使用 Proto DataStore

Datastore 是什么?

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

学习内容

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

构建目标

在此 Codelab 中,您将从一个示例应用入手开始构建。该应用会显示一个任务列表,其中的任务可以按照完成状态进行过滤,并可按优先级和截止时间进行排序。

fcb2ffa4e6b77f33.gif

“显示已完成任务”过滤器的布尔值标记保存在内存中。使用 SharedPreferences 对象将排序顺序持久存储到磁盘中。

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

  • 将“已完成状态”过滤器持久保存在 DataStore 中。
  • 将排序顺序从 SharedPreferences 迁移到 DataStore 中。

我们还建议您学习使用 Preferences DataStore Codelab,以便更好地了解两种实现之间的区别。

所需条件

有关架构组件的介绍,请查看Room with a View codelab。有关 Flow 的说明,请查看“带 Kotlin Flow 和 LiveData 的高级协程”Codelab

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

为帮助您尽快入门,我们准备了一个入门级项目供您在其基础上进行构建。

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

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

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

如果您未安装 git,可以点击下方按钮下载此 Codelab 的全部代码:

下载源代码

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

b3c0dfdb92dfed77.png

应用运行并显示任务列表:

16eb4ceb800bf131.png

在应用中,您可以看到一个任务列表。每个任务都具有以下属性:名称、已完成状态、优先级和截止时间。

为简化我们需要使用的代码,应用仅允许您执行以下两项操作:

  • 切换“显示已完成任务”的可见性 - 默认情况下,这些任务处于隐藏状态
  • 按优先级和/或截止时间对任务排序

应用采用“应用架构指南”中推荐的架构。在每个文件包中,您可以找到以下内容:

data

  • Task 模型类。
  • TasksRepository 类 - 负责提供任务。为简单起见,该类会返回硬编码数据,并通过 Flow 提供该数据,以呈现更真实的场景。
  • UserPreferencesRepository 类 - 用于存储 SortOrder,定义为 enum。当前的排序顺序根据枚举值名称以 String 的形式存储在 SharedPreferences 中。该类提供了用于存储和获取排序顺序的同步方法。

ui

  • 与使用 RecyclerView 显示 Activity 相关的类。
  • TasksViewModel 类负责界面逻辑。

TasksViewModel - 用于存储构建以下需要在界面中显示的数据所需的全部元素:任务列表、“显示已完成”标记和排序顺序标记。所有这些元素均封装在 TasksUiModel 对象中。每当其中某个值发生变化时,我们都必须重构一个新的 TasksUiModel。为此,我们需要组合以下 3 个元素:

  • Flow<List<Task>> - 检索自 TasksRepository
  • MutableStateFlow<Boolean> - 存储最新的“显示已完成”标记,该标记仅保存在内存中。
  • MutableStateFlow<SortOrder> - 存储最新的 SortOrder 值。

为确保正确更新界面,仅在 Activity 启动时才公开 LiveData<TasksUiModel>

我们的代码存在几个问题:

  • 我们在初始化 UserPreferencesRepository.sortOrder 时阻断了磁盘 IO 上的界面线程。这可能会导致界面卡顿。
  • “显示已完成”标记仅保存在内存中,因此每次用户打开应用时,该标记都会重置。与 SortOrder 一样,该标记在应用关闭后仍应保留。
  • 我们目前使用 SharedPreferences 来永久保存数据,但我们在内存中保留了 MutableStateFlow,可以手动修改该值,以便获取有关更改的通知。如果在应用的其他地方修改了该值,则很容易发生错误。
  • UserPreferencesRepository 中,我们公开了两种更新排序顺序的方法:enableSortByDeadline()enableSortByPriority()。这两种方法都依赖当前的排序顺序值,但如果在一个方法结束之前调用另一个方法,则最终值可能会出错。此外还需要注意,由于这些方法是在界面线程上调用的,因此它们可能会导致界面卡顿和严格模式违例。

尽管“显示已完成”标记和排序顺序标记都是用户偏好设置,但目前两者以两种不同的对象来表示。因此,我们的一个目标是将这两个标记统一到一个 UserPreferences 类下。

我们一起来看看如何使用 DataStore 来帮助我们解决这些问题。

您可能经常需要存储较小或简单的数据集。为此,您过去可能使用过 SharedPreferences,但此 API 也存在一系列缺点。Jetpack DataStore 库旨在解决这些问题,从而创建一个简单、安全性更高的异步 API 来存储数据。它提供 2 种不同的实现:

  • Preferences DataStore
  • Proto DataStore

功能

SharedPreferences

PreferencesDataStore

ProtoDataStore

异步 API

✅(仅用于通过监听器读取已更改的值)

