Slug andy controllers cover

一起来重构臃肿的 Controller 吧!

试想,你有这样一个臃肿并知晓任何事情的视图控制器,不知何时它的职责扩张到了要同时控制硬盘I/O读写以及决定导航栏样式。Andy Matuschak 将提出如何减少坏代码的规模,并且安全重构控制器职责的解决方案。一起来吧!


概述

大家好,我是 Andy Matuschak,今天我将向大家演示如何重构一个非常糟糕的视图控制器。我负责领导 可汗学院 的移动端工程开发,学院旨在提供免费的世界顶尖级别的教育,无论何人、何时以及何地。在此之前,我在苹果公司中研究了多年的 UIKit 框架,因此我觉得我有这个资格来玩一玩 UIViewController

这个初始的臃肿视图控制器以及重构的步骤都可以在 GitHub 上找到,因此请使用它来跟随接下来的教程。

糟糕的视图控制器

我建立了一个名为 MegaController 的测试应用,这是一个待办事项列表。你能够增加任务,完成任务之后就可以将任务删除。它可以根据必须完成任务的时间来对不同的任务进行分类。顶部导航栏的颜色取决于任务的紧迫程度:如果任务非常紧迫,那么导航栏颜色将是红色(即表示“紧迫Danger”),否则就可能是橙色(表示“注意Warning”)或是黄色(表示“正常Placid”)。这个应用确实非常简单。

这个视图控制器大致要实现四件事情,这对视图控制器来说是在太冗余了。

class ViewController: UITableViewController, NSFetchedResultsControllerDelegate, UIViewControllerTransitionDelegate, UITViewControllerAnimatedTransitioning

快速浏览一遍这个 糟糕的视图控制器类。这是目前这个类所要做的内容:

  • FetchedResultsController
  • Core Data 栈(stack)
  • 配置断言(predicate)以及执行检索
  • 控制 I/O 流
  • 展示视觉效果
  • 展示重要的转场效果
  • TableViewDelegate
  • TableViewDataSource
  • 获取表格节(section)的标题
  • 单元格的硬耦合(hard coupled)显示
  • 根据检索结果动态处理视图的变化
  • 为其他视图控制器自定义显示动画
  • 可恶的解耦合(unwind coupling)

现在让我们详细分析一下:

FetchedResultsController

这个视图控制器配置了 FetchedResultsController 以及其代理。它从文件系统读取数据,然后开启所有的 Core Data 事务。这是 Core Data 的模板,用来在这个视图控制器中启动 Core Data 栈。

ViewDidLoad

viewDidLoad() 中,我们配置了一个非常复杂的断言和检索请求。我们配置了 FetchedResultsController,然后接着让其执行检索。我们还执行了 IO,然后我们执行了某种非常巧妙但仍不清晰的缓存机制。我们还更新了导航栏以及状态栏,因此这个函数还负责展示视觉效果。它还包含了在 Section 中根据日期动态排序查询结果的代码。它使用任意选择的数字来定义了“尽快”以及“即将到来”这两个 Section。然后就是一个重要的用以展示变化的转场效果。它还定义了表视图的代理和数据源,还知晓 Section 的标题和其中的数据。此外它还知晓如何在 zero 和 now 之间建立关联。

Table View

//cellForRowAtIndexPath
switch numberOfCalendarDaysUntilTaskDueDate {
  case 0:
    description = "Today"
  case 1:
    description = "Tomorrow"
  default:
    description = "In \(numberOfCalendarDaysUntilTaskDueDate) days"
}

cell.detailTextLabel.text = description.lowerCaseString
return cell

这就是负责在单元格一侧的字符串中显示“ Today ”之类的方法。这行代码有一个问题:昨天创建的任务应该需要“ Yesterday ”,但是我们的代码并没有实现它,它只会显示“In negative one days”。这个代码同样也和单元格建立了强耦合关系。我们进入到了单元格中,然后设置详情文本标签的 text 属性。我们同样也设置了单元格上文本标签的 text 属性。如果我们改变了单元格显示该信息的方法,我们还必须对这段代码进行修改,因此我们的任务还包括了对其重构。

处理动态变化

