美團外賣Android Crash治理之路

美團外賣Android Crash治理之路

Crash率是衡量一個App好壞的重要指標之一。如果你忽略了它的存在,它就會得寸進尺,愈演愈烈,最後造成大量用戶的流失,進而給公司帶來無法估量的損失。本文講述美團外賣Android客戶端團隊在將App的Crash率從千分之三做到萬分之二過程中所做的大量實踐工作,拋磚引玉,希望能夠為其他團隊提供一些經驗和啟發。

面臨的挑戰和成果

面對用戶使用頻率高,外賣業務增長快,Android碎片化嚴重這些問題,美團外賣Android App如何持續的降低Crash率,是一項極具挑戰的事情。通過團隊的全力全策,美團外賣Android App的平均Crash率從千分之三降到了萬分之二,最優值萬一左右(Crash率統計方式:Crash次數/DAU)。

美團外賣自2013年創建以來,業務就以指數級的速度發展。美團外賣承載的業務,從單一的餐飲業務,發展到餐飲、超市、生鮮、果蔬、藥品、鮮花、蛋糕、跑腿等十多個大品類業務。目前美團外賣日完成訂單量已突破2000萬,成為美團點評最重要的業務之一。美團外賣客戶端所承載的業務模塊越來越多,產品複雜度越來越高,團隊開發人員日益增加,這些都給App降低Crash率帶來了巨大的挑戰。

Crash的治理實踐

對於Crash的治理,我們儘量遵守以下三點原則:

  • 由點到面。一個Crash發生了,我們不能只針對這個Crash的去解決,而要去考慮這一類Crash怎麼去解決和預防。只有這樣才能使得這一類Crash真正被解決。
  • 異常不能隨便吃掉。隨意的使用try-catch,只會增加業務的分支和隱蔽真正的問題,要了解Crash的本質原因,根據本質原因去解決。catch的分支,更要根據業務場景去兜底,保證後續的流程正常。
  • 預防勝於治理。當Crash發生的時候,損失已經造成了,我們再怎麼治理也只是減少損失。儘可能的提前預防Crash的發生,可以將Crash消滅在萌芽階段。

常規的Crash治理

常規Crash發生的原因主要是由於開發人員編寫代碼不小心導致的。解決這類Crash需要由點到面,根據Crash引發的原因和業務本身,統一集中解決。常見的Crash類型包括:空節點、角標越界、類型轉換異常、實體對象沒有序列化、數字轉換異常、Activity或Service找不到等。這類Crash是App中最為常見的Crash,也是最容易反覆出現的。在獲取Crash堆棧信息後,解決這類Crash一般比較簡單,更多考慮的應該是如何避免。下面介紹兩個我們治理的量比較大的Crash。

NullPointerException

NullPointerException是我們遇到最頻繁的,造成這種Crash一般有兩種情況:

  • 對象本身沒有進行初始化就進行操作。
  • 對象已經初始化過,但是被回收或者手動置為null,然後對其進行操作。

針對第一種情況導致的原因有很多,可能是開發人員的失誤、API返回數據解析異常、進程被殺死後靜態變量沒初始化導致,我們可以做的有:

  • 對可能為空的對象做判空處理。
  • 養成使用@NonNull和@Nullable註解的習慣。
  • 儘量不使用靜態變量,萬不得已使用SharedPreferences來存儲。
  • 考慮使用Kotlin語言。

針對第二種情況大部分是由於Activity/Fragment銷燬或被移除後,在Message、Runnable、網絡等回調中執行了一些代碼導致的,我們可以做的有:

  • Message、Runnable回調時,判斷Activity/Fragment是否銷燬或被移除;加try-catch保護;Activity/Fragment銷燬時移除所有已發送的Runnable。
  • 封裝LifecycleMessage/Runnable基礎組件,並自定義Lint檢查,提示使用封裝好的基礎組件。
  • 在BaseActivity、BaseFragment的onDestory()裡把當前Activity所發的所有請求取消掉。

