避免ANR的核心原则:主线程不可阻塞,协程的正确使用

聊到ANR,很多人的第一反应是「我代码里没做耗时操作啊」。但你想想看,主线程不可阻塞这个原则,在实际的Compose项目中,远比想象中容易踩坑。我最早做加密UI的时候,就曾因为一个看似无害的Bitmap操作,让用户眼睁睁看着屏幕卡住不动。

ANR的本质是什么?说白了,就是系统觉得你主线程「罢工」太久了。Android系统会监控主线程的响应能力,一旦超过阈值(通常是5秒),就会弹出那个让人头疼的对话框。在Compose里,主线程不仅承载着传统的触摸事件和生命周期回调,还负责重组和布局。所以,阻塞主线程的代价更大。

核心原则一:主线程绝不阻塞

你可能觉得这是废话。但实际开发中,很多看似无关紧要的操作都会悄悄阻塞主线程。我列举几个常见的「隐形炸弹」:

  • 文件读写:从SharedPreferences读取加密配置
  • 位图处理:对解密后的图片进行裁剪或旋转
  • 复杂计算:加密算法中的非对称密钥生成
  • 网络请求:获取远程加密证书(虽然不常见)
  • 序列化:将加密数据转为JSON字符串

关键认知:Compose的UI不是「实时更新」的。它通过重组来响应状态变化。如果你在重组期间做了耗时操作,整个帧就会跳过,看起来就是卡顿。如果耗时超过5秒,ANR就来了。

举个例子,我有个朋友在Composable中直接写了这么一段代码:

@Composable
fun DecryptedImage(data: ByteArray) {
    // 这里有问题!
    val bitmap = decryptAndDecode(data) // 耗时操作,阻塞主线程
    Image(bitmap = bitmap.asImageBitmap(), contentDescription = null)
}

这段代码在重组时直接解密密文并生成图片,decryptAndDecode 如果处理的是大图,很容易超过16ms的帧预算。更糟的是,如果图片很大且加密算法复杂,完全可能触发ANR。

避坑指南:我曾经接手过一个项目,整个屏幕的UI都在一个Composable里。结果用户每次打开加密列表,都会卡住1-2秒。排查后发现,原来是在重组时做了大量的字符串加密校验。切记:重组函数里不要有任何耗时逻辑,哪怕只花100ms都不要。

核心原则二:用协程让出主线程

既然主线程不能阻塞,那怎么办?用协程把耗时操作扔到后台线程去。

Compose 对协程的支持其实挺好的。它提供了几个好用的工具:

API 作用 使用场景
LaunchedEffect 在Composable的生命周期内启动协程 执行异步初始化、数据加载
rememberCoroutineScope 获取当前组件的协程作用域 在事件回调中启动协程
snapshotFlow 将Compose状态转化为Flow 监听状态变化并执行副作用
withContext 切换协程的调度器 指定代码在哪个线程池执行

我个人习惯在加密操作中,始终把Dispatchers.Default或Dispatchers.IO作为后台调度器。具体选哪个,看你的操作类型:

  • CPU密集型:加密算法运算、密钥派生 → Dispatchers.Default
  • IO密集型:读写加密文件、网络请求证书 → Dispatchers.IO

来看一个改进后的版本:

@Composable
fun SafeDecryptedImage(data: ByteArray) {
    var bitmap by remember { mutableStateOf<ImageBitmap?>(null) }

    LaunchedEffect(data) {
        // 把耗时操作切换到 Default 线程池
        val result = withContext(Dispatchers.Default) {
            decryptAndDecode(data)
        }
        bitmap = result
    }

    bitmap?.let {
        Image(bitmap = it, contentDescription = null)
    }
}

这样,加密和解码的逻辑就在后台线程跑了,主线程只负责更新状态和重组UI,完全不会阻塞。

我的经验:处理大文件加密时,我还会加一个进度状态。用Dispatchers.Default做分块加密,每处理完一块就更新State<Float>,UI层就能实时显示进度条,用户体验好很多。

核心原则三:协程作用域必须关联生命周期

这一点很容易被忽略。很多人直接在Composable里用GlobalScope.launch,或者用lifecycleScope但不注意取消时机。

为什么重要?因为如果协程在组件销毁后仍然运行,它可能会:

  • 更新已销毁组件的状态 → 引发内存泄漏
  • 持有已废弃的Context → 导致资源无法释放
  • 在后台继续加密操作 → 浪费CPU和电量

LaunchedEffect 和 rememberCoroutineScope 的好处是,它们会自动绑定到Composable的生命周期。当Composable移出组合树,协程自动取消。

核心原则:协程的作用域决定了它的生命周期。永远使用与UI组件绑定的作用域,不要用GlobalScope。

实战套路:加密操作的三步流程

避免ANR不是靠某个单一技巧,而是形成一套固定的处理模式。我个人总结的加密操作三步流程如下:

  1. 接收事件:在UI层收集用户输入或按钮点击
  2. 切换线程:通过withContext(Dispatchers.Default)进入后台
  3. 更新状态:将加密结果通过State传回UI

这套流程看起来简单,但很多项目里就是没做到。要么是第二步忘了,要么是第三步用了主线程还能接受的方式。

警惕:别在remember的lambda里做加密。因为remember的lambda是在重组时执行的,它依然在Composition阶段,会阻塞主线程。我记得有一次调试一个诡异的ANR,查了半天发现是remember { encryptConfig() }在初始化时做了大量的密钥派生。

架构层面的思考

如果只是零散地在UI里用协程,时间长了还是会乱。我建议在项目中划分清晰的职责:

  • UI层:只负责收集事件、展示状态,不直接做任何加密操作
  • 加密层:通过挂起函数暴露安全的加密接口,内部保证在合适的调度器上运行
  • 数据层:管理加密后的存储,同样使用协程

这样,UI层根本不需要关心线程切换。它只需要调用挂起函数,然后等待结果。

// 加密层的挂起函数
suspend fun encryptData(plainText: String): ByteArray = withContext(Dispatchers.Default) {
    // 具体的加密逻辑,比如 AES 加密
    aesEngine.encrypt(plainText.toByteArray())
}

// UI层调用
@Composable
fun EncryptionButton(plainText: String) {
    val scope = rememberCoroutineScope()
    var result by remember { mutableStateOf<ByteArray?>(null) }

    Button(onClick = {
        scope.launch {
            result = encryptData(plainText) // 自动在后台执行
        }
    }) {
        Text("加密")
    }
}

我个人习惯把加密层所有函数都声明为suspend的,并且内部用withContext确保线程安全。UI层只管调用,不用操心调度器的事。这样既安全又清晰。

核心原则总览

把上面的内容归纳一下,避免ANR的核心原则就三点:

避免ANR核心原则 主线程不阻塞 协程让出主线程 作用域绑定生命周期 LaunchedEffect rememberCoroutineScope 自动取消 防止内存泄漏 实战建议 加密操作统一使用挂起函数,UI层只做事件收集和状态展示

这张图应该能帮你理清思路:主线程不阻塞是总纲领,通过协程切换调度器实现异步,通过作用域管理保证安全。这三者缺一不可。

总结一句话:不要在主线程做任何可能耗时超过1ms的操作。加密、解密、序列化、文件读写,统统扔到Dispatchers.Default或Dispatchers.IO里。然后用LaunchedEffect或rememberCoroutineScope把它和UI的生命周期绑在一起。这么一套组合拳下来,ANR基本就远离你的加密UI了。

记住,避免ANR的核心不是写得快,而是写得对。把该让出的时间让出来,系统自然会给你流畅的体验。