深入理解 Activty 載入速度優化

DK_BurNIng發表於2018-02-27

如何定義activity載入速度?

個人理解,進入一個activity開始 一直到首屏頁面被渲染出來也就是使用者可見的狀態。這個時間當然是越短越好。這個時間越長, activity的白屏時間就越長,這對於很多低端的手機使用者來說是不可忍受的,使用者體驗極差。

如何得到activity繪製完ui介面的時間?

方案A:onCreate和onResume的時間差

答:先說結論,此測量activity首屏渲染時間的方法為錯誤。 下面從多個維度來證明這個方案的錯誤。

首先我們看onResume的函式註釋:

深入理解 Activty 載入速度優化

注意看這個地方紅線標註的單詞是指的“互動”這個意思,也就是說,執行到onresume這個方法的時候是指的使用者可以互動了, 並沒有說可以看到東西了,也沒說ui繪製完畢了。有人問 “可以互動“”難道不是在“可以看見“”之後麼,你沒看到怎麼互動呢? 其實這個說法是錯誤的,如果你的程式碼寫的很爛,手機又很差的話,其實activity在白屏的時候 頁面還沒渲染出來你就可以點返回 鍵進行返回了。這個返回的動作 就是可以互動的狀態,但是白屏代表著介面還沒繪製完畢。這一點你用MONKEY跑自動化測試的時候 可以明顯看到。

方案B:命令列檢視activity的啟動時間

深入理解 Activty 載入速度優化

可以看到用命令列啟動一個acitivty的時候 下面也是有時間輸出的。這個時間一般都會認為相當接近我們想要的activity的啟動時間了。我們注意看一下 同樣的一條命令, 我們第一次啟動這個activity遠遠比後面幾次時間要長。原因就是第一次載入一個activity的時候 很多圖片類的資源 文字資源 xml等等資訊都是第一次load到記憶體裡,所以比較耗時,後面因為載入過一次所以記憶體有一些快取之類的東西所以後面幾次時間會比較快(要知道io操作是相當耗時的,直接從記憶體載入當然快很多)。

我們在原始碼裡搜尋一下這段輸出的日誌關鍵字,最終定位到這段日誌是在activityrecord這個類的這個方法裡輸出的。

深入理解 Activty 載入速度優化

大家可以看一下,這個totalTime 的定義,當前時間 減去 開始執行的時間。可以得出一個結論這個時間已經非常接近 我們想要的時間了。我們的介面繪製時間一定是小於這個總時間的。 有興趣的同學可以跟蹤一下這個mLaunStartTime 到底是在哪裡被誰賦值。我這裡篇幅所限就不過多論述。

深入理解 Activty 載入速度優化

可以給點提示activitystack的這個方法被呼叫的時候賦值的。

有沒有更好的方案C?

方案B的時間雖然可以接近我們想要的結果,但是畢竟這是命令列才能使用,還得有root許可權,非root許可權的手機你是無法 執行這個命令的,這讓我們想統計activity的啟動時間帶來了困難。一定要找到一個可以從程式碼層面輸出介面繪製時間的方法。

都知道activity的管理者真正是activitythread,所以我們直接找這個類的原始碼看看。這個方法過長了,我們先放主要的片段

深入理解 Activty 載入速度優化

深入理解 Activty 載入速度優化

首先我們看第一張圖,這裡明顯的呼叫了,resume這個方法的回撥,但是下面第二張圖可以看到裡面有個decorView 並且這個decorView 正在被vm add進去,都知道decorView的子view 有個xml佈局裡面有個framelayout是我們acitivity的rootview,就是那個id為content的layout。可以看出來 這裡onResume方法呼叫就在這個addview 前面了,所以再次證明方案a是多麼不靠譜,你acitivity的介面都沒add進去呢 怎麼可能繪製結束?

這裡可能有些繞,但是隻要記住activity的層級關係即可:

一個Activity包含了一個Window,這個Window其實是一個PhoneWindow,在PhoneWindow中包含了DecorView,變數名稱為mDecor,mDecor有一個子View,這個子View的佈局方式根據設定的主題來確定,在這個子View的xml佈局中包含了一個FrameLayout元素,這個FrameLayout元素的id為content,這個content對應於PhoneWindow中的mContentParent變數,使用者自定義的佈局作為mContentParent的子View存在,一般情況下mContentParnet只有一個子View,如果在Activity呼叫addView方式實際上是給PhoneWindow中的mContentParent新增子View,由於mContentParent是一個FrameLayout,因此新的子view會覆蓋通過setContentView新增的子view。

