记账这事,说起来简单,但真正动手做一个让自己每天都愿意打开的记账 App,难度比想象中大得多。我去年年底把主力设备换成了鸿蒙系的手机之后,一直纠结一个问题:市面上成熟的记账应用不少,但数据都在别人服务器上,而且功能越做越重,开屏一堆广告。作为一个 Flutter 开发者,我天然想用自己最趁手的工具链去解决这个日常需求。于是就有了「记一笔 · MemoMoney」这个项目——一个跑在 HarmonyOS 6.0 上、用 Flutter 开发的个人理财助手,目前先把收入记录模块完整落地了。这篇文章就是记录我从环境配置、数据建模到编码实现、踩坑修复的完整过程,尤其是 Flutter 在鸿蒙真机上的兼容性细节,希望对打算在鸿蒙设备上做 Flutter 应用的朋友有实际帮助。
1. 为什么我坚持用 Flutter 做鸿蒙应用,而不是原生重写
1.1 记账应用的核心痛点不是功能少,而是"打开成本"太高
市面上大部分记账应用的首页信息密度极高,图表、账户、预算、理财推荐堆在一起,我每次想记一笔饭钱都要在界面上找半天入口。时间一长,记账这个习惯自然就放弃了。所以 MemoMoney 的第一设计原则是:从打开 App 到完成一笔收入记录,绝对不超过 5 秒。这个目标决定了技术选型必须足够轻量,UI 渲染必须足够快,而跨端一致性要好——我不希望以后 iOS、Android 甚至桌面端各写一套 UI,平白增加维护成本。
Flutter 的 Skia 自绘引擎在复杂列表和自定义动画场景下表现出色,而且布局代码可以完全控制像素级细节。对于记账这种需要大量自定义数字键盘和表单交互的应用,Flutter 比原生 ArkUI 更符合我的习惯。另外,团队里如果以后有其他成员参与,Flutter 的招聘和学习成本也远低于重新培养一套鸿蒙原生开发技能树。
1.2 鸿蒙适配现状:Flutter 不再是"二等公民"
很多人对 Flutter 跑鸿蒙的印象还停留在"实验性项目"阶段,实际上 OpenHarmony 社区对 Flutter 的适配已经推进了相当长的时间。当前主流做法是使用 OpenHarmony 官方维护的 Flutter 分支(基于 Flutter 主干定制),配合 DevEco Studio 构建鸿蒙侧的 Runner 工程。HarmonyOS 6.0 的 API 版本对 Flutter 插件的兼容度比前几个版本提升明显,大部分纯 Dart 包可以直接运行,只有涉及原生能力(图库、传感器、支付等)才需要写平台通道。
如果项目对 UI 一致性要求高、业务逻辑偏重,且团队已经熟悉 Flutter,那么用 Flutter 开发鸿蒙应用完全可行。相反,如果你的应用需要深度调用鸿蒙的分布式能力,比如跨设备流转、超级终端,那就老老实实走 ArkTS 原生路线,Flutter 在这些系统级能力上还隔着一层桥接的损耗。
1.3 项目整体架构:先跑通最小闭环
MemoMoney 的架构采用分层设计,UI 层全部用 Flutter Widget 实现,业务状态用 Provider 管理,数据层通过抽象 repository 接口隔离数据库实现,底层先用内嵌数据库做本地持久化。当前版本聚焦收入记录模块——因为收入数据的字段相对固定,但金额、分类、时间、备注的边界条件很多,非常适合先把数据模型和增删改查跑扎实。分模块迭代的好处是每一块都能独立验证效果,不需要等到所有功能做完才能使用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零搭建 Flutter × HarmonyOS 6.0 开发环境
2.1 获取正确的 Flutter SDK 分支
这一步是新手最容易踩坑的地方。直接用 flutter.dev 下载的官方稳定版 SDK 是没办法构建鸿蒙应用的,必须切换到 OpenHarmony 的 Flutter 适配分支,或者使用社区维护的 flutter_flutter 仓库中基于 ohos 的分支。具体操作步骤如下:
bash复制git clone https://gitee.com/openharmony-sig/flutter_flutter.git
cd flutter_flutter
git checkout master
克隆完成后,把 bin 目录加入 PATH 环境变量。注意不要同时把官方 Flutter SDK 的两个 bin 目录都暴露在 PATH 里,否则 flutter doctor 会检测到多个版本,出现各种奇怪错误。我一开始就吃过这个亏——同时存在两个 flutter 命令位置,导致 pub 缓存路径错乱,依赖包下载后始终无法被正确解析。
验证环境配置是否正确,可以运行:
bash复制flutter --version
flutter doctor
如果版本号显示的是基于 OpenHarmony 定制的分支版本,并且 doctor 列出了 DevEco Studio 的路径,说明基础环境没问题。
2.2 版本匹配:Flutter、Dart、DevEco Studio 和鸿蒙 SDK
版本匹配是另外一个大坑。热搜里反复出现的"flutter 各个版本不对导致依赖包下不下来",核心原因基本都出在版本矩阵不匹配。我这里整理了一份当前我实测可用的稳定组合:
| 组件 | 版本 | 说明 |
|---|---|---|
| Flutter SDK | ohos 分支 master(2025.11 后) | 需与鸿蒙 Runner 模板对应 |
| Dart SDK | 随 Flutter SDK 内置 | 不需要单独安装 |
| DevEco Studio | 5.1+ | 低版本可能不支持 API 12 以上工程 |
| HarmonyOS SDK | API 12 及以上 | 对应 HarmonyOS 6.0 设备 |
| Java(用于 Gradle) | JDK 17 | DevEco Studio 自带即可 |
这里要特别注意一点:pubspec.yaml 里如果写了 environment: sdk: ^3.x.x,且依赖的包要求 Flutter 最低版本高于你当前分支的版本,就会在 flutter pub get 时出现锁定失败的报错。解决办法不是盲目升级 Flutter,而是先查看报错中提示的具体包名,用 dependency_overrides 定向覆盖。例如:
yaml复制dependency_overrides:
intl: 0.19.0
2.3 cmake 与构建工具的隐藏依赖
做纯 Dart 项目时不需要关心 CMake,但如果你的 Flutter 工程里有任何 C/C++ 原生插件(比如 sqlite3、crypto 的某些实现),鸿蒙侧构建时就会触发 CMake 配置。热搜里有人提到 flutter cmake error at cmakelists.txt:3,一般都是 NDK 路径或 Visual Studio 生成器的问题。在鸿蒙开发环境中,需要把 DevEco Studio 自带的 NDK 路径正确暴露给 CMake:
bash复制export ANDROID_NDK_HOME=/path/to/DevEcoStudio/sdk/default/openharmony/toolchains
如果在 Windows 上,还需要确认已安装 Visual Studio 2022 的 C++ 桌面开发组件,否则 CMake 找不到可用的生成器。
3. 收入记录模块的数据模型:字段设计决定功能上限
3.1 一张收入记录表的核心字段
收入记录不像账本查询那么复杂,但字段设计必须从第一天就考虑好报表统计、同步、去重等后续需求。我在 MemoMoney 里设计了以下核心字段:
| 字段名 | 类型 | 含义 | 备注 |
|---|---|---|---|
| id | TEXT | 主键 | 用 UUID,避免多端同步时产生冲突 |
| amount | INTEGER | 金额 | 以"分"为单位存储,避免浮点误差 |
| category_id | TEXT | 分类 ID | 关联分类表 |
| source | TEXT | 收入来源 | 自定义文本,比如"某公司工资" |
| remark | TEXT | 备注 | 非必填 |
| record_time | INTEGER | 记账时间 | 毫秒时间戳 |
| create_time | INTEGER | 创建时间 | 毫秒时间戳 |
| update_time | INTEGER | 更新时间 | 用于增量同步 |
| sync_status | INTEGER | 同步状态 | 0 本地未同步、1 已同步、2 同步冲突 |
金额字段我特别强调一下:一定不要用 REAL 浮点数存金额。0.1 + 0.2 的精度问题在金融场景是不可接受的,所以所有金额统一转成整数分。界面上显示时再格式化成元,比如 formatAmount(int cents) 返回 "128.00"。
3.2 分类体系:一级固定加二级自定义
收入分类比支出分类简单,但如果不提前设计,后期做统计图时会非常被动。MemMoney 的分类表设计为两级结构:
- 一级分类:工资、奖金、理财收益、兼职、礼金、退款、报销、其他
- 二级分类:用户可自由新增,挂在某个一级分类下,比如"工资"下的"绩效奖金"
数据库层面用一张 category 表自关联 parent_id,顶级分类的 parent_id 为空。为什么不直接用一个分类字段?因为后续做月度收入趋势、分类占比时,需要按一级分类汇总;而个人用户的收入来源又可能非常个性化,二级自定义给了足够的弹性。
3.3 状态机设计:本地编辑与同步状态解耦
sync_status 这个字段值得单独说一说。很多本地优先的应用失败,就是因为没有在数据结构层面区分"用户已提交"和"服务端已确认"。我在设计时把状态机拆成四态:
- LOCAL_CREATED:用户刚录入,尚未进入同步队列
- SYNC_PENDING:已写入待同步队列,等待网络
- SYNCED:服务端已确认,数据一致
- CONFLICT:服务端与本地记录冲突,需要人工介入
收入记录模块当前虽然只做本地存储,但字段和状态机必须提前预留。否则等做到"本地+后端同步"时,你就要做一次完整的数据库迁移,甚至可能要清空用户数据——这是绝对不可接受的。
4. 收入录入界面:把"5 秒完成记账"落到实处
4.1 页面结构:金额键盘优先,分类决策靠后
MemoMoney 的收入录入页没有传统的表单结构,主界面从上到下依次是:当前选中分类的图标和名称、金额展示区(大字号显示)、分类宫格区、备注输入框。底部的固定区域是自带数字键盘的弹出层。
之所以这样排布,是因为记账动作的核心是"输入金额",而不是"选分类"。大多数场景下,用户打开 App 就是为了快速记一笔,分类可以靠默认记忆自动带上。只有当用户主动点击分类图标时,才会切换到分类选择状态。这种交互模式在实际使用中效果很好,我记一笔工资平均只需要点三次屏幕:打开 App、输入数字、确认保存。
4.2 金额输入的校验与格式化
金额输入是最容易出边界问题的地方。我用一个 TextEditingController 配合 FilteringTextInputFormatter 来限制输入:
dart复制class AmountInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
if (newValue.text.isEmpty) return newValue;
// 只允许数字和小数点
final pattern = RegExp(r'^\d{0,7}(\.\d{0,2})?$');
if (!pattern.hasMatch(newValue.text)) return oldValue;
return newValue;
}
}
正则里的 \d{0,7} 限制了整数部分最多 7 位(即 9999999.99 元),超过这个金额的收入基本不存在,同时防止用户误触键盘导致 UI 错乱。另外一个细节是:输入过程中不做千分位展示,只有在失焦或点击确认时才把金额格式化成 1,234,567.89 这样的展示格式,否则键盘弹出和光标移动都会受到干扰。
4.3 分类选择的交互实现
分类宫格我采用 GridView.count 实现,每行 4 个分类,每个分类包含一个图标和一个文字标签。选中的分类用主题色描边,未选中的保持灰色调。这里有一个性能优化点:分类列表是固定数据,打开页面时就一次性加载完毕,用 setState 做切换即可,不需要引入流式状态管理。
如果用 Provider 或 Riverpod 管理全局状态,页面会在打开时多一层依赖解析,对于这种局部状态切换来说没有必要。保持局部状态的作用域最小化,代码更清晰,重构也更容易。
4.4 日期与备注的处理策略
收入记录的记账时间默认取当前时间,但用户可能需要补记几天前的工资。我在日期区域放置了一个"今天"按钮,点击后弹出 showDatePicker,选择结果立即更新到页面。备注字段只保留单行输入,回车即保存并收起键盘,保持轻量感。
5. 本地持久化层:内嵌数据库选型与封装落地
5.1 为什么不用 shared_preferences 直接存 JSON
很多轻量工具类 App 的常规操作是用 shared_preferences 存一个大 JSON 字符串,但记账类的数据结构会持续增长,性能和数据安全都指望不上。一旦记录数超过几百条,整个 JSON 的序列化耗时就会突破肉眼可感知的范围。所以 MemoMoney 的内嵌数据库选择遵循一个核心原则:必须支持结构化查询、索引和事务。
5.2 候选方案横向对比
| 方案 | 底层引擎 | 类型安全 | 迁移机制 | 鸿蒙兼容性 |
|---|---|---|---|---|
| drift | SQLite | 强(代码生成) | 内置 migration API | 需原生 sqlite3 支持,实测可跑 |
| Hive | 自研二进制 | 弱 | 手动写版本迁移 | 纯 Dart,兼容性最好 |
| sqflite | SQLite | 弱 | 手动管理 | 鸿蒙上需使用 sqflite_ohos 适配包 |
| isar | 自研 | 强 | 弱 | 目前对鸿蒙支持不完整 |
我最终选了 drift。原因有三:第一,drift 在 SQLite 之上提供了编译期类型安全检查,字段改名字段加索引都能在编译阶段发现错误;第二,迁移逻辑可以写成方法链,从 v1 到 v2 的变更非常直观;第三,drift 的查询 API 天然支持流式响应,以后做统计报表时可以直接监听数据库变化刷新 UI,非常适合账单类应用。
注意,drift 在鸿蒙上跑起来需要一个 sqlite3_flutter_libs 的适配版本,社区里有 sqlite3_flutter_libs_ohos 这样的 fork 可以直接用。只要在 pubspec.yaml 里替换依赖来源即可。
5.3 drift 模式的建表与 DAO 封装
我用 drift 内置的 DSL 定义数据表结构:
dart复制class IncomeRecords extends Table {
TextColumn get id => text()();
IntColumn get amount => integer()();
TextColumn get categoryId => text()();
TextColumn get source => text().nullable()();
TextColumn get remark => text().nullable()();
IntColumn get recordTime => integer()();
IntColumn get createTime => integer()();
IntColumn get updateTime => integer()();
IntColumn get syncStatus => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
对应的 DAO 封装如下,收入记录模块就依赖这个 repository 接口:
dart复制@DriftAccessor(tables: [IncomeRecords])
class IncomeRecordDao extends DatabaseAccessor<AppDatabase>
with _$IncomeRecordDaoMixin {
IncomeRecordDao(super.db);
Future<IncomeRecord> insertRecord(IncomeRecord record) {
return into(incomeRecords).insertReturning(record);
}
Future<List<IncomeRecord>> getByTimeRange(int start, int end) {
return (select(incomeRecords)
..where((t) => t.recordTime.isBetweenValues(start, end))
..orderBy([(t) => OrderingTerm.desc(t.recordTime)]))
.get();
}
}
插入时用 insertReturning 而不是 insert,可以拿到完整记录,方便后续把自增的本地 ID 或默认值回填到内存模型。查询时间范围时给 record_time 字段建索引,否则数据量过万后会出现明显卡顿:
sql复制CREATE INDEX idx_income_record_time ON income_records (record_time);
5.4 数据库迁移从第一天就写
用户可能升级 App,数据库结构不可能一成不变。drift 的迁移方案是在构造函数里传入 MigrationStrategy,比如增加一个字段:
dart复制@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
},
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.addColumn(incomeRecords, incomeRecords.source);
}
},
);
这个设计看起来简单,但最大的价值是让数据库结构变更变成一种日常习惯,而不是"等到发版前才想起"的紧张事。每个版本的迁移逻辑都应该有对应的测试用例,确保升级后数据不丢失。
6. 鸿蒙真机上的实战踩坑:从依赖冲突到图库调用
6.1 依赖版本冲突:pub get 卡住的真正原因
我在项目初期遇到了一个非常典型的报错:Because every version of flutter_loremipsum depends on intl ^0.18.0, every version of memo_money depends on intl ^0.18.0. 翻译过来就是:两个包对 intl 的版本要求互相冲突,pub 无法解析。
这事的常见原因有两类:一是某个包本身没更新,对新版 Flutter 的 API 还不支持;二是间接依赖传递导致版本锁定失败。我的建议是:不要为了兼容老包而降低整个 SDK 版本,而是用 dependency_overrides 强制指定某个依赖的版本。例如:
yaml复制dependency_overrides:
intl: 0.19.0
如果 dependency_overrides 也不能解决,再看这个包是否有维护中的 fork。对于纯 Dart 包,通常问题不大;如果是有原生代码的包,还要关注它是否提供 ohos 平台的实现。
6.2 Flutter 页面主题颜色与 Material 组件细节
HarmonyOS 的 Material 组件渲染在某些细节上和 Android 不完全一样。我在项目里把 showLicensePage 的页面主题色改成应用主色调时,发现鸿蒙上返回按钮的默认颜色没跟着主题变。排查下来发现是系统组件的默认样式在鸿蒙平台上有细微差异。
解决方案是统一用 ThemeData 控制,不要依赖任何组件的默认值。例如:
dart复制ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Color(0xFF3F7DFF)),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF3F7DFF),
foregroundColor: Colors.white,
),
)
6.3 调用鸿蒙图库:平台通道的正确写法
MemoMoney 后续版本计划支持"拍照或从图库选择发票/凭证图片作为附件"。Flutter 社区的家目录 image_picker 插件对鸿蒙的适配还不完整,稳妥做法是自己在鸿蒙侧写一个 MethodChannel 通道。
Flutter 侧 Dart 代码:
dart复制class ImagePickerBridge {
static const platform = MethodChannel('com.memomoney/image_picker');
Future<String?> pickFromGallery() async {
try {
final path = await platform.invokeMethod<String>('pickFromGallery');
return path;
} on PlatformException catch (e) {
return null;
}
}
}
鸿蒙侧需要实现对应的方法并返回图片 URI 或已拷贝到应用沙盒的路径。这里要注意:直接返回原图路径可能会因为访问权限问题导致后续无法读取图片内容,所以最好先把选中的临时文件拷贝到应用私有目录,再返回新路径。权限申请也要在鸿蒙侧用 requestPermissions 明确声明。
6.4 关于鸿蒙 IAP 支付:提前了解,但暂不接入
热搜里有人问"flutter 兼容鸿蒙拉起 iap 支付",我的判断是:现阶段 Flutter 插件对鸿蒙 IAP 的封装还不成熟,涉及商品校验、回调分发等都还需要平台通道手动适配,不建议在 1.0 版本引入。记账应用的订阅功能可以走"多端订阅"方案,先做好数据同步,再考虑在鸿蒙端接入支付,这样业务逻辑不会被支付 SDK 的适配拖住。
7. 本地优先与后端同步的规划:让数据先活在本地
7.1 为什么选择"本地优先"而非"实时云同步"
MemoMoney 的数据同步策略是:所有写操作先写入本地 SQLite,然后通过后台队列慢慢同步到服务端。优先保证用户在任何场景下都能打开即记,哪怕地铁上没有信号。
本地优先的另一个好处是隐私。收入数据极其敏感,用户对上传行为的容忍度很低。如果第一版就把数据放到云端,等于让渡了用户信任。本地优先方案里,云端同步是"可选增值功能",用户可以通过开关控制是否启用,而不是强制行为。
7.2 增量同步:记录级状态位加更新时间戳
增量同步的核心是 sync_status 和 update_time 两个字段。每次同步开始时,客户端向服务端发送本地每条变更记录的 id 和 update_time,服务端对比后返回需要更新的记录或冲突标记。
线上实操中,我通常设计一个 SyncQueue 表来处理失败重试:
| 字段 | 说明 |
|---|---|
| record_id | 关联的本地记录 ID |
| entity_type | 记录类型,如 income_record |
| operation | CREATE / UPDATE / DELETE |
| retry_count | 失败重试次数 |
| last_error | 最近一次错误信息 |
同步任务只负责扫描这个队列表,取出待同步记录并逐个提交,提交成功才移除。重试超过 5 次就把 sync_status 标记为 CONFLICT,等用户手动确认。这样即使服务端偶尔抖动,数据也不会被静默跳过。
7.3 多端同步冲突处理的最小策略
多端同步最大的问题是离线编辑后的冲突。我的处理逻辑比较简单:如果同一条记录在两端都有修改,且 update_time 不同,就认为冲突。冲突时优先保留服务端版本,同时把本地版本复制为一条新的待确认记录,用户可以在"同步冲突"列表中决定保留哪一条。尽量避免自动合并——自动合并在收入记录这种场景下很容易出错。宁可让用户花几秒钟做一次选择,也不要默默覆盖用户数据。
7.4 后续扩展:报表统计与分类优化
数据积累到一定程度,要做月度趋势、分类占比时,drift 的流式查询能力就会派上大用场。金额汇总可以写成一条 SQL 聚合查询,然后 watch() 监听数据变化,任何新记录写入后 UI 都会自动刷新,不需要手动拉取接口。
分类层面,等用户使用一段时间后,可以根据历史记录给高频分类排序,把最常用的几个分类前置展示在这个用户专属的宫格前几位。这个功能等收入数据量上来之后再做,效果会更明显。
个人经验来看,Flutter 跑鸿蒙今年已经不是"能不能跑"的问题,而是"跑得稳不稳"的问题。MemoMoney 的收入记录模块从环境配置到真机流畅运行,总共花了两周左右,其中一半时间耗在版本适配和依赖排查上。这部分成本会随着社区的成熟逐渐下降,但短期内做鸿蒙 Flutter 项目,建议一定先从数据层和本地存储开始,把基础打牢,再逐步接入原生能力。后续我再把支出模块、图表统计和云同步做完,再回来更新具体的实现细节。
