☰
告别SharedPreferences:DataStore+Android Keystore安全迁移实战
2026/10/21 4:17:12 网站建设 项目流程

最近不少团队升级依赖时,应该都陆陆续续看到了那条提示:项目中还在用的SharedPreferences在最新的 API 文档里已经被标记为废弃(Deprecated)。虽然短期内它不会真的被移除,兼容性也会继续保留,但既然官方已经明确点出了接班人,新代码再继续堆SharedPreferences就多少有点说不过去了。我这两周正好把一个老模块从SharedPreferences整体迁了出来,选用的组合就是标题里那套DataStore + Android Keystore:前者负责把存储 API 的体验和性能问题一次解决,后者负责把真正敏感的字段加密进硬件安全域。

这篇文章把整个迁移思路、加密方案的选型逻辑、完整可跑的代码,以及实际踩过的坑都整理了一遍。无论你是刚开始接触 DataStore,还是已经准备把手头的消费级存储升级为硬件级安全存储,都建议从头到尾读一遍,尤其是最后那张异常排查表,大概率能帮你省一整个下午的调试时间。

1. SharedPreferences 为什么会被官方抛弃

1.1 弃用背景与官方转移信号

先说说这次“弃用”到底是怎么回事。2024 年的 API 35 文档里,SharedPreferences连同SharedPreferences.Editor、OnSharedPreferenceChangeListener这一整组相关类,都被官方正式标注成了 Deprecated。文档里的迁移指引也写得非常直接:建议开发者迁移到 Jetpack DataStore,并且在文档末尾明确说了不会再对新版本 Android 系统特性做适配。

这里很多人有个误解,以为“标记废弃”等于“马上用不了”。实际上不是,它短期内不会从 SDK 里删掉,你以前写的代码也照样能跑。但 IDE 会在 lint、构建日志里给出警告提示,而且随着后续版本迭代,官方不会再为它修复新系统引入的边缘问题。所以对新项目来说,直接选择 DataStore 基本没有理由再去选 SP;对老项目来说,迁移的性价比也在随着时间不断提高。我自己当时做技术选型的时候,也拿着几个备选方案来回对比过,最后还是选了 DataStore,主要原因就是官方长期维护、生态适配完整、迁移路径平滑。

1.2 SharedPreferences 真正落下的技术债

SharedPreferences的问题不是“不能用了”,而是它设计得太早,几个底层缺陷在现在的移动环境下实在扛不住。

第一个问题:读取是整文件加载。每次你调用getSharedPreferences(key, ...),系统都会去磁盘读一个完整的 XML 文件,一次性解析进内存,并且在首次访问时可能发生在主线程。如果一个配置文件的 key 数量上千,再加上首启时一堆模块同时读配置,主线程卡顿个一两百毫秒是非常正常的现场现象。我实际给一个老项目做耗时统计时,单是getSharedPreferences首读就贡献了启动阶段将近 5% 的主线程卡顿时间。

第二个问题:写一次就重写整个文件。SharedPreferences的落盘模型是全量覆写,哪怕你只改了一个布尔值,它也会把整个 XML 重新序列化写一遍。高频写入的场景下,IO 磨损和磁盘占用都会以非常难看的曲线增长。再加上commit()是同步写,直接卡主线程;apply()虽然是异步写,但它背后的QueuedWork机制在一些特殊场景下还是会在 Activity 生命周期里等落盘完成,照样能触发 ANR。

第三个问题:完全没有类型安全。SP 的 key 全是字符串,value 全靠运行时类型判断。一个 key 的拼写错误,编译期完全看不出来,等线上数据错乱、读取返回 null 的时候才追悔莫及。DataStore 虽然也是 key-value(Preferences 变体),但底层有了完整的类型约束和每次读操作的不可变快照,从 API 体验上就能挡掉一批低级 bug。

