現代 Android 官方為什麼更推薦 Repository 暴露 suspend fun,而不是在內部 launch

前言

--

寫這篇文章,其實是因為最近看到有些公眾號在介紹 Kotlin 協程時,都推薦在 Repository 中這樣寫:

kotlin 體驗AI代碼助手 代碼解讀複製代碼fun load() {
    repositoryScope.launch {
        // do something
    }
}

甚至把這種寫法包裝成一種「最佳實踐」,理由通常是:

  • Repository 自己負責非同步;
  • 呼叫方不用 launch,呼叫更簡單;
  • 避免阻塞主執行緒。

看到這樣的文章越來越多,我實在忍不住了。

因為它真正改變的並不是「是否非同步」,而是任務的生命週期歸屬

很多初學者會誤以為:

  體驗AI代碼助手 代碼解讀複製代碼耗時操作 = launch

於是只要存取資料庫、發起網路請求,就習慣性在 Repository 裡偷偷 launch 一個新的協程。

但現代 Android 官方推薦的設計思想並不是這樣。

對於絕大多數一次性資料操作,Repository 更應該暴露 suspend fun,由呼叫方決定在哪個 CoroutineScope 中啟動協程。

因為:

suspend 表達的是能力,而 launch 表達的是任務歸屬。

真正應該思考的問題從來不是「哪裡寫 launch 更方便」,而是:

誰應該擁有這次任務的生命週期、取消權、完成時機以及例外邊界?

這也是我寫這篇文章最想討論的問題。


一、先說結論:官方推薦背後的統一口徑是什麼?

如果把 Android 官方近幾年的協程建議壓縮成一句話,可以概括為:

Data / Domain 層對外暴露「掛起函式和 Flow」,由呼叫方控制協程的建立、取消和生命週期。

也就是:

  • 一次性操作:暴露 suspend fun
  • 持續資料流:暴露 Flow
  • 誰來啟動協程:通常是更上層,例如 ViewModel、UseCase 入口、WorkManager、應用程式級協調者

所以從職責上看:

  • Repository 負責:定義資料語義
  • 呼叫方負責:定義執行時機和生命週期

這就是為什麼更推薦:

kotlin 體驗AI代碼助手 代碼解讀複製代碼suspend fun load()

而不是:

kotlin 體驗AI代碼助手 代碼解讀複製代碼fun load() {
    repositoryScope.launch { }
}

二、這兩個寫法本質上到底差在哪?

看起來只是少寫一個 launch,但它們表達的是兩種完全不同的架構語義。

寫法 A:suspend fun load()

image.png

呼叫方:

image.png

這個設計表示:

  • Repository 只描述「這件事怎麼做」
  • ViewModel 決定「什麼時候做」
  • 任務歸 viewModelScope
  • 頁面銷毀時,任務可以自動取消
  • 例外能沿呼叫鏈自然傳播

這是結構化並發的典型寫法。


寫法 B:Repository 內部 launch

image.png

呼叫方:

image.png

這個設計實際上表示:

  • ViewModel 只是「發了個命令」
  • 真正的協程生命週期不再歸 ViewModel 管
  • 呼叫方不知道任務什麼時候結束
  • 呼叫方也不知道例外會怎麼處理
  • 頁面銷毀後,這個任務可能還繼續跑

所以,差別不是「誰寫 launch 更方便」,而是:

誰擁有這次非同步任務的控制權。


三、為什麼 suspend fun 更符合現代 Android 架構?

1. 呼叫方才能決定生命週期

在 Android 裡,不同層的生命週期完全不同:

  • Fragment / Activity 生命週期短
  • ViewModel 生命週期通常跟頁面綁定
  • 應用程式級物件生命週期更長
  • WorkManager 甚至希望任務在程序重啟後繼續

同一個 Repository,可能被很多不同呼叫方使用。

如果 Repository 自己偷偷 launch,它就等於假設:

「這份任務的生命週期由我來定。」

但這就是問題所在。

更合理的方式是讓呼叫方決定:

image.png

