本文记录 lightsource-compose 接入 OpenHarmony 的完整过程,覆盖纯 Compose UI 库的内核识别、鸿蒙 Kotlin 工具链钉版、真实度量内核编译、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

lightsource-compose 是 Lucas Krug 的 Compose Multiplatform 交互式光照效果库:给界面挂一个可拖动的点光源,让"感光"元素的方向阴影、光面高亮描边、文字阴影与辉光随光源位置实时变化,上游全部构建在 Compose 1.11 的 Modifier.dropShadow + androidx.compose.ui.graphics.shadow.Shadow 之上。本次适配与 KStore 的"真编译整库"路线不同,回到社区早期路线(cmp_qrose 的"内核提取"):上游是纯 Compose UI 库,没有可以整体编译的引擎——但所有视觉效果的数字都来自一个 internal 的度量函数 calculateLightMetrics(方向、偏移、模糊、透明度,26 行纯算术)。本次适配把它以纯 Kotlin 形式下沉 commonMain 成为公共 API LightSourceMetrics,Compose 实现原样隔离 src/composeMain 不参与 ohos 编译,ArkUI 页面作为视觉复现宿主,每一个阴影数字都来自真实编译进 .so 的度量内核,JVM 单测交叉验证证明没有漂移。

项目地址: AtomGit/oh-tpc/lightsource-compose

上游仓库: lucas-krug/lightsource-compose

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

lightsource-compose 为 Compose Multiplatform 应用提供"点光源 ↔ 界面"这一件事的完整答案:LightScene 包住内容并绘制辉光与光源标记,rememberStaticLightSource / rememberStatefulLightSource / rememberAnimatedLightSource 提供固定、可拖、动画三种光源,Modifier.lightShadow 给任意形状元素加方向阴影和光面高亮描边,LightText 把文字阴影换成背光方向的方向字形阴影,Modifier.dragLightSource 把拖拽手势接进光源状态。驱动这一切的是同一个纯函数:给定光源位置、元素中心、elevation、maxOffset、lightRadius,算出方向单位向量、阴影偏移、模糊半径、阴影透明度与高亮透明度。KMP 项目把这些能力放在 commonMain,由 Android、iOS 共享。OpenHarmony 应用同样需要这套光照能力,但上游构建(Kotlin 2.4.0 / Compose Multiplatform 1.11.1 / AGP 8.13.2)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以画出卡片,却无法证明上游的度量数学在 OpenHarmony ARM64 设备上完成编译、链接和执行,也无法保证鸿蒙侧算出的阴影方向、模糊档数与 Android/iOS 一致。适配需要同时验证度量内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.4.0 / CMP 1.11.1 / AGP 8.13.2(com.android.kotlin.multiplatform.library)与鸿蒙工具链无法共存于一个 Gradle 构建;必须整体替换根构建。
UI 内核粘连全库唯一实现文件 LightScene.kt(611 行)把 Composable、Modifier、状态类和度量数学装在一个文件里,internal 计算函数签名带 Offset / Size,模块外的桥够不着。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
视觉语义跨不了桥辉光、渐变描边是 Compose 绘制语义,C ABI 只能同步一来一回传数字;视觉层必须在 ArkUI 侧按其惯用法复现。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:度量内核的纯 Kotlin 化下沉与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。度量语义完全由逐行同源的内核负责,ArkUI 只负责调用 Native、解析 JSON 和按上游模型复现视觉。

1.2 库提供的能力

Demo 提供一个真实的点光源会话,对应桥接的全部入口与上游样式参数:

  • 拖动光源:PanGesture 更新 @State 光源位置,对应上游 rememberLightSourceState + Modifier.dragLightSource 的同一语义;
  • 方向阴影:对三张不同 elevation 的卡片逐个调用 lightSourceCalc,返回的 offsetX / offsetY / blurPx / shadowAlpha 直接映射到 ArkUI .shadow();
  • 高亮描边:highlightAlpha 驱动 .border() 颜色透明度,近似上游光面渐变描边的背光侧亮度;
  • 辉光:radialGradient 三停渐变(0.32×强度 → 0.09×强度 → 0),中心与半径来自光源,对应上游 LightGlow;
  • 光源标记:外圈 34vp 16% 光圈 + 内圈 10vp 实心圆,与上游 LightMarker 的 34.dp / 10.dp 一致;
  • 文字阴影:方向与卡片一致、按比例缩放的 Text.shadow(),对应上游 LightText(内部 maxOffset 固定 24 的比例关系);
  • 参数调节:亮度(0…1)/ 光半径(100…800)/ 影子长度(0…2)三个滑条——影子长度拖到 0 即验证上游 shadowLength = 0 关闭方向偏移的语义;
  • 状态行:显示最近一次内核返回的真实数字(如 计算成功 cardA off=(-1.8, -15.7) blur=14.3 a=0.30 d=279),来自 Native 调用而非静态文字。

响应包含:

字段作用
ok计算是否成功,失败时 error 带原文;NaN / Infinity 请求直接拒绝。
directionX / directionY从光源指向元素的单位向量(高亮画在背光侧)。
offsetX / offsetY方向阴影偏移(已按 shadowLength 与 maxOffset 截断)。
blurPx / shadowAlpha阴影模糊半径与透明度(量化档位随距离稳定)。
highlightAlpha光面高亮强度(强度 × 距离衰减,上限 0.30)。
distancePx光源到元素中心距离(对拍与调试用)。

度量侧完整语义(8 档距离量化、maxOffset 截断链、强度钳制、shadowLength=0 语义)全部在上游原版数学里逐行保留,JVM 侧保留上游 fixture 平移的 2 项单测交叉验证,证明 ohos .so 里执行的度量逻辑没有漂移。

1.3 实现适配

维度要求
代码复用度量数学逐行同源下沉(量化步长 / 截断链 / 系数一字不改);Compose 实现 0 行改动原样存档 composeMain(git 识别为 100% rename)。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 androidApp / iosApp / shared 留盘不进 settings.gradle.kts。
桥接稳定1 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI,非有穷数直接拒绝。
依赖纪律内核零第三方依赖——只用 kotlin-stdlib;桥内 JSON 手写 MiniJson,不引 kotlinx-serialization 及其版本对齐问题。
UI 完整单页覆盖全部桥接入口与上游样式参数:三卡片(不同 elevation)、方向文字、辉光、标记、三滑条、双开关、重置。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游是纯 Compose UI 库,当前社区 CMP 工具链没有 Compose 1.11 dropShadow / Shadow 的 ohos 制品先例;ArkTS Demo 是视觉复现宿主,所有阴影数字来自真实编译进 .so 的度量内核。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点内核与 UI 边界、确认纯 UI 库的可提取面、确定交付物
第 2 阶段:目标与构建打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:内核下沉       ── calculateLightMetrics 纯 Kotlin 化、LightSourceMetrics 公共入口、单测平移
第 4 阶段:桥协议与原生桥接 ── 度量 JSON 契约、@CName C ABI、C++ N-API、DisposeString 纪律
第 5 阶段:ArkUI 宿主     ── 拖动光源、辉光、三卡片方向阴影、高亮描边、文字阴影、参数面板
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行、数字对拍和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
lightsource-compose/                   上游库模块(度量内核进 ohos 构建,jvm() + ohosArm64 + ohosX64)
lightsource-compose/src/commonMain/    ExperimentalLightSourceApi + LightMetrics.kt(纯内核,本 fork 新增)
lightsource-compose/src/commonTest/    LightMetricsTest(上游 fixture 平移,参数一字未改)
lightsource-compose/src/composeMain/   上游 LightScene.kt 原样存档(611 行,0 行改动,不参与编译)
example/nativeApp/                     Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                    DevEco Stage、CMake N-API 和 ArkTS 页面
androidApp/ iosApp/ shared/            上游 demo 工程,留盘不进 settings
README.OpenHarmony.md / _CN.md         鸿蒙适配说明(中英双份)
README.upstream.md                     上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :lightsource-compose 与 :example:nativeApp,rootProject.name = "ohos_lightsource"。上游的 CMP 1.11.1 / AGP 8.13.2 / vanniktech 发布插件全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 指向本机安装路径)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 8.14.5 降一档,与已审核工具链线一致)
Compose / CMP不引入——composeMain 隔离,构建里零 Compose 依赖
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.lightsource
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点

纯 UI 库的盘点问题和引擎库不同:不是"哪些模块能整体编译",而是"库的数字从哪来、能不能脱离 Compose 类型"。逐文件结论:

文件内容依赖形态处置
LightScene.kt611 行,库全部实现Compose runtime / ui / foundation / material3composeMain 存档不编译
ExperimentalLightSourceApi.kt@RequiresOptIn(Level.ERROR) 注解纯 KotlincommonMain 原样保留
calculateLightMetrics / quantizeShadowDistance26 行度量算术仅 Offset / Size 参与,纯计算纯 Kotlin 化下沉 commonMain
LightMetricsTest.kt2 条量化行为 fixtureCompose 几何类型平移到纯 API,参数一字未改

expect / actual 声明全库 0 处,上游唯一的"平台依赖"就是 Compose 本身——所以隔离一行 Compose 类型,内核就完整了。度量函数的消费方式也确认过:lightShadow 与 LightText 只消费中心点与四个标量输出,桥协议传元素中心即可,ElementBounds 不必下沉,上游"零尺寸守卫"(元素未测量时返回默认值)由 ArkTS 侧保证(传的总是已布局元素的实测尺寸)。

1.4 页面边界
标题区          LIGHTSOURCE · OPENHARMONY / "拖动空白处移动光源"提示行
状态行          最近一次内核返回(计算成功 cardA off=(x,y) blur=… a=… d=…)
场景区          辉光层(radialGradient)/ 三张卡片(方向阴影 + 高亮描边)/ 方向文字阴影 / 光源双圆标记
控制区          辉光开关 / 光源标记开关 / 重置光源按钮
滑条区          亮度 0..1 / 光半径 100..800 / 影子长度 0..2

页面 aboutToAppear 即对三个元素各调一次 lightSourceCalc;拖动、调参、开关全部实时重算。

第 2 阶段:目标与构建打通

2.1 内核识别(本适配的成败手)

引擎类库(KStore、qrose 的编码器)内核和 UI 天然分文件,git mv 就完成隔离;lightsource 不一样——611 行一个文件全部装完。第一步必须先证明"可提取内核存在且值得提取":

  • 上游所有视觉(卡片阴影、描边亮度、文字阴影)的数字都来自 internal fun calculateLightMetrics(...),函数体只有方向、量化、衰减、截断的纯算术;
  • 签名带 Offset / Size 是唯一障碍,桥协议只需要元素中心坐标,Float 参数即可等价替换;
  • 在 ArkTS 里手抄这 26 行是最差选项:8 档量化步长、截断链、系数是上游行为的契约,抄一遍就多一份漂移面。

这一步成立,cmp_qrose 的"内核提取"模式才在这个纯 UI 库上成立。

2.2 工具链钉版(为什么是 2.2.21-1.0.0)

社区已审核的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(cmp_qrose / number-flow / flag-bar 等一批库用的)。上游 Kotlin 2.4.0 + CMP 1.11.1 与鸿蒙工具链无法共存于一个构建;dropShadow / Shadow 这类 Compose 1.9+ API 在社区 CMP 制品线(1.9.2 线 / 1.12.0-jk 线)上没有审核过的可用性先例,不能拿配额去赌。库模块因此收敛为:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()              // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

两个工具链细节:其一,Gradle wrapper 从上游 8.14.5 钉到 8.14.1(与已审核线一致);其二,上游 gradle/gradle-daemon-jvm.properties 必须删除——daemon JVM 判据指向本机不存在的 Corretto,且它的优先级高于 org.gradle.java.home,不删则 gradle.properties 里的 JDK 21 配置被架空。

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()
    }
}

rootProject.name = "ohos_lightsource"

include(":lightsource-compose")
include(":example:nativeApp")

根 build.gradle.kts 全局禁用 ohos 测试任务(ohos 测试任务在这套工具链上不可跑,测试统一 JVM 跑):

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}
2.3 Compose 实现隔离

LightScene.kt 原样 git mv 到 lightsource-compose/src/composeMain/(0 行改动,git 识别为 100% rename)。composeMain 不是 Kotlin 源集——build 脚本没有注册它,KMP 插件只认已注册源集目录,所以它对所有目标(包括 ohos 和 jvm)都不编译。留下来是为了对照上游 API,也是将来社区 CMP 工具链对齐后接回 commonMain 的素材。实测:

./gradlew :lightsource-compose:compileKotlinJvm        # BUILD SUCCESSFUL
./gradlew :lightsource-compose:compileKotlinOhosArm64  # BUILD SUCCESSFUL
./gradlew :lightsource-compose:compileKotlinOhosX64    # BUILD SUCCESSFUL

ohos 编译器看不到任何 androidx.compose——隔离在编译期得到证明。

