最近Jetpack
又增加了新成员,提出了一个关于小型数据存储相关的DataStore
组件。
根据官网的描述,DataStore
完全是对标现有的SharedPreferences
。
SharedPreferences
相信大家都有用过,既然在现有的基础上提出DataStore
那自然是为了解决SharedPreferences
的缺点的。
如果你还不知道SharedPreferences
有什么缺点?没关系,我们正好来复习一遍。你可以对标一下在使用SharedPreferences
的过程中是否也遇到过这些问题。
SharedPreferences的糟心事
为了精简语言,下面都将SharedPreferences
简称sp
一次性读取阻塞主线程
1
| sp = getSharedPreferences("settings_preference", Context.MODE_PRIVATE)
|
在使用sp
的过程中,会通过getSharedPreferences
来初始化sp
。
上面这段代码最终会进入SharedPreferencesImpl
的loadFromDisk
方法。
具体调用就不带大家走一遍了,如果都贴出来文章就变成代码粘贴板了,我们只关注核心逻辑,其它感兴趣的可以自行查看源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| 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
方法,例如getString
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| public String getString(String key, @Nullable String defValue) { synchronized (mLock) { awaitLoadedLocked(); String v = (String)mMap.get(key); return v != null ? v : defValue; } } private void awaitLoadedLocked() { if (!mLoaded) { // Raise an explicit StrictMode onReadFromDisk for this // thread, since the real read will be in a different // thread and otherwise ignored by StrictMode. BlockGuard.getThreadPolicy().onReadFromDisk(); } while (!mLoaded) { try { mLock.wait(); // 等待sp文件读取完毕 } catch (InterruptedException unused) { } } if (mThrowable != null) { throw new IllegalStateException(mThrowable); } }
|
这里会在awaitLoadedLocked
方法中调用mLock.wait()
来等待sp
的初始化完成。
所以如果sp
文件过大,初始化所花的时间过多,会导致后面sp
数据获取时的阻塞。
类型不安全
在我们使用sp
过程中,用的最多的应该是它的put
与get
方法。现在我们用这两个方法来写一段代码
1 2 3 4 5 6 7 8 9 10
| sp = getSharedPreferences("settings_preference", Context.MODE_PRIVATE) // 某一个地方的逻辑 sp.edit().putString("key_name_from_sp", "from sp").apply() // 另一个地方的逻辑 sp.edit().putInt("key_name_from_sp", "from sp").apply() // 获取key_name_from_sp值 sp.getString("key_name_from_sp", "")
|
如果你运行上面的代码你可以发现程序运行异常,本质问题是对同一个key
赋值了不同类型的值。将原来String
类型的值转变成Int
类型。由于sp
内部是通过Map
来保存对于的key-value
,所以它并不能保证key-value
的类型固定,也进一步导致通过get
方法来获取对应key
的值的类型也是不安全的。这就造成了所谓的类型不安全。
1 2 3 4 5 6 7
| public String getString(String key, @Nullable String defValue) { synchronized (mLock) { awaitLoadedLocked(); String v = (String)mMap.get(key); return v != null ? v : defValue; } }
|
在getString
的源码中,会进行类型强制转换,如果类型不对就会导致程序崩溃。由于sp
不会在代码编译时进行提醒,只能在代码运行之后才能发现,所以就避免不掉可能发生的异常,从而导致sp
类型不安全。
apply异步没有回调
为了防止sp
写入时阻塞线程,一般都会使用apply
方法来将数据异步提交到磁盘,即写入到文件中。
虽然apply
是异步,但它并没有返回值,同样也没有对应的结果回调。
1 2 3
| public void apply() { ... }
|
导致ANR
apply
异步提交解决了线程的阻塞问题,但如果apply
任务过多数据量过大,可能会导致ANR
的产生。
ANR
的产生是主线程长时间未响应导致的。apply
不是异步的吗?它怎么又会产生ANR
呢?
来看下apply
的源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| 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
的onPause
与onStop
、Service
的onDestory
中会等待QueuedWork
中的任务全部完成,一旦QueuedWork
中的任务非常耗时,例如sp
的写入磁盘数据量过多,就会导致主线程长时间未响应,从而产生ANR
。
具体调用分别在ActivityThread
中的handlePauseActivity
、handlePauseActivity
与handleStopService
方法中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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; } }
|
那如何解决呢?首先使用sp
不要存储过大的key-value
数据,本身sp
就是轻量的存储,对于大数据还是使用room
来存储。
此类ANR
都是经由QueuedWork.waitToFinish()
触发的,如果在调用此函数之前,将其中保存的队列手动清空,那么是不是能解决问题呢,答案是肯定的。
另外在今日头条的一篇文章中已经提出解决ANR
的方法,具体解决可以自行查看
https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247484387&idx=1&sn=e3c8d6ef52520c51b5e07306d9750e70&scene=21#wechat_redirect
不能跨进程通信
sp
是不能跨进程通信的,虽然在获取sp
的时候提供了MODE_MULTI_PROCESS
,但内部并不是用来跨进程的。
1 2 3 4 5 6 7 8
| 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
只是重新读取一遍文件而已,并不能保证跨进程通信。
上面的sp
问题不知道你在使用的过程中是否有遇到过,或者说有幸中标几条,大家可以留言来对比一下,说出你的故事(此处应该有酒)。
DataStore
针对sp
那几个问题,DataStore
都够能规避。为了精简语言,下面都将DataStore
简称ds
ds
内部使用kotlin
协程通过挂起的方式来避免阻塞线程,同时也避免产生ANR
。
ds
不仅支持sp
同时还支持protocol buffers类型的存储,而protocol buffers
可以保证数据类型安全。
ds
能够在编译阶段提醒sp
类型错误,保证sp
类型的类型不安全问题。
ds
使用Flow
来获取数据,每次保存数据之后都会通知最近的Flow
。
ds
完美支持sp
数据的迁移,你可以无成本过渡到ds
。
所以ds
将会是Android
后续轻量数据存储的首选组件。我们也是时候来了解ds
的使用。
引入DataStore
首先我们要引入ds
,方式很简单直接在build
中添加依赖即可。唯一需要注意的是ds
支持sp
与protocol buffers
两种类型,所以对应的也有两种依赖。
1 2 3 4 5
| // Preferences DataStore implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01" // Proto DataStore implementation "androidx.datastore:datastore-core:1.0.0-alpha01"
|
下面针对这两种类型分别做介绍。
创建DataStore
针对sp
类型的数据,ds
只需通过createDataStore
方法来获取对应的ds
对象
1
| private val dataStore = createDataStore("settings")
|
其中settings
为对应的文件名,存储方式为datastore/ + name + .preferences_pb
protocol buffers
类型需要额外实现Serializer
接口,提供读写的入口。
1 2 3 4 5 6 7 8 9 10 11 12 13
| object SettingsSerializer : Serializer<Settings> { override fun readFrom(input: InputStream): Settings { return Settings.parseFrom(input) } override fun writeTo(t: Settings, output: OutputStream) { t.writeTo(output) } } private val dataStoreProto = createDataStore("settings.pb", SettingsSerializer)
|
其中的Settings
类是通过protocol buffers
脚本自动生成的。要生成Settings
类,你需要做两件事
- 配置
protocol buffers
环境
- 编写
.proto
文件
所以你可能需要懂一点protocol buffers
相关的语法。
如果后续有空,可能会单独开文章介绍一下protocol buffers
相关的内容,大厂用的基本上都是protocol buffers
。
1 2 3 4 5 6 7
| syntax = "proto3"; option java_multiple_files = true; message Settings { string key_name = 1; }
|
使用protocol buffers
运行上面的代码就能自动帮我们生成对应的Settings
类。其中它里面的一个变量就是keyName_
,它是String
类型。通过创建类与对应变量的方式来约定类型的安全。
读
sp
与protocol buffers
类型的读操作使用方式都一样,首先都要创建Preferences.Key
类型的key
。
1
| val DATA_KEY = preferencesKey<String>("key_name")
|
对应的preferencesKey
如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| inline fun <reified T : Any> preferencesKey(name: String): Preferences.Key<T> { return when (T::class) { Int::class -> { Preferences.Key<T>(name) } String::class -> { Preferences.Key<T>(name) } Boolean::class -> { Preferences.Key<T>(name) } Float::class -> { Preferences.Key<T>(name) } Long::class -> { Preferences.Key<T>(name) } Set::class -> { throw IllegalArgumentException("Use `preferencesSetKey` to create keys for Sets.") } else -> { throw IllegalArgumentException("Type not supported: ${T::class.java}") } } }
|
它支持Int
、String
、Boolean
、Float
与Long
类型的数据,另外还有一个preferencesSetKey
,用来支持set
类型的数据。
调用preferencesKey
每次都创建一个Preferences.Key
对象,那它这样如何保证是同一个key
呢?
如果你去看源码就会一目了然。
1 2 3 4 5 6 7 8 9 10 11 12
| internal constructor(val name: String) { override fun equals(other: Any?) = if (other is Key<*>) { name == other.name } else { false } override fun hashCode(): Int { return name.hashCode() } }
|
原来是它重写了equals
方法,内部实现对name
的比较。那么只要创建preferencesKey
时传入的name
相同,就能保证获取到的是同一个key
的数据。
有了key
,再来通过dataStore.data.map
来获取Flow
,同时暴露出对应的Preferences
1 2 3 4 5 6 7 8 9 10 11 12
| private suspend fun read() { dataStore.data.map { // unSafe type if (it[DATA_KEY] is String) { it[DATA_KEY] ?: "" } else { "type is String: ${it[DATA_KEY] is String}" } }.collect { Toast.makeText(this@DataStoreActivity, "read result: $it", Toast.LENGTH_LONG).show() } }
|
同时在read
中写了一个验证SharedPreference
类型不安全的示例。如果在别的地方赋值了DATA_KEY
非String
类型的数据时,将会弹出else
中的语句。
下面是protocol buffers
的读取
1 2 3 4 5 6 7 8
| private suspend fun protoRead() { dataStoreProto.data.map { // safe type it.keyName }.collect { Toast.makeText(this, "read result success form proto: $it", Toast.LENGTH_LONG).show() } }
|
需要注意的是这里获取的数据就是类型安全的。这里的it
对应的就是在ds
创建时产生的Settings
。
写
写sp
与protocol buffers
有所不同。
对于sp
直接使用dataStore.edit
来写入数据
1 2 3 4 5 6
| private suspend fun write(value: String) { dataStore.edit { it[DATA_KEY] = value LogUtils.d("dataStore write: $value") } }
|
而protocol buffers
使用的是updateData
方法
1 2 3 4 5
| private suspend fun protoWrite(value: String) { dataStoreProto.updateData { it.toBuilder().setKeyName(value).build() } }
|
迁移SharedPreferences
迁移也分为两种,一种是迁移到ds
的sp
中;另一种是迁移到protocol buffers
中。
具体来看,如果迁移到ds
的sp
中,只需在之前创建ds
基础上额外再加一个migrations
参数。
1
| private val dataStore = createDataStore("settings", migrations = listOf(SharedPreferencesMigration(this, "settings_preference")))
|
通过创建SharedPreferencesMigration
来迁移对应的sp
数据。
下面是迁移到protocol buffers
中
1 2 3 4 5 6 7 8 9 10 11 12
| val settingsDataStore: DataStore<Settings> = context.createDataStore( produceFile = { File(context.filesDir, "settings.preferences_pb") }, serializer = SettingsSerializer, migrations = listOf( SharedPreferencesMigration( context, "settings_preferences" ) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences -> // Map your sharedPrefs to your type here } ) )
|
迁移完之后需要执行一次代码,同时应该停止再次使用sp
。如果迁移成功将会删除之前sp
的.xml
类型的文件,生成对应ds
文件。
最后附上一张Google
分析的SharedPreferences
和DataStore
的区别图

目前可以看到DataStore
还处在alpha
版本,非常期待它之后的正式版本。
另外,针对DataStore
的使用,我写了一个demo
,大家可以在android-api-analysis中获取。
项目
android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件。开发人员可以使用android-startup
来简化启动序列,并显式地设置初始化顺序与组件之间的依赖关系。 与此同时android-startup
支持同步与异步等待,并通过有向无环图拓扑排序的方式来保证内部依赖组件的初始化顺序。
AwesomeGithub: 基于Github
客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin
语言进行开发,项目架构是基于Jetpack&DataBinding
的MVVM
;项目中使用了Arouter
、Retrofit
、Coroutine
、Glide
、Dagger
与Hilt
等流行开源技术。
flutter_github: 基于Flutter
的跨平台版本Github
客户端,与AwesomeGithub
相对应。
android-api-analysis: 结合详细的Demo
来全面解析Android
相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。
daily_algorithm: 每日一算法,由浅入深,欢迎加入一起共勉。