本文主要内容转载于 闻者通达的个人博客。
本文是斯坦福大学 cs193p 公开课程第04集的相关笔记。
cs193p 课程介绍:
The lectures for the Spring 2023 version of Stanford University’s course CS193p (Developing Applications for iOS using SwiftUI) were given in person but, unfortunately, were not video recorded. However, we did capture the laptop screen of the presentations and demos as well as the associated audio. You can watch these screen captures using the links below. You’ll also find links to supporting material that was distributed to students during the quarter (homework, demo code, etc.).
cs193p 课程网址: https://cs193p.sites.stanford.edu/2023
1. 第三节课最后15分钟
1.1 整理代码
我们在应用 MVVM 设计思想前需要清理一下代码。
移除以下部分:
@State var cardCount: Int = 4
var cardCountAdjsters: some View {
HStack {
cardRemover
Spacer()
cardAdder
}
.imageScale(.large)
}
func cardCountAdjsters(by offset: Int, symbol: String) -> some View {
Button(action: {
cardCount += offset
}, label: {
Image(systemName: symbol)
})
.disabled(cardCount + offset < 1 || cardCount + offset > emojis.count)
}
var cardRemover: some View {
return cardCountAdjsters(by: -1, symbol: "rectangle.stack.badge.minus.fill")
}
var cardAdder: some View {
return cardCountAdjsters(by: 1, symbol: "rectangle.stack.badge.plus.fill")
}
改成以下部分:
...
VStack {
ScrollView {
cards
}
Spacer()
cardCountAdjsters
}
...
// change to
...
ScrollView {
cards
}
...
ForEach(0..<cardCount, id: \.self) { index in
// change to
ForEach(emojis.indices, id: \.self) { index in
1.2 新建一个 Model 文件
File
-> New
-> File
-> Swift File
, 并将文件命名为 MemorizeGame
实现(写)一个 MemorizeGame (Model):
import Foundation
struct MemoryGame<CardContent> {
var cards: Array<Card>
func choose(card: Card) {
}
struct Card {
var isFaceUp: Bool
var isMatched: Bool
var content: CardContent
}
}
1.3 新建一个 ViewModel 文件
File
-> New
-> File
-> Swift File
, 将其命名为EmojiMemoryGame.swift
实现(写)一个 MemorizeGame (ViewModel):
import SwiftUI
class EmojiMemoryGame {
var model: MemoryGame<String>
}
2. 第四节课
2.1 访问控制:实现由部分分离转为全部分离
2.1.1 部分分离
我们来看看 MVVM 文件:
View:
// ContentView.swift
import SwiftUI
struct ContentView: View {
var viewModel: EmojiMemoryGame
let emojis = ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"]
...
Model:
// MemoryGame.swift
import Foundation
struct MemoryGame<CardContent> {
var cards: Array<Card>
func choose(card: Card) {
}
...
ViewModel:
// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
var model: MemoryGame<String>
}
由于我们现在仍然可以在 View
中通过 viewModel.model.xxx
直接从 View 访问 Model,因此这是部分分离模式。
2.1.2 完全分离
如果我们想避免View 直接访问 Model,我们需要使用关键字 private
来实现。这被称为完全分离。
修改 ViewModel:
// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
private var model: MemoryGame<String>
}
那我们现在如何访问 model 呢?我们需要修改 ViewModel 让它可以被访问。
这是修改过后的 ViewModel:
//EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
// private 使得 View 无法直接访问 model;这里意味着在 ViewModel 中使用 Model
private var model: MemoryGame<String>
// 在 ViewModel 中访问 model 中的数据 cards
var cards: Array<MemoryGame<String>.Card> {
return model.cards
}
// 在 ViewModel 中实现对 model 中的 card 的 choose 操作
func choose(card: MemoryGame<String>.Card) {
model.choose(card: card)
}
}
private(set)
对应的在 Model
中应该怎么修改呢?
private(set)
关键字允许其他函数只能读,但不可以修改。
// MemoryGame.swift
struct MemoryGame<CardContent> {
private(set) var cards: Array<Card>
...
2.2 ViewModel 方法补充和 Model 对应修改
2.2.1 Model
的 choose 函数声明和 ViewModel
的 choose 函数声明调用
忽略函数标签 (No External Name)
// MemoryGame.swift
func choose(_ card: Card) {
}
// EmojiMemoryGame.swift
func choose(_ card: MemoryGame<String>.Card) {
model.choose(card)
}
choose
函数在被外部调用后不需要有外部称呼。然而,我们在一些情况下不需要省略外部称呼:
- 数据类型是字符串,整数,或不确定的。
- 添加外部称呼可以增加代码的可读性。
2.2.2 Model
结构体的初始化器和 ViewModel
类初始化
类初始值设定项没有参数,并且仅当所有变量都有默认值时才起作用。 我们接下来开始写 ViewModel (EmojiMemoryGame.swift) 初始化函数。
另外,我们想要通过用 numberOfPairsOfCards
和 cardContentFactory
来初始化 Model (MemoryGame.swift)。因此我们先需要自定义 Model 的初始化函数。(这两个参数分别代表卡片数,以及通过卡片索引获取卡片内容的函数)
Model
初始化器
1. For 循环
我们想要对每张原卡片,卡片组中都会有两张存在。我们可以使用 _
来忽略循环的索引。
// MemoryGame.swift
for pairIndex in 0..<numberOfPairsOfCards {
cards.append(XXXX)
cards.append(XXXX)
}
// Use _ to ignore the pairIndex
for _ in 0..<numberOfPairsOfCards {
cards.append(XXXX)
cards.append(XXXX)
}
XXXX
代表的是 cardContentFactory
通过 pairIndex
索引获取到的卡片内容。下边会讲如何使用闭包来实现该函数。
2. 闭包语法
// MemoryGame.swift
import Foundation
struct MemoryGame<CardContent> {
private(set) var cards: Array<Card>
init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
cards = []
// add numberOfParisOfCards x 2 cards
for pairIndex in 0..<numberOfPairsOfCards {
let content = cardContentFactory(pairIndex)
cards.append(Card(content: content))
cards.append(Card(content: content))
}
}
...
在 ViewModel
中初始化 Model
变量
接下来,我们需要初始化 model
变量:
// EmojiMemoryGame.swift
import SwiftUI
func createCardContent(forPairAtIndex index: Int) -> String {
return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][index]
}
class EmojiMemoryGame {
private var model = MemoryGame(
numberOfPairsOfCards: 4,
cardContentFactory: createCardContent
)
MemoryGame
初始化器的第二个参数需要传入一个接受整数并返回字符串的函数。createCardContent
是一个接受整数并返回字符串的函数,因此我们可以将它传入。
我们可以使用闭包语法让它更变得更简洁一些:将 createCardContent
函数内容移入类初始化参数 cardContentFactory
后,并将函数大括号 {}
替换为 in
。
// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
private var model = MemoryGame(
numberOfPairsOfCards: 4,
cardContentFactory: { (index: Int) -> String in
return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][index]
}
)
...
由于类型推断,输入参数为 Int
类型,我们可以省略类型声明,如下:
可按住 option,并 click 查看
Index
推断的类型为Int
。类型推断在第二节有说明:Stanford-cs193p-02|More-SwiftUI
// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
private var model = MemoryGame(
numberOfPairsOfCards: 4,
cardContentFactory: { index in
return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][index]
}
)
...
同时,由于 cardContentFactory
是这个函数的最后一个参数,我们可以使用尾随闭包:
尾随闭包在第二节笔记中有说明:Stanford-cs193p-02|More-SwiftUI
// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
private var model = MemoryGame(numberOfPairsOfCards: 4) { index in
return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][index]
}
...
1. $0
$0 是一个用于表示第一个参数的特殊占位符,这里可使用 $0
替代 index
。
// EmojiMemoryGame.swift
...
private var model = MemoryGame(numberOfPairsOfCards: 4) { index in
return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][index]
}
...
// If we use $0
...
private var model = MemoryGame(numberOfPairsOfCards: 4) { $0
return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][$0]
}
...
2. 静态变量和函数
倘若,我们还想简化这个类的初始化,想将这个表情数组提取为一个变量 emojis
使用, 以下代码 XCode 会报错:
// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
let emojis = ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"]
private var model = MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
return emojis[pairIndex]
}
...
错误信息是 “Cannot use instance member ‘emojis’ within property initializer; property initializers run before ‘self’ is available”. emojis
和 model
被称为 property initializer。由于property initialized 的运行顺序是不确定的 (不是源代码的顺序),我们可能会先初始化 model,但此时 emojis 还未被初始化,因此不能使用一个初始化变量去初始化另一个。
那么怎么解决呢?
第一种方法:将 emojis 设置为一个全局变量
// EmojiMemoryGame.swift
let emojis = ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"]
class EmojiMemoryGame {
private var model = MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
// EmojiMemoryGame.emojis[pairIndex]
return emojis[pairIndex]
}
...
但这会污染命名空间,可能会与其他变量或常量发生命名冲突,难以调试。而且全局变量可以在任意位置被修改,View
和 Model
中都可以修改,导致代码很难理解,因为变量的状态可能在不知情的情况下被修改。
所以为了使它具有可封装性,还是得将它放回类 class
中。
第二种方法:使用关键字 static
来解决
我们可以使用关键字 static
来解决这个问题。这个关键字可以让 emojis
变为全局变量 (事实上被称为 type variable) 但仅限于这个类的内部访问。而且全局变量会被优先初始化,不用担心初始化变量的顺序问题。
// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
static let emojis = ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"]
private var model = MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
return EmojiMemoryGame.emojis[pairIndex]
}
...
private
则可以将这个变量限制在 class
内部使用,外部无法直接访问或修改。此时不再需要 EmojiMemoryGame
来调用 emojis
,如下:
// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
private static let emojis = ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"]
private var model = MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
// EmojiMemoryGame.emojis[pairIndex]
return emojis[pairIndex]
}
...
注意:现在
emojis
的全名其实是EmojiMemoryGame.emojis
.
我们现在将上面的变量变成一个函数,然后存在 model里:
// EmojiMemoryGame.swift
...
private var model = createMemoryGame()
func createMemoryGame() {
return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
return emojis[pairIndex]
}
}
...
会有一堆错误信息。
我们需要将函数标记为 static
并增加返回类型。Swift 是不可以推断返回类型的。
// EmojiMemoryGame.swift
...
private static func createMemoryGame() -> MemoryGame<String> {
return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
return emojis[pairIndex]
}
}
private var model = createMemoryGame()
...
3. “.thing”
此处
.thing
是补充说明privat static
,并不涉及ViewModel
和Model
的实现
当我们看到 ".一个东西"时, 它只有可能是静态变量或者枚举类型 enum
。如下:
//ContentView.swift
...
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
...
...
}
.foregroundColor(.orange)
...
我们在代码中发现了一个 .orange
,它实际与 Color.orange
的含义完全相同。我们再打开 Swift 的开发者文档:
.orange
、.pink
和 .purple
都是静态 static
变量。
2.2.3 问题解决:数组不能超出索引
// EmojiMemoryGame.swift
...
private static func createMemoryGame() -> MemoryGame<String> {
return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
return emojis[pairIndex]
}
}
...
我们访问数组时不能超出索引,因此需要新增一些逻辑。
// EmojiMemoryGame.swift
...
private static func createMemoryGame() -> MemoryGame<String> {
return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
if emojis.indices.contains(pairIndex) {
return emojis[pairIndex]
} else {
return "⁉️"
}
}
}
...
我们同时希望有至少 4 张卡:
// MemorizeGame.swift
...
init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
cards = []
// add numberOfParisOfCards x 2 cards
for pairIndex in 0..<max(2, numberOfPairsOfCards) {
let content = cardContentFactory(pairIndex)
cards.append(Card(content: content))
cards.append(Card(content: content))
}
}
...
2.3 在 View 中使用 ViewModel
注意: 我们将 ContentView.swift 文件重命名为了 EmojiMemoryGameView.swift
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
ForEach(emojis.indices, id: \.self) { index in
CardView(content: emojis[index])
.aspectRatio(2/3, contentMode: .fit)
}
}
.foregroundColor(.orange)
}
}
struct CardView: View {
let content: String
@State var isFaceUp = true
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: 12)
Group {
base.fill(.white)
base.strokeBorder(lineWidth: 2)
Text(content).font(.largeTitle)
}
.opacity(isFaceUp ? 1 : 0)
base.fill().opacity(isFaceUp ? 0 : 1)
}
.onTapGesture {
isFaceUp.toggle()
}
}
}
...
现在需要循环卡片。
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
ForEach(viewModel.cards.indices, id: \.self) { index in
CardView(card: viewModel.cards[index])
.aspectRatio(2/3, contentMode: .fit)
}
}
.foregroundColor(.orange)
}
}
struct CardView: View {
let card: MemoryGame<String>.Card
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: 12)
Group {
base.fill(.white)
base.strokeBorder(lineWidth: 2)
Text(card.content).font(.largeTitle)
}
.opacity(card.isFaceUp ? 1 : 0)
base.fill().opacity(card.isFaceUp ? 0 : 1)
}
}
}
...
如果我们现在想要查看卡片的正面,只需要更改 Model 即可。(MemorizeGame.swift
文件)
2.3.1 CardView Struct 优化
我们还可以优化一下 CardView。调用时需要写 card:
很麻烦:
CardView(card: viewModel.cards[index])
我们可以创建自己的 初始化函数 (init) 忽略函数标签。
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
ForEach(viewModel.cards.indices, id: \.self) { index in
CardView(viewModel.cards[index])
.aspectRatio(2/3, contentMode: .fit)
}
}
.foregroundColor(.orange)
}
}
struct CardView: View {
let card: MemoryGame<String>.Card
init(_ card: MemoryGame<String>.Card) {
self.card = card
}
...
2.3.2 放大 Emoji
2.4 View
和 ViewModel
关联优化
2.4.1 MARK: - Intents
当我们使用 MAKR: - XXX
时, -
可以在Swift中看起来有一条线一样,如下图。
2.4.2 洗牌
修改 View 和 ViewModel
实现(修改) ViewModel:
// EmojiMemoryGame.swift
...
class EmojiMemoryGame {
...
...
// MARK: - Intents
func shuffle() {
model.shuffle()
}
...
...
}
实现(修改)View:
// EmojiMemoryGameView.swift
...
var body: some View {
VStack {
ScrollView {
cards
}
Button("Shuffle") {
viewModel.shuffle()
}
}
.padding()
}
...
mutating
我们需要让 Model 支持洗牌的操作。
// MemorizeGame.swift
...
func shuffle() {
cards.shuffle()
}
...
但是,self (Model) 是不可更变的。
// MemorizeGame.swift
...
mutating func shuffle() {
cards.shuffle()
}
...
任何函数需要修改 Model 必须要被标记 mutating 关键字,因为这会造成写时复制(copy on write)。
Reactive UI
@ObservableObject
// EmojiMemoryGame.swift
...
class EmojiMemoryGame: ObservableObject {
...
@Published private var model = createMemoryGame()
...
// MARK: - Intents
func shuffle() {
model.shuffle()
objectWillChange.send()
}
...
objectWillChange.send()
会通知 UI (View),有些东西将要变了。 @Published
则在一些东西变动后, 说一些东西已经变了,
// EmojiMemoryGameView.swift
...
struct EmojiMemoryGameView: View {
@ObservedObject var viewModel: EmojiMemoryGame = EmojiMemoryGame()
var body: some View {
VStack {
ScrollView {
cards
}
Button("Shuffle") {
viewModel.shuffle()
}
}
.padding()
}
...
我们也需要给我们的 viewModel
变量添加 @ObservedObject
。此处 @ObservedObject
的作用会观察 EmojiMemoryGame
中的 @Published
变量变化,如果发生变化了,则会重新渲染 UI。
重要提示: 有一个 @ObservedObject
然后赋值一些东西是非常不好的习惯。
@ObservedObject var viewModel: EmojiMemoryGame = EmojiMemoryGame()
正确 的方法:
// EmojiMemoryGameView.swift
import SwiftUI
struct EmojiMemoryGameView: View {
@ObservedObject var viewModel: EmojiMemoryGame
...
...
}
#Preview {
//此处初始化 viewModel 变量,来进行预览
EmojiMemoryGameView(viewModel: EmojiMemoryGame())
}
我们还需要修改 App:
// MemorizeApp.swift
import SwiftUI
@main
struct MemorizeApp: App {
// 初始化 viewModel 对象
@StateObject var game = EmojiMemoryGame()
var body: some Scene {
WindowGroup {
EmojiMemoryGameView(viewModel: game)
}
}
}
@StateObject
jj意思是你不能和其他 View
共享这个对象。
续第五节课:@ObservedObject
和 @StateObject
区别
1. 示例(非课件内容)
import SwiftUI
class Counter: ObservableObject {
@Published var value = 0
}
struct ParentView: View {
@StateObject private var counter = Counter() // 由父视图负责创建和管理
var body: some View {
ChildView(counter: counter) // 将 ObservableObject 传递给子视图
}
}
struct ChildView: View {
@ObservedObject var counter: Counter // 观察父视图传递过来的实例
var body: some View {
VStack {
Text("Counter: \(counter.value)")
Button("Increment") {
counter.value += 1
}
}
}
}
ParentView
创建并管理Counter
的生命周期。ChildView
使用@ObservedObject
观察传入的Counter
实例,但不负责管理其生命周期。
使用 @StateObject
和 @ObservedObject
的核心在于对生命周期的理解,确保在合适的上下文中管理和观察数据。
2. 区别
特性 | @ObservedObject | @StateObject |
---|---|---|
负责实例创建 | 否:只观察传入的 ObservableObject | 是:负责创建并管理 ObservableObject 的生命周期 |
生命周期管理 | 不管理,依赖于外部实例的生命周期 | 自动管理,当视图销毁时,ObservableObject 也销毁 |
初始化时机 | 外部传入实例,必须在外部已创建 | 仅在视图首次初始化时创建一次 |
使用场景 | 子视图:观察由父视图传递的 ObservableObject 实例 | 父视图:创建并管理自己的 ObservableObject 实例 |
重复渲染 | 每次视图刷新时,观察相同的实例 | 不会在视图刷新时重复创建 ObservableObject |
@StateObject
适用于当前视图需要自己创建并管理ObservableObject
实例的生命周期,常用于父视图或独立视图。@ObservedObject
适用于观察由外部传递的ObservableObject
,常用于子视图。@ObservedObject
和@StateObject
都是用于观察ObservableObject
的属性包装器,但它们有不同的使用场景和生命周期管理方式。