貨拉拉使用者 iOS 端卡頓優化實踐

語言: CN / TW / HK

前言

卡頓優化一直是客戶端效能治理的重要方向之一,在這之前,我們先來解釋下什麼是卡頓。

卡頓,直白來說就是使用者在使用APP的過程中能感受到介面一卡一卡的不流暢。從原理來說,就是在使用者能夠感知的視覺場景中,當事件處理和UI展示的綜合消耗時間超過使用者視覺系統的最大期待時間時,就會出現卡頓現象。卡頓會影響使用者的操作,損害使用者體驗,進一步影響使用者對APP的評價和留存。因此,操作流暢度是決定APP體驗好壞的關鍵因素之一。優化卡頓,將APP的使用者體驗做到極致,在一定程度上能夠提升使用者的忠誠度和APP的市場佔有率。

行業標準

那麼,APP的卡頓率在多少區間算是正常或者優秀呢? 我們可以參考 《2020移動應用效能管理白皮書 | 基調聽雲》推薦的行業標準:

| 效能指標 | 優秀值 | 及格值 | 極差值 | 行業參考值 | | ------ | --- | --- | ---- | ----- | | 卡頓率(%) | <=2 | 5 | >=8 | 4 |

整體狀況

APP卡頓優化是一個長期過程,貨拉拉使用者端APP卡頓治理分多期進行,在前期的治理中,我們的卡頓率資料採用的是bugly的卡頓監控。治理前,APP的卡頓率是6.13%,通過2個月的治理實踐,卡頓率降到了2.1%,已接近行業優秀標準。因此,我們總結了這段時間的一些探索和實踐,希望能給大家在App卡頓優化方面提供一些借鑑和思路。

卡頓原理和檢測

為什麼出現卡頓?

螢幕顯示影象是需要CPU和GPU結合工作。CPU 負責計算顯示內容,包括檢視建立、佈局計算、圖片解碼、文字繪製等,CPU 完成計算後,會將計算內容提交給 GPU;GPU 進行變換、合成、渲染,將渲染結果提交到幀緩衝區,當下一次垂直同步訊號(簡稱 V-Sync)到來時,將渲染結果顯示到螢幕上。

UI檢視顯示到螢幕中的過程:

image.png

在螢幕顯示影象前,CPU 和 GPU 需要完成自身的任務,系統會每(1000/60=16.67ms)將UI的變化重新繪製,渲染到螢幕上。如果在16ms內,主執行緒進行了耗時操作,CPU和GPU沒有來得及生產出一幀緩衝,那麼這一幀會被丟棄,顯示器就會保持不變,繼續顯示上一幀內容,使用者的視覺上就出現了卡頓;因此卡頓產生的原因就是,CPU和GPU沒有及時處理好資料。所以,針對卡頓優化的思路是,儘可能減少 CPU 和 GPU 資源消耗。

UIEvent的事件是在Runloop迴圈機制驅動下完成的,主執行緒任意一個環節進行了耗時操作,主執行緒都無法執行Core Animation回撥,進而造成介面無法重新整理。使用者互動是需要UIEvent的傳遞和響應,也必須在主執行緒中完成。所以說主執行緒的阻塞會導致UI和互動的雙雙阻塞,這也是導致卡頓的根本原因。

卡頓檢測

知道了卡頓出現的根本原因,我們就很好理解如何進行卡頓檢測了。業界常見的卡頓檢測是對主執行緒的Runloop進行監控,因為卡頓直接導致操作無響應,介面動畫遲緩,所以通過檢測主執行緒能否響應任務,來判斷是否卡頓。在講如何用Runloop來檢測卡頓之前,我們先來回顧下Runloop的執行機制。

  1. Runloop的執行機制

RunLoop 會接收兩種型別的輸入源:

  • 來自另一個執行緒或者來自不同應用的非同步訊息;
  • 來自預訂時間或者重複間隔的同步事件;

RunLoop主要的工作是,當有事件要去處理時保持執行緒忙,當沒有事件要處理時讓執行緒進入休眠 。

整個 RunLoop 過程 :

image.png

  1. RunLoop監控卡頓的原理

