Android卡頓優化分析及解決方案

目錄

寫在前面

一、卡頓介紹及優化工具選擇

1.1、卡頓問題介紹

1.2、優化工具選擇

二、自動化卡頓檢測方案及優化

2.1、爲什麼需要自動化卡頓檢測

2.2、自動化卡頓檢測方案原理

2.3、AndroidPerformanceMonitor

三、ANR實戰分析

3.1、ANR介紹

3.2、ANR實戰分析

3.3、線上ANR監控方案

四、應用界面秒開

4.1、界面秒開率統計

五、優雅監控耗時盲區

5.1、爲什麼會出現耗時盲區

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

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


寫在前面

最近過的有點累,身心俱疲,成年人的世界太難了,慶幸的是自己還是堅持着把今天的內容整理完了😊!

在上一篇中介紹了Android性能優化系列專欄中的佈局優化——《你想知道的佈局優化都在這裏了》,今天就繼續來說一下另外一個比較重要的性能優化點,也就是Android中的卡頓優化。

一、卡頓介紹及優化工具選擇

1.1、卡頓問題介紹

對於用戶來說我們的應用當中的很多性能問題比如內存佔用高、流量消耗快等不容易被發現,但是卡頓卻很容易被直觀的感受到,對於開發者來說,卡頓問題又難以定位,那麼它究竟難在哪裏呢?

卡頓問題難點:

  • 產生原因錯綜複雜:代碼、內存、繪製、IO等都有可能導致卡頓
  • 不易復現:線上卡頓問題在線下難以復現,這和用戶當時的系統環境有很大關係(比如當時用戶磁盤空間不足導致的IO寫入性能下降從而引發了卡頓,所以我們最好能記錄在發生卡頓時用戶當時的場景)

1.2、優化工具選擇

①、CPU Profiler

  • 圖形化的形式展示執行時間、調用棧等
  • 信息全面,包含所有線程
  • 運行時開銷嚴重,整體都會變慢

使用方式:

  • Debug.startMethodTracing("");
  • Debug.stopMethodTracing("");
  • 生成文件在sd卡:Android/data/packagename/files

②、Systrace

  • 監控和跟蹤Api調用,線程運行情況,生成Html報告
  • 要求是在API18以上使用,所以這裏推薦使用TraceCompat

使用方式:

Systrace優點

  • 輕量級,開銷小
  • 直觀反映CPU利用率
  • 右側Alert一欄會給出相關建議

③、StrictMode

  • Android2.3引入的工具類——嚴苛模式,Android提供的一種運行時檢測機制,幫助開發者檢測代碼中的一些不規範的問題
  • 包含:線程策略和虛擬機策略檢測
  • 線程策略:1、自定義的耗時調用,detectCustomSlowCalls() 2、磁盤讀取操作,detectDiskReads 3、網絡操作,detectNetwork  
  • 虛擬機策略:1、Activity泄露,detectActivityLeaks() 2、Sqlite對象泄露,detectLeakedSqliteObjects 3、檢測實例數量,setClassInstanceLimit()

現在到之前的Demo中來實際使用一下,找到我們的Application類,新增一個方法initStrictMode():

private void initStrictMode(){
        if (DEV_MODE) {
            StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                    .detectCustomSlowCalls() //API等級11,使用StrictMode.noteSlowCode
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()// or .detectAll() for all detectable problems
                    .penaltyLog() //在Logcat 中打印違規異常信息
                    .build());
            StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
                    .detectLeakedSqlLiteObjects()
                    .setClassInstanceLimit(FeedBean.class, 1)
                    .detectLeakedClosableObjects() //API等級11
                    .penaltyLog()
                    .build());
        }
    }

