从SharedPreferences到DataStore:Android存储进化之路

从SharedPreferences到DataStore:Android存储进化之路

SharedPreferences 的辉煌与落幕

在早期的 Android 开发中,SharedPreferences 可谓是开发者们的得力助手,是数据存储领域的 "明星产品" 。它就像是一个轻量级的 "保险箱",以简单易用的键值对(Key-Value)形式,帮助开发者轻松存储和获取各种基本数据类型,如布尔值、整型、字符串等,而且它还能自动将数据持久化到设备存储中,方便应用在不同会话之间保留关键信息。

凭借这种简单直接的使用方式,SharedPreferences 迅速成为了 Android 开发中存储少量配置信息、用户偏好设置等场景的首选方案。比如,保存用户的登录状态、主题设置、推送通知开关等,它都能完美胜任。在那个时期的 Android 应用代码中,到处都能看到 SharedPreferences 的身影,它就像一位默默奉献的幕后英雄,支撑着无数应用的正常运转,为开发者们节省了大量的时间和精力,成为了 Android 开发生态中不可或缺的一部分。

SharedPreferences 的痛点大揭秘

随着 Android 应用开发的不断演进,对数据存储的性能、安全性和稳定性提出了更高要求。在这个过程中,SharedPreferences 的一些局限性逐渐暴露出来,曾经的辉煌逐渐被一系列痛点所掩盖。

(一)阻塞主线程

在初始化 SharedPreferences 时,它会将整个文件内容加载到内存中,这个过程是通过子线程进行 IO 读取并完成 XML 解析 。然而,在解析完成之前,其他所有操作,如 getXXX () 和 edit (),都需要等待初始化完成。这就意味着,如果在主线程中调用这些操作,而此时初始化尚未完成,主线程就会被阻塞,从而影响应用的响应速度,甚至可能导致 ANR(Application Not Responding)错误。例如,在应用启动时,如果需要从 SharedPreferences 中读取大量配置信息,而此时初始化过程较慢,就会使应用的启动界面长时间处于空白状态,给用户带来极差的体验。

(二)类型安全缺失

SharedPreferences 在存储和读取数据时,不能保证类型安全。由于它使用相同的键(Key)进行操作,putXXX 方法可以使用不同类型的数据覆盖掉相同的键 。这就导致在读取数据时,如果使用了错误的类型方法,如用 getString () 方法去读取一个原本存储为整型的数据,就会出现 ClassCastException 异常。例如:

java 复制代码
SharedPreferences sp = getSharedPreferences("test", Context.MODE_PRIVATE);
sp.edit().putInt("key", 1).apply();
String value = sp.getString("key", ""); // 这里会抛出ClassCastException异常

(三)内存浪费严重

一旦 SharedPreferences 加载了数据,这些数据就会一直留在内存中。因为它通过静态的 ArrayMap 缓存每一个 SP 文件,而每个 SP 文件内容又通过 Map 缓存键值对数据 。这对于一些内存资源有限的设备来说,是一个不小的负担。尤其是当存储的数据量较大时,会占用大量的内存空间,导致应用的内存开销增大,甚至可能引发内存泄漏,影响应用的整体性能。

(四)异步隐患重重

虽然 apply () 方法被设计为异步提交数据,以避免阻塞主线程,但由于其设计问题,仍然可能会导致程序发生 ANR 。当调用 apply () 方法时,数据会先被原子提交到内存,然后再异步提交到硬件磁盘。在这个过程中,如果在主线程中调用了 QueuedWork.waitToFinish () 方法,而此时异步提交任务尚未完成,主线程就会被阻塞。特别是在数据量较大或者设备性能较低的情况下,这种阻塞可能会持续较长时间,从而引发 ANR。例如,在应用切换页面时,如果有大量的 SharedPreferences 异步提交任务尚未完成,而此时系统调用了 QueuedWork.waitToFinish () 方法,就可能导致页面切换卡顿甚至无响应。

(五)数据一致性难保

