组织代码中的依赖关系

什么是依赖关系

例子:

/// Swift demo
class Canvas {
  var color: UIColor

  func drawCircle(at origin: (Double, Double), d: Double) {
    let pen = Pencil(of: color)
    pen.circle(at: origin, d: d)
  }
}

let canvas = Canvas(color: .red)
canvas.drawCircle(at: (0, 0), d: 10)
  1. 首先,是由于对类型名称的了解而引入的依赖关系。这里Canvas要求必须存在一个叫做Pencil的
  2. 其次,是由于要发送给其它对象的方法名而引入的依赖关系。这里Canvas要求Pencil中必须有一个叫做circle的方法
  3. 第三,是方法的参数引入的依赖关系。既然Canvas创建了Pencil对象,它自然就需要知道Pencil的init方法需要接受一个UIColor对象
    除了上面三点之外,还有一类依赖关系是参数的顺序。但由于Swift并不像Ruby一样可以通过命名参数调整顺序,因此这类依赖关系,我们就不在这里讨论了

评估项目中类依赖关系的方法

了解了一些具体的处理依赖关系的方法之后,这一节最后,我们介绍一个评估依赖关系的方法。只有找到项目中最危险的分子,才能最安全有效的实施重构。对于一个类型来说,我们可以从两个方面来评估它:

  • 在未来发生修改的可能性;
  • 类型的依赖关系的多少;
    而这两个方面,又不是彼此独立的,我们可以画一个平面图来表达它们的关系:
    img

这里,越靠近顶部的类型,包含的依赖关系越多;越靠近右侧的类型,越可能发生修改。重构之前,我们要做的第一件事,就是把收集到的类型,对号入座地收集到这个图里。

通常,可以放在区域A中的类型,抽象级别应该是最高的,它们通常是一些接口或者抽象基类这样的东西,只用于约定行为。因此,它们非常稳定,几乎不会改变。对于这样的类型,即便它们有很多依赖关系(例如:Swift中的Collection就是多个protocols组合起来的),也是没问题的。

而对于B和C区域的类型,通常它们都不是我们要关注的重点。项目中,绝大多数的类型,应该属于这两个区域。由于它们的依赖关系并不多,所以你会发现对这些类型的修改,你的脑子里一下子就能反映出具体的方案并确定结果。当然,也正因为如此,其实重构它们的收益,并不大 🙂

最后,就是区域D中的类型。它们才是项目中最危险的分子。它们不仅依赖关系多,还经常需要被需改。所谓的Massive View Controller就是最典型的例子之一。尽管修改它们并不容易,但你要知道,这些类型,绝对是你在开始重构的时候,一定要要优先考虑的对象。

解决方法

从名字依赖到方法依赖

/// Swift demo
protocol Pen {
  func circle(at origin: (Double, Double), d: Double)
}

class Pencil: Pen {
  func circle(at origin: (Double, Double), d: Double) {}
}

class Canvas {
  func drawCircle(
    at origin: (Double, Double),
    d: Double,
    by pen: Pen) {
    pen.circle(at: origin, d: d)
  }
}

let canvas = Canvas()
canvas.drawCircle(
  at: (0, 0),
  d: 10,
  by: Pencil(of: .red))

而这种直接把对象当作参数传递的行为,就叫做依赖注入(Dependency Injection)。把原本Canvas依赖的Pencil对象,“注入”到drawCircle。没错,这个听起来很复杂的名称,实际上就这么简单的一回事儿。

不过在很多时候,这种方法仍旧是不可行的。例如,项目工期不允许我们立即修改某个接口的签名,或者,我们根本就无法修改来自第三方的代码。但即便是面对这些情况,我们仍旧可以通过依赖注入在某些程度上缓解对象之间的依赖关系,以便于在时机成熟的时候,更方便的处理问题。

把对象的创建统一隔离到init方法

如果条件不允许立即修改drawCircle的签名,至少,我们也要把Pencil对象的创建从drawCircle的定义里搬出来,放到Canvas的init方法里:

/// SWIFT
class Canvas {
  var pen: Pencil
  init(pen: Pencil) { self.pen = pen }

  func drawCircle(at origin: (Double, Double), d: Double) {
    pen.circle(at: origin, d: diameter)
  }
}

let canvas = Canvas(pen: Pencil(of: .red))
canvas.drawCircle(at: (0, 0), d: 10)

这样做有两个目的:

  • 一个是让drawCircle不再隐性的使用一个具象的Pencil对象,未来我们修改drawCircle签名的时候,可以不用再修改它的实现;
  • 另一个则是更明确的把Canvas依赖Pencil这个事实暴露出来,从上面的例子就可以看到,每次创建Canvas的时候,都“注入”了一个Pencil对象,这就好比在时刻提醒你:喔,对了,Canvas现在可是得依赖Pencil的。在条件允许的时候,我得把它修改过来;

