目录
- 为什么需要 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!