Android Dex分包之旅

當(dāng)程序越來(lái)越大之后,出現(xiàn)了一個(gè) dex 包裝不下的情況,通過(guò) MultiDex 的方法解決了這個(gè)問(wèn)題,但是在低端機(jī)器上又出現(xiàn)了 INSTALL_FAILED_DEXOPT 的情況,那再解決這個(gè)問(wèn)題吧。等解決完這個(gè)問(wèn)題之后,發(fā)現(xiàn)需要填的坑越來(lái)越多了,文章講的是我在分包處理中填的坑,比如 65536、LinearAlloc、NoClassDefFoundError等等。

INSTALL_FAILED_DEXOPT

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

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 

編譯環(huán)境

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

根據(jù) StackOverFlow – Does the Android ART runtime have the same method limit limitations as Dalvik? 上面的說(shuō)法,是因?yàn)?Dalvik 的 invoke-kind 指令集中,method reference index 只留了 16 bits,最多能引用 65535 個(gè)方法。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 里面的引用方法數(shù)超過(guò)了 65536,那也只有前面的 65536 得的到調(diào)用。所以這個(gè)不是 dex 的原因。其次,既然和 dex 沒有關(guān)系,那在打包 dex 的時(shí)候?yàn)槭裁磿?huì)報(bào)錯(cuò)。我們先定位 Too many 關(guān)鍵字,定位到了 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 在這里做了判斷,當(dāng)大于 65536 的時(shí)候就拋出異常了。所以在生成 dex 文件的過(guò)程中,當(dāng)調(diào)用方法數(shù)不能超過(guò) 65535 。那我們?cè)俑桓a,發(fā)現(xiàn) MemberIdsSection 的一個(gè)子類叫 MethodidsSection :

public final class MethodIdsSection extends MemberIdsSection {}

回過(guò)頭來(lái),看一下 orderItems() 方法在哪里被調(diào)用了,跟到了 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 在哪里被調(diào)用,查到了 UniformItemSection 父類 Section :

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

那現(xiàn)在再跟一下 prepare() ,查到 DexFile 中有調(diào)用:

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() 吧,因?yàn)槭?private 的,直接在類中找調(diào)用的地方就可以了:

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() 方法吧,最終發(fā)現(xiàn)在 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;
    }
    //調(diào)用writeDex的地方
    private static int runMonoDex() throws IOException {
        //blablabla......
        outArray = writeDex(outputDex);
        //blablabla......
    }
    //調(diào)用runMonoDex的地方
    public static int run(Arguments arguments) throws IOException {
        if (args.multiDex) {
            return runMultiDex();
        } else {
            return runMonoDex();
        }
    }
}

args.multiDex 就是是否分包的參數(shù),那么問(wèn)題找著了,如果不選擇分包的情況下,引用方法數(shù)超過(guò)了 65536 的話就會(huì)拋出異常。

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

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 ,是一個(gè) List 集合,那么我們看一下這個(gè)集合在哪里添加數(shù)據(jù)的:

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...
    }
    //調(diào)用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...
    }
    //調(diào)用FileBytesConsumer的地方
    private static void processOne(String pathname, FileNameFilter filter) {
        ClassPathOpener opener;

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

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

}

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

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);
        }
    }
}

跟到這里發(fā)現(xiàn)是傳進(jìn)來(lái)的參數(shù),那我們?cè)倏纯?gradle 里面?zhèn)鞯氖鞘裁磪?shù)吧,查看 Dex task :

public class Dex extends BaseTask {
    @InputFiles
    Collection<File> libraries
}
我們把這個(gè)參數(shù)打印出來(lái):

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

打印出來(lái)發(fā)現(xiàn)是 build/intermediates/pre-dexed/ 目錄里面的 jar 文件,再把 jar 文件解壓發(fā)現(xiàn)里面就是 dex 文件了。所以 DexMerger 的工作就是合并這里的 dex 。

更改編譯環(huán)境

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

將 gradle 設(shè)置為 2.1.0-alpha3 之后,在項(xiàng)目的 build.gradle 中即使沒有設(shè)置 multiDexEnabled true 也能夠編譯通過(guò),但是生成的 apk 包依舊是兩個(gè) dex ,我想的是可能為了設(shè)置 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= 用于控制每一個(gè) dex 的最大方法個(gè)數(shù)。

這個(gè)參數(shù)在查看 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...

更多細(xì)節(jié)可以查看源碼:Github – platform_dalvik/Main

FB 的工程師們?cè)?jīng)還想到過(guò)直接修改 LinearAlloc 的大小,比如從 5M 修改到 8M: Under the Hood: Dalvik patch for Facebook for Android 。

dexopt && dex2oat

image.png

dexopt

當(dāng) Android 系統(tǒng)安裝一個(gè)應(yīng)用的時(shí)候,有一步是對(duì) Dex 進(jìn)行優(yōu)化,這個(gè)過(guò)程有一個(gè)專門的工具來(lái)處理,叫 DexOpt。DexOpt 是在第一次加載 Dex 文件的時(shí)候執(zhí)行的,將 dex 的依賴庫(kù)文件和一些輔助數(shù)據(jù)打包成 odex 文件,即 Optimised Dex,存放在 cache/dalvik_cache 目錄下。保存格式為 apk路徑 @ apk名 @ classes.dex 。執(zhí)行 ODEX 的效率會(huì)比直接執(zhí)行 Dex 文件的效率要高很多。

dex2oat

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

NoClassDefFoundError

現(xiàn)在 INSTALL_FAILED_DEXOPT 問(wèn)題是解決了,但是有時(shí)候編譯完運(yùn)行的時(shí)候一打開 App 就 crash 了,查看 log 發(fā)現(xiàn)是某個(gè)類找不到引用。

  • Build Tool 是如何分包的
    為什么會(huì)這樣呢?是因?yàn)?build-tool 在分包的時(shí)候只判斷了直接引用類。什么是直接引用類呢?舉個(gè)栗子:
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 三個(gè)類,其中 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 了。同理還要單例模式中拿到單例之后直接調(diào)用某個(gè)方法返回的是另外一個(gè)對(duì)象,并非單例對(duì)象。

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

  • 環(huán)境檢查,包括傳入?yún)?shù)合法性檢查,路徑檢查以及proguard環(huán)境檢測(cè)等。
  • 使用mainDexClasses.rules規(guī)則,通過(guò)Proguard的shrink功能,裁剪無(wú)關(guān)類,生成一個(gè)tmp.jar包。
  • 通過(guò)生成的tmp jar包,調(diào)用MainDexListBuilder類生成主dex的文件列表

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

在項(xiàng)目中,可以直接運(yùn)行 gradle 的 task 。

  • collect{flavor}{buildType}MultiDexComponents Task 。這個(gè) task 是獲取 AndroidManifest.xml 中 Application 、Activity 、Service 、 Receiver 、 Provider 等相關(guān)類,以及 Annotation ,之后將內(nèi)容寫到 build/intermediates/multi-dex/{flavor}/{buildType}/maindexlist.txt 文件中去。

  • packageAll{flavor}DebugClassesForMultiDex Task 。該 task 是將所有類打包成 jar 文件存在 build/intermediates/multi-dex/{flavor}/debug/allclasses.jar 。 當(dāng) BuildType 為 Release 的時(shí)候,執(zhí)行的是 proguard{flavor}Release Task,該 task 將 proguard 混淆后的類打包成 jar 文件存在 build/intermediates/classes-proguard/{flavor}/release/classes.jar

  • shrink{flavor}{buildType}MultiDexComponents Task 。該 task 會(huì)根據(jù) maindexlist.txt 生成 componentClasses.jar ,該 jar 包里面就只有 maindexlist.txt 里面的類,該 jar 包的位置在 build/intermediates/multi-dex/{flavor}/{buildType}/componentClasses.jar

  • create{flavor}{buildType}MainDexClassList Task 。該 task 會(huì)根據(jù)生成的 componentClasses.jar 去找這里面的所有的 class 中直接依賴的 class ,然后將內(nèi)容寫到 build/intermediates/multi-dex/{flavor}/{buildType}/maindexlist.txt 中。最終這個(gè)文件里面列出來(lái)的類都會(huì)被分配到第一個(gè) 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= 參數(shù)是一個(gè)類列表的文件,在該文件中的類會(huì)被打包在第一個(gè) dex 中。

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

Application Not Responding

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

微信加載方案

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

  • dex 形式
    微信是將包放在 assets 目錄下的,在加載 Dex 的代碼時(shí),實(shí)際上傳進(jìn)去的是 zip,在加載前需要驗(yàn)證 MD5,確保所加載的 Dex 沒有被篡改。

  • dex 類分包規(guī)則
    分包規(guī)則即將所有 Application、ContentProvider 以及所有 export 的 Activity、Service 、Receiver 的間接依賴集都必須放在主 dex。

  • 加載 dex 的方式
    加載邏輯這邊主要判斷是否已經(jīng) dexopt,若已經(jīng) dexopt,即放在 attachBaseContext 加載,反之放于地球中用線程加載。怎么判斷?因?yàn)樵谖⑿胖?,若判?revision 改變,即將 dex 以及 dexopt 目錄清空。只需簡(jiǎn)單判斷兩個(gè)目錄 dex 名稱、數(shù)量是否與配置文件的一致。

總的來(lái)說(shuō),這種方案用戶體驗(yàn)較好,缺點(diǎn)在于太過(guò)復(fù)雜,每次都需重新掃描依賴集,而且使用的是比較大的間接依賴集。

Facebook 加載方案

Facebook的思路是將 MultiDex.install() 操作放在另外一個(gè)經(jīng)常進(jìn)行的。

  • dex 形式

    與微信相同。

  • dex 類分包規(guī)則

    Facebook 將加載 dex 的邏輯單獨(dú)放于一個(gè)單獨(dú)的 nodex 進(jìn)程中。

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

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

  • 加載 dex 的方式

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

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

美團(tuán)加載方案

  • dex 形式
    在 gradle 生成 dex 文件的這步中,自定義一個(gè) task 來(lái)干預(yù) dex 的生產(chǎn)過(guò)程,從而產(chǎn)生多個(gè) 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 類分包規(guī)則
    把 Service、Receiver、Provider 涉及到的代碼都放到主 dex 中,而把 Activity 涉及到的代碼進(jìn)行了一定的拆分,把首頁(yè) Activity、Laucher Activity 、歡迎頁(yè)的 Activity 、城市列表頁(yè) Activity 等所依賴的 class 放到了主 dex 中,把二級(jí)、三級(jí)頁(yè)面的 Activity 以及業(yè)務(wù)頻道的代碼放到了第二個(gè) dex 中,為了減少人工分析 class 的依賴所帶了的不可維護(hù)性和高風(fēng)險(xiǎn)性,美團(tuán)編寫了一個(gè)能夠自動(dòng)分析 class 依賴的腳本, 從而能夠保證主 dex 包含 class 以及他們所依賴的所有 class 都在其內(nèi),這樣這個(gè)腳本就會(huì)在打包之前自動(dòng)分析出啟動(dòng)到主 dex 所涉及的所有代碼,保證主 dex 運(yùn)行正常。
  • 加載 dex 的方式
    通過(guò)分析 Activity 的啟動(dòng)過(guò)程,發(fā)現(xiàn) Activity 是由 ActivityThread 通過(guò) Instrumentation 來(lái)啟動(dòng)的,那么是否可以在 Instrumentation 中做一定的手腳呢?通過(guò)分析代碼 ActivityThread 和 Instrumentation 發(fā)現(xiàn),Instrumentation 有關(guān) Activity 啟動(dòng)相關(guān)的方法大概有:execStartActivity、 newActivity 等等,這樣就可以在這些方法中添加代碼邏輯進(jìn)行判斷這個(gè) class 是否加載了,如果加載則直接啟動(dòng)這個(gè) Activity,如果沒有加載完成則啟動(dòng)一個(gè)等待的 Activity 顯示給用戶,然后在這個(gè) Activity 中等待后臺(tái)第二個(gè) dex 加載完成,完成后自動(dòng)跳轉(zhuǎn)到用戶實(shí)際要跳轉(zhuǎn)的 Activity;這樣在代碼充分解耦合,以及每個(gè)業(yè)務(wù)代碼能夠做到顆?;那疤嵯拢妥龅降诙€(gè) dex 的按需加載了。

