不要让调用方承担你本该承担的复杂度 —— Android Data 层设计原则

谁最了解复杂度,谁最该承担它。


前言

在做 Android 架构评审时,我经常看到这样的代码:

ViewModel 在关心什么?它在关心缓存策略、缓存 key 的格式、是否要强制刷新。这些本不该是它的事。

这就是复杂度泄露------一层不该承担的复杂度,流窜到了它的调用方。

这篇文章想聊一个设计原则,以及它在 Android Data 层的落地实践。


一、核心原则:复杂度要被安置到合适的地方

先说原则的本质:

不要让调用方承担你本该承担的复杂度,谁最了解复杂度,谁最该承担它。

这句话背后是封装的哲学。但封装不是把复杂度藏起来,而是把它安置到最合适的地方。

在 Android Data 层,三类角色各司其职:

角色 了解什么 该承担什么
RemoteDataSource 网络协议、序列化、HTTP 状态码 网络重试、超时处理、异常转换
LocalDataSource 数据库结构、SQL、缓存格式 读写操作、数据版本迁移
Repository 数据来源、缓存策略、刷新时机 数据流编排、来源判断、异常统一封装
ViewModel 用户意图、UI 状态 只管"我要什么数据"

每一层只承担属于自己的那一份复杂度,不多也不少。


二、复杂度泄露的三种常见表现

1. 接口参数暴露实现细节

forceRefresh、cacheKey、fallbackToLocal 这些参数,是 Repository 自己的内部决策,不应该暴露给 ViewModel。你把参数暴露出去的那一刻,就是在告诉调用方:你来决定怎么取数据吧。


2. 异常直接透传给上层

ViewModel 根本不该知道 HttpException 和 SQLiteException 是什么。这些是底层细节,Repository 应该把它们消化掉,转换成业务语义:

3. 数据来源判断逻辑外泄

这段逻辑本属于 Repository,写在 ViewModel 里是严重的职责错位。Repository 的核心价值之一,就是对上层屏蔽数据来源:


三、Repository 的边界:不是上帝类

这个原则有一个容易踩的坑------把"谁了解谁承担"理解成:

"那我把所有复杂度都塞进 Repository 就好了。"

然后 Repository 变成上帝类,几千行,什么都管。

封装 ≠ 藏污纳垢。

Repository 应该承担的是编排逻辑,不是实现细节。以下复杂度不该由 Repository 承担:

  • 具体的 HTTP 请求构造 → 交给 RemoteDataSource
  • 具体的 SQL 语句 → 交给 LocalDataSource / DAO
  • 业务规则的组合计算 → 交给 UseCase

每一层向下调用时,都在说:"这部分复杂度我不了解,交给你。"


四、线程切换:被忽视的复杂度泄露

线程管理,是复杂度泄露里最隐蔽的一种------因为它不体现在接口参数里,而是藏在调用方式上。

反模式:调用方来切线程

表面上看没问题,但这背后的含义是:ViewModel 知道 getUser 是一个 IO 操作。

它为什么知道?因为 Repository 没有封装好线程,调用方被迫承担了"该在哪个线程跑"这个复杂度。

这是一种隐式的实现细节泄露。


正确做法:DataSource 自己承担线程切换

谁最了解一个操作该在哪个线程跑?DataSource 自己。

网络请求跑在 IO 线程,数据库读写跑在 IO 线程,这是 DataSource 的内部知识,不该往外透。

suspend 函数版:

这样 Repository 和 ViewModel 调用时,完全不需要关心线程:


Flow 版:用 flowOn 而非在 collect 侧切换

当 DataSource 返回 Flow 时,线程控制用 flowOn,它影响的是上游的执行线程,语义更精确:

flowOn 应该在最了解自己执行代价的那一层声明,通常是 DataSource,而不是调用方。


特别说明:Retrofit 和 Room 已经帮你做了

如果你用的是:

  • Retrofit + 协程 :suspend fun 接口方法内部已经自动切到后台线程,不需要再加 withContext
  • Room + 协程/Flow :Room 的 suspend 查询和 Flow 返回值,也已经在内部处理了线程

这种情况下,DataSource 层不需要额外的 withContext 或 flowOn。

但如果你有自定义 DataSource(比如读文件、操作 SharedPreferences、调用第三方同步 SDK),就必须自己在 DataSource 内部处理线程切换,绝对不能把这个负担留给调用方。


把线程也纳入职责表

回到开头的那张职责表,线程管理同样是复杂度的一部分:

角色 了解什么 该承担什么
RemoteDataSource 网络 IO、协议细节 网络重试、异常转换、线程切换
LocalDataSource 数据库 IO、文件 IO 读写操作、数据迁移、线程切换
Repository 数据来源、缓存策略 数据流编排、来源判断、异常封装
ViewModel 用户意图、UI 状态 只管"我要什么数据",无需感知线程

ViewModel 里最理想的状态:launch 后面不跟任何 Dispatcher。


五、实践中的细节

用 sealed class 表达业务语义,而非原始异常

这样 ViewModel 处理的是业务错误,而不是底层技术细节。


Flow 是天然的复杂度封装器

调用方拿到的只是一个 Flow,完全不知道里面发生了什么。


不要为了"灵活性"过早暴露参数

很多时候我们加 forceRefresh: Boolean 是因为"万一 ViewModel 需要手动刷新呢"。

更好的方案是:

两个函数,语义清晰,调用方不需要理解 forceRefresh=true 到底意味着什么。


六、一句话总结

复杂度不会消失,只会转移。好的设计,是让它停留在最了解它的那一层,既不向上泄露,也不堆积。

参考

为什么我不在 Android ViewModel 中直接处理异常

Android Data 层设计的四条红线:为什么必须坚持、如何落地

相关推荐
Starry-sky(jing)16 小时前
Vidda ADB 开启:R Pro 65 找不到开发者选项的处理与连接验收
android·adb·远程控制·电视·vidda
雨白16 小时前
从原理到实践:使用 Kotlin KSP 仿写 ButterKnifeLite
android·kotlin
疯华整猫17 小时前
把二十七个 init 从首帧前面搬走:用 Trae 把启动优化钉进 CI
android·性能优化·ai编程
传奇开心果18 小时前
【探秘 Jetpack Compose 框架深水区】 第8课:rememberSaveable 的边界在哪里?——Bundle、Saver 与什么值得跨进程存活
android·kotlin
Frankie_stand19 小时前
测试自动化实战指南:从脚本化、自动判据到 CI 集成的完整落地路径
android·ci/cd·自动化
恋猫de小郭20 小时前
Flutter 大小核优化又被回滚,然后又开新提案?
android·前端·flutter
Kapaseker20 小时前
领导问我 Kotlin 的 Nothing 是什么?
android·kotlin
韩曙亮20 小时前
【错误记录】安卓 adb 调试 5037 端口占用排查 ( adb 的三层结构 | 找出本机占用 5037 的进程 )
android·数据库·adb·安卓·错误记录
三少爷的鞋21 小时前
不改变异步本质,只改变代码组织:Telegram 项目的 Kotlin 协程实践
android