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 的可变共享状态、锁竞争、主线程阻塞、写文件不原子四个历史顽疾。

相关推荐
AFinalStone2 小时前
Android 7系统网络(八)应用API层—ConnectivityManager使用与实战调试
android·网络
plainGeekDev3 小时前
kapt 替换为 KSP
android·java·kotlin
蜡台3 小时前
通过Gradle脚本声明更改Java变量
android·java·开发语言·python·kotlin·gradle·groovy
阿pin6 小时前
Android随笔-SharedPreferences
android·sp
茶栀(*´I`*)7 小时前
Android 测试入门指南:ADB 基础配置与常用设备管理命令解析
android·adb
gxgldyh9 小时前
Android Framework源码解析(七):BootAnimation 启动流程解析——开机动画是如何显示出来的?
android
达达尼昂10 小时前
在 Claude Cowork 中用好 Claude Fable 5
android·人工智能·后端
sTone8737511 小时前
写时复制COW的第一性理解
android·c++·flutter
sTone8737511 小时前
Zygote的第一性理解
android