美團外賣Android Crash治理之路

美團技術團隊發表於2019-03-03

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檔案,之後通過dex2jarjd-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靜態程式碼檢查工具,可以掃描並發現程式碼中潛在的問題,提醒開發人員及早修正,提高程式碼質量。

但是Android原生提供的Lint規則(如是否使用了高版本API)遠遠不夠,缺少一些我們認為有必要的檢測,也不能檢查程式碼規範。因此我們開始開發自定義Lint,目前我們通過自定義Lint規則已經實現了Crash預防、Bug預防、提升效能/安全和程式碼規範檢查這些功能。如檢查實現了Serializable介面的類,其成員變數(包括從父類繼承的)所宣告的型別都要實現Serializable介面,可以有效的避免NotSerializableException;強制使用封裝好的工具類如ColorUtil、WindowUtil等可以有效的避免因為引數不正確產生的IllegalArgumentException和因為Activity已經finish導致的BadTokenException。

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北京、上海、成都,歡迎有興趣的同學投遞簡歷到wukai05@meituan.com。如果對我們團隊感興趣,可以關注我們的專欄

美團外賣Android Crash治理之路

相關文章