一个KMP/CMP项目的组织结构和集成方式

本文主要介绍一下 KMP/CMP 项目的组织结构和集成方式,从而概览整个技术架构,对其有个总体上的认知。

对于如何创建 KMP 项目可以直接使用官方的工具:

kmp.jetbrains.com

具体的调试步骤和环境可以看官方文档:www.jetbrains.com/help/kotlin...

官方的项目结构文档:www.jetbrains.com/help/kotlin...

KMP/CMP 参考项目 Fread:github.com/0xZhangKe/F...

基本结构

首先 KMP 项目目前仍然使用 Gradle 构建,因此项目结构和我们的 Android 项目大体上类似,但是源码文件夹有所不同,毕竟是跨平台项目,肯定存在一些代码是各端独自实现的情况,那么肯定也需要不同的平台文件夹存放该平台特有的代码及实现。

所以一个 KMP 项目的模块结构如下:

目标平台有 Android 和 iOS,commonMain 则表示所有平台的共享代码。

commonCode

上图中的 commonMain 中存放的就是通用代码 ,这里的代码可以被所有平台使用,也会参与所有平台的构建流程。

其中的代码与平台无关,也不可以使用任何平台独有的 Api,只能使用其它模块或者支持 KMP 的依赖库中的 common code。当然目前也是好起来了,越来越多的三方库都在已经支持了 KMP,大部分情况下都有现成的库直接用。

对于不同的编译目标平台,commonMain 中的代码也会编译成不同的产物,这一切都是 Kotlin 编译器的功劳。

Targets

目标平台定义了 common code 将会被编译到哪些平台。对于不同的平台编译的产物也不同,比如 androidTarget 产物是 aar,iOS 产物是 framework

另外 Target 本身只是一个标识符,用于标识不同的目标平台,编译器将会根据这些目标平台的不同来编译出不同的产物。包括依赖包管理也是通过 Target 标识的。

我们可以通过在 gradle 的 kotlin 块内管理 target:

scss 复制代码
kotlin {
    jvm() // Declares a JVM target
    iosArm64() // Declares a target that corresponds to 64-bit iPhones
}

通过声明 jvmiosArm64目标,commonMain 中的代码将被编译为这些目标:

Target 还提供了 Gradle DSL 用于一些配置,其中包括通用配置和平台独有的配置。

通用配置

通用配置是指在任何 target 内都可以使用的配置。

name Description
platformType 目标平台类型,取值范围:jvmandroidJvmjswasmnativecommon
artifactsTaskName 构建此目标的最终产物的任务名称
components 用于设置 Gradle 发布内容的组件
compilerOptions 用于该目标的编译器选项,此声明会覆盖在顶层配置的任何 compilerOptions {} 设置。

此外还有一些平台独有的配置。

scss 复制代码
kotlin {
    wasmWasi {
        nodejs()
        binaries.executable()
    }
    js().browser {
        webpackTask { /* ... */ }
    }
    linuxX64 {
        binaries {
            executable {
                // Binary configuration.
            }
        }
    }
    androidTarget {
        publishLibraryVariants("release")
    }
}

具体配置可以看官方文档:www.jetbrains.com/help/kotlin...

Source sets

Kotlin Source set一组具有各自 Target、依赖项和编译器选项的源文件。它是在 KMP 项目中共享代码的主要方式。

Source Set 的特性:

  • 对于给定的项目具有唯一的名称。
  • 包含一组源文件和资源,通常存储在与源集同名的目录中。
  • 指定此 Source set 中的代码编译为的一组目标。这些目标会影响此源集中可用的语言结构和依赖项。
  • 定义其自己的依赖项和编译器选项。

Kotlin 提供了一系列预定义的源集。其中之一是 commonMain,它存在于所有 KMP 项目中,并编译为所有声明的目标。

在 Gradle 脚本中,我们可以通过 kotlin.sourceSets {} 块内的名称访问 Source set:

bash 复制代码
kotlin {
    sourceSets {
        commonMain {
            // Configure the commonMain source set
        }
    }
}

这里的 commonMain 对应的就是我们上面第一张截图中的那个 commonMain 文件夹,一般来说每个常规的 source set 都会有一个自己的代码文件夹,不同模块中的相同的 source set 文件夹中的代码可以互相调用,因为他们最终都会被编译到同一个产物内。

因此 source set 主要是用来管理该 Target 下的代码集合的,比如为该 Target 添加某些依赖库,设置源码路径等。

Platform-specific source sets

跟 Target 类似的是,source set 也提供了针对平台特有的设置。

在设置依赖库的时候我们经常会用到这个功能,比如我们现在需要一个视频播放器,但是没有找到适合业务的跨平台播放器组件,以此需要各端独自实现,那么我们在 Android 端需要依赖 media3,iOS 端需要依赖另外一个视频播放器的组件,那么就可以通过 source set 设置不同的依赖。