美團(tuán)的這種方式對(duì)主 dex 的要求非常高,因?yàn)榈诙€(gè) dex 是等到需要的時(shí)候再去加載。重寫Instrumentation 的 execStartActivity 方法,hook 跳轉(zhuǎn) Activity 的總?cè)肟谧雠袛?,如果?dāng)前第二個(gè) dex 還沒有加載完成,就彈一個(gè) loading Activity等待加載完成。

綜合加載方案

微信的方案需要將 dex 放于 assets 目錄下,在打包的時(shí)候太過(guò)負(fù)責(zé);Facebook 的方案每次進(jìn)入都是開啟一個(gè) nodex 進(jìn)程,而我們希望節(jié)省資源的同時(shí)快速打開 App;美團(tuán)的方案確實(shí)很 hack,但是對(duì)于項(xiàng)目已經(jīng)很龐大,耦合度又比較高的情況下并不適合。所以這里嘗試結(jié)合三個(gè)方案,針對(duì)自己的項(xiàng)目來(lái)進(jìn)行優(yōu)化。

  • dex 形式
    第一,為了能夠繼續(xù)支持 Android 2.x 的機(jī)型,我們將每個(gè)包的方法數(shù)控制在 48000 個(gè),這樣最后分出來(lái) dex 包大約在 5M 左右;第二,為了防止 NoClassDefFoundError 的情況,我們找出來(lái)啟動(dòng)頁(yè)、引導(dǎo)頁(yè)、首頁(yè)比較在意的一些類,比如 Fragment 等(因?yàn)樵谏?maindexlist.txt 的時(shí)候只會(huì)找 Activity 的直接引用,比如首頁(yè) Activity 直接引用 AFragemnt,但是 AFragment 的引用并沒有去找)。

  • dex 類分包規(guī)則
    第一個(gè)包放 Application、Android四大組件以及啟動(dòng)頁(yè)、引導(dǎo)頁(yè)、首頁(yè)的直接引用的 Fragment 的引用類,還放了推送消息過(guò)來(lái)點(diǎn)擊 Notification 之后要展示的 Activity 中的 Fragment 的引用類。
    Fragment 的引用類是寫了一個(gè)腳本,輸入需要找的類然后將這些引用類寫到 multidex.keep 文件中,如果是 debug 的就直接在生成的 jar 里面找,如果是 release 的話就通過(guò) mapping.txt 找,找不到的話再去 jar 里面找,所以在 gradle 打包的過(guò)程中我們?nèi)藶楦蓴_一下:

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()
        }
    } 
}

