本文记录 MaterialThemePrefs 接入 OpenHarmony 的完整过程,覆盖 Compose UI 库的源码集拆分、鸿蒙工具链钉版、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

MaterialThemePrefs 是 Compose Multiplatform 的 Material Design 2/3 主题偏好库:ThemeEnum 三态(Light / Dark / SystemDefault)、PreferenceHelper 平台持久化、MaterialThemePrefs / Material3ThemePrefs 按状态切换 colors / colorScheme,再用 PreferableMaterialTheme + LocalThemePrefs 注进组件树。本次适配与 KStore 的"真编译整库"路线(存储引擎依赖的 kotlinx ohos 制品线已补齐)有本质区别:本库的价值主体是 Compose UI 组件,而社区尚无 compose ohos 制品——因此走社区早期已审路线(qrose / haze / PreCompose / voyager 的"内核提取 + 渲染外置"):把主题语义内核(三态枚举、偏好状态机、持久化契约、M2/M3 色板模型)下沉到 commonMain 纯 Kotlin,原样编译进 OpenHarmony 动态库,Compose 组件整体留档 composeMain,ArkUI 页面作为换肤验收宿主。

项目地址: AtomGit/oh-tpc/MaterialThemePrefs.git

上游仓库: softartdev/MaterialThemePrefs

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

MaterialThemePrefs 为 Compose Multiplatform 应用提供"主题偏好"这一件事的完整答案:rememberPreferenceHelper() 一行拿到平台持久化(Android SharedPreferences / JVM Preferences / iOS NSUserDefaults / Wasm localStorage),ThemePrefs.themeState 持有可响应的主题状态,MaterialThemePrefs.colors 与 Material3ThemePrefs.colorScheme 在 Light / Dark / SystemDefault 之间切换色板,isSystemInDarkTheme() 决定 System 态的实际深浅。在此之上,PreferableMaterialTheme 把 ThemePrefs 放进 CompositionLocal,SettingsScaffold / ThemeAlertDialog / RadioDialogContent 提供成套设置页 UI。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要"记住用户的主题选择并全页换肤",但上游构建(Kotlin 2.3.10 / Gradle 9.3.1 / AGP 9.0.1 / CMP 1.10.1)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示三种选项,却无法证明主题语义内核(三态解析、缺省回落、持久化契约、M2/M3 色板)在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证语义内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.3.10 / Gradle 9.3.1 / AGP 9.0.1(com.android.kotlin.multiplatform.library)与鸿蒙工具链无法共存于一个 Gradle 构建;必须整体换到鸿蒙 Kotlin 线。
依赖矩阵库的 commonMain 直接依赖 compose-runtime / compose-resources(theme-prefs)与 compose-material / compose-material3 全家(theme-material / theme-material3),这些 CMP 制品没有 ohos 变体——与 KStore 不同,这条障碍无法靠换制品线绕开。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
响应式概念跨不了桥CompositionLocal、@Composable、isSystemInDarkTheme() 是 Compose 运行时概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:主题语义内核的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。三态解析与持久化语义完全由提取后的 Kotlin 内核负责,ArkTS 只负责调用 Native、解析 JSON、落盘偏好和渲染换肤。

1.2 库提供的能力

Demo 提供一个真实的主题偏好会话,对应桥接的全部入口:

  • 初始化(init):ArkTS 从 @ohos.data.preferences 读出已存主题(可能为空),交给内核 restore(stored)——缺省回落 SystemDefault,与上游 Android actual 的 getInt 缺省语义一致;
  • 切换(set):内核 setTheme(theme) 更新状态机,响应带 persist 字段,ArkTS 拿它写入存储;
  • 跟随系统(system):系统深浅色变化时 ArkTS 推送 systemDark,内核只在 SystemDefault 态改写 isDark;
  • 清空(clear):内核重置为 SystemDefault,响应带 clearStorage,ArkTS 删除存储 key;
  • 持久化闭环:杀进程重启后再 init,读回上次选择——证明是落盘而不是内存状态;
  • 色板解析:响应同时携带按 isDark 解析后的 M2(12 键)与 M3(29 键)色板,ArkTS 直接刷整页颜色。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
