效能優化技巧知識梳理(2) 記憶體優化

澤毛發表於2017-12-21

一、前言

對於應用中的記憶體優化,和佈局優化類似,也有很多的技巧,這裡我們分為以下幾方面來總結:

  • Java優化技巧
  • 避免不必要物件的建立
  • 保證不使用物件的釋放
  • 使用效能優化工具,定位記憶體問題

二、Java 優化技巧

首先,我們介紹一些Java語法中的優化技巧,強烈推薦大家在程式設計時參考阿里巴巴編寫的<<阿里巴巴Java開發手冊>>,下載地址,這裡簡要介紹一些常用的知識點:

  • 儘量採用原始資料型別,而不是物件,例如int要比Integer佔用更少的記憶體。
  • 如果一個方法不需要訪問物件的成員變數,或者呼叫非靜態方法,那麼應當將它宣告為static
  • 將常量宣告為static final
  • 避免內部的getXXX()/setXXX()方法,而是直接訪問變數。
  • 使用增強的for迴圈,而不是for(int i = 0; i < 100; i++)這樣的迴圈。
  • 避免使用float型別,當對精度要求不高,採用int型別。

三、避免不必要物件的建立

(1) 單例物件在需要的時候初始化

在使用單例時,我們應當僅在使用到該單例時才去初始化它,這裡我們可以通過“靜態初始化會在類被載入時觸發”這一原理,來實現懶載入。

public class OptSingleton {
    
    private OptSingleton() {}
    
    public static OptSingleton getInstance() {
        return Holder.INSTANCE;
    }
    
    private static class Holder {
        public static final OptSingleton INSTANCE = new OptSingleton();
    }
}
複製程式碼

(2) 避免進行自動裝箱

自動裝箱指的是將原始的資料型別轉換成為引用型別,例如int轉換成為Integer,這種自動裝箱操作,雖然方便了我們的使用,但是在某些場景下的不當使用有可能會導致效能問題,主要有兩點:

  • 第一點:使用操作符時的自動裝箱
    public static void badAssemble() {
        Integer sum = 0;
        for (int i = 0; i < (1 << 30); i++) {
            sum = sum + i;
        }
    }
複製程式碼

就有自動裝箱的過程,其中sum+i可以分解為下面這兩句,也就是說,在迴圈的過程中,我們建立了大量的臨時物件Integer,而建立完之後,它們很快又會被GC回收掉,因此,會出現記憶體抖動的現象。

int result = sum + i;
Integer sum = new Integer(result);
複製程式碼

我們使用Android Studio提供的檢測工具可以驗證上面的結論:

效能優化技巧知識梳理(2)   記憶體優化
而如果我們使用正常的寫法,那麼是不會出現上面的情況的:

    public static void badAssemble() {
        int sum = 0;
        for (int i = 0; i < (1 << 30); i++) {
            sum = sum + i;
        }
    }
複製程式碼

此時的監測結果為:

效能優化技巧知識梳理(2)   記憶體優化

  • 第二點:使用容器時的自動裝箱

當我們使用例如HashMap這種容器的時候,除了要儲存儲存的資料,還要儲存Key值,這些Key值就是由自動裝箱的過程所產生的。

此時,我們就可以考慮選用Android平臺上提供的優化容器來儘可能地避免裝箱操作,這些容器包括SparseArraySparseBooleanArraySparseIntArraySparseLongArray,這些容器有以下特點:

  • key值都為原始資料型別int,避免了隱式裝箱的操作,這同時也是它的侷限性。
  • 其內部是通過兩個陣列儲存資料的,一個用於key,另一個用於value,為了優化效能,它內部對資料還採取了壓縮的方式來表示稀疏陣列的資料,從而節約記憶體空間。
  • 在查詢資料時,採用的是二分查詢法,相比於HashMap需要遍歷Entry陣列找到相等的hash值,一般來說,我們的資料量都不會太大,而在資料量較小時,二分查詢要比遍歷陣列,查詢速度更快。

