談談元件化-從原始碼到理解

codelang發表於2018-06-04

這幾天一直在元件化架構方面的知識點,下面主要分析一下“得到”的元件化方案和Arouter實現元件間路由的功能。

元件化涉及到的知識點

得到的方案

最近一會在探索元件化的實現方案,得到是在每個元件的build.gradle給annotationProcessorOptions設定host引數,這個引數就是我們當前元件的Group,apt拿到這個Group名稱拼接需要生成的路由表類的全路徑(不同的module都會生成不同的路由表類),然後掃描當前module被註釋了RouteNode的類,將path和類資訊儲存到生成的類,類的生成主要通過javapoet框架實現

下面是App模組的路由表

public class AppUiRouter extends BaseCompRouter {
    public AppUiRouter() {
    }
    public String getHost() {
        return "app";
    }
    public void initMap() {
        super.initMap();
        this.routeMapper.put("/main", MainActivity.class);
        this.paramsMapper.put(MainActivity.class, new HashMap() {
            {
                this.put("name", Integer.valueOf(8));
            }
        });
        this.routeMapper.put("/test", TestActivity.class);
    }
}
複製程式碼

這些都是在編譯期間實現的,那麼,執行期呢?在執行的時候,通過在Application註冊這個路由表類,

UIRouter.getInstance().registerUI("app");
複製程式碼

這個app引數就是我們在build.gradle設定的host的值,也就是Group值,然後通過UIRouter的fetch方法,拼接apt之前生成的登錄檔類所在的路徑,然後通過反射,將這個類拿到,存檔到map集合裡面

    private IComponentRouter fetch(@NonNull String host) {
        //通過host拼接apt生成的類的路徑
        String path = RouteUtils.genHostUIRouterClass(host);
        if (routerInstanceCache.containsKey(path))
            return routerInstanceCache.get(path);
        try {
            Class cla = Class.forName(path);
            IComponentRouter instance = (IComponentRouter) cla.newInstance();
            routerInstanceCache.put(path, instance);
            return instance;
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }
複製程式碼

這樣,在下次發起openUri開啟其他元件的Activity的時候,就可以通過openUri的方式,拿到host值,然後拿到IComponentRouter,然後拿到path,取出登錄檔對應的Activity.class,然後就跟平常一樣startActivity開啟對應的Activity,具體可以看 BaseCompRouter

當然,得到的元件化不僅僅這些,還有application的註冊,因為元件模組有些需要在application中初始化,但是一個app中不允許多個application的存在,所以,得到提供了兩個方案,反射的方式,將元件的application路徑交給主app,由主app的application統一反射註冊,另一種方案就是通過gradle外掛的方式,在元件的build.gradle設定combuild引數,主要是為了向外掛提供引數,如下:

combuild {
    applicationName = 'com.luojilab.share.runalone.application.ShareApplication'
    isRegisterCompoAuto = true
}
複製程式碼

然後module統一依賴 apply plugin: 'com.dd.comgradle'

外掛中做了不少東西,具體的大家可以去看看,我大致說說,子模組生成aar,移動到componentrelease資料夾,主模組去componentrelease資料夾中compile依賴這些aar,如果元件是單獨除錯模組,也給模組設定了sourceSet,設定不同路徑的AndroidManifest,然後註冊了transform,transform主要是將combuild設定的applicationName,拿到類路徑,然後通過javassist插入位元組碼插入到主Application的onCreate方法中去,看一看生成後是什麼樣的

public class AppApplication extends Application {
    public AppApplication() {
    }
    public void onCreate() {
        super.onCreate();
        UIRouter.getInstance().registerUI("app");
        Object var2 = null;
        (new ReaderAppLike()).onCreate();
        (new ShareApplike()).onCreate();
        (new KotlinApplike()).onCreate();
    }
}
複製程式碼

大致差不多了,我來點評一下:

得到的方案還是有點詬病的,在build.grdle中設定了moudle的名稱,這個名稱是要與application註冊的名稱是必須要一致的,這兩個名稱沒有一個統一的來源,很容易導致整合的開發者弄錯,導致找不到登錄檔,我建議的方案是,在元件的build.gradle設定一個ext擴充套件變數,為我們模組的名字,然後apt的host去拿這個擴充套件變數,buildTypes裡面設定一個buildConfigField,指向的也是這個變數,那麼在元件中註冊元件的時候,就可以通過BuildConfig去拿這個變數

大致思路程式碼:

apply plugin: 'com.dd.comgradle'

ext.moduleName = [
        host: "share"
]
android {
   ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = [host: moduleName.host]
            }
        }
   ...
    buildTypes {
        debug {
            buildConfigField "String", "HOST", "\"${moduleName.host}\""
        }
        release {
            buildConfigField "String", "HOST", "\"${moduleName.host}\""
        }
    
    }
    
//-------------------------------------------

子元件ShareApplike.class

    @Override
    public void onCreate() {
       //子元件包名+BuildConfig拿到host的值
        uiRouter.registerUI(com.luojilab.share.BuildConfig.HOST);
        Log.i("ShareApplike","ShareApplike-----");
        new ShareApplike().onCreate();
    }

複製程式碼

這樣可以確保註冊的元件和生成的元件是一致的

還有一個我覺得不好的就是application那個用transform插入位元組碼的功能,需要在build.gradle中去配置comBuild對應的application路徑,對於整合者來說,配置越少,實現功能越強大是最好的方法,transform實現的功能就是對各個元件的application插入位元組碼,其實是完全可以拋棄使用transform,雖然用transfrom插入位元組碼可以避免了反射,但畢竟元件的application比較少,反射的話,也就那幾個類,影響不了多大的效能,反而是登錄檔,如果元件註冊的路由特別多,那麼這個路由表就會特別大,反射會影響很大的效能,我覺得比較好的方法是,定義一個和RouteNode一樣的註解叫RouteApplication,然後將元件需要執行的application都標上RouteApplication註解,apt解析拿到這些類,生成對應的moudle名稱+Application的類,然後在執行階段,openUri開啟其他元件的時候,拼接路徑類,然後反射,和路由表方式一樣,這樣,可以完全摒棄transfrom的存在,少了一些配置

還有一個就是,如果為了效能著想,還是不要用apt的方式,apt總會遇到反射,建議全用transfrom插入位元組碼的方式,將路由全部插入到一個路由表管理類裡面,這個路由表管理類是我們自己寫好的,只是裡面啥都沒有,都是在編譯階段通過transform插入,transform使用javassist或是asm都可以操作位元組碼,只不過一個好用,但耗時,一個不好用,速度快,但用誰都無關緊要,並都是在編譯階段,只要不影響執行階段就行

還有就是apt只能對當前module的類進行掃描拿到class資訊,並且是掃描不了jar包、maven、aar裡面的類,所以,還是比較有侷限性的,transfrom可以掃描apt解決不了地方


Arouter的方案

去年CC元件化的作者向Arouter提交了一個pr,auto-register為Arouter提供一個在編譯階段自動註冊路由的功能,以前Arouter是通過反射的方式註冊路由表,現在是通過transfrom插入位元組碼實現。

Arouter不同於“得到”元件化,Arouter的元件模組是不能單獨執行的,需要開發著自行解決,Arouter只提供了路由的解決方案

Arouter主要提供了三個註解處理器

  • RouteProcessor : 處理註解的路由,作用在類上面
  • InterceptorProcessor : 路由攔截器,作用在類上面
  • AutowiredProcessor : 注入上個頁面傳遞過來的值,作用在欄位上面

