如何利用 Kotlin 特性封装 DataStore

news2024/9/22 21:21:56

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

最新 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,同样简洁好用,并且能保证类型安全。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/488949.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

(十)Shapefile文件创建——创建Shapefile和dBASE

&#xff08;十&#xff09; Shapefile文件创建——创建Shapefile和dBASE ArcCatalog 可以创建新的 Shapefile 和 dBASE表&#xff0c;并可进行属性项及索引的操作定义 Shapefile 的坐标系统。当在目录中改变 Shapefile 的结构和特性 (Properties)时必须使用 ArcMap 来更新或重…

动态规划 --- 01背包

动态规划 — 01背包 一直到现在都非常害怕动态规划&#xff0c;因为基本上自己都无法想出dp递推式&#xff0c;太难受了 T.T 今天再一次遇到了需要写01背包的情况&#xff0c;根据自己学习的一点点经历&#xff0c;再稍微总结一下01背包吧&#xff0c;虽然是个被认为dp入门的…

自学Python必须知道的优秀社区

国内学习Python网站&#xff1a; 知乎学习平台&#xff1a;Python - 基础入门 - 知学堂黑马程序员视频库&#xff1a;大数据学习路线2023版-黑马程序员大数据学习路线图菜鸟教程&#xff1a;菜鸟教程 - 学的不仅是技术&#xff0c;更是梦想&#xff01;极客学院&#xff1a;极…

香港服务器租用攻略:如何优化用户体验?

服务器是网站、应用程序和其他在线内容的核心&#xff0c;对于在线业务来说是至关重要的。如今&#xff0c;随着互联网的普及和数字化转型&#xff0c;越来越多的企业选择在香港租用服务器&#xff0c;以满足其业务需求。但是&#xff0c;租用服务器并不仅仅是选择一个服务商并…

让chatGPT给我写一个CSS,我太蠢了

前言 CSS这东西&#xff0c;让AI写的确有点难度&#xff0c;毕竟它写出来的东西&#xff0c;没办法直接预览&#xff0c;这是其次。重要的是CSS这东西怎么描述&#xff0c;不好描述啊&#xff0c;比如我让他给我制作一个这样的效果出来&#xff0c;没办法描述&#xff0c;所以…

AcWIng1085. 不要62(数位DP)

文章目录 一、问题二、分析三、代码 一、问题 二、分析 这道题涉及的算法是数位DP。如果大家不懂数位DP的话&#xff0c;可以先去看作者之前的文章&#xff1a;第五十章 动态规划——数位DP模型 假设一个数 n n n&#xff0c;我们先求出从 1 1 1到 n n n当中&#xff0c;所有…

《花雕学AI》如何用ChatGPT提升工作效率:适合不同场合的实用技巧大全

实用技巧分类目录 一、最佳ChatGPT 4提示 二、最佳写作和内容创作ChatGPT提示 三、最佳趣味性ChatGPT提示 四、最佳网络开发的ChatGPT提示 五、最佳音乐主题ChatGPT提示 六、最佳职业主题ChatGPT提示 七、最佳用于教育的ChatGPT提示 八、最佳用于市场营销的ChatGPT提示 九、最…

MEET开发者 | 选择和努力一样重要,专访杭州三汇测试工程师齐雪莲

「MEET开发者」栏目的第二期嘉宾是来自杭州三汇的测试工程师——齐雪莲。她是从小在新疆长大的甘肃人&#xff0c;10岁的时候回到了甘肃&#xff0c;大学又考回了新疆&#xff0c;在塔里木大学就读计算机科学与技术专业。 毕业后齐雪莲入职了三汇新疆办事处任测试一职&#xff…

电脑没有网络连接怎么办 电脑无法连接网络怎么解决

这个问题至少困扰我一周 目录 电脑没有网络连接怎么办? 方法一 方法二 方法三 方法四 方法五 方法六 电脑没有网络连接怎么办? 其中也包括了改IP。。电脑就是不好使 #include <iostream> using namespace std; int main(){system("netsh interface ip s…

日志收集系统:将应用产生的数据通过flume收集后发送到Kafka,整理后保存至hbase

目录 前言&#xff1a;功能描述 第一步&#xff1a;flume拉取日志数据&#xff0c;并将源数据保存至Kafka flume配置文件&#xff1a; users&#xff1a; user_friends_raw&#xff1a; events&#xff1a; train&#xff1a; 第二步&#xff1a;Kafka源数据处理 方式一…

