一文理解Jetpack——DataStore

2,611 阅读6分钟

屏幕截图 2024-05-02 102507.png

DataStore 是一个 Jetpack 数据存储框架,为少量、简单的数据存储,提供了一种安全、一致的方式。DataStore 的推出主要目的是为了取代 SharedPreferencesDataStore 提供两种不同的实现:Preferences DataStoreProto DataStore,其中 Preferences DataStore 是以 key-value 形式存储数据的;而 Proto DataStore 是以 Proto 形式存储数据的。

DataStore 的特点

SharedPreferencesDataStore 之间的对比图如下所示:

1_PY9ynFd3rwC05qTQ7pDFlg.png

在异步接口和同步工作上:DataStore本身是基于 Kotlin 协程和 Flow 实现异步数据存储的,因此它不支持做同步工作。相反 SharedPreferences 有 commit 这个接口来同步提交数据。但是它是有风险的,可能会导致 ANR 和卡顿。

在异常处理上:当数据解析错误时,SharedPreferences 会抛出运行时异常,例如 ClassCastException 异常。而 DataStore 则可以依靠 Flow 的异常处理机制,捕获读写数据时出现的任何异常。

在类型安全上:Preferences DataStoreSharedPreferences 都不能提供类型安全保护。而使用 Proto DataStore,则可以为数据预定义结构,从而确保类型安全。

在数据一致性上:SharedPreferences 并不保证原子性。相比之下,DataStore 内部会确保数据是在原子读-修改-写操作中更新的。

在迁移支持上:SharedPreferences 没有内置的迁移机制,这就需要您将旧存储中的值重新映射到新存储中,然后进行清理。所有这些都会增加运行时出现异常的几率,因为你很容易遇到数据类型不匹配的问题。然而,DataStore 提供了一种将数据轻松迁移到其中的方法,同时还提供了 SharedPreferences 到 DataStore 迁移的实现方法。

DataStore的使用

DataStore 提供两种不同的实现:Preferences DataStoreProto DataStore。下面分别介绍它们的使用。

Preferences DataStore的使用

屏幕截图 2024-05-15 080111.png

第一步添加依赖,代码如下:

implementation("androidx.datastore:datastore-preferences:1.1.1")

第二步,获取 Preferences DataStore 的对象。Google 是推荐使用 preferencesDataStore 属性委托的方式来获取 Preferences DataStore。代码示例如下,如果不了解委托可以看我之前写的文章一文理解 Kotlin 的委托

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

其委托内部最后是通过 PreferenceDataStoreFactory.create 来创建 Preferences DataStore 的对象。从下面的源码中,我们能看到 preferencesDataStore 属性委托内部保证了线程安全。

public fun preferencesDataStore(
    ...
): ReadOnlyProperty<Context, DataStore<Preferences>> {
    return PreferenceDataStoreSingletonDelegate(name, corruptionHandler, produceMigrations, scope)
}


internal class PreferenceDataStoreSingletonDelegate internal constructor(
    ...
) : ReadOnlyProperty<Context, DataStore<Preferences>> {

   ...

    @GuardedBy("lock")
    @Volatile
    private var INSTANCE: DataStore<Preferences>? = null

    //双重检查的方式确保线程安全地获取一个对象,但注意这里不是单例
    //因为 INSTANCE 不是静态变量
    override fun getValue(thisRef: Context, property: KProperty<*>): DataStore<Preferences> {
        ...
    }
}

但是,需要注意的一点是,preferencesDataStore 属性委托不能保证单例对象。如果在同一进程中为给定文件创建多个 DataStore 实例,会破坏所有 DataStore 功能。如果给定文件在同一进程中有多个有效的 DataStore 实例,DataStore 在读取或更新数据时将抛出 IllegalStateException异常。错误代码示例如下:

//会创建三个不同的 DataStore 对象
val dataStore: DataStore<Preferences> by preferencesDataStore("settings")  
val dataStore1: DataStore<Preferences> by preferencesDataStore("settings")  
val dataStore2: DataStore<Preferences> by preferencesDataStore("settings")

Preferences DataStore 中,我们需要为存储在其中的每个值定义一个键(Key)。 Google 提供以下方法用于键值的定义:

  • intPreferencesKey()
  • doublePreferencesKey()
  • stringPreferencesKey()
  • booleanPreferencesKey()
  • floatPreferencesKey()
  • longPreferencesKey()
  • stringSetPreferencesKey()

代码示例如下:

//通过 intPreferencesKey 方法创建key
val EXAMPLE_COUNTER = intPreferencesKey("example_counter")

//通过 key 读取内容
val exampleCounterFlow: Flow<Int> = context.dataStore.data
  .map { preferences -> // 数据转化
    preferences[EXAMPLE_COUNTER] ?: 0
}

