先说一个实际的判断:如果你手头有一批OpenHarmony设备,想快速做出一个能用的工具类App,Flutter目前是脚本最少、见效最快的方案。我最近正好给一套智能家居中控设备做了一个家具购买记录App,其中最核心的模块就是商家管理——记录你在哪家店买的沙发、哪家定的柜子、保修期到什么时候,还要能随时翻出商家的电话和地址。这个模块看着不起眼,但踩坑不少,特别是把Flutter项目跑到OpenHarmony上的时候,很多Android/iOS上的经验直接失效。
这篇文章把我的完整实现过程、数据模型设计、组件通信经验和真机调试踩坑都写出来。如果你正好要开发类似的"记录/管理类"工具App,或者想在OpenHarmony设备上跑Flutter却心里没底,这篇文章可以帮你少走一大段弯路。
1. 为什么这个App最合适的落点不是鸿蒙OS而是OpenHarmony
1.1 OpenHarmony与HarmonyOS NEXT在实际开发中的区别
先说个很多人容易混淆的概念。OpenHarmony是一个开源操作系统底座,你可以理解成"操作系统的地基和公共设施";HarmonyOS NEXT是基于OpenHarmony做的商业发行版,像精装修的房子。作为开发者,我们做工具类App经常直接面向OpenHarmony设备,而不是必须上商用系统。
我做这个家具购买记录App的起因,是给一套智能家居中控台设备配一个内置工具。那台设备跑的就是OpenHarmony,GPU性能一般,内存也不算宽裕,但它有个好处:屏幕尺寸固定、系统纯净、没有手机上的各种推送和权限轰炸。这种场景特别适合"一个Flutter应用包解决所有UI"的做法。
如果你手头的目标设备是OpenHarmony的开发板、智能终端、行业平板,那么Flutter for OpenHarmony就是完全可行的路线。它和鸿蒙NEXT上的开发不一样:NEXT那边有官方声明支持的生态,而OpenHarmony这边更多是靠社区维护的Flutter引擎适配分支,走的还是标准Flutter那套Dart代码。
1.2 Flutter for OpenHarmony的成熟度评估
说实话,这个适配分支现在已经到了"能用、能跑、能上真机"的程度,但它不是一马平川。我的体感评估如下:
| 能力 | 成熟度 | 说明 |
|---|---|---|
| Dart UI框架 | 高 | 大部分组件渲染正常,Flex布局、滚动、路由都能用 |
| 平台通道 | 中高 | MethodChannel可用,可以调一些系统能力 |
| 数据库 | 中高 | drift/sqflite的OpenHarmony适配包可以跑通 |
| 相机/相册 | 中 | 需要配合平台通道自实现或使用社区插件,细节有坑 |
| 复杂PlatformView | 低 | 原生嵌入视图的可用性弱,视频类、地图类慎用 |
| 热重载 | 中 | 可用,偶尔不稳定,真机调试要耐心 |
也就是说,凡是以Dart UI为主、依赖原生能力少的工具型App,非常适合。凡是要大量嵌入原生控件、涉及硬件编解码、高性能图形渲染的,就别硬上了,先小范围验证再说。我这个App的商家管理、购买记录、保修提醒、发票照片存储,都属于"标准表单+列表+数据库+少量图片",正好在舒适区里。
2. 商家模块的数据模型设计:字段规划与数据库选型
2.1 家具购买场景下商家表该怎么建
商家管理这个模块,核心不是"能增删改查就完了",而是要把家具购买业务中反复要用到的信息一次设计到位。我最初想的很简单:商家名、电话、地址。但真正开始整理自己家里的家具购买记录时,发现这样远远不够。
举个真实例子:一张实木餐桌是在线下门店定的,门店地址在红星美凯龙;一张床垫是在网店买的,订单在手机App里;一套定制衣柜是厂家上门量尺做的,联系人是设计师小张。这三种商家,后续需要的追溯信息完全不同。门店商家需要门店地址、营业时间;网店商家需要店铺链接、快递发货地址;定制商家需要联系人、生产线备注。如果只用三个字段,后期找保修凭证、问补货、找售后的时候会非常痛苦。
所以我最终设计的商家表如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER 主键自增 | 内部主键 |
| name | TEXT 非空 | 商家名称,用于列表展示和搜索 |
| category | INTEGER | 商家类型:0线下门店,1线上网店,2定制厂家 |
| contact_name | TEXT | 联系人姓名 |
| phone | TEXT | 联系电话 |
| address | TEXT | 地址 |
| store_url | TEXT | 网店链接或官网 |
| rating | REAL | 评分,默认3.0 |
| remark | TEXT | 备注 |
| created_at | INTEGER | 创建时间戳 |
| updated_at | INTEGER | 更新时间戳 |
这个表刻意把"联系人和商家名"分开。因为家具定制商家很多时候你记住的是设计师或者销售顾问的名字,而不是公司全名。搜索的时候,我会让列表同时匹配name和contact_name,这样输入"小张"也能把定制厂家搜出来。
2.2 家具购买记录与商家的关联设计
商家表不是孤立存在的,它要和购买记录表(也就是这个App的核心数据)建立关联。购买记录表的关键字段我这样设计:
CREATE TABLE purchase_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, merchant_id INTEGER NOT NULL, product_name TEXT NOT NULL, category TEXT, price REAL, purchase_date TEXT, warranty_end TEXT, invoice_path TEXT, merchant_name_snapshot TEXT, FOREIGN KEY (merchant_id) REFERENCES merchants(id) ON DELETE CASCADE );注意这个merchant_name_snapshot字段,这是我踩过坑之后补上的。最初我只存了merchant_id,列表展示时需要join商家表拿名字。后来我发现一个真实问题:如果你删掉一个无效商家,所有历史购买记录的外键会跟着变脏,或者显示"未知商家"。但对用户来说,哪怕这个商家倒闭了,他在2019年买的那张床垫的保修期还没过,他仍然需要知道"当年是在哪买的、电话是多少"。
所以我在记录表里冗余存了一份商家的名称快照,列表展示优先用快照,不用每次都去关联查询。这是典型的"读多写少、数据不常变"场景,冗余带来的收益远大于风险。购买记录表里还存了保修到期日期,后续做"保修即将到期提醒"时需要按月扫描这张表,索引要建在warranty_end上。
2.3 数据库方案:为什么我选择了drift的OpenHarmony适配
数据模型定完,接下来是数据库选型。Flutter侧常见的方案有三个:sqflite、drift、Hive。Hive是纯Dart的NoSQL方案,遇上"商家和购买记录外键关联、按条件筛选排序"这类SQL强需求,写起来反而绕。sqflite是老牌关系型方案,SQL操作直接,但代码需要手写好多映射样板。
drift(以前叫moor)有一个明显优势:表结构用Dart代码声明,查询语句类型安全,编译期就能抓到字段名拼写错误。这版本对OpenHarmony也有对应的适配包,用的还是sqflite那套底层SQLite能力,只要按适配包的说明配置好NativeDatabase,真机和模拟器都能跑。
我最终选了drift,是因为这个App后续还要加"按品牌汇总花费""按月份查看购买趋势"这类统计查询。如果字段全是手写字符串拼SQL,维护成本会随时间线性增长;drift的查询可以写成像Dart方法链的格式,后期加字段、加索引都安全很多。
提示:无论选哪个库,都要确认你接的是OpenHarmony适配版而不是原生包。直接用默认的sqflite包在OpenHarmony真机上会报 MissingPluginException,这是新手最常踩的第一个坑。
3. 商家列表页实现:从静态数据到动态搜索过滤
3.1 列表页的UI布局与组件拆分
商家列表页是这个模块的门面,用户90%的时间都停留在这里。我采用的布局是:顶部固定搜索栏,下方是商家卡片列表,右下角悬浮添加按钮,列表支持下拉刷新。整体UI用CustomScrollView + SliverToBoxAdapter + SliverList来实现,搜索栏固定在顶部不随列表滚动,悬浮按钮用Stack叠在右下角。
列表项的卡片设计,我参考了"联系人名片"的思路:左侧是商家类型图标,中间是商家名称和地址/店铺链接,右侧是评分和箭头。类型图标用三个不同的Icon,线下门店、线上网店、定制厂家一眼就能分清。
组件拆成两层:
MerchantListPage:负责状态管理和数据加载。MerchantCard:负责单条商家的展示,接收一个Merchant对象。
写Widget时我习惯把所有可能变化的子组件拆成const构造,这样重建列表时只有真正变化的部分触发重绘。这个表后面有性能优化,先说结论:列表项里如果用了非const的Text样式对象,哪怕只刷新一个评分,也会导致整项重新build。所以我在定义卡片的圆角、边距、字体样式时全部用了常量。
3.2 搜索、排序与筛选的完整实现
搜索是列表页最核心的交互。我的做法是输入框监听文本变化,300毫秒防抖后再去过滤数据。为什么需要防抖?因为用户输入"红木"的时候,中间会经过"红"、"红木"两个状态,如果每次输入都查询数据库,低端OpenHarmony设备上明显能感觉到掉帧。防抖逻辑用Timer实现:
Timer? _debounce; void _onSearchChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { _loadMerchants(keyword: value); }); }过滤条件除了关键字,还有排序和筛选。排序方式我给了两个:按评分从高到低、按创建时间从新到旧。筛选是商家类型的三段选择,用ChoiceChip做在搜索栏下方一排。每次筛选条件变化后,我都重新组装查询参数并加载数据。这里我采用"每次查询走数据库"而不是"一次性全部加载到内存再过滤",因为家具记录App虽然单个商家表数据量不大,但如果后续购买记录多了,列表连带要显示最近的购买时间和购买件数,内存里做关联会越写越乱。
数据库查询的核心代码大致是这样:
Stream<List<Merchant>> watchFilteredMerchants({ String? keyword, int? category, MerchantSort sort = MerchantSort.ratingDesc, }) { final query = _select(_merchants) ..orderBy([(m) => OrderingTerm.desc(sort == MerchantSort.createdAtDesc ? m.createdAt : m.rating)]); if (category != null) { query.where((m) => m.category.equalsValue(category)); } if (keyword != null && keyword.isNotEmpty) { final like = '%$keyword%'; query.where((m) => m.name.like(like) | m.contactName.like(like)); } return query.watch(); }watch()返回Stream的好处是:表单页保存了商家之后,不需要任何手动通知,列表会自动感知数据库变化并刷新。这也是drift这类响应式数据层的核心价值。
3.3 下拉刷新与空状态的细节处理
下拉刷新用Flutter自带的RefreshIndicator,OnRefresh回调里去重新加载数据。这里有一个容易忽略的点:RefreshIndicator要求它的child必须是可滚动的,所以不要把它包在ListView外面,而是让RefreshIndicator直接包住ListView,并且ListView的physics要设置成AlwaysScrollableScrollPhysics,否则内容不满一屏时下拉手势会失效。
空状态也要单独设计。我在列表数据为空的时候展示一个居中的空状态:一个简单的Icon + "还没有商家,点右下角添加"的提示文字。这个看起来不起眼,但实际使用中,空状态能直接引导新用户完成第一个操作,避免用户打开App后发呆。
4. 新增与编辑商家的表单实战:校验、照片、保存链路
4.1 表单组织和输入校验
新增和编辑商家我复用了同一个表单页面,区别只在于进入时是否携带空商家对象。用同一个页面的好处是,校验逻辑、保存逻辑只维护一份,后续加字段不会出现"新增能存、编辑不能存"的割裂。
表单用Form+TextFormField组织,四个必填字段是:商家名称、联系人、电话、类型。其余地址、网店链接、评分、备注都是选填。校验器写在字段的validator参数里,核心规则如下:
- 商家名称不能为空,长度2到30个字符。
- 电话允许座机和手机,只做宽松的正则匹配,比如
^[0-9\-+()]{5,20}$,不做归属地校验。因为有些商家的联系电话是400客服号,用严格手机号正则会误杀。 - 网店链接选填,但如果填了就必须以http://或https://开头,做一次基础格式校验,避免后期点击跳转时崩溃。
- 评分用
Slider组件,1分到5分,步长0.5,显示当前分数。比输入框输入数字体验好得多,也避免了输入5.999这种脏数据。
Form校验时机有个细节:不要等用户点保存时才validate,那样用户会被一连串报错砸蒙。我在每个字段的onChanged里调用validator单独校验该字段,只对"已填写过的字段"显示错误,未参与过的字段保持静默。这样既做到了即时反馈,又不会在页面刚打开时把所有红字错误全弹出来。
4.2 商家Logo/发票照片的选取与本地存储
商家管理里有个很多人会忽略的需求:给商家拍一张门头照,或者存一张发票照片。等到几年后要找保修凭证的时候,你会非常感谢当年存过这张照片。
照片处理是这个模块里在OpenHarmony上坑最多的部分。原生image_picker在适配版上能跑通,但有两个问题:
第一,相册选择器弹出来的是OpenHarmony系统的选择界面,风格和Flutter页面会有点割裂。如果你追求统一体验,可以后期自己用PlatformView或平台通道封装原生相册。我这版先用系统选择器,把体验割裂放在"可接受"一档。
第二,权限问题。在Android上,Android 13之后读相册可以不申请权限,用系统的Photo Picker就行。OpenHarmony上不同版本的权限策略不完全一致,最稳妥的做法是:应用只在需要选择照片的那一刻才通过平台通道调起系统选择器,不要提前申请一堆权限。
照片存储我放在应用私有目录appdata/com.example.furniture_app/files/images/下,数据库只存相对路径。为什么不用公共相册?因为发票照片和商家门头照属于私密数据,用户未必希望它们混进系统相册里;而且私有目录在卸载应用时自动清理,不会给用户留下困惑的残留文件。读取时用rootBundle不需要,用dart:io的File读取即可。这里注意OpenHarmony上的文件路径前缀和Android不完全一样,写路径时不要硬编码/storage/emulated/0/,要用PathProvider获取到的真实根目录。
4.3 保存链路:表单校验 -> 组装Model -> 写库 -> 刷新列表
保存按钮的完整链路我整理为四步:
formKey.currentState!.validate()校验所有字段。- 把TextFormField的控制器值组装成Merchant对象。字段多的场景别一个个手写
Merchant(name: _nameController.text...),那样既啰嗦又容易漏字段。我写了一个fromForm的工厂方法统一组装。 - 写数据库:新增用
insert,编辑用update。编辑时要注意只更新必要的字段,不要整行覆盖。 - 返回列表页:因为列表用的
watch()响应式查询,数据库变化会自动触发界面刷新,不需要手动从当前页传结果回去。
这里涉及一个容易被忽略的异步细节。表单保存通常在按钮点击回调里写:
await _saveMerchant(); if (!mounted) return; Navigator.pop(context);如果你省略了mounted判断,在OpenHarmony真机上如果保存过程超过几百毫秒,用户可能已经手动切走了页面,这时再pop会直接导致Unhandled Exception。这类崩溃在调试期很少遇到,因为调试机性能好、数据库小;真用户设备上数据多了以后,保存超过100毫秒是常态,崩溃率会突然上来。
关于Future.then回调的执行顺序,有人问过"then回调是放入微任务队列吗",这里正好可以解释:是的。Dart里await操作符之后的代码会被调度为微任务,优先于事件循环里的其他事件执行。所以在保存链路里写多个await时,不要假设每个await之间一定会插入其他UI事件,它们会连续执行完。如果你想给用户"保存中"的中间态,需要显式用setState把按钮置灰并显示进度圈,而不是指望两个await之间有时间窗口。
5. 组件通信与状态同步:Flutter侧与OpenHarmony侧的桥接经验
5.1 列表页到表单页的传参方式
列表页点击"新增"进入空白表单,点击某个商家卡片进入编辑表单。传参方式我用的是最简单的构造函数传参:编辑时传入一个Merchant对象,表单页initState时用它的值回填所有控制器。
为什么不优先用路由参数?因为路由参数本质上是字符串映射,传整个Model要么序列化、要么单独存共享状态,都不如构造函数直接传对象来的干净。Flutter的路由是可以携带任意对象的,Navigator.push(MaterialPageRoute(builder: (_) => MerchantFormPage(merchant: merchant))),对象引用直接可用,没有序列化开销。
这个场景不需要考虑"页面被系统回收后路由参数丢失"的问题,因为MaterialPageRoute在栈里的页面不会主动销毁状态。如果你用的是GoRouter这类声明式路由,可能就要额外处理了。
5.2 跨组件状态同步的选型:SetState、Provider 还是 Riverpod
做列表页 + 搜索 + 筛选 + 表单四个组件的状态同步,选型时可以分两个层面看:
- 页面内部临时状态(比如搜索框文本),用
setState足够。 - 跨页面共享的数据(商家列表、当前筛选条件),用响应式数据源(drift的watch)就够了,不一定非要引入全局状态库。
实际写下来,我这个模块没有引入Provider或Riverpod,直接用drift的watch()解决了最大头的"数据变更通知"。组件树里搜索条件变化的通信,因为发生在同一个页面,用StatefulWidget + setState就能搞定。全局状态库在这个规模的项目里是负担大于收益:它引入context依赖、provider层级,调试时还要多跳一层。
但如果你确定后续App要加多个复杂页面(设置页、统计页、提醒页),并且这些页面都要共享商家和购买记录数据,那建议早点接Riverpod或Provider。判断标准很简单:如果"页面A改了数据,页面B要看到变化"的场景超过两个,就用状态库;否则先别上,YAGNI原则在中小型项目里非常适用。
5.3 Flutter侧与OpenHarmony系统能力交互
商家管理里用到的系统能力不多,主要是两个:照片选择和电话拨号。但这两个足够演示Flutter与OpenHarmony平台交互的完整套路。
MethodChannel的注册分两步:
- Flutter侧
MethodChannel('com.example.furniture_app/merchant')调用invokeMethod('pickImage')或invokeMethod('callPhone', {'phone': '138...'})。 - OpenHarmony侧(ArkTS或C++)用适配分支提供的入口注册对应的handler,接收method name和参数,执行系统能力后返回结果。
我踩过的坑:MethodChannel的channel名一定要和Flutter侧完全一致,包括大小写和点号。有一次我Flutter侧写的是merchant_channel,OpenHarmony侧写的是merchant_channel_ohos,真机上调用时静默失败,没有任何日志提示,排查了很久。后来用debugPrint在handler入口打日志,才定位到是名字不匹配。
PlatformView的适配在OpenHarmony上目前是薄弱项。我最初想用原生地图控件嵌入到"商家地址查看"页面,试了几天发现适配分支对PlatformView的支持还不稳定,最后改成用URL在WebView里打开地图链接。如果你做的App必须内嵌复杂原生视图,建议先做一个最小demo验证PlatformView在你目标设备上的真实表现,再决定技术路线。
6. 在OpenHarmony真机上运行的踩坑与调试
6.1 环境搭建中的几个关键步骤
把Flutter项目跑到OpenHarmony真机上,环境搭建比普通Flutter要麻烦一些,但并不神秘。按顺序做四件事:
- 确认Flutter SDK版本。Flutter for OpenHarmony的适配分支不能随便用官方最新stable,要跟随适配仓库的release tag走,先查清楚你clone的适配仓库当前推荐哪个版本,再决定本地Flutter版本。
- 安装OpenHarmony SDK。这一步通常在DevEco Studio里完成,需要你根据目标设备的API版本勾选对应的SDK。
- 克隆项目后先跑一次
flutter pub get,确认所有依赖都有OpenHarmony对应的包。 - 构建应用包并安装。OpenHarmony的构建产物和Android不同,命令行构建要调用适配分支提供的脚本,不要假设
flutter build apk能用。
第一次接触这块的开发者最容易卡在第四步,因为Flutter官方命令在OpenHarmony适配分支上并不能像Android那样一站式。我的建议是:按照适配仓库的README把构建脚本跑通一次,生成安装包后,再考虑能不能把它集成到DevEco Studio的IDE流程里,降低日常构建成本。
6.2 编译与运行时的报错记录
这一节分享几个真机调试中高频出现的报错和我的定位方法。
第一个是典型的Unhandled Exception日志,类似:
E/flutter [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] unhandled exception: ...这个日志看着吓人,但它只是Dart VM捕获未处理异常的通用打印,真正有用的信息在异常类型和堆栈里。我遇到过的两个高频原因:一是在mounted判断缺失时对已经销毁的页面调用了Navigator或setState;二是异步回调里访问了空对象。定位方法很简单:看日志里dart:...堆栈的第一个业务文件,把对应的onPressed或then回调补上判空和mounted保护就行。
第二个是渲染相关的。Flutter新版本默认开启Impeller渲染引擎,在OpenHarmony适配分支上如果你的真机GPU驱动比较旧,可能出现白色屏幕、字体模糊、圆角矩形发黑这类现象。排查顺序是:关掉Impeller(通过gradle/构建配置回退到Skia),如果恢复正常,说明问题是GPU渲染兼容性;再打开Impeller看是否只是个别组件问题。如果你的App对性能要求不高,在低端OpenHarmony设备上直接保留Skia可能比折腾Impeller更省心。
第三个和文本有关。OpenHarmony设备字体渲染和Android有细微差别,中英文混排的行高、字重偶尔会和UI设计稿对不上。这个没有捷径,只能给Text组件增加textScaler和style.height的适配开关,并在多个目标设备上截图比对。
6.3 调试小技巧和性能优化
真机调试时,日志过滤是关键。OpenHarmony设备上系统日志夹杂着各种底层输出,我一般用adb logcat | grep flutter只看Flutter侧日志,或者用--verbose参数启动应用时输出更详细的渲染信息。
列表性能这块,商家列表如果只有几十条,几乎不需要优化。但如果后续做到几百条,有三件小事建议提前做:
- ListView加
itemExtent,固定条目高度,滚动性能会好很多。 - 所有列表项的图片、图标加上缓存,不要在build方法里反复创建ImageProvider。
- 不要用
ListView.builder的childCount里做耗时计算,把需要计算的数据都提前算好,构建方法只负责展示。
我实际体验下来,OpenHarmony低端设备上的Flutter性能比同时期Android低端机大概弱一档。原因不在Flutter本身,而是系统图形栈和GPU驱动还不是那么成熟。所以写UI时尽量克制:少用阴影、少用半透明叠加、少用复杂的Hero动画。不是不能用,而是要先跑真机看帧率再决定要不要保留。
最后再分享一个实用的小技巧。我在这类"记录 + 管理"App上吃过一次亏:只考虑新增,没考虑删除。用户真的会删掉一个已经搬走的商家,但删除之后他可能后悔,因为他需要查那个商家的地址去旧门店开发票。所以在商家管理的删除操作里,我只是把is_deleted字段置为1,而不是物理删行。列表查询默认加WHERE is_deleted = 0。这样既保证了列表干净,又保留了恢复数据的可能性。建议你在设计任何"管理类"数据模型时,都考虑一下软删除,这个习惯能帮你少挖很多坑。
做OpenHarmony上的Flutter应用,本质上是用一套已经成熟的技术栈,去适配一个还在快速演化的系统底座。最核心的经验就一句话:把应用逻辑和平台细节分开,数据层、业务层、UI层都不要直接依赖OpenHarmony的私有能力,这样当底层适配版本更新时,你的迁移成本会小很多。商家管理只是这个思路下的一个落地案例,但思路可以复制到整个App。