Android DataStore及多进程使用

1,693 阅读7分钟

引入dataStore

implementation "androidx.datastore:datastore-preferences:1.1.0-alpha04"

目前dataStore1.1.0 alpha版本支持多进程使用,稳定版不支持多进程,如果不考虑多进程使用,则可以直接使用稳定版。

谷歌官方建议是,如果使用SharedPreferences,则可以考虑迁移到DataStore

DataStore一共有两种类型:PreferencesDataStore和ProtoDataStore。

  • Preferences DataStore: 使用键存储和访问数据。此实现不需要预定义的架构,也不确保类型安全。
  • Proto DataStore: 将数据作为自定义数据类型的实例进行存储。此实现要求您使用协议缓冲区来定义架构,但可以确保类型安全。

DataStore使用Kotlin实现,如果项目是纯java的话,需要使用rxJava配合。并集成对应rxJava版本的dataStore库。

注意事项

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

PreferencesDataStore

PreferencesDataStore,从名称上就能看出,用于替换SharedPreferences。PreferencesDataStore提供了内置的从SP迁移的功能,构造PreferencesDataStore时,提供你想要替换的SP文件名称,及需要迁移的key集合,即可自动从给定名称的SP生成一个PreferencesDataStore文件,在迁移完成之后,会删除原有的SP。

由于上方的注意事项,dataStore需要确保在一个应用的一个地方进行统一赋值,避免在多处生成实例。推荐是在某个kotlin文件顶部生成

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

调用上述实例就会生成一个文件名为settings.preferences_pb的datastore文件。

若需要从sp迁移,则需调用创建方法:

class PreferenceDataStoreFactory 
@JvmOverloads
public fun create(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    migrations: List<DataMigration<Preferences>> = listOf(),
    scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    produceFile: () -> File
)

corruptionHandler为文件读写失败时的回调

migrations即为数据合并处理的操作 ,sp可以使用内置的SharedPreferencesMigration获取针对sp的操作。

public fun SharedPreferencesMigration(
    context: Context,
    sharedPreferencesName: String,
    keysToMigrate: Set<String> = MIGRATE_ALL_KEYS,
): SharedPreferencesMigration<Preferences>

keysToMigrate即是需要合并的key集合,默认为所有的Key。

scope为文件操作所在的协程作用域,一般使用默认即可。

produceFile需返回一个文件,该文件即为该PreferencesDataStore所对应的文件。这里需要注意指定文件后缀,如果此处返回的文件后缀不是以preferences_pb结尾,则在生成时,会抛出异常。所以我们使用dataStore库中在context上的一个扩展方法所生成的文件。

public fun Context.preferencesDataStoreFile(name: String): File =
    this.dataStoreFile("$name.preferences_pb")

最终调用如下:

private val settingStore:DataStore<Preferences> = PreferenceDataStoreFactory.create(corruptionHandler = null,
    migrations = listOf(
    SharedPreferencesMigration(MyApplication.mApplication,
        SP_FILE_NAME))
    , produceFile = {
        MyApplication.mApplication.preferencesDataStoreFile(DATA_STORE_NAME)
    }
)

获取值

dataStore使用专门的key类型来约定对应的值的数据类型,使用时,我们需要先定义出来我们需要的各个key

val testKey = stringPreferencesKey(keyName)//定义一个值为string的key,keyName即为该key的名称

类似sharedPreference,key类型还有

intPreferencesKey
doublePreferencesKey
booleanPreferencesKey
floatPreferencesKey
longPreferencesKey
stringSetPreferencesKey
及独有的
byteArrayPreferencesKey

在同一个keyName上使用不同类型Key去获取值,会导致ClassCastException。

PreferencesDataStore使用kotlin flow去处理数据的获取,因此我们首先需要使用定义好的key从dataStore中获取我们需要的数据流:

private val testKeyFlow = settingStore.data.map { preferences ->
    preferences[testKey]
}

现在就可以用使用flow的方法去获取其中的数据

GlobalScope.launch {
    val value = testKeyFlow.firstOrNull() ?: ""
}

更新值:

更新值,只需在dataStore上调用edit方法即可:

GlobalScope.launch {
    dataStore.edit {
        it[testKey] = value
    }
}

多进程支持

DataStore1.1.0-alpha版本支持多进程更新数据,多进程的创建需要通过MultiProcessDataStoreFactory创建:

创建方法有两种:

createFromStorage

public object MultiProcessDataStoreFactory 

public fun <T> create(
    storage: Storage<T>,
    corruptionHandler: ReplaceFileCorruptionHandler<T>? = null,
    migrations: List<DataMigration<T>> = listOf(),
    scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
): DataStore<T> = DataStoreImpl<T>(
    storage = storage,
    initTasksList = listOf(DataMigrationInitializer.getInitializer(migrations)),
    corruptionHandler = corruptionHandler ?: NoOpCorruptionHandler(),
    scope = scope
)

storage是dataStore定义的与文件系统交互的一个接口,库中有两种针对该接口的实现:OkioStorage与FileStorage。

OkioStorage

OkioStorage使用okio库实现了对文件的读写,更易使用,内部封装好了数据缓冲。但是官方默认实现的OkioStorage,仅支持单进程。

public class OkioStorage<T>(
    private val fileSystem: FileSystem,
    private val serializer: OkioSerializer<T>,
    private val producePath: () -> Path
) : Storage<T> {

    override fun createConnection(): StorageConnection<T> {
        
        return OkioStorageConnection(fileSystem, canonicalPath, serializer) {
            synchronized(activeFilesLock) {
                activeFiles.remove(canonicalPath.toString())
            }
        }
    }
}

internal class OkioStorageConnection<T>(
    private val fileSystem: FileSystem,
    private val path: Path,
    private val serializer: OkioSerializer<T>,
    private val onClose: () -> Unit
) : StorageConnection<T> {


    override val coordinator = createSingleProcessCoordinator()

 }

coordinator即为用于实现文件锁的工具。OkioStorage直接写为createSingleProcessCoordinator,返回单进程的文件锁,因此使用OkioStorage无法实现多进程方案,而且OkioStorage为final类,我们也无法通过覆写来更改使用的文件锁。

FileStorage

FileStorage为使用FileInputStream与FileOutputStream进行文件读写的实现

class FileStorage<T>(
    private val serializer: Serializer<T>,
    private val coordinatorProducer: (File) -> InterProcessCoordinator = {
        SingleProcessCoordinator()
    },
    private val produceFile: () -> File
) : Storage<T> {
}

coordinatorProducer即为文件锁参数,传入多进程版本文件锁即可实现多进程实现。

FileStorage第一个参数为处理数据如何写入文件以及如何从文件中读取数据的序列化工具,因此使用FileStorage生成跨进程dataStore的话,我们可以直接使用第二个多进程文件创建方法。因为第二个多进程文件创建方法等价于使用FileStorage。

createFromSerializer

public object MultiProcessDataStoreFactory 

public fun <T> create(
    serializer: Serializer<T>,
    corruptionHandler: ReplaceFileCorruptionHandler<T>? = null,
    migrations: List<DataMigration<T>> = listOf(),
    scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    produceFile: () -> File
): DataStore<T> = DataStoreImpl<T>(
    storage = FileStorage(
        serializer,
        { MultiProcessCoordinator(scope.coroutineContext, it) },
        produceFile
    ),
    initTasksList = listOf(DataMigrationInitializer.getInitializer(migrations)),
    corruptionHandler = corruptionHandler ?: NoOpCorruptionHandler(),
    scope = scope
)

Serializer:

public interface Serializer<T> {

    /**
     * Value to return if there is no data on disk.
     */
    public val defaultValue: T

