Android 开源库 #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)

4,236 阅读13分钟

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

当一个开发者或团队的水平积累到一定程度,会有自内向外输出价值的需求。在这个专栏里,小彭将为你分享 Android 方向主流开源组件的实现原理,包括网络、存储、UI、监控等。

本文是 Android 开源库系列的第 2 篇文章,完整文章目录请移步到文章末尾~

前言

我们继续上一篇文章的分析:


6. 两种写回策略

在获得事务对象后,我们继续分析 Editor 接口中的 commit 同步写回策略和 apply 异步写回策略。

6.1 commit 同步写回策略

Editor#commit 同步写回相对简单,核心步骤分为 4 步:

  • 1、调用 commitToMemory() 创建 MemoryCommitResult 事务对象;
  • 2、调用 enqueueDiskWrite(mrc, null) 提交磁盘写回任务(在当前线程执行);
  • 3、调用 CountDownLatch#await() 阻塞等待磁盘写回完成;
  • 4、调用 notifyListeners() 触发回调监听。

commit 同步写回示意图

其实严格来说,commit 同步写回也不绝对是在当前线程同步写回,也有可能在后台 HandlerThread 线程写回。但不管怎么样,对于 commit 同步写回来说,都会调用 CountDownLatch#await() 阻塞等待磁盘写回完成,所以在逻辑上也等价于在当前线程同步写回。

SharedPreferencesImpl.java

public final class EditorImpl implements Editor {

    @Override
    public boolean commit() {
        // 1、获取事务对象(前文已分析)
        MemoryCommitResult mcr = commitToMemory();
        // 2、提交磁盘写回任务
        SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* 写回成功回调 */);
        // 3、阻塞等待写回完成
        mcr.writtenToDiskLatch.await();
        // 4、触发回调监听器
        notifyListeners(mcr);
        return mcr.writeToDiskResult;
    }
}

6.2 apply 异步写回策略

Editor#apply 异步写回相对复杂,核心步骤分为 5 步:

  • 1、调用 commitToMemory() 创建 MemoryCommitResult 事务对象;
  • 2、创建 awaitCommit Ruunnable 并提交到 QueuedWork 中。awaitCommit 中会调用 CountDownLatch#await() 阻塞等待磁盘写回完成;
  • 3、创建 postWriteRunnable Runnable,在 run() 中会执行 awaitCommit 任务并将其从 QueuedWork 中移除;
  • 4、调用 enqueueDiskWrite(mcr, postWriteRunnable) 提交磁盘写回任务(在子线程执行);
  • 5、调用 notifyListeners() 触发回调监听。

可以看到不管是调用 commit 还是 apply,最终都会调用 SharedPreferencesImpl#enqueueDiskWrite() 提交磁盘写回任务。

区别在于:

  • 在 commit 中 enqueueDiskWrite() 的第 2 个参数是 null;
  • 在 apply 中 enqueueDiskWrite() 的第 2 个参数是一个 postWriteRunnable 写回结束的回调对象,enqueueDiskWrite() 内部就是根据第 2 个参数来区分 commit 和 apply 策略。

apply 异步写回示意图

SharedPreferencesImpl.java

@Override
public void apply() {
    // 1、获取事务对象(前文已分析)
    final MemoryCommitResult mcr = commitToMemory();
    // 2、提交 aWait 任务
    // 疑问:postWriteRunnable 可以理解,awaitCommit 是什么?
    final Runnable awaitCommit = new Runnable() {
        @Override
        public void run() {
            // 阻塞线程直到磁盘任务执行完毕
            mcr.writtenToDiskLatch.await();
        }
    };
    QueuedWork.addFinisher(awaitCommit);
    // 3、创建写回成功回调
    Runnable postWriteRunnable = new Runnable() {
        @Override
        public void run() {
            // 执行 aWait 任务
            awaitCommit.run();
            // 移除 aWait 任务
            QueuedWork.removeFinisher(awaitCommit);
        }
    };

    // 4、提交磁盘写回任务,并绑定写回成功回调
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable /* 写回成功回调 */);

    // 5、触发回调监听器
    notifyListeners(mcr);
}

QueuedWork.java

// 提交 aWait 任务(后文详细分析)
private static final LinkedList<Runnable> sFinishers = new LinkedList<>();

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

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

