写在前面
最近在翻Jetpack库,发现了DataStore,官方是这么说的:
Jetpack DataStore 是一种数据存储解决方案,允许您使用协议缓冲区存储键值对或类型化对象。DataStore 使用 Kotlin 协程和 Flow 以异步、一致的事务方式存储数据。 如果您目前是使用
SharedPreferences
存储数据的,请考虑迁移到 DataStore。
显而易见,在需要存储较小或简单的数据集时,DataStore比起SP更加简单且安全性更高,所以学习使用DataStore是很有价值的。
参考文章
最先看的肯定是官方文档:
应用架构:数据层 - DataStore - Android 开发者 | Android Developers (google.cn)
官方有些地方比较跳跃,这时候需要其他优秀文档辅助学习:
Jetpack Proto DataStore - 掘金 (juejin.cn)
一文读懂 SharedPreferences 的缺陷及一点点思考 - 掘金 (juejin.cn)
基础知识
对比
DataStore的存在是为了替代SP,所以为什么可以替代呢?我们看看官方给的图来看看SP相对于DataStore有什么劣势。
-
界面线程上的安全调用
SP的apply()
方法会阻断fsync()
上的界面线程。每次有服务启动或停止以及每次 activity 在应用中的任何地方启动或停止时,系统都会触发待处理的fsync()
调用。 界面线程在apply()
调度的待处理fsync()
调用上会被阻断,这通常会导致 ANR。 -
运行时的异常影响
SharedPreferences 会将解析错误作为运行时异常抛出 -
类型安全
例如以下代码,我们先写入数据,其中设置key所对应的值为int类型,但在后面使用相同key获取数据时却调用getString()方法,这样程序一旦运行就会报错java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
。这段代码在编译阶段完全正常,但SharedPreferences却无法对这种操作进行规避,需要完全依靠开发者本身去遵循规范。kotlinval sp = getSharedPreferences("test", Context.MODE_PRIVATE) val edit = sp.edit() edit.putInt("key", 0); edit.apply() val value = sp.getString("key", "")
注意
在开始DataStore的学习前,我们要记住以下几个规则(引用自官方文档)
- 请勿在同一进程中为给定文件创建多个
DataStore
实例 ,否则会破坏所有 DataStore 功能。如果给定文件在同一进程中有多个有效的 DataStore 实例,DataStore 在读取或更新数据时将抛出IllegalStateException
。 - DataStore 的通用类型必须不可变。更改 DataStore 中使用的类型会导致 DataStore 提供的所有保证都失效,并且可能会造成严重的、难以发现的 bug。强烈建议您使用可保证不可变性、具有简单的 API 且能够高效进行序列化的协议缓冲区。
- 切勿对同一个文件混用
SingleProcessDataStore
和MultiProcessDataStore
。如果您打算从多个进程访问DataStore
,请始终使用MultiProcessDataStore
。
准备
我们通过一个计数器例子,在具体的情景中理解和使用DataStore
xml布局如下:
XML
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="28sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:src="@drawable/ic_baseline_exposure_plus_1_24"
app:backgroundTint="@color/black"
app:background="@color/black"
app:tint="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:contentDescription="add" />
</androidx.constraintlayout.widget.ConstraintLayout>
activity代码如下:
kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val tv = findViewById<TextView>(R.id.tv)
val fab = findViewById<FloatingActionButton>(R.id.fab)
}
}
我们的最终目的是通过不断点击FloatingActionButton使得TextView内的数字不断+1
Preferences DataStore
Preferences DataStore 根据键访问数据。虽然不确保类型安全,但因为无需事先定义架构,Preferences DataStore相对于Proto DataStore更易上手且创建更快。
添加依赖
gradle
implementation("androidx.datastore:datastore-preferences:1.0.0")
创建 Preferences DataStore
我们使用 preferencesDataStore 创建 Preferences DataStore 的实例,通过 preferencesDataStore 委托可确保我们有一个 DataStore 实例在应用中具有该名称。
kotlin
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "count-preferences")
定义键
由于 Preferences DataStore 不使用预定义的架构,我们必须使用相应的键类型函数为需要存储在 DataStore<Preferences>
实例中的每个值定义一个键。 官方提供以下方法用于键值的定义,而键的类型可以通过各方法的命名体现。
- intPreferencesKey()
- doublePreferencesKey()
- stringPreferencesKey()
- booleanPreferencesKey()
- floatPreferencesKey()
- longPreferencesKey()
- stringSetPreferencesKey()
在计数器案例中,TextView展示的是当前计数值,所以我们需要为int值定义一个键,即使用intPreferencesKey()
kotlin
val COUNTER = intPreferencesKey("counter")
写入数据
在计数器的案例中,我们通过 FloatingActionButton 的点击事件来进行DataStore的写入操作,而Preferences DataStore的写入操作通过edit
函数实现。注意edit
函数是一个挂起函数,所以我们需要在协程内运行。
kotlin
fab.setOnClickListener {
MainScope().launch {
dataStore.edit { preferences ->
// 获取当前存储在dataStore内key为COUNTER的键值
val currentCounterValue = preferences[COUNTER] ?: 0
// 将改键值+1
preferences[COUNTER] = currentCounterValue + 1
}
}
}
读取数据
Preferences DataStore 公开 Flow<Preferences>
中存储的数据,每当偏好设置发生变化时,Flow<Preferences>
就会发出该数据。我们使用DataStore.data
属性,其返回值是Flow,所以每当我们点击 FloatingActionButton 修改数据,我们能及时接收改变后的数据并修改TextView状态。
kotlin
MainScope().launch {
dataStore.data
.map {
it[COUNTER] ?: 0
}.collect {
tv.text = it.toString()
}
}
从 SharedPreferences 迁移到 Preferences DataStore
为了演示怎么迁移,我们重头再来,在准备部分的代码基础上临时创建 SharedPreferences 储存数据。
kotlin
val sp = getSharedPreferences("test",Context.MODE_PRIVATE)
val edit = sp.edit()
// 为了验证顺利迁移,我们初始值设置为10
edit.putInt("number",10);
edit.apply()
运行程序后查看数据已经成功保存在本地
现在可以开始将 SharedPreferences 迁移到 Preferences DataStore 了。因为 DataStore 的存在就是为了替代SP,所以谷歌早提供SharedPreferencesMigration
属性用于SP数据迁移。其他代码与前面类似,只需向迁移列表传入 SharedPreferencesMigration
属性,其中构造函数第二个参数 sharedPreferencesName 为所创建SP的文件名称,在本例中即为"test"。
kotlin
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "preferences-test",
// 新增部分
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "test"))
}
)
由于键只能从 SharedPreferences 迁移一次,因此在我们迁移完毕后,需要将刚才临时创建的SP相关代码删除,此时的完整代码如下:
有一处需要注意的,在SP文件创建时,我们的key值设置为"number",迁移后dataStore的key值也会被设置为"number"。所以与前面的例子相比,我们还需要将intPreferencesKey()
函数中的key值更改为"number"。
kotlin
class MainActivity : AppCompatActivity() {
// 创建:preferencesDataStore 委托可确保我们有一个 DataStore 实例在应用中具有该名称
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "preferences-test",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "test"))
}
)
private val COUNTER = intPreferencesKey("number")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val tv = findViewById<TextView>(R.id.tv_test)
val fab = findViewById<FloatingActionButton>(R.id.fab)
MainScope().launch {
dataStore.data
.map {
it[COUNTER] ?: 0
}.collect {
tv.text = it.toString()
}
}
fab.setOnClickListener {
MainScope().launch {
dataStore.edit {
val currentCounterValue = it[COUNTER] ?: 0
it[COUNTER] = currentCounterValue + 1
}
}
}
}
}
运行程序后可以看到界面初始数值为10,说明迁移完毕
Proto DataStore
SharedPreferences 和 Preferences DataStore 的一个缺点是无法定义架构,保证不了存取键时使用了正确的数据类型。而 Proto DataStore 利用协议缓冲区来定义架构来解决此问题,确保了类型安全。 协议缓冲区可持久保留强类型数据。与 XML 和其他类似的数据格式相比,协议缓冲区速度更快、规格更小、使用更简单,并且更清楚明了。虽然使用 Proto DataStore 需要学习新的序列化机制,但因为 Proto DataStore 的强大类型优势,所以非常值得我们学习。
添加依赖
-
添加协议缓冲区插件
gradleplugins { ...... id "com.google.protobuf" version "0.8.17" }
-
添加协议缓冲区和 Proto DataStore 依赖项
gradledependencies { ...... implementation "androidx.datastore:datastore:1.0.0" implementation "com.google.protobuf:protobuf-javalite:3.18.0" }
-
配置协议缓冲区
如果在这步Sync Now报错,先Sync Now前两步再Sync Now对协议缓冲区的配置gradleprotobuf { protoc{ // 设置 protoc 的版本号 artifact = "com.google.protobuf:protoc:3.14.0" } generateProtoTasks { all().each { task -> task.builtins { java { option 'lite' } } } } }
定义架构
在 app/src/main/
目录下创建proto文件夹,我们将在里面创建proto文件,如图所示
创建count.proto文件,文件和相关注释内容如下:
如果想要了解更多关于如何定义 proto 架构,可以参阅这个文档:Language Guide (proto 3) | Protocol Buffers Documentation (protobuf.dev)
其中内部类字段类型与Java的对应关系:
string->String,int32->int,int64->long,bool->Boolean,float->float,double->double
proto
// 声明proto的版本
syntax = "proto3";
option java_package = "com.wg.jetpackDemos.dataStore.proto"; // 指定了生成的Java类的包名
option java_multiple_files = true; // 设置生成的Java类是一个文件还是多个文件
// message 声明的是内部类
message Count {
int32 counter = 1;
}
每当我们创建或者变更proto文件时都需要Rebuild Project,即可生成对应的Java文件
创建 Proto DataStore
-
创建序列化器:
定义一个实现Serializer<T>
的类,其中 T 是 proto 文件中定义的类型。通过实现序列化器告知DataStore如何读取和写入我们在 proto 文件中定义的数据类型,如果磁盘上没有数据,序列化器还会定义默认返回值。kotlinobject CountData : Serializer<Count> { override val defaultValue: Count get() = Count.getDefaultInstance() override suspend fun readFrom(input: InputStream): Count { try { return Count.parseFrom(input) }catch (exception:InvalidProtocolBufferException){ throw CorruptionException("Cannot read proto.", exception) } } override suspend fun writeTo(t: Count, output: OutputStream) = t.writeTo(output) }
-
创建 Proto DataStore 实例
使用dataStore
所创建的属性委托来创建DataStore<T>
实例,其中T
是在 proto 文件中定义的类型。
fileName
参数:告知 DataStore 使用哪个文件存储数据
serializer
参数:告知 DataStore 在第一步中定义的序列化器类的名称kotlinval Context.counterDataStore : DataStore<Count> by dataStore( fileName = "count.pb", serializer = CountData )
写入数据
与Preferences DataStore不同,Proto DataStore使用updatData()
函数用于更新存储的对象。
kotlin
fab.setOnClickListener {
MainScope().launch {
counterDataStore.updateData { count ->
count.toBuilder()
.setCounter(count.counter + 1)
.build()
}
}
}
读取数据
读取数据则与 Preferences DataStore 类似
kotlin
MainScope().launch {
counterDataStore.data.collect { count ->
tv.text = count.counter.toString()
}
}
从 SharedPreferences 迁移到 Proto DataStore
前期准备与上面"从 SharedPreferences 迁移到 Preferences DataStore"部分相同,如果有跳过 Preferences DataStore 部分直接看 Proto DataStore 的朋友需要往回翻看一下。
同迁移到 Preferences DataStore 的思路一样,我们只需在DataStore构造器中向迁移列表传入 SharedPreferencesMigration
属性。这里需要注意的是,SharedPreferencesMigration的包为androidx.datastore.migrations.SharedPreferencesMigration
,我那时候因为导错包找了好久的bug,请以我为戒。
kotlin
val Context.counterDataStore : DataStore<Count> by dataStore(
fileName = "count.pb",
serializer = CountData,
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(context,"test"){
sharedPreferencesView, counter ->
// 获取 SharedPreferences 的数据
val count = sharedPreferencesView.getInt("number",0)
counter.toBuilder().setCounter(count).build()
}
)
}
)
在运行前记得删掉SP相关代码,迁移完毕结果如下: