
前阵子我把一直在维护的 Flutter 家具购买记录 App 往 OpenHarmony 上迁第一个完整做完的功能就是品牌管理。Flutter for OpenHarmony 这个话题看起来热度很高真正落地时会发现很多帖子只讲了“能跑起来”很少聊到“一个完整业务模块怎么做”。品牌管理这种功能刚好是个试金石它有标准 CRUD有列表和搜索还要处理本地存储、状态刷新、页面跳转几乎把日常 App 开发的基础环节都覆盖了。这篇文章我按实际开发顺序写从需求拆解和技术选型开始接着讲环境搭建、数据模型、UI 实现、CRUD 细节最后把 OpenHarmony 适配期间踩过的坑全部交底。适合正在评估 Flutter 跨端到 OpenHarmony 的团队也想给打算拿一个小项目验证技术方案的开发者做参考。1. 项目思路与选型为什么从品牌管理切入1.1 家具购买记录里的品牌管理到底要做什么做这个 App 的初衷很简单家里装修完买家具沙发、床垫、餐桌、书架这些东西买完三个月可能还记得价格等过了一年需要售后时品牌、型号、保修期往往全忘了。于是想做一个小工具把每次家具购买的信息记录下来核心的查询维度就是品牌。品牌管理在这个场景里其实承担着“主数据维护”的角色。它至少要做四件事维护品牌档案品牌名称、产地、备注信息比如客服电话、保修政策。建立品牌和购买记录的关联一条家具购买记录必须归属于某个品牌否则后续按品牌统计就是空谈。支持按品牌浏览和检索列表里能看到每个品牌名下有多少件家具、累计花了多少钱。提供常规的新增、编辑、删除操作。这里我没有把需求做复杂。像品牌 Logo 上传、品牌官网跳转、云端同步这些功能看起来有想象空间但对第一版落地没有太大帮助反而会牵扯图片存储、网络权限这类 OpenHarmony 适配成本更高的东西。品牌管理的 MVP 边界就是一个干净可靠的本地数据模块先把记录和分类的基础打牢。实际开发中我给自己限定了一个原则凡是能在 Dart 侧完成的逻辑绝不在原生层碰。品牌管理恰好是一个插件弱依赖模块非常适合用来验证 Flutter 在 OpenHarmony 上的基础能力也方便后续排查问题时缩小范围。1.2 用 Flutter 而不是 ArkTS 重写一遍现在 OpenHarmony 应用开发的主流方式确实是用 ArkTS 加 ArkUI官方支持度也最高。但 Flutter 团队里通常已经沉淀了不少跨端业务代码如果为了 OpenHarmony 重新用 ArkTS 写一遍双端维护成本直接翻倍。我的出发点很现实能不能把现有 Flutter 这套 App 低成本移植到 OpenHarmony先跑通业务模块再逐步适配平台能力。这里要说明我使用的不是 Google 官方版本而是社区适配 OpenHarmony 的 Flutter SDK 分支。这个分支在 API 对齐和引擎裁剪上会比标准分支慢一点但日常 UI 渲染、路由跳转、文本输入这些能力已经基本可用。选型时我的判断依据是现有 Flutter 业务代码复用率能到八成以上品牌管理不依赖相机、定位、推送这类高风险原生能力同一套代码未来可以在 Andorid、iOS、OpenHarmony 三个平台继续迭代。关键要接受一个事实Flutter 生态里大部分第三方插件在 OpenHarmony 上还没有原生实现很多 GitHub 上写着跨平台的包跑在 OpenHarmony 上会直接抛 MissingPluginException。所以工程结构从一开始就要把“数据存储”这类能力隔离开给自己留出切换实现的余地。2. 环境搭建与工程骨架跑通 OpenHarmony 的 Flutter 项目2.1 环境准备与版本匹配这个步骤没有太多黑科技但版本匹配最容易让人崩溃。我本地的组合是某稳定适配版 Flutter SDK配合 DevEco Studio 里下载的 OpenHarmony SDK。先保证两条命令能跑通flutter --version flutter doctorflutter doctor 不一定能自动识别 OpenHarmony 环境所以还要手动确认 ohos 相关目录是否存在。如果 Flutter 工程还没有生成 ohos 平台目录可以尝试从已有模版工程里复制或者用适配版 SDK 提供的项目初始化命令创建flutter create --platforms ohos --template app furniture_app这个命令生成的工程会自动带出ohos/目录里面有一套 OpenHarmony 工程的壳。没有的话检查一下 Flutter 版本是不是带 ohos 支持的 dev 分支或者直接去社区项目仓库拉一个示例工程做对比。经验是不要一上来就装最新 Flutter main 分支。调试开发没问题但一旦遇到输入法或者渲染异常很难判断是业务问题还是引擎问题。建议坚持使用与 OpenHarmony SDK 版本配套的稳定适配版并固定版本号不要轻易升级。开发机上我用了 OpenHarmony 模拟器先跑通 Hello World然后才把项目代码逐步拷贝进去。真机调试比模拟器更重要因为 OpenHarmony 的输入法、交互回调、屏幕适配在模拟器上往往会掩盖问题。至少准备一台开发板或真实设备后面会替你节省大量排查时间。2.2 目录结构与依赖清单品牌管理模块的目录结构我尽量保持清晰因为后续还要加购买记录、统计报表、品类管理如果从一开始就是平铺的大文件夹后面会很难维护。这是我最终采用的骨架lib/ main.dart models/ brand.dart stores/ brand_store.dart providers/ brand_provider.dart pages/ brand_list_page.dart brand_detail_page.dart widgets/ brand_form_dialog.dart依赖我只保留了真正用到的几个dependencies: flutter: sdk: flutter provider: ^6.1.2 path_provider: ^2.1.3 intl: ^0.18.1provider 用来管理品牌列表状态path_provider 负责找到持久化目录intl 用于格式化时间和金额。没有引入重量级数据库或者状态管理框架原因在后面数据存储和状态管理两节会详细展开。main.dart里的初始化逻辑也很简单创建 JsonBrandStore再创建 BrandProvider用 Provider 包住整个 App。这样页面里面不需要再关心 store 如何构造后续替换数据库实现只改一行注册代码。3. 数据模型与存储方案先想清楚再写代码3.1 品牌模型字段是怎么定出来的建模最忌讳一步到位我参照的是家具购买记录的真实查询需求。一个品牌档案最终保留在这些字段class Brand { final String id; final String name; final String origin; final String remark; final int createdAt; int furnitureCount; double totalSpent; Brand({ required this.id, required this.name, required this.origin, required this.remark, required this.createdAt, this.furnitureCount 0, this.totalSpent 0, }); MapString, dynamic toJson() { return { id: id, name: name, origin: origin, remark: remark, createdAt: createdAt, furnitureCount: furnitureCount, totalSpent: totalSpent, }; } factory Brand.fromJson(MapString, dynamic json) { return Brand( id: json[id] as String, name: json[name] as String, origin: json[origin] as String? ?? , remark: json[remark] as String? ?? , createdAt: json[createdAt] as int, furnitureCount: json[furnitureCount] as int? ?? 0, totalSpent: (json[totalSpent] as num?)?.toDouble() ?? 0, ); } }选 id 时我没有用整型自增因为文件存储场景下自增 id 需要额外记录当前序列而且删除、合并时容易出问题。我用的是“时间戳 随机数”的字符串主键格式不美观但足够唯一。origin 字段对应“产地/品牌国别”有的品牌在全球多个国家销售用户不一定记得准确产地但这个字段允许为空只是辅助筛选。remark 才是真正的宝藏用来记录保修电话、安装注意事项、购买门店等非结构化信息。furnitureCount 和 totalSpent 其实不属于品牌原始档案它们是聚合字段来自家具购买记录。放到 Brand 里主要是方便列表页直接展示不用每次都去遍历所有记录。这个设计在数据量几百条时完全够用如果以后数据大了再拆分也不迟。3.2 存储层用 JSON 文件撑起第一版再考虑数据库第一版我开始想得很简单按之前做 App 的经验直接上 sqflite。结果在 OpenHarmony 跑起来之后发现 sqlite 相关的原生通道并没有完全接入这个 Flutter 分支启动即报插件缺失。我评估了一下专门去给插件补 OpenHarmony 实现工作量远大于业务模块本身。于是换了个思路品牌数据量不大单机使用为什么不用结构化 JSON 文件这个选择不是退步而是尊重平台生态现状。文件方案的好处是平台依赖少核心只需要一个“能拿到应用持久化目录”的能力甚至拿不到时也可以回退到相对目录。我封装了一个 BrandStore 抽象abstract class BrandStore { FutureListBrand loadAll(); Futurevoid saveAll(ListBrand brands); } class JsonBrandStore implements BrandStore { final String _filePath; JsonBrandStore(this._filePath); override FutureListBrand loadAll() async { try { final file File(_filePath); if (!await file.exists()) return []; final content await file.readAsString(); if (content.isEmpty) return []; final data jsonDecode(content) as Listdynamic; return data.map((e) Brand.fromJson(e as MapString, dynamic)).toList(); } catch (_) { return []; } } override Futurevoid saveAll(ListBrand brands) async { final file File(_filePath); await file.create(recursive: true); await file.writeAsString(jsonEncode(brands.map((e) e.toJson()).toList())); } }实际初始化路径直接用 path_provider 获取文档目录final dir await getApplicationDocumentsDirectory(); final store JsonBrandStore(${dir.path}/brands.json);这个方案有一个明显的劣势并发写入时要做好串行保护而且每次保存都会全量落盘。但目前品牌数量通常几十个JSON 序列化后再写入耗时可以忽略不计。在代码注释里我也提醒自己将来引入购买记录表如果要按日期、金额做复杂聚合查询再换 SQLite届时只需实现同一个 BrandStore 接口对上层 UI 完全透明。很多开发者会觉得第一版就上一个数据库才是“正规做法”但我这几年的体会是选型要贴着业务规模和平台现状走能跑、能改、能换比一味追求架构完整更重要。品牌管理本身就是验证这一点的好场景。4. 品牌管理状态与界面实现4.1 Provider 管理品牌数据状态管理我用了 provider没有选 bloc 和 riverpod。品牌模块的数据流很简单加载列表、增删改、通知刷新ChangeNotifier 天然够用写起来也直白。团队里其他成员接手心理门槛也低。BrandProvider 核心代码如下class BrandProvider extends ChangeNotifier { final BrandStore _store; ListBrand _brands []; bool _loading false; String _keyword ; BrandProvider(this._store); ListBrand get brands { if (_keyword.isEmpty) return _brands; return _brands.where((b) b.name.contains(_keyword)).toList(); } bool get loading _loading; Futurevoid load() async { _loading true; notifyListeners(); _brands await _store.loadAll(); _loading false; notifyListeners(); } Futurevoid addBrand(Brand brand) async { _brands.add(brand); await _store.saveAll(_brands); notifyListeners(); } Futurevoid updateBrand(Brand updated) async { final index _brands.indexWhere((b) b.id updated.id); if (index 0) return; _brands[index] updated; await _store.saveAll(_brands); notifyListeners(); } Futurevoid deleteBrand(String id) async { _brands.removeWhere((b) b.id id); await _store.saveAll(_brands); notifyListeners(); } void setKeyword(String keyword) { _keyword keyword.trim(); notifyListeners(); } }有几个细节值得注意。load 方法进入时先置 loading 并 notify是为了让页面能显示进度如果文件不存在也没有数据会返回一个空列表不会抛异常。brands 的 getter 里直接做关键词过滤界面层不需要再维护一套过滤列表。保存顺序我也做了规定先改内存列表再写文件最后 notifyListeners。这样即使文件写入失败UI 层还是保持用户已经看到的最终结果至少不会出现界面和状态不一致的闪跳。4.2 列表页、空态和搜索列表页的整体布局是 AppBar 加右下角 FloatingActionButton中间根据状态显示 loading、空态或者 ListView。第一版不搞复杂但用户体验不能太生硬。body: ConsumerBrandProvider( builder: (context, provider, child) { if (provider.loading) { return const Center(child: CircularProgressIndicator()); } final filtered provider.brands; if (filtered.isEmpty) { return const Center(child: Text(还没有品牌点击右下角添加)); } return ListView.separated( itemCount: filtered.length, separatorBuilder: (_, __) const Divider(height: 1), itemBuilder: (context, index) { final brand filtered[index]; return ListTile( title: Text(brand.name), subtitle: Text(${brand.origin} · ${brand.remark}), trailing: Text(${brand.furnitureCount}件), onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: (_) BrandDetailPage(brandId: brand.id), ), ); }, ); }, ); }, ),搜索框我放在了 AppBar 下方的安全区域而不是 AppBar 的 actions 里主要为了方便输入也不用担心标题栏宽度不够。搜索输入时调用 provider.setKeyword列表实时过滤。空态文案不是随便写的。“还没有品牌点击右下角添加”这句话同时揭示了当前状态和下一步操作比一句“暂无数据”有用得多。后来使用测试里几乎所有人都能第一时间明白该怎么操作。至于品牌详情页我目前展示的是品牌基础信息和一个“购买记录列表”占位页。因为在家具记录模块还没完成前详情页强行堆功能反而显得空洞。我先放了一个简单的信息卡片并列出关联的购买记录数量后面再接列表。5. 新增、编辑、删除品牌管理的完整 CRUD5.1 表单校验与保存新增和编辑共用一个弹窗表单这是最省代码也最不容易出现双份逻辑的写法。我封装了一个 BrandFormDialog内部用 Form 和 TextFormField 做校验。class BrandFormDialog extends StatefulWidget { final Brand? brand; const BrandFormDialog({super.key, this.brand}); override StateBrandFormDialog createState() _BrandFormDialogState(); } class _BrandFormDialogState extends StateBrandFormDialog { final _formKey GlobalKeyFormState(); late final TextEditingController _nameController; late final TextEditingController _originController; late final TextEditingController _remarkController; override void initState() { super.initState(); _nameController TextEditingController(text: widget.brand?.name ?? ); _originController TextEditingController(text: widget.brand?.origin ?? ); _remarkController TextEditingController(text: widget.brand?.remark ?? ); } void _submit() { if (!_formKey.currentState!.validate()) return; final brand widget.brand; if (brand null) { final newBrand Brand( id: ${DateTime.now().microsecondsSinceEpoch}_${Random().nextInt(9999)}, name: _nameController.text.trim(), origin: _originController.text.trim(), remark: _remarkController.text.trim(), createdAt: DateTime.now().millisecondsSinceEpoch, ); Navigator.of(context).pop(newBrand); } else { final updated Brand( id: brand.id, name: _nameController.text.trim(), origin: _originController.text.trim(), remark: _remarkController.text.trim(), createdAt: brand.createdAt, furnitureCount: brand.furnitureCount, totalSpent: brand.totalSpent, ); Navigator.of(context).pop(updated); } } override Widget build(BuildContext context) { return AlertDialog( title: Text(widget.brand null ? 添加品牌 : 编辑品牌), content: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( controller: _nameController, decoration: const InputDecoration(labelText: 品牌名称), validator: (v) { if (v null || v.trim().isEmpty) { return 品牌名称不能为空; } return null; }, ), TextFormField( controller: _originController, decoration: const InputDecoration(labelText: 产地), ), TextFormField( controller: _remarkController, decoration: const InputDecoration(labelText: 备注), maxLines: 2, ), ], ), ), actions: [ TextButton( onPressed: () Navigator.of(context).pop(), child: const Text(取消), ), ElevatedButton( onPressed: _submit, child: const Text(保存), ), ], ); } }编辑时这里有个小坑。新建和编辑如果都通过同一个 pop 返回 Brand 对象页面侧需要判断当前是“新增”还是“编辑”。这个判断我放在列表页的_openForm方法里Futurevoid _openForm({Brand? brand}) async { final result await showDialogBrand( context: context, builder: (_) BrandFormDialog(brand: brand), ); if (result null) return; final provider context.readBrandProvider(); if (brand null) { provider.addBrand(result); } else { provider.updateBrand(result); } }表单校验目前只处理了非空判断后续应该增加重名判断。品牌名称虽然在业务上要求唯一但真正做校验时需要遍历现有品牌列表我放在 provider 侧过滤一遍比在表单组件里直接依赖全局状态更干净。5.2 删除策略和关联数据处理删除品牌绝对不只是 removeWhere 一下那么简单。家具购买记录和品牌是多对一关系如果直接把品牌删掉之前的购买记录就会变成“无家可归”的孤儿数据。我在第一版就吃过这个亏某条沙发购买记录还带着品牌名但品牌详情页已经打不开了查询统计时那一类数据直接消失。为了避免这个问题我定下的策略是如果品牌下已经关联了购买记录就不允许直接删除。用户在列表页删除时会看到一个提示说明“该品牌下还有 N 件购买记录请先处理关联记录或改为编辑”。Futurevoid _deleteBrand(Brand brand) async { if (brand.furnitureCount 0) { await showDialogvoid( context: context, builder: (_) AlertDialog( title: const Text(无法删除), content: Text(${brand.name} 下还有 ${brand.furnitureCount} 条购买记录建议改为编辑。), actions: [ TextButton( onPressed: () Navigator.of(context).pop(), child: const Text(知道了), ), ], ), ); return; } final ok await showDialogbool( context: context, builder: (_) AlertDialog( title: const Text(删除品牌), content: Text(确定删除品牌“${brand.name}”吗), actions: [ TextButton( onPressed: () Navigator.of(context).pop(false), child: const Text(取消), ), ElevatedButton( onPressed: () Navigator.of(context).pop(true), child: const Text(删除), ), ], ), ); if (ok true) { context.readBrandProvider().deleteBrand(brand.id); } }这个策略在产品层面不是唯一解比如也可以设计成“删除品牌时把记录迁移到未分类”给用户一个选择。但对我这个单机小工具来说先保护数据完整性比提供复杂的原数据迁移更重要。等购买记录模块完整了再在设置页提供“合并品牌”能力也不迟。6. 常见问题和排障实录6.1 OpenHarmony 适配期遇到的三个典型问题做品牌管理模块期间我确实积累了一些之前做 Android 时从没遇到过的坑这里列成速查表现象可能原因处理方式调用 path_provider 后抛 MissingPluginExceptionOpenHarmony 分支还没有实现对应的原生 channel改用内置文件路径策略或暂时在调试模式用 Directory.current 定位TextField 点击后中文输入法不弹起只能输英文输入法框架与 Flutter 引擎通道冲突升级适配版 SDK并在运行时禁用 suggestions 相关配置对比验证页面使用系统返回键时无法正常 pop路由监听在 OHOS 端只走了生命周期没有走 WidgetsBinding在 App 入口加全局返回统一处理避免依赖默认 Navigation 行为HAP 安装到设备失败签名证书未正确配置在 DevEco Studio 里生成调试证书或检查项目签名配置第三个问题最隐蔽表面看是返回键失灵实际上可能是 OpenHarmony 的窗口管理把返回事件先交给原生侧处理了Flutter 引擎没有按照 Android 的习惯自动回调。我最后是在原生入口用一个统一事件桥接把系统返回键转为 Flutter 的导航指令。这个做法不算优雅但至少解决了功能问题而且对品牌列表这类带层级导航的页面非常管用。6.2 性能和状态刷新调优品牌列表的数据量不大性能瓶颈通常不在这里但几个细节还是值得留意。不要在 build 方法里执行文件读取或任何异步操作。我的做法是在页面 initState 里调用 provider.load然后 UI 只消费 provider 里的数据。否则每次键盘弹出、resize列表都会去读一次文件很快就会发现卡顿。状态刷新要克制。addBrand 时我一次性修改 _brands然后 save 到磁盘最后再调一次 notifyListeners。如果每改一个字段就 notify 一次用户会看到列表反复重建而且弹窗关闭瞬间会有明显闪烁。还有一点是列表的 key。品牌列表里我用的是品牌 id不是 list 索引。删除一个品牌后Flutter 才能准确复用那些没变化的 item减少无谓的重绘。尤其在后面接入详情页返回时这个细节能让体验顺畅不少。列表项的金额显示可以用整数“元”展示但内部计算要用 double。真正统计时精度的坑更多建议用分为单位做整型运算展示时再除以 100。这个我在品牌模型里先预留了 totalSpent 字段等购买记录模块开发时就不会因为类型问题返工。这次品牌管理做下来我最大的感受是Flutter for OpenHarmony 的成熟度还没有到可以无脑平推的程度但它也没有传言中那么不可用。关键是选对场景品牌管理这种不依赖摄像头、定位等复杂原生能力的业务模块恰恰是最适合做验证的。如果你们团队也在评估要不要把 Flutter 应用迁到 OpenHarmony我建议第一件事不是搭建宏伟架构而是把业务模块按插件依赖程度排个序从品牌管理这种弱依赖模块开始跑通一个完整闭环后再逐步扩大范围。别把 OpenHarmony 当作另一个 Android 来看文件路径、输入法、返回键这些细节都要在真实设备上反复测。这套品牌管理的实现虽然简单但分层、状态管理、存储隔离的思路后续做任何模块都能直接复用。