Android 之不要滥用 SharedPreferences(下)

闪存
Android 存储优化系列专题
  • SharedPreferences 系列

Android 之不要滥用 SharedPreferences
Android 之不要滥用 SharedPreferences(2)— 数据丢失

  • ContentProvider 系列(待更)

《Android 存储选项之 ContentProvider 启动过程源码分析》
《Android 存储选项之 ContentProvider 深入分析》

  • 对象序列化系列

Android 对象序列化之你不知道的 Serializable
Android 对象序列化之 Parcelable 深入分析
Android 对象序列化之追求完美的 Serial

  • 数据序列化系列(待更)

《Android 数据序列化之 JSON》
《Android 数据序列化之 Protocol Buffer 使用》
《Android 数据序列化之 Protocol Buffer 源码分析》

  • SQLite 存储系列

Android 存储选项之 SQLiteDatabase 创建过程源码分析
Android 存储选项之 SQLiteDatabase 源码分析
数据库连接池 SQLiteConnectionPool 源码分析
SQLiteDatabase 启用事务源码分析
SQLite 数据库 WAL 模式工作原理简介
SQLite 数据库锁机制与事务简介
SQLite 数据库优化那些事儿


在上篇《Android 之不要滥用 SharedPreferences》一文,详细为大家分析了关于 SharedPreferences 存储机制以及对它的不当使用,可能引发的“严重后果”。本文也是建立在该基础之上进一步对 SharedPreferences 可能导致数据丢失场景进行分析。如果你对 SharedPreferences 机制还不熟悉的话,可以先去参考下。

先来简单回顾下:SharedPreferences 是 Android 中比较常用的存储方法,它可以用来存储一些比较小的键值对集合。虽然 SharedPreferences 使用非常简便,但也是我们诟病比较多的存储方法。它的性能问题比较多,我可以轻松说出它的“几宗罪”。

  1. 跨进程不安全。由于没有使用跨进程的锁,就算使用 MODE_MULTI_PROCESS,SharedPreferences 在跨进程频繁读写有可能导致数据全部丢失。根据线上统计,SharedPreferences 大约会有万分之一的损坏率。

  2. 加载缓慢。SharedPreferences 文件的加载使用了异步线程,而且加载线程并没有设置优先级,如果这个时候读取数据就需要等待文件加载线程的结束。这就导致主线程等待低优先线程锁的问题,比如一个 100KB 的 SP 文件读取等待时间大约需要 50 ~ 100ms,并且建议大家提前用预加载启动过程用到的 SP 文件。

  3. 全量写入。无论是 commit() 还是 apply(),即使我们只改动其中一个条目,都会把整个内容全部写到文件。而且即使我们多次写同一个文件,SP 也没有将多次修改合并为一次,这也是性能差的重要原因之一。

  4. 卡顿。由于提供了异步落盘的 apply 机制,在崩溃或者其它一些异常情况可能会导致数据丢失。所以当应用收到系统广播,或者被调用 onPause 等一些时机,系统会强制把所有的 SharedPreferences 对象的数据落地到磁盘。如果没有落地完成,这时候主线程会被一直阻塞。这样非常容易造成卡顿,甚至是ANR,从线上数据来看 SP 卡顿占比一般会超过 5%。

坦白来讲,系统提供的 SharedPreferences 的应用场景是用来存储一些非常简单、轻量的数据。我们不要使用它存储过于复杂的数据,例如 HTML、JSON 等。而且 SharedPreferences 的文件存储性能与文件大小有关,每个 SP 文件不能过大,我们不要将毫无关联的配置项保存在同一个文件中,同时考虑将频繁修改的条目单独隔离出来。

数据丢失分析

SharedPrefenerces 提供了线程安全操作(内部有大量Synchronized方法),但是并不能保证跨进程数据的安全,也就是在跨进程访问时可能会导致文件损坏(但并不局限于多进程场景)。

1、疑问:文件为什么会损坏?

为什么会文件损坏?在回答该问题之前先要明确一下什么是文件损坏?一个文件的格式或者内容,如果没有按照应用程序写入的结果都属于文件损坏。它不只是文件格式错误,文件内容丢失可能才是最常出现的,SharedPreferences 跨进程读写就非常容易出现数据丢失的情况。

我们可以从应用程序、文件系统和磁盘三个角度来审视这个问题。

  • 应用程序。大部分的 I/O 方法都不是原子操作的,文件的跨进程或者多线程写入、使用一个已经关闭的文件描述符 fd 来操作文件,它们都有可能导致数据被覆盖或者删除。事实上,大部分的文件损坏都是因为应用程序代码设计考虑不当导致的,并不是文件系统或者磁盘的问题。

  • 文件系统。虽说内核崩溃或者系统突然断电都有可能导致文件系统损坏,不过文件系统也做了很多的保护措施。例如 system 分区保证只读不可写,增加异常检查和恢复机制等。

在文件系统这一层,更多是因为断电而导致的写入丢失。为了提升 I/O 性能,文件系统把数据写入到 Page Cache 中,然后等待合适的时机才会真正的写入磁盘。当然我们也可以通过 fsync、msync 这些接口强制写入磁盘。(SharedPreferences 在落盘时就使用了sync 机制)

  • 磁盘。手机上使用的闪存是电子式的存储设备,所以资料传输过程可能会发生电子遗失等现象导致数据错误。不过闪存也会使用 ECC、多级编码等方式增加数据的可靠性,一般来说出现这种情况的可能性比较小。

接下来还是结合源码的角度与大家一起重点探讨下 SharedPreferences 的落盘机制:

2、SharedPreferences 的备份文件

再回顾下我们通过 Context.getSharedPreferences(name),得到的实际类型是:SharedPreferencesImpl。有关 SharedPreferencesImpl 的机制在上篇文章中已经详细分析过。SharedPreferencesImpl 的构造方法,如下图:

SharedPreferenceImpl 的构造方法

注意源码中 mBackupFile 变量,本文也是重点围绕该变量进行分析。

创建 SharedPreferences 备份文件

从这可以看出,mBackupFile 是原始文件的备份文件,如:.../config.xml.bak(config 为 SharedPreferences 的文件名)。

3、mBackupFile 备份文件的作用

无论我们使用 SharedPreferences 的 commit() 或 apply() 提交数据,都会调用到 writeToFile 方法:


提交流程

这里只给大家简单贴下调用栈,commit 提交方法如下:

commit 提交

enqueueDiskWrite 方法如下:

enqueueDiskWrite 方法

可以看到 wirteToFile 方法的调用时机,这也是我们要重点追踪的方法。wirteToFile 方法的作用是将我们前面一系列的 putXxx 或 remove 后的数据落盘到存储设备(在移动设备一般指的是 Flash 闪存)。

4、写入文件分析

由于 writeToFile 方法内容较多,我们分上下两个部分分析:

writeToFile 方法,执行数据落盘

省去部分日志代码,代码中也标注了详细的注释:

首先如果源文件存在(SharedPreferences 文件,这里相对它的备份文件而言),判断如果要写入的数据是否真正发生变化,如果未发生变化则直接 return,这算是一层优化,避免无谓的 I/O 操作。

注意判断数据是否真正发生变化是在 EditorImpl 的 commmitToMemory() 方法中,在上篇文章中也有分析到:当前一系列操作数据发生在 EditorImpl 的 mModified(Map)变量中,该方法会比较 mModified 与 SharedPreferencesImpl 中 mMap 后修正最后一次 mMap 中数据,如果数据发生改变,如下图:

当前数据是否真的发生变化

继续向下分析,mBackupFile.exists() 方法判断当前是否存在备份文件,如果不存在,则将原始文件重名为备份文件。此时如果存在该文件的备份文件,则直接将源文件丢弃:mFile.delete()。

writeToFile 方法的下半部分分析,如下图:

writeToFile 方法下半部分

由于代码篇幅较长,省去部分。

创建 mFile 文件的输出流,这里很明白是要写入数据使用,系统将真正写入数据的操作都封装在 XmlUtils 中,然后强制 sync 落盘到闪存。

强制落盘

熟悉 I/O 的朋友都知道,我们应用程序平时用到的 read/write 操作都属于标准 I/O,也就是缓存 I/O(Buffered I/O)。它的关键特性有:

(1)对于读操作来说,当应用程序读取某块数据的时候,如果这块数据已经存放在页缓冲中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。

(2)对于写操作来说,应用程序也会将数据先写到页缓冲(Page Cache)中去,数据是否被立即写到磁盘上去取决于应用所采用写操作的机制。默认系统采用的是延迟写机制,应用程序只需要将数据写到页缓冲中去就可以了,完全不需要等数据全部被写回到磁盘,系统会负责定期地将放在页缓冲中的数据刷到磁盘上。

SharedPreferences 在写入文件时采用强制落盘机制来保证数据 “不丢失”:FileUtils.sync()。

如果上面步骤没有发生任何异常,则删除备份文件,还记得前面说过,在新的写入文件之前,先将原始文件备份吗?如下图:

原文件重名为备份文件

如果写入过程未发生异常,则直接 return,表示本次写入成功。如果写入过程发生异常,则直接将源文件删除:mFile.delete()。catch() 异常后的代码调用,删除源文件 mFile.delete(),如下图:

删除 SharedPreference 的原文件

此时不知道会不会有这样一个疑问?数据都丢失了?

