查看原文
其他

关于Jetpack DataStore的八点疑问

小鱼人爱编程 鸿洋 2023-09-13

本文作者


作者:小鱼人爱编程

链接:

https://juejin.cn/post/7235801811524763705

本文由作者授权发布。


前言

DataStore是Android上一种轻量级存储方案,依据官方教程很容易就写出简易的Demo。
本篇主要是分析关于DataStore(Preferences)使用过程中的一些问题,通过问题寻找本质,反过来能更好地指导我们合理使用DataStore。
本篇内容目录:
1DataStore如何存取数据?


DataStore有两种存储类型:Preferences(与SharedPreferences对标) 和 Proto
为方便行文,以下所说的DataStore指的是Preferences类型。

引入依赖

在Module级别的build.gradle里引入:
implementation("androidx.datastore:datastore-preferences:1.0.0")

使用DataStore存取数据

存数据

1. 先声明DataStore对象:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "test")
DataStore是key-value 结构,因此在存取数据之前先定义好key的名字以及value的类型。
2. 声明key的结构:
val myNameKey = stringPreferencesKey("name")
val myAgeKey = intPreferencesKey("age")
想要在DataStore里存储姓名和年龄,其中姓名是String类型,年龄是Int类型。
3. 存储value
suspend fun saveData() {
    context.dataStore.edit {
        //给不同的key赋值
        it[myNameKey] = "fish"
        it[myAgeKey] = 18
    }
}

取数据

suspend fun queryData() {
    context.dataStore.data.collect {
        it.asMap().forEach {
            println("${it.key.name}, ${it.value}")
        }
    }
}
//打印结果:
I/System.out: name, fish
I/System.out: age, 18

可以看出存取过程和SharedPreferences很相似,只是key的构造有些差异。


2DataStore能存放哪些类型数据?


上面在构造DataStore的Key时,我们使用了两个函数:
stringPreferencesKeyintPreferencesKey,其中前缀指明了存储的value是什么类型。
实际上还有其它类型的value:
可以看出有7种类型:
Boolean、Double、Float、Int、Long、String、Set
3DataStore存取是否耗时?


在存储数据时,我们都依赖于:
dataStore.data
而它是Flow类型:
而Flow必须要在协程里使用,因此我们使用了挂起函数(suspend)修饰存取函数。
同时我们也知道,挂起函数并不耗时。
当在主线程里分别调用DataStore的存取函数,并不会阻塞主线程。
值得注意的是:
1. 存取数据的闭包的执行是在当前协程(调用saveData/queryData的协程)里执行的。
2. 假若当前是在主线程发起的存取动作,那么闭包将在主线程执行。

总的来说:借助于协程的特性,DataFlow存取数据并不耗时。


4DataStore Flow是如何设计的?


DataStore Flow是冷流还是热流?

先看DataStore的实现,主要依靠:SingleProcessDataStore
在里面找到dataStore.data的定义:
//定义热流
private val downstreamFlow = MutableStateFlow(UnInitialized as State<T>)
override val data: Flow<T> = flow {

    val currentDownStreamFlowState = downstreamFlow.value

    if (currentDownStreamFlowState !is Data) {
        actor.offer(SingleProcessDataStore.Message.Read(currentDownStreamFlowState))
    }

    emitAll(
        //监听热流变化
        downstreamFlow.dropWhile {
            //满足条件则丢弃数据
            if (currentDownStreamFlowState is Data<T> ||
                currentDownStreamFlowState is Final<T>
            ) {
                //不满足则继续流向map
                false
            } else {
                //判断是否满足
                it === currentDownStreamFlowState
            }
        }.map {
            when (it) {
                //根据类型,返回不同的值
                is ReadException<T> -> throw it.readException
                is Final<T> -> throw it.finalException
                //正常的返回值
                is Data<T> -> it.value
                is UnInitialized -> error(
                    "This is a bug in DataStore. Please file a bug at: " +
                            "https://issuetracker.google.com/issues/new?" +
                            "component=907884&template=1466542"
                )
            }
        }
    )
}
可以看出:
1. dataStore.data 是Flow,它是冷流。
2. dataStore.data 里依靠downstreamFlow(热流)持续监听数据的变化。
3. 因此dataStore.data 可以持续监听数据的变化,当DataStore里数据发生变化时将会回调闭包。

DataStore Flow与其它Flow的差异

先看普通的flow:
suspend fun queryData2() {
    val flow = flow { 
        emit("hello")
    }

    flow.collect {
        println(it)
    }

    println("normal flow end")
}
大家猜测一下:"normal flow end"会打印吗?
再看DataStore的Flow:
suspend fun queryData() {
    context.dataStore.data.collect {
        it.asMap().forEach {
            println("${it.key.name}, ${it.value}")
        }
    }

    println("dataStore flow end")
}
再猜一下:"dataStore flow end"会打印吗?
答案是:
"normal flow end"会打印,而"dataStore flow end"永远没有机会执行
原因是DataStore Flow里依赖了热流监听数据,而热流的collect是不会退出的。

其实这也很容易想到:若是DataStore Flow的collect退出了,它就无法监听数据变化了。


5DataStore 刷新范围?


存取影响范围

我们已经知道DataStore Flow可以监听数据的变化,假设我们一个文件里存放了很多对Key--Value,但是我们只关心其中一个或是某几个Key--Value的变化,比如现在新增一个key="score"字段:

val myScoreKey = floatPreferencesKey("score")
suspend fun queryDataV2() {
    context.dataStore.data.map {
        //只关心分数的变化    
        it[myScoreKey]
    }..collect {
        println("$it")
    }
}
suspend fun saveData2() {
    context.dataStore.edit {
        //只修改分数
        it[myScoreKey] = 99f
    }
}
虽然文件了存放了三个字段:name、age、score,但是我们只更新了score字段,并且也仅仅监听score字段的变化。
那么问题来了:单个设置/监听某个字段会提升效率吗?
答案是:不会,因为DataStore的更新是基于单个文件的全量更新,也就是说虽然只是更改了score字段的值,写入文件的时候name/age字段值也会写入。
我们换个写法来进行测试:
suspend fun saveData2() {
    context.dataStore.edit {
        //只修改分数
        it[myNameKey] = "fish is perfect"
    }
}
现在只是更改name字段,最后发现只监听了score变化的闭包也调用了。
小结:
DataStore更新和监听都是针对单个文件的全部字段。

存相同的数值

还是以保存name为例:
suspend fun saveData2() {
    context.dataStore.edit {
        //只修改分数
        it[myNameKey] = "fish is perfect"
    }
}
当调用这函数两次。
问题:第二次调用的时候,还有会写文件的动作吗?

答案:不会,因为每次更新数据之前都会比对和上一次的数据是否一致,若是一致则不会再写入文件,当然也不会产 生数据变化的通知。


6DataStore是线程安全的吗?


先看Demo:
suspend fun saveData2() {
    context.dataStore.edit {
        //只修改分数
        it[myNameKey] = "fish is perfect3"
    }
}

GlobalScope.launch(Dispatchers.IO) {
    myDataStore.saveData2()
}

GlobalScope.launch(Dispatchers.Main) {
    myDataStore.saveData2()
}
同时在子线程和主线程去更新DataStore的内容,这样合理吗?会有线程安全的问题产生吗?
答案:合理的、可行的,因为DataStore的读写是线程安全的。
1. 不管是读还是写,每次调用当做一次任务,若当前没有协程执行任务,则开启新协程执行任务,新协程跑在IO线程里。
2. 若是有任务在执行,则仅仅只是将任务加入到队列里,调用者返回;当上个任务执行完毕再执行该任务。
3. 因此单个DataStore读写是线程安全的。

此处的策略和线程池的实现类似,有需要的可以查看过往关于线程池设计的文章。


7能否创建多个DataStore实例?


我们一般会将都DataStore的操作封装起来:
class MyDataStore(val context: Context) {
    val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "test")

    suspend fun saveData2() {
        context.dataStore.edit {
            //只修改分数
            it[myNameKey] = "fish is perfect3"
        }
    }
}
而在Activity里的onCreate()方法调用如下:
lifecycleScope.launch {
    MyDataStore(this@DataStoreActivity).saveData2()
}
问题:这么写会有什么问题呢?
你可能会说,我试了没啥问题啊?进入Activity后成功写入DataStore。
那退出Activity再进入Activity试一次呢?
兴许你已经遇到Crash了:
提示不能有多个DataStore实例去操作同一个文件。
你可能又有疑问了:第一次进入Activity用的是一个DataStore实例,第二次进入Activity是另一个新的实例,第一个实例已经销毁了呀?为啥还会提示?
因为我们并不能完全确保同一时间只有一个DataStore实例在操作,若是存在不同的实例访问同一个文件,那么将会产生不可预期的脏数据。因此DataStore设计时就严格限制只能有一个实例访问同一个文件。
那么如何避免此种问题呢?很简单,只需要确保我们创建同一个文件只关联一个DataStore实例即可。
class MyDataStore(val context: Context) {
    companion object {
        val Context.dataStore: DataStore<Preferences> by preferencesDataStore(MyDataStore.javaClass.name)
    }
}

通过静态变量确保只有一个实例。


8DataStore 如何获取同步数据?


DataStore的核心优势在于:
使用协程挂起函数存取数据,不阻塞UI,不像SharedPreferences可能会引发ANR。
DataStore只对外暴露了Flow,调用者需要通过Flow存取数据,也就是要求调用者要拥有协程环境。然而我们可能面临的现实环境是:
1. 调用者没有协程环境(针对老的代码)。
2. 调用者需要同步访问DataStore数据。
第1点就不说了,有些老代码是Java代码,无法使用协程/接入协程代价较大。
第2点的场景:基础数据如登录与否存储在DataStore,而其它调用方仅仅只需要1个方法判断是否已经登录。
针对第2点需要同步方法有两种思路:
1. 提供一个同步方法,用于获取外界关注的状态,而内部监听Flow的变化,有变化就同步到状态里, 如此一来,对于协程和Flow的使用控制在内部,外部仅仅只需要获取内存状态即可。
2. 提供一个同步方法,直接获取数据。
我们来看看第二种思路的实现:
val myNameKey = stringPreferencesKey("name")
fun getName():String? {
    return runBlocking {
        context.dataStore.data.map {
            it[myNameKey]
        }.first() as? String
    }
}
可以看出,我们提供的getName()并不是挂起函数,外界调用会一直等到数据的返回。
此处你可能会有担忧:getName()函数阻塞了,如果主线程调用不会耗时吗?
没错,你的担忧是合理的,假若该DataStore是第一次读取,那么getName()将阻塞等待DataStore将文件加载到内存,最后才会返回。
而只要读取了一次数据,那么后续将无需再次进行I/O读取,都是内存操作,无需担忧耗时问题。

对于第一次读取耗时问题,我们可以进行预加载,比如在某个时机提前加载数据。


9DataStore 全流程


本文基于:datastore-preferences:1.0.0


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

Android 14 之返回界面升级:预览目标界面 + 全新返回箭头
我们写的build.gradle是如何跑起来的? -  Gradle探究
Kotlin中return@forEach了个寂寞


点击 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存