本篇文章主要讲解在SwiftUI中如何安全的更新View,能够让大家明白SwiftUI中View的刷新相关的原理。
View状态的定义并没有一个标准的答案,我们暂时把它定义为:**在某一时刻,View中所有用@State修饰的变量的瞬时值。**我用瞬时值这一说法,只是想表达那一时刻的值。
struct ContentView: View { @State var show = false var body: some View { Example4() } } 复制代码
可以看出,body是一个计算属性,当我们需要在body中更新show时,就有可能会发生未知的后果,这个我们在下边详细讲解。
先给大家看一个简单的例子:
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) } } 复制代码
运行程序后,我们会得到一个运行时的提示信息:
[SwiftUI] Modifying state during view update, this will cause undefined behavior. 复制代码
这句话说明当我们在计算body的同时改变了状态,会产生未知的后果。按照我们的经验,我们只需要把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) } } 复制代码
上边代码中的CPUWheel
并没有给出,大家可以在这里https://gist.github.com/agelessman/ed514f2d6dc3378375faf0e64006048e下载完整代码。
那么为什么我们已经使用了DispatchQueue.main.async{}
,还有问题呢?原因在于:
DispatchQueue.main.async
是一个异步函数,就跟按钮的点击事件一样,在计算body的时候,并不会直接执行DispatchQueue.main.async
中的代码,这时候状态修改了,又触发了View的刷新我们不再用上边的这个例子演示,大家先看下边这个效果:
源码如下:
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的方式直接修改状态当进行旋转的时候,self.direction
一直都在改变,但为什么没有造成CPU的过度消耗呢?我们在上边代码中的两个地方加了打印函数:
print("更新body direction = \(self.direction) ") print("更新effectValue direction = \(self.direction) ") 复制代码
打印结果如下:
更新effectValue direction = 北 更新body direction = 北 更新effectValue direction = 北 ... 更新effectValue direction = 北 更新effectValue direction = 东 更新body direction = 东 更新effectValue direction = 东 ... 更新effectValue direction = 东 更新effectValue direction = 南 更新body direction = 南 更新effectValue direction = 南 ... 更新effectValue direction = 南 更新effectValue direction = 西 更新body direction = 西 更新effectValue direction = 西 ... 更新effectValue direction = 西 更新effectValue direction = 北 更新body direction = 北 更新effectValue direction = 北 ... 更新effectValue direction = 北 复制代码
通过仔细分析上边的打印结果,我们得到如下结论:
更新body direction = X
: 系统并不是每次direction改变就更新body,而是非常聪明的知道什么时候需要更新body即便系统在处理更新问题上已经足够聪明了,但我们在编码的时候,还是要十分小心。每当在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,由于数字的宽度都不一样,造成了死循环,我们看一下打印结果:
278.66666666666663 314.0 315.3333333333333 311.66666666666663 305.66666666666663 317.0 309.66666666666663 316.66666666666663 311.66666666666663 305.66666666666663 317.0 309.66666666666663 316.66666666666663 311.66666666666663 305.66666666666663 317.0 复制代码
可以看到,width在这几个数值之间不断切换,如果我们固定死每个数字的宽度,就能解决这个问题:
var body: some View { Text("Width = \(self.width)") .font(.custom("Cochin", size: 30)) .background(WidthGetter(width: self.$width)) } 复制代码
DispatchQueue.main.async{}
,这样可以把状态的修改时机放到body计算完成之后DispatchQueue.main.async{}
,也有可能会存在问题注:上边的内容参考了网站https://swiftui-lab.com/state-changes/,如有侵权,立即删除。