我一直觉得,把Flutter跑在OpenHarmony上本身就是一件挺折腾的事,但真正把一个App功能从头到尾做完,才发现折腾里全是值得记录的细节。这篇文章就以我最近调试通的一个垃圾分类指南App为例,讲讲“我的收藏”这个模块是怎么从数据结构设计、状态管理到最终在OpenHarmony设备上稳定跑起来的。如果你正好在搞Flutter for OpenHarmony,或者想了解Provider在真实项目里怎么落地,那这部分内容应该能帮你少踩不少坑。
这个App一开始要做的事情并不复杂:搜索垃圾名称,确认它属于可回收、有害、厨余还是其他垃圾,顺便给出投放建议。但产品提了一个很常见又很核心的需求——用户收藏常用的垃圾条目,之后在首页或者独立列表里一眼看到自己常扔的类别。听上去只是个列表加标记,可真到了多页面共享状态、OpenHarmony和Flutter版本匹配、本地缓存读写这些环节,坑一个接一个。我会把整个过程拆成设计、存储、实现、适配、排查五个部分来聊,重点放在“我的收藏”的实现过程,环境相关的问题也会一并交代。
1. 项目整体设计与技术选型
1.1 为什么在OpenHarmony上选Flutter而不是ArkUI
我参与的这个版本,一开始OpenHarmony和Android要同时交付。OpenHarmony原生应用开发现在主推ArkTS和ArkUI,如果只跑一个设备,直接用ArkUI写会更正统,开发工具DevEco Studio对ArkUI的支持也足够好。但问题在于,这套垃圾分类指南App已经有了完整的Flutter页面库,设计稿也是按Flutter组件体系输出的,单独再维护一套ArkUI页面意味着所有后续改动都要做两遍:列表布局、主题色、交互状态、路由跳转,每个地方都可能出现偏差。
Flutter for OpenHarmony的生态虽然没有ArkUI原生那么“亲儿子”,但它把“一套代码多平台运行”的价值落实得很实在。这里需要说明一下,Flutter for OpenHarmony用的不是Flutter官方主线默认支持的分支,而是OpenHarmony SIG维护的flutter_flutter分支,配合OpenHarmony上的引擎适配壳。OpenHarmony OS的系统主体是C/C++,应用开发层常见的API是ArkTS,而Flutter负责在引擎层把Dart代码桥接到OpenHarmony的C++接口上。实际操作时,绝大多数纯Dart代码可以原样跑起来,但一些和平台底层交互的Plugin需要检查是否有OpenHarmony实现。比如shared_preferences、path_provider在OpenHarmony分支上都有对应实现,而某些商业地图SDK、推送SDK就未必支持。
所以我的选型结论比较保守:如果项目像垃圾分类指南这样,核心页面是列表、详情、搜索、收藏,而且需要多端交付,Flutter for OpenHarmony是划算的。如果业务大量依赖OpenHarmony独有的系统服务,比如分布式软总线、系统级卡片,那还是踏踏实实用ArkUI更稳。这个判断不是拍脑袋,是把这个App的页面结构和系统能力依赖清单拉出来对比后得出的:页面级代码占了九成,系统独占能力几乎没有,Flutter适配的成本被摊得很薄。
1.2 状态管理用Provider的落地考量
“我的收藏”这个需求最麻烦的不是写一个ListView,而是跨页面状态同步。用户在指南详情页点了收藏,收藏列表页要马上多一条;在收藏列表页左滑删除,详情页的爱心图标也要跟着变灰。如果只在每个页面内部用setState,页面之间就只能靠构造参数来回传,传一两层还好,一旦碰到“首页搜索列表点进去的详情页”和“指南分类点进去的详情页”这种多入口场景,回调路径会变得很乱。
最后我选了Provider + ChangeNotifier。理由很直接:ChangeNotifier本身只负责数据通知,Provider负责配合Widget树做依赖注入和重建控制,不需要引入事件流或者Action的概念,对团队里熟悉Flutter StatefulWidget的同事来说,心智负担是最小的。对比Riverpod,它在编译期安全性和功能性上更强,但要多学一套Provider家族的概念,对一个小工具类App有点杀鸡用牛刀。Bloc就更不用说了,如果只是维护一个收藏ID集合,硬上Bloc会导致每个状态变更都写Event再加Reducer,代码量直接翻倍。
具体结构上,我在main函数里创建SharedPreferences实例,然后用MultiProvider一次性挂载FavoritesProvider和AppSettingsProvider。FavoritesProvider内部维护一个Set 用于存放垃圾条目ID,对外暴露isFavorite、toggle、clear等方法。所有用到收藏状态的地方,要么通过context.watch ()来监听,要么通过context.read ()去触发事件。这样详情页和收藏页共享同一个对象实例,状态变化后通知所有监听者,列表和按钮自动重建,完全不需要手动刷新。
下面是一段挂载代码,可以当成模板直接用:
void main() async { WidgetsFlutterBinding.ensureInitialized(); final prefs = await SharedPreferences.getInstance(); runApp(MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => FavoritesProvider(prefs)), ], child: const GarbageGuideApp(), )); }这个写法确保了所有路由页面都能拿到FavoritesProvider。如果你只在某一条路由链上挂Provider,那离开这条链的页面就读不到,收藏状态会变“灵异事件”——同一个App,有的页面还能收藏,有的页面直接报ProviderNotFound。挂在runApp外面是最不容易出错的。
2. 数据模型与本地存储设计
2.1 垃圾分类条目的数据模型怎么定义
先交代一下App的基础数据长什么样。垃圾分类指南的数据是从assets目录下打包的JSON文件加载的,每条记录包含:唯一ID、垃圾名称、类别、详细说明、投放注意事项和一个图标名。类别我固定成四个枚举值:recyclable、hazardous、kitchen、other,对应可回收、有害、厨余和其他。这样定义的好处是,列表页可以根据类别直接映射颜色和Tag文本,不需要每个页面重复写switch。
Dart这边的模型类,我并没有用json_serializable这类代码生成库,因为数据字段固定,手写fromJson和toJson也就十几行,反而更直观。当然,如果项目后期条目涨到几千条并且有后端动态下发,我会考虑引入freezed或者json_serializable,减少手工维护字段的体力活。收藏功能并不需要把完整的GarbageItem对象序列化存储到本地,因为全部数据已经在assets里,我只需要保存垃圾条目的ID,运行时通过ID去数据表里查完整信息。这样最省存储,也避免了两份数据不一致的问题。
需要特别注意的一个点:OpenHarmony上的Flutter App,assets相关内容在构建时就要跟随bundle打包,如果后期更新了asset数据,没有重新安装或者热更新,旧版App拿到的还是旧数据。所以“收藏ID + 静态数据”的方案在离线型工具类App里是一个很好的组合;但如果数据是服务端动态配置的,那收藏模型就要改成保存整个JSON快照,否则联网之后ID可能失效。
2.2 收藏列表的持久化方案
本地存储的方案我在SQLite和SharedPreferences之间纠结过一阵。SQLite在大数据量、复杂查询下有优势,但收藏列表的形态比较固定:一个ID集合,最多也就几百条,不涉及到按分类SQL过滤,所有的过滤可以在内存里完成。所以最终选SharedPreferences,理由是它在这个场景下足够、部署简单,同时OpenHarmony分支上对shared_preferences的支持很完整,不用再处理SQLitePlugin的兼容问题。
| 方案 | 优点 | 缺点 | 本项目结论 |
|---|---|---|---|
| SQLite | 查询强、事务可靠、适合复杂关系 | 插件适配和代码复杂度高 | 不选 |
| SharedPreferences | 简单、键值语义清晰、插件适配完善 | 只适合小量非结构化数据 | 选 |
| 文件 + JSON | 灵活可控、可自定义格式 | 需要自管路径和异常恢复 | 不选 |
我用了shared_preferences插件来保存一个List ,key命名为favorite_ids。每次用户点击收藏按钮,我先更新内存里的Set,再异步把整个ID列表写入preferences。这里的异步写入不需要等它完成才能刷新界面,但如果写入失败,会导致App杀掉后收藏丢失,所以我会在写完后加一个Debug日志确认。如果你想做得更严谨,可以给写入操作加个try/catch,失败时弹一个SnackBar提示“收藏保存失败”,同时保留内存状态,等下一次操作再重试。
这里有一个小坑:SharedPreferences在OpenHarmony上第一次调用getInstance时,底层会去读配置文件,如果在App启动时急着用它,会拿不到实例。我的做法是在main函数里await SharedPreferences.getInstance(),拿到之后把它作为参数传进FavoritesProvider的构造函数。这样构造出来的Provider内部一定有一个可用的prefs实例,后续读写都不会出现空指针。
3. “我的收藏”功能实现全过程
3.1 收藏状态管理类FavoritesProvider
这里直接放核心代码,FavoritesProvider我写成这样:
class FavoritesProvider extends ChangeNotifier { FavoritesProvider(this._prefs) { _load(); } final SharedPreferences _prefs; final Set<String> _ids = {}; final List<String> _orderedIds = []; static const String _prefsKey = 'favorite_ids'; List<String> get orderedIds => List.unmodifiable(_orderedIds); bool get isLoaded => _loaded; bool isFavorite(String itemId) => _ids.contains(itemId); Future<void> _load() async { final stored = _prefs.getStringList(_prefsKey); if (stored != null) { _ids.addAll(stored); _orderedIds.addAll(stored); } _loaded = true; notifyListeners(); } Future<void> toggle(String itemId) async { if (!_ids.add(itemId)) { _ids.remove(itemId); _orderedIds.remove(itemId); } else { _orderedIds.add(itemId); } notifyListeners(); await _prefs.setStringList(_prefsKey, _orderedIds.toList()); } Future<void> removeAll() async { _ids.clear(); _orderedIds.clear(); notifyListeners(); await _prefs.remove(_prefsKey); } }几个容易被忽略的点:Set只用于判断当前是否收藏,保证isFavorite是O(1);List用于维持收藏顺序,保证ListView展示稳定。_load在构造函数里发起,但由于SharedPreferences实例已经就绪,所以没有竞态问题。我没有用FutureBuilder去等Provider完成加载,而是在收藏列表页面里用一个isLoaded标志位控制空状态,避免刚打开页面时因还没加载完而误显示“暂无收藏”。
3.2 收藏列表页面与空状态
收藏列表页我用的不是复杂的长列表优化方案,就是Scaffold + AppBar + Consumer嵌套一个ListView.separated。数据源通过context.watch ().orderedIds拿到,再用GarbageRepository根据ID查完整数据,拼成List 。这里有个细节:查询时不能直接在build方法里去遍历几千条原始数据,否则每次状态变化都要全量匹配。我提前按ID建了一个HashMap<String, GarbageItem>,查询复杂度降为O(1),列表rebuild时也流畅。
空状态是整个页面最容易做得像“半成品”的地方。当收藏列表为空时,我返回一个居中的Column,上面是一个灰色心形图标,下面写“还没有收藏内容”,再给一个“去看看垃圾指南”的按钮,点击后跳转指南首页。因为OpenHarmony和Android的字体渲染不太一样,中文文本偶尔会回退成系统默认字体,所以空状态文案长度要控制好,不要写太长溢出屏幕。
列表项我长这样:左侧是一个圆形的图标占位,中间是垃圾名称和类别Tag,右侧是取消收藏的IconButton。列表项点击进入详情,按钮点击只处理取消收藏。为了避免点击IconButton时触发整行的onTap,我把IconButton单独包在一个GestureDetector里,并且用InkWell包住整行而不是直接用ListTile。这里有个实战经验:在OpenHarmony的Flutter分支上,InkWell的点击水波纹表现和Android原生有细微差异,但功能正常,不用太纠结。
3.3 收藏按钮交互与反馈
收藏按钮的完整交互分成三块:状态展示、点击动效、操作反馈。状态展示是用Icon的两种形态:isFavorite ? Icons.favorite : Icons.favorite_border,颜色用主题红和灰色。动效我用AnimatedSwitcher,里面根据状态切换Icon组件,切换时带一个ScaleTransition,让心形有一个“弹一下”的感觉。代码大致是:
AnimatedSwitcher( duration: const Duration(milliseconds: 200), transitionBuilder: (child, animation) { return ScaleTransition(scale: animation, child: child); }, child: Icon( isFav ? Icons.favorite : Icons.favorite_border, key: ValueKey(isFav), color: isFav ? Colors.redAccent : Colors.grey, ), )点击触发FavoritesProvider.toggle后,我会用SnackBar给一个反馈:“已收藏到我的列表”或“已取消收藏”。SnackBar在OpenHarmony的Flutter分支上默认显示在底部,样式和Android一致,但连续多次点击时会出现SnackBar排队,看起来很傻。我加了ScaffoldMessenger.of(context).removeCurrentSnackBar()先移除旧的再显示新的,这个细节建议你也加上。
还有一个操作性问题:收藏按钮如果放在AppBar的actions里,点击事件是安全的。但如果你把收藏按钮和列表项放在同一个Row里,一定要注意点击热区至少48x48,否则OpenHarmony上的触摸采样差异会让用户觉得“老点不中”。我实际把IconButton的padding调大了一圈,同时给外层加了一个Tooltip,真机手测没有误触问题。另外,为了防止用户在一秒内连续点击导致存储写入覆盖,我加了一个轻量防抖:在toggle开始后置一个_toggleDebounce标记,Dart单线程模型下await之前的同步段不会被插队,但为了UI安全性,我还是忽略了短时间内重复触发。
3.4 从列表进入详情页的数据传递
收藏列表页点击条目后要跳到详情页。最直接的办法是把整个GarbageItem对象通过构造函数传给下一个页面,但这样列表页和详情页会强耦合,一旦详情页还需要额外数据,构造函数就要改。这个项目我用了路由参数传递ID,然后在详情页内部通过Repository读取完整数据。好处是收藏列表页只关心“我要打开ID=xx的详情”,详情页自己负责数据加载,方便后续做深链或者通知栏跳转。
在OpenHarmony上,Navigator.push的页面转场动画和Android原生类似,但不同版本会有差异。我没有用自定义PageRouteBuilder,只用了默认MaterialPageRoute,保证兼容性。而且,如果从收藏列表进入详情,再清理App进程,然后重新打开App,应该直接落在首页而不是停留在详情页;详情页的返回逻辑不需要特殊处理,因为收藏列表本来就在Navigation栈里,返回行为天然是回到列表。
4. OpenHarmony平台适配与差异处理
4.1 Flutter for OpenHarmony的环境准备
要在OpenHarmony上跑Flutter,第一步就不是flutter create那么简单。我在项目里用的是OpenHarmony SIG维护的flutter_flutter分支,把它checkout到本地并配置到PATH,然后用这个分支的flutter命令创建工程。创建完成后,工程目录里会多出ohos/目录,对应OpenHarmony应用工程结构,需要配合DevEco Studio安装的OpenHarmony SDK来构建。
环境依赖这块,有两点值得记下来。第一,flutter命令不能和官方版本混用,否则plugin的注册代码路径会出现奇怪错乱。第二,OpenHarmony的SDK环境变量和构建工具链要确认好,hdc命令要能连上设备,IDE里要能看到设备列表。我遇到最多的问题是本地明明装了OpenHarmony SDK,flutter build却报找不到SDK,最后定位到local.properties里的sdk.dir路径写错了。如果你也遇到,先检查这个文件,别一上来就重装环境。
4.2 存储路径与插件适配
OpenHarmony的沙箱目录和Android不完全一致,path_provider在不同平台上返回的路径也不同。OpenHarmony分支的path_provider实现里,getApplicationDocumentsDirectory返回的是应用沙箱下的files目录或对应文档目录。如果直接把Android开发时硬编码的路径用在OpenHarmony上,大概率会读不到文件。
我的收藏功能因为用的是shared_preferences,没有涉及手写文件路径,所以没踩太深。但如果你后续要导出收藏列表到CSV或者备份,建议统一通过path_provider获取目录,不要把路径写死在代码里。还有一个插件适配的习惯问题:Flutter官方插件市场的插件很多只有Android/iOS实现,OpenHarmony需要额外确认ohos目录下的实现类是否存在。shared_preferences、path_provider、url_launcher这些常用插件都在SIG仓库里有适配;但类似flutter_secure_storage这种依赖硬件安全能力的插件,就要多看两眼了。我这次没有用安全存储,因为收藏列表也不算敏感数据。
4.3 渲染引擎Impeller与其他性能问题
OpenHarmony分支的Flutter引擎,在渲染底层上还在持续适配。根据我使用的社区分支情况,Skia是当前比较稳的渲染路径,Impeller适配还不完整。如果在OpenHarmony设备上运行时发现文字模糊、圆角区域出现黑边、或者滚动时偶发闪屏,不用怀疑自己的代码,先检查是不是渲染引擎的问题。
我在真机上跑收藏列表时,Flutter日志里出现过一次与Impeller相关的warning,后来改为Skia渲染就正常了。这个改动不是通过代码完成的,而是在启动时给Flutter引擎传入--no-enable-impeller参数,或者在OpenHarmony对应配置里关掉。具体位置取决于你用的移植壳,我在项目里是改的engine_layer配置。如果你是在官方主线跑Android设备,Impeller默认开启是没问题的;但切到OpenHarmony分支,不要盲目把Android的优化经验照搬过来。
5. 常见问题与调试心得
5.1 新建项目后跑不起来的排查思路
这个标题也是我踩得最深的坑。新建Flutter项目之后,第一次执行flutter run,OpenHarmony设备上经常会启动失败。我见到的原因集中在三类:Gradle仓库没有配置到OpenHarmony需要的地址、OpenHarmony SDK版本分支与Flutter分支不匹配、以及hdc未连接。
处理方法是先跑一条flutter doctor检查Flutter分支和第二平台支持是否正常。如果显示OpenHarmony相关项没有问题,再手动执行hdc list targets确认设备可见。之后用flutter run -d 带上完整日志,看到类似“Unable to load native library”的报错,多半是产物库没打进去,可以在ohos模块里检查libs目录下是否有libflutter.so。这个文件不存在,App根本起不来。不要一看到红色日志就认为是Dart代码问题,OpenHarmony的插件机制和Android的aar/jar不是同一套,多看看构建产物。
我补充一个和XTS认证相关的经验:如果后续要上架OpenHarmony应用市场,兼容性测试里对应用接口调用有约束。Flutter适配层如果用了系统私有接口,很可能在XTS测试中挂掉。所以写业务时尽量走标准Flutter plugin接口,不要自己去调OpenHarmony私有API,这个原则在收藏功能上也适用。
5.2 Unhandled Exception的定位思路
我在开发过程中经常能看到这种日志:
e/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: MissingPluginException(No implementation found for method getAll on channel plugins.flutter.io/shared_preferences)这种抛错最直接的原因是shared_preferences的OpenHarmony实现没有被注册。OpenHarmony分支的Flutter插件注册方式和Android略有区别,有时候需要在ohos目录下手动初始化插件实例。我当时是在MainApplication的onCreate里看到的模板代码,它实际上会扫描插件,但插件如果不在依赖列表里,或channel名字不一致,就会出现MissingPluginException。
遇到Unhandled Exception千万别只盯着最后一行。我自己的习惯是先看栈顶,判断异常是来自Dart层还是平台层;然后看channel名称,判断哪个插件报错;最后检查是不是插件版本与Flutter分支不匹配。比如shared_preferences你如果用了pub上最新版,而SIG分支的Plugin化框架只适配到某个旧接口,就会在运行时找不到实现。解决办法就是将该插件版本锁定到SIG仓库示例工程里对应的版本。
5.3 收藏状态丢失与页面不同步排查
收藏状态丢失,现象很多,但原因基本可归为四类:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 冷启动后收藏列表空白 | 异步加载未完成,页面先渲染了空态 | 增加isLoaded标志,加载完成前显示loading |
| 点击收藏后页面没刷新 | build里用了read而不是watch | 统一build方法里用watch,回调里用read |
| 快速连续点击后数据丢失 | 多次写入覆盖了本地存储 | 给toggle加防抖或串行写入队列 |
| 多页面状态不一致 | Provider挂载层级不对 | 用MultiProvider挂在runApp外层 |
其中第一类最常见。FavoritesProvider里_load是异步的,App一启动页面就能构建,但prefs的读取结果要过一会儿才回来。如果页面默认展示空列表,用户会看到一瞬间的“还没有收藏内容”闪烁。我给收藏页面加了一个初始化判断:isLoaded为false时显示一个轻量的CircularProgressIndicator,加载完成后再展示真正的列表,这个体验问题就解决了。
5.4 给后来者的一点建议
如果让我把这次实战浓缩成几句话,我最想说的是:Flutter for OpenHarmony目前更像一个“可用但需要耐心”的适配方案,不要拿完整版Flutter在Android上的体验去衡量它。写“我的收藏”这种功能,难的不是UI,而是环境和对齐。在动手写第一个页面之前,先把Flutter分支版本、OpenHarmony SDK版本、所有插件版本固定住,并跑通一个包含shared_preferences、path_provider和Navigator的最小示例。这套验证通过之后,再往上堆业务,效率高得多。
另外,调试工具也别一味照搬Android经验。OpenHarmony上很多日志不会进Logcat,我用的是hdc shell hilog命令过滤Flutter关键字,效率很高。开发过程中如果发现页面交互卡顿,不要急着优化Dart代码,先看看是不是渲染引擎或GPU驱动在OpenHarmony上的兼容问题。针对性降级Skia后,列表滚动就稳定了。
个人习惯上,我还给收藏功能加了简单的角标统计:收藏数量大于0时,首页底部Tab上显示一个小数字。这个改动虽然只有几行,但在真机上对“用户感知收藏是否成功”帮助很大。虽然这个模块一开始只是“我的收藏”,但一个顺手的小反馈,往往比功能本身更能打动用户。