第 3 阶段:内核下沉

3.1 纯 Kotlin 化的边界

新增 commonMain/LightMetrics.kt,公共入口 LightSourceMetrics.calculate(...),参数全部 Float:

上游成员去向
calculateLightMetrics(internal,Offset/Size 签名)LightSourceMetrics.calculate(public,纯 Float 签名)
LightMetrics(internal,Offset 字段)LightMetricsResult(directionX/Y、shadowOffsetX/Y、blurPx、shadowAlpha、highlightAlpha)
quantizeShadowDistance / ShadowDistanceStepsPerRadius同文件 private,逐行照抄
ElementBounds(Offset/Size)不下沉——桥协议直接传元素中心
LightSource / LightSourceState / Modifier.* / LightScene / LightText随 LightScene.kt 进 composeMain 存档

镜像结果类型刻意改名 LightMetricsResult:将来社区 CMP 对齐、composeMain 接回 commonMain 时,两边不会撞类名,镜像内核可以整体删除,上游文件零改动恢复编译。

3.2 量化与截断链逐行保留

两处最容易被"顺手优化"掉的行为必须原样保留:

  • 8 档量化:quantizeShadowDistance 把归一化距离按每半径 8 档取整,让远处元素的模糊 / 透明度呈台阶稳定、方向偏移保持平滑。上游单测断言全距离域只有 13 档模糊值,这条 fixture 被原样平移,等于用 JVM 给鸿蒙数字背书;
  • 截断链:offsetLength = min(elevation × (0.75 + nd×0.9) × shadowLength, maxOffset),shadowLength = 0 完全关闭方向偏移、maxOffset 兜底、intensity 钳制 0…1——全部照抄。
3.3 单测平移

commonTest/LightMetricsTest.kt 两条用例的 fixture 参数与上游一字未改(元素中心 (50,50)、光源 (-450,50) / (-451,51)、elevation 20、maxOffset 80、lightRadius 1000),只把构造调用换成纯 API——这是"平移而非重写"的直接证据。

第 4 阶段:桥协议与原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(liblightsource_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoslightsource.so)
  │ LightSourceCalc
  ▼
LightSourceMetrics.calculate + UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制度量语义,所以选择 C ABI + JSON。

4.2 JSON 契约
请求 : {"lightX":320,"lightY":210,"intensity":0.9,"shadowLength":1.0,
        "elementCX":119,"elementCY":178,
        "elevationPx":12,"maxOffsetPx":36,"lightRadiusPx":420}
响应 : {"ok":true,"directionX":-0.64,"directionY":-0.77,
        "offsetX":-60.9,"offsetY":-73.1,
        "blurPx":36.6,"shadowAlpha":0.37,"highlightAlpha":0.26,"distancePx":812.0}
        {"ok":false,"error":"..."}

单位量纲无关:坐标、elevation、maxOffset、radius 用同一单位即可。上游 Compose 用密度换算后的 px,demo 用 vp(相当于密度 1),滑条值直接对应上游的 dp 默认值。字段名里的 Px 只表示"已换算的成长度数值",不强制 px。请求字段必须是有穷数,NaN / Infinity 被桥拒绝。

4.3 Kotlin/Native 导出函数
@OptIn(ExperimentalLightSourceApi::class)
@CName("LightSourceCalc")
fun lightSourceCalc(request: String): String = try {
    /* MiniJson 解析 → LightSourceMetrics.calculate → 扁平 JSON 响应 */
} catch (t: Throwable) {
    "{\"ok\":false,\"error\":${jsonQuote(t.message ?: t.toString())}}"
}
C ABI 符号ArkTS 方法覆盖内核
LightSourceCalclightSourceCalc(req)LightSourceMetrics.calculate 全部度量输出

每个导出整体 try/catch,Kotlin 异常永远不跨 FFI;非法 JSON、缺字段、非有穷数、不支持的字段值,全部走同一个 ok:false 通道,ArkTS 侧统一渲染。桥内 JSON 手写 MiniJson(扁平键值、引号状态机),不引 kotlinx-serialization——为 9 个数值字段拉一个序列化运行时还要和 HarmonyOS Kotlin 插件做版本对齐,不值得。

4.4 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"lightSourceCalc", nullptr, NapiLightSourceCalc, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoslightsource_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoslightsource_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。

