一、 为何要告别 SharedPreferences?

在 Android 开发中,如果你曾经用过 SharedPreferences 来保存用户的设置或者简单的数据,那你一定对它又爱又恨。爱的是它用起来真的非常简单,恨的是它在处理复杂数据、类型安全以及异步操作时,常常让人头疼。比如,你想存一个用户对象,里面有名字、年龄和邮箱,用 SharedPreferences 就得一个个字段拆开存,取的时候再拼起来,不仅麻烦,还容易出错。更重要的是,它在主线程上进行磁盘 I/O 操作,虽然提供了 apply() 来异步写入,但依然可能在某些场景下引发性能问题或 ANR(应用无响应)。

随着应用越来越复杂,我们对数据存储的要求也更高了:需要类型安全、支持异步、易于维护,并且能处理更结构化的数据。这就是 Google 推出 Jetpack DataStore 的初衷。DataStore 提供了两种解决方案:Preferences DataStore 和 Proto DataStore。前者类似于 SharedPreferences 的升级版,用键值对存储;后者则更强大,它使用 Protocol Buffers 来定义数据结构,提供了强类型保证和更高的灵活性。今天,我们就重点聊聊如何从熟悉的 SharedPreferences 迁移到更强大的 Proto DataStore,实现一次平滑的升级。

二、 认识新朋友:Proto DataStore 的核心概念

在开始动手迁移之前,我们得先了解 Proto DataStore 的几个关键点,这能帮你更好地理解后续的步骤。

首先,Protocol Buffers(简称 Protobuf) 是 Google 的一种数据序列化机制。你可以把它想象成一种更高效、更强大的“数据契约”,用它来定义你的数据结构。在 Android 里,我们需要通过一个 .proto 文件来定义这个契约。

其次,DataStore 是异步和基于流的。所有读写操作都是通过 Kotlin 协程或者 RxJava 的 Flow 来完成的,这意味着它默认就是非阻塞的,非常适合现代 Android 应用的开发模式。

最后,迁移是关键一步。DataStore 提供了专门的 API,可以将旧的 SharedPreferences 数据完整地导入到新的 DataStore 中,确保用户升级应用时不丢失任何设置。

听起来可能有点抽象,别担心,接下来我们会用具体的例子,一步一步带你走完整个流程。

三、 迁移实战:从定义数据契约开始

迁移的第一步,是为你的数据定义一个 Protobuf 契约。假设我们原来用 SharedPreferences 存了用户的主题模式(深色/浅色)和消息通知开关。现在,我们要用 Proto DataStore 来管理这些设置。

技术栈:Kotlin + Android Jetpack

1. 设置 Protobuf 依赖
在你的模块级 build.gradle.kts 文件中添加必要的插件和依赖:

// 注意:这是Kotlin DSL的写法
plugins {
    id("com.android.application")
    kotlin("android")
    // 添加protobuf插件
    id("com.google.protobuf") version "0.9.4"
}

android {
    // ... 你的其他配置
}

dependencies {
    // DataStore 依赖
    implementation("androidx.datastore:datastore:1.0.0")
    implementation("androidx.datastore:datastore-preferences:1.0.0")
    // Proto DataStore 需要protobuf运行时
    implementation("com.google.protobuf:protobuf-javalite:3.21.12")
}

// 配置protobuf插件
protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.21.12"
    }
    generateProtoTasks {
        all().forEach { task ->
            task.builtins {
                create("java") {
                    option("lite")
                }
            }
        }
    }
}

2. 创建 .proto 文件定义数据结构
app/src/main/proto/ 目录下(如果没有就新建),创建一个名为 user_preferences.proto 的文件。

// 指定使用的protobuf语法版本
syntax = "proto3";

// 选项,生成轻量级的Java类,更适合Android
option java_package = "com.example.myapp.datastore";
option java_multiple_files = true;

// 定义消息类型,这对应我们想要存储的数据结构
message UserPreferences {
  // 字段定义:类型 字段名 = 唯一的数字标签;
  // 数字标签一旦定义,后续就不能再更改,这是protobuf的兼容性要求
  string theme_mode = 1; // 主题模式,如 "light", "dark"
  bool notifications_enabled = 2; // 通知开关
  int32 login_count = 3; // 新增字段:登录次数,展示扩展性
}

编译项目后,Protobuf插件会自动为我们生成 UserPreferences 这个Java类,它包含了所有字段的getter/setter,以及构建器(Builder)模式的方法。

四、 构建 DataStore 并实现迁移

