跳转到内容
Skip
3.2k

将 Swift 包移植到 Android

Swift Package Index 网站跟踪了数千个已经为 Android 构建的 Swift 包。移植你自己的包或你依赖的包通常是一个直接的过程。本指南涵盖了我们在移植 Swift 包方面积累的经验,以及如何应用这些知识将你那些仅限 Apple 的 Swift 包转变为通用多平台包,不仅可以为 iOS 和 macOS 构建,还可以为 Android 构建。

什么样的 Swift 包适合移植到 Android?最好的试金石是包是否提供通用功能,而不是对 iOS 特定框架有不可或缺的依赖。一些好的候选示例包括:

  • 业务逻辑
  • 算法和通用数据结构
  • 网络工具
  • 在线 Web 服务和 API 客户端
  • 数据持久化
  • 解析器和格式化器

另一方面,移植到 Android 可能比较困难的包的示例包括:

  • 自定义 UIKit 组件
  • HealthKit、CarPlay、Siri 集成库
  • 其他 Apple 特定的 *Kit 库集成

现在,仅仅因为一个 Swift 包被设计为与 Apple 特定框架配合工作,并不意味着它不可能移植到 Android。这只是意味着工作量会很大,并且需要创建到等效 Kotlin 或 Java 框架的桥接。这完全是可能的,但本指南的主题是如何移植自然可移植的 Swift 包到 Android。

假设你有一个标准的 Swift 包,根目录包含 Package.swift 文件,以及通常的 Sources/ 和(希望的)Tests/ 文件夹,其中包含各个目标和源文件。

在包目录中运行 swift buildswift test 是否能在终端中工作?如果是,那么你已经有了一個跨平台包,可以在多个平台上工作:iOS 和 macOS!仅凭这一点就是一个好迹象,表明你的包可能适合 Android。许多在 iOS 上可用的框架在 macOS 上并不存在,所以要么你的包没有使用太多 iOS 框架,要么它足够智能只条件引用它们(更多内容见下文)。但是我们如何为 Android 构建和测试呢?

首先,安装 Skip 和原生 Android SDK。然后尝试使用 Android 工具链构建你的 Swift 包。非常简化的快速开始如下:

Terminal window
$ brew install skip
skip was successfully installed!
$ skip android sdk install
[✓] Install Swift Android SDK (2.4s)
$ cd MySwiftPackage/
$ skip android build
Building for debugging...
[0/2] Write sources
[4/4] Emitting module DemoPackage
Build complete! (1.85s)

如果你看到 “Build complete!”,那么恭喜你!你的包已经可以为 Android 构建了,你可以继续到测试部分。但如果你遇到构建命令的错误,你需要将你的包移植到 Android。请继续阅读…

维基百科将移植定义为”为了在不同于给定程序(原本设计用于该执行)的计算环境中实现某种形式的执行而调整软件的过程”。

换句话说,你在设计时考虑的是 iOS,现在你想让它在 Android 上工作。以下各节将介绍你首次尝试在新平台上构建包时可能遇到的一些最常见的问题。

假设你的 Swift 包定义了一个 Event 协议和一个简单的默认实现:

Event.swift
protocol Event {
var dateRange: Range<Date> { get }
var isConfirmed: Bool { get }
}
struct SimpleEvent : Hashable, Codable {
let start, end: Date
let confirmed
}
extension SimpleEvent : Event {
var dateRange: Range<Date> { self.start..<self.end }
var isConfirmed: Bool { confirmed }
}

你的包还包括一个使用 iOS EventKit 框架实现 Event 的扩展,如下所示:

Event+EventKit.swift
import EventKit
extension EKEvent : Event {
var dateRange: Range<Date> { self.startDate..<self.endDate }
var isConfirmed: Bool { self.status == .confirmed }
}

EventKit 是一个仅限 Apple 的框架,所以当你尝试为 Android 构建包时,会遇到错误:

Terminal window
$ skip android build
7 | import EventKit
| `- error: no such module 'EventKit'

解决方案很简单:将任何引用缺失模块的代码包装在 #if canImport(EventKit) 中,这会在指定模块可用时条件编译代码:

Event.swift
protocol Event {
var dateRange: Range<Date> { get }
var isConfirmed: Bool { get }
}
struct SimpleEvent : Hashable, Codable {
let start, end: Date
let confirmed
}
extension SimpleEvent : Event {
var dateRange: Range<Date> { self.start..<self.end }
var isConfirmed: Bool { confirmed }
}
#if canImport(EventKit)
import EventKit
extension EKEvent : Event {
var dateRange: Range<Date> { self.startDate..<self.endDate }
var isConfirmed: Bool { self.status == .confirmed }
}
#endif

现在你将拥有包的所有通用功能可供 Android 使用,你可以将其适配为将来可能创建的任何 Android 特定数据结构。

在移植 Skip 应用的 Swift 代码时,使用 SkipFuse 模块来提供 Android 可能缺少的一些 iOS 功能。SkipFuse 是一个提供跨平台功能的伞框架。例如,它提供了跨平台版本的 OSLog用于日志记录,并允许你的 Swift @Observables驱动 Jetpack Compose UI。虽然在移植工具模块时通常不需要 SkipFuse,但它对于在 Android 上开发完整的编译 Swift 应用是不可或缺的。

考虑以下简单的工具,它获取一个 URL 并将其解码为 Item 结构体:

Item.swift
import Foundation
struct Item: Decodable {
let id: Int
let name: String
}
func fetch(_ url: URL) async throws -> Item {
let (data, response) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Item.self, from: data)
}

你可能会惊讶地发现它在 Android 上编译失败:

$ skip android build
Building for debugging...
Fetcher.swift:9:49: error: type 'URLSession' (aka 'AnyObject') has no member 'shared'
7 |
8 | func fetch(_ url: URL) async throws -> Item {
9 | let (data, response) = try await URLSession.shared.data(from: url)
| `- error: type 'URLSession' (aka 'AnyObject') has no member 'shared'
10 | return try JSONDecoder().decode(Item.self, from: data)
11 | }

这个有些令人困惑的错误消息只是意味着它找不到 URLSession 类型,因为它在 Android 上的 Foundation 模块中不存在。

在 Darwin 平台(macOS、iOS 和其他 Apple 操作系统)上,Foundation 模块是广泛功能的伞。但在其他平台上,如 Android 和 Linux,Foundation 被拆分为多个独立的子组件:

  • FoundationEssentials:所有基础 Foundation 类型:DateCalendarURLIndexSet
  • FoundationInternationalizationDateFormatterNumberFormatter 和其他本地化工具
  • FoundationNetworkingURLSessionURLCache 和其他网络工具
  • FoundationXMLXMLParser

解决方案很简单:在任何使用网络功能的文件中添加 FoundationNetworking 的条件导入,如下所示:

Item.swift
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
struct Item: Decodable {
let id: Int
let name: String
}
func fetch(_ url: URL) async throws -> Item {
let (data, response) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Item.self, from: data)
}

这将包含 Android 和 Linux 等需要它的平台所需的 FoundationNetworking 模块,但在 Darwin 平台(如 iOS 和 macOS)上会安静地忽略它,因为网络类型已包含在单体 Foundation 框架中。

Swift 与 C 有很好的集成,许多有用的函数来自系统的 C 库,在 macOS 和 iOS 上称为 Darwin。以计算三角形斜边的简单示例为例,它使用从标准 C 库引入的一些数学函数:

Hypotenuse.swift
import Darwin
func hypotenuse(a: Double, b: Double) -> Double {
return sqrt(pow(a, 2) + pow(b, 2))
}

如果你尝试为 Android 构建,会遇到错误:

