优秀的 iOS 应用架构:MVVM、MVC、VIPER,孰优孰劣?

New Features in Realm Obj-C & Swift

We’ve released version 2.7 of Realm Objective‑C and Realm Swift! In this release, we’re introducing improved permission APIs, faster reconnects, password change and applying a few bug fixes to keep your apps running strong.

MVVM、MVC、VIPER……架构的数目之多,令人眼花缭乱,那么问题来了,哪个架构才是最好的呢?让我们来谈论下与优秀的 iOS 应用架构有关的二三事。


概述(0:00)

我是 Krzysztof Zabtocki,现在我在纽约时报工作,但是最为人所知的是我的各种开源工作,或者是 Foldify 这个多年以前我制作的应用。您可能也听说过我编写的 Objective-C Playgrounds。Apple 说我们没办法为 Objective-C 提供 Playground 功能,如果要使用这个功能的话只能够用 Swift,我证明了他们的说法是大错特错的。

今天我的这个讲演主题是:优秀的架构,这个主题对我来说是非常重要的,当然对许多人来说,这个主题也很有争议。我们该如何定义何为优秀的架构呢?当人们谈论这些话题时,就很类似于空格和 Tab 的争论。争论总是非常激烈,而且要得出结论也非常得困难。

当我在准备此次演讲的时候,我发现我需要考虑很多东西,因为设计模式实在是太多了。我们有 SOLID 原则,有 Uncle Bob 的「Clean Architecture」,我完全可以提及这些东西,但问题是这些内容您完全可以在合适的设计书籍当中读到,并且显然与编写出”The Gang of Fours” 之类佳作的作者相比,我的能力远不及他们。

我更多地想谈一谈关于 iOS 开发的实际内容。我多次作为一名架构咨询师,被聘请为整个项目进行代码审查,并向开发团队提出建议,比如说如何改进他们的架构,如何让代码更好,代码的演进方向是否正确,以及是否可以在未来对项目进行改善等等。

因此我想谈一谈我自认为的重点所在,所以我说的内容可能会与大家心目当中所想的有所偏差,我还要谈一谈为什么 Apple 所给的示例代码并不好,以及为什么他们要这么做。他们今年在 WWDC 上举办了一场演讲,他们谈到有两种非常好的设计模式,但是他们完完全全滥用了这两个设计模式,所以我要说一说这方面的内容。我还要谈一谈 MVVM,这是一个常用的体系架构,为什么在使用 MVVM 的时候很多人会犯不少错误,以及我们该如何改进 MVVM 和其他架构。

何为优秀的架构?(2:26)

我们该如何定义何为优秀的架构呢?从某个特定的应用架构中,我发现我总是希望拥有以下几种特性:

我希望每个对象都拥有一个特定、明确的角色。角色这个理念很容易理解,也很容易去修改,当您去阅读源代码的时候,您就可以立即看到这个对象是否真正履行了角色的义务,或者看到您之后写的逻辑是否会违反角色义务。

我希望拥有一个简单的数据流,简单到我可以很轻易地理解,此外如果发生了崩溃或者错误,我可以很轻松地进行调试。我不想让多个不同的对象共用一个共享资源,因为这样的话错误就很难找到。如果您能够使用单向数据流的话,无疑是最优选择,因为单向数据流将允许您放置一个断点,然后查看数据的具体变化形式。有些架构是允许我们这样做的。

我希望架构不依赖于某个特定的框架或者服务,原因是:我编程的 20 多年来这一直让我感到非常的痛苦,因为我没办法在我使用的依赖关系和我编写的代码之间,创建一个简单的抽象关系。每次一旦有框架或者是服务弃坑了。之后当我们谈论类似事情的时候,我也会提及这一点。

我也希望架构足够灵活,之所以如此,是因为只要架构容易理解,那么修改起来就很容易。我不希望它过于灵活,是因为我可能会有 200 多个抽象类,这样所有玩意儿都是抽象的,那么新加入项目的人就完全无法理解,甚至当您必须要添加一个新的功能时,还必须绕很多圈,而这仅仅只是为了能够添加这个功能而已。我想要灵活性是因为它很简单,而不是因为它的过度设计。如果用更为技术性的术语表示的话,那么架构应该是关注点分离的 (separation of concern),这是一个很清晰的设计模式,具备单一的清晰权责。

测试

在设计架构的时候,可测试性是一件非常有帮助的能力。这并不涉及到测试本身,我只需要在 1 秒钟之内可以调用并执行测试就行了。避免的是无限制的依赖关系,特别是框架依赖,请避免这些的出现。借助关注点分离,我们就可以界定出清晰的界限。一个对象通常应该只有一个角色。这句话我经常说,因为有些时候您的对象很可能会违反这个角色,不过如果您的类太多的话,那么这样也可能是一种很好的选择。有些时候您会让类拥有协调器 (coordinate) 的角色,所以有些人会将其归为「违反了 SRP」,但是实际上这些类仍然具备单一权责,因为它们承担的是在不同的子系统之间进行沟通交流的责任。

我认为,在我们开发应用的时候,需要好好地去测试并理解应用,这对我们的开发是非常有帮助的。可测试能力是一个很复杂很复杂,但是很优秀的特性。有一个非常棘手的问题是,可能我们很多人都不被允许编写测试,因为我们是在做外包,因为编写测试的工作量也会需要售卖,但是客户可能并不愿意支付,这是因为他们并不明白测试的重要性。此外还有其他很多原因让您不去编写测试,但是我之前也负责过一个团队,我经常跟与我们签合同的人说,如果你自己从头开始,来负责承包整个应用,那么是否进行测试的选择权在你自己的手上。假设有人被雇来搭建房子,那么他们并不会告诉被雇的人该如何工作,之所以雇佣他们是因为相信他们足够优秀,能够很好地完成这项工作。

如果有人聘用我来做一整个应用的话,那么我将竭尽所能,对我来说要做的工作就还包括了测试。这是因为有无测试的差异是非常巨大的。对于此时此刻我所谈论的架构来说,它们最明显的好处便是测试。

首先,当您开始用 TDD 方法来编写测试的时候,您首先就得先把测试写了。您需要先设计整个架构、所有类、所有管理器的 API,然后再去编写代码。如果您打算添加新功能的话,那么您可能就需要在测试文件中创建一个类,但是在编写完测试之前,应用中是不存在任何的接口的。

于是您就编写了第一个测试,然后根据您打算使用函数的方式来编写函数。突然您改变了想法。您不打算将其放在头文件了 (Objectiv-C),或者将其变成了一个空函数 (Swift)。您所做的第一件事就是去尝试调用这个接口,这似乎是一个非常简单的区别,但实际上您 API 的差别是非常大的。当您编写这个 API 的时候,您希望它们能够易于测试,不然的话测试就是一件非常痛苦的事。

这也是为什么很多人不想写测试的原因,因为这实在是太难了。我敢打赌,如果测试很难写的话,那么就说明您的架构方法肯定有问题。如果您将测试视为头等公民,然后以测试为始的话,那么就可以让接口更简单,或者说更为灵活。我往往会尽可能让测试变得简单,因为这意味着我可以轻松地在系统中交换元素。

借助红-绿-重构 (red-green-refactor),这也是 TDD 所采用的一种方法,您首先就需要编写失败测试,然后以最简单的方式进行编译,然后使其以最简单的方式实现,之后再对其进行重构。实际上,您最终变得到了一个更为简洁的实现方式,不仅仅测试如此,对于代码实现而言也是如此。

这绝对是我在每个项目中都会考虑的事情。如果您必须要创建大量的伪对象 (fake object),以便测试某个特定功能的话,那么通常是不可行的。这样会导致对象的角色过多,或者系统的依赖关系过于复杂。

Foldify(8:41)

Foldify 是基于 Parse 构建的,因此我仍需要将其迁移。如果您应用中的模型对象上有一层封装的话,就像我这样,那么比起直接使用 SDK 来,将其从 Parse 迁移到其他后端提供器无疑要简单的多。更不用说这些第三方应用还很可能会弃坑了。

当我一开始使用这个框架的时候,实际上体验非常糟糕。我甚至不能从他们的服务器中下载图片,因为它们会在框架中抛弃内部资源。添加一层封装总是没坏处的,这样可以帮您提前避免各种各样的麻烦。

几年之后,即便您对应用的维护不再积极频繁,那么只要应用还能够带来收益,那么维护项目也能轻松如意。我现在并未积极地更新 Foldify 了,但是我仍然没有弃坑,因为仍然有一些系统功能我需要去处理,但是我并不会去添加任何新的功能了。

让架构能够在未来几年都能够适用,这一点是非常重要的。这不像是在外包公司里面,您往往会负责一个项目,然后您就必须要一直对其进行维护,即便您不再负责这个项目了。

我总是假定自己是负责解决问题的那个人,如果您实现这么想的话,那么之后您所需要解决的问题数目也会变少。

糟糕的架构(10:07)

糟糕架构的迹象有些什么呢?我们该如何检查架构中存在哪些潜在的问题呢?

举个例子,您可以搜索并计算文件中的代码行数。这通常是一个很明显的迹象。如果 Swift 中的某个视图控制器拥有超过 3,000 行代码,或者说对应的测试代码行数超过了 4,000 行,那么这个迹象就很明显了。这并不一定意味着您的架构就很糟糕,因为您可能会拥有很多个类,只不过这些类都放在了一个单独的文件当中而已,以便方便访问或者出于别的目的,但是这仍不失为一个很好的迹象。

我实际上为我得 Objective-C 项目编写了一段脚本,我已经将其放在了 Github 上面,它是用 Bootstrap 运行的,只要我的文件过长,它就会触发警告,提示我:「看一下这个文件,检查一下您是否在哪里犯了错误」。

另一件您还可以做的事就是,看一看项目当中是否使用了全局状态或者 Up Delegate,因为严格来说,我并没有看到任何很好的理由来解释为什么这样做。如果您有多个类的话,那么这或许也可以解释,但是如果您使用 Up Delegate 来存储全局属性,而不是使用依赖注入,也不是使用容器,也没有使用单例模式来编写的话,那么实际上这就是个全局状态。它往往会带来各种各样的麻烦。您可以运行一个依赖可视化工具,然后看一看所有指向 Up Delegate 的东西,此外还有很多类与其他类相互关联,这些类通常不是模块化的,而且也不适宜进行互换。

如果您想要避免这些的话,那么实际上如今有一个更好的可视化工具。您可以在 Github 仓库中将这个工具检出。

设计模式(12:00)

我想快速谈论下设计模式。我不打算讲述整个 SOLID 原则,因为关于这方面的文章已经有很多了,而这些文章无疑比我在这里的讲述要专业得多,并且我也不希望让大家听这些令人昏昏欲睡的玩意儿。

多年以来我发现,人们往往将设计模式视为了圣旨,有时候人们可能会将精力放在了某个特定的模式上面,然后就想当然地觉得能够在所有地方都能够应用这个模式。我想说的是,设计模式只是一个工具箱而已,它里面包含了各式各样的工具,在某个特定的场景下,这些模式就非常有效。

举个例子,基于特定原因构建的单例模式实际上并不是一个很糟糕的模式,只是因为人们的滥用,导致其变得糟糕。就算使用了最好的设计模式,也可以将应用架构变得一团糟。

您必须拥有足够的经验,并花费足够的时间投入在开发中,这样才能够理解并知道这时候该应用何种设计模式。我的态度永远都是务实的,如果有些时候您必须要遵循某个特定的设计模式的话,或者说某个设计模式并不适用于这里,但是如果它能够让您的代码更好,更易于测试,那么就值得考虑去更换模式。

比如说单例模式,如果我在这里问大家,大家就很可能会说,这个模式是很糟糕的,我永远不会去使用它,或者说我选择使用它,是因为我没有别的更好的办法。这个模式本身并不糟糕,它是为了处理资源争用而创立的。如果您实际上拥有一个单独的对象,比如说在访问某种数据库,或者某种只允许您拥有一个接口的玩意儿,那么您就可以应用单例模式,但是绝大多数人并不会这样做,尤其是在 iOS 领域。人们只是将其视为全局状态同步器,所以将所有有的没的全部抛给了单例。

日志记录器就是一个非常直观的例子,它通常会变成应用中的一个单例,但是对于现在,特别是 Swift 而言,您不应当暴露这个记录器实际上是使用单例这个事实,即便您只拥有一个对象,这样做会让您的架构变得更加困难。

人们之所以使用单例,其原因是这样使得访问变得容易许多。如果我需要记录应用当中的某个消息,那么我不希望给项目中的每个类传递一个记录器,因为我需要在某一时刻进行记录,或者在每个类当中都可以进行记录,因为我希望能够对其进行调试。我需要记录某些信息,以防我遇到了崩溃。我需要访问某种类型的记录器,我还希望能够记录系统的相关消息,不管是服务器还是文件的消息,这都无所谓。我只需要有一种方法能够记录信息,就行了。

即便您使用了单例,那么也没有必要将其暴露出来,特别是对于 Swift 而言。我创建了一个简单的名为 Loggable 的协议,它拥有一个内部使用了单例的默认实现,这样信息就不会泄露到文件外部,此外在应用控制器的初始配置之外,也永远不会对记录器进行访问。

如果我拥有一个类,然后让其实现 Loggable 协议,我就立即可以访问一个名为 log 的函数,这个函数内部使用了单例,但是应用当中的其他部分对其一无所知。我可以在测试中改变它,也可以修改 Loggable 的实现,也就是默认的实现当中,对其进行重写。我甚至可以测试日志是否正确,这些类是否记录了我所感兴趣的消息。

关于单例模式被滥用的例子还有很多。无论您是否使用了单例模式,都不要将其暴露出来。

另一个例子是,我看到很多人使用了网络管理器。假设您想要根据 URL 来设置图像查看器,很多人会选择使用单例。这通常是因为我接下来要谈及的一个更深层次的元音。这通常是因为缺乏依赖注入,因此我们要让依赖注入变得容易。

组合(16:34)

如果说让我推荐一个建议所有人都去使用和学习的模式,那么就是组合设计模式 (Composition) 了,原因很简单,就是我非常喜欢这个模式。它能够很自然地引导出起来好的选择,因为只要您正确地遵循组合模式,那么您的代码就自然拥有了单一权责,您的代码将遵循 DRY 原则。当您的代码具备单一权责的那一刻,就自然遵循的 DRY 原则。您也自然会遵循 DRY 原则来使用它,这样就不会浪费时间,这样您就拥有了这些完全可重用的代码,所以您就不会去重复创建相同的代码。

这个模式一旦遵循,那么所带来的好处是非常多的,而且无需做额外的处理,因为这些处理不是您需要考虑的事情。就算您只是简单的实现了组合模式,那么自然而然地就会导向 SRP 和 DRY 原则。这使得代码更容易测试,因为如果我拥有了一个小对象,那么我就能够将其注入到其他对象当中,而不是注入一个伪对象。此外,我也可以在对对象进行测试的时候,只测试几个接口即可。

当您将对象分割成诸多小对象的时候,那么这些小对象的可变性将会非常小,因此您需要测试的代码也会变少。

关于组合模式的一个不错的例子就是关于游戏以及游戏开发方面了。如果您鼓捣过 Unity,那么您就看到过一些非常优秀的 3D 图形游戏了。在 Unity 中,您能看到愤怒的小鸟,以及其他各式各样的游戏,这些大相径庭的游戏都是用了完全一样的系统。

整个系统都是基于组合模式的,它基于实体系统,也就是一种利用组合模式的模式。您可以创建一个对象,然后给这个对象添加行为。例如,您在游戏中创建了一个怪物的实例。这个怪物一开始是不会动的,它只会丢火球或者之类的操作,然后游戏设计决定如果这个怪物能够移动并更随玩家,那么将能提升游戏体验。您只需要添加另一个具备此功能的对象即可,然后注入怪物对象当中,这样怪物就开始跟随玩家移动了。

Unity 可能是我在实际行业作品中看到的最好的例子了,它能够支撑如此多的游戏,而这些游戏看起来截然不同,游戏功能也各式各样。即便是复杂程度,也有很多变化,这只是一个引擎而已。这个引擎遵循一个单一的模式,这个模式是非常好用的,而且带来的好处也是显而易见的。

与组合模式相关的其他模式便是依赖注入 (dependency injection)。一旦您生成了这些小对象,那么为什么要对它们进行硬编码呢?为什么要创建一个具有硬编码依赖关系的新实例呢?为什么不注入它呢?

