Android卡頓檢測(一)BlockCanary

卡頓檢測是個至關大的話題,檢測場景小到本機測試、自動化測試、本地監控,大到線上抽樣採集上報。卡頓緣由也千差萬別,跟CPU、內存、I/O可能都有關。本系列文章旨在經過一些經常使用的本地卡頓檢測工具來定位卡頓緣由,並分析其底層實現原理。若是想自研一些APM工具這些原理必須掌握。html

卡頓分析工具概覽.png

談到卡頓首先想到的就是BlockCanary,它以其簡單易用的特色被普遍用於檢測全局的卡頓狀況,咱們有必要首先了解一下它內部的原理。本篇先來看看BlockCanary項目傳送門戳這裏java

最新版本

com.github.markzhai:blockcanary-android:1.5.0
複製代碼

BlockCanary原理解析

咱們知道Android Framework 不少業務都是經過消息機制完成的,包括UI繪製更新、四大組件生命週期、ANR檢查等等。android

消息機制給咱們一個啓發,咱們能夠監測主線程消息處理的狀況來追蹤卡頓問題。以UI渲染爲例,主線程Choreographer(Android 4.1及之後)每16ms請求一個vsync信號,當信號到來時觸發doFrame操做,它內部又依次進行了input、Animation、Traversal過程(具體流程分析參考好文Android Choreographer 源碼分析),而這些都是經過消息機制驅動的。git

BlockCanary檢測的原理也是基於主線程消息的處理流程。既然要檢測主線程消息處理狀況,那先要清楚主線程Looper對象的建立。github

# -> 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函數是Android程序的入口,它並非一個線程類,它運行在主線程中。能夠看到經過prepareMainLooper和loop函數使主線程的looper跑起來了。shell

再看loop方法bash

# -> 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函數內部使用了死循環,主線程爲何不會卡死?爲何不會觸發ANR?文末有參考文章。網絡

dispatchMessage函數會對消息進行分發,並交由對應的runnable或handler處理,因此監控主線程的卡頓問題實際上就是監控dispatchMessage函數的耗時狀況。app

能夠看到在dispatchMessage先後各有一次logging的打印,而且調用println方法的logging對象還能夠經過setMessageLogging方法設置,也就是說Looper內部自己就提供了hook點。ide

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

咱們能夠自定義一個Printer並複寫其println函數來實現卡頓的監控。事實上,BlockCanary就是這麼作的。監控到卡頓點後,dump函數調用堆棧並獲取CPU運行狀況,即可綜合分析卡頓的緣由。

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;
        //設置自定義printer
        Looper.getMainLooper().setMessageLogging(mBlockCanaryCore.monitor);
    }
}
複製代碼

這裏的mBlockCanaryCore.monitor就是LooperMonitor對象,它實現了Printer接口。 咱們重點看一下它的println方法。

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

主線已經清楚,咱們先大體看一下BlockCanary運行的核心流程把握全局。

官方流程圖
再來看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();
    }
}
複製代碼

可見內部有一個調用堆棧採樣器和cpu採樣器。 這裏有一點須要注意:採樣開始的時間點爲0.8*卡頓閾值。爲何不在卡頓閾值那個點採樣呢?這裏實際上是一種容錯處理。 假設當前函數調用及實際耗時狀況以下,卡頓閾值設置爲220。

fun foo () {
    a()//函數耗時200
    b()//函數耗時20
    c()//函數耗時10
}
複製代碼

可見致使卡頓的罪魁禍首應該是函數a,但若是在卡頓閾值220纔開始dump調用堆棧,有可能捕獲到的卡頓堆棧爲foo() -> b()或c(),設置0.8倍的預採樣點就是爲了下降這種狀況出現的概率。咱們悲觀的認爲當前已超過80%卡頓閾值的函數就是致使卡頓的主因。

回到採樣流程來,首先看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());
    }
}
複製代碼

很簡單,就是獲取當前線程的堆棧信息,並保存在一個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
        ...
    }
}
複製代碼

這裏是依據Linux系統cpu的統計方式,Linux系統會將cpu信息和當前進程信息分別存放在/proc/stat和/proc/pid/stat文件中,具體統計原理參看Linux平臺Cpu使用率的計算

經過CPU的使用狀況能夠大體瞭解系統的運行狀況,CPU若是處於高負載狀態,多是在作CPU密集型計算。若是CPU負載正常,可能處於IO密集狀態。

當信息都採集完成後咱們回到主線代碼。

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

//判斷是否發生了卡頓
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的統計,它經過函數SystemClock.currentThreadTimeMillis()獲取,它反映的是線程處於running狀態下的時間,這裏須要一張Thread運行狀態圖。

線程狀態.png

因此好比經過調用thread.sleep方式致使卡頓時並不會統計到threadTime中的。也就是說threadTime反映的是線程真正運行的時間,中間好比鎖的獲取、cpu的調度及其餘非running狀態等狀況不計算在內。

onBlockEvent的實如今BlockCanary建立之初。

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();
                //寫入文件系統
                LogWriter.save(blockInfo.toString());

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

    LogWriter.cleanObsolete();
}
複製代碼

mInterceptorChain目前註冊了兩個回調,一個是DisplayService,它收到block消息會發送通知。另外一個是BlockCanaryContext,咱們能夠經過自定義BlockCanaryContext並複寫onBlock方法作額外的處理,好比上報網絡。

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

}
複製代碼

接下來就能夠經過通知消息查看卡頓的具體信息。電視端若是屏蔽了通知欄,可在應用列表中找到入口,若是應用列表入口也被系統屏蔽,可直接使用adb命令打開。

adb shell am start <packageName>/com.github.moduth.blockcanary.ui.DisplayActivity
複製代碼

BlockCanary的不足

  • 全局性,只能在初始化以後使用,初始化以前的卡頓問題沒法分析,好比Application的attachBaseContext函數。這一點只能經過系通通計工具(Traceview/Systrace)或手動插樁。
  • 準確性,因爲其使用0.8倍的卡頓閾值做爲採樣點,仍可能出現不能準確識別卡頓函數的狀況。
  • 卡頓閾值把控,手動設置的卡頓閾值是全局的,但對於某個重要場景咱們的要求可能更爲嚴苛,這樣就須要在不一樣的業務場景設置不一樣的卡頓閾值。
  • 細粒度的函數耗時評估,BlockCanary只能告訴咱們當前的卡頓函數是哪一個,但不能準確的告知到底卡頓了多久,這對於卡頓優化來講是更爲精細的指標(Hugo就能夠優雅的解決這個問題)。

參考文章

相關文章
相關標籤/搜索