其实我只是想看看SharedPreferences是如何实现的

那天我突然看到有人说使用SharedPreferences会出现ANR,要知道ANR可是个大问题啊,于是我就想看看SharedPreferences是如何实现的,然后记录于此,还请各位指点!

我们都知道,我们是通过Context来获取SharedPreferences的,于是,我们就在Context中看到这么一个抽象方法,比较Context本身就是个抽象类嘛!

public abstract SharedPreferences getSharedPreferences(String name, @PreferencesMode int mode);

然后,很显然,我们得找到Context的具体实现类,才能知道getSharedPreferences这个方法里面的逻辑,我们很快就注意到ContextWrapper,而且我们的Activity和Application都继承于ContextWrapper,然后我们就找到这样的一个实现方法,如下:

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        return mBase.getSharedPreferences(name, mode);
    }

其中mBase是申明的一个Context成员变量,而我们想看getSharedPreferences的具体实现,找到mBase就是关键,ContextWrapper中相关代码如下:

    Context mBase;
    public ContextWrapper(Context base) {
        mBase = base;
    }
    protected void attachBaseContext(Context base) {
        if (mBase != null) {
            throw new IllegalStateException("Base context already set");
        }
        mBase = base;
    }

然后我们注意到mBase有两种初始化方法,其中attachBaseContext值得我们注意,毕竟我们记得在Activity的源码中就有attachBaseContext方法,如下:

    @Override
    protected void attachBaseContext(Context newBase) {
        super.attachBaseContext(newBase);
        if (newBase != null) {
            newBase.setAutofillClient(this);
        }
    }

其中 super.attachBaseContext(newBase)这个调用的就是ContextWrapper中的attachBaseContext方法。同样的,Application中的attach方法中也有调用到该方法。
我们继续查看,发现Activity的attachBaseContext是由attach方法调用的,我们来看看这个方法:

 final void attach(Context context, ActivityThread aThread,
            Instrumentation instr, IBinder token, int ident,
            Application application, Intent intent, ActivityInfo info,
            CharSequence title, Activity parent, String id,
            NonConfigurationInstances lastNonConfigurationInstances,
            Configuration config, String referrer, IVoiceInteractor voiceInteractor,
            Window window, ActivityConfigCallback activityConfigCallback) {

        attachBaseContext(context);
        ......
}

我们很自然地注意到第二个参数ActivityThread,毕竟总所周知,ActivityThread才是应用真正的入口,所以,我们的关注点应该转到ActivityThread上来,我们来看看其入口的代码:

public static void main(String[] args) {
        .......
        Looper.prepareMainLooper();
        .......
        ActivityThread thread = new ActivityThread();
        thread.attach(false, startSeq);

        if (sMainThreadHandler == null) {
            sMainThreadHandler = thread.getHandler();
        }

        if (false) {
            Looper.myLooper().setMessageLogging(new
                    LogPrinter(Log.DEBUG, "ActivityThread"));
        }

        // End of event ActivityThreadMain.
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
        Looper.loop();

        throw new RuntimeException("Main thread loop unexpectedly exited");
    }

我们摘取了关键代码,很显然,这就是android的消息分发机制,其中我们用的是主线程的Looper,所以其Handler也默认绑定到主线程中,于是我们的四大组件默认都运行在主线程中的,这一点必须明确!
另外,注意到最后抛出的异常没,说明Looper死循环必须贯穿APP整个生命周期,除非APP退出了,否则必须一直进行Looper死循环,否则就会抛出异常。
好了,上面这些属于顺带一提的,说这个入口主要是想说下面这两行代码:

        ActivityThread thread = new ActivityThread();
        thread.attach(false, startSeq);

这里会进行什么操作呢?attach传false,说明不是系统应用,会直接绑定到AMS(ActivityManagerService),如下attach中的关键代码:

           final IActivityManager mgr = ActivityManager.getService();
            try {
                mgr.attachApplication(mAppThread, startSeq);
            } catch (RemoteException ex) {
                throw ex.rethrowFromSystemServer();
            }

正如大家所知道的,其实ActivityThread拥有与AMS交互并且管理Activity和Service等组件的重要作用。
至于ActivityThread与AMS间的交互,其实都是进行着一个个IPC交互,你看到上面代码中的RemoteException没?在这里我们就不对IPC调用作过多的说明和深究了,我们只需知道一点:其实Activity的每个生命周期调用,其实是由ActivityThread与AMS进行IPC交互来完成的。

我们明白了上述过程后,发现了ActivityThread中有很多handle开头的方法,如下图所示:

消息分发机制调用的方法

没错,这些就是IPC后通过消息分发机制调用的方法,我们重点关注的是Activity的启动,如上图红圈中的方法:

    /**
     * Extended implementation of activity launch. Used when server requests a launch or relaunch.
     */
    @Override
    public Activity handleLaunchActivity(ActivityClientRecord r,
            PendingTransactionActions pendingActions, Intent customIntent) {
        .......
        final Activity a = performLaunchActivity(r, customIntent);
        .......
    }

