知乎 Android 客户端组件化实践

001.jpg

 背景

知乎 Android 客户端最早使用的是最常见的单工程 MVC 架构,所有业务逻辑都放在了主工程 Module 里,网络层和一些公共代码分别被抽成了一个 Module。现在看来,当时的业务线、产品功能及研发团队都比不上现在的体量和丰富度,遇到的问题随时组内沟通就可以解决。所以在知乎稳步发展的前几年,并没有遇到什么大的问题。

早期架构图极简版图:
/uploads/fox/01124619_0.620
后来公司发展速度加快,拆分了多个独立的事业部,每个事业部有独立的 Android 开发团队,每个团队都有独立开发、测试和部署的需求;随着业务规模的扩大,早期的代码耦合导致的问题也逐渐显现出来;开发人员也越来越多,单工程的架构在人员协作方面也显得越来越力不从心。
 
同时考虑到对未来可能出现的多应用的支持,我们开始了工程的组件化重构。今天我们会在这篇文章中分享我们组件化过程中的一些实践。
 
组件化实践
 
我们使用的是多工程多仓库的方案,即每个组件都有自己的独立仓库,均可独立于主工程单独运行;主工程通过 aar 依赖各个组件,自身则逐渐被拆成一个壳的状态,不包含业务逻辑代码。经过一年多的不断迭代,现在是这个样子:
/uploads/fox/01124619_1.620
它包含 4 个层次: 
 
主工程:除了一些全局配置和主 Activity 之外,不包含任何业务代码。
业务组件:最上层的业务,每个组件表示一条完整的业务线,彼此之间互相独立。 
基础组件:支撑上层业务组件运行的基础业务服务。 
基础 SDK:完全业务无关的基础代码。 
 
各层次职责清晰独立,可以很方便的进行拆解和组合;由于都有自己的版本,业务线可以独立发版,随时升级、回滚。
 
基本解耦方案

组件化的第一步就是对要拆出去的组件进行解耦,常见解耦方式有以下几种:

(1) 公用代码处理: 基础业务逻辑分别拆成基础组件 自身逻辑完整、用于完成某一特定功能、不含业务逻辑的一组代码,独立成 SDK 代码量很小不足以拆分成单独拆分的代码和资源,我们统一放在一个专门建立的 common 组件中,并且严格限制 common 组件的增长。随着组件化的逐渐进行,common 应该逐渐变小而不是增大。 碰巧被共同使用的一些代码和资源片段,通常它们被复用只是因为被开发人员搜索到而直接使用了,很多时候某个资源已经被 A 业务声明了前缀,但是由于没有隔离,仍然会不可避免的被他人在 B 业务中强行复用,这时候如果 A 业务方要进行一些修改,B 业务就会受到影响 —— 这种情况我们允许直接复制.
(2) 初始化:有些组件有在应用启动时初始化服务的需求,而且很多服务还是有依赖关系的,最初我们为每个组件都添加了一个 init() 方法,但是并不能解决依赖顺序问题,需要每个组件都在 app 工程中按顺序添加初始化代码才能正常运行,这使得不熟悉整套组件业务的人很难建立起一个可以独立运行的组件 app。因此我们开发了一套多线程初始化框架,每个组件只要新建若干个启动 Task 类,并在 Task 中声明依赖关系即可:

/uploads/fox/01124619_2.620
启动顺序示例图:
/uploads/fox/01124619_3.620
这样就解决了组件在主工程中堆积初始化代码的问题,在简化了代码的同时还有加快启动速度的功效。
(3) 路由:界面间使用 Url 进行跳转,不但实现了解耦,也统一了各端的页面打开方式。我们实现了一套灵活小巧的路由框架 ZRouter,它支持多组件、路由拦截、AB Test 、参数正则匹配、降级策略、任意参数传递以及自定义跳转等功能,可以自定义路由的各个阶段,完全满足了我们的业务需求。
(4) 接口:除了页面间的跳转,不同业务之间不可避免的会有一些调用,为了避免组件的直接通信,通常都是使用接口依赖的方式。我们实现了一个 Interface Provider 来支持接口通信,它可以通过运行时在动态注册一个接口,同时也实现了对于 ServiceLoader 的支持。只要一方组件将通信接口暴露出来,使用方就可以直接使用接口进行调用。

动态注册接口

Provider.register(AbcInterface.class,new AbcInterfaceImpl())

获取实例并调用

Provider.get(AbcInterface.class).doSomething()

(5) EventBus:这个自不必说,虽然说滥用是一个问题,但是有些场景下,使用事件还是最为方便简单的方式
(6) 组件 API 模块:上面提到的接口和事件以及一些跨组件使用的 Model 放到哪里好呢?如果直接将这些类下沉到一个公共组件中,由于业务的频繁更新,这个公共组件可能会更新得十分频繁,开发也十分的不方便,所以使用公共组件是行不通的,于是我们采取了另一种方式——组件 API :为每个有对外暴露需求的组件添加一个 API 模块,API 模块中只包含对外暴露的 Model 和组件通信用的 Interface 与 Event。有需要引用这些类的组件只要依赖 API 即可。
相互独立的组件,其实可能是藕断丝连的
/uploads/fox/01124619_4.620
一个典型的组件工程结构是这个样子:
/uploads/fox/01124619_5.620
以上图为例,它包含三个模块: template :组件代码,它包含了这个组件所有业务代码 template-api:组件的接口模块,专门用于与其他组件通信,只包含 Model、Interface 和 Event,不存在任何业务和逻辑代码 app 模块:用于独立运行 app,它直接依赖组件模块,只要添加一些简单的配置,即可实现组件独立运行组件半自动拆分。
 
有了解耦的方法,剩下的就是采取行动拆分组件了,拆组件是一个很头疼的问题,它非常考虑一个人的细心与耐心,由于无法准确知道有哪些代码要被拆走,也不能直观的知晓依赖关系,移动变得非常的困难且容易出错,一旦不能一次性拆分成功,到处都是编译错误,便只能靠人肉一点一点的挪。 工欲善其事,必先利其器。为了解决这个问题,我们开发了一个辅助工具 RefactorMan: 它可以递归的解析出工程中所有源码的引用和被引用情况,同时会根据预设规则自动分析出所有不合理的依赖,在开发人员根据提示解决了不合理依赖之后,即可将组件一键移出,大大减少了拆组件的工作量。我们在组件化初期曾经走过一些弯路,最初拆出的八个组件工程的的部分源码经历了几次的反复移动才得出最优解,而有了 RefactorMan,我们可以面对反复的拆分和组合组件有恃无恐 Bonus :由于可以分析和移动资源,所以额外获得了清理无用资源的功能联合编译完整包。

单独运行组件 app 并不能完整的覆盖所有的 case,尤其是在给 QA 测试的时候,还是需要编译完整的主工程包的,所以我们需要一个直接编译完整包的方案: 最初我们的实现方式只针对组件,比较简单: 首先在 setting.gradle 中动态引入组件 module:

def allComponents = ["base", "account" ... "template" ...]
allComponents.forEach({ name ->
if (shouldUseSource(name)) {
// 动态引入外部模块
include ":${name}"
project(":${name}").projectDir = getComponentDir(name);
}
})

然后在 app/build.gradle 中切换依赖,需要将所有被间接依赖的组件全部 exclude 以防止同时依赖了一个组件的 module 和 aar:

allComponents.forEach({ name ->
if (shouldUseSource(name)) {
implementation(project(":${name}")) { exclude group: COMPONENT_GROUP }
} else {
implementation("${COMPONENT_GROUP}:${name}:${versions[name]}") { exclude group: COMPONENT_GROUP }
}
})

由于所有组件的 group 都是一样的,所以这样做并没有什么问题,但是后来一些基础 SDK 也出现了这种需求,这时候就需要一种通用的源码依赖方案,因此做了一下修改,直接使用 gradle 提供的依赖替换功能,只需要修改 setting.gradle 即可:

// ... 忽略读取配置代码 ...
configs.forEach { artifact, prj ->
include ":${prj.name}"
project(":${prj.name}").projectDir = new File(prj.dir)
}
gradle.allprojects { project ->
if (project == project.rootProject) {
return
}
project.configurations.all {
resolutionStrategy.dependencySubstitution {
configs.forEach { artifact, prj ->
// 在这里进行替换
substitute module(artifact) with project(":${prj.name}")
}
}
}
}

而 build.gradle 的依赖写法与普通的工程完全一样。 普通状态下的的主工程:
/uploads/fox/01124619_6.620
源码引用 template 组件后的主工程:
/uploads/fox/01124619_7.620
这样我们就可以像之前在单工程中一样写代码了。 得益于源码引用,我们直接在提交组件代码的时候,CI 会自动联合主工程编译出完整包,QA 会根据完整包进行测试,在测试通过后即可自动发布到公司的仓库,并通过内部的集成平台集成到主工程。 
 
小 tip :工程 .idea/vcs.xml 中定义了当前工程关联的 Git 仓库,可以在联合编译的同时通过修改 vcs.xml 来把组件目录也关联到主工程 Git 配置中,在开发过程中就可以使用 Android Studio 的内置 Git 功能了。
 
包含子业务线的组件
 
