SharedPreferences 是 Android 里面一个轻量级别的存储方案,不过随着项目的发展,SharedPreferences 使用不当,也很容易引发一些问题,甚至会导致 Crash 的发生
因此,我们有必要搞清楚 SharedPreferences 的基本原理
SharedPreferences
SharedPreferences
的本质是一个基于xml文件存储的key-value键值对数据的文件操作工具类,如下:
<?xml version="1.0" encoding="utf-8"?>
<map>
<String name="name">fritz</String>
</map>
它的基本使用如下:
SharedPreferences sp = context.getSharedPreferences(“sp”, Context.MODE_PRIVATE);
sp.edit().putString(“name”, fritz).commit();
直接在这里开始阅读吧:
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
//....省去部分代码
File file;
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
//生成缓存xml文件file的map
mSharedPrefsPaths = new ArrayMap<>();
}
//获取缓存
file = mSharedPrefsPaths.get(name);
if (file == null) {
//生成本地xml文件并写入缓存中
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
return getSharedPreferences(file, mode);
}
这里看到一个关键的对象 mSharedPrefsPaths
:
/**
* 用于存储xml文件的file的map
*/
private ArrayMap<String, File> mSharedPrefsPaths;
好了,接着往下看:
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
//获取sp的cache
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
//得到sp的实现类
sp = cache.get(file);
//如果为null
if (sp == null) {
//...省去部分代码
//生成sp的实现类
sp = new SharedPreferencesImpl(file, mode);
//存入缓存中
cache.put(file, sp);
return sp;
}
}
//...省去部分代码
return sp;
}
可以看到另一个值得注意的对象 cache
,它是 File
映射到 SharedPreferences
的关键:
private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
if (sSharedPrefsCache == null) {
//生成缓存的map
sSharedPrefsCache = new ArrayMap<>();
}
final String packageName = getPackageName();
ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
if (packagePrefs == null) {
packagePrefs = new ArrayMap<>();
//按照应用报名来做key,存入缓存中
sSharedPrefsCache.put(packageName, packagePrefs);
}
return packagePrefs;
}
sSharedPrefsCache
其实很简单:
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
可以知道,它其实是针对包名来缓存 App 下面的多个 SharedPreferences
结合上面的代码,缓存流程如下:
SharedPreferencesImpl
SharedPreferencesImpl
是 SharedPreferences
的实现类,它在构造方法里面读取本地的 xml 文件并将其内容转为 map :
SharedPreferencesImpl(File file, int mode) {
//sp操作的本地xml文件对象
mFile = file;
//一个容灾文件,用于异常恢复,后缀为.bak
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
//sp全部的键值对数据,通过解析mFile得到数据
mMap = null;
mThrowable = null;
//异步解析本地的mFile并对mMap进行赋值
startLoadFromDisk();
}
可以看到 SharedPreferencesImpl
一开始就准备了一个异常恢复文件并且读取本地磁盘,追踪一下 startLoadFromDisk
:
private void loadFromDisk() {
synchronized (mLock) {
//标记位来记录是否读取完成
if (mLoaded) {
return;
}
if (mBackupFile.exists()) {
//如果容灾文件存在就同步到mFile
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
Map<String, Object> map = null;
StructStat stat = null;
Throwable thrown = null;
try {
stat = Os.stat(mFile.getPath());
if (mFile.canRead()) {
BufferedInputStream str = null;
try {
str = new BufferedInputStream(
new FileInputStream(mFile), 16 * 1024);
//通过XmlUtils将内容赋值给map
map = (Map<String, Object>) XmlUtils.readMapXml(str);
} catch (Exception e) {
Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
} finally {
IoUtils.closeQuietly(str);
}
}
} catch (ErrnoException e) {
} catch (Throwable t) {
thrown = t;
}
synchronized (mLock) {
//完成读取
mLoaded = true;
mThrowable = thrown;
try {
if (thrown == null) {
if (map != null) {
//map的赋值,这里就等于将xml内容转为了map
mMap = map;
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
} else {
mMap = new HashMap<>();
}
}
} catch (Throwable t) {
mThrowable = t;
} finally {
//激活其他正在等待的线程
mLock.notifyAll();
}
}
}
可以看出,构造方法这里准备了一个容灾文件并且通过 XmlUtils.readMapXml
将本地 xml 文件里面的数据全部存储到静态 mMap
里面去了
这也是为什么 Google 反复强调 SharedPreferences
只能存储轻量级别的数据,因为 App 下的全部 SharedPreferences
以及对应的 File
都已经给静态存储起来了,也就是说 xml 数据会一直存在内存之中了,试想下如果在 xml 里面存入大量的数据,那么对应也会消耗非常大的内存了,这点必须注意
不过将数据都存入内存中,优势也很明显,那就是读取速度非常快。唯一需要注意的就是每次读操作都要通过 mLoaded
判断 mMap
是否初始化完成了:
@Override
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
eturn v != null ? v : defValue;
}
}
private void awaitLoadedLocked() {
if (!mLoaded) {
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}
可以看到,如果 mMap
没有完成初始化,那么线程就会一直卡在直到初始化完成为止,如果当前线程是主线程,而 xml 数据量也比较大的情况下,就可能会发生 ANR 了
因此,我们使用 SharedPreferences
时,文件必须足够小,我们可以针对不同的功能需求来创建复数以上的 xml 文件;比分说我们需要在启动页里面通过 SharedPreferences
配置是否打开 App 的引导页面,就创建一个只用于 app 初始化的 SharedPreferences
,更小的 SharedPreferences
可以更快完成初始化,加快 app 的启动速度
Editor
SharedPreferences
的读取操作就只是简单的 map 取值,但是写入操作就不是了,全部的写入修改操作都是通过 Editor
对象,而 Editor
的实质是 EditorImpl
:
public final class EditorImpl implements Editor {
//锁
private final Object mEditorLock = new Object();
//一个用于diff的 map
private final Map<String, Object> mModified = new HashMap<>();
//一个是否清空数据的 flag
private boolean mClear = false;
@Override
public Editor putString(String key, @Nullable String value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}
//......
}
写入操作时,不是直接对 mMap
进行操作,而是先写入 mModified
中,如果忘记调用 commit
或者 apply
方法,数据其实并没有写入磁盘,在写入磁盘后将会清空 mModified
,因此可以考虑将其单例化
mModified
只是个用来修改 mMap
的diff 工具,真正用于修改本地数据的操作是 commit()
和 apply()
这两个方法;它们的区别就是 apply()
是异步将数据写入本地存储,commit()
则是直接写入本地存储,但是会返回一个是否操作成功的布尔值
从效率考虑的话, apply()
的效率是比 commit()
要高的;如果你很重视写入操作是否成功,需要做一些补救措施,那就使用 commit()
吧
下面来分析下它们的源码实现吧
commit
@Override
public boolean commit() {
//提交到内存中
MemoryCommitResult mcr = commitToMemory();
//执行写入本地存储到操作,传入 mcr 对象
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
//开启 mcr 里面的闭锁
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
}
//锁释放了,发送通知
notifyListeners(mcr);
//锁释放后,返回操作结果
return mcr.writeToDiskResult;
}
apply
@Override
public void apply() {
//提交到内存中
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
//开启 mcr 里面的闭锁
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
};
//添加一个Finisher
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
//启动闭锁
awaitCommit.run();
// 任务完成后移除Finisher
QueuedWork.removeFinisher(awaitCommit);
}
};
//执行写入本地存储到操作,传入 mcr 对象和 postWriteRunnable
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
//发送通知
notifyListeners(mcr);
}
关于 QueuedWork
,推荐阅读这篇文章 QueuedWork 的源码分析 ,在这里 QueuedWork
就是个安全锁, QueuedWork
有个 waitToFinish
方法在 Activity
进入 onStop()
时回调,而 waitToFinish
会执行队列中的 Runnable
方法,触发了闭锁来阻塞主线程,这就可以确保 Activity
销毁之前将内容写到本地存储里面去
写入本地的操作
不管是 commit
还是 apply
,其核心方法都是 commitToMemory
和 enqueueDiskWrite
,下面我们来看下源码;
commitToMemory
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
Map<String, Object> mapToWriteToDisk;
synchronized (SharedPreferencesImpl.this.mLock) {
//说明至少有1个线程在使用 mMap
if (mDiskWritesInFlight > 0) {
//当前是多线程的情况,直接修改 mMap 会出现写入混乱的情况
//此时的内存修改需要克隆获得新拷贝,然后再修改拷贝的内容
mMap = new HashMap<String, Object>(mMap);
}
mapToWriteToDisk = mMap;
//写入任务数递增,多线程的话会大于1
mDiskWritesInFlight++;
//是否有监听器
boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
//获取所有被修改的keys
keysModified = new ArrayList<String>();
//获取所有注册的修改事件监听器
listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}
synchronized (mEditorLock) {
//是否有数据修改的flag
boolean changesMade = false;
//如果有清空设置,那么清空本地缓存
if (mClear) {
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
//把本地存储数据全部置空
mapToWriteToDisk.clear();
}
mClear = false;
}
//对比新旧数据进行更新修改
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
if (v == this || v == null) {
//检查 mapToWriteToDisk 中是否包含这个 key,没有则检查下一个 key
if (!mapToWriteToDisk.containsKey(k)) {
continue;
}
//删除空数据
mapToWriteToDisk.remove(k);
} else {
//检查是否需要修改原有的数据
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
if (existingValue != null && existingValue.equals(v)) {
//相同的数据,不要修改本地存储
continue;
}
}
//更新数据
mapToWriteToDisk.put(k, v);
}
changesMade = true;
if (hasListeners) {
keysModified.add(k);
}
}
//清空存储diff数据的map
mModified.clear();
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
//返回一个 MemoryCommitResult 对象
return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
mapToWriteToDisk);
}
上面代码也很简单,就是把 mMap
暂存的修改提交到 mapToWriteToDisk
并且让 mapToWriteToDisk
和 mModified
进行对比合并差异数据,最终存入到 MemoryCommitResult
里面:
接着看下这个 MemoryCommitResult
的源码:
private static class MemoryCommitResult {
final long memoryStateGeneration;
final List<String> keysModified;
final Set<OnSharedPreferenceChangeListener> listeners;
//写入本地存储的 Map
final Map<String, Object> mapToWriteToDisk;
//闭锁,用于阻塞线程直到写入操作结束后解锁
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);
//写入操作是否成功
volatile boolean writeToDiskResult = false;
// 是否有实际内容写入本地存储
boolean wasWritten = false;
private MemoryCommitResult(long memoryStateGeneration, List<String> keysModified,
Set<OnSharedPreferenceChangeListener> listeners,
Map<String, Object> mapToWriteToDisk) {
this.memoryStateGeneration = memoryStateGeneration;
this.keysModified = keysModified;
this.listeners = listeners;
this.mapToWriteToDisk = mapToWriteToDisk;
}
void setDiskWriteResult(boolean wasWritten, boolean result) {
//是否有写入内容的结果
this.wasWritten = wasWritten;
//得到是否写入操作成功的结果
writeToDiskResult = result;
//解锁
writtenToDiskLatch.countDown();
}
}
可以看出, MemoryCommitResult
主要是用来记录操作结果,保管写入磁盘的数据以及给写入操作加一把锁和解锁,下面我们来看下具体的写入操作了
enqueueDiskWrite
参数 postWriteRunnable
非空说明是 apply()
的执行逻辑,反之是 commit()
的执行逻辑
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
//flag,用于判断是否 commit 调用
final boolean isFromSyncCommit = (postWriteRunnable == null);
//执行写入操作的 Runnable
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
//写入本地存储
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
//写入结束后,操作写入的线程数减1
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
//上锁直到写入操作完成后解锁
//apply 的执行逻辑
postWriteRunnable.run();
}
}
};
if (isFromSyncCommit) {
//当前是 commit 的执行逻辑
boolean wasEmpty = false;
synchronized (mLock) {
//判断当前是否处于多线程的环境
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
//如果不是,那么commit 就直接执行写入操作
writeToDiskRunnable.run();
return;
}
}
//如果是多线程,使用队列确保写入任务的执行顺序
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
可以看出,不管是 commit()
还是 apply()
都是利用 CountDownLatch
来确保写入操作可以顺利完成的,同时让在多线程中执行写入操作不影响写入任务的执行顺序,同时看下 QueuedWork.queue
的逻辑:
private static final long DELAY = 100;
private static Handler getHandler() {
synchronized (sLock) {
if (sHandler == null) {
HandlerThread handlerThread = new HandlerThread("queued-work-looper",
Process.THREAD_PRIORITY_FOREGROUND);
handlerThread.start();
//得到一个异步的Handler
sHandler = new QueuedWorkHandler(handlerThread.getLooper());
}
return sHandler;
}
}
public static void queue(Runnable work, boolean shouldDelay) {
//获取一个异步的Handler对象
Handler handler = getHandler();
synchronized (sLock) {
//存储 Runnable 任务的队列
sWork.add(work);
if (shouldDelay && sCanDelay) {
//如果是 apply ,那么就延时 100 ms 执行 Runnable
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
//如果是 commit ,那么立即执行 Runnable
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
这里就知道了 applay
是怎么异步进行写入操作了,也可以看出 commit()
和 apply()
执行的各自不同的执行逻辑了:
最终写入到本地的方法都是 writeToFile
:
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
// 原文件是否存在
boolean fileExists = mFile.exists();
// 重命名当前文件,以便在下次读取时将其用作备份
if (fileExists) {
//是否需要执行写入操作的标记位
boolean needsWrite = false;
//如果磁盘状态值小于此提交的状态值,则只需要写入
if (mDiskStateGeneration < mcr.memoryStateGeneration) {
// commit()写入
if (isFromSyncCommit) {
needsWrite = true;
} else {
// apply()异步写入
synchronized (mLock) {
// 中间状态不需要持久化,仅持久化最新状态
if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
needsWrite = true;
}
}
}
}
//不需要执行写入操作
if (!needsWrite) {
//提示结束了,设置结果值并解锁
mcr.setDiskWriteResult(false, true);
return;
}
//容灾文件是否存在
boolean backupFileExists = mBackupFile.exists();
//容灾文件不存在
if (!backupFileExists) {
//把当前文件改名为容灾文件
if (!mFile.renameTo(mBackupFile)) {
Log.e(TAG, "Couldn't rename file " + mFile
+ " to backup file " + mBackupFile);
//容灾文件生成失败,新变更内容不得写入磁盘
//设置结果值并解锁
mcr.setDiskWriteResult(false, false);
return;
}
} else {
//容灾文件已存在,删除源文件
mFile.delete();
}
}
//尝试写入文件、删除备份和返回true时,尽可能做到原子性
//如果出现任何异常则删除新文件,并在下一次从容灾文件中恢复
try {
//创建文件输出流
FileOutputStream str = createFileOutputStream(mFile);
if (str == null) {
//文件输出流创建失败,无法执行写入操作
mcr.setDiskWriteResult(false, false);
return;
}
//把内存中需要写入的数据按照 xml 格式写入到 str 流
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
//同步流,也就是执行写入操作
FileUtils.sync(str);
//关闭流
str.close();
//修改文件权限模式
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
//写入操作顺利完成,删除容灾文件
mBackupFile.delete();
//磁盘状态值更新
mDiskStateGeneration = mcr.memoryStateGeneration;
//设置结果并解锁
mcr.setDiskWriteResult(true, true);
//同步次数+1
mNumSync++;
//结束函数
return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}
// 写入操作出现异常,删除已写入文件
// 下次读取内容发现mFile不存在,会检查容灾文件并恢复mFile
if (mFile.exists()) {
if (!mFile.delete()) {
Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
}
}
//设置结果值并解锁
mcr.setDiskWriteResult(false, false);
}
从上面代码可以看出每次执行写入本地存储时,流程如下:
- 将之前的
File
文件备份为容灾文件( .bak 格式的文件) - 拿到输出流进行写入操作
- 写入操作完成,删除容灾文件并解锁
- 如果出现写入操作异常,那么删除原来的
xml
文件,下次写入直接从容灾文件开始恢复
也就是说写入操作成果与否全看容灾文件是否存在,如果需要百分百确定写入操作是否成功了,可以检查下容灾文件( .bak 格式的文件)是否存在
使用的建议
上面我们已经对 SharedPreferences
的源码有了深刻的了解,也明白如果使用不当的话会造成各种异常,因此这里就 SharedPreferences
提出下面的使用建议:
- 避免在
SharedPreferences
中存储大量的数据,因为你存储的全部数据都是保存到 App 的内存里面;而且每一次commit()
和apply()
都是创建一个空的文件并一次性写入到本地,因此数据越大耗时就越久,就越容易产生 ANR - 每一次
commit()
和apply()
都有一定的性能消耗,因此不要多次提交修改,将全部的修改集中在一起,一次性提交 - 针对使用频率和功能模块来划分不同的
SharedPreferences
文件,更小的文件意味着更快的加载速度 - 要注意到
SharedPreferences
的第一次初始化也是个耗时操作,我们可以将它放在子线程中或者启动页面的onCtreate
方法中,尽可能地提前初始化,这样子SharedPreferences
就会建立好缓存了 - 每次调用
edit()
都会创建一个新的EditorImpl
对象,不要频繁调用edit()
,如果一个需要多次用到EditorImpl
对象,可以将其变成全局变量,也可以考虑将其变成单例
参考内容
1.SharedPreferences最佳实践
2. Android工程化最佳实践 书中的SharedPreferences 章节