Android组件化渐进方案

291 阅读13分钟

一、背景

我们在项目中为什么要做组件化方案,很多人都会说现在的Android开发中都是组件化开发了,不用这个落后了。确实成熟的持续迭代的项目都是组件化开发了,但是我们更要搞清楚为什么要做组件化方案,它的优缺点是什么,这样我们才能在项目中灵活使用,避免踩坑,耽误进度。

做技术类的方案都需要对自己提出以下问题:

  • 为什么一定要做这个,优缺点在哪里?
  • 做这个的好处什么,怎么做才是最好的,最合适的?

针对于以上两个问题,我们从接下来的分析里给出答案。

1.1 为什么做组件化

1.1.2 项目介绍

从项目角度来看,我们有2个APP需要维护,一些基础功能类似,我们在前期的开发中,为了快速迭代,将基础库作为拷贝不同的项目中进行的代码复用,整体项目持续迭代了一年多的时间。

1.1.2 遇到了什么问题

可以针对性的列下我们在项目迭代过程中遇到的问题:

  • 2APP拷贝代码,如果有一个问题需要修复,也需要拷贝代码
  • 一些像网络,下载,监控等基础功能也在不断优化,2APP都需要功能相同,但是配置是不一致,需要拷贝代码再进行修改,容易改出问题,开发测试也需要再来一遍
  • 业务越来越多,编译时间越来越长,例如我开发一个首页页面,其实不需要直播的依赖,但是现在依赖较多,不可能单独编译,研发体验不好

从以上来看,我们需要一个新的解决方案,来解决我们现在的问题,它需要做到以下几类事情:

  • 针对于一些基础库,网络,下载,监控等,我们需要它作为一个基础功能,需要有用统一的库来保证它的一致性,又需要自定义一些接口,让APP可以灵活实现
  • 针对于一些业务功能开发,可以独自编译,而不引入此业务功能无关的依赖,导致编译过久,提升开发体验
  • 针对于主APP尽可能的做薄,分成业务功能库和基础库,来做实际的事情,主APP尽量配置

那我们对于我们APP的理想架构模型就出来了: 可以更详细的解释下,我们如何分层的:

  1. 基础库,代表的是原子组件,也就是说他不需要依赖其他的同级基础库,它自己就是一个功能,例如,网络只需要做网络请求的事情,那我们定义就是一个请求接口,让APP1APP2各自配置不同的Client即可
  2. 功能库,代表的是原子组件的集合,例如我像让登录做成一个SDKAPP1APP2的逻辑都是一致的,那我们登录需要,网络请求,埋点,监控等多个原子组件的依赖才能实际使用
  3. 业务模块库,也就是具体的业务功能库,这个模块是业务变化最频繁的地方,例如我们的首页,我的页,播放器页等不同的页面相关的,它在不同的APP逻辑,UI展示等都是不同的,它需要基础库加功能库来组合搭建才能形成

这样模块部分就分好了,可能大家对于如何引用不太直观,那我们可以来举个栗子:

// 基础库,一些接口模块
module(:network-interface)//网络
module(:share-interface)//share

// 网络实现库,app1-实现,它可以不是一个库,而是在APP1里的实现,为了举例子先暂时这样写,更直观
module(:network-impl1)
module(:network-impl2)

// 功能库,以分享举例
module(:share) {
    implementation :share-interface
    implementation :network-interface // 这样就有了网络能力
    implementation :ui-common // 这样就有了UI能力
    implementation :trace-interface // 这样就有了监控能力
    implementation .....// 依赖其他的原子库
}

// 分享一般都要有SDK,所以可以有不同的实现
module(:share-vivo) {
    implementation :share-interface
    implementation :network-interface // 这样就有了网络能力
    implementation :vivo-push // 真正的vivopush
}

// APP首页模块
module(:app1-home) {
    implementation :share-interface // 需要分享
    implementation :network-interface // 需要网络
}

// 真正的APP
module(:app1) {

    implementation :ui-common

    implementation :share-interface
    implementation :share
    implementation :share-vivo

    implementation :network-interface
    implementation :network-impl1

    implementation :app1-home // 首页
}

简单的写了下各个部分需要的模块依赖,总体上是符合SOLID的模式来做的。

二、方案设计

针对于上述我们存在痛点,我们需要组件化的方式来改造我们的APP,那么就会遇到一下几个问题

2.2 具体问题分析

