社团管理App在OpenHarmony设备上正式上线前,最让我头疼的不是登录、不是通讯录,而是这个看起来人畜无害的荣誉勋章模块。项目需求文档里只有一句话——"给表现积极的同学发个勋章"。我当时天真地以为这就是画个圆形图标、存个发放记录,结果从需求评审到线上稳定运行,前后折腾了小一个月。这篇复盘会把荣誉勋章的数据模型、接口设计、CustomPainter绘制、交互动画、离线缓存、消息通知以及多机型适配的完整链路拆开讲清楚,尤其会重点说明OpenHarmony上跑Flutter时和安卓完全不一样的几个坑。如果你正在用Flutter做OpenHarmony应用,或者要给管理系统加类似的激励功能,这篇内容应该能帮你省掉大把排查时间。
我不会只贴代码。每一步我都会讲清楚为什么这样选、当时踩了哪些坑、换一种方案为什么不行,尽量让读过的人能直接照着落地。
1. 需求推倒两次后,我把勋章拆成了"定义、实例、状态"三件事
1.1 第一次推倒:只画图标根本不算勋章系统
第一版我做了个勋章页,后端给个列表,前端GridView展示,管理员手动录入"某某同学获得XX奖章"。评审会上负责社团工作的老师问了一个问题:"如果这个奖章发错了,怎么撤回?"我愣住。第二个问题:"去年发的优秀社员勋章,今年换届后是不是应该自动过期?"我又愣住。第三个问题:"如果同一个活动,系统重复签发怎么办?"
这三个问题直接推翻了我第一版设计。勋章不是单纯的"图片+文字",它是一个有生命周期的状态对象。它的核心不是"长什么样",而是"什么时候生效、什么时候失效、被谁授予、为什么被授予、能不能撤回"。
1.2 最终确定的勋章分类和生命周期
在第二版设计里,我把勋章分成了三类:
- 管理员手动授予:比如"最佳贡献奖",由社团干部在后台操作,必须有授予理由。
- 活动参与自动发放:成员报名并签到活动后,系统按规则自动授予,比如"打卡之星"。
- 成长等级类:根据累计积分自动升级,比如"三星志愿者"。
这三类看起来不同,但底层都能统一成同一个模型:一个定义(BadgeDefinition)加一个实例(BadgeInstance)。定义描述勋章本身长什么样、什么规则;实例记录"谁在什么时间因为什么原因获得了这枚勋章、当前状态是否有效"。
实例状态我定了一组枚举,禁止直接改字段,只能做状态流转:
| 状态 | 含义 | 允许流转到 |
|---|---|---|
| ACTIVE | 正常持有中 | REVOKED, EXPIRED |
| REVOKED | 管理员撤回 | 无 |
| EXPIRED | 超过有效期自动失效 | 无 |
| PENDING | 客户端离线创建,待服务端确认 | ACTIVE, REVOKED |
1.3 数据模型长这样
BadgeDefinition: - id、name、description - iconKey:勋章图案标识,前端根据key找对应绘制方案 - tier:铜/银/金/特殊 - category:手动/活动/成长 - validDays:有效期天数,0表示永久 - maxPerUser:同一用户最多持有几个,防止重复授予 BadgeInstance: - id、userId、badgeDefId - status:当前状态 - grantedBy:授予人/系统 - grantReason:授予理由 - grantedAt、expireAt - revokedAt、revokeReason - version:乐观锁版本号这套模型的精髓在于:实例创建之后就不再修改,任何业务变更都是状态字段的一次流转,而不是覆盖原来的记录。后面排查"勋章为什么发重了""为什么撤回了还能看到"这类问题,全靠这条设计兜底。
2. 让Flutter跑到OpenHarmony上:环境搭建与编译踩坑记录
2.1 先搞清楚你用的是哪条Flutter分支
Flutter官方并不直接支持OpenHarmony,需要拉社区适配版本。这里有个容易搞混的点:OpenHarmony不是安卓,虽然它兼容部分安卓API,但Flutter跑到OpenHarmony上必须用特定的引擎和构建工具链。
我当时的操作是这样的:
- 安装DevEco Studio,用它装好OpenHarmony SDK,重点确认SDK里的
ets、toolchains、hdc目录都齐全。 - 拉取适配OpenHarmony的Flutter版本,通过
flutter config配置好SDK路径。 - 在项目根目录初始化一个
ohos平台目录,里面用hvigor管理构建。 - 接上真机后先用
hdc list targets确认设备被识别,再用flutter run -d ohos启动。
这套流程在安卓上跑通只要十分钟,在OpenHarmony上我第一次折腾了一整天。原因很多,但主要集中在这几个地方。
2.2 编译期最容易卡住的三类问题
第一类是插件不兼容。很多pub仓库里的插件直接依赖安卓原生代码,gradle文件里写死了安卓依赖,到OpenHarmony上根本编译不过去。我项目的shared_preferences一开始也遇到了类似问题,后来换成了项目里自己写的一个轻量文件存储工具,用JSON文件做持久化,反而更可控。
第二类是平台判断问题。Flutter代码里常见的Platform.isAndroid在OpenHarmony上不一定按你预期走,某些系统服务能力也不存在。最稳妥的方式是封装一层PlatformAdapter,把"当前是否OpenHarmony"作为一个独立判断条件,别依赖默认的platform值。
第三类是签名和安装。OpenHarmony的HAP包需要配置签名信息,不像安卓调试那么随意。签名不对的时候,hdc install会给你一个并不直观的错误码,我第一次还以为是编译缓存问题,清了一堆东西才发现是签名没配。
| 问题 | 表面现象 | 实际原因 | 解决办法 |
|---|---|---|---|
| 插件编译失败 | gradle报找不到SDK | 插件带安卓原生依赖 | 换纯Dart实现或自己写适配 |
| 运行后白屏 | UI没渲染 | Flutter引擎与OpenHarmony版本不匹配 | 锁版本,升/降Flutter对应commit |
| 安装失败 | hdc install报错 | HAP签名未配置 | 检查签名文件并重新配置 |
| 日志看不到 | flutter logs无输出 | hdc服务没连上 | 重新hdc kill + hdc start |
2.3 调试手段也要换
连上OpenHarmony真机之后,adb命令大部分是无效的,要用hdc。比如抓取应用日志:hdc shell hilog,查看进程列表:hdc shell ps -ef。如果你习惯了安卓的adb logcat,前半天效率会很低。我后来直接把常用的几条hdc命令写在便签上,省得每次查文档。
3. 把发勋章当成转账:数据模型与接口的幂等设计
3.1 为什么要用"转账"的思路来设计发放接口
第一次联调的时候,后台管理人员在界面上连续点了两次"授予",结果系统真的给同一个用户发了两枚相同的勋章。排查下来是因为前端按钮没做防抖,请求发了两遍,服务端也老老实实处理了两遍。
这让我意识到,发放勋章和银行转账在分布式系统层面是同一类问题:一个操作可能因为网络超时被客户端重试,但服务端必须保证"同一笔请求只生效一次"。我把这个原则定为整个模块的基线。
3.2 幂等方案与服务端实现要点
具体做法是每笔发放请求带上一个唯一的requestId。前端在点击授予按钮时生成UUID,随请求体一起提交。服务端把这笔请求存一张grant_request表,核心逻辑是:
- 先查
request_id是否已存在,存在就直接返回上次结果,不重复执行。 - 不存在才开启事务,插入
badge_instance、更新用户勋章计数、写入审计日志。 - 事务提交后才返回成功,否则返回失败。
SQL事务示意:
BEGIN; INSERT INTO badge_instances (id, user_id, badge_def_id, status, granted_by, grant_reason, granted_at, expire_at, version) VALUES (...); UPDATE user_badge_count SET total = total + 1 WHERE user_id = ?; INSERT INTO badge_events (instance_id, action, operator, created_at) VALUES (?, 'GRANT', ?, NOW()); COMMIT;不要把expireAt的计算交给客户端。客户端时间可以被改,也可以偏差很大。服务端统一用数据库时间算出过期时间,比如validDays是180天,就写成DATEADD(day, 180, NOW())。
3.3 撤回操作也必须有理由和权限边界
撤回勋章不是一个简单的删除。我在revoke接口里要求必须传revokeReason,没有理由直接拒绝。服务端只做状态流转:把ACTIVE改为REVOKED,并记录revokedAt和操作人。撤回原因在用户端默认不展示,只在管理端后台可见,避免成员之间产生不必要的矛盾。
这个"撤回需要留痕"的设计后来很救命。有一次某社团给同学错发了"最佳组织奖",撤回后指导老师问原因,我直接查审计记录给出了精确到秒的操作时间、操作人和理由。
4. 荣誉勋章的绘制与动画:从静态图片到有点质感的视觉
4.1 为什么不用PNG图片,而是用CustomPainter
第一种方案是美工出一套奖章PNG,前端直接套上去。美工问了我一句:"金银铜三档,每档再分三星、四星、五星,你要几张?"我算了一下,光静态图就快二十张,还要考虑深色模式、不同尺寸的适配。我当场决定改用代码绘制。
用CustomPainter绘制勋章的最大好处是资源重量几乎没有,一个iconKey对应一套绘制逻辑,只要改几个颜色参数就能生出不同档次的勋章,而且缩放不模糊。代价是需要理解Canvas的绘制顺序,以及反复测试渐变、描边、文字排布。
4.2 CustomPainter 核心绘制代码
勋章本质上是几层图形的叠加:外圈光芒、圆形底、内圈描边、中心星标、底部文字。我把绘制拆成几步:
class MedalPainter extends CustomPainter { final MedalTier tier; final double shineAngle; // 扫光旋转角 @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = size.width / 2 * 0.82; // 1. 外圈:用SweepGradient画一圈旋转渐变,模拟金光扫过 final outerRect = Rect.fromCircle(center: center, radius: radius); final sweepShader = SweepGradient( colors: [tier.shineColor, tier.baseColor, tier.shineColor], transform: GradientRotation(shineAngle), ).createShader(outerRect); canvas.drawCircle(center, radius, Paint()..shader = sweepShader); // 2. 内圈:用径向渐变制造金属厚度 final innerRadius = radius * 0.72; final innerRect = Rect.fromCircle(center: center, radius: innerRadius); final radialShader = RadialGradient( colors: [tier.innerLight, tier.innerDark], center: Alignment(-0.4, -0.4), ).createShader(innerRect); canvas.drawCircle(center, innerRadius, Paint()..shader = radialShader); // 3. 中心星标:画一个五角星 final starPath = _buildStarPath(center, innerRadius * 0.4); canvas.drawPath(starPath, Paint()..color = tier.starColor); // 4. 底部名称:用TextPainter绘制"社团荣耀"字样 final textPainter = TextPainter( text: TextSpan(text: '社团荣耀', style: TextStyle(color: Colors.white, fontSize: radius * 0.18)), textDirection: TextDirection.ltr, )..layout(); final textOffset = Offset(center.dx - textPainter.width / 2, center.dy + innerRadius * 0.42); textPainter.paint(canvas, textOffset); } @override bool shouldRepaint(covariant MedalPainter oldDelegate) { return oldDelegate.tier != tier || oldDelegate.shineAngle != shineAngle; } }shouldRepaint里只比较关键参数,避免每帧都重建painter。扫光动画用的是AnimationController驱动shineAngle变化,幅度控制在0.05到0.5之间,让光效有呼吸感但又不闪到刺眼。
4.3 勋章列表和详情页的动画节奏
颁发成功后的庆祝动画我做了两个版本。第一个版本是满屏粒子爆炸加勋章旋转,很炫,但是在低端OpenHarmony设备上明显掉帧。后来改成勋章从卡片中央放大并旋转半圈,同时有一圈光晕扩散一次,整体耗时900毫秒,效果干净利落。
列表页进入详情页我用了Hero动画,缩略图过渡到大图,代码量不大,但视觉连贯性提升非常明显。有一点要提醒:Hero动画要求两个页面的Tag一致,且不能在同一个页面写两个重复Tag,这个坑我犯过一次,跳转时直接黑屏。
5. 颁发、撤回、离线与通知:让勋章真正到用户手里
5.1 管理端到用户端的完整链路
这条链路你看起来可能觉得没什么,但实际跑通时问题很多:
- 管理端在成员列表里选人,选择勋章,填写理由。
- 前端提交发放请求,带
requestId,进入带loading的提交中状态。 - 服务端开启事务写入实例,返回成功。
- 用户端通过通知接口增量拉取新勋章,刷新列表。
- 用户点开勋章详情,看到图案、颁发时间和理由。
最开始的版本没有第四步,用户端每次进页面全量重新拉列表,体验很一般。后来改成按updatedAt > lastCursor做增量拉取,配合本地数据库缓存,打开首页时几乎瞬间渲染旧数据,后台静默拉新数据,体验提升了一个档次。
5.2 离线和缓存:为什么用户"看不到刚发的勋章"
这是个真实事故。管理端在后台明明点了"授予",服务端也写库了,但用户端刷新不出来。排查半天发现是用户端缓存策略的问题:应用启动后,本地数据库会先加载一遍,再请求网络。而当时的加载逻辑是"如果本地有缓存就完全信任缓存,不触发增量拉取",导致服务端新写入的勋章永远进不了本地。
后来我把同步策略改成:启动时先展示本地缓存,同时必定发起一次增量拉取;服务端返回新的记录后,本地做merge,而不是清空重写。缓存merge的规则是version + updatedAt判断谁更新,本地不删除任何历史状态,这样REVOKED的记录也能保留在审计场景里,只是列表上过滤掉。
5.3 通知方案:为什么选了轻量轮询而不是推送SDK
OpenHarmony应用接入厂商推送通道并不像安卓上接主流推送SDK那么顺手,而且社团App使用频率不高,实时性要求没那么夸张。我最后选了轻量增量轮询:用户端每30秒请求一次增量接口,带lastCursor,有变化就刷新,无变化返回空。对服务器压力几乎可以忽略,实现起来也没有额外依赖。
移动端轮询要注意电量和流量,所以我只在应用处于前台时轮询,切到后台就停掉,回到前台立刻拉一次。这种方式下,勋章颁发延迟最多30秒,对一个校园社团场景完全够用。
5.4 撤回不同步的坑
撤回的场景也被我踩过。管理员把一枚发错的勋章撤回后,因为本地缓存没有及时同步,用户端还是显示"获得该勋章"。后来我在缓存表里增加了对status的强制判断,列表查询时过滤掉REVOKED和EXPIRED。光这一个过滤逻辑就避免了两种混乱:用户看到已撤销勋章、活动统计里算入已撤销勋章。
6. 多屏适配与性能调优:不要让勋章页卡出动画变PPT
6.1 不同OpenHarmony设备的布局策略
OpenHarmony跑的设备形态比预想中杂,有标准竖屏手机,也有不少平板。最开始我是固定三列的GridView,平板上一行只有三张卡,图大得离谱。后来改成用LayoutBuilder根据宽度计算列数:
final columns = constraints.maxWidth > 700 ? 4 : (constraints.maxWidth > 400 ? 3 : 2);再配合SliverGridDelegateWithFixedCrossAxisCount的childAspectRatio做卡片比例控制。勋章卡片内容固定,比例用1.0比较稳,图片区域占上部,文字区域占下部。
6.2 滚动列表卡顿的排查与优化
测试时发现一个现象:勋章列表在OpenHarmony低端设备上快速滚动时,帧率掉得很明显。用性能工具抓了几帧,定位到两个问题。
第一个问题是一次性构建了太多CustomPainter。列表页里的每个勋章卡都是图片,而每张图片都走了一遍painter绘制。优化做法是列表项用RepaintBoundary包起来,让滚动时已经画好的内容不被反复重绘;同时把勋章绘制结果缓存成一个ui.Picture,点击时再重新绘制高清版本。
第二个问题是勋章扫光动画在列表滚动时没有暂停。我做了一个滚动监听,isScrolling为true时暂停所有勋章动画控制器,滚动结束再恢复。这个优化对帧率帮助很大,卡顿感肉眼可见地消失了。
| 优化项 | 优化前 | 优化后 |
|---|---|---|
| 列表滚动帧率 | 低端机明显掉帧 | 基本稳定 |
| 勋章卡重建次数 | 每次滚动都重建 | RepaintBoundary隔离 |
| 动画开销 | 滚动中仍播放扫光 | 滚动暂停、停后恢复 |
| 图片内存 | 全量生成Bitmap | 按需生成、缓存Picture |
6.3 用性能工具确认问题,而不是靠感觉
调试性能瓶颈我最大的心得是:一定要用工具量化,不要靠"感觉有点卡"。OpenHarmony上可以用设备侧的性能抓帧工具看每帧耗时,也可以用Flutter自带的DevTools看渲染时间线。把每一帧的build、layout、paint三段耗时拉出来看,问题出在哪一段一目了然。我之前一直怀疑是布局嵌套太深,结果数据显示paint占了大头,白白浪费了半天调布局代码。
写在最后
勋章模块上线后稳定跑了两周,没有出现一例重复发放,也没有用户反馈"勋章消失"。回头看我最大的体会是:这种看起来很小的功能,真正难的不是画图,而是把"状态"和"一致性"想清楚。发勋章就是转账,撤回就是状态流转,离线就是缓存加幂等重试。把这三句话想透,代码怎么写都不会跑偏。
如果下一步要扩展,我建议考虑两件事:一是做一个后台定时任务,扫描临近过期的勋章并提前三天提醒用户;二是把勋章统计加到社团年报里,用数据驱动成员活跃度分析。这两件事都在现有数据模型之上做扩展,不需要推倒重来。希望这篇复盘对你有用。