跟着近年来有关 SwiftUI 的文章与书本越来越多,开发者应该都现已清楚地掌握了 —— “视图是状况的函数” 这一 SwiftUI 的基本概念。每个视图都有与其对应的状况,当状况改变时,SwiftUI 都将从头核算与其对应视图的 body 值。

假如视图呼应了不应呼应的状况,或许视图的状况中包括了不应包括的成员,都可能形成 SwiftUI 对该视图进行不必要的更新( 重复核算 ),当相似状况会集呈现,将直接影呼运用的交互呼应,并发生卡顿的状况。

一般咱们会将这种剩余的核算行为称之为过度核算或重复核算。本文将介绍怎么削减( 甚至防止 )相似的状况发生,然后改进 SwiftUI 运用的全体体现。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

视图状况的构成

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

  • 运用 @State、@StateObject 这类特点包装器声明的变量
  • 视图类型( 契合 View 协议 )的结构参数
  • 例如 onReceive 这类的事情源

一个视图能够包括多个不同品种的 Source of Truth,它们一起构成了视图状况( 视图的状况是个复合体 )。

根据不同品种的 Source of Truth 的完成原理与驱动机制之间的区别,下文中,咱们将以此为分类,分别介绍其对应的优化技巧。

契合 DynamicProperty 协议的特点包装器

简直每一个 SwiftUI 的运用者,在学习 SwiftUI 的第一天就会接触到例如 @State、@Binding 这些会引发视图更新的特点包装器。

跟着 SwiftUI 的不断发展,这类的特点包装器越来越多,已知的有( 截至 SwiftUI 4.0):@AccessibilityFocusState、@AppStorage、@Binding、@Environment、@EnvironmentObject、@FetchRequest、@FocusState、@FocusedBinding、@FocusedObject、@FocusedValue、@GestureState、@NSApplicationDelegateAdaptor、@Namespace、@ObservadObject、@ScaledMetric、@SceneStorage、@SectionedFetchRequest、@State、@StateObject、@UIApplicationDelegateAdaptor、@WKApplicationDelegateAdaptor、@WKExtentsionDelegateAdaptor 等。一切能够让变量成为 Source of Truth 的特点包装器都有一个特点 —— 契合 DynamicProperty 协议。

因而,了解 DynamicProperty 协议的运作机制关于优化因该品种 Source of Truth 形成的重复核算尤为重要。

DynamicProperty 的作业原理

苹果并没有供给太多有关 DynamicProperty 协议的材料,公开的协议办法只需 update ,其完好的协议要求如下:

public protocol DynamicProperty {
  static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
  static var _propertyBehaviors: UInt32 { get }
  mutating func update()
}

其间 _makeProperty 办法是整个协议的灵魂所在。经过 _makeProperty 办法,SwiftUI 得以完成在将视图加载到视图树时,把所需的数据( 值、办法、引证等 )保存在 SwiftUI 的保管数据池中,并在特点图( AttributeGraph )中将视图与该 Source of Truth 相关起来,让视图呼应其改变( 当 SwiftUI 数据池中的数据给出改变信号时,更新视图 )。

以 @State 为例:

@propertyWrapper public struct State<Value> : DynamicProperty {
  internal var _value: Value
  internal var _location: SwiftUI.AnyLocation<Value>? // SwiftUI 保管数据池中的数据引证
  public init(wrappedValue value: Value)
  public init(initialValue value: Value) {
        _value = value // 创立实例时,只会暂存初始值
    }
  public var wrappedValue: Value {
    get  //  guard let _location else { return _value} ...
    nonmutating set // 只能改动 _location 指向的数据
  }
  public var projectedValue: SwiftUI.Binding<Value> {
    get
  }
  // 在将视图加载到视图树中时,调用此办法,完结相关作业
  public static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
}
  • 在初始化 State 时,initialValue 仅被保存在 State 实例的内部特点 _value 中,此时,运用 Stae 包装的变量值没有被保存在 SwiftUI 的保管数据池中,而且 SwiftUI 也没有在特点图中将其作为 Source of Truth 与视图相关起来。

  • 当 SwiftUI 将视图加载到视图树时,经过调用 _makeProperty 完结将数据保存到保管数据池以及在特点图中创立相关的操作,并将数据在保管数据池中的引证保存在 _location ( AnyLocation 为引证类型,为 AnyLocationBase 的子类 ) 中。wrappedValue 的 get 和 set 办法都是针对 _location 操作的( projectedValue 也相同 )。

  • 当 SwiftUI 将视图从视图树上删除时,会同时完结对 SwiftUI 数据池以及相关的整理作业。如此,运用 State 包装的变量,其存续期将与视图的存续期保持完全一致。而且 SwiftUI 会在其改变时自动更新( 从头核算 )对应的视图。

