一篇文章帶你領略Android混淆的魅力

在 Android 日常開發過程中,混淆是我們開發 App 的一項必不可少的技能。只要是我們親身經歷過 App 打包上線的過程,或多或少都需要了解一些代碼混淆的基本操作。那麼,混淆到底是什麼?它的好處有哪些?具體效果如何?別急,下面我們來一一探索它的"獨特"魅力。

混淆簡介

代碼混淆Obfuscated code)是將程序中的代碼以某種規則轉換爲難以閱讀和理解的代碼的一種行爲。

在 Android 日常開發過程中,混淆是我們開發 App 的一項必不可少的技能。只要是我們親身經歷過 App 打包上線的過程,或多或少都需要了解一些代碼混淆的基本操作。那麼,混淆到底是什麼?它的好處有哪些?具體效果如何?別急,下面我們來一一探索它的"獨特"魅力🐳。

混淆簡介

代碼混淆Obfuscated code)是將程序中的代碼以某種規則轉換爲難以閱讀和理解的代碼的一種行爲。

混淆的好處

混淆的好處就是它的目的:令 APK 難以被逆向工程,即很大程度上增加反編譯的成本。此外,Android 當中的"混淆"還能夠在打包時移除無用資源,顯著減少 APK 體積。最後,還能以變通方式避免 Android 中常見的 64k 方法數引用的限制。

我們先來看一下混淆前後的 APK 結構對比。

混淆前:
混淆前

混淆後:
混淆後

從上面兩張圖可以看出:經過混淆處理之後,我們的 APK 中包名、類名、成員名等都被替換爲隨機、無意義的名稱,增加了代碼閱讀和理解的困難程度,提高了反編譯的成本。細心的小夥伴可能又會注意到:混淆前後 APK 的體積竟然從 2.7M 減小到了 1.4M,體積縮減了近一倍!真的有這麼神奇嗎?哈哈,確實是這麼神奇,讓我們慢慢來揭開它的神祕面紗吧😏。

Android 當中的混淆

在 Android 中,我們平常所說的"混淆"其實有兩層意思,一個是 Java 代碼的混淆,另外一個是資源的壓縮。其實這兩者之間並沒有什麼關聯,只不過習慣性地放在一起來使用。那麼,說了這麼多,Android 平臺上到底該如何開啓混淆呢?

啓用混淆

......
  
android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

以上就是開啓混淆的基本操作了,通過 minifyEnabled 設置爲 true 來開啓混淆。同時,可以設置 shrinkResourcestrue 來開啓資源的壓縮。不難看出,我們一般在打 release 包時才啓用混淆,因爲混淆會增加額外的編譯時間,所以不建議在 debug 模式下啓用。此外,需要注意的是:只有在啓用混淆的前提下開啓資源壓縮纔會有效!以上代碼中的 proguard-android.txt 表示 Android 系統爲我們提供的默認混淆規則文件,而 proguard-rules.pro 則是我們想要自定義的混淆規則,至於如何自定義混淆規則我們將在接下來會講到😄。

代碼混淆

其實,Java 平臺爲我們提供了 Proguard 混淆工具來幫助我們快速地對代碼進行混淆。根據 Java 官方介紹,Proguard 對應的具體中文定義如下:

  • 它是一個包含代碼文件壓縮優化混淆校驗等功能的工具
  • 它能夠檢測並刪除無用的類、變量、方法和屬性
  • 它能夠優化字節碼並刪除未使用的指令
  • 它能夠將類、變量和方法的名字重命名爲無意義的名稱從而達到混淆效果
  • 最後,它還會校驗處理後的代碼,主要針對 Java 6 及以上版本和 Java ME

資源壓縮

Android 中,編譯器爲我們提供了另外一項強大的功能:資源的壓縮。資源壓縮能夠幫助我們移除項目及依賴倉庫中未使用到的資源,有效地降低了apk包的大小。由於資源壓縮與代碼混淆是協同工作,所以,如果需要開啓資源的壓縮,切記要先開啓代碼混淆,否則會出現以下問題:

ERROR: Removing unused resources requires unused code shrinking to be turned on. See http://d.android.com/r/tools/shrink-resources.html for more information.
Affected Modules: app

自定義要保留的資源

當我們開啓了資源壓縮之後,系統會默認替我們移除所有未使用的資源,假如我們需要保留某些特定的資源,可以在我們項目中創建一個被 <resources> 標記的 XML 文件(如 res/raw/keep.xml),並在 tools:keep 屬性中指定每個要保留的資源,在 tools:discard 屬性中指定每個要捨棄的資源。這兩個屬性都接受逗號分隔的資源名稱列表。同樣,我們可以使用字符 * 作爲通配符。如:

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@layout/activity_video*,@layout/dialog_update_v2"
    tools:discard="@layout/unused_layout,@drawable/unused_selector" />

啓用嚴格檢查模式

正常情況下,資源壓縮器可準確判定系統是否使用了資源。不過,如果您的代碼(包含庫)調用 Resources.getIdentifier(),這就表示您的代碼將根據動態生成的字符串查詢資源名稱。這時,資源壓縮器會採取防禦性行爲,將所有具有匹配名稱格式的資源標記爲可能已使用,無法移除。例如,以下代碼會使所有帶 img_ 前綴的資源標記爲已使用:

String name = String.format("img_%1d", angle + 1);
res = getResources().getIdentifier(name, "drawable", getPackageName());

這時,我可以開啓資源的嚴格審查模式,只會保留確定已使用的資源。

移除備用資源

Gradle 資源壓縮器只會移除未被應用引用的資源,這意味着它不會移除用於不同設備配置的備用資源。必要時,我們可以使用 Android Gradle 插件的 resConfigs 屬性來移除您的應用不需要的備用資源文件(常見的有用於國際化支持的 strings.xml,適配用的 layout.xml 等):

android {
    defaultConfig {
        ...
        //保留中文和英文國際化支持
        resConfigs "en", "zh"
    }
}

自定義混淆規則

品嚐完了以上"配菜",下面讓我們來品味一下本文的"主菜":自定義混淆規則。首先,我們來了解一下常見的混淆命令。

keep 命令

這裏說的 keep 命令指的是一系列以 -keep 開頭的命令,它主要用來保留 Java 中不需要進行混淆的元素。以下是常見的 -keep 命令:

  • -keep

    作用:保留指定的類和成員,防止被混淆處理。例如:

    # 保留包:com.moos.media.entity 下面的類以及類成員
    -keep public class com.moos.media.entity.**
    
    # 保留類:NumberProgressBar
    -keep public class com.moos.media.widget.NumberProgressBar {*;}
    
  • -keepclassmembers

    作用:保留指定的類的成員(變量/方法),它們將不會被混淆。如:

    # 保留類的成員:MediaUtils類中的特定成員方法
    -keepclassmembers class com.moos.media.MediaUtils {
        public static *** getLocalVideos(android.content.Context);
        public static *** getLocalPictures(android.content.Context);
    }
    
  • -keepclasseswithmembers

    作用:保留指定的類和其成員(變量/方法),前提是它們在壓縮階段沒有被刪除。與-keep 使用方式類似:

    # 保留類:BaseMediaEntity 的子類
    -keepclasseswithmembers public class * extends com.moos.media.entity.BaseMediaEntity{*;}
    
    # 保留類:OnProgressBarListener接口的實現類
    -keep public class * implements com.moos.media.widget.OnProgressBarListener {*;}
    
  • @Keep

    除了以上方式,你也可以選擇使用 @Keep 註解來保留期望代碼,防止它們被混淆處理。比如,我們通過 @Keep 修飾一個類來保留它不被混淆:

    @Keep
    data class CloudMusicBean(var createDate: String,
                              var id: Long,
                              var name: String,
                              var url: String,
                              val imgUrl: String)
    

    同樣地,我們也可以讓 @Keep 來修飾方法或者字段進而保留它們。

其他命令

  1. dontwarn

    -dontwarn 命令一般在我們引入新的 library 時會使用到,常用於處理 library 中無法解決的警告。如:

    -keep class twitter4j.** { *; }
    
    -dontwarn twitter4j.**
    
  2. 其他的命令用法可參考 Android 系統提供的默認混淆規則:

    #混淆時不生成大小寫混合的類名
    -dontusemixedcaseclassnames
    
    #不跳過非公共的庫的類
    -dontskipnonpubliclibraryclasses
    
    #混淆過程中記錄日誌
    -verbose
    
    #關閉預校驗
    -dontpreverify
    
    #關閉優化
    -dontoptimize
    
    #保留註解
    -keepattributes *Annotation*
    
    #保留所有擁有本地方法的類名及本地方法名
    -keepclasseswithmembernames class * {
        native <methods>;
    }
    
    #保留自定義View的get和set方法
    -keepclassmembers public class * extends android.view.View {
       void set*(***);
       *** get*();
    }
    
    #保留Activity中View及其子類入參的方法,如: onClick(android.view.View)
    -keepclassmembers class * extends android.app.Activity {
       public void *(android.view.View);
    }
    
    #保留枚舉
    -keepclassmembers enum * {
        **[] $VALUES;
        public *;
    }
    
    #保留序列化的類
    -keepclassmembers class * implements android.os.Parcelable {
      public static final android.os.Parcelable$Creator CREATOR;
    }
    
    #保留R文件的靜態成員
    -keepclassmembers class **.R$* {
        public static <fields>;
    }
    
    -dontwarn android.support.**
    
    -keep class android.support.annotation.Keep
    
    -keep @android.support.annotation.Keep class * {*;}
    
    -keepclasseswithmembers class * {
        @android.support.annotation.Keep <methods>;
    }
    
    -keepclasseswithmembers class * {
        @android.support.annotation.Keep <fields>;
    }
    
    -keepclasseswithmembers class * {
        @android.support.annotation.Keep <init>(...);
    }
    

    更多混淆命令可以參考文章:Proguard 最全混淆規則說明 ,這裏就不做詳細講解了。