IndexOutOfBoundsException

這類Crash常見於對ListView的操作和多線程下對容器的操作。

針對ListView中造成的IndexOutOfBoundsException,經常是因為外部也持有了Adapter裡數據的引用(如在Adapter的構造函數里直接賦值),這時如果外部引用對數據更改了,但沒有及時調用notifyDataSetChanged(),則有可能造成Crash,對此我們封裝了一個BaseAdapter,數據統一由Adapter自己維護通知, 同時也極大的避免了The content of the adapter has changed but ListView did not receive a notification,這兩類Crash目前得到了統一的解決。

另外,很多容器是線程不安全的,所以如果在多線程下對其操作就容易引發IndexOutOfBoundsException。常用的如JDK裡的ArrayList和Android裡的SparseArray、ArrayMap,同時也要注意有一些類的內部實現也是用的線程不安全的容器,如Bundle裡用的就是ArrayMap。

系統級Crash治理

眾所周知,Android的機型眾多,碎片化嚴重,各個硬件廠商可能會定製自己的ROM,更改系統方法,導致特定機型的崩潰。發現這類Crash,主要靠雲測平臺配合自動化測試,以及線上監控,這種情況下的Crash堆棧信息很難直接定位問題。下面是常見的解決思路:

  1. 嘗試找到造成Crash的可疑代碼,看是否有特異的API或者調用方式不當導致的,嘗試修改代碼邏輯來進行規避。
  2. 通過Hook來解決,Hook分為Java Hook和Native Hook。Java Hook主要靠反射或者動態代理來更改相應API的行為,需要嘗試找到可以Hook的點,一般Hook的點多為靜態變量,同時需要注意Android不同版本的API,類名、方法名和成員變量名都可能不一樣,所以要做好兼容工作;Native Hook原理上是用更改後方法把舊方法在內存地址上進行替換,需要考慮到Dalvik和ART的差異;相對來說Native Hook的兼容性更差一點,所以用Native Hook的時候需要配合降級策略。
  3. 如果通過前兩種方式都無法解決的話,我們只能嘗試反編譯ROM,尋找解決的辦法。

我們舉一個定製系統ROM導致Crash的例子,根據Crash平臺統計數據發現該Crash只發生在vivo V3Max這類機型上,Crash堆棧如下:

java.lang.RuntimeException: An error occured while executing doInBackground() at android.os.AsyncTask$3.done(AsyncTask.java:304) at java.util.concurrent.FutureTask.finishCompletion(FutureTask.java:355) at java.util.concurrent.FutureTask.setException(FutureTask.java:222) at java.util.concurrent.FutureTask.run(FutureTask.java:242) at android.os.AsyncTask$SerialExecutor$1.run(AsyncTask.java:231) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1112) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:587) at java.lang.Thread.run(Thread.java:818)Caused by: java.lang.NullPointerException: Attempt to invoke interface method 'int java.util.List.size()' on a null object reference at android.widget.AbsListView$UpdateBottomFlagTask.isSuperFloatViewServiceRunning(AbsListView.java:7689) at android.widget.AbsListView$UpdateBottomFlagTask.doInBackground(AbsListView.java:7665) at android.os.AsyncTask$2.call(AsyncTask.java:292) at java.util.concurrent.FutureTask.run(FutureTask.java:237) ... 4 more

我們發現原生系統上對應系統版本的AbsListView裡並沒有UpdateBottomFlagTask類,因此可以斷定是vivo該版本定製的ROM修改了系統的實現。我們在定位這個Crash的可疑點無果後決定通過Hook的方式解決,通過源碼發現AsyncTask$SerialExecutor是靜態變量,是一個很好的Hook的點,通過反射添加try-catch解決。因為修改的是final對象所以需要先反射修改accessFlags,需要注意ART和Dalvik下對應的Class不同,代碼如下:

public static void setFinalStatic(Field field, Object newValue) throws Exception { field.setAccessible(true); Field artField = Field.class.getDeclaredField("artField"); artField.setAccessible(true); Object artFieldValue = artField.get(field); Field accessFlagsFiled = artFieldValue.getClass().getDeclaredField("accessFlags"); accessFlagsFiled.setAccessible(true); accessFlagsFiled.setInt(artFieldValue, field.getModifiers() & ~Modifier.FINAL); field.set(null, newValue); }

private void initVivoV3MaxCrashHander() { if (!isVivoV3()) { return; } try { setFinalStatic(AsyncTask.class.getDeclaredField("SERIAL_EXECUTOR"), new SafeSerialExecutor()); Field defaultfield = AsyncTask.class.getDeclaredField("sDefaultExecutor"); defaultfield.setAccessible(true); defaultfield.set(null, AsyncTask.SERIAL_EXECUTOR); } catch (Exception e) { L.e(e); }}

美團外賣App用上述方法解決了對應的Crash,但是美團App裡的外賣頻道因為平臺的限制無法通過這種方式,於是我們嘗試反編譯ROM。

Android ROM編譯時會將framework、app、bin等目錄打入system.img中,system.img是Android系統中用來存放系統文件的鏡像 (image),文件格式一般為yaffs2或ext。但Android 5.0開始支持dm-verity後,system.img不再提供,而是提供了三個文件system.new.dat,system.patch.dat,system.transfer.list,因此我們首先需要通過上述的三個文件得到system.img。但我們將vivo ROM解壓後發現廠商將system.new.dat進行了分片,如下圖所示:

美團外賣Android Crash治理之路

經過對system.transfer.list中的信息和system.new.dat 1 2 3 … 文件大小對比研究,發現一些共同點,system.transfer.list中的每一個block數*4KB 與對應的分片文件的大小大致相同,故大膽猜測,vivo ROM對system.patch.dat分片也只是單純的按block先後順序進行了分片處理。所以我們只需要在轉化img前將這些分片文件合成一個system.patch.dat文件就可以了。

最後根據system.img的文件系統格式進行解包,拿到framework目錄,其中有framework.jar和boot.oat等文件,因為Android4.4之後引入了ART虛擬機,會預先把system/framework中的一些jar包轉換為oat格式,所以我們還需要將對應的oat文件通過ota2dex將其解包獲得dex文件,之後通過dex2jar和jd-gui查看源碼。

OOM

OOM是OutOfMemoryError的簡稱,在常見的Crash疑難排行榜上,OOM絕對可以名列前茅並且經久不衰。因為它發生時的Crash堆棧信息往往不是導致問題的根本原因,而只是壓死駱駝的最後一根稻草。導致OOM的原因大部分如下:

  • 內存洩漏,大量無用對象沒有被及時回收導致後續申請內存失敗。
  • 大內存對象過多,最常見的大對象就是Bitmap,幾個大圖同時加載很容易觸發OOM。

內存洩漏

內存洩漏指系統未能及時釋放已經不再使用的內存對象,一般是由錯誤的程序代碼邏輯引起的。在Android平臺上,最常見也是最嚴重的內存洩漏就是Activity對象洩漏。Activity承載了App的整個界面功能,Activity的洩漏同時也意味著它持有的大量資源對象都無法被回收,極其容易造成OOM。常見的可能會造成Activity洩漏的原因有:

  • 匿名內部類實現Handler處理消息,可能導致隱式持有的Activity對象無法回收。
  • Activity和Context對象被混淆和濫用,在許多隻需要Application Context而不需要使用Activity對象的地方使用了Activity對象,比如註冊各類Receiver、計算屏幕密度等等。
  • View對象處理不當,使用Activity的LayoutInflater創建的View自身持有的Context對象其實就是Activity,這點經常被忽略,在自己實現View重用等場景下也會導致Activity洩漏。

對於Activity洩漏,目前已經有了一個非常好用的檢測工具:LeakCanary,它可以自動檢測到所有Activity的洩漏情況,並且在發生洩漏時給出十分友好的界面提示,同時為了防止開發人員的疏漏,我們也會將其上報到服務器,統一檢查解決。另外我們可以在debug下使用StrictMode來檢查Activity的洩露、Closeable對象沒有被關閉等問題。

