第三章:模型-视图-控制器模式

模型-视图-控制器(MVC)模式将对象分成三种不同类型。是的,你猜对了:这三种类型是模型、视图和控制器!

下面是这些类型的关系。

MVC在iOS编程中非常常见,因为它是苹果在UIKit中选择采用的设计模式。

控制器被允许为他们的模型和视图拥有强大的属性,因此它们可以被直接访问。控制器也可以有一个以上的模型和/或视图。

反之,模型和视图不应该持有对其拥有的控制器的强引用。这将导致一个保留循环。

相反,模型通过属性观察与控制器沟通,你将在后面的章节中深入学习,而视图通过IBActions与控制器沟通。

这让你可以在多个控制器之间重复使用模型和视图。赢了!

注意:视图可以通过一个委托对其拥有的控制器有一个弱引用(见第4章,"委托模式")。例如,一个UITableView可以为它的delegate和/或dataSource引用持有一个对它自己的视图控制器的弱引用。然而,表视图并不知道这些是设置给它自己的控制器的--它们只是碰巧如此而已。

控制器更难被重用,因为它们的逻辑往往是非常具体的,无论它们在做什么工作。因此,MVC并不试图重用它们。

你什么时候应该使用它?

将这种模式作为创建iOS应用程序的起点。

在几乎每一个应用中,除了MVC,你可能还需要其他的模式,但当你的应用需要时,引入更多的模式也是可以的。

Playground示例

打开Starter目录下的FundamentalDesignPatterns.xcworkspace。这是一个Playground页面的集合,你将学习的每个基本设计模式都有一个页面。在本节结束时,你会有一个很好的设计模式参考资料

从文件层次结构中打开 "概览 "页面。

本页列出了三种类型的设计模式。

MVC是一种结构模式,因为它是关于将对象组成模型、视图或控制器的。

接下来,从文件层次结构中打开Model-View-Controller页面。对于代码实例,你将使用MVC创建一个 "Address Screen"。你能猜到地址屏的三个部分是什么吗?当然是模型、视图和控制器! 在代码示例后添加这段代码来创建模型。

import UIKit

// MARK: - Address 
public struct Address { 
    public var street: String 
    public var city: String 
    public var state: String 
    public var zipCode: String 
}

这创建了一个简单的结构,代表一个地址。

接下来需要导入UIKit来创建AddressView作为UIView的子类。添加这段代码来完成。

// MARK: - AddressView 
public final class AddressView: UIView { 
  @IBOutlet public var streetTextField: UITextField! 
  @IBOutlet public var cityTextField: UITextField! 
  @IBOutlet public var stateTextField: UITextField! 
  @IBOutlet public var zipCodeTextField: UITextField! 
}

在实际的iOS应用中,而不是在playground上,你也会为这个视图创建一个XIB或故事板,并将IBOutlet属性连接到其子视图。你将在本章的教程项目中练习这样做。

最后,你需要创建AddressViewController。接下来添加这段代码。

// MARK: - AddressViewController 
public final class AddressViewController: UIViewController {

// MARK: - Properties public var address: Address?
public var addressView: AddressView! {
  guard isViewLoaded else { return nil }
  return (view as! AddressView) 
 }
}

在这里,你可以看到控制器对它所拥有的视图和模型持有一个强引用。

addressView是一个计算属性,因为它只有一个getter。它首先检查isViewLoaded,以防止在视图控制器呈现在屏幕上之前创建视图。如果isViewLoaded为真,它就会将视图转换为AddressView。为了不发出警告,你要用圆括号包围这个投射。

在实际的iOS应用中,你还需要在故事板或xib上指定视图的类,以确保应用正确地创建一个AddressView,而不是默认的UIView。

回顾一下,控制器的责任是协调模型和视图。在这种情况下,控制器应该使用地址的值来更新它的地址视图。

在viewDidLoad被调用时,就是一个好的地方。在AddressViewController类的末尾添加以下内容。

