Android随笔-DataStore

DataStore是什么

DataStore 是 Jetpack 推出的异步、事务性、类型安全的数据存储库,定位是彻底替代 SharedPreferences。它建立在 Kotlin 协程 + Flow 之上,分两种形态:

形态 存储方式 类型安全 对标
Preferences DataStore 键值对(protobuf 二进制) 弱(和 SP 一样靠 key) SP 的直接替代
Proto DataStore 自定义 protobuf schema 强(编译期生成的类型) 结构化配置/小型数据模型

文件存储在 /data/data/<包名>/files/datastore/<文件名>(不是 SP 的 shared_prefs 目录)。

二、Preferences DataStore 使用

1. 依赖

groovy 复制代码
implementation "androidx.datastore:datastore-preferences:1.1.1"

2. 定义(必须是进程内单例)

kotlin 复制代码
// 写在文件顶层,by 委托保证全局唯一实例
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

必须单例:同一个文件创建多个 DataStore 实例会直接抛 IllegalStateException(文件锁冲突),这是最常见的坑。by preferencesDataStore 委托内部会缓存,天然解决。

3. 定义 key

kotlin 复制代码
object PrefKeys {
    val LAUNCH_COUNT = intPreferencesKey("launch_count")
    val USER_NAME = stringPreferencesKey("user_name")
    val DARK_MODE = booleanPreferencesKey("dark_mode")
    val LAST_TS = longPreferencesKey("last_timestamp")
    // 还有 float / double / stringSet / byteArrayPreferencesKey
}

4. 读

kotlin 复制代码
// data 是 Flow<Preferences>,每次变更都会发射新的完整快照
val nameFlow: Flow<String> = context.settingsDataStore.data
    .map { prefs -> prefs[PrefKeys.USER_NAME] ?: "" }

// 在 ViewModel 里转成 StateFlow
val name = nameFlow.stateIn(viewModelScope, SharingStarted.Eagerly, "")

// 只想取一次(注意是挂起函数)
val count = context.settingsDataStore.data.first()[PrefKeys.LAUNCH_COUNT] ?: 0

5. 写

kotlin 复制代码
// Preferences DataStore 的 edit 等价于 Proto DataStore 的 updateData
context.settingsDataStore.edit { prefs ->
    prefs[PrefKeys.LAUNCH_COUNT] = (prefs[PrefKeys.LAUNCH_COUNT] ?: 0) + 1
    prefs.remove(PrefKeys.USER_NAME)   // 删除
    // prefs.clear()                   // 清空
}

edit{} 是一个事务块:lambda 里拿到的 MutablePreferences 是当前已落盘数据的快照,lambda 返回后整体原子写回。多个并发 edit 会严格排队(见原理部分)。

三、内部原理(源码级)

核心实现类是 SingleProcessDataStore,理解了它就理解了 DataStore 的 90%。

1. 状态机:ReadState

DataStore 内部的数据持有是一个显式状态机:

kotlin 复制代码
sealed class ReadState<T> {
    class UnInitialized      // 还没读过文件
    class ReadException      // 读文件炸了(不可恢复异常)
    class Data<T>(           // 正常状态
        val value: T,              // 当前数据快照(不可变对象)
        val hashCode: Int,
        val updateLock: ...
    ) : ReadState<T>()
    class Final<T>           // scope 取消,终态
}

注意 Data 里持有的 value 是不可变对象 (ImmutablePreferences)。每次 edit 不是原地改,而是生成一个全新的快照替换掉旧引用------这就是 Flow 的订阅者能拿到"新数据"的原因,也是写时复制(copy-on-write)思路。

2. 并发核心:SimpleActor(单写者模型)

这是 DataStore 最精华的设计。所有读写请求都被包装成 Message,投递给一个 Actor 严格串行处理:

kotlin 复制代码
// SingleProcessDataStore
private val actor = SimpleActor<Message<T>>(
    scope = scope,
    onComplete = { ... },
    consumeMessage = { message ->
        when (message) {
            is Message.Read -> handleRead(message)
            is Message.Update -> handleUpdate(message)
        }
    }
)

SimpleActor 内部结构:

kotlin 复制代码
class SimpleActor<T>(scope, ..., consumeMessage) {
    private val messageQueue = Channel<T>(capacity = UNLIMITED)  // 无界 Channel 当消息队列

    init {
        scope.launch { consumeMessages() }
    }

    private suspend fun consumeMessages() {
        for (msg in messageQueue) {        // for 循环收 Channel,FIFO 严格串行
            consumeMessage(msg)
        }
    }

    fun offer(msg: T) { messageQueue.trySend(msg) }
}

这意味着:不存在锁竞争,不存在并发写文件,天然线程安全。你开 100 个协程同时 edit,它们只是在往 Channel 里投消息,落盘永远一个接一个。

3. 读流程:从收集 Flow 到发射数据

kotlin 复制代码
override val data: Flow<T> = flow {
    // ① 先发当前内存快照(如果已初始化)
    val currentState = readState.value
    if (currentState is Data) emit(currentState.value)

    // ② 订阅后续变更
    downstreamFlow.collect { emit(it) }
}.onStart {
    actor.offer(Message.Read(...))   // ③ 有人收集了,触发初始化读盘
}

当状态是 UnInitialized 时,第一条 Read 消息触发 readAndInit():

kotlin 复制代码
private suspend fun readAndInit() {
    // 双重检查,只初始化一次
    val initData = FileInputStream(file).use { serializer.readFrom(it) }  // 挂起,在 IO 调度器
    updateData(initData)            // 状态机 UnInitialized → Data
    // 然后依次补跑排队的 Update 任务
    runTasks()
}

要点:读文件是挂起操作执行在 Dispatchers.IO,主线程只是等协程恢复,线程本身没被占。

4. 写流程:updateData 的完整链路

kotlin 复制代码
override suspend fun updateData(transform: suspend (t: T) -> T): T {
    val ack = CompletableDeferred<T>(coroutineContext[Job])
    val updateMsg = Message.Update(transform, ack, currentState, callerContext)
    actor.offer(updateMsg)       // 投消息
    return ack.await()           // 挂起等结果
}

Actor 消费到 Update 消息时:

kotlin 复制代码
// handleUpdate → transformAndWrite
val newData = update.transform(curValue)   // ① 执行你的 lambda,拿到新数据
writeScope { outputStream ->
    serializer.writeTo(newData, outputStream)  // ② 序列化写文件
}
ack.complete(newData)                       // ③ 唤醒挂起的调用方

事务性的来源:transform 拿到的 curValue 一定是"上一个 Update 落盘后的最新值",且 Actor 串行保证两个 Update 不会交错。read-modify-write 天然正确,不需要任何锁。

5. 落盘:临时文件 + 原子 rename

kotlin 复制代码
// FileConnections.writeScope
val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX)   // "<name>.tmp"
scratchFile.createNewFile()
FileOutputStream(scratchFile).use { stream ->
    serializer.writeTo(newData, stream)
    stream.fd.sync()                                          // fsync 刷盘
}
if (!scratchFile.renameTo(file)) throw IOException("Unable to rename")

三个保证:

  • fsync:数据真正进磁盘,不是 page cache;
  • rename 原子性:写一半崩溃,要么旧文件完整、要么新文件完整,不存在半截文件(对比 SP 的 .bak 备份方案,这是更干净的解法);
  • .tmp 残留由启动时 cleanUp 处理。

6. 损坏恢复:CorruptionHandler

文件损坏(解密失败、半截 protobuf)时 serializer.readFrom 抛 CorruptionException:

kotlin 复制代码
val Context.safeStore by preferencesDataStore(
    name = "settings",
    corruptionHandler = ReplaceFileCorruptionHandler { ex ->
        emptyPreferences()   // 损坏时用空配置重启,而不是崩溃
    }
)

内部 readAndInit 捕获后调用 handler 拿到替代值继续初始化,状态机进入 Data 而不是 ReadException。

7. 作用域与生命周期

DataStoreFactory.create(scope = ...) 里的 scope 决定:

  • IO 跑在哪个调度器(默认 Dispatchers.IO);
  • Actor 消费协程的生命周期------scope 取消,DataStore 进入 Final 态,再访问抛异常。

用 by preferencesDataStore 委托时默认 scope 是应用级的,正常不用管。

四、Proto DataStore

当需要强类型 + 自定义结构时使用。三步:

1. 定义 schema(.proto 文件)

proto 复制代码
syntax = "proto3";
option java_package = "com.example.proto";
option java_multiple_files = true;

message UserSettings {
  string user_name = 1;
  int32 launch_count = 2;
  bool dark_mode = 3;
}

protobuf 插件编译后生成 Java/Kotlin 类。

2. 实现 Serializer

kotlin 复制代码
object UserSettingsSerializer : Serializer<UserSettings> {
    override val defaultValue: UserSettings = UserSettings.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): UserSettings =
        try {
            UserSettings.parseFrom(input)
        } catch (e: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", e)
        }

    override suspend fun writeTo(t: UserSettings, output: OutputStream) =
        t.writeTo(output)
}

3. 创建与使用

