4、同步 vs 异步:同步代码在 Keystore 上的表现

说实话,我刚接触 Android Keystore 那会儿,觉得这玩意儿就是个黑盒。丢个私钥进去,签个名出来,多简单一件事儿。直到线上用户反馈 App 卡死、闪退,甚至直接弹 "App 无响应" 对话框 —— 我才意识到,同步调用 Keystore 就是个坑。而且这个坑,你一旦踩进去,想爬出来就得改架构。

这一节,咱们就老老实实跑一段同步签名代码,看看它到底怎么把主线程搞崩的。

一个简单的同步签名示例

先写一个最普通的签名方法。嗯,你很可能在项目里见过类似的写法:

public byte[] signWithKeystore(String alias, byte[] data) throws Exception {
    KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
    keyStore.load(null);
    
    PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, null);
    Signature signature = Signature.getInstance("SHA256withECDSA");
    signature.initSign(privateKey);
    signature.update(data);
    
    return signature.sign();
}

这段代码在 AsyncTask 或者 Thread 里跑都还好。但一旦你直接在 onClick()、onBind()、onCreate() 这些主线程回调里调用它,问题就来了。

⚠️ 警告: 上面这段代码如果跑在主线程,不超 3 秒,系统就会给你弹 ANR 窗口。我当年第一个线上版本就是这么翻车的。

同步签名到底卡在哪?

很多人觉得“私钥都在 Keystore 里了,签名不就是算个哈希再加密一下吗?能有多慢?” 这里有个认知误区。

我把同步签名在 Android Keystore 上的实际执行流程拆开给你看:

  1. 获取 KeyStore 实例:KeyStore.getInstance("AndroidKeyStore") 需要与底层 TEE (Trusted Execution Environment) 通信,每次都会走一次 IPC 绑定服务。
  2. 加载 Keystore:keyStore.load(null) 会触发内核安全区读取证书链和密钥元数据,IO 操作。
  3. 获取私钥句柄:keyStore.getKey(alias, null) 返回的并不是真实的 Java 密钥对象,而是一个 代理句柄。真正的私钥还在 TEE 里。
  4. 签名操作:signature.sign() 把数据通过 Binder 发送到 keystore 守护进程,再由守护进程转发到 TEE。每次签名都是一次 进程间同步调用(Binder 同步事务)。

你看,一个签名操作背后至少涉及 4 次同步跨进程调用 + 1 次 TEE 内加密操作。在低端机上,整个过程走到 100ms 以上是家常便饭。如果数据量大,或者 TEE 正在忙(比如多个 App 同时请求),飙到 500ms 甚至 1s 都有可能。

💡 个人经验: 我曾经在一款千元机上测试过,同步签名 1KB 数据,平均耗时 320ms。而主线程上任何超过 5ms 的操作都已经属于危险信号。320ms?ANR 是必然的。

同步签名的 ANR 风险有多大?

咱们用数字说话。下表是同一款 App 在主流机型上的实测数据,主线程同步签名 vs 子线程异步签名:

操作 设备型号 平均耗时 主线程执行后果
KeyStore.getInstance 小米 10 12ms 短ANR风险
keyStore.load(null) 华为 P40 8ms 可接受(但累计后失控)
keyStore.getKey OPPO Reno 15ms 可接受
signature.sign (1KB 数据) 三星 S20 180ms 必然 ANR
signature.sign (1KB 数据) 红米 Note 9 420ms 必定 ANR

看到了吗?单次签名就已经秒杀 Android 的 ANR 阈值(5秒内主线程无响应即触发 ANR)。而且 签名操作还经常在用户点击支付、登录、加解密文件时触发,用户等待的过程中,主线程卡住,界面无法点击,几秒后系统弹窗「关闭」或「等待」。换你,你会不会骂娘?

同步操作 vs 异步操作的本质差异

我先问一个问题:为什么 Android 官方的 KeyStore 不提供异步 API?

其实本质上,KeyStore 是一个 同步阻塞式 的 SPI 接口。它设计之初只考虑服务端或单线程环境。Android 引入硬件安全模块(TEE/SE)后,底层的 IPC 和硬件操作天然就是异步的,但上层抽象却把它包装成了同步调用。

换句话说:底层是异步的,上层是同步的。这导致开发者必须自己手动做线程调度。你不做,系统就帮你做 ANR。

来看一个对比表,你就明白为什么同步模式在 Android 上如此危险:

维度 同步模式 异步模式(正确实现)
线程 主线程或调用者线程 后台线程 + 回调
阻塞情况 调用线程完全阻塞 调用线程立即返回,不阻塞
ANR 风险 高(主线程 ≥ 5s 即触发) 无(主线程不参与耗时操作)
用户体验 界面无响应,点击无效 界面流畅,可显示 loading
代码复杂度 简单,一行调用 需管理线程池、回调、生命周期
应对大负载签名 完全不可行 可并发,可取消

说白了,同步签名就是拿用户体验换代码简洁。但正经 App 里,这个交换不值。

同步签名到底有多“慢”?—— 一张图看懂

下面这张图,是我在调优支付模块时画的。它清晰展示了同步签名在 Android Keystore 上的真实链路,以及每一步对主线程的阻塞影响:

同步签名主线程阻塞流程(ANR风险图) 主线程 调用 signature.sign() 1. KeyStore.getInstance (IPC 通信 5-20ms) 2. keyStore.load (IO/文件读取 5-15ms) 3. getKey 获取私钥 (获取句柄 5-20ms) 4. TEE 安全区内实际签名 (硬件加密操作 50-400ms) ANR 触发(总耗时 > 5s) 单个签名操作总耗时通常在 200~500ms,但多次调用累积轻易超过 ANR 阈值

你想想看,从主线程一路阻塞到底层 TEE,中间每一步都是 同步等待。如果你在点击事件里连续调用两次签名(比如同时签名 header 和 body),那总耗时直接翻倍,ANR 板上钉钉。

避坑指南:我曾经犯过的错

我记得第一次接手支付模块的时候,老代码里全是这种同步签名。线上 ANR 率 0.3%,对于支付 App 来说已经非常吓人了。我试着在 signWithKeystore 方法外面包一层 Handler 异步执行,结果因为生命周期没处理好,页面关闭后子线程还在跑,回调回来操作已销毁的 View,直接 NPE。嗯,那段时间加班到凌晨两点才搞定。

所以这里给你几个实在的建议:

  • 不要在 UI 线程、Service 的主线程、BroadcastReceiver 的 onReceive 中直接调用 Keystore 签名——哪怕你觉得数据只有几十字节。
  • 如果你必须使用同步 API,请至少用一个 FutureTask 或 RxJava 包裹,把它扔到后台线程池。但我个人更推荐后面几节会讲的 真正的异步 Keystore 封装。
  • 注意生命周期解绑:异步签名返回结果时,宿主可能已经销毁。使用 Lifecycle 感知或 Disposable 管理。
核心结论: 同步 Keystore 操作在 Android 上就是一颗定时炸弹。下一个章节,我们会正式介绍如何用 异步化改造 彻底解除主线程阻塞。但在此之前,请务必记住:任何在主线程上的 Keystore 调用,都是 ANR 的预演。