// MARK: - View Lifecycle 
public override func viewDidLoad() {
  super.viewDidLoad()
  updateViewFromAddress() 
}

private func updateViewFromAddress() {
  guard let addressView = addressView, 
    let address = address else { return }   
  addressView.streetTextField.text = address.street
  addressView.cityTextField.text = address.city 
  addressView.stateTextField.text = address.state
  addressView.zipCodeTextField.text = address.zipCode
}

如果在viewDidLoad被调用后设置了地址,控制器也应该在那时更新addressView。

用下面的内容替换地址属性。

public var address: Address? { 
  didSet { 
    updateViewFromAddress() 
   } 
  }

这是一个例子,说明了模型如何告诉控制器某些东西已经改变,视图需要更新。

如果你还想让用户在视图中更新地址呢?这就对了--你可以在控制器上创建一个IBAction。

在updateViewFromAddress()之后添加这个动作。

// MARK: - Actions 
@IBAction public func updateAddressFromView( _ sender: AnyObject) {

guard let street = addressView.streetTextField.text,
  street.count > 0, 
  let city = addressView.cityTextField.text,   
  city.count > 0, 
  let state = addressView.stateTextField.text, 
  state.count > 0, 
  let zipCode = addressView.zipCodeTextField.text, 
  zipCode.count > 0 else {
    // TO-DO: show an error message, handle the error, etc
    return
  } 
  address = Address(street: street, city: city,state: state, zipCode: zipCode)
}

最后,这是一个例子,说明视图如何告诉控制器有什么变化,模型需要更新。在实际的iOS应用中,你还需要从AddressView的子视图中连接这个IBAction,比如UITextField的valueChanged事件或UIButton的touchUpInside事件。

总而言之,这给你一个简单的例子,说明MVC模式是如何工作的。你已经看到了控制器是如何拥有模型和视图的,以及每个模型和视图是如何相互作用的,但总是通过控制器。

你应该注意什么?

MVC是一个很好的起点,但它也有局限性。不是每个对象都能整齐地归入模型、视图或控制器的范畴。因此,只使用MVC的应用程序往往在控制器中有大量的逻辑。

这可能会导致视图控制器变得非常大。对于这种情况的发生,有一个相当古怪的术语,叫做 "大规模的视图控制器"。

为了解决这个问题,你应该在你的应用程序需要时引入其他设计模式。

教程项目

在本节中,你将创建一个名为Rabble Wabble的教程应用。

这是一个语言学习应用,类似于Duolingo,WaniKani和Anki。

你将从头开始创建这个项目,所以打开Xcode并选择File ▸ New ▸ Project。然后选择iOS ▸ Single View App,并按下一步。

在产品名称中输入RabbleWabble;选择你的团队,如果你没有设置一个团队,则保留为无(如果你只使用模拟器,则不需要);将你的组织名称和组织标识设置为你想要的;确认语言设置为Swift;取消选择使用SwiftUI、使用Core Data、包括单元测试和包括UI测试;并点击下一步继续。

选择一个方便的位置来保存项目,然后按创建。

你需要做一些组织工作来展示MVC模式。

输入QuestionViewController作为新名称,并按回车键进行修改。然后,在类QuestionViewController前添加关键字public,像这样。

public class QuestionViewController: UIViewController

在本书中,对于那些应该被其他类型公开访问的类型、属性和方法,你将使用public;如果某些东西只应该被类型本身访问,你将使用private;如果它应该被子类或相关类型访问,但不打算在其他方面被普遍使用,你将使用 internal。这就是所谓的访问控制。

这是iOS开发中的一个 "最佳实践"。如果你把这些文件移到一个单独的模块中,例如创建一个共享库或框架,如果你遵循这个最佳做法,你会发现它更容易做到。

接下来,在文件层次结构中选择黄色的RabbleWabble组,然后一起按命令+选项+N来创建一个新组。

选择新组并按回车键编辑其名称。输入AppDelegate,然后再按回车键确认。

重复这个过程,为控制器、模型、资源和视图创建新组。

把AppDelegate.swift和SceneDelegate.swift移到AppDelegate组,把QuestionViewController.swift移到Controllers,把Assets.xcassets和Info.plist移到Resources,把LaunchScreen.storyboard和Main.storyboard移到View。

最后,在黄色的RabbleWabble组上点击右键,选择按名称排序。

你对SceneDelegate感到好奇吗?这是一个在iOS 13中引入的新类。它的目的是允许一个应用的多个 "场景 "共存,甚至支持你的应用同时运行多个窗口。这在大屏幕上特别有用,比如iPad。

你的文件层次结构最终应该看起来像这样。

由于你移动了Info.plist,你需要告诉Xcode它的新位置在哪里。为此,选择蓝色的RabbleWabble项目文件夹;选择RabbleWabble目标;选择构建设置选项卡;在搜索框中输入Info.plist;双击包装部分下的Info.plist行;并将其文本替换为以下内容。

RabbleWabble/Resources/Info.plist

这是使用MVC模式的一个良好开端。通过简单地将你的文件分组,你就告诉其他开发者你的项目使用了MVC。明确性是好的!

创建模型

接下来你将创建Rabble Wabble的模型。

首先,你需要创建一个问题模型。在文件层次结构中选择模型组,按Command + N创建一个新文件。从列表中选择Swift文件并点击下一步。将该文件命名为Question.swift,然后点击创建。用以下内容替换Question.swift的全部内容。

import Foundation

public struct Question { 
  public let answer: String 
  public let hint: String? 
  public let prompt: String 
}

你还需要另一个模型来作为一组问题的容器。

在模型组中创建另一个名为QuestionGroup.swift的文件,并将其全部内容替换为以下内容。

import Foundation

  public struct QuestionGroup { 
  public let questions: [Question] 
  public let title: String 
}

接下来,你需要添加问题组的数据。这可能会造成大量的重复输入,所以我提供了一个文件,你可以简单地拖放到项目中。

打开Finder,导航到你为本章下载项目的地方。除了Starter和Final目录外,你会看到一个Resources目录,其中包含QuestionGroupData.swift、Assets.xcassets和LaunchScreen.storyboard。

将Finder窗口置于Xcode上方,然后将QuestionGroupData.swift拖入Models组,像这样。

当出现提示时,请勾选 "需要时复制项目 "选项,并按 "完成 "键添加文件。

由于你已经打开了 "Resources"目录,你应该把其他的文件也复制过来。首先,选择应用程序中 "Resources"下现有的Assets.xcassets,然后按 "删除 "键将其删除。当出现提示时,选择移动到垃圾箱。然后,将新的Assets.xcassets从Finder拖到应用程序的资源组中,如果需要的话,在提示时勾选复制项目。

接下来,选择应用程序中 "视图 "下现有的LaunchScreen.storyboard,并按 "删除 "键将其删除。同样,确保在出现提示时选择移到垃圾桶。然后,将新的LaunchScreen.storyboard从Finder中拖到应用程序的资源组中,如果需要,在提示时勾选复制项目。

打开QuestionGroupData.swift,你会看到有几个静态方法被定义为基本短语、数字和其他。这个数据集是日语的,但如果你愿意,你可以把它调整为其他语言。你很快就会用到这些东西了。

打开LaunchScreen.storyboard,你会看到一个漂亮的布局,只要应用程序启动,就会显示出来。

建立并运行,看看这个可爱的应用程序图标和启动屏幕吧

创建视图

你现在需要设置MVC的 "视图 "部分。选择视图组,并创建一个名为QuestionView.swift的新文件。

将其内容替换为以下内容。

import UIKit

public class QuestionView: UIView { 
  @IBOutlet public var answerLabel: UILabel! 
  @IBOutlet public var correctCountLabel: UILabel! 
  @IBOutlet public var incorrectCountLabel: UILabel! 
  @IBOutlet public var promptLabel: UILabel! 
  @IBOutlet public var hintLabel: UILabel!
}

