使用 Proto DataStore

1. 简介

什么是 DataStore?

DataStore 是一种新的改进的数据存储解决方案,旨在取代 SharedPreferences。DataStore 基于 Kotlin 协程和 Flow 构建,提供了两种不同的实现:**Proto DataStore**,允许您存储**类型化对象**(由协议缓冲区支持)和**Preferences DataStore**,存储**键值对**。数据以异步、一致和事务的方式存储,克服了 SharedPreferences 的一些缺点。

您将学到什么

  • 什么是 DataStore 以及为什么要使用它。
  • 如何将 DataStore 添加到您的项目中。
  • Preferences 和 Proto DataStore 之间的区别以及各自的优势。
  • 如何使用 Proto DataStore。
  • 如何从 SharedPreferences 迁移到 Proto DataStore。

您将构建什么

在此代码实验室中,您将从一个示例应用程序开始,该应用程序显示任务列表,这些任务可以按其已完成状态进行筛选,并可以按优先级和截止日期进行排序。

429d889061f19c94.gif

“显示已完成任务”筛选器的布尔标志保存在内存中。排序顺序使用SharedPreferences对象持久保存到磁盘。

由于 DataStore 有两种不同的实现:Preferences DataStore 和 Proto DataStore,您将学习如何在每种实现中使用**Proto DataStore**完成以下任务

  • 将已完成状态筛选器持久保存到 DataStore。
  • 将排序顺序从 SharedPreferences 迁移到 DataStore。

我们建议您也学习 Preferences DataStore 代码实验室,以便更好地了解两者之间的区别。

您需要什么

有关架构组件的介绍,请查看Room with a View 代码实验室。有关 Flow 的介绍,请查看使用 Kotlin Flow 和 LiveData 的高级协程代码实验室

2. 设置

在此步骤中,您将下载整个代码实验室的代码,然后运行一个简单的示例应用程序。

为了让您尽快开始,我们为您准备了一个启动项目供您构建。

如果您已安装 git,则只需运行以下命令。要检查是否已安装 git,请在终端或命令行中键入git --version,并验证它是否可以正确执行。

 git clone https://github.com/android/codelab-android-datastore

初始状态位于master分支中。解决方案代码位于proto_datastore分支中。

如果您没有 git,可以点击以下按钮下载此代码实验室的所有代码

下载源代码

  1. 解压缩代码,然后在 Android Studio Arctic Fox 中打开项目。
  2. 在设备或模拟器上运行**app**运行配置。

89af884fa2d4e709.png

应用程序运行并显示任务列表

16eb4ceb800bf131.png

3. 项目概述

该应用程序允许您查看任务列表。每个任务具有以下属性:名称、已完成状态、优先级和截止日期。

为了简化我们需要处理的代码,该应用程序只允许您执行两个操作

  • 切换“显示已完成任务”可见性 - 默认情况下隐藏任务
  • 按优先级、截止日期或截止日期和优先级对任务进行排序

该应用程序遵循应用架构指南中推荐的架构。以下是您在每个包中将找到的内容

data

  • Task模型类。
  • TasksRepository类 - 负责提供任务。为简单起见,它返回硬编码数据并通过Flow公开它以表示更真实的场景。
  • UserPreferencesRepository类 - 保存SortOrder,定义为enum。当前的**排序顺序作为String保存在 SharedPreferences 中**,基于枚举值名称。它公开了同步方法来保存和获取排序顺序。

ui

  • 与显示带有RecyclerViewActivity相关的类。
  • TasksViewModel类负责 UI 逻辑。

TasksViewModel - 保存构建 UI 需要显示的数据所需的所有元素:任务列表、showCompletedsortOrder标志,包装在TasksUiModel对象中。每次这些值之一发生更改时,我们都必须重新构建一个新的TasksUiModel。为此,我们结合了 3 个元素

  • TasksRepository检索Flow<List<Task>>
  • 一个MutableStateFlow<Boolean>,保存最新的showCompleted标志,该标志仅保存在**内存中**。
  • 一个MutableStateFlow<SortOrder>,保存最新的sortOrder值。

