Files
knowledge-kit/Chapter1 - iOS/1.128.md
2024-05-08 21:57:14 +08:00

98 KiB
Raw Blame History

SwiftUI 研究

Quick Start

Xcode 新建项目 Language 选择 Swift 语言、Interface 选择 SwiftUI。然后就可以生成默认的工程项目。

可以看到下面的文件:

奇怪的事情发生了AppDelegate 不见了,也没地方构建 keyWindow怎么办为什么文件叫 SwiftUIDemoApp

其实:

  • SwiftUIDemo 是项目名称SwiftUI 规范约定,默认生成 项目名 + App.swfit
  • Apple 设计 SwiftUI 的时候,打算让 UIKit、AppKit 退居二线,所以默认没有 AppDelegate、KeyWindow
  • @mian 属性告诉编译器,这是应用程序主入口。编译器会自动生成一个入口函数,类似传统的 main 函数,内部初始化应用程序和启动 RunLoop

如果需要使用 AppDelegate 来处理一些逻辑,可以按照下面的方式:

  • 声明一个类,需继承自 NSObject、遵循 UIApplicationDelegate 协议,实现协议方法
  • 在主入口处,添加 @UIApplicationDelegateAdaptor 标记
class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        print("applicationDidFinishLaunching")
        return true
    }
    func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
        print("applicationDidReceiveMemoryWarning")
    } 
}

@main
struct SwiftUIDemoApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

QA需要注意的是在 SwiftUI 中只有部分代理可以使用。为什么?

iOS 13 以前,由 UIApplicationDelegate 来控制生命周期iOS 13 以后,由 UISceneDelegate 来控制生命周期。在 iOS 13 之后用UIScene 替代了之前 UIWindow 来管理视图,背后的设计考量主要是为了解决 iPadOS 展示多窗口的问题。

在 iOS 14 之后Apple 又给 SwiftUI 提供了更优雅的 API 来显示和控制 Scene。所以控制应用展示可以这样

@main
struct SwiftUIDemoApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    @Environment(\.scenePhase) var scenePhase
    var body: some Scene {
        WindowGroup {
            ContentView()
        }.onChange(of: scenePhase) { newScenePhase in
            switch newScenePhase {
            case .active:
                print("应用启动了")
            case .inactive:
                print("应用休眠了")
            case .background:
                print("应用在后台展示")
            @unknown default:
                print("default")
            }
        }
    }
}

SwiftUI 的文档写的还是不错。

ContentView.swift 及其效果如下:

上面的代码 some viewview 是 SwiftUI 一个核心的协议,代表了闭包中元素描述。如下代码所示,其是通过一个 associatedtype 修饰的,带有这种修饰的协议不能作为类型来使用,只能作为类型约束来使用。

通过 Some View 的修饰,其向编译器保证:每次闭包中返回的一定是一个确定,而且遵守 view 协议的类型。这样的设计,为开发者提供了一个灵活的开发模式,抹掉了具体的类型,不需要修改公共 API 来确定每次闭包的返回类型,也降低了代码书写难度。

@State 内部是在 Get 的时候建立数据源与视图的关系,并且返回当前的数据引用,使视图能够获取,在 Set 方法中会监听数据发生变化、会通知 SwiftUI 重新获取视图 body再通过 Function Builders 方法重构 UI绘制界面在绘制过程中会自动比较视图中各个属性是否有变化如果发生变化便会更新对应的视图避免全局绘制资源浪费。

Xcode 对于 SwiftUI 的支持

  • Xcode 支持预览

  • 在预览界面选中某个空间,同时按住 command + 单击,可以调出一个操作面板。第一个是 UI 检查器,可以查看和修改

    在代码区域选中控件,同时按住 command + 单击,同样可以调出一个操作面板

  • 预览模式下,支持代码和预览界面的实时刷新同步。

FunctionBuilder

Swift 源代码路径:lib/Parse/ParseDecl.cpp

// Historical name for result builders.
checkInvalidAttrName("_functionBuilder", "resultBuilder",
                       DeclAttrKind::ResultBuilder, diag::attr_renamed_warning);

遵循 View 协议的,其实本质上都是调用 body 来绘制 UI 的。@ViewBuilder 其实就是 @_functionBuilder,编译器会对它所包含的方法有一定的要求,其隐藏在各个容器类型的最后一个闭包参数中。

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View {

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required ``View/body-swift.property`` property.
    associatedtype Body : View

    /// The content and behavior of the view.
    ///
    /// When you implement a custom view, you must implement a computed
    /// `body` property to provide the content for your view. Return a view
    /// that's composed of built-in views that SwiftUI provides, plus other
    /// composite views that you've already defined:
    ///
    ///     struct MyView: View {
    ///         var body: some View {
    ///             Text("Hello, World!")
    ///         }
    ///     }
    ///
    /// For more information about composing views and a view hierarchy,
    /// see <doc:Declaring-a-Custom-View>.
    @ViewBuilder @MainActor var body: Self.Body { get }
}

FunctionBuilder 通过闭包构建样式,将闭包中的 UI 描述传递给专门的构造器,提供类似 DSL 的开发模式。

示例代码

struct ObservableObjectDemoChildView: View {
    @StateObject var p:People = People()
    var body: some View {
        VStack {
            Text("Hello SwiftUI")
        }
    }
}

VStack 点进去发现 @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)

如果没有 ViewBuilder 也就是 FunctionBuilder 的这一特性,开发者必须对容器视图进行管理。开发量陡增

var body: some View {
	var builder = VStackBuilder()
	builder.add(Text("Hello SwiftUI"))
	return builder.build()
}

但是,@_functionBuilder 也存在一定局限性ViewBuilder 的 buildBlock 最多传入十个参数,也就是布局中最多只能有十个 View如果超过十个 View可以考虑使用 TupleView 来用多元的方式合并 View。

拓展了很多种 View 的情况

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

    public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View
}

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

    public static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View
}

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

    public static func buildBlock<C0, C1, C2, C3>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> TupleView<(C0, C1, C2, C3)> where C0 : View, C1 : View, C2 : View, C3 : View
}

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

    public static func buildBlock<C0, C1, C2, C3, C4>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> TupleView<(C0, C1, C2, C3, C4)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View
}

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

    public static func buildBlock<C0, C1, C2, C3, C4, C5>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> TupleView<(C0, C1, C2, C3, C4, C5)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View
}

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

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6) -> TupleView<(C0, C1, C2, C3, C4, C5, C6)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View
}

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

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View
}

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

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View
}

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

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View
}

QA为什么给控件设置颜色等方法都是返回一个 View

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

    /// Sets the color of the foreground elements displayed by this view.
    ///
    /// - Parameter color: The foreground color to use when displaying this
    ///   view. Pass `nil` to remove any custom foreground color and to allow
    ///   the system or the container to provide its own foreground color.
    ///   If a container-specific override doesn't exist, the system uses
    ///   the primary color.
    ///
    /// - Returns: A view that uses the foreground color you supply.
    @inlinable public func foregroundColor(_ color: Color?) -> some View

}

在传统的命令式编程布局系统中,我们对一些 UI 系统结构是通常是通过继承实现的,再编写代码时通过对属性的调用来修改视图的外观,如颜色透明度等。 但这会带来导致类继承结构比较复杂,如果设计不够好会造成 OOP 的通病类爆炸,并且通过继承来的数据结构,子类会集成父类的存储属性,会导致子类实例在内存占据比较庞大,即便很多属性都是默认值并不使用

在 SwiftUI 中,当你对一个视图调用 foregroundColor 修饰符时,你实际上是在创建并返回一个新的视图。这是 SwiftUI 声明式编程模型的一部分,其中每个视图都是基于先前的视图通过添加修饰符或组合其他视图来创建的。本质也是一个 Modifier

View 上大多数调用的方法都称为 Modifier,一种是为 原地Modifier ,另外一种为 封装类Modifier原地Modifier 是返回同样类型的 View封装类Modifier 则可以返回不同类型的 View在开发中我们经常需要自定义 ViewModifier 来对 View 进行特定的变换操作。

这种设计有以下好处:

  • 声明式编程:通过将 foregroundColor 设计为 ViewModifier,符合声明式编程范式。意味着你可以通过描述你想要的界面外观和行为,而不是通过编写更新界面的代码,来构建用户界面。这种方式使代码更易于阅读和维护,同时也减少了与界面状态同步相关的错误
  • 链式调用与组合性:ViewModifier 允许开发者以链式调用的方式组合多个修饰符,从而轻松创建复杂的视图层次结构

SwiftUI 元控件

在 SwiftUI 系统中我们使用结构体遵守 View 协议,通过组合现有的控件描述,实现 Body 方法,但 Body 的方法会不会无限递归下去?

在 SwiftUI 系统中定义了 6 个元/主 View Text Color Spacer Image Shape Divider, 它们都不遵守 View 协议,只是基本的视图数据结构。

其他常见的视图组件都是通过组合元控件和修饰器来组合视图结构的。如 Button Toggle 等。

状态管理

不像 Vue/ReactSwiftUI 关键字太多了,容易搞混淆:@State、@Binding、ObservableObject、@ObservedObject、.environmentObject()、@EnvironmentObject、@StateObject

@State

和一般的存储属性不同,@State 修饰的值,在 SwiftUI 内部会被自动转换为一对 setter 和 getter对这个属性进行赋值的操作将会触发 View 的刷新,它的 body 会被再次调用,底层渲染引擎会找出界面上被改变的部分,根据新的属性值计算出新的 View并进行刷新。

import SwiftUI

struct StateDemoView: View {
    @State var name: String = ""
    var body: some View {
        VStack {
            Text(name)
            Spacer().frame(height: 100)
            Button {
                name = "杭城小刘"
            } label: {
                Text("change name")
            }
        }
    }
}

@Binding

和 @State 类似,@Binding 也是对属性的修饰,它做的事情是将值语义的属性“转换”为引用语义。对被声明为 @Binding 的属性进行赋值,改变的将不是属性本身,而是它的引用,这个改变将被向外传递.

struct Dog {
    var name: String = "Unknown"
}
struct BindDemoView: View {
    @State var dog: Dog = Dog()
    var body: some View {
        VStack {
            Text(dog.name)
            Spacer().frame(height: 100)
            ChildView(childDog: $dog)
        }
    }
}