您现在已经拥有了可以这么做的对象了,如果您可以很轻松地进行互换,那么没有理由去自找麻烦。如果 Unity 不允许您对普通对象进行修改,那么您就必须为所有东西都创建一个类。您必须要为玩家提供一个类,然后您也必须为敌人提供一个类。有些时候在 Unity 中,您确实需要为敌人创建一个单独的类,然后为其配置不一样的属性。这种特性对组合模式而言是非常好的,相辅相成。

您也可以很好地进行测试。在测试中,您可以将不同的实现方式进行交换。您可以使用相同的对象,然后修改其中的一个小组件,这样就能够执行完全不同的任务,这个功能非常有用,而且只需较少的测试,就能够完全达到相同的单元测试覆盖率。即便 Apple 今年推荐这个模式,他们也只谈到了带吗注入,由于它允许您重用代码,而且无需编写强依赖,所以这个模式很好。我会很快说一下这件事,因为 Apple 他们完全搞混了这个模式的意义,而且我想简要提及一下当前流行的架构模式。

流行的架构模式(20:39)

Bogdan Orlov 曾发表了一篇很著名的文章「iOS 架构模式」。我只从其中选出三种来讨论:MVC、Viper 和 MVVM。我很希望顺序是从常用到不常用的,但是考虑到 MVC 是最基础的架构,所以这也可能是迄今为止最常用的模式,尽管 Apple 使用 MVC 的方式是大错特错的。

MVC

我们要谈论的是经典 MVC,而不是 Apple 的那个版本,MVC 是在很久以前创建出来的,更适用于 Web 开发。View (视图) 用于转储 (dump),由 Controller (控制器)渲染,而 Controller 又从 Model (模型) 中拿取数据。当 Model 发生变化时,Controller 就会创建一个新的 View 镜像,这个镜像很类似于 HTML。由于我们是在 iOS 当中使用,所以我们有特定的框架,所以要应用这个模式并不容易。

Apple 认为我们应该做的是,Controller 用于协调 View Model 和 Model 之间的关系,然后 Controller 是功能最多的部分,所以人们总是会将 View Controller 称之为「臃肿的」,这就是现状所在。View Controller 与 View 的生命周期绑定得非常紧密,这意味着您没办法将这两者清晰地分离开来。由于 View Controller 是不可重用的,所以您的 View 也没办法重用,您能够重用的仅仅只有 Model 而已,这完完全全是浪费资源。

View Controller 和 View 的权责被交织在了一起。人们在 View Controller 当中实现网络访问、下载、数据处理,这完全是疯了。如果您尝试使用这种架构来编写测试,那么您就能够知道为什么人们讨厌测试了。这并不是因为错误本身不对,而是因为您所尝试去测试的架构本身就导致了测试变得异常困难。这里没有组合模式,没有注入,所以变成了一团糟。

Viper

现在我们来看下 Viper,前面这两种模式实际上并不是整个应用的架构模式,它们更像是 UI 模式,而 Viper 则是第一种考虑整个架构的模式,这个模式也是非常优秀的。它引入了几个新类型的对象。

例如,交互器 (interactor) 负责业务逻辑,它用于协调其他要使用组合模式的服务。然后是非常重要的路由 (router),它允许您为视图和视图控制器配置不同的展示上下文,以便您能够在多个地方当中展示相同的 UI。展示器 (presenter) 是一个包含 UIKit 独立展示逻辑的类。它本身不直接与 UIKit 进行交互,但是它会格式化链接,并执行类似的操作。

这个模式是非常优秀的,但是需要大量的样板代码 (boilerplate),以至于让人们需要编写代码生成器 (generator) 只为了去生成新的模块。在 Github 上关于代码生成 Viper 类的实现由两到三种。由于它需要这么多麻烦的初始步骤,这可能是很多人不愿使用的原因。

MVVM

在 iOS 上最受欢迎的模式便是 MVVM 了,也就是 Model-View-ViewModel。最重要的就是考虑 View Controller 和视图层这两块。由于 View Controller 与 View 的生命周期紧密耦合,因此很难将 View Controller 视作视图层。

这种模式无疑也是非常好的,因为您可以对 View Model (视图模型) 进行测试。没有这个模式的话,那么 View Model 就必须要包含 UIKit,这也就意味着无需加载 View Controller 作为依赖,就可以对整个业务逻辑进行测试。

MVC 的测试能力有 30%,而 MVVM 可以达到 70%,而且您还可以对剩余部分执行 UI 测试。此外 MVVM 也没有绑定其他库,通常使得样板代码变得非常多。通常情况您可能会想要绑定的能力。您完全没必要使用函数响应式编程,因为这通常会导致其余部分变得十分庞大。当然使用 ReactiveCocoa 和 NRX 是很不错的,但是用起来同样也很困难,它给人们带来了一个非常陡峭的学习曲线。我个人很喜欢它们,但是使用带有绑定的 MVVM 也很不错。

只需要创建一个简单的可观察类就可以用了,这样就会比标准的 MVC 要好用得多。这可能是我们所拥有的最受欢迎的模式了,但是很多人往往也将其搞混了。其好处包括了可测试能力。您可以对您的 View Model 进行测试,因为它当中并未包含 UIKit。您无需担心如何对 View Controller 进行测试,也无需要去检查是否必须对这些生命周期事件做手脚,我必须要调用 View Controller 的 getter 才能够去加载所需文件当中的 Outlet。所有的这些操作都不再需要去做了,因为您的 View Model 已经非常干净、漂亮,您可以对整个 View Model 的交互进行测试。

剩下唯一要测试的就是 View Controller 是否绑定上了。对于 Viper 而言,这是一个非常严肃的问题,因此它引入了 Router 的概念。如果我们没有 Router 的话,那么 Apple 就会说依赖注入就是一个非常好的选择,因为这样的话您就可以重用代码,然后他们就展示了一个示例,讲述了 View Controller 中推入了另一个 View Controller,然后将这些依赖注入到这个新的 View Controller 当中。

