Android高手筆記 - 開篇 & 崩潰優(yōu)化

開篇-焦慮的移動開發(fā)者如何破局

移動互聯(lián)網(wǎng)的發(fā)展不知不覺已經(jīng)十多年了,Mobile First 也已經(jīng)變成了 AI First。換句話說,我們已經(jīng)不再是“風口上的豬”。
可以說,國內(nèi)移動互聯(lián)網(wǎng)的紅利期已經(jīng)過去了,現(xiàn)在是增量下降、存量廝殺,從爭奪用戶到爭奪時長。
移動端的招聘量變少,但中高端的職位卻多了起來,這說明行業(yè)只是變得成熟規(guī)范起來了。
競爭激烈,但產(chǎn)品質量與留存變得更加重要,我們進入了技術賦能業(yè)務的時代。
不要把時間浪費在糾結問題上,而是應該放在解決問題上。

  • 一個應用至少會經(jīng)過開發(fā)、編譯 CI、測試、灰度和發(fā)布這幾個階段;
  • Android 綠色聯(lián)盟開發(fā)者大會上推出的應用體驗標準,有對應用的兼容性、穩(wěn)定性、性能、功能和安全做了詳細的定義;
  • 我們很多時候都在用戰(zhàn)術的勤奮掩蓋戰(zhàn)略的懶惰,性能優(yōu)化的關鍵在于如何解決存量問題,同時快速發(fā)現(xiàn)增量問題;

下面開始高質量開發(fā)篇

崩潰優(yōu)化(上)

崩潰率是衡量一個應用質量高低的基本指標;

  1. Android 崩潰分為 Java 崩潰和 Native 崩潰:

    1. Java 崩潰就是在 Java 代碼中,出現(xiàn)了未捕獲異常,導致程序異常退出;
    2. Native 崩潰又是怎么產(chǎn)生的呢?一般都是因為在 Native 代碼中訪問非法地址,
      也可能是地址對齊出現(xiàn)了問題,或者發(fā)生了程序主動 abort,這些都會產(chǎn)生相應的 signal 信號,導致程序異常退出。
  2. Native崩潰的捕獲流程

  • 可以參考:

Android平臺Native代碼的崩潰捕獲機制及實現(xiàn)

  • 完整的 Native 崩潰從捕獲到解析的流程:
    1. 編譯端。編譯 C/C++ 代碼時,需要將帶符號信息的文件保留下來
    2. 客戶端。捕獲到崩潰時候,將收集到盡可能多的有用信息寫入日志文件,然后選擇合適的時機上傳到服務器。
    3. 服務端。讀取客戶端上報的日志文件,尋找適合的符號文件,生成可讀的 C/C++ 調用棧。
  1. Native 崩潰捕獲的難點

上面的三個流程中,最核心的是怎么樣保證客戶端在各種極端情況下依然可以生成崩潰日志。
因為在崩潰時,程序會處于一個不安全的狀態(tài),如果處理不當,非常容易發(fā)生二次崩潰。
那么,生成崩潰日志時會有哪些比較棘手的情況呢?

情況一:文件句柄泄漏,導致創(chuàng)建日志文件失敗,怎么辦?
應對方式:我們需要提前申請文件句柄 fd 預留,防止出現(xiàn)這種情況。

情況二:因為棧溢出了,導致日志生成失敗,怎么辦?
應對方式:為了防止棧溢出導致進程沒有空間創(chuàng)建調用棧執(zhí)行處理函數(shù),我們通常會使用常見的 signalstack。
在一些特殊情況,我們可能還需要直接替換當前棧,所以這里也需要在堆中預留部分空間。

情況三:整個堆的內(nèi)存都耗盡了,導致日志生成失敗,怎么辦?
應對方式:這個時候我們無法安全地分配內(nèi)存,也不敢使用 stl 或者 libc 的函數(shù),因為它們內(nèi)部實現(xiàn)會分配堆內(nèi)存。
這個時候如果繼續(xù)分配內(nèi)存,會導致出現(xiàn)堆破壞或者二次崩潰的情況。Breakpad 做的比較徹底,重新封裝了
Linux Syscall Support,來避免直接調用 libc。

