八个常见的SwiftUI误用及对应的正确打开方式
直接把八条误用先简明扼要地罗列如下,然后我们逐条深入展开:
- 添加不必要的 View 和 Modifier
- 在需要用 @StateObject 的地方用了 @ObservedObject
- Modifier 顺序错误
- 给属性包装器添加属性观察者
- 在需要用描边框的地方使用了描形状
- Alert 和 Sheet 与可选状态的使用
- 尝试改变 SwiftUI 视图后面的东西
- 用错误的范围动态创建视图
添加不必要的 View 和 Modifier
比如,你可能希望用一个红色矩形填满屏幕?然后你像下面这样编写代码:
Rectangle() .fill(Color.red)
的确,上面的代码可以工作 —— 它能准确地得到你想要的效果。但是其中一半的代码是不必要的,因为你只需要像下面这样写也能实现一样的效果:Color.red
这是因为在 SwiftUI 中,所有的颜色和形状都自动遵循了 View 协议,你可以把它们直接当成视图来使用。
你可能也会经常看见形状裁切,因为为了实现特定形状,应用 clipShape()
是件很自然的事情。例如,可以像下面这样让我们的红色矩形拥有圆角:Color.red .clipShape(RoundedRectangle(cornerRadius: 50))
但这也是不要的 —— 借助 cornerRadius() modifier,代码可以简化如下:Color.red .cornerRadius(50)
在需要用 @StateObject 的地方用了 @ObservedObject
@State
用于值类型属性,并且属性由当前视图拥有。因此,整数,字符串,数组等,都是应用 @State
的绝佳场景。
1 | class DataModel: ObservableObject { |
可以明确的说,这么做是错误的,并且极有可能在你的应用中带来问题。
译者注:基于代码片段说这样写一定是错误的,这个表述是不严谨的。
作者应该是隐含假设了 ContentView 是应用的顶级视图(通常来说,如果你不改工程模板的默认输出,ContentView 也确实是顶级视图)。对于顶级视图来说,SwiftUI 2.0 应当使用 @StateObject ,它是为了解决 @ObservedObject 或者 @EnvironmentObject 对象的所有权问题。但是对于附属于顶级视图的视图层级,各子视图的数据源可以是 @ObservedObject 或者 @EnvironmentObject,因为它们的生命周期受顶级视图管理,进而可以由顶级视图统一保证数据的可用性。
@State
表示某个值类型属性由当前视图拥有,这里的“拥有”很重要。而 @StateObject
则相当于引用类型版本的 @State
。
因此,上面的代码应该改成这样:
@StateObject model = DataModel()
当你使用 @ObservedObject
来创建某个对象实例时,你的视图并不拥有这个对象实例,也就是说,这个实例可以在任何时候被销毁(译者注:视图无法了解也无法干预这个时机)。狡猾的是,对象在视图还需要用它时被销毁的情况只是偶尔发生,所以你可能认为你的代码很完美。
需要记住的重点是 @State
和 @StateObject
表示“视图拥有数据”,而 @ObservedObject
和 @EnvironmentObject
则没有。
Modifier 顺序错误
最经典的例子是 padding 和 background 的使用,如下:
1 | Text("Hello, World!") |
由于我们在 background 颜色之后应用 padding,颜色只会被直接应用在文本周围,而不是被添加留白之后的文本周围。如果你希望留白和文本背景都是绿色,应该将代码改成下面这样:
1 | Text("Hello, World!") |
给属性包装器添加属性观察者
某些情况下你可能会为属性包装器添加诸如 didSet
这样的属性观察者,但它不会如你预期的那样工作。
例如,如果你在使用滑块,希望在滑块值改变时执行某种动作,你可能会下面这样编写代码:
1 | struct ContentView: View { |
但是,这个 didSet
属性观察者永远都不会被调用,因为属性的值是由绑定直接修改的,而不是每次创建一个新值。
对此,SwiftUI 原生的方式是使用 onChange()
modifier,如下:
1 | struct ContentView: View { |
不过,我个人更喜欢一种不同的方案:我使用基于 Binding
的扩展来返回新的绑定,其中的 get 和 set 包装的值和之前一样,但是在新值得到时也会调用处理函数:
1 | extension Binding { |
有了这个扩展,我们就可以把绑定的动作直接附着在滑块视图上:
1 | struct ContentView: View { |
在需要用描边框的地方使用了描形状
不理解 stroke()
和 strokeBorder
的区别是初学者常犯的错误。尝试下面的代码:
Circle() .stroke(Color.red, lineWidth: 20)
注意看,你会发现圆的左边缘和右边缘怎么不见了?(译者:这里预设你是竖屏运行程序,高度大于宽高)这是因为 stroke()
modifier 会把描边居中对齐在形状的轮廓线上,所以一个 20 个点的红色描边会绘制 10 个点到形状的边缘线外部,10 个点在边缘线内部 —— 这就导致了你看到圆形的左右超出屏幕的现象。
作为对照,strokeBorder()
则是把整个描边都绘制在形状内部,所以它不会放大形状的边框。
Circle() .strokeBorder(Color.red, lineWidth: 20)
相比于使用 strokeBorder()
,使用 stroke()
有一个好处是它返回的是一个新形状,而不是一个新视图。这使得你可以创建出某些本来难以实现的效果,比如给一个形状描两次边:
1 | Circle() |
Alert 和 Sheet 与可选状态的使用
1 | struct User: Identifiable { |
应当考虑换成可选型的实现方案。这个方案去掉了 Boolean
,也不必强制解包。唯一的要求是你所监听的目标需要遵循 Identifiable
。
举个例子,我们可以在 selectedUser
发生变化的任何时候展示警告弹窗,就像下面这样:
1 | struct ContentView: View { |
这会使得你的代码更加易于读写,并且避免因为强制解包可能带来的麻烦。
尝试改变 SwiftUI 视图后面的东西
SwiftUI 初学者最常犯的一个错误是他们常常试图去改变 SwiftUI 视图的背景。代码通常长下面这个样子:
1 | struct ContentView: View { |
这会展示一个白色的屏幕,中间是红色背景色只匹配文本区域的文本视图。而大多数人的本意其实是想让整个屏幕的背景呈现出红色。这个时候他们会想,SwiftUI 背后究竟是什么样的 UIKit 视图呢?
当然,背后肯定是有一个 UIKit 视图,它由 UIHostingController
管理,角色类似于一个 UIKit 视图控制器。但是假如你通过 SwiftUI 试图去踏足 UIKit 的领地,你的改动很可能会让 SwiftUI 呈现出奇怪的结果,或者你甚至都没法直接改动 UIKit。实际上,想到达成大多数人想要的效果,SwiftUI 里的实现方式应该是像下面这样的:
1 | Text("Hello, World!") |
动态视图的范围参数错误
有多个 SwiftUI 视图的构造器允许我们传入范围,这个事实让许多复杂视图的创建过程变得十分简单。
例如,假设我们想要展示一个拥有 4 个项目的列表,我们只需要这样写:
1 | struct ContentView: View { |
这样写本身没问题,不过一旦你需要在运行时改变范围时,问题就来了。你看我已经用 @State
属性包装器把想要改变的行数变成可修改的,所以我们可以用一个按钮来修改它的值:
1 | Button("Add Row") { |
运行代码,点击按钮,Xcode 调试输出会输出警告,而列表视图纹丝不动 —— 这个方案不管用。
问题出在你既没有为列表的参数提供 Identifiable
协议实现,也没有提供指定的 id
参数,以此告诉 SwiftUI
这个范围会动态变化:(译者:实际上并不是 “告诉 SwiftUI 范围会动态变化”,而是明确范围的项怎样才算变化。Identifiable
或者 id
参数明确了两个项之间是如何区别。能够区别开的项目才能侦测变化)。
1 | List(0..<rowCount, id: \.self) { row in |
代码改成这样就没问题了。