在我们实施的过程中,有一些详细问题还需要了解清楚,可以列出来参考下:

  1. 代码如何存放管理
  2. 单独的模块如何打包
  3. 模块如何集成到宿主,如何调试
  4. 组件间如何解耦

这些问题基本都是我们在组件化阶段遇到的问题,我们按照需要来做一个优缺点分析和梳理:

2.2.1 代码管理

新增代码是考虑单个仓库还是多个仓库,考虑到多个仓库切换起来太麻烦,我们选择了单仓的实现,将所有的基础功能和功能库还是都放到一个新的仓库里来做,而业务模块库还是放到原来APP的仓库里,业务库由于会频繁改动所以还是以module的方式依赖。

2.2.2 打包

单个仓库对应多个module,那么我们需要打包时提供能选择module的能力。 由于会有注解和注解处理的库,所以打包的gradle脚本需要兼容AndroidJava不同模式的打包。 我们之前在打包的时候有一个问题时,需要先手动更新版本号然后再打aar,人工改很费事,所以需要一个提供自动升级版本号的功能,我们在打包的时候先自动升级对应module的版本号,然后再打包即可,需要自动化处理。

2.2.3 调试能力

基础和功能组件需要单独调试的能力,需要有一种方式来灵活替换,低成本引入。

针对于业务模块库我们想要实现解耦的话,很多的组件化会设计成调试的时候是一个独立的APK,集成的时候是一个单独的module。那我们在想,我们需要这么做吗?

考虑之前经验,我们在做flutter开发时,也是flutter有一个单独的工程可以进行调试和使用,自测没问题了再集成到APP的主工程中;但我们开发中总是会遇到和宿主中其他模块更新的问题,举一个例子native打开一个flutter页面,flutter页面刷新之后需要通知给前一个native页面,我们实现了统一桥来解决这个问题,但是使用中测试总是反馈更新不对的问题,那我们排查就需要知道是native接收错误,还是flutter发送错误,又或者是桥错误(只是简单举例,实际中可能有更复杂的场景)那我们需要对整个APP进行debug,这样会很麻烦,降低我们开发效率。

那我们想怎么做呢?我们如果朝着解耦的目的,APP只是一个配置实现的话,那我们的宿主APP不就是可以看做一个独立的APP使用的吗,那我们不需要自己在一个业务模块中在开发时切换成应用,集成时再变成aar集成了;把宿主APP做成一个壳,这样的话我们开发过程中只需要引入正在开发的模块即可,通过统跳来跳转,例如我在开发首页,不需要依赖直播,那么不引入直播库,就可以加快编译;但是我们的需求除了首页还有视频页也需要修改,那我只引入这两个模块即可,他的调试和实际使用路径都是和真实测试环境一样的,不存在说开发的时候是好的,集成到APP里了它不工作了,可以在开发阶段就解决问题。

2.2.4 解耦

由于路由框架,我们原来内部有一个实现,所以这次就不重复造轮子再做了,我们就需要来做组件间解耦就好了,组件间解耦有好多文章里也都写过了,它的目的和原因是啥,大家应该都懂他的作用,具体实现有ARouterWMRouterTheRouter等。

上述几个库都比较完善代码较多,而且我们引入的话编译可能也有问题,我们项目中已经有booster了,修改字节码也较为简单,那我们自己也需要实现一个解耦库,也是通过编译期注解+ASM修改字节码的方式来实现的。

2.2.5 计划总结

我们需要做的事情可以列出来看下:

  1. 创建一个代码库:为了避免库太多新增一个代码库,把2APP都需要的基础库和功能库放进来
  2. 提供打包能力:我们在不同的模块需要在jenkins上支持单独打包能力
  3. 宿主单独调试能力:我们需要一中简单的方式来在宿主中调试组件源码
  4. 组件间解耦能力:业务间相互耦合需要解开,统一跳转能力(由于我们项目中有原来自己写的统跳能力,够用了,先暂时不处理)
  5. 基础库拆分:先做基础库,网络,下载,埋点,组件等基础部分
  6. 功能库-业务库拆分:各个同学认领不同的功能库和业务库,统一实现
  7. 文档沉淀,新人可以快速上手

团队规模不大,所有的同学都需要参与进来,我完成了前4个部分之后,让其他同学在逐步进入,需要按照之前负责的功能和业务来分同学负责,让每一个同学都能负责一部分,这样的话有问题修复,或者代码合入,都需要找这个组件负责同学,提升大家的责任意识和也更有成就感!

