速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

知乎 Android 客户端组件化实践

  • 2020-03-26
  • 本文字数:5358 字

    阅读完需:约 18 分钟

知乎 Android 客户端组件化实践

背景

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



早期架构图极简版


后来公司发展速度加快,拆分了多个独立的事业部,每个事业部有独立的 Android 开发团队,每个团队都有独立开发、测试和部署的需求;随着业务规模的扩大,早期的代码耦合导致的问题也逐渐显现出来;开发人员也越来越多,单工程的架构在人员协作方面也显得越来越力不从心。同时考虑到对未来可能出现的多应用的支持,我们开始了工程的组件化重构。今天我们会在这篇文章中分享我们组件化过程中的一些实践。

组件化实践

我们使用的是多工程多仓库的方案,即每个组件都有自己的独立仓库,均可独立于主工程单独运行;主工程通过 aar 依赖各个组件,自身则逐渐被拆成一个壳的状态,不包含业务逻辑代码。经过一年多的不断迭代,现在是这个样子:



它包含 4 个层次:


主工程:除了一些全局配置和主 Activity 之外,不包含任何业务代码。


业务组件:最上层的业务,每个组件表示一条完整的业务线,彼此之间互相独立。


基础组件:支撑上层业务组件运行的基础业务服务。


基础 SDK:完全业务无关的基础代码。


各层次职责清晰独立,可以很方便的进行拆解和组合;由于都有自己的版本,业务线可以独立发版,随时升级、回滚。


基本解耦方案


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


(1) 公用代码处理


  1. 基础业务逻辑分别拆成基础组件

  2. 自身逻辑完整、用于完成某一特定功能、不含业务逻辑的一组代码,独立成 SDK

  3. 代码量很小不足以拆分成单独拆分的代码和资源,我们统一放在一个专门建立的 common 组件中,并且严格限制 common 组件的增长。随着组件化的逐渐进行,common 应该逐渐变小而不是增大。

  4. 碰巧被共同使用的一些代码和资源片段,通常它们被复用只是因为被开发人员搜索到而直接使用了,很多时候某个资源已经被 A 业务声明了前缀,但是由于没有隔离,仍然会不可避免的被他人在 B 业务中强行复用,这时候如果 A 业务方要进行一些修改,B 业务就会受到影响 —— 这种情况我们允许直接复制


(2) 初始化


有些组件有在应用启动时初始化服务的需求,而且很多服务还是有依赖关系的,最初我们为每个组件都添加了一个 init() 方法,但是并不能解决依赖顺序问题,需要每个组件都在 app 工程中按顺序添加初始化代码才能正常运行,这使得不熟悉整套组件业务的人很难建立起一个可以独立运行的组件 app。因此我们开发了一套多线程初始化框架,每个组件只要新建若干个启动 Task 类,并在 Task 中声明依赖关系即可:



App 只需知道自己依赖什么组件即可,在编译 app 时 gradle 插件会自动定位到所有的 Task,并运行时生成依赖图,按依赖顺序启动 Task:



这样就解决了组件在主工程中堆积初始化代码的问题,在简化了代码的同时还有加快启动速度的功效。


