这也正是很多开发者诟病SharedPreferences
的原因之一,那么,从事物的两面性上来看,高内存占用 真的是设计者的问题吗?
不尽然,因为SharedPreferences
的设计初衷是数据的 轻量级存储 ,对于类似应用的简单的配置项(比如一个boolean
或者int
类型),即使很多也并不会对内存有过高的占用;而对于复杂的数据(比如复杂对象序列化后的字符串),开发者更应该使用类似Room
这样的解决方案,而非一股脑存储到SharedPreferences
中。
因此,相对于「SharedPreferences
会导致内存使用过高」的说法,笔者更倾向于更客观的进行总结:
虽然 内存缓存机制 表面上看起来好像是一种 空间换时间 的权衡,实际上规避了短时间内频繁的I/O
操作对性能产生的影响,而通过良好的代码规范,也能够避免该机制可能会导致内存占用过高的副作用,所以这种设计是 值得肯定 的。
3、写操作的优化
针对写操作,设计者同样设计了一系列的接口,以达到优化性能的目的。
我们知道对键值对进行更新是通过mSharedPreferences.edit().putString().commit()
进行操作的——edit()
是什么,commit()
又是什么,为什么不单纯的设计初mSharedPreferences.putString()
这样的接口?
设计者希望,在复杂的业务中,有时候一次操作会导致多个键值对的更新,这时,与其多次更新文件,我们更倾向将这些更新 合并到一次写操作 中,以达到性能的优化。
因此,对于SharedPreferences
的写操作,设计者抽象出了一个Editor
类,不管某次操作通过若干次调用putXXX()
方法,更新了几个xml
中的键值对,只有调用了commit()
方法,最终才会真正写入文件:
// 简单的业务,一次更新一个键值对
sharedPreferences.edit().putString().commit();
// 复杂的业务,一次更新多个键值对,仍然只进行一次IO操作(文件的写入)
Editor editor = sharedPreferences.edit();
editor.putString();
editor.putBoolean().putInt();
editor.commit(); // commit()才会更新文件
了解到这一点,读者应该明白,通过简单粗暴的封装,以达到类似SPUtils.putXXX()
这种所谓代码量的节省,从而忽略了Editor.commit()
的设计理念和使用场景,往往是不可取的,从设计上来讲,这甚至是一种 倒退 。
另外一个值得思考的角度是,本质上文件的I/O
是一个非常重的操作,直接放在主线程中的commit()
方法某些场景下会导致ANR
(比如数据量过大),因此更合理的方式是应该将其放入子线程执行。
因此设计者还为Editor
提供了一个apply()
方法,用于异步执行文件数据的同步,并推荐开发者使用apply()
而非commit()
。
看起来Editor
+apply()
方法对写操作做了很大的优化,但更多的问题随之而来,比如子线程更新文件,必然会引发 线程安全问题;此外,apply()
方法真的能够像我们预期的一样,能够避免ANR
吗?答案是并不能,这个我们后文再提。
4、数据的更新 & 文件数量的权衡
随着业务复杂度的上升,需要面对新的问题是,xml
文件中的数据量愈发庞大,一次文件的写操作成本也愈发高昂。
xml
中数据是如何更新的?读者可以简单理解为 全量更新 ——通过上文,我们知道xml
文件中的数据会缓存到内存的mMap
中,每次在调用editor.putXXX()
时,实际上会将新的数据存入在mMap
,当调用commit()
或apply()
时,最终会将mMap
的所有数据全量更新到xml
文件里。
由此可见,xml
中数据量的大小,的确会对 写操作 的成本有一定的影响,因此,设计者更建议将 不同业务模块的数据分文件存储 ,即根据业务将数据存放在不同的xml
文件中。
因此,不同的xml
文件应该对应不同的SharedPreferences
对象,如果想要对某个xml
文件进行操作,就通过传不同的文件标识符,获取对应的SharedPreferences
:
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// name参数就是文件名,通过不同文件名,获取指定的SharedPreferences对象
}
因此,当xml
文件过大时,应该考虑根据业务,细分为若干个小的文件进行管理;但过多的小文件也会导致过多的SharedPreferences
对象,不好管理且易混淆。实际开发中,开发者应根据业务的需要进行对应的平衡。
二、线程安全问题
SharedPreferences
是线程安全的吗?
毫无疑问,SharedPreferences
是线程安全的,但这只是对成品而言,对于我们目前的实现,显然还有一定的差距,如何保证线程安全呢?
——那,为了保证线程安全,怎么着不得加个锁吧。
加个锁?那是起步!3把锁,你还别嫌多。你得研究开发写代码时的心理,舍得往代码里吭哧吭哧加锁的开发,压根不在乎再加2把。
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()
时,才尝试将mEditorMap
与mMap
进行合并,以达到数据更新的目的。
因此,这里我们还需要另外一把锁保证mEditorMap
的线程安全,笔者认为,不和mMap
公用同一把锁的原因是,在apply()
被调用之前,getXXX
和putXXX
理应是没有冲突的。
代码实现参考如下:
public final class EditorImpl implements Editor {
@Override
public Editor putString(String key, String value) {
synchronized (mEditorLock) {
mEditorMap.put(key, value);
return this;
}
}
}
而当真正需要执行apply()
进行写操作时,mEditorMap
与mMap
进行合并,这时必须通过2把锁保证mEditorMap
与mMap
的线程安全,保证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
其整体是比较完善的,但是为什么相比较MMKV
和Jetpack DataStore
,其性能依然有明显的落差呢?
如何做好面试突击,规划学习方向?
面试题集可以帮助你查漏补缺,有方向有针对性的学习,为之后进大厂做准备。但是如果你仅仅是看一遍,而不去学习和深究。那么这份面试题对你的帮助会很有限。最终还是要靠资深技术水平说话。
网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。建议先制定学习计划,根据学习计划把知识点关联起来,形成一个系统化的知识体系。
学习方向很容易规划,但是如果只通过碎片化的学习,对自己的提升是很慢的。
我搜集整理过这几年字节跳动,以及腾讯,阿里,华为,小米等公司的面试题,把面试的要求和技术点梳理成一份大而全的“ Android架构师”面试 Xmind(实际上比预期多花了不少精力),包含知识脉络 + 分支细节。
在搭建这些技术框架的时候,还整理了系统的高级进阶教程,会比自己碎片化学习效果强太多。
网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!