
事情要从我三个月前那次加油说起。加油站的电子小票上显示里程、油量、金额我拍了个照想着月底汇总一下。结果月底找照片找了20分钟数据散落在备忘录、相册和微信文件里别说算平均油耗连上次保养时是几万公里都想不起来。我当时就在手机上搜了一圈油耗记录App要么是Android/iOS专版要么就是强联网广告一堆在OpenHarmony设备上根本没有顺手的工具。正好那阵子我在研究Flutter对OpenHarmony的适配索性自己做了一个——这就是FillUp油耗追踪器的由来。这个项目别看是一个“小App”它把Flutter开发里最常用的几块东西全串起来了列表页、详情页、表单录入、状态管理、本地持久化、统计计算再加上OpenHarmony真机调试和上架前要关注的兼容性问题。如果你刚开始接触OpenHarmony开发或者对一个“功能完整但又不复杂”的Flutter练手项目感兴趣这篇实战笔记应该能帮你少踩不少坑。1. 为什么用Flutter给OpenHarmony写一个油耗追踪器1.1 项目的由头油耗记录这件事比看起来复杂油耗记录听起来特别简单——每次加油填三个数字里程、升数、金额。但真用起来就会发现这里面有几个隐藏需求。第一个是“趋势”。单次油耗说明不了问题空调开得多、路况堵、胎压不足油耗都会波动。没有累计数据你根本判断不了到底是车出了问题还是这箱油跑的路况特殊。第二个是“费用”。很多人记账不是为了算百公里油耗而是想知道一个月花多少油钱每公里成本是多少。第三个是“异常提醒”如果某次油耗突然超过历史平均值的20%那这箱油大概率有问题——可能是加油站短了斤两也可能是车辆状态变了。这些问题决定了FillUp不能只做一个“填空加保存”的备忘录它需要能展示趋势、算平均值、做对比。这些功能放在一个App里技术栈覆盖正好是Flutter开发的主干页面导航、组件复用、数据传递、状态刷新、本地存储、图表绘制。比“Hello World”有信息量又比电商项目容易掌控适合一个周末出第一版。1.2 Flutter OpenHarmony的组合到底可不可行很多朋友听到“OpenHarmony上跑Flutter”第一反应是“靠谱吗”。我做完FillUp的结论是可行而且比想象中顺手。关键原因在于Flutter的渲染机制。Flutter的UI是自己通过Skia/Impeller绘制的不依赖系统原生控件所以一套Dart代码在Android、iOS、OpenHarmony上渲染结果基本一致。OpenHarmony设备上做适配主要工作是让Flutter引擎能跑在OpenHarmony的运行时里这部分由社区和厂商做了大量工作我们开发者的任务就是配好SDK和工程然后专注业务代码。需要注意的是OpenHarmony不是Android不能用Android通道的Gradle那套东西直接跑。它有自己的工程结构、签名机制和打包格式HAP包所以工具链和构建流程需要单独适配。后面我会详细说环境搭建。1.3 第一版功能边界FillUp第一版我锁定了三个模块记录录入里程、油量、金额、油品类型、加油站、备注日期默认当天。记录列表与详情按时间倒序展示支持查看单条详情、修改、删除。统计看板百公里平均油耗、每公里成本、月度费用、油耗趋势折线。我刻意没有做云同步和登录注册。原因很简单本地优先的App可以先把核心体验做扎实数据存在本地也不涉及账号体系后面要接华为账号或者云端同步再在数据层做抽象就行。第一版少一点外围功能反而能把记录详情的链路走通。2. 搭建OpenHarmony的Flutter开发环境比想象中麻烦一点2.1 工具链准备DevEco Studio与Flutter SDK的分工OpenHarmony上做Flutter开发需要两套工具配合工具作用说明DevEco StudioOpenHarmony官方IDE负责OpenHarmony SDK管理、工程编译、签名、安装调试Flutter SDKohos适配版Dart编译与Flutter引擎需要拉取支持OpenHarmony的Flutter分支标准版Flutter不会自动识别ohos平台hdc命令行设备连接与调试OpenHarmony版本的adb工具用来安装HAP、查看日志我踩的第一个坑是装完DevEco Studio后它默认只管OpenHarmony原生工程不会自动把Flutter的SDK路径配好。你需要在DevEco Studio里配置Flutter SDK路径指向ohos适配版本的Flutter目录同时设置OpenHarmony SDK所在的目录。这两者的关系有点像Android开发里的“Android SDK Flutter SDK”组合但细节上完全不是一套东西。2.2 创建支持OpenHarmony的Flutter项目有了工具链创建项目分两步先用Flutter创建Dart工程再生成OpenHarmony壳工程。Flutter本身的Dart工程是平台无关的直接用flutter create创建即可。工程里主要写lib/目录下的Dart代码。然后要在这个工程下生成ohos目录底层是创建OpenHarmony原生壳工程让Flutter引擎可以挂载上去。生成的壳工程里包括entry模块、签名配置、模块配置文件等。这里有个容易忽略的点OpenHarmony真机调试需要开发人员证书。DevEco Studio里可以配置自动签名但前提是你的设备已经被识别并且开启了开发者模式。我第一次在真机上跑FillUp时hdc list targets显示设备离线折腾了半天发现只是设备没解锁开发者模式开了之后马上正常。建议你先把设备连上、解锁、打开开发者模式再开始跑工程。2.3 在真机上跑通第一个页面跑通OpenHarmony真机的命令和Android类似可以直接在DevEco Studio里点Run也可以命令行操作。核心流程是编译Dart代码、打出HAP包、通过hdc安装到设备、拉起应用。第一个页面跑通后我建议你先用adb/hdc logcat看一眼日志有没有异常尤其是Flutter引擎初始化是否成功。OpenHarmony上的Flutter运行时依赖一些系统库如果设备固件版本过老或者SDK版本不匹配可能卡在启动画面。运气好的话一句“Flutter engine started”就能看到你的页面了。3. 记录详情的数据结构先把油箱和里程的账算清楚3.1 核心数据模型设计记录详情是整个App的心脏。我设计的数据模型是下面这样字段类型说明idString主键用时间戳随机数生成odoMeterint当前总里程公里fuelVolumedouble本次加油量升fuelCostdouble本次加油金额fuelPricedouble油价金额/油量可手动覆盖fuelTypeString92号/95号/0号柴油等stationString加油站名称remarkString备注recordTimeDateTime加油时间当初纠结过一个问题要不要让用户输入单价还是金额除以油量自动算后来我两个都保留了——自动计算出来单价之后允许用户修改。因为有些加油站有优惠券、满减活动实际支付金额和挂牌价不一致如果只存金额和油量单价永远是个估值统计费用时会不准。3.2 油耗计算公式与边界情况百公里油耗的基础公式很简单百公里油耗 本次加油升数 / (本次里程 - 上次里程) * 100但实际使用中有几个边界情况必须处理首次记录没有“上次里程”无法计算油耗只能作为基准记录。如果本次里程小于上次里程说明用户可能填错了或者中途清零里程表。我选择的方案是弹提示让用户确认但不强制拦截毕竟有些摩托车的里程表需要手动归零。如果两次加油间隔很短、里程差很小计算出的油耗可能异常大。这种情况我会在统计时标记为可疑数据。Dart代码看起来是这样double calcFuelConsumption({ required int currentOdo, required int lastOdo, required double fuelVolume, }) { final distance currentOdo - lastOdo; if (distance 0 || fuelVolume 0) { return 0; } return (fuelVolume / distance * 100 * 10).roundToDouble() / 10; }保留一位小数主要是因为仪表显示本身就有误差精确到小数点后两位没有实际意义反而容易让用户困惑。3.3 录入表单的交互细节录入页用的是Flutter的Form TextFormField组合。这里有几个交互细节做的时候纠结比较久里程和油量用numberWithOptions带小数键盘金额也用数字键盘但允许小数点。日期默认当天用户点击后弹出showDatePicker选完回填。油品类型和加油站做成下拉和自动补全方便第二次加油时直接选上次的加油站。表单校验逻辑里程必须大于0油量或金额至少填一个日期不能晚于今天。表单完成后的保存按钮是整个App里第一个组件通信的场景录入页保存成功后需要告诉列表页“数据变了重新加载”。我当时用了最直接的方式——在录入页保存后直接Navigator.pop返回true列表页await这个结果后刷新。这样代码简单也不容易出状态同步的问题。4. 记录列表与详情页的导航实现4.1 页面路由的两种写法FillUp里有列表页、录入页、详情页三个主要页面路由设计上我用的是onGenerateRoute统一管理没有用逐个页面硬编码的MaterialPageRoute。两种写法各有取舍。直接写在build里页面间耦合重改一个入口要翻好几处代码用onGenerateRoute集中管理页面和路由就是“路径到页面”的映射关系清晰得多。尤其后面加统计页时只需要在routes配置里加一行不用改动既有页面。详情页的打开方式我选择的是构造器只传记录id不传整个Record对象Navigator.pushNamed( context, /recordDetail, arguments: recordId, );为什么不直接传对象因为详情页里允许修改记录修改完成之后上一个页面如果还持有旧的Record对象就会出现数据不一致。传id的话详情页每次进入后自己从存储里重新读数据保证拿到的一定是最新的。4.2 Navigator切换页面后的状态保持有个读者问过“flutter navigator切换页面后会丢失状态吗”这个问题的答案取决于你怎么切换的。如果用的是Navigator.push原页面并没有销毁它的State还留在栈里pop回来之后滚动位置、临时输入内容都还在。但如果你用了pushReplacement、pushAndRemoveUntil或者页面被系统回收重建State就会被销毁。FillUp的列表页有一个坑列表数据在每次从详情页返回后需要判断是否需要刷新。如果用户在详情页修改了记录再删除列表还是旧数据就有问题。我的处理方式是列表页用await等Navigator.push的返回值如果返回值为true就重新加载数据。这样既不会在每次返回时无脑刷新导致闪烁也不会漏掉变更。另一个状态保持的细节是滚动位置。列表页在pop回来后滚动位置应该保持。Flutter默认在同一个State实例下会保持但如果列表数据刷新导致ListView重建ScrollController的offset可能被重置。我用了PageStorageKey来稳定位置实测下来在几十条记录、快速进出详情页的场景下表现稳定。4.3 组件通信的几个实际场景“flutter组件通信”这个问题做FillUp时我在几个地方用到了不同方式父传子列表项卡片组件只接收一个Record对象纯展示不操作数据。子传父录入页保存成功后通过Navigator.pop(bool)把结果传回列表页。跨页面详情页修改数据后同样用pop结果通知列表页刷新。状态共享统计页需要读全部记录我做了个Repository单例里面封装了Hive的读写统计页和列表页都从它拿数据修改操作统一走Repository避免了多个页面各持有一份数据副本。这个设计可能不是大型App的最佳实践但在这个规模下足够清晰界面只管展示和交互数据全走Repository。5. 持久化存储方案Hive还是数据库5.1 先排除掉不合适的方案说到本地存储Flutter生态里常见方案有三个shared_preferences、sqflite、Hive。我在FillUp里最终选了Hive理由如下方案适用场景在OpenHarmony上的顾虑shared_preferences存一些配置项、Flag、轻量数据对但它本质是键值对不适合存几十上百条结构化的加油记录sqflite需要复杂查询SQL的关系数据底层依赖SQLite原生库OpenHarmony上可能需要额外编译适配风险较高Hive结构化的可序列化对象读写轻量纯Dart实现不依赖平台原生代码在OpenHarmony上兼容性最好Hive的底层存储格式是二进制Box文件读写速度很快几百条加油记录完全无压力。而且它不涉及网络和权限也不用维护复杂的SQL迁移脚本适合FillUp这种功能单一的本地应用。5.2 Hive接入与Box设计用Hive之前我把数据模型写了个toJson/fromJson然后直接以Map形式存进Box。没有引入Hive的TypeAdapter原因是这个项目只有一种数据类型TypeAdapter带来的类型安全收益不明显反而要维护生成代码流程。核心代码大概是这样class RecordRepository { static const boxName fillup_records; Futurevoid addRecord(Record record) async { final box await Hive.openBoxMap(boxName); await box.add(record.toJson()); } FutureListRecord getAllRecords() async { final box await Hive.openBoxMap(boxName); return box.values.map((e) Record.fromJson(e)).toList(); } Futurevoid updateRecord(String id, Record record) async { final box await Hive.openBoxMap(boxName); final index box.values.toList().indexWhere((e) e[id] id); if (index ! -1) { await box.putAt(index, record.toJson()); } } }Hive的putAt需要传索引index一开始我总是笨办法遍历查找后来发现box.keys会保留每条记录的固定key就可以根据key来更新了。这也是一个小坑如果你对Box执行了remove操作后续记录的index会变化用index定位最好只在当前内存数据里使用。5.3 数据备份与初始化考虑本地数据最大的风险是设备换新或者应用被卸载。FillUp第一版做了JSON导出/导入功能导出就是读取全部记录生成一个JSON文件放到应用文档目录里导入就是反过来解析JSON写入Box。这里有个需要特别注意的细节Hive.init需要指定一个可写的目录OpenHarmony上通常用getApplicationDocumentsDirectory或类似接口获取路径。如果路径不对写进Box的数据可能无法持久化或者App重启后数据“神秘消失”。我第一次在OpenHarmony上跑就是没注意这个每次杀掉App进程数据就没了排查了半天才发现是初始化目录落到临时目录去了。6. 统计页与图表让数据自己说话6.1 统计口径平均油耗怎么算才靠谱做统计页之前我琢磨了一下“平均油耗”的口径。很多人会直接把每次记录的百公里油耗加总求平均但这个算法有问题如果你两次加油间隔不同比如一次隔了200公里一次隔了800公里简单平均会给短间隔那次过高的权重算出来的数值并不代表真实油耗。更靠谱的做法是“总量平均法”百公里平均油耗 所有加油量之和 / (最后一次里程 - 第一次里程) * 100不过这样算也有个缺点中间如果用户跳过了某次没记录分母会偏小。所以我在统计页同时展示了两个数字一个总平均一个最近10次加权平均。两个数字会话不一致如果差异明显说明中间有数据缺失或者异常记录提醒用户检查。每公里成本的计算就简单了每公里成本 所有加油金额之和 / 总行驶里程这个数字对通勤族特别有用可以直接算出一个月的出行成本。6.2 用CustomPaint画折线图图表这块我没有引入fl_chart之类的第三方库。原因有两个一是OpenHarmony上第三方Flutter插件的兼容性需要逐个验证插件如果涉及原生平台实现很可能要等适配二是FillUp只需要一条油耗趋势折线用CustomPaint自己画也就一百行代码的事还能完全控制样式。核心思路是把最近N次的油耗值映射到画布坐标上先画网格线再画数据点和折线。class TrendChart extends CustomPainter { final Listdouble values; final Color lineColor; final Color gridColor; override void paint(Canvas canvas, Size size) { final gridPaint Paint() ..color gridColor ..strokeWidth 0.5; for (int i 1; i 5; i) { final y size.height * i / 5; canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); } final linePaint Paint() ..color lineColor ..style PaintingStyle.stroke ..strokeWidth 2 ..strokeCap StrokeCap.round; final path Path(); for (int i 0; i values.length; i) { final x size.width * i / (values.length - 1); final y size.height - (values[i] / maxValue) * size.height * 0.8; if (i 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, linePaint); } }真实使用时会有两个注意点一是数据点只有1个时values.length - 1会除零要做最小安全判断二是油耗费数值范围可能从5到15波动很大maxValue取历史最大值而不是固定值曲线变化才明显。6.3 月度汇总与列表筛选统计页除了折线图还做了月度费用列表按月份把记录分组汇总每个月的加油次数、总金额、平均油耗。Flutter里做分组统计我用的groupBy来自collection包一次遍历就能把记录按月份归类。列表页也顺势加了筛选能力按油品类型筛选、按时间范围筛选。这里用的是下拉筛选和日期范围选择器筛选条件变化时重新从Repository查询不缓存在内存保证数据一致。7. 实测中遇到的OpenHarmony适配问题与排查思路7.1 Impeller渲染与中文字体Flutter的渲染引擎在较新版本里有Skia和Impeller两条路线。在OpenHarmony上Impeller的支持情况和Android上不完全一样有的设备上启用Impeller后会出现渲染异常。FillUp遇到的问题是部分OpenHarmony设备上中文字体加粗效果异常文字看起来糊成一团。排查思路很简单先在真机上用Flutter的启动参数切换渲染引擎再对比页面表现。如果关掉Impeller后显示正常就说明问题出在引擎适配层不是项目代码。对FillUp这种纯UI应用保持默认渲染引擎、不主动开启实验性的Impeller开关反而是最稳的。另外还遇到过一个字体问题系统默认字体在OpenHarmony上显示中文没问题但某些自定义字体文件加载失败。我的处理是中文场景用系统默认字体数字和英文标题保留自定义字体。7.2 异步回调与FutureBuilder的时序OpenHarmony上调试时有个和热词“flutter future的then回调 是放入微任务队列吗”相关的实际问题我用FutureBuilder读取Hive数据时出现了“闪空白”现象。原因不复杂FutureBuilder传入的future如果在build方法里重新调用每次重建都会触发新的异步任务而Hive.openBox本身也是异步的导致页面先显示空数据再加载完成。解决方法是把future的创建方式移出build方法放到State.initState里缓存或者直接用async/await在initState里把数据读好再setState。后来我统一改成Repository模式页面层不再直接操作Hive的Future而是由Repository在内部缓存好数据对外暴露同步的getAllRecords接口或者事件流。这也是我在做统计页时把数据层独立出来的原因之一——异步一遍到处散落排查起来太痛苦。7.3 从XTS认证角度看兼容性自查如果你打算把App发布到OpenHarmony的应用市场XTS认证是绕不开的环节。名词听着高大上其实核心就是一套兼容性测试标准包括应用行为、隐私合规、性能等方面。我在发布前按照XTS关注点自查了一圈发现有几个容易踩的点权限最小化FillUp原本申请了Internet权限想用来同步数据后来第一版没做云同步就把权限去掉了。不必要的权限会成为合规审查的扣分项。崩溃率OpenHarmony对应用崩溃率有要求本地存储场景的崩溃大多发生在数据读取时没做异常处理。我在Repository里统一加了try/catch读不到数据就返回空列表而不是直接抛异常。隐私协议如果App会采集用户数据需要提供隐私政策。FillUp第一版纯本地存储不需要弹隐私协议弹窗这反而是个优势。XTS测试跑完一遍比你想的严不少。建议你在开发中期就跑一次测试套件不要等全部做完再查不然改起来成本很高。另外真机是必须的模拟器上能通过的测试在真机上不一定过。做完FillUp之后我最大的感受是OpenHarmony开发并没有大家想的那么“另一个世界”Flutter这一套在OpenHarmony上的适配已经能让开发者专注业务代码了。真正常出问题的地方反而是环境和细节——比如Hive的初始化路径、Navigator的状态保持、FutureBuilder的异步时序这些小问题每个都不难但堆在一起会非常消耗耐心。最后分享一个小技巧用hdc调试的时候强烈建议用debug签名包跑真机不要用release包。release包的签名校验严格调试时改动的日志输出也会被优化掉排查问题会多花一倍时间。我这是踩过坑才记住的希望能让你少走弯路。