如何面向多平台

当使用 SwiftUI 完成项目时,它基本可以在 iOS、macOS、tvOS 甚至 watchOS 上运行,但苹果从一开始就表示:SwiftUI 并不是一个多平台框架,而是一个在多个平台上创建应用的框架。意思是说它不是“编写一次,即可随处可运行”;而是“学习一次,可以应用任何地方”。

所以虽然大多数相同的基本视图类型存在于所有 Apple 平台上,但如何应用它们以及使用哪些修饰符还都需要调整,以确保每个应用程序在任何平台都具有良好的外观和感觉。言下之意,适配还是要单独做的。

创建新的 Target

在 Xcode 里,target 的概念可以粗暴理解成编辑的不同的包。例如同样的项目,面向 iOS 最终会编译一个程序包;面向 watchOS 肯定也会编辑成另外一个包;甚至用于测试,也会编译一个专门的包。所以当你希望项目面向多平台时,首先要创建一个新的 Target。

创建 Target 具体步骤如下(以创建 watchOS 平台应用为例):

在 Target 之间共享文件

创建新 Target 后,一般都会重用原 Target (例如 iOS) 的一些资源,包括:数据模型、资源文件、以及无需修改即可在两个 Target 中使用的视图等等…

给 Target 设置专用文件

假设有 Target A 和 Target B ;另外有一个 视图 A,和视图 B ,点击视图 A 的链接会跳转到视图 B…

<aside> 💡 当两个Target 的视图差别比较大的时候,最好用创建专用视图文件。我们可以复制之前完成的 Target 的文件代码作为起点进行修改。 而如果两个Target 的视图差别不是很大,您就可以通过小的调整或下面提到的 #if 条件编译来跨平台重用视图。

</aside>


具体适配实例

1. 创建新的平台 Target

2. 调整项目设置

在提取具体文件之前,需要调整一些小项目设置:

<aside> 💡 最后也是最重要的是,这款新的 Mac 应用程序将自动针对 macOS 12,这比我们目标的 iOS 版本还提前一个 SwiftUI 版本。我们可以将其强制降低到 11.5 以兼容 iOS 14,但最好还是在少数需要的地方升级 iOS 15 的现有代码。转到 iOS project 和 target 的设置,并将其部署目标 Deployment Target 提高到 15.0。这会给 iOS 构建带来一系列有趣的问题,但我们会在将内容转移到 Mac 时修复这些问题。

</aside>

3. 添加新目标需要的文件

完成了所有配置后,现在需要将各种文件添加到新的 Mac target,以便获得想要的所有功能。转到 “View” 菜单并选择 “Inspectors > File” 激活显示文件检查器,然后选择以下文件,并为每个文件选中标记为 UltimatePortfolioMac 的框:

添加后我们的代码完全不可用了,不过没关系,我们将修复它,并在此过程中使应用程序也能在其他平台上运行。

4. 创建平台调整文件

接下来需要做一些改动,用于填补某些功能在 macOS 上不可用的状况。我们会创建一个新的文件去编写这些调整,在其中会为特定平台(包括 iOS)重写一些内容。事实上,我们对所有操作系统都需要进行这些调整。

// 例如在 UltimatePortfolioApp.swift 中有以下代码:
// 它在 macOS 里是不可用的,我们看如何通过调整文件去修改它
.onReceive(
    NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification),
    perform: save
)

创建 macOS 的调整文件

首先在 macOS group 中创建名为 PlatformAdjustments.swift 的新 Swift 文件,将其放置在 UltimatePortfolioMac 文件夹和目标中,而不是原始的 UltimatePortfolio 目标中。(如果你试图把它同时放在这两个地方,会更糟),然后添加以下代码:

// 在 PlatformAdjustments.swift 文件中添加 Foundation 导入
import Foundation

// 1.为其提供第一个解决方法
// 这意味着“不管在代码任何地方看到 InsetGroupedListStyle,都会用 SidebarListStyle 代替
typealias InsetGroupedListStyle = SidebarListStyle

// 2. 由于无法在 macOS 上访问 UIApplication ,因此需要一个更好、更跨平台的解决方案:我们将让每个平台决定应该关注哪个通知
// 将其添加到针对 macOS 平台的调整文件中:
extension Notification.Name {
    static let willResignActive = NSApplication.willResignActiveNotification
}

创建 iOS 的调整文件

对 iOS 系统也需要类似的调整,这意味着在 UltimatePortfolio 目标中,也创建一个 PlatformAdjustments.swift 文件,添加以下代码:

import SwiftUI

// 将来会添加更多内容,但现在它只需要添加自己的 willResignActive 通知名称:
extension Notification.Name {
    static let willResignActive = UIApplication.willResignActiveNotification
}

