版权声明

1.本文版权归原作者所有,转载需注明作者信息及原文出处。

2.本文作者:赵裕(vimerzhao),永久链接:https://github.com/vimerzhao/vimerzhao.github.io/blob/master/android/2020-01-17-opt-apk-size-by-remove-debuginfo.md

3.作者公众号:V大师在一号线 。联系邮箱:vimerzhao@foxmail.com


目录:


背景

目前Android安装包的优化方法论比较成熟,比如

  • 混淆代码(Proguard、AndResGuard)
  • 移除不在使用的代码和资源
  • 对于音频、图片等使用更轻量的格式
  • 等等

这些方法都比较常规,在项目成熟后优化的空间也比较有限。以应用宝为例,目前(2020年1月)项目代码中Java文件8040个,代码行数约143万行,最终生成的 release包 9.33M。可以优化的空间极为有限,而且由于维护较差,分析已经废弃的代码和资源其实非常耗时耗力。本文的方案可以使应用宝在现有基础上立刻减少约 700k 安装包大小,收益十分可观,而且对于一个项目, 代码量越大,效果越明显

原理

我们在开发中经常会去看Crash日志来定位问题,如下:

W/System.err: java.lang.NullPointerException
W/System.err: at b.a.a.a.a(Test.java:26)
W/System.err: at com.tencent.androidfactory.MainActivity.onCreate(MainActivity.java:15)
W/System.err: at android.app.Activity.performCreate(Activity.java:7458)
W/System.err: at android.app.Activity.performCreate(Activity.java:7448)
W/System.err: at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1286)
......

然后通过 map 文件找到 对应的类:

com.tencent.androidfactory.Test -> b.a.a.a:
14:14:void <init>() -> <init>
23:69:void test() -> a
16:17:void log() -> b
20:21:void log1() -> c

这里为什么要把类名和方法名用杂乱无章的字符代替呢?一方面原因就是后者 更精简,在dex文件中占用的空间更小,但这不是今天讨论的重点。上面Crash信息的后面指明了Crash的具体位置是第 26 行,这是不是说明了dex存在一些信息,指明了字节码位置到源代码位置的信息,进一步,我们是不是可以参考上面混淆之后通过map映射找到原来的类的做法,把字节码位置到源代码位置的信息作为map存在本地!!

这里需要指出Dex文件存在一个 debugItemInfo 的区域,记录了指令集位置到行号的信息,正是因为这些信息的存在,我们才能做单步调试等操作,这也是这个命名的由来。

以应用宝为例,130万行代码都要保存这个映射信息的话其实占用的空间是很大的(也就是上面优化掉的那部分)。

其实,优化掉这部分信息已经有一些工具支持了:

  • Proguard工具开启优化后默认不保留这个信息,除非设置了 -keepattributes LineNumberTable
  • facebook的 redex 也有类似功能,配置drop_line_numbers
  • 最近字节跳动开源了一个ByteX,也有类似能力,配置 deleteLineNumber

蚂蚁金服的支付宝 App 构建优化解析:Android 包大小极致压缩也直接提到了这种做法,但问题也很明显、很严重,会丢失行号信息(低版本都是-1,高版本是指令集位置),导致Crash无法排查,此外,每个版本也需要做兼容,但是该文章并未详细描述,本文正是填补这部分空白。

实现

首先,Java层的Crash上报都是通过自定义 Thread.setDefaultUncaughtExceptionHandleruncaughtException(Thread thread, Throwable throwable) 方法实现的,下面以Android 4.4的源码为例,分析下底层原理。

Throwable的每个构建函数都有一个 fillInStackTrace();调用,具体逻辑如下:

/**
* Records the stack trace from the point where this method has been called
* to this {@code Throwable}. This method is invoked by the {@code Throwable} constructors.
*
* <p>This method is public so that code (such as an RPC system) which catches
* a {@code Throwable} and then re-throws it can replace the construction-time stack trace
* with a stack trace from the location where the exception was re-thrown, by <i>calling</i>
* {@code fillInStackTrace}.
*
* <p>This method is non-final so that non-Java language implementations can disable VM stack
* traces for their language. Filling in the stack trace is relatively expensive.
* <i>Overriding</i> this method in the root of a language's exception hierarchy allows the
* language to avoid paying for something it doesn't need.
*
* @return this {@code Throwable} instance.
*/
public Throwable fillInStackTrace() {
if (stackTrace == null) {
return this; // writableStackTrace was false.
}
// Fill in the intermediate representation.
stackState = nativeFillInStackTrace();
// Mark the full representation as in need of update.
stackTrace = EmptyArray.STACK_TRACE_ELEMENT;
return this;
}

