Flutter for OpenHarmony实战:添加交易记录模块设计与数据持久化避坑指南

发布时间:2026/10/17 15:25:04
Flutter for OpenHarmony实战:添加交易记录模块设计与数据持久化避坑指南
我在实际开发中踩过不少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 FutureDatabase get instance async { _db ?? await _init(); return _db!; } static FutureDatabase _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, }); MapString, 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方法同时支持事后编辑时的updateimport package:sqflite/sqflite.dart; class TransactionDao { final Database db; TransactionDao(this.db); Futureint insert(TransactionModel model) async { return db.insert(transactions, model.toMap()); } Futureint 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, ); } FutureListMapString, 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 保存前的校验逻辑宁可严一点别让脏数据进库代码可以写得很快但校验逻辑必须逐项过。我的校验顺序如下金额字段非空且解析为double后必须大于0分类必须已选中记账时间不能晚于当前时间超过1分钟防止误把未来时间当作交易时间但给足时钟误差容错备注长度限制在100字以内超出给提示以上通过后拼装TransactionModel并调用DAO.insert校验失败的反馈我用的是ScaffoldMessenger弹SnackBar而不是每个字段下面都放errorText。因为页面本身高度有限多个错误同时触发时下方重点显示第一个错误即可体验比一片红字清爽。保存成功后我做两件事弹出短暂的成功提示然后Navigator.pop回到上一页上一页通过异步等待拿到“是否新增成功”的结果决定要不要刷新列表和汇总数据。Futurevoid _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()并且数据库读取时也做个rounddouble _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.pushbool( context, MaterialPageRoute(builder: (context) const AddTransactionPage()), ); if (added true) { tabController.refreshData(); }等后面页面越来越多手动刷新太琐碎了再引状态管理也不迟。5. 数据联动和后续扩展让记录真正驱动整个App5.1 新增记录后首页汇总如何正确刷新首页的收支汇总无外乎两种实现方式。第一种是数据驱动保存后直接返回最新汇总数据给首页。这种方式接口设计简单但首页还需要感知到其他入口比如修改、删除导致的数据变化不够通用。第二种是事件驱动新增交易记录成功后通过一个全局的监听器广播“数据已变更”事件。首页接收后重新查询当月汇总和最近流水。这种方式解耦性更好任何功能入口只要发出事件所有关心数据的页面都能收到通知。我这里推荐第二种思路但落地方式不用太复杂一个简单的ChangeNotifier就够。比如做一个FinanceDataNotifier保存成功后调用notifier.refresh()首页AnimatedBuilder或ListenableBuilder监听它数据模型有变化就重新查库。这样做的好处是做编辑和删除功能时复用同一套通知逻辑不需要为每个操作单独写回调链。代价只是一个个小小的状态管理类很值得。5.2 编辑、删除、分类管理等后续模块的扩展位置这个模块做好后后续几个方向都能顺滑衔接点击交易列表项进入编辑页把TransactionModel的id传给编辑页加载后回填表单复用现有DAO的update方法滑动删除或长按删除操作需要补一个delete方法并在删除后也触发通知刷新分类页从静态数据替换成数据库表后只要保证categoryId字段对应关系不变交易记录表不用重建后续增加“预算”模块时可以在插入交易后检查当月该分类累计支出是否超出预算阈值发本地通知提醒我做这些扩展时一直守着一条底线不要为了“用上某个新架构”而重写核心数据层。交易记录表字段已经稳定DAO接口清晰上层怎么改都基本不会伤筋动骨。5.3 数据备份与恢复的提前设计个人理财数据非常怕丢所以备份功能虽然我现在还没做完整版但在结构上做了预留。最简单的雏形是把transactions表导出成JSON文件再提供一个导入接口。导出时需要注意金额格式化、时间戳统一、分类路径带上。导入时要在事务里执行避免半途失败留下脏数据Futurevoid importTransactions(ListTransactionModel 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项目希望这份实战记录能帮你少走几段弯路。