November 14, 2017

SharedPreferences源码分析

前言

SharedPreferences作为Android应用配置项的存储,给上层提供了非常方便的接口,这篇Post将从源码层面分析SharedPreferences实现的细节。

获取SharedPreferences

获取SharedPreferences实例是通过ContextImpl.getSharedPreferences(String name, int mode)方法来完成的。

这里的sSharedPrefsContextImpl的静态成员,类型为ArrayMap<String, ArrayMap<String, SharedPreferencesImpl>>,从这个ArrayMap里,我们可以通过包名获取另一个ArrayMap packagePrefs,其中包含文件名到SharedPreferencesImpl的映射。这里我初看的时候不太理解,为什么要两级映射。后来想了一下,应该是针对sharedUserId的情况,这种情况下,同一个进程会有不同的Context和包名,个人见解,如果不对,欢迎指正。
这里要注意如下几点:

  1. ContextImpl对SharedPreferencesImpl对象是做了缓存的,因为是静态成员,所以同一进程内,无论调用多少次getSharedPreferences方法,返回的对象都是同一个。
  2. 如果指定了MODE_MULTI_PROCESS的flag,那么执行startReloadIfChangedUnexpectedly方法,这个方法的具体内容会在后面分析,这个方法的实现也算是SharedPreferences的一大黑点。

SharedPreferencesImpl初始化

ContextImpl如果发现请求的SharedPrefernces不在缓存中,就会创建新的SharedPreferencesImpl对象,并加入缓存。

SharedPreferences从文件加载到磁盘的流程如下:

  1. mLoaded置为false,这个变量用来标识文件是否已经完成了加载,对这个变量的访问都是上锁的,一是为了保证可见性,而是为了保证同步。然后新创建一个线程,真正执行从磁盘加载的操作。
  2. 如果检测到备份文件存在,说明当前文件mFile是有问题的,直接删除,并用备份文件覆盖当前文件。
  3. 从XML文件读出键值对,保存在mMap数组,并记录下当前文件的大小和修改时间戳到mStatSizemStatTimestamp

初始化过程就是这样,这段代码第24-26行我个人认为是多余的,只有loadFromDiskLocked()方法本身会把mLoaded置为true,但是这个方法本身是用this加锁的,理论上是不会出现,进了这个方法,mLoaded为true的情况。

SharedPreferences读取

读取相关的代码相对简单,取一段作为样例:

awaitLoadedLocked在每次取数据之前都会调用,如果mLoaded为false,说明没有加载完,那么挂起当前线程,等待加载完后统一唤醒。因为用了wait/notify,所以awaitLoadedLocked调用前必须获取this的锁。
取数据就是从mMap中根据key取出需要的数据,如果是getAll()的话,这里返回的是mMap的副本,以免对返回数据的修改影响了mMap的值。这里mMap是如何保证和磁盘上文件同步的,看完后面SharedPreferences的写入就会明白。

SharedPreferences写入

SharedPreferences的写入是整个SharedPreferences源码中最复杂的一块。但只是相对而言,其实仔细看看还是很好理解的。
SharedPreferences的所有写入操作,都是通过一个内部类SharedPreferencesImpl.EditorImpl来实现的。源码里的putXX方法我只摘了一个出来,多余的去掉了:

我们知道,写入流程,一般都是putXX->commit/apply,我们就按这个流程入手,来看看到底写入SharedPreferences的时候执行了哪些操作。这里可以看到,putXX实际上执行的操作,就是把key/value对放到了mModified里面。真正的写入过程,先以commit()方法为例看看。
这里又要引入另一个内部类
MemoryCommitResult

这个内部类表示的是把修改提交到内存的结果。commit()方法的实现,先调用了commitToMemory()方法,获取了返回的MemoryCommitResult之后,再把返回结果加入磁盘写入队列,并在当前线程等待写入操作完成,最后通知所有的监听器,写入完成。

提交到内存