尽可能在复杂方法中剥离依赖关系

除了把依赖的对象暴露在init方法之外,另外一个无副作用的改进,就是把“深埋”在复杂方法里的依赖关系从中剥离开,变成一个属于类自身的方法或属性,例如:

/// SWIFT
class Canvas {
  var pen: Pencil
  init(pen: Pencil) { self.pen = pen }

  func drawCircle(at origin: (Double, Double), d: Double) {
    /// Complicated render before draw
    circle(at: origin, d: diameter)
    /// Complicated pipeline work
  }

  func circle(at origin: (Double, Double), d: Double) {
    pen.circle(at: origin, d: diameter)
  }
}

这样做的目的,和我们在init中注入对象是类似的,也就是尽可能明确在未来重构代码时,需要修改的代码边界。让需要重构的部分,明确暴露出来。

多层容器

也就是说,BoxueUserSessionRepository是有状态的,我们不能够随意创建BoxueUserSessionRepository对象,如果有多个地方需要这个对象,我们应该让它成为一个单例。
而这,也是我们通过容器类创建对象的一个重要作用:即创建并持有需要长时间保持的依赖关系

但如果我们在一个项目中只使用一个容器类,往往还是会有一些不方便的地方。

最直接的问题,就是这个类的体积会越来越大,它会包含越来越多的单例对象和工厂方法。最终,自己演化成一个Massive Container,变得难以维护。

另一个问题是,一个App中的依赖关系往往不是平等的。有些依赖关系存活在App的整个生命周期、有些依赖关系只在用户登录后才产生,有些关系,则仅和某个UI、某个交互相关。如果把这些依赖关系的管理都放在一个容器中,我们可能就需要不断在这个容器中安插Optional类型的属性,然后通过unwrap它来判断各种情况。显然,这会让我们的container变得更加复杂。

面对这种问题,我们就可以使用多层容器来解决。

多层容器分层规则

按照一个对象从创建到销毁所覆盖到的区域的大小,我们可以把这个区域分成4大类:

  • App作用域,这个作用域里是我们最熟悉的单例对象,它们从App启动的时候就被创建,之后便一直保持在内存里,直到App结束。我们之前定义的sharedMainViewModel就属于这类作用域;
  • 用户作用域,是指在用户登录后创建,登出后销毁的对象;
  • 功能作用域,是指用户使用了某个功能之后才创建,离开这个功能后就可以销毁的对象。一会儿,我们就会看到这样一个例子;
  • 交互作用域,是指只有在执行了某个交互动作(例如手势)之后才创建的对象,这应该是生命周期最短的一类对象了;

有了这些作用域,我们就可以制定一个简单的对容器进行分层的规则:

  • 首先,管理App作用域的容器永远都是根容器,我们上一节定义的BoxueAppDepedencyContainer就是如此;
  • 其次,每当我们需要一个新的对象作用域时,就创建一个新的子容器来管理其中所有对象的创建方法;
  • 最后,下层容器可以向上层容器请求其管理的依赖关系;

例子:
BoxueGuideDependencyContainer要可以向它的上层容器BoxueAppDepedencyContainer请求数据,这可以通过在init方法中注入上层容器实现:

public class BoxueGuideDependencyContainer {
  /// - Properties
  let sharedMainViewModel: MainViewModel
  let sharedUserSessionRepository: UserSessionRepository

  init(appDependencyContainer: BoxueAppDepedencyContainer) {
    self.sharedMainViewModel =
      appDependencyContainer.sharedMainViewModel
    self.sharedUserSessionRepository =
      appDependencyContainer.sharedUserSessionRepository
  }
}

其他

如何组织文件

在项目目录中,这种“一个protocol会对应多个实现”的情况,我们都用下面的方式来组织文件:

在图中可以看到,Auth和UserSessionStore分别表示要实现的功能。在根目录中,我们定义对应的protocol。然后在Implementations中,包含对这个protocol的多个实现。

MVVM

MVVM过程

通常,我们会对“究竟应该把网络IO的代码放到哪里”这样的问题感到困惑。实际上,严格来说,网络IO只是View Model获取数据的一种途径而已,从这个角度上说,它和本地读取数据没有很大区别。因此我们也不应该有类似这样的困惑。只不过,当View Model中存在着多种获取数据的通路的时候,我们会在View Model和Model之间加一层代理来屏蔽掉这个差别。我们管这层代理,就叫做Repository

因此,View Model和Model之间的交互,就可以进一步被细化成这样:
MVVM细化过程

所谓的view model status,就是一组表示当前view类型的case

public enum GuideViewStatus {
  case welcome
  case signIn
  case signUp
  case contactUs
  case resetPassword
  case requestNotification
}
订阅评论
提醒
guest
0 评论
内联反馈
查看所有评论
0
希望看到您的想法,请您发表评论x