常见开发主题
本章介绍如何使用 Skip 在跨平台中执行各种常见开发任务。
使用 Skip.env 进行配置
Section titled “使用 Skip.env 进行配置”Skip 应用的自定义应主要通过直接编辑附带的 Skip.env 文件来完成,而不是在 Xcode 中更改应用设置。只有在 Skip.env 文件中设置的属性(如 PRODUCT_NAME、PRODUCT_BUNDLE_IDENTIFIER 和 MARKETING_VERSION)才会同步到 HelloSkip.xcconfig 和 AndroidManifest.xml。通过这种方式,可以使应用元数据的一个子集在 iOS 和 Android 版本之间保持同步,但这需要你放弃使用 Xcode Build Settings 界面(它不会修改 .xcconfig 文件,而是在本地 .xcodeproj/ 文件夹中覆盖设置)。
自定义模块包名
Section titled “自定义模块包名”默认情况下,Skip 会根据 Swift 模块名自动生成 Java/Kotlin 包名,方法是将模块名从驼峰命名转换为点分隔格式(例如 MyAppModule 变为 my.app.module)。如果你需要匹配特定的命名规范,或者在与第三方 Android 库集成时需要特定的包结构,你可能需要自定义这个包名。
你可以通过编辑模块的 Skip/skip.yml 文件并在 skip 组下添加 package 属性来自定义此包名。你还必须更新项目的 Skip.env 文件,在 ANDROID_PACKAGE_NAME 变量中指定新的包名。这确保生成的 AndroidManifest.xml 和其他项目元数据正确引用你的新包名。
# MyAppModule 的 Skip 配置skip: package: 'my.custom.package.name'// Android 入口点的包名,由 AndroidManifest.xml 引用ANDROID_PACKAGE_NAME = my.custom.package.name// Kotlin Main.kt Android 应用入口package my.custom.package.name将你的应用本地化为多种语言可以最大限度地扩大其覆盖范围。本地化是让你的应用被全球用户访问的关键部分。Skip 通过将 SwiftUI 引入 Android 来实现真正的通用性,但另一半是确保用户能够理解你的应用内容。
Skip 支持 Xcode 15 新增的 xcstrings 目录格式,为添加多语言支持提供了简单便捷的解决方案。
考虑以下默认 “Hello” 应用中 Tab 栏页面的 SwiftUI 代码片段,显示 “Hello Skipper!” 消息。
VStack { Text("Hello \(name)!") Image(systemName: "heart.fill") .foregroundStyle(.red)}.font(.largeTitle).tabItem { Label("Welcome", systemImage: "heart.fill") }对于设备语言设置为法语的用户,我们希望 Tab 项显示为 “Bienvenue”,消息显示为 “Bonjour Skipper!”。这可以通过在 Xcode 中编辑 Localizable.xcstrings 文件并为每种支持的语言填写翻译来完成。字符串插值通过将变量(例如 "\(name)")替换为 token "%@" 来处理,这会使翻译后的字符串在运行时插入需要替换的任何变量。
要在 SwiftUI 之外本地化字符串,请使用 iOS 标准的 NSLocalizedString 函数,该函数也要求你为任何变量插入 token:
let localizedTitle = NSLocalizedString("License key for %@")sendLicenseKey(key, to: user, title: String(format: localizedTitle, user.fullName))Xcode 15 原生支持编辑字符串目录,提供了便捷的用户界面:
结果是,通过更新这一个文件,你就可以将应用本地化为多种语言,使这些语言的母语用户能够轻松使用你的应用。例如,默认的 “Hello” 应用包含英语、法语、西班牙语、日语和中文的本地化,以下是 iOS 和 Android 版本的展示:










xcstrings 格式
Section titled “xcstrings 格式”Skip 插件处理 .xcstrings 本地化格式,这是 Xcode 15 用作应用本地化的单一真实来源的格式。由 skip init --appid=… hello-skip HelloSkip 创建的默认项目会生成一个 Sources/HelloSkip/Resources/Localizable.xcstrings 文件,可以作为向项目添加新语言和字符串翻译的起点。
Localizable.xcstrings 文件是一个简单的 JSON 文件,可以通过 Xcode 编辑,也可以交给专业翻译人员翻译后重新集成到你的应用中。其结构简单,可以手动编辑或使用机器翻译工具进行编辑。格式摘录如下:
{ "sourceLanguage" : "en", "strings" : { "Hello %@!" : { "localizations" : { "es" : { "stringUnit" : { "state" : "translated", "value" : "¡Hola %@!" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bonjour %@!" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "こんにちは、%@!" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "你好,%@!" } } } }, "Welcome" : { "localizations" : { "es" : { "stringUnit" : { "state" : "translated", "value" : "Bienvenido" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bienvenue" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ようこそ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "欢迎" } } } } }, "version" : "1.0"}以下表格列出了将在运行时被替换的支持的 token:
| Token | 含义 |
|---|---|
| $@ | 字符串或字符串化的实例 |
| %ld 或 %lld | 整数 |
| %lf 或 %f | 浮点数 |
| %.3f | 格式化的浮点数 |
| %1$@ | 手动指定的位置参数 |
| %% | 转义的百分号字符 |
有关 xcstrings 格式的 Xcode 编辑器的更多信息,请参阅 https://developer.apple.com/documentation/xcode/localizing-and-varying-text-with-a-string-catalog ↗。
接受 String 的 SwiftUI 组件(如 Text("Hello \(name)!") 或 Button("Click Me") { doSomething() })以及标准的 NSLocalizedString 函数都假定本地化字符串定义在 main bundle 中。在 Android 端,Bundle.main 使用主应用模块中定义的资源,即应用中包含入口点和顶级资源的模块。
然而,当你通过将应用拆分为独立的 SwiftPM 模块来进行模块化时,对这些字符串键的引用仍然会假定它们定义在 main bundle 中,而不是 module bundle 中。这个假设内置于 SwiftUI 和转译后的 SkipUI 中。这意味着如果你创建了一个要在应用之间共享的 UI 组件库模块,你需要手动指定应该引用的 bundle 来获取翻译后的键。
每个接受本地化字符串的 SwiftUI 组件都会有一个接受 Bundle 参数的构造函数。NSLocalizedString 函数也接受一个可选的 Bundle。你通常希望使用 Bundle.module 值作为参数,这将使其引用组件的模块。例如:
VStack { Text("Hello \(name)!", bundle: .module) Button { doSomething() } label: { Text("Click Me", bundle: .module) }}
...
let localizedTitle = NSLocalizedString("License key for %@", bundle: .module)sendLicenseKey(key, to: user, title: String(format: localizedTitle, user.fullName))额外的 bundle 参数会使你的代码更冗长,但好处是无论组件是定义在顶级应用包中还是独立模块中,都可以使用。
本地化原始字符串
Section titled “本地化原始字符串”除了本地化 SwiftUI 组件外,还可以使用 Foundation 函数 NSLocalizedString 访问字符串本地化字典,从而对界面元素之外构建的字符串进行本地化。
例如,创建一个带有本地化字符串的局部变量:
let helloWorld = NSLocalizedString("Hello World", bundle: .module, comment: "greeting string")comment 参数是必需的,用于为翻译人员提供上下文。
以这种方式本地化的字符串参数化比 SwiftUI 元素稍微复杂一些,因为自动字符串插值不会被转换为 %@ token:
let personName = "Skipper"let greeting = String(format: NSLocalizedString("Hello, %@!", bundle: .module, comment: "parameterized greeting"), personName)Skip 支持 Apple UserNotifications 框架的核心 API,使你的 iOS 通知处理代码能够在跨平台中工作。但是,将通知功能集成到你的应用中的设置将因你使用的推送服务而异。Skip 的 Firebase 支持 内置了推送消息功能。按照其说明在你的双平台应用中支持基于 Firebase Cloud Messaging 的推送通知。
当用户点击通知时,你通常希望将他们引导到应用的特定部分。为此,我们建议在通知元数据中发送深度链接 URL。下一节将讨论如何在跨平台应用中支持深度链接,我们的 FireSide 示例应用 ↗ 演示了推送通知、深度链接以及它们的结合使用。以下摘录展示了如何从 UNUserNotificationCenterDelegate 将用户引导到应用中的某个位置:
public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { // 在通知 payload 中查找自定义 'deep_link' 键,它应该是一个带有应用 scheme 的 URL let content = response.notification.request.content if let deepLink = content.userInfo["deep_link"] as? String, let url = URL(string: deepLink) { Task { @MainActor in await UIApplication.shared.open(url) } }}深度链接允许你将用户引导到应用的特定部分。Skip 支持自定义 URL scheme 和 SwiftUI 深度链接处理。
Darwin 端设置
Section titled “Darwin 端设置”要在 iOS 构建中支持深度链接,首先请按照 Apple 的说明在 Xcode 中注册你的自定义 URL scheme ↗。
Android 设置
Section titled “Android 设置”编辑 Android 构建的 AndroidManifest.xml,为你的自定义 URL scheme 添加 intent-filter。例如,要支持 myurlscheme,请将以下内容添加到你的 AndroidManifest.xml:
<manifest ...> ... <application ...> ... <activity ...> ... <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.DEFAULT" /> <data android:scheme="myurlscheme" /> </intent-filter> </activity> </application></manifest>SwiftUI
Section titled “SwiftUI”SwiftUI 使用 onOpenURL ↗ 视图修饰符来拦截和处理深度链接。将修饰符放在应用打开时将渲染的视图上,并使用其 action 处理给定的 URL。这通常涉及更新你的导航绑定以将用户引导到应用中的指定位置,如下例所示:
enum Tab : String { case cities, favorites, settings}
public struct ContentView: View { @AppStorage("tab") var tab = Tab.cities @State var cityListPath = NavigationPath()
public var body: some View { TabView(selection: $tab) { NavigationStack(path: $cityListPath) { CityListView() } ... .tag(Tab.cities)
NavigationStack { FavoriteCityListView() } ... .tag(Tab.favorites)
SettingsView() ... .tag(Tab.settings) } // travel://<tab>[/<city>],例如 travel://cities/London 或 travel://favorites .onOpenURL { url in if let tabName = url.host(), let tab = Tab(rawValue: tabName) { self.tab = tab // 选择编码的 Tab if tab == .cities, let city = city(forName: url.lastPathComponent)) { // iOS 在切换 Tab 后需要异步分发以读取 navigationDestinations DispatchQueue.main.async { // 将导航栈设置为根 + 指定城市 cityListPath.removeLast(cityListPath.count) cityListPath.append(city.id) } } } } }}在 iOS 上,测试深度链接处理的最简单方法是在 Safari 中输入带有自定义 scheme 的 URL。你也可以将 URL 写入日历事件或备忘录。iOS 会将文本变为可点击的链接,点击后就会打开你的应用。
Android 包含一个 adb 命令,用于向正在运行的模拟器或设备发送 intent,包括深度链接。基于上面的 SwiftUI 示例,在终端中输入如下命令:
% adb shell am start -W -a android.intent.action.VIEW -d "travel://cities/London"singleTop
Section titled “singleTop”当用户在 Android 上点击你的应用的通知或深度链接时,系统会发出一个 Intent 来打开你的应用。默认情况下,这将初始化应用主 Activity 的新实例,即使你的应用已经在运行。这意味着任何临时 UI 状态都可能会丢失。
如果你希望获得更接近 iOS 的行为——即当应用通过通知或深度链接被拉到前台时保持 UI 不变,你可以使用 singleTop 启动模式。按如下方式编辑你的 Android/app/src/main/AndroidManifest.xml:
<manifest ...> ... <application ...> ... <activity ... android:launchMode="singleTop"> ... </activity> </application></manifest>有关启动模式和其他 Activity 选项的更多信息,请阅读这里 ↗。
将共享资源放在 Sources/ModuleName/Resources/ 文件夹中。Skip 会将此文件夹中的文件复制到你的 Android 构建中,并使它们可用于标准的 Bundle 加载 API。例如,以下 Swift 代码在 iOS 和 Android 上都会加载 Sources/ModuleName/Resources/sample.dat 文件:
let resourceURL = Bundle.module.url(forResource: "sample", withExtension: "dat")let resourceData = Data(contentsOf: resourceURL)资源嵌入在 Android APK 的 assets/ 文件夹中,通过 “asset:” 协议的自定义 URL 处理器从指定路径读取资源。
Skip 支持两种资源处理模式,在模块的 Skip/skip.yml 文件中配置:
process(默认):资源被扁平化到单个输出目录中。本地化文件(.xcstrings)会自动转换为包含.strings和.stringsdict文件的.lproj文件夹。这是大多数资源的标准行为。copy:资源按原样复制,保留原始文件和目录层次结构。不执行特殊处理。本地化文件、资源目录和其他文件保持不变。当你需要维护代码在运行时导航的特定目录结构时,这很有用。
要配置资源,请在 Skip/skip.yml 的 skip 部分添加 resources 数组:
skip: resources: - path: 'Resources' mode: 'process' - path: 'ResourcesCopy' mode: 'copy'对应的 Package.swift target 声明必须包含两个资源目录:
.target( name: "MyModule", dependencies: [...], resources: [ .process("Resources"), .copy("ResourcesCopy") ], plugins: [.plugin(name: "skipstone", package: "skip")])在此示例中,Resources/ 中的文件按常规方式处理(本地化转换、扁平化),而 ResourcesCopy/ 中的文件则按原样复制并保留目录结构。两组资源都合并到同一个 Android asset 层次结构中,并可在运行时通过 Bundle.module 访问。
如果 skip.yml 中未指定 resources 部分,Skip 会回退到默认行为,即以 process 模式处理 Resources/ 文件夹。
Skip 支持在资源目录中定义的颜色名称以及应用级的 AccentColor 资源。有关如何在 iOS 和 Android 上使用命名颜色的说明,请参阅 SkipUI 模块文档。
Skip 允许你在 iOS 和 Android 上使用自定义字体。SkipUI 模块文档 详细介绍了如何安装和使用自定义字体。
Skip 支持包含 PNG、JPG 和 PDF 图片文件的 iOS 资源目录,以及导出的 Google Material Icons ↗ 和 SF Symbols ↗ SVG 文件。Skip 还支持网络图片和打包图片文件。SkipUI 模块文档 详细介绍了在哪里放置共享资源目录、如何向 Android 应用提供 SF Symbols,以及如何在 SwiftUI 中加载和显示图片。
Skip 可以通过从 PNG 或 SVG 文件生成和更新图标来帮助你管理应用图标。例如:
$ skip icon --open-preview --foreground white --random-background https://raw.githubusercontent.com/google/material-design-icons/refs/heads/master/symbols/web/crowdsource/materialsymbolsrounded/crowdsource_40px.svg有关更多信息,请参阅 skip icon CLI 参考。
Skip 完全支持 iOS 和 Android 系统配色方案,以及 SwiftUI 样式修饰符(如 .background、.foregroundStyle、.tint 等)。但是,你可能希望自定义 Android UI 的颜色和组件的某些方面,而这些无法通过 SwiftUI 的标准修饰符进行配置。Skip 为此提供了额外的 Android 专用 API。这些 SwiftUI 扩展允许你“深入底层”并操控 Skip 对 Compose 的底层使用。它们在 SkipUI 模块文档的 Material 主题中有详细说明。
打开应用设置
Section titled “打开应用设置”移动应用中的一个常见模式是将用户引导到应用的系统设置页面——例如,授予之前被拒绝的权限。在 iOS 上,这会打开设置应用并跳转到你的应用条目。在 Android 上,它会打开系统应用信息屏幕,用户可以在那里管理权限、通知、存储和其他设置。
Skip 通过标准的 UIApplication API 支持此功能:
await UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)这在两个平台上都有效,无需 #if SKIP 条件判断。例如,你可以在必需权限被拒绝时使用一个按钮来触发它:
Button("Open Settings") { Task { await UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) }}