在现代 Android 架构中,Data 层是整个系统稳定性与可维护性的基石。
无论是 Clean Architecture、MVVM、MVI,还是模块化架构,Data 层都承担着「真实世界 → 领域逻辑」的关键职责。
然而,在实际项目中,Data 层往往最容易出现隐性风险:阻塞、错误的并发模型、接口不一致、伪异步等问题,会在后期演变成性能瓶颈、线程死锁、不可控的异常,甚至影响业务稳定性。
为了让 Data 层具备可维护性、可测试性与高并发安全性,我们总结出 四条绝对不能触碰的红线。
这些红线不是“最佳实践”,而是——底线要求。
红线一:接口返回类型必须是 Flow 或 suspend,禁止出现其他异步形式
❌ 常见错误写法
- 各种回调 callback
LiveData- RxJava(除非是历史包袱,不得不接)
- 自定义 Listener
Future/CompletableFuture
✔️ 正确做法
- 单次结果 →
suspend fun - 连续数据流 →
Flow<T>
为什么这是红线?
- Data 层在最底下,必须保持最小依赖与最纯粹的 Kotlin 并发模型。
Flow + suspend是 Kotlin 官方并发体系的核心:结构化并发、取消传播、背压、线程调度,这些能力都自带。- 接口风格统一之后,上层的 UseCase、ViewModel、UI 协程模型可以完全统一,复杂度会明显下降。
额外建议:Repository 层的接口要么是 Flow,要么是 suspend,不再混入 callback、Rx、LiveData 等其他异步形式。
如何选择 Flow 或 suspend 可参考你原文里链接的那篇指南。
1.1 错误示例:Repository 用 callback,接口风格不统一
上层用起来会变成:
问题:
- 一部分接口是 callback,一部分可能是 Rx 或 Future,风格混乱。
- 结构化并发、错误传播、取消,全部要自己手搓。
1.2 正确示例:统一用 suspend + Flow
实现层:
ViewModel 调用统一是协程风格:
红线二:禁止使用阻塞队列(BlockingQueue、LinkedBlockingQueue 等)
为什么禁止?
阻塞队列属于 Java 线程模型,与 Kotlin 协程的结构化并发完全不兼容,会导致:
- 阻塞线程池,导致
Dispatchers.IO/Default饱和 - 无法响应协程取消,阻塞调用卡在那里停不下来
- 生产者 / 消费者模型里很容易出现隐性死锁
- 性能不可控,难以写单测和压测
正确替代方案
| 需求 | 正确方案 |
|---|---|
| 生产者 / 消费者 | Channel |
| 单值共享 | MutableStateFlow |
| 多值事件流 | SharedFlow |
| 背压控制 | Flow + buffer() |
关键点
Data 层必须使用协程原生并发工具,而不是 Java 并发工具。
2.1 错误示例:在协程里用 BlockingQueue
问题:
take/put都会阻塞线程,即使你在协程里。- 一旦消费卡死,线程池被占,其他协程也被拖死。
- 协程取消不会让
take()自动退出。
2.2 正确示例:用 Channel 替代阻塞队列
红线三:禁止使用 Java 重锁(ReentrantLock、synchronized、wait/notify)
为什么禁止?
Java 重锁属于“阻塞式锁”,在协程世界里问题很多:
- 直接阻塞线程 → 破坏协程调度
- 协程取消不会自动释放锁 → 假取消
- 多个 Repository 并发访问时,非常容易搞出死锁
正确替代方案
| 场景 | 推荐工具 |
|---|---|
| 临界区保护 | Mutex |
| 原子操作/自增计数 | Atomic* |
| 多协程之间的协作 | Channel / Flow |
| 控制资源访问并发度 | Semaphore |
关键原则
Data 层必须使用协程友好的同步原语,确保不会阻塞线程。
3.1 错误示例:在 suspend 中使用 ReentrantLock
这是标准的“协程壳 + 线程锁”的伪协程模型。
3.2 正确示例:用 Mutex 替代重锁
好处:
withLock内部是挂起,不阻塞线程。- 与协程取消、结构化并发天然兼容。
红线四:suspend 方法内部必须是真正的异步,禁止伪异步
❌ 常见反面例子(看起来是 suspend,实际上还是在阻塞):
- 在
suspend方法里直接用同步网络请求、文件 IO 等阻塞式 API,啥调度都不做 - 在
suspend里Thread.sleep、阻塞锁来回用,只是换了个壳子 - 在
suspend里用同步 Room DAO,甚至配合allowMainThreadQueries()在主线程跑
✔️ 正确打开方式:
- 阻塞式 IO 必须包在
withContext(Dispatchers.IO)里 - 能用 Retrofit、Room 提供的
suspend或 Flow API 就不要自己造轮子 - 并发和通信统一用 Channel / Flow / Mutex / Semaphore 这一套协程工具
4.1 网络:同步 OkHttp vs Retrofit suspend
4.1.1 反例:suspend + 同步 execute(伪异步)
如果在 Dispatchers.Main 调用它,直接卡 UI 并且crash;
4.1.2 正例:至少用 withContext(IO) 包一下
4.1.3 更推荐:直接用 Retrofit 的 suspend API
ViewModel:
4.2 延时 / 定时:Thread.sleep vs delay
4.2.1 反例:用 Thread.sleep 模拟“耗时”
4.2.2 正例:用 delay 挂起,不阻塞线程
4.3 文件 IO:直接读 vs withContext(IO)
4.3.1 反例:直接在 suspend 中读文件
4.3.2 正例:IO 密集逻辑用 withContext(IO)
4.4 CPU 密集:Main 上跑计算 vs Default dispatcher
4.4.1 反例:不切 Dispatcher,直接算
4.4.2 正例:用 Dispatchers.Default
ViewModel 调用:
4.5 Room:禁止 allowMainThreadQueries + 同步 DAO
4.5.1 反例:allowMainThreadQueries + 同步 DAO(红线)
ViewModel:
结论:禁止在生产代码中使用
allowMainThreadQueries()。
4.5.2 过渡方案:同步 DAO + withContext(IO)
ViewModel:
这可以作为历史包袱的临时过渡方案,但不要再新增同步 DAO。
4.5.3 推荐:Room 原生 suspend + Flow
Repository:
ViewModel:
Room 相关硬规范:
- 禁止
allowMainThreadQueries()- 新 DAO 一律使用
suspend/Flow- 老同步 DAO 用
withContext(IO)包一层作为过渡,逐步迁移到挂起 API
4.6 为什么这一条也算红线?
suspend这个关键字本身不代表“异步”,它只是“可以挂起”。- 如果里面全是阻塞调用,那协程模型基本就废了。
- 伪异步的典型后果:
- IO 线程池被耗光
- UI 偶发卡顿,很难排查
- 协程取消看起来成功了,其实底层还在跑
- 系统整体性能下降
简单自检三问:
- 阻塞型操作是否都用
withContext(Dispatchers.IO/Default)隔离在合适的线程池? - 是否优先调用了真正的异步 API(Retrofit suspend、Room suspend / Flow)?
- 是否还在用
File、Socket、传统 Java IO、同步 Room DAO 顶在主逻辑上?
总结:Data 层的四条红线不是建议,而是底线
| 红线 | 核心问题 | 正确做法 |
|---|---|---|
1. 接口必须是 Flow 或 suspend | 接口风格混乱、并发模型不统一 | 统一到 Kotlin 并发模型(Flow + suspend) |
| 2. 禁止阻塞队列 | 阻塞线程、死锁风险 | 使用 Channel / Flow |
| 3. 禁止 Java 重锁 | 阻塞式锁破坏协程,死锁难排查 | 使用 Mutex / Atomic* / Semaphore |
4. suspend 内必须是真异步(含 Room) | 伪异步导致性能与稳定性问题 | withContext + 真异步 API,Room 用挂起/Flow |
结语:Data 层的稳定性决定整个系统的稳定性
Data 层是所有业务的根基。
一旦底层出现阻塞、死锁、伪异步等问题,越往上影响越大,最终会演变成难以排查的线上问题。
坚持这四条红线,就是在为整个系统的长期可维护性和稳定性负责:
- 接口统一:Flow + suspend
- 并发统一:Channel / Flow / Mutex / Semaphore
- 阻塞统一隔离:withContext + 真异步 API
- 数据库统一走 Room 的 suspend / Flow
把这些变成团队的“硬规范”,Data 层就会从隐性地雷区,变成真正可靠的基础设施。