SharedPreferences

源码解析

getSharedPreferences

通过 Context 的 getSharedPreferences 方法获取 Sp 实例,其具体的实现在 ContextImpl 类中,方法代码如下:

@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
    // 处理 name 为 null 的情况
    // At least one application in the world actually passes in a null
    // name.  This happened to work because when we generated the file name
    // we would stringify it to "null.xml".  Nice.
    if (mPackageInfo.getApplicationInfo().targetSdkVersion <
            Build.VERSION_CODES.KITKAT) {
        if (name == null) {
            name = "null";
        }
    }

    File file;
    synchronized (ContextImpl.class) {
        if (mSharedPrefsPaths == null) {
            //创建缓存的 map
            mSharedPrefsPaths = new ArrayMap<>();
        }
        //先从缓存中获取 File 
        file = mSharedPrefsPaths.get(name);
        if (file == null) {
            //缓存中没有,先生成file
            file = getSharedPreferencesPath(name);
            //加入缓存
            mSharedPrefsPaths.put(name, file);
        }
    }
    //通过文件生成 SharedPreferences
    return getSharedPreferences(file, mode);
}

逻辑就是根据名称找到或者生成对应的文件,生成文件时调用了 getSharedPreferencesPath,该方法代码如下:

调用了makeFileName方法,第一个参数是文件目录,通过getPreferencesDir方法获取;第二个是文件名,通过这里可以看出存储sp使用的xml文件。

getPreferencesDir 代码如下,其实就是获取 data 下的 "shared_prefs"目录:

ensurePrivateDirExists 方法主要目的是在目录不存在时进行创建,源码如下,细节就不再分析了。

makeFilename 方法实现如下:

可以看出主要逻辑就是生成 File 对象。

以上是生成 file 对象的过程。

创建 SharedPreferences

有了文件后,就可以创建 Sp 实例了,这个过程通过 getSharedPreferences(file, mode)方法完成,该方法源码如下:

方法会先存缓存中获取,如果缓存没有,那么会执行创建,并把新创建的sp实例放入缓存。

创建前先进行了一些权限检查,checkMode 用于检查 sp 的模式,MODE_WORLD_READABLE 和 MODE_WORLD_WRITEABLE 在7.0及以后会抛出异常。

创建 sp 时直接实例化了SharedPreferencesImpl,它是 SharePreferences 的实现类,构造方法的源码如下:

在构造方法中,调用了 startLoadFromDisk 方法,从名字可以看出来,这个方法的任务主要是从文件中读取内容,其源码如下:

在该方法中,先将读取状态标识置为false,然后开启了一个线程执行文件读取任务,文件读取任务由 loadFromDisk 方法定义,代码为:

主要逻辑是读取XML文件并将其解析为 Map 对象,然后将读取标识置为 true。sp创建后,就可以进行读取和写入了。

读取值

getString 方法为例,在 SharedPreferencesImpl中代码:

在读取前,先调用了 awaitLoadedLocked 方法,在还没有完成文件读取时进行等待:

而前面 loadFromDisk 方法在读取成功后会调用 mLock.notifyAll 方法,从而通知读取操作可以继续。

值的读取就是从生成的 HashMap 中根据 key 获取对应的值。

写入值

相比读取,写入过程就复杂很多了。在写入时,需要先调用 edit 方法,获取一个 Editor 对象。

创建 Editor

edit 方法源码如下:

当 Sp 可用时,创建了一个 EditorImpl 对象并返回。EditorImplEditor的实现类,同时是SharedPreferencesImpl的内部类。

EditorImpl 只有三个属性:

执行修改主要是一些重载的 put 方法,remove 方法用于移除一个键值对,clear 方法用于清空修改。

putXxx

EditorImpl 中的对应的修改方法代码如下(put 方法以 putString 为例):

put 和 remove 操作的是 EditorImpl 的 mModified,而 clear 方法只是将清除标记置为 true。

另外这些方法都返回 Editor 对象,方便链式调用。

通过对 EditorImpl 操作后,所有要新增或发生修改的键值对都被记录在了 mModified 这个 map 中,而要使这些更改生效,就需要调用 apply 或者 commit 进行提交。

先来看下 apply 方法。

EditorImpl#apply()

apply 方法源码如下,略去了一些 log 信息:

apply 逻辑是:

  1. 通过 commitToMemory 方法把修改提交至内存中

  2. 通过 enqueueDiskWrite 将磁盘写入任务提交至任务队列

  3. 通知监听者

awaitCommit 可能在文件写入时被执行,也可能会在 QueuedWork 执行完所有任务后再执行。如果通过文件写入过程执行,会从QueuedWork的finisher中把它移除。

EditorImpl#commitToMemory()

这个方法用于将在 Editor 上执行所有更改提交到内存的Sp中,并返回一个 MemoryCommitResult 对象提交信息,代码如下:

该方法的主要逻辑是,根据 Editor 的 mModified 中记录的发生修改的key,来更新内存中的 map 对应的 key。如果Sp有监听者,还需要记录发生修改的key,以便通知监听者。最后返回一个MemoryCommitResult 对象,它记录了当前内存中Sp版本号、发生变更的key、监听者和最要写入文件的map(即内存中Sp的所有键值对)。

需要注意的点:

  1. 对于 Editor#clear() 方法,如果执行过,会先将所有键值对清空,然后写入 mModified 中记录的键值对

  2. 内存中的Sp 有一个版本号标识,每次提交新的改动到内存后,该版本号会加1

该方法返回的是一个 MemoryCommitResult 对象,MemoryCommitResult 是 SharedPreferencesImpl 的静态内部类,它的定义如下:

其中 setDiskWriteResult 方法用来设置文件写入结果,后面会讲到。

SharedPreferencesImpl#enqueDiskWrite

将变更提交的内存后得到 MemoryCommitResult 对象后,调用了 enqueDiskWrite 进行文件写入,这个方法源码如下:

该方法中第二个参数是一个 Runnable ,用于在文件写入后执行。如果这个参数为null,那么就说明此次调用来自 commit() 方法,否则此次调用就来自 apply ()方法,在上面的 apply 方法中,我们也看到它确实传了一个 Runnable。

上面方法中的 writeToDiskRunnable 定义了文件写入的过程:先调用 writeToFile 将内存的Sp写入文件,然后将 mDiskWritesInFlight 减一,最后在执行 postWriteRunnable。

不过 writeToDiskRunnable 的执行时机却暗藏玄机。如果此调用来自 commit() 方法,并且目前只有这次写入需要执行,那么就会在当前线程执行这次文件写入(如果我们的调用来自主线程,就会直接在主线程中执行文件写入,这就可能导致性能问题);其他情况都会通过 QueuedWork 把写入任务加入队列中,关于 QueuedWork 稍后再介绍,先来看看 writeToFile 方法如何执行文件写入的。

SharedPreferencesImpl#writeToFile()

writeToFile 是执行文件写入的方法,代码如下,我省略的一些日志输出代码:

在写入前,会先比较当前磁盘版本号与内存版本号,只有磁盘版本号小于内存版本号时,才会执行文件写入。

另外,对于 apply ,会将每次 mcr 的版本号与当前内存的最终版本号进行对比,只有相等时才会执行文件写入。这样一来,如果短时间内有多次 apply 文件写入请求,只有最后一次写入会被真正执行。

在执行写入前,会先创建一个备份文件,当写入过程中发生意外,下次读取时可以从备份文件中恢复。在从文件加载Sp 的 loadFromDisk 方法中就有如下代码:

正式的文件写入过程主要分以下几步:

  1. 创建文件输出流

  2. 将 map 写入到 xml 文件

  3. 删除备份文件

  4. 更新磁盘Sp对应版本号 mDiskStateGeneration

  5. 设置写入结果

关于第五步,针对文件写入情况,会调用 MemoryCommitResult#setDiskWriteResult 方法设置结果,这个方法源码:

设置完结果后,调用了 writtenToDiskLatch.countDown() 以通知正在等待的线程。

以上就是 apply 的过程,主要分为两步:

  1. 提交修改至内存,生成一个 MemoryCommitResult 对象mcr,这个 mcr 记录着当前内存中sp的版本号、所有键值对、发生修改的键等信息

  2. 通过 QueuedWork 将文件写入任务入队,等待执行

在讲解 QueuedWork 之前,我们先对照着看一下 commit 方法。

EditorImpl#commit()

commit 方法也包括提交内存和将文件写入任务入队,但后面还增加了等待文件写入完成的过程,因为commit 方法的返回值就是文件写入的结果。

另外,调用 SharedPreferencesImpl#enqueDiskWrite方法时,第二个参数传的是 null,在分析该方法时我们也看到,该方法正是以这个参数是否为 null 来区分是commit 还是 apply 的。

为了方便阅读,再把该方法源码贴一遍:

对于 commit 方法提交,如果当前并没有其他文件写入任务要执行,那么会在当前线程执行文件写入。

接下来终于要揭晓 QueuedWork 的神秘面纱了。

在看代码细节之前,先来看看官方注释对 QueuedWork 有个初步了解:

Internal utility class to keep track of process-global work that's outstanding and hasn't been finished yet.

New work will be {@link #queue queued}. It is possible to add 'finisher'-runnables that are {@link #waitToFinish guaranteed to be run}. This is used to make sure the work has been finished.

This was created for writing SharedPreference edits out asynchronously so we'd have a mechanism to wait for the writes in Activity.onPause and similar places, but we may use this mechanism for other things in the future.

The queued asynchronous work is performed on a separate, dedicated thread.

通过以上注释,可以获取一下信息:

  1. 用于维护进程级别的任务

  2. 新任务通过queue方法入队

  3. 可以增加 finisher 任务,它们肯定会被执行,可以用来确保任务已经被执行完成

  4. 目前主要用于执行 Sp 的文件同步并提供等待完成机制

  5. 队列的任务会在单独线程中执行。

下面就来看看 QueuedWork 的代码实现,先来看看它的几个重要的静态属性:

静态属性

QueuedWork#queue()

前面在 SharedPreferencesImpl#enqueDiskWrite(),调用了queue 方法将文件写入工作入队,该方法源码如下:

主要逻辑就是讲任务加入队列中,然后通过handler发送消息来出发任务的执行。这个方法的第二个参数标识是否需要延后一段时间(DELAY 的值是100),在SharedPreferencesImpl#enqueDiskWrite()中是这样调用的:

也就是说,对于 commit ,shouldDelay 为 false;对于 apply ,shouldDelay 为true。shouldDelay 决定了在通过 handler 发送消息时是否启用延时。

那为什么要 apply 进行延时呢?

在 SharedPreferencesImpl 的 writeToFile 方法中有如下判断:

DELAY 的值是常量100,如果100ms 内有多次 apply 提交,这个延时可以确保当100ms后,任务队列中的文件写入任务被统一处理时,只有最新的apply提交对应的文件写入任务会真正被执行。因为只有它对应的mcr的版本号是和内存 一致的,其他的mcr版本都低于内存版本。这样可以有效去除冗余的文件写入,提升性能。

getHandler()

上面方法中 handler 的获取调用了 getHandler() 方法,其源码如下:

可以看到,初始化时,新建了一个 HanlderThread ,并基于它创建了一个 QueuedWorkHandler 赋值给 sHandler。

QueuedWorkHandler

QueuedWorkHandler 是 QueuedWork 的静态内部类,它的定义很简单,在收到MSG_RUN消息后,调用 processPendingWork() 处理所有待执行的任务。

processPendingWork

processPendingWork 的逻辑也很简单,就是将任务列表中的任务按序执行。正常情况下,这个过程就是在 getHandler 中创建的 HandlerThread 中进行。

那不正常情况呢?

waitToFinish

waitToFinish 会在当前线程立刻执行所有待执行的任务,任务执行完后会一并执行所有 finisher 来通知任务执行完成。

而通过方法的注释可以看出,这个方法主要在 Activity的onPause 方法中、BroadcastReceiver 的 onReceive 方法后、以及Service 的 onCommand 后调用(具体见下面代码),以确保所有任务都被执行没有丢失,但这回导致在主线程执行文件写入,是有可能造成性能问题的。

建议

  • 尽量使用apply

  • 多次 put ,一次apply

  • 拆分 sp 文件的大小,避免一个 app 只使用一个 Sp,这样文件将会变得很大,写入时间会变长。如果恰好卡在 waitToFinish 这样的时间点,有可能造成 ANR。

  • 不要连续多次edit(), 应该获取一次获取edit(),然后多次执行putxxx(), 减少内存波动

  • 不要使用MODE_MULTI_PROCESS

相关问题

commit 和 apply 区别?

commit 会等待文件写入完成,并且有可能会在主线程写入文件,并且没有针对短时间内频繁更新做优化,有可能导致每次操作都在主线程写入。

apply 如果短时间内(100ms)有多次提交,只有最后一次会执行文件写入(因为会对比每次提交的版本号是否与当前内存版本号一致),并且是在单独的线程里执行写入,不会影响性能。

相关链接

gityuan:全面剖析SharedPreferences

SharedPreferences灵魂拷问之原理

最后更新于

这有帮助吗?