本文将经过一段可复现的“灵异代码”,对 State 注入优化机制、模态视图( Sheet、FullScreenCover )内容的生成机遇以及不同上下文( 相互独立的视图树 )之间的数据协调等问题进行讨论。

可在 此处 获取本文代码

原文发表在我的博客wwww.fatbobman.com

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

问题

不久之前,网友 Momo6 在 聊天室 中咨询了如下一个 问题:

一段因 @State 注入机制所产生的“灵异代码”

在下面的代码中,假如注释掉 ContentView 中的 Text("n = \(n)") 代码,在按下按钮后( n 设置为 2),fullScreenCover 视图中 Text 显现的 n 仍为 1( 预期为 2)。假如不注释这行代码,fullScreenCover 中将显现 n = 2 ( 契合预期 )。这是为什么?

struct ContentView: View {
    @State private var n = 1
    @State private var show = false
    var body: some View {
        VStack {
            // 假如注释掉下面这行 Text 代码
            // 在按下 Button ( n = 2 ) 后 , full-screen 中的 Text 仍显现 n = 1
            // Text("n = \(n)") // 解除注释,sheet 中的 Text 将显现 n = 2
            Button("Set n = 2") {
                n = 2
                show = true
            }
        }
        .fullScreenCover(isPresented: $show) {
            VStack {
                Text("n = \(n)") 
                Button("Close") {
                    show = false
                    print("n in fullScreenCover is", n) // 不管是否注释掉上面的 Text ,此处均打印为 2
                }
            }
        }
    }
}

为了演示明晰,我将 fullScreenCover 换成了 sheet( 改动不影响上面所描绘的现象 ), 并为 Button 增加了 ButtonStyle。

一段因 @State 注入机制所产生的“灵异代码”

此处建议暂停几分钟,看看你是否能想出其间的问题所在?

问题构成

尽管看起来有些古怪,但 Text 的增加与否,确实将影响 Sheet 视图中的显现内容。而呈现这种现象的原因则是由 State 注入的优化机制、 Sheet( FullScreenCover )视图的生命周期以及新建上下文等几方面一起形成的。

State 注入的优化机制

在 SwiftUI 中,关于引证类型,开发者能够经过 @StateObject、@ObservedObject 或 @EnvironmentObject 将其注入到视图中。经过这些办法注入的依赖,不管视图的 body 中是否运用了该实例的特点,只需该实例的 objectWillChange.send() 办法被调用,与其相关的视图都将被强制改写( 从头核算 body 值 )。

与之不同的是,针对值类型的首要注入手段 @State,SwiftUI 则为其实现了高度的优化机制( EnvironmentValue 没有提供优化,行为与引证类型注入行为共同 )。这意味着,即便咱们在定义视图的结构体中声明了运用 @State 标注的变量,但只需 body 中没有运用该特点( 经过 ViewBuilder 支持的语法 ),即便该特点发生改变,视图也不会改写。

struct StateTest: View {
    @State var n = 10
    var body: some View {
        VStack {
            let _ = print("update")
            Text("Hello")
            Button("n = n + 1") {
                n += 1
                print(n)
            }
        }
    }
}

在下方的动图中,在 Text 中不包括 n 的情况下,即便 n 值改动,StateTest 视图的 body 也不会从头核算。当在 Text 中增加 n 的引证后,每次 n 值发生改变,都将引发视图更新。

一段因 @State 注入机制所产生的“灵异代码”

经过调查加载后视图的 State 源数据,咱们能够看到,State 包括一个 _wasRead 私有特点,在其与恣意视图相关后,该值为 true。

一段因 @State 注入机制所产生的“灵异代码”

回到咱们当时的“问题”代码:

struct ContentView: View {
    @State private var n = 1
    @State private var show = false
    var body: some View {
        VStack {
            // Text("n = \(n)") // 注释掉该行后,sheet 中的 n 显现为 1( 并非预期中的 2 )
            Button("Set n = 2") {
                n = 2
                show = true
            }
            .buttonStyle(.bordered)
        }
        .sheet(isPresented: $show) {
            VStack {
                Text("n = \(n)")
                Button("Close") {
                    show = false
                    print("n in fullScreenCover is", n)
                }
                .buttonStyle(.bordered)
            }
        }
    }
}

当咱们在 ContentView 中增加了 Text 后,Button 中对 n 的修正将引发 body 从头求值,注释后则不引发求值。这也就形成了是否增加 Text( 在 body 中引证 n ),会影响 body 能否再度求值。

Sheet( FullScreenCover )视图的生命周期

或许有人会问,在 sheet 的代码中,Text 相同包括了对 n 的引证。这个引证难道不会让 n 与 ContentView 视图之间树立相关吗?

与大多数的 View Extension 和 ViewModifier 不同,在视图中,经过 .sheet.fullScreenCover 来声明的模态视图内容代码的闭包,只会在显现模态视图的时候才会被调用、解析( 对闭包中的 View 进行求值 )。

