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 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) 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 = 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("ByteCode")
fun readData(key: Preferences.Key): Flow =
dataStore.data
.map { preferences ->
preferences[key] ?: false
}
dataStore.data
会返回一个 Flow
,每当数据变化的时候都会重新发出。
写入数据
suspend fun saveData(key: Preferences.Key) {
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 {
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 {
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? = 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? {
if (mDataStore == null) {
mDataStore = context.createDataStore(
name = DATA_STORE_NAME,
migrations = listOf(
SharedPreferencesMigration(
context,
SHARED_PREFERENCES_NAME
)
)
)
}
return mDataStore
}
private inline fun 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(key)] ?: defaultValue
}?.first() ?: defaultValue
}
}
private inline fun setConfig(context: Context?, key: String?, value: T) {
if (context == null || key == null) {
return
}
GlobalScope.launch {
getDataStore(context)?.edit {
it[preferencesKey(key)] = value
}
}
}
}
迁移前后文件结构:
测试可正常使用。
这样修改可以只改变一个文件,各调用处无需变动,就完成到 Preferences DataStore
的迁移,但是 get
方法都是 runBlocking
同步方法,没有使用到 DataStore
的全部功能。这里只是为了简单验证下迁移的可行性和便捷性,后续可以继续优化充分利用好 DataStore
的优势。