SwiftUI

SwiftUI 安全更新View

本篇文章主要讲解在SwiftUI中如何安全的更新View,能够让大家明白SwiftUI中View的刷新相关的原理。


1. View的State是什么?

View状态的定义并没有一个标准的答案,我们暂时把它定义为:在某一时刻,View中所有用@State修饰的变量的瞬时值。我用瞬时值这一说法,只是想表达那一时刻的值。

struct ContentView: View {
    @State var show = false
    var body: some View {
        Example4()
    }
}

可以看出,body是一个计算属性,当我们需要在body中更新show时,就有可能会发生未知的后果,这个我们在下边详细讲解。


2. Updating the State View

先给大家看一个简单的例子:

struct MyView: View {
    @State private var flag = false
    
    var body: some View {
        Button("Toggle Flag") {
            self.flag.toggle()
        }
    }
}

大家对这段代码太熟悉了,我们知道view在计算body的时候,不能修改view中的状态,那么这种写法为什么没问题呢?

答案非常简单,修改状态的代码self.flag.toggle()在一个闭包中,当计算body的时候,并不会执行该闭包,也就是说,在计算body的时候,并没有修改状态,只有点击了按钮后,view的状态才被修改,再次触发body的计算。

一旦我们修改状态的方式改变了,就会产生问题,看下边的代码:

struct OutOfControlView: View {
    @State private var count: Int = 0

    var body: some View {
        self.count += 1

        return Text("计算次数:\(self.count)")
            .multilineTextAlignment(.center)
    }
}

运行程序后,我们会得到一个运行时的提示信息:

这句话说明当我们在计算body的同时改变了状态,因为状态的改变,又导致View的重新渲染,这样死循环下去,有可能会产生未知的后果。按照我们的经验,我们只需要把self.count += 1放到DispatchQueue闭包中就可以了:

DispatchQueue.main.async {
        self.count += 1
}

这么做,就不会产生运行时的提醒信息,但仍然有很大的问题,为了让大家看到OutOfControlView刷新view对CPU的严重消耗,我们写一个能够显示CPU使用百分比的View,效果如下:

可以看到,计数器不断的增加,CPU使用率很高,说明OutOfControlView一直不断的刷新,上边效果的实现代码:

struct Example1: View {
    @State private var show = false

    var body: some View {
        VStack {
            CPUWheel()
                .frame(height: 150)

            if show {
                OutOfControlView()
            }

            Button(self.show ? "隐藏" : "显示") {
                self.show.toggle()
            }
        }
    }
}

struct OutOfControlView: View {
    @State private var count: Int = 0

    var body: some View {
        DispatchQueue.main.async {
            self.count += 1
        }

        return Text("计算次数:\(self.count)")
            .multilineTextAlignment(.center)
    }
}

那么为什么我们已经使用了DispatchQueue.main.async{}, 还有问题呢?原因在于:

  • DispatchQueue.main.async是一个异步函数,就跟按钮的点击事件一样,在计算body的时候,并不会直接执行。
  • 当body计算完成后才会执行DispatchQueue.main.async中的代码,这时候状态修改了,又触发了View的刷新。
  • 一直重复循环上边两个过程。

3. 如何打破上边的死循环呢?

我们不再用上边的这个例子演示,大家先看下边这个效果:

  • 随着箭头的旋转,箭头上方的方向文字也随着更新
  • CPU的使用率并不高
  • 在body的计算过程中实时修改状态

源码如下:

struct Example2: View {
    @State private var show = false
    @State private var direction = ""

    var body: some View {
        print("更新body direction = \(self.direction) ")
        return VStack {
            CPUWheel()
                .frame(height: 150)

            Text("\(self.direction)")
                .font(.largeTitle)

            Image(systemName: "location.north.fill")
                .resizable()
                .frame(width: 100, height: 100)
                .foregroundColor(.green)
                .modifier(RotateEffect(direction: self.$direction, angle: self.show ? 360 : 0))

            Button("开始") {
                withAnimation(.easeInOut(duration: 3.0)) {
                    self.show.toggle()
                }
            }
            .padding(.top, 50)
        }
    }
}

struct RotateEffect: GeometryEffect {
    @Binding var direction: String
    var angle: Double

    var animatableData: Double {
        get {
            angle
        }
        set {
            angle = newValue
        }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        DispatchQueue.main.async {
            self.direction = self.getDirection(self.angle)
            print("更新effectValue direction = \(self.direction) ")
        }

        let rotation = CGAffineTransform(rotationAngle: CGFloat(angle * (Double.pi / 180.0)))
        let offset1 = CGAffineTransform(translationX: size.width / 2.0, y: size.height / 2.0)
        let offset2 = CGAffineTransform(translationX: -size.width / 2.0, y: -size.height / 2.0)
        return ProjectionTransform(offset2.concatenating(rotation).concatenating(offset1))
    }

    func getDirection(_ angle: Double) -> String {
        switch angle {
        case 0..<45:
            return "北"
        case 45..<135:
            return "东"
        case 135..<225:
            return "南"
        case 225..<315:
            return "西"
        default:
            return "北"
        }
    }
}

关于上边的代码,大家需要注意以下几点:

  • @Binding var direction: String: 在RotateEffect中,我们通过Binding的方式直接修改状态
  • 通过getDirection来计算某个角度下的方向

当进行旋转的时候,self.direction一直都在改变,但为什么没有造成CPU的过度消耗呢?我们在上边代码中的两个地方加了打印函数:

print("更新body direction = \(self.direction) ")
print("更新effectValue direction = \(self.direction) ")

打印结果如下:

通过仔细分析上边的打印结果,我们得到如下结论:

  • 更新body direction = X: 系统并不是每次direction改变就更新body,而是非常聪明的知道什么时候需要更新body。
  • 正常情况下,系统已经帮我们规避了很多重复刷新的风险,我们需要理解其背后的刷新原理,才能写出更好性能的view。

4. 另一种死循环

即便系统在处理更新问题上已经足够聪明了,但我们在编码的时候,还是要十分小心。每当在body中更新数据的时候,都需要仔细分析整个更新过程,下边演示另一个会产生死循环的例子:

代码如下:

struct Example3: View {
    @State private var width: CGFloat = 0.0
    
    var body: some View {
        Text("Width = \(self.width)")
            .font(.largeTitle)
            .background(WidthGetter(width: self.$width))
    }
    
    struct WidthGetter: View {
        @Binding var width: CGFloat
        
        var body: some View {
            GeometryReader { proxy -> AnyView in
                DispatchQueue.main.async {
                    self.width = proxy.frame(in: .local).width
                    print(self.width)
                }
                return AnyView(Color.clear)
            }
        }
    }
}

当我们在WidthGetter中修改状态width的时候,Example3都需要重新刷新body,由于数字的宽度都不一样,造成了死循环,我们看一下打印结果:

可以看到,width在这几个数值之间不断切换,如果我们固定死每个数字的宽度,就能解决这个问题:

    var body: some View {
        Text("Width = \(self.width)")
            .font(.custom("Cochin", size: 30))
            .background(WidthGetter(width: self.$width))
    }

总结

  • 尽可能避免一边更新body,一边修改状态
  • 使用DispatchQueue.main.async{},这样可以把状态的修改时机放到body计算完成之后
  • 即便使用DispatchQueue.main.async{},也有可能会存在问题