4.5 CMake 和类型声明
add_library(ohoslightsource SHARED IMPORTED)
set_target_properties(ohoslightsource PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(lightsource_napi SHARED napi_init.cpp)
target_link_libraries(lightsource_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoslightsource)

OHOS_ARCH 为空或 libohoslightsource.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);IMPORTED_NO_SONAME + $ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见,且 KN so 的 NEEDED 不会出现绝对路径。copyKnSo* Gradle 任务在 linkDebugShared* 之后自动把双 ABI 三件套回填 entry/libs/<abi>/。

export const lightSourceCalc: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 驱动方式
private recompute(): void {
  const results: MetricsResult[] = [];
  for (const el of this.elements) {
    const request: MetricsRequest = { /* 光源 + 元素中心 + 三参数 */ };
    let res: MetricsResult = { ok: false, error: 'no response' };
    try {
      res = JSON.parse(lightSourceCalc(JSON.stringify(request))) as MetricsResult;
    } catch (err) { /* 收进状态行 */ }
    results.push(res);
  }
  this.metrics = results;   // @State 刷新 → 阴影/描边/状态行全部更新
}

lightSourceCalc 是同步纯算术调用,微秒级;拖动帧里对三个元素各调一次没有压力。请求体必须用显式声明的 MetricsRequest 接口——ArkTS 严格模式(arkts-no-untyped-obj-literals)拒绝 JSON.stringify({...} as Record<string, number>) 这种无类型字面量。

5.2 页面交互

场景区自下而上:辉光层(radialGradient,三停 [0.32i, 0.09i, 0],中心/半径来自光源)、三张卡片(.shadow() 半径/偏移/透明度全部来自桥 + .border() 颜色透明度来自 highlightAlpha)、方向文字阴影(Text.shadow(),系数按上游 LightText 内部 maxOffset=24 对主场景 36 的比例关系取 0.4)、光源双圆标记(Column + borderRadius 实现,API 18 全兼容)。PanGesture 只挂场景 Stack——控制面板放在 Stack 外层,滑条拖动不会误移光源。底部控制区:辉光 / 光源标记双开关、重置光源按钮、亮度 / 光半径 / 影子长度三滑条,任一变化即 recompute()。

5.3 视觉近似边界

上游光面高亮是沿轮廓的线性渐变描边(亮侧到中心渐隐),ArkUI .border() 只有纯色,demo 用 highlightAlpha 驱动边框透明度近似亮侧强度;辉光的 SRGB 合成细节与 Skia 不逐像素一致。这是有意的取舍:数字是内核的、画法是 ArkUI 的,近似边界在文档里写明,不冒充逐像素一致。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoslightsource)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   签名由 DevEco 自动生成回填
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.lightsource、versionName 0.1.0-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/libohoslightsource_api.h
        ├── src/main/cpp/types/liblightsource_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :lightsource-compose:jvmTest                                        # 2 项交叉验证
./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 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 勾选自动签名后由 IDE 回填完整配置)。

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.lightsource -m entry

四、完整代码对照

4.1 整体架构

com.lklabs.lightsource(上游包名保留)
    │ ExperimentalLightSourceApi(原样)
    │ LightSourceMetrics.calculate / LightMetricsResult(度量内核,纯 Kotlin,逐行同源)
