一文带你玩转 DataStore

简介: 一文带你玩转 DataStore

缘起 SharedPreferences

说起 SharedPreferences(下面简称 SP),只要是安卓开发都不会陌生的,平时开发都离不开,不过它确实很方便,以键值对的形式存储在本地,使用非常简单:

val sp = getSharedPreferences("Test", Context.MODE_PRIVATE)
sp.edit { putString("jetPack", "text") }
val jetPack = sp.getString("jetpack", "")

只需要上面几行代码,SP 的使用就完成了,但是——简单使用的背后是很多坑,前两天在公众号上看到一片文章:再见 SharedPreferences拥抱 Jetpack DataStore,里面说了很多 SP 的坑,比如:getXXX() 方法可能会导致主线程阻塞、不能保证类型安全、加载的数据会一直留在内存中、apply() 方法是异步的,可能会发生 ANR、不能用于跨进程通信等等。。。具体坑的原因这块就不赘述了,大家可以直接跳转上面的文章进行查看。

有人可能会问,上面文章中都说了怎样使用 DataStore 了你为啥还要再写一篇文章呢?因为。。。我看了这篇文章之后尝试觉得使用起来有点麻烦,而且使用的时候还出现了一些问题,觉得大家可能也会遇到,并且我在百度上搜索之后并没有找到想要的结果,所以才来想写一篇文章,以避免大家重复踩坑。

拥抱 DataStore

为啥要使用呢?

先来看看 Google 官方对 DataStore 的描述吧:

  • 以异步、一致的事务方式存储数据,克服了 SharedPreferences 的一些缺点

这说的,一句话把 SP 都给搞死了,这意思不就是让我们抛弃 SP 来拥抱 DataStore 嘛!Google 都这样说了,那咱们还是来使用吧,官方肯定有自己的道理,再来贴一段上面文章中对 DataStore 优点的描述吧:

  • DataStore 是基于 Flow 实现的,所以保证了在主线程的安全性
  • 以事务方式处理更新数据,事务有四大特性(原子性、一致性、 隔离性、持久性)
  • 没有 apply() 和 commit() 等等数据持久的方法
  • 自动完成 SharedPreferences 迁移到 DataStore,保证数据一致性,不会造成数据损坏
  • 可以监听到操作成功或者失败结果

再来看看 Google 分析的 SharedPreferences 和 DataStore 的区别的一张图吧:

a5db64f913f00edbb7a741c375ba22e8.png

看到这里是不是已经蠢蠢欲动了?那就赶快继续往下看吧!

使用方法

首选项数据存储和原型数据存储

DataStore提供了两种不同的实现:首选项DataStore和Proto DataStore。

  • Proto DataStore将数据存储为自定义数据类型的实例。此实现要求使用协议缓冲区定义架构,但它提供类型安全性。
  • 首选项DataStore使用键存储和访问数据。此实现不需要预定义的架构,并且不提供类型安全性。

添加依赖

上面所说的有两种不同的实现,它们所使用的依赖也各不相同,按照上面的顺序来放下依赖吧!

Proto DataStore 方式:
// Typed DataStore (Typed API surface, such as Proto)
dependencies {
  implementation "androidx.datastore:datastore:1.0.0-alpha05"
}
// Alternativey - use the following artifact without an Android dependency.
dependencies {
  implementation "androidx.datastore:datastore-core:1.0.0-alpha05"
}

键值对方式:

// Preferences DataStore (SharedPreferences like APIs)
dependencies {
  implementation "androidx.datastore:datastore-preferences:1.0.0-alpha05"
}
// Alternativey - use the following artifact without an Android dependency.
dependencies {
  implementation "androidx.datastore:datastore-preferences-core:1.0.0-alpha05"
}

Proto DataStore 方式具体使用

Proto DataStore 实现使用的是 DataStore 和 protocol buffers 将类型化的对象持久保存到磁盘。

本篇文章暂不描述 Proto DataStore 的具体使用了,大家可以去官方文档进行查看,因为使用需要使用 protobuf 语言,这块就先跳过了,因为这块只是之前看过,并没有实际进行使用过,就不在这里班门弄斧了。

下面贴下官方文档描述的地址吧:

https://developer.android.google.cn/topic/libraries/architecture/datastore?hl=zh_cn

键值对方式具体使用

构建 DataStore

val preferenceName = "PlayAndroidDataStore"
var dataStore: DataStore<Preferences> = context.createDataStore(preferenceName)

写入数据

这块需要说一下,DataStore 和 SP 不太一样,只能写入下面几种固定类型:Int , Long , Boolean , Float , String,这里先看下官方的例子吧,具体使用方法我会在下面的内容中写清楚的:

suspend fun incrementCounter() {
  dataStore.edit { settings ->
    val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
    settings[EXAMPLE_COUNTER] = currentCounterValue + 1
  }
}

这块我在看的时候就有点懵逼,写入数据的时候不应该方法传入一个值来写入嘛,后来转念一想,奥,官方的意思是像我上面写的 SP 的使用例子一样,直接改变值来进行写入。

读取数据

val EXAMPLE_COUNTER = preferencesKey<Int>("example_counter")
val exampleCounterFlow: Flow<Int> = dataStore.data
  .map { preferences ->
    // No type safety.
    preferences[EXAMPLE_COUNTER] ?: 0
}

这个就比较好理解了,通过 key 值来获取 value。

是不是挺简单,不就初始化一下,然后需要存的时候存一下,需要取的时候取一下不得了!根本不需要看!用的时候直接用不得了!

清除数据

之前咱们使用 SP 的时候可以直接使用下面的方法进行清除数据:

fun clear(context: Context) {
    val preferences = context.getSharedPreferences("name", Context.MODE_PRIVATE)
    val editor = preferences.edit()
    editor.clear()
    editor.apply()
}

但是现在的 DataStore 该怎样清除数据呢?其实和 SP 也类似,甚至更加简单:

suspend fun clear() {
    dataStore.edit {
        it.clear()
    }
}

迁移 SP 数据到 DataStore

在初始化 DataStore 的时候咱们调用了一个 context 的扩展函数 createDataStore ,在上面使用的时候咱们只传入了 DataStore 的名字,但是点进去看下这个扩展函数:

public fun Context.createDataStore(
    name: String,
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    migrations: List<DataMigration<Preferences>> = listOf(),
    scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
): DataStore<Preferences> =
    PreferenceDataStoreFactory.create(
        corruptionHandler = corruptionHandler,
        migrations = migrations,
        scope = scope
    ) {
        File(this.filesDir, "datastore/$name.preferences_pb")
    }

发现这个方法其实还有好几个参数,只不过都有默认值,来说下上面方法的几个参数的作用吧:

  • name:这个没啥好说的,就是 DataStore 的名字
  • corruptionHandler:如果数据存储在尝试读取数据时遇到 CorruptionException,则调用corruptionHandler。当数据无法反序列化时,序列化程序将引发CorruptionException
  • migrations:这个参数就是用来迁移 SP 的,在下面会给出使用方法
  • scope:这个参数大家就更熟悉了,协程的作用域

看完上面参数大家应该已经知道该怎样迁移了,下面是迁移的代码:

dataStore = context.createDataStore(
    name = preferenceName,
    migrations = listOf(
        SharedPreferencesMigration(
            context,
            "你存储 SP 的 Name"
        )
    )
)

是不是很简单,但是要注意,迁移会在访问数据之前运行。每个 producer 和 migration 可能会多次运行,无论它是否已经成功(可能是因为另一个迁移失败或对磁盘的写入失败)。

踩坑记录

小坑坑

上面已经写出了官方文档中的示例代码,来稍微改动下使用试试吧!

上面已经初始化完成了,这里就直接进行使用吧,先来一个保存 Boolean 的方法吧:

suspend fun saveBooleanData(key: String, value: Boolean) {
    dataStore.edit { mutablePreferences ->
        mutablePreferences[preferencesKey(key)] = value
    }
}

