深入探索Android卡頓優化(下)

前言

成爲一名優秀的Android開發,需要一份完備的知識體系,在這裏,讓我們一起成長爲自己所想的那樣~。

在上篇文章中,筆者帶領大家學習了卡頓優化分析方法與工具、自動化卡頓檢測方案及優化這兩塊內容。如果對這塊內容還不瞭解的同學建議先看看《深入探索Android卡頓優化(上)》。本篇,爲深入探索Android卡頓優化的下篇。這篇文章包含的主要內容如下所示:

  • 1、ANR分析與實戰
  • 2、卡頓單點問題檢測方案
  • 3、高效實現界面秒開
  • 4、優雅監控耗時盲區
  • 5、卡頓優化技巧總結
  • 6、常見卡頓問題解決方案總結
  • 7、卡頓優化的常見問題

卡頓時間過長,一定會造成應用發生ANR。下面,我們就來從應用的ANR分析與實戰來開始今天的探索之旅。

一、ANR分析與實戰

1、ANR介紹與實戰

首先,我們再來回顧一下ANR的幾種常見的類型,如下所示:

  • 1、KeyDispatchTimeout:按鍵事件在5s的時間內沒有處理完成。
  • 2、BroadcastTimeout:廣播接收器在前臺10s,後臺60s的時間內沒有響應完成。
  • 3、ServiceTimeout:服務在前臺20s,後臺200s的時間內沒有處理完成。

具體的時間定義我們可以在AMS(ActivityManagerService)中找到:

// How long we allow a receiver to run before giving up on it.
static final int BROADCAST_FG_TIMEOUT = 10*1000;
static final int BROADCAST_BG_TIMEOUT = 60*1000;

// How long we wait until we timeout on key dispatching.
static final int KEY_DISPATCHING_TIMEOUT = 5*1000;

接下來,我們來看一下ANR的執行流程。

ANR執行流程

  • 1、首先,我們的應用發生了ANR。
  • 2、然後,我們的進程就會接收到異常終止信息,並開始寫入進程ANR信息,也就是當時應用的場景信息,它包含了應用所有的堆棧信息、CPU、IO等使用的情況。
  • 3、最後,會彈出一個ANR提示框,看你是要選擇繼續等待還是退出應用,需要注意這個ANR提示框不一定會彈出,根據不同ROM,它的表現情況也不同。因爲有些手機廠商它會默認去掉這個提示框,以避免帶來不好的用戶體驗。

分析完ANR的執行流程之後,我們來分析下怎樣去解決ANR,究竟哪裏可以作爲我們的一個突破點。

在上面我們說過,當應用發生ANR時,會寫入當時發生ANR的場景信息到文件中,那麼,我們可不可以通過這個文件來判斷是否發生了ANR呢?

關於根據ANR log進行ANR問題的排查與解決的方式筆者已經在深入探索Android穩定性優化的第三節ANR優化中講解過了,這裏就不多贅述了。

線上ANR監控方式

深入探索Android穩定性優化的第三節ANR優化中我說到了使用FileObserver可以監聽 /data/anr/traces.txt的變化,利用它可以實現線上ANR的監控,但是它有一個致命的缺點,就是高版本ROM需要root權限,解決方案是隻能通過海外Google Play服務、國內Hardcoder的方式去規避。但是,這在國內顯然是不現實的,那麼,有沒有更好的實現方式呢?

那就是ANR-WatchDog,下面我就來詳細地介紹一下它。

ANR-WatchDog項目地址

ANR-WatchDog是一種非侵入式的ANR監控組件,可以用於線上ANR的監控,接下來,我們就使用ANR-WatchDog來監控ANR。

首先,在我們項目的app/build.gradle中添加如下依賴:

implementation 'com.github.anrwatchdog:anrwatchdog:1.4.0'

然後,在應用的Application的onCreate方法中添加如下代碼啓動ANR-WatchDog:

new ANRWatchDog().start();

可以看到,它的初始化方式非常地簡單,同時,它內部的實現也非常簡單,整個庫只有兩個類,一個是ANRWatchDog,另一個是ANRError。

接下來我們來看一下ANRWatchDog的實現方式。