(3) 預先指定容器的大小

當我們使用例如HashMapArrayList這些容器時,往往不習慣給它們指定一個初始值,然而當這些容器儲存空間不足時,就會去自動擴容,其擴容的大小往往是原始大小的兩倍。

因此,當我們需要儲存額外的一個元素的時候剛好容器不夠了,那麼就需要擴容,但是這時候就會出現額外的浪費空間。

(4) 對於佔用資源的 Activity,合理的使用 LaunchMode

對於Activity來說,其預設的啟動模式是standard,也就是說,每次啟動這個Activity,都會建立一個新的例項,像類似於瀏覽器這種記憶體大戶,每次外部開啟一個網頁,都需要建立一個Activity,而Activity又會去例項化WebView,那麼是相當耗費資源的,這時,我們就可以考慮使用singleTask或者singleInstance來實現。

(5) 處理螢幕旋轉導致的重建

當螢幕發生旋轉時,如果我們沒有在AndroidManifest.xml中,對其configChanges屬性進行宣告,那麼就會導致Activity進行重建,此時,就需要重新載入Activity所需要展示的資料。

此時,我們就可以對其進行如下的宣告:

android:configChanges="keyboardHidden|orientation|screenSize"
複製程式碼

接著在ActivityonConfigurationChanged進行監聽,對佈局進行相應的改變,而不需要重新載入資料。

(6) 處理字串拼接

在程式碼中,我們經常使用到字串拼接的操作,這裡有兩點注意:

採用高效的拼接方式

例如下面的操作,就會建立大量的臨時物件:

    public static void badString() {
        String result = "result";
        String append = "append";
        for (int i = 0; i < (1 << 30); i++) {
            result += append;
        }
    }
複製程式碼

記憶體檢測的結果如下,可以發現,我們出現了大量記憶體抖動的情況:

效能優化技巧知識梳理(2)   記憶體優化
而如果我們採用StringBuilder的方式進行拼接:

    public static void goodString() {
        StringBuilder result = new StringBuilder("result");
        String append = "append";
        for (int i = 0; i < (1 << 20); i++) {
            result.append(append);
        }
    }
複製程式碼

那麼最終的結果為:

效能優化技巧知識梳理(2)   記憶體優化
因此,在處理字串拼接的時候,應當儘量避免直接使用"+"號,而是使用以下兩種方式的一種:

  • 使用靜態方法,String.format方法進行拼接。
  • 非執行緒安全的StringBuilder,或者是執行緒安全的StringBuffer

避免不必要的字串拼接

當我們需要列印Log時,一般會將它們寫在一個公共類中,然後使用一個DEBUG開關,讓他們在外發版本上關閉:

    private static final boolean DEBUG = true;
    
    public static void LogD(String tag, String msg) {
        if (DEBUG) {
            Log.d(tag, msg);
        }
    }
複製程式碼

但是這種方式有一點弊端,就是,我們在呼叫該方法時msg一般都是通過拼接多個字串進行傳入的,也就是說,即使沒有列印該Log,也會進行字串拼接的操作,因此,我們應當儘量將DEBUG開關放在字串拼接的外部,避免不必要拼接操作。

(7) 減少不必要的異常

