超谈 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,可以有 Window
,WindowGroup
,MenuBarExtra
,Settings
等。
@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 上,是
Activity
和Fragment
- 在 UIKit / AppKit 上,是
UIViewController
和NSViewController
应该注意的是,不论是 Activity
、Fragment
还是 UIViewController
/NSViewController
,都是一个 Controller 的角色——负责管理 View 以及自身的生命周期、事件等。它们具有天然的隔离性,能很好地把数据以及 View 限制在自身管理——在 Android 上甚是如此,Activity
和 Fragment
的实例甚至不被我们创建。
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)
}
}
}
以上代码:
- 使用了 sheet(isPresented:onDismiss:content:) 来实现了一个 Sheet 的效果,如果你在 iOS 上运行,那么会发现效果跟 UISheetPresentationController 效果是一样的
- sheet(isPresented:onDismiss:content:) 最后的 block 接收一个 SwiftUI 的 View,来描述此 Sheet 的 UI
此方式的导航乍一看跟上述提到的第一种差不多,不同的是通过一个 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 也有此相关的演讲视频,欢迎去点击查看。
Subscribe to JuniperPhoton's Blog
Get the latest posts delivered right to your inbox