[译] Android 架构:Part 2 —— 介绍 Clean Architecture

3,007 阅读9分钟

在本系列的第一部分,我们介绍了我们在寻找可行架构的道路上所犯过的错误。在这部分,我们将介绍传说中的 Clean Architecture。

当你在谷歌搜索 "clean architecture" 时,你看到的第一张图片是:

它也被称为洋葱架构,因为图看起来象个洋葱(你会意识到你需要写样板代码写到哭);或者是端口和适配器,因为你可以看到右图的一些端口。六角架构是另一个相似的架构。

Clean Architecture 是前面提到的 Uncle Bob 的心血结晶,他是 《代码整洁之道》的作者。这种方法的要点是,业务逻辑(也称为 domain),是宇宙的核心。

掌控你的领域(domain)

当你打开项目时,你应该已经知道这个 app 是做什么的,与技术无关。其它一切都是实现细节。譬如,持久化就是一个细节。定义接口,创建一个快速的粗糙的内存内(in-memory)实现,不要想太多,直到完成业务。然后你可以决定怎样真正地持久化数据。数据库,网络,两者结合,文件系统 —— 或者仍然保留在内存中,或者结果你根本不需要持久化。总之一句话:内层包含业务逻辑,外层包含实现细节。

话说回来, Clean Architectue 有一些特性使这成为可能:

  1. 依赖规则
  2. 抽象
  3. 层与层之间的通信

I.依赖规则

依赖规则可以用下图解释:

外层应该依赖内层。那三个在红色框框内的箭头表示依赖。与其使用“依赖”,也许使用“看见”、“知道”、“了解”这类术语更好。在这些术语中,外层看见,知道,了解内层,但内层看不见,也不知道,更不了解外层。正如我们先前所说,内存包含业务逻辑,外层包含实现细节。遵循依赖规则,业务逻辑既看不到,也不知道,更不了解实现细节。这正是我们努力想要做到的。

如何实现依赖规则取决于你。你可以把它们放到不同的包,但小心“内层的”包不要使用“外层的”包。然而,如果有人不知道依赖规则,没有什么可以阻止他破坏规则。一个更好的方法是把层分离到不同的 Android 模块(modules,即子项目),并在构建文件(build.grale)中调整依赖,这样内层就无法依赖外层。

还有值得一提的是,虽然没人可以阻止你跨层依赖,譬如蓝色的层的组件使用红色的层的组件,但我强烈建议你只访问相邻的层的组件。

II.抽象

抽象原则之前已有所暗示。也就是说,当你朝图中间移动时,东西变得更抽象。 这是有道理的:正如我们所说内层包含业务逻辑,而外层包含实现细节。

甚至可以在多个层之间划分相同的逻辑组件,如图所示。 内层定义更抽象的部分,外层定义更具体的部分。

举个例子说清楚些。我们可以定义一个 “Notifications” 的抽象接口,并将其放到内层,这样你的业务逻辑需要时可以使用它来向用户显示通知。另一方面,我们可以这样来实现该接口,即使用 Android NotificationManager 显示通知来实现,并把该实现放到外层。

以这种方式,业务逻辑可以使用这样的功能 —— 通知(在我们的例子中)—— 但它不了解实现细节:实际的通知是如何实现的。此外,业务逻辑甚至不知道实现细节的存在。来看下面这张图片:

当将抽象规则和依赖规则组合在一起时,结果是使用通知的抽象业务逻辑既不会看到,也不会知道,更不会了解使用 Android NotificationManager 的具体实现。这很好,因为我们可以在业务逻辑毫不知情的情况下切换具体实现。

让我们把这种规则组合和标准的三层架构简单对比下,看看它们各自的抽象和依赖是怎样的以及如何工作的。

在图中,你可以看到,标准三层架构的所有依赖最终都传到数据库。也就是说,抽象和依赖(方向)并不一致。在逻辑上,业务层应该是 app 的中心,但它却不是,因为依赖朝向数据库。

业务层不应该知道数据库,应该反过来。在 Clean Architecture 中,依赖朝向业务层(内层),并且抽象也上升到业务层,因此它们很好地匹配。

这是重要的,因为抽象是理论,依赖是实践。抽象是 app 的逻辑布局,依赖关系是(组件)如何实际组合在一起。在 Clean Architecture 中,这两者是匹配(译者注:指方向一致)的。而在标准三层架构中则不然,如果你不小心,很容易导致各种逻辑上的不一致和混乱。

