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