這樣:

  • 頁面不在了,可以取消
  • 頁面重試,可以重新發起
  • 呼叫方可以決定串行、並行、限流、逾時
  • 生命週期跟 UI / 業務動作邊界一致

這正是結構化並發要解決的問題。


2. suspend 天然支援結構化並發

suspend fun 最大的價值不是「非同步」,而是:

它把非同步操作保留在目前呼叫鏈裡。

例如:

image.png

這裡 repository.loadUser()repository.loadNotice() 都是可組合的。

因為它們是 suspend fun,所以我們可以:

  • coroutineScope 做並發組合
  • supervisorScope 控制失敗隔離
  • withTimeout 控制逾時
  • retryrunCatchingResult 包裝錯誤策略

但如果 Repository 內部直接 launch,那這些組合能力會明顯變差。因為你已經拿不到這個任務的完成時機了。


3. launch 會讓完成時機變得模糊

suspend 版本:

image.png

呼叫方很清楚:

image.png

內部 launch 版本:

image.png

呼叫方:

image.png

於是你就會開始補各種東西:回呼、事件通知、額外狀態流、手寫完成監聽……

而這些問題,本來 suspend fun 天然就已經解決了。

Repository 內部 launch 並沒有減少複雜度,只是把時序複雜度藏起來了。


4. 例外傳播會被打斷

suspend fun 的一個重要優點是:

例外可以沿呼叫鏈自然向上傳遞。

例如:

image.png這條鏈路是很自然的:Repository 丟錯 → ViewModel 捕獲 → UI 決定如何顯示。

但如果 Repository 自己 launch,例外處理就變成一個問題:是 Repository 自己吃掉?還是印日志?還是發事件通知?還是依賴 CoroutineExceptionHandler?更致命的是如果上層 try catch 其實是包不住的。

錯誤邊界開始變得不透明,而不透明往往就是可維護性開始下滑的地方。


5. 取消能力會被削弱

如果呼叫鏈保持結構化:

image.png

那當 viewModelScope 被取消時,repository.load() 也會跟著取消。

但如果 Repository 使用自己的 repositoryScope,那呼叫方取消了自己,也不一定能取消 Repository 內部那個任務。

這會帶來幾個常見問題:

  • 頁面沒了,網路還在跑
  • 請求結果回來了,但頁面已經銷毀
  • 多次點擊觸發多個背景任務,難以收斂

四、很多人把 withContextlaunch 搞混了

這是整篇文章裡一個非常關鍵的點,值得單獨拿出來說。

很多開發者把:

image.png

和:

image.png

認為是差不多的東西——「反正都是把耗時操作放到背景跑」。

實際上,它們解決的是完全不同的問題

很多開發者真正想表達的是:

image.png

結果卻寫成了:

image.png


withContext 只是切換執行上下文

image.png

這裡雖然執行緒可能從 Main 切到了 IO,但整個任務仍然屬於同一個協程。也就是說:

  • 生命週期沒有變
  • 取消關係沒有變
  • 例外傳播沒有變

withContext 只是暫時切換了執行環境,並沒有建立新的任務。整個呼叫鏈仍然保持結構化並發。


launch 則會建立新的協程

如果改成:

image.png

情況就完全不同了。Repository 已經主動建立了一個新的 Job,這意味著:

  • 生命週期發生了變化
  • 取消關係發生了變化
  • 例外傳播關係也發生了變化

呼叫方取消自己的協程,並不一定能夠取消 Repository 內部那個新的任務。呼叫方也無法天然知道它什麼時候完成。

withContext 改變的是執行執行緒;launch 改變的是任務歸屬。

很多公眾號把這兩者混為一談,這是導致 Repository 濫用 launch 的重要原因之一。

真正需要執行緒切換時,優先考慮 withContext;真正需要建立一個擁有獨立生命週期的新任務時,再考慮 launch


五、Repository 內部 launch 最大的問題:職責越界

直覺上覺得自己只是「封裝了一下非同步」。但本質上,你已經額外承擔了這些職責:

  • 決定協程在哪個 Scope 裡跑
  • 決定任務是否可取消
  • 決定例外由誰處理
  • 決定任務能否並發重入
  • 決定呼叫方如何感知完成

