什么是 Android Clean Architecture

一个订单页面需要请求商品价格、计算满量折扣,再显示总价。最直接的写法,是在 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 状态处理出了问题。

五、常见误区:看起来分了层,实际仍然耦合

  1. 每个接口都包装一个只调用一次的 UseCase。 没有业务规则、复用或明确边界价值时,这会制造大量转发类。领域层是可选层,不是打卡项目。
  2. 把 Retrofit DTO 或 Room Entity 一路传到 UI。 页面被外部数据格式牵着走,服务端字段变化会越过边界。必要时分别定义 DTO、领域模型和 UiModel;但简单页面也不要为了"纯洁"做无意义的一对一复制。
  3. Repository 同时负责网络、缓存、金额规则、导航和埋点。 Repository 更适合管理数据来源及同步策略;真正的业务决策放在领域用例,导航留在表现层。
  4. 用 Context、LiveData 或 ViewModel 写领域规则。 这会把规则锁进 Android 生命周期。领域层尽量使用普通 Kotlin 数据、接口和协程能力。
  5. 以为所有依赖都必须单向调用。 运行时外层实现会被内层接口调用;规则约束的是源码"认识谁",不是方法调用箭头只能向内。
  6. 忽略错误和事务边界。 一个 UseCase 涉及多次写入时,不能因为它叫"用例"就自动获得原子性。事务通常由数据层提供能力,再通过清晰的接口暴露给用例。

六、现有项目如何渐进改造?

不要先新建十几个模块。选一个经常变化、业务规则明确的功能做纵向切片:

  1. 找出当前页面里的业务规则与外部 I/O;先写出需要保护的测试用例。
  2. 把规则抽到不依赖 Android UI 的普通 Kotlin 类中。
  3. 为规则所需的数据能力定义最小接口,把 Retrofit/Room 访问留给数据实现。
  4. 让 ViewModel 只协调事件、调用用例并暴露 UI 状态。
  5. 由应用组合根接线,补齐数据边界与页面状态测试,再评估是否值得拆模块。

衡量是否成功,不看 UseCase 类的数量,而看两个问题:改 UI 时会不会误伤业务规则?换数据来源时会不会改报价逻辑? 如果答案都趋近于"不会",边界就开始发挥作用了。

七、面试时可以这样回答

Clean Architecture 的核心是什么? 业务规则位于更稳定的内层,源码依赖向内。外层技术细节通过内层需要的抽象接入,使业务不依赖具体 UI、网络或数据库实现。

它和 MVVM 是什么关系? MVVM 是表现层组织方式,Clean Architecture 是跨层边界和依赖规则;二者可以组合。

Android 一定要有 domain 模块和 UseCase 吗? 不一定。业务复杂、规则可复用或需要独立测试时很有价值;简单 CRUD 可以先用 UI 层与数据层。是否单独拆 Gradle 模块也取决于团队规模和构建成本。

Repository 接口为什么常放在领域层? 当领域用例需要数据能力时,由用例一侧声明所需接口,数据层实现它,避免领域反向依赖 Retrofit/Room。但若没有独立领域层,接口位置可以按实际依赖关系调整。

一句话总结:Clean Architecture 不是固定的三层文件夹模板,而是一种让核心业务规则远离易变技术细节的依赖设计。

参考资料

相关推荐
字节跳动的猫1 小时前
LikeShop 搜索功能优化二开:中文分词、商品权重排序与搜索热词管理
android·java·中文分词
恋猫de小郭1 小时前
Flitz ,一个把 Flutter 玩成热插拔 Preview 的有趣项目,不错的思路
android·前端·flutter
千码君20162 小时前
个人开发者:安卓手机adb调试
android·adb·智能手机
我不会插花弄玉13 小时前
7.函数【由浅入深-MySQL】
android·数据库·mysql
vilya13 小时前
手机端 Agent 怎么动起来:Kotlin 与 Python 之间的桥
android·agent
高晶14 小时前
Android 新手实战:从零搭建 WebRTC 在线直播系统
android·程序员
alexhilton14 小时前
为什么你的 LazyColumn 仍然卡顿
android·kotlin·android jetpack
>Andre<15 小时前
系统架构全景
android
hai_android19 小时前
Android 嵌套滑动冲突:内部拦截 vs 外部拦截实战
android·android studio·android jetpack