Android教你如何發(fā)現(xiàn)APP卡頓的實(shí)現(xiàn)
最近部門打算優(yōu)化下 APP 在低端機(jī)上的卡頓情況,既然想優(yōu)化,就必須獲取卡頓情況,那么如何獲取卡頓情況就是本文目的。
一般主線程過(guò)多的 UI 繪制、大量的 IO 操作或是大量的計(jì)算操作占用 CPU,導(dǎo)致 App 界面卡頓。只要我們能在發(fā)生卡頓的時(shí)候,捕捉到主線程的堆棧信息和系統(tǒng)的資源使用信息,即可準(zhǔn)確分析卡頓發(fā)生在什么函數(shù),資源占用情況如何。那么問(wèn)題就是如何有效檢測(cè) Android 主線程的卡頓發(fā)生?
用 adb 系統(tǒng)工具觀察 App 的卡頓數(shù)據(jù)情況,試圖重現(xiàn)場(chǎng)景來(lái)定位問(wèn)題。
常用的方式是使用 adb SurfaceFlinger 服務(wù)和 adb gfxinfo 功能,在自動(dòng)化操作 app 的過(guò)程中,使用 adb 獲取數(shù)據(jù)來(lái)監(jiān)控 app 的流暢情況,發(fā)現(xiàn)出現(xiàn)出現(xiàn)卡頓的時(shí)間段,尋找出現(xiàn)卡頓的場(chǎng)景和操作。
方式1:adb shell dumpsysSurfaceFlinger
使用 ‘a(chǎn)db shell dumpsysSurfaceFlinger' 命令即可獲取最近 127 幀的數(shù)據(jù),通過(guò)定期執(zhí)行 adb 命令,獲取幀數(shù)來(lái)計(jì)算出幀率 FPS。
方式2:adb shell dumpsys gfxinfo
使用 ‘a(chǎn)db shell dumpsys gfxinfo' 命令即可獲取最新 128 幀的繪制信息,詳細(xì)包括每一幀繪制的 Draw,Process,Execute 三個(gè)過(guò)程的耗時(shí),如果這三個(gè)時(shí)間總和超過(guò) 16.6ms 即認(rèn)為是發(fā)生了卡頓。
已有的兩種方案比較適合衡量回歸卡頓問(wèn)題的修復(fù)效果和判斷某些特定場(chǎng)景下是否有卡頓情況,然而,這樣的方式有幾個(gè)明顯的不足:
- 一般很難構(gòu)造實(shí)際用戶卡頓的環(huán)境來(lái)重現(xiàn);
- 這種方式操作起來(lái)比較麻煩,需編寫自動(dòng)化用例,無(wú)法覆蓋大量的可疑場(chǎng)景,測(cè)試重現(xiàn)耗時(shí)耗人力;
- 無(wú)法衡量靜態(tài)頁(yè)面的卡頓情況;
- 出現(xiàn)卡頓的時(shí)候app無(wú)法及時(shí)獲取運(yùn)行狀態(tài)和信息,開(kāi)發(fā)定位困難。
隨著對(duì)Android 源碼的深入研究,也有了其他兩種比較方便的方式,并且這兩種方式侵入性小,占用內(nèi)存低,能夠更好的用在實(shí)際場(chǎng)景中:
- 利用UI線程的Looper打印的日志匹配;
- 使用Choreographer.FrameCallback
利用 UI 線程的 Looper 打印的日志匹配
Android 主線程更新 UI。如果界面1秒鐘刷新少于 60 次,即 FPS 小于 60,用戶就會(huì)產(chǎn)生卡頓感覺(jué)。簡(jiǎn)單來(lái)說(shuō),Android 使用消息機(jī)制進(jìn)行 UI 更新,UI 線程有個(gè) Looper,在其 loop方法中會(huì)不斷取出 message,調(diào)用其綁定的 Handler 在 UI 線程執(zhí)行。如果在 handler 的 dispatchMesaage 方法里有耗時(shí)操作,就會(huì)發(fā)生卡頓。
下面來(lái)看下 Looper.loop( ) 的源碼
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; // Make sure the identity of this thread is that of the local process, // and keep track of what that identity token actually is. Binder.clearCallingIdentity(); final long ident = Binder.clearCallingIdentity(); // Allow overriding a threshold with a system prop. e.g. // adb shell 'setprop log.looper.1000.main.slow 1 && stop && start' final int thresholdOverride = SystemProperties.getInt("log.looper." + Process.myUid() + "." + Thread.currentThread().getName() + ".slow", 0); boolean slowDeliveryDetected = false; 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 final Printer logging = me.mLogging; if (logging != null) { logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what); } // Make sure the observer won't change while processing a transaction. final Observer observer = sObserver; final long traceTag = me.mTraceTag; long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs; long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs; if (thresholdOverride > 0) { slowDispatchThresholdMs = thresholdOverride; slowDeliveryThresholdMs = thresholdOverride; } final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0); final boolean logSlowDispatch = (slowDispatchThresholdMs > 0); final boolean needStartTime = logSlowDelivery || logSlowDispatch; final boolean needEndTime = logSlowDispatch; if (traceTag != 0 && Trace.isTagEnabled(traceTag)) { Trace.traceBegin(traceTag, msg.target.getTraceName(msg)); } final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0; final long dispatchEnd; Object token = null; if (observer != null) { token = observer.messageDispatchStarting(); } long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid); try { msg.target.dispatchMessage(msg); if (observer != null) { observer.messageDispatched(token, msg); } dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0; } catch (Exception exception) { if (observer != null) { observer.dispatchingThrewException(token, msg, exception); } throw exception; } finally { ThreadLocalWorkSource.restore(origWorkSource); if (traceTag != 0) { Trace.traceEnd(traceTag); } } if (logSlowDelivery) { if (slowDeliveryDetected) { if ((dispatchStart - msg.when) <= 10) { Slog.w(TAG, "Drained"); slowDeliveryDetected = false; } } else { if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery", msg)) { // Once we write a slow delivery log, suppress until the queue drains. slowDeliveryDetected = true; } } } if (logSlowDispatch) { showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg); } if (logging != null) { logging.println("<<<<< Finished to " + msg.target + " " + msg.callback); } // Make sure that during the course of dispatching the // identity of the thread wasn't corrupted. final long newIdent = Binder.clearCallingIdentity(); if (ident != newIdent) { Log.wtf(TAG, "Thread identity changed from 0x" + Long.toHexString(ident) + " to 0x" + Long.toHexString(newIdent) + " while dispatching to " + msg.target.getClass().getName() + " " + msg.callback + " what=" + msg.what); } msg.recycleUnchecked(); } }
代碼中兩處標(biāo)紅的地方,就是 msg.target.dispatchMessage(msg) 的執(zhí)行前后索打印的 log。通過(guò)測(cè)量處理時(shí)間就能檢測(cè)到部分UI線程是否有耗時(shí)的操作。注意到這行執(zhí)行代碼的前后,有兩個(gè) logging.println 函數(shù),如果設(shè)置了logging,會(huì)分別打印出 ”>>>>> Dispatching to “ 和 ”<<<<< Finished to “ 這樣的日志,這樣我們就可以通過(guò)兩次log的時(shí)間差值,來(lái)計(jì)算 dispatchMessage 的執(zhí)行時(shí)間,從而設(shè)置閾值判斷是否發(fā)生了卡頓。
那么如何設(shè)置 logging 呢?
我們看下面的代碼:
/** * Control logging of messages as they are processed by this Looper. If * enabled, a log message will be written to <var>printer</var> * at the beginning and ending of each message dispatch, identifying the * target Handler and message contents. * * @param printer A Printer object that will receive log messages, or * null to disable message logging. */ public final class Looper { private Printer mLogging; public void setMessageLogging(@Nullable Printer printer) { mLogging = printer; } } public interface Printer { void println(String x); }
Looper 的 mLogging 是私有的,并且提供了 setMessageLogging(@Nullable Printer printer) 方法,所以我們可以自己實(shí)現(xiàn)一個(gè) Printer,在通過(guò) setMessageLogging() 方法傳入即可,代碼如下:
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(); } } }); } }
設(shè)置了logging后,loop方法會(huì)回調(diào) logging.println 打印出每次消息執(zhí)行的時(shí)間日志:”>>>>> Dispatching to “和”<<<<< Finished to “。BlockDetectByPrinter 的使用則在Application 的 onCreate 方法中調(diào)用 BlockDetectByPrinter.start() 即可。
我們可以簡(jiǎn)單實(shí)現(xiàn)一個(gè) LogMonitor 來(lái)記錄卡頓時(shí)候主線程的堆棧信息。當(dāng)匹配到 >>>>> Dispatching 時(shí),執(zhí)行 startMonitor,會(huì)在 200ms(設(shè)定的卡頓閾值)后執(zhí)行任務(wù),這個(gè)任務(wù)負(fù)責(zé)在子線程(非UI線程)打印UI線程的堆棧信息。如果消息低于 200ms 內(nèi)執(zhí)行完成,就可以匹配到 <<<<< Finished 日志,那么在打印堆棧任務(wù)啟動(dòng)前執(zhí)行 removeMonitor 取消了這個(gè)任務(wù),則認(rèn)為沒(méi)有卡頓的發(fā)生;如果消息超過(guò) 200ms 才執(zhí)行完畢,此時(shí)認(rèn)為發(fā)生了卡頓,并打印 UI 線程的堆棧信息。
LogMonitor如何實(shí)現(xiàn)?
public class LogMonitor { private static final String TAG = "LogMonitor"; private static LogMonitor sInstance = new LogMonitor(); private HandlerThread mLogThread = new HandlerThread("log"); private Handler mIoHandler; private static final long TIME_BLOCK = 200L; 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 來(lái)構(gòu)造一個(gè) Handler,HandlerThread 繼承自 Thread,實(shí)際上就一個(gè) Thread,只不過(guò)比普通的 Thread 多了一個(gè) Looper,對(duì)外提供自己這個(gè) Looper 對(duì)象的 getLooper 方法,然后創(chuàng)建 Handler 時(shí)將 HandlerThread 中的 looper 對(duì)象傳入。這樣我們的 mIoHandler 對(duì)象就是與 HandlerThread 這個(gè)非 UI 線程綁定的了,它處理耗時(shí)操作將不會(huì)阻塞UI。如果UI線程阻塞超過(guò) 200ms,就會(huì)在子線程中執(zhí)行 mLogRunnable,打印出 UI 線程當(dāng)前的堆棧信息,如果處理消息沒(méi)有超過(guò) 1000ms,則會(huì)實(shí)時(shí)的 remove 掉這個(gè)mLogRunnable 任務(wù)。
發(fā)生卡頓時(shí)打印出堆棧信息的大致內(nèi)容如下,開(kāi)發(fā)可以通過(guò) log 定位耗時(shí)的地方。
2020-10-30 14:26:13.823 30359-30415/com.example.myproxyplugin E/LogMonitor: java.lang.Thread.sleep(Native Method)
java.lang.Thread.sleep(Thread.java:443)
java.lang.Thread.sleep(Thread.java:359)
com.example.myproxyplugin.MainActivity$1.run(MainActivity.java:22)
android.os.Handler.handleCallback(Handler.java:900)
android.os.Handler.dispatchMessage(Handler.java:103)
android.os.Looper.loop(Looper.java:219)
android.app.ActivityThread.main(ActivityThread.java:8347)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1055)
優(yōu)點(diǎn):用戶使用 app 或者測(cè)試過(guò)程中都能從app層面來(lái)監(jiān)控卡頓情況,一旦出現(xiàn)卡頓能記錄 app 狀態(tài)和信息, 只要dispatchMesaage執(zhí)行耗時(shí)過(guò)大都會(huì)記錄下來(lái),不再有前面兩種adb方式面臨的問(wèn)題與不足。
缺點(diǎn):需另開(kāi)子線程獲取堆棧信息,會(huì)消耗少量系統(tǒng)資源。
在實(shí)際實(shí)現(xiàn)中,不同手機(jī)不同 Android 系統(tǒng)甚至是不同的 ROM 版本,Loop 函數(shù)不一定都能打印出 ”>>>>> Dispatching to “ 和 ”<<<<< Finished to “ 這樣的日志,導(dǎo)致該方式無(wú)法進(jìn)行。
優(yōu)化的策略:我們知道 Loop 函數(shù)開(kāi)始和結(jié)束必會(huì)執(zhí)行 println 打印日志,所以優(yōu)化版本將卡頓的判斷改為,Loop輸出第一句 log 時(shí)當(dāng)作 startMonitor,輸出下一句log時(shí)當(dāng)作end時(shí)刻來(lái)解決這個(gè)問(wèn)題。
其實(shí) Looper 中有個(gè) Observer 接口可以很好的完成這個(gè)任務(wù),只是因?yàn)楸粯?biāo)記為 hide 了,所以我們不能使用,不過(guò)可以知道下。
Observer 接口提供了三個(gè)方法,分別是監(jiān)聽(tīng)任務(wù)開(kāi)始,結(jié)束,發(fā)生錯(cuò)誤的回調(diào)。
/** {@hide} */ public interface Observer { /** * Called right before a message is dispatched. * * <p> The token type is not specified to allow the implementation to specify its own type. * * @return a token used for collecting telemetry when dispatching a single message. * The token token must be passed back exactly once to either * {@link Observer#messageDispatched} or {@link Observer#dispatchingThrewException} * and must not be reused again. * */ Object messageDispatchStarting(); /** * Called when a message was processed by a Handler. * * @param token Token obtained by previously calling * {@link Observer#messageDispatchStarting} on the same Observer instance. * @param msg The message that was dispatched. */ void messageDispatched(Object token, Message msg); /** * Called when an exception was thrown while processing a message. * * @param token Token obtained by previously calling * {@link Observer#messageDispatchStarting} on the same Observer instance. * @param msg The message that was dispatched and caused an exception. * @param exception The exception that was thrown. */ void dispatchingThrewException(Object token, Message msg, Exception exception); }
利用Choreographer.FrameCallback監(jiān)控卡頓
Choreographer.FrameCallback 官方文檔鏈接(https://developer.android.com/reference/android/view/Choreographer.FrameCallback.html)
我們知道, Android 系統(tǒng)每隔 16ms 發(fā)出 VSYNC 信號(hào),來(lái)通知界面進(jìn)行重繪、渲染,每一次同步的周期為16.6ms,代表一幀的刷新頻率。SDK 中包含了一個(gè)相關(guān)類,以及相關(guān)回調(diào)。理論上來(lái)說(shuō)兩次回調(diào)的時(shí)間周期應(yīng)該在 16ms,如果超過(guò)了 16ms 我們則認(rèn)為發(fā)生了卡頓,利用兩次回調(diào)間的時(shí)間周期來(lái)判斷是否發(fā)生卡頓(這個(gè)方案是 Android 4.1 API 16 以上才支持)。
這個(gè)方案的原理主要是通過(guò) Choreographer 類設(shè)置它的 FrameCallback 函數(shù),當(dāng)每一幀被渲染時(shí)會(huì)觸發(fā)回調(diào) FrameCallback, FrameCallback 回調(diào) void doFrame (long frameTimeNanos) 函數(shù)。一次界面渲染會(huì)回調(diào) doFrame 方法,如果兩次 doFrame 之間的間隔大于 16.6ms 說(shuō)明發(fā)生了卡頓。
public class FPSFrameCallback implements Choreographer.FrameCallback { private static final String TAG = "FPS_TEST"; private long mLastFrameTimeNanos = 0; private long mFrameIntervalNanos; public FPSFrameCallback(long lastFrameTimeNanos) { mLastFrameTimeNanos = lastFrameTimeNanos; // 1s 60 幀 mFrameIntervalNanos = (long) (1000000000 / 60.0); } @Override public void doFrame(long frameTimeNanos) { //初始化時(shí)間 if (mLastFrameTimeNanos == 0) { mLastFrameTimeNanos = frameTimeNanos; } final long jitterNanos = frameTimeNanos - mLastFrameTimeNanos; if (jitterNanos >= mFrameIntervalNanos) { final long skippedFrames = jitterNanos / mFrameIntervalNanos; if (skippedFrames > 30) { Log.i(TAG, "Skipped " + skippedFrames + " frames! " + "The application may be doing too much work on its main thread."); } } mLastFrameTimeNanos = frameTimeNanos; //注冊(cè)下一幀回調(diào) Choreographer.getInstance().postFrameCallback(this); } }
本質(zhì)和 log 沒(méi)太多區(qū)別,但是這個(gè)更加通用些,不會(huì)因?yàn)闄C(jī)型系統(tǒng)原因出現(xiàn)不可用的問(wèn)題。
示例
下面進(jìn)入實(shí)戰(zhàn),看看代碼層面是如何實(shí)現(xiàn)的。
MainActivity 代碼如下:
public class MainActivity extends AppCompatActivity { Handler handler = new Handler(Looper.getMainLooper()); private final Runnable runnable = new Runnable() { @Override public void run() { try { Thread.sleep(600); handler.postDelayed(runnable, 500); } catch (InterruptedException e) { e.printStackTrace(); } } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Choreographer.getInstance().postFrameCallback(new FPSFrameCallback(System.nanoTime())); BlockDetectByPrinter.start(); } @Override protected void onResume() { super.onResume(); handler.postDelayed(runnable, 500); } }
收集到的堆棧信息如下:
2020-10-30 14:26:13.823 30359-30415/com.example.myproxyplugin E/LogMonitor: java.lang.Thread.sleep(Native Method)
java.lang.Thread.sleep(Thread.java:443)
java.lang.Thread.sleep(Thread.java:359)
com.example.myproxyplugin.MainActivity$1.run(MainActivity.java:22)
android.os.Handler.handleCallback(Handler.java:900)
android.os.Handler.dispatchMessage(Handler.java:103)
android.os.Looper.loop(Looper.java:219)
android.app.ActivityThread.main(ActivityThread.java:8347)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1055)
對(duì)于 FPS log 可以看到如下信息:
I/Choreographer: Skipped 64 frames! The application may be doing too much work on its main thread.
I/FPS_TEST: Skipped 65 frames! The application may be doing too much work on its main thread.
如果你要把上面的方法用到自己的APP 中,那么還需要很多操作,具體可以閱讀參考文獻(xiàn)的內(nèi)容。
參考文章
到此這篇關(guān)于Android教你如何發(fā)現(xiàn)APP卡頓的實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)Android APP卡頓內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Android編程實(shí)現(xiàn)通話錄音功能的方法
這篇文章主要介紹了Android編程實(shí)現(xiàn)通話錄音功能的方法,結(jié)合實(shí)例形式較為詳細(xì)的分析了Android廣播接收機(jī)制實(shí)現(xiàn)錄音功能的操作技巧,需要的朋友可以參考下2017-06-06Android實(shí)現(xiàn)檢測(cè)實(shí)體按鍵事件并屏蔽
這篇文章主要介紹了Android實(shí)現(xiàn)檢測(cè)實(shí)體按鍵事件并屏蔽 ,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-08-08android中soap協(xié)議使用(ksoap調(diào)用webservice)
kSOAP是如何調(diào)用ebservice的呢,首先要使用SoapObject,這是一個(gè)高度抽象化的類,完成SOAP調(diào)用??梢哉{(diào)用它的addProperty方法填寫要調(diào)用的webservice方法的參數(shù)2014-02-02解決android viewmodel 數(shù)據(jù)刷新異常的問(wèn)題
這篇文章主要介紹了解決android viewmodel 數(shù)據(jù)刷新異常的問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-03-03Android啟動(dòng)頁(yè)面定時(shí)跳轉(zhuǎn)的三種方法
這篇文章主要介紹了Android啟動(dòng)頁(yè)面定時(shí)跳轉(zhuǎn)的三種方法,實(shí)現(xiàn)打開(kāi)一個(gè)Android手機(jī)APP的歡迎界面后跳轉(zhuǎn)到指定界面的效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-11-11Android RecyclerView區(qū)分視圖類型的Divider的實(shí)現(xiàn)
本篇文章主要介紹了Android RecyclerView區(qū)分視圖類型的Divider的實(shí)現(xiàn),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-04-04Android自定義實(shí)現(xiàn)一個(gè)車牌字母選擇鍵盤
這篇文章主要為大家詳細(xì)介紹了Android如何自定義實(shí)現(xiàn)一個(gè)車牌字母選擇鍵盤,文中的示例代碼講解詳細(xì),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2023-06-06Android:“萬(wàn)能”Activity重構(gòu)篇
本文主要介紹了mvp以及每一層,以及使用mvp來(lái)重構(gòu)“萬(wàn)能”Activity,其實(shí)每一層需要注意的東西還有很多,比如model層是最難寫的一層。具有很好的參考價(jià)值,下面跟著小編一起來(lái)看下吧2017-01-01Android 模擬器(emulator-5554...)出現(xiàn)錯(cuò)誤解決辦法
這篇文章主要介紹了Android 模擬器出現(xiàn)錯(cuò)誤解決辦法的相關(guān)資料,如:Unable to get view server version from device,F(xiàn)ailed to install helloworld.apk on device 'emulator-5554': timeout,這種常見(jiàn)錯(cuò)誤,解決辦法,需要的朋友可以參考下2016-11-11