iT邦幫忙

2021 iThome 鐵人賽

DAY 22
2
Mobile Development

Jetpack Compose X Android Architecture X Functional Reactive Programming系列 第 22

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 搞不好還更好維護,恩....我相信現在很多人都沒看過這樣子的架構,很難想像這會是什麼樣子,沒關係,之後你會看到他的!


上一篇
新需求與架構設計的演進
下一篇
Re-architect with UseCase driven design
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
ReccaChao
iT邦新手 1 級 ‧ 2021-09-27 12:05:43

會不會是為了提升未來的共用性
所以需要定義出很多的介面,以免未來加入共用元件時產生困難?

我個人的看法是不要太早預先設計,如果真的有需要的話,再去定義介面也不遲。預先訂出介面的缺點除了 boilerplate 之外,還有之後修改的彈性也變小了,未來想要再去做修改也會是很困難的。

另外的一點是我覺得 interface 的最大特點是他使用抽象與 polymorphism 來讓整個程式可重用性更高,但是如果沒有怎麼用到 polymorphism 的話我會覺得這個 interface 很可惜。

我要留言

立即登入留言