做微动漫App的时候,我给自己挖过一个坑:首页、详情页、我的页面都要用到收藏,一开始图省事在每个页面里各自setState,结果收藏状态到处不同步。详情页点完收藏,回到首页角标没变,再点进去发现按钮状态还是旧的。后来换成Flutter for OpenHarmony,问题又多了一层——鸿蒙生态的插件支持不像Android那么齐全,很多常用的Flutter包拿过来直接跑,轻则编译不过,重则运行期挂掉。
这篇文章记录的是我在一个微动漫类App里落地收藏功能的完整过程,包括业务边界怎么拆、存储和状态管理怎么选、Dart和ArkTS之间怎么通信,以及打包上OpenHarmony设备时踩过的坑。如果你是正在做OpenHarmony适配的Flutter开发者,或者刚接触跨端开发想找一个能把生态链路走通的小项目,收藏功能刚好合适。它不是那种“几行代码搞定”的玩具,但也没有复杂到让人望而却步。
1. 先理清收藏功能的业务边界
1.1 微动漫App里的收藏到底承担了什么
微动漫App的内容形态很典型:短动画、动态漫画、条漫,更新节奏快,单集时长短。用户的收藏行为跟长视频平台不一样,更多是在碎片时间里“先把东西收起来,晚点再看”。所以收藏功能在微动漫App里至少承担三个角色:追更单、备看清单、情感标记。
需求评审阶段,产品通常会提一堆“想要”的功能,比如收藏夹分组、标签管理、批量导出、分享收藏单。我的建议是MVP阶段先砍到最小闭环:能收藏、能取消收藏、能看到已收藏列表、有明确的空状态。先把这几个操作跑通,再去考虑分组和云同步。原因很简单,收藏功能看起来简单,实际牵扯本地存储、全局状态、页面通信、原生桥接,每多一个需求,复杂度都会翻倍。
还有一个容易被忽略的非功能约束——离线可用。微动漫用户在地铁、电梯里看内容,网络很差是常态。用户在弱网环境下点收藏,如果还要等服务器响应才算成功,体验基本就废了。所以无论后续要不要做云同步,收藏动作本身必须先落本地,然后再考虑上行同步。这个判断会影响后面的技术选型。
1.2 技术选型:本地存储和状态管理怎么配
本地存储我第一版用了shared_preferences家族的OHOS适配版本。很多人一听就说“这玩意不是只能存字符串吗”,但对收藏这个场景来说,数据量小、结构简单、读写频率低,Preferences完全够用。我踩过一个过度设计的坑:项目刚起步就把sqflite、drift、objectbox全列进方案,结果功能没写多少,数据库迁移和样板代码先堆了几百行。收藏这种轻量功能,先把KV存储跑通,等收藏量真上万了再换数据库也不迟。
状态管理我选了flutter_bloc里的Cubit。选择理由很务实:收藏状态需要被App里多个页面共享,而且操作动作只有toggle收藏、加载列表、清空列表这几类,Cubit写起来比完整Bloc少很多模板。我也见过用Provider做同样事的,没问题,但Cubit对异步操作和流式通知的支持更顺滑,后面接EventChannel推送收藏变更时还能复用同一套Stream思路。
组件通信层面,既然选了全局状态管理,页面之间“点完收藏另一个页面不知道”的问题就已经解决了一半。另一半需要靠平台通道:如果后续要做云端收藏同步,Dart侧要告诉ArkTS侧去发起网络请求,ArkTS侧收到服务端推送后又要通知Dart侧刷新收藏列表,这就分别对应MethodChannel和EventChannel。通信方案不是越多越好,而是每个场景有最合适的那一个。
提示:技术选型阶段最重要的不是“哪个框架最强”,而是“这个功能需要的最小可靠方案是什么”。收藏功能用不到的重量级状态管理、数据库组件,一律砍掉。
2. 搭建Flutter for OpenHarmony工程,别在第一步就卡住
2.1 环境搭建和ohos平台目录
想在OpenHarmony上跑Flutter,标准官方版Flutter SDK并不能直接支持,你需要使用带ohos平台支持的Flutter SDK分支,一般由OpenHarmony SIG或社区维护。拉下来之后,把Flutter的bin目录配到PATH里,和正常Flutter开发一样。
创建工程时,记得把ohos平台加进去:
flutter create --platforms ohos,android .如果你的项目已经存在,也可以在工程根目录手动补一个ohos目录,然后用DevEco Studio打开这个目录,IDE会自动识别hvigor工程配置。
一个很容易踩的坑是:很多人习惯了在Android Studio里新建Flutter项目,到了OpenHarmony侧也用AS强行管理ohos目录,结果配置经常对不上。我的建议是分工明确:Dart侧代码继续用Android Studio或VSCode写,ohos原生工程统一交给DevEco Studio管理。两边IDE共用同一个工程目录,不会冲突,但别试图用一套IDE把所有事情都管了。
环境变量这里我吃过亏。除了Flutter SDK路径,还要确保ohos-sdk所在路径被正确指引到hvigor配置里。以下是我个人项目里用到的环境参考:
| 组件 | 项目里的版本/路径 | 备注 |
|---|---|---|
| Flutter SDK | 社区OHOS适配分支,锁定3.22.x | 不要用滚动版本,否则插件适配容易崩 |
| OpenHarmony SDK | API 12及以上 | 一些新组件API只有高版本才有 |
| DevEco Studio | 5.x | 用于管理ohos目录和签名 |
| hvigor | 随DevEco Studio自动配置 | 打包hap的构建工具 |
2.2 OpenHarmony插件适配的核心思路
Flutter on OpenHarmony的系统架构,简单说就是Dart Framework通过Flutter引擎调用OpenHarmony的图形、输入、平台服务。插件的作用是让Dart代码能调到ArkTS侧的原生能力。理解这个之后,判断一个Flutter插件能不能在ohos上用就很简单:看它有没有ohos原生目录和对应的ArkTS实现,没有的话就得自己补。
收藏功能通常要依赖shared_preferences、path_provider这类基础插件。如果你发现某个插件没有ohos支持,不要立刻放弃整个方案,先看看它的实现逻辑。比如shared_preferences,Dart侧API就是一套,Android用SharedPreferences,iOS用NSUserDefaults,OpenHarmony侧可以用系统Preferences封装一个同名实现。社区里也有一些大佬维护了ohos适配版本,遇到问题还可以自己写插件。
之前我们给一个登录认证插件做OHOS适配,流程可以抽象成三步:把原生能力封装成ArkTS模块,在Flutter侧声明MethodChannel,处理Dart和ArkTS之间的类型映射。收藏插件要做的事也是一模一样的套路。很多人一听到HDI就紧张,其实HDI是硬件接口层,大部分App业务根本碰不到,只有做系统能力适配或者接底层音视频编解码时才会涉及。微动漫App如果后面要接自家播放器SDK,通常是用PlatformView来做,而不是直接操作HDI。
再说一个跟渲染相关的坑。新版Flutter引擎默认启用Impeller渲染器,但OpenHarmony适配分支对Impeller的支持可能滞后。如果你在真机上发现画面出现奇怪的色块、花屏或者某些动画不刷新,可以先试着关闭Impeller再跑一次:
flutter run --no-enable-impeller3. 收藏功能核心代码:从持久化到页面交互
3.1 用FavoritesRepository封装存储逻辑
我不建议让UI直接操作SharedPreferences。哪怕功能再简单,也要加一层Repository。原因很简单:今天你用Preferences,明天想换sqlite,后天要接云同步,如果UI层直接散落着几十处prefs读写,改起来会想哭。
先定义一个收藏条目模型。注意一定要存完整展示信息,只存ID的坑我踩过——收藏列表页需要标题和封面,没有这些信息就只能去服务端逐个请求,离线状态下整个列表直接废掉:
class FavoriteItem { final String id; final String title; final String coverUrl; final int createdAt; FavoriteItem({ required this.id, required this.title, this.coverUrl = '', required this.createdAt, }); Map<String, dynamic> toJson() => { 'id': id, 'title': title, 'coverUrl': coverUrl, 'createdAt': createdAt, }; factory FavoriteItem.fromJson(Map<String, dynamic> json) => FavoriteItem( id: json['id'] as String, title: json['title'] as String, coverUrl: json['coverUrl'] as String? ?? '', createdAt: json['createdAt'] as int, ); }然后写Repository:
class FavoritesRepository { static const _prefsKey = 'favorite_items'; Future<List<FavoriteItem>> loadAll() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_prefsKey); if (raw == null || raw.isEmpty) return []; final list = jsonDecode(raw) as List<dynamic>; return list .map((e) => FavoriteItem.fromJson(e as Map<String, dynamic>)) .toList(); } Future<void> saveAll(List<FavoriteItem> items) async { final prefs = await SharedPreferences.getInstance(); final raw = jsonEncode(items.map((e) => e.toJson()).toList()); await prefs.setString(_prefsKey, raw); } }这个实现有几个刻意的选择。第一,整个收藏列表序列化成一条JSON存储,读取时一次性拿到全部数据,简单粗暴但够用。第二,每次保存是全量覆盖,收藏数量少的时候没有性能问题。第三,createdAt字段是排序依据,新收藏的条目要排在最前面,没有这个时间戳后面做云同步合并时也会非常被动。
3.2 用Cubit管理收藏状态,页面永不丢状态
收藏状态是一个典型的全局共享状态:详情页需要判断“当前作品是否已收藏”,首页需要展示收藏角标,收藏列表页需要展示全部已收藏内容。我用Cubit来管理这一份状态:
class FavoriteCubit extends Cubit<List<FavoriteItem>> { FavoriteCubit(this._repository) : super(const []); final FavoritesRepository _repository; Future<void> load() async { final items = await _repository.loadAll(); emit(items); } bool isFavorite(String id) => state.any((e) => e.id == id); Future<void> toggle(FavoriteItem item) async { final exists = isFavorite(item.id); final next = List<FavoriteItem>.from(state); if (exists) { next.removeWhere((e) => e.id == item.id); } else { next.insert(0, item); } emit(next); await _repository.saveAll(next); } }两个细节必须说明。第一,Cubit的state要当作不可变数据来用,我每次都用List.from(state)先复制再修改,绝不在原列表上add/remove后直接emit。如果直接改原state再emit,Flutter的相等性判断会认为状态没有变化,页面就不会刷新。第二,toggle里是“先emit、后保存”,这样用户点收藏按钮的瞬间UI就有反馈,写Preferences的异步过程在后台完成。Preferences本地写入基本不会失败,所以这个顺序是安全的。
经常有人问,Flutter的Navigator切换页面之后,会不会丢失状态?我的回答是:状态放Cubit里就不会丢。Navigator每次push都会创建新的页面Widget,页面里的局部setState状态当然会被重置;但如果状态在Cubit里,页面重建只是重新订阅同一个数据源,取到的还是同一份内存数据。收藏功能里最忌讳的就是把收藏状态复制到页面State里,那样详情页改完、收藏页不知道,收藏页删完、详情页还显示已收藏。
3.3 详情页按钮和收藏列表的UI落地
详情页的收藏按钮,建议用BlocBuilder或者context.watch来监听Cubit,而不是自己维护一个bool。我见过有人用IconButton加onPressed里setState,然后在initState里从Cubit读一次初始值,后续状态变化完全感知不到,这是典型错误。
final favoriteCubit = context.read<FavoriteCubit>(); FavoriteButton( isFavorite: context.watch<FavoriteCubit>().isFavorite(comicId), onPressed: () { final item = FavoriteItem( id: comicId, title: comicTitle, coverUrl: comicCoverUrl, createdAt: DateTime.now().millisecondsSinceEpoch, ); favoriteCubit.toggle(item); }, );按钮的点击区域要控制好,收藏按钮在卡片上会被多个事件竞争,我建议至少保持48x48的逻辑像素热区,同时给icon的过渡动画一点反馈,不然用户不知道到底点没点上。
收藏列表页则直接监听Cubit的完整状态:
BlocBuilder<FavoriteCubit, List<FavoriteItem>>( builder: (context, items) { if (items.isEmpty) { return const EmptyFavorites(); } return ListView.builder( itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; return FavoriteCard( title: item.title, coverUrl: item.coverUrl, onRemove: () => context.read<FavoriteCubit>().toggle(item), ); }, ); }, )列表页的取消收藏复用了同一个toggle方法,这样详情页、列表页、首页角标拿到的一定是同一份数据。不过要注意性能:如果收藏列表有几千条,每次toggle都会触发整个列表重建,多少会有点卡。MVP阶段无所谓,数据量上来之后,让Cubit发出带diff的事件,比如FavoriteAdded和FavoriteRemoved,UI局部更新就好。
4. 跨页面与原生能力:MethodChannel和EventChannel配合实战
4.1 Dart和ArkTS之间如何可靠通信
微动漫App的收藏功能一旦接入账号体系,就会遇到两种通信场景。第一种是Dart主动通知ArkTS发起云同步,比如用户点击“同步收藏”按钮,登录token存在原生侧,网络请求需要由原生侧发出。这个用MethodChannel最合适,Dart调用、原生返回结果,语义上是“请求-应答”。第二种是服务端推送收藏变更,比如某部作品下架了,后台通知到设备,原生侧需要主动告诉Dart侧“刷新一下收藏列表”。这个用EventChannel最合适,原生侧单向推数据,Dart侧用Stream接收。
Dart侧先定义好通道:
class FavoritesSyncChannel { static const _channel = MethodChannel('com.example.microanime/favorites'); static const _eventChannel = EventChannel('com.example.microanime/favorites_events'); Future<bool> syncToCloud(List<FavoriteItem> items) async { final result = await _channel.invokeMethod<bool>('syncFavorites', { 'items': items.map((e) => e.toJson()).toList(), }); return result ?? false; } Stream<Map<dynamic, dynamic>> get favoritesEvents => _eventChannel.receiveBroadcastStream(); }MethodChannel和EventChannel要特别注意生命周期。EventChannel的Stream必须在App启动阶段注册一次,最好放在全局单例或者main()里初始化,不要放在页面build里。理由是:用户一旦离开当前页面,Stream被销毁,原生侧再推送收藏变更就再也收不到了。这个bug特别隐蔽,因为开发调试时你通常就停在一个页面上,问题不会暴露,上了真机一锁屏再解锁就丢事件。
Flutter组件通信的方式很多,InheritedWidget、Provider、Stream、EventChannel各有用武之地。我的切分标准是:UI局部共享状态用InheritedWidget或Provider,全局业务状态用Cubit这类状态管理库,跨原生边界用MethodChannel和EventChannel。把这三类边界分清楚,代码结构就不会糊成一团。
4.2 自己写一个OpenHarmony收藏插件要过哪些坎
如果社区已有的shared_preferences适配满足不了“收藏同步到云端”的需求,我们就得自己补一个OHOS插件。以ArkTS侧为例,核心结构大致是这样:
import { MethodCall, MethodChannel, EventChannel } from '@ohos/flutter_ohos_plugin'; export class FavoritesPlugin { private channel: MethodChannel = new MethodChannel('com.example.microanime/favorites'); private eventChannel: EventChannel = new EventChannel('com.example.microanime/favorites_events'); constructor() { this.channel.setMethodCallHandler((call: MethodCall) => { if (call.method === 'syncFavorites') { const args = call.arguments as Record<string, Object>; return this.syncCloud(args['items'] as string); } return Promise.reject(new Error('unknown method: ' + call.method)); }); } private syncCloud(itemsJson: string): Promise<boolean> { // 调用鸿蒙侧的网络框架,把收藏列表上传到服务器 return Promise.resolve(true); } pushRemoteRemove(ids: string[]): void { this.eventChannel.sink?.success({ type: 'remoteRemove', ids: ids, }); } }这份代码是结构示意,具体API名称会随Flutter SDK for OpenHarmony的版本变化,但思路是通用的。写插件最容易踩的坑是类型映射:Dart侧的Map、List、String、数字等类型,在ArkTS侧都能对应,但自定义对象不行,建议一律序列化成JSON字符串再传。我们曾经把一个FavoriteItem对象直接塞进通道,结果原生侧拿到的是一个不可读的结构,排查了大半天,改成JSON字符串后一次通过。
还有一个非常容易被忽略的点:权限声明。微动漫App要同步收藏列表,必须在OHOS工程的module.json5里申请网络权限:
{ module: { requestPermissions: [ { name: "ohos.permission.INTERNET" } ] } }没有这个权限时,真机上网络请求会静默失败,Dart侧还以为是同步成功返回了false。在开发调试阶段权限够用,但等你要发布签名的hap包时,权限配置对不对会直接影响上架审核。
4.3 PlatformView嵌入原生页面的边界处理
微动漫App里面如果要用原生播放器、原生广告组件,就会涉及PlatformView。很多初次接触混编的同学会把“嵌入原生播放器”和“必须用PlatformView”绑定,其实要分情况。如果只是在详情页里嵌一个播放窗口,PlatformView能用;但如果你发现PlatformView在部分OpenHarmony版本上跟Flutter列表同屏滚动时出现图层抖动、白屏,就得考虑换方案。
我自己遇到过的解法有三种:把播放器切换成Texture方式渲染,让系统把视频帧作为纹理交给Flutter绘制;或者干脆把整屏交给原生页面,通过Navigator做原生和Flutter的页面级混编;还有一种就是降级为“启动原生播放器Activity/Ability”,彻底脱离Flutter渲染链路。收藏列表页本身用不到PlatformView,但很多微动漫App会在收藏列表页里带原生Banner广告,这时候你就得做取舍。
提示:PlatformView的图层问题不是写代码能完全规避的,它跟OpenHarmony的窗口合成机制有关。遇到白屏先别急着改业务代码,先确认是不是在低端真机上复现,再考虑纹理方案。
5. 打包、真机运行和常见报错排查
5.1 OpenHarmony打包hap时被反复摩擦的3个问题
打包阶段我踩过的坑比写业务代码还多,列三个最有代表性的,都值得记进项目Wiki。
第一个:release构建时抛出java.lang.AssertionError: java.lang.Exception: could not close input stream之类的错误。这个报错看起来像代码问题,实际多数是构建缓存或资源I/O冲突。我的处理顺序是:先执行flutter clean,删掉build目录,重新构建;不行就把gradle缓存目录里的Flutter相关缓存清掉;还不行就检查工程路径里有没有中文目录或者特殊符号。我项目里出现过一次,原因是Windows的杀毒软件锁了临时文件,把构建产物目录加白名单就好了。
第二个:构建脚本里出现“you are applying flutter's main gradle plugin imperatively using the apply script”的提示。这个问题的本质是Flutter 3.x以后推荐用pluginManagement方式声明插件,而旧工程模板还在用apply plugin: flutter这种命令式写法。处理方法是把settings.gradle里的插件声明方式改过来,同时检查ohos模块的依赖配置,不要混用两套插件加载逻辑。
第三个:插件版本跟Flutter SDK不匹配。OpenHarmony生态的Flutter插件往往跟着特定SDK版本走,如果你把Flutter SDK升级了一个小版本,很多适配插件会直接编译失败。我的办法是给项目锁死Flutter SDK版本,不追新,插件一律用固定版本号,不用latest。记录一个实际打包遇问题的排查表:
| 报错/异常 | 常见原因 | 处理建议 |
|---|---|---|
| AssertionError: could not close input stream | 构建缓存损坏或资源文件被占用 | flutter clean,清理gradle缓存,检查路径 |
| main gradle plugin applied imperatively | 插件声明方式混用 | 改用pluginManagement统一声明 |
| 插件编译失败 | Flutter SDK升级 | 锁定SDK和插件版本,不要盲目升级 |
| 真机无网络 | module.json5缺少权限 | 添加ohos.permission.INTERNET |
5.2 真机运行时的状态刷新与性能细节
微动漫App的收藏列表页一般会加下拉刷新,用Flutter自带的RefreshIndicator就行。但有一个细节要提醒:RefreshIndicator的回调必须是一个Future,刷新过程中这个Future没返回,loading动画就一直转。而且刷新逻辑要基于当前Cubit状态去和服务端做合并,不能简单地“重新加载本地数据”或者“清空后拉远端”,否则用户刚收藏的内容会被远端旧数据覆盖掉。
代码结构大致是:
Future<void> _refreshFavorites(BuildContext context) async { final cubit = context.read<FavoriteCubit>(); final currentItems = cubit.state; final remoteItems = await favoritesApi.fetchRemote(); final merged = _mergeLocalAndRemote(currentItems, remoteItems); await cubit.replace(merged); }收藏功能里还有一个容易忽略的时序坑。有些同学习惯用SharedPreferences.getInstance().then((prefs) => setState()),然后纠结Future的then回调到底是不是放在微任务队列里。其实这不是关键,关键是不要在then回调里依赖一个已经被销毁的页面上下文,更不要用then来触发UI状态更新。收藏状态统一走Cubit的emit,Dart侧的异步顺序自然会被状态管理框架处理好,UI只订阅状态流就行。
真机调试的时候还有一个判断陷阱:OHOS上的flutter run首次启动会比较慢,Dart VM要attach到设备,紧接着还要编译加载原生hap,容易让人误以为“性能很差”。建议用release包再测性能,别拿debug包的数据去跟产品汇报。
6. 收藏功能还能怎么扩展
6.1 从单机收藏到云同步
单机收藏跑通之后,最值得做的扩展是云同步。数据结构要先升级,给FavoriteItem加一个updatedAt字段,同步策略用“本地上传+远端合并”,不要简单覆盖。具体流程是:用户登录后,先把本地收藏列表全量上传,服务端返回最新的收藏全集,客户端再用updatedAt做合并,冲突时以后修改时间为准。
更稳的方案是引入离线变更队列。用户每次收藏和取消收藏,除了更新主收藏列表,还要在本地写一条变更记录“add/remove + id + 时间戳”。网络恢复后,客户端把变更队列按顺序发给服务端,服务端逐条应用。这个设计能让断网状态下的收藏操作完全不丢,等微动漫App后续要加“多端收藏同步”时,这个队列就是现成的底座。
6.2 从列表到智能推荐和收藏夹分组
另一个扩展方向是收藏夹管理。微动漫内容更新快,用户收藏多了之后,按“更新中”“已完结”“想看”分组非常实用。这个分组逻辑建议放在Cubit的派生数据里,不要在UI层每次build时重复计算。比如可以暴露一个groupedFavoritesgetter,根据每个条目的状态字段归到不同的List,页面直接订阅分组结果就好。
收藏数据本身还可以反哺推荐系统。当用户收藏了几部作品,App首页的“猜你喜欢”就能基于收藏标签做内容召回。这一步在技术上只是把收藏列表从本地读出来上传到推荐服务,但前提是之前每一步的数据模型都留有扩展余地。我当时做收藏功能时把FavoriteItem设计成可以加tags、status等字段的JSON结构,后来接推荐和分组,几乎没改存储结构。
我自己做一遍这个功能最大的体会是:收藏功能是典型的“业务简单、工程不简单”的需求。把本地存储抽象成Repository、把收藏状态收敛到全局Cubit、把原生通道做成独立插件,这三件事认真做好,后面无论换数据库、接入云同步,还是适配新的OpenHarmony版本,收藏功能都不会伤筋动骨。最后再分享一个小技巧:在详情页和列表页同时操作同一个收藏项时,一定要以ID为主键做幂等,按钮连点也不怕,这样用户怎么快速折腾都不会出现数据错乱。