Android组件化调研

1 篇文章 0 订阅
1 篇文章 0 订阅

Android组件化调研

什么是组件化

组件化就是将一个app分成多个Module(或工程),每个Module都是一个组件(也可以是一个基础库供组件依赖),开发的过程中我们可以单独调试部分组件,组件间不需要互相依赖,但可以相互调用,最终发布的时候所有组件以lib的形式被主app工程依赖并打包成一个apk。

组件化、模块化、插件化的区别可以参考:Android组件化&模块化&插件化演进

为什么要组件化

组件化优点如下:

  1. 项目拆分成有机组件,可以提高组件的复用性

  2. 降低组件间的耦合度

  3. 组件可以单独编译、调试,加快编译速度

  4. 并行开发,提供开发效率

如何组件化

组件独立调试

每个 业务组件 都是一个完整的整体,可以当做独立的App,需要满足单独运行及调试的要求,这样可以提升编译速度提高效率。

如何做到组件独立调试呢?有两种方案:

  1. 单工程方案,组件以module形式存在,动态配置组件的工程类型
  2. 多工程方案,业务组件以library module形式存在于独立的工程,且只有这一个library module
单工程方案

单工程模式,整个项目只有一个工程,它包含:App module 加上各个业务组件module,就是所有的代码,这就是单工程模式。那这种方案如何做到组件单独调试呢?

动态配置组件工程类型

我们可以在根目录的gradle.properties中定义一个常量值 isModule,true为即独立调试;false为集成调试。然后在业务组件的build.gradle中读取 isModule,设置成对应的插件即可。代码如下:

// gradle.properties
#组件独立调试开关, 每次更改值后要同步工程
isModule = false
// build.gradle
// 注意gradle.properties中的数据类型都是String类型,使用其他数据类型需要自行转换
if (isModule.toBoolean()){
    apply plugin: 'com.android.application'
}else {
    apply plugin: 'com.android.library'
}
动态配置ApplicationId 和 AndroidManifest

一个 App 是需要一个 ApplicationId的,所以组件在独立调试时需要一个ApplicationId而在集成调试时是不需要的;同样的,一个App只有一个启动页,所以组件在独立调试时需要启动页而在集成调试时不要。因此,ApplicationId、AndroidManifest也是需要根据isModule来进行配置。

// build.gradle
android {
...
    defaultConfig {
...
        if (isModule.toBoolean()) {
            // 独立调试时添加 applicationId ,集成调试时移除
            applicationId "com.zhugefang.agent"
        }
...
    }

    sourceSets {
        main {
            // 独立调试与集成调试时使用不同的 AndroidManifest.xml 文件
            if (isModule.toBoolean()) {
                manifest.srcFile 'src/main/moduleManifest/AndroidManifest.xml'
            } else {
                manifest.srcFile 'src/main/AndroidManifest.xml'
            }
        }
    }
...
}
多工程方案

多工程方案,业务组件以library module形式存在于独立的工程。独立工程 自然就可以独立调试了,不再需要进行上面那些配置了。当所有业务组件都拆分成独立组件时,原本的工程就变成一个只有app模块的壳工程了,通过壳工程来集成所有的业务组件。

maven引用组件

多工程的方案如何进行集成调试呢?—— 使用Maven引用组件。

  • 发布组件的AAR包到公司的maven仓库

  • 然后在壳工程中就使用implemention依赖就可以了,和第三方库的使用一样

apply plugin: 'maven'

uploadArchives {
    repositories {
        mavenDeployer {
            File propFile = new File('../remoteRepo.properties')
            if (propFile.exists() && propFile.canRead()) {
                Properties props = new Properties()
                props.load(new FileInputStream(propFile))

                repository(url: props['repoUrl']) {
                    authentication(userName: props['userName'], password: props['password'])
                }
                version = rootProject.ext.releaseTime
                pom.version = version
                pom.artifactId = "tools"
                pom.groupId = "com.zhuge.common"
                pom.packaging = 'aar'
                doLast {
                    println("=== " + "common tools version:" + version + " ===")
                }
            } else {
                println("远程仓库配置文件不存在或无权限")
            }
        }
    }
}

主要就是发布组件AAR的配置:AAR的版本号、名称、maven仓地址账号等。

然后,再build.gradle中引用:

// build.gradle
apply from: 'maven_push.gradle'

Sync后,点击Gradle任务uploadArchives,即可打包并发布arr到maven仓:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3N17Xllu-1614765647068)(/Users/gx0609m/Library/Application Support/typora-user-images/image-20210119114437763.png)]

最后,壳工程要引用组件ARR,需要先在壳工程的根目录下build.gradle中添加maven仓库地址:

allprojects {
    repositories {
        google()
        jcenter()
        // 私有服务器仓库地址
        maven {
            url 'http://xxx'
        }
    }
}

接着在app的build.gradle中添加依赖即可:

dependencies {
    ...
    implementation 'com.***:ui:1.0.0'
    // 以及其他业务组件
}
方案比较
  • 单工程方案没法做到代码权限管控,也不能做到开发人员职责划分明确,每个开发人员都可以对任意的组件进行修改,还是会造成耦合和混乱
  • 多工程把每个组件都分割成单独的工程,代码权限可以明确管控。集成测试时,通过maven引用来集成即可。并且业务组件和业务基础组件也可以 和 基础组件一样,可以给公司其他项目复用
  • 单工程方案在调试时可以直接调试源码,多工程方案可能在调试时需要处理下源码引用的问题

组件间通信

既然要降低组件间的耦合,那么各个组件之间就不能直接依赖。但是组件间必定会存在交互行为,如何在不直接进行依赖的各组件间进行通信。

组件间的通信形式可以归类成以下三种形式:

  • 页面跳转 - 如从组件A页面跳转到组件B中的页面

  • 接口调用 - 组件A调用组件B向外部暴露的接口方法

  • 消息发布 - 组件A向外部发布消息

接口调用

组件间的接口调用是普遍的通信形式。组件间接口调用需要满足以下条件:

  • 组件需明确向外部暴露的接口
  • 组件需在合适的时机向组件管理框架注册服务(接口的实现)
  • 组件管理框架需向组件提供获取其它组件服务的方法

首先,组件如何向外部暴露接口?主流的有三种暴露形式:

  1. 接口下沉 – 所有组件向外部暴露的接口,包括数据结构,都下沉到一个公共组件里,所有组件都需要依赖这个公共组件。这种做法比较方便,但是随着组件的增加,业务的增加,这个公共组件会越来越臃肿,造成难以维护的问题。

  2. 组件暴露的接口单独拆开 – 每个组件拆分成两个module,一个用来对外暴露接口(Export Module),一个是组件的具体实现(Implement Module)。组件A调用组件B的接口时,只需要依赖组件B的Export Module;同样,组件B调用组件A的接口时,也只需要依赖组件A的Export Module。这样可以避免出现组件的循环依赖问题。

  3. 统一组件接口 – 所有组件对外部暴露的接口保持完全一致,包括传递的数据,以及返回的结果全部封装成统一格式,放在一个公共组件中,或者直接放在组件管理框架中。这样组件间的通信形式完全保持一致,不会引起公共组件的膨胀,也不会出现组件的循环依赖问题。

针对这些组件通信形式,目前有一些流行的开源框架来帮助我们完成组件间的通信。后面会介绍主流的开源组件路由框架,以及我们云门店当前是如何进行组件化以及处理组件化中的通信问题的。最后我们将共同探讨云门店的组件化机制是否需要改善,以及如果需要的话,如何改善。

Application生命周期分发

组件化过程中,在使用Application时会存在一些问题:

  • 组件单独运行时如何在Application中初始化一些必要的库
  • 项目整体运行时,如何初始化组件特有的库
生命周期抽象类

抽象出一个生命周期的接口/抽象类,包含Application的重要生命周期方法,如onCreate、onDestroy,各业务组件通过去实现这个接口,从而实现在组件内对Application生命周期的响应。

public interface IAppLifecycle {
  
    /**
     * 应用初始化
     
     * @param context
     */
    public void onCreate(Context context);

    public void onTerminate();
}

假设我们有组件ModuleA、ModuleB,这两个组件内分别有ModuleAAppLifecycleImpl、ModuleBAppLifecycleImpl,我们在壳工程集成时,可以在壳工程的Application.onCreate()方法里执行:

@Override
public void onCreate() {
    super.onCreate();
    IAppLifecycle moduleA = new ModuleAAppLifecycleImpl();
    IAppLifecycle moduleB = new ModuleBAppLifecycleImpl();
    moduleA.onCreate(this);
    moduleB.onCreate(this);
}        

有多少个组件,就手动构造多少个IAppLifecycle,并执行它的onCreate()方法。

这样的方式虽然可行,但是并不能达到我们期望的壳工程和业务组件间的代码隔离(虽然有依赖),因此我们可以用反射的方式进行优化:

通过采用硬编码组件名、解析配置文件等的方式去除在壳工程中对业务组件的代码依赖:

public static String[] moduleLifecycles = {
            "ModuleAAppLifecycleImpl",
            "ModuleBAppLifecycleImpl"
};

@Override
public void onCreate() {
    super.onCreate();
    for (String init: moduleLifecycles) {       
         try {            
        			Class<?> clazz = Class.forName(init);            
        			IAppLifecycle appLifecycle = (IAppLifecycle) clazz.newInstance();            
        			appLifecycle.onCreate(this);        
         } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {           
         			e.printStackTrace();       
         }  
    }
}     

上面的方式似乎解决了代码隔离的问题,但其实还是存在耦合性的,无论是硬编码还是在配置文件中配置,我们都需要知道各组件的组件名,而且每新增一个组件,我们都需要去修改壳工程的代码或者是配置文件,那有没有更好的方案能解决这种问题呢?

APT + 自定义Gradle插件 + ASM
原理流程
  1. 通过注解来标记实现了IAppLifecycle接口的类,在编译阶段扫描自定义注解
  2. 在特定的包名下生成各自的代理类
  3. 需要一个 组件生命周期管理类
  4. 在壳工程的Application#onCreate()中调用 组件生命周期管理类 扫描特定包名下的代理类
  5. 通过反射进行类的实例化

第三步中,由于需要在运行时扫描文件,因此每次应用冷启动时,都要读取并扫描全部class,而通常一个安装包里,加上第三方库,class文件可能数以千计、数以万计,因此这样的性能损耗是很大的。而对这一步的优化我们就可以采用 自定义Gradle插件 + ASM字节码修改技术了。

其中,自定义Gradle插件主要是通过Transform api在打包阶段将代理类统一放到一个jar中,从而减少不必要的对其他无关class文件的扫描,而ASM主要是动态的把代码插入到 组件生命周期管理类 中的注册组件代理类的方法中。

开源库

https://github.com/hufeiyang/Android-AppLifecycleMgr

开源的组件路由框架

准确来说是组件通信框架,路由是组件通信的其中一种手段,也并不是解决所有通信问题的通用手段,但是目前许多开源的组件通信框架都是以XRouter的形式来命名,所以就被称为路由框架了。

ARouter

功能介绍:

  • 支持直接解析标准URL进行跳转,并自动注入参数到目标页面中
  • 支持多模块工程使用
  • 支持添加多个拦截器,自定义拦截顺序
  • 支持依赖注入,可单独作为依赖注入框架使用
  • 支持InstantRun(还不太清楚如何支持的)
  • 支持MultiDex(Google方案)
  • 映射关系按组分类、多级管理,按需初始化
  • 支持用户指定全局降级与局部降级策略
  • 页面、拦截器、服务等组件均自动注册到框架
  • 支持多种方式配置转场动画
  • 支持获取Fragment
  • 完全支持Kotlin以及混编
  • 支持第三方 App 加固(通过transform api避免对dex文件进行扫描,加固后的app无法扫描dex文件)
  • 支持生成路由文档
  • 提供 IDE 插件便捷的关联路径和目标类

发起路由操作:

// 1. 应用内简单的跳转(通过URL跳转在'进阶用法'中)
ARouter.getInstance().build("/test/activity").navigation();

// 2. 跳转并携带参数
ARouter.getInstance().build("/test/1")
            .withLong("key1", 666L)
            .withString("key3", "888")
            .withObject("key4", new Test("Jack", "Rose"))
            .navigation();

定义activity

// 为每一个参数声明一个字段,并使用 @Autowired 标注
// URL中不能传递Parcelable类型数据,通过ARouter api可以传递Parcelable对象
@Route(path = "/test/activity")
public class Test1Activity extends Activity {
    @Autowired
    public String name;
    @Autowired
    int age;
    @Autowired(name = "girl") // 通过name来映射URL中的不同参数
    boolean boy;
    @Autowired
    TestObj obj;    // 支持解析自定义对象,URL中使用json传递

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ARouter.getInstance().inject(this);

    // ARouter会自动对字段进行赋值,无需主动获取
    Log.d("param", name + age + boy);
    }
}

定义接口实现:

// 如果需要传递自定义对象,新建一个类(并非自定义对象类),然后实现 SerializationService,并使用@Route注解标注(方便用户自行选择序列化方式),例如:
@Route(path = "/yourservicegroupname/json")
public class JsonServiceImpl implements SerializationService {
    @Override
    public void init(Context context) {

    }

    @Override
    public <T> T json2Object(String text, Class<T> clazz) {
        return JSON.parseObject(text, clazz);
    }

    @Override
    public String object2Json(Object instance) {
        return JSON.toJSONString(instance);
    }
}

编译期注解处理框架生成类文件,具体包括:

每个Route注解都有group属性,每个分组(group)都会生成一个ARouter$$Group$$<GroupName>类,实现IRouteGroup接口,在它的loadInto()方法中会将所有属于该分组的使用了Route注解的类的RouteMeta信息存入Map中,key是Route注解的path属性值。

ARouter$$Group$$GroupA implements IRouteGroup {

	public void loadInto(Map<String, RouteMeta> atlas) {
		atlas.put("/test/1", RouteMeta.build(RouteType.ACTIVITY, Test1Activity.class, ...));
		...
	}
}

每个组件生成一个ARoute$$Root$$<ComponentName>类,实现IRouteRoot接口,在它的loadInto()方法中会将所有分组对应的生成的分组类存入Map中,key是分组名。

ARouter$$Root$$ComponentA implements IRouteRoot {

	public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
		routes.put("GroupA", ARouter$$Group$$GroupA.class);
		...
	}
}

对于接口调用,组件向外提供的服务接口必须继承自IProvider接口。注解处理框架会为每个组件提供的服务接口生成一个ARouter$$Providers$$<ComponentName>类,实现IProviderGroup接口,在它的loadInto()方法中会将所有这些服务接口的实现类信息存入Map中,key是服务接口的完整路径。

ARouter$$Providers$$ComponentA implements IProviderGroup {

	public void loadInto(Map<String, RouteMeta> providers) {
		providers.put("com.xx.IXXInterface", RouteMeta.buid(...));
		...
	}
}

ARouter还提供了一个com.alibaba.arouter的gradle插件,该插件通过使用transform-api来实现在LogisticsCenter类的loadRouterMap()方法中代码,调用各组件中的IProviderGroup实现类、IRouteRoot实现类以及IInterceptorGroup实现类(也是注解处理器自动生成的)的loadInto()方法,从而将各组件的路由信息、提供的接口信息、以及拦截器信息都注册到LogisticsCenter中。

组件通信相关的所有信息由LogisticsCenter等类负责维护,组件间通信也是由它负责完成,比如,请求某个组件的接口时,LogisticsCenter根据提供的uri信息,定位到对应的接口实现类,通过反射创建该实现类的实例,然后返回给请求的组件。

ARouter不支持组件间跨进程通信。

CC

所有组件对外部暴露的接口完全保持一致:

package com.billy.cc.core.component;

public interface IComponent {

    /**
     * 组件名称
     */
    String getName();

    
    boolean onCall(CC cc);

}

组件间的页面跳转、接口调用都通过这个接口统一实现,各组件内部根据CC参数的具体动作名称将调用转发到具体的处理模块,或者调起具体的activity。因为每个组件向外部暴露的接口完全一致,无法通过接口确定所属组件,所以需要在调用之前从组件管理框架获取通过组件名显式地获取该组件的接口实例。

	//通过CC调用ComponentB,将此动态组件注册为用户登录状态的监听器
    CC.obtainBuilder("ComponentB")
        .setActionName("addLoginObserver")
        .addParam("componentName", loginUserObserverComponent.getName())
        .addParam("actionName", OBSERVER_ACTION_NAME)
        .build()
        .callAsync();

支持定义全局拦截器(IGlobalCCInterceptor)或者在构建调用参数时动态注册拦截器(CC.add(ICCInterceptor))。另外,该框架通过CC.enableRemoteCC()支持跨进程调用组件接口,它将内部的跨进程细节(IRemoteCCService跨进程接口)封装了起来,组件可以直接通过IComponent接口调用跨进程组件,而不用关心跨进程细节。

CC还提供了一个cc-register插件,在每个组件中应用此插件,配置组件提供的服务接口信息,可以实现组件接收到onCall()调用时,能够正确地将onCall()调用的命令分发到目标服务中。它的实现原理是利用transform-api,将接口注册到每个组件的IComponent实现类中,IComponent实现类根据onCall()调用的动作名可以查找到目标服务接口,进而对该服务接口进行调用。

project.apply plugin: 'cc-register'

...

ccregister.registerInfo.add([
    //在自动注册组件的基础上增加:自动注册组件B的processor
    'scanInterface'             : 'com.billy.cc.demo.component.b.processor.IActionProcessor'
    , 'codeInsertToClassName'   : 'com.billy.cc.demo.component.b.ComponentB'
    , 'codeInsertToMethodName'  : 'initProcessors'
    , 'registerMethodName'      : 'add'
])
public class ComponentB implements IComponent, IMainThread {

    private AtomicBoolean initialized = new AtomicBoolean(false);
    private final HashMap<String, IActionProcessor> map = new HashMap<>(4);

    private void initProcessors() {
    	// 在这里进行字节码插桩,调用add()方法
    }

    // 这个方法是通过字节码插桩的方式调用的
    private void add(IActionProcessor processor) {
        map.put(processor.getActionName(), processor);
    }

    @Override
    public String getName() {
        return "ComponentB";
    }

    @Override
    public boolean onCall(CC cc) {
        if (initialized.compareAndSet(false, true)) {
            synchronized (map) {
                initProcessors();
            }
        }
        String actionName = cc.getActionName();
        IActionProcessor processor = map.get(actionName);
        if (processor != null) {
            return processor.onActionCall(cc);
        }
        CC.sendCCResult(cc.getCallId(), CCResult.error("has not support for action:" + cc.getActionName()));
        return false;
    }

    @Override
    public Boolean shouldActionRunOnMainThread(String actionName, CC cc) {
        if ("login".equals(actionName)) {
            return true;
        }
        return null;
    }
}

CC支持跨进程的组件通信但是不完全解耦,通信时需要指定组件名,同时方法调用的易用性、可理解性较差,在通信上,由于需要对调用参数以及返回值进行包装以及解包装所以效率较低。

聚美Router

该框架只支持页面路由和动作路由(类似消息总线,但是消息的接收方是固定的且只有一个),不支持组件间接口调用的通信方式。组件通信共用的接口通过接口下沉的方式,放到一个统一的组件中。

也是通过编译期注解生成路由表类,生成的路由表类名统一为RouterRuleCreator,然后即可通过以下代码进行路由表注册使用:

RouterConfiguration.get().addRouteCreator(new RouterRuleCreator());

该框架没有提供gradle插件,所以不支持自动添加注册代码。

聚美Router同样也不支持组件间跨进程通信。

WMRouter

主要提供页面路由、ServiceLoader两大功能,用于组件间页面跳转和组件间接口调用。

实现原理和ARouter也类似于ARouter。

补充

每个框架都提供注册拦截器对页面路由(部分框架也支持接口调用)的拦截处理,每个框架注册拦截器的方式可能不尽相同,但是拦截器的作用基本都是相同的。例如,当某个组件想要跳转另外一个组件的_商品购买页面_时,由于购买需要登陆,所以会针对该跳转的uri注册一个拦截器,用于拦截该请求,跳转到_账户登陆页面_。

云门店组件化现状

云门店目前抽出了两个功能组件和四个业务组件,已抽出的业务组件目前并不支持单独的运行调试、由于是单工程形式的导致组件间的代码并不是严格隔离的,耦合较严重,且还有待抽离的组件,所以严格来说并不算是一个组件化的项目。

页面跳转

云门店中组件间和组件内的页面跳转主要采用的是ARouter路由,少量页面之间用的是startActivity方式,这种方式在组件间的跳转也会导致代码的耦合。

接口调用

组件间的接口调用在云门店中的使用较少,主要是通过页面跳转传参以及消息发布的方式。

消息发布

EventBus

结论

开源组件路由框架选择

Arouter——由于云门店已经集成了Arouter,且Arouter也满足日常的开发以及后续的组件化优化需求

组件化方案技术选型

  • 多工程
  • 组件下沉 + 组件暴露api
  • 生命周期抽象类 + 配置文件

架构

在这里插入图片描述
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值