这样稍微封装下咱们在进行调用的时候就要方便的多,最起码省的再来构建一个 preferencesKey 对象啊!程序员嘛,能省事就省事!

再来一个读取 Boolean 的方法:

fun readBooleanFlow(key: String, default: Boolean = false): Flow<Boolean> =
    dataStore.data
        .catch {
             //当读取数据遇到错误时,如果是 `IOException` 异常,发送一个 emptyPreferences 来重新使用
             //但是如果是其他的异常,最好将它抛出去,不要隐藏问题
            if (it is IOException) {
                it.printStackTrace()
                emit(emptyPreferences())
            } else {
                throw it
            }
        }.map {
            it[preferencesKey(key)] ?: default
        }

读取方法也稍微进行封装了下,直接返回一个 Flow,这个读取方法中加入了 catch ,因为 Flow 出现 IO 异常的时候无法通过 try/catch 捕获到,所以需要这样来捕获下异常。

其他的几种 Float、Int、Long、String 和上面的都类似,只是参数类型不同而已,这里由于篇幅问题就先不贴代码了,最后会给出完整代码。

先来看下使用的时候吧,测试方法很简单,两个按钮,一个点击的时候每种类型新增一条数据,一个点击的时候读取每种类型的数据,先来看下新增吧:

GlobalScope.launch {
    dataStore.apply {
        saveBooleanData("BooleanData", true)
        saveFloatData("FloatData", 15f)
        saveIntData("IntData", 12)
        saveLongData("LongData", 56L)
        saveStringData("StringData", "我爱你啊")
    }
}

这里由于保存方法中的 edit 是一个挂起函数,所以需要在协程内部进行使用。

再来看下读取的代码:

GlobalScope.launch {
    Log.e("ZHUJIANG", "哈哈哈")
    dataStore.readBooleanFlow("BooleanData").collect {
        Log.e("ZHUJIANG", "BooleanData: $it" )
    }
    dataStore.readFloatFlow("FloatData").collect {
        Log.e("ZHUJIANG", "FloatData: $it" )
    }
    dataStore.readIntFlow("IntData").collect {
        Log.e("ZHUJIANG", "IntData: $it" )
    }
    dataStore.readLongFlow("LongData").collect {
        Log.e("ZHUJIANG", "LongData: $it" )
    }
    dataStore.readStringFlow("StringData").collect {
        Log.e("ZHUJIANG", "StringData: $it" )
    }
    Log.e("ZHUJIANG", "哈哈哈222")    
}

大家看上面的代码有问题吗?我在最开始使用的时候就是这样写的,我以为就是这样使用的,也许是怪自己对 Flow 不了解,以前使用的时候就是这样。

写到这里的时候我感觉一切顺利,感觉很不错,新的库很简单嘛,情理之中!

接下来运行下吧!下面是运行点击打出来的 Log:

2020-12-04 20:48:55.399 7147-7254/com.zj.play E/ZHUJIANG: 哈哈哈
2020-12-04 20:48:55.403 7147-7254/com.zj.play E/ZHUJIANG: BooleanData: true

啊?为什么啊!上面明明执行了那么多啊。。。这里为啥只打印出了第一条?而且最下面的一条 Log 也没有打出来!

解决小坑坑

觉得自己好像哪里写的不对,但是官方文档就这样说的啊,我之前用 Flow 也是这样用的啊,不行,再去看看文档!

果然找到了答案,官方是这样描述的:

DataStore的主要好处之一是异步API,但是将周围的代码更改为异步可能并不总是可行的。如果您正在使用使用同步磁盘I / O的现有代码库,或者您具有不提供异步API的依赖项,则可能是这种情况。

Kotlin协程提供 runBlocking() 协程生成器,以帮助弥合同步和异步代码之间的鸿沟。您可以用来runBlocking()从DataStore同步读取数据。以下代码阻塞调用线程,直到DataStore返回数据为止