如果 RunLoop 的執行緒,進入睡眠前,方法執行時間過長而導致無法進入睡眠;或者執行緒喚醒後,接收訊息時間過長而無法進入下一步,就可以認為是執行緒受阻。如果這個執行緒是主執行緒,表現出來的就是出現卡頓。 所以,利用 RunLoop 來監控卡頓,就需要關注這兩個階段。進入睡眠之前和喚醒後的兩個loop狀態值,也就是 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting(觸發 Source0 回撥和接收 match_port 訊息兩個狀態)。執行緒的訊息事件是依賴於 RunLoop ,通過開闢一個子執行緒來監控主執行緒的 RunLoop 的狀態,就能夠發現呼叫方法是否執行過長,從而判斷出是否出現卡頓。

RunLoop的六個狀態 :

``` / Run Loop Observer Activities /

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {

kCFRunLoopEntry = (1UL << 0), //進入loop

kCFRunLoopBeforeTimers = (1UL << 1), //觸發 Timer 回撥

kCFRunLoopBeforeSources = (1UL << 2),//觸發 Source0 回撥

kCFRunLoopBeforeWaiting = (1UL << 5),//等待 mach_port 訊息

kCFRunLoopAfterWaiting = (1UL << 6),//接受 mach_port 訊息

kCFRunLoopExit = (1UL << 7), //退出 loop

kCFRunLoopAllActivities = 0x0FFFFFFFU //loop 所有狀態改變

}; ```

  1. 監控的實現

    1. 建立一個RunLoop的觀察者:

CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL}; _runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &runLoopObserverCallBack, &context); //將觀察者新增到主執行緒runloop的common模式下 CFRunLoopAddObserver(CFRunLoopGetMain(), _runLoopObserver, kCFRunLoopCommonModes);

  1. 再將觀察者 runLoopObserver 新增到主執行緒 RunLoop 的 common 模式下,然後再建立一個持續的子執行緒專門用來監控主執行緒的RunLoop狀態。實時計算 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 兩個狀態區域之間的耗時是否超過某個閾值,超過即可判斷為卡頓,然後把對應的堆疊資訊進行上報。

``` dispatch_async(dispatch_get_global_queue(0, 0), ^{ //子執行緒開啟一個持續的loop用來進行監控 while (YES) { long semaphoreWait = dispatch_semaphore_wait(self.dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC)); if (semaphoreWait != 0) { if (!self.runLoopObserver) { self.timeoutCount = 0; self.dispatchSemaphore = 0; self.runLoopActivity = 0; return; }

    //BeforeSources和AfterWaiting這兩個狀態區間時間能夠檢測到是否卡頓
    if (self.runLoopActivity == kCFRunLoopBeforeSources || self.runLoopActivity == kCFRunLoopAfterWaiting) {
      //上報對應的卡頓堆疊資訊
    }
  }
}

}); ```

除了上面介紹的自研卡頓監控方案,也可以使用第三方SDK,不過檢測原理都是大同小異的。貨拉拉使用者端因工程中本身就集成了Bugly SDK,因此在治理前期,我們只是把Bugly的卡頓檢測開啟,以最小的投入成本,達到線上儘快有卡頓指標可以參考的目的。

卡頓治理實踐

我們在開啟Bugly的卡頓監控時,將卡頓閾值blockMonitorTimeout設定為3秒,這也是SDK預設閾值。即,監控主執行緒 Runloop 的執行,觀察執行耗時是否超過3s。在監控到卡頓時會立即記錄執行緒堆疊到本地,在App從後臺切換到前臺時,執行上報。治理前期有大量的卡頓異常上報,我們對上報的卡頓進行分期治理,根據異常發生次數劃分為Top 20、Top50等。上報量Top 20的卡頓為高頻卡頓,上報次數頻繁、影響使用者多,需優先治理。

使用者端Top4的卡頓如下:

常見卡頓

在治理過程中,我們將常見的卡頓原因做了聚合分類,並且針對不同的卡頓原因,總結了對應不同的解決方案,以下為針對性的治理方案:

  1. IO讀寫

    1. 在主執行緒做大量的資料讀寫操作

優化方案:開啟子執行緒,非同步去讀取和儲存本地的資料,邏輯處理完了再回到主執行緒重新整理UI

例如:+[CityManager saveLocalCityList:] (CityManager.m:)

全國的城市列表資料量大,在獲取到最新的資料後,會將資料存放在本地。在資料寫入和讀取的時候,開啟子執行緒,非同步讀取和存入本地。優化後的程式碼:

``` // 子執行緒非同步儲存

  • (void)getCityList { ......

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [CityManager saveLocalCityList:list]; ...... });

} ```

  1. UI繪製相關

    1. 複雜的UI、圖文混排的繪製量過大

優化方案:無事件處理的地方儘可能的使用CALayer,保持檢視的輕量,避免重寫drawRect方法;

  1. 頻繁使用setNeedsLayout,layoutIfNeeded來重新整理UI

有時候為了達到立即重新整理UI的效果,會呼叫如下的程式碼:

``` - (void)updateConfig {

......

[self setNeedsLayout];

[self layoutIfNeeded];

} ```

這樣呼叫後,會觸發呼叫layoutSubViews,強制檢視立即更新其佈局,使用自動佈局時,佈局引擎會根據需要來更新檢視的位置,以滿足約束的更改。這樣會增加消耗,容易造成卡頓。

優化方案:有時候想要立即重新整理UI,可能只是為了獲取最新的frame資料。可進行程式碼邏輯的調整,換一種方式實現,就能減少layoutIfNeeded的呼叫,減少卡頓的出現。

  1. 主執行緒相關

    1. 在主執行緒上做網路同步請求,或者在主執行緒中做資料解析和模型轉換

優化方案:在子執行緒中發起網路請求,並且在子執行緒中進行資料的解析和模型的轉換;處理完邏輯後,再回到主執行緒中重新整理UI。

  1. 主執行緒做大量的邏輯處理,運算量大,CPU 持續高佔用

優化方案:有複雜邏輯的地方,建議梳理邏輯,優化演算法,並且把邏輯的處理放在子執行緒中進行處

理;處理完後,再回到主執行緒中重新整理UI。

首頁是使用者使用率最高的頁面,版本不停的迭代,貨拉拉首頁的需求也是頻繁的變化;在車型選擇模組,隨著需求的變化,有很多的AB實驗疊加在一起,導致車型選擇模組有大量的邏輯判斷,檢視非常多且複雜;隨著需求的迭代,越來越難以維護,屬於卡頓異常高發區。經過綜合分析,決定對這個模組進行邏輯梳理,重構車型模組的UI。上線後該模組的卡頓上報量明顯下降,收益明顯。

  1. boundingRectWithSize在主執行緒中執行

優化方案:文字高度的計算會佔用很大一部分CPU資源,因此在涉及計算的地方,最好不要在主執行緒中進行;在子執行緒中計算好再回到主執行緒中重新整理對應的UI。例如:

優化後的程式碼:

``` NSString *text = @"貨拉拉..."; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ CGFloat width = [text boundingRectWithSize:CGSizeMake(375, 20) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName :[UIFont systemFontOfSize:15]} context:nil].size.width; dispatch_async(dispatch_get_main_queue(), ^{ self.logoLabel.width = width; }); });

```

  1. 啟動時任務太多,沒有做執行緒的優先順序管理,影響首頁的UI建立,導致卡頓

優化方案:針對啟動的所有任務進行梳理,根據任務的重要性進行優先順序的劃分;非啟動必須的任務,可延遲執行,等待首頁UI初始化完成後再進行執行;優先順序較低的任務,將其放入子執行緒中執行,避免造成主執行緒阻塞,引起卡頓。

  1. 其他

    1. 本地讀取icon

優化方案:本地的小圖片儘量使用Images.xcassets來管理,不建議使用bundle。Images.xcassets中的圖片,使用imageName讀取,載入到記憶體中,會有快取,佔據記憶體空間。對於需要重複載入的icon,因為有快取,載入速度會提升很多。

但是對於記憶體大的圖片資源,最好放在bundle中,並且使用imageWithContentsOfFile讀取,這個方法不會有快取,這樣可以更好的控制記憶體。

  1. 第三方SDK相關的問題

優化方案:需要結合具體的SDK進行優化;若是SDK內部引起的,可聯絡第三方進行對應的問題反饋,促進問題的優化;

疑難卡頓

上報的卡頓執行緒堆疊資訊中,可能存在資訊不準確的情況,也可能存在很多資訊不足的情況,根據上報的內容,無法精確定位卡頓的具體位置。例如:

針對這種疑難的卡頓上報,需要藉助使用者的日誌,進一步定位。在上報卡頓時,也上報使用者的userid,根據使用者的id在內部平臺上查詢使用者詳細的實時日誌和離線日誌。

實時日誌:

  • 記錄了使用者的操作路由,可定位卡頓的具體頁面
  • 包含行為埋點、自動化埋點、異常埋點內容,可分析使用者的行為,進一步定位卡頓的程式碼位置

離線日誌:

  • 屬於實時日誌的補充,實時日誌無法定位問題時,進一步分析離線日誌
  • 記錄了更多的打點內容和網路請求相關資料

總結

以上都是對線上已經產生的卡頓進行治理的方案,更重要的其實是,我們如何在編碼階段就規避卡頓的產生。因此,我們也總結了一些思路和規範。

如何避免卡頓

  1. 避免使用CPU自定義繪圖,無事件處理的地方儘可能的使用CALayer,保持檢視的輕量;
  2. 儘量複用檢視,減少檢視的新增和移除;例如移除檢視需要動畫,可使用隱藏屬性來實現;
  3. 避免重寫drawRect方法,該方法會開闢額外的記憶體空間進行CPU繪製,更要避免在其中做耗時操作;
  4. 在更新佈局的時候,減少layoutIfNeeded的使用,儘量只使用setNeedsLayout
  5. 將耗時操作放在子執行緒中進行,減輕主執行緒的壓力
  6. 避免主執行緒進行IO相關的操作
  7. 針對於必須在 CPU 上進行繪製的元件,嘗試使用多執行緒的非同步繪製能力,減輕主執行緒壓力
  8. 圖片的大小和UIImageView的size保持一致,避免CPU進行伸縮操作
  9. 控制執行緒的最大併發數量,CPU排程處理也需要耗時,執行緒過多會使CPU繁忙
  10. 避免出現離屏渲染

防劣化措施

在經過卡頓治理後,為了進一步治理優化,並且防止資料惡化,我們採取了以下措施:

  1. 程式碼質量

提高開發階段的程式碼質量,在開發階段就減少卡頓的產生

  • 建立了Code Review 制度
  • 將引起卡頓的常見原因加入程式碼規範,code review時需特別注意
  • 通過CR發現可優化點,提前發現可能引起卡頓的地方

  • 版本迭代治理

每個版本上線初期,觀察卡頓的上報情況。對於新增的卡頓,統計並分配任務,在下一個版本中進行優化治理。最大程度的減少了卡頓的存在,防止指標的惡化。

  1. 監控平臺

在自研的監控平臺上,使用者端針對頁面進行了卡頓次數的上報統計,新版本上線後,可根據版本號觀察資料的變化,及時發現新的卡頓問題。

後續規劃

  • 卡頓治理的一期都是基於bugly工具上報的執行緒資訊進行優化,後續使用者端將接入自研的卡頓檢測工具,會在此資料上進一步治理卡頓;
  • 目前優化的都是普通的卡頓,對於APP卡死還未進行專項治理。後續會接入自研的工具,重點進行卡死現象的採集和治理;
  • 在DEBUG模式下,開啟卡頓彈框提醒;檢測到卡頓情況後彈出彈框,在開發和測試階段可儘早的發現和治理卡頓;

結語

iOS的卡頓優化是一個複雜且艱鉅的任務,它涉及到程式碼的重構、邏輯的重寫、底層元件的改動,在優化的同時,還必須要保障業務邏輯的正常和穩定。因此,合理地分期進行,優先解決卡頓上報量大的問題,再去解決上報量小的問題,抓大放小,持續治理,APP的使用者體驗一定會有潛移默化的提升。

參考

13 | 如何利用 RunLoop 原理去監控卡頓?-極客時間

2020移動應用效能管理白皮書 | 基調聽雲