III.层与层之间的通信

现在我们将 app 分模块,将所有内容分开,将业务逻辑放在我们 app 的中心,并在外层实现细节,一切看起来都很棒。 但是你可能很快遇到一个有趣的问题。

如果你的 UI 是一个实现细节,网络是一个实现细节,业务逻辑在中间,那么我们如何从互联网获取数据,经过业务逻辑,然后发送到界面?

业务逻辑在中间,应该协调网络和界面,但它甚至不知道两者的存在。这是一个关于通信和数据流的问题。

我们希望数据能够从外层流向内层,反之亦然,但依赖规则不允许。 让我们举个最简单的例子。

我们只有两层,绿色和红色的。绿色的是外层,它知道红色的,红色的是内层,它只知道自己。我们希望数据从绿色流向红色,然后折回绿色。该解决方案先前已经暗示过了,看下图:

图的右边部分显示了数据流。数据源于 Controller,经过 UseCase(或者替换成你选择的组件)的输入端口,然后通过 UseCase 本身,最后通过 UseCase 输出端口发送到 Presenter。

图的主要部分(左边)的箭头表示组合和继承 —— 组合用实心箭头表示,继承用空心箭头表示。组合也被称作 has-a 关系,继承被称作 is-a 关系。圆圈中的 “I” 和 “O” 表示输入和输出端口。可以看到,定义在绿色层中的 Controller,拥有一个(has-a)定义在红色层中的输入端口。UseCase(齿轮,业务逻辑,现在不重要)是一个(is-a)(或实现)输入端口,并且拥有一个(has-a)输出端口。最后,定义在绿色层中的 Presenter 实际上是一个(is-a)定义在红色层的输出端口。

现在,我们可以将其与数据流匹配。Controller 拥有一个输入端口 —— 拥有一个指向它的引用。它调用输入端口的一个方法,这样数据就从 Controller 流到输入端口。但输入端口是一个接口,而它的实际实现是 UseCase。也就是说,它调用 UseCase 的一个方法,这样数据就流向了 UseCase。UseCase 执行某些操作,并希望将数据发送回来。它拥有输出端口的一个引用 —— 输出端口定义在同一层 —— 因此它可以调用上面的方法。因此,数据流向输出端口。最后 Presenter 是,或者实现了输出端口,这是魔法的一部分。因为它实现了输出端口,数据实际上流到它那了。

巧妙的是,UseCase 只知道它的输出端口,世界在此停止(意指数据流到此结束)。Presenter 实现了它(输出端口),实际上它可以被任何对象实现,因为 UseCase 不知道或不关心这些,它只清楚其层内的一亩三分地。可以看到,通过结合组合和继承,我们可以使数据流向两个方向,尽管内层并不知道它们在和外部世界通信。瞄一眼下图:

可以看到,和依赖箭头一样,has-a 和 is-a 箭头也指向中间。这是符合逻辑的。根据依赖规则,这是唯一可行的方法。外层可以看到内层,但不能反过来。唯一复杂的部分是,is-a 关系尽管指向了中间,却反转了数据流。

请注意,定义输入和输出端口是内层自己的职责,因此外层可以使用它们与其建立通信。我说过,这个解决方案先前已经暗示过,而且已经有了。那个讲解抽象的通知例子,也是这种通信的一个例子。我们在内层定义了一个通知接口,业务逻辑可以用来向用户显示通知,但是我们在外层也定义一个实现。在这种情况下,通知接口是业务逻辑的输出端口,用来和外部世界(在本例中,就是和具体的实现)通信。你不需要把你的类命名为 FooOutputPort 或者 BarInputPort,我们命名端口只是为了解释理论。

总结

那么,它是过度复杂,过度费解的过度工程吗?好吧,当你习惯了,它就简单。并且这是必要的。它允许我们使得好的抽象/依赖实际匹配真实世界的通信和工作。也许这一切都提醒你不过是空中楼阁:美丽,理论上优雅,但过于复杂,我们仍然不知它是否有效,但在我们的案例中,它确实有效。

这就是本系列的第二部分。最后,第三部分,毕竟我们已经了解了理论和架构,将讲解所有你需要了解的那些图上的标签。换句话说,分离的组件。我们将向你展示一个真实的应用于 Android 的 Clean Architecture。

原文