APP卡頓檢測工具 BlockCanary——使用和原理

引子

在復雜的項目環(huán)境中袁辈,由于歷史代碼龐大挨措,業(yè)務復雜汗洒,包含各種第三方庫议纯,所以在出現(xiàn)了卡頓的時候,很難定位到底是哪里出現(xiàn)了問題溢谤,即便知道是哪一個Activity/Fragment瞻凤,動輒數(shù)千行的類再加上跳來跳去調來調去的憨攒,結果就是不了了之隨它去了。

事實上阀参,很多情況下卡頓不是必現(xiàn)的肝集,它們可能與機型、環(huán)境蛛壳、操作等有關杏瞻,存在偶然性,即使發(fā)生了衙荐,再去查那如山般的logcat捞挥,也不一定能找到卡頓的原因。

BlockCanary就是來解決這個問題的忧吟。告別打點和調試砌函,哪里卡頓,一目了然溜族。

一讹俊、介紹

BlockCanary是一個Android平臺的一個非侵入式的性能監(jiān)控組件,應用只需要實現(xiàn)一個抽象類斩祭,提供一些該組件需要的上下文環(huán)境劣像,就可以在平時使用應用的時候檢測主線程上的各種卡慢問題,并通過組件提供的各種信息分析出原因并進行修復摧玫。官方地址:Android Performance Monitor

BlockCanary對主線程操作進行了完全透明的監(jiān)控绑青,并能輸出有效的信息诬像,幫助開發(fā)分析、定位到問題所在闸婴,迅速優(yōu)化應用坏挠。其特點有:

  • 非侵入式,簡單的兩行就打開監(jiān)控邪乍,不需要到處打點降狠,破壞代碼優(yōu)雅性。
  • 精準庇楞,輸出的信息可以幫助定位到問題所在(精確到行)榜配,不需要像Logcat一樣,慢慢去找吕晌。

目前包括了核心監(jiān)控輸出文件蛋褥,以及UI顯示卡頓信息功能。

目前的問題:由于需要獲取CPU的信息睛驳,而在API 26(Android O)以后烙心,除非系統(tǒng)級應用膜廊,普通應用無法獲取 /proc/stat目錄下的信息,導致這個插件幾乎失效淫茵,不過不妨礙我們進行學習爪瓜。

二、實用方法

2.1 引入

dependencies {
    compile 'com.github.markzhai:blockcanary-android:1.5.0'

    // 僅在debug包啟用BlockCanary進行卡頓監(jiān)控和提示的話匙瘪,可以這么用
    debugCompile 'com.github.markzhai:blockcanary-android:1.5.0'
    releaseCompile 'com.github.markzhai:blockcanary-no-op:1.5.0'
}

2.2 使用

在Application中:

public class DemoApplication extends Application {
    @Override
    public void onCreate() {
        // 在主進程初始化調用哈
        BlockCanary.install(this, new AppBlockCanaryContext()).start();
    }
}

繼承BlockCanaryContext實現(xiàn)自己的AppBlockCanaryContext :

public class AppBlockCanaryContext extends BlockCanaryContext {
    // 實現(xiàn)各種上下文铆铆,包括應用標示符,用戶uid辆苔,網(wǎng)絡類型算灸,卡慢判斷闕值,Log保存位置等

    /**
     * Implement in your project.
     *
     * @return Qualifier which can specify this installation, like version + flavor.
     */
    public String provideQualifier() {
        return "unknown";
    }

    /**
     * Implement in your project.
     *
     * @return user id
     */
    public String provideUid() {
        return "uid";
    }

    /**
     * Network type
     *
     * @return {@link String} like 2G, 3G, 4G, wifi, etc.
     */
    public String provideNetworkType() {
        return "unknown";
    }

    /**
     * Config monitor duration, after this time BlockCanary will stop, use
     * with {@code BlockCanary}'s isMonitorDurationEnd
     *
     * @return monitor last duration (in hour)
     */
    public int provideMonitorDuration() {
        return -1;
    }

    /**
     * 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 1000;
    }

    /**
     * Thread stack dump interval, use when block happens, BlockCanary will dump on main thread
     * stack according to current sample cycle.
     * <p>
     * Because the implementation mechanism of Looper, real dump interval would be longer than
     * the period specified here (especially when cpu is busier).
     * </p>
     *
     * @return dump interval (in millis)
     */
    public int provideDumpInterval() {
        return provideBlockThreshold();
    }

    /**
     * Path to save log, like "/blockcanary/", will save to sdcard if can.
     *
     * @return path of log files
     */
    public String providePath() {
        return "/blockcanary/";
    }

    /**
     * If need notification to notice block.
     *
     * @return true if need, else if not need.
     */
    public boolean displayNotification() {
        return true;
    }

    /**
     * Implement in your project, bundle files into a zip file.
     *
     * @param src  files before compress
     * @param dest files compressed
     * @return true if compression is successful
     */
    public boolean zip(File[] src, File dest) {
        return false;
    }

    /**
     * Implement in your project, bundled log files.
     *
     * @param zippedFile zipped file
     */
    public void upload(File zippedFile) {
        throw new UnsupportedOperationException();
    }