首先在這裏加了一個標記位DEV_MODE,也就是隻在線下開發的時候纔會走到這個方法。對於線程策略使用方式就是StrictMode.setThreadPolicy,然後就是一些配置比如磁盤的讀取、寫入、網絡監控等,如果出現了違規情況我們使用的是penaltyLog()方法在日誌中打印出違規信息,這裏你也可以選擇別的方式。對於虛擬機策略這裏是配置需要檢測出Sqlite對象的泄露,並且這裏還設置某個類的實例數量是x,如果大於x它應該會被檢測出不合規。

二、自動化卡頓檢測方案及優化

2.1、爲什麼需要自動化卡頓檢測

  • 上面介紹的幾種系統工具只適合線下實際問題作針對性分析
  • 線上及測試環節需要自動化檢測方案幫助開發者定位卡頓,記錄卡頓發生時的場景

2.2、自動化卡頓檢測方案原理

  • 消息處理機制,一個線程不管有多少Handler都只會有一個Looper對象存在,主線程中執行的任何代碼都會通過Looper.loop()方法執行,loop()函數中有一個mLogging對象
  • mLogging對象在每個message處理前後都會被調用
  • 主線程如果發生卡頓,則一定是在dispatchMessage方法中執行了耗時操作,然後我們可以通過mLogging對象對dispatchMessage執行的時間進行監控

我在這裏從Looper.java的loop()方法的源碼中截取了一段代碼,大家看下:

// This must be in a local variable, in case a UI event sets the logger
if (logging != null) {
    logging.println(">>>>> Dispatching to " + msg.target + " " +
        msg.callback + ": " + msg.what);
}

......
此處省略一大段代碼

if (logging != null) {
    logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}            

它在Message執行的前後都打印了一段日誌並且是不同的,所以我們可以通過這個來判斷Message處理的開始和結束的時機。

具體的實現原理:

  • 使用 Looper.getMainLooper.setMessageLogging()來設置自己的logging
  • 匹配>>>>> Dispatching,閾值之後在子線程中執行任務(獲取堆棧及場景信息,比如內存大小、電量、網絡狀態等)
  • 匹配<<<<< Finished,說明在指定的閾值之內message被執行完成沒有發生卡頓,任務啓動之前取消掉

2.3、AndroidPerformanceMonitor

下面我們在項目中實際使用一下:

首先在application中進行初始化:

//BlockCanary初始化
BlockCanary.install(this,new AppBlockCanaryContext()).start();

這裏入參有一個AppBlockCanaryContext,這個是我們自定義BlockCanary配置的一些信息:

public class AppBlockCanaryContext extends BlockCanaryContext {
  
    public String provideQualifier() {
        return "unknown";
    }

    public String provideUid() {
        return "uid";
    }

    public String provideNetworkType() {
        return "unknown";
    }

    public int provideMonitorDuration() {
        return -1;
    }

    //設置卡頓閾值爲500ms
    public int provideBlockThreshold() {
        return 500;
    }

    public int provideDumpInterval() {
        return provideBlockThreshold();
    }

    public String providePath() {
        return "/blockcanary/";
    }

    public boolean displayNotification() {
        return true;
    }

    public boolean zip(File[] src, File dest) {
        return false;
    }

    public void upload(File zippedFile) {
        throw new UnsupportedOperationException();
    }

    public List<String> concernPackages() {
        return null;
    }

    public boolean filterNonConcernStack() {
        return false;
    }

    public List<String> provideWhiteList() {
        LinkedList<String> whiteList = new LinkedList<>();
        whiteList.add("org.chromium");
        return whiteList;
    }

    public boolean deleteFilesInWhiteList() {
        return true;
    }

    public void onBlock(Context context, BlockInfo blockInfo) {
        Log.i("jarchie","blockInfo "+blockInfo.toString());
    }
}

然後在MainActivity中模擬一次卡頓,讓當前線程休息2s,然後來看一下這個組件會不會通知我們:

        try {
            Thread.currentThread().sleep(2000);
        }catch (InterruptedException e){
            e.printStackTrace();
        }        

