Clean architecture in Android

要谈架构的话当然一定会聊到这现在最夯最流行的 Clean architecture,虽然在前面的文章中已经提过几次了,但是应该不是所有人都有真正的看过这本书,所以部分的知识是从别人的分享得来,又或是看着别人的实作,猜测 Clean architecture 的内涵以及要解决的问题。今天我将以读过这本书的读者来出发,来分享我对於 Clean architecture 的心得,虽然质量一定不及看原本的书,或是业界着名讲师分享的内容以及课程,但是我前前後後也看过了不少网路上对於 Clean architecture 在 Android 上面的实作方式,有些喜欢的实作,但是更多的是不喜欢,当然这些批评可能还是有我主观的成分在,各位读者也不用把我的批评当作太严重的事情看待,如果有不同意见的也非常欢迎,因为知识跟经验本来就是不断从错中学习并改进的,有可能是那些实作也是在错误学习的过程,也有可能错的是我。

以下我列出在书中最喜欢的几点:

The Dependency Rule

抽象不应该依赖实作细节,反之,实作细节应该依赖抽象。抽象在这本书中还有另外一个称呼:high-level policy,指的是在应用程序中最核心的商业逻辑,商业逻辑本身应该能够独立运作,不管是手机的 UI ,终端机指令或者是单元测试,都能够有办法自由操作并验证商业逻辑,这样做的好处有很多,像是刚刚所列出的单元测试,这样子的 high-level policy 是很简单的去做单元测试的,另外一个好处是容易抽换假实作,像是本系列文章中的前面所示范的,我使用简单的 InMemoryNoteRepository 来快速实验手势操作应该要怎麽实做,等到 View 跟 ViewModel 都完成之後,再来研究比较困难的实作细节,也就是 Firebase firestore。

对於目前的便利贴 App 来说,high-level policy 是 EditorViewModel 以及 EditTextViewModel ,其他则都是实作细节,这些实作细节目前是容易抽换的,像是要把 View 从 Jetpack Compose 改成 Android View 的话,也不需要动到 ViewModel。所以这个 App 是符合 Dependency Rule 的,以下附上丑丑的手绘图。

66F5E5B1-B1CD-43BD-AE7E-85B89AADA60B.jpg

Screaming architecture

这本书的作者 Uncle bob 很常举一个例子,就是当他在开发 ruby on rails 应用程序时,或是在看一个 ruby on rail 专案时,他会完全没办法从第一眼看出这个应用程序的用途!为什麽呢?因为专案结构的第一层就是 View 、 Model 跟 Controller,完全没有跟该专案相关的任何关键字!举例来说,如果是电商的话应该会有 ShoppingCart、Goods 或是 Recomendation 的 package,如果是直播的话应该会有 StreamingCore、CastingRoom 或是 Chat 相关的 package,这样分类的话,找相对应功能不是会比较好找吗?与此同理,在使用 Clean Architecture 的时候请不要这样分类: View, PortAdapter, UseCase, Entity ,这样一点意义都没有。

作者在写者本书的本意就是要尽量避免这些无关商业逻辑的“技术细节”,然而现在却反而在一些地方有人提倡使用 "Clean architecture 框架",让原本简单无比的商业逻辑操作被这些 Clean architecture 框架给绑架了,这样没有比较乾净,反而多了很多样板程序码(boilerplate code),请看下面这个从某个“Clean architecture 框架”复制来的范例:

public class GetUserDetails extends UseCase<User, GetUserDetails.Params> {

  private final UserRepository userRepository;

  @Inject
  GetUserDetails(UserRepository userRepository, ThreadExecutor threadExecutor,
      PostExecutionThread postExecutionThread) {
    super(threadExecutor, postExecutionThread);
    this.userRepository = userRepository;
  }

  @Override Observable<User> buildUseCaseObservable(Params params) {
    Preconditions.checkNotNull(params);
    return this.userRepository.user(params.userId);
  }

  public static final class Params {

    private final int userId;

    private Params(int userId) {
      this.userId = userId;
    }

    public static Params forUser(int userId) {
      return new Params(userId);
    }
  }
}

这一整段程序码总共有快 30 行的程序码,然而真正的商业逻辑在哪里呢?只有在 this.userRepository.user(params.userId) 这一行!!其他的都是没必要的实作细节,甚至 Param 本身也是一个没有意义的存在(当然可能作者的原意只是做示范,但这个示范还是太不实用了)。

在很多不同地方都有像这种 UseCase 的介面,有的只有一个 class 跟一个 invoke() 函式,但是这无法解决非同步的问题,於是又有另一种非同步版本的 UseCase 出现,一开始出现 RxJava 的,过不久之後又想淘汰他们改用 Coroutine,所以改成 suspend function ,但是又发现这无法处理 Streaming 事件,於是又出现了一个 Flow 版本的 UseCase,难怪工程师常说要改架构!怎麽改不都改不完不是吗?

