DataStore
Jetpack 的 DataStore 是一种数据存储解决方案,可以像 SharedPreferences 一样存储键值对或使用 protocol buffers 存储类型化的对象。 DataStore 使用 Kotlin 的协程和 Flow 以异步的、一致性的、事务性的方式来存储数据,对比 SharedPreferences 有许多改进和优化,主要作为 SharedPreferences 的替代品,并且由 SharedPreferences 迁移非常方便。
DataStore 提供了两种方式:
-
Preferences DataStore:以键值对的形式存储在本地,和 SP 类似,但是 DataStore 是基于
Flow实现的,不会阻塞主线程,但不能保证类型安全。 -
Proto DataStore:存储自定义数据类型的对象(typed objects),通过
protocol buffers将对象序列化存储在本地,这要求通过protocol buffers预先定义 schema,但是能保证类型安全。
既然 DataStore 是 SP 的替代和改进,那 SP 存在着什么问题需要被改进呢?
SharedPreferences 的不足
SharedPreference 是一个轻量级的数据存储方式,使用起来非常方便,以键值对的形式存储在本地,但存在以下问题:
通过 getXXX() 方法获取数据,可能会导致主线程阻塞
所有 getXXX() 方法都是同步的,在主线程调用 get 方法,必须等待 SP 加载完毕,初始化 SP 的时候,会将整个 xml 文件内容加载内存中,如果文件很大,读取较慢,会导致主线程阻塞。
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) // 异步加载 SP 文件内容
sp.getString("jetpack", ""); // 等待 SP 加载完毕
getSharedPreferences 时开启一个线程异步读取数据,最终会进入SharedPreferencesImpl的loadFromDisk方法:
private void loadFromDisk() {
synchronized (mLock) {
if (mLoaded) {
return;
}
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
// Debugging
if (mFile.exists() && !mFile.canRead()) {
Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
}
Map<String, Object> map = null;
StructStat stat = null;
Throwable thrown = null;
try {
stat = Os.stat(mFile.getPath());
if (mFile.canRead()) {
BufferedInputStream str = null;
try {
str = new BufferedInputStream(
new FileInputStream(mFile), 16 * 1024);
map = (Map<String, Object>) XmlUtils.readMapXml(str);
} catch (Exception e) {
Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
} finally {
IoUtils.closeQuietly(str);
}
}
} catch (ErrnoException e) {
// An errno exception means the stat failed. Treat as empty/non-existing by
// ignoring.
} catch (Throwable t) {
thrown = t;
}
synchronized (mLock) {
mLoaded = true;
mThrowable = thrown;
// It's important that we always signal waiters, even if we'll make
// them fail with an exception. The try-finally is pretty wide, but
// better safe than sorry.
try {
if (thrown == null) {
if (map != null) {
mMap = map;
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
} else {
mMap = new HashMap<>();
}
}
// In case of a thrown exception, we retain the old map. That allows
// any open editors to commit and store updates.
} catch (Throwable t) {
mThrowable = t;
} finally {
mLock.notifyAll();
}
}
}
在这里通过对象锁 mLock机制来对其进行加锁操作。只有当 SP 文件中的数据全部读取完毕之后才会调用mLock.notifyAll() 来释放锁,而 get 方法会在 awaitLoadedLocked 方法中调用 mLock.wait()来等待SP 的初始化完成。所以虽然这是异步方法,但当读取的文件比较大时,还没读取完,接着调用 getXXX() 方法需等待其完成,就可能导致主线程阻塞。
SharedPreference 不能保证类型安全
调用 getXXX() 方法的时候,可能会出现 ClassCastException 异常,因为使用相同的 key 进行操作的时候,putXXX 方法可以使用不同类型的数据覆盖掉相同的 key。
val key = "jetpack"
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE)
sp.edit { putInt(key, 0) } // 使用 Int 类型的数据覆盖相同的 key
sp.getString(key, ""); // 使用相同的 key 读取 Sting 类型的数据
由于 SP 内部是通过Map来保存对于的key-value,所以它并不能保证key-value的类型固定,导致通过get方法来获取对应key的值的类型也是不安全的。
在getString的源码中,会进行类型强制转换,如果类型不对就会导致程序崩溃。由于SP不会在代码编译时进行提醒,只能在代码运行之后才能发现,避免不掉可能发生的异常。
SharedPreference 加载的数据会一直留在内存中,浪费内存
通过 getSharedPreferences() 方法加载的数据,最后会将数据存储在静态的成员变量中。静态的 ArrayMap 缓存每一个 SP 文件,而每个 SP 文件内容通过 Map 缓存键值对数据,这样数据会一直留在内存中,浪费内存。
apply() 方法虽然是异步的,仍可能会发生 ANR
apply 异步提交解决了线程的阻塞问题,但如果 apply 任务过多数据量过大,可能会导致ANR的产生。
apply() 方法不是异步的吗,为什么还会造成 ANR 呢?apply() 方法本身没有问题,但是当生命周期处于 handleStopService() 、 handlePauseActivity() 、 handleStopActivity() 的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。
public void apply() {
final long startTime = System.currentTimeMillis();
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " applied after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
};
// 注意:将awaitCommit添加到队列中
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
// 成功写入磁盘之后才将awaitCommit移除
QueuedWork.removeFinisher(awaitCommit);
}
};
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
notifyListeners(mcr);
}
这里关键点是会将 awaitCommit 加入到 QueuedWork 队列中,只有当 awaitCommit 执行完之后才会进行移除。
另一方面,在 Activity 和 Service 的 handleStopService() 、 handlePauseActivity() 、 handleStopActivity() 中会等待 QueuedWork 中的任务全部完成,一旦 QueuedWork 中的任务非常耗时,例如 SP 的写入磁盘数据量过多,就会导致主线程长时间未响应,从而产生 ANR:
public void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving,
int configChanges, PendingTransactionActions pendingActions, String reason) {
ActivityClientRecord r = mActivities.get(token);
if (r != null) {
if (userLeaving) {
performUserLeavingActivity(r);
}
r.activity.mConfigChangeFlags |= configChanges;
performPauseActivity(r, finished, reason, pendingActions);
// Make sure any pending writes are now committed.
if (r.isPreHoneycomb()) {
//等待任务完成
QueuedWork.waitToFinish();
}
mSomeActivitiesChanged = true;
}
}
SharedPreference 不能跨进程通信
SP 是不能跨进程通信的,虽然在获取 SP 时提供了MODE_MULTI_PROCESS,但内部并不是用来跨进程的。
public SharedPreferences getSharedPreferences(File file, int mode) {
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
// 重新读取SP文件内容
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}
在这里使用 MODE_MULTI_PROCESS 只是重新读取一遍文件而已,并不能保证跨进程通信。
apply() 方法没有结果回调
为了防止 SP 写入时阻塞线程,一般都会使用 apply 方法来将数据异步写入到文件中,但它无法有返回值,也没有对应的结果回调,所以无法得知此次写入结果是成功还是失败。
DataStore 有哪些改进
针对 SP 的几个问题,DataStore 都够能规避。
-
DataStore内部使用kotlin协程通过挂起的方式来避免阻塞线程,避免产生 ANR。 -
DataStore不仅支持 SP 同时还支持protocol buffers类型的存储,protocol buffers是可以保证数据类型安全的。 -
DataStore能够在编译阶段提醒 SP 类型错误,减少写代码时的失误导致类型不安全问题。
-
DataStore使用Flow来获取数据,每次保存数据之后都会通知最近的Flow,可以获得到操作成功或失败的结果。 -
DataStore完美支持 SP 数据的迁移,可以无成本过渡到DataStore。
对比图
SharedPreferences、DataStore、MMKV 的对比:
DataStore 的使用和迁移
Preferences DataStore
添加依赖
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01"
构建 DataStore
private val PREFERENCE_NAME = "DataStore"
var dataStore: DataStore<Preferences> = context.createDataStore(
name = PREFERENCE_NAME
存储位置为 data/data/包名/files/datastore/ + PREFERENCE_NAME + .preferences_pb
读取数据
注意:
Preferences DataStore只支持Int,Long,Boolean,Float,String这几种键值对数据。
val KEY_BYTE_CODE = preferencesKey<Boolean>("ByteCode")
fun readData(key: Preferences.Key<Boolean>): Flow<Boolean> =
dataStore.data
.map { preferences ->
preferences[key] ?: false
}
dataStore.data 会返回一个 Flow<T>,每当数据变化的时候都会重新发出。
写入数据
suspend fun saveData(key: Preferences.Key<Boolean>) {
dataStore.edit { mutablePreferences ->
val value = mutablePreferences[key] ?: false
mutablePreferences[key] = !value
}
}
通过 DataStore.edit() 写入数据的,DataStore.edit() 是一个 suspend 函数,所以只能在协程体内使用。
从 SharedPreferences 迁移
迁移 SharedPreferences 到 DataStore 只需要 2 步。
- 构建
DataStore的时候,需要传入一个SharedPreferencesMigration
dataStore = context.createDataStore(
name = PREFERENCE_NAME,
migrations = listOf(
SharedPreferencesMigration(
context,
SharedPreferencesRepository.PREFERENCE_NAME
)
)
)
- 当
DataStore对象构建完了之后,需要执行一次读取或者写入操作,即可完成SharedPreferences迁移到DataStore,当迁移成功之后,会自动删除SharedPreferences使用的文件。
注意: 只从 SharedPreferences 迁移一次,因此一旦迁移成功之后,应该停止使用 SharedPreferences。
Proto DataStore
Protocol Buffers:是 Google 开源的跨语言编码协议,可以应用到 C++ 、C# 、Dart 、Go 、Java 、Python 等等语言,Google 内部几乎所有 RPC 都在使用这个协议,使用了二进制编码压缩,体积更小,速度比 JSON 更快,但是缺点是牺牲了可读性。
Proto DataStore 通过 protocol buffers 将对象序列化存储在本地,比起 Preference DataStore 支持更多类型,使用二进制编码压缩,体积更小速度更快。使用 Proto DataStore 需要先引入 protocol buffers。
本文只对 Proto DataStore 做简单介绍。
添加依赖
// Proto DataStore
implementation "androidx.datastore:datastore-core:1.0.0-alpha01"
// protobuf
implementation "com.google.protobuf:protobuf-javalite:3.10.0"
当添加完依赖之后需要新建 proto 文件,在本文示例项目中新建了一个 common-protobuf 模块,将新建的 person.proto 文件,放到了 common-protobuf 模块 src/main/proto 目录下。
在 common-protobuf 模块,build.gradle 文件内,添加以下依赖:
implementation "com.google.protobuf:protobuf-javalite:3.10.0"
新建 Person.proto 文件,添加以下内容
syntax = "proto3";
option java_package = "com.hi.dhl.datastore.protobuf";
option java_outer_classname = "PersonProtos";
message Person {
// 格式:字段类型 + 字段名称 + 字段编号
string name = 1;
}
执行 protoc ,编译 proto 文件
protoc --java_out=./src/main/java -I=./src/main/proto ./src/main/proto/*.proto
构建 DataStore
object PersonSerializer : Serializer<PersonProtos.Person> {
override fun readFrom(input: InputStream): PersonProtos.Person {
try {
return PersonProtos.Person.parseFrom(input) // 是编译器自动生成的,用于读取并解析 input 的消息
} catch (exception: Exception) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override fun writeTo(t: PersonProtos.Person, output: OutputStream) = t.writeTo(output) // t.writeTo(output) 是编译器自动生成的,用于写入序列化消息
}
读取数据
fun readData(): Flow<PersonProtos.Person> {
return protoDataStore.data
.catch {
if (it is IOException) {
it.printStackTrace()
emit(PersonProtos.Person.getDefaultInstance())
} else {
throw it
}
}
写入数据
suspend fun saveData(personModel: PersonModel) {
protoDataStore.updateData { person ->
person.toBuilder().setAge(personModel.age).setName(personModel.name).build()
}
}
从 SharedPreferences 迁移
-
创建映射关系
-
构建 DataStore 并传入 shardPrefsMigration
-
执行一次读取或者写入操作
SuperApp引入
SuperApp 当前使用 SP 实现小数据存取,具体由 IPCConfig 工具类封装 SP 提供静态方法供各处使用。鉴于 DataStore 的各项改进及迁移非常方便,可以考虑从 SP 迁移到 DataStore。
Proto DataStore 虽然有更多优势,但需要引入Protocol Buffers,同时开发者需要如 proto 语法等更多的学习成本,使用和迁移也会稍微麻烦些。考虑到现在暂时没有 Proto DataStore 对应的使用场景,可以先迁移到 Preferences DataStore,后续如有需要再做处理。
初步改写 IPCConfig:
const val SHARED_PREFERENCES_NAME = "com.tplink.superapp_preferences"
const val DATA_STORE_NAME = "IPCConfig"
object IPCConfig {
private var mDataStore: DataStore<Preferences>? = null
@JvmStatic
fun putBoolean(context: Context?, key: String?, flag: Boolean) {
setConfig(context, key, flag)
}
@JvmStatic
fun getBoolean(context: Context?, key: String?, defaultValue: Boolean): Boolean {
return getConfig(context, key, defaultValue)
}
@JvmStatic
fun putInt(context: Context?, key: String?, num: Int) {
setConfig(context, key, num)
}
@JvmStatic
fun getInt(context: Context?, key: String?, defaultValue: Int): Int {
return getConfig(context, key, defaultValue)
}
@JvmStatic
fun putString(context: Context?, key: String?, value: String) {
setConfig(context, key, value)
}
@JvmStatic
fun getString(context: Context?, key: String?, defaultValue: String): String {
return getConfig(context, key, defaultValue)
}
@JvmStatic
fun putLong(context: Context?, key: String?, value: Long) {
setConfig(context, key, value)
}
@JvmStatic
fun getLong(context: Context?, key: String?, defaultValue: Long): Long {
return getConfig(context, key, defaultValue)
}
private fun getDataStore(context: Context): DataStore<Preferences>? {
if (mDataStore == null) {
mDataStore = context.createDataStore(
name = DATA_STORE_NAME,
migrations = listOf(
SharedPreferencesMigration(
context,
SHARED_PREFERENCES_NAME
)
)
)
}
return mDataStore
}
private inline fun <reified T : Any> getConfig(
context: Context?,
key: String?,
defaultValue: T
): T {
if (context == null || key == null) {
return defaultValue
}
return runBlocking {
getDataStore(context)?.data
?.catch {
// 当读取数据遇到错误时,如果是IOException异常,发送一个emptyPreferences重新使用
// 但是如果是其他的异常,最好将它抛出去,不要隐藏问题
it.printStackTrace()
if (it is IOException) {
emit(emptyPreferences())
} else {
throw it
}
}?.map {
it[preferencesKey<T>(key)] ?: defaultValue
}?.first() ?: defaultValue
}
}
private inline fun <reified T : Any> setConfig(context: Context?, key: String?, value: T) {
if (context == null || key == null) {
return
}
GlobalScope.launch {
getDataStore(context)?.edit {
it[preferencesKey<T>(key)] = value
}
}
}
}
迁移前后文件结构:
测试可正常使用。
这样修改可以只改变一个文件,各调用处无需变动,就完成到 Preferences DataStore 的迁移,但是 get 方法都是 runBlocking 同步方法,没有使用到 DataStore 的全部功能。这里只是为了简单验证下迁移的可行性和便捷性,后续可以继续优化充分利用好 DataStore 的优势。