Android apk瘦身最佳實(shí)踐(一):去除R.class

1. 前言

記得早期剛開始做 Android 開發(fā)的時(shí)候,一個(gè) Android 應(yīng)用也就幾兆的大小。到現(xiàn)在,一個(gè) APP少說十幾兆,大則好幾十兆甚至上百兆。所以針對 apk 包的瘦身問題,擺在了所有開發(fā)者的面前。畢竟安裝包越小,下載安裝肯定也就更快,對 APP 的運(yùn)營也是有幫助的。網(wǎng)上已經(jīng)有很多關(guān)于這方面的文章了,但是很多都泛泛而談,道理大家都懂,但是怎么實(shí)操確不清楚。所以,本人計(jì)劃將實(shí)際項(xiàng)目中用到的方案寫出來,剖析剖析原理,一是給自己做個(gè)總結(jié),二是給有需要的人做個(gè)參考,共同交流進(jìn)步。

2. R.java 文件結(jié)構(gòu)

眾所周知,R.java 是自動(dòng)生成的,它包含了應(yīng)用內(nèi)所有資源的名稱到數(shù)值的映射關(guān)系。先創(chuàng)建一個(gè)最簡單的工程,看看 R.java 文件的內(nèi)容:

R.java文件結(jié)構(gòu)

從圖中可以看到,R.java 內(nèi)部包含了很多內(nèi)部類:如 layout、mipmap、drawable、string、id 等等,這些內(nèi)部類里面只有 2 種數(shù)據(jù)類型的字段:

public static final int 
public static final int[]

這里面,只有 styleable 最為特殊,只有它里面有 public static final int[] 類型的字段定義,其它都只有 int 類型的字段。

public static final class styleable {
    ...
    public static final int[] ActionBarLayout = new int[]{16842931};
    public static final int ActionBarLayout_android_layout_gravity = 0;
    ...
}

此外,我們發(fā)現(xiàn) R.java 類的代碼行數(shù)有 1800 多行了,這還只是一個(gè)簡單的工程,壓根沒有任何業(yè)務(wù)邏輯。如果我們采用組件化開發(fā)或者在工程里創(chuàng)建多個(gè) module ,你會發(fā)現(xiàn)在每個(gè)模塊的包名下都會生成一個(gè) R.java 文件。以我的實(shí)際項(xiàng)目為例,我們采用組件化開發(fā)的架構(gòu),一個(gè) APP 由將近 30 個(gè)組件組成,編譯時(shí)則會生成將近 30 個(gè) R.java 文件,算上業(yè)務(wù)邏輯里的資源 id ,平均每個(gè) R.java 算 3000 行代碼的話,則總共有 90000 行的代碼,當(dāng)然這只是一個(gè)很籠統(tǒng)的估計(jì)。

3.為什么R文件可以刪除

所有的 R.java 里定義的都是常量值,以 Activity 為例:

public class MainActivity extends AppCompatActivity {

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
    
}

R.layout.activity_main 實(shí)際上對應(yīng)的是一個(gè) int 型的常量值,那么如果我們編譯打包時(shí),將所有這些對 R 類的引用直接替換成常量值,效果也是一樣的,那么 R.java 類在 apk 包里就是冗余的了。

前面說過 R.java 類里有2種數(shù)據(jù)類型,一種是 static final int 類型的,這種常量在運(yùn)行時(shí)是不會修改的,另一種是 static final int[] 類型的,雖然它也是常量,但它是一個(gè)數(shù)組類型,并不能直接刪除替換,所以打包進(jìn) apk 的 R 文件中,理論上除了 static final int[] 類型的字段,其他都可以全部刪除掉。以上面這個(gè)為例:我們需要做的是編譯時(shí)將 setContentView(R.layout.activity_main) 替換成:

setContentView(213196283);

4. ProGuard對R文件的混淆

通常我們會采用 ProGuard 進(jìn)行混淆,你會發(fā)現(xiàn)混淆也能刪除很多 R$*.class,但是混淆會造成一個(gè)問題:混淆后不能通過反射來獲取資源了?,F(xiàn)在很多應(yīng)用或者SDK里都有通過反射調(diào)用來獲取資源,比如大家最常用的統(tǒng)計(jì)SDK友盟統(tǒng)計(jì)、友盟分享等,就要求 R 文件不能混淆掉,否則會報(bào)錯(cuò),所以我們常用的做法是開啟混淆,但 keep 住 R 文件,在 proguard 配置文件中增加如下配置:

-keep class **.R$* {
    *;
}
-dontwarn **.R$*
-dontwarn **.R

ProGuard 本身會對 static final 的基本類型做內(nèi)聯(lián),也就是把代碼引用的地方全部替換成常量,全部內(nèi)聯(lián)以后整個(gè) R 文件就沒地方引用了,就會被刪掉。如果你的應(yīng)用開啟了混淆,并且不需要keep住R文件,那么后面講的對你都不適用了,可以就此打住。

如果你的應(yīng)用需要keep住R文件,那么接下來,我們講講如何刪除所有 R 文件里的冗余字段。

4. 開發(fā)思路

具體的目標(biāo)知道了,那怎么去實(shí)現(xiàn)呢,先說說我的思路:

  1. 在打包 apk 編譯時(shí)找到所有的 R$*.class ;
  2. 收集所有 R$*.class 里的 public static final int 字段信息,將鍵值對緩存起來;
  3. 遍歷所有的 class,如果是 R.class,則刪除里面的 public static final int 字段,但是需要保留 R$styleable.class 里的 public static final int[] 字段;
  4. 如果不是 R$*.class ,則遍歷該 class 里所有引用的靜態(tài)字段,如果有對 R 文件里的靜態(tài)字段引用,則根據(jù)前面緩存的鍵值對將其替換成對應(yīng)的常量 int 值;

為了實(shí)現(xiàn)這個(gè)目標(biāo),我們需要?jiǎng)?chuàng)建一個(gè) Gralde Plugin,在編譯打包時(shí)能直接幫我們完成。這里需要用到2個(gè)技術(shù):其一是 Gradle Transform,它能夠在項(xiàng)目構(gòu)建階段即由 class 到 dex 轉(zhuǎn)換期間,讓開發(fā)者修改 class 文件;其二是 ASM 技術(shù),它能讓我們直接操作修改 class 文件。

5.R文件瘦身插件實(shí)操

這里提取出幾個(gè)主要步驟來講講,具體代碼已經(jīng)開源。

怎么判斷某個(gè) class 文件是否為 R$*.class ,主要是通過 class 的文件名來判斷,然后通過 ASM 技術(shù)來讀取 R$*.class 里的所有 int 字段:

    /**
     * 收集所有 R.class 及其內(nèi)部類里的 int 常量字段信息
     * 存儲的 key = class全路徑類名 + 字段名,value = 該字段的常量值
     */
    static Map<String, Integer> mRInfoMap = new HashMap<>()

    /**
     * 收集R類相關(guān)信息,將所有 R.class 類里的 int 常量值緩存起來
     *
     * @param file class文件
     */
    static void collectRInfo(File file) {
        if (!isRClass(file.absolutePath)) {
            return
        }
        def fullClassName = getFullClassName(file.getAbsolutePath())
        println "需要收集的R類信息:fullClassName = ${fullClassName}"
        new FileInputStream(file).withStream { InputStream is ->
            ClassReader classReader = new ClassReader(is)
            ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5) {
                @Override
                FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
                    if (value instanceof Integer) {
                        //遍歷讀取所有 R.class 里的 int 常量值,例如 com/hm/library1/R$mipmap.class 里的 ic_launcher 常量值,
                        //存儲時(shí)存為 "com/hm/library1/R$mipmapic_launcher" = ***
                        mRInfoMap.put(fullClassName - ".class" + name, value)
                    }
                    return super.visitField(access, name, desc, signature, value)
                }
            }
            classReader.accept(classVisitor, 0)
        }
    }
    
        /**
     * 判斷該 class 文件是否是 R.class 類,及其內(nèi)部類如 R$id.class
     *
     * @param classFilePath class文件的全路徑名,例如:/Users/hjy/Desktop/app/build/intermediates/classes/debug/com/hm/library1/R.class
     * @return 如果是R.class及其它內(nèi)部類class則返回true,否則返回false
     */
    static boolean isRClass(String classFilePath) {
        classFilePath ==~ '''.*/R\\$.*\\.class|.*/R\\.class'''
    }

    /**
     * 判斷該 class 文件是否是 R.class 類,及其內(nèi)部類如 R$id.class,但是 R$styleable.class 類排除在外
     *
     * @param classFilePath
     * @return
     */
    static boolean isRFileExceptStyleable(String classFilePath) {
        classFilePath ==~ '''.*/R\\$(?!styleable).*?\\.class|.*/R\\.class'''
    }

    /**
     * 從形如 /Users/hjy/Desktop/heima/code/gitlab/HM-ThinApk/app/build/intermediates/classes/debug/com/hm/library1/R.class 的類路徑中截取出 com/hm/library1/R.class
     * 不管是當(dāng)前工程的代碼,還是遠(yuǎn)程依賴的aar包,在打包編譯時(shí),都會在工程的 app/build/intermediates/classes 路徑下生成一系列R.class文件,
     * 根據(jù)打包模式是 debug 還是 release來區(qū)分,從中可以截取出 R.class 的包名了。
     *
     * @param filePath class文件全路徑
     * @return 返回類似 "com/hm/library1/R.class"、"com/hm/library1/R$mipmap.class",其實(shí)就是類的全路徑class名
     */
    static String getFullClassName(String filePath) {
        String mode = "/debug/"
        int index = filePath.indexOf(mode)
        if (index == -1) {
            mode = "/release/"
            index = filePath.indexOf(mode)
        }
        return filePath.substring(index) - "${mode}"
    }
    