其中,stackState就包含了指令集位置信息(the intermediate representation),该对象会被传递给下面的natvie方法解出具体行号:

/*
* Creates an array of StackTraceElement objects from the data held
* in "stackState".
*/
private static native StackTraceElement[] nativeGetStackTrace(Object stackState);

于是我们的思路就是 Hook住上报的位置,通过反射拿到指令集位置,在Crash上报前把指令集位置赋值给无意义的行号(理论上高版本在没有debugItemInfo时,已经默认是指令集位置而不是-1了)。这里比较坑的是 stackState的类型,其定义如下:

/**
* An intermediate representation of the stack trace. This field may
* be accessed by the VM; do not rename.
*/
private transient volatile Object stackState;

在不同版本上,该对象的数据类型都不一样。

4.0(华为畅玩4C,版本4.4.4):

5.0(华为P8 Lite,版本5.0.2):

6.0(三星GALAXY S7,版本6.0.1):

7.0+(三星GALAXY C7,版本7.0)

这里是第一个比较坑的地方,有的是int数组,有的是long数组,有的是Object数组的第一/最后一项,而且指令集位置有的在一起,有的是间隔的,确实比较坑,需要适配兼容。

8.0

8.0有一个问题,异常处理系统初始化时会执行如下逻辑:

// 代码版本:Android8.0,文件名称:RuntimeInit.java
protected static final void commonInit() {
if (DEBUG) Slog.d(TAG, "Entered RuntimeInit!"); /*
* set handlers; these apply to all threads in the VM. Apps can replace
* the default handler, but not the pre handler.
*/
Thread.setUncaughtExceptionPreHandler(new LoggingHandler());
Thread.setDefaultUncaughtExceptionHandler(new KillApplicationHandler()); ......
}

其中 Thread.setUncaughtExceptionPreHandler(new LoggingHandler()); 是该版本新增的,会在uncaughtException 之前调用,LoggingHandler 会导致Throwable#getInternalStackTrace被调用,该方法逻辑如下:

/**
* Returns an array of StackTraceElement. Each StackTraceElement
* represents a entry on the stack.
*/
private StackTraceElement[] getInternalStackTrace() {
if (stackTrace == EmptyArray.STACK_TRACE_ELEMENT) {
stackTrace = nativeGetStackTrace(stackState);
stackState = null; // Let go of intermediate representation.
return stackTrace;
} else if (stackTrace == null) {
return EmptyArray.STACK_TRACE_ELEMENT;
} else {
return stackTrace;
}
}

因此,8.0以上版本在Hook默认的 UncaughtExceptionHandler 时,stackState信息已经丢失了!!我的解决办法是 反射Hook掉Thread#uncaughtExceptionPreHandler 字段,使 LoggingHandler 被覆盖

但是在9.0会有以下错误

Accessing hidden field Ljava/lang/Thread;->uncaughtExceptionPreHandler:Ljava/lang/Thread$UncaughtExceptionHandler; (dark greylist, reflection)
java.lang.NoSuchFieldException: No field uncaughtExceptionPreHandler in class Ljava/lang/Thread; (declaration of 'java.lang.Thread' appears in /system/framework/core-oj.jar)
at java.lang.Class.getDeclaredField(Native Method)
at top.vimerzhao.testremovelineinfo.ExceptionHookUtils.init(ExceptionHookUtils.java:18)
......

通过类似FreeReflection目前可以突破这个限制,因此Android 9+ 的机型依然可以使用这个方案。

深入

这里再详细介绍下底层获取行号的逻辑,首先Throwable 会调用到一个native方法(这里的注释信息讲的很清楚,注意看):

//http://androidxref.com/4.4_r1/xref/dalvik/vm/native/dalvik_system_VMStack.cpp

