版权声明
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
。java
目录:android
目前Android安装包的优化方法论比较成熟,好比git
这些方法都比较常规,在项目成熟后优化的空间也比较有限。以应用宝为例,目前(2020年1月)项目代码中Java文件8040个,代码行数约143万行,最终生成的 release包 9.33M。能够优化的空间极为有限,并且因为维护较差,分析已经废弃的代码和资源其实很是耗时耗力。本文的方案可使应用宝在现有基础上马上减小约 700k 安装包大小,收益十分可观,并且对于一个项目, 代码量越大,效果越明显 。github
咱们在开发中常常会去看Crash日志来定位问题,以下:vim
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存在本地!!app
这里须要指出Dex文件存在一个 debugItemInfo
的区域,记录了指令集位置到行号的信息,正是由于这些信息的存在,咱们才能作单步调试等操做,这也是这个命名的由来。函数
以应用宝为例,130万行代码都要保存这个映射信息的话其实占用的空间是很大的(也就是上面优化掉的那部分)。工具
其实,优化掉这部分信息已经有一些工具支持了:post
-keepattributes LineNumberTable
drop_line_numbers
deleteLineNumber
蚂蚁金服的支付宝 App 构建优化解析:Android 包大小极致压缩也直接提到了这种作法,但问题也很明显、很严重,会丢失行号信息(低版本都是-1,高版本是指令集位置),致使Crash没法排查,此外,每一个版本也须要作兼容,可是该文章并未详细描述,本文正是填补这部分空白。
首先,Java层的Crash上报都是经过自定义 Thread.setDefaultUncaughtExceptionHandler
的 uncaughtException(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;
在不一样版本上,该对象的数据类型都不同。
这里是第一个比较坑的地方,有的是int数组,有的是long数组,有的是Object数组的第一/最后一项,并且指令集位置有的在一块儿,有的是间隔的,确实比较坑,须要适配兼容。
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没有必要,老板也能够以为招你这我的没有必要。
(逃~)
欢迎扫码关注做者公众号,及时获取最新信息。