而其它经过视图修饰器声明的代码块,则会在主视图 body 求值时进行必定的操作:

  • overlay、background 等,会在 body 求值时调用、解析( 因为要与主视图一并显现 )
  • alert、contextMenu 等则会在 body 求值时调用( 能够理解为创立实例 ),但只有在需求显现时才进行求值

这就是说,即便咱们在 Sheet 代码块的 Text 中增加了对 n 的引证,但只需模态视图尚未显现,则 n 的 _wasRead 仍为 false( 并没有与视图创立相关 )。

为了演示上面的论说,咱们将 Sheet 中的代码用一个契合 View 协议的结构体包装起来,以便利咱们调查。

struct AnalyticsView: View {
    @State private var n = 1
    @State private var show = false
    var body: some View {
        let _ = print("Parent View update") // 主视图 body 求值
        VStack {
            // Text("n = \(n)") // 注释掉该行后,sheet 中的 n 显现为 1( 并非预期中的 2 )
            Button("Set n = 2") {
                n = 2
                show = true
            }
            .buttonStyle(.bordered)
        }
        .sheet(isPresented: $show) {
            SheetInitMonitorView(show: $show, n: n)
        }
    }
}
struct AnalyticsViewPreview: PreviewProvider {
    static var previews: some View {
        AnalyticsView()
    }
}
struct SheetInitMonitorView: View {
    @Binding var show: Bool
    let n: Int
    init(show: Binding<Bool>, n: Int) {
        self._show = show
        self.n = n
        print("sheet view init") // 创立实例( 表明 sheet 的闭包被调用 )
    }
    var body: some View {
        let _ = print("sheet view update") // sheet 视图求值
        VStack {
            Text("n = \(n)")
            Button("Close") {
                show = false
                print("n in fullScreenCover is", n)
            }
            .buttonStyle(.bordered)
        }
    }
}

一段因 @State 注入机制所产生的“灵异代码”

经过输出内容咱们能够看出,在初次对 ContextView 进行求值时( 打印 Parent View update),Sheet 代码块中的 SheetInitMonitorView 没有任何输出( 意味着闭包没有被调用 ),只有在模态视图进行显现时,SwiftUI 才执行 .sheet 闭包中的函数,创立 Sheet 视图。

回到开端的代码:

.fullScreenCover(isPresented: $show) {
    VStack {
        Text("n = \(n)")
        Button("Close") {
            show = false
            print("n in fullScreenCover is", n) // 不管是否注释掉上面的 Text ,此处均打印为 2
        }
    }
}

尽管咱们经过 .fullScreenCover 在 Text 中引证了 n , 但因为该段代码并不会在 ContextView 求值时被调用,因而也不会让 n 与 ContextView 创立相关。

在 ContextView 不包括 Text 的情况下,在 Sheet 显现后,n 的 _wasRead 将转变为 true( Sheet 视图显现后,方创立相关 )。能够经过在 Button 中增加如下代码进行查看:

Button("Set n = 2") {
    n = 2
    show = true
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1){ // 推迟已保证 Sheet 中的视图已完结创立
        dump(_n)
    }
}

Sheet 视图的上下文

当 SwiftUI 创立并显现一个 Sheet 视图时,并非在现有的视图树上创立分支,而是新建一棵独立的视图树。也就是说 Sheet 中的视图与原有视图分别处于不同的上下文中。

在 SwiftUI 前期的版别中,关于分别坐落不同上下文的独立的视图树,开发者需求显式为 Sheet 视图树注入环境依赖。后期版别已为开发者自动完结该注入作业。

这意味着,相较于在原有视图树上创立分支,在新上下文中重建视图树的开支更大,需求进行的作业也更多。

而 SwiftUI 为了优化效率,一般会对若干操作进行兼并。即便为新上下文中的视图进行的相关操作是在视图求值操作之前完结的,但因为 n 的改变与相关操作被集中在一个 Render Loop 中,这样会导致在相关之后并不会强制新相关的视图改写( 相关后,值并没有发生改变 )。

现象剖析

依据上文中介绍的内容,咱们对本文代码的古怪现象进行一个完好的梳理:

当 ContextView 中不包括 Text( ContextView 没有与 n 创立相关 )

  • 程序运转,SwiftUI 对 ContextView 的 body 进行求值并烘托

  • .fullScreenCover 的闭包此时并未被调用,但捕获了视图当时的 n 值 ( n = 1 )

  • 点击 Button 后,尽管 n 的内容发生改变,但 ContextView 的 body 并未从头求值

  • 因为 show 转变为 true ,SwiftUI 开端调用 .fullScreenCover 的闭包,创立 Sheet 视图

    尽管 show 也是经过 State 声明的,但 show 的改变并不会导致 ContextView 从头更新。这是因为在 .fullScreenCover 的结构办法中,咱们传递的是 show 的 projectedValue( Binding 类型 )

  • 因为兼并操作的原因,在 Sheet 视图相关到 n 后,并不会从头更新

  • Sheet 中的 Text 显现 n = 1

  • 点击 Sheet 中的 Close 按钮,执行 Button 闭包,从头取得 n 的当时值( n = 2 ),打印值为 2

