全新的可持续化数据方式:DataStore

自从Android诞生以来,持久化数据存储就有好多种手段,其中Preference被常常提起。一般来说,Preference是用于存储App的用户设置,并且Android还有PreferenceActivity、PreferenceFragment帮忙构建一个比较美观的设置页面。但是由于苹果的UI设计方案过于深入人心,许多软件为了在Android上模仿苹果的设计一般都不使用原生和之后推出的Material Design设计语言。

不过这一节并不是来说如何设计设置页面,而是来说一下它的后台。原版的Preference早就已经停用,现在的Preference、PreferenceFragmentCompat都是使用的支持库的东西(现在已经转移到了androidx.preference),而且已经停止了更新(版本号为1.1.1,最近更新时间为2020年4月15日)说明Google自己都认为这个API过于古老了。而且Google已经不再推荐使用PreferenceActivity,建议改用PreferenceFragmentCompat,在原本的Activity中使用替换布局的方式替换。而且一堆带有Compat的类,搞的这个API过于臃肿。既然Google已经决定弃用,那么是不是应该出一个新的?

于是,Jetpack DataStore就出现了。本节的剩余部分就是来介绍这个全新的API。

注:1.0.0-alpha07已经推出,虽然只有一个改动但是会导致书写习惯的改变,不再需要单例类,这里仍然保留本文书写时的初始版本(1.0.0-alpha06)后续可能还会改变API,请注意!!即DataStore现在处在Alpha阶段,非常不稳定,API会随时出现重大改动,慎重更新!本节也会稍微写一下可能会影响到改动你自己的程序代码的改动介绍。

写在前面

由于DataStore是一个全新的API,现在能获取到的信息很少,中文的资料就更少了。在我写这一部分的时候,中文相关文章不超过3篇,而且用的时候你会发现和Preference差的太远了,直接将Preference替换成DataStore是跑不出来的,因此这里给一些真正能够让你跑通的资料,好好研究一下。

  • 官方的Codelab:永远的神,任何你不懂的东西都要去这里看看,尤其是在刚刚出现新的API的时候,官方教你写的代码大多数都是可以跑的,否则就不能作为教程出现。链接在这里,请自取,需要科学上网。
  • Kotlin Flow:原本的DataStore(1.0.0-alpha06)实现都是使用的Kotlin Flow和协程(Coroutine),这里需要你对这俩有一定认识。这部分资料还是比较多的,本文会涉及到一些但是不会说的非常详细(因为我自己还在研究这个Flow)
  • RxJava:如果你不会使用Flow也没关系,在1.0.0-alpha06的时候加入了两个全新的库:androidx.datastore:datastore-rxjava2:1.0.0-alpha06androidx.datastore:datastore-rxjava3:1.0.0-alpha06,在使用这两个库的时候需要你对RxJava较熟悉。本文主要写有关Flow的,不会涉及到RxJava的内容,需要你自己来尝试尝试。

什么是DataStore?

DataStore是一种全新的持久化数据存储API,原本是基于Kotlin协程和Kotlin Flow开发的,用到了许多Kotlin的语言特性。DataStore分为Preferences DataStore和Protobuf DataStore,这里主要介绍前者,后者我还需要自己慢慢探索。简单来说前者比较容易存储一些基础类型,但是比较复杂的存储就只能使用后者,后者存储较多较复杂数据的时候性能会高一些。值得一提的是,Protobuf也是Google搞出来的东西。

DataStore到底有啥优势?

用一个表格来说明问题吧:

特色 SharedPreferences Preferences
DataStore
Proto
DataStore
异步操作
(仅在通过listener读改动过的数据)

(通过Flow)

(通过Flow)
同步操作
(在UI线程中操作会有安全风险)
x x
UI线程使用的
安全性
x
(会转到Dispatchers.IO)

(会转到Dispatchers.IO)
可以发出错误信号
Can signal error
x
在运行异常中的安全性 x
具有高度一致性保证的事务性API
Has a transactional API with strong consistency guarantees
x
数据迁移 x
(从SharedPreferences)

(从SharedPreferences)
类型安全性 x x √(Protocol Buffers)

该表格摘自Codelab。

如何使用Preferences DataStore?

