Security 库的 EncryptedSharedPreferences,性能损耗有多少

Security 库的 EncryptedSharedPreferences,性能损耗有多少

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:加密 key
  • PrefValueEncryptionScheme.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() 确保数据落地,但加密之后这个选择的影响被放大了。


    我跟踪了 EncryptedSharedPreferencescommit() 实现,它继承自 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 的 AeadDeterministicAead 实例内部持有 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 全量替换后超标,我们做了分级处理:


  • 启动路径上必须读写的配置:保留普通 SharedPreferences,但只存非敏感数据(如主题、语言、是否首次启动)
  • 敏感数据(token、用户 ID):用 EncryptedSharedPreferences,但延迟初始化,不在 Application.onCreate 里创建
  • 启动时需要的敏感数据:改为从服务端按需获取,或者用 AccountManager 做短期缓存

  • 还有一个细节: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()
  • 低端设备或定制硬件:务必实测,不要只看高端机数据

  • 如果性能实在过不了关,替代方案包括:

  • 自己用 Tink 做应用级加密,批量读写时减少加密次数
  • SQLCipher 加密数据库,适合结构化数据
  • 对特定字段单独加密,而不是全量包装 SharedPreferences

  • 但每种替代都有工程成本,EncryptedSharedPreferences 的价值在于"足够简单"。只是这个简单是有价格的,而且价格在中低端设备上可能比预期高很多。


    我后来翻了一下 Google 官方的性能最佳实践文档,确实没有专门提 Security 库的性能影响。也许在他们内部的测试矩阵里,Pixel 系列的 Keystore 实现足够快,这个问题不够显著。但对国内厂商和海外的中低端设备市场,这就是个真实存在的坑。

    PackageVisibility 的查询白名单,Android 11 后的适配 2026-06-29
    Docker 构建 Android 环境,镜像层缓存策略 2026-06-30

    评论区