/*
* public static int fillStackTraceElements(Thread t, StackTraceElement[] stackTraceElements)
*
* Retrieve a partial stack trace of the specified thread and return
* the number of frames filled. Returns 0 on failure.
*/
static void Dalvik_dalvik_system_VMStack_fillStackTraceElements(const u4* args,
JValue* pResult)
{
Object* targetThreadObj = (Object*) args[0];
ArrayObject* steArray = (ArrayObject*) args[1];
size_t stackDepth;
int* traceBuf = getTraceBuf(targetThreadObj, &stackDepth); if (traceBuf == NULL)
RETURN_PTR(NULL); /*
* Set the raw buffer into an array of StackTraceElement.
*/
if (stackDepth > steArray->length) {
stackDepth = steArray->length;
}
dvmFillStackTraceElements(traceBuf, stackDepth, steArray);
free(traceBuf);
RETURN_INT(stackDepth);
}

该方法计算行信息的是dvmFillStackTraceElements:

// http://androidxref.com/4.4_r1/xref/dalvik/vm/Exception.cpp

/*
* Fills the StackTraceElement array elements from the raw integer
* data encoded by dvmFillInStackTrace().
*
* "intVals" points to the first {method,pc} pair.
*/
void dvmFillStackTraceElements(const int* intVals, size_t stackDepth, ArrayObject* steArray)
{
unsigned int i; /* init this if we haven't yet */
if (!dvmIsClassInitialized(gDvm.classJavaLangStackTraceElement))
dvmInitClass(gDvm.classJavaLangStackTraceElement); /*
* Allocate and initialize a StackTraceElement for each stack frame.
* We use the standard constructor to configure the object.
*/
for (i = 0; i < stackDepth; i++) {
Object* ste = dvmAllocObject(gDvm.classJavaLangStackTraceElement,ALLOC_DEFAULT);
if (ste == NULL) {
return;
} Method* meth = (Method*) *intVals++;
int pc = *intVals++; int lineNumber;
if (pc == -1) // broken top frame?
lineNumber = 0;
else
lineNumber = dvmLineNumFromPC(meth, pc); ......
/*
* Invoke:
* public StackTraceElement(String declaringClass, String methodName,
* String fileName, int lineNumber)
* (where lineNumber==-2 means "native")
*/
JValue unused;
dvmCallMethod(dvmThreadSelf(), gDvm.methJavaLangStackTraceElement_init,
ste, &unused, className, methodName, fileName, lineNumber); ......
dvmSetObjectArrayElement(steArray, i, ste);
}
}

由此可知,默认行号可能是0,否则通过 dvmLineNumFromPC 获取具体信息:

//http://androidxref.com/4.4_r1/xref/dalvik/vm/interp/Stack.cpp

/*
* Determine the source file line number based on the program counter.
* "pc" is an offset, in 16-bit units, from the start of the method's code.
*
* Returns -1 if no match was found (possibly because the source files were
* compiled without "-g", so no line number information is present).
* Returns -2 for native methods (as expected in exception traces).
*/
int dvmLineNumFromPC(const Method* method, u4 relPc)
{
const DexCode* pDexCode = dvmGetMethodCode(method); if (pDexCode == NULL) {
if (dvmIsNativeMethod(method) && !dvmIsAbstractMethod(method))
return -2;
return -1; /* can happen for abstract method stub */
} LineNumFromPcContext context;
memset(&context, 0, sizeof(context));
context.address = relPc;
// A method with no line number info should return -1
context.lineNum = -1; dexDecodeDebugInfo(method->clazz->pDvmDex->pDexFile, pDexCode,
method->clazz->descriptor,
method->prototype.protoIdx,
method->accessFlags,
lineNumForPcCb, NULL, &context); return context.lineNum;
}

由此可知,默认行号还可能是-2/-1,而 dexDecodeDebugInfo 里面就是具体的解析信息了,不做深入分析(太复杂了,给看懵逼了~)。

效果

以一台Android6.0的魅族为例,我的Demo部分日志如下:

01-14 10:17:42.525 845-868/? I/ExceptionHookUtils: succeed [28, 12, 12, 5, 6]
01-14 10:17:42.525 845-868/? I/ExceptionHookUtils: set top.vimerzhao.testremovelineinfo.a from -1 to 28
01-14 10:17:42.526 845-868/? I/ExceptionHookUtils: set top.vimerzhao.testremovelineinfo.a from -1 to 12
01-14 10:17:42.526 845-868/? I/ExceptionHookUtils: set top.vimerzhao.testremovelineinfo.a from -1 to 12
01-14 10:17:42.526 845-868/? I/ExceptionHookUtils: set top.vimerzhao.testremovelineinfo.MainActivity$a from -1 to 5
01-14 10:17:42.526 845-868/? I/ExceptionHookUtils: set java.lang.Thread from 818 to 6

