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

4,005 阅读12分钟

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

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

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

前言

大家好,我是小彭。

SharedPreferences 是 Android 平台上轻量级的 K-V 存储框架,亦是初代 K-V 存储框架,至今被很多应用沿用。

有的小伙伴会说,SharedPreferences 是旧时代的产物,现在已经有 DataStore 或 MMKV 等新时代的 K-V 框架,没有学习意义。但我认为,虽然 SharedPreference 这个方案已经过时,但是并不意味着 SharedPreference 中使用的技术过时。做技术要知其然,更要知其所以然,而不是人云亦云,如果要你解释为什么 SharedPreferences 会过时,你能说到什么程度?

不知道你最近有没有读到一本在技术圈非常火爆的一本新书 《安卓传奇 · Android 缔造团队回忆录》,其中就讲了很多 Android 架构演进中设计者的思考。如果你平时也有从设计者的角度思考过 “为什么”,那么很多内容会觉得想到一块去了,反之就会觉得无感。

—— 图片引用自电商平台

今天,我们就来分析 SharedPreference 源码,在过程中依然可以学习到非常丰富的设计技巧。在后续的文章中,我们会继续分析其他 K-V 存储框架,请关注。

本文源码分析基于 Android 10(API 31),并关联分析部分 Android 7.1(API 25)。


思维导图:


1. 实现 K-V 框架应该思考什么问题?

在阅读 SharedPreference 的源码之前,我们先思考一个 K-V 框架应该考虑哪些问题?

  • 问题 1 - 线程安全: 由于程序一般会在多线程环境中执行,因此框架有必要保证多线程并发安全,并且优化并发效率;

  • 问题 2 - 内存缓存: 由于磁盘 IO 操作是耗时操作,因此框架有必要在业务层和磁盘文件之间增加一层内存缓存;

  • 问题 3 - 事务: 由于磁盘 IO 操作是耗时操作,因此框架有必要将支持多次磁盘 IO 操作聚合为一次磁盘写回事务,减少访问磁盘次数;

  • 问题 4 - 事务串行化: 由于程序可能由多个线程发起写回事务,因此框架有必要保证事务之间的事务串行化,避免先执行的事务覆盖后执行的事务;

  • 问题 5 - 异步写回: 由于磁盘 IO 是耗时操作,因此框架有必要支持后台线程异步写回;

  • 问题 6 - 增量更新: 由于磁盘文件内容可能很大,因此修改 K-V 时有必要支持局部修改,而不是全量覆盖修改;

  • 问题 7 - 变更回调: 由于业务层可能有监听 K-V 变更的需求,因此框架有必要支持变更回调监听,并且防止出现内存泄漏;

  • 问题 8 - 多进程: 由于程序可能有多进程需求,那么框架如何保证多进程数据同步?

  • 问题 9 - 可用性: 由于程序运行中存在不可控的异常和 Crash,因此框架有必要尽可能保证系统可用性,尽量保证系统在遇到异常后的数据完整性;

  • 问题 10 - 高效性: 性能永远是要考虑的问题,解析、读取、写入和序列化的性能如何提高和权衡;

  • 问题 11 - 安全性: 如果程序需要存储敏感数据,如何保证数据完整性和保密性;

  • 问题 12 - 数据迁移: 如果项目中存在旧框架,如何将数据从旧框架迁移至新框架,并且保证可靠性;

  • 问题 13 - 研发体验: 是否模板代码冗长,是否容易出错。

提出这么多问题后:

你觉得学习 SharedPreferences 有没有价值呢?

如果让你自己写一个 K-V 框架,你会如何解决这些问题呢?

新时代的 MMKV 和 DataStore 框架是否良好处理了这些问题?


2. 从 Sample 开始

SharedPreferences 采用 XML 文件格式持久化键值对数据,文件的存储位置位于应用沙盒的内部存储 /data/data/<packageName>/shared_prefs/ 位置,每个 XML 文件对应于一个 SharedPreferences 对象。

在 Activity、Context 和 PreferenceManager 中都存在获取 SharedPreferences 对象的 API,它们最终都会走到 ContextImpl 中:

ContextImpl.java

class ContextImpl extends Context {

    // 获取 SharedPreferences 对象
    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        // 后文详细分析...
    }
}

示例代码

SharedPreferences sp = getSharedPreferences("prefs", Context.MODE_PRIVATE);

// 创建事务
Editor editor = sp.edit();
editor.putString("name", "XIAO PENG");
// 同步提交事务
boolean result = editor.commit(); 
// 异步提交事务
// editor.apply()

// 读取数据
String blog = sp.getString("name", "PENG");

prefs.xml 文件内容

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>    
    <string name="name">XIAO PENG</string>
</map>

3. SharedPreferences 的内存缓存

由于磁盘 IO 操作是耗时操作,如果每一次访问 SharedPreferences 都执行一次 IO 操作就显得没有必要,所以 SharedPreferences 会在业务层和磁盘之间增加一层内存缓存。在 ContextImpl 类中,不仅支持获取 SharedPreferencesImpl 对象,还负责支持 SharedPreferencesImpl 对象的内存缓存。

ContextImpl 中的内存缓存逻辑是相对简单的:

  • 步骤1:通过文件名 name 映射文件对应的 File 对象;
  • 步骤 2:通过 File 对象映射文件对应的 SharedPreferencesImpl 对象。

两个映射表:

  • mSharedPrefsPaths: 缓存 “文件名 to 文件对象” 的映射;
  • sSharedPrefsCache: 这是一个二级映射表,第一级是包名到 Map 的映射,第二级是缓存 “文件对象 to SP 对象” 的映射。每个 XML 文件在内存中只会关联一个全局唯一的 SharedPreferencesImpl 对象

继续分析发现: 虽然 ContextImpl 实现了 SharedPreferencesImpl 对象的缓存复用,但没有实现缓存淘汰,也没有提供主动移除缓存的 API。因此,在 APP 运行过程中,随着访问的业务范围越来越多,这部分 SharedPreferences 内存缓存的空间也会逐渐膨胀。这是一个需要注意的问题。

在 getSharedPreferences() 中还有 MODE_MULTI_PROCESS 标记位的处理:

如果是首次获取 SharedPreferencesImpl 对象会直接读取磁盘文件,如果是二次获取 SharedPreferences 对象会复用内存缓存。但如果使用了 MODE_MULTI_PROCESS 多进程模式,则在返回前会检查磁盘文件相对于最后一次内存修改是否变化,如果变化则说明被其他进程修改,需要重新读取磁盘文件,以实现多进程下的 “数据同步”。

但是这种同步是非常弱的,因为每个进程本身对磁盘文件的写回是非实时的,再加上如果业务层缓存了 getSharedPreferences(…) 返回的对象,更感知不到最新的变化。所以严格来说,SharedPreferences 是不支持多进程的,官方也明确表示不要将 SharedPreferences 用于多进程环境。

SharedPreferences 内存缓存示意图

流程图

ContextImpl.java

class ContextImpl extends Context {

    // SharedPreferences 文件根目录
    private File mPreferencesDir;

    // <文件名 - 文件>
    @GuardedBy("ContextImpl.class")
    private ArrayMap<String, File> mSharedPrefsPaths;

    // 获取 SharedPreferences 对象
    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        // 1、文件名转文件对象
        File file;
        synchronized (ContextImpl.class) {
            // 1.1 查询映射表
            if (mSharedPrefsPaths == null) {
                mSharedPrefsPaths = new ArrayMap<>();
            }
            file = mSharedPrefsPaths.get(name);
            // 1.2 缓存未命中,创建 File 对象
            if (file == null) {
                file = getSharedPreferencesPath(name);
                mSharedPrefsPaths.put(name, file);
            }
        }
        // 2、获取 SharedPreferences 对象
        return getSharedPreferences(file, mode);
    }
		
    // -> 1.2 缓存未命中,创建 File 对象
    @Override
    public File getSharedPreferencesPath(String name) {
        return makeFilename(getPreferencesDir(), name + ".xml");
    }

    private File getPreferencesDir() {
        synchronized (mSync) {
            // 文件目录:data/data/[package_name]/shared_prefs/
            if (mPreferencesDir == null) {
                mPreferencesDir = new File(getDataDir(), "shared_prefs");
            }
            return ensurePrivateDirExists(mPreferencesDir);
        }
    }
}

文件对象 to SP 对象:

ContextImpl.java

class ContextImpl extends Context {

    // <包名 - Map>
    // <文件 - SharedPreferencesImpl>
    @GuardedBy("ContextImpl.class")
    private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;

