☰
Flutter for OpenHarmony实战:添加交易记录模块设计与数据持久化避坑指南
2026/10/17 15:24:26 网站建设 项目流程

我在实际开发中踩过不少OpenHarmony的坑,这次借着“Flutter for OpenHarmony 个人理财管理App实战 - 添加交易记录”这个模块,把完整的设计思路、代码实现和排查过程整理出来。这个功能虽然看着简单,但它牵动数据层、页面状态、分类体系、首页联动,是整个App最核心的入口。做对了,后面所有统计、图表、账单导出都顺理成章;做糙了,越往后期补越难受。

我默认你已经能跑通Flutter for OpenHarmony的基础工程,如果环境还在折腾阶段,建议先停下来,把模拟器或真机上的Hello World跑顺了再往下做。好,直接进入正题。

1. 项目定位:为什么先把“添加交易记录”这个模块单独拎出来做

1.1 记账App里的业务闭环核心

个人理财管理App通常包含账本列表、交易流水、分类统计、图表报表、预算提醒几个大块。单看数据流转,所有上游功能的源头都是“交易记录”。一条记录落库后,首页的收支汇总要变、分类占比要变、月度趋势要变,甚至预算余量也要跟着收缩。可以说,交易记录的写入是全App一切数据演变的起点。

但是很多开发者做这类项目时容易犯一个错:一上来就画首页、做图表、搞预算,把添加记录的入口做成一个简陋的弹窗。结果就是图表数据全是假数据,联调时来回改接口字段,最后回到源头补业务逻辑,推倒重来。我身边就有个做类似练手项目的朋友,整整耽误了一个周末。

所以我把“添加交易记录”当作一个独立的里程碑,而不是页面清单里的一项。它需要做到数据模型稳定、字段校验完整、页面跳转闭环、落库链路可靠,并且给后续编辑、删除、统计预留好扩展点。这些全都满足后,再做上层展示才有安全感。

1.2 选型理由:为什么是Flutter for OpenHarmony,而不是ArkUI或纯原生

OpenHarmony本身提供了ArkUI声明式开发框架,如果从零开始并且只面向OpenHarmony设备,理论上说用ArkUI更“正统”。但实际选型时要结合团队背景和跨端诉求来考虑。

我这里选择Flutter for OpenHarmony,核心原因有这么几点。

第一,团队已有的Dart/Flutter积累可以直接复用。组件思想、状态管理、异步模式、页面路由都是现成的,不需要整个团队重新学一套ArkTS语法和状态管理机制。

第二,Flutter的渲染层在OpenHarmony上的适配度已经比较可观,Canvas、文本排版、基础组件大部分能正常工作。个人理财这种中重度表单界面,用Flutter原生组件就能覆盖绝大多数场景,不需要频繁下沉到平台侧写原生代码。

第三,后续如果要把同一套代码打到Android、iOS、Web上,Flutter + OpenHarmony这套技术栈的复用性明显更好。ArkUI的产物主要面向OpenHarmony生态,跨端这一项就输掉了。

当然,选型不是没有代价。Flutter for OpenHarmony在插件生态上仍然偏薄,社区第三方插件很多没有直接适配,需要自己写Platform Channel桥接或者改用平台兼容方案。这点我在后面避坑部分会展开讲。

1.3 模块边界:添加记录这步功能到底包含什么

明确功能边界很重要。我设计“添加交易记录”模块时,划定了一个清晰的闭环:

  • 支持记录类型切换:支出、收入,默认支出
  • 金额输入:纯数字、校验非空非零
  • 分类选择:根据类型动态加载支出分类或收入分类
  • 记账时间:默认当前时间,支持修改日期和时间
  • 账户:预留账户字段,当前项目先给默认账户
  • 备注:可选填,限制长度
  • 保存:校验通过后写入数据库,返回上页并通知刷新

