Android UI效能優化 檢測應用中的UI卡頓
一、概述
在做app效能優化的時候,大家都希望能夠寫出絲滑的UI介面,以前寫過一篇部落格,主要是基於Google當時釋出的效能優化典範,主要提供一些UI優化效能示例:
實際上,由於各種機型的配置不同、程式碼迭代歷史悠久,程式碼中可能會存在很多在UI執行緒耗時的操作,所以我們希望有一套簡單檢測機制,幫助我們定位耗時發生的位置。
本篇部落格主要描述如何檢測應用在UI執行緒的卡頓,目前已經有兩種比較典型方式來檢測了:
- 利用UI執行緒Looper列印的日誌
- 利用Choreographer
兩種方式都有一些開源專案,例如:
其實編寫本篇文章,主要是因為發現一個還比較有意思的方案,該方法的靈感來源於一篇給我微信投稿的文章:
該專案主要用於捕獲UI執行緒的crash,當我看完該專案原理的時候,也可以用來作為檢測卡段方案,可能還可以做一些別的事情。
所以,本文出現了3種檢測UI卡頓的方案,3種方案原理都比較簡單,接下來將逐個介紹。
二、利用loop()中列印的日誌
(1)原理
大家都知道在Android UI執行緒中有個Looper,在其loop方法中會不斷取出Message,呼叫其繫結的Handler在UI執行緒進行執行。
大致程式碼如下:
public static void loop() {
final Looper me = myLooper();
final MessageQueue queue = me.mQueue;
// ...
for (;;) {
Message msg = queue.next(); // might block
// 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);
}
// focus
msg.target.dispatchMessage(msg);
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
// ...
}
msg.recycleUnchecked();
}
}
所以很多時候,我們只要有辦法檢測:
msg.target.dispatchMessage(msg);
此行程式碼的執行時間,就能夠檢測到部分UI執行緒是否有耗時操作了。可以看到在執行此程式碼前後,如果設定了logging,會分別打印出>>>>> Dispatching to
和<<<<< Finished to
這樣的log。
我們可以通過計算兩次log之間的時間差值,大致程式碼如下:
public class BlockDetectByPrinter {
public static void start() {
Looper.getMainLooper().setMessageLogging(new Printer() {
private static final String START = ">>>>> Dispatching";
private static final String END = "<<<<< Finished";
@Override
public void println(String x) {
if (x.startsWith(START)) {
LogMonitor.getInstance().startMonitor();
}
if (x.startsWith(END)) {
LogMonitor.getInstance().removeMonitor();
}
}
});
}
}
假設我們的閾值是1000ms,當我在匹配到>>>>> Dispatching
時,我會在1000ms毫秒後執行一個任務(打印出UI執行緒的堆疊資訊,會在非UI執行緒中進行);正常情況下,肯定是低於1000ms執行完成的,所以當我匹配到<<<<< Finished
,會移除該任務。
大概程式碼如下:
public class LogMonitor {
private static LogMonitor sInstance = new LogMonitor();
private HandlerThread mLogThread = new HandlerThread("log");
private Handler mIoHandler;
private static final long TIME_BLOCK = 1000L;
private LogMonitor() {
mLogThread.start();
mIoHandler = new Handler(mLogThread.getLooper());
}
private static Runnable mLogRunnable = new Runnable() {
@Override
public void run() {
StringBuilder sb = new StringBuilder();
StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
for (StackTraceElement s : stackTrace) {
sb.append(s.toString() + "\n");
}
Log.e("TAG", sb.toString());
}
};
public static LogMonitor getInstance() {
return sInstance;
}
public boolean isMonitor() {
return mIoHandler.hasCallbacks(mLogRunnable);
}
public void startMonitor() {
mIoHandler.postDelayed(mLogRunnable, TIME_BLOCK);
}
public void removeMonitor() {
mIoHandler.removeCallbacks(mLogRunnable);
}
}
我們利用了HandlerThread這個類,同樣利用了Looper機制,只不過在非UI執行緒中,如果執行耗時達到我們設定的閾值,則會執行mLogRunnable
,打印出UI執行緒當前的堆疊資訊;如果你閾值時間之內完成,則會remove掉該runnable。
(2)測試
用法很簡單,在Application的onCreate中呼叫:
BlockDetectByPrinter.start();
即可。
然後我們在Activity裡面,點選一個按鈕,讓睡眠2s,測試下:
findViewById(R.id.id_btn02)
.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
}
});
執行點選時,會打印出log:
02-21 00:26:26.408 2999-3014/com.zhy.testlp E/TAG:
java.lang.VMThread.sleep(Native Method)
java.lang.Thread.sleep(Thread.java:1013)
java.lang.Thread.sleep(Thread.java:995)
com.zhy.testlp.MainActivity$2.onClick(MainActivity.java:70)
android.view.View.performClick(View.java:4438)
android.view.View$PerformClick.run(View.java:18422)
android.os.Handler.handleCallback(Handler.java:733)
android.os.Handler.dispatchMessage(Handler.java:95)
會打印出耗時相關程式碼的資訊,然後可以通過該log定位到耗時的地方。
三、 利用Choreographer
Android系統每隔16ms發出VSYNC訊號,觸發對UI進行渲染。SDK中包含了一個相關類,以及相關回調。理論上來說兩次回撥的時間週期應該在16ms,如果超過了16ms我們則認為發生了卡頓,我們主要就是利用兩次回撥間的時間週期來判斷:
大致程式碼如下:
public class BlockDetectByChoreographer {
public static void start() {
Choreographer.getInstance()
.postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long l) {
if (LogMonitor.getInstance().isMonitor()) {
LogMonitor.getInstance().removeMonitor();
}
LogMonitor.getInstance().startMonitor();
Choreographer.getInstance().postFrameCallback(this);
}
});
}
}
第一次的時候開始檢測,如果大於閾值則輸出相關堆疊資訊,否則則移除。
使用方式和上述一致。
四、 利用Looper機制
先看一段程式碼:
new Handler(Looper.getMainLooper())
.post(new Runnable() {
@Override
public void run() {}
}
該程式碼在UI執行緒中的MessageQueue中插入一個Message,最終會在loop()方法中取出並執行。
假設,我在run方法中,拿到MessageQueue,自己執行原本的Looper.loop()
方法邏輯,那麼後續的UI執行緒的Message就會將直接讓我們處理,這樣我們就可以做一些事情:
public class BlockDetectByLooper {
private static final String FIELD_mQueue = "mQueue";
private static final String METHOD_next = "next";
public static void start() {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
try {
Looper mainLooper = Looper.getMainLooper();
final Looper me = mainLooper;
final MessageQueue queue;
Field fieldQueue = me.getClass().getDeclaredField(FIELD_mQueue);
fieldQueue.setAccessible(true);
queue = (MessageQueue) fieldQueue.get(me);
Method methodNext = queue.getClass().getDeclaredMethod(METHOD_next);
methodNext.setAccessible(true);
Binder.clearCallingIdentity();
for (; ; ) {
Message msg = (Message) methodNext.invoke(queue);
if (msg == null) {
return;
}
LogMonitor.getInstance().startMonitor();
msg.getTarget().dispatchMessage(msg);
msg.recycle();
LogMonitor.getInstance().removeMonitor();
}
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
}
其實很簡單,將Looper.loop裡面本身的程式碼直接copy來了這裡。當這個訊息被處理後,後續的訊息都將會在這裡進行處理。
中間有變數和方法需要反射來呼叫,不過不影響檢視
msg.getTarget().dispatchMessage(msg);
執行時間,但是就不要在線上使用這種方式了。
不過該方式和以上兩個方案對比,並無優勢,不過這個思路挺有意思的。
使用方式和上述一致。
最後,可以考慮將卡頓日誌輸出到檔案,慢慢分析;可以結合上述原理以及自己需求開發做一個合適的方案,也可以參考已有開源方案。
參考
我的微信公眾號:hongyangAndroid
(可以給我留言你想學習的文章,支援投稿)