    /**
     * Packages that developer concern, by default it uses process name,
     * put high priority one in pre-order.
     *
     * @return null if simply concern only package with process name.
     */
    public List<String> concernPackages() {
        return null;
    }

    /**
     * Filter stack without any in concern package, used with @{code concernPackages}.
     *
     * @return true if filter, false it not.
     */
    public boolean filterNonConcernStack() {
        return false;
    }

    /**
     * Provide white list, entry in white list will not be shown in ui list.
     *
     * @return return null if you don't need white-list filter.
     */
    public List<String> provideWhiteList() {
        LinkedList<String> whiteList = new LinkedList<>();
        whiteList.add("org.chromium");
        return whiteList;
    }

    /**
     * Whether to delete files whose stack is in white list, used with white-list.
     *
     * @return true if delete, false it not.
     */
    public boolean deleteFilesInWhiteList() {
        return true;
    }

    /**
     * Block interceptor, developer may provide their own actions.
     */
    public void onBlock(Context context, BlockInfo blockInfo) {

    }
}

三驻啤、原理

可翻看筆者前一篇文章:安卓中的消息循環(huán)模型

利用Android中的消息處理機制菲驴,在Looper.java中這么一段:

private static Looper sMainLooper;  // guarded by Looper.class

...

/**
 * Initialize the current thread as a looper, marking it as an
 * application's main looper. The main looper for your application
 * is created by the Android environment, so you should never need
 * to call this function yourself.  See also: {@link #prepare()}
 */
public static void prepareMainLooper() {
    prepare(false);
    synchronized (Looper.class) {
        if (sMainLooper != null) {
            throw new IllegalStateException("The main Looper has already been prepared.");
        }
        sMainLooper = myLooper();
    }
}

/** Returns the application's main looper, which lives in the main thread of the application.
 */
public static Looper getMainLooper() {
    synchronized (Looper.class) {
        return sMainLooper;
    }
}

即整個應用的主線程,只有這一個looper骑冗,不管有多少handler赊瞬,最后都會回到這里。

而Looper的loop方法中有這么一段:

public static void loop() {
    ...

    for (;;) {
        ...

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

        msg.target.dispatchMessage(msg);

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

        ...
    }
}

mLogging在每個message處理的前后被調用贼涩,而如果主線程卡住了巧涧,就是在dispatchMessage里卡住了。

核心流程圖(圖源作者博客):


流程圖

BlockCanary啟動一個線程負責保存UI線程當前堆棧信息遥倦,將堆棧信息以及CPU信息保存分別保存在 mThreadStackEntries和mCpuInfoEntries中谤绳,每條信息都以時間撮為key保存。

BlockCanary注冊了logging來獲取事件開始結束時間袒哥。如果檢測到事件處理時間超過閾值(默認值1s)缩筛,則從mThreadStackEntries中查找T1T2這段時間內(nèi)的堆棧信息,并且從mCpuInfoEntries中查找T1T2這段時間內(nèi)的CPU及內(nèi)存信息堡称。并且將信息格式化后保存到本地文件瞎抛,并且通知用戶。
該組件利用了主線程的消息隊列處理機制却紧,通過

Looper.getMainLooper().setMessageLogging(mainLooperPrinter);

并在mainLooperPrinter中判斷start和end桐臊,來獲取主線程dispatch該message的開始和結束時間,并判定該時間超過閾值(如2000毫秒)為主線程卡慢發(fā)生晓殊,并dump出各種信息断凶,提供開發(fā)者分析性能瓶頸。

...
@Override
public void println(String x) {
    if (!mStartedPrinting) {
        mStartTimeMillis = System.currentTimeMillis();
        mStartThreadTimeMillis = SystemClock.currentThreadTimeMillis();
        mStartedPrinting = true;
    } else {
        final long endTime = System.currentTimeMillis();
        mStartedPrinting = false;
        if (isBlock(endTime)) {
            notifyBlockEvent(endTime);
        }
    }
}

private boolean isBlock(long endTime) {
    return endTime - mStartTimeMillis > mBlockThresholdMillis;
}
...

四挺物、源碼解讀

 BlockCanary.install(this, new AppBlockContext()).start();

首先我們看看他的入口懒浮,install這個方法:

 /**
     * Install {@link BlockCanary}
     *
     * @param context            Application context
     * @param blockCanaryContext BlockCanary context
     * @return {@link BlockCanary}
     */
    public static BlockCanary install(Context context, BlockCanaryContext blockCanaryContext) {
        BlockCanaryContext.init(context, blockCanaryContext);
        setEnabled(context, DisplayActivity.class, BlockCanaryContext.get().displayNotification());
        return get();
    }

這里調用三行代碼:

  • 調用init()方法, 記錄ApplicationBlockCanaryContext, 為后面的處理提供上下文Context和配置參數(shù)(例如: 卡頓閾值,是否顯示通知 等等...)
  • 調用setEnabled()方法, 判斷桌面是否顯示黃色的logo圖標
  • 調用get()方法, 創(chuàng)建BlockCanary的實例,并且創(chuàng)建BlockCanaryInternals實例, 賦值給mBlockCanaryCore屬性, 用來處理后面的流程