情況四:堆破壞或二次崩潰導致日志生成失敗,怎么辦?
應對方式:Breakpad 會從原進程 fork 出子進程去收集崩潰現(xiàn)場,此外涉及與 Java 相關的,一般也會用子進程去操作。
這樣即使出現(xiàn)二次崩潰,只是這部分的信息丟失,我們的父進程后面還可以繼續(xù)獲取其他的信息。在一些特殊的情況,
我們還可能需要從子進程 fork 出孫進程。
  1. 選擇合適的崩潰服務

對于很多中小型公司來說,并不建議自己去實現(xiàn)一套如此復雜的系統(tǒng),可以選擇一些第三方的服務。
目前各種平臺也是百花齊放,包括騰訊的Bugly、阿里的啄木鳥平臺、網(wǎng)易云捕、Google 的 Firebase 等等

  1. 如何客觀地衡量崩潰

要衡量一個指標,首先要統(tǒng)一計算口徑。如果想評估崩潰造成的用戶影響范圍,我們會先去看 UV 崩潰率。
UV 崩潰率 = 發(fā)生崩潰的 UV / 登錄 UV
我們還可以去看應用 PV 崩潰率、啟動崩潰率、重復崩潰率這些指標,計算方法都大同小異。

這里為什么要單獨統(tǒng)計啟動崩潰率呢?因為啟動崩潰對用戶帶來的傷害最大,應用無法啟動往往通過熱修復也無法拯救。
閃屏廣告、運營活動,很多應用啟動過程異常復雜,又涉及各種資源、配置下發(fā),極其容易出現(xiàn)問題。
微信讀書、蘑菇街、淘寶、天貓這些“重運營”的應用都有使用一種叫作“安全模式”的技術來保障客戶端的啟動流程,
在監(jiān)控到客戶端啟動失敗后,給用戶自救的機會。

安全模式:天貓App啟動保護實踐
不要寫死!天貓App的動態(tài)化配置中心實踐
天貓App A/B測試實踐

  1. 如何客觀地衡量穩(wěn)定性

崩潰率是不是就能完全等價于應用的穩(wěn)定性呢?答案是肯定不行。處理了崩潰,我們還會經(jīng)常遇到 ANR。
怎么去發(fā)現(xiàn)應用中的 ANR 異常呢?

1. 使用 FileObserver 監(jiān)聽 /data/anr/traces.txt 的變化;

很多高版本的 ROM,已經(jīng)沒有讀取這個文件的權限了,只能思考其他路徑,海外可以使用 Google Play 服務,
而國內(nèi)微信利用Hardcoder框架向廠商獲取了更大的權限。

2. 監(jiān)控消息隊列的運行時間;
這個方案無法準確地判斷是否真正出現(xiàn)了 ANR 異常,也無法得到完整的 ANR 日志, 在我看來,更應該放到卡頓的性能范疇;

都有哪些應用退出的情形?
1. 主動自殺:Process.killProcess()、exit() 等。
2. 崩潰:出現(xiàn)了 Java 或 Native 崩潰。
3. 系統(tǒng)重啟: 系統(tǒng)出現(xiàn)異常、斷電、用戶主動重啟等,我們可以通過比較應用開機運行時間是否比之前記錄的值更小。
4. 被系統(tǒng)殺死: 被 low memory killer 殺掉、從系統(tǒng)的任務管理器中劃掉等。
5. ANR。

我們可以在應用啟動的時候設定一個標志,在主動自殺或崩潰后更新標志,
這樣下次啟動時通過檢測這個標志就能確認運行期間是否發(fā)生過異常退出。
對應上面的五種退出場景,我們排除掉主動自殺和崩潰(崩潰會單獨的統(tǒng)計)這兩種場景,
希望可以監(jiān)控到剩下三種的異常退出,理論上這個異常捕獲機制是可以達到 100% 覆蓋的。
所以就得到了一個新的指標來衡量應用的穩(wěn)定性,即異常率。
UV 異常率 = 發(fā)生異常退出或崩潰的 UV / 登錄 UV
根據(jù)應用的前后臺狀態(tài),我們可以把異常退出分為前臺異常退出和后臺異常退出;
通過異常率我們可以比較全面的評估應用的穩(wěn)定性,對于線上監(jiān)控還需要完善崩潰的報警機制。