对此我有一个疑问。假如说您从一个简单的 View Controller 中开始,它当中存在一个依赖。当您创建它的时候,就注入依赖,这是没有问题的。那么假设我们要添加另一个 View Controller,并且这个 View Controller 需要去监控数据,因为并不是所有的 View Controller 都拥有相同的依赖的。那么您该怎么办呢?

您需要将 View Controller B 的依赖添加到 View Controller A 当中,然后如果您还需要再添加另一个 View Controller 的话,那么这个操作就要再做一遍。现在,您的第一个 View Controller 就变成应用中其他 View Controller 的接收器了。通常在应用中,我们会使用很多的导航,所以这种情况发生的几率会非常大。您会产生一个包含有大量依赖的 View Controller,而这些依赖是完全不可读的。如果您去查看源码,就会发现自己仍然不知道实际需要哪些玩意儿。如果让我来看看大家的 View Controller,然后就会发现,哦,用了数据库,用了图片提供器,用了日志记录器,等等,实际上它很可能只使用了其中一个。View Controller 实际上可以不使用这些。

第一个 View Controller 可能是欢迎屏幕,它当中是没有任何依赖的,它只是用来展示消息的。现在,您就必须要将所有东西注入到这里面,这就是一个很严峻的问题了。如果您没有 Router 的话,您最终就会面临这样的处境。这个场景是很不错的,因为它只区分了 iPad 和 iPhone。如果我必须要在应用当中的不同位置展示同一个视图控制器的话,那么让我们假设这种情况只会发生在 iPad 和 iPhone 的差异之中,所以我必须要在 View Controller 里面放置一个 if 语句。这就是 Apple 所建议的方法,View Controller 推入 View Controller 所需要做的。

我真的很不喜欢这样做,因为接下来,比如说当客户过来找到我,然后让我修改用户设置当中的某个特性,我们本来是要从用户信息当中展示用户设置的,现在却要在别的界面当中展示了。因此您就需要去编写另一个 if 语句,然后引入更多的状态,这样才能够确定它是在何种上下文当中展示的,这并不是一个好办法。这里面出现了很多的依赖,这使得阅读源码和今后的修改变得无比困难。

它同时也摈弃了诸多来自 MVVM 的好处。我们从 MVVM 获得的好处包含了易测试性。而这个做法使得测试难以为继,不仅是它用了某种单例来验证应用是否在 iPad 上运行,而且还需要推入 View Controller,因此存在多个依赖。在某个特定类当中展示 View Controller 也会引入依赖,无论是否被重写,这确实是一件很困难的事。我甚至不知道如何开始编写测试,也不知道如何去执行测试。

我们能做什么?(29:33)

我们不需要那些不必要的依赖,绝大多数时候,视图模型不需要知道其他视图模型的信息,除非它们之间是包含关系。如果您有一组视图模型的话,然后用其来创建项目的话,是很不错,但是从另一个角度而言,我并不希望这样做。我不想要纯粹的可重用性,也不想要意大利面式的代码,因为这会让我抓狂,我非常讨厌 if 语句。我想要让测试变得更为容易,而不是更困难。

为了测试我的视图模型,我不希望让我的视图控制器停止工作,这也不是我之后要做的事。我想要做的是,我创建一个简单的测试。让编写测试变得愉悦,而且也容易验证测试是否清晰。如果您有这样的诉求的话,就说明您的测试定义难以阅读,这是一个很严肃的问题,因为测试代码质量应该与您的代码质量相同。

我的确是有办法的,我将其称之为 MVVM+。它只是一个带有数据流协调器 (flow coordinator) 的 MVVM 而已,这种模式首先是由我的朋友 Jim Robka 介绍给我的。他向我展示了这个模式,我对此持保留态度,但是这个方法当时就被很多人使用了,甚至可能在此之前就有人使用了。

它本质上是一个 Router,但是它的权责要更为丰富。大家可以去阅读这两篇文章,从而获取更为详细的信息,其中一篇就是我博客 merowing.info 上的。它改进了数据流控制器 (Flow Controller) 的架构。

另一个则是 Coordinator Redux,您也可以在我的博客上找到。它与 Router 非常相似,但是如果您有多个视图控制器的话,那么它就可以大展身手了。如果您使用 MVVM 的话,那么就会存在一个对象,用来配置视图控制器以及视图模型,因为这既可以应用于 MVC,也可以应用于 MVVM。这样就存在了双向数据流,但是这实际上并不意味着您的视图模型需要知道您使用了何种模式。

我很喜欢这种方式,也就是将视图控制器视作一个黑盒,视图控制器只需要暴露一个接口,通知我是否发生了某个我感兴趣的事件。假设说,我有一个 EditUserSettings 视图控制器,那么这个控制器将具备保存或者取消的触发器,而这两个触发器是我唯二感兴趣的事情。

如果这样的话,那么 Flow Coordinator 将只会在特定上下文环境当中创建视图控制器。这个视图控制器在用户资料当中所展示的界面,与在其他界面中所展示的界面,可以配置成完全不同的样子。

用户资料可能是一个更好的例子。如果我打算展示用户资料,通常您可能会从主界面中展示它,就像截图当中的这样,您向左滑动,然后就可以看到用户资料或者其他类似的信息。接着,设计界面发生了变化,现在当您查看某个帖子,然后点击别人的头像的死后,您就必须要展示这个界面,因为我也希望看到这个屏幕。这是一个完全不同的展示上下文h环境,因此您需要拥有不同的配置上下文环境。

Flow Coordinator(32:30)