handleLaunchActivity中我们注意到performLaunchActivity这个方法,关键代码如下:

    /**  Core implementation of activity launch. */
    private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
              .......
              ContextImpl appContext = createBaseContextForActivity(r);
              .......
              activity.attach(appContext, this, getInstrumentation(), r.token,
                        r.ident, app, r.intent, r.activityInfo, title, r.parent,
                        r.embeddedID, r.lastNonConfigurationInstances, config,
                        r.referrer, r.voiceInteractor, window, r.configCallback);
              .......
    }

终于到这里来了,看到没?在performLaunchActivity方法里,我们创建了ContextImpl,然后将其传给了activity,于是,我们苦苦追寻的实现类就是ContextImpl,当然,光凭名字我就知道它是Context的真正实现类了,只是,我们还是必须要这样有凭有据、明明白白滴追踪一下代码!

当然,我们回顾一下上面ContextWrapper、ContextImpl和Context三者的关系,从设计模式的角度上来说,这里应用了代理模式
其中,Context为抽象主题类,ContextImpl为真实主题类,ContextWrapper为代理类,ContextImpl和ContextWrapper都继承于Context,然后ContextWrapper持有ContextImpl的引用,完成了对ContextImpl的代理操作。

接下来我们重点就放在ContextImpl来了,我们可以看到getSharedPreferences的真正实现,如下:

 @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        // At least one application in the world actually passes in a null
        // name.  This happened to work because when we generated the file name
        // we would stringify it to "null.xml".  Nice.
        if (mPackageInfo.getApplicationInfo().targetSdkVersion <
                Build.VERSION_CODES.KITKAT) {
            if (name == null) {
                name = "null";
            }
        }

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

我们可以看到,最终会调用的是getSharedPreferences(File file, int mode),注意到其中创建文件的方法:

   @Override
    public File getSharedPreferencesPath(String name) {
        return makeFilename(getPreferencesDir(), name + ".xml");
    }

   private File getPreferencesDir() {
        synchronized (mSync) {
            if (mPreferencesDir == null) {
                mPreferencesDir = new File(getDataDir(), "shared_prefs");
            }
            return ensurePrivateDirExists(mPreferencesDir);
        }
    }

   private File makeFilename(File base, String name) {
        if (name.indexOf(File.separatorChar) < 0) {
            return new File(base, name);
        }
        throw new IllegalArgumentException(
                "File " + name + " contains a path separator");
    }

从上面这3个方法,我们可以知道:
1)SharedPreferences其实存储为一个xml文件
2)SharedPreferences存储的位置在这个目录下:/data/data/你APP包名/shared_prefs/
3)创建SharedPreferences的名字中不能含有“/”这样的分隔符,否则会报错

接着,我们来看看最终创建的方法:getSharedPreferences(File file, int mode):

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

代码也很简单,我们最终返回的是SharedPreferencesImpl对象,它是SharedPreferences的实现类,然后通过一个ArrayMap进行了缓存,一个文件对应一个SharedPreferencesImpl,然后再追其根源,其实就是一个文件名对应一个SharedPreferencesImpl,为什么这么说呢?因为我们无法直接调用这个方法:getSharedPreferences(File file, int mode),源码也以注释说明清楚了,不信你可以去试试看O(∩_∩)O

我们回到方法的最后,意思就是当mode为Context.MODE_MULTI_PROCESS时,会执行:sp.startReloadIfChangedUnexpectedly();
这个就是说当在多进程时,每次获取SharedPreferences都会尝试去重新加载数据,以防数据发生变化而不一致。这就是SharedPreferences在多进程中保证数据正确性的方法,当然,Context.MODE_MULTI_PROCESS这个已经是被废弃掉了,谷歌推荐使用ContentProivder来完成多进程文件的共享,而不是SharedPreferences,至于原因,后面加以说明。

接下来我们看看SharedPreferencesImpl的构造函数:

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

这里嘛,注意到makeBackupFile,这个是搞了一个备份文件“.bak”,以防在保存数据过程中存现中断,下次进来时可以通过备份文件进行恢复,算是一种保险措施吧,当然,它也仅仅只能恢复保存进“.bak”文件中的数据而已。
我们重点要看的是最后一个方法:

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

原来,这里直接创建一个线程,将SharedPreferences文件中的数据直接加载到内存中去,这里需要说明两点:
1)不用使用SharedPreferences存储大量的数据,不然你想啊,那么大的数据直接load进内存,简直了···
2)这里也解释了上面说到的不建议使用Context.MODE_MULTI_PROCESS的原因:因为上面的startReloadIfChangedUnexpectedly会调用到startLoadFromDisk这个方法,这样一来,在多进程环境中,很多时候获取SharedPreferences会多次load数据进内存,这浪费了内存的缓存作用,同时读写IO也会影响性能。

上面说到的,将SharedPreferences文件中的数据load进内存,根据源码会保存为一个 Map<String, Object>键值对对象,然后之后的所有读操作都从这个Map对象中读取,这也解释了为什么SharedPreferences在第一次读会较慢,而后面就很快了?那是因为第一次读时需要花时间将数据load进内存,之后都从Map读就很快了。

