深入理解SharedPreferences

摘要

关于SharedPreferences需要知道的问题:

  1. SharedPreferences的线程安全吗?
  2. 每次调用getSharedPreferences时都会创建一个SharedPreferences对象吗?这个对象具体是哪个类对象?
  3. 在UI线程中调用getXXX有可能导致ANR吗?
  4. 为什么SharedPreferences只适合用来存放少量数据,为什么不能把SharedPreferences对应的xml文件当成普通文件一样存放大量数据?
  5. commit和apply有什么区别?
  6. SharedPreferences每次写入时是增量写入吗?

那么代码是如何保证SharedPreferences的线程安全,我们先要了解Context。

Context,中文直译为“上下文”

  1. 它描述的是一个应用程序环境的信息,即上下文。
  2. 该类是一个抽象(abstract class)类,Android提供了该抽象类的具体实现类(后面我们会讲到是ContextIml类)。
  3. 通过它我们可以获取应用程序的资源和类,也包括一些应用级别操作,例如:启动一个Activity,发送广播,接受Intent信息等。

Context相关类的继承关系

Context类

路径:/frameworks/base/core/java/android/content/Context.java

说明:抽象类,提供了一组通用的API。

ContextIml.java类

路径 :/frameworks/base/core/java/android/app/ContextImpl.java

说明:该Context类的实现类为ContextIml,该类实现了Context类的功能。请注意,该函数的大部分功能都是直接调用其属性mPackageInfo去完成,这点我们后面会讲到。

ContextWrapper类

路径 :/frameworks/base/core/java/android/content/ContextWrapper.java

说明: 正如其名称一样,该类只是对Context类的一种包装,该类的构造函数包含了一个真正的Context引用,即ContextIml对象。

ContextThemeWrapper类

路径:/frameworks/base/core/java/android/view/ContextThemeWrapper.java

说明:该类内部包含了主题(Theme)相关的接口,即android:theme属性指定的。只有Activity需要主题,Service不需要主题,所以Service直接继承于ContextWrapper类。

什么时候创建Context实例

应用程序创建Context实例有如下几种情况:

  1. 创建Application对象时,而且整个App共一个Application对象
  2. 创建Service对象时
  3. 创建Activity对象时

因此应用程序App共有的Context数目公式为:

1
总Context实例个数 = Service个数 + Activity个数 + 1(Application对应的Context实例)

创建Application对象的时机

每个应用程序在第一次启动时,都会首先创建Application对象。如果对应用程序启动一个Activity(startActivity)流程比较
清楚的话,创建Application的时机在创建handleBindApplication()方法中,该函数位于 ActivityThread.java类中 ,如下:

创建Activity对象的时机

通过startActivity()或startActivityForResult()请求启动一个Activity时,如果系统检测需要新建一个Activity对象时,就会回调handleLaunchActivity()方法,该方法继而调用performLaunchActivity()方法,去创建一个Activity实例,并且回调onCreate(),onStart()方法等, 函数都位于 ActivityThread.java类 ,如下:

创建Service对象的时机

通过startService或者bindService时,如果系统检测到需要新创建一个Service实例,就会回调handleCreateService()方法,完成相关数据操作。handleCreateService()函数位于 ActivityThread.java类,如下:

另外,需要强调一点的是,通过对ContextImp的分析可知,其方法的大多数操作都是直接调用其属性mPackageInfo(该属性类型为PackageInfo)的相关方法而来。这说明ContextImp是一种轻量级类,而PackageInfo才是真正重量级的类。而一个App里的所有ContextIml实例,都对应同一个packageInfo对象。

SharedPreferences相关

Context获取SharedPreferences类的使用方法最终调用如下:

可以看到这里使用到了单例模式,sSharedPrefsCache是一个ArrayMap,packagePrefs也是一个ArrayMap,它们的关系是这样的:

packagePrefs存放文件name与SharedPreferencesImpl键值对,sSharedPrefsCache存放包名与ArrayMap键值对。注意sSharedPrefsCache是static变量,也就是一个类只有一个实例,因此每次getSharedPreferences其实拿到的都是同一个SharedPreferences对象。

对于一个SharedPreferences文件name,第一次调用getSharedPreferences时会去创建一个SharedPreferencesImpl对象,SharedPreferencesImpl的构造方法最终调用startLoadFromDisk()方法。它会开启一个子线程,然后去把指定的SharedPreferences文件中的键值对全部读取出来,存放在一个Map中。

SharedPreferencesImpl的getXXX()方法

如果我们在UI线程这样写:

1
2
SharedPreferences sp = getSharedPreferences("test", Context.MODE_PRIVATE);
String name = sp.getString("name", null);

看看getString()的源码:


注意之前读取指定的SharedPreferences文件中键值对代码,我们看到有一个变量mLoaded,它用来标识文件是否读取完成。如果mLoaded为false,那么awaitLoadedLocked()会一直阻塞,所以getString()有可能迟迟无法返回,被阻塞住。一旦loadFromDiskLocked()方法调用快结束,mLoaded方法被置为true,并且notifyAll(),getString()的阻塞就会被唤醒。所以,在UI线程中调用getXXX可能会导致ANR,要根据具体情况考虑是否需要把SharedPreferences的读写放在子线程中。SharedPreferences只能用来存放少量数据,如果一个SharedPreferences对应的xml文件很大的话,在初始化时会把这个文件的所有数据都加载到内存中,这样就会占用大量的内存,有时我们只是想读取某个xml文件中一个key的value,结果它把整个文件都加载进来了,显然如果必要的话这里需要进行相关优化处理。

SharedPreferences的Editor

getXXX()方法使用synchronized关键字,this指代SharedPreferencesImpl.class。那么看看SharedPreferences的写操作。


我们可以看到之前loadFromDiskLocked()方法将SharedPreferences文件读取到内存中,存储到mMap中。并且getXXX()方法也是从mMap中取值。

这里每次修改值的时候,都是将要修改的键值对存放在mModified中,最后调用apply()或者commit()才会真正把数据写入文件中。看到这里我们会说,这里synchronized关键字this指代
EditorImpl.class,并不是SharedPreferencesImpl.class,是如何保证线程安全的。很明显我们知道两个方法修改的Map并不相同,真正的写操作并不在此。

EditorImpl的apply()函数和commit()函数的比较

先看文档介绍:

注意当两个编译器在同时修改参数时,最终以后面的apply()执行成功。

apply()会立刻提交改变到内存SharedPreferences,但是会开启一个异步提交到硬盘,不像commit(),它的参数是同步写入固态存储空间,你将不会被通知任何失败信息。如果其他编辑器在apply()方法还没完成,又再SharedPreferences执行一个常规的commit()方法,commit()方法会阻塞直到所有异步提交完成。

作为在进程中的一个单例对象SharedPreferences,如果你忽略返回值可以用apply()方法代替commit()方法,并且是安全的。

你不需要担心用apply()写入磁盘会被android组件生命周期和它们之间的交互影响。framwork会确保在切换状态之前apply()可以完成对动态磁盘的写入操作。



注意当两个编译器在同时修改参数时,最终以后面的commit()执行成功。

如果你不关心返回值并且你用在你app的主线程上调用,试着用apply()代替commit()。
commitToMemory()方法 和 enqueueDiskWrite()方法

关键有两步,先调用commitToMemory(),再调用enqueueDiskWrite(),commitToMemory()就是产生一个“合适”的MemoryCommitResult对象mcr,然后调用enqueueDiskWrite()时需要把这个对象传进去,commitToMemory方法:

在一次commit事务中,如果同时put一些键值对和调用clear,那么clear掉的只是之前的键值对,这次put进去的键值对还是会被写入的。

如果一个键值对的value是this(SharedPreferencesImpl)或者是null那么表示将此键值对删除。

可以查看remove()和clear()方法:

接下来看enqueueDiskWrite()方法:

从代码可以看到,commit的写操作是在调用线程中执行的,而apply内部是用一个单线程的线程池实现的,因此写操作是在子线程中执行的。

SharedPreferences每次写入时是增量写入吗?

mBackupFile在SharedPreferencesImpl构造方法中创建,SharedPreferences在写入时会先把之前的xml文件改成名成一个备份文件,然后再将要写入的数据写到一个新的文件中,如果这个过程执行成功的话,就会把备份文件删除。由此可见每次即使只是添加一个键值对,也会重新写入整个文件的数据,这也说明SharedPreferences只适合保存少量数据,文件太大会有性能问题。

所以

在SharedPreferences获取数据上,因apply()和commit()提交都会先将改变提交到内存,所以不用担心立刻取值不正确的问题。

SharedPreferences是否进程安全?

Context的文档介绍中有以下一段话:

MODE_MULTI_PROCESS不提供任何机制来协调跨进程并发修改。作为开发者不应该试图使用它,而是应该使用一个显式的跨进程数据管理方法如ContentProvider。所以SharedPreferences不是进程安全的。

参考

  1. 深入理解Android中的SharedPreferences

  2. Android中Context详解