kotlin 复制代码
val Context.userStore: DataStore<UserSettings> by dataStore(
    fileName = "user_settings.pb",
    serializer = UserSettingsSerializer
)

// 写:builder 模式
context.userStore.updateData { current ->
    current.toBuilder()
        .setLaunchCount(current.launchCount + 1)
        .build()
}

// 读
val settings = context.userStore.data.first()

Preferences vs Proto 怎么选:散落的开关/标记用 Preferences(省去维护 proto);有明确领域模型、字段间有关联的用 Proto(编译期类型检查 + 默认值管理)。

五、从 SP 迁移

kotlin 复制代码
val Context.settingsDataStore by preferencesDataStore(
    name = "settings",
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = "legacy_sp"
                // keysToMigrate = setOf("a", "b")  // 可选:只迁指定 key
            ) { prefs, current ->   // 可选的映射 lambda,自定义转换逻辑
                current.toMutablePreferences().apply {
                    putAll(prefs.all.map { (k, v) -> ... })
                }.toPreferences()
            }
        )
    }
)

机制:首次 readAndInit 时如果 datastore 文件不存在,按顺序跑所有 Migration,把 SP 数据转换后作为初始值落盘,然后从 SP 删掉已迁移的 key(用 SharedPreferencesView 操作,迁移只执行一次,靠目标文件是否存在来判断)。

六、常见坑与最佳实践

  1. 多实例冲突:同一文件两个 DataStore → IllegalStateException: DataStore cannot be used with multiple...。解决:一律用顶层 by preferencesDataStore 委托。
  2. 主线程 runBlocking { data.first() }:把异步 API 强行同步化,等于把 SP 的 ANR 问题手动搬回来。永远用 Flow 收集或协程挂起。
  3. 大对象/大数据:和 SP 一样是全量读写(改一个字段也是整个文件重写一遍)。超过几百 KB 的列表数据请用 Room。
  4. 高频写入:edit 每次都全量序列化 + fsync,埋点式高频写入要自己做批量/节流。
  5. IO 异常:文件读不出来(非 CorruptionException 的 IOException)会让 Flow 抛异常,UI 层记得 catch {}:
kotlin 复制代码
dataStore.data
    .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
    .map { ... }
  1. 多进程:SingleProcessDataStore 明确只保证单进程。新版 datastore-core-multiprocess 提供了 MultiProcessDataStoreFactory(基于文件锁 + 版本协商),但成熟度不如单进程方案;重多进程场景还是看 MMKV。
  2. 版本兼容:读老文件时 protobuf 字段号是兼容的关键------字段只能新增、不能改号、不能删号(删了要 reserved)。

七、总结

复制代码
你的代码                        SingleProcessDataStore 内部
─────────                      ──────────────────────────────
data.collect {}    ──offer──▶  Message.Read ─┐
                                             │
edit { ... }       ──offer──▶  Message.Update│    SimpleActor (Channel + 单协程)
updateData { }     ──offer──▶  Message.Update┴──▶ 严格 FIFO 串行消费
                                                     │
                              ReadState 状态机 ◀─────┘
                              UnInitialized → Data → Final
                                                     │
                              读: serializer.readFrom (Dispatchers.IO)
                              写: .tmp 文件 + fsync + renameTo 原子替换
                                                     │
                              变更 → StateFlow 发射新 Immutable 快照 → 下游 Flow

一句话总结 :DataStore = 不可变快照 + Actor 单写者串行化 + 协程挂起 IO + 原子文件替换,四个设计分别解决了 SP 的可变共享状态、锁竞争、主线程阻塞、写文件不原子四个历史顽疾。

相关推荐
笑一下蒜了.15 分钟前
Linux 存储多路径访问手册|multipath.conf 各配置段详解
android·linux·运维
凡泰AI18 分钟前
如何通过小程序多端框架,让一个小程序同时运行在iOS、安卓、鸿蒙以及PC客户端,实现一次开发多端运行的效果
android·ios·小程序
一笑的小酒馆1 小时前
Androidiot扫地机器人设备详情页地图加载慢卡顿优化方案
android
Zhu7587 小时前
在k8s集群部署MySQL单实例,支持多个主流发行版
android·mysql·kubernetes
AFinalStone10 小时前
Android 7系统异常问题排查(四)Framework层(上)—System Server Watchdog
android·系统异常
宝杰X712 小时前
Android Room3 多平台数据库
android·数据库
huibin14785236914 小时前
热缓解学习记录
android·学习
小田的博客17 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器
一笑的小酒馆18 小时前
Android智能猫砂盆视频加载慢卡顿问题分析
android
只会cv的小前端20 小时前
七巧低代码服务端脚本使用方法
android·低代码·rxjava