Android卡頓檢測工具(一)BlockCanary

卡頓檢測是個相當(dāng)大的話題,檢測場景小到本機(jī)測試、自動化測試、本地監(jiān)控,大到線上抽樣采集上報。卡頓原因也千差萬別,跟CPU、內(nèi)存、I/O可能都有關(guān)。本系列文章旨在通過一些常用的本地卡頓檢測工具來定位卡頓原因,并分析其底層實(shí)現(xiàn)原理。如果想自研一些APM工具這些原理必須掌握。

卡頓分析工具概覽.png

談到卡頓首先想到的就是BlockCanary,它以其簡單易用的特點(diǎn)被廣泛用于檢測全局的卡頓情況,我們有必要首先了解一下它內(nèi)部的原理。本篇先來看看BlockCanary項(xiàng)目傳送門戳這里

最新版本

com.github.markzhai:blockcanary-android:1.5.0

BlockCanary原理解析

我們知道Android Framework 很多業(yè)務(wù)都是通過消息機(jī)制完成的,包括UI繪制更新、四大組件生命周期、ANR檢查等等。

消息機(jī)制給我們一個啟發(fā),我們可以監(jiān)測主線程消息處理的情況來追蹤卡頓問題。以UI渲染為例,主線程Choreographer(Android 4.1及以后)每16ms請求一個vsync信號,當(dāng)信號到來時觸發(fā)doFrame操作,它內(nèi)部又依次進(jìn)行了input、Animation、Traversal過程(具體流程分析參考好文Android Choreographer 源碼分析),而這些都是通過消息機(jī)制驅(qū)動的。

BlockCanary檢測的原理也是基于主線程消息的處理流程。既然要檢測主線程消息處理情況,那先要清楚主線程Looper對象的創(chuàng)建。

# -> ActivityThread
public static void main(String[] args) {
    ...

    Looper.prepareMainLooper();

    ActivityThread thread = new ActivityThread();
    thread.attach(false);

    if (sMainThreadHandler == null) {
        sMainThreadHandler = thread.getHandler();
    }

    if (false) {
        Looper.myLooper().setMessageLogging(new
                LogPrinter(Log.DEBUG, "ActivityThread"));
    }

    ...
    Looper.loop();

    throw new RuntimeException("Main thread loop unexpectedly exited");
}

ActivityThread的main函數(shù)是Android程序的入口,它并不是一個線程類,它運(yùn)行在主線程中。可以看到通過prepareMainLooper和loop函數(shù)使主線程的looper跑起來了。

再看loop方法

# -> Looper.java
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;
    ...

    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
        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);
        }

        ...

        msg.recycleUnchecked();
    }
}

這里先留一個問題loop函數(shù)內(nèi)部使用了死循環(huán),主線程為什么不會卡死?為什么不會觸發(fā)ANR?文末有參考文章。

dispatchMessage函數(shù)會對消息進(jìn)行分發(fā),并交由對應(yīng)的runnable或handler處理,所以監(jiān)控主線程的卡頓問題實(shí)際上就是監(jiān)控dispatchMessage函數(shù)的耗時情況。

可以看到在dispatchMessage前后各有一次logging的打印,并且調(diào)用println方法的logging對象還可以通過setMessageLogging方法設(shè)置,也就是說Looper內(nèi)部本身就提供了hook點(diǎn)。

# -> Looper.java
public void setMessageLogging(@Nullable Printer printer) {
    mLogging = printer;
}

我們可以自定義一個Printer并復(fù)寫其println函數(shù)來實(shí)現(xiàn)卡頓的監(jiān)控。事實(shí)上,BlockCanary就是這么做的。監(jiān)控到卡頓點(diǎn)后,dump函數(shù)調(diào)用堆棧并獲取CPU運(yùn)行情況,便可綜合分析卡頓的原因。

BlockCanary源碼分析

來看看BlockCanary初始化的方法install和start。

# -> BlockCanary.java