SharedPreferences 没有事务性 API,这就意味着在并发访问或者出现异常的情况下,无法保证数据的一致性 。例如,当多个线程同时对 SharedPreferences 进行写入操作时,可能会出现数据丢失或者数据错误的情况。因为它的写入操作不是原子性的,可能会被其他线程的操作打断。另外,在使用 apply () 方法异步提交数据时,如果在数据还未完全写入磁盘之前,应用进程被杀死,那么这些未写入的数据就会丢失,导致内存与磁盘数据不一致。

DataStore 闪亮登场

面对 SharedPreferences 的诸多痛点,Google 推出了 DataStore,作为其替代方案,为开发者提供了更强大、更可靠的数据存储方式。DataStore 是 Jetpack 组件库中的一员,它以其异步、类型安全和事务性的特性,迅速成为了 Android 开发者们的新宠。

(一)DataStore 初相识

DataStore 是 Jetpack 组件库中的一部分,专门用于在 Android 应用中存储简单的键值对数据或类型化对象 。它的出现,旨在解决 SharedPreferences 存在的问题,提供一种更现代化、更高效、更安全的数据存储解决方案。DataStore 基于 Kotlin 协程和 Flow 构建,这使得它能够以异步、非阻塞的方式进行数据存储和读取操作,从而避免了对主线程的阻塞,保证了应用的流畅性 。同时,它提供了类型安全的保障,特别是在 Proto DataStore 中,通过 Protobuf 定义数据结构,能有效避免类型转换错误。而且,DataStore 还具备事务性更新的特点,确保数据的一致性和完整性,从根本上解决了 SharedPreferences 在数据一致性方面的不足。

(二)两种实现方式详解

DataStore 提供了两种不同的实现方式,以满足不同场景下的数据存储需求。

  1. Preferences DataStore:Preferences DataStore 适用于存储简单的键值对数据,它的使用方式与 SharedPreferences 类似,但在性能和安全性上有了显著提升 。在使用 Preferences DataStore 时,我们首先需要在项目的 build.gradle 文件中添加依赖:
groovy 复制代码
implementation "androidx.datastore:datastore-preferences:1.1.1"

然后,通过以下代码获取 Preferences DataStore 的实例:

kotlin 复制代码
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

接下来,就可以进行数据的读写操作了。例如,定义一个键并写入数据:

kotlin 复制代码
private val KEY_NAME = stringPreferencesKey("name")

suspend fun saveName(context: Context, name: String) {
    context.dataStore.edit { preferences ->
        preferences[KEY_NAME] = name
    }
}

读取数据时,可以使用 Flow 来监听数据的变化:

kotlin 复制代码
val nameFlow: Flow<String?> = context.dataStore.data.map { preferences ->
    preferences[KEY_NAME]
}
  1. Proto DataStore:Proto DataStore 则更适合存储复杂的结构化数据 。它基于 Protocol Buffers(Protobuf)技术,通过定义数据结构的.proto 文件,生成对应的 Java 或 Kotlin 类,从而实现对复杂对象的高效存储和读取。在使用 Proto DataStore 时,需要先定义.proto 文件,例如:
protobuf 复制代码
syntax = "proto3";

package com.example;

message User {
  string name = 1;
  int32 age = 2;
  string email = 3;
}

然后,在项目的 build.gradle 文件中添加相关依赖,并配置 Protobuf 插件,生成对应的 Java 或 Kotlin 类 。接着,实现一个 Serializer 来处理数据的读写:

kotlin 复制代码
object UserSerializer : Serializer<User> {
    override val defaultValue: User
        get() = User.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): User {
        try {
            return User.parseFrom(input)
        } catch (e: IOException) {
            throw CorruptionException("Cannot read proto.", e)
        }
    }

    override suspend fun writeTo(t: User, output: OutputStream) {
        t.writeTo(output)
    }
}

最后,获取 Proto DataStore 的实例并进行数据操作:

kotlin 复制代码
val Context.userDataStore: DataStore<User> by dataStore(
    fileName = "user.pb",
    serializer = UserSerializer
)

suspend fun saveUser(context: Context, user: User) {
    context.userDataStore.updateData { currentUser ->
        currentUser.toBuilder()
           .mergeFrom(user)
           .build()
    }
}

