本文做者:yanxin1563css
本文做者:html
kanglei前端
百度App自2016年上半年嘗試Feed流業務形態,至2017年下半年,歷經10個版本的迭代,基本完成了產品形態的初步探索。在整個Feed流形態的閉環中,新聞詳情頁(文中稱爲落地頁)做爲重要的組成部分,若是打開頁面後,loading時間過長,會嚴重影響用戶體驗。所以咱們針對落地頁這種H5的首屏展示速度進行了長期優化,本文會詳細闡述整個優化思路和技術細節java
經過分析用戶反饋,發現當時的落地頁從點擊到首屏展示平均須要3s的時間,每次用戶興致勃勃的想要瀏覽感興趣的文章時,卻由於過長的loading時間,而不耐煩的選擇了back。爲了提高用戶體驗,咱們進行了如下工做:react
經過用戶反饋、QA測試等多種渠道,發現落地頁首屏加載慢問題android
定義首屏性能指標(首屏含圖,以圖片加載爲準;首屏無圖,以文字渲染結束爲準)web
NA、內核、H5三方針對本身加載H5的流程進行劃分並埋點上報後端
統計側根據三端上報的數據產出平均值、80分位值的性能報表緩存
分析性能報表,找到不合理的耗時點,並進行優化性能優化
以AB實驗方式,對比優化先後的性能報表數據,產出優化效果,同時評估用戶體驗等相關指標
按照長期優化的方式,不斷分析定位性能瓶頸點並優化,以AB實驗方式評估效果,最終達到咱們的落地頁秒開目標
優化以前,咱們與業內大多數的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初始化時間是否還能夠優化
基於以前對Hybrid性能的分析,咱們內部孵化了一個叫作CloudHybrid的項目,用來解決落地頁首屏展示慢的痛點;一句話來形容CloudHybrid方案,就是採用後端直出+預取+攔截的方式,簡化頁面渲染流程,提早化&並行化網絡請求邏輯,進而提高H5首屏速度
對於Hybrid方案來講,端上預置和加載的html文件只是一個模板文件,內部包含一些簡單的JS和CSS文件,端上加載HTML後,須要執行JS經過端能力從Server異步請求正文數據,獲得數據後,還須要解析JSON,構造DOM,應用CSS樣式等一系列耗時的步驟,最終才能由內核進行渲染上屏;爲了提高首屏展現速度,能夠利用後端渲染技術(smarty)對正文數據和前端代碼進行整合,直出首屏內容,直出後的html文件包含首屏展示所需的內容和樣式,內核能夠直接渲染;首屏外的內容(包括相關推薦、廣告等)能夠在內核渲染完首屏後,執行JS,並利用preact進行異步渲染,百度APP直出方案:
對於客戶端來講,從CDN中拉取到的html都是已經在server渲染好首屏的,這樣的內容無需二次加工,展示速度能夠大大提高,僅直出一點,手百Feed落地頁的首屏性能數據就從2600ms優化到2000ms之內
爲了保證首屏渲染結果的準確性,除了在server側對正文內容和前端代碼進行整合外,還須要一些影響頁面渲染的客戶端狀態信息,例如首圖地址、字體大小、夜間模式等
這裏咱們採用動態回填的方式,前端會在直出的html中定義一系列特殊字符,用來佔位;客戶端在loadUrl以前,會利用正則匹配的方式,查找這些佔位字符,並按照協議映射成端信息;通過客戶端回填處理後的html內容,已經具有了展示首屏的全部條件
先看下優化先後效果,第一幅是優化前,第二幅是優化後:
正常來講,直出後的頁面展示速度已經很快了;但在實際開發中,你可能會遇到即便本身的數據加載速度再快,仍然會出現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(); } }
通過直出的改造以後,爲了更快的渲染首屏,減小過程當中涉及到的網絡請求耗時,咱們能夠按照必定的策略和時機,提早從CDN中請求部分落地頁html,緩存到本地,這樣當用戶點擊查看新聞時,只需從緩存中加載便可。手百預取服務架構圖:
目前手百預取服務支撐着圖文、圖集、視頻、廣告等多個業務方,根據業務場景的不一樣,觸發時機能夠自定義,也能夠遵循咱們默認的刷新、滑停、點擊等時機,此外,咱們會對預取內容進行優先級排序(根據資源類型、觸發時機),會動態的根據當前手機狀態信息進行併發控制和流量控制,在一些降級場景中,server還能夠經過雲控的方式來控制是否預取以及預取的數量
在落地頁中,除了文本外,圖片也是重要的組成部分。直出解決了文字展示的速度問題,但圖片的加載渲染速度仍不理想,尤爲是首屏中帶有圖片的文章,其首圖的渲染速度纔是真正的首屏時間點
傳統Hybrid方案,前端頁面經過端能力調用NA圖片下載能力來緩存和渲染圖片,雖然實現了客戶端和前端圖片緩存的共享,但因爲JS執行時機較晚,且屢次端能力調用存在效率問題,致使圖片渲染延後
初步改進方案:爲了提高圖片加載速度,減小JS調用耗時,改成純H5請求圖片,速度雖然有所提高,可是客戶端和前端緩存沒法共享,當點擊圖片調起NA圖片查看器時,沒法作到沉浸式效果,且仍需重複下載一次圖片,形成流量浪費
終極方案:藉由內核的shouldInterceptRequest回調,攔截落地頁圖片請求,由客戶端調用NA圖片下載框架進行下載,並以管道方式填充到內核的WebResourceResponse中
此方案在知足圖片渲染速度的同時,解耦了客戶端和前端代碼,客戶端充當server角色,對圖片進行請求和緩存控制,保證前端和客戶端能夠共用圖片緩存,改造後的方案,非首圖展示流程,頁面不卡頓,首屏80分位值縮短80ms~150ms
效果以下,第一幅圖爲優化前,第二幅爲優化後:
爲了減小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; }
a. WebView初始化完成,馬上loadUrl,無需等待框架onCreate或者OnResume結束
b. WebView初始完成後到頁面首屏繪製完成之間,儘可能減小UI線程的其餘操做,繁忙的UI線程會拖慢WebView.loadUrl的速度
具體到Feed落地頁場景,因爲咱們的落地頁包含兩部分,WebView+NA評論組件,正常流程會在WebView初始化結束後,開始評論組件的初始化及評論數據的獲取。因爲此時評論的初始化仍處在onCreate的UI消息處理中,會嚴重延遲內核加載主文檔的邏輯。考慮到用戶進入落地頁的時候,評論組件對用戶來講並不可見,因此將評論組件的初始化延遲到頁面的pageFinish時機或者firstScreenPaintFinished;80分位性能提高60ms~100ms
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%
壓縮預取的包大小,減小下行流量
少預取或者不預取
圖文:優化直出html中內聯的css、icon等數據,數據大小減小約40%
1) 圖文:經過對圖文資源進行評分,來決定4G是否須要預取,多組AB試驗最優效果劣化9.5ms
2)視頻:爲了平衡性能和流量,在性能劣化可接受的範圍內(視頻起播時間劣化100ms),針對視頻部分採用流量高峯期不預取的策略,減小視頻總流量約7%,總體帶寬峯值降低3%
通用用戶操做行爲,對Feed預取進行AI預測,減小無效預取的數量。
在總結以前,先來看下總體優化的先後效果對比,第一幅爲優化前,第二幅爲優化後:
能夠看到,通過一系列的優化手段,落地頁已經實現了秒開效果。回顧所作的事情,從分析用戶反饋到定位性能瓶頸,再到各類優化嘗試,發現全部相似的性能優化手段均可以從如下幾點入手:
提早作:包括預建立WebView和預取數據
並行作:包括圖片直出&攔截加載,框架初始化階段開啓異步線程準備數據等
輕量化:對於前端來講,要儘可能減小頁面大小,刪減沒必要要的JS和CSS,不只能夠縮短網絡請求時間,還能提高內核解析時間
簡單化:對於簡單的信息展現頁面,對內容動態性要求不高的場景,能夠考慮使用直出替代hybrid,展現內容直接可渲染,無需JS異步加載
頁面的更新機制,目前方案僅適用於偏靜態頁面,對於動態性要求較高的業務,須要提供頁面更新機制,保證每次顯示的正確性
開源之路:後續計劃將咱們總結下來的這套方案打包開源,前行之路一定坎坷,但願你們多多支持
---------------------------------
在微信-搜索頁面中輸入「百度App技術」,便可關注微信官方帳號。