Markdown版本笔记 | 我的GitHub首页 | 我的博客 | 我的微信 | 我的邮箱 |
---|---|---|---|---|
MyAndroidBlogs | baiqiantao | baiqiantao | bqt20094 | baiqiantao@sina.com |
组件化 得到 DDComponent JIMU 模块 插件 MD
目录
DDComponent 组件化介绍
浅谈Android组件化
什么是组件化
为什么要“彻底组件化”?
组件化过程中要注意的问题
iOS 和 Android 的组件化有何区别?
组件化后的具体成果
Android彻底组件化方案实践
模块化、组件化与插件化
如何实现组件化
代码解耦:module + library
组件的单独调试 isRunAlone
组件的数据传输:service
组件之间的UI跳转:apt
组件的生命周期:Javassist
集成调试:aar
代码隔离:apply plugin
组件化的拆分步骤和动态需求
总结
DDComponent 组件化介绍
原始的得到官网的项目(2.7K):DDComponentForAndroid
项目作者离职后维护的项目(1.4K):JIMU
个人实践Demo
官方Demo简化
已实现的功能
- 组件可以单独调试
- 杜绝组件之间相互耦合,代码完全隔离,彻底解耦
- 组件之间通过接口+实现的方式进行数据传输
- 使用scheme和host路由的方式进行activity之间的跳转
- 自动生成路由跳转路由表
- 任意组件可以充当host,集成其他组件进行集成调试
- 可以动态对已集成的组件进行加载和卸载
- 支持kotlin组件
浅谈Android组件化
什么是组件化
模块化、插件化和组件化的关系
在技术开发领域,模块化
是指分拆代码,即当我们的代码特别臃肿的时候,用模块化将代码分而治之、解耦分层。具体到 android 领域,模块化的具体实施方法分为插件化和组件化
。
插件化和组件化的区别
一套完整的插件化或组件化都必须能够实现单独调试、集成编译、数据传输、UI 跳转、生命周期和代码边界
这六大功能。插件化和组件化最重要而且是唯一的区别的就是:插件化可以动态
增加和修改线上的模块,组件化的动态能力相对较弱,只能对线上已有模块进行动态的加载和卸载
,不能新增和修改
.。
如何取舍插件化和组件化?
在插件化和组件化取舍的一个重要原则是:APP 是否有动态增加或修改
线上模块的需求,如果这种动态性的需求很弱,就不需要考虑插件化,一般说来,电商类或广告类产品对这个需求比较强烈,而类似“得到 APP”这类的知识服务产品,每个功能的推出都是经过精细打磨的,对这种即时的动态性要求不高,所以不需要采用插件化。
如果你的产品对动态性的要求比较高,那么在选择插件化之前也需要从两个方面权衡一下:
- 一是插件化不可避免的去 hook 一些系统的 api,也就不可避免地有兼容性的问题,因此每个插件化方案需要有专门的团队去负责维护;
- 二是从一个业务逻辑复杂的项目中去拆分插件化需要的时间可能是非常巨大的,需要考虑对开发节奏的影响。
因此,对大多数产品来说,组件化都是一个不错甚至最佳的选择,它没有兼容性,可以更方便地拆分,并且几乎没有技术障碍,可以更顺利地去执行。特别是对急需拆分的产品来说,组件化是一个可退可守的方案,可以更快地执行下去,并且将来要是迁移到插件化,组件化拆分也是必经的一步。
何为“彻底”组件化
之所以称这个方案是彻底的组件化,主要是为了更好地强调组件之间代码边界
的问题,组件之间的直接引用(compile)是要坚决避免的,一旦这么做了,就难免会导致直接使用其他组件的具体实现类,这样针对接口编程的要求就成了一句空话。更严重的是,一旦决定对组件进行动态地加载或卸载,就会导致严重地崩溃。所以只有做到了代码隔离,这个组件化方案才可以称之为“彻底”的。
在现在的方案中可以做到代码编写期间组件之间是完全不可见的,因此杜绝了直接使用具体的实现类的情况,但是在编译打包的时候,又会自动把依赖的组件打包进去。该方案是一个集合了六大功能的完整方案,覆盖了组件化中需要考虑的全部情况。
既然是“彻底”组件化,那么代码解耦之后,怎样才能让主项目间接引用
各个独立的组件呢?
方案采用的是一个配置文件,每个组件声明自己所需要的其他组件,配置分为 debug 和 release 两种,可以在日常开发和正式打包之间更灵活的切换。
方案自定义了一个 gradle 插件,它去读取每个组件的配置文件,构建出组件之间的依赖关系
。这个插件更“智能”的地方在于,它分析运行的 task 命令,判断是否是打包命令
,是的话(例如 assembleRelease)自动根据配置引入,不是(例如正常的 sync /build)等则不引入,也就是在代码编写期间
组件之间是完全不可见的,因此杜绝了直接使用具体的实现类的情况。但是在编译打包
的时候,又会自动把依赖的组件打包进去。当然这里面还会涉及到组件之间如何通过“接口 + 实现”的方式进行数据传输,每个组件如果进行加载等问题,这些在 方案 中都有成熟的解决方式。
方案中自定义的 gradle 插件还有一个比较好的功能就是可以自动的识别和修改组件的属性,它可以识别出当前调试的是哪个组件,然后把这个组件修改为 application 项目,而其他组件则默默的修改成 library 项目。因此不论是要单独编译一个组件还是要把这个组件集成到其他组件中调试,都是不需要做任何的手动修改,使用起来相当的方便。
为什么要“彻底组件化”?
在刚开始对“得到 APP”Android 端的代码进行组件化拆分的时候,“得到 APP”已经是一个千万用户级的产品。经过那么长时间的积累,几十万行代码堆积在一起,编译一次大约需要 10 分钟的时间,这严重影响了开发效率。
由于业务复杂,代码交织在一起,可谓牵一发而动全身,因此每个人在写新需求的时候都有严重的代码包袱,瞻前顾后,花费在熟悉之前的代码的时间甚至大于新需求的开发时间。并且每个改动都需要测试人员进行大范围的回归,所以整个开发团队的效率都受到了影响。在这种情况下,实施组件化是迫在眉睫了。
由于国内对插件化
的研究是比较火爆的,而对组件化的研究热情就相对淡了很多。在设计“得到 APP”组件化方案的时候,几乎查阅了全部的组件化文章,都没有找到一个完整的支持上面说的六大功能的方案,所以不得不从头开始设计,“彻底组件化”的方案可跳转阅读 Android 彻底组件化方案实践 和 Android 彻底组件化 demo
组件化过程中要注意的问题
让方案从纸上运用到实际,是一个比较困难的过程,这期间要注意两个方面的问题:一是技术细节上的不断完善,二是团队的共识建设问题。
技术上的问题主要是如何让方案更灵活,需求总是比预期要复杂,遇到特殊的需求,之前的设计可能就没法实现,或者必须得突破之前确定的拆分原则。这时候就需要回过头再审视整个方案,看看能否在某些方面做一些调整。方案中数据传输和 UI 跳转是分开的两个功能,这是在实际拆分中才做出的选择,因为数据传输更为频繁,且交互形式更多样,使用一个标准化的路由协议难以满足,因此把数据传输改成了接口 + 实现的形式,针对接口编程就可以更加灵活地处理这些情况。
除了技术上的,更重要的是团队的共识问题。要执行一个组件化拆分这样的大工程,需要团队的每个人达成共识,无论是在方案还是在技术的实现细节上,大家都能有一个统一的方向。
为此,在拆分之前多做几次组内的分享讨论,从方案的制定到每一次的实施,都让团队的大部分成员参与进来。正所谓磨刀不误砍柴工,在这种前提下,团队的共识建设会对后期工作效率的提高产生很大的价值。确立了共识,还需要确立统一的规则,虽说条条大路通罗马,但是在一个产品里,还是需要选择统一的道路,否则即便做了拆分,效果也会大打折扣。
iOS 和 Android 的组件化有何区别?
无论是 Android 还是 iOS,要解决的问题都是一样的,因此在组件化方案上要实现的功能(即上面所说的上面六种功能)也都是一样的,所以两者的组件化大体上来说是基本相同的。
有一个微小的区别在于技术实现方式的不同,由于两个平台用到的开发技术是不同的,Android 的组件化可能需要考虑向插件化的迁移,后期一旦有动态变动功能的强需求,可以快速地切换。而目前苹果官方是不允许这种动态性的,所以这方面的考虑就会少一点。但是 iOS 同样可以做到动态地加载和卸载组件的,因此在诸如生命周期、代码边界等问题上也需要格外注意,只是目前一些 iOS 组件化方案在这方面可能考虑的相对少一点。
组件化后的具体成果
组件化后的代码结构非常清晰,分层结构以及之间的交互很明了,团队中的任何一个人都可以很轻松的绘制出代码结构图,这个在之前是没法做到的,并且每个组件的编译时间从 10 分钟降到了几十秒,工作效率有了很大地提升,最关键的还是解耦之后,每次开发需求的时候,面对的代码越来越少,不用背负那么重的代码包袱,可以说达到了“代码越写越少”的理想情况。
其实组件化对外输出也是很可观的,现在一个版本开发完成后,我们可以跟测试说这期就回归“每天听本书”组件,其他的不需要回归。这种自信在之前是绝对没有的,测试的压力也可以小很多。更重要的是我们的组件可以复用,“得到 APP”会上线新的产品,他们可以直接使用已有的组件,省去了很多重复造*的工作,这点对整个公司效率的提升也是很有帮助的。
Android彻底组件化方案实践
模块化、组件化与插件化
项目发展到一定程度,随着人员的增多,代码越来越臃肿,这时候就必须进行模块化的拆分。在我看来,模块化是一种指导理念,其核心思想就是分而治之、降低耦合。而在Android工程中如何实施,目前有两种途径,也是两大流派,一个是组件化,一个是插件化。
提起组件化和插件化的区别,有一个很形象的图:
上面的图看上去似乎比较清晰,其实容易导致一些误解,有下面几个小问题,图中说的就不太清楚:
- 组件化是一个整体吗?去了头和胳膊还能存在吗?左图中,似乎组件化是一个有机的整体,需要所有器官都健在才可以存在。而实际上组件化的目标之一就是降低整体(app)与器官(组件)的依赖关系,缺少任何一个器官app都是可以存在并正常运行的。
- 头和胳膊可以单独存在吗?左图也没有说明白,其实答案应该是肯定的。每个器官(组件)可以在补足一些基本功能之后都是可以独立存活的。这个是组件化的第二个目标:组件可以单独运行。
- 组件化和插件化可以都用右图来表示吗?如果上面两个问题的答案都是YES的话,这个问题的答案自然也是YES。每个组件都可以看成一个单独的整体,可以按需的和其他组件(包括主项目)整合在一起,从而完成的形成一个app
- 右图中的小机器人可以动态的添加和修改吗?如果组件化和插件化都用右图来表示,那么这个问题的答案就不一样了。对于组件化来讲,这个问题的答案是部分可以,也就是在编译期可以动态的添加和修改,但是在运行时就没法这么做了。而对于插件化,这个问题的答案很干脆,那就是完全可以,不论是在编译期还是运行时!
本文主要集中讲的是组件化的实现思路,对于插件化的技术细节不做讨论,我们只是从上面的问答中总结出一个结论:组件化和插件化的最大区别(应该也是唯一区别)就是组件化在运行时不具备动态添加和修改组件的功能
,但是插件化是可以的。
暂且抛弃对插件化“道德”上的批判,我认为对于一个Android开发者来讲,插件化的确是一个福音,这将使我们具备极大的灵活性。但是苦于目前还没有一个完全合适、完美兼容的插件化方案,特别是对于已经有几十万代码量的一个成熟产品来讲,套用任何一个插件化方案都是很危险的工作。所以我们决定先从组件化做起,本着做一个最彻底的组件化方案的思路去进行代码的重构,下面是最近的思考结果,欢迎大家提出建议和意见。
如何实现组件化
要实现组件化,不论采用什么样的技术路径,需要考虑的问题主要包括下面几个:
- 代码解耦。如何将一个庞大的工程拆分成有机的整体?
- 组件单独运行。上面也讲到了,每个组件都是一个完整的整体,如何让其单独运行和调试呢?
- 数据传递。因为每个组件都会给其他组件提供的服务,那么主项目(Host)与组件、组件与组件之间如何传递数据?
- UI跳转。UI跳转可以认为是一种特殊的数据传递,在实现思路上有啥不同?
- 组件的生命周期。我们的目标是可以做到对组件可以按需、动态的使用,因此就会涉及到组件加载、卸载和降维的生命周期。
- 集成调试。在开发阶段如何做到按需的编译组件?一次调试中可能只有一两个组件参与集成,这样编译的时间就会大大降低,提高开发效率。
- 代码隔离。组件之间的交互如果还是直接引用的话,那么组件之间根本没有做到解耦,如何从根本上避免组件之间的直接引用呢?也就是如何从根本上杜绝耦合的产生呢?只有做到这一点才是彻底的组件化。
代码解耦:module + library
把庞大的代码进行拆分,AndroidStudio能够提供很好的支持,使用IDE中的multiple module
这个功能,我们很容易把代码进行初步的拆分。在这里我们对两种module进行区分:
- 一种是
基础库
library,这些代码被其他组件直接引用
(是次方案中非常核心的设计理念),比如网络库module可以认为是一个library。 - 另一种我们称之为Component,这种module是一个
完整的功能模块
,比如读书或者分享module就是一个Component。
为了方便,我们统一把library称之为依赖库,而把Component称之为组件,我们所讲的组件化也主要是针对Component这种类型。而负责拼装这些组件以形成一个完成app的module,一般我们称之为主项目、主module或者Host
,方便起见我们也统一称为主项目。
经过简单的思考,我们可能就可以把代码拆分成下面的结构:
这种拆分都是比较容易做到的,从图上看,读书、分享等都已经拆分组件,并共同依赖于公共的依赖库(简单起见只画了一个),然后这些组件都被主项目所引用。读书、分享等组件之间没有直接的联系,我们可以认为已经做到了组件之间的解耦。
但是这个图有几个问题需要指出:
- 从上面的图中,我们似乎可以认为组件只有集成到主项目才可以使用,而实际上我们的希望是每个组件是个整体,可以独立运行和调试,那么如何做到单独的调试呢?
- 主项目可以直接引用组件吗?也就是说我们可以直接使用compile project(:reader)这种方式来引用组件吗?如果是这样的话,那么主项目和组件之间的耦合就没有消除啊。我们上面讲,组件是可以动态管理的,如果我们删掉reader(读书)这个组件,那么主项目就不能编译了啊,谈何动态管理呢?所以
主项目对组件的直接引用是不可以的
,但是我们的读书组件最终是要打到apk里面,不仅代码要和并到claases.dex里面,资源也要经过meage操作合并到apk的资源里面,怎么避免这个矛盾呢? - 组件与组件之间真的没有相互引用或者交互吗?读书组件也会调用分享模块啊,而这在图中根本没有体现出来啊,那么组件与组件之间怎么交互呢?
这些问题我们后面一个个来解决,首先我们先看代码解耦要做到什么效果,像上面的直接引用并使用其中的类肯定是不行的了。所以我们认为代码解耦的首要目标就是组件之间的完全隔离
,我们不仅不能直接使用其他组件中的类,最好能根本不了解其中的实现细节。只有这种程度的解耦才是我们需要的。
组件的单独调试 isRunAlone
其实单独调试比较简单,只需要把 apply plugin: 'com.android.library'
切换成 apply plugin: 'com.android.application'
就可以,但是我们还需要修改一下AndroidManifest
文件,因为一个单独调试需要有一个入口的actiivity。
我们可以设置一个变量isRunAlone
,标记当前是否需要单独调试,根据isRunAlone的取值,使用不同的gradle插件和AndroidManifest文件,甚至可以添加Application等Java文件,以便可以做一下初始化的操作。
为了避免不同组件之间资源名重复,在每个组件的build.gradle中增加resourcePrefix "xxx_"
,从而固定每个组件的资源前缀。下面是读书组件的build.gradle
的示例:
if (isRunAlone.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
//... ..
resourcePrefix "readerbook_"
sourceSets {
main {
if (isRunAlone.toBoolean()) {
manifest.srcFile 'src/main/runalone/AndroidManifest.xml'
java.srcDirs = ['src/main/java', 'src/main/runalone/java']
res.srcDirs = ['src/main/res', 'src/main/runalone/res']
} else {
manifest.srcFile 'src/main/AndroidManifest.xml'
}
}
}
通过这些额外的代码,我们给组件搭建了一个测试Host
,从而让组件的代码运行在其中,所以我们可以再优化一下我们上面的框架图。
组件的数据传输:service
上面我们讲到,主项目和组件、组件与组件之间不能直接使用类的相互引用来进行数据交互。那么如何做到这个隔离呢?
在这里我们采用接口+实现
的结构。每个组件声明自己提供的服务Service(抽象类或者接口),组件负责将这些Service实现并注册到一个统一的路由Router中去。如果要使用某个组件的功能,只需要向Router请求这个Service的实现,具体的实现细节我们全然不关心(也没有暴露),只要能返回我们需要的结果就可以了。这与Binder的C/S架构很相像。
因为我们组件之间的数据传递都是基于接口编程的,接口和实现是完全分离的,所以组件之间就可以做到解耦,我们可以对组件进行替换、删除等动态管理。
这里面有几个小问题需要明确:
- 组件怎么暴露自己提供的服务呢?在项目中我们简单起见,专门建立了一个
componentservice
(另一个核心设计)的依赖库,里面定义了每个组件向外提供的service和一些公共model。将所有组件的service整合在一起,是为了在拆分初期操作更为简单,后面需要改为自动化的方式来生成。这个依赖库需要严格遵循开闭原则,以避免出现版本兼容等问题。 - service的具体实现是由所属组件注册到Router中的,那么是在什么时间注册的呢?这个就涉及到组件的加载等生命周期,我们在后面专门介绍。
- 一个很容易犯的小错误就是通过持久化的方式来传递数据,例如file、sharedpreference等方式,这个是需要避免的。
下面就是加上数据传输功能之后的架构图:
组件之间的UI跳转:apt
可以说UI的跳转也是组件提供的一种特殊的服务,可以归属到上面的数据传递中去。不过一般UI的跳转我们会单独处理,一般通过短链
的方式来跳转到具体的Activity。每个组件可以注册自己所能处理的短链的scheme和host
,并定义传输数据的格式。然后注册到统一的UIRouter
中,UIRouter通过scheme和host的匹配关系负责分发路由。
UI跳转部分的具体实现是通过在每个Activity上添加注解,然后通过apt(Annotation Processing Tool, 注解处理器)
形成具体的逻辑代码。这个也是目前Android中UI路由的主流实现方式。
具体的功能介绍和使用规范,请大家参见文章:android彻底组件化—UI跳转升级改造
组件的生命周期:Javassist
由于我们要动态的管理组件,所以给每个组件添加几个生命周期状态:加载、卸载和降维
。为此我们给每个组件增加一个ApplicationLike
类,里面定义了onCreate和onStop
两个生命周期函数。
- 加载 onCreate:上面讲了,每个组件负责将自己的服务实现注册到Router中,其具体的实现代码就写在onCreate方法中。那么主项目调用这个onCreate方法就称之为组件的加载,因为一旦onCreate方法执行完,组件就把自己的服务注册到Router里面去了,其他组件就可以直接使用这个服务了。
- 卸载 onStop:卸载与加载基本一致,所不同的就是调用ApplicationLike的onStop方法,在这个方法中每个组件将自己的服务实现从Router中取消注册。不过这种使用场景可能比较少,一般适用于一些只用一次的组件。
- 降维:降维使用的场景更为少见,比如一个组件出现了问题,我们想把这个组件从本地实现改为一个wap页。降维一般需要后台配置才生效,可以在onCreate对线上配置进行检查,如果需要降维,则把所有的UI跳转到配置的wap页上面去。
一个小的细节是,主项目负责加载组件,由于主项目和组件之间是隔离的,那么主项目如何调用组件ApplicationLike的生命周期方法呢?
目前我们采用的是基于编译期字节码插入
的方式,扫描所有的ApplicationLike
类(其有一个共同的父类),然后通过Javassist
(Java assist ,一个开源的分析、编辑和创建Java字节码的类库)在主项目的onCreate中插入调用 ApplicationLike.onCreate 的代码
。
我们再优化一下组件化的架构图:
集成调试:aar
每个组件单独调试通过并不意味着集成在一起没有问题,因此在开发后期我们需要把几个组件机集成到一个app里面去验证。由于我们上面的机制保证了组件之间的隔离,所以我们可以任意选择几个组件参与集成。这种按需索取的加载机制可以保证在集成调试中有很大的灵活性,并且可以极大的加快编译速度。
我们的做法是这样的,每个组件开发完成之后,发布一个relaese的aar
到一个公共仓库,一般是本地的maven库。然后主项目通过参数配置要集成的组件就可以了。
所以我们再稍微改动一下组件与主项目之间的连接线,形成的最终组件化架构图如下:
代码隔离:apply plugin
此时再回顾我们在刚开始拆分组件化时提出的三个问题,应该说都找到了解决方式,但是还有一个隐患没有解决,那就是我们可以使用compile project(xxx:reader.aar)
来引入组件吗?虽然我们在数据传输章节使用了接口+实现
的架构,组件之间必须针对接口编程,但是一旦我们引入了reader.aar,那我们就完全可以直接使用到其中的实现类啊,这样我们针对接口编程的规范就成了一纸空文。千里之堤毁于蚁穴,只要有代码(不论是有意还是无意)是这么做了,我们前面的工作就白费了。
我们希望只在assembleDebug或者assembleRelease
的时候把aar引入进来(也就是在执行打包
命令的时候,首先通过compile引入组件),而在开发阶段,所有组件都是看不到的(因为开发阶段我们并没有通过compile引入组件),这样就从根本上杜绝了引用实现类的问题。
为了实现这个目的,我们是这么做的:我们把这个问题交给gradle
来解决,我们创建一个gradle插件,然后每个组件都apply这个插件,插件的配置代码也比较简单,就是在执行打包命令的时候,根据配置添加各种组件依赖,并且自动化生成组件加载代码:
apply plugin: 'com.dd.comgradle'
//根据配置添加各种组件依赖,并且自动化生成组件加载代码
if (project.android instanceof AppExtension) {
AssembleTask assembleTask = getTaskInfo(project.gradle.startParameter.taskNames)
if (assembleTask.isAssemble && (assembleTask.modules.contains("all") || assembleTask.modules.contains(module))) {
project.dependencies.add("compile","xxx:reader-release@aar") //添加组件依赖
//字节码插入的部分也在这里实现
}
}
//获取正在执行的Task的信息
private AssembleTask getTaskInfo(List<String> taskNames) {
AssembleTask assembleTask = new AssembleTask();
for (String task : taskNames) {
if (task.toUpperCase().contains("ASSEMBLE")) {
assembleTask.isAssemble = true;
String[] strs = task.split(":")
assembleTask.modules.add(strs.length > 1 ? strs[strs.length - 2] : "all");
}
}
return assembleTask
}
组件化的拆分步骤和动态需求
拆分原则(只是建议)
组件化的拆分是个庞大的工程,特别是从几十万行代码的大工程拆分出去,所要考虑的事情千头万绪。为此我觉得可以分成三步:
- 从产品需求到开发阶段再到运营阶段都有清晰边界的功能开始拆分,比如读书模块、直播模块等,这些开始分批先拆分出去
- 在拆分中,造成组件依赖主项目的依赖的模块继续拆出去,比如账户体系等
- 最终主项目就是一个Host,包含很小的功能模块(比如启动图)以及组件之间的拼接逻辑
组件化的动态需求(然并不支持)
最开始我们讲到,理想的代码组织形式是插件化的方式,届时就具备了完备的运行时动态化。在向插件化迁徙的过程中,我们可以通过下面的集中方式来实现编译速度的提升和动态更新。
- 在快速编译上:采用组件级别的增量编译。在抽离组件之前可以使用代码级别的增量编译工具,如 freeline(但databinding支持较差)、fastdex等。
- 动态更新方面:暂时不支持新增组件等大的功能改进。可以临时采用方法级别的热修复,或者功能级别的 Tinker 等工具(Tinker的接入成本较高)。
得到组件化改造大流程
总结
本文是笔者在设计"得到app"的组件化中总结一些想法(目前已经离职加入头条),在设计之初参考了目前已有的组件化和插件化方案,站在巨人的肩膀上又加了一点自己的想法,主要是组件化生命周期以及完全的代码隔离方面。特别是最后的代码隔离,不仅要有规范上的约束(针对接口编程),更要有机制保证开发者不犯错,我觉得只有做到这一点才能认为是一个彻底的组件化方案。
2018-4-22