在这里插入图片描述

有了“新增”,下一步就是把编辑做成真正可靠的能力:

  • 从详情页进入编辑页
  • 自动把原记录的数据预填到表单
  • 保存后更新同一条记录(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(...) 构建一个新对象,但把 idvehicleId 沿用旧记录:

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 做一次性预填
  • 不改变主键/归属idvehicleId 必须沿用旧记录
  • 不在页面写太多业务规则:页面做输入与反馈,规则尽量放在 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);

它带来三个实际收益:

  1. 输入体验稳定:用户不会看到 12.012.3333333 这种“看着像 bug”的数字。
  2. 减少无意义改动:如果用户只是打开编辑页看一眼再保存,格式化能让保存前后文本更一致。
  3. 降低浮点误差影响:虽然最终保存还是解析成 double,但至少展示层不会扩散误差。

如果你后续要做“输入时限制小数位数”,那属于更强的输入约束(例如加 inputFormatters),可以再作为后续文章的增强点。


17. 小结

编辑能力看起来只是“填一下旧值”,但真正的核心在于:

  • 通过 arguments 传递对象
  • 预填只做一次,避免覆盖用户输入
  • 保存时 id 不变,保证更新的是同一条记录

下一篇我们会把“记录详情”讲透:详情页如何展示字段、如何删除并刷新、以及分享文本怎么拼。


文章底部添加社区引导:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