SharedPreferences 相关总结

SP 存储相关内容。

有关 SharedPreferences 的简单使用:

以下代码的源码基于 android-29 , 为什么要这么强调一下呢?因为几乎每个版本都会有一些大大小小的修改, 官方也在陆续做优化。

以下包含以下内容:

  1. SharedPreferences 对象的获取
    • 获取代码
    • context.getSharedPreferences(fileName, mode) 代码分析
    • 新建 SP对象 和 loadFromDisk 的代码分析
    • 小结
  2. 访问 SP 的数据:读数据和写数据操作
    • 读操作
    • 写操作, commit()apply() 的区别
  3. 一些有关 SP 源码的有趣操作
    • mLock 变量的加锁
    • mBackupFile 备份文件的存在
    • apply()commit() 的误解
    • 对多进程的处理,不支持多进程
    • QueuedWork 的影响, 可能会造成阻塞

1. SharedPreferences 的获取

1.1 获取代码

当我们获取一个 Sp 时, 获取代码如下:

// context getSharedPreferences()
val preferences = context.getSharedPreferences(prefName,Context.MODE_PRIVATE)

通过 ContextImpl 获取到对应文件的 SharedPreferences 对象。
而我们都知道,如果当前 prefName 的文件不存在,会新建一个文件。

1.2 context.getSharedPreferences(fileName, mode) 代码分析

getSharedPreferences(name, mode) 代码如下:

// 部分代码如下:ContextImpl.getSharedPreferences(xxx) 如下:
// 1. synchronized 加锁 ,对整个 `ContextImpl` 这个类加锁
synchronized (ContextImpl.class) {
    // 2. mSharedPrefsPaths 为 arrayMap, 如果为空,则会新建一个,里面存贮这 `key-> 文件名`, 
    // `value -> File`
    if (mSharedPrefsPaths == null) {
        mSharedPrefsPaths = new ArrayMap<>();
    }
    file = mSharedPrefsPaths.get(name);
    // 3. 如果没有该文件,则去新建文件
    if (file == null) {
        // 4. getSharedPreferencesPath(name) 中会新建该文件
        file = getSharedPreferencesPath(name);
        // 5. 把该文件加入到 ArrayMap 中,下次直接读取;
        mSharedPrefsPaths.put(name, file);
    }
}

// 获取真正的 sp 对象
return getSharedPreferences(file, mode);

上述代码中,重要的位置添加了注释,在这里再说一下:

  1. synchronized 加锁 ,对整个 ContextImpl 这个类加锁
    读这部分源码时,你会发现有很多类似的实现,有时是对 ContextImpl 一个类加锁,有时会对一个对象加锁,其中都是有不同用意的,慢慢体会。
  2. mSharedPrefsPaths 是一个 key-> 文件名 name, value -> File 的一个 ArrayMap,
    所有当前 APP 下的 SP 文件,只要被加载过,都会被存在这个 Map
  3. 如果没有该 SP 文件,则去新建文件 getSharedPreferencesPath(),
    getSharedPreferencesPath 这个方法里面,有指定 SP 文件存放位置的代码。
  4. 把该文件加入到 ArrayMap 中,下次可以直接得到该 File

注:上述中的文件,其实是 File, 此时读取到的 File 尚未经过 Xml 解析,更未得到 SharedPreferencesImpl 对象。

1.3 ContextImpl.getSharedPreferences(file, mode) 的代码分析

简化为下面代码:

public SharedPreferences getSharedPreferences(File file, int mode) {
    ...
    synchronized (ContextImpl.class) {
        final ArrayMap cache = getSharedPreferencesCacheLocked();
        sp = cache.get(file);
        ...
        sp = new SharedPreferencesImpl(file, mode);
        ache.put(file, sp);
    }
    ...
    return sp;
}

getSharedPreferences(file, mode) 方法中,会去检测 sSharedPrefsCache所对应的当前包名的 ArrayMap 中是否有缓存的该文件 Filesp 对象,如果没有该对应的 SP 对象,则会新建一个 new SharedPreferencesImpl(file, mode)

new SharedPreferencesImpl() 的代码
主要作用:

  1. 新建 SharedPreferencesImpl 对象;
  2. 创建备份文件;
  3. 异步读取 File 文件里面的内容到 mMap