那么 Flow Coordinator 要做些什么呢,它具备多个配置方法,用以针对特定的场景进行配置。视图控制器无需知晓它需要根据何种场景进行配置。Coordinator 允许您在多个地方重用相同的视图控制器。您甚至可以将同一个视图控制器用作子视图控制器,而其所有者是另一个完全不同的视图控制器,这个功能是非常有用的。您不仅可以使用代理,还可以使用闭包,这取决于您拥有多少个触发器。

如果触发器很少的话,那么闭包可能是一个不错的选择。创建一个 Flow 控制器,然后对视图模型进行配置,配置是基于上下文环境的。假设我有一个图片选择器,然后我需要修改我的头像,那么我可能会在设置界面当中执行这项操作;我也可能需要发布一篇新的文章,然后这个文章是基于这张图片进行的。我想要使用同一段代码,并且这段代码我已经写好了,我不希望在里面使用 if 语句,只要我使用了 Flow Coordinator,那么我就不必这样做了。

我可以让其在 iPad 和 iPhone 上分别有不同的样式,甚至还可以使用某种限制。您可以在很多地方中重用同一段代码。它只是去监听视图模型或者视图控制器,这具体取决于您的架构事件,然后通过 Coordinator 来进行响应。

举个例子,如果您将其作为模型进行展示,那么就可以在 Flow Coordinator 当中进行配置,只需要展示即可,然后在闭包当中只有关闭或者保存这两个操作,您所要做的就是将这个页面关闭即可。这段操作是完全独立于视图模型或者视图控制器之外的,这是它非常方便的一个特性。

此外,由于它还允许您根据需要进行注入,因此这也是另一处便利所在。每个视图控制器只需要定义依赖或者视图模型即可,因此您只需要在那里创建依赖列表,然后当您读取文件的时候,也就是您的视图模型,您就会立即看到实际所使用的内容,因为您无需添加一个接收器,也无需将其传递给另一个对象,就能做到这一点;您只需要使用所关心的东西就行。这对文档编写而言也是非常棒的一个特性,它同时也简化了测试,因为如果没有手动检索的话,那么我就没办法确定是否在任何地方都使用这玩意儿。

视图模型并不负责进行视图展示,通常其当中并不包含 UIKit,您甚至也不需要使用 UIImage,很多时候您完全可以使用 URL 来作为标识符。虽然您也可以使用 UIImage,但是通常而言,在您的视图模型里面没有任何视图,也没有任何视图控制器出现。

从测试的角度来看,它被视为一个黑盒,这意味着您可以只添加一个非常简单的接口,然后就可以进行测试了。这是可重用的,因为相同的模式可以在多个地方使用。

ReSwift(35:24)

我们还是重点谈一谈 MVVM,因为这是我目前所遵循的方法,不过在行业内,有不少有趣的模式如雨后春笋般纷拥出现,其中一个模式让我非常激动,那就是 ReSwift。ReSwift 是一种实现了 Redux 功能的第三方库,它借用了后端或者 Web 开发的视角,并且是一个单向数据流的架构,这意味着这个库很容易理解。您只需要使用纯函数来修改状态,并且绝对没有比测试纯函数更简单的测试操作了。只需要确定输入和输出,这就是所需要测试的功能。

它只有一个单一的数据来源。使用 Redux 之后,您的应用当中只拥有唯一一种状态结构。我所喜欢的一点是,它具备惊人的可改造性,因为如果您使用了单向数据架构,那么您可以在数据流的任意一点上附加某个东西,从而执行修改或者其他功能。

那么假设您想要记录所有对状态进行修改的函数,那么对于 Redux 而言这是一个很简单的操作,因为您只需要在其中添加另一个函数,并让所有函数都通过它走一遍。您可以构建诸如代码重载之类的东西,从而可以将 Bug 重现。

想象一下,假设您发现在一个很少用的模块内出现了崩溃,正常情况下这个问题发生的几率非常小,那么我们假设只有 1% 的用户才会碰到这个问题,并且它只会在一些非常特殊的数据场景中才会发生,让我们假设服务器出现了通信延迟。

这是一个很严重的问题。我们需要知道该如何重现这个 Bug,由于所有东西都通过单一数据点和单一修改点,因此您实际上可以将所有修改状态的行为保存下来,而状态是所有问题的源头所在。一旦您将这些行为记录了下来,你就可以重现这些操作。如果发生了崩溃,那么您就可以将崩溃点之前发生的一切进行重启,然后连接到调试器,从而手动检查上一个数据点的情况,您就可以看到有某个调用被卡在了某个地方,这也正是导致崩溃发生的所在之处。我认为这种做法比打开崩溃报告要好用得多。

这种方法同样让您能够更好地进行远程工作,举个例子,假如您正在处理某个特定功能,您可以将这个功能应该出现的状态给加载出来,然后将其发给您的同事,这样他们就可以加载这个应用状态,这样就完成了迭代操作。

如果您对我其他工作有所了解的话,也就是这个 Playground,就知道我本人是非常喜欢代码注入的,我很喜欢实时编码,因此我希望能够用本地框架完成这一点,而不是让 Web 框架来执行。这种模式就能够很好地运作,因为一旦您能够加载状态,就意味着您可以重新编译项目,并对状态进行重载。

您可以实现实时编码,您可以在某个特定屏幕上动态创建视图和功能,针对特定场景这个功能无疑是非常令人震惊的。如果您以前没有尝试过代码注入的话,那么我强烈推荐您去尝试一下。我不会提高到 Playground 的层次,因为 Playground 的运行并不快,所以我说的只是实际应用那一层次。

代码注入工具(38:34)