/**
* A watchdog timer thread that detects when the UI thread has frozen.
*/
public class ANRWatchDog extends Thread {

可以看到,ANRWatchDog實際上是繼承了Thread類,也就是它是一個線程,對於線程來說,最重要的就是其run方法,如下所示:

private static final int DEFAULT_ANR_TIMEOUT = 5000;

private volatile long _tick = 0;
private volatile boolean _reported = false;

private final Runnable _ticker = new Runnable() {
    @Override public void run() {
        _tick = 0;
        _reported = false;
    }
};

@Override
public void run() {
    // 1、首先,將線程命名爲|ANR-WatchDog|。
    setName("|ANR-WatchDog|");

    // 2、接着,聲明瞭一個默認的超時間隔時間,默認的值爲5000ms。
    long interval = _timeoutInterval;
    // 3、然後,在while循環中通過_uiHandler去post一個_ticker Runnable。
    while (!isInterrupted()) {
        // 3.1 這裏的_tick默認是0,所以needPost即爲true。
        boolean needPost = _tick == 0;
        // 這裏的_tick加上了默認的5000ms
        _tick += interval;
        if (needPost) {
            _uiHandler.post(_ticker);
        }

        // 接下來,線程會sleep一段時間,默認值爲5000ms。
        try {
            Thread.sleep(interval);
        } catch (InterruptedException e) {
            _interruptionListener.onInterrupted(e);
            return ;
        }

        // 4、如果主線程沒有處理Runnable,即_tick的值沒有被賦值爲0,則說明發生了ANR,第二個_reported標誌位是爲了避免重複報道已經處理過的ANR。
        if (_tick != 0 && !_reported) {
            //noinspection ConstantConditions
            if (!_ignoreDebugger && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) {
                Log.w("ANRWatchdog", "An ANR was detected but ignored because the debugger is connected (you can prevent this with setIgnoreDebugger(true))");
                _reported = true;
                continue ;
            }

            interval = _anrInterceptor.intercept(_tick);
            if (interval > 0) {
                continue;
            }

            final ANRError error;
            if (_namePrefix != null) {
                error = ANRError.New(_tick, _namePrefix, _logThreadsWithoutStackTrace);
            } else {
                // 5、如果沒有主動給ANR_Watchdog設置線程名,則會默認會使用ANRError的NewMainOnly方法去處理ANR。
                error = ANRError.NewMainOnly(_tick);
            }
           
           // 6、最後會通過ANRListener調用它的onAppNotResponding方法,其默認的處理會直接拋出當前的ANRError,導致程序崩潰。 _anrListener.onAppNotResponding(error);
            interval = _timeoutInterval;
            _reported = true;
        }
    }
}

首先,在註釋1處,我們將線程命名爲了|ANR-WatchDog|。接着,在註釋2處,聲明瞭一個默認的超時間隔時間,默認的值爲5000ms。然後,註釋3處,在while循環中通過_uiHandler去post一個_ticker Runnable。注意這裏的_tick默認是0,所以needPost即爲true。接下來,線程會sleep一段時間,默認值爲5000ms。在註釋4處,如果主線程沒有處理Runnable,即_tick的值沒有被賦值爲0,則說明發生了ANR,第二個_reported標誌位是爲了避免重複報道已經處理過的ANR。如果發生了ANR,就會調用接下來的代碼,開始會處理debug的情況,然後,我們看到註釋5處,如果沒有主動給ANR_Watchdog設置線程名,則會默認會使用ANRError的NewMainOnly方法去處理ANR。ANRError的NewMainOnly方法如下所示:

/**
 * The minimum duration, in ms, for which the main thread has been blocked. May be more.
 */
public final long duration;

static ANRError NewMainOnly(long duration) {
    // 1、獲取主線程的堆棧信息
    final Thread mainThread = Looper.getMainLooper().getThread();
    final StackTraceElement[] mainStackTrace = mainThread.getStackTrace();

    // 2、返回一個包含主線程名、主線程堆棧信息以及發生ANR的最小時間值的實例。
    return new ANRError(new $(getThreadTitle(mainThread), mainStackTrace).new _Thread(null), duration);
}

可以看到,在註釋1處,首先獲了主線程的堆棧信息,然後返回了一個包含主線程名、主線程堆棧信息以及發生ANR的最小時間值的實例。(我們可以改造其源碼在此時添加更多的卡頓現場信息,如CPU 使用率和調度信息、內存相關信息、I/O 和網絡相關的信息等等

接下來,我們再回到ANRWatchDog的run方法中的註釋6處,最後這裏會通過ANRListener調用它的onAppNotResponding方法,其默認的處理會直接拋出當前的ANRError,導致程序崩潰。對應的代碼如下所示:

private static final ANRListener DEFAULT_ANR_LISTENER = new ANRListener() {
    @Override public void onAppNotResponding(ANRError error) {
        throw error;
    }
};

瞭解了ANRWatchDog的實現原理之後,我們試一試它的效果如何。首先,我們給MainActivity中的懸浮按鈕添加主線程休眠10s的代碼,如下所示:

@OnClick({R.id.main_floating_action_btn})
void onClick(View view) {
    switch (view.getId()) {
        case R.id.main_floating_action_btn:
            try {
                // 對應項目中的第170行
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            jumpToTheTop();
            break;
        default:
            break;
    }
}

然後,我們重新安裝運行項目,點擊懸浮按鈕,發現在10s內都不能觸發屏幕點擊和觸摸事件,並且在10s之後,應用直接發生了崩潰。接着,我們在Logcat過濾欄中輸入fatal關鍵字,找出致命的錯誤,log如下所示:

2020-01-18 09:55:53.459 29924-29969/? E/AndroidRuntime: FATAL EXCEPTION: |ANR-WatchDog|
Process: json.chao.com.wanandroid, PID: 29924
com.github.anrwatchdog.ANRError: Application Not Responding for at least 5000 ms.
Caused by: com.github.anrwatchdog.ANRError$$$_Thread: main (state = TIMED_WAITING)
    at java.lang.Thread.sleep(Native Method)
    at java.lang.Thread.sleep(Thread.java:373)
    at java.lang.Thread.sleep(Thread.java:314)
    // 1
    at json.chao.com.wanandroid.ui.main.activity.MainActivity.onClick(MainActivity.java:170)
    at json.chao.com.wanandroid.ui.main.activity.MainActivity_ViewBinding$1.doClick(MainActivity_ViewBinding.java:45)
    at butterknife.internal.DebouncingOnClickListener.onClick(DebouncingOnClickListener.java:22)
    at android.view.View.performClick(View.java:6311)
    at android.view.View$PerformClick.run(View.java:24833)
    at android.os.Handler.handleCallback(Handler.java:794)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loop(Looper.java:173)
    at android.app.ActivityThread.main(ActivityThread.java:6653)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:821)
 Caused by: com.github.anrwatchdog.ANRError$$$_Thread: AndroidFileLogger./storage/emulated/0/Android/data/json.chao.com.wanandroid/log/ (state = RUNNABLE)

可以看到,發生崩潰的線程正是|ANR-WatchDog|。我們重點關注註釋1,這裏發生崩潰的位置是在MainActivity的onClick方法,對應的行數爲170行,從前可知,這裏正是線程休眠的地方。

接下來,我們來分析一下ANR-WatchDog的實現原理。

2、ANR-WatchDog原理

  • 首先,我們調用了ANR-WatchDog的start方法,然後這個線程就會開始工作。
  • 然後,我們通過主線程的Handler post一個消息將主線程的某個值進行一個加值的操作
  • post完成之後呢,我們這個線程就sleep一段時間。
  • 在sleep之後呢,它就會來檢測我們這個值有沒有被修改,如果這個值被修改了,那就說明我們在主線程中執行了這個message,即表明主線程沒有發生卡頓,否則,則說明主線程發生了卡頓
  • 最後,ANR-WatchDog就會判斷髮生了ANR,拋出一個異常給我們。

最後,ANR-WatchDog的工作流程簡圖如下所示:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-eHUvBwng-1581908980196)(https://raw.githubusercontent.com/JsonChao/Awesome-Android-Performance/master/screenshots/anr_watch_dog_implement.png)]

上面我們最後說到,如果檢測到主線程發生了卡頓,則會拋出一個ANR異常,這將會導致應用崩潰,顯然不能將這種方案帶到線上,那麼,有什麼方式能夠自定義最後發生卡頓時的處理過程嗎?

其實ANR-WatchDog自身就實現了一個我們自身也可以去實現的ANRListener,通過它,我們就可以對ANR事件去做一個自定義的處理,比如將堆棧信息壓縮後保存到本地,並在適當的時間上傳到APM後臺。

3、小結

ANR-WatchDog是一種非侵入式的ANR監控方案,它能夠彌補我們在高版本中沒有權限去讀取traces.txt文件的問題,需要注意的是,在線上這兩種方案我們需要結合使用。

在之前,我們還講到了AndroidPerformanceMonitor,那麼它和ANR-WatchDog有什麼區別呢?

對於AndroidPerformanceMonitor來說,它是監控我們主線程中每一個message的執行,它會在主線程的每一個message的前後打印一個時間戳,然後,我們就可以據此計算每一個message的具體執行時間,但是我們需要注意的是一個message的執行時間通常是非常短暫的,也就是很難達到ANR這個級別。然後我們來看看ANR-WatchDog的原理,它是不管應用是如何執行的,它只會看最終的結果,即sleep 5s之後,我就看主線程的這個值有沒有被更改。如果說被改過,就說明沒有發生ANR,否則,就表明發生了ANR

根據這兩個庫的原理,我們便可以判斷出它們分別的適用場景,對於AndroidPerformanceMonitor來說,它適合監控卡頓,因爲每一個message它執行的時間並不長。對於ANR-WatchDog來說,它更加適合於ANR監控的補充

此外,雖然ANR-WatchDog解決了在高版本系統沒有權限讀取 /data/anr/traces.txt 文件的問題,但是在Java層去獲取所有線程堆棧以及各種信息非常耗時,對於卡頓場景不一定合適,它可能會進一步加劇用戶的卡頓。如果是對性能要求比較高的應用,可以通過Hook Native層的方式去獲得所有線程的堆棧信息,具體爲如下兩個步驟:

通過這種方式就大致模擬了系統打印 ANR 日誌的流程,但是由於採用的是Hook方式,所以可能會產生一些異常甚至崩潰的情況,這個時候就需要通過 fork 子進程方式去避免這種問題,而且使用 子進程去獲取堆棧信息的方式可以做到完全不卡住我們主進程。

但是需要注意的是,fork 進程會導致進程號發生改變,此時需要通過指定 /proc/[父進程 id]的方式重新獲取應用主進程的堆棧信息

通過 Native Hook 的 方式我們實現了一套“無損”獲取所有 Java 線程堆棧與詳細信息的卡頓監控體系。爲了降低上報數據量,建議只有主線程的 Java 線程狀態是 WAITING、TIME_WAITING 或者 BLOCKED 的時候,纔去使用這套方案

二、卡頓單點問題檢測方案

除了自動化的卡頓與ANR監控之外,我們還需要進行卡頓單點問題的檢測,因爲上述兩種檢測方案的並不能滿足所有場景的檢測要求,這裏我舉一個小栗子:

比如我有很多的message要執行,但是每一個message的執行時間
都不到卡頓的閾值,那自動化卡頓檢測方案也就不能夠檢測出卡
頓,但是對用戶來說,用戶就覺得你的App就是有些卡頓。

除此之外,爲了建立體系化的監控解決方案,我們就必須在上線之前將問題儘可能地暴露出來

1、IPC單點問題檢測方案

常見的單點問題有主線程IPC、DB操作等等,這裏我就拿主線程IPC來說,因爲IPC其實是一個很耗時的操作,但是在實際開發過程中,我們可能對IPC操作沒有足夠的重視,所以,我們經常在主程序中去做頻繁IPC操作,所以說,這種耗時它可能並不到你設定卡頓的一個閾值,接下來,我們看一下,對於IPC問題,我們應該去監測哪些指標。

  • 1、IPC調用類型:如PackageManager、TelephoneManager的調用。
  • 2、每一個的調用次數與耗時。
  • 3、IPC的調用堆棧(表明哪行代碼調用的)、發生線程。

常規方案

常規方案就是在IPC的前後加上埋點。但是,這種方式不夠優雅,而且,在平常開發過程中我們經常忘記某個埋點的真正用處,同時它的維護成本也非常大

接下來,我們講解一下IPC問題監測的技巧。

IPC問題監測技巧

在線下,我們可以通過adb命令的方式來進行監測,如下所示:

// 1、首先,對IPC操作開始進行監控
adb shell am trace-ipc start
// 2、然後,結束IPC操作的監控,同時,將監控到的信息存放到指定的文件當中
adb shell am trace-ipc stop -dump-file /data/local/tmp/ipc-trace.txt
// 3、最後,將監控到的ipc-trace導出到電腦查看
adb pull /data/local/tmp/ipc-trace.txt

然後,這裏我們介紹一種優雅的實現方案,看過深入探索Android佈局優化(上)的同學可能知道這裏的實現方案無非就是ARTHook或AspectJ這兩種方案,這裏我們需要去監控IPC操作,那麼,我們應該選用哪種方式會更好一些呢?

要回答這個問題,就需要我們對ARTHook和AspectJ這兩者的思想有足夠的認識,對應ARTHook來說,其實我們可以用它來去Hook系統的一些方法,因爲對於系統代碼來說,我們無法對它進行更改,但是我們可以Hook住它的一個方法,在它的方法體裏面去加上自己的一些代碼。但是,對於AspectJ來說,它只能針對於那些非系統方法,也就是我們App自己的源碼,或者是我們所引用到的一些jar、aar包。因爲AspectJ實際上是往我們的具體方法裏面插入相對應的代碼,所以說,他不能夠針對於我們的系統方法去做操作,在這裏,我們就需要採用ARTHook的方式去進行IPC操作的監控

在使用ARTHook去監控IPC操作之前,我們首先思考一下,哪些操作是IPC操作呢?

比如說,我們通過PackageManager去拿到我們應用的一些信息,或者去拿到設備的DeviceId這樣的信息以及AMS相關的信息等等,這些其實都涉及到了IPC的操作,而這些操作都會通過固定的方式進行IPC,並最終會調用到android.os.BinderProxy,接下來,我們來看看它的transact方法,如下所示:

public boolean transact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {

這裏我們僅僅關注transact方法的參數即可,第一個參數是一個行動編碼,爲int類型,它是在FIRST_CALL_TRANSACTION與LAST_CALL_TRANSACTION之間的某個值,第二、三個參數都是Parcel類型的參數,用於獲取和回覆相應的數據,第四個參數爲一個int類型的標記值,爲0表示一個正常的IPC調用,否則表明是一個單向的IPC調用。然後,我們在項目中的Application的onCreate方法中使用ARTHook對android.os.BinderProxy類的transact方法進行Hook,代碼如下所示:

try {
        DexposedBridge.findAndHookMethod(Class.forName("android.os.BinderProxy"), "transact",
                int.class, Parcel.class, Parcel.class, int.class, new XC_MethodHook() {
                    @Override
                    protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                        LogHelper.i( "BinderProxy beforeHookedMethod " + param.thisObject.getClass().getSimpleName()
                                + "\n" + Log.getStackTraceString(new Throwable()));
                        super.beforeHookedMethod(param);
                    }
                });
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }

重新安裝應用,即可看到如下的Log信息:

2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ WanAndroidApp$1.beforeHookedMethod  (WanAndroidApp.java:160)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │    LogHelper.i  (LogHelper.java:37)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ [WanAndroidApp.java | 160 | beforeHookedMethod] BinderProxy beforeHookedMethod BinderProxy
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ java.lang.Throwable
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at json.chao.com.wanandroid.app.WanAndroidApp$1.beforeHookedMethod(WanAndroidApp.java:160)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at com.taobao.android.dexposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:237)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at me.weishu.epic.art.entry.Entry64.onHookBoolean(Entry64.java:72)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:237)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at me.weishu.epic.art.entry.Entry64.booleanBridge(Entry64.java:86)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.os.ServiceManagerProxy.getService(ServiceManagerNative.java:123)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.os.ServiceManager.getService(ServiceManager.java:56)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.os.ServiceManager.getServiceOrThrow(ServiceManager.java:71)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.UiModeManager.<init>(UiModeManager.java:127)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.SystemServiceRegistry$42.createService(SystemServiceRegistry.java:511)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.SystemServiceRegistry$42.createService(SystemServiceRegistry.java:509)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.SystemServiceRegistry$CachedServiceFetcher.getService(SystemServiceRegistry.java:970)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.SystemServiceRegistry.getSystemService(SystemServiceRegistry.java:920)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.ContextImpl.getSystemService(ContextImpl.java:1677)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.view.ContextThemeWrapper.getSystemService(ContextThemeWrapper.java:171)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.Activity.getSystemService(Activity.java:6003)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.support.v7.app.AppCompatDelegateImplV23.<init>(AppCompatDelegateImplV23.java:33)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.support.v7.app.AppCompatDelegateImplN.<init>(AppCompatDelegateImplN.java:31)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.support.v7.app.AppCompatDelegate.create(AppCompatDelegate.java:198)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.support.v7.app.AppCompatDelegate.create(AppCompatDelegate.java:183)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.support.v7.app.AppCompatActivity.getDelegate(AppCompatActivity.java:519)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.support.v7.app.AppCompatActivity.onCreate(AppCompatActivity.java:70)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at me.yokeyword.fragmentation.SupportActivity.onCreate(SupportActivity.java:38)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at json.chao.com.wanandroid.base.activity.AbstractSimpleActivity.onCreate(AbstractSimpleActivity.java:29)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at json.chao.com.wanandroid.base.activity.BaseActivity.onCreate(BaseActivity.java:37)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.Activity.performCreate(Activity.java:7098)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.Activity.performCreate(Activity.java:7089)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1215)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2770)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2895)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.ActivityThread.-wrap11(Unknown Source:0)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1616)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.os.Handler.dispatchMessage(Handler.java:106)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.os.Looper.loop(Looper.java:173)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at android.app.ActivityThread.main(ActivityThread.java:6653)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at java.lang.reflect.Method.invoke(Native Method)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
2020-01-22 19:52:47.657 10683-10683/json.chao.com.wanandroid I/WanAndroid-LOG: │ 	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:821)

