跳转到内容
Skip 中文站
3.2k

跨平台主题

无论是为了绕过 Skip 在 Android 支持方面的限制、区分 iOS 和 Android 的体验、利用特定操作系统的功能,还是仅仅因为你更喜欢为每个平台分别编写应用代码,你都会发现自己需要编写仅适用于 iOS 或仅适用于 Android 的代码。Skip 让这一切变得简单。

编写仅适用于 iOS 或 Android 的代码,最常见也最便捷的方式是使用条件编译指令 ↗。你可能已经在 Swift 中使用过它们了。例如:

#if DEBUG
…
#else
…
#endif

对仅适用于 Android 的代码使用 #if os(Android),对仅适用于 iOS 的代码使用 #if !os(Android)。以下示例在 Android 上会打印 “Android”,在 iOS 上会打印 “iOS”:

#if os(Android)
print("Android")
#endif
#if !os(Android)
print("iOS")
#endif
#if os(Android)
print("Android")
#else
print("iOS")
#endif

你也可以在 SwiftUI 修饰符链中使用编译指令:

Text("Hello World")
#if os(Android)
.italic()
#else
.bold()
#endif

Skip 识别两个用于 Android 特定代码的条件编译符号:os(Android) 和 SKIP。它们的行为取决于你使用的是 Skip Fuse(原生编译)还是 Skip Lite(转译)模式。

#if os(Android) 标记仅在 Android 上运行的代码。它的工作方式与任何其他 Swift 平台检查(#if os(iOS)、#if os(Linux) 等)相同。其内部代码的编译方式取决于模式:

  • 在 Skip Fuse 中,#if os(Android) 内的代码由 Swift Android 编译器作为原生 Swift 编译。它是标准的 Swift 代码,就像 #if os(iOS) 代码在 iOS 上是标准 Swift 一样。你无法从此代码块中调用 Kotlin 或 Java API。
  • 在 Skip Lite 中,#if os(Android) 内的代码会被转译为 Kotlin,因为 Lite 模块中的所有 Swift 都会被转译。在此模式下,#if os(Android) 和 #if SKIP 的效果相同。

#if SKIP 标记始终会被转译为 Kotlin 的代码,无论使用哪种模式。当你需要直接调用 Kotlin 或 Java API 时,应使用此代码块,因为代码会在构建时变为 Kotlin。在 Fuse 和 Lite 两种模式下,#if SKIP 代码都会被转译,可以调用任何 Kotlin 或 Java API。

下表总结了其行为:

条件Skip Fuse(原生)Skip Lite(转译)
#if os(Android)作为 Android 的原生 Swift 编译转译为 Kotlin(与 #if SKIP 相同)
#if SKIP转译为 Kotlin转译为 Kotlin
#if !os(Android)仅 iOS(从 Android 构建中排除)仅 iOS(从 Android 构建中排除)
#if !SKIP在 iOS 和 Android 上均作为原生 Swift 编译仅 iOS(从 Android 构建中排除)

实际使用中:

  • 当你想编写仅在 Android 上运行但不需要调用 Kotlin/Java API 的平台特定 Swift 代码时,使用 #if os(Android)。在 Fuse 模式下,这是标准 Swift。在 Lite 模式下,它会像其他代码一样被转译。
  • 当你需要直接调用 Kotlin 或 Java API 时,使用 #if SKIP。这在两种模式下都有效,因为代码始终会被转译。
  • 使用 #if !os(Android) 将代码从 Android 中完全排除(仅限 iOS)。

如上所述,#if SKIP 代码块让你编写在 Fuse 和 Lite 两种模式下都会被转译为 Kotlin 的代码。#if SKIP 代码块内的代码可以直接调用 Kotlin 和 Java API,因为 Skip 会在构建时将其转换为 Kotlin。考虑以下代码:

func printFormatted(time timeInMills: Int64) {
#if os(Android)
let formatted = androidTimeString(milliseconds: timeInMills)
#else
let formatted = … iOS 代码路径 …
#endif
print(formatted)
}
#if SKIP
func androidTimeString(milliseconds: Int64) -> String {
let dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault())
dateFormat.timeZone = java.util.TimeZone.getTimeZone("GMT")
return dateFormat.format(java.util.Date(milliseconds))
}
#endif

这里发生了什么?

  1. printFormatted 函数使用 #if os(Android) 在 Android 构建中调用 androidTimeString(milliseconds:)。
  2. androidTimeString(milliseconds:) 定义在 #if SKIP 代码块中。#if SKIP 代码块中的所有代码都会被转译为 Kotlin。所以虽然这看起来像 Swift 代码,但在 Android 构建过程中它会变成 Kotlin 代码。这意味着它可以自然地调用任何其他 Kotlin 或 Java API,就像这些 API 是用 Swift 编写的一样。这里我们使用了多种 Java 日期格式化函数。
  3. Skip 会自动桥接 SKIP 代码块中所有非私有代码,以便你可以从编译后的 Swift 代码中与之交互。

虽然上面的例子有些刻意,但请考虑其更广泛的意义。你可以使用这种机制直接与任何 Android 库交互,包括第三方依赖项或你自己的自定义 Kotlin 代码。如此便捷地同时利用仅限 iOS 和仅限 Android 的 API 的能力——直接在代码内联中——使 Skip 区别于许多其他跨平台解决方案,在那些方案中调用平台原生代码可能很困难。稍后,我们将看到如何利用这一点来混合使用 SwiftUI 和 Compose 视图。

当你在转译的 #if SKIP 代码块中调用 Kotlin 和 Java 代码时,请记住你仍然是在用 Swift 编写。代码仍然由 Xcode 解析,因此必须具有有效的 Swift 语法。但它会被排除在 iOS 构建之外,对 Swift 编译器不可见,这允许它进行任何语法有效的 API 调用而不会导致 Xcode 报错。只需编写自然的 Swift 代码,并想象你调用的 Kotlin 或 Java API 是一个 Swift 库 API。Skip 的转译器会处理剩下的工作。

我们有时称这种混合方言为 Kotlish:语法上是 Swift 但会转译为 Kotlin 的代码。Kotlish 文档深入介绍了这种方言,包括其局限性以及转译器需要帮助时可用的逃逸方式。

如果你想直接复制粘贴 Kotlin 代码,你需要将其转换为有效的 Swift 以避免语法错误。幸运的是,这两种语言极其相似。你通常只需要改变一些调用约定:

  • Kotlin 中的命名参数值使用等号(=)指定,而 Swift 中使用冒号(:)。注意,Kotlin 中参数名永远不是必需的。
  • Kotlin 中的闭包参数使用箭头(->)指定,而 Swift 中使用 in 关键字。
  • 要导入 Kotlin 包中的所有内容,使用 import com.xyz.__ 而不是 import com.xyz.*。后者不是有效的 Swift 语法。

例如,以下 Kotlin 代码:

val start = 1
var result = 0
for (i in 1 until 10) {
result += someFunction(value = start + i, block = { arg -> arg + 1 })
}

当嵌入到 Swift 的 #if SKIP 代码块中时,应使用以下语法:

let start = 1
var result = 0
for i in 1..<10 {
result += someFunction(value: start + i, block: { arg in arg + 1 })
}

如果你想引入 Kotlin 代码,可以考虑向项目中添加 Kotlin 文件,而不是将其翻译为嵌入式 Swift。

你可能希望在编译后的 Swift 代码与 #if SKIP 代码块中的转译代码之间来回传递复杂数据类型,以及转译后的 Swift 与各种 Kotlin 和 Java API 之间传递。

为了在转译的 #if SKIP 函数之间传递类型,这些类型必须支持桥接。许多常见类型开箱即支持桥接,而桥接你自己的类型通常只需要对它们进行标注。详见桥接文档。

与 Kotlin API 来回传递复杂数据通常很简单,因为 Skip 转译器统一了 Swift 和 Kotlin 的类型系统。不过,当 Skip 使用自定义类型来表示常见数据结构时,我们已经标准化了一种简单的方法将这些类型转换为其 Kotlin 等效物:

  • .kotlin(nocopy: Bool = false):Skip 为其 Swift 类型在 Android 上实现此函数,以返回等效的标准 Kotlin 对象。例如,当你的 Swift 代码使用 Array 时,Skip 的转译输出使用 skip.lib.Array,这是一个旨在镜像 Swift 数组 API 和值语义的 Skip 类型。调用 someArray.kotlin() 返回一个包含相同元素的 kotlin.collections.List。默认情况下,Skip 返回一个副本,该副本还会对每个元素递归调用 .kotlin()。你可以使用可选的 nocopy 参数来提示不需要这样做。在这种情况下,Skip 会直接返回 Array 的底层 List 实例(如果可能的话)。

