2022 年做 Apple 多平台开发,SwiftUI 能独当一面吗?

在 2021 年的一月,我发布了一篇文章 ——「MyerSplash 完成了全平台的目标」。

MyerSplash 完成了全平台的目标

所谓的全平台,是包括 Windows、Android、iOS、iPadOS、macOS 在内的全平台。但是一共有三套代码库 ——UWP/XAML,Android 原生 Framework 以及使用 UIKit + Mac Catalyst。是的,Apple 平台本身是只有一套代码库,完全是靠 UIKit 来开发,同时使用 Mac Catalyst 技术,让适配了 iPadOS 后的 app 直接运行在 Mac 上。

当然,因为本身开发周期就比较长(可能是从 2019 年开始拖),而且当初也只打算适配 iOS,因此才直接选用了 UIKit 来开发,后来 Mac Catalyst 只能算是个美好的意外。不过回到 2021 年,当时的 SwiftUI 的发展也算是风生水起,如果在那之前我对 Apple 平台的技术栈本身有多了解的话,可能也会直接选用 SwiftUI—— 在我看来,2020 年,SwiftUI 支持 App Protocol ,Mac 电脑支持了 Apple Silicon,才算把「SwiftUI 作为一个 Apple 内多平台开发的 UI 框架」推向日程吧。

在后来的 2022 年,也就是今年初,我完全使用了 SwiftUI 来开发了 MyerTidy,在技术上最初就支持了 iOS、iPadOS、macOS 以及其他。当然因为这个 app 本身主要受众是 Mac 用户,所以如果你去搜索 iOS 和 iPadOS 上的 App Store,会发现它的版本还是停留在最初的版本 —— 向 App Store 审核人员说明这个 app 对于手机和平板的意义,可能有点困难。

那么回到标题 ——2022 年使用 SwiftUI 做 Apple 平台开发,能独当一面吗?我打算就以 MyerTidy 的开发作为经验,来聊聊这个话题。当然相比之下,MyerTidy 不是什么大 app,在这之上的开发经验也就仅限于此 app,其他不涉及的,我也不会展开说太多。

TL;TR:SwiftUI 已经具备能完整构建一个 Apple 多平台 app 的能力。目前它的迭代也处于「渐进式」的状态,能以多大的百分比去构造你的 app,取决于你跟 SwiftUI 以及 Apple 其他团队工作进度的对齐程度如何。

App Protocol 就足够了吗?

App essentials in SwiftUI - WWDC20 - Videos - Apple Developer

在 2020 年,iOS 14 和 macOS 11 开始就推出了 App Protocol。在以往,你需要通过实现 UISceneDelegateUIApplicationDelegateNSApplicationDelegate 等 Protocol 处理 App 生命周期,通过的是 Delegate 的形式。现在你可以实现 App Protocol 的 body 属性,配合 Swift 的 ResultBuilder 特性来返回你的 app 支持的多个 Scene:

@main
struct MyerTidyApp: App {
    #if os(macOS)
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    #elseif os(iOS)
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    #endif
        
    var body: some Scene {
        #if os(macOS)
        SettingsScene()
        #endif
        
        WindowGroup {
            ScaffoldView()
        }
        #if os(macOS)
        .windowStyle(.hiddenTitleBar)
        #endif
    }
}

当然,你也注意到了,光这个 App Protocol 实现本身,就用了不少 Compile Condition。

尽管 App Protocol 本身是支持 Apple 多个平台的,但是 Scene 的实现并不是 —— 就上述的例子来说,Settings Scene 只支持 macOS—— 它会出现在 macOS 上方 MenuBar 里,而其他平台不支持的话,就需要使用 #if os(macOS) 这样的形式来做到编译时条件隔离。同时 windowStyle(_:) 本身也仅支持 macOS,因此也需要上述的方式来做编译时条件隔离。

你也注意到,在最开始定义了一个名为 appDelegate 的变量,并针对不同的平台,使用不同的 PropertyWrapper 修饰。这是把 App Protocol 跟 UIKit 和 AppKit 桥接的方式。你需要在不同平台各自实现对应的 Delegate:

class AppDelegate: NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, handlerFor intent: INIntent) -> Any? {        
        if intent is DeleteEmptyFoldersIntent {
            return DeleteEmptyFoldersIntentHandler()
        } else if intent is GroupFilesInAppIntent {
            return GroupFilesInAppIntentHandler()
        } else if intent is GroupFilesByIntent {
            return GroupFilesByIntentHandler()
        } else {
            return nil
        }
    }
    
    /// This method will be called instead of SwiftUI's onContinueUserActivity
    /// I assumes that's because we are using AppDelegate.
    ///
    /// Also see: <https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623072-application>
    func application(_ application: NSApplication,
                     continue userActivity: NSUserActivity,
                     restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
        let handler = createUserActivityHandler(activityType: userActivity.activityType)
        return handler.handle(userActivity: userActivity)
    }
}

在这个例子里,之所以要实现自己的 NSApplicationDelegate,是因为 SiriKit Intents 里,需要接受 Intent 的话,只能通过 NSApplicationDelegate 的方式,而 SwiftUI App Protocol 并没有提供这样的支持。

更多关于 SiriKit Intents 的坑可以见这里:

使用 SiriKit Intents 以支持 macOS 快捷指令操作

那么回到此标题:App Protocol 就足够了吗?我的回答是:当然可以,同时你得去接受这个事实 —— 在实现某些需求的时候,你很有可能依然会遇到需要去使用 NSApplicationDelegate 等老的方式。因此在实现一个跨平台 app 的时候,你需要对 UIKit 和 AppKit 都要有足够的了解。

糟糕的向后兼容

尽管 Swift 在 2019 年的 5.0 版本已经实现了 ABI 稳定,但也仅限到 Swift 语言为止。SwiftUI 依然处于 Apple 技术里快速迭代的一个,每年都有新的 API 推出,而且不能往后兼容。这意味着要使用新 API,你必须要么从 minimum deployment 里就放弃对老版本的支持,要么就针对新老版本写不同的代码以做支持 —— 但既然这么做,我直接使用老 API 老方式来实现不可吗?尽管代码不太好看,也不 SwiftUI,但至少能用。

SwiftUI Digital Lounges 里也提到过这点:

Full Question: Are any of these changes back-deployable to previous OSes (e.g., like the new Section initialisers were last year).&amp;lt;br/>Answer: Not this year! Last year we had some nice syntactic refinements that we were able to back deploy, but many of the features this year require fundamental new support in the OS.

看起来 SwiftUI 团队也能意识到这一点,就看什么时候去做支持了。另外,既然提到向后兼容的事情,其他家是这么做的:

  • Windows 上,通过 Windows App SDK 实现往后兼容到 Windows 10 (1809) 版本
  • Android 上,Jetpack 项目(早期的 Support Library)实现往后兼容到老的 Android 版本,它的更新不随着 IDE/Android 版本更新,可以通过 gradle 独立更新版本

以下就一些例子来讲一下现在因为新特性无法向后兼容而导致的一些不好的开发体验。

举个例子:如果要在 macOS 上开发一个 MenuBarExtra 功能,在以往你需要在 NSApplicationDelegate 里实现,而到 2022 年 macOS 13 上,你可以使用 MenuBarExtra 这个 Scene 来实现。但是 macOS 13 是 2022 年的系统,我想大概不会有什么 app 会直接放弃掉上年的系统,仅仅为了 API 调用方便。同时,因为 SceneBuilder 的原因,你无法在标记为 SceneBuilder 的块里使用 if

var body: some Scene {
    // error: Closure containing control flow statement cannot be used with result builder 'SceneBuilder'
    if #available(macOS 13.0, *) {
        MenuBarExtra("MenuBar") {
            VStack {
                // todo...
            }
        }
    } else {
        // ??? How to return this?
    }
}

在这里会报错:

Closure containing control flow statement cannot be used with result builder 'SceneBuilder'

因为 ViewBuilder 实现了以下方法,上述写法在构造 View 的时候是可行的:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {

    /// Provides support for “if” statements in multi-statement closures,
    /// producing an optional view that is visible only when the condition
    /// evaluates to `true`.
    public static func buildIf<Content>(_ content: Content?) -> Content? where Content : View

    /// Provides support for "if" statements in multi-statement closures,
    /// producing conditional content for the "then" branch.
    public static func buildEither<TrueContent, FalseContent>(first: TrueContent) -> _ConditionalContent<TrueContent, FalseContent> where TrueContent : View, FalseContent : View

    /// Provides support for "if-else" statements in multi-statement closures,
    /// producing conditional content for the "else" branch.
    public static func buildEither<TrueContent, FalseContent>(second: FalseContent) -> _ConditionalContent<TrueContent, FalseContent> where TrueContent : View, FalseContent : View
}

理论上,我们可以扩展 SceneBuilder 实现对 if 的支持,但因为没有提供一个 EmptyScene 的支持,因此似乎并不行。

自定义 Layout

我认为自定义 Layout 是一个 UI 框架必须要有而且需要在早期公开的基本能力。现代的 UI 布局方式其实都是大同小异的,都存在两个步骤:测量、布局 —— 父节点问子节点需要多大的空间,子节点告诉父节点它需要的空间,父节点综合各种要素最后布局子节点到指定的坐标,这是一个「双向协调」的过程。

SwiftUI 直到 2022 年才加入了 Layout Protocol 来帮助开发者此功能,具体的使用不在这里详细说。在这之前,你只能使用官方提供的少量布局:

  • HStack/VStack/ZStack
  • LazyHStack/LazyVStack
  • LazyHGrid/LazyVGrid
  • List

你可能会以为,这些布局在之前都是使用了 Layout Protocol 来实现,但其实并不 —— 在今年推出 Layout Protocol 的同时,也推出了使用此官方 Protocol 实现的 HStackLayoutVStackLayout,配合 AnyLayout 你可以实现根据给定条件切换布局,也可以直接调用它们内实现的 Layout Protocol 方法,来辅助你实现自定义 Layout。

MyerTidy 使用了 Layout Protocol 来实现了一个 StaggeredLayout,并实现了不错的 Resize 时候的动画效果:

动图封面

对于 iOS 16 和 macOS 13 以下版本的系统,就简单 fallback 到 HStack 了:

if #available(iOS 16.0, macOS 13.0, *) {
    StaggeredGrid {
        LinksSubViews()
    }
} else {
    HStack {
        LinksSubViews()
    }
}

题外话:要实现这个动画,不要把思维定在「在 Layout 内去实现动画」,也就是说,Layout 内不应该有 MutableState,它的布局参数应该是依靠外界来改变的。如果要实现宽度改变的时候产生布局动画,那么就应该把宽度作为一个 State 来改变:

@available(iOS 16.0, macOS 13.0, *)
struct StaggeredGrid<Content: View>: View {
    @State var width: CGFloat? = nil
        
    var content: () -> Content
    
    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }
    
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                Color.clear.onChange(of: proxy.size.width) { newValue in
                    withAnimation {
                        self.width = newValue
                    }
                }
            }.frame(maxHeight: 0)
            
            StaggeredGridLayout {
                content()
            }.frame(maxWidth: width == nil ? nil: width! - 5, maxHeight: nil, alignment: .leading)
        }
    }
}

上述实现自带了一些魔法:

  • 使用 GeometryReader 来获取父 View 宽度并使用 onChange (of:) 接受宽度的改变,使用 withAnimation 来让 SwiftUI 实现动画;
  • GeometryReader 外套了个高度为 0 的 frame,是为了不让它影响 StaggeredGridLayout 本身的高度
  • StaggeredGridLayout 的最大宽度 -5,是为了能提前响应 resize Window 的宽度改变,从而产生动画

Swift Concurrency

Swift Concurrency 是在 2021 年推出的,在 Xcode 13.2 之后支持向后兼容到 macOS Catalina (10.15),算是个不错的开头。

MyerTidy 使用了 Swift Concurrency 来实现了大量图片合成「时间切片」功能:

MyerTidy 新版本上新:支持合成「延时切片」功能

其实不难:

那么 Swift Concurrency 跟 SwiftUI 有何关系呢?Swift Concurrency 的很多官方教程里,都提到说怎么标记一个方法为可挂起的方法,很简单,加个 async 就可以了:

func foo() async -> ReturnValue {
}

但是这个 async 方法必须在 Swift Concurrency 的上下文里调用,这样一层层往上走,你会发现你最后会跑到了 task(priority:_:) 这个地方:

View().task {
   await foo()
}

这样其实是比较安全的做法。 task(priority:_:) 会在 View 出现前执行,在消失后会自动 Cancel—— 当然,Swift Concurrency 的取消系统是协作式的,意味着你需要在耗时的地方手动检查一下 Task.isCancelled 来确保你的任务是能取消的。

特别注意:上述的 View「出现」和「消失」,跟 onAppear 和 onDisappear 是基本对应的,它是指 View 在整个 View 树上的结构变化。

具体可以见:

Demystify SwiftUI - WWDC21 - Videos - Apple Developer

聪明的你应该知道我想说啥了:task(priority:_:) 也仅仅支持 iOS 15 和 macOS 12 以上。如果你要往后兼容,那么写法就有点不一样了:

  • 你需要在 onAppear 的时候手动创建一个 Task 并引用着它,然后在里面去 await 你的 async 方法
  • 在 onDisappear 的时候去手动 cancel 上述引用的 Task

这意味着你需要在你的 ViewModel(或者是 State Holder,随你怎么叫)里去引用这个 Task,这个可能会有些难看。

那么其他 Apple 第一方 Framework,比如 Foundation、Core Data 等的呢?因为 Swift Concurrency 是在 2021 年推出的,官方的支持上 Swift Concurrency async API 的,也自然是从 2021 年,也就是 iOS 15,macOS 12 开始了。如果你想要支持更低版本的系统,那么就只能手动对那些你用到的 Callback 风格 API 使用 withUnsafeContinuation(function:_:) 等 API 来封装了。

万事有退路

你始终可以把新老技术混合在一起使用 —— 这是 SwiftUI 这种新技术设计之初就会考虑并需要解决的问题。你可以在一个使用 UIKit (AppKit) 的项目上混合使用 SwiftUI 的 View(事实上,MyerSplash 确实有部分 View 以及 UI Bug 是利用 SwiftUI 实现和解决的),你可以在纯 SwiftUI 上使用你的 UIKit 或 AppKit 上 View,这都是没有问题的 —— 事实上,从 SwiftUI-Introspect 此类的实现来看,有不少控件是直接沿用 UIKit 和 AppKit 上的,部分(比如 Text、Button)可能才是重新使用新的方式来自行绘制。

但即便如此,在 2022 年的这个时候,我认为启动一个项目,最好的方式还是使用 SwiftUI 来构建 ——Declarative 式的 UI 框架已经不再是「未来」,而是「现在」了,Flutter、Jetpack Compose 等的流行也佐证了这个事实。

更何况,在 Apple 这个软硬件生态的护城河围住的开发者们,也不应该仅局限于开发适用于 iPhone 上的 app 了。SwiftUI 是官方唯一的构造适用于 iOS、iPadOS、macOS、watchOS 以及 tvOS app 的方式,也是不少新系统特性(Widget、Live Activity,Complications 等)的唯一支持的技术。

当然在拥抱新技术的同时,也希望我们的目标用户们能尽早拥抱新系统,毕竟这是一个双赢的事情,不是吗?