lightsource-compose/src/composeMain/**(上游 LightScene.kt 原样存档,不编译)
    │ LightScene / lightShadow / LightText / dragLightSource(等社区 CMP 对齐后接回)
Bridge.kt + MiniJson
    │ LightSourceCalc
liblightsource_napi.so
    │ lightSourceCalc
Index.ets
    │ JSON parse + @State 更新
    ▼
ArkUI 真机页面(辉光 / 三卡片方向阴影 / 高亮描边 / 文字阴影 / 光源标记)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_lightsource、上游 demo 工程移出
gradle/libs.versions.toml只留 org.jetbrains.kotlin.multiplatform 2.2.21-1.0.0
build.gradle.kts(根)版本 0.1.0-ohos.1、全局禁用 Ohos*Test* 任务
lightsource-compose/build.gradle.kts纯 KMP:jvm() + ohosArm64 + ohosX64,零 Compose 依赖
lightsource-compose/src/commonMain/.../LightMetrics.kt度量内核下沉(LightSourceMetrics / LightMetricsResult,本 fork 新增)
lightsource-compose/src/commonTest/.../LightMetricsTest.kt上游 fixture 平移(参数一字未改)
lightsource-compose/src/composeMain/.../LightScene.kt上游实现原样存档(git 100% rename,不编译)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.ktC ABI、MiniJson、错误通道、非有穷数拒绝
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档

4.3 关键 API 对照

层次API作用
上游(Compose)LightScene(lightSource, drawGlow, drawMarker)场景容器 + 辉光 + 标记(composeMain 存档)
上游(Compose)Modifier.lightShadow(shape, elevation, maxOffset, highlight)方向阴影 + 高亮描边(composeMain 存档)
上游(Compose)LightText(elevation)方向文字阴影(composeMain 存档)
内核LightSourceMetrics.calculate(...)方向 / 偏移 / 模糊 / 双 alpha,纯 Kotlin 公共 API
内核quantizeShadowDistance(8 档)距离量化,影子参数台阶稳定
NativeLightSourceCalcC ABI JSON 导出
N-APIlightSourceCalc(req)向 ArkTS 暴露能力
ArkTSrecompute()三元素逐个触发并刷新 UI
ArkUI.shadow() / .border() / radialGradient数字消费者(视觉复现层)

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(lightSourceCalc(JSON.stringify({
  lightX: this.lightX, lightY: this.lightY,
  intensity: this.intensity, shadowLength: this.shadowLength,
  elementCX: el.x + el.w / 2, elementCY: el.y + el.h / 2,
  elevationPx: el.elevationVp, maxOffsetPx: el.maxOffsetVp,
  lightRadiusPx: this.radiusVp
})));
// res.ok === true 时阴影参数全部就位:offsetX / offsetY / blurPx / shadowAlpha / highlightAlpha
@OptIn(ExperimentalLightSourceApi::class)
@CName("LightSourceCalc")
fun lightSourceCalc(request: String): String {
    val json = MiniJson.parse(request)
    val m = LightSourceMetrics.calculate(
        lightX = json.float("lightX", 0f), lightY = json.float("lightY", 0f),
        intensity = json.float("intensity", 0.9f),
        shadowLength = json.float("shadowLength", 1f),
        elementCenterX = json.float("elementCX", 0f),
        elementCenterY = json.float("elementCY", 0f),
        elevationPx = json.float("elevationPx", 12f),
        maxOffsetPx = json.float("maxOffsetPx", 36f),
        lightRadiusPx = json.float("lightRadiusPx", 420f),
    )
    /* 扁平 JSON 响应 */
}

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:提取度量内核,而不是等社区 CMP 或在 ArkTS 手抄

上游 dropShadow / Shadow 在社区 CMP 制品线上没有审核过的可用性先例,"等"等于不交付;在 ArkTS 手抄 26 行度量更差——8 档量化步长、截断链、系数是上游行为的契约,抄一遍多一份漂移面,而且和 Android/iOS 对不上数。正确答案是把数字内核真编译进 ohos 动态库,视觉交给 ArkUI 复现。

决策 2:工具链钉 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1

沿社区已审核线(cmp_qrose / number-flow 同款):eazytec 双仓置于 pluginManagement / dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64。上游 gradle-daemon-jvm.properties 必须删除——daemon JVM 判据优先级高于 org.gradle.java.home,不删则 JDK 21 配置被架空。

决策 3:根构建替换(cmp_qrose 模式),上游 demo 工程留盘

上游根构建(CMP 插件 / AGP / vanniktech 发布)对鸿蒙没有保留价值,整体替换为极简 ohos 构建。androidApp / iosApp / shared 引用的 alias 已被剔除,留盘不进 settings.gradle.kts,文档明确警告不要拿它们 sync——它们的存在方式只有这一种。

决策 4:镜像类型改名 LightMetricsResult,composeMain 零改动

内核镜像类型不沿用上游 LightMetrics 的名字,将来社区 CMP 对齐、composeMain 接回 commonMain 时两边不撞类名,镜像可整体删除、上游文件零改动恢复编译。LightScene.kt 用 git mv 纯移动(100% rename),上游历史可追溯。

决策 5:JVM fixture 平移 + 状态行真机对拍