让我们再来看下 ShardPreferencesImpl 构造方法(源码上图已贴出)的最后 startLoadFromDisk() 方法,如下图:(只贴出与 Backup 文件相关)

loadFromDisk 加载文件数据到内存

检查源文件的备份文件是否存在:mBackupFile.exists(),如果存在,则将源文件删除:mFile.delete(),然后将备份文件修改为源文件:mBackupFile.renameTo(mFile)。后续操作就是从备份文件加载相关数据到内存 mMap 容器中了。

小结

SharedPreferences 的写入操作,首先是将源文件备份:mFile.renameTo(mBackupFile) 再写入所有数据,只有写入成功,并且通过 sync 完成落盘后,才会将 Backup(.bak) 文件删除。如果写入过程中进程被杀,或者关机等非正常情况发生。进程再次启动后如果发现该 SharedPreferences 存在 Backup 文件,就将 Backup 文件重名为源文件,原本未完成写入的文件就直接丢弃,这样最多也就是未完成写入的数据丢失,它能保证最后一次落盘(真正落盘)成功后的数据。也正式这个 BackUp 机制,导致多进程可能会丢失新写入的数据。但也不是只有多进程场景才会发生数据丢失的情况。

1、Context.MODE_MULTI_PROCESS 到底做了什么?

在《Android之不要滥用SharedPreferences》只是简单给大家提到:使用 Context.MODE_MULTI_PROCESS 只是重新从文件加载了一遍 SharedPreferences 数据,不要指望这货能够跨进程通信。如下图:

MODE_MULTI_PROCESS 的作用

关于 SharedPreferences 的创建过程在上篇文章中已经做过详细介绍,不再赘述,这里主要关注红线框中部分:startReloadIfChangedUnexpectedly 方法跟踪:

重新从文件中加载一遍到内存 Map

hasFileChangedUnexpectedly 方法如果返回 false 直接 return。
否则 startLoadFromDisk(关于 startLoadFromDisk 方法的作用已经多次说明过,不再赘述)。hasFileChangedUnexpectedly 方法如下图:

当前文件内容是否发生过变化

SharedPreferences 中会记录最后修改时间以及文件大小,当使用 Context.MODE_MULTI_PROCESS 时,此时会通过 StructStat(Os.stat() 返回) 计算得到,然后与当前最后同步时间和文件大小进行比较,如果不匹配就会触发 startLoadFromDisk 方法执行,既重新加载文件内容到内存 mMap 中。

2、SharedPreferences 的监控

SharedPreferences 中为我们提供了 OnSharedPreferenceChangeListener 数据改变回调:

数据改变通知

需要注意 onSharedPreferenceChanged() 的回调时机在 commit() 和 apply() 有所区别:

(1)使用 commit() 提交时,onSharedPreferenceChanged() 回调时机是在数据落盘完成之后(不代表一定成功,有可能发生异常)

(2)使用 apply() 提交时,onSharedPreferenceChanged() 回调时机是在完成数据内存替换之后,既 mModified 中数据提交到 mMap 完成之后(前者是对我们一系列putXxx() 或 remove() 做保存,后者是写入文件时使用)。

(3)系统保存 OnSharedPreferenceChangeListener 对象在 WeakHashMap 中:

弱引用保存数据监听

不熟悉 WeakHashMap 的机制可以去了解下,故如果在局部创建 OnSharedPreferenceChangeListener 对象,在方法体结束后生命周期即结束。

通过 OnSharedPreferenceChangeListener 回调我们可以监控任意 SharedPreferences 提交的 key:value,比如较大的数据直接给出警告;也可以监控单个 SharedPreferences 文件是否过大。

  • SharedPrefenerces 的优化

我们也可以替换通过复写 Application 的 getSharedPreferences 方法替换系统默认实现,比如优化卡顿、合并多次 apply 操作、支持跨进程操作等。具体如何实现参考这里

重写 Application 相关方法替换 SharedPreferences 实现

对系统提供的 SharedPreferences 的小修小补虽然性能有所提升,但是依然不能彻底解决问题。基本每个大公司都会自研一套替代的存储方案,比如微信最近就开源了MMKV

最后

SharedPreferences 是我们日常经常使用的存储方法,但是里面的确会有大大小小的暗坑。所以我们需要充分了解它们的优缺点,这样在工作中可以更好地使用和优化。

总的来说,我们需要结合应用场景选择合适的数据存储方法。除了 SharedPreferences,Android 还为应用开发者提供了其它存储数据的方法。你可以参考 Android 存储优化系列专题中其他存储方法分析。

文中分析如有不妥或更好的分析结果,还请大家指出!如果你喜欢我的文章,就请留个赞吧!

推荐阅读

推荐阅读更多精彩内容