混淆"黑名單"

我們在瞭解了混淆的基本命令之後,很多人應該還是一頭霧水:到底哪些內容該混淆?其實,我們在使用代碼混淆時,ProGuard 對我們項目中大部分代碼進行了混淆操作,爲了防止編譯時出錯,我們應該通過 keep 命令保留一些元素不被混淆。所以,我們只需要知道哪些元素不應該被混淆

枚舉

項目中難免可能會用到枚舉類型,然而它不能參與到混淆當中去。原因是:枚舉類內部存在 values 方法,混淆後該方法會被重新命名,並拋出 NoSuchMethodException。慶幸的是,Android 系統默認的混淆規則中已經添加了對於枚舉類的處理,我們無需再去做額外工作。想了解更多枚舉內部細節可以去查看源碼,篇幅有限不再細說。

被反射的元素

被反射使用的類、變量、方法、包名等不應該被混淆處理。原因在於:代碼混淆過程中,被反射使用的元素會被重命名,然而反射依舊是按照先前的名稱去尋找元素,所以會經常發生 NoSuchMethodExceptionNoSuchFiledException 問題。

實體類

實體類即我們常說的"數據類",當然經常伴隨着序列化反序列化操作。很多人也應該都想到了,混淆是將原本有特定含義的"元素"轉變爲無意義的名稱,所以,經過混淆的"洗禮"之後,序列化之後的 value 對應的 key 已然變爲沒有意義的字段,這肯定是我們不希望的。同時,反序列化的過程創建對象從根本上來說還是藉助於反射,混淆之後 key 會被改變,所以也會違揹我們預期的效果。

四大組件

Android 中的四大組件同樣不應該被混淆。原因在於:

  1. 四大組件使用前都需要在 AndroidManifest.xml 文件中進行註冊聲明,然而混淆處理之後,四大組件的類名就會被篡改,實際使用的類與 manifest 中註冊的類並不匹配,故而出錯。
  2. 其他應用程序訪問組件時可能會用到類的包名加類名,如果經過混淆,可能會無法找到對應組件或者產生異常。

JNI 調用的Java 方法

當 JNI 調用的 Java 方法被混淆後,方法名會變成無意義的名稱,這就與 C++ 中原本的 Java 方法名不匹配,因而會無法找到所調用的方法。

其他不應該被混淆的

  • 自定義控件不需要被混淆
  • JavaScript 調用 Java 的方法不應混淆
  • Java 的 native 方法不應該被混淆
  • 項目中引用的第三方庫也不建議混淆

混淆後的堆棧跟蹤

代碼經過 ProGuard 混淆處理後,想要讀取 StackTrace(堆棧追蹤)信息就會變得很困難。由於方法名稱和類的名稱都經過混淆處理,即使程序發生崩潰問題,也很難定位問題所在。幸運的是,ProGuard 爲我們提供了補救的措施,在着手進行之前,我們先來看一下 ProGuard 每次構建後生成了哪些內容。

混淆輸出結果

混淆構建完成之後,會在 <module-name>/build/outputs/mapping/release/ 目錄下生成以下文件:

  • dump.txt

    說明 APK 內所有類文件的內部結構。

  • mapping.txt

    提供混淆前後的內容對照表,內容主要包含類、方法和類的成員變量。

  • seeds.txt

    羅列出未進行混淆處理的類和成員。

  • usage.txt

    羅列出從 APK 中移除的代碼。

恢復堆棧跟蹤

