SharedPreferences

Start

前言

转载
我们知道 SharedPreferences 会从文件读取 xml 文件, 并将其以 getXxx/putXxx 的形式提供读写服务. 其中涉及到如下几个问题:

  1. 如何从磁盘读取配置到内存
  2. getXxx 如何从内存中获取配置
  3. 最终配置如何从内存回写到磁盘
  4. 多线程/多进程是否会有问题
  5. 最佳实践

1. 结论

  • SharedPreferences 是线程安全的,内部由大量 synchronized 关键字保障。
  • SharedPreferences 不是进程安全的。
  • 第一次 getSharedPreferences 会读取磁盘文件,后续的 getSharedPreferences 会从内存缓存中获取。 如果第一次调用 getSharedPreferences 时还没从磁盘加载完毕就调用 getXxx/putXxx , 则 getXxx/putXxx 操作会卡主,直到数据从磁盘加载完毕后返回。
  • 所有的 getXxx 都是从内存中取的数据
  • apply 是同步回写内存,然后把异步回写磁盘的任务放到一个单线程的队列中等待调度。commit 和前者一样,只不过要等待异步磁盘任务结束后才返回。
  • MODE_MULTI_PROCESS 是在每次 getSharedPreferences 时检查磁盘上配置文件上次修改时间和文件大小, 一旦所有修改则会重新从磁盘加载文件。 所以并不能保证多进程数据的实时同步。
  • 从 Android N 开始,不再支持 MODE_WORLD_READABLE & MODE_WORLD_WRITEABLE。一旦指定,会抛异常。

2. 最佳实践

  • 不要多进程使用,很小几率会造成数据全部丢失(万分之一), 现象是配置文件被删除。
  • 不要依赖 MODE_MULTI_PROCESS,这个标记就像 MODE_WORLD_READABLE/MODE_WORLD_WRITEABLE 未来会被废弃。
  • 每次 apply / commit 都会把全部的数据一次性写入磁盘,所以单个的配置文件不应该过大, 影响整体性能。

3. 源码分析

3.1 SharedPreferences 对象的获取

一般来说有如下方式:

  1. PreferenceManager.getDefaultSharedPreferences
  2. ContextImpl.getSharedPreferences

我们以上述 [1] 为例来看看源码:

// PreferenceManager.java
public static SharedPreferences getDefaultSharedPreferences(Context context) {
    return context.getSharedPreferences(getDefaultSharedPreferencesName(context),
            getDefaultSharedPreferencesMode());
}

可以看到最终也是调用到了 ContextImpl.getSharedPreferences, 源码:

// ContextImpl.java
@Override
    public SharedPreferences getSharedPreferences(File file, int mode) {
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
            sp = cache.get(file);
            if (sp == null) {
                checkMode(mode);
                if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {
                    if (isCredentialProtectedStorage()
                            && !getSystemService(UserManager.class)
                                    .isUserUnlockingOrUnlocked(UserHandle.myUserId())) {
                        throw new IllegalStateException("SharedPreferences in credential encrypted "
                                + "storage are not available until after user is unlocked");
                    }
                }
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            // If somebody else (some other process) changed the prefs
            // file behind our back, we reload it.  This has been the
            // historical (if undocumented) behavior.
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }

可见 sdk 是先取了缓存,如果缓存未命中,才构造对象。也就是说,多次 getSharedPreferences 几乎是没有代价的。 同时, 实例的构造被 synchronized 关键字包裹,因此构造过程是多线程安全的。

3.2 SharedPreferences 的构造

第一次构建对象时:

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

有这么几个关键信息:

  1. mFile 代表我们磁盘上的配置文件。
  2. mBackupFile 是一个灾备文件,用户写入失败时进行恢复,后面会再说。其路径是 mFile 加后缀 ‘.bak’。
  3. mMap 用于在内存中缓存我们的配置数据, 也就是 getXxx 数据的来源。

还涉及到一个 startLoadFromDisk, 我们来看看:

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

开启了一个线程从文件读取, 其源码如下:

// SharedPreferencesImpl.java
private void loadFromDisk() {
    synchronized (SharedPreferencesImpl.this) {
        if (mLoaded) {
            return;
        }
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }

    ... 略去无关代码 ...

    str = new BufferedInputStream(
            new FileInputStream(mFile), 16*1024);
    map = XmlUtils.readMapXml(str);

    synchronized (SharedPreferencesImpl.this) {
        mLoaded = true;
        if (map != null) {
            mMap = map;
            mStatTimestamp = stat.st_mtime;
            mStatSize = stat.st_size;
        } else {
           mMap = new HashMap<>();
        }
        notifyAll();
    }
}

loadFromDisk 这个函数很关键。它就是实际从磁盘读取配置文件的函数。 可见, 它做了如下几件事:

  1. 如果有 ‘灾备’ 文件,则直接使用灾备文件回滚。
  2. 把配置从磁盘读取到内存的并保存在 mMap 字段中(看代码最后 mMap = map)。
  3. 标记读取完成, 这个字段后面 awaitLoadedLocked 会用到。 记录读取文件的时间,后面 MODE_MULTI_PROCESS 中会用到。
  4. 发一个 notifyAll 通知已经读取完毕, 激活所有等待加载的其他线程。

总结一下:


从磁盘读取配置

3.3 getXxx 的流程

// SharedPreferencesImpl.java
public String getString(String key, @Nullable String defValue) {
    synchronized (this) {
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

可见, 所有的 get 操作都是线程安全的。并且 get 仅仅是从内存中(mMap) 获取数据, 所以无性能问题。

考虑到配置文件的加载是在单独的线程中异步进行的(参考 ‘SharedPreferences 的构造’),所以这里的 awaitLoadedLocked 是在等待配置文件加载完毕。 也就是说如果我们第一次构造 SharedPreferences 后就立刻调用 getXxx 方法, 很有可能读取配置文件的线程还未完成, 所以这里要等待该线程做完相应的加载工作。

来看看 awaitLoadedLocked 的源码:

// SharedPreferencesImpl.java 
  private void awaitLoadedLocked() {
        if (!mLoaded) {
            // Raise an explicit StrictMode onReadFromDisk for this
            // thread, since the real read will be in a different
            // thread and otherwise ignored by StrictMode.
            BlockGuard.getThreadPolicy().onReadFromDisk();
        }
        while (!mLoaded) {
            try {
                mLock.wait();
            } catch (InterruptedException unused) {
            }
        }
        if (mThrowable != null) {
            throw new IllegalStateException(mThrowable);
        }
    }

很明显,如果加载还未完成(mLoaded == false),getXxx 会卡在 awaitLoadedLocked,一旦加载配置文件的线程工作完毕, 则这个加载线程会通过 notifyAll 会通知所有在 awaitLoadedLocked 中等待的线程,getXxx 就能够返回了。不过大部分情况下,mLoaded == true。这样的话 awaitLoadedLocked 会直接返回。

3.4 putXxx 的流程

setget 稍微麻烦一点儿,因为涉及到 EditorMemoryCommitResult 对象。

先来看看 edit() 方法的实现:

// SharedPreferencesImpl.java
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 (this) {
        awaitLoadedLocked();
    }

    return new EditorImpl();
}

Editor

Editor 没有构造函数,只有两个属性被初始化:

// SharedPreferencesImpl.java
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;

    ... 略去方法定义 ...
    public Editor putString(String key, @Nullable String value) { ... }
    public boolean commit() { ... }
    ...
}

mModified 是我们每次 putXxx 后所改变的配置项。
mClear 标识要清空配置项, 但是只清了 SharedPreferences.mMap

edit() 会保障配置已从磁盘读取完毕,然后仅仅创建了一个对象。接下来看看 putXxx 的真身:

// SharedPreferencesImpl.java
public Editor putString(String key, @Nullable String value) {
    synchronized (this) {
        mModified.put(key, value);
        return this;
    }
}

很简单,仅仅是把我们设置的配置项放到了 mModified 属性里保存。等到 apply 或者 commit 的时候回写到内存和磁盘。咱们分别来看看。

apply

apply 是各种 ‘最佳实践’ 推荐的方式,那么它到底是怎么异步工作的呢?我们来看个究竟:

// SharedPreferencesImpl.java
public void apply() {
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }
            }
        };

    QueuedWork.add(awaitCommit);

    Runnable postWriteRunnable = new Runnable() {
            public void run() {
                awaitCommit.run();
                QueuedWork.remove(awaitCommit);
            }
        };

    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

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

可以看出大致的脉络:

  1. commitToMemory 应该是把修改的配置项回写到内存。
  2. QueuedWork.add(awaitCommit) 貌似没什么卵用。
  3. SharedPreferencesImpl.this.enqueueDiskWrite 把配置项加入到一个异步队列中,等待调度。

我们来看看 commitToMemory 的实现(略去大量无关代码):

// SharedPreferencesImpl.java
private MemoryCommitResult commitToMemory() {
    MemoryCommitResult mcr = new MemoryCommitResult();
    synchronized (SharedPreferencesImpl.this) {

        ... 略去无关 ...

        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);
        }
}

总结来说就两件事:

  1. Editor.mModified 中的配置项回写到 SharedPreferences.mMap 中,完成了内存的同步。
  2. 把 SharedPreferences.mMap 保存在了 mcr.mapToWriteToDisk 中。而后者就是即将要回写到磁盘的数据源。

我们再来回头看看 apply 方法:

// SharedPreferencesImpl.java
public void apply() {
    final MemoryCommitResult mcr = commitToMemory();

    ... 略无关 ...

    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
}
  • commitToMemory 完成了内存的同步回写
  • enqueueDiskWrite 完成了硬盘的异步回写, 我们接下来具体看看 enqueueDiskWrite
// SharedPreferencesImpl.java
private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                final Runnable postWriteRunnable) {
    final Runnable writeToDiskRunnable = new Runnable() {
            public void run() {
                synchronized (mWritingToDiskLock) {
                    writeToFile(mcr);
                }

                ...
            }
        };

    ...

    QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}

QueuedWork.singleThreadExecutor 实际上就是 ‘一个线程的线程池’, 如下:

// QueuedWork.java
public static ExecutorService singleThreadExecutor() {
    synchronized (QueuedWork.class) {
        if (sSingleThreadExecutor == null) {
            // TODO: can we give this single thread a thread name?
            sSingleThreadExecutor = Executors.newSingleThreadExecutor();
        }
        return sSingleThreadExecutor;
    }
}

回到 enqueueDiskWrite 中,这里还有一个重要的函数叫做 writeToFile:

writeToFile

// SharedPreferencesImpl.java
private void writeToFile(MemoryCommitResult mcr) {
    // Rename the current file so it may be used as a backup during the next read
    if (mFile.exists()) {
        if (!mBackupFile.exists()) {
            if (!mFile.renameTo(mBackupFile)) {
                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);
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
        ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
        try {
            final StructStat stat = Os.stat(mFile.getPath());
                mStatTimestamp = stat.st_mtime;
                mStatSize = stat.st_size;
        }
        // Writing was successful, delete the backup file if there is one.
        mBackupFile.delete();
        return;
    }

    // Clean up an unsuccessfully written file
    mFile.delete();
}

代码大致分为三个过程:

  1. 先把已存在的老的配置文件重命名(加 ‘.bak’ 后缀), 然后删除老的配置文件。这相当于做了灾备。
  2. mFile 中一次性写入所有配置项。即 mcr.mapToWriteToDisk(这就是 commitToMemory 所说的保存了所有配置项的字段) 一次性写入到磁盘。如果写入成功则删除灾备文件,同时记录了这次同步的时间。
  3. 如果上述过程 [2] 失败,则删除这个半成品的配置文件。

好了, 我们来总结一下 apply:

  1. 通过 commitToMemory 将修改的配置项同步回写到内存 SharedPreferences.mMap 中。此时,任何的 getXxx 都可以获取到最新数据了。
  2. 通过 enqueueDiskWrite 调用 writeToFile 将所有配置项一次性异步回写到磁盘, 这是一个单线程的线程池。

时序图:

apply 时序图

commit

看过了 apply 再看 commit 就非常容易了。

// SharedPreferencesImpl.java
public boolean commit() {
    MemoryCommitResult mcr = commitToMemory();
    SharedPreferencesImpl.this.enqueueDiskWrite(
        mcr, null /* sync write on this thread okay */);
    try {
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
        return false;
    }
    notifyListeners(mcr);
    return mcr.writeToDiskResult;
}

时序图:

commit 时序图

只需关注最后一条 ‘等待异步任务返回’ 的线,对比 apply 的时序图, 一眼就看出差别。

registerOnSharedPreferenceChangeListener

最后需要提一下的就是 listener:

  • 对于 apply,listener 回调时内存已经完成同步, 但是异步磁盘任务不保证是否完成。
  • 对于 commit, listener 回调时内存和磁盘都已经同步完毕。

申明:开始的图片来源网络,侵删

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容