貨拉拉Android穩定性治理

語言: CN / TW / HK

作者簡介
Kelvin,貨拉拉資深客戶端工程師,目前負責貨拉拉App Android端效能優化、APM相關工作。

App Crash對於使用者來講是一種最糟糕的體驗,它會導致流程中斷、app口碑變差、app解除安裝、使用者流失、訂單流失等。相關資料顯示,當Android App的崩潰率超過0.4%的時候,活躍使用者有明顯下降態勢。根據統計2021年初我們的Crash率為5%,大量的研發時間用於定位和解決使用者反饋、使用者投訴,crash治理刻不容緩。通過去年的大量實踐治理,我們app的Crash率降低並且穩定在0.02%,在此做一些總結和分享,希望能為其他團隊提供經驗和啟發。

行業標準

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

| 效能指標 | 優秀值 | 及格值 | 極差值 | 行業參考值 | | ------ | ----- | --- | ---- | ----- | | 崩潰率(%) | <=0.1 | 0.6 | >=1 | 0.5 |

我們對Crash率的治理目標設定為了0.03%。

治理前的整體狀況

App的Crash率高達5+%,隨著產品的不斷迭代,複雜度不斷提升,給Crash率降低帶來巨大挑戰,我們必須在處理好新生的Crash的同時針對遺留的Crahs進行清理。Crash部分分為兩部分,Native部分crash佔比高達72%,這部分的Crash主要來源於Flutter、第三方的地圖和第三方的SDK,難以解決。Java部分的crash很多沒有得到及時解決,大量遺留的歷史債,多數有通用的解決方案。

Crash治理方法

常見Crash的處理方式

  • 根據Crash統計平臺的堆疊,使用者日誌,操作路徑定位和解決
  • 尋找共性,機型、品牌、系統版本、所在頁面、使用者操作等輔助解決問題
  • 復現場景,能夠復現通常就很容易解決,可以線下復現或者雲真機復現

其他處理方式

  • 對crash率較高的模組進行業務梳理,排查,重構等
  • 與第三方sdk溝通升級解決問題,修改SDK的使用方式

Crash治理實踐

由於內部的統計平臺建設比較晚,我們只能獲取到近期的Crash率變化,可以看到Crash率明顯收斂。

image.png

  1. 程式碼重構

為應對需求的頻繁變化、提高研發效率,貨拉拉首頁、確認下單頁等使用Flutter和小程式實現,而這部分是使用者使用率最高的頁面,程式碼量龐大而且複雜。在線上環境中產生了大量的Crash,大部分的Crash為偶發,線下環境無法復現,從堆疊情況很難定位問題的根源,修改起來有可能涉及到多端,各端都需要進行迴歸校驗。經過綜合分析,我們決定梳理邏輯,讓最重要的這部分程式碼迴歸原生,重構上線之後Crash率明顯下降,由原來的5%下降到0.5%,穩定性大幅提高。

  1. 三方SDK和Native崩潰處理

這部分的Crash處理使我們從Crash率由千分位降低到了0.05%。在初期我們很容易解決了java部分的Crash,然而第三方sdk和Native的Crash居高不下,有很長一段時間我們通過提交工單,諮詢對接方等形式並沒有辦法得到解決。對於有些crash,我們升級了sdk發現crash量反而增加,又需要降級回舊版本。對於有些crash,堆疊資訊卻跟這個sdk沒有關係。針對這些部分的崩潰通常用以下兩種方式解決:

  • 檢視Api的呼叫存在問題(呼叫時機,呼叫順序,傳入引數,呼叫的執行緒或者程序等)
  • 溝通解決,(在沒有一對一開發對接的情況下,嘗試提工單諮詢),將Crash的堆疊傳送給第三方SDK進行處理

我們兩組Crash量最高的為例:

2.1 第三方VMP執行緒監控記憶體情況並關閉App導致

Crash統計平臺上有大量的Native崩潰SIGSEGV(SEGV_MAPERR)、SIGABRT和SIGSEGV(SEGV_ACCERR)等,堆疊通常為系統級別的so內部(libgui.so、libGLES_mali.so和libc.so等),從抓取的堆疊中我們無法得知問題根源。我們已知的資訊是Vivo手機Android10,Android11崩潰率極高,為此我們病急亂投醫式進行了幾種嘗試:

  • 錯誤堆疊中有出現硬體加速相關的so,我們嘗試關閉了整個的硬體加速功能
  • 進行大量的記憶體洩露治理、執行緒治理
  • 降低手機幀率
  • 聯絡手機廠商

多次嘗試無果之後,在一次的使用者日誌對比中發現部分bug存在相同的日誌

諮詢第三方SDK後得知VMP監控,對應用程序的/proc/pid/mem和/proc/self/task/pid/mem檔案監控,如果檔案被操作則會退出App,退出方式可能存在問題,導致地圖sdk中捕獲native崩潰時出現二次崩潰,在將這部分bug處理之後我們的崩潰率降低到0.1%左右,native部分的Crash堆疊沒有這部分Crash參雜,堆疊資訊更加明確,問題也容易定位一些。

2.2 地圖使用

地圖是貨拉拉應用中必不可少的元件,在使用者下單過程中都會用到,貫穿整個主流程。地圖部分的crash絕大部分為native,並且有些是特定品牌機型獨有的crash。我們將Crash反饋給SDK方,開發人員根據堆疊定位問題,經過多次分析導致Crash的原因一部分是sdk導致,另一部分是我們呼叫不合理的導致。SDK內部的原因通過升級解決,crash呈收斂趨勢。呼叫不合理部分主要是因為銷燬時機不正確導致,我們對各個地圖使用場景進行排查修復,在幾個版本修復之後總體Crash率降低到了0.05%。

  1. OOM治理

OutOfMemoryError是指記憶體溢位,也是一類難以解決的Crash,通常上報的堆疊資訊,使用者日誌等都沒有太多的參考價值,往往記憶體已經在其他位置增加到記憶體溢位的臨界點,而上報的位置很可能不存在記憶體問題,而引起記憶體溢位主要的原因有:記憶體洩漏,引用大物件,記憶體抖動,執行緒使用不合理等。

3.1 記憶體洩漏

主要是用LeakCanary進行檢測,通常Activity的洩漏會顯得比較嚴重,因為它承載了整個頁面的控制元件、資料等,Activity無法回收也會導致這部分物件無法回收。

常見引起Activity洩漏的原因

  • Handler,Thread等匿名內部類隱式持有外部類導致
  • 註冊的監聽器未及時反註冊:EventBus,廣播
  • 單例物件持有Activity:貨拉拉App中有一個頁面管理會持有Activity物件,有一部分頁面新增之後沒有移除造成了大量記憶體洩漏的場景
  • 網路請求,非同步任務:由於早期網路部分封裝的不好,網路請求沒有與頁面的生命週期繫結,在弱網或者使用者關閉頁面較快的情況下會出現記憶體洩漏。
  • Context使用Application就可以的時候使用了Activity:例如,Toast,第三方SDK
3.2 執行緒治理

當執行緒超過系統設定的上限,也會導致OOM的發生,報錯:pthread_create (1040KB stack) failed: Out of memory。通常處理方式如下:

  • 對App內部執行緒池進行統一,對於隨意使用的非同步任務統一改為使用執行緒池或者RxJava
  • 檢查App內Timer,HandlerThread類的合理使用
  • 溝通內部其他團隊的SDK中增加執行緒代理,統一使用App端的執行緒池,避免執行緒的隨意使用
  • 分析合理可以替換的執行緒或者執行緒池進行插樁替換處理
3.3 大物件處理

通常App中圖片都是佔用記憶體的大戶,bitmap的管理是記憶體治理中非常重要的環節。對於這裡的處理,我們首先是將圖片載入統一使用Glide,再將App中的原生載入圖片替換成Glide。

另外,通過MAT工具,篩選、排序大物件,對頭部大物件進行優化:

  • 原本在同一個SharePreference的資料拆分到多個,使用完畢進行回收。
  • 某個類中持有一個非常大的資料,而並不是經常用到,可以放入資料庫或者檔案中。
3.4 記憶體抖動

其實針對這部分的優化,我們只是對檢測到的、比較嚴重的、不合理的記憶體增長進行了優化。通過AndroidStudio的Profiler,我們發現記憶體出現鋸齒狀的增長,或者GC頻率極高的情況,對著部分不合理的物件分配進行分析和解決。

這裡舉兩個例子:

a.自定義View的onDraw方法中建立物件

image.png b.設定監聽佈局變化,再改變控制元件的大小,位置等。在改變這個view顯示狀態時又會出發監聽,這裡就會不斷的執行,造成了記憶體的瘋狂增長,GC頻率提高,在一些測試機上十幾秒就回進行一次GC。

``` View.getViewTreeObserver().addOnGlobalLayoutListener(() -> {

//改變view的大小,位置等

int[] view1Location = new int[2];

view1.getLocationOnScreen(view1Location);

int[] view2Location = new int[2];

view2.getLocationOnScreen(view2Location);

}); ```

解決的方式主要有兩種:

1.物件複用:對於onDraw中的使用場景可以將物件作為一個成員變數,其他情況考慮是否可以建立物件池

2.增加條件:在達到某種條件下才允許建立物件的邏輯,比如第二個例子中,由於業務原因無法移除監聽,我們增加了一些條件,只有少量情況才會執行建立的邏輯。

4.常規Crash

這部分問題通常根據堆疊和使用者手機的相關情況很容易定位到問題,不過不能只是修改出問題的那行程式碼,更不可以隨意的進行try catch,需要找到問題的本質,可能這個問題的還會導致其他的一系列問題。比方說:

點選事件的某一行出現了空指標,發現是資料為空導致,資料是從前一個頁面的網路介面傳遞過來,而介面資料為空是因為傳入的裝置id為空。那麼此時我們只在點選事件這裡做判空肯定是不夠的,需要將裝置id為空的原因找到,否則這整個鏈路都會出現crash或者業務異常。

4.1 空指標NullPointerException

空指標雖然上報的量不大,但卻是我們統計到出現次數最多的bug。通常是物件本身沒做初始化或者物件被回收的情況下使用該物件導致:

  • 程式碼邏輯分支引起,沒走到初始化的位置,卻走了相應的呼叫邏輯。
  • 服務端介面返回資料異常,資料庫查詢資料異常
  • 一些postDelay或者非同步回撥,回撥時也能被回收,可能導致空指標
  • 靜態變數被回收

通常解決空指標不能直接在程式碼基礎上增加判空處理,應當尋找真正導致物件為空的原因進行規避,常見的解決方式有:

  • 做好判空或者使用kotlin語言
  • 使用註解@NonNull和@Nullable
  • 存在非同步任務應當適時停止非同步任務或者移除監聽回撥(例如,頁面銷燬停止網路請求,移除頁面內的postDelay任務)
  • 靜態變數做好管理,可以本地持久化
4.2 索引越界IndexOutofBoundsException

常見原因:

  • 字串拼接,擷取索引不正確,SpannableString 索引設定異常
  • ListView,RecyclerView或者ViewPager中資料變化沒有通知Adapter資料發生變化
  • 多執行緒情況下使用不安全的集合

解決方式:

  • 字串,SpannableString有關索引操作,應當先判斷好字串長度
  • 多執行緒場景下應當用安全的形式訪問集合或者使用執行緒安全的集合
4.3 系統原因產生的bug

對於這類bug,觸發的原因很難查到,我們需要找到堆疊中的關鍵字、系統版本、機型進行分析,解決方案通常是規避呼叫或者hook的方式解決。例如下面的bug,關鍵字“autofill”,版本Android10,對應於Android10的自動填充功能。

錯誤名稱和堆疊資訊

android.os.RemoteException Remote stack trace: at com.android.internal.util.Preconditions.checkCollectionElementsNotNull

image.png 簡要分析

上面的錯誤日誌涉及到跨程序,分為三段來看會清晰一些。

App程序中ActivityThread的mH接收到Message進行處理handleRequestAssistContextExtras,再呼叫ActivityTaskManagerService的aidl方法reportAssistContextExtras

進入到ActivityTaskManagerService.reportAssistContextExtras新建FillRequest的時構造中呼叫Preconditions.checkCollectionElementsNotNull丟擲異常,異常資訊傳送回我們App程序。

Preconditions類異常丟擲的位置

呼叫驗證

通過對原始碼檢視,mH中handleRequestAssistContextExtras的呼叫只有一處,發出這個message的也只有一處。通過hook,確定使用者在使用自動填充功能時,會呼叫這裡。

解決方案

設定關閉EditText控制元件的自動填充功能

Edittext的自動填充引起的Bug_乂星人的部落格-CSDN部落格_android edittext 自動填充

4.4 其他Crash較多的bug
  • android.app.RemoteServiceException Context.startForegroundService() did not then call Service.startForeground()

Android8.0啟動服務適配問題,App內部和SDK中都有閃退出現,排查App中所有的Service進行適配處理。

  • android.content.ActivityNotFoundException No Activity found to handle Intent

這個是我們App中js回撥安卓跳轉頁面的方法,由於頁面未找到導致的閃退。另外我們發現業務中由

JavaScriptInterface回撥安卓端再進行頁面的一些操作和發出的Eventbus事件引發了許多莫名其妙的小bug,為此我們將JavaScriptInterface中的回撥儘可能都拋回主執行緒中執行,減少了因子執行緒操作UI和多執行緒執行不安全的問題。

  • com.google.gson.stream.MalformedJsonException

由於後端資料或者本地資料庫問題導致資料的解析異常,統一使用了Json解析工具,對異常進行捕獲處理

Crash預防和輔助工具

  1. 灰度釋出

貨拉拉的打包釋出系統支援持續整合、灰度釋出、構建統計、配置下發、強升普升弱升、內測分發等功能,灰度釋出支援設定裝置ID、數量、百分比、版本號、品牌型號、網路制式、時間、系統版本、業務城市、定位城市等非常靈活。

灰度系統的主要作用:

  • 儘早的獲得使用者的反饋,完善產品功能
  • 暴露存在的Crash問題、產品設計問題,及時修改,縮小影響面

貨拉拉目前的版本釋出會嚴格按照灰度策略進行逐步放量,在業務需要時會增加對灰度的範圍設定例如:城市、品牌型號、版本號等。對於風險較大的需求,我們可能會專門安排一個灰度版本進行釋出,最大可能的縮小影響範圍。

image.png

  1. 應用配置系統

通過貨拉拉的打包釋出系統進行配置下發,同時這個配置支援灰度釋出,可以像App釋出灰度一樣下發,對於一些業務可以通過配置下發,控制功能的引數,功能的開關,進行業務降級,修改AB實驗等。

image.png

例如,我們在線上環境中有些H5頁面使用了離線包,其他部門對閘道器進行切換,導致離線包出現跨域問題,無法訪問頁面。我們通過配置下發及時關閉離線包功能,業務降級為線上網頁。

  1. 程式碼質量相關措施

建立Code Review 制度

  • 保證程式碼的可讀性和風格一致性:便於其他成員對程式碼的理解,增強維護性
  • 發現錯誤:程式碼中出現錯誤很難避免,而這些錯誤在另外一個人眼中可能很容易被發現
  • 發現設計問題:檢查程式碼中的設計不合理,避免後續維護、迭代困難
  • 健壯性檢查:檢查程式碼的健壯性,是否有潛在的安全問題,效能風險等
  • 互相學習:閱讀他人的程式碼也可以從中吸取經驗

增加模組Owner

  • 模組內程式碼主要由Owner開發實現,Owner會比較熟悉,並且能夠保證程式碼的一致性。
  • Owner可以更好的review其他開發者的程式碼

業務串講、思維導圖

  • 讓團隊內部成員熟悉業務模組和流程
  • 利於團隊內溝通、學習

程式碼掃描:不符合團隊規範的程式碼無法提交

  • 保證程式碼的規範
  • 保證程式碼的一致性

  • 熱修復

當App已經大面積釋出,而Crash會導致大量使用者無法使用、閃退、主流程無法執行等,我們會考慮熱修復。如果沒有熱修復技術,那麼我們只能重新發布App版本,甚至需要進行強制升級,會造成極差的使用者體驗。

image.png

  1. 日誌系統

貨拉拉的日誌系統分為兩類:

實時日誌:使用者在使用App時產生日誌通過上傳策略上報到我們平臺,支援日誌等級篩選、TAG篩選和搜尋等常用功能。

離線日誌:根據使用者ID等進行推送使用者進行上報,或者使用者主動進行上報。

通過日誌系統,我們可以根據日誌等級、日誌時間等得知當前使用者的使用場景和操作路徑,便於場景復現,是解決Crash的利器之一。

總結

過去的一年我們對App的重點模組進行重構、第三方SDK治理、多次的專項治理,Crash率由5+%降低到了0.02%,穩定性的提升使我們使用者反饋問題和使用者投訴明顯減少,安卓app端相關的投訴反饋由去年4月的12個降低到今年4月的2個,讓我們更多地留存使用者,保證使用者順利完成訂單。我們總結出幾點治理原則:

  • 舉一反三,由點及面,由一個問題引入或者歸類出一類問題,進行專項的治理。
  • 尋找本質,找到問題的根因而不是一味地try catch,判斷繞過。
  • 及時解決,Crash問題儘可能在開發和測試環節已經出現應當及時解決,而不是灰度和全量之後再處理。
  • 著重預防,將Crash消滅在萌芽階段,進行程式碼review、模組劃分、技術串講等措施。
  • 控制准入,嚴格控制模組、sdk、新技術的引入,減少引入風險

Crash治理並非一勞永逸,需要長期治理,建立長效機制。另外一方面,對於新技術和跨平臺技術,在業務允許的情況下我們應當需要勇敢的去嘗試,利用現有的預防措施做好防範工作,而不會是直接摒棄這些技術的應用。

參考連結:

友盟+U-APM 移動應用效能體驗報告:Android崩潰率達0.32%,OPPO 、華為、VIVO 崩潰表現良好
移動應用效能報告 歸檔 | 基調聽雲
移動端效能標準值及定義整理 - 掘金