跳转到内容
Skip
3.2k

应用开发

Skip 允许你在应用的 iOS 与 Android 版本之间自由决定共享多少代码。跨平台主题一章详细介绍如何集成 Android 或 iOS 特有代码;本章则聚焦于两个平台共享的开发模式。

下文假设你已经熟悉 iOS 开发。我们将重点说明 Skip 双平台开发与标准 iOS 开发的不同,包括如何使用 Skip 工具,以及出现问题时如何处理。

所有跨平台工具都有局限,Skip 也不例外。因此,Skip 让你可以直接从 Android 构建中排除不受支持的 iOS 代码。你可以在应用的 iOS 端使用任意 iOS 特性,而不必破坏项目结构。当某项特性在 Android 上不可用时,只需提供回退方案或替代实现。你可以从 Skip 已支持的数千个跨平台模块中寻找解决方案,也可以移植尚不能为 Android 编译的 Swift 包。

无论是为了绕过限制,还是打造有差异化的 Android 体验,Skip 都能让你轻松集成 Android 特有方案

请记住:构建错误只是在告诉你,哪些内容可能尚未在 Android 上开箱即用。解决它们也许需要额外工作,但它们并不是不可逾越的障碍。

要在 Android 上运行和测试应用,你需要一个 Android 模拟器,或者一台已配对且启用开发者模式的 Android 设备。按照命令行参考中的说明,先运行 skip android emulator create,再运行 skip android emulator launch,即可创建并启动模拟器。

你也可以安装并启动 Android Studio.app,然后从欢迎界面的省略号菜单中打开 Device Manager,创建所需的模拟器。之后既可以通过 Device Manager 启动模拟器,也可以在终端中运行类似 ~/Library/Android/sdk/emulator/emulator @Pixel_6_API_33 的命令。

Android Studio 设备管理器截图

要在已连接的 Android 设备上安装并运行应用,必须按照 ADB 文档在设备上启用 USB 调试,然后将 Android 设备与开发电脑配对。

请确保同一时间只有一台真机一个模拟器处于运行状态,否则 Skip 无法判断应该在哪里启动应用。也可以在项目的 .xcconfig 文件中,将 ANDROID_SERIAL 变量设置为目标真机或模拟器的标识符。运行 /opt/homebrew/bin/adb devices 可查看所有已配对的可用标识符。

如果你按照创建应用中的说明使用 skip create 创建了项目,那么 Skip 应用每次成功构建后,都会自动尝试在正在运行的 Android 模拟器或设备上启动。必须恰好有一个模拟器或设备处于运行状态,Skip 项目的 Launch APK 脚本阶段才能成功安装并运行应用。

如果 Skip 的 Xcode 插件出现问题,请查看常见问题排查

默认情况下,每当你从 Xcode 运行 iOS 应用时,Skip 也会创建并运行 Android 应用。在迭代开发过程中并排构建和运行两端应用,有助于确保它们的外观和行为一致。

调试 iOS 特有问题时,可以编辑项目根目录中与 Package.swift 同级的 AppName.xcconfig 文件,将 SKIP_ACTION = launch 改为 SKIP_ACTION = build,从而禁止启动 Android 应用。这样仍会构建 Android 端,但不会在模拟器上启动。

如果要完全跳过 Android 构建,请在同一文件中设置 SKIP_ACTION = none。当你只专注于 iOS 时,这可以加快构建速度。

如果选择创建彼此独立、但共享双平台 Swift 框架的 iOS 与 Android 应用,那么你需要在各自对应的 IDE 中构建和运行它们。项目类型指南提供了将双平台框架整合到开发流程中的建议。

在 Xcode 中构建双平台框架时,会构建 iOS 代码并运行 SkipStone 构建插件,但不会执行 Android 构建。受 Xcode 插件限制,调用 Android 编译器的方式只有两种:以 macOS 为目标运行模块的单元测试套件,或者导出框架的构建产物。详情请参阅测试部署文档。

框架测试开发截图