// 是的,两者之间的唯一区别是 UIApplication 与 NSApplication ,但稍后还需要其中一个用于 watchOS
// 完成后,就可以调整 UltimatePortfolioApp.swift 中的代码,以适应所有平台的新共享 willResignActive 通知:
.onReceive(
    NotificationCenter.default.publisher(for: .willResignActive),
    perform: save
)

另一个针对多平台的调整是颜色的使用。 Apple 在公开的颜色方面非常不一致,这意味着我们需要为想要定位的每个平台添加自定义调整。令人烦恼的是,SwiftUI 在这里也不是很好,这就是为什么我们的 Color-Additions 文件必须包装两个 UIColor 实例,以确保在 iOS 上获得原生颜色。

继续在 macOS 文件夹和目标中创建另一个新的 Swift 文件,名为 Color-Additions.swift。然后将 iOS 的 Color-Additions.swift 文件的内容复制到新的 macOS 等效文件中,然后取消选中原始目标的框,以便仅使用我们的 macOS 版本。

// 现在将 macOS 代码更改为:
extension Color {
    static let systemGroupedBackground = Color(NSColor.windowBackgroundColor)
    static let secondarySystemGroupedBackground = Color(NSColor.controlBackgroundColor)
}

用 #if 处理平台适配

#if … #elseif … #endif

项目的许多部分都需要告诉 Swift 编译完全不同的代码,这是通过启用 #if 编译器指令实现的。

例如一个更改是在 DataController.swift 中,其中有个特殊命令行参数来在测试时禁用动画。为了进行此构建,需要添加对 iOS 的检查:

#if DEBUG
if CommandLine.arguments.contains("enable-testing") {
    self.deleteAll()
    #if os(iOS)
    UIView.setAnimationsEnabled(false)
    #endif
}
#endif

另一个是 UltimatePortfolioApp.swift,我们在其中使用 UIApplicationDelegateAdaptor 属性包装器。这仅在 iOS 上可用,因此我们需要将其删除以用于其他平台:

#if os(iOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif

#if + 逻辑关系

还有个地方是 ContentView 中的 isEligibleForPrediction 属性,该属性仅适用于 iOS 和 watchOS。因此我们可以对平台做选择性编译:

#if os(iOS) || os(watchOS)
activity.isEligibleForPrediction = true
#endif

升级支持 iOS 15

有两个地方我们需要将旧的 SwiftUI 代码替换为跨平台等效代码,并在此过程中修复更多编译器错误。

第一个是 ProjectsView ,您会发现 macOS 上不允许使用 actionSheet() 修饰符。不过没关系,因为我们可以将其切换为旧的 Menu 按钮,该按钮在 macOS 上可用。但这并不是一个完美的修复,因为 SwiftUI 在 watchOS 或 tvOS 上不提供 Menu ,但至少它让我们在 macOS 上更近了一步。因此,删除旧的 actionSheet() 修饰符及其绑定的 showingSortOrder 布尔属性,然后将 sortOrderToolbarItem 代码替换为:

var sortOrderToolbarItem: some ToolbarContent {
    ToolbarItem(placement: .navigationBarLeading) {
        Menu {
            Button("Optimized") { viewModel.sortOrder = .optimized }
            Button("Creation Date") { viewModel.sortOrder = .creationDate }
            Button("Title") { viewModel.sortOrder = .title }
        } label: {
            Label("Sort", systemImage: "arrow.up.arrow.down")
        }
    }
}

// 同样,在处理 tvOS 和 watchOS 时,我们需要更仔细地重新审视这一点,但这足以让我们暂时前进

另一个需要更改地方是在 EditProjectView.swift 中,其中有旧的 alert() 修饰符。这个修饰符本身在 macOS 上很好,但我们在这里设置了一个调用 showAppSettings() 的按钮,而该按钮会在“设置”应用中显示我们应用程序的设置。正是最后一部分导致了问题:因为 macOS 没有提供等效的方式来显示应用程序的通知设置。因此我们需要将整个 showAppSettings() 方法标记为仅在 iOS 上可用:

#if os(iOS)
func showAppSettings() {
    guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
        return
    }

    if UIApplication.shared.canOpenURL(settingsURL) {
        UIApplication.shared.open(settingsURL)
    }
}
#endif

修改完后,原有的 alert() 会报错,但这没关系,我们将其移至新的 alert() 修饰符,就可以有选择地对 showAppSettings() 编译 ,像这样:

.alert("Oops!", isPresented: $showingNotificationsError) {
    #if os(iOS)
    Button("Check Settings", action: showAppSettings)
    #endif
    Button("OK") { }
} message: {
    Text("There was a problem. Please check you have notifications enabled.")
}