flutter_for_openharmonyFillUp -油耗追踪器app实战+车辆管理实现
到目前为止,你已经能新增一条加油记录。但只要你做过任何“和车相关”的工具类 App,就一定会遇到一个问题:如果没有“车辆”这个主实体,一切记录都会失去归属。这一篇我们把做成一个可长期使用的模块。说明:本文所有代码片段都来自项目文件。我会把代码拆成小块,并在每块后面紧跟解释。

到目前为止,你已经能新增一条加油记录。
但只要你做过任何“和车相关”的工具类 App,就一定会遇到一个问题:
如果没有“车辆”这个主实体,一切记录都会失去归属。
这一篇我们把 车辆管理 做成一个可长期使用的模块。
它不是单独的一页 UI,它应该同时解决:
- 数据层:车的数据如何存,如何删,怎么和加油记录建立关系。
- 状态层:当前选择哪辆车,切换后其他页面如何自动刷新。
- 页面层:列表、空态、添加、详情、删除这些高频操作。
说明:本文所有代码片段都来自项目文件 lib/app/fillup_app.dart。
我会把代码拆成小块,并在每块后面紧跟解释。
1. 路由层:车辆相关页面必须是一组完整闭环
车辆管理至少需要 3 个页面:
- 车辆列表:作为“管理入口”。
- 添加车辆:第一次使用时必须能快速创建。
- 车辆详情:承载“下钻动作”,把车辆当作一个实体来展示。
项目里这些页面的路由在 FillUpApp.getPages 里定义:
getPages: <GetPage<dynamic>>[
GetPage(name: '/', page: () => const FillUpShell()),
GetPage(name: '/vehicles', page: () => const VehiclesPage()),
GetPage(name: '/vehicle/add', page: () => const VehicleEditorPage()),
GetPage(name: '/vehicle/detail', page: () => const VehicleDetailPage()),
GetPage(name: '/fillups', page: () => const FillUpsPage()),
// ...
],
这里我有一个很明确的命名习惯:
- 管理列表用复数:
/vehicles - 单体详情用单数:
/vehicle/detail
它不是为了“好看”,而是为了后续扩展时更不容易写错:
- 你要加编辑页时,路径很自然就是
/vehicle/edit。 - 你要加“车辆选择器”时,也更容易把它当作
/vehicles的子能力。
2. 依赖注入:VehiclesController 必须在启动时就存在
车辆是全局状态。
当你在车辆管理页新增一辆车:
- 记录页应该立刻从“未选择车辆”变成可用。
- 新增加油页应该能立刻读到
activeVehicle。
所以我在 FillUpBindings 里注入 VehiclesController:
class FillUpBindings extends Bindings {
void dependencies() {
Get.put(FillUpShellController());
Get.put(FillUpSettingsController());
Get.put(VehiclesController());
Get.put(FillUpsController());
}
}
这段代码决定了一件事:
- 不管你从哪个页面进入车辆管理,拿到的都是同一个 controller。
它是 GetX 项目里非常关键的工程化细节。
3. Vehicle 模型:最小字段集 + create/fromMap/toMap 三件套
车辆模型在 fillup_app.dart 中定义为:
class Vehicle {
final String id;
final String name;
final String plateNo;
final int createdAtMs;
const Vehicle({
required this.id,
required this.name,
required this.plateNo,
required this.createdAtMs,
});
factory Vehicle.create({required String name, required String plateNo}) {
return Vehicle(
id: const Uuid().v4(),
name: name,
plateNo: plateNo,
createdAtMs: DateTime.now().millisecondsSinceEpoch,
);
}
factory Vehicle.fromMap(Map<String, Object?> map) {
return Vehicle(
id: (map['id'] as String?) ?? '',
name: (map['name'] as String?) ?? '',
plateNo: (map['plate_no'] as String?) ?? '',
createdAtMs: (map['created_at_ms'] as int?) ?? 0,
);
}
Map<String, Object?> toMap() {
return <String, Object?>{
'id': id,
'name': name,
'plate_no': plateNo,
'created_at_ms': createdAtMs,
};
}
}
这里我只强调两个工程要点:
Vehicle.create()统一生成id/createdAtMs。fromMap()对缺字段做兜底。
4. vehicles 表结构:字段名要和 toMap/fromMap 严格一致
车辆数据使用 sqflite 落库。
表结构在 FillUpDb._open() 的 onCreate 里创建:
await db.execute(
'CREATE TABLE IF NOT EXISTS vehicles ('
'id TEXT PRIMARY KEY, '
'name TEXT, '
'plate_no TEXT, '
'created_at_ms INTEGER'
')',
);
你会发现:
- 数据库列名
plate_no对应toMap()的 key。 created_at_ms对应createdAtMs。
我建议你把这套命名规则固定下来:
- Dart 字段用 camelCase。
- SQLite 列名用 snake_case。
- toMap/fromMap 是唯一的映射层。
5. listVehicles:创建时间倒序,符合“最近添加更重要”的直觉
车辆列表中,用户更可能管理“最近新增的车”。
所以我让查询默认倒序:
Future<List<Vehicle>> listVehicles() async {
final db = await _open();
final rows = await db.query('vehicles', orderBy: 'created_at_ms DESC');
return rows.map(Vehicle.fromMap).toList();
}
这个排序策略带来的体验是:
- 当用户添加第二辆车时,新车会出现在顶部。
- 用户不会因为列表很长而找不到刚加的车。
6. upsertVehicle:用 replace 统一新增/编辑写入路径
车辆保存我采用了 upsert 思路:
Future<void> upsertVehicle(Vehicle vehicle) async {
final db = await _open();
await db.insert('vehicles', vehicle.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
}
ConflictAlgorithm.replace 的意义是:
- 如果 id 已存在,相当于更新。
- 如果 id 不存在,相当于插入。
这样未来你做“编辑车辆信息”时,不用再补一套 update SQL。
7. deleteVehicle:删除车辆时顺带清理该车的 fillups
车辆是主实体。
记录是从属数据。
如果你只删 vehicles 表的行,不删 fillups 表的记录,就会留下孤儿数据。
它们在 UI 上可能看不见,但会污染统计、导出、容量。
所以数据库层做了级联清理:
Future<void> deleteVehicle(String vehicleId) async {
final db = await _open();
await db.delete('vehicles', where: 'id = ?', whereArgs: <Object?>[vehicleId]);
await db.delete('fillups', where: 'vehicle_id = ?', whereArgs: <Object?>[vehicleId]);
}
这里我希望你记住一个经验:
- “级联清理”最好放在 DB 层,而不是页面层。
因为页面层太容易漏掉某个入口。
而 DB 层是所有入口共享的唯一真实写入路径。
8. VehiclesController:车辆列表 + 当前选中车辆
controller 中有两份状态:
vehicles:全部车辆列表。activeVehicleId:当前选中车辆。
真实实现是:
Future<void> refreshAll() async {
final list = await FillUpDb.instance.listVehicles();
vehicles.value = list;
if (activeVehicleId.value == null && list.isNotEmpty) {
activeVehicleId.value = list.first.id;
}
}
Vehicle? get activeVehicle {
final id = activeVehicleId.value;
if (id == null) return null;
return vehicles.firstWhereOrNull((v) => v.id == id);
}
Future<void> remove(String vehicleId) async {
await FillUpDb.instance.deleteVehicle(vehicleId);
if (activeVehicleId.value == vehicleId) {
activeVehicleId.value = null;
}
await refreshAll();
}
这段 controller 我盯住三件事就够了:
refreshAll()拉取车辆列表后,必要时自动选中第一辆车。activeVehicle把“按 id 找车”的细节封装起来。remove()删除当前车时先把activeVehicleId置空,再刷新。
9. VehiclesPage:列表/空态/删除入口
车辆列表页的核心结构是:
Obx监听ctl.vehicles- 空列表时展示
_EmptyState - 有数据时用
ListView.separated
真实代码里列表项用了 InkWell + Ink 来做更一致的点击反馈(以下为节选):
body: Obx(() {
final list = ctl.vehicles;
if (list.isEmpty) {
return const _EmptyState(
title: '暂无车辆',
subtitle: '点击右下角 + 添加第一辆车',
icon: Icons.directions_car,
);
}
return ListView.separated(
padding: EdgeInsets.all(16.w),
itemCount: list.length,
separatorBuilder: (_, __) => SizedBox(height: 10.h),
itemBuilder: (context, i) {
final v = list[i];
return InkWell(
borderRadius: BorderRadius.circular(16.r),
onTap: () => Get.toNamed('/vehicle/detail', arguments: v),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.4)),
),
child: ListTile(
leading: const Icon(Icons.directions_car),
title: Text(v.name),
subtitle: Text(v.plateNo),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => ctl.remove(v.id),
),
),
),
);
},
);
}),
这里 UI 只抓两个体验点:
- 空态给出明确行动提示(去添加第一辆车)。
- 列表项整体可点,点击反馈一致,进入详情无心理成本。
10. 删除按钮很短,但删除链路要“从 DB 到状态”都兜住
列表里删除按钮的触发只有一行:
onPressed: () => ctl.remove(v.id),
真正的可靠性来自 controller:
- DB 层会删除 vehicles 表。
- DB 层会顺带删除 fillups 表中该车的记录。
- controller 在必要时把
activeVehicleId置空,并刷新列表。
这就是为什么我说:
- 页面越薄,越不容易出错。
补充建议:目前这里没有二次确认弹窗。
如果你担心误删,可以在 VehiclesPage 里加一个 showDialog<bool>,再去调用 ctl.remove()。
但无论加不加确认,级联删除都应该由 DB 层兜底。
11. VehicleEditorPage:添加车辆表单要做到“输入最少、保存最稳”
添加车辆页面本质上是一个小表单。
项目里的实现非常直接:
onPressed: () async {
final name = nameCtl.text.trim();
final plate = plateCtl.text.trim();
if (name.isEmpty) {
Get.snackbar('请输入名称', '车辆名称不能为空', snackPosition: SnackPosition.BOTTOM);
return;
}
final v = Vehicle.create(name: name, plateNo: plate);
await vehicles.addOrUpdate(v);
Get.back();
},
这里我认为“工具类应用”的表单要满足三个点:
- 必填尽量少:我只强制 name。
- 保存反馈明确:name 为空直接 snackbar。
- 保存后马上返回:减少用户路径。
另外别忽略 dispose():只要你用了 TextEditingController,就应该释放。
12. 新增车辆后为什么能立刻在车辆列表看到:addOrUpdate 内部 refreshAll
页面里保存后只有一句:
await vehicles.addOrUpdate(v);
controller 的实现是:
Future<void> addOrUpdate(Vehicle v) async {
await FillUpDb.instance.upsertVehicle(v);
await refreshAll();
}
返回 VehiclesPage 后,Obx 会因为 vehicles.value 更新而自动 rebuild,你不需要再额外做“手动刷新列表”的动作。
13. VehicleDetailPage:车辆详情不是“摆设”,它是下钻入口
我把车辆详情页当作“以车为中心的功能入口”。
真实代码如下:
final vehicle = Get.arguments as Vehicle?;
if (vehicle == null) {
return const Scaffold(body: Center(child: Text('无车辆数据')));
}
FilledButton.tonalIcon(
onPressed: () {
Get.toNamed('/fillups', arguments: vehicle);
},
icon: const Icon(Icons.local_gas_station),
label: const Text('查看该车加油记录'),
),
FilledButton.tonalIcon(
onPressed: () {
Get.toNamed('/feature/maintenance_plan', arguments: vehicle);
},
icon: const Icon(Icons.build),
label: const Text('维护计划'),
),
14. 最终小结:车辆管理模块的“闭环标准”
这一篇我们把车辆管理做成了一个闭环:
- 路由层:
/vehicles、/vehicle/add、/vehicle/detail。 - 数据层:vehicles 表 + upsert/list/delete + 删除时清理 fillups。
- 状态层:
VehiclesController统一维护车辆列表与当前车辆。 - 页面层:列表空态、添加表单、详情下钻。
下一篇我们继续做第 07 篇:添加车辆的交互细节与可扩展点。
文章底部添加社区引导:https://openharmonycrossplatform.csdn.net
更多推荐

所有评论(0)