struct ChildView: View {
    @Binding var childDog: Dog
    var body: some View {
        Button {
            childDog.name = "TaoTao"
        } label: {
            Text("点我")
        }
    }
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> {
		// ...
	  /// Creates a binding with a closure that reads from the binding value, and
    /// a closure that applies a transaction when writing to the binding value.
    ///
    /// - Parameters:
    ///   - get: A closure to retrieve the binding value. The closure has no
    ///     parameters, and returns a value.
    ///   - set: A closure to set the binding value. The closure has the
    ///     following parameters:
    ///       - newValue: The new value of the binding value.
    ///       - transaction: The transaction to apply when setting a new value.
    public init(get: @escaping () -> Value, set: @escaping (Value, Transaction) -> Void)
}

Binding 结构体使用闭包捕获了原本的属性值,使得属性可以用引用的方式保留。

SwiftUI 布局算法

SwiftUI 会通过 body 的返回值获取描述视图的控件信息,转换为对应的内部视图信息,交给 2D 绘图引擎 Metal 或者 Open GL 绘制,其中比较复杂的 Toggle 可能引用自原本的UIKit实现。

  • 父视图为子视图提供预估尺寸
  • 子视图计算自己的实际尺寸
  • 父视图根据子视图的尺寸将子视图放在自身的坐标系中

比较重要的是第二步,对于一个视图描述,通常有三种设置尺寸的方式。

  • 无需计算,根据内容推断,如 Image 是和图片等大Text 是计算出来的可视范围,类似 NSString 根据字体计算宽高。
  • Frame 强制指定宽高
  • 设置缩放比例 如 Image 设置 aspectRatio。

SwiftUI 中将计算出的模糊坐标点会对齐到清晰的像素点,避免出现锯齿感。

VStack/HStack

假设 HStack 主轴方向长度为 W1。

  • 根据人机交互指南的预留出边距 S, 边距根据元素的排列可能有多个
  • 得到剩余的主轴宽度 W2= W1 - N * S
  • 平均分配一个预估宽度
  • 计算一些具备明确宽高的元素 如 Image 设置了 Frame的元素的等。
  • 沿主轴方向从前到后计算,,如果计算出来的宽度小于预估宽度则正常显示,不够则截断
  • 最后的元素为剩余宽度,如果不够显示则阶段
  • 默认的交叉轴对齐方式为 CenterStack 占据包括最大元素的边界。

可以查看这篇文章 CSDN: SwiftUI之深入解析布局协议的功能与布局的实现教程

ObservableObject

如果说 @State 是全自动的话ObservableObject 就是半自动它需要搭配使用。ObservableObject 协议要求实现类型是 class它只有一个需要实现的属性objectWillChange。在数据将要发生改变时这个属性用来向外进行“广播”它的订阅者 (一般是 View 相关的逻辑) 在收到通知后,对 View 进行刷新。 创建 ObservableObject 后,实际在 View 里使用时,我们需要将它声明为 @ObservedObject。这也是一个属性包装它负责通过订阅 objectWillChange 广播,将具体管理数据的 ObservableObject 和当前的 View 关联起来。

class Person: ObservableObject {
    @Published var name: String = ""
    @Published var age: Int = 0
}

struct ObservableObjectDemoView: View {
    @ObservedObject var person: Person
    
    var body: some View {
        VStack {
            Text(person.name)
                .padding(.leading)
                .font(.headline)
                .fontWeight(.heavy)
                .foregroundColor(.black)
            Text(String(person.age))
                .padding(.leading)
                .font(.subheadline)
                .fontWeight(.heavy)
                .foregroundColor(.black)
            Spacer().frame(height: 30)
            Button {
                person.name = "杭城小刘"
                person.age = 28
            } label: {
                Text("点我更改")
            }
        }
    }
}
  • @ObservedObject 修饰的必须是遵守 ObservableObject 协议的 class 对象
  • class 对象的属性只有被 @Published 修饰时,属性的值修改时,才能被监听到

EnvironmentObject

在 SwiftUI 中View 提供了 environmentObject() 方法,来把某个 ObservableObject 的值注入到当前 View 层级及其子层级中去。在这个 View 的子层级中,可以使用 @EnvironmentObject 来直接获取这个绑定的环境值。

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

    /// Supplies an `ObservableObject` to a view subhierarchy.
    ///
    /// The object can be read by any child by using `EnvironmentObject`.
    ///
    /// - Parameter object: the object to store and make available to
    ///     the view's subhierarchy.
    @inlinable public func environmentObject<T>(_ object: T) -> some View where T : ObservableObject
}
class Student: ObservableObject {
    @Published var name: String = "unknown"
    @Published var age: Int = 0
    deinit {
        print("Student dealloc")
    }
}

struct EnvironmentObjectDemoView: View {
    @ObservedObject var stduent: Student
    
    var body: some View {
        VStack {
            Text(stduent.name)
                .font(.headline)
            Text(String(stduent.age))
                .font(.subheadline)
            Spacer().frame(height: 100)
            StudentChildView().environmentObject(stduent)
        }
    }
}

struct StudentChildView: View {
    @EnvironmentObject var childStudent: Student
    var body: some View {
        VStack {
            Text(childStudent.name)
                .font(.headline)
            Text(String(childStudent.age))
                .font(.subheadline)
            Spacer()
                .frame(height: 100)
            Button {
                childStudent.name = "杭城小刘"
                childStudent.age = 28
            } label: {
                Text("点我更改")
            }

        }
    }
}

@StateObject

@StateObject 行为类似 @ObservedObject 对象区别是StateObject由SwiftUI负责针对一个指定的View创建和管理一个实例对象不管多少次View更新都能够使用本地对象数据而不丢失

@StateObject@ObservedObject 区别:

  • @ObservedObject 只是作为 View 的数据依赖,不被 View 持有View 更新时 @ObservedObject 对象可能会被销毁
  • @StateObject 针对引用类型设计,当 View 更新时,实例不会被销毁,与 @State 类似,使得 View 本身拥有数据
class People: ObservableObject {
    @Published var age: Int = 0
    deinit {
        print("People dealloc")
    }
}

struct ObservableObjectLifeCycleView: View {
    @State var count: Int = 0
    var body: some View {
        VStack {
            Text("刷新 Count 计数: \(count)")
            Button {
                count += 1
            } label: {
                Text("刷新")
            }
            Spacer().frame(height: 100)
            ObservableObjectDemoChildView()
        }
    }
}

struct ObservableObjectDemoChildView: View {
    @ObservedObject var p:People = People()
    var body: some View {
        VStack {
            Text("\(p.age)")
            Button {
                p.age += 1
            } label: {
                Text("+1")
            }
        }
    }
}
  • 点击 +1 按钮Text 上的数字在 +1当点击刷新的时候Text 数字恢复为0说明 p 对象被销毁,也打印了 People dealloc
  • 点击刷新,打印 People dealloc

将 ObservableObjectDemoChildView 稍作调整

struct ObservableObjectDemoChildView: View {
    @StateObject var p:People = People()
    var body: some View {
        VStack {
            Text("\(p.age)")
            Button {
                p.age += 1
            } label: {
                Text("+1")
            }
        }
    }
}
  • 点击 +1 按钮Text 上的数字在 +1当点击刷新的时候Text 数字不会变为0说明 p 对象没有释放
  • 点击刷新,也只是 +1

@StateObject 的生命周期与当前所在 View 生命周期保持一致,即当 View 被销毁后,@StateObject 的数据销毁,当 View 被刷新时,@StateObject 的数据会保持;而 @ObservedObject 不被 View 持有,生命周期不一定与 View 一致,即数据可能被保持或者销毁。

自定义 SwiftUI 属性装饰器

import SwiftUI

@propertyWrapper
struct UserDefaultWrapper<T> {
    var key: String
    var defaultValue: T
  
    init(_ key: String, defaultValue: T) {
        self.key = key
        self.defaultValue = defaultValue
    }
    
    var wrappedValue: T {
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
        get {
            UserDefaults.standard.value(forKey: key) as? T ?? defaultValue
        }
    }
}

struct PropertyWrapperView: View {
    
    @UserDefaultWrapper("hasShowedUserGuide", defaultValue: false)
    static var hasShowedUserGuide: Bool
    
    @State private var showText = PropertyWrapperView.hasShowedUserGuide ? "已经展示过" : "没有展示过"
    var body: some View {
        Button(action: {
            if !PropertyWrapperView.hasShowedUserGuide {
                PropertyWrapperView.hasShowedUserGuide = true
                self.showText = "已经展示过"
            }
        }) {
            Text(self.showText)
        }
    }
}


struct PropertyWrapperView_Previews: PreviewProvider {
    static var previews: some View {
        PropertyWrapperView()
    }
}

SwiftUI 与 UIKit 混合开发

SwiftUI、UIKit 各有优缺点,相信你是老司机了,就不赘述了。但大多数场景下,单个框架无法满足需求,那如何混合开发呢?

第一种方式UIViewRepresentable

让 UIKit 的控件,封装成一个 SwiftUI 控件,然后在 SwiftUI 侧使用。

  • 定义一个结构体,遵循 UIViewRepresentable 协议

  • 指定 associatedtype UIViewType : UIView 关联类型声明,该关联类型指定 UIViewRepresentable 对象将桥接的具体的 UIView 子类型。

  • 关联类型在 Swift 中用于在泛型或协议中定义占位符类型,这些类型在协议的实现或泛型的使用中将被具体的类型所替代。在 UIViewRepresentable 的上下文中,UIViewType 关联类型就是这样一个占位符,它代表了你将要在 SwiftUI 中使用的具体 UIView 子类。

    当你实现 UIViewRepresentable 协议时,你需要提供 UIViewType 的具体类型。这样SwiftUI 就知道如何创建和管理这个特定类型的 UIView 实例了。

  • 实现 UIViewRepresentable 协议方法

    • @MainActor func makeUIView(context: Self.Context) -> Self.UIViewType 方法用于创建和配置 UIView 实例。当你将 UIViewRepresentable 的实例添加到 SwiftUI 视图层次结构中时,系统会调用此方法。在这里,你可以初始化你的 UIView 并设置其初始状态。这个方法返回一个 UIView实例,该实例将被嵌入到 SwiftUI 界面中
    • @MainActor func updateUIView(_ uiView: Self.UIViewType, context: Self.Context) 方法用于更新 UIView 实例的状态。当 SwiftUI 视图的状态发生变化时(例如,由于响应某个动作或绑定到某个变量的值发生变化),系统会调用此方法。你可以在这里根据新的状态来更新你的 UIView
    • @MainActor static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator) 方法用于在移除 UIView 时执行一些清理操作
    • @MainActor func makeCoordinator() -> Self.Coordinator 方法用于创建并返回一个协调器对象,该对象可以处理与托管的UIView对象之间的交互。协调器是一个自定义的对象,负责管理 UIView 的行为,并处理来自 UIView 的事件和更新。典型的应用场景,比如 UITableView 的数据和事件代理的逻辑

举个例子,包装一个 UIKit 中的 UITableView 控件给 SwiftUI 使用

// UIKItGeneratedView.swift
import SwiftUI

struct UIKItGeneratedView: View {
    var body: some View {
        VStack {
            Text("SwiftUI + UIKit Demo")
            Spacer()
            CustomView()
        }
    }
}

struct UIKItGeneratedView_Previews: PreviewProvider {
    static var previews: some View {
        UIKItGeneratedView()
    }
}


struct CustomView: UIViewRepresentable {
    
    typealias UIViewType = UITableView
    
    func makeUIView(context: Context) -> UITableView {
        let tableView: UITableView = UITableView()
        tableView.delegate = context.coordinator
        tableView.dataSource = context.coordinator
        tableView.frame = CGRect(x: 0, y: 100, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height - 100)
        tableView.backgroundColor = .red
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "CustomTableViewCell")
        return tableView
    }
    
    func updateUIView(_ uiView: UITableView, context: Context) {
        
    }
    
    func makeCoordinator() -> CustomTableViewController {
        return CustomView.CustomTableViewController()
    }
    
    class CustomTableViewController: NSObject, UITableViewDataSource, UITableViewDelegate {
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return 30
        }
        
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            var cell: UITableViewCell? = tableView.dequeueReusableCell(withIdentifier: "CustomTableViewCell", for: indexPath)
            if cell == nil {
                cell = UITableViewCell(style: .subtitle, reuseIdentifier: "CustomTableViewCell")
            }
            cell?.imageView?.image = UIImage(named: "HaiTang")
            cell?.textLabel?.text = "我是 Cell 标题"
            cell?.detailTextLabel?.text = "我是 Cell 内容"
            return cell!
        }
        
        func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            print("点击了\(indexPath.row)行")
        }
        
        func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
            return 40
        }
    }
}

//struct CustomViewController: UIViewControllerRepresentable {
//
//}

// SwiftUIDemoApp.swift
@main
struct SwiftUIDemoApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    @Environment(\.scenePhase) var scenePhase
    var body: some Scene {
        WindowGroup {
            UIKItGeneratedView()
        }.onChange(of: scenePhase) { newScenePhase in
            switch newScenePhase {
            case .active:
                print("应用启动了")
            case .inactive:
                print("应用休眠了")
            case .background:
                print("应用在后台展示")
            @unknown default:
                print("default")
            }
        }
    }
}

第二种方式UIViewControllerRepresentable

  • 按照传统的方式写一个 Swift Class。可以按照纯代码的方式写也可以按照 StoryBoard 结合代码的方式写

    CustomViewController.swift

    import UIKit
    
    class CustomTableViewCell: UITableViewCell {
    
        @IBOutlet weak var titleLabel: UILabel!
        @IBOutlet weak var contentLabel: UILabel!
    
        override func awakeFromNib() {
            super.awakeFromNib()
    
        }
    }
    
    class CustomViewController: UIViewController {
    
        @IBOutlet weak var tableView: UITableView!
    
        override func viewDidLoad() {
            super.viewDidLoad()
            tableView.delegate = self
            tableView.dataSource = self
        }
    }
    
    extension CustomViewController: UITableViewDataSource, UITableViewDelegate {
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            100
        }
    
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            var cell:CustomTableViewCell? = tableView.dequeueReusableCell(withIdentifier: "CustomTableViewCell") as? CustomTableViewCell
            if cell == nil {
                cell = UITableViewCell(style: .default, reuseIdentifier: "CustomTableViewCell") as? CustomTableViewCell
            }
            cell?.titleLabel.text = "第\(indexPath.row + 1)行"
            cell?.contentLabel.text = "我是内容\(indexPath.row + 1)"
            return cell!
        }
    
        func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
            50
        }
    
        func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            print("点击了第\(indexPath.row + 1)行")
        }
    }
    
  • 在使用的地方,新建一个结构体,遵循 UIViewControllerRepresentable 协议,实现协议方法

    • associatedtype UIViewControllerType : UIViewController 指定 associatedtype UIViewControllerType : UIViewController 关联类型声明,该关联类型指定 UIViewControllerRepresentable 对象将桥接的具体的 UIViewController 子类型
    • @MainActor func makeUIViewController(context: Self.Context) -> Self.UIViewControllerType 负责创建并配置 UIViewController 实例。这个方法在第一次需要创建视图控制器时被调用,允许你在 SwiftUI 中集成和使用 UIKit 中的视图控制器。
    • @MainActor func updateUIViewController(_ uiViewController: Self.UIViewControllerType, context: Self.Context) 方法在视图控制器的生命周期中可能会被多次调用,用于更新视图控制器的状态或属性。

    UIKitGeneratedViewController.swift

    import SwiftUI
    
    struct UIKitGeneratedViewController: View {
        var body: some View {
            VStack {
                CustomViewControllerWarpper()
            }
        }
    }
    
    struct CustomViewControllerWarpper: UIViewControllerRepresentable {
        typealias UIViewControllerType = CustomViewController
    
        func makeUIViewController(context: Context) -> CustomViewController {
            // 纯代码生成的用 CustomViewController()。 StoryBoard 生成的用 UIStoryboard(name:bundle).instantiateInitialViewController()
            let vc = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(identifier: "CustomViewController") as! CustomViewController
            return vc
        }
    
        func updateUIViewController(_ uiViewController: CustomViewController, context: Context) {
           print(context)
        }
    }
    
    struct UIKitGeneratedViewController_Previews: PreviewProvider {
        static var previews: some View {
            UIKitGeneratedViewController()
        }
    }
    

    使用

    @main
    struct SwiftUIDemoApp: App {
        var body: some Scene {
            WindowGroup {
                UIKitGeneratedViewController()
            }
        }
    }
    

最佳实践

SwiftUI 是个 UI 框架、也是个组件库,核心是为了解决 UI 构建复杂、繁琐的问题。Redux 在前端由来已久,有 store、state、action、middleware、reducer 等角色,多个角色各司其职,不存在团队规范和约定后不遵守的情况。通过 store 来管理状态,状态变化后,使用到该状态的 UI 组件会收到通知,更新 UI。用户点击操作 UI产生 actionaction 经历过一系列 middleware 后来到了 storestore 让 reducer 根据 action 和当前的 state 计算,得到一个新的 state。新的 state 变化了,使用到的地方的 UI 也会自动更新。(数据和 UI 的双向绑定)

对比 MVC

  • 苹果早期官方给的 MVC 缺少了状态管理能力,导致了实现状态管理,控制器的代码很复杂。
  • 另一个问题是状态传递很混乱,不同的开发有自己的偏好: callback、delegate、kvo、notification代码中可能存在多种状态传递的手段代码的可读性、可维护性下降团队协作很困难。
  • 很难看出某些状态会和哪些 UI 相关,在实际开发迭代、修复 bug 的过程中很容易引入新 bug

对比 MVVM

  • 状态绑定的代码比较多,代码冗余比较多,比较枯燥且可能出错。选哪个技术手段也容易受到挑战。而 Redux 则是框架已经帮忙处理好了状态绑定。
  • 可测性。容易测试业务逻辑,相对于 ViewModel 的测试,对 reducer 进行测试更容易编写reducer 是纯函数,对于给定的输入,输出也恒定,不会修改外部状态。
  • 代码风格较 Redux 不够统一,导致代码易读性不如 Redux。Redux 多个角色清晰分明,没有理解成本,对于框架层的东西,团队小伙伴不需要按照“素质”、“约定”去遵循实现。
  • 状态的改变较 Redux 不容易跟踪。如果出了问题,需要调试比较麻烦。
  • 状态传递不太方便。如果需要将状态传递到比较深的视图上,往往是不太方便的。而 Redux 可以通过框架的能力轻松的将状态送到任何地方。

开源项目

Apple 推出了 SwiftUI但没有像最早 MVC 一样,在 SwiftUI 中推出一个状态管理的官方架构,虽然 SwiftUI 有 @State@ObservedObject@StateObject 等,但这些东西在不同父子组件、兄弟组件的状态传递、状态管理 case 下,该如何组织是一个没有规范的问题。另外单测困难,因为逻辑代码耦合在 View 相关的代码中。为此,业界借鉴前端领域的 Redux Redux-like 的方案很多,比较有名的是 ReSwiftTCA,个人更倾向于 TCA全称是 The Composable Architecture。

利用 TCA 做一个简易版的计数器 App

安装依赖File -> Add Packages输入 swift-composable-architecture 搜索,点击右下角 Add Package 即可。

然后开始开发:先编写 Reducer 部分,再开发相关 UI

// Counter.swift
import ComposableArchitecture
import SwiftUI

struct Counter: Reducer {
  	// State
    struct State: Equatable {
        var count: Int = 0
    }
		// Action
    enum Action {
        case increment
        case decrement
        case reset
        case setCount(String)
    }
		// Reducer
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .increment:
                state.count += 1
                return .none
            case .decrement:
                state.count -= 1
                return .none
            case .reset:
                state.count = 0
                return .none
            case .setCount(let text):
                state.count = Int(text) ?? state.count
                return .none
            }
        }
    }
}
// TCADemoApp.swift
import SwiftUI
import ComposableArchitecture
@main
struct TCADemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(store: Store(initialState: Counter.State(count: 0)) {
                Counter()
            })
        }
    }
}
// ContentView.swift
import SwiftUI
import ComposableArchitecture

struct ContentView: View {
    let store: StoreOf<Counter>
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            VStack {
                TextField(String(viewStore.count), text: viewStore.binding(get: { state in
                    String(state.count)
                }, send: { value in
                    Counter.Action.setCount(value)
                }))
                .frame(width: 40)
                .multilineTextAlignment(.center)
                .foregroundColor(colorOfCountInfo(viewStore.count))

                Spacer().frame(height: 100)
                HStack {
                    Button {
                        store.send(.increment)
                    } label: {
                        Text("加一")
                    }
                    Spacer().frame(width: 50)
                    Button {
                        store.send(.decrement)
                    } label: {
                        Text("减一")
                    }
                    
                    Spacer().frame(width: 50)
                    Button {
                        store.send(.reset)
                    } label: {
                        Text("重置")
                    }
                }
            }
        }
    }
    
    func colorOfCountInfo(_ value: Int) -> Color? {
        if value == 0 {
            return nil
        }
        return value > 0 ? .red : .green
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(store: Store(initialState: Counter.State(count: 0)) {
            Counter()
        })
    }
}