我们另一个还需要处理的就是根据检索结果来进行动态变化。

func controller(controller: NSFetchedResultsController, didChangeObject anObject: NSManagedObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath? ) {
  switch type {
    case .Insert:
      // 向表视图插入任务
    case .Delete:
      // 从表视图移除
  }
}

FetchedResultsController 将会返回已插入的对象,或者告诉你它删除了一个对象,然后你再反过来告诉表视图这些信息。在这种情况下,由于这些动态 Section 的变化,这种做法显然更加复杂。FetchedResultsController 无法用来计算这些节的变化,因为它必须是管理对象(managed object)中的一个存储属性。它将获取 FetchedResultsController 所知晓的索引路径,然后计算“soon”或者”upcoming” Section 中被删除或者被插入单元格的索引路径。

导航栏以及添加任务

导航栏以及 preferredStatusBarStyle 都是视觉效果展示的一部分。同样,这还包括了 segue,因为当我们单击“plus”按钮的时候,将会展示一个自定义的动画。因此,我们有跳转到另一个视图控制器的自定义展示动画。这个视图控制器和需要展示的其他视图控制器建立了强耦合关系。

Receive news and updates from Realm straight to your inbox

可恶的解耦合

最后一项是 unwindFromAddController,这个东西特别令人发指。这是一个根据 ID 进行构建的 ID 动作。我们将 source feed 控制器从 segue 外提取出来,然后通过 AddViewController 将其强制下转。这对这个关系来说是强耦合部分。此外,我们直接从视图控制器的 UI 提取数据。我敢肯定在您的应用当中一定会存在这样的代码。我们同样也直接修改了视图 layer 层。因此,这个方法是一个模型、视图、控制器的结合体。

让我们解决它们

让我们来试着解决这些问题,并为他们编写测试。某些人喜欢使用 Mock 和 Stub 来测试任何东西,并且将可能会发生在视图控制器中的所有动作进行自动化测试。我觉得这种做法非常有意思,但是我对测试发生在这里的 实际逻辑 更感兴趣,因此我打算把重点放在这上面来。我并不打算测试对我来说看起来像 XML 的任何东西。视图控制器不是 XML,但是有些时候,视图控制器中并没有用来执行计算的方法。

主题化导航栏

我想出了一个有趣的 Swift 方法来重构导航栏以及状态栏风格,将他们在视图控制器中进行主题化(theming)。我们新枚举中的 NavigationTheme 将会是 PlacidWarning 以及 Danger

enum NavigationTheme {
  case Placid
  case Warning
  case Danger

  var barTintColor: UIColor? {
    switch self {
      case .Placid: return nil
      case .Warning return UIColor(red: 235/255, green: 156/255, blue: 77/255, alpha: 1.0)
      case .Danger return UIColor(red: 248/255, green: 73/255, blue: 68/255, alpha: 1.0)
    }
  }
}

在 Objective-C 中,这些枚举值或许只能是数字,但是这里我们能够做的东西更多:我们将可以设置导航栏的主题风格,比如说栏目文本颜色。这是一个简短的方法,由它将引申出很多东西。有这样一个有趣的逻辑部分用来设置导航栏主题,并且它切实知晓我们处于何种状况,是处于“dangerous”还是“placid”。我打算将其写作扩展,因为我想强调的是,这可能会在另一个完全不同的文件中使用。

extension NavigationTheme {
  init(forNumberOfUpcomingTasks numberOfUpcomingTasks: Int) {
    switch numberOfUpcomingTasks {
      case 0...3:
        self = .Placid
      case 4...9:
        self = .Warning
      default:
        self = .Danger
    }
  }
}

如果我们用 Java 来写的话,这很可能会写成一个工厂方法,不过我们并不打算用 Java。Self 表示 Placid,所以我更偏向于使用 Swift 风格来写这样一个自定义构造器。我在扩展中写下这段代码主要是想强调,这个类型本身的实现方法并不需要知道构造器的具体内容。如果导航栏主题类型中有什么私有信息的话,我仍然能够写出这样一个构造器出来。如果它想要变成导航栏主题的工厂方法的话,也可以用外部类型来写,但是在 Swift 中我们并不打算这样做。