配置方面,還是一樣,每個元件都必須依賴註解處理器,Arouter和“得到”提供的引數作用是不一樣的,得到提供的引數直接就是路由表的分組Group,而Arouter提供的module引數主要是生成收集當前module所有的分組,然後收集的分組對應各個路由表

  javaCompileOptions {
            annotationProcessorOptions {
                arguments = [ moduleName : project.getName() ]
            }
複製程式碼

RouteProcessor中主要是掃描被Route註解的類,然後拿到當前Route註解類的path、group和被注入值Autowired欄位。這些資訊都儲存在RouteMeta類中,主要是方便管理,這個地方說一下group這個欄位,舉個例子:

/**
 *  那麼test就是這個group欄位
 */
@Route(path = "/test/activity1")
複製程式碼

這個group欄位是什麼時候賦值給RouteMeta的呢,那就是在呼叫categories方法的時候,通過routeVerify方法進行校驗是否符合path路徑的時候賦值的,具體可以看RouteProcessor類的routeVerify方法。

然後可以看categories方法,這個方法看下groupMap這個集合,他是一個Map<String, Set<RouteMeta>>型別,主要功能還是分揀,以Group為key,將Group一樣的RouteMeta放在一個set集合裡面,為後面生成登錄檔類做基礎

分揀好分組的資訊之後,就會開始遍歷這個groupMap集合,這個地方主要功能就是通過javapoet來建立類檔案,來看下一段生成類的程式碼,稍微比較核心一點。

    // 拼接 Arouter$$Group$$<test>類(groupName)
    String groupFileName = NAME_OF_GROUP + groupName;
    //生成對應的類
    JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
         TypeSpec.classBuilder(groupFileName)
            .addJavadoc(WARNING_TIPS)
            .addSuperinterface(ClassName.get(type_IRouteGroup))
            .addModifiers(PUBLIC)
            .addMethod(loadIntoMethodOfGroupBuilder.build())
            .build()).build().writeTo(mFiler);
            //將生成類儲存到一個rootMap集合,這個是找到Group對應的路由表的關鍵
            rootMap.put(groupName, groupFileName);
            }
複製程式碼

在遍歷迴圈結束後,rootMap的作用來了,首先是填充欄位,拼接欄位資訊新增到MethodSpec.Builder中

   if (MapUtils.isNotEmpty(rootMap)) {
      // Generate root meta by group name, it must be generated before root, then I can find out the class of group.
     for (Map.Entry<String, String> entry : rootMap.entrySet()) {
        loadIntoMethodOfRootBuilder.addStatement("routes.put($S, $T.class)", entry.getKey(), ClassName.get(PACKAGE_OF_GENERATE_FILE, entry.getValue()));
        }
     }
複製程式碼

這個地方就是我們在build.gradle中javaCompileOptions設定moduleName的原因,主要功能就是生成以當前module名字為結尾的Arouter$$Root$$類,然後將Group的類資訊儲存在這個moduleName類中

   // 拼接 Arouter$$Root$$<moduleName>類
    String rootFileName = NAME_OF_ROOT + SEPARATOR + moduleName;
    JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
    TypeSpec.classBuilder(rootFileName)
            .addJavadoc(WARNING_TIPS)
            .addSuperinterface(ClassName.get(elements.getTypeElement(ITROUTE_ROOT)))
            .addModifiers(PUBLIC)
            //新增拼接好的欄位
            .addMethod(loadIntoMethodOfRootBuilder.build())
            .build()).build().writeTo(mFiler);
複製程式碼

下面我貼一下生成的兩個類

ARouter$$Root$$app.java :收集app module中所有Group對應的路由表類路徑

public class ARouter$$Root$$app implements IRouteRoot {
    public ARouter$$Root$$app() {
    }

    public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
        routes.put("service", service.class);
        routes.put("test", test.class);
        routes.put("test2", test2.class);
    }
}
複製程式碼

ARouter$$Group$$test2.java : app module中test分組的路由表

public class ARouter$$Group$$test2 implements IRouteGroup {
    public ARouter$$Group$$test2() {
    }