在 Android 的 Transform 階段,我們能讀取到所有的 class 文件和 jar 包,那么 R$*.class 是在文件目錄里還是在 jar 包里呢?實(shí)際上,每個(gè) module 的代碼打包成 aar 文件時(shí),里面并不包含 R.class ,而是包含一個(gè)名為 R.txt 的文本文件,該文本文件里包含了資源 id 的映射關(guān)系,最后我們打包生成 apk 文件時(shí),打包工具會收集所有 aar 包里的 R.txt 文件,重新生成 R.class 文件,一般可以在 app/build/intermediates/classes/ 目錄下,看到所有的 R.class 文件。所有我們不需要遍歷 jar 包來查找 R$*.class 文件,只需要遍歷 class 文件目錄即可,Transform 類里的大致代碼如下:

   @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        inputs.each { TransformInput input ->
            //第一次循環(huán),只是為了收集 R.java 類信息
            input.directoryInputs.each { DirectoryInput directoryInput ->
                if (directoryInput.file.isDirectory()) {
                    directoryInput.file.eachFileRecurse { File file ->
                        if (file.isFile()) {
                            //收集R.java類的信息
                            collectRInfo(file)
                        }
                    }
                } else {
                    //收集R.java類的信息
                    collectRInfo(directoryInput.file)
                }
            }

        }
        ......
    }

通過這種方式可以收集到所有 R.class 文件,接下來我們需要二次遍歷所有的 class 文件和 jar 包,這次需要?jiǎng)h除 R.class 以及替換對 R.class 的直接引用。

    /**
     * 將所有對 R.class 有引用的代碼,直接替換成 int 值,這樣在其他類里就不會內(nèi)聯(lián) R.class 了,
     * R.class 存不存在就不會影響編譯運(yùn)行了
     *
     * @param bytes
     * @return
     */
    private static byte[] replaceRInfo(byte[] bytes) {
        ClassReader classReader = new ClassReader(bytes)
        ClassWriter classWriter = new ClassWriter(0)
        ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5, classWriter) {

            @Override
            MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
                def methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
                methodVisitor = new MethodVisitor(Opcodes.ASM5, methodVisitor) {
                    @Override
                    void visitFieldInsn(int opcode, String owner, String name1, String desc1) {
                        String key = owner + name1
                        Integer value = mRInfoMap.get(key)
                        if (value != null) {
                            println "替換對R.class的直接引用:${owner} - ${name1}"
                            super.visitLdcInsn(value)
                        } else {
                            super.visitFieldInsn(opcode, owner, name1, desc1)
                        }
                    }
                }
                return methodVisitor
            }
        }
        classReader.accept(classVisitor, 0)
        return classWriter.toByteArray()
    }