我知道有两种可以进行代码注入的工具,其中最好的一个便是 Xcode Injection 插件。如果您安装了这个插件,那么您甚至可以在 Swift 文件当中,动态地进行重编译,甚至可以执行多次,尤其是在调试 UIKit 的时候,这能够节省您大量可观的时间。如果您只是打算修改某个设计的话,那么这个工具最适合不过了,借助这个工具我节省了上千美刀,我强烈推荐。

这就是我所期待的架构。我认为他们最近应该把 1.0 版本发布了,但是我并不是很有信心,因为稳定版确实很难实现。这是从 Web 开发的角度来看 MVC 的,这允许您直接使用 UIKit 来渲染视图,我觉得我们非常有必要使用这种编程模型。

现在也有很多使用该模式的库。也就是使用 Redux 的思路,并基于它构建的。

有一个名为 Render 的库,最近还在 iOS Dev Weekly 上出现过。它使用了功能性视图的思路,并且能够通过功能状态来绘制视图。当状态的数目变得越来越多,这种做法可能会变得愈加便捷。我并不是 Web 开发人员,我实际上并不喜欢 Web 开发,但是如果我能够重现 Bug,并且能够快速开发,将一些东西摒弃开来,从而只做我真正感兴趣的东西,而且还能做出类似的东西的话,而这种模式也能够很容易地允许这样做。并且这种模式也很容易进行测试。

结论(40:05)

最后,什么是优秀的架构,这的确是一个非常艰难的话题。万能药是不存在的。我不认为有某种架构能够解决所有问题,我觉得您应该根据从客户那里收集的需求和用例来考虑架构的事情。

我现在在新闻出版平台上工作,这个平台是基于 Reflection 之上建立的。这使得绝大多数模式都能得以应用其中。同样,我们通常不会创建模式来解决编程语言效率低下的问题。

例如,我同样也参与了另一个使用 Reflection 的项目,这是一个关于英超的应用,并且是基于小部件来构建的。用户可以看到各种不同类型信息的小部件:评论、视屏、得分,等等。架构的好处在于,它们可以利用 Objective-C 的动态特性,从而可以通过检查文件中的 Target 来启用和禁用某些功能。

如果我想要禁用某个功能,我只需要选中一组文件,然后取消 Target 的选择,然后编译项目,这样这些功能就没有了,反之亦然。如果我想要添加某个功能,我只需要添加几个类,并让其遵循某个特定的模式,然后将其编译后,这个新功能也能够使用了。我可以让 10 个开发者同时我的项目中进行开发,他们完全可以各自为战,而且无需碰触核心代码。

因此,根据需求,您的架构很可能会与我所使用的完全不同。现在,我默认会使用 MVVM 架构,而今后,ReSwift 或者其他架构可能会更加合适。编程的好处就在于我们每天都需要学习,两年后我的观点可能与现在大相径庭。如果不是这样的话,那么我就会担心是不是我学得东西还不够多。

我很喜欢进行架构设计,因为它能够很自然地让代码变得干净整洁,我不会试图去强迫自己去钻牛角尖。只需要遵循几个简单的原则,比如说使用 TDD、使用组合模式,仅仅只使用这两个原则,就使得我能够比十年前做得更好。

我想要让架构变得简单,并且正因为此,我也想让架构变得足够灵活。我想要遵循这样一句话「通俗易懂,蠢货」。我不希望里面出现很多抽象概念。

我一直记得这样一件事,就是当我在谈论过度抽象的时候,那时候我正在鼓捣游戏开发,我曾经参与了游戏引擎和框架的编写,我们有一部分人在编写一个 3D 游戏引擎,这个时候我和我的朋友对此有不同的做法。

我的朋友将所有的东西进行抽象化,它尽可能将所有可能的场景和配置列举出来。当他在编写碰撞的时候,他就会为所有的东西、所有的形状之间编写碰撞代码。他会将所有的这些东西全部实现。然后他把他的游戏引擎版本发布了出来,这里面有六到七个游戏演示,其中有一个游戏看起来与吃豆人非常相似,只不过是做成了 3D 的版本,之后他就被聘用了。

这个引擎是开源的,里面所有的东西都是开源的,因此他可以随意使用。他被一家制作儿童游戏的公司给聘用了,他为他们所做的游戏基本上就是一个吃豆人 3D 版,只不过主角不是吃豆人,而是一条鱼罢了。

从游戏的角度来看,或者说从图形的角度来看,这种创新可以说是微乎其微,两年之后,他就开始重新编写一个新的游戏引擎了。我认得觉得,所谓的灵活性,就是意味着简单易懂,而且功能演进也很简单,很多人都犯下了过度设计的错误,并试图一条路走到黑,不停的建立各种各样的抽象,这些人完完全全是走上了错误的方向。

显然,将某些东西进行抽象是有好处的,但是对于游戏而言这就需要斟酌再三了,我认为遵循某个可靠的原则便是一个好的开始。对于架构而言,这可能是最著名的模式,这样您就可以阅读到很多非常优秀的架构方面的文章,尽管这些文章也会非常复杂。

问答时间到(44:14)

问:假设我要采用带有 Flow 控制器的 MVVM,那么有没有带有源码的教程示例,并且有足够的解析能让人完全理解呢?

Krzysztof:我在 merowing.info 上写了一篇文章,您可以阅读这篇关于 Flow 控制器的文章,另一篇则是关于 Coordinator Redux 的。您只需要在 Google 当中输入标题,就能看到了。一篇是 “Improving Architecture, and it’s Flow Controllers”,另一篇是 “Coordinator Redux”。这两篇文章都例举了一些示例,正好是您所需要的。如果您有任何问题的话,您可以随时在 Twitter 上与我联系,我很乐意帮忙。我并没有很复杂的应用示例。或许我们可以将某个应用开源出来供大家学习。我可以去问下能否将我编写的面试挑战给开源出来,我给几家公司编写了这些问题。