当 ContextView 中包括 Text ( ContextView 与 n 之间创立了相关 )

  • 程序运转,SwiftUI 对 ContextView 的 body 进行求值并烘托
  • .fullScreenCover 的闭包此时并未被调用,但捕获了视图当时的 n 值 ( n = 1 )
  • 点击 Button 后,因为 n 值发生了改变,ContextView 从头求值( 从头解析 DSL 代码 )
  • 在从头求值的进程中,.fullScreenCover 的闭包捕获了新的 n 值 ( n = 2 )
  • 创立 Sheet 视图并烘托
  • 因为 .fullScreenCover 闭包现已究竟捕获了新值,因而 Sheet 的 Text 显现为 n = 2

也就是说,经过增加 Text,让 ContextView 与 n 创立了相关,在 n 改变后,ContextView 进行了从头求值,然后让 fullScreenCover 的闭包捕获了改变后的 n 值,并呈现了预期中的成果。

解决计划

在了解了“异常”的原因后,解决并防止再次呈现类似的古怪现象已不是难事。

计划一、在 DSL 中进行相关,强制改写

原代码中,经过增加 Text 为 ContextView 和 n 之间创立相关就是一个能够承受的解决计划。

另外,咱们也能够经过无需增加额定显现内容的办法来创立相关:

Button("Set n = 2") {
    n = 2
    show = true
}
.buttonStyle(.bordered)
// .id(n)  
.onChange(of:n){_ in } // id 或 onChange 均能够在不增加显现内容的情况下,创立相关

在 创立自适应高度的 Sheet 的推文 中,我便运用过 id 来解决重制 Sheet 高度的问题。

计划二、运用 @StateObject 强制改写

咱们能够经过创立引证类型的 Source 来防止在不同上下文之间相关 State 可能呈现的顺序错误。事实上,运用 @StateObject 相当于在 vm.n 发生改变后,强制视图从头核算。

struct Solution2: View {
    @StateObject var vm = VM()
    @State private var show = false
    var body: some View {
        VStack {
            Button("Set n = 2") {
                vm.n = 2
                show = true
            }
            .buttonStyle(.bordered)
        }
        .sheet(isPresented: $show) {
            VStack {
                Text("n = \(vm.n)")
                Button("Close") {
                    show = false
                    print("n in fullScreenCover is", vm.n)
                }
                .buttonStyle(.bordered)
            }
        }
    }
}
class VM: ObservableObject {
    @Published var n = 1
}

计划三、运用 Binding 类型,重获新值

咱们能够将 Binding 类型视作一个对某值的 get 和 set 办法的包装。Sheet 视图在求值时,将经过 Binding 的 get 办法,取得 n 的最新值。

Binding 中 get 办法对应的是 ContextView 中 n 的原始地址,无需经过为 Sheet 从头注入的进程,因而在求值阶段便能够取得最新值

struct Solution3: View {
    @State private var n = 1
    @State private var show = false
    var body: some View {
        VStack {
            Button("Set n = 2") {
                n = 2
                show = true
            }
            .buttonStyle(.bordered)
        }
        .sheet(isPresented: $show) {
            SheetView(show: $show, n: $n)
        }
    }
}
struct SheetView:View {
    @Binding var show:Bool
    @Binding var n:Int
    var body: some View {
        VStack {
            Text("n = \(n)")
            Button("Close") {
                show = false
                print("n in fullScreenCover is", n)
            }
            .buttonStyle(.bordered)
        }
    }
}

计划四、推迟更新数据

经过推迟修正 n 值( 在 Sheet 视图求值并相关数据后再修正 ),强迫 Sheet 视图从头求值

struct Solution4: View {
    @State private var n = 1
    @State private var show = false
    var body: some View {
        VStack {
            Button("Set n = 2") {
                // 极小的推迟便能够达到作用
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.01 ){
                    n = 2
                }
                show = true
            }
            .buttonStyle(.bordered)
        }
        .sheet(isPresented: $show) {
            VStack {
                Text("n = \(n)")
                Button("Close") {
                    show = false
                    print("n in fullScreenCover is", n)
                }
                .buttonStyle(.bordered)
            }
        }
    }
}

总结

尽管现已发展到 4.0 版别,但 SwiftUI 仍会呈现一些与预期不符的行为。在面临这些“灵异现象”时,假如咱们能对其进行更多的研讨,那么不只能够在今后防止类似的问题,而且在剖析的进程中,也能对 SwiftUI 的各种运转机制有深化的掌握。

希望本文能够对你有所协助。同时也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行交流。

订阅下方的 邮件列表,能够及时取得每周的 Tips 汇总。

原文发表在我的博客wwww.fatbobman.com

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