首頁 > 軟體

SharedPreference引發ANR原理詳解

2023-02-24 06:00:44

正文

日常開發中,使用過SharedPreference的同學,肯定在監控平臺上看到過和SharedPreference相關的ANR,而且量應該不小。如果使用比較多或者經常用sp存一些巨量資料,如json等,相關的ANR經常能排到前10。下面就從原始碼的角度來看看,為什麼SharedPreference容易產生ANR。

SharedPreference的用法,相信做過Android開發的同學都會,所以這裡就只簡單介紹一下,不詳細介紹了。

// 初始化一個sp
SharedPreferences sharedPreferences = context.getSharedPreferences("name_sp", MODE_PRIVATE);
// 修改key的值,有兩種方法:commit和apply
sharedPreferences.edit().putBoolean("key_test", true).commit();
sharedPreferences.edit().putBoolean("key_test", true).apply();
// 讀取一個key
sharedPreferences.getBoolean("key_test", false);

SharedPreference問題

SharedPreference的相關方法,除了commit外,一般的開發同學都會直接在主執行緒呼叫,認為這樣不耗時。但其實,SharedPreference的很多方法都是耗時的,直接在主執行緒調很可能會引起ANR的問題。另外,雖然apply方法的呼叫不耗時,但是會引起生命週期相關的ANR問題。

下面就來從原始碼的角度,看一下可能引起ANR的問題所在。

getSharedPreference(String name, int mode)

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        File file;
        //  與sp相關的操作,都使用ContextImpl的類鎖
        synchronized (ContextImpl.class) {
            if (mSharedPrefsPaths == null) {
                mSharedPrefsPaths = new ArrayMap<>();
            }
            // mSharedPrefsPaths是記憶體快取的檔案路徑
            file = mSharedPrefsPaths.get(name);
            if (file == null) {
                // 此處獲取SharedPreferences的檔案路徑,可能存在耗時
                file = getSharedPreferencesPath(name);
                mSharedPrefsPaths.put(name, file);
            }
        }
        return getSharedPreferences(file, mode);
    }

下面看下獲取檔案路徑的方法:getSharedPreferencesPath(),這個方法可能存在耗時。

    public File getSharedPreferencesPath(String name) {
        // 建立一個sp的儲存檔案
        return makeFilename(getPreferencesDir(), name + ".xml");
    }

呼叫getPreferencesDir()獲取sharedPrefs的根路徑

    private File getPreferencesDir() {
        // 所有和檔案有關的操作,都會使用mSync鎖,可能出現與其他執行緒搶鎖的耗時
        synchronized (mSync) {
            if (mPreferencesDir == null) {
                mPreferencesDir = new File(getDataDir(), "shared_prefs");
            }
            // 這個方法,如果目錄不存在,會建立目錄,可能存在耗時
            return ensurePrivateDirExists(mPreferencesDir);
        }
    }

ensurePrivateDirExists():確保檔案目錄存在

    private static File ensurePrivateDirExists(File file, int mode, int gid, String xattr) {
        if (!file.exists()) {
            final String path = file.getAbsolutePath();
            try {
                // 建立資料夾,會耗時
                Os.mkdir(path, mode);
                Os.chmod(path, mode);
            } catch (ErrnoException e) {
            }
        return file;
    }

再來看看getSharedPreferences生成SharedPreferenceImpl物件的流程。

    public SharedPreferences getSharedPreferences(File file, int mode) {
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            // 獲取cache,先從cache中獲取SharedPreferenceImpl
            final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
            sp = cache.get(file);
            if (sp == null) {
                // 如果沒有cache,則建立一個SharedPreferencesImpl,此處可能存在耗時
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
        return sp;
    }

先來看下cache的原理

    private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
        // sSharedPrefsCache是一個靜態變數,全域性有效
        if (sSharedPrefsCache == null) {
            sSharedPrefsCache = new ArrayMap<>();
        }
        // key:包名,value: ArrayMap<File, SharedPreferencesImpl> 
        final String packageName = getPackageName();
        ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
        if (packagePrefs == null) {
            packagePrefs = new ArrayMap<>();
            sSharedPrefsCache.put(packageName, packagePrefs);
        }
        return packagePrefs;
    }

再來看看SharedPreferenceImpl的構造方法,看看SharedPreference是怎麼初始化的。

    SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file);
        // 設定是否load到記憶體的標誌位為false
        mLoaded = false;
        startLoadFromDisk();
    }

startLoadFromDisk():開啟一個子執行緒,將sp中的內容讀取到記憶體中

    private void startLoadFromDisk() {
        // 改mLoaded標誌位時,需要獲取mLock鎖
        synchronized (mLock) {
           // load之前先設定mLoaded標誌位為false
            mLoaded = false;
        }
        // 開啟一個執行緒,從檔案中將sp中的內容讀取到記憶體中
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                // 在子執行緒load
                loadFromDisk();
            }
        }.start();
    }

loadFromDisk:真正讀取檔案的地方

   private void loadFromDisk() {
        synchronized (mLock) {
            // 如果已經load過了,直接return,不需要再重新load
            if (mLoaded) {
                return;
            }
            stat = Os.stat(mFile.getPath());
            if (mFile.canRead()) {
                BufferedInputStream str = null;
                try {
                    str = new BufferedInputStream(
                            new FileInputStream(mFile), 16 * 1024);
                    // 讀取xml的內容到map中
                    map = (Map<String, Object>) XmlUtils.readMapXml(str);
                } catch (Exception e) {
                    Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
                } finally {
                    IoUtils.closeQuietly(str);
                }
            }
        synchronized (mLock) {
            // 設定mLoaded標誌位為true,表示已經load完,通知所有在等待的執行緒
            mLoaded = true;
            mLock.notifyAll();
        }
    }

總結:經過上面的分析,getSharedPreferences主要的卡頓點在於,獲取PreferencesDir的時候,可能存在目錄尚未建立的情況。如果這個時候呼叫了建立目錄的方法,就會非常耗時。

getBoolean(String key, boolean defValue)

這個方法和所有獲取key的方法一樣,都可能存在耗時。

SharedPreferencesImpl的構造方法,我們知道會開啟一個新的執行緒,將內容從檔案中讀取到快取的map裡,這個步驟我們叫load。

    public boolean getBoolean(String key, boolean defValue) {
        synchronized (mLock) {
            // 需要等待,直到load成功
            awaitLoadedLocked();
            // 從快取中取value
            Boolean v = (Boolean)mMap.get(key);
            return v != null ? v : defValue;
        }
    }

主要耗時的方法,在awaitLoadedLocked裡。

    private void awaitLoadedLocked() {
       // 只有當mLoaded為true時,才能跳出死迴圈
        while (!mLoaded) {
            try {
                // 呼叫wait後,會釋放mLock鎖,並且進入等待池,等待load完之後的喚醒
                mLock.wait();
            } catch (InterruptedException unused) {
            }
        }
    }

這個方法,呼叫了mLock.wait(),釋放了mLock的物件鎖,並且進入等待池,直到load完被喚醒。

總結:所以,getBoolean等獲取key的方法,會等待,直到sp的內容從檔案中copy到快取map裡。很可能存在耗時。

commit()

commit()方法,會進行同步寫,一定存在耗時,不能直接在主執行緒呼叫。

        public boolean commit() {
            // 開始排隊寫
            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;
        }

apply()

大家都知道apply方法是非同步寫,但是也可能造成ANR的問題。下面我們來看apply方法的原始碼。

        public void apply() {
            // 先將更新寫入記憶體快取
            final MemoryCommitResult mcr = commitToMemory();
            // 建立一個awaitCommit的runnable,加入到QueuedWork中
            final Runnable awaitCommit = new Runnable() {
                    @Override
                    public void run() {
                        try {
                            // 等待寫入完成
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }
                    }
                };
            // 將awaitCommit加入到QueuedWork中
            QueuedWork.addFinisher(awaitCommit);
            Runnable postWriteRunnable = new Runnable() {
                    @Override
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.removeFinisher(awaitCommit);
                    }
                };
            // 真正執行sp持久化操作,非同步執行
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
            // 雖然還沒寫入檔案,但是記憶體快取已經更新了,而listener通常都持有相同的sharedPreference物件,所以可以使用記憶體快取中的資料
            notifyListeners(mcr);
        }

可以看到這裡確實是在子執行緒進行的寫入操作,但是為什麼說apply也會引起ANR呢?

因為在ActivityService的一些生命週期方法裡,都會呼叫QueuedWork.waitToFinish()方法,這個方法會等待所有子執行緒寫入完成,才會繼續進行。主執行緒等子執行緒,很容易產生ANR問題。

public static void waitToFinish() {
       Runnable toFinish;
       //等待所有的任務執行完成
       while ((toFinish = sPendingWorkFinishers.poll()) != null) {
           toFinish.run();
       }
   }

Android 8.0 在這裡做了一些優化,但還是需要等寫入完成,無法完成解決ANR的問題。

總結

綜上所述,SharedPreference可能在以下幾種情況下產生卡頓,從而引起ANR:

  • 建立SharedPreference時,呼叫getPreferenceDir,可能存在建立目錄的行為
  • getBoolean等方法,會等待直到SharedPreference將檔案中的鍵值對全部讀取到快取裡,才會返回
  • commit方法直接同步寫,如果不小心在主執行緒呼叫,會引起卡頓
  • apply方法雖然是在非同步執行緒寫入,但是由於ActivityService的生命週期會等待所有SharedPreference的寫入完成,所以可能引起卡頓和ANR問題

SharedPreference從設計之初,就是為了儲存少量key-value對,而存在的。其本身的設計,就存在很多缺陷。在儲存特別少量資料的時候,效能瓶頸還不顯著。但是現在很多開發同學在使用的時候,會往裡面存一些大型的JSON字串等,導致它的缺點被明顯暴露出來。建議在使用SharedPreference的時候,只用於儲存少量資料,不要存大的字串。

當然,我們也有一些方法來統一優化SharedPreference,減少ANR的發生,下一篇我們繼續講。

以上就是SharedPreference引發ANR原理詳解的詳細內容,更多關於SharedPreference引發ANR的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com