• 技术|Android安装包极限优化


    版权声明

    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没有必要,老板也可以觉得招你这个人没有必要。

    (逃~)

    参考


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

  • 相关阅读:
    C# 事件的简单例子
    pl sql 的目录 所在的目录 不能有 小括号,如 Program Files (x86)
    转】 C# 图片格式(JPG,BMP,PNG,GIF)等转换为ICO图标
    TQQ2440第三节:串口
    今天发现一个bug,不知道是什么问题,printf的问题吗,还是什么。先记下!
    【转载】内存对齐详解
    TQQ2440第二节:流水灯
    TQQ2440第一节:启动代码
    wince下sources\sources.cmn\Makefile.def的相关作用
    (基于Java)编写编译器和解释器第10章:类型检查第一部分
  • 原文地址:https://www.cnblogs.com/zhaoyu1995/p/12319370.html
Copyright © 2020-2023  润新知