说明:

  • 发送消息,而非直接改变状态。按钮响应事件里,通过 store 发送 action 的方式 store.send(.increment)
  • 只在 Reducer 中改变状态。类似 func reduce(into state: inout State, action: Action) -> Effect<Action> Reducer 中 inout 的 state 可以原地修改,该函数返回一个 Effect代表不该在 reduer 中进行的副作用。比如异步请求网络、文件 IO
  • 更新状态并触发渲染Reducer 中修改了状态,新的状态被 TCA 用来触发 view 的渲染TCA 使用 ViewStore 来通过 @ObservedObject 触发 UI 刷新

TCA 对单元测试的支持也很好。TestStore 是 TCA 中专门用来处理测试的一种 Store。它可以接收通过 send 发送的 Action还在内部提供断言。如果接收到 Action 后产生的新的 model 状态和提供的 model 状态不符,那么测试失败。

如下

// TCADemoTests.swift
import XCTest
@testable import TCADemo
import ComposableArchitecture

final class TCADemoTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    @MainActor func testCounterIncrement() async throws {
        let store = TestStore(initialState: Counter.State(count: 0)) {
            Counter()
        }
        await store.send(.increment) { state in
            state.count += 1
        }
    }

    @MainActor func testCounterDecrement() async throws {
        let store = TestStore(initialState: Counter.State(count: 1)) {
            Counter()
        }
        await store.send(.decrement) { state in
            state.count -= 1
        }
    }
    
    @MainActor func testCounterReset() async throws {
        let store = TestStore(initialState: Counter.State(count: 2)) {
            Counter()
        }
        await store.send(.reset) { state in
            state.count = 0
        }
    }
}

可以看到如果某个单测 case 失败,则会清楚的显示错误的信息。

如果需要在测试的时候使用“重复测试”功能,右击测试按钮,在弹出框里做重复测试的配置修改。

动手做一个简易版 Redux

新建 Redux.swift 是一个纯逻辑 Swift 文件。

//
//  Redux.swift
//  SwiftUIDemo
//
//  Created by Unix_Kernel on 4/3/24.
//

import Foundation

protocol Action {}
class IncreaseAction: Action {}
class DecreaseAction: Action {}

struct ReduxState {
    var count: Int
    init(count: Int) {
        self.count = count
    }
}

typealias Reducer = (ReduxState, Action) -> ReduxState

final class Store: ObservableObject {
    var reducer: Reducer
    @Published private (set) var state: ReduxState
    
    init(reducer: @escaping Reducer, state: ReduxState) {
        self.reducer = reducer
        self.state = state
    }
    
    func dispatch(_ action: Action) {
        self.state = self.reducer(self.state, action)
    }
}

使用的地方

工程入口文件 SwiftUIDemoApp.swift

@main
struct SwiftUIDemoApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    @Environment(\.scenePhase) var scenePhase
    var body: some Scene {
        WindowGroup {
            let state = ReduxState(count: 0)
            let reducer: Reducer = { (state, action) -> ReduxState in
                switch action {
                case is IncreaseAction:
                    return ReduxState(count: state.count + 1)
                case is DecreaseAction:
                    return ReduxState(count: state.count - 1)
                default:
                    return ReduxState(count: state.count)
                }
            }
            let store: Store = Store(reducer: reducer, state: state)
            ReduxDemoView().environmentObject(store)
        }
    }
}

另一个展示的页面 ReduxDemoView.swift

//
//  ReduxDemoView.swift
//  SwiftUIDemo
//
//  Created by Unix_Kernel on 4/3/24.
//

import SwiftUI

struct ReduxDemoView: View {
    @EnvironmentObject private var store: Store
    var body: some View {
        VStack {
            Text("数据:\(store.state.count)")
            Spacer()
                .frame(height: 50)
            HStack {
                Button {
                    store.dispatch(IncreaseAction())
                } label: {
                    Text("+1")
                }
                Spacer()
                    .frame(width: 100)
                Button {
                    store.dispatch(DecreaseAction())
                } label: {
                    Text("-1")
                }
            }.buttonStyle(.borderedProminent)
        }
    }
}

struct ReduxDemoView_Previews: PreviewProvider {
    static var previews: some View {
        ReduxDemoView()
    }
}

实现效果如下:

核心技术

SwiftUI 的渲染机制

Render loop 是驱动 SwiftUI 进行渲染更新的重要机制,了解它的原理和策略,可以揭秘 SwiftUI 高性能背后的秘密。

  • event loop事件循环基于消息事件的循环例如触摸被系统包装成一个事件一层一层传递给 UI 组件并最终触发 UI 组件渲染。

  • render loop渲染循环是一个更小的概念更多关注在消息处理和屏幕渲染上

  • invalidated无效、失效类似于 Flutter 的 dirty 。当一个 View 的关联属性改变了,或者其他原因导致 View 需要刷新View 就会被标记为 invalidated此时框架会对 View 的body 进行 evaluate 。

  • evaluate直译是评估我更倾向于翻译成计算也就是当框架发现一个 View 被标记为 invalidated 后,框架会尝试比对改变前和改变后的 body 内容。如果框架认为 body 内容改变了就会重新渲染。注意evaluation 并不一定会导致重新渲染,这取决于框架对 body 的评估结果。评估虽然不会必然导致渲染,但框架仍需读取 body 数据并进行(可能复杂的)计算以确定内容是否改变。

GUI 的本质离不开 EventLoop对于 iOS 来说,无论 UIKit 还是 SwiftUI 背后都是 RunLoop。RunLoop 会向 UI 代码分发消息,进而出发屏幕的一部分重新渲染。消息的处理和屏幕上的图形渲染构成一个应用程序的 render loop。

onAppear

在 SwiftUI 中,我们没法得到像 UIKit 中那么丰富的视图生命周期。如果我们想在一个视图出现时执行一个动作,我们只能使用一个函数:onAppear 。但是它到底是什么时候被调用的呢?是不是像 viewWillAppear 那样,在视图被渲染并在屏幕上可见之前调用?如果是的话,我们可以信赖它吗?

// ViewModel.h
import Foundation
class ViewModel: ObservableObject {
    @Published var statusText: String = "invalid"
    
    func fetch() {
        self.statusText = "loading"
    }
}
// ContentView.swift
struct ContentView: View {
    let store: StoreOf<Counter>
    @StateObject var model = ViewModel()
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            VStack {
                Text(model.statusText).padding().onAppear() {
                    model.fetch()
                }
            }
       }
}

会发现直接展示的是 “loading”没有看到过 “invalid”。onAppear 靠谱吗?真的是一出现就调用吗?比如在速度较慢的 iPhone 上,或者在有高刷的新款 iPhone 上,会发生什么?会不会因为显示器的刷新率不够而导致 Text 文字闪烁?如果我们给 Text 增加过渡动画这是否会导致问题还有上面这种代码会导致渲染效率降低吗我们可以看到body 的关联值 statusText 改变了两次,即 body 被评估了两次,那么内容也会被渲染两次吗?

从硬件开始说起

视图是如何显示在屏幕上的iPhone 有一个具有特定刷新率的屏幕。对于大多数 iPhone 来说,这是 60 赫兹。这意味着显示屏每秒刷新 60 次,而每一帧都持续 1/60 秒。最高端的 iPhone 有一个动态刷新率,最大刷新率为 120 赫兹。GPU 需要保证只在两次显示刷新之间改变视频帧。如果不这样做,屏幕就会一次合并两个帧的视频,这可能会导致图形伪影,如撕裂。

除了使用 GPU一个应用程序的部分内容也可能使用 CPU 来渲染内容。在这种情况下,图像首先被生成为位图,然后被发送到 GPU 。GPU 对图形进行转换和组合。如果一个特定的视图或一块图形的渲染成本很高,它可以由 GPU 存储到内存中。

在屏幕上显示数据只是故事的一半,还需要接收用户的输入。触摸输入通常以一个特定的频率进行采样。这个频率可能高于显示屏的刷新率。即使触摸的采样频率与显示器刷新率相同,触摸采样率和显示器刷新率也可能不完全同步。对于最新的 iPhone ,触摸采样率是 120 赫兹,是显示器刷新率的两倍。虽然我们不能以注册触摸的速度来更新屏幕,但我们可以利用这些额外的触摸数据在屏幕上显示更详细的图形。在一个绘图应用程序中,我们可以根据更多的触摸来显示绘制的笔触。

游戏大多基于 update loop (更新循环),试图生成尽可能多的帧,以满足甚至超过显示器的硬件刷新率。相反,应用程序只会在数据发生变化、响应触控等事件后才驱动系统执行绘图操作。当应用程序需要处理此类事件时,操作系统会将其唤醒,然后应用程序利用 UI 框架再次渲染屏幕的部分内容。

注册输入事件并使用这些事件在屏幕上渲染图像,需要精确地进行协调。当编写一个应用程序时,你一般不需要担心这个问题。你只需要使用手势或控制事件,然后改变视图内容。但操作系统会仔细地将事件传递给你的应用程序,使你得到的事件不会多于或少于你所需要的,以便在每次刷新显示器时准确地提供一帧,同时也提供尽可能低的延迟。

RunLoop

在苹果平台上,每个应用程序的核心 event loop事件循环背后都是 CFRunLoop 实现的。这个核心基础对象是随 Mac OS X 10.0 发布的 Carbon API 的一部分,并在许多不同的 UI 框架和迭代中存活至今。在被 Carbon 应用程序使用后,它还被 UIKit 使用,如今仍被 SwiftUI 使用。main dispatch queue (主队列)也是在 CFRunLoop 之上实现的Swift Concurrency 的 MainActor 也是如此。

想要看到 CFRunLoop 是如何工作的,最好的方法是我们创建一个自己的 run loop。假设我们正在编写一个简单的命令行程序等待用户输入然后对其采取行动。

while let input = readLine() {
  print(input)
}

我们在一个循环中读取用户的输入,如果我们接收到了什么,就对它执行一个方法,打印出来。这就是一个 run loop 。该程序可以处于两种状态。在第一种状态下,它是空闲的,等待用户输入。线程将被置入睡眠状态,而 CPU 时间被用于其他进程。当有用户输入时,操作系统会唤醒我们的线程来处理它。

如果我们还想在同一个线程中监听传入的网络事件呢?现在我们不能再使用 readLine 方法了,因为那会阻塞线程,直到有用户输入文本。有很多方法可以实现同时等待多个操作系统事件。但无论何种方式,它都需要内核支持。对于一个命令行程序,通常会使用 select 或 Dispatch sources。而在系统内部CFRunLoop 使用 mach 端口。

