iOS 工程

[iOS 架構 #6] Clean Architecture 核心

你常會在徵才公告或技術部落格中看到「以 Clean Architecture 為基礎」這句話。但實際打開程式碼後,會發現每個團隊的樣貌都不一樣。有些地方有 UseCase,有些沒有,Repository 的職責也各不相同。

閱讀 4 分鐘
[iOS 架構 #6] Clean Architecture 核心 封面圖

你常會在徵才公告或技術部落格中看到「以 Clean Architecture 為基礎」這句話。但實際打開程式碼後,會發現每個團隊的樣貌都不一樣。有些地方有 UseCase,有些沒有,Repository 的職責也各不相同。

之所以混亂,原因很簡單:Clean Architecture 不是特定的資料夾結構或類別清單,而是一項原則。

今天就來整理這項原則的內容,以及在 iOS 中以 UseCase 和 Repository 形式實作時,各自負責什麼。


核心是「相依性只能向內」

Clean Architecture 是 Robert Martin(Uncle Bob)整理出的概念,將 App 視為同心圓式的分層結構。

  • 最內層:網域。實體與商業規則。「這個 App 是做什麼的?」
  • 中間層:UseCase。App 的行為情境
  • 最外層:UI、資料庫、網路、框架

規則只有一條。相依性永遠只能由外向內。

內層的網域程式碼不應知道外層的 UIKit、URLSession 或 SwiftData。反過來,外層可以知道內層。如此一來,不論替換 UI 框架或變更伺服器 API,都不必修改 App 的核心規則。

你可能已經注意到,這是把 DIP(相依性反轉原則)擴展到整個 App 的規模,也就是以分層為單位套用「依賴抽象,而非具體實作」。


Repository:隱藏「資料來自哪裡」的邊界

Repository 是網域與資料來源之間的邊界。將協定放在網域側,實作放在外層。

// 網域層 — URLSession什麼都 SwiftData不知道
protocol PostRepository {
    func fetchPosts(userID: String) async throws -> [Post]
}

// 資料層 — 實作放在外層
final class RemotePostRepository: PostRepository {
    func fetchPosts(userID: String) async throws -> [Post] {
        // URLSession 呼叫, DTO 解碼, Post轉換
    }
}

重點在於協定的位置。介面由網域擁有,因此網域只知道「可以取得文章」,不知道資料來自伺服器、快取還是本機資料庫。伺服器回應格式變更時,只要修改 DTO(Data Transfer Object,承載伺服器回應的資料傳輸物件)與實作即可;測試時則可插入假的 Repository。

網域不必知道資料來自哪裡
網域不必知道資料來自哪裡

UseCase:承載「這個 App 要做的事」之一的容器

UseCase 是將一個 App 行為情境化為物件,例如「載入動態」或「發佈文章」。

final class LoadFeedUseCase {
    private let postRepository: PostRepository
    private let blockRepository: BlockRepository

    func execute(userID: String) async throws -> [Post] {
        let posts = try await postRepository.fetchPosts(userID: userID)
        let blocked = try await blockRepository.blockedUserIDs()
        return posts
            .filter { !blocked.contains($0.authorID) }
            .sorted { $0.createdAt > $1.createdAt }
    }
}

「將封鎖使用者的文章從動態中排除」是放在 UseCase 中的商業規則。如果放進 ViewModel 會怎樣?動態、個人檔案和搜尋畫面都會各自實作封鎖篩選,最後一定會有一處出現偏差。抽到 UseCase 後,規則集中在一處,也能不依賴畫面進行測試。

因此,MVVM(Model-View-ViewModel)和 Clean Architecture 並非競爭關係,而是正交關係。MVVM 整理 View 端,Clean Architecture 整理其後的分層。上一篇提到「要避免 Massive ViewModel,就把邏輯往下移」,那個承接邏輯的位置就是 UseCase 和 Repository。


應該導入到什麼程度?

Clean Architecture 的陷阱是過度導入。兩個畫面的 App 若完整配置 UseCase、Repository、DTO、Mapper,傳遞一個值就得經過五個檔案,VIPER(View・Interactor・Presenter・Entity・Router)的樣板程式碼問題也會原樣重現。

實際上的判斷標準如下。

  • **Repository 幾乎總是有益。**只要將網路與 DB 程式碼從畫面中分離,測試與變更就會更容易。
  • **當多個畫面開始共用商業規則時,再導入 UseCase。**如果大多數 UseCase 只是原封不動轉呼叫 Repository,現在還太早。
  • **依分層拆分模型(DTO/網域/畫面模型)應等規模變大後再做。**一開始就全部拆開,只會不斷累積 Mapper 程式碼。
不是一次備齊所有東西,而是在需要時逐層建立
不是一次備齊所有東西,而是在需要時逐層建立

總結

  • Clean Architecture 不是資料夾結構,而是「相依性只能向內(網域)」的原則,也是 DIP 的 App 規模擴展版。
  • Repository 是隱藏資料來源的邊界,核心在於協定由網域擁有。
  • UseCase 是放置多個畫面共用商業規則的地方,與 MVVM 正交,因此可以一起使用。
  • 目標不是全部備齊。從 Repository 開始,等規則累積後再加入 UseCase。

下一篇將轉換方向,介紹把狀態管理本身做成架構的 TCA(The Composable Architecture)。