在多进程中使用 SharedPreference

之前都是使用 SharedPreference 来做一些基本的保存工作,因为都是在同一进程下使用,所以也没有遇到过什么问题,这次偶然间需要在多进程下使用,结果发现在读取时会存在读取不到的问题,因此去看看了源码,找到了问题原因和解决方式,也对 SharedPreference 有了更深的理解,特此记录一下~

获取 SharedPreference

通常我们都是通过 Context.getSharedPreferences() 来获取 SharedPreference 对象,这个 Context 无论是 Application、 Service 或是 Activity,都是继承自 ContextWrapper,通过查看 ContextWrapper 源码可以看发现内部都是调用了 mBase 的相关方法,而这个 mBase 就是 ContextImpl。getSharedPreferences() 在 ContextImpl 有两个重载方法

public SharedPreferences getSharedPreferences(String name, int mode) {
        ...
        synchronized (ContextImpl.class) {
            if (mSharedPrefsPaths == null) {
                mSharedPrefsPaths = new ArrayMap<>();
            }
            file = mSharedPrefsPaths.get(name);
            if (file == null) {
                file = getSharedPreferencesPath(name);
                mSharedPrefsPaths.put(name, file);
            }
        }
        return getSharedPreferences(file, mode);
    }

这个是我们常用的,其中 name 为文件名称,也就是生成后保存在 data 目录下的 xml 文件名称,mode 为操作模式,通常我们传入的都是 Context.MODE_PRIVATE,这里只是去获取 file 文件,然后调用 getSharedPreferences(file, mode) 方法

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) {
                ...
                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) {
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }

可以看到,在获取 SharedPreference 时,系统会其做一个缓存,因此不会每次都去 新建一个出来,减少了不必要的开销,这里要特别注意最后一段,当 mode 为Context.MODE_MULTI_PROCESS 或是 Android 版本低于 Android 3.0时,会去执行 startReloadIfChangedUnexpectedly() 方法,这个地方就是在多进程下可以使用的原因,后续再说。

get

调用 SharedPreference 的各种 get 方法,其实是从内存中去拿数据,这里以 getString() 为例

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

其中 awaitLoadedLocked() 的作用保证数据已经从文件中加载到内存中,mLoadedloadFromDisk() 中加载完成后,即 Map 被赋值后被置为 true

private void awaitLoadedLocked() {
         ....
        while (!mLoaded) {
            try {
                mLock.wait();
            } catch (InterruptedException unused) {
            }
        }
}

commit 和 apply

在获取到 SharedPreference 后,要想保存数据,必须要调用 edit() 方法来获取一个 Editor 对象,Editor 是 SharedPreference 内的一个接口,提供了所有的 put、提交以及清除的方法,它的实现是 EditorImpl。
commit() 方法带有一个布尔的返回值,用来返回是否成功将提交写入文件中,而且是同步写入,因此如果要写入的数据过大,会造成线程阻塞,apply() 方法没有返回值,用异步方式写入,也是比较推荐的一种用法。

 public boolean commit() {
        MemoryCommitResult mcr = commitToMemory();
        SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);
        try {
            //使用 CountDownLatch 来做等待操作
            mcr.writtenToDiskLatch.await();
        } catch (InterruptedException e) {
            return false;
        }
        notifyListeners(mcr);
        return mcr.writeToDiskResult;
}
public void apply() {
        final MemoryCommitResult mcr = commitToMemory();
        ....
        SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
        notifyListeners(mcr);
}

可以看到两个方法中都是先通过调用 commitToMemory() 获取到了一个 MemoryCommitResult 对象,commitToMemory() 主要是将 Editor 中的更改添加到 SharedPreference 的缓存 Map 中去,如果调用了 Clear(),则会去对 Map 做清空操作,接着会遍历更改写入到 Map 中,最后返回一个 MemoryCommitResult 对象

 return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,mapToWriteToDisk);

其中 memoryStateGeneration 是一个长整型,用来记录当前内存的状态,会在每次修改后加一,keysModified 是所有更改的 key 值,listeners 是通过registerOnSharedPreferenceChangeListener() 注册的 Listener 集合,mapToWriteToDisk是修改后需要写入磁盘的 Map。在获取到 MemoryCommitResult 后,会将其传入 SharedPreference 的 enqueueDiskWrite() 方法中

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();
                    }
                }
            };
        if (isFromSyncCommit) {
            boolean wasEmpty = false;
            synchronized (mLock) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                writeToDiskRunnable.run();
                return;
            }
        }
        QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    }

这里要注意的是 mDiskWritesInFlight 是在 commitToMemory() 做加一操作,因此,如果传入的 postWriteRunnable 为空,则 wasEmpty 肯定为true,因此 commit() 方法会同步写入,否则会将 postWriteRunnable 传入到 QueuedWork.queue() 中去

LinkedList<Runnable> sWork = new LinkedList<>();
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);
            }
        }
    }

queue () 中会通过 getHandler 来获取到一个 Handler,然后通过这个Handler发送一条消息,其实这里就是 apply() 是异步写入的关键,通过查看 getHandler() 代码,发现里面就是通过 HandlerThread 来获取这个 Handler,因此也完成了线程的切换

 private static Handler getHandler() {
        synchronized (sLock) {
            if (sHandler == null) {
                HandlerThread handlerThread = new HandlerThread("queued-work-looper",
                        Process.THREAD_PRIORITY_FOREGROUND);
                handlerThread.start();
                sHandler = new QueuedWorkHandler(handlerThread.getLooper());
            }
            return sHandler;
        }
    }

Mode

  • Context.MODE_PRIVATE 表明只能被当前应用读写或是分享同一 user ID 的所有应用读写
  • Context.MODE_MULTI_PROCESS 已被标记为弃用,随时可能会被移除,如果在多进程下官方推荐使用 ContentProvider 来进行数据共享

多进程中使用

首先来分析下为什么会出现获取数据为空的情况,之前在看 Context.getSharedPreferences() 时,可以看到 Context 会对 SharedPreference 做一个缓存,即只会在第一次获取时才会新创建对象,因此,对应的 SharedPreference 构造函数中的 startLoadFromDisk() 也只有在第一次才会调用,那么,问题就来了,当你在主进程中添加或修改了数据,而在进程2中已经获取过对应的SharedPreference,这时在进程2中去调用 get 方法,因为进程2内存中保存的 Map 中数据并未更改,所以返回空数据或旧数据。
而当我们把 mode 改为 Context.MODE_MULTI_PROCESS 时为什么就可以获取到正确的数据了呢?主要原因就在 getSharedPreferences() 中,当 mode Context.MODE_MULTI_PROCESS 时,会调用下面这个方法来重新从文件中读取数据

 void startReloadIfChangedUnexpectedly() {
        synchronized (mLock) {
            if (!hasFileChangedUnexpectedly()) {
                return;
            }
            startLoadFromDisk();
        }
}

//还有一处调用是在 SharedPreferencesImpl 的构造函数中
private void startLoadFromDisk() {
        synchronized (mLock) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                loadFromDisk();
            }
        }.start();
}

因此,这里主要是强制在每次调用 getSharedPreferences() 时都去从文件中重新加载一边,保证此时内存中的数据是最新的。

总结

在源码中可以发现官方已经将 Context.MODE_MULTI_PROCESS 标记为弃用,而且极力推荐使用 ContentProvider 来进行进程间的数据共享,因此使用这个 mode 来在进程下使用 SharedPreference 是不安全的,但有时我们只是需要存储一些简单的数据,用 ContentProvider 好像又有点过于繁琐了,所以我觉得,也需要视情况而定,如果你能保证自己的数据量不大,且使用不是很频繁,那么使用这个 mode 也不失为一个办法。另外,在多进程下想安全的像使用 SharedPreference 来保存和读取数据,不妨试试腾讯开源的 MMKV

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

推荐阅读更多精彩内容