我们当前的组件,绝大部分是一个组件一个仓库的,对于一般的组件来说,并没有什么问题,但是对于有的业务线,本身规模比较大,包含了若干个子业务,比如知乎大学,电子书、live 和私家课等子业务,这些子业务本身功能独立,但是共享整个业务线的基础代码,同时大业务线也有会一些汇总所有子业务的页面,它们的关系是这个样子:
/uploads/fox/01124619_8.620
这几个业务如果都要拆分出去独立成组件,然后抽离公共部分成为也成为一个业务线基础组件,这时候会面临一个很大的问题:由于几条业务线都属于同一个主业务线,做活动或者上新 Feature 的时候,这几个组件经常会发生联动,需要先更新 base 再更新其他业务线,提交 mr 也要同时提多个仓库,出现频繁的连锁更新;而如果不拆的话,业务线代码本身就已经很庞大,即使是单独编译组件 app 也会很慢,并且随着时间的推移,各个业务线的代码边界会像组件化之前的主工程一样逐渐劣化,耦合会越来越严重。
 

所以现在需求变成了这个样子: 对外保持只有一个组件:有联动需求的时候,组件仍然只发布一次更新 各个子业务仍旧保持互相独立和隔离,可以独立运行 我们曾经试图使用 sourceSets 的方式将不同的业务代码放到不同的文件夹,但是 sourceSets 的问题在于,它并不能限制各个 sourceSet 之间互相引用,base 模块甚至可以直接引用最上层的代码,虽然可以在编译期进行检查,但是总有一些后知后觉的意味,并且使用 sourceSets 想让各个模块单独跑起来配置也比较麻烦。而 Android Studio 的 module 天然具有隔离的优势。所以我们的解决方案是在组件工程中使用多 Module 结构:

/uploads/fox/01124619_9.620
各个子业务线分别拆成同一个工程中不同的 Module:它们共同依赖 base ,同时各个业务线互相不依赖,这些子业务又在一个主 Module 中汇集起来,正如上面图片所示那样。
 
对于外界来说只有一个 main 组件,如果直接通过 ./gradlew :main:uploadArchives 来发布,那么就只能把 main Module 的代码发布上去,其他 Module 的代码是无法发布的,所以我们需要在发布的时候将所有的代码合并到 main 中去。这时候只能使用添加 sourceSet 的方式,而一旦使用了 sourceSet,代码就不再隔离了。所以我们使用了一个动态的策略:编译时使用 sourceSet 依赖,其他时候使用 module 依赖,这样可以同时拥有两者的优势。

也就是说:表面看起来,这是一个普通的多模块的工程,但是实际上,他们的关系是动态的:写代码时是七个葫芦娃,编译时是葫芦小金刚:
/uploads/fox/01124619_10.620
如何做到呢,可以简单的判断当前启动的 Task,一般我们只在 assemble、install、upload 的时候使用合体操作,而其他时候使用普通的 project 依赖,示例代码如下:

boolean useSource = gradle.startParameter.taskNames.any {
it.contains("assemble") || it.contains("install") || it.contains("upload"))
}
subProject.forEach { subProject ->
if (useSource) {
android.sourceSets.main {
java.srcDirs += file("../${subProject}/src/main/java")
res.srcDirs += file("../${subProject}/src/main/res")
}
} else {
dependencies { implementation project(":$subProject") }
}
}

其他资源例如 resources、assets、aidl、renderscript、jni、jniLibs、shaders 以及 aar 和 jar 文件,它们都是多文件的,可以使用与上面类似的方法添加。

但是 manifest 不同,一个 module 中只有一个 AndroidManifest.xml ,所以需要有一个方法将子业务的 manifest 合并。我们使用了官方提供的 ManifestMerger 实现了 manifest 的合并,这里不再展开合并的具体代码,有兴趣的同学可以自己去看源码。

将上面代码封装了一个方法 using,主 module 就可以这样引用子 module 了:

dependencies {
using "base"
using "sub1"
using "sub2"
using 'sub3'
using 'sub4'
}

由于每个子业务组件都是独立的,仍然可以单独配置独立编译独立运行,由于每个业务的代码量相对整个业务线来说大大减少了,所以得到了更快的编译速度。
 
总结

最近两年很多公司都开始了 App 的组件化,组件化的基础思想都是相通的,但是并没有一个放之四海而皆准的通用解决方案,各个公司在组件化的过程中都会根据自身的情况不断的调整方案,适合自身发展的,才是最好的。一些组件化初期看起来不起眼的问题,可能进行到后期才会慢慢显现出来,这时候就要及时调整方案。知乎的组件化也是在不断的变动中逐渐完善的,并且以后肯定也会随着业务和代码的变动不断的进行优化,这会是一个持续的过程,后续我们也会持续分享一些组件化遇到的问题和解决方案。 以上就是我们在组件化过程中的一部分实践,由于本人的水平有限,如有错误和疏漏,欢迎各位同学指正。
 

关于作者:
潘志会,2016 年加入知乎,现为知乎 Android 基础架构团队负责人,有着丰富的 Android 工程化,组件化经验,设计并主导了知乎的 Android 组件化拆分工作。

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/industrynews/257683.html

(0)
上一篇 2022年5月19日 17:11
下一篇 2022年5月19日 17:13

相关推荐

发表回复

登录后才能评论