static void init(Context context, BlockCanaryContext blockCanaryContext) {
        sApplicationContext = context;
        sInstance = blockCanaryContext;
    }

這個init方法就做了一個賦值的操作,將我們傳遞過來的context進行賦值。

我們繼續(xù)看BlockCanary.start()做了什么事:

public void start() {
    if (!mMonitorStarted) {
        mMonitorStarted = true;
        Looper.getMainLooper().setMessageLogging(mBlockCanaryCore.monitor);
    }
}

start()方法只做了一件事: 給Looper設置一個Printer

那么當Looper處理消息的前后, 就會調用mBlockCanaryCore.monitor的println()方法砚著。

mBlockCanaryCore.monitor是BlockCanaryInternals的成員屬性LooperMonitor

class LooperMonitor implements Printer {
    ...
    @Override
    public void println(String x) {
        //如果StopWhenDebugging, 就不檢測
        if (mStopWhenDebugging && Debug.isDebuggerConnected()) {
            return;
        }
        if (!mPrintingStarted) {
            mStartTimestamp = System.currentTimeMillis();
            mStartThreadTimestamp = SystemClock.currentThreadTimeMillis();
            mPrintingStarted = true;
            startDump();  //在子線程中獲取調用棧和CPU信息
        } else {
            final long endTime = System.currentTimeMillis();
            mPrintingStarted = false;
            if (isBlock(endTime)) {  //判斷是否超過設置的閾值
                notifyBlockEvent(endTime);
            }
            stopDump(); //停止獲取調用棧和CPU信息
        }
    }
    //判斷是否超過設置的閾值
    private boolean isBlock(long endTime) {
        return endTime - mStartTimestamp > mBlockThresholdMillis;
    }
    ...
}

LooperMonitor的println()就是最核心的地方, 實現(xiàn)代碼也很簡單:

  • Looper處理消息前, 獲取當前時間并且保存, 調用startDump()啟動一個任務定時去采集 調用棧/CPU 等等信息
  • Looper處理消息完成, 獲取當前時間, 判斷是否超過我們自定義的閾值isBlock(endTime)如果超過了, 就調用notifyBlockEvent(endTime)來通知處理后面的流程
  • 調用stopDump()停止獲取調用棧以及CPU的任務

startDump采集的信息包括:

  • 基本信息:機型, CPU內(nèi)核數(shù), 進程名, 內(nèi)存, 版本號 等等
  • 耗時信息:實際耗時, 主線程時鐘耗時, 卡頓開始時間和結束時間
  • CPU信息:時間段內(nèi)CPU是否忙, 時間段內(nèi)的系統(tǒng)CPU/應用CPU占比, I/O占- - CPU使用率
  • 堆棧信息:發(fā)生卡頓前的最近堆棧

五次伶、總結

blockcanary完美利用了安卓上的消息機制,給Looper設置一個Printer稽穆,通過記錄堆棧和CPU信息冠王,計算主線程處理消息的時間,如果超過了閾值舌镶,就檢索此時的堆棧和cpu信息來幫助分析卡頓原因柱彻。

BlockCanary — 輕松找出Android App界面卡頓元兇
GitHub:BlockCanary

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市餐胀,隨后出現(xiàn)的幾起案子哟楷,更是在濱河造成了極大的恐慌,老刑警劉巖否灾,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件卖擅,死亡現(xiàn)場離奇詭異,居然都是意外死亡墨技,警方通過查閱死者的電腦和手機惩阶,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來扣汪,“玉大人断楷,你說我怎么就攤上這事≌副穑” “怎么了冬筒?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長茅主。 經(jīng)常有香客問我账千,道長,這世上最難降的妖魔是什么暗膜? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮鞭衩,結果婚禮上学搜,老公的妹妹穿的比我還像新娘。我一直安慰自己论衍,他們只是感情好瑞佩,可當我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著坯台,像睡著了一般炬丸。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天稠炬,我揣著相機與錄音焕阿,去河邊找鬼。 笑死首启,一個胖子當著我的面吹牛暮屡,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播毅桃,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼褒纲,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了钥飞?” 一聲冷哼從身側響起莺掠,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎读宙,沒想到半個月后彻秆,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡论悴,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年掖棉,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片膀估。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡幔亥,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出察纯,到底是詐尸還是另有隱情帕棉,我是刑警寧澤,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布饼记,位于F島的核電站香伴,受9級特大地震影響,放射性物質發(fā)生泄漏具则。R本人自食惡果不足惜即纲,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望博肋。 院中可真熱鬧低斋,春花似錦、人聲如沸匪凡。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽病游。三九已至唇跨,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背买猖。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工改橘, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人政勃。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓唧龄,卻偏偏與公主長得像,于是被迫代替她去往敵國和親奸远。 傳聞我的和親對象是個殘疾皇子既棺,可洞房花燭夜當晚...
    茶點故事閱讀 42,901評論 2 345

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