为了确保我们仅在 Activity 启动时正确更新 UI,我们公开了LiveData<TasksUiModel>

我们的代码存在一些问题

  • 在初始化UserPreferencesRepository.sortOrder时,我们阻塞了磁盘 IO 上的 UI 线程。这可能导致 UI 卡顿。
  • showCompleted标志仅保存在内存中,这意味着每次用户打开应用程序时它都会重置。与SortOrder一样,这应该持久化以在关闭应用程序后继续存在。
  • 我们目前正在使用 SharedPreferences 持久保存数据,但我们在内存中保留了一个MutableStateFlow,我们手动修改它以能够收到更改通知。如果值在应用程序的其他地方被修改,这很容易破坏。
  • UserPreferencesRepository中,我们公开了两种更新排序顺序的方法:enableSortByDeadline()enableSortByPriority()。这两种方法都依赖于当前的排序顺序值,但是,如果一个方法在另一个方法完成之前被调用,我们最终会得到错误的最终值。更重要的是,这些方法可能导致 UI 卡顿和 Strict Mode 违规,因为它们是在 UI 线程上调用的。

尽管showCompletedsortOrder标志都是用户首选项,但它们目前表示为两个不同的对象。因此,我们的目标之一是将这两个标志统一到UserPreferences类下。

让我们了解如何使用 DataStore 来帮助我们解决这些问题。

4. DataStore - 基础知识

通常,您可能会发现自己需要存储少量或简单的数据集。为此,过去您可能使用了 SharedPreferences,但此 API 也有一些缺点。Jetpack DataStore 库旨在解决这些问题,为存储数据创建一个简单、更安全和异步的 API。它提供了 2 种不同的实现

  • Preferences DataStore
  • Proto DataStore

功能

SharedPreferences

PreferencesDataStore

ProtoDataStore

异步 API

✅(仅用于读取更改的值,通过侦听器

✅(通过Flow和 RxJava 2 & 3 Flowable

✅(通过Flow和 RxJava 2 & 3 Flowable

同步 API

✅(但不安全在 UI 线程上调用)

安全在 UI 线程上调用

❌(1)

✅(工作在后台移至Dispatchers.IO

✅(工作在后台移至Dispatchers.IO

可以发出错误信号

免受运行时异常

❌(2)

具有具有强一致性保证的事务 API

处理数据迁移

类型安全

✅ 使用协议缓冲区

(1) SharedPreferences 具有一个同步 API,在 UI 线程上调用时看起来很安全,但实际上它执行磁盘 I/O 操作。此外,apply()fsync()上阻塞 UI 线程。每次任何服务启动或停止以及每次应用程序中任何位置的活动启动或停止时,都会触发挂起的fsync()调用。UI 线程被apply()调度的挂起的fsync()调用阻塞,通常成为ANR的来源。

(2) SharedPreferences 将解析错误作为运行时异常抛出。

Preferences 与 Proto DataStore

虽然 Preferences 和 Proto DataStore 都允许保存数据,但它们以不同的方式执行此操作

  • **Preference DataStore**,像 SharedPreferences 一样,基于键访问数据,而无需预先定义模式。
  • **Proto DataStore**使用协议缓冲区定义模式。使用 Protobufs 允许**持久化强类型数据**。它们比 XML 和其他类似的数据格式更快、更小、更简单且更不容易产生歧义。虽然 Proto DataStore 要求您学习一种新的序列化机制,但我们认为 Proto DataStore 带来的强类型优势是值得的。

Room 与 DataStore

如果您需要部分更新、引用完整性或大型/复杂数据集,则应考虑使用 Room 而不是 DataStore。DataStore 非常适合小型或简单的数据集,并且不支持部分更新或引用完整性。

5. Proto DataStore - 概述

SharedPreferences 和 Preferences DataStore 的缺点之一是无法定义模式或确保使用正确的类型访问键。Proto DataStore 通过使用协议缓冲区来定义模式来解决此问题。使用 protos DataStore 知道存储了哪些类型,并且只会提供它们,从而无需使用键。

让我们看看如何将 Proto DataStore 和 Protobufs 添加到项目中,协议缓冲区是什么以及如何将它们与 Proto DataStore 一起使用,以及如何将 SharedPreferences 迁移到 DataStore。

添加依赖项

要使用 Proto DataStore 并让 Protobuf 为我们的模式生成代码,我们需要对模块的 build.gradle 文件进行一些更改

  • 添加 Protobuf 插件
  • 添加 Protobuf 和 Proto DataStore 依赖项
  • 配置 Protobuf
plugins {
    ...
    id "com.google.protobuf" version "0.8.17"
}

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

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

    // 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'
                }
            }
        }
    }
}

