13. Hilt与DataStore:DataStore的注入、SharedPreferences迁移方案

说实话,DataStore 出来也有好几年了。但我发现很多项目还在用 SharedPreferences,问就是「懒得改」。嗯,我能理解——毕竟 SP 用了那么多年,也没出过什么大乱子。但如果你正在用 Hilt 做依赖注入,那迁移到 DataStore 其实比你想象中要轻松得多。

我个人习惯是,新项目直接上 DataStore,老项目逐步迁移。今天我们就聊聊怎么用 Hilt 把 DataStore 注入到项目里,以及如何把 SharedPreferences 平滑地迁移过来。

13.1 为什么是 DataStore?

先简单说说 DataStore 解决了什么问题。SharedPreferences 有几个老毛病:

  • 同步读写会卡 UI 线程——你想想看,主线程里调个 getString(),万一文件大一点,界面就掉帧了
  • 类型不安全——存的时候是 String,取的时候可能当成 Int 用,运行时才崩
  • 没有事务保证——写入一半进程被杀,数据就坏了

DataStore 基于 Flow,天生异步,还支持类型安全。说白了,它就是 Google 给 SP 准备的接班人。

核心结论:DataStore 分两种——Preferences DataStore(键值对)和 Proto DataStore(带 Schema)。日常替代 SP 用 Preferences 就够了。

13.2 用 Hilt 注入 DataStore

这部分我踩过坑。一开始我直接在 Activity 里创建 DataStore,结果每个页面都要传 Context,代码丑得不行。后来用 Hilt 做单例注入,整个世界清净了。

13.2.1 添加依赖

首先,在 build.gradle 里加上 DataStore 和 Hilt 的依赖:

// DataStore
implementation "androidx.datastore:datastore-preferences:1.0.0"

// Hilt(如果你还没加)
implementation "com.google.dagger:hilt-android:2.48"
kapt "com.google.dagger:hilt-compiler:2.48"

13.2.2 定义 Module 提供 DataStore

我习惯把 DataStore 的创建放在一个单独的 Hilt Module 里。这样整个应用只需要一个实例,不会重复创建文件。

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {

    @Provides
    @Singleton
    fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
        return context.createDataStore(
            name = "app_settings"
        )
    }
}

这里有个细节:@ApplicationContext 是 Hilt 提供的 qualifier,用来获取应用级别的 Context。千万别传 Activity 的 Context,否则 DataStore 的生命周期会乱掉。

13.2.3 封装 Repository

直接暴露 DataStore 给 ViewModel 其实不太优雅。我一般会再包一层 Repository,把读写逻辑封装起来。

class SettingsRepository @Inject constructor(
    private val dataStore: DataStore<Preferences>
) {
    companion object {
        private val KEY_USER_NAME = stringPreferencesKey("user_name")
        private val KEY_NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
    }

    val userName: Flow<String> = dataStore.data
        .map { preferences ->
            preferences[KEY_USER_NAME] ?: ""
        }

    val notificationsEnabled: Flow<Boolean> = dataStore.data
        .map { preferences ->
            preferences[KEY_NOTIFICATIONS_ENABLED] ?: true
        }

    suspend fun saveUserName(name: String) {
        dataStore.edit { preferences ->
            preferences[KEY_USER_NAME] = name
        }
    }

    suspend fun setNotificationsEnabled(enabled: Boolean) {
        dataStore.edit { preferences ->
            preferences[KEY_NOTIFICATIONS_ENABLED] = enabled
        }
    }
}

你看,这样 ViewModel 里只需要注入 SettingsRepository,完全不用关心 DataStore 的细节。测试的时候也方便 mock。

13.2.4 在 ViewModel 中使用

@HiltViewModel
class SettingsViewModel @Inject constructor(
    private val settingsRepo: SettingsRepository
) : ViewModel() {

    val userName: StateFlow<String> = settingsRepo.userName
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "")

    fun updateName(name: String) {
        viewModelScope.launch {
            settingsRepo.saveUserName(name)
        }
    }
}
小提示:stateIn() 把 Flow 转成 StateFlow 时,WhileSubscribed(5000) 是个好习惯。它会在订阅消失后等待 5 秒再取消上游,避免配置变更时重复创建 DataStore 的协程。

13.3 SharedPreferences 迁移方案

老项目迁移是个细致活。我曾经在一个百万 DAU 的应用里做过迁移,踩了不少坑。这里分享一套稳妥的方案。

