1. 从单体项目到组件化:为什么我劝你别再硬撑了
如果你手头有一个Android项目已经写了两三年,app模块里的代码量到了几十万行,每次编译都要等三分钟以上,改一个Button样式都要连带跑完所有功能回归——那这篇文章就是写给你看的。
我在前两年接手的一个项目就是典型状态:app模块下包名杂乱无章,登录、支付、推送、分享、IM、商城全部挤在一起。业务方提需求越来越快,代码冲突越来越频繁,每次git merge光解决冲突就要花掉半天。更让人头疼的是,整个团队十几个人都在改同一个模块,Git提交记录乱成一锅粥,谁改了哪儿根本说不清楚。真正让我下决心做组件化改造的导火索,是一次线上崩溃——起因只是某位同事在改商城页面的SharedPreferences时,字段名跟登录模块的工具类撞了,结果把所有人的登录态都搞丢了。那一次事故让我彻底意识到,单体应用的耦合已经不只是技术债问题,而是直接威胁线上稳定性的定时炸弹。
组件化并不是什么高深的技术,它的核心思想只有一句话:把单体的app模块,按业务或功能边界拆成多个独立的组件模块,每个模块可以独立编译、独立测试、独立维护,最终通过一种约定的方式组装成完整的App。
这篇文章我会完整记录一次从单体应用到组件化的改造全过程。内容不是教科书式的理论,而是我自己踩坑踩出来的实操经验——包括模块怎么划分、路由怎么设计、资源冲突怎么规避、独立调试和集成模式怎么切换、还有改造过程中遇到的各种诡异问题。如果你正在考虑组件化改造,或者已经在改造路上被各种问题卡住,这篇文章应该能帮你省下不少弯路。
2. 动手之前先想明白:组件化到底拆的是什么
2.1 拆的不是代码,是职责边界
很多人一听到组件化,第一反应就是"把代码拆到不同模块里去"。这句话对了一半。拆代码只是表象,真正拆的是职责边界和依赖方向。
在单体架构里,代码之间天然没有边界,任何类都可以被任意地方引用。你一个工具类放在util包里,理论上谁都能调。这听起来很方便,但代价是——没有任何人知道这个工具类被谁依赖,改起来慌不择路。
组件化拆分的本质,是把"谁依赖谁"这个隐性问题变成显性约束。我习惯把模块分成三个层级:
- 基础层(base / common):网络库封装、图片加载、基础工具类、自定义View、通用Dialog等。这一层不依赖任何业务模块,只被上层依赖。
- 业务组件层:按业务线划分,比如登录组件、商城组件、支付组件、IM组件。每个组件内部是完整的业务闭环,组件之间禁止相互直接引用。
- 宿主壳层(app):只负责组装。它依赖需要用到的业务组件,统一配置路由表、管理Application的初始化顺序,本身不写业务代码。
这个分层规则必须要用依赖检查机制落地,而不是靠自觉。我在改造时就定了一条铁律:基础层禁止依赖任何业务组件,业务组件只允许依赖基础层和其他暴露接口的组件,宿主壳层是唯一允许依赖所有组件的地方。谁违反规则,代码评审直接打回。
2.2 组件粒度怎么定:一个页面一个组件是误区
组件拆到多细,直接影响改造工程量。拆得太粗,模块之间依然高度耦合,没解决实际问题;拆得太细,光维护组件间的通信就够你喝一壶的,增量编译的优势也被频繁跨模块调用抵消了。
我自己的经验是:按"业务闭环"而非"页面"来划分。所谓业务闭环,就是用户完成一个完整操作链路所涉及的所有页面和逻辑。比如用户在商品列表页点进详情页、加购物车、下单、支付,这条路是一个闭环,适合放在一个组件里。而登录算一个独立闭环,因为几乎每个App都需要登录,但它不依赖商品链路。
实际操作中,我一般会把一个组件控制在10到30个页面左右。太小了没必要,太大了又会失去组件化的意义。如果你发现一个组件内部页面之间互相跳转的逻辑特别多,那说明它本身就应该是一个组件,而不是分散到多个组件里,这点在划分时务必留意。
2.3 改造路径的选择:自顶向下还是自下而上
组件化改造有两种推进路径,效果差别很大。
自顶向下是先把app壳层拆出来,再把业务页面一个一个抽到组件里。这种方式前期见效快,app壳层独立出来后,构建速度会有明显提升,团队能立刻感受到收益。但缺点是改造过程中业务组件还是散落在app里,组件之间的耦合问题解决得不彻底。
自下而上是先把基础能力抽成底层模块,再把业务组件往上搭。这种方式工程量大,前期看不到明显效果,团队容易失去信心。
我实际验证下来比较推荐的做法是双轨并行:优先抽基础层中耦合度最低的部分(比如网络、图片、工具类),同时选一个业务边界最清晰的模块(比如登录)做第一批组件化试点。基础层先落底,给业务组件提供干净的底座;试点组件跑通整条链路,验证路由、资源隔离、独立调试这些关键机制可行。试点跑通后,再按业务优先级逐个迁移剩余模块。
这样做的最大好处是风险可控。试点模块出了问题,影响面就那么大,能快速回滚;机制验证成熟之后,大批量迁移的速度反而快。一口气想把所有模块全拆完的团队,几乎都会在中期暴露出各种历史遗留耦合问题,搞得进退两难。
3. 模块拆分落地:从一个登录组件开始完整走一遍
3.1 模拟项目背景与目标结构
为了把整个流程讲具体,这里用我参与过的某模拟项目来演示。项目原本是单体结构,app模块下有登录注册、商品浏览、下单支付、消息中心、个人中心5个业务域。改造目标是最终形成如下结构:
项目根目录 ├── app(宿主壳层,只做组装) ├── base_lib(基础库:网络、图片、工具、UI组件) ├── component_login(登录注册) ├── component_mall(商品浏览与下单) ├── component_pay(支付流程) ├── component_message(消息中心) ├── component_profile(个人中心) ├── third_libs(第三方SDK适配层,按需集成) └── buildSrc 或 gradle版本目录(统一依赖管理)首批试点选登录组件,原因是它的业务闭环最清晰:登录、注册、找回密码、以及登录后状态同步,依赖的外部系统只有登录态存储和用户信息接口,边界相对干净。
3.2 Gradle模块创建的完整步骤
第一步,在项目根目录新建component_login模块。使用Android Studio向导创建时类型选Phone & Tablet Module,包名按公司的域名规范来。component_login的build.gradle初始配置与常规模块没有太大区别,重点是为了支持后续独立调试,我通常给每个业务组件加一个单独开关。实际调试时我会把isRunAlone切到true,这时组件模块会变成独立的application入口,有自己的AndroidManifest.xml,可以单独跑起来调内部逻辑。
关键代码如下:
android { // 资源前缀约束,防止资源名冲突 resourcePrefix "login_" } if (isRunAlone.toBoolean()) { apply plugin: 'com.android.application' // applicationId 使用带后缀的独立包名,便于测试包共存 } else { apply plugin: 'com.android.library' }这里有个很关键的细节:resourcePrefix一定要在组件模块创建时就配上。它的作用是强制组件内所有资源必须以login_开头,从源头避免资源名冲突。这个配置我后面会专门讲,因为很多人都是彻底改完才想起来,到时候满屏的资源命名冲突会改到你怀疑人生。
新建模块后,记得把模块里的AndroidManifest.xml拆成两份,一份给集成模式用(不声明Application和启动Activity),一份给独立调试用(声明Application和入口Activity)。具体放在src/main/AndroidManifest.xml和src/debug/AndroidManifest.xml,这在后面独立调试章节再详细展开。
3.3 代码迁移顺序:由底向上的依赖梳理
模块建好后,最难的是把原有代码搬过去。这里我有几条可复用的经验:
第一,先搬不带业务逻辑的纯工具类和UI组件。这些类依赖最少,搬过去风险最低,而且能立刻让app模块瘦身。登录组件里的密码加密工具、验证码倒计时控件这些,都是第一批迁移对象。
第二,再搬网络请求和数据层。登录组件的网络请求是独立的LoginApiService,把它连带着请求/响应模型一起搬到组件里。这一步要注意:如果多个组件共用同一套数据模型,应该把模型下沉到base_lib,不要各自复制一份。否则同一份用户信息在两个模块里维护两份,字段不同步的时候你根本不知道哪边是对的。
第三,最后搬页面和交互逻辑。页面类是耦合最重的部分,通常涉及自定义Application初始化、全局导航、跨页面的状态同步。搬的过程中要同步调整这些依赖关系,这也是跨组件通信方案要提前设计好的原因。
整个登录组件迁移,我花了大约两个工作日。过程不复杂,但需要细致。每个类搬过去之后,都要同步检查它的依赖是否都迁移完成,不然编译时一堆红色的报错会让你非常崩溃。
3.4 搬完代码之后的第一轮编译修复
把代码搬到新模块后,第一轮编译几乎必然报错。常见的错误类型有几类:
没有导入错误是最容易处理的,多数是import缺失,根据IDE的提示Alt+Enter导入即可。
找不到符号通常是依赖缺失。某个方法在base_lib里,但component_login没有声明依赖base_lib,编译就报错。处理方式是检查build.gradle中声明了implementation project(':base_lib')。
资源访问权限错误相对隐蔽。在单体项目中,app里的代码可以直接引用R.string.app_name这样的资源。拆成组件后,如果这个资源在别的模块里,默认私有访问会失败。处理方式有两种:一是用transitive把资源暴露出去,二是把公共资源下沉到base_lib。我强烈建议后者,因为资源跨模块随意引用会让资源管理变得一团乱。
// base_lib 中放公共资源 dependencies { // 组件模块依赖基础库,即可访问其资源 implementation project(':base_lib') }提示:当Android Studio对
R类导入报红色错误时,先检查依赖声明,再检查资源是否存在,不要盲目执行Build > Clean Project,很多情况下清理后依旧报错。
4. 组件间通信:接口下沉与事件总线
组件拆好后,最核心的问题就浮出水面:组件之间怎么通信?登录组件登录成功后,消息组件和个人中心组件怎么知道用户登录状态变了?商城组件怎么通知支付组件去发起支付?
4.1 基础通信方案:接口下沉到基础库
组件化刚起步时,不必一上来就引入复杂的路由框架。最简单的方案是接口下沉:把通信的接口定义放在base_lib里,组件A实现这个接口,组件B通过查找得到接口实例,然后调用。以登录状态为例,我在base_lib里定义一个UserAction接口:
public interface LoginAction { void login(LoginRequest request, LoginCallback callback); void logout(); boolean isLoggedIn(); }登录组件实现这个接口,然后在初始化时把实例注册到一个服务管理类里。其他组件通过ServiceManager.get(LoginAction.class)就能拿到实现,从而调用登录方法。这样,组件之间完全不直接引用对方的类,只依赖基础库里定义的接口。
这个方案的优点是实现简单,不需要引入额外的路由框架,改动量小。缺点是随着业务变多,base_lib里的接口数量会膨胀,而且每个新接口都要手动注册和查找,代码写起来比较啰嗦。如果项目规模不大,或者你想以最小成本完成组件化改造,这个方案足够用了。
4.2 组件间跳转:路由框架的核心机制
如果两个组件之间只是传递数据、调用方法,接口下沉就能解决。但如果你在组件A的一个页面里,要跳转到组件B的某个页面,怎么办?总不能直接startActivity然后import页面的类——那样就破坏了组件间的隔离。
这时候就要引入路由框架。主流的方案有阿里ARouter之类,但我一般建议自己封装一个轻量级的,因为第三方路由框架绑定较深,出了问题排查难度高,而且很多功能你根本用不到。自己封装的路由核心机制非常简单:
每张需要被跨组件访问的页面,在@Route注解里注册一个路径。
@Route(path = "/login/LoginActivity", description = "登录页") public class LoginActivity extends AppCompatActivity { // ... }在初始化时,框架通过注解处理器扫描所有带@Route的类,建立路径到Class的映射表。跳转时,调用方只需要传路径字符串:
Router.getInstance() .build("/login/LoginActivity") .withString("source", "mall_checkout") .navigation(context);路由最核心的价值不只是"能跳转",而是解耦。页面A不关心目标页面是哪个类,只关心路径。哪怕目标页面完全重写,只要路径不变,调用方完全无感。
自己封装路由框架时,要注意几个高级能力,否则落地时会发现不够用:
- 参数传递与类型转换:支持
Integer、String、Parcelable等类型。我的实现是构建一个Bundle,在navigation时自动解析withXxx方法存入的参数。 - 拦截器机制:登录拦截是路由框架最典型的应用场景。用户跳转到需要登录的页面时,路由先执行登录校验,未登录则跳转登录页,登录成功后再自动跳回原目标。这比在每个页面手动判断登录态要干净得多。
- 降级策略:目标页面不存在时给出友好提示,而不是让应用崩溃。调试期可以在降级逻辑里打印所有已注册的路由表,方便排查。
- 跨组件调用:除了页面跳转,跨组件方法调用也可以用路由实现,和前面的
ServiceManager思路相似,只是路径化管理起来更统一。
4.3 模块间状态同步:从观察者模式到事件总线
登录成功需要通知消息组件更新未读数量,下单成功需要通知个人中心刷新订单角标——这类跨模块状态同步,光靠接口查询不够,因为你要的是"变化通知"而不是"主动获取"。
最简单的方案是用一个极简的事件总线管理器。核心逻辑就是观察者模式:维护一个事件类型到订阅者的映射,发布者发出事件,所有订阅者收到通知。
public class EventBus { private final Map<Class<?>, List<Object>> subscribers = new ConcurrentHashMap<>(); public void register(Object subscriber) { ... } public void unregister(Object subscriber) { ... } public void post(Object event) { // 通过反射调用订阅者中带有 @Subscribe 注解的方法 } }我用的是自己封装的极简事件总线,核心逻辑两百行左右,没有用第三方框架。好处是完全可控,出了问题半小时就能读完源码。
注意:事件通知只是传递"发生了什么",不要传递大对象。事件里只放必要的信息,比如事件类型、少量业务ID。具体的业务数据,订阅者收到事件后自行查询。这样可以避免多个模块同时维护同一份数据副本,导致数据不一致的隐患。
4.4 跨组件通信选型:什么时候用接口,什么时候用路由,什么时候用事件
很多新手问:这三个通信方式到底怎么选?
我用的判断标准很简单:
- 有明确的调用方和目标类,用接口下沉。比如支付组件调起支付,支付结果需要返回给调用方,这种是一对一的强关联,接口最合适。
- 页面跳转,不看目标是谁,只关心路径,用路由。因为跳转天然是面向路径的,不需要关心目标实现。
- 一对多的状态广播,发布方不关心谁在听,用事件总线。登录状态变化、购物车数量变化这种天然就是广播语义。
三种方式配合使用:路由承载页面跳转,接口承载强关联的方法调用,事件总线承载弱关联的状态同步。三者的关系理清了,组件的通信边界就能保持清晰。
5. 组件化工程的整体构建策略与依赖管理
5.1 统一依赖版本管理:版本目录方案怎么落地
组件模块多起来之后,每个模块的build.gradle都要声明依赖,如果每个模块里写着不同的依赖版本,后果就是编译时不定期爆出依赖冲突。
组件化改造做依赖管理,我推荐直接用Gradle的版本目录(Version Catalog)。它是Gradle官方推荐的依赖管理方式,能解决资源冲突、版本统一、依赖声明规范化等问题。在项目根目录的gradle/libs.versions.toml文件里集中定义:
[versions] androidx-core-ktx = "1.12.0" retrofit = "2.9.0" [libraries] androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }各模块声明依赖时:
dependencies { implementation libs.androidx.core implementation libs.retrofit }用版本目录的收益不只是统一版本,更重要的是依赖变化时只改一处,所有模块自动同步。组件化改造后模块数通常会增加好几个,如果没有集中管理,依赖冲突会让你疲于奔命。
5.2 组件调试开关与构建变体
前面提到每个业务组件都有一个isRunAlone开关。这个开关放在根目录的gradle.properties里:
isRunAlone=false组件模块在build.gradle里读取这个属性,决定自己应用application插件还是library插件。同时,独立调试模式下要有单独的AndroidManifest.xml,里面声明独立的Activity入口。
这个开关的常见问题在于:切换之后,Android Studio的Sync Project没有重新执行,或者没切Build Variant导致模块类型没生效。我的经验是,每次改完gradle.properties之后,一定要执行一次File > Sync Project with Gradle Files,然后检查Build Variants窗口,确认模块的类型已经正确切换。
5.3 公共工具类会不会变成"垃圾场"
base_lib这个模块很容易变成代码垃圾场——什么工具类都往里塞,功能越加越多,最后跟原来的app模块一样臃肿。我做了两条硬性约束:
第一,新工具类必须至少被两个不同组件使用,才能进入base_lib。如果只是某个组件内部用,就放到组件自己的util包里。
第二,base_lib里不允许写业务逻辑。哪怕只涉及一点点登录相关的判断,也不准放在基础库。基础库的原则是"绝对中立的工具箱",不给任何业务方向。
这两条约束要靠代码评审来硬卡。有一次某同事往base_lib里加了带登录逻辑的弹出框,被我发现后打回重新挪到登录组件。这类"随手放错位置"的代码如果不及时纠正,几个月后base_lib就会腐化得跟原来的app一样。
6. 组件独立调试与集成联调的双模式切换
6.1 为什么组件必须能独立运行
组件化改造的一个关键目标是团队并行开发和独立编译。如果一个组件不能独立运行,那调试时每改一个按钮颜色,都要把整个App编译一次,增量编译的优势荡然无存。
组件独立运行的机制上面已经提到了,核心是isRunAlone开关加独立的debug/AndroidManifest.xml。在独立调试模式下,组件作为application运行,有自己的Application类,可以在里面初始化必要的依赖,比如网络框架、图片加载库。
src/debug/AndroidManifest.xml的内容大致是:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <application android:name=".debug.DebugApplication" android:theme="@style/Theme.Debug"> <activity android:name=".DebugMainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>这样集成模式下使用main/AndroidManifest.xml(无启动入口),独立调试模式下使用debug/AndroidManifest.xml(带启动入口),互不干扰。
6.2 独立调试的完整流程
我把独立调试模式下的完整操作步骤整理一下,方便直接照着做:
- 在根目录
gradle.properties中把isRunAlone改为true(调试哪个组件就改哪个)。 - 打开
Build Variants窗口,确认目标组件模块的module类型是application。 - 给组件配置运行任务。Android Studio默认会把所有
application模块都作为可运行目标,直接选择带DebugApplication的配置运行即可。 - 调试
DebugApplication里的初始化逻辑,确保网络层、图片加载等基础能力可用。 - 在
DebugMainActivity里写一个简单的业务入口,例如直接跳转到登录页。 - 运行组件,验证业务逻辑。
调试完成后,把isRunAlone改回false,Sync Project,确认模块回到library类型。
6.3 集成模式下的资源与依赖问题排查
集成模式下最常见的问题是resourcePrefix规则没遵守导致的资源冲突。举个例子,某个组件在模块中定义了一个ic_launcher,另一个组件也定义了同名资源,打包时aapt会直接报资源冲突,或者更隐蔽地指向错误的资源。
遇到资源冲突报错时,完整排查链路是这样的:
- 先看报错信息中冲突资源的名称。通常aapt会直接告诉你
resource entry ic_launcher is already defined。 - 用
Ctrl+Shift+F全局搜索,看哪些模块定义了这个资源。 - 如果是之前的模块,按新组件的
resourcePrefix规则重命名。 - 如果这个资源被多个模块共用,下沉到
base_lib并去掉前缀。 - 改完重命名之后,同步修改代码中的引用,全局搜索旧资源名,逐处替换。
- 最后执行
Build > Clean Project,再重新编译验证。
这里我特别强调:不要在集成模式下花大量时间排查资源冲突,应该先从源头卡住。每个组件创建时就设置resourcePrefix,评审时检查资源命名。规则前置的成本远低于事后补救。
6.4 集成运行中发现的边界问题:Application初始化顺序
集成模式下所有组件共用一个Application,组件初始化顺序就必须明确定义。我把初始化分为三个阶段:
- 基础库初始化:网络、图片、日志等,最先执行。
- 业务组件初始化:按依赖关系排序。比如支付组件依赖登录状态,那登录组件就要先于支付组件初始化。
- 业务注册:路由表加载、事件总线订阅、ServiceManager注册等,最后执行。
为了保证顺序,我在壳层的Application里写了一个ComponentInitializer工具,用一个有序链表管理初始化任务:
public class ComponentInitializer { static { register(new BaseLibInitializer(), 0); register(new LoginComponentInitializer(), 1); register(new PayComponentInitializer(), 2); register(new RouterInitializer(), 99); } }分级注册之后,再遇到某个组件初始化顺序不对导致的崩溃,直接调整序号就行。这个方案看起来简单,实际运行中非常稳。
7. 全套编码规范:从命名到依赖的硬性约束
7.1 资源前缀、类命名与包结构规范
组件化项目里,规范不是"建议",而是"必须"。我列一下我实际项目中强制执行的那几条:
- 资源前缀:登录组件资源一律以
login_开头,商城组件以mall_开头。resourcePrefix配置强制工具检查,审计时人工抽查。 - 包结构:每个组件内统一分包结构。以登录组件为例,
component_login下的包名规范是com.公司域名.login,内部继续按ui、data、api、util、bean分。 - 路由路径:统一格式是
/模块名/页面名,比如/login/LoginActivity,/mall/GoodsDetailActivity。路径单词一律用驼峰,避免大小写不一致导致的路由找不到。 - 禁止跨组件的类引用:组件A的代码里绝对不允许直接
import组件B的类。这条我是通过Checkstyle自定义规则拦截的,扫描import语句,发现跨组件引用直接报错。
7.2 依赖方向的限制与检查
为了约束"基础层禁止依赖业务组件、业务组件禁止互相依赖",除了Review,我建议用gradle任务做自动检查。在根项目的build.gradle里加一个简单的checkDependencies任务:
task checkDependencies { def forbidden = [ 'component_mall': ['component_pay', 'component_message'], 'component_login': ['component_mall'], ] // 读取每个组件的 build.gradle 的依赖声明,发现违规依赖则报错 }我在实际项目中就是靠这套自动检查加人工Review双重保险,才把依赖混乱的苗头在萌芽期就掐掉。改造成熟之后的项目,新增的跨组件依赖几乎为零。
7.3 路由优先级与冲突检查
路由路径冲突也是组件化改造后期很容易出现的问题。两个组件各自注册了/login/LoginActivity,路由表构建时就会冲突覆盖。
我在自己的路由框架里加了路径冲突检测:初始化时遍历所有路由映射,如果同一个路径被两个不同的类注册,直接打印错误日志,并且杀进程,防止带着错误路由表上线。这个机制在调试期帮了大忙,几乎每次出现路径冲突都能立刻暴露。
8. 真实踩坑记录与排查过程复盘
组件化改造过程中我踩了不少坑。这里记录几个印象最深的,每个都附上完整的排查链路,希望能帮你缩短排查时间。
8.1 资源文件冲突的完整排查链路
某次集成模式下编译报错,aapt提示login_bg_loading资源已被定义。我全局搜索后发现,登录组件和商城组件各定义了一个同名资源,但内容不同。代码里登录页实际引用的是商城组件的资源,表现就是登录页的加载背景图在某些机型上显示异常。
排查链路如下:
- 先定位到报错资源名,确认是哪两个模块冲突。
- 全局搜索这个资源的引用位置,发现登录页的布局中引用的是无前缀的旧资源名。
- 检查登录组件的
resourcePrefix配置,发现该组件创建时没有配置前缀规则,导致迁移代码时大量旧资源名没有重命名。 - 处理方法:把登录组件所有资源统一加上
login_前缀,同时修改代码中的引用。 - 验证:重新编译,确认资源冲突消失,再检查登录页背景图显示正常。
这个坑的根因就是组件在迁移初期没有立即配置resourcePrefix。如果提前配置就不会有后续问题。
8.2 路由找不到页面的诡异故障
改造成型后有一段时间,用户反馈从商城页跳转支付页偶尔失败,点击没反应,也不崩溃。第一次排查时查了路由表,路径正确,页面类存在,没有任何报错。
后来查了路由初始化时才发现,路由表构建是异步的,在极端情况下业务页面跳转发生在路由表构建完成之前,路径还没有注册进去,所以找不到目标页面。
修复方案有两个层面:一是在路由组件的Application初始化时改为同步构建路由表,确保路由注册完成前不会响应跳转请求。二是给路由框架的降级逻辑加上日志,万一找不到目标页面时打详细日志而不是静默失败。修复后这类故障再也没有出现过。
8.3 登录态丢失的诡异问题
改造后的某个版本,用户反馈登录状态经常会掉线。起初以为是token过期逻辑有误,排查了很久。后来发现根因是登录组件和消息组件都维护了一份用户信息,消息组件在某次数据刷新时把未登录状态写回了SharedPreferences,覆盖了登录组件刚写的登录态。
这个问题的根因是同一份数据在两个组件里各自维护了一份副本。修复方案是把登录态存储下沉到base_lib,提供统一的读写接口,两个组件都通过接口访问,不再各自操作SharedPreferences。这一个看似简单的修复不仅解决了登录态丢失,还顺带消灭了其他地方的数据不一致隐患。
8.4 代码分支冲突与合并策略
组件化改造过程中同时有多个功能分支在并行开发,而改造分支涉及大量代码移动,几乎每个分支的冲突都是大片大片的。这让merge成了团队最头疼的事。
我后来调整了合并策略:每周固定一次主干合并,冲突由改动方各自负责解决;改动方在回归测试时,除了验证自己的功能,还必须验证与组件化的兼容性。坚持两三个迭代后,团队适应了新结构,冲突量明显下降。
基础库的变更也要小心。凡是涉及base_lib的改动,都要在合并前通知所有组件负责人,确保影响面可控。
9. 拆完不是终点:组件化完成后的持续治理
9.1 防止组件间悄悄重新耦合
组件化改造完成并不代表一劳永逸,反而是一场"持续战"。团队在忙于新功能时,为了赶进度,非常容易图省事,直接在组件A里import组件B的类,或者把本该放在基础层的工具函数复制一份塞到组件里。
我一直采取两个措施防微杜渐:一是Checkstyle加规则,凡是import语句中包含其他组件的包名直接报错。这比Review彻底得多——人总有漏看的时候,机器不会。二是规范要求新增公共代码必须评审后放入base_lib,并且代码库中一旦发现"重复工具方法",立即合并。
9.2 依赖升级策略与回归风险
组件多了以后,依赖升级不再是"改个版本号"那么简单。比如升级网络库版本,影响的组件可能有七八个,每个组件都要回归验证。我自己的升级流程是:先在base_lib升级并验证,再逐个组件的build.gradle里改版本,每改一个组件的版本就编译一次,同时跑该组件的关键用例,全部通过后再提交。
这套流程稍慢,但胜在稳。遇到紧急安全补丁时,可以并行验证多个组件的依赖性,但至少也要保证核心链路不受影响。
9.3 团队协作方式、代码评审关注点与组件数量收敛
组件化改造对团队协作方式的影响,比很多人预想的要大。Review时除了常规代码质量,还要重点确认有没有跨组件耦合、有没有放错层级的依赖、资源命名是否规范、路由注册是否完整。
组件数量本身也需要持续关注。模块过多会让编译整体耗时上升,维护成本增加。我一般认为,常见规模的项目,业务组件加基础组件控制在10个左右是合理的。如果业务线扩张导致组件超过二十个,就要考虑是否需要合并相近的组件,或者引入微前端式的小程序容器来收敛。
9.4 改造完成后如何量化收益
组件化做完,总得向团队和老板讲清楚收益,否则大家只会觉得"改了个架构,功能没变化"。我梳理了几个可以量化的指标:
- 编译时间:单体编译从三分钟减到增量编译三十秒左右,独立模块的单独编译更短。
- 并行开发效率:两个团队同时改不同组件,互不阻塞,同一时间段内的合并冲突大幅下降。
- 代码耦合度:用自动化工具统计,业务组件间的直接引用数量从改造前的上百条降到了个位数。
- 稳定性:线上崩溃率在改造完成后的两个季度内明显下降,资源冲突和数据不一致类的问题没再出现。
这些数据记得在改造过程中就持续记录,等结束时拿出来跟改造前的基线对比,最有说服力。
10. 最后分享两点组件化改造的私人体会
改造过程中我跟团队反复讲的一句话是:组件化拆的不是代码,是人心的边界。每个人都要学会从"这是公共的"思维转变成"这是我职责范围内的"。代码层面的规则哪怕做得再完善,如果大家从根本上没有认同这个边界,腐化只是时间问题。所以我在每次周会上只花五分钟讲规则,剩下全是讲实际案例——这个组件因为耦合出了什么bug,那个模块因为规范省了多少时间。讲多了,团队成员自然会形成共同的价值观。
另外还有一个经常被忽略的小技巧:保留一份组件化改造前的完整代码分支。这个东西在改造过程中帮了我们大忙——遇到任何"以前是这样但现在怎么不行了"的问题,直接切回旧分支对比行为,定位速度能快好几倍。直到改造彻底稳定之后,我才删掉了这份备份分支。
组件化这条路,走一次就知道值不值。它不会让你的功能瞬间变多,但会让你的团队在功能越加越多的时候,依然保持快速和平稳。如果你也在犹豫要不要动手,我的建议是:选一个边界最清晰的模块先试点,跑通它。试点的收益,会比任何理论分析都有说服力。