action / theme回显本次动作与当前主题状态。
isDark三态解析后的实际深浅,ArkUI 换肤的依据。
persist仅 set 后出现:要求调用方写入存储的主题名。
clearStorage仅 clear 后出现:要求调用方删除存储 key。
m2 / m3Material 2 Colors 12 键、Material 3 ColorScheme 29 键,#aarrggbb 格式。

1.3 实现适配

维度要求
代码复用语义内核不重写:ThemeEnum / PreferenceHelper 接口 / THEME_KEY 原样下沉,新增 ThemePrefsCore 状态机承接 ThemePrefs 语义;上游 Compose 文件零改动留档 composeMain。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);theme-material / theme-material3 / sample / 发布链全部留盘不进构建。
桥接稳定使用 1 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律提取后的内核是零第三方依赖的纯 Kotlin——连 kotlinx 都不需要,工具链钉社区已审旧线(HarmonyOS Kotlin 2.2.21-1.0.0)。
UI 完整单页覆盖全部桥接入口:三态主题卡片、M2/M3 切换、九角色色板预览、状态行、清空偏好和持久化状态展示。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游的 PreferableMaterialTheme / SettingsScaffold / ThemeAlertDialog 是 Compose 组件,留在 composeMain 等社区 compose-ohos 制品出现后接回;ArkTS Demo 只是把桥暴露的主题闭环画出来。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与源码依赖度、确认"内核提取"路线、确定交付物
第 2 阶段:目标与构建打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 降 8.14.1
第 3 阶段:内核提取与桥协议 ── 源码集拆分、ThemePrefsCore 状态机、M2/M3 色板复刻、五动作 JSON 契约
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 系统深色监听、preferences 持久化、主题卡片、色板预览实时换肤
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
theme/theme-prefs/                     主题语义内核(进 ohos 构建,jvm + ohosArm64 + ohosX64)
theme/theme-prefs/src/commonMain/      ThemeEnum / PreferenceHelper / THEME_KEY + 新增 ThemePrefsCore
theme/theme-prefs/src/composeMain/     留档:ThemePrefs.kt、expect/actual、四平台持久化实现
theme/theme-material/                  M2 Compose 组件,纯 Compose,留盘不进 settings
theme/theme-material3/                 M3 Compose 组件,纯 Compose,留盘不进 settings
sample/                                上游五端示例(shared/android/desktop/ios/web),留盘不进构建
convention-plugins/                    vanniktech 发布插件约定,留盘不进构建
example/nativeApp/                     Kotlin/Native 动态库(Bridge + Palettes + MiniJson)
example/harmonyApp/                    DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md         鸿蒙适配说明(中英双份)
README.upstream.md                     上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :theme:theme-prefs 与 :example:nativeApp,rootProject.name = "ohos_materialthemeprefs"。上游的 dokka / vanniktech 发布插件、ABI 校验、AGP classpath、foojay 工具链解析全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向本机 Temurin 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 从已审仓替换;2.2.21 旧线不支持 Gradle 9.x)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.mtp
export JAVA_HOME="/path/to/jdk-21"    # Windows 下 gradlew.bat 亦可用 set JAVA_HOME=...
java -version
1.3 源码依赖度盘点
模块源集文件依赖形态处置
theme-prefscommonMain 3 文件compose-runtime + compose-resources(@Composable expect、stringRes)拆分:内核下沉 commonMain,Compose 部分留档 composeMain
theme-materialcommonMain 6 文件compose-material / foundation / icons-extended 全量留盘不进构建(无内核可提取)
theme-material3commonMain 6 文件compose-material3 全量留盘不进构建
sample/*五端入口 + shared UIAGP / navigation / activity-compose留盘不进构建

expect 声明全库只有 1 处:@Composable internal expect fun obtainPreferenceHelper(): PreferenceHelper,上游给了四个平台 actual(SharedPreferences / java.util.prefs / NSUserDefaults / localStorage)。这个 expect 长在 Compose 运行时上——ohos 没有 compose 制品,不可能补第五个 actual,这是决定"内核提取"路线的直接证据。处置:expect 与四个 actual 整体留档 composeMain,内核换一种方式满足同一语义——持久化外置到 ArkTS 侧的 @ohos.data.preferences,通过桥的 persist / clearStorage 字段驱动(见 3.2)。

theme-material / theme-material3 值得单独说明:它们的全部文件都是 @Composable 组件与 Material 类型(Colors / ColorScheme),没有一行可提取的纯逻辑;唯一有跨端价值的是 darkColors() / lightColors() / darkColorScheme() / lightColorScheme() 的默认色值——Material 基线是公开常量,把它们复刻成纯 Kotlin 色板模型随桥输出,是这两个模块在鸿蒙侧的存在方式。

1.4 页面边界
标题区          MaterialThemePrefs / 副标题(Compose Multiplatform theme prefs on HarmonyOS)
状态行          theme=... isDark=... persisted=... 与最近 action 结果
Theme 区        Light / Dark / System default 三张单选卡片(点击发 set)
Preview 区      Use Material 3 开关 + 九角色色板网格(角色名 + 色值)
                (primary / onPrimary / primaryContainer(M3) 或 primaryVariant(M2) /
                  secondary / tertiary(M3) 或 secondaryVariant(M2) / background / surface /
                  surfaceVariant(M3) / error)+ 主按钮对比度演示块
Persistence 区  Clear preferences 按钮(发 clear,删存储)
底部            版本 1.0.0-ohos.1 · 仓库链接

页面启动时先从 AppStorage 取 storedTheme(EntryAbility 异步读出的存储值)发 init 握手,系统深浅色经 @StorageLink('systemDark') 监听、变化即发 system。

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

2.1 路线验证(本适配的成败手)

先验证依赖能否换线:eazytec 社区 Maven(https://maven.eazytec-cloud.com)提供 HarmonyOS Kotlin 发行版(ohosArm64() / ohosX64() target DSL 与 ohosMain 层级源集只有这套插件给得出),但不提供 compose 制品。compose-runtime / compose-material / compose-material3 在 Maven Central 与社区仓都没有 ohos 变体,而上游 CMP 1.10.1 对应的 Compose 编译器又与鸿蒙 Kotlin 2.2.21(CMP 1.9.2 级别)对不齐——两条路都堵死,结论:UI 库的鸿蒙适配只能提取内核,渲染交给 ArkUI。这与 KStore 的判断过程完全同构:先查制品可达性,再定路线——只是结论相反。

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

社区已审的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose 同款)。本库提取后的内核是零依赖纯 Kotlin,没有"新制品反推工具链"的约束(KStore 因 coroutines 1.11.0-0.1.0-07 的 stdlib 元数据必须升 2.4.20 线,这里不存在);上游 Kotlin 2.3.10 源码与 2.2.21 源码级兼容,内核文件没有新语法,降级成本为零。代价是 Gradle:2.2.21 旧线不支持 9.x,wrapper 必须从上游 9.3.1 降到 8.14.1(从已审仓整体拷贝 wrapper jar/脚本/properties)。两条工具链线的经验不能互相套用——依赖决定线,线决定 Gradle。

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_materialthemeprefs"

include(":theme:theme-prefs")
include(":example:nativeApp")

gradle/libs.versions.toml 收敛为一行版本一个插件;gradle.properties 钉版本并放开 Kotlin/Native 开关:

org.gradle.java.home=C:\\path\\to\\jdk-21

version=1.0.0-ohos.1

kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true
kotlin.apple.deprecated.allowUsingOpenSourceCompiler=true
2.3 编译语义内核

theme-prefs/build.gradle.kts 收敛为纯 KMP 模块:

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

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}
    }
}

实测:

./gradlew :theme:theme-prefs:compileKotlinOhosArm64 :theme:theme-prefs:compileKotlinOhosX64   # BUILD SUCCESSFUL
2.4 测试与发布链的处置

上游根构建挂着 dokka 聚合、vanniktech 发布、ABI 校验和五端 sample,对鸿蒙没有保留价值,随根构建替换整体剥离;上游 theme-prefs 本没有测试源集,交叉验证以 jvm 目标编译 + 语义核对表 + 真机功能用例承担(见 6.2 / 6.4)。根构建全局禁用 ohos 测试任务,防止无 SDK 环境的设备测试任务卡构建:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

第 3 阶段:内核提取与桥协议建模

3.1 源码集拆分
动作文件去向
下沉改写ThemeEnum.ktcommonMain:去掉 stringRes: StringResource 参数,保留三态纯枚举
下沉改写PreferenceHelper.ktcommonMain:保留接口与 THEME_KEY,@Composable expect 移出
新增ThemePrefsCore.ktcommonMain:纯 Kotlin 状态机(76 行,全仓唯一新增库源文件)
留档ThemePrefs.ktcomposeMain(LocalThemePrefs / PreferableMaterialTheme)
留档expect + rememberPreferenceHelpercomposeMain/PreferenceHelperCompose.kt
留档四平台 actualcomposeMain/platform/(Android / Jvm / Ios / WasmJs)
留盘theme-material / theme-material3 / sample / convention-plugins不进 settings.gradle.kts

一个容易踩的细节:desktopMain 的 JvmPreferenceHelper 是 obtainPreferenceHelper 的 actual——只把 expect 挪去 composeMain 而留下它,jvm() 目标会立刻编译报错(actual 无 expect)。平台 actual 必须与 expect 同进退,这是"源码集拆分"和"模块留盘"的分界线:模块不进构建可以原样躺着,进构建的模块里源集必须自洽。

3.2 状态机与持久化外置

ThemePrefsCore 承接上游 ThemePrefs 的语义,持久化通过 onPersist 回调外置:

public class ThemePrefsCore(
    initial: ThemeEnum = ThemeEnum.SystemDefault,
    private val onPersist: (ThemeEnum?) -> Unit = {},
) {
    public var theme: ThemeEnum = initial
        private set
    public var systemDark: Boolean = false
        private set

    public fun restore(stored: ThemeEnum?): ThemeEnum {
        theme = stored ?: ThemeEnum.SystemDefault   // 缺省回落,对齐上游 Android actual
        return theme
    }
    public fun setTheme(theme: ThemeEnum) { this.theme = theme; onPersist(theme) }
    public fun updateSystemDark(systemDark: Boolean) { this.systemDark = systemDark }
    public val isDark: Boolean
        get() = when (theme) {
            ThemeEnum.Light -> false
            ThemeEnum.Dark -> true
            ThemeEnum.SystemDefault -> systemDark
        }
    public fun clear() { theme = ThemeEnum.SystemDefault; onPersist(null) }
}

为什么持久化不留在 Kotlin 侧:@ohos.data.preferences 是 ArkTS 系统能力,Kotlin/Native 编译期够不着;上游四个平台 actual 各自对接平台存储,ohos 的对应物只能在 ArkTS。桥的职责因此定为语义在 Kotlin、落盘在 ArkTS:set 响应带 persist(要求写入)、clear 响应带 clearStorage(要求删 key),写不写、写没写成,由 ArkTS 的 @ohos.data.preferences 闭环。

3.3 JSON 契约与色板复刻
com.softartdev.theme.pref/**(提取后的语义内核,纯 Kotlin)
        ↓ ThemePrefsCore / isDark / persist 语义
Bridge.kt + Palettes.kt + MiniJson(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API mtpRender(request)
        ↓
ArkUI Index.ets(单页滚动换肤)

请求与响应:

请求 : {"action":"init","stored":"Dark","systemDark":false}
       {"action":"set","theme":"Dark","systemDark":false}
       {"action":"system","systemDark":true}
       {"action":"clear"}
       {"action":"query"}
响应 : {"ok":true,"action":"set","theme":"Dark","isDark":true,
        "persist":"Dark","m2":{...12键...},"m3":{...29键...}}
       {"ok":false,"error":"..."}

两个不对称设计:persist 只在 set 后出现、clearStorage 只在 clear 后出现——语义动作与落盘动作一一对应,ArkTS 不需要自己判断"这次要不要写存储";m2 / m3 按 isDark 解析后直接给那一套(浅色给浅色板、深色给深色板),ArkTS 不需要知道"SystemDefault + systemDark 应该查哪张表"。

M2 / M3 色板是 Material 基线默认值的 1:1 复刻(darkColors() / lightColors() / darkColorScheme() / lightColorScheme() 的公开常量),四套共 82 个色值以 #aarrggbb 字符串随响应输出。手写 MiniJson 而不是引入 kotlinx.serialization——内核零依赖的纪律延伸到桥上,避免为解析五个字段的请求给 .so 增加 MB 级运行时。

3.4 错误通道

每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:

@CName("MtpRender")
fun mtpRender(request: String): String {
    return try {
        val json = MiniJson.parse(request)
        when (json.str("action").ifBlank { "query" }) { /* init/set/system/clear/query 分发 */ }
    } catch (t: Throwable) {
        """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
    }
}

非法 JSON、未知 action、set 缺 theme、未知主题名,全部走同一个 ok:false 通道,ArkTS 侧统一渲染。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libmtp_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosmtp.so)
  │ MtpRender
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制三态解析语义,所以选择 C ABI + JSON。本库只需要 1 个 C ABI 符号(五动作合一分发),比 KStore 的 2 符号更简——版本信息不需要单独握手,随每次响应的 action / theme 自然带出。

4.2 Kotlin/Native 导出函数
C ABI 符号ArkTS 方法覆盖内核
MtpRendermtpRender(req)restore / setTheme / updateSystemDark / isDark / clear + M2/M3 色板解析
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"mtpRender", nullptr, NapiMtpRender, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

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

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

add_library(mtp_napi SHARED napi_init.cpp)
target_link_libraries(mtp_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosmtp)

OHOS_ARCH 为空或 libohosmtp.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

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

第 5 阶段:ArkUI 宿主

5.1 系统深色监听与偏好初始化
// EntryAbility.ets
onConfigurationUpdated(newConfig: Configuration): void {
  this.applyColorMode(newConfig);          // → AppStorage.setOrCreate('systemDark', dark)
}

private initPreferences(): void {
  preferences.getPreferences(this.context, 'mtp_theme_prefs').then((store) => {
    const stored: string = store.getSync(THEME_KEY, '') as string;
    AppStorage.setOrCreate('storedTheme', stored);   // 页面 @StorageLink 接住后补发 init
    AppStorage.setOrCreate('prefsStore', store);
  });
}

config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK 判定系统深色;preferences 异步就绪晚于页面 aboutToAppear 的竞态用 @StorageLink('storedTheme') @Watch 补发 init 解决。

5.2 页面统一入口
private dispatch(action: string, theme?: string): void {
  const req: MtpRequest = { action, systemDark: this.systemDark };
  if (action === 'init' && this.storedTheme !== '') req.stored = this.storedTheme;
  if (theme !== undefined) req.theme = theme;
  const resp = JSON.parse(mtpRender(JSON.stringify(req))) as MtpResponse;
  this.apply(resp);                        // @State 刷新整页
  if (resp.persist !== undefined) store.putSync(THEME_KEY, resp.persist), store.flush();
  if (resp.clearStorage === true) store.deleteSync(THEME_KEY), store.flush();
}

三张主题卡片、M2/M3 开关、Clear 按钮全部走这一个入口;页面配色(背景 / 卡片 / 文字 / 主按钮)全部取自响应的 m3 / m2——切主题、切系统深色、切 Material 版本,整页即时换肤。

5.3 新 SDK 的两处 API 差异(真机实测踩坑)
  • @ohos.application.ConfigurationConstant 在新 SDK 是默认导出:import ConfigurationConstant from '...',且枚举在 ColorMode 下——写成命名导入直接编译失败;
  • Preferences.getStringSync 已从类型上移除,用 getSync(key, '') as string 替代(putSync / deleteSync / flush 不变)。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosmtp)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       ├── Bridge.kt                         @CName("MtpRender") + MiniJson
│       └── Palettes.kt                       M2/M3 × 深/浅 四套基线色板
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.mtp、versionName 1.0.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/libohosmtp_api.h
        ├── src/main/cpp/types/libmtp_napi/{index.d.ts, oh-package.json5}
        ├── src/main/ets/entryability/EntryAbility.ets
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.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 的 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.mtp -m entry

四、完整代码对照

4.1 整体架构

com.softartdev.theme.pref/**(提取后的语义内核)
    │ ThemeEnum 三态 / PreferenceHelper 契约 / ThemePrefsCore 状态机
Bridge.kt + Palettes.kt + MiniJson
    │ MtpRender(五动作分发 + 色板解析)
libmtp_napi.so
    │ mtpRender
Index.ets
    │ JSON parse + @State 换肤 + preferences 落盘
    ▼
ArkUI 真机页面(单页滚动实时换肤)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_materialthemeprefs、三模块留盘
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单条目
build.gradle.kts(根)版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务
theme-prefs/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,零依赖
theme-prefs/.../ThemePrefsCore.kt唯一新增库源文件(状态机 + onPersist 外置持久化)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 C ABI、MiniJson、错误通道
nativeApp/.../Palettes.ktM2/M3 深/浅四套 82 色值复刻
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/entryability/EntryAbility.etscolorMode 监听、preferences 初始化
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(22 节)

4.3 关键 API 对照

层次上游 API桥等价
内核ThemeEnum.Light / Dark / SystemDefaulttheme: "Light" / "Dark" / "SystemDefault"(大小写不敏感)
内核PreferenceHelper.themeEnum 读取action=query(或 init 的 stored 入参)
内核PreferenceHelper.themeEnum 写入action=set → 响应 persist,ArkTS 写 preferences
内核PreferenceHelper.clear()action=clear → 响应 clearStorage,ArkTS 删 key
内核ThemePrefs.themeState引擎内存态,响应 theme 字段
M2MaterialThemePrefs.colors(darkColors()/lightColors())响应 m2(按 isDark 解析后的那套,12 键)
M3Material3ThemePrefs.colorScheme响应 m3(29 键)
ComposeisSystemInDarkTheme()请求 systemDark(来自 config.colorMode)
ComposerememberPreferenceHelper / LocalThemePrefs / PreferableMaterialThemecomposeMain 留档;ArkTS 用 @State / @StorageLink 等价

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(mtpRender(JSON.stringify({
  action: 'set', theme: 'Dark', systemDark: false
})));
// res.ok === true, res.theme === 'Dark', res.isDark === true, res.persist === 'Dark'
// res.m3.primary === '#ffbb86fc'(M2 深色主色)……
"set" -> {
    val theme = parseTheme(json.str("theme")) ?: error("unknown theme")
    engine.updateSystemDark(json.bool("systemDark"))
    engine.setTheme(theme)
    respond("set", persist = theme)      // 语义在 Kotlin
}
// 落盘在 ArkTS:resp.persist → putSync(THEME_KEY, persist) + flush()

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

五、关键决策说明

决策 1:内核提取而不是等 compose-ohos 制品

本库与 KStore 的路线差异不是偏好,是依赖形态决定的:存储引擎的依赖(kotlinx 线)在社区仓有 ohos 制品,所以真编译整库;主题库的依赖就是 Compose 本身,ohos 制品缺位时任何"整库编译"的尝试都会在第一个 import androidx.compose.runtime 上失败。提取的边界也由依赖决定——ThemeEnum / PreferenceHelper / 状态语义不碰 Compose 的部分全部保留,碰的部分(@Composable expect、MutableState、CompositionLocal)整体留档。语义没有重写:三态解析、缺省回落、clear 语义与上游逐条对齐。

决策 2:工具链钉 2.2.21-1.0.0 旧线,Gradle 降 8.14.1

内核零依赖,没有制品反推压力,按社区已审旧线(qrose 同款)钉 HarmonyOS Kotlin 2.2.21-1.0.0;上游 2.3.10 源码与 2.2.21 源码级兼容,降级零成本。Gradle 必须随线降到 8.14.1(wrapper 整体替换),这是旧线的硬约束——与 KStore 的"2.4.20 线留 9.x"互为镜像案例。

决策 3:根构建替换(cmp_qrose 模式),三个 Compose 模块留盘

theme-material / theme-material3 无内核可提取(全部是 @Composable),sample 五端入口与发布链对鸿蒙无意义——全部留盘不进 settings.gradle.kts,与 kstore-storage 的处置同构。它们的存档价值在于:社区 compose-ohos 制品出现后,composeMain 与这两个模块可以原样接回。

决策 4:持久化外置 ArkTS(语义在 Kotlin、落盘在 ArkTS)

@ohos.data.preferences 是 ArkTS 系统能力,Kotlin/Native 侧无法实现第五个 PreferenceHelper actual。桥的契约让职责保持上游形状:内核只输出 persist / clearStorage 语义意图(等价于上游 actual 里的 putInt / clear 调用点),ArkTS 执行落盘。THEME_KEY 常量值(theme_key)在两侧保持一致。

决策 5:色板模型随桥走 + jvm 交叉验证保留

M2/M3 基线色值是公开常量,复刻进 Palettes.kt 让"切主题 = 换一套正确颜色"在鸿蒙侧可验收,也保留了未来接回 Compose 时的对照物。jvm() 目标保留:内核语义与上游对齐的正确性由编译层语义核对 + 真机功能用例兜底(上游无测试源集,见 6.2)。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Temurin,org.gradle.java.home 指向本机安装)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 交叉验证

上游 theme-prefs 未携带测试源集,交叉验证采用三层:jvm 目标编译(:theme:theme-prefs:compileKotlinJvm BUILD SUCCESSFUL,证明内核与 JVM 语义同源)+ 语义核对表 + 真机功能用例(6.4):

语义点上游行为桥行为验证方式
无存储缺省getInt 缺省 SystemDefault.ordinalrestore(null) → SystemDefault真机 clear 后重启
set 持久化putInt(THEME_KEY, ordinal)响应 persist → ArkTS putSync真机 set 后重启
clear 语义preferences.edit().clear()响应 clearStorage → ArkTS deleteSync真机 Clear 后重启
System 解析isSystemInDarkTheme()systemDark + isDark真机系统深色切换
M2/M3 切换colors / colorScheme 分支响应 m2 / m3 双色板真机开关切换

6.3 HAP 验证

:theme:theme-prefs:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: MtpRender / libohosmtp_symbols 均为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libmtp_napi.so)
CompileArkTS: finished(修复两处新 SDK API 差异后通过)
PackageHap: finished
install bundle successfully
start ability successfully

entry-default-unsigned.hap 约 5.0 MB,内含双 ABI 各三件套(libmtp_napi.so / libohosmtp.so / libc++_shared.so);KN so arm64 约 2.3 MB、x86_64 约 1.9 MB(debugShared,含 Kotlin/Native 运行时),libc++_shared.so 双 ABI 各约 1.8 / 1.7 MB。

6.4 功能用例

  1. 首次启动无存储:init 握手 theme=SystemDefault,页面跟随系统深浅;
  2. 点 Dark 卡片:状态行 theme=Dark isDark=true,整页切 M3 深色(背景 #ff1c1b1f、主色 #ffd0bcff),状态行出现 mtpRender "set" → ok,persisted=Dark;
  3. 切回 Light / SystemDefault:M3 浅色板 / 跟随系统,persisted 同步更新;
  4. SystemDefault 态在系统设置切深色:页面实时换肤(system action 经 @StorageLink 触发),存储值不变;
  5. Use Material 3 开关关闭:色板与页面配色切到 M2(深色主色 #ffbb86fc、背景 #ff121212);
  6. 杀掉应用重启:init 读回上次选择(如 Dark),页面直接深色——落盘持久化成立;
  7. 点 Clear preferences:存储 key 删除,回 SystemDefault,重启后同样无存储;
  8. hdc shell hilog | grep MtpNapi 可见每次 mtpRender 的请求字节数日志。

6.5 验证结论

语义内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的主题偏好会话:三态切换、系统深色跟随、M2/M3 双色板、持久化闭环与清空重置(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/MaterialThemePrefs.git
cd MaterialThemePrefs
export JAVA_HOME="/path/to/jdk-21"
./gradlew :theme:theme-prefs:compileKotlinOhosArm64 :theme:theme-prefs:compileKotlinOhosX64   # klib
./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.mtp -m entry
hdc shell hilog | grep MtpNapi

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :theme:theme-prefs 真正编译成 ohosArm64 / ohosX64 目标——哪怕内核只有三个文件;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 源码集拆分时 expect 与 actual 必须同进退:expect 挪进 composeMain 而 actual 留在 desktopMain,jvm() 目标立刻报"actual 无 expect"——进构建的模块里源集必须自洽,不进构建的模块才能整目录躺着;
  4. ThemeEnum 的 stringRes 参数长在 compose-resources 上,直接整体编译会连坐失败——去掉参数保留纯枚举,本地化标签交给 Demo 层;
  5. 持久化不能放 Kotlin 侧:@ohos.data.preferences 是 ArkTS 系统能力,onPersist 回调外置 + persist / clearStorage 响应字段是唯一通路;
  6. ArkTS 不能 import libohosmtp.so——HarmonyOS Kotlin 的 CAdapter 不把 @CName 业务符号挂给 ArkTS,必须 C++ N-API 胶水层(libmtp_napi.so);
  7. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  9. CMake 引入 KN so 要 IMPORTED_NO_SONAME + $ORIGIN rpath,否则 Windows 构建机的绝对路径会被写进 RUNPATH,真机 dlopen 失败;
  10. libc++_shared.so 必须与 KN so 一起拷进 entry/libs/<abi>/(从 ~/.konan/dependencies 按 triple 定位),缺它 NAPI so 起不来;
  11. org.gradle.java.home 是本机绝对路径,换机器必改;本机无 JDK 时先装 Temurin 21(wrapper 启动本身需要 JVM);
  12. 新 SDK 的 ArkTS 编译器有两处破坏性差异:ConfigurationConstant 改为默认导出(枚举在 ColorMode 下)、Preferences.getStringSync 类型移除(换 getSync)——报错信息都很直接,照提示改即可。

8.2 已知问题

  • 鸿蒙侧无 Compose UI:theme-material / theme-material3 组件与 composeMain 留档,等社区 compose-ohos 制品与上游 CMP 版本对齐后接回;
  • 桥只携带 Material 基线色板:上游 darkColors(...) / lightColorScheme(...) 的自定义色板参数未过桥(Demo 用默认值),可作为后续扩展;
  • 上游英 / 俄本地化字符串(composeResources)未过桥,Demo 标签为英文;
  • android / ios / wasmJs 目标剥离(源文件留在树里),发布、dokka、ABI 校验一并剥离,本 fork 不发 Maven Central;
  • 仓库根 LICENSE 文件为 GPL-3.0 全文,与上游 README / POM 声明的 MIT 不一致——上游遗留问题,原样保留;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.mtp 匹配的签名。

8.3 未来优化方向

  • 社区 compose-ohos 制品出现后接回 composeMain 与 M2/M3 模块,ArkUI 降级为对照 Demo;
  • 色板桥扩展自定义颜色参数(对应上游 darkColorPalette / lightColorScheme 构造参数);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 MaterialThemePrefs(Compose UI 库,三模块)
    → 验证 compose ohos 制品缺位 → 确认"内核提取"路线
    → 工具链钉 HarmonyOS Kotlin 2.2.21-1.0.0(Gradle 降 8.14.1)
    → 源码集拆分(commonMain 纯 Kotlin 化 + composeMain 留档)
    → ohosArm64 / ohosX64 KLIB + ThemePrefsCore 状态机
    → C ABI(@CName × 1)+ 五动作 JSON 契约 + M2/M3 色板复刻
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析 + preferences 落盘
    → ArkUI 真机单页验收(三态 / 系统跟随 / 双色板 / 重启持久化)

9.2 封装层次

  • 内核层:ThemeEnum 三态、PreferenceHelper 契约、ThemePrefsCore 状态机(restore / setTheme / isDark / clear)与 onPersist 外置持久化;
  • 桥层:五动作 JSON 分发、M2/M3 深浅四套基线色板、手写 MiniJson;
  • Native 层:双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:系统深色监听、preferences 落盘、页面状态与整页换肤;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 依赖形态决定适配路线:KStore 依赖 kotlinx 制品线(已补齐)所以真编译整库,MaterialThemePrefs 依赖 Compose 本身(ohos 缺位)所以内核提取——两条都是已审正路,选择依据是当天制品可达性与库的 UI 依赖度,而不是个人偏好;UI 库的"最小内核"划在最后一个 import androidx.compose 之后;
  2. 平台能力够不着就语义外置:持久化 API 在 ArkTS 侧,Kotlin 内核就用 persist / clearStorage 字段输出意图、由调用方落盘——契约对齐上游 actual 的调用点,语义与实现都不漂移;
  3. 换肤类库的验收重点是"闭环"而不是"像素":三态解析正确、系统深色跟随、杀进程重启后主题保持、清空后回落缺省——这四条闭环全部走通,色板值与 Material 基线逐键一致,适配即可信。

9.4 适配成果

当前适配已完成:

  • MaterialThemePrefs 1.0.0 语义内核的双 ABI Kotlin/Native 编译(commonMain 纯 Kotlin 化,新增 ThemePrefsCore 一个库源文件,上游 Compose 文件零改动留档);
  • 1 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证双 ABI 全局 T 符号);
  • M2 / M3 深 / 浅四套 82 色值的桥侧复刻与实时换肤 Demo(三态卡片、Material 版本开关、九角色色板、状态行、清空偏好);
  • ArkTS 侧 @ohos.data.preferences 持久化闭环(persist / clearStorage 契约);
  • HAP 构建(双 ABI 六件套 so,约 5.0 MB)、签名、设备安装和真机验证;
  • AtomGit 项目文档、OpenHarmony README(中英双份,主文档 22 节)和本地效果图。

适配过程 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