这里有一个疑问:

在 apply() 方法中,在执行 enqueueDiskWrite() 前创建了 awaitCommit 任务并加入到 QueudWork 等待队列,直到磁盘写回结束才将 awaitCommit 移除。这个 awaitCommit 任务是做什么的呢?

我们稍微再回答,先继续往下走。

6.3 enqueueDiskWrite() 提交磁盘写回事务

可以看到,不管是 commit 还是 apply,最终都会调用 SharedPreferencesImpl#enqueueDiskWrite() 提交写回磁盘任务。虽然 enqueueDiskWrite() 还没到真正调用磁盘写回操作的地方,但确实创建了与磁盘 IO 相关的 Runnable 任务,核心步骤分为 4 步:

  • 步骤 1:根据是否有 postWriteRunnable 回调区分是 commit 和 apply;
  • 步骤 2:创建磁盘写回任务(真正执行磁盘 IO 的地方):
    • 2.1 调用 writeToFile() 执行写回磁盘 IO 操作;
    • 2.2 在写回结束后对前文提到的 mDiskWritesInFlight 计数自减 1;
    • 2.3 执行 postWriteRunnable 写回成功回调;
  • 步骤 3:如果是异步写回,则提交到 QueuedWork 任务队列;
  • 步骤 4:如果是同步写回,则检查 mDiskWritesInFlight 变量。如果存在并发写回的事务,则也要提交到 QueuedWork 任务队列,否则就直接在当前线程执行。

其中步骤 2 是真正执行磁盘 IO 的地方,逻辑也很好理解。不好理解的是,我们发现除了 “同步写回而且不存在并发写回事务” 这种特殊情况,其他情况都会交给 QueuedWork 再调度一次。

在通过 QueuedWork#queue 提交任务时,会将 writeToDiskRunnable 任务追加到 sWork 任务队列中。如果是首次提交任务,QueuedWork 内部还会创建一个 HandlerThread 线程,通过这个子线程实现异步的写回任务。这说明 SharedPreference 的异步写回相当于使用了一个单线程的线程池,事实上在 Android 8.0 以前的版本中就是使用一个 singleThreadExecutor 线程池实现的。

提交任务示意图

SharedPreferencesImpl.java

private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
    // 1、根据是否有 postWriteRunnable 回调区分是 commit 和 apply
    final boolean isFromSyncCommit = (postWriteRunnable == null);
    // 2、创建磁盘写回任务
    final Runnable writeToDiskRunnable = new Runnable() {
        @Override
        public void run() {
            synchronized (mWritingToDiskLock) {
                // 2.1 写入磁盘文件
                writeToFile(mcr, isFromSyncCommit);
            }
            synchronized (mLock) {
                // 2.2 mDiskWritesInFlight:进行中事务自减 1
                mDiskWritesInFlight--;
            }
            if (postWriteRunnable != null) {
                // 2.3 触发写回成功回调
                postWriteRunnable.run();
            }
        }
    };

    // 3、同步写回且不存在并发写回,则直接在当前线程
    // 这就是前文提到 “commit 也不是绝对在当前线程同步写回” 的源码出处
    if (isFromSyncCommit) {
        boolean wasEmpty = false;
        synchronized (mLock) {
            // 如果存在并发写回的事务,则此处 wasEmpty = false
            wasEmpty = mDiskWritesInFlight == 1;
        }
        // wasEmpty 为 true 说明当前只有一个线程在执行提交操作,那么就直接在此线程上完成任务
        if (wasEmpty) {
            writeToDiskRunnable.run();
            return;
        }
    }

    // 4、交给 QueuedWork 调度(同步任务不可以延迟)
    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit /*是否可以延迟*/ );
}

@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
    // 稍后分析
}

QueuedWork 调度:

QueuedWork.java

@GuardedBy("sLock")
private static LinkedList<Runnable> sWork = new LinkedList<>();

// 提交任务
// shouldDelay:是否延迟
public static void queue(Runnable work, boolean shouldDelay) {
    Handler handler = getHandler();

    synchronized (sLock) {
        // 入队
        sWork.add(work);
        // 发送 Handler 消息,触发 HandlerThread 执行任务
        if (shouldDelay && sCanDelay) {
            handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY /* 100ms */);
        } else {
            handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
        }
    }
}

