目录
- 为什么需要 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: Int
init(_ raw: Int) {
precondition(raw >= 0, "Unread cannot be negative")
value = raw
}
}
// Entity
struct Conversation: Identifiable, Equatable {
let id: UUID
private(set) var unread: UnreadCount
mutating 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: ConversationRepository
init(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: URLSession
private let cache: UserDefaults
func 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: GetUnreadCountUseCase
init(getCount: GetUnreadCountUseCase) {
self.getCount = getCount
super.init(Initial())
}
@MainActor
func 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!