出行iOS使用者端卡頓治理實踐

語言: CN / TW / HK

一、前言

我們使用APP有時會遇到點選響應遲鈍、頁面跳轉緩慢、滑動列表不流暢、卡死無響應,這些就是卡頓問題,它會影響使用者體驗,嚴重時會導致使用者的流失,因此卡頓治理是非常重要的。

但是要將卡頓治理好並不容易。很多卡頓是隨著時間各種機型、系統、執行環境的出現慢慢浮現的,並且卡頓總數量往往比較多,這就意味著不可能一次性解決,從卡頓預防、監控上報、上報處理都是長期的事情。那麼該如何去做好卡頓治理呢?接下來我將對App的實踐做個總結說明,有興趣的同學可以在評論區一起探討。

二、治理效果

我們使用的是Bugly進行卡頓的監控,設定的卡頓閾值是3000ms。經過多期的卡頓優化,iOS乘客端裝置卡頓率從6.51%降到了0.21%。

| 版本號 | v1.2.10(優化前) | v1.3.30(優化後) | | ----- | ------------ | ------------ | | 裝置卡頓率 | 6.51% | 0.21% |

截止目前最新版本v1.3.30的裝置卡頓率是0.21%,見下圖。

三、治理策略

卡頓治理是長期的事情,為了使治理工作有序且能看到效果,需要有策略得進行。

首先,把卡頓分為四個階段,A階段是處理大頭且卡頓原因明顯的;B階段處理大頭的疑難卡頓;C階段處理小頭的卡頓;D階段是達到目標後的階段,主要維持卡頓不突然大增。

其次,對於每個階段,動態分期處理。分期是以一個發版視窗為一期,動態是每期圍繞最近兩個版本進行。另外針對比較疑難的卡頓,可以採取嘗試性地解決,儘可能減少它的發生次數。

再其次,各期卡頓處理方案和效果形成文件,卡頓防劣化基於此文件進行組內分享,另外這樣也方便跟蹤各期處理的效果。

最後,在這個策略之下再進行具體卡頓問題的處理。

四、治理方式

卡頓治理能起到直接效果的方式就是解決上報的卡頓問題。把上報的卡頓分為兩類,一類是開發人員寫的方法耗時導致的;另一類是比較隱晦的,通常涉及到系統方法內部的執行。因為第一類卡頓在實際開發中大家都有意識避免,也容易解決,所以接下來重點講述第二類卡頓的解決。

  1. 復現卡頓的思路

復現堆疊是解決問題的關鍵,復現了堆疊就確定了執行鏈路,接下來就好定位問題了。

流程圖 (2).jpg

結合實際的經驗,復現的具體操作如下:

先從Bugly上報堆疊中找到關鍵方法或函式,在Xcode中新增符號斷點,執行APP到對應的使用場景中,然後在斷點停住時可以使用lldb除錯指令bt打印出呼叫棧跟Bugly中的比對。 如果是一樣的,那麼執行的堆疊就復現了。接下來從Debug Navgator欄中可以很方便的檢視到這期間的方法執行情況,結合程式碼檢視這個呼叫鏈路中存在的耗時操作,定位問題進行處理。

在實際的堆疊對比時,如果發現Bugly堆疊中有的方法在Xcode堆疊中沒有,但該方法前後都能對上,不用慌!這可能是Xcode在打包時將一些方法優化成內聯函數了,這種情況我們執行在release模式下檢視就可以了,接下來通過兩個示例來說明。

  1. 示例

  1. 系統庫函式帶下劃線

根據上述思路,首先分析多個上報記錄,檢視到堆疊都是下面這樣:

0 libsystem_kernel.dylib _mach_msg_trap + 8 1 libsystem_kernel.dylib _mach_msg + 76 2 libdispatch.dylib __dispatch_mach_send_and_wait_for_reply + 540 3 libdispatch.dylib _dispatch_mach_send_with_result_and_wait_for_reply + 60 4 libxpc.dylib _xpc_connection_send_message_with_reply_sync + 240 5 Foundation ___NSXPCCONNECTION_IS_WAITING_FOR_A_SYNCHRONOUS_REPLY__ + 16 6 Foundation -[NSXPCConnection _sendInvocation:orArguments:count:methodSignature:selector:withProxy:] + 2540 7 Foundation -[NSXPCConnection _sendSelector:withProxy:arg1:arg2:arg3:] + 152 8 Foundation __NSXPCDistantObjectSimpleMessageSend3 + 84 9 CoreLocation _CLCopyTechnologiesInUse + 32832 10 CoreLocation _CLCopyTechnologiesInUse + 26408 11 CoreLocation _CLClientStopVehicleHeadingUpdates + 94460 12 XLUser + [ HLLMKLocationRecorder locationAuthorised] ( HLLMKLocationRecorder .m: 299 ) 13 ... // 以下略