詳細(xì)代碼可見:Github — PhotoNoter/gradle

  • 加載 dex 的方式
    在防止 ANR 方面,我們采用了 Facebook 的思路。但是稍微有一點(diǎn)區(qū)別,差別在于我們并不在一開啟 App 的時(shí)候就去起進(jìn)程,而是一開啟 App 的時(shí)候在主進(jìn)程里面判斷是否 dexopt 過(guò)沒,沒有的話再去起另外的進(jìn)程的 Activity 專門做 dexopt 操作 。一旦拉起了去做 dexopt 的進(jìn)程,那么讓主進(jìn)程進(jìn)入一個(gè)死循環(huán),一直等到 dexopt 進(jìn)程結(jié)束再結(jié)束死循環(huán)往下走。那么問(wèn)題來(lái)了,第一,主進(jìn)程進(jìn)入死循環(huán)會(huì) ANR 嗎?第二,如何判斷是否 dexopt 過(guò);第三,為了界面友好,dexopt 的進(jìn)程該怎么做;第四,主進(jìn)程怎么知道 dexopt 進(jìn)程結(jié)束了,也就是怎么去做進(jìn)程間通信。

  • 一個(gè)一個(gè)問(wèn)題的解決,先第一個(gè):因?yàn)楫?dāng)拉起 dexopt 進(jìn)程之后,我們?cè)?dexopt 進(jìn)程的 Activity 中進(jìn)行 MultiDex.install() 操作,此時(shí)主進(jìn)程不再是前臺(tái)進(jìn)程了,所以不會(huì) ANR 。

  • 第二個(gè)問(wèn)題:因?yàn)榈谝淮螁?dòng)是什么數(shù)據(jù)都沒有的,那么我們就建立一個(gè) SharedPreference ,啟動(dòng)的時(shí)候先去從這里獲取數(shù)據(jù),如果沒有數(shù)據(jù)那么也就是沒有 dexopt 過(guò),如果有數(shù)據(jù)那么肯定是 dexopt 過(guò)的,但是這個(gè) SharedPreference 我們得保證我們的程序只有這個(gè)地方可以修改,其他地方不能修改。

  • 第三個(gè)問(wèn)題:因?yàn)?App 的啟動(dòng)也是一張圖片,所以在 dexopt 的 Activity 的 layout 中,我們就把這張圖片設(shè)置上去就好了,當(dāng)關(guān)閉 dexopt 的 Activity 的時(shí)候,我們得關(guān)閉 Activity 的動(dòng)畫。同時(shí)為了不讓 dexopt 進(jìn)程發(fā)生 ANR ,我們將 MultiDex.install() 過(guò)程放在了子線程中進(jìn)行。

  • 第四個(gè)問(wèn)題:Linux 的進(jìn)程間通信的方式有很多,Android 中還有 Binder 等,那么我們這里采用哪種方式比較好呢?首先想到的是既然 dexopt 進(jìn)程結(jié)束了自然在主進(jìn)程的死循環(huán)中去判斷 dexopt 進(jìn)程是否存在。但是在實(shí)際操作中發(fā)現(xiàn),dexopt 雖然已經(jīng)退出了,但是進(jìn)程并沒有馬上被回收掉,所以這個(gè)方法走不通。那么用 Broadcast 廣播可以嗎?可是可以,但是增加了 Application 的負(fù)擔(dān),在拉起 dexopt 進(jìn)程前還得注冊(cè)一個(gè)動(dòng)態(tài)廣播,接收到廣播之后還得注銷掉,所以這個(gè)也沒有采用。那么最終采用的方式是判斷文件是否存在,在拉起 dexopt 進(jìn)程前在某個(gè)安全的地方建立一個(gè)臨時(shí)文件,然后死循環(huán)判斷這個(gè)文件是否存在,在 dexopt 進(jìn)程結(jié)束的時(shí)候刪除這個(gè)臨時(shí)文件,那么在主進(jìn)程的死循環(huán)中發(fā)現(xiàn)此文件不存在了,就直接跳出循環(huán),繼續(xù) Application 初始化操作。

public class NoteApplication extends Application {
@Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        //開啟dex進(jìn)程的話也會(huì)進(jìn)入application
        if (isDexProcess()) {
            return;
        }
        doInstallBeforeLollipop();
        MultiDex.install(this);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        if (isDexProcess()) {
            return;
        }
      //其他初始化
    }
    
  private void doInstallBeforeLollipop() {
        //滿足3個(gè)條件,1.第一次安裝開啟,2.主進(jìn)程,3.API<21(因?yàn)?1之后ART的速度比dalvik快接近10倍(畢竟5.0之后的手機(jī)性能也要好很多))
        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();
            }
        }
    }

詳細(xì)代碼可見:Github — PhotoNoter/NoteApplication

總的來(lái)說(shuō),這種方式好處在于依賴集非常簡(jiǎn)單,同時(shí)它的集成方式也是非常簡(jiǎn)單,我們無(wú)須去修改與加載無(wú)關(guān)的代碼。但是當(dāng)沒有啟動(dòng)過(guò) App 的時(shí)候,被推送全家桶喚醒或者收到了廣播,雖然這里都是沒有界面的過(guò)程,但是運(yùn)用了這種加載方式的話會(huì)彈出 dexopt 進(jìn)程的 Activity,用戶看到會(huì)一臉懵比的。
推薦插件: 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)