scss 复制代码
kotlin {
    sourceSets {
        androidMain {
            dependencies {
	            implementation(libs.media3)
            }
        }
        iosMain {
            dependencies {
                implementation(libs.ios.video.player)
            }
        }
    }
}

通过这样的方式,我们就可以针对不同的平台添加不同的依赖,这些依赖可以在各自的文件夹中依赖到,比如我们可以在 androidMain 文件夹中直接使用 media3,在 iosMain 中使用 iosVideoPlayer

Compilation to a specific target

在编译阶段,编译器会针对当前编译的 Target 选择使用不同的 source set,比如编译到 Android 平台,那么会选择 commonMain+androidMain 的代码和依赖库进行编译。

Intermediate source sets(中间源集)

除了上述我们说到的普通 source set 之外,还有一种叫做中间源集。

中间源集的概念其实很简单,比如我们现在的 KMP 项目同时支持了 Android、 iOS 和 macOS,现在我们需要一个用于生成 UUID 的函数,这个函数各端各自实现。但实际上 iOS 和 macOS 的实现是一致的,我们总不能同样的代码在 iosMainmacosMain 中各写一份吧,这样很容易出错。

于是 KMP 提供了中间源集用于解决这个问题,在上述情况中,我们可以直接将代码写在 appleMain 中,appleMain 可以使用 Apple 平台的能力,并且在 iosMainmacosMain 中都可以依赖到其中的代码。

完整的层次结构如下,其中彩色的部分可以理解为 Intermediate source sets.

测试

从上面的示例和截图中可以看出来,source set 源码文件夹都带了一个 Main/Test 后缀,Main 包含生产代码,Test 后缀就表示该 source set 的测试代码,他们之间会自动建立关联,Test 可以直接依赖到 Main 中的代码。

Kotlin 也提供了支持 KMP 的默认测试框架,其中包含@kotlin.Test注解以及各种断言方法,例如assertEqualsassertTrue

当然我们也可以像常规测试一样,为每个平台在其各自的 source set 中编写平台特定的测试。也可以为每个 source set 设置平台特定的依赖项,例如 JUnit 针对 JVM 和 XCTest iOS 的依赖项。要针对特定目标运行测试,请使用<targetName>Test任务。

依赖关系-dependsOn

dependsOn 是两个 source sets 之间的特定关系,通过 dependsOn 将整个 source sets 关系设置为一个树状结构,不过一般来说我们并不需要手动设置。

scss 复制代码
kotlin {
    // Targets declaration
    sourceSets {
        // Example of configuring the dependsOn relation
        iosArm64Main.dependsOn(commonMain)
    }
}

自定义 source sets

在某些情况下,我们可能需要在项目中设置自定义中间源集。假设有一个项目会编译到 JVM、JS 和 Linux,并且只想在 JVM 和 JS 之间共享一些源。在这种情况下,我们应该为这对目标找到一个特定的源集。

Kotlin 不会自动创建这样的源集,但我们可以使用 by creating 手动创建它:

scss 复制代码
kotlin {
    jvm()
    js()
    linuxX64()

    sourceSets {
        // Create a source set named "jvmAndJs"
        val jvmAndJsMain by creating {
            // ...
        }
    }
}

但此时我们自己创建的 jvmAndJsMain 的依赖关系是独立的,因为我们并未手动指定任何依赖关系,此时就需要使用上面说的 dependsOn 了。

scss 复制代码
kotlin {
    jvm()
    js()
    linuxX64()

    sourceSets {
        val jvmAndJsMain by creating {
            // Don't forget to add dependsOn to commonMain
            dependsOn(commonMain.get())
        }

        jvmMain {
            dependsOn(jvmAndJsMain)
        }

        jsMain {
            dependsOn(jvmAndJsMain)
        }
    }
}

现在,该项目的依赖关系如下:

依赖库的 KMP 兼容性

首先,我们可以通过如下方式给不同的 source set 添加不同的依赖库:

scss 复制代码
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
        }
        jvmMain.dependencies {
            implementation("com.google.guava:guava:32.1.2-jre")
        }
    }
}

被添加到 commonMain 中的依赖库会被传递到所有 Target 中,而添加到 jvmMain 中的依赖库只会在 jvmMain 文件夹中可见。

Kotlin 会把每个依赖关系解析为一组 source sets,而这组 source sets 中的每一个都必须和当前使用它的源集(consumer source set)在目标平台上兼容。

也就是说你添加的依赖库的 target 列表必须包含你的项目的 target 列表,缺少一个都会被视为不兼容。

