KMM(Kotlin Multiplatform Mobile)入门(四)与依赖库交互

KMM 的依赖类型

KMM 的依赖根据平台分为三类,分别是 Common 依赖、Android 依赖、iOS 依赖,其中 Common 依赖顾明思议,是用于通用逻辑的,这种依赖只能使用基于最标准的 Kotlin 底层能力(不可以耦合 JVM、JS)构建

例如,在 Android 端上比较常用的 Kotlin Reflect、OkHTTP、GSON、Fresco,在 iOS 上比较常用的 AFNetworking、YYModel 之类的库,都不能直接用在 KMM 模块的 Common 代码库中

那么,上面说的这些库能不能让 KMM 继续使用?或者有没有直接可以使用的库?。。。

答案当然是肯定的!

使用 Common 或 Android 依赖

Common 依赖

首先是需要找到能够使用的第三方库,这里推荐一些比较优秀、可以直接使用的 KMM 库,不过这些库可能还需要在各平台的代码库中添加依赖项,以便实现差异化功能或者平台耦合能力

官方 JSON 解析库:https://github.com/Kotlin/kotlinx.serialization

HTTP 请求库:https://github.com/ktorio/ktor

SQLite 操作库:https://github.com/cashapp/sqldelight

这些库的依赖也非常简单,和普通的 Gradle 依赖类似,只需要在 KMM 模块根目录的 build.gradle.kts 文件中添加即可,如下图所示,在 commonMain 变量后面的闭包中,新建一个 dependencies 闭包,即可以按照常规的 Gradle 依赖形式,添加 ktor 的 Common 依赖

在 Sync 成功后,便可以使用 Common 能力了

Android 依赖

Android 依赖多用于为实现在 Common 中使用 expect 关键字修饰的方法,提供 Android 平台基础能力支持,比如:网络、图片加载等

Android 依赖添加相对比较简单,且限制较少,唯一需要考虑的就是与现有项目集成时的相互依赖关系,或许,对于现有 App 的架构,为合理利用各类组件或能力,需要参考下图所示的模式进行架构调整

由于 KMM 模块可能是放在一个 Android 工程当中的,所以对依赖现有模块会比较简单

依赖一个现有模块

如下图所示,首先我的工程中可能存在一个公共库的模块,假设它叫:mylib,其中还包含一些 Android 平台的工具类

如果我需要在 KMM 模块的 Android 实现中引入 mylib 库,同时,我还需要调用 OkHTTP,同样在 KMM 模块根目录的 build.gradle.kts 文件中按以下配置添加依赖即可,与 Common 添加依赖形式类似

Sync 成功以后,即可在 Android 实现的代码中调用我们需要的模块了

iOS 依赖

为 KMM 模块的 iOS 实现添加依赖相对比较麻烦,是这篇文章的重点,由于 iOS 程序并不在 JVM 这种 Runtime 上运行,且 iOS 需要考虑到 Objective-C 和 Swift 之间的调用问题,所以必须以 Kotlin Native 的形式进行交互(PS:其中的核心是 Kotlin Native 底层的 cinterop)

基本原理

Kotlin Native 内部使用 cinterop 来对 Apple Framework 进行扫描,根据 Framework 中的 Headers(.h 文件)获取可以调用的类、方法、变量、常量及他们对应的类型,最终生成 klib 文件

klib 文件中包含着针对不同 CPU 架构所编译的二进制文件,以及可供 Kotlin Native 调用的 knm 文件,knm 文件类似 Jar 包中的 .class 文件,是被编译后的 Kotlin 代码,内部将 cinterop 扫描出来的 Objective-C 内容转换成了 Kotlin 对应的内容,以便 IDE 可以进行索引,最终在 KMM 模块中使用 Kotlin 代码进行调用

之所以 KMM(或者说 Kotlin Native) 可以调用 iOS SDK (或者 macOS SDK)中 UIKit、Foundation 等模块中的类、方法等内容,也都是利用了上面的原理和流程实现的

注意:使用纯 Swift 开发的库,目前还不支持与 KMM 进行直接交互,但可以利用 Bridge,先暴露给 Objective-C,只要在 Objective-C 中可以调用,则可以与 KMM 进行交互

而 iOS 的项目终究会使用 LLVM 进行编译,所以还有很关键的一点就是:保证主工程与各类库之间的 Compile、Link 过程正确

那么如果是一个现有的第三方库或工程模块,通常可能会以以下三种形式使用:

  • CocoaPods
  • Framework
  • 源码

