Android Dex分包之旅
當程式越來越大之後,出現了一個 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 :
- 即使 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

Picture
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}MultiDexComponentsTask 。這個 task 是獲取 AndroidManifest.xml 中 Application 、Activity 、Service 、 Receiver 、 Provider 等相關類,以及 Annotation ,之後將內容寫到 build/intermediates/multi-dex/{flavor}/{buildType}/maindexlist.txt 檔案中去。
-
packageAll{flavor}DebugClassesForMultiDexTask 。該 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}MultiDexComponentsTask 。該 task 會根據 maindexlist.txt 生成 componentClasses.jar ,該 jar 包裡面就只有 maindexlist.txt 裡面的類,該 jar 包的位置在 build/intermediates/multi-dex/{flavor}/{buildType}/componentClasses.jar
-
create{flavor}{buildType}MainDexClassListTask 。該 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程式碼才會被喚醒