那如果退一步讲,这些 UseCase 的存在是有意义的,他能够确保这些商业逻辑都不是跑在 Main Thread 上面,那我们在 ViewModel 就可以做少一点事情了吗?但很可惜的看起来没有, ViewModel 的程序码还是充满了“框架”的技术细节,以下程序码是来自 google io github repo

@HiltViewModel
class ScheduleViewModel @Inject constructor(
    private val loadScheduleUserSessionsUseCase: LoadScheduleUserSessionsUseCase,
    signInViewModelDelegate: SignInViewModelDelegate,
    scheduleUiHintsShownUseCase: ScheduleUiHintsShownUseCase,
    topicSubscriber: TopicSubscriber,
    private val snackbarMessageManager: SnackbarMessageManager,
    getTimeZoneUseCase: GetTimeZoneUseCase,
    private val refreshConferenceDataUseCase: RefreshConferenceDataUseCase,
    observeConferenceDataUseCase: ObserveConferenceDataUseCase
) : ViewModel(),
    SignInViewModelDelegate by signInViewModelDelegate {

    // Exposed to the view as a StateFlow but it's a one-shot operation.
    val timeZoneId = flow<ZoneId> {
        if (getTimeZoneUseCase(Unit).successOr(true)) {
            emit(TimeUtils.CONFERENCE_TIMEZONE)
        } else {
            emit(ZoneId.systemDefault())
        }
    }.stateIn(viewModelScope, Lazily, TimeUtils.CONFERENCE_TIMEZONE)

    val isConferenceTimeZone: StateFlow<Boolean> = timeZoneId.mapLatest { zoneId ->
        TimeUtils.isConferenceTimeZone(zoneId)
    }.stateIn(viewModelScope, Lazily, true)
...

这麽多的 UseCase ,我们有办法轻易的辨认出来哪些是同步,哪些是非同步吗?

而且这麽多的“Clean architecture 框架”都有一个共通点,就是一个 UseCase 只能对应一个 Class ,然後这个 Class 只能做一件事,但 Uncle bob 当初在写这本书的时後可没有这样规定啊...甚至没有具体的导引,我猜他当初这样的用意,是要让读者不要太拘泥於实作的形式。除了这些框架的作法之外,不知道大家有没有想过一个 class 同时有多的 UseCase 会是怎样的情况呢?或是一个 UseCase 是不是也可以组合其他多个 UseCase 呢?这些应该都是可行的。而且我觉得作者也没有严格的规定一定要有 UseCase 这一层,这一层的出现是根据他往年丰富的经验所归纳出来的,而且还有一个很有趣的一点,就是大家对於 UseCase 的关注度甚至多於 Entity,让 UseCase 直接操作 Repository,使用完之後这个 UseCase 就结束了,彷佛 Entity 是个不存在的东西一样,但是 Entity 不也是商业逻辑的核心吗?他们跑去哪了呢?

小结

其实在过去我也做过“框架开发”这种事,也不觉得有任何问题,但是当我看了 Google IO App 的源码後,开始觉得不太对劲,为什麽我光是要理解他的框架就要花不少时间?为什麽要了解核心的商业逻辑是这麽的遥远路程?这一切真的值得吗?也许对同一个组织来说,他们已经很习惯这种开发模式了,而且他们都已经对有框架的理解上都有同一个共识,所以在阅读以及开发上不会花费太多时间。但除此之外有没有更好的做法呢?我个人觉得是有的,甚至还觉得没有 UseCase 这一层也无所谓,让 ViewModel 直接操作 Entity 搞不好还更好维护,恩....我相信现在很多人都没看过这样子的架构,很难想像这会是什麽样子,没关系,之後你会看到他的!


<<:  Day.19 认识索引 - 二级索引 (Secondary Index)

>>:  [NestJS 带你飞!] DAY12 - Interceptor

【修正模型】4-2 呼叫堆叠(Call Stack)

今天要来提提昨天学到的执行上下文对於整个 JavaScript 执行过程中的角色以及当浏览器事件发生...

javascript(DOM)(DAY19)

在上一篇文章中说明了javascript的DOM和event是什麽,而这篇文章会介绍如何利用上一篇所...

Day17 Vue Component(元件)

元件(Component)是Vue里主要也是最强大的特性之一,它提供了THML DOM元素的扩充性,...

[烧烤吃到饱-2] 好好吃肉韩式烤肉吃到饱-台中公益店 #中秋节烤肉精选店家

这样的食材,才299吃到饱,别挑剔了啦~ 这家好好吃肉,就位在前几天分享过的「咕咕家」正对面。 好好...

#10 CSS3 Flexbox: nav style setting

What is nav? nav = navagator “The <nav> HTML...