private static Handler getHandler() {
    synchronized (sLock) {
        if (sHandler == null) {
            // 创建 HandlerThread 后台线程
            HandlerThread handlerThread = new HandlerThread("queued-work-looper", Process.THREAD_PRIORITY_FOREGROUND);
            handlerThread.start();

            sHandler = new QueuedWorkHandler(handlerThread.getLooper());
        }
        return sHandler;
    }
}

private static class QueuedWorkHandler extends Handler {
    static final int MSG_RUN = 1;

    QueuedWorkHandler(Looper looper) {
        super(looper);
    }

    public void handleMessage(Message msg) {
        if (msg.what == MSG_RUN) {
            // 执行任务
            processPendingWork();
        }
    }
}

private static void processPendingWork() {
    synchronized (sProcessingWork) {
        LinkedList<Runnable> work;

        synchronized (sLock) {
            // 创建新的任务队列
            // 这一步是必须的,否则会与 enqueueDiskWrite 冲突
            work = sWork;
            sWork = new LinkedList<>();

            // Remove all msg-s as all work will be processed now
            getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
        }

        // 遍历 ,按顺序执行 sWork 任务队列
        if (work.size() > 0) {
            for (Runnable w : work) {
                w.run();
            }
        }
    }
}

比较不理解的是:

同一个文件的多次写回串行化可以理解,对于多个文件的写回串行化意义是什么,是不是可以用多线程来写回多个不同的文件?或许这也是 SharedPreferences 是轻量级框架的原因之一,你觉得呢?

6.4 主动等待写回任务结束

现在我们可以回答 6.1 中遗留的问题:

在 apply() 方法中,在执行 enqueueDiskWrite() 前创建了 awaitCommit 任务并加入到 QueudWork 等待队列,直到磁盘写回结束才将 awaitCommit 移除。这个 awaitCommit 任务是做什么的呢?

要理解这个问题需要管理分析到 ActivityThread 中的主线程消息循环:

可以看到,在主线程的 Activity#onPause、Activity#onStop、Service#onStop、Service#onStartCommand 等生命周期状态变更时,会调用 QueudeWork.waitToFinish():

ActivityThread.java

@Override
public void handlePauseActivity(...) {
    performPauseActivity(r, finished, reason, pendingActions);
    // Make sure any pending writes are now committed.
    if (r.isPreHoneycomb()) {
        QueuedWork.waitToFinish();
    }
    ...
}

private void handleStopService(IBinder token) {
    ...
    QueuedWork.waitToFinish();
    ActivityManager.getService().serviceDoneExecuting(token, SERVICE_DONE_EXECUTING_STOP, 0, 0);
    ...
}

waitToFinish() 会执行所有 sFinishers 等待队列中的 aWaitCommit 任务,主动等待所有磁盘写回任务结束。在写回任务结束之前,主线程会阻塞在等待锁上,这里也有可能发生 ANR。

主动等待示意图

至于为什么 Google 要在 ActivityThread 中部分生命周期中主动等待所有磁盘写回任务结束呢?官方并没有明确表示,结合头条和抖音技术团队的文章,我比较倾向于这 2 点解释:

  • 解释 1 - 跨进程同步(主要): 为了保证跨进程的数据同步,要求在组件跳转前,确保当前组件的写回任务必须在当前生命周期内完成;
  • 解释 2 - 数据完整性: 为了防止在组件跳转的过程中可能产生的 Crash 造成未写回的数据丢失,要求当前组件的写回任务必须在当前生命周期内完成。

当然这两个解释并不全面,因为就算要求主动等待,也不能保证跨进程实时同步,也不能保证不产生 Crash。

抖音技术团队观点

QueuedWork.java

@GuardedBy("sLock")
private static Handler sHandler = null;

public static void waitToFinish() {
    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);
        }
        // We should not delay any work as this might delay the finishers
        sCanDelay = false;
    }

    // Android 8.0 优化:帮助子线程执行磁盘写回
    // 作用有限,因为 QueuedWork 使用了 sProcessingWork 锁保证同一时间最多只有一个线程在执行磁盘写回
    // 所以这里应该是尝试在主线程执行,可以提升线程优先级
    processPendingWork();

    // 执行 sFinshers 等待队列,等待所有写回任务结束
    try {
        while (true) {
            Runnable finisher;

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

            if (finisher == null) {
                break;
            }
            // 执行 mcr.writtenToDiskLatch.await();
            // 阻塞线程直到磁盘任务执行完毕
            finisher.run();
        }
    } finally {
        sCanDelay = true;
    }
}

Android 7.1 QueuedWork 源码对比:

public static boolean hasPendingWork() {
    return !sPendingWorkFinishers.isEmpty();
}

7. writeToFile() 姗姗来迟

最终走到具体调用磁盘 IO 操作的地方了!

7.1 写回步骤

writeToFile() 的逻辑相对复杂一些了。经过简化后,剩下的核心步骤只有 4 大步骤:

  • 步骤 1:过滤无效写回事务:

    • 1.1 事务的 memoryStateGeneration 内存版本小于 mDiskStateGeneration 磁盘版本,跳过;
    • 1.2 同步写回必须写回;
    • 1.3 异步写回事务的 memoryStateGeneration 内存版本版本小于 mCurrentMemoryStateGeneration 最新内存版本,跳过。
  • 步骤 2:文件备份:

    • 2.1 如果不存在备份文件,则将旧文件重命名为备份文件;
    • 2.2 如果存在备份文件,则删除无效的旧文件(上一次写回出并且后处理没有成功删除的情况)。
  • 步骤 3:全量覆盖写回磁盘:

    • 3.1 打开文件输出流;
    • 3.2 将 mapToWriteToDisk 映射表全量写出;
    • 3.3 调用 FileUtils.sync() 强制操作系统页缓存写回磁盘;
    • 3.4 写入成功,则删除备份文件(如果没有走到这一步,在将来读取文件时,会重新恢复备份文件);
    • 3.5 将磁盘版本记录为当前内存版本;
    • 3.6 写回结束(成功)。
  • 步骤 4:后处理: 删除写至半途的无效文件。

7.2 写回优化

继续分析发现,SharedPreference 的写回操作并不是简单的调用磁盘 IO,在保证 “可用性” 方面也做了一些优化设计:

  • 优化 1 - 过滤无效的写回事务:

如前文所述,commit 和 apply 都可能出现并发修改同一个文件的情况,此时在连续修改同一个文件的事务序列中,旧的事务是没有意义的。为了过滤这些无意义的事务,在创建 MemoryCommitResult 事务对象时会记录当时的 memoryStateGeneration 内存版本,而在 writeToFile() 中就会根据这个字段过滤无效事务,避免了无效的 I/O 操作。

  • 优化 2 - 备份旧文件:

由于写回文件的过程存在不确定的异常(比如内核崩溃或者机器断电),为了保证文件的完整性,SharedPreferences 采用了文件备份机制。在执行写回操作之前,会先将旧文件重命名为 .bak 备份文件,在全量覆盖写入新文件后再删除备份文件。

如果写回文件失败,那么在后处理过程中会删除写至半途的无效文件。此时磁盘中只有一个备份文件,而真实文件需要等到下次触发写回事务时再写回。

如果直到应用退出都没有触发下次写回,或者写回的过程中 Crash,那么在前文提到的创建 SharedPreferencesImpl 对象的构造方法中调用 loadFromDisk() 读取并解析文件数据时,会从备份文件恢复数据。

  • 优化 3 - 强制页缓存写回:

在写回文件成功后,SharedPreference 会调用 FileUtils.sync() 强制操作系统将页缓存写回磁盘。

写回示意图

SharedPreferencesImpl.java

// 内存版本
@GuardedBy("this")
private long mCurrentMemoryStateGeneration;

// 磁盘版本
@GuardedBy("mWritingToDiskLock")
private long mDiskStateGeneration;

// 写回事务
private static class MemoryCommitResult {
    // 内存版本
    final long memoryStateGeneration;
    // 需要全量覆盖写回磁盘的数据
    final Map<String, Object> mapToWriteToDisk;
    // 同步计数器
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);

    // 后文写回结束后调用
    // wasWritten:是否有执行写回
    // result:是否成功
    void setDiskWriteResult(boolean wasWritten, boolean result) {
        this.wasWritten = wasWritten;
        writeToDiskResult = result;
        // 唤醒等待锁
        writtenToDiskLatch.countDown();
    }
}

