关于SharePreferences(以下简称SP)的使用,相信从刚开发Android都开始使用了,但是对于SP的原理以及SP的缺点可能很多人没有系统的认知。
首先说一下SP的结论:
- 容易因此ANR:SP不适合存储数据量很大的信息;同时JSON以及HTML最好也不用SP存储,因为特殊字符转义是非常消耗性能的。
- 全量写入:在apply或者commit的时候,会先添加信息到内存中,在开启子线程,将内存中的信息写入到磁盘中(先清空磁盘该文件的信息,在全部写入)。
- 跨进程不安全:Sp没有跨进程的锁,就算使用MODE_MULTI_PROCESS,当频繁的操作SP时,也可能会造成数据丢失。
- 在Android N 之后 不再支持MODE_WORLD_READABLE, MODE_WORLD_WRITEABLE默认(不能被其他应读写)
- commit:当数据写入内存以及写入磁盘,都完成时调用listener。有返回值。 apply: 数据写入内存成功之后,在写入磁盘的过程中,直接调用listener,并不会等待写入磁盘完成。无返回值。
问题:
- SP为什么不适合存储很大的数据量?
- apply和commit的区别是否可以从源码中获得答案
- 为什么android7.0之后不支持MODE_WORLD_READABLE和MODE_WORLD_WRITEABLE?
- 加载磁盘数据不是开启了子线程了吗? 那为什么还会造成ANR呢?
源码分析:
context.getSharedPreferences(String string, int mode);
我们知道实际上context的真正实现类是ContextImp,所以进入到ContextImp的getSharedPreferences方法查看:
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
......
File file;
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
//定义类型:ArrayMap<String, File> mSharedPrefsPaths;
mSharedPrefsPaths = new ArrayMap<>();
}
//从mSharedPrefsPaths中是否能够得到file文件
file = mSharedPrefsPaths.get(name);
if (file == null) {//如果文件为null
//就创建file文件
file = getSharedPreferencesPath(name);
将name,file键值对存入集合中
mSharedPrefsPaths.put(name, file);
}
}
return getSharedPreferences(file, mode);
}
从上述代码可知,获取file文件,如果不存在就创建,存在就直接使用。其中mSharedPrefsPaths的泛型时:<String, File> , 顾名思义:<文件名,文件>。
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
//重点1
checkMode(mode);
.......
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
//获取缓存对象(或者创建缓存对象)
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
//通过键file从缓存对象中获取Sp对象
sp = cache.get(file);
//如果是null,就说明缓存中还没后该文件的sp对象
if (sp == null) {
//重点2:从磁盘读取文件
sp = new SharedPreferencesImpl(file, mode);
//添加到内存中
cache.put(file, sp);
//返回sp
return sp;
}
}
//如果设置为MODE_MULTI_PROCESS模式,那么将执行SP的startReloadIfChangedUnexpectedly方法。
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}
根据注释不难理解,重点说下流程:
- 获取缓存区,从缓存区中获取数据,看是否存在sp对象,如果存在就直接返回
- 如果不存在,那么就从磁盘获取数据,
- 从磁盘获取的数据之后,添加到内存中,
- 返回sp。
如果设置MODE_MULTI_PROCESS模式,之后的操作我们下文分析。
标记了两个重点,先看下重点1:
private void checkMode(int mode) {
if (getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.N) {
//抛出异常
if ((mode & MODE_WORLD_READABLE) != 0) {
throw new SecurityException("MODE_WORLD_READABLE no longer supported");
}
if ((mode & MODE_WORLD_WRITEABLE) != 0) {
throw new SecurityException("MODE_WORLD_WRITEABLE no longer supported");
}
}
}
从上述源码是不是一目了然呢? 就是说在Android N 之后,不支持MODE_WORLD_READABLE以及MODE_WORLD_WRITEABLE模式了。这两个模式就是设置该文件是否可以被其他应用使用,在高版本中,Google可能也是出于安全考虑才移除了这两个模式。那么我们设置MODE_MULTI_PROCESS呢?实际上设置该模式之后,会对比当前缓存中的修改时间以及文件大小是否和磁盘的修改时间以及文件大小是否一致,如果不一致,就重新读取磁盘数据,更新到缓存中。
看下重点2,看下是怎么读取磁盘文件的:
SharedPreferencesImpl(File file, int mode) {
mFile = file; //存储文件
//备份文件(灾备文件)
mBackupFile = makeBackupFile(file);
//模式
mMode = mode;
//是否加载过了
mLoaded = false;
// 存储文件内的键值对信息
mMap = null;
//从名字可以知道是:开始加载数据从磁盘
startLoadFromDisk();
}
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
//开启子线程加载磁盘数据
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
从上述源码,可以清楚的看到实际上加载磁盘数据是开启了一个子线程的。这里有个备份文件存在,其实在跨进程操作以及频繁操作可能会出现异常,此时备份文件排场用场了;是否可以猜测到:备份文件就是程序正常运行的情况下,是不会存在的,在异常的情况下,才会产生。至于怎么用,继续看:
private void loadFromDisk() {
synchronized (mLock) {
//如果加载过了 直接返回
if (mLoaded) {
return;
}
//备份文件是否存在,
if (mBackupFile.exists()) {
//删除file原文件
mFile.delete();
//将备份文件命名为:xml文件
mBackupFile.renameTo(mFile);
}
}
.......
Map map = null;
StructStat stat = null;
try {
//下面的就是读取数据
stat = Os.stat(mFile.getPath());
if (mFile.canRead()) {
BufferedInputStream str = null;
try {
str = new BufferedInputStream(
new FileInputStream(mFile), 16*1024);
map = XmlUtils.readMapXml(str);
} catch (Exception e) {
Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
} finally {
IoUtils.closeQuietly(str);
}
}
} catch (ErrnoException e) {
/* ignore */
}
synchronized (mLock) {
//已经加载完毕,
mLoaded = true;
//数据不是null
if (map != null) {
//将map赋值给全局的存储文件键值对的mMap对象
mMap = map;
//更新内存的修改时间以及文件大小
mStatTimestamp = stat.st_mtime;
mStatSize = stat.st_size;
} else {
mMap = new HashMap<>();
}
//重点:唤醒所有以mLock锁的等待线程
mLock.notifyAll();
}
}
首先判断备份文件是否存在,如果存在,就更该备份文件的后缀名;接着就开始读取数据,然后将读取的数据赋值给全局变量存储文件键值对的mMap对象,并且更新修改时间以及文件大小变量。
最后一句话标了重点:唤醒所有以mLock为锁的等待线程。
到此为止,初始化SP对象就算完成了,其实可以看出来就是一个二级缓存流程:磁盘到内存。
接下来看下获取SP文件流程:
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) { 锁判断
awaitLoadedLocked(); //等待机制
String v = (String)mMap.get(key); //从键值对中获取数据
return v != null ? v : defValue;
}
}
private void awaitLoadedLocked() {
.......
while (!mLoaded) { //在加载数据完毕的时候,值为true
try {
//线程等待
mLock.wait();
} catch (InterruptedException unused) {
}
}
}
从上述操作可以看出,如果数据没有加载完毕(也就是说mLoaded=false),此时将线程等待,注意:getXXX方法是发生在UI线程的,试想一下,如果文件很大,那么意味着子线程加载数据需要很长时间,那么是不是就一直等待下去了,相当于子线程执行完了,UI线程才会苏醒,这样的话,可怕不?ANR还远吗?!
接下来看下putXXX以及apply源码:
public Editor edit() {
//跟getXXX原理一样
synchronized (mLock) {
awaitLoadedLocked();
}
//返回EditorImp对象
return new EditorImpl();
}
public Editor putBoolean(String key, boolean value) {
synchronized (mLock) {
mModified.put(key, value);
return this;
}
}
public void apply() {
final long startTime = System.currentTimeMillis();
//根据名字可以知道:提交数据到内存
final MemoryCommitResult mcr = commitToMemory();
........
//提交数据到磁盘中
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
//重点:调用listener
notifyListeners(mcr);
}
根据上述代码可以看出,其实是先执行了commitToMemory,提交数据到内存;然后提交数据到磁盘中;紧接着调用了listener,注意是:紧接着!如果提交数据到磁盘中是异步的,那么是不是就意味着,执行回调会提前执行,不会等待子线程执行结果呢? 答案是肯定的。
先看下commitToMemory方法:
// Returns true if any changes were made
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
//写到磁盘的数据集合
Map<String, Object> mapToWriteToDisk;
synchronized (SharedPreferencesImpl.this.mLock) {
if (mDiskWritesInFlight > 0) {
mMap = new HashMap<String, Object>(mMap);
}
//赋值此时缓存集合给mapToWriteToDisk
mapToWriteToDisk = mMap;
.......
synchronized (mLock) {
boolean changesMade = false;
//重点:是否清空数据
if (mClear) {
if (!mMap.isEmpty()) {
changesMade = true;
//清空缓存中键值对信息
mMap.clear();
}
mClear = false;
}
//循环mModified,将mModified中的数据更新到mMap中
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
// "this" is the magic value for a removal mutation. In addition,
// setting a value to "null" for a given key is specified to be
// equivalent to calling remove on that key.
if (v == this || v == null) {
if (!mMap.containsKey(k)) {
continue;
}
mMap.remove(k);
} else {
if (mMap.containsKey(k)) {
Object existingValue = mMap.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
//注意:此时把键值对信息写入到了缓存集合中
mMap.put(k, v);
}
.........
}
//清空临时集合
mModified.clear();
......
}
}
return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
mapToWriteToDisk);
}
先说一个两个变量:mModified就是我们本次要更新添加的键值对集合;mClear是我们调用clear()方法的时候赋值的;大致流程就是:首先判断是否需要清空内存数据,然后循环mModified集合,添加更新数据到内存的键值对集合中。
注意:看下如下代码:
preferences.edit().clear().putString("name", "lisi").apply();
那么此时的name值是多少呢? 很显然是lisi,因为源码中是:先清空内存集合键值对,有循环更新了内存键值对。
接着来看下commit方法:
public boolean commit() {
.......
//更新数据到内存
MemoryCommitResult mcr = commitToMemory();
//更新数据到磁盘
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
//等待:等待磁盘更新数据完成
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
if (DEBUG) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " committed after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
//执行listener回调
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
从上述源码,在对比一下apply的流程,是不是一目了然呢? 首先apply没有返回值,commit有返回值;其实apply执行回调是和数据写入磁盘并行执行的,而commit方法执行回调是等待磁盘写入数据完成之后。
针对SP的性能问题,腾讯推出了开源项目MMVK,可以很好弥补SP的缺点。
MMVK
至此SP的原理就分析完了,文中如有错误,欢迎指正。
Android开发学习交流群: