移動端頁面載入耗時監控方案

語言: CN / TW / HK

本文闡述了個人對移動端頁面載入耗時監控的一些理解,主要從:節點劃分及對應的實現方案,線上監控注意點,後續還能做的事 三個方面來和大家分享。

前言

移動端的頁面載入速度,作為最為影響使用者體驗的因素之一,是我們做移動端效能優化的重點方向之一。

而優化的效果體現,需要置信的指標進行衡量(常見方法論:尋找方向->確定指標->實踐->量化收益),而本文想要分享的就是:如何真實、完整、方便的獲得頁面載入時間,並會向線上監控環節,有一定延伸。

本文的示例程式碼都是OC(因為Java和kotlin我也不會😅),但相關思路和方案也適用於Android(Android端已實現並上線)。

頁面載入耗時

常見方案

頁面載入時長是一直以來大家都在攻堅的方向,所以市面上也有非常非常多的度量方案,從節點劃分角度看:

較為基礎的:ViewController 的 init -> viewDidLoad -> viewDidAppear

更進一步的:ViewController 的 init -> viewDidLoad -> viewDidAppear -> user Interactable

主流方案:ViewController 的 init -> viewDidLoad -> viewDidAppear -> view render completed -> user Interactable

還有什麼地方可以改進的嗎?

對於這些成熟方案,我還有什麼可以更進一步的嗎?主要總結為以下幾個方面吧: - 完整反映使用者體感

我們做效能優化,歸根結底,更是使用者體驗優化,在滿足功能需要的同時,不影響使用者的使用體驗。 所以,我個人認為,大多數的效能指標,都要考慮到使用者體驗這個方向;頁面啟動速度這一塊,更是如此;而傳統的方案,能夠完整的反應使用者體感嗎? 我覺得還是有一部分的缺失的:使用者主動發起互動到ViewController這個階段。這一部分有什麼呢,不就是直接tap觸發的action裡vc就初始化了嗎? 實際在一些較為複雜、大型的專案中,並不然,中間可能會有很多其他處理,例如:方法hook、路由排程、引數解析、containerVC的初始化、動態庫載入等等。這一部分的耗時,實際上也是使用者體感的一部分,而這一部分的耗時,如果不加監控的話,也會對整體耗時產生劣化。(這裡可能會有小夥伴問了,這些東西,不應該由各自負責的同學,例如負責路由的同學,自行監控嗎?這裡我想闡述的一個觀點時,時長類的監控,如果由幾個時間段拼接,相比於endTime - startTime,難免會產生gap,即,加入endTime = 10,startTime = 0,那麼中間分成兩段,很有可能endTime2 = 10,startTime2 = 6;endTime1 = 4,startTime1 = 0,造成總時長不準。總而言之,還是希望得到一個能夠完整反映使用者體感的時長。)

  • 資料採集與業務解耦

這一點其實市面上的很多方案已經做得很好了。解耦,一方面是為了,提效:避免後續有新的頁面需要監控時,需要進行新的開發;另一方面,也是避免業務迭代對於監控資料的影響:如果是手動侵入性埋點,很難保證後續新增的耗時任務對監控資料不產生影響。 而本文方案,不需要在業務程式碼中插入任何程式碼,大都是通過方法hook來實現資料採集的;而對範圍、以及匹配關係等的控制,也都是通過配置來完成的。

具體實現

節點確定&資料採集方式

頁面載入流程節點 根據一個頁面(ViewController)的載入過程中,開發主要進行的處理,以及可能對使用者體感產生影響的因素,將頁面載入過程劃分為如上圖所示的11個節點,具體解釋及實現方案如下:

1. 使用者行為觸發頁面跳轉

由於頁面的跳轉一般是通過使用者點選、滑動等行為觸發的,因此這裡監聽使用者觸控式螢幕幕的時間點;但有效節點僅為VC在初始化前的最後一次點選/互動。

具體實現: hook UIWidow 的 sendEvent:方法,在swizzle方法內記錄資訊;為了效能考慮,目前僅記錄一個uint64_t的時間戳,且僅記憶體寫; 注意這裡需要記錄手指擡起的時間,即 touch.phase == UITouchPhaseEnded,因為一般action被呼叫的時機就是此時; 同時,為了適配各種行為觸發的新頁面出現,還增加了一個手動新增該節點的方法,使一些較複雜且不通用,業務特性較強的初始化場景,也能夠有該節點資料,且不依賴hook;但注意該手動方法為侵入式資料採集方式。

2. ViewController的初始化

具體實現:hook UIViewController或你的VC基類 的 - (instancetype)init 的方法;

3. 本地UI初始化

不依賴於網路資料的UI開始初始化。

這個節點,我實際上並沒有在本次實現,這裡的一個理想態是:將這部分行為(即UI初始化的程式碼),通過協議的方式,約束到指定方法中;例如,架構層面約束一個setupSubviews的介面,回撥給各業務VC,供其進行基礎UI繪製(目前這種方式再一些更復雜的業務場景下實現並執行較好);有這個基礎約束的前提下,才能準確的採集我理想中該節點的耗時。而我目前所負責的模組,並沒有這種強約束,而又不能簡單的去認為所有基礎UI都是在viewDidLoad中去完成的。因此需要 對原有架構的一定修改 或 能夠保證所有基礎UI行為都在viewDidLoad中實現,才能夠實現該節點資料的準確採集。 因此2 ~ 3和3 ~ 4間的耗時,被融合為了一段2 ~ 4的耗時。

4. 本地UI初始化完成

不依賴於網路資料的UI初始化完成。

具體實現:監聽主執行緒的閒時狀態,VC初始化 節點後的首個閒時狀態表示 本地UI初始化完成;(閒時狀態即runloop進入kCFRunLoopBeforeWaiting

5. 發起網路請求

呼叫網路SDK的時間點。

這裡描述的就是上面的節點劃分圖的第二條線,因為兩條線的節點間沒有強制的線性關係,雖然圖中當前節點是放在了VC初始化平行的位置,但實際上,有些實現會在VC初始化之前就發起網路請求,進行預載入,這種情況在實現的時候也是需要相容的。

具體實現:hook 業務呼叫網路SDK發起請求方法的api;這裡的網路庫各家實現方案就可能有較大差異了,根據自身情況實現即可。

6. 網路SDK回撥

網路SDK的回撥觸發的時間點。

具體實現:hook 網路SDK向業務層回撥的api;差異性同5。

7. send request
8. receive response

真正 發出網路請求 和 收到response 的時間點,用於計算真正的網路層耗時。 這倆和5、6是不是重複了啊?並不然,因為,網路庫在接收到發起網路請求的請求後,實際上在端階段,還會進行很多處理,例如公參的處理、簽名、驗籤、json2Model等,都會產生耗時;而真正離開了端,在網上逛蕩那一段,更是幾乎“完全不可控”的狀態。所以,分開來統計:端部分 和 網路階段,才能夠為後續的優化提供資料基礎,這也是資料監控的意義所在

具體實現: 實際上系統網路api中就有對網路層詳細效能資料的收集

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics; 根據官方文件中的描述 image 可以發現,我們實際上需要的時長就是從 fetchStartDateresponseEndDate 間的時間。 因此可以該delegate,獲取這兩個時間點。

9. 詳細UI初始化

詳細UI指,依賴於網路介面資料的UI,這部分UI渲染完成才是頁面達到對使用者可見的狀態。

具體實現:這裡我們認為從網路SDK觸發回撥時,即開始進行詳細UI的渲染,因此該節點和節點6是同一個節點。

10. 詳細UI渲染完成

頁面對使用者來說,真正達到可見狀態的節點。

具體實現: 對於一個常規的App頁面來說,如何定義一個頁面是否真正渲染完成了呢?

被有效的檢視鋪滿

什麼是有效檢視呢?視訊,圖片,文字,按鈕,cell,能向用戶傳遞資訊,或者產生互動的view; 鋪滿,並不是指完全鋪滿,而是這些有效檢視填充到一定比例即可,因為按照正常的視覺設計和互動體驗,都不會讓整個螢幕的每一個畫素點都充滿資訊或具備互動能力;而這個比例,則是根據業務的不同而不同的。 下面則是上述邏輯的實現思路:

確定有效檢視的具體類

UITextView UITextField UIButton UILabel UIImageView UITableViewCell UICollectionViewCell 主流方案中比較常見的,是前幾種類,並不包括最後的兩個cell;而這裡為什麼將cell也作為有效檢視類呢? 首先,出於業務特徵考慮,目前應用該套監控方案的頁面,主要是以卡片列表樣式呈現的;而且個人認為,市面上很多App的頁面也都是列表形式來呈現內容的;當然,如果業務特徵並不相符,例如全屏的視訊播放頁,就可以不這樣處理。 其次,將cell作為有效檢視,確實能夠極大的降低每次計算覆蓋率的耗時的。效能監控本身產生的效能消耗,是效能方向一直以來需要著重關注的點,畢竟你一個為了效能優化服務的工具,反而帶來了不小的劣化,怎樣也說不太過去啊😂~ 我也測試了是否包含cell對計算耗時的影響: 下表中為,在一個層級較為複雜的業務頁面,頁面完全渲染完成之後,完成一次覆蓋率達到閾值的掃描所需的時長。 | 有效檢視 | 包含 cell | 不包含 cell | | ---------- |-------------| -----| | 檢測一次覆蓋率耗時(ms) | 1~5 | 15~18 | | 耗時減少 | | 15ms/次(83%) |

而且,有效檢視的類,建議支援線上配置,也可以是一些自定義類。

將cell作為有效檢視,大家可能會產生一個新的顧慮:佔位cell的情況,再具體點,就是常見的骨架圖怎麼辦?骨架圖是什麼,就是在網路請求未返回的時候,用快取的data或者模擬樣式,渲染出一個包含大致結構,但不包含具體內容的頁面狀態,例如這種:

這種情況下,cell已經鋪滿了螢幕,但實際上並未完成渲染。這裡就要依賴於節點的前後順序了,詳細UI是依賴於網路資料的,而骨架圖是在網路返回之前繪製完成的,所以真正的覆蓋率計算,是從網路資料返回開始的,因此骨架圖的填充完成節點,並不會被錯誤統計未詳細UI渲染完成的節點。

覆蓋率的計算方式

如上圖所示,開闢兩個陣列a、b,陣列空間分別為螢幕長寬的畫素數,並以0填充,分別代表橫縱座標; 從ViewController的view開始遞迴遍歷他的subView,遇見有效檢視時,將其frame的width和height,對應在陣列a、b中的range的記憶體空間,都填充為1,每次遍歷結束後,計算陣列a、b中內容為1的比例,當達到閾值比例時,則視為可見狀態。 示例程式碼如下:

``` - (void)checkPageRenderStatus:(UIView *)rootView { if (kPhoneDeviceScreenSize.width <= 0 || kPhoneDeviceScreenSize.height <= 0) { return; }

memset(_screenWidthBitMap, 0, kPhoneDeviceScreenSize.width);
memset(_screenHeightBitMap, 0, kPhoneDeviceScreenSize.height);

[self recursiveCheckUIView:rootView];

}

  • (void)recursiveCheckUIView:(UIView *)view { if (_isCurrentPageLoaded) { return; }

    if (view.hidden) { return; }

    // 檢查view是否是白名單中的例項,直接用於填充bitmap for (Class viewClass in _whiteListViewClass) { if ([view isKindOfClass:viewClass]) { [self fillAndCheckScreenBitMap:view isValidView:YES]; return; } }

    // 最後遞迴檢查subviews if ([[view subviews] count] > 0) { for (UIView *subview in [view subviews]) { [self recursiveCheckUIView:subview]; } } }

  • (BOOL)fillAndCheckScreenBitMap:(UIView *)view isValidView:(BOOL)isValidView {

    CGRect rectInWindow = [view convertRect:view.bounds toView:nil];

    NSInteger widthOffsetStart = rectInWindow.origin.x; NSInteger widthOffsetEnd = rectInWindow.origin.x + rectInWindow.size.width; if (widthOffsetEnd <= 0 || widthOffsetStart >= _screenWidth) { return NO; } if (widthOffsetStart < 0) { widthOffsetStart = 0; } if (widthOffsetEnd > _screenWidth) { widthOffsetEnd = _screenWidth; } if (widthOffsetEnd > widthOffsetStart) { memset(_screenWidthBitMap + widthOffsetStart, isValidView ? 1 : 0, widthOffsetEnd - widthOffsetStart); }

    NSInteger heightOffsetStart = rectInWindow.origin.y; NSInteger heightOffsetEnd = rectInWindow.origin.y + rectInWindow.size.height; if (heightOffsetEnd <= 0 || heightOffsetStart >= _screenHeight) { return NO; } if (heightOffsetStart < 0) { heightOffsetStart = 0; } if (heightOffsetEnd > _screenHeight) { heightOffsetEnd = _screenHeight; } if (heightOffsetEnd > heightOffsetStart) { memset(_screenHeightBitMap + heightOffsetStart, isValidView ? 1 : 0, heightOffsetEnd - heightOffsetStart); }

    NSUInteger widthP = 0; NSUInteger heightP = 0; for (int i=0; i< _screenWidth; i++) { widthP += _screenWidthBitMap[i]; } for (int i=0; i< _screenHeight; i++) { heightP += _screenHeightBitMap[i]; }

    if (widthP > _screenWidth * kPageLoadWidthRatio && heightP > _screenHeight * kPageLoadHeightRatio) { _isCurrentPageLoaded = YES; return YES; }

    return NO; } ``` 但是也會有極端情況(類似下圖)

無法正確反應有效檢視的覆蓋情況。但是出於效能考慮,並不會採用二維陣列,因為w*h的量太大,遍歷和計算的耗時,會有指數級的激增;而且,正常業務形態,應該不太會有類似的極端形態。

即使真的會較高頻的出現類似情況,也有一套備選方案:計算有效檢視的面積 佔 總面積 的比例;該種方式會涉及到UI座標系的頻繁轉換,耗時也會略差於當前的方式。

在某些業務場景下,例如 無/少結果情況,關於頁面等,完全渲染後,也無法達到鋪滿閾值。 這種情況,會以使用者發生互動(同 1、使用者行為觸發頁面跳轉 的獲取方式)和 主執行緒閒時狀態超過5s (可配)來做兜底,看是否屬於這種狀態,如果是,則相關效能資料不上報,因為此種頁面對效能的消耗較正常鋪滿的情況要低,並不能真實的反應效能消耗、瓶頸,因此,僅正常鋪滿的業務場景進行監控並優化,即可。

掃描的觸發時機

以幀重新整理為準,因為只有每次幀重新整理後,UI才會真正產生變化;出於效能考慮,不會每幀都進行掃描,每間隔x幀(x可配,預設為1),掃描一次;同時,考慮高刷屏 和 大量UI繪製時會丟幀 的情況,設定 掃描時間間隔 的上下限,即:滿足 隔x幀 的前提下,如果和上次掃描的時間差小於 下限,仍不掃描;如果 某次掃描時,和上次掃描的時間間隔 大於 上限,則無論中間隔幾幀,都開啟一次掃描。

11. 使用者可互動

使用者可見之後的下一個對使用者來說至關重要的節點。如果只是可見,然後就瘋狂佔用主執行緒或其他資源,造成使用者的點選等互動行為,還是會被卡主,使用者只能看,不能動,這個體感也是很差的;

具體實現:詳細UI渲染完成 後的 首次主執行緒閒時狀態。

監控方案

這裡由於各家的基建並不相同,因此只是總結一些小的建議,可能會比較零散,大家見諒。

  1. 建議取樣收集

首先,資料的採集或者其他的新增行為/方法,一定是會產生耗時的,雖然可能不多,但還是秉著盡善盡美的原則,還是能少點就少點的,所以資料的採集,包括前面的hook等等一切行為,都只是隨機的面向一部分使用者開放,降低影響範圍; 而且,如果資料量極大,全量的資料上報,其實對資料鏈路本身也會產生壓力、增加成本。 當前,取樣的前提是基本資料量足夠,不然的話,取樣樣本量過小,容易對統計結果產生較大波動,造成不置信的結果。

  1. 可配置

除了基本的是否開啟的開關之外,還有其他的很多的點 需要/可以/建議 使用線上配置控制。個人認為,線上配置,除了實現對邏輯的控制,更重要的一個作用,就是出現問題時及時止損。 舉一些我目前使用的配置中的例子: - 有效檢視類 - 渲染完成狀態,橫縱座標的填充百分比閾值 - 終態的兜底閾值 - VC的類名、對應的網路請求 等等。

  1. 本地異常資料過濾

由於我們的樣本資料量會非常大,所以對於異常資料我們不需要“手軟”,我們需要有一套本地異常資料過濾的機制,來保證上報的資料都是符合要求的;不然我們後續統計處理的時候,也會因此出現新的問題需要解決。

後續還能做的事

這一部分,是對後續可實現方案的一個美好暢想~

1)頁面可見態的終點,不只是覆蓋率

其實,實際業務場景中,很多cell,即使繪製完,並渲染到螢幕上,此時,使用者可見的也沒有達到我們真正希望使用者可見的狀態,很多內容,都還是一個placeholder的狀態。例如,通過url載入的image,我們一般都是先把他的size算好,把他的位置留好,cell渲染完就直接展示了;再進一步,如果是一個視訊的播放卡片,即使網路圖片載入好了,還要等待視訊幀的返回,才能真正達到這張卡片的$\color{red}{業務終態}$(求教這裡標紅後如何能夠讓字型大小一致)。

這個非常後置,而且我們端上可能也影響不了什麼的節點,採集起來有意義嗎?

我覺得這是一個非常有價值的節點。一直都在說“技術反哺業務”,那麼業務想要使用者真正看到的那個終態,就是很重要的一環;因此,使用者能在什麼時間點看到,從業務角度說,能夠影響其後續的方案設計(表現形式),完善使用者體感對業務指標的影響;從技術角度說,可以感知真實的全鏈路的表現(不只是端),從而有針對性的進行優化。

如何獲取到所有的業務終態呢?

這裡一定是和業務有所耦合的,因為每個業務的終態,只有業務自身才知道;但是我們還是要儘量降低耦合度。 這裡可以用協議的方式,為各個業務增加一個達到終態的標識,那麼在某個業務達到終態之後,設定該標識即可,這裡就是唯一對業務的侵入了;然後和計算覆蓋率類似,這裡的遍歷,是業務維度(這裡想象為卡片更好理解一點),只有全部業務的標識都ready之後,才是真正達到業務上的終態。