6. 定义和使用 protobuf 对象

Protocol buffers 是一种用于序列化结构化数据的机制。您只需定义一次希望数据如何结构化,然后编译器就会生成源代码,以便轻松地写入和读取结构化数据。

创建 proto 文件

您在 proto 文件中定义模式。在我们的 codelab 中,我们有两个用户偏好:show_completedsort_order;目前它们以两个不同的对象表示。因此,我们的目标之一是将这两个标志统一到存储在 DataStore 中的 UserPreferences 类下。我们不会在 Kotlin 中定义此类,而是在 protobuf 模式中定义它。

查看Proto 语言指南,以获取有关语法的详细信息。在本 codelab 中,我们只关注所需的类型。

app/src/main/proto 目录中创建一个名为 user_prefs.proto 的新文件。如果您没有看到此文件夹结构,请切换到项目视图。在 protobuf 中,每个结构都使用 message 关键字定义,结构的每个成员都在消息内部定义,基于类型和名称,并分配一个从 1 开始的顺序。让我们定义一个 UserPreferences 消息,目前它只包含一个名为 show_completed 的布尔值。

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;
}

创建序列化器

为了告诉 DataStore 如何读取和写入我们在 proto 文件中定义的数据类型,我们需要实现一个序列化器。序列化器还定义了如果磁盘上没有数据则返回的默认值。在 data 包中创建一个名为 UserPreferencesSerializer 的新文件。

object UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
    override suspend fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

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

7. 在 Proto DataStore 中持久化数据

创建 DataStore

showCompleted 标志保存在内存中,在 TasksViewModel 中,但它应该存储在 UserPreferencesRepository 中,在一个 DataStore 实例中。

要创建 DataStore 实例,我们使用 dataStore 代理,以 Context 作为接收者。该代理有两个必填参数

  • DataStore 将作用于的文件的名称。
  • 用于 DataStore 的类型的序列化器。在我们的例子中:UserPreferencesSerializer

为简单起见,在本 codelab 中,让我们在 TasksActivity.kt 中执行此操作。

private const val USER_PREFERENCES_NAME = "user_preferences"
private const val DATA_STORE_FILE_NAME = "user_prefs.pb"
private const val SORT_ORDER_KEY = "sort_order"

private val Context.userPreferencesStore: DataStore<UserPreferences> by dataStore(
    fileName = DATA_STORE_FILE_NAME,
    serializer = UserPreferencesSerializer
)

class TasksActivity: AppCompatActivity() { ... }

dataStore 代理确保我们在应用程序中只有一个名为该名称的 DataStore 实例。目前,UserPreferencesRepository 实现为单例,因为它保存了 sortOrderFlow 并避免将其绑定到 TasksActivity 的生命周期。因为 UserPreferenceRepository 只会处理来自 DataStore 的数据,并且不会创建和保存任何新对象,所以我们现在就可以删除单例实现。

  • 删除 companion object
  • constructor 设置为 public