1 | import Darwin
| `- error: no such module 'Darwin'

这是因为 Darwin 模块在 Android 上不存在。它简单地称为 Android。同样,我们用方便的 canImport 条件来解决:

Hypotenuse.swift
#if canImport(Darwin)
import Darwin
#elseif canImport(Android)
import Android
#else
#error("Unknown platform")
#endif
func hypotenuse(a: Double, b: Double) -> Double {
return sqrt(pow(a, 2) + pow(b, 2))
}

在这种情况下,我们在 iOS 和 macOS 上导入 Darwin,在 Android 上导入 Android。这两者都会提供对系统标准 C 库的访问。

简单的 C 函数(如 powsqrt)在 Darwin 和 Android 平台上的公开方式通常完全相同。但 Android C 库中某些函数和数据结构的定义有时会有细微差异。例如,以下代码在 Darwin 平台上使用 FILE 类型和 fopenfwrite C 函数:

Functions.swift
import Darwin
let fd: UnsafeMutablePointer<FILE> = fopen("file.txt", "w")
var buffer: [UInt8] = [1, 2, 3]
let count: Int = buffer.withUnsafeBufferPointer { ptr in
fwrite(ptr.baseAddress, MemoryLayout<UInt8>.stride, ptr.count, fd)
}

这将为 Android 构建失败:

Terminal window
$ skip android build
FileWrite.swift:15:30: error: cannot find type 'FILE' in scope
13 | #endif
14 |
15 | let fd: UnsafeMutablePointer<FILE> = fopen("file.txt", "w")
| `- error: cannot find type 'FILE' in scope
16 | var buffer: [UInt8] = [1, 2, 3]
17 | let count: Int = buffer.withUnsafeBufferPointer { ptr in
FileWrite.swift:18:16: error: value of optional type 'UnsafePointer<UInt8>?' must be unwrapped to a value of type 'UnsafePointer<UInt8>'
16 | var buffer: [UInt8] = [1, 2, 3]
17 | let count: Int = buffer.withUnsafeBufferPointer { ptr in
18 | fwrite(ptr.baseAddress, MemoryLayout<UInt8>.stride, ptr.count, fd)
| |- error: value of optional type 'UnsafePointer<UInt8>?' must be unwrapped to a value of type 'UnsafePointer<UInt8>'
| |- note: coalesce using '??' to provide a default when the optional value contains 'nil'
| `- note: force-unwrap using '!' to abort execution if the optional value contains 'nil'
19 | }
20 |

这里有两个独立的问题:

  • FILE 在 Android 上不存在,所以 UnsafeMutablePointer<FILE> 必须替换为 OpaquePointer
  • fwrite 这样接受文件指针的函数不接受可选类型,所以必须从指针的 rawValue 强制解包

以下条件类型别名将处理第一个问题,简单地强制解包指针的地址(在所有平台上应该有效)解决第二个:

Functions.swift
#if canImport(Darwin)
import Darwin
#elseif canImport(Android)
import Android
#else
#error("Unknown platform")
#endif
#if os(Android)
typealias Descriptor = OpaquePointer
#else
typealias Descriptor = UnsafeMutablePointer<FILE>
#endif
let fd: Descriptor = fopen("file.txt", "w")
var buffer: [UInt8] = [1, 2, 3]
let count: Int = buffer.withUnsafeBufferPointer { ptr in
fwrite(ptr.baseAddress!, MemoryLayout<UInt8>.stride, ptr.count, fd)
}

除非你在开发与平台 C 库交互的非常底层的代码,否则你很少会遇到这类问题。但当你遇到时,很高兴知道解决方案往往相当简单。最困难的部分通常只是解读编译失败消息。

现在你的包可以用命令 skip android build 为 Android 构建了。太棒了!

但你只完成了一半:你需要确保你的代码不仅能为 Android 构建,而且能实际工作。希望你的 Swift 包在 Test/ 文件夹中包含了测试用例,并且在你的 macOS 机器上用 swift test 运行测试可以工作。例如,使用 swift-algorithms 包:

swift test output
$ swift test
Building for debugging...
[78/78] Linking swift-algorithmsPackageTests
Build complete! (12.67s)
Test Suite 'All tests' started at 2025-01-21 19:25:03.841.
Test Suite 'swift-algorithmsPackageTests.xctest' started at 2025-01-21 19:25:03.842.
Test Suite 'AdjacentPairsTests' started at 2025-01-21 19:25:03.842.
Test Case '-[SwiftAlgorithmsTests.AdjacentPairsTests testEmptySequence]' started.
Test Case '-[SwiftAlgorithmsTests.AdjacentPairsTests testEmptySequence]' passed (0.002 seconds).
Test Case '-[SwiftAlgorithmsTests.AdjacentPairsTests testIndexTraversals]' started.
Test Case '-[SwiftAlgorithmsTests.AdjacentPairsTests testIndexTraversals]' passed (0.002 seconds).
Test Suite 'All tests' passed at 2025-01-21 19:25:05.718.
Executed 212 tests, with 0 failures (0 unexpected) in 1.870 (1.876) seconds

为了在 Android 上运行测试,你需要插入一个 Android 设备(启用 USB 调试),或者配置并启动一个 Android 模拟器,可以从命令行或 Android Studio) 完成。

设置好 Android 开发目标后,你可以用 skip android test 命令运行包的测试用例,它会编译测试用例,打包它们(连同任何相关资源),复制到 Android 设备或模拟器,然后远程执行测试用例。

例如,对于 swift-algorithms 包:

Terminal window
% skip android test
[0/1] Planning build
Building for debugging...
[83/84] Linking swift-algorithmsPackageTests.xctest
Build complete! (11.68s)
[✓] Check Swift Package (0.87s)
[✓] Connecting to Android (0.18s)
[✓] Copying test files (0.88s)
Test Suite 'All tests' started at 2025-01-21 21:02:09.086
Test Suite 'swift-algorithms-1C77777B-CEC3-4075-8853-E77CECFCF30B.xctest' started at 2025-01-21 21:02:09.105
Test Suite 'AdjacentPairsTests' started at 2025-01-21 21:02:09.105
Test Case 'AdjacentPairsTests.testEmptySequence' started at 2025-01-21 21:02:09.105
Test Case 'AdjacentPairsTests.testEmptySequence' passed (0.014 seconds)
Test Case 'AdjacentPairsTests.testIndexTraversals' started at 2025-01-21 21:02:09.120
Test Case 'AdjacentPairsTests.testIndexTraversals' passed (0.004 seconds)
Test Suite 'All tests' passed at 2025-01-21 21:02:21.697
Executed 212 tests, with 0 failures (0 unexpected) in 12.579 (12.579) seconds

如果有任何测试失败,这就是你需要深入研究测试用例细节、隔离问题并应用修复的地方。测试失败的原因有很多,比如对文件系统布局的假设。这些需要逐案检查和解决。

一旦所有测试通过,你就成功将 Swift 包移植到了 Android!

一旦你的包构建完成并且测试通过,你会希望确保它们继续通过。维护支持多平台的包可能比仅支持单平台更具挑战性,因为当实现新功能或修复 bug 时,变更通常只在开发者当前工作的平台上进行测试。例如,如果你在应用的 iOS 端工作并在某个包中修复了一个 bug,你可能只在一个平台上测试了变更,但它可能无意中破坏了另一个平台上的某些东西。

这就是持续集成(CI)非常有用的地方。如果你使用 GitHub 作为包的源代码管理系统,你可以使用 GitHub Actions 在每次推送到仓库或创建拉取请求时自动在多个平台上构建和测试你的包。

为了促进 Android CI,我们提供了 swift-android-action,使你只需一行配置就可以为 Android 构建和测试你的包。

以下 .github/workflows/ci.yml 脚本示例将在每次提交或创建 PR 时在 macOS、iOS、Linux 和 Android 上构建和测试你的包:

.github/workflows/ci.yml
name: swift package ci
on:
push:
branches:
- '*'
workflow_dispatch:
pull_request:
branches:
- '*'
jobs:
linux-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "在 Linux 上测试 Swift 包"
run: swift test
- name: "在 Android 上测试 Swift 包"
uses: skiptools/swift-android-action@v2
macos-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: "在 macOS 上测试 Swift 包"
run: swift test
- name: "在 iOS 上测试 Swift 包"
run: xcodebuild test -sdk "iphonesimulator" -destination "platform=iOS Simulator,name=iPhone 15" -scheme "$(xcodebuild -list -json | jq -r '.workspace.schemes[-1]')"

你可以在 GitHub 上许多支持 Android 的包中看到这个工作流的应用,例如 Skip 自己的 swift-sqlcipher 包。

这样,你可以放心,一旦你完成了让包在 Android 上工作的艰苦工作,它会继续在所有你支持的平台上工作。

将 Swift 包扩展到支持 iOS 以外的平台可能一开始看起来令人生畏,但使用本指南的建议,你可以遵循几个简单的步骤,让你走上正确的轨道:

  1. 设置 Skip 和 Swift Android SDK
  2. 尝试用 skip android build 构建你的包
  3. 识别构建错误并通过条件导入和适应平台差异来解决它们
  4. 设置 Android 模拟器或设备进行测试
  5. skip android test 测试你的包
  6. 识别测试失败并逐案解决

这是我们用来为数十个流行的 Swift 包添加 Android 支持的顺序,例如 GraphQLCryptoSwiftPromiseKit。随着数千个 Swift 包目前为 Android 构建,我们认为该平台已经达到了足够的临界质量,使 Swift 成为两个主要移动平台(iOS Android)应用许多部分的有吸引力的语言。如果你有一个为 Android 构建的热门 GitHub 包,预计它很快就会出现在 Swift Package Index 上!