>>分享Android开发相关的技术 书籍支持  卫琴直播  品书摘要  在线测试  资源下载  联系我们
发表一个新主题 开启一个新投票 回复文章 您是本文章第 22531 个阅读者 刷新本主题
 * 贴子主题:  Android 之不要滥用 SharedPreferences(下) 回复文章 点赞(0)  收藏  
作者:flybird    发表时间:2024-04-15 04:23:38     消息  查看  搜索  好友  邮件  复制  引用

在上篇《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 的构造方法,如下图:
  


点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
SharedPreferenceImpl 的构造方法
  
注意源码中 mBackupFile 变量,本文也是重点围绕该变量进行分析。
  


点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
创建 SharedPreferences 备份文件
  
从这可以看出,mBackupFile 是原始文件的备份文件,如:.../config.xml.bak(config 为 SharedPreferences 的文件名)。

3、mBackupFile 备份文件的作用

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




点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
提交流程
这里只给大家简单贴下调用栈,commit 提交方法如下:



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
commit 提交
enqueueDiskWrite 方法如下:



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
enqueueDiskWrite 方法
可以看到 wirteToFile 方法的调用时机,这也是我们要重点追踪的方法。wirteToFile 方法的作用是将我们前面一系列的 putXxx  或 remove 后的数据落盘到存储设备(在移动设备一般指的是 Flash 闪存)。

4、写入文件分析

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



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
writeToFile 方法,执行数据落盘
省去部分日志代码,代码中也标注了详细的注释:
首先如果源文件存在(SharedPreferences 文件,这里相对它的备份文件而言),判断如果要写入的数据是否真正发生变化,如果未发生变化则直接 return,这算是一层优化,避免无谓的 I/O 操作。
注意判断数据是否真正发生变化是在 EditorImpl 的 commmitToMemory() 方法中,在上篇文章中也有分析到:当前一系列操作数据发生在 EditorImpl 的 mModified(Map)变量中,该方法会比较 mModified 与 SharedPreferencesImpl 中 mMap 后修正最后一次 mMap 中数据,如果数据发生改变,如下图:



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
当前数据是否真的发生变化
继续向下分析,mBackupFile.exists()  方法判断当前是否存在备份文件,如果不存在,则将原始文件重名为备份文件。此时如果存在该文件的备份文件,则直接将源文件丢弃:mFile.delete()。
writeToFile 方法的下半部分分析,如下图:



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
writeToFile 方法下半部分
由于代码篇幅较长,省去部分。
创建 mFile 文件的输出流,这里很明白是要写入数据使用,系统将真正写入数据的操作都封装在 XmlUtils 中,然后强制 sync 落盘到闪存。



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
强制落盘
熟悉 I/O 的朋友都知道,我们应用程序平时用到的 read/write 操作都属于标准 I/O,也就是缓存 I/O(Buffered I/O)。它的关键特性有:
(1)对于读操作来说,当应用程序读取某块数据的时候,如果这块数据已经存放在页缓冲中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。
(2)对于写操作来说,应用程序也会将数据先写到页缓冲(Page Cache)中去,数据是否被立即写到磁盘上去取决于应用所采用写操作的机制。默认系统采用的是延迟写机制,应用程序只需要将数据写到页缓冲中去就可以了,完全不需要等数据全部被写回到磁盘,系统会负责定期地将放在页缓冲中的数据刷到磁盘上。
SharedPreferences 在写入文件时采用强制落盘机制来保证数据 “不丢失”:FileUtils.sync()。
如果上面步骤没有发生任何异常,则删除备份文件,还记得前面说过,在新的写入文件之前,先将原始文件备份吗?如下图:



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
原文件重名为备份文件
如果写入过程未发生异常,则直接 return,表示本次写入成功。如果写入过程发生异常,则直接将源文件删除:mFile.delete()。catch() 异常后的代码调用,删除源文件 mFile.delete(),如下图:



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
删除 SharedPreference 的原文件
此时不知道会不会有这样一个疑问?数据都丢失了?
让我们再来看下 ShardPreferencesImpl 构造方法(源码上图已贴出)的最后 startLoadFromDisk() 方法,如下图:(只贴出与 Backup 文件相关)



