大佬教你极简方法来处理Android SharedPreferences设计与实现,为什么说Flutter让移动开发变得更好

109 阅读7分钟

1、保证复杂流程代码的可读性

为了保证SharedPreferences是线程安全的,Google的设计者一共使用了3把锁:

final class SharedPreferencesImpl implements SharedPreferences {

// 1、使用注释标记锁的顺序

// Lock ordering rules:

// - acquire SharedPreferencesImpl.mLock before EditorImpl.mLock

// - acquire mWritingToDiskLock before EditorImpl.mLock

// 2、通过注解标记持有的是哪把锁

@GuardedBy("mLock")

private Map<String, Object> mMap;

@GuardedBy("mWritingToDiskLock")

private long mDiskStateGeneration;

public final class EditorImpl implements Editor {

@GuardedBy("mEditorLock")

private final Map<String, Object> mModified = new HashMap<>();

}

}

复制代码

对于这样复杂的类而言,如何提高代码的可读性?SharedPreferencesImpl做了一个很好的示范:通过注释明确写明加锁的顺序,并为被加锁的成员使用@GuardedBy注解

对于简单的 读操作 而言,我们知道其原理是读取内存中mMap的值并返回,那么为了保证线程安全,只需要加一把锁保证mMap的线程安全即可:

public String getString(String key, @Nullable String defValue) {

synchronized (mLock) {

String v = (String)mMap.get(key);

return v != null ? v : defValue;

}

}

复制代码

那么,对于 写操作 而言,我们也能够通过一把锁达到线程安全的目的吗?

2、保证写操作的线程安全

对于写操作而言,每次putXXX()并不能立即更新在mMap中,这是理所当然的,如果开发者没有调用apply()方法,那么这些数据的更新理所当然应该被抛弃掉,但是如果直接更新在mMap中,那么数据就难以恢复。

因此,Editor本身也应该持有一个mEditorMap对象,用于存储数据的更新;只有当调用apply()时,才尝试将mEditorMapmMap进行合并,以达到数据更新的目的。

因此,这里我们还需要另外一把锁保证mEditorMap的线程安全,笔者认为,不和mMap公用同一把锁的原因是,在apply()被调用之前,getXXXputXXX理应是没有冲突的。

代码实现参考如下:

public final class EditorImpl implements Editor {

@Override

public Editor putString(String key, String value) {

synchronized (mEditorLock) {

mEditorMap.put(key, value);

return this;

}

}

}

复制代码

而当真正需要执行apply()进行写操作时,mEditorMapmMap进行合并,这时必须通过2把锁保证mEditorMapmMap的线程安全,保证mMap最终能够更新成功,最终向对应的xml文件中进行更新。

文件的更新理所当然也需要加一把锁:

// SharedPreferencesImpl.EditorImpl.enqueueDiskWrite()

synchronized (mWritingToDiskLock) {

writeToFile(mcr, isFromSyncCommit);

}

复制代码

最终,我们一共通过使用了3把锁,对整个写操作的线程安全进行了保证。

篇幅限制,本文不对源码进行详细引申,有兴趣的读者可参考 SharedPreferencesImpl.EditorImpl 类的apply()源码。

3、摆脱不掉的ANR

apply()方法设计的初衷是为了规避主线程的I/O操作导致ANR问题的产生,那么,ANR的问题真得到了有效的解决吗?

并没有,在 字节跳动技术团队这篇文章 中,明确说明了线上环境中,相当一部分的ANR统计都来自于SharedPreference,由此可见,apply()并没有完全规避掉这个问题,那么导致ANR的原因又是什么呢。

经过我们的优化,SharedPreferences的确是线程安全的,apply()的内部实现也的确将I/O操作交给了子线程,可以说其本身是没有问题的,而其原因归根到底则是Android的另外一个机制。

apply()方法中,首先会创建一个等待锁,根据源码版本的不同,最终更新文件的任务会交给QueuedWork.singleThreadExecutor()单个线程或者HandlerThread去执行,当文件更新完毕后会释放锁。

但当Activity.onStop()以及Service处理onStop等相关方法时,则会执行 QueuedWork.waitToFinish()等待所有的等待锁释放,因此如果SharedPreferences一直没有完成更新任务,有可能会导致卡在主线程,最终超时导致ANR

什么情况下SharedPreferences会一直没有完成任务呢? 比如太频繁无节制的apply(),导致任务过多,这也侧面说明了SPUtils.putXXX()这种粗暴的设计的弊端。

Google为何这么设计呢?字节跳动技术团队的这篇文章中做出了如下猜测:

无论是 commit 还是 apply 都会产生 ANR,但从 Android 之初到目前 Android8.0,Google 一直没有修复此 bug,我们贸然处理会产生什么问题呢。Google 在 Activity 和 Service 调用 onStop 之前阻塞主线程来处理 SP,我们能猜到的唯一原因是尽可能的保证数据的持久化。因为如果在运行过程中产生了 crash,也会导致 SP 未持久化,持久化本身是 IO 操作,也会失败。

如此看来,导致这种缺陷的原因,其设计也的确是有自身的考量的,好在 这篇文章 末尾也提出了一个折衷的解决方案,有兴趣的读者可以了解一下,本文不赘述。

三、进程安全问题


1、如何保证进程安全

SharedPreferences是否进程安全呢?让我们打开SharedPreferences的源码,看一下最顶部类的注释:

/**

  • ...

  • This class does not support use across multiple processes.

  • ...

*/

public interface SharedPreferences {

// ...

}

复制代码

由此,由于没有使用跨进程的锁,SharedPreferences是进程不安全的,在跨进程频繁读写会有数据丢失的可能,这显然不符合我们的期望。

那么,如何保证SharedPreferences进程的安全呢?

实现思路很多,比如使用文件锁,保证每次只有一个进程在访问这个文件;或者对于Android开发而言,ContentProvider作为官方倡导的跨进程组件,其它进程通过定制的ContentProvider用于访问SharedPreferences,同样可以保证SharedPreferences的进程安全;等等。

篇幅原因,对实现有兴趣的读者,可以参考 百度 或文章末尾的 参考资料

2、文件损坏 & 备份机制

SharedPreferences再次迎来了新的挑战。

由于不可预知的原因(比如内核崩溃或者系统突然断电),xml文件的 写操作 异常中止,Android系统本身的文件系统虽然有很多保护措施,但依然会有数据丢失或者文件损坏的情况。

作为设计者,如何规避这样的问题呢?答案是对文件进行备份,SharedPreferences的写入操作正式执行之前,首先会对文件进行备份,将初始文件重命名为增加了一个.bak后缀的备份文件:

// 尝试写入文件

private void writeToFile(...) {

if (!backupFileExists) {

!mFile.renameTo(mBackupFile);

}

}

复制代码

这之后,尝试对文件进行写入操作,写入成功时,则将备份文件删除:

// 写入成功,立即删除存在的备份文件

// Writing was successful, delete the backup file if there is one.

mBackupFile.delete();

复制代码

反之,若因异常情况(比如进程被杀)导致写入失败,进程再次启动后,若发现存在备份文件,则将备份文件重名为源文件,原本未完成写入的文件就直接丢弃:

// 从磁盘初始化加载时执行

private void loadFromDisk() {

synchronized (mLock) {

if (mBackupFile.exists()) {

mFile.delete();

mBackupFile.renameTo(mFile);

}

}

}

复制代码

现在,通过文件备份机制,我们能够保证数据只会丢失最后的更新,而之前成功保存的数据依然能够有效。

四、小结


综合来看,SharedPreferences那些一直被关注的问题,从设计的角度来看,都是有其自身考量的。

我们可以看到,虽然SharedPreferences其整体是比较完善的,但是为什么相比较MMKVJetpack DataStore,其性能依然有明显的落差呢?

这个原因更加综合且复杂,即使笔者也还是处于浅显的了解层面,比如后两者在其数据序列化方面都选用了更先进的protobuf协议,MMKV自身的数据的 增量更新 机制等等,有机会的话会另起新的一篇进行分享。

反过头来,相对于对组件之间单纯进行 不好 的定义,笔者更认为通过辩证的方式去看待和学习它们,相信即使是SharedPreferences,学习下来依然能够有所收获。

参考 & 感谢


细心的读者应该能够发现,关于 参考&感谢 一节,笔者着墨越来越多,原因无他,笔者 从不认为 一篇文章就能够讲一个知识体系讲解的面面俱到,本文亦如是。

因此,读者应该有选择性查看其它优质内容的权利,甚至是为其增加一些简洁的介绍(因为标题大多都很相似),而不是文章末尾甩一堆https开头的链接不知所云。

这也是对这些内容创作者的尊重,如果你喜欢本文,也同样希望你能够喜欢下面这些文章。

1、请不要滥用SharedPreference @Weishu

我们如何定义好的文章?深度引人入胜,笔者觉得缺一不可,深度保证了文章能够经久不衰,引人入胜代表了流畅的 文字功底文章结构,这篇文章将apply()导致的ANR原理通过浅显易懂的方式解构的非常透彻,我认为它是最适合进阶学习SharedPreferences的文章。

2、Android源码分析之SharedPreferences @xiaoweiz

对于一门技术,如何系统掌握其 理论 ,笔者的理解是,学习理解其设计思想,从零开始一步步完善整个系统结构,最终通过源码进行互相印证。

而对于SharedPreferences,学习设计思想,看本文;源码解析,看这篇。

3、Android 之不要滥用 SharedPreferences(下) @godliness

最后

文末放一个小福利给大家,点击我的GitHub即可领取

群内有许多技术大牛,有任何问题,欢迎广大网友一起来交流,群内还不定期免费分享高阶Android学习视频资料和面试资料包~

偷偷说一句:群里高手如云,欢迎大家加群和大佬们一起交流讨论啊!