val exampleData = runBlocking { dataStore.data.first() }

真相大白了!原来上面的是异步实现方式,获取到的数据 Flow 也是异步的!如果想时时获取的话可以使用 first() ,那么说来就来,新增一个封装的方法:

fun readBooleanData(key: String, default: Boolean = false): Boolean {
    var value = false
    runBlocking {
        dataStore.data.first {
            value = it[preferencesKey(key)] ?: default
            true
        }
    }
    return value
}

这个方法是基于上面封装好的方法来进行使用的,上面的方法返回一个 Flow 的对象,这里通过 first() 方法来同步获取到 Boolean 值。再照着这个方法改下类型,将剩下几个方法写一下,然后修改下测试代码:

Log.e("ZHUJIANG", "哈哈哈")
val booleanData = dataStore.readBooleanData("BooleanData")
Log.e("ZHUJIANG", "booleanData: $booleanData" )
val floatData = dataStore.readFloatData("FloatData")
Log.e("ZHUJIANG", "floatData: $floatData" )
val intData = dataStore.readIntData("IntData")
Log.e("ZHUJIANG", "intData: $intData" )
val longData = dataStore.readLongData("LongData")
Log.e("ZHUJIANG", "longData: $longData" )
val stringData = dataStore.readStringData("StringData")
Log.e("ZHUJIANG", "stringData: $stringData" )
Log.e("ZHUJIANG", "哈哈哈222")

下面再来看下打印的值:

2020-12-04 21:25:20.124 19620-19711/com.zj.play E/ZHUJIANG: 哈哈哈
2020-12-04 21:25:20.167 19620-19709/com.zj.play E/ZHUJIANG: booleanData: true
2020-12-04 21:25:20.168 19620-19709/com.zj.play E/ZHUJIANG: floatData: 15.0
2020-12-04 21:25:20.168 19620-19709/com.zj.play E/ZHUJIANG: intData: 22
2020-12-04 21:25:20.169 19620-19709/com.zj.play E/ZHUJIANG: longData: 56
2020-12-04 21:25:20.169 19620-19709/com.zj.play E/ZHUJIANG: stringData: 我爱你啊
2020-12-04 21:25:20.169 19620-19709/com.zj.play E/ZHUJIANG: 哈哈哈222

诶!完美!这才是咱们想要的结果嘛!这样 DataStore 的使用就和 SP 的使用类似了!

这块不只是读取数据可以用 runBlocking ,同样的,存储数据也可以这么写:

fun saveSyncBooleanData(key: String, value: Boolean) =
    runBlocking { saveBooleanData(key, value) }

只要加上 runBlocking ,块中的代码都会阻塞调用线程,直到执行结束为止。很明显,如果耗时的操作的话主线程会由于阻塞而造成卡顿的现象,所以耗时操作还是使用异步存储或读取吧。

但还有一个问题,人家 DataStore 本来是支持异步的啊!咱们刚才写的执行出问题的代码其实就是用的 DataStore 返回的 Flow,Flow 本来就是异步的,咱们确实也可以像上面那样进行使用,但上面代码的问题是什么呢?

咱们想的是存储完成之后直接进行读取,但是 Flow 也是可观察的,它会将当前协程给阻塞住,因为它会将改变的值再传回来,这样干说有点不太好理解,还是再来测试下,咱们先来修改下保存的代码,改成每点击一次将值都加一并保存起来:

saveIntData("IntData", add++)

将 add 设置为一个全局变量,然后读取方法只写一个:

dataStore.readIntFlow("IntData").collect {
    Log.e("ZHUJIANG", "IntData: $it")
}

运行之后点击一次写入,再点击一次读取,然后多次进行点击,再来看一下打出来的 Log :

2020-12-04 21:32:50.915 23116-23159/com.zj.play E/ZHUJIANG: 哈哈哈
2020-12-04 21:32:50.918 23116-23159/com.zj.play E/ZHUJIANG: IntData: 24
2020-12-04 21:32:52.911 23116-23433/com.zj.play E/ZHUJIANG: IntData: 25
2020-12-04 21:32:53.184 23116-23495/com.zj.play E/ZHUJIANG: IntData: 26
2020-12-04 21:32:53.447 23116-23158/com.zj.play E/ZHUJIANG: IntData: 27
2020-12-04 21:32:53.773 23116-23432/com.zj.play E/ZHUJIANG: IntData: 28

是不是有点恍然大明白的感觉,就是因为它需要一直在等待数据,所以才一直阻塞着协程!那有什么方法能不让它等待,或者说不让它阻塞嘛?当然有,上面的 first() 方法不就是嘛!first 方法获取的就是 Flow 中第一次的数据,当然 collect 也可以设置只获取一次:

dataStore.readBooleanFlow("StringData").take(1).collect{
    Log.e("ZHUJIANG", "StringData: $it" )
}

查看了下 Flow 的方法,咱们还可以通过下面这个方法来获取第一次的数据:

dataStore.readIntFlow("IntData").first {
    Log.e("ZHUJIANG", "111IntData: $it")
    true
}

注意,这里需要返回一个 boolean 值,这个 boolean 值注意要返回 true ,如果返回 false 的话就和 collect 一样了,这个 first 方法的返回值意思就是如果数据是你想要的话就返回 true ,Flow 就结束,如果没有你想要的数据就返回 false,Flow 就继续接受数据,也就是继续阻塞着当前的协程。

来看下源码吧:

public suspend fun <T> Flow<T>.first(predicate: suspend (T) -> Boolean): T {
    var result: Any? = NULL
    collectWhile {
        if (predicate(it)) {
            result = it
            false
        } else {
            true
        }
    }
    if (result === NULL) throw NoSuchElementException("Expected at least one element matching the predicate $predicate")
    return result as T
}

这个方法很简单,参数是一个函数,返回值是 Boolean,其他没什么,调用了 collectWhile ,那就来看下 collectWhile 的源码吧:

internal suspend inline fun <T> Flow<T>.collectWhile(crossinline predicate: suspend (value: T) -> Boolean) {
    val collector = object : FlowCollector<T> {
        override suspend fun emit(value: T) {
            // Note: we are checking predicate first, then throw. If the predicate does suspend (calls emit, for example)
            // the the resulting code is never tail-suspending and produces a state-machine
            if (!predicate(value)) {
                throw AbortFlowException(this)
            }
        }
    }
    try {
        collect(collector)
    } catch (e: AbortFlowException) {
        e.checkOwnership(collector)
    }
}

这个方法接受一个函数,函数的返回值为 Boolean ,我们发现上面的 first() 方法直接返回了 false,也就在这个方法中的 emit 中会抛一个 Flow 已经终止的异常。所以当接收到一个值之后 Flow 就停止了。

其实 Flow 还有一些别的方法,如果想了解更多的话可以直接看下 Kotlin 的官方文档:

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/index.html

继续优化

在UI线程上执行同步 I / O 操作可能会导致 ANR 或 UI 混乱,咱们可以通过从DataStore异步预加载数据来缓解这些问题:

override fun onCreate(savedInstanceState: Bundle?) {
    lifecycleScope.launch {
        dataStore.data.first()
        // You should also handle IOExceptions here.
    }
}

工具类封装

我将上面使用的 DataStore 的方法封装成了一个工具类,大家如果有需要的话可以直接拿去进行使用。

下面来看看思考下该怎样写,首先这个类应该是个单例,整个项目都需要进行使用,当然如果你想根据业务分开的话也可以建立多个,如果业务简单点的话可以直接设置成单例,在 Kotlin 中设置单例很简单,直接使用关键字 object 就可以了,但是这里不能这样使用,因为 DataStore 的初始化需要 context ,所以需要传入 context,所以单例就变成了下面这个样子:

class DataStoreUtils private constructor(ctx: Context) {
    private var context: Context = ctx
    companion object {
        @Volatile
        private var instance: DataStoreUtils? = null
        fun getInstance(ctx: Context): DataStoreUtils {
            if (instance == null) {
                synchronized(DataStoreUtils::class) {
                    if (instance == null) {
                        instance = DataStoreUtils(ctx)
                    }
                }
            }
            return instance!!
        }
    }
}

然后加上需要的全局变量,并对 DataStore 进行初始化:

private val preferenceName = "PlayAndroidDataStore"
private var dataStore: DataStore<Preferences>
init {
    dataStore = context.createDataStore(preferenceName)
}

接下来再来添加几个方法吧,方便大家平时使用。平时使用的时候有的人不愿意每种类型使用不同的方法,都喜欢只用一个方法,那就来通过泛型加几个方法吧!

先来加下 putData 方法:

suspend fun <U> putData(key: String, value: U) {
    when (value) {
        is Long -> saveLongData(key, value)
        is String -> saveStringData(key, value)
        is Int -> saveIntData(key, value)
        is Boolean -> saveBooleanData(key, value)
        is Float -> saveFloatData(key, value)
        else -> throw IllegalArgumentException("This type can be saved into DataStore")
    }
}

这也是个挂起函数,有了挂起函数再来一个不需要在协程中使用的方法吧:

fun <U> putSyncData(key: String, value: U) {
    when (value) {
        is Long -> saveSyncLongData(key, value)
        is String -> saveSyncStringData(key, value)
        is Int -> saveSyncIntData(key, value)
        is Boolean -> saveSyncBooleanData(key, value)
        is Float -> saveSyncFloatData(key, value)
        else -> throw IllegalArgumentException("This type can be saved into DataStore")
    }
}

putData 就写完了,再来加一下 getData 方法吧:

fun <U> getData(key: String, default: U): Flow<U> {
    return when (default) {
        is Long -> readLongFlow(key, default) as Flow<U>
        is String -> readStringFlow(key, default) as Flow<U>
        is Int -> readIntFlow(key, default) as Flow<U>
        is Boolean -> readBooleanFlow(key, default) as Flow<U>
        is Float -> readFloatFlow(key, default) as Flow<U>
        else -> throw IllegalArgumentException("This type can be saved into DataStore")
    }
}

同样的,再来加一下不在协程中使用的方法:

fun <U> getSyncData(key: String, default: U): U {
    val res = when (default) {
        is Long -> readLongData(key, default)
        is String -> readStringData(key, default)
        is Int -> readIntData(key, default)
        is Boolean -> readBooleanData(key, default)
        is Float -> readFloatData(key, default)
        else -> throw IllegalArgumentException("This type can be saved into DataStore")
    }
    return res as U
}

到这里工具类就封装完成了,不管你是想要同步使用还是异步使用,这个工具类都能满足你的需求。

如果你想更省事一些,我把这个类放到了 Github 中,可以直接拿去进行使用,如果对你有帮助可以点个 Star。

https://github.com/zhujiang521/PlayAndroid/blob/master/core/src/main/java/com/zj/core/util/DataStoreUtils.kt

下面是本文中的测试代码地址:

https://github.com/zhujiang521/PlayAndroid/blob/master/app/src/main/java/com/zj/play/profile/ProfileAdapter.kt

精致的结尾

本以为这篇文章很简单,应该不用多久就能写完,但是愣生生写了好几个小时。有时候就是这样,像我写的前几篇关于 玩安卓 的几篇文章,每次其实想写很多东西,但却不知道怎么下笔,但有时候觉得写不了多少东西的往往能写很多。。。

看到这里的童鞋们应该已经会用 DataStore 了,当然如果想等等 Google 发正式版在用也可以。




目录
相关文章
|
存储 安全 Java
您的小妾DataStore已经驾到,SharePreference离你而去
您的小妾DataStore已经驾到,SharePreference离你而去
287 0
您的小妾DataStore已经驾到,SharePreference离你而去
|
存储 安全 测试技术
DataStore —— SharedPreferences 的替代者 ?
DataStore —— SharedPreferences 的替代者 ?