点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
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 数据,不要指望这货能够跨进程通信。如下图:

  点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
MODE_MULTI_PROCESS 的作用
关于 SharedPreferences 的创建过程在上篇文章中已经做过详细介绍,不再赘述,这里主要关注红线框中部分:startReloadIfChangedUnexpectedly 方法跟踪:
  
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
重新从文件中加载一遍到内存 Map
hasFileChangedUnexpectedly 方法如果返回 false 直接 return。
否则 startLoadFromDisk(关于 startLoadFromDisk 方法的作用已经多次说明过,不再赘述)。hasFileChangedUnexpectedly 方法如下图:

点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
当前文件内容是否发生过变化
SharedPreferences 中会记录最后修改时间以及文件大小,当使用 Context.MODE_MULTI_PROCESS 时,此时会通过 StructStat(Os.stat() 返回) 计算得到,然后与当前最后同步时间和文件大小进行比较,如果不匹配就会触发 startLoadFromDisk 方法执行,既重新加载文件内容到内存 mMap 中。

2、SharedPreferences 的监控

SharedPreferences 中为我们提供了 OnSharedPreferenceChangeListener 数据改变回调:
  
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
数据改变通知
需要注意 onSharedPreferenceChanged() 的回调时机在 commit() 和 apply() 有所区别:
(1)使用 commit() 提交时,onSharedPreferenceChanged() 回调时机是在数据落盘完成之后(不代表一定成功,有可能发生异常)
(2)使用 apply() 提交时,onSharedPreferenceChanged() 回调时机是在完成数据内存替换之后,既 mModified 中数据提交到 mMap 完成之后(前者是对我们一系列putXxx() 或 remove() 做保存,后者是写入文件时使用)。
(3)系统保存 OnSharedPreferenceChangeListener 对象在 WeakHashMap 中:
  
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
弱引用保存数据监听
不熟悉 WeakHashMap 的机制可以去了解下,故如果在局部创建 OnSharedPreferenceChangeListener 对象,在方法体结束后生命周期即结束。
通过 OnSharedPreferenceChangeListener 回调我们可以监控任意 SharedPreferences 提交的 key:value,比如较大的数据直接给出警告;也可以监控单个 SharedPreferences 文件是否过大。
  • SharedPrefenerces 的优化
我们也可以替换通过复写 Application 的 getSharedPreferences 方法替换系统默认实现,比如优化卡顿、合并多次 apply 操作、支持跨进程操作等。具体如何实现参考这里

  点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
重写 Application 相关方法替换 SharedPreferences 实现
对系统提供的 SharedPreferences 的小修小补虽然性能有所提升,但是依然不能彻底解决问题。基本每个大公司都会自研一套替代的存储方案,比如微信最近就开源了MMKV

最后

SharedPreferences 是我们日常经常使用的存储方法,但是里面的确会有大大小小的暗坑。所以我们需要充分了解它们的优缺点,这样在工作中可以更好地使用和优化。
总的来说,我们需要结合应用场景选择合适的数据存储方法。除了 SharedPreferences,Android 还为应用开发者提供了其它存储数据的方法。你可以参考 Android 存储优化系列专题中其他存储方法分析。
文中分析如有不妥或更好的分析结果,还请大家指出!如果你喜欢我的文章,就请留个赞吧!

----------------------------
原文链接:https://www.jianshu.com/p/f5a29bce2e6f



程序猿的技术大观园:www.javathinker.net
网站系统异常


系统异常信息
Request URL: http://www.javathinker.net/WEB-INF/lybbs/jsp/topic.jsp?postID=1130

java.lang.NullPointerException

如果你不知道错误发生的原因,请把上面完整的信息提交给本站管理人员