译自 8 Common SwiftUI Mistakes - and how to fix them

SwiftUI 是一个庞大而复杂的框架,虽然使用起来很有趣,但也有很大的出错空间。在本文中,我将介绍 SwiftUI 学习者常犯的 8 个错误,以及如何解决这些错误。

其中一些错误只是简单的误解,而 SwiftUI 如此庞大,这些错误很容易犯。其他人是为了更深入地了解 SwiftUI 的工作原理,还有一些人更多是遗留思维的标志 —— 有时你会花费大量时间编写 view 和 modifier,而没有花时间简化最终结果。

无论如何,我不会让你对这八个错误是什么感到悬念,所以在我们深入研究它们之前,这里有一个简短的总结:
1、添加不必要的 View 和 Modifier
2、在需要用 @StateObject 的地方用了 @ObservedObject
3、Modifier 顺序错误
4、给属性包装器添加属性观察者
5、在需要用描边框的地方使用了描形状
6、Alert 和 Sheet 与可选状态的使用
7、尝试改变 SwiftUI 视图后面的东西
8、用错误的范围动态创建视图

1 添加不必要的 View 和 Modifier

让我们从最常见的一种开始,即编写比您实际需要的更多的 SwiftUI 代码。这很常见,部分原因是我们经常在解决问题时编写大量代码,但之后很容易忘记清理代码。有时还需要回到旧习惯,特别是如果您来自 UIKit 或其他用户界面框架。

作为一个开始的例子,你如何用红色矩形填充屏幕?你可以这样写:

Rectangle()
    .fill(Color.red)

的确,上面的代码可以工作 —— 它能准确地得到你想要的效果。但是其中一半的代码是不必要的,因为你只需要像下面这样写也能实现一样的效果:

Color.red

这是因为在 SwiftUI 中,所有的颜色和形状都自动遵循了 View 协议,你可以把它们直接当成视图来使用。
你可能也会经常看见形状裁切,因为为了实现特定形状,应用 clipShape() 是件很自然的事情。例如,可以像下面这样让我们的红色矩形拥有圆角:

Color.red
    .clipShape(RoundedRectangle(cornerRadius: 50))

但这也是不要的 —— 借助 cornerRadius() modifier,代码可以简化如下:

Color.red
    .cornerRadius(50)

移除这类的冗余代码需要时间,因为你需要转变思维习惯,这一点对于 SwiftUI 的初学者来说更加困难。 因此,假如你一开始采用了这些更长版本的代码,不必担忧,多加训练。

2 在需要用 @StateObject 的地方用了 @ObservedObject

SwiftUI 提供了众多属性包装器,帮助我们构建数据响应式的用户界面,其中最重要的当属 @State, @StateObject 和 @ObservedObject。掌握它们的使用场景非常重要,因为误用它们会给你的代码带来各种问题。
第一个比较直接:@State 用于值类型属性,并且属性由当前视图拥有。因此,整数,字符串,数组等,都是应用 @State 的绝佳场景。
后两者则有点令人困惑,你可能会经常看到下面这样的代码:

class DataModel: ObservableObject {
    @Published var username = "@twostraws"
}

struct ContentView: View {
    @ObservedObject var model = DataModel()

    var body: some View {
        Text(model.username)
    }
}

可以明确的说,这么做是错误的,并且极有可能在你的应用中带来问题。
译者注:基于代码片段说这样写一定是错误的,这个表述是不严谨的。作者应该是隐含假设了 ContentView 是应用的顶级视图(通常来说,如果你不改工程模板的默认输出,ContentView 也确实是顶级视图)。对于顶级视图来说,SwiftUI 2.0 应当使用 @StateObject ,它是为了解决 @ObservedObject 或者 @EnvironmentObject 对象的所有权问题。但是对于附属于顶级视图的视图层级,各子视图的数据源可以是 @ObservedObject 或者 @EnvironmentObject,因为它们的生命周期受顶级视图管理,进而可以由顶级视图统一保证数据的可用性。

正如我前面说到的,@State 表示某个值类型属性由当前视图拥有,这里的 “拥有” 很重要。而 @StateObject 则相当于引用类型版本的 @State。

因此,上面的代码应该改成这样:

@StateObject model = DataModel()

当你使用 @ObservedObject 来创建某个对象实例时,你的视图并不拥有这个对象实例,也就是说,这个实例可以在任何时候被销毁(译者注:视图无法了解也无法干预这个时机)。狡猾的是,对象在视图还需要用它时被销毁的情况只是偶尔发生,所以你可能认为你的代码很完美。
需要记住的重点是 @State 和 @StateObject 表示 “视图拥有数据”,而 @ObservedObject 和 @EnvironmentObject 则没有。

3 Modifier 顺序错误

Modifier 的顺序在 SwiftUI 中至关重要。顺序错误不仅会导致布局在上视觉上的偏差,也会导致其行为的错误。

解释这个问题最经典的例子是 padding 和 background 的使用,如下:

Text("Hello, World!")
    .font(.largeTitle)
    .background(Color.green)
    .padding()

由于我们在 background 颜色之后应用 padding,颜色只会被直接应用在文本周围,而不是被添加留白之后的文本周围。如果你希望留白和文本背景都是绿色,应该将代码改成下面这样:

Text("Hello, World!")
    .font(.largeTitle)
    .padding()    
    .background(Color.green)

当你尝试调整视图位置时,这个原理会让事情变得更有趣。
例如,offset() modifier 会修改一个视图被渲染的位置,但并不实际改变视图的位置。也就是说,应用在 offset 之后的 modifier 表现得就像 offset 从未发生过。
尝试下面的代码:

Text("Hello, World!")
    .font(.largeTitle)
    .offset(x: 15, y: 15)
    .background(Color.green)

你会发现文本偏移了,但背景颜色没有偏移。现在,尝试交换 offset() 和 background() 的位置:

Text("Hello, World!")
    .font(.largeTitle)
    .offset(x: 15, y: 15)
    .background(Color.green)

现在你会看到文本和背景都移动了。

另外,position() modifier 会改变一个视图在其父节点中的渲染位置,但这一点是借助它先在视图周围应用一个可伸展尺寸的 frame 来实现的。

尝试下面的代码:

Text("Hello, World!")
    .font(.largeTitle)
    .background(Color.green)
    .position(x: 150, y: 150)

你会发现背景颜色紧贴在文本四周,并且整个视图被放置在左上角。现在,尝试对调 background() 和 position():

Text("Hello, World!")
    .font(.largeTitle)
    .position(x: 150, y: 150)
    .background(Color.green)

这一回你会发现整个屏幕都变成绿色了。还是因为 position() 要求 SwiftUI 放置一个可伸缩尺寸的 frame 在文本视图周围,这导致视图自动占满了所有的可用空间。然后我们给视图上了绿色,所以整个屏幕呈现绿色。
你所应有的绝大多数 modifier 都创建了新视图 —— 应用一个 position 或者 background 时,你实际上是在将现有的视图包装起来。这个机制对于我们大有用处,我们可以多次应用 modifier,比如添加多层留白和背景:

Text("Hello, World!")
    .font(.largeTitle)
    .padding()
    .background(Color.green)
    .padding()
    .background(Color.blue)

或者应用多个 shadows 以创建很深的阴影效果:

Text("Hello, World!")
    .font(.largeTitle)
    .foregroundColor(.white)
    .shadow(color: .black, radius: 10)
    .shadow(color: .black, radius: 10)
    .shadow(color: .black, radius: 10)

4 给属性包装器添加属性观察者

某些情况下你可能会为属性包装器添加诸如 didSet 这样的属性观察者,但它不会如你预期的那样工作。

例如,如果你在使用滑块,希望在滑块值改变时执行某种动作,你可能会下面这样编写代码:

struct ContentView: View {
    @State private var rating = 0.0 {
        didSet {
            print("Rating changed to \(rating)")
        }
    }

    var body: some View {
        Slider(value: $rating)
    }
}

但是,这个 didSet 属性观察者永远都不会被调用,因为属性的值是由绑定直接修改的,而不是每次创建一个新值。

对此,SwiftUI 原生的方式是使用 onChange() modifier,如下:

struct ContentView: View {
    @State private var rating = 0.0

    var body: some View {
        Slider(value: $rating)
            .onChange(of: rating) { value in
                print("Rating changed to \(value)")
            }
    }
}

不过,我个人更喜欢一种不同的方案:我使用基于 Binding 的扩展来返回新的绑定,其中的 get 和 set 包装的值和之前一样,但是在新值得到时也会调用处理函数:

extension Binding {
    func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
        Binding(
            get: { self.wrappedValue },
            set: { newValue in
                self.wrappedValue = newValue
                handler(newValue)
            }
        )
    }
}

有了这个扩展,我们就可以把绑定的动作直接附着在滑块视图上:

struct ContentView: View {
    @State private var rating = 0.0

    var body: some View {
        Slider(value: $rating.onChange(sliderChanged))
    }
    
    func sliderChanged(_ value: Double) {
        print("Rating changed to \(value)")
    }
}

挑选最适合你的方案。

5 在需要用描边框的地方使用了描形状

不理解 stroke() 和 strokeBorder 的区别是初学者常犯的错误。尝试下面的代码:

Circle()
    .stroke(Color.red, lineWidth: 20)

注意看,你会发现圆的左边缘和右边缘怎么不见了?(译者:这里预设你是竖屏运行程序,高度大于宽高)这是因为 stroke() modifier 会把描边居中对齐在形状的轮廓线上,所以一个 20 个点的红色描边会绘制 10 个点到形状的边缘线外部,10 个点在边缘线内部 —— 这就导致了你看到圆形的左右超出屏幕的现象。
作为对照,strokeBorder() 则是把整个描边都绘制在形状内部,所以它不会放大形状的边框。

Circle()
    .strokeBorder(Color.red, lineWidth: 20)

相比于使用 strokeBorder(),使用 stroke() 有一个好处是它返回的是一个新形状,而不是一个新视图。这使得你可以创建出某些本来难以实现的效果,比如给一个形状描两次边:

Circle()
    .stroke(style: StrokeStyle(lineWidth: 20, dash: [10]))
    .stroke(style: StrokeStyle(lineWidth: 20, dash: [10]))
    .frame(width: 280, height: 280)

6 Alert 和 Sheet 与可选状态的使用

当你在学习使用 sheet 和可选型的时候,很容易想到把 sheet 的展示绑定到像下面这样的

Boolean:
struct User: Identifiable {
    let id: String
}

struct ContentView: View {
    @State private var selectedUser: User?
    @State private var showingAlert = false

    var body: some View {
        VStack {
            Button("Show Alert") {
                selectedUser = User(id: "@twostraws")
                showingAlert = true
            }
        }
        .alert(isPresented: $showingAlert) {
            Alert(title: Text("Hello, \(selectedUser!.id)"))
        }
    }
}

当然,这可以正确工作 —— 而且这个方案容易理解。但是一旦你越过了初级阶段,你就应当考虑换成可选型的实现方案。这个方案去掉了 Boolean,也不必强制解包。唯一的要求是你所监听的目标需要遵循 Identifiable。
举个例子,我们可以在 selectedUser 发生变化的任何时候展示警告弹窗,就像下面这样:

struct ContentView: View {
    @State private var selectedUser: User?

    var body: some View {
        VStack {
            Button("Show Alert") {
                selectedUser = User(id: "@twostraws")
            }
        }
        .alert(item: $selectedUser) { user in
            Alert(title: Text("Hello, \(user.id)"))
        }
    }
}

这会使得你的代码更加易于读写,并且避免因为强制解包可能带来的麻烦。

7 尝试改变 SwiftUI 视图后面的东西

SwiftUI 初学者最常犯的一个错误是他们常常试图去改变 SwiftUI 视图的背景。代码通常长下面这个样子:

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .background(Color.red)
    }
}

这会展示一个白色的屏幕,中间是红色背景色只匹配文本区域的文本视图。而大多数人的本意其实是想让整个屏幕的背景呈现出红色。这个时候他们会想,SwiftUI 背后究竟是什么样的 UIKit 视图呢?
当然,背后肯定是有一个 UIKit 视图,它由 UIHostingController 管理,角色类似于一个 UIKit 视图控制器。但是假如你通过 SwiftUI 试图去踏足 UIKit 的领地,你的改动很可能会让 SwiftUI 呈现出奇怪的结果,或者你甚至都没法直接改动 UIKit。
实际上,想到达成大多数人想要的效果,SwiftUI 里的实现方式应该是像下面这样的:

Text("Hello, World!")
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(Color.red)
    .ignoresSafeArea()

8 动态视图的范围参数错误

有多个 SwiftUI 视图的构造器允许我们传入范围,这个事实让许多复杂视图的创建过程变得十分简单。

例如,假设我们想要展示一个拥有 4 个项目的列表,我们只需要这样写:

struct ContentView: View {
    @State private var rowCount = 4

    var body: some View {
        VStack {
            List(0..<rowCount) { row in
                Text("Row \(row)")
            }
        }
    }
}

这样写本身没问题,不过一旦你需要在运行时改变范围时,问题就来了。你看我已经用 @State 属性包装器把想要改变的行数变成可修改的,所以我们可以用一个按钮来修改它的值:

Button("Add Row") {
    rowCount += 1
}
.padding(.top)

运行代码,点击按钮,Xcode 调试输出会输出警告,而列表视图纹丝不动 —— 这个方案不管用。
问题出在你既没有为列表的参数提供 Identifiable 协议实现,也没有提供指定的 id 参数,以此告诉 SwiftUI 这个范围会动态变化:(译者:实际上并不是 “告诉 SwiftUI 范围会动态变化”,而是明确范围的项怎样才算变化。Identifiable 或者 id 参数明确了两个项之间是如何区别。能够区别开的项目才能侦测变化)。

List(0..<rowCount, id: \.self) { row in
    Text("Row \(row)")
}