这篇文章来自阅读Let’s Play: Refactor the Mega Controller!
在该文中,作者阐述了如何使用Swift重构一个臭名昭著的Massive View Controller。从中,我们可以一窥Swift诸多优秀的特性以及如何利用这些特性将ViewController的职责进行解耦。
但是作者由于时间有限,并没有讲述完全,因此本文是我阅读源码后的理解。
建议大家在阅读本文之前,能够先去看看链接中的视频。
Let’s get started
首先我们下载源码,可以看到如下文件:
- NavigationController.swift
- ViewController.swift
- AddViewController.swift
其中,ViewController.swift
是项目的核心,代码行数超过246行。在这里我要强调一下,并不是代码行数多不好,而是要看你这个职责是不是相关。如果246行都是在实现一个数据结构或者算法,当然可行。但是如果246行里面包含了逻辑业务、网络请求、数据持久化,那必然是可以分离一部分职责出去。
在本文的ViewController.swift
,这个类在初始状态下包含了UITableViewDataSource
, UITableViewDelegate
, UIViewControllerTransitioningDelegate
, UIViewControllerAnimatedTransitioning
, NSFetchedResultsController
以及一系类跟UI显示相关的代码。
1. 干掉UINavigationBar相关的内容
作者在app中构建了可变化的NavigationBar,因此bar的样式是根据不同状态进行改变的。原来的逻辑整体写在了ViewController.swift
中,如下所示:
func updateNavigationBar() {
switch fetchedResultsController!.fetchedObjects!.count {
case 0...3:
navigationController!.navigationBar.barTintColor = nil
navigationController!.navigationBar.titleTextAttributes = nil
navigationController!.navigationBar.tintColor = nil
case 4...9:
navigationController!.navigationBar.barTintColor = UIColor(red: 235/255, green: 156/255, blue: 77/255, alpha: 1.0)
navigationController!.navigationBar.titleTextAttributes = [NSForegroundColorAttributeName: UIColor.whiteColor()]
navigationController!.navigationBar.tintColor = UIColor.whiteColor()
default:
navigationController!.navigationBar.barTintColor = UIColor(red: 248/255, green: 73/255, blue: 68/255, alpha: 1.0)
navigationController!.navigationBar.titleTextAttributes = [NSForegroundColorAttributeName: UIColor.whiteColor()]
navigationController!.navigationBar.tintColor = UIColor.whiteColor()
}
}
override func preferredStatusBarStyle() -> UIStatusBarStyle {
switch fetchedResultsController?.fetchedObjects!.count {
case .Some(0...3), .None:
return .Default
case .Some(_):
return .LightContent
}
}
同时,还在几个事件回调的地方,如Core Data的controllerDidChange
,调用了setNeedsStatusBarAppearanceUpdate()
。
这个想法粗略想想并没什么问题,因为我们需要根据一系列的事件变化来改变我们的界面样式,这是很明显的业务逻辑。而我们都很清楚,ViewController就是用来写业务逻辑的地方。
先抛开ViewController是否是应该写业务逻辑的地方这一个有待商榷的论点之外,我们先看看,我们可以如何重构现有代码。
首先,updateNavigationBar
中多个case中的代码有了重复,因此我们可以将其重构成一个函数,接受三个关于样式的参数,如下:
func applyTheme(barTintColor:newBarTintColor, tintColor:newTintColor, titleTextAttributes:newTextAttributes) {
barTintColor = barTintColor:newBarTintColor
tintColor = tintColor:newTintColor
titleTextAttributes = titleTextAttributes:newTextAttributes
}
重构完函数以后,我们发现在多个样式中用到了switch case进行业务逻辑参数转换样式参数的过程。这说明什么,我们可以将转换逻辑和switch case一起通过Enum进行重构(这里说的东西都是基于你懂Enum)
enum NavigationTheme {
case Normal
case Warning
case Doomed
var statusBarStyle: UIStatusBarStyle {
switch self {
case .Normal: return .Default
case .Warning, .Doomed: return .LightContent
}
}
var barTintColor: UIColor? {
switch self {
case .Normal:
return nil
case .Warning:
return UIColor(red: 235/255, green: 156/255, blue: 77/255, alpha: 1.0)
case .Doomed:
return UIColor(red: 248/255, green: 73/255, blue: 68/255, alpha: 1.0)
}
}
var titleTextAttributes: [String: NSObject]? {
switch self {
case .Normal:
return nil
case .Warning, .Doomed:
return [NSForegroundColorAttributeName: UIColor.whiteColor()]
}
}
var tintColor: UIColor? {
switch self {
case .Normal:
return nil
case .Warning, .Doomed:
return UIColor.whiteColor()
}
}
}
extension NavigationTheme {
init(numberOfImminentTasks: Int) {
switch numberOfImminentTasks {
case -Int.max ... 3:
self = .Normal
case 4...9:
self = .Warning
default:
self = .Doomed
}
}
}
由于Enum在swift中是一等公民,因此可以可以在其中构建大量的Computed Properties,这些计算变量依赖于当前enum的状态。不仅如此,我们还将之前分散的三种样式,组合成了一个紧凑的结构体,大大简化了变量传输。
重构结束后,我们在Viewcontroller.swift
中设置一个计算变量navigationTheme
,其的构造函数是之前的fetchedResultsController?.fetchedObjects?.count
。
最后是在相应的事件后触发更新UINavigationBar即可,在本文的视线中,是采用了closure的形式完成:
navigationThemeDidChangeHandler = { [weak self] theme in
if let navigationController = self?.navigationController {
navigationController.navigationBar.applyTheme(theme)
navigationController.statusBarStyle = theme.statusBarStyle
}
}
2. 干掉时间相关的转换逻辑
相信很多人做app的时候遇到过,服务器返回的是一系列标准时间参数,而你需要将其转换成界面需要的生日、星座、年龄等等,这又是一大堆的业务逻辑。为了解决这种逻辑代码和ViewController的耦合,很多人提出了ViewModel
,将部分弱业务逻辑代码剥离出来,单独写在一个地方。
但是,我需要强调一点,这种形式的剥离,并不能叫ViewModal,而是一个简单的adapter而已。
在本文中,列表Cell里面需要根据日期距离当前时间的差距显示成昨天、今天、明天等等。因此,其构建了一个单独的的DateFormatter,根据传入的两个Date进行转换,代码如下:
struct RelativeTimeDateFormatter {
let calendar: NSCalendar
init(calendar: NSCalendar = NSCalendar.autoupdatingCurrentCalendar()) {
self.calendar = calendar
}
func stringForDate(date: NSDate, relativeToDate baseDate: NSDate) -> String {
var beginningOfDate: NSDate? = nil
var beginningOfBaseDate: NSDate? = nil
calendar.rangeOfUnit(.Day, startDate: &beginningOfDate, interval: nil, forDate: date)
calendar.rangeOfUnit(.Day, startDate: &beginningOfBaseDate, interval: nil, forDate: baseDate)
let numberOfCalendarDaysBetweenDates = calendar.components(NSCalendarUnit.Day, fromDate: beginningOfBaseDate!, toDate: beginningOfDate!, options: NSCalendarOptions()).day
switch numberOfCalendarDaysBetweenDates {
case -Int.max ... -2:
return "\(abs(numberOfCalendarDaysBetweenDates)) days ago"
case -1:
return "Yesterday"
case 0:
return "Today"
case 1:
return "Tomorrow"
default:
return "In \(numberOfCalendarDaysBetweenDates) days"
}
}
}
这里需要注意的是,NSCalendar的初始化非常耗时,过去在Objective-C时代常常使用dispatch_once构建单例传输,在这里通过结构体中的成员变量维护了一份,作用是同样的。
3. 干掉NSPredicate
对于NSPredicate,有些人可能还不熟悉,他就是类似于SQLite中的查询语句,只不过其应用范围是CoreData。咦,查询语句还能重构?
其实在本文中,对于NSPredicate的使用只有原先这一句 fetchRequest.predicate = NSPredicate(format: "dueDate <= %@", argumentArray: [NSCalendar.currentCalendar().dateByAddingUnit(.Day, value: 10, toDate: NSDate(), options: NSCalendarOptions())!])
这段代码从重复性上来说是不需要重构的。但是,我们可以看到,在这里的构造参数里面,我们还是进行了一定的业务逻辑转换。所以,和DateFormatter一样,我们也可以将这部分所谓为的”弱业务逻辑”代码进行剥离:
extension NSPredicate {
convenience init(forTasksWithinNumberOfDays numberOfDays: Int, ofDate date: NSDate, calendar: NSCalendar = NSCalendar.currentCalendar()) {
self.init(format: "dueDate <= %@", argumentArray: [calendar.dateByAddingUnit(.Day, value: numberOfDays, toDate: date, options: NSCalendarOptions())!])
}
}
除了业务逻辑剥离之外,其实我们也可以看到,在这个NSPredicate的新构造参数,可以接受一个calendar
,这对于测试用例编写的依赖注入是非常有好处的。
4. Core Data Stack
用过Core Data的人都知道,Core Data的使用非常麻烦,需要配置大量的选项,照着苹果源码写的经历相信大家都有过,那恶心的200-300行配置代码,真是么么哒了。
但是,这几百行代码又是无法省略的,那该怎么办呢?
一个比较好的解决方案就Core Data Stack
。意为将CoreData的初始化以及多个NSManagerObjectContext封装进CoreDataStack
维护。
在本文中,因为只是使用了一个主线程的NSManagerObjectContext,所以可能读者在阅读源码的时候可能觉得这个重构只是将CoreData配置从View剥离了。但是实际上,使用CoreDataStack可以做到更多,建议大家阅读Github上相关项目。
5. 干掉NSFetchedResultsControllerDelegate
NSFetchedResultsController大家可以简单理解为获取CoreData数据的一个中介层。根据传输进入的谓语NSPredicate进行查询,查询结束后通过相应的Delegate事件回调。
在作者的代码中,作者通过构建manager
的方式剥离了NSFetchedResultsController的职责,将NSFetchedResultsController的初始化、回调封装进了UpcomingTaskDataManager.swift
中。
不过值得注意的一点是,尽管作者封装的NSFetchedResultsControllerDelegate的回调,但是为了让调用者可以自定义处理事件,实际上作者还是需要暴露一些的Delegate,当然,新的回调相对来说进行了一定的简化,同时在数据回调时经过了业务转化。
protocol UpcomingTaskDataManagerDelegate {
func dataManagerWillChangeContent(dataManager: UpcomingTaskDataManager)
func dataManagerDidChangeContent(dataManager: UpcomingTaskDataManager)
func dataManager(dataManager: UpcomingTaskDataManager, didInsertRowAtIndexPath indexPath: NSIndexPath)
func dataManager(dataManager: UpcomingTaskDataManager, didDeleteRowAtIndexPath indexPath: NSIndexPath)
}
6. CoreDataModel <=> Model
这一步是将从Core Data中获取的NSManagedObject Model 转换成业务中使用的Model。为什么要这么做呢?原因有三个:
- CoreData中的属性一更改,就会触发NSFetchedResultsController,这会很影响性能。
- CoreData中的属性存在很多bug
- NSManagedObject不是一个struct类型,很有可能误伤
import CoreData import Foundation struct Task: Equatable { var id: String var title: String var dueDate: NSDate } func ==(lhs: Task, rhs: Task) -> Bool { return lhs.id == rhs.id && lhs.title == rhs.title && lhs.dueDate == rhs.dueDate } extension Task { init(managedTask: NSManagedObject) { self.id = managedTask.valueForKey("id") as! String self.title = managedTask.valueForKey("title") as! String self.dueDate = managedTask.valueForKey("dueDate") as! NSDate } }
作者用以上的Task类型替换了CoreData中的ManagedObject,可以有效的避免以上问题。
7.封装数据结构
在这一步里,我将作者自定义TaskTableViewCell
和构建AddCompletionSegue
合并到了一块说。
这两步的重构,看似简单,但是其实也蕴含了一个思想:类型越确定,编程越容易,运行越安全
在原文的实现,一开始作者都是通过采用基础的数据结构UITableViewCell和UISegue。这样带来的坏处就是类型不明确导致的职责不明确。对于基础的数据结构,我们常常还要进行类型判断和转换,容易犯错。
8.干掉UITableViewDelegate和UITableViewDataSource
这一步想必大家都很熟悉了,微博上整天热传了用ViewModel重构你的ViewController经常提及的就是干掉UITableViewDelegate和UITableViewDataSource。
那说了那么多,我们来看看究竟如何干掉它。
毫无以为,我们首先要构建一个类型,来实现UITableViewDelegate和DataSource,如下所示:
// 1.
class UpcomingTaskDataManagerTableViewAdapter<CellType: UITableViewCell>: NSObject, UITableViewDataSource, UpcomingTaskDataManagerDelegate {
private let tableView: UITableView
private let upcomingTaskDataManager: UpcomingTaskDataManager
private let cellReuseIdentifier: String
private let cellConfigurationHandler: (CellType, Task) -> ()
private let didChangeHandler: () -> Void
// .2
init(tableView: UITableView, upcomingTaskDataManager: UpcomingTaskDataManager, cellReuseIdentifier: String, cellConfigurationHandler: (CellType, Task) -> (), didChangeHandler: () -> Void) {
self.tableView = tableView
self.upcomingTaskDataManager = upcomingTaskDataManager
self.cellReuseIdentifier = cellReuseIdentifier
self.cellConfigurationHandler = cellConfigurationHandler
self.didChangeHandler = didChangeHandler
super.init()
}
// 3.
func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
upcomingTaskDataManager.deleteTask(upcomingTaskDataManager.taskSections[indexPath.section].items[indexPath.row])
}
func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return upcomingTaskDataManager.taskSections.count
}
func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return upcomingTaskDataManager.taskSections[section].title
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return upcomingTaskDataManager.taskSections[section].items.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let task = upcomingTaskDataManager.taskSections[indexPath.section].items[indexPath.row]
let cell = tableView.dequeueReusableCellWithIdentifier(cellReuseIdentifier, forIndexPath: indexPath) as! CellType
cellConfigurationHandler(cell, task)
return cell
}
- 这个
UpcomingTaskDataManagerTableViewAdapter
通过传入一个CellType支持泛型。 - 通过接受几个closure来进行自定义的配置,包括cell的样式配置以及tableview数据更新后的回调。
- 实现的UITableViewDataSource
同样,由于职责的重新分配,我们要将跟TaskManager
(包括NSFetchedResultsController)相关的划入到这个adapter中。
大结局
最后重构后的ViewController,只有37代码,效果如下:
class ViewController: UITableViewController {
var navigationThemeDidChangeHandler: ((NavigationTheme) -> Void)?
var navigationTheme: NavigationTheme {
return NavigationTheme(numberOfImminentTasks: upcomingTaskDataManager.totalNumberOfTasks)
}
private let upcomingTaskDataManager = UpcomingTaskDataManager()
private var upcomingTaskDataManagerTableViewAdapter: UpcomingTaskDataManagerTableViewAdapter<TaskTableViewCell>!
override func viewDidLoad() {
super.viewDidLoad()
upcomingTaskDataManagerTableViewAdapter = UpcomingTaskDataManagerTableViewAdapter(
tableView: tableView,
upcomingTaskDataManager: upcomingTaskDataManager,
cellReuseIdentifier: "Cell",
cellConfigurationHandler: { cell, task in
cell.viewData = TaskTableViewCell.ViewData(task: task, relativeToDate: NSDate())
},
didChangeHandler: { [weak self] in self?.updateNavigationBar() }
)
upcomingTaskDataManager.delegate = upcomingTaskDataManagerTableViewAdapter
tableView.dataSource = upcomingTaskDataManagerTableViewAdapter
updateNavigationBar()
}
override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
return true
}
func updateNavigationBar() {
navigationThemeDidChangeHandler?(navigationTheme)
}
@IBAction func unwindFromAddController(segue: AddCompletionSegue) {
upcomingTaskDataManager.createTaskWithTitle(segue.taskTitle, dueDate: segue.taskDueDate)
}
}