Apple Vision Pro 项目拾遗——深入理解SwiftUI

536 阅读9分钟

写完Before visionOS: iOS AR场景下实现手指识别交互-ARKit + RealityKit + VisionKit后,我就进入了公司的 Apple Vision Pro 项目 inSpaze 正式接触了 SwiftUISwiftUI 的基础入门的文章很多,从能写页面到理解代码需要一次进阶,本篇文章旨在成为这样一个桥梁。

声明式编程思想

UIKit 是命令式编程UI框架,SwiftUI 是声明式编程UI框架,二者在思想本质上完全不同,要理解 SwiftUI 第一步也是最难的一步就是彻底转变编程思想。

命令式编程是一种编程范式,开发者描述的是“怎么做”,即通过一系列命令和步骤来改变界面状态。在命令式 UI 中,开发者必须明确地指定每一步操作来构建和更新界面。

声明式编程是一种编程范式,开发者描述的是“是什么”,而不是“怎么做”。在声明式 UI 中,开发者定义了界面应该是什么样子,而不是如何一步步构建这个界面。

不是很直观?没关系,我们从 UIKit 的命令式编程讲:使用UIKit时我们不断地在创建并持有 UIView 、添加到父视图上、在需要改变视图时直接修改我们持有的 view 。。。“命令”二字的关键在于我们自己创建并持有,所以可以在之后“命令”视图去做事情。

而在SwiftUI中我们编写的View我们并没有持有,所以也不存在后续可以继续命令视图做任何改变。我们只能“事先声明”在何时UI界面是什么样的。声明式编程是如何运作的?我们在 SwiftUI 文件里写的什么?

视图是状态的函数

苹果在 WWDC SwiftUI 相关视频中多次提到 The views are a function of state,如何理解视图是状态的函数?我们把这句话转变为公式:

image.png 我们写的SwiftUI代码就是f(state)函数,从数学函数f(x)或者程序function的角度都可以解读:不同的 state 的参数会返回不同的UI结果。所以我们编写的代码是所有我们预见的不同state对应的视图的所有情况的函数,某一时刻的视图状态取决于当时 state 是什么。所以我们的 SwiftUI 的返回值才是视图,我们并没有持有视图。以下是一个简单的SwiftUI代码:

import SwiftUI

struct ContentView: View {
    @State private var number: Int = 0

    var body: some View {
        VStack {
            Text("Hello, world! \(number)")
                .background(.red)
            Button("Change number") {
                number += 1
            }
        }
    }
}

bodystruct ContentView 的计算属性,返回的是不透明 View 类型,Text 会根据 state 的不同值而显示不同的内容,我们不能持有 Text 视图,而是通过声明了在不同的 state 时,body 应该返回具体怎样的不同的UI视图。

基于以上思想,我们的SwiftUI文件应该要编写所有不同的state数据对应的UI的代码,因为需要“事先声明”所有case。而在遇到问题时,应该从 state 或者系统提供的 View Modifier 去寻找解决方案,而不是再去企图“命令”、“监听”视图。改UI,就要去改数据。

View Modifier

声明视图后,我们可以像给 UIKitview 设置属性一样给视图添加个性化UI样式设置,SwiftUI 中的一个点语法叫做View Modifier,如上面的代码中的 .background(.red)View Modifier 分为以下几种:

  • 局部(Local) View Modifiers

    • 这些修饰符只影响单个视图或视图层次结构中的某一部分。它们不涉及数据的传递或共享。
    • 例子包括 .padding().background().foregroundColor().font() 等。
  • 环境(Environment) View Modifiers

    • 这些修饰符用于在视图层次结构中传递共享数据。环境不仅仅是一个全局的值字典——一个视图子树的环境可以不同于另一个子树的环境,也就是说,环境中的值会严格地向下(不含自身)传播到视图树中(从父级到子级)。
    • 例子包括 .environmentObject(_:).environment(_:_:) 等。
    • @Environment 和 @EnvironmentObject 属性包装器用于在视图之间传递数据。
  • 偏好(Preference) View Modifiers

    • 偏好修饰符也用于在视图层次结构中传递特定的偏好值,与 Environment 相反,Preference 是从向上(不含自身)传播(从子级到父级)。它们通常用于自定义布局和视图协调。
    • 例子包括 .preference(key:value:) 和 .anchorPreference(key:value:transform:) 等。
    • 使用 PreferenceKey 协议定义偏好键,并通过 .onPreferenceChange(_:_:) 捕获偏好值的变化。
  • 绑定(Binding) View Modifiers

    • 这些修饰符用于将视图的某些属性绑定到外部数据源。它们允许视图和数据之间的双向绑定。
    • 例子包括 .binding(_:),虽然 SwiftUI 本身没有这样的修饰符,但 @Binding 属性包装器和一些修饰符(如 .toggleStyle(_:))本质上涉及绑定。

为什么要了解这个?闭上眼敲View Modifier显示什么算什么不行就调不就行了?不是的,有些情况下我们了解这些之后就能明白为什么代码产生了奇怪的现象,例如下面的示例:

image.png 为什么 Text 设置 navigationTitleModifier 可以生效?我们看 debug log(给视图添加了一个Mirror的 debug 扩展,可以 print 当前 View 的类型),navigationTitle 是一个 Preference 类型的 Modifier ,也就是从下到上传播,且不包含最上级,所以 NavigationStack 平级的 navigationTitle 不会生效。类似的情况 Environment 可以试一下给 VStack 添加 .font Modifier。

Layout

视图修饰后,下一步就是完成视图布局,视图布局过程的任务是给视图树中的每个视图分配位置和大小,在创建一个View视图之后会编写大量代码处理布局,而在 SwiftUI 中,这个算法在原理上是简单的:对于层次结构中的每个视图,SwiftUI 都会提供一个大小(可用空间)。视图在可用空间内进行布局,并报告其实际大小。系统(默认情况下)然后在可用空间内将视图居中。

通俗得讲,视图的排版过程分三步:

  1. 上层视图告诉下层视图自己拥有多少空间,并询问他们需要申请多少空间。
  2. 下层视图告诉上层视图自己需要申请多少空间,这个空间可以不考虑上层视图提供的大小。
  3. 1和2可能会反复协商多次之后,上层视图拿到所有下层视图申请大小的数据,根据自己的布局规则决定将下层视图放置在哪个位置。

每一个视图的布局就是沿着这样一个布局链去完成自己的任务:一是从上层视图拿到空间后返回自己的子视图们一共需要多少的空间,二是把子视图放置在合适的位置。所以我们如果要自定义 Layout 也就需要实现 Layout协议的两个 function:

struct MyCustomLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        // 计算并返回布局的理想尺寸,参考subviews子视图们的需求
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        // 确定并设置每个子视图的位置和尺寸
    }
}

布局过程是系统自动完成的,开发者要关注的是我们添加的组件本身默认会以怎样的大小去向上申请。View 按申请空间大小的规则可以分为以下三种:

HuggingNeutralExpanding
按需申请由子视图决定默认占满
TextVStackColor
ImageButtonImage.resizable()
.........

所以有时为了把视图空间填满,在视图底部放一个 Color

视图生命周期和视图刷新

UI框架需要呈现一个更新的视图树时,最简单的实现方式可能是抛弃所有内容,从零开始重新绘制屏幕。然而这是低效的,因为底层视图对象(例如 TableView )的重建成本可能很高。更糟糕的是,重建这些视图对象可能意味着丢失视图状态,例如滚动位置、当前选择等。

为了解决这个问题,SwiftUI 需要知道哪些底层视图对象需要更改、添加或删除。换句话说:SwiftUI 需要将之前的视图树值(评估 body 的结果)与当前的视图树值(在状态更改后重新评估 body 的结果)进行比较。

我们可以把评估 body 的结果看做两部分:一部分是视图结构,如 VStack<TupleView<(Button<Text>, Text)>> ,另一部分是数据绑定池。

视图处理的全过程:

  • SwiftUI 首先对视图进行求值( 由外向内 )。
  • 在全部求值结束后开始进行布局( 由父视图到子视图 )。
  • 在布局结束后,调用视图对应的 onAppear 闭包(顺序不明)。
  • 渲染视图。

可以驱动视图进行更新的源被称之为 Source of Truth,它的类型有:

  • 使用 @State@StateObject@Binding@Environment 这类属性包装器声明的变量。
  • 视图类型( 符合 View 协议 )的构造参数。
  • 例如 onReceiveonChangeOf这类的事件源。
  • NotificationTimerAnimation状态等。

SwiftUI 使用感受

讲一下 SwiftUI 的使用感受:绝大部分的 UI 搭建是能够满足的,而且开发效率很高,我所在的visionOS项目UI设计师后期也可以上手去完成简单的 UI 页面开发、UI验收修改。但是与UIKit相比还是有部分功能未能支持,或者说需要通过违反 SwiftUI 初衷的方式去解决(例如GeometryReader)。但是苹果这几年在 SwiftUI 的更新也可以看出来,需要 GeometryReader 才能实现的,逐步增加了直接的 View Modifier 去解决,比如 .containerRelativeFrame 解决占内容大小比例的问题、比如 WWDC2024 增加了 ScrollPosition解决监听 ScrollView 滚动位置问题。

同时UI框架向声明式编程范式进化是一个趋势,例如FlutterArkUILitho等的出现,就像更现代的语言如 Swift 是一个趋势一样。

最后

这一年在SwiftUI开发过程中遇到了很多问题,也记了大量笔记,时间匆忙,只从中摘取了部分比较核心的内容,文章没有涉及的其他方向,甚或UI布局的问题、Apple Vision Pro 相关,后续再慢慢补充。