在 OpenHarmony 设备上跑 Flutter,最常被问的一句话是:社区分支到底稳不稳?我的答案通常是可以做,但要把平台差异当成一等公民来设计。最近我负责的生活助手App就把成就徽章系统完整实现了一遍,后端不参与解锁计算,所有进度、状态、判定全部落在端侧。这个模块做起来不算难,难的是在 Flutter for OpenHarmony 的生态边界内,把数据模型、事件通道、本地存储和页面动画整合得不别扭。
如果你现在正打算在 OpenHarmony 上做 Flutter 项目,或者手头已经有一版生活助理类App想加成就系统,这篇内容应该能帮你少走不少弯路。我会按实际的推进顺序来讲:先是成就系统的产品形态怎么转成技术模型,接着是 Flutter 和 OpenHarmony 之间的通道怎么接,然后才是徽章解锁引擎、持久化方案、页面展示和动画,最后把我在打包和运行过程中遇到的一堆古怪报错整理成速查表。全程没有吹得天花乱坠的架构,只有能直接照着写的代码和思路。
1. 成就系统在生活助手里的定位与设计思路
1.1 先从产品需求反推技术模型
生活助手这类App,表面上核心是记录任务、打卡、步数和饮水,但真正让用户留下来的往往是“我今天完成了任务、获得了小徽章”的那种即时反馈。成就系统如果做成一堆静态卡片挂在页面里,用户看两次就腻了;如果做成复杂游戏化体系,又会把研发周期拖很长。所以第一件事是限定范围:不做等级、不做积分商城,只做成就徽章。
我先把产品需求拆成了三类。第一类是单次行为成就,比如“第一次创建任务”“第一次完成打卡”;第二类是累计类成就,比如“累计完成任务 50 个”“连续打卡 7 天”;第三类是隐藏成就,比如“凌晨 5 点完成一次早鸟任务”,这种成就列表里不显示目标进度,只有解锁后才知道存在。
从技术角度反推,这三类需求只需要三个基本概念:徽章定义、徽章进度状态、解锁条件。定义包含 id、名称、描述、稀有度、图标;状态包含当前进度、目标值、解锁时间;条件则是事件类型和事件参数的组合。隐藏成就只是前端不展示进度,后端/端侧数据模型完全一样,不需要额外的“隐藏表”。
这样设计的好处是,整个成就系统可以独立成一个模块,主App只需要往事件总线里丢“任务完成”“打卡成功”“步数更新”这类事件。成就模块不关心业务是怎么实现的,只关心自己订阅的事件类型。后续加新徽章,后端配置下发或者本地配置加一条记录就行,不用改主流程。
1.2 状态流转与解锁条件拆解
徽章状态我采用了最简单的三态模型:锁定中、进行中、已解锁。锁定中和进行中在数据库里的区别只是 progress 是否大于 0,前端展示时再做区分。这样避免引入“已领取”“待领取”等状态——如果未来真的要做奖励领取,再加一个 reward_claimed 字段就行,而不是把状态枚举复杂化。
每次收到业务事件后,引擎会依次判断所有未解锁的徽章。判断过程包含两个关键动作:一个是累加进度,一个是检查阈值。累加进度不能简单地写成 progress = progress + 1,因为不同徽章监听的事件维度不同,比如连续打卡 7 天需要判断日期连续性,而不是单纯计数。所以我把条件判定抽成了独立函数,徽章配置里有 conditionType,引擎根据 conditionType 分发到不同的判定逻辑。
下面是我在实际配置里常用的一组条件类型,正好覆盖生活助手的核心场景:
| conditionType | 对应事件 | 说明 |
|---|---|---|
| task_created | 用户创建任务 | 单次事件,目标值一般为 1 |
| task_completed | 用户完成任务 | 累计事件,目标值可以为 20、50 |
| checkin_streak | 用户连续打卡 | 需要读取上次打卡日期,判断连续性 |
| step_total | 步数累计 | 步数由原生侧周期性上报 |
| early_bird | 早起任务 | 需要解析事件发生的小时,隐藏徽章专用 |
这个表看起来简单,但真正写代码时容易踩坑的是 checkin_streak。如果用户中断了一天才回来打卡,连续天数应当重置为 1,而不是继续加 1。所以事件里不能只传“我打卡了”,还要把本次打卡日期传上来,由成就引擎判断日期差。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Flutter for OpenHarmony 的基础适配方法
2.1 选哪个 Flutter 分支,别盲目追新
做 OpenHarmony 上的 Flutter,首先要明确一件事:官方 Flutter 并没有默认支持 OpenHarmony,能跑的是社区维护的 fork 版本,常见的是 OpenHarmony SIG 维护的 flutter_flutter 分支。我见过太多人直接把官方 Flutter 环境装好,然后找不到 OpenHarmony 设备,其实根本原因就是分支没切对。
我使用的方案是:单独准备一套 OpenHarmony Flutter SDK,不跟官方 Flutter SDK 混用。因为混用很容易出现“The current configured Flutter SDK is not known to be fully supported”这类版本提示,某些工具链还会自动把开发环境切回官方分支,导致构建的时候完全找不到 hvigor 相关配置,场面一度非常混乱。
版本号的坑也得说一句。OpenHarmony 分支的版本节奏比官方慢,不是官方出了 3.44、3.47 我就要跟着升级。我的建议是:先确认你的 OpenHarmony SDK API 版本和 Flutter 分支相互匹配,再决定功能开发。徽章系统这种纯 UI + 状态机模块,对 Flutter 新版本没有硬需求,稳定比新特性重要得多。项目里如果用了比较新的 Flutter 特性,先看分支是否已经合入,不要拿官方更新日志对标 OpenHarmony。
还有一个实际问题是渲染引擎。Flutter 在新版本里逐步转向 Impeller 渲染引擎,但不同设备驱动适配差异很大。在部分低端 OpenHarmony 设备上,开着 Impeller 反而可能出现纹理异常或字体发虚。徽章页图片和动画比较多,我最后是关闭了 Impeller,切回 Skia,优先保证视觉稳定。做优化之前先看看渲染引擎的兼容性,别为了“新引擎性能好”把线上体验搭进去。
2.2 平台通道:MethodChannel 与 EventChannel 的鸿蒙侧配合
生活助手里,步数、消息提醒这些数据往往在原生侧更可靠,所以成就系统的数据来源不能全部靠 Flutter 自己算。我的做法是双通道配合:MethodChannel 用来做一次性查询,EventChannel 用来接收持续上报的数据流。
比如进入成就页面时,我需要知道今天实时步数,走 MethodChannel:
dart复制const MethodChannel _stepChannel = MethodChannel('com.example.lifeassistant/steps');
Future<int> fetchTodaySteps() async {
final int steps = await _stepChannel.invokeMethod('fetchTodaySteps');
return steps;
}
而步数在后台变化后,原生侧会通过 EventChannel 把增量推送到 Flutter 侧:
dart复制const EventChannel _stepEventChannel = EventChannel('com.example.lifeassistant/step_events');
void listenStepEvents() {
_stepEventChannel.receiveBroadcastStream().listen((event) {
if (event is int) {
achievementEngine.dispatch(
AchievementEvent(
type: 'step_total',
timestamp: DateTime.now(),
payload: {'deltaSteps': event},
),
);
}
});
}
这里有几个容易忽视的细节。第一,EventChannel 的订阅时机必须在 Flutter 引擎启动之后,不能在 main() 初始化早期调用,否则原生侧的 Handler 还没注册完,事件会静默丢失。第二,原生侧上报的增量要转换为 int 再做运算,Dart 侧不要隐式信任 dynamic 数据。第三,如果用户在后台运行App,步数事件可能大批量积压,Flutter 侧要处理“一次性来很多事件”的情况,最好在引擎入口做节流,比如每 5 秒合并一次。
平台通道的命名也要尽量具体。不要叫“steps”这种一眼看出是测试的名字,项目里用“com.example.lifeassistant/steps”这种格式,后续在鸿蒙侧做权限排查和日志过滤会方便很多。
3. 成就徽章核心实现:模型、存储、解锁引擎
3.1 徽章模型与 JSON 配置设计
数据模型是整个成就系统最值得花时间设计的地方。我最初直接用 Map 到处传,后来发现条件逻辑越来越复杂,还是老老实实建了模型类。徽章定义和状态分开建,定义是静态的,状态是动态的。
先看徽章定义:
dart复制enum BadgeRarity { common, rare, epic, legendary }
class BadgeDefinition {
final String id;
final String title;
final String description;
final BadgeRarity rarity;
final String iconAsset;
final String conditionType;
final int targetValue;
final bool isHidden;
const BadgeDefinition({
required this.id,
required this.title,
required this.description,
required this.rarity,
required this.iconAsset,
required this.conditionType,
required this.targetValue,
this.isHidden = false,
});
factory BadgeDefinition.fromJson(Map<String, dynamic> json) {
return BadgeDefinition(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
rarity: BadgeRarity.values.byName(json['rarity'] as String),
iconAsset: json['iconAsset'] as String,
conditionType: json['conditionType'] as String,
targetValue: json['targetValue'] as int,
);
}
}
然后是徽章的动态状态:
dart复制class BadgeState {
final String badgeId;
int progress;
DateTime? unlockedAt;
BadgeState({required this.badgeId, this.progress = 0, this.unlockedAt});
bool get isUnlocked => unlockedAt != null;
bool get isInProgress => progress > 0 && unlockedAt == null;
void addProgress(int delta) {
if (isUnlocked) return;
progress += delta;
}
void tryUnlock(int target, DateTime now) {
if (progress >= target && !isUnlocked) {
unlockedAt = now;
}
}
factory BadgeState.fromJson(Map<String, dynamic> json) {
return BadgeState(
badgeId: json['badgeId'] as String,
progress: json['progress'] as int? ?? 0,
unlockedAt: json['unlockedAt'] != null
? DateTime.fromMillisecondsSinceEpoch(json['unlockedAt'] as int)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'badgeId': badgeId,
'progress': progress,
'unlockedAt': unlockedAt?.millisecondsSinceEpoch,
};
}
}
我之所以把定义和状态分开,是因为徽章定义只有一份,状态则要跟着用户账号和设备走。如果以后接入云同步,定义可以走配置中心,状态走用户数据服务,两者天然解耦。另外,徽章定义的 JSON 我放在了 assets 目录里,用 part/part of 组织了一个常量配置文件,方便运营人员直接改 JSON 增加徽章,不用重新发版,也不用把目标值散落在业务代码里。
3.2 持久化:为什么我用 SQLite 而不是 pure SharedPreferences
成就进度必须可靠持久化。用户解锁了一个徽章,不能因为App杀后台就丢掉。最开始我想偷懒,直接用 shared_preferences 存一个大的 JSON 字符串,后来发现两个问题:一是每次写入都需要整个序列化,徽章数量一多效率很低;二是要更新单个徽章进度时,必须读出全部数据、改完再写回,并发场景下容易出现覆盖。
所以最终选了 SQLite。在 Flutter for OpenHarmony 环境里,并没有官方标准的 sqflite 支持,但社区有适配 OpenHarmony 的 sqlite 插件版本,原理是调用 OpenHarmony 的 RDB 能力。如果你的业务数据量不大,也可以用 shared_preferences 的 ohos 适配版,但成就系统这种“频繁写进度、偶尔查列表”的场景,SQLite 更合适。
建表语句很朴素:
sql复制CREATE TABLE IF NOT EXISTS badge_state (
badge_id TEXT PRIMARY KEY,
progress INTEGER NOT NULL DEFAULT 0,
unlocked_time INTEGER,
is_hidden INTEGER DEFAULT 0
);
查询时一次性加载所有徽章状态到内存:
dart复制Future<void> loadAllStates(Database db) async {
final rows = await db.query('badge_state');
for (final row in rows) {
final state = BadgeState(
badgeId: row['badge_id'] as String,
progress: row['progress'] as int? ?? 0,
unlockedAt: row['unlocked_time'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(row['unlocked_time'] as int),
);
_states[state.badgeId] = state;
}
}
写入时不需要每次都全量保存,只在进度变化时执行 upsert:
dart复制Future<void> saveState(Database db, BadgeState state) async {
await db.insert(
'badge_state',
{
'badge_id': state.badgeId,
'progress': state.progress,
'unlocked_time': state.unlockedAt?.millisecondsSinceEpoch,
'is_hidden': state.isUnlocked ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
这里记住一个关键点:unlocked_time 一定要存毫秒时间戳,不要存 ISO 字符串。存字符串看起来可读性好,但排序、时区转换和数据库统一处理都麻烦。我遇到过排查“为什么连续打卡天数对不上”时发现是日期字符串格式不一致导致的,换成时间戳后世界安静了。
3.3 解锁引擎与事件驱动
引擎层是一个 ChangeNotifier,所有业务模块通过 dispatch 方法把事件喂进来,引擎内部完成判断、持久化、通知页面刷新。用 ChangeNotifier 的原因很简单:Flutter 自带,不需要额外引入状态管理库,OpenHarmony 分支对 provider 这类包的兼容性虽然没问题,但少一个依赖少一分适配风险。
dart复制class AchievementEngine extends ChangeNotifier {
final List<BadgeDefinition> _definitions;
final Map<String, BadgeState> _states = {};
void dispatch(AchievementEvent event) {
for (final definition in _definitions) {
if (definition.conditionType != event.type) continue;
final state = _states[definition.id];
if (state == null || state.isUnlocked) continue;
state.addProgress(_resolveDelta(definition, event));
state.tryUnlock(definition.targetValue, event.timestamp);
if (state.isUnlocked) {
_onBadgeUnlocked(definition, state);
}
}
notifyListeners();
}
int _resolveDelta(BadgeDefinition definition, AchievementEvent event) {
if (definition.conditionType == 'step_total') {
return event.payload['deltaSteps'] as int? ?? 0;
}
if (definition.conditionType == 'checkin_streak') {
return _handleStreak(definition, event);
}
return 1;
}
}
连续打卡的 _handleStreak 是重点,不能简单加 1。我保存了每个徽章的 lastCheckinDate,如果日期和今天的差值是 1,说明连续,progress 加 1;如果是 0,说明重复打卡,忽略;如果大于 1,说明中断,重设为 1。这个逻辑写成文档很绕,但在代码里就是三个 if。
解锁成功后,源码里不要只改状态。还要做两件事:调用 saveState 持久化,把一个 unlock 事件推给 UI 层弹动画。我用的是 ChangeNotifier 回调加一个 List<BadgeDefinition> pendingUnlocks,每次 notifyListeners 后 UI 侧检测这个列表,如果非空就弹解锁横幅。
这里有个性能问题:如果一批事件同时触发多个徽章解锁,比如用户同步步数时一次解锁了三个,UI 不能同时弹三屏,需要按解锁时间顺序排队展示。我的方式是 pendingUnlocks 用队列,每个动画播完后再取下一个。
4. 徽章页面与解锁动效
4.1 页面数据绑定与保持状态
成就页面我用了一个封装好的 AchievementGrid 组件,外层 ListenableBuilder 监听 AchievementEngine,变化时重新构建网格。网格的每一项根据 BadgeState 的状态显示不同样式:未解锁但进行中显示灰色图标加进度条;已解锁显示彩色图标和解锁日期;隐藏且未解锁的直接显示一个问号图标,不泄露任何进度信息。
Flutter 页面结构大致是这样:
dart复制class AchievementPage extends StatefulWidget {
@override
State<AchievementPage> createState() => _AchievementPageState();
}
class _AchievementPageState extends State<AchievementPage>
with AutomaticKeepAliveClientMixin<AchievementPage> {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return ListenableBuilder(
listenable: achievementEngine,
builder: (context, _) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: achievementEngine.allDefinitions.length,
itemBuilder: (context, index) {
final def = achievementEngine.allDefinitions[index];
final state = achievementEngine.stateOf(def.id);
return BadgeCard(definition: def, state: state);
},
);
},
);
}
}
AutomaticKeepAliveClientMixin 很容易被忽略。如果 App 底部用了 TabBar,用户切到成就页再切走,不 keepAlive 的话,页面会重建,滚动位置和动画状态全部丢失。徽章页属于中等开销页面,keepAlive 是合理的。网上经常有人问“Flutter navigator 切换页面后会不会丢失状态”,答案取决于 State 生命周期和页面是否被回收。成就系统里我明确不希望每次切 Tab 都重置,所以必须加 keepAlive。
4.2 解锁弹层动画的实现细节
解锁动画我在 OpenHarmony 上不敢随便用第三方插件,因为很多特效包依赖 GPU 调用或者平台特定通道,符号表在鸿蒙侧不一定完整。最后我用的是 Flutter 内置 AnimationController + Overlay,做了一套简洁的解锁横幅,效果足够而且没有适配风险。
具体实现思路是:engine 里触发了抢锁时,往 Overlay 里插入一个条目,动画分为两段:先缩放着从屏幕中央出现,再往上滑到屏幕顶部变成常驻提示。代码大致如下:
dart复制void showUnlockBanner(BuildContext context, BadgeDefinition badge) {
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _UnlockBanner(
badge: badge,
onDone: () => entry.remove(),
),
);
overlay.insert(entry);
}
_UnlockBanner 内部用一个 AnimationController,duration 大约是 1500ms。前半段 ScaleTransition 从 0.6 到 1.0,后半段 SlideTransition 移到顶部。动画结束后回调 onDone,移除 OverlayEntry。这里要注意,OverlayEntry 一旦插入,必须保证有明确的移除时机,否则会出现“解锁后弹窗永远悬在页面上”的 bug。我用 addStatusListener 监听 completed 状态,再延迟 200ms 移除,给用户一点停顿感。
图标方面,生活助手徽章我统一用 PNG 资源,初始包体增加了大约 1.2MB,可以接受。如果你要加载远端图,建议先本地缓存再显示,避免解锁动画过程中图片还没加载完,出现灰底。
4.3 多页面同步刷新与红点提示
成就不是一个孤立页面。用户在主页面完成任务时,底部 Tab 的成就入口应该出现红点,提示有可解锁的徽章。这个需求用 ChangeNotifier 也很好做,在主页面监听 engine 里“未解锁数量是否增加”即可。
我定义了一个 computed 值:hasPendingUnlock,只要 pendingUnlocks 非空,就返回 true。可以监听它,在使用 AnimatedBuilder 构建导航栏时动态显示一个小红点。注意红点的出现和消失要稍微延迟,不能和解锁动画的 1500ms 强绑定,否则容易闪来闪去。
5. 常见问题与避坑速查
5.1 OpenHarmony 插件适配的三类典型报错
整个项目实施过程中,我遇到最多的问题其实不在成就业务逻辑,而在 Flutter 与 OpenHarmony 的插件适配。这里整理一张速查表,对应我实际出现过的报错和解决思路,能少走很多弯路。
| 报错现象 | 可能原因 | 我的处理方式 |
|---|---|---|
| The current configured Flutter SDK is not known to be fully supported. Please ... | 当前用的是官方 Flutter SDK,或 OpenHarmony 分支版本不匹配 | 检查 which flutter,切回 OpenHarmony 社区分支;临时调低 Flutter 版本策略 |
| You are applying Flutter's main Gradle plugin imperatively using the apply script | 项目把 Android 构建脚本逻辑带到了 OHOS 工程,或者混用了 Gradle 与 hvigor | 确认模块是否误引 Android 目录;OpenHarmony 工程走 hvigor 配置,不要复制 gradle 语法 |
| java.lang.AssertionError: java.lang.exception: could not close input ... | 打包资源文件被占用,或缓存目录里有损坏资源 | 删除 build 目录和临时缓存,重新构建 |
| EventChannel 收不到原生事件 | 订阅时机太早,原生 Handler 未注册 | 将 listenStepEvents 放在 engine 初始化完成、页面创建前调用,并加日志确认原生侧开始广播 |
这些报错有一个共同特征:不是你的逻辑写错了,而是环境或插件版本混了。所以排查时先别急着读代码,先看当前 Flutter SDK 来源、工程类型、插件里有没有 ohos 目录。
另外要特别提醒:不要假设所有 Flutter 插件在 OpenHarmony 上都有适配版。用某个包之前,先看它的 pubspec 和源码目录里有没有 ohos 目录,或者 README 里有没有 OpenHarmony 支持说明。如果没有,要么找替代方案,要么自己写平台通道。比如部分复杂的地图、支付插件,OpenHarmony 侧根本没有对应实现,硬接就是白费时间。
5.2 成就数据在 OpenHarmony 与调试工具之间的坑
我在 OpenHarmony 真机上调试时,经常会用日志工具查看数据库内容。但 OpenHarmony 的文件沙箱和 Android 不一样,直接用常规命令去读应用数据库路径会提示权限不够。这时候不要硬改权限,正确做法是:通过 hdc shell 进入应用沙箱目录,或者走应用自带的“导出成就数据”功能先写到一个用户可以读取的位置,再拉出来。
这个坑说小不小。我一度以为数据库写失败了,因为到了真机上查不到表,后来发现只是调试工具没有权限读沙箱。成就进度和普通日志不一样,它重要在真实设备上的落盘结果,所以务必在真机上验证冷启动后重新加载逻辑,而不要在模拟器/桌面端跑一遍就认为完事。
5.3 高频事件与解锁判定的性能优化
步数事件是高频事件,如果每来一步都遍历所有徽章并且写数据库,对低端 OpenHarmony 设备尤其不友好。我在 engine 入口做了一个简单节流:步数事件先累计到一个临时变量,每 5 秒或者收到 50 事件后合并派发一次。实现方式就是计时器加计数器,没有任何黑科技。
还有一个优化细节:dispatch 里第一层就先过滤“已解锁徽章”。已解锁的徽章永远不需要再加进度,这是最明显的剪枝。如果徽章数量增多,可以再把定义列表按 conditionType 建索引,每个事件只遍历监听该类型的徽章,而不是全量循环。
5.4 解锁弹窗重复触发的一个玄学问题
最后分享一个我调了很久的小问题:某个徽章明明已经解锁了,弹窗还是偶尔出现两次。后来发现原因是 saveState 是异步写入,第一次解锁后状态还没落库,另一个事件又在同一批调度里到达,engine 里 _states 已经更新了呀,怎么还会重复?其实问题出在 UI 层:notifyListeners 被连续调用两次,每次都会检查 pendingUnlocks,而 pendingUnlocks 里同一个 BadgeDefinition 被塞了两次。
解决方法是每次弹窗展示前,在 engine 里用单独的 _shownUnlockToastIds 集合去重,一个 badgeId 在一次冷启动周期内只允许展示一次。这个集合在用户杀掉App重启后丢弃,但数据库里已有 unlockedAt,所以不会造成逻辑错误。
我在实际接入中还有一个很实用的习惯:每个徽章的解锁条件做一张本地测试配置表,开发阶段把 targetValue 临时改成 1,触发对应事件就能马上看到解锁效果。等到联调结束,再改回真实目标值。这样不用为了测连续打卡七天而真的等七天,也不用每次重新发版,只要把配置微调一下就行。这个习惯一直保留到了项目后期,排查奇怪的边界条件时特别省时间。