/**
 * 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();
}
# -> BlockCanary.java
public void start() {
    if (!mMonitorStarted) {
        mMonitorStarted = true;
        //設(shè)置自定義printer
        Looper.getMainLooper().setMessageLogging(mBlockCanaryCore.monitor);
    }
}

這里的mBlockCanaryCore.monitor就是LooperMonitor對象,它實(shí)現(xiàn)了Printer接口。
我們重點(diǎn)看一下它的println方法。

# -> LooperMonitor.java
public void println(String x) {
    if (mStopWhenDebugging && Debug.isDebuggerConnected()) {
        return;
    }
    if (!mPrintingStarted) {
        //dispatchMessage前一次打印進(jìn)入這里
        mStartTimestamp = System.currentTimeMillis();
        mStartThreadTimestamp = SystemClock.currentThreadTimeMillis();
        mPrintingStarted = true;
        //開始dump信息
        startDump();
    } else {
        //dispatchMessage后一次打印進(jìn)入這里
        final long endTime = System.currentTimeMillis();
        mPrintingStarted = false;
        //判斷是否發(fā)生卡頓
        if (isBlock(endTime)) {
            //存儲dump下來的信息并通知
            notifyBlockEvent(endTime);
        }
        //停止dump
        stopDump();
    }
}

主線已經(jīng)清楚,我們先大致看一下BlockCanary運(yùn)行的核心流程把握全局。

官方流程圖

再來看startDump和stopDump

# -> LooperMonitor.java
private void startDump() {
    if (null != BlockCanaryInternals.getInstance().stackSampler) {
        BlockCanaryInternals.getInstance().stackSampler.start();
    }

    if (null != BlockCanaryInternals.getInstance().cpuSampler) {
        BlockCanaryInternals.getInstance().cpuSampler.start();
    }
}

private void stopDump() {
    if (null != BlockCanaryInternals.getInstance().stackSampler) {
        BlockCanaryInternals.getInstance().stackSampler.stop();
    }

    if (null != BlockCanaryInternals.getInstance().cpuSampler) {
        BlockCanaryInternals.getInstance().cpuSampler.stop();
    }
}

可見內(nèi)部有一個調(diào)用堆棧采樣器和cpu采樣器。
這里有一點(diǎn)需要注意:采樣開始的時間點(diǎn)為0.8*卡頓閾值。為什么不在卡頓閾值那個點(diǎn)采樣呢?這里其實(shí)是一種容錯處理。
假設(shè)當(dāng)前函數(shù)調(diào)用及實(shí)際耗時情況如下,卡頓閾值設(shè)置為220。

fun foo () {
    a()//函數(shù)耗時200
    b()//函數(shù)耗時20
    c()//函數(shù)耗時10
}

可見導(dǎo)致卡頓的罪魁禍?zhǔn)讘?yīng)該是函數(shù)a,但如果在卡頓閾值220才開始dump調(diào)用堆棧,有可能捕獲到的卡頓堆棧為foo() -> b()或c(),設(shè)置0.8倍的預(yù)采樣點(diǎn)就是為了降低這種情況出現(xiàn)的幾率。我們悲觀的認(rèn)為當(dāng)前已超過80%卡頓閾值的函數(shù)就是導(dǎo)致卡頓的主因。

回到采樣流程來,首先看stackSampler是如何采樣的。

# -> StackSampler.java
protected void doSample() {
    StringBuilder stringBuilder = new StringBuilder();

    for (StackTraceElement stackTraceElement : mCurrentThread.getStackTrace()) {
        stringBuilder
                .append(stackTraceElement.toString())
                .append(BlockInfo.SEPARATOR);
    }

    synchronized (sStackMap) {
        if (sStackMap.size() == mMaxEntryCount && mMaxEntryCount > 0) {
            sStackMap.remove(sStackMap.keySet().iterator().next());
        }
        sStackMap.put(System.currentTimeMillis(), stringBuilder.toString());
    }
}

很簡單,就是獲取當(dāng)前線程的堆棧信息,并保存在一個LinkedHashMap對象sStackMap中。

再來看cpuSampler的處理

# -> CpuSampler
@Override
protected void doSample() {
    BufferedReader cpuReader = null;
    BufferedReader pidReader = null;

    try {
        cpuReader = new BufferedReader(new InputStreamReader(
                new FileInputStream("/proc/stat")), BUFFER_SIZE);
        String cpuRate = cpuReader.readLine();
        if (cpuRate == null) {
            cpuRate = "";
        }

        if (mPid == 0) {
            mPid = android.os.Process.myPid();
        }
        pidReader = new BufferedReader(new InputStreamReader(
                new FileInputStream("/proc/" + mPid + "/stat")), BUFFER_SIZE);
        String pidCpuRate = pidReader.readLine();
        if (pidCpuRate == null) {
            pidCpuRate = "";
        }

        parse(cpuRate, pidCpuRate);
    } catch (Throwable throwable) {
        Log.e(TAG, "doSample: ", throwable);
    } finally {
        //release resource
        ...
    }
}

這里是依據(jù)Linux系統(tǒng)cpu的統(tǒng)計方式,Linux系統(tǒng)會將cpu信息和當(dāng)前進(jìn)程信息分別存放在/proc/stat和/proc/pid/stat文件中,具體統(tǒng)計原理參看Linux平臺Cpu使用率的計算

通過CPU的使用情況可以大致了解系統(tǒng)的運(yùn)行情況,CPU如果處于高負(fù)載狀態(tài),可能是在做CPU密集型計算。如果CPU負(fù)載正常,可能處于IO密集狀態(tài)。

當(dāng)信息都采集完成后我們回到主線代碼。

# -> LooperMonitor
@Override
public void println(String x) {
    if (mStopWhenDebugging && Debug.isDebuggerConnected()) {
        return;
    }
    if (!mPrintingStarted) {
        mStartTimestamp = System.currentTimeMillis();
        mStartThreadTimestamp = SystemClock.currentThreadTimeMillis();
        mPrintingStarted = true;
        startDump();
    } else {
        final long endTime = System.currentTimeMillis();
        mPrintingStarted = false;
        if (isBlock(endTime)) {
            notifyBlockEvent(endTime);
        }
        stopDump();
    }
}

//判斷是否發(fā)生了卡頓
private boolean isBlock(long endTime) {
    return endTime - mStartTimestamp > mBlockThresholdMillis;
}

private void notifyBlockEvent(final long endTime) {
    final long startTime = mStartTimestamp;
    final long startThreadTime = mStartThreadTimestamp;
    final long endThreadTime = SystemClock.currentThreadTimeMillis();
    //通知寫日志線程記錄日志
    HandlerThreadFactory.getWriteLogThreadHandler().post(new Runnable() {
        @Override
        public void run() {
           mBlockListener.onBlockEvent(startTime, endTime, startThreadTime, endThreadTime);
        }
    });
}

這里需要注意的是對于threadTime的統(tǒng)計,它通過函數(shù)SystemClock.currentThreadTimeMillis()獲取,它反映的是線程處于running狀態(tài)下的時間,這里需要一張Thread運(yùn)行狀態(tài)圖。

線程狀態(tài).png

所以比如通過調(diào)用thread.sleep方式導(dǎo)致卡頓時并不會統(tǒng)計到threadTime中的。也就是說threadTime反映的是線程真正運(yùn)行的時間,中間比如鎖的獲取、cpu的調(diào)度及其他非running狀態(tài)等情況不計算在內(nèi)。

onBlockEvent的實(shí)現(xiàn)在BlockCanary創(chuàng)建之初。

public BlockCanaryInternals() {
    stackSampler = new StackSampler(
            Looper.getMainLooper().getThread(),
            sContext.provideDumpInterval());
    cpuSampler = new CpuSampler(sContext.provideDumpInterval());

    setMonitor(new LooperMonitor(new LooperMonitor.BlockListener() {

        @Override
        public void onBlockEvent(long realTimeStart, long realTimeEnd,
                                 long threadTimeStart, long threadTimeEnd) {
            // Get recent thread-stack entries and cpu usage
            ArrayList<String> threadStackEntries = stackSampler
                    .getThreadStackEntries(realTimeStart, realTimeEnd);
            if (!threadStackEntries.isEmpty()) {
                BlockInfo blockInfo = BlockInfo.newInstance()
                        .setMainThreadTimeCost(realTimeStart, realTimeEnd, threadTimeStart, threadTimeEnd)
                        .setCpuBusyFlag(cpuSampler.isCpuBusy(realTimeStart, realTimeEnd))
                        .setRecentCpuRate(cpuSampler.getCpuRateInfo())
                        .setThreadStackEntries(threadStackEntries)
                        .flushString();
                //寫入文件系統(tǒng)
                LogWriter.save(blockInfo.toString());

                if (mInterceptorChain.size() != 0) {
                    for (BlockInterceptor interceptor : mInterceptorChain) {
                      //回調(diào)觀察者,發(fā)送通知
                      interceptor.onBlock(getContext().provideContext(), blockInfo);
                    }
                }
            }
        }
    }, getContext().provideBlockThreshold(), getContext().stopWhenDebugging()));

    LogWriter.cleanObsolete();
}

mInterceptorChain目前注冊了兩個回調(diào),一個是DisplayService,它收到block消息會發(fā)送通知。另一個是BlockCanaryContext,我們可以通過自定義BlockCanaryContext并復(fù)寫onBlock方法做額外的處理,比如上報網(wǎng)絡(luò)。

# -> BlockCanaryContext
/**
 * Block interceptor, developer may provide their own actions.
 */