我还没有实现其他样式,这是我故意的,因为我打算编写一个测试。我们已经可以编写一个有意义的测试:testNoTaskIsPlacid。这里我使用 Xcode 的单元测试框架,然后我打算用 Swift 2 中带来的新东西 @testable ,这样我就可以测试内部方法了。注意到,导航栏主题是内部类,通常情况下从另外一个模块中我无法对其进行测试,但是由于我们使用了 @testable ,因此我们可以对其进行引用。现在我要断言:如果我尝试建立一个没有任何任务的导航栏主题,我将得到 placid 导航栏主题。

//NavigationThemeTests.swift

@testable import MegaController
import XCTest

class   NavigationThemeTests: XCTestCase {
  func testNoTaskIsPlacid() {
    XCTAssertEqual(NavigationTheme(forNumberOfUpcomingTasks: 0), .Placid)
  }
}

我仍然还可以测试某些内含警告的任务,还可以大量测试位于“急迫”状态的任务,大家都心知肚明,这些测试都能轻易的通过。所以现在我将自信地回到我的导航栏主题中,获取在这被捕获的有趣数据。

我的架构师给我的说明书中说:“在这种情况下,你要淡定”,这也就是我在这里做的。我的测试现在在测试着区域逻辑的实现。我打算实现 NavigationTheme 中的剩余内容。从 Objective-C 中出来的 titleTextAttributes 着实是一个好用的东西。当我们处于 placid 状态的时候,我们并没有任何特殊的标题文本属性,但当我们位于 warning 或者 danger 状态的时候,文字将会变为白色,同样包括了我们设置的文本颜色。

enum NavigationTheme {
  case Placid
  case Warning
  case Danger

  var barTintColor: UIColor? {
    switch self {
      case .Placid: return nil
      case .Warning return UIColor(red: 235/255, green: 156/255, blue: 77/255, alpha: 1.0)
      case .Danger return UIColor(red: 248/255, green: 73/255, blue: 68/255, alpha: 1.0)
    }
  }

  var titleTextAttributes: [String: NSObject]? {
    switch self {
      case .Placid: return nil
      case .Warning, .Danger:
        return [NSForegroundColorAttributeName: UIColor.whiteColor()]
    }
  }

  var tintColor: UIColor? {
    switch self {
      case .Placid: return nil
      case .Warning, .Danger:
        return UIColor.whiteColor()
    }
  }

  var statusBarStyle: UIStatusBarStyle {
    switch self {
      case .Placid: return .Default
      case .Warning, .Danger: return .LightContent
    }
  }
}

当导航栏处于 Placid 状态的时候是没有设置文本颜色的。右上角的“添加”按钮打算使用系统默认的蓝色,但是当我们处于 Warning 或者 Danger 状态的时候,我们要使用白色,通过使用首选(Preferred) 状态栏风格会是比较有趣的小技巧。当我们处于 Placid 状态的时候,我们就使用系统默认的风格,而当处于 Danger 状态的时候,就使用 LightContent 风格。

现在我们再次拥有了类似 XML 风格的导航栏主题描述,因此我打算借此摆脱位于我视图控制器中此类代码的影响。我会将其提炼,然后移走,最重要的就是移到另一个不影响其他代码运行的地方。这个文件不会关心我程序中的其他东西,并且除非我访问它,否则这个文件永不会运行。现在,这东西就不像一个内嵌的视图控制器了,只是因为状态栏的实现发生了改变。这个导航栏主题并不会真实做出修改,这也是为什么我喜欢这种代码风格的原因——我能够轻易地理解这个文件,并且可以针对它做出很好的回应。

我必须将这个可怕的代码从我的视图控制器中移走。现在,这个控制器不再关注它是否位于一个导航控制器中。这个视图控制器将抵达其上级导航控制器,无论这个控制器是否可能会有其他内置视图控制器包含在其中,也无论知晓视图控制器是如何变化的,都可以改变导航栏的颜色。如果某些视图控制器打算改变导航栏的颜色,那么你可能就彻底完蛋了。因此这种设计从一开始就是糟糕透顶的。

状态栏风格

