Skip to content

HudsonAndroid/Component-Strategy

Folders and files

NameName
Last commit message
Last commit date
Jun 1, 2022
Jun 1, 2022
Jun 1, 2022
Jul 5, 2022
Jun 1, 2022
May 31, 2022
May 30, 2022
Jun 1, 2022
Jun 1, 2022
Jul 22, 2022
Jul 22, 2022
May 30, 2022
Jul 22, 2022
Jul 11, 2022
Jun 1, 2022
May 31, 2022
May 30, 2022
May 30, 2022
May 30, 2022
Jul 22, 2022
May 31, 2022

Repository files navigation

Component-Strategy 组件化方案

组件化方案优点:

  • 充分体现高内聚,低耦合特性,益于后续维护升级
  • 各个组件单独测试,编译速度提高
  • 功能业务重用
  • 团队并行开发,效率提升

组件化示例图

1.组件化的目标

  • 1.各个组件无耦合关系,相互独立,可拔插
  • 2.组件可以单独测试验证或独立运行

组件构成上,由最上层的app壳 + 本身app主功能业务组件 + 常规业务组件(和基础业务组件) + 基础功能组件构成。

本身app主功能业务组件跟app本身功能关联度最大,与常规业务组件不同,能够被复用于其他应用的可能性更低。

壳工程作为“傀儡”,仅负责处理启动屏,和统筹依赖其他业务组件以及app主功能业务组件。

为了统一管理各个组件的依赖库版本,以及统一使用一个Gradle版本构建,因此新增一个统一的gradle配置文件(将该文件作为远程依赖,远程可视化视图配置参数将更便捷地控制版本)。

1.2 业务组件的可测试性

为了确保业务组件本身可以单独进行除了基础的单元测试之外,还能进行GUI测试,因此应该确保业务组件本身的可应用化的特性。

因此需要手动控制部分代码文件,以确保在组件自我测试验证时保持应用的主体性,而在作为组件引入其他上传业务组件中时作为组件模块提供功能

借助于Gradle的sourceSets和android的application和library特性来实现这一点。

1)控制组件和应用的特性来回切换

公共Gradle配置文件中新增是否集成的标记flag,通过该标记控制是组件还是应用。

注:为了考虑到组件自身的独立控制性,可以考虑将标记下沉给组件开发方控制,此处为了多个模块统一控制,使用公共Gradle配置文件。

flags = [
    // 控制业务组件是否集成,如果是false,业务组件将作为应用程序方式运行,即构建产物是apk
    isRelease: false
]

组件中的配置:

// 1) 切换application和library
plugins {
    // 不能这样使用
//    if(flags.isRelease){
//        id 'com.android.application'
//    }else{
//        id 'com.android.library'
//    }
    id 'org.jetbrains.kotlin.android'
}

// 必须按照旧版Gradle的方式
if(flags.isRelease){
    apply plugin: 'com.android.application'
}else{
    apply plugin: 'com.android.library'
}

// 2)切换applicationId
android {
    defaultConfig {
        if(!flags.isRelease){
            applicationId "com.hudson.order"
        }
    }
}

注意:

  • 1.示例工程的Gradle版本是7.4.2,Gradle新增了plugins{}方式引入Gradle插件,但是内部不支持其他声明,包括If-else。 因此需要使用旧版的引入方式,即apply plugin
  • 2.plugins的声明必须优先于其他声明。 例如把apply plugin段放到plugins前面将会报错

2)控制Manifest中启动activity的状态

当业务组件被当成组件接入壳工程时,由于本身可以被配置成应用,所以manifest中的启动activity是有配置的,这样导致的结果是我们安装app壳工程时,将会在桌面上产生多个启动图标

为了解决这个问题,manifest文件需要根据不同环境因素而有不同的表现形式。

为此我们可以借助Gradle的sourceSet功能在非集成状态下,指定另一份manifest源码文件。

android{
    sourceSets {
        main{
            if(flags.isRelease){
                manifest.srcFile 'src/main/AndroidManifest.xml'
                // 打包时要排除掉dev目录
                java {
                    exclude 'src/main/dev/'
                }
            }else{
                manifest.srcFile 'src/main/dev/AndroidManifest.xml'
            }
        }
    }
}

由于组件化特性,本身部分组件仅有一些自身的主体逻辑的情况下,是无法走完自我测试验证的GUI整体流程的

例如分享组件,本身实现的功能就是分享,那么GUI测试的话,必然需要一个页面承载主动调起分享组件的能力,这个时候就需要一个页面,且携带一个按钮,按钮点击后,带上分享相关的参数,调用分享组件实际主体业务,以完成功能测试,而不是得依赖其他的应用功能来完成自测.