以下是 SkipFoundation 转译包中 Calendar 的此函数实现。注意,SkipFoundation 的 Calendar 使用了一个类型为 java.util.Calendar 的内部 platformValue 成员,以利用 Java 现有的日历功能。

#if SKIP
extension Calendar: KotlinConverting<java.util.Calendar> {
public func kotlin(nocopy: Bool = false) -> java.util.Calendar {
return nocopy ? platformValue : platformValue.clone() as java.util.Calendar
}
}
#endif

同样,.kotlin() 函数仅在传递具有不同表示形式的内置类型到 Kotlin 和 Java API 时才需要。对于 Int 或 String 等基本类型则不需要,你自己定义的类型可以直接实现 Kotlin 或 Java 协议。


Skip 的 SwiftUI 实现包含额外的 API,允许你在 SwiftUI 和纯 Compose 代码之间流畅切换。这种强大的能力在以下几个方面很有用:

  • 如果你更喜欢完全用 Compose 编写应用的部分代码,Skip 的互操作性允许你在平台之间共享任意数量的 UI,而不用担心与框架冲突。你可以添加 Kotlin 库并包含 Kotlin 文件,其中包含自定义的 Kotlin 和 Compose 函数,你可以使用本章中的技术来调用它们。
  • 轻松嵌入 Android 特定的 UI 组件,并从应用的任何位置访问 Android 特定的功能。
  • Skip 尚未为 Android 实现所有 SwiftUI API。能够将 Compose 视图简单地混入你的 UI 中,可以帮助你绕过 Skip 当前的任何暂时不足。

下面的 MapView 示例在 iOS 上使用 Apple Maps,在 Android 上使用 Google Maps。为了嵌入 Google Maps,它使用了 Skip 仅限 Android 的 ComposeView,这是一个围绕转译后的 ContentComposer 的原生 SwiftUI 包装器。有关在 SwiftUI 中嵌入 Compose 以及反之的详细讨论,请参阅 SkipUI 模块中的 ComposeView 主题。

import SwiftUI
import MapKit
struct MapView : View {
let latitude: Double
let longitude: Double
var body: some View {
#if os(Android)
// 在 Android 平台上,我们在 ComposeView 中使用 com.google.maps.android.compose.GoogleMap
ComposeView { MapComposer(latitude: latitude, longitude: longitude) }
#else
// 在 Darwin 平台上,我们使用 SwiftUI 的 Map 类型
Map(initialPosition: .region(MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1))))
#endif
}
}
#if SKIP
import com.google.maps.android.compose.__
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
struct MapComposer : ContentComposer {
let latitude: Double
let longitude: Double
@Composable func Compose(context: ComposeContext) {
GoogleMap(cameraPositionState: rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(LatLng(latitude, longitude), Float(12.0))
})
}
}
#endif

Skip 还提供了仅限 Android 的 SwiftUI API,允许你自定义 Skip 底层的 Compose 组件。更多信息请阅读 SkipUI 模块的 Material 文档。


Skip 确保你在共享 Swift 业务逻辑中定义的 @Observable 类型也能为你的 Compose UI 提供支持,如开发章节中所述。透明的模型集成是 Skip 允许你在共享代码和平台特定代码之间无缝切换的另一种方式。


许多 Android 系统调用需要 Context ↗ 或 Activity ↗ 引用。

SkipFuse ↗ 和 SkipFuseUI ↗ 提供 Swift 扩展来获取这些引用,用于编译的 Skip Fuse 代码:

import SkipFuse
#if os(Android)
let applicationContext = ProcessInfo.processInfo.dynamicAndroidContext()
if let packageName: String = try? applicationContext.getPackageName() {
…
}
…
#endif
import SwiftUI
#if os(Android)
if let activity = UIApplication.shared.dynamicAndroidActivity() {
…
}
#endif

这些 Android 对象作为 AnyDynamicObject 子类返回到你的原生代码中。了解更多关于 AnyDynamicObject 的信息,请查看这里。

同样,SkipFoundation ↗ 和 SkipUI ↗ 提供 Swift 扩展来获取这些引用,用于转译的 Skip Lite 代码:

import Foundation
#if SKIP
let applicationContext = ProcessInfo.processInfo.androidContext
let packageName = applicationContext.getPackageName()
…
#endif
import SwiftUI
#if SKIP
if let androidActivity: androidx.appcompat.app.AppCompatActivity = UIApplication.shared.androidActivity {
let intent = androidActivity.getIntent()
…
}
#endif

除了 Skip 自己的双平台库之外,还有数千个 Swift 包 ↗可用于 Android。你可以在你的 Skip Fuse 应用中使用这些包,就像在仅限 iOS 的应用中使用一样。但是,要了解如何集成仅限 iOS 或仅限 Android 的依赖项,请阅读依赖项文档章节。


Skip 将以 “SKIP” 开头的任何 // 或 /* */ 注释行视为构建指令。Skip 支持多种指令,所有这些指令都会影响 Skip 的输出或行为。

  • SKIP <Attributes> 此指令将 Skip 特定的属性应用于目标元素。Skip 目前识别五个属性:@nocopy、@nodispatch、@bridge、@bridgeMembers 和 @nobridge。有关 @nocopy 的解释,请参阅 Struct 转译主题。在转译的 async 函数上使用 @nodispatch 可以阻止 Skip 插入 Kotlin 以在 Dispatcher 上运行它。@bridge、@bridgeMembers 和 @nobridge 帮助控制哪些 API 在编译后的 Swift 和 Kotlin 之间进行桥接。

    // SKIP @bridge
    struct S {
    …
    }
  • SKIP DECLARE: <Kotlin> 在转译代码中用自定义 Kotlin 替换目标声明。这对于自定义类型、函数或属性在 Kotlin 中的声明方式很有用,而不会影响其主体的转译。

    // SKIP DECLARE: override fun SaverScope.save(value: Any): Any?
    override func save(value: Any) -> Any? {
    …
    }
  • SKIP EXTERN:在转译代码中标记外部原生函数。此指令用于 C 集成。

  • SKIP INSERT: <Kotlin> 在转译代码中插入任意 Kotlin 代码。

    // SKIP INSERT: var count by remember { mutableStateOf(100) }
    var countString = count.description
  • SKIP NOWARN 将此注释放在有问题的行上以静默 Skip 的警告或错误。

    // SKIP NOWARN
    let dict = obj as? Dictionary<Int, String>
  • SKIP REPLACE: <Kotlin> 在转译代码中用任意 Kotlin 替换目标语句。与 SKIP DECLARE 不同,这包括主体。

    // SKIP REPLACE:
    // fun printOS() {
    // print("Android")
    // }
    public func printOS() {
    print("iOS")
    }
  • SKIP SYMBOLFILE 此指令在转译的 Swift 源文件顶部使用。它告诉 Skip 该文件应仅用于收集已声明 API 的符号。Skip 期望你会用相应的 Kotlin 文件来实现这些 API。实际上,它将 Swift 源文件变成了头文件。SkipLib 广泛使用了这种技术来在 Kotlin 中实现 Swift 标准库的各个方面。

指定 Kotlin 代码的指令可以跨多行注释。Skip 假设指令的 Kotlin 代码会一直延续,直到 Skip 遇到以下之一:

  • 新指令
  • 空注释行
  • 注释块结束

因此,这样写:

// SKIP INSERT: var count by remember { mutableStateOf(100) }

和这样写都是有效的:

// SKIP INSERT:
// var count by remember {
// mutableStateOf(100)
// }

到目前为止介绍的机制允许你从 Swift 中使用 Kotlin 和 Java API,或者在转译的 Swift 源文件中嵌入纯 Kotlin 代码片段。但 Skip 还允许你在 Xcode 项目中包含完整的 Kotlin 和 Java 源文件。只需使用标准的 .kt 和 .java 扩展名,并将源文件放在 Sources/<ModuleName>/Skip 目录中,与 skip.yml 文件并列。Skip 会将它们包含在最终的 Android 构建中。SkipLib 使用这种技术在 Kotlin 中实现 Swift 标准库的各个方面。

在处理 Kotlin 和 Java 文件时,我们通常更喜欢在 Android Studio 中打开生成的 Android 项目,以利用其语法高亮、自动补全和其他便利功能。实际上,你可以有一个专门的 Android 团队使用 Android Studio 来扩展和定制应用的 Android 版本,而其他团队则负责 iOS 和共享代码库部分。

默认情况下,Skip 会将你的驼峰命名法 Swift 模块名转换为点分隔的小写 Kotlin 包名:

  • MyPackage 变为 my.package
  • MyHTTPLibrary 变为 my.http.library
  • Kotlin 包名必须至少有两个段,所以 Product 变为 product.module

