目錄
- 為什么需要 Domain 層
- 清晰的三層架構
- 核心概念:Entity / Value Object / Use Case / Repository
- Swift 代碼實戰
- 測試策略
- 在舊項目中落地的步驟
- 結語
1 為什么需要 Domain 層
在傳統 MVC / MVVM 中,我們往往把業務規則寫進 ViewController 或 ViewModel。
問題隨規模放大而爆發:
痛點 | 具體表現 |
---|---|
可測試性差 | 單元測試必須啟動 UIKit,跑真機或模擬器 |
業務難復用 | 同樣的計費、權限邏輯被多處復制 |
維護成本高 | UI 改版常常誤傷業務代碼 |
Domain 層 = 把“業務世界”的概念模型與用例流程抽離出來,形成純 Swift 代碼;UI 與外部數據存取只依賴它,卻不影響它。
2 三層架構速覽
層級 | 依賴方向 | 關鍵詞 |
---|---|---|
Presentation | ?? 調用 UseCase | UIKit / SwiftUI / Combine / Bloc |
Domain | 純 Swift | Entity?ValueObject?UseCase?Repository協議 |
Data / Infrastructure | ?? 實現 Repository | URLSession / CoreData / Realm / BLE |
依賴只允許由外向內,Domain 不感知任何框架。
3 關鍵概念
角色 | 職責 | 要點 |
---|---|---|
Entity | 有唯一標識 + 生命周期,如 Order | 行為應遵守不變式 |
Value Object | 無標識,靠值判等,如 Money | 必須不可變 |
Use Case (Interactor) | 滿足用戶故事的業務流程,如 PlaceOrder | 只依賴協議 |
Repository 協議 | Domain 訪問數據的抽象 | 不關心具體存儲方式 |
Place Order 意思是:下單 / 提交訂單
4 Swift 代碼實戰
場景:展示并更新聊天未讀數
4.1 Entity 與 Value Object
// Value Object
struct UnreadCount: Equatable {let value: Intinit(_ raw: Int) {precondition(raw >= 0, "Unread cannot be negative")value = raw}
}// Entity
struct Conversation: Identifiable, Equatable {let id: UUIDprivate(set) var unread: UnreadCountmutating func markAllRead() {unread = .init(0)}
}
4.2 Repository 協議
protocol ConversationRepository {/// 從緩存或網絡獲取未讀數func unreadCount() async throws -> UnreadCount/// 將未讀數持久化func save(_ count: UnreadCount) async throws
}
4.3 Use Case
/// 單一職責:獲取并緩存未讀數
struct GetUnreadCountUseCase {private let repo: ConversationRepositoryinit(repo: ConversationRepository) { self.repo = repo }func execute() async throws -> UnreadCount {let count = try await repo.unreadCount()try await repo.save(count) // 讀完即寫緩存return count}
}
4.4 Data 層實現(摘錄)
final class ConversationApiDataSource: ConversationRepository {private let api: URLSessionprivate let cache: UserDefaultsfunc unreadCount() async throws -> UnreadCount {let (data, _) = try await api.data(from: URL(string: "/unread")!)let json = try JSONDecoder().decode(UnreadDTO.self, from: data)return .init(json.total)}func save(_ count: UnreadCount) async throws {cache.set(count.value, forKey: "unread_total")}
}
4.5 Presentation 層集成
final class UnreadCubit: Cubit<UnreadState> {private let getCount: GetUnreadCountUseCaseinit(getCount: GetUnreadCountUseCase) {self.getCount = getCountsuper.init(Initial())}@MainActorfunc fetch() {Task {emit(Loading())do {let count = try await getCount.execute()emit(Loaded(count))} catch {emit(Failed(error))}}}
}
- UI 只感知
UnreadState
,不關心 Repository 具體實現。 - 想改用 Realm 緩存?僅替換
ConversationApiDataSource
,Domain 與 UI 零改動。
5 單元測試策略
final class FakeConversationRepo: ConversationRepository {var next: UnreadCount = .init(3)func unreadCount() async throws -> UnreadCount { next }func save(_ count: UnreadCount) async throws { /* no-op */ }
}func testGetUnreadCount() async throws {let repo = FakeConversationRepo()let useCase = GetUnreadCountUseCase(repo: repo)let result = try await useCase.execute()XCTAssertEqual(result, .init(3))
}
- 無需啟動 App、無需網絡;執行速度毫秒級。
- Entity 的不變式可直接覆蓋極端值(負數、溢出等)。
6 如何在舊項目落地
- 挑出最穩定的業務規則(如價格計算、權限判斷)。
- 抽成純 Swift 類型,斬斷 UIKit / CoreData 依賴。
- 對 UI 暴露 Use Case 協議,用 DI 容器(例:Swinject)注入實現。
- 漸進式替換:新功能強制走 Domain;舊代碼按需遷移。
- 持續加測試,確保遷移未破壞行為。
7 結語
Domain 層讓 iOS 項目的業務核心脫離平臺細節,既提高可測試性,又帶來長久可維護性。
掌握它,你將在大型團隊協作與多端共享邏輯(watchOS / visionOS / server Swift)時,享受顯著的工程收益。
Happy refactoring!