1. 背景
SharedPreferences 是 Android 中非常重要的轻量级 KV 存储组件,但是 SharedPreferences 也有其性能方面的问题,本文将主要从源码的角度分析 SharedPreferences 的工作原理和存在的问题。
2. 存在的问题
由于业务不断的发展,SharedPreferences 组件开始慢慢出现一些性能或者功能的缺陷,主要表现如下:
性能瓶颈
Google 将 SharedPreferences 作为轻量级 KV 存储组件推出,可是随着业务不断的发展,存储的 KV 越来越多,导致出现了性能上的问题,其中 commit 和 apply 方法均存在性能问题,文件的格式为XML,读写均较耗时。
存储安全问题
SharedPreferences 将 KV 存储在 XML 结构的文件中,没有加解密存储的功能,存在安全问题。
多进程问题
SharedPreferences 不支持跨进程同步问题,KV 组件跨进程是比较强的需求。
3. SharedPreferences 源码分析
下面结合 SharedPreferences 的源码分析其工作过程,并且指出其性能问题的根本原因,本文重点分析其性能相关的问题,分析源码的时候也主要关注这块。
3.1 SharedPreferences 加载
SharedPreferences 具体的实现在 SharedPreferencesImpl 中,初始化代码如下:
@UnsupportedAppUsage
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
startLoadFromDisk();
}
其中 mFile 是 SharedPreferences 存储的文件,mBackupFile 是存储文件的备份,主要在更新文件时起作用,防止系统问题 mFile 写不完整导致的数据丢失问题,重点看 startLoadFromDisk() 加载文件的函数。
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
通过 mLock 锁管理文件加载状态 mLoaded,这里开启线程 loadFromDisk() , 主要是这里并不一定要立马使用 SharedPreferences,尽可能减少对调用线程的阻塞,继续看 loadFromDisk() 方法
private void loadFromDisk() {
(1)
synchronized (mLock) {
if (mLoaded) {
return;
}
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
......
try {
(2)
stat = Os.stat(mFile.getPath());
if (mFile.canRead()) {
BufferedInputStream str = null;
try {
str = new BufferedInputStream(
new FileInputStream(mFile), 16 * 1024);
map = (Map<String, Object>) XmlUtils.readMapXml(str);
} catch (Exception e) {
Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
} finally {
IoUtils.closeQuietly(str);
}
}
} catch (Throwable t) {
thrown = t;
}
(3)
synchronized (mLock) {
mLoaded = true;
mThrowable = thrown;
try {
.......
} catch (Throwable t) {
mThrowable = t;
} finally {
mLock.notifyAll();
}
}
}
loadFromDisk() 方法整体逻辑分成 3 个部分分析:
- 这里有个很关键的 mBackupFile 的判断和使用,SharedPreferences 在更新数据到文件的时候会将原来的文件重命名为 mBackupFile,然后将所有的 KV 全量写入文件中,执行完成后删除 mBackupFile,如果 mBackupFile 存在就说明之前全量写可能失败了,mFile 可能有损坏,所以需要删除 mFile,使用 mBackupFile 。
- 主要是从 XML 格式的文件中读取数据并且解析成 map 。
- 修改 mLoaded 加载状态,并且唤醒所有等待的线程,这些等待的线程后面说。
3.2 SharedPreferences 读取数据
SharedPreferences 读取数据有很多种方法,基本都是封装过的,这里看下其中的 getString 方法。
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
这个方法看起来就是一个阻塞等待 SharedPreferences 加载完成后从 mMap 种获取数据的过程,重点看下 awaitLoadedLocked() 方法的实现。
private void awaitLoadedLocked() {
......
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}
这里就是判断 SharedPreferences 的加载状态,如果没有加载完成就等待,上面加载流程也分析过了,加载完成后会唤醒所有等待的线程。
3.3 SharedPreferences 存储数据
SharedPreferences 使用 Editor 中的 putXXX 系列方法修改数据,最后通过 commit 或者 apply 方法提交修改。这里可以看出SharedPreferences 最初的设计就是希望多次修改后统一提交。先看一下 commit 的源码
public boolean commit() {
......
(1)
MemoryCommitResult mcr = commitToMemory();
(2)
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null);
try {
(3)
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
......
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
这里主要的逻辑就有三部分:
- 对比确定当前内存中修改的需要提交的内容。
- 添加到写数据的队列中。
- 阻塞当前线程等待写数据结束。
看下 MemoryCommitResult 的定义:
private static class MemoryCommitResult {
......
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);
......
void setDiskWriteResult(boolean wasWritten, boolean result) {
......
writtenToDiskLatch.countDown();
}
}
上面省略了大部分内容,可以看出在写数据结束后才会唤醒阻塞的线程。
下面看下 SharedPreferencesImpl.this.enqueueDiskWrite 的内容:
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final boolean isFromSyncCommit = (postWriteRunnable == null);
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
(1)
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
(2)
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
这段代码基本就看上面标注的两个地方:
- 写数据到文件中,这里面的内容不再深入了。
- QueuedWork.queue 可以简单理解为一个延迟 100 毫秒单独执行的线程。
所以 commit 方法是同步写数据到文件中,会阻塞当前线程。继续看下 apply 方法:
public void apply() {
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);
......
}
apply 方法中的 mcr.writtenToDiskLatch.await() 看起来没有阻塞当前的线程,SharedPreferencesImpl.this.enqueueDiskWrite 方法前面分析过是直接调用 QueuedWork.queue 放到另外一个线程中执行。
上面看着是不是 apply 方法没啥问题,提交到线程中执行,不会阻塞当前的线程,也就没有主线程的性能问题了吗?Android 为了提高 apply 方法的提交成功率在 Activity 和 Service 的生命周期中强行执行提交:
@Override
public void handleStopActivity(ActivityClientRecord r, int configChanges,
PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) {
......
if (!r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}
......
}
private void handleStopService(IBinder token) {
mServicesData.remove(token);
Service s = mServices.remove(token);
if (s != null) {
try {
s.onDestroy();
......
QueuedWork.waitToFinish();
......
} catch (Exception e) {
......
}
} else {
Slog.i(TAG, "handleStopService: token=" + token + " not found.");
}
}
这两个方法均会执行 QueuedWork.waitToFinish() 方法,QueuedWork.waitToFinish() 中会将所有提交的任务阻塞式执行完,所以就可能导致 ANR 的出现。
4. SharedPreferences 源码总结
- SharedPreferences 读写的时候都是阻塞状态,直到文件加载完成。
- commit 和 apply 方法均可以提交修改,commit 是同步的,apply 是异步的。
- commit 和 apply 的提交均存在性能问题。
5. KV 组件使用的建议
- 如果要使用 SharedPreferences 作为 KV 组件,要注意多次修改统一提交,减少修改文件次数,同一个 SharedPreferences 存储的数据不应该过多。
- 可以使用 MMKV 等高性能的 KV 组件。
6. 框架学习总结
Google 也一直在优化 SharedPreferences 的性能,但是 SharedPreferences 实在是跟不上现在 Android 项目的性能要求和功能要求,索性就开发新的框架 DataStore 来替代。SharedPreferences 中个人主要的收获是 BackupFile 的思路,当一个文件要频繁被读写的时候,就可能出现文件损坏的情况发生(例如数据库),这个时候就需要备份文件的设计,确保文件数据尽量不被丢失。