當我們把程序運行之後,會發現手機桌面上出現了一個Blocks的圖標,這個玩意和之前我們使用LeakCanary的時候有點像哈,然後點進去果然發現了剛剛的Block信息,如下所示:

這裏詳細的打出了當前的CPU核心數、進程名、內存情況、block的堆棧信息等等,我們就可以根據這些堆棧找到對應哪個類的哪一行代碼出現了問題,然後進行修改即可。

對於這種方案的總結如下:

  • 非侵入式方案:可以監控在主線程中執行的任何方法並且不需要我們手動埋點
  • 方便精準,定位到代碼某一行

這種方案網上有很多的使用資料,但是實際上它也是存在一定的問題的,自動檢測方案的問題:

  • 確實卡頓了,但卡頓堆棧可能不準確
  • 和OOM一樣,最後的堆棧只是表象,不是真正的問題

舉個栗子:主線程在T1 T2時間段內發生了卡頓,卡頓檢測方案獲取卡頓堆棧的信息是T2時刻,但是實際情況可能是整個這一段時間之內某個函數的耗時過長導致的卡頓,捕獲堆棧的時機此時該函數已經執行完成,所以在T2時刻捕獲的堆棧信息並不能準確的反應現場情況。

自動檢測方案優化

  • 獲取監控週期內的多個堆棧,而不僅是最後一個,這樣如果發生卡頓,由於我們有多個堆棧信息,所以可以推測出整個週期內究竟發生了什麼,能夠更加清晰的還原卡頓現場

海量卡頓堆棧處理:高頻卡頓上報量太大,會導致服務端有壓力

  • 分析:一個卡頓下多個堆棧大概率有重複
  • 解決:對一個卡頓下堆棧進行hash排重,找出重複的堆棧
  • 效果:極大的減少展示量同時更高效找到卡頓堆棧

三、ANR實戰分析

3.1、ANR介紹

ANR分類:Application Not Responding

  • KeyDispatchTimeout,5s:按鍵或者觸摸事件在特定的時間內無響應
  • BroadcastTimeout,前臺10s,後臺60s:BroadcastReceiver在特定的時間內沒有響應完成
  • ServiceTimeout,前臺20s,後臺200s:Service在特定的時間內沒有處理完成

ANR執行流程:

  • 發生ANR
  • 進程接收異常終止信號,開始寫入進程ANR信息(包含當前所有線程的堆棧信息以及CPU、IO等的使用情況)
  • 彈出ANR提示框,告知用戶選擇關閉還是繼續等待(根據ROM不同表現也不同,有些手機廠商可能會去掉)

ANR解決套路:

  • adb pull data/anr/traces.txt存儲路徑
  • 詳細分析:CPU、IO、鎖

3.2、ANR實戰分析

接下來我們來模擬一次ANR的出現,回到項目中,首先在MainActivity中創建一個線程,並且讓它持有當前Activity20秒,然後在主線程中我們再次申請這把鎖,讓它彈一個吐司,代碼也都很簡單,如下所示:

將項目跑起來,此時我操作系統的返回鍵,然後就真的出現了ANR異常,系統彈出了一個彈框詢問你是繼續等待還是關閉應用,點擊關閉應用,此時traces.txt文件已經生成了。其實從代碼中分析也可以看出添加的這些代碼肯定會造成ANR異常,主線程要申請MainActivity.this這把鎖,但此時這把鎖是被我們開始創建的異步線程所持有着,必須要等到異步線程執行完成之後才能繼續往下執行,意思也就是MainActivity的onCreate()方法要在這裏卡頓20s。那麼我們該如何將生成的traces.txt文件導出到我們本地進行分析呢?下面一起來看一下:

這裏我們使用adb pull data/anr/traces.txt這個命令進行導出,如果你的手機和我的一樣是高版本的,可能你會出現以下問題,它說找不到,這個問題真的很蛋疼,然後我使用adb shell命令,進到anr的目錄下,查看該文件夾下的文件是有的:

此時我嘗試直接pull這個文件名,很遺憾,依然不行,它會告訴你沒有權限,這裏可能需要root手機,我沒有進行嘗試,有條件的可以嘗試一下root之後是否可以:

最後無奈我們使用adb bugreport這個命令,此命令導出一個zip的壓縮包,這個過程可能會有點慢,耐心等待就OK了:

然後它會將文件導出到你命令行所在的當前目錄下,找到壓縮包解壓之後,在FS/data/anr這個目錄下就可以看到anr文件了:

然後我將我需要的anr文件打開,然後搜索應用包名,這裏就看到了anr發生的地方以及具體的原因:

3.3、線上ANR監控方案

  • 通過FileOberver監控文件變化,如果此文件發生了變化,那就說明發生了ANR,將文件上傳服務器進行詳細分析,注意這種方式在高版本有權限問題,也就意味着在高版本中我們無法監控此文件的變化
  • 爲了解決上面的問題,於是乎有了ANR-WatchDog

①、ANR-WatchDog

現在到Demo中實際應用一下,首先在build.gradle中引入這個庫,然後在application中的onCreate()方法中作初始化操作:

new ANRWatchDog().start();

然後運行程序之後我這裏按返回鍵進行按鍵事件交互,程序直接Crash掉了,這是ANR-WatchDog對於ANR的默認處理,然後來看下日誌:

從日誌我們可以看到它拋出的異常是在MainActivity的76行,同時告知我們main Thread的狀態是blocked的,通過去代碼中查找發現第76行正好就是發生鎖衝突的地方:

②、ANR-WatchDog源碼解析

下面我們跟着源碼來看一下這個庫它的實現原理是什麼?這個庫一共就兩個類:ANRError和ANRWatchDog:

首先來看ANRWatchDog實際上是繼承自Thread類,本質上它是一個線程,對於一個線程來說最重要的就是它的run()方法:

@Override
    public void run() {
        setName("|ANR-WatchDog|");

        int lastTick;
        int lastIgnored = -1;
        while (!isInterrupted()) {
            lastTick = _tick;
            _uiHandler.post(_ticker);
            
            。。。。省略部分代碼,見下方分析中的代碼
        }
    }

在run()方法中首先是對這個線程進行命名,接着聲明瞭一個變量lastTick,然後進行while循環,在循環中它通過_uiHandler post了一個runnable即_ticker:

private final Runnable _ticker = new Runnable() {
        @Override public void run() {
            _tick = (_tick + 1) % Integer.MAX_VALUE;
        }
    };

這個runnable內部是進行+1的操作,接着這個線程就會sleep一段時間:

try {
    Thread.sleep(_timeoutInterval);
}
catch (InterruptedException e) {
    _interruptionListener.onInterrupted(e);
    return ;
}

之後就會進行檢測:

// If the main thread has not handled _ticker, it is blocked. ANR.
            if (_tick == lastTick) {
                if (!_ignoreDebugger && Debug.isDebuggerConnected()) {
                    if (_tick != lastIgnored)
                        Log.w("ANRWatchdog", "An ANR was detected but ignored because the debugger is connected (you can prevent this with setIgnoreDebugger(true))");
                    lastIgnored = _tick;
                    continue ;
                }

                ANRError error;
                if (_namePrefix != null)
                    error = ANRError.New(_namePrefix, _logThreadsWithoutStackTrace);
                else
                    error = ANRError.NewMainOnly();
                _anrListener.onAppNotResponding(error);
                return;
            }

具體的是檢測剛剛的runnable是否被執行,其實就是判斷“+1”的操作有沒有被執行,如果+1成功則判定runnable執行成功,說明主線程未發生卡頓,如果沒有+1成功,則判定runnable未被執行,說明主線程已經發生了卡頓,並且它會拿到MainThread,通過主線程拿到堆棧信息,然後返回一個ANRError:

static ANRError NewMainOnly() {
        final Thread mainThread = Looper.getMainLooper().getThread();
        final StackTraceElement[] mainStackTrace = mainThread.getStackTrace();

        return new ANRError(new $(getThreadTitle(mainThread), mainStackTrace).new _Thread(null));
    }

然後通過anrListener調用onAppNotResponding()方法:即:_anrListener.onAppNotResponding(error);

public interface ANRListener {
    public void onAppNotResponding(ANRError error);
}

這個方法的默認實現是直接將這個error給throw出去,這樣就會導致程序崩潰:

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

ANR-WatchDog的實現原理:

從上面我們知道ANR-Watchdog在每次發生ANR的時候,它是直接將異常拋出的,這樣做其實是有一個問題的,就是每次發生ANR的時候應用都會異常崩潰掉,這其實給人的用戶體驗就很不好,那該怎麼辦呢?如果你繼續閱讀官方文檔你會發現它有一個ANRListener,你可以覆寫這個listener,在onAppNotResponding這個回調方法中自己實現ANR事件的定製化處理。好,如此一來我們就可以很方便的拿到ANR的堆棧信息,然後就可以上報服務端,進行日誌的統計分析。

總結:①、非侵入式;②、彌補高版本無權限讀取trace.txt文件問題;③、兩種方式結合使用

區別:

  • AndroidPerformanceMonitor:監控主線程的每一個Message的執行,在主線程每個msg前後加時間戳,然後可以計算出每個msg的具體執行時間(注意:一般情況下msg的執行時間是很短的,還達不到ANR級別)
  • ANR-Watchdog:不管你是如何執行的,只管最終結果,sleep 5s後看值是否修改過,若沒被改則發生ANR
  • 前者適合監控卡頓,後者適合補充ANR監控

四、應用界面秒開

應用界面秒開的實現方案:

  • SysTrace查看CPU運行程度,以及啓動優化部分的優雅異步+優雅延遲初始化
  • 界面異步Inflate、X2C、繪製優化
  • 提前獲取頁面數據

4.1、界面秒開率統計

  • onCreate到onWindowFocusChanged
  • 實現特定接口,在具體方法中統計耗時

這裏來介紹一個開源方案:Lancet,它是一個輕量級的Android AOP框架:

  • 編譯速度快,支持增量編譯
  • API簡單,沒有任何多餘代碼插入apk(包體積優化)
  • https://github.com/eleme/lancet
  • @Proxy通常用於對系統API調用的Hook
  • @Insert通常用於操作APP與Library的類

下面我們來具體使用一下這個庫,我們使用這個庫來統計頁面的onCreate()方法到onWindowsFocusChanged()方法之間的加載耗時情況:

①、添加依賴

這裏大家可以參考github上的使用方式進行依賴的添加,主要是兩個部分:工程的build.gradle和app module的build.gradle:

classpath 'me.ele:lancet-plugin:1.0.6' //工程的build.gradle

apply plugin: 'me.ele.lancet' //module的build.gradle
//lancet
compileOnly 'me.ele:lancet-base:1.0.6'

②、編寫一個實體類,定義用於上述兩個方法時間統計的成員變量:

public class ActivityLive {

    public long mOnCreateTime;
    public long mOnWindowsFocusChangedTime;

}

③、創建統計方法的工具類,在類中分別編寫onCreate()和onWindowFocusChanged()方法,關於具體的註解的使用含義詳見代碼註釋:

public class ActivityHooker {

    public static ActivityLive mLive;

    static {
        mLive = new ActivityLive();
    }

    //@Insert:使用自己程序中自己的一些類需要添加,值這裏就指定onCreate()方法,
    //可配置項mayCreateSuper是當目標函數不存在的時候可以通過它來創建目標方法
    //@TargetClass:框架知道要找的類是哪個,可配置項Scope.ALL:匹配value所指定的所有類的子類
    @Insert(value = "onCreate",mayCreateSuper = true)
    @TargetClass(value = "androidx.appcompat.app.AppCompatActivity", scope = Scope.ALL)
    protected void onCreate(Bundle savedInstanceState) {
        mLive.mOnCreateTime = System.currentTimeMillis();
        Origin.callVoid(); //無返回值的調用
    }


