美文网首页
从源码分析为什么SharePreference会导致ANR

从源码分析为什么SharePreference会导致ANR

作者: 卖火柴的笨小孩 | 来源:发表于2022-05-30 11:02 被阅读0次

    一、背景

      SharePreference是Android系统中的持久化存储工具,使用xml文件存放数据,适用于存储数据量较小的场景,使用时一次性将数据读取到内存中。作为一个常用的存储工具,在bugly上的ANR率却很高,通过分析发现除了SharePreference自身设计缺陷外,开发者不规范使用也会使得应用出现ANR的概率提高,常见的几种ANR场景堆栈如下

    堆栈1
    java.lang.Object.wait(Native Method)
    android.app.SharedPreferencesImpl.awaitLoadedLocked(SharedPreferencesImpl.java:225)
    android.app.SharedPreferencesImpl.contains(SharedPreferencesImpl.java:288)
    
    堆栈2
    ANR_EXCEPTION: ANR Input dispatching timed out (Waiting to send key event because the focused window has not finished processing all of the input events that were previously delivered to it. Outbound queue length: 0. Wait queue length: 1.)
    android.app.QueuedWork.processPendingWork(QueuedWork.java:251)
    android.app.QueuedWork.waitToFinish(QueuedWork.java:177)
    android.app.ActivityThread.handleServiceArgs(ActivityThread.java:4784)
    android.app.ActivityThread.access$3100(ActivityThread.java:308)
    

    接下来将从源码分析导致SharePreference频繁出现ANR的原因。

    二、源码分析

    1、初始化SharedPreferences

        @Override
        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);
                    mSharedPrefsPaths.put(name, file);
                }
            }
            return getSharedPreferences(file, 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);
                    ...省略
                    sp = new SharedPreferencesImpl(file, mode);
                    cache.put(file, sp);
                    return sp;
                }
            }
            ...
            return sp;
        }
    
        @UnsupportedAppUsage
        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;
                }
               ...
            }
            Map<String, Object> map = null;
              // 读取xml文件
            map = (Map<String, Object>) XmlUtils.readMapXml(str);
            synchronized (mLock) {
                mLoaded = true;
                mThrowable = thrown;
                try {
                    if (thrown == null) {
                        if (map != null) {
                             //把读取到的数据同步到内存中
                            mMap = map;
                            ...
                        } else {
                            mMap = new HashMap<>();
                        }
                    }
                } catch (Throwable t) {
                    mThrowable = t;
                } finally {
                    mLock.notifyAll();
                }
            }
        }
    

    可以看到getSharedPreferences做了几件事情:

    (1)首次调用的情况下会先去初始化一个变量mSharedPrefsPaths,这个变量是用来存储对应sp的文件,每次调用getSharedPreferences会去判断对应sp名字的文件是否已经存在,存在的话就不再新建文件,不存在则新建并put进mSharedPrefsPaths中。

    (2)通过getSharedPreferencesCacheLocked()拿到对应包名的所有sp数据(ArrayMap<File, SharedPreferencesImpl>),如果缓存变量sSharedPrefsCache中没有,则新建一个放入缓存sSharedPrefsCache中。

    (3)判断缓存中是否存在对应File的SharedPreferencesImpl,如果不存在SharedPreferencesImpl的构造方法就会调用startLoadFromDisk()异步从磁盘中加载对应的xml文件数据,生成一个SharedPreferencesImpl对象,最后再释放掉锁,唤醒其他等待的线程。

    从这里我们可以看出,在这里容易出现ANR的是第3步,假如子线程正在加载对应xml且文件很大,此时主线程又刚好需要读取数据,将会出现主线程等子线程的情况。

    </br>

    2、读取数据getString

    public String getString(String key, @Nullable String defValue) {
            synchronized (mLock) {
                awaitLoadedLocked();
                String v = (String)mMap.get(key);
                return v != null ? v : defValue;
            }
    }
    
    @GuardedBy("mLock")
    private void awaitLoadedLocked() {
        if (!mLoaded) {
            BlockGuard.getThreadPolicy().onReadFromDisk();
        }
        while (!mLoaded) {
            try {
                mLock.wait();
            } catch (InterruptedException unused) {
            }
        }
        if (mThrowable != null) {
            throw new IllegalStateException(mThrowable);
        }
    }
    

      无论是getXxx()或者是contains()方法,在获取值的时候,会先调用awaitLoadedLocked判断数据是否已经读取到了内存,如果还没有就去读取并等待锁释放,直到上文提到的子线程执行完loadFromDisk()方法后,将mLoaded置为true,并notifyAll所有等待的线程时,再从mMap缓存中取值返回。假如我们在调用getXXX()contains()方法时,此时由于loadFromDisk()读取的文件较大,导致主线程一直处于等待状态,就有可能出现一开始我们提到的ANR堆栈1的情况。

    3、修改数据putString

    SharedPreferences.Editor editor = mSharedPreferences.edit();
    editor.putString(key1, value1);
    editor.putString(key2, value2);
    editor.putString(key3, value3);
    editor.apply();//或commit()
    
    @Override
     public Editor putString(String key, @Nullable String value) {
         synchronized (mEditorLock) {
             mModified.put(key, value);
             return this;
         }
     }
    

      修改数据是调用EditorImpl(Editor的实现类)中的putXxx()方法,通过这些方法可以批量预处理数据,每次put数据后是存储在mModified(Map类型)进行修改,最后通过ommit()或者apply()将数据持久化到xml文件中。

    </br>

    4、写入数据commit&&apply

      提交数据时,可以用SharedPreferences.Editor的**commit()或者apply()方法,他们本质上的区别是:前者是在主线程中同步将数据写入文件,而后者则是在子线程中异步将数据写入文件。为了不影响UI线程,我们一般使用apply()来写入数据,仅当需要确认写入结果且sp文件不大时,才使用commit()。接下来我们通过源码看下这两个方法在实现上的区别,首先看下commit()。

    @Override
    public boolean commit() {
        long startTime = 0;
        ...
        // 比较数据是否发生改变并写入内存
        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;
    }
    

    可以看到commit()中主要由5步组成:

    (1)通过commitToMemory()将写操作数据同步刷新到内存中。

    (2)利用SharedPreferencesImplenqueueDiskWrite()将内存中的数据写入文件。

    (3)利用CountDownLatch.await()实现线程同步,等待数据写入完成。

    (4)OnSharedPreferenceChangeListener监听回调。

    (5)返回数据写入文件的结果。

    </br>

    接着看下apply()

    @Override
    public void apply() {
        final long startTime = System.currentTimeMillis();
          // 比较数据是否发生改变并写入内存
        final MemoryCommitResult mcr = commitToMemory();
        final Runnable awaitCommit = new Runnable() {
                @Override
                public void run() {
                    try {
                         // 等待数据写入完成
                        mcr.writtenToDiskLatch.await();
                    } catch (InterruptedException ignored) {
                    }
                }
            };
        QueuedWork.addFinisher(awaitCommit);
        Runnable postWriteRunnable = new Runnable() {
                @Override
                public void run() {
                    awaitCommit.run();
                    QueuedWork.removeFinisher(awaitCommit);
                }
            };
           // 将内存中的数据写入文件
        SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
        notifyListeners(mcr);
    }
    

    apply()的流程如下:

    (1)通过commitToMemory()将写操作数据同步刷新到内存中。

    (2)往QueuedWork中添加awaitCommit任务,该任务用于等待数据写入完成。

    (3)当awaitCommit执行时,阻塞当前线程,直到postWriteRunnable执行完毕后,将awaitCommitQueuedWork 中移除。

    (4)调用enqueueDiskWrite()将内存中的数据写入文件。

    </br>

    从上面分析可以看出,commit()和apply()方法都会用到**commitToMemory()和enqueueDiskWrite()方法,接下来我们分别看下它们具体做了什么,首先看下commitToMemory()。

    private MemoryCommitResult commitToMemory() {
                long memoryStateGeneration;
                List<String> keysModified = null;
                Set<OnSharedPreferenceChangeListener> listeners = null;
                Map<String, Object> mapToWriteToDisk;
                synchronized (SharedPreferencesImpl.this.mLock) {
                    if (mDiskWritesInFlight > 0) {
                        // 拷贝内存中的数据mMap
                        mMap = new HashMap<String, Object>(mMap);
                    }
                    mapToWriteToDisk = mMap;
                    // 待写入磁盘标识+1
                    mDiskWritesInFlight++;
                    boolean hasListeners = mListeners.size() > 0;
                    if (hasListeners) {
                        keysModified = new ArrayList<String>();
                        listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
                    }
                    synchronized (mEditorLock) {
                        boolean changesMade = false;
                        // mClear是在EditrImpl.clear()被调用时置为true,代表清除内存中的数据
                        if (mClear) {
                            if (!mapToWriteToDisk.isEmpty()) {
                                changesMade = true;
                                mapToWriteToDisk.clear();
                            }
                            mClear = false;
                        }
                        // mModified是putXxx()时候用来存储数据变化的变量,用它跟内存中的数据进行对比,将发生变化的数据同步到内存中
                        for (Map.Entry<String, Object> e : mModified.entrySet()) {
                            String k = e.getKey();
                            Object v = e.getValue();
                            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 &amp;&amp; existingValue.equals(v)) {
                                        // 数据相等说明没有发生改变,则不作操作直接跳过
                                        continue;
                                    }
                                }
                                // 内存中未包含该数据,则添加该数据到内存中
                                mapToWriteToDisk.put(k, v);
                            }
                            changesMade = true;
                            if (hasListeners) {
                                keysModified.add(k);
                            }
                        }
                        mModified.clear();
                        if (changesMade) {
                            mCurrentMemoryStateGeneration++;
                        }
                        memoryStateGeneration = mCurrentMemoryStateGeneration;
                    }
                }
                // 构建MemoryCommitResult对象返回,传入的参数在后续写入文件时候会用到
                return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
                        mapToWriteToDisk);
    }
    

    commitToMemory()主要做了以下几件事:

    (1)拷贝内存数据赋值给mapToWriteToDisk变量,将写入磁盘标识mDiskWritesInFlight加1。

    (2)判断mClear是否为true,是的话清空内存数据。

    (3)将mModified中记录的写操作数据同步到mapToWriteToDisk中,mapToWriteToDisk代表最终要写入文件的数据集合。

    (4)清空mModified数据,生成MemoryCommitResult对象返回。

    </br>
    看下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) {
                         //待写入磁盘标识+1
                        mDiskWritesInFlight--;
                    }
                      // 如果是apply()调用,则postWriteRunnable会不为null,执行等待写入结束的postWriteRunnable
                    if (postWriteRunnable != null) {
                        postWriteRunnable.run();
                    }
                }
            };
        if (isFromSyncCommit) {
            boolean wasEmpty = false;
            synchronized (mLock) {
                 // 待写入磁盘标识为1
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                 //如果是commit()调用且待写入磁盘标识为1时直接执行写入文件的writeToDiskRunnable
                writeToDiskRunnable.run();
                return;
            }
        }
        //apply()调用则将任务放入QueuedWork队列
        QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    }
    

    梳理下enqueueDiskWrite()流程:

    (1)构建一个writeToDiskRunnable任务,用于执行writeToFile()写入数据。

    (2)如果是commit()调用,则直接在主线程中写入文件。如果是apply()调用,执行等待写入结束的postWriteRunnable任务,并将writeToDiskRunnable加入QueuedWork队列,通过QueuedWork里的QueuedWorkHandler在异步线程中写入文件。

    </br>

      看到这里可能大家都已经清楚了commit()是在主线程进行数据写入文件操作,操作不当会导致应用卡顿或者ANR,所以平时尽量用apply()。但是apply()真的就安全了吗?会不会也会有问题?答案是会的,虽然它是在非主线程写入文件,但是它是将写入任务放到QueuedWork中操作,而在ActivityThreadhandleStopActivity()handleServiceArgs()或者handlePauseActivity() 等方法的时候都会调用QueuedWork.waitToFinish()方法,在异步任务完成前,主线程会因此阻塞,假如此时QueuedWork中的任务很多又或者sp文件过大时,就有可能出现文章一开始提到的ANR堆栈2情况。

    </br>

    三、总结

    1、出现ANR的原因

    (1)调用getSharedPreferences()后又调用getXxx()或者contains()等方法,如果此时需要从文件读取数据到缓存,是异步去读取,且调用getXxx()或者contains()所在线程会等待读取完成,如果文件过大就会导致主线程等待时间边长,进而导致ANR。

    (2)使用commit()提交数据时,由于sp文件过大,导致主线程阻塞导致ANR。

    (3)由于ActivityThreadhandleStopActivity()等方法会等待QueuedWork任务执行完,如果用户频繁调用apply()或者sp文件较大时,会导致QueuedWork中等待处理的任务过多或者耗时很长,阻塞主线程,最终导致ANR。

    2、如何避免

    (1)非必要场景尽量避免使用commit(),要使用的话需确保数据量小。

    (2)无论使用commit()还是apply()都不要存放过大的value,因为数据会存在内存中,内存使用过高会导致频繁GC,导致丢帧或者ANR。

    (3)不要频繁调用apply()写入数据,尽量批量修改然后再提交,减少锁竞争和QueuedWork任务数量,降低内存消耗。

    (4)提前初始化SharedPreferences,避免getXxx()时读取文件线程未结束,出现等待的情况。

    (5)避免将所有key-value都放在同个文件中,频繁使用的key-value应该统一放到一个文件中,因为每次读写都是全量操作,文件过大会导致读取或者写入速度慢。

    相关文章

      网友评论

          本文标题:从源码分析为什么SharePreference会导致ANR

          本文链接:https://www.haomeiwen.com/subject/bploprtx.html