// 提交写回事务
private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
    ...
    // 创建磁盘写回任务
    final Runnable writeToDiskRunnable = new Runnable() {
        @Override
        public void run() {
            synchronized (mWritingToDiskLock) {
                // 2.1 写入磁盘文件
                writeToFile(mcr, isFromSyncCommit);
            }
            synchronized (mLock) {
                // 2.2 mDiskWritesInFlight:进行中事务自减 1
                mDiskWritesInFlight--;
            }
            if (postWriteRunnable != null) {
                // 2.3 触发写回成功回调
                postWriteRunnable.run();
            }
        }
    };
    ...
}

// 写回文件
// isFromSyncCommit:是否同步写回
@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
    boolean fileExists = mFile.exists();
    // 如果旧文件存在
    if (fileExists) { 
        // 1. 过滤无效写回事务
        // 是否需要执行写回
        boolean needsWrite = false;

        // 1.1 磁盘版本小于内存版本,才有可能需要写回
        // (只有旧文件存在才会走到这个分支,但是旧文件不存在的时候也可能存在无意义的写回,
        // 猜测官方是希望首次创建文件的写回能够及时尽快执行,毕竟只有一个后台线程)
        if (mDiskStateGeneration < mcr.memoryStateGeneration) {
            if (isFromSyncCommit) {
                // 1.2 同步写回必须写回
                needsWrite = true;
            } else {
                // 1.3 异步写回需要判断事务对象的内存版本,只有最新的内存版本才有必要执行写回
                synchronized (mLock) {
                    if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
                        needsWrite = true;
                    }
                }
            }
        }

        if (!needsWrite) {
            // 1.4 无效的异步写回,直接结束
            mcr.setDiskWriteResult(false, true);
            return;
        }

        // 2. 文件备份
        boolean backupFileExists = mBackupFile.exists();
        if (!backupFileExists) {
            // 2.1 如果不存在备份文件,则将旧文件重命名为备份文件
            if (!mFile.renameTo(mBackupFile)) {
                // 备份失败
                mcr.setDiskWriteResult(false, false);
                return;
            }
        } else {
            // 2.2 如果存在备份文件,则删除无效的旧文件(上一次写回出并且后处理没有成功删除的情况)
            mFile.delete();
        }
    }

    try {
        // 3、全量覆盖写回磁盘
        // 3.1 打开文件输出流
        FileOutputStream str = createFileOutputStream(mFile);
        if (str == null) {
            // 打开输出流失败
            mcr.setDiskWriteResult(false, false);
            return;
        }
        // 3.2 将 mapToWriteToDisk 映射表全量写出
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
        // 3.3 FileUtils.sync:强制操作系统将页缓存写回磁盘
        FileUtils.sync(str);
        // 关闭输出流
        str.close();
        ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);

        // 3.4 写入成功,则删除备份文件(如果没有走到这一步,在将来读取文件时,会重新恢复备份文件)
        mBackupFile.delete();
        // 3.5 将磁盘版本记录为当前内存版本
        mDiskStateGeneration = mcr.memoryStateGeneration;
        // 3.6 写回结束(成功)
        mcr.setDiskWriteResult(true, true);

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

    // 在 try 块中抛出异常,会走到这里
    // 4、后处理:删除写至半途的无效文件
    if (mFile.exists()) {
        if (!mFile.delete()) {
            Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
        }
    }
    // 写回结束(失败)
    mcr.setDiskWriteResult(false, false);
}

// -> 读取并解析文件数据
private void loadFromDisk() {
    synchronized (mLock) {
        if (mLoaded) {
            return;
        }
        // 1、如果存在备份文件,则恢复备份数据(后文详细分析)
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }
    ...
}

至此,SharedPreferences 核心源码分析结束。


8. SharedPreferences 的其他细节

SharedPreferences 还有其他细节值得学习。

8.1 SharedPreferences 锁总结

SharedPreferences 是线程安全的,但它的线程安全并不是直接使用一个全局的锁对象,而是采用多种颗粒度的锁对象实现 “锁细化” ,而且还贴心地使用了 @GuardedBy 注解标记字段或方法所述的锁级别。

