在上一节课里主要讲了MVVM的开发模式与swift里的数据类型,并且创建了Model与ViewModel的文件,本节课是第二课视图的延续,将通过ViewModel将Model与View连接起,将实现真正的MVVM开发模式。
现在的ViewModel与Model通过 createMemoryGame() 函数实了连接。巧妙的使用了闭包动态指定了卡片(Card)的内容(content)。而我们的视图还是独立出来的一个界面演示,没有与Model和ViewModel连接起来。
1、首先清理掉了以@State 开始的变量,因为我们的数据都需要来源于Model所以所有的临时变量将都不需要了。
2、通过定义viewModel变量与VM连接。
我们需要在程序入口文件(通常是项目名字+App)MemoryizeApp.swift文件里的启动接口处增加一个变量:
@main
struct MemorizeApp: App {
let game = EmojiMemoryGame()//初始化ViewModel
var body: some Scene {
WindowGroup {
ContentView(viewModel: game)//与View连接
}
}
}
在我们的View的ContentView.swift文件里清除了临时变量后,定义一个viewModel与VM进行一个连接操作;
var viewModel:EmojiMemoryGame//定义一个属性包装器,由启动入口指定使用的VM
这样我们理论上就实现了M+V+VM的链接,当然视图还有部分是错误的,我们还需要将原来临时变量的地方改为实际使用的数据来驱动视图。
3、修复View的显示
首先我们要通过ForEach对cards里的数据进行遍历,在第二次课我们知道使用ForEach使用通过ID识别,所以我们的Card结构体要符合Identifiable协议:
struct Card: Identifiable {
var isFaceUp:Bool = false
var isMatched:Bool = false
var content:CardContent
var id:Int //符合Identifiable
}
这里增加了id为Int类型后需要修复初始化Card里缺失id这个参数的错误:
cards.append(Card(content: content, id: pairIndex * 2))//卡1的ID为遍历顺序 * 2
cards.append(Card(content: content, id: pairIndex * 2 + 1))//配套卡2的ID为遍历顺序 * 2 + 1
这样我们的View里ForEach就满足条件即:
//通过ForEach把VM里的所有Card都遍历出来
ForEach(viewModel.cards){ card in
CardView(card: card)
.aspectRatio(2/3,contentMode: .fit)
}
4、修复单个卡片CardView视图的显示
我们清理了临时变量,在内容处需要显示来源于VM的内容,所以我们先要一个属性包装器card,在上面的代码里可以看到我们传入的是单个卡片(card)进入。类型是MemoryGame结构下的Card结构体。所以代码如下:
struct CardView:View {
let card:MemoryGame<String>.Card//属性包装器card
var body: some View{
ZStack{
let shape = RoundedRectangle(cornerRadius: 20)
if card.isFaceUp{//这里判断的是当前卡片状态
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 3)
//Text里显示的当前卡片的内容
Text(card.content).font(.largeTitle)
}else{
shape.fill()
}
}
}
}
上面的代码里已清除了点击的手势,我们需要在主视图里增加点击手势,这是因为我们还需要增加MVVM开发模式中最重要的双向数据绑定,当用户点击后触发数据传输给VM并通过VM通知Model数据修改操作。
5、双向数据绑定
在ForEach下面的CardView增加一个点击手势并调用VM里的选择卡片功能。
CardView(card: card)
.aspectRatio(2/3,contentMode: .fit)
.onTapGesture {
viewModel.choose(card)//点击后调用VM里的choose功能
}
6、ViewModel的中介作用体现
在ViewModel里增加以下代码:
// MARK: - Intent(s) 用户意图
func choose(_ card: MemoryGame.Card) {
model.choose(card: card) //调用Model里的方法修改被选中后的卡片
}
第一行注释以英文的MARK:开始的目的是为了在快速跳转里增加一个选择标签。可以迅速的找到功能代码。
上面的代码可以看到VM里的choose并不实现选择卡片的功能。而是直接调用了Model里的choose功能,我们的路径就出现了,用户点击 -> View -> ViewModel(choose) - Model(choose)。
然后我们要在Model里实现choose被选择的卡片里面的 isFaceUp = false 改为 isFaceUp = true ,我们理论上实现了MVVM的开发模式,但还不是真正的MVVM,我们还需要增加几个关键字对数据进行绑定,在这之前我们先要尝试通过Model里的choose功能对数据进行一个修改操作。
目前我们通过用户触发修改数据的方向假设为“正向”,数据修改后触发视图重建的方向为“反向”,我们是2个方向的数据传输,所以MVVM也可以叫着双向数据绑定,目前我们卡在了“正向”传输的功能实现上,课程讲这一步的主要目的是为了让我们再次深入理解一下struct是值传递的特点。
//选择并切换卡片的向上状态
func choose(_ card:Card) {
let chosenIndex = index(of: card) //找到当前卡片的索引
var chosenCard = cards[chosenIndex] //通过索引找到数据里的卡片并赋值给chosenCard(这一步是值传递)
chosenCard.isFaceUp.toggle()//切换当前卡片的向上状态(swift语言特点是这一步才会复制哦)
print("chosenCard = \(chosenCard)")
print("所有卡片(cards) = \(cards)")
}
//通过卡片id对比查找当前卡片的索引值
func index(of card:Card) -> Int {
for index in 0..
通过控制台查看到我们打印的chosenCard里的数据确定被修改成功了,但是cards里还是原来的值。这说明swift的结构体(struct)把cards[chosenIndex]的值复制给了chosenCard,而我们只是修改了chosenCard的值,并不能影响到cards[chosenIndex]里的值。
值传递的写时复制指的是将struct MemoryGame 这个结构体的初始化整个都给复制了。所以我们才能不加关键字就才在感觉上实现了修复操作,这实际是一个立即初始化的新的结构体,这也让我们加深View结构体的重建印象。
为了要真正的修改数组里的card值,我们需要加上关键字:mutating
mutating func choose(_ card:Card) {
let chosenIndex = index(of: card) //找到当前卡片的索引
cards[chosenIndex].isFaceUp.toggle()//切换被选中卡片的状态
}
使用mutating可以修改数据的前提条件是我们在定义ViewModel的时候使用了关键字var才可以(private(set) let model:MemoryGame),可以尝试将var改为let后XCode会立即报错:Cannot use mutating member on immutable value: 'model' is a 'let' constant
现在我们在数据层面实现了对选择后的卡片的修改,但是并未反应到视图层面。下面我们就需要通过几个关键字实现真正的MVVM。
我们通过增加mutating关键字完成了正向的数据传递,现在我们需要反向告诉视图,我们的数据修改好了,请立即重建视图给用户:Model 数据更新 -> ViewModel 通知视图 -> View 视图接收 -> 视图重建
1、ViewModel 通知视图
VM要通知视图需要符合ObservableObject协议即可,这个协议自动实现了 var objectWillChange: ObservableObjectPublisher 里的objectWillChange.send()方法。send()就是发送通知的函数。我们要发布哪个的通知需要增加一个修饰@Published关键字:
@Published private(set) var model:MemoryGame = createMemoryGame()
这句话的意思是一但model里发生了任意的数据改变就立即通知给视图。
2、视图接收
VM发送过来的通知并不是自动接收的,我们需要使用一个@ObservedObject关键修饰一下即可:
@ObservedObject var viewModel:EmojiMemoryGame
做为使用中文多的人要注意了,ObservedObject与ObservableObject是2个不同的单词(以前搞混过),只要视图接收到了来自VM的通知就会立即重建视图(这一步自动完成,不需要我们我们手动操作)。
到达这一步我们就实现了真正的MVVM开发模式。这是swiftUI的唯一开发模式,在以后的实战开发中随时随地的在使用。所以需要对MVVM进行深入的学习与理解。
关于使用了属性包装器可以在这里:http://www.55mx.com/ios/114.html 查看。使用ObservedObject包装的属性必须使用var定义。要不然会报错:Property wrapper can only be applied to a 'var'
1、enum枚举
枚举是一个在swift里非常重要的数据类型。enum有点像class和struct,它是由块数据结构构建的类型。但是枚举只有离散状态,枚举的是一些离散值构成的。说通俗一点枚举就是一些固定的选择项,就像你去饭店点菜的菜单,厨师只能做菜单上的内容。详细介绍:http://www.55mx.com/ios/103.html
2、Optional 可选类型
Optional其底层的构成就是一个枚举类型,上面我们说枚举是菜单选项,那么Optional就是点菜菜单里当天可能会出现买光了的选项。比如你要吃的蛋炒饭有N种原因会有没有的情况(点的人太多、没有买到蛋、没有剩饭了等)。
a、Optional的定义
//像定义Array<Element>的泛型一样
enum Optional<T> {
case none
case some(T)//如果有值则赋予为类型为T
}
case some是一个关联的枚举类型,T取决于定义时的指定。没有值的时候就是none,有值就会类型为T的关联值。
b、Optional的语法糖及原型
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 //非语法糖
c、Optional 强制解包
let hello:String? = ...
print(hello!)//强制解包打印
/*****************原型代码************/
switch hell{
case .none//如果值为nil程序会崩溃
case .some(let data):print(data)//有值则打印,无值就崩溃
}
d、Optional安全解包
if let safeHello = hello{
print(safeHello)//有值打印
}else{
print("safeHello的值不存在")//hello的值为nil,程序不会崩溃
}
/***********原型代码************/
switch hello{
case .none:{ print("hello的值不存在")}
case .some(let data):{ print(data)}
}
e、Optional的默认值
let x:String? = ...
let y = x ?? "foo" //语法糖
/***********原型代码************/
switch x{
case .none y = "foo"
case .some( let data): y = data//非语法糖
}
f、一个多重结构的Optional
let x: String? = ...
let y = x?.foo()?.bar?.z//语法糖
上面的代码里只要有任何一个?为空那么y就会是nil值。我们可以通过原型代码来理解这里的多个"?"。
switch x{
case .nene: y = nil//第一个?
case .some(let xVal):
switch xVal.foo() {
case .nene: y = nil//第二个?
case .some(let xFooVal):
switch xFooVal {
case .nene: y = nil//第三个?
case .some(let xFbVal): y = xFbVal.z
}
}
}
我们需要层层判断最后才能确定y的值是否为空。中间只有一个?的值为none则y的值就会是nil,看到原型代码终于知道了还是语法糖好用吧。更多其它的基本的数据类型介绍:http://www.55mx.com/ios/99.html
上面插入学习了枚举和可选值的目的是为了修复index函数里返回的那个 0值。先检测索引值是否存在,然后使用安全解包读取索引值的卡片。
mutating func choose(_ card:Card) {
//let chosenIndex = index(of: card)! 强制解包
if let chosenIndex = index(of: card){
cards[chosenIndex].isFaceUp.toggle()//安全解包
}
}
//修改原来的Int为Optional类型
func index(of card:Card) -> Int? {
for index in 0..<cards.count {
if cards[index].id == card.id{
return index
}
}
return nil //返回空值
}
上面的代码可以优化下,通过内置的firstIndex取代Index函数:
mutating func choose(_ card:Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id}){
cards[chosenIndex].isFaceUp.toggle()
}
}
目前为止,所有卡片都可以点击并切换了。我们的下一步就需要判断一下当点击第3张卡片的同时,前面被翻开的2张应该自动的翻到背面,首先我们要定义一个找到唯一张朝上的卡片:
private var indexOfTheOneAndOnlyFaceUpCard:Int?//唯一一张朝上的卡片索引
使用上面学习过的Optional类型来处理是否有这个索引。现在我们需要在choose函数里去尝试找到这个唯一朝上的卡片。然后对卡片的内容进行一个对比操作,当内容一样我们就设置卡片的匹配状态为true即可。
//对CardContent提出新的要求符合Equatable协议
struct MemoryGame<CardContent> where CardContent: Equatable
我们自己定义的结构体如果要使用 == 判段的时候就需要符合Equatable协议才行,所以使用where对CardContent做一个要求限制。
然后就是卡片匹配的逻辑代码部分,也是本节课最重要的核心算法逻辑。
//这是未对代码进行优化前的整个匹配的逻辑算法,理解这个后再对比优化后的代码能学到很多
mutating func choose(_ card:Card) {
//使用if let判断就不可以直接使用&&、and判断。换行是为了增加可读行
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id}) ,
!cards[chosenIndex].isFaceUp,//增加要求被选择的卡片isFaceUp为false
!cards[chosenIndex].isMatched//要求被选择的卡片isMatched为false
{
//安全解包indexOfTheOneAndOnlyFaceUpCard
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard{
//尝试比对选择的卡与当前唯一被翻转卡片的内容是否一样
if cards[chosenIndex].content == cards[potentialMatchIndex].content{
//匹配成功后让2张一样的卡片同时标记为被匹配状态
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
//不管比如成功与否都设置为nil进行下一轮尝试
indexOfTheOneAndOnlyFaceUpCard = nil
} else {
for index in cards.indices { //indices的作用与 0..<cards.count一样
cards[index].isFaceUp = false//让所有卡片都朝背面
}
//解包失败说明当前为nil所以设置被选中的卡片为唯一索引
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
cards[chosenIndex].isFaceUp.toggle()//翻转被选中的卡片
}
}
通过上面的匹配算法我们在模拟器里可以看到已能成功修改匹配的卡片状态,如果二次点击卡片(因为做了判断)发现没有任何效果,所以我们需要在View里把成功匹配了的卡片隐藏起来。
在单个卡片(CardView)的判断里增加一个else if 然后使用透明的方式对卡片隐藏起来。
if card.isFaceUp{
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 3)
Text(card.content).font(.largeTitle)
}else if card.isMatched{
shape.opacity(0)//使用opacity透明度来隐藏掉卡片
}else{
shape.fill()
}
继续使用shape的目的是为了占用同样大小的位置,如果你想让匹配后的内容移动位置增加游戏的难度我们可以使用EmptyView() 替换掉shape.opacity(0) 即可。
本课主要结合了上一节课的MVVM理论然后使其各部分数据进行了双向绑定的编码,然后重点的编写了匹配的判断逻辑部分。虽然我们现在的VM对于Model的解释还不多。但ViewModel是Model到View的守门人。他使我们的Model私有化并仅在需要时才向视图公开有关于Model的信息,有效的保护数据安全。VM还支持整个反应式架构,因为VM能观察到Model的变化(@Published),swift可以检查到Model的原因是它由struct组成的,所以我们的Model不能使用class的来编写。
View根据数据的改变来重建整个界面,首先会检查主体body是否有数据更改,然后子视图里是否有数据更改,我们可以尽量使用子视图以模块化的布局,这样只有数据有变化的某个子视图模块会被重建以减少被View重建的成本,提升显示效率。
除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:https://www.55mx.com/post/148
《CS193P2021学习笔记第四课:记忆游戏的逻辑代码》的网友评论(0)