ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenHarmony上Flutter成就系统开发:从规则引擎到动画实践

OpenHarmony上Flutter成就系统开发:从规则引擎到动画实践 为什么我要在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 MapString, 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 FutureDatabase _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.onBehaviorEvent().listen(_handleEvent); } Futurevoid _handleEvent(BehaviorEvent event) async { // 根据事件类型找到匹配的徽章规则 final matchedBadges await _badgeRepo.findByEventType(event.type); for (final badge in matchedBadges) { await _processBadge(event, badge); } } Futurevoid _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时间补单时按时间顺序逐个喂给引擎保证顺序性。Futurevoid _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还是MethodChannelFlutter与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 StateBadgeUnlockDialog createState() _BadgeUnlockDialogState(); } class _BadgeUnlockDialogState extends StateBadgeUnlockDialog with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animationdouble _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: Tweendouble(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 StateParticleEffect createState() _ParticleEffectState(); } class _ParticleEffectState extends StateParticleEffect 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把快捷方式被点击了这个事件推回FlutterFlutter侧补发一次打卡事件给成就引擎。整条链路实现了之前设想的MethodChannel调用 EventChannel回传。// 调用原生侧创建桌面快捷方式 static const _methodChannel MethodChannel(com.example.assistant/shortcut); Futurevoid 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. 状态管理与内存治理别让徽章系统拖垮整个App8.1 状态管理方案的选择Flutter生态的状态管理方案五花八门Provider、Riverpod、Bloc、GetX各有拥趸。我在成就模块里用的是Provider ChangeNotifier没有引入更重的框架原因很简单成就模块的状态结构清晰数据量也不大用一个ChangeNotifier就能覆盖UI刷新需求没必要为这个模块引入完整的状态管理框架。class BadgeViewModel extends ChangeNotifier { final AchievementEngine _engine; ListBadgeDefinition _unlockedBadges []; ListBadgeProgress _badgeProgress []; ListBadgeDefinition get unlockedBadges List.unmodifiable(_unlockedBadges); ListBadgeProgress get badgeProgress List.unmodifiable(_badgeProgress); Futurevoid 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 StateBadgeUnlockDialog { StreamSubscription? _subscription; override void initState() { super.initState(); _subscription eventBus.onBadgeUnlockedEvent().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和动画。成就感是用户的事而数据一致性是开发者的基本功——这两件事做好了徽章系统就成功了一大半。
返回列表