在某些時候,如果我們能預見到某些有可能會發生異常的場景,那麼提前進行判斷,將可以避免由於異常所帶來的代價,以啟動第三方應用為例,我們可以先判斷該intent所對應的應用是否存在,再去啟動它,而不是等到異常發生時再去捕獲:

    public static void startApp(Context context) {
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("www.qq.com"));
        intent.setComponent(new ComponentName("com.android.browser", "com.android.browser.BrowserActivity"));
        if (intent.resolveActivity(context.getPackageManager()) == null) {
            return;
        }
        try {
            context.startActivity(intent);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
複製程式碼

(8) 執行緒複用

當執行非同步操作時,不要通過new Thread的方式啟動一個新的執行緒來執行操作,而是儘可能地對已經建立的執行緒進行復用,一般來說,主要有兩種方式:

(9) 合理的適應物件池

例如,我們最常用的Handler傳送訊息,當需要建立一個訊息時,可以使用Handler提供的obtainMessage方法,獲取到Message物件,其內部,就會去Message中所維護的一個靜態連結串列中,查詢當前可用的Message物件,並將其標誌位置為0,表明其正在使用。

使用物件池時,應當注意兩點:

  • 將物件放回物件池時,注意初始化,防止出現髒資料。
  • 合理的控制物件池的增長,防止出現大量無用物件。

(10) 使用 inBitmap 對記憶體塊複用

inBitmap指的是複用記憶體塊,不需要重新給這個Bitmap申請一塊新的記憶體,避免了一次記憶體的分配和回收,關於inBitmap的詳細解釋,可以參見這篇文章,Managing Bitmap Memory,其Demo對應的下載地址,對於inBItmap屬性的使用,有以下兩點限制:

  • 該屬性只能在3.0之後使用,在2.3上,bitmap的資料是儲存在native的記憶體區域中。
  • 4.4之前,inBitmap只能重用相同大小的bitmap記憶體區域,而在4.4之後,可以重用任何bitmap記憶體區域,只要這塊記憶體比將要分配的記憶體大就可以。

(11) 使用註解替代列舉

public class Constant {

    public static final int FLAG_START = 0;
    public static final int FLAG_STOP = 1;
    public static final int FLAG_PAUSE = 2;

    @IntDef({FLAG_START, FLAG_STOP, FLAG_PAUSE})
    public @interface VideoState {}
}
複製程式碼

當我們定義的形參時,在引數之前,加上之前定義的註解:

    public static void accept(@Constant.VideoState int videoState) {
        Log.d("OptUtils", "state=" + videoState);
    }
複製程式碼

如果我們傳入了不屬於上面的三個值,那麼IDE就會警告我們:

效能優化技巧知識梳理(2)   記憶體優化

(12) 謹慎初始化 Application

當我們在專案當中,引入一些第三方庫,或者將一些元件放到其它程式,加入我們自定義了Application的子類,並且在AndroidManifest.xml中進行了宣告,那麼在啟動這些執行在其它程式中的元件時,就會呼叫該ApplicationonCreate()方法,此時,我們就應當根據程式所要求的資源進行初始化。

例如下面,我們將RemoteActivity宣告在remote程式當中,並且給application指定了自定義的OptApplication

    <application
        android:name=".OptApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".OptActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity android:name=".RemoteActivity" android:process=":remote"/>
    </application>
複製程式碼

OptApplication中,判斷一下呼叫該方法程式名,進行不同邏輯的初始化操作:

public class OptApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        if (isMainProcess()) {
            //對主程式的資源進行初始化。
            Log.d("OptApplication", "isMainProcess=" + true);
        } else {
            //對其它程式資源進行初始化。
            Log.d("OptApplication", "isMainProcess=" + false);
        }
    }

    private boolean isMainProcess() {
        ActivityManager am = ((ActivityManager) getSystemService(Context.ACTIVITY_SERVICE));
        List<ActivityManager.RunningAppProcessInfo> process = am.getRunningAppProcesses();
        String mainProcessName = getPackageName();
        int myPid = android.os.Process.myPid();
        for (ActivityManager.RunningAppProcessInfo info : process) {
            if (info.pid == myPid && mainProcessName.equals(info.processName)) {
                return true;
            }
        }
        return false;
    }
}
複製程式碼

(13) 避免在 onDraw 方法中建立物件

onDraw方法中建立臨時物件,不僅會影響繪製的效能,而且這些臨時物件在onDraw方法執行完之後又很快被回收,那麼將會造成記憶體抖動。

