使用 Proto DataStore

1. 简介

什么是 DataStore?

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

你将学到什么

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

你将构建什么

在此 Codelab 中,你将从一个示例应用开始,该应用显示一个任务列表,这些任务可以按其完成状态进行过滤,并可以按优先级和截止日期进行排序。

429d889061f19c94.gif

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

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

  • 在 DataStore 中持久化完成状态过滤器。
  • 将排序顺序从 SharedPreferences 迁移到 DataStore。

我们建议你也完成 Preferences DataStore Codelab,以便更好地理解两者之间的区别。

你需要准备什么

有关 Architecture Components 的介绍,请查阅带有 View 的 Room Codelab。有关 Flow 的介绍,请查阅使用 Kotlin Flow 和 LiveData 的高级协程 Codelab

2. 设置

在此步骤中,你将下载整个 Codelab 的代码,然后运行一个简单的示例应用。

为了让你尽快上手,我们为你准备了一个入门项目。

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

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

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

如果未安装 git,你可以点击以下按钮下载此 Codelab 的所有代码

下载源代码

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

89af884fa2d4e709.png

应用运行并显示任务列表

16eb4ceb800bf131.png

3. 项目概览

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

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

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

该应用遵循应用架构指南中建议的架构。以下是你在每个包中会找到的内容

data

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

ui

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

TasksViewModel - 持有构建需要在 UI 中显示的数据所需的所有元素:任务列表、showCompletedsortOrder 标志,包装在一个 TasksUiModel 对象中。每当其中一个值发生变化时,我们都必须重新构建一个新的 TasksUiModel。为此,我们组合了 3 个元素

  • TasksRepository 获取 Flow<List<Task>>
  • 一个 MutableStateFlow<Boolean>,持有最新的 showCompleted 标志,该标志仅保留在内存中
  • 一个 MutableStateFlow<SortOrder>,持有最新的 sortOrder 值。

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

我们的代码存在一些问题

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

虽然 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() 调用。apply() 安排的待处理 fsync() 调用会阻塞 UI 线程,常常成为 ANR 的根源。

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

Preferences DataStore vs Proto DataStore

虽然 Preferences DataStore 和 Proto DataStore 都允许保存数据,但它们的方式不同

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

Room vs 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 对象

协议缓冲区是一种用于序列化结构化数据的机制。你只需定义一次数据结构,然后编译器会生成源代码,以便轻松写入和读取结构化数据。

创建 proto 文件

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

有关语法的深入信息,请查阅Proto 语言指南。在此 Codelab 中,我们只关注所需的类型。

app/src/main/proto 目录中创建一个名为 user_prefs.proto 的新文件。如果看不到此文件夹结构,请切换到项目视图。在 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

为了简化,在此 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 的当前状态。要更新它,我们需要将偏好设置对象转换为 builder,设置新值,然后构建新的偏好设置。

updateData() 在原子读-写-修改操作中以事务方式更新数据。协程在数据持久化到磁盘后完成。

让我们创建一个挂起函数 updateShowCompleted(),它允许我们更新 UserPreferencesshowCompleted 属性,该函数调用 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 中定义它。protobufs 中的 enums 定义方式与 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 类。创建 DataStore 的 by 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 对象转换为 builder,设置排序顺序,然后通过调用 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 builder 并将 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 转换为 builder,设置新的排序顺序,然后再次构建偏好设置来更新它。

以下是 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 时,传入 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 标志是否正确保存。

查阅 Codelab 仓库的 proto_datastore 分支以比较你的更改。

10. 总结

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

  • SharedPreferences 存在一系列缺点 - 从在 UI 线程上调用看起来安全的同步 API,没有错误信号机制,缺乏事务 API 等等。
  • DataStore 是 SharedPreferences 的替代品,解决了该 API 的大部分缺点。
  • DataStore 使用 Kotlin 协程和 Flow 提供了完全异步的 API,处理数据迁移,保证数据一致性,并处理数据损坏。