天天看點

插件化工程 R 檔案瘦身技術方案

作者:京東雲開發者

随着業務的發展及版本疊代,用戶端工程中不斷增加新的業務邏輯、引入新的資源,随之而來的問題就是安裝包體積變大,前期各個業務子產品通過無用資源删減、大圖壓縮或轉上雲、AB 實驗業務邏輯下線或其他手段在降低包體積上取得了一定的成果。

在瘦身的過程中我們關注到了 R 檔案瘦身的概念,目前京東 APP 是支援插件化的,有業務插件工程、宿主工程,對業務插件封包件進行分析,發現除了正常的資源及代碼外,R 類檔案大概占包體積的 3%~5% 左右,對宿主工程封包件進行分析,R 類檔案占比也有 3% 左右。我們先後在對 R 類檔案瘦身的可行性及業界開源項目進行調研後,探索出了一套适用于插件化工程的 R 檔案瘦身技術方案。

理論基礎 —R 檔案

R 檔案也就是我們日常工作中經常打交道的 R.java 檔案,在 Android 開發規範中我們需要将應用中用到的資源分别放入專門命名的資源目錄中,外部化應用資源以便對其進行單獨維護。

插件化工程 R 檔案瘦身技術方案

外部化應用資源後,我們可在項目中使用 R 類 ID 來通路這些資源,且 R 類 ID 具有唯一性。

public class MainActivity  extends BaseActivity {
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}           

在 android apk 打包流程中 R 類檔案是由 aapt(Android Asset Packaing Tool)工具打包生成的,在生成 R 類檔案的同時對資源檔案進行編譯,生成 resource.arsc 檔案,resource.arsc 檔案相當于一個檔案索引表,應用層代碼通過 R 類 ID 可以通路到對應的資源。

插件化工程 R 檔案瘦身技術方案

R 檔案瘦身的可行性分析

日常開發階段,在主工程中通過 R.xx.xx 的方式引用資源,經過編譯後 R 類引用對應的常量會被編譯進 class 中。

setContentView(2131427356);           

這種變化叫做内聯,内聯是 java 的一種機制(如果一個常量被标記為 static final,在 java 編譯的過程中會将常量内聯到代碼中,減少一次變量的記憶體尋址)。

非主工程中,R 類資源 ID 以引用的方式編譯進 class 中,不會産生内聯。

setContentView(R.layout.activity_main);           

産生這種現象的原因是 AGP 打包工具導緻的。具體細節,大家可以去查閱一下 android gradle plugin 在 R 檔案上的處理過程。

結論:R 類 id 内聯後程式可運作,但并非所有的工程都會自動産生内聯現象,我們需要通過技術手段在合适的時機将 R 類 id 内聯到程式中,内聯完成後,由于不再依賴 R 類檔案,則可以将 R 類檔案删除,在應用正常運作的同時,達到包瘦身目的。

插件化工程 R 檔案瘦身實戰

制定技術方案

目前京東 Android 用戶端是支援插件化的,整個插件化工程包含公共庫(是一個 aar 工程,用來存放元件和宿主共用的類和資源)、業務插件(插件工程是一個獨立的工程,編譯産物可以運作在宿主環境中)、宿主(主工程,提供運作環境)。在插件化的過程中為了防止宿主和插件資源沖突,通過修改插件 packageId 保證了資源的唯一性。由于公共資源庫、宿主是被很多業務依賴,對這兩個項目進行改動評估影響涉及比較多,插件一般都是業務子產品自行維護,不存在被依賴問題,是以先在業務插件子產品進行 R 類瘦身實踐。

對業務插件工程打出的包進行反編譯以後,發現 R 類 ID 無内聯現象,且 R 類檔案具有一定的大小,對包内的 R 檔案進行分析,發現 R 檔案中僅包含業務自身的資源,不包含業務依賴的公共資源 R 類。

public View onCreateView(LayoutInflater paramLayoutInflater, ViewGroup paramViewGroup, Bundle paramBundle) {
    this.b = paramLayoutInflater.inflate(R.layout.lib_pd_main_page, paramViewGroup, false);
    this.h = (PDBuyStatusView)this.b.findViewById(R.id.pd_buy_status_view);
    this.f = (PageRecyclerView)this.b.findViewById(R.id.lib_pd_recycle_view);}           
插件化工程 R 檔案瘦身技術方案

結合對業界開源項目的調研分析,嘗試制定符合京東商城的技術方案并優先在業務插件内完成 R 類 ID 内聯并删除對應的 R 檔案。

1. 通過transformapi 收集要處理的 class 檔案

Transform 是 Android Gradle 提供的操作位元組碼的一種方式,它在 class 編譯成 dex 之前通過一系列 Transform 處理來實作修改.class 檔案。

@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
//  通過TransformInvocation.getInputs()擷取輸入檔案,有兩種
//  DirectoryInpu以源碼方式參與編譯的目錄結構及目錄下的檔案
//  JarInput以jar包方式參與編譯的所有jar包
    allDirs = new ArrayList<>(invocation.getInputs().size());
    allJars = new ArrayList<>(invocation.getInputs().size());
    Collection<TransformInput> inputs = invocation.getInputs();
    for (TransformInput input : inputs) {
        Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
         for (DirectoryInput directoryInput : directoryInputs) {
               allDirs.add(directoryInput.getFile());
             }
            Collection<JarInput> jarInputs = input.getJarInputs();
         for (JarInput jarInput : jarInputs) {
                allJars.add(jarInput.getFile());
             }
     }
}           

2. 對收集到的.class 檔案結合 ASM 架構進行分析處理

ASM 是一個操作 Java 位元組碼的類庫,通過 ASM 我們可以友善對.class 檔案進行修改。

優先識别 R 類檔案,通過 ClassVisitor 通路 R.class 檔案,讀取檔案中的靜态常量,進行臨時變量存儲:

@Overridepublic FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {    //R類中收集 public static final int 對應的變量  if (JDASMUtil.isPublic(access) && JDASMUtil.isStatic(access) &&JDASMUtil.isFinal(access) &&JDASMUtil.isInt(desc)) {       jdRstore.addInlineRField(className, name, value);      }      return super.visitField(access, name, desc, signature, value);}           

非 R 類檔案,通過 MethodVisitor 識别到代碼中的 R 類引用,擷取引用對應的值,進行 id 值替換:

@Override
    public void visitFieldInsn(int opcode, String owner, String name, String desc) {
        if (opcode == Opcodes.GETSTATIC) {
            //owner:包名;name:具體變量名;value:R類變量對應的具體id值
            Object value = jdRstore.getRFieldValue(owner, name);
            if (value != null) {
              //調用該api實作值替換
                mv.visitLdcInsn(value);
                return;
            }
        }
        super.visitFieldInsn(opcode, owner, name, desc);
    }           

* 注:以上代碼僅為部分示意代碼,非正式插件代碼。

插件化工程 R 檔案瘦身技術方案

在業務子產品引入 R 類瘦身插件後,業務子產品功能可正常運作,且插件包大小均有 3%~5% 不同程度的減少。

公共資源 R 類 ID 内聯

由于在京東 android 用戶端代碼中,更多的資源檔案集中在公共資源庫中,相對的公共庫生成的 R 類檔案也更大,對編譯後的 apk 包内容進行分析後,公共資源庫的 R 類檔案占比高達 3%。

公共庫跟随宿主一起打包,在宿主打包過程中引入 R 類瘦身插件,打包後的 apk 有明顯的減小,手機安裝 apk 後啟動首頁正常展示無問題,但在打開某些業務插件時,會有異常閃退現象,崩潰類型為 R.x resource not found。對崩潰原因分析如下:業務插件代碼中使用了公共庫中的 R 類資源、插件打包流程獨立于宿主打包,在插件打包的過程中僅完成了業務子產品 R 類的内聯,并沒有考慮到公共資源 R 類的内聯,基于上述原因當宿主打包過程完成 R 類檔案删除瘦身後,我們在運作某業務插件的過程中,自然就會報公共資源 R 類找不到的問題進而産生崩潰。

插件化工程 R 檔案瘦身技術方案

為了解決這個問題一開始的方案設想是增加白名單機制,keep 住所有被業務子產品使用的公共資源,但很快這個想法就被推翻,公共資源存在本身就是希望各個業務子產品直接引用這部分資源,而不是自己定義,如果 keep 住的話,必然有很大一部分的資源無法删減,瘦身的效果會大打折扣。

既然保留的方案并不合适,那就将公共資源 R 類 id 也内聯到代碼中去。前面提到京東是支援插件化的,整個插件化方案是基于 aura 平台實作的,我們向 aura 團隊進行了咨詢,然後 get 到了新的方案切入點。

aura 平台在插件化的過程中已認證 aapt2 引入了公共資源 id 固定的能力,在該能力下,已定義的公共資源 id 會一直固定 (各個業務插件中引用的公共資源 id 一緻),且公共資源庫中已有的資源不可被其他子產品重複定義,否則會覆寫之前已定義好的資源,基于上述的結果和規則,我們對之前的 R 檔案瘦身 gralde plugin 功能進行完善,将公共資源的 R 類 id 内聯到項目中。

利用 appt2 的 - stable-ids 和 - emit-ids 兩個參數實作固化資源 id 的功能,并将将固化後的 ids 檔案命名為 shared_res_public.xml 存儲在公共資源庫中,業務插件依賴公共資源庫,在打包編譯的過程中 aura 會将 shared_res_public.xml 複制到業務工程臨時編譯檔案夾 intermediates 下的指定位置并參與業務子產品的打包過程中,其檔案内容格式如下:

插件化工程 R 檔案瘦身技術方案

修改 R 檔案瘦身 gradle plugin 代碼,從指定位置讀取并識别這部分公共資源,按照 <name,id> 的形式進行變量存儲,并在後續過程中對業務子產品中的公共資源部分進行 id 替換。

public Map<String, String> parse() throws Exception {
        if (in == null) {
            return null;
        }
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        Document doc = builder.parse(in);
        Element rootElement = doc.getDocumentElement();
        NodeList list = rootElement.getChildNodes();
        ......
        return resNode;
    }
}           
插件化工程 R 檔案瘦身技術方案

