Android 調試實戰與原理詳解

語言: CN / TW / HK

圖片來自:https://101.dev/
本文作者: 雪谷

前言

調試功能做為開發的必備神技,熟練掌握後能極大的提高開發效率,再也不必為頻繁運行代碼而苦惱了。文章同時還會詳細介紹調試的原理以及一些調試過程中的常見問題,想知道為什麼方法斷點那麼慢?

接下來將從以下四個方面來講解調試是如何運作的: 1. 調試操作 2. 調試實戰 3. 調試原理 4. 常見問題

調試簡介

這裏我們介紹一下調試的常見操作,靈活掌握這些操作,可以幫助我們快速定位到對應代碼或者獲取想要的信息。

運行調試

開啟 Debug 調試模式有兩種方式:

運行調試

Debug Run:直接以 Debug 模式運行 APP,該模式的優點是可以調試程序啟動相關的代碼, 例如 Application.onCreate()

Attach To Process:在程序運行中選擇進程來調試,該模式的優點是隨時可開啟、關閉 Debug 模式,使用靈活方便。

注意:Debug Run 會導致程序整體變慢,建議使用等待調試,使用該方式可以在啟動應用後處於等待狀態,在開啟調試後,應用才會走初始化流程,有兩種方式開啟等待斷點:
  方法1:「開發者選項 - 選擇調試應用」的方式來調試應用啟動階段代碼。具體方式為「選擇調試應用」-> 「運行應用」-> 「Attach To Process」,然後等待斷點執行即可。
  方法2:使用adb命令adb shell am set-debug-app -w --persistent 包名開啟,「-w」即表示應用啟動時等待調試程序;關閉使用adb shell am clear-debug-app

調試操作

下面介紹一下 Debug 過程中的常見操作:

斷點操作

  1. Show Execution Point:跳到當前執行的斷點處。
  2. Step Over:單步執行,執行到當前行的下一行。
  3. Step Into:進入正在執行的方法。
  4. Focus Step Into:同3,但是可以進入源碼,在3無法進入的情況下,可以嘗試該操作。
  5. Step Out:跳出正在執行的方法。
  6. Drop Frame:返回到當前方法的調用處。
  7. Run to Cursor:運行到光標處(光標必須在當前斷點位置後)。
  8. Evaluate expression:計算選中的變量的值。

斷點類型

斷點類型

斷點分為以下四種類型:
行斷點: 當執行到此行是停止執行,等待調試。
屬性斷點:打在類的成員變量上,當變量初始化或變量的值改變時觸發斷點。
異常斷點:當拋出指定異常時觸發斷點。
方法斷點:當需要知道一個方法的調用方時。

這裏着重講一下方法斷點的使用場景:
如下所示,有個接口 IMethodTest,同時有兩個類 MethodTestImpl1MethodTestImpl2 實現了該接口,在 IMethodTestprintMethod() 上打上方法斷點。

IMethodTest

在代碼中實例化了 MethodTestImpl2 來調用 printMethod()

MethodTestImpl2

最後當 Debug 到該方法斷點時,會自動走到 MethodTestImpl2printMethod() 的實現中。

MethodTestImpl2

注:方法斷點只支持 Java 代碼。

調試實戰

大家都知道調試是提高開發效率的利器,那麼它是如何幫助開發者的呢?
答案就是「查看信息」和「減少編譯次數」。

查看信息

當程序運行結果並不如你的預期時,通過調試來查看當前內存裏的變量以及堆棧信息,是最快速定位問題的方式。

查看局部變量的方式如下圖所示

查看局部變量

系統自動打印:在當前調試位置之前的代碼右側會自動打印當前棧幀裏保存的變量值。
鼠標懸停:鼠標懸停在一個變量上幾秒後,會列出該變量的詳細信息。
Variables 區:在 Variables 區裏會自動打印當前方法裏的變量詳細信息。

查看全局變量有兩種方式

查看全局變量

在 Variables 區添加監聽:點擊左側操作欄裏的「+」,輸入對應變量值,即可實時觀察該值的變化。

查看全局變量

在 Evaluate Expression 中輸入想要觀察的變量,回車後即可查看當前時刻該變量的值。

注:查看局部變量和全局變量需要斷點位置能訪問到該值。

查看堆棧信息

在調試頁面的「Debugger」Tab下可以查看當前的調用堆棧。

查看堆棧

需要注意的是,一個線程只會被一個斷點阻塞,但是不同線程是可以同時阻塞的,可以切換下拉框來切換線程,紅色圓點表示正在被阻塞的線程

線程

減少編譯次數

越大的項目運行起來越是緩慢,而有時我們只是修改了一行代碼甚至是一個字符,這時再去重新編譯是效率非常低下的,而靈活運用各種調試技巧,就可以幫助我們在不重新運行項目的前提下,去修改運行中代碼。

編譯驗證

運行期代碼植入

想修改已經運行起來的代碼,有兩種方式:

在 Variables 區中使用 setValue。

setValue

使用 Evaluate Expression。

Evaluate Expression

Evaluate Expression 是一個非常強大的功能,可以展開執行任意的代碼段。靈活運用可以大量的減少編譯次數,例如:
修改網絡請求、外部跳轉等來源的數據,模擬各種場景。
執行某些代碼,直接查看結果。
* 執行某一段異常代碼,直接查看報錯信息。

日誌斷點

日誌是輔助開發排查問題的常見手段,但是在代碼中添加日誌存在一些不便的情況,例如:
需要重新運行程序。
開發完成之後需要去除對應的日誌代碼。
而使用日誌斷點就可以避免以上問題,使用方式為在斷點位置右鍵,取消 Suspend 框的勾選,同時勾選 Evaluate and Log 並輸入想要的內容。

日誌斷點

條件斷點

當一個斷點會被多次執行,而調試時只需求在某些特定條件下才掛起,可以使用條件斷點。使用方式為在斷點位置右鍵,在 Condition 框中輸入條件表達式,回車,這時斷點右下角出現一個「?」即為條件斷點成功掛載。
注意,條件斷點的表達式返回值必須為 true 或者 false,否則斷點報錯。

條件斷點

異常斷點

當開發者知道接下來一定會報某一個異常,但是又不知道會是哪段代碼觸發時,可以嘗試使用異常斷點。使用方式為在斷點管理界面點擊「+」,添加 Java Exception Breakpoints。

異常斷點

然後輸入你想要捕獲的異常,注意,這裏也會捕獲系統拋出的異常,捕獲時請仔細觀察。

異常斷點

多線程斷點

多線程是日常開發中常見的問題,針對一系列線程切換場景,調試工具也有對應的方式來輔助我們定位問題。

這裏請先思考一下這個示例,在不開啟斷點的情況下,下圖的代碼執行後會輸出什麼信息?

多線程斷點

答案就是「無法確定」。
沒錯,在 CPU 的時間片執行機制下,如果不加以控制,開發者是無法預估線程執行順序的。而直接寫一系列的線程控制代碼耗時不小,有沒有辦法能先讓線程按照開發者想要的順序去執行呢?請繼續往下看:

在斷點位置上右鍵,出來的管理界面裏有 All 和 Thread 兩個選項:
All 表示阻塞所有線程,即所有線程都走到當前斷點位置後,才能繼續往下走。
Thread 表示阻塞當前線程,即當前線程的代碼走完後,才會走其他線程。

多線程斷點

所以結合上面的示例:
All 選項的輸出結果為:所有線程先執行完 start,再執行 end,但是哪個線程先執行無法確定。

All

Thread 選項的輸出結果為:一個線程先執行完 start,再執行 end,然後是另外一個線程,但是哪個線程先執行無法確定。

Thread

調試Release包

調試Relase包偏Android逆向,由於篇幅有限,這裏主要介紹和調試相關內容,前期準備可以看這裏DebugApkSmali

在反編譯 APK,Smali 文件生成後,我們需要把手機和 Android Studio 關聯上,這裏需要使用 Remote 功能,具體流程如下:

選擇 Edit Configurations。

Edit Configurations

新增 Remote JVM Debug,Name 隨意,Port 不與現有端口衝突即可。

Remote JVM Debug

查看需要調試的頁面位於哪個進程,先通過adb shell dumpsys activity top | grep ACTIVITY查看棧頂頁面(這裏調試的是知乎),然後在 AndroidManifest.xml 中查看對應 Activity 的 android:process(沒有該屬性的話就看 application 的 process)。

activity

通過adb shell ps | grep com.zhihu.android查看該進程對應的 PID,根據下圖可以得到對應的 PID 為16282。

pid

最後通過adb forward tcp:5005 jdwp:16282連接上手機和 Android Studio,就可以開始愉快的調試。

通過上面的介紹,我們瞭解了調試 Release 包的方式,但是大家有沒有一種雨裏霧裏的感覺呢,為什麼知道了端口就可以關聯上?tcp 和 jdwp 又是什麼意思?他們之前又是怎麼傳輸數據的呢?帶着這些疑問,我們一起來看下調試原理。

調試原理

假如用簡單的一句話來解釋調試原理,可以概括為「通過ADB協議以及JDWP協議來實現調試器與虛擬機之間的通信」,如下圖所示,調試的過程,其實就是通信的過程,理解了如何通信以及傳遞了那些信息,就明白了調試的核心原理。後續內容請都參考該圖來理解。

調試原理

ADB 架構

首先需要了解的是 ADB 架構,其中包含了三個部分:ADB Server、ADB Client 以及 ADB Dameon。

ADB Server

運行在電腦上的進程名為 adb 的後台進程,端口號5037,作用是管理 ADB Client 與 ADB Dameon 進程的通信。如下圖所示,通過 adb device (任意 adb 命令均可)命令可以從常駐的後台進程 adb 上 fork 一個子進程用於當前的通信。通過命令查看相關進程可以發現會有三個:
Android Studio 進程連接 adb 進程的通信。
adb 進程連接 Android Studio 進程的通信。
* adb 常駐進程。

ADB Server

ADB Server 中包含 Local Service 和 Remote Service,Local Service 用於與 ADB Client 交互,Remote Service 用於與 ADB Dameon 交互。

ADB Client

ADB Client 運行在電腦上,一般通過命令行或者 Android Studio 執行 adb 命令來與其交互。ADB Client 的主要職責是解析命令,做預處理,然後發送給 ADB Server,這裏分為兩種情況:
ADB Server 能處理的命令就自己處理,如 adb version。
ADB Server 不能處理的命令就發送給 ADB Dameon,並接受返回消息,如 adb devices。

ADB Dameon

ADB Dameon 運行在手機上的服務進程,進程名為 adbd,在手機啟動後,由 Zygote 進程創建。ADB Dameon 的主要職責是:
為手機提供adb服務。
創建 Local Service 和 Remote Service,Local Service 用於與 JVM 交互,Remote Service 用於與 ADB Server 交互。

瞭解了三者的分工後,可以通過下圖對 ADB 架構有一個較為整體的理解。

ADB 架構

看到這裏,大家應該就能理解為什麼連接手機和 Android Studio 的命令是adb forward tcp:5005 jdwp:16282了,它實際上就是把 ADB 和 手機虛擬機進行連接,同時也可以發現 ADB Server 和 ADB Dameon 之間的協議既可以是 USB(數據線)也可以是 TCP 的方式,其中 TCP 就是調試功能支持 WIFI、遠程的基礎。

注:由於篇幅有限,這裏只對 ADB 架構做了簡略的介紹,感興趣的同學可以自行學習。

JDWP協議

在瞭解了 ADB 協議後,我們知道了命令是如何從 Android Studio 或者命令行傳輸到手機上的 ADB Dameon 的,那麼 ADB Dameon 又是如何與虛擬機交互的,以及傳輸協議中的數據格式又是怎樣的呢,這裏就需要理解 JDWP 協議了。

概念介紹

JDWP 是 Java Debug Wire Protocol 的縮寫,其本質上是調試器和目標虛擬機進行調試交互的通信協議,通過命令包和回覆包兩種格式來傳輸數據。
這裏有四個概念需要了解:
調試器(Debugger):Android Studio、Eclipse、DDMS、Terminal 等,他們都實現了支持 JDWP 通信接口。
目標虛擬機(Target VM):JVM、Art、Dalvik 等,在虛擬機啟動時,會加載JDWP模塊。
命令包(Command packet):調試器發送給虛擬機用於獲取程序狀態信息或控制程序運行,或者虛擬機發送給調試器用於通知事件觸發消息。
回覆包(Reply packet):虛擬機發送給調試器用於回覆命令包的請求或者執行結果。

它們之間的交互如下圖:

JDWP

數據包

JDWP 數據包包含包頭和數據兩部分,數據部分就是簡單的二進制數據流,我們這裏注重講一下包頭部分的結構,這也是調試命令傳輸的核心。

數據頭

如上圖所示,命令包和回覆包的前三部分結構是相同的:
length:4字節,數據包長度,包含包頭和數據。
id:4字節,數據包序號,命令包和回覆包必須保持一致。
* flags:1字節,數據包類型,0x80 表示命令包,0x00 表示回覆包。

不同之處在於最後2字節:
命令包包含 cmd set(命令分組)和 cmd id(命令序號)兩部分,分別佔1字節。
回覆包裏存放的是 error code 錯誤碼,非0即為存在錯誤,佔2字節。

常見的命令分組和序號按照功能大致分為18組命令,包含了虛擬機信息、類、對象、線程、方法、事件等不同類型的操作命令。見下圖:

命令組
該圖片來源FreeBuf。

查看完整命令組及詳細信息見:命令組

這裏以獲取虛擬機版本的命令 VirtualMachine:version 為例演示,幫助大家理解命令到底是如何傳輸的。
首先來看獲取虛擬機版本會回覆哪些信息:

虛擬機版本

通過上述表格可以推導出命令包與回覆包的包信息為:

包信息

把對應編碼轉換成字符串為:

字符串

需要注意,非基本數據類型的內存結構,例如 String,使用「長度」+「字符數據」的形式。以 vmName 字段為例,DalvikVM 的 ASCII 碼為「44 61 6c 76 69 6b 56 4d」,DalvikVM 的長度為8,所以綜合後 DalvikVM 的返回數據為「00 00 00 08 44 61 6c 76 69 6b 56 4d」。而 jdwpMajor 為純數字,所以 jdwpMajor 的返回數據為「00 00 00 01」。

到這裏調試原理就講完了,原理部分只是從整體架構的層面為大家介紹了一下,內部還有很多的知識點值得大家去深究,感興趣的同學可以自行學習。

常見問題

在講完了調試實戰和原理之後,我們來看一些常見的調試問題:

  • 斷點主動斷開
    現象:在某些機型上,例如華為非鴻蒙系統、部分 OPPO、一加設備等,當斷點在 Activity、Fragment 的生命週期方法上超過10秒或者卡住頁面展示超過一定時間(不同設備時長不一致)時,會出現斷點主動斷開的情況。
    解決方式:使用非阻塞式的日誌斷點。

  • 無法Attach to Process
    現象:在掛載進程進行調試時,出現 Error running 'Android Debugger (-1)': Invalid argument : Argument invalid [port] 的報錯,這時是由於 adb 進程端口號被其他進程搶佔了。
    解決方式:使用 adb kill-server 殺死 adb 進程,然後使用任意一個 adb 命令(adb devices)fork 一個新的 adb 進程即可。

  • 方法斷點導致Debug卡頓
    現象:在使用方法斷點時,調試器會變得異常卡頓,這是因為方法斷點需要跟蹤方法的入棧和出棧,每次進出都要發送指令給調試,具體流程如下:
    1.把方法斷點加入斷點列表。
    2.調試器發送指令告訴虛擬機需要監聽 Method Entry 和 Method Exit。
    3.虛擬機每次收到 Method Entry 或者 Method Exit 後發送事件給調試器。
    4.調試器判斷是否在斷點列表中。
    5.存在則向虛擬機發送 SetBreakPoint 請求掛起,否則發送請求釋放該方法棧。
    解決方式:
    1.根據實際情況放開 Method Entry 或者 Method Exit,如下圖所示。
    2.用完即棄,及時去除方法斷點。
    3.不要用!使用行斷點(官方建議)。

方法斷點

總結

調試是一個優秀開發者必備的技巧,對提升開發效率有極大的幫助。掌握調試原理也可以幫助開發者更好的理解 Android 架構,是一個高級開發者的必經之路。

參考資料

本文發佈自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!