大對象

在Android平臺上,我們分析任一應用的內存信息,幾乎都可以得出同樣的結論:佔用內存最多的對象大都是Bitmap對象。隨著手機屏幕尺寸越來越大,屏幕分辨率也越來越高,1080p和更高的2k屏已經佔了大半份額,為了達到更好的視覺效果,我們往往需要使用大量高清圖片,同時也為OOM埋下了禍根。對於圖片內存優化,我們有幾個常用的思路:

  • 儘量使用成熟的圖片庫,比如Glide,圖片庫會提供很多通用方面的保障,減少不必要的人為失誤。
  • 根據實際需要,也就是View尺寸來加載圖片,可以在分辨率較低的機型上儘可能少地佔用內存。除了常用的BitmapFactory.Options#inSampleSize和Glide提供的BitmapRequestBuilder#override之外,我們的圖片CDN服務器也支持圖片的實時縮放,可以在服務端進行圖片縮放處理,從而減輕客戶端的內存壓力。
  • 分析App內存的詳細情況是解決問題的第一步,我們需要對App運行時到底佔用了多少內存、哪些類型的對象有多少個有大致瞭解,並根據實際情況做出預測,這樣才能在分析時做到有的放矢。Android Studio也提供了非常好用的Memory Profiler,堆轉儲和分配跟蹤器功能可以幫我們迅速定位問題。

AOP增強輔助

AOP是面向切面編程的簡稱,在Android的Gradle插件1.5.0中新增了Transform API之後,編譯時修改字節碼來實現AOP也因為有了官方支持而變得非常方便。在一些特定情況下,可以通過AOP的方式自動處理未捕獲的異常:

  • 拋異常的方法非常明確,調用方式比較固定。
  • 異常處理方式比較統一。
  • 和業務邏輯無關,即自動處理異常後不會影響正常的業務邏輯。典型的例子有讀取Intent Extras參數、讀取SharedPreferences、解析顏色字符串值和顯示隱藏Window等等。

這類問題的解決原理大致相同,我們以Intent Extras為例詳細介紹一下。讀取Intent Extras的問題在於我們非常常用的方法 Intent#getStringExtra 在代碼邏輯出錯或者惡意攻擊的情況下可能會拋出ClassNotFoundException異常,而我們平時在寫代碼時又不太可能給所有調用都加上try-catch語句,於是一個更安全的Intent工具類應運而生,理論上只要所有人都使用這個工具類來訪問Intent Extras參數就可以防止此類型的Crash。但是面對龐大的舊代碼倉庫和諸多的業務部門,修改現有代碼需要極大成本,還有更多的外部依賴SDK基本不可能使用我們自己的工具類,此時就需要AOP大展身手了。

我們專門製作了一個Gradle插件,只需要配置一下參數就可以將某個特定方法的調用替換成另一個方法:

WaimaiBytecodeManipulator { replacements( "android/content/Intent.getIntExtra(Ljava/lang/String;I)I=com/waimai/IntentUtil.getInt(Landroid/content/Intent;Ljava/lang/String;I)I", "android/content/Intent.getStringExtra(Ljava/lang/String;)Ljava/lang/String;=com/waimai/IntentUtil.getString(Landroid/content/Intent;Ljava/lang/String;)Ljava/lang/String;", "android/content/Intent.getBooleanExtra(Ljava/lang/String;Z)Z=com/waimai/IntentUtil.getBoolean(Landroid/content/Intent;Ljava/lang/String;Z)Z", ...) }}

上面的配置就可以將App代碼(包括第三方庫)裡所有的Intent.getXXXExtra調用替換成IntentUtil類中的安全版實現。當然,並不是所有的異常都只需要catch住就萬事大吉,如果真的有邏輯錯誤肯定需要在開發和測試階段及時暴露出來,所以在IntentUtil中會對App的運行環境做判斷,Debug下會將異常直接拋出,開發同學可以根據Crash堆棧分析問題,Release環境下則在捕獲到異常時返回對應的默認值然後將異常上報到服務器。

