开源鸿蒙平台 CMP 三方库「snowfall-compose」适配全流程
本文记录
snowfall-compose接入 OpenHarmony 的完整过程,覆盖 CMP 动画库盘点、物理模型抽离、真实引擎编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机逐帧渲染页面、HAP 构建和真机验收。
snowfall-compose是基于 Compose Multiplatform 的雪花下落动画修饰符库:任意组件挂上Modifier.snowfall()即得到一层下雪效果,参数有color/alpha/strokeWidth/drawPosition(Ahead / Behind)/snowflakes: List<Path>/snowflakeMinSize / MaxSize / MinSpeed / MaxSpeed / Density。本次适配与 KStore 的"真编译整库"路线有本质区别:上游 5 个 Kotlin 文件 100% 依赖androidx.compose.ui(DrawModifierNode/Path/withFrameNanos/rememberSaveable),上游 CMP 1.7.1 / Kotlin 2.0.21 与社区 HarmonyOS Kotlin 2.2.21 工具链对不齐——因此走社区早期已审路线(qrose / haze / PreCompose / voyager 的"零依赖内核提取"):把雪花物理引擎(数量、落点、位移、角度扰动、出界回收、参数热更新)逐条公式对照上游重写为纯 Kotlin,连同三种默认雪花矢量坐标一起编成 OpenHarmony KLIB;上游 Compose 实现整体git mv到composeMain隔离不编。更关键的一步在桥协议:动画库不能沿用"一次调用出结果"的模式,本次把 C ABI 升级为 handle + 每帧状态查询(SnowfallCreate/SnowfallFrame/SnowfallConfig/SnowfallDispose四入口),ArkTS 用displaySync逐帧拉取雪花状态、用 CanvasPath2D描边渲染——真机实测 123 片雪花稳定 60 fps。
项目地址: AtomGit/oh-tpc/snowfall-compose.git
上游仓库: skeptick/snowfall-compose
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
snowfall-compose 为 Compose Multiplatform 应用提供"内容之上叠一层雪"这一件事的完整答案:Modifier.snowfall() 一行挂上任意组件,SnowfallDrawPosition.Ahead / Behind 决定雪画在内容前还是内容后;改参数不闪烁——密度变化只裁出界雪花并按需追加,尺寸 / 速度范围变化按 scaleRatio / speedRatio 比例因子重算已有雪花,这是它区别于"粒子发射器"的声明式语义。上游在 Android / iOS / 桌面 / Web(含 wasm)上共享同一份 commonMain。OpenHarmony 应用同样需要这类节日 / 氛围动画能力,但上游构建(Kotlin 2.0.21 / Compose 1.7.1 / AGP 8.5.2)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS 粒子动画,页面可以下雪,却无法证明上游的雪花物理在 OpenHarmony ARM64 设备上完成编译、链接和执行——两边的下落角区间、回收边界、密度换算一旦漂移,“适配"就变成了"重写”。适配需要同时验证物理引擎、Native 动态库、C ABI、N-API、ArkUI 逐帧渲染页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 全量 Compose 依赖 | 上游 5 个 Kotlin 文件全部 import androidx.compose.ui,没有一行可直接编译的纯 Kotlin;不像 KStore 有完整 commonMain 内核可真编译。 |
| 工具链不一致 | 上游 Kotlin 2.0.21 / Compose 1.7.1 / AGP 8.5.2 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象与 Compose Path,需要 C ABI、C++ N-API 和 JSON。 |
| 动画驱动跨不了桥 | 上游用 withFrameNanos 帧回调驱动、mutableFloatStateOf 触发重绘;这套机制到鸿蒙侧必须有等价的逐帧管道。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:雪花物理引擎的 ohos 编译(公式逐条对照上游)、根构建替换(cmp_qrose 模式)和 handle + 每帧状态查询的 C ABI/N-API 桥接层。物理语义完全由对照上游的引擎实现负责,ArkTS 只负责逐帧拉状态、描边渲染和参数面板。
1.2 库提供的能力
Demo 提供一场真实的持续下雪会话,对应桥接的全部入口:
- 创建(Create):
SnowfallCreate按画布尺寸 / 尺寸范围 / 速度范围 / 密度开店,返回 handle、初始雪花数量和三种默认雪花的矢量线段(ArkTS 一次构建Path2D复用整个生命周期); - 逐帧推进(Frame):
SnowfallFrame每帧执行上游物理——x += speed × cos(angle)、y += speed × sin(angle)、角度±0.0025随机扰动,返回全部雪花的[x, y, scale, shapeIndex, ...]平铺数组; - 热更新(Config):尺寸 / 速度 / 密度滑条变化走
SnowfallConfig,已有雪花按比例因子平滑变尺寸变速,密度变化只裁出界雪花并按需追加,不闪烁重生; - 绘制层(Ahead / Behind):
zIndex切换雪花画在内容前还是内容后,即时生效不重建引擎; - 渲染参数:7 色板、
alpha、strokeWidth在 ArkTS 侧本地生效,不过桥。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
handle / count | 引擎句柄与当前雪花数量(状态行实时显示,真机实测 123 片)。 |
paths | Create 返回:三种雪花的 segs 线段数组与归一化尺寸 size。 |
flakes | Frame 返回:[x, y, scale, shapeIndex, ...] 平铺浮点数组(x/y 取 1 位小数、scale 取 3 位,控制每帧 JSON 体积)。 |
引擎侧完整能力(密度换算 0.0003 / 屏幕密度 × density、面积计数、落点随机、角度扰动、coerceIn 边界、触底回收、三类热更新失效判定)全部在 commonMain 的 SnowfallEngine 里,随编译进入 .so;上游 Compose 实现留在 composeMain 等 CMP 工具链对齐后接回。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游物理公式逐条对照重写为纯 Kotlin 引擎(SnowfallEngine.kt + SnowflakeShapes.kt,全仓仅有的 2 个新引擎源文件);三种雪花坐标逐字转写自上游 Snowflakes.kt;上游 Compose 源码 git mv 到 composeMain 原样保留(100% rename,历史完整)。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample/(android / js / wasmJs)留盘不进构建。 |
| 桥接稳定 | 4 个 C ABI 和 UTF-8 JSON,handle 管理引擎生命周期;异常永远不跨 FFI。 |
| 依赖纪律 | 引擎零依赖(纯 Kotlin stdlib),不引入任何 kotlinx 制品,没有版本对齐问题。 |
| UI 完整 | 单页覆盖全部桥接入口与全部上游参数:预览区、状态行、绘制层开关、7 色板、7 个滑条实时热更。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。snowfall-compose 的卖点是"CMP 修饰符语义",鸿蒙侧用 Canvas 逐帧描边等价复现该语义;物理模型必须与上游一致,这是适配的验收底线。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点上游 5 文件依赖形态(100% Compose)、确定"物理抽离 + composeMain 隔离"路线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.10.2 → 8.14.1、JDK 21
第 3 阶段:桥协议建模 ── handle + 每帧状态查询、Create/Frame/Config/Dispose 四入口 JSON 契约、物理公式对照、错误通道
第 4 阶段:原生桥接 ── @CName C ABI ×4、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── displaySync 帧循环、Path2D 一次构建、Canvas 描边渲染、参数面板热更
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
snowfall-compose/ 库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
snowfall-compose/src/commonMain/ 新写:物理引擎 SnowfallEngine.kt + SnowflakeShapes.kt(纯 Kotlin,零依赖)
snowfall-compose/src/composeMain/ 上游 Modifier.snowfall() 实现整体隔离(git mv 保留,ohos 不编)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
sample/ 上游示例(android / js / wasmJs),留盘不进 settings
content/ 上游截图素材
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
doc/ 本文章与真机效果图
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :snowfall-compose 与 :example:nativeApp,rootProject.name = "ohos_snowfall"。上游根构建的 buildscript classpath(AGP / compose / vanniktech 发布)随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Temurin jdk-21.0.12.101-hotspot) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 8.10.2 升版;2.2.21 线要求,与 qrose 同款) |
| 引擎依赖 | 无(纯 Kotlin stdlib,不引入任何 kotlinx 制品) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.snowfall |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
| 上游文件 | 行数 | 依赖形态 | 处置 |
|---|---|---|---|
Snowfall.kt | 320 | DrawModifierNode / LayoutAwareModifierNode / withFrameNanos / rememberSaveable | git mv → composeMain,物理逻辑对照重写进引擎 |
internal/Snowflakes.kt | 150 | Path(三种雪花坐标) | 坐标逐字转写为 FloatArray 进引擎 |
internal/SnowflakeState.kt | 50 | mutableFloatStateOf | Compose State 包装去掉,纯字段进引擎 |
internal/SnowfallState.kt | 40 | Saver / listSaver | 持久化不跨桥,鸿蒙侧暂不实现(见 8.2) |
internal/Extensions.kt | 15 | Path.getBounds | 角度区间常量与 Float.times(range) 算子照搬;包围盒改为对坐标数组求 min/max |
盘点结论决定了路线:没有一行上游代码能直接编进 ohos,但物理公式是纯数学。抽取边界划在"物理模型 / 渲染实现"之间——引擎负责"雪花在哪、多大、多快",渲染交给 ArkTS Canvas。这个判断与 KStore 正好相反(KStore 的 commonMain 是完整内核、直接真编译),适配路线由库的依赖形态决定,不存在万能模板。
1.4 页面边界
标题区 snowfall-compose · OpenHarmony / 副标题(CMP 雪花动画库 · Kotlin/Native ohosArm64/ohosX64)
预览区 360vp 黑色圆角画布:中央 "Happy New Year!" 文字 + Canvas 雪花层(zIndex 切换前后)
状态行 雪花 N · fps(真机实测:雪花 123 · 60 fps)+ 引擎状态(引擎就绪 handle=1)
绘制层开关 Ahead / Behind Toggle(zIndex 即时切换,不重建引擎)
色板区 White / Red / Green / Blue / Yellow / Cyan / Magenta 七色即点即变
滑条区 alpha(0~1) / strokeWidth(1~3) 本地生效;min/maxSize(10~50dp)、min/maxSpeed(0~5dp)、density(0.1~5) 过桥热更
页面启动时 display.getDefaultDisplaySync().densityPixels 取屏幕密度,画布 onReady / onAreaChange 换算 px 后调 snowfallCreate 开店。
第 2 阶段:目标与依赖打通
2.1 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
引擎零依赖带来一个直接好处:没有 KStore 那种"工具链版本跟着 kotlinx 制品走"的反推问题,直接落社区标准钉法 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 同款)。代价在 Gradle:上游 wrapper 是 8.10.2,2.2.21 线的 Kotlin 插件在 8.10.2 上配置期就会失败,必须升到 8.14.1——这条经验按工具链线区分:2.4.20 线可以留 Gradle 9.x(KStore 先例),2.2.21 线钉 8.14.1,两条线的经验不能互相套用。
settings.gradle.kts 把 eazytec 仓库写在最前:
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
google()
mavenCentral()
}
}
rootProject.name = "ohos_snowfall"
include(":snowfall-compose")
include(":example:nativeApp")
版本目录裁剪到只剩一个插件别名:
[versions]
kotlin = "2.2.21-1.0.0"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
2.2 编译物理引擎
snowfall-compose/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
}
}
commonMain.dependencies {} 是刻意留空的——引擎就是零依赖。上游的 compose.foundation / compose.ui、android target、vanniktech 发布插件全部随重写剥离。实测:
./gradlew :snowfall-compose:compileKotlinJvm # BUILD SUCCESSFUL(约 21 秒)
2.3 composeMain 隔离
上游 5 个文件用 git mv snowfall-compose/src/commonMain snowfall-compose/src/composeMain 整体迁移,git 识别为 100% rename,历史完整保留。composeMain 不是注册的 source set,任何 target 都不编译它——留下来有两个作用:对照上游 API(参数语义、Stroke / withTransform 的渲染细节),以及等社区 compose.ui ohos 制品与 CMP 对齐后原样接回。
2.4 nativeApp 动态库
example/nativeApp/build.gradle.kts 是 sharedLib 的唯一归属地(库模块不开 binaries.sharedLib,否则每个模块都吐一份带运行时的 so):
kotlin {
applyDefaultHierarchyTemplate()
compilerOptions {
optIn.add("kotlin.experimental.ExperimentalNativeApi")
}
ohosArm64 {
binaries.sharedLib { baseName = "ohossnowfall" }
}
ohosX64 {
binaries.sharedLib { baseName = "ohossnowfall" }
}
sourceSets {
ohosMain.dependencies {
implementation(project(":snowfall-compose"))
}
}
}
fun cxxShared(triple: String): File {
val deps = file("${System.getProperty("user.home")}/.konan/dependencies")
val hits = deps.listFiles().orEmpty()
.map { File(it, "lib/$triple/libc++_shared.so") }.filter { it.isFile }
check(hits.isNotEmpty()) { "libc++_shared.so for $triple not found under $deps" }
return hits.first()
}
tasks.register<Copy>("copyKnSoArm64") {
from(layout.buildDirectory.dir("bin/ohosArm64/debugShared")) { include("libohossnowfall.so") }
from(cxxShared("aarch64-linux-ohos"))
into(rootProject.layout.projectDirectory.dir("example/harmonyApp/entry/libs/arm64-v8a"))
}
// copyKnSoX64 同构,target 换 x86_64-linux-ohos 与 x86_64 目录
tasks.matching { it.name == "linkDebugSharedOhosArm64" }.configureEach {
finalizedBy("copyKnSoArm64")
}
cxxShared 从 ~/.konan/dependencies 找匹配 triple 的 libc++_shared.so——KN so 的 DT_NEEDED 里有它,缺了运行时 dlopen 直接失败。根构建还全局禁用 ohos 测试任务(社区纪律):
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
第 3 阶段:桥协议建模
3.1 JSON 契约
io.github.skeptick.snowfall.engine/**(新写引擎,公式对照上游)
↓ SnowfallEngine / resize / update / frame
Bridge.kt(@CName × 4,扁平 JSON 进出)
↓ UTF-8 JSON
N-API snowfallCreate / snowfallFrame / snowfallConfig / snowfallDispose
↓
ArkUI Index.ets(displaySync 逐帧 + Canvas 描边)
请求与响应:
Create 请求 : {"width":1080,"height":1188,"minSize":30,"maxSize":60,
"minSpeed":0.6,"maxSpeed":3,"density":1,"screenDensity":3}
Create 响应 : {"ok":true,"handle":1,"count":123,
"paths":[{"size":19.295,"segs":[11.997,2,11.997,13.04,...]},...]}
Frame 请求 : {"handle":1}
Frame 响应 : {"ok":true,"count":123,"flakes":[541.2,87.3,0.841,0,...]}
Config 请求 : {"handle":1,"minSize":40,"maxSize":80,"density":2,"screenDensity":3,...}(字段可省略)
Config 响应 : {"ok":true,"count":246}
Dispose 请求 : {"handle":1}
Dispose 响应 : {"ok":true}
失败统一 : {"ok":false,"error":"..."}
3.2 为什么是 handle + 每帧状态查询(本适配的核心设计)
条码库(qrose)一次调用出一张 PNG,存储库(KStore)一次调用完成一个事务——它们都是"请求即结果"。动画库不行:60fps 意味着每秒 60 次交互,两种直观方案都走不通:
| 备选方案 | 为什么不选 |
|---|---|
| KN 每帧出一张位图 | 每秒 60 次几百 KB 的编码解码 + PixelMap 分配,内存和 CPU 都不现实。 |
| ArkTS 重写物理 | 两边公式漂移不可避免,适配失去意义(见 1.1)。 |
| handle + 每帧状态查询 | 引擎活在 Kotlin 侧,ArkTS 每帧只要 [x, y, scale, shapeIdx] × N 的平铺浮点数组——123 片雪花约 2KB/帧,同步 C 调用毫秒级,物理完全不过桥。 |
handle 用 HashMap<Int, SnowfallEngine> 管理,SnowfallDispose 释放;flakes 里 x/y 圆整到 1 位小数、scale 圆整到 3 位,进一步压每帧体积。形状几何只在 Create 时传一次(三种雪花 84 笔线段共几百个浮点数),ArkTS 构建 Path2D 后整个生命周期复用。
3.3 物理公式对照(引擎没漂的证明)
上游 Snowfall.kt / internal/*.kt | 引擎 SnowfallEngine.kt | 语义 |
|---|---|---|
SourceAngleRange = 1.45f..1.55f | 同名常量 | 初始下落角约 83°~89°,近垂直带随机水平分量 |
AddableAngleRange = -0.0025f..0.0025f | 同名常量 | 每帧角度随机扰动,雪花左右摇摆 |
Float.times(range) 算子 | 同名 private 算子 | ratio 在区间内线性取值(scale/speed/angle 共用) |
Path.getBounds().size.maxDimension | SnowflakeShape.size(坐标数组求 min/max) | 形状归一化基准尺寸 |
0.0003f / LocalDensity.density × density | 0.0003f / screenDensity × density(桥内换算) | 密度统一到"每平方像素雪花数" |
(canvasArea × density).roundToInt() | updateCount() 同式 | 数量按面积 |
x = Random × w,y = Random × h × -1 | buildSnowflakes() 同式 | 落点随机、从画布上方外进场 |
x += speed × cos(angle);y += speed × sin(angle) | moveDown() 同式 | 每帧位移,speed 单位 px/帧(60fps 语义) |
coerceIn(-flakeSize, w + flakeSize) 等 | 同式 | 边界约束 |
y == h + flakeSize → recycle | 同式(浮点相等依赖 coerceIn 的精确赋值,行为一致) | 触底回收:重随机 scale/speed/angle/x,y 置顶 |
adjustCount 只裁出界并追加 | 同逻辑 | 密度变化不闪烁重生 |
尺寸变按 scaleRatio 重算 / 速度变按 speedRatio 重算 | update() 三类失效判定同上游 SnowfallElement.update | 参数热更新语义 |
三种默认雪花的 84 笔线段坐标(Snowflake1×30 / Snowflake2×32 / Snowflake3×22,24×24 设计网格)逐字转写自上游 Snowflakes.kt,连上游自带的重复线段都原样保留。
3.4 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("SnowfallFrame")
fun snowfallFrame(request: String): String = try {
val json = MiniJson.parse(request)
val engine = engines[json.int("handle", -1)] ?: return """{"ok":false,"error":"unknown handle"}"""
engine.frame()
/* flakes 拼装 */
} catch (t: Throwable) {
"""{"ok":false,"error":"${escape(t.message ?: t.toString())}"}"""
}
非法 JSON、未知 handle、非法区间,全部走同一个 ok:false 通道。桥内 JSON 用手写 MiniJson(扁平对象、引号状态机跳过字符串内逗号),不为此引入 kotlinx.serialization——与 cmp_qrose 审核模式一致。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string(每帧一次)
▼
C++ N-API entry(libsnowfall_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohossnowfall.so)
│ SnowfallCreate / SnowfallFrame / SnowfallConfig / SnowfallDispose
▼
UTF-8 JSON + DisposeString
HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务函数挂到 ArkTS 模块上——libohossnowfall.so 里能 llvm-nm 到全部符号,但 ArkTS 直接 import 只能看到 stdlib 类型。所以 CMake 再包一层 libsnowfall_napi.so 作为真正的 NAPI 表面。
4.2 Kotlin/Native 导出函数
@CName("SnowfallCreate") fun snowfallCreate(request: String): String // 开店 + 形状几何下发
@CName("SnowfallFrame") fun snowfallFrame(request: String): String // 推进一帧 + 全量状态
@CName("SnowfallConfig") fun snowfallConfig(request: String): String // 参数热更 / 画布 resize
@CName("SnowfallDispose") fun snowfallDispose(request: String): String // 释放引擎
| C ABI 符号 | ArkTS 方法 | 覆盖能力 |
|---|---|---|
SnowfallCreate | snowfallCreate(req) | 引擎构造 + 初始数量 + 三种雪花几何 |
SnowfallFrame | snowfallFrame(req) | 上游物理逐帧推进(moveDown / recycle) |
SnowfallConfig | snowfallConfig(req) | 尺寸 / 速度 / 密度热更 + resize(对应上游 update / onRemeasured) |
SnowfallDispose | snowfallDispose(req) | 生命周期管理(aboutToDisappear 调用) |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"snowfallCreate", nullptr, NapiSnowfallCreate, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"snowfallFrame", nullptr, NapiSnowfallFrame, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"snowfallConfig", nullptr, NapiSnowfallConfig, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"snowfallDispose", nullptr, NapiSnowfallDispose, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
四个回调共用一个 CallKn(env, info, fn) 辅助:napi_typeof 校验必须是 string、先量长度再开 std::vector<char> 拷贝(API 26 的 NAPI 头对缓冲区有 const 约束)、调 KN 函数并包装返回。KN 返回的字符串必须经 libohossnowfall_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohossnowfall_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与 4 个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohossnowfall SHARED IMPORTED)
set_target_properties(ohossnowfall PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(snowfall_napi SHARED napi_init.cpp)
target_link_libraries(snowfall_napi PUBLIC
libace_napi.z.so
ohossnowfall)
OHOS_ARCH 为空或 libohossnowfall.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。类型声明 index.d.ts:
export const snowfallCreate: (request: string) => string;
export const snowfallFrame: (request: string) => string;
export const snowfallConfig: (request: string) => string;
export const snowfallDispose: (request: string) => string;
entry/oh-package.json5 以 file:./src/main/cpp/types/libsnowfall_napi 引入本地类型包——这是 ArkTS 能 import { snowfallFrame } from 'libsnowfall_napi.so' 的关键。
第 5 阶段:ArkUI 宿主
5.1 帧循环(等价上游 withFrameNanos)
private startLoop(): void {
try {
this.sync = displaySync.create();
this.sync.on('frame', () => { this.tick(); });
this.sync.start();
} catch (e) {
this.timer = setInterval(() => { this.tick(); }, 16); // displaySync 不可用时的兜底
}
}
private tick(): void {
const now = Date.now();
if (now - this.lastFrameTime < 12) return; // 上游速度按 60fps 的 px/帧定义,节流压回 ~60fps
this.lastFrameTime = now;
if (this.handle === 0) return;
const raw = snowfallFrame(JSON.stringify({ handle: this.handle }));
const parsed = JSON.parse(raw) as FrameOk;
if (parsed.ok && parsed.flakes !== undefined) {
this.flakes = parsed.flakes;
this.redraw(); // 同帧内完成物理拉取与描边
}
}
displaySync 的 vsync 回调在 120Hz 屏上每 8.3ms 一次,12ms 节流把它压回 16.6ms 间隔——与上游 withFrameNanos 的 60fps 假设对齐;90Hz 屏会落到约 45fps(雪花略慢,见 8.2)。fps 用 1 秒滑动窗口统计、每秒刷新一次 @State,避免状态写入本身制造重绘风暴。
5.2 渲染路径(等价上游 withTransform + drawPath)
this.paths = parsed.paths.map((shape: ShapeInfo): Path2D => {
const path = new Path2D();
const segs = shape.segs;
for (let i = 0; i + 3 < segs.length; i += 4) {
path.moveTo(segs[i], segs[i + 1]);
path.lineTo(segs[i + 2], segs[i + 3]);
}
return path; // Create 时构建一次,终身复用
});
private redraw(): void {
const ctx = this.context;
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
ctx.globalAlpha = this.alpha;
ctx.strokeStyle = this.color;
ctx.lineWidth = this.strokeWidth;
for (let i = 0; i + 3 < flakes.length; i += 4) {
const size = this.pathSizes[flakes[i + 3]];
const scale = flakes[i + 2];
ctx.save();
ctx.translate(flakes[i] - size * scale / 2, flakes[i + 1] - size * scale / 2);
ctx.scale(scale, scale); // scale 同时作用于线宽——与上游 withTransform 行为一致
ctx.stroke(this.paths[flakes[i + 3]]);
ctx.restore();
}
}
上游在 withTransform({ scale(scale, offset); translate(...) }) 里 drawPath(path, color, alpha, stroke)——变换作用于几何和描边宽度。ArkTS 侧 translate + scale + stroke 的组合恰好同语义:线宽随雪花缩放,视觉行为对齐。drawPosition 用 Stack 子项 zIndex 切换(Canvas 在 Text 前或后),即时生效、Canvas 实例与引擎都不重建。
5.3 参数分治与单位换算
参数分两类走两条路:渲染参数(color / alpha / strokeWidth / drawPosition)ArkTS 本地生效,下一帧 redraw 自然应用;物理参数(尺寸 / 速度 / 密度范围 + 画布尺寸)过桥 snowfallConfig,由引擎按上游语义热更。单位换算对齐上游 LocalDensity:
this.densityPixels = display.getDefaultDisplaySync().densityPixels; // ≈ LocalDensity.density
// 滑条 dp 值 → px:minSize * this.densityPixels,onAreaChange 的 vp 面积 → px 同乘
画布尺寸取 onReady 的 context.width/height(px)为准,onAreaChange 走 vp × densityPixels 换算,两条路都归一到 px 引擎。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohossnowfall)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName × 4 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.snowfall、versionName 1.0.2-ohos.1
└── entry/
├── build-profile.json5 externalNativeOptions + abiFilters [arm64-v8a, x86_64]
├── libs/{arm64-v8a,x86_64}/ linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
├── src/main/cpp/CMakeLists.txt IMPORTED 引入 KN so、$ORIGIN rpath
├── src/main/cpp/napi_init.cpp N-API 导出、参数检查、DisposeString
├── src/main/cpp/include/libohossnowfall_api.h
├── src/main/cpp/types/libsnowfall_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 逐帧渲染验收页面
6.2 构建、签名和安装
./gradlew :snowfall-compose:compileKotlinJvm
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。
hdc list targets -v
hdc -t <设备序列号> install -r \
entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
-a EntryAbility -b org.terminator.ohos.snowfall -m entry
四、完整代码对照
4.1 整体架构
io.github.skeptick.snowfall.engine/**(新写物理引擎,公式对照上游)
│ SnowfallEngine / Snowflake / SnowflakeShape
│ 密度计数 / 落点随机 / moveDown / recycle / adjustCount / 热更新
example.nativeapp.Bridge.kt + MiniJson
│ SnowfallCreate / SnowfallFrame / SnowfallConfig / SnowfallDispose
libsnowfall_napi.so
│ snowfallCreate / snowfallFrame / snowfallConfig / snowfallDispose
Index.ets
│ displaySync 逐帧 + Path2D 复用 + Canvas 描边
▼
ArkUI 真机页面(单页滚动,全参数面板)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_snowfall、sample/ 移出 |
gradle/libs.versions.toml | 裁剪到 Kotlin 2.2.21-1.0.0 单插件别名 |
build.gradle.kts(根) | 版本 1.0.2-ohos.1、全局禁用 Ohos*Test* 任务 |
snowfall-compose/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,commonMain 零依赖 |
snowfall-compose/src/commonMain/.../engine/SnowfallEngine.kt | 物理引擎(数量 / 位移 / 扰动 / 回收 / 热更新,公式对照上游) |
snowfall-compose/src/commonMain/.../engine/SnowflakeShapes.kt | 三种默认雪花坐标(逐字转写)+ 包围盒归一化 |
snowfall-compose/src/composeMain/ | 上游 5 文件整体隔离(git mv 100% rename,ohos 不编) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 4 个 C ABI、handle 注册表、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 逐帧渲染验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(18 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | SnowfallEngine(shapes) | 构造引擎,默认携带上游三种雪花形状 |
| 引擎 | resize(w, h) / update(size, speed, density) | 对应上游 onRemeasured / SnowfallElement.update |
| 引擎 | frame() | 推进一帧(对应上游 withFrameNanos 回调体) |
| 引擎 | SnowflakeShape.size | 包围盒最大边,等价上游 Path.getBounds().maxDimension |
| Native | SnowfallCreate / Frame / Config / Dispose | C ABI JSON 导出(handle 生命周期) |
| N-API | snowfallCreate(req) 等 4 个 | 向 ArkTS 暴露能力 |
| ArkTS | tick() / redraw() / applyConfig() | 逐帧拉取、描边渲染、物理参数热更 |
4.4 ArkTS 与 Kotlin 的边界
// ArkTS:每帧只传 handle,拿回平铺状态数组
const raw = snowfallFrame(JSON.stringify({ handle: this.handle }));
const parsed = JSON.parse(raw) as FrameOk;
// parsed.flakes = [x, y, scale, shapeIndex, x, y, scale, shapeIndex, ...]
// Kotlin:物理完全在引擎侧,位置圆整后平铺拼装
engine.frame()
buildString {
append("{\"ok\":true,\"count\":").append(snowflakes.size).append(",\"flakes\":[")
for (index in snowflakes.indices) {
val flake = snowflakes[index]
if (index > 0) append(',')
append(trim1(flake.x)).append(',')
append(trim1(flake.y)).append(',')
append(trim(flake.scale)).append(',')
append(flake.shapeIndex)
}
append("]}")
}
两者之间每帧只传输扁平浮点数组字符串,不传输 Kotlin 对象、Path 或位图。
五、关键决策说明
决策 1:物理抽离 + composeMain 隔离,而不是真编译整库
KStore 真编译整库的前提是 kotlinx ohos 制品线齐备、commonMain 本身无 UI 依赖。snowfall-compose 的上游 5 文件 100% import androidx.compose.ui,DrawModifierNode / withFrameNanos / Path 在社区 CMP 1.9.2 工具链上不存在——真编译无从谈起。抽取边界划在"物理模型 / 渲染实现"之间:物理是纯数学,公式逐条对照重写;渲染是 Compose 专属,交 ArkTS Canvas。上游源码不删不改,整体隔离在 composeMain 等 CMP 对齐后接回。
决策 2:桥协议从"一次调用"升级为 handle + 每帧状态查询
社区已审的 qrose(条码)/ Vico(图表)/ KStore(存储)都是"一次调用一次结果"。动画库每秒 60 次交互,这个模式不成立——每帧出位图不现实,ArkTS 重写物理会漂移。handle + 每帧状态查询让引擎常驻 Kotlin 侧,每帧只传约 2KB 的平铺位置数组;形状几何 Create 时传一次、ArkTS 终身复用。这是本仓对社区 CMP 适配模式的新贡献。
决策 3:Kotlin 2.2.21 标准钉法 + Gradle 升 8.14.1
引擎零依赖(纯 stdlib),没有制品线倒推版本的问题,直接用社区标准钉法 2.2.21-1.0.0。Gradle 从上游 8.10.2 升到 8.14.1 是 2.2.21 线的硬要求;工具链经验按线区分——2.4.20 线可留 Gradle 9.x(KStore 先例),2.2.21 线钉 8.14.1(qrose 同款)。
决策 4:ArkTS Canvas 描边渲染,而不是 KN 光栅化出图
qrose 在 KN 里光栅化 PNG,是因为条码出图是一次性的、且 Painter 样式模型复杂。雪花是每帧重画的三种固定线稿——ArkTS Canvas 原生就有 Path2D + stroke 能力,translate + scale + stroke 与上游 withTransform + drawPath 逐语义等价(线宽随缩放一致)。KN 只管物理、ArkTS 只管描边,是这条链路上职责最干净的切法。
决策 5:displaySync 节流压 60fps,保住上游 px/帧语义
上游速度单位是 px/帧、假设 60fps。鸿蒙高端屏 90/120Hz,若按原始 vsync 回调驱动,雪花会快 1.5~2 倍。tick 内 12ms 节流把帧间隔压回约 16.6ms(120Hz 屏隔帧执行),物理语义与 Android 上游一致;代价是 90Hz 屏落到约 45fps(见 8.2)。
六、测试与验证
6.1 测试环境
本次验证使用 Windows x64、JDK 21(Temurin jdk-21.0.12.101-hotspot,org.gradle.java.home 已写进 gradle.properties)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 编译与符号验证
./gradlew :snowfall-compose:compileKotlinJvm # BUILD SUCCESSFUL(约 21 秒)
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# BUILD SUCCESSFUL(合计约 58 秒)
| 产物 | 位置 | 大小 |
|---|---|---|
libohossnowfall.so(arm64) | entry/libs/arm64-v8a/ | 3,715,888 B(约 3.5MB,含 KN 运行时) |
libohossnowfall.so(x86_64) | entry/libs/x86_64/ | 3,411,840 B(约 3.3MB,模拟器用) |
libc++_shared.so ×2 | 各 ABI 目录 | 1,850,184 / 1,731,432 B(随 copyKnSo* 成对回填) |
llvm-nm -D libohossnowfall.so(双 ABI 一致):
T SnowfallCreate
T SnowfallFrame
T SnowfallConfig
T SnowfallDispose
T libohossnowfall_symbols
CAdapter 链接期的 api.cpp return-type 警告为 HarmonyOS Kotlin 生成代码的已知问题(qrose / KStore 同款),.so 正常链接。
6.3 HAP 验证
:snowfall-compose:compileKotlinJvm: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/<abi>/
llvm-nm -D: 5 个符号均为全局 T
BuildNativeWithCmake: finished(双 ABI 编出 libsnowfall_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
6.4 功能用例
- 启动即下雪:黑色预览区三形态雪花持续下落、左右摇摆、触底回收循环;
- 状态行出现
雪花 123 · 60 fps与引擎就绪 handle=1——数量来自引擎面积公式(1080×1188×0.0003/3×1 ≈ 123),与上游公式推算一致; - 七色板即点即变(实测 Cyan:雪花整体变青色,下一帧生效不重建引擎);
alpha0.30 /strokeWidth1.00 滑条实时生效;- 绘制层 Toggle:Ahead 档雪花压在 “Happy New Year!” 之上,Behind 档雪花沉到文字之下,Canvas 与引擎均不重建;
- 尺寸 / 速度 / 密度滑条过桥热更:已有雪花按比例因子平滑变化,密度调大时数量即时增加、只追加新雪花不重排旧雪花;
- fps 稳定接近 60(1 秒滑动窗口统计),全参数拖动不掉帧;
- 退出页面
aboutToDisappear调snowfallDispose,handle 释放、帧循环停止。
6.5 验证结论
物理引擎、Native 动态库、C ABI、N-API、ArkTS 逐帧渲染页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的持续下雪会话:123 片雪花、60 fps、全参数实时热更、绘制层即时切换(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/snowfall-compose.git
cd snowfall-compose
export JAVA_HOME="/path/to/jdk-21"
./gradlew :snowfall-compose:compileKotlinJvm # klib(JVM 交叉验证)
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.snowfall -m entry
hdc shell hilog | grep Snowfall
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:snowfall-compose真正编译出ohosArm64/ohosX64KLIB——哪怕上游没有可直接编译的内核,也要把可抽的模型抽出来对照重写; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- Gradle 版本按工具链线区分:2.2.21 线必须 8.14.1(上游 8.10.2 直接失败),2.4.20 线可留 9.x——上游 wrapper 不能想当然沿用;
- 根构建替换时 buildscript classpath 引用的 catalog 别名(AGP / compose / publish)必须随裁剪一起清掉,否则 sync 阶段就 unresolved;
git mv整目录迁移前目标父目录若已存在,会把源目录嵌套进目标而不是合并——迁移后要核对层级;- HarmonyOS Kotlin 的 CAdapter 不把
@CName业务函数挂到 ArkTS,必须 CMake 再包一层 NAPI so;nm_modname(snowfall_napi)、ArkTS import(libsnowfall_napi.so)、index.d.ts导出名三个名字必须一致,错一个都是"能安装、一点就崩"; - HAP 缺当前 ABI 的 so 报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;模拟器是 x86_64,只编 arm64 就是 9568347; - KN so 的
DT_NEEDED含libc++_shared.so,copyKnSo*必须从~/.konan/dependencies把匹配 triple 的 libc++ 成对拷进entry/libs/<abi>/; - 动画库不能沿用"一次调用出结果"的桥模式:每帧出位图不现实,最终形态是 handle + 每帧状态查询,每帧载荷压到平铺浮点数组(x/y 一位小数、scale 三位);
- 上游速度按 60fps 的 px/帧定义,高刷屏必须节流(tick 内 12ms 间隔),否则雪花在 120Hz 屏上快一倍、与 Android 上游行为漂移;
- ArkTS
Area的尺寸单位是 vp,喂引擎前要乘densityPixels换 px,等价上游LocalDensity;CanvasRenderingContext2D.width本身就是 px,两条路要统一; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free();DevEco 新版模板会自动生成.clang-tidy/.clangd,已审仓规范里没有这两个文件,提交前要清理; - 签名材料由 DevEco 自动写入
build-profile.json5,仓库内保持空数组、product 悬空引用,证书绝不进入 AtomGit。
8.2 已知问题
- 鸿蒙侧没有 Compose Modifier /
DrawModifierNode:上游Modifier.snowfall()实现留在composeMain不编译,渲染由 ArkTS Canvas 描边等价复现,等社区compose.uiohos 制品与 CMP 对齐后接回; - 每帧 JSON 是轻量协议:几百片雪花约 2KB/帧(同步毫秒级),超大画布 / 上千片场景可优化为 FlatBuffer 或共享内存;
- 60fps 固定步长假设(上游同款):120Hz 屏节流后正好 60fps,90Hz 屏落到约 45fps、雪花略慢;改为按时间累积的变步长会偏离上游语义,暂不处理;
- 自定义
snowflakes: List<Path>参数未过桥:引擎只携带上游 3 种默认形状(84 笔线段),自定义形状可在 Create 请求加segs字段支持(见 8.3); - 上游
rememberSaveable的状态持久化未做等价:页面销毁重建后雪花位置重置(数量不变);HAP Demo 无旋转场景,影响有限; - 上游 android / ios / mac / js / wasmJs 目标剥离(源文件留在树里),
sample/留盘不进构建,发布链路一并剥离; - HAP 安装必须使用与 bundle name
org.terminator.ohos.snowfall匹配的签名。
8.3 未来优化方向
- Create 请求增加自定义
segs字段,把上游snowflakes: List<Path>参数的完整能力过桥; - 评估 FlatBuffer / 共享内存替代每帧 JSON,支撑超大画布与千片级密度;
- 帧驱动改为 displaySync 时间戳累积的变步长(同时改引擎为 px/秒语义),摆脱 60fps 假设——需要与上游行为做一次显式分叉决策;
- 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)验收矩阵与
uitest自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 snowfall-compose(5 文件 100% Compose 依赖)
→ 源码依赖度盘点:没有可直接编译的内核
→ 物理模型抽离:公式逐条对照重写为纯 Kotlin(SnowfallEngine + SnowflakeShapes)
→ 上游 Compose 实现整体 git mv 到 composeMain 隔离(100% rename)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.10.2 → 8.14.1)
→ 根构建替换(cmp_qrose 模式)
→ ohosArm64 / ohosX64 KLIB + 双 ABI sharedLib
→ C ABI(@CName × 4,handle + 每帧状态查询)
→ C++ N-API(DisposeString 纪律)
→ ArkTS displaySync 节流 60fps + Path2D 复用 + Canvas 描边
→ ArkUI 真机单页验收(123 片 · 60 fps · 全参数热更)
9.2 封装层次
- 引擎层:
SnowfallEngine——密度计数、落点随机、位移、角度扰动、边界约束、触底回收、三类参数热更新(公式对照上游); - 数据层:
SnowflakeShape——三种默认雪花的线段坐标与包围盒归一化尺寸; - Native 层:生成双 ABI 动态库并通过 4 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:帧循环节流、Path2D 生命周期管理、描边渲染、参数分治与状态面板;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 适配路线由库的依赖形态决定:KStore 的 commonMain 无 UI 依赖且制品线齐备,正确答案是真编译整库;snowfall-compose 全量依赖
compose.ui,正确答案是物理抽离 + composeMain 隔离。不存在万能模板,先做源码依赖度盘点再定路线; - 动画库的桥协议需要重新设计:"一次调用出结果"对 60fps 连续渲染不成立,handle + 每帧状态查询把物理留在 Kotlin 侧、每帧只传几 KB 平铺数组——这条模式可以复用到任何逐帧驱动的 CMP 库(涟漪、雨滴、烟花);
- "模型没漂"要拿数据证明:雪花数量 123 片是面积公式(1080×1188×0.0003/3)的推算值,fps 60 是 60fps 语义的落地值——真机状态行的数字与上游公式互相印证,比"看起来在下雪"更有说服力。
9.4 适配成果
当前适配已完成:
- snowfall-compose
1.0.2(Apache-2.0)物理引擎的双 ABI Kotlin/Native 编译(2 个新引擎源文件,公式逐条对照上游,上游源码 100% rename 隔离保留); - 4 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号,双 ABIlibohossnowfall.so约 3.5MB / 3.3MB); - handle + 每帧状态查询桥协议(对社区 CMP 适配模式的补充);
- DevEco Stage ArkUI 逐帧渲染验收页面(三形态雪花、7 色板、9 个实时控件、绘制层切换、fps / 数量状态行);
- HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证(雪花 123 片 · 60 fps);
- AtomGit 项目文档、OpenHarmony README(中英双份)、全流程文章与真机效果图。
适配过程 FAQ
以下问题来自
ohosArm64目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。
Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?
- 确认 DevEco Studio 版本与工程
build-profile.json5的compatibleSdkVersion一致; - 在
File > Settings > Build, Execution, Deployment > Ohpm配置可用的 ohpm 仓库镜像或代理; - 删除
oh_modules、oh-package-lock.json5与.hvigor缓存后重新 Sync; - 公司网络需要代理时,同时给 ohpm 与 hvigor 配置
http.proxy。
Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?
- 先在
example目录执行./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用linkDebugSharedOhosX64); - 确认
libxxx.so与libc++_shared.so已复制到ohos/entry/libs/<abi>/; - 检查
entry/build-profile.json5的abiFilters与设备 ABI 是否一致(真机arm64-v8a,模拟器x86_64); - 清理
entry/.cxx与entry/build后重试。
Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?
- 确认 CMake 的
add_library(<target> SHARED ...)名称与import ... from 'libxxx_napi.so'完全一致; - 解包 HAP,确认
libs/<abi>/libxxx_napi.so已打包; - 删除
entry/build、entry/.cxx与oh_modules后重新构建; - 检查
module.json5的srcEntry与页面路径是否正确。
Q4:真机安装报 signature verification failed 或 install failed?
- 在 DevEco Studio 的
Signing Configs配置证书、Profile 与密码; bundleName必须与签名 profile 中的 bundle name 一致;- 设备需加入 profile 的
deviceIds,调试证书有效期需覆盖当前时间; - 用
hdc list targets确认设备在线,再执行hdc install -r xxx-signed.hap。
Q5:应用启动白屏、闪退或 NAPI 调用崩溃?
- 用
hdc hilog | grep -i "napi\|kotlin"查看原生日志; - 确认
libc++_shared.so与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致; - 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用
XxxFree释放; - 在 DevEco 的 Native 调试配置中附加
lldb,定位空指针与越界访问。
Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?
- 使用鸿蒙发行版 Kotlin(如
2.2.21-1.0.0),并在settings.gradle.kts配置 eazytec 仓库; - Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
- 确认
kotlin { ohosArm64() }已应用,且local.properties的 SDK 路径有效; - 清理
~/.gradle/caches中对应版本的 Kotlin/Native 缓存后重试。
Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?
- 对齐 DevEco Studio、HarmonyOS SDK 与
build-profile.json5中的compatibleSdkVersion; - 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免
modelVersion与 IDE 不匹配; - 命令行构建时设置
DEVECO_SDK_HOME指向 DevEco 的sdk目录。
Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?
- 使用 DevEco 自带的 node 与 hvigor:
"$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...; - 设置
JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME; - 加上
--no-daemon排除守护进程缓存影响,并清理entry/build与.hvigor。
参考文档
更多推荐



所有评论(0)