    public void loadInto(Map<String, RouteMeta> atlas) {
        atlas.put("/test2/activity2", RouteMeta.build(RouteType.ACTIVITY, Test2Activity.class, "/test2/activity2", "test2", new HashMap<String, Integer>() {
            {
                this.put("key1", Integer.valueOf(8));
            }
        }, -1, -2147483648));
    }
}
複製程式碼

Arouter生成的路由表和“得到”的方案不一樣,然後我們來對比一下,“得到”的方案是給當前元件定死了這個Group分組,比如Reader元件設定的host為reader,那麼,這個Reader元件中,所有生成的路由表的Group分組都是reader,好處就是提前做好了分組的概念,生成的路由表類也是根據host的名稱生成出來,很直觀,反觀Arouter,首先生成的是一個關於module的類,這個module類中儲存了當前module所有的group分組的類資訊,如果當前module有很多的group,那麼就會生成很多的類,不好的地方看起來不太直觀,生成的類資訊太多,不過都差不多,Arouter反射的物件是module,“得到”反射的物件是Group。

Group分組在Arouter並不是一個很重的概念,跟“得到”的方案不一樣,每個元件都規定了group,而Arouter可以隨意定義group,可能一個元件裡面有很多的group。

路由表資訊都生成了,接下來就是反註冊了,Arouter之前的方案是採用遍歷Dex檔案取出類資訊並將這些類資訊進行反射,拿到登錄檔,放到一個快取裡面,後來引入auto-register之後,採用注入位元組碼的方式,主要邏輯來看LogisticsCenter類。

public synchronized static void init(Context context, ThreadPoolExecutor tpe) throws HandlerException {
           
            long startInit = System.currentTimeMillis();
            //billy.qi modified at 2017-12-06
            //load by plugin first
            loadRouterMap();
            if (registerByPlugin) {
                logger.info(TAG, "Load router map by arouter-auto-register plugin.");
            } else {
             ...
              routerMap = ClassUtils.getFileNameByPackageName(mContext, ROUTE_ROOT_PAKCAGE);
             ...
            }
    }
複製程式碼

loadRouterMap這個方法主要是設定是否使用自動註冊的功能,預設registerByPlugin的值為false,還是採用ClassUtils的方式去反射登錄檔,如果想採用auto-register的方,設定registerByPlugin為true,並在build.gradle中依賴外掛 apply plugin: 'com.alibaba.arouter',具體的依賴可以看arouter-api模組

auto-register的好處是什呢麼?剛和作者聊了下

  • 優化了啟動速度
  • 解決了加固後找不到路由的問題
AutoRegister外掛從根本上解決了找不到dex檔案的問題:通過編譯時進行位元組碼掃描對應3個介面的實現類,生成註冊程式碼到ARouter的LogisticsCenter類中,執行時無需再讀取dex檔案,從而避免加固的相容性問題。
複製程式碼

大致意思就是,Arouter原來要遍歷apk的dex來找到登錄檔類資訊,但是,由於加固問題,會導致找不到dex檔案,遍歷dex檔案是一個耗時的操作,在初始化應用的時候速度沒有自動註冊的好。

這個地方還有一個好玩的事情,我們還是來看下loadRouterMap這個方法吧,主要是來看這個註釋

  private static void loadRouterMap() {
        registerByPlugin = false;
        //auto generate register code by gradle plugin: arouter-auto-register
        // looks like below:
        // registerRouteRoot(new ARouter..Root..modulejava());
        // registerRouteRoot(new ARouter..Root..modulekotlin());
     }
複製程式碼

