查看原文
其他

每日一问:谈谈 SharedPreferences 不为人知的秘密(下)

nanchen nanchen 2020-10-29

上周三我们写了每日一问:谈谈 SharedPreferences 不为人知的秘密(上),原本计划周四写出下篇,但哪知家里出了非常令人难过的事情,导致可能近一两个月我都可能导致偶尔没有日更的情况。但所有事情都不应该成为借口,今天我们就来继续看看这个 SharedPreferences 到底有多坑。对于上周提到的  SharedPreferences  的简单使用基于获取实例方法的探讨不清楚的可以点击这里。此外,昨天搞了一波商业吹捧,大概需要 400 个阅读量,没有查看的朋友,如果可以,也希望可以点击这里进去看看

SharedPreferences 的内部类 Editor

我们在写入数据之前,总是要先通过类似这样的代码获取 SharedPreferences 的内部类 Editor

val editor = sharedPreferences.edit()

我们当然要看看这个到底是什么东西。

@Override
public Editor edit() {
// TODO: remove the need to call awaitLoadedLocked() when
// requesting an editor. will require some work on the
// Editor, but then we should be able to do:
//
// context.getSharedPreferences(..).edit().putString(..).apply()
//
// ... all without blocking.
synchronized (mLock) {
awaitLoadedLocked();
}

return new EditorImpl();
}

我们在

可以看到,我们在读取解析完 XML 文件的时候,直接返回了一个 Editor 的实现类 EditorImpl。我们随便查看一个 putXXX 的方法一看。

private final Object mEditorLock = new Object();

@GuardedBy("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);
return this;
}
}

可以看到,我们在 EditorImpl 里面使用了一个 HashMap 来存放我们的键值对数据,每次 put 的时候都会直接往这个键值对变量 mModified 中进行数据的 put 操作。

commit() 和 apply()

我们总是在更新数据后需要加上 commit() 或者 apply() 来进行输入的写入操作,我们不妨来看看他们的实现到底有什么区别。

先看 commit() 和 apply() 的源码。

@Override
public boolean commit() {
long startTime = 0;

if (DEBUG) {
startTime = System.currentTimeMillis();
}

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");
}
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}

@Override
public void apply() {
final long startTime = System.currentTimeMillis();

final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}

if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " applied after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
};

QueuedWork.addFinisher(awaitCommit);

Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};

SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
notifyListeners(mcr);
}

可以看到,apply() 和 commit() 的区别是在 commit() 把内容同步提交到了硬盘,而 apply() 是先立即把修改提交给了内存,然后开启了一个异步的线程提交到硬盘。commit() 会接收 MemoryCommitResult 里面的一个 boolean 参数作为结果,而 apply() 没有对结果做任何关心。

我们可以看到,文件写入更新的操作都是交给 commitToMemory() 做的,这个方法返回了一个 MemoryCommitResult 对象,我们来看看到底做了什么。

// 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) {
// We optimistically don't make a deep copy until
// a memory commit comes in when we're already
// writing to disk.
if (mDiskWritesInFlight > 0) {
// We can't modify our mMap as a currently
// in-flight write owns it. Clone it before
// modifying it.
// noinspection unchecked
mMap = new HashMap<String, Object>(mMap);
}
mapToWriteToDisk = mMap;
mDiskWritesInFlight++;

boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
keysModified = new ArrayList<String>();
listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}

synchronized (mEditorLock) {
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();
// "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 (!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);
}
}

mModified.clear();

if (changesMade) {
mCurrentMemoryStateGeneration++;
}

memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
mapToWriteToDisk);
}

可以看到,我们这里的 mMap 即存放当前 SharedPreferences 文件中的键值对,而 mModified 则存放的是当时 edit() 时 put 进去的键值对,这个我们前面有所介绍。这里有个 mDiskWritesInFlight 看起来应该是表示正在等待写的操作数量。

接下来我们首先处理了 edit().clear() 操作的 mClear 标志,当我们在外面调用 clear() 方法的时候,我们会把 mClear 设置为 true,这时候我们会直接通过 mMap.clear() 清空此时文件中的键值对,然后再遍历 mModified 中新 put 进来的键值对数据放到 mMap 中。也就是说:在一次提交中,如果我们又有 put 又有 clear() 操作的话,我们只能 clear() 掉之前的键值对,这次 put() 进去的键值对还是会被写入到 XML 文件中。

