一、背景
我们在项目中为什么要做组件化方案,很多人都会说现在的Android开发中都是组件化开发了,不用这个落后了。确实成熟的持续迭代的项目都是组件化开发了,但是我们更要搞清楚为什么要做组件化方案,它的优缺点是什么,这样我们才能在项目中灵活使用,避免踩坑,耽误进度。
做技术类的方案都需要对自己提出以下问题:
- 为什么一定要做这个,优缺点在哪里?
- 做这个的好处什么,怎么做才是最好的,最合适的?
针对于以上两个问题,我们从接下来的分析里给出答案。
1.1 为什么做组件化
1.1.2 项目介绍
从项目角度来看,我们有2
个APP需要维护,一些基础功能类似,我们在前期的开发中,为了快速迭代,将基础库作为拷贝不同的项目中进行的代码复用,整体项目持续迭代了一年多的时间。
1.1.2 遇到了什么问题
可以针对性的列下我们在项目迭代过程中遇到的问题:
2
个APP
拷贝代码,如果有一个问题需要修复,也需要拷贝代码- 一些像网络,下载,监控等基础功能也在不断优化,
2
个APP
都需要功能相同,但是配置是不一致,需要拷贝代码再进行修改,容易改出问题,开发测试也需要再来一遍 - 业务越来越多,编译时间越来越长,例如我开发一个首页页面,其实不需要直播的依赖,但是现在依赖较多,不可能单独编译,研发体验不好
从以上来看,我们需要一个新的解决方案,来解决我们现在的问题,它需要做到以下几类事情:
- 针对于一些基础库,网络,下载,监控等,我们需要它作为一个基础功能,需要有用统一的库来保证它的一致性,又需要自定义一些接口,让
APP
可以灵活实现 - 针对于一些业务功能开发,可以独自编译,而不引入此业务功能无关的依赖,导致编译过久,提升开发体验
- 针对于主
APP
尽可能的做薄,分成业务功能库和基础库,来做实际的事情,主APP
尽量配置
那我们对于我们APP的理想架构模型就出来了:
可以更详细的解释下,我们如何分层的:
- 基础库,代表的是原子组件,也就是说他不需要依赖其他的同级基础库,它自己就是一个功能,例如,网络只需要做网络请求的事情,那我们定义就是一个请求接口,让
APP1
和APP2
各自配置不同的Client
即可 - 功能库,代表的是原子组件的集合,例如我像让登录做成一个
SDK
,APP1
和APP2
的逻辑都是一致的,那我们登录需要,网络请求,埋点,监控等多个原子组件的依赖才能实际使用 - 业务模块库,也就是具体的业务功能库,这个模块是业务变化最频繁的地方,例如我们的首页,我的页,播放器页等不同的页面相关的,它在不同的
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 具体问题分析
在我们实施的过程中,有一些详细问题还需要了解清楚,可以列出来参考下:
- 代码如何存放管理
- 单独的模块如何打包
- 模块如何集成到宿主,如何调试
- 组件间如何解耦
这些问题基本都是我们在组件化阶段遇到的问题,我们按照需要来做一个优缺点分析和梳理:
2.2.1 代码管理
新增代码是考虑单个仓库还是多个仓库,考虑到多个仓库切换起来太麻烦,我们选择了单仓的实现,将所有的基础功能和功能库还是都放到一个新的仓库里来做,而业务模块库还是放到原来APP
的仓库里,业务库由于会频繁改动所以还是以module
的方式依赖。
2.2.2 打包
单个仓库对应多个module
,那么我们需要打包时提供能选择module
的能力。
由于会有注解和注解处理的库,所以打包的gradle
脚本需要兼容Android
和Java
不同模式的打包。
我们之前在打包的时候有一个问题时,需要先手动更新版本号然后再打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 解耦
由于路由框架,我们原来内部有一个实现,所以这次就不重复造轮子再做了,我们就需要来做组件间解耦就好了,组件间解耦有好多文章里也都写过了,它的目的和原因是啥,大家应该都懂他的作用,具体实现有ARouter
,WMRouter
,TheRouter
等。
上述几个库都比较完善代码较多,而且我们引入的话编译可能也有问题,我们项目中已经有booster
了,修改字节码也较为简单,那我们自己也需要实现一个解耦库,也是通过编译期注解+ASM
修改字节码的方式来实现的。
2.2.5 计划总结
我们需要做的事情可以列出来看下:
- 创建一个代码库:为了避免库太多新增一个代码库,把
2
个APP
都需要的基础库和功能库放进来 - 提供打包能力:我们在不同的模块需要在
jenkins
上支持单独打包能力 - 宿主单独调试能力:我们需要一中简单的方式来在宿主中调试组件源码
- 组件间解耦能力:业务间相互耦合需要解开,统一跳转能力(由于我们项目中有原来自己写的统跳能力,够用了,先暂时不处理)
- 基础库拆分:先做基础库,网络,下载,埋点,组件等基础部分
- 功能库-业务库拆分:各个同学认领不同的功能库和业务库,统一实现
- 文档沉淀,新人可以快速上手
团队规模不大,所有的同学都需要参与进来,我完成了前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 组件间解耦通信方式
我们整体的实现思路,同ARouter
和TheRouter
类似,是使用的注解和注解处理器来进行编译期处理一个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
的组件化渐进方案就分享完了~大家有什么问题的话可以留言讨论😯