定义
SharedPreferences 是一种轻量级的存储类,以KV(Key-Value)键值对的方式保存数据,其数据存储在本地的一个xml文件中,默认是data/data/share_prefs文件。
所以,SP本质是对XML文件的读写操作。在使用K读取和写入对应的V时,需要对xml文件进行解析,因此这是一个耗时随着文件大小成正比的操作。
轻量级:SP适合存储简单的数值类型的数据,复杂的数据不适合存在SP中。
读操作优化
从SP的本质可以看出:每一次读写操作都要进行一次I/O操作,耗时不说,性能也是太低了。如何优化读操作那?既然,读操作的耗时与性能问题是由于I/O操作引起的,那么我们只要减少IO操作就可以解决了。如何减少IO操作那?我们只对本地文件进行一次读取,将读取的键值对缓存在一个Map中,接下来的所有读操作只从map中获取。
上面我们只进行了一次IO操作,解决了读操作的耗时问题,但是,我们引入了一个map,且该map栈的内存大小与XML的大小成正比,是不是会造成内存占用过高那?从SP的定义我们知道:SP是一个轻量级的文件存储系统。因此,正常使用SP不会造成内存占用过高的问题。
final class SharedPreferencesImpl implements SharedPreferences {
private final File mFile; // xml文件
private final File mBackupFile; // 写入时的备份文件
private final Object mLock = new Object(); //
读取缓存与加载本地数据使用的锁
// 写入磁盘的对象锁
private final Object mWritingToDiskLock = new Object();
@GuardedBy("mLock")
private Map<String, Object> mMap; // 缓存文件中的KV键值对
@GuardedBy("mLock")
private int mDiskWritesInFlight = 0; // 待写入磁盘的数量 0 or 1
SharedPreferencesImpl(File file, int mode) {
mFile = file;
// 每一次创建SP时,会建立备份文件。
mBackupFile = new File(file.getPath() + ".bak");
mMap = null;
startLoadFromDisk();
}
// 开始从磁盘中加载数据并缓存到 mMap 中
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
// 在创建 SP时,启用新线程加载磁盘数据
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
private void loadFromDisk() {
synchronized (mLock) { // 获取锁
if (mLoaded) {
return;
}
// 如果备份文件存在,则将备份文件内容复制到mFile中
if (mBackupFile.exists()) {
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 {
// 读取XML中的键值对
str = new BufferedInputStream(
new FileInputStream(mFile), 16 * 1024);
// 将键值对添加到map中
map = (Map<String, Object>) XmlUtils.readMapXml(str);
} catch (Exception 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) {
// 对mMap进行赋值
mMap = map;
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
} else {
mMap = new HashMap<>();
}
}
} catch (Throwable t) {
mThrowable = t;
} finally {
// mLock.wait() 与 mLock.notifyAll() 是成对存在的,且都必须拿到锁
// 通知所有等待mLock对象锁的线程,谁调用了 mLock.wait()
mLock.notifyAll();
}
}
}
// 获取key对应的值value
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key); // 从缓存中获取数据
return v != null ? v : defValue;
}
}
// 等待loadFromDisk完成
@GuardedBy("mLock")
private void awaitLoadedLocked() {
if (!mLoaded) {
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
mLock.wait(); // 如果在loadFromDisk完成前调用了 getXX(),且获取到了锁,则需要释放锁,等待它完成
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}
}
写操作优化
我们知道,SP的写操作是更新xml文件的动作,也是一个IO操作。所以,我们也可以使用缓存来减少IO操作。另外,写操作与读操作不同的一点是:我们可以将多个写操作合并为一个更新文件的操作。所以,我们可以使用以下方法来更新数据:
val sp = context.getSharedPreferences("nameFile", Context.PreferencesMode.MODE_PRIVATE)
// 一次缓存更新,一次提交
sp.edit().putString("key", "value").commit()
// 合并多次缓存更新, 一次提交
val editor = sp.ecit()
edittor.putString("key", "value")
edittor.putBoolean("key", false)
editor.commit()
通过上面的代码,我们可以看出,SP的设计者将所有的写操作抽象到 Editor 类中,且更新缓存与写入文件的操作是可以分开的。
public final class EditorImpl implements Editor {
private final Object mEditorLock = new Object(); // 写入时的锁
@GuardedBy("mEditorLock") // mModified 必须在持有 mEditorLock 对象锁时才能被访问
private final Map<String, Object> mModified = new HashMap<>();
@GuardedBy("mEditorLock")
private boolean mClear = false;
@Override
public Editor putString(String key, @Nullable String value) {
synchronized (mEditorLock) {
mModified.put(key, value); // 缓存到 map 中
return this;
}
}
// 同步提交
@Override
public boolean commit() {
// 将更新的数据合并到读操作使用的 mMap 集合中
MemoryCommitResult mcr = commitToMemory();
// 执行写入磁盘的任务
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
// 等待写入结果
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
}
// 返回写入结果
return mcr.writeToDiskResult;
}
// 异步写入
@Override
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);
}
// 将写入磁盘的动作入队
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) {
// 写入磁盘
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
// 待写入磁盘的数量减1
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
// 执行异步写入完成的回调函数
postWriteRunnable.run();
}
}
};
// 如果是同步写入且没有其他等待写入的任务,则直接运行。
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
// 将写入任务交给 QueuedWork
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
}
从上面的代码,我们可以看出:如果使用 commit 提交一个同步写入操作,是直接运行在发起commit的线程的。而IO操作总是一个耗时操作,因此调用 commit 的线程是主线程,可能会导致ANR。因此,使用 aplly 会将IO操作放在子线程执行,可以减少ANR发生的频率。
只调用putXX()操作,而不调用 commit ,那M使用getXX()是得不到最新数据的。
线程安全
SP 主要提供对本地XML文件以及缓存数据的访问与修改操作,所以,在访问与修改时使用锁就可以保证同一时刻只有一个线程可以操作数据,来保证线程安全。
从上面的代码,我们看到,无论是 get()还是 put() 都使用了锁,因此,SP是线程安全的。
那么读取与写入使用的锁是一把吗?SP 为了保证线程安全一共使用了几把锁?
-
读取时的锁
在读取数据时,一共使用了一把锁 mLock。
-
写入时的锁
在写入数据时,一共使用了两把锁 mEditorLock 和 mWritingToDiskLock,分别控制对写入缓存和对文件操作的访问顺序。
ANR问题
前面我们知道, commit 在主线程调起可能会发生ANR,所以,官方推荐使用 apply。因为 apply 会将写入文件的IO操作放在子线程中去执行。因此,我们可以说:apply 是为了规避主线程执行IO操作导致ANR问题。可是ANR真的可以完全规避吗?
剖析 SharedPreference apply 引起的 ANR 问题
进程安全问题
SP 是不支持跨进程的,也就是说非进程安全的。那么如何解决进程安全问题那?
- 使用文件锁,保证只有一个进程访问xml文件
- 使用ContentProvider结合SP,保证SP的进程安全
总结
SP 是一个比较成熟的文件存储系统,虽然在性能方面有诸多的限制。例如现在的MMKV,DataStore都使用了 protobuf协议,且 MMKV 有其自身的 增量更新 机制,而SP依然是全量更新。不过,作为一个比较文件系统,SP的学习价值是不言而喻的,其中备份机制,使用缓存减少IO的思想,都是值得借鉴与学习的。