//通过 key 写入数据
context.dataStore.edit { settings ->
    val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
    settings[EXAMPLE_COUNTER] = currentCounterValue + 1
}

从上面的代码可以看到,获取数据时,由于 DataStore 返回的是 Flow 类型的值,我们就可以使用 Flow 的异常处理方式,代码示例如下:

context.dataStore.data
    .catch { exception -> //异常处理
       
    }.map { preferences -> // 数据转化
        ...
    }.collect { // 获取数据
        ...
    }

写入数据时,我们使用 edit 方法。该方法其实是 DataStore 的扩展方法,它内部实现是调用的 updateData 方法。updateData 方法在原子读取-修改-写入操作中事务性地更新数据,这些操作都是串行的,如果某个操作失败的话,事务会终止并抛出异常。

写入完成后,数据最终会存储在 pb 文件中,该文件在 /data/user/0/你的应用包名/files/datastore 目录下,文件名则是设置的名字 + .preferences_pb ,比如上面示例的存储文件名为 settings.preferences_pb 

Proto DataStore的使用

屏幕截图 2024-05-17 074925.png

相对于 Preferences DataStoreProto DataStore 是使用 ProtoBuf 来存储数据。至于什么是 ProtoBuf,你可以理解成更小、更快的Json,更详细的介绍可以看协议缓冲区文档

首先我们添加 Preferences DataStore 的依赖

dependencies {
  implementation("androidx.datastore:datastore:1.1.1")
}

Proto DataStore 的使用其实和 Preferences DataStore 类似。不同得是我们需要自己存储对象的结构,以及自定义序列化读写。代码如下所示:首先先定义类对象的结构,

syntax = "proto3";

option java_package = "com.example.application";
option java_multiple_files = true;

message Settings {
  int32 example_counter = 1;
}

再使用pb生成对应的类文件,如何生成对应的java类文件可以看"一篇就够"系列:Android 中使用 Protobuf。生成完类之后,我们就可以自定义序列化类 SettingsSerializer,在其中使用 pb 的方式来执行读写操作。代码如下所示:

object SettingsSerializer : Serializer<Settings> {
  override val defaultValue: Settings = Settings.getDefaultInstance()

  override suspend fun readFrom(input: InputStream): Settings {
        try {
          //parseFrom 是pb生成的java类中的方法,用于生成对应的类对象
          //如果你不想使用pb,可以在这里使用 Gson 
          //这时就变成了 json 来存储数据了
          return Settings.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
          throw CorruptionException("Cannot read proto.", exception)
        }
  }

  override suspend fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output)
}

val Context.settingsDataStore: DataStore<Settings> by dataStore(
  fileName = "settings.pb",
  serializer = SettingsSerializer //我们自定义的序列化类
)

最后我们就可以使用 settingsDataStore 来执行读取和写入的操作了。代码示例如下:

//读取数据
val exampleCounterFlow: Flow<Int> = context.settingsDataStore.data
  .map { settings ->
    settings.exampleCounter
  }

//写入数据
suspend fun incrementCounter() {
  context.settingsDataStore.updateData { currentSettings ->
    currentSettings.toBuilder()
      .setExampleCounter(currentSettings.exampleCounter + 1)
      .build()
    }
}

在多进程中使用

屏幕截图 2024-05-19 222642.png

如果你想在多进程中使用 DataStore,你可以使用 MultiProcessDataStoreFactory.create 来获取对应的 DataStore 的对象。在多进程操作中,DataStore 可保证:

  • 读取仅返回已持久存储到磁盘的数据。
  • 写后读一致性。
  • 写入会序列化。
  • 写入绝不会阻塞读取。

代码示例如下:

val dataStore: DataStore<Settings> = MultiProcessDataStoreFactory.create(
   serializer = SettingsSerializer(),
   produceFile = {
       File("${context.cacheDir.path}/myapp.preferences_pb")
   }
)

如果你看 DataStore 的源码,无论 MultiProcessDataStoreFactory.create 还是 dataStore,以及preferencesDataStore 其功能都是通过 DataStoreImpl 来实现的。就像我们常常使用的 Context,其大部分功能都是 ContextImpl 实现的。这样看,DataStore 是不是瞬间熟悉多了。

回到正题,DataStore 在单进程和多进程的处理其实是分别交给了 SingleProcessCoordinatorMultiProcessCoordinator。有兴趣的话,可以自己去看看源码,这里就不多介绍了。

DataStore、MMKV、SharedPreferences的对比

关于的对比,可以看【面试黑洞】Android 的键值对存储有没有最优解这篇文章,讲得很详细了。我这里就总结一下文章的内容,如下面表格所示:

DataStoreMMKVSharedPreferences
是否支持多进程
是否可能丢失数据
存储速度存储速度均衡小数据量很快,大数据量慢小数据量快,大数据量慢
适用场景非高频写入的场景高频写入场景不使用协程的项目

参考