1. 程式人生 > >Android UI效能優化 檢測應用中的UI卡頓

Android UI效能優化 檢測應用中的UI卡頓

一、概述

在做app效能優化的時候,大家都希望能夠寫出絲滑的UI介面,以前寫過一篇部落格,主要是基於Google當時釋出的效能優化典範,主要提供一些UI優化效能示例:

實際上,由於各種機型的配置不同、程式碼迭代歷史悠久,程式碼中可能會存在很多在UI執行緒耗時的操作,所以我們希望有一套簡單檢測機制,幫助我們定位耗時發生的位置。

本篇部落格主要描述如何檢測應用在UI執行緒的卡頓,目前已經有兩種比較典型方式來檢測了:

  1. 利用UI執行緒Looper列印的日誌
  2. 利用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
(可以給我留言你想學習的文章,支援投稿)