SP
存储相关内容。
有关 SharedPreferences
的简单使用:
以下代码的源码基于
android-29
, 为什么要这么强调一下呢?因为几乎每个版本都会有一些大大小小的修改, 官方也在陆续做优化。
以下包含以下内容:
-
SharedPreferences
对象的获取- 获取代码
-
context.getSharedPreferences(fileName, mode)
代码分析 - 新建
SP
对象 和loadFromDisk
的代码分析 - 小结
- 访问
SP
的数据:读数据和写数据操作- 读操作
- 写操作,
commit()
和apply()
的区别
- 一些有关
SP
源码的有趣操作- 对
mLock
变量的加锁 -
mBackupFile
备份文件的存在 - 对
apply()
和commit()
的误解 - 对多进程的处理,不支持多进程
-
QueuedWork
的影响, 可能会造成阻塞
- 对
1. SharedPreferences
的获取
1.1 获取代码
当我们获取一个 Sp
时, 获取代码如下:
// context getSharedPreferences()
val preferences = context.getSharedPreferences(prefName,Context.MODE_PRIVATE)
通过 ContextImpl
获取到对应文件的 SharedPreferences
对象。
而我们都知道,如果当前 prefName
的文件不存在,会新建一个文件。
1.2 context.getSharedPreferences(fileName, mode)
代码分析
getSharedPreferences(name, mode)
代码如下:
// 部分代码如下:ContextImpl.getSharedPreferences(xxx) 如下:
// 1. synchronized 加锁 ,对整个 `ContextImpl` 这个类加锁
synchronized (ContextImpl.class) {
// 2. mSharedPrefsPaths 为 arrayMap, 如果为空,则会新建一个,里面存贮这 `key-> 文件名`,
// `value -> File`
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
file = mSharedPrefsPaths.get(name);
// 3. 如果没有该文件,则去新建文件
if (file == null) {
// 4. getSharedPreferencesPath(name) 中会新建该文件
file = getSharedPreferencesPath(name);
// 5. 把该文件加入到 ArrayMap 中,下次直接读取;
mSharedPrefsPaths.put(name, file);
}
}
// 获取真正的 sp 对象
return getSharedPreferences(file, mode);
上述代码中,重要的位置添加了注释,在这里再说一下:
-
synchronized
加锁 ,对整个ContextImpl
这个类加锁
读这部分源码时,你会发现有很多类似的实现,有时是对ContextImpl
一个类加锁,有时会对一个对象加锁,其中都是有不同用意的,慢慢体会。 -
mSharedPrefsPaths
是一个key-> 文件名 name, value -> File
的一个ArrayMap
,
所有当前APP
下的SP
文件,只要被加载过,都会被存在这个Map
中 - 如果没有该
SP
文件,则去新建文件getSharedPreferencesPath()
,
getSharedPreferencesPath
这个方法里面,有指定SP
文件存放位置的代码。 - 把该文件加入到
ArrayMap
中,下次可以直接得到该File
;
注:上述中的文件,其实是
File
, 此时读取到的File
尚未经过Xml
解析,更未得到SharedPreferencesImpl
对象。
1.3 ContextImpl.getSharedPreferences(file, mode)
的代码分析
简化为下面代码:
public SharedPreferences getSharedPreferences(File file, int mode) {
...
synchronized (ContextImpl.class) {
final ArrayMap cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
...
sp = new SharedPreferencesImpl(file, mode);
ache.put(file, sp);
}
...
return sp;
}
在 getSharedPreferences(file, mode)
方法中,会去检测 sSharedPrefsCache
所对应的当前包名的 ArrayMap
中是否有缓存的该文件 File
的 sp
对象,如果没有该对应的 SP
对象,则会新建一个 new SharedPreferencesImpl(file, mode)
new SharedPreferencesImpl()
的代码
主要作用:
- 新建
SharedPreferencesImpl
对象; - 创建备份文件;
- 异步读取
File
文件里面的内容到mMap
中
在 new SharedPreferencesImpl(file, mode)
中,会新建一个 BackUp
备份的 File
:
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
startLoadFromDisk();
}
// 为 prefsFile 新建一个备份的 文件,以 `.bak` 结尾
static File makeBackupFile(File prefsFile) {
return new File(prefsFile.getPath() + ".bak");
}
我们都知道,SP
文件是存储为 xml
格式的文件,那么是如何解析成,我们熟知的 map
形式呢?
代码在 startLoadFromDisk()
方法里面, 它会开一个线程 thread
去 loadFromDisk
,
loadFromDisk
代码如下:
// 代码如下
private void loadFromDisk() {
synchronized (mLock) {
// 1. 是否解析过该文件, 如果解析过,则不再重复解析,处理多线程访问的问题。
if (mLoaded) {
return;
}
// 2. 如果备份文件存在,则删除 mFile, 把 mBackupFile 重命名为 mFile
// 即,最终使用的是备份文件里面的数据
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
...
Map map = null;
try {
str = new BufferedInputStream(new FileInputStream(mFile), 16 * 1024);
map = (Map) XmlUtils.readMapXml(str);
} catch() {...}
...
synchronized (mLock) {
// 3. 设置标志符为 true, 已经读取了该文件到内存 map 中;
mLoaded = true;
...
// 4. 复制给成员变量 mMap
mMap = map;
//在这里其实有个 try catch 给 mMap 赋值, 在 finally 中这么实现
...
finally {
// 5. 用于唤醒所有等待 mLock 的线程,也就是说在这里从 `xml` 文件中读取数据到内存 mMap 中,
//是可能会存在线程等待的
mLock.notifyAll();
}
}
}
上面代码中,标注了一些值得注意的地方, 在罗列一下:
- 通过
mLoaded
判断 是否解析过该文件, 如果解析过,则不再重复解析,处理多线程访问的问题; - 如果备份文件存在,则删除
mFile
, 把mBackupFile
重命名为mFile
, 即,最终使用的是备份文件里面的数据 - 设置标志符
mLoaded
为true
, 表示已经读取了该文件到内存map
中; - 复制给成员变量
mMap
, 后续所有从该SP
中访问的数据,都来自于它; - 用于唤醒所有等待
mLock
的线程,也就是说在这里从xml
文件中读取数据到内存mMap
, 是可能会存在线程等待的。
得到 sp
返回值后,我们再次回到 getSharedPreferences(File file, int mode)
//ContextImpl.java android-29
public SharedPreferences getSharedPreferences(File file, int mode) {
...
// synchronized (ContextImpl.class) 加锁, 保证重复从内存中读取文件
// 1. 得到返回值 sp
sp = new SharedPreferencesImpl(file, mode);
// 2. 把返回值 sp 加入到 cache map 中,下次再次访问该 SP 文件, 不需要再次解析该文件,
//节省时间和开销
cache.put(file, sp);
return sp;
}
至此,我们已经拿到了 SP
的对象, 经过 getSharedPreferences(File file, int mode)
返回,
val preferences = SP
这里赋值成功.
后续,便可以通过它访问存储的数据。
1.4 小结
在上述过程中,我们主要分析了如何得到 SP
对象,在这里有以下需要值得注意的点:
-
在
ContextImpl
中有两个内存变量mSharedPrefsPaths
sSharedPrefsCache
sSharedPrefsCache
是ArrayMap
,>
它的key
为当前APP
的包名,value
为 Map 「它的key
为sp 对应的 File
,value
为SP 对应的对象
」
而mSharedPrefsPaths
是ArrayMap
, 是根据SP
的文件名name
, 获取到对应的File
在解析文件时,会优先看是否存在备份文件,如果存在,则用备份文件去替换原有文件;
解析对应文件
xml File
到mMap
中是异步的, 在子线程中完成,并且有加锁处理,防止多个线程进行解析
2. 访问它的数据 :读操作 + 写操作
2.1 读操作:getxxx()
在第一部分,当我们拿到了 SP
对象 preferences
后,我们可以访问到它里面的数据,
简单示例如下:
// 通过这种形式可以访问到它内部对应的数据
preferences.getString("key_name", "default_value")
再看一下,getString(xxx)
内部的实现 :
// SharedPreferencesImpl.getString() 方式实现代码:
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
// 1.使用 synchronized 加锁,防止多线程同时访问
synchronized (mLock) {
// 2. 判断是否加载 File 文件到内存 mMap 中,
awaitLoadedLocked();
// 3. 所有取的数据都是在 `mMap` 中取的数据
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
当读取一个数据时,会有以下逻辑, 在上述代码中有标注:
会通过
synchronized (mLock)
加锁,防止多线程的同时访问;-
在内部会等待 「加载
File 文件到内存
mMap」 的完成: 这里本质上是判断
mLoaded这个变量的值是否为
true, -
true:表示文件已经读取好了到内存
mMap中; -
false: 表示文件尚未读入到内存
mMap` 中在
awaitLoadedLocked()
的代码中,有mLock.wait()
,
这也就是为什么需要在loadFromDisk()
里面最后的finally
代码块中mLock.notifyAll()
唤醒等待的地方,继续执行他们的取数据操作。 所有取的数据都是在
mMap
中取的数据,
一旦我们拿到mMap
时,在同一个进程下的数据获取都是对mMap
的读数据操作.
2.2 写操作 putxxx()
对于 put
操作,我们都知道,其实是需要拿到 preferences.edit()
对象
preferences.edit()
才是我们操作写数据时需要使用到的。
@Override
public Editor edit() {
...
//
synchronized (mLock) {
// 等待加载完成, mMap 有数据
awaitLoadedLocked();
}
return new EditorImpl();
}
实际的对象为为 EditImpl
, 看以下代码:
// 以 putString(xxx) 代码为例
@Override
public Editor putString(String key, @Nullable String value) {
// 同样有加锁, Object mEditorLock , 防止多线程同时写数据
synchronized (mEditorLock) {
// mModified = new HashMap(), 是一个 HashMap 对象
mModified.put(key, value);
return this;
}
}
从代码中,可以看到:
- 同样会有加锁处理, 保证多线程安全
- 所有数据的
put
都会暂时放入到mModified
这个Map
中;
暂时存放在
mModified
的数据,会在EditIpml.commitToMemory()
中写入到mMap
中
EditIpml.commitToMemory()
并不是一个耗时操作,真正把mMap
写入到File
中才是耗时操作
一个完整 put
操作代码调用如下:
// 首先得到一个 EditorImpl() 对象
val edit = preferences.edit()
// 添加数据
edit.putString("name", "chendroid")
// 提交数据:两种方式,commit() 或者 `apply()`
//edit.commit()
edit.apply()
代码说明如下:
preferences.edit()
每次都会new EditorImpl()
, 因此不要频繁调用edit()
方法;两种提交方式:
commit()
:commit()
提交后,会在UI
线程「当前线程」进行数据的写入apply()
的提交方式:apply()
提交后,会把数据写入的操作放在IO
线程里面
因为,如果没有额外的需求,我们一般是使用apply()
做数据的写入操作
2.2.1 edit.commit()
后续逻辑
先分析下源码:
public boolean commit() {
...
// 1. 首先把 `mModified` 中新增的数据,写入到 `mMap` 内存中,
MemoryCommitResult mcr = commitToMemory();
// 2. 真正写入文件 File 中,写入到磁盘中
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null);
try {
// 3. 在这里等待,直到 writtenToDiskLatch 计数器为 0 ,主线程结束等待,开始执行后续逻辑
// 子线程中,会调用 writtenToDiskLatch.countDown() 使线程计数器减 1
mcr.writtenToDiskLatch.await();
} catch ...
} finally {...}
...
notifyListeners(mcr);
// 4. 返回执行结果,是否提交数据成功。
return mcr.writeToDiskResult;
}
在上述代码中, 已经写了部分注解:
- 首先,
commitToMemory()
实际操作是把mModified
新增的值给对应的mMap
新增 - 真正写入磁盘的操作是在
enqueueDiskWrite()
里面,这个是耗时操作。它的两个参数是有区别的
enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable)
,- 第一个参数:
mcr
是commitToMemory()
的结果 - 第二个参数:
postWriteRunnable
是决定是否会异步提交的关键
- 第一个参数:
-
commit()
是会等待写入操作完成后才返回的,因为会存在mcr.writtenToDiskLatch.await()
的操作 -
commit()
是有返回结果的,返回是否写入到磁盘disk
.
注:上述代码中,
mcr.writtenToDiskLatch
是CountDownLatch
的实例。
作用是等待其他的线程都执行完任务,必要时可以对各个任务的执行结果进行汇总,
然后主线程才继续往下执行。
下面我们在看一看 edit.apply()
操作。
enqueueDiskWrite()
的具体逻辑,我们暂不分析
2.2.2 edit.apply()
后续逻辑
首先看一下它的源码:
//
public void apply() {
...
//1. 和 commit() 相同的逻辑,写入到内存 mMap 中
final MemoryCommitResult mcr = commitToMemory();
// 2. 新建了一个 Runnable
final Runnable awaitCommit = new Runnable() {
...
// 3. 在 Runnable 里面这里的等待操作~
mcr.writtenToDiskLatch.await();
...
}
// 4. 把 awaitCommit 添加进去
QueuedWork.addFinisher(awaitCommit);
// 5. 这里新建的 postWriteRunnable
Runnable postWriteRunnable = new Runnable() {
run() {
// 6. 在第 2 步中新建的 runnable 在这里被调用运行
awaitCommit.run();
//
QueuedWork.removeFinisher(awaitCommit);
}
}
// 7. 和 commit() 一样的调用方法, 写入到磁盘中去
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
}
它里面的代码逻辑如下:
和
commit()
相同的逻辑,写入到内存mMap
中新建了一个
Runnable
, 用于表示等待完成后的操作;新建了
postWriteRunnable
, 并在postWriteRunnable
中,调用了awaitCommit.run()
, 表示等待写入磁盘已经完成;调用
enqueueDiskWrite
, 并传入了postWriteRunnable
我们可以发现,上述中 apply()
和 commit()
都调用了 enqueueDiskWrite()
, 用于把当前改动写入到磁盘文件中;
但是有所不同,commit()
中,传入的第二个参数 Runnable
为 null
;
而 apply()
中,传入了不为 null
的 postWriteRunnable
;
那得看一下 enqueueDiskWrite(mcr, postWriteRunnable)
这个方法的代码了:
// 写入磁盘操作
private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
// 1. isFromSyncCommit 代表着是否同步写入磁盘中,
//如果为 true , 代表这同步写入,「commit() 传入的 postWriteRunnable == null」 所以为同步写入
//如果为 false, 代表这异步写入,apply() 传入的 postWriteRunnable != null」, 所以为异步写入
final boolean isFromSyncCommit = (postWriteRunnable == null);
//
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
// 2. 真正写入磁盘文件的位置
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
//
if (postWriteRunnable != null) {
// 3. 等写入文件完成后,这里手动调用传入的 `postWriteRunnable.run()`
postWriteRunnable.run();
}
}
}
// 4. 这里为 commit() 走的逻辑,为 true ,同步写入
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
// mDiskWritesInFlight 默认为 0, 分别有 `++` 和 `--` 操作;
// `mDiskWritesInFlight++` 在 `commitToMemory()` 中;
// `mDiskWritesInFlight--` 在 `enqueueDiskWrite().writeToDiskRunnable` 中;
if (wasEmpty) {
// 5. 这里直接调用 writeToDiskRunnable, 并且 return 数据
writeToDiskRunnable.run();
return;
}
}
// 6. apply() 逻辑, 会加入到队列中,等待完成
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
从上面代码中,我们可以看到:
-
commit()
传入的postWriteRunnable
为null
,isFromSyncCommit == true
表示同步提交; -
apply()
传入的postWriteRunnable != null
,isFromSyncCommit == false
表示异步提交; -
writeToDiskRunnable
是真正执行写入文件的runnable
, 它的run()
方法调用的时机决定着它执行在那个线程; -
commit()
走的逻辑: 在当前线程中调用writeToDiskRunnable.run();
并且返回,是同步提交代码的,可能会存在阻塞主线程的情况; -
apply()
走的逻辑: 把writeToDiskRunnable
加入到QueuedWork
队列中, 在IO
线程中执行writeToDiskRunnable
; -
QueuedWork
内部是自带了HandlerThread
, 在它内部IO
线程中执行对应的任务 -
QueuedWork.queue()
会把当前的work 「Runnable」
加入到sWork.add(work)「一个 LinkedList 队列」
中 -
QueuedWork
在执行sWork
里面的消息是顺序执行的; -
QueuedWork.queue(Runnable work, boolean shouldDelay)
第二个参数是指:是否延迟发送消息;
在这里,apply()
传入的是true
, 代表会延迟发送消息「hanlder
发送xiaoxi 」,默认延迟100ms
2.3 总结
从上面的读数据和写数据的分析中,我们了解了以下内容:
读数据时, 数据都是从
mMap
中取得的值;写数据时,实际操作的对象为
EditorImpl()
,会先把数据存储在mModified
中,等到apply()
提交时,才会更新mMap
中的值, 并且每次提交都会更新磁盘中的文件;apply()
提交是异步的,在IO
线程完成的;commit()
提交是同步的, 在当前线程完成的, 可能会出现阻塞主线程的情况;apply()
的异步执行中,消息是按照顺序依次执行的不管是
apply()
还是commit()
, 每次提交后,都会有内存写入磁盘的操作
即使只改动了一个很小的值,也会把全部写入到磁盘中,多次修改提交,就会有多次的写入磁盘,这也是SP
性能不足够优秀的原因;
3 一些 SP
的有趣操作
3.1 对 mLock
变量的加锁
为了确保多线程的安全,整个 SharedPreferencesImpl
代码中,总共有 19
处使用了下面的形式:
synchronized (mLock) {
// do something
}
只要涉及到数据的访问,读取,加载,写入,均都对该变量的加锁处理;
这也保证了数据的安全性以及可见性。
3.2 mBackupFile
备份文件的存在
mBackupFile
的存在,保证了在一些特殊的异常情况下,假设在写入磁盘数据时发生了异常,此时仍然存在 mBackupFile
可供使用。
当
loadFromDisk()
时,会优先检查是否存在mBackupFile
如果存在,则删除原文件mFile
, 把mBackupFile
重命名为mFile
;当
writeToFile()
时,会先存在检查备份文件是否存在,如果不存在,则将mFile
重命名为mBackupFile
;当
writeToFile()
继续运行时,如果File
写入成功,则会再次删除mBackupFile
;
3.3 对 apply()
和 commit()
的误解
这里要注意下,因为 commit()
是同步提交到磁盘中的,而 apply()
是异步提交到磁盘中,是在 IO
线程中完成的;
有些博文的理解是:apply()
会等待一些数据的修改,然后再把这些修改一次性写入磁盘中;
这是错误的
** apply()
和 commit()
每次提交,都会对文件进行一次修改写入磁盘操作**
它们的不同是在:写入磁盘的线程有所不同。
3.4 对多进程的处理,不支持多进程
context.getSharedPreferences(prefName,Context.MODE_PRIVATE)
中,
第二个参数是:mode
, 当其符合 Context.MODE_MULTI_PROCESS
时,其实并没有支持多进程的操作。
在它的注释中:
// Context.java 中
/**
* @deprecated MODE_MULTI_PROCESS does not work reliably
*/
@Deprecated
public static final int MODE_MULTI_PROCESS = 0x0004;
也可以看到官方说是不支持的。
而当是这个 mode
时,SP
是如何处理的呢?
// 代码在 Context.getSharedPreferences(File file, int mode) 中
if ((mode & Context.MODE_MULTI_PROCESS) != 0 || ... {
//
sp.startReloadIfChangedUnexpectedly();
}
return sp
而在 sp.startReloadIfChangedUnexpectedly()
这个里面,做了什么操作呢?
//
void startReloadIfChangedUnexpectedly() {
synchronized (mLock) {
...
// 异步开启去磁盘中再读取一遍文件
startLoadFromDisk();
}
}
而上述代码中,已经把 SP
的 对象 return
出去了,
在这里并没有多进程做多余的加锁操作,因此 SP
并不支持多进程传输数据。
3.5 QueuedWork
的影响
在 apply()
异步提交数据后,最终会被加入到 QueuedWork.enque(postrunnable)
进行处理的。
那么…… 我们有没有其他地方涉及到这个类?
在 QueuedWork
中,有这样一个方法 waitToFinish()
//Trigger queued work to be processed immediately. 触发队列中的任务立马执行
// Is called from the Activity base class's onPause(),
//它会在 `Activity.onPause()` 方法中被调用
// so async work is never lost: 以防异步工作丢失,数据未完成写入。
public static void waitToFinish() {
//
...
}
它被调用的位置在 ActivityThread.java
中,源码如下:
// ActivityThread.handlePauseActivity() 方法中
public void handlePauseActivity(...) {
...
// Make sure any pending writes are now committed.
if (r.isPreHoneycomb()) {
// 调用位置。 使得所有异步的操作都完成
QueuedWork.waitToFinish();
}
...
}
而在 QueuedWork.waitToFinish()
的源码中,如果会完成的话,会一直执行,知道任务完成,才会继续走 onPause())
的其余操作。
所以, 当我们异步 apply()
提交很多次任务时,这时突然杀死 APP
, 这时候如果 QueuedWork
中未完全执行,是会发生阻塞的;
而一旦阻塞的时间过长,可能就会发生 ANR
.
这里我们要注意一下。
4 整体总结
有关 SP
的分析,在上面,尽可能的分析到了。
可以根据上面的代码分析,给出一些建议:
- 尽可能分散的使用
SP
文件,建立多个SP
文件,同时保证每个SP
文件不会过大,因为每次都会写入磁盘的操作,即使你只修改了这份文件的小个小部分; - 分成多个文件还有一个原因是:当你对其中的数据进行操作时,不能同时操作,可能会存在等待的情况,如果是多个文件,分别去不同的文件中取,会高效;
- 可以提前初始化
SP
,得到它的对象, 这样再使用的时候就可以不用等待可以直接存储数据;
例如在Application
中初始化 - 不要每次都
sp.edit()
, 因为edit()
方法总会新建一个EditorImpl
对象; - 无特殊需求,不要使用
commit()
提交修改,而是使用apply()
-
SP
不支持多进程通信, 不要依赖它做一些多进程的操作
支持多进程的方式:- MMKV: 腾讯开源,https://github.com/Tencent/MMKV
- FastSharedPreferences : 使用
synchronized
和一些原子操作的CAS
实现 https://github.com/JeremyLiao/FastSharedPreferences
参考链接
-
Android
之不要滥用SharedPreferences
(上): https://www.jianshu.com/p/5fcef7f68341
注:这里的作者的一系列文章都很精彩,可以阅读下。 -
https://github.com/JeremyLiao/FastSharedPreferences
, 源码的一些实现 -
Android-29
源码
这次就先这样吧~
2020.7.21 by chendroid