繼續跟:

深入理解 Activty 載入速度優化

深入理解 Activty 載入速度優化

一直跟,跟到這裡:

這裡我們new 出了ViewRootImpl物件, 我們知道這個物件就是android view的根物件了,負責view繪製的measure, layout, draw的巨長的方法 performTraversals就是這個類的,我們繼續看setView方法 這裡面最重要的就是呼叫了requestLayout 這個方法

  @Override
    public void requestLayout() {
        if (!mHandlingLayoutInLayoutRequest) {
            checkThread();
            mLayoutRequested = true;
            scheduleTraversals();
        }
    }
    
    //這個方法其實不難理解,看名字自己翻譯下就知道就是遍歷做一些事情的意思(至於是什麼事當然是ui繪製啊)
    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            //Choreographer 負責幀率重新整理的一個類,以後會講到他。暫時理解成類似於往ui執行緒post了一個訊息就可以了
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            if (!mUnbufferedInputDispatch) {
                scheduleConsumeBatchedInput();
            }
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }
    
    //mTraversalRunnable 就是這個類的物件
    final class TraversalRunnable implements Runnable {
        @Override
        public void run() {
            doTraversal();
        }
    }
    final TraversalRunnable mTraversalRunnable = new TraversalRunnable();
    
    
    void doTraversal() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

            if (mProfile) {
                Debug.startMethodTracing("ViewAncestor");
            }
            //這個方法應該很敏感,很有名的一個方法 就不分析他了 太長了,超出篇幅。
            performTraversals();

            if (mProfile) {
                Debug.stopMethodTracing();
                mProfile = false;
            }
        }
    }
複製程式碼

分析到這裡,應該可以稍微理一理activity繪製的一個大概流程:

1.activitythread 呼叫handleresumeactivity方法 也就是 先回撥onresume方法 2.scheduleTraversals post了一個TraversalRunnable訊息。 3.post的這個訊息做了一件事 呼叫了繪製ui的核心方法performTraversals。

這個流程也再次驗證了方案a 利用oncreate和onresume時間差的不靠譜

方案C:IdleHandler

方案C 是一個接近靠譜的方法。在闡述這個方法之前,我們先用一張圖迴歸一下Handler Looper和MessageQueue這個東西。

深入理解 Activty 載入速度優化

簡單來說一下這三者之間的關係: Handler通過sendMessage將訊息投遞給MessageQueue,Looper通過訊息迴圈(loop)不斷的從MessageQueue中取出訊息,然後訊息被Handler的dispatchMessage分發到handleMessage方法消費掉。

然後我們看一個特殊的原始碼,來自於MessageQueue:

深入理解 Activty 載入速度優化

注意看他的註釋:

其實意思就是說,如果我們looper裡的訊息都處理完了,那麼就會回撥這個介面,如果這個方法返回false,那麼回撥這一次以後就會把這個idleHandler給幹掉,如果返回true,那麼訊息處理完畢就繼續呼叫這個iderHandler介面的queueidle方法。

so:我們的正確方案C 就呼之欲出了:

深入理解 Activty 載入速度優化
t1 就是oncreate方法的時間戳。 第一個標註紅線的 顯然是被證明過錯誤的做法。 而第二個標註紅線的 顯然是正確的做法。 前面已經分析過,activity的繪製正是從往ui執行緒的handler裡post的 一個訊息開始,那麼這個訊息對應的動作全部處理結束以後, 顯然就回回撥我們這個idleHandler的了。所以這個方法是目前為止最通用最準確 獲取activity啟動以後到顯示東西到螢幕這一段時間 最準確的方法。

知道activity啟動時間了以後能做什麼?

簡單來說,在大部分低端手機中,我們總是希望使用者進入一個新頁面的時候能儘快看到這個頁面想要展示的內容,尤其在弱網環境 或者大量資料需要從網路中獲取時,我們總是希望介面能先展示一些固定的結構,甚至基本要素。然後等對應的介面回來以後再進去 填充資料,否則頁面白白的區域顯示時間過長,體驗不佳(這點頭條新浪微博微信等做的尤其出色)

如何加快activity的啟動時間?

cpu的時間片總是固定的,硬體所限,為了讓ui執行緒儘快的處理完畢,我們總是希望這一段時間內儘可能的只有ui執行緒在跑, 這樣ui執行緒獲取的時間片更多,執行速度起來就會很快,如果你一開始就在oncreate方法裡做了太多的諸如網路操作, io操作,資料庫操作,那必然的是ui執行緒獲取cpu時間變少,速度變慢。

確定我們的延遲載入方案

我們來看這樣一段程式:

 TextView textView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        textView = (TextView) findViewById(R.id.tv);
        Log.v("wuyue", "textView height==" + textView.getWidth());
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
                @Override
                public boolean queueIdle() {
                    Log.v("wuyue", "textView height2==" + textView.getWidth());
                    return false;
                }
            });
        }
    }
複製程式碼

很顯然,第一種在oncreate方法裡獲取tv的高度肯定獲取不到因為這會還沒繪製結束呢。 第二種就可以拿到了,原因前面已經說過了。不多講。

深入理解 Activty 載入速度優化

日誌也反應了我們的正確性。

那麼有沒有更好的方法來證明這個是正確的呢?

可以用android studio的 method trace來看方法的執行軌跡,ddms的 method profiling也可以。這2個工具在這裡不多介紹了。 是查卡頓的很重要的方法,各位自行百度谷歌使用方法即可。

除了啟動優化以外,我們還可以做些什麼?

前面講述的是activity的啟動優化,實際上,我們更希望實時的知道我們app執行的具體情況,比如滑動的時候到底有沒有卡頓? 如果有卡頓發生,怎麼知道大概在哪裡出現了問題以便我們迅速定位到問題程式碼?

adb shell dumpsys gfxinfo

這個命令大家都很熟悉,可獲取最新128幀的繪製資訊,詳細包括每一幀繪製的Draw,Process,Execute三個過程的耗時,如果這三個時間總和超過16.6ms即認為是發生了卡頓。 但是我們不可能每次到一個頁面都去手動執行以下這個命令,太麻煩了,而且 不同的手機還要多次打這個命令,線上實際生產版本也沒辦法讓使用者來打這個命令獲取結果,所以實際上這個方法並不使用。 還是需要在程式碼層面下功夫

Looper程式碼揭祕

深入理解 Activty 載入速度優化

ui執行緒繫結的looper的loop方法 無限迴圈跑這段程式碼,執行dispatch方法,注意這個方法的前後都有logging的輸出。 那麼這2個logging輸出的時間差 是不是就可以認為這是我們執行ui執行緒的時間嗎?這個時間長不就代表了ui執行緒有卡頓現象麼?

深入理解 Activty 載入速度優化

同時我們到 這個me.mLogging還可以通過public的set方法來設定。

確定思路設計抓取卡頓資訊的方案。

通過setMessageLogging方法來設定我們自定義的printer。

自定義的printer 要重寫 println 方法,判斷如果是dispatch方法前後的日誌格式輸出,那麼就要計算時間戳。

超過這個時間戳就認為卡頓了,輸出執行緒上下文堆疊資訊 看看是哪裡,哪個方法出現了卡頓。

重要程式碼

  • 自定義printer
package com.suning.mobile.ebuy;

import android.os.Looper;
import android.util.Printer;


public class CustomPrinterForGetBlockInfo {
    public static void start() {
        Looper.getMainLooper().setMessageLogging(new Printer() {
            //日誌輸出有很多種格式,我們這裡只捕獲ui執行緒中dispatch上下文的日誌資訊
            //所以這裡定義了2個key值,注意不同的手機這2個key值可能不一樣,有需要的話這裡要做機型適配,
            //否則部分手機這裡可能抓取不到日誌資訊
            private static final String START = ">>>>> Dispatching";
            private static final String END = "<<<<< Finished";
            @Override
            public void println(String x) {
                //這裡的思路就是如果發現在列印dispatch方法的 start資訊,
                //那麼我們就在 “時間戳” 之後 post一個runnable
                if (x.startsWith(START)) {
                    LogMonitor.getInstance().startMonitor();
                }
                //因為我們start 不是立即start runnable 而是在“時間戳” 之後 那麼如果在這個時間戳之內
                //dispacth方法執行完畢以後的END到來,那麼就會remove掉這個runnable
                //所以 這裡就知道 如果dispatch方法執行時間在時間戳之內 那麼我們就認為這個ui沒卡頓,不輸出任何卡頓資訊
                //否則就輸出卡頓資訊 這裡卡頓資訊主要用StackTraceElement 來輸出
                if (x.startsWith(END)) {
                    LogMonitor.getInstance().removeMonitor();
                }
            }
        });
    }
}

複製程式碼
  • 看看我們的LogMoniter
package com.suning.mobile.ebuy;

import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.util.Log;


public class LogMonitor {
    private static LogMonitor sInstance = new LogMonitor();
    //HandlerThread 這個其實就是一個thread,只不過相對於普通的thread 他對外暴露了一個looper而已。方便
    //我們和handler配合使用
    private HandlerThread mLogThread = new HandlerThread("BLOCKINFO");
    private Handler mIoHandler;
    //這個時間戳的值,通常設定成不超過1000,你可以調低這個數值來優化你的程式碼。數值越低 暴露的資訊就越多
    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();
            //把ui執行緒的block的堆疊資訊都列印出來 方便我們定位問題
            StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
            for (StackTraceElement s : stackTrace) {
                sb.append(s.toString() + "\n");
            }
            Log.e("BLOCK", sb.toString());
        }
    };

    public static LogMonitor getInstance() {
        return sInstance;
    }

    public void startMonitor() {
        //在time之後 再啟動這個runnable 如果在這個time之前呼叫了removeMonitor 方法,那這個runnable肯定就無法執行了
        mIoHandler.postDelayed(mLogRunnable, TIME_BLOCK);
    }

    public void removeMonitor() {
        mIoHandler.removeCallbacks(mLogRunnable);
    }
}

複製程式碼
  • 最後再application中的oncreate方法啟動我們的統計函式
    深入理解 Activty 載入速度優化

基本上就可以了。可以滿足我們的卡頓統計需求。

額外奉送,統計幀率的方法。

前面我們分析actiivty頁面繪製的時候提到過Choreographer這個類。其實這個類網上資料超多,大家可以自行搜尋一下, 這個類的 Choreographer.getInstance().postFrameCallback(this); 是可以統計到幀率的。實時的,很方便。 通過這個我們也可以檢測到卡頓現象,和上面的方法其實效果差不多,唯一要注意的,大多數blog的isMonitor 其實都不可用,原因是

深入理解 Activty 載入速度優化

注意看這個函式是個hide函式,壓根沒辦法給我們app使用到的。編譯是不可能編譯通過的。 這裡給出正確的寫法,其餘程式碼我就不多複述了其實都差不多。搜搜都可以搜到。

public boolean isMonitor() {
        //網上流傳的方法多數是這個,但是這個是錯的,因為hasCallbacks 是一個hide函式 你壓根呼叫不了的,只能反射呼叫
        //return mIoHandler.hasCallbacks(mLogRunnable);
        try {
            //通過詳細地類名獲取到指定的類
            Class<?> handlerClass = Class.forName("android.os.Handler");
            //通過方法名,傳入引數獲取指定方法
            java.lang.reflect.Method method = handlerClass.getMethod("hasCallbacks", Runnable.class);
            Boolean ret = (Boolean) method.invoke(mIoHandler, mLogRunnable);
            return ret;
        } catch (Exception e) {
        }
        return false;
    }
複製程式碼

總結

說了這麼多,其實本篇文章核心思想就2點,統計activity啟動時間,儘可能縮小頁面白屏的時間。 統計卡頓的上下文環境,方便我們定位程式碼問題便於優化。大體的分析問題和解決問題的思路都在這裡了。 有興趣的同學可以自行擴充思路,寫出一個個庫方便使用。但是核心思想應該就是上述內容。 當然不想重複造輪子的同學也可以使用開源庫。在這裡我推薦2個個人認為比較好的:

比較小巧精緻的庫功能不多

這個庫就大而全了。全面檢測android app效能的工具

相關文章