Domain 层完全指南(面向 iOS 开发者)

发布于:2025-06-25 ⋅ 阅读:(18) ⋅ 点赞:(0)

目录

  1. 为什么需要 Domain 层
  2. 清晰的三层架构
  3. 核心概念:Entity / Value Object / Use Case / Repository
  4. Swift 代码实战
  5. 测试策略
  6. 在旧项目中落地的步骤
  7. 结语

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 如何在旧项目落地

  1. 挑出最稳定的业务规则(如价格计算、权限判断)。
  2. 抽成纯 Swift 类型,斩断 UIKit / CoreData 依赖。
  3. 对 UI 暴露 Use Case 协议,用 DI 容器(例:Swinject)注入实现。
  4. 渐进式替换:新功能强制走 Domain;旧代码按需迁移。
  5. 持续加测试,确保迁移未破坏行为。

7 结语

Domain 层让 iOS 项目的业务核心脱离平台细节,既提高可测试性,又带来长久可维护性
掌握它,你将在大型团队协作与多端共享逻辑(watchOS / visionOS / server Swift)时,享受显著的工程收益。

Happy refactoring!


网站公告

今日签到

点亮在社区的每一天
去签到