SwiftUI 上有一个困扰了不少人的问题:为什么无法在视图的结构函数中,更改 State 包装的变量值?了解了上述进程,问题便有了答案。

struct TestView: View {
    @State private var number: Int = 10
    init(number: Int) {
        self.number = 11 // 更改无效
    }
    var body: some View {
        Text("\(number)") // 初次运行,显现 10
    }
}

在结构函数中运用 self.number = 11 赋值时,视图没有加载,_location 为 nil , 因而赋值对应的 wrappedValue set 操作并不会起作用。

关于像 @StateObject 这类针对引证类型的特点包装器,SwiftUI 会在特点图中将视图与包装目标实例( 契合 ObservableObject 协议 )的 objectWillChange( ObjectWillChangePublisher )相关起来,在该 Publisher 发送数据时,更新视图。任何经过 objectWillChange.send 进行的操作都将导致视图被改写,不管实例中的特点内容是否被修改。

@propertyWrapper public struct StateObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
  internal enum Storage { // 经过内部界说的枚举来标注视图是否现已被加载、数据是否已被数据池保管
    case initially(() -> ObjectType)
    case object(ObservedObject<ObjectType>)
  }
  internal var storage: StateObject<ObjectType>.Storage
  public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
        storage = .initially(thunk) // 初始化,视图没有加载
    }
  @_Concurrency.MainActor(unsafe) public var wrappedValue: ObjectType {
    get
  }
  @_Concurrency.MainActor(unsafe) public var projectedValue: SwiftUI.ObservedObject<ObjectType>.Wrapper {
    get
  }
    // 在 DynamicProperty 要求的办法中,完成将实例保存在保管数据池,并将视图与保管实例的 objectWillChange 进行相关
  public static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
}

@ObservedObject 与 @StateObject 最大的区别是,ObservedObject 并不会在 SwiftUI 保管数据池中保存引证目标的实例( @StateObject 会将实例保存在保管数据池中 ),仅会在特点图中创立视图与视图类型实例中的引证目标的 objectWillChange 之间的相关。

@ObservedObject var store = Store() // 每次创立视图类型实例,都会从头创立 Store 实例

由于 SwiftUI 会不守时地创立视图类型的实例( 非加载视图 ),每次创立的进程都会从头创立一个新的引证目标,因而假定运用上面的代码( 用 @ObservedObject 创立实例 ),让 @ObservedObject 指向一个不稳定的引证实例时,很简单呈现一些奇怪的现象。

阅览如下的文章,能够协助你更好地了解本节的内容:SwiftUI 视图的生命周期研讨、@state 研讨、@StateObject 研讨

防止非必要的声明

任何能够在当时视图之外进行改动的 Source of Truth( 契合 DynamicProperty 协议的特点包装器 ),只需在视图类型中声明了,不管是否在视图 body 中被运用,在它给出改写信号时,当时视图都将被改写。

例如下面的代码:

struct EnvObjectDemoView:View{
    @EnvironmentObject var store:Store
    var body: some View{
        Text("abc")
    }
}

虽然当时的视图中并没有调用 store 实例的特点或办法,但不管在任何场合,但只需该实例的 objectWillChange.send 办法被调用( 例如修改了运用 @Published 包装的特点 ),一切与之相相关的视图( 包括当时视图 )都会被改写( 对 body 求值 )。

类型的状况在 @ObservedObject、@Environment 上也会呈现:

struct MyEnvKey: EnvironmentKey {
    static var defaultValue = 10
}
extension EnvironmentValues {
    var myValue: Int {
        get { self[MyEnvKey.self] }
        set { self[MyEnvKey.self] = newValue }
    }
}
struct EnvDemo: View {
    @State var i = 100
    var body: some View {
        VStack {
            VStack {
                EnvSubView()
            }
            .environment(\.myValue, i)
            Button("change") {
                i = Int.random(in: 0...100)
            }
        }
    }
}
struct EnvSubView: View {
    @Environment(\.myValue) var myValue // 声明了,但并没有在 body 中运用
    var body: some View {
        let _ = print("sub view update")
        Text("Sub View")
    }
}

即使 EnvSubView 的 body 中没有运用 myValue,但由于其祖先视图对 EnvironmentValues 中的 myValue 进行了修改,EnvSubView 也会被改写。

只需多查看代码,清除掉这些没有运用的声明,就能够防止因而种方式发生重复核算。

其他建议

  • 需求跳动视图层级时,考虑运用 Environment 或 EnvironmentObject

  • 关于不紧密的 State 关系,考虑在同一个视图层级运用多个 EnvironmentObject 注入,将状况别离

  • 在适宜的场景中,能够运用 objectWillChange.send 替换 @Published

  • 能够考虑运用第三方库,对状况进行切分,削减视图改写几率

  • 无需寻求完全防止重复核算,应在依靠注入便利性、运用功能体现、测验难易度等方面获得平衡

  • 不存在完美的处理方案,即使像 TCA 这类的抢手项目,面临切分粒度高、层次多的 State 时,也会有显着的功能瓶颈

视图的结构参数

在测验改进 SwiftUI 视图的重复核算行为时,开发者一般会将留意力会集于那些契合 DynamicProperty 协议的特点包装器之上,然而,对视图类型结构参数进行优化,有时会获得愈加显着的收益。

SwiftUI 会将视图类型的结构参数作为 Source of Truth 对待。与契合 DynamicProperty 协议的特点包装器自动驱动视图更新的机制不同,SwiftUI 在更新视图时,会经过查看子视图的实例是否发生改变( 绝大多数都由结构参数值的改变导致 )来决定对子视图更新与否。

例如:当 SwiftUI 在更新 ContentView 时,假如 SubView 的结构参数( name 、age )的内容发生了改变,SwiftUI 会对 SubView 的 body 从头求值( 更新视图 )。

struct SubView{
    let name:String
    let age:Int
    var body: some View{
        VStack{
            Text(name)
            Text("\(age)")
        }
    }
}
struct ContentView {
    var body: some View{
        SubView(name: "fat" , age: 99)
    }
}

简单、粗犷、高效的比对战略

咱们知道,在视图的存续期中,SwiftUI 一般会多次地创立视图类型的实例。在这些创立实例的操作中,绝大多数的意图都是为了查看视图类型的实例是否发生了改变( 绝大多数的状况下,改变是由结构参数的值发生了改变而导致 )。

  • 创立新实例
  • 将新实例与 SwiftUI 当时运用的实例进行比对
  • 如实例发生改变,用新实例替换当时实例,对实例的 body 求值,并用新的视图值替换老的视图值
  • 视图的存续期不会由于实体的替换有所改变

由于 SwiftUI 并不要求视图类型有必要契合 Equatable 协议,因而选用了一种简单、粗犷但非常高效地根据 Block 的比对操作( 并非根据参数或特点 )。

比对成果仅能证明两个实例之间是否不同,但 SwiftUI 无法确定这种不同是否会导致 body 的值发生改变,因而,它会无脑地对 body 进行求值。

为了防止发生重复核算,经过优化结构参数的规划,让实例仅在真实需求更新时才发生改变。

由于创立视图类型实例的操作反常地频频,因而一定不要在视图类型的结构函数中进行任何会对系统形成负担的操作。别的,不要在视图的结构函数中为特点( 没有运用契合 DynamicProperty 协议的包装器 )设置不稳定值( 例如随机值 )。不稳定值会导致每次创立的实例都不同,然后形成非必要的改写

化整为零

上述的比对操作是在视图类型实例中进行的,这意味着将视图切分成多个小视图( 视图结构体 )能够获得愈加精细的比对成果,并会削减部分 body 的核算。

struct Student {
    var name: String
    var age: Int
}
struct RootView:View{
    @State var student = Student(name: "fat", age: 88)
    var body: some View{
        VStack{
            StudentNameView(student: student)
            StudentAgeView(student: student)
            Button("random age"){
                student.age = Int.random(in: 0...99)
            }
        }
    }
}
// 分成小视图
struct StudentNameView:View{
    let student:Student
    var body: some View{
        let _ = Self._printChanges()
        Text(student.name)
    }
}
struct StudentAgeView:View{
    let student:Student
    var body: some View{
        let _ = Self._printChanges()
        Text(student.age,format: .number)
    }
}

上面的代码虽然完成了将 Student 的显现子视图化,但是由于结构参数的规划问题,并没有起到削减重复核算的作用。

在点击 random age 按钮修改 age 特点后,虽然 StudentNameView 中并没有运用 age 特点,但 SwiftUI 仍然对 StudentNameView 和 StudentAgeView 都进行了更新。

这是由于,咱们将 Student 类型作为参数传递给了子视图,SwiftUI 在比对实例的时分,并不会关心子视图中详细运用了 student 中的哪个特点,只需 student 发生了改变,那么就会从头核算。为了处理这个问题,咱们应该调整传递给子视图的参数类型和内容,仅传递子视图需求的数据。

struct RootView:View{
    @State var student = Student(name: "fat", age: 88)
    var body: some View{
        VStack{
            StudentNameView(name: student.name) // 仅传递需求的数据
            StudentAgeView(age:student.age)
            Button("random age"){
                student.age = Int.random(in: 0...99)
            }
        }
    }
}
struct StudentNameView:View{
    let name:String // 需求的数据
    var body: some View{
        let _ = Self._printChanges()
        Text(name)
    }
}
struct StudentAgeView:View{
    let age:Int
    var body: some View{
        let _ = Self._printChanges()
        Text(age,format: .number)
    }
}

经过上面的改动后,仅当 name 特点发生改变时,StudentNameView 才会更新,同理,StudentAgeView 也只会在 age 发生改变时更新。

让视图契合 Equatable 协议以自界说比对规矩

或许由于某种原因,你无法选用上面的办法来优化结构参数,SwiftUI 还供给了别的一种经过调整比对规矩的方式用以完成相同的成果。

  • 让视图契合 Equatable 协议
  • 为视图自界说判别相等的比对规矩

在前期的 SwiftUI 版别中,咱们需求运用 EquatableView 包装契合 Equatable 协议的视图以启用自界说比较规矩,近期的版别现已无需运用

仍以上面的代码举例:

struct RootView: View {
    @State var student = Student(name: "fat", age: 88)
    var body: some View {
        VStack {
            StudentNameView(student: student)
            StudentAgeView(student: student)
            Button("random age") {
                student.age = Int.random(in: 0...99)
            }
        }
    }
}
struct StudentNameView: View, Equatable {
    let student: Student
    var body: some View {
        let _ = Self._printChanges()
        Text(student.name)
    }
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.student.name == rhs.student.name
    }
}
struct StudentAgeView: View, Equatable {
    let student: Student
    var body: some View {
        let _ = Self._printChanges()
        Text(student.age, format: .number)
    }
    static func== (lhs: Self, rhs: Self) -> Bool {
        lhs.student.age == rhs.student.age
    }
}

此种办法仅会影响视图类型实例的比对,并不会影响因契合 DynamicProperty 协议的特点包装器发生的改写

闭包 —— 简单被忽略的突破点

当结构参数的类型为函数时,稍不留意,就能够导致重复核算。

比如,下面的代码:

struct ClosureDemo: View {
    @StateObject var store = MyStore()
    var body: some View {
        VStack {
            if let currentID = store.selection {
                Text("Current ID: \(currentID)")
            }
            List {
                ForEach(0..<100) { i in
                    CellView(id: i){ store.sendID(i) } // 运用跟随闭包的方式为子视图设定按钮动作
                }
            }
            .listStyle(.plain)
        }
    }
}
struct CellView: View {
    let id: Int
    var action: () -> Void
    init(id: Int, action: @escaping () -> Void) {
        self.id = id
        self.action = action
    }
    var body: some View {
        VStack {
            let _ = print("update \(id)")
            Button("ID: \(id)") {
                action()
            }
        }
    }
}
class MyStore: ObservableObject {
    @Published var selection:Int?
    func sendID(_ id: Int) {
        self.selection = id
    }
}

当点击某一个 CellView 视图的按钮后,一切的 CellView ( 当时 List 显现区域 )都会从头核算。

避免 SwiftUI 视图的重复计算

这是由于,乍看起来,咱们并没有在 CellView 中引入会导致更新的 Source of Truth,但由于咱们将 store 放置在闭包傍边,点击按钮后,由于 store 发生了变化,然后导致 SwiftUI 在对 CellView 实例进行比对时确定其发生了改变。

CellView(id: i){ store.sendID(i) }

处理的办法有两种:

  • 让 CellView 契合 Equatable 协议,不比较 action 参数
struct CellView: View, Equatable {
    let id: Int
    var action: () -> Void
    init(id: Int, action: @escaping () -> Void) {
        self.id = id
        self.action = action
    }
    var body: some View {
        VStack {
            let _ = print("update \(id)")
            Button("ID: \(id)") {
                action()
            }
        }
    }
    static func == (lhs: Self, rhs: Self) -> Bool { // 将 action 扫除在比较之外
        lhs.id == rhs.id
    }
}
ForEach(0..<100) { i in
    CellView(id: i){ store.sendID(i) }
}
  • 修改结构参数中的函数界说,将 store 扫除在 CellView 之外
struct CellView: View {
    let id: Int
    var action: (Int) -> Void // 修改函数界说
    init(id: Int, action: @escaping (Int) -> Void) {
        self.id = id
        self.action = action
    }
    var body: some View {
        VStack {
            let _ = print("update \(id)")
            Button("ID: \(id)") {
                action(id)
            }
        }
    }
}
ForEach(0..<100) { i in
    CellView(id: i, action: store.sendID) // 直接传递 store 中的 sendID 办法,将 store 扫除在外
}

避免 SwiftUI 视图的重复计算

事情源

为了全面地向 SwiftUI life cycle 转型,苹果为 SwiftUI 供给了一系列能够直接在视图中处理事情的视图润饰器,例如:onReceive、onChange、onOpenURL、onContinueUserActivity 等。这些触发器被称为事情源,它们也被视为 Source of Truth ,是视图状况的组成部分。

这些触发器是以视图润饰器的形式存在的,因而触发器的生命周期同与其相关的视图的存续期完全一致。当触发器接收到事情后,不管其是否更改当时视图的其他状况,当时的视图都会被更新。因而,为了削减因事情源导致的重复核算,咱们能够考虑选用如下的优化思路:

  • 操控生命周期

    只在需求处理事情时才加载与其相关的视图,用相关视图的存续期来操控触发器的生命周期

  • 减小影响范围

    为触发器创立独自的视图,将其对视图更新的影响范围降至最低

struct EventSourceTest: View {
    @State private var enable = false
    var body: some View {
        VStack {
            let _ = Self._printChanges()
            Button(enable ? "Stop" : "Start") {
                enable.toggle()
            }
            TimeView(enable: enable) // 独自的视图,onReceive 只能导致 TimeView 被更新
        }
    }
}
struct TimeView:View{
    let enable:Bool
    @State private var timestamp = Date.now
    var body: some View{
        let _ = Self._printChanges()
        Text(timestamp, format: .dateTime.hour(.twoDigits(amPM: .abbreviated)).minute(.twoDigits).second(.twoDigits))
            .background(
                Group {
                    if enable { // 只在需求运用时,才加载触发器
                        Color.clear
                            .task {
                                while !Task.isCancelled {
                                    try? await Task.sleep(nanoseconds: 1000000000)
                                    NotificationCenter.default.post(name: .test, object: Date())
                                }
                            }
                            .onReceive(NotificationCenter.default.publisher(for: .test)) { notification in
                                if let date = notification.object as? Date {
                                    timestamp = date
                                }
                            }
                    }
                }
            )
    }
}
extension Notification.Name {
    static let test = Notification.Name("test")
}

避免 SwiftUI 视图的重复计算

请留意,SwiftUI 会在主线程上运行触发器闭包,假如闭包中的操作比较昂贵,能够考虑将闭包发送到后台队列

总结

本文介绍了一些在 SwiftUI 中怎么防止形成视图重复核算的技巧,除了从中查找是否有能处理你当时问题的办法外,我更期望我们将关注点会集于这些技巧在背后对应的原理。

期望本文能够对你有所协助。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】