Preferences DataStore用法很简单:创建、在协程体里操作和编辑或查看。它只支持Int、Double、String、Boolean、Float、Long、StringSet。

所有的教程就会这样告诉你:

// 创建DataStore
val dataStore = context.createDataStore(Constants.DATASTORE_NAME)
// 查看键值
dataStore.data.map {
    i = it[key]
}
// 编辑键值
dataStore.edit {
    it[key] = value
}

乍一看,好像没啥不对的。但是,mapedit都是挂起函数(suspend function),这也就意味着这些函数只能出现在协程体里。但是,当你直接在协程体里运行这个代码(例如ViewModel中的viewModelScope.launch)仍然会有问题:虽然会在/data/data/<package>/files/datastore下能找到对应的文件,但是却无法存储键值。这是一个坑,我就是这么坑进去的>_<

于是,我看完Codelab示例程序之后我才知道应该怎么才能正确使用DataStore。

Kotlin Flow

简单来说一下Flow这个东西。在DataStore中Flow是主角,相比于挂起函数可以返回多个结果,而且是异步地返回。可以看一下官方在Flow API文档中的描述和示例代码。用挂起函数实现异步传数据:

suspend fun simple(): List<Int> {
    delay(1000) // pretend we are doing something asynchronous here
    return listOf(1, 2, 3)
}

fun main() = runBlocking<Unit> {
    simple().forEach { value -> println(value) } 
}

这样的写的话,suspend function会返回一个数组,主进程不会被阻塞,程序执行后1s就会立刻执行循环。而Flow的话就可以实现每次只输出一个列表中的元素,将Flow的泛型设置为Int:

fun simple(): Flow<Int> = flow { // flow builder
    for (i in 1..3) {
        delay(100) // pretend we are doing something useful here
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> {
    // Launch a concurrent coroutine to check if the main thread is blocked
    launch {
        for (k in 1..3) {
            println("I'm not blocked $k")
            delay(5000)
        }
    }
    // Collect the flow
    simple().collect { value -> println(value) } 
}

这样也可以不阻塞主进程,输出则是launch中的println和collect中的println交替执行。

Flow的函数中,所有不带suspend修饰的函数都是中间函数(例如map,filter,take,zip),最后都要转化为带有suspend修饰的函数的末端函数(例如collect,collectLatest,single,reduce)。

键值对

Preferences DataStore使用的是键值对(Key-value),使用的Key是一个类:Preferences.Key。在1.0.0-alpha05及以前的版本如果想要定义键,定义的方式是preferencesKey<Type>(name),在1.0.0-alpha06中,这个API改掉了,估计是怕用户输入这个泛型可能会越界,因此他们将这个定义方式改成了intPreferencesKey()doublePreferencesKey()等等。

当要检索值的时候,可以使用dataStore.dataget()函数进行检索,就比较类似于SharedPreference的getString()getFloat()之类的;当需要编辑值的时候,调用dataStore.edit()函数直接编辑,这个也和SharedPreference类似。

定义DataStore(1.0.0-alpha07)

它又改了!!我的文章有一部分就没法用啦…

之前之所以需要创造操作符单例类,原因在于通过Context.createDataStore(String)方法创建的DataStore并不是单例的,也就是说在使用过程中可能会创建多个DataStore,这不符合逻辑。现在这段API改掉,那么就不需要单例类了:

// Preferences DataStore
fun Context.dataStore = preferencesDataStore("example")
// Protobuf DataStore
fun Context.dataStore = dataStore("example", serializer)

上面的代码官方的说明是说:“要在顶级Kotlin文件中”,我自己的话是将所有跟这个类似的扩展函数都放到一个文件中,然后在自定义的Application类中定义DataStore。

现在只要用到DataStore,直接在协程体里使用即可。

viewModelScope.launch {
    // edit
    dataStore.edit {
        it[key] = value
    }
    // ...
}

创建操作符单例类(1.0.0-alpha06及以前)

问题来了,怎么使用呢?我自己试过直接在协程体里用是无法生效的,因此我去看了一下Codelab,它是用了一个特殊的单例类,在单例类里面执行创建DataStore的操作。由于创建操作需要在context中创建,因此在创建单例类的中需要传入一个参数,这样的话object关键字就用不了了(Constructors are not allowed for objects)我采用懒汉式定义方法:

class DataStoreOperator private constructor(val context: Context) {
    private val dataStore: DataStore<Preferences> =
        context.createDataStore(Constants.DATASTORE_NAME)

    suspend fun setSomeValue(value: Int) = setValue(key, value)

    private suspend inline fun <reified T> setValue(key: Preferences.Key<T>, value: T) {
        dataStore.edit {
            it[key] = value
        }
    }

    companion object {
        private var operator: DataStoreOperator? = null
        fun instance(context: Context) =
            if (operator == null) {
                DataStoreOperator(context).also { operator = it }
            } else operator!!

        fun instance() = if (operator == null) {
            throw NullPointerException()
        } else operator!!
    }
}

这段代码中我还多了一个函数:setValue(),为后面创建编辑每个键值的函数做准备(之后所有的编辑操作都可以使用简单的setSomeValue())。

单例类的初始化是调用instant()函数。一般情况下,第二个instant()可能会用不到,因为你可以只在自定义的Application类中初始化,这样的话你只要有Context就可以获取到Application对象。

读取和修改键值

上一个小节已经解决了如何修改键值,那么这个小节来解决如何读取。DataStore其实可以一次性读取多个键值,读取的时候需要使用到dataStore.data这个Flow<Preferences>对象。在上面介绍Flow的时候我已经说过用末端函数来返回结果,因此你可以将你想一次性输出的所有的键值封装到一个类中:

data class Settings {
    val i: Int,
    val e: Enumber
}

enum class Enumber {
    FIRST, SECOND
}

这里举例特意举了一个枚举类的例子,虽然Preferences DataStore只支持那些类型,但是枚举类比较特殊,是可以转化成String类型的。那么读取或修改的代码如下:

// 键值的定义
val keyI = intPreferencesKey("int")
val keyE = namePreferencesKey("enum")
// 读取函数
val settings = dataStore.data
    .catch {
        if (it is IOException) {
            emit(emptyPreferences())
        } else throw it
    }
    .map {
        val i = it[keyI] ?: -1 // 如果找不到可以使用?:操作符设置默认输出值,与SharedPreference中的defValue参数类似
        val e = Enumber.valueOf(it[keyE] ?: Enumber.FIRST.name)
        Settings(i, e)
    }
// 修改函数
suspend fun setI(i: Int) = setValue(keyI, I)
suspend fun setE(e: Enumber) = setValue(keyE, e.name)

这些函数都应该写到上一小节中的DSO类(DataStoreOperator)里。

使用

使用的话就很简单了,之前就说过直接在协程体里使用即可。ViewModel中自动提供一个viewModelScopeActivity中有lifecycleScope,调用其中的launch()函数即可使用:

class MainViewModel(app: MyApp): AndroidViewModel(app) {
    val dso = getApplication<MyApp>().dataStoreOperator
    init {
        viewModelScope.launch {
            val settings = dso.settings
            dso.setI(1)
            ...
        }
    }
}

顺带提一句,如果你要使用ViewModel建议使用AndroidViewModel,这个要在构造对象的时候输入一个Application对象,使用getApplication<MyApp>()函数在函数体里调用你自己的Application,方便你使用里面的东西,例如Room数据库、DataStore、Preference、Resources等。

版本更迭

最后来总结一下DataStore的版本更迭吧:

时间 版本号 更新
2021.06.30 1.0.0-rc01 Bugs fix
2021.06.16 1.0.0-beta02 Bugs fix
2021.04.21 1.0.0-beta01 Bugs fix
2021.03.10 1.0.0-alpha08 依赖于context的迁移到DataStore property delegate
2021.02.24 1.0.0-alpha07 全新的创建DataStore的方式(同时createDataStore被移除)
2021.01.13 1.0.0-alpha06 添加RxJava封装容器,移除preferencesKey<T>(name: String): Key<T>方法,并替换为每种受支持类型专用的方法,隐藏CorruptionHandler接口
2020.12.02 1.0.0-alpha05 允许(但不强求)关闭传递给Serializer.writeTo()OutputStream
2020.11.17 1.0.0-alpha04 修复重大bug
2020.11.11 以前版本 支持双精度拥有重大Bug:导致Preference Datastore崩溃并显示java.lang.NoClassDefFoundError