(14) 合理地使用 ArrayMap 替代 HashMap

前面我們介紹了SparseArray,它的侷限性是其key值只能為原始資料型別int,而如果我們要求它的key值為引用型別時,那麼可以考慮使用ArrayMap

SparseArray一樣,它會對key使用二分法進行新增、查詢、刪除等操作,在新增、刪除、查詢資料的時候都是先使用二分查詢法得到相應的index,然後通過index進行新增、查詢、刪除操作。

如果在資料量較大的情況,那麼它的效能將退化至少50%

(15) 謹慎使用抽象程式設計

抽象能夠提升程式碼的靈活性與可維護性,然而,抽象會導致一個顯著的額外記憶體開銷:它們需要同等量的程式碼用於可執行,這些程式碼會被mapping到記憶體中。

(16) 使用 Protocol Buffers

在平時的網路資料傳輸時,一般用的最多的是JSON或者xml,而Protocal BuffersGoogle為序列化結構資料而設計的,相比於普通的資料傳輸方式,它具有以下優點:

  • 編碼/解碼方式簡單
  • 序列化 & 反序列化 & 速度快
  • 資料壓縮效果更好

關於Protocol Buffers的詳細介紹,大家可以閱讀 Carson_Ho 所寫的一系列文章,推薦閱讀:Protocol Buffer 序列化原理大揭祕 - 為什麼Protocol Buffer效能這麼好?

(17) 謹慎使用依賴注入框架

諸如Guice或者RoboGuice這些依賴注入框架,它們可以減少大量findViewById的繁瑣操作,但是這些註解的框架為了要搜尋程式碼中的註解,通常都需要經歷較長的初始化過程,並且還可能將一些你用不到的物件也一併載入到記憶體當中,這些用不到的物件會一直佔用記憶體空間,等到很久之後才釋放。

(18) 謹慎使用多程式

在我們有大量需要執行在後臺的任務,例如音樂、視訊、下載等業務,那麼可以將它們放在獨立的程式當中。但是,我們不應當濫用它們,因為每建立一個新的程式,那麼必然要分配一些記憶體來儲存該程式的一些資訊,這都將增加記憶體的佔用。

(19) 使用 ProGurad 優化程式碼

通過ProGuard對程式碼進行優化、壓縮、混淆,可以移除不需要的程式碼、重新命名類、域與方法等,做法就是在buildTypes的指定型別下增加下面的程式碼:

    buildTypes {
        release {
            //對於release版本採用進行混淆。
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-project.txt'
        }
        debug {
            //對於debug版本不混淆。
            minifyEnabled false
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-project.txt'
        }
複製程式碼

這裡的混淆規檔案有兩份,如果有多份,那麼可以使用逗號分隔,第一個是Android自帶的混淆檔案,而第二個則是應用自定義的混淆規則檔案,關於混淆檔案的語法,可以參考這篇文章: ProGuard 程式碼混淆技術詳解

(20) 謹慎使用第三方 Library

在專案中引入第三方Library時,應當注意以下幾點:

  • 不要匯入無用的功能:如果需要使用到定位功能,那麼就只需要匯入定位的Library即可,不要引入導航等Library
  • 不要匯入功能重複的Library:目前存在很多開源的第三方網路框架,例如Volley/OkHttp/Retrofit等,那麼在我們引入一個新的網路框架時應當先檢查程式碼中原有的網路框架,將之前的程式碼都替換成為新的框架,而不是匯入多份。
  • 使用為移動平臺定製的Library:很多開源專案都會針對移動平臺進行專案的優化與裁剪,我們應當首先考慮使用擁有這些版本的開源庫。

(21) 使用 AnimatedVectorDrawable 替換幀動畫

圖片壓縮知識梳理(6) - VectorDrawable 及 AnimatedVectorDrawable 使用詳解 中,我們介紹了AnimatedVectorDrawable的使用,在需要實現一些簡單圖形的動畫時,它比幀動畫效率更高、佔用記憶體更小。

(22) 讀取和螢幕解析度匹配的圖片

當我們讀取圖片時,應當儘量結合當前手機的解析度進行處理,這裡有兩點建議:

  • 在圖片載入到記憶體之前,對其進行縮放,避免載入進入過大的圖片,以從資原始檔中讀取圖片為例,我們傳入預期的寬高,先將Bitmap.ConfiginJustDecodeBounds置為true,獲取到目標圖片的寬高而不是將整張圖片都載入到記憶體中,在根據預期的寬高計算出一個比例,去載入一個適合螢幕解析度的圖片,具體的操作如下面的程式碼塊所示:
    public static int calculateInSampleSize(BitmapFactory.Options options, int dstWidth, int dstHeight) {
        int srcWidth = options.outWidth;
        int srcHeight = options.outHeight;
        int inSampleSize = 1;
        if(srcHeight > dstHeight && srcWidth > dstHeight) {
            int halfWidth = srcWidth / 2;
            int halfHeight = srcHeight / 2;
            while ((halfHeight / inSampleSize) > dstHeight && (halfWidth / inSampleSize) > dstWidth) {
                inSampleSize *= 2;
            }
        }
        return inSampleSize;
    }

    public static Bitmap decodeResource(Resources res, @DrawableRes int resId, Bitmap.Config config, int dstWidth, int dstHeight) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inPreferredConfig = config;
        if(dstWidth <= 0 && dstHeight <= 0) {
            return BitmapFactory.decodeResource(res, resId, options);
        }
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId, options);
        options.inSampleSize = calculateInSampleSize(options, dstWidth, dstHeight);
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res, resId, options);
    }
