把荣誉勋章模块从零到一做完我最大的感受是它不是一个 UI 问题而是一个规则建模问题。在 Flutter for OpenHarmony 的社团管理 App 实战里荣誉勋章是最容易做花哨、也最容易做糊的功能。你可以在两分钟内画出一张闪闪发光的勋章图片但真正困难的是让它在正确的时刻出现、只出现一次、并且无论在 Android 还是 OpenHarmony 真机上都能流畅展示。我们这款社团管理 App 覆盖了成员报名、活动签到、积分累计、组织排行等场景。荣誉勋章承担的是“即时正反馈”成员参加完活动马上收到一枚勋章比单纯加一分更有仪式感。这篇文章就是这次实战的记录适合两种读者一是刚接触 Flutter for OpenHarmony想了解跨端适配细节的开发者二是想把积分、勋章这类激励功能做得更稳的产品和技术同学。我会从需求拆解、数据模型、规则引擎、UI 动画、存储同步和问题排查几个层面展开代码会直接给可直接落地的版本。1. 项目整体设计与技术选型1.1 为什么选 Flutter for OpenHarmony而不是双端各写一套先讲背景我们原本的主应用就是 Flutter 写的已经积累了报名、签到、个人中心等模块。后续要支持 OpenHarmony 设备第一个问题就是要不要用 ArkUI 重写一遍如果重写勋章、活动、积分每个模块都要做两套后续维护就是双倍成本。而 Flutter for OpenHarmony 提供的是同一套 Dart 代码运行到 OpenHarmony 的路径。业务层几乎不用改需要适配的是底层能力。对于团队不大、又想快速覆盖更多设备的学生社团性质组织来说这是性价比最高的方案。但也要说实话Flutter for OpenHarmony 的插件生态比传统 Android 平台少一截。比如数据库、路径获取、通知这些常用插件有些需要切换到社区维护的适配版本。所以项目一开始我们就约定所有涉及平台能力的调用必须收拢到一个 PlatformAdapter 接口里不在业务代码里直接 new 一个平台对象。后面接 OpenHarmony 时只需要替换 adapter 实现不需要动勋章逻辑。这个选择有一个潜在前提团队本身已经比较熟悉 Flutter。如果你恰好有一支成熟的 ArkUI 开发队伍并且产品明确只跑在 OpenHarmony 设备上那直接使用 ArkUI 也许更省事。跨端适配本身是交易省了“业务重复开发”的力气就要付出“底层适配排查”的成本。关键是别混着来最怕一个模块用 ArkUI、另一个模块用 Flutter最后变成两套代码都在维护。1.2 荣誉勋章模块的需求拆解我们和运营同学、社团管理员聊了一圈整理出几个典型场景新成员完成第一次活动签到自动获得“初次见面”勋章。累计参与 5 次活动获得“活动达人”勋章。连续 3 周参加周常活动获得“坚持之星”勋章。管理员在特殊贡献后手动补发“贡献之星”勋章。需求边界我们明确为勋章只读定义由后端或配置下发用户获得记录可以增但不能随便删勋章墙只展示已获得/未获得不做积分交易。这样就把“勋章”和“积分”解耦了积分可以消耗勋章是荣誉凭证不能因为积分被扣就收回勋章。技术挑战集中在四点。第一条件组合复杂有“并且”“或者”“数量达到”“连续次数”多种规则。第二活动事件可能重复触发同一枚勋章不能重复发放。第三OpenHarmony 真机上的渲染兼容性和动画性能不一致。第四本地存储与远程同步之间要保证幂等。后面每个章节都会对应解决这些问题。1.3 模块边界与依赖方向这个模块我们叫 medallion不叫 badge 是因为 badge 在很多项目里也被当作“角标”“徽标”语义容易混淆。模块对外只暴露一个 MedallionService内部包含 MedalDefinition、UnlockRule、MedalRecord、MedalStore 四块。依赖方向是单向的活动系统产生 ActivityCompletedEvent该事件携带 userId、activityId、eventId勋章系统订阅事件调用规则引擎判断存储层只被 MedallionService 使用。不允许反方向依赖比如勋章系统去查活动系统的数据库。这么做最大的好处是规则引擎可以独立单测不需要起数据库或网络。我们在开发中期加新的“周年庆勋章”时只加了一个 JSON 配置和对应 evaluator没动任何页面代码。2. 核心模型与数据结构设计2.1 勋章定义和用户记录分开建模最容易被忽视的是把勋章定义和用户持有记录放在同一个类里。假设你在 User 对象上加一个 List 很快就会发现勋章图标描述每个人重复存一份规则判断还没法复用。我们用两个对象分开建模。第一个是 MedalDefinition描述勋章本身enum MedalRarity { common, rare, epic, legendary } class MedalDefinition { final String id; final String name; final String description; final String iconKey; // 对应 assets 或远程图标的 key final MedalRarity rarity; final UnlockRule rule; const MedalDefinition({ required this.id, required this.name, required this.description, required this.iconKey, required this.rarity, required this.rule, }); }第二个是 MedalRecord描述“某个用户在某时获得了某勋章”class MedalRecord { final String recordId; final String userId; final String medalId; final DateTime issuedAt; final String sourceEventId; final String? sourceAdminId; // 手动补发时记录操作人 }recordId 是本机生成唯一IDsourceEventId 是产生这条记录的业务事件ID。这两个字段在防重复发放里很关键。后续查询“某个用户的勋章墙”其实查的是 MedalRecord 列表再通过 medalId 找到对应的 MedalDefinition组合成界面要展示的 ViewModel。这个拆分做对了后面要加“勋章过期时间”“勋章排名名次”都只是加字段不会改结构。2.2 规则条件用 JSON 配置驱动我们不想每次改规则都发版所以勋章定义不从代码硬编码而是从 JSON 配置加载。举一个实际配置{ id: activity_5, name: 活动达人, description: 累计参与 5 次社团活动, iconKey: medal_activity_5, rarity: rare, rule: { type: and, conditions: [ { type: participated_activities, operator: gte, value: 5 } ] } }对应的 Dart 解析器会把 rule 拆成 UnlockRule 和条件对象列表。我们在 UnlockCondition 上用 sealed classsealed class UnlockCondition {} class CountCondition extends UnlockCondition { final String metric; final String operator; // eq, gte, lte final int value; } class StreakCondition extends UnlockCondition { final String metric; final int weeks; }RuleContext 是规则引擎的输入class RuleContext { final String userId; final String orgId; final int participatedActivities; final int attendanceCount; final int streakWeeks; final DateTime now; }这样设计之后判断逻辑完全和 UI 解耦。执行规则时不要在这个类里塞数据库查询所有输入提前从服务层组装好。有人问为什么不用代码直接写 if (count 5)因为运营活动一变比如“从第 6 次开始才发”就要发版而 JSON 配置可以下发更新后端或本地缓存都能热替换。2.3 规则引擎的求值顺序与组合一个勋章允许 and/or/not 组合。我们的 UnlockRule 结构是递归树class UnlockRule { final RuleOperator operator; // and, or, not final ListUnlockCondition conditions; final ListUnlockRule children; }evaluate 方法递归执行and 全真为真or 有真即真not 对子结果取反。重点是在每个叶子条件上不允许写“可能抛异常”的逻辑。所有 number 类输入都提前 clamp字符串统一 trim。这样即使配置写错了最多是判不中不会让整个 App 崩掉。我们一开始把条件 evaluator 写在 MedallionService 里后来发现测起来很麻烦。重构后每个 condition type 对应一个 evaluator比如 CountConditionEvaluator。这样新增条件类型时只是注册一个 map不影响老逻辑。这部分的单元测试覆盖率我们做到了 85% 以上尤其覆盖了“参与次数刚好等于 5”“已连续 3 周但第 4 周中断”这类边界。3. UI 层实现与动效优化3.1 勋章墙布局与卡片设计勋章墙我建议用 GridView 而不是 ListView。勋章天然适合两列或三列网格卡片信息密度高。我们用两列一屏能看到 4 个勋章滑动节奏舒服。卡片最外层是一个 Stack底层放稀有度渐变色边框中间放勋章图标顶部叠加一个半透明解锁状态蒙层。未获得的勋章用 ColorFiltered 把图标整体置灰并且不显示名称高光。已获得勋章则在右上角显示一个很小的 check 标记。这个 check 标记用自绘 CustomPaint 就能画不需要额外资源图。稀有度颜色我们做成表稀有度主色辅助色用途common#9E9E9E#E0E0E0初次见面、周常参与rare#2196F3#BBDEFB活动达人、优秀成员epic#9C27B0#E1BEE7贡献之星、专项冠军legendary#FFB300#FFE082年度最佳、创始成员卡片实现里一定要给关键区域加 const 前缀。比如名称文本、描述文本、间距 Padding这些在滑动中不会变加 const 能减少 rebuild。我们曾经写了一个“根据解锁状态动态改变透明度”的父组件结果每次动画帧都会重建整张卡片后来才反应过来把状态变化收敛到 AnimatedBuilder 内部父组件只负责传数据。3.2 解锁动效的三种实现与取舍解锁动效我踩过不少坑。第一版做了旋转加缩放再加一层 Bloom 光晕效果很炫。但在 OpenHarmony 的默认渲染路径下模糊和 saveLayer 操作会把帧率拉到 45fps 左右明显不跟手。第二版简化成三个动画缩放 0.7 到 1.0旋转 -6 度到 0以及一个由透明度控制的高光贴图。高光贴图预先切好不用运行时模糊帧率稳定在 60fps。具体的做法是用 AnimationControllerAnimationController _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 700), ); void _playUnlockAnimation() { _controller.forward(from: 0); }卡片在 build 时用 CurvedAnimation(parent: _controller, curve: Curves.elasticOut) 驱动 ScaleTransition 和 RotationTransition。高光层用 FadeTransition透明度从 0 到 0.7 再到 0这个用 TweenSequence 控制。如果勋章是首次从列表页点击进入详情页可以用 Hero 动画共享图片。Hero 在 OpenHarmony 上表现正常前提是两个页面用同一个 tag 和同一路径的图片不要用网络 URL 直接当 tagURL 可能带版本参数导致匹配失败。我们统一用 medalId 作为 Hero tag稳妥很多。3.3 列表滚动性能与图片缓存勋章墙的列表会越来越长特别是运营喜欢堆“限定勋章”。我们要管控三点每个卡片包 RepaintBoundary避免局部颜色变化时整条列表重绘。图标不要直接用大图。iconKey 对应一张 256x256 的压缩图列表里只加载 128x128 的规格。我们给图标加载器加了一层内存缓存key 是 iconKey size。grid 的 childAspectRatio 固定不动态计算。如果因为长文本导致卡片高度不一优先截断描述而不是动态布局。另外在 OpenHarmony 真机上如果列表超过 50 个勋章建议用 GridView.builder 固定 itemCount。不要用 SingleChildScrollView 套 Column那样所有卡片一次性 build很容易掉帧。实测下来做一个“已获得 x / 总数 y”的统计入口很重要因为用户会为了收集完成度去主动参加活动这个后面可以单独讲。4. 数据持久化与状态管理4.1 本地存储选型小文件比重量级数据库更合适一开始我想用数据库存储勋章记录后来发现勋章场景数据量很小。一个成员最多几百条记录而且只有追加和查询不需要复杂 SQL。我们用“目录内 JSON 文件 原子写入”就足够了。具体路径应用沙箱下的 documents/medal/definitions.json 存放勋章定义documents/medal/records/{userId}.json 存放每个用户的记录。写入时先写 records/{userId}.json.tmp写完再 rename 覆盖原文件。这样即使过程中 App 被杀也不会留下半个 JSON。OpenHarmony 的沙箱路径获取与 Android 不完全一样我们封装在 PlatformAdapter.getAppDocumentsPath() 里。如果以后要换数据库只改存储层MedallionService 不感知。注意不要把记录和定义写在同一个文件里。定义是全局配置用户记录是个人数据混在一起会导致每次同步都传一大堆无关数据。4.2 状态管理与发放流程UI 层我们用 ChangeNotifier 暴露一个 MedalStore它监听本地文件变化并广播。业务层用 MedallionService 提供发放操作。发放流程用伪代码描述接收 ActivityCompletedEvent 或者管理员手动命令。根据 medalId 找到 MedalDefinition把用户当前上下文组装成 RuleContext。在内存锁中检查该事件是否已在处理队列。查询本地 records若已存在相同 medalId sourceEventId直接返回。执行 rule.evaluate(context)通过则创建 MedalRecord。原子写入本地文件更新内存缓存notifyListeners。异步把新增记录同步到远程 API失败则留在“待同步”列表。关键在第 3 步我们用 MapString, Future 存处理队列避免同一事件在异步回调里并发执行两次。代码final MapString, Futurevoid _inflight {}; Futurevoid issueFromEvent(String eventId, MedalDefinition medal, RuleContext ctx) async { final key $eventId:${medal.id}; if (_inflight.containsKey(key)) return _inflight[key]; final future _doIssue(medal, ctx, eventId); _inflight[key] future; try { await future; } finally { _inflight.remove(key); } }这个模式在处理按钮重复点击时也很有用。不要依赖按钮的 enabled 状态因为网络延迟或页面重建都可能让状态丢失。只要事件 ID 固定队列就能挡住重复请求。4.3 防止重复发放的幂等设计重复发放主要来自两类场景活动系统重试事件以及用户手动触发刷新导致重复提交。单靠 UI“按钮置灰”是不够的后端接口必须幂等。我们的做法是全局唯一 key也就是 sourceEventId。这个 ID 在事件源头生成一次活动签到对应一个固定 ID无论重试多少遍都一样。本地查重逻辑首先内存锁挡住同一进程的并发然后持久化 records 里查 (medalId, sourceEventId) 组合。远程数据库同样给这两个字段建唯一索引插入违反唯一约束时直接忽略。这样即使客户端先写本地成功、远程同步失败、网络恢复了再同步服务端也能识别出这是旧事件而不会发放第二次。这个幂等设计同样可以复用在积分发放上我们后来做积分模块时直接沿用了。这里要特别提醒不要把 sourceEventId 设置成类似“当前时间戳随机数”这样的值。每次重试生成的随机数都不同幂等就失效了。必须是事件源头产生的稳定 ID例如“活动实例ID 成员ID 签到行为ID”。5. 常见问题与排查技巧实录5.1 OpenHarmony 真机上 ShaderMask 一片黑这是我在真机调试时遇到的第一个大坑。列表页有些勋章图标需要做“渐变遮罩”效果我用 ShaderMask 包了一层。在预览器里一切正常一到 OpenHarmony 真机上相关区域直接变成黑色日志也没有报错。后来定位到是渲染缓存路径的兼容问题ShaderMask 生成的一层离屏缓冲在没有启用硬件加速通道时可能出现黑块。我们绕开方案是不再用 ShaderMask 做遮罩改成在素材阶段预渲染好“带高光版本的勋章图”切换状态时直接用两张图做交叉淡入。效果几乎一致但稳定性高很多。排查这类问题时建议先做一个简单的“只有 ShaderMask 的 Demo 页面”在真机上跑排除业务代码干扰。如果确认是兼容问题就及时换实现不要死磕同一个 API。跨端开发最重要的习惯是“一个效果在 A 端 OK不代表在 B 端 OK”。5.2 解锁动画掉帧不是 CPU 不够而是过度绘制另一个高频问题是解锁动画时掉帧。我们用 Flutter 的性能图层看发现动画期间 GPU 线程任务很长原因是我给卡片同时叠加了 BoxShadow、ImageFiltered 模糊层和圆形渐变边框。这三个效果每个都会触发一次 saveLayer叠加起来就是三次离屏渲染。处理办法是砍效果保留 BoxShadow 但把 blurRadius 从 12 降到 6高光不再用模糊而是用预生成的高光 PNG渐变边框从卡片内部绘制改成普通 Container 的线性渐变背景不额外加阴影。调整之后动画期间每帧耗时从 18ms 降到 8ms 左右已经觉得完全够用。这个优化思路在 Android 上也适用真正的性能问题往往不是地图片而是隐形的离屏缓冲。做动效前先想清楚哪些层真的需要遮罩和模糊能用贴图表达的尽量用贴图。5.3 勋章漏发和条件误判的边界情况有段时间有同学反馈“我连续来了三周为什么没拿到坚持之星”。排查后发现规则配置里比较的是“本周日期本地时间”而活动事件的结束时间用的是 UTC。两个时间在时区边缘地区会差一天导致第三周的第一次签到被算进第二周。修法是所有规则上下文统一用“事件时间”而不是“当前时间”并且由后端下发的活动记录里已经带好标准化时间字符串。同时我们在规则引擎单测里补了一条“事件时间跨越自然周边界”的用例。类似的边界还有“累计参与 5 次”的判定如果活动系统已经把失败未签到也算成参与规则会误判。所以在组装 RuleContext 时participatedActivities 只统计状态为“已确认”的活动记录不能直接传总数。这也是“规则输入由服务层组装而不是规则引擎内部查库”带来的好处查库里很难发现数据口径问题。5.4 插件兼容性排查流程Flutter for OpenHarmony 上最耗时间的其实是插件适配。我们遇到 PathProvider 在 OpenHarmony 上返回路径为空shared_preferences 写值不持久。后来总结了一套快速排查流程先看插件是否有 OpenHarmony 适配版本没有就找平台通道的文档。最小复现新建一个空白 Flutter 工程只调用该插件确定问题是否出在插件本身。如果插件冲突通过 PlatformAdapter 接口自行实现路径读取用系统环境变量或平台通道调用 OpenHarmony 能力。把平台差异注释写在 adapter 里方便后来维护的人。我们最终把“路径获取”“文件读写”“网络状态”都放进了 adapter。后续迁移到新设备时只改 adapter不动业务。排查时需要耐心因为 Flutter for OpenHarmony 的报错栈有时不会直接指向问题插件。建议把 plugin 版本固定下来不要频繁升级否则一个问题没解决又引入新兼容问题。比如我们曾经为了修复一个网络插件的小问题升级了依赖结果另一处图片加载开始闪退最后只能整体回滚。这次把荣誉勋章模块做完我最大的收获是“规则建模先行”这句话。如果你先做动画、再做规则很可能在联调阶段发现勋章该发没发、不该发却发了最后推倒重来。反过来先把 MedalDefinition、UnlockRule、RuleContext 这些模型定稳再把 UI 做得再炫也不会心虚。最后再分享一个实用技巧在勋章墙页面加一个“已获得 x / 总数 y”的统计入口用户真的会为了收集完成度去主动参加活动。这个功能是我们上线后反馈最多、也最容易维护的增强点。