三、方案实现

我们的问题分析出来了,也清楚了我们要的目标,接下来我们就可以编码实现了!

3.1 基本配置实现

创建代码库,支持分module打包能力,提供自动升级aar版本号,提供sample工程这些都较为简单和繁琐,我们直接贴关键代码即可:

apply plugin: "maven-publish"

// 每一个模块的数据模型
class UploadMavenPublishExtension {
    String groupId
    String artifactId
    String version
}

project.extensions.add("upload_maven", UploadMavenPublishExtension)
def isAndroid = project.extensions.findByName("android") != null

task sourceJar(type: Jar) {
    if (isAndroid) {
        from android.sourceSets.main.java.srcDirs
    } else {
        from sourceSets.main.allJava
    }
}

task getAarVersion() {
    doLast {
        def upload_maven = project.getExtensions().findByName("upload_maven")
        println upload_maven.version
    }
}

// jenkins打包时自动升级当前lib的版本号
task incrementVersion() {
    doLast {
        def upload_maven = project.getExtensions().findByName("upload_maven")
        def v = upload_maven.version //get this build file's text and extract the version value
        if (v.endsWith('-SNAPSHOT')) {
            return
        }
        String minor = v.substring(v.lastIndexOf('.') + 1) //get last digit
        int m = minor.toInteger() + 1                      //increment
        String major = v.substring(0, v.length() - 1)       //get the beginning
        String s = buildFile.getText().replaceFirst("version \"$v\"", "version \"" + major + m + "\"")
        buildFile.setText(s) //replace the build file's text
        println major + m
    }
}

project.afterEvaluate {

    def upload_maven = project.getExtensions().findByName("upload_maven")

    println "upload_maven.groupId:" + upload_maven.groupId
    println "upload_maven.artifactId:" + upload_maven.artifactId
    println "upload_maven.version:" + upload_maven.version

    publishing {
        publications {
            maven(MavenPublication) {

                groupId upload_maven.groupId
                artifactId upload_maven.artifactId
                version upload_maven.version

                from isAndroid ? components.release : components.java

                //配置上传源码
                artifact sourceJar {
                    classifier "sources"
                }
            }
        }
        repositories {
            maven {
                //指定要上传的maven私服仓库
                url = XXXXX
                //认证用户和密码
                credentials {
                    username XXXXX
                    password XXXXX
                }
            }
        }
    }
}

3.2 aar的调试方案

参考了网上其他同学的实现:

import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.reflect.TypeToken

buildscript {
    //依赖仓库源
    repositories {
        maven { url 'https://maven.aliyun.com/nexus/content/repositories/google' }
        maven { url 'https://maven.aliyun.com/nexus/content/groups/public' }
        mavenCentral()
        google()
    }
    dependencies {
        //为当前脚本添加解析gson的依赖
        classpath "com.google.code.gson:gson:2.8.5"
    }
}

println("开始debug_aar")

List<ModuleSource> list = loadDebugConfig()

for (ModuleSource module : list) {
    if (module.debug) {
        include ":${module.localName}"
        //gradle8弃用了/xx/xx相对路径的形式,所以用使用$绝对路径
        project(":${module.localName}").projectDir = file("${module.sourceDir}")
        println("debug外部模块[:${module.localName}],源码路径 ${module.sourceDir}")
    }
}

if (list.size() > 0) {
    gradle.addProjectEvaluationListener(new ProjectEvaluationListener() {
        @Override
        void beforeEvaluate(Project projectObj) {
        }

        @Override
        void afterEvaluate(Project projectObj, ProjectState state) {
            projectObj.configurations.all { config ->
                config.resolutionStrategy.dependencySubstitution {
                    for (ModuleSource ms : list) {
                        if (ms.debug) {
                            substitute module(ms.aarName) with project(":${ms.localName}")
                        }
                    }
                }
            }
        }
    })
}

def loadDebugConfig() {
    List<ModuleSource> list = new ArrayList<>()
    String json = null
    try {
        json = file("debug_aar_config.json").getText()
    } catch (ignored) {
        println("根目录不存在debug_aar_config.json文件。(如果不需要debug aar源码忽略该信息)")
    }
    if (json == null) {
        return list
    }

    //解析debug_source_config.json中的字段
    List<ModuleSource> result = new Gson().fromJson(json, new TypeToken<List<ModuleSource>>(){}.getType())
    return result
}

class ModuleSource {

