Android 通過 APT 解耦模組依賴

kymjs張濤發表於2018-08-13

本文開源實驗室原創,轉載請以連結形式註明地址:kymjs.com/code/2018/0…
Android APT 的新玩法,生成類的特殊載入方式。在 Android 多 module 工程中使用 APT,會出現類衝突問題,如果你也碰上這種問題,希望本文對你有所幫助。

對本文有任何問題,可加我的個人微信:kymjs123

APT 是什麼?Annotation Process Tool,註解處理工具。
這本是 Java 的一個工具,但 Android 也可以使用,他可以用來處理編譯過程時的某些操作,比如 Java 檔案的生成,註解的獲取等。

在 Android 上,我們使用 APT 通常是為了生成某些處理標註有指定註解的方法、類或變數,比如 EventBus3.0開始,就是使用 APT 去處理onEvent 註解的;dagger2、butterknife 等著名的開源庫也都是使用 APT 去實現的。再舉一個大家非常熟悉的實際使用場景:在 Android 模組化重構的過程中,就會需要大量用到 APT 去生成作為跨模組轉發層的中間類,在我之前講《餓了麼模組化平臺設計》中的鐵金庫 IronBank 就大量使用了 APT 與 AOP 技術去實現跨模組的處理工作。

實現 APT

當然,本文要講的是 APT 的新玩法,講 APT demo 的文章有太多了,大家隨便網上搜一下就一大把,如果會了的同學,可以跳過本節。
要實現一個簡單的 APT demo 是很容易的。首先在 idea 中建立一個 Java 工程(由於 Android Studio 不能直接建立 Java 工程,我們選用 idea 更簡單)

1、首先建立一個我們需要處理的註解宣告:

@Retention(RetentionPolicy.SOURCE)
@Target({ElementType.METHOD})
public @interface Produce {

    Class<?> returnType() default Produce.class;

    Class<?>[] params() default {};
}
複製程式碼

關於註解類的建立以及上面各個給註解類加註解的含義,在我很早之前的一篇部落格《Android註解式繫結控制元件,沒你想象的那麼難》中已經有很詳細的介紹了,不知道的同學可以再去看一看。

2、第二步,我們為了之後處理方便,建立一個 JavaBean 用來封裝需要的資料。

class ItemData {
    Element element;
    String className = "";
    String returnType = "";
    String methodName = "";
    String[] params = {};
}
複製程式碼

3、最後就是最重要的一個類了:註解是處理方式

public class MyAnnotationProcessor extends AbstractProcessor {
}
複製程式碼

所有的註解處理類必須繼承自系統的AbstractProcessor,如果想要讓這個註解處理類生效,還要在我們的工程中建立一個 meta 檔案,meta 檔案中寫好要提供註解處理功能的那個類的包名+類名。比如我的是這樣寫的:

開源實驗室

3.1、重寫兩個方法

public class MyAnnotationProcessor extends AbstractProcessor {

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> supportTypes = new HashSet<>();
        supportTypes.add(Produce.class.getCanonicalName());
        return supportTypes;
    }

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        boolean isProcess = false;
        try {
            isProcess = true;
            List<ItemData> creatorList = parseProduce(roundEnvironment);
            genJavaFile(creatorList);
        } catch (Exception e) {
            isProcess = false;
        }
        return isProcess;
    }
}
複製程式碼

getSupportedAnnotationTypes是用來告訴 APT,我要關注的註解型別是哪些型別。這裡只有一個註解@Produce所以我們的 set 就只新增了一個型別。
process()就是真正用於處理註解的函式,這裡我是通過parseProduce()返回了所有被@Produce修飾的方法的資訊,就是我們前面封裝的 JavaBean,包含了方法所在類名、方法返回值、方法名、方法引數等資訊。
然後再通過genJavaFile()去生成方法對應的跨模組的中間類。

生成類檔案

在 APT 中,要生成一個類辦法有很多,比如讀取某個 Java 檔案模板,將檔案內的類别範本轉換成目的碼;可以使用square公司開源的javapoet庫,通過傳參直接輸出目標類檔案;也可以最簡單的直接通過輸出流將一個 Java 程式碼字串輸出到檔案中。

比如,寫 demo 我就直接用輸出 Java 字串的辦法了。(程式碼節選,刪掉多餘類宣告、try...catch)

