代码越来越便宜,判断力越来越贵:AI 时代的 Android 架构与协程避坑指南

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 并发工具在协程业务链路中需要谨慎使用:

  • BlockingQueue
  • LinkedBlockingQueue
  • ReentrantLock
  • wait/notify
  • synchronized

这里并不是说 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()
    }
}

当网络请求失败时,上层可能收到:

  • IOException
  • HttpException
  • 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 牵着鼻子走的复制粘贴工具人。

做一个真正理解系统运行机制、能够识别复杂度、敢于对不合理设计说不的工程师。

相关推荐
Gracker2 小时前
SmartPerfetto 1.19.0:换过分析引擎之后,这次更新了什么
android
HQL_seven6 小时前
【Android】BroadcastReceiver 详解
android
_祝你今天愉快7 小时前
Paint Cavans高级绘制:[二] Paint-滤镜,XFERMODE
android
小乖写代码7 小时前
字段获取代码(Android) · Android Input Examples
android·whatsapp·账号注册
booboosui7 小时前
停一个活只有杀进程:界面上少了「说点要求」
android
传奇开心果编程10 小时前
【Flutter踩坑排雷】第28课:平台目录缺失与修复坑|`flutter create` 的“重建”与“覆盖”边界
android·flutter·ios·移动开发·跨平台·dart·声明式ui
longxibo19 小时前
【AIBIZ低代码平台:能长出业务系统的平台】
android·低代码·rxjava
AI_Gump1 天前
Harness篇|DeepSeek Harness 上手机:五条安卓移植路线与 App 内 Linux 拆解
android·linux·人工智能·智能手机·deepseek
疯华整猫1 天前
否决 Flutter 提案那天,我发现自己也说不清 Framework 管什么
android