本文主要内容转载于闻者通达的个人博客
本文是斯坦福大学 cs193p 公开课程第05集的相关笔记。
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. Equatable Protocol
// EmojiMemoryGameView.swift
struct EmojiMemoryGameView: View {
@ObservedObject var viewModel: EmojiMemoryGame
var body: some View {
VStack {
ScrollView {
cards
.animation(.default, value: )
}
Button("Shuffle") {
viewModel.shuffle()
}
}
.padding()
}
...
我们可以通过添加 .animation
视图修饰符来制作动画。value
表示它只会在此值更改时进行动画处理。
但是,这会导致错误 “Referencing instance method ‘animation(:value:)’ on ‘Array’ requires that ‘MemoryGame<String>.Card’ conform to ‘Equatable’”。这表明,如果某些内容发生更改,动画将复制该更改。当某些内容也发生变化时,它需要另一个副本。仅当两个副本不相等时,动画才会进行动画处理。因此,我们需要使 Card
遵循 Equatable
协议(protocol)。
// MemorizeGame.swift
...
struct Card: Equatable {
// 为了实现 Equatable 协议,此处需要编写一个静态函数如下:
static func == (lhs: Card, rhs: Card) -> Bool {
return lhs.isFaceUp == rhs.isFaceUp &&
lhs.isMatched == rhs.isMatched &&
lhs.content == rhs.content
}
var isFaceUp = true
var isMatched = false
let content: CardContent
}
...
为了实现 Equatable
协议,我们需要编写一个静态函数,该函数接受一个函数并返回一个 Bool
。该函数需要两个参数,一个左侧 Card
和一个右侧 Card
。
但是,错误信息 “Referencing operator function '==' on 'Equatable' requires that 'CardContent' conform to 'Equatable'”
表示我们需要使 CardContent
相等。
// MemorizeGame.swift
...
struct MemoryGame<CardContent> where CardContent: Equatable {
...
...
struct Card: Equatable {
static func == (lhs: Card, rhs: Card) -> Bool {
return lhs.isFaceUp == rhs.isFaceUp &&
lhs.isMatched == rhs.isMatched &&
lhs.content == rhs.content
}
var isFaceUp = true
var isMatched = false
let content: CardContent
}
}
为了使我们的 CardContent
相等,我们使用 where CardContent: Equatable
来实现它。这意味着,我们对 Card
还是有所限制的。另外,Swift 中另一个很酷的特性是,如果我们像上面这样对 CardContent
比较每件事,我们就可以直接删除之前在 Card
结构体中添加的静态函数。
struct MemoryGame<CardContent> where CardContent: Equatable {
...
...
struct Card: Equatable {
var isFaceUp = true
var isMatched = false
let content: CardContent
}
}
它确实有效,但卡片的动画效果为淡入淡出,看起来不像洗牌。
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
ForEach(viewModel.cards.indices, id: \.self) { index in
CardView(viewModel.cards[index])
.aspectRatio(2/3, contentMode: .fit)
.padding(4)
}
}
.foregroundColor(.orange)
}
...
因为我们的 ForEach 迭代数组的索引。它从卡 0、1、2、3 …并为每个 API 创建 Card 视图。例如,当我们洗牌时,我们会将牌从 0 号移动到 7 号。但是 ForEach 仍然显示从 0、1、2…
我们想要移动卡片本身,即 Card 视图。
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
ForEach(viewModel.cards, id: \.self) { card in
CardView(card)
.aspectRatio(2/3, contentMode: .fit)
.padding(4)
}
}
.foregroundColor(.orange)
}
...
我们对 ForEach
做了一些改变。上面的代码将产生错误 "Referencing initializer 'init(\_:id:content:)' on 'ForEach' requires that 'MemoryGame\\<String\\>.Card' conform to 'Hashable'"
。
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
ForEach(viewModel.cards) { card in
CardView(card)
.aspectRatio(2/3, contentMode: .fit)
.padding(4)
}
}
.foregroundColor(.orange)
}
...
现在,是时候谈谈 id: \.self
了,用来识别这些 cards
。
在这里,我们想让 id
唯一,即对某张 Card
具有唯一 id
且不会变化,但通过 id:\.self
无法实现,因为 Card
包括 isFaceUp
、isMatch
属性,当我们单击卡片时,这些属性会发生变化,id
就会发生变化。
我们需要一些其他东西来识别 Card
。
2. Identifiable Protocol
// MemorizeGame.swift
import Foundation
struct MemoryGame<CardContent> where CardContent: Equatable {
private(set) var cards: Array<Card>
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, id: "\(pairIndex+1)a"))
cards.append(Card(content: content, id: "\(pairIndex+1)b"))
}
}
...
...
struct Card: Equatable, Identifiable {
var isFaceUp = true
var isMatched = false
let content: CardContent
var id: String
}
}
我们添加了一个键入为 String 的 id,并在创建新卡时分配该 id。id 看起来像 1a、1b、2a、2b、3a、3b 等…
3. CustomDebugStringConvertible
我们当前的印刷信息相当复杂。所以,我们可以让它变得更好。
// MemorizeGame.swift
...
struct Card: Equatable, Identifiable, CustomDebugStringConvertible {
var isFaceUp = true
var isMatched = false
let content: CardContent
var id: String
var debugDescription: String {
"\(id): \(content) \(isFaceUp ? "up" : "down") \(isMatched ? "matched": "")"
}
}
...
我们为 Card 实施 CustomDebugStringConvertible
protocol 协议,并定义debugDescription。
现在,它变得更简洁了。
4. ViewModel Intent - Part 1:翻转卡片
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
ForEach(viewModel.cards) { card in
CardView(card)
.aspectRatio(2/3, contentMode: .fit)
.padding(4)
.onTapGesture {
viewModel.choose(card)
}
}
}
.foregroundColor(.orange)
}
...
// MemorizeGame.swift
...
func choose(_ card: Card) {
card.isFaceUp.toggle()
}
...
现在,我们开始实现用户的意图(翻转卡片)。
我们可以使用 .onTapGesture
和 .toggle()
来在用户触摸屏幕时翻转卡片。但是,由于卡片是值类型,当 choose 函数获取卡片的副本时,我们无法直接使用 .toggle()
来翻转它。(当你在 MemoryGame 的 choose 函数中传递 card 作为参数时,Swift 会创建该卡片的一个副本。这是因为 Card 是一个结构体,而结构体在 Swift 中是值类型。对值类型的任何修改会影响拷贝,而不会影响原始值。)
// MemorizeGame.swift
...
mutating func choose(_ card: Card) {
let chosenIndex = index(of: card)
cards[chosenIndex].isFaceUp.toggle()
}
func index(of card: Card) -> Int {
for index in cards.indices {
if cards[index].id == card.id {
return index
}
}
return 0 // FIXME: bogus!
}
...
我们将直接使用卡片的索引来修改卡片数组。我们实现了一个名为 index 的函数,用于查找卡片的索引。
目前,如果找不到卡片,我们将返回 0,也就是我们的第一张卡片。
在我们知道如何解决这个问题之前,先来了解一下枚举(enum)。
5. Enum
5.1 Enum 简介
enum FastFoodMenuItem {
case hamburger
case fries
case drink
case cookie
}
-
Enum
是除了struct
和class
以外的另一种数据结构。 -
它只能包含离散的状态。
-
枚举是一种值类型(如
struct
),因此它会在传递时被复制。 -
每个状态都可以(但不是必须) 有自己的“关联数据”
enum FastFoodMenuItem { case hamburger(numberOfPatties: Int) case fries(size: FryOrderSize) case drink(String, ounces: Int) // the unnamed String is the brand, e.g. "Coke" case cookie }
请注意,饮料案例有 2 条关联数据(其中一条是“未命名的”)
在上面的示例中,FryOrderSize
也可能是一个枚举,例如 …enum FryOrderSize { case large case small }
5.2 设置枚举的值
设置枚举的值时,如果有关联数据,必须提供关联的数据值。
let menuItem: FastFoodMenuItem = FastFoodMenuItem.hamburger(patties: 2)
var otherItem: FastFoodMenuItem = FastFoodMenuItem.cookie
Swift 可以在赋值左侧或另一侧推断类型,但不能同时。
let menuItem = FastFoodMenuItem.hamburger(patties: 2)
var otherItem: FastFoodMenuItem = .cookie
// Swift can't figure this out
var yetAnotherItem = .cookie
5.3 检查枚举的状态
通常使用 switch
来检查枚举的状态,尽管我们可以使用 if
语句,但如果有关联数据,则这种情况不常见。
var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
case FastFoodMenuItem.hamburger: print("burger")
case FastFoodMenuItem.fries: print("fries")
case FastFoodMenuItem.drink: print("drink")
case FastFoodMenuItem.cookie: print("cookie")
}
上述代码将在控制台上打印 “burger”。
var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
case .hamburger: print("burger")
case .fries: print("fries")
case .drink: print("drink")
case .cookie: print("cookie")
}
没有必要使用完全表示的 FastFoodMenuItem.fries
(因为 Swift 可以推断出 FastFoodMenuItem
)
5.4 break
如果您不想在给定的情况下执行任何操作,请使用 break
var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
case .hamburger: break
case .fries: print("fries")
case .drink: print("drink")
case .cookie: print("cookie")
}
该代码不会在控制台上打印任何内容。
5.5 Default
switch
必须处理所有可能的情况,因此通常使用 default
来管理不感兴趣的状态。
var menuItem = FastFoodMenuItem.cookie
switch menuItem {
case .hamburger: break
case .fries: print("fries")
default: print("other")
}
如果 menuItem 是一个 cookie,则上面的代码将在控制台上打印 “other”。
顺便说一句,你可以 switch
任何类型(不仅仅是 enum),例如 string
let s: String = "hello"
switch s {
case "goodbye": ...
case "hello": ...
default: ... // gotta have this for String because switch has to cover ALL cases
}
5.6 允许多行
switch
中的每个 case 都可以是多行,并且不会落入下一个 case …
var menuItem = FastFoodMenuItem.fries(size: FryOrderSize.large)
switch menuItem {
case .hamburger: print("burger")
case .fries:
print("yummy")
print("fries")
case .drink:
print("drink")
case .cookie: print("cookie")
}
上面的代码会在控制台上打印 “yummy” 和 “fries”,而不是 “饮料”。
如果您将关键字 fallthrough
放在某个 case
的最后一行,那么它将会继续执行下一个 case
。
关于关联数据,您可以通过 switch
语句访问这些数据,使用 let
语法进行解构。
var menuItem = FastFoodMenuItem.drink("Coke", ounces: 32)
switch menuItem {
case .hamburger(let pattyCount): print("a burger with \(pattyCount) patties")
case .fries(let size): print("a \(size) order of fries!")
case .drink(let brand, let ounces): print("a \(ounces)oz \(brand)")
case .cookie: print("a cookie!")
}
请注意,检索关联数据的局部变量可以具有不同的名称
(例如 pattyCount
与 enum 声明中的 patties 的比较)
(例如,上面的 brand
在枚举声明中甚至没有名称)
5.7 有 Method
,不能有存储属性
枚举可以有方法(和计算属性),但不能有存储属性。
enum FastFoodMenuItem {
case hamburger(numberOfPatties: Int)
case fries(size: FryOrderSize)
case drink(String, ounces: Int)
case cookie
func isIncludedInSpecialOrder(number: Int) -> Bool { }
var colories: Int { // switch on self and calculate caloric value here }
}
其中,colories
和 isIncludedInSpecialOrder
分别为计算属性和方法。
枚举的状态仅取决于它所处的 case
以及该 case
的关联数据.
在枚举自己的方法中,你可以使用 self …
enum FastFoodMenuItem {
...
func isIncludedInSpecialOrder(number: Int) -> Bool {
switch self {
case .hamburger(let pettyCount): return pettyCount == number
case .fries, .cookie: return true // a drink and cookie in every special order
case .drink(_, let ounces): return ounces == 16 // & 16oz drink of any kind
}
}
}
5.8 获取枚举的所有成员
enum TeslaModel: CaseIterable {
case X
case S
case Three
case Y
}
现在,这个枚举将有一个静态变量 .allCases
,可以迭代。
for model in TestlaModel.allCases {
reportSalesNumbers(for: model)
}
func reportSalesNumbers(for model: TeslaModel) {
switch model { ... }
}
6. optional
optional 是一个枚举。它看起来像这样:
enum Optional<T> { // a generic type
case none
case some(T) // the some case has associated value of type T
}
你可以看到它只能有两种值: 已设置值的 case (some
) 或未设置值的 case(none
)。在已设置的case 中,它可以有一些关联值,它们是“generic type” T
。
我们在哪些地方使用 Optional?我们可能有一个值,它有时可以是“未设置”、“未指定”或“未确定”的。这种情况经常发生。这就是 Swift 引入许多“语法糖”的原因,以便于使用可选值(Optionals)。
6.1 optional 声明
声明一个类型为 Optional<T>
的变量可以使用语法 T?
。然后,您可以将其赋值为 nil
(Optional.none),或者可以将其赋值为类型 T
的某个值(Optional.some
,并使得 关联值 = 该值
)。
请注意,可选值总是隐式地以 nil
开始。
var hello: String? var hello: Optional<String> = .none
var hello: String? = "hello" var hello: Optional<String> = .some("hello")
var hello: String? = nil var hello: Optional<String> = .none
6.2 optional 访问值
- 你可以通过
!
强制访问关联值。
let hello: String? = ...
print(hello!)
switch hello {
case .none: // raise an exception (crash)
case .some(let data): print(data)
}
- 或者“安全地”使用
if let
然后使用{ }
中安全获取的关联值。
if let safehello = hello {
print(safehello)
} else {
// do something else
}
switch hello {
case .none: // raise an exception (crash)
case .some(let safehello): print(safehello)
}
- 您也可以使用较短的版本,同
2
。
if let hello {
print(hello)
} else {
// do something else
}
- 还有
??
它执行 “Optional defaulting”,被称为“nil-coalescng 运算符”。
let hello: String? = ...
let y = x ?? "foo"
switch hello {
case .none: y = "foo"
case .some(let data): y = data
}
现在回到 Code。
7. ViewModel Intent - Part 2
// MemorizeGame.swift
...
private func index(of card: Card) -> Int? {
for index in cards.indices {
if cards[index].id == card.id {
return index
}
}
return nil
}
...
让我们使用 Optional 修复虚假信息。
// MemorizeGame.swift
...
mutating func choose(_ card: Card) {
if let chosenIndex = index(of: card) {
cards[chosenIndex].isFaceUp.toggle()
}
}
...
我们还需要更改 choose 函数,因为 chosenIndex
现在是 Optional 类型。我们可以通过使用 !
,但如果 index
返回 nil
会报错。所以,我们使用 safe unwrap。
7.1 函数作为参数
// MemorizeGame.swift
...
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
cards[chosenIndex].isFaceUp.toggle()
}
}
...
我们不必自行实现函数 index
。我们实际上可以找到 chosenIndex
。
7.2 Flip the cards down when NOT matched
// MemorizeGame.swift
...
var indexOfTheOneAndOnlyFaceUpCard: Int?
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
if !cards[chosenIndex].isFaceUp && !cards[chosenIndex].isMatched {
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
// Two Cards Face Up
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
indexOfTheOneAndOnlyFaceUpCard = nil
} else {
for index in cards.indices {
cards[index].isFaceUp = false
}
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
}
cards[chosenIndex].isFaceUp = true
}
}
...
我们的游戏逻辑现在看起来是正确的,但是我们需要在牌匹配时隐藏它们。
// EmojiMemoryGameView.swift
...
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: 12)
Group {
base.fill(.white)
base.strokeBorder(lineWidth: 2)
Text(card.content)
.font(.system(size: 200))
.minimumScaleFactor(0.01)
.aspectRatio(1, contentMode: .fit)
}
.opacity(card.isFaceUp ? 1 : 0)
base.fill().opacity(card.isFaceUp ? 0 : 1)
}
.opacity(card.isFaceUp || !card.isMatched ? 1 : 0)
}
...
所以,我们回到我们的视图,让匹配的卡片不透明度为 0。
我们的主要游戏逻辑现在应该可以工作了。
7.3 set+get 计算属性
// MemorizeGame.swift
...
var indexOfTheOneAndOnlyFaceUpCard: Int? {
get {
var faceUpCardIndices = [Int]()
for index in cards.indices {
if cards[index].isFaceUp {
faceUpCardIndices.append(index)
}
}
if faceUpCardIndices.count == 1 {
return faceUpCardIndices.first
} else {
return nil
}
}
set {
for index in cards.indices {
if index == newValue {
cards[index].isFaceUp = true
} else {
cards[index].isFaceUp = false
}
}
}
}
...
我们可以通过将 indexOfTheOneAndOnlyFaceUpCard
制作成一个计算属性来优化游戏逻辑。这个计算属性在被获取时将返回其他面朝上的卡片(或 nil)。当被设置(indexOfTheOneAndOnlyFaceUpCard = something
)时,它将把传入的卡片设置为面朝上,所有其他卡片则面朝下。
// MemorizeGame.swift
...
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
if !cards[chosenIndex].isFaceUp && !cards[chosenIndex].isMatched {
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
// Two Cards Face Up
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
} else {
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
}
cards[chosenIndex].isFaceUp = true
}
}
...
我们的计算属性已经实现了很多步骤,我们可以对 choose
函数进行简化。
7.4 choose
函数优化
// MemorizeGame.swift
...
var indexOfTheOneAndOnlyFaceUpCard: Int? {
get {
let faceUpCardIndices = cards.indices.filter { index in cards[index].isFaceUp }
return faceUpCardIndices.count == 1 ? faceUpCardIndices.first : nil
}
set {
cards.indices.forEach { cards[$0].isFaceUp = (newValue == $0) }
}
}
...
现在,我们使用 functional programming 来优化我们的代码。我们还可以使用 extension
使代码变得更好。
7.5 extension
// MemorizeGame.swift
...
import Foundation
struct MemoryGame<CardContent> where CardContent: Equatable {
private(set) var cards: Array<Card>
...
...
var indexOfTheOneAndOnlyFaceUpCard: Int? {
get { cards.indices.filter { index in cards[index].isFaceUp }.only }
set { cards.indices.forEach { cards[$0].isFaceUp = (newValue == $0) } }
}
...
...
}
extension Array {
var only: Element? {
count == 1 ? first : nil
}
}
我们对 array
类型添加 extension
,以便在我们的 indexOfTheOneAndOnlyFaceUpCard
计算属性中使用 .only
。