内核逻辑在 commonMain,JVM 与 ohos 跑同一份代码;上游 2 条 fixture 参数一字不改平移到纯 API,是"内核没漂"的闸门。demo 状态行显示内核返回的真实数字(off / blur / a / d),真机上可与 JVM 打表逐位对拍——数字层验收不需要任何额外工具。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(win32)、JDK 21(Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :lightsource-compose:jvmTest

2 项用例全部通过(上游 fixture 平移,参数一字未改):

测试类用例数钉住什么
LightMetricsTest2近距扰动下 blur / shadowAlpha / highlightAlpha 台阶稳定而方向偏移保持平滑;全距离域模糊恰 13 档(8 档量化的直接推论)

内核零第三方依赖、量化与截断链逐行同源,上游 fixture 照跑全绿——这正是"数字没漂"的直接证据。

6.3 HAP 验证

:lightsource-compose:compileKotlinJvm + jvmTest: BUILD SUCCESSFUL
:example:nativeApp:linkDebugSharedOhosArm64: BUILD SUCCESSFUL(copyKnSoArm64 回填三件套)
:example:nativeApp:linkDebugSharedOhosX64:   BUILD SUCCESSFUL(copyKnSoX64 回填三件套)
llvm-nm -D: LightSourceCalc 为全局 T 符号
llvm-readelf -d: NEEDED 全部为 ohos 短名,无绝对路径
BuildNativeWithCmake: finished(双 ABI 编出 liblightsource_napi.so)
CompileArkTS: finished(修复 arkts-no-untyped-obj-literals 后)
PackageHap: finished
install bundle successfully
start ability successfully

libohoslightsource.so debugShared 双 ABI 分别约 3.6 MB(arm64)/ 3.3 MB(x86_64),含 Kotlin/Native 运行时,各配同 triple libc++_shared.so(约 1.85 / 1.73 MB);liblightsource_napi.so 为 NAPI 包装层,体积小。

6.4 功能用例

  1. 启动即出场景:三张卡片带方向阴影与高亮描边、辉光与光源标记可见,状态行显示 计算成功 cardA off=(-1.8, -15.7) blur=14.3 a=0.30 d=279——来自真实 Native 调用而非静态文字;
  2. 拖动光源,三张卡片阴影方向实时背离光源、高亮描边常亮背光侧,状态行数字随位置连续变化;
  3. 影子长度滑条拖到 0,方向偏移完全消失(上游 shadowLength = 0 语义在真机上成立);
  4. 亮度滑条调低,阴影与辉光同步变淡(intensity 钳制 0…1);
  5. 关辉光 / 关标记,只有对应图层消失,阴影数字不受影响;
  6. 光半径滑条改变距离衰减,近处卡片影子短而深、远处卡片影子长而浅,档位呈台阶稳定(8 档量化);
  7. 重置光源回到初始位置,状态行数字与启动时一致;
  8. 每一步的数字可与 JVM 打表对拍(同输入同输出)。

6.5 验证结论

度量内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的光照会话:拖动光源、方向阴影、高亮描边、文字阴影、辉光、标记与参数调节闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/lightsource-compose.git
cd lightsource-compose
export JAVA_HOME="/path/to/jdk-21"
./gradlew :lightsource-compose:jvmTest                                          # 2 项交叉验证
./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.lightsource -m entry
hdc shell hilog | grep LightSource

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让度量内核真正编译成 ohosArm64 / ohosX64 动态库,页面数字才有出处;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 纯 UI 库的内核和 UI 装在同一个文件里(LightScene.kt 611 行),先做"数字从哪来"的盘点再动手——内核识别错了,后面全返工;
  4. Kotlin/Native stdlib 没有 JVM 的 String.format:桥内 \uXXXX 转义照抄 JVM 惯用法即在 KN 编译期报 Unresolved reference 'format',改用 toString(16).padStart(4, '0');
  5. 上游 gradle-daemon-jvm.properties 的优先级高于 org.gradle.java.home,不删除则守护进程被钉回上游判据,gradle.properties 里的 JDK 21 形同虚设;
  6. ArkTS 严格模式 arkts-no-untyped-obj-literals:JSON.stringify({...} as Record<string, number>) 编不过,请求体必须显式声明 interface;ArkTS 编译器一次只报一处,改完可能连环报,逐个消;
  7. Circle.fill 标注为 SDK 26 API,兼容 SDK 5.1.0(18) 编译出 WARN——光源标记改 Column + borderRadius + backgroundColor,视觉效果相同且 API 18 全兼容;
  8. 手势挂最外层会让滑条拖动也移动光源——PanGesture 只挂场景 Stack,控制面板放在外层 Column;
  9. CAdapter 不会把 @CName 业务符号挂到 ArkTS:直接 import KN so 看不到 lightSourceCalc,必须 CMake 再包一层 liblightsource_napi.so;
  10. 上游 androidApp / iosApp / shared 引用的 alias 已被剔除,不进 settings.gradle.kts,不要拿它们 sync;
  11. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  12. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();so 的 NEEDED 若出现 Windows 盘符,是 RUNPATH 被写进绝对路径——IMPORTED_NO_SONAME + $ORIGIN。

8.2 已知问题

  • Compose 实现不跑在鸿蒙上:composeMain 存档,等社区 CMP 的 ohos 制品覆盖 dropShadow / Shadow 后接回,届时 ArkUI 复现层降级为 fallback;
  • 视觉层是 ArkUI 近似:光面渐变描边以纯色边框近似、辉光的 SRGB 合成与 Skia 不逐像素一致、文字阴影按上游比例关系(maxOffset 24 对 36)取 0.4 系数——数字是内核的,画法是近似的;
  • 上游 lightShadow 的任意 Shape(路径形状)在 ArkUI 无对应物,demo 圆角由 borderRadius 承担;
  • LightScene 的场景级 drawLightBorders 开关未单列 UI(highlightAlpha = 0 时描边不可见,需要时 ArkTS 加一个 Toggle 即可,不必动桥);
  • android / ios 目标剥离(源文件留在树里),vanniktech 发布一并剥离,本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.lightsource 匹配的签名。

8.3 未来优化方向

  • 社区 CMP 的 ohos 制品覆盖 dropShadow / Shadow 后,把 composeMain 接回 commonMain,ArkUI 复现层降级为 fallback;
  • 高亮描边升级为 linearGradient 环形组件,逼近上游 lightFacingHighlight 的渐变轮廓;
  • 桥增加批量接口(一次调用算多元素),把拖动帧的 N 次往返收成 1 次;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 lightsource-compose(纯 Compose UI 库)
    → 内核识别:全部视觉数字来自 calculateLightMetrics(26 行纯算术 + 8 档量化)
    → Compose 实现隔离 composeMain(git 100% rename,0 行改动)
    → 工具链钉 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(eazytec 置前、daemon-jvm 判据删除)
    → ohosArm64 / ohosX64 KLIB + jvm() 交叉验证
    → 内核纯 Kotlin 下沉(LightSourceMetrics 公共入口 + 镜像类型改名)
    → C ABI(@CName)+ C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析(显式 interface,严格模式)
    → ArkUI 真机页面(拖动光源 / 方向阴影 / 高亮描边 / 文字阴影 / 辉光 / 标记)

9.2 封装层次

  • 内核层:逐行同源的度量数学(方向、偏移、模糊、双 alpha、8 档量化、截断链),零第三方依赖;
  • 存档层:上游 Compose 实现 0 行改动存档 composeMain,等待社区 CMP 工具链接回;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、JSON 契约、拖动与参数分发、状态行与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 纯 UI 库的适配面先找"数字内核":上游所有视觉都从 calculateLightMetrics 出数,提取它而不是重写视觉——数字是契约、视觉是宿主,两者分开后 CMP 库在当前工具链下照样可交付、可验收;
  2. 工具链按已审核线钉,上游遗留判据要清干净:Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 + eazytec 置前是这套模式的三件套,gradle-daemon-jvm.properties 这类优先级更高的上游文件不清理,钉版等于没钉;
  3. 交叉验证是"内核没漂"的唯一可信证明:上游 fixture 参数一字不改地平移,13 档量化在两端同数;再配一个把真实数字显示出来的状态行,真机对拍零工具成本——渲染面可以近似,数字面必须逐位一致。

9.4 适配成果

当前适配已完成:

  • lightsource-compose 0.1.0(0.1.0-ohos.1)度量内核的纯 Kotlin 下沉与双 ABI Kotlin/Native 编译(上游 Compose 实现 0 行改动存档,git 100% rename);
  • 1 个 C ABI 符号(LightSourceCalc)和 C++ N-API 包装层(llvm-nm 验证全局 T 符号,NEEDED 短名);
  • DevEco Stage ArkUI 单页验收页面(拖动光源、方向阴影、高亮描边、文字阴影、辉光、标记、三滑条双开关重置、状态行对拍);
  • 2 项 JVM 交叉验证全绿(上游 fixture)+ HAP 构建(双 ABI 三件套 so)、签名、设备安装和真机验证;
  • 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。

参考文档

Logo

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

更多推荐