剛開始看的時候,我一直以為auto-register所做的插入的位元組碼就是插入registerRouteRoot(new ARouter..Root..modulejava()),我們在前面分析的時候就說過,登錄檔的Group分組是放在每個module的類資訊中,如果直接將module類找到,拿出他的Group map集合,根據map集合就可以找到Route路由集合,並且,一點也不會用到反射,確實優化的不錯,但看完auto-register的原始碼後,發現並不是插入的這段程式碼,而是插入register("ARouter$$Root$$moduleName類路徑");,就是將各個module儲存分組的類進行了註冊,來看下regiter方法

    private static void register(String className) {
        if (!TextUtils.isEmpty(className)) {
            try {
                Class<?> clazz = Class.forName(className);
                Object obj = clazz.getConstructor().newInstance();
                if (obj instanceof IRouteRoot) {
                    //
                    registerRouteRoot((IRouteRoot) obj);
                } else if (obj instanceof IProviderGroup) {
                    registerProvider((IProviderGroup) obj);
                } else if (obj instanceof IInterceptorGroup) {
                    registerInterceptor((IInterceptorGroup) obj);
                } else {
                    logger.info(TAG, "register failed, class name: " + className
                            + " should implements one of IRouteRoot/IProviderGroup/IInterceptorGroup.");
                }
            } catch (Exception e) {
                logger.error(TAG,"register class error:" + className);
            }
        }
    }
複製程式碼

registerRouteRoot(new ARouter..Root..modulejava())相比,多了一步反射,我很好奇,明明transform能找到儲存Group分組的module類,通過插入位元組碼就能解決,為啥還要多做一步反射呢?擺脫反射不是能更好的優化效能嗎?後來我去問了auto-register的作者,他跟我說,故事是這樣的:

我提交PR後,ARouter的作者反饋說增加了首個dex的大小,要改成類名反射建立物件的方式註冊(需要配置混淆規則)。
但是我測試下來沒發現這個註冊對首個dex的影響有多大,所以autoregister中繼續保持以物件方式註冊
複製程式碼

最終,Arouter還是採用了反射的方式

最後來說下auto-register做了啥,auto-register主要利用transform遍歷所有模組的class資訊,尋找class的全路徑起始部分是否是com/alibaba/android/arouter/routes/,是的話,加入到一個快取的registerList集合裡面,等待被插入位元組碼

插入位元組碼部分,我們來看看吧,大致貼一點程式碼

        @Override
        void visitInsn(int opcode) {
            //generate code before return
            if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)) {
                extension.classList.each { name ->
                    name = name.replaceAll("/", ".")
                    mv.visitLdcInsn(name)//儲存group分組的module類名
                    // generate invoke register method into LogisticsCenter.loadRouterMap()
                    mv.visitMethodInsn(Opcodes.INVOKESTATIC
                            , ScanSetting.GENERATE_TO_CLASS_NAME//com/alibaba/android/arouter/core/LogisticsCenter
                            , ScanSetting.REGISTER_METHOD_NAME//register
                            , "(Ljava/lang/String;)V"
                            , false)
                }
            }
            super.visitInsn(opcode)
        }
複製程式碼

這段程式碼是用asm來插入位元組碼,asm尋找類路徑是採用斜槓的方式,但插入位元組碼的類,需要是點號,這段程式碼就是向LogisticsCenter類的loadRouterMap方法,插入一段register("儲存group分組的module類名");程式碼

Arouter具體的分析也說完了,最後來說個總結吧

總結

涉及到的知識點

  • apt的使用
  • transfrom 的使用

在我看的幾款元件化實施方案上面,上面這兩個知識點必須要了解,如果想一起探討的話,可以加QQ群492386431,畢竟一個人的想法會有侷限性,transfrom方面還需要知道gradle plugin外掛的知識。

這裡非常感謝CC元件化的作者billy,也是auto-register的作者,他真的是一位很棒的開發者,有什麼問題,他都會在群裡一一講解,幫助開發者解決困惑,畢竟現在很多群發圖和閒扯淡的多,他的群號是686844583,大家可以一起探討,愛奇藝開源的跨程式元件化方案Andromeda的作者王龍海也在,希望大家能一起學習交流

相關文章