Android SharedPreferences解析

基于Api29源码

SharedPreferences接口

首先,让我们看下SharedPreferences接口


SharedPreferences接口

其中有两个子接口 EditorOnSharedPreferenceChangeListener。我们发现SharedPreferences接口有很多getXXX系列的方法,通过这些方法可以获得我们存进去的key对应的value。其中子接口Editor有很多putXXX系列的方法,我们可以利用这些方法为指定的key设置对应的value。需要注意的是,调用一系列的putXXX方法后如果没有调用apply和commit是不会生效的。所以正常的使用规则通常类似 SharedPreferences对象.edit().putBoolean("xxx",false).putString("yyy").apply();

其中 commit 方法是有返回提交成功还是失败的,通常是同步调用(特殊情况下面有分析)。所以如果我们在主线程同时不需要知道操作是否成功的话是可以直接调用 apply 方法进行异步提交的。

对于子接口 OnSharedPreferenceChangeListener ,其作用就是在SharedPreferences的key被修改时进行回调,我们也可以看到SharedPreferences接口有registerOnSharedPreferenceChangeListener和unregisterOnSharedPreferenceChangeListener这两个方法进行回调的注册以及取消注册。

那我们怎么获得SharedPreferences对象呢?

  1. Context#getSharedPreferences
    我们这里简单分析下源码。
    Context的实现类是ContextImpl,所以我们去ContextImpl里面找getSharedPreferences函数。注意我贴的源码都会删掉一些不影响阅读的代码。
    public SharedPreferences getSharedPreferences(String name, int mode) {
        File file;
        synchronized (ContextImpl.class) {
            if (mSharedPrefsPaths == null) {
                mSharedPrefsPaths = new ArrayMap<>();
            }
            file = mSharedPrefsPaths.get(name);
            if (file == null) {
                file = getSharedPreferencesPath(name); //1
                mSharedPrefsPaths.put(name, file);
            }
        }
        return getSharedPreferences(file, mode);
    }

首先看入参,需要一个name来代表SharedPreferences对象对应硬盘上的文件的文件名。其次需要一个文件mode,mode有四种取值:MODE_PRIVATE,MODE_WORLD_READABLE,MODE_WORLD_WRITEABLE,MODE_MULTI_PROCESS。我们只要用第一种,后面三个都是用于多进程,且Android 7.0之后用会丢异常,后面会有分析。第一种可以简单理解为只有创建该文件的进程可以控制读写(其实如果多个进程有同一个userId是都可以处理该文件的)

我们能看到ContextImp用了一个ArrayMap对象mSharedPrefsPaths进行数据缓存。该Map对象的key为name,value为对应的硬盘File对象。

这里我们分析第一次使用,是没有缓存的,所以直接看注释1处的代码getSharedPreferencesPath

    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);  //1
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
        ...
        return sp;
    }

可以看到这里又有一个ArrayMap的缓存对象cache。这个Map对象的key为File对象,value为SharedPreferencesImpl对象。SharedPreferencesImpl就是SharedPreferences的实现类。

其中注释1处的checkMode方法会检查android版本是否大于等于7.0,如果是的话mode为MODE_WORLD_READABLE或MODE_WORLD_WRITEABLE会丢异常。

    private void checkMode(int mode) {
        if (getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.N) {
            if ((mode & MODE_WORLD_READABLE) != 0) {
                throw new SecurityException("MODE_WORLD_READABLE no longer supported");
            }
            if ((mode & MODE_WORLD_WRITEABLE) != 0) {
                throw new SecurityException("MODE_WORLD_WRITEABLE no longer supported");
            }
        }
    }

至此我们就知道了SharedPreferences的获取过程,下一章节会继续探究SharedPreferences使用过程中的源码。

  1. Activity#getPreferences
    public SharedPreferences getPreferences(int mode) {
        return getSharedPreferences(getLocalClassName(), mode);
    }

Activity继承于Context的,内部也是调用了上面的getSharedPreferences方法,只不过文件名是Activity的名称。

SharedPreferences源码解析

创建过程

首先看下构造函数

    SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file); //File对象,从名字看得出这个是备份文件
        mMode = mode;
        mLoaded = false; //Boolean对象 判断是否已经从硬盘文件加载数据到内存
        mMap = null; //Map<String, Object>对象 硬盘文件存的key-value会放到该内存对象中
        startLoadFromDisk();
    }

我们直接看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
                return;
            }
            if (mBackupFile.exists()) {
                mFile.delete();
                mBackupFile.renameTo(mFile);
            }
        }

        Map<String, Object> map = null;
        if (mFile.canRead()) {
            BufferedInputStream str  = new BufferedInputStream(
                new FileInputStream(mFile), 16 * 1024);
            map = (Map<String, Object>) XmlUtils.readMapXml(str);
        }
        mLoaded = true; //将标记位设置true表示已经加载过硬盘数据到内存
        if (map != null) {
            mMap = map;
        } else {
            mMap = new HashMap<>();
        }
        //如果在loadFromDisk未执行完的时候调用putXXX、getXXX系列方法
        //会执行mLock.wait()进行等待,所以执行完后将那些wait的线程唤醒。
        mLock.notifyAll();
    }

上面的代码就是读取本地文件,当一个xml来解析获得一个map对象,然后赋值给成员变量mMap。

获取数据过程

Ok,知道了创建过程,让我们看看获取value的过程。
一些列的getXXX方法我们只需要分析一个就好。
这里我们开始分析getInt方法。

    public int getInt(String key, int defValue) {
        synchronized (mLock) {
            awaitLoadedLocked(); //等待loadFromDisk执行完
            Integer v = (Integer)mMap.get(key);
            return v != null ? v : defValue;
        }
    }

其中awaitLoadedLocked会等待到子线程执行完loadFromDisk。

    private void awaitLoadedLocked() {
        while (!mLoaded) {
            mLock.wait();
        }
    }

获取数据的过程很简单,就是通过内存对象mMap进行操作,既然是通过内存对象进行操作,那这里就有需要注意的一点,我们不应该对mMap取出来的对象进行修改,这样的话其他线程再次从mMap中获取数据的话,取出来的就是我们修改过后的数据!

设置数据过程

设置数据稍微麻烦一点,必须要先调用edit方法获得Editor对象。

    public Editor edit() {
        synchronized (mLock) {
            awaitLoadedLocked();
        }
        return new EditorImpl();
    }

EditorImpl是Editor接口的实现类,我们看下其大概结构。

    public final class EditorImpl implements Editor {
        private final Object mEditorLock = new Object();//同步锁
        //所有的putXXX系列方法调用都会先用该对象存储
        private final Map<String, Object> mModified = new HashMap<>();
        private boolean mClear = false;//清除标记
    }

putXXX系列方法也基本一样,所以这里我们分析下putStringSet就好了。

    public Editor putStringSet(String key, Set<String> values) {
        synchronized (mEditorLock) {
            mModified.put(key,
                    (values == null) ? null : new HashSet<String>(values));
            return this;
        }
    }

可以看到我们所有的putXXX系列方法中都将要改变的key和value存到了mModified对象中,其会在我们调用commit和apply方法时统一作用到mMap内存对象再提交到硬盘上。

提交数据过程

接下来我们分析下commit和apply方法

    public boolean commit() {
        MemoryCommitResult mcr = commitToMemory();
        SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null);
        mcr.writtenToDiskLatch.await();
        notifyListeners(mcr);
        return mcr.writeToDiskResult;
    }

    public void apply() {
        final MemoryCommitResult mcr = commitToMemory();
        final Runnable awaitCommit = () -> mcr.writtenToDiskLatch.await();
        QueuedWork.addFinisher(awaitCommit);
        Runnable postWriteRunnable = () -> {
            awaitCommit.run();
            QueuedWork.removeFinisher(awaitCommit);
        };
        SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
        notifyListeners(mcr);
    }

我们分析下两个方法的区别,其都调用了commitToMemory方法、enqueueDiskWrite方法、notifyListeners方法。

notifyListeners很简单,就是通知OnSharedPreferenceChangeListener进行回调。

commitToMemory主要就是将mModified的数据依次应用到mMap上。需要注意的是其会在一开始判断你是否用Editor对象调用过clear方法,如果调用过的话他会先将mMap数据清空,再依次将mModified的数据依次应用到mMap上。比如执行如下代码后SharedPreferences.editor().putString("xx","1").clear().commit() ,mMap里就只有一个为xx的key啦。

Ok,重点就是enqueueDiskWrite方法。

    private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        //如果是commit方法,postWriteRunnable为null
        final boolean isFromSyncCommit = (postWriteRunnable == null);

        final Runnable writeToDiskRunnable = new Runnable() {
                public void run() {
                    synchronized (mWritingToDiskLock) {
                        //将数据写到文件
                        writeToFile(mcr, isFromSyncCommit);
                    }
                    synchronized (mLock) {
                        mDiskWritesInFlight--;
                    }
                    if (postWriteRunnable != null) {
                        postWriteRunnable.run();
                    }
                }
            };

        if (isFromSyncCommit) {
            //只有commit方式才会走到这
            boolean wasEmpty = false;
            synchronized (mLock) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                writeToDiskRunnable.run();
                return;
            }
        }

        QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    }

我们先分析apply方式,他会调用QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
我们直接看queue方法。

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

如果是apply形式的话,shouldDely为true,其中getHandler获得的Handler对象是通过一个HandlerThread中的Looper创建的,简单说这个Handler对象是负责将消息发送到子线程的。DELAY是100ms,所以通过apply方法是会将上面的writeToDiskRunnable对象放到子线程延迟100ms执行。换句话说是异步的。

最后我们看看commit方式,commit方法稍微复杂点,其涉及到mDiskWritesInFlight变量。这个变量在commitToMemory时会+1,writeToDiskRunnable中执行完writeToFile后会-1,所以我们就把他理解为要写到硬盘的次数就行!
我多举几个例子。
例子1:我调用commit时,会走到commitToMemory,其 mDiskWritesInFlight+1=1表示需要一次写到硬盘,然后执行enqueueDiskWrite方法,在这里面由于我是commit形式调用,其mDiskWritesInFlight又等于1,wasEmpty标记位为true,所以就直接同步执行writeToDiskRunnable.run();
例子2:我调用commit时(线程1),会走到commitToMemory,其 mDiskWritesInFlight+1=1表示需要一次写到硬盘,这个时候我在另一个线程(线程2)又创建另一个editor对象并且调用commit,线程2走到commitToMemory,其其 mDiskWritesInFlight+1=2!,这个时候尽管是commit方式,mDiskWritesInFlight不等于1,所以wasEmpty为false,所以会走到QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);。我们知道queue方法是将writeToDiskRunnable放到子线程执行的!所以,commit形式并不一定是同步执行的!

后记

关于mBackupFile

还记得mBackupFile对象么。这个其实就是防止将内存数据写到硬盘数据失败的一种回退机制。
mBackupFile对象主要用在writeToFile方法中,我简单截取相关代码。

    private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
        //在这个方法中我们主要是通过mFile获取一个输出流用来将内存数据写入硬盘,所以会先将之前的数据备份
        boolean backupFileExists = mBackupFile.exists();
        if (!backupFileExists) {
            //如果不存在mBackupFile,则将mFile重命名为mBackupFile进行备份
            mFile.renameTo(mBackupFile)
        } else {
            //如果存在mBackupFile,即已经有备份文件了,直接删掉mFile即可
            mFile.delete();
        }

        
        FileOutputStream str = createFileOutputStream(mFile);
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);

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