RevenueCat KMP SDK:用Gradle构建Swift包替代Coc
Wrapping a Swift Package in a Gradle Module: How RevenueCat's KMP SDK Builds purchases-ios Without CocoaPods
独立开发者做跨平台SDK时,这是解决Swift/iOS依赖管理的完整工程方案,直接给出了替代CocoaPods的Gradle实现细节和避坑指南。
Kotlin/Native talks to Apple code through cinterop, the tool that generates Kotlin bindings from C and Objective-C headers, and for most of Kotlin Multiplatform's history, the standard way to hand it an iOS dependency has been the CocoaPods plugin. Without CocoaPods, a Swift package leaves cinterop with nothing to read: Swift has no headers, and Gradle can't build a Swift package by itself.
Kotlin/Native 通过 cinterop 与 Apple 代码进行交互,cinterop 是一个从 C 和 Objective-C 头文件生成 Kotlin 绑定的工具。在 Kotlin Multiplatform 的大部分历史中,向其传递 iOS 依赖的标准方式是使用 CocoaPods 插件。如果没有 CocoaPods,Swift 包会让 cinterop 无头文件可读:Swift 没有头文件,而 Gradle 无法自行构建 Swift 包。
RevenueCat's Kotlin Multiplatform SDK took that route anyway in 3.0.0. It dropped CocoaPods and now compiles the purchases-ios Swift package from source inside two Gradle modules, kn-core and kn-ui, that contain almost no Kotlin. The question worth answering is what those modules actually do: how a Swift package becomes something the Kotlin/Native compiler can bind, cache, and publish.
RevenueCat 的 Kotlin Multiplatform SDK 在 3.0.0 版本中依然采取了这一路线。它放弃了 CocoaPods,现在从源代码编译 purchases-ios Swift 包,该过程发生在两个 Gradle 模块 kn-core 和 kn-ui 内部,这两个模块几乎不包含任何 Kotlin 代码。值得探讨的问题是这些模块实际上做了什么:即一个 Swift 包如何变成 Kotlin/Native 编译器可以绑定、缓存和发布的对象。
In this article, you'll dive deep into how the SDK wraps a Swift package in a Gradle module, exploring the swiftPackage() DSL, swift package describe, the Swift build task, the source hash in the .def file, the facade module, the release build configuration, resource bundling, type sharing across modules, and the shims around cinterop.
在本文中,你将深入探讨 SDK 如何将 Swift 包封装在 Gradle 模块中,涉及 swiftPackage() DSL、swift package describe 命令、Swift 构建任务、.def 文件中的源哈希值、外观模块(facade module)、发布版构建配置、资源捆绑、跨模块的类型共享以及围绕 cinterop 的适配层(shims)。
The fundamental problem: cinterop reads Objective-C, and Gradle doesn't build Swift
根本问题:cinterop 读取的是 Objective-C,而 Gradle 不构建 Swift
The cinterop tool takes its instructions from a definition file, a .def, that tells it what to parse and what to link. For an Objective-C library, three entries carry most of the weight:
cinterop 工具从其定义文件(.def)中获取指令,该文件告诉它要解析什么以及链接什么。对于 Objective-C 库,以下三个条目承担了主要作用:
language = Objective-C
modules = MyLibrary
staticLibraries = libMyLibrary.alanguage switches the parser from C to Objective-C, and modules names a Clang module, a named set of headers, for cinterop to read. staticLibraries names a static library, an .a archive of compiled object files, that cinterop copies into the klib it produces. A klib is Kotlin/Native's library format, the counterpart of a JAR on the JVM, and as the Kotlin documentation puts it, "When using a klib like this in your program, the library is linked automatically."
language 将解析器从 C 切换到 Objective-C,modules 命名一个 Clang 模块(一组命名的头文件),供 cinterop 读取。staticLibraries 命名一个静态库(编译后的目标文件的 .a 归档),cinterop 会将其复制到其生成的 klib 中。klib 是 Kotlin/Native 的库格式,相当于 JVM 上的 JAR;正如 Kotlin 文档所述:“在你的程序中使用这样的 klib 时,库会自动链接。”
A Swift package provides none of these. Swift has no header files, so there's nothing to parse until you ask the compiler to generate an Objective-C header, and that header only contains the declarations Swift exposes to Objective-C. The package itself contains no Clang module and no archive to link, because SwiftPM, the Swift Package Manager, compiles a package as part of building whatever app depends on it. Even reading the package is a problem: Package.swift is a Swift program, and only SwiftPM can evaluate it.
Swift 包不提供上述任何内容。Swift 没有头文件,因此在你要求编译器生成 Objective-C 头文件之前,没有什么可解析的;而且该头文件仅包含 Swift 向 Objective-C 暴露的声明。包本身不包含 Clang 模块或可供链接的归档,因为 SwiftPM(Swift Package Manager)在构建依赖它的任何应用时会一并编译包。甚至读取包本身也是个问题:Package.swift 是一个 Swift 程序,只有 SwiftPM 能对其进行求值。
Before 3.0.0, the SDK solved this the way many Kotlin Multiplatform libraries did, with the Kotlin CocoaPods plugin, which hands the build to CocoaPods and Xcode and then points cinterop at what they produce. Looking at the core of the 2.x configuration in the core module:
在 3.0.0 版本之前,SDK 采用许多 Kotlin Multiplatform 库的做法来解决这个问题:使用 Kotlin CocoaPods 插件,将构建任务交给 CocoaPods 和 Xcode,然后让 cinterop 指向它们生成的产物。查看核心模块中 2.x 配置的核心部分:
cocoapods {
ios.deploymentTarget = libs.versions.ios.deploymentTarget.core.get()
framework {
baseName = "Purchases"
isStatic = true
}
pod("PurchasesHybridCommon") {
version = libs.versions.revenuecat.common.get()
extraOpts += listOf("-compiler-option", "-fmodules")
}
}This works, but every step it hides has a cost. Building the iOS side needed a working CocoaPods installation, apps had to add PurchasesHybridCommon to their own Xcode project and keep its version paired with the Kotlin SDK, and parallel Gradle tasks could race each other inside CocoaPods' shared cache. In April 2026, the team fixed a flaky iOS CI job, and the commit message spells the problem out:
这种方法可行,但每个被隐藏的步骤都有代价。构建 iOS 端需要安装可用的 CocoaPods,应用必须将 PurchasesHybridCommon 添加到自己的 Xcode 项目中,并保持其版本与 Kotlin SDK 配对,而且并行的 Gradle 任务可能会在 CocoaPods 的共享缓存中发生竞争。2026 年 4 月,团队修复了一个不稳定的 iOS CI 作业,提交信息清楚地说明了问题所在:
Gradle runs podInstallSyntheticIos for core, models, mappings, and revenuecatui in parallel. All four tasks hit the shared CocoaPods cache (~Library/Caches/CocoaPods/Pods/) concurrently, causing Errno::ENOENT race conditions during cp_r.
Gradle 并行运行 core、models、mappings 和 revenuecatui 的 podInstallSyntheticIos 任务。这四个任务同时访问共享的 CocoaPods 缓存(~Library/Caches/CocoaPods/Pods/),导致在 cp_r 期间出现 Errno::ENOENT 竞态条件。
A month later, 3.0.0 deleted the plugin and the CI workaround with it. What replaced it doesn't hide the build behind another tool: it runs each step as a Gradle task, in about 1,200 lines of convention plugin code under build-logic/.../swift.
一个月后,3.0.0 删除了该插件以及相关的 CI 变通方案。取而代之的方案不再用另一个工具隐藏构建过程:它在 build-logic/.../swift 下约 1,200 行约定插件代码中,将每个步骤作为 Gradle 任务运行。
JetBrains is working on an official answer too: SwiftPM import, an Alpha feature that arrived with the Kotlin 2.4.20 release candidates. The SDK builds on Kotlin 2.3.20 with its own implementation, which makes it a readable reference for what any such integration has to handle.
JetBrains 也在开发官方解决方案:SwiftPM import,这是随 Kotlin 2.4.20 候选发布版推出的 Alpha 功能。SDK 基于 Kotlin 2.3.20 并使用其自身实现进行构建,这使其成为任何此类集成所需处理内容的可读参考。
Declaring a Swift package: The swiftPackage() DSL
声明 Swift 包:swiftPackage() DSL
Everything starts in a module's build file. In SwiftPM, a target is a module inside a package, and kn-core declares two Swift targets from its appleMain source set: RevenueCat, from the purchases-ios checkout that the repository tracks as a git submodule under upstream/, and AdditionalSwift, a small local package you'll meet later. The second declaration shows the shape without optional arguments:
一切始于模块的构建文件。在 SwiftPM 中,target 是包内的一个模块,kn-core 从其 appleMain 源集声明了两个 Swift target:RevenueCat,来自 purchases-ios 检出,该仓库将其跟踪为 upstream/ 下的 git submodule;以及 AdditionalSwift,一个稍后会遇到的小型本地包。第二个声明展示了不带可选参数的形状:
appleMain.dependencies {
swiftPackage(
path = file("src/swift"),
target = "AdditionalSwift",
packageName = "com.revenuecat.purchases.kn.core.additional"
)
}path points at the directory containing Package.swift, target picks one SwiftPM target to build, and packageName is the Kotlin package the generated bindings land in. It reads like a dependency declaration, but it doesn't add anything to a Gradle configuration. If you examine the function:
path 指向包含 Package.swift 的目录,target 选择要构建的一个 SwiftPM target,packageName 是生成的绑定所在的 Kotlin 包。它看起来像依赖声明,但它不会向 Gradle 配置添加任何内容。如果你检查该函数:
fun KotlinDependencyHandler.swiftPackage(
path: File,
target: String,
packageName: String,
customDeclarations: String? = null,
swiftSettings: SwiftSettings? = null,
) {
val registry = project.extensions.findByType(SwiftPackageRegistry::class.java)
?: error("SwiftPackageRegistry not found. ...")
val dependency = SwiftDependency(
packagePath = path,
target = target,
packageName = packageName,
sourceSetName = getSourceSetName(),
customDeclarations = customDeclarations,
swiftSettings = swiftSettings,
)
registry.add(dependency)
project.getOrCreateGlobalSwiftRegistry().register(target, project, dependency)
}The function records a SwiftDependency in two places, a registry on the current project and a global registry on the root project, and does nothing else. The real configuration happens later, once the build script has finished evaluating.
该函数在两个地方记录 SwiftDependency:当前项目上的注册表和根项目上的全局注册表,除此之外不做任何其他操作。真正的配置发生在后面,一旦构建脚本完成评估。
The one surprising call is getSourceSetName(). The build needs to know which source set the declaration came from, because that decides which Kotlin/Native targets receive bindings, but KotlinDependencyHandler doesn't expose its source set publicly. The function finds it by reflection:
一个令人意外的调用是 getSourceSetName()。构建过程需要知道该声明来自哪个源集,因为这决定了哪些 Kotlin/Native 目标会接收绑定,但 KotlinDependencyHandler 并未公开其源集。该函数通过反射来查找它:
private fun KotlinDependencyHandler.getSourceSetName(): String {
var current: Class<*>? = this.javaClass
while (current != null && current != Any::class.java) {
for (field in current.declaredFields) {
try {
field.isAccessible = true
val value = field.get(this)
if (value is KotlinSourceSet) {
return value.name
}
} catch (_: Exception) {
// Continue to next field
}
}
current = current.superclass
}
error("Could not determine source set for swiftPackage(). ...")
}It walks the handler's fields up the class hierarchy until one of them holds a KotlinSourceSet. This leans on Kotlin Gradle Plugin internals, and the full error message admits it: "This might be due to a Kotlin Gradle Plugin version incompatibility." The name then feeds a simple mapping, where appleMain covers every ios, macos, tvos, and watchos target and iosMain covers only the ios ones. That's why kn-core, which declares its packages in appleMain and adds watchOS targets, builds Swift for iOS and watchOS, while kn-ui declares RevenueCatUI, the module that renders RevenueCat's paywalls and Customer Center, in iosMain and builds for iOS only.
它会沿着类层次结构遍历处理程序的字段,直到找到包含 KotlinSourceSet 的那个字段。这依赖于 Kotlin Gradle Plugin 的内部实现,完整的错误信息也承认了这一点:“这可能是由于 Kotlin Gradle Plugin 版本不兼容。”随后,该名称被用于一个简单的映射关系:appleMain 覆盖所有 ios、macos、tvos 和 watchos 目标,而 iosMain 仅覆盖 ios 目标。这就是为什么 kn-core(在 appleMain 中声明其包并添加了 watchOS 目标)会为 iOS 和 watchOS 构建 Swift,而 kn-ui(在 iosMain 中声明 RevenueCatUI——即渲染 RevenueCat 支付墙和客户中心的模块)仅为 iOS 构建的原因。
更进一步:量化金融体系
看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力