Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

存储体系与 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 已弃用, 不应作为新项目默认推荐. 迁移策略应根据数据威胁模型选择:

  1. 用 Android Keystore 生成 / 保存不可导出的密钥材料.
  2. 使用经过审查的加密库对数据做 AEAD 加密, 并保存版本, nonce/IV 和迁移信息.
  3. 对 token 优先考虑服务端短期凭据, 轮换, 撤销和最小存储, 而不是只做本地加密.
  4. 为旧密文设计分阶段读取, 重写和失败回退.

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 按 “谁最快” 简单排名, 先看数据语义.

版本与参考资料