问:当您在使用这些复杂模式的时候,有些时候执行 UI 变换和动画的时候会务必困难;那么对这些模式进行自定义的时候会很困难么?

Krzysztof:对于 Flow Coordinator 而言,它本质上是用来对视图进行配置的。它能够配置视图去使用某个特定的变换代理,因此这并不会比标准方法难。您可以使用它来配置不同的用例。假设您正在做一个非常漂亮的动画,由于即将出现的屏幕的某个部分已经出现在屏幕上了,所以您只是需要将一个小的图像变换为一个大的图像而已,您完全可以使用某种自定义动画让这个变换看起来更好看。在另一个场景中,您可能是需要从推送通知当中打开这篇文章,也就是展示在一个完全不同的屏幕当中,因此这里就没有很好看的动画了,因为这里无法使用锚点。在这种情况下,您不希望让其执行变换。借助 Flow Coordinator,您实际上可以完全做到这点,您不必要去创建一个单独的控制器,只需要将这个操作委托出去即可。

问:完全借助 Coordinator 的话,您的意思是就不使用 Storyboard 的 Segue 了?

Krzysztof:实际上是可以的,我以前就这样做过,在 Objective-C 中我修改了 Storyboard 的传递方式。我的 Segue 实际上是在 Flow Coordinator 下触发的,但是这有点黑科技了。Segue 一般不是这样子用得。我很喜欢使用 Interface Builder,我已经讨论了我所创建的行为概念,并且也大量使用它们,但是我认为在涉及到如何将其封装在架构当中的时候,Segue 就很容易导致糟糕的结果,所以我不会使用它们。尽管您可以使用 Objective-C 这样做,但是我仍然写了一篇文章,讲述如何在 Swift 中做到这一点。不过如果您真的想要使用 Segue 的话,那么仍然还有一种办法可以做到这一点,但是使用 Segue 并没有特别多的好处。如果您要使用 Segue 的话,比如说将表视图的单元格点击事件配置为一个 Segue,那么您就不能在别的地方复用这个表视图了,除非您要重复与之相同的设计,这对我而言,是一个极大的损失。

问:您觉得为什么 Apple 没有在 SDK 当中对这些模式进行改进呢?

MVC 模式是在 iOS 之前出现的,因为这个模式是在 80 年代推出的。这是一个非常老的模式了,它已经历经了时间的洗礼,并且使用 MVC 并不意味着您肯定会遇到非常糟糕的代码,因为如果您使用了组合模式和 TDD 方法,那么实际上您可以测试一大堆东西,然后用控制器来协调它们。这只是加大了难度而已,如果您想让 Apple 现在就去改变默认的模式,那么这无疑是个巨大的挑战。他们需要修改如此多的文档,其他所有的东西也都得跟着更新。

就这点而言,我觉得他们都会跟进社区所提出的意见的,他们实际上也接触了我以及其他几名开发人员,询问该如何改进 Interface Builder。他们也经常去倾听社区的意见,然后看到人们使用了各种各样的模式,但是对于 Apple 来说,使用 MVVM 就意味着他们需要给我们提供绑定机制,因为没有绑定机制的 MVVM 是完全无法使用的。这里可能会出现很多样板代码,我也不认为 Apple 会加入 FRP 的潮流,因为这无疑是非常复杂的。如果您是用了类似 Viper 之类的模式,您就很难向人们解释,因为它的学习曲线过于陡峭了。

MVC 则是一个很好的开始,当您开始学习编写 iOS 的时候,您可以使用 MVC 来编写出优秀的 iOS 应用,也不一定会让代码变得一团糟。只是这些年来,随着项目的不断扩张,如果您不使用更好的模式,或者不使用组合模式的话,那么代码最终通常会变得混乱。

我觉得这个问题只是理解度的问题。MVC 很容易理解,解释起来非常容易。它不一定是完全正确的,但是却是最简单的。MVVM 同样解释起来也很容易,但是它需要有一种非常好用的绑定机制,因为没有绑定机制的话,MVVM 使用起来也很复杂,会存在大量的样板代码。

很难向人们解释 Viper 的机制,尤其是对于新人而言,您可以看到在 Swift 当中,Apple 试图让越来越多的开发者参与其中,所以他们现在有了 Playground for iPad,这样孩子就可以学习,这个举动非常惊人,而且对此我也很高兴。

我认为应该让平台更容易理解。如果您有 Web 开发的经验的话,那么或许您可以直接转向 ReSwift,因为它里面的理念对您来说非常熟悉。之所以里面有很多熟悉的概念,因此我认为 MVC 对于不同经验、不同水平的人们而言,都是非常容易理解的。

很多 Apple 工程师都表示,这些例子并不意味着架构是非常优秀的,他们只是针对某项技术的特定示例而已,我们不应当遵循这些视图控制器的编写方式。我认为这正是区别所在,他们试图让开发更容易理解,而不是为我们提供这个或那个模式,让其更为灵活,大家迸发出的灵感也就更多。

谢谢大家。


Krzysztof Zabłocki

Krzysztof Zabłocki

Programmer that loves solving problems with code, always looking for new and interesting challenges. Prior to iOS he did games and graphics programming. He's worked with a lot of startups and big clients like: The New York Times, Headspace, Mashable, Unilever, Shell, The News International.

His work has received many awards including Apple Essential, Best of Year. He enjoys teaching and sharing his knowledge, which has led him to speak at over 20 conferences around the world and building libraries and tools used by thousands of developers, including immersive technologies like Playgrounds.