缘起 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 的区别的一张图吧:
看到这里是不是已经蠢蠢欲动了?那就赶快继续往下看吧!
使用方法
首选项数据存储和原型数据存储
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 的官方文档:
继续优化
在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。
下面是本文中的测试代码地址:
精致的结尾
本以为这篇文章很简单,应该不用多久就能写完,但是愣生生写了好几个小时。有时候就是这样,像我写的前几篇关于 玩安卓 的几篇文章,每次其实想写很多东西,但却不知道怎么下笔,但有时候觉得写不了多少东西的往往能写很多。。。
看到这里的童鞋们应该已经会用 DataStore 了,当然如果想等等 Google 发正式版在用也可以。