Swift UI Bug: 首次调用sheet时不会取用最新的@State 属性

也许是机器人也许是真人,但是从统计数字来看每天都有一些朋友到访我的博客。其中真人的很大一部分应该都引流自几年前我发布的算法竞赛题目的题解。(即使现在看来,只有那么一两篇题解是写的比较好的)

不过从这篇文章开始,本低水平算法和生活博客就会涉及一个新的领域——Apple 平台的开发了。这样做对我的好处很多,记录下来自己在学开发的过程中遇到的问题和心得,对于自己的理解和掌握有很大帮助。在这个过程中可以遇到在做相类似事情的朋友,可以得到很多指正的机会。同时鉴于Swift(现在我并没有学Objective-C) 作为一个比较新且受众不那么大的语言,这些博客也可以为Swift 学习提供一定的中文资源。即使大部分我写到的东西会和之前的算法内容一样,低水平。

最近在完成一个Swift 项目时遇到一个非常神奇的问题。在经过和几个Swift 交流群的朋友讨论以及一些信息搜索之后,基本上可以确认是一个Swift UI 的bug。

考虑这样一个场景:

你有一个Swift UI的视图,它有一个@State 的属性number。你用Swift UI给出了1个文本框和2个按钮。文本框用于显示number 的值,按下A按钮时,number 会加1。按下B按钮时,会呼出1个sheet,这个sheet中只有一个显示number 的值的文本框,其实是和前面的那个文本框一样。

你可以查看下面这个GIF 来理解这个视图的作用:

正常运作的Swift UI

你可能在想,这里面哪里有什么bug呢?当点击增加按钮的时候,number 被增加了,并且number 的这个改变也同步到了视图和sheet 的文本框里。所以,这样的写法里没有触发Swift UI 的bug。

上面这个视图的代码如下:

struct TestView: View {
    @State private var number:Int = 0
    @State private var showSheet = false
    var body: some View {
        VStack{
            Button(action: {//一个按钮,用于给number增加1
                number = self.number + 1
            }){
                Text("A:Add 1 to Number")
                    .font(.largeTitle)
            }
            
            Button(action:{//一个按钮,用于呼出一个显示number数值的sheet
                showSheet.toggle()
            }){
                Text("B:Show Number")
                    .font(.largeTitle)
            }
            
            Text("Number = \(number)")//用于实时显示number数值的文本框
                .font(.largeTitle)
        }
        .sheet(isPresented: $showSheet){
            Text("Number = \(number)")
                .font(.largeTitle)
        }
    }
}

但当你尝试删除这个视图本身的文本框(而不是sheet里的文本框)时,这个bug 就会被触发了。删除原视图的文本框后演示如下,请仔细观察。可以稍微等待,直到GIF 重新开始播放:

Swift UI 的bug 被触发

发现什么了吗?

在我连续点击3次增加number 的按钮之后,初次唤醒sheet 时,sheet 中显示的number 仍然是0! 也就是number 的初始值,而不是它当时的真实值(3)。初次唤醒sheet 之后,当我们再对number 进行更改时,它的改变才会同步到sheet 上。

引起这个区别的,仅仅是我们删除了原视图中的文本框。但文本框本身对于number 是只读的,不会对number 进行任何的修改。

也就是说,如果原视图中没有对某个@State 属性的调用,那么,第一次呼出sheet 时,传入sheet 的这个属性会是它的初始值,而不是它实时的真实值。

虽然我对于Swift UI 的机制并没有非常深入的了解,不能非常准确地解释这个bug 出现的原因,但是可以大致猜测:如果原视图里没有调用@State 属性的视图组件,那么在首次唤醒sheet 之前,@State 属性的变化不会通知到Swift UI 。这个联系会在初次调用sheet 之后才建立起来。

搜索相关的资料,发现早有关于这个问题的讨论。这个问题在iOS 14之后开始出现,并且直到现在(2022年5月)也没有修复。相关的帖子可以参考:

Sheet sees the variable change from the second time | Apple Developer Forums

SwiftUI @State and .sheet() ios13 vs ios14 – Stack Overflow

那么如何解决这个问题?

首先,我们使用上面帖子中回复者提供的方法,你可以用@Binding 的方式从原视图向sheet 传递值。(前提是你要把sheet 单独写成一个View 的struct 而不是一个闭包)

如果你不想把sheet 单独写成一个视图,就想用闭包的方式来描述它,另外的解决方法是:根据我上面对bug 机制的猜测,由于原视图里没有对@State 属性的调用,才导致了首次呼出sheet 的时候使用的值不是最新。我们可以在原视图里添加一些对@State 属性的引用,例如:

struct TestView: View {
    @State private var number:Int = 0
    @State private var showSheet = false
    var body: some View {
        VStack{
            Button(action: {//一个按钮,用于给number增加1
                number = self.number + 1
            }){
                Text("A:Add 1 to Number")
                    .font(.largeTitle)
            }
            
            Button(action:{//一个按钮,用于呼出一个显示number数值的sheet
                showSheet.toggle()
            }){
                Text("B:Show Number")
                    .font(.largeTitle)
            }
            
            if(number == -1){
                Text("Hello World!")
            }
//            Text("Number = \(number)")//用于实时显示number数值的文本框
//                .font(.largeTitle)
        }
        .sheet(isPresented: $showSheet){
            Text("Number = \(number)")
                .font(.largeTitle)
        }
    }
}

上述代码的第22至24行:

if(number == -1){
    Text("Hello World!")
}

通过一个if 语句对number 进行了调用。但从这个视图的操作逻辑来看,number == -1 是永远不可能成立的,所以这个if 不会对视图造成任何影响,也就是说这个“Hello World”文本框永远不可能被显示。

你也可以用类似的if 语句,使用在你的视图中永远不可能成立的判断语句,对可能传入sheet 的@State 属性进行调用,从而避免Swift UI 的这个bug。

在增加了这个if 判断之后,即使原视图中没有调用number 的文本框,sheet 也能正确地取到number 的值了。

使用if 规避这个Swift UI bug 后的效果

希望Apple 能尽早修复这个bug 。毕竟改用@Binding 有些麻烦,而且在代码里掺入这些if 比较影响可读性。

既然这个问题已经出现了一年多,而且已经有许多相关的讨论,那么很可能已经有人向Apple 提出过这个问题了。(BTW,怎么看到关于Swift UI 的issue?)期待一下一个月之后的WWDC22 能解决这个问题。(如果没有,我就再去反馈一次)

WWDC22

留下评论

您的电子邮箱地址不会被公开。