使用 Skip 编写双平台代码与开发标准 iOS 应用很相似,而看到自己的 Swift 与 SwiftUI 在 Android 上运行会是非常棒的体验。不过,同时面向两个平台也会带来纯 iOS 开发中没有的复杂性:

  1. 开发过程中,你很可能会希望使用某个尚未在 Android 上获得支持的 iOS API、框架或特性。本节会介绍遇到双平台覆盖限制时有哪些选择。
  2. 移植指南总结了编译跨平台 Swift 时常见的问题。此外,编译后的 Swift 必须通过桥接与 Android 的 Kotlin 和 Java API 交互。
  3. 编写双平台应用也意味着要使用双平台库。依赖管理文档介绍了如何使用其他双平台库,以及 iOS 或 Android 特有库。

当开发方向出现问题时,Skip 会尽可能早地发出警告。例如,Skip 构建插件可能在真正尝试为 Android 编译项目之前,就报告警告和错误。无论错误来自桥接、转译、Kotlin 编译还是原生编译,Skip 都会尽量将其映射回出错的 Swift 源代码,并在 Xcode 中显示。因此,每条错误消息通常会出现两次:一次直接显示在 Swift 源码中,另一次显示在 Xcode 侧边栏的问题导航器里。点击条目即可跳转到出错代码。

Kotlin 编译错误截图

最常见的构建错误包括:

  • 使用了 Android 尚不支持的 API。对于尚未移植到 Android 的 API,下文会介绍可选方案。
  • 跨平台 Swift 需要调整 import。详情请查阅移植指南

处理错误是开发工作不可分割的一部分。请务必阅读调试一章,了解如何查看生成代码、读取日志,以及调试 Skip 框架或应用的 Android 端。


Google 推荐使用 Jetpack Compose 开发 Android 用户界面。Skip 可以将 SwiftUI 的大部分内容转译为 Compose,让你使用 SwiftUI 构建跨 iOS 与 Android 的界面。你也可以在自己选择的 Android IDE 中,用纯 Compose 单独编写 Android UI。正如跨平台主题所述,Skip 甚至允许你在 SwiftUI 与 Compose 之间灵活切换。最终,是使用 SwiftUI、Jetpack Compose,还是将二者结合,由你决定。

无论使用 Skip 转译的 SwiftUI,还是直接用 Kotlin 编写 Compose API,Skip 都会确保 @Observable 模型类型参与 Compose 状态跟踪。这样,它们就能像驱动 iOS 界面一样,无缝驱动 Android 用户界面。

@Observable 的集成过程是透明的,但用 Swift 模型驱动自定义 Compose UI 时需要注意:

  • 任何定义 @Observable 类型的 Swift 文件都必须 import SkipFuse。该导入会启用状态跟踪,使 Android UI 与 Swift 模型保持同步。如果缺少此导入,SkipStone 构建插件会发出警告。
  • 要从 Kotlin UI 使用 @Observable,请确保该 @Observable 已被桥接
  • 如果正在编写自定义 Compose UI,还必须添加对 SkipModel 的 SwiftPM 依赖,@Observable 才能在 Android 上正常工作,如下例所示。使用 SwiftUI 界面时不需要额外添加,因为对 SkipFuseUI 的依赖已经包含 SkipModel
...
let package = Package(
name: "travel-posters-model",
...
dependencies: [
.package(url: "https://github.com/skiptools/skip.git", from: "1.2.0"),
.package(url: "https://github.com/skiptools/skip-model.git", from: "1.0.0"), // <-- 在此添加
.package(url: "https://github.com/skiptools/skip-fuse.git", from: "1.0.0")
],
targets: [
.target(name: "TravelPostersModel",
dependencies: [
.product(name: "SkipFuse", package: "skip-fuse"),
.product(name: "SkipModel", package: "skip-model") // <-- 在此添加
],
plugins: [.plugin(name: "skipstone", package: "skip")]),
...
]
)

TravelPosters 示例中的 CityManager 类型,展示了如何在相互独立的 iOS 与 Android 应用之间共享 @Observable

