Google 新增加了一个新 Jetpack 的成员 DataStore,主要用来替换 SharedPreferences, DataStore 应该是开发者期待已久的库,DataStore 是基于 Flow 实现的,一种新的数据存储方案,它提供了两种实现方式:
- Proto DataStore:存储类的对象(typed objects ),通过 protocol buffers 将对象序列化存储在本地,protocol buffers 现在已经应用的非常广泛,无论是微信还是阿里等等大厂都在使用,我们在部分业务场景中也用到了 protocol buffers,会在后续的文章详细分析
- Preferences DataStore:以键值对的形式存储在本地和 SharedPreferences 类似,但是 DataStore 是基于 Flow 实现的,不会阻塞主线程,并且保证类型安全
因此 Jetpack DataStore 将会分为 2 篇文章来分析:
- 再见 SharedPreferences 拥抱 Jetpack DataStore (一) : 主要来分析 Preferences DataStore
- 再见 SharedPreferences 拥抱 Jetpack DataStore (二) : 主要来分析 Proto DataStore
今天这篇文章主要来介绍 Jetpack DataStore 其中一种实现方式 Preferences DataStore,文章中的示例代码,已经上传到 GitHub 欢迎前去查看 AndroidX-Jetpack-Practice/DataStoreSimple
GitHub 地址:https://github.com/hi-dhl/AndroidX-Jetpack-Practice
这篇文章会涉及到 Koltin flow 相关内容,如果不了解可以先去看另外一篇文章 Kotlin Flow 是什么?Channel 是什么?
通过这篇文章你将学习到以下内容:
- 那些年我们所经历的 SharedPreferences 坑?
- 为什么需要 DataStore?它为我们解决了什么问题?
- 如何在项目中使用 DataStore?
- 如何迁移 SharedPreferences 到 DataStore?
- MMKV、DataStore、SharedPreferences 的不同之处?
一个新库的出现必定为我们解决了一些问题,那么 Jetpack DataStore 为我们解决什么问题呢,在分析之前,我们需要先来了解 SharedPreferences 都有那些坑。
那些年我们所经历的 SharedPreferences 坑
SharedPreference 是一个轻量级的数据存储方式,使用起来也非常方便,以键值对的形式存储在本地,初始化 SharedPreference 的时候,会将整个文件内容加载内存中,因此会带来以下问题:
- 通过
getXXX()
方法获取数据,可能会导致主线程阻塞 - SharedPreference 不能保证类型安全
- SharedPreference 加载的数据会一直留在内存中,浪费内存
apply()
方法虽然是异步的,可能会发生 ANR,在 8.0 之前和 8.0 之后实现各不相同apply()
方法无法获取到操作成功或者失败的结果
接下来我们逐个来分析一下 SharedPreferences 带来的这些问题,在文章中 SharedPreference 简称 SP。
getXXX()
方法可能会导致主线程阻塞
所有 getXXX()
方法都是同步的,在主线程调用 get
方法,必须等待 SP 加载完毕,会导致主线程阻塞,下面的代码,我相信小伙伴们并不陌生。
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) // 异步加载 SP 文件内容 sp.getString("jetpack", ""); // 等待 SP 加载完毕
调用 getSharedPreferences()
方法,最终会调用 SharedPreferencesImpl#startLoadFromDisk()
方法开启一个线程异步读取数据。
frameworks/base/core/java/android/app/SharedPreferencesImpl.java
private final Object mLock = new Object(); private boolean mLoaded = false; private void startLoadFromDisk() { synchronized (mLock) { mLoaded = false; } new Thread("SharedPreferencesImpl-load") { public void run() { loadFromDisk(); } }.start(); }
正如你所看到的,开启一个线程异步读取数据,当我们正在读取一个比较大的数据,还没读取完,接着调用 getXXX()
方法。
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() { ...... while (!mLoaded) { try { mLock.wait(); } catch (InterruptedException unused) { } } ...... }
在同步方法内调用了 wait()
方法,会一直等待 getSharedPreferences()
方法开启的线程读取完数据才能继续往下执行,如果读取几 KB 的数据还好,假设读取一个大的文件,势必会造成主线程阻塞。
SP 不能保证类型安全
调用 getXXX()
方法的时候,可能会出现 ClassCastException 异常,因为使用相同的 key 进行操作的时候,putXXX
方法可以使用不同类型的数据覆盖掉相同的 key。
val key = "jetpack" val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) // 异步加载 SP 文件内容 sp.edit { putInt(key, 0) } // 使用 Int 类型的数据覆盖相同的 key sp.getString(key, ""); // 使用相同的 key 读取 Sting 类型的数据
使用 Int 类型的数据覆盖掉相同的 key,然后使用相同的 key 读取 Sting 类型的数据,编译正常,但是运行会出现以下异常。
java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
SP 加载的数据会一直留在内存中
通过 getSharedPreferences()
方法加载的数据,最后会将数据存储在静态的成员变量中。
// 调用 getSharedPreferences 方法,最后会调用 getSharedPreferencesCacheLocked 方法 public SharedPreferences getSharedPreferences(File file, int mode) { ...... final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked(); return sp; } // 通过静态的 ArrayMap 缓存 SP 加载的数据 private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache; // 将数据保存在 sSharedPrefsCache 中 private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() { ...... ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName); if (packagePrefs == null) { packagePrefs = new ArrayMap<>(); sSharedPrefsCache.put(packageName, packagePrefs); } return packagePrefs; }
通过静态的 ArrayMap 缓存每一个 SP 文件,而每个 SP 文件内容通过 Map 缓存键值对数据,这样数据会一直留在内存中,浪费内存。
apply()
方法是异步的,可能会发生 ANR
apply()
方法是异步的,为什么还会造成 ANR 呢?曾今的字节跳动就出现过这个问题,具体详情可以点击这里前去查看 剖析 SharedPreference apply 引起的 ANR 问题 而且 Google 也明确指出了 apply()
的问题。
简单总结一下:apply()
方法是异步的,本身是不会有任何问题,但是当生命周期处于 handleStopService()
、 handlePauseActivity()
、 handleStopActivity()
的时候会一直等待 apply()
方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR,一起来分析一下为什么异步方法还会阻塞主线程,先来看看 apply()
方法的实现。
frameworks/base/core/java/android/app/SharedPreferencesImpl.java
public void apply() { final long startTime = System.currentTimeMillis(); final MemoryCommitResult mcr = commitToMemory(); final Runnable awaitCommit = new Runnable() { @Override public void run() { mcr.writtenToDiskLatch.await(); // 等待 ...... } }; // 将 awaitCommit 添加到队列 QueuedWork 中 QueuedWork.addFinisher(awaitCommit); Runnable postWriteRunnable = new Runnable() { @Override public void run() { awaitCommit.run(); QueuedWork.removeFinisher(awaitCommit); } }; // 8.0 之前加入到一个单线程的线程池中执行 // 8.0 之后加入 HandlerThread 中执行写入任务 SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable); }
- 将一个 awaitCommit 的 Runnable 任务,添加到队列 QueuedWork 中,在 awaitCommit 中会调用
await()
方法等待,在handleStopService
、handleStopActivity
等等生命周期会以这个作为判断条件,等待任务执行完毕 - 将一个 postWriteRunnable 的 Runnable 写任务,通过
enqueueDiskWrite
方法,将写入任务加入到队列中,而写入任务在一个线程中执行
注意:在 8.0 之前和 8.0 之后 enqueueDiskWrite()
方法实现逻辑各不相同
在 8.0 之前调用 enqueueDiskWrite()
方法,将写入任务加入到 单个线程的线程池 中执行,如果 apply()
多次的话,任务将会依次执行,效率很低,android-7.0.0_r34 源码如下所示。
// android-7.0.0_r34: frameworks/base/core/java/android/app/SharedPreferencesImpl.java private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) { ...... QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable); } // android-7.0.0_r34: frameworks/base/core/java/android/app/QueuedWork.java public static ExecutorService singleThreadExecutor() { synchronized (QueuedWork.class) { if (sSingleThreadExecutor == null) { sSingleThreadExecutor = Executors.newSingleThreadExecutor(); } return sSingleThreadExecutor; } }
通过 Executors.newSingleThreadExecutor()
方法创建了一个 单个线程的线程池,因此任务是串行的,通过 apply()
方法创建的任务,都会添加到这个线程池内。
在 8.0 之后将写入任务加入到 LinkedList 链表中,在 HandlerThread 中执行写入任务,android-10.0.0_r14 源码如下所示。
// android-10.0.0_r14: frameworks/base/core/java/android/app/SharedPreferencesImpl.java private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) { ...... QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit); } // android-10.0.0_r14: frameworks/base/core/java/android/app/QueuedWork.java private static final LinkedList<Runnable> sWork = new LinkedList<>(); public static void queue(Runnable work, boolean shouldDelay) { Handler handler = getHandler(); // 获取 handlerThread.getLooper() 生成 Handler 对象 synchronized (sLock) { sWork.add(work); // 将写入任务加入到 LinkedList 链表中 if (shouldDelay && sCanDelay) { handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY); } else { handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN); } } }
在 8.0 之后通过调用 handlerThread.getLooper()
方法生成 Handler,任务都会在 HandlerThread 中执行,所有通过 apply()
方法创建的任务,都会添加到 LinkedList 链表中。
当生命周期处于 handleStopService()
、 handlePauseActivity()
、 handleStopActivity()
的时候会调用 QueuedWork.waitToFinish()
会等待写入任务执行完毕,我们以其中 handlePauseActivity()
方法为例。
public void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving, int configChanges, PendingTransactionActions pendingActions, String reason) { ...... // 确保写任务都已经完成 QueuedWork.waitToFinish(); ...... } }
正如你所看到的在 handlePauseActivity()
方法中,调用了 QueuedWork.waitToFinish()
方法,会等待所有的写入执行完毕,Google 在 8.0 之后对这个方法做了很大的优化,一起来看一下 8.0 之前和 8.0 之后的区别。
注意:在 8.0 之前和 8.0 之后 waitToFinish()
方法实现逻辑各不相同
在 8.0 之前 waitToFinish()
方法只做了一件事,会一直等待写入任务执行完毕,我先来看看在 android-7.0.0_r34 源码实现。
android-7.0.0_r34: frameworks/base/core/java/android/app/QueuedWork.java
private static final ConcurrentLinkedQueue<Runnable> sPendingWorkFinishers = new ConcurrentLinkedQueue<Runnable>(); public static void waitToFinish() { Runnable toFinish; while ((toFinish = sPendingWorkFinishers.poll()) != null) { toFinish.run(); // 相当于调用 `mcr.writtenToDiskLatch.await()` 方法 } }
sPendingWorkFinishers
是 ConcurrentLinkedQueue 实例,apply
方法会将写入任务添加到sPendingWorkFinishers
队列中,在 单个线程的线程池 中执行写入任务,线程的调度并不由程序来控制,也就是说当生命周期切换的时候,任务不一定处于执行状态toFinish.run()
方法,相当于调用mcr.writtenToDiskLatch.await()
方法,会一直等待waitToFinish()
方法就做了一件事,会一直等待写入任务执行完毕,其它什么都不做,当有很多写入任务,会依次执行,当文件很大时,效率很低,造成 ANR 就不奇怪了,尤其像字节跳动这种大规模的 App
在 8.0 之后 waitToFinish()
方法做了很大的优化,当生命周期切换的时候,会主动触发任务的执行,而不是一直在等着,我们来看看 android-10.0.0_r14 源码实现。
android-10.0.0_r14: frameworks/base/core/java/android/app/QueuedWork.java
private static final LinkedList<Runnable> sFinishers = new LinkedList<>(); public static void waitToFinish() { ...... try { processPendingWork(); // 主动触发任务的执行 } finally { StrictMode.setThreadPolicy(oldPolicy); } try { // 等待任务执行完毕 while (true) { Runnable finisher; synchronized (sLock) { finisher = sFinishers.poll(); // 从 LinkedList 中取出任务 } if (finisher == null) { // 当 LinkedList 中没有任务时会跳出循环 break; } finisher.run(); // 相当于调用 `mcr.writtenToDiskLatch.await()` } } ...... }
在 waitToFinish()
方法中会主动调用 processPendingWork()
方法触发任务的执行,在 HandlerThread 中执行写入任务。
另外还做了一个很重要的优化,当调用 apply()
方法的时候,执行磁盘写入,都是全量写入,在 8.0 之前,调用 N 次 apply()
方法,就会执行 N 次磁盘写入,在 8.0 之后,apply()
方法调用了多次,只会执行最后一次写入,通过版本号来控制的。
SharedPreferences 的另外一个缺点就是 apply()
方法无法获取到操作成功或者失败的结果,而 commit()
方法是可以接收 MemoryCommitResult 里面的一个 boolean 参数作为结果,来看一下它们的方法签名。
public void apply() { ... } public boolean commit() { ... }
SP 不能用于跨进程通信
我们在创建 SP 实例的时候,需要传入一个 mode
,如下所示:
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE)
Context 内部还有一个 mode
是 MODE_MULTI_PROCESS
,我们来看一下这个 mode
做了什么
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 文件内容,并不能用 SP 来做跨进程通信。
到这里关于 SharedPreferences 部分分析完了,接下来分析一下 DataStore 为我们解决什么问题?
DataStore 解决了什么问题
Preferences DataStore 主要用来替换 SharedPreferences,Preferences DataStore 解决了 SharedPreferences 带来的所有问题
Preferences DataStore 相比于 SharedPreferences 优点
- DataStore 是基于 Flow 实现的,所以保证了在主线程的安全性
- 以事务方式处理更新数据,事务有四大特性(原子性、一致性、 隔离性、持久性)
- 没有
apply()
和commit()
等等数据持久的方法 - 自动完成 SharedPreferences 迁移到 DataStore,保证数据一致性,不会造成数据损坏
- 可以监听到操作成功或者失败结果
另外 Jetpack DataStore 提供了 Proto DataStore 方式,用于存储类的对象(typed objects ),通过 protocol buffers 将对象序列化存储在本地,protocol buffers 现在已经应用的非常广泛,无论是微信还是阿里等等大厂都在使用,我们在部分场景中也使用了 protocol buffers,在后续的文章会详细的分析。
注意:
Preferences DataStore 只支持 Int
, Long
, Boolean
, Float
, String
键值对数据,适合存储简单、小型的数据,并且不支持局部更新,如果修改了其中一个值,整个文件内容将会被重新序列化,可以运行 AndroidX-Jetpack-Practice/DataStoreSimple 体验一下,如果需要局部更新,建议使用 Room。
在项目中使用 Preferences DataStore
Preferences DataStore 主要应用在 MVVM 当中的 Repository 层,在项目中使用 Preferences DataStore 非常简单,只需要 4 步。
1. 需要添加 Preferences DataStore 依赖
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01"
2. 构建 DataStore
private val PREFERENCE_NAME = "DataStore" var dataStore: DataStore<Preferences> = context.createDataStore( name = PREFERENCE_NAME
3. 从 Preferences DataStore 中读取数据
Preferences DataStore 以键值对的形式存储在本地,所以首先我们应该定义一个 Key.
val KEY_BYTE_CODE = preferencesKey<Boolean>("ByteCode")
这里和我们之前使用 SharedPreferences 的有点不一样,在 Preferences DataStore 中 Key 是一个 Preferences.Key<T>
类型,只支持 Int
, Long
, Boolean
, Float
, String
,源码如下所示:
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) } ...... // 如果是其他类型就会抛出异常 } }
当我们定义好 Key 之后,就可以通过 dataStore.data
来获取数据
override fun readData(key: Preferences.Key<Boolean>): Flow<Boolean> = dataStore.data .catch { // 当读取数据遇到错误时,如果是 `IOException` 异常,发送一个 emptyPreferences 来重新使用 // 但是如果是其他的异常,最好将它抛出去,不要隐藏问题 if (it is IOException) { it.printStackTrace() emit(emptyPreferences()) } else { throw it } }.map { preferences -> preferences[key] ?: false }
- Preferences DataStore 是基于 Flow 实现的,所以通过
dataStore.data
会返回一个Flow<T>
,每当数据变化的时候都会重新发出 catch
用来捕获异常,当读取数据出现异常时会抛出一个异常,如果是IOException
异常,会发送一个emptyPreferences()
来重新使用,如果是其他异常,最好将它抛出去
4. 向 Preferences DataStore 中写入数据
在 Preferences DataStore 中是通过 DataStore.edit()
写入数据的,DataStore.edit()
是一个 suspend 函数,所以只能在协程体内使用,每当遇到 suspend 函数以挂起的方式运行,并不会阻塞主线程。
以挂起的方式运行,不会阻塞主线程 :也就是协程作用域被挂起, 当前线程中协程作用域之外的代码不会阻塞。
首先我们需要创建一个 suspend 函数,然后调用 DataStore.edit()
写入数据即可。
override suspend fun saveData(key: Preferences.Key<Boolean>) { dataStore.edit { mutablePreferences -> val value = mutablePreferences[key] ?: false mutablePreferences[key] = !value } }
到这里关于 Preferences DataStore 读取数据和写入数据就已经分析完了,接下来分析一下如何迁移 SharedPreferences 到 DataStore。
迁移 SharedPreferences 到 DataStore
迁移 SharedPreferences 到 DataStore 只需要 2 步。
- 在构建 DataStore 的时候,需要传入一个 SharedPreferencesMigration
dataStore = context.createDataStore( name = PREFERENCE_NAME, migrations = listOf( SharedPreferencesMigration( context, SharedPreferencesRepository.PREFERENCE_NAME ) ) )
- 当 DataStore 对象构建完了之后,需要执行一次读取或者写入操作,即可完成 SharedPreferences 迁移到 DataStore,当迁移成功之后,会自动删除 SharedPreferences 使用的文件
注意: 只从 SharedPreferences 迁移一次,因此一旦迁移成功之后,应该停止使用 SharedPreferences。
相比于 MMKV 有什么不同之处
最后用一张表格来对比一下 MMKV、DataStore、SharedPreferences 的不同之处,如果发现错误,或者有其他不同之处,期待你来一起完善。
另外在附上一张 Google 分析的 SharedPreferences 和 DataStore 的区别
全文到这里就结束了,这篇文章主要分析了 SharedPreferences 和 DataStore 的优缺点,以及为什么需要引入 DataStore 和如何使用 DataStore,为了节省篇幅源码分析部分会在后续的文章中分析。
关于 SharedPreferences 和 DataStore 相关的代码,已经上传到了 GitHub 欢迎前去查看 AndroidX-Jetpack-Practice/DataStoreSimple ,可以运行一下示例项目,体验一下 SharedPreferences 和 DataStore 效果。
参考文献
- Preferences DataStore codelab
- Now in Android #25
- Prefer Storing Data with Jetpack DataStore
- 剖析 SharedPreference 引起的 ANR 问题
- SharedPreferences 问题分析和解决
结语
关注公众号:ByteCode,查看一系列 Android 系统源码、逆向分析、算法、译文、Kotlin、Jetpack 源码相关的文章,如果这篇文章对你有帮助,请帮我点个 star,感谢!!!,欢迎一起来学习,在技术的道路上一起前进。
在国庆期间我梳理了 LeetCode / 剑指 offer 及国内外大厂面试题解,截止到目前为止我已经在 LeetCode 上 AC 了 124+ 题,每题都会用 Java 和 kotlin 去实现,并且每题都有多种解法、解题思路、时间复杂度、空间复杂度分析,题库逐渐完善中,欢迎前去查看。
最后推荐我一直在更新维护的项目和网站:
- 计划建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,欢迎前去查看:AndroidX-Jetpack-Practice
- LeetCode / 剑指 Offer / 国内外大厂面试题,涵盖: 多线程、数组、栈、队列、字符串、链表、树,查找算法、搜索算法、位运算、排序等等,每道题目都会用 Java 和 kotlin 去实现,仓库持续更新,欢迎前去查看 Leetcode-Solutions-with-Java-And-Kotlin,剑指 offer 及国内外大厂面试题解:在线阅读,LeetCode 系列题解:在线阅读
- 最新 Android 10 源码分析系列文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,仓库持续更新,欢迎前去查看 Android10-Source-Analysis
- 整理和翻译一系列精选国外的技术文章,每篇文章都会有译者思考部分,对原文的更加深入的解读,仓库持续更新,欢迎前去查看 Technical-Article-Translation
- 「为互联网人而设计,国内国外名站导航」涵括新闻、体育、生活、娱乐、设计、产品、运营、前端开发、Android 开发等等网址,欢迎前去查看 为互联网人而设计导航网站
精选文章
- 为数不多的人知道的 Kotlin 技巧以及 原理解析(一)
- 为数不多的人知道的 Kotlin 技巧以及 原理解析(二)
- 为数不多的人知道的 AndroidStudio 快捷键(一)
- 为数不多的人知道的 AndroidStudio 快捷键(二)
- 再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度
- Jetpack 最新成员 AndroidX App Startup 实践以及原理分析
- Jetpack 成员 Paging3 实践以及源码分析(一)
- Jetpack 新成员 Paging3 网络实践及原理分析(二)
- Jetpack 新成员 Hilt 实践(一)启程过坑记
- Jetpack 新成员 Hilt 实践之 App Startup(二)进阶篇
- Jetpack 新成员 Hilt 与 Dagger 大不同(三)落地篇
- 全方面分析 Hilt 和 Koin 性能
- 神奇宝贝(PokemonGo) 眼前一亮的 Jetpack + MVVM 极简实战
- Google 推荐在项目中使用 sealed 和 RemoteMediator
- Kotlin Sealed 是什么?为什么 Google 都用
- Kotlin StateFlow 搜索功能的实践 DB + NetWork