刪除 jar 包中的 R.class 相關(guān)引用:

    /**
     * 遍歷 jar 文件里的所有 class,替換所有對 R.class 的直接引用
     *
     * @param srcJar jar文件
     */
    static void replaceAndDeleteRInfoFromJar(File srcJar) {
        File newJar = new File(srcJar.getParentFile(), srcJar.name + ".tmp")
        JarFile jarFile = new JarFile(srcJar)

        new JarOutputStream(new FileOutputStream(newJar)).withStream { JarOutputStream jarOutputStream ->
            jarFile.entries().each { JarEntry entry ->
                jarFile.getInputStream(entry).withStream { InputStream inputStream ->
                    ZipEntry zipEntry = new ZipEntry(entry.name)
                    byte[] bytes = inputStream.bytes
                    if (entry.name.endsWith(".class")) {
                        bytes = replaceRInfo(bytes)
                    }
                    if (bytes != null) {
                        jarOutputStream.putNextEntry(zipEntry)
                        jarOutputStream.write(bytes)
                        jarOutputStream.closeEntry()
                    }
                }
            }
        }

        jarFile.close()
        srcJar.delete()
        newJar.renameTo(srcJar)
    }

這樣還是會有個(gè)弊端,如果刪除了所有的 R$*.class 里的字段,某些資源通過反射調(diào)用依舊會失敗,所以我們還是需要能通過配置來 keep 住某些字段。

6.資源keep

所有的代碼已經(jīng)開源,有興趣的可以查看,里面會有更具體的注釋:Android R文件瘦身插件:源碼地址

資源 keep 配置示例:

thinRConfig {
    keepInfo {
        demomipmap {
            keepRPackageName = "com.hm.iou.thinapk.demo"
            keepRClassName = "mipmap"
            keepResName = ["ic_launcher"]
            keepResNameReg = ["ic_launcher.*"]
        }
        librarystring {
              keepRPackageName = "com.hm.iou.library"
            keepRClassName = "string"
            keepResName = ["app_name"]
            keepResNameReg = [""]
        }
    }
}

上面這個(gè)配置,com.hm.iou.thinapk.demo.R.mipmap 類里名為 ic_launcher 的字段不會被刪除,com.hm.iou.library.R.string 類里名為 app_name 的字段不會被刪除。

  • keepRPackageName:表示 R 文件所在的包名;
  • keepRClassName:表示 R 文件里的內(nèi)部類名,如mipmap、string、id、drawable、layout 等等;
  • keepResName:要 keep 的資源名,是個(gè)數(shù)組
  • keepResNameReg:要 keep 的資源名,這是個(gè)正則表達(dá)式,會根據(jù)正則來進(jìn)行匹配;

7. 使用方法

在工程根目錄 build.gradle 里增加配置:

buildscript {
    repositories {
        google()
        jcenter()
        maven { url 'https://jitpack.io' }
    }
    dependencies {
        ......
        classpath 'com.github.houjinyun:Android-ThinApk:v2.0.1'
    }
}

在 app/build.gradle 里增加配置:

//使用該插件
apply plugin: 'com.hm.plugin.thinapk'

//插件配置
thinRConfig {
    keepInfo {
        //在 R.class 里的 com.hm.iou.thinapk.demo.R.mipmap.ic_launcher 會 keep 住,自己根據(jù)需要配置
        mipmap {
            keepRPackageName = "com.hm.iou.thinapk.demo"
            keepRClassName = "mipmap"
            keepResName = ["ic_launcher"]
            keepResNameReg = ["ic_launcher.*"]
        }
    }
}

8.小結(jié)

本插件對采用組件化方式開發(fā)的app,或者有大量資源id定義的app可能會有顯著效果,以我自己的項(xiàng)目為例,采用該插件以后,apk包大小減小了差不多0.4M左右。對這2種情況除外的app,效果可能并不會那么顯著。當(dāng)然這種方案只是錦上添花而已,我們應(yīng)用里少用幾張圖片,可能包大小就減小了很多。但同樣的條件下,打出來的 apk 包肯定越小越好。

系列文章
Android apk瘦身最佳實(shí)踐(一):去除R.class
Android apk瘦身最佳實(shí)踐(二):代碼混淆和資源壓縮
Android apk瘦身最佳實(shí)踐(三):資源混淆原理
Android apk瘦身最佳實(shí)踐(四):采用AndResGuard進(jìn)行資源混淆
Android apk瘦身最佳實(shí)踐(五):圖片壓縮
Android apk瘦身最佳實(shí)踐(六):采用D8編譯器

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

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

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