**commitToMemory()**的实现比较繁琐,这里分步讲解一下:

  1. 先检查mDiskWritesInFlight变量,看有没有正在写入,或者等待写入的数据,如果有的话,这里要做一次深拷贝。这里第60行我初看也觉得很奇怪,但是结合后面一起看,就能理解了。这里注意到,第62行是直接把mMap赋值给了mapToWriteToDisk,这里是浅拷贝,也就是说,修改mMap的值,同样会影响mapToWriteToDisk,而后者是被加入磁盘写入队列,正在被写入,或者将要被写入到磁盘的Map,这种影响显然是我们不希望发生的,因为在写入磁盘时,获取的锁并不是this,如果mapToWriteToDisk在这时被修改,可能出现异常。所以这里深拷贝之后,可以保证mapToWriteToDisk指向的,永远是那个时刻的Map。
  2. 增加mDiskWritesInFlight,标记要写入磁盘的任务增加了一个。
  3. 检查mClear,看看是否需要清空当前SharedPreferences。
  4. 81-106行合并了mModifiedmMap,这里需要注意一个很tricky的地方,第87行的判断,其实remove()方法的实现,就是把value设为EditorImpl本身,效果等同于把某一个key对应的value设为null。
  5. 最后把mModified清空,因为已经被合并到了mMap
写入磁盘

写入磁盘相关的代码如下:

enqueueDiskWrite方法接收两个参数,第一个是上一步返回的MemoryCommitResult对象,第二个参数是一个Runnable对象,如果是从commit()调用的,第二个参数应该为空。这里的QueuedWork是一个工具类,会创建一个当前进程共享的单线程执行器,其实相当于一个队列,按照入队列顺序执行写入操作。注意,如果是commit()调用,这里并不会用到QueuedWork的线程池,而是直接在当前线程完成写词盘操作。写入操作的具体实现在writeToFile方法。

  1. 第77到96行,重命名当前文件作为备份文件,用于在写入错误的时候进行回滚。如果备份文件已经存在,那么说明当前文件是有问题的,直接删除当前文件。
  2. MemoryCommitResult.mapToWriteToDisk写入到磁盘,并执行fsync操作,然后根据mMode设置文件权限。
  3. 记录下当前文件的大小和修改时间戳到mStatSizemStatTimestamp
  4. 删除备份文件,并返回结果给MemoryCommitResult,这个操作会解除commit()CountDownLatch的等待。
  5. 如果上述操作失败,则删除未完成写入的文件。
异步写入

现在再来看看**apply()**方法的实现:

apply()commit()结合起来看,commit()直接在当前线程执行了await操作,而apply()是写了一个Runnable,这个awaitCommit最后会在哪个线程执行呢?这里就要看看QueuedWork的源码:

从代码里可以看到,这里把awaitCommit加入到QueuedWork.sPendingWorkFinishers,并不会马上执行,只会在waitToFinish()中执行,而这个方法的调用时机,也只有Activity的onPause()中,BroadcastReceiver的onReceive()调用后等几个时机,只是为了保证异步任务不丢失而做的fallback逻辑。真正的写入还是调用writeToFile()方法,任务会被分发给QueuedWork提供的单线程线程池执行。

多进程问题

最后来说一下SharedPreferences的一个最大的坑,多进程下的问题。先来看一个flag的定义:

Context.MODE_MULTI_PROCESS
This constant was deprecated in API level 23. MODE_MULTI_PROCESS does not work reliably in some versions of Android, and furthermore does not provide any mechanism for reconciling concurrent modifications across processes. Applications should not attempt to use it. Instead, they should use an explicit cross-process data management approach such as ContentProvider.

看官方文档,他们还是知道这里的实现是有问题的。我们回溯到代码,看一下对于这个flag的处理,前面我们看到过,ContextImpl如果发现了这个flag,就会调用**SharedPreferencesImpl.startReloadIfChangedUnexpectedly()**方法,这个方法实现如下:

这个方法做的,仅仅是在文件有变化的时候,重新加载了一遍。首先,这个操作不是原子性的,并没有用flock锁住文件,因为mFilemBackupFile的关系,一个进程在写,而另一个进程在读的时候,就会造成写入的数据丢失。第二,这个判断文件是否有变化的条件也是有问题的,只要检测到mDiskWritesInFlight大于0就认为是自己修改了文件时间戳,这是不严谨的,假如当前进程的写任务只是刚刚写到了内存,而另一个进程修改了文件,此时mDiskWritesInFlight是大于0的,但是文件确实发生了本进程不知道的修改,另一个进程写入的数据还是读不到。所以官方很良心地废弃了这个Flag,转而推荐用ContentProvider实现跨进程数据共享。