Skip 允许你在两个平台之间共享全部或部分用户界面。由于 Skip 会在 Android 上把 SwiftUI 视图转换为 Jetpack Compose,最终两个平台得到的都是真正的原生界面,而不是对原生效果的跨平台模拟。

在 Android 上,import SwiftUI 会提供 SkipFuseUI,由它把 SwiftUI 视图桥接到 Jetpack Compose。View 及其 SwiftUI 属性必须使用默认(internal)或 public 可见性。私有视图和属性对 Skip 桥接层不可见,因此不会出现在 Android 上。下面是一个有效的跨平台 SwiftUI 视图:

import SwiftUI
struct MyView : View {
@State var counter = 1 // 所有 SwiftUI 类型和成员都应使用 internal 或 public 访问级别
private let title = "..." // 非 SwiftUI 成员可以使用 private
...
var body: some View {
...
}
}

SkipFuseUI 底层使用 SkipUI 用户界面库。SkipUI 文档列出了受支持的 SwiftUI 组件,并介绍了各种 SkipUI 主题。阅读这些内容可以帮助你在编写跨平台 SwiftUI 时避开常见问题。


Skip 对 iOS API 的覆盖范围会随每个版本持续扩大,但仍有部分 iOS 框架和特性尚不能在 Android 上使用。当前覆盖情况请查看 SkipUI 支持的组件

使用 Android 尚不支持的 iOS API,会触发“API 不可用”错误,或者 Android Swift 编译器的构建错误。遇到错误时,请先查看移植指南,确认该 API 是否其实可用,只是 Android 端需要使用不同的导入方式。如果正在编写 SwiftUI 代码,请查阅 SkipUI 模块了解支持范围。

当 Android 缺少所需 API 时,你仍有多种选择。可以尝试使用受支持的替代 API 完成任务。Swift Package Index 列出了许多已知能够为 Android 构建的跨平台 Swift 包。如果找不到现成方案,可以使用 Skip 的 iOS 与 Android 集成技术,分别实现 iOS 和 Android 代码路径,充分利用各平台的原生方案。如果所需 API 所属框架已经有 Android 镜像——无论是 Skip 开源库还是社区库——也许只需在现有库中补充缺失 API 即可。如果扩展了现有库,请考虑将改进贡献回 Skip 社区。按照这里的说明可以配置 Xcode,用于本地 Skip 库开发。

Skip Fuse 支持数千个第三方模块。如果这些模块无法满足需求,并且 Skip 库社区库中也没有所需功能,可以考虑自行创建双平台库或共享 API。也欢迎将成果作为社区库贡献出来。

某些 iOS 应用扩展和功能尚未在 Android 上实现,或者在 Android 上没有直接对应物。请使用跨平台主题中的技术实现仅 iOS 或仅 Android 的方案。例如,可以通过编译器指令将 iOS 小组件排除在 Android 构建之外,再加入一个 Kotlin 文件,为 Android 实现原生小组件。


嵌入自定义 SwiftUI 与 Compose 视图

Section titled “嵌入自定义 SwiftUI 与 Compose 视图”

Skip 让在共享 SwiftUI 代码旁结合平台特有 UI 框架变得非常容易。在 iOS 上可以使用任意 SwiftUI 视图,包括由 MapKit、Charts 或其他 Apple 框架支持的视图;在 Android 上,则可以在 #if SKIP 代码块内通过 ComposeView 直接进入 Jetpack Compose。

核心模式如下:

  1. 使用 #if !SKIP 包裹 iOS 特有的导入和视图代码。
  2. 使用 #if SKIP(或 #else 分支)包裹 Android 特有的 Compose 导入和视图代码。
  3. 在 Android 上使用 ComposeView,从 SkipUI 视图层级桥接到原始 Jetpack Compose 可组合项。

下面来自 Skip Showcase 应用的示例展示了这种模式:在 iOS 上嵌入 Apple MapKit 的 Map,在 Android 上嵌入 Google Maps 的 GoogleMap 可组合项。

