☰
OpenHarmony上Flutter成就系统开发:从规则引擎到动画实践
2026/10/1 11:45:51 网站建设 项目流程

为什么我要在OpenHarmony上用Flutter做生活助手App的成就系统

先交代一下背景。团队年初接到一个任务:基于OpenHarmony做一款生活助手类App,涵盖待办提醒、习惯打卡、喝水提醒、每日总结这类功能。当时摆在我们面前的有两条路——直接用ArkTS配合ArkUI从头写,或者引入Flutter做跨端统一。考虑到我们原本就有成熟的Flutter技术栈,且后续还要覆盖Android和iOS,最终敲定了Flutter for OpenHarmony这条路线。

这篇内容不打算讲整个App的完整开发流程,而是把其中最典型、也最考验架构设计的一个模块单独拎出来:成就徽章系统。为什么单独讲它?因为成就系统横跨了数据建模、状态管理、事件驱动、动画交互、本地持久化、平台桥接多个层面,几乎把Flutter做业务开发时会遇到的坑都踩了一遍。尤其到了OpenHarmony这个新平台上,很多在Android上理所当然的写法,到这里全都不灵了。

如果你也在做Flutter跨端项目,或者准备把现有Flutter应用迁移到OpenHarmony,又或者只是对成就系统这类游戏化机制的产品设计感兴趣,这篇文章应该都能给你一些可落地的参考。从技术选型到方案落地,从踩坑记录到性能调优,我尽量把每一步决策背后的原因都讲清楚。

1. 为什么会选这条技术路线:Flutter、OpenHarmony与成就系统的三方博弈

1.1 先说OpenHarmony上的开发选择

OpenHarmony从一开始就主推ArkTS + ArkUI,这套组合本身的完成度其实相当不错,声明式UI的写法和Flutter的Widget树、SwiftUI的View都有相似之处。但问题在于:我们团队没人写过ArkTS,而Flutter这边有成熟的基础组件库、状态管理方案和第三方插件生态。

纯Flutter化还有一个容易被忽略的优势:业务逻辑层可以做到真正的跨端复用。生活助手App的核心——待办、打卡、成就计算、数据统计——这些逻辑写成Dart以后,Android、iOS、OpenHarmony三端共用一份代码。UI层面需要用PlatformView或者原生组件桥接的其实并不多,大部分页面用Flutter自己的Widget就能渲染。

1.2 成就徽章系统为什么是个好"试金石"

成就系统看着简单,拆开全是细节。首先要定义徽章的种类和达成规则,其次要实时追踪用户的行为数据,然后要在特定事件发生时判断是否解锁新徽章,最后还要处理解锁后的展示、通知、动画。这中间涉及到数据存储、事件广播、全局状态同步、UI刷新时机。

在OpenHarmony上,这套链路还要额外过一层:Flutter引擎与OpenHarmony原生侧的数据交换。比如用户完成了某个待办任务,这个事件是从原生侧推给Flutter的,还是Flutter自己感知的?不同的选择直接影响成就检查的实时性。这部分我会在后面专门展开。

1.3 Flutter for OpenHarmony的适配现状

先说结论:能做,但别期待跟Android一样顺滑。OpenHarmony这边的Flutter适配目前主要由社区维护,OpenHarmony的官方组织在推动,但版本跟进速度比Android/iOS慢半拍。我们用的Flutter版本是根据OpenHarmony适配分支调整过的,一些插件API和标准版有差异。

具体到Impeller渲染引擎,Flutter在Android上逐步切换到Impeller,但在OpenHarmony上还是Skia为主。实际体验下来,普通页面的渲染流畅度没问题,复杂的模糊、阴影效果可能会有性能损耗,做徽章解锁动画的时候需要特别注意。这个话题后面讲动画时细说。

2. 成就系统的产品逻辑:先把规则想清楚,再写代码

2.1 生活助手里到底需要哪些"成就"

做成就系统的一大误区,是一上来就想着把功能做得多炫、徽章种类做得多丰富。生活助手类App的成就跟游戏里的成就不同,它的核心价值不是让用户"炫耀",而是让用户坚持。