// 读取
val sharedPreferences = getSharedPreferences("123", Context.MODE_PRIVATE)
// 写入
val editor = sharedPreferences.edit()
editor.putInt("1", 123)
editor.clear()
editor.apply()
Log.e("nanchen2251", "${sharedPreferences.getInt("1", 0)}")

也就是说,当我们编写下面的代码的时候,得到的打印还是 123。

然后我们接着往下看,又发现了另外一个 commit() 和 apply() 都做了调用的方法是 enqueueDiskWrite()

/**
* Enqueue an already-committed-to-memory result to be written
* to disk.
*
* They will be written to disk one-at-a-time in the order
* that they're enqueued.
*
* @param postWriteRunnable if non-null, we're being called
* from apply() and this is the runnable to run after
* the write proceeds. if null (from a regular commit()),
* then we're allowed to do this disk write on the main
* thread (which in addition to reducing allocations and
* creating a background thread, this has the advantage that
* we catch them in userdebug StrictMode reports to convert
* them where possible to apply() ...)
*/

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) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};

// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}

QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

在这个方法中,首先通过判断 postWriteRunnable 是否为 null 来判断是 apply() 还是 commit()。然后定义了一个 Runnable 任务,在 Runnable 中先调用了 writeToFile()进行了写入和计数器更新的操作。

然后我们再来看看这个 writeToFile() 方法做了些什么。

@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
long startTime = 0;
long existsTime = 0;
long backupExistsTime = 0;
long outputStreamCreateTime = 0;
long writeTime = 0;
long fsyncTime = 0;
long setPermTime = 0;
long fstatTime = 0;
long deleteTime = 0;

if (DEBUG) {
startTime = System.currentTimeMillis();
}

boolean fileExists = mFile.exists();

if (DEBUG) {
existsTime = System.currentTimeMillis();

// Might not be set, hence init them to a default value
backupExistsTime = existsTime;
}

// Rename the current file so it may be used as a backup during the next read
if (fileExists) {
boolean needsWrite = false;

// Only need to write if the disk state is older than this commit
if (mDiskStateGeneration < mcr.memoryStateGeneration) {
if (isFromSyncCommit) {
needsWrite = true;
} else {
synchronized (mLock) {
// No need to persist intermediate states. Just wait for the latest state to
// be persisted.
if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
needsWrite = true;
}
}
}
}

if (!needsWrite) {
mcr.setDiskWriteResult(false, true);
return;
}

boolean backupFileExists = mBackupFile.exists();

if (DEBUG) {
backupExistsTime = System.currentTimeMillis();
}
// 此处需要注意一下
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();
}
}

// Attempt to write the file, delete the backup and return true as atomically as
// possible. If any exception occurs, delete the new file; next time we will restore
// from the backup.
try {
FileOutputStream str = createFileOutputStream(mFile);

if (DEBUG) {
outputStreamCreateTime = System.currentTimeMillis();
}

if (str == null) {
mcr.setDiskWriteResult(false, false);
return;
}
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);

writeTime = System.currentTimeMillis();

FileUtils.sync(str);

fsyncTime = System.currentTimeMillis();

str.close();
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);

if (DEBUG) {
setPermTime = System.currentTimeMillis();
}

try {
final StructStat stat = Os.stat(mFile.getPath());
synchronized (mLock) {
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
}
} catch (ErrnoException e) {
// Do nothing
}

if (DEBUG) {
fstatTime = System.currentTimeMillis();
}

// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();

if (DEBUG) {
deleteTime = System.currentTimeMillis();
}

mDiskStateGeneration = mcr.memoryStateGeneration;

mcr.setDiskWriteResult(true, true);

if (DEBUG) {
Log.d(TAG, "write: " + (existsTime - startTime) + "/"
+ (backupExistsTime - startTime) + "/"
+ (outputStreamCreateTime - startTime) + "/"
+ (writeTime - startTime) + "/"
+ (fsyncTime - startTime) + "/"
+ (setPermTime - startTime) + "/"
+ (fstatTime - startTime) + "/"
+ (deleteTime - startTime));
}

