【性能優(yōu)化】Android冷啟動優(yōu)化

前段時間做冷啟動優(yōu)化,剛好也很久沒寫博文了,覺得還是很有必要記錄下。

一.常規(guī)操作

public class MainActivity extends Activity {
    private static final Handler sHandler = new Handler(Looper.getMainLooper());

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        sHandler.postDelay(new Runnable() {
            @Override
            public void run() {
                // 頁面啟動所需耗時初始化
                doSomething();
            }
        }, 200);
    }
}

大部分開發(fā)者在遇到頁面冷啟動耗時初始化時,會首先考慮通過Handler.postDelay()方法延遲執(zhí)行。但延遲多久合適?100ms?500ms?還是1s?

延遲過晚,可能會有體驗問題;延遲過早,對冷啟動沒效果。延遲的時間(比如200ms)在三星手機上測試時沒問題,換了在華為手機試了就有問題了,然后就圍繞著機型的適配不斷調整延遲的時間,試圖尋找最合適的值,結果發(fā)現(xiàn)根本就是不可能的。

二.起始終止點

先來看一張圖

冷啟動流程圖.png

上圖是Google提供的冷啟動流程圖,可以看到冷啟動的起始點時Application.onCreate()方法,結束點在ActivityRecord.reportLanuchTimeLocked()方法。

我們可以通過以下兩種方式查看冷啟動的耗時

1.查看Logcat

在 Android Studio Logcat 過濾關鍵字 “Displayed”,可以查看到如下日志:

2019-07-03 01:49:46.748 1678-1718/? I/ActivityManager: Displayed com.tencent.qqmusic/.activity.AppStarterActivity: +12s449ms

后面的12s449ms就是冷啟動耗時

2.adb dump

通過終端執(zhí)行“adb shell am start -W -S <包名/完整類名> ”

adb冷啟動Activity.png

“ThisTime:1370”即為本次冷啟動耗時(單位ms)

三、尋找有效結束回調

上面知道,冷啟動計時起始點是Application.onCreate(),結束點是ActivityRecord.reportLanuchTimeLocked(),但這不是我們可以寫業(yè)務寫邏輯的地方啊,大部分應用業(yè)務都以Activity為載體,那么結束回調在哪?

1.IdleHandler

從冷啟動流程圖看,結束時間是在UI渲染完計算的,所以很明顯,Activity生命周期中的onCreate()、onResume()、onStart()都不能作為冷啟動的結束回調。

常規(guī)操作中用Handler.postDelay()問題在于Delay的時間不固定,但我們知道消息處理機制中,MessageQueue有個ArrayList<IdleHandler>

public final class MessageQueue {

    Message mMessages;
    
    priavte final ArrayList<IdleHandler> mIdelHandlers = new ArrayList<IdelHandler>();

    Message next() {
        ...
        int pendingIdelHandlerCount = -1; // -1 only during first iteration
        for(;;) {
            ...
            // If first time idle, then get the number of idlers to run.
            // Idle handles only run if the queue is empty or if the first message
            // in the queue (possibly a barrier) is due to be handled in the future.
            if (pendingIdleHandlerCount < 0 && (mMessages == null || now < mMessages.when)) {
                pendingIdleHandlerCount = mIdleHandlers.size();
            }
            if (pendingIdleHandlerCount <= 0) {
                // No idle handlers to run.  Loop and wait some more.
                mBlocked = true;
                continue;
            }
            // Run the idle handlers.
            // We only ever reach this code block during the first iteration.
            for (int i = 0; i < pendingIdleHandlerCount; i++) {
                final IdleHandler idler = mPendingIdleHandlers[i];    
                mPendingIdleHandlers[i] = null;
                // release the reference to the handler
                boolean keep = false;
                try {        
                    keep = idler.queueIdle();    
                } catch (Throwable t) {        
                    Log.wtf(TAG, "IdleHandler threw exception", t);
                }    
            }                    
            ...
        }
    }
}

可以在列表中添加Idle任務,Idle任務列表只有MessageQueue隊列為空時才會執(zhí)行,也就是所在線程任務已經(jīng)執(zhí)行完時,線程處于空閑狀態(tài)時才會執(zhí)行Idle列表中的任務。

冷啟動過程中,在Activity.onCreate()中將耗時初始化任務放置到Idle中

public class MainActivity extends Activity {

    private static final Handler sHandler = new Handler(Looper.getMainLooper());

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
        @Override    
        public boolean queueIdle() {   
            // 頁面啟動所需耗時初始化            
            doSomething();
            return false;
        }});
    }
}

正常情況下,初始化任務是在UI線程所有任務執(zhí)行完才開始執(zhí)行,且該方案也不用考慮機型問題。但有個問題,如果UI線程的任務一直不執(zhí)行完呢?會有這情況?舉個??,Activity首頁頂部有個滾動的Banner,banner的滾動是通過不斷增加延遲Runnable實現(xiàn)。那么,初始化任務就可能一直沒法執(zhí)行。

另外,如果初始化的任務會關系到UI的刷新,這時,在Activity顯示后再去執(zhí)行,在體驗上也可能會有所折損。

回顧冷啟動流程圖,冷啟動結束時,剛好是UI渲染完,如果我們能確保在UI渲染完再去執(zhí)行任務,這樣,既能提升冷啟動數(shù)據(jù),又能解決UI上的問題。

因此,解鈴還須系鈴人,要想找到最合適的結束回調,還是得看源碼。

2.onWindowFocusChanged()

首先,我們找到了第一種方案

public class BaseActivity extends Activity {

    private static final Handler sHandler = new Handler(Looper.getMainLooper());
    private boolean onCreateFlag;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        onCreateFlag = true;
        setContentView(R.layout.activity_main);
    }

    @Override
    public void onWindowFocusChanged(boolean hasFocus) {    
        super.onWindowFocusChanged(hasFocus);    
        if (onCreateFlag && hasFocus) {
            onCreateFlag = false;

            sHandler.post(new Runnable() {
                @Override
                public void run() {
                    onFullyDrawn();
                }
            })
        }
    }

    @CallSuper
    protected void onFullyDrawn() {
        // TODO your logic
    }
}

關于onWindowFocusChanged()的系統(tǒng)調用流程感興趣的可以看看我的上一篇文章《Activity.onWindowFocusChanged()調用流程》

onWindowFocusChanged()調用流程.png

至于為什么要在onWindowFocusChanged()再通過Handler.post()延后一個任務,一開始我是通過打點,發(fā)現(xiàn)沒post()時,onWindowFocusChanged()打點在Log“Displayed”之前,增加post()便在Log“Displayed”之后,梳理了下調用流程,大概是渲染調用requestLayout()也是增加任務監(jiān)聽,只有SurfaceFlinger渲染信號回來時才會觸發(fā)渲染,因此延后一個任務,剛好在其之后

冷啟動生命周期Log日志.png

3.View.post(Runnable runnable)

第二種方案,我們通過View.post(Runnable runnable)方法實現(xiàn)

public class BaseActivity extends Activity {

    private static final Handler sHandler = new Handler(Looper.getMainLooper());
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    // 方案只有在onResume()或之前調用有效
    protected void postAfterFullDrawn(final Runnable runnable) {    
        if (runnable == null) {        
            return;    
        }    
        getWindow().getDecorView().post(new Runnable() {        
            @Override        
            public void run() {            
                sHandler.post(runnable);                    
            }    
        });
    }
}

需要注意的是,該方案只有在onResume()或之前調用有效。為什么?

先看View.post()源碼實現(xiàn)

public boolean post(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    // 這里要注意啦!attachInfo 不為空,實際是通過Handler.post()延遲一個任務
    if (attachInfo != null) {        
        return attachInfo.mHandler.post(action);    
    }

    // Postpone the runnable until we know on which thread it needs to run.    
    // Assume that the runnable will be successfully placed after attach.    
    getRunQueue().post(action);    
    return true;
}

private HandlerActionQueue mRunQueue;

private HandlerActionQueue getRunQueue() {    
    if (mRunQueue == null) {        
        mRunQueue = new HandlerActionQueue();    
    }    
    return mRunQueue;
}

通過View.post()調用了HandlerActionQueue.post()

public class HandlerActionQueue { 

    private HandlerAction[] mActions;    
    private int mCount;    

    public void post(Runnable action) {        
        postDelayed(action, 0);    
    }    

    /**
    * 該方法僅僅是將傳入的任務Runnable存放到數(shù)組中
    **/
    public void postDelayed(Runnable action, long delayMillis) {        
        final HandlerAction handlerAction = new HandlerAction(action, delayMillis);        
        synchronized (this) {            
            if (mActions == null) {                
                mActions = new HandlerAction[4];            
            }            
            mActions = GrowingArrayUtils.append(mActions, mCount, handlerAction);            
            mCount++;        
        }    
    }
}

到此,我們調用View.post(Runnable runnable)僅僅是把任務Runnable以HandlerAction姿勢存放在HandlerActionQueue的HandlerAction[]數(shù)組中。那這個數(shù)組什么時候會被訪問調用?

既然是冷啟動,那還是得看冷啟動系統(tǒng)的回調,直接看ActivityThread.handleResumeActivity()

final void handleResumeActivity(IBinder token,
          boolean clearHide, boolean isForward, boolean reallyResume, int seq, String reason) {
    ActivityClientRecord r = mActivities.get(token);
    ...
    r = performResumeActivity(token, clearHide, reason);    ...
    if (r != null) {
        if (r.window == null && !a.mFinished && willBeVisible) {
            r.window = r.activity.getWindow();
            View decor = r.window.getDecorView();
            decor.setVisibility(View.INVISIBLE);
            ViewManager wm = a.getWindowManager();
            WindowManager.LayoutParams l = r.window.getAttributes();
            a.mDecor = decor;
            l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
            l.softInputMode |= forwardBit;
            if (r.mPreserveWindow) {    
                a.mWindowAdded = true;    
                r.mPreserveWindow = false;    
                ViewRootImpl impl = decor.getViewRootImpl();    
                if (impl != null) {        
                    impl.notifyChildRebuilt();    
                }
            }
            if (a.mVisibleFromClient) {    
                if (!a.mWindowAdded) {        
                    a.mWindowAdded = true;
                    // 上面一大串操作基本可以不看,因為到這我們基本都知道下一步是渲染,也就是ViewRootImpl上場了        
                    wm.addView(decor, l);    
                } else {            
                    a.onWindowAttributesChanged(l);    
                }
            }
        }
    }
}

到渲染了,直接進ViewRootImpl.performTraversals()

public final class ViewRootImpl implements ViewParent,   
     View.AttachInfo.Callbacks, ThreadedRenderer.DrawCallbacks {

    boolean mFirst;

    public ViewRootImpl(Context context, Display display) {
        ...
        mFirst = true; // true for the first time the view is added
        ...
    }

    private void performTraversals() {
        final View host = mView;    
        ...
        if (mFirst) {
            ...
            host.dispatchAttachedToWindow(mAttachInfo, 0);
            ...
        }
        ...
        performMeasure();
        performLayout();
        preformDraw();
        ...
        mFirst = false;
    }
}

再進到View.dispatchAttachedToWindow()去瞧瞧

void dispatchAttachedToWindow(AttachInfo info, int visibility) {
    // 倒車請注意!倒車請注意!這里mAttachInfo != null啦!
    mAttachInfo = info;
    ...
    // Transfer all pending runnables.
    // 系統(tǒng)也提示了,到這里執(zhí)行pending的任務runnbales
    if (mRunQueue != null) {    
        mRunQueue.executeActions(info.mHandler);    
        mRunQueue = null;
    }
    ...
}

// 開始訪問前面存放的任務,看看executeActions()怎么工作
public class HandlerActionQueue {        
    private HandlerAction[] mActions;

    /**
    * 我褲子都脫了,你給我看這些?實際也是調用Handler.post()執(zhí)行任務
    **/
    public void executeActions(Handler handler) {    
        synchronized (this) {        
            final HandlerAction[] actions = mActions;        
            for (int i = 0, count = mCount; i < count; i++) {            
                final HandlerAction handlerAction = actions[i];            
                handler.postDelayed(handlerAction.action, handlerAction.delay);
            }        
            mActions = null;        
            mCount = 0;    
        }
    }
}

也就是說,View內部維護了一個HandlerActionQueue,我們可以在DecorView attachToWindow前,通過View.post()將任務Runnables存放到HandlerActionQueue中。當DecorView attachToWindow時會先遍歷先前存放在HandlerActionQueue的任務數(shù)組,通過handler挨個執(zhí)行。

1.在View.dispatchAttachedToWindow()時mAttachInfo就被賦值了,因此,之后通過View.post()實際就是直接調用Handler.post()執(zhí)行任務。再往前看,performResumeActivity()在渲染之前先執(zhí)行,也就說明了為什么只有在onResume()或之前調用有效
2.在View.post()的Runnable run()方法回調中在延遲一個任務,從performTraverals()調用順序看剛好是在渲染完后下一個任務執(zhí)行

四.被忽略的Theme

先來看兩張效果圖

TranslucentTheme.gif

CommonTheme.gif

第一張點擊完桌面Icon后并沒有馬上拉起應用,而是停頓了下,給人感覺是手機卡頓了;

第二張點擊完桌面Icon后立即出現(xiàn)白屏,然后隔了一段時間后才出現(xiàn)背景圖,體驗上很明顯覺得是應用卡了。

那是什么導致它們的差異?答案就是把閃屏Activity主題設置成全屏無標題欄透明樣式

<activity
    android:name="com.huison.test.MainActivity"
    ...
    android:theme="@style/TranslucentTheme" />

<style name="TranslucentTheme" parent="android:Theme.Translucent.NoTitleBar.Fullscreen" /></pre>

這樣可以解決冷啟動白屏或黑屏問題,體驗上會更好。

五.總結

關于冷啟動優(yōu)化,總結為12個字“減法為主,異步為輔,延遲為補

減法為主

盡量做減法,能不做的盡量不做!

Application.onCreate()一定要輕!一定要輕!一定要輕!項目中多多少少會涉及到第三方SDK的接入,但不要全部在Application.onCreate()中初始化,盡量懶加載。

Debug包可以加日志打印和部分統(tǒng)計,但Release能不加的就不加

異步為輔

耗時任務盡量異步!見過好多RD都不怎么喜歡做回調,獲取某個狀態(tài)值時,即使調用的函數(shù)很耗時,也是直接調用,異步回調重新刷新轉態(tài)值也能滿足業(yè)務需求。

當然也不是所有的場景都采用異步回調,因為異步就涉及線程切換,在某些場景下可能會出現(xiàn)閃動,UI體驗極差,所以說要盡量!

延遲為補

其實前面找結束點都是為延遲鋪路的,但延遲方案并不是最佳的,當我們把冷啟動的任務都延遲到結束時執(zhí)行,冷啟動是解決了,但有可能出現(xiàn)結束時任務過多、負載過大而引發(fā)其他問,比如ANR、交互卡頓。以前做服務端時,前端(當時幾百萬DAU)有一個哥們直接寫死早上9點請求某個接口,導致接口直接報警了,如果他把9點改為10點,結果肯定一樣,后面改成了區(qū)段性隨機請求,這樣就把峰值磨平了。同樣,冷啟動過程如果把任務都延遲到結束點,那結束點也有可能負載過大出問題。

削峰填谷,離散化任務,合理地利用計算機資源才是解決根本問題!

其他

1.冷啟動盡量減少SharedPreferences使用,尤其是和文件操作一起,底層ContextImpl同步鎖經(jīng)常直接卡死。網(wǎng)上有人說用微信的MMKV替換SP,我試了下,效果不是很明顯,可能和項目有關系吧,不過MMKV初始化也需要時間。
2.關注冷啟動的常駐內存和GC情況,如果GC過于頻繁也會有所影響,支付寶做過這方面的分析
支付寶客戶端架構解析:Android 客戶端啟動速度優(yōu)化之「垃圾回收」

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容