這些其實都不是普通 Repository 的核心職責。普通 Repository 更像:

  • 資料存取抽象
  • 本地 / 遠端組合
  • 查詢與寫入語義封裝

而不是:

  • 背景任務排程器
  • 生命週期託管器
  • 任務狀態協調中心

六、那 launch 應該放在哪裡?

一個很實用的經驗是:

launch 放在邊界層;suspend 留在可組合的業務能力層。

1. ViewModel

kotlin 體驗AI代碼助手 代碼解讀複製代碼fun refresh() {
    viewModelScope.launch {
        repository.load()
    }
}

適用於頁面按鈕點擊、首次載入、下拉重新整理、頁面生命週期觸發的任務。

2. UseCase / Interactor 入口

image.png

這裡 launch 是合理的,因為 UseCase 正在定義一個更高層的業務執行過程。

3. WorkManager / 應用程式級任務協調者

如果任務天然就應該脫離頁面生命週期存在(日志上傳、離線同步、大檔案下載、資料遷移),那麼它本來就不該綁定到 viewModelScope,而應該放在 WorkManager 或 application scope 等元件中。


七、Repository 就絕對不能建立協程嗎?

也不是。這裡要區分兩件事。

情況 1:一次性操作,對外應該暴露 suspend

Repository 內部可以使用 withContextcoroutineScopesupervisorScopeasync 等在目前呼叫鏈內部組織子任務

image.png

這裡 Repository 是在「目前呼叫鏈內部組織子任務」,而不是偷偷建立一個脫離呼叫方的頂層任務。這兩者差別非常大。

情況 2:任務必須比呼叫方活得更久

這才是 Repository 內部啟動協程可能合理的場景,例如使用者點擊「收藏」,希望即使離開頁面也盡量完成寫入。

這時更準確的設計是:

image.png

重點不是「終於可以在 Repository 裡 launch 了」,而是:

  • 這是特例,不是預設模式
  • 任務為什麼需要更長生命週期,是被明確設計過
  • 作用域來自外部注入,而不是 Repository 隨便 new 一個

能在 Repository 內部啟動協程,不代表「預設就該這麼寫」;它只適合那些明確需要脫離呼叫方生命週期的工作。


八、團隊裡可以直接落地的判斷規則

在程式碼審查時糾結 Repository 要不要自己 launch,可以直接問 4 個問題:

問題 1:這是一次性操作,還是持續資料源?

一次性操作 → 優先 suspend fun;持續資料源 → 優先 Flow

問題 2:誰最適合決定它的生命週期?

頁面相關 → ViewModel;業務動作入口 → UseCase;脫離頁面的背景任務 → WorkManager / application scope。如果答案不是 Repository,那 Repository 就不該預設自己 launch

問題 3:呼叫方需不需要知道它何時完成、是否失敗?

只要答案是「需要」,那內部 launch 通常就不是好主意。一旦內部 launch,完成與失敗邊界就會立刻變模糊。

問題 4:這個任務是否必須活得比呼叫方更久?

不需要 → suspend fun;需要 → 才考慮外部注入長生命週期 scope 或背景任務框架。


九、比較穩的團隊約定

  1. Repository 對一次性操作預設暴露 suspend fun
  2. Repository 對持續資料預設暴露 Flow
  3. 協程的啟動通常由 ViewModel / UseCase / WorkManager 決定
  4. Repository 內部可以用 coroutineScope / withContext 組織實作,但不要預設偷開頂層 launch
  5. 只有當任務明確需要脫離呼叫方生命週期時,才考慮注入外部長生命週期 Scope

這樣做的好處:生命週期歸屬清晰、取消語義清晰、例外傳播清晰、完成時機清晰、分層職責更穩定。


十、總結

現代 Android 推薦 Repository 暴露 suspend fun,不是因為 launch 不能用,而是因為一次性資料操作的生命週期,預設應該由呼叫方而不是 Repository 來控制。


原文出處:https://juejin.cn/post/7650074080125599790


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝16   ❤️1
471
🥈
我愛JS
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登