關于Hardcoder: 你信不信,這篇只有手機廠商能看懂

  1. 課后作業(yè): 使用 Breakpad 來捕獲一個 Native 崩潰,通過這個simple可以深入學習一下Breakpad如何不會 Native 崩潰

崩潰優(yōu)化(下)

  • 解決崩潰跟破案一樣需要經(jīng)驗,我們分析的問題越多越熟練,定位問題就會越快越準;
  • 崩潰現(xiàn)場是我們的“第一案發(fā)現(xiàn)場”,操作系統(tǒng)是整個崩潰過程的"最佳目擊證人"

崩潰現(xiàn)場應采集哪些信息

1. 崩潰信息:
  • 進程名、線程名:

崩潰的進程是前臺進程還是后臺進程,崩潰是不是發(fā)生在 UI 線程。

  • 崩潰堆棧和類型:

屬于 Java 崩潰、Native 崩潰,還是 ANR

2. 系統(tǒng)信息
  • Logcat: 這里包括應用、系統(tǒng)的運行日志
  • 機型、系統(tǒng)、廠商、CPU、ABI、Linux 版本等
  • 設備狀態(tài):是否root、是否是模擬器
3. 內(nèi)存信息

OOM、ANR、虛擬內(nèi)存耗盡等,很多崩潰都跟內(nèi)存有直接關系

  • 系統(tǒng)剩余內(nèi)存:

當系統(tǒng)可用內(nèi)存很?。ǖ陀?MemTotal 的 10%)時,OOM、大量 GC、系統(tǒng)頻繁自殺拉起等問題都非常容易出現(xiàn)

  • 應用使用內(nèi)存:

包括 Java 內(nèi)存、RSS(Resident Set Size)、PSS(Proportional Set Size),我們可以得出應用本身內(nèi)存的占用大小和分布

  • 虛擬內(nèi)存:

可以通過 /proc/self/status 得到,通過 /proc/self/maps 文件可以得到具體的分布情況,有時候我們一般不太重視虛擬內(nèi)存,但是很多類似 OOM、tgkill 等問題都是虛擬內(nèi)存不足導致的

4. 資源信息

有的時候我們會發(fā)現(xiàn)應用堆內(nèi)存和設備內(nèi)存都非常充足,還是會出現(xiàn)內(nèi)存分配失敗的情況,這跟資源泄漏可能有比較大的關系

  • 文件句柄 fd:

文件句柄的限制可以通過 /proc/self/limits 獲得,一般單個進程允許打開的最大文件句柄個數(shù)為 1024。
但是如果文件句柄超過 800 個就比較危險,需要將所有的 fd 以及對應的文件名輸出到日志中,進一步排查是否出現(xiàn)了有文件或者線程的泄漏

  • 線程數(shù):

當前線程數(shù)大小可以通過上面的 status 文件得到,一個線程可能就占 2MB 的虛擬內(nèi)存,過多的線程會對虛擬內(nèi)存和文件句柄帶來壓力。
根據(jù)我的經(jīng)驗來說,如果線程數(shù)超過 400 個就比較危險。需要將所有的線程 id 以及對應的線程名輸出到日志中,進一步排查是否出現(xiàn)了線程相關的問題。

  • JNI:

使用 JNI 時,如果不注意很容易出現(xiàn)引用失效、引用爆表等一些崩潰。我們可以通過 DumpReferenceTables 統(tǒng)計 JNI 的引用表,進一步分析是否出現(xiàn)了 JNI 泄漏等問題。

5. 應用信息

除了系統(tǒng),其實我們的應用更懂自己,可以留下很多相關的信息

  • 崩潰場景

崩潰發(fā)生在哪個 Activity 或 Fragment,發(fā)生在哪個業(yè)務中

  • 關鍵操作路徑

不同于開發(fā)過程詳細的打點日志,我們可以記錄關鍵的用戶操作路徑,這對我們復現(xiàn)崩潰會有比較大的幫助

  • 其他自定義信息

不同的應用關心的重點可能不太一樣,比如網(wǎng)易云音樂會關注當前播放的音樂,QQ 瀏覽器會關注當前打開的網(wǎng)址或視頻。此外例如運行時間、是否加載了補丁、是否是全新安裝或升級等信息也非常重要。

6. 其他信息

除了上面這些通用的信息外,針對特定的一些崩潰,我們可能還需要獲取類似磁盤空間、電量、網(wǎng)絡使用等特定信息。所以說一個好的崩潰捕獲工具,會根據(jù)場景為我們采集足夠多的信息,讓我們有更多的線索去分析和定位問題。當然數(shù)據(jù)的采集需要注意用戶隱私,做到足夠強度的加密和脫敏。

崩潰分析 三部曲

第一步:確定重點

確認和分析重點,關鍵在于在日志中找到重要的信息,對問題有一個大致判斷。一般來說,我建議在確定重點這一步可以關注以下幾點。

1. 確認嚴重程度

解決崩潰也要看性價比,我們優(yōu)先解決 Top 崩潰或者對業(yè)務有重大影響,例如啟動、支付過程的崩潰。

2. 崩潰基本信息

確定崩潰的類型以及異常描述,對崩潰有大致的判斷。

  1. Java 崩潰類型比較明顯
  2. Native 崩潰:
    需要觀察 signal、code、fault addr 等內(nèi)容,以及崩潰時 Java 的堆棧;
  3. ANR:
    先看看主線程的堆棧,是否是因為鎖等待導致。接著看看 ANR 日志中 iowait、CPU、GC、system server 等信息,進一步確定是 I/O 問題,或是 CPU 競爭問題,還是由于大量 GC 導致卡死。
3. Logcat

Logcat 一般會存在一些有價值的線索,日志級別是 Warning、Error 的需要特別注意。從 Logcat 中我們可以看到當時系統(tǒng)的一些行為跟手機的狀態(tài),例如出現(xiàn) ANR 時,會有“am_anr”;App 被殺時,會有“am_kill”。

4. 各個資源情況

結合崩潰的基本信息,我們接著看看是不是跟 “內(nèi)存信息” 有關,是不是跟“資源信息”有關。比如是物理內(nèi)存不足、虛擬內(nèi)存不足,還是文件句柄 fd 泄漏了。

第二步:查找共性

如果使用了上面的方法還是不能有效定位問題,我們可以嘗試查找這類崩潰有沒有什么共性。找到了共性,也就可以進一步找到差異,離解決問題也就更進一步
機型、系統(tǒng)、ROM、廠商、ABI,這些采集到的系統(tǒng)信息都可以作為維度聚合,找到了共性,可以對你下一步復現(xiàn)問題有更明確的指引。

第三步:嘗試復現(xiàn)

“只要能本地復現(xiàn),我就能解”,相信這是很多開發(fā)跟測試說過的話。有這樣的底氣主要是因為在穩(wěn)定的復現(xiàn)路徑上面,我們可以采用增加日志或使用 Debugger、GDB 等各種各樣的手段或工具做進一步分析。

疑難問題:系統(tǒng)崩潰 的解決思路

1. 查找可能的原因。

通過上面的共性歸類,我們先看看是某個系統(tǒng)版本的問題,還是某個廠商特定 ROM 的問題。雖然崩潰日志可能沒有我們自己的代碼,但通過操作路徑和日志,我們可以找到一些懷疑的點。

2. 嘗試規(guī)避。

查看可疑的代碼調用,是否使用了不恰當?shù)?API,是否可以更換其他的實現(xiàn)方式規(guī)避。

3. Hook 解決。

這里分為 Java Hook 和 Native Hook。以我最近解決的一個系統(tǒng)崩潰為例,我們發(fā)現(xiàn)線上出現(xiàn)一個 Toast 相關的系統(tǒng)崩潰,
它只出現(xiàn)在 Android 7.0 的系統(tǒng)中,看起來是在 Toast 顯示的時候窗口的 token 已經(jīng)無效了。這有可能出現(xiàn)在 Toast
需要顯示時,窗口已經(jīng)銷毀了。


android.view.WindowManager$BadTokenException:
 at android.view.ViewRootImpl.setView(ViewRootImpl.java)
 at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java)
 at android.view.WindowManagerImpl.addView(WindowManagerImpl.java4)
 at android.widget.Toast$TN.handleShow(Toast.java)

為什么 Android 8.0 的系統(tǒng)不會有這個問題?在查看 Android 8.0 的源碼后我們發(fā)現(xiàn)有以下修改:

try {
 mWM.addView(mView, mParams);
 trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
 /* ignore */
}

考慮再三,我們決定參考 Android 8.0 的做法,直接 catch 住這個異常。這里的關鍵在于尋找 Hook 點,這個案例算是相對比較簡單的。
Toast 里面有一個變量叫 mTN,它的類型為 handler,我們只需要代理它就可以實現(xiàn)捕獲。

崩潰攻防是一個長期的過程,我們希望盡可能地提前預防崩潰的發(fā)生,將它消滅在萌芽階段。這可能涉及我們應用的整個流程,包括人員的培訓、編譯檢查、靜態(tài)掃描工作,還有規(guī)范的測試、灰度、發(fā)布流程等。

獲得logcat和Jave堆棧的方法:

一. 獲取logcat

logcat日志流程是這樣的,應用層 --> liblog.so --> logd,底層使用ring buffer來存儲數(shù)據(jù),獲取的方式有以下三種:

1. 通過logcat命令獲取
  • 優(yōu)點:非常簡單,兼容性好。
  • 缺點:整個鏈路比較長,可控性差,失敗率高,特別是堆破壞或者堆內(nèi)存不足時,基本會失敗。
2. hook liblog.so實現(xiàn)

通過hook liblog.so 中__android_log_buf_write 方法,將內(nèi)容重定向到自己的buffer中。

  • 優(yōu)點:簡單,兼容性相對還好。
  • 缺點:要一直打開。
3. 自定義獲取代碼
  • 通過移植底層獲取logcat的實現(xiàn),通過socket直接跟logd交互。
  • 優(yōu)點:比較靈活,預先分配好資源,成功率也比較高。
    缺點:實現(xiàn)非常復雜

二. 獲取Java 堆棧

native崩潰時,通過unwind只能拿到Native堆棧。我們希望可以拿到當時各個線程的Java堆棧

1. Thread.getAllStackTraces()。
  • 優(yōu)點:簡單,兼容性好。
  • 缺點:
    a. 成功率不高,依靠系統(tǒng)接口在極端情況也會失敗。
    b. 7.0之后這個接口是沒有主線程堆棧。
    c. 使用Java層的接口需要暫停線程
2. hook libart.so

通過hook ThreadList和Thread的函數(shù),獲得跟ANR一樣的堆棧。為了穩(wěn)定性,我們會在fork子進程執(zhí)行。

  • 優(yōu)點:信息很全,基本跟ANR的日志一樣,有native線程狀態(tài),鎖信息等等。
  • 缺點:黑科技的兼容性問題,失敗時可以用Thread.getAllStackTraces()兜底

獲取Java堆棧的方法還可以用在卡頓時,因為使用fork進程,所以可以做到完全不卡主進程。這塊我們在后面會詳細的去講。

課后練習

一種“完全解決”TimeoutException 的方法 https://github.com/AndroidAdvanceWithGeektime/Chapter02

我是今陽,如果想要進階和了解更多的干貨,歡迎關注公眾號”今陽說“接收我的最新文章

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容