R 類資源 id 内聯部分代碼如下:

public void visitFieldInsn(int opcode, String owner, String name, String desc) {
        if (opcode == Opcodes.GETSTATIC) {
            //優先從業務子產品R類資源中查找
            Object value = jdRstore.getRFieldValue(owner, name);
            if (value != null) {
                mv.visitLdcInsn(value);
                return;
            }
           //從公共R類資源中查找
            value = getPublicRFileValue(name);
            if (value != null) {
                mv.visitLdcInsn(value);
                return;
            }
        }
        super.visitFieldInsn(opcode, owner, name, desc);
    }           

該方案完善後,結合商詳業務插件進行了驗證,在商詳及宿主均完成 R 檔案内聯瘦身後,商詳子產品業務功能可正常使用,無異常現象。

考慮到 R 檔案内聯瘦身 gradle plugin 是在打包編譯階段引入的,我們也統計了一下引入該插件以後對打包時長的影響,資料如下:

插件化工程 R 檔案瘦身技術方案

結合資料來看,引入 R 檔案瘦身插件後對整體打包時長并無顯著影響。

至此,基于京東商城探索的插件化工程 R 檔案瘦身 gradle plugin 就開發完成,目前已在部分業務插件子產品進行了線上驗證,在功能上線以後我們也及時的進行了崩潰觀測以及使用者回報的跟進,暫無異常問題。當然圍繞 R 檔案瘦身縮減包體積這個目的,開發人員有各種各樣的技術方案,上述方案不一定适用于所有的用戶端開發體系,另外後續也将圍繞包瘦身這一常态事務建設一系列的相關工具,介入工作當中的各個階段,高效、有效的控制包體積的增長,如大家在瘦身方面有相關建議和想法也歡迎大家來一起讨論。

參考文章:

Gradle Plugin:

https://docs.gradle.org/current/userguide/custom_plugins.html

Gradle Transform:

https://developer.android.com/reference/tools/gradle-api/7.0/com/android/build/api/transform/Transform

APK 建構流程:

https://developer.android.com/studio/build/index.html?hl=zh-cn#build-process

作者:耿蕾 田創新

來源:京東雲開發者社群

繼續閱讀