可以看出,這裏彈出了應用中某一個IPC調用的所有堆棧信息。在這裏,具體是在AbstractSimpleActivity的onCreate方法中調用了ServiceManager的getService方法,它是一個IPC調用的方法。這樣,應用的IPC調用我們就能很方便地捕獲到了。

大家可以看到,通過這種方式我們可以很方便地拿到應用中所有的IPC操作,並可以獲得到IPC調用的類型、調用耗時、發生次數、調用的堆棧等等一系列信息。當然,除了IPC調用的問題之外,還有IO、DB、View繪製等一系列單點問題需要去建立與之對應的檢測方案。

2、卡頓問題檢測方案

對於卡頓問題檢測方案的建設,主要是利用ARTHook去完善線下的檢測工具,儘可能地去Hook相對應的操作,以暴露、分析問題。這樣,才能更好地實現卡頓的體系化解決方案。

三、如何實現界面秒開?

界面的打開速度對用戶體驗來說是至關重要的,那麼如何實現界面秒開呢?

其實界面秒開就是一個小的啓動優化,其優化的思想可以借鑑啓動速度優化與佈局優化的一些實現思路

1、界面秒開實現

首先,我們可以通過Systrace來觀察CPU的運行狀況,比如有沒有跑滿CPU;然後,我們在啓動優化中學習到的優雅異步以及優雅延遲初始化等等一些方案;其次,針對於我們的界面佈局,我們可以使用異步Inflate、X2C、其它的繪製優化措施等等;最後,我們可以使用預加載的方式去提前獲取頁面的數據,以避免網絡或磁盤IO速度的影響,或者也可以將獲取數據的方法放到onCreate方法的第一行

