业界方案
在网上随便搜索一下就能发现瘦身有好多方案,但是实践一下就能发现好多都不靠谱
方案 | 作用 | 瘦身效果 |
---|---|---|
proguard | 代码混淆 | 效果明显 |
abiFilter "armeabi" | 去除其他平台so | 效果明显 |
resConfigs "zh" | 语言文件去除 | 0.1M |
shrinkResources | 无用资源去除需维护keep文件 | 1M |
TinyPng | 图片压缩,账号收费 | 3M |
ThinR | 移除R文件 | 0.3M |
AndResGuard | 资源混淆白名单维护难 | 资源混淆0.3M,7zip压缩2M |
webp | android兼容性差 | 不推荐 |
Lint 无用资源去除 | 有可能删除getIdentifier调用的资源 | 不推荐 |
redex | 安全风险高,对于加固、热修复等功能有影响 | 未实践 |
so动态加载 | 风险高,大部分so都需要实时加载 | 未实践 |
加固 | 隐藏dex | 1M |
重复资源优化 | 对比资源文件 md5,删除重复文件和resources.arsc 中的定义 |
0.2M |
移除TINY_PNG文件 | 通过android-chunk-utils把resources.arsc 中对应的定义和文件移除,风险高 |
美团文章一带而过,我实践一下,实际代码特别复杂,arsc 文件索引value要重新计算,减小0.1M都不到 |
方案实践
Smallapk Gradle插件减小APK体积25%
apply plugin: 'smallapk'
动态资源查找
其他方案网上都有,我重点讲讲SmallApk插件怎么解决getIdentifier方法带来的动态资源问题。
- ShrinkResources只能去除小部分无用资源的问题
- 解决AndResGuard需要配置白名单的问题
首先需要了解ShrinkResources的原理:
通过ResourceUseModel建立一个资源引用树,找到有可能是resource.getIdentifier调用的资源标记为reachable,找到无用资源并替换成tiny的小文件
用这种方式查找到的动态资源会特别多,因为用正则表达式匹配了所有的字符串,那么如何精确找到动态资源呢,你会发现android源码里面写着Todo,哈哈。
@Override
public void visitMethodInsn(int opcode, String owner, String name,
String desc, boolean itf) {
super.visitMethodInsn(opcode, owner, name, desc, itf);
if (owner.equals("android/content/res/Resources")
&& name.equals("getIdentifier")
&& desc.equals(
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I")) {
mFoundGetIdentifier = true;
// TODO: Check previous instruction and see if we can find a literal
// String; if so, we can more accurately dispatch the resource here
// rather than having to check the whole string pool!
}
}
那就只能自己想个方案找到getIdentifier引用的所有资源了。
先来看看效果,这个是getIdentifier的多种调用方式
这个是用SmallApk插件找到的动态资源
这个是找到的动态资源调用关系图
那么SmallApk是怎么做的呢
思路和android源码ResourceUsageAnalyzer是一样的,都是匹配字符串常量,唯一的区别就是加入了方法有向图搜索节点,排除大部分无用字符串。
首先形成调用有向图
/**
* KeepResUsageVisitor会把methodNode、constantNode、fieldNode、classNode调用关系转换成有向图
*/
class KeepResUsageVisitor extends ClassVisitor {
private String className;
public KeepResUsageVisitor() {
super(Opcodes.ASM5);
}
@Override
public void visit(int version, int access, String name, String signature,
String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
className = name;
}
@Override
public MethodVisitor visitMethod(int access, final String name,
String desc, String signature, String[] exceptions) {
String methodName = name;
return new MethodVisitor(Opcodes.ASM5) {
@Override
public void visitLdcInsn(Object cst) {
super.visitLdcInsn(cst);
if (cst instanceof String) {//常量节点
String constant = (String) cst;
GraphNode caller = new GraphNode();
caller.putClass(className);
caller.putMethod(methodName);
caller.putConstant(constant);
GraphNode called = new GraphNode();
called.putClass(className);
called.putMethod(methodName);
GraphHolder.addNode(caller, called);
}
}
@Override
public void visitFieldInsn(int opcode, String owner, String name, String desc) {
super.visitFieldInsn(opcode, owner, name, desc);//变量节点
GraphNode caller = new GraphNode();
caller.putClass(owner);
caller.putField(name);
GraphNode called = new GraphNode();
called.putClass(className);
called.putMethod(methodName);
GraphHolder.addNode(caller, called);
}
@Override
public void visitMethodInsn(int opcode, String owner, String name,
String desc, boolean itf) {//方法节点
super.visitMethodInsn(opcode, owner, name, desc, itf);
GraphNode caller = new GraphNode();
caller.putClass(className);
caller.putMethod(methodName);
GraphNode called = new GraphNode();
called.putClass(owner);
called.putMethod(name);
GraphHolder.addNode(caller, called);
}
};
}
@Override
public FieldVisitor visitField(int access, String name, String desc, String signature,
Object value) {
final String field = name;
if (value instanceof String) {//变量节点
String constant = (String) value;
GraphNode caller = new GraphNode();
caller.putClass(className);
caller.putField(field);
caller.putConstant(constant);
GraphNode called = new GraphNode();
called.putClass(className);
called.putField(field);
GraphHolder.addNode(caller, called);
}
return new FieldVisitor(Opcodes.ASM5) ;
}
}
接着找到getIdentifier的方法节点
@Override
public void call(GraphNode caller, GraphNode called) {
if (called.getClassName().equals("android/content/res/Resources")
&& called.getMethod().equals("getIdentifier")) {
if (!caller.getClassName().startsWith("android/support/v7")) {
dynamicCallGraph.add(caller);
}
}
}
然后找到所有调用getIdentifier的字符串常量
private void addCodeStrings() {
mLogPrinter.println("Dynamic String---->CodeString:");
List list = new ArrayList<>();
Set codeStrings = new HashSet<>();
for (GraphNode callGraph : dynamicCallGraph) {
Collection set = GraphHolder.findParentNode(callGraph);
if (set != null) {
for (GraphCall call : set) {
GraphNode caller = call.getCaller();
String value = caller.getConstant();
if (value != null) {
list.add(caller);
codeStrings.add(value);
}
}
}
}
}
最后匹配字符串常量找到动态资源
// getResources().getIdentifier("ic_video_codec_" + codecName, "drawable", ...)
for (Resource resource : mModel.getResources()) {
if (resource.name.startsWith(name)) {
mDynamicUsed.add(resource);
}
}
找到动态资源以后就能去解决AndResGuard和ShrinkResources的问题了
解决ShrinkResources只能去除小部分无用资源的问题,只要把找到的动态资源文件写入到/build/intermediates/res/merged/release/raw/keep.xml
中
static void writeKeepXml(Set list, File keepFile) {
if (list == null || list.size() == 0) {
return
}
StringBuffer buffer = new StringBuffer()
list.each { value ->
buffer.append(“@“ + value.type.getName() + “/“ + value.name)
buffer.append(“,”)
}
buffer.deleteCharAt(buffer.length() - 1)
def builder = new groovy.xml.StreamingMarkupBuilder()
builder.encoding = “UTF-8”
def result = builder.bind {
mkp.xmlDeclaration()
mkp.declareNamespace(‘tools’: ‘http://schemas.android.com/tools’)
resources(‘tools:shrinkMode’: ‘strict’, ‘tools:keep’: buffer)
}
def writer = new FileWriter(keepFile)
writer << result
}
解决AndResGuard需要配置白名单的问题,只要把动态资源加入到白名单就可以
Set keepResSet = new HashSet<>();
if (mDynamicUsed != null){
for (Resource resource : mDynamicUsed) {
keepResSet.add("R."+resource.type.getName()+"."+resource.name);
}
}
resproguardTask.setWhiteList(keepResSet)
你问我答
-
AndResGuard会混淆资源文件名,xml资源文件里面也使用了文件名的字符串,那为什么apk没有崩溃?
因为编译完以后布局xml文件里变成了int常量,AndResGuard修改的是字符串,int索引没变
-
proguard也会去除R文件,那为什么用ThinR还会减小包体积?
因为aar包里不存在R.class的,app打包的时候会重新生成lib库的R文件,但是因为生成lib库的class文件时R文件的变量不是final,所以aar里面是直接引用引用了lib.R.id,
然后proguard判断lib库R文件是有引用关系的不能去除,ThinR相当于接着把lib库里面的R文件删除
-
在mac上解压缩apk再压缩会去,你会发现这个apk已经没法安装了,为什么,照理说不做任何操作应该不影响apk签名呀?
因为MAC解压缩的时候会存在.DS_Store文件,直接压缩会把外面的文件夹目录也压缩进去
-
重新压缩apk以后体积会小,为什么apk自己不是压缩过了吗?
因为默认图片是不压缩的
shrinkResources不是删除了无用资源吗,那为什么我用Lint去删除无用资源,包体积还是会变小?
一个是资源问题,一个是代码问题。
资源问题:shrinkResources匹配字符串常量得到的无用资源会比较少,而lint扫描会只扫描硬静态引用资源,这样扫描的资源文件会比较多
代码问题:lint还会删掉java文件,而shrinkResources只会去除无用资源,虽然android源码里面二次打包TWO_PASS_AAPT
,但是默认没开启android gradle插件默认是开启v2签名的,为什么在我们的app里面用修改meta-inf文件的方式加入渠道号还可以运行?
因为我们先加固,然后重新v1签名,再打渠道包,运气好,刚好绕过了v2签名的坑,哈哈zipalign会影响v1签名和v2签名吗?
请在v1签名后使用zipalign,v2签名前使用zipalign,v1签名和v2签名可以同时存在,不能只用v2签名,因为在7.0手机只会校验v1签名