sp.edit().putString(“c”, “dmn”).apply();
每次调用 edit 方法都会创建一个 Editor 对象,造成额外的内存占用。很多设计者会对 SharedPreferences 进行封装,隐藏掉 edit()
和commit/apply()
调用流程,但往往同时也忽略了Editor.commit/apply()
的设计理念和使用场景。如果是复杂的场景,用户可以在多次 putXxx 方法之后再统一进行 commit/apply()
,也就是一次更新多个键值对,只进行一次 IO 操作。
commit/apply 引起的 ANR 问题
commit 是同步地提交到硬件磁盘,有返回值表明修改是否成功,如果在主线程中提交会阻塞线程,影响后续的操作,可能导致 ANR;而 apply 是将修改数据提交到内存,而后异步真正提交到硬件磁盘,没有返回值。我们着重研究一下 apply 为什么会导致 ANR 问题,先来看看 apply 的源码:
@Override
public void apply() {
final long startTime = System.currentTimeMillis();
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await(); //等待
} catch (InterruptedException ignored) {
}
···
}
};
QueuedWork.addFinisher(awaitCommit); //加入队列
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
notifyListeners(mcr);
}
首先把带有 await 的 runnable 添加到 QueuedWork 队列,然后把这个写入任务 postWriteRunnable 通过 enqueueDiskWrite 交给 HandlerThread(Handler + Thread) 进行执行,待处理的任务排队进行执行。然后我们进入 ActivityThread 的 handleStopActivity 方法中,可以看到如下代码
// Make sure any pending writes are now committed.
if (!r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}
我们再来看看 waitToFinish 中的一段源码
Is called from the Activity base class’s onPause(), after BroadcastReceiver’s onReceive,
- after Service command handling, etc. (so async work is never lost)
*/ //这个注释很重要
public static void waitToFinish() {
···
try {
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
}
if (finisher == null) {
break;
}
finisher.run(); //关键,相当于调用 mcr.writtenToDiskLatch.await()
}
} finally {
sCanDelay = true;
}
}
还记得之前的 QueuedWork.addFinisher(awaitCommit)
吗,里面的 awaitCommit 在等待写入线程,如果用户使用了太多的 apply,也就是说写入队列中会有很多写入任务。而只有一个线程在写入,一旦涉及到大量的读写很容易造成ANR(android 8.0 之前,android 8.0 之前的实现 QueuedWork.waitToFinish 是有缺陷的。在多个生命周期方法中,在主线程等待任务队列去执行完毕,而由于cpu调度的关系任务队列所在的线程并不一定是处于执行状态的,而且当apply提交的任务比较多时,等待全部任务执行完成,会消耗不少时间,这就有可能出现 ANR),因为本文的源码时基于 android 29 的,所以该版本或者说是 android 8.0之后并不存在 ANR 问题,因为 8.0之后做了很大的优化,会主动触发processPendingWork取出写任务列表中依次执行,而不是只在在等待。还有一个更重要的优化:
我们知道在调用 apply 方法时,会将改动同步提交到内存中 map 中,然后将写入磁盘的任务加入的队列中,在工作线程中从队列中取出写入任务,依次执行写入。注意,不管是内存的写入还是磁盘的写入,对于一个 xml 格式的 sp 文件来说,都是全量写入的。 这里就存在优化的空间,比如对于同一个 sp 文件,连续调用 n 次apply,就会有 n 次写入磁盘任务执行,实际上只需要最后执行最后那次就可以了,最后那次提交对应内存的 map 是持有最新的数据,所以就可以省掉前面 n-1 次的执行,这个就是android 8.0中做的优化,是使用版本来进行控制的。
解决方案
解决方案可以参考今日头条的解决方案,通过反射 ActivityThread 中的 H(Handler) 变量,给 Handler 设置一个 callback,Handler 的 dispatchMessage 中先处理 callback。队列清理需要反射调用 QueuedWork。Google 之所以在Activity/Service 的 onStop 之前调用该方法是为了尽量保证 sp 的数据持久化,该文章中也对比了清理队列和未清理情况下的失败率(相差不大)。
还有一个解决方案,因为 SharedPreferences 是个接口,所以可以自己实现 apply (异步调用系统 commit,这样并不会导致类似系统 apply 那样的阻塞),同时重写 Activity 和 Application 的 getSharedPreference 方法,直接返回自己的实现。但是这个方案带来的副作用比清理等待锁更加明显:系统apply是先同步更新缓存再异步写文件,调用方在同一线程内读写缓存是同步的,无需关心上下文数据读写同步问题;commit