举例来说,如果你当前的项目 target 中包含了 jsMain,但是你打算添加的依赖库的 target 中未包含 jsMain,那么就会被视为不兼容,那么就无法使用这个库。

对齐不同 source sets 依赖库版本

Kotlin Multiplatform 的 common 源集(commonMain)会被编译多次(为不同 Target),因此依赖库版本必须一致。

为什么要对齐版本(align versions)?

  • commonMain 会参与多个目标(如 Android、iOS 等)的构建。
  • 如果在不同目标中,commonMain 使用的依赖版本不一致,会导致编译出来的 .klib 不一致,从而出问题。
  • Kotlin Gradle 插件会自动统一这些版本,确保所有使用 commonMain 的地方依赖的库版本相同

举个例子:

首先 commonMain 中声明了如下依赖:

scss 复制代码
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")

同时 androidMain 中又声明了如下依赖:

scss 复制代码
implementation("androidx.navigation:navigation-compose:2.7.7")

但是这个 navigation 库内部依赖了 coroutines 1.8.0

那么问题来了:

  • androidMaincommonMain 是要一起编译的
  • Gradle 会发现冲突(1.7.3 vs 1.8.0),它会选择更高的版本:1.8.0
  • 所以 commonMain 也会用 1.8.0 的 coroutines
  • iOS 同样也使用了 commonMain 的代码
  • 所以 Gradle 会自动把 coroutines 1.8.0 应用到 iosMain 等所有平台

Kotlin Multiplatform 项目中,只要有任何 source set 引入了较高版本的共享依赖,Gradle 就会自动将这个版本同步到所有需要它的 source set 上,以确保生成的 common 代码在所有平台上都是一致的。

iOS 集成

对于 iOS 平台来说,KMP 项目的代码会直接编译成 framework 产物给到 iOS 项目集成继续参与 Xcode 的编译流程。那么具体来说是如何集成的呢?比如我们直接 clone 一个 KMP/CMP 项目到本地然后直接用 Xcode 打开可以直接运行,那么 Kotlin 代码如何开始编译?

Xcode 构建阶段(Build Phase)可以添加一些自定义脚本,对于一个默认支持了 KMP 的 Xcode 项目来说,构建阶段会被添加一行自定义脚本,该脚本用于执行一个 gradle 命令以此生成 Xcode 需要的 framework 文件。

我们可以打开 Xcode 项目视图中的 Build Phase 看到这个脚本:

其实我们也可以自己运行这个脚本,运行完成后会看到 build 文件夹下生成了 framework 文件。

好了现在 frameworks 已经生成了,剩下的就是如何把这这个文件添加到 Xcode 项目的依赖中去。

我们接着打开 Xcode 的项目视图中的 Build Settings 往下找到 Search Paths 栏,可以看到 gradle build 目录中的 frameworks 路径已经被添加到项目中了。

这样整个 Xcode 的构建环节就搞明白了,首先通过自定义编译脚本调用 gradle 编译出 framework 文件,并且预先将该路径添加到了 Xcode 构建依赖中,然后 Xcode 内就可以把 KMP 模块当作一个普通的 framework 依赖库使用了。

当然这些配置也可以根据自己的项目需要修改,具体的配置也可以在 project.pbxproj 文件内看到。

现有项目集成 KMP/CMP

首选还是直接看官方文档:developer.android.com/kotlin/mult...

因为 Kotlin for iOS 代码最终会编译成 framework,所以如果现有的 Xcode 项目如果想集成 KMP 其实也可以不必设置编译脚本,也不必对现有项目做其他的更改,只需要像依赖普通的库一样依赖这个 Kotlin 的产物即可。

相关推荐
良木林1 小时前
JavaScript书写基础和基本数据类型
开发语言·前端·javascript
梁同学与Android2 小时前
Android ---【内存优化】常见的内存泄露以及解决方案
android·java·内存泄漏
人生游戏牛马NPC1号5 小时前
学习 Flutter (三):玩安卓项目实战 - 上
android·学习·flutter
小馬佩德罗7 小时前
Android系统的问题分析笔记 - Android上的调试方式 debuggerd
android·调试
brzhang7 小时前
我操,终于有人把 AI 大佬们 PUA 程序员的套路给讲明白了!
前端·后端·架构
止观止8 小时前
React虚拟DOM的进化之路
前端·react.js·前端框架·reactjs·react
goms8 小时前
前端项目集成lint-staged
前端·vue·lint-staged
谢尔登8 小时前
【React Natve】NetworkError 和 TouchableOpacity 组件
前端·react.js·前端框架
清霜之辰8 小时前
安卓基于 FirebaseAuth 实现 google 登录
android·google·auth·firebase
GitLqr8 小时前
数码洞察 | Apple VS DMA、三星新品、Android 16KB Page Size
android·ios·samsung