val userFlow: Flow<User> = context.userDataStore.data

通过这两种实现方式,DataStore 为开发者提供了更加灵活和强大的数据存储能力,无论是简单的配置信息还是复杂的对象数据,都能轻松应对。

DataStore 优势尽显

(一)异步操作保流畅

DataStore 基于 Kotlin 协程和 Flow 构建,所有的读写操作都是异步的,这是它相较于 SharedPreferences 的一大显著优势 。在 SharedPreferences 中,初始化时会将整个文件内容加载到内存,且 getXXX () 方法是同步的,若在主线程调用,当数据量较大或初始化未完成时,会阻塞主线程,影响应用响应速度 。而 DataStore 的异步操作使得在读取和写入数据时,主线程不会被阻塞,可以继续处理其他任务,从而保证了应用的流畅性。例如,当应用需要在启动时读取大量的用户配置数据时,使用 DataStore 的异步读取操作,用户几乎不会察觉到数据加载的过程,应用可以快速展示界面,提升了用户体验。

(二)类型安全有保障

在类型安全方面,DataStore,尤其是 Proto DataStore,通过使用 Protocol Buffers(协议缓冲区)来定义数据模型,在编译时就能进行严格的类型检查 。这意味着,在代码编写阶段,如果出现类型不匹配的错误,编译器会立即报错,避免了在运行时才发现类型转换错误的情况。例如,我们定义一个 User 的 Proto 文件:

protobuf 复制代码
syntax = "proto3";

package com.example;

message User {
  string name = 1;
  int32 age = 2;
}

然后生成对应的 Kotlin 类。在使用时,通过 Proto DataStore 存储和读取 User 对象,编译器会确保数据类型的一致性。如果尝试将一个不符合 User 数据结构的数据进行存储或读取,编译将无法通过。而在 SharedPreferences 中,由于使用相同的键进行不同类型数据的操作,很容易出现类型转换异常,如前面提到的用 getString () 方法读取原本存储为整型的数据。

(三)数据一致性可靠

DataStore 提供了事务性更新的特性,确保数据的一致性 。在进行数据更新时,DataStore 会将所有的更改作为一个原子操作进行提交,要么全部成功,要么全部失败。这就避免了在并发情况下,由于部分数据更新成功而部分失败导致的数据不一致问题。例如,在一个多线程的场景下,多个线程同时对用户的账户信息进行更新,包括余额、积分等。使用 DataStore,这些更新操作会被视为一个事务,只有当所有的更新都成功完成后,数据才会被持久化到存储中。如果其中任何一个更新操作失败,整个事务会回滚,保证了数据的完整性和一致性。而 SharedPreferences 没有事务性 API,在多线程并发访问时,可能会出现数据丢失或错误的情况。

(四)响应式编程更便捷

DataStore 与 Kotlin 的 Flow 结合,实现了响应式编程,使得数据变化的监听和处理变得更加便捷 。通过 Flow,我们可以轻松地观察 DataStore 中数据的变化,并在数据发生改变时,自动触发相应的操作,比如更新 UI。例如,在一个设置界面中,用户可以修改应用的主题模式,当用户保存设置后,DataStore 中的主题模式数据会发生变化,此时通过 Flow 监听这个变化,界面可以立即更新为用户选择的新主题,实现了数据与 UI 的实时同步。示例代码如下:

kotlin 复制代码
val themeFlow: Flow<String> = context.dataStore.data.map { preferences ->
    preferences[KEY_THEME] ?: "default_theme"
}

themeFlow.collectLatest { theme ->
    // 根据新的主题更新UI
    updateUI(theme)
}

在这个例子中,当 DataStore 中的主题数据发生变化时,Flow 会发射新的数据,collectLatest 会收集这个变化,并调用 updateUI 方法来更新 UI,实现了响应式的 UI 更新。

迁移实战指南

(一)迁移步骤解析

  1. 添加依赖:首先,在项目的 build.gradle 文件中添加 DataStore 的依赖。如果使用 Preferences DataStore,添加以下依赖:
groovy 复制代码
implementation "androidx.datastore:datastore-preferences:1.1.1"

