テクノロジー| 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マスターズEメール:[email protected]

内容:


背景

現在、最適化の方法論Androidのインストールパッケージは、次のような、比較的成熟しています

  • 難読化コード(ProGuardの、AndResGuard)
  • 削除コードとリソースが使用されていません
  • より軽量の形式を使用して、オーディオ、写真などのために

これらの方法は、比較的従来のものである空間で、プロジェクトの最適化が比較的限定されて成熟します。ポーは、例えば、現在(2020年1月)プロジェクトのJavaコードファイル8040、コード行の約143万行は、最終的にリリースパッケージの結果として適用するには9.33Mをスペースを最適化することができ、非常に限られている、と悪いため、メンテナンスのため、分析には、コードを放棄したとリソースは実際には非常に時間がかかります。プログラムは、既存のベースで、この宝の使用はすぐ程度削減することができます700Kインストールパッケージサイズ、収入は非常にかなりのですが、またプロジェクトのためのより大きなコードの量、より大きな効果

原則

次のように私たちはしばしば、問題を特定するために、開発中のクラッシュログを参照してください。

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)
......

そして、カテゴリ別に対応したマップファイルを検索します:

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ファイルで占められているが、これは、今日の議論の焦点ではありません。クラッシュバック情報を識別する上記クラッシュの特定の位置はライン26であるDEXの存在は、ソースコードのバイトコードの位置に位置情報を示すいくつかの情報を示すないさらに後、我々は、ことにより、上記の参照を混同しないことができ元のクラスのアプローチ、豊富な写真マッピングローカルマップがあるとして、バイトコードのソースの場所に位置情報を!

ファイルデックスのがあることを指摘しなければならないdebugItemInfoからこそ、このような情報の存在を、私たちはこの命名の由来であるシングルステップのデバッグと他の操作を、行うことができ、命令セットの行番号に位置情報を記録し、地域。

ポーを適用するには、例えば、コードの130万行は、マッピング情報を保存する必要があり、その後、実際にスペースを占有している(つまり、その一部出て上記の最適化で)素晴らしいです。

実際には、サポートツールの一部を離れて最適化された情報のこの部分:

  • デフォルトの最適化を開いた後、ProGuardのツールがない限り、この情報は保持されません。 -keepattributes LineNumberTable
  • Facebookはある可約式は同様の機能、構成を有していますdrop_line_numbers
  • 最近オープン暴行、バイトByteXを、それが同様の容量、構成を有していますdeleteLineNumber

蟻ゴールドのドレスアリペイアプリケーションのビルドに最適化解析:Androidのパケットサイズの極端な圧縮もこのような行為への直接参照が、問題は非常に深刻な、また非常に明白である、(下のバージョンである-1行番号情報を失い、高いバージョンは、命令セットでありますクラッシュの調査とすることはできません結果として位置)、 しかも、すべてのバージョンとの互換性も行われる必要があるが、この記事では詳しく説明しませんが、この論文では、いくつかのギャップを埋めるためです。

実現

まず、クラッシュのJava層のカスタムレポートは、次のように、実装方法のAndroid 4.4、一例として、ソースコード解析の基礎となる原則。Thread.setDefaultUncaughtExceptionHandleruncaughtException(Thread thread, Throwable throwable)

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命令セット位置情報(中間表現)が含まれ、オブジェクトはnatvie特定の行番号を解決する以下の方法に渡されます。

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

私たちのアイデアはしているので、反射命令セットの位置を介して取得する、ライブレポートの場所をフック、行番号がクラッシュ報告する前に無意味な命令セットに割り当てられた位置(理論的には、高くないバージョンでは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(Huawei社P8 Liteは、バージョン5.0.2):

6.0(サムスンGALAXY S7、バージョン6.0.1):

7.0+(サムスンGALAXY C7、バージョン7.0)

ここではいくつかのint配列の最初/最後の項目、長いいくつかの配列、いくつかのオブジェクトの配列、および命令セットポジションいくつか一緒に、いくつかの間隔は、実際にピットで最初のピットの比較があり、あなたは互換性のあるアダプタが必要。

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

(逃~)

参考


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

おすすめ

転載: www.cnblogs.com/zhaoyu1995/p/12319370.html
おすすめ