天天看點

Android 方法數雜談

       最近在做些百川、accs以及aus 等 sdk 産品化的事情,很容易遇到主程式在內建時會出現方法數超标的問題,即時分 dex 主 dex 的方法數也經常不夠用。雖然每次問題都有同學解決, 但回想起這幾年 android 程式員和方法數之間林林總總的相愛相殺,發現很多問題隻能事前疏通而缺乏事後防範、總結,是以還是覺得有必要小聊下方法數這個話題。

       方法,對于開發者來說是程式中一段代碼的定義,而對于執行方(os、虛拟機、解釋器等)來說,就是一個存儲在可執行對象(c 的 elf、windows 的 pe、java 的 jar等)中的符号或指令。方法數也不是什麼新奇玩意,java 的 class 檔案中就有定義,elf 的符号表也有隐含展現,類似的還有變量數等定義。在 android 平台大行其道之前,對方法數讨論的問題不多。直到 facebook 2013 年的一篇文章[1],提到一些大型應用會遇到的兩個方法數問題:

dex 方法數超标 linearalloc 存儲方法數的空間在 android 2.3 及以下隻有 5 mb

       當時國内少數巨無霸應用在遇到這類問題後,也根據 facebook 這篇文章的思想實作了分 dex 的方案(如下圖的代碼片段);甚至完成對 linearalloc 的修改,但 android 2.3 及以下的機器份額日益減少,這個相容已不再重要。

Android 方法數雜談

       随着非 bat 企業對繁榮和需求的進一步訴求,遇到 android 方法數問題的産品也日益增多。對dex格式進行分析,會發現 dex 本身并沒有對方法數進行限制。dex 方法數受限制的真正原因是 dex 位元組碼的設計:

“the storage unit in the instruction stream is a 16-bit unsigned quantity”

       由于位元組碼在調用方法時,必須顯示尋址方法在 dex 存儲的索引,即meth@bbbb[2]。bbbb 的含義是每個四位,四個 b 就是十六位,是以最多支援 2^16 個方法。為保護 dex 位元組碼的執行,是以在生成、合并 dex 時會對方法數、變量等進行檢查和保護。google 在 5.0 已推出分 dex 的 workaround: multdex,雖然不夠完美,但已經使得這類問題的解決開始趨向集中。

       實際上,控制方法數問題的根本要義就是減少打入到 dex 中的方法。dex 是 dalvik 虛拟機的位元組碼檔案,class 是 java 虛拟機的位元組碼,雖然兩者在格式、文法和實作上有一些差别,但本質還是存在一一映射的關系,如下圖:

Android 方法數雜談

與 class 格式類似,dex 用一段連續的空間存放方法的索引集,每個方法被一個 <code>method_id_item</code> 資料結構所描述,由 <code>class_idx</code>, <code>proto_idx</code>, <code>name_idx</code> 這三個元素組成[3,4], 它們分别代表<code>方法所在類類型索引</code>、<code>方法聲明的索引</code>以及<code>方法名的索引</code>。

       如下圖所示,dex 中所有方法都來自 android 的 java 代碼(不排除以後有其他語言可以被編譯為 dex 格式),而 dex 是由 android 打包時會通過 dx 工具将編譯為 class 的 java 檔案轉化而來。

Android 方法數雜談

可以發現 java 檔案的來源如下:

引入的 aidl 檔案

參與編譯的 java 源碼

根據資源生成的 r 檔案

依賴的其他庫(會被一同打入到編譯結果的)

       事實 99% 的方法都來自開發者建立的 java 檔案或者引入的庫中,那麼 java 檔案會從哪些方面對方法數産生影響?

       定義方法的根本目的就是要調用它。為了說明調用方法的意義,下圖給出一個簡單的示例:聲明兩個類 mainactivity 和 test,這兩個類都有一個 foo 函數,裡面執行了 activity 的 startactivity。

Android 方法數雜談

反編譯生成的 apk,得到 dex 對應的 smali 檔案(smali 是 dex 的彙編器,和 dalvik 一樣都是冰島語,是一脈相承的東西)。可以看到調用 activity 的 startactivity 的位元組碼出現在 test 和 mainactivity 中。

Android 方法數雜談

那麼這種方法的調用會不會增加 dex 的方法?先記錄下目前的方法數為24個。

Android 方法數雜談

       繼續驗證,這次隻改動一個地方:将 test 類中 foo 函數的參數類型改為 mainactivity。依舊是調用庫方法,不同的是調用者的類型由父類 activity 變成 子類 mainactivity。

Android 方法數雜談

經過反編譯分析,發現smali 紅框中的方法其所在的類也相應地變為 mainactivity,再計算方法數變為 25,__增加 1 個__。是以即便是調用方法,也會增加方法數。

Android 方法數雜談
Android 方法數雜談

導緻方法增加的事實是:當類 a 的執行個體 a 調用了被 invoke-virtual 所修飾的方法 f。在編譯期,a 的 位元組碼中會增加方法 f(如果 f 不在 a 中),即便 f 沒被 a 複寫或者 f 在 a 的父類中被标記為 final,也阻止不了編譯器這樣的行為,這是由于虛拟機要實作多态特性而決定的。在運作期,當虛拟機執行到 a 的執行個體 a 調用 f,如找不到 f 則會出現 nosuchmethodexception。

       因為多态和複寫是 oo 最常見的程式設計手段,假如濫用繼承且祖先類中的方法很多,那麼所有祖先類定義過的方法都會添加到子類中,進而導緻方法數膨脹。是以除了進行字面意義上地減少方法,還可以從設計角度來解決這類問題。

       綜上所述,決定一個方法的三個要素是方法參數清單和傳回值、方法名稱以及該方法所在的類,修改任何三要素之一都會導緻方法數的增加。換一個角度思考,其實不同 class 檔案中的相同方法符号會在生成 dex 時被合并,這也是我認為 dex 和 class 兩者設計理念的最大差別:dex 格式提供聚合能力。至于用棧還是寄存器來實作相比頂層設計的意義便沒有那麼顯著。其實這個優化思路更早的痕迹出現在 c 語言的連結器中,如下圖所示,連結器通過合并 目标檔案相似段(elf 格式)來擷取更好的性能和擴充性,這個過程和 dx 将 一系列 class 轉化為 dex 如出一轍。

Android 方法數雜談

       縱觀世界程式設計語言發展史,java 經常被拿來與 c# 對比,但兩者的發展理念早已大相徑庭。例如 c# 吸取了很多語言的特點,也更像一個大雜燴,很早就提供了 lambda 表達式、 async 關鍵字以及豐富的異步 api 接口,看過去的确琳琅滿目、功能強大且能幫助快速開發,但實質上如果不清楚其内部原理和實作機制,很容易使用不當且造成隐晦甚至是災難性的後果。java 在這方面并沒有亦步亦趨,更像是一個按着既有計劃前進的長者。

       為了讓使用者更為得心應手,java 每個版本也持續都引入了不少新特性,例如 1.1 的内部類、1.5 的泛型、1.8的 lambda 等,滿足了開發者不同的訴求。

       這裡我們來看看文法糖對方法數的影響,下面兩個檔案分别在類 test 中定義了 foo 和 toarray 兩個方法,類 test2 繼承 test,并重寫了 foo 的傳回值。

Android 方法數雜談

我們發現 foo 傳回值的類型被改寫,基類 test 中的方法 foo 傳回的是 object,而子類 test2 傳回的是 object 的子類 long,這種用法的好處在于為多态提供了更多的擴充性,能夠讓子類的實作更為聚焦,平時一些常見的程式庫中就采用了類似用法。分析和對比位元組碼發現:子類 test2 中會存在兩個 foo 方法,原因是編譯器會在子類 test2 中生成(synthetic)一個與父類一緻的方法來做被複寫類型的方法的橋接(bridge),進而實作這一便捷的文法。

繼續檢視泛型方法 toarray 的位元組碼,其底層是使用 signature 位元組碼關鍵字來标記被擦除前的類型資訊,而泛型本身并沒有引入額外的方法。

Android 方法數雜談

       除此之外,java 中最常見的文法就是使用大量的内部類、匿名類,這一塊比 c++ 友善不少。在類 test 中我們使用匿名類和内部類來觀察他們對方法數的影響。

Android 方法數雜談

類 test 中的内部類和外部類會互相通路一些具有 private 權限的方法和變量:

繼承 runnable 的匿名類 test$1 會通路到外部類 test 的私有變量,

外部類 test 通路 靜态内部類 test$cs 和内部類 test&amp;c1 定義的私有方法

Android 方法數雜談

對于匿名類通路外部私有變量的情況,可以發現 test$1 會通過 test 的 access$000 靜态方法來擷取其私有變量的值,access$000 是編譯期在 test 中生成。

Android 方法數雜談

對于外部類通路内部類私有方法的情況,也會生成相應的靜态方法 access$xxx 來幫助突破限制。

Android 方法數雜談

值得注意的是 test 通路内部類的 private 變量卻沒有增加方法。這是因為由于 test$cs 和 test$c1 是常量,編譯期就已經确定 <code>c1_.i + cs.i</code> 的值。 同理,如果這些變量不被 final 修飾為常量,那麼編譯器也會為它們生成 access$xxx 方法來突破通路限制。

綜上所述,文法糖的本質是帶給開發者以更為便利的使用,這種便捷如果建立在與語言原有設定不一之處,要不就是缺陷,要不就是編譯器在背後做了不少。無論是複寫傳回值還是突破通路權限或者一些類似 lambda 等新文法,它們無一例外地以增加方法、内部類等位元組碼為代價來實作這種便捷,通過這種手段來屏蔽掉一些不重要的細節,将最令人關心的特性呈現給開發者。

       如果要書寫一個 java 檔案,難免要在 abstract class、annotation、class、enum、interface 這五種結構中選取或者組合,它們又在方法數上又有何差異?我們定義這五種結構最簡實作,即沒有任何方法和成員(用 t_xx.java 命名,xx 表示這些結構前兩個字的縮寫),來看看不同結構對方法數的影響。

Android 方法數雜談

通過反編譯 smali 檔案分析可得:

Android 方法數雜談

這裡篇幅問題就不列出位元組碼了,綜上所述:

接口和注解沒有引入方法,位元組碼的大小也是最少

類和抽象類引入了一個方法(會調用 object 的預設構造函數),大小理論上相同。(除去類名長度等因素,上圖 t_ab 與 t_cl 的位元組碼大小相差 9 個位元組是因為抽象類的描述比類的多了 abstract 關鍵字 加 1 個空格所緻)

枚舉引入了 1 個預設構造函數和 3 個靜态方法,所需的位元組碼最多,是其他結構的數倍甚至二十倍。枚舉有其特性和優雅之處,但使用過多也會對方法數和程式大小産生影響。

       本文簡單介紹了方法數的來龍去脈,優化方法數的文章也很多。但最有效的還是要在設計初期就把這個問題考慮進去,這裡隻聊點 the principle underlying。具體的優化和設計方案如對引入依賴的處理、怎樣避免方法數膨脹等還需圍繞原則,結合項目實際特點來選取和開展。

繼續閱讀