ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter适配OpenHarmony实战:家具App分类管理模块全解析

Flutter适配OpenHarmony实战:家具App分类管理模块全解析

最近在忙一个挺有意思的项目:把一套 Flutter 写的家具购买记录 App 适配到 OpenHarmony 设备上。这个 App 本身不算复杂,核心就是记录家里每件家具的购买信息,比如品类、价格、购买渠道、日期这些,然后按分类去统计和回顾。

不过真跑起来之后,我发现最花时间的反而不是业务功能本身,而是“分类管理”这个听起来不起眼的模块。它牵扯到数据模型设计、持久化方案选型、UI 交互状态管理,还有 OpenHarmony 上 Flutter 的种种兼容细节。今天就把这个模块的完整实现过程拆开聊聊,包括我踩过的坑和最后采取的方案。

这套东西适合谁看?如果你正在做 Flutter 的多平台适配,或者想在 OpenHarmony 上跑一个带本地存储、带增删改查的真实项目,又或者只是好奇 Flutter 在非 Android 系统上到底有多“有缝”,这篇都能给你一些参考。

1. 方案选型:Flutter 在 OpenHarmony 上的适配思路

1.1 为什么要用 Flutter 写 OpenHarmony 应用

选择 Flutter 来适配 OpenHarmony,而不是直接用 ArkTS 重新开发,主要看重两件事:代码复用率和团队技术栈的延续性。

我当初接这个项目的时候,团队里已经有了一套为 Android 写的 Flutter 版本家具购买记录 App,功能、UI、数据层都调得差不多了。要是为了适配 OpenHarmony 用 ArkTS 重新撸一遍,等于把 UI、状态管理、持久化、路由全重写,工作量直接翻倍,后续维护还要养两套代码。

Flutter 的跨端优势在于它的渲染引擎是自绘的,不依赖系统原生控件。也就是说,只要你把 Flutter 引擎在 OpenHarmony 上跑起来,Dart 层写的 Widget 基本都能正常渲染。这跟系统 UI 组件差异导致的那种“处处要兼容”的情况完全不一样,省了很大一部分适配精力。

1.2 开发环境与工具链的准备

在 OpenHarmony 上跑 Flutter,第一步是拿到对应版本的 Flutter SDK。官方主分支的 Flutter SDK 并不直接支持 OpenHarmony,你需要用 OpenHarmony 组织维护的 Flutter 分支。

我当时用的版本是基于 Flutter 3.7 的 OpenHarmony 适配版。具体步骤是这样的:

下载 OpenHarmony 专用 Flutter SDK 分支,解压到本地目录。然后在环境变量里把 flutter 命令指向这个 SDK。

接着用 DevEco Studio 打开生成的 android 工程目录,DevEco Studio 会自动识别并加载 OpenHarmony 的工程配置。这里有个关键点:直接跑 flutter run 是行不通的,OpenHarmony 的构建流程不依赖 Gradle 的 Android 插件。你需要先把 Flutter 工程编译出产物,再通过 DevEco Studio 去打包和部署。

我记得当时折腾环境花了差不多一整天,主要卡在 SDK 版本匹配上。后来我把系统镜像、DevEco Studio、Flutter SDK 的版本都锁定到了官方文档推荐的组合,才顺利跑通。这里建议不要用太新的 DevEco Studio,反而老版本的兼容性更稳。

2. 家具购买记录 App 的整体设计与数据模型

2.1 核心业务需求拆解

家具购买记录 App 的业务主线很清晰:用户买了一件家具,App 记录下来,之后可以按分类、按时间、按价格去回顾和分析。

分类管理在这个 App 里承担的角色,是给所有家具记录做归类。我采用按家具类型分类的方式,而不是按房间位置,因为用户关注的核心维度是“我买了什么类型的家具”,比如沙发、床、柜子、桌椅。分类模块的功能需求就三条:新增分类、编辑分类、删除分类。

不过删除分类这个操作有个业务联动:分类下面可能有购买记录。如果直接把分类删了,那这些购买记录就变成“无家可归”的状态。我最后的处理方式是:删除分类前检查该分类下是否有记录,有的话提示用户,并让用户决定是否连带删除记录。

2.2 本地数据存储方案选择

Flutter 在 Android 上常用的本地数据库是 sqflite,但在 OpenHarmony 上直接跑 sqflite 会碰到原生插件适配的问题。因为 sqflite 依赖 Android 的 SQLite API,OpenHarmony 虽然也有 SQLite,但接口路径、JNI 绑定方式都不一样,官方适配插件当时还不稳定。

我试过两种替代方案:用 drift 数据库框架的 OpenHarmony 适配版,以及直接用 JSON 文件存本地。drift 适配版当时在真机上读写有点慢,而且生成代码的构建步骤和 OpenHarmony 的打包流程有冲突,最后还是放弃了。

我最后采用的是 JSON 文件存储方案。每条分类就是一个 JSON 对象,整个分类列表存成一个 JSON 数组,写入应用私有目录下的文件。考虑到分类管理的数据量非常小,一般家用场景可能也就几十个分类,JSON 文件完全扛得住,而且实现简单、可控性强。

2.3 分类数据模型设计

分类模型在设计时考虑了家具场景的特殊需求,除了基础字段,还加了一个图标字段用于 UI 展示。我用的是内置图标库的索引值,而不是存图片,这样可以避免引入资源文件管理和网络加载的问题。

实际模型定义如下:

class Category { String id; // 唯一标识,用时间戳 + 随机数生成 String name; // 分类名称,如:沙发、床、柜子 int iconIndex; // 图标索引,对应内置图标列表 double budget; // 分类预算,可选字段,用于后续统计 int createdAt; // 创建时间戳,用于排序 }

budget 字段是后来加的。用户给“沙发”设一个预算上限,然后购买记录表里每添加一条沙发记录,App 就能自动算这个分类的剩余预算。这个功能很实用,也是我推荐你在做类似项目时一定要预留的字段,因为买家具这件事本来就有预算规划的需求。

主键 id 不用自增数字,而是用 DateTime.now().millisecondsSinceEpoch 加上一个随机数拼接。这样在同步数据或者未来做多端数据合并时,不会因为自增 ID 冲突导致数据覆盖。

2.4 购买记录与分类的关联设计

分类和购买记录是典型的一对多关系。我在购买记录模型里存了 categoryId 字段,而不是嵌套存整个分类对象。这样做的原因是:如果分类名称改了,所有购买记录里显示的名称会自动跟着变;如果嵌套存储,就要遍历所有记录去更新,很容易漏。

有人可能会问,那如果分类删了呢?前面说过,删除分类时会检查并处理关联记录。具体逻辑是:在执行删除之前,先查一下该分类下的记录数量,如果大于零,就弹出对话框提示用户,并提供两个选项:“连记录一起删”和“取消删除”。这个设计虽然简单,但避免了最头疼的脏数据问题。

3. 分类管理核心功能实现

3.1 服务层:分类数据的增删改查

不管 UI 怎么变,数据层先稳定下来。我单独建了一个 CategoryService,把所有操作封装成异步方法,这样后续切换到真正的数据库也不用改 UI 代码。

class CategoryService { static final String _filePath = '/data/storage/el2/base/files/categories.json'; static Future<List<Category>> loadCategories() async { File file = File(_filePath); if (!await file.exists()) return []; String jsonStr = await file.readAsString(); List<dynamic> list = json.decode(jsonStr); return list.map((e) => Category.fromJson(e)).toList(); } static Future<void> saveCategories(List<Category> categories) async { File file = File(_filePath); String jsonStr = json.encode(categories.map((e) => e.toJson()).toList()); await file.writeAsString(jsonStr); } static Future<void> addCategory(Category category) async { List<Category> categories = await loadCategories(); categories.add(category); await saveCategories(categories); } static Future<void> updateCategory(Category category) async { List<Category> categories = await loadCategories(); int index = categories.indexWhere((e) => e.id == category.id); if (index >= 0) { categories[index] = category; await saveCategories(categories); } } }

这里是按 OpenHarmony 的实际应用沙箱路径写的文件路径。Android 上你一般会通过 path_provider 拿路径,但 path_provider 这个插件在 OpenHarmony 上的适配我当时测试还不稳定,干脆直接写了。

如果你对路径不确定,可以在真机上跑一段代码,打印 Directory.current.path 看看当前目录在哪。我调试的时候发现 OpenHarmony 的沙箱路径和 Android 差别不小,别按 Android 的目录结构去推测。

3.2 数据源隔离:在 UI 层和文件层之间加缓冲

直接每个页面都调用 loadCategories 从文件读,性能上没问题,但会导致 UI 状态难以统一。比如分类列表页里删了一个分类,返回首页统计页面时,首页的分类数据还是旧的,必须重新触发一次加载。

我的方案是引入了一个单例的 CategoryStore,内存中维护一份 List<Category>,所有页面通过它读写,每次写操作成功后同时更新内存和文件。

class CategoryStore { static final CategoryStore instance = CategoryStore._(); CategoryStore._(); List<Category> _categories = []; List<Category> get categories => List.unmodifiable(_categories); Future<void> init() async { _categories = await CategoryService.loadCategories(); } Future<void> add(Category category) async { _categories.add(category); await CategoryService.saveCategories(_categories); } Future<void> delete(String id) async { _categories.removeWhere((e) => e.id == id); await CategoryService.saveCategories(_categories); } }

这样做最大的好处是状态同步及时。任何一个页面操作完分类,其他页面通过 Store 拿到的都是最新数据,配合 Provider 做界面刷新非常顺滑。

3.3 分类列表页的实现

分类列表页是整个模块的主页面。我把它分成两部分:上半部分是分类统计小结,下半部分是分类列表,完整代码实现如下:

class CategoryListPage extends StatelessWidget { @override Widget build(BuildContext context) { final categories = CategoryStore.instance.categories; int totalCount = categories.length; return Scaffold( appBar: AppBar(title: Text('分类管理')), body: Column( children: [ Padding( padding: EdgeInsets.all(16), child: Text('共 $totalCount 个分类'), ), Expanded( child: ListView.separated( itemCount: categories.length, itemBuilder: (context, index) { final item = categories[index]; return ListTile( leading: CircleAvatar( child: Icon(IconData(item.iconIndex)), ), title: Text(item.name), subtitle: Text('预算:${item.budget} 元'), trailing: PopupMenuButton( onSelected: (value) { if (value == 'edit') { // 跳转编辑页 } else if (value == 'delete') { // 删除操作 } }, itemBuilder: (_) => [ PopupMenuItem(value: 'edit', child: Text('编辑')), PopupMenuItem(value: 'delete', child: Text('删除')), ], ), onTap: () { // 跳转到分类详情 }, ); }, separatorBuilder: (_, __) => Divider(height: 1), ), ), ], ), ); } }

列表用 ListView.separated 来渲染,分类卡片里展示了分类名称、预算和图标。PopupMenuButton 是 Flutter 内置的菜单组件,在 OpenHarmony 上渲染为右上角的三个点按钮,点击弹出编辑和删除选项。

使用 PopupMenuButton 而不是常用的长按弹出菜单,主要考虑是给用户更直观的“可见可点”操作方式,特别是不太熟悉长按手势的普通用户。

3.4 新增与编辑分类页实现

新增分类和编辑分类共用同一个页面,通过传入的分类参数决定模式。核心表单字段包括:分类名称输入框、图标选择器、预算输入框。

class CategoryEditPage extends StatefulWidget { final Category? category; @override State<CategoryEditPage> createState() => _CategoryEditPageState(); } class _CategoryEditPageState extends State<CategoryEditPage> { late final TextEditingController _nameController; late final TextEditingController _budgetController; int _selectedIconIndex = 0; bool get isEdit => widget.category != null; @override void initState() { super.initState(); _nameController = TextEditingController(text: widget.category?.name ?? ''); _budgetController = TextEditingController( text: widget.category?.budget.toString() ?? '' ); _selectedIconIndex = widget.category?.iconIndex ?? 0; } Widget _buildIconPicker() { List<IconData> icons = [ Icons.chair, Icons.weekend, Icons.bed, Icons.cabinet, Icons.table_restaurant, Icons.light, ]; return GridView.count( crossAxisCount: 6, shrinkWrap: true, physics: NeverScrollableScrollPhysics(), children: icons.map((icon) { int index = icons.indexOf(icon); return GestureDetector( onTap: () => setState(() => _selectedIconIndex = index), child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: _selectedIconIndex == index ? Colors.blue.withOpacity(0.2) : Colors.transparent, ), child: Icon(icon), ), ); }).toList(), ); } }

图标选择器用 GridView 配合六个家具相关图标做展示,选中后高亮背景色。为什么用 index 而不是直接用 IconData?因为图标是存在 JSON 里的,JSON 里不能存 IconData 对象,存索引就方便了——存的时候存数字,读的时候通过索引在图标列表里反查。

保存逻辑是这样:

Future<void> _save() async { String name = _nameController.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('分类名称不能为空')) ); return; } double budget = double.tryParse(_budgetController.text) ?? 0; final category = Category( id: isEdit ? widget.category!.id : DateTime.now().millisecondsSinceEpoch.toString(), name: name, iconIndex: _selectedIconIndex, budget: budget, createdAt: isEdit ? widget.category!.createdAt : DateTime.now().millisecondsSinceEpoch, ); if (isEdit) { await CategoryStore.instance.update(category); } else { await CategoryStore.instance.add(category); } Navigator.pop(context); }

这里保存前只做了非空校验,预算字段允许为空,空的时候按 0 处理。因为预算在分类管理里不是必填项,用户可以不设,后续统计时不计算超预算逻辑就行了。

3.5 删除分类与级联记录处理

删除分类的完整逻辑是实现上最需要小心的地方。服务层准备好之后,UI 层的调用要加一层确认和保护。

Future<void> _deleteCategory(Category category) async { int recordCount = await RecordService.countByCategory(category.id); if (recordCount == 0) { // 直接删除 await CategoryStore.instance.delete(category.id); return; } bool? confirmed = await showDialog<bool>( context: context, builder: (context) => AlertDialog( title: Text('删除分类'), content: Text('该分类下有 $recordCount 条购买记录,是否连同记录一起删除?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: Text('取消'), ), TextButton( onPressed: () => Navigator.pop(context, true), child: Text('连记录一起删'), ), ], ), ); if (confirmed == true) { await RecordService.deleteByCategory(category.id); await CategoryStore.instance.delete(category.id); } }

在删除前先查询关联记录数量,这条查询语句是 RecordService 里最简单的按条件计数。如果记录为零,静默删除;如果有记录,必须让用户知晓。这个确认步骤虽然多一次弹窗,但避免了很多“事后找不回来”的麻烦。

我踩过一个很典型的坑:开始没做这个检查,直接删了一个分类。后来用户反馈,之前记的“餐桌”购买记录全没了,只剩一张孤零零的收据。从此以后,任何删除操作我都遵循“先查关联,再确认,再删除”的三步原则。

4. OpenHarmony 适配问题与排查记录

4.1 文件路径与沙箱机制

前面提到过,OpenHarmony 的文件路径和 Android 不一样。Android 的 app 私有目录通常通过 path_provider 获取,路径类似 /data/data/包名/files。OpenHarmony 的应用沙箱路径结构更复杂,并且不同 API 版本的路径规则有差异。

我建议的做法是:第一,先跑一个测试页面,用 Directory.current 打印当前路径;第二,把文件读写封装成单独的服务层,路径集中在一个常量文件里;第三,不要用绝对路径写死,而是通过相对路径结合当前目录拼接。

如果你用的是较新版本的 OpenHarmony,可以尝试通过 device.getApplicationInfo 相关 API 获取沙箱路径。老版本就直接写死 /data/storage/el2/base/files,亲测可用。

4.2 Flutter 插件的兼容性边界

Flutter 生态里很多常用插件在 OpenHarmony 上都是不可用的,这是一个必须提前接受的事实。我遇到的具体问题包括:path_provider 不可用、shared_preferences 有适配版但需要单独引入、sqflite 完全不可用。

最好的策略是尽量减少插件依赖,能用 Dart 原生能力解决的就不引插件。比如 JSON 存储,我就不用任何插件,Directly 用 dart:io 的 File 读写。Dart 的 isolate、异步 IO、字符串处理完全够用。

如果你要加图片、网络、本地通知这些功能,要先查一下对应的 OpenHarmony 适配插件在不在维护。有些以社区贡献者维护形式存在,质量参差不齐,尽量以官方示例项目里的推荐插件为准。

4.3 构建产物与签名配置

OpenHarmony 的 Flutter 工程构建流程,和 Android 差别非常大。Android 跑 flutter build apk 就得到一个 apk,签好名就能装。OpenHarmony 这边需要先生成 HAP 包,然后配签名。

签名配置要提前做。我当时拿到的开发设备要求必须先有签名证书才能安装应用,没有签名的 HAP 包直接装不上。配置签名的时候注意,需要把对应模块的 profile 文件和证书文件都放在工程目录里,并在 DevEco Studio 的界面里完成配置。

还有一个小坑:每次丢签名配置或者载入新工程时,构建出来的 HAP 包可能还是旧签名。清洁构建一下,把 build 目录删掉再重新生成。

4.4 中英文输入法与键盘问题

在 OpenHarmony 真机上,Flutter 自带的文本输入能力存在一个已知问题:系统中文输入法弹出的键盘,在部分版本上无法正确关联 Flutter 的输入框。具体表现是,你点击输入框,键盘弹出来了,但按键没有反应,或者输入的字母不进入文本框。

这个问题一开始让我以为是代码写错了,排查了很久。后来发现是 Flutter 引擎和系统的输入法服务对接不完整。临时解决方案是:在输入框上强行设置某个输入类型,部分情况下能触发正确的输入关联。

如果遇到这个问题,可以试试在 TextField 上设置 keyboardType 和 textInputAction,尽量避免使用明文类型(multiline),或者升级到修复过该问题的 Flutter 适配版本。这个问题已经有人在 GitHub 上反馈,新的适配分支有修复,但需要重新集成。

4.5 调试效率提升技巧

OpenHarmony 上的 Flutter 调试,热重载(Hot Reload)支持的稳定性不如 Android。有一次我在真机上频繁热重载,直接导致设备和开发机连接断开,必须重新拔插 USB。

后来我的工作流是:先写好单元测试,保证业务逻辑无误,再部署到真机做 UI 验证。UI 验证时尽可能批量调整,减少热重载次数。真机和模拟器的渲染效果也有差异,模拟器流畅不代表真机流畅,最终以真机为准。

5. 分类管理的经验总结与后续优化方向

5.1 当前实现方案的总结性评估

这套分类管理模块跑下来,整体稳定性没问题。JSON 文件方案在当前数据量下读写速度尚可,分类列表页几百条记录滑动流畅,没有卡顿。内存单例的 CategoryStore 保证各页面数据一致,增删改查逻辑清晰,代码可维护性比较高。

不过要清楚这个方案的上限。当购买记录数量达到几千条、分类数量超过一百时,每次改动都重写整个 JSON 文件的策略就不合适了。这时候需要引入真正的数据库方案,或者至少把文件切分成“分类表文件”和“记录表文件”分别维护。我现在还能用 JSON,是因为家具购买记录这种场景天然低频,一天能记录三四条就不错了。

5.2 下一步可以加的实用功能

基于现在这个分类管理基础,有几个扩展方向是很自然的:

分类排序功能。现在的列表按创建时间排列,用户新建一个“其他”分类后只能排到最后,体验一般。可以加一个拖拽排序,把排序结果存进 Category 模型的 sortOrder 字段,展示时按 sortOrder 排序。

分类预算超限提醒。模型里已经有 budget 字段,下一步可以做:统计分类下所有记录的总金额,超过预算时在列表页显示红色警示。这个功能需要在 RecordService 里加一个按分类分组求金额的聚合方法,不算复杂。

分类合并功能。用户很可能创建了“沙发”和“沙发椅”两个分类,实际买完发现在统计时想合并。做这个功能需要批量更新购买记录的 categoryId,把旧分类的记录全部转移到新分类下,再删掉旧分类。这个功能对数据一致性要求高,值得仔细设计。

5.3 我个人在实际适配过程中的体会

这几个星期的适配经历让我最深刻的一点体会是:所谓跨平台适配,最大的成本不在 UI 渲染,而在生态依赖。Flutter 在 OpenHarmony 上跑 UI 没问题,但周边插件的适配进度,直接影响项目的技术选型和实施排期。如果你要做的 App 大量依赖地图、支付、推送这类第三方原生能力,那在 OpenHarmony 上的适配成本会呈指数上升。反过来,像我们这种数据逻辑为主、UI 自绘、不依赖系统独有能力的工具类 App,适配成本就很可控。

另一个体会是:项目架构的低耦合设计,在跨端适配时价值特别大。因为数据层封装得干净,切换存储方案时我只动了 CategoryService 内部的实现,UI 层完全不用改。如果当初把数据库操作散落在各个页面里,这次适配光是改路径和改读写逻辑就够崩溃的。

最后,如果你也计划做 OpenHarmony 的 Flutter 适配,建议先做一个小规模的验证工程,只包含一个页面、一个文件读写操作,完整走通“开发-编译-签名-真机部署”链路之后,再开始迁移业务代码。这个验证工程能帮你提前暴露工具链的问题,避免在业务代码上边适配边排查,效率完全不一样。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进