定义好数据结构后,我们需要创建 DataStore 实例,并告诉它如何从旧的 SharedPreferences 读取数据。

1. 创建 Serializer
Serializer 告诉 DataStore 如何将我们定义的 Protobuf 消息类型和磁盘上的字节进行转换。

package com.example.myapp.datastore

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import com.example.myapp.datastore.UserPreferences // 自动生成的类
import java.io.InputStream
import java.io.OutputStream

// 1. 实现 Serializer 接口
object UserPreferencesSerializer : Serializer<UserPreferences> {
    // 默认值,如果文件不存在或为空,则返回这个值
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): UserPreferences {
        try {
            // 使用 Protobuf 生成类的 parseFrom 方法从流中读取
            return UserPreferences.parseFrom(input)
        } catch (exception: Exception) {
            // 如果文件损坏或格式不对,抛异常并返回默认值
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
        // 使用 Protobuf 生成类的 writeTo 方法写入流
        t.writeTo(output)
    }
}

// 2. 通过扩展属性创建 DataStore 实例
// 这会在 Context 上创建一个名为“user_preferences.pb”的文件
val Context.userDataStore: DataStore<UserPreferences> by dataStore(
    fileName = "user_preferences.pb",
    serializer = UserPreferencesSerializer
)

2. 实现迁移逻辑
这是最关键的一步。我们需要定义一个从 SharedPreferencesUserPreferences 的映射关系。

package com.example.myapp.datastore

import android.content.Context
import androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map

// 旧的 SharedPreferences 名称(假设你原来用的这个名字)
private const val OLD_PREFS_NAME = "my_old_settings"

// 1. 为旧的 Preferences DataStore 定义扩展(用于读取旧数据)
val Context.oldPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
    name = OLD_PREFS_NAME
)

// 2. 创建迁移对象
val sharedPrefsMigration = object : DataMigration<UserPreferences> {
    override suspend fun shouldMigrate(currentData: UserPreferences): Boolean {
        // 这里我们可以添加一些逻辑来判断是否需要迁移
        // 例如,检查旧文件是否存在。但通常 DataStore 会处理。
        // 简单返回 true 表示总是尝试迁移。
        return true
    }

    override suspend fun migrate(currentData: UserPreferences): UserPreferences {
        // 从旧的 Preferences DataStore 中读取数据
        val oldPrefs = context.oldPreferencesDataStore.data.first()

        // 构建新的 UserPreferences 对象
        return UserPreferences.newBuilder(currentData).apply {
            // 映射旧键名到新字段
            // 注意:这里假设旧键名是 "theme_mode" 和 "notifications_enabled"
            oldPrefs[stringPreferencesKey("theme_mode")]?.let { theme ->
                themeMode = theme // 直接赋值,类型是 String
            }
            oldPrefs[booleanPreferencesKey("notifications_enabled")]?.let { enabled ->
                notificationsEnabled = enabled // 直接赋值,类型是 Boolean
            }
            // 旧数据中没有 login_count,所以使用默认值(0)
        }.build()
    }

    override suspend fun cleanUp() {
        // 迁移完成后,可以在这里进行清理工作,比如删除旧文件。
        // 但务必谨慎,确保数据已成功迁移并验证。
        // context.getSharedPreferences(OLD_PREFS_NAME, Context.MODE_PRIVATE).edit().clear().apply()
        println("迁移完成,可在此清理旧资源。")
    }
}

// 3. 更新 DataStore 创建,加入迁移
val Context.userDataStoreWithMigration: DataStore<UserPreferences> by dataStore(
    fileName = "user_preferences.pb",
    serializer = UserPreferencesSerializer,
    // 传入迁移列表,可以传入多个迁移,按顺序执行
    migrations = listOf(sharedPrefsMigration)
)

现在,当应用首次使用 userDataStoreWithMigration 时,DataStore 会自动执行 shouldMigratemigrate 方法,将旧数据搬到新家。

五、 使用新 DataStore 读写数据

迁移完成后,我们就可以愉快地使用新的 DataStore 了。它的 API 非常简洁,并且基于 Flow。

package com.example.myapp.ui.settings

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.myapp.datastore.userDataStoreWithMigration
import com.example.myapp.datastore.UserPreferences
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

class SettingsViewModel(private val context: Context) : ViewModel() {