// Copyright 2023–2026 Skip
import SwiftUI
#if !SKIP
import MapKit
#else
// 在 skip.yml 中添加依赖:implementation("com.google.maps.android:maps-compose:6.4.1")
import com.google.maps.android.compose.__
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
#endif
struct MapPlayground: View {
var body: some View {
MapView(latitude: 48.8566, longitude: 2.3522)
}
}
struct MapView : View {
let latitude: Double
let longitude: Double
var body: some View {
#if !SKIP
// 在 Darwin 平台上使用新的 SwiftUI Map 类型
if #available(iOS 17.0, macOS 14.0, *) {
Map(initialPosition: .region(MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1))))
} else {
Text("Map requires iOS 17")
.font(.title)
}
#else
// 在 Android 平台上,通过 ComposeView 使用 com.google.maps.android.compose.GoogleMap
ComposeView { ctx in
GoogleMap(cameraPositionState: rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(LatLng(latitude, longitude), Float(12.0))
})
}
#endif
}
}

还可以在 Skip 应用中嵌入传统平台视图:iOS 上的 UIKit UIView 子类,以及 Android 上基于传统 XML 的 View 子类。当你需要集成没有 SwiftUI 或 Compose 对应实现的现有平台组件时,这种方式很有用。

在 iOS 上,使用 UIViewRepresentable(macOS 上使用 NSViewRepresentable)包装 UIKit 视图,以便在 SwiftUI 中使用。在 Android 上,则在 ComposeView 内使用 Compose 的 AndroidView 工厂包装传统 Android 视图,以便加入 SkipUI 视图层级。

下面来自 Skip Bookings 应用的示例展示了如何在 iOS 上嵌入 WKWebView,并在 Android 上嵌入 android.webkit.WebView

import SwiftUI
#if !SKIP
import WebKit
#else
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.ui.viewinterop.AndroidView
#endif
// 将传统 UIKit.UIView/AndroidView 适配到 SwiftUI/Compose 所需的平台特有 View 父类型
#if canImport(UIKit)
typealias ViewAdapter = UIViewRepresentable
#elseif canImport(AppKit)
typealias ViewAdapter = NSViewRepresentable
#else
typealias ViewAdapter = View
#endif
/// 这是一个可作为嵌入式浏览器视图使用的最简 WebView。
/// 它不包含地址栏或导航按钮。
/// 如需更高级的网页组件,请使用 https://github.com/skiptools/skip-web
struct WebView: ViewAdapter {
let url: URL
var enableJavaScript: Bool = true
#if SKIP
// 在 Android 平台上,先用 AndroidView 包装 WebView,
// 将传统视图适配到 Compose 上下文,再用 ComposeView 包装,
// 使其集成到 SwiftUI 视图层级中
var body: some View {
ComposeView { context in
AndroidView(factory: { ctx in
let webView = WebView(ctx)
let client = WebViewClient()
webView.webViewClient = client
webView.settings.javaScriptEnabled = enableJavaScript
webView.setBackgroundColor(0x000000)
webView.loadUrl(url.absoluteString)
return webView
}, modifier: context.modifier, update: { webView in
})
}
}
#else
// 在 Darwin 平台上,使用 UIViewRepresentable/NSViewRepresentable
// 适配系统加载 WKWebView,将传统 UIKit 视图接入
// SwiftUI 视图层级
func makeCoordinator() -> WKWebView {
let webView = WKWebView(frame: .zero)
webView.configuration.defaultWebpagePreferences.allowsContentJavaScript = enableJavaScript
webView.load(URLRequest(url: url))
return webView
}
func update(webView: WKWebView) {
}
#if canImport(UIKit)
func makeUIView(context: Context) -> WKWebView { context.coordinator }
func updateUIView(_ uiView: WKWebView, context: Context) { update(webView: uiView) }
#elseif canImport(AppKit)
func makeNSView(context: Context) -> WKWebView { context.coordinator }
func updateNSView(_ nsView: WKWebView, context: Context) { update(webView: nsView) }
#endif
#endif
}

要了解如何使用 Skip 处理本地化、资源与图片加载、JSON 编解码等常见跨平台开发任务,请参阅常见主题