拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android组件化改造实战:从路由设计到编译提速的完整复盘

先说一个场景项目迭代到第三年代码量冲上60万行主工程里直接揉着三十多个模块随便动一个基础类就有十几个业务模块跟着重新编译一次全量构建要七八分钟。团队的日常成了“互相踩脚”你改你的详情页布局我在列表页引用了同一个资源名一合并代码就冲突。这就是我决定做Android组件化改造的真实背景。这篇文章不是理论科普是我把主工程拆成“壳工程 业务组件 基础库”三层架构之后从路由设计、Gradle工程化、依赖治理到编译提速全链路操作的复盘。包含具体的配置写法、参数选择的理由和踩过的坑。如果你正被大项目折磨或者想从单模块工程往组件化迁移这篇可以作为一份能直接落地的参考。1. 组件化的本质不是拆分代码是治理依赖关系1.1 单体工程到底卡在哪先别急着动手拆模块想清楚“为什么要做”比“怎么做”重要得多。单体工程最痛的不是代码多而是三条编译链路长所有模块在同一个编译单元里Gradle的Task依赖让全量构建时间指数上涨改动底层代码会触发全量重编。耦合无法收敛编译期可见性太强业务模块之间互相随意引用一个工具方法都可能变成隐式API重构基本靠勇气。团队协作互相阻塞A组改了基础库B组、C组的构建就红灯测试环境经常处于“半残”状态。组件化解决的就是这三件事把业务模块拆成可独立编译、可独立运行、可独立迭代的组件让团队之间只通过设计好的接口通信而不是直接触碰对方的内部实现。1.2 组件化、模块化、插件化的边界很多方案讨论时会把这三个词混在一起我按自己的理解做个区分方案核心手段编译期切分运行期切分适用场景模块化按包名或目录组织代码逻辑分层无同一工程共享所有代码无中小型项目整理代码结构组件化按业务边界拆分独立Gradle模块有各组件可独立编译无运行时都在同一APK内中大型项目团队并行开发插件化动态加载未安装的插件APK/DEX有有运行时按需加载需要动态更新、体积瘦身的大型App组件化和模块化最大的区别在于“能否独立运行”。模块化只是目录层面的逻辑清晰组件化是构建层面的物理隔离。和插件化相比组件化又保守得多所有组件最终还是会打进同一个APK不涉及动态加载稳定性风险更低。如果你的业务规模还没到“一个功能模块单独交付”的体量硬上插件化就是给自己挖坑组件化的投入产出比是最合适的。1.3 组件化改造的目标定义先定目标再动手我当时的定义是三条缺一不可任何一个业务组件在Gradle里切换一个开关就能从library模式变成application模式独立运行。组件之间不允许直接引用对方的代码和资源只能通过路由和服务接口通信。公共能力沉淀到base层所有组件对base的依赖是单向的不允许反向依赖。这三条是组件化的底线。如果只是把一个大模块拆成几个小模块但还互相乱引那不过是把单体换了个姿势继续痛。2. 组件化设计思路与核心机制2.1 分层架构模型我采用的模型是业界比较通用的四层结构壳工程App壳只负责组装和启动不包含任何业务代码统一依赖业务组件决定最终APK的形态。业务组件层按业务边界划分比如首页、商城、我的、订单。每个组件内部可以继续拆细但对外只暴露路由入口和服务接口。功能组件层供业务组件复用的纯粹功能性模块比如网络库、图片加载、埋点SDK封装、数据库等不关联任何具体业务。基础库层最底层的通用代码比如日志、工具类、通用UI组件禁止引用任何上层代码。这条链路的依赖方向永远是上层指向下层。业务组件之间如果需要协作不允许直接依赖而是把公共需要沉淀到功能组件层或基础库层或者通过服务接口解耦。2.2 路由引擎组件之间靠什么找到彼此上面讲到组件之间不能直接引用那A组件跳转到B组件的页面怎么办传统写法是startActivity(new Intent(this, XXActivity.class))这行代码在组件化工程里是不合法的因为A组件根本看不到B组件的类。解决方案就是路由。现在谈到路由业界最常用的方案是ARouter核心原理可以概括为三步编译期搜库通过APT注解处理器扫描所有被Route注解标记的类生成路由映射的Java类文件。运行期装配App启动时拿到这些映射类把path到目标类的映射关系注册到内存里的路由表中。跳转期匹配调用ARouter.getInstance().build(path).navigation()时直接查表反射拿到目标Activity的Class完成跳转。路由的价值不单是“用字符串代替了new Intent”这么简单更重要的是它把“页面之间的直接依赖”变成了“对路由框架的间接依赖”这让组件在没有物理编译依赖的前提下完成跳转变成了可能。代码上我只依赖ARouter的API和path常量path常量由C组件维护、集中放到底层common模块中A组件不关心C组件内部长什么样。2.3 跨组件调用服务的三种姿势页面跳转只是最基本的诉求更复杂的情况是跨组件调用能力。我总结出三种常见的解法接口下沉把跨组件需要的接口定义放到common/基础层实现类放在具体的业务组件中调用方只依赖接口定义不依赖实现类。服务注册与发现把上述接口的实现类用路由注册调用方通过路由拿到实现类实例。ARouter的IProvider机制就是干这个的。事件总线组件间通过发布和订阅解耦典型的如EventBus。但事件总线我记得在大型项目里要克制使用因为事件满天飞之后很难追踪调用链出了问题排查成本极高。三种方案不是互斥的我实际用的组合是接口定义下沉到common服务实现注册到路由跨组件的同步调用走服务发现纯异步的解耦场景再用事件总线兜底。这里有一个很微妙的点接口下沉之后接口文件每增加一个方法所有依赖方都要重新编译。所以接口设计要稳宁可多拆几个细接口也别做一个超级大接口否则接口变动导致的编译连锁反应会让组件之间形成新的隐性耦合。3. Gradle工程化落地把组件化的设想变成可编译的工程3.1 工程目录结构与模块拆分设计文档写得再好落地还是要靠Gradle。先看我的工程结构大概长什么样MyProject/ ├── settings.gradle ├── build.gradle ├── gradle.properties ├── app/ // 壳工程 ├── lib_common/ // 基础库层 ├── lib_network/ // 功能组件层网络 ├── lib_image/ // 功能组件层图片加载 ├── module_home/ // 业务组件首页 ├── module_mine/ // 业务组件我的 ├── module_order/ // 业务组件订单 └── module_common_ui/ // 功能组件层通用UI模块拆分的粒度是个经验活。我建议遵守两个原则按业务边界拆不按代码复用拆。两个完全不同的业务页面为什么要放一起只有同一个业务域、同一个团队维护的代码才值得放进同一组件。组件内部继续按功能分包把对外暴露的入口类和内部实现类分开包放减少未来重构时的包裹传递。3.2 组件开关一个组件同时具备application和library两种形态这是组件化Gradle配置的核心我贴一段能直接用的配置根目录gradle.properties中加入开关# 每个业务组件一个开关true为独立运行false为集成到壳工程 isHomeRunAlonetrue isMineRunAlonefalse注意Gradle读取Properties时字符串会保留原样所以后面判断时一定用toBoolean()不要直接拿字符串和true比较那种写法会出非常隐蔽的问题。然后看module_home/build.gradle中关键的几段配置// 根据开关切换插件和三方框架的引入方式 if (isHomeRunAlone.toBoolean()) { apply plugin: com.android.application } else { apply plugin: com.android.library } android { // 独立运行时要能单独安装所以需要applicationId if (isHomeRunAlone.toBoolean()) { applicationId com.example.home } sourceSets { main { if (isHomeRunAlone.toBoolean()) { manifest.srcFile src/main/debug/AndroidManifest.xml } else { manifest.srcFile src/main/AndroidManifest.xml } } } }这里的核心逻辑是独立运行模式下这个模块被打包成application需要自己的applicationId和带启动入口的AndroidManifest.xml集成模式下它变回library使用统一的Manifest不带启动Activity。有个很值得注意的点sourceSets切换的不仅是Manifest文件。独立运行模式下组件还需要自己的Application类所以很多团队会在src/main/debug目录下放一套Debug入口代码包括一个启动Activity和一个DebugApplication。这套代码只参与独立模式的构建不会被打进正式包放心写。3.3 壳工程的Application初始化策略集成模式下所有组件都进了同一个APK但App只会运行一个Application。所以壳工程里要负责所有组件的初始化。我采用的做法是在壳工程的Application.onCreate()里统一调用各组件暴露的init()方法class App : Application() { override fun onCreate() { super.onCreate() // 按启动顺序初始化各业务组件 HomeComponent.init(this) MineComponent.init(this) OrderComponent.init(this) // 初始化基础库 NetworkManager.init(this, BuildConfig.API_BASE_URL) } }这里有个性能细节初始化的顺序会直接影响冷启动时间。耗时操作比如创建数据库实例、预加载缓存要放到子线程或延迟到首帧之后不要在onCreate里同步做一堆重活。我的经验是把初始化任务拆成“必需同步”和“可异步”两类前者顺序执行后者丢到空闲调度。4. 依赖管理与版本治理4.1 统一版本管理组件一多版本管理就变得棘手。几十个Gradle文件里散落着各种依赖版本升级一个依赖要全局搜索替换特别容易漏。Gradle常见的版本统一方案有三种ext root build.gradle统一声明把版本号放到ext块里子模块通过rootProject.ext.xxx读取。简单直接Android Studio自动补全对ext的支持也还行。build.gradle里直接定义每个模块各写各的用起来最省事但缺乏约束小项目图方便可以这么干项目大了不推荐。Gradle Version CatalogGradle 7.4以后官方推的libs.versions.toml方案在gradle/libs.versions.toml中集中声明版本和依赖IDE支持好提示也清晰。我用的是Version Catalog一份toml文件管所有依赖。关键是基础的第三方库版本比如AndroidX库、Kotlin、协程都要保证全工程统一否则编译时会碰到各种依赖冲突。4.2 implementation和api的正确姿势这个点能写一整节因为太多人栽在这儿。implementation和api的区别一句话说清楚api会暴露给下游模块implementation不会。举例来说lib_network用api com.squareup.okhttp3:okhttp:4.xmodule_home依赖lib_network这时候module_home能用OkHttp的类因为它通过api拿到了传递依赖如果lib_network用implementation依赖OkHttpmodule_home里哪怕只用了OkHttp的Request类编译也会报“找不到类”的错误。我踩过的坑是组件化早期为了省事基础库全部用api结果基础库依赖什么业务组件全都可见依赖边界完全失效。正确做法是坚持最小可见性原则基础库对内部实现用implementation兜底只有真的需要暴露给下游的类型才用api。4.3 依赖方向的硬性约束组件化的依赖关系不能靠自觉要靠机制卡住。我项目里加了两个硬性约束业务组件之间禁止互相依赖如果发现module_home依赖了module_order代码评审直接打回。业务组件只能依赖功能组件和基础库且基础库不能依赖任何业务组件。这个约束靠Gradle插件和IDE检查配合落地。有段时间我靠人肉检查结果就是在某个版本迭代中因为赶进度又悄悄引入了跨组件依赖等到模块独立运行时才发现一堆问题。后来加了个简单的gradle脚本在CI阶段扫描模块依赖才把这个口子堵住。5. 编译提速与组件二进制化5.1 独立编译的收益组件化的直接回报就是编译速度。在单体工程里改动一个底层类会触发所有依赖它的模块重新编译。组件化之后业务组件相对独立Gradle的增量编译能做到只编译发生变化的组件和依赖它的少量模块。我改造后的实测数据全量构建从8分钟下降到3分钟左右日常开发增量编译基本控制在30秒内。这个数据不算最好但开发体验已经是天壤之别。想再压一压编译时间可以从这几方面入手开启Gradle构建缓存org.gradle.cachingtrue让相同输入跳过编译直接用缓存产物。配置并行构建org.gradle.paralleltrue让互不依赖的模块并行编译。关闭不必要的注解处理KAPT如果用得多可以评估迁移到KSP编译时间能再降一截。5.2 将稳定组件发布到私有Maven仓库组件化到一定阶段你会发现一个更狠的招组件不再只是源码模块而是发布成二进制依赖。操作思路是把稳定的组件比如module_order已经很久没改过通过Maven插件发布到公司的私有仓库壳工程改为依赖远程坐标。这样壳工程的构建根本不用拉取该组件的源码直接下载编译好的AAR编译时间进一步缩短。发布命令一般长这样./gradlew :module_order:publish然后在壳工程的build.gradle里改一行implementation com.example:module-order:1.2.0代价是要维护一套版本发布流程组件有改动时要重新发布依赖方升级版本。对于快速迭代的新业务来说发布到本地仓库反而拖慢速度所以我的策略是业务稳定期发布迭代活跃期保持源码依赖。5.3 团队协作模式的变化组件化不只是技术改动还改变了团队协作的方式。以前是所有人推同一个分支现在是每个组件一个独立仓库或者一个仓库多个独立目录组件之间通过版本号建立依赖关系各组的发版节奏不再互相牵制。这里要提醒一句组件化不是万能的。团队五六个人、产品还在快速试错阶段组件化带来的工程复杂度反而会拖慢迭代。我曾经见过一个十人团队硬拆了二十个组件结果每天光协调依赖版本就耗掉半天典型的过度设计。6. 常见问题与排查技巧实录6.1 Manifest合并冲突组件化之后每个组件都有自己的AndroidManifest.xml最终构建时Gradle要把它们合并成一个。最常见的报错是Manifest merger failed原因通常是多个组件声明了相同的特殊属性或权限。排查方法其实不玄妙看构建输出的合并报告Gradle会给出具体的合并日志和冲突原因。我看到最频繁的两类冲突多个组件都声明了android:theme或android:icon这类冲突可以在合并时通过tools:replace指定覆盖规则但治本要统一从继承主题中去除重复声明。各组件的activity使用了相同的android:name或android:taskAffinity这种通常是把同一个Activity复制到了多个组件需要自查归属关系。6.2 资源冲突与命名污染资源冲突是组件化工程的经典难题。两个业务组件都定义了colors.xml里的color_primary合并时后加载的会把先加载的覆盖掉界面风格直接错乱。根治方案是强制资源命名前缀我用Gradle配置卡住了每个组件的资源前缀android { resourcePrefix home_ }设置之后module_home里如果出现color/primary构建会直接报错强制改名为home_primary。这样资源名的冲突就从根子上被约束了。还有个容易被忽略的是资源在style中的隐式使用。比如组件A定义了style_btn_primary组件B在layout里引用同名style但没定义自己的编译期不报错运行时样式却是错的。这类问题靠人眼排查效率太低建议加个lint规则或者在Code Review阶段把资源引用检查列为必检项。6.3 路由目标找不到ARouter时代最经典的崩溃就是Path not found跳转时找不到对应的页面。我复盘过几次发现无非是几个原因Route注解的path写错了或者和调用方传的path不一致。路由表没被加载。ARouter在编译器生成的映射类是通过类名反射加载的如果你的ProGuard混淆规则没保留对应类名生产环境下路由表会是空的。页面所在的组件没有参与当前构建。独立运行某个组件时它身边根本没有其他组件的class路由找不到目标其实是正常现象因为独立模式下只能自跳转自己的页面。排查技巧ARouter提供了ARouter.getInstance().build(path)的日志开关开发阶段把debuggable打开、把日志级别调成LogLevel.INFO能看到路由加载了哪些表、匹配到了哪些目标。生产包记得把日志关掉。另外ProGuard规则是个常踩的坑。组件化之后每个组件的混淆规则建议放在各自模块的proguard-rules.pro里并用consumerProguardFiles在library中声明这样打成AAR时规则会自动传递给依赖方不再需要壳工程集中维护一份超级大的混淆规则文件。6.4 场景化主题千万别把组件化当成银弹组件化解决的是“多团队、大规模、长迭代周期”的开发协作问题。如果你的项目只有一两个模块、团队也就几个人单体工程的成本远低于组件化工程。拆分的开销是非常真实的模块管理、版本协调、接口设计、构建链路维护每一样都在消耗人力。我做过的几个项目的经验是代码量低于20万行、业务模块少于5个模块化加良好的包结构就够用了。团队在10人以上、业务条线清晰、各模块迭代节奏差异明显时组件化的收益才真正显现。如果连分层结构都还没有先把分层做好再谈组件化地基不稳硬拆墙只会塌得更快。最后再分享一个我个人的体会组件化的核心不是路由不是Gradle配置而是依赖治理的意识。每个开发都愿意遵守“不跨组件引用”的规则组件化才能发挥价值否则再完美的架构设计也会在一次次“临时加个依赖”的妥协中迅速腐化回单体。ArchUnit这类依赖校验工具能帮上忙但最可靠的还是团队对架构底线的共识。如果你正在做组件化改造先把沟通机制建立好这比任何技术方案都重要。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门