Android卡頓優(yōu)化 | 自動化卡頓檢測方案與優(yōu)化(AndroidPerformanceMonitor / BlockCanary)

本文要點

  • 為何需要自動化檢測方案
  • 自動卡頓檢測方案原理
  • 看一下Looper.loop()源碼
  • 實現(xiàn)思路
  • AndroidPerformanceMonitor實戰(zhàn)
  • 基于AndroidPerformanceMonitor源碼簡析
  • 接下來我們討論一下方案的不足
  • 自動檢測方案優(yōu)化

項目GitHub

為何需要自動化檢測方案

  • 前面提到過的系統(tǒng)工具只適合線下針對性分析艰匙,無法帶到線上膘盖!
  • 線上及測試環(huán)節(jié)需要自動化檢測方案

方案原理

  • 源于Android的消息處理機制;
    一個線程不管有多少Handler磕诊,只會有一個Looper存在嗦篱,
    主線程中所有的代碼冰单,都會通過Looper.loop()執(zhí)行;
  • loop()中有一個mLogging對象灸促,
    它在每個Message處理前后都會被調(diào)用:
  • 如果主線程發(fā)生卡頓诫欠,
    一定是在dispatchMessage執(zhí)行了耗時操作!
    Handler機制圖

由此腿宰,我們便可以通過mLogging對象
dispatchMessage執(zhí)行的時間進行監(jiān)控呕诉;

看一下Looper.loop()源碼

    public static void loop() {
        final Looper me = myLooper();
        if (me == null) {
            throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
        }
        final MessageQueue queue = me.mQueue;

        // Make sure the identity of this thread is that of the local process,
        // and keep track of what that identity token actually is.
        Binder.clearCallingIdentity();
        final long ident = Binder.clearCallingIdentity();

        // Allow overriding a threshold with a system prop. e.g.
        // adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
        final int thresholdOverride =
                SystemProperties.getInt("log.looper."
                        + Process.myUid() + "."
                        + Thread.currentThread().getName()
                        + ".slow", 0);

        boolean slowDeliveryDetected = false;

        for (;;) {
            Message msg = queue.next(); // might block
            if (msg == null) {
                // No message indicates that the message queue is quitting.
                return;
            }

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

            final long traceTag = me.mTraceTag;
            long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;
            long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs;
            if (thresholdOverride > 0) {
                slowDispatchThresholdMs = thresholdOverride;
                slowDeliveryThresholdMs = thresholdOverride;
            }
            final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0);
            final boolean logSlowDispatch = (slowDispatchThresholdMs > 0);

            final boolean needStartTime = logSlowDelivery || logSlowDispatch;
            final boolean needEndTime = logSlowDispatch;

            if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
                Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
            }

            final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
            final long dispatchEnd;
            try {
                msg.target.dispatchMessage(msg);
                dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
            } finally {
                if (traceTag != 0) {
                    Trace.traceEnd(traceTag);
                }
            }
            if (logSlowDelivery) {
                if (slowDeliveryDetected) {
                    if ((dispatchStart - msg.when) <= 10) {
                        Slog.w(TAG, "Drained");
                        slowDeliveryDetected = false;
                    }
                } else {
                    if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery",
                            msg)) {
                        // Once we write a slow delivery log, suppress until the queue drains.
                        slowDeliveryDetected = true;
                    }
                }
            }
            if (logSlowDispatch) {
                showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg);
            }

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

            // Make sure that during the course of dispatching the
            // identity of the thread wasn't corrupted.
            final long newIdent = Binder.clearCallingIdentity();
            if (ident != newIdent) {
                Log.wtf(TAG, "Thread identity changed from 0x"
                        + Long.toHexString(ident) + " to 0x"
                        + Long.toHexString(newIdent) + " while dispatching to "
                        + msg.target.getClass().getName() + " "
                        + msg.callback + " what=" + msg.what);
            }

            msg.recycleUnchecked();
        }
    }
  • 里邊有一個for循環(huán),

    會不斷地讀取消息隊列隊頭進行處理:
  • 處理之前吃度,會調(diào)用logging.println()
    執(zhí)行之后再次調(diào)用
    我們可以從打印日志的前綴來判斷Message處理的開始結(jié)束甩挫;

實現(xiàn)思路

  • 通過Looper.getMainLooper().setMessageLogging();
    來設(shè)置我們自己的Logging椿每;
    這樣每次Message處理的前后伊者,
    調(diào)用的就是我們自己的Logging;
  • 如果匹配到>>>>> Dispatching间护,
    我們就可以執(zhí)行一個代碼亦渗,
    即在指定的閾值時間之后,
    在子線程中開始執(zhí)行一個【獲取當前子線程的堆棧信息以及當前的一些場景信息(如內(nèi)存大小汁尺、變量法精、網(wǎng)絡(luò)狀態(tài)等)】的任務(wù);

    如果匹配到<<<<< Finished痴突,
    則說明在指定的閾值時間內(nèi)搂蜓,Message被執(zhí)行完成,沒有發(fā)生卡頓辽装,
    那便將這個任務(wù)取消掉帮碰;

AndroidPerformanceMonitor實戰(zhàn)

  • AndroidPerformanceMonitor原理:便是上述的實現(xiàn)思路和原理;

  • 特性1:非侵入式的性能監(jiān)控組件拾积,
    可以用通知的方式 彈出卡頓信息殉挽,同時用logcat打印出關(guān)于卡頓的詳細信息;
    可以檢測所有線程中執(zhí)行的任何方法拓巧,又不需要手動埋點斯碌,
    設(shè)置好閾值等配置,就“坐享其成”肛度,等卡頓問題“愿者上鉤”J淠础!

  • 特性2:方便精確贤斜,可以把問題定位到代碼的具體某一行2叻汀9淇恪!

【方案的不足 以及框架源碼解析 在下面實戰(zhàn)之后總結(jié):锬ā带族!】


實戰(zhàn)開始---------------------------------------------------

  • 庫的依賴:implementation 'com.github.markzhai:blockcanary-android:1.5.0'

  • 庫的主頁:https://github.com/markzhai/AndroidPerformanceMonitor

  • 在項目中引入依賴后,
    在Application進行初始化蟀给,
    BlockCanary.install(this, new AppBlockCanaryContext()).start();
    第一個參數(shù)是上下文蝙砌,
    第二個參數(shù)是需要傳入一個Block的配置類實例【BlockCanaryContext類實例或者其子類實例】:

public class TestApp extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        ...

        //AndroidPerformanceMonitor測試
        BlockCanary.install(this, new AppBlockCanaryContext()).start();

    }
}    
  • AppBlockCanaryContext是我們自定義的類,
    配置了BlockCanary的各種信息跋理,
    代碼較多择克,可以看下GitHub,這里就不貼全部代碼了~
    下面兩個配置方法分別是
    給出一個uid前普,可以用于在上報時上報當前的用戶信息肚邢;
    第二個是自定義卡頓的閾值時間,過了閾值便認為是卡頓拭卿,
    這里指定的是500ms骡湖;
    /**
     * Implement in your project.
     *
     * @return user id
     */
    public String provideUid() {
        return "uid";
    }

    /**
     * Config block threshold (in millis), dispatch over this duration is regarded as a BLOCK. You may set it
     * from performance of device.
     *
     * @return threshold in mills
     */
    public int provideBlockThreshold() {
        return 500;
    }
  • 接著在MainActivityonCreate()中,
    讓主線程沉睡兩秒(2000ms > 設(shè)定的閾值500ms)峻厚;

  • 運行時响蕴,因為主線程停滯時間超過既定閾值,
    組件會認為其卡頓并且彈出通知;萏摇浦夷!
    當然Android8.0以后比較麻煩,
    因為notificationManager需要配置Channel等才能用辜王,
    或者允許后臺彈出界面军拟,
    桌面上便會出現(xiàn)這個圖標:
    進去之后就可以看到了相應(yīng)的信息了:
    當然,我們可以在logcat中定位關(guān)鍵詞blockInfo誓禁,
    看到同樣的詳細的信息:
    如上,
    Block框架打印出來了【當前子線程的堆棧信息以及當前的一些場景信息(如內(nèi)存大小肾档、變量摹恰、網(wǎng)絡(luò)狀態(tài)等)】,
    time-starttime-end時間間隔又可以知道阻塞的時間,如上圖展示出來的怒见,正是我們設(shè)置的2秒K状取!G菜!闺阱!
    也可以看到uid鍵的值 便是我們剛剛設(shè)定的字符串“uid”
    同時還直接幫我們定位到卡頓問題的出處6姹洹:ɡ!瘦穆!

可見得BlockCanary已然
成功檢測到卡頓問題的各種具體信息了!I尥恪扛或!


基于AndroidPerformanceMonitor源碼簡析

由于篇幅原因,筆者把以下解析內(nèi)容提取出來單獨作一篇博客哈~

目錄
1. 監(jiān)控周期的 定義
2. dump模塊 / 關(guān)于.log文件
3. 采集堆棧周期的 設(shè)定
4. 框架的 配置存儲類 以及 文件系統(tǒng)操作封裝
5. 文件寫入過程(生成.log文件的源碼)
6. 上傳文件
7. 設(shè)計模式碘饼、技巧
8. 框架中各個主要類的功能劃分



