超谈 SwiftUI:剖析 App 结构和页面导航

前言

大家好,这是一个新系列的文章,来聊聊使用 SwiftUI 开发的一些事情。作为一名以往都是以 Android 开发任职的开发者,在使用 SwiftUI 陆陆续续有一年多时间了,尝试着去玩转 SwiftUI,也充分的利用了这个现代的开发工具,期间也不断的探索和实践,来熟悉和掌握 SwiftUI 的开发思路。

近几个月的开发成果是 MyerList、MyerTidy、MyerSplash 2 和 Photon AI Translaltor 这几个 apps。这几个有着不同的功能模块的应用,尽管都不是那种特别复杂类型的 apps,但是在使用 SwiftUI 的过程中,也感受到了其中的魅力,感觉在其中的经验已经可以让我开始输出一些东西了。

在这系列的文章里,我期望的一些输出有:

  • 我自己关于使用 SwiftUI 做 Apple 跨平台开发的一些见解
  • 一些 Apple 文档没有提到的用法
  • 分享以 SwiftUI 思维开发的组件的思路

另外之前写过一篇文章,可以配合着阅读:

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

首先第一篇是关于「App 导航和页面结构」。

App 导航和页面结构

在实现 App 的具体功能之前,我认为首先要理清它的「骨架」——也就是能把各种功能 Wire up 起来的部分。

SwiftUI App 的骨架,可以说既散又紧的——松散是因为 SwiftUI 本质上只提供 View 这一种结构来让你实现各种所谓的「页面」;聚合是因为,SwiftUI 在 iOS 和 macOS 平台的背后,依然是 UIKit 和 AppKit 那一套东西,SwiftUI 之上始终是活在 UIKit 和 AppKit 下的,被各自平台框架管理的。同时,SwiftUI 也提供了一些遵循官方导航结构的 View 组件(NavigationView 等),来帮助我们组织页面。

一个 SwiftUI app 的开始,是 2020 年引入 App Protocol,你实现此 Protocol,然后来配置 app 各种 Scene,可以有 WindowWindowGroupMenuBarExtraSettings 等。

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        #if os(macOS)
        Settings {
            SettingsView()
        }
        #endif
    }
}

Scene 之下便是 SwiftUI 的基础结构:View,很简单。


作为对比,UIKit 在不使用 Storyboard 的情况下,UIApplication 跟你自己的 MainViewController 的部分,是这样连接起来的:

private var window: UIWindow?

func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    window = UIWindow(frame: UIScreen.main.bounds)
    let controller = MainViewController(nibName: nil, bundle: nil)
    window!.rootViewController = controller
    window!.makeKeyAndVisible()
    return true
}

当然,SwiftUI 背后依然是 UIKit 和 AppKit 这一套东西,所以以上代码可能也存在于底层某处。SwiftUI 只是一个 Declarative UI 框架,给我们隐去了这些细节。举个例子,你使用 SwiftUI 的 App + WindowGroup 等描述的 App 结构,在 AppKit 上是这样的对应关系:

  • 依然会有一个 NSApplication 来表示 App 的生命周期
  • 你的 WindowGroup 的 View 实际上是在 NSWindow 里维护并绘制显示
  • MenuBarExtra 的背后是一个 NSStatusItem,如果你设置 MenuBarExtraStyle 为 Menu,那么点击后会显示 NSMenu;如果你设置 style 为 Window,那么点击后的 View 会被 NSWindow 维护
  • Settings Window 同理

这些 ”下层“ 的东西,还没有到没有必要掌握的底部。前面也提到 MenuBarExtra 这个 Scene:

可以方便地造一个纯 Menu Bar 的 macOS app,你可以设置点击后是打开一个 Window 还是一个 Menu。但缺点是只能在 macOS 13.0+ 使用,macOS 系统的升级率没有你想象的高,一旦你要支持 macOS 12 或以下,就需要手动创建 NSStatusItem 并管理它了。

我的 app Photon AI Translator 是一个纯 MenuBar 的 AI 翻译 app,支持 macOS 12 或以上,同时也是通过自行管理 NSStatusItem 和 NSWindow 的方式来实现。

从 View 开始,就是我们自由发挥的时间了。

我们通常使用「页面」这个形式来组织我们的 app,这很好理解——首页、详情页、账号页面、分享页面等。但是页面这东西,放到代码里,应该是一个怎样的东西呢?在以往,我们不知不觉地把官方的一些组件跟页面这个名词捆绑在一起:

  • 在 Android 上,是 ActivityFragment
  • 在 UIKit / AppKit 上,是 UIViewControllerNSViewController

应该注意的是,不论是 ActivityFragment 还是 UIViewController/NSViewController,都是一个 Controller 的角色——负责管理 View 以及自身的生命周期、事件等。它们具有天然的隔离性,能很好地把数据以及 View 限制在自身管理——在 Android 上甚是如此,ActivityFragment 的实例甚至不被我们创建。

SwiftUI 上导航的本质

而 SwiftUI 上导航的本质——通过当前的状态,描述 View 的存在与否。

但正如一直强调的:SwiftUI 底下依然是 UIKit 和 AppKit,所以实际上导航方式有几种:

  • 通过当前的状态,描述当前 SwiftUI View(底下则是对应 UIView 或者 NSView) 的显示与否
  • 隐式地启动/嵌入新的 UIViewController/NSViewController,并使用 SwiftUI 描述该 View

第一种是通过当前的状态,描述当前 SwiftUI View 的存在与否。

enum Page {
    case main
    case detail
}

class AppNavigator: ObservableObject {
    @Published var page: Page = .main
}