接下来,打开Main.storyboard并滚动到现有场景。按住option键并按下Object library按钮,打开它并防止它关闭。在搜索栏中输入标签,并将三个标签拖放到场景中,但不要重叠在一起。

之后按对象库窗口上的红色X来关闭它。

双击最上面的标签,将其文本设置为提示。把中间的标签的文字设置为提示,把下面的标签的文字设置为答案。

选择 Prompt label,然后打开 Utilities pane窗格,选择Attributes inspector标签。将标签的字体设置为System 50.0,将其对齐方式设置为居中,行数设置为0。

将提示标签的字体设置为System 24.0,对齐方式为居中,行数为0。将答案标签的字体设置为System 48.0,对齐方式为居中,行数为0。

接下来,选择Prompt label,选择Add New Constraints的图标,并进行以下操作。

选择Hint label,选择 Add New Constraints的图标,然后进行以下操作。

选择 "答案 "标签,选择 "添加新约束 "的图标,然后进行以下操作。

现在的场景应该是这样的。

接下来,按下Object library按钮,在搜索栏中输入UIButton,然后将一个新按钮拖到视图的左下角。

打开属性检查器,将按钮的图像设置为ic_circle_x,并删除按钮的默认标题。

把另一个按钮拖到视图的右下角。删除Button的默认标题,并将其Image设置为ic_circle_check。

拖一个新的标签到场景中。将其放置在红色X按钮的正下方,并将其文本设置为0。打开属性检查器,将颜色设置为与红色圆圈相匹配。将字体设置为System 32.0,并将对齐方式设置为中心。根据需要调整这个标签的大小,以防止剪切。

把另一个标签拖到场景中,把它放在绿色复选按钮的下面,并把它的文字设置为0。打开属性检查器,把颜色设置为与绿色圆圈一致。将字体设置为System 32.0,并将对齐方式设置为中心。根据需要调整这个标签的大小,以防止剪裁。

接下来你需要设置按钮和标签的约束。

选择红色的圆形按钮,选择Add New Constraints的图标,然后进行以下操作。

选择红色的标签,选择Add New Constraints的图标,然后进行以下操作。

同时选择红色圆圈图像视图和红色标签,选择 "对齐 "图标并执行以下操作。

选择绿色圆圈图像视图,选择 "添加新约束 "的图标,然后执行以下操作。

选择绿色的标签,选择 "添加新约束 "的图标,然后进行以下操作。

同时选择绿色的圆形图像视图和绿色的标签,选择 "对齐 "的图标,并进行以下操作。

现在的场景应该是这样的。

为了完成QuestionView的设置,你需要在场景中设置视图的类并连接属性。

点击场景中的视图,注意不要选择任何子视图,然后打开身份检查器。将类设置为QuestionView。

打开 "连接检查器",从每个 "出口 "拖到相应的子视图,如图所示。

建设和运行,并查看风景。棒极了!

创建控制器

你终于准备好创建MVC的 "控制器 "部分了。

打开QuestionViewController.swift,添加以下属性。

// MARK: - Instance Properties 
public var questionGroup = QuestionGroup.basicPhrases() public var questionIndex = 0

public var correctCount = 0 public var incorrectCount = 0

public var questionView: QuestionView! { 
  guard isViewLoaded else { return nil } 
  return (view as! QuestionView) 
}

现在你把问题组硬编码为基本短语。在未来的一章中,你将扩展这个应用程序,使用户能够从列表中选择问题组。

questionIndex是当前显示的问题的索引。当用户通过问题时,你将增加它。

correctCount是正确答案的计数。用户通过按下绿色复选按钮来表示正确的回答。

同样地,incorrectCount是错误回答的数量,用户将通过按下红色的X按钮来表示。

questionView是一个计算的属性。在这里你要检查isViewLoaded,这样你就不会因为访问这个属性而导致视图被无意地加载。如果视图已经被加载,你就把它强制转换为QuestionView。

