从 0 到 1:用 KMP + Compose Multiplatform + Ktor 实现鸿蒙「历史上的今天」应用
从 0 到 1:用 KMP + Compose Multiplatform + Ktor 实现鸿蒙「历史上的今天」应用
配套代码:本文对应的完整工程
kmpcmpdemo(Kotlin 2.2.21-0.3.0 / Compose Multiplatform 1.9.2-0.3.0),一套 Compose 代码跑通 Android / iOS / HarmonyOS 三端,鸿蒙端已在真机验证运行。
太长不看(TL;DR)
-
用 CPF-KMP-CMP 鸿蒙化工具链(KMP 2.2.21-0.3.0 + CMP 1.9.2-0.3.0)搭建三端共享 UI 的 KMP 工程;
-
commonMain 里写 Ktor 客户端 + Kotlinx Serialization 调 ALAPI 的「历史上的今天」接口,UI 用 Compose Multiplatform 一次编写;
-
鸿蒙端只补一个 napi 入口(
MainArkUIViewController),ArkTS 宿主用一页Index.ets包住 Compose; -
整条链路:
gradlew publishDebugBinariesToHarmonyApp(编 libkn.so)→ hvigorassembleHap(打包签名)→hdc install+aa start(真机运行); -
两个必踩的坑:鸿蒙不声明
ohos.permission.INTERNET会报 POSIX error 13;CIO 引擎在 Native 平台不支持 TLS,必须换ktor-client-curl引擎。
一、为什么是 KMP + Compose Multiplatform + 鸿蒙
过去写鸿蒙应用只有 ArkTS 一条路,App 里如果还有 Android/iOS 端,就等于同一套业务写三遍。KMP(Kotlin Multiplatform)+ Compose Multiplatform(CMP)的思路是:业务逻辑和 UI 都在 commonMain 写一次,各端只留一个薄壳入口。
CPF-KMP-CMP 社区把这条链路补到了鸿蒙上:Kotlin 编译器加 ohosArm64 target,CMP 对接鸿蒙的 OHRender/自渲染,Ktor、kotlinx 等常用三方库都有鸿蒙化版本(版本号形如 3.3.3-0.3.0,对齐社区上游)。
本文不空谈架构,直接用一个真实的小应用走完全过程:调用 ALAPI 的「历史上的今天」接口(API ID 11),展示列表、支持关键词搜索、点击查看详情。
二、环境准备(以本机 macOS Apple Silicon 为例)
| 组件 | 版本 | 说明 |
|---|---|---|
| 操作系统 | macOS(Apple Silicon) | 本机实测 |
| DevEco Studio | 26.0.0(DS-261.23567.138.36.2600821) | 内置 HarmonyOS SDK API 26 |
| Android Studio | 2026.1.3 | 装 KMP OHOS Support 插件 |
| JDK | OpenJDK 17.0.15(Homebrew,/opt/homebrew/opt/openjdk@17) | JAVA_HOME 指向它 |
| Gradle | 8.9(腾讯镜像分发) | 与 wrapper 保持一致 |
| KMP / CMP | kotlin 2.2.21-0.3.0 / composeMultiplatform 1.9.2-0.3.0 | CPF 鸿蒙化基线 |
| 鸿蒙 SDK | API 26(DevEco 内置 Contents/sdk/default) | 示例工程兼容/目标 6.0.0(20) |
| 设备 | HarmonyOS 真机 | hdc list targets 可识别 |
环境变量(写入 ~/.zshrc):
export JAVA_HOME="/opt/homebrew/opt/openjdk@17" export DEVECO_SDK_HOME="/Applications/DevEco-Studio.app/Contents/sdk" export PATH="/Applications/DevEco-Studio.app/Contents/tools/ohpm/bin:$PATH" export PATH="/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin:$PATH"
搭建顺序:JDK 17 → Android Studio(装 KMP OHOS Support 插件)→ DevEco Studio。ohpm/hvigor 直接复用 DevEco 内置工具链。
三、工程结构:KMP 共享 + harmonyApp 宿主
kmpcmpdemo/ ├── settings.gradle.kts # 仓库指向鸿蒙化 Maven 私仓 maven.eazytec-cloud.com ├── gradle/libs.versions.toml # kotlin 2.2.21-0.3.0 / composeMultiplatform 1.9.2-0.3.0 ├── composeApp/ # KMP 共享模块 │ └── src/ │ ├── commonMain/ # App.kt(Compose UI)+ AlapiClient.kt + 数据模型 │ ├── androidMain/ # MainActivity(薄壳) │ ├── iosMain/ # MainViewController(薄壳) │ ├── ohosMain/ # MainArkUIViewController(napi 入口) │ └── commonTest/ ├── harmonyApp/ # 鸿蒙宿主工程(DevEco/hvigor) │ └── entry/src/main/ │ ├── ets/entryability/EntryAbility.ets │ ├── ets/pages/Index.ets # @cpf-kmp-cmp/compose 包住 Compose │ ├── cpp/napi_init.cpp # napi 入口壳 │ └── module.json5 # 模块配置(含权限声明!) └── runscript/ # 一键跑鸿蒙的脚本
四、接入依赖:鸿蒙化的 Ktor 与 Serialization
三方库在私仓 https://maven.eazytec-cloud.com/nexus/repository/maven-public/ 发布,版本号带 -0.3.0 鸿蒙基线后缀。在 gradle/libs.versions.toml 里追加:
[versions]
kotlinx-serialization = "1.9.1-0.3.0"
ktor = "3.3.3-0.3.0"
[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
composeApp/build.gradle.kts 里按平台挂引擎(关键点,见后文踩坑 2):
plugins {
// ...
alias(libs.plugins.kotlinSerialization)
}
kotlin {
// androidTarget / iosX64 / iosArm64 / iosSimulatorArm64 / ohosArm64 ...
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
}
androidMain.dependencies {
// ...
implementation(libs.ktor.client.okhttp) // Android 用 okhttp 引擎
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin) // iOS 用 darwin 引擎
}
ohosMain.dependencies {
api(libs.compose.multiplatform.export)
implementation(libs.ktor.client.curl) // 鸿蒙用 curl 引擎(libcurl 处理 TLS)
}
}
}
为什么鸿蒙不用默认的 CIO 引擎?看「踩坑记录」第二节,Native 平台上 CIO 根本没有 TLS 实现,HTTPS 请求必炸,官方鸿蒙化分支提供 curl 引擎就是干这个的。
五、对接 ALAPI「历史上的今天」接口
5.1 接口信息
用 ALAPI 官方 skill(npx skills add https://github.com/ALAPI-SDK/skill)直接拉取 OpenAPI 规格,确认接口定义:
| 项 | 值 |
|---|---|
| Base URL | https://v3.alapi.cn |
| 列表 | POST /api/eventHistory,body:token(必填)+ month + day |
| 搜索 | POST /api/eventHistory/search,body:token + word(必填)+ page |
| 详情 | POST /api/eventHistory/get,body:token + id(必填) |
| 鉴权 | 所有接口都要传 token 参数,放 JSON body 或 query 均可 |
先用 curl 真实调用一次验证响应结构(token 从 ALAPI 控制台申请):
curl -s -X POST "https://v3.alapi.cn/api/eventHistory" \
-H "Content-Type: application/json" \
-d '{"token":"<你的token>","month":"9","day":"5"}'
返回结构(统一外壳):
{
"success": true,
"code": 200,
"message": "success",
"data": [
{
"id": "3879899253ba11eb90470c42a1415493",
"title": "“万里长江第一隧”双线贯通",
"year": 2008, "month": 9, "day": 5,
"monthday": "0905",
"date": "2008年9月5日",
"desc": "2008年9月5日(农历…),……"
}
],
"request_id": "…", "time": 1788565675, "usage": 0
}
注意:详情接口 /get 的正文在 content 字段(不是 desc),数据模型要区分开。
5.2 数据模型(commonMain)
HistoryTodayModels.kt:
@Serializable
data class AlapiResponse<T>(
@SerialName("request_id") val requestId: String = "",
val success: Boolean = false,
val message: String = "",
val code: Int = 0,
val data: T? = null,
val time: Long = 0,
val usage: Int = 0,
)
@Serializable
data class HistoryEvent(
val id: String = "", val title: String = "",
val year: Int = 0, val month: Int = 0, val day: Int = 0,
val monthday: String = "", val date: String = "", val desc: String = "",
)
@Serializable
data class HistoryEventDetail(
val id: String = "", val title: String = "",
val year: Int = 0, val month: Int = 0, val day: Int = 0,
val content: String = "", // 详情正文
)
5.3 API 客户端(commonMain,三端共享)
AlapiClient.kt 封装三个接口,POST + JSON body 传 token,返回统一反序列化后的数据:
class AlapiClient(private val token: String) {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
/** 拉取指定月日的历史事件列表 */
suspend fun fetchHistoryEvents(month: Int, day: Int): List<HistoryEvent> {
val response: AlapiResponse<List<HistoryEvent>> =
client.post("https://v3.alapi.cn/api/eventHistory") {
contentType(ContentType.Application.Json)
setBody(mapOf(
"token" to token,
"month" to month.toString(),
"day" to day.toString(),
))
}.body()
return response.data ?: emptyList()
}
/** 按关键词搜索历史事件 */
suspend fun searchEvents(word: String, page: Int = 1): List<HistoryEvent> {
val response: AlapiResponse<List<HistoryEvent>> =
client.post("https://v3.alapi.cn/api/eventHistory/search") {
contentType(ContentType.Application.Json)
setBody(mapOf(
"token" to token,
"word" to word,
"page" to page.toString(),
))
}.body()
return response.data ?: emptyList()
}
/** 获取事件详情 */
suspend fun fetchEventDetail(id: String): HistoryEventDetail? {
val response: AlapiResponse<HistoryEventDetail> =
client.post("https://v3.alapi.cn/api/eventHistory/get") {
contentType(ContentType.Application.Json)
setBody(mapOf("token" to token, "id" to id))
}.body()
return response.data
}
}
六、Compose Multiplatform UI(commonMain 一次编写)
页面结构:Screen sealed class 做简易导航(列表页 / 详情页),不引入导航库,够用即可:
private sealed interface Screen {
data object List : Screen
data class Detail(val eventId: String) : Screen
}
列表页 HistoryListScreen:顶部搜索栏(OutlinedTextField + 搜索按钮),关键词为空时回退到当天列表,非空走 /search;下方 LazyColumn 渲染卡片,卡片可点击进详情:
@Composable
private fun HistoryListScreen(client: AlapiClient, onOpenDetail: (String) -> Unit) {
val scope = rememberCoroutineScope()
var uiState by remember { mutableStateOf<HistoryUiState>(HistoryUiState.Loading) }
var query by remember { mutableStateOf("") }
fun doSearch(word: String) {
scope.launch {
uiState = HistoryUiState.Loading
uiState = try {
val trimmed = word.trim()
if (trimmed.isEmpty()) {
HistoryUiState.Success(client.fetchHistoryEvents(month = 9, day = 5))
} else {
HistoryUiState.Success(client.searchEvents(trimmed))
}
} catch (e: Throwable) {
HistoryUiState.Error(e.message ?: "请求失败")
}
}
}
LaunchedEffect(Unit) { doSearch("") } // 首次加载当天
Scaffold(
modifier = Modifier.fillMaxSize().safeContentPadding(),
topBar = { TopAppBar(title = { Text("历史上的今天") }) },
) { innerPadding ->
Column(Modifier.fillMaxSize().padding(innerPadding)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = query, onValueChange = { query = it },
modifier = Modifier.weight(1f),
placeholder = { Text("搜索关键词,如“长江”") }, singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = { doSearch(query) }, enabled = query.isNotBlank()) { Text("搜索") }
}
when (val state = uiState) {
is HistoryUiState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
is HistoryUiState.Error -> /* 错误 + 重试按钮 */
is HistoryUiState.Success -> LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.events, key = { it.id }) { event ->
HistoryEventCard(event, onClick = { onOpenDetail(event.id) })
}
}
}
}
}
}
详情页 HistoryDetailScreen:按 eventId 拉 /get,展示年份日期、标题、完整正文(content),带返回按钮,正文区可滚动:
@Composable
private fun HistoryDetailScreen(client: AlapiClient, eventId: String, onBack: () -> Unit) {
var detailState by remember { mutableStateOf<DetailUiState>(DetailUiState.Loading) }
LaunchedEffect(eventId) {
detailState = DetailUiState.Loading
detailState = try {
val detail = client.fetchEventDetail(eventId)
if (detail != null) DetailUiState.Success(detail)
else DetailUiState.Error("未找到该事件详情")
} catch (e: Throwable) {
DetailUiState.Error(e.message ?: "请求失败")
}
}
Scaffold(
modifier = Modifier.fillMaxSize().safeContentPadding(),
topBar = {
TopAppBar(
title = { Text("事件详情") },
navigationIcon = { TextButton(onClick = onBack) { Text("← 返回") } },
)
},
) { innerPadding ->
// Loading / Error / Success 三态;Success 用 Column + verticalScroll 展示 content
}
}
UI 状态用两个 sealed interface(HistoryUiState / DetailUiState)管理加载、错误、成功三态,卡片用 Card(onClick=…) 支持点击。
七、三端入口:UI 写一次,入口各一行
App() 是唯一入口 Composable,三端各自包一层壳:
Android(MainActivity.kt):
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent { App() }
}
}
iOS(MainViewController.kt):
fun MainViewController() = ComposeUIViewController { App() }
鸿蒙(ohosMain/MainArkUIViewController.kt,napi 入口):
@OptIn(ExperimentalNativeApi::class, ExperimentalForeignApi::class)
@CName("MainArkUIViewController")
fun MainArkUIViewController(env: napi_env): napi_value {
initMainHandler(env)
return ComposeArkUIViewController(env) { App() }
}
ArkTS 侧(harmonyApp/entry/src/main/ets/pages/Index.ets)用一页把 Compose 包进来:
import { ArkUIViewController, Compose } from '@cpf-kmp-cmp/compose'; // ohpm 依赖
import nativeApi from 'libentry.so';
@Entry
@Component
struct Index {
private controller: ArkUIViewController | undefined = undefined;
aboutToAppear() {
// 初始化 napi 入口,把 Compose 内容挂到 ArkUI 节点
}
// build() 中通过 Compose() 组件承载 libkn.so 渲染的内容
}
业务代码一行不写 ArkTS——三端共享同一套 Compose UI。
八、构建 → 打包 → 真机运行
8.1 KMP 侧:编译共享代码为 libkn.so
# 编译 ohosArm64 共享库并发布到 harmonyApp(含头文件 libkn_api.h 与 .so) ./gradlew :composeApp:publishDebugBinariesToHarmonyApp
产物落位:
harmonyApp/entry/libs/arm64-v8a/libkn.so # 动态库(debug 约 70~80MB,含 libcurl) harmonyApp/entry/src/main/cpp/include/arm64-v8a/libkn_api.h
8.2 ArkTS 侧:ohpm 装依赖 + hvigor 打包签名
cd harmonyApp ohpm install --all # 拉取 @cpf-kmp-cmp/compose 等鸿蒙化依赖 export DEVECO_HOME=/Applications/DevEco-Studio.app/Contents export DEVECO_SDK_HOME=$DEVECO_HOME/sdk export PATH="$DEVECO_SDK_HOME:$DEVECO_HOME/jbr/Contents/Home/bin:$DEVECO_HOME/tools/node/bin:$DEVECO_HOME/tools/ohpm/bin:$DEVECO_HOME/tools/hvigor/bin:$PATH" # 同步 + 打包签名(产物 entry-default-signed.hap) node "$DEVECO_HOME/tools/hvigor/bin/hvigorw.js" --sync -p product=default -p buildMode=debug --parallel --incremental --daemon node "$DEVECO_HOME/tools/hvigor/bin/hvigorw.js" --mode module -p module="entry@default" -p product=default -p buildMode=debug -p requiredDeviceType=phone assembleHap --parallel --incremental --daemon
8.3 真机安装运行
HDC=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc HAP=entry/build/default/outputs/default/entry-default-signed.hap # 1. 推包并安装 "$HDC" -t <设备ID> shell mkdir -p /data/local/tmp/debug_install "$HDC" -t <设备ID> file send "$HAP" /data/local/tmp/debug_install/ "$HDC" -t <设备ID> shell bm install -p /data/local/tmp/debug_install/entry-default-signed.hap # 2. 启动 "$HDC" -t <设备ID> shell aa start -a EntryAbility -b com.example.harmonyapp # 3. 验证进程与日志 "$HDC" -t <设备ID> shell ps -ef | grep harmonyapp "$HDC" -t <设备ID> shell hilog -x | grep -iE "TLS|POSIX|Fatal|Crashed"
本项目还附带 runscript/runOhosApp-Mac.sh,一条命令走完「编译 → 打包 → 安装 → 启动」全流程:
./runscript/runOhosApp-Mac.sh ohosArm64 2LQ0224129000383
九、踩坑记录(两个必踩的坑)
9.1 坑 1:Permission denied——鸿蒙要显式声明网络权限
现象:应用能启动,UI 正常渲染,但请求一发就失败,页面报:
加载失败 POSIX error 13: Permission denied (13)
根因:HarmonyOS 应用默认不授予网络权限。kmpcmpdemo 的 harmonyApp/entry/src/main/module.json5 里原本只声明了 ohos.permission.VIBRATE,没有 ohos.permission.INTERNET——ktor 发起 HTTPS 请求时直接被系统拒绝,返回 errno 13。
修复:在 module.json5 的 requestPermissions 增加网络权限声明(并在 resources/base/element/string.json 补上 reason 字符串,否则 hvigor 编译会因引用不存在的资源而失败):
{
"name": "ohos.permission.INTERNET",
"reason": "$string:internet_permission_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "always" }
}
// resources/base/element/string.json
{ "name": "internet_permission_reason", "value": "用于访问网络获取历史上的今天数据" }
9.2 坑 2:TLS sessions are not supported on Native platform——CIO 引擎无 TLS
现象:权限修好后重新构建安装,页面又报:
加载失败 TLS sessions are not supported on Native platform.
根因:默认给鸿蒙配的 CIO 引擎(ktor-client-cio)在 Native 平台(含 OHOS)没有实现 TLS。翻 ktor 源码(ktor-network-tls 的 TLSClientSession.nonJvm.kt)可以看到实现就是直接抛错:
internal actual suspend fun openTLSSession(...): Socket {
error("TLS sessions are not supported on Native platform.")
}
任何 HTTPS 请求都会命中这一行。iOS 有 Darwin 引擎(NSURLSession)替它兜底,鸿蒙没有。
修复:CPF 鸿蒙化 ktor 提供了 ktor-client-curl 引擎(基于 libcurl,TLS 由 libcurl 处理),私仓版本 3.3.3-0.3.0 含 ohosArm64 变体(自带 libcurl cinterop 绑定)。把 ohosMain 的引擎从 cio 换成 curl 即可:
ohosMain.dependencies {
api(libs.compose.multiplatform.export)
implementation(libs.ktor.client.curl) // 替换 ktor-client-cio
}
替换后 libkn.so 会大一圈(curl 引擎 + libcurl 打进 so,debug 约 80MB),但 HTTPS 完全正常。
9.3 小坑:material3 API 实验性注解
TopAppBar 等 Material3 API 标记为实验性,需要:
@OptIn(ExperimentalMaterial3Api::class)
否则 Kotlin 编译器直接报错 This material API is experimental…。
十、效果与总结
最终三端共享同一套 Compose UI,鸿蒙真机(HarmonyOS,API 26 SDK 构建)实测:
-
默认展示 9 月 5 日历史事件列表(29 条);
-
搜索「长江」等关键词,走
/eventHistory/search返回匹配事件; -
点击卡片进详情,走
/eventHistory/get展示完整正文; -
全程无 ArkTS 业务代码,唯一手动接触鸿蒙侧的是权限声明与打包流程。

这条链路的增量成本:一个 ohosArm64 target + 一个 napi 入口壳 + 一页 Index.ets + 一批 -0.3.0 版本号。换来的是 Android / iOS / HarmonyOS 三端同一套 Compose 代码直接跑通,网络层用 Ktor 鸿蒙化版本(curl 引擎)无缝对接 HTTPS 生态。
参考资料
-
CPF-KMP-CMP 文档仓库:docs:基于 OpenHarmony 的 KMP&CMP 开发文档项目 - AtomGit
-
Ktor 鸿蒙化分支(main-3.3.3-OH):ktor:基于 Kotlin 的异步 Web 框架项目 - AtomGit
-
ALAPI 开放平台:https://www.alapi.cn
-
-
作者:坚果(jianguoxu),License:Apache-2.0
-
直接获取:
git clone https://atomgit.com/jianguoxu/kmpcmpdemo.git -
包含:composeApp(KMP 共享模块)/ harmonyApp(鸿蒙宿主)/ iosApp(iOS 宿主)/ runscript(一键脚本)/ README / LICENSE
-
基线:KMP 2.2.21-0.3.0 / CMP 1.9.2-0.3.0,含 harmonyApp 宿主
-
免责声明:文中版本号、依赖坐标为 0.3.0 基线下的快照,CPF-KMP-CMP 持续迭代,请以官方最新文档为准。
更多推荐



所有评论(0)