瞭解完混淆構建完畢後輸出的內容之後,我們現在就來看一下之前的問題:混淆處理後,StackTrace 定位困難。如何來恢復 StackTrace 的定位能力呢?系統爲我們提供了 retrace 工具,結合上文提到的 mapping.txt 文件,就可以將混淆後的崩潰堆棧追蹤信息還原成正常情況下的 StackTrace 信息。主要有兩種方式來恢復 StackTrace,爲了方便理解,我們以下面這段崩潰信息爲例,藉助兩種方式分別來還原:

 java.lang.RuntimeException: Unable to start activity 
     Caused by: kotlin.KotlinNullPointerException
        at com.moos.media.ui.ImageSelectActivity.k(ImageSelectActivity.kt:71)
        at com.moos.media.ui.ImageSelectActivity.onCreate(ImageSelectActivity.kt:58)
        at android.app.Activity.performCreate(Activity.java:6237)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1107)
  1. 通過 retrace 腳本工具

    首先我們要進入到 Android SDK 路徑的 /tools/proguard/bin 目錄中,這裏以 Mac 系統爲例,可以看到如下內容:

    腳本目錄

    可以看到如上三個文件,而 proguardgui.sh 纔是我們需要的 retrace 腳本(Windows系統下爲 proguardgui.bat )。Windows 系統中只需要雙擊腳本 proguardgui.bat 即可運行,至於 Mac 系統,如果你沒有做任何配置,只需要將 proguardgui.sh 腳本拖動到 Mac 自帶的終端中,回車鍵即可運行。接着,我們會看到如下界面:

    腳本頁面

    選擇 ReTrace 欄 ,並添加我們項目中混淆生成的 mapping.txt 文件所在位置,然後將我們的混淆後的崩潰信息複製到 Obfuscated stack trace 那一欄,點擊 ReTrace! 按鈕即可還原出我們的崩潰日誌信息,結果如上圖所示,我們之前的混淆日誌:at com.moos.media.ui.ImageSelectActivity.k(ImageSelectActivity.kt:71) 被還原成了 at com.moos.media.ui.ImageSelectActivity.initView(ImageSelectActivity.kt:71)ImageSelectActivity.k 是我們混淆後的方法名,ImageSelectActivity.initView 則是最初未混淆前的方法名,藉助於 ReTrace 工具的幫助,我們就可以像以前一樣很快定位到崩潰代碼區域了。

  2. 通過 retrace 命令行

    我們先要將崩潰信息複製到 txt 格式的文件(如:proguard_stacktrace.txt)中保存,然後執行以下命令即可(MAC系統):

    retrace.sh -verbose mapping.txt proguard_stacktrace.txt
    

    如果你是 windows 系統,可以執行以下命令:

    retrace.bat -verbose mapping.txt proguard_stacktrace.txt
    

    最終還原的結果和之前效果一樣:

    命令行實現

    也許你通過以上兩種方式在對 stackTrace 進行恢復時,發現 Unknown Source 問題:

    known

值得注意的是,記得在混淆規則中加上如下配置來提升我們的 StackSource 查找效率:

# 保留源文件名和具體代碼行號
-keepattributes SourceFile,LineNumberTable

此外,我們每次使用 ProGuard 創建發佈構建時都都會覆蓋之前版本的 mapping.txt 文件,因此我們每次發佈新版本時都必須小心地保存一個副本。通過爲每個發佈構建保留一個 mapping.txt 文件副本,我們就可以在用戶提交的已混淆的 StackTrace 來對舊版本應用的問題進行調試和修復。

漲姿勢的操作

經過上文的介紹,我們知道,APK 在經過代碼混淆處理後,包名、類名、成員名被轉化爲無意義、難以理解的名稱,增加反編譯的成本。Android ProGuard 爲我們提供了默認的"混淆字典",即將元素名稱轉爲英文小寫字母的形式。那麼,我們可以定義自己的混淆字典嗎?賣個關子,我們先來看一張效果圖:

混淆高級

這個波操作是不是有點"出類拔萃"了?哈哈,就不賣關子了,其實很簡單,只要生成一套自己的 txt 格式的混淆字典,然後在混淆規則 Proguard-rules.pro 中應用一下即可:

混淆字典配置

本文中使用的混淆字典可以在此處查看並下載:proguard_tradition.txt

當然,大家也可以自己去定製化自己的"混淆字典",增加反編譯的難度。

一路走下來,我們發現,從混淆技術的必要性和優點來看,它還是很值得我們去深入學習和研究的,本文帶大家領略的僅僅是"冰山一角"。由於本人的技術水平有限,若大家發現有問題或者闡述不當之處,歡迎指出並修正。

相關參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章