    /**是否调试aar*/
    boolean debug = false

    /**引入module名字*/
    String localName = null

    String aarName = null

    /**绝对路径*/
    String sourceDir = null
}

//需要调试的二模块
[
  {
    "debug": true,
    "localName": "xxxxxx",
    "sourceDir": "../xxxxxx",
    "aarName": "xxxxx"
  }
]

我们看核心代码即可,使用的是config.resolutionStrategy.dependencySubstitution中的substitute来做的源码和aar的替换。

3.3 组件间解耦通信方式

我们整体的实现思路,同ARouterTheRouter类似,是使用的注解和注解处理器来进行编译期处理一个lib里的接口和实现的对应关系,然后再利用booster来修改字节码做聚合,基本实现方式都大同小异了,核心代码可以看下:

首先先定义注解库:

import kotlin.reflect.KClass

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class ServiceProvider(val service: KClass<*>)

然后在主机处理器中生成文件,核心代码:

private fun parseServiceProvider(roundEnv: RoundEnvironment) {
    val elementSet = roundEnv.getElementsAnnotatedWith(ServiceAnnotation::class.java)
    elementSet.forEach { element ->
        val interfaceName = element.getAnnotationClassValue<ServiceAnnotation> { service }.toString()
        val implName = element.toString()
        if (interfaceToImplMap[interfaceName] == null) {
            interfaceToImplMap[interfaceName] = implName
        } else {
            throw ServiceProcessorException("$implName not is $interfaceName impl, already have one impl :${interfaceToImplMap[interfaceName]}")
        }
    }
    interfaceToImplMap.forEach { (t, u) ->
        println("$TAG, key:$t  -> value:$u")
    }
    createServiceProviderMapFile(interfaceToImplMap)
}

private fun createServiceProviderMapFile(interfaceToImplMap: MutableMap<String, String>) {
    if (interfaceToImplMap.isEmpty()) {
        return
    }
    val path = processingEnv.filer.createSourceFile(PACKAGE_NAME + POINT + CLASS_NAME).toUri().toString()
    println("$TAG path:$path")
    val className = CLASS_NAME + kotlin.math.abs(path.hashCode()).toString()
    val jfo = processingEnv.filer.createSourceFile(PACKAGE_NAME + POINT + className)
    val genFile = File(jfo.toUri().toString())
    if (genFile.exists()) {
        genFile.delete()
    }
    PrintStream(jfo.openOutputStream()).use { ps ->
        ps.println(String.format("package %s;", PACKAGE_NAME))
        ps.println()
        ps.println("import java.util.HashMap;")
        ps.println("import java.util.Map;")
        ps.println()
        ps.println("/**")
        ps.println(" * Generated code Don't modify!!!")
        ps.println(" */")
        ps.println(String.format("public class %s {", className))
        ps.println()
        ps.println("    public static Map<String, String> getMapper() {")
        ps.println("        HashMap<String, String> map = new HashMap<>();")
        interfaceToImplMap.forEach { (k, v) ->
            ps.println(String.format("        map.put(\"%1s\", \"%2s\");", k, v))
        }
        ps.println("        return map;")
        ps.println("    }")
        ps.println("}")
        ps.flush()
    }
}

从代码里可以看到,getMapper方法返回的就是当前lib的服务和实现的集合。 最后我们看下字节码如何处理的核心代码,这部分由于我们已经集成了booster那直接booster来做更简单(关于booster使用不清楚的可以直接看文档来做:booster,能干很多事情,是一个很强大的库,推荐大家使用),看下核心的代码

//Booster提供了一个Collector能力,可以先搜集成功之后再进行注入,详细的可以看文档。
class ServiceProviderCollector(call: (name: String) -> Unit) :
    AbstractSupervisor<String>(call) {

    private companion object {
        //每个模块都会生成的文件,都是这样的格式匹配开头即可
        const val MAPPER_CLASS = "xxxx/xxx/xxx/ServiceProvider_"
    }

    override fun accept(name: String): Boolean {
        return name.startsWith(MAPPER_CLASS) && name.endsWith("class")
    }

    override fun collect(name: String, data: () -> ByteArray) {
        action(name)
    }
}

// 核心代码
private val serviceProviderList = mutableListOf<String>()

private val serviceProviderCollector = ServiceProviderCollector {
    serviceProviderList.add(it)
}

override fun onPreTransform(context: TransformContext) {
    context.registerCollector(serviceProviderCollector)
}