✅(通过 Flow

✅(通过 Flow

同步 API

✅(但无法在界面线程上安全调用)

可在界面线程上安全调用

❌*

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

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

可以提示错误

不受运行时异常影响

❌**

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

处理数据迁移

✅(迁移自 SharedPreferences)

✅(迁移自 SharedPreferences)

类型安全

✅ 使用协议缓冲区

  • SharedPreferences 有一个看上去可以在界面线程中安全调用的同步 API,但是该 API 实际上执行磁盘 I/O 操作。此外,apply() 会阻断 fsync() 上的界面线程。每次有服务启动或停止以及每次 activity 在应用中的任何地方启动或停止时,系统都会触发待处理的 fsync() 调用。界面线程在 apply() 调度的待处理 fsync() 调用上会被阻断,这通常会导致 ANR

** SharedPreferences 会将解析错误作为运行时异常抛出。

Preferences DataStore 与 Proto DataStore

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

  • 与 SharedPreferences 一样,Preferences DataStore根据键访问数据,而无需事先定义架构。
  • Proto DataStore 使用协议缓冲区来定义架构。使用协议缓冲区可持久保留强类型数据。与 XML 和其他类似的数据格式相比,协议缓冲区速度更快、规格更小、使用更简单,并且更清楚明了。虽然使用 Proto DataStore 需要学习新的序列化机制,但我们认为 Proto DataStore 有着强大的优势,值得学习。

Room 与 DataStore

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

SharedPreferences 和 Preferences DataStore 的一个缺点是无法定义架构,保证不了存取键时使用了正确的数据类型。Proto DataStore 可利用协议缓冲区定义架构来解决此问题。通过使用协议,DataStore 可以知道存储的类型,并且无需使用键便能提供类型。

接下来,我们看看如何将 Proto DataStore 和协议缓冲区添加到项目中;协议缓冲区是什么以及如何将其用于 Proto DataStore;如何将 SharedPreferences 迁移到 DataStore 中。

添加依赖项

为了使用 Proto DataStore,让协议缓冲区为我们的架构生成代码,我们需要对 build.gradle 文件进行一些更改:

  • 添加协议缓冲区插件
  • 添加协议缓冲区和 Proto DataStore 依赖项
  • 配置协议缓冲区
plugins {
    ...
    id "com.google.protobuf" version "0.8.12"
}

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

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.10.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'
                }
            }
        }
    }
}

协议缓冲区是一种对结构化数据进行序列化的机制。您只需对数据结构化的方式进行一次定义,编译器便会生成源代码,轻松写入和读取结构化数据。

创建 proto 文件

您可以在 proto 文件中定义架构。在此 Codelab 中,我们有两个用户偏好设置:“显示已完成”标记和排序顺序标记;目前两者以两种不同的对象来表示。因此,我们的一个目标是将这两个标记统一到存储在 DataStore 中的一个 UserPreferences 类下。我们将在协议缓冲区架构而非 Kotlin 中定义该类。

请查看 Proto 语言指南,深入了解关于语法的信息。在此 Codelab 中,我们仅关注我们需要使用的类型。

app/src/main/proto 目录中创建一个名为 user_prefs.proto 的新文件。如果您未看到此文件夹结构,请切换到项目视图。在协议缓冲区中,每个结构都使用一个 message 关键字进行定义,并且结构中的每一个成员都会根据类型和名称在消息内进行定义,从而获得从 1 开始的排序。现在,我们来定义一个 UserPreferences 消息,目前该消息只有一个名为 showCompleted 的布尔值。

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 fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

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

创建 DataStore

“显示已完成”标记存放在内存中,在 TasksViewModel 里。我们来根据 Context.createDataStore() 扩展方法在 UserPreferencesRepository 中创建一个 DataStore<UserPreferences> 私有字段。此方法有两个必填参数:

  • DataStore 会进行处理的文件的名称。
  • DataStore 使用的类型的序列化器。我们使用的序列化器如下:UserPreferencesSerializer
private val dataStore: DataStore<UserPreferences> =
    context.createDataStore(
        fileName = "user_prefs.pb",
        serializer = UserPreferencesSerializer)

从 Proto DataStore 中读取数据

Proto DataStore 会公开存储在 Flow<UserPreferences> 中的数据。我们来创建一个被赋予 dataStore.data 的公共 userPreferencesFlow: Flow<UserPreferences> 值:

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data

读取数据时处理异常

由于 DataStore 从文件中读取数据,因此如果读取数据时出现错误,系统会抛出 IOException。我们可以使用 catch Flow 转换来处理这些异常,只需记录错误即可:

private val TAG: String = "UserPreferencesRepo"

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

将数据写入 Proto DataStore

为了写入数据,DataStore 提供了一个挂起 DataStore.updateData() 函数,我们将在此函数中以参数的形式获取 UserPreferences 的当前状态。若要更新状态,我们需要将偏好对象转换为构建器,设置新值,并构建新的偏好。

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

现在我们来创建一个名为 updateShowCompleted() 的挂起函数,用于更新 UserPreferences 的“显示已完成”属性,该函数将调用 dataStore.updateData() 并设置新值:

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

此时,应用可以成功编译,但是我们刚刚在 UserPreferencesRepository 中创建的功能不会被使用。

定义将要在 proto 中保存的数据

排序顺序保存在 SharedPreferences 中。让我们将其迁移到 DataStore 中。首先,我们更新 proto 文件中的 UserPreferences,以存储排序顺序。由于排序顺序是一个 enum,因此必须在 UserPreference 中进行定义。与 Kotlin 类似,enums 需要在协议缓冲区中进行定义。

枚举的默认值为枚举类型定义中列出的第一个值。但是从 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) 语句添加另一个 case。由于我们不打算处理除当前选项以外的其他选项,我们只需要在其他 case 中抛出一个 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 类。让我们在 UserPreferencesRepository 中创建该类。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 dataStore: 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 转换成构建器,设置新的排序顺序,然后再次构建 preference 来实现更新。

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()
    }
}

现在,UserPreferencesRepository 在 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

我们的应用现在应能成功进行编译。运行一下,看看“显示已完成”标记和排序顺序标记是否能成功保存。

查看 Codelab 代码库的 proto 分支,与您的更改进行比较。

现在,您已迁移到 Proto DataStore,那就让我们总结一下所学的内容:

  • SharedPreferences 存在一些缺点,包括看上去可以在界面线程中安全调用的同步 API,没有发出错误信号的机制,缺少事务性 API 等。
  • DataStore 可替代 SharedPreferences,解决 API 的大部分问题。
  • DataStore 有一个使用 Kotlin 协程和 Flow 的完全异步的 API,可以处理数据迁移,保证数据一致性并处理数据损坏问题。