使用 @GuardedBy 注解标记锁级别

@GuardedBy("mLock")
private Map<String, Object> mMap;
对象锁功能描述
1、SharedPreferenceImpl#mLockSharedPreferenceImpl 对象的全局锁全局使用
2、EditorImpl#mEditorLockEditorImpl 修改器的写锁确保多线程访问 Editor 的竞争安全
3、SharedPreferenceImpl#mWritingToDiskLockSharedPreferenceImpl#writeToFile() 的互斥锁writeToFile() 中会修改内存状态,需要保证多线程竞争安全
4、QueuedWork.sLockQueuedWork 的互斥锁确保 sFinishers 和 sWork 的多线程资源竞争安全
5、QueuedWork.sProcessingWorkQueuedWork#processPendingWork() 的互斥锁确保同一时间最多只有一个线程执行磁盘写回任务

8.2 使用 WeakHashMap 存储监听器

SharedPreference 提供了 OnSharedPreferenceChangeListener 回调监听器,可以在主线程监听键值对的变更(包含修改、新增和移除)。

SharedPreferencesImpl.java

@GuardedBy("mLock")
private final WeakHashMap<OnSharedPreferenceChangeListener, Object> mListeners =
    new WeakHashMap<OnSharedPreferenceChangeListener, Object>();

SharedPreferences.java

public interface SharedPreferences {

    public interface OnSharedPreferenceChangeListener {
        void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key);
    }
}

比较意外的是: SharedPreference 使用了一个 WeakHashMap 弱键散列表存储监听器,并且将监听器对象作为 Key 对象。这是为什么呢?

这是一种防止内存泄漏的考虑,因为 SharedPreferencesImpl 的生命周期是全局的(位于 ContextImpl 的内存缓存),所以有必要使用弱引用防止内存泄漏。想想也对,Java 标准库没有提供类似 WeakArrayList 或 WeakLinkedList 的容器,所以这里将监听器对象作为 WeakHashMap 的 Key,就很巧妙的复用了 WeakHashMap 自动清理无效数据的能力。

提示: 关于 WeakHashMap 的详细分析,请阅读小彭说 · 数据结构与算法 专栏文章 《WeakHashMap 和 HashMap 的区别是什么,何时使用?》

8.3 如何检查文件被其他进程修改?

在读取和写入文件后记录 mStatTimestamp 时间戳和 mStatSize 文件大小,在检查时检查这两个字段是否发生变化

SharedPreferencesImpl.java

// 文件时间戳
@GuardedBy("mLock")
private StructTimespec mStatTimestamp;
// 文件大小
@GuardedBy("mLock")
private long mStatSize;

// 读取文件
private void loadFromDisk() {
    ...
    mStatTimestamp = stat.st_mtim;
    mStatSize = stat.st_size;
    ...
}

// 写入文件
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
    ...
    mStatTimestamp = stat.st_mtim;
    mStatSize = stat.st_size;
    ...
}

// 检查文件
private boolean hasFileChangedUnexpectedly() {
    synchronized (mLock) {
        if (mDiskWritesInFlight > 0) {
            // If we know we caused it, it's not unexpected.
            if (DEBUG) Log.d(TAG, "disk write in flight, not unexpected.");
            return false;
        }
    }

    // 读取文件 Stat 信息
    final StructStat stat = Os.stat(mFile.getPath());

    synchronized (mLock) {
        // 检查修改时间和文件大小
        return !stat.st_mtim.equals(mStatTimestamp) || mStatSize != stat.st_size;
    }
}

至此,SharedPreferences 全部源码分析结束。


9. 总结

可以看到,虽然 SharedPreferences 是一个轻量级的 K-V 存储框架,但的确是一个完整的存储方案。从源码分析中,我们可以看到 SharedPreferences 在读写性能、可用性方面都有做一些优化,例如:锁细化、事务化、事务过滤、文件备份等,值得细细品味。

在下篇文章里,我们来盘点 SharedPreferences 中存在的 “缺点”,为什么 SharedPreferences 没有乘上新时代的船只。请关注。


版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

参考资料

推荐阅读

Android 开源库系列完整目录如下(2023/07/12 更新):

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~