用力抱一下 Jetpack DataStore

4,295 阅读12分钟

缘起 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 的区别的一张图吧:

SharedPreferences 和 DataStore 区别

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

使用方法

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

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 语言,这块就先跳过了,因为这块只是之前看过,并没有实际进行使用过,就不在这里班门弄斧了。

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

developer.android.google.cn/topic/libra…

键值对方式具体使用

构建 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 的官方文档:

kotlin.github.io/kotlinx.cor…

继续优化

在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。

github.com/zhujiang521…

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

github.com/zhujiang521…

精致的结尾

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

看到这里的童鞋们应该已经会用 DataStore 了,如果公司项目不是特别忙的可以考虑迁移下,当然如果想等等 Google 发正式版在用也可以。

如果对大家有帮助的话别忘了点个赞和关注啊!如果文中哪块出问题了可以在评论区告诉我,感激不尽!

好了,先这样吧,回见了各位!!!