接下來找到關鍵方法、函式。從上面的堆疊中,我們可以看到是主執行緒執行到[LocationRecorder locationAuthorised]方法中第269行發生,該行程式碼是[CLLocationManager authorizationStatus],是獲取系統使用者當前定位許可權的方法。對堆疊中第0-12行中的方法做一番瞭解,初步發現xpc_connection_send_message_with_reply_sync函式可能會阻塞當前執行緒,點選檢視官方說明

接下來新增符號斷點xpc_connection_send_message_with_reply_sync, 注意如果是系統庫中的帶下劃線的函式,我們新增符號斷點的時候一般需要少一個下劃線_,又比如上述的__dispatch_mach_send_and_wait_for_reply函式,我們新增符號斷點時也要少一個下劃線,即_dispatch_mach_send_and_wait_for_reply

然後在斷點停住時,在lldb除錯臺敲bt後回車就可以打印出當前的呼叫棧。

20221213-181927.jpeg

然後經過比對是一致的,這樣確認了場景。

然後定位問題。在上述卡頓堆疊呼叫鏈路中,我發現自己專案的方法呼叫鏈路中不存在耗時多的操作,那麼接下來可以分析系統函式是否存在耗時多的操作,一般是涉及程序間通訊的,接著我們通過xpc_connection_send_message_with_reply_sync方法查網上資料,發現這個方法涉及到了程序間通訊。

最後出解決方案。我們通過新增一個單例類,單例設定為CLLocationManager代理並根據代理方法更新單例的定位許可權屬性,專案中全域性獲取定位許可權的方式改為訪問單例類中的屬性,上線後就解決了該問題。

  1. 系統庫中方法呼叫,不帶下劃線

下面是在iPhone6、6plus之類的較老機型上發生的卡頓,是在push頁面後鍵盤彈起時發生。

0 libsystem_kernel.dylib ___psynch_cvwait + 8 1 libsystem_pthread.dylib __pthread_cond_wait$VARIANT$mp + 688 2 Foundation -[NSCondition waitUntilDate:] + 128 3 Foundation -[NSConditionLock lockWhenCondition:beforeDate:] + 100 4 UIKitCore -[UIKeyboardTaskQueue lockWhenReadyForMainThread] + 420 5 UIKitCore -[UIKeyboardTaskQueue waitUntilAllTasksAreFinished] + 84 6 UIKitCore -[UIKeyboardImpl generateAutofillCandidate] + 136 7 UIKitCore -[UIKeyboardImpl setDelegate:force:] + 4884 8 UIKitCore -[UIPeripheralHost(UIKitInternal) _reloadInputViewsForResponder:] + 1544 9 UIKitCore -[UIResponder(UIResponderInputViewAdditions) reloadInputViews] + 80 10 UIKitCore -[UIResponder becomeFirstResponder] + 804 11 UIKitCore -[UIView(Hierarchy) becomeFirstResponder] + 156 12 UIKitCore -[UITextField becomeFirstResponder] + 244 13 ... // 以下略

從堆疊中看到,系統庫中的方法是一些OC方法。對於這個情況我們新增符號斷點時可以直接使用方法的名字,比如上述的waitUntilDate:lockWhenCondition:beforeDate:。斷點停住後,在lldb除錯臺bt就可以打印出當前的呼叫棧,跟Bugly卡頓堆疊是對得上的。

通過方法呼叫鏈路分析認為是這個鎖產生的卡頓,在生成鍵盤上候選詞時會呼叫到。在我們設定輸入框的屬性autocorrectionTypeUITextAutocorrectionTypeNo後,就不會出現了,從而這個卡頓就解決了。

if (低版本機型) { textField.autocorrectionType = UITextAutocorrectionTypeNo; }

五、常見卡頓

  1. 多個小耗時任務累積成了卡頓

在一次runloop迴圈中呼叫方法多、執行鏈路較長的情況下,如果該鏈路中存在多個小耗時的操作,就大概率會發生卡頓。這種情況在測試時期不會暴露,但線上執行的環境更復雜,APP可能處於後臺、低電量等裝置CPU資源緊張的情況。這種卡頓的特點是,在卡頓列表中,有該執行鏈路中的多個方法的卡頓問題。

一個例子,在某個網路請求回撥到主執行緒後,Json解析成Model,之後有建立和更新UI、多個地方呼叫layoutIfNeed立即佈局檢視、磁碟IO存取資料等多個小段耗時操作。對於這樣的卡頓,解決辦法是,首先排查出鏈路中涉及到耗時操作的地方,進行優化;然後將某些方法放到非同步主佇列中執行,這樣鏈路就縮短了。

  1. Jetsam 機制下收到記憶體警告

iOS 系統在記憶體緊張時,會壓縮一些記憶體內容,並在需要時解壓,但副作用是會造成較高的 CPU 佔用甚至卡頓,手機耗電量也會隨之增加。為了解決上面的問題,蘋果設計了 Jetsam 機制。 其工作方式是當記憶體不足時,系統會通知前臺應用去釋放記憶體(通過 applicationDidReceiveMemoryWarning 方法和 UIApplicationDidReceiveMemoryWarningNotification 通知),如果記憶體壓力依然存在,將會終止一些後臺APP, 最終記憶體還不夠的話,就會終止當前APP(FOOM),並且上報日誌。

