AI 正在改变软件开发。
以前,一个程序员可能需要花半天时间查 API、试错、调线程。现在,只要把需求扔进对话框,AI 几分钟就能生成一套看起来相当完整的代码。以前需要自己推敲半天的架构,现在 AI 甚至可以帮你画出精美的架构图。
写代码正在变得越来越便宜,便宜得就像不要钱一样。
但有一件事情,并没有因此变得容易一丝一毫:
判断 AI 给出的代码,到底是个什么水平。
AI 很擅长生成一种东西,叫作局部正确(Local Correctness)。
代码可以编译,可以运行,甚至可以通过单元测试。但当你兴冲冲地把它合入主干,准备迎接老板夸奖时,它可能反手给你一个 ANR、内存泄漏,或者一个很难复现的并发问题。
为什么?
因为软件开发真正困难的地方,从来不是把代码敲进编辑器,而是:
- 这个职责到底应该由谁来承担?
- 这个资源的生命周期究竟属于谁?
- 这个任务应该在哪里启动,又应该在哪里取消?
- 这个异常被处理之后,会不会破坏原有的控制流程?
- 这个设计在局部看起来合理,放到整个系统中是否依然成立?
这些问题,才是真正考验工程师能力的地方。
今天,我们就从 Android 协程和架构设计出发,聊聊 AI 时代程序员应该守住哪些工程底线。
一、理解协程:不要把 API 当成万能解决方案
1. 别把 launch 和 withContext 混为一谈
在日常 Code Review 中,有一种代码几乎随处可见:
kotlin
viewModelScope.launch(Dispatchers.IO) {
val user = repository.getUser()
}
很多开发者已经形成了肌肉记忆:
- 需要网络请求?
launch(Dispatchers.IO)。 - 需要读数据库?
launch(Dispatchers.IO)。 - 不知道怎么处理?先切个 IO 再说。
但这里其实混淆了两个概念:任务启动和执行调度。
launch 的核心职责是创建一个新的协程任务,而不是切换线程。例如:
kotlin
viewModelScope.launch {
val user = repository.getUser()
}
这里的 launch 默认继承 viewModelScope 的 CoroutineContext。通常情况下,它会在 Main Dispatcher 上执行。
如果 Repository 内部已经正确处理了 IO 调度,那么调用方根本不需要再指定 Dispatchers.IO。例如:
kotlin
class UserRepository(
private val api: UserApi
) {
suspend fun getUser(): User {
return withContext(Dispatchers.IO) {
api.getUser()
}
}
}
这里需要理解三个概念:
- CoroutineScope:决定协程属于谁,以及生命周期如何管理。
- CoroutineContext:描述协程运行时所使用的上下文。
- Dispatcher:负责决定协程在哪个线程或线程池执行。
它们承担的是不同职责。launch 用于启动任务,withContext 用于在当前协程中切换执行上下文。
不要为了执行一个挂起函数,就习惯性地在调用方指定
Dispatchers.IO。
2. 谁负责执行,谁就应该负责调度
一个健康的 Android 架构,Data 层应该具备 Main-safe 能力。
什么叫 Main-safe?就是无论调用方是否在主线程调用,我都能够保证自己不会因为阻塞操作而导致主线程卡顿。例如:
kotlin
class UserRepository(
private val api: UserApi
) {
suspend fun getUser(): User {
return withContext(Dispatchers.IO) {
api.getUser()
}
}
}
上层只需要:
kotlin
viewModelScope.launch {
val user = repository.getUser()
}
ViewModel 不需要关心底层使用的是同步 IO、线程池,还是其他执行机制。这就是职责边界。
如果每个调用方都需要知道 Repository 内部使用什么线程,那么线程调度的实现细节就已经泄漏到了上层。
这里有一个值得深入理解的观点:Main-safe 不仅仅是线程规范,更是 Data 层对调用方的一种架构契约。
调用方可以在主线程发起调用,却不必知道底层是否涉及网络、数据库、文件 IO 或 CPU 计算。这意味着线程模型和阻塞风险都应该被封装在合适的层级,而不是让 ViewModel、UseCase 到处承担调度职责。
关于这个问题,我专门写过一篇文章: 👉 《Main-safe:现代 Android 架构真正的分水岭》
线程调度应该由执行具体工作的那一层负责,而不是由发起工作的那一层负责。
二、管理好并发:不要让任务和资源失去归属
1. Repository 不要偷偷 launch
有些代码喜欢这样写:
kotlin
class UserRepository(
private val scope: CoroutineScope
) {
fun loadUser() {
scope.launch {
api.loadUser()
}
}
}
看起来很方便。调用方只需要调用 loadUser(),Repository 就会自动在后台完成任务。但问题也随之而来:
- 谁负责等待结果?
- 谁负责取消任务?
- 如果页面销毁了,这个任务还要继续运行吗?
- 如果请求失败,异常应该交给谁处理?
这些问题在当前设计中都没有明确答案。更自然的设计应该是:
kotlin
class UserRepository(
private val api: UserApi
) {
suspend fun loadUser(): User {
return api.loadUser()
}
}
调用方:
kotlin
viewModelScope.launch {
val user = repository.loadUser()
}
这里有一个非常重要的区别:
suspend表示我提供一种可以被协程调用的执行能力。launch则表示创建一个新的协程任务,并确定它所属的作用域。
suspend是能力,launch是任务归属。
一次性数据操作,通常应该由调用方决定何时启动、何时取消。当 ViewModel 销毁时,viewModelScope 会取消它所管理的协程,取消信号也会向下游传播。这样,任务的生命周期就有了明确的所有者。
当然,Repository 并不是绝对不能持有 CoroutineScope。例如应用级数据同步、WebSocket 长连接、后台缓存刷新,这些任务本身就可能属于应用生命周期。关键不在于 Repository 能不能 launch,而在于:
这个任务究竟应该属于调用方,还是应该属于应用本身?
如果没有明确的生命周期归属,就不要随意创建新的协程作用域。
我之前专门讨论过这个问题:为什么现代 Android 架构更推荐 Repository 暴露 suspend fun,而不是在内部偷偷启动 launch。 👉 《现代 Android 官方为什么更推荐 Repository 暴露 suspend fun,而不是在内部 launch》
这篇文章进一步讨论了任务生命周期、取消权、完成时机和异常边界。它们看似是协程 API 的使用细节,实际上决定了架构中谁拥有这项工作的控制权。
2. 不要把 Java 阻塞并发模型直接搬进协程
Kotlin 协程并不是简单地给 Java 线程池换了一层语法。它有自己的一套并发原语。
例如,下面这些 Java 并发工具在协程业务链路中需要谨慎使用:
BlockingQueueLinkedBlockingQueueReentrantLockwait/notifysynchronized
这里并不是说 Java 并发工具全部不能使用,而是需要理解它们的执行语义。
先看 BlockingQueue:
kotlin
val queue = LinkedBlockingQueue<String>()
suspend fun consume() {
val value = queue.take()
println(value)
}
虽然 consume 是 suspend 函数,但 take() 依然会阻塞当前线程。这意味着协程可能占据一个线程,只为了等待队列中出现数据。如果大量协程都采用这种方式等待,线程池资源就可能被消耗。
更适合协程的方式是 Channel:
kotlin
val channel = Channel<String>()
suspend fun consume() {
for (value in channel) {
println(value)
}
}
当 Channel 暂时没有数据时,消费者协程可以挂起,而不需要阻塞底层线程。
再来看锁。例如:
kotlin
val mutex = Mutex()
suspend fun update() {
mutex.withLock {
// 临界区
}
}
Mutex 是面向协程设计的同步原语。它允许协程在等待锁时挂起,而不是阻塞线程。
特别需要注意的是 ReentrantLock。它具有线程所有权语义。如果在持有锁期间发生挂起,协程恢复时可能运行在另一个线程上,从而导致释放锁时出现线程所有权问题。
synchronized 也不是绝对不能使用。对于短暂、不发生挂起的同步临界区,它仍然有合理的使用场景。关键是不要把阻塞线程的等待方式,误认为协程中的挂起等待。
3. suspend 不代表非阻塞
这是一个非常容易被忽略的问题。有些开发者认为,只要给函数加上 suspend,它就自动变成异步非阻塞了。例如:
kotlin
suspend fun loadData(): Data {
Thread.sleep(3000)
return fetchData()
}
这段代码虽然是 suspend 函数,但 Thread.sleep 依然会阻塞当前线程。suspend 只是说明函数具备挂起能力,并不意味着函数内部一定使用了非阻塞操作。
如果调用的是传统阻塞 API,应该明确进行调度隔离:
kotlin
suspend fun loadData(): Data {
return withContext(Dispatchers.IO) {
legacyApi.loadData()
}
}
如果只是延时等待,则应该使用:
kotlin
suspend fun loadData(): Data {
delay(3000)
return fetchData()
}
两者有本质区别:
Thread.sleep()是阻塞当前线程。delay()是挂起当前协程,不需要一直占用线程等待。
再看计算任务:
kotlin
suspend fun calculate(): Result {
return withContext(Dispatchers.Default) {
heavyCalculation()
}
}
异步是一种执行能力,非阻塞是一种资源使用方式。两者不是同一个概念。
4. 不要吞掉 CancellationException
AI 生成代码时,经常会出现这样的异常处理:
kotlin
try {
repository.load()
} catch (e: Exception) {
// 处理异常
}
看起来没有问题,但这里隐藏着一个非常重要的控制信号:CancellationException。
协程取消不是普通的业务异常,而是结构化并发中的控制机制。
正确写法:
kotlin
try {
repository.load()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// 处理真正需要处理的异常
}
另外,Flow 的 catch 操作符对取消异常具有特殊处理,不应该把它和普通 try-catch 简单等同。异常处理的关键,不是捕获得越多越安全,而是明确哪些异常应该处理,哪些控制信号必须继续传播。
不要把协程取消,当成系统运行失败。
三、守住架构边界:不要让数据和状态失去秩序
1. 不要为了通信而制造全局事件总线
回顾以前的 Android 项目,EventBus 和 LocalBroadcastManager 曾经是非常常见的通信方式。例如:
- A 页面发送事件:
EventBus.getDefault().post(UserLoginEvent()) - B 页面订阅事件:
@Subscribe fun onUserLogin(event: UserLoginEvent) { refreshUser() }
这种方式的问题在于依赖关系是隐式的。A 页面并不知道谁在监听,B 页面也不清楚事件究竟来自哪里。随着业务增长,事件的发送者和接收者越来越多,整个系统就容易形成一张难以追踪的通信网络。
现代 Android 开发中,可以根据不同场景选择更明确的通信机制:
| 场景 | 推荐方式 |
|---|---|
| UI 持续状态 | StateFlow |
| 多消费者事件广播 | SharedFlow |
| 单消费者任务传递 | Channel |
| 跨页面共享业务状态 | Repository + Flow |
| 同 Activity 下 Fragment 通信 | Shared ViewModel |
例如,ViewModel 暴露 UI 状态:
kotlin
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
UI 层:
kotlin
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
这里,UI 不需要手动注册和注销事件监听器。
但需要注意,SharedFlow 并不天然保证一次性事件不会重复,也不保证事件一定被接收。例如:
kotlin
private val _events = MutableSharedFlow<UiEvent>(replay = 0)
当没有活跃订阅者时,事件可能直接丢失。如果设置 replay,又可能让新订阅者收到之前的事件。所以,一次性事件的设计,需要结合消费者数量、生命周期、重放策略和业务是否允许丢失来决定。
还有一个容易犯的错误:把 EventBus 换成一个静态 GlobalEventBus,内部使用 MutableSharedFlow,然后在任何地方都可以发送事件。这并没有解决原来的问题,底层换成了 Flow,但全局隐式依赖依然存在。
技术可以升级,但架构上的错误不会因为换了 API 就自动消失。
关于这部分,我也写过一篇专门讨论事件总线的文章,里面结合了 UI 状态、Fragment 通信、一次性事件和跨模块状态共享等场景: 👉 《Android 现代架构不需要事件总线》
2. Data 层应该优先使用 suspend 和 Flow
现代 Android 项目中,Data 层应该尽量统一异步接口的表达方式。通常:
- 一次性异步操作使用
suspend。 - 持续数据更新使用
Flow。 - 纯计算和内存数据访问保留普通同步函数。
例如:
kotlin
interface UserRepository {
suspend fun getUser(): User
fun observeUser(): Flow<User>
suspend fun updateUser(user: User)
}
这套接口表达非常清晰。调用方不需要关心底层使用了 Retrofit、Room,还是传统 Callback API。例如,某些系统 API 仍然使用 Callback:
kotlin
fun observeLocation(): Flow<Location> = callbackFlow {
val callback = object : LocationCallback {
override fun onLocationChanged(location: Location) {
trySend(location)
}
}
locationManager.register(callback)
awaitClose {
locationManager.unregister(callback)
}
}
这里 Callback 依然存在,但它被封装在 Data 层内部,没有泄漏到上层。这才是我们真正想要的设计。
我们要消灭的不是 Callback,而是 Callback 对上层架构的侵入。
3. Data 层必须处理好异常,不能让底层异常随意向上泄漏
这是分层架构中一个很容易被忽略的问题。假设 Repository 直接调用 Retrofit:
kotlin
class UserRepository(
private val api: UserApi
) {
suspend fun getUser(): User {
return api.getUser()
}
}
当网络请求失败时,上层可能收到:
IOExceptionHttpException- JSON 解析异常
- 第三方 SDK 自己定义的异常
如果 ViewModel 直接处理这些异常,就意味着 ViewModel 必须了解底层使用了什么网络库、数据库或者 SDK。这显然不是一个理想的架构边界。未来项目从 Retrofit 换成其他网络实现,ViewModel 是否也需要跟着修改?
Data 层不仅要隐藏数据来源,还要隐藏底层技术的异常实现细节。
这里可以用一个更通用的架构原则来理解:
不要让调用方承担你本该承担的复杂度,谁最了解复杂度,谁最该承担它。
网络协议、HTTP 状态码、数据库结构、缓存策略,这些都是 Data 层应该了解的事情。ViewModel 关心的是用户意图和 UI 状态,而不是 HttpException 或 SQLiteException。
我在《不要让调用方承担你本该承担的复杂度 ------ Android Data 层设计原则》中专门讨论过这个问题,其中也包括异常透传、线程切换和数据来源判断等复杂度泄漏问题。 👉 《不要让调用方承担你本该承担的复杂度 ------ Android Data 层设计原则》
我更倾向于将异常处理拆成三个层次:
| 层级 | 主要职责 |
|---|---|
| Data 层 | 识别底层技术异常,转换为稳定的数据访问错误 |
| Domain 层 | 根据业务语义表达业务失败 |
| ViewModel | 消费结果,将业务结果映射为 UI 状态 |
这里有几个需要注意的地方:
IOException被转换为网络访问异常。HttpException被转换为统一的接口异常。CancellationException原样向上传播。- 没有识别的异常,不应该为了统一形式而全部吞掉。
Data 层的目的不是消灭异常,而是屏蔽底层技术细节,让上层不必依赖 Retrofit、Room 等具体实现。
接着看 Domain 层。Domain 层不应该继续依赖具体网络异常,而应该根据业务语义决定如何表达失败。
最后看 ViewModel:
kotlin
viewModelScope.launch {
when (val result = getUserUseCase()) {
is Result.Success -> {
_uiState.value = UiState.Content(result.data)
}
is Result.Failure -> {
_uiState.value = UiState.Error(result.error)
}
}
}
这样,ViewModel 不需要知道底层网络请求究竟抛出了 IOException,还是 HttpException。它只需要根据业务结果更新 UI 状态。
4. ViewModel 不应该重复处理底层异常
有了前面的分层设计,再来看 ViewModel。很多项目中会出现这样的代码:
kotlin
viewModelScope.launch {
try {
val user = repository.getUser()
_uiState.value = UiState.Content(user)
} catch (e: IOException) {
_uiState.value = UiState.Error("网络异常")
} catch (e: HttpException) {
_uiState.value = UiState.Error("服务器异常")
}
}
这段代码并不是语法错误,但它暴露了一个问题:ViewModel 已经知道底层使用了什么网络实现。
如果多个 ViewModel 都这么写,相同的异常处理逻辑就会散落在不同页面中。这会带来几个问题:
- 异常处理逻辑重复。
- 不同页面对同一种错误的处理方式可能不一致。
- 底层技术变化可能影响多个 ViewModel。
- ViewModel 承担了不属于自己的技术职责。
更合理的方式是:
UseCase / Repository 负责把异常变成结果,ViewModel 负责把结果变成 UI 状态。
关于这个观点,我还有一篇专门讨论 ViewModel 异常处理边界的文章: 👉 《为什么我不在 Android ViewModel 中直接处理异常?》
5. Data 层默认暴露冷流,不要过早转换成热流
很多开发者写 Repository 时,喜欢直接在内部使用 stateIn 或 shareIn。例如:
kotlin
class UserRepository(
private val scope: CoroutineScope
) {
val userFlow = dataSource.observeUser()
.stateIn(
scope,
SharingStarted.Eagerly,
User()
)
}
这样做并非一定错误,但它引入了额外的生命周期和资源管理问题。因为 stateIn 不只是一个类型转换操作,它还意味着:
- 需要一个
CoroutineScope。 - 需要决定上游何时启动。
- 需要决定数据缓存多久。
- 需要决定何时停止上游。
- 需要考虑多个消费者如何共享数据。
对于普通 Repository 数据接口,更自然的方式是:
kotlin
class UserRepository(
private val dataSource: UserDataSource
) {
fun observeUser(): Flow<User> {
return dataSource.observeUser()
}
}
由 ViewModel 根据 UI 的生命周期决定是否转换成 StateFlow:
kotlin
val userState = repository.observeUser()
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
User()
)
这样职责就比较清晰:
text
DataSource --> Repository --> ViewModel --> StateFlow<UiState> --> UI
Data 层负责数据从哪里来,以及如何转换。ViewModel 负责 UI 状态的组织和生命周期管理。
当然,Data 层并不是绝对不能使用热流。例如:
- 应用级登录状态。
- WebSocket 长连接状态。
- 蓝牙连接状态。
- 多个消费者共享同一个昂贵的数据源。
这些场景本身就存在明确的数据共享需求。因此,更准确的原则是:
冷流是默认的数据表达方式,热流是有明确共享需求时的资源管理策略。
6. runBlocking:同步世界与协程世界之间的桥梁
最后再聊一个经常出现在面试中的问题:runBlocking 和 launch 有什么区别?
一句话概括:
launch创建协程任务,不阻塞当前线程。runBlocking创建协程作用域,并阻塞当前线程,直到内部任务完成。
例如:
kotlin
fun main() {
runBlocking {
delay(1000)
println("Finished")
}
}
runBlocking 常用于普通 JVM 程序入口、测试代码,以及少数必须从同步接口调用挂起函数的场景。但在 Android 中,需要非常谨慎:
- Activity / Fragment 主线程中,不应该使用。
- ViewModel 中,不应该使用。
- 普通协程内部,不应该为了调用 suspend 函数而嵌套使用。
再来看 coroutineScope:
kotlin
suspend fun loadData() = coroutineScope {
val a = async { loadA() }
val b = async { loadB() }
a.await() to b.await()
}
两者最大的区别在于:
runBlocking是普通函数,会阻塞当前线程。coroutineScope是 suspend 函数,会挂起当前协程,不需要阻塞当前线程等待子任务。
runBlocking是同步世界的桥梁,coroutineScope是协程世界中的结构化并发容器。不要把桥梁当成日常通道。
👉 《Android 面试系列:runBlocking 到底该在哪用?》
四、代码可以生成,判断力需要积累
AI 能够在几秒钟内生成大量代码。
但代码生成得越快,工程师越应该关注那些无法通过简单编译检查的问题:
- 你能不能看出一个
launch(Dispatchers.IO)是否真的有必要? - 你能不能判断一个 suspend 函数是否真的非阻塞?
- 你能不能识别 Repository 内部偷偷启动的协程,会不会破坏任务生命周期?
- 你能不能理解 SharedFlow 为什么会丢失事件?
- 你能不能判断一个锁在协程挂起恢复之后是否依然安全?
- 你能不能判断一个异常应该在哪一层处理,应该转换成什么语义?
这些问题,才是软件工程真正需要的判断力。
我一直认为,好的架构不是把所有技术都用上,也不是把每一种可能性都提前抽象出来。而是明确职责,控制复杂度,让每一项设计都有存在的理由。
AI 可以帮我们写代码、查资料、生成架构图,甚至提出很多看起来不错的方案。但最终,仍然需要有人对系统的运行结果负责。
代码越来越便宜,判断力越来越贵。
别做被 AI 牵着鼻子走的复制粘贴工具人。
做一个真正理解系统运行机制、能够识别复杂度、敢于对不合理设计说不的工程师。