private void genJavaFile(List<Item> pageList) {
    JavaFileObject jfo = processingEnv.getFiler().createSourceFile(PACKAGE + POINT + className);
    PrintStream ps = new PrintStream(jfo.openOutputStream());
    ps.println(String.format("public class %s implements com.kymjs.Interceptor {", className));
    ps.println("\tpublic <T> T interception(Class<T> clazz, Object... params) {");

    for (Item item : pageList) {
        ps.print(String.format("if (%s.class.equals(clazz)", item.returnType));
        // 省略多引數判斷邏輯
        for (int count = 0; count < item.params.length; count++) {

        }
        ps.println(") {");
        ps.print(String.format("\t\t\tobj = (T) %s.%s(", item.className, item.methodName));
        // 引數型別判斷邏輯
        for (int count = 0; count < item.params.length; count++) {

        }
        ps.println(");} else ");
    }
    ps.println("{\n}return obj;}}");
    ps.flush();
}
複製程式碼

最終,就會在工程目錄下生成類似這樣的一個檔案:Android 模組化

執行時載入類

本節介紹的內容,相關詳細內容建議優先閱讀:《優雅移除模組間耦合》這篇我在 droidcon 大會上分享的文字稿。
新類生成好了以後,自然需要讓生成的類生效,通常我們之間使用 ClassLoader 載入我們生成好的類。而在生效之前的編譯階段,會碰上一個很大的問題:普通的單 module 的 Android 工程使用 APT 不會有任何問題,但是多 module 使用的時候就會發生每個 module 都有一個包名類名完全相同的生成類,這就會發生類衝突了。

最簡單的解決類衝突的辦法就是讓每次生成的類,類名都不一樣。
比如你可以講類的檔案加一個 hashcode或者隨機數字尾,這樣就基本能避免類衝突問題了(只能說基本,畢竟hashcode、random也有重複的機率)。

但是如果類名不一樣的話,如何在執行時通過 ClassLoader 載入一個不知道類名的類呢?有兩種辦法,一種是通過介面遍歷,給每個 APT 生成的類一個空介面父類,在執行時遍歷所有類的父介面,是否是這個介面的,如果是就用ClassLoader載入他;另一種辦法是通過類字首,比如讓所有類都有一個特殊的字首,在執行時就能知道所有 APT 生成類了。
這種方法對應的程式碼我可以給大家看一下(節選,刪掉某些不重要的程式碼):

private void getAllDI(Context context) {
    mInterceptors.writeLock().lock();
    try {
        ApplicationInfo info = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
        String path = info.sourceDir;
        DexFile dexfile = new DexFile(path);
        Enumeration entries = dexfile.entries();
        byte isLock = NONE;

        while (entries.hasMoreElements()) {
            String name = (String) entries.nextElement();
            if (name.startsWith(PACKAGE + "." + SUFFIX)) {
                threadIsRunned = true;
                if (isLock <= 0) {
                    mInterceptors.writeLock().lock();
                    isLock = LOCK;
                }
                Class clazz = Class.forName(name);
                if (Interceptor.class.isAssignableFrom(clazz) && !Interceptor.class.equals(clazz)) {
                    mInterceptors.add((Interceptor) clazz.newInstance());
                }
            } else {
                if (isLock > 0) {
                    mInterceptors.writeLock().unlock();
                    isLock = UNLOCK;
                }
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        mInterceptors.writeLock().unlock();
    }
}
複製程式碼

由於遍歷所有類是一個耗時操作,所以通常我們將其放線上程中,因此還需要保證多個執行緒的執行緒安全問題,防止類還沒有被 ClassLoader 載入,就已經去訪問這個類的情況。

另一種實現方式就是通過額外的 gradle 外掛,在編譯期講所有 APT 生成類找到,記錄到某個類中,這樣就可以在載入的時候避免遍歷所有類這步耗時操作。或者,如果實際需求中 APT 生成類中的內容是允許亂序的,比如本例中將所有類中加了@Produce 註解的方法記錄下來這樣的操作,也可以在編譯期,將所有 APT 生成的類的內容集中到一個統一的類中,在執行時載入這個固定類(事實上我們就是這麼做的),這樣就能大大提高初始化時的速度了。

相關文章