2)效能指標 關聯 業務行為

其實,現在效能監控,各類平臺,各個團隊,或多或少的都在做,我相信,效能資料採集的程式碼,在工程中,也不僅僅只有一份;這個現狀,在很多成一定規模的網際網路公司中都可能存在。

而如果您和我一樣,作為一個業務團隊,如何在不重複造輪子的情況下,夾縫中求生存呢?

我個人目前的理解:將 效能表現 與 業務場景 相關聯。

幀率、啟動耗時、CPU、記憶體等等,這些效能指標資料的獲取,在業界都有非常成熟的方案,而且我們的工程裡,一定也有相關的程式碼;而我們能做的,僅僅是,調一下人家的api,然後把資料再自己上傳一份(甚至有的連上傳都包含了),就完事了嗎?

這樣我覺得並不能體現出我們自建監控的價值。個人理解,監控的意義在於:暴露問題 + 輔助定位問題 + 驗證問題的解決效果

所以我們作為業務團隊,將 效能資料 和 我們的業務做了什麼 bind 到一起了,是不是就能一定程度上完成了上面的目的呢?

我們可以明確,我們什麼樣的業務行為,會影響我們的效能資料,也就是影響我們的使用者基礎體驗。這樣,不僅會幫助我們定位問題的原因,甚至會影響產品側的一些產品能力設計方案。

完成這些建設之後,可能我們的監控就可以變成這樣,甚至更好的狀態: image.png

3)完善全鏈路對效能表現的關注

效能資料的關注、監控,不應該僅僅在線上階段,開發期 → 測試期 → 線上,全鏈路各個環節都應該具有。

  • 目前各家都比較關注線上監控,相信都已經較為完善;

  • 測試期的業務流程效能指令碼;對於測試的效能測試方案,開放應該參與共建或者有一定程度的參與,這樣才能從一定程度上保證資料的準確性,以及雙方效能資料的相互認可;

  • 開發期,目前能夠提供展示實時CPU、FPS、記憶體資料的基礎能力的工具很常見,也比較容易實現;但實際上,在日常開發的過程中,很難讓RD同時關注需求情況與效能資料表現。因此,還是需要一些工具來輔助:例如,我們可以對某些效能指標,設定一些閾值,當日常開發中,超過閾值時,則彈窗提醒RD確認是否原因、是否需要優化,例如,詳細UI繪製階段的耗時閾值是800ms,如果某位同學在進行變更後,實際繪製耗時多次超越該值,則彈窗提醒。