我们的生活助手App围绕几个核心场景:待办任务、习惯打卡、喝水提醒、每日总结。围绕这些场景,我把徽章设计成四类:

  • 首次类:比如"完成第一个任务""第一次连续打卡3天""记录第一条待办事项"——门槛低,用来快速建立正反馈。
  • 里程碑类:比如"完成100个任务""连续打卡30天""累计早起50次"——中等门槛,对应阶段性坚持的回报。
  • 连续类:比如"连续7天完成每日总结""连续打卡14天不间断"——针对连续行为,强化习惯养成。
  • 隐藏类:比如"凌晨完成一次打卡"这种特殊行为触发的徽章——给用户制造惊喜感。

设计时有一个原则:每个徽章必须能由程序自动判定,不能需要人工审核。像"完成一项重要任务"这种听起来美好但无法客观量化的规则,直接砍掉。所有规则都基于明确的数字指标:次数、连续天数、特定时间段内的行为。

2.2 规则引擎的边界问题

规则定清楚了,接下来就要处理规则引擎的两个经典难题:重复触发和并发触发。

重复触发指的是:用户已经解锁了"完成第一个任务"徽章,那后续再完成任务时不能再次触发。这个好解决,检查一下解锁状态即可。

并发触发指的是:用户完成一次行为,同时满足了多个徽章的条件。比如一次打卡行为,既满足了"首次打卡"又满足了"连续3天打卡"。这种情况不能只检查到一个就跳过其他检查,需要把该事件相关的所有规则全部跑一遍,保证每个满足条件的徽章都能解锁。

还有一类情况容易漏掉:离线状态下的行为积累。用户可能在无网络环境下完成了几个任务、打了几次卡,这些行为当时没有被系统检查,那等他恢复联网时,需要能补上该次行为产生的成就判定。我们的方案是通过本地事件日志,每次启动或者从后台恢复时,把未消费的行为事件重新分发一遍。

3. 数据建模:徽章系统的"地基"怎么打

3.1 核心数据表设计

这一步非常关键,建模建不好,后面写逻辑全是补丁。我直接给出我们在项目里最终落地的一套结构。

徽章定义(BadgeDefinition):描述徽章本身的静态属性。

class BadgeDefinition { final String id; // 唯一标识,比如 "first_task_done" final String name; // 徽章名称 final String description; // 达成条件描述 final BadgeCategory category; // 类别:first/milestone/streak/hidden final int targetValue; // 目标值,比如连续打卡3天里的"3" final String iconPath; // 选中态图标 final String iconLockedPath; // 未解锁时展示的图标(通常是剪影或灰色) final String? secretRule; // 隐藏规则的描述,未解锁时不显示 BadgeDefinition({ required this.id, required this.name, required this.description, required this.category, required this.targetValue, required this.iconPath, required this.iconLockedPath, this.secretRule, }); }

用户进度(BadgeProgress):描述用户和某个徽章之间的进度关系。

class BadgeProgress { final String badgeId; int currentValue; // 当前进度值 bool isUnlocked; // 是否已解锁 DateTime? unlockedAt; // 解锁时间,用于排序和记录展示 DateTime? lastUpdatedAt; // 最后更新时间,用于避免重复触发 BadgeProgress({ required this.badgeId, this.currentValue = 0, this.isUnlocked = false, this.unlockedAt, this.lastUpdatedAt, }); }

行为事件(BehaviorEvent):用户产生的原始行为记录,是成就触发引擎的输入。

class BehaviorEvent { final String type; // 事件类型,如 "task_completed"、"check_in" final int count; // 产生的事件数量,通常为1,也可能为批量导入的数据 final DateTime occurredAt; // 行为发生的时间 final Map<String, dynamic>? attributes; // 附加属性,如任务优先级、所属类别 BehaviorEvent({ required this.type, this.count = 1, required this.occurredAt, this.attributes, }); }

3.2 状态流转:锁定、进行中、已解锁

一个徽章有三个状态:锁定(locked)、进行中(in progress)和已解锁(unlocked)。

锁定的徽章还没有任何进度记录,可以理解为用户根本还没接触过这类行为。进行中的徽章有进度值,但还没达到目标。已解锁就是目标达成。

在UI层面,这几个状态决定了徽章墙上的展示形式:没进度记录的徽章显示一个模糊的轮廓,有进度但没完成的显示出进度条和完成百分比,已解锁的显示出完整图标、解锁时间、还可能附带一句文案。

项目里我使用的是三张独立的表分别存储这三种状态,因为后续要支持"徽章墙"页面的分组、排序、筛选,独立表查询更方便。三个状态之间不共享外键,彻底避免状态互相干扰。实际跑下来,这个设计在后期扩展新徽章时省了不少心。

3.3 持久化方案的取舍

OpenHarmony上Flutter的本地存储,我优先推荐用shared_preferences的OpenHarmony适配版,简单场景足够。但徽章进度这种结构化的、需要频繁读写的场景,用shared_preferences存JSON字符串并不合适——每次更新都要整体反序列化,越到后面数据越大,性能就越差。

考虑到数据量不大、但读写频繁的特点,我们最终用了轻量级数据库的方案,为OpenHarmony适配过的sqflite的移植版。建库建表、查询、事务更新,跟Android上的一模一样,几乎没有额外学习成本。

class BadgeDatabase { static const _dbName = 'life_assistant.db'; static const _dbVersion = 1; static Future<Database> _open() async { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, _dbName), version: _dbVersion, onCreate: (db, version) async { await db.execute(''' CREATE TABLE badge_progress ( badge_id TEXT PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0, is_unlocked INTEGER NOT NULL DEFAULT 0, unlocked_at TEXT, last_updated_at TEXT ) '''); }, ); } }

数据量不大,但每次读取进度、每次更新进度都走数据库,配合后面要讲的ValueNotifier或StreamBuilder做UI响应,整体效率完全够用。

4. 成就判定的核心引擎:事件流驱动而不是"定时轮询"

4.1 为什么不选轮询方案

刚开始设计成就触发机制时,团队里有人提议用定时任务每分钟扫一遍所有行为数据,检查满足条件没有。这个方案实现最简单,但有两个致命缺陷:

一是实时性差。用户刚完成打卡,UI上不能立刻弹出"解锁新徽章"的反馈,要等到下一次轮询,这体验就很僵硬。

二是性能浪费严重。随着用户使用时间拉长,行为数据越来越多,全量扫描的耗时呈线性增长。而且大部分扫描周期内根本没有新行为发生——等于是无限空转。

游戏行业那边早就验证过了:事件驱动的成就判定才是正解。当用户产生一个行为时,把行为包装成事件抛出,成就引擎只处理这个事件,只检查与这个事件类型相关的徽章规则。这样不用关心行为数据的历史存量,实时性也有保障。

4.2 事件驱动引擎的具体实现

我设计了一个集中式的成就检查器AchievementEngine,核心是一个事件分发器。整个模块负责接收行为事件,查找与该事件类型匹配的徽章定义,逐个检查进度条件,满足条件就触发解锁。

class AchievementEngine { final BadgeRepository _badgeRepo; final EventBus _eventBus; AchievementEngine(this._badgeRepo, this._eventBus) { _eventBus.on<BehaviorEvent>().listen(_handleEvent); } Future<void> _handleEvent(BehaviorEvent event) async { // 根据事件类型找到匹配的徽章规则 final matchedBadges = await _badgeRepo.findByEventType(event.type); for (final badge in matchedBadges) { await _processBadge(event, badge); } } Future<void> _processBadge( BehaviorEvent event, BadgeDefinition badge, ) async { // 读取当前进度 final progress = await _badgeRepo.getProgress(badge.id); // 已解锁的徽章直接忽略,防止重复触发 if (progress.isUnlocked) return; // 根据行为时间判断是否需要计入进度 final nextValue = _calculateNewValue(progress, event, badge); if (nextValue > progress.currentValue) { progress.currentValue = nextValue; } if (progress.currentValue >= badge.targetValue) { // 达成解锁条件 progress.isUnlocked = true; progress.unlockedAt = event.occurredAt; // 通知UI层弹出解锁动画 _eventBus.fire(BadgeUnlockedEvent(badge)); } await _badgeRepo.saveProgress(progress); } }

EventBus我用的是event_bus包,它内部就是一个StreamController。Flutter本身没有内置全局事件总线,用StreamController包装一层也很简单。

4.3 连续类徽章的进度算法

连续徽章是这里面最容易写错的。以"连续打卡7天"为例,如果今天打卡了,进度加1;但如果明天没打卡,后天再打卡时,进度应该重置回1而不是继续累加。

这个逻辑在_calculateNewValue里处理。核心算法:记录上一次打卡日期,比较间隔天数,间隔为0说明同一天重复打卡,不更新进度;间隔为1说明连续,进度加目标次数;间隔大于1说明中断,重置为1。

int _calculateStreakValue( BadgeProgress progress, BehaviorEvent event, ) { final lastUpdate = progress.lastUpdatedAt; if (lastUpdate == null) { // 首次行为,从1开始 return 1; } final now = event.occurredAt; final sameDay = now.year == lastUpdate.year && now.month == lastUpdate.month && now.day == lastUpdate.day; if (sameDay) { // 同一天重复打卡,不额外增加 return progress.currentValue; } final nextDay = lastUpdate.add(const Duration(days: 1)); final isConsecutive = now.year == nextDay.year && now.month == nextDay.month && now.day == nextDay.day; if (isConsecutive) { return progress.currentValue + event.count; } // 中断了,重置为1 return 1; }

这里有个隐蔽的坑要提醒一下:时区问题。DateTime.now()获取的是本地时间,如果用户在23:59打了一次卡,00:01又打了一次,系统认为是连续两天。从规则定义的角度,这没问题。但如果用户切换了时区、或者手机时间被手动修改过,判断"同一天"就会出现偏差。我们最终把"日期"这个概念统一落在本地时区的自然日上,不考虑UTC切换,规避了大量烦琐的边界情况。

4.4 离线补单的处理

用户离线时的行为事件,不会实时触发成就检查。我们做了个离线队列,每次检查事件之前先处理积压的离线事件。事件本身记录了occurredAt时间,补单时按时间顺序逐个喂给引擎,保证顺序性。

Future<void> _flushPendingEvents() async { final pending = await _localEventStore.fetchPendingEvents(); for (final event in pending) { await _handleEvent(event); await _localEventStore.markProcessed(event.id); } }

关于离线补单这块,有个经验值得分享:很多团队做成就系统时,会在离线事件里丢失进度,因为本地缓存的事件是易失的。我们当时的做法是把行为事件本身也落到数据库里,等网络恢复再统一处理,这样即使用户在未来某次启动时还没恢复网络,本地事件依然可以驱动UI解锁徽章。

5. 触发引擎接入业务:待办、打卡、提醒如何和成就引擎衔接

5.1 业务代码埋点还是事件总线

有了成就引擎之后,下一个问题是业务代码如何接入。两种方式:显式调用和事件总线。

显式调用最直接:用户完成任务,业务代码直接调用AchievementEngine.triggerTaskCompleted()。好处是链路清晰,坏处是成就系统的检查和业务逻辑耦合在一起,每新增一种行为都要在业务代码里加一行调用,不够灵活。

事件总线方案的好处是业务代码完全不需要感知成就系统的存在。用户完成了待办任务,业务方只需要在适当的位置广播一个BehaviorEvent(type: 'task_completed'),成就引擎订阅这个事件自行处理。之后想新增徽章规则时,只需要注册新的事件类型监听,业务方不需要改一行代码。

我们最终选择了事件总线。这在后期新增隐藏徽章时体现出了巨大优势——新徽章的规则代码完全放在成就模块内部,业务侧零侵入。

5.2 EventChannel还是MethodChannel:Flutter与OpenHarmony原生侧的事件通路

这里要特别讲讲Flutter和OpenHarmony原生侧之间的通信选择。OpenHarmony上使用Flutter,依然支持MethodChannel和EventChannel这两套标准API。

我们在实现"系统级事件"接入成就引擎时遇到过一个具体场景:用户通过桌面快捷方式直接触发"打卡"操作,这个行为是从OpenHarmony原生侧发起的。原生侧完成打卡逻辑后,需要告诉Flutter侧的成就引擎"打卡行为发生了"。

按照事件流的思路,这里用EventChannel更合适。MethodChannel适合"你来我往"的调用,而EventChannel天然适合原生侧单向推送事件。我们还有一个场景是用户设置了定期提醒,到点之后原生侧广播一个"提醒到期"事件,Flutter侧收到后先弹本地通知、同时检查该提醒对应的习惯打卡是否连续,从而触发解锁逻辑。

// Flutter侧创建EventChannel并接收原生推送 static const _eventChannel = EventChannel('com.example.assistant/behavior_events'); void _listenNativeEvents() { _eventChannel.receiveBroadcastStream().listen((event) { if (event is Map) { final behavior = BehaviorEvent( type: event['type'], count: event['count'] ?? 1, occurredAt: DateTime.fromMillisecondsSinceEpoch(event['timestamp']), ); _engine.dispatch(behavior); } }); }

OpenHarmony原生侧对应的事件发送逻辑参照官方文档即可,核心是通过EventChannel的sendEvent向Flutter侧推送数据。

5.3 时间驱动的成就:别忘记"午夜重置"

生活习惯类的成就绕不开时间驱动。比如"今日总结"徽章,要求用户当天完成一次总结。这种徽章的时间窗口是自然日,过了午夜就失效。

一个容易忽略的地方:如果用户在23:59打开App,一直用到第二天00:01,页面上显示的日期变了,但尚未触发的每日检查逻辑不会自动执行。我们需要在App从后台恢复、或者跨天时主动触发一次date_changed事件。

@override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { final now = DateTime.now(); if (_lastActiveDate != null && !_isSameDay(now, _lastActiveDate!)) { // 跨天,抛一个日期变更事件 _engine.dispatch(BehaviorEvent( type: 'date_changed', count: 1, occurredAt: now, )); } _lastActiveDate = now; } }

这个跨天事件统一通知所有按日刷新的徽章重新检查,避免用户第二天打开App时发现昨天的总结徽章解锁了,但UI上还是显示未解锁的错位问题。

6. 徽章墙UI与解锁动效:Display Logic比视觉本身更重要

6.1 徽章墙的信息架构

整个页面主要承担三个职责:向用户展示已获得的徽章、展示未获得的徽章及进度、以及提供一种"收藏展览"的成就感体验。已解锁区在上方,按解锁时间倒序排列;未解锁区在下方,按进度从高到低排列。

未解锁区我特意做了进度条展示。这个功能在设计之初有争议:有人觉得进度可见会削弱成就的神秘感,但我们最终决定进度可见,因为生活助手类App的定位是帮助用户坚持,"还差2次就能解锁"比"神秘未解锁徽章"更能激励用户。

6.2 解锁弹窗的动画实现

解锁新徽章时的反馈很关键。我实现了一个居中弹窗:徽章图标从中间放大浮现,带一个轻微的弹性效果,同时外圈有一圈光环旋转。

class BadgeUnlockDialog extends StatefulWidget { final BadgeDefinition badge; const BadgeUnlockDialog({super.key, required this.badge}); @override State<BadgeUnlockDialog> createState() => _BadgeUnlockDialogState(); } class _BadgeUnlockDialogState extends State<BadgeUnlockDialog> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation<double> _scaleAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 900), vsync: this, ); // 使用ElasticOut曲线,模拟一种弹性弹出的效果 _scaleAnimation = CurvedAnimation( parent: _controller, curve: Curves.elasticOut, ); _controller.forward(); } @override Widget build(BuildContext context) { return Dialog( backgroundColor: Colors.transparent, child: ScaleTransition( scale: _scaleAnimation, child: Column( mainAxisSize: MainAxisSize.min, children: [ // 光效旋转动画 RotationTransition( turns: Tween<double>(begin: 0, end: 0.25).animate( CurvedAnimation( parent: _controller, curve: const Interval(0.2, 0.6, curve: Curves.easeOut), ), ), child: Image.asset(widget.badge.iconPath, width: 160, height: 160), ), const SizedBox(height: 12), Text(widget.badge.name, style: ...), Text(widget.badge.description, style: ...), // 解锁时间 Text( "解锁于 ${_formatDate(widget.badge.unlockedAt)}", style: ..., ), ], ), ), ); } }

说到Impeller在OpenHarmony上的实际表现:这里用到的RotationTransition和ScaleTransition都依赖动画合成,OpenHarmony上目前跑的还是Skia后端,动画图层多的话会有一些开销。我把光环旋转拆成了一个独立图层,和弹窗主体的缩放分开做,避免图层混合时产生额外开销。实测下来低端设备上这个弹窗也能保持在流畅的帧率范围内。

6.3 点亮徽章时的粒子效果

为了让解锁过程更有仪式感,我又加了一个粒子爆炸效果——徽章点亮瞬间,周围散落星星点点的碎片。这部分用的是自定义CustomPainter,逐帧绘制粒子位置和透明度。

class ParticleEffect extends StatefulWidget { final Duration duration; const ParticleEffect({super.key, required this.duration}); @override State<ParticleEffect> createState() => _ParticleEffectState(); } class _ParticleEffectState extends State<ParticleEffect> with SingleTickerProviderStateMixin { late final AnimationController _controller; final List<_Particle> _particles = []; @override void initState() { super.initState(); _controller = AnimationController( duration: widget.duration, vsync: this, )..forward(); // 初始化20颗粒子的初始位置、速度和方向 for (var i = 0; i < 20; i++) { _particles.add(_Particle.random()); } } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return CustomPaint( painter: _ParticlePainter( particles: _particles, progress: _controller.value, ), child: child, ); }, ); } }

这里要说一个各大平台通用的经验:粒子动画的粒子数不要贪多,20个左右配合合理的速度和透明度变化,视觉效果已经完全够用。再多就影响了电池续航和低端机的帧率,收益甚微。

7. 把成就系统和OpenHarmony原生能力接起来

7.1 桌面角标与通知联动

成就解锁之后,除了App内的弹窗,我们还会通过OpenHarmony的通知服务给用户发送一条解锁推送。这个需求在Flutter侧通过flutter_local_notifications的OpenHarmony适配版本实现。

要注意,OpenHarmony通知的角标能力和Android不太一样,需要主动声明权限,并且通知栏展示的样式也有差别。实测过程中,我们发现OpenHarmony对通知图标的尺寸和圆角有严格要求,如果直接复用Android的资源会出现显示异常。解决方式是给通知API单独准备一套适配OpenHarmony的图标资源。

7.2 桌面快捷方式

生活助手App一个重要功能是"一键打卡"。我们通过原生侧创建了桌面快捷方式,用户点击快捷方式直接完成"喝水打卡"这个动作,不需要打开App进入页面再点一次。

这个能力的实现,Flutter侧用MethodChannel调用原生侧代码,一步步创建快捷方式。创建成功之后,原生侧通过EventChannel把"快捷方式被点击了"这个事件推回Flutter,Flutter侧补发一次打卡事件给成就引擎。整条链路实现了之前设想的MethodChannel调用 +EventChannel回传。

// 调用原生侧创建桌面快捷方式 static const _methodChannel = MethodChannel('com.example.assistant/shortcut'); Future<void> createWaterCheckInShortcut() async { try { await _methodChannel.invokeMethod('createShortcut', { 'id': 'water_check_in', 'name': '喝水打卡', }); } on PlatformException catch (e) { debugPrint('创建快捷方式失败: ${e.message}'); } }

7.3 PlatformView的嵌入问题

我们的页面里有一部分统计报表是OpenHarmony原生图表组件,需要嵌入到Flutter页面中。这在Android上有个标准方案Hybrid Composition(混合合成),在OpenHarmony上也有对应的PlatformView适配。

但这块的开发体验还比较初期,嵌入原生视图会导致对应的Flutter页面渲染性能下降,特别是列表滚动场景。我的建议是:如果页面里有原生视图,尽量保证它是静态内容或者独立页面,不要让它在类似ListView的滚动容器里反复重建。我们的做法是把原生图表放在一个单独的统计页,页面切换时才创建视图,离开时销毁。

8. 状态管理与内存治理:别让徽章系统拖垮整个App

8.1 状态管理方案的选择

Flutter生态的状态管理方案五花八门,Provider、Riverpod、Bloc、GetX各有拥趸。我在成就模块里用的是Provider + ChangeNotifier,没有引入更重的框架,原因很简单:成就模块的状态结构清晰,数据量也不大,用一个ChangeNotifier就能覆盖UI刷新需求,没必要为这个模块引入完整的状态管理框架。

class BadgeViewModel extends ChangeNotifier { final AchievementEngine _engine; List<BadgeDefinition> _unlockedBadges = []; List<BadgeProgress> _badgeProgress = []; List<BadgeDefinition> get unlockedBadges => List.unmodifiable(_unlockedBadges); List<BadgeProgress> get badgeProgress => List.unmodifiable(_badgeProgress); Future<void> refresh() async { final allBadges = await _engine.getAllBadges(); final progress = await _engine.getAllProgress(); _unlockedBadges = allBadges.where((b) { final p = progress.firstWhere((p) => p.badgeId == b.id, orElse: () => ...); return p.isUnlocked; }).toList(); _badgeProgress = progress; notifyListeners(); } void handleBadgeUnlocked(BadgeUnlockedEvent event) { // 更新内存态,触发页面刷新 refresh(); } }

8.2 内存泄漏排查

徽章系统相比其他模块多了一层事件订阅。使用EventBus时,监听器注册以后一定要在合适的生命周期取消订阅,否则就是典型的内存泄漏场景。

我们在实践里踩过一次:在解锁弹窗的StatefulWidget里注册了BadgeUnlockedEvent的监听,忘记在dispose()里取消订阅。弹窗每次出现都多一个注册,App长时间运行之后卡顿明显。排查后新增了完整的订阅销毁流程:

class _BadgeUnlockDialogState extends State<BadgeUnlockDialog> { StreamSubscription? _subscription; @override void initState() { super.initState(); _subscription = eventBus.on<BadgeUnlockedEvent>().listen((event) { // 处理弹窗的其他逻辑 }); } @override void dispose() { _subscription?.cancel(); _controller.dispose(); super.dispose(); } }

8.3 数据一致性:事务更新

徽章进度更新时,读了进度、改了值、写回数据库,这中间涉及多次异步操作。极端情况下,两个并发的BehaviorEvent同时触发同一枚徽章的进度更新,会导致进度计算错误:一个读到的currentValue是0,另一个读到的也是0,两人都改为1,最终只增加了1,正确的应该是增加2。

处理方案很简单,把进度更新放进数据库事务里,在同一事务中完成读取和写入,避免并发脏读。SQLite的事务开销完全可以接受,同时必须在冲突处理上加上乐观锁——更新时校验last_updated_at时间戳,发现已被他人更新,则重新加载数据再尝试合并进度。

9. 测试与真机验证:徽章系统上线前的最后一关

9.1 单元测试覆盖的核心逻辑

成就系统的规则逻辑非常多,不能只靠真机手工验证。我用Flutter自带的test框架对核心引擎做了充分的单测覆盖。

重点覆盖的测试场景:

  • 首次行为解锁
  • 重复行为不重复解锁
  • 连续行为正确累加
  • 中断后进度重置
  • 一个事件同时触发多个徽章
  • 离线事件补发
  • 跨天重置逻辑
test('连续打卡7天后解锁,中断后重置进度', () async { final engine = AchievementEngine(mockRepo, mockBus); final badge = BadgeDefinition( id: 'week_streak', name: '一周战士', description: '连续打卡7天', category: BadgeCategory.streak, targetValue: 7, iconPath: '', iconLockedPath: '', ); // 连续7天打卡 final startDate = DateTime(2025, 1, 1); for (var i = 0; i < 7; i++) { await engine.dispatch(BehaviorEvent( type: 'check_in', count: 1, occurredAt: startDate.add(Duration(days: i)), )); } // 验证已解锁 final progress = await mockRepo.getProgress(badge.id); expect(progress.isUnlocked, isTrue); // 中断2天后再打卡 await engine.dispatch(BehaviorEvent( type: 'check_in', count: 1, occurredAt: startDate.add(const Duration(days: 9)), )); final progressAfterReset = await mockRepo.getProgress(badge.id); expect(progressAfterReset.currentValue, 1); });

9.2 真机调试中遇到的几个问题

第一批适配OpenHarmony的真机测试,直接暴露了几个在模拟器上发现不了的问题。

一个是字体渲染。OpenHarmony的系统字体跟Android不完全一致,中文数字混排时,徽章名称和进度的排版会差异明显。我们在UI层把关键文字区域固定了字体大小和行高,避免出现截断。

另一个是通知权限单独确认。OpenHarmony的通知默认不开启,需要在代码里检查授权状态。很多用户第一次安装App时误点了拒绝,导致后续徽章解锁推送全部收不到。我们在设置页加了"重新开启通知"的引导入口,上线后这个功能的使用率挺高,说明解决了一个真实痛点。

9.3 性能调优

徽章墙页面的性能一度不理想。主要原因是GridView里每个徽章卡片都加载了两张图片(锁定态和解锁态),图片资源总共加起来约2MB。首帧滚动时能明显感觉到掉帧。

优化方案是给图片加上precacheImage——在页面加载前预加载前几屏的资源,滚动时就不会突然卡住。同时把未解锁的徽章图片统一用缓存加载,避免同一张剪影图被重复解码。

页面里的动画帧率也做了监控。OpenHarmony上用Flutter跑动画,建议开启debugProfile查看帧率情况。实际数据显示Impeller/Skia后端的绘制时间比Android高一些,所以部分高成本动画(比如粒子特效)在低端机上自动降级为简化版。

10. 后续可以怎么扩展:成就系统的进阶玩法

10.1 赛季与限时徽章

目前的徽章系统是永久性的,用户一旦解锁就永久保留。后续可以做的扩展是"赛季制":每个月一个主题,赛季内达成的徽章在赛季结束后不再产出,形成稀缺感。这个扩展对数据模型的要求是:徽章进度加一个seasonId字段,赛季结束时归档进度。

10.2 成就分享卡片

解锁一枚有成就感的徽章之后,用户大概率想分享到社交平台。可以生成一张带徽章、用户昵称、解锁时间的分享卡片,用Flutter的RepaintBoundary把Widget截取成图片,再调用系统分享能力。

这个扩展对成就系统本身的收益是正向的:用户自发传播,带来新用户,新用户又去追求徽章,形成增长闭环。

10.3 成就系统的数据可视化

积累了长期的成就数据之后,可以做一个"成就时间线"页面,像树状图一样展示用户从第一天使用到现在解锁的每一个徽章。这种时间线形式的情感价值很强,用户看到自己一路坚持的轨迹,留存率会有明显提升。

我自己在实际项目里的体会是——成就徽章系统看似是个锦上添花的功能,但它对用户留存和习惯养成的拉动作用远超预期。很多用户会为了"连续打卡30天"这枚徽章,在原本想放弃的那天坚持下来。这就是游戏化机制在工具类App里的价值所在。

如果你正在做类似的东西,我的建议是:先把规则引擎和数据模型想清楚,再去做UI和动画。成就感是用户的事,而数据一致性是开发者的基本功——这两件事做好了,徽章系统就成功了一大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询