欢迎加入 KMP&CMP 鸿蒙社区:https://atomgit.com/CPF-KMP-CMP

适配后仓库地址(AtomGit,待提交):https://atomgit.com/oh-tpc/ohos_konfetti

摘要

本文是 KMP 三方库鸿蒙化适配系列的第六篇,“库对平台的隐含假设"这条主线在此迎来第五类样本。前四篇分别是:输入侧的能力假设(kotlinx-datetime / kotlinx-io,失效=抛异常)、时序假设(Essenty,失效=静默丢状态)、数据契约假设(kotlin-csv,失效=每格静默错值),以及输出侧的终端假设(Mordant,失效=从未被真正渲染)。Konfetti(DanielMartinus/Konfetti,Maven nl.dionsegijn:konfetti-core:2.0.5)是另一个输出侧假设,但性质不同:它是被动库——自己不驱动任何循环,假设"宿主会按稳定节拍持续喂帧,且节拍永不中断”。这是节奏假设(帧时钟假设),而且它有上游实锤:issue #340「deltaMs includes time when the app is on the background」,切后台的时间被算进 dt,用户回到应用的那一帧,粒子按真实流失的秒数一步积分、集体瞬移离屏。全文先利用 konfetti-core"编译期零依赖、纯 Kotlin"的结构替它把 KMP 的门推开(上游 issue #328 请求多平台支持至今开放,本次适配的第一步就是补 ohosArm64 target),再逐条拆掉节奏假设的三个支点:帧时钟必须由宿主喂入、dt 必须钳制(超过 50ms 视为时钟断档)、页面隐藏必须停表(与 Essenty 那篇的生命周期时序在此会师);渲染层则从 Android Compose Canvas 翻译到 ArkUI Canvas(save/translate/rotate/restore 一一同构)。验证全部做成可辨识对照:时钟停走=粒子静止、同 seed 模拟统计可复现、"切后台归来"在钳制开关两侧呈现"连续"与"瞬移"两个世界。方法论沉淀成一句:主动库要宿主给数据,被动库要宿主给时间——凡是把驱动权交给平台的库,适配的本质就是把"谁在什么时候调用我"重新接回来。

目录

一、接着 Mordant 往下走:被动库与"谁来喂帧"

上一篇 Mordant 把假设从输入侧挪到了输出侧,但它的失效仍然是"静态的"——转义流没人解释,画面错一次就错在那里。Konfetti 的失效是动态的:它是一个粒子系统,每一帧都要根据"距离上一帧过去了多久"(dt)来积分出新的位置。它自己不计时、不驱动循环,完全依赖宿主按节拍喂帧。

这类"被动库"的隐含假设可以拆成三个支点:有人喂帧(宿主存在渲染循环)、节拍稳定(dt 是一个小而平滑的值)、节拍永不中断(应用永远在前台)。三条在桌面终端上基本成立,在移动设备上第三条几乎必然被打破——切后台、锁屏、页面跳转,时钟说停就停。

Konfetti 的上游把这个假设的裂缝写得明明白白:issue #340 标题就是"deltaMs includes time when the app is on the background"——切后台的时间被原封不动算进了 dt。用户离开 5 秒回来,库认为"过去了 5 秒",于是一步积分把所有粒子送出屏幕。这不是鸿蒙特有的问题,但它恰好是鸿蒙适配时必须处理的问题:移动平台的生命周期切换远比桌面频繁。

还有一个更基础的事实:Konfetti 上游还不是 Kotlin Multiplatform——issue #328/#323 请求多平台支持至今开放。好在它的分层给了我们一条捷径,这正是下一节的内容。

二、先看清 Konfetti 的分层:零依赖核心与渲染外壳

按"是否碰平台、是否碰时间"把 Konfetti 切开:

第一层:物理模拟(konfetti-core),纯逻辑,编译期零依赖。 锥形爆发(angle/spread)、速度区间(speed/maxSpeed)、阻尼衰减(damping)、重力、旋转、寿命与淡出(timeToLive/fadeOut)——全是"配置加随机数进、粒子数组出"的纯运算。Maven 上可以验证:konfetti-core 2.0.5 的编译依赖只有 kotlin-stdlib。这一层就是为多平台而生的,只是上游还没给它挂上别的 target。

第二层:帧驱动(时钟),节奏假设的主战场。 上游把"每帧推进"缝在 Android 的渲染循环里,dt 由平台回调给出。鸿蒙上这一层要整体重接:谁喂帧、dt 怎么算、断档怎么办。

第三层:渲染外壳(konfetti-compose / konfetti-xml),Android 专属。 Compose Canvas 上的 save/translate/rotate/fill。这一层在鸿蒙上换 ArkUI Canvas 重写,API 几乎一一同构,工作量最小。

分层清楚后,适配的三件事就定了:给 core 挂 target、把时钟接给宿主、把画笔换成 ArkUI。

三、第一步:替它把 KMP 的门推开

ohosArm64() 起手式与系列前几篇一致,但这一篇它有特殊意义——上游自己都还没进 KMP 的门:

// konfetti-core/build.gradle.kts
kotlin {
    jvm()          // 现状:上游只发布了 JVM 构件
    ohosArm64()    // 本次新增:零依赖纯 Kotlin,直接继承 commonMain

    sourceSets {
        val commonMain by getting
        val ohosArm64Main by creating {
            dependsOn(commonMain)   // 物理核心不含任何平台 API,一行不改即可编译
        }
    }
}

因为 konfetti-core 编译期零依赖,这一步几乎是系列里最顺的——没有 expect/actual 要补,没有平台 API 要绕。适配的真正工作量全部在后面两层:时间和画笔。这也印证了系列的判断:适配工作量与代码体量无关,与"库对平台做了多少隐含假设"强相关——konfetti-core 的体量不小,但它的假设几乎为零;它的时间假设才是一切麻烦的来源。

四、第二步:帧时钟从哪来——被动库的时间语义

被动库的铁律:库不产生时间,时间一律由宿主喂入。 鸿蒙侧的喂帧方式按场景选:

// 方案一:CMP 场景 —— 用 Compose 的帧回调(CPF-KMP-CMP 的 compose-multiplatform-core 1.9.x-OH 提供)
LaunchedEffect(party) {
    var last = withFrameNanos { it }
    while (isActive) {
        val now = withFrameNanos { it }
        step((now - last) / 1_000_000_000.0)   // dt 单位秒
        last = now
    }
}

// 方案二:ArkTS 宿主场景 —— 定时器喂帧(本篇 demo 的等价实现)
// setInterval(() => tick(), 16)   // ~16ms,tick() 内计算 dt 并 stepParticles

两条路里 dt 的语义必须统一:dt 是"上一帧到这一帧的真实间隔",由喂帧方计算、被推进方消费;推进方对 dt 不做任何假设——除了一个例外,见下一节。

这里有个演示价值很高的副产物:把时钟停掉,粒子立刻静止。主动动画库(属性动画)会自己走完,被动库不会——"停止时钟=画面凝固"本身就是节奏假设成立的直接证据。

五、第三步:dt 钳制——上游 #340 的修复点

稳定节拍是理想,断档才是常态。切后台 5 秒回来,喂帧方算出 dt=5.0s——如果把这个值原样交给积分器,一步就把速度 300px/s 的粒子送出 1500px。上游 #340 的根源正是这里:后台时间被算进了 dt。

修复点放在消费侧(推进方)而不是生产侧,这样无论谁喂帧都安全:

const val MAX_DT = 0.05   // 60fps 一帧 16.7ms,放宽到 50ms:超过即视为时钟断档

fun step(particles: MutableList<Particle>, rawDt: Double, clamp: Boolean) {
    val clamped = clamp && rawDt > MAX_DT
    val dt = if (clamped) MAX_DT else rawDt
    // 阻尼、重力、位移……全部只用这个被钳制过的 dt
}

两个设计决策要说明:第一,钳制点选在 50ms——正常帧(16.7ms)永远不会触发,偶发卡顿(一两帧)也不受影响,只有真正的断档(切后台)才会被拦下;第二,钳制的同时要打日志——"检测到时钟断档"这条记录本身就是给验证用的可辨识证据,静默钳制和静默丢状态一样危险。

验证这个修复要用对照实验,而且要排除 ttl 的干扰:默认配置下粒子寿命只有 2.6 秒,离开 5 秒后粒子早该自然消失了,看不出钳制与否的差别。所以对照组用"长寿命、无重力、固定速度"的专用配置——让"瞬移离屏"和"原地连续"成为唯一变量。

六、第四步:生命周期停表——与 Essenty 那篇会师

钳制解决的是"断档后的安全",但还有一半契约:页面隐藏时主动停表。不停表有两个后果——空转耗电(后台还在 60fps 烧 CPU),以及归来时一堆本不该发生的断档钳制日志。

这一步就是 Essenty 那篇的时序假设在动画库上的翻版:aboutToDisappear 停掉喂帧循环,页面回显时重新启动。把两篇放在一起看,会发现一条统一的线索:Essenty 要宿主在正确的时机"通知"它,Konfetti 要宿主在正确的时机"喂"它、并"停喂"它——被动库的适配,本质都是把驱动权从平台手里接回来、按时序契约重新交接。

// ArkTS 侧的等价实现(真实 KMP 工程挂 lifecycle 监听同理)
aboutToDisappear(): void {
    clearInterval(this.timerId)   // 停表:不空转、归来不爆炸
    this.timerId = -1
}

七、第五步:渲染翻译——Compose Canvas 到 ArkUI Canvas

konfetti-compose 的绘制逻辑是 Android Compose Canvas:每个粒子 save → translate → rotate → 按 shape 画矩形/圆 → restore,淡出用透明度。搬到 ArkUI Canvas 几乎逐行同构:

// ArkUI CanvasRenderingContext2D:与 Compose DrawScope 一一同构
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate(p.rot)
ctx.globalAlpha = fadeAlpha(p)   // ttl 末段线性淡出(fadeOutEnabled)
ctx.fillStyle = p.color
// shape 0 = 纸带矩形,shape 1 = 圆片
ctx.restore()

翻译时的两个注意点:粒子坐标统一用逻辑像素(vp),画布尺寸从 onAreaChange 拿真实值注入模拟器(画布尺寸是"边界回收"的依据,拿错会让粒子提前消失或永不消失);绘制循环里不要分配对象,粒子对象复用,GC 抖动在 60fps 下会被肉眼看见——这又绕回了节奏假设:节拍稳定不只是时钟的事,每一帧的耗时也得稳定。

八、第六步:导出、打包与三组真机验证

导出链路与系列一致:@CName 导出符号 → NAPI 注册 → .so 进 HAR。真机验证三组,每组各堵节奏假设的一个支点:

  1. 时钟停走:点"停止时钟",粒子应立即静止;重启时钟,从静止处继续。证明"有人喂帧"确实是动画的全部来源。
  2. 切后台归来对照(上游 #340):同 seed 爆发一批长寿命粒子,时钟回拨 5 秒。钳制开:画面连续、只前进 50ms、日志出现"检测到时钟断档";钳制关:整屏粒子一帧消失(瞬移离屏)。用同 seed 保证两侧看到的是同一批粒子——随机数不可复现,对照就不成立。
  3. 生命周期停表:切走页面再切回,确认无"归来爆炸"、无后台空转(日志静默),帧循环随页面重建。

图 1:真机 Canvas 彩带爆发效果(~300 粒子 @60fps)
图 2:钳制开/关两次"切后台归来"的同帧对照(连续 vs 瞬移离屏)
图 3:时钟断档日志(“dt=5.02s -> 钳制为 0.05s”)
图 4:停止时钟后画面凝固、重启后继续的录屏
(投稿前请补入上述四张真实截图)

九、踩坑清单

坑一:ohosArm64() 报未定义。 同系列前几篇,需要 HarmonyOS Kotlin 定制版工具链。

坑二:以为上游是 KMP。 Konfetti 的多平台支持还在 issue #328 里躺着,konfetti-compose 只是 Android Compose 构件。适配要自己补 target——好在 core 零依赖,这一步不难。

坑三:dt 不钳制。 切后台的时间全额参与积分,归来一帧粒子瞬移离屏(上游 #340 的原始行为)。钳制点放在消费侧,50ms 上限。

坑四:钳制了但不留痕。 静默钳制会让"断档发生过"这件事无从验证。钳制必须打日志,这也是验证用例的证据来源。

坑五:拿默认配置做钳制对照。 默认粒子寿命 2.6 秒,离开 5 秒后粒子自然到期,瞬移和到期混在一起,对照失效。对照实验要用长寿命无重力的专用配置,让钳制成为唯一变量。

坑六:页面隐藏不停表。 后台空转烧电,归来时狂打断档日志。aboutToDisappear 停表是节奏假设的另一半契约。

坑七:绘制循环里分配对象。 每帧 new 出来的临时对象会制造 GC 抖动,60fps 下肉眼可见——节拍稳定不只是时钟的事。

十、小结:五类假设拼图与"被动库"统一原则

六篇下来,"库对平台的隐含假设"的拼图补齐了五格:

类型侧别样本失效表现谁兜底
能力假设输入kotlinx-datetime / kotlinx-io抛异常 / 回退 / 权限拒绝编译器与运行时会喊
时序假设输入Essenty不崩,静默丢状态靠验证用例
数据契约假设输入kotlin-csv不崩不丢,每格静默错值只能靠可辨识对拍
终端假设输出Mordant不崩不丢不错,只是从未被真正渲染翻译层 + 宿主注入
节奏假设输出Konfetti归来一帧,集体瞬移宿主喂帧 + dt 钳制 + 停表

Konfetti 给这条主线补上了最后一块拼图,也把系列前几篇串成了一句话:库分两种——主动库向平台要数据,被动库向平台要时间。 kotlinx-io 要的是"路径从哪来",Essenty 要的是"谁在什么时候通知我",Konfetti 要的是"谁在每一帧喂我、什么时候停喂"。适配被动库时,先问三个问题:谁喂帧?dt 断档怎么办?页面隐藏停不停表?三问答完,帧时钟假设就拆干净了。而验证的通用配方依然没变:同 seed 可复现、对照两侧同框、每个假设一条可辨识证据。

欢迎加入 KMP&CMP 鸿蒙社区,一起共建 OpenHarmony 跨平台生态:
https://atomgit.com/CPF-KMP-CMP

推荐使用码道进行 KMP/CMP 工程的代码补全与适配辅助,专属邀请入口:
https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgiths


环境信息:DevEco Studio 26.0.0 Release / HarmonyOS Kotlin 2.2.21-1.0.0 / Gradle 8.14.1 / JDK 21 / 真机 ROM 6.1+ / konfetti-core 2.0.5

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