Stanford-cs193p-04|MVVM-应用

本文主要内容转载于 闻者通达的个人博客

本文是斯坦福大学 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函数在被外部调用后不需要有外部称呼。然而,我们在一些情况下不需要省略外部称呼:

  1. 数据类型是字符串,整数,或不确定的。
  2. 添加外部称呼可以增加代码的可读性。
2.2.2 Model 结构体的初始化器和 ViewModel 类初始化

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

另外,我们想要通过用 numberOfPairsOfCardscardContentFactory 来初始化 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”. emojismodel 被称为 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]
    }
...

但这会污染命名空间,可能会与其他变量或常量发生命名冲突,难以调试。而且全局变量可以在任意位置被修改,ViewModel 中都可以修改,导致代码很难理解,因为变量的状态可能在不知情的情况下被修改。

所以为了使它具有可封装性,还是得将它放回类 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,并不涉及 ViewModelModel 的实现

当我们看到 ".一个东西"时, 它只有可能是静态变量或者枚举类型 enum。如下:

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

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

.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

make emoji bigger

2.4 ViewViewModel 关联优化

2.4.1 MARK: - Intents

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

swift MARK

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 的属性包装器,但它们有不同的使用场景和生命周期管理方式。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值