因为使用源码集成一个第三方库比较复杂,下面我们以 AFNetworking 为例,依次来介绍 KMM 模块如何与 CocoaPods 和 Framework 的依赖进行交互,最终目标则是在 KMM 模块的 iOS 实现中能够成功调用 AFNetworking 的能力,并在 iOS App 的 Xcode 工程中成功编译,最终能够正常运行在 iOS 模拟器或真机上

与 CocoaPods 依赖进行交互

注意:在实际开发中,一些团队可能会参考 CocoaPods 开发自己的依赖管理工具,则不能使用下面的方法配置依赖,但可以根据 Kotlin Native 插件的源码开发合适的插件

确保 CocoaPods 配置正确

如果想要使用 CocoaPods 管理依赖,首先必须让 KMM 模块以 CocoaPods 的形式创建,那么在创建模块的时候,或新建工程的时候,需要选中以 CocoaPod 进行依赖管理,否则后续再想使用 CocoaPods 会比较麻烦(需要手动为 KMM 创建 podspec 文件,并进行配置)如下图所示:

如果使用 CocoaPods 正确创建 KMM 模块以后,会在 KMM 模块的根目录中自动生成一个以模块名命名的 podspec 文件(与 build.gradle.kts 文件同级),其中使用 CocoaPods 规范对 KMM 模块进行描述和构建配置

而在之前的章节中提到的 KMM 模块根目录 build.gradle.kts 文件,结构和内容也会略有不同,首先是头部的插件,会多了对 CocoaPods 的引用,这是 Kotlin Native 为在 Gradle 中操作 CocoaPods 而开发的 Gradle 插件

build.gradle.kts 的 kotlin 闭包中,也会多出 cocoapods 子闭包,用于进行 KMM 模块的 CocoaPods 配置

另外,就是在 build.gradle.kts 文件的底部,也不会自动生成 packForXcode 任务的代码,完全使用 CocoaPods 来进行构建

此时,KMM 模块的 CocoaPods 基本配置就可以说是基本正确了

添加 CocoaPods 依赖

如果我们需要的依赖是 CocoaPods 库中的,例如:AFNetworking,则比较简单

只需要在 build.gradle.kts 文件的 kotlin 闭包中,增加一个 cocoapods 子闭包,然后进行 Sync

1
2
3
4
5
6
7
8
9
kotlin
// ...
cocoapods {
pod("AFNetworking") {
// 这个闭包可选,可以配置一些最低版本等,按 pods 语法配置即可
version = "~> 4.0.1"
}
}
}

Sync 完成以后,即可以在 iosMain 中调用 AFNetworking 的各项能力了,此时,在 Kotlin 代码中调用的 AFNetworking 包,将自动被加上 cocoapods 前缀

与 Apple Framework 依赖进行交互

如果我们的依赖是以 Framework 的形式提供的,或者我们的 iOS 功能不能使用 CocoaPods 进行依赖管理,此时,就需要 KMM 与 Apple Framework 进行交互

生成 Framework

首先,我们需要获取到所需库的 Framework 文件,以 AFNetworking 为例,Framework 文件可手动编译生成,如果是自己项目中的公共库,也可以先编译成 Framework

可以将 Framework 编译成多指令集(默认只会编译模拟器所需的 x86_64)的形式,以便后续构建时不用切换 Framework

从 Xcode 项目的 DerivedData 目录中,可以获取到生成的 Framework 文件,我们可以将其拷贝到 KMM 项目的某个目录(方便配置路径即可)当中存放,后续会有使用

注意:如果需要引用已有项目中,已经生成好的 Framework 文件,建议将 iOS 工程放在与 Android 工程目录同一目录当中,以便配置相对路径,免去了拷贝的过程,同时也不需要维护两份 Framework 文件了

添加 def 文件

由于没有 CocoaPods 来进行模块管理,我们就需要手动对 Framework 进行定义,具体表现就是 .def 文件以及 Gradle 中的配置项

如上图所示,通常,def 文件都会存放在 KMM 模块的 src/nativeInterop/cinterop 目录中,只要放在此目录下,无需额外配置,Kotlin 会进行自动扫描

为保证后续编译顺利,建议 def 文件的命名及其中的 module、package 全部统一,如下面代码所示:

1
2
3
4
5
# AFNetworking.def
# language 一定要配成 Objective-C,否则默认为 C 语言
language = Objective-C
modules = AFNetworking
package = AFNetworking

如果有多个 Framework 需要依赖,则可以定义多个 def 文件

配置 Gradle 编译参数

完成 def 文件的创建后,需要在 KMM 模块根目录的 build.gradle.kts 文件中完成 Framework 相关的编译配置

首先,找到 iosTarget("ios") 方法后面的闭包

在这个闭包中,与 binaries 闭包同级,添加一个 compilations.getByName("main") 闭包,如下面代码所示:

1
2
3
4
5
6
7
8
9
iosTarget("ios") {
binaries {
// ...
}

compilations.getByName("main") {
// ...
}
}

接着,在新建的闭包中,添加一个常量,这个常量命名,建议和上面定义的 def 文件名、module、package一致,以 Kotlin 语法 by 的形式,使用 cinterops.creating 创建一个闭包,其中 compilerOpts 方法即为配置该 Framework 依赖的编译参数,主要是指定 Framework 文件的绝对路径

其中,第一个参数使用 -framework 不变,第二个参数为 Framework 的名称(不需要添加后缀),第三个参数内容必须以 -F 开头,后面拼接 Framework 文件所在目录的绝对路径(注意是目录,不要具体到 Framework 文件),这里绝对路径可以合理利用 Gradle 的 rootProject.projectDir 变量进行拼接,增强项目的通用性,可参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
iosTarget("ios") {
// ...
compilations.getByName("main") {
val AFNetworking by cinterops.creating {
compilerOpts("-framework", "AFNetworking", "-F${rootProject.projectDir}/shared/framework/")
}
// val OtherLib by cinterops.creating {
// compilerOpts("-framework", "OtherLib", "-F${rootProject.projectDir}/shared/framework/")
// }
// ...
}
}

如果有多个 Framework 需要依赖,则可以使用 cinterops.creating 创建多个 Kotlin 变量,每个闭包内,都需要指定好上面的路径参数

完成编译参数的配置后,还需要配置必须的链接参数,位置在 binaries 闭包内,与 framework 闭包平级,添加一个 all 闭包,其中,使用 linkerOpts 方法即为对链接参数的配置,这里的参数内容与上面所说的 compilerOpts 方法一致,如果需要依赖其他 Framework,则需要在 all 闭包内调用多次 linkerOpts 来进行配置,完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
iosTarget("ios") {
binaries {
framework {
baseName = "shared"
}

all {
linkerOpts("-framework", "AFNetworking", "-F${rootProject.projectDir}/shared/framework/")
// linkerOpts("-framework", "OtherLib", "-F${rootProject.projectDir}/shared/framework/")
}
}

compilations.getByName("main") {
val AFNetworking by cinterops.creating {
compilerOpts("-framework", "AFNetworking", "-F${rootProject.projectDir}/shared/framework/")
}
}
}

生成 klib 并在 Kotlin 中调用

完成以上配置后,在命令行中,执行./gradlew shared:packForXcode 触发对 iOS 的构建,可以看到构建过程中多出了对 AFNetworking 进行 cinterop 的 Task

如果编译完成后,没有任何报错,且在下图所示的路径中,生成了带有 AFNetworking 字样的 klib 文件,那么恭喜你,与 Apple Framework 的交互成功了 99%!

在 iosMain 目录中新建一个 Kotlin 文件进行测试,可以看到可以成功调用

常见问题及解决思路

  • ld 报错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    The /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld command returned non-zero exit code: 1.
    output:
    Undefined symbols for architecture x86_64:
    "_OBJC_CLASS_$_AFHTTPSessionManager", referenced from:
    objc-class-ref in result.o
    ld: symbol(s) not found for architecture x86_64

    FAILURE: Build failed with an exception.

    * What went wrong:
    Execution failed for task ':shared:linkDebugFrameworkIos'.
    > Compilation finished with errors

    其中 ld: symbol(s) not found for architecture x86_64 是关键,ld 是 Link 时调用的,如果出现问题,一般是 linkerOpts 配置问题,AFNetworking 并没有依赖其他的库,如果 Framework 还依赖了其他库,如:libstdc++CoreTelephony 等,需要将所有依赖的库都加入在 linkerOpts 当中

  • 依赖项排查

    如果遇到上面的 Link 错误,可能需要分析 Framework 的依赖,这时可能需要分析 Framework 的源码,我们所引用的 Framework,它自己依赖的任何第三方 Framework、系统 Framework、标准 C/C++ 库等,都需要添加配置 linkerOpts

    如果 Framework 使用 CocoaPods 管理依赖,可以分析 Podspec 文件中的任何 dependency、dependencies、framworks 等字样

    如果 Framework 不使用 CocoaPods,那么则需要分析 Xcode 工程中的依赖,如图所示

    另外,在 linkerOpts 中,如果引用系统 Framework、标准 C/C++ 库,则不需要配置路径,下面举两个常用的例子:

    1
    2
    3
    4
    // 引入系统的 Network.framework
    linkerOpts("-framework", "Network")
    // 引入 libc++.tbd,前缀使用 -l
    linkerOpts("-lc++")