如果使用 Proto DataStore,除了上述依赖外,还需要添加 protobuf 相关的依赖和插件配置 :

groovy 复制代码
plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'com.google.protobuf'
}

protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.19.4'
    }
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

dependencies {
    implementation "androidx.datastore:datastore:1.1.1"
    implementation "androidx.datastore:datastore-proto:1.1.1"
    implementation 'com.google.protobuf:protobuf-java-util:3.19.4'
}
  1. 创建 DataStore 实例:对于 Preferences DataStore,可以在 Context 扩展属性中创建实例 :
kotlin 复制代码
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

对于 Proto DataStore,需要先定义.proto 文件,然后创建 DataStore 实例 :

kotlin 复制代码
val Context.userDataStore: DataStore<User> by dataStore(
    fileName = "user.pb",
    serializer = UserSerializer
)
  1. 迁移数据:读取 SharedPreferences 中的数据,并将其写入 DataStore 。例如,迁移一个字符串类型的数据:
kotlin 复制代码
// 读取SharedPreferences数据
val sharedPreferences = getSharedPreferences("your_prefs_name", Context.MODE_PRIVATE)
val spValue = sharedPreferences.getString("your_key", "default_value")

// 写入DataStore
suspend fun migrateData() {
    val key = stringPreferencesKey("your_key")
    dataStore.edit { settings ->
        settings[key] = spValue ?: "default_value"
    }
}
  1. 验证迁移结果:通过读取 DataStore 中的数据,验证迁移是否成功 :
kotlin 复制代码
val newValueFlow: Flow<String> = dataStore.data.map { preferences ->
    preferences[stringPreferencesKey("your_key")] ?: "default_value"
}

newValueFlow.collect { value ->
    println("Migrated value: $value")
}

(二)注意事项提醒

  1. 线程安全:DataStore 的操作是异步的,在使用时需要注意线程安全。特别是在多线程环境下,避免对 DataStore 进行并发的写入操作,以免出现数据不一致的问题。如果需要在多线程中使用 DataStore,可以考虑使用协程的同步机制,如 Mutex 来保证操作的原子性。

  2. 数据类型转换:在迁移数据时,要注意数据类型的转换。虽然 DataStore 提供了类型安全的保障,但在从 SharedPreferences 迁移数据时,可能需要手动进行类型转换。例如,如果在 SharedPreferences 中存储的是整型数据,而在 DataStore 中使用的是字符串类型来接收,就需要进行适当的转换。

  3. 异常处理:在进行数据迁移和操作 DataStore 时,要做好异常处理。例如,在读取或写入 DataStore 时,可能会出现 I/O 异常、数据解析异常等。可以在代码中使用 try - catch 块来捕获异常,并进行相应的处理,如提示用户操作失败、记录日志等 。例如:

kotlin 复制代码
try {
    // 操作DataStore的代码
} catch (e: IOException) {
    // 处理I/O异常
    Log.e("DataStore", "An I/O error occurred: ${e.message}")
    Toast.makeText(context, "数据操作失败,请稍后重试", Toast.LENGTH_SHORT).show()
} catch (e: CorruptionException) {
    // 处理数据损坏异常
    Log.e("DataStore", "Data corruption occurred: ${e.message}")
    Toast.makeText(context, "数据损坏,请联系管理员", Toast.LENGTH_SHORT).show()
}
  1. 版本兼容性:在添加 DataStore 依赖时,要注意版本兼容性。不同版本的 DataStore 可能会有不同的特性和 API 变化,确保所使用的版本与项目中的其他依赖库和 Android 系统版本兼容,避免因版本冲突导致的编译错误或运行时异常 。在更新 DataStore 版本时,仔细阅读官方文档中的版本变更说明,及时调整代码以适应新的 API。
相关推荐
乱码三千5 小时前
密码管理工具站正式上线啦
前端·html·github
IMPYLH5 小时前
HTML 的 <rt> 元素
前端·html
单线程_015 小时前
从案例分析 Vue3 Tokenizer+Parser 源码五
前端·javascript·vue.js
mldong12 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排13 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby606113 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角15 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!15 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰15 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi16 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js