一个订单页面需要请求商品价格、计算满量折扣,再显示总价。最直接的写法,是在
Activity或ViewModel里调用 Retrofit,拿到 DTO 后顺手计算折扣。功能能跑,但当接口字段改变、折扣规则增加、页面从 XML 换成 Compose 时,这段代码很可能一起被改动。
Clean Architecture(整洁架构)要解决的不是"类放进哪个文件夹",而是让业务规则尽量不被 UI、网络、数据库等具体技术绑住。 它最重要的一条规则是:源代码依赖指向内层;内层不认识外层。
本文以 Android/Kotlin 的订单报价功能为例,说明这条规则如何落到代码、模块与测试中。
一、先分清:Clean Architecture 不是 MVVM 的替代品
MVVM 主要描述表现层内部 如何组织:UI 把事件交给 ViewModel,ViewModel 暴露状态供 UI 渲染。Clean Architecture 描述的是整个应用的边界与依赖方向。因此,项目可以同时采用 MVVM 和 Clean Architecture;也可以用 MVI 组织表现层,而依旧遵守同样的依赖规则。
常见的 Android 落地方式如下:
| 层 | 关心什么 | 典型代码 | 不应直接依赖什么 |
|---|---|---|---|
| 表现层(presentation) | 用户事件、页面状态、展示格式 | Compose/Fragment、ViewModel、UiState | Retrofit DTO、Room Entity、数据库实现细节 |
| 领域层(domain,可按复杂度取舍) | 业务概念、规则、用例、所需能力的抽象 | Product、GetOrderQuoteUseCase、ProductRepository 接口 |
Context、View、Retrofit、Room |
| 数据层(data) | 从哪里取得数据、如何缓存和同步、如何转换模型 | Retrofit API、Room DAO、Repository 实现、DTO 映射 | 具体页面如何显示 |
这里的"内层"不是指运行时先执行的代码,而是编译时的代码依赖 。页面点击后,调用链通常是 UI → ViewModel → UseCase → Repository 实现 → 网络/数据库;但源代码依赖可以是 presentation → domain ← data。领域层定义自己需要的 ProductRepository 接口,数据层来实现它。运行时的调用方向与源码依赖方向不必相同。
text
运行时调用:UI → UseCase → Repository 实现 → API
presentation ────────→ domain ←──────── data
ViewModel 模型 / 规则 / 接口 Retrofit / Room / 实现
↑
app 负责装配具体对象
app 模块通常承担依赖注入、导航和应用启动等组合工作。它知道哪些具体实现要交给哪些抽象,但这些细节不应反向渗入领域规则。
二、用订单报价写一遍完整的数据流
需求:输入商品 ID 和购买数量,读取商品单价;购买三件及以上打九折;页面展示原价、优惠和实付金额。示例用"分"为金额单位,避免 Double 直接计算货币。
1. 领域层:先写业务含义
kotlin
// domain:没有 Android、Retrofit、Room 类型
data class Product(
val id: String,
val priceCents: Long,
)
data class OrderQuote(
val subtotalCents: Long,
val discountCents: Long,
val totalCents: Long,
)
interface ProductRepository {
suspend fun getProduct(id: String): Product
}
class GetOrderQuoteUseCase(
private val products: ProductRepository,
) {
suspend operator fun invoke(productId: String, quantity: Int): OrderQuote {
require(productId.isNotBlank()) { "商品 ID 不能为空" }
require(quantity in 1..99) { "购买数量必须为 1~99" }
val product = products.getProduct(productId)
require(product.priceCents >= 0) { "商品价格不能为负" }
require(product.priceCents <= Long.MAX_VALUE / quantity) { "金额超出计算范围" }
val subtotal = product.priceCents * quantity
val discount = if (quantity >= 3) subtotal / 10 else 0L
return OrderQuote(
subtotalCents = subtotal,
discountCents = discount,
totalCents = subtotal - discount,
)
}
}
规则"买三件打九折"在 UseCase 中,不在 Activity 中,也不在网络 DTO 的扩展函数中。以后接入本地缓存或更换接口,报价规则无需因此改写。示例里的 subtotal / 10 是"优惠金额向下取整到分"的简化约定;真实交易应与服务端统一舍入、税费、优惠叠加和最终结算口径,客户端报价不能单独作为扣款依据。
2. 数据层:把外部数据翻译成领域模型
kotlin
// data:以下示例使用 Retrofit
data class ProductDto(
val id: String,
val price_cents: Long,
)
interface ProductApi {
@GET("products/{id}")
suspend fun getProduct(@Path("id") id: String): ProductDto
}
class RemoteProductRepository(
private val api: ProductApi,
) : ProductRepository {
override suspend fun getProduct(id: String): Product {
val dto = api.getProduct(id)
return Product(id = dto.id, priceCents = dto.price_cents)
}
}
ProductDto 是接口契约,Product 是业务模型,两者有意分开。服务端把 price_cents 改名、增加字段或切换传输格式时,变化主要留在数据层。实际项目还应在数据边界处理 HTTP 错误、缓存策略、离线数据、数据校验和异常到业务错误的映射。
3. 表现层:把结果变成页面状态
kotlin
sealed interface QuoteUiState {
data object Idle : QuoteUiState
data object Loading : QuoteUiState
data class Success(val quote: OrderQuote) : QuoteUiState
data class Error(val message: String) : QuoteUiState
}
class QuoteViewModel(
private val getOrderQuote: GetOrderQuoteUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow<QuoteUiState>(QuoteUiState.Idle)
val uiState: StateFlow<QuoteUiState> = _uiState.asStateFlow()
fun quote(productId: String, quantity: Int) {
viewModelScope.launch {
_uiState.value = QuoteUiState.Loading
try {
val result = getOrderQuote(productId, quantity)
_uiState.value = QuoteUiState.Success(result)
} catch (e: CancellationException) {
throw e // 协程取消不是普通业务失败
} catch (e: Exception) {
_uiState.value = QuoteUiState.Error(e.message ?: "报价失败")
}
}
}
}
UI 只负责收集 uiState、渲染金额和发送点击事件。示例直接展示异常消息是为了突出分层;正式产品应把异常转换为稳定、可本地化的 UI 文案,不要把原始服务端或网络错误暴露给用户。若页面会连续触发多次报价,还要决定"后一次覆盖前一次"还是允许并发,并处理过期结果覆盖新结果的问题。
最后由组合根把它们连起来:ProductApi → RemoteProductRepository → GetOrderQuoteUseCase → QuoteViewModel。可以手动构造,也可以用 Hilt/Koin 完成注入。依赖注入工具不是 Clean Architecture 本身;即使用了 Hilt,如果 ViewModel 仍直接调用 Retrofit 并计算折扣,边界问题仍然存在。
三、模块怎么拆?先保证方向,再决定数量
一个多模块版本可以是:
text
app
├── feature:quote:presentation ──→ feature:quote:domain
├── feature:quote:data ──→ feature:quote:domain
└── 组合与依赖注入
feature:quote:domain ──→ Kotlin / 必要的基础库
domain 不依赖 data,也不引入 Android UI 类型。项目很小时,先在一个模块内建立明确的 package 边界也可以;模块数量并不能自动保证架构干净。等到多人并行、构建时间、边界测试或复用需求真正出现,再拆 Gradle 模块通常更经济。
Repository 接口放在哪里也不是宗教题。上例让领域用例定义它所需的抽象,因此接口位于 domain。如果项目没有独立领域层,或用例只是转发数据,按 Android 官方推荐的 UI 层与数据层组织、将 Repository 放在数据层也完全合理。关键是依赖方不直接绑死具体实现,而不是所有项目都必须复制同一套三层目录。
四、为什么这套边界让测试更容易?
最有价值的是领域规则可以在普通 JVM 测试中跑,不需要启动模拟器,也不需要真实网络:
kotlin
class FakeProductRepository(
private val product: Product,
) : ProductRepository {
override suspend fun getProduct(id: String): Product = product
}
@Test
fun `购买三件享受九折`() = runTest {
val useCase = GetOrderQuoteUseCase(
FakeProductRepository(Product("p1", priceCents = 1_000)),
)
val quote = useCase("p1", quantity = 3)
assertEquals(3_000, quote.subtotalCents)
assertEquals(300, quote.discountCents)
assertEquals(2_700, quote.totalCents)
}
测试还应覆盖数量边界、负价格、溢出与 Repository 失败。数据层另做映射、缓存和 API/DAO 集成测试;表现层测试"Loading → Success/Error"的状态变化。分层的收益不是测试文件变多,而是失败时更容易定位是业务规则、数据转换还是 UI 状态处理出了问题。
五、常见误区:看起来分了层,实际仍然耦合
- 每个接口都包装一个只调用一次的 UseCase。 没有业务规则、复用或明确边界价值时,这会制造大量转发类。领域层是可选层,不是打卡项目。
- 把 Retrofit DTO 或 Room Entity 一路传到 UI。 页面被外部数据格式牵着走,服务端字段变化会越过边界。必要时分别定义 DTO、领域模型和 UiModel;但简单页面也不要为了"纯洁"做无意义的一对一复制。
- Repository 同时负责网络、缓存、金额规则、导航和埋点。 Repository 更适合管理数据来源及同步策略;真正的业务决策放在领域用例,导航留在表现层。
- 用
Context、LiveData或ViewModel写领域规则。 这会把规则锁进 Android 生命周期。领域层尽量使用普通 Kotlin 数据、接口和协程能力。 - 以为所有依赖都必须单向调用。 运行时外层实现会被内层接口调用;规则约束的是源码"认识谁",不是方法调用箭头只能向内。
- 忽略错误和事务边界。 一个 UseCase 涉及多次写入时,不能因为它叫"用例"就自动获得原子性。事务通常由数据层提供能力,再通过清晰的接口暴露给用例。
六、现有项目如何渐进改造?
不要先新建十几个模块。选一个经常变化、业务规则明确的功能做纵向切片:
- 找出当前页面里的业务规则与外部 I/O;先写出需要保护的测试用例。
- 把规则抽到不依赖 Android UI 的普通 Kotlin 类中。
- 为规则所需的数据能力定义最小接口,把 Retrofit/Room 访问留给数据实现。
- 让 ViewModel 只协调事件、调用用例并暴露 UI 状态。
- 由应用组合根接线,补齐数据边界与页面状态测试,再评估是否值得拆模块。
衡量是否成功,不看 UseCase 类的数量,而看两个问题:改 UI 时会不会误伤业务规则?换数据来源时会不会改报价逻辑? 如果答案都趋近于"不会",边界就开始发挥作用了。
七、面试时可以这样回答
Clean Architecture 的核心是什么? 业务规则位于更稳定的内层,源码依赖向内。外层技术细节通过内层需要的抽象接入,使业务不依赖具体 UI、网络或数据库实现。
它和 MVVM 是什么关系? MVVM 是表现层组织方式,Clean Architecture 是跨层边界和依赖规则;二者可以组合。
Android 一定要有 domain 模块和 UseCase 吗? 不一定。业务复杂、规则可复用或需要独立测试时很有价值;简单 CRUD 可以先用 UI 层与数据层。是否单独拆 Gradle 模块也取决于团队规模和构建成本。
Repository 接口为什么常放在领域层? 当领域用例需要数据能力时,由用例一侧声明所需接口,数据层实现它,避免领域反向依赖 Retrofit/Room。但若没有独立领域层,接口位置可以按实际依赖关系调整。
一句话总结:Clean Architecture 不是固定的三层文件夹模板,而是一种让核心业务规则远离易变技术细节的依赖设计。