第 8 章 · 平台通信 — Module / expect-actual / 双向调用

在「Module 模拟器」里点按钮,看模拟的"Kotlin 层 ↔ 原生层"通信日志

什么时候必须调原生?

能搞定(commonMain 内)搞不定(必须调原生)
UI 描述 / 布局调用相机 / 相册
业务逻辑 / 数据处理推送(FCM / APNs / 鸿蒙)
HTTP 请求(HttpModule)设备信息 / 权限
路由跳转音视频 SDK
本地存储(StorageModule)第三方 SDK(支付、登录、IM)

两种通信方案怎么选

✅ expect / actual

KMP 原生方案,静态绑定,编译期确定实现

  • 简单的纯函数 API(getDeviceId / mkdir)
  • 一次性返回结果(无回调)
  • 有状态的小工具类(FileStorage)
// commonMain
expect fun currentPlatform(): String

// androidMain
actual fun currentPlatform() = "Android ${Build.VERSION.RELEASE}"

✅ Module(推荐)

框架封装的通信层,动态注册,运行时按 name 查找

  • 复杂能力(生命周期 / 回调 / 事件)
  • 需要双向通信(Kotlin ↔ 原生)
  • 第三方 SDK 接入(IM / 支付 / 推送)
acquireModule<CameraModule>(MODULE_NAME)
    .takePhoto { path -> updateUI(path) }

💡 简单粗暴:一次性调用 → expect/actual;持续/双向 → Module

expect/actual 5 种常见模式

① 纯函数

// commonMain
expect fun getCurrentTimeMillis(): Long

// androidMain
actual fun getCurrentTimeMillis(): Long = System.currentTimeMillis()

// iosMain
actual fun getCurrentTimeMillis(): Long =
    (NSDate().timeIntervalSince1970 * 1000).toLong()

② expect class(带状态)

// commonMain
expect class FileStorage(name: String) {
    fun put(key: String, value: String)
    fun get(key: String): String?
}

// androidMain
actual class FileStorage actual constructor(name: String) {
    private val sp = appContext.getSharedPreferences(name, MODE_PRIVATE)
    actual fun put(key: String, value: String) { sp.edit().putString(key, value).apply() }
    actual fun get(key: String): String? = sp.getString(key, null)
}

③ expect interface

// commonMain
expect interface Logger {
    fun debug(tag: String, msg: String)
    fun error(tag: String, msg: String, throwable: Throwable? = null)
}

④ expect object(单例)

// commonMain
expect object DeviceInfo {
    val osName: String
    val osVersion: String
    fun isTablet(): Boolean
}

⑤ 异步(协程)

// commonMain
expect suspend fun fetchLocation(): Pair<Double, Double>

// androidMain
actual suspend fun fetchLocation() = suspendCancellableCoroutine { cont ->
    LocationServices.getFusedLocationProviderClient(appContext)
        .lastLocation
        .addOnSuccessListener { cont.resume(it.latitude to it.longitude) }
}

Kuikly 内置 Module 速览

Module作用常用方法
HttpModule网络请求request(url, method, params, callback)
RouterModule路由跳转(含原生页面)openUrl("kuikly://...")
PageRouterModulePager 间跳转openPage(name, data)
SharedPreferencesModule本地 KV 存储setItem / getItem / removeItem
ToastModuleToast 提示showToast(text)
TimerModule定时器schedule(delay, interval, callback)
ImagePreviewModule大图预览preview(urls, index)
LogModule日志输出i / d / e
NotificationModule跨页面广播postNotification / addListener
CalendarModule日历addEvent / queryEvents

调用模板

// 姿势 1:每次都 acquire
acquireModule<ToastModule>(ToastModule.MODULE_NAME).showToast("hi")

// 姿势 2:缓存到字段(推荐)
private val toast by lazy {
    acquireModule<ToastModule>(ToastModule.MODULE_NAME)
}
toast.showToast("hi")

HttpModule 完整示例

val http = acquireModule<HttpModule>(HttpModule.MODULE_NAME)
http.request(
    url = "https://api.example.com/articles",
    method = "GET",
    params = JSONObject().apply { put("page", 1) },
    headers = JSONObject().apply { put("Authorization", "Bearer xxx") },
) { result ->
    val data = result?.optJSONArray("data") ?: return@request
    val list = (0 until data.length()).map { i ->
        val o = data.getJSONObject(i)
        Article(o.getString("id"), o.getString("title"))
    }
    articles.clear()
    articles.addAll(list)
}

自定义 Module · 4 步法

1
commonMain:定义 expect class
声明对外 API 形态,业务侧只看这一份接口
expect class CameraModule() : Module {
    fun takePhoto(onComplete: (String?) -> Unit)
    fun pickFromAlbum(maxCount: Int, onComplete: (List<String>) -> Unit)

    companion object {
        const val MODULE_NAME = "Camera"
    }
}
2
androidMain:写 actual 实现
用 Android 原生 API 实现,启动 Intent + 拿回 callback
actual class CameraModule actual constructor() : Module() {
    actual fun takePhoto(onComplete: (String?) -> Unit) {
        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        currentActivity.launchForResult(intent) { code, _ ->
            onComplete(if (code == OK) tempFile.absolutePath else null)
        }
    }
}
3
iosMain:写 actual 实现
用 UIKit 的 UIImagePickerController
actual class CameraModule actual constructor() : Module() {
    actual fun takePhoto(onComplete: (String?) -> Unit) {
        val picker = UIImagePickerController().apply {
            sourceType = UIImagePickerControllerSourceTypeCamera
            delegate = createDelegate(onComplete)
        }
        currentVC?.presentViewController(picker, true, null)
    }
}
4
Application 注册 + 业务调用
App 启动时注册到全局表,业务侧 acquire 调用
// Android Application
ModuleRegistry.register(CameraModule.MODULE_NAME) { CameraModule() }

// 业务 Pager
event {
    click {
        acquireModule<CameraModule>(CameraModule.MODULE_NAME)
            .takePhoto { path -> ctx.photoPath = path }
    }
}

Module 通信模拟器

点击下面的按钮,模拟 Kuikly 业务侧调 Module → 触发原生层动作 → 回调结果。下面会显示日志。

📞 Platform Bridge Demo

点击下方按钮触发各种原生能力调用…

通信日志(Bridge 流水)

// 等待 Module 调用…