1. 项目定位:给“水电费记录本”一个具体的应用场景
水费、电费、燃气费,这三笔账看着不大,真要等年底对账单的时候,翻聊天记录、翻缴费小程序、翻支付流水,能把你逼疯。更麻烦的是家里不止一套自住的房子,出租屋的每一笔水电费到底是租客垫付还是我这边的自动扣款,时间一长根本对不上。所以我很早就有个念头:做一个本地的水电费缴纳记录本。正好那段时间开始折腾Flutter和鸿蒙跨平台开发,就顺手把这个小工具当成了练手项目,也沉淀出一套比较完整的开发路径。
这个应用的核心功能非常直接:把每一笔水费、电费、燃气费的交费记录存下来,包括金额、用量、户号、缴费时间、缴费状态、发票或截图凭证。往后随便什么时候想查,打开应用按月份筛选,当月交了多少钱、同比多了还是少了,一眼就能看清。不需要注册登录,不需要云端同步,不需要考虑多人协作,就纯粹是一个“本地优先”的个人账单工具。
这套教程适合三类人。第一类是自己家里或者出租屋多、经常被水电账单搞晕的普通用户,想用一个小应用脱离Excel和备忘录;第二类是刚开始学Flutter、想找一个完整业务场景练手的开发者,跟着走一遍数据模型、状态管理、页面交互和数据库持久化;第三类是正在做鸿蒙端跨平台适配的开发者,想看看Flutter项目里哪些模块需要单独处理、哪些依赖在鸿蒙上容易翻车。我自己属于第三类,但做出来的东西实际也在给第二类当样例。
1.1 账单记录这件事,为什么值得做成一个小应用
有人会问,水电费就几笔,拿个笔记本写一下不就行了?我一开始也这么想。但实际用过之后发现,纸质记录和备忘录有两个根本问题:一是无法快速筛选,比如“去年下半年水费一共交了多少”,你得翻上半年的每一页纸,再拿计算器加一遍;二是容易漏记,尤其是多套房子交替交费,哪套房子哪个月还没交,靠脑子根本记不住。
把这些需求翻译成技术语言,就是一个带增删改查的本地数据库应用,外加按月筛选和聚合统计。如果再加上“缴费状态提醒”和“账单导出备份”,用户体验会明显上一个台阶。这里有一个很关键的设计判断:这个应用不需要云同步。水电费账单本身属于比较敏感的隐私数据,放在本地数据库里最省心,也避免自己做后端服务器、账号体系那一整套复杂度。做个人工具,最怕的就是为了“完整”而引入根本用不上的功能。
1.2 用户角色与核心功能清单
这个应用虽然简单,但功能设计上要按“角色”想清楚。我的使用场景里其实有两个角色:一个是普通家庭用户,记录自己家的水电气账单,另一个是房东角色,需要记录不同房子的缴费情况。两个角色用到的数据模型其实是同一张表单,只是在“户号”和“备注”字段上会有区别。所以我最后没有把角色做成权限系统,而是统一成一套接口,靠字段灵活兜住。
最终确认的功能清单如下:
- 缴费记录的新增、编辑、删除,支持水费、电费、燃气费、物业费四个大类
- 按日期、分类、户号、缴费状态进行筛选和搜索
- 首页展示最近一次缴费信息、本月总支出、待缴账单提醒
- 月度和年度统计,用柱状图展示各分类费用趋势
- 数据导出为CSV或JSON文件,手动备份到本机存储
- 缴费凭证图片关联,缴费后把截图保存到记录详情里
这六条功能看着不多,但实现完以后基本覆盖了日常使用的高频路径。我在设计阶段特意把“统计报表”往后放了放,先保证增删改查和筛选足够顺手。这个顺序很重要,很多人做个人项目时一上来就搭报表模块,结果基础录入交互还没做顺,整个项目就烂尾了。
2. 技术选型:Flutter如何承接鸿蒙端的跨平台需求
这个项目起初并没有锁定非用Flutter不可。当时摆在面前的有三条路:原生鸿蒙开发、Flutter、其他跨平台框架。我最终选择Flutter,核心原因就是它对UI的描述方式和开源生态对“小工具类应用”实在太友好了。一套Dart代码,过去在Android和Windows上能跑,现在鸿蒙设备也能通过Flutter的适配层跑起来,业务逻辑和页面组件大部分复用,这份效率是原生开发很难给的。
用原生做当然很稳,对系统控件的调用也最彻底,但一个个人记账应用如果同时在手机、平板、甚至PC上维护一套独立代码,工作量直接翻倍。Flutter的好处在于跨端渲染一致性做得比较出色,同样的卡片、同样的表单,在安卓和鸿蒙上几乎不会出现尺寸错位或者字体渲染差异。尤其是“水电费记录本”这种重列表、重表单、轻动画的应用,Flutter的默认控件风格本身就足够干净,不需要做太多平台定制。
2.1 Flutter在鸿蒙设备上的落地路径
先实话实说,Flutter不是官方原生支持鸿蒙,它是通过鸿蒙侧的Flutter适配引擎把Dart代码运行起来的。这套路径现在已经比较成熟,但你要有心理准备:三方插件不一定全部兼容鸿蒙,比如某些纯安卓依赖库可能直接编译不过去。
落地步骤大致是这样:先装好Flutter SDK和鸿蒙开发工具链,用Flutter创建常规跨平台工程,然后再单独添加鸿蒙平台的工程配置,让鸿蒙IDE能够识别并构建出鸿蒙的安装包。构建完成以后,Dart层面的页面代码、数据库操作、状态管理逻辑完全不需要改,需要单独处理的主要是原生权限声明、文件路径差异、部分插件的鸿蒙适配情况。
我在这个项目里碰到过一个小插曲,某个图表插件在安卓上显示正常,跑到鸿蒙上却直接白屏。后来换成Flutter自带的绘制思路,用简单的柱状图组件重写,反而更轻量。这件事给我的教训是:鸿蒙端的插件生态还在追赶阶段,选型时尽量不要依赖太重的第三方原生组件,像图表、文件选择、分享这类能力,优先用纯Dart实现。
2.2 各跨平台方案在鸿蒙上的实际差距
做技术选型不能只看宣传噱头,要看最终构建出来的产物能不能在目标设备上稳定运行。我把当时考虑过的几个方案放在一起做过对比:
| 方案 | UI一致性与开发效率 | 鸿蒙适配成熟度 | 适合场景 |
|---|---|---|---|
| 原生鸿蒙开发 | 最高 | 天然支持 | 系统深度功能、性能敏感应用 |
| Flutter | 高,组件统一 | 可用,需处理三方插件差异 | 中轻量业务应用、数据展示类应用 |
| 其他跨平台方案 | 中 | 依赖社区适配,稳定性看具体版本 | 以网页/小程序为主的项目 |
那段时间我实际跑过几个小Demo,Flutter在鸿蒙真机上的启动速度和滑动流畅度对我这个场景完全够用。我的结论是:如果你要做的应用主要偏表单、列表、本地数据,Flutter完全能扛;如果要用到大量底层硬件能力、系统特色服务,还是要看原生,或者做好混合开发的准备。
2.3 本地数据存储选型:SQLite优先而不是云同步
水电费记录的数据量很小,一年顶多几十条,但字段结构是固定的,适合用关系型数据库。我在项目里直接选了SQLite,Flutter里用sqflite这个库来操作。为什么不用SharedPreferences一把梭?因为记录一旦多了,你需要按月筛选、按分类汇总,SharedPreferences只适合放轻量的键值配置,不能执行SQL聚合。
这个决定还有一个现实原因:本地SQLite文件天然适合离线使用,没有网络请求,也没有云厂商依赖。配合文件的导入导出功能,备份就是拷一个文件的事。数据量小,备份文件也就几十KB,放到网盘或者电脑上都很方便。对个人工具来说,这种“云”其实已经足够,不需要再引入真正的后端存储。
3. 数据模型与工程结构
进入编码之前,最关键的一步是设计数据表和工程目录。很多人一上来就写页面,写到一半发现字段对不上,又回头改数据库,来回折腾。我先花了半天时间把功能模块和数据表整理清楚,后面写代码的速度反而快了很多。
3.1 功能模块怎么拆分
Flutter项目虽然是单页应用为主,但代码不能全部堆在main.dart里。我按职责拆成六个目录:
- models:数据模型类,比如Bill类,负责Field与数据库字段映射
- screens:页面层,比如首页列表、新增记录、统计页、设置页
- widgets:通用组件,比如状态标签、金额卡片、空视图
- services:数据库访问、CSV导入导出等业务服务
- utils:日期格式化、金额格式化等工具函数
- state:全局状态管理,在这就是Provder相关的Store
这样的结构看着传统,但对个人项目非常友好。页面只关注UI渲染,数据操作全部走服务层,以后扩展别的功能,比如加个“房屋管理”,只需要新增一个model和对应service,不用改动既有页面。
3.2 bills表字段设计
数据表我命名为bills,字段尽量贴合实际账单信息,不搞过度设计。建表SQL如下:
CREATE TABLE bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, amount REAL NOT NULL, usage_amount REAL, account_no TEXT, bill_date TEXT NOT NULL, due_date TEXT, status INTEGER DEFAULT 0, remark TEXT, photo_path TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP );字段设计有几处值得单独说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| type | TEXT | 缴费分类,水费/电费/燃气费/物业费 |
| amount | REAL | 实缴金额,单位元,保留两位小数 |
| usage_amount | REAL | 用量,水按吨、电按度、气按立方米 |
| account_no | TEXT | 户号,多套房时拿来区分账单 |
| bill_date | TEXT | 账单日期,格式统一为yyyy-MM-dd |
| status | INTEGER | 0未缴费,1已缴费 |
| photo_path | TEXT | 本地图片路径,缴费凭证截图 |
用量字段我统一叫usage_amount,虽然不同能源单位不一样,但记录数值本身比区分单位更有价值。单位信息在分类里就能表示:电费必然对应“度”,水费对应“吨”,不需要单独做表。这算是从“够用就好”的思维方式出发做的取舍。
建表时status用整数而不是布尔值,是为了以后扩展“部分缴费”或“已作废”这类状态。这不需要在数据库里定义复杂枚举,Dart侧写个扩展函数转换显示文案即可。
3.3 状态管理:setState够用的时候就别折腾
状态管理方案我在这个项目里选的是Provider。Flutter自带setState其实也能写完整个应用,但列表页、筛选条件、统计页之间会有数据联动,只用setState会导致刷新范围过大、状态散落在各个页面里。Provider的粒度控制更好,又不引入太复杂的学习成本。
这里有个真实的取舍过程。多人协作或大型项目适合Bloc或者Riverpod,但对水电费记录本这种个人工具,Provider的写法最直观。核心状态我只维护一个BillStore,里面放当前筛选条件、记录列表、本月汇总数。新增和删除记录之后,调用一个refresh方法重新拉数据库,页面就自动更新。就这么简单。
4. 鸿蒙开发环境搭建与项目初始化
环境搭建是跨平台开发里最磨人的一环,没有之一。鸿蒙的适配链路跟安卓不完全一样,Flutter项目要跑上去,需要把工具链理顺。我把我自己的一次完整流程记录下来,你在安装时如果版本号对不上,问题也不大,关键是理解每一步在干什么。
4.1 工具链准备
首先保证电脑上有Flutter SDK,版本选择当前稳定版即可,不要去追最新的beta版。接着安装鸿蒙侧开发需要的SDK和IDE。这里要注意,鸿蒙IDE自带了一部分工具,但Flutter构建鸿蒙产物时,需要额外安装鸿蒙的SDK组件,而且要确保SDK版本和IDE版本匹配。
安装完成以后,分别检查两个工具的版本信息是否正常。常见的坑是环境变量没配好,导致在终端里敲flutter命令能识别,但鸿蒙构建时找不到SDK路径。我当时就是在这一步卡了快一个小时,最后把鸿蒙SDK路径手动加到环境变量里解决。
如果第一次接触鸿蒙开发,强烈建议先安装好官方模拟器,不要在环境都没跑通的情况下直接烧真机。模拟器的调试反馈速度快很多,等代码稳定以后再上真机测试性能。
4.2 初始化项目并接入鸿蒙平台
项目初始化没什么特殊的,标准命令创建一个Flutter工程:
flutter create water_bill_app cd water_bill_app生成出来的工程默认包含Android、iOS、Windows等平台目录,但不会直接包含鸿蒙平台的工程目录。这一步需要手动把鸿蒙平台的配置文件加入工程。
配置方式可以分成两步理解:第一步是在Flutter工程里添加鸿蒙适配依赖,让Flutter工具链认识这个平台;第二步是鸿蒙IDE打开工程的相应隐藏目录,生成并管理鸿蒙原生壳工程。壳工程本质上是一个空的原生入口,负责把Flutter引擎加载起来,真正的业务全部跑在Dart侧。
完成以后运行flutter doctor,正常情况下应该能看到鸿蒙相关的检查项。如果没有,多半是环境变量没生效,重新打开终端,再检查一次SDK路径。
4.3 真机调试与权限配置
模拟器上跑通之后,真机调试会暴露不少问题。先把鸿蒙设备开启开发者模式并打开USB调试,用数据线连接电脑,执行flutter devices,如果能列出设备说明连接成功。
这个应用需要写本地数据库,还要读取图片作为凭证,所以鸿蒙壳工程里要声明存储相关权限。权限不像Dart代码那样可以在跨平台层统一处理,必须进入鸿蒙工程配置里手动添加。我的建议是:先只加最少的权限,跑一个数据库写入的测试用例,确认没问题再补图片相关的权限。权限越多,应用商店审核和用户安装时的心理负担都越大。
5. 核心功能实现:从录入到统计的完整链路
项目大头在业务功能本身。我按用户操作路径来讲,从新建一笔缴费记录开始,到列表查看、筛选、统计、导出,完整闭环走一遍,你会看到Flutter加SQLite这套组合有多顺手。
5.1 新增缴费记录:表单校验与日期处理
新增页面就是一个表单,按照bills表的字段逐项来:
- 分类用下拉选择框,默认电费
- 金额用文本输入框,限制只能输入数字和小数点
- 用量输入框,可留空
- 户号文本输入框,可留空
- 账单日期用日期选择器
- 状态用空隙按钮,表现出“未缴费/已缴费”
表单提交时要做三层校验。第一层是必填项,金额和日期不能为空。第二层是格式校验,金额必须大于0,并且保留两位小数。第三层是业务校验,比如选了“未缴费”状态,账单日必须早于或等于今天,避免录一个未来的欠费记录。
日期处理的坑主要在格式化上。数据库里我用文本格式yyyy-MM-dd保存,不存时间戳,这样SQL查询里的LIKE和BETWEEN都很好写。读取时再用DateTime.parse转成可比较的对象。这个方案牺牲了一点点排序灵活性,但换来的是可读性强,导出CSV时直接就是用户能看懂的日期。
表单提交的核心代码风格如下:
Future<void> _submit() async { final amount = double.tryParse(_amountController.text.trim()); if (amount == null || amount <= 0) { // 提示金额不合法 return; } final bill = Bill( type: _selectedType, amount: amount, usageAmount: _usageValue, accountNo: _accountNoController.text.trim(), billDate: _selectedDate, status: _isPaid ? 1 : 0, remark: _remarkController.text.trim(), ); await _billStore.addBill(bill); }5.2 记录列表:按状态筛选与快捷操作
列表页是整个应用的使用核心。我用ListView构建卡片式列表,每条记录显示分类图标、金额、日期和缴费状态。卡片右侧放两个快捷按钮:编辑和删除。删除按钮必须弹确认对话框,输入框里还可以放一个小的撤销机制,防止手滑。
列表的筛选逻辑放在BillStore里,三个维度:月份、分类、缴费状态。任何条件变化都会触发数据库查询:
List<Bill> _filteredBills = bills.where((bill) { bool typeMatch = _typeFilter == null || bill.type == _typeFilter; bool statusMatch = _statusFilter == null || bill.status == _statusFilter; bool dateMatch = _monthFilter == null || bill.billDate.startsWith(_monthFilter); return typeMatch && statusMatch && dateMatch; }).toList();这个项目因为数据量不大,我采用了先全量读取再内存筛选的方式。如果以后记录超过几千条,再改成SQL查询直接限定条件。对个人工具来说,内存筛选的代码更简单,也不容易出缓存同步的Bug。
首页除了列表,还会放一个汇总卡片,显示本月水电气总支出。这个数字用一条聚合SQL查出来,不用额外维护字段,每次进入页面自动刷新。
5.3 月度聚合统计与图表展示
统计页我用聚合SQL把数据按月拆出来:
SELECT type, SUM(amount) as total, COUNT(*) as count FROM bills WHERE bill_date BETWEEN ? AND ? GROUP BY type;然后把每组结果拆成柱状图的Y轴数据,用Flutter的图表库画出来。图表库的选择在前面说过要谨慎,鸿蒙端对部分原生图表插件的兼容性不稳定。我后来选择了一个纯Dart实现的图表方案,虽然样式上简单不少,但胜在稳定,渲染速度快,几行组件就能出柱状图。
这一页的交互我做得比较克制:顶部是三个统计指标卡片,本月总支出、上月总支出、年度平均;中间是分类汇总柱状图;下面是按月切换的横轴滚动列表。用户想看哪个月就点哪个月,图表和列表随之联动。
5.4 CSV备份与恢复
备份功能是我额外给自己加的,因为数据都存在本地,最怕手机刷机或者误删应用。备份逻辑非常简单,把bills表所有记录读取出来,拼接成CSV字符串,然后通过系统分享面板保存到本机文件:
String buildCsv(List<Bill> bills) { final buffer = StringBuffer('类型,金额,用量,户号,账单日期,状态,备注\n'); for (final bill in bills) { buffer.write('${bill.type},${bill.amount},${bill.usageAmount},${bill.accountNo},${bill.billDate},${bill.status},${bill.remark}\n'); } return buffer.toString(); }恢复功能则反过来,读取CSV文件,逐行解析后批量插入数据库。这里有一个很重要的防呆设计:恢复功能必须放一个“先清空再导入”的确认开关。我第一次实现时直接导入,结果旧记录和新记录混在一起,统计数字全都乱了。后来改成默认导入前清空本地表,用三步确认弹窗兜底,才敢放出来日常使用。
6. 实操中踩过的坑与排查提示
开发这个项目的过程中,我踩过不少很具体的坑,有些是Flutter通用的,有些是鸿蒙端特有的。把这些整理成一个速查表,比在文档里翻半天高效得多。
6.1 鸿蒙真机运行问题
| 问题现象 | 排查思路 |
|---|---|
| flutter devices看不到鸿蒙设备 | 检查USB调试是否打开,换数据线,重启IDE |
| 构建报SDK路径错误 | 检查鸿蒙SDK环境变量是否配置且匹配 |
| 安装成功但启动闪退 | 优先在鸿蒙IDE里看日志,八成是插件原生代码不兼容 |
| 模拟器能跑真机白屏 | 检查鸿蒙壳工程的权限声明,缺失权限会导致页面初始化失败 |
真机调试的日志查看方式和安卓不太一样,不要习惯性去翻Flutter的日志窗口。鸿蒙侧的崩溃信息能提供更多细节,尤其是原生组件加载失败的报错。我当时排查图表白屏,就是在鸿蒙侧日志里看到某个so文件加载失败,才确定问题出在插件兼容性上。
6.2 中文、日期、路径等跨端差异
这部分踩过的坑相当细碎。Flutter在鸿蒙上运行,中文输入本身没问题,但键盘在特定输入框会吃掉拼音候选词,如果你遇到这个现象,优先检查是不是用了第三方输入法插件,系统默认键盘一般没事,这个锅不乱甩给框架。
日期格式化要显式指定locale。我给项目里所有DateFormat都补了locale参数,例如:
DateFormat('yyyy年MM月', 'zh_CN')不指定locale时,某些环境下可能默认按英文格式拼接,排序和显示都会乱。路径也是,读取数据库文件和图片不要硬编码路径,统一走path_provider或者sqflite的API获取应用目录。鸿蒙和安卓应用目录路径规则不一样,硬编码必踩坑。
6.3 数据安全与升级迁移
数据库的onUpgrade是很多新手容易忽略的环节。我最初建表时没考虑以后加字段,结果想增加“photo_path”凭证图片字段时,直接升级数据库会报错。SQLite的迁移逻辑必须在onUpgrade回调里写清楚,否则用户更新应用后数据全丢:
onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await db.execute('ALTER TABLE bills ADD COLUMN photo_path TEXT'); } }另外一个容易忽略的点是删除确认。所有删除操作都应该有防误删设计。我在列表滑动手势里加了一步“选择滑出的删除按钮后,必须再点确认弹窗”,虽然多一步操作,但配合恢复功能,实际使用中再也没有出现过误删找不回的情况。
6.4 包体积与性能优化
Flutter应用的基础包体积就比原生大,鸿蒙端因为适配层的原因会再大一点。对照片裁剪压缩、列表懒加载,这些常规手段就不多说了,真正有用的一个建议是:第三图片处理库,能不用就不用。我为了给缴费凭证做缩略图,一开始引入了一个图像处理插件,包体积涨了不少,鸿蒙上兼容性还有风险。后来发现Flutter自带的Image组件加cacheWidth参数就能搞定缩略图渲染,效果差别不大,包体积却明显降下来了。
日常使用的流畅度方面,SQLite查询本身非常快,不要做任何耗时操作放在UI线程。数据库增删改查我都用异步方法,配合FutureBuilder或者StreamBuilder更新页面,滚动列表时几乎感觉不到卡顿。
最后再分享一个细节:我这套应用做了“最近一次缴费提醒”的本地通知功能,本来想用系统通知栏插件,后来发现纯Dart定时器加应用内红点提示也完全够用,还省下权限申请。个人工具优先保证核心链路稳定,而不是功能列表好看。这个判断,我觉得比任何技术选型都值钱。