1、课程介绍:数据加密与ANR的关系

数据加密,说白了就是给数据加把锁。App 里敏感信息存本地、传网络,都得先加密。但问题是——加密很吃 CPU,一旦跑在主线程,直接卡死界面。你想想看,用户点个按钮,结果界面僵住 5 秒,系统弹出「无响应」对话框……那就是 ANR(Application Not Responding)。

我个人习惯,每次做加密功能时都会先问自己一句:这个操作会不会阻塞 UI?如果回答“是”,那就必须放到后台线程。这不是小题大做——Android 系统对于主线程超时(触摸事件 5s,广播 10s)会直接杀进程,用户体验直接归零。

加密操作为什么容易引发 ANR?

加密不是瞬间完成的。常见的几种加密操作,耗时差异很大:

加密类型 典型耗时(1MB 数据) 是否适合主线程
AES-CBC 加解密 ~50ms ❌ 不安全,可能卡顿
RSA 2048 密钥生成 ~300ms ❌ 必 ANR
RSA 公钥加密(小数据) ~5ms ⚠️ 临界,建议异步
PBKDF2 密钥派生(10000 轮) ~80ms ❌ 不适合
HMAC-SHA256 ~1ms ✅ 极快,但仍避免高频

嗯,这里要注意:上面只是参考值,不同设备、不同数据量差异很大。我在项目中遇到过一台低端机,RSA 密钥生成跑了接近 1 秒——要是放在主线程,ANR 妥妥的。

Compose UI 中的特殊风险

Compose 是声明式 UI,重组(Recomposition)是常态。你写了一个加密函数,本意是用户操作时才调用,但如果不小心把加密逻辑写进了 remember 或 LaunchedEffect 的错误地方,重组时会反复执行。我在一个线上项目里就吃过这个亏——

曾经有一个列表页面,每项数据需要解密显示。我把解密直接写在 @Composable 函数里,结果列表滚动时每帧都重新解密……界面掉帧严重,偶尔 ANR。后来改成后台协程 + 缓存才解决。

为什么会这样?因为 Compose 的 @Composable 函数可能被频繁调用(重组)。你必须在重组中避免任何耗时操作,包括加密。Android 主线程只有一个 Looper,加密霸占它,布局刷新和点击事件就得排队。

核心矛盾:加密安全 vs UI 流畅

加密需要安全,UI 需要流畅。两者本身不冲突,冲突的是你在哪个线程做。下面这张图展示了两种场景的对比:

加密操作在 UI 线程 vs 后台线程的 ANR 对比 错误做法:加密在 UI 线程 用户点击 → 触发加密 主线程执行加密(耗时) 界面卡死 → 超时 ANR 正确做法:加密在后台线程 用户点击 → 启动协程 后台线程执行加密 UI 线程保持流畅 关键点:加密操作必须通过协程/线程池 切到后台线程,完成后通过回调或 State 更新 UI

Android 加密 API 的线程安全

常见的加密 API 如 javax.crypto.Cipher、java.security.KeyStore、AndroidKeyStore 等,本身是线程安全的(实例可以被多个线程共享),但内部同步锁会带来额外阻塞。比如 Cipher.getInstance() 第一次调用时会加载 JCE 提供者,耗时可能达到几百毫秒——这要是放在主线程,直接卡 UI。所以即使你不做实际加密,单单获取 Cipher 实例也建议放到后台。

我的建议:在 Application 启动时,预加载常用加密算法实例到缓存中。比如 AES/GCM/NoPadding 的 Cipher 对象,提前在后台线程初始化好。这样用户真正需要加密时,拿到的已经是预热过的实例,性能好很多。

Compose 中的典型错误模式

以下代码在我审查过的项目中至少出现过三次:

// ❌ 错误示例:加密直接写在 Composable 中
@Composable
fun SecureContent(encryptedData: ByteArray) {
    val plainData = remember(encryptedData) {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.DECRYPT_MODE, secretKey)
        cipher.doFinal(encryptedData) // 主线程执行耗时操作!
    }
    Text(text = String(plainData))
}

你会说:这不是用 remember 缓存了吗?但它的问题在于:remember 的 key 一旦变化(比如数据更新),解密又在主线程重新执行。而如果解密耗时较长,重组时就会卡 UI。代码看起来没问题,实际上踩了 ANR 的雷。

⚠️ 避坑指南:我曾经在一个即时通讯 App 中,把消息体解密写在了 remember 里。因为消息列表会频繁刷新(新消息导致重组),每次都要重新解密所有未缓存的消息。结果在旧手机上,列表滑动掉帧严重,偶尔 ANR。后来改为:在 ViewModel 中用 viewModelScope.launch(Dispatchers.IO) 解密,结果以 State 形式暴露给 Compose。

正确的做法:协程 + State

用 Compose 的 LaunchedEffect 或 ViewModel 协程,把加密操作调度到 Dispatchers.IO 上:

// ✅ 正确示例:后台解密 + Compose State
@Composable
fun SecureContent(encryptedData: ByteArray) {
    var plainText by remember { mutableStateOf("") }

    LaunchedEffect(encryptedData) {
        withContext(Dispatchers.IO) {
            val cipher = Cipher.getInstance("AES/GCM/NoPadding")
            // ... 解密
            val decrypted = cipher.doFinal(encryptedData)
            withContext(Dispatchers.Main) {
                plainText = String(decrypted)
            }
        }
    }

    Text(text = plainText)
}

这样加密跑在 IO 线程,主线程只负责更新 State 触发重组。重组本身只做简单的赋值和 UI 绘制,不会卡。

总结几个关键点

  • 任何耗时操作(>5ms)都不应出现在主线程,加密尤甚。
  • Compose 的重组是隐式的,容易「不小心」把加密写进 @Composable。
  • 使用 Dispatchers.IO 或 Dispatchers.Default 做加密,通过 mutableStateOf 回传结果。
  • 预加载 Cipher 实例到后台,避免首次调用卡顿。
  • 在低端机上尤其要警惕 RSA 密钥生成、PBKDF2 这类重型操作。