@Lenciel

Android开发中使用ProGuard

今天被同事问到怎么在 release 版本里面所有的日志都去掉的时候,竟然只能回忆起用ProGuard做过这个,完全忘记怎么做的了,特立此存照。文章里面使用的例子放在 Android-Maven-ProGuard-Sample-App

ProGuard简介

在移动设备上面开发应用程序,性能是一个很关键的指标。你的老板走过来要你提高性能的时候,你的第一反应有可能是抓起熟悉的工具花几个小时 profile 自己的应用,找出那些时间都花在哪里了。在使用这么终极的手段之前,千万不要忘记了先试试 ProGuard。

做 Android 之前就是 Java 程序员的可能早就已经对 ProGuard 很熟悉了。简单的来说,ProGuard 就是一个 Java 的 class 文件处理器,主要的功能类似奥运会口号:

  • 让你的程序变得更小更快
  • 让你的程序变得更难被反向工程

尽管 ProGuard 不是专用于 Android 开发的,但是在 Android 的 SDK 里面已经包括了这个工具,路径是ANDROID_HOME/tools/proguard,文档可以在http://proguard.sourceforge.net看到。

让程序变得更小更快的好处是不言而喻的。ProGuard 通过对 bytecode 进行优化,优化手段包括去掉无用的代码,去掉内联方法的调用,对类的继承结构进行优化,把所有能加上的finalstatic加上,以及对算术运算进行Peephole optimization等等。

让程序变得更难被反向工程就不一定是每个人都需要的了。一般情况下,对 Android 的反向工程是把 Dalvik 的 bytecode 转换成 Java 的 bytecode,然后使用传统的 Java 反向工具转成成 Java 源代码。如果你的项目是开源的,显然也没有必要防止别人反向。但是如果是下面几种情况,你就很可能需要它了:

  • 你在源文件里面有一些不想被别人看到的信息,如密码等
  • 你的代码里面有自己或者公司的赖以生存的知识产权
  • 你的甲方有明确的要求
  • 你的程序按 license 等方式收费,你不想被别人把 licens 检查的部分去掉重新编译个版本

ProGuard 可以帮助通过对类,方法和成员名称进行混淆,同时通过去掉结构化的信息,如文件名或者行号表等,来使得代码从理论上变得不可被反向工程。

如此看来,ProGuard 真是美好事物一枚。但是 ProGuard 也不是随手一点药到病除的,也有一点学习曲线。

启用ProGuard

如果你使用 Eclipse 的 ADT,每个新建的项目都会生成一个proguard.cfg文件在项目的根目录。你对 ProGuard 的所有设定就是在这个文件里面完成的。要想在项目里面启用 Proguard,只需要把同样在项目根目录的default.properties里面加上:

proguard.config=proguard.cfg

当然,如果你蛋疼到要自己去移动 cfg 文件的位置,也要记得去改等号后面的部分。然后,在所有的 release 版本的 build 里,你的 Proguard 就已经生效了。对于使用 Eclipse 的同学来说,release 的 build 就是指通过选择 Android Tools>Export Signed/Unsigned Application Package 来进行。

因为在大多数开发中我们都会使用 Ant 或者是 Maven 来对项目进行管理,所以一般不会直接用 Eclipse 来进行 release 版本的编译,所以通常我们还要掌握如何在不使用 ADT 的情况下使用 ProGuard。对于 Maven 而言,可以通过使用 Maven Android plugin 来完成。同时,由于 ProGuard 已经完全集成到 Android 的工具链里面了,所以 Android 的 Ant 任务里面也有一个专门的 private 任务叫做-obfuscate,会把激活并使用 ProGuard 作为 release 这个 target 的一部分,所以使用 Ant 的话只要一个 ant release 就可以了。

当 ProGuard 执行以后,会产生几个特别重要的文件:

  • mapping.txt:保存了混淆后的名字和混淆前名字的对应关系。对于每次 release 的 build,都要记得保存这个文件,要不然如果你收到 release 版本上报出的 defect 的时候,就等着哭吧。
  • seeds.txt:ProGuard 找到的你的程序的 entrypoint 列表。
  • usage.txt:ProGuard 觉得没有用所以移除了的一堆类,域和方法的 list。要想学习写作「完美」的 ProGuard 规则的同学就要经常来这个文件看看自己定下的 rule 对 ProGuard 的行为究竟有什么样的影响。如果你有用的类出现在 list 里面了,说明你削得太猛了,反之亦然。