override fun onPostTransform(context: TransformContext) {
    context.unregisterCollector(serviceProviderCollector)
}

override fun transform(context: TransformContext, klass: ClassNode): ClassNode {
    if (klass.name == TARGET_CLASS) {
        klass.methods.find { it.name == "_inject" }?.instructions?.apply {
            val firstInsn = this.first
            //其实就做了一件事,将serviceProviderList搜集好的APP所有的对应关系,然后注入到一个ServieProvider工具类中,这样其他类就可以用了
            serviceProviderList.forEach {
                val serviceFullName = it.split(".").first()
                val serviceName = it.split("/").last().split(".").first()
                println("$TAG, serviceFullName:$serviceFullName,serviceName:$serviceName")
                insertBefore(firstInsn, FieldInsnNode(Opcodes.GETSTATIC, TARGET_CLASS, "_interfaceClassToImplClassMap", "Ljava/util/Map;"))
                insertBefore(firstInsn, MethodInsnNode(Opcodes.INVOKESTATIC, serviceFullName, "getMapper", "()Ljava/util/Map;", false))
                insertBefore(firstInsn, InsnNode(Opcodes.DUP))
                insertBefore(firstInsn, LdcInsnNode("$serviceFullName.getMapper()"))
                insertBefore(firstInsn, MethodInsnNode(Opcodes.INVOKESTATIC, "kotlin/jvm/internal/Intrinsics", "checkNotNullExpressionValue", "(Ljava/lang/Object;Ljava/lang/String;)V", false))
                insertBefore(firstInsn, MethodInsnNode(Opcodes.INVOKEINTERFACE, "java/util/Map", "putAll", "(Ljava/util/Map;)V", true))
            }
        }
    }
    return klass
}

那么我们如何使用呢,看下核心代码:

object ServiceProvider {

    const val TAG = "ServiceProvider"

    val _interfaceClassToImplClassMap = mutableMapOf<String, String>()
    val _cacheMap = mutableMapOf<String, IService>()

    var _hasInited = false

    /**
     * 在初始化的时候需要首先调用此类
     */
    @JvmStatic
    fun initConfig() {
        if (!_hasInited) {
            _hasInited = true
            _inject()
            _interfaceClassToImplClassMap.forEach { (t, u) ->
                Log.d(TAG, "_interfaceClassToImplClassMap,t:$t -> u:$u")
            }
        }
    }

    @JvmStatic
    inline fun <reified T : IService> getInstance(context: Context): T {
        val key: String = T::class.java.canonicalName!!.toString()
        val v = _cacheMap[key]
        if (v == null) {
            synchronized(_cacheMap) {
                if (_cacheMap[key] == null) {
                    val implClass = _interfaceClassToImplClassMap[key] ?: throw ServiceException("not found ${T::class.java} impl class is null")
                    (Class.forName(implClass) as Class<T>).also { clazz ->
                        _cacheMap[key] = clazz.newInstance().also { instance ->
                            val method = clazz.getMethod("init", Context::class.java)
                            method.invoke(instance, context)
                        }
                    }
                }
            }
        }
        return _cacheMap[key] as T
    }

    /**
     * 不能删除,编译期会注入到这个方法中
     */
    fun _inject() {
    }
}

举例子,以登录为例:

interface ILogin : IService {
    fun login()
}

//绑定
@ServiceAnnotation(ILogin::class)
class PageSchemeService : ILogin {
    override fun init(context: Context) {
    }

    override fun login() {
        Log.i("hellokai", "login")
    }
}

// 真正调用的地方
ServiceProvider.getInstance<ILogin>(context)

这样我们就基本实现了解耦,而且实现方式简单灵活,之后也便于代码修改。 这里有一个问题就是kotlin的泛型reified模式所用到的方法和字段都必须是要共有的,不能是私有的属性这点要注意下,所以使用了_来进行区分。

四、方案落地

基础方案实现之后,先渐进式的更新了一个基础功能模块,之后会逐步的按照原来的涉及方案来逐步替换,后面就是需要多人协同,大家一起来共建了,因为业务也在不断地进行迭代,不一定每个版本都有时间弄,如果按照上述方案涉及理想情况都落地的话,预估还要半年的时间,之后要是碰到了其他的技术问题再来分享吧。

一个从0-1的野蛮生长方式的逐步到成熟稳定的APP的组件化渐进方案就分享完了~大家有什么问题的话可以留言讨论😯