@Override
public void onBlock(Context context, BlockInfo blockInfo) {

}

BlockCanary的不足

  • 全局性,只能在初始化之后使用,初始化之前的卡頓問題無法分析,比如Application的attachBaseContext函數(shù)。這一點(diǎn)只能通過系統(tǒng)統(tǒng)計工具(Traceview/Systrace)或手動插樁。
  • 準(zhǔn)確性,由于其使用0.8倍的卡頓閾值作為采樣點(diǎn),仍可能出現(xiàn)不能準(zhǔn)確識別卡頓函數(shù)的情況。
  • 卡頓閾值把控,手動設(shè)置的卡頓閾值是全局的,但對于某個重要場景我們的要求可能更為嚴(yán)苛,這樣就需要在不同的業(yè)務(wù)場景設(shè)置不同的卡頓閾值。
  • 細(xì)粒度的函數(shù)耗時評估,BlockCanary只能告訴我們當(dāng)前的卡頓函數(shù)是哪個,但不能準(zhǔn)確的告知到底卡頓了多久,這對于卡頓優(yōu)化來說是更為精細(xì)的指標(biāo)(Hugo就可以優(yōu)雅的解決這個問題)。

下一篇:微信自研APM利器Matrix 卡頓分析工具之(二)Trace Canary

參考文章

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

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