这种情况下,我们可以手动在非集成情况下增加源码、资源的搜索路径,然后这些页面仅在非集成状态下可以被正常调用。

   sourceSets {
        main{
            if(flags.isRelease){
                manifest.srcFile 'src/main/AndroidManifest.xml'
                // 打包时要排除掉dev目录
                java {
                    exclude 'src/main/dev/'
                }
            }else{
                manifest.srcFile 'src/main/dev/AndroidManifest.xml'

                // 增加源码搜索路径 src/main/dev目录
                java{
                    srcDir 'src/main/dev'
                }

                // 参考 https://developer.android.com/studio/build/build-variants#sourcesets
                res.srcDirs = ['src/main/res/', 'src/main/dev/res/']
            }
        }
    }

这样,在非集成模式下,代码源将会增加dev目录下的内容,包括资源文件(这里是layout布局文件)

仅非集成模式下测试视图

我们在主体代码中增加GUI测试的预留入口(仅示例)

非集成模式下GUI测试预留入口

这样单独运行业务组件(此时为应用程序状态),我们可以进入到dev中定义的页面DebugPageActivity中去。

而当我们切换成组件状态(即集成模式)下,DebugPageActivity是没法被找到的,即预留入口本身无意义。

集成模式下结构

总结

这样能保证,

在集成模式下,组件正常以子功能/子模块的形式被引入到壳工程中

非集成模式下,组件以应用的身份且可以运用仅开发时期的页面完成相关的GUI测试验证。

2.路由实现

经过上面组件拆分后,各个组件之间的页面跳转还是依赖了Activity的startActivity方法,这样导致跳转组件涉及的双方会有直接的类依赖关系,未完全解耦。

因此有必要提供一个路由工具,将各个业务组件整合起来,解除各自的依赖关系。

这个首先想到的就是ARouter框架

我们手动来实现类似ARouter的功能。

2.1 APT(Annotation Processing Tool)

利用注解动态动态生成代码已经非常常见,像ButterKnife、Dagger、EventBus、ARouter等都是借助了APT注解处理器来完成的。

其中EventBus是没有借助任务第三方代码生成工具,像写字符串一样完成的java文件的动态生成。

一般情况会借助JavaPoet来完成java文件的动态生成。

2.2 自定义注解和注解处理器

1)新建自定义注解HRoute

@Target(AnnotationTarget.CLASS) // 作用在类上
@Retention(AnnotationRetention.SOURCE) // 编译期生效
annotation class HRoute(
    val path: String,
    val group: String = "" // 一般指定为组件名
)

2)新建自定义注解处理器HRouter-Annotation-Processor

@AutoService(Processor::class)
@SupportedAnnotationTypes("com.hudson.hrouter.annotation.HRoute") // 需要处理的注解类
@SupportedSourceVersion(SourceVersion.RELEASE_8)
class HRouterAnnotationProcessor: AbstractProcessor() {
    // ...
}

注意:

  • 1.注解处理器module需要依赖HRouter注解module
  • 2.kotlin中要使用auto-service的话要借助kapt,而不是annotation-processor
  • 3.注解处理器一旦make project一次之后,后面make project不会触发处理器的逻辑处理,需要先build clean之后重新make project

3)依赖关系梳理

注解HRoute可能在各个业务组件的各个页面都要使用,因此将HRouter注解的依赖通过api方式放入基础功能组件common中;

而注解处理器由于kapt或者annotationProcessor只对当前module有效且不向上传递依赖,因此注解处理器需要在各个需要配置路由的业务组件上增加依赖。

4)附录

编译器给注解处理器传递参数。

大部分情况下,一个组件的页面都同属于一个路由组,不同组件处于不同的路由组,因此可以给路由组直接传递模块名或者模块标识。

我们可以直接给注解处理器在处理模块时传递模块名或模块标识,用于统一归纳路由组。

    // 给内部的注解处理器传递参数
    javaCompileOptions {
        annotationProcessorOptions{
            arguments = [
                HRouterGroup: name,// 路由分组直接指定为模块名
                RouterPkg: hroute_info.packageName // 路由代码生成的包名
            ]
        }
    }

给注解处理器传递参数1

给注解处理器传递参数2

2.3 路由表的维护

在ARouter中维护了多张路由映射关系表,并在初始化阶段会加载ARouter指定APT的生成包名下遍历查找所有的生成类,参见ARouter的LogisticsCenter的init方法。

ARouter中分为group和实际的path,一般情况下group可以不用设置。

一个组件中可能存在多个group,因此ARouter对应于每一个组件都会生成一个Group的集合类(不是Collection类型,而是指承载了一个group集合的类),这个类的类名有组件名参与组成文件名规则,因此ARouter需要在Gradle中配置注解处理器的传递参数

android {
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = [AROUTER_MODULE_NAME: project.getName()] // 传递组件名
            }
        }
    }
}

另外还需要生成一张每个group中配置的路由集合表的类,这个类的文件名规则需要group参与。

尽管如此,可能存在的冲突问题是,假设A组件声明了路由group为 order; B组件声明的路由group也为 order,那么生成的最终文件由于只有group参与文件名规则,因此必然存在冲突,这个是需要注意的地方。 (可以参考这篇文章

2.3.1 组件路由组表的维护

为了更好地管理当前组件中不同的路由组group,因此可以将组件中不同group的路由集合归纳到一个集合中,通过group名可以在该集合中找到对应的group中所有路由的集合,整体关系大致如下:

路由表结构图

前面提到,为了避免各个组件的组件路由Group文件名冲突,因此加入组件名参与文件名的规则。

但是考虑到运行期间,应用程序无法知晓所配置的路由具体属于哪个组件(所以建议上路由group与组件名存在一定关系),因此需要在初始化阶段加载所有组件各自对应的组件路由Group表,合并路由Group表(注:不同的组件不能定义相同的group,因为会在生成Group-Path最底层表的时候出现重复类文件问题,见上面分析)。

性能问题的考量:

一个组件对应了一个组件路由Group表类,需要经过反射来实例化这个类(性能损耗点1);另外运行期间无法知晓路由组group与路由Group表类的关系,因此必须初始化阶段就将所有路由Group表类初始化,如果组件化工程中组件数量繁多的话,可能需要耗费比较大的时间去处理(性能损耗点2,即没法做到按需加载,懒加载,因为无法通过group知道具体该实例化哪个组件路由Group表)。

另外,为了确保只加载组件路由Group表类,而忽略其他类(例如Group与Path映射表),组件路由Group表类的类名需要按照一定的规则前缀来确保正确识别。(其他类应该确保类名规则出现该规则,此逻辑与ARouter的表初始化类似)

运行阶段路由组件直接在生成类的包下查找所有组件路由Group表的类,并加载到内存中。(过程涉及对指定包的类查找,可以参考ARouter中的实现

2.3.2 Group-Route子表的管理

相比之下,Group-Route子表的管理就简单得多,因为每一个group都将对应生成一个维护了该组group路由集合的类,而类名除了固定模板仅与group名关联。(正因为如此,不同组件不能设置相同的group,否则将会创建同包同名的类)

2.3.3 总结示例

例如有如下组件结构:

组件结构示例

那么对应的生成类关系是:

组件示例辅助类生成.png

说明:

  • 每个组件对应会生成一个组件Group表类,该类包括了定义在该组件中的所有Group信息;

  • 每个group对应又生成相应的group-route的路由表类,该类定义了在该group中所有的Route信息;

  • Route信息维护了对具体页面的描述,包括了页面的全类名信息

2.4 路由参数管理

路由过程免不了需要传递参数信息,因此需要一套路由参数管理机制。

我们设想的路由发起端是这样的:

RouterManager.getInstance()
    .build("/order/OrderMainActivity")
    .withString("name", "张三")
    .withString("age", 20)
    .navigation(this);

而路由的目标端是这样的:

@HRouter(path = "/logic/main", group = "/logic")
class AppMainActivity : AppCompatActivity() {

    @Parameter
    var greet: String? = null


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        ParameterInjectorManager.inject(this)
    }
}

可以看出,目标端的逻辑非常像依赖注入框架Dagger或者Hilt的操作。 没错,这里就是我们手动帮助AppMainActivity完成对greet变量的初始化,即外部注入变量值。

因此被注解的变量必须是开放且可被修改的。 所以参数目标的代码所要实现的就是一个依赖注入的过程。

1) 注入器类的动态生成

为了统一管理所有页面的注入器类,因此需要定义一个注入器的接口类型

然后由路由参数注解处理器解析路由参数注解信息,动态生成一个页面对应的注入类。

路由注入器生成过程

注:kotlin中定义变量后,默认是public的,但本质是携带有getter和setter的变量,如果在java中访问kotlin的该变量是不能直接通过参数获取的,否则将会报如下错误:

// 被注入的类(kotlin)
@HRouter(path = "/logic/main", group = "/logic")
class AppMainActivity : AppCompatActivity() {

    @Parameter
    var greet: String? = null
}