这里同样还可以使用首选状态栏样式,这个东西非常有意思,因为导航控制器通常不会请求其内含的控制器返回一个“首选状态栏样式”。我已经重载了导航控制器以便让其能够这样做。

//NavigationController.swift

class NavigationViewController: UINavigationViewController {
  override func childViewControllerForStatusBarStyle() ->
  UIViewController? {
    return topViewController
  }
}

要移除不恰当的代码,我们需要了解我在几年前的 WWDC 上讲过的原理。在这里我试着问自己以下这个问题:“导航栏颜色变化的原理是什么?什么才能最终决定导航栏颜色?谁应该知晓如何决定?”。我建议应该是这两者的父类。这两者的父类可以避免两者互相知晓,由于并没有特别的原因说明导航控制器应当知晓顶部视图控制器当中的标识符,因此导航控制器类不应当与我的视图控制器类建立强耦合关系,反之亦然。因此这意味着我们需要一种外部仲裁器(arbitrator),默认情况下就是应用代理(delegate)了。这看起来可能是个糟糕的主意,但是我会尽可能让其并不那么糟糕,最后我们再来一起比较它们的优劣。

为了让这个外部实体知晓原理,我需要用一个弱引用的方式来传递事件。我打算使用一个非常老套的方式:themeDidChangeHandler

var themeDidChangeHandler: ((NavigationTheme) -> Void)?
var theme: NavigationTheme {
    return NavigationTheme(forNumberOfUpcomingTasks: fetchedResultsController?.fertchedObjects?.count ?? 0)
}

当导航栏主题发生变化的时候,我就需要调用这个东西。此外,我同样还可以访问当前的主题以及标题栏的主题,这些东西非常易于计算,因为我所需要做的仅仅就是用 “Upcoming” 的任务数目来计算导航栏主题罢了,而 “Upcoming” 的任务数目则可以通过 FetchedResultsController 的检索结果对象数目得到,如果没有的话则会返回一个零,因为这是可选的。在更新导航栏的过程中,我所需要做的仅仅只是用我的主题调用 themeDidChangeHandler 即可。

//ViewController.swift
...

func updateNavigationBar() {
  themeDidChangeHandler?(themne)
  ...
}
...

这个代码无需知晓导航栏的具体实现,并且无需知晓如何改变导航栏的相关设置。我喜欢故事板(storyboard)的一件事就是它们可以简单的分离视图控制器。我先要展示的一件事就是单击这个“添加”按钮然后显示 AddViewController,这里的方法还没有实现,因此并没有 presentAddViewController 这样的方法。视图控制器嵌入在故事板中的标识符可以在添加按钮按下时实现跳转,这也是实现分离的一种方法。然而,我更想获取这些视图控制器实例的引用,不过这并不可能,目前这只是一种黑科技而已。我打算在这里做一些假设,比如说导航栏是视图控制器,并且我们还拥有一个 window。

//AppDelegate.swift
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  private var navigationController: NavigationController {
    return window!.rootViewController! as NavigationController
  }

  private var storyboard: UIStoryboard {
    return UIStoryboard(name: "Main", bundle: nil)
  }

  private lazy var viewController: ViewController = {
    return self.storyboard.instantiateViewControllerWithIndentifier("Primary") as! ViewController
  }()

}

要完成从故事板中将视图控制器实例进行分离,我们最后必须要创建这个 themeDidChangeHandler 方法以在启动时改变导航栏颜色。

//AppDelegate.swift
...
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject : AnyObject]?) -> Bool {
  viewController.themeDidChangeHandler = { [weak self] in
    if let navigationController = self?.navigationController {
      // 避免这样做
      navigationController.navigationBar.barTintColor = theme.barTintColor
    }
  }
  navigationController.viewControllers = [viewController]
  return true
}
...

因此,当主题发生变化时,themeDidChangeHandler 方法将会是运转代码的一部分。如果我们要弹出导航控制器,那么我们不得不这样做,你上面所看到的就是我不想做的。navigationbar.barTintColor = theme.barTintColor 并不是个好主意。这东西将会出现四次,并且根本不像 XML 的格式。为了避免这样,我打算建立一个主题。我的方法就是简单的 分解。我打算让这个东西能够将主题应用给导航栏:

//UINavigationBar-Theme.swift

extension UINavigationBar {
  func applyTheme(theme: NavigationTheme) {
    barTintColor = theme.barTintColor
    tintColor = theme.tintColor
    titleTextAttributes = theme.titleTextAttributes
  }
}

//AppDelegate.swift
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject : AnyObject]?) -> Bool {
  viewController.themeDidChangeHandler = { [weak self] in
    if let navigationController = self?.navigationController {
      navigationController.navigationBar.applyTheme(theme)
    }
  }
  navigationController.viewControllers = [viewController]
  return true
}

这个文件完全隔绝了任何东西(除了 UIKit 中的)。除非所有人都开始采用 React Native,否则我并不能彻底摈弃 UIKit。我希望我们现在拥有了正确的栏目颜色和主题。

我们得到了黄色,但是状态栏颜色却错了。我们打算做的就是使用一个黑科技,让导航控制器知晓,它的顶视图控制器应该要负责选择合适的状态栏风格。顶部视图控制器同样要负责调用我设置的状态栏外观更新方法,而无论其父类视图是否打算让状态栏进行改变。我们将移除这段代码,然后使用数据驱动(data-driven)来代替。

//NavigationController.swift

class NavigationViewController: UINavigationViewController {
  var statusBarStyle: UIStatusBarStyle = .Default  {
    didSet {setNeedsStausBarAppreanceUpdate()}
  }

  override func preferredStatusBarStyle -> UIStatusBarStyle {
    return statusBarStyle
  }
}

//AppDelegate.swift
...
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject : AnyObject]?) -> Bool {
  viewController.themeDidChangeHandler = { [weak self] in
    if let navigationController = self?.navigationController {
      navigationController.navigationBar.applyTheme(theme)
      navigationController.statusBarStyle = theme.statusBarStyle
    }
  }
  navigationController.viewControllers = [viewController]
  return true
}

我喜欢我们进行的改动,因为现在原理已经十分清晰了。没有人愿意花大量时间来实现导航控制器状态栏风格,同样也包括栏目文本。相反,我们拥有了十分直接的数据流和控制,并且我们让它们以树形方式来实现,这也是我最喜欢的形状。我认为,控制流、控制权以及数据流通过这个树上流或下流的方式是非常易于理解的。现在我们还需要做的就是视图控制器糟糕的列表,因此我们可以对其下手。

分离数据源

让我们尝试在这里对数据源情况进行一些变化。我打算将所有 Croe Data 的内容放到另一个文件当中,并且我打算称之为 Core Data 栈。

//CoreDataStack.swift

import CoreData
import Foundation

class CoreDataStack {
  lazy var applicationDocumentsDirectory: NSURL = {
    let urls = NSFileManager.defaultManager().URLsforDirectory(.DocumentDirectory, inDomains: .UserDomainMask)
    return urls[urls.count-1]
  }()

  lazy var managedObjectModel: NSManagedObjectModel = {
    let modelURK = NSBundle.mainBundle().URLForResource("MegaController", withExtension: "momd")!
    return NSManagedObjectModel(contentsOfURL: modelURL)!
  }()

  lazy var persistentStoreCoordinator: NSPersistentStoreCoordinator = {
    let coordinator = NSPersistentStoreCoordinator(managedObjectModel: self.managedObjectModel)
    let url = self.applicationDocumentsDirectory.URLByAppendingPathComponent("SingleViewCoreData.sqlite")
    do {
      try coordinator.addPersistentStoreWithType(NSSQLiteStoreType, configuration: nil, URL: url, options: nil)
    }catch{
      fatalError("Couldn't load database: \(error)")
    }

    return coordinator
  }()

  lazy var managedObjectContext: NSManagedObjectContext = {
    let coordinator = self.persistentStoreCoordinator
    var managedObjectContext = NSManagedObjectContext(concurrencyType: .MainQueueConcurrencyType)
    managedObjectContext.persistentStoreCoordinator = coordinator
    return managedObjectContext
  }()
}

