[譯] 實用 ProGuard 規則示例

DerekDick發表於2018-08-14

我在之前的文章中解釋了 為什麼每個人都應該將 ProGuard 用於他們的 Android 應用、怎麼啟用它以及在使用中可能面臨的錯誤種類。這其中涉及很多理論,因為我認為理解基本原理以準備好處理任何潛在問題非常重要。

我還在一篇單獨的文章中談到了 為 Instant App 構建配置 ProGuard 的非常具體的問題。

在這裡,我想談 ProGuard 規則在中型樣例應用上的實用示例:出自 Nick ButcherPlaid.

從 Plaid 中吸取的教訓

Plaid 實際上是研究 ProGuard 問題的一個很好的主題,因為它包含使用註解處理與程式碼生成、反射、Java資源載入和原生程式碼(JNI)的第三方庫的混合體。我提取並記錄下了一些適用於其他應用的實用建議:

資料類

public class User {
  String name;
  int age;
  ...
}
複製程式碼

每個應用可能都有某種資料類(也被稱為 DMOs,模型等,取決於上下文以及它們處在應用架構中的位置)。關於資料物件的事實是,通常在某些時候他們將被載入或儲存(序列化)到某些其他介質中,例如網路(HTTP 請求)、資料庫(通過 ORM)、磁碟上的 JSON 檔案或 Firebase 資料儲存。

許多簡化序列化與反序列化這些欄位的工具依賴於反射。GSON、Retrofit、Firebase —— 他們都檢查資料類的欄位名並把它們轉換成另一種表現形式(例如:{“name”: “Sue”, “age”: 28}),用於傳輸或儲存。它們將資料讀入 Java 物件時也是同理 —— 它們看到鍵值對 “name”:”John” 並嘗試通過查詢 String name 欄位將其應用到 Java 物件上。

結論:我們不能讓 ProGuard 重新命名或刪除這些資料類的任何欄位,因為它們必須與序列化的格式匹配。最好給整個類新增一個 @Keep 註解或者給所有模型新增萬用字元規則:

-keep class io.plaidapp.data.api.dribbble.model.** { *; }
複製程式碼

警告:在測試你的應用是否容易受到這個問題的影響是可能會出錯。例如,如果你在版本 N 的應用程式中將一個物件序列化成 JSON 並將其儲存到磁碟而沒有使用適當的 keep 規則,那麼儲存的資料可能看起來像這樣:{“a”: “Sue”, “b”: 28}。因為 ProGuard 將你的欄位重新命名為 ab,所以一切看起來似乎都有效,資料也會被正確地儲存和載入。

然而,當你再一次構建你的應用併發布版本 N+1 的應用時,ProGuard 可能會決定將你的欄位重新命名為某些其他的,比如 cd。因此,之前儲存的資料將無法載入。

首先你必須確保你有適當的 keep 規則。

從原生層呼叫的 Java 程式碼(JNI)

Android 的 預設 ProGuard 檔案(你應該總是包括它們,它們有一些非常有用的規則)已經包含了針對在原生層實現的方法的規則(-keepclasseswithmembernames class * { native <methods>; })。遺憾的是,沒有一種全能的方法可以保留從反方向呼叫的程式碼:從 JNI 到 Java。

利用 JNI,完全有可能從 C / C++ 程式碼中構造 JVM 物件或者找到並呼叫 JVM 控制程式碼的方法,而且事實上,Plaid 的一個庫就是這樣

結論:因為 ProGuard 只能審查 Java 類,所以它不會知道任何在原生程式碼中發生的使用。我們必須通過 @Keep 註解或 -keep 規則來顯式地保留這些類和成員的使用。

-keep, includedescriptorclasses
            class in.uncod.android.bypass.Document { *; }
-keep, includedescriptorclasses
            class in.uncod.android.bypass.Element { *; }
複製程式碼

從 JAR/APK 開啟資源

Android 有其自己的資源系統,通常不會有 ProGuard 的問題。然而,在普通的 Java 中有另一種 直接從 JAR 檔案載入資源的機制。並且某些第三方庫即使被編譯到 Android 應用中也可能會使用這種機制(在這種情況下,它們將嘗試從 APK 載入)。

問題是通常這些類會在自己的包名下尋找資源(這將轉換為 JAR 或 APK 中的檔案路徑)。ProGuard 可能在混淆時重新命名包名,因此在編譯之後可能會發生類及其資原始檔不再位於最終 APK 中的同一包內。

要以這種方式識別載入資源,你可以在你的程式碼和任何你依賴的第三方庫中查詢 Class.getResourceAsStream / getResourceClassLoader.getResourceAsStream / getResource 的呼叫。

結論:我們應該保留任何使用這種機制從 APK 載入資源的類的名字。

在 Plaid 中,實際上有兩個 —— 一個在 OKHttp 庫中,另一個在 Jsoup 庫中:

-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
-keepnames class org.jsoup.nodes.Entities
複製程式碼

如何為第三方庫制定規則

在理想的世界裡,每個你使用的依賴都會在 AAR 中提供他們所需要的 ProGuard 規則。有時他們會忘記這樣做或只發布 JAR,這些 JAR 沒有標準的方式來提供 ProGuard 規則。

在這種情況下,在開始除錯應用和制定規則之前,記得檢視文件。一些庫的作者提供推薦的 ProGuard 規則(例如在 Plaid 中使用的 Retrofit),這可以為你節省大量時間,並讓你免受挫折。遺憾的是,很多庫都不會這樣(例如這篇文章中提到的 Jsoup 和 Bypass 的情況)。另請注意,在某些情況下,隨庫提供的配置只能在禁用優化的條件下起作用,因此如果你開啟了優化,那麼你可能踏入了未知領域。

那麼當庫沒有提供規則時,如何制定規則呢? 我只能給你一些提示:

  1. 閱讀構建輸出和 logcat!
  2. 構建警告會告訴你新增哪些 -dontwarn 規則
  3. ClassNotFoundExceptionMethodNotFoundExceptionFieldNotFoundException 會告訴你新增哪些 -keep 規則

當你使用了 ProGuard 的應用崩潰時,你應該慶幸 —— 你將有一個開始調查的地方 :)

最糟糕的一類除錯問題是你的應用工作了,但是例如螢幕沒有顯示或沒有從網路載入資料。

在這裡你需要去考慮我在本文中描述的一些場景並動手實踐,甚至扎入第三方庫的程式碼中並理解它可能失敗的原因,例如當它使用反射、攔截或 JNI 時。

除錯與堆疊跟蹤

ProGuard 預設會刪除程式執行不需要的許多程式碼屬性和隱藏後設資料。其中一些對開發者實際上很有用 —— 例如,你可能希望保留堆疊跟蹤的原始檔名和行號,以使除錯更容易:

-keepattributes SourceFile, LineNumberTable
複製程式碼

你也應當記得 儲存構建發行版本時生成的 ProGuard 對映檔案並將其上傳到 Play 以便從使用者遇到的任何崩潰中得到反混淆的堆疊跟蹤。

如果要在使用 ProGuard 構建的應用中附加偵錯程式來逐步執行方法程式碼,那麼你還應該保留以下屬性,以保留關於區域性變數的一些除錯資訊(在 debug 構建型別中只需要這一行):

-keepattributes LocalVariableTable, LocalVariableTypeTable
複製程式碼

縮小的除錯構建型別

構建型別的預設配置為 debug 不使用 ProGuard。這很有道理,因為我們希望在開發時快速迭代和編譯,但仍然希望使用 ProGuard 來構建釋出版本以使其儘可能小和優化。

但是為了全面測試和除錯任何 ProGuard 問題,最好像這樣設定一個單獨的、縮小的除錯構建:

buildTypes {
  debugMini {
    initWith debug
    minifyEnabled true
    shrinkResources true
    proguardFiles getDefaultProguardFile('proguard-android.txt'),
                  'proguard-rules.pro'
    matchingFallbacks = ['debug']
  }
}
複製程式碼

使用這種構建型別,你將能夠 連線偵錯程式, 執行 UI 測試 (也在持續整合伺服器上) 或 monkey 測試 你的應用,以便在儘可能接近釋出版本的構建上發現可能的問題。

結論:當你使用 ProGuard 時,你應當總是通過端到端測試,或者手動瀏覽應用的所有頁面來看是否有任何缺失或崩潰,以對你的構建版本進行徹底的 QA。

執行時註解,型別攔截

ProGuard 預設會刪除程式碼中的所有註解甚至一些剩餘的型別資訊。對於一些庫來說,這不是個問題 —— 那些在編譯時處理註解與生成程式碼的庫(例如 Dagger2Glide 等等)可能以後程式執行時不需要這些註解。

還有另外一類實際上在執行時檢查註解或檢視引數與異常的型別資訊的工具。例如 Retrofit 就這樣做,通過使用 Proxy 物件來攔截方法呼叫,然後檢視註解和型別資訊來決定什麼內容該放入 HTTP 請求或從 HTTP 請求中讀取。

結論:有時需要並保留在執行時而不是編譯時被取的型別資訊與註解。你可以檢視 ProGuard 手冊中的屬性列表

-keepattributes *Annotation*, Signature, Exception
複製程式碼

如果你使用預設的Android ProGuard 配置檔案(getDefaultProguardFile('proguard-android.txt')),那麼前兩個選項 —— 註解和簽名 —— 是專門為你準備的。如果你沒有使用預設的配置檔案,那麼你必須保證你自己新增它們(如果你知道你的應用需要他們,那麼重複它們也沒有什麼壞處)。

將所有內容移至預設包

預設情況下,ProGuard 配置中不會新增 -repackageclasses 選項。如果你已經在混淆你的程式碼並且使用適當的 keep 規則解決了任何問題,那麼你可以新增這個選項以進一步減小 DEX 的大小。它的工作原理是將所有類移至預設(根)包,從而實質上釋放了被像 「com.example.myapp.somepackage」這樣的字串所佔用的空間。

-repackageclasses
複製程式碼

ProGuard 優化

正如我之前提到的,ProGuard 可以為你做三件事:

  1. 它擺脫了未使用的程式碼,
  2. 重新命名識別符號從而使程式碼更小,
  3. 對整個程式進行優化。

在我看來,每個人都應該嘗試並配置他們的構建來使1. 和 2. 工作。

為了解鎖 3.(額外的優化),你必須使用其他預設的 ProGuard 配置檔案。在你的 build.gradle 中,將 proguard-android.txt 引數改為 proguard-android-optimize.txt

release {
  minifyEnabled true
  proguardFiles
      getDefaultProguardFile('proguard-android-optimize.txt'),
      'proguard-rules.pro'
}
複製程式碼

這會是你的釋出構建更慢,但可能會讓你的應用執行地更快和進一步縮小程式碼體積,這要歸功於方法內聯、類合併與更侵略性的程式碼刪除等優化。但要做好準備,它可能會引入新的、更難診斷的錯誤,因此謹慎使用,如果有任何不起作用,務必禁用某些特定的優化或完全禁用優化配置。

就 Plaid 來說,ProGuard 優化干擾了 Retrofit 如何使用沒有具體實現的代理物件,並剝離了一些實際需要的方法引數。我必須在我的配置中新增這一行:

-optimizations !method/removal/parameter
複製程式碼

你可以在 ProGuard 中找到 可能的優化列表以及如何禁用它們

何時使用 @Keep-keep

@Keep 的支援在預設的 Android ProGuard 規則檔案中實際上是通過一系列 -keep 規則實現的,因此它們基本上是等效的。指定 -keep 規則更靈活,因為它提供萬用字元,你也可以使用不同的變體,這些變體稍有不同(-keepnames-keepclasseswithmembers 以及更多)。

每當需要一個簡單的「保留這個類」或「保留這個方法」規則時,我實際上更喜歡在類或成員上新增 @Keep 註解的簡單性,因為它離程式碼很近,幾乎就像文件一樣。

如果其他開發者想要在我之後重構程式碼,他們會立即知道被 @Keep 標記的類 / 成員需要特殊處理,而不必記住和參考 ProGuard 配置並且冒著破壞某些東西的風險。IDE 中大部分的程式碼重構也應當自動保留類的 @Keep 註解。

Plaid 統計資訊

這有一些來自 Plaid 的統計資訊,它們展示了我通過使用 ProGuard 刪除了多少程式碼。在有更多依賴和更大 DEX 的更復雜的應用上,節省的可能更多。

[譯] 實用 ProGuard 規則示例

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章