依賴庫的問題

Android App經常會依賴很多AAR, 每個AAR可能有多個版本,打包時Gradle會根據規則確定使用的最終版本號(默認選擇最高版本或者強制指定的版本),而其他版本的AAR將被丟棄。如果互相依賴的AAR中有不兼容的版本,存在的問題在打包時是不能發現的,只有在相關代碼執行時才會出現,會造成NoClassDefFoundError、NoSuchFieldError、NoSuchMethodError等異常。

如圖所示,order和store兩個業務庫都依賴了platform.aar,一個是1.0版本,一個是2.0版本,默認最終打進APK的只有platform 2.0版本,這時如果order庫裡用到的platform庫裡的某個類或者方法在2.0版本中被刪除了,運行時就可能發生異常,雖然SDK在升級時會盡量做到向下兼容,但很多時候尤其是第三方SDK是沒法得到保證的,在美團外賣Android App v6.0版本時因為這個原因導致熱修復功能喪失,因此為了提前發現問題,我們接入了依賴檢查插件Defensor。

美團外賣Android Crash治理之路

Defensor在編譯時通過DexTask獲取到所有的輸入文件(也就是被編譯過的class文件),然後檢查每個文件裡引用的類、字段、方法等是否存在。

除此之外我們寫了一個Gradle插件SVD(strict version dependencies)來對那些重要的SDK的版本進行統一管理。插件會在編譯時檢查Gradle最終使用的SDK版本是否和配置中的一致,如果不一致插件會終止編譯並報錯,並同時會打印出發生衝突的SDK的所有依賴關係。

Crash的預防實踐

單純的靠約定或規範去減少Crash的發生是不現實的。約定和規範受限於組織架構和具體執行的個人,很容易被忽略,只有靠工程架構和工具才能保證Crash的預防長久的執行下去。

工程架構對Crash率的影響

在治理Crash的實踐中,我們往往忽略了工程架構對Crash率的影響。Crash的發生大部分原因是源於程序員的不合理的代碼,而程序員工作中最直接的接觸的就是工程架構。對於一個邊界模糊,層級混亂的架構,程序員是更加容易寫出引起Crash的代碼。在這樣的架構裡面,即使程序員意識到導致某種寫法存在問題,想要去改善這樣不合理的代碼,也是非常困難的。相反,一個層級清晰,邊界明確的架構,是能夠大大減少Crash發生的概率,治理和預防Crash也是相對更容易。這裡我們可以舉幾個我們實踐過的例子闡述。

業務模塊的劃分

原來我們的Crash基本上都是由個別同學關注解決的,團隊裡的每個同學都會提交可能引起Crash的代碼,如果負責Crash的同學因為某些事情,暫時沒有關注App的Crash率,那麼造成Crash的同學也不會知道他的代碼引起了Crash。

對於這個問題,我們的做法是App的業務模塊化。業務模塊化後,每個業務都有都有唯一包名和對應的負責人。當某個模塊發生了Crash,可以根據包名提交問題給這個模塊的負責人,讓他第一時間進行處理。業務模塊化本身也是工程架構優先需要考慮的事情之一。

頁面跳轉路由統一處理頁面跳轉

對外賣App而言,使用過程中最多的就是頁面間的跳轉,而頁面間跳轉經常會造成ActivityNotFoundException,例如我們配了一個scheme,但對方的scheme路徑已經發生了變化;又例如,我們調用手機上相冊的功能,而相冊應用已被用戶自己禁用或移除了。解決這一類Crash,其實也很簡單,只需要在startActivity增加ActivityNotFoundException異常捕獲即可。但一個App裡,啟動Activity的地方,幾乎是隨處可見,無法預測哪一處會造成ActivityNotFoundException。