// 注入器(java)
public class AppMainActivity_ParameterInjector implements ParameterInjector {
  @Override
  public void inject(Object targetObject) {
    AppMainActivity t = (AppMainActivity)targetObject;
    t.greet = t.getIntent().getStringExtra("greet"); // 出错
  }
}


...\Component-Strategy\app_logic\build\generated\source\kapt\debug\com\hudson\logic\AppMainActivity_ParameterInjector.java:11: 

错误: greet 在 AppMainActivity 中是 private 访问控制
t.greet = t.getIntent().getStringExtra("greet");

解决方案:

  • 1.参考Hilt依赖注入框架的方式,将变量设置为lateinit var
    1. 给字段增加 @JvmField 注解,这样就能在java中直接访问
  • 3.类注入器采用kotlin实现,即利用KotlinPoet完成

这个问题在ARouter官方也有提及

2) 注入器的统一管理

与Hilt/Dagger类似,注入器需要通过统一的管理,相当于维护一个注入器存储中心

与DNS解析过程类似,每当外界需要注入的时候,外界需要传递一个路由页面类型(解析前的域名);

注入器存储中心拿到该信息后,去存储中查询(DNS服务器查找是否有对应IP),找到后返回给调用者;

调用者拿到结果继续完成注入过程。

因此注入器的管理中心可以设计成一个key-value的map存储中心。 另外考虑到App页面较多情况下,可能没必要缓存过多层级可以将map结构设计为LruCache缓存。

3)自动完成依赖注入

添加了路由参数注入器管理者后,我们只需要在注入的页面合适的位置手动调用inject方法即可完成路由参数的传递。

但是这个操作过程基本也是固定的模板代码,因此可以把这段逻辑自动化。

要解决的问题是在现有页面代码上初始化位置自动附加上注入逻辑,可以考虑的方案有:

  • 1.如果只是Activity路由跳转,通过Application.registerActivityLifecycleCallbacks注册监听所有Activity的生命周期,在onCreate方法中直接注入。(或者类似可以监听到页面生命周期的处理方案)

壳app自动帮助完成注入ShellApplication.kt

class ShellApplication: Application() {

    override fun onCreate() {
        super.onCreate()
        HRouter.initAsync(this)

        autoInjectActivityPage()
    }

    private fun autoInjectActivityPage(){
        registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks{
            // ....

            override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
                injectActivity(activity)
            }
        })
    }

    private fun injectActivity(activity: Activity){
        try{
            ParameterInjectorManager.inject(activity)
        }catch (e: Exception){
            // e.printStackTrace() ignore, 对于那些没有注册路由的页面
        }
    }
}

注:上面忽略了异常情况,可以不忽略,通过Activity上配置的路由注解来过滤出可以注入的Activity。 但是由于HRoute注解仅在编译期生效,因此需要要修改下HRoute注解的生效期。

  • 2.为了避免破坏代码编写者的逻辑,通过ASM操纵字节码修改对应初始化方法的代码,覆盖原有class文件的方案

2.5 路由功能的测试验证

我们要达到的目的是,不直接引用任何其他组件的类,而跳转到对应组件的页面中去。 经过路由组件后,我们达到了如下效果:

  • 1)App壳依赖了其他业务组件,但仅停留在dependencies引入的层面,没有任何其他的耦合关联。
  • 2)除了App壳之外的其他业务组件之间没有任何的直接关联,即使是dependencies也没有,但依然可以实现页面跳转。

比如实例代码logic的页面AppMainActivity实现了跳转到没有任何关联的Product组件页面中去。

3.总结

壳工程负责统筹所有业务组件,以及启动页、相关的公共初始化等功能; 业务组件各自专职负责自身擅长的业务功能; 基础组件负责提供公共的基础功能实现; 路由框架贯穿各个业务组件的沟通桥梁。

总结

组件化方案,在确保应用和组件快速切换,保证独立测试验证的前提下,使得各个业务组件之间的耦合关系彻底断开,各个业务组件的共同宣言:

我们各自独立,但又通过Router框架保持沟通,共同扛起了APP的功能大旗。

Good Brother

参考文档

  1. 工程-study_module
  2. 视频-Android组件化实战
  3. Android官方-配置 build 变体
  4. Gradle官方-配置sourceSets
  5. Android官方-使用Hilt实现依赖注入
  6. ARouter-动手撸一个ARouter (ARouter源码分析)
  7. ARouter-【Android进阶】 这次我把ARouter源码搞清楚啦!
  8. ARouter-ARouter源码浅析
  9. ARouter-(4.2.40)阿里开源路由框架ARouter的源码分析

About

组件化方案

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published