我不打算止步于此,不仅仅只让客户端去访问管理对象上下文(managed object context)。如果我想对其进行测试,那么我可能就没法执行内存中存储(in-memory)了。我们可以逐步对其进行优化,这里我打算进行重构的就是把东西分块,每次我们把代码移走之后,这个文件就会变得更优化,并且易于理解。当我们将 Core Data 栈移走之后,现在我们就可以在 ViewController 中解决某些问题了。

//ViewController.swift

...
  private let coreDataStack = CoreDataStack()
...
  fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: coreDataStack.managedObjectContext, sectionNameKeyPath: nil, cacheName: nil)
...
// 用 coreDataStack.managedObjectContext 替换所有的 managedObjectContext

在这个文件中我们并不想留下 Core Data 栈的任何痕迹,不过我们也并不打算将其全部移走。相反,我所做的是渐进式改进。曾经这里有四个任何人都可访问的可用变量,但现在只有一个可用了。我可以更严格地控制初始化,如果照着这个思路走的话,下一步我打算进行修改的就是将这个方式应用到类当中。这同时也意味着我可以从这个类中移除强引用的实例,这种做法非常优美,因为对我来说,如果我打算在项目中创建另一个视图控制器,并在里面添加 Core Data 栈的话,所做的一切工作都十分自然。出于这点考虑,这段在视图控制器中的代码是完全不适合的。

然而,在这个文件中我们仍旧还有海量的 Core Data 遗留物。在这里我想谴责一下 Core Data,因为我觉得危机原则(principle at stake)比起在这看我为大家展示转换更有用。这种遗留物深深融入了 Core Data 的骨子里,其原因不是人们通常所争论的 Core Data 的专门设计模式,比如说“ API 服务实在太冗余了”,或者“所有的这些方法太复杂了,容易出问题”。根本上来说,在 Core Data 中到处都存在着一种叫“共享可变区域(shared mutable state)”的东西。比如说有一个 NSManagedObject 对象,这个对象在创建并管理它的上下文中拥有一个引用。这个引用是可变的,因为如���其余对象能够从管理对象中获取到该引用的话,那么这个引用就能够在你那里变更,这意味着在传递过程中其余对象能够获取到该管理对象所在的上下文引用。如果你不够小心的话,一切都有可能发生,但是我们不想这么费心。对我来说,使用 Core Data 以及一切 ORM 数据库真的是一件烦躁的事。

自然而然,我下一步操作就是逐步从这个视图控制器中移除所有 Core Data 的信息。我会快点阐述我是如何做的,因为我对其已经了然于心。在这个类当中,我们可以提取一个名为 FetchedResultsController 的类出来,它和 Core Data 建立引用。这样就移除了 Core Data 栈,用断言和检索请求代替,所以说它和 Core Data 建立了引用。IO、保存以及位存储操作都在这个类当中完成。并且,根据检索结果动态处理改变也在这里进行。我会一个接一个地提取这些任务,并且在所有这些任务的最底层进行,执行提取的是 NSManagedObject 本身,因为 NSManagedObject 建立了与之关联的任何事物的依赖关系。我会一直提取这些东西,直到我拥有能够展示的必要数据。当我打算创建一个任务的时候,我所需要做的就是提供任务创建所必需的数据即可。这就是你应当如何创建 Core Data 对象的方法:获取到管理对象上下文,持久化存储协调器、管理对象模型、这个任务对应的实体,然后向指定的上下文中插入数据。

let newTask = NSManagedObject(entity: coreDataStack.managedObjectContext.persistentStoreCoordinator!.managedObjectModel.entitiesByName["Task"]!, insertIntoManagedObjectContent: coreDataStack.managedObjectContext)
newTask.setValue(addViewController.textField.text, forKey "title")
newTask.setValue(addViewController.datePicker.text, forKey "dueDate")
try! coreDataStack.managedObjectContext.save()

这东西可以进一步优化,你可以为其创建一个继承自 NSManagedObject 的类。NSManagedObject 子类将所有涉及到的东西相互关联,不过这仅仅只是将其隐藏并且隐藏的并不是很好。

我创建了一个名为 DataManager的类 ,就管理对象而言它仍旧有些冗余,但是它所做的太少了。让我们快速查看一下当这个数据管理器存在后的视图控制器的状态吧。FetchedResultsController 不见了,因为我们将其移到了数据管理器当中,最重要的一点是现在它开始看起来很像 XML 了。我仍不喜欢这个视图控制器虽然是表视图代理以及数据源协议,但其协议实现并没有做任何事情。它们并不对多数数据进行编码。我们有其他对 NSFetchResultsController 的某些变化方法版本更友好的方法,我让它们更具体一些。你会注意到这些一点都没涉及到 NSManagedObject。由于我们是在 cellForRowAtIndexPath 中更新单元格,这些仍然和 NSManagedObject 有依赖。

要移除这个东西还有一步要做。UpcomingTaskDataManager 公开了两个属性,其中一个是需要关注的。这只是我用于主题上的一个合计。它暴露了某些是管理对象列表中的列表的任务部分。并且这些任务部分的数组会时常更新。这并不是真正意义上的存储:这不是管理对象数组的数组的数组。实际上它是从结果缓存中得来的,是一个结构体。使用结构体的原因是只需要一个步骤就可以将其和所有东西隔离开来。其没有被隔离的唯一途径就是让其依赖于 NSManagedObject

我们可以看一下其依赖于 NSManagedObject 的方式,尤其关注一下它需要从 NSManagedObject 中获得的东西。它从 NSManagedObject 所获得的所有东西都必须能够根据日期提取出来。这可能是根据对应日期而响应的列表的列表,并且这个类仍然会进行编译。和我们之前对导航栏主题进行重构后的理由一样,此时所有这些代码变得更容易理解。只有当直接父类告知其需要运行时,这段代码才会运行,这是不可变的并且是完全隔离的。还有另一个有趣的原理需要知道,我知道这似乎听起来不可思议,不过确实是“不可变”的。持有实例的变量位置是可变的,但是它本身的值是不可变的。因此,我可以以安全的方式来将缓存存储到文件当中,我也可以将其用于测试当中,也可以通过网络将其发送出去,等等。这些东西突然变得可以互相交换了。

所有这些东西都有值,这也使得测试很容易写。这样转化十分简单,提取 NSManagedObject 出来,然后我的视图控制器将不受 NSManagedObject 的干涉,一切都变得十分整洁。我们接下来要做的只剩下几件事情了。

从视图控制器中分离表视图

我会简单说说我是如何将这部分提取出来的,即配置单元格、建立视图控制器与表视图单元格之间的耦合关系。有一个名为 ViewModels 的设计模式,我认为这个方式十分正确。因此这个设计模式工作的方式就是给一个称之为 ViewModel 的东西添加对你视图的引用,这个 ViewModel 拥有视图所需的全部数据。这个ViewModel 同样也实现了这个视图中所有的动作。我不认为这是一个好主意,因为它们是活跃的(not inert),并且可以从多个方面对其进行操控。如果我们仅需要执行第一个部分,我们可以使用我称之为 ViewData 的东西:

class MyCell {
  struct ViewData {
    let title: String
    let timeSummary: String
    init(task: Task) {

    }
  }

  var data: ViewData {
    didSet {
      //set values of views
    }
  }
}

我打算在这里写一个内联类。我们可能会写一个 MyCell,MyCell拥有一个ViewData,这是它所需要呈现的数据。它拥有一个标题,并且有一个timeSummary——比如说“距离现在还有一天”之类的,因此这些值都是完全不活跃的。我并不会传递一个 NSManagedObject进去,也不会传递一个能做任何事情的东西进去,我只是传递一个字符串结构体进去。我能将这些数据通过为我的 ViewData 结构体而创建一个构造器中移走,无论它是否是从我的数据管理器当中出来的。现在,我们使用的仍然是一个管理对象,因此我可以从管理对象中初始化我的 ViewData,或者如果是某些值类型的话,我也可以通过它来进行初始化。现在这个构造器可以对与我特定相关的 DateTime Formatter 建立了依赖,这个我在 Sketch 中完成了,并没有给大家展示。如你所期望的,这里算出了字符串所应用的样子。再次说明,这是完全隔离的。

