从 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)→ hvigor assembleHap(打包签名)→ hdc install + aa start(真机运行);

  • 两个必踩的坑:鸿蒙不声明 ohos.permission.INTERNET 会报 POSIX error 13CIO 引擎在 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 Studio26.0.0(DS-261.23567.138.36.2600821)内置 HarmonyOS SDK API 26
Android Studio2026.1.3装 KMP OHOS Support 插件
JDKOpenJDK 17.0.15(Homebrew,/opt/homebrew/opt/openjdk@17JAVA_HOME 指向它
Gradle8.9(腾讯镜像分发)与 wrapper 保持一致
KMP / CMPkotlin 2.2.21-0.3.0 / composeMultiplatform 1.9.2-0.3.0CPF 鸿蒙化基线
鸿蒙 SDKAPI 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 URLhttps://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,三端各自包一层壳:

AndroidMainActivity.kt):

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        setContent { App() }
    }
}

iOSMainViewController.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.json5requestPermissions 增加网络权限声明(并在 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-tlsTLSClientSession.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.0ohosArm64 变体(自带 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 生态。


参考资料

免责声明:文中版本号、依赖坐标为 0.3.0 基线下的快照,CPF-KMP-CMP 持续迭代,请以官方最新文档为准。

Logo

一站式 AI 云服务平台

更多推荐