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)整理出的概念,将应用看作同心圆式的分层结构。

  • 最内层:领域。实体和业务规则。“这个应用是做什么的?”
  • 中间层:UseCase。应用的行为场景
  • 最外层:UI、数据库、网络和框架

规则只有一条。依赖始终只能从外向内。

内层的领域代码不应了解外层的 UIKit、URLSession 或 SwiftData。反过来,外层可以了解内层。这样无论替换 UI 框架还是修改服务器 API,都不必触碰应用的核心规则。

正如你可能已经注意到的,这相当于将 DIP(依赖倒置原则)扩展到整个应用的规模,也就是按分层应用“依赖抽象,而不是具体实现”。


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:承载“这个应用要做的一件事”的容器

UseCase 是将一个应用行为场景封装成对象,例如“加载信息流”或“发布文章”。

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 的陷阱在于过度引入。一个只有两个页面的应用如果完整配置 UseCase、Repository、DTO 和 Mapper,传递一个值就要经过五个文件。VIPER(View·Interactor·Presenter·Entity·Router)的样板代码问题也会原样重现。

实际的判断标准如下。

  • **Repository 几乎总是有价值。**仅仅将网络和 DB 代码从页面中分离出来,就能让测试和修改更加容易。
  • **当出现多个页面共享的业务规则时,再引入 UseCase。**如果大多数 UseCase 只是原样转发 Repository 调用,那还为时过早。
  • **按分层拆分模型(DTO/领域/页面模型)应等项目规模扩大后再做。**一开始就全部拆分,只会不断堆积 Mapper 代码。
不要一开始全部配齐,而是在需要时逐层构建
不要一开始全部配齐,而是在需要时逐层构建

总结

  • Clean Architecture 不是目录结构,而是“依赖只能向内(领域)”这一原则,是将 DIP 扩展到应用规模的结果。
  • Repository 是隐藏数据来源的边界,关键在于协议由领域拥有。
  • UseCase 是放置多个页面共享业务规则的地方。它与 MVVM 正交,因此可以一起使用。
  • 目标不是全部配齐。从 Repository 开始,等规则积累起来后再添加 UseCase。

下一篇我们换个方向,介绍将状态管理本身构建成架构的 TCA(The Composable Architecture)。