下面是 CFRunLoop 的示意图,将其与我们的命令行应用程序进行比较。

如果你在 Xcode 调试器中暂停一个 iOS 应用程序,主线程的堆栈信息中便会出现下面的调用栈

* frame #0: libsystem_kernel.dylib`mach_msg_trap + 10
  frame #1: libsystem_kernel.dylib`mach_msg + 59
  frame #2: CoreFoundation`__CFRunLoopServiceMachPort + 319
  frame #3: CoreFoundation`__CFRunLoopRun + 1249

mach_msg 是系统调用,CFRunLoop 用它来等待多个可能的事件中的任何一个。在这期间,我们的应用程序没有使用 CPU ,或者至少主线程没有使用。

一个 CFRunLoop 被配置为一组传递事件的输入源。当一个应用程序被启动时,它在主线程上启动一个 run loop ,用一个 input sources输入源来传递触摸事件。其他的输入源之后也可以被添加到其中。你也可以在辅助线程上启动新的 run loop 。我们可以用一个带有两个输入源的 CFRunLoop 实现一个处理用户输入和网络事件的命令行程序。

来自输入源的事件会按照特定的顺序进行处理。run loop 一共有 4 种类型的输入源:

  • input sources 0。这是自定义的输入源它们手动调用 CFRunLoop 函数来传递事件。iOS 应用程序中的触摸事件在一个辅助线程上处理,然后通过 input sources 0 送到主线程的 run loop 中。
  • input sources 1。这是基于机器端口的输入源。例如 CADisplayLink可用于将绘图代码与显示器刷新率同步。异步网络代码也可以使用 input sources 1。(然而,请注意,许多网络库在内部调度队列上使用阻塞的 I/O 调用来代替网络调用,然后通过主调度队列将代码调度到主线程)
  • Timer sources。计时器如Timer使用这种特殊的输入源。
  • The main dispatch queue。调度到主队列的代码以及与主队列相关的调度源也构成了一个输入源。这允许旧代码和基于调度的代码之间的沟通。其他队列没有基于 CFRunLoop 实现)

除了添加输入源,我们还可以向 CFRunLoop 添加观察者,当 run loop 到达特定周期时会发送通知。run loop 的周期是由 CFRunLoopActivity 定义的观察者可以选择对其中的一个或几个周期进行监听。run loop 观察者在苹果自己的框架中被广泛使用

__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__

Core Animation & render Server

你是否有过这样的经历当一个应用程序出现卡顿时你认为它不可能是卡顿因为还有一些动画在进行中即使应用程序的主线程被卡住指示器菊花仍在旋转这总是让我困惑。即使主线程繁忙或暂停iOS中的动画也可以继续。这不是因为动画发生在另一个线程中,而是因为它们发生在另一个进程中。

Core Animation 与 render server 对话,告诉它要画什么和做什么动画。一般来说,我们会对一个视图进行多次修改,作为对用户操作的反馈。在 UIKit 中,为了响应一个按钮的点击,你可能会同时改变一个视图的大小和背景颜色,或者你可能会调用多个方法来触发 setNeedsDisplay 。如果我们每改变一个参数就渲染一次很明显效率会非常低也会导致一些奇怪的问题。为了告诉系统该把哪几个参数打包一起渲染Core Animation 框架暴露了 CATransactions

CATransaction 包含了 begincommit 两个方法。你可以手动 begin (启动)和 commit (提交)一个 CATransaction 事务。如果你不主动调用 CATransaction APICATransaction 也会在引擎下被隐式调用。

Demo

@IBAction func buttonPress() {
  self.view.backgroundColor = .red
  sleep(2)
  self.view.backgroundColor = .white
}

在按下按钮后,应用程序被卡住 2 秒但它所处的视图的背景颜色保持为白色。视图层的变化在睡眠前没有被渲染。这是因为设置背景颜色启动begin了一个隐式渲染事务而这个事务在睡眠前没有提交commit

改进

@IBAction func buttonPress() {
  CATransaction.begin()
  self.view.backgroundColor = .red
  CATransaction.commit()
  sleep(2)
  self.view.backgroundColor = .white
}

我们现在按下这个按钮它所在的视图的背景颜色就会变成红色然后在应用程序卡住的时候保持红色两秒钟然后变成白色。我们主动提交commit了一个事务因此视图在睡眠之前改变了颜色。由此可以推断仅仅改变一个视图的背景颜色不主动调用 CATransaction ),只会隐式地创建渲染事务,并不会去提交这个事务。

那么隐式的事务究竟何时提交?答案是:每当一个隐式事务被启动,就会在当前 run loop 周期结束时被安排提交。它的底层是用一个 run loop 观察者来完成的,这个观察者是由 Core Animation 添加到主 CFRunLoop 中的,观察的周期是 CFRunLoopActivity.beforeWaiting

CATransaction 是可嵌套的。你可以在一个 CATransaction 里面启动另一个 CATransaction 但是只有外部事务会被用来渲染和改变屏幕内容。外层事务可以是一个被隐式地启动的事务。举个例子:有些控件可能在调用它们的 action handlers 之前就已经调用了动画代码,动画代码启动了一个隐式事务。然后当你在 action handlers 内使用显式事务(手动调用 CATransaction.commit() )对一个图层进行修改时,提交它不会立即产生任何效果(需要等外层的隐式事务提交时才会改变)。

虽然你在 SwiftUI 应用程序中不直接使用 CATransactions但 SwiftUI 框架在内部仍然使用 Core Animation 和 CATransactions 进行绘制和动画。与 render server 一起Core Animation 对 iOS 来说是非常基础的。

触摸事件和显示器刷新率

需要自定义动画或使用物理引擎的应用程序可以使用 CADisplayLink 来使绘图代码与显示器的刷新率同步。在这个 API 可用之前,特别是游戏开发者很难做到这一点,他们不得不使用 NSTimer 并想办法绕过很多限制。

应用程序从操作系统接收触摸事件的频率与显示屏刷新的频率相同。这是合理的,因为我们使用触摸来更新视图,如果比显示的频率更高,那就是一种浪费。但是,如果我们将收到这些触摸事件的时间与 CADisplayLink 启动的时间进行比较,我们会看到它们并不完全同步。

在具有高触摸刷新率的 iPhone 上,一个显示刷新周期内会发生多个触摸事件,但我们不会单独接收它们。在 UIKit 中,我们可以从 UITouch 对象中获得那些中间的触摸事件。

所有的 run loop 输入源,包括用于实现 CADisplayLink 和接收触摸的输入源,都以不同的方式应对系统繁忙的情况。如果多个触摸事件发生时,应用程序仍在忙于响应前一个触摸,它们将不会被单独传递,但仍可从最近的触摸事件中恢复触摸。相反,如果在下一次显示刷新即将发生时系统仍在忙碌, CADisplayLink 根本不会通知我们。

全貌

当 APP 不做任何事情时,一个 SwiftUI 应用程序将有一个空闲的 CFRunLoop 。CFRunLoop 将等待来自输入源的事件如触摸、网络事件、定时器或显示器刷新。为了响应触摸SwiftUI 可能会调用一个 Button 的 action handler。如果我们在 action handler 中设置一个断点,我们会在堆栈跟踪中看到 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ 。这是因为触摸事件是由 input sources 0 输入源传递的。

为了响应来自输入源的事件,我们可能会修改视图的一些 @State 变量,或者在 @ObservedObject 上调用一个函数,进而触发 objectWillChangeSwiftUI 视图会被标记为 invalidated无效意味着它的 body 需要被重新评估,但它不是立即重新评估,之后会评估。因为会可能存在这样一个 caseT1 时刻值修改为1T2 时刻修改为2T3 时刻修改为1如果每次都立即评估效率会很低。

那之后是什么时候?给 body 方法内加断点,在 LLDB 输入 bt 可以看到

(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
  * frame #0: 0x000000010c64f2cd TCADemo`closure #1 in closure #2 in ContentView.body.getter(self=TCADemo.ContentView @ 0x00007ff7b38aea80, viewStore=0x0000600003ccd140) at ContentView.swift:19:22
    frame #1: 0x000000010c65100e TCADemo`partial apply for closure #1 in closure #2 in ContentView.body.getter at <compiler-generated>:0
    frame #2: 0x00000001122b8a5b SwiftUI`SwiftUI.VStack.init(alignment: SwiftUI.HorizontalAlignment, spacing: Swift.Optional<CoreGraphics.CGFloat>, content: () -> τ_0_0) -> SwiftUI.VStack<τ_0_0> + 159
    frame #3: 0x000000010c64ed88 TCADemo`closure #2 in ContentView.body.getter(viewStore=0x0000600003ccd140, self=TCADemo.ContentView @ 0x00007ff7b38b00e0) at ContentView.swift:17:13
    frame #4: 0x000000010c64eeaa TCADemo`partial apply for closure #2 in ContentView.body.getter at <compiler-generated>:0
    frame #5: 0x000000010c75a53b TCADemo`WithViewStore.body.getter(self=ComposableArchitecture.WithViewStore<TCADemo.Counter.State, TCADemo.Counter.Action, SwiftUI.VStack<SwiftUI.TupleView<(SwiftUI.ModifiedContent<SwiftUI.ModifiedContent<SwiftUI.Text, SwiftUI._PaddingLayout>, SwiftUI._AppearanceActionModifier>, SwiftUI.ModifiedContent<SwiftUI.Spacer, SwiftUI._FrameLayout>, SwiftUI.ModifiedContent<SwiftUI.ModifiedContent<SwiftUI.ModifiedContent<SwiftUI.TextField<SwiftUI.Text>, SwiftUI._FrameLayout>, SwiftUI._EnvironmentKeyWritingModifier<SwiftUI.TextAlignment>>, SwiftUI._EnvironmentKeyWritingModifier<Swift.Optional<SwiftUI.Color>>>, SwiftUI.ModifiedContent<SwiftUI.Spacer, SwiftUI._FrameLayout>, SwiftUI.HStack<SwiftUI.TupleView<(SwiftUI.Button<SwiftUI.Text>, SwiftUI.ModifiedContent<SwiftUI.Spacer, SwiftUI._FrameLayout>, SwiftUI.Button<SwiftUI.Text>, SwiftUI.ModifiedContent<SwiftUI.Spacer, SwiftUI._FrameLayout>, SwiftUI.Button<SwiftUI.Text>)>>)>>> @ 0x00007ff7b38b0550) at WithViewStore.swift:418:17
    frame #6: 0x000000010c75b6c2 TCADemo`protocol witness for View.body.getter in conformance WithViewStore<A, B, C> at <compiler-generated>:0
    frame #7: 0x0000000111bb2b37 SwiftUI`___lldb_unnamed_symbol79902 + 22
    frame #8: 0x000000011232d8f3 SwiftUI`___lldb_unnamed_symbol138684 + 34
    frame #9: 0x0000000111bb2a93 SwiftUI`___lldb_unnamed_symbol79901 + 1429
    frame #10: 0x000000011232df07 SwiftUI`___lldb_unnamed_symbol138706 + 458
    frame #11: 0x00000001119b3ff4 SwiftUI`___lldb_unnamed_symbol66299 + 26
    frame #12: 0x00007ff81fd7a1d7 AttributeGraph`AG::Graph::UpdateStack::update() + 537
    frame #13: 0x00007ff81fd7a9ab AttributeGraph`AG::Graph::update_attribute(AG::data::ptr<AG::Node>, unsigned int) + 443
    frame #14: 0x00007ff81fd87378 AttributeGraph`AG::Subgraph::update(unsigned int) + 910
    frame #15: 0x000000011287008b SwiftUI`___lldb_unnamed_symbol175286 + 754
    frame #16: 0x0000000112872b5c SwiftUI`___lldb_unnamed_symbol175379 + 15
    frame #17: 0x0000000111dd9bb3 SwiftUI`___lldb_unnamed_symbol99983 + 37
    frame #18: 0x000000011269be30 SwiftUI`___lldb_unnamed_symbol163473 + 69
    frame #19: 0x000000011269a9cc SwiftUI`___lldb_unnamed_symbol163376 + 78
    frame #20: 0x0000000111dd9a94 SwiftUI`___lldb_unnamed_symbol99979 + 55
    frame #21: 0x0000000112872b35 SwiftUI`___lldb_unnamed_symbol175378 + 126
    frame #22: 0x0000000112872a89 SwiftUI`___lldb_unnamed_symbol175377 + 52
    frame #23: 0x000000011210ba1c SwiftUI`___lldb_unnamed_symbol121562 + 12
    frame #24: 0x0000000111b3f6e8 SwiftUI`___lldb_unnamed_symbol76464 + 113
    frame #25: 0x0000000111b3f669 SwiftUI`___lldb_unnamed_symbol76463 + 40
    frame #26: 0x0000000111b3f75f SwiftUI`___lldb_unnamed_symbol76465 + 43
    frame #27: 0x00007ff800387055 CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23
    frame #28: 0x00007ff8003819c2 CoreFoundation`__CFRunLoopDoObservers + 515
    frame #29: 0x00007ff800381f0d CoreFoundation`__CFRunLoopRun + 1161
    frame #30: 0x00007ff8003816a7 CoreFoundation`CFRunLoopRunSpecific + 560
    frame #31: 0x00007ff809cb128a GraphicsServices`GSEventRunModal + 139
    frame #32: 0x000000010e963ad3 UIKitCore`-[UIApplication _run] + 994
    frame #33: 0x000000010e9689ef UIKitCore`UIApplicationMain + 123
    frame #34: 0x000000011276c667 SwiftUI`___lldb_unnamed_symbol166820 + 199
    frame #35: 0x000000011276c514 SwiftUI`___lldb_unnamed_symbol166818 + 130
    frame #36: 0x0000000111dd07e9 SwiftUI`static SwiftUI.App.main() -> () + 61
    frame #37: 0x000000010c65657e TCADemo`static TCADemoApp.$main(self=TCADemo.TCADemoApp) at TCADemoApp.swift:10:1
    frame #38: 0x000000010c656609 TCADemo`main at TCADemoApp.swift:0
    frame #39: 0x000000010d5712bf dyld_sim`start_sim + 10
    frame #40: 0x0000000117ddc52e dyld`start + 462
(lldb) 

堆栈中有 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ ,就像隐式提交 CATranscation 一样,被标记为 invalidated无效的视图其 body 评估也被安排在当前 RunLoop 周期结束时执行。这也是通过一个 RunLoop 观察者实现的,该观察者观察 CFRunLoopActivity.beforeWaiting 阶段。如果一个视图在同一个 run loop 中被两次标记为 invalidated无效它将只会被评估一次。

在所有 invalidated无效的视图被重新评估后SwiftUI 不会立即将控制权返回给 run loop。一些 View 的回调,如 onChangeonPreferenceChange ,以及 onAppear 首先被调用,这些回调可能再次使视图 invalidated无效。对于视图第二次评估SwiftUI 没有使用 run loop 观察器。

而如果这第二次评估导致再次调用回调,并导致再一次视图 invalidated无效SwiftUI 将暂时禁用视图 invalidated无效以防止无限循环。它还会打印一个类似这样的警告 onChange(of: _) action tried to update multiple times per frame

在重新评估视图的时候,我们仍然会同时对多个视图、多个属性进行修改。正如我们所看到的,这些变化不会立即在屏幕上绘制。它们也会启动一个隐式 CATransaction 。因此SwiftUI 利用了 UIKit 应用程序中的相同优化。

只有当隐式 CATransaction 被提交时,视图的内容才会被渲染到屏幕上。这也是 CPU 真正调用渲染代码的时刻。不过这带来一个问题:如果 SwiftUI 在 render loop 的这一部分崩溃了,就很难弄清楚如何解决,因为很难看到是哪个视图的哪一部分导致的。

总结:

在 render loop 中,为了优化代码,有一个常见的模式:确保只在需要的时候调用。当调用一个函数或改变一个变量触发了一个更新时,这个更新不会立即执行。相反,它被安排在以后进行。当视图因其状态改变而失效时,例如 onChangeonAppear 这样的处理程序被调用时,以及当 Core Animation 需要绘制图形时,就会发生这种优化。这些优化在框架内部处理,主要使用了 CFRunLoop 观察者

SwiftUI 中的渲染循环可能隐藏得很好,它所使用的技术与我们在 UIKit 应用程序中使用的技术相同并且有很好的文档。如果我们能更好地了解它的工作原理我们就能更好地理解我们所写的代码的副作用并做出更好的决定。有时我们可能会把“渲染”等价成“evaluate 评估”。但有时,理解其中的区别会很有帮助。

渲染流程

  • 所有的 SwiftUI 控件都是一个结构体,实例是值类型,它们会遵循 View 协议,实现 body 计算属性;这个 body 计算属性内部所描述的就是视图结构的样子
  • 每个 body 得到的 some View 都会映射到 SwiftUI 内部的一个 RenderNodeRenderNode 也会持有在自定义 View 上定义的各种状态,为这些状态分配内存空间存储数据,同时给这些状态的添加属性监听,一旦状态属性发生变化,就重新建立 some View 到 RednerNode 的映射关系
  • 后台的渲染引擎 (CoreGraphics, Metal) 会通过 RenderNode 对比 some View 的变化,在 RunLoop 的加持下,将变化的部分绘制出来,最终呈现给用户

虽然上面的流程是这样子的,但在之前 SwiftUI 官方只是告诉你怎么把数据声明为 SwiftUI 可感知的状态,触发界面绘制。并没有明确的说明以下四个问题:

  1. SwiftUI View 和 RenderNode 之间是按照什么关系来映射的?
  2. SwiftUI View 和 RenderNode 生命周期是否一致,存在什么关系?
  3. SwiftUI View 重新实例化后State 是如何被保持住的?
  4. 状态发生变化后SwiftUI 是怎么找到相应的 View 和 RenderNode 来进行操作的?

注意2个概念

  • SwiftUI 控件、View 都是结构体,是值类型,代表的是开发者用 DSL 描述的界面布局和层级
  • 视图界面元素都是类,是引用类型,指的是渲染节点或真实显示的 UI 界面

SwiftUI 内部是如何处理开发者编写的描述性代码的呢?其内部有三个核心概念来支撑:

  • 视图标识 (Identity) - 标识在应用程序的多次更新过程中视图元素,决定是否重新生成视图元素
  • 生命周期 (Lifetime) - 跟踪视图和数据状态随时间变化的过程,根据开发者描述来处理视图如何更新
  • 依赖关系 (Dependencies) - 对数据状态进行监听,决定视图何时需要更新

这三个核心概念帮助 SwiftUI 解决什么需要改变,如何改变,以及何时改变的问题,最终渲染出相应的用户界面。

接下来,让我们更深入地讨论这三个概念。

视图标识(View Identity)

上图中这两只狗狗,到底是不是同一个呢?我们似乎无法准确地给出答案。为什么呢?因为我们缺乏一些关键信息,那就是 Identity。

所以当 SwiftUI 处理你的界面描述时,它也需要 Identity 这个关键信息区分视图是否是同一个。

让我们来看下上面这个 Good Dog, Bad Dog 的小应用,你可以点击屏幕上的任何位置来切换狗狗的状态。但是我们从技术层面分析,上面的界面可以有两种 SwiftUI 的描述方式:

  1. 自定义两个完全不同的 SwiftUI View根据当前狗狗的状态去做逻辑判断描述
  2. 把上面的界面描述成一个 SwiftUI 自定义 View在区别展示的地方用不同的颜色来区分

这两种 SwiftUI 的描述方式,会让视图从一种状态过渡到另一种状态的方式截然不同:

  • 按照第一种方式,由于是完全不同的视图,就意味着上面狗爪子的图标应该独立执行过渡动画,最终看起来只有淡入和淡出的效果
  • 按照第二种方式SwiftUI 内部认为它是同一个视图,这就意味着在过渡期间,狗爪子图标会执行在屏幕上滑动的动画效果

可以看出 SwiftUI 在处理过渡动画的时候,会根据不同状态下的 View 是如何连接的来进行处理,而决定 View 连接方式的关键就是 View Identity:

  • 共享 Identity 的 View 代表的是同一个 UI 界面元素,只是处在不同状态下而已 (Same identity = Same element)
  • 代表不同 UI 界面元素的 View它的 Identity 也总是不同 (Different identities = Distinct elements)

Identity 既然这么重要,那么开发者是如何用代码来定义的呢?在 SwiftUI 中分两种方式来定义 Identity

  • 声明式 Identity一般是在 View 上添加一个 id(_:) 修饰器或者在数据驱动列表控件中显示声明 Identifier如 ForEach、List。参考前端 Vue、React List 中的 id
  • 结构性 Identity是 SwiftUI 根据 View 的类型和层级结构来动态识别,虽然这种 Identity 不需要开发者指定,但也需要开发者清晰的将 View 的层级结构描述出来,方便 SwiftUI 内部识别。类似 xpath根据 UI 层级和结构,生成唯一的 Identity
声明式 Identity

就像上面这两只狗狗,仅通过图片,很难判断这是不是同一只狗狗,但如果我们能用名字来标识它们。就很容易得出结论。像这样给狗狗起名字来标识它们的方式,就是在显式声明 Identity。

需要注意的是,声明式 Identity 是非常强大且灵活的。我们在之前 AppKit 或 UIKit 中编写界面的方式,其实就是采用的显式声明 Identity 的方式。怎么理解?由于 UIView 和 NSView 都是类,引用类型,所以它们的实例其实是一个指针,这个指针指向了一块内存空间。其中指针所代表的内存地址就是一种显式声明的 Identity。

我们可以通过视图的指针来标识每个视图,如果多个视图指针,都共享同一块内存空间,那么它们其实是同一个视图,如下图所示:

问题来了SwiftUI 中的 View 都是结构体, 值类型,没有指针的概念,那 SwiftUI 怎么来唯一标识一个 View 的呢?

其实SwiftUI 是用另外一种形式来显式标识 View。通过下面的例子能更好的理解例如在这个救援犬列表里用 dogTagID KeyPath 获取相应属性,在参数里指定到 id 上,就是在显式声明 View 的 Identity。这样就能标识出每条数据对应的展示视图。一旦列表数据发生变化 SwiftUI 可以根据这些 ID 来判断,哪些视图需要新生成,哪些视图重复使用,只需要执行动画。

结构性 Identity

不显式声明 Identity这并不意味着这些 View 根本没有 Identity也就是说每个 View 都有一个 Identity即使它不是显式声明出来的。在这种情况下 SwiftUI 内部会对没有显式 Identity 的 View 根据它的描述层级结构生成一种隐式的 Identity就叫做结构性 Identity。

如上图,假设我们有两只相似的狗狗,但我们不知道它们的名字,我们仍然还要标识出它们。这时候可以通过它们坐的位置来标识,如 左边的狗右边的狗

像这种利用排列位置的不同,来区分它们的方式就是所谓的 结构性 Identity

SwiftUI 几乎在所有地方都采用了这种结构化 Identity 的方式来标识 View。一个典型的例子就是在 SwiftUI 中 使用 if else 条件判断的时候,条件语句的结构使得 SwiftUI 能够明确的识别每个 View如下图第一个 AdoptionDirectory 只在条件为 True 的时候显示,第二个 DogList 只在条件为 False 的显示。

但是 SwiftUI body 计算属性需要一个明确一致的返回类型,但 if else 条件判断使得返回类型不一致了,会引起编译失败。这时候 SwiftUI 引入了一个的黑魔法 - ViewBuilder (默认是附加在 body 计算属性上的,不需要开发者单独指定)。ViewBuilder 帮助 SwiftUI 把各种条件判断,封装成 _ConditionalContent 的数据结构。但为了区分在不同分支下的类型不同,用泛型来进行了区分,这样即保证了返回数据的一致性,又保证了 SwiftUI 内部可以通过泛型识别出不同分支下结构性 Identity。

见源代码:

如下图,我们只用一个 PawView 自定义View在这个自定义的 View 的 Modifier 上利用三目运算的方式来动态改变需要变化部分的数值,当在不同状态之间发生界面切换的时候,由于始终是一个视图元素,所以就会执行平滑的滑动动画。

其实,如果你回到 UIKit 中理解,也是一样的,我们在 UIView 上执行动画的时候,一般也是在同一个 UIView 的实例里去动态改变它的属性去修改样式, 才会有那种平滑过渡的效果;相反,如果虽然是同一个类型的 UIView但是对应的是不同的实例去做那种平滑的过渡效果也是很难实现的。

综上,在使用结构性 Identity 的时候,第二种描述 View 的方式是更好的选择。应该尽量避免切换 Identity这样做会给动画和性能都带来良好的效果也有利于维持视图的生命周期和数据状态。

危险的 AnyView

说起 AnyView ,这家伙绝对是 Identity 的克星。

上图是一个使用 AnyView 的示例代码。在这个自定义 View 中,为了保证最终返回一个明确一致的数据类型,每个分支都用一个 AnyView 包裹起来。由于 AnyView 隐藏了所包装视图的类型,让 SwiftUI 无法在条件判断中识别出结构性 Identity在 SwiftUI 眼里,它看到的都是一些擦除类型的 AnyView更要命的是这段代码阅读起来特别困难。

那么,接下来让我们用正确的方式来重构这段代码:

  • 第一步:消除 AnyView 包裹,把内部具体的 View 类型暴露出来
  • 第二步:去掉 所有的 return 关键字
  • 第三步:在方法上添加 @ViewBuilder 标识,保证最终返回的是一个明确的类型,编译通过
  • 第四步:由于我们只是在 dog 的 breed 状态之间来回判断,那么把 if else 改为 switch case 会更合适

重构后,最终代码和 View 层级结构如下图:

一般情况下,还是尽量避免使用 AnyView因为 AnyView 有如下缺陷:

  • 代码难于阅读
  • 由于擦除了所有的 View 类型,无法在编译的过程中给出相应的提示
  • 可能会导致不必要的性能损失

生命周期(Lifetime)

Lifetime 与 Identity 的关系

如上图,这里有个叫 Theseus 的小猫。他在一天中可能有各种不同的状态,一会装可爱,一会睡觉,一会发火,但是无论处于何种状态,他都是那只叫 Theseus 的小猫。

视图在整个生命周期内有各种不同的状态,每个状态在 SwiftUI 中由不同的 View 实例(值类型)来描述,而 Identity 将这些不同状态下的 View 值随着时间的推移关联起来,它们都对应着同一个视图元素。这就是 Identity 与视图生命周期建立联系的本质。

让我们用上面的代码来更清晰的理解这一点,这里我们有一个简单的自定义 View - PurrDecibelView用来显示猫叫声的强度。一开始的时候 SwiftUI 调用 body 计算属性,获取到叫声为 25 的 View但是突然小猫饿了希望获得更多的关注叫声变大为50这时候 SwiftUI 监听到叫声这个状态的变化,重新调用 body 计算属性,获取到一个全新的 View这两个 View 是完全截然不同的两个值。SwiftUI 会在后台对这两个值进行对比并对比出哪些部分发生了变化。得出对比结果后,告诉渲染视图执行变化部分的渲染操作,同时,用完的 View 值也会被销毁。

这里非常重要的一点就是 View 的值跟 Identity 生命周期是不同的。值类型的 View 生命周期是非常短暂的。开发者要控制好的其实是它们的 Identity。也就是说随着时间的推移 SwiftUI 创建很多新的 View 用来描述视图当前状态下的显示方式,但是 SwiftUI 内部只是拿这些 View 来进行样式和布局的对比,用完了这些 View 值就会销毁,其内部用 Identity 唯一标识的那个视图(RenderNode)会一直在内存中,并且一直都是同一个。但是一旦 Identity 发生变化,内部的视图元素生命周期也会结束。

所以,如下图,我们经常用到的生命周期方法 onAppear 和 onDisappear其实是在视图显示和消失的时候触发而不是 View 创建和销毁的时候触发。

所以最终我们得出如下公式来阐述 ViewLifeTimeIdentity 三者之间的关系:

  • View Value ≠ View Identity
  • View(视图)'s LifeTime = duration of the Identity

视图和 struct 值类型的 View 没有严格对应关系,但持续可见的视图必然对应一个 identity。一个视图对应 >= 1个 DSL 描述的 View也就是结构体

Lifetime 与 State 的关系

理解了 Identity 与视图生命周期之间的联系,也能够帮助你更好地理解 SwiftUI 如何维持数据状态

提到维持数据状态,那肯定要用到 State 和 StateObject。这两个状态管理工具可以保证在不同的 View 实例被创建的时候,封装的数据能够一直维持在内存中,相当于一种内存记忆。但是你去看它们的定义会发现它们都是结构体。按理说,在每次创建新的 View 实例后,应该就销毁重新生成了,那咋维持数据的呀?其实它们内部都会有一个 Storage 类,用来存储它们所修饰的数据。当一个视图根据 Identity 第一次创建的时候SwiftUI 在内部为 State 和 StateObject 的 Storage 分配相应的内存空间,用来保存状态的初始值。注意这里的 Storage 跟 Identity 是对应的,生命周期也是一致的

如下图的 CatRecorder 自定义 View每次的 title 发生变化,由于他被 @State 修饰SwiftUI 内部会在内存中保存这个数据,并且监听他的变化,一旦发生变化,就调用 body重新计算。

下面,让我们来看一下在有分支的情况下,视图生命周期和数据状态之间的关系。

如上图代码,分支里的两个 CatRecorder 由于结构性 Identity 不同,所以它们被 SwiftUI 视为是两个不同的视图。之前说过这样会影响动画效果,其实也会影响它们内部数据状态的维持。

比方说,第一次进入的是 True 分支SwiftUI 会为 CatRecorder 生成一个新的视图,并为数据分配内存空间,以存储状态的初始值。当 CatRecorder 内部状态发生变化时,只要都是在 True 分支下,由于 Identity 没变,所以还是同一个视图,所以状态也会连续性的变化,不会有数据丢失的情况。但是一旦 dayTime 发生变化,进入了 False 分支SwiftUI 发现 Identity 发生了变化,会生成新的视图和与之对应状态的内存空间,这时候新的 CatRecorder 内部的所有状态都是初始值。True 分支下的视图和对应的状态接下来也会被释放。如果我们再切回到 True 分支,之前 True 分支的状态也回不来了,因为相较于上次的 View 类型,这又是一个全新的 Identity会重新创建视图和数据状态存储空间。所以最终分支切换后在界面上有时候会发现记录的小猫状态突然丢失了。

所以可以得出的结论是View Identity 一旦变化,视图内部对应的数据状态也会被重新替换。也就是说:

State's Lifetime = 视图's Lifetime != View's Lifetime

稳定的 Identity

保证 Identity 稳定,这一点非常重要,尤其是在使用数据驱动型的列表控件时,在下面这些控件中,往往都需要用数据的 id 来给 View 显式声明 Identity。

下面两张图是两种不同的 ForEach 的用法。其中第一种用法是一个常数的 RangeSwiftUI 可以直接用 Range 的值来为视图生成 Identity以确保在视图的整个生命周期内 Identity 是稳定的。但当使用一个动态的 Range 时,会导致声明式的 Identity 数值是不可预期的Identity 一旦切换,视图都会重新生成,这样就会出现性能问题。 所以在 Xcode 12 的时候,检查到这种使用方式会编译报错,而在新版本的 Xcode 13 这将变为一个警告 (beta 版本似乎不生效)。

ForEach(0..<5) {	offset in
	Text("🐑 \(offset)")
}

ForEach(0..<sheeps) { offset in
		Text("🐑 \(offset)")
}

在 Swift 标准库有个 Identifiable 协议来帮助开发者保证 Identity 稳定。SwiftUI 也充分利用了这个协议,使得开发者只需要提供 KeyPath它内部通过 Identifiable 协议可以动态的访问到相应的属性,从而生成稳定的 View Identity。

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ForEach where ID == Data.Element.ID, Content : View, Data.Element : Identifiable {

    /// Creates an instance that uniquely identifies and creates views across
    /// updates based on the identity of the underlying data.
    ///
    /// It's important that the `id` of a data element doesn't change unless you
    /// replace the data element with a new data element that has a new
    /// identity. If the `id` of a data element changes, the content view
    /// generated from that data element loses any current state and animations.
    ///
    /// - Parameters:
    ///   - data: The identified data that the ``ForEach`` instance uses to
    ///     create views dynamically.
    ///   - content: The view builder that creates views dynamically.
    public init(_ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content)
}

如上图,如果仔细看下 ForEach 控件初始化函数的定义,可以看出 SwiftUI 充分利用了 Swift 类型系统的特性来约束 API 使用体验:

  • 通过这个定义,能一眼看出 ForEach 声明了一个数据集合和一个视图集合之间的关系
  • 将集合中的元素限制为必须遵循 Identifiable 协议,目的是为了保证集合元素能够提供一个稳定的 Identity以便 SwiftUI 可以在视图的整个生命周期内跟踪数据。

所以,确保 Identity 的稳定性,对于开发者来说是非常重要的。因为他会影响到视图和与之对应数据的生命周期。

依赖关系处理 (Dependencies)

依赖关系图

struct DogView: View {
	 @Binding var dog: Dog
	 var treat: Treat
	 var body: some view {
	 		Button {
	 			dog.reward(treat)
	 		} label: {
	 			PawView()
	 		}
	 }
}

该 View 有两个属性 dog 和 treat它们都可以理解为视图的依赖关系。依赖关系就是视图更新的入口。当依赖关系发生变化时会重新调用 View 的 body获取整个 View 的层级描述信息。在这个例子中,描述的就是一个有触发行为的按钮。他对应的视图层级结构如下:

看上面这张图的话,是一个树结构,但是有可能多个视图都依赖同一个状态。有可能某个子视图也依赖顶级视图中的状态。情况越来越复杂后,这就不再是一个树结构。重新整理,避免让连接线之间交叉,如下图,可以看出它们之间的关系实际上是一个图结构。我们可以称之为依赖关系图

深入的理解这个依赖关系图很重要,因为它保证了 SwiftUI 只更新那些需要重新调用 body 的 View。以最底部的依赖关系为例。如果我们检查这个依赖关系会发现有两个 View 依赖它,当依赖的数据状态发生变化,只有这两个 View 会被标记为无效。同时 SwiftUI 开始调用每个视图的 body 计算属性,只为标记为无效的视图产生一个新的 body 值。

状态管理工具,在 SwiftUI 依赖关系的建立就是通过它们来实现的:

  • @Binding
  • @Environment
  • @State
  • @StateObject
  • @ObservableObject
  • @EnvironmentObject

改进 Identity

Identity 就是依赖关系图的灵魂重要性不言而喻。正如之前所说Identity 用来标识一个视图,所以 SwiftUI 会根据 Identity 来高效的判断哪些视图需要更新,哪些视图需要新建,哪些视图需要销毁。

稳定性

对于开发者来说,首先要确保的就是 Identity 的稳定性。稳定的 Identity 会给 SwiftUI 带来如下好处:

  • 确保视图生命周期的准确性,一个视图的生命周期是由 Identity 来决定的,一个不稳定的 Identity 会导致视图生命周期意外缩短
  • 提高应用程序的性能SwiftUI 无需在依赖关系图更新的过程中为不必要的视图和状态重新分配内存空间
  • 缩小影响依赖关系影响的范围
  • 保证数据状态不会无故丢失

在下图的例子中,每次都生成一个 UUID 和 直接用 Indices 来显式声明 Identity 都是不稳定的方式,因为它们都会随着时间推移发生变化,不能准确地标识一个视图,最终导致的结果就是,当我们在列表头部新插入数据时,整个列表都会重新刷新。相反,我们如果用一个 databaseID 就是可以的,因为这个 ID 只对应一个数据,能够清晰的标识一个与该数据对应的视图。这时候我们在头部新插入数据,所有的动画效果都非常自然了。

唯一性

但是只保证 Identity 的稳定性还是不够的。好的 Identity 还要确保唯一性。每个 Identity 都应该准确映射到一个单一的视图。

唯一的 Identity 会给 SwiftUI 带来如下好处:

  • 平滑的动画效果
  • 同样可以提高性能
  • 准确地的反应视图和状态之间的依赖关系

像下面的代码中使用 name 的 KeyPath 来给 View 显式声明 Identity是不合理的因为我们无法保证 name 的唯一性,一旦出现重名的情况,新的视图很有可能不会展示出来。但当把 name 换成 serialNumber一切都正常了

去分支

上面,我们都是用声明式 Identity 来说明如何改进 Identity接下来看看如何改进结构性 Identity。

上面的代码,乍一看似乎没什么问题。但是仔细分析会发现,这里有个性能问题。content 在不同的分支条件下,会产生不同的结构性 Identity这就导致了分支切换后针对同一个 View 会生成两个不同的视图元素,也就是在内存中分配两份内存空间。这点其实是可以避免的。虽然这里我们很轻易的发现了这个问题,但当项目大了之后,有可能这些 ViewModifier 的代码都不在一起,所以这种问题很容易被忽视。

修改:把分支结构去掉,改为在 opacity 修饰器上添加三目运算的方式来动态修改透明度。由于去掉了分支结构,所以 content 只会生成单一的结构性 Identity也就避免了不必要的内存开销提高了性能。

像上面代码直接把透明度设置为 1也就是跟初始状态一致其实 SwiftUI 发现这种情况是不执行任何渲染操作的。我们把这样的修饰器称为 "惰性修饰器",因为它们不影响渲染的结果。

最佳实践

是不是跟觉得在 SwiftUI 中使用条件分支很可怕?不要担心,想用分支的时候还是得用,只是用完之后,要多考虑下这个地方用分支来描述 View 结构的必要性,也就是要考虑当前代码的 View 到底是用来代表多个视图还是代表同一个视图的不同状态。

如果是代表同一个视图的不同状态,那么使用一个惰性修饰器来标识一个单一的视图,往往是更好的选择。

在下图中还给出了一些其他的惰性修饰器作为参考:

总结

日常开发的时候可能对 Identity 的思考、认知不够不知道它原来影响动画、视图生命周期、状态生命周期都有关系。Identity 帮助 SwiftUI 系统做了很多决策。

-SwiftUI View 和 视图元素之间采用 Identity 关联起来它们之间并非一一对应Identity 有声明式的,也有结构式的。在 SwiftUI 中每当状态发生变化,都会调用对应的 body 生成新的 View 值,但是否生成新的视图则完全由 Identity 来决定。如果 Identity 一致,就会根据 Identity 去内存中查找之前创建的视图,换言之,相当于保持之前视图的生命周期,并且在内存中用类维持住之前的数据状态,只对更改数据后,视图变化的部分进行渲染操作。如果 Identity 不一致,则会新建视图元素,同时视图所依赖的状态也会被重新分配,回到初始值。

总而言之View Identity 对 SwiftUI 来说是至关重要的。我们一定要时刻注意 View 的 显式 Identity结构性 Identity,并提高 Identity 的稳定性,确保 Identity 的唯一性。

开发 tips

要回答好这个问题,其实就是在聊 UIKit SwiftUI 的能力边界在哪里?它们各有优缺点,开发人员可以根据具体需求和场景来选择如何搭配使用它们。

SwiftUI 的优势:

  • 声明式 UI和主流的前端框架一样提供了声明式 UI 编程模式,使得构建和管理 UI 变得简单直观。但它不只是一个开发框架,更是一个组件库,具备一些常见的 UI 组件能力(能力小于等于 UIKit
  • 响应式 UI支持响应式编程可以轻松处理各种状态聚焦于逻辑。又和前端主流框架做了一样的事情不如说是大前端优秀的设计在客户端落地并在官方侧取得了支持
  • 跨平台特性SwiftUI 可用于 iOS、macOS、watchOS、tvOS 的应用程序,具备较好的跨平台特性

UIKit 的优势:

  • 成熟的生态系统UIKit 拥有丰富的第三方库和组件,可以满足各种复杂的 UI 和功能需求
  • 定制能力UIKit 提供了更多的自定义和底层控制能力,适用于需要高度定制化的界面和交互

在实际开发中,可以根据以下场景来搭配使用 SwiftUI 和 UIKit

  • 逐步迁移:对于已有的 UIKit 项目,可以逐步引入 SwiftUI例如在新功能或模块中使用 SwiftUI逐步迁移现有界面和功能。但需要考虑的是 SwiftUI 必须用 Swift 语言开发,新语言开发简单,但背后的比如 Crash 监控、热修复、动态路由、打包构建系统等新语言如何与现有的基建打通是需要调研和考虑的一个事情。不过都2024了Apple 官方拥抱了类似大前端很成熟的声明式开发、响应式编程,客户端同学该开始 SwiftUI 了。
  • 混合使用:可以在同一个应用程序中同时使用 SwiftUI 和 UIKit根据具体需求选择合适的界面构建方式。例如可以使用 SwiftUI 构建应用程序的主界面,同时使用 UIKit 来展示特定的复杂界面或功能。因为 SwiftUI 封装的是大多数常见、高频使用的 UI 组件,所以不可能满足所有需求,那些交互复杂的,还需要使用 UIKit 的能力。
  • 复用现有组件:可以将现有的 UIKit View 或者 ViewController 封装为 SwiftUI 可以用的组件。遵循 UIViewRepresentableUIViewControllerRepresentable 协议即可。
  • 跨平台开发:对于需要在多个平台上展示相似界面的应用程序,可以使用 SwiftUI 来实现跨平台的 UI 共享,同时使用 UIKit 来处理特定平台的细节和定制化

一言以蔽之:大多数情况下,使用 UIKit 足以,但想要使用新的框架, SwiftUI 很棒,但某些交互复杂的功能无法实现,还需要借助 UIKit 的能力,包括丰富的组件库和开源项目的支持。

参考资料