一篇文章带你领略Android混淆的魅力

在 Android 平常开发过程当中,混淆是咱们开发 App 的一项必不可少的技能。只要是咱们亲身经历过 App 打包上线的过程,或多或少都须要了解一些代码混淆的基本操做。那么,混淆究竟是什么?它的好处有哪些?具体效果如何?别急,下面咱们来一一探索它的"独特"魅力🐳。php

混淆简介

代码混淆Obfuscated code)是将程序中的代码以某种规则转换为难以阅读和理解的代码的一种行为。html

混淆的好处

混淆的好处就是它的目的:令 APK 难以被逆向工程,即很大程度上增长反编译的成本。此外,Android 当中的"混淆"还可以在打包时移除无用资源,显著减小 APK 体积。最后,还能以变通方式避免 Android 中常见的 64k 方法数引用的限制。java

咱们先来看一下混淆先后的 APK 结构对比。android

混淆前: git

混淆前

混淆后: github

混淆后

从上面两张图能够看出:通过混淆处理以后,咱们的 APK 中包名、类名、成员名等都被替换为随机、无心义的名称,增长了代码阅读和理解的困难程度,提升了反编译的成本。细心的小伙伴可能又会注意到:混淆先后 APK 的体积居然从 2.7M 减少到了 1.4M,体积缩减了近一倍!真的有这么神奇吗?哈哈,确实是这么神奇,让咱们慢慢来揭开它的神秘面纱吧😏。windows

Android 当中的混淆

在 Android 中,咱们日常所说的"混淆"其实有两层意思,一个是 Java 代码的混淆,另一个是资源的压缩。其实这二者之间并无什么关联,只不过习惯性地放在一块儿来使用。那么,说了这么多,Android 平台上到底该如何开启混淆呢?ruby

启用混淆

......
  
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 则是咱们想要自定义的混淆规则,至于如何自定义混淆规则咱们将在接下来会讲到😄。bash

代码混淆

其实,Java 平台为咱们提供了 Proguard 混淆工具来帮助咱们快速地对代码进行混淆。根据 Java 官方介绍,Proguard 对应的具体中文定义以下:app

  • 它是一个包含代码文件压缩优化混淆校验等功能的工具
  • 它可以检测并删除无用的类、变量、方法和属性
  • 它可以优化字节码并删除未使用的指令
  • 它可以将类、变量和方法的名字重命名为无心义的名称从而达到混淆效果
  • 最后,它还会校验处理后的代码,主要针对 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());
复制代码

这时,我能够开启资源的严格审查模式,只会保留肯定已使用的资源:

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:shrinkMode="strict" />
复制代码

移除备用资源

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 系统为例,能够看到以下内容:

    retrace脚本目录

    能够看到如上三个文件,而 proguardgui.sh 才是咱们须要的 retrace 脚本(Windows系统下为 proguardgui.bat )。Windows 系统中只须要双击脚本 proguardgui.bat 便可运行,至于 Mac 系统,若是你没有作任何配置,只须要将 proguardgui.sh 脚本拖动到 Mac 自带的终端中,回车键便可运行。接着,咱们会看到以下界面:

    retrace脚本界面

    选择 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
    复制代码

    最终还原的结果和以前效果同样:

    retrace命令行还原stacktrace

    也许你经过以上两种方式在对 stackTrace 进行恢复时,发现 Unknown Source 问题:

    unknown source问题

值得注意的是,记得在混淆规则中加上以下配置来提高咱们的 StackSource 查找效率:

# 保留源文件名和具体代码行号
-keepattributes SourceFile,LineNumberTable
复制代码

此外,咱们每次使用 ProGuard 建立发布构建时都都会覆盖以前版本的 mapping.txt 文件,所以咱们每次发布新版本时都必须当心地保存一个副本。经过为每一个发布构建保留一个 mapping.txt 文件副本,咱们就能够在用户提交的已混淆的 StackTrace 来对旧版本应用的问题进行调试和修复。

涨姿式的操做

通过上文的介绍,咱们知道,APK 在通过代码混淆处理后,包名、类名、成员名被转化为无心义、难以理解的名称,增长反编译的成本。Android ProGuard 为咱们提供了默认的"混淆字典",即将元素名称转为英文小写字母的形式。那么,咱们能够定义本身的混淆字典吗?卖个关子,咱们先来看一张效果图:

自定义混淆字典

这个波操做是否是有点"出类拔萃"了?哈哈,就不卖关子了,其实很简单,只要生成一套本身的 txt 格式的混淆字典,而后在混淆规则 Proguard-rules.pro 中应用一下便可:

混淆字典配置

本文中使用的混淆字典能够在此处查看并下载:proguard_tradition.txt

固然,你们也能够本身去定制化本身的"混淆字典",增长反编译的难度。

一路走下来,咱们发现,从混淆技术的必要性和优势来看,它仍是很值得咱们去深刻学习和研究的,本文带你们领略的仅仅是"冰山一角"。因为本人的技术水平有限,若你们发现有问题或者阐述不当之处,欢迎指出并修正。

相关参考

相关文章
相关标签/搜索