7.3.5 C、Objective-C 和 Swift 库的导入

原文链接: https://kotlinlang.org/docs/native-lib-import-stability.html

7.3.5 C、Objective-C 和 Swift 库的导入

Kotlin/Native 提供了导入 C 和 Objective-C 库的能力。你还可以通过变通方式在 Kotlin/Native 项目中导入纯 Swift 库。

C 和 Objective-C 库导入的稳定性

Beta

对 C 和 Objective-C 库导入的支持目前处于 Beta 阶段。

处于 Beta 状态的主要原因之一是:使用 C 和 Objective-C 库可能会影响你的代码与不同 Kotlin 版本、依赖项和 Xcode 之间的兼容性。本指南列出了实践中经常出现的兼容性问题、只在某些情况下出现的问题,以及假设性的潜在问题。

为简化说明,我们把 C 和 Objective-C 库(这里称为_原生库_)分为:

  • 平台库,Kotlin 默认提供,用于访问各平台上的“系统”原生库。
  • 第三方库,其他所有需要在 Kotlin 中额外配置才能使用的原生库。

这两类原生库的兼容性细节有所不同。

平台库

平台库随 Kotlin/Native 编译器一起提供。因此,在项目中使用不同的 Kotlin 版本会得到不同版本的平台库。对于 Apple 目标(例如 iOS),平台库是基于该编译器版本所支持的 Xcode 版本生成的。

随 Xcode SDK 提供的原生库 API 会随每个 Xcode 版本而变化。即使这些变化在原生语言层面是源码和二进制兼容的,由于互操作的实现方式,它们对 Kotlin 也可能变成破坏性的。

因此,更新项目中的 Kotlin 版本可能会带来平台库的破坏性变更。这在两种情况下会有影响:

  • 平台库中存在影响项目源代码编译的源码级破坏性变更。通常这很容易修复。
  • 平台库中存在影响你某些依赖的二进制级破坏性变更。通常没有简单的变通办法,你需要等待库开发者在其一侧修复,例如更新 Kotlin 版本。

注意: 这类二进制不兼容会表现为链接警告和运行时异常。如果你希望在编译期就检测它们,可以使用 -Xpartial-linkage-loglevel=ERROR 编译器选项把警告提升为错误。

当 JetBrains 团队更新用于生成平台库的 Xcode 版本时,会尽合理努力避免平台库中的破坏性变更。只要可能出现破坏性变更,团队都会进行影响分析,然后决定忽略某个特定变更(因为受影响的 API 不常被使用)或应用针对性的修复。

平台库出现破坏性变更的另一个潜在原因是把原生 API 转换为 Kotlin 的算法发生了变化。在这种情况下,JetBrains 团队同样会尽合理努力避免破坏性变更。

使用平台库中新的 Objective-C 类

Kotlin 编译器不会阻止你使用在部署目标上不可用的 Objective-C 类。

例如,如果你的部署目标是 iOS 17.0,而你使用了只在 iOS 18.0 中出现的类,编译器不会给出警告,而你的应用可能会在 iOS 17.0 的设备上启动时崩溃。此外,即使执行流程从未到达这些使用处,这种崩溃也会发生,因此用版本检查把它们保护起来是不够的。

更多细节请参阅强链接。

第三方库

除了系统平台库之外,Kotlin/Native 还允许导入第三方原生库。例如,你可以使用 CocoaPods 集成,或设置 cinterops 配置。

导入 Xcode 版本不匹配的库

导入第三方原生库可能会导致与不同 Xcode 版本的兼容性问题。

处理原生库时,编译器通常使用本地安装的 Xcode 中的头文件,因为几乎所有原生库头文件都会导入来自 Xcode 的“标准”头文件(例如 stdint.h)。

这就是 Xcode 版本会影响把原生库导入 Kotlin 的原因。这也是在使用第三方原生库时,从非 Mac 宿主交叉编译 Apple 目标仍然不可能的原因之一。

每个 Kotlin 版本最多与一个 Xcode 版本最兼容。这是推荐版本,它针对相应的 Kotlin 版本经过了最多的测试。你可以在兼容性表中查看与特定 Xcode 版本的兼容性。

使用更新或更旧的 Xcode 版本通常也是可行的,但可能会带来问题,通常影响第三方原生库的导入。

使用比推荐版本更新的 Xcode 版本可能会破坏某些 Kotlin 功能。第三方原生库的导入受此影响最大。在不受支持的 Xcode 版本下,它常常完全无法工作。

通常来说,Kotlin 在较旧的 Xcode 版本上工作良好。偶尔会出现问题,最常导致:

  • Kotlin API 引用了不存在的类型,例如 KT-71694。
  • 来自系统库的类型被包含进原生库的 Kotlin API 中。在这种情况下,项目可以成功编译,但会有系统原生类型被添加到你的原生库包中。例如,之后你可能会在 IDE 自动补全中意外看到这个类型。

如果你的 Kotlin 库在较旧的 Xcode 版本下能成功编译,那么发布它是安全的,除非你在 Kotlin 库 API 中使用了第三方库的类型。

使用传递性的第三方原生依赖

当你项目中的某个 Kotlin 库在其实现中导入了第三方原生库时,你的项目也会获得对该原生库的访问权。这是因为 Kotlin/Native 不区分 api 和 implementation 依赖类型,所以原生库最终总是成为 api 依赖。

使用这样的传递性原生依赖更容易出现兼容性问题。例如,Kotlin 库开发者所做的一处改动可能会使该原生库的 Kotlin 表示不再兼容,从而导致你在更新该 Kotlin 库时出现兼容性问题。

因此,与其依赖传递依赖,不如直接配置与同一个原生库的互操作。为此,请为该原生库使用另一个包名,类似于使用自定义包名来避免兼容性问题。

在库 API 中使用原生类型

如果你发布一个 Kotlin 库,请小心库 API 中的原生类型。这些用法预计在未来会因修复兼容性及其他问题而失效,从而影响你的库用户。

在某些情况下,在库 API 中使用原生类型是必要的,因为这是该库用途所需,例如某个 Kotlin 库基本上是在为原生库提供扩展。如果你的情况不是这样,请避免或限制在库 API 中使用原生类型。

这条建议只适用于库 API 中对原生类型的使用,与应用代码无关。它也不适用于库的实现,例如:

1
2
3
4
5
6
7
// 格外小心!库 API 中使用了原生类型:
public fun createUIView(): UIView
public fun handleThirdPartyNativeType(c: ThirdPartyNativeType)

// 照常小心;库 API 中没有使用原生类型:
internal fun createUIViewController(): UIViewController
public fun getDate(): String = NSDate().toString()

发布使用第三方库的库

如果你发布的 Kotlin 库使用了第三方原生库,可以采取以下几项措施来避免兼容性问题。

使用自定义包名

为第三方原生库使用自定义包名可能有助于避免兼容性问题。

当原生库被导入到 Kotlin 时,它会得到一个 Kotlin 包名。如果该名称不唯一,库用户可能会遇到冲突。例如,如果同一个原生库在用户项目的其他地方或其他依赖中以相同的包名被导入,这两处使用就会冲突。

在这种情况下,编译可能会因 Linking globals named '...': symbol multiply defined! 错误而失败。不过,也可能出现其他错误,甚至成功编译。

要为第三方原生库使用自定义名称:

  • 通过 CocoaPods 集成导入原生库时,请在 Gradle 构建脚本的 pod {} 块中使用 packageName 属性。
  • 使用 cinterops 配置导入原生库时,请在该配置块中使用 packageName 属性。
检查与较旧 Kotlin 版本的兼容性

发布 Kotlin 库时,使用第三方原生库可能会影响该库与其他 Kotlin 版本的兼容性,具体来说:

  • Kotlin Multiplatform 库不保证向前兼容(即较旧的编译器可以使用由较新编译器编译的库)。

在实践中,有时是可以的;不过,使用原生库可能会进一步限制向前兼容性。

  • Kotlin Multiplatform 库提供向后兼容(即较新的编译器可以使用由较旧版本生成的库)。

在 Kotlin 库中使用原生库通常不应影响其向后兼容性。但它会增加出现影响兼容性的更多编译器缺陷的可能性。

避免内嵌静态库

导入原生库时,可以通过 -staticLibrary 编译器选项或 .def 文件中的 staticLibraries 属性来包含关联的静态库(.a 文件)。在这种情况下,你的库用户无需处理原生依赖和链接器选项。

不过,无法以任何方式配置所包含静态库的使用:既不能排除它,也不能替换它。因此,用户无法解决与其他包含同一静态库的 Kotlin 库之间的潜在冲突,也无法调整它的版本。

原生库支持的演进

目前,在 Kotlin 项目中使用 C 和 Objective-C 可能导致兼容性问题,本指南列出了其中一部分。为修复这些问题,未来可能需要一些破坏性变更,而这本身又会加重兼容性问题。

Swift 库导入

Kotlin/Native 不支持直接导入纯 Swift 库。不过有几种变通办法。

一种方式是手动进行 Objective-C 桥接。使用这种方式时,你需要编写自定义 Objective-C 包装器和 .def 文件,并通过 cinterop 消费这些包装器。

不过在大多数情况下,我们推荐使用_反向导入_方式:在 Kotlin 一侧定义期望的行为,在 Swift 一侧实现实际功能,再把它传回 Kotlin。