new SharedPreferencesImpl(file, mode) 中,会新建一个 BackUp 备份的 File:

SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    mBackupFile = makeBackupFile(file);
    mMode = mode;
    mLoaded = false;
    mMap = null;
    mThrowable = null;
    startLoadFromDisk();
}

// 为 prefsFile 新建一个备份的 文件,以 `.bak` 结尾
static File makeBackupFile(File prefsFile) {
    return new File(prefsFile.getPath() + ".bak");
}

我们都知道,SP 文件是存储为 xml 格式的文件,那么是如何解析成,我们熟知的 map 形式呢?

代码在 startLoadFromDisk() 方法里面, 它会开一个线程 threadloadFromDisk
loadFromDisk 代码如下:

// 代码如下
private void loadFromDisk() {
    synchronized (mLock) {
        // 1. 是否解析过该文件, 如果解析过,则不再重复解析,处理多线程访问的问题。
        if (mLoaded) {
            return;
        }
        // 2. 如果备份文件存在,则删除 mFile, 把 mBackupFile 重命名为 mFile
        // 即,最终使用的是备份文件里面的数据
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }
    ...
    Map map = null;
    try {
        str = new BufferedInputStream(new FileInputStream(mFile), 16 * 1024);
        map = (Map) XmlUtils.readMapXml(str);
    } catch() {...}
    ...
    synchronized (mLock) {
        // 3. 设置标志符为 true, 已经读取了该文件到内存 map 中;
        mLoaded = true;
        ...
        // 4. 复制给成员变量 mMap
        mMap = map;
        //在这里其实有个 try catch 给 mMap 赋值, 在 finally 中这么实现
        ...
        finally {
            // 5. 用于唤醒所有等待 mLock 的线程,也就是说在这里从 `xml` 文件中读取数据到内存 mMap 中,
            //是可能会存在线程等待的
            mLock.notifyAll();
        }
    }
    
}

上面代码中,标注了一些值得注意的地方, 在罗列一下:

  1. 通过 mLoaded判断 是否解析过该文件, 如果解析过,则不再重复解析,处理多线程访问的问题;
  2. 如果备份文件存在,则删除 mFile, 把 mBackupFile 重命名为 mFile , 即,最终使用的是备份文件里面的数据
  3. 设置标志符 mLoadedtrue, 表示已经读取了该文件到内存 map 中;
  4. 复制给成员变量 mMap , 后续所有从该 SP 中访问的数据,都来自于它;
  5. 用于唤醒所有等待 mLock 的线程,也就是说在这里从 xml 文件中读取数据到内存 mMap, 是可能会存在线程等待的。

得到 sp 返回值后,我们再次回到 getSharedPreferences(File file, int mode)

//ContextImpl.java android-29
public SharedPreferences getSharedPreferences(File file, int mode) {
    ...
    
    // synchronized (ContextImpl.class) 加锁, 保证重复从内存中读取文件
    // 1. 得到返回值 sp
    sp = new SharedPreferencesImpl(file, mode);
    // 2. 把返回值 sp 加入到 cache  map 中,下次再次访问该 SP 文件, 不需要再次解析该文件,
    //节省时间和开销
    cache.put(file, sp);
    return sp;
}

至此,我们已经拿到了 SP 的对象, 经过 getSharedPreferences(File file, int mode) 返回,
val preferences = SP 这里赋值成功.

后续,便可以通过它访问存储的数据。

1.4 小结

在上述过程中,我们主要分析了如何得到 SP 对象,在这里有以下需要值得注意的点:

  1. ContextImpl 中有两个内存变量 mSharedPrefsPaths sSharedPrefsCache

    sSharedPrefsCacheArrayMap>,
    它的 key 为当前 APP 的包名, value 为 Map 「它的 keysp 对应的 FilevalueSP 对应的对象
    mSharedPrefsPathsArrayMap, 是根据 SP 的文件名 name , 获取到对应的 File

  2. 在解析文件时,会优先看是否存在备份文件,如果存在,则用备份文件去替换原有文件;

  3. 解析对应文件 xml FilemMap 中是异步的, 在子线程中完成,并且有加锁处理,防止多个线程进行解析

2. 访问它的数据 :读操作 + 写操作

2.1 读操作:getxxx()

在第一部分,当我们拿到了 SP 对象 preferences 后,我们可以访问到它里面的数据,
简单示例如下:

// 通过这种形式可以访问到它内部对应的数据
preferences.getString("key_name", "default_value")

再看一下,getString(xxx) 内部的实现 :

// SharedPreferencesImpl.getString() 方式实现代码:
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
    // 1.使用 synchronized 加锁,防止多线程同时访问
    synchronized (mLock) {
        // 2. 判断是否加载 File 文件到内存 mMap 中, 
        awaitLoadedLocked();
        // 3. 所有取的数据都是在 `mMap` 中取的数据
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

当读取一个数据时,会有以下逻辑, 在上述代码中有标注:

  1. 会通过 synchronized (mLock) 加锁,防止多线程的同时访问;

  2. 在内部会等待 「加载 File 文件到内存mMap」 的完成: 这里本质上是判断mLoaded这个变量的值是否为true, -true:表示文件已经读取好了到内存mMap中; -false: 表示文件尚未读入到内存mMap` 中

    awaitLoadedLocked() 的代码中,有 mLock.wait()
    这也就是为什么需要在 loadFromDisk() 里面最后的 finally 代码块中 mLock.notifyAll()
    唤醒等待的地方,继续执行他们的取数据操作。

  3. 所有取的数据都是在 mMap 中取的数据,
    一旦我们拿到 mMap 时,在同一个进程下的数据获取都是对 mMap 的读数据操作.

2.2 写操作 putxxx()

对于 put 操作,我们都知道,其实是需要拿到 preferences.edit() 对象

preferences.edit() 才是我们操作写数据时需要使用到的。

@Override
public Editor edit() {
    ...
    // 
    synchronized (mLock) {
        // 等待加载完成, mMap 有数据
        awaitLoadedLocked();
    }
    return new EditorImpl();
}

实际的对象为为 EditImpl, 看以下代码:

// 以 putString(xxx) 代码为例
@Override
public Editor putString(String key, @Nullable String value) {
    // 同样有加锁, Object mEditorLock , 防止多线程同时写数据
    synchronized (mEditorLock) {
        // mModified = new HashMap(), 是一个 HashMap 对象
        mModified.put(key, value);
        return this;
    }
}

从代码中,可以看到:

  1. 同样会有加锁处理, 保证多线程安全
  2. 所有数据的 put 都会暂时放入到 mModified 这个 Map 中;

暂时存放在 mModified 的数据,会在 EditIpml.commitToMemory() 中写入到 mMap
EditIpml.commitToMemory() 并不是一个耗时操作,真正把 mMap 写入到 File 中才是耗时操作

一个完整 put 操作代码调用如下:

// 首先得到一个 EditorImpl() 对象
val edit = preferences.edit()
// 添加数据
edit.putString("name", "chendroid")

// 提交数据:两种方式,commit()  或者 `apply()`
//edit.commit()
edit.apply() 

代码说明如下:

  1. preferences.edit() 每次都会 new EditorImpl(), 因此不要频繁调用 edit() 方法;

  2. 两种提交方式:commit(): commit() 提交后,会在 UI 线程「当前线程」进行数据的写入

  3. apply() 的提交方式: apply() 提交后,会把数据写入的操作放在 IO 线程里面
    因为,如果没有额外的需求,我们一般是使用 apply() 做数据的写入操作

2.2.1 edit.commit() 后续逻辑

先分析下源码:

public boolean commit() {
    ...
    // 1. 首先把 `mModified` 中新增的数据,写入到 `mMap` 内存中,
    MemoryCommitResult mcr = commitToMemory();
    // 2. 真正写入文件 File 中,写入到磁盘中
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null);
    try {
        // 3. 在这里等待,直到 writtenToDiskLatch 计数器为 0 ,主线程结束等待,开始执行后续逻辑
        // 子线程中,会调用 writtenToDiskLatch.countDown() 使线程计数器减 1
        mcr.writtenToDiskLatch.await();
    } catch ...
    
    } finally {...}
    ...
    notifyListeners(mcr);
    // 4. 返回执行结果,是否提交数据成功。
    return mcr.writeToDiskResult;
}

在上述代码中, 已经写了部分注解:

  1. 首先,commitToMemory() 实际操作是把 mModified 新增的值给对应的 mMap 新增
  2. 真正写入磁盘的操作是在 enqueueDiskWrite() 里面,这个是耗时操作。它的两个参数是有区别的
    enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable),
    • 第一个参数: mcrcommitToMemory() 的结果
    • 第二个参数: postWriteRunnable 是决定是否会异步提交的关键
  3. commit() 是会等待写入操作完成后才返回的,因为会存在 mcr.writtenToDiskLatch.await() 的操作
  4. commit() 是有返回结果的,返回是否写入到磁盘 disk .

注:上述代码中,mcr.writtenToDiskLatchCountDownLatch 的实例。
作用是等待其他的线程都执行完任务,必要时可以对各个任务的执行结果进行汇总,
然后主线程才继续往下执行。

下面我们在看一看 edit.apply() 操作。

enqueueDiskWrite() 的具体逻辑,我们暂不分析

2.2.2 edit.apply() 后续逻辑

首先看一下它的源码:

//
public void apply() {
    ...
    //1. 和 commit() 相同的逻辑,写入到内存 mMap 中
    final MemoryCommitResult mcr = commitToMemory();
    // 2. 新建了一个 Runnable
    final Runnable awaitCommit = new Runnable() {
        ...
        // 3. 在 Runnable 里面这里的等待操作~
        mcr.writtenToDiskLatch.await();
        ...
    }
    // 4. 把 awaitCommit 添加进去
    QueuedWork.addFinisher(awaitCommit);    
    // 5. 这里新建的 postWriteRunnable 
    Runnable postWriteRunnable = new Runnable() {
        run() {
            // 6. 在第 2 步中新建的 runnable 在这里被调用运行 
            awaitCommit.run();
            // 
            QueuedWork.removeFinisher(awaitCommit);
        }
    }
    // 7. 和 commit()  一样的调用方法, 写入到磁盘中去
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
}

它里面的代码逻辑如下:

  1. commit() 相同的逻辑,写入到内存 mMap

  2. 新建了一个 Runnable, 用于表示等待完成后的操作;

  3. 新建了 postWriteRunnable, 并在 postWriteRunnable 中,调用了 awaitCommit.run(), 表示等待写入磁盘已经完成;

  4. 调用 enqueueDiskWrite, 并传入了 postWriteRunnable


我们可以发现,上述中 apply()commit() 都调用了 enqueueDiskWrite(), 用于把当前改动写入到磁盘文件中;
但是有所不同,commit() 中,传入的第二个参数 Runnablenull;
apply() 中,传入了不为 nullpostWriteRunnable;

那得看一下 enqueueDiskWrite(mcr, postWriteRunnable) 这个方法的代码了:

// 写入磁盘操作
private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
    // 1. isFromSyncCommit 代表着是否同步写入磁盘中,
    //如果为 true , 代表这同步写入,「commit() 传入的 postWriteRunnable == null」 所以为同步写入
    //如果为 false, 代表这异步写入,apply() 传入的 postWriteRunnable != null」, 所以为异步写入
    final boolean isFromSyncCommit = (postWriteRunnable == null);
    //
    final Runnable writeToDiskRunnable = new Runnable() {
        @Override
        public void run() {
            synchronized (mWritingToDiskLock) {
                // 2. 真正写入磁盘文件的位置
                writeToFile(mcr, isFromSyncCommit);
            }
            synchronized (mLock) {
                mDiskWritesInFlight--;
            }
            //
            if (postWriteRunnable != null) {
                // 3. 等写入文件完成后,这里手动调用传入的 `postWriteRunnable.run()`
                postWriteRunnable.run();
            }
        }
    }
    // 4. 这里为 commit() 走的逻辑,为 true ,同步写入
    if (isFromSyncCommit) {
        boolean wasEmpty = false;
        synchronized (mLock) {
            wasEmpty = mDiskWritesInFlight == 1;
        }
        // mDiskWritesInFlight 默认为 0, 分别有 `++` 和 `--` 操作;
        // `mDiskWritesInFlight++` 在 `commitToMemory()` 中;
        // `mDiskWritesInFlight--` 在 `enqueueDiskWrite().writeToDiskRunnable` 中;
        if (wasEmpty) {
            // 5. 这里直接调用 writeToDiskRunnable, 并且 return 数据
            writeToDiskRunnable.run();
            return;
        }
    }
    // 6. apply() 逻辑, 会加入到队列中,等待完成
    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

从上面代码中,我们可以看到:

  1. commit() 传入的 postWriteRunnablenull, isFromSyncCommit == true 表示同步提交;
  2. apply() 传入的 postWriteRunnable != null , isFromSyncCommit == false 表示异步提交;
  3. writeToDiskRunnable 是真正执行写入文件的 runnable, 它的 run() 方法调用的时机决定着它执行在那个线程;
  4. commit() 走的逻辑: 在当前线程中调用 writeToDiskRunnable.run(); 并且返回,是同步提交代码的,可能会存在阻塞主线程的情况;
  5. apply() 走的逻辑: 把 writeToDiskRunnable 加入到 QueuedWork 队列中, 在 IO 线程中执行 writeToDiskRunnable
  6. QueuedWork 内部是自带了 HandlerThread, 在它内部 IO 线程中执行对应的任务
  7. QueuedWork.queue() 会把当前的 work 「Runnable」 加入到 sWork.add(work)「一个 LinkedList 队列」
  8. QueuedWork 在执行 sWork 里面的消息是顺序执行的;
  9. QueuedWork.queue(Runnable work, boolean shouldDelay) 第二个参数是指:是否延迟发送消息;
    在这里,apply() 传入的是 true, 代表会延迟发送消息「hanlder 发送xiaoxi 」,默认延迟 100ms

2.3 总结

从上面的读数据和写数据的分析中,我们了解了以下内容:

  1. 读数据时, 数据都是从 mMap 中取得的值;

  2. 写数据时,实际操作的对象为 EditorImpl(),会先把数据存储在 mModified 中,等到 apply() 提交时,才会更新 mMap 中的值, 并且每次提交都会更新磁盘中的文件;

  3. apply() 提交是异步的,在 IO线程完成的;

  4. commit() 提交是同步的, 在当前线程完成的, 可能会出现阻塞主线程的情况;

  5. apply() 的异步执行中,消息是按照顺序依次执行的

  6. 不管是 apply() 还是 commit(), 每次提交后,都会有内存写入磁盘的操作
    即使只改动了一个很小的值,也会把全部写入到磁盘中,多次修改提交,就会有多次的写入磁盘,这也是 SP 性能不足够优秀的原因;

3 一些 SP 的有趣操作

3.1 对 mLock 变量的加锁

为了确保多线程的安全,整个 SharedPreferencesImpl 代码中,总共有 19 处使用了下面的形式:

synchronized (mLock) {
    // do something
}

只要涉及到数据的访问,读取,加载,写入,均都对该变量的加锁处理;
这也保证了数据的安全性以及可见性。

3.2 mBackupFile 备份文件的存在

mBackupFile 的存在,保证了在一些特殊的异常情况下,假设在写入磁盘数据时发生了异常,此时仍然存在 mBackupFile 可供使用。

  1. loadFromDisk() 时,会优先检查是否存在 mBackupFile 如果存在,则删除原文件 mFile, 把 mBackupFile 重命名为 mFile;

  2. writeToFile() 时,会先存在检查备份文件是否存在,如果不存在,则将 mFile 重命名为 mBackupFile ;

  3. writeToFile() 继续运行时,如果 File 写入成功,则会再次删除 mBackupFile;

3.3 对 apply()commit() 的误解

这里要注意下,因为 commit() 是同步提交到磁盘中的,而 apply() 是异步提交到磁盘中,是在 IO 线程中完成的;
有些博文的理解是:apply() 会等待一些数据的修改,然后再把这些修改一次性写入磁盘中;
这是错误的

** apply()commit() 每次提交,都会对文件进行一次修改写入磁盘操作**

它们的不同是在:写入磁盘的线程有所不同

3.4 对多进程的处理,不支持多进程

context.getSharedPreferences(prefName,Context.MODE_PRIVATE) 中,
第二个参数是:mode , 当其符合 Context.MODE_MULTI_PROCESS 时,其实并没有支持多进程的操作。

在它的注释中:

// Context.java 中
/**
* @deprecated MODE_MULTI_PROCESS does not work reliably 
*/
@Deprecated
public static final int MODE_MULTI_PROCESS = 0x0004;

也可以看到官方说是不支持的。

而当是这个 mode 时,SP 是如何处理的呢?

// 代码在 Context.getSharedPreferences(File file, int mode) 中
if ((mode & Context.MODE_MULTI_PROCESS) != 0 || ... {
    // 
    sp.startReloadIfChangedUnexpectedly();
}
return sp

而在 sp.startReloadIfChangedUnexpectedly() 这个里面,做了什么操作呢?

//
void startReloadIfChangedUnexpectedly() {
    synchronized (mLock) {
        ...
        // 异步开启去磁盘中再读取一遍文件
        startLoadFromDisk();    
    }
}

而上述代码中,已经把 SP 的 对象 return 出去了,
在这里并没有多进程做多余的加锁操作,因此 SP并不支持多进程传输数据。

3.5 QueuedWork 的影响

apply() 异步提交数据后,最终会被加入到 QueuedWork.enque(postrunnable) 进行处理的。

那么…… 我们有没有其他地方涉及到这个类?
QueuedWork 中,有这样一个方法 waitToFinish()

//Trigger queued work to be processed immediately. 触发队列中的任务立马执行
// Is called from the Activity base class's onPause(), 
//它会在 `Activity.onPause()` 方法中被调用 
// so async work is never lost: 以防异步工作丢失,数据未完成写入。
public static void waitToFinish() {
    //
    ...
}

它被调用的位置在 ActivityThread.java 中,源码如下:

// ActivityThread.handlePauseActivity() 方法中
public void handlePauseActivity(...) {
    ...
    // Make sure any pending writes are now committed.
    if (r.isPreHoneycomb()) {
        // 调用位置。 使得所有异步的操作都完成
        QueuedWork.waitToFinish();
    }
    ...
}

而在 QueuedWork.waitToFinish() 的源码中,如果会完成的话,会一直执行,知道任务完成,才会继续走 onPause()) 的其余操作。

所以, 当我们异步 apply() 提交很多次任务时,这时突然杀死 APP, 这时候如果 QueuedWork 中未完全执行,是会发生阻塞的;

而一旦阻塞的时间过长,可能就会发生 ANR.

这里我们要注意一下。

4 整体总结

有关 SP 的分析,在上面,尽可能的分析到了。

可以根据上面的代码分析,给出一些建议:

  1. 尽可能分散的使用 SP 文件,建立多个 SP 文件,同时保证每个 SP 文件不会过大,因为每次都会写入磁盘的操作,即使你只修改了这份文件的小个小部分;
  2. 分成多个文件还有一个原因是:当你对其中的数据进行操作时,不能同时操作,可能会存在等待的情况,如果是多个文件,分别去不同的文件中取,会高效;
  3. 可以提前初始化 SP ,得到它的对象, 这样再使用的时候就可以不用等待可以直接存储数据;
    例如在 Application 中初始化
  4. 不要每次都 sp.edit(), 因为 edit() 方法总会新建一个 EditorImpl 对象;
  5. 无特殊需求,不要使用 commit() 提交修改,而是使用 apply()
  6. SP 不支持多进程通信, 不要依赖它做一些多进程的操作
    支持多进程的方式:
    • MMKV: 腾讯开源,https://github.com/Tencent/MMKV
    • FastSharedPreferences : 使用 synchronized 和一些原子操作的 CAS 实现 https://github.com/JeremyLiao/FastSharedPreferences

参考链接

  1. Android 之不要滥用 SharedPreferences(上): https://www.jianshu.com/p/5fcef7f68341
    注:这里的作者的一系列文章都很精彩,可以阅读下。
  2. https://github.com/JeremyLiao/FastSharedPreferences, 源码的一些实现
  3. Android-29 源码

这次就先这样吧~

2020.7.21 by chendroid

你可能感兴趣的:(SharedPreferences 相关总结)