存储体系与 Scoped Storage
存储选型要同时考虑数据模型, 容量, 进程模型, 备份, 加密, 迁移和用户可见性.“某个库最快” 或 “内部存储绝对不能放图片” 都不是完整结论.
一, 结构化配置: Preferences 与 Proto DataStore
| 方案 | 适用场景 | 关键边界 |
|---|---|---|
| Preferences DataStore | 少量无 schema 的键值配置 | 类型约束较弱, 默认实现不能笼统视为自动多进程安全 |
| Proto DataStore | 有明确 schema, 默认值和演进需求的配置 | 需要 protobuf schema 与序列化器 |
| Room | 查询, 关系, 事务和较大结构化数据 | 需要 migration, 索引和数据库测试 |
DataStore 1.1.0 及以上提供 MultiProcessDataStoreFactory; 它面向 Android 5.0(API 21) 及以上, 多个进程访问同一文件时每个进程仍只能创建一个实例. 单进程 DataStoreFactory/preferencesDataStore 与多进程工厂不能混用来访问同一路径, 否则可能破坏一致性. 先确认依赖版本和进程模型; 普通 Preferences/Proto DataStore 不是天然多进程方案.
以下为上下文片段: 依赖 DataStore 1.1+, SettingsSerializer 为应用的 Proto serializer. 所有进程传入同一个绝对 produceFile 路径; 每个进程由其进程内单例容器持有一个实例. 只要该文件被多进程访问, 所有进程都必须使用 MultiProcessDataStoreFactory, 不得有任何普通 DataStore 实例访问同一路径.
object SettingsStoreContainer {
private var instance: DataStore<Settings>? = null
fun get(context: Context): DataStore<Settings> = synchronized(this) {
instance ?: MultiProcessDataStoreFactory.create(
serializer = SettingsSerializer,
produceFile = {
File(context.filesDir, "datastore/settings.pb").absoluteFile
},
).also { instance = it }
}
}
class AppGraph(context: Context) {
val settings: DataStore<Settings> = SettingsStoreContainer.get(context.applicationContext)
}
二, App-specific, MediaStore 与 SAF
- App-specific 内部/外部目录适合应用私有文件, 缓存和可清理数据; 是否存图片/视频取决于容量, 生命周期, 备份和清理责任, 不是绝对禁止.
- 用户可见媒体优先通过 MediaStore 管理.
- 用户主动选择任意文档 / 目录使用 Storage Access Framework, 并持久化必要 URI 权限.
- 缓存目录随时可能被系统或应用清理, 业务关键数据不能只放 cache.
三, 敏感数据与 Android Keystore
Jetpack Security Crypto 中 EncryptedSharedPreferences 等 API 已弃用, 不应作为新项目默认推荐. 迁移策略应根据数据威胁模型选择:
- 用 Android Keystore 生成 / 保存不可导出的密钥材料.
- 使用经过审查的加密库对数据做 AEAD 加密, 并保存版本, nonce/IV 和迁移信息.
- 对 token 优先考虑服务端短期凭据, 轮换, 撤销和最小存储, 而不是只做本地加密.
- 为旧密文设计分阶段读取, 重写和失败回退.
Keystore 密钥是否由 TEE, StrongBox 或其他硬件保护取决于设备与密钥属性, 不能保证 “一定在 TEE/SE”.需要高保证时读取 key security level/attestation, 并设计不支持设备的降级策略.
安全等级推演: 对 SecretKey 用 SecretKeyFactory/KeyStore.getKeyInfo() 取 KeyInfo, 读 isInsideSecureHardware(), getSecurityLevel() 等属性; 非对称密钥可看 key.getOrigin() 判断密钥是否在安全硬件内生成. 需要强绑定时用密钥证明 (attestation) 校验证书链, 判断密钥是否由可信 TEE/StrongBox 签发, 也可用于识别模拟器或降级环境. 模拟器 /root 等低保证环境下应降级: 不把本地密钥当作设备身份的唯一依据, 提高服务端校验强度并缩短敏感数据留存.
设备指纹 / 风控 SDK 的存储策略 (风控域案例)
风控 SDK 的存储策略与普通应用差异在于: 既要尽量维持设备身份跨重装的稳定性, 又要满足隐私最小化, 两者需要在存储层显式权衡.
- 跨重装持久化: app-specific 数据在卸载后会被系统清除, 不能把 “本地唯一标识文件” 当作跨重装的持久凭据. 更稳妥的是 “重装后生成的稳定标识 + 服务端关联”: 本地只保存本次安装随机生成的标识与派生密钥, 由服务端用多信号把新标识关联回既有风险画像, 而不是依赖本地唯一文件跨卸载存活.
- 卸载后的 Keystore 行为: Keystore 密钥通常随应用数据一并被清除, 是否残留取决于设备 / ROM 与厂商实现, 不能假设卸载后密钥仍可用; 密钥默认不可导出, 卸载清库后不存在 “导出恢复” 路径, 依赖该密钥加密的历史数据应视为不可恢复.
- Keystore 与设备绑定: 不可导出属性保证私钥不离开安全硬件, 取证即使拿到设备镜像也拿不到私钥明文, 只能看到受密钥保护的密文; 但这依赖设备安全等级 (TEE/StrongBox 或纯软件实现), 不能一概而论.
- 敏感采集数据的存储: 本地化, 最小化, 加密. 客户端只落盘风控所需的少量信号, 不存可还原身份的明文; 完整判断交给服务端, 客户端不长期留存采集原始数据.
实操: SAF 与 MediaStore 的边界
以下为上下文片段: 使用 ActivityResultContracts.StartActivityForResult 读取返回 Intent.flags, 放在 ComponentActivity 或 Fragment 中; 省略界面, 持久 URI 元数据表和错误提示. ACTION_OPEN_DOCUMENT 让用户选择既有文件, 拿到的是 content:// URI, 不是稳定文件路径.
private val openDocument = registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
val uri = result.data?.data ?: return@registerForActivityResult
val requested = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
val granted = result.data!!.flags and requested
if (granted == 0) return@registerForActivityResult
try {
contentResolver.takePersistableUriPermission(uri, granted)
persistedUris.save(uri, granted) // 保存 URI 和同一组实际 grant flags
} catch (error: SecurityException) {
// 用户撤销授权或提供方拒绝持久化;提示重新选择.
} catch (error: UnsupportedOperationException) {
// 提供方未支持持久化授权;本次临时授权仍须按生命周期使用.
}
if (granted and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) {
try {
val input = contentResolver.openInputStream(uri)
?: throw IOException("Provider returned no input stream for $uri")
input.bufferedReader().use { it.readLine() }
} catch (error: IOException) {
// 读取失败不阻断随后独立的写入尝试;按业务提示或重试.
} catch (error: SecurityException) {
// 已获 grant 也可能被撤销;写入仍由其独立边界决定.
}
}
if (granted and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
try {
val payload = "updated by InterviewDemo\n".toByteArray(Charsets.UTF_8)
val output = contentResolver.openOutputStream(uri, "wt")
?: throw IOException("Provider returned no output stream for $uri")
output.use { it.write(payload) }
} catch (error: IOException) {
// 写入失败不影响已完成的读取;按业务提示或重试.
} catch (error: SecurityException) {
// 写入授权可能已被撤销;读取结果不受影响.
}
}
}
fun chooseTextFile() = openDocument.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE)
.setType("text/plain")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION),
)
fun releaseDocument(uri: Uri, persistedFlags: Int) {
contentResolver.releasePersistableUriPermission(uri, persistedFlags)
persistedUris.remove(uri)
}
预期: 只对实际返回的 read/write grant 持久化, 读写和释放; 若提供方未授予写权限, 绝不尝试写入. openOutputStream(uri, "wt") 的 wt 请求写入时覆盖 / 截断既有内容, 不能用于追加语义; 示例写入明确的非空 UTF-8 payload. 持久化成功后保存的 flags 是释放时唯一可用的同一组 flags, 不能凭请求时 flags 猜测. 用户在系统设置撤销授权, 提供方删除文件或应用主动释放授权后都必须处理 SecurityException. 适用于支持 SAF 的 Android 4.4(API 19) 及以上, 具体文档提供方能力不同.
以下为上下文片段: 保存用户明确要求导出的 JPEG 到共享图片库. API 29+ 用 IS_PENDING/RELATIVE_PATH, 应用创建自己的媒体通常不需要广泛存储写权限; API 28 及以下使用 DATA, 并须在 Manifest 声明且运行时取得 WRITE_EXTERNAL_STORAGE 后再写入. 读取其他媒体时, API 33+ 按媒体类型请求 READ_MEDIA_IMAGES 等权限, 旧版本使用 READ_EXTERNAL_STORAGE; 优先考虑 Photo Picker. 权限和行为仍须按设备 API, targetSdk 与商店政策复核.
val legacyFile = if (Build.VERSION.SDK_INT < 29) {
val parent = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
check(parent.exists() || parent.mkdirs()) { "Cannot create ${parent.absolutePath}" }
File(parent, "export-${System.currentTimeMillis()}-${UUID.randomUUID()}.jpg")
} else {
null
}
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "export-${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= 29) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/InterviewDemo")
put(MediaStore.Images.Media.IS_PENDING, 1)
} else {
put(MediaStore.Images.Media.DATA, requireNotNull(legacyFile).absolutePath)
}
}
val collection = if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
var uri: Uri? = null
try {
uri = requireNotNull(contentResolver.insert(collection, values))
requireNotNull(contentResolver.openOutputStream(requireNotNull(uri))).use { it.write(jpegBytes) }
if (Build.VERSION.SDK_INT >= 29) contentResolver.update(requireNotNull(uri), ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
} catch (failure: Exception) {
uri?.let { contentResolver.delete(it, null, null) }
legacyFile?.delete() // insert 或写入失败均清理旧版残留文件
throw failure
}
选择路径: 应用私有草稿用 app-specific 目录; 用户可见的图片, 视频, 音频用 MediaStore; 用户选择的任意文档或目录用 SAF; 关系查询和事务用 Room; 少量配置用 DataStore. 媒体读取权限, Photo Picker 和旧版外部存储兼容规则随 API/targetSdk 变化, 应在发布时按官方行为变更表复核.
自测 (在 Android 工程执行):(1) 选只读 provider 的文档, 预期只保存 read grant 且不写入;(2) 选可写测试文档, 先写入非空 UTF-8 payload, 再验证 wt 会覆盖 / 截断旧内容, 不能以空写成功作为证据;(3) 重启后用保存的 flags 读取, 再释放后预期得到 SecurityException;(4) 在 API 28 与 API 29+ 设备各导出一张图, 预期前者有唯一文件名, 父目录与运行时写权限, 后者写完前不出现在图库, 且 insert / 写入失败时 MediaStore 行和残留文件均被删除;(5) 在两个进程同时读写同一路径, 预期均经各自单例的 MultiProcess DataStore 实例访问, 日志中没有普通 DataStore 访问该路径.
四, 空间, 内存与清理信号
Application.onTrimMemory() 是内存压力 / 进程状态回调, 不是磁盘空间不足通知. 磁盘治理应主动检查可用空间, 控制缓存配额, 响应写入失败, 并提供用户可理解的清理策略.
- 缓存使用 LRU/TTL/配额并允许重建.
- 关键写入采用临时文件 + fsync / 原子替换等策略 (按数据重要性权衡成本).
- 大文件下载支持断点, 校验, 失败清理和容量预估.
- 断点上传的时序与校验细节见 30 网络排障专项.
五, Room 迁移
Room schema 演进本质是数据库文件的原地升级: 迁移失败或破坏性迁移会直接清空既有本地数据, 属于存储层备份与清理责任的范畴. Room 会在已注册的 migration 图中找一条可用路径, 但不要承诺 “自动寻找最短路径”; 每次 schema 变化都应有明确迁移或可接受的破坏性策略, 并用导出的 schema + MigrationTestHelper 覆盖多版本升级测试. 迁移类写法, AutoMigration 边界与 fallbackToDestructiveMigration() 的代价见 31 数据库进阶.
六, MMKV 等第三方 KV
MMKV 可以作为特定性能 / 多进程场景的候选, 但不是无条件最佳实践. 选型前比较:
- 数据一致性, 崩溃恢复和多进程语义;
- 加密与密钥管理;
- 数据迁移, 可观测性和维护状态;
- 实际设备上的读取/写入/启动 benchmark;
- 与 DataStore/Room 的团队维护成本.
高频面试题
Q1: Preferences DataStore 和 Proto DataStore 怎么选?
少量简单配置可用 Preferences; 需要 schema, 类型安全, 默认值和演进时使用 Proto. 多进程需求要单独选择对应实现, 不能由名字推断.
Q2: Keystore 是否保证硬件安全?
不保证所有设备都一样. 密钥可能由 TEE/StrongBox 等级保护, 也可能只有软件级保护, 应读取安全等级或使用 attestation 判断.
Q3: Scoped Storage 下如何让用户选择文件?
通过 SAF 获取用户授权的 URI, 按需持久化 URI permission, 并使用 ContentResolver 读写; 不要依赖真实文件路径.
易错点 / 追问
- Type DataStore 的正确名称是 Proto DataStore.
onTrimMemory()不表示磁盘空间不足.- 不继续把已弃用的 EncryptedSharedPreferences 当新项目默认方案.
- 不把 MMKV, Room, DataStore 按 “谁最快” 简单排名, 先看数据语义.
版本与参考资料
- 最后核验: 2026-08-07
- DataStore
- Storage overview
- Android Keystore
- Room migrations