(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 即可。



相互独立的组件,其实可能是藕断丝连的


一个典型的组件工程结构是这个样子:



以上图为例,它包含三个模块:


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 的依赖写法与普通的工程完全一样。


普通状态下的的主工程:



源码引用 template 组件后的主工程:



这样我们就可以像之前在单工程中一样写代码了。


得益于源码引用,我们直接在提交组件代码的时候,CI 会自动联合主工程编译出完整包,QA 会根据完整包进行测试,在测试通过后即可自动发布到公司的仓库,并通过内部的集成平台集成到主工程


小 tip :工程 .idea/vcs.xml 中定义了当前工程关联的 Git 仓库,可以在联合编译的同时通过修改 vcs.xml 来把组件目录也关联到主工程 Git 配置中,在开发过程中就可以使用 Android Studio 的内置 Git 功能了


包含子业务线的组件


我们当前的组件,绝大部分是一个组件一个仓库的,对于一般的组件来说,并没有什么问题,但是对于有的业务线,本身规模比较大,包含了若干个子业务,比如知乎大学,电子书、live 和私家课等子业务,这些子业务本身功能独立,但是共享整个业务线的基础代码,同时大业务线也有会一些汇总所有子业务的页面,它们的关系是这个样子:



这几个业务如果都要拆分出去独立成组件,然后抽离公共部分成为也成为一个业务线基础组件,这时候会面临一个很大的问题:由于几条业务线都属于同一个主业务线,做活动或者上新 Feature 的时候,这几个组件经常会发生联动,需要先更新 base 再更新其他业务线,提交 mr 也要同时提多个仓库,出现频繁的连锁更新;而如果不拆的话,业务线代码本身就已经很庞大,即使是单独编译组件 app 也会很慢,并且随着时间的推移,各个业务线的代码边界会像组件化之前的主工程一样逐渐劣化,耦合会越来越严重。


所以现在需求变成了这个样子:


  1. 对外保持只有一个组件:有联动需求的时候,组件仍然只发布一次更新

  2. 各个子业务仍旧保持互相独立和隔离,可以独立运行


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



各个子业务线分别拆成同一个工程中不同的 Module:它们共同依赖 base ,同时各个业务线互相不依赖,这些子业务又在一个主 Module 中汇集起来,正如上面图片所示那样


对于外界来说只有一个 main 组件,如果直接通过 ./gradlew :main:uploadArchives 来发布,那么就只能把 main Module 的代码发布上去,其他 Module 的代码是无法被连带上去的,所以我们需要在发布的时候将所有的代码合并到 main 中去。这时候只能使用添加 sourceSet 的方式,而一旦使用了 sourceSet,代码就不再隔离了。所以我们使用了一个动态的策略:编译时使用 sourceSet 依赖,其他时候使用 module 依赖,这样可以同时拥有两者的优势。


也就是说:表面看起来,这是一个普通的多模块的工程,但是实际上,他们的关系是动态的:写代码时是七个葫芦娃,编译时是葫芦小金刚:



如何做到呢,可以简单的判断当前启动的 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 的组件化,组件化的基础思想都是相通的,但是并没有一个放之四海而皆准的通用解决方案,各个公司在组件化的过程中都会根据自身的情况不断的调整方案,适合自身发展的,才是最好的。一些组件化初期看起来不起眼的问题,可能进行到后期才会慢慢显现出来,这时候就要及时调整方案。知乎的组件化也是在不断的变动中逐渐完善的,并且以后肯定也会随着业务和代码的变动不断的进行优化,这会是一个持续的过程,后续我们也会持续分享一些组件化遇到的问题和解决方案。


以上就是我们在组件化过程中的一部分实践,由于本人的水平有限,如有错误和疏漏,欢迎各位同学指正。


2020-03-26 19:001887

评论

发布
暂无评论
发现更多内容

美国商务部宣布:EDA等四项技术实施新的出口管制,8月15日生效!

雨果

芯片技术

从入门到高手,数据从业者成长一般经过哪些阶段?

雨果

数据工程师必备技能

一文搞定架构思维,DFD 的结构化分析,只需明白这3点

老崔说架构

如何在企业数字化团队内部实现分析建模过程全要素的可获得与成果可复现

ModelWhale

团队协作 数字化转型 全要素场景 代码复现 金融场景

OpenHarmony轻量设备Hi3861芯片开发板启动流程分析

OpenHarmony开发者

OpenHarmony

开源一夏 | 在 STM32L051 上使用 RT-Thread (二、无线温湿度传感器 之 CubeMX配置)

矜辰所致

开源 RT-Thread 8月月更 STM32L051

开源一夏 | 使用 JavaScript 将文本转换为文件

海拥(haiyong.site)

JavaScript GitHub 开源 前端 8月月更

数据工程师越做越有成就感的7点经验分享

雨果

数据工程师

TiFlash 源码阅读(六)DeltaTree Index 的设计和实现分析

PingCAP

TiDB TiDB 源码解读

数据结构——二叉树

工程师日月

8月月更

阿里最新的Spring Boot项目开发实战手册,掀起了项目实战变革狂潮

Java面试那些事儿

Java 编程 程序员 面试 架构师

JSON 之父:JavaScript 已经成为了进步的障碍

雨果

JavaScript

QCA9880 wallys 2×2 MIMO 802.11ac Mini PCIe 2,4GHz / 5GHz Designed for E

wallys-wifi6

量化交易合约机器人系统开发策略分析

薇電13242772558

量化策略

10种有用的Linux Bash_Completion 命令示例

华为云开发者联盟

Linux 后端 开发

融会贯通,并行不悖 | 2022年8月《中国数据库行业分析报告》精彩抢先看

墨天轮

数据库 greenplum MPP 国产数据库 HTAP

4步教你学会使用Linux-Audit工具

华为云开发者联盟

Linux 工具 安全 监控 开发

Linux 6.0 第一个候选版本发布

雨果

Liunx

为Bert注入知识的力量 Baidu-ERNIE & THU-ERNIE & KBert

了不起的程序猿

Java 编程 后端 java程序员 BERT

C#/VB.NET 替换 PDF 文件上的现有图像

在下毛毛雨

C# .net PDF 替换图像

QCA9882 wallys 802.11AC 802.11AN wifi QCA9882 Module Wireless AC/AN MiniPCIE Standard Card

wallys-wifi6

QCA9882

泄露了,22年阿里巴巴秋招内部面试资料,看完之后剑指offer

Java面试那些事儿

Java 编程 程序员 面试 架构师

微服务、网关、服务发现/注册的正确打开方式

Java全栈架构师

Java 程序员 架构 微服务 程序人生

开源的价值观与文化的传递

开源社

#开源

怎么分辨企业转型是“真数字化”还是 “伪数字化”?

雨果

数字化转型

Node 之父着急宣布Deno 将迎来重大变革,疑为针对最近大火的“Bun”

雨果

node.js

Solana上的结算协议龙头,Zebec潜力颇受看好

小哈区块

国产系统的不足或许可以靠小程序弥补

Geek_99967b

小程序

泄露了,Alibaba697页的MySQL应用实战与性能调优手册,太强了

Java编程日记

Java 编程 程序员 面试 架构师

区块链带你避“坑”,电信诈骗退!退!退!

旺链科技

区块链 产业区块链 电信诈骗

乔布斯之后,下一代触控交互由一家中国公司重新定义

硬科技星球

知乎 Android 客户端组件化实践_文化 & 方法_Peter Porker_InfoQ精选文章