long fsyncDuration = fsyncTime - writeTime;
mSyncTimes.add((int) fsyncDuration);
mNumSync++;

if (DEBUG || mNumSync % 1024 == 0 || fsyncDuration > MAX_FSYNC_DURATION_MILLIS) {
mSyncTimes.log(TAG, "Time required to fsync " + mFile + ": ");
}

return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}

// Clean up an unsuccessfully written file
if (mFile.exists()) {
if (!mFile.delete()) {
Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
}
}
mcr.setDiskWriteResult(false, false);
}

代码比较长,做了一些时间的记录和 XML 的相关处理,但最值得我们关注的还是其中打了标注的对于 mBackupFile 的处理。我们可以明显地看到,在我们写入文件的时候,我们会把此前的 XML 文件改名为一个备份文件,然后再将要写入的数据写入到一个新的文件中。如果这个过程执行成功的话,就会把备份文件删除。由此可见:即使我们每次只是添加一个键值对,也会重新写入整个文件的数据,这也说明了 SharedPreferences 只适合保存少量数据,文件太大会有性能问题。

看完了这个 writeToFile() ,我们再来看看下面做了啥。

// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}

QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);

可以看到,当且仅当是 commit() 并且只有一个待写入操作的时候才能直接执行到 writeToDiskRunnable.run(),否则都会执行到 QueuedWork 的 queue() 方法,这个 QueuedWork 又是什么东西?

/** Finishers {@link #addFinisher added} and not yet {@link #removeFinisher removed} */
@GuardedBy("sLock")
private static final LinkedList<Runnable> sFinishers = new LinkedList<>();

/** Work queued via {@link #queue} */
@GuardedBy("sLock")
private static final LinkedList<Runnable> sWork = new LinkedList<>();
/**
* Internal utility class to keep track of process-global work that's outstanding and hasn't been
* finished yet.
*
* New work will be {@link #queue queued}.
*
* It is possible to add 'finisher'-runnables that are {@link #waitToFinish guaranteed to be run}.
* This is used to make sure the work has been finished.
*
* This was created for writing SharedPreference edits out asynchronously so we'd have a mechanism
* to wait for the writes in Activity.onPause and similar places, but we may use this mechanism for
* other things in the future.
*
* The queued asynchronous work is performed on a separate, dedicated thread.
*
* @hide
*/

public class QueuedWork {
/**
* Add a finisher-runnable to wait for {@link #queue asynchronously processed work}.
*
* Used by SharedPreferences$Editor#startCommit().
*
* Note that this doesn't actually start it running. This is just a scratch set for callers
* doing async work to keep updated with what's in-flight. In the common case, caller code
* (e.g. SharedPreferences) will pretty quickly call remove() after an add(). The only time
* these Runnables are run is from {@link #waitToFinish}.
*
* @param finisher The runnable to add as finisher
*/

public static void addFinisher(Runnable finisher) {
synchronized (sLock) {
sFinishers.add(finisher);
}
}

/**
* Remove a previously {@link #addFinisher added} finisher-runnable.
*
* @param finisher The runnable to remove.
*/

public static void removeFinisher(Runnable finisher) {
synchronized (sLock) {
sFinishers.remove(finisher);
}
}

/**
* Trigger queued work to be processed immediately. The queued work is processed on a separate
* thread asynchronous. While doing that run and process all finishers on this thread. The
* finishers can be implemented in a way to check weather the queued work is finished.
*
* Is called from the Activity base class's onPause(), after BroadcastReceiver's onReceive,
* after Service command handling, etc. (so async work is never lost)
*/

public static void waitToFinish() {
long startTime = System.currentTimeMillis();
boolean hadMessages = false;

Handler handler = getHandler();

synchronized (sLock) {
if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
// Delayed work will be processed at processPendingWork() below
handler.removeMessages(QueuedWorkHandler.MSG_RUN);

if (DEBUG) {
hadMessages = true;
Log.d(LOG_TAG, "waiting");
}
}

// We should not delay any work as this might delay the finishers
sCanDelay = false;
}

StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
processPendingWork();
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}