UserPreferencesRepository 应该获取一个 DataStore 实例作为构造函数参数。目前,我们可以将 Context 作为参数保留,因为它被 SharedPreferences 需要,但我们稍后会将其删除。

class UserPreferencesRepository(
    private val userPreferencesStore: DataStore<UserPreferences>,
    context: Context
) { ... }

让我们更新 TasksActivityUserPreferencesRepository 的构造并传入 dataStore

viewModel = ViewModelProvider(
    this,
    TasksViewModelFactory(
        TasksRepository,
        UserPreferencesRepository(userPreferencesStore, this)
    )
).get(TasksViewModel::class.java)

从 Proto DataStore 读取数据

Proto DataStore 在 Flow<UserPreferences> 中公开存储的数据。让我们创建一个公共的 userPreferencesFlow: Flow<UserPreferences> 值,该值被分配给 dataStore.data

val userPreferencesFlow: Flow<UserPreferences> = userPreferencesStore.data

处理读取数据时的异常

由于 DataStore 从文件中读取数据,因此在读取数据时发生错误时会抛出 IOException。我们可以使用 catch Flow 变换来处理这些异常,并只记录错误。

private val TAG: String = "UserPreferencesRepo"

val userPreferencesFlow: Flow<UserPreferences> = userPreferencesStore.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
        }
    }

将数据写入 Proto DataStore

要写入数据,DataStore 提供了一个挂起函数 DataStore.updateData(),其中我们获取 UserPreferences 的当前状态作为参数。要更新它,我们将必须将首选项对象转换为构建器,设置新值,然后构建新的首选项。

updateData() 以原子读写修改操作的方式更新数据。一旦数据持久化到磁盘上,协程就会完成。

让我们创建一个挂起函数,允许我们更新 UserPreferencesshowCompleted 属性,称为 updateShowCompleted(),它调用 dataStore.updateData() 并设置新值。

suspend fun updateShowCompleted(completed: Boolean) {
    userPreferencesStore.updateData { preferences ->
        preferences.toBuilder().setShowCompleted(completed).build()
    }
}

此时,应用程序应该可以编译,但我们刚刚在 UserPreferencesRepository 中创建的功能没有被使用。

8. 从 SharedPreferences 到 Proto DataStore

定义要保存在 proto 中的数据

排序顺序保存在 SharedPreferences 中。让我们将其移动到 DataStore。为此,让我们首先更新 proto 文件中的 UserPreferences 以存储排序顺序。由于 SortOrder 是一个 enum,我们将不得不将其定义在我们的 UserPreference 中。enums 在 protobuf 中的定义方式类似于 Kotlin。

对于枚举,默认值为枚举类型定义中列出的第一个值。但是,当从 SharedPreferences 迁移时,我们需要知道我们获取的值是默认值还是之前在 SharedPreferences 中设置的值。为了帮助解决这个问题,我们为 SortOrder 枚举定义了一个新值:UNSPECIFIED,并将其列在第一位,以便它可以成为默认值。

我们的 user_prefs.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;

  // defines tasks sorting order: no order, by deadline, by priority, by deadline and priority
  enum SortOrder {
    UNSPECIFIED = 0;
    NONE = 1;
    BY_DEADLINE = 2;
    BY_PRIORITY = 3;
    BY_DEADLINE_AND_PRIORITY = 4;
  }

  // user selected tasks sorting order
  SortOrder sort_order = 2;
}

清理并重建您的项目,以确保生成了一个新的 UserPreferences 对象,其中包含新字段。

现在 SortOrder 在 proto 文件中已定义,我们可以从 UserPreferencesRepository 中删除该声明。删除

enum class SortOrder {
    NONE,
    BY_DEADLINE,
    BY_PRIORITY,
    BY_DEADLINE_AND_PRIORITY
}

确保在任何地方都使用了正确的 SortOrder 导入

