Jetpack DataStore [beta] 体验记录

前言

很久没写东西了,主要是 Compose 更新变化太快,官方文档更新都跟不上代码,写相关的内容马上就会过时。

但是在 Compose Demo 项目中不是只有 UI,数据层面逐步增加了 Room 和 DataStore,DataStore 已经迎来 beta 版本,想提前了解一下的话,现在正是时候。

DataStore 概述

DataStore 的目标是成为 SharedPreference 的上位替代,用于少量 key-value 数据的存储,存储方式是本地文件。这听起来和 SharedPreference 并没有本质区别,事实上也确实如此,因为二者的区别在 API 的封装上。SharedPreference 的实现相当简单,存取值得时候通过类型消除和类型强转保证 xml 的文件内容和 Java 的数据类型匹配,无法保证安全性。在读写数据时主要提供同步的 API,在 UI 线程读取较大内容的时候会有明显卡顿,而异步的 apply 写入又难以确认写入完成的时间。

DataStore 从设计之初就想避免上述问题,也因此变得相对复杂,提高了使用门槛,要不要换恐怕还得从业务角度考虑,现在仅做了解即可。

首先,DataStore 提供了两种不同数据结构的实现,分别是 Preferences 实现和 Proto 实现。其中 Preferences 的实现较为简单,不需要提前定义数据类型,也不能保证类型安全,这方面跟 SharedPreference 一致;Proto 实现则是使用 protobuf 预定义要保存的数据,可以保证类型正确,代价是代码更加复杂。

其次,DataStore 深度绑定 Kotlin 协程,读取数据和写入数据的函数都是 suspend 函数,读数据的返回值直接采用了 Flow,统一了单次获取和持续监听。

使用 DataStore

DataStore 的两种实现对应着不同的依赖,我觉得 Preferences 实现像是一种向易用性妥协的方案,真的想要简单好用不如还是用 SharedPreference,所以要学就一步到位,用 Proto 实现。

Proto 实现的使用方式分为以下几步:

  1. 引入依赖
  2. 编写 .proto 定义数据格式
  3. 编译 .proto(手动调用或者配置 gradle 插件自动生成)
  4. 编写数据类接入代码
  5. 读写数据

比起 SharedPreference 确实多了很多步骤,但流程还算清晰,真正的坑都潜伏在细节中。

一、引入依赖

implementation("androidx.datastore:datastore:1.0.0-beta02") // 截止到20210626的最新版
复制代码

二、protobuf

protobuf 的全称是 Protocal Buffers,详细信息参考官网:developers.google.com/protocol-bu…

protobuf 已经有很多应用场景了,最广为人知的应该是 gRPC。比起 xml 或者 json,protobuf 更节省空间,速度更快。

具体的语法不在本文范围内,按需学习即可。举个例子,我用来保存用户个人配置的一个 .proto 文件内容如下:

syntax = "proto3";

option java_package = "com.github.moqigit.timecount.datastore";
option java_multiple_files = true;

message UserPreferences {
  int64 lastSessionTime = 1;
  bool anti_anxious = 2;
  bool check_update = 3;
  bool cloud_storage = 4;
  string primary_color = 5;
  string light_color = 6;
  string dark_color = 7;
}
复制代码

protobuf 的使用方法是先定义数据结构,再编译成指定语言供开发者使用。(暂不支持 Kotlin,我们用 Java 即可)

按照官方文档安装后,我们可以在终端用 protoc 命令编译 .proto 文件
Usage: protoc [OPTION] PROTO_FILES

三、kts + protobuf 插件

每次改动都敲命令或者执行脚本编译 proto 也是个比较麻烦的事,程序员应该向自动化努力。gradle 构建系统已经有 protobuf 的插件,”简单”添加一下就可以解放双手。

简单的前提,是别用 kts。

因为用 Kotlin 编写 gradle 脚本实际上还是比较冷门的,毕竟 groovy 的 DSL 相当简洁了,完全可以满足日常开发工作的需求。不过用都用了,坑是自选的,只能建议各位现阶段不要太勇,别给自己找麻烦。

过程主要是查文档,看 kts 中的函数和 groovy 的区别,逐步解决构建中的错误,不赘述了,直接看结果:

// app/build.gradle.kts

plugins {
    id("com.android.application")
    id("kotlin-android")
    id("kotlin-kapt")
    id("com.google.protobuf").version("0.8.12")
}

dependencies {
    implementation("androidx.datastore:datastore:1.0.0-beta02")
    implementation("com.google.protobuf:protobuf-javalite:3.10.0")
}

// protobuf 插件的配置函数跟 groovy 写法上有一定的区别,千万别直接复制
protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.10.0"
    }
    generateProtoTasks {
        all().configureEach {
            builtins {
                id("java") { option("lite") }
            }
        }
    }
}
复制代码

.proto 文件放在 app/src/main/proto 文件夹中可以省略配置 android sourceSets,make 成功后可以在 generated java 文件夹下找到生成的代码,大概是这样

image.png

四、接入数据类

文档参考:developer.android.com/topic/libra…

接入数据类是一个增加模板代码的过程,分成两步,最终目的是提供一个 DataStore 对象的获取方式。

  1. 实现 Serializer<T>,定义默认值
  2. 通过委托创建 DataStore<T> 对象供外部调用

代码如下:

object UserPreferenceSerializer : Serializer<UserPreferences> {

	override val defaultValue: UserPreferences
		get() = UserPreferences.getDefaultInstance()

	override suspend fun readFrom(input: InputStream): UserPreferences {
		try {
			return UserPreferences.parseFrom(input)
		} catch (e: Exception) {
			throw CorruptionException("Cannot read proto.", e)
		}
	}

	override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
		t.writeTo(output)
	}
}

val Context.userPrefDataStore : DataStore<UserPreferences> by dataStore(
    fileName = "user_pref.pb", // 保存文件名
    serializer = UserPreferenceSerializer
)
复制代码

Serializer 的代码几乎是固定的,创建 DataStore 的参数也只是多需要一个文件名,如果能通过带参数的注解之类的方式自动生成对应代码就好了,希望正式版能有优化。

fileName 对应的文件保存在 /data/data/com.github.moqigit.timecount/files/datastore 里面,内容不是文本所以不能直接查看,安全性比 SharedPreference 高。protobuf 非常省空间,上面的7个键值对的测试数据只需要 40B 的空间,另一个用 SharedPreference 存的2个键值对的文件就要 188B。

五、读写 API

读写 API 就相对简单了,会协程就没什么问题。

读:

private fun printData() {
    viewModelScope.launch {
        val pref = this@MainActivity.userPrefDataStore.data.firstOrNull() ?: return@launch
        Log.e("asdfg", "${pref.lastSessionTime}")
        Log.e("asdfg", "${pref.toString()}")
    }
}
复制代码

打印数据只需要一次读取,所以取 first 即可,如果需要监听数据变化,则需要改用 collect。

写:

private fun writeTestData() {
    viewModelScope.launch {
        pref.updateData {
            it.toBuilder()
                .setAntiAnxious(true)
                .setCloudStorage(true)
                .setCheckUpdate(true)
                .setDarkColor("#333333")
                .setLightColor("#262626")
                .setLastSessionTime(System.currentTimeMillis())
                .setPrimaryColor("#513f2d")
                .build()
        }
    }
}
复制代码

经过事先定义的数据结构自带数据类型,读写过程就像 data class 一样顺滑,数据类型经由编译器检查也很难出错,这应该是 protobuf 的大优势。

对应的页面还没弄好,只能在 Logcat 看效果了。

首次打开,只有一个默认值有数据
image.png

写入之后,数据更新

image.png

默认值在 Serializer 的 defaultValue 中设置,如果自定义了完善的默认值数据,初次打开就不会一片空白了。

DataStore 的 Hilt 封装

DataStore 的读写依赖 Context,使用时限制较多,解决方案也是老生常谈了,可以全局持有 applicationContext,用顶层函数或者单例提供 DataStore 对象。

但 Demo 项目已经接入 Hilt 了,DataStore 也很适合通过依赖注入创建对象,随手加一下就好了:

@Module
@InstallIn(SingletonComponent::class)
object DSManager {

    private val Context.userPrefDataStore : DataStore<UserPreferences> by dataStore(
        fileName = "user_pref.pb",
        serializer = UserPreferenceSerializer
    )
    
    @Provides
    fun provideUserPrefDS(@ApplicationContext context: Context) = context.userPrefDataStore
}
复制代码

在 Activity 的使用:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var pref: DataStore<UserPreferences>
    
    //……
}
复制代码

在 ViewModel 的使用:

@HiltViewModel
class TimeCountViewModel @Inject constructor(
	private val pref: DataStore<UserPreferences>,
) : ViewModel() {
    // ……
}
复制代码



DataStore 的上手体验就是这样了,初次使用比较复杂,优势需要复杂的业务场景才能体现,个人不是很推荐急着使用,毕竟兼容历史版本是一个巨大的坑,。而且我们也不是一定要在 SharedPreference 和 DataStore 二选一,不想用 Kotlin 协程还可以试一下 MMKV

Hilt 也比较稳定了,接下来准备写 Compose 或者 Hilt,有什么想法欢迎评论指出~

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享