@Rays
2017-10-19T11:22:45.000000Z
字数 8191
阅读 2532
语言开发
摘要: 如何瘦身是APK的重要优化技术。APK在安装和更新时都需要经过网络下载到设备,APK越小,用户体验越好。本文作者通过对APK内在机制的详细解析,给出了对APK各组成成分的优化方法及技术,并实现了一个基本APK的最小化过程。
正文:
高尔夫运动中,分数最小者胜出。
让我们将这一原则应用到Android App开发中。我们将玩转一个称为“ApkGolf”的APK,目的是创建一个尽可能具有最少字节数的App,并可安装在运行Oreo的设备上。
一开始,我们用Android Studio生成一个缺省的App,创建密钥库(Keystore)并对App签名,然后使用命令stat -f%z $filename
测定生成APK文件的字节数大小。
进一步,为确保该APK工作正常,我们将在一台运行Oreo的Nexus 5x手机上安装它。
看上去挺漂亮。但是现在我们的APK大小近乎1.5Mb。
考虑到我们App的功能非常简单,1.5Mb的规模看上去过于臃肿了。因此,我们要深入了解一下该项目,看看是否有一些能立竿见影地削减文件大小的地方。Android Studio生成了:
AppCompatActivity
而得到的MainActivity
;ConstraintLayout
的布局文件;AppCompat
和ConstraintLayout
的支持库;AndroidManifest.xml
文件;看上去首当其冲的目标是启动图标文件,因为APK中共包含了15个图像文件,并且在mipmap-anydpi-v26
下还有两个XML文件。下面,让我们使用Android Studio的APK Analyser对该APK文件做一个定量分析。
给出的结果与我们的最初假设大相径庭,其中显示Dex文件是大头,而上述资源仅占APK大小的20%。
文件 | 大小占比 |
---|---|
classes.dex | 74% |
res | 20% |
resources.arsc | 4% |
META-INF | 2% |
AndroidManifest.xml | <1% |
下面让我们逐个分析每个文件的行为。
看上去罪魁祸首是classes.dex
文件,它占据了73%的空间,因而它成为我们的首要削减目标。该文件为Dex格式,其中包含了我们的全部编译后代码,以及对Android框架和支持库中外部方法的引用。
然而android.support
软件包中引用了超过13000种的方法,对于一个简单的“Hello World”App而言,完全没有必要。
目录“res”中包含了大量的布局(Layout)文件、Drawable和动画,它们并非在Android Studio UI中立刻可见。同样,它们也是由支持库推入其中的,约占APK规模的20%。
在resources.arsc
文件中,还包含了对每个资源的引用。
目录“META-INF
”中包含有CERT.SF
、MANIFEST.MF
和CERT.RSA
文件,这些文件都需要v1 APK签名。如果有攻击者修改了我们APK中的代码,签名就会不匹配。这一机制保障了用户能避免执行第三方恶意软件的风险。
在MANIFEST.MF
文件中列出了APK中的所有文件。其中,CERT.SF
文件中包含了文件清单的摘要,以及每个文件的独立摘要。CERT.RSA
文件中包含了一个公钥,用于验证CERT.SF
文件的完整性。
在签名文件中,没有目标明显可优化。
看上去AndroidManifest
文件非常类似于我们的原始输入文件。唯一差别在于,文件中的字符串和Drawable等资源被整数资源ID所替代,这些ID以0x7F
开头。
我们尚未在App的build.gradle
文件中设置允许最小化(Minification)和资源收缩(Resource Shrinking)。我们现在做此设置:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android.txt'), 'proguard-rules.pro'
}
}
}
-keep class com.fractalwrench.** { *; }
将minifyEnabled
属性设置为“true”值,这将启用Proguard,该功能将从App中剥离出那些未使用的代码,并对符号的名称做模糊化处理,使得App难以被反向工程。
设置shrinkResources
属性,将会在APK中移除任何并非直接引用的资源。这时如果我们使用反射机制间接地访问资源,就会导致问题,但是本文给出的App并不存在这样的问题。
我们已经实现了APK规模减半,并未对我们的APP有任何可见的影响。
对于那些尚未在App中启用minifyEnabled
和shrinkResources
的开发人员,这是本文给出的最需要重视的并应学会的技巧。他们仅花费数小时做配置和测试,就能轻松地削减数兆的规模。
现在classes.dex
文件已削减到占用APK的57%。在我们的Dex文件中,大多数方法引用属于android.support
软件包,因此我们将要去除该支持库。具体做法为:
build.gradle
中彻底清除依赖块。
dependencies {
implementation 'com.android.support:appcompat-v7:26.1.0'
implementation 'com.android.support.constraint:constraint-layout:1.0.2'
}
MainActivity
,以扩展android.app.Activity
。
public class MainActivity extends Activity
TextView
。
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="Hello World!" />
styles.xml
文件,并从AndroidManifest
文件的<application>
元素中移除android:theme
属性。colors.xml
文件。天哪,我们刚刚实现了近十倍的削减,即从786Kb削减到108Kb。唯一可见的更改是工具条(Toolbar)的颜色,现在它使用了缺省的OS主题。
目录“res”现在占用APK规模约95%,原因是所有的加载图标。如果这些PNG图片是由我们自己的设计师所给出的,那么我们可以尝试将它们转换为WebP格式,该格式更加高效,并被API 15及以上所支持。
幸运的是,Google已经优化了我们的Drawable。即便没有这种优化,ImageOptim也可优化PNG并从中剥离不必要的元数据。
让我们当一次坏人,将我们所有的加载图标替换为单一的单像素黑点,并置于未验证的res/drawable
目录中。图片大小约67个字节。
我们已经移除了几乎全部的资源,因此毫不奇怪APK规模已经削减了约95%。但是resources.arsc
依然引用了如下项:
让我们从第一项着手。
Android框架会膨胀我们的XML文件,并自动创建一个TextView
对象,用于Activity
对象的contentView
。
我们可以尝试一些跳过中间的过程,具体做法是移除XML文件,并使用程序设置contentView
。这样会降低资源的规模,因为我们减少了一个XML文件。但是Dex文件将会增大,因为我们引用了额外的TextView
方法。
TextView textView = new TextView(this);
textView.setText("Hello World!");
setContentView(textView);
让我们查看一下这一权衡做法的工作情况,它削减了5710个字节。
下面我们将删除strings.xml
文件,并将AndroidManifest
中的android:label
属性值更改为“A”。这看上去是一个小更改,但是它从resources.arsc
中删除了一项,削减了Manifest文件中的字符数,并从“res”目录中移除了一个文件。略有裨益,我们削减了228个字节。
Android Platform代码库中的resources.arsc
的文档告诉我们,APK中的每个资源通过resources.arsc
中的一个整数ID引用。这些ID具有两个命名空间(Namespace):
0x01: 系统资源(预装在framework-res.apk中);
0x7f: 应用资源(捆绑在应用的.apk文件中)。
那么如果在0x01
命名空间中引用了一个资源,我们的APK发生了什么?我们应该可以在削减文件规模的同时,得到一个更漂亮的图标。
android:icon="@android:drawable/btn_star"
虽然文档是这样说的,但是在一个生产App中,我们应该保持“永远不要信任系统资源”这一原则。该步骤会导致Google Play验证失败,而且考虑到我们知道某些制造商已经重定义了白色,因此在具体操作时需要慎重。
目前为止,我们尚未对Manifest文件下手。
android:allowBackup="true"
android:supportsRtl="true"
移除这些属性将会削减48个字节。
看上去Dex文件中依然包括BuildConfig
和R
。
-keep class com.fractalwrench.MainActivity { *; }
如果我们精炼Proguard规则,就会清除掉这些类。
现在对我们的Activity
赋予一个混淆后的名字。对于正常类,Proguard可自动实现混淆功能,但是考虑到Activity
类名会通过Intents
唤醒,因此缺省情况下不要混淆Activity
的名字。
MainActivity -> c.java
com.fractalwrench.apkgolf -> c.c
当前在App签名中,我们使用了v1和v2签名。看上去这完全是浪费,尤其是v2会对整个APK做哈希,提供了更高级的保护能力和性能。
在APK Analyser中,v2签名并不可见,因为它在APK文件本身中以二进制块的形式存在。v1签名是可见的,它是以CERT.RSA
and CERT.SF
文件的形式给出。
Android Studio UI中提供了v1签名的复选框,我们需要去除该选择,并生成一个签名的APK。我们也需要做相反的过程。
签名 | 大小(字节) |
---|---|
v1 | 3511 |
v2 | 3307 |
看上去从此以后我们使用的是v2。
现在我们要手工编辑我们的APK了。我们将使用如下命令:
# 1. 创建一个未签名的APK。
./gradlew assembleRelease
# 2. 解压缩归档文件。
unzip app-release-unsigned.apk -d app
# 对文件进行编辑。
# 3. 压缩归档文件
zip -r app app.zip
# 4. 运行zipalign。
zipalign -v -p 4 app-release-unsigned.apk app-release-aligned.apk
# 5. 使用v2签名运行apksigner。
apksigner sign --v1-signing-enabled false --ks $HOME/fake.jks --out signed-release.apk app-release-unsigned.apk
# 6. 验证签名。
apksigner verify signed-release.apk
此链接详细概述了APK签名过程。总而言之,gradle生成了一个未签名的归档文件,zipalign更改了未压缩资源的字节对齐方式,用于改进加载APK时的RAM使用,最后APK将被加密签名。
未签名且未对齐的APK大小为1902字节,这意味着签名和对齐过程增加了约1 Kb。
很奇怪!我们对未对齐的APK解压缩并手工签名,并手动移除了META-INF/MANIFEST.MF
,这削减了543字节。如果有人知道原因,请告诉我!
现在我们的签名APK中只有三个文件,当然还可以去除resources.arsc
,因为我们并未定义任何资源!
这将使我们仅保留Manifest和classes.dex
文件,两个文件大小相当。
让我们将剩余的字符串都更改为‘c’,更新版本为26,然后生成一个签名的APK。
compileSdkVersion 26
buildToolsVersion "26.0.1"
defaultConfig {
applicationId "c.c"
minSdkVersion 26
targetSdkVersion 26
versionCode 26
versionName "26"
}
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="c.c">
<application
android:icon="@android:drawable/btn_star"
android:label="c"
>
<activity android:name="c.c.c">
这将削减9个字节。
尽管文件中的字符数并未改变,但是我们更改了‘c’字符的频次。这使得压缩算法可以进一步降低文件的大小。
通过移除```Activity````的Launch Intent Filter,我们可以进一步优化Manifest。此后,我们将使用如下命令加载App:
adb shell am start -a android.intent.action.MAIN -n c.c/.c
下面给出新的Manifest文件:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="c.c">
<application>
<activity
android:name="c"
android:exported="true" />
</application>
</manifest>
我们还移除了加载图标。
我们最初需求是生成一个可安装在设备上的APK。现在是运行“Hello World”的时候了。
我们的App引用了TextView
、Bundle
和Activity
中的方法。通过移除Activity
,并替换为用户定义的Application
类,我们可以进一步削减Dex文件大小。现在我们的Dex文件应该仅引用了单一的方法,即Application
的构造函数。
现在我们的源文件如下:
package c.c;
import android.app.Application;
public class c extends Application {}
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="c.c">
<application android:name=".c" />
</manifest>
我们可以使用adb验证该APK是可以成功安装的,也可以通过Setting App做验证。
在此次优化中,我花费了多个小时研究Dex文件格式,意在了解诸如校验码和偏移量等各种机制,它们是手工编辑文件中的难点。
但是长话短说,被我证实的是,只要存在classes.dex
文件,APK文件就能安装。因此,只要简单地删除原始文件并在终端运行touch classes.dex
,使用这一空文件就能获得近10%的规模削减。
有时看上去最愚蠢的方法反而最有效。
非签名APK中的Manifest文件是二进制的XML格式,该格式看上去并没有官方的文档。我们可以使用HexFiend编译器去修改文件内容。
我们可以猜测出位于文件头部的数个感兴趣项。头四个字节编码了38
,是与Dex文件所使用的版本相同。随后的两个字节编码为660
,这无疑是文件的大小。
下面,我们尝试通过设置targetSdkVersion为1
并更新文件大小头部为659
,去删除一个字节。不幸的是,Android系统拒绝了这个非法的APK,因此看上去这里另有玄机。
下面我们让我们对整个文件输入虚字符,然后在不更改文件大小的情况下尝试安装APK。这将确定校验码是否发挥作用,以及更改是否使得文件头部的偏移值失效。
令人惊奇的是,下图的Manifest文件被解释为一个有效的APK,可运行在运行Oreo的Nexus 5X手机上:
我想我听到了负责维护BinaryXMLParser.java
的Android Framework工程师对着枕头在大声尖叫。
为最大化收益,我们将使用空字节(Null)替换这些虚字符。这可使简化使用HexFiend查看文件的重要部分,也将使前期的压缩破解可削减一些字节。
下图给出了一些Manifest文件中的重要成分。如果没有这些成分,APK将会安装失败。
一些事情即刻是很明显的,例如Manifest文件和软件包标记。在字符串池中还可以找到软件包名称和versionCode。
以十六进制查看文件可显示文件头部的值,这些值描述了字符串池及其它值,例如0x9402
是文件的大小。字符串也具有一种有意思的编码。如果字段超出了8个字节,它们的总长度将在随后的两个字节中指定。
但是,看上去我们并不能从中做更进一步的削减。
让我们查看一下最终的APK。
终归,我们使用v2签名在APK中留名。让我们创建一个利用压缩破解的新密钥库。
这可削减20个字节。
现在的1757
个字节是相当的小。据我所知,这是最小的现有APK。
但是我完全有理由确信,Android社区中会有人能再做进一步的优化,并打破我的记录。如果你设法打破了1757
个字节的记录,请向这个放置最小APK的代码库发送一个PR,或者通过Twitter联系我。
我希望读者能喜欢这一了解Android APK内在机制的过程。如果有任何问题、反馈或是想对我的撰写主题给出建议,请通过Twitter联系我!
查看英文原文: Playing APK Golf: Reducing an Android APK's size by 99.99%