import com.codelab.android.datastore.UserPreferences.SortOrder

TasksViewModel.filterSortTasks() 中,我们根据 SortOrder 类型执行不同的操作。现在我们还添加了 UNSPECIFIED 选项,我们需要为 when(sortOrder) 语句添加另一个情况。因为我们不想处理除我们现在正在处理的选项之外的其他选项,所以我们可以在其他情况下抛出 UnsupportedOperationException

我们的 filterSortTasks() 函数现在如下所示

private fun filterSortTasks(
    tasks: List<Task>,
    showCompleted: Boolean,
    sortOrder: SortOrder
): List<Task> {
    // filter the tasks
    val filteredTasks = if (showCompleted) {
        tasks
    } else {
        tasks.filter { !it.completed }
    }
    // sort the tasks
    return when (sortOrder) {
        SortOrder.UNSPECIFIED -> filteredTasks
        SortOrder.NONE -> filteredTasks
        SortOrder.BY_DEADLINE -> filteredTasks.sortedByDescending { it.deadline }
        SortOrder.BY_PRIORITY -> filteredTasks.sortedBy { it.priority }
        SortOrder.BY_DEADLINE_AND_PRIORITY -> filteredTasks.sortedWith(
            compareByDescending<Task> { it.deadline }.thenBy { it.priority }
        )
        // We shouldn't get any other values
        else -> throw UnsupportedOperationException("$sortOrder not supported")
    }
}

从 SharedPreferences 迁移

为了帮助迁移,DataStore 定义了 SharedPreferencesMigration 类。by dataStore 方法创建 DataStore(在 TasksActivity 中使用),还公开了 produceMigrations 参数。在此块中,我们创建了应为此 DataStore 实例运行的 DataMigration 列表。在我们的例子中,我们只有一个迁移:SharedPreferencesMigration

在实现 SharedPreferencesMigration 时,migrate 块会给我们两个参数

  • SharedPreferencesView 允许我们从 SharedPreferences 中检索数据
  • UserPreferences 当前数据

我们将必须返回一个 UserPreferences 对象。

在实现 migrate 块时,我们将必须执行以下步骤

  1. 检查 UserPreferences 中的 sortOrder 值。
  2. 如果这是 SortOrder.UNSPECIFIED,则表示我们需要从 SharedPreferences 中检索值。如果缺少 SortOrder,则可以使用 SortOrder.NONE 作为默认值。
  3. 获取排序顺序后,我们将必须将 UserPreferences 对象转换为构建器,设置排序顺序,然后通过调用 build() 再次构建对象。此更改不会影响其他字段。
  4. 如果 UserPreferences 中的 sortOrder 值不是 SortOrder.UNSPECIFIED,我们可以直接返回我们在 migrate 中获取的当前数据,因为迁移必须已经成功运行。
private val sharedPrefsMigration = SharedPreferencesMigration(
    context,
    USER_PREFERENCES_NAME
) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences ->
    // Define the mapping from SharedPreferences to UserPreferences
    if (currentData.sortOrder == SortOrder.UNSPECIFIED) {
        currentData.toBuilder().setSortOrder(
            SortOrder.valueOf(
                sharedPrefs.getString(SORT_ORDER_KEY, SortOrder.NONE.name)!!
            )
        ).build()
    } else {
        currentData
    }
}

现在我们定义了迁移逻辑,我们需要告诉 DataStore 它应该使用它。为此,更新 DataStore 构建器并将 migrations 参数分配给一个包含我们 SharedPreferencesMigration 实例的新列表。

private val userPreferencesStore: DataStore<UserPreferences> = context.createDataStore(
    fileName = "user_prefs.pb",
    serializer = UserPreferencesSerializer,
    migrations = listOf(sharedPrefsMigration)
)

将排序顺序保存到 DataStore