这就是析取逻辑所应有的样子。这个单元格拥有一个对其 ViewData 的 setter 方法,并且所有的 didSet 都必须更新视图的值。视图控制器无需直到单元格中的视图。我可以针对单元格的样式来进行改变,而无需改变视图控制器。我可以改变我的模型,我可以看一看任务 TextField ,或者我也可以改变新任务名字的 Textfield 而无需改变实现方法。


阅读源代码,并且通过从GitHub 上的 repo 阅读提交历史来了解更多关于重构的步骤。


问答环节

问:你对向硬盘中保存结构体有什么看法吗?比如说 NSKeyedArchiver 需要 NSCoding 以及 NSCopying 之类的协议。

Andy: 我只是做了序列化一个 Bookmark 数据库做该做的事情。我所做的就是创建一个串行结构体,由于它不做任何事情因此数据库允许存储结构体。这个结构体会返回数据,并且将会收纳一些 Bookmark,不过这也就是它的唯一功能。它根本不关心 Bookmark 中拥有何种数据,因为它根本不需要。它执行了属性列表序列化,并且通过对 Bookmark 建立映射进行编码。这就是你问题的答案。

问: 当你从故事板中实例化一个控制器的时候,故事板将会传递给你一个实例,因此移除通过子控制器实例化根控制器的权利,以确保实例化正确进行。这个方法会不会更好些?

Andy: 据我所知,除非不使用故事板,否则这个方法并不更优。要是你把你的整个应用描绘成一个枚举的话会怎样?这的确是个好方法。并且,你可以让导航控制器遵循此原则,而不是遵从 UINavigationController 的原则。通过这种方法就可以实例化视图控制器了。尽管 UIKit 已经尽可能帮你实现这种想法了,不过还是谨慎行事。

问: 你提到了 React Native — 为什么你说将其作为主要的 UI 框架阻碍了你的工作呢?

Andy: JavaScript 可能代表着未来的潮流,但不幸的是它同样也很糟糕。据我所知,我们是第一个采用 Swift 的大型项目。我们已经写了上万行 Swift 代码,并且我们对其又爱又恨。我们并不是一个真正的科技公司,可汗学院只是一个很小的非营利性组织,只有几个工程师,我们的问题并不是技术问题。由于我们是第一个吃螃蟹的人,出于这种经验,我变得比较保守,不想做 React Native 的小白鼠。

另一件事情就是 React Native 的 Android 支持目前还未公开。这样一来,即使我们想采用 React Native,也不会带来太大的好处。当我们打算采用的时候(我认为我们很有可能会采用),我们不回使用普通的 JavaScript,而是使用 Flow,这样就可以有类型检查、协程(coroutines)等诸如此类的工具,让体验更加惬意。

问: 你重构了很多视图控制器。你有没有什么关于何时应该停止提取的建议?

Andy: 这的确是一个很大的问题。你知道我们是怎样称呼“Spaghetti 代码”的反义词的吗?我们称之为“Ravioli 代码”。这是个很有趣的对比,因为“均衡耦合(balances coupling)”有个术语称之为“内聚(cohesion)”。耦合意味着你不希望在相同的类型中有太多不同的东西。而“内聚”意味着你不希望有太多本质上是相同的东西分布在此处。

有个很好的例子是关于文档字符串处理的。我打算告诉你这些天我们是如何处理的,因为这确实给我们带来了困扰。我们在我们的文档字符串中协议一系列的 EG。比如说,我们写了“并这个东西所使用的东西用在 UI 的一部分上”,然后耦合并不是真的耦合,这只是文本耦合。就和地图一样,无论你看哪一个部分,你都能够找到指引,然后可以前往附近的部分,然后接下来你往往就可以找到一条路了。虽然这是一个严重的问题,但是这很难找到平衡点。

About the content

This content has been published here with the express permission of the author.

Andy Matuschak

Andy is the lead mobile developer at Khan Academy, where he’s creating an educational content platform that’s interactive, personalized, and discovery-based. Andy has been making things with Cocoa for 12 years—most recently, iOS 4.1–8 on the UIKit team at Apple. More broadly, he’s interested in software architecture, computer graphics, epistemology, and interactive design. Andy and his boundless idealism live in San Francisco, primarily powered by pastries.

4 design patterns for a RESTless mobile integration »

close