说完读,我们接着说写,说写就离不开要说commit 和 apply。
我们都知道一般建议使用apply,就算你用了commit ,AndroidStudio也会给出这样的提醒:

Consider using apply() instead of commit on shared preferences. Whereas commit blocks and writes its data to persistent storage immediately, apply will handle it in the background.

当然,如果你使用commit然后用变量接收commit 的结果的话,就没有上面的提醒,这也说明了commit 和 apply的第一个不同:commit有一个boolean返回值,而apply没有。

我们直接贴出commit 和 apply两方法的关键代码:

        @Override
        public boolean commit() {
             ......
            MemoryCommitResult mcr = commitToMemory();
            SharedPreferencesImpl.this.enqueueDiskWrite(
                mcr, null /* sync write on this thread okay */);
             ......
            return mcr.writeToDiskResult;
        }
        @Override
        public void apply() {
             ......
            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                    @Override
                    public void run() {
                        try {
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }

                        if (DEBUG && mcr.wasWritten) {
                            Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                                    + " applied after " + (System.currentTimeMillis() - startTime)
                                    + " ms");
                        }
                    }
                };

            QueuedWork.addFinisher(awaitCommit);

            Runnable postWriteRunnable = new Runnable() {
                    @Override
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.removeFinisher(awaitCommit);
                    }
                };

            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
           ......
        }

很明显,
1)commit 和 apply都调用了commitToMemory,该方法从名字就知道提交到内存,也就说两个方法都先更新了内存Map的数据。
2)commit 和 apply两个方法也都调用了enqueueDiskWrite,该方法从名字也能知道就是保存到本地磁盘的,而主要区别在于第二个参数,commit中传的是null,而且源码还给出了注释:sync write on this thread okay,意思就是在当前线程同步写进磁盘;而apply则传了一个Runnable对象,然后使用了QueuedWork.queue方法加入了任务,很显然它属于一个异步操作。
于是,这里就有了commit 和 apply的第二个不同:保存到本地磁盘,commit是同步、阻塞的,apply是异步、非阻塞的。

上面说到了QueuedWork.queue,这里插一下QueuedWork相关的东西,它属于android的一个内部工具类,用于跟踪那些未完成的或尚未结束的全局任务,我们也同样来看一下其相关源码:

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

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

很明显了,内部使用了HandlerThread,然后用Handler进行消息的分发,再次证明了apply是异步、非阻塞的。
当然,我们上面apply方法中有这么一行代码:

QueuedWork.addFinisher(awaitCommit);

这里再说明一下,QueuedWork有waitToFinish方法来保证addFinisher中的Runnable得以执行,那么,让我们来看看waitToFinish都在哪里被调用的:

waitToFinish调用之处

原来绕了一大圈,我们又回到ActivityThread中来了,比如handlePauseActivity,意思就是在activity暂停时就会调用waitToFinish,该方法的目的就是确保之前提交的异步任务能被执行完毕,而由于在ActivityThread中调用,也就是在主线程中,所以,如果使用apply方法而出现ANR的话,一般就是出现在调用waitToFinish这个过程。

好啦,我们总算可以回到最开始说的问题了:使用SharedPreferences会出现ANR,经过上面一系列分析,我们得出了以下出现ANR的情况:
1)首次getXXX,如果你的SharedPreferences中存储了大量数据,那么在首次获取数据时,会将文件中的数据load进内存,我们得在主线程中等其load完毕后才能get,如果load时间很长就有可能造成ANR
2)commit,这个就很容易理解了,在主线程中进行保存到本地磁盘的操作,该操作有可能出现ANR
3)apply,就如上面所分析的,在调用waitToFinish时有可能出现ANR

说了这么多,我们是不是得写一个ANR出来啊?没问题,请看如下代码:

 SharedPreferences sp= getSharedPreferences("mysp", 0);
 SharedPreferences.Editor editorA = sp.edit();
 for(int i=0;i<300000;i++){
        editorA.putString("A" + i, "a" + i);
 }
 editorA.apply();
 SharedPreferences.Editor editorB = sp.edit();
 for(int i=0;i<300000;i++){
       editorB.putString("B"+i,"b"+i);
 }
 editorB.commit();

简单粗暴,这样就是一个因commit造成的ANR,同时,我们接着看看读取数据的情况,因为上面的数据量较大了,所以笔者在首次读取时发现直接黑屏了好几秒,倒是还没出现ANR。

最后,我们针对上面3种出现ANR的情况给出如下的建议
1)请别往SharedPreferences中存入大量的数据,数据量大时请考虑使用本地数据库
2)如果担心commit在主线程保存数据会导致ANR,其实有一种做法就是直接新建一个子线程来执行,可以考虑用一个单线程池来进行封装
3)apply的话可以考虑使用清理等待锁,据头条app开发团队的测试验证,效果还是很OK的,有空笔者再进行上手验证,暂且收录于此。

参考链接:
https://blog.csdn.net/shifuhetudi/article/details/52089562
https://www.jianshu.com/p/875d13458538

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

推荐阅读更多精彩内容