如何利用 Kotlin 特性封装 DataStore

前言

Jetpack DataStore 是一种数据存储解决方案,由于使用了 Kotlin 协程或者 RxJava 以异步、一致的事务方式存储数据,用法相较于其它存储方案 (SharedPreferences、MMKV) 会更加特别,所以目前网上都没有什么比较好的 DataStore 封装。

个人了解了用法后觉得使用起来挺麻烦的,会和很多人一样,觉得无脑用 MMKV 就完事了,个人也对 MMKV 做了非常好用的封装,感觉没必要用 DataStore。直到我看到了扔物线的文章,才明白不能无脑用 MMKV,但是 DataStore 用起来有点繁琐,还是有必要封装一下的。

在做了很多摸索和尝试后,终于封装出了一套个人非常满意的用法,希望能帮助到大家 ~ 文章会比较长,建议耐心看完。

基础用法

DataStore 提供两种不同的实现:Preferences DataStore 和 Proto DataStore。Preferences DataStore 是使用键值的方式进行存储,而 Proto DataStore 是将数据作为自定义数据类型的实例进行存储,简单来说就是存取什么样的数据都由一个 Protopuf 文件决定,所以 Proto DataStore 能确保类型是安全的,但是学习成本会高很多,因为要学习多一门新的语言。

Preferences DataStore 的键值用法相对来说会更加符合多数人的使用习惯,所以个人选择使用 Preferences DataStore,并且经过个人封装后的用法其实也是能确保类型是安全的。

下面介绍一下 Preferences DataStore 的用法。

Kotlin 用法

添加 Preferences DataStore 的依赖:

dependencies {
    implementation "androidx.datastore:datastore-preferences:1.0.0"
}

创建 DataStore

使用属性委托来创建 Datastore<Preferences> 实例,这行代码要写在 Kotlin 文件的顶层,这样可以更轻松地将 Datastore<Preferences> 对象保留为单例。比如:

// At the top level of your kotlin file:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

前面说了 DataStore 用法相较于其它存储方案会更加特别,从创建的代码就体现出来了。这里声明的是一个 Context 的扩展属性,用法不是很常规。你可以理解为给 Context 类额外声明了一个名为 dataStore 的属性,而且这个属性是个单例。

读取内容

读取数据时要使用相应的键类型函数为需要存储在 DataStore<Preferences> 实例中的每个值定义一个键。例如,如需为 int 值定义一个键,请使用 intPreferencesKey()属性,然后通过 Flow 提供适当的存储值。比如:

val EXAMPLE_COUNTER = intPreferencesKey("example_counter")
val exampleCounterFlow: Flow<Int> = context.dataStore.data
  .map { preferences ->
    // No type safety.
    preferences[EXAMPLE_COUNTER] ?: 0
  }

写入内容

修改数据需要使用 edit() 函数,在代码块中用前面定义的 key 对象去更新值。

suspend fun incrementCounter() {
  context.dataStore.edit { settings ->
    val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
    settings[EXAMPLE_COUNTER] = currentCounterValue + 1
  }
}

由于 Preferences.Key<T> 对象不仅有键名的信息,还有返回值类型的信息,只要存取用了同一个 key 对象,就能保证存取的类型是一致的。这种设计相较于其它键值存储方案 (SharedPreferences、MMKV) 会更好一点。

Java 用法

需要添加额外的 RxJava 依赖,有 RxJava2 和 RxJava3 可选。

dependencies {
    // optional - RxJava2 support
    implementation "androidx.datastore:datastore-preferences-rxjava2:1.0.0"

    // optional - RxJava3 support
    implementation "androidx.datastore:datastore-preferences-rxjava3:1.0.0"
}

创建 RxDataStore

创建一个 RxDatastore<Preferences> 实例。

RxDataStore<Preferences> dataStore =
  new RxPreferenceDataStoreBuilder(context, "settings").build();

注意这里只是创建了对象,我们还要自己将其实现为单例,不能每次想存取数据的时候都创建一个新的 RxDatastore<Preferences> 实例。前面的 Kotlin 用法是用了属性委托的语法特性实现了单例。

读取内容

读取数据时同样要创建一个 Preferences.Key<Integer> 对象,然后调用 dataStore.data().map(...) 函数,用法和 Kotlin 的类似,但是返回一个 RxJava 的 Flowable 对象,这样我们就能在 Java 代码中使用了。

Preferences.Key<Integer> EXAMPLE_COUNTER = PreferencesKeys.int("example_counter");

Flowable<Integer> exampleCounterFlow =
  dataStore.data().map(prefs -> prefs.get(EXAMPLE_COUNTER));

写入内容

修改数据需要使用 dataStore.updateDataAsync() 函数。

Single<Preferences> updateResult = dataStore.updateDataAsync(prefsIn -> {
  MutablePreferences mutablePreferences = prefsIn.toMutablePreferences();
  Integer currentInt = prefsIn.get(EXAMPLE_COUNTER);
  mutablePreferences.set(EXAMPLE_COUNTER, currentInt != null ? currentInt + 1 : 1);
  return Single.just(mutablePreferences);
});
// The update is completed once updateResult is completed.

返回的 Single 对象不需要订阅,需要在更新完成后做什么事才进行订阅。

小结

可以看到 Preferences DataStore 用起来比 SharedPreferences、MMKV 麻烦很多,所以有必要封装一下简化用法。

封装思路

Proto DataStore 如何保证类型安全

本来不打算讲 Proto DataStore 的,但是了解官方的设计思想能更好的帮助我们去封装 DataStore。

Proto DataStore 用法会更加地不常规,如果没用过 Protobuf,估计连官方文档都看不懂。而很多文章都是直接摘抄官方文档,导致个人早期学习的时候都没搞懂到底是怎么来用。所以个人会尽量详细点把整体的工作机制和用法讲清楚。

首先要用 Protopuf 语言写一个文件,比如 settings.pb,并放到 app/src/main/proto/ 文件夹中。

syntax = "proto3";

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

message Settings {
  int32 example_counter = 1;
}

Protobuf 语言和 Java 很像,即使我们没学过也能读懂上面的代码,这里定义了一个 Settings 类,有个名为 example_counter 的 int 变量。

之后我们能通过该文件去创建一个对应的 DataStore 单例。

val Context.settingsDataStore: DataStore<Settings> by dataStore(
  fileName = "settings.pb",
  serializer = SettingsSerializer
)

重点来了,这里的 DataStore 泛型是 Settings,这是哪来的呢?难道是 settings.pb 文件声明的? Kotlin 和 Java 不可能会跨语言访问到其它语言的类呀。这就是令人最疑惑的地方,其实 settings.pb 文件会编译生成一个对应 Java 类,所以我们得重新编译一下项目,这样就能得到一个 Settings 类了。

知道会编译生成 Java 类的关键信息后,Proto DataStore 的工作机制就能好理解了。存储的数据肯定还是会写到一个文件中,那么就需要将文件数据序列化成一个 Java 对象,那要怎么序列化呢?上面创建 DataStore 还有个参数是 SettingsSerializer,这个类还需要我们自己写。

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

  override suspend fun readFrom(input: InputStream): Settings {
    try {
      return Settings.parseFrom(input)
    } catch (exception: InvalidProtocolBufferException) {
      throw CorruptionException("Cannot read proto.", exception)
    }
  }

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

这段序列化代码会让人很懵是怎么写出来的,其实不用管,照抄就行了,这是一套模板代码。简单说一下,这里的序列化需要做到三件事:取默认值、从文件流中得到 Java 对象、把 Java 对象写到文件中。这三个功能具体怎么实现不用我们写,编译生成的 Java 类会提供 getDefaultInstance()parseFrom()writeTo() 函数给我们调用。

到这里我们终于把 Proto DataStore 的整个工作机制讲清楚了,终于能讲下为什么能保证类型安全了,来看下读写的用法。

读取内容:

val exampleCounterFlow: Flow<Int> = context.settingsDataStore.data
  .map { settings ->
    // The exampleCounter property is generated from the proto schema.
    settings.exampleCounter
  }

写入内容:

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

和 Preferences DataStore 最大的不同是,在代码块中得到的是一个 Settings 类型的对象,类型是由 DataStore 的泛型决定的。该对象就限定了我们能存取什么类型的数据,保证读写的类型是一致的。

可能有人会说如果还有另一个 Protopuf 文件也声明了个 example_counter 变量,但类型是 String,那存取的时候不就可能出现类型错误了?其实并不会,因为新的 Protopuf 文件是会创建另一个 DataStore 对象来使用的,即使有同名对象,也同名不同源,不会相互影响的。

目前只有 Proto DataStore 要求把数据类型定义出来,以此确保类型安全。而 Preferences DataStore、MMKV、SharedPreferences 都没这样的要求,都存在着类型安全隐患。那么有没什么办法能让键值存储方案也能确保类型安全?其实也有,可以使用 Koltin 属性委托封装一下。

MMKV 的属性委托方案

先讲一下个人的另一个库 MMKV-KTX 的封装思路,这个库甚至被 ChatGPT 推荐了。

在这里插入图片描述

来看下新版本的用法,需要让一个类继承 MMKVOwner 类并传入 mmapID 参数,然后在该类里能使用 by mmkvXXXX() 函数将属性委托给 MMKV

object Settings : MMKVOwner(mmapID = "settings") {
  var exampleCounter by mmkvInt(default = 1)
}

设置或获取属性的值会调用对应的 encode()decode() 函数,用属性名作为 key 值。比如:

val counter = Settings.exampleCounter

Settings.exampleCounter = 100

我们这么来使用的话同样可以保证类型安全,因为这和 Proto DataStore 保证类型安全的思路是类似的。我们用 Kotlin 属性委托写的 Settings 类就包含了前面 settings.pb 文件所声明的信息,定义了一个 Settings 类,有个名为 exampleCounter 的 Int 变量。该类限制了能存取什么类型的数据,能确保类型安全。

这里使用了 Kotlin 属性委托进行封装,简单讲下属性委托,其实是一种委托 (代理) 模式的运用。一般我们是把一个接口代理给一个具体的实现类,而属性委托是把赋值和取值操作代理给委托类。该委托类需要有固定模板的 getValue() 和 setValue() 函数,并且能拿到属性名,那就能用属性名作为 MMKV 存取的键名。比如:

val kv = MMKV.defaultMMKV()

class MMKVIntProperty(private val default: Int = 0) : ReadWriteProperty<Any, Int> {

  override fun getValue(thisRef: Any, property: KProperty<*>): Int =
    kv.decodeInt(property.name, default)

  override fun setValue(thisRef: Any, property: KProperty<*>, value: Int) {
    kv.encode(property.name, value)
  }
}

这样就能把一个属性通过 by 关键字代理给我们写的委托类,赋值会调用 setValue() 函数,取值会调用 getValue() 函数。比如:

var counter: Int by MMKVIntProperty()

虽然使用了 Kotlin 属性委托进行封装,但是属性委托并不是精髓。让 MMKV 和属性委托相结合很多人都想得到,但是 MMKVOwner 的设计思路大多数人想不到,这个类才是个人库的精髓所在。

MMKVOwner 顾名思义就是 MMKV 对象的拥有者,代码非常少,但是作用非常大。

open class MMKVOwner(override val mmapID: String) : IMMKVOwner {
  override val kv: MMKV by lazy { MMKV.mmkvWithID(mmapID) }
}

// 该接口用于兼容不能多继承的场景
interface IMMKVOwner {
  val mmapID: String
  val kv: MMKV
}

个人限制了必须要继承了该类才能使用 MMKV 的属性委托,这样来设计有两个关键的作用:

第一个关键作用是能引导用户把属性委托都集中写到一个类中,这样写才能保证在该类里面是类型安全的。如果没有 MMKVOwner 的限制可以随意委托,那么可能会有人想存就存、想读就读,写出下面的代码:

class InputWifiActivity : AppCompatActivity() {
  private var psd by mmkvString()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_input_wifi)
    //...
    btnConfirm.setOnClickListener {
      psd = etPassword.text.toString()
      //...
    }
  }
}

class QRCodeActivity : AppCompatActivity() {
  private val pwd by mmkvString()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_wifi)
    //...
    createQRCode(ssid, pwd)
  }
}

这里分别写了两个属性委托,存的是 psd,但是别人读数据敲成了 pwd,这样存取的键名就不一致了,甚至可能出现类型不一致的情况。正确的用法是都把 MMKV 的属性委托写到一个类中,使用同一个属性委托进行存取就一定不会出错。而且同一个类中不能声明同名不同类型的对象,就更能保证类型是安全的。

MMKVOwner 类的存在会让分开写属性委托的成本变高,引导大家更规范地集中在 Repository 或 Model 等数据类中使用 MMKV 属性委托。并且对于一些不太懂的同事,想随便在别的类里写 by mmkvXXXX() 是不行的,只能模仿已有的代码写到数据类中。

第二个关键作用是硬性要求使用 MMKV.mmkvWithID(mmapID) 进行分区存储,这是保证类型安全的第二重保障。

前面所说把委托集中写到一个类中,其实只能保证在该类里面是类型安全的,而我们实际开发可能存在多个数据存储类。比如在组件化项目,我们不知道别人会存取怎么样的数据,就可能会不同的组件定义了同名的属性委托。比如:

// 在视频组件
object VideoRepository {
  var counter by mmkvInt()
}

// 在消息组件
object MessageRepository {
  var counter by mmkvInt()
}

如果用的都是 MMKV.defaultMMKV(),就出现了数据相互覆盖的情况,甚至可能会类型不一致,存在类型安全隐患。我们回头想一下 Proto DataStore 也可能有多个 protopuf 文件存在同名变量, 但是每个 protopuf 文件都创建了一个对应的 DataStore 对象,这样数据才互不干扰,保证了类型安全。那我们也给每个 Kotlin 存储类都创建一个对应的 MMKV 对象不就解决了。

MMKVOwner 有个 mmapID 构造参数,强制要求了用 MMKV.mmkvWithID(mmapID) 创建 MMKV,使其支持分区存储,这样就 100% 保证类型安全了。

// 在视频组件
object VideoRepository : MMKVOwner(mmapID = "video") {
  var counter by mmkvInt()
}

// 在消息组件
object MessageRepository : MMKVOwner(mmapID = "message") {
  var counter by mmkvInt()
}

只用属性委托封装 MMKV 是不够好的,因为属性委托更多的作用只是免去声明大量的键名常量,而 MMKVOwner 的存在能让大家更规范地去写类型安全的委托代码。

那是不是把这套封装方案的底层实现换成 DataStore 就可以了?其实没那么容易,DataStore 的用法相对于 MMKV 特殊很多。个人做过了很多摸索和尝试后,才把类似的用法封装出来了。下面给大家分享一下个人是怎么封装的。

DataStore 的属性委托方案

根据前面 Owner + 属性委托的封装思路,我们设计出以下的用法:

object Settings : DataStoreOwner(name = "settings") {
  var exampleCounter by intPreference(default = 1)
}

这个 Kotlin 文件就对应着前面示例的 Protobuf 文件,这样我们就用了 Preferences DataStore 结合 Kotlin 特性来达到 Proto DataStore 确保类型安全的效果。下面就开始来实现。

定义 DataStoreOwner

首先要定义一个 DataStoreOwner 类,该类能获取一个 DataStore 对象,构造函数需要传入个 name 参数去创建 DataStore 对象。

但是创建 DataStore 对象是需要 Context 的,我们还需要定义一个 application 静态变量用于初始化,这样就能得到一个 Context 对象。另外可以再抽取一个 IDataStoreOwner 接口,得到以下代码。

open class DataStoreOwner(name: String) : IDataStoreOwner {
  private val Context.dataStore by preferencesDataStore(name)
  override val dataStore get() = context.dataStore
}

interface IDataStoreOwner {
  val context: Context get() = application
  val dataStore: DataStore<Preferences>

  companion object {
    internal lateinit var application: Application
  }
}

为什么要抽取一个接口?如果本身就有个父类,就没法再继承 DataStoreOwner 类了。抽取一个 IDataStoreOwner 接口就是为了能用 Kotlin 委托的特性去解决多继承的问题,用法如下:

object SettingsRepository : BaseRepository(), IDataStoreOwner by DataStoreOwner("settings") {
  // ...
}

可以用 App Startup 自动初始化 application 静态变量。

<application>
  <provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <meta-data
      android:name="com.dylanc.datastore.DataStoreInitializer"
      android:value="androidx.startup" />
  </provider>
</application>

class DataStoreInitializer : Initializer<Unit> {

  override fun create(context: Context) {
    IDataStoreOwner.application = context as Application
  }

  override fun dependencies() = emptyList<Class<Initializer<*>>>()
}

如何实现属性委托

这是该方案实现起来的一大难点,因为 DataStore 一定要用 Kotlin 协程或者 RxJava 异步存取数据。而属性委托的 get、set 函数只能是同步的,那该怎么办?用 runBlocking {...} 可以把协程的异步执行改成同步执行,那么属性的委托类就有办法写出来了。比如:

class IntPreferenceProperty(private val default: Int?) : ReadWriteProperty<IDataStoreOwner, Int?> {
  override fun getValue(thisRef: IDataStoreOwner, property: KProperty<*>): Int? =
    runBlocking {
      val key = intPreferencesKey(property.name)
      thisRef.dataStore.data.map { it[key] }.firstOrNull() ?: default
    }

  override fun setValue(thisRef: IDataStoreOwner, property: KProperty<*>, value: Int?) {
    runBlocking {
      thisRef.dataStore.edit { preferences ->
        val key = intPreferencesKey(property.name)
        if (value == null) {
          preferences.remove(key)
        } else {
          preferences[key] = value
        }
      }
    }
  }
}

给接口增加个委托函数简化委托用法。

interface IDataStoreOwner {
  // ... 
  
  fun intPreference(default: Int? = null) = IntPreferenceProperty(default)
}

这样把我们前面所想的用法给实现出来了。

object Settings : DataStoreOwner(name = "settings") {
  var exampleCounter by intPreference(default = 1)
}

其实目前的封装只是把 MMKV 方案的底层实现换成了 DataStore,获取属性值或者给属性赋值会调用 DataStore 读取或者保存数据。但是调用方式和 MMKV 方案不一样,因为 DataStore 只支持异步的用法,改成同步调用是会阻塞线程的。所以我们用的时候需要另起个线程,比如:

thread {
  val counter = Settings.exampleCounter
  handler.post {
    tvCounter.text = counter.toString()
  }
}

虽然这么也能用,但是老是要自己去切线程很麻烦,还完全摒弃了 Kotlin 协程的用法,用起来非常不方便。但是属性的 get、set 只能是同步调用,好像很难把协程用法给保留。

个人后面又做了尝试和摸索,终于找到了个解决方案,就是不调用属性本身的 get、set 函数,而是调用我们自己另外实现的用了 suspend 修饰的 get、set 函数。

那要怎么做呢?首先还是得用到属性委托,用属性委托才能拿到属性名,用属性名作为 key 能省去声明大量的键名常量,并且能保证在类里的键名不会重复。

但是我们不能像前面声明一个读写 Int 类型的属性委托类,而是声明一个只读的属性委托类,返回的类型是我们自定义的, 具有 suspend 修饰的 get、set 函数。我们先定义一个 DataStorePreference 类:

class DataStorePreference<V>(
  private val dataStore: DataStore<Preferences>,
  val key: Preferences.Key<V>,
  val default: V?
) {
  suspend fun set(value: V?): Preferences =
    dataStore.edit { preferences ->
      if (value == null) {
        preferences.remove(key)
      } else {
        preferences[key] = value
      }
    }
    
  suspend fun get(): V? = asFlow().first()

  fun asFlow(): Flow<V?> =
    dataStore.data.map { it[key] ?: default }
}

这样我们就能实现一个获取 DataStorePreference 属性的只读委托类,注意这里要做个缓存,不然每次获取属性的时候都会创建个新对象。

class PreferenceProperty<V>(
  private val key: (String) -> Preferences.Key<V>,
  private val default: V? = null,
) : ReadOnlyProperty<IDataStoreOwner, DataStorePreference<V>> {
  private var cache: DataStorePreference<V>? = null

  override fun getValue(thisRef: IDataStoreOwner, property: KProperty<*>): DataStorePreference<V> =
    cache ?: DataStorePreference(thisRef.dataStore, key(property.name), default).also { cache = it }
}

我们再封装一下委托函数:

interface IDataStoreOwner {
  fun intPreference(default: Int? = null) = 
    PreferenceProperty(::intPreferencesKey, default)

  fun doublePreference(default: Double? = null) = 
    PreferenceProperty(::doublePreferencesKey, default)

  fun longPreference(default: Long? = null) = 
    PreferenceProperty(::longPreferencesKey, default)

  fun floatPreference(default: Float? = null) =
    PreferenceProperty(::floatPreferencesKey, default)