我們在卡頓上報中可以看到有一些卡頓是因為收到記憶體警告時發生的,這就需要我們根據自己頁面的情況在收到記憶體警告時適當的清理記憶體,並且最好是在收到記憶體警告通知的處理放到非同步主佇列中,避免過多的記憶體警告處理都集中在一次runloop中。

  1. 主執行緒執行了耗時任務

比如在聊天頁面選擇高清大圖後,先執行壓縮後儲存到本地一份再發送,這個可能會發生卡頓。我們可以將此操作放到非同步子佇列中處理。

  1. 呼叫涉及程序間通訊的系統API

如果方法執行中呼叫了涉及到程序間同步通訊的API,是可能發生卡頓的,特點是堆疊中會有_xpc_connection_send_message_with_reply_sync這個函式,發現的幾種情況是:

  • NSUserDefault呼叫寫操作
  • CLLocationManager當前定位許可權狀態的獲取
  • 給通用剪貼簿UIPasteboard設定值、獲取值。
  • UIApplication通過openURL開啟其他APP
  • CNCopyCurrentNetworkInfo獲取WiFi資訊
  • 給系統鑰匙串keychain中設定值。

解決辦法是儘量不頻繁呼叫,或者尋找其他的實現方式,比如NSUserDefault可以換為使用MMKV;跳轉到其它APP進行分享或者第三方支付時,可以在跳轉前將這個操作放到非同步主佇列中進行,也能避免極端情況下多次調用出現卡頓。

  1. 處於後臺時遞迴呼叫自身方法

有個具體的例子是:有一個2S的Lotties動畫,在動畫結束後3秒之後再次執行該動畫,如果我們的實現方式是如下,那麼處於後臺時就可能發生卡頓。

// 這個是有問題的寫法,在後臺時也會一直遞迴呼叫自身 private func beginCallCarAnimation() { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { // 讓 callCarBtnAnimation開始一個2s的lotties動畫 self.callCarBtnAnimation.play() } // 開啟延時等待5s後呼叫自身方法再次開啟動畫 DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in self?.beginCallCarAnimation() } }

解決辦法為, 將這個5S的延時放到動畫結束後,因為動畫模式設定為了pauseAndRestore, 在進入後臺時會儲存狀態,進入前臺時恢復動畫,執行完動畫才呼叫closure

private func beginCallCarAnimation() { // 注意📢:如果進入後臺動畫會停止,下次回來掃光結束後才開始等3秒 self.callCarBtnAnimation.play { finished in guard finished else { return } // 動畫結束後,3秒後再次執行動畫 // 因為動畫設定的後臺模式為pauseAndRestore,這樣就能保證在後臺時不會遞迴執行 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.beginCallCarAnimation() } } }

  1. 初始化比較大的Lotties動畫

在Lotties動畫檔案較大時,在一些情況下也是有必要將動畫檔案的解析放到非同步子執行緒中,完成後再回調主執行緒初始化檢視。如下是一個例子:

``` // 序列佇列去執行動畫檔案載入和解析,序列佇列也寫在全域性引用的地方 DispatchQueue(label: "com.xxx.caton").async { // 在子執行緒載入解析動畫檔案 let animation = Animation.named("lotties檔名") let provider = BundleImageProvider(bundle: Bundle.main, searchPath: nil) DispatchQueue.main.async { // 回撥主執行緒初始化動畫檢視 let animationView = AnimationView.init(animation: animation, imageProvider: provider) animationView.loopMode = .playOnce animationView.backgroundBehavior = .pauseAndRestore

    self.addSubview(animationView)
    animationView.snp.makeConstraints { ... }
}

} ```

  1. 監聽系統通知後執行太擁擠

APP中監聽進入前臺、後臺、記憶體警告通知的地方很多,導致通知一來,CPU就上升,我們可以適當得將一些處理放到一個序列子佇列中完成,如果是耗時的操作,在進入後臺時,可以使用開啟後臺任務的API來完成。

  1. 列印函式

專案中有大量的呼叫NSLogprintdebugPrint,在線上是會有卡頓問題上報的。實際上,拋開卡頓,release環境下也是不需要控制檯列印的,我們應該遮蔽。

對於OC中可以使用巨集定義NSLog來解決。對於Swift來說,print和debugPrint都會在release下列印,應該封裝使用自己的列印方法,並且巨集定義在release下不生效。

六、總結

對於APP,卡頓治理是一件長期的事情,需要定好一個策略分期有序進行,這樣下來也能看到每期的效果和價值。對於個人,在解決卡頓問題時,會遇到一些疑難卡頓,可能需要花費很多時間查閱API文件、網上資料甚至是查原始碼才能解決,但這個過程也讓我們獲得成長。

參考:

iOS 保持介面流暢的技巧

iOS-runloop-ibrime

iOS記憶體abort(Jetsam) 原理探究

程序間同步通訊官方API文件

從底層分析一下存在跨程序通訊問題的 NSUserDefaults