try {
while (true) {
Runnable finisher;

synchronized (sLock) {
finisher = sFinishers.poll();
}

if (finisher == null) {
break;
}

finisher.run();
}
} finally {
sCanDelay = true;
}

synchronized (sLock) {
long waitTime = System.currentTimeMillis() - startTime;

if (waitTime > 0 || hadMessages) {
mWaitTimes.add(Long.valueOf(waitTime).intValue());
mNumWaits++;

if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
mWaitTimes.log(LOG_TAG, "waited: ");
}
}
}
}
/**
* Queue a work-runnable for processing asynchronously.
*
* @param work The new runnable to process
* @param shouldDelay If the message should be delayed
*/

public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler();

synchronized (sLock) {
sWork.add(work);

if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
}

简单地说,这个 QueuedWork 类里面有一个专门存放 Runnable 的两个 LinkedList 对象,他们分别对应未完成的操作 sFinishers 和正在工作的 sWork
我们在 waitToFinish() 方法中,会不断地去遍历执行未完成的 Runnable。我们根据注释也知道了这个方法会在 Activity 的 onPause() 和 BroadcastReceiver 的 onReceive() 方法后调用。假设我们频繁的调用了 apply()方法,并紧接着调用了 onPause() ,那么就可能会发生 onPause() 一直等待 QueuedWork.waitToFinish 执行完成而产生 ANR。也就是说,即使是调用了 apply() 方法去异步提交,也不是完全安全的。如果 apply() 方法使用不当,也是可能出现 ANR 的。

总结

说了这么多,我们当然还是需要做一个总结。

  1. apply() 没有返回值而 commit() 返回 boolean 表明修改是否提交成功 ;

  2. commit() 是把内容同步提交到硬盘的,而 apply() 先立即把修改提交到内存,然后开启一个异步的线程提交到硬盘,并且如果提交失败,你不会收到任何通知。

  3. 所有 commit() 提交是同步过程,效率会比 apply() 异步提交的速度慢,在不关心提交结果是否成功的情况下,优先考虑 apply() 方法。

  4. apply() 是使用异步线程写入磁盘,commit() 是同步写入磁盘。所以我们在主线程使用的 commit() 的时候,需要考虑是否会出现 ANR 问题。

  5. 我们每次添加键值对的时候,都会重新写入整个文件的数据,所以它不适合大量数据存储。

  6. 多线程场景下效率比较低,因为 get 操作的时候,会锁定 SharedPreferencesImpl 里面的对象,互斥其他操作,而当 putcommit() 和 apply() 操作的时候都会锁住 Editor 的对象,在这样的情况下,效率会降低。

  7. 由于每次都会把整个文件加载到内存中,因此,如果 SharedPreferences 文件过大,或者在其中的键值对是大对象的 JSON 数据则会占用大量内存,读取较慢是一方面,同时也会引发程序频繁 GC,导致的界面卡顿。

基于以上缺点:

  1. 建议不要存储较大数据到 SharedPreferences,也不要把较多数据存储到同一个 name 对应的 SharedPreferences 中,最好根据规则拆分为多个 SharedPreferences文件。

  2. 频繁修改的数据修改后统一提交,而不是修改过后马上提交。

  3. 在跨进程通讯中不去使用 SharedPreferences

  4. 获取 SharedPreferences 对象的时候会读取 SharedPreferences 文件,如果文件没有读取完,就执行了 get 和 put 操作,可能会出现需要等待的情况,因此最好提前获取 SharedPreferences 对象。

  5. 每次调用 edit() 方法都会创建一个新的 EditorImpl 对象,不要频繁调用 edit()方法。
    参考链接:https://juejin.im/post/5adc444df265da0b886d00bc#heading-10


—————END—————



我是南尘,只做比心的公众号,欢迎关注我。

推荐阅读:

每日一问:用了动画这么久,我竟然还不知道这个

日活百万的 App 的屏幕录制是怎么做的

每日一问:onAttachXXX 到底有多重要?


欢迎关注南尘的公众号:nanchen
做不完的开源,写不完的矫情,只做比心的公众号,如果你喜欢,你可以选择分享给大家。如果你有好的文章,欢迎投稿,让我们一起来分享。
          长按上方二维码关注        做不完的开源,写不完的矫情        一起来看 nanchen 同学的成长笔记



    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存