  fun booleanPreference(default: Boolean? = null) =
    PreferenceProperty(::booleanPreferencesKey, default)

  fun stringPreference(default: String? = null) =
    PreferenceProperty(::stringPreferencesKey, default)

  fun stringSetPreference(default: Set<String>? = null) =
    PreferenceProperty(::stringSetPreferencesKey, default)
}

这样我们就能用属性委托了,和我们最开始设计的用法一有点点不同是,属性用 val 而不是 var,因为委托类是只读的。

object Settings : DataStoreOwner(name = "settings") {
  val exampleCounter by intPreference(default = 1)
}

这就在能协程里使用该属性提供的 get、set 函数,比我们刚开始用同步方式封装的属性委托好用多了。

lifecycleScope.launch {
  tvCounter.text = Settings.exampleCounter.get().toString()
}

如何支持 RxJava

虽然现在 Kotlin 用得越来越多,但是在 Java 代码使用的场景还是要考虑的,比如可能接手了个老项目,要基于已有的 RxJava 代码进行开发。

那要如何兼顾呢?个人想到了一个用法,就是把 DataStoreOwner 改成 RxDataStoreOwner,原有的属性委托就能增加返回 RxJava 观察者对象的函数,这样就能在 Java 环境下调用了。

在封装的时候发现了一个很大的问题,就是 DataStore 和 RxDataStore 不同源。比如我们用同一个 name 来创建 DataStore 和 RxDataStore 单例:

// At the top level of your kotlin file:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val Context.rxDataStore: RxDataStore<Preferences> by rxPreferencesDataStore(name = "settings")

这两个单例对象即使用了同一个 key 对象去存取数据,也都是存在两份数据互不干扰。那要怎么让 DataStore 和 RxDataStore 存取的数据都是来自同一个文件?这个虽然在官方文档上没有写,但是理论上应该是可以做到的,那就只能在源码上找答案了。

个人在翻源码的时候找到了一段关键代码:

在这里插入图片描述

RxDataStore 类有个 create() 静态函数,参数中有个 DataStore 对象,那么我们通过这个函数去创建的 RxDataStore 对象应该会和传入的 DataStore 对象是同源的。这个猜想在 RxPreferenceDataStoreBuilder 的源码得到了验证。

在这里插入图片描述

可以看到 RxPreferenceDataStoreBuilder 的 build() 函数最终也是用 RxDataStore.create() 创建对象的,但是在函数里另外创建了个 DataStore 对象。这样就说清楚为什么前面用同一个 name 创建的 DataStore 和 RxDataStore 不同源了,因为用了两个不同的 DataStore 对象。

我们封装一个扩展函数,用已有的 DataStore 对象去创建 RxDataStore 单例。

fun DataStore<Preferences>.toRxDataStore(scheduler: Scheduler = Schedulers.io()) = lazy {
  RxDataStore.create(this, CoroutineScope(scheduler.asCoroutineDispatcher() + Job()))
}

val rxDataStore: RxDataStore<Preferences> by dataStore.toRxDataStore()

这样就能让 DataStore 和 RxDataStore 对象存取同一份数据了。

有了 RxDataStore 对象就能去实现属性委托的自定义类型了,写个 RxDataStorePreference 类继承 DataStorePreference 并增加 getAsync()setAsync()asFlowable() 函数,这几个函数都是通过 RxDataStore 对象来实现。

class RxDataStorePreference<V>(
  dataStore: DataStore<Preferences>,
  key: Preferences.Key<V>,
  override val default: V,
  private val rxDataStore: RxDataStore<Preferences>
) : DataStorePreference<V>(dataStore, key, default) {

  fun asFlowable(): Flowable<V> =
    rxDataStore.data().map { it[key] ?: default }

  fun getAsync(): Single<V> = asFlowable().first(default)

  fun setAsync(value: V?): Single<Preferences> =
    rxDataStore.updateDataAsync {
      val preferences = it.toMutablePreferences()
      if (value == null) {
        preferences.remove(key)
      } else {
        preferences[key] = value
      }
      Single.just(preferences)
    }
}

这里有个细节,我们重写了 default 属性,把原本的可空类型修改为非空类型。这么做是因为 RxJava 的 Flowable 发出了 null 数据就会执行 onError(),后续不会再回调 onNext()。为了保证订阅关系不被中断,我们需要给个非空的默认值。