struct ContentView: View {
    @StateObject var navigator: AppNavigator
    
    var body: some View {
        ZStack {
            switch navigator.page {
            case .main:
                MainView(navigator: navigator).zIndex(0)
            case .detail:
                DetailView(navigator: navigator).zIndex(1)
            }
        }
    }
}

struct DetailView: View {
    @ObservedObject var navigator: AppNavigator
    
    var body: some View {
        Button("Go back") {
            navigator.page = .main
        }
    }
}

进一步分析下:

  • 我们通过 AppNavigator 这个 ObservableObject 来管理当前的导航状态,这是一个纯业务逻辑的东西,没有涉及 UI 显示
  • ContentView 根据 AppNavigator 的状态,来决定显示什么 View,所有可能的路径分支都在一个逻辑里,一目了然
  • 要进行导航(页面间跳转),只需要拿到 AppNavigator  对象就能做到——在 Declarative 的世界,这就是真理:你通过改变状态,然后 UI 根据你的状态,来选择自己的显示内容
特别注意,如果你使用了 ZStack 来作为页面的容器,同时你的页面有使用 transition 来实现 View 出现和消失的动画,那么需要设置到 zIndex 来描述页面之间的深度关系。如果没有使用 ZIndex 的话,那么有可能会出现动画异常,具体表现为出现的时候有动画,但是消失的时候没有。

第二种,是隐式地启动/嵌入新的 UIViewController/NSViewController,并使用 SwiftUI 描述该 View。

class AppNavigator: ObservableObject {
    @Published var showShare = false
}

struct ContentView: View {
    @StateObject var navigator: AppNavigator
    
    var body: some View {
        ZStack {
            // ...
        }.sheet(isPresented: $navigator.showShare) {
            ShareSheet(navigator: navigator)
        }
    }
}

以上代码:

此方式的导航乍一看跟上述提到的第一种差不多,不同的是通过一个 View Modifier 来描述 Sheet 的出现与否,但其实也就只是写法问题。但实际使用 sheet View Modifier 的话,会间接地 present 了一个 UISheetPresentationController (iOS)或使用了 presentAsSheet(_:) 的方式来 present 来一个 SheetPresentationWindow(macOS)。

如果你通过 Xcode 的 Debug View Hierarchy 来查看,会发现多了一个 SheetPresentationWindow

在 iOS 上则多了一个 UITransitionView 包裹着我们的 SheetView:

从这里可以看得出,SwiftUI 作为能统一 Apple 平台的 Declarative UI 框架,底下依然离不开 UIKit 和 AppKit 的东西——在表现层先做了统一,底层实现可以慢慢从目前面向不同平台做 bridge 的方式转向真正实现统一,这样对 Apple 和开发者来说都有好处。

上述的例子使用了 Sheet 来介绍,但诸如 NavigationView 都是类似的。在 iOS 上它对应的就是 UINavigationController。但 NavigationView 还是个 SwiftUI 上的 Wrapper,一些 SwiftUI 没有暴露的方法,你则需要通过类似以下的奇怪方式来实现。比如要修改 NavigationTitle 的颜色:

#if os(iOS)
    init() {
        let navBarAppearance = UINavigationBar.appearance()
        navBarAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor(Color.primaryColor)]
        navBarAppearance.titleTextAttributes = [.foregroundColor: UIColor(Color.primaryColor)]
    }
#endif

此外,我们也能看到一些 SwiftUI 的组件底下已经不是 UIKit 和 AppKit 这一套了:比如 Text。Text 底层不依赖 UITextView 或者 NSTextView,直接使用了更为底层的方式来绘制文本。你可以通过 SwiftUI-Introspect 这个库来获取底层的 UIView/NSView,里面也提到了一些 SwiftUI View 没有对应的 UIView/NSView:

题外:根据我在 macOS 上的测试,如果你使用 SwiftUI 的 Text 来显示大量文本并通过一定的间隔来刷新 UI(类似 ChatGPT 生成式文本的打字机效果),那么一旦文本过多那么会非常卡 UI 线程,使用 NSTextView 则没有这个问题。NSTextView 经过这么多年的迭代,很多性能问题理论上都已经处理得很好了,纯自绘的 Text 则过于年轻,SwiftUI 团队可能还没考虑得这么多。

总结

此文介绍了 SwiftUI App 结构以及剖析了页面导航的两种底层本质。SwiftUI 作为上层的跨 Apple 平台 Declarative UI 框架,给我们隐藏了大量的底层框架细节,让我们更专注于描述 UI 本身。了解 SwiftUI 的底层实现,有助于解决开发过程中的玄学问题。同时目前 SwiftUI 依然年轻,有很多下层的东西没有完全暴露,会导致有些效果会无法实现,了解它的底层实现,也有助于帮你脱离这个困境。

SwiftUI 在我看来依然是你新建跨 Apple 平台 app 的时候所选用的 UI 框架的第一选择。尽管 UIKit 和 AppKit 有很多概念都是相似的,但实际很多细节都是不一样的,SwiftUI 已经帮你处理好了这些平台相关的细节,让跨 Apple 平台开发的你省去了很多不该操的心。当然如果你有更低的系统版本支持的要求,你也可以完全自己使用对应平台的 App 框架来搭建 app,然后使用 SwiftUI 来做页面的 UI 实现。在之后,我也会分享如何实现自己的一个 MenuBarExtra,以支持 macOS 12 或一下的系统。

注:SwiftUI 跟各平台底下的 UI 框架的关系也符合 Progressive Disclousure 的形式,在 WWDC22 也有此相关的演讲视频,欢迎去点击查看。