要更新调用 enableSortByDeadline()enableSortByPriority() 时的排序顺序,我们必须执行以下操作

  • dataStore.updateData() 的 lambda 中调用它们各自的功能。
  • 由于 updateData() 是一个挂起函数,因此 enableSortByDeadline()enableSortByPriority() 也应该设置为挂起函数。
  • 使用从 updateData() 接收到的当前 UserPreferences 来构造新的排序顺序。
  • 通过将其转换为构建器,设置新的排序顺序,然后再次构建首选项来更新 UserPreferences

以下是 enableSortByDeadline() 的实现方式。我们将让您自己对 enableSortByPriority() 进行更改。

suspend fun enableSortByDeadline(enable: Boolean) {
    // updateData handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.updateData { preferences ->
        val currentOrder = preferences.sortOrder
        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_PRIORITY) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_DEADLINE
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_PRIORITY
                } else {
                    SortOrder.NONE
                }
            }
        preferences.toBuilder().setSortOrder(newSortOrder).build()
    }
}

现在您可以删除 context 构造函数参数和所有 SharedPreferences 的用法。

9. 更新 TasksViewModel 以使用 UserPreferencesRepository

现在 UserPreferencesRepositoryshow_completedsort_order 标志都存储在 DataStore 中并公开了 Flow<UserPreferences>,让我们更新 TasksViewModel 以使用它们。

删除 showCompletedFlowsortOrderFlow,而是创建一个名为 userPreferencesFlow 的值,该值使用 userPreferencesRepository.userPreferencesFlow 初始化。

private val userPreferencesFlow = userPreferencesRepository.userPreferencesFlow

tasksUiModelFlow 创建中,将 showCompletedFlowsortOrderFlow 替换为 userPreferencesFlow。相应地替换参数。

调用 filterSortTasks 时,传入 userPreferences 中的 showCompletedsortOrder。你的代码应该如下所示

private val tasksUiModelFlow = combine(
        repository.tasks,
        userPreferencesFlow
    ) { tasks: List<Task>, userPreferences: UserPreferences ->
        return@combine TasksUiModel(
            tasks = filterSortTasks(
                tasks,
                userPreferences.showCompleted,
                userPreferences.sortOrder
            ),
            showCompleted = userPreferences.showCompleted,
            sortOrder = userPreferences.sortOrder
        )
    }

showCompletedTasks() 函数现在应该更新为调用 userPreferencesRepository.updateShowCompleted()。由于这是一个挂起函数,所以在 viewModelScope 中创建一个新的协程

fun showCompletedTasks(show: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.updateShowCompleted(show)
    }
}

userPreferencesRepository 函数 enableSortByDeadline()enableSortByPriority() 现在是挂起函数,因此也应该在 viewModelScope 中启动的新协程中调用它们

fun enableSortByDeadline(enable: Boolean) {
    viewModelScope.launch {
       userPreferencesRepository.enableSortByDeadline(enable)
    }
}

fun enableSortByPriority(enable: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.enableSortByPriority(enable)
    }
}

清理 UserPreferencesRepository

让我们删除不再需要的字段和方法。你应该可以删除以下内容

  • _sortOrderFlow
  • sortOrderFlow
  • updateSortOrder()
  • private val sortOrder: SortOrder
  • private val sharedPreferences

我们的应用现在应该可以成功编译了。让我们运行它,看看 show_completedsort_order 标志是否正确保存。

查看代码实验室仓库的 proto_datastore 分支,以比较你的更改。

10. 总结

现在你已经迁移到 Proto DataStore,让我们回顾一下我们学到的内容

  • SharedPreferences 带有一系列缺点 - 从在 UI 线程上调用看似安全的同步 API,到没有错误信号机制,再到缺少事务性 API 等等。
  • DataStore 是 SharedPreferences 的替代方案,它解决了 API 的大多数缺点。
  • DataStore 具有使用 Kotlin 协程和 Flow 的完全异步 API,处理数据迁移,保证数据一致性并处理数据损坏。