剩下就是实现属性委托了,思路也是类似的,写个 RxDataStoreOwner 类继承 DataStoreOwner 类,重写委托函数,将委托的类型改为 RxDataStorePreference,篇幅关系就不带着大家写代码了。

这样封装之后,只需在原有的用法上把 DataStoreOwner 改成 RxDataStoreOwner 就支持了 RxJava。

object Settings : RxDataStoreOwner(name = "settings") {
  val exampleCounter by intPreference(default = 1)
}

最终方案

个人基于以上的思路封装好了 DataStoreKTX 开源库方便大家使用,大家觉得不错的话希望点个 star 支持一下~

Features

  • 无需创建 DataStore、RxDataStore、Preferences.Key 对象;
  • 支持 Kotlin 协程和 RxJava 用法;
  • 用属性名作为键名,无需声明大量的键名常量;
  • 可以确保类型安全,避免类型或者键名不一致导致的异常;

基础用法

在根目录的 build.gradle 添加:

allprojects {
    repositories {
        //...
        maven { url 'https://www.jitpack.io' }
    }
}

在模块的 build.gradle 添加依赖:

dependencies {
    implementation 'com.github.DylanCaiCoding.DataStoreKTX:datastore-ktx:1.0.0'
}

让一个类继承 DataStoreOwner 类,即可在该类使用 by xxxxPreference() 函数将属性委托给 DataStore,比如:

object SettingsRepository : DataStoreOwner(name = "settings") {
  val counter by intPreference()
  val language by stringPreference(default = "zh")
}

如果已经有了父类没法继承,那就实现 IDataStoreOwner by DataStoreOwner(name),比如:

object SettingsRepository : BaseRepository(), IDataStoreOwner by DataStoreOwner(name = "settings") {
  // ...
}

要确保使用过的 name 不重复,只有这样才能 100% 确保类型安全!!!

支持使用以下类型的委托函数,会用属性名作为存取的 key 值:

  • intPreference()
  • longPreference()
  • booleanPreference()
  • floatPreference()
  • doublePreference()
  • stringPreference()
  • stringSetPreference()

调用该属性的 get() 函数会执行 dataStore.data.map {...} 的读取数据,比如:

// 需要在协程中调用
val language = SettingsRepository.language.get()
// val language = SettingsRepository.language.getOrDefault()

调用该属性的 set() 函数会执行 dataStore.edit {...} 的保存数据,比如:

// 需要在协程中调用
SettingsRepository.counter.set(100)
SettingsRepository.counter.set { (this ?: 0) + 1 }

也可以作为 FlowLiveData 使用,这样每当数据发生变化都会有通知回调,可以更新 UI 或流式编程。比如:

SettingsRepository.counter.asLiveData()
  .observe(this) {
    tvCount.text = (it ?: 0).toString()
  }

SettingsRepository.counter.asFlow()
  .map { ... }

适配 RxJava

默认只支持协程用法,可以做一些简单地适配扩展出 RxJava 用法。首先要在 build.gradle 添加 datastore-rxjava2datastore-rxjava3 依赖。

dependencies {
    // 可选
    implementation 'com.github.DylanCaiCoding.DataStoreKTX:datastore-rxjava2:1.0.0'
    implementation 'com.github.DylanCaiCoding.DataStoreKTX:datastore-rxjava3:1.0.0'
}

然后把 DataStoreOwner 类改为 RxDataStoreOwner 类,这样就适配好了。建议给属性添加 @JvmStatic 注解,可以让调用该属性的 Java 代码会更加简洁。

object SettingsRepository : RxDataStoreOwner(name = "settings") {
  @JvmStatic
  val counter by intPreference()
}

调用该属性新增的 getAsync() 函数会执行 rxDataStore.updateDataAsync(prefsIn -> ...) 的读取数据,返回值是 Single<T>,比如:

SettingsRepository.getCounter().getAsync()
    .subscribe(counter -> {
      // ...
    });

调用该属性新增的 setAsync() 函数会执行 rxDataStore.data().map(prefs -> ...) 的读取数据,比如:

SettingsRepository.getCounter().setAsync(100);
    SettingsRepository.getCounter().setAsync((counter, prefsIn) -> counter + 1);

也可以将作为 Flowable 使用,这样每当数据发生变化都会有通知回调,可以更新 UI 或流式编程。比如:

SettingsRepository.getCounter().asFlowable()
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(counter -> tvCounter.setText(String.valueOf(counter)));

协程用法和 RxJava 用法可以混用,只要是同一个属性,存取函数的都是操作同一个数据源。

关于 MMKV-KTX

最近看了下个人的 MMKV-KTX 库已经有 600 左右月下载量了,那至少有几千人在使用,感觉还是有必要讲下最新 1.2.16 版本的注意事项。

最新版把 MMKVOwner 接口改成了类,有个 mmapID 构造参数,这样使用起来才更加规范,能确保类型安全,建议都升级一下~

升级后通常给 MMKVOwner 加个括号和 mmapID 就行了。但是如果本来就有个父类,没法再继承 MMKVOwner 类,那就改成实现 IMMKVOwner by MMKVOwner(mmapID),比如:

object SettingsRepository : BaseRepository(), IMMKVOwner by MMKVOwner(mmapID = "settings") {
  // ...
}

另外新增了 mmkvXXXX().asLiveData() 用法,将属性委托给 LiveData,存储数据时可以直接连 UI 一起更新了,有需要的可以使用一下。例如:

object SettingRepository : MMKVOwner(mmapID = "settings") {
  val isNightMode by mmkvBool().asLiveData()
}

SettingRepository.isNightMode.observe(this) {
  checkBox.isChecked = it
}

SettingRepository.isNightMode.value = true

总结

本文介绍 DataStore 的协程用法和 RxJava 用法,尽量讲清楚了 Proto DataStore 的工作机制和用法,以及 Proto DataStore 为什么能保证类型安全。然后讲了 MMKV 的属性委托方案,用 Owner + 属性委托的方式实现的 Kotlin 类与 Protopuf 文件有着相似的作用,同样能保证类型安全。

所以个人基于这个方案对 Preferences DataStore 封装,虽然遇到有不少问题,但是一一攻克了。最终封装出了一个非常好用的库 DataStoreKTX,觉得有帮助的话希望能点个 star 支持一下 ~

另外如果选择使用 MMKV,建议用一下个人的另一个库 MMKV-KTX,同样简洁好用,并且能保证类型安全。

如果你看到了这里,觉得文章写得不错就给个赞呗?
更多Android进阶指南 可以扫码 解锁更多Android进阶资料

在这里插入图片描述
敲代码不易,关注一下吧。ღ( ´・ᴗ・` )

  • 22
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
DataStore是一个新的异步API,用于在Kotlin中存储和读取数据。它使用Kotlin协程和Flow来实现异步操作,并在单独的线程上运行,从而保证了线程安全性。DataStore提供了结构化的错误处理、类型安全以及存储自定义复杂或大型数据类对象的支持。 要使用DataStore,首先需要获取DataStore对象。可以通过使用Kotlin委托来实现,具体如下所示: ```kotlin private val settingsDataStore by preferencesDataStore(name = "app_settings") ``` 在上述代码中,`settingsDataStore`是一个DataStore对象,它使用了`preferencesDataStore`委托来获取。`name`参数指定了DataStore的名称,可以是任何字符串,例如"app_settings"或包名称等。 一旦获取了DataStore对象,就可以使用它来读取和写入数据。以下是一些示例代码: ```kotlin // 写入数据 settingsDataStore.edit { settings -> settings[KEY_NAME] = "John" settings[KEY_AGE] = 25 } // 读取数据 val nameFlow: Flow<String?> = settingsDataStore.data.map { settings -> settings[KEY_NAME] } // 监听数据变化 settingsDataStore.data .map { settings -> settings[KEY_AGE] } .distinctUntilChanged() .onEach { age -> // 处理数据变化 } .launchIn(lifecycleScope) ``` 上述代码中,`edit`函数用于写入数据,`data`属性用于读取数据。可以使用`map`和`distinctUntilChanged`等函数对数据进行转换和过滤。`launchIn`函数用于在协程作用域中启动数据监听。 总结一下,DataStore是一个用于存储和读取数据的异步API,它提供了线程安全、结构化的错误处理、类型安全以及存储自定义复杂或大型数据类对象的支持。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值