使用 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时,我们阻塞了 UI 线程上的磁盘 IO。这可能导致 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 使用 Protocol Buffers 定义模式。使用 Protobufs 允许持久化强类型数据。它们比 XML 和其他类似的数据格式更快、更小、更简单且更不容易产生歧义。虽然 Proto DataStore 需要您学习一种新的序列化机制,但我们认为 Proto DataStore 带来的强类型优势是值得的。

Room 与 DataStore

如果您需要进行部分更新、引用完整性或处理大型/复杂数据集,则应考虑使用 Room 而不是 DataStore。DataStore 适用于小型或简单数据集,不支持部分更新或引用完整性。

5. Proto DataStore - 概述

SharedPreferences 和 Preferences DataStore 的缺点之一是无法定义模式或确保以正确的类型访问键。Proto DataStore 通过使用 Protocol Buffers 定义模式来解决此问题。使用 protos DataStore 知道存储了哪些类型,并将直接提供它们,从而无需使用键。

让我们看看如何将 Proto DataStore 和 Protobufs 添加到项目中,Protocol Buffers 是什么以及如何将它们与 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 文件中定义模式。在我们的代码实验室中,我们有两个用户偏好:show_completedsort_order;目前它们表示为两个不同的对象。因此,我们的目标之一是将这两个标志统一到存储在 DataStore 中的 UserPreferences 类下。我们不会在 Kotlin 中定义此类,而是在 protobuf 模式中定义它。

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

app/src/main/proto 目录中创建一个名为 user_prefs.proto 的新文件。如果您没有看到此文件夹结构,请切换到Project 视图。在 protobufs 中,每个结构都使用 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 文件中定义的数据类型,我们需要实现一个 Serializer。Serializer 还定义了如果磁盘上没有数据则返回的默认值。在 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

为简单起见,在本代码实验室中,让我们在 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 在 protobufs 中的定义方式与 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

现在 UserPreferencesRepository 在 DataStore 中存储 show_completedsort_order 标志,并公开一个 Flow<UserPreferences>,让我们更新 TasksViewModel 以使用它们。

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

private val userPreferencesFlow = userPreferencesRepository.userPreferencesFlow

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

当调用 filterSortTasks 时,传入 userPreferencesshowCompletedsortOrder。你的代码应该如下所示

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,处理数据迁移,保证数据一致性并处理数据损坏。