在多进程中使用 SharedPreference

之前都是使用 SharedPreference 来做一些基本的保存工作,因为都是在同一进程下使用,所以也没有遇到过什么问题,这次偶然间需要在多进程下使用,结果发现在读取时会存在读取不到的问题,因此去看看了源码,找到了问题原因和解决方式,也对 SharedPreference 有了更深的理解,特此记录一下~

获取 SharedPreference

通常我们都是通过 Context.getSharedPreferences() 来获取 SharedPreference 对象,这个 Context 无论是 Application、 Service 或是 Activity,都是继承自 ContextWrapper,通过查看 ContextWrapper 源码可以看发现内部都是调用了 mBase 的相关方法,而这个 mBase 就是 ContextImpl。getSharedPreferences() 在 ContextImpl 有两个重载方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public SharedPreferences getSharedPreferences(String name, int mode) {
...
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
file = mSharedPrefsPaths.get(name);
if (file == null) {
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
return getSharedPreferences(file, mode);
}

这个是我们常用的,其中 name 为文件名称,也就是生成后保存在 data 目录下的 xml 文件名称,mode 为操作模式,通常我们传入的都是 Context.MODE_PRIVATE,这里只是去获取 file 文件,然后调用 getSharedPreferences(file, mode) 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
...
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);
return sp;
}
}
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}

可以看到,在获取 SharedPreference 时,系统会其做一个缓存,因此不会每次都去 新建一个出来,减少了不必要的开销,这里要特别注意最后一段,当 mode 为Context.MODE_MULTI_PROCESS 或是 Android 版本低于 Android 3.0时,会去执行 startReloadIfChangedUnexpectedly() 方法,这个地方就是在多进程下可以使用的原因,后续再说。

get

调用 SharedPreference 的各种 get 方法,其实是从内存中去拿数据,这里以 getString() 为例

1
2
3
4
5
6
7
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}

其中 awaitLoadedLocked() 的作用保证数据已经从文件中加载到内存中,mLoadedloadFromDisk() 中加载完成后,即 Map 被赋值后被置为 true

1
2
3
4
5
6
7
8
9
private void awaitLoadedLocked() {
....
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
}

commit 和 apply

在获取到 SharedPreference 后,要想保存数据,必须要调用 edit() 方法来获取一个 Editor 对象,Editor 是 SharedPreference 内的一个接口,提供了所有的 put、提交以及清除的方法,它的实现是 EditorImpl。
commit() 方法带有一个布尔的返回值,用来返回是否成功将提交写入文件中,而且是同步写入,因此如果要写入的数据过大,会造成线程阻塞,apply() 方法没有返回值,用异步方式写入,也是比较推荐的一种用法。

1
2
3
4
5
6
7
8
9
10
11
12
 public boolean commit() {
MemoryCommitResult mcr = commitToMemory();
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);
try {
//使用 CountDownLatch 来做等待操作
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
1
2
3
4
5
6
public void apply() {
final MemoryCommitResult mcr = commitToMemory();
....
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
notifyListeners(mcr);
}

可以看到两个方法中都是先通过调用 commitToMemory() 获取到了一个 MemoryCommitResult 对象,commitToMemory() 主要是将 Editor 中的更改添加到 SharedPreference 的缓存 Map 中去,如果调用了 Clear(),则会去对 Map 做清空操作,接着会遍历更改写入到 Map 中,最后返回一个 MemoryCommitResult 对象

1
return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,mapToWriteToDisk);

其中 memoryStateGeneration 是一个长整型,用来记录当前内存的状态,会在每次修改后加一,keysModified 是所有更改的 key 值,listeners 是通过registerOnSharedPreferenceChangeListener() 注册的 Listener 集合,mapToWriteToDisk是修改后需要写入磁盘的 Map。在获取到 MemoryCommitResult 后,会将其传入 SharedPreference 的 enqueueDiskWrite() 方法中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
final boolean isFromSyncCommit = (postWriteRunnable == null);
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

这里要注意的是 mDiskWritesInFlight 是在 commitToMemory() 做加一操作,因此,如果传入的 postWriteRunnable 为空,则 wasEmpty 肯定为true,因此 commit() 方法会同步写入,否则会将 postWriteRunnable 传入到 QueuedWork.queue() 中去

1
2
3
4
5
6
7
8
9
10
11
12
LinkedList<Runnable> sWork = new LinkedList<>();
public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler();
synchronized (sLock) {
sWork.add(work);
if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}

queue () 中会通过 getHandler 来获取到一个 Handler,然后通过这个Handler发送一条消息,其实这里就是 apply() 是异步写入的关键,通过查看 getHandler() 代码,发现里面就是通过 HandlerThread 来获取这个 Handler,因此也完成了线程的切换

1
2
3
4
5
6
7
8
9
10
11
private static Handler getHandler() {
synchronized (sLock) {
if (sHandler == null) {
HandlerThread handlerThread = new HandlerThread("queued-work-looper",
Process.THREAD_PRIORITY_FOREGROUND);
handlerThread.start();
sHandler = new QueuedWorkHandler(handlerThread.getLooper());
}
return sHandler;
}
}

Mode

  • Context.MODE_PRIVATE 表明只能被当前应用读写或是分享同一 user ID 的所有应用读写
  • Context.MODE_MULTI_PROCESS 已被标记为弃用,随时可能会被移除,如果在多进程下官方推荐使用 ContentProvider 来进行数据共享

多进程中使用

首先来分析下为什么会出现获取数据为空的情况,之前在看 Context.getSharedPreferences() 时,可以看到 Context 会对 SharedPreference 做一个缓存,即只会在第一次获取时才会新创建对象,因此,对应的 SharedPreference 构造函数中的 startLoadFromDisk() 也只有在第一次才会调用,那么,问题就来了,当你在主进程中添加或修改了数据,而在进程2中已经获取过对应的SharedPreference,这时在进程2中去调用 get 方法,因为进程2内存中保存的 Map 中数据并未更改,所以返回空数据或旧数据。
而当我们把 mode 改为 Context.MODE_MULTI_PROCESS 时为什么就可以获取到正确的数据了呢?主要原因就在 getSharedPreferences() 中,当 mode Context.MODE_MULTI_PROCESS 时,会调用下面这个方法来重新从文件中读取数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 void startReloadIfChangedUnexpectedly() {
synchronized (mLock) {
if (!hasFileChangedUnexpectedly()) {
return;
}
startLoadFromDisk();
}
}

//还有一处调用是在 SharedPreferencesImpl 的构造函数中
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}

因此,这里主要是强制在每次调用 getSharedPreferences() 时都去从文件中重新加载一边,保证此时内存中的数据是最新的。

总结

在源码中可以发现官方已经将 Context.MODE_MULTI_PROCESS 标记为弃用,而且极力推荐使用 ContentProvider 来进行进程间的数据共享,因此使用这个 mode 来在进程下使用 SharedPreference 是不安全的,但有时我们只是需要存储一些简单的数据,用 ContentProvider 好像又有点过于繁琐了,所以我觉得,也需要视情况而定,如果你能保证自己的数据量不大,且使用不是很频繁,那么使用这个 mode 也不失为一个办法。另外,在多进程下想安全的像使用 SharedPreference 来保存和读取数据,不妨试试腾讯开源的 MMKV