通过 dexdump 工具可以导出一个行号到指令集位置的map文件,部分信息如下:

  Virtual methods   -
#0 : (in Ltop/vimerzhao/testremovelineinfo/MainActivity$a;)
name : 'run'
type : '()V'
access : 0x0001 (PUBLIC)
code -
registers : 2
ins : 1
outs : 1
insns size : 9 16-bit code units
catches : (none)
positions :
0x0000 line=17 // 这里小于且最接近5
0x0008 line=18
locals :
0x0000 - 0x0009 reg=1 this Ltop/vimerzhao/testremovelineinfo/MainActivity$a;
source_file_idx : 0 ()
... Virtual methods -
#0 : (in Ltop/vimerzhao/testremovelineinfo/a;)
name : 'a'
type : '()V'
access : 0x0001 (PUBLIC)
code -
registers : 3
ins : 1
outs : 2
insns size : 21 16-bit code units
catches : (none)
positions :
0x0000 line=15
0x0007 line=16
0x000c line=17 // 这里小于且最接近12
0x000f line=18
0x0014 line=19
locals :
0x0000 - 0x0015 reg=2 this Ltop/vimerzhao/testremovelineinfo/a;
#1 : (in Ltop/vimerzhao/testremovelineinfo/a;)
name : 'b'
type : '()V'
access : 0x0001 (PUBLIC)
code -
registers : 3
ins : 1
outs : 2
insns size : 21 16-bit code units
catches : (none)
positions :
0x0000 line=22
0x0007 line=23
0x000c line=24 // 这里小于且最接近12
0x000f line=25
0x0014 line=26
locals :
0x0000 - 0x0015 reg=2 this Ltop/vimerzhao/testremovelineinfo/a;
#2 : (in Ltop/vimerzhao/testremovelineinfo/a;)
name : 'c'
type : '()V'
access : 0x0001 (PUBLIC)
code -
registers : 4
ins : 1
outs : 2
insns size : 48 16-bit code units
catches : (none)
positions :
0x0000 line=29
0x0007 line=30
0x000c line=31
0x0011 line=32
0x0016 line=33
0x001b line=34
0x001c line=35 // 这里小于且最接近28
0x001f line=36
0x0024 line=37
0x0029 line=38
0x002c line=39
0x002f line=41
locals :
0x001c - 0x0030 reg=1 a Ljava/lang/Object;
0x0000 - 0x0030 reg=3 this Ltop/vimerzhao/testremovelineinfo/a;
source_file_idx : 0 ()

这里我加了一些注释,通过指令集位置,我们成功找到了行号,而查看Demo源代码也确实如此:

所以,上报后Crash的排查问题也可以解决了。

总结

以上,是对改该方案的具体实现的分析,有了以上信息,代码自然水到渠成了(100行左右~),不做赘述。

个人认为这个方案可以作为安装包优化的最后一根救命稻草,但本身入侵性较强,除非被KPI所逼迫,走头无路,否则不必剑走偏锋。

有次吃饭时,我提到这个方法,大家觉得1M的事情,何必费这么大功夫,但有时候KPI就是KPI,你可以觉得这1M没有必要,老板也可以觉得招你这个人没有必要。

(逃~)

参考


欢迎扫码关注作者公众号,及时获取最新信息。

技术|Android安装包极限优化的更多相关文章

  1. 技术|Android安装包优化

    版权声明 1.本文版权归原作者所有,转载需注明作者信息及原文出处. 2.本文作者:赵裕(vimerzhao),永久链接:https://github.com/vimerzhao/vimerzhao.g ...

  2. iOS - ipa安装包大小优化

    在App Store上显示的下载大小和实际下载下来的大小,我们通过下表做一个对比: iPhone型号 系统 AppStore 显示大小 下载到设备大小 iPhone6 10.2.1 91.5MB 88 ...

  3. Android安装包相关知识汇总 (编译过程图给力)

    转自: https://mp.weixin.qq.com/s?__biz=MzAwNDY1ODY2OQ==&mid=208008519&idx=1&sn=278b7793699 ...

  4. android 安装包签名问题探究

    1.首先先科普一下,android为什么需要给安装包签名: 所有的Android应用程序在发布之前都要求开发人员用一个证书进行数字签名,anroid系统不会安装没有进行签名的由于程序.    平时我们 ...

  5. Android安装包apk文件在某些版本操作系统上安装解析包出错问题的解决办法

    当我们将Android升级功能的中的下载新版本apk文件存放在data/data/xxx.apk位置时,在有的些版本的手机中安装可能会出现安装包解析出错的问题,对于该问题的解决方案是提升该文件的权限. ...

  6. unity 导出 android安装包配置方案

    原地址:http://blog.csdn.net/u012085988/article/details/17393111 1.jdk本人安装的是win32版的(虽然系统是64位的.但听说装64位的导出 ...

  7. JFrame实现批量获取Android安装包安全证书MD5

    今天遇到一个需求.获取全部apk的签名的MD5.以下是我使用Java SE实现的一个工具.贴出核心源码.希望给有须要的朋友有所帮助. 界面例如以下: 仅仅须要制定.apk文件所在的文件夹就可以,核心代 ...

  8. Android App安装包瘦身计划

    Android App安装包瘦身计划 Android App安装包体积优化: 理由, 指标和可以采用的方法. 本文内容归纳如下图: 为什么要安装包瘦身 安装包需要瘦身吗? 不需要吗? 安装包要瘦身的主 ...

  9. 专项测试——移动app安装包检测

    一.背景和现状 安装包的重要性无需多提,针对安装包质量控制越来越严格和规范,包括证书.文件大小.安装成功率等,APP的证书及混淆是影响APP的安装成功率及代码安全性的很大因素,随着功能迭代,安装包也会 ...

随机推荐

  1. Plastic Bottle Manufacturer: Characteristic Analysis Of Plastic Packaging Bottles

    Plastic packaging bottles are usually made of 7 materials. Due to its inherent characteristics, the ...

  2. PAT 1017 Queueing at Bank (模拟)

    Suppose a bank has K windows open for service. There is a yellow line in front of the windows which ...

  3. 201771010135 杨蓉庆《面对对象程序设计(java)》第十五周学习总结

    1.实验目的与要求 (1) 掌握Java应用程序的打包操作: (2) 了解应用程序存储配置信息的两种方法: (3) 掌握基于JNLP协议的java Web Start应用程序的发布方法: (5) 掌握 ...

  4. vue项目打包后运行报错400如何解决

    昨天一个Vue项目打包后,今天测试,发现无论localhost还是服务器上都运行不了,报错如下: Failed to load resource: the server responded with ...

  5. 用python计算一条射线到两个平面的交点

    前两天,一个朋友找我(半个程序猿)用python帮他写数学模型,当时的我直接是懵逼的,当听到三维啥的时候,整个人就好了,最终在周末花了3个小时把逻辑理了一遍,给小伙伴一个满意的答复了,话不多说,我来整 ...

  6. python中,字符串前的u,b,r字符的含义

    1.字符串前加 u 例:u"我是含有中文字符组成的字符串." 作用: 后面字符串以 Unicode 格式 进行编码,一般用在中文字符串前面,防止因为源码储存格式问题,导致再次使用时 ...

  7. JS-this的使用

    做前端开发已经半年之多了,前几天看见apply时心生疑惑,于是查阅了好多资料但还是不太理解,只知道是源于this的问题,今天偶然看到了阮一峰大佬的讲解js中的this问题(http://www.rua ...

  8. Plastic Bottle Manufacturer Tips - Attention To Plastic Bottle Processing Technology

    In fact, the processing technology of plastic bottles is actually quite strict. In fact, regular man ...

  9. spring web 测试用例

    spring web 测试有三种方式 1. 自己初始化 MockMvc 2.依赖@springbootTest 它会帮你生产 webTestClient ,只需自己注入即可. 3.启动的时候,不加载整 ...

  10. 【代码审计】VAuditDemo 命令注入漏洞

    一般PHP中可以使用下列函数来执行外部的应用程序或命令 system() exec() passthru() shell_exec() 跟踪$cmd --> 跟进$target,发现传递给tar ...