技術|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.comjava

目錄:android


背景

目前Android安裝包的優化方法論比較成熟,好比git

  • 混淆代碼(Proguard、AndResGuard)
  • 移除不在使用的代碼和資源
  • 對於音頻、圖片等使用更輕量的格式
  • 等等

這些方法都比較常規,在項目成熟後優化的空間也比較有限。以應用寶爲例,目前(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

  • 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沒有必要,老闆也能夠以爲招你這我的沒有必要。

(逃~)

參考


歡迎掃碼關注做者公衆號,及時獲取最新信息。

相關文章
相關標籤/搜索