13.3.1 迁移策略对比

策略 优点 缺点 适用场景
一次性迁移 代码干净,无历史包袱 需要改所有调用点,风险高 小项目或新模块
双写双读 可灰度,可回滚 维护两套代码,工作量大 大型项目,需要逐步切换
代理模式 对业务代码无侵入 需要设计抽象层 中大型项目,团队有设计能力

我个人推荐「代理模式」。说白了,就是定义一个接口,然后分别实现 SP 版本和 DataStore 版本。切换时只需要改 Hilt 的 Module 绑定。

13.3.2 代理模式实战

先定义一个接口:

interface SettingsStorage {
    fun getUserName(): Flow<String>
    suspend fun saveUserName(name: String)
    fun isNotificationsEnabled(): Flow<Boolean>
    suspend fun setNotificationsEnabled(enabled: Boolean)
}

然后分别实现:

// SharedPreferences 实现
class SharedPrefsSettingsStorage @Inject constructor(
    @ApplicationContext private val context: Context
) : SettingsStorage {
    private val prefs = context.getSharedPreferences("app_settings", Context.MODE_PRIVATE)

    override fun getUserName(): Flow<String> = flow {
        emit(prefs.getString("user_name", "") ?: "")
    }

    override suspend fun saveUserName(name: String) {
        prefs.edit().putString("user_name", name).apply()
    }

    // ... 其他方法
}

// DataStore 实现
class DataStoreSettingsStorage @Inject constructor(
    private val dataStore: DataStore<Preferences>
) : SettingsStorage {
    // 代码同前面的 SettingsRepository
}

最后在 Hilt Module 里绑定:

@Module
@InstallIn(SingletonComponent::class)
abstract class StorageModule {

    @Binds
    @Singleton
    abstract fun bindSettingsStorage(
        impl: DataStoreSettingsStorage  // 想切回 SP 就改成 SharedPrefsSettingsStorage
    ): SettingsStorage
}

这样,业务代码完全不用改。哪天 DataStore 出了更好的替代品,换个实现类就行。

13.3.3 数据迁移工具

DataStore 官方提供了一个迁移工具,可以把 SP 的数据一次性搬过来。用法很简单:

val dataStore = context.createDataStore(
    name = "app_settings",
    migrations = listOf(
        SharedPreferencesMigration(context, "old_shared_prefs_name")
    )
)

第一次创建 DataStore 时,它会自动读取旧的 SP 文件,把数据写进 DataStore,然后删除 SP 文件。嗯,这里要注意——迁移只执行一次。如果用户已经升级过版本,第二次启动就不会再跑了。

我曾经踩过的坑:迁移过程中如果用户强制杀进程,数据可能会丢失一部分。建议在迁移前先备份 SP 文件,或者等迁移完成后再做关键操作。另外,如果 SP 文件特别大(比如超过 100KB),迁移可能会卡几秒钟,最好在后台线程做。

13.4 知识体系总览

下面这张图总结了本章的核心逻辑:

Hilt + DataStore 知识体系 Hilt 注入 DataStore @Module → @Provides → DataStore<Preferences> Repository 封装 ViewModel 消费 SP 迁移方案 Flow 读写 类型安全 StateFlow 转换 协程安全 代理模式 / 双写 官方迁移工具

13.5 避坑指南

最后,分享几个我实际项目中遇到的坑:

  • DataStore 的 data 属性是 Flow,但第一次读取可能为空。 如果你用 .first() 拿值,记得给默认值。否则 UI 上可能出现空白。
  • 不要在 edit 块里做耗时操作。 DataStore 的写操作是单线程队列执行的,如果你在 edit 里做网络请求,整个 DataStore 的写入都会被卡住。
  • 迁移时注意 SP 的 key 冲突。 如果旧 SP 里有个 key 叫 "user_name",新 DataStore 里也有,迁移工具会以 SP 的值为准。如果你已经在新版本里写了新数据,迁移可能会覆盖掉。
  • 测试时记得用 runBlockingTestrunTest DataStore 的读写都是挂起函数,普通单元测试跑不了。我一般会用 Turbine 库来测试 Flow 的发射值。

好了,DataStore 的注入和迁移就聊到这儿。说白了,Hilt 让 DataStore 的接入变得特别简单——一个 Module、一个 Repository,剩下的就是业务代码的事了。至于迁移,别想着一步到位,用代理模式慢慢来,稳得很。

公众号:蓝海资料掘金营,微信deep3321