滚珠螺杆在设备上的应用

滚珠螺杆跟直线导轨一样&#xff0c;是很多机械设备上不可或缺的重要部件&#xff0c;它是确保机器能够具备高加工精度的前提条件&#xff0c;因此本身对于精度的要求也相当地高。今天&#xff0c;我们就来了解一下滚珠螺杆在不同设备上的应用吧&#xff01; 1、大型的加工中心…

磁盘U盘变本地磁盘寻回教程

磁盘损坏怎么恢复&#xff1f;磁盘是我们工作、学习和生活中常用的信息存储工具&#xff0c;因为容量大、价格便宜而深受人们的喜爱&#xff0c;因此磁盘也成为了我们一些重要信息的信息载具。磁盘U盘变本地磁盘寻回教程这时我们该如何恢复我们丢失的数据呢&#xff1f;这个时候…

ubuntu 安装 notepad++,显示中文菜单,并解决中文乱码问题

1.安装notepad sudo snap install notepad-plus-plus sudo snap install wine-platform-runtime2. notepad中文乱码问题 安装完成之后&#xff0c;输入中文会显示“口口…”&#xff0c;实际上并不是缺少什么windows字库&#xff0c;而是刚安装好的notepad默认字体是Courier …

4月VR大数据:PICO平台应用近400款,领跑国内VR生态

Hello大家好&#xff0c;每月一期的VR内容/硬件大数据统计又和大家见面了。 想了解VR软硬件行情么&#xff1f;关注这里就对了。我们会统计Steam平台的用户及内容等数据&#xff0c;每月初准时为你推送&#xff0c;不要错过喔&#xff01; 本数据报告包含&#xff1a;Steam VR硬…

软件测试面试题最牛汇总,不会有人没有这份文档吧

常见的面试题汇总 1、你做了几年的测试、自动化测试&#xff0c;说一下 selenium 的原理是什么&#xff1f; 我做了五年的测试&#xff0c;1年的自动化测试&#xff1b; selenium 它是用 http 协议来连接 webdriver &#xff0c;客户端可以使用 Java 或者 Python 各种编程语言…

一个.Net版本的ChatGPT SDK

ChatGPT大火&#xff0c;用它来写代码、写表白书、写文章、写对联、写报告、写周边… 啥都会&#xff01; 个人、小公司没有能力开发大模型&#xff0c;但基于开放平台&#xff0c;根据特定的场景开发应用&#xff0c;却是非常火热的。 为了避免重复造轮子&#xff0c;今天给…

你真的会跟 ChatGPT 聊天吗?(上)

前言&#xff1a;即使你对文中提及的技术不大了解&#xff0c;你也可以毫无压力地看完这篇描述如何更好地获得 ChatGPT 生成内容的文章。因为我也是利用 Azure OpenAI 等认知服务来学习&#xff0c;然后就这样写出来的。所以&#xff0c;舒服地坐下来&#xff0c;慢慢看吧&…

网络计算模式复习(三)

云计算和网格技术的差别 相对于网格计算&#xff0c;在表现形式上&#xff0c;云计算拥有明显的特点&#xff1a; 低成本&#xff0c;这是最突出的特点虚拟机的支持&#xff0c;得在网络环境下的一些原来比较难做的事情现在比较容易处理镜像部署的执行&#xff0c;这样就能够…

【微服务 | 学成在线】项目易错重难点分析(媒资管理模块篇·下)

文章目录 视频处理视频编码和文件格式文件格式和视频编码方式区别ProcessBuilder分布式任务调度XXL-JOBXXL-JOB配置XXL-JOB使用分片广播技术方案视频处理方案及实现思路分布式锁 视频处理 视频编码和文件格式 什么是视频编码&#xff1f; 同时我们还要知道我们为什么要对视频…

家用洗地机哪款好?2023入门级智能洗地机

现代社会对卫生日益重视&#xff0c;尤其是在工业、商业和公共场所要求越来越高。传统清洁方式不能满足人们的需求&#xff0c;清洁工作效率低且卫生难以保证。而洗地机的出现&#xff0c;正是为了解决这些问题。它能够深入清洁地面&#xff0c;有效防止不必要的污垢、细菌和病…