需要注意的是这些文件的输出目录。在使用 Ant ProGuard target 的时候,输出目录是bin/proguard/,但是如果是通过 ADT(右键 project>Android Tools>Export)的话,输出目录会是proguard/

还有一个常见的困惑就是 ProGuard 是怎么找到那些需要处理的文件的。一般情况下,ProGuard 希望你用-injars或者是-libraryjars来告诉它。但是对 Android 开发而言,Ant 任务和 ADT 都会自动的查看你的libsoutput和项目的classpath目录。

从执行过程的日志来看,ProGuard 对类文件的操作分为三个步骤:shrink,optimize,obfuscate。每个步骤都是可选的,可以通过使用-dontshrink-dontoptimize-dontobfuscate来分别关掉。一般来说,不用因为结果「不如人意」就随意的关掉某个步骤。完整的进行三个步骤,然后不断的改变规则,直到达到最佳效果,是使用 ProGuard 的最佳方式。

编写ProGuard规则

ProGuard 和很多工具一样,其强大之处在于选项够多。作为 Android 开发者使用,首先心里要明白,没有一个唯一的最佳配置规则。在此基础上,去掌握一些对 Android 程序而言通常是适用的规则。然后,就像在文章里面已经反复强调过的一样,以这些规则为起点,反复的调整你的规则,找出一个对自己的程序最适用的规则。

当然,因为选项太多,ProGuard 给初学者的感觉难免是千头万绪,无从下手。因此,我们可以从一个例子程序入手来找到对 ProGuard 的「感觉」。

这个例子本身没有任何特别之处,MyButton类继承自Button但是没有添加新的方法,可以通过它来观察 ProGuard 如何对继承结构进行压缩。Click 的 handler 除开显示 toast 之外也没有特别的功能,可以通过它来观察 ProGuard 对方法名的混淆。AMPSampleActivity里面还专门有一个没有被调用的方法,可以通过它来观察 ProGuard 对这种情况的处理。下面是程序的入口 Activity 的实现:

我们期望 ProGuard 做的事情包括:

  • 保留AMPSampleActivity类,因为它是我们在 XML 里面指定的程序入口
  • 保留StringUtils类和它的repeat方法
  • 保留myClickHandler方法
  • 保留MyButton
  • 去掉unusedMethod
  • 除开 XML 里面引用的类(AMPSampleActivityMyButton),其他的类名都需要被混淆
  • 除开 XML 里面引用的方法名(myClickHandler),其他的方法名都要被混淆
  • 完成一些对 Android 而言通常适用的优化(下面会仔细展开)

ProGuard 的规则是「白名单」的,也就是说 ProGuard 只会对你特别指定的类刀下开恩。这也就是说,对任何程序,我们都至少要写一条规则,来保留程序的入口类。因为是 Android 程序,我们可以这么写:

-keep public class * extends android.app.Activity

这里我们可以看到 ProGuard 的 rule 用的语法基本上遵循了 Java 本身的语法(extends等等),但是它支持使用通配符。规则中的-keep告诉 ProGuard 不要删除也不要混淆任何从android.app.Activity继承的类。

很简单,不是吗?如果你这个时候运行程序,会看到:

org.lenciel.android/org.lenciel.android.AMPSampleActivity}:
     android.view.InflateException: Binary XML file line #6: Error inflating
     class org.lenciel.android.MyButton

为什么在 inflate 我们自定义的 view 的时候 crash 了呢?这是因为自定义的 view 是在 XML 里面被用到的,而不是在 Java 代码里面。因此 ProGuard 会认为这是没有用的代码而试着删除它。要保证这些自定义的 view 不被误删,就需要定义如下的规则:

-keepclasseswithmembers class * {
    public <init>(android.content.Context, android.util.AttributeSet);
}

-keepclasseswithmembers class * {
    public <init>(android.content.Context, android.util.AttributeSet, int);
}

这两条规则告诉 ProGuard 不要对定义了可能被LayoutInflater调用的构造函数的任何类进行优化。我们这里使用了-keepclasseswithmembers而不是-keep

再次运行,会遇到下面的错误:

java.lang.IllegalStateException: Could not find a method
    myClickHandler(View) in the activity class org.lenciel.android.AMPSampleActivity for onClick handler on
    view class org.lenciel.android.MyButton

去查看usage.txt你会发现myClickHandler又被干掉了。为什么在第一条规则里面我们告诉 ProGuard 不要动AMPSampleActivity里面的任何东西,还是会有这种情况发生?这是使用-keep的一个常见的误会。我们用-keep告诉 ProGuard 保留一个类的时候,没有提供任何类的「body」信息的话,ProGuard 仅仅会保留这个类的名字。它仍然会对这个类内部的所有东西进行优化和混淆。要保留方法,我们需要这么写:

-keep public class * extends android.app.Activity {
    methods;
}

但是这样写显然又太过于慷慨了。下面这条规则会好很多:

-keepclassmembers class * extends android.app.Activity {
    public void *(android.view.View);
}

这条规则告诉 ProGuard,如果一个Activityshrink阶段没有被去掉,那么就保留那些public的,没有返回值的,传入了android.view.View作为参数的方法。

可以看到,使用 ProGuard 存在一个不断调优的过程。他山之石,可以攻玉,已经有很多人使用 ProGuard 来优化 Android 程序了,于是也有了一些被普遍采用的规则和选项,我们下面来个简单说明。

常用规则和选项

前面看到的规则对于例子程序就足够了。但是如果我们的程序使用了Service怎么办?和Activity一样,Service也是在 manifest xml 里面定义的,因此我们需要对proguard.cfg做一定的扩展。

下面的规则是针对 Android 程序一般来说都比较有效的。

一般来说,下面的 Android framework class 都是需要保留的:

-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class com.android.vending.licensing.ILicensingService

虽然你的程序可能一开始没有使用其中的一些类,但是定义好全部这些规则也是有好处的:它可以避免你在使用 ProGuard 编出的版本 crash 之后去搞半天才发现有某个类似的规则需要更新但是你忘记了。

第二个有用的规则是保留staticCREATOR域,这个是 Android 用来 parcel 对象的。这个域由于是在运行的时候Instrospection的,所以 ProGuard 会认为它是无用的域并把它去掉。下面这条 rule 可以防止这样的事情发生:

-keepclassmembers class * implements android.os.Parcelable {
    static android.os.Parcelable$Creator CREATOR;
}

在程序中如果你调用了 native 的 code,比如你用 JNI 来调用了 c 的 lib,由于在 Java 代码里面是一份方法的签名,而没有方法的实现,它必须被链接到 native code 上。这也就意味着这些函数名不能被 ProGuard 加以混淆了,不然链接的过程就会失败。下面的规则可以保证 ProGuard 不去动 native 的方法名:

-keepclasseswithmembernames class * {
     native methods;
}

我们这里使用的-keepclasseswithmembernames是告诉 ProGuard,被调用过的方法留着,没有调用过的都去掉。

前面的规则看起来都一目了然。下面这个可能要费解一些:

-keepclassmembers enum * {
     public static **[] values();
     public static ** valueOf(java.lang.String);
}

这个规则是让 ProGuard 不要去动任何EnumvaluesvalueOf方法。这些方法之所以特殊是因为 Java 自己是通过发射机制来调用它们的。这可能也是 Google不建议使用Java enum 的原因吧:它们比final class fields的性能要低不少。如果你已经遵照 Google 的教诲停止使用Enum,那你也不需要这条规则了,恭喜。

下面来看看常用的选项:

-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-dontpreverify
-verbose

第一个选项可以避免像 Windows 这样不区分大小写的操作系统不会因为类似A.classa.class写到同一个文件里面就驾崩。

第二个选项是因为 ProGuard 默认不会处理任何非 public 的类。但是有时候我们会遇到 public 的类继承自内部的非 public 的类。所以打开这个选项可以更好的覆盖。

第三个选项是告诉 ProGuard 不要做preverify(预检验),因为这个只对 J2ME 或者是 Java6 的平台有用。

最后一个选项,你们懂的。

前面我们提到过 ProGuard 有一个优化代码(optimize)的过程。大多数时候 ProGuard 都会火力全开的对所有的代码做优化。这些优化操作有些时候是相当 aggressive 的,比如合并类的时候 ProGuard 会试着既从纵向上合并也从横向上合并,以便得到尽量少的类文件,也就可以得到尽量小的 APK。同时它还会试着优化循环和代数运算。默认的 ADT 生成的 ProGuard 选项关掉了很多的优化选项:

-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*

Google 并没有提供他们这么配置的依据。我们可以试着先禁用这个选项,看看程序运行起来会不会有问题。如果遇到了问题再试着慢慢的减弱优化,来「探底」。

同时 ProGuard 的优化是可以「递归」的,也就是优化完的结果可以作为下次优化的输入继续优化。你可以指定它反复进行多少次。但 ProGuard 如果发现已经没有什么可以优化,会自动停下来,不一定跑到你指定的次数。一般设置成 5 就够了:

-optimizationpasses 5

如果处理混淆后版本的错误报告

如果你发布了混淆的版本,有一个问题你就得面对:用户提交的问题单里面产生自这些类和方法都完全打乱过后的版本。为了展示这种问题,在 Demo 程序里面专门加了这么一个类:

public class Bomb {
    public void explode() {
        throw new RuntimeException("Boom!");
    }
}

在 onCreate 方法里面它会被引爆:

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);
    String toast = StringUtils.repeat("Hello ProGuard! ", 3);
    Toast.makeText(this, toast, Toast.LENGTH_SHORT).show();
    new Bomb().explode();
}

如果你运行程序,就会看到下面的错误:

java.lang.RuntimeException: Unable to start activity ...MainActivity}:
java.lang.RuntimeException: Boom!
...
Caused by: java.lang.RuntimeException: Boom!
   at org.lenciel.android.MainActivity.onCreate(Unknown Source)
   at android.app.Instrumentation.callActivityOnCreate(
    Instrumentation.java:1047)
   at android.app.ActivityThread.performLaunchActivity(
    ActivityThread.java:2627)
   ... 11 more

可以看到在错误出现位置的 stack trace 既没有行号也没有文件名。这是因为相关的信息都被 ProGuard 优化掉了。如果我们想避免这种情况,就要在proguard.cfg里面加上下面的选项:

-keepattributes SourceFile,LineNumberTable

显然,有了行号和文件名,还是解决不了方法被混淆的问题。我们这个例子程序里面方法很少,而onCreate方法因为是Override的,所以 ProGuard 不会去动它。如果是正式的工程,最好的办法还是用retrace工具来根据mapping.txt还原整个日志。

$ retrace proguard/mapping.txt stacktrace.txt

Plan for 2012

Don't touch me

在读过《把时间当作朋友》和Matt Might的文章之后,回首 2011 就让人觉得莫名慌张起来。按《把时间当作朋友》这本书里所说,定计划要以周为单位,通过培养自己对时间的控制力,逐步把计划做到月甚至年这样的跨度。本座自觉还算比较能控制自己,所以打算制作一个基本单位为月的计划,下面的十项活动将穿插在这个计划之中:

  1. 一点新知识
  2. 一砣好身体
  3. 一个新玩具
  4. 一批新兴趣
  5. 一门新语言
  6. 一款新软件
  7. 一马新项目
  8. 一把新文艺

一点新知识

用进废退,数理化史地生音体美忘记了就算了吧。但半路出家的本座,还有很多基础知识也非常单薄。准备了 6 本不那么理论的书,争取能用 2 个月一本的龟速精读:

一砣好身体

关注自己的健康状况,保持适当的体育锻炼,既是对自己负责,也是对家人负责。2012 年体育锻炼方面的发展目标是除开足球之外,要开拓可以和偶像派互动的项目。下面的四个项目,12 个月都可以开展,但是可以根据气候的不同,在四个季度各有偏重:

  • 跳绳
  • 自行车
  • 跑步
  • 羽毛球