接下來我們討論一下方案的不足

  • 不足1:確實檢測到卡頓熙兔,但獲取到的卡頓堆棧信息可能不準確;
  • 不足2:和OOM一樣艾恼,最后的打印堆棧只是表象住涉,不是真正的問題;
    我們還需要監(jiān)控過程中的一次次log信息來確定原因钠绍;
    【假設(shè)初始方案舆声,整個監(jiān)控周期只采集一次】
    如上圖,
    假設(shè)主線程
    時間點T1(Message分發(fā)五慈、處理前)T2(Message分發(fā)纳寂、處理后)之間的時間段中發(fā)生了卡頓,
    卡頓檢測方案是在T2時刻泻拦,
    也就是 阻塞時間完全結(jié)束 (前提是T2-T1大于閾值毙芜,確定了是卡頓問題)的時刻,
    方案才開始獲取卡頓堆棧的信息争拐,

    實際發(fā)生卡頓(如發(fā)生違例耗時處理過程)的時間點腋粥,
    可能是在這個時間段內(nèi),而非獲取信息的T2點架曹,
    那有可能隘冲,
    耗時操作時間段內(nèi),即在T2點之前就已經(jīng)執(zhí)行完成了绑雄,
    T2點獲取到的可能不是卡頓發(fā)生的準確時刻展辞,
    也就是說T2時刻獲取到的信息,不能夠完全反映卡頓的現(xiàn)場万牺;
    最后的T2點的堆棧信息只是表象罗珍,不能反映真正的問題;

    我們需要縮小采集堆棧信息的周期脚粟,進行高頻采集覆旱,詳細如下;

自動檢測方案優(yōu)化

  • 優(yōu)化思路:獲取監(jiān)控周期內(nèi)的多個堆棧核无,而不僅是一個扣唱;

  • 主要步驟:
    startMonitor開始監(jiān)控(Message分發(fā)、處理前),
    接著高頻采集堆棧T肷场A侗搿!
    阻塞結(jié)束曲聂,Message分發(fā)霹购、處理后,前后時間差——阻塞時間超過閾值朋腋,即發(fā)生卡頓齐疙,便調(diào)用endMonitor(Message分發(fā)、處理后)旭咽;
    記錄 高頻采集好的堆棧信息 到文件中贞奋;【具體源碼解析見上面解析部分(另一篇博客)】
    在合適的時機上報給服務(wù)器;【相關(guān)方案以及源碼解析見上面解析部分(另一篇博客)】

  • 如此一來穷绵,
    便能更清楚地知道在整個卡頓周期(阻塞開始到結(jié)束轿塔;Message分發(fā)、處理前到后)之內(nèi)仲墨,
    究竟是哪些方法在執(zhí)行勾缭,哪些方法執(zhí)行比較耗時;
    優(yōu)化卡頓現(xiàn)場不能還原的問題目养;

  • 新問題:面對 高頻卡頓堆棧信息的上報俩由、處理,服務(wù)端有壓力癌蚁;

    • 突破點:一個卡頓下多個堆棧大概率有重復幻梯;
    • 解決:對一個卡頓下的堆棧進行hash排重,
      找出重復的堆棧努释;
    • 效果:極大地減少展示量碘梢,同時更高效地找到卡頓堆棧;





參考:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末伐蒂,一起剝皮案震驚了整個濱河市煞躬,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌逸邦,老刑警劉巖恩沛,帶你破解...
    沈念sama閱讀 217,185評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異昭雌,居然都是意外死亡,警方通過查閱死者的電腦和手機健田,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,652評論 3 393
  • 文/潘曉璐 我一進店門烛卧,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事总放〕视睿” “怎么了?”我有些...
    開封第一講書人閱讀 163,524評論 0 353
  • 文/不壞的土叔 我叫張陵局雄,是天一觀的道長甥啄。 經(jīng)常有香客問我,道長炬搭,這世上最難降的妖魔是什么蜈漓? 我笑而不...
    開封第一講書人閱讀 58,339評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮宫盔,結(jié)果婚禮上融虽,老公的妹妹穿的比我還像新娘。我一直安慰自己灼芭,他們只是感情好有额,可當我...
    茶點故事閱讀 67,387評論 6 391
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著彼绷,像睡著了一般巍佑。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上寄悯,一...
    開封第一講書人閱讀 51,287評論 1 301
  • 那天萤衰,我揣著相機與錄音,去河邊找鬼热某。 笑死腻菇,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的昔馋。 我是一名探鬼主播筹吐,決...
    沈念sama閱讀 40,130評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼秘遏!你這毒婦竟也來了丘薛?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,985評論 0 275
  • 序言:老撾萬榮一對情侶失蹤邦危,失蹤者是張志新(化名)和其女友劉穎洋侨,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體倦蚪,經(jīng)...
    沈念sama閱讀 45,420評論 1 313
  • 正文 獨居荒郊野嶺守林人離奇死亡希坚,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,617評論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了陵且。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片裁僧。...
    茶點故事閱讀 39,779評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出聊疲,到底是詐尸還是另有隱情茬底,我是刑警寧澤,帶...
    沈念sama閱讀 35,477評論 5 345
  • 正文 年R本政府宣布获洲,位于F島的核電站阱表,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏贡珊。R本人自食惡果不足惜最爬,卻給世界環(huán)境...
    茶點故事閱讀 41,088評論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望飞崖。 院中可真熱鬧烂叔,春花似錦、人聲如沸固歪。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,716評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽牢裳。三九已至逢防,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間蒲讯,已是汗流浹背忘朝。 一陣腳步聲響...
    開封第一講書人閱讀 32,857評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留判帮,地道東北人局嘁。 一個月前我還...
    沈念sama閱讀 47,876評論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像晦墙,于是被迫代替她去往敵國和親悦昵。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,700評論 2 354

推薦閱讀更多精彩內(nèi)容