    // -> 2、获取 SharedPreferences 对象
    @Override
    public SharedPreferences getSharedPreferences(File file, int mode) {
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            // 2.1 查询缓存
            final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
            sp = cache.get(file);
            // 2.2 未命中缓存(首次获取)
            if (sp == null) {
                // 2.2.1 检查 mode 标记
                checkMode(mode);
                // 2.2.2 创建 SharedPreferencesImpl 对象
                sp = new SharedPreferencesImpl(file, mode);
                // 2.2.3 缓存
                cache.put(file, sp);
                return sp;
            }
        }
        // 3、命中缓存(二次获取)
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            // 判断当前磁盘文件相对于最后一次内存修改是否变化,如果时则重新加载文件
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }

    // 根据包名获取 <文件 - SharedPreferencesImpl> 映射表
    @GuardedBy("ContextImpl.class")
    private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
        if (sSharedPrefsCache == null) {
            sSharedPrefsCache = new ArrayMap<>();
        }

        final String packageName = getPackageName();
        ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
        if (packagePrefs == null) {
            packagePrefs = new ArrayMap<>();
            sSharedPrefsCache.put(packageName, packagePrefs);
        }

        return packagePrefs;
    }
    ...
}

4. 读取和解析磁盘文件

在创建 SharedPreferencesImpl 对象时,构造函数会启动一个子线程去读取本地磁盘文件,一次性将文件中所有的 XML 数据转化为 Map 散列表。

需要注意的是: 如果在执行 loadFromDisk() 解析文件数据的过程中,其他线程调用 getValue 查询数据,那么就必须等待 mLock 锁直到解析结束。

如果单个 SharedPreferences 的 .xml 文件很大的话,就有可能导致查询数据的线程被长时间被阻塞,甚至导致主线程查询时产生 ANR。这也辅证了 SharedPreferences 只适合保存少量数据,文件过大在解析时会有性能问题。

读取示意图

SharedPreferencesImpl.java

// 目标文件
private final File mFile;
// 备份文件(后文详细分析)
private final File mBackupFile;
// 模式
private final int mMode;
// 锁
private final Object mLock = new Object();
// 读取文件标记位
@GuardedBy("mLock")
private boolean mLoaded = false;

SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    mBackupFile = makeBackupFile(file);
    mMode = mode;
    mLoaded = false;
    mMap = null;
    mThrowable = null;
    // 读取并解析文件数据
    startLoadFromDisk();
}

private void startLoadFromDisk() {
    synchronized (mLock) {
        mLoaded = false;
    }
    // 子线程
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            loadFromDisk();
        }
    }.start();
}

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

    Map<String, Object> map = null;
    if (mFile.canRead()) {
        // 2、读取文件
        BufferedInputStream str = new BufferedInputStream(new FileInputStream(mFile), 16 * 1024);
        // 3、将 XML 数据解析为 Map 映射表
        map = (Map<String, Object>) XmlUtils.readMapXml(str);
        IoUtils.closeQuietly(str);
    }

    synchronized (mLock) {
        mLoaded = true;

        if (map != null) {
            // 使用解析的映射表
            mMap = map;
        } else {
            // 创建空的映射表
            mMap = new HashMap<>();
        }
        // 4、唤醒等待 mLock 锁的线程
        mLock.notifyAll();
    }
}

static File makeBackupFile(File prefsFile) {
    return new File(prefsFile.getPath() + ".bak");
}

查询数据可能会阻塞等待:

SharedPreferencesImpl.java

public String getString(String key, @Nullable String defValue) {
    synchronized (mLock) {
        // 等待 mLoaded 标记位
        awaitLoadedLocked();
        // 查询数据
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

private void awaitLoadedLocked() {
    // “检查 - 等待” 模式
    while (!mLoaded) {
        try {
            mLock.wait();
        } catch (InterruptedException unused) {
        }
    }
}

5. SharedPreferences 的事务机制

是的,SharedPreferences 也有事务操作。

虽然 ContextImpl 中使用了内存缓存,但是最终数据还是需要执行磁盘 IO 持久化到磁盘文件中。如果每一次 “变更操作” 都对应一次磁盘 “写回操作” 的话,不仅效率低下,而且没有必要。

所以 SharedPreferences 会使用 “事务” 机制,将多次变更操作聚合为一个 “事务”,一次事务最多只会执行一次磁盘写回操作。虽然 SharedPreferences 源码中并没有直接体现出 “Transaction” 之类的命名,但是这就是一种 “事务” 设计,与命名无关。

5.1 MemoryCommitResult 事务对象

SharedPreferences 的事务操作由 Editor 接口实现。

SharedPreferences 对象本身只保留获取数据的 API,而变更数据的 API 全部集成在 Editor 接口中。Editor 中会将所有的 putValue 变更操作记录在 mModified 映射表中,但不会触发任何磁盘写回操作,直到调用 Editor#commitEditor#apply 方法时,才会一次性以事务的方式发起磁盘写回任务。

比较特殊的是:

  • 在 remove 方法中:会将 this 指针作为特殊的移除标记位,后续将通过这个 Value 来判断是移除键值对还是修改 / 新增键值对;
  • 在 clear 方法中:只是将 mClear 标记位置位。

可以看到: 在 Editor#commit 和 Editor#apply 方法中,首先都会调用 Editor#commitToMemery() 收集需要写回磁盘的数据,并封装为一个 MemoryCommitResult 事务对象,随后就是根据这个事务对象的信息写回磁盘。

SharedPreferencesImpl.java

final class SharedPreferencesImpl implements SharedPreferences {

    // 创建修改器对象
    @Override
    public Editor edit() {
        // 等待磁盘文件加载完成
        synchronized (mLock) {
            awaitLoadedLocked();
        }
        // 创建修改器对象
        return new EditorImpl();
    }

    // 修改器
    // 非静态内部类(会持有外部类 SharedPreferencesImpl 的引用)
    public final class EditorImpl implements Editor {

        // 锁对象
        private final Object mEditorLock = new Object();

        // 修改记录(将以事务方式写回磁盘)
        @GuardedBy("mEditorLock")
        private final Map<String, Object> mModified = new HashMap<>();

        // 清除全部数据的标记位
        @GuardedBy("mEditorLock")
        private boolean mClear = false;

        // 修改 String 类型键值对
        @Override
        public Editor putString(String key, @Nullable String value) {
            synchronized (mEditorLock) {
                mModified.put(key, value);
                return this;
            }
        }

        // 修改 int 类型键值对
        @Override
        public Editor putInt(String key, int value) {
            synchronized (mEditorLock) {
                mModified.put(key, value);
                return this;
            }
        }

        // 移除键值对
        @Override
        public Editor remove(String key) {
            synchronized (mEditorLock) {
                // 将 this 指针作为特殊的移除标记位
                mModified.put(key, this);
                return this;
            }
        }

        // 清空键值对
        @Override
        public Editor clear() {
            synchronized (mEditorLock) {
                // 清除全部数据的标记位
                mClear = true;
                return this;
            }
        }

        ...

        @Override
        public void apply() {
            // commitToMemory():写回磁盘的数据并封装事务对象
            MemoryCommitResult mcr = commitToMemory();
            // 同步写回,下文详细分析
        }

        @Override
        public boolean commit() {
            // commitToMemory():写回磁盘的数据并封装事务对象
            final MemoryCommitResult mcr = commitToMemory();
            // 异步写回,下文详细分析
        }
    }
}

MemoryCommitResult 事务对象核心的字段只有 2 个:

  • memoryStateGeneration: 当前的内存版本(在 writeToFile() 中会过滤低于最新的内存版本的无效事务);
  • mapToWriteToDisk: 最终全量覆盖写回磁盘的数据。

SharedPreferencesImpl.java

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

    @GuardedBy("mWritingToDiskLock")
    volatile boolean writeToDiskResult = false;
    boolean wasWritten = false;

    // 后文写回结束后调用
    void setDiskWriteResult(boolean wasWritten, boolean result) {
        this.wasWritten = wasWritten;
        // writeToDiskResult 会作为 commit 同步写回的返回值
        writeToDiskResult = result;
        // 唤醒等待锁
        writtenToDiskLatch.countDown();
    }
}

5.2 创建 MemoryCommitResult 事务对象

下面,我们先来分析创建 Editor#commitToMemery() 中 MemoryCommitResult 事务对象的步骤,核心步骤分为 3 步:

  • 步骤 1 - 准备映射表

首先,检查 SharedPreferencesImpl#mDiskWritesInFlight 变量,如果 mDiskWritesInFlight == 0 则说明不存在并发写回的事务,那么 mapToWriteToDisk 就只会直接指向 SharedPreferencesImpl 中的 mMap 映射表。如果存在并发写回,则会深拷贝一个新的映射表。

mDiskWritesInFlight 变量是记录进行中的写回事务数量记录,每执行一次 commitToMemory() 创建事务对象时,就会将 mDiskWritesInFlight 变量会自增 1,并在写回事务结束后 mDiskWritesInFlight 变量会自减 1。

  • 步骤 2 - 合并变更记录

其次,遍历 mModified 映射表将所有的变更记录(新增、修改或删除)合并到 mapToWriteToDisk 中(此时,Editor 中的数据已经同步到内存缓存中)。

这一步中的关键点是:如果发生有效修改,则会将 SharedPreferencesImpl 对象中的 mCurrentMemoryStateGeneration 最新内存版本自增 1,比最新内存版本小的事务会被视为无效事务。

  • 步骤 3 - 创建事务对象

最后,使用 mapToWriteToDisk 和 mCurrentMemoryStateGeneration 创建 MemoryCommitResult 事务对象。

事务示意图

SharedPreferencesImpl.java

final class SharedPreferencesImpl implements SharedPreferences {

    // 进行中事务计数(在提交事务是自增 1,在写回结束时自减 1)
    @GuardedBy("mLock")
    private int mDiskWritesInFlight = 0;

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

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

    // 修改器
    public final class EditorImpl implements Editor {

        // 锁对象
        private final Object mEditorLock = new Object();

        // 修改记录(将以事务方式写回磁盘)
        @GuardedBy("mEditorLock")
        private final Map<String, Object> mModified = new HashMap<>();

        // 清除全部数据的标记位
        @GuardedBy("mEditorLock")
        private boolean mClear = false;

        // 获取需要写回磁盘的事务
        private MemoryCommitResult commitToMemory() {
            long memoryStateGeneration;
            boolean keysCleared = false;
            List<String> keysModified = null;
            Set<OnSharedPreferenceChangeListener> listeners = null;
            Map<String, Object> mapToWriteToDisk;

            synchronized (SharedPreferencesImpl.this.mLock) {
                // 如果同时存在多个写回事务,则使用深拷贝 
                if (mDiskWritesInFlight > 0) {
                    mMap = new HashMap<String, Object>(mMap);
                }
                // mapToWriteToDisk:需要写回的数据
                mapToWriteToDisk = mMap;
                // mDiskWritesInFlight:进行中事务自增 1
                mDiskWritesInFlight++;

                synchronized (mEditorLock) {
                    // changesMade:标记是否发生有效修改
                    boolean changesMade = false;

                    // 清除全部键值对
                    if (mClear) {
                        // 清除 mapToWriteToDisk 映射表(下面的 mModified 有可能重新增加键值对)
                        if (!mapToWriteToDisk.isEmpty()) {
                            changesMade = true;
                            mapToWriteToDisk.clear();
                        }
                        keysCleared = true;
                        mClear = false;
                    }

                    // 将 Editor 中的 mModified 修改记录合并到 mapToWriteToDisk
                    // mapToWriteToDisk 指向 SharedPreferencesImpl 中的 mMap,所以内存缓存越会被修改
                    for (Map.Entry<String, Object> e : mModified.entrySet()) {
                        String k = e.getKey();
                        Object v = e.getValue();
                        if (v == this /*使用 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();
                    // 如果发生有效修改,内存版本自增 1
                    if (changesMade) {
                        mCurrentMemoryStateGeneration++;
                    }
                    // 记录当前的内存版本
                    memoryStateGeneration = mCurrentMemoryStateGeneration;
                }
            }
            return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified, listeners, mapToWriteToDisk);
        }
    }
}

步骤 2 - 合并变更记录中,存在一种 “反直觉” 的 clear() 操作:

如果在 Editor 中存在 clear() 操作,并且 clear 前后都有 putValue 操作,就会出现反常的效果:如以下示例程序,按照直观的预期效果,最终写回磁盘的键值对应该只有 ,但事实上最终 和 两个键值对都会被写回磁盘。

出现这个 “现象” 的原因是:SharedPreferences 事务中没有保持 clear 变更记录和 putValue 变更记录的顺序,所以 clear 操作之前的 putValue 操作依然会生效。

示例程序

getSharedPreferences("user", Context.MODE_PRIVATE).let {
    it.edit().putString("name", "XIAOP PENG")
        .clear()
        .putString("age", "18")
        .apply()
}

小结一下 3 个映射表的区别:

  • 1、mMap 是 SharedPreferencesImpl 对象中记录的键值对数据,代表 SharedPreferences 的内存缓存;
  • 2、mModified 是 Editor 修改器中记录的键值对变更记录;
  • 3、mapToWriteToDisk 是 mMap 与 mModified 合并后,需要全量覆盖写回磁盘的数据。

后续源码分析,见下一篇文章:Android 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)


版权声明

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

参考资料

推荐阅读

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

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