重构你的ViewController

这篇文章来自阅读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
    }
  1. 这个UpcomingTaskDataManagerTableViewAdapter通过传入一个CellType支持泛型。
  2. 通过接受几个closure来进行自定义的配置,包括cell的样式配置以及tableview数据更新后的回调。
  3. 实现的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)
    }
}