开源鸿蒙平台 CMP 三方库「MaterialThemePrefs」适配全流程
本文记录
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 / m3 | Material 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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向本机 Temurin 21) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 从已审仓替换;2.2.21 旧线不支持 Gradle 9.x) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.mtp |
export JAVA_HOME="/path/to/jdk-21" # Windows 下 gradlew.bat 亦可用 set JAVA_HOME=...
java -version
1.3 源码依赖度盘点
| 模块 | 源集文件 | 依赖形态 | 处置 |
|---|---|---|---|
theme-prefs | commonMain 3 文件 | compose-runtime + compose-resources(@Composable expect、stringRes) | 拆分:内核下沉 commonMain,Compose 部分留档 composeMain |
theme-material | commonMain 6 文件 | compose-material / foundation / icons-extended 全量 | 留盘不进构建(无内核可提取) |
theme-material3 | commonMain 6 文件 | compose-material3 全量 | 留盘不进构建 |
sample/* | 五端入口 + shared UI | AGP / 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.kt | commonMain:去掉 stringRes: StringResource 参数,保留三态纯枚举 |
| 下沉改写 | PreferenceHelper.kt | commonMain:保留接口与 THEME_KEY,@Composable expect 移出 |
| 新增 | ThemePrefsCore.kt | commonMain:纯 Kotlin 状态机(76 行,全仓唯一新增库源文件) |
| 留档 | ThemePrefs.kt | composeMain(LocalThemePrefs / PreferableMaterialTheme) |
| 留档 | expect + rememberPreferenceHelper | composeMain/PreferenceHelperCompose.kt |
| 留档 | 四平台 actual | composeMain/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 方法 | 覆盖内核 |
|---|---|---|
MtpRender | mtpRender(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.toml | Kotlin 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.kt | 1 个 C ABI、MiniJson、错误通道 |
nativeApp/.../Palettes.kt | M2/M3 深/浅四套 82 色值复刻 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/entryability/EntryAbility.ets | colorMode 监听、preferences 初始化 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(22 节) |
4.3 关键 API 对照
| 层次 | 上游 API | 桥等价 |
|---|---|---|
| 内核 | ThemeEnum.Light / Dark / SystemDefault | theme: "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 字段 |
| M2 | MaterialThemePrefs.colors(darkColors()/lightColors()) | 响应 m2(按 isDark 解析后的那套,12 键) |
| M3 | Material3ThemePrefs.colorScheme | 响应 m3(29 键) |
| Compose | isSystemInDarkTheme() | 请求 systemDark(来自 config.colorMode) |
| Compose | rememberPreferenceHelper / LocalThemePrefs / PreferableMaterialTheme | composeMain 留档;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.ordinal | restore(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 功能用例
- 首次启动无存储:init 握手
theme=SystemDefault,页面跟随系统深浅; - 点 Dark 卡片:状态行
theme=Dark isDark=true,整页切 M3 深色(背景#ff1c1b1f、主色#ffd0bcff),状态行出现mtpRender "set" → ok,persisted=Dark; - 切回 Light / SystemDefault:M3 浅色板 / 跟随系统,
persisted同步更新; - SystemDefault 态在系统设置切深色:页面实时换肤(system action 经
@StorageLink触发),存储值不变; - Use Material 3 开关关闭:色板与页面配色切到 M2(深色主色
#ffbb86fc、背景#ff121212); - 杀掉应用重启:init 读回上次选择(如 Dark),页面直接深色——落盘持久化成立;
- 点 Clear preferences:存储 key 删除,回
SystemDefault,重启后同样无存储; 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 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:theme:theme-prefs真正编译成ohosArm64/ohosX64目标——哪怕内核只有三个文件; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 源码集拆分时 expect 与 actual 必须同进退:expect 挪进 composeMain 而 actual 留在 desktopMain,
jvm()目标立刻报"actual 无 expect"——进构建的模块里源集必须自洽,不进构建的模块才能整目录躺着; ThemeEnum的stringRes参数长在compose-resources上,直接整体编译会连坐失败——去掉参数保留纯枚举,本地化标签交给 Demo 层;- 持久化不能放 Kotlin 侧:
@ohos.data.preferences是 ArkTS 系统能力,onPersist回调外置 +persist/clearStorage响应字段是唯一通路; - ArkTS 不能 import
libohosmtp.so——HarmonyOS Kotlin 的 CAdapter 不把@CName业务符号挂给 ArkTS,必须 C++ N-API 胶水层(libmtp_napi.so); - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - CMake 引入 KN so 要
IMPORTED_NO_SONAME+$ORIGINrpath,否则 Windows 构建机的绝对路径会被写进 RUNPATH,真机dlopen失败; libc++_shared.so必须与 KN so 一起拷进entry/libs/<abi>/(从~/.konan/dependencies按 triple 定位),缺它 NAPI so 起不来;org.gradle.java.home是本机绝对路径,换机器必改;本机无 JDK 时先装 Temurin 21(wrapper 启动本身需要 JVM);- 新 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 三条经验
- 依赖形态决定适配路线:KStore 依赖 kotlinx 制品线(已补齐)所以真编译整库,MaterialThemePrefs 依赖 Compose 本身(ohos 缺位)所以内核提取——两条都是已审正路,选择依据是当天制品可达性与库的 UI 依赖度,而不是个人偏好;UI 库的"最小内核"划在最后一个
import androidx.compose之后; - 平台能力够不着就语义外置:持久化 API 在 ArkTS 侧,Kotlin 内核就用
persist/clearStorage字段输出意图、由调用方落盘——契约对齐上游 actual 的调用点,语义与实现都不漂移; - 换肤类库的验收重点是"闭环"而不是"像素":三态解析正确、系统深色跟随、杀进程重启后主题保持、清空后回落缺省——这四条闭环全部走通,色板值与 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。
参考文档
更多推荐


所有评论(0)