我們的做法是將頁面的跳轉,都通過我們封裝的scheme路由去分發。這樣的好處是,通過scheme路由,在工程架構上所有業務都是解耦,模塊間不需要相互依賴就可以實現頁面的跳轉和基本類型參數的傳遞;同時,由於所有的頁面跳轉都會走scheme路由,我們只需要在scheme路由裡一處加上ActivityNotFoundException異常捕獲即可解決這種類型的Crash。路由設計示意圖如下:

美團外賣Android Crash治理之路

網絡層統一處理API髒數據

客戶端的很大一部分的Crash是因為API返回的髒數據。比如當API返回空值、空數組或返回不是約定類型的數據,App收到這些數據,就極有可能發生空指針、數組越界和類型轉換錯誤等Crash。而且這樣的髒數據,特別容易引起線上大面積的崩潰。

最早我們的工程的網絡層用法是:頁面監聽網絡成功和失敗的回調,網絡成功後,將JSON數據傳遞給頁面,頁面解析Model,初始化View,如圖所示。這樣的問題就是,網絡雖然請求成功了,但是JSON解析Model這個過程可能存在問題,例如沒有返回數據或者返回了類型不對的數據,而這個髒數據導致問題會出現在UI層,直接反應給用戶。

美團外賣Android Crash治理之路

根據上圖,我們可以看到由於網絡層只承擔了請求網絡的職責,沒有承擔數據解析的職責,數據解析的職責交給了頁面去處理。這樣使得我們一旦發現髒數據導致的Crash,就只能在網絡請求的回調裡面增加各種判斷去兼容髒數據。我們有幾百個頁面,補漏完全補不過來。通過幾個版本的重構,我們重新劃分了網絡層的職責,如圖所示:

美團外賣Android Crash治理之路

從圖上可以看出,重構後的網絡層負責請求網絡和數據解析,如果存在髒數據的話,在網絡層就會發現問題,不會影響到UI層,返回給UI層的都是校驗成功的數據。這樣改造後,我們發現這類的Crash率有了極大的改善。

大圖監控

上面講到大對象是導致OOM的主要原因之一,而Bitmap是App裡最常見的大對象類型,因此對佔用內存過大的Bitmap對象的監控就很有必要了。

我們用AOP方式Hook了三種常見圖片庫的加載圖片回調方法,同時監控圖片庫加載圖片時的兩個維度:

  1. 加載圖片使用的URL。外賣App中除靜態資源外,所有圖片都要求發佈到專用的圖片CDN服務器上,加載圖片時使用正則表達式匹配URL,除了限定CDN域名之外還要求所有圖片加載時都要添加對應的動態縮放參數。
  2. 最終加載出的圖片結果(也就是Bitmap對象)。我們知道Bitmap對象所佔內存和其分辨率大小成正比,而一般情況下在ImageView上設置超過自身尺寸的圖片是沒有意義的,所以我們要求顯示在ImageView中的Bitmap分辨率不允許超過View自身的尺寸(為了降低誤報率也可以設定一個報警閾值)。

開發過程中,在App裡檢測到不合規的圖片時會立即高亮出錯的ImageView所在的位置並彈出對話框提示ImageView所在的Activity、XPath和加載圖片使用的URL等信息,如下圖,輔助開發同學定位並解決問題。在Release環境下可以將報警信息上報到服務器,實時觀察數據,有問題及時處理。

美團外賣Android Crash治理之路

Lint檢查

我們發現線上的很多Crash其實可以在開發過程中通過Lint檢查來避免。Lint是Google提供的Android靜態代碼檢查工具,可以掃描並發現代碼中潛在的問題,提醒開發人員及早修正,提高代碼質量。

Lint檢查可以在多個階段執行,包括在本地手動檢查、編碼實時檢查、編譯時檢查、commit時檢查,以及在CI系統中提Pull Request時檢查、打包時檢查等,如下圖所示。更詳細的內容可參考《美團外賣Android Lint代碼檢查實踐》。

美團外賣Android Crash治理之路

資源重複檢查

在之前的文章《美團外賣Android平臺化架構演進實踐》中講述了我們的平臺化演進過程,在這個過程中大家很大的一部分工作是下沉,但是下沉不完全就會導致一些類和資源的重複,類因為有包名的限制不會出現問題。但是一些資源文件如layout、drawable等如果同名則下層會被上層覆蓋,這時layout裡view的id發生了變化就可能導致空指針的問題。

為了避免這種問題,我們寫了一個Gradle插件通過hook MergeResource這個Task,拿到所有library和主庫的資源文件,如果檢查到重複則會中斷編譯過程,輸出重複的資源名及對應的library name,同時避免有些資源因為樣式等原因確實需要覆蓋,因此我們設置了白名單。同時在這個過程中我們也拿到了所有的的圖片資源,可以順手做圖片大小的本地監控,如下圖所示:

美團外賣Android Crash治理之路

Crash的監控&止損的實踐

監控

在經過前面提到的各種檢查和測試之後,應用便開始發佈了。我們建立了如下圖的監控流程,來保證異常發生時能夠及時得到反饋並處理。首先是灰度監控,灰度階段是增量Crash最容易暴露的階段,如果這個階段沒有很好的把握住,會使得增量變存量,從而導致Crash率上升。

如果條件允許的話,可以在灰度期間制定一些灰度策略去提高這個階段Crash的暴露。例如分渠道灰度、分城市灰度、分業務場景灰度、新裝用戶的灰度等等,儘量覆蓋所有的分支。灰度結束之後便開始全量,在全量的過程中我們還需要一些日常Crash監控和Crash率的異常報警來防止突發情況的發生,例如因為後臺上線或者運營配置錯誤導致的線上Crash。除此之外還需要一些其他的監控,例如,之前提到的大圖監控,來避免因為大圖導致的OOM。具體的輸出形式主要有郵件通知、IM通知、報表。

美團外賣Android Crash治理之路

止損

儘管我們在前面做了那麼多,但是Crash還是無法避免的,例如,在灰度階段因為量級不夠,有些Crash沒有被暴露出來;又或者某些功能客戶端比後臺更早上線,而這些功能在灰度階段沒有被覆蓋到;這些情況下,如果出現問題就需要考慮如何止損了。

問題發生時首先需要評估重要性,如果問題不是很嚴重而且修復成本較高可以考慮在下個版本再修復,相反如果問題比較嚴重,對用戶體驗或下單有影響時就必須要修復。修復時首先考慮業務降級,主要看該部分異常的業務是否有兜底或者A/B策略,這樣是最穩妥也是最有效的方式。

如果業務不能降級就需要考慮熱修復了,目前美團外賣Android App接入的熱修復框架是自研的Robust,可以修復90%以上的場景,熱修成功率也達到了99%以上。如果問題發生在熱修復無法覆蓋的場景,就只能強制用戶升級。強制升級因為覆蓋週期長,同時影響用戶的體驗,只在萬不得已的情況下才會使用。

展望

Crash的自我修復

我們在做新技術選型時除了要考慮是否能滿足業務需求、是否比現有技術更優秀和團隊學習成本等因素之外,兼容性和穩定性也非常重要。但面對國內非富多彩的Android系統環境,在體量百萬級以上的的App中幾乎不可能實現毫無瑕疵的技術方案和組件,所以一般情況下如果某個技術實現方案可以達到0.01‰以下的崩潰率,而其他方案也沒有更好的表現,我們就認為它是可以接受的。但是哪怕僅僅十萬分之一的崩潰率,也代表還有用戶受到影響,而我們認為Crash對用戶來說是最糟糕的體驗,尤其是涉及到交易的場景,所以我們必須本著每一單都很重要的原則,盡最大努力保證用戶順利執行流程。

實際情況中有一些技術方案在兼容性和穩定性上做了一定妥協的場景,往往是因為考慮到性能或擴展性等方面的優勢。這種情況下我們其實可以再多做一些,進一步提高App的可用性。就像很多操作系統都有“兼容模式”或者“安全模式”,很多自動化機械機器都配套有手動操作模式一樣,App裡也可以實現備用的降級方案,然後設置特定條件的觸發策略,從而達到自動修復Crash的目的。

舉例來講,Android 3.0中引入了硬件加速機制,雖然可以提高繪製幀率並且降低CPU佔用率,但是在某些機型上還是會有繪製錯亂甚至Crash的情況,這時我們就可以在App中記錄硬件加速相關的Crash問題或者使用檢測代碼主動檢測硬件加速功能是否正常工作,然後主動選擇是否開啟硬件加速,這樣既可以讓絕大部分用戶享受硬件加速帶來的優勢,也可以保障硬件加速功能不完善的機型不受影響。

還有一些類似的可以做自動降級的場景,比如:

  • 部分使用JNI實現的模塊,在SO加載失敗或者運行時發生異常則可以降級為Java版實現。
  • RenderScript實現的圖片模糊效果,也可以在失敗後降級為普通的Java版高斯模糊算法。
  • 在使用Retrofit網絡庫時發現OkHttp3或者HttpURLConnection網絡通道失敗率高,可以主動切換到另一種通道。

這類問題都需要根據具體情況具體分析,如果可以找到準確的判定條件和穩定的修復方案,就可以讓App穩定性再上一個臺階。

特定Crash類型日誌自動回撈

外賣業務發展迅速,即使我們在開發時使用各種工具、措施來避免Crash的發生,但Crash還是不可避免。線上某些怪異的Crash發生後,我們除了分析Crash堆棧信息之外,還可以使用離線日誌回撈、下發動態日誌等工具來還原Crash發生時的場景,幫助開發同學定位問題,但是這兩種方式都有它們各自的問題。

離線日誌顧名思義,它的內容都是預先記錄好的,有時候可能會漏掉一些關鍵信息,因為在代碼中加日誌一般只是在業務關鍵點,在大量的普通方法中不可能都加上日誌。動態日誌(Holmes)存在的問題是每次下發只能針對已知UUID的一個用戶的一臺設備,對於大量線上Crash的情況這種操作並不合適,因為我們並不能知道哪個發生Crash的用戶還會再次復現這次操作,下發配置充滿了不確定性。

我們可以改造Holmes使其支持批量甚至全量下發動態日誌,記錄的日誌等到發生特定類型的Crash時才上報,這樣一來可以減少日誌服務器壓力,同時也可以極大提高定位問題的效率,因為我們可以確定上報日誌的設備最後都真正發生了該類型Crash,再來分析日誌就可以做到事半功倍。

總結

業務的快速發展,往往不可能給團隊充足的時間去治理Crash,而Crash又是App最重要的指標之一。團隊需要由一個個Crash個例,去探究每一個Crash發生的最本質原因,找到最合理解決這類Crash的方案,建立解決這一類Crash的長效機制,而不能飲鴆止渴。只有這樣,隨著版本的不斷迭代,我們才能在Crash治理之路上離目標越來越近。

  1. Crash率從2.2%降至0.2%,這個團隊是怎麼做到的?
  2. Android運行時ART加載OAT文件的過程分析
  3. Android動態日誌系統Holmes
  4. Android Hook技術防範漫談
  5. 美團外賣Android Lint代碼檢查實踐

維康,美團高級工程師,2016年校招加入美團,目前作為外賣Android App主力開發,主要負責App Crash治理和集成構建相關工作。

少傑,美團高級工程師,2017年加入美團,目前作為外賣Android App技術負責人,主要負責App監控相關工作。

曉飛,美團技術專家,2015年加入美團,是外賣Android的早期開發者之一,目前作為外賣Android App負責人,主要負責版本管理和業務架構。

招聘信息

美團外賣誠招Android、iOS、FE高級/資深工程師和技術專家,Base北京、上海、成都,歡迎有興趣的同學投遞簡歷到[email protected]

美團外賣Android Crash治理之路


分享到:


相關文章: