Security 库的 EncryptedSharedPreferences,性能损耗有多少
Security 库的 EncryptedSharedPreferences,性能损耗有多少
Android Jetpack Security 库在 1.0.0 稳定版发布之后,EncryptedSharedPreferences 就成了很多团队快速实现本地数据加密的默认选择。它包装了普通的 SharedPreferences,在读写时自动做 AES 加密,API 几乎无感,接入成本极低。但"几乎无感"不等于真的没有代价。去年我在一个启动性能敏感的项目里把它接进去之后,冷启动时间涨了将近 200ms,这才意识到问题不是"有没有损耗",而是"损耗到底发生在哪、能不能接受"。这篇文章把我后来做的测试、源码阅读和实际踩坑过程整理出来。
从一次冷启动回归说起
项目背景是一台定制 Android 设备,Android 10(API 29),4GB RAM,应用启动速度是硬指标。原本用普通 SharedPreferences 存储用户登录态和一些配置,冷启动到首帧大概 780ms。产品经理要求本地敏感数据必须加密,技术选型时考虑到 Tink 的学习成本,直接用了 androidx.security:security-crypto:1.1.0-alpha06。
接入方式很标准:
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secret_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)把原有的 context.getSharedPreferences("config", MODE_PRIVATE) 全局替换掉,功能一切正常。但跑了一遍启动耗时测试,中位数从 780ms 飙到 980ms,涨幅 25%。更麻烦的是波动很大,P90 能到 1.4s,而原来普通 SharedPreferences 的 P90 只有 920ms。
第一反应是 MasterKey 的初始化在搞事情。Security 库底层依赖 Google Tink,而 Tink 的密钥生成涉及 Android Keystore,这在很多设备上是实打实的硬件操作,尤其是没有 StrongBox 支持的时候,会走 TEE(Trusted Execution Environment),耗时完全看厂商实现。
MasterKey 初始化:第一次创建的坑
EncryptedSharedPreferences 的构造函数需要传入一个 MasterKey,这个 MasterKey 背后对应着 Keystore 里一个别名(alias)。如果这是应用首次安装启动,Keystore 里还没有这个别名,Tink 会触发密钥生成。
我抓了一段 Systrace,看到首次启动时 KeyGenParameterSpec 相关的 native 调用占了 160ms 左右。具体路径是 MasterKeys#getOrCreate -> AndroidKeystoreAesGcm 的构造 -> KeyStore#getEntry,如果 entry 不存在,走到 KeyGenerator#generateKey。
// Tink 内部 AndroidKeystoreKmsClient.java 的简化逻辑
private Aead getOrGenerateNewAeadKey(String keyUri) throws GeneralSecurityException {
String keyId = validateKmsKeyUriAndRemovePrefix(KEYSTORE_URI_PREFIX, keyUri);
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
keyStore.load(null);
if (!keyStore.containsAlias(keyId)) {
// 这里生成新密钥
generateNewAeadKey(keyId);
}
// ...
}这个 generateNewAeadKey 在 Android 10 的某些设备上特别慢。我手边有一台 Samsung Galaxy A51(Exynos 9611),测下来首次密钥生成 340ms;另一台 Pixel 3a(Snapdragon 670)只要 90ms。差异巨大,而且不可控。
更隐蔽的问题是:如果你以为 MasterKey.Builder 每次调用都是轻量的,在 Application.onCreate 里每次都 new 一个,即使密钥已经存在,KeyStore#load(null) 和 containsAlias 的调用也不是免费的。我测试下来,已存在密钥的情况下,每次构造 MasterKey 约 8-15ms,取决于设备 Keystore 实现的繁忙程度。
我们项目的修复策略是:把 MasterKey 做成单例,Application 生命周期内只初始化一次;另外把 EncryptedSharedPreferences 的创建延迟到真正第一次读写时,而不是堵在启动路径上。但这只解决了"初始化"的问题,读写本身的损耗还在。
单次读写延迟:SIV 和 GCM 的双重开销
EncryptedSharedPreferences 的构造参数里有两个枚举,很多人直接抄文档,没细想区别:
PrefKeyEncryptionScheme.AES256_SIV:加密 keyPrefValueEncryptionScheme.AES256_GCM:加密 value这意味着你每次 putString("username", "alice"),实际上发生了两次独立的加密操作。key 被 SIV 模式加密,value 被 GCM 模式加密。而普通 SharedPreferences 只是做一次内存写入和可能的磁盘 fsync。
我写了一个微基准测试,在 Pixel 6(Android 14)上跑 1000 次循环:
// 测试代码,排除首次初始化影响
val plainPrefs = context.getSharedPreferences("plain", MODE_PRIVATE)
val encryptedPrefs = // ... 已初始化好的 EncryptedSharedPreferences
measureRepeated("plain_write") {
plainPrefs.edit { putString("key_$it", "value_$it") }
}
measureRepeated("encrypted_write") {
encryptedPrefs.edit { putString("key_$it", "value_$it") }
}结果:plain_write 平均 0.12ms,encrypted_write 平均 2.8ms,差距约 23 倍。读操作差距更大,plain_read 0.08ms,encrypted_read 3.5ms,因为读的时候要先解密 key 匹配,再解密 value。
这个差距在每次 SharedPreferences.edit() 批量写入时会累积。假设启动时要写 20 个配置项,普通 SP 总共 2-3ms,EncryptedSharedPreferences 要到 50-60ms,而且这 50ms 是 CPU 密集计算,会吃掉你启动阶段的 CPU quota,影响其他异步任务的调度。
commit() vs apply():加密让差异更显著
普通 SharedPreferences 里 apply() 是异步写磁盘,commit() 是同步写。很多开发者习惯在启动路径上用 commit() 确保数据落地,但加密之后这个选择的影响被放大了。
我跟踪了 EncryptedSharedPreferences 的 commit() 实现,它继承自 SharedPreferences 接口,内部先把所有修改项逐个加密,然后委托给底层一个普通 SharedPreferences 的 commit()。关键路径:
// EncryptedSharedPreferences.java 内部
@Override
public boolean commit() {
// 1. 把 mModified 里的明文 key/value 全部加密
Map<String, String> encryptedMap = new HashMap<>();
for (Map.Entry<String, Object> entry : mModified.entrySet()) {
String encryptedKey = mKeyDeterministicAead.encryptDeterministically(
entry.getKey().getBytes(UTF_8),
new byte[0]
);
// value 加密类似,用 Aead.encrypt
// ...
}
// 2. 写入底层 SharedPreferences
return mSharedPreferences.edit()
.putAll(encryptedMap)
.commit();
}注意这里 mKeyDeterministicAead.encryptDeterministically 是同步在主线程执行的。如果你在主线程调 commit() 写 10 个键值对,就是 10 次 SIV + 10 次 GCM 加密,全部卡主线程。而普通 SharedPreferences 的 commit() 虽然也是同步写磁盘,但磁盘 IO 可以部分被系统调度优化,CPU 占用不高;加密则是实打实的 CPU 计算。
我测了一个极端 case:主线程连续 commit() 50 个字符串键值对,普通 SP 约 80ms,EncryptedSharedPreferences 约 1.8s,直接触发 ANR。这个测试不现实,但说明问题方向:加密 SP 的 commit() 比原来危险得多,主线程使用要极度谨慎。
apply() 理论上把加密和磁盘 IO 都甩给 QueuedWork 的线程池,但加密部分在 Android Security 1.1.0-alpha06 的实现里其实还是在调用线程做的,只有最后的磁盘写入是异步。我读了源码确认这一点:
// apply() 内部
public void apply() {
// 加密仍然在主线程
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
mcr.writtenToDiskLatch.await();
}
};
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};
// 只有这里异步
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
}所以 apply() 在 EncryptedSharedPreferences 里也不能完全解脱主线程负担,加密计算还是会阻塞调用线程直到内存中的 Map 准备好。批量写入时建议自己切到后台线程,加密完再 apply()。
密钥轮换与数据迁移:1.1.0-alpha 的 breaking change
Security 库在 1.1.0-alpha03 之前和之后有一个不兼容的密钥格式变更,这个坑我踩得很结实。旧版本用 MasterKey.KeyScheme.AES256_GCM,底层 Tink 的 key template 和 1.1.0-alpha03 之后不同。如果用户设备上已有旧版生成的密钥,升级应用后新版本的 Tink 无法识别,直接抛 GeneralSecurityException。
具体异常信息:
java.security.GeneralSecurityException: cannot use an Android Keystore key,
Tink cannot identify the key type.
If you are using Tink in Android, make sure you have called
AndroidKeysetManager.Builder.withSharedPref()这个错误在 StackOverflow 上有很多帖子,但官方文档没有醒目提示。修复方案是自定义 KeyGenParameterSpec 强制指定旧版兼容的 spec,或者主动做数据迁移:用旧密钥解密读出,再用新密钥加密写回。
我们当时的 workaround 是判断版本号,首次启动新版本时把旧 EncryptedSharedPreferences 的数据迁移到新的文件名下,然后删除旧文件。代码 roughly 这样:
fun migrateIfNeeded(context: Context) {
val oldPrefs = try {
// 用旧版方式构造,可能失败
EncryptedSharedPreferences.create(
"old_secret",
"master_key",
context,
AES256_SIV,
AES256_GCM
)
} catch (e: Exception) { null }
oldPrefs?.all?.let { data ->
val newPrefs = // ... 新版方式创建
newPrefs.edit {
data.forEach { (k, v) -> putString(k, v as String) }
}
context.deleteSharedPreferences("old_secret")
}
}这个迁移本身又要走一遍全量解密+加密,启动时间再涨一截。而且 deleteSharedPreferences 是 API 24 才加的,更低版本要反射或者手动删文件。
内存占用:Tink 的 keyset handle
除了时间和 CPU,EncryptedSharedPreferences 的内存开销也值得注意。Tink 的 Aead 和 DeterministicAead 实例内部持有 KeysetHandle,而 KeysetHandle 又引用着 protobuf 解析出来的 keyset 结构。每个 EncryptedSharedPreferences 实例(对应一个文件)会持有两套:一套用于 key 的 SIV,一套用于 value 的 GCM。
我用 Android Studio Profiler 抓了一个 heap dump,单个 EncryptedSharedPreferences 实例大约占用 80-120KB 堆内存(取决于 key 数量和 Tink 版本),而普通 SharedPreferences 同样数据量下约 15-20KB。如果应用有多个加密 pref 文件,这个累积不可忽视。我们项目里原本分了 4 个 pref 文件按模块隔离,全换成加密后内存涨了约 400KB,在低端机上触发更频繁的 GC。
后来合并成了 2 个文件,并确保全局只持有一个 EncryptedSharedPreferences 引用,避免重复创建 Tink 实例。
对比:自己用 Tink 会不会更好?
既然 Security 库底层就是 Tink,那绕过 EncryptedSharedPreferences 直接调用 Tink,能不能减少开销?我试了一下。
Tink 的 AndroidKeysetManager 允许你更精细地控制 keyset 的存储和加载。自己管理时,可以把 keyset 缓存为单例,避免每次构造 Aead 时的重复解析;还可以选择不用 Android Keystore 保护 keyset,而是用本地加密存储,减少 Keystore 交互。
但代价是安全性降级。Security 库的设计选择是:MasterKey 存在 Keystore 里,keyset 本身也加密存储在 SharedPreferences 中,双重保护。自己实现时如果图省事把 keyset 明文放 SharedPreferences,或者用一个硬编码的密码做本地加密,就违背了初衷。
我实测了一个折中方案:用 Tink 直接构造 Aead,但 keyset 用 withSharedPref 存储,MasterKey 仍走 Keystore。这样省去了 EncryptedSharedPreferences 那层 key/value 逐个加密的包装开销,但 API 不再兼容 SharedPreferences,迁移成本很高。性能上比 EncryptedSharedPreferences 快约 30%,主要来自减少了一次 Java 层的 HashMap 遍历和字符串转换。
最终我们没有采用这个方案,因为 30% 的提升不值得放弃 SharedPreferences 的接口兼容性,而且自己维护 Tink 的 keyset 生命周期容易出安全问题。
真实设备上的综合测试数据
为了给出可复现的结论,我在三台设备上跑了统一测试:连续读写 100 个键值对(key 和 value 都是 32 字符随机字符串),测量总耗时和 CPU 时间。Security 库版本 1.1.0-alpha06,Tink 版本 1.8.0。
读操作数据类似,不再列。RK3568 是我们项目的芯片,ARM Cortex-A55 四核,没有硬件 AES 加速,结果最差。这说明加密 SP 的性能损耗和设备算力强相关,高端机上"勉强可用"的方案,在中低端定制设备上可能直接不可用。
另外测了批量 apply() 的异步效果:100 个键值对 apply(),普通 SP 主线程阻塞约 3ms(只是准备内存 Map),EncryptedSharedPreferences 主线程阻塞约 180ms,因为加密逃不掉。这个 180ms 在启动主线程上就是肉眼可见的卡顿。
我们的最终取舍
回到项目本身,启动时间硬指标是 900ms 以内。EncryptedSharedPreferences 全量替换后超标,我们做了分级处理:
还有一个细节:EncryptedSharedPreferences 的 getString() 在 key 不存在时,内部仍然会尝试解密 key 去匹配,这个解密计算不会省。所以如果某个配置项经常读不到(比如功能开关默认关闭),比普通 SP 多浪费很多 CPU。我们在代码里加了内存层的 HashMap 缓存,启动时一次性把需要的加密配置项读出来,后续访问走内存,不再碰 EncryptedSharedPreferences。
Security 1.1.0 之后的进展
写文章时 Security 库最新是 1.1.0-alpha06,看 GitHub 上的 issue 列表,性能相关的抱怨不少。#198 有人报告了同样的启动慢问题,维护者的回复是"这是 Keystore 的已知限制,建议避免在启动路径使用"。#220 讨论了 key 加密的必要性,有人提议提供不加密 key 的模式来减少一半开销,但目前没有采纳。
Tink 本身在 1.9.0 里有一些 JVM 层面的优化,比如减少了 keyset 解析时的临时对象分配,但 Security 库还没有升级依赖。我手动 exclude 了旧版 Tink,强制用 1.9.0,测试下来读写耗时降低约 8%,不算质变。
另外注意到 Security 库 1.1.0 开始支持 MasterKey.Builder.setUserAuthenticationRequired(),可以要求生物识别后才能使用密钥。这个特性增加了 Keystore 的交互次数,性能只会更差,不适合对延迟敏感的场景。
一些未公开的实现细节
读源码时注意到 EncryptedSharedPreferences 对 key 的加密用的是 DeterministicAead,具体是 AES-SIV-CMAC。SIV 模式的"deterministic"意味着同样的明文 key 总是产生同样的密文,这样底层 SharedPreferences 才能用加密后的 key 做查找匹配。但这也带来一个限制:如果两个不同的明文 key 哈希冲突了怎么办?SIV 的实现里实际上没有处理这个,依赖 AES 的块长度和 CMAC 的完整性来保证唯一性,理论上存在极微小概率的碰撞,实际没听说有人遇到。
value 加密用 AES-GCM,每次 put 都会生成新的随机 nonce,所以同样的 value 两次写入密文不同。GCM 的 tag 是 128 bit,附加在每个 value 后面,存储开销每个 value 多 16 字节。100 个配置项就多 1.6KB,可以忽略。
还有一个坑:EncryptedSharedPreferences 的 contains(key) 方法,实现是先加密 key,再查底层 SP。这个加密操作意味着 contains() 也不是 O(1) 的轻量调用,和中档的 getString() 差不多耗时。如果你代码里习惯用 contains() 做存在性检查,要留意。
给其他团队的参考
如果你正在评估要不要用 EncryptedSharedPreferences,我的建议是先看场景:
commit()如果性能实在过不了关,替代方案包括:
但每种替代都有工程成本,EncryptedSharedPreferences 的价值在于"足够简单"。只是这个简单是有价格的,而且价格在中低端设备上可能比预期高很多。
我后来翻了一下 Google 官方的性能最佳实践文档,确实没有专门提 Security 库的性能影响。也许在他们内部的测试矩阵里,Pixel 系列的 Keystore 实现足够快,这个问题不够显著。但对国内厂商和海外的中低端设备市场,这就是个真实存在的坑。