我不打算在这个模块里做“多笔记录批量添加”“周期记账”“OCR识别记账”这些扩展功能。它们价值确实存在,但会快速推高复杂度,挤压核心链路的质量。先把基本的立住,后续迭代再逐步加,这样每一步都可交付、可验证,心态也稳。

2. 数据模型先行:交易记录的表结构与本地存储方案

2.1 字段设计:表里放什么,背后是记账业务的取舍

添加交易记录的第一件事不是写界面,而是设计数据表。字段设计错了,后面每个功能都要跟着返工。

我个人最终定下的字段如下:

字段名类型说明备注
idINTEGER PRIMARY KEY AUTOINCREMENT主键自增
typeINTEGER交易类型0支出 1收入
amountREAL金额单位:元,保留两位
category_idTEXT分类ID关联分类表
account_idINTEGER账户ID预留外键
remarkTEXT备注可为空
trade_timeINTEGER实际记账时间毫秒时间戳
created_atINTEGER创建时间毫秒时间戳
updated_atINTEGER更新时间毫秒时间戳

有几个字段需要解释一下为什么这么设计。

amount用REAL而不是INTEGER。有人习惯用“分”做整数存储来避免浮点精度问题,这在严谨的金融App里是对的。但个人记账场景,流水金额一般只有两位小数,SQLite的REAL足够满足展示需求,而用整数存“分”还要在UI层来回做转换,容易出低级错误。所以我选择用元为单位的浮点,在输入层严格限制小数位数不超过两位。

trade_time和created_at分离。会有用户补录几天前的账,比如昨天出去吃了一顿饭,今天才想起来记。trade_time就是业务上的“交易发生时间”,created_at是这条数据真正写入系统的时间。两个字段分开,后面做月度统计才能按trade_time聚合,否则补录数据会把统计搞乱。

category_id用TEXT。因为我计划分类管理用字符串标识符,比整数主键更容易做本地化和后续同步,也方便导出时保持分类路径完整。

2.2 数据库选型:OpenHarmony上用本地SQLite还是简易存储

个人理财App的数据存储,首先是本地优先,因为交易数据属于强隐私数据,不上云是常态。本地存储可选的大致有几类:

  • shared_preferences键值对
  • sqflite提供的SQLite能力
  • drift这类基于sqflite的ORM框架
  • 其他NoSQL方案

我最终选的是sqflite。原因很直接:它是SQLite的Flutter封装,在OpenHarmony上的Flutter适配版已经有人在维护基础能力,建库、建表、增删改查这些核心操作是可靠的。键值对存储只适合放配置项,不适合放交易流水这种需要聚合查询的数据。而drift工具链虽然好,但在OpenHarmony插件兼容性上多一层风险,越晚踩雷越好。

实际使用中,我通过sqflite_common_ffi或平台侧适配来连接数据库。下面这段是数据库初始化工具类的写法,我把创建表和数据库升级都放到一起管理:

import 'package:sqflite/sqflite.dart'; class DBHelper { static const _dbName = 'personal_finance.db'; static const _dbVersion = 1; static Database? _db; static Future<Database> get instance async { _db ??= await _init(); return _db!; } static Future<Database> _init() async { String path = await getDatabasesPath(); return openDatabase( '$path/$_dbName', version: _dbVersion, onCreate: (db, version) async { await db.execute(''' CREATE TABLE transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER NOT NULL, amount REAL NOT NULL, category_id TEXT NOT NULL, account_id INTEGER DEFAULT 0, remark TEXT, trade_time INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) '''); }, onUpgrade: (db, oldVersion, newVersion) async { // 后续版本迁移字段时在这里处理 }, ); } }

注意getDatabasesPath()在OpenHarmony设备上可能返回的具体路径和Android略有差异,调试时要打印出来确认一下,我心里有个底,后面权限那部分也会再提。

2.3 实体类、DAO和对外接口:让业务代码不直接碰SQL

单纯有了表还不够,UI层最好别直接去拼SQL字符串。我习惯做一个TransactionModel实体类,再包一个TransactionDao,页面只跟DAO打交道。这样可以随时替换底层存储实现,也方便写单测。

enum TransactionType { expense, income } class TransactionModel { final int? id; final TransactionType type; final double amount; final String categoryId; final int accountId; final String remark; final DateTime tradeTime; final DateTime createdAt; final DateTime updatedAt; TransactionModel({ this.id, required this.type, required this.amount, required this.categoryId, this.accountId = 0, this.remark = '', required this.tradeTime, required this.createdAt, required this.updatedAt, }); Map<String, Object?> toMap() { return { 'type': type == TransactionType.expense ? 0 : 1, 'amount': amount, 'category_id': categoryId, 'account_id': accountId, 'remark': remark, 'trade_time': tradeTime.millisecondsSinceEpoch, 'created_at': createdAt.millisecondsSinceEpoch, 'updated_at': updatedAt.millisecondsSinceEpoch, }; } }

DAO层我做了一个insert方法,同时支持事后编辑时的update:

import 'package:sqflite/sqflite.dart'; class TransactionDao { final Database db; TransactionDao(this.db); Future<int> insert(TransactionModel model) async { return db.insert('transactions', model.toMap()); } Future<int> update(TransactionModel model) async { if (model.id == null) { throw ArgumentError('id 不能为空'); } return db.update( 'transactions', model.toMap(), where: 'id = ?', whereArgs: [model.id], conflictAlgorithm: ConflictAlgorithm.replace, ); } Future<List<Map<String, Object?>>> queryByTimeRange( int startMs, int endMs, ) async { return db.query( 'transactions', where: 'trade_time >= ? AND trade_time <= ?', whereArgs: [startMs, endMs], orderBy: 'trade_time DESC', ); } }

这里先不多写统计SQL,等做到首页图表再扩展。但从DAO接口可以看出,我把“时间查询”作为最基础的能力预留了,这个字段设计带来的好处,到后面做月度报表时你会发现特别舒服。

3. 添加交易记录的页面实现:从表单布局到保存链路

3.1 页面整体结构:用一块画布装下所有录入区

我个人比较推荐单页表单+底部保存按钮的实现方式,而不是用弹窗。因为交易记录涉及的交互控件有类型切换、金额输入、分类选择、时间调整、备注录入,弹窗承载这些内容会显得非常局促,而且键盘弹起后遮挡很严重。用一个独立页面,上下滚动,体验会好很多。

页面结构大致分为三段:

  • 顶部:自定义导航栏,左侧返回,中间标题“记一笔”,右侧留空或放“存为草稿”(这个阶段先不做草稿)。
  • 中间:表单主体,滚动的Column或ListView。
  • 底部:使用SafeArea包住的保存按钮,避免全面屏手势区误触。

我用Scaffold + AppBar + body + bottomNavigationBar来实现,直接把保存按钮放在bottomNavigationBar槽位,它天然固定在底部,不随表单滚动。

3.2 类型切换和金额输入:最容易被低估的两个控件

收入/支出切换我初期用了两个Tab式的按钮组,点击切换时顺带把分类列表换掉。这里有一个小细节:如果当前选择的分类在切换后不匹配,就直接置空,让用户重新选,避免出现“支出记录选了加油,切到收入后还在加油”的荒谬状态。

金额输入在Flutter里可以用TextField,但需要配置成只有数字键盘、限制小数两位。然而纯用TextField又会面临一个痛点:用户想输入“50.”中间带小数点时,校验器容易误判。我建议不要把所有逻辑都塞给inputFormatters,而是先宽松输入,在保存环节做一次严格数值解析。

我用的金额处理思路:

  • keyboardType设置为TextInputType.numberWithOptions(decimal: true)
  • inputFormatters用自定义的正则保证最多一个小数点,小数位不超过两位
  • 保存时用double.tryParse,命中空值或null就提示“请输入金额”

自定义formatter和校验逻辑后面统一放到3.4节里,这里先不铺开。

3.3 分类选择:弹出底部面板还是新开页面

分类选择这个交互,我试过两种方式。

第一种是底部弹出一个BottomSheet,里面用GridView展示分类图标和名称,顶部加一个搜索框。好处是操作路径短,手指不用大幅度移动。坏处是分类数量多时弹层加载稍慢,而且BottomSheet内嵌滚动时和主页面滚动容易产生手势冲突。

第二种是跳转到一个新的分类选择页,选择完返回值再更新。页面信息密度比弹窗高,也能容纳预设分类和自定义分类两个Tab。缺点是页面跳转多了一步。

最终我选了新开分类页的做法。因为理财App后期肯定要支持分类编辑、图标管理、排序拖拽,独立页面在这些扩展上明显更干净。分类页通过Navigator.pop返回选中的CategoryModel,表单页在then回调里更新选中状态。

分类数据结构本身很简单:id、名称、图标路径、是否是默认分类。这里先不建表,用一组静态内存数据撑住首版,等后面做分类管理再单独建表。

3.4 保存前的校验逻辑:宁可严一点,别让脏数据进库

代码可以写得很快,但校验逻辑必须逐项过。我的校验顺序如下:

  1. 金额字段非空,且解析为double后必须大于0
  2. 分类必须已选中
  3. 记账时间不能晚于当前时间超过1分钟(防止误把未来时间当作交易时间,但给足时钟误差容错)
  4. 备注长度限制在100字以内,超出给提示
  5. 以上通过后,拼装TransactionModel并调用DAO.insert

校验失败的反馈,我用的是ScaffoldMessenger弹SnackBar,而不是每个字段下面都放errorText。因为页面本身高度有限,多个错误同时触发时,下方重点显示第一个错误即可,体验比一片红字清爽。

保存成功后,我做两件事:弹出短暂的成功提示,然后Navigator.pop回到上一页,上一页通过异步等待拿到“是否新增成功”的结果,决定要不要刷新列表和汇总数据。

Future<void> _handleSave() async { final amountText = _amountController.text.trim(); if (amountText.isEmpty) { _showTip('请输入金额'); return; } final amount = double.tryParse(amountText); if (amount == null || amount <= 0) { _showTip('金额格式不正确'); return; } if (_selectedCategory == null) { _showTip('请选择分类'); return; } final now = DateTime.now(); final tradeTime = _selectedTradeTime ?? now; final model = TransactionModel( type: _selectedType, amount: amount, categoryId: _selectedCategory!.id, accountId: 0, remark: _remarkController.text.trim(), tradeTime: tradeTime, createdAt: now, updatedAt: now, ); try { final dao = TransactionDao(await DBHelper.instance); await dao.insert(model); if (!mounted) return; Navigator.pop(context, true); } catch (e) { _showTip('保存失败:${e.toString()}'); } }

这里有个我在OpenHarmony真机上真实踩过的坑:如果数据库还没有初始化就调用DBHelper.instance,有概率拿到空的Database对象,导致insert直接报错。所以DBHelper里我用的是懒加载+空合并,并且强烈建议在main()里提前调用一次:

void main() async { WidgetsFlutterBinding.ensureInitialized(); await DBHelper.instance; runApp(const MyApp()); }

提前初始化的好处是后续任何页面取数据库实例都是同步可用的,避免在用户操作节点突然因为IO初始化失败而闪退。

3.5 时间选择器的处理:直接用日期+时间双滚轮

trade_time我用了showDatePicker选日期,再用showTimePicker选时间。两个组件在OpenHarmony适配版里整体可用。默认值都用当前时间,用户不改就直接存当前时间戳。

这里要提醒一个细节:DateTime在存储到毫秒时间戳时,需要注意本地时区问题。不要自己手动拼接“年-月-日 时:分:秒”字符串。直接millisecondsSinceEpoch,读取时再fromMillisecondsSinceEpoch转成本地时间。这样后续做跨时区备份恢复才能保住数据一致性。

4. 实战排雷:OpenHarmony真机和模拟器上的高频问题

4.1 Flutter for OpenHarmony环境搭建的隐藏问题

如果你是用OpenHarmony官方推出的SDK包配置Flutter环境,最常遇到的第一道坎是SDK路径识别不了。具体表现是flutter doctor能看到OpenHarmony分支,但构建时提示SDK相关组件缺失。

我的建议是严格按照OpenHarmony平台的文档一步步来,特别注意这三个点:

  • harmonyOS SDK的路径不能有中文和空格,否则一些原生工具链会炸
  • 环境变量OHOS_SDK_HOME必须指向正确的sdk目录,而不是IDE安装根目录
  • 首次构建会下载很多依赖,网络不稳定时容易出现半途失败,可以多试几次,别急着换版本

还有一个体验上的差异:OpenHarmony模拟器比Android模拟器资源占用更重,启动也慢。如果机器配置不够,强烈建议直接跑真机调试,省去模拟器带来的很多无关问题。

4.2 sqflite的so库和Platform Channel问题

我在OpenHarmony上集成sqflite时遇到过一个非常隐蔽的问题:App在模拟器上运行正常,一到真机就报MissingPluginException。

排查思路不复杂但过程略曲折,最后发现原因是真机的OpenHarmony版本和模拟器不完全一致,Universal大包没有正确打进去,导致原生侧的MethodChannel实现没有被加载。

如果你也遇到“模拟器能用、真机不能用”的情况,优先检查Flutter是否启用了完整的ABI打包:

flutter build hap --debug --target-platform ohos-arm64

有些环境下默认构建只打了模拟器架构,真机跑不起来,指定target-platform之后问题就消失了。

另外需要强调一点:不要随便用Android版的sqflite发行包直接替换,OpenHarmony的sqflite适配解除了对Android内部API的依赖。装错包不会立刻报错,但可能在数据库打开后出现文件句柄异常,这种问题最难定位。最好还是从适配OpenHarmony的分支拉依赖,或者用公开可用的适配版本。

4.3 数据写入失败:常见原因与排查路径

真机调试过程中,我遇到了几次写入失败的情况。整理成表格,方便对照:

现象可能原因处理方式
数据库创建异常目录不可写检查getDatabasesPath()的返回路径,确保应用沙箱目录权限正常
字段类型错误categoryId传了int确保DAO入参类型与建表字段一致,TEXT字段不要传数字
事务冲突多个异步同时写同一时间窗口密集插入时,对DAO方法加同步锁或串行队列
值超出精度amount传了超过两位小数在输入层就限制小数位数,保存时统一四舍五入到分

这里有个经验:不要把双精度浮点直接序列化到JSON再塞进数据库,JSON解析后的double往往带一长串尾巴。我在代码里统一做了amount.toDouble(),并且数据库读取时也做个round:

double _normalizeAmount(double value) { return (value * 100).roundToDouble() / 100; }

这个方法成本很低,但能避免很多“神奇的数字尾巴”问题。

4.4 页面刷新不生效:路由返回后的数据同步

新增记录返回到首页时,我一开始只在initState里加载数据。后来发现一个很常见的现象:写入成功后返回首页,界面数据纹丝不动,重启App才会刷出新记录。

原因就在于initState只会在页面创建时执行一次,从添加页返回到已有页面时,原页面并不会重新initState。

解决方案无非三种:

  • 使用Navigator.push返回await方式主动刷新
  • 使用状态管理库,比如Provider、GetX、Riverpod,监听数据变化
  • 使用EventBus或Stream通知列表更新

我个人建议首版直接用第一种,最简单不需要额外库,逻辑也透明。从添加页的调用处写清楚:

final added = await Navigator.push<bool>( context, MaterialPageRoute(builder: (context) => const AddTransactionPage()), ); if (added == true) { tabController.refreshData(); }

等后面页面越来越多,手动刷新太琐碎了,再引状态管理也不迟。

5. 数据联动和后续扩展:让记录真正驱动整个App

5.1 新增记录后,首页汇总如何正确刷新

首页的收支汇总无外乎两种实现方式。

第一种是数据驱动,保存后直接返回最新汇总数据给首页。这种方式接口设计简单,但首页还需要感知到其他入口(比如修改、删除)导致的数据变化,不够通用。

第二种是事件驱动,新增交易记录成功后,通过一个全局的监听器广播“数据已变更”事件。首页接收后重新查询当月汇总和最近流水。这种方式解耦性更好,任何功能入口只要发出事件,所有关心数据的页面都能收到通知。

我这里推荐第二种思路,但落地方式不用太复杂,一个简单的ChangeNotifier就够。

比如做一个FinanceDataNotifier,保存成功后调用notifier.refresh(),首页AnimatedBuilder或ListenableBuilder监听它,数据模型有变化就重新查库。

这样做的好处是做编辑和删除功能时,复用同一套通知逻辑,不需要为每个操作单独写回调链。代价只是一个个小小的状态管理类,很值得。

5.2 编辑、删除、分类管理等后续模块的扩展位置

这个模块做好后,后续几个方向都能顺滑衔接:

  • 点击交易列表项进入编辑页,把TransactionModel的id传给编辑页,加载后回填表单,复用现有DAO的update方法
  • 滑动删除或长按删除操作,需要补一个delete方法,并在删除后也触发通知刷新
  • 分类页从静态数据替换成数据库表后,只要保证categoryId字段对应关系不变,交易记录表不用重建
  • 后续增加“预算”模块时,可以在插入交易后检查当月该分类累计支出是否超出预算阈值,发本地通知提醒

我做这些扩展时一直守着一条底线:不要为了“用上某个新架构”而重写核心数据层。交易记录表字段已经稳定,DAO接口清晰,上层怎么改都基本不会伤筋动骨。

5.3 数据备份与恢复的提前设计

个人理财数据非常怕丢,所以备份功能虽然我现在还没做完整版,但在结构上做了预留。

最简单的雏形是把transactions表导出成JSON文件,再提供一个导入接口。导出时需要注意金额格式化、时间戳统一、分类路径带上。导入时要在事务里执行,避免半途失败留下脏数据:

Future<void> importTransactions(List<TransactionModel> list) async { final db = await DBHelper.instance; await db.transaction((txn) async { final dao = TransactionDao(txn); for (final item in list) { await dao.insert(item); } }); }

未来如果有云同步需求,这个JSON结构也可以作为上行同步的payload,再配合一个“最后同步时间”字段做增量同步。至少比临时起意去把SQLite文件硬当附件上传要稳得多。

最后的经验分享

整个“添加交易记录”做下来,我最想提醒自己的其实是:这个功能看起来只是一堆输入框,但真正让它变难的是隐藏在下面的数据一致性和跨端适配问题。如果你也想自己写一个理财类App练手,建议千万别跳过数据库封装这层,也别急着先做花哨的图表界面。先把一条记录从页面走到数据库,再原路读取出来展示在列表里,这个闭环通了,后面再加速就很容易。

另外一个小技巧:真机调试时,如果发现数据库写入后读取结果偶尔不对,可以先开启SQLite的日志开关,把实际执行的SQL打出来看一遍,十有八九能快速定位是参数类型问题还是WHERE条件写错了。别靠肉眼一条条对Map字段,效率低且容易漏。

这个模块完成后,我的下一步计划是继续往列表页和统计页推进,等数据量跑起来后,再回过来优化查询效率。如果你也在做类似的Flutter for OpenHarmony项目,希望这份实战记录能帮你少走几段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询