第四个问题:多进程模式并不靠谱。SP 的MODE_MULTI_PROCESS设计得比较粗糙,多个进程同时读写时,没有事务保证,也没有可靠的日志机制做并发控制,数据覆盖、丢失都发生过。官方一直不推荐使用这个模式,但很多老项目为了“省事”还是用了,结果线上出过不少事故。DataStore 则明确把多进程方案推向了外部存储同步,至少在官方 API 层面不再给你提供“看似能用”的入口。

2. DataStore 迎来接班时刻的核心优势

2.1 Preferences DataStore 和 Proto DataStore 怎么选

DataStore 其实有两个变形,它们底层数据结构不同,使用场景也完全不同。这里先把差异摆出来,免得选型选错。

维度Preferences DataStoreProto DataStore
数据结构无类型 Key-ValueProtocol Buffers 定义的强类型结构
文件格式.preferences_pb.pb
类型安全有(按 key 区分类型)极强(编译期生成类)
学习成本低,和 SP 思路相似较高,需要学 protobuf
Schema 演进不涉及需要维护 proto 文件、处理兼容
适合场景轻量偏好、少量配置、快速落地复杂结构化配置、需要版本演进

如果项目里只是存主题、语言、用户 ID 这类零散字段,用 Preferences DataStore 就够了,没必要为一个小功能引入 protobuf 那套编译链。如果有结构化的离线配置,比如一套复杂的推荐策略参数、一波需要版本演进的功能开关,那上 Proto DataStore 是更体面的选择。“永远只用一套”这种想法反而会让简单问题复杂化。

2.2 数据读写模型的变化是实质性的

DataStore 最核心的变化,在于它把存储打造成了响应式数据流。你不再像 SP 那样“get 一个值、立刻拿回来”,而是通过Flow持续观察变化,界面层拿到的是一个会自己更新的数据源。

举个例子。老写法是:

val theme = prefs.getString("theme_mode", "light")

新写法变成了:

val themeFlow: Flow<String> = context.dataStore.data.map { prefs -> prefs[PreferencesKeys.THEME_MODE] ?: "light" }

UI 层直接collect这个 Flow,数据一变,界面自动刷新。这种模型对 Compose 这类声明式 UI 简直天然契合,不需要再手动监听OnSharedPreferenceChangeListener,也不用担心注册监听后忘记反注册导致的内存泄漏。

另一个实质改进是事务性写入。DataStore 的edit操作是原子性的——一个事务内所有修改要么全部生效,要么全部不生效。不像 SP 的Editor,中途异常退出很容易出现“改了一半就落盘”的脏数据。而且 DataStore 默认在Dispatchers.IO上执行读写,主线程压力直接降为零。这一点对启动阶段优化非常重要。

2.3 迁移旧数据不流一滴血

官方为这次换血准备了专门的迁移工具,不需要手写文件解析和数据搬运。SharedPreferencesMigration这个类的作用就是把旧 SP 里的每一个 key-value 原样搬到 DataStore 的.preferences_pb文件里。只需要在构建 DataStore 的时候挂上它的实例,第一次启动时自动执行一次迁移。

代码长这样:

val Context.dataStore by preferencesDataStore( name = "user_prefs", produceMigrations = { context -> listOf( SharedPreferencesMigration( context, oldSharedPreferencesName = "user_prefs" // 对应旧SP文件名 ) ) } )

注意这个迁移是一次性的:执行成功之后,DataStore 会在迁移记录中标记完成,之后不会重复执行。如果你想保留旧 SP 文件作为备份,没问题;如果确认迁移稳定,可以后续手动删掉旧 XML 文件释放空间。

3. 加密方案的选型:为什么最终落在 AES-GCM 上

3.1 存储内容的安全等级划分

拿到 DataStore 之后,并不代表所有数据都要加密。把所有 key 无差别加密,既拖慢读写速度,又无端增加复杂度。我做迁移时会把数据分了三类:

安全等级示例方案
公开级主题色、语言、引导页版本DataStore 明文存储
业务级用户 ID、推送标签、最近浏览记录DataStore 明文 + 逻辑层脱敏
机密级AccessToken、RefreshToken、离线密钥、支付票据DataStore + Android Keystore 硬件加密

真正需要“硬件级”保护的,往往只有那几个密钥型字段。这一点要先想清楚,不然全盘加密会给自己找不必要的麻烦。举个例子,主题模式这个键,就算被其他人读走,也不过是知道用户用深色还是浅色,完全没有加密价值。但 AccessToken 一旦泄露,攻击者直接就拿到了用户身份,这必须上最高防护等级。

3.2 Android Keystore 的安全边界与硬件能力

Android Keystore 不是一个普通的加密库,它是系统级的安全密钥容器。它的核心能力是:密钥一旦生成并标记为不可提取,那么密钥材料永远无法从安全硬件中导出。应用只可以发起“用密钥做运算”的请求,却拿不到密钥本身的字节。

你可以把它理解成一个自带保安的保险箱:你想用里面的钥匙开锁,保安会帮你完成开锁动作,但钥匙永远不交到你手里。就算应用被 root、被恶意注入,攻击者最多也只能调用“解密运算”,无法直接把密钥抄走,更没法把密钥导入到另一台设备去碰运气。

Android 7.0 之后的设备基本都会借助 TEE(Trusted Execution Environment)来提供隔离环境;更高的机型如果支持 StrongBox 独立安全芯片,还可以通过setIsStrongBoxBacked(true)让密钥进一步从主 CPU 环境脱离。这种能力就已经配得上“硬件级安全”这个说法了。需要注意的是,并非所有设备都支持 StrongBox,启用前最好做一次运行时能力探测,不支持时降级到 TEE 即可,不必把业务整个拦住。

3.3 AES-GCM 成为主力方案的核心理由

密钥容器定好了,接下来就是选加密算法。实践中我强烈建议用AES/GCM/NoPadding,而不是很多老教程里用的 RSA 或 ECIES。

原因有三个。

第一,对称加密适合本地大块数据。本地加密没有密钥分发压力,不需要走非对称的复杂数学运算。AES 加解密速度快、开销低,一个几百字节的 token 加密耗时完全可以忽略。RSA/ECIES 更常用于“协商一个临时密钥”这类场景,拿来做本地配置加密属于大材小用,还会引入密文长度膨胀、性能开销上升这类新问题。

第二,GCM 模式是带认证的加密。GCM 加密出来的密文中不只包含数据,还附带一个认证标签。解密时如果密文被篡改过一个 bit、或者数据被截断,系统会直接抛出AEADBadTagException,而不是解出一段被污染的数据。这个特性对存储类数据尤其宝贵——你宁可读取失败,也不能读到被篡改的假 token。

第三,在 Android Keystore 里,AES-GCM 是官方支持最完整的对称加密方案。KeyGenParameterSpec 里配合BLOCK_MODE_GCM和ENCRYPTION_PADDING_NONE,几乎不需要额外处理兼容分支,API 都给你铺好了。密钥大小用 256 位,安全余量充足。

3.4 关于 IV 复用:被太多教程踩烂的坑

现在网上很多 Android 安全存储教程,会把 IV(初始化向量)写死成一个固定值。这种做法极其危险。GCM 模式下,同一把密钥如果配合重复的 IV 加密不同明文,密文之间会形成数学上的关联,攻击者能在不知道密钥的情况下还原出部分明文。正确做法是每次加密时由Cipher随机生成一个新 IV,然后把 IV 连同密文一起存储,解密时从存储里取出来用。

我在后面的代码里会把 IV 直接拼接在密文前面,统一做一次 Base64 编码存进 DataStore。每次加密出来的结果都不同,这是正常现象,不要拿去对比。

4. 完整落地:DataStore + Android Keystore 的工程实现

4.1 依赖引入与数据容器初始化

先在你的模块级build.gradle.kts里加上 DataStore 依赖:

implementation("androidx.datastore:datastore-preferences:1.1.1")

如果你选用 Proto DataStore,换成:

implementation("androidx.datastore:datastore:1.1.1")

不需要额外引入任何 Keystore 依赖,它是 Android 平台自带的 API。

然后初始化一个专门的 DataStore 实例。我这里特意新建了一个secure_prefs文件,与普通明文偏好分开,逻辑上更清爽:

private val Context.secureDataStore by preferencesDataStore(name = "secure_prefs")

4.2 Keystore 密钥管理封装

封装一个密钥管理器,负责在 AndroidKeyStore 中生成或获取 AES 密钥。核心是KeyGenParameterSpec的参数设置,这里每一步都有讲究:

object KeystoreManager { private const val KEY_ALIAS = "secure_prefs_key" private const val KEYSTORE_PROVIDER = "AndroidKeyStore" private val keyStore: KeyStore by lazy { KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } } fun getOrCreateKey(): SecretKey { (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } val keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER ) val spec = KeyGenParameterSpec.Builder( KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) // 只在目标设备支持 StrongBox 时才开启 .setIsStrongBoxBacked(true) .build() keyGenerator.init(spec) return keyGenerator.generateKey() } }

这里有一点要特别说明:setIsStrongBoxBacked(true)在部分不支持 StrongBox 的机型上会直接抛异常。落地时最好是先探测设备能力再做配置,或者干脆先不设置这个参数,默认走 TEE 也足够安全。我自己的处理是把这行包在 try-catch 里,捕获到异常后重建一个不带 StrongBox 要求的 Builder。

4.3 加密解密工具类

紧跟着封装加解密工具。这里的核心是“每次加密都用新 IV,并把 IV 与密文一起保存”:

object CryptoManager { private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val IV_SIZE = 12 // GCM 推荐的 96 bit IV fun encrypt(plainText: String): String { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, KeystoreManager.getOrCreateKey()) val iv = cipher.iv val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) val combined = ByteArray(iv.size + encrypted.size) System.arraycopy(iv, 0, combined, 0, iv.size) System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size) return Base64.encodeToString(combined, Base64.NO_WRAP) } fun decrypt(encryptedData: String): String { val combined = Base64.decode(encryptedData, Base64.NO_WRAP) val iv = combined.copyOfRange(0, IV_SIZE) val encrypted = combined.copyOfRange(IV_SIZE, combined.size) val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init( Cipher.DECRYPT_MODE, KeystoreManager.getOrCreateKey(), GCMParameterSpec(128, iv) // 128 bit 认证标签长度 ) return String(cipher.doFinal(encrypted), Charsets.UTF_8) } }

关于IV_SIZE,GCM 模式官方建议是 12 字节(96 bit),市面上绝大部分 Android Keystore 实现也遵循这个值。不排除个别坚果设备返回不同 IV 长度的情况,严格起见可以改用cipher.iv.size动态获取,保证兼容性:

val iv = cipher.iv val ivSize = iv.size

这样写才不会在某台设备的厂商定制 ROM 上翻车。

4.4 DataStore 安全读写封装

有了加密工具,剩下就是把它和 DataStore 结合。封装一个SecurePrefs对象,对外提供协程友好的读写方法:

object SecurePrefs { private val Context.secureDataStore by preferencesDataStore(name = "secure_prefs") private object Keys { val TOKEN = stringPreferencesKey("encrypted_token") } val Context.tokenFlow: Flow<String?> get() = secureDataStore.data.map { prefs -> val encrypted = prefs[Keys.TOKEN] ?: return@map null runCatching { CryptoManager.decrypt(encrypted) } .getOrNull() // 解密失败时返回 null,而不是直接抛崩 } suspend fun Context.saveToken(token: String) { val encrypted = CryptoManager.encrypt(token) secureDataStore.edit { prefs -> prefs[Keys.TOKEN] = encrypted } } suspend fun Context.clearToken() { secureDataStore.edit { prefs -> prefs.remove(Keys.TOKEN) } } }

注意这里tokenFlow里用了runCatching。因为 Keystore 相关的异常类别比较杂,有硬件层面的、有系统层面的,线上还见过KeystoreException和AEADBadTagException混合出现的情况。不管哪种,都不应该因为读取一个本地 token 就把整个应用带崩,兜底返回 null 然后走登录流程,才是更稳妥的产品表现。

4.5 业务侧调用示例

业务侧读 token 时,在 ViewModel 里直接收集 Flow 即可:

class MainViewModel : ViewModel() { val token: StateFlow<String?> = appContext.tokenFlow .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = null ) }

写操作更简单,在协程里调用:

viewModelScope.launch { appContext.saveToken("新token") }

整个链路从“加密-存储-读取-解密”就闭合了,应用侧几乎没有多余的心智负担。

5. 从 SharedPreferences 迁移到安全存储的实操路径

5.1 先迁移,再加密,不要一上来就插加密层

我见过不少团队做迁移时会把两步合在一起:一边迁移数据,一边给迁移层手动加密。结果就是排查问题时,不知道到底是迁移写坏了,还是加密解密的某一步出了问题。正确的做法应该分两步走。

第一步,先让 DataStore 接管旧数据。用官方SharedPreferencesMigration把旧 SP 文件里的数据平移过来,这一步只做格式切换,不改变数据形态:

private val Context.dataStore by preferencesDataStore( name = "secure_prefs", produceMigrations = { context -> listOf( SharedPreferencesMigration( context, "old_secure_prefs", setOf("access_token", "refresh_token", "user_id") ) ) } )

运行起来,确认数据都进来了,且 DataStore 文件生成正常,再进入第二步。

第二步,读取并回写加密版本。迁移完成后,敏感字段仍是以明文形式躺在 DataStore 文件里的。这一步需要把目标字段取出来,用CryptoManager.encrypt()加密,再写回同一个 key,之后明文字段就不存在了。这个动作要在应用启动早期做一次,做完就把迁移完成标记持久化,避免每次启动重复触发。

5.2 旧 SP 文件的清理时机

DataStore 迁移完成后,旧 SP 文件并不会自动删除,它只是不再被任何 API 读取。如果留着心里别扭,可以在确认线上没有回滚必要之后,用常规FileAPI 删掉对应 XML 文件。

需要提醒的是:删除操作不要放在迁移执行的同一事务里。先让 DataStore 的数据跑几天,观察没有异常上报,再另起一个清理任务,降低风险。我做过几次迁移,最有价值的经验就是:迁移是一次性的,出错了只有修 Key 才能补救,删除旧文件这件事慢半拍不丢人。

5.3 加密升级带来的外部兼容问题

数据加密后,其他模块如果还在直接读旧偏好,会立刻读到一段 Base64 乱码。所以在切换代码的时候,要保证所有对这几个 key 的读取点同时换成 SecurePrefs 的新方法,不能出现“半个应用用新 API,半个应用用旧 API”的中间态。代码审查时最好 glob 一遍旧方法调用,确认没有遗漏。

另外,加密数据经过 Base64 编码会膨胀约 1/3。如果原本 token 字段只有 128 字节,加密后可能接近 180 字节,对 DataStore 文件体积影响不大,但如果你用这个字段做日志打印或上报,务必先截断,别把完整密文打进日志。

6. 常见问题与异常排查实录

6.1 问题速查表

现象大概率原因处理方式
解密抛AEADBadTagException密文被篡改、或密钥与加密时不一致捕获异常,清除该字段并引导用户重置密钥/重新登录
设备锁屏凭据修改后解密失败Keystore 中受凭据保护的密钥已永久失效提示用户重新登录,业务方重新写入新密钥加密数据
应用恢复自 Android 云备份后数据读不出来DataStore 文件被恢复,但 Keystore 密钥不会随之恢复解密兜底返回 null,并走重新认证流程
在某些低端机型上首次生成密钥抛异常设备 Keystore 实现异常或 StrongBox 不支持用 try-catch 降级到 TEE 方案,不要强上 StrongBox
每次加密后的结果都不同,怀疑加密坏了GCM 每次随机生成 IV,正常现象无需处理,不要手动固定 IV
DataStore 文件损坏导致启动异常非正常断电或旧系统 IO 异常通过corruptionHandler清掉损坏文件,兜底重写

6.2 备份恢复场景的血泪教训

这里值得单独拿出来讲。Android 的自动备份机制会把应用的文件目录备份到云端,DataStore 的.preferences_pb文件也会被备份下来。但 Android Keystore 里的密钥,出于安全设计,从来不参与应用数据备份。这就意味着:用户换新机或重装应用后,DataStore 文件恢复了,但解密所需的密钥没了,所有加密字段会集体解密失败。

这个问题没有银弹。商业 App 的通用做法是:解密兜底返回 null,前端检测到 token 失效,引导用户重新走一遍登录授权流程,用服务端下发的全新 token 重新加密写回。如果你存的是本地生成的离线密钥,那就只能允许用户重置本地数据。无论哪种情况,异常捕获兜底都必不可少,否则一旦出现备份恢复场景,应用冷启动就直接崩溃,用户体感极差。

6.3 锁屏凭据变更导致的密钥不可用

如果KeyGenParameterSpec里设置了setUserAuthenticationRequired(true),那还牵扯一个更敏感的联动:用户修改锁屏密码、移除锁屏或者换指纹,都会导致密钥被系统永久标记为失效。这是 Keystore 的安全机制,不是 bug——它的本意就是防止设备丢失后被解锁篡改后继续使用加密数据。

对这种密钥,业务侧必须预判“失效后怎么恢复”的闭环,比如检测到KeyPermanentlyInvalidatedException后,马上清掉本地缓存并引导用户重新登录。如果嫌这个流程麻烦,可以不设置用户认证要求,仅依赖设备原生解锁状态,密钥在设备解锁后一直可解密,安全性也够用。自行权衡即可,但一旦选了强认证,就一定要把失效路径做完整。

6.4 加密/解密性能实测参考

最后给一个数据参考。AES-GCM + Keystore 在工程机上加密 1KB 的数据大约需要 10-20ms,解密类似。这个耗时基本可以忽略。真正需要注意的是频繁创建Cipher实例和频繁调用KeyStore查询——虽然每次查询不算慢,但几百次连续操作叠加也会产生可见时间消耗。如果有批量处理需求,建议在内存里缓存一次SecretKey引用,而不是每次加解密都走一遍KeyStore.getKey()。我封装的时候已经在KeystoreManager里加了一层内存缓存,实测批量读写场景能省下不少开销。

7. 个人的一点实际体会

把 SharedPreferences 迁到 DataStore + Android Keystore 这套方案后,我最直观的感受不是“代码变好看了”,而是启动阶段的主线程卡顿确实降了下来,token 更安全性,异常路径也更可控。整个过程最花时间的不是写加密代码,而是想清楚哪些 key 值得上锁、哪些不上锁,以及构建一套稳妥的解密失败兜底策略。

最后再分享一个小技巧:如果你在自动化测试里跑这套代码,尽量在测试设备上提前生成好密钥并保持锁屏状态稳定,否则每次测试环境重置证书都可能导致密钥失效,排查半天原来不是代码问题。把密钥生成、加解密、异常兜底这三个维度单独拆出测试用例,比端到端一把梭好调试得多。希望这篇能帮你把这条路上该踩的坑提前踩完。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询