你可以通过以下方式之一来定义期望的部分:

  • 创建一个接口。对于多个函数和可测试性而言,基于接口的方式扩展性更好。
  • 使用 Swift 闭包。它们非常适合快速原型,但这种方式有局限 —— 例如它无法保持状态。
  • 使用 Swift 导出。你可以直接在 Swift 中实现 Kotlin 接口,并把 Swift 对象传回 Kotlin,而无需 Objective-C 桥接。

考虑这个把 CryptoKit Swift 库反向导入 Kotlin 项目的示例:

接口

  1. 在 Kotlin 一侧,创建一个接口来描述 Kotlin 对 Swift 的期望:
1
2
3
4
   // CryptoProvider.kt
   interface CryptoProvider {
       fun hashMD5(input: String): String
   }
  1. 在 Kotlin 一侧,把来自 MainViewController 的平台特有实现作为参数传给 App 可组合函数,并在需要的地方使用它:
1
2
3
4
5
6
7
   // App.kt
   @Composable
   fun App(cryptoProvider: CryptoProvider) {
       // 在 UI 中的用法示例
       val hashed = cryptoProvider.hashMD5("Hello, world!")
       androidx.compose.material3.Text("Compose: $hashed")
   }
1
2
3
4
   // MainViewController.kt
   fun MainViewController(cryptoProvider: CryptoProvider) = ComposeUIViewController {
       App(cryptoProvider)
   }
  1. 在 Swift 一侧,使用纯 Swift 库 CryptoKit 实现 MD5 哈希功能:
1
2
3
4
5
6
7
8
9
   // iosApp/ContentView.swift
   import CryptoKit

   class IosCryptoProvider: CryptoProvider {
       func hashMD5(input: String) -> String {
           guard let data = input.data(using: .utf8) else { return "failed" }
           return Insecure.MD5.hash(data: data).description
       }
   }
  1. 把 Swift 实现传给 Kotlin 组件:
1
2
3
4
5
6
7
8
9
   // iosApp/ContentView.swift
   struct ComposeView: UIViewControllerRepresentable {
       func makeUIViewController(context: Context) -> UIViewController {
           // 把 Swift 实现注入到 Kotlin UI 入口点
           MainViewControllerKt.MainViewController(cryptoProvider: IosCryptoProvider())
       }

       func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
   }

Swift 闭包

  1. 在 Kotlin 一侧,声明一个函数参数并在需要的地方使用它:
1
2
3
4
5
6
7
    // App.kt
    @Composable
    fun App(md5Hasher: (String) -> String) {
        // 在 UI 中的用法示例
        val hashed = md5Hasher("Hello, world!")
        androidx.compose.material3.Text("Compose: $hashed")
    }
1
2
3
4
    // MainViewController.kt
    fun MainViewController(md5Hasher: (String) -> String) = ComposeUIViewController {
        App(md5Hasher)
    }
  1. 在 Swift 一侧,用 CryptoKit 库构建 MD5 哈希器,并把它作为闭包传入:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    // iosApp/ContentView.swift
    import CryptoKit
    import SwiftUI

    struct ComposeView: UIViewControllerRepresentable {
        func makeUIViewController(context: Context) -> UIViewController {
            MainViewControllerKt.MainViewController(md5Hasher: { input in
                guard let data = input.data(using: .utf8) else { return "failed" }
                return Insecure.MD5.hash(data: data).description
            })
        }

        func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
    }

Swift 导出

  1. 在 Kotlin 一侧,声明一个接口、一个接受该接口的函数,以及一个 Swift 实现可以继承的 open 基类:
1
2
3
4
5
6
7
8
   // CryptoProvider.kt
   interface CryptoProvider {
       fun hashMD5(input: String): String
   }

   fun processHash(provider: CryptoProvider, input: String): String = provider.hashMD5(input)

   open class SwiftBase
  1. 在 Swift 一侧,继承导出的 SwiftBase 类,使用纯 Swift 的 CryptoKit 库实现该接口,并把该对象传回 Kotlin:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
   // iosApp/ContentView.swift
   import CryptoKit

   final class IosCryptoProvider: SwiftBase, CryptoProvider {
       func hashMD5(input: String) -> String {
           guard let data = input.data(using: .utf8) else { return "failed" }
           return Insecure.MD5.hash(data: data).description
       }
   }

   let provider = IosCryptoProvider()

   // 调用 Kotlin 函数,后者会回调 Swift 中的 hashMD5()
   print(processHash(provider: provider, input: "Hello, world!"))

当 Kotlin 接收到该 Swift 对象时,会把它当作普通 Kotlin 接口的实现来对待,直接调用 Swift 代码。

在更复杂的项目中,使用依赖注入把 Swift 实现传回 Kotlin 会更方便。更多信息请参阅依赖注入框架,或查阅 Koin 框架文档。