    // 1. 读取数据:将 DataStore 的 Flow 映射成 UI 需要的状态
    val userPreferencesFlow = context.userDataStoreWithMigration.data
        .map { userPrefs ->
            // 这里可以进行数据转换
            SettingsUiState(
                themeMode = userPrefs.themeMode,
                notificationsEnabled = userPrefs.notificationsEnabled,
                loginCount = userPrefs.loginCount
            )
        }

    // 2. 更新数据
    fun updateThemeMode(newTheme: String) {
        viewModelScope.launch {
            context.userDataStoreWithMigration.updateData { currentPrefs ->
                // updateData 提供了一个原子性的“读-改-写”操作
                currentPrefs.toBuilder()
                    .setThemeMode(newTheme)
                    .build()
            }
        }
    }

    fun incrementLoginCount() {
        viewModelScope.launch {
            context.userDataStoreWithMigration.updateData { currentPrefs ->
                currentPrefs.toBuilder()
                    .setLoginCount(currentPrefs.loginCount + 1)
                    .build()
            }
        }
    }
}

// 一个简单的UI状态数据类
data class SettingsUiState(
    val themeMode: String = "light",
    val notificationsEnabled: Boolean = true,
    val loginCount: Int = 0
)

在 Activity 或 Fragment 中,你可以像观察其他 LiveData 或 Flow 一样观察 userPreferencesFlow,并调用 ViewModel 的方法来更新数据。整个过程都是类型安全的,themeMode 一定是 String,notificationsEnabled 一定是 Boolean,编译器会帮你检查,大大减少了运行时错误。

六、 深入分析:场景、优缺点与注意事项

应用场景
Proto DataStore 非常适合管理结构化、需要类型安全且可能随时间演变的配置数据。例如:用户个人资料、应用设置、功能开关、本地缓存的数据模型等。如果你的数据只是几个简单的、无关的开关,Preferences DataStore 可能更轻量。但如果你的数据有关联性,或者未来可能会增加字段,Proto DataStore 的扩展性优势就非常明显。

技术优缺点
优点:

  1. 强类型安全:在编译期就能发现类型错误,避免 SharedPreferencesgetString 错拿成 getInt 这类问题。
  2. 支持复杂数据结构:通过 Protobuf 可以轻松定义嵌套对象、列表等。
  3. 异步与线程安全:基于 Flow 的 API 天然支持异步,读写操作不会阻塞主线程。
  4. 数据一致性updateData 提供了原子性操作,避免了并发读写冲突。
  5. 良好的扩展性:Protobuf 协议向后兼容性很好,可以安全地添加新字段,旧版本应用读取新数据也不会崩溃。

缺点:

  1. 学习曲线:需要理解 Protobuf 的基本概念和语法,增加了初始复杂度。
  2. 配置稍繁琐:需要配置 Gradle 插件和编写 .proto 文件,对于简单需求显得“杀鸡用牛刀”。
  3. 不能直接查看内容:生成的是二进制文件,不像 SharedPreferences 的 XML 文件可以直接在设备文件浏览器中查看和调试。

注意事项

  1. 一次迁移:DataStore 的迁移通常只发生一次(当旧数据文件存在且新文件第一次被创建时)。确保你的迁移逻辑正确且完整。
  2. 默认值:在 Serializer 中定义的 defaultValue 很重要,它是数据文件的“基石”。
  3. 字段标签不可更改.proto 文件中的字段数字标签(如 = 1)一旦被使用,就永远不能更改或重复使用,这是 Protobuf 保持前后兼容的核心规则。新增字段请使用新的、从未用过的数字标签。
  4. 逐步迁移:对于大型应用,不必一次性迁移所有 SharedPreferences。可以按功能模块逐个迁移,降低风险。
  5. 测试:务必为迁移逻辑编写单元测试和集成测试,模拟各种旧数据情况,确保迁移万无一失。

七、 总结

SharedPreferences 迁移到 Proto DataStore,看似步骤不少,但实际上是一条通往更健壮、更可维护数据存储层的必经之路。它用前期稍微多一点的配置和设计,换来了长远的开发效率提升和运行时稳定性。

整个过程可以概括为:定义契约(.proto) -> 实现序列化(Serializer) -> 规划迁移(DataMigration) -> 安全切换(更新DataStore实例) -> 享受新特性(类型安全、异步Flow)

希望这篇指南能像一张清晰的地图,帮你顺利完成这次重要的“数据基础设施”升级。记住,每一次技术的升级,都是为了给用户带来更稳定流畅的体验,同时也是开发者自身技能树的一次宝贵拓展。现在,就打开你的项目,开始尝试吧!