1

SharedPreferences灵魂拷问之原理

 2 years ago
source link: http://www.androidchina.net/10207.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

SharedPreferences灵魂拷问之原理 – Android开发中文站

你的位置:Android开发中文站 > 热点资讯 > SharedPreferences灵魂拷问之原理

先来一波灵魂追问:

  • 听说提交要用apply(),为什么?
  • 和commit()什么区别?
  • 跨进程怎么操作?
  • 会堵塞主线程吗?
  • 很着急有替代方案吗?

1、加载/初始化

image

一切从getSharedPreference(String name,int Mode)这个方法说起;通过这个方法获取到一个SharedPreference实例。SharedPreferences是一个接口(interface),他的具体实现类为SharedPreferencesImpl。
SharedPreference的加载的主要过程:

  • 找到对应name的文件。
  • 加载对应文件到内存中SharedPreference。
  • 一个xml文件对应一个ShredPreferences单例。
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
private ArrayMap<String, File> mSharedPrefsPaths;

sSharedPrefsCache存储的是File和SharedPreferencesImpl键值对,当对应File的SharedPreferencesImpl加载之后就会一支存储于sSharedPrefsCache中。类似的mSharedPrefsPaths存储的是name和File的对应关系。使用的ArrayMap,关于ArrayMap这种Android特有的数据结构,详细了解可以看这https://juejin.im/post/5d550f1d51882515fd6be09a

当通过name最终找到对应的File之后,就会实例化一个SharedPreferencesImpl对象。在SharedPreferences构造方法中开启一个子线程加载磁盘中的xml文件。

大家都应该很明确的一点是,SP持久化的本质是在本地磁盘记录了一个xml文件,这个文件所在的文件夹shared_prefs

image

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

怎么保证使用sp.get(String name)的时候SP的初始化或者说从磁盘中加载到内存中这一过程已经完成了呢?

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

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

使用awaitLoadedLocked()方法检测,是否已经加载完成,如果没有加载完成,就等待堵塞。等加载完成之后,继续执行;
在loadFromDisk()方法中,如果加载成功会把mLoaded标志位置为true,然后 mLock.notifyAll();
最终,就把位于磁盘中的文件,加载到了内存中对应一个SharedPreferces对象,SharedPreferences中mMap。

2、编辑提交

当想SP中存入数据的时候,实例代码如下。

sharedPreferences.edit().putInt("number", 100).puString("age","18").apply();
sharedPreferences.edit().putInt("number", 100).commit();

调用sharedPreferences.edit()返回一个EditorImpl对象,操作数据之后调用apply()或者commit()。

2.1、 commit()流程
   @Override
    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;
        } finally {}
        notifyListeners(mcr);//通知监听
        return mcr.writeToDiskResult;
      }

     //
    private void enqueueDiskWrite(final MemoryCommitResult mcr,final Runnable postWriteRunnable) {
        //如果postWriteRunnable为空表示来自commit()方法调用
        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();
                    }
                }
            };
            //当commit提交,且mDiskWritesInFlight为1的时候,直接在当前所在线程执行写入磁盘操作
        if (isFromSyncCommit) {
            boolean wasEmpty = false;
            synchronized (mLock) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                writeToDiskRunnable.run();
                return;
            }
        }

        //交个QueuedWork,QueuedWork内部维护了一个HandlerThread,一直执行写入磁盘操作。
        QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    }

image

如注释:当调用commit()方法之后

  • 首先将编辑的结果同步到内存中。
  • enqueueDiskWrite()将这个结果同步到磁盘中,enqueueDiskWrite()的第二个参数postWriteRunnable传入空。通常情况下也就是mDiskWritesInFlight(正在执行的写入磁盘操作的数量)为1的时候,直接在当前所在线程执行写入磁盘操作。否则还是异步到QueuedWork中去执行。commit()时,写入磁盘操作会发生在当前线程的说法是不准确的。
  • 执行mcr.writtenToDiskLatch.await(); MemoryCommitResult 中有个一个CountDownLatch 成员变量,他的具体作用可以查阅其他资料。总的来说,当前线程执行会堵塞在这,直到mcr.writtenToDiskLatch满足了条件。也就是当写入磁盘成功之后,会继续执行下面的操作。
  • 所以,commit提交之后会有返回结果,同步堵塞直到有返回结果。
2.2、 apply()流程
   @Override
        public void apply() {
            final long startTime = System.currentTimeMillis();
            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                    @Override
                    public void run() {mcr.writtenToDiskLatch.await();}
                   };
            QueuedWork.addFinisher(awaitCommit);
            Runnable postWriteRunnable = new Runnable() {
                    @Override
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.removeFinisher(awaitCommit);
                    }
                };
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
            notifyListeners(mcr);
        }
  • 加入到QueuedWork中,是一个单线程的操作。
  • 没有返回结果。
  • 默认会有100ms的延迟
2.3 、QueuedWork
2.3.1、 关于延迟磁盘写入。
    /** Delay for delayed runnables, as big as possible but low enough to be barely perceivable */
    private static final long DELAY = 100;
    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()方式提交的时候,默认消息会延迟发送100毫秒,避免频繁的磁盘写入操作。

当commit()方式,调用QueuedWork的queue()时,会立即向handler()发送Message。

2.3.2、主线程堵塞ANR

You don’t need to worry about Android component lifecycles and their interaction with apply() writing to disk. The framework makes sure in-flight disk writes from apply() complete before switching states.

官方文档中有这样段化话,意思是您不需要担心Android组件生命周期及其对apply()写入磁盘的影响。框层架确保在切换状态之前完成使用apply()方法正在执行磁盘写入的动作。
然而还真是不让人那么省心。

罪魁祸首在这:

//QueuedWork.java
    public static void waitToFinish() {
        ...
          processPendingWork();//执行文件写入磁盘操作
        ....
    }
    private static void processPendingWork() {
        long startTime = 0;
      ....
     if (work.size() > 0) {
         for (Runnable w : work) {
             w.run();
         }
      ...  
    }

waitToFinish()会将,储存在QueuedWork的操作一并处理掉。什么时候呢?在Activiy的 onPause()、BroadcastReceiver的onReceive()以及Service的onStartCommand()方法之前都会调用waitToFinish()。大家知道这些方法都是执行在主线程中,一旦waitToFinish()执行超时,就会跑出ANR。

至于waitToFinish调用具体时机,查看ActivityThread.java类文件。这里只是说本质原理。

3、跨进程操作的解决方案

\\ContextImpl
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");
            }
        }
    }

Andorid 7.0及以上会抛出异常,Sharepreferences不再支持多进程模式。多进程共享文件会出现问题的本质在于,因为不同进程,所以线程同步会失效。要解决这个问题,可尝试跨进程解决方案,如ContentProvider、AIDL、AIDL、Service。

4、替代方案

  • 有问题,主线程堵塞。
  • 一不留神容易产生ANR。

既然SharedPreferences有这么多问题?就没人管管吗?
温和的治理方法或者说小建议

4.1、 温和改良派
  • 低频 尽量保证多次edit一个apply,原因上文讲过,尽量维持低频的写入。
  • 异步 能用apply()方法提交的就用apply()方法提交,原因这个方法是异步的,有延迟的(100s)
  • 小量 尽量维持Sharepreferences的体量小些,方便磁盘快速写入。
  • 合规 如果村JSON数据,就不要使用Sharepreferences了,因为SharedPerences本质是xml文件格式存储的,要存储JSON文件需要转义效率很低。不如直接自己编写代码文件读写在App私有目录中存储。
4.2、 激进铲除派
  • 腾讯微信团队的MMKV采用内存映射的方式,解决SharedPreferences的各种问题。
  • 原理基于内存映射mmap,具体使用 原理 源码 https://github.com/Tencent/MMKV

通过本文我们了解了SharedPreferences的基本原理。再回头看看文章开头的那几个问题,是不是有答案了。

  • commit()方法和apply()方法的区别:commit()方法是同步的有返回结果,同步保证使用Countdownlatch,即使同步但不保证往磁盘的写入是发生在当前线程的。apply()方法是异步的具体发生在QueuedWork中,里面维护了一个单线程去执行磁盘写入操作。
  • commit()和apply()方法其实都是Block主线程。commit()只要在主线程调用就会堵塞主线程;apply()方法磁盘写入操作虽然是异步的,但是当组件(Activity Service BroadCastReceiver)这些系统组件特定状态转换的时候,会把QueuedWork中未完成的那些磁盘写入操作放在主线程执行,且如果比较耗时会产生ANR,手动可怕。
  • 跨进程操作,需要借助Android平台常规的IPC手段(如,AIDL ContentProvider等)来完成。
  • 替代解决方案:看4。

作者:Drummor
链接:https://juejin.im/post/5df7af66e51d4557f17fb4f7


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK