本文是斯坦福大学 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


第三节课最后15分钟

整理代码

我们在应用 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

新建一个 Model 文件

File -> New -> File -> Swift File, 并将文件命名为 MemorizeGame

create-memorizeGame-swift-file

实现(写)一个 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
    }
}

新建一个 ViewModel 文件

File -> New -> File -> Swift File, then named as EmojiMemoryGame

实现(写)一个 MemorizeGame (ViewModel):

import SwiftUI

class EmojiMemoryGame {
    var model: MemoryGame<String>
}

第四节课

访问控制

部分分离

我们来看看 MVVM 文件:

View:

import SwiftUI

struct ContentView: View {
    var viewModel: EmojiMemoryGame
    
    let emojis = ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"]
...

Model:

import Foundation

struct MemoryGame<CardContent> {
    var cards: Array<Card>
    
    func choose(card: Card) {
        
    }
...

ViewModel:

import SwiftUI

class EmojiMemoryGame {
    var model: MemoryGame<String>
}

由于我们现在仍然可以通过viewModel.model.xxx直接从 View 访问 Model,因此这是部分分离模式。

完全分离

如果我们想避免View 直接访问 Model,我们需要使用关键字 private 来实现。这被称为完全分离。

修改 ViewModel:

import SwiftUI

class EmojiMemoryGame {
    private var model: MemoryGame<String>
}

那我们现在如何访问 model 呢?我们需要修改 ViewModel 让它可以被访问。

这是修改过后的 ViewModel:

import SwiftUI

class EmojiMemoryGame {
    private var model: MemoryGame<String>
    
    var cards: Array<MemoryGame<String>.Card> {
        return model.cards
    }
    
    func choose(card: MemoryGame<String>.Card) {
        model.choose(card: card)
    }
}

private(set)

private(set) 关键字允许其他函数只能读,但不可以修改。

struct MemoryGame<CardContent> {
    private(set) var cards: Array<Card>
...

忽略函数标签 (No External Name)

// MemoryGame.swift
    func choose(_ card: Card) {
        
    }

// EmojiMemoryGame.swift
    func choose(_ card: MemoryGame<String>.Card) {
        model.choose(card)
    }

choose函数在被外部调用后不需要有外部称呼。然而,我们在一些情况下不需要省略外部称呼:

  1. 数据类型是字符串,整数,或不确定的。
  2. 添加外部称呼可以增加代码的可读性。

类的初始化器

类初始值设定项没有参数,并且仅当所有变量都有默认值时才起作用。 我们接下来开始写 ViewModel (EmojiMemoryGame.swift) 初始化函数。

我们想要通过用numberOfPairsOfCardscardContentFactory来初始化 Model (MemoryGame.swift)。因此我们也需要自定义 Model 的初始化函数。(默认的初始化函数不能实现)

注意: 这一章节非常长,因此最终的代码在文件较后部分。

For 循环

我们可以使用 _ 来忽略循环的索引。

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)
}

闭包语法

// 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))
        }
    }
...

接下来,我们需要初始化 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 是一个接受整数并返回字符串的函数,因此我们可以将它传入。

我们可以使用闭包语法让它更变得更简洁一些:

// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
    private var model = MemoryGame(
        numberOfPairsOfCards: 4,
        cardContentFactory: { (index: Int) -> String in
            return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][index]
        }
    )
...

我们甚至可以使用类型推断省略更多部分:

// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
    private var model = MemoryGame(
        numberOfPairsOfCards: 4,
        cardContentFactory: { index in
            return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][index]
        }
    )
...

同时,由于 cardContentFactory 是这个函数的最后一个参数,我们可以使用尾闭包:

// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
    private var model = MemoryGame(numberOfPairsOfCards: 4) { index in
        return ["👻", "🎃", "🕷️", "🎉","😄", "😎", "💩"][index]
    }
...

$0

$0 是一个用于表示第一个参数的特殊占位符。

// 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]
    }
...

静态变量和函数

以下代码 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". emojismodel 被称为 property initializer, 但是 property initialized 的运行顺序是不确定的 (不是源代码的顺序)。

我们可以使用关键字 static 来解决这个问题。这个关键字可以让 emojis 变为全局变量 (事实上被称为 type variable) 但仅限于这个类的内部访问。 全局变量会被优先初始化。

//  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()
...

".thing"

当我们看到 ".一个东西"时, 它只有可能时静态变量或者 enmu。

...
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
    ...
    ...
}
.foregroundColor(.orange)
...

我们在代码中发现了一个 .orange ,它实际与 Color.orange 的含义完全相同。我们再打开 Swift 的开发者文档:

static-var-color-orange

.orange 时一个静态变量。

代码保护

//  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))
        }
    }
...

在 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 文件)

init(_

我们还可以优化一下 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
    }
...

将 Emoji 变大一些

make-the-emoji-bigger

MARK: -

当我们使用 MAKR: - XXX 时, - 可以在Swift中看起来有一条线一样,如下图。

MARK-

洗牌

修改 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 的作用是,如果有些东西被改变了,重绘 UI。

重要提示: 有一个 @ObservedObject 然后 = 一些东西是非常不好的习惯.

@ObservedObject var viewModel: EmojiMemoryGame = EmojiMemoryGame()

正确 的方法:

// EmojiMemoryGameView.swift
import SwiftUI

struct EmojiMemoryGameView: View {
    @ObservedObject var viewModel: EmojiMemoryGame  
        ...
        ...
}

#Preview {
    EmojiMemoryGameView(viewModel: EmojiMemoryGame())
}

我们还需要修改 App:

//  MemorizeApp.swift
import SwiftUI

@main
struct MemorizeApp: App {
    @StateObject var game = EmojiMemoryGame()
    var body: some Scene {
        WindowGroup {
            EmojiMemoryGameView(viewModel: game)
        }
    }
}

@StateObject 意思是你不能共享这个对象。