为了避免在 #if SKIP 代码块中使用 Kotlin 和 Java API 时必须显式 import,请将 Kotlin 和 Java 文件的包名设置为你的 Swift 模块的派生包名。例如,如果你包含文件 Sources/MyCoolApp/Skip/CustomKotlin.kt,则包头应为 package my.cool.app。

从 Skip 1.7.2 开始,生成的包名可以在模块的 skip.yml 文件中自定义:

skip:
package: 'org.example.mypackage'

Skip 会从你的双平台应用或框架生成完整的 gradle 项目。你可以在 Android Studio ↗ 中打开这个项目。这对于调试 Android 特定问题和单元测试,或者迭代你的 Kotlin 文件 通常很有用。

默认情况下,Xcode 和 Android Studio 构建到不同的位置:Xcode 使用 DerivedData,而 Android Studio 使用项目目录中的 .build 文件夹。如果你只从其中一个 IDE 构建和运行,这不会影响你,但如果你在以下情况下可能会有问题:

  1. 你希望能够从 Xcode 和 Android Studio 构建和运行相同的输出,在两者之间来回切换。
  2. 你正在使用 Xcode 工作区来迭代应用的 SwiftPM 依赖项的本地副本。Android Studio 不会看到你的工作区副本,而是会始终使用 Package.swift 中定义的依赖源。

如果你想共享相同的构建位置,你可以通过将 Android Studio 指向你的项目的 Xcode DerivedData 位置来实现。编辑项目的 Android/settings.gradle.kts 文件,取消注释设置 BUILT_PRODUCTS_DIR 系统属性的行,并指定项目的 Debug-iphonesimulator 文件夹的路径。结果应该类似于:

pluginManagement {
// 初始化 Skip 插件文件夹并为非 Xcode 构建执行预构建
val pluginPath = File.createTempFile("skip-plugin-path", ".tmp")
// 覆盖 Android IDE 的输出可以通过取消注释并设置 Xcode 路径来完成:
System.setProperty("BUILT_PRODUCTS_DIR", "${System.getProperty("user.home")}/Library/Developer/Xcode/DerivedData/MySkipProject-aqywrhrzhkbvfseiqgxuufbdwdft/Build/Products/Debug-iphonesimulator")
…

要在 Android Studio 中打开你的双平台 Xcode 应用:

  1. 按住 Control 键单击 Android/settings.gradle.kts 文件,并从弹出的上下文菜单中选择 Open with External Editor。
  2. 在 Android Studio 中,选择 File → Sync Project with Gradle Files
Open in Android Studio

一旦你的应用在 Android Studio 中打开,你就可以从那里运行和调试它。请记住,虽然你可以在 Android Studio 中迭代应用的自定义 Kotlin 文件,但你对转译文件所做的任何编辑都会在下次更新源 Swift 时被覆盖。

如果你想更新 Swift,请在 Xcode 中进行所需的编辑,然后在 Android Studio 中重新构建。如果你已按照上一节所述将 Android Studio 指向 DerivedData,你也可以选择从 Xcode 重新构建。

要在 Android Studio 中运行单元测试,请先在 Xcode 中运行它们以便它们被转译。然后在 SkipStone/plugins 中找到你的 Test 模块的输出文件夹,并在其 settings.gradle.kts 文件上使用 Open with External Editor。在 Android Studio 中运行单元测试将允许你使用 Android 的原生调试工具来调试测试。

Open in Android Studio

如果你创建了共享双平台框架的独立 iOS 和 Android 应用,在 Android Studio 中运行你的 Android 应用与运行任何其他 Android 应用相同。请参阅项目类型指南,了解有关将双平台框架集成到你的 Android 开发工作流程中的提示。

要在 Android Studio 中打开你的双平台框架:

  1. 确保你已在 Xcode 中构建了项目。
  2. 按照说明创建 SkipLink Xcode 组。
  3. 按住 Control 键单击 Android/settings.gradle.kts 文件,并从弹出的上下文菜单中选择 Open with External Editor。

要在 Android Studio 中运行框架单元测试,请先在 Xcode 中运行它们以便它们被转译。然后它们将在 gradle 项目中可用。在 Android Studio 中运行单元测试将允许你使用 Android 的原生调试工具来调试测试。