另外,程序员这个职业带来的伤害常常是RSI。本座一直有比较严重的脖子疼和曾经越来越严重的肩膀和手腕疼。通过了解 RSI,保持良好的姿势以及定时起来活动,比较好的缓解了疼痛。但是用电脑有时候是一屁股下去就不好起来的。让人欣喜的,传说中的Aeron在中国也能买到了。所以明年还有一个目标:

一个新玩具

在回成都之后第一年在 E 公司主要的玩具是那套收星的玩意儿。原来只要大概 800 块钱左右的成本(DM500 和所有的分路器合路器 F 头都是在这家淘宝店买的),就可以用 60CM 的锅收134/138/146 三颗 ku 星。

接下来移籍 M 公司,主要的玩具似乎变成了 Android。但是后来机缘巧合开始接触了各式各样的监控设备。说实在的从前端的 IP 化的云台枪机,到 DVR,到最后的矩阵上墙,里面还是有很多挺有趣的技术。而且像中国这么大一个工地,到处都要装监控,这里面商机也不小。

明年如果能说动偶像派买个 LEGO 的MindStorm当然是最好,如果失败,就入手最近红到爆的Arduino,话说本座已经堆了很多这方面的书了都没有真正花时间去看过。可是作为控制系的学生,每次看到它们,都有难以抑制的原始冲动。这种东西,一年玩一个就足够了:

一批新兴趣

编程的很多道理你不一定只能在编程中学到,因为人类最先进的学习方法是类比。

而且,每天坐在电脑前面的老公在老婆和其他亲属心中的仇恨值是很高的。下面是一批本座觉得有可能进化为兴趣爱好并能够得到偶像派的广泛赞许和普遍接受的备选项目。仍然是均分到各个月份,但每个季度有倾斜的加大某项的比重:

  • 摄影
  • 画画
  • 写毛笔字
  • 厨艺

一门新语言

程序员之所以被认为是个苦逼的职业,很重要的原因就是技术更新换代很快。去年看完《Seven Languages in Seven Weeks》之后本座就有计划学一下 Haskell 和 Scala,但是去了一趟杭州拿到冰河翻译的新书之后本座把明年的目标换成了下面两个,每个半年:

一款新软件

人的创造力需要反复而大量的的基础知识和实作经验垫底,而基础知识和实作经验往往蕴藏在那些已经被时间验证过的作品里。所以要想拍好电影得看很多好电影,要想写好书得看很多好书,要想写出好软件得用很多好软件。在使用别人的软件时,可以想想如果是自己来写,什么地方做得比它好,什么地方还需要像别人学习。暂定的目标有:

一马新项目

写软件谋生的好处是不言而喻的,坏处是如果你老是只为了工资奖金写软件,慢慢的你就会感觉到「无趣」。无趣的根源很多,最主要的无非「人家想用 xyz,但是只能用 zyx」。其实有GithubBitbucket之后,这些都是借口啊,亲。项目内容暂时保密。

一把新文艺

还在读研究生的时候(也是 J2EE 最红火的时候?),本座发现一件「奇闻「:Spring的作者 Rod Johnson 说之所以自己能写出拨乱反正的《Expert One-on-one》和Spring,和他学习过的音乐知识是分不开的。Google 了一下,原来他除开有计算机学位还是个音乐学博士。

之后稍微留心就发现,这是一个普遍的现象。比如那个写微积分教材赚的钱就可以建 2400w 刀豪宅的音乐家James Stewart,再比如 2011 年最被关注的去世者Steve Jobs。有点儿文艺细胞,能从人性的角度思考,做出的产品往往比纯粹的工程产品要打动人心。

本座挺佩服自己能找出这么一个伟大的理由,得以在新的一年里继续毫无节制的:

  • 看电影
  • 读小说
  • 听音乐

要顺利贯彻执行计划,总得有个考核标准。打算今后多写一点儿 blog 记录在案,不然也对不起本座 A 给酋长的银子啊:

Don't touch me