Jetpack学习:轻松掌握DataStore

写在前面

最近在翻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有什么劣势。

  1. 界面线程上的安全调用
    SP的apply() 方法会阻断 fsync() 上的界面线程。每次有服务启动或停止以及每次 activity 在应用中的任何地方启动或停止时,系统都会触发待处理的 fsync() 调用。 界面线程在 apply() 调度的待处理 fsync() 调用上会被阻断,这通常会导致 ANR。

  2. 运行时的异常影响
    SharedPreferences 会将解析错误作为运行时异常抛出

  3. 类型安全
    例如以下代码,我们先写入数据,其中设置key所对应的值为int类型,但在后面使用相同key获取数据时却调用getString()方法,这样程序一旦运行就会报错java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String。这段代码在编译阶段完全正常,但SharedPreferences却无法对这种操作进行规避,需要完全依靠开发者本身去遵循规范。

    kotlin 复制代码
    val sp = getSharedPreferences("test", Context.MODE_PRIVATE)
    val edit = sp.edit()
    edit.putInt("key", 0);
    edit.apply()
    val value = sp.getString("key", "")
注意

在开始DataStore的学习前,我们要记住以下几个规则(引用自官方文档)

  1. 请勿在同一进程中为给定文件创建多个 DataStore 实例 ,否则会破坏所有 DataStore 功能。如果给定文件在同一进程中有多个有效的 DataStore 实例,DataStore 在读取或更新数据时将抛出 IllegalStateException
  2. DataStore 的通用类型必须不可变。更改 DataStore 中使用的类型会导致 DataStore 提供的所有保证都失效,并且可能会造成严重的、难以发现的 bug。强烈建议您使用可保证不可变性、具有简单的 API 且能够高效进行序列化的协议缓冲区。
  3. 切勿对同一个文件混用 SingleProcessDataStoreMultiProcessDataStore 。如果您打算从多个进程访问 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 的强大类型优势,所以非常值得我们学习。

添加依赖
  1. 添加协议缓冲区插件

    gradle 复制代码
    plugins {  
        ......
        id "com.google.protobuf" version "0.8.17"  
    }
  2. 添加协议缓冲区和 Proto DataStore 依赖项

    gradle 复制代码
    dependencies {  
        ......
        implementation "androidx.datastore:datastore:1.0.0"
        implementation "com.google.protobuf:protobuf-javalite:3.18.0"  
    }
  3. 配置协议缓冲区
    如果在这步Sync Now报错,先Sync Now前两步再Sync Now对协议缓冲区的配置

    gradle 复制代码
    protobuf {  
        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
  1. 创建序列化器:
    定义一个实现 Serializer<T>的类,其中 T 是 proto 文件中定义的类型。通过实现序列化器告知DataStore如何读取和写入我们在 proto 文件中定义的数据类型,如果磁盘上没有数据,序列化器还会定义默认返回值。

    kotlin 复制代码
    object 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)  
    }
  2. 创建 Proto DataStore 实例
    使用 dataStore 所创建的属性委托来创建 DataStore<T> 实例,其中 T 是在 proto 文件中定义的类型。
    fileName参数:告知 DataStore 使用哪个文件存储数据
    serializer 参数:告知 DataStore 在第一步中定义的序列化器类的名称

    kotlin 复制代码
    val 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相关代码,迁移完毕结果如下:

相关推荐
开发者阿伟2 天前
Android Jetpack DataBinding源码解析与实践
android·android jetpack
alexhilton7 天前
Android技巧:学习使用GridLayout
android·kotlin·android jetpack
Wgllss15 天前
轻松搞定Android蓝牙打印机,双屏异显及副屏分辨率适配解决办法
android·架构·android jetpack
alexhilton21 天前
群星闪耀的大前端开发
android·kotlin·android jetpack
一航jason1 个月前
Android Jetpack Compose 现有Java老项目集成使用compose开发
android·java·android jetpack
帅次1 个月前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
IAM四十二1 个月前
Jetpack Compose State 你用对了吗?
android·android jetpack·composer
Wgllss1 个月前
那些大厂架构师是怎样封装网络请求的?
android·架构·android jetpack
x0242 个月前
Android Room(SQLite) too many SQL variables异常
sqlite·安卓·android jetpack·1024程序员节
alexhilton2 个月前
深入理解观察者模式
android·kotlin·android jetpack