flutter_for_openharmonyFillUp -油耗追踪器app实战+编辑加油实现
不覆盖用户正在输入的内容:用做一次性预填不改变主键/归属idvehicleId必须沿用旧记录不在页面写太多业务规则:页面做输入与反馈,规则尽量放在 controller/model提示“修改未保存”:当用户改了字段直接返回时,弹窗提示里程递增校验(可选):只对同车记录做约束,允许补录历史。

有了“新增”,下一步就是把编辑做成真正可靠的能力:
- 从详情页进入编辑页
- 自动把原记录的数据预填到表单
- 保存后更新同一条记录(
id不变)
说明:本文所有代码均来自项目文件 lib/app/fillup_app.dart,我会用小片段把关键点拆开讲。
1. 编辑入口:从记录详情页点击“编辑”
编辑入口不应该藏得太深。详情页右上角我放了一个编辑按钮,点击后带上当前 FillUpEntry:
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => Get.toNamed('/fillup/edit', arguments: e),
),
这里把 e 作为 arguments 传过去,是我比较推荐的一种方式:
- 编辑页拿到的是“完整对象”,不用二次查库
- 预填表单时字段齐全,逻辑简单
2. 编辑页如何识别“编辑模式”:在 initState 读 Get.arguments
FillUpEditorPage 同时承担新增和编辑,所以它需要在启动时判断“当前有没有传入要编辑的记录”。
void initState() {
super.initState();
final arg = Get.arguments;
if (arg is FillUpEntry) {
_editing = arg;
}
}
这一段虽然短,但它决定了后面所有行为:
_editing != null:进入编辑模式_editing == null:进入新增模式
我会把“模式判断”尽量集中在 initState,避免在 build 的各处散落判断,后期更好维护。
3. 预填表单:只做一次,避免 build 多次导致重复覆盖
Flutter 的 build() 可能会被调用多次。如果每次 build 都把 controller 的 text 重置,会出现两个问题:
- 用户刚改了一半,页面 rebuild 又把输入覆盖回旧值
- 输入体验非常糟糕
所以我在代码里加了 _didInitFromArgs 作为“一次性开关”:
if (!_didInitFromArgs && _editing != null) {
final e = _editing!;
_date = DateTime.fromMillisecondsSinceEpoch(e.dateMs);
_isFullTank = e.isFullTank;
odometerCtl.text = e.odometer.toStringAsFixed(1);
litersCtl.text = e.liters.toStringAsFixed(2);
priceCtl.text = e.pricePerLiter.toStringAsFixed(2);
stationCtl.text = e.station;
noteCtl.text = e.note;
_didInitFromArgs = true;
}
这段预填做了两类数据:
- 控制器输入框:里程/油量/单价/加油站/备注
- 页面状态:日期、是否加满
你会发现我把数值字段用
toStringAsFixed()格式化了一下,这是为了避免出现诸如12.0/12.0000001这种看起来不干净的输入体验。
4. 标题与保存按钮:根据 isEdit 显示不同文本
编辑模式下,用户需要明确知道自己在“改一条旧记录”,而不是“新增”。
final isEdit = _editing != null;
return Scaffold(
appBar: AppBar(title: Text(isEdit ? '编辑加油' : '新增加油')),
// ...
);
保存按钮同样复用一套逻辑,但文案更明确:
label: Text(isEdit ? '保存修改' : '保存'),
这种小差异能显著降低误操作。
5. 保存时最关键的原则:更新同一条记录(id 不变)
编辑保存的本质是:
- 同一条数据的主键不变(
id不变) - 其他字段更新
在代码里体现为:编辑时直接用 FillUpEntry(...) 构建一个新对象,但把 id 和 vehicleId 沿用旧记录:
final entry = isEdit
? FillUpEntry(
id: _editing!.id,
vehicleId: vehicleId,
dateMs: _date.millisecondsSinceEpoch,
odometer: odo,
liters: liters,
pricePerLiter: price,
totalCost: liters * price,
isFullTank: _isFullTank,
station: stationCtl.text.trim(),
note: noteCtl.text.trim(),
)
: FillUpEntry.create(
vehicleId: vehicleId,
date: _date,
odometer: odo,
liters: liters,
pricePerLiter: price,
isFullTank: _isFullTank,
station: stationCtl.text.trim(),
note: noteCtl.text.trim(),
);
await fillups.addOrUpdate(entry);
Get.back();
我喜欢这种“构建一个新对象再 upsert”的写法:
- 不会直接修改旧对象(减少副作用)
- 逻辑统一:新增/编辑最终都走
addOrUpdate()
6. controller 层为什么用 addOrUpdate:让页面不用关心“插入还是更新”
页面只负责把输入转换成 FillUpEntry,至于是 insert 还是 update,交给 controller:
Future<void> addOrUpdate(FillUpEntry entry) async {
await FillUpDb.instance.upsertFillUp(entry);
await refreshAll(vehicleId: entry.vehicleId);
}
这段代码的关键是 保存后刷新:
- 你从编辑页
Get.back()回列表时,列表已经是最新数据 - 统计页(依赖同一个 controller)也能在下一次
Obx刷新里拿到新数据
7. 编辑页为什么一定要正确释放 TextEditingController:避免长期使用后的内存问题
编辑页是典型的“高频进入、短暂停留、迅速返回”的页面。你可能不会立刻感受到资源泄漏,但长期使用后,重复创建 controller 不释放,会造成不必要的内存占用。
项目里在 _FillUpEditorPageState.dispose() 里把所有输入 controller 都释放掉:
void dispose() {
odometerCtl.dispose();
litersCtl.dispose();
priceCtl.dispose();
stationCtl.dispose();
noteCtl.dispose();
super.dispose();
}
我会把这种“必做项”当成编辑页的一部分,而不是等到出现问题再补。
你也可以把它理解成一个习惯:只要用了
TextEditingController,就必须在dispose()里配对释放。
8. 日期编辑的体验:为什么我用 ListTile 而不是 TextField
编辑日期时,最怕用户误输入(例如 2026-13-40),所以我不让用户手输日期,而是复用新增页的日期选择器。
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_month),
title: const Text('日期'),
subtitle: Text(DateFormat('yyyy-MM-dd').format(_date)),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
initialDate: _date,
);
if (picked != null) setState(() => _date = picked);
},
),
这里的要点是:
subtitle永远展示当前值,编辑前用户就能看到“我现在要改的是哪一天”。- 选择器返回 null 表示取消,所以
picked != null才更新。
如果你后面想加“快速选择今天/昨天”,也可以在这个 ListTile 的 trailing 再扩展一个小菜单。
9. 数字输入与解析:为什么我不用 Form/validator 也能写得稳
编辑页里里程、油量、单价都是 TextField,最终要解析成 double。
项目里用的是一种比较朴素但稳定的写法:
final odo = double.tryParse(odometerCtl.text.trim()) ?? 0;
final liters = double.tryParse(litersCtl.text.trim()) ?? 0;
final price = double.tryParse(priceCtl.text.trim()) ?? 0;
if (odo <= 0 || liters <= 0 || price <= 0) {
Get.snackbar('数据不完整', '里程/油量/单价必须大于 0', snackPosition: SnackPosition.BOTTOM);
return;
}
我选择它的原因:
tryParse对非法输入很安全,不会抛异常- 把错误提示做成统一 snackbar,用户能立刻知道哪里不对
实战建议:如果你后续要做更严格的校验(例如里程必须递增、油量不能过大),我更推荐把逻辑放到 controller 层,因为它可以拿到该车的历史记录作为参照。
10. vehicleId 在编辑时为什么必须沿用旧值:避免“误改到另一辆车”
编辑时有一个潜在问题:用户可能在你打开编辑页后,又在别处把“当前车辆”切换了。
如果编辑页直接使用 vehicles.activeVehicle?.id,就会出现“把 A 车的记录保存到 B 车”这种灾难性 bug。
所以代码里在编辑模式下明确选择 _editing!.vehicleId:
final vehicleId = isEdit ? _editing!.vehicleId : vehicles.activeVehicle?.id;
if (vehicleId == null) {
Get.snackbar('请先添加车辆', '没有车辆无法记录加油', snackPosition: SnackPosition.BOTTOM);
return;
}
这个判断的含义是:
- 编辑:记录属于哪辆车是事实,不能变
- 新增:才取“当前车辆”作为归属
11. totalCost 在编辑时为什么重新计算:让 UI 输入和存储保持一致
编辑页允许用户修改油量/单价,所以总价也必须同步变化。
项目里在编辑路径上直接重算:
totalCost: liters * price,
这和我们的 FillUpEntry.create()(新增路径)是一致的:
- “新增”走
create()统一计算 - “编辑”直接根据当前输入计算
如果你未来想把“总价可编辑”也开放出来(例如用户只记了总价),那就应该把
totalCost的来源从“计算结果”升级为“可输入字段”。
12. 为什么保存后列表会自动刷新:refreshAll(vehicleId) 把刷新粒度卡在“单车”
编辑保存的最后一步是:
await fillups.addOrUpdate(entry);
Get.back();
而 controller 的实现是:
Future<void> addOrUpdate(FillUpEntry entry) async {
await FillUpDb.instance.upsertFillUp(entry);
await refreshAll(vehicleId: entry.vehicleId);
}
我非常强调这一点:刷新粒度是按 vehicleId 的。
- 你编辑 A 车的记录,只需要刷新 A 车的列表
- 数据量更大时,刷新速度更快
同时,fillups.value = list 会触发依赖它的 Obx rebuild,所以记录列表页和统计页都能拿到更新后的数据。
13. 编辑页的边界与可维护性建议(实践总结)
这一页看起来是“把旧值填进去再保存”,但真正要写得稳定,我一般会盯住三条底线:
- 不覆盖用户正在输入的内容:用
_didInitFromArgs做一次性预填 - 不改变主键/归属:
id、vehicleId必须沿用旧记录 - 不在页面写太多业务规则:页面做输入与反馈,规则尽量放在 controller/model
后面如果你要进一步增强编辑页,我建议优先做这两项:
- 提示“修改未保存”:当用户改了字段直接返回时,弹窗提示
- 里程递增校验(可选):只对同车记录做约束,允许补录历史
14. 可选字段的编辑体验:station/note 为什么不做强校验
编辑页里“加油站/备注”是可选的。
对应的输入框实现非常直接:
TextField(
controller: stationCtl,
decoration: const InputDecoration(labelText: '加油站(可选)'),
),
TextField(
controller: noteCtl,
decoration: const InputDecoration(labelText: '备注(可选)'),
),
我不对它们做必填校验,是因为它们属于“增强信息”,而不是“统计必需信息”。
不过有一个细节很关键:保存时我会对输入做 trim():
station: stationCtl.text.trim(),
note: noteCtl.text.trim(),
这样能避免用户无意输入的空格导致:
- 列表/详情页判断
isNotEmpty时出现“看似为空但其实有空格”的异常 - 导出 CSV 时字段莫名其妙多出空白
15. “加满开关”在编辑时更重要:它会影响统计分段
很多用户第一次使用会出现这种情况:
- 新增时忘记打开“加满”
- 后面发现统计图表算不出来
- 回到记录里,需要把某几条记录改成“加满”
所以编辑页必须允许用户修改该状态,并且这个开关要足够显眼:
SwitchListTile(
value: _isFullTank,
onChanged: (v) => setState(() => _isFullTank = v),
title: const Text('本次加满油箱'),
subtitle: const Text('仅“加满”的两次记录之间参与油耗计算'),
),
这里的关键不是 Switch 本身,而是 subtitle 的解释:它能把“为什么我要打开它”讲清楚。
当你在编辑页修改这个开关并保存后:
- 列表页会立刻把 tag 从“未加满”变成“加满”(或反过来)
- 统计页的“有效段数/油耗趋势/每公里成本”会随之变化
16. 格式化策略:toStringAsFixed 的作用不只是好看
你会在预填时看到这样的格式化:
odometerCtl.text = e.odometer.toStringAsFixed(1);
litersCtl.text = e.liters.toStringAsFixed(2);
priceCtl.text = e.pricePerLiter.toStringAsFixed(2);
它带来三个实际收益:
- 输入体验稳定:用户不会看到
12.0、12.3333333这种“看着像 bug”的数字。 - 减少无意义改动:如果用户只是打开编辑页看一眼再保存,格式化能让保存前后文本更一致。
- 降低浮点误差影响:虽然最终保存还是解析成 double,但至少展示层不会扩散误差。
如果你后续要做“输入时限制小数位数”,那属于更强的输入约束(例如加 inputFormatters),可以再作为后续文章的增强点。
17. 小结
编辑能力看起来只是“填一下旧值”,但真正的核心在于:
- 通过
arguments传递对象 - 预填只做一次,避免覆盖用户输入
- 保存时
id不变,保证更新的是同一条记录
下一篇我们会把“记录详情”讲透:详情页如何展示字段、如何删除并刷新、以及分享文本怎么拼。
文章底部添加社区引导:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)