那麼我們如何去衡量界面的打開速度呢?

通常,我們是通過界面秒開率去統計頁面的打開速度的,具體就是計算onCreate到onWindowFocusChanged的時間。當然,在某些特定的場景下,把onWindowFocusChanged作爲頁面打開的結束點並不是特別的精確,那我們可以去實現一個特定的接口來適配我們的Activity或Fragment,我們可以把那個接口方法作爲頁面打開的結束點

那麼,除了以上說到的一些界面秒開的實現方式之外,還沒有更好的方式呢?

那就是Lancet。

2、Lancet

Lancet是一個輕量級的Android AOP框架,它具有如下優勢:

  • 1、編譯速度快,支持增量編譯。
  • 2、API簡單,沒有任何多餘代碼插入apk。(這一點對應包體積優化時至關重要的)

然後,我來簡單地講解下Lancet的用法。Lancet自身提供了一些註解用於Hook,如下所示:

  • @Prxoy:通常是用於對系統API調用的Hook。
  • @Insert:經常用於操作App或者是Library當中的一些類。

接下來,我們就是使用Lancet來進行一下實戰演練。

首先,我們需要在項目根目錄的 build.gradle 添加如下依賴:

dependencies{
    classpath 'me.ele:lancet-plugin:1.0.5'
}

然後,在 app 目錄的’build.gradle’ 添加:

apply plugin: 'me.ele.lancet'

dependencies {
    compileOnly 'me.ele:lancet-base:1.0.5'
}

接下來,我們就可以使用Lancet了,這裏我們需要先新建一個類去進行專門的Hook操作,如下所示:

public class ActivityHooker {

    @Proxy("i")
    @TargetClass("android.util.Log")
    public static int i(String tag, String msg) {
        msg = msg + "JsonChao";
        return (int) Origin.call();
    }
}

上述的方法就是對android.util.Log的i方法進行Hook,並在所有的msg後面加上"JsonChao"字符串,注意這裏的i方法我們需要從android.util.Log裏面將它的i方法複製過來,確保方法名和對應的參數信息一致;然後,方法上面的@TargetClass與@Proxy分別是指定對應的全路徑類名與方法名;最後,我們需要通過Lancet提供的Origin類去調用它的call方法來實現返回原來的調用信息。完成之後,我們重新運行項目,會出現如下log信息:

2020-01-23 13:13:34.124 7277-7277/json.chao.com.wanandroid I/MultiDex: VM with version 2.1.0 has multidex supportJsonChao
2020-01-23 13:13:34.124 7277-7277/json.chao.com.wanandroid I/MultiDex: Installing applicationJsonChao

可以看到,log後面都加上了我們預先添加的字符串,說明Hook成功了。下面,我們就可以用Lancet來統計一下項目界面的秒開率了,代碼如下所示:

public static ActivityRecord sActivityRecord;

static {
    sActivityRecord = new ActivityRecord();
}

@Insert(value = "onCreate",mayCreateSuper = true)
@TargetClass(value = "android.support.v7.app.AppCompatActivity",scope = Scope.ALL)
protected void onCreate(Bundle savedInstanceState) {
    sActivityRecord.mOnCreateTime = System.currentTimeMillis();
    // 調用當前Hook類方法中原先的邏輯
    Origin.callVoid();
}

@Insert(value = "onWindowFocusChanged",mayCreateSuper = true)
@TargetClass(value = "android.support.v7.app.AppCompatActivity",scope = Scope.ALL)
public void onWindowFocusChanged(boolean hasFocus) {
    sActivityRecord.mOnWindowsFocusChangedTime = System.currentTimeMillis();
    LogHelper.i(getClass().getCanonicalName() + " onWindowFocusChanged cost "+(sActivityRecord.mOnWindowsFocusChangedTime - sActivityRecord.mOnCreateTime));
    Origin.callVoid();
}

上面,我們通過@TargetClass和@Insert兩個註解實現Hook了android.support.v7.app.AppCompatActivity的onCreate與onWindowFocusChanged方法。我們注意到,這裏@Insert註解可以指定兩個參數,其源碼如下所示:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Insert {
    String value();

    boolean mayCreateSuper() default false;
}

第二個參數mayCreateSuper設定爲true則表明如果沒有重寫父類的方法,則會默認去重寫這個方法。對應到我們ActivityHooker裏面實現的@Insert註解方法就是如果當前的Activity沒有重寫父類的onCreate和
onWindowFocusChanged方法,則此時默認會去重寫父類的這個方法,以避免因某些Activity不存在該方法而Hook失敗的情況

然後,我們注意到@TargetClass也可以指定兩個參數,其源碼如下所示:

@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target({ElementType.TYPE, ElementType.METHOD})
public @interface TargetClass {
    String value();

    Scope scope() default Scope.SELF;
}

第二個參數scope指定的值是一個枚舉,可選的值如下所示:

public enum Scope {

    SELF,
    DIRECT,
    ALL,
    LEAF
}

對於Scope.SELF,它代表僅匹配目標value所指定的一個匹配類;對於DIRECT,它代表匹配value所指定的類的一個直接子類;如果是Scope.ALL,它就表明會去匹配value所指定的類的所有子類,而我們上面指定的value值爲android.support.v7.app.AppCompatActivity,因爲scope指定爲了Scope.ALL,則說明會去匹配AppCompatActivity的所有子類。而最後的Scope.LEAF 代表匹配 value 指定類的最終子類,因爲java是單繼承,所以繼承關係是樹形結構,所以這裏代表了指定類爲頂點的繼承樹的所有葉子節點。

最後,我們設定了一個ActivityRecord類去記錄onCreate與onWindowFocusChanged的時間戳,如下所示:

public class ActivityRecord {

    /**
    * 避免沒有僅執行onResume就去統計界面打開速度的情況,如息屏、亮屏等等
    */
    public boolean isNewCreate;

    public long mOnCreateTime;
    public long mOnWindowsFocusChangedTime;
}

通過sActivityRecord.mOnWindowsFocusChangedTime - sActivityRecord.mOnCreateTime得到的時間即爲界面的打開速度,最後,重新運行項目,會得到如下log信息:

2020-01-23 14:12:16.406 15098-15098/json.chao.com.wanandroid I/WanAndroid-LOG: │ [null | 57 | json_chao_com_wanandroid_aop_ActivityHooker_onWindowFocusChanged] json.chao.com.wanandroid.ui.main.activity.SplashActivity onWindowFocusChanged cost 257
2020-01-23 14:12:18.930 15098-15098/json.chao.com.wanandroid I/WanAndroid-LOG: │ [null | 57 | json_chao_com_wanandroid_aop_ActivityHooker_onWindowFocusChanged] json.chao.com.wanandroid.ui.main.activity.MainActivity onWindowFocusChanged cost 608

從上面的log信息,我們就可以知道 SplashActivity 和 MainActivity 的界面打開速度分別是257ms和608ms。

最後,我們來看下界面秒開的監控緯度。

3、界面秒開監控緯度

對於界面秒開的監控緯度,主要分爲以下三個方面:

  • 總體耗時
  • 生命週期耗時
  • 生命週期間隔耗時

首先,我們會監控界面打開的整體耗時,也就是onCreate到onWindowFocusChanged這個方法的耗時;當然,如果我們是在一個特殊的界面,我們需要更精確的知道界面打開的一個時間,這個我們可以用自定義的接口去實現。其次,我們也需要去監控生命週期的一個耗時,如onCreate、onStart、onResume等等。最後,我們也需要去做生命週期間隔的耗時監控,這點經常被我們所忽略,比如onCreate的結束到onStart開始的這一段時間,也是有時間損耗的,我們可以監控它是不是在一個合理的範圍之內。通過這三個方面的監控緯度,我們就能夠非常細粒度地去檢測頁面秒開各個方面的情況

四、優雅監控耗時盲區

儘管我們在應用中監控了很多的耗時區間,但是還是有一些耗時區間我們還沒有捕捉到,如onResume到列表展示的間隔時間,這些時間在我們的統計過程中很容易被忽視,這裏我們舉一個小栗子:

我們在Activity的生命週期中post了一個message,那這個message很可能其中
執行了一段耗時操作,那你知道這個message它的具體執行時間嗎?這個message其實
很有可能在列表展示之前就執行了,如果這個message耗時1s,那麼列表的展示
時間就會延遲1s,如果是200ms,那麼我們設定的自動化卡頓檢測就無法
發現它,那麼列表的展示時間就會延遲200ms。

其實這種場景非常常見,接下來,我們就在項目中來進行實戰演練。

首先,我們在MainActivity的onCreate中加上post消息的一段代碼,其中模擬了延遲1000ms的耗時操作,代碼如下所示:

// 以下代碼是爲了演示Msg導致的主線程卡頓
    new Handler().post(() -> {
        LogHelper.i("Msg 執行");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });

接着,我們在RecyclerView對應的Adapter中將列表展示的時間打印出來,如下所示:

if (helper.getLayoutPosition() == 1 && !mHasRecorded) {
        mHasRecorded = true;
        helper.getView(R.id.item_search_pager_group).getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                helper.getView(R.id.item_search_pager_group).getViewTreeObserver().removeOnPreDrawListener(this);
                LogHelper.i("FeedShow");
                return true;
            }
        });
    }

最後,我們重新運行下項目,看看兩者的執行時間,log信息如下:

2020-01-23 15:21:55.076 19091-19091/json.chao.com.wanandroid I/WanAndroid-LOG: │ [MainActivity.java | 108 | lambda$initEventAndData$1$MainActivity] Msg 執行
2020-01-23 15:21:56.264 19091-19091/json.chao.com.wanandroid I/WanAndroid-LOG: │ [null | 57 | json_chao_com_wanandroid_aop_ActivityHooker_onWindowFocusChanged] json.chao.com.wanandroid.ui.main.activity.MainActivity onWindowFocusChanged cost 1585
2020-01-23 15:21:57.207 19091-19091/json.chao.com.wanandroid I/WanAndroid-LOG: │ ArticleListAdapter$1.onPreDraw  (ArticleListAdapter.java:93)
2020-01-23 15:21:57.208 19091-19091/json.chao.com.wanandroid I/WanAndroid-LOG: │    LogHelper.i  (LogHelper.java:37)
2020-01-23 15:21:57.208 19091-19091/json.chao.com.wanandroid I/WanAndroid-LOG: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
2020-01-23 15:21:57.208 19091-19091/json.chao.com.wanandroid I/WanAndroid-LOG: │ [ArticleListAdapter.java | 93 | onPreDraw] FeedShow

從log信息中可以看到,MAinActivity的onWindowFocusChanged方法延遲了1000ms才被調用,與此同時,列表頁時延遲了1000ms才展示出來。也就是說,post的這個message消息是執行在界面、列表展示之前的。因爲任何一個開發都有可能在某一個生命週期或者是某一個階段以及一些第三方的SDK裏面,回去做一些handler post的相關操作,這樣,他的handler post的message的執行,很有可能在我們的界面或列表展示之前就被執行,所以說,出現這種耗時的盲區是非常普遍的,而且也不好排查,下面,我們分析下耗時盲區存在的難點。

1、耗時盲區監控難點

首先,我們可以通過細化監控的方式去獲取耗時的一些盲區,但是我們卻不知道在這個盲區中它執行了什麼操作。其次,對於線上的一些耗時盲區,我們是無法進行排查的。

這裏,我們先來看看如何建立耗時盲區監控的線下方案。

2、耗時盲區監控線下方案

這裏我們直接使用TraceView去檢測即可,因爲它能夠清晰地記錄線程在具體的時間內到底做了什麼操作,特別適合一段時間內的盲區監控。

然後,我們來看下如何建立耗時盲區監控的線上方案。

3、耗時盲區監控線上方案

我們知道主線程的所有方法都是通過message來執行的,還記得在之前我們學習了一個庫:AndroidPerformanceMonitor,我們是否可以通過這個mLogging來做盲區檢測呢?通過這個mLogging確實可以知道我們主線程發生的message,但是通過mLogging無法獲取具體的調用棧信息,因爲它所獲取的調用棧信息都是系統回調回來的,它並不知道當前的message是被誰拋出來的,所以說,這個方案並不夠完美。

那麼,我們是否可以通過AOP的方式去切Handler方法呢?比如sendMessage、sendMessageDeleayd方法等等,這樣我們就可以知道發生message的一個堆棧,但是這種方案也存在着一個問題,就是它不清楚準確的執行時間,我們切了這個handler的方法,僅僅只知道它具體是在哪個地方被髮的和它所對應的堆棧信息,但是無法獲取準確的執行時間。如果我們想知道在onResume到列表展示之間執行了哪些message,那麼通過AOP的方式也無法實現。

那麼,最終的耗時盲區監控的一個線上方案就是使用一個統一的Handler,定製了它的兩個方法,一個是sendMessageAtTime,另外一個是dispatchMessage方法。因爲對於發送message,不管調用哪個方法最終都會調用到一個是sendMessageAtTime這個方法,而處理message呢,它最終會調用dispatchMessage方法。然後,我們需要定製一個gradle插件,來實現自動化的接入我們定製好的handler,通過這種方式,我們就能在編譯期間去動態地替換所有使用Handler的父類爲我們定製好的這個handler。這樣,在整個項目中,所有的sendMessage和handleMessage都會經過我們的回調方法。接下來,我們來進行一下實戰演練。

首先,我這裏給出定製好的全局Handler類,如下所示:

public class GlobalHandler extends Handler {

    private long mStartTime = System.currentTimeMillis();

    public GlobalHandler() {
        super(Looper.myLooper(), null);
    }

    public GlobalHandler(Callback callback) {
        super(Looper.myLooper(), callback);
    }

    public GlobalHandler(Looper looper, Callback callback) {
        super(looper, callback);
    }

    public GlobalHandler(Looper looper) {
        super(looper);
    }

    @Override
    public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
        boolean send = super.sendMessageAtTime(msg, uptimeMillis);
        // 1
        if (send) {
            GetDetailHandlerHelper.getMsgDetail().put(msg, Log.getStackTraceString(new Throwable()).replace("java.lang.Throwable", ""));
        }
        return send;
    }

    @Override
    public void dispatchMessage(Message msg) {
        mStartTime = System.currentTimeMillis();
        super.dispatchMessage(msg);

        if (GetDetailHandlerHelper.getMsgDetail().containsKey(msg)
            && Looper.myLooper() == Looper.getMainLooper()) {
            JSONObject jsonObject = new JSONObject();
            try {
                // 2
                jsonObject.put("Msg_Cost", System.currentTimeMillis() - mStartTime);
                jsonObject.put("MsgTrace", msg.getTarget() + " " + GetDetailHandlerHelper.getMsgDetail().get(msg));

                // 3
                LogHelper.i("MsgDetail " + jsonObject.toString());
                GetDetailHandlerHelper.getMsgDetail().remove(msg);
            } catch (Exception e) {
            }
        }
    }
}

上面的GlobalHandler將會是我們項目中所有Handler的一個父類。在註釋1處,我們在sendMessageAtTime這個方法裏面判斷如果message發送成功,將會把當前message對象對應的調用棧信息都保存到一個ConcurrentHashMap中,GetDetailHandlerHelper類的代碼如下所示:

public class GetDetailHandlerHelper {

    private static ConcurrentHashMap<Message, String> sMsgDetail = new ConcurrentHashMap<>();

    public static ConcurrentHashMap<Message, String> getMsgDetail() {
        return sMsgDetail;
    }
}

這樣,我們就能夠知道這個message它是被誰發送過來的。然後,在dispatchMessage方法裏面,我們可以計算拿到其處理消息的一個耗時,並在註釋2處將這個耗時保存到一個jsonObject對象中,同時,我們也可以通過GetDetailHandlerHelper類的ConcurrentHashMap對象拿到這個message對應的堆棧信息,並在註釋3處將它們輸出到log控制檯上。當然,如果是線上監控,則會把這些信息保存到本地,然後選擇合適的時間去上傳。最後,我們還可以在方法體裏面做一個判斷,我們設置一個閾值,比如閾值爲20ms,超過了20ms就把這些保存好的信息上報到APM後臺。

在前面的實戰演練中,我們使用了handler post的方式去發送一個消息,通過gradle插件將所有handler的父類替換爲我們定製好的GlobalHandler之後,我們就可以優雅地去監控應用中的耗時盲區了。

對於實現全局替換handler的gradle插件,除了使用AspectJ實現之外,這裏推薦一個已有的項目:DroidAssist

然後,重新運行項目,關鍵的log信息如下所示:

MsgDetail {"Msg_Cost":1001,"MsgTrace":"Handler (com.json.chao.com.wanandroid.performance.handler.GlobalHandler) {b0d4d48} \n\tat 
com.json.chao.com.wanandroid.performance.handler.GlobalHandler.sendMessageAtTime(GlobalHandler.java:36)\n\tat
json.chao.com.wanandroid.ui.main.activity.MainActivity.initEventAndData$__twin__(MainActivity.java:107)\n\tat"

從以上信息我們不僅可以知道message執行的時間,還可以從對應的堆棧信息中得到發送message的位置,這裏的位置是MainActivity的107行,也就是new Handler().post()這一行代碼。使用這種方式我們就可以知道在列表展示之前到底執行了哪些自定義的message,我們一眼就可以知道哪些message其實是不符合我們預期的,比如說message的執行時間過長,或者說這個message其實可以延後執行,這個我們都可以根據實際的項目和業務需求進行相應地修改

4、耗時盲區監控方案總結

耗時盲區監控是我們卡頓監控中不可或缺的一個環節,也是卡頓監控全面性的一個重要保障。而需要注意的是,TraceView僅僅適用於線下的一個場景,同時對於TraceView來說,它可以用於監控我們系統的message。而最後介紹的動態替換的方式其實是適合於線上的,同時,它僅僅監控應用自身的一個message。

五、卡頓優化技巧總結

1、卡頓優化實踐經驗

如果應用出現了卡頓現象,那麼可以考慮以下方式進行優化:

  • 首先,對於耗時的操作,我們可以考慮異步或延遲初始化的方式,這樣可以解決大多數的問題。但是,大家一定要注意代碼的優雅性。
  • 對於佈局加載優化,可以採用AsyncLayoutInflater或者是X2C的方式來優化主線程IO以及反射導致的消耗,同時,需要注意,對於重繪問題,要給與一定的重視。
  • 此外,內存問題也可能會導致應用界面的卡頓,我們可以通過降低內存佔用的方式來減少GC的次數以及時間,而GC的次數和時間我們可以通過log查看。

然後,我們來看看卡頓優化的工具建設。

2、卡頓優化工具建設

工具建設這塊經常容易被大家所忽視,但是它的收益卻非常大,也是卡頓優化的一個重點。首先,對於系統工具而言,我們要有一個認識,同時一定要學會使用它,這裏我們再回顧一下。

  • 對於Systrace來說,我們可以很方便地看出來它的CPU使用情況。另外,它的開銷也比較小。
  • 對於TraceView來說,我們可以很方便地看出來每一個線程它在特定的時間內做了什麼操作,但是TraceView它的開銷相對比較大,有時候可能會被帶偏優化方向。
  • 同時,需要注意,StrictMode也是一個非常強大的工具。

然後,我們介紹了自動化工具建設以及優化方案。我們介紹了兩個工具,AndroidPerformanceMonitor以及ANR-WatchDog。同時針對於AndroidPerformanceMonitor的問題,我們採用了高頻採集,以找出重複率高的堆棧這樣一種方式進行優化,在學習的過程中,我們不僅需要學會怎樣去使用工具,更要去理解它們的實現原理以及各自的使用場景。

同時,我們對於卡頓優化工具的建設也做了細化,對於單點問題,比如說IPC監控,我們通過Hook的手段來做到儘早的發現問題。對於耗時盲區的監控,我們在線上採用的是替換Handler的方式來監控所有子線程message執行的耗時以及調用堆棧

最後,我們來看一下卡頓監控的指標。我們會計算應用整體的卡頓率,ANR率、界面秒開率以及交換時間、生命週期時間等等。在上報ANR信息的同時,我們也需要上報環境和場景信息,這樣不僅方便我們在不同版本之間進行橫向對比,同時,也可以結合我們的報警平臺在第一時間感知到異常

六、常見卡頓問題解決方案總結

1、CPU資源爭搶引發的卡頓問題如何解決?

此時,我們的應用不僅應該控制好核心功能的CPU消耗,也需要儘量減少非核心需求的CPU消耗。

2、要注意Android Java中提供的哪些低效的API?

比如List.removeall方法,它內部會遍歷一次需要過濾的消息列表,在已經存在循環列表的情況下會造成CPU資源的冗餘使用,此時應該去優化相關的算法,避免使用List.removeall這個方法。

3、如何減少圖形處理的CPU消耗?

這個時候我們需要使用神器renderscript來圖形處理的相關運算,將CPU轉換到GPU。關於renderscript的背景知識可以看看筆者之前寫的深入探索Android佈局優化(下)

4、硬件加速長中文字體渲染時造成的卡頓如何解決?

此時只能關閉文本TextView的硬件加速,如下所示:

textView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);

當開啓了硬件加速進行長中文字體的渲染時,首先會調用ViewRootImpl.draw()方法,最後會調用GLES20Canvas.nDrawDisplayList()方法開始通過JNI調整到Native層。在這個方法裏,會繼續調用OpenGLRenderer.drawDisplayList()方法,它通過調用DisplayList的replay方法,以回放前面錄製的DisplayList執行繪製操作

DisplayList的replay方法會遍歷DisplayList中保存的每一個操作。其中渲染字體的操作名是DrawText,當遍歷到一個DrawText操作時,會調用OpenGLRender::drawText方法區渲染字體。最終,會在OpenGLRender::drawText方法裏去調用Font::render()方法渲染字體,而在這個方法中有一個很關鍵的操作,即獲取字體緩存。我們都知道每一箇中文的編碼都是不同的,因此中文的緩存效果非常不理想,但是對於英文而言,只需要緩存26個字母就可以了。在Android 4.1.2版本之前對文本的Buffer設置過小,所以情況比較嚴重,如果你的應用在其它版本的渲染性能尚可,就可以僅僅把Android 4.0.x的硬件加速關閉,代碼如下所示:

// AndroidManifest中
<Applicaiton
        ...
        android:hardwareAccelerated="@bool/hardware_acceleration">
        
// value-v14、value-v15中設置相應的Bool
值即可
<bool name="hardware_acceleration">false</bool>

此外,硬件渲染還有一些其它的問題在使用時需要注意,具體爲如下所示:

  • 1、在軟件渲染的情況下,如果需要重繪某個父View的所有子View,只需要調用這個Parent View的invalidate()方法即可,但如果開啓了硬件加速,這麼做是行不通的,需要遍歷整個子View並調用invalidate()。
  • 2、在軟件渲染的情況下,會常常使用Bitmap重用的方式來節省內存,但是如果開啓了硬件加速,這將會無效。
  • 3、當開啓硬件加速的UI在前臺運行時,需要耗費額外的內存。當硬件加速的UI切換到後臺時,上述額外內存有可能不會釋放,這大多存在於Android 4.1.2版本中。
  • 4、長或寬大於2048像素的Bitmap無法繪製,顯示爲一片透明。原因是OpenGL的材質大小上限爲2048 * 2048,因此對於超過2048像素的Bitmap,需要將其切割成2048 * 2048以內的圖片塊,最後在顯示的時候拼起來。
  • 5、當UI中存在過渡繪製時,可能會發生花屏,一般來說繪製少於5層不會出現花屏現象,如果有大塊紅色區域就要十分小心了。
  • 6、需要注意,關於LAYER_TYPE_SOFTWARE,雖然無論在App打開硬件加速或沒有打開硬件加速的時候,都會通過軟件繪製Bitmap作爲離屏緩存,但區別在於打開硬件加速的時候,Bitmap最終還會通過硬件加速方式drawDisplayList去渲染這個Bitmap。

七、卡頓優化的常見問題

1、你是怎麼做卡頓優化的?

從項目的初期到壯大期,最後再到成熟期,每一個階段都針對卡頓優化做了不同的處理。各個階段所做的事情如下所示:

  • 1、系統工具定位、解決
  • 2、自動化卡頓方案及優化
  • 3、線上監控及線下監測工具的建設

我做卡頓優化也是經歷了一些階段,最初我們的項目當中的一些模塊出現了卡頓之後,我是通過系統工具進行了定位,我使用了Systrace,然後看了卡頓週期內的CPU狀況,同時結合代碼,對這個模塊進行了重構,將部分代碼進行了異步和延遲,在項目初期就是這樣解決了問題。

但是呢,隨着我們項目的擴大,線下卡頓的問題也越來越多,同時,在線上,也有卡頓的反饋,但是線上的反饋卡頓,我們在線下難以復現,於是我們開始尋找自動化的卡頓監測方案,其思路是來自於Android的消息處理機制,主線程執行任何代碼都會回到Looper.loop方法當中,而這個方法中有一個mLogging對象,它會在每個message的執行前後都會被調用,我們就是利用這個前後處理的時機來做到的自動化監測方案的。同時,在這個階段,我們也完善了線上ANR的上報,我們採取的方式就是監控ANR的信息,同時結合了ANR-WatchDog,作爲高版本沒有文件權限的一個補充方案。

在做完這個卡頓檢測方案之後呢,我們還做了線上監控及線下檢測工具的建設,最終實現了一整套完善,多維度的解決方案。

2、你是怎麼樣自動化的獲取卡頓信息?

我們的思路是來自於Android的消息處理機制,主線程執行任何代碼它都會走到Looper.loop方法當中,而這個函數當中有一個mLogging對象,它會在每個message處理前後都會被調用,而主線程發生了卡頓,那就一定會在dispatchMessage方法中執行了耗時的代碼,那我們在這個message執行之前呢,我們可以在子線程當中去postDelayed一個任務,這個Delayed的時間就是我們設定的閾值,如果主線程的messaege在這個閾值之內完成了,那就取消掉這個子線程當中的任務,如果主線程的message在閾值之內沒有被完成,那子線程當中的任務就會被執行,它會獲取到當前主線程執行的一個堆棧,那我們就可以知道哪裏發生了卡頓。

經過實踐,我們發現這種方案獲取的堆棧信息它不一定是準確的,因爲獲取到的堆棧信息它很可能是主線程最終執行的一個位置,而真正耗時的地方其實已經執行完成了,於是呢,我們就對這個方案做了一些優化,我們採取了高頻採集的方案,也就是在一個週期內我們會多次採集主線程的堆棧信息,如果發生了卡頓,那我們就將這些卡頓信息壓縮之後上報給APM後臺,然後找出重複的堆棧信息,這些重複發生的堆棧大概率就是卡頓發生的一個位置,這樣就提高了獲取卡頓信息的一個準確性。

3、卡頓的一整套解決方案是怎麼做的?

首先,針對卡頓,我們採用了線上、線下工具相結合的方式,線下工具我們冊中醫藥儘可能早地去暴露問題,而針對於線上工具呢,我們側重於監控的全面性、自動化以及異常感知的靈敏度。

同時呢,卡頓問題還有很多的難題。比如說有的代碼呢,它不到你卡頓的一個閾值,但是執行過多,或者它錯誤地執行了很多次,它也會導致用戶感官上的一個卡頓,所以我們在線下通過AOP的方式對常見的耗時代碼進行了Hook,然後對一段時間內獲取到的數據進行分析,我們就可以知道這些耗時的代碼發生的時機和次數以及耗時情況。然後,看它是不是滿足我們的一個預期,不滿足預期的話,我們就可以直接到線下進行修改。同時,卡頓監控它還有很多容易被忽略的一個盲區,比如說生命週期的一個間隔,那對於這種特定的問題呢,我們就採用了編譯時註解的方式修改了項目當中所有Handler的父類,對於其中的兩個方法進行了監控,我們就可以知道主線程message的執行時間以及它們的調用堆棧。

對於線上卡頓,我們除了計算App的卡頓率、ANR率等常規指標之外呢,我們還計算了頁面的秒開率、生命週期的執行時間等等。而且,在卡頓發生的時刻,我們也儘可能多地保存下來了當前的一個場景信息,這爲我們之後解決或者復現這個卡頓留下了依據。

八、總結

恭喜你,如果你看到了這裏,你會發現要做好應用的卡頓優化的確不是一件簡單的事,它需要你有成體系的知識構建基底。最後,我們再來回顧一下面對卡頓優化,我們已經探索的以下九大主題:

  • 1、卡頓優化分析方法與工具:背景介紹、卡頓分析方法之使用shell命令分析CPU耗時、卡頓優化工具。
  • 2、自動化卡頓檢測方案及優化:卡頓檢測方案原理、AndroidPerformanceMonitor實戰及其優化。
  • 3、ANR分析與實戰:ANR執行流程、線上ANR監控方式、ANR-WatchDog原理。
  • 4、卡頓單點問題檢測方案:IPC單點問題檢測方案、卡頓問題檢測方案。
  • 5、如何實現界面秒開?:界面秒開實現、Lancet、界面秒開監控緯度。
  • 6、優雅監控耗時盲區:耗時盲區監控難點以及線上與線下的監控方案。
  • 7、卡頓優化技巧總結:卡頓優化實踐經驗、卡頓優化工具建設。
  • 8︎、常見卡頓問題解決方案總結
  • 9、卡頓優化的常見問題

相信看到這裏,你一定收穫滿滿,但是要記住,方案再好,也只有自己動手去實踐,才能真正地掌握它。只有重視實踐,充分運用感性認知潛能,在項目中磨鍊自己,纔是正確的學習之道。在實踐中,在某些關鍵動作上刻意練習,也會取得事半功倍的效果。

參考鏈接:

1、國內Top團隊大牛帶你玩轉Android性能分析與優化 第6章 卡頓優化

2、極客時間之Android開發高手課 卡頓優化

3、《Android移動性能實戰》第四章 CPU

4、《Android移動性能實戰》第七章 流暢度

5、Android dumpsys cpuinfo 信息解讀

6、如何清楚易懂的解釋“UV和PV"的定義?

7、nanoscope-An extremely accurate Android method tracing tool

8、DroidAssist-A lightweight Android Studio gradle plugin based on Javassist for editing bytecode in Android.

9、lancet-A lightweight and fast AOP framework for Android App and SDK developers

10、MethodTraceMan-用於快速找到高耗時方法,定位解決Android App卡頓問題

11、Linux環境下進程的CPU佔用率

12、使用 ftrace

13、profilo-A library for performance traces from production

14、ftrace 簡介

15、atrace源碼

16、AndroidAdvanceWithGeektime
/ Chapter06

17、AndroidAdvanceWithGeektime
/ Chapter06-plus

讚賞

如果這個庫對您有很大幫助,您願意支持這個項目的進一步開發和這個項目的持續維護。你可以掃描下面的二維碼,讓我喝一杯咖啡或啤酒。非常感謝您的捐贈。謝謝!


Contanct Me

● 微信:

歡迎關注我的微信:bcce5360

● 微信羣:

微信羣如果不能掃碼加入,麻煩大家想進微信羣的朋友們,加我微信拉你進羣。

● QQ羣:

2千人QQ羣,Awesome-Android學習交流羣,QQ羣號:959936182, 歡迎大家加入~

About me

很感謝您閱讀這篇文章,希望您能將它分享給您的朋友或技術羣,這對我意義重大。

希望我們能成爲朋友,在 Github掘金上一起分享知識。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章