百度APP-Android H5首屏優化實踐

百度App技術發表於2019-08-17

一、背景

百度App自2016年上半年嘗試Feed流業務形態,至2017年下半年,歷經10個版本的迭代,基本完成了產品形態的初步探索。在整個Feed流形態的閉環中,新聞詳情頁(文中稱為落地頁)作為重要的組成部分,如果開啟頁面後,loading時間過長,會嚴重影響使用者體驗。因此我們針對落地頁這種H5的首屏展現速度進行了長期優化,本文會詳細闡述整個優化思路和技術細節

二、方法論

通過分析使用者反饋,發現當時的落地頁從點選到首屏展現平均需要3s的時間,每次使用者興致勃勃的想要瀏覽感興趣的文章時,卻因為過長的loading時間,而不耐煩的選擇了back。為了提升使用者體驗,我們進行了以下工作:
圖片描述

  • 通過使用者反饋、QA測試等多種渠道,發現落地頁首屏載入慢問題
  • 定義首屏效能指標(首屏含圖,以圖片載入為準;首屏無圖,以文字渲染結束為準)
  • NA、核心、H5三方針對自己載入H5的流程進行劃分並埋點上報
  • 統計側根據三端上報的資料產出平均值、80分位值的效能報表
  • 分析效能報表,找到不合理的耗時點,並進行優化
  • 以AB實驗方式,對比優化前後的效能報表資料,產出優化效果,同時評估使用者體驗等相關指標
  • 按照長期優化的方式,不斷分析定位效能瓶頸點並優化,以AB實驗方式評估效果,最終達到我們的落地頁秒開目標

三、Hybrid方案簡述及效能瓶頸

(一)方案簡述
優化之前,我們與業內大多數的App一樣,在落地頁的技術選型中,為了滿足跨平臺和動態性的要求,採用了Hybrid這種比較成熟的方案。Hybrid,顧名思義,即混合開發,也就是半原生半Web的方式。頁面中的複雜互動功能採用端能力的方式,呼叫原生API來實現。成本低,靈活性較好,適合偏資訊展示類的H5場景。
下面用一張圖來表示百度App中Hybrid的實現機制和載入流程
圖片描述
(二)效能瓶頸
為了分析Hybrid方案首屏展現較慢的原因,找到具體的效能瓶頸,客戶端和前端分別針對各自載入過程中的關鍵節點進行埋點統計,並藉由效能監控平臺日誌進行展示,下圖是擷取的某一天全網使用者的落地頁首屏展現速度80分位資料
圖片描述
各階段效能點可以按Hybrid載入流程進行劃分,可以看到,從點選到首屏展現,大致需要2600ms,其中初始化NA元件需要350ms,Hybrid初始化需要170ms,前端H5執行JS獲取正文並渲染需要1400ms,完成圖片載入和渲染需要700ms的時間
我們具體分析下四個階段的效能損耗主要發生在哪些地方:
1) 初始化NA元件
從點選到落地頁框架初始化完成,主要工作為初始化WebView,尤其是第一次進入(WebView首次建立耗時均值為500ms)
2) Hybrid初始化
這個階段的工作主要包含兩部分,一個是根據調起協議中傳入的相關引數,校驗解壓下發到本地的Hybrid模板,大致需要100ms的時間;此外,WebView.loadUrl執行後,會觸發對Hybrid模板頭部和Body的解析
3) 正文載入&渲染
執行到這個階段,核心已經完成了對Hybrid模板頭部和body的解析,此時需要載入解析頁面所需的JS檔案,並通過JS呼叫端能力發起對正文資料的請求,客戶端從Server拿到資料後,用JsCallback的方式回傳給前端,前端需要對客戶端傳來的JSON格式的正文資料進行解析,並構造DOM結構,進而觸發核心的渲染流程;此過程中,涉及到對JS的請求,載入、解析、執行等一系列步驟,並且存在端能力呼叫、JSON解析、構造DOM等操作,較為耗時
4) 圖片載入
第(3)步中,前端獲取到的正文資料包含落地頁的圖片地址集,在完成正文的渲染後,需要前端再次執行圖片請求的端能力,客戶端這邊接收到圖片地址集後按順序請求伺服器,完成下載後,客戶端會呼叫一次IO將檔案寫入快取,同時將對應圖片的本地地址回傳給前端,最終通過核心再發起一次IO操作獲取到圖片資料流,進行渲染;總體來看,圖片渲染的時間依賴前端的解析效率、端能力執行效率、下載速度、IO速度等因素
通過分析,延伸出對Hybrid方案的一些思考:

  • 渲染為什麼這麼慢
  • 圖片請求能否提前
  • 序列邏輯是否可以改為並行
  • WebView初始化時間是否還可以優化

四、百度App落地頁優化方案

(一)CloudHybrid
基於之前對Hybrid效能的分析,我們內部孵化了一個叫做CloudHybrid的專案,用來解決落地頁首屏展現慢的痛點;一句話來形容CloudHybrid方案,就是採用後端直出+預取+攔截的方式,簡化頁面渲染流程,提前化&並行化網路請求邏輯,進而提升H5首屏速度

1.後端直出-快速渲染首屏
a. 頁面靜態直出
對於Hybrid方案來說,端上預置和載入的html檔案只是一個模板檔案,內部包含一些簡單的JS和CSS檔案,端上載入HTML後,需要執行JS通過端能力從Server非同步請求正文資料,得到資料後,還需要解析JSON,構造DOM,應用CSS樣式等一系列耗時的步驟,最終才能由核心進行渲染上屏;為了提升首屏展示速度,可以利用後端渲染技術(smarty)對正文資料和前端程式碼進行整合,直出首屏內容,直出後的html檔案包含首屏展現所需的內容和樣式,核心可以直接渲染;首屏外的內容(包括相關推薦、廣告等)可以在核心渲染完首屏後,執行JS,並利用preact進行非同步渲染

百度APP直出方案:

圖片描述
對於客戶端來說,從CDN中拉取到的html都是已經在server渲染好首屏的,這樣的內容無需二次加工,展現速度可以大大提升,僅直出一點,手百Feed落地頁的首屏效能資料就從2600ms優化到2000ms以內
b. 動態資訊回填
為了保證首屏渲染結果的準確性,除了在server側對正文內容和前端程式碼進行整合外,還需要一些影響頁面渲染的客戶端狀態資訊,例如首圖地址、字型大小、夜間模式等
這裡我們採用動態回填的方式,前端會在直出的html中定義一系列特殊字元,用來佔位;客戶端在loadUrl之前,會利用正則匹配的方式,查詢這些佔位字元,並按照協議對映成端資訊;經過客戶端回填處理後的html內容,已經具備了展現首屏的所有條件
c. 動畫間渲染
先看下優化前後效果(上圖:優化前;下圖:優化後):
優化前圖片描述
正常來說,直出後的頁面展現速度已經很快了;但在實際開發中,你可能會遇到即使自己的資料載入速度再快,仍然會出現Activity切換過程中無法渲染H5頁面的問題(可以通過開發者模式放慢動畫時間來驗證),產生視覺上的白屏現象(如上面上圖)
我們通過研究原始碼發現,系統處理view繪製的時候,有一個屬性setDrawDuringWindowsAnimating,從命名可以看出來,這個屬性是用來控制window做動畫的過程中是否可以正常繪製,而恰好在Android 4.2到Android N之間,系統為了元件切換的流程性考慮,該欄位為false,我們可以利用反射的方式去手動修改這個屬性,改進後的效果見上面下圖


/**
     * 讓 activity transition 動畫過程中可以正常渲染頁面
     */
    private void setDrawDuringWindowsAnimating(View view) {
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M
                || Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
            // 1 android n以上  & android 4.1以下不存在此問題,無須處理
            return;
        }
        // 4.2不存在setDrawDuringWindowsAnimating,需要特殊處理
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
            handleDispatchDoneAnimating(view);
            return;
        }
        try {
            // 4.3及以上,反射setDrawDuringWindowsAnimating來實現動畫過程中渲染
            ViewParent rootParent = view.getRootView().getParent();
            Method method = rootParent.getClass()
                    .getDeclaredMethod("setDrawDuringWindowsAnimating", boolean.class);
            method.setAccessible(true);
            method.invoke(rootParent, true);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    /**
     * android4.2可以反射handleDispatchDoneAnimating來解決
     */
    private void handleDispatchDoneAnimating(View paramView) {
        try {
            ViewParent localViewParent = paramView.getRootView().getParent();
            Class localClass = localViewParent.getClass();
            Method localMethod = localClass.getDeclaredMethod("handleDispatchDoneAnimating");
            localMethod.setAccessible(true);
            localMethod.invoke(localViewParent);
        } catch (Exception localException) {
            localException.printStackTrace();
        }
    }

2.智慧預取-提前化網路請求
經過直出的改造之後,為了更快的渲染首屏,減少過程中涉及到的網路請求耗時,我們可以按照一定的策略和時機,提前從CDN中請求部分落地頁html,快取到本地,這樣當使用者點選檢視新聞時,只需從快取中載入即可

手百預取服務架構圖

圖片描述
目前手百預取服務支撐著圖文、圖集、視訊、廣告等多個業務方,根據業務場景的不同,觸發時機可以自定義,也可以遵循我們預設的重新整理、滑停、點選等時機,此外,我們會對預取內容進行優先順序排序(根據資源型別、觸發時機),會動態的根據當前手機狀態資訊進行併發控制和流量控制,在一些降級場景中,server還可以通過雲控的方式來控制是否預取以及預取的數量
3.通用攔截-快取共享、請求並行
在落地頁中,除了文字外,圖片也是重要的組成部分。直出解決了文字展現的速度問題,但圖片的載入渲染速度仍不理想,尤其是首屏中帶有圖片的文章,其首圖的渲染速度才是真正的首屏時間點
傳統Hybrid方案,前端頁面通過端能力呼叫NA圖片下載能力來快取和渲染圖片,雖然實現了客戶端和前端圖片快取的共享,但由於JS執行時機較晚,且多次端能力呼叫存在效率問題,導致圖片渲染延後
圖片描述
初步改進方案:為了提升圖片載入速度,減少JS呼叫耗時,改為純H5請求圖片,速度雖然有所提升,但是客戶端和前端快取無法共享,當點選圖片調起NA圖片檢視器時,無法做到沉浸式效果,且仍需重複下載一次圖片,造成流量浪費
終極方案:藉由核心的shouldInterceptRequest回撥,攔截落地頁圖片請求,由客戶端呼叫NA圖片下載框架進行下載,並以管道方式填充到核心的WebResourceResponse中
圖片描述
此方案在滿足圖片渲染速度的同時,解耦了客戶端和前端程式碼,客戶端充當server角色,對圖片進行請求和快取控制,保證前端和客戶端可以共用圖片快取,改造後的方案,非首圖展現流程,頁面不卡頓,首屏80分位值縮短80ms~150ms
效果如下(上圖:優化前Hybrid方案;下圖:優化後通用攔截方案):
圖片描述圖片描述
4.整體方案流程
圖片描述
(二)新的優化嘗試
1.WebView預建立
為了節省WebView的效能損耗,我們可以在合適時機提前建立好WebView,並存入快取池,當頁面需要顯示內容時,直接從快取池獲取建立好的WebView,根據效能資料顯示,WebView預建立可以提升首屏渲染時間200ms+
圖片描述
具體以Feed落地頁為例,當使用者進入手百並觸發Feed吸頂操作後,我們會建立第一個WebView,當使用者進入落地頁後,會從快取池中取出來渲染H5頁面,為了不影響頁面的載入速度,同時保證下次進入落地頁快取池中仍然有可用的WebView元件,我們會在每次頁面載入完成(pageFinish)或者back退出落地頁的時機,去觸發預建立WebView的邏輯
由於WebView的初始化需要和context進行繫結,若想實現預建立的邏輯,需要保證context的一致性,常規做法我們考慮可以用fragment來實現承載H5頁面的容器,這樣context可以用外層的activity例項,但Fragment本身的切換流暢度存在一定問題,並且這樣做限定了WebView預建立適用的場景。為此,我們找到了一種更加完美的替代方案,即MutableContextWrapper

Special version of ContextWrapper that allows the base context to be modified after it is initially set. Change the base context for this ContextWrapper. All calls will then be delegated to the base context. Unlike ContextWrapper, the base context can be changed even after one is already set.
簡單來說,就是一種新的context包裝類,允許外部修改它的baseContext,並且所有ContextWrapper呼叫的方法都會代理到baseContext來執行

下面是擷取的一段預建立WebView的程式碼

/**
     * 建立WebView例項
     * 用了applicationContext
     */
    @DebugTrace
    public void prepareNewWebView() {
        if (mCachedWebViewStack.size() < CACHED_WEBVIEW_MAX_NUM) {
            mCachedWebViewStack.push(new WebView(new MutableContextWrapper(getAppContext())));
        }
    }
    /**
     * 從快取池中獲取合適的WebView
     * 
     * @param context activity context
     * @return WebView
     */
    private WebView acquireWebViewInternal(Context context) {
        // 為空,直接返回新例項
        if (mCachedWebViewStack == null || mCachedWebViewStack.isEmpty()) {
            return new WebView(context);
        }
        WebView webView = mCachedWebViewStack.pop();
        // webView不為空,則開始使用預建立的WebView,並且替換Context
        MutableContextWrapper contextWrapper = (MutableContextWrapper) webView.getContext();
        contextWrapper.setBaseContext(context);
        return webView;
    }

2.NA元件懶載入
a. WebView初始化完成,立刻loadUrl,無需等待框架onCreate或者OnResume結束
b. WebView初始完成後到頁面首屏繪製完成之間,儘量減少UI執行緒的其他操作,繁忙的UI執行緒會拖慢WebView.loadUrl的速度

具體到Feed落地頁場景,由於我們的落地頁包含兩部分,WebView+NA評論元件,正常流程會在WebView初始化結束後,開始評論元件的初始化及評論資料的獲取。由於此時評論的初始化仍處在onCreate的UI訊息處理中,會嚴重延遲核心載入主文件的邏輯。考慮到使用者進入落地頁的時候,評論元件對使用者來說並不可見,所以將評論元件的初始化延遲到頁面的pageFinish時機或者firstScreenPaintFinished;80分位效能提升60ms~100ms

3.核心優化
a. 核心渲染優化:
核心中主要分為三個執行緒(IOThread、MainThread、ParserThread),首先IOThread會從網路端或者本地獲取html資料,並把資料交給MainThread(渲染執行緒,十分繁忙,用於JS執行,頁面佈局等),為了保證MainThread不被阻塞,需要額外起一個後臺執行緒(ParserThread)用來做html的解析工作。ParserThread每解析到落地頁html中帶有特殊class標記的一個div標籤或者P標籤(圖中的first、second)時,就會觸發一次MainThread的layout工作,並把layout後得到的高度與螢幕高度進行對比,如果當前layout高度已經大於螢幕高度,我們認為首屏內容已經完成佈局,可以觸發渲染上屏邏輯,不必等到整篇html全部解析完成再上屏,提前了首屏的渲染時間;80分位下,核心的渲染優化可以提升首屏速度100ms~200ms
圖片描述
b. 預載入JS:
預建立好WebView後,通過預載入JS(與核心約定好的JS內容,核心側執行該JS時,只做初始化操作),觸發WebView初始化邏輯,縮短後續載入url耗時;80分位效能提升80ms左右

五、新的問題-流量和速度的平衡

頻繁預取會帶來流量的浪費:預取的命中率雖然達到了90%以上,但有效率僅有15%
解決思路:
&nbsp&nbsp1.壓縮預取的包大小,減少下行流量
&nbsp&nbsp2.少預取或者不預取
(一)精簡預取資料:
圖文:優化直出html中內聯的css、icon等資料,資料大小減少約40%
(二)後端智慧預取:
1) 圖文:通過對圖文資源進行評分,來決定4G是否需要預取,多組AB試驗最優效果劣化9.5ms
2)視訊:為了平衡效能和流量,在效能劣化可接受的範圍內(視訊起播時間劣化100ms),針對視訊部分採用流量高峰期不預取的策略,減少視訊總流量約7%,整體頻寬峰值下降3%
(三)AI智慧預取
通用使用者操作行為,對Feed預取進行AI預測,減少無效預取的數量。

六、總結&展望

(一)優化總結
在總結之前,先來看下整體優化的前後效果對比(上圖:優化前;下圖:優化後):
圖片描述圖片描述
可以看到,經過一系列的優化手段,落地頁已經實現了秒開效果。回顧所做的事情,從分析使用者反饋到定位效能瓶頸,再到各種優化嘗試,發現所有類似的效能優化手段都可以從以下幾點入手:

  • 提前做:包括預建立WebView和預取資料
  • 並行做:包括圖片直出&攔截載入,框架初始化階段開啟非同步執行緒準備資料等
  • 輕量化:對於前端來說,要儘量減少頁面大小,刪減不必要的JS和CSS,不僅可以縮短網路請求時間,還能提升核心解析時間
  • 簡單化:對於簡單的資訊展示頁面,對內容動態性要求不高的場景,可以考慮使用直出替代hybrid,展示內容直接可渲染,無需JS非同步載入

圖片描述
(二)TODO

  • 頁面的更新機制,目前方案僅適用於偏靜態頁面,對於動態性要求較高的業務,需要提供頁面更新機制,保證每次顯示的正確性
  • 開源之路:後續計劃將我們總結下來的這套方案打包開源,前行之路必定坎坷,希望大家多多支援

相關文章