通過(guò) sdk 的 mainDexClasses.rules 知道主 dex 里面會(huì)有 Application、Activity、Service、Receiver、Provider、Instrumentation、BackupAgent 和 Annotation。當(dāng)這些類以及直接引用類比較多的時(shí)候,都要塞進(jìn)主 dex ,就引發(fā)了 main dex capacity exceeded build error 。

為了解決這個(gè)問(wèn)題,當(dāng)執(zhí)行 Create{flavor}{buildType}ManifestKeepList task 之前將其中的 activity 去掉,之后會(huì)發(fā)現(xiàn) /build/intermediates/multi_dex/{flavor}/{buildType}/manifest_keep.txt 文件中已經(jīng)沒有 Activity 相關(guān)的類了。

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()
詳細(xì)可以看 CreateManifestKeepList 的源碼:Github – CreateManifestKeepList

Too many classes in –main-dex-list
沒錯(cuò),還是 Too many classes in –main-dex-list 的錯(cuò)誤。在美團(tuán)的自動(dòng)拆包中講到:

實(shí)際應(yīng)用中我們還遇到另外一個(gè)比較棘手的問(wèn)題, 就是Field的過(guò)多的問(wèn)題,F(xiàn)ield過(guò)多是由我們目前采用的代碼組織結(jié)構(gòu)引入的,我們?yōu)榱朔奖愣鄻I(yè)務(wù)線、多團(tuán)隊(duì)并發(fā)協(xié)作的情況下開發(fā),我們采用的aar的方式進(jìn)行開發(fā),并同時(shí)在aar依賴鏈的最底層引入了一個(gè)通用業(yè)務(wù)aar,而這個(gè)通用業(yè)務(wù)aar中包含了很多資源,而ADT14以及更高的版本中對(duì)Library資源處理時(shí),Library的R資源不再是static final的了,詳情請(qǐng)查看google官方說(shuō)明,這樣在最終打包時(shí)Library中的R沒法做到內(nèi)聯(lián),這樣帶來(lái)了R field過(guò)多的情況,導(dǎo)致需要拆分多個(gè)Secondary DEX,為了解決這個(gè)問(wèn)題我們采用的是在打包過(guò)程中利用腳本把Libray中R field(例如ID、Layout、Drawable等)的引用替換成常量,然后刪去Library中R.class中的相應(yīng)Field。

同樣,hu關(guān)于這個(gè)問(wèn)題可以參考這篇大神的文章:當(dāng)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 分包之旅技術(shù)分享疑難解答

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

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

Q3:處于第一次安裝成功之后,app收到推送全家桶是否會(huì)被喚醒?
不會(huì),因?yàn)樾枰状卧赼pplication執(zhí)行過(guò)一次推送的init代碼才會(huì)被喚醒
Q4:最終方案?
示例代碼參考 :

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 前言 最近開發(fā)中我們發(fā)現(xiàn),我們的產(chǎn)品在Android設(shè)備版本低于5.0以下第一次安裝啟動(dòng)會(huì)出現(xiàn)黑屏、ANR等情況。...
    miraclehen閱讀 3,797評(píng)論 2 11
  • 為什么要分包? 1、65536問(wèn)題 導(dǎo)致因素隨著項(xiàng)目apk的龐大以及加入更多的第三方庫(kù),app的方法數(shù)已經(jīng)超過(guò)了6...
    會(huì)撒嬌的犀犀利閱讀 2,439評(píng)論 1 15
  • 為什么需要對(duì)Dex進(jìn)行分包 Android在安裝應(yīng)用的過(guò)程中,系統(tǒng)會(huì)運(yùn)行一個(gè)名為DexOpt的程序?yàn)樵搼?yīng)用在當(dāng)前機(jī)...
    Boreas_su閱讀 4,438評(píng)論 0 9
  • Tinker 熱補(bǔ)丁接入過(guò)程中的坑?。。?=============== Tinker 介紹 官方接入說(shuō)明 gra...
    朱立志閱讀 2,247評(píng)論 0 2
  • 最近項(xiàng)目apk方法數(shù)即將達(dá)到65536上限,雖然通過(guò)瘦身減少了一些方法數(shù),但是隨著更多sdk的接入,終究還是避免不...
    the_q閱讀 16,748評(píng)論 6 39

友情鏈接更多精彩內(nèi)容