    //註解含義同上面onCreate()
    @Insert(value = "onWindowFocusChanged",mayCreateSuper = true)
    @TargetClass(value = "androidx.appcompat.app.AppCompatActivity", scope = Scope.ALL)
    public void onWindowFocusChanged(boolean hasFocus) {
        mLive.mOnWindowsFocusChangedTime = System.currentTimeMillis();
        Log.i("onWindowFocusChanged","---"+(mLive.mOnWindowsFocusChangedTime - mLive.mOnCreateTime));
        Origin.callVoid();
    }

}

下面運行程序來看下結果:

界面秒開監控緯度

  • 總體耗時:onCreate()--->onWindowsFocusChanged(),更精確的時間可以通過自定義接口來實現
  • 生命週期耗時:onCreate()、onStart()、onResume()等等
  • 生命週期間隔耗時:各個生命週期耗時時間差

五、優雅監控耗時盲區

5.1、爲什麼會出現耗時盲區

對於一般的監控方案,它的監控指標只是一個大的範圍,只是一個數據,比如:

  • 生命週期的間隔
  • onResume到Feed展示的間隔
  • 舉個栗子:比如在Activity的生命週期當中postMessage,很有可能在Feed展示之前執行,如果msg耗時1s,那麼Feed展示時間也就相對應的延遲1s,如果是200ms,那麼自動化卡頓監測方案實際上就監測不到它,但是你的列表展示就相對應的延時200ms

如下代碼所示,我首先在Activity的onCreate()方法中發送了一個msg,並且打印了一條日誌

然後在列表展示的第一條同樣打印一條日誌:

最後輸出的結果如下:

從執行結果來看,這個MSG是跑在Feed展示之前的,這個msg模擬的耗時是1s,此時用戶看到界面的時間也就被往後延遲了1s。其實這個場景還是很常見的,因爲我們可能由於某些業務需求在某個生命週期或者某個階段及某些第三方的SDK中會做一些handler post的操作,這個操作很有可能會在列表展示之前被執行到,所以出現這種耗時的盲區,既普遍又不好排查。

耗時盲區監控難點

  • 通過細化監控的方式知道盲區時間,但是不清楚在盲區中具體在做什麼
  • 線上盲區無從排查

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

這種場景非常適合之前說過的一個工具,你能想到是什麼嗎?————答案是TraceView:

  • 特別適合一段時間內的盲區監控
  • 線程具體時間做了什麼,一目瞭然

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

  • 方法一:主線程的所有方法都是通過msg來執行的,那麼我們是否可以通過mLogging來做盲區監測呢?mLogging確實知道主線程發送了msg執行了一段代碼,但是它並不清楚msg具體的調用棧信息,它所能獲取到的調用棧信息都是系統回調它的,它並不清楚msg是被誰拋出的,這個只能說可以,但是不太好。
  • 方法二:是否可以通過AOP的方式來切Handler的sendMessage()等方法呢?使用這種方式我們可以知道發送msg的堆棧信息,但是這種方案並不清楚具體的執行時間,你只知道這個msg是在哪裏被髮送的,你並不知道它會在什麼時候執行。

可行性方案:

  • 使用統一的Handler:定製具體方法:sendMessageAtTime()和dispatchMessage(),對於發送消息,不管你使用哪個方法發送,最終都會走到這個sendMessageAtTime(),而處理消息同樣的道理,最終都是調用dispatchMessage()
  • 替換項目中所有使用的Handler,將其替換爲自己定製的Handler(https://github.com/didi/DroidAssist

嗯,寫着寫着天兒就黑了,又到了飯點了。OK,關於Android卡頓優化的部分,今天就先到這裏了,後面如果有需要補充的,再進行補充吧,拜了個拜,下期再會!

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