dex文件結構(三):dex文件差分包分成

作者:hackest
鏈接:http://www.lxweimin.com/p/5a2e33a61ba2

當程序越來越大之后,出現了一個 dex 包裝不下的情況,通過 MultiDex 的方法解決了這個問題,但是在低端機器上又出現了 INSTALL_FAILED_DEXOPT 的情況,那再解決這個問題吧。等解決完這個問題之后,發現需要填的坑越來越多了,文章講的是我在分包處理中填的坑,比如 65536、LinearAlloc、NoClassDefFoundError等等。

INSTALL_FAILED_DEXOPT

INSTALL_FAILED_DEXOPT 出現的原因大部分都是兩種,一種是 65536 了,另外一種是 LinearAlloc 太小了。兩者的限制不同,但是原因卻是相似,那就是App太大了,導致沒辦法安裝到手機上。

65536

trouble writing output: Too many method references: 70048; max is 65536.
或者
UNEXPECTED TOP-LEVEL EXCEPTION:

java.lang.IllegalArgumentException: method ID not in [0, 0xffff]: 65536
 at com.android.dx.merge.DexMerger$6.updateIndex(DexMerger.java:501)
 at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:276)
 at com.android.dx.merge.DexMerger.mergeMethodIds(DexMerger.java:490)
 at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:167)
 at com.android.dx.merge.DexMerger.merge(DexMerger.java:188)
 at com.android.dx.command.dexer.Main.mergeLibraryDexBuffers(Main.java:439)
 at com.android.dx.command.dexer.Main.runMonoDex(Main.java:287)
 at com.android.dx.command.dexer.Main.run(Main.java:230)
 at com.android.dx.command.dexer.Main.main(Main.java:199)
 at com.android.dx.command.Main.main(Main.java:103):Derp:dexDerpDebug FAILED 

編譯環境

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:1.3.0'
    }
}

android {
    compileSdkVersion 23
    buildToolsVersion "25.0.3"
    //....
    defaultConfig {
        minSdkVersion 14
        targetSdkVersion 23
        //....
    }
}

為什么是65536

根據 StackOverFlow – Does the Android ART runtime have the same method limit limitations as Dalvik? 上面的說法,是因為 Dalvik 的 invoke-kind 指令集中,method reference index 只留了 16 bits,最多能引用 65535 個方法。Dalvik bytecode :

Op & Format Mnemonic Mnemonic / Syntax Arguments
6e..72 invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB6e: A: argument word count (4 bits)B:
35c invoke-virtual6f: invoke-super70: invoke-direct71: invoke-static72: invoke-interface method reference index (16 bits)C..G: argument registers (4 bits each)
  • 即使 dex 里面的引用方法數超過了 65536,那也只有前面的 65536 得的到調用。所以這個不是 dex 的原因。其次,既然和 dex 沒有關系,那在打包 dex 的時候為什么會報錯。我們先定位 Too many 關鍵字,定位到了 MemberIdsSection :
public abstract class MemberIdsSection extends UniformItemSection {
  /** {@inheritDoc} */
    @Override
    protected void orderItems() {
        int idx = 0;

        if (items().size() > DexFormat.MAX_MEMBER_IDX + 1) {
            throw new DexIndexOverflowException(getTooManyMembersMessage());
        }

        for (Object i : items()) {
            ((MemberIdItem) i).setIndex(idx);
            idx++;
        }
    }

    private String getTooManyMembersMessage() {
        Map<String, AtomicInteger> membersByPackage = new TreeMap<String, AtomicInteger>();
        for (Object member : items()) {
            String packageName = ((MemberIdItem) member).getDefiningClass().getPackageName();
            AtomicInteger count = membersByPackage.get(packageName);
            if (count == null) {
                count = new AtomicInteger();
                membersByPackage.put(packageName, count);
            }
            count.incrementAndGet();
        }

        Formatter formatter = new Formatter();
        try {
            String memberType = this instanceof MethodIdsSection ? "method" : "field";
            formatter.format("Too many %s references: %d; max is %d.%n" +
                    Main.getTooManyIdsErrorMessage() + "%n" +
                    "References by package:",
                    memberType, items().size(), DexFormat.MAX_MEMBER_IDX + 1);
            for (Map.Entry<String, AtomicInteger> entry : membersByPackage.entrySet()) {
                formatter.format("%n%6d %s", entry.getValue().get(), entry.getKey());
            }
            return formatter.toString();
        } finally {
            formatter.close();
        }
    }
}

items().size() > DexFormat.MAX_MEMBER_IDX + 1 ,那 DexFormat 的值是:

public final class DexFormat {
  /**
     * Maximum addressable field or method index.
     * The largest addressable member is 0xffff, in the "instruction formats" spec as field@CCCC or
     * meth@CCCC.
     */
    public static final int MAX_MEMBER_IDX = 0xFFFF;
}

dx 在這里做了判斷,當大于 65536 的時候就拋出異常了。所以在生成 dex 文件的過程中,當調用方法數不能超過 65535 。那我們再跟一跟代碼,發現 MemberIdsSection 的一個子類叫 MethodidsSection :

public final class MethodIdsSection extends MemberIdsSection {}

回過頭來,看一下 orderItems() 方法在哪里被調用了,跟到了 MemberIdsSection 的父類 UniformItemSection :

public abstract class UniformItemSection extends Section {
    @Override
    protected final void prepare0() {
        DexFile file = getFile();

        orderItems();

        for (Item one : items()) {
            one.addContents(file);
        }
    }
    
    protected abstract void orderItems();
}

再跟一下 prepare0 在哪里被調用,查到了 UniformItemSection 父類 Section :

public abstract class Section {
    public final void prepare() {
        throwIfPrepared();
        prepare0();
        prepared = true;
    }
    
    protected abstract void prepare0();
}

那現在再跟一下 prepare() ,查到 DexFile 中有調用:

public final class DexFile {
  private ByteArrayAnnotatedOutput toDex0(boolean annotate, boolean verbose) {
        classDefs.prepare();
        classData.prepare();
        wordData.prepare();
        byteData.prepare();
        methodIds.prepare();
        fieldIds.prepare();
        protoIds.prepare();
        typeLists.prepare();
        typeIds.prepare();
        stringIds.prepare();
        stringData.prepare();
        header.prepare();
        //blablabla......
    }
}

那再看一下 toDex0() 吧,因為是 private 的,直接在類中找調用的地方就可以了:

public final class DexFile {
    public byte[] toDex(Writer humanOut, boolean verbose) throws IOException {
        boolean annotate = (humanOut != null);
        ByteArrayAnnotatedOutput result = toDex0(annotate, verbose);

        if (annotate) {
            result.writeAnnotationsTo(humanOut);
        }

        return result.getArray();
    }

    public void writeTo(OutputStream out, Writer humanOut, boolean verbose) throws IOException {
        boolean annotate = (humanOut != null);
        ByteArrayAnnotatedOutput result = toDex0(annotate, verbose);

        if (out != null) {
            out.write(result.getArray());
        }

        if (annotate) {
            result.writeAnnotationsTo(humanOut);
        }
    }
}

先搜搜 toDex() 方法吧,最終發現在 com.android.dx.command.dexer.Main 中:

public class Main {
    private static byte[] writeDex(DexFile outputDex) {
        byte[] outArray = null;
        //blablabla......
        if (args.methodToDump != null) {
            outputDex.toDex(null, false);
            dumpMethod(outputDex, args.methodToDump, humanOutWriter);
        } else {
            outArray = outputDex.toDex(humanOutWriter, args.verboseDump);
        }
        //blablabla......
        return outArray;
    }
    //調用writeDex的地方
    private static int runMonoDex() throws IOException {
        //blablabla......
        outArray = writeDex(outputDex);
        //blablabla......
    }
    //調用runMonoDex的地方
    public static int run(Arguments arguments) throws IOException {
        if (args.multiDex) {
            return runMultiDex();
        } else {
            return runMonoDex();
        }
    }
}

args.multiDex 就是是否分包的參數,那么問題找著了,如果不選擇分包的情況下,引用方法數超過了 65536 的話就會拋出異常。

同樣分析第二種情況,根據錯誤信息可以具體定位到代碼,但是很奇怪的是 DexMerger ,我們沒有設置分包參數或者其他參數,為什么會有 DexMerger ,而且依賴工程最終不都是 aar 格式的嗎?那我們還是來跟一跟代碼吧。

public class Main {
    private static byte[] mergeLibraryDexBuffers(byte[] outArray) throws IOException {
        ArrayList<Dex> dexes = new ArrayList<Dex>();
        if (outArray != null) {
            dexes.add(new Dex(outArray));
        }
        for (byte[] libraryDex : libraryDexBuffers) {
            dexes.add(new Dex(libraryDex));
        }
        if (dexes.isEmpty()) {
            return null;
        }
        Dex merged = new DexMerger(dexes.toArray(new Dex[dexes.size()]), CollisionPolicy.FAIL).merge();
        return merged.getBytes();
    }
}

這里可以看到變量 libraryDexBuffers ,是一個 List 集合,那么我們看一下這個集合在哪里添加數據的:

public class Main {
    private static boolean processFileBytes(String name, long lastModified, byte[] bytes) {
        boolean isClassesDex = name.equals(DexFormat.DEX_IN_JAR_NAME);
        //blablabla...
        } else if (isClassesDex) {
            synchronized (libraryDexBuffers) {
                libraryDexBuffers.add(bytes);
            }
            return true;
        } else {
        //blablabla...
    }
    //調用processFileBytes的地方
    private static class FileBytesConsumer implements ClassPathOpener.Consumer {

        @Override
        public boolean processFileBytes(String name, long lastModified,
                byte[] bytes)   {
            return Main.processFileBytes(name, lastModified, bytes);
        }
        //blablabla...
    }
    //調用FileBytesConsumer的地方
    private static void processOne(String pathname, FileNameFilter filter) {
        ClassPathOpener opener;

        opener = new ClassPathOpener(pathname, true, filter, new FileBytesConsumer());

        if (opener.process()) {
          updateStatus(true);
        }
    }
    //調用processOne的地方
    private static boolean processAllFiles() {
        //blablabla...
        // forced in main dex
        for (int i = 0; i < fileNames.length; i++) {
            processOne(fileNames[i], mainPassFilter);
        }
        //blablabla...
    }
    //調用processAllFiles的地方
    private static int runMonoDex() throws IOException {
        //blablabla...
        if (!processAllFiles()) {
            return 1;
        }
        //blablabla...
    }

}

跟了一圈又跟回來了,但是注意一個變量:fileNames[i],傳進去這個變量,是個地址,最終在 processFileBytes 中處理后添加到 libraryDexBuffers 中,那跟一下這個變量:

public class Main {
    private static boolean processAllFiles() {
        //blablabla...
        String[] fileNames = args.fileNames;
        //blablabla...
    }
    public void parse(String[] args) {
        //blablabla...
        }else if(parser.isArg(INPUT_LIST_OPTION + "=")) {
            File inputListFile = new File(parser.getLastValue());
            try{
                inputList = new ArrayList<String>();
                readPathsFromFile(inputListFile.getAbsolutePath(), inputList);
            } catch(IOException e) {
                System.err.println("Unable to read input list file: " + inputListFile.getName());
                throw new UsageException();
            }
        } else {
        //blablabla...
        fileNames = parser.getRemaining();
        if(inputList != null && !inputList.isEmpty()) {
            inputList.addAll(Arrays.asList(fileNames));
            fileNames = inputList.toArray(new String[inputList.size()]);
        }
    }
    
    public static void main(String[] argArray) throws IOException {
        Arguments arguments = new Arguments();
        arguments.parse(argArray);

        int result = run(arguments);
        if (result != 0) {
            System.exit(result);
        }
    }
}

跟到這里發現是傳進來的參數,那我們再看看 gradle 里面傳的是什么參數吧,查看 Dex task :

public class Dex extends BaseTask {
    @InputFiles
    Collection<File> libraries
}
我們把這個參數打印出來:

afterEvaluate {
    tasks.matching {
        it.name.startsWith('dex')
    }.each { dx ->
        if (dx.additionalParameters == null) {
            dx.additionalParameters = []
        }
        println dx.libraries
    }
}

打印出來發現是 build/intermediates/pre-dexed/ 目錄里面的 jar 文件,再把 jar 文件解壓發現里面就是 dex 文件了。所以 DexMerger 的工作就是合并這里的 dex 。

更改編譯環境

buildscript {
    //...
    dependencies {
        classpath 'com.android.tools.build:gradle:2.1.0-alpha3'
    }
}

將 gradle 設置為 2.1.0-alpha3 之后,在項目的 build.gradle 中即使沒有設置 multiDexEnabled true 也能夠編譯通過,但是生成的 apk 包依舊是兩個 dex ,我想的是可能為了設置 instantRun 。

解決 65536

Google MultiDex 解決方案:

在 gradle 中添加 MultiDex 的依賴:

dependencies { compile 'com.android.support:MultiDex:1.0.0' }

在 gradle 中配置 MultiDexEnable :

android {
    buildToolsVersion "21.1.0"
    defaultConfig {
        // Enabling MultiDex support.
        MultiDexEnabled true
  }
}

在 AndroidManifest.xml 的 application 中聲明:

<application
  android:name="android.support.multidex.MultiDexApplication">
<application/>

如果有自己的 Application 了,讓其繼承于 MultiDexApplication 。

如果繼承了其他的 Application ,那么可以重寫 attachBaseContext(Context):

@Override 
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    MultiDex.install(this);
}

LinearAlloc

gradle:

afterEvaluate { 
  tasks.matching { 
    it.name.startsWith('dex') 
  }.each { dx -> 
    if (dx.additionalParameters == null) { 
      dx.additionalParameters = []
    }  
    dx.additionalParameters += '--set-max-idx-number=48000' 
  } 
}

--set-max-idx-number= 用于控制每一個 dex 的最大方法個數。

這個參數在查看 dx.jar 找到:

//blablabla...
} else if (parser.isArg("--set-max-idx-number=")) { // undocumented test option
  maxNumberOfIdxPerDex = Integer.parseInt(parser.getLastValue());
} else if(parser.isArg(INPUT_LIST_OPTION + "=")) {
//blablabla...

更多細節可以查看源碼:Github – platform_dalvik/Main

FB 的工程師們曾經還想到過直接修改 LinearAlloc 的大小,比如從 5M 修改到 8M: Under the Hood: Dalvik patch for Facebook for Android 。

dexopt && dex2oat

img

image.png

dexopt

當 Android 系統安裝一個應用的時候,有一步是對 Dex 進行優化,這個過程有一個專門的工具來處理,叫 DexOpt。DexOpt 是在第一次加載 Dex 文件的時候執行的,將 dex 的依賴庫文件和一些輔助數據打包成 odex 文件,即 Optimised Dex,存放在 cache/dalvik_cache 目錄下。保存格式為 apk路徑 @ apk名 @ classes.dex 。執行 ODEX 的效率會比直接執行 Dex 文件的效率要高很多。

dex2oat

Android Runtime 的 dex2oat 是將 dex 文件編譯成 oat 文件。而 oat 文件是 elf 文件,是可以在本地執行的文件,而 Android Runtime 替換掉了虛擬機讀取的字節碼轉而用本地可執行代碼,這就被叫做 AOT(ahead-of-time)。dex2oat 對所有 apk 進行編譯并保存在 dalvik-cache 目錄里。PackageManagerService 會持續掃描安裝目錄,如果有新的 App 安裝則馬上調用 dex2oat 進行編譯。

NoClassDefFoundError

現在 INSTALL_FAILED_DEXOPT 問題是解決了,但是有時候編譯完運行的時候一打開 App 就 crash 了,查看 log 發現是某個類找不到引用。

  • Build Tool 是如何分包的
    為什么會這樣呢?是因為 build-tool 在分包的時候只判斷了直接引用類。什么是直接引用類呢?舉個栗子:
public class MainActivity extends Activity {
    protected void onCreate(Bundle savedInstanceState) {
        DirectReferenceClass test = new DirectReferenceClass();
    }
}

public class DirectReferenceClass {
    public DirectReferenceClass() {
        InDirectReferenceClass test = new InDirectReferenceClass();
    }
}

public class InDirectReferenceClass {
    public InDirectReferenceClass() {

    }
}

上面有 MainActivity、DirectReferenceClass 、InDirectReferenceClass 三個類,其中 DirectReferenceClass 是 MainActivity 的直接引用類,InDirectReferenceClass 是 DirectReferenceClass 的直接引用類。而 InDirectReferenceClass 是 MainActivity 的間接引用類(即直接引用類的所有直接引用類)。

如果我們代碼是這樣寫的:

public class HelloMultiDexApplication extends Application {
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        DirectReferenceClass test = new DirectReferenceClass();
        MultiDex.install(this);
    }
}

這樣直接就 crash 了。同理還要單例模式中拿到單例之后直接調用某個方法返回的是另外一個對象,并非單例對象。

build tool 的分包操作可以查看 sdk 中 build-tools 文件夾下的 mainDexClasses 腳本,同時還發現了 mainDexClasses.rules 文件,該文件是主 dex 的匹配規則。該腳本要求輸入一個文件組(包含編譯后的目錄或jar包),然后分析文件組中的類并寫入到–output所指定的文件中。實現原理也不復雜,主要分為三步:

  • 環境檢查,包括傳入參數合法性檢查,路徑檢查以及proguard環境檢測等。
  • 使用mainDexClasses.rules規則,通過Proguard的shrink功能,裁剪無關類,生成一個tmp.jar包。
  • 通過生成的tmp jar包,調用MainDexListBuilder類生成主dex的文件列表

Gradle 打包流程中是如何分包的

在項目中,可以直接運行 gradle 的 task 。

  • collect{flavor}{buildType}MultiDexComponents Task 。這個 task 是獲取 AndroidManifest.xml 中 Application 、Activity 、Service 、 Receiver 、 Provider 等相關類,以及 Annotation ,之后將內容寫到 build/intermediates/multi-dex/{flavor}/{buildType}/maindexlist.txt 文件中去。
  • packageAll{flavor}DebugClassesForMultiDex Task 。該 task 是將所有類打包成 jar 文件存在 build/intermediates/multi-dex/{flavor}/debug/allclasses.jar 。 當 BuildType 為 Release 的時候,執行的是 proguard{flavor}Release Task,該 task 將 proguard 混淆后的類打包成 jar 文件存在 build/intermediates/classes-proguard/{flavor}/release/classes.jar
  • shrink{flavor}{buildType}MultiDexComponents Task 。該 task 會根據 maindexlist.txt 生成 componentClasses.jar ,該 jar 包里面就只有 maindexlist.txt 里面的類,該 jar 包的位置在 build/intermediates/multi-dex/{flavor}/{buildType}/componentClasses.jar
  • create{flavor}{buildType}MainDexClassList Task 。該 task 會根據生成的 componentClasses.jar 去找這里面的所有的 class 中直接依賴的 class ,然后將內容寫到 build/intermediates/multi-dex/{flavor}/{buildType}/maindexlist.txt 中。最終這個文件里面列出來的類都會被分配到第一個 dex 里面。

解決 NoClassDefFoundError

gradle :

afterEvaluate { 
  tasks.matching { 
    it.name.startsWith('dex') 
  }.each { dx -> 
    if (dx.additionalParameters == null) { 
      dx.additionalParameters = []
    }  
    dx.additionalParameters += '--set-max-idx-number=48000' 
    dx.additionalParameters += "--main-dex-list=$projectDir/multidex.keep".toString()
  } 
}

--main-dex-list= 參數是一個類列表的文件,在該文件中的類會被打包在第一個 dex 中。

multidex.keep 里面列上需要打包到第一個 dex 的 class 文件,注意,如果需要混淆的話需要寫混淆之后的 class 。

Application Not Responding

因為第一次運行(包括清除數據之后)的時候需要 dexopt ,然而 dexopt 是一個比較耗時的操作,同時 MultiDex.install() 操作是在 Application.attachBaseContext() 中進行的,占用的是UI線程。那么問題來了,當我的第二個包、第三個包很大的時候,程序就阻塞在 MultiDex.install() 這個地方了,一旦超過規定時間,那就 ANR 了。那怎么辦?放子線程?如果 Application 有一些初始化操作,到初始化操作的地方的時候都還沒有完成 install + dexopt 的話,那不是又 NoClassDefFoundError 了嗎?同時 ClassLoader 放在哪個線程都讓主線程掛起。好了,那在 multidex.keep 的加上相關的所有的類吧。好像這樣成了,但是第一個 dex 又大起來了,而且如果用戶操作快,還沒完成 install + dexopt 但是已經把 App 所以界面都打開了一遍。。。雖然這不現實。。

微信加載方案

首次加載在地球中頁中, 并用線程去加載(但是 5.0 之前加載 dex 時還是會掛起主線程一段時間(不是全程都掛起))。

  • dex 形式
    微信是將包放在 assets 目錄下的,在加載 Dex 的代碼時,實際上傳進去的是 zip,在加載前需要驗證 MD5,確保所加載的 Dex 沒有被篡改。
  • dex 類分包規則
    分包規則即將所有 Application、ContentProvider 以及所有 export 的 Activity、Service 、Receiver 的間接依賴集都必須放在主 dex。
  • 加載 dex 的方式
    加載邏輯這邊主要判斷是否已經 dexopt,若已經 dexopt,即放在 attachBaseContext 加載,反之放于地球中用線程加載。怎么判斷?因為在微信中,若判斷 revision 改變,即將 dex 以及 dexopt 目錄清空。只需簡單判斷兩個目錄 dex 名稱、數量是否與配置文件的一致。

總的來說,這種方案用戶體驗較好,缺點在于太過復雜,每次都需重新掃描依賴集,而且使用的是比較大的間接依賴集。

Facebook 加載方案

Facebook的思路是將 MultiDex.install() 操作放在另外一個經常進行的。

  • dex 形式

    與微信相同。

  • dex 類分包規則

    Facebook 將加載 dex 的邏輯單獨放于一個單獨的 nodex 進程中。

<activity 
android:exported="false"
android:process=":nodex"android:name="com.facebook.nodex.startup.splashscreen.NodexSplashActivity">

所有的依賴集為 Application、NodexSplashActivity 的間接依賴集即可。

  • 加載 dex 的方式

    因為 NodexSplashActivity 的 intent-filter 指定為 Main 和LAUNCHER ,所以一打開 App 首先拉起 nodex 進程,然后打開 NodexSplashActivity 進行 MultiDex.install() 。如果已經進行了 dexpot 操作的話就直接跳轉主界面,沒有的話就等待 dexpot 操作完成再跳轉主界面。

這種方式好處在于依賴集非常簡單,同時首次加載 dex 時也不會卡死。但是它的缺點也很明顯,即每次啟動主進程時,都需先啟動 nodex 進程。盡管 nodex 進程邏輯非常簡單,這也需100ms以上。

美團加載方案

  • dex 形式
    在 gradle 生成 dex 文件的這步中,自定義一個 task 來干預 dex 的生產過程,從而產生多個 dex 。
tasks.whenTaskAdded { task ->
   if (task.name.startsWith('proguard') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
       task.doLast {
           makeDexFileAfterProguardJar();
       }
       task.doFirst {
           delete "${project.buildDir}/intermediates/classes-proguard";

           String flavor = task.name.substring('proguard'.length(), task.name.lastIndexOf(task.name.endsWith('Debug') ? "Debug" : "Release"));
           generateMainIndexKeepList(flavor.toLowerCase());
       }
   } else if (task.name.startsWith('zipalign') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
       task.doFirst {
           ensureMultiDexInApk();
       }
   }
} 
  • dex 類分包規則
    把 Service、Receiver、Provider 涉及到的代碼都放到主 dex 中,而把 Activity 涉及到的代碼進行了一定的拆分,把首頁 Activity、Laucher Activity 、歡迎頁的 Activity 、城市列表頁 Activity 等所依賴的 class 放到了主 dex 中,把二級、三級頁面的 Activity 以及業務頻道的代碼放到了第二個 dex 中,為了減少人工分析 class 的依賴所帶了的不可維護性和高風險性,美團編寫了一個能夠自動分析 class 依賴的腳本, 從而能夠保證主 dex 包含 class 以及他們所依賴的所有 class 都在其內,這樣這個腳本就會在打包之前自動分析出啟動到主 dex 所涉及的所有代碼,保證主 dex 運行正常。
  • 加載 dex 的方式
    通過分析 Activity 的啟動過程,發現 Activity 是由 ActivityThread 通過 Instrumentation 來啟動的,那么是否可以在 Instrumentation 中做一定的手腳呢?通過分析代碼 ActivityThread 和 Instrumentation 發現,Instrumentation 有關 Activity 啟動相關的方法大概有:execStartActivity、 newActivity 等等,這樣就可以在這些方法中添加代碼邏輯進行判斷這個 class 是否加載了,如果加載則直接啟動這個 Activity,如果沒有加載完成則啟動一個等待的 Activity 顯示給用戶,然后在這個 Activity 中等待后臺第二個 dex 加載完成,完成后自動跳轉到用戶實際要跳轉的 Activity;這樣在代碼充分解耦合,以及每個業務代碼能夠做到顆粒化的前提下,就做到第二個 dex 的按需加載了。

美團的這種方式對主 dex 的要求非常高,因為第二個 dex 是等到需要的時候再去加載。重寫Instrumentation 的 execStartActivity 方法,hook 跳轉 Activity 的總入口做判斷,如果當前第二個 dex 還沒有加載完成,就彈一個 loading Activity等待加載完成。

綜合加載方案

微信的方案需要將 dex 放于 assets 目錄下,在打包的時候太過負責;Facebook 的方案每次進入都是開啟一個 nodex 進程,而我們希望節省資源的同時快速打開 App;美團的方案確實很 hack,但是對于項目已經很龐大,耦合度又比較高的情況下并不適合。所以這里嘗試結合三個方案,針對自己的項目來進行優化。

  • dex 形式
    第一,為了能夠繼續支持 Android 2.x 的機型,我們將每個包的方法數控制在 48000 個,這樣最后分出來 dex 包大約在 5M 左右;第二,為了防止 NoClassDefFoundError 的情況,我們找出來啟動頁、引導頁、首頁比較在意的一些類,比如 Fragment 等(因為在生成 maindexlist.txt 的時候只會找 Activity 的直接引用,比如首頁 Activity 直接引用 AFragemnt,但是 AFragment 的引用并沒有去找)。
  • dex 類分包規則
    第一個包放 Application、Android四大組件以及啟動頁、引導頁、首頁的直接引用的 Fragment 的引用類,還放了推送消息過來點擊 Notification 之后要展示的 Activity 中的 Fragment 的引用類。
    Fragment 的引用類是寫了一個腳本,輸入需要找的類然后將這些引用類寫到 multidex.keep 文件中,如果是 debug 的就直接在生成的 jar 里面找,如果是 release 的話就通過 mapping.txt 找,找不到的話再去 jar 里面找,所以在 gradle 打包的過程中我們人為干擾一下:
tasks.whenTaskAdded { task ->
    if (task.name.startsWith("create") && task.name.endsWith("MainDexClassList")) {
        task.doLast {
            def flavorAndBuildType = task.name.substring("create".length(), task.name.length() - "MainDexClassList".length())
            autoSplitDex.configure {
                description = flavorAndBuildType
            }
            autoSplitDex.execute()
        }
    } 
}

詳細代碼可見:Github — PhotoNoter/gradle

  • 加載 dex 的方式
    在防止 ANR 方面,我們采用了 Facebook 的思路。但是稍微有一點區別,差別在于我們并不在一開啟 App 的時候就去起進程,而是一開啟 App 的時候在主進程里面判斷是否 dexopt 過沒,沒有的話再去起另外的進程的 Activity 專門做 dexopt 操作 。一旦拉起了去做 dexopt 的進程,那么讓主進程進入一個死循環,一直等到 dexopt 進程結束再結束死循環往下走。那么問題來了,第一,主進程進入死循環會 ANR 嗎?第二,如何判斷是否 dexopt 過;第三,為了界面友好,dexopt 的進程該怎么做;第四,主進程怎么知道 dexopt 進程結束了,也就是怎么去做進程間通信。
  • 一個一個問題的解決,先第一個:因為當拉起 dexopt 進程之后,我們在 dexopt 進程的 Activity 中進行 MultiDex.install() 操作,此時主進程不再是前臺進程了,所以不會 ANR 。
  • 第二個問題:因為第一次啟動是什么數據都沒有的,那么我們就建立一個 SharedPreference ,啟動的時候先去從這里獲取數據,如果沒有數據那么也就是沒有 dexopt 過,如果有數據那么肯定是 dexopt 過的,但是這個 SharedPreference 我們得保證我們的程序只有這個地方可以修改,其他地方不能修改。
  • 第三個問題:因為 App 的啟動也是一張圖片,所以在 dexopt 的 Activity 的 layout 中,我們就把這張圖片設置上去就好了,當關閉 dexopt 的 Activity 的時候,我們得關閉 Activity 的動畫。同時為了不讓 dexopt 進程發生 ANR ,我們將 MultiDex.install() 過程放在了子線程中進行。
  • 第四個問題:Linux 的進程間通信的方式有很多,Android 中還有 Binder 等,那么我們這里采用哪種方式比較好呢?首先想到的是既然 dexopt 進程結束了自然在主進程的死循環中去判斷 dexopt 進程是否存在。但是在實際操作中發現,dexopt 雖然已經退出了,但是進程并沒有馬上被回收掉,所以這個方法走不通。那么用 Broadcast 廣播可以嗎?可是可以,但是增加了 Application 的負擔,在拉起 dexopt 進程前還得注冊一個動態廣播,接收到廣播之后還得注銷掉,所以這個也沒有采用。那么最終采用的方式是判斷文件是否存在,在拉起 dexopt 進程前在某個安全的地方建立一個臨時文件,然后死循環判斷這個文件是否存在,在 dexopt 進程結束的時候刪除這個臨時文件,那么在主進程的死循環中發現此文件不存在了,就直接跳出循環,繼續 Application 初始化操作。
public class NoteApplication extends Application {
@Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        //開啟dex進程的話也會進入application
        if (isDexProcess()) {
            return;
        }
        doInstallBeforeLollipop();
        MultiDex.install(this);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        if (isDexProcess()) {
            return;
        }
      //其他初始化
    }
    
  private void doInstallBeforeLollipop() {
        //滿足3個條件,1.第一次安裝開啟,2.主進程,3.API<21(因為21之后ART的速度比dalvik快接近10倍(畢竟5.0之后的手機性能也要好很多))
        if (isAppFirstInstall() && !isDexProcessOrOtherProcesses() && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            try {
                createTempFile();
                startDexProcess();
                while (true) {
                    if (existTempFile()) {
                        try {
                            Thread.sleep(50);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    } else {
                        setAppNoteFirstInstall();
                        break;
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

詳細代碼可見:Github — PhotoNoter/NoteApplication

總的來說,這種方式好處在于依賴集非常簡單,同時它的集成方式也是非常簡單,我們無須去修改與加載無關的代碼。但是當沒有啟動過 App 的時候,被推送全家桶喚醒或者收到了廣播,雖然這里都是沒有界面的過程,但是運用了這種加載方式的話會彈出 dexopt 進程的 Activity,用戶看到會一臉懵比的。
推薦插件: https://github.com/TangXiaoLv/Android-Easy-MultiDex


Too many classes in –main-dex-list
UNEXPECTED TOP-LEVEL EXCEPTION:com.android.dex.DexException: Too many classes in –main-dex-list, main dex capacity exceeded at com.android.dx.command.dexer.Main.processAllFiles(Main.java:494) at com.android.dx.command.dexer.Main.runMultiDex(Main.java:332) at com.android.dx.command.dexer.Main.run(Main.java:243) at com.android.dx.command.dexer.Main.main(Main.java:214) at com.android.dx.command.Main.main(Main.java:106)

通過 sdk 的 mainDexClasses.rules 知道主 dex 里面會有 Application、Activity、Service、Receiver、Provider、Instrumentation、BackupAgent 和 Annotation。當這些類以及直接引用類比較多的時候,都要塞進主 dex ,就引發了 main dex capacity exceeded build error 。

為了解決這個問題,當執行 Create{flavor}{buildType}ManifestKeepList task 之前將其中的 activity 去掉,之后會發現 /build/intermediates/multi_dex/{flavor}/{buildType}/manifest_keep.txt 文件中已經沒有 Activity 相關的類了。

def patchKeepSpecs() {
def taskClass = "com.android.build.gradle.internal.tasks.multidex.CreateManifestKeepList";
def clazz = this.class.classLoader.loadClass(taskClass)
def keepSpecsField = clazz.getDeclaredField("KEEP_SPECS")
keepSpecsField.setAccessible(true)
def keepSpecsMap = (Map) keepSpecsField.get(null)
if (keepSpecsMap.remove("activity") != null) {
println "KEEP_SPECS patched: removed 'activity' root"
} else {
println "Failed to patch KEEP_SPECS: no 'activity' root found"
}
}

patchKeepSpecs()
詳細可以看 CreateManifestKeepList 的源碼:Github – CreateManifestKeepList

Too many classes in –main-dex-list
沒錯,還是 Too many classes in –main-dex-list 的錯誤。在美團的自動拆包中講到:

實際應用中我們還遇到另外一個比較棘手的問題, 就是Field的過多的問題,Field過多是由我們目前采用的代碼組織結構引入的,我們為了方便多業務線、多團隊并發協作的情況下開發,我們采用的aar的方式進行開發,并同時在aar依賴鏈的最底層引入了一個通用業務aar,而這個通用業務aar中包含了很多資源,而ADT14以及更高的版本中對Library資源處理時,Library的R資源不再是static final的了,詳情請查看google官方說明,這樣在最終打包時Library中的R沒法做到內聯,這樣帶來了R field過多的情況,導致需要拆分多個Secondary DEX,為了解決這個問題我們采用的是在打包過程中利用腳本把Libray中R field(例如ID、Layout、Drawable等)的引用替換成常量,然后刪去Library中R.class中的相應Field。

同樣,hu關于這個問題可以參考這篇大神的文章:當Field邂逅65535 。

DexException: Library dex files are not supported in multi-dex mode
com.android.dex.DexException: Library dex files are not supported in multi-dex mode
? at com.android.dx.command.dexer.Main.runMultiDex(Main.java:322)
? at com.android.dx.command.dexer.Main.run(Main.java:228)
? at com.android.dx.command.dexer.Main.main(Main.java:199)
? at com.android.dx.command.Main.main(Main.java:103)

解決:

android {
dexOptions {
preDexLibraries = false
}
}
OutOfMemoryError: Java heap space
UNEXPECTED TOP-LEVEL ERROR:
? java.lang.OutOfMemoryError: Java heap space

解決:

android {
dexOptions {
javaMaxHeapSize "2g"
}
}

Android 分包之旅技術分享疑難解答

Q1:Facebook mutidex 方案為何要多起一個進程,如果采用單進程 線程去處理呢?
答:install能不能放到線程里做?如果開新線程加載,而主線程繼續Application初始化—-——導致如果異步化,multidex安裝沒有結束意味著dex還沒加載進來,這時候如果進程需要seconday.dex里的classes信息不就悲劇了—-某些類強行使用就會報NoClassDefFoundError.
FaceBook多dex分包方案
安裝完成之后第一次啟動時,是secondary.dex的dexopt花費了更多的時間,認識到這點非常重要,使得問題轉化為:在不阻塞UI線程的前提下,完成dexopt,以后都不需要再次dexopt,所以可以在UI線程install dex了
我們現在想做到的是:既希望在Application的attachContext()方法里同步加載secondary.dex,又不希望卡住UI線程
FB的方案就是:
讓Launcher Activity在另外一個進程啟動,但是Multidex.install還是在Main Process中開啟,雖然邏輯上已經不承擔dexopt的任務
這個Launcher Activity就是用來異步觸發dexopt的 ,load完成就啟動Main Activity;如果已經loaded,則直接啟動Main Process
Multidex.install所引發的合并耗時操作,是在前臺進程的異步任務中執行的,所以沒有anr的風險

Q2:當沒有啟動過 App 的時候,被推送全家桶喚醒或者收到了廣播(App已經處于不是第一次啟動過)
會喚醒,而且會出現dexopt的獨立進程頁面activity,一閃而過用戶會懵逼...
改進采用新的思路會喚起新進程,但是該進程只會觸發一次...
如何保證只觸發一次? 我們先判斷是否第一次安裝啟動應用,當應用不是第一次安裝啟動時,我們直接啟動閃屏頁,并且結束掉子進程即可。

Q3:處于第一次安裝成功之后,app收到推送全家桶是否會被喚醒?
不會,因為需要首次在application執行過一次推送的init代碼才會被喚醒
Q4:最終方案?
示例代碼參考 :

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 當程序越來越大之后,出現了一個 dex 包裝不下的情況,通過 MultiDex 的方法解決了這個問題,但是在低端機...
    hackest閱讀 2,641評論 6 21
  • 當程序越來越大之后,出現了一個 dex 包裝不下的情況,通過 MultiDex 的方法解決了這個問題,但是在低端機...
    Android高級開發閱讀 429評論 0 5
  • [TOC] 錯誤表現 app 無法打包,日志為 錯誤原因 生成的第一個classes.dex中方法數操過65535...
    木貓尾巴閱讀 7,729評論 4 14
  • 最近項目apk方法數即將達到65536上限,雖然通過瘦身減少了一些方法數,但是隨著更多sdk的接入,終究還是避免不...
    the_q閱讀 16,535評論 6 39
  • Tinker 熱補丁接入過程中的坑!!! =============== Tinker 介紹 官方接入說明 gra...
    朱立志閱讀 2,148評論 0 2