接下来你需要添加代码来实际显示一个问题。在你刚才添加的属性后面添加以下内容。

// MARK: - View Lifecycle 
public override func viewDidLoad() {
  super.viewDidLoad()
  showQuestion() 
}

private func showQuestion() { 
 let question = questionGroup.questions[questionIndex]

  questionView.answerLabel.text = question.answer   
  questionView.promptLabel.text = question.prompt 
  questionView.hintLabel.text = question.hint
  
  questionView.answerLabel.isHidden = true  
  questionView.hintLabel.isHidden = true
}

注意这里你是如何在控制器中写代码,根据模型中的数据来操作视图的。MVC的魅力!

构建并运行,看看问题在屏幕上是什么样子!

现在,没有任何方法可以看到答案。你也许应该把它改掉。

在视图控制器的末尾添加以下代码。

// MARK: - Actions 
@IBAction func toggleAnswerLabels(_ sender: Any) {
  questionView.answerLabel.isHidden = !questionView.answerLabel.isHidden   
  questionView.hintLabel.isHidden = !questionView.hintLabel.isHidden
}

这将切换提示和答案标签是否被隐藏。你在showQuestion()中把答案和提示标签设置为隐藏,以便在每次显示新问题时重置状态。

这是一个视图通知其控制器已经发生的动作的例子。作为回应,控制器执行代码来处理这个动作。

你还需要把这个动作挂在视图上。打开Main.storyboard,按下对象库按钮。

在搜索栏中输入 "tap",并将 "Tap Gesture Recognizer "拖到视图上。

确保你把它拖到基本视图上,而不是拖到某个标签或按钮上。控制拖动轻触手势识别器对象到场景中的问题视图控制器对象,然后选择toggleAnswerLabels:。

建立并运行,并尝试点击视图来显示/隐藏答案和提示标签。接下来,你需要处理每当按钮被按下时的情况。

打开QuestionViewController.swift,在类的末尾添加以下内容。

// 1 
@IBAction func handleCorrect(_ sender: Any) {
  correctCount += 1
  questionView.correctCountLabel.text = "\(correctCount)"
  showNextQuestion() 
}

// 2 
@IBAction func handleIncorrect(_ sender: Any) {
  incorrectCount += 1
  questionView.incorrectCountLabel.text = "\(incorrectCount)"
  showNextQuestion() 
}

// 3 
private func showNextQuestion() { 
  questionIndex += 1 
  guard questionIndex < questionGroup.questions.count else { 
    // TODO: - Handle this...!
    return 
  } 
  showQuestion() 
}

你刚刚又定义了三个动作。下面是每个动作的作用。

  1. handleCorrect(_:)将在用户按下绿色圆圈按钮表示他们的答案正确时被调用。在这里,你增加correctCount并设置correctCountLabel文本。

  2. handleIncorrect(_:) 将在用户按下红色圆圈按钮表示他们的答案不正确时被调用。在这里,你增加incorrectCount并设置incorrectCountLabel文本。

  3. showNextQuestion()被调用以推进到下一个问题。你要根据questionIndex是否小于questionGroup.question.count来判断是否还有其他问题,如果有,就显示下一个问题。

你将在下一章中处理没有其他问题的情况。

最后,你需要将视图上的按钮与这些动作连接起来。

打开Main.storyboard,选择红色的圆圈按钮,然后控制拖动到QuestionViewController对象上,选择handleIncorrect:。

同样的,选择绿色的圆圈按钮,然后控制拖动到QuestionViewController对象上,选择handleCorrect:。

再一次,这些都是视图通知控制器需要处理的例子。建立并运行,并尝试按下每个按钮。

关键点

在本章中,你了解了模型-视图-控制器(MVC)模式。下面是它的关键点。

你已经让Rabble Wabble有了一个很好的开端。然而,你还有很多功能需要添加:让用户挑选问题组,处理没有任何问题时的情况,以及其他更多的功能。

继续看下一章,了解委托设计模式并继续构建Rabble Wabble。


上一章 目录 下一章