複製程式碼
  • 將圖片放在與螢幕解析度匹配的資料夾當中

圖片基礎知識梳理(2) - Bitmap 佔用記憶體分析 一文當中,我們分析過,在res目錄下可以建立多個不同的圖片資料夾,即drawable-xhpi/drawable-xxhdpi/drawable-xxxhdpi,只有當圖片放在機型對應解析度下的資料夾時,才不會進行縮放操作,如果某張圖片放在比它解析度低的資料夾當中,那麼將會進行放大操作,不僅會使圖片變得模糊,還要佔用額外的記憶體。

因此,我們應當將圖片放在對應機型解析度的資料夾當中。

三、保證不使用物件的釋放

(1) 避免 Activity 洩露

Activity洩露是我們在開發中最長遇見的記憶體洩露型別,下面總結幾點大家比較容易犯的錯誤:

在 Activity 中定義非靜態的 Handler 內部類

例如下面這樣,我們在Activity中定義了一個非靜態的內部類LeakHandler,那麼作為內部類,leakHandler預設持有外部類的例項,也就是LeakActivity

public class LeakActivity extends Activity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        LeakHandler leakHandler = new LeakHandler();
        leakHandler.sendEmptyMessageDelayed(0, 50000);
    }
    
    private class LeakHandler extends Handler {

        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
        }
    }
}
複製程式碼

在呼叫了sendEmptyMessageDelayed之後,那麼會建立一個Message物件放到Looper的佇列MessageQueue當中等待被執行,而該Message中的target會執行傳送它的Handler,也就是LeakHandler,那麼在該訊息被處理之前,會一直存在一條從LeakActivityMessageQueue的引用鏈,因此,在這段時間內如果Activity被銷燬,它的記憶體也無法釋放,就是造成記憶體洩露。

對於這種問題,有以下幾個處理的技巧:

  • Handler定義為靜態內部類,這樣它就不會持有外部的類的引用,如果需要在handleMessage中呼叫Activity中的方法,那麼可以傳入它作為引數,並持有它的弱引用以保證它能夠回收。
    private static class SafeHandler extends Handler {
        
        private WeakReference<Activity> mActHolder;
        
        public SafeHandler(Activity activity) {
            mActHolder = new WeakReference<>(activity);    
        }

        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (mActHolder != null) {
                Activity activity = mActHolder.get();
                if (activity != null && !activity.isDestroyed()) {
                    //僅在 Activity 沒有被銷燬時,才執行操作。
                }
            }
        }
    }
複製程式碼
  • ActivityonDestroy()方法中,通過removeCallbacksAndMessages(null)方法移除所有未執行的訊息。

單例中的成員變數或者 static 成員變數持有了 Activity 的引用

根據持有的方式,可以簡單地分為直接持有、間接持有兩種型別:

  • 直接持有:在Android的很多Api中,都會使用到上下文資訊Context,而Activity繼承於Context類,因此我們經常會將它傳給其它類,並將它作為這些類的成員變數以便後續的操作,那麼如果這個成員變數所屬的類是一個單例,或者說它是該類中的一個靜態成員變數,那麼就會導致該Activity所佔用的記憶體無法被釋放。
  • 間接持有:某個中間物件持有了Activity,而該中間物件又作為了單例中的成員變數或者某類中的static成員變數,這些物件最常見的有以下兩類: (a) Activity的非靜態內部類,例如監聽器,那麼它就會預設持有Activity的引用。 (b) Activity中的控制元件,其mContext變數指向了它所屬的Activity

當出現這種情況時,我們應當注意這幾點:

  • 如果可以使用ApplicationContext,那麼就用Activity.getApplicationContext()來替代,不要用Activity
  • 如果必須使用Activity,那麼確保在ActivityonDestroy()方法執行時,將它們到Activity的引用鏈想方設法切斷,將引用設為空,或者登出監聽器。

當然不僅是Activity,對於應用當中的某些大物件,例如Bitmap等,我們也應當注意,是否出了類似於上面這種直接和間接引用的情況。

(2) 對於只執行一次的後臺任務,使用 IntentService 替代 Service

當我們需要將某些任務的生命週期和Activity分離開來,那麼一般會使用Service,但是Service就需要我們進行手動管理,如果忘記,那麼將會導致額外的記憶體佔用,並且擁有Service程式的oom_adj值一般會高於沒有Service的程式,系統會更傾向於將它保留。

對於一些短時的後臺任務,我們可以考慮採用IntentService,它的onHandleIntent回撥是在非同步執行緒中執行的,並且任務執行完畢後,該Service會自動銷燬,不需要手動管理。

(3) 在 onLowMemory() / onTrimMemory() 回撥當中,釋放不必要的資源

為了能讓各個應用知曉當前系統記憶體的使用情況,提供了兩種型別的回撥onLowMemoryonTrimMemory,在ApplicationActivityFragementServiceContentProvider這些元件中,都可以收到這兩個回撥,進行相應的處理。

onLowMemory

當最後一個後臺應用(優先順序為background的程式)被殺死之後,前臺應用就會收到onLowMemory回撥。

onTrimMemory(int level)

onLowMemory相比,onTrimMemory的回撥更加頻繁,每次計算程式優先順序時,只要滿足對應的條件,就會觸發。level引數則表明了當前記憶體的佔用情況,各等級的解釋如下表所示,等級從上到下,程式被殺的可能性逐漸增大:

效能優化技巧知識梳理(2)   記憶體優化
我們應當根據當前的等級,釋放掉一些不必要的記憶體,以免應用程式被殺死。

(4) 及時關閉 Cursor

無論是使用資料庫,還是ContentProvider來查詢資料,在查詢完畢之後,一定要記得關閉Cursor

四、使用效能優化工具,定位記憶體問題

關於記憶體的優化工具,之前一系列的文章已經介紹過了,大家可以檢視下面這三篇文章:

五、特別鳴謝

以上的總結,借鑑了網上幾位大神的總結,特此鳴謝:

參考的文章包括以下幾篇:


更多文章,歡迎訪問我的 Android 知識梳理系列:

相關文章