家里老人药箱里的药越堆越多,有些过期了也没人发现,降压药和感冒药混在一起,每次找药都要翻半天;每个月称体重全靠手写在本子上,翻起来一片混乱。我决定自己动手做一款家庭药箱管理App,顺手把体重记录也做进去,目标设备是国产的OpenHarmony平板。技术路线没有纠结太久——直接用Flutter。原因很简单:Flutter在OpenHarmony上的适配已经走过了最不稳定的阶段,openharmony分支能正常出包,一套代码还能同时覆盖手机和平板,以后想上Android、iOS也不用重写。
这篇文章会把项目从环境搭建、数据模型设计、体重记录功能、原生通道、状态管理到打包细节完整捋一遍,全程按我的实测流程来写。里面涉及的所有坑都是真实踩过的,适合准备在OpenHarmony上用Flutter起项目的开发者参考,尤其是做医疗健康类工具的朋友。
1. Flutter 在 OpenHarmony 上的适配现状与起手准备
1.1 版本仓库和分支选择,别用主干SDK硬编
OpenHarmony的Flutter适配不是Google官方在维护,而是由openharmony-sig组织维护的独立仓库flutter_flutter。这里要提醒一句:千万不要直接拿官方主干版本的Flutter SDK去编译OpenHarmony应用。主干分支面向Android/iOS/Web,对OpenHarmony的抽象层、Engine接入和Native Plugin机制完全不兼容,强行编包会死在CMake或Gradle阶段。
我项目用的是3.7.12-ohos这个tag,稳定性和接口完整度都够用。选它而不是最新版的原因很简单:社区适配版本滞后于官方版本,追求最新反而容易踩到还没修完的Engine Bug。实际操作命令如下:
git clone https://gitee.com/openharmony-sig/flutter_flutter.git cd flutter_flutter git checkout 3.7.12-ohos export PATH="$PWD/bin:$PATH" flutter doctorflutter doctor执行完,能看到OpenHarmony Toolchain相关的检查项,只要Device/Platform项没有飘红,环境基本就OK。另外一个容易被忽略的点是:flutter_flutter仓库会把引擎预编译产物塞到bin/cache/artifacts/engine路径下,首次执行命令时下载可能需要十几分钟,建议用稳定的网络环境。
1.2 创建工程和OpenHarmony宿主壳
环境变量配好后,创建工程需要显式指定目标平台。OpenHarmony在Flutter工具链里的平台标识是ohos:
flutter create --platforms ohos medicine_kit生成工程后,目录结构和你熟悉的普通Flutter工程基本一致,多出来的ohos目录就是OpenHarmony宿主壳工程,本质是一个标准的Stage模型应用,可以用DevEco Studio打开做原生调试。后续编译产物验证、添加系统权限、注册原生插件,都需要在这个宿主壳上操作。
工程创建完毕,先跑一个空壳应用确认设备能正常装包。连接OpenHarmony设备后执行flutter run --debug,如果看到引擎启动日志和Flutter渲染画面,说明基础链路已经打通。这一步不要跳过,我之前在模拟器上一切正常,真机上却因为屏幕旋转矩阵问题导致白屏,排查了很久才发现是宿主工程没配置Abilities的orientation。
1.3 原生ArkUI和Flutter的取舍,我为什么没选ArkUI
很多人在OpenHarmony上做应用会本能地选ArkUI声明式开发,毕竟是官方亲儿子。但我做了个简单对比,最终依然坚持Flutter路线:
| 对比维度 | ArkUI原生 | Flutter (openharmony) | uni-app |
|---|---|---|---|
| 跨端能力 | 只能在OpenHarmony/鸿蒙上跑 | 一套代码覆盖OH/Android/iOS | 覆盖Web/小程序/App |
| 渲染性能 | 系统原生组件,性能优秀 | Impeller/Skia自绘引擎,性能稳定 | 依赖WebView渲染,复杂页面有压力 |
| 生态复用 | 官方组件库偏系统类 | 可复用pub.dev大量纯Dart包 | uni_modules插件市场 |
| 学习成本 | 需要额外学ArkTS声明式语法 | 会Flutter即可,无额外语法成本 | 会Vue即可 |
| 原生插件适配 | 系统API直接调用 | 需针对ohos做少量适配 | 部分原生插件要重新封装 |
核心原因是"沉淀价值"。医疗健康类工具大概率不会只做一个平台,Flutter把页面和业务逻辑沉淀下来,后续如果要出Android版、iOS版,不需要另起炉灶。而ArkUI虽然开发体验不错,一旦跨端需求出现,前期的UI和状态管理代码都要推翻。
2. 家庭药箱的两张核心表:药品与体重记录的数据设计
2.1 药品清单字段设计,每一个字段都有明确职责
家庭药箱管理的核心是"药不能放过期,医嘱不能忘"。药品表的设计直接决定了后续提醒功能和列表筛查好不好做。我最终采用了这些字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| name | TEXT | 药品名称,必填,用于列表展示 |
| spec | TEXT | 规格描述,比如"0.25g*24片" |
| stock | INTEGER | 剩余库存数量,默认0 |
| expiry_date | TEXT | 有效期,用yyyy-MM-dd字符串存储 |
| usage_guide | TEXT | 用法用量,比如"每日两次,每次一片" |
| remind_time | TEXT | 提醒时间,用HH:mm存储 |
| created_at | TEXT | 创建时间,写入时自动填充 |
有效期为什么用TEXT存yyyy-MM-dd而不是直接存时间戳?两个原因:一是日期字符串可读性好,调试时一眼看懂,格式化成本为零;二是yyyy-MM-dd字典序等于时间序,ORDER BY expiry_date出来的结果天然就是从近到远的过期顺序,不需要额外转换函数。
提醒时间单独拆出来,是为了和药品"服用频率"解耦。有些药是一天三次,有些是隔天一次,提醒逻辑差异较大,统一存一个HH:mm字段,界面展示和通知解析都方便。
2.2 体重记录字段设计,BMI在写入时就算好
体重记录功能听起来简单,实际要做顺手并不简单:不能只存一个体重数字,体脂率、BMI、测量日期、备注都会影响用户体验。我的体重表设计如下:
CREATE TABLE weight_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, weight REAL NOT NULL, body_fat_rate REAL, bmi REAL, measure_date TEXT NOT NULL, remark TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE UNIQUE INDEX idx_weight_date ON weight_record(measure_date);measure_date建了唯一索引,防止同一天重复录入。UI层不需要额外判断"今天有没有记录过",直接写入时利用ON CONFLICT REPLACE把同一天的数据覆盖,既省代码又保证数据一致性,这是我的实际习惯。
BMI为什么不查询时现算?因为展示BMI的场景太多了:列表卡片、趋势图顶部、统计面板,每次现算就要把身高传过来,查询SQL也会变复杂。不如在保存时用身高一次算好,之后所有查询直接读字段。
2.3 数据库初始化和版本管理,用sqflite的分支包
OpenHarmony上的本地数据库方案,我选的是sqflite_ohos。它本质是社区对Android版sqflite的OpenHarmony适配分支,接口和原版保持了一致,业务层几乎感觉不到差异。Dart侧直接依赖它即可,不需要区分运行平台。
初始化流程放在main()里完成,用一个单例Repository持有数据库引用:
final dbPath = await getDatabasesPath(); final path = '$dbPath/medicine_kit.db'; _database = await openDatabase( path, version: 2, onCreate: _createTables, onUpgrade: _onUpgrade, );版本号管理是有讲究的。如果后续新增表或改字段,只要把version加1,并在_onUpgrade里写迁移逻辑,不要直接在_createTables里顺手改老表结构,否则老用户升级时会崩。这就是数据库迁移的基本规律:新表的CREATE TABLE放onCreate,老表的ALTER TABLE放onUpgrade,两者分开维护。
3. 体重记录功能实战:统计卡片、折线图与增删改查
3.1 页面结构拆分,三层布局各司其职
体重记录页面我拆成了三个区域。顶部是统计卡片,展示当前体重、BMI、近30天变化量;中间是最近30条记录的折线趋势图;下方是可以侧滑删除的记录列表。
Column( children: [ _buildSummaryCard(currentRecord), Expanded( flex: 3, child: _buildTrendChart(records), ), Expanded( flex: 4, child: _buildRecordList(records), ), ], )统计卡片这里的"近30天变化量"很容易做错:不是用最新体重减去最老体重,而是要用最新体重减去记录存在的最早体重。如果用户最近才恢复记录,最老记录可能就在昨天,变化量会误显示为0,体验很差。
3.2 折线图绘制,fl_chart在OpenHarmony上的表现
趋势图用的插件是fl_chart,版本0.66。这个包是纯Dart绘制,依赖的是Flutter自带的Canvas能力,不涉及PlatformView,所以OpenHarmony上跑起来没有任何适配问题。这一点很关键,选图表组件时最好避开那些依赖WebView或原生View的库,否则到了OpenHarmony上就是无尽痛苦。
核心代码片段:
LineChartData( minY: yMin.floorToDouble() - 1, maxY: yMax.ceilToDouble() + 1, lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, color: const Color(0xFF4CAF50), barWidth: 2, dotData: const FlDotData( show: true, checkToShowDot: (spot, barData) => true, ), belowBarData: BarAreaData( show: true, gradient: LinearGradient( colors: [Color(0x334CAF50), Colors.transparent], ), ), ), ], titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 30, getTitlesWidget: (value, meta) { final index = value.toInt(); if (index >= 0 && index < dates.length) { return Text('${dates[index].month}/${dates[index].day}'); } return const SizedBox.shrink(); }, ), ), ), )这里有两个细节值得说。第一,minY和maxY不要直接用数据里的最值,要往上下各扩1,否则折线会贴在图表边缘,视觉效果特别差。第二,横轴坐标点改造成日期标签时,必须做边界判断,因为fl_chart会扫描整个X轴范围,超出数据范围的坐标如果直接索引数组会报空指针。
3.3 添加与删除记录的完整流程
添加记录用showModalBottomSheet弹出三层表单:日期选择器、体重输入框、体脂率输入框。体重输入框的类型要设成TextInputType.numberWithOptions(decimal: true),允许小数点输入。校验规则我写得很保守:
- 日期不能晚于今天,防止"未来数据"
- 体重范围30到300kg,体脂率范围3到75%
- 同一天已存在记录时,弹出确认框"是否覆盖当日记录"
用户点保存后,先把BMI算好,再执行INSERT或UPDATE。BMI计算公式是:体重除以身高米的平方。身高我在设置页放了常量,读取后传入工具函数。
删除记录直接用Dismissible包裹列表项,右滑露出背景色,confirmDismiss里调用数据库删除逻辑,再通过Cubit发通知刷新页面。需要注意的一点:Dismissible的key必须唯一稳定,我是用数据库记录id生成的ValueKey,千万不要用记录在列表中的索引,否则删除后列表重排,Dismissible会错乱。
4. EventChannel 接入:用药提醒与原生能力协作
4.1 MethodChannel 和 EventChannel 怎么分工
很多初学者把Flutter和原生的通道都叫"Channel",其实它们分工完全不同。MethodChannel是请求-响应模型,Dart调用一次,原生处理一次,返回一个结果。EventChannel是订阅-推送模型,Dart监听一个事件流,原生可以持续不断往里推数据,直到取消订阅。
在家庭药箱App里,我用EventChannel实现了一个核心场景:系统日历/系统提醒服务到点后触发,把"该吃药了"事件推送出来。这是因为药箱的定时提醒在App退到后台时依然要生效,而Flutter引擎在后台无法保证Dart代码持续运行,必须交给系统侧处理,事件再通过EventChannel回到Dart。
4.2 Dart侧和ArkTS侧的通道注册
Dart侧代码非常简洁,就是一个EventChannel定义加订阅方法:
class ReminderChannel { static const EventChannel _eventChannel = EventChannel('com.medicine_kit/medicine_reminder'); static Stream<dynamic> reminders() { return _eventChannel.receiveBroadcastStream(); } }使用方只需要在页面initState时订阅:
_reminderSub = ReminderChannel.reminders().listen((event) { final data = jsonDecode(event as String); // 弹通知、刷新药品列表 });OpenHarmony原生侧的注册逻辑,大致是创建一个EventChannel实例,传入宿主Context和与Dart侧一致的Channel名,然后设置StreamHandler。下面是一个ArkTS侧示意代码,实际工程里细节会多一些:
import { EventChannel } from '@ohos/flutter_ohos'; let eventChannel = new EventChannel(abilityContext, 'com.medicine_kit/medicine_reminder'); eventChannel.setStreamHandler({ onListen: (arguments, eventSink) => { // 注册系统提醒服务,到点后调用 eventSink.success(payload) }, onCancel: (arguments) => { // 取消系统提醒服务,释放资源 } });EventChannel发送的数据如果是复杂结构,统一用JSON字符串传递,Dart侧再解析。不要试图直接传ArkTS的Object或Map,跨语言边界的类型转换容易翻车——我之前传过一次原生Array,Dart侧接收后类型变成List ,还得自己做类型转换,不如直接传字符串干净。
4.3 提醒事件的重连策略,EventChannel需要重新订阅
EventChannel有一个隐藏的坑:它是一次性订阅关系。如果原生侧因为内存压力重启了Flutter引擎,或者应用从后台被系统回收后恢复,事件流会断掉,Dart侧拿到异常。所以我在App生命周期回调里做了一次"事件重新订阅":
onResume时先取消旧的广播订阅,再重新receiveBroadcastStream- 监听
onError事件,捕获通道异常后延迟1秒重连
这个"重连"思路本质上是把EventChannel事件视为易失连接,不能假设它永远在线。实际项目里,提醒事件从系统到Dart的链路损耗很小,但可靠性需要自己在业务层兜底。
5. 状态管理选型:Cubit方案和页面状态保活
5.1 为什么用Cubit而不是完整版Bloc
家庭药箱App的状态其实就两类:药品列表状态、体重记录列表状态。用完整版Bloc会引入Event和State两个维度的样板代码,对这类数据流不算复杂的App有点冗余。Cubit把Event层去掉了,只保留State和emit,写起来轻量很多。
class WeightCubit extends Cubit<WeightState> { WeightCubit(this._repository) : super(WeightState.initial()); Future<void> loadRecords() async { final records = await _repository.getAllRecords(); emit(WeightState(records: records)); } Future<void> addRecord(WeightRecord record) async { await _repository.upsertRecord(record); final records = await _repository.getAllRecords(); emit(WeightState(records: records)); } }Cubit适合的是"数据从仓库到页面同步"这个简单闭环。如果你以后要把操作日志、撤销回滚、复杂表单交互都融进来,再考虑升级到完整Bloc。技术选型永远为当前的业务复杂度服务,不是为了"架构好看"给自己增加工作量。
5.2 Navigator切换页面不丢状态的正确姿势
热门搜索词里有一条"flutter navigator切换页面后,会丢失状态吗",这个问题我在药箱App里真实遇到了:从药品详情页返回列表页时,列表滚动位置回到了顶部。本质上不是Navigator本身丢状态,而是页面被销毁重建导致的。
我的解决方案分两层:
第一层,Tab切换保留状态,使用了AutomaticKeepAliveClientMixin混入,并配合IndexedStack承载页面。这样Tab页之间切换时不会触发销毁重建,滚动位置和输入框内容都还在。
class _MedicinePageState extends State<MedicinePage> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; }第二层,跨页面传参的场景,核心数据不要只存在Widget的局部变量里。比如新增药品页面填写的表单数据,如果通过构造参数传回列表页,一旦列表页重建,数据就丢了。正确做法是让所有页面共享同一个Repository实例,页面重建后从Repository重新读取数据,UI自然恢复到最新状态。
5.3 组件通信的三种常见姿势
- 父子组件通信:最简单的方式,构造器传参加回调函数,适合单个子组件的场景
- 跨页面通信:通过共享的Cubit或Repository,注入方式用
BlocProvider.value,页面不依赖路由传参 - 跨组件层级:使用
InheritedWidget,bloc库底层就是靠它实现的,自定义状态共享也可以直接用它
这三种姿势在药箱App里都用到了。体重记录的图表和列表是兄弟组件,它们共用同一个WeightCubit实例,所以图表数据不会和列表数据不一致。本质上这些都是状态提升的思路——把状态放到公共的Cubit里,让多个组件共享同一个数据源,比不断在组件间传值和回调清晰得多。
6. 构建Hap包踩坑实录:从Gradle警告到PlatformView性能
6.1 构建流程与产物,hap包才是OpenHarmony应用的真实格式
OpenHarmony应用最终交付物是.hap包,不是Android的.apk。Flutter工程构建Hap包的命令很直接:
flutter build hap --release这条命令会先编译Flutter引擎产物,再调用OpenHarmony的打包工具链把宿主壳和资源打包。产物输出到build/ohos/outputs/hap/release目录。整体构建时间比Android慢一些,第一次全量构建可能需要四到六分钟,主要原因是要处理OpenHarmony的工具链依赖和引擎库合并。
构建日志里如果出现针对某个so文件找不到的报错,先检查是不是没有安装完整的Native SDK组件。DevEco Studio的SDK Manager里,Native和ArkTS组件都要装上,只装默认的ArkTS组件会缺链接库。
6.2 Gradle插件声明方式引发的版本警告
构建时日志里出现了很常见的警告:
You are applying Flutter's main Gradle plugin imperatively using the apply script method.
这是ohos模块的settings.gradle用了老式apply from写法导致的。新版Flutter工具链希望你把插件用声明式pluginManagement引进去,否则后续升级工具链时会出现插件的版本解析问题。我把ohos/settings.gradle改成声明式引入插件后,警告消失,构建也更稳定了。这种警告看着不影响编译,但如果你一年后升级Flutter SDK,它可能会变成直接报错。
6.3 打包期AssertionError与SDK版本校验
Flutter 3.x版本打包时偶发这个异常:
java.lang.AssertionError: java.lang.Exception: could not close i...
我遇到的原因是构建缓存里有残留的索引文件句柄没有释放,缓存目录跨进程切换后出现竞争。执行flutter clean,然后手动删掉项目下ohos/.idea目录里的临时缓存,重新构建就正常了。如果依然复现,检查工程路径是否包含中文或空格——这类IO流异常在非ASCII路径下很容易被触发。
另一个SDK版本校验警告值得留意:
The current configured Flutter SDK is not known to be fully supported. Please...
这个警告是因为项目同时被flutter_flutter(openharmony分支)和系统的官方Flutter SDK混用了。确认flutter --version输出里的commit号是openharmony分支对应的版本后,警告即可消除。如果还报,删掉项目下ohos/.gradle和.dart_tool目录,重新执行flutter pub get。
6.4 PlatformView和Impeller在OpenHarmony上的性能适配
应用内嵌了WebView做用药说明帮助中心,在OpenHarmony上首次打开时能看到明显的渲染延迟,这属于PlatformView的性能问题。原因是OpenHarmony的PlatformView嵌入目前没有像Android那样成熟的虚拟显示优化,原生View和Flutter纹理层合成时存在额外开销。
我采用的优化手段有三个:
- 减少PlatformView实例数,用单例WebView容器复用
- 关闭无关的页面切换动画,避免PlatformView合成时机错过垂直同步
- 在宿主工程里显式关闭Impeller引擎,命令是:
flutter.impeller.enabled=falseImpeller引擎在OpenHarmony上的适配还不够成熟,某些渲染路径会回退到软件绘制,导致帧率不稳。对于以列表和图表为核心的药箱App,Skia引擎的表现反而更稳定。这一点和Android/iOS上的趋势相反,在OpenHarmony上做项目不能照搬主流平台的经验。
6.5 最终安装验证与发布前自检
构建成功后,我用hdc工具把hap包装到设备上进行真机验证。安装命令行是:
hdc install build/ohos/outputs/hap/release/medicine_kit-release.hap验证清单我会重点关注三块:一是EventChannel提醒事件进程被杀后是否能恢复,测试方式是调起应用后强行杀后台进程,观察系统提醒是否还能拉起通知;二是体重图表的滚动流畅度,连续滑动30秒看帧率;三是数据库升级,直接从旧版本覆盖安装新包,确认表结构迁移没有报错。
7. 几个容易被忽视的细节,最后一起说透
这个项目做完,有一些零碎但确实影响体验的点,集中在最后分享:
第一,药品过期筛选逻辑。expiry_date字符串直接比较大小是可行的,因为yyyy-MM-dd格式字典序即时间序,但要注意一定统一格式,不能在入库时混入yyyy/M/d这种不规范的写法。我专门在Repository层写了一个formatDateString工具函数,所有日期字段写入前都过一遍,从源头杜绝脏数据。
第二,日期选择器不要直接用第三方库,Flutter自带的showDatePicker在OpenHarmony上表现良好,组件是纯Dart实现,没有原生依赖,主题风格和Material设计保持一致。
第三,字体显示在OpenHarmony上需要单独验证。中文字体在部分OpenHarmony设备上默认字重偏细,我在TextStyle里显式设置了fontFamily回退链,保证数字和中文混排时不会出现奇怪的字体宽度跳动。
第四,药品提醒通知的权限。OpenHarmony的通知权限和Android不太一样,需要在宿主工程module.json5里显式声明ohos.permission.NOTIFICATION,否则Dart侧申请权限拿到的永远是false。
最后说点体感。Flutter在OpenHarmony上已经具备实际项目落地的条件,但不要把它当成和Android完全对等的环境,尤其要注意引擎渲染特性、PlatformView性能和原生插件的适配边界。这个药箱App目前一直在平板电视上服役,用药提醒、体重曲线、药品过期预警这三个核心功能稳定运行,没有遇到需要推翻重来的问题。希望这篇文章能帮你少踩几个我踩过的坑。