    /**
     * Unmarshal object from stream.
     *
     * @param input the InputStream with the data to deserialize
     */
    public suspend fun readFrom(input: InputStream): T

    /**
     *  Marshal object to a stream. Closing the provided OutputStream is a no-op.
     *
     *  @param t the data to write to output
     *  @output the OutputStream to serialize data to
     */
    public suspend fun writeTo(t: T, output: OutputStream)
}

该接口没有默认实现的工具,我们通过PreferenceDataStoreFactory创建Preference时,库中使用的有一个PreferencesSerializer实现了sp的序列化读取。

object PreferenceDataStoreFactory
public fun create(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    migrations: List<DataMigration<Preferences>> = listOf(),
    scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    produceFile: () -> File
): DataStore<Preferences> {
    val delegate = create(
        storage = OkioStorage(FileSystem.SYSTEM, PreferencesSerializer) {
            val file = produceFile()
            check(file.extension == PreferencesSerializer.fileExtension) {
                "File extension for file: $file does not match required extension for" +
                    " Preferences file: ${PreferencesSerializer.fileExtension}"
            }
            file.absoluteFile.toOkioPath()
        },
        corruptionHandler = corruptionHandler,
        migrations = migrations,
        scope = scope
    )
    return PreferenceDataStore(delegate)
}

但是PreferencesSerializer实现的接口是OkioSerializer

object PreferencesSerializer : OkioSerializer<Preferences> 

而OkioSerializer与Serializer没有任何关联

public interface OkioSerializer<T> {

    /**
     * Value to return if there is no data on disk.
     */
    public val defaultValue: T

    /**
     * Unmarshal object from source.
     *
     * @param source the BufferedSource with the data to deserialize
     */
    public suspend fun readFrom(source: BufferedSource): T

    /**
     *  Marshal object to a Sink.
     *
     *  @param t the data to write to output
     *  @param sink the BufferedSink to serialize data to
     */
    public suspend fun writeTo(t: T, sink: BufferedSink)
}

所以目前sp多进程支持的话,我们需要自己实现Serializer,并使用MultiProcessDataStoreFactory的带Serializer参数的构造方法获取dataStore。

不过SP的序列化读写可以参考官方的PreferencesSerializer实现,只需替换输入输出流为inputStream与outputStream即可

class SharedPreferenceSerializer : Serializer<Preferences> {

    override val defaultValue: Preferences
        get() {
            return emptyPreferences()
        }

    override suspend fun readFrom(input: InputStream): Preferences {
        val preferencesProto = PreferencesMapCompat.readFrom(input)

        val mutablePreferences = mutablePreferencesOf()

        preferencesProto.preferencesMap.forEach { (name, value) ->
            addProtoEntryToPreferences(name, value, mutablePreferences)
        }

        return mutablePreferences.toPreferences()
    }

    override suspend fun writeTo(t: Preferences, output: OutputStream) {
        val preferences = t.asMap()
        val protoBuilder = PreferencesProto.PreferenceMap.newBuilder()

        for ((key, value) in preferences) {
            protoBuilder.putPreferences(key.name, getValueProto(value))
        }
        protoBuilder.build().writeTo(output)
    }

PreferencesMapCompat.readFrom为官方实现的序列化Preference的工具类,里面有做缓冲处理,所以可以直接使用即可。

addProtoEntryToPreferences与getValueProto为官方PreferencesSerializer中的方法,可以直接复用。

ProtoDataStore

ProtoDataStore使用DataStore和Protocol buffer处理数据。

Protocol Buffer,按谷歌的描述,他类似Json,但是更轻量,更快。你可以使用更自然的方式去描述你的数据如何组装在一起,然后你可以使用构造出来的代码,去编写组装数据的序列化读取和输出。

总的来说:协议缓冲区(Protocol Buffer)是定义语言(在.proto文件中创建)、proto编译器为与数据交互而生成的代码、特定于语言的运行时库以及写入文件(或通过网络连接发送)的数据的序列化格式的组合。

Protocol Buffer的优点:

  • 简洁的数据结构
  • 快速解析
  • 多语言支持
  • 自动生成的优化方法
  • 前后兼容的数据更新

Protocol Buffer不适用的情形:

  • 协议缓冲区倾向于假设整个消息可以一次加载到内存中,并且不超过对象图。对于超过少数兆字节的数据,请考虑一个不同的解决方案。在使用较大的数据时,由于串行副本,您可能有效地获得了几个数据副本,这可能会在内存使用中引起令人惊讶的尖峰。

  • 当协议缓冲区序列化时,相同的数据可以具有许多不同的二进制序列化。如果不完全解析它们,则不能比较两个消息以保持平等。

  • 消息未压缩。

  • 对于许多科学和工程用途,如涉及浮点数的大型,多维阵列的计算等,协议缓冲消息的大小和速度不具有优势。对于这些应用,FITS和类似格式的开销较少

  • 协议缓冲区对于非面向对象的语言没有做到良好的支持。

  • 协议缓冲消息并不能自我描述他们的数据。也就是说,如果你不能访问相应的.proto文件,就无法完全描述对应的数据。

  • 非正式标准,不具有法律效益。

Protocol Buffer工作流程:

添加依赖项

使用Proto DataStore,需要修改build.gradle

  • 添加协议缓冲区插件

  • 添加协议缓冲区和 Proto DataStore 依赖项

  • 配置协议缓冲区

plugins {
    ...
    id "com.google.protobuf" version "0.8.17"
}

dependencies {
    implementation  "androidx.datastore:datastore-core:1.0.0"
    implementation  "com.google.protobuf:protobuf-javalite:3.18.0"
    ...
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.14.0"
    }

    // Generates the java Protobuf-lite code for the Protobufs in this project. See
    // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
    // for more information.
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

创建数据对象

在项目的src/main目录下,新建proto目录,在该目录下创建文件UserPreferences.proto

syntax = "proto3";

option java_package = "com.codelab.android.datastore";
option java_multiple_files = true;

message UserPreferences {
  // filter for showing / hiding completed tasks
  bool show_completed = 1;
}

rebuild项目,完成之后,在build/generated/source.proto下,就能看到编译出的对应java语言的数据类。

使用混淆的话,需要在混淆文件中添加:

-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
    <fields>;
}

创建Serializer

数据对象描述数据如何在文件中存储,具体的对象序列化与反序列化需要通过Serializer进行:

object UserPreferenceSerializer :Serializer<UserPreferences>{
    override val defaultValue: UserPreferences
        get() = UserPreferences.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): UserPreferences {
       return UserPreferences.parseFrom(input)
    }

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

使用protocol buffer进行数据的序列化与反序列化很简单,只需调用生成对象的输入与读取即可。

创建dataStore

有了Serializer,我们就可以直接创建对应的dataStore:

private val dataStore = DataStoreFactory.create(serializer = UserPreferenceSerializer){
    context.dataStoreFile("user_prefs.pb")
}

获取数据

类似PreferencesDataStore,不过无需再定义key,可以直接通过flow获取该数据对象的值


val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        // dataStore.data throws an IOException when an error is encountered when reading data
        if (exception is IOException) {
            Log.e(TAG, "Error reading sort order preferences.", exception)
            emit(UserPreferences.getDefaultInstance())
        } else {
            throw exception
        }
    }

更新数据

写入数据通过dataStore提供的updateData方法,在此函数中以参数的形式获取 dataStore对应数据 的当前状态。并将偏好对象转换为构建器,设置新值,并构建新的偏好。

GlobalScope.launch {
    dataStore.updateData {
        it.toBuilder().setShowCompleted(false).build()
    }
}

updateData() 在读取-写入-修改原子操作中用事务的方式更新数据。直到数据持久存储在磁盘中,协程才会完成。