译自 https://www.hackingwithswift.com/articles/224/common-swiftui-mistakes-and-how-to-fix-them
欢迎关注微信公众号「Swift 花园」
SwiftUI 是一个庞大且复杂的框架。使用这个框架编程无疑是享受的,但犯错的机会也不少见。这篇文章我将带大家速览 SwiftUI 初学者常犯的一些错误,并提供修正方案。
其中的一些错误是由于简单的误解导致。由于 SwiftUI 太大,这种情况其实容易出现。而另一些错误则与深入理解 SwiftUI 的工作方式有关,还有一些是原有的思维方式导致 —— 你可能花了很多时间编写 view 和 modifier,但没有想到用 SwiftUI 的方式简化结果。
开门见山,你不需要猜我会给你准备什么菜,这里我直接把八条误用先简明扼要地罗列如下,然后我们逐条深入展开:
添加不必要的 View 和 Modifier
在需要用
@StateObject
的地方用了@ObservedObject
Modifier 顺序错误
给属性包装器添加属性观察者
在需要用描边框的地方使用了描形状
Alert 和 Sheet 与可选状态的使用
尝试改变 SwiftUI 视图后面的东西
用错误的范围动态创建视图
1 添加不必要的 View 和 Modifier
让我们从最常见的一个误用开始,它会让我们编写更多的 SwiftUI 代码。这种误用的部分原因通常是我们在解决问题时编写了许多代码,但是最后忘记整理代码。还有的时候,则是旧习惯作祟,尤其是当编写者是从 UIKit 或者其他 UI 框架转到 SwiftUI 上。
比如,你可能希望用一个红色矩形填满屏幕?然后你像下面这样编写代码:
1 | Rectangle() |
的确,上面的代码可以工作 —— 它能准确地得到你想要的效果。但是其中一半的代码是不必要的,因为你只需要像下面这样写也能实现一样的效果:
1 | Color.red |
这是因为在 SwiftUI 中,所有的颜色和形状都自动遵循了 View 协议,你可以把它们直接当成视图来使用。
你可能也会经常看见形状裁切,因为为了实现特定形状,应用 clipShape ()
是件很自然的事情。例如,可以像下面这样让我们的红色矩形拥有圆角:
1 | Color.red |
但这也是不要的 —— 借助 cornerRadius ()
modifier,代码可以简化如下:
1 | Color.red |
移除这类的冗余代码需要时间,因为你需要转变思维习惯,这一点对于 SwiftUI 的初学者来说更加困难。 因此,假如你一开始采用了这些更长版本的代码,不必担忧,多加训练。
2 在需要用 @StateObject
的地方用了 @ObservedObject
SwiftUI 提供了众多属性包装器,帮助我们构建数据响应式的用户界面,其中最重要的当属 @State
, @StateObject
和 @ObservedObject
。掌握它们的使用场景非常重要,因为误用它们会给你的代码带来各种问题。
第一个比较直接:@State
用于值类型属性,并且属性由当前视图拥有。因此,整数,字符串,数组等,都是应用 @State
的绝佳场景。
后两者则有点令人困惑,你可能会经常看到下面这样的代码:
1 | class DataModel: ObservableObject { |
可以明确的说,这么做是错误的,并且极有可能在你的应用中带来问题。
译者注:基于代码片段说这样写一定是错误的,这个表述是不严谨的。作者应该是隐含假设了
ContentView
是应用的顶级视图(通常来说,如果你不改工程模板的默认输出,ContentView
也确实是顶级视图)。对于顶级视图来说,SwiftUI 2.0 应当使用@StateObject
,它是为了解决@ObservedObject
或者@EnvironmentObject
对象的所有权问题。但是对于附属于顶级视图的视图层级,各子视图的数据源可以是@ObservedObject
或者@EnvironmentObject
,因为它们的生命周期受顶级视图管理,进而可以由顶级视图统一保证数据的可用性。
正如我前面说到的,@State
表示某个值类型属性由当前视图拥有,这里的 “拥有” 很重要。而 @StateObject
则相当于引用类型版本的 @State
。
因此,上面的代码应该改成这样:
1 | @StateObject model = DataModel() |
当你使用 @ObservedObject
来创建某个对象实例时,你的视图并不拥有这个对象实例,也就是说,这个实例可以在任何时候被销毁(译者注:视图无法了解也无法干预这个时机)。狡猾的是,对象在视图还需要用它时被销毁的情况只是偶尔发生,所以你可能认为你的代码很完美。
需要记住的重点是 @State
和 @StateObject
表示 “视图拥有数据”,而 @ObservedObject
和 @EnvironmentObject
则没有。
3 Modifier 顺序错误
Modifier 的顺序在 SwiftUI 中至关重要。顺序错误不仅会导致布局在上视觉上的偏差,也会导致其行为的错误。
解释这个问题最经典的例子是 padding
和 background
的使用,如下:
1 | Text("Hello, World!") |
由于我们在 background
颜色之后应用 padding
,颜色只会被直接应用在文本周围,而不是被添加留白之后的文本周围。如果你希望留白和文本背景都是绿色,应该将代码改成下面这样:
1 | Text("Hello, World!") |
当你尝试调整视图位置时,这个原理会让事情变得更有趣。
例如,offset ()
modifier 会修改一个视图被渲染的位置,但并不实际改变视图的位置。也就是说,应用在 offset
之后的 modifier 表现得就像 offset
从未发生过。
尝试下面的代码:
1 | Text("Hello, World!") |
你会发现文本偏移了,但背景颜色没有偏移。现在,尝试交换 offset ()
和 background ()
的位置:
1 | Text("Hello, World!") |
现在你会看到文本和背景都移动了。
另外,position ()
modifier 会改变一个视图在其父节点中的渲染位置,但这一点是借助它先在视图周围应用一个可伸展尺寸的 frame 来实现的。
尝试下面的代码:
1 | Text("Hello, World!") |
你会发现背景颜色紧贴在文本四周,并且整个视图被放置在左上角。现在,尝试对调 background ()
和 position ()
:
1 | Text("Hello, World!") |
这一回你会发现整个屏幕都变成绿色了。还是因为 position ()
要求 SwiftUI 放置一个可伸缩尺寸的 frame 在文本视图周围,这导致视图自动占满了所有的可用空间。然后我们给视图上了绿色,所以整个屏幕呈现绿色。
你所应有的绝大多数 modifier 都创建了新视图 —— 应用一个 position
或者 background
时,你实际上是在将现有的视图包装起来。这个机制对于我们大有用处,我们可以多次应用 modifier,比如添加多层留白和背景:
1 | Text("Hello, World!") |
或者应用多个 shadows 以创建很深的阴影效果:
1 | Text("Hello, World!") |
4 给属性包装器添加属性观察者
某些情况下你可能会为属性包装器添加诸如 didSet
这样的属性观察者,但它不会如你预期的那样工作。
例如,如果你在使用滑块,希望在滑块值改变时执行某种动作,你可能会下面这样编写代码:
1 | struct ContentView: View { |
但是,这个 didSet
属性观察者永远都不会被调用,因为属性的值是由绑定直接修改的,而不是每次创建一个新值。
对此,SwiftUI 原生的方式是使用 onChange ()
modifier,如下:
1 | struct ContentView: View { |
不过,我个人更喜欢一种不同的方案:我使用基于 Binding 的扩展来返回新的绑定,其中的 get
和 set
包装的值和之前一样,但是在新值得到时也会调用处理函数:
1 | extension Binding { |
有了这个扩展,我们就可以把绑定的动作直接附着在滑块视图上:
1 | struct ContentView: View { |
挑选最适合你的方案。
5 在需要用描边框的地方使用了描形状
不理解 stroke ()
和 strokeBorder
的区别是初学者常犯的错误。尝试下面的代码:
1 | Circle() |
注意看,你会发现圆的左边缘和右边缘怎么不见了?(译者:这里预设你是竖屏运行程序,高度大于宽高)这是因为 stroke ()
modifier 会把描边居中对齐在形状的轮廓线上,所以一个 20 个点的红色描边会绘制 10 个点到形状的边缘线外部,10 个点在边缘线内部 —— 这就导致了你看到圆形的左右超出屏幕的现象。
作为对照,strokeBorder ()
则是把整个描边都绘制在形状内部,所以它不会放大形状的边框。
1 | Circle() |
相比于使用 strokeBorder ()
,使用 stroke ()
有一个好处是它返回的是一个新形状,而不是一个新视图。这使得你可以创建出某些本来难以实现的效果,比如给一个形状描两次边:
1 | Circle() |
6 Alert 和 Sheet 与可选状态的使用
当你在学习使用 sheet 和可选型的时候,很容易想到把 sheet 的展示绑定到像下面这样的 Boolean:
1 | struct User: Identifiable { |
当然,这可以正确工作 —— 而且这个方案容易理解。但是一旦你越过了初级阶段,你就应当考虑换成可选型的实现方案。这个方案去掉了 Boolean,也不必强制解包。唯一的要求是你所监听的目标需要遵循 Identifiable
。
举个例子,我们可以在 selectedUser
发生变化的任何时候展示警告弹窗,就像下面这样:
1 | struct ContentView: View { |
这会使得你的代码更加易于读写,并且避免因为强制解包可能带来的麻烦。
7 尝试改变 SwiftUI 视图后面的东西
SwiftUI 初学者最常犯的一个错误是他们常常试图去改变 SwiftUI 视图的背景。代码通常长下面这个样子:
1 | struct ContentView: View { |
这会展示一个白色的屏幕,中间是红色背景色只匹配文本区域的文本视图。而大多数人的本意其实是想让整个屏幕的背景呈现出红色。这个时候他们会想,SwiftUI 背后究竟是什么样的 UIKit 视图呢?
当然,背后肯定是有一个 UIKit 视图,它由 UIHostingController
管理,角色类似于一个 UIKit 视图控制器。但是假如你通过 SwiftUI 试图去踏足 UIKit 的领地,你的改动很可能会让 SwiftUI 呈现出奇怪的结果,或者你甚至都没法直接改动 UIKit。
实际上,想到达成大多数人想要的效果,SwiftUI 里的实现方式应该是像下面这样的:
1 | Text("Hello, World!") |
8 动态视图的范围参数错误
有多个 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 |
代码改成这样就没问题了。
封面来自 Zdeněk Macháček on Unsplash