大型 App 架构演进与模块化、组件化实践(3):组件化:模块化的延伸与独立运行
大型 App 架构演进与模块化、组件化实践(3):组件化:模块化的延伸与独立运行
本文是「大型 App 架构演进与模块化、组件化实践」系列的第 3 篇,共 3 篇。在上一篇中,我们探讨了「模块化策略:大卸八块的艺术」的相关内容。
四、组件化:模块化的延伸与独立运行
组件化可以看作是模块化的一种高级形态或目标。它不仅仅是将代码拆分到不同模块,更强调将这些模块视为可独立开发、测试、甚至运行的“组件”。
-
核心理念:每个组件(通常是一个 Feature 模块)都应该具备独立运行和被组装的能力。
-
关键实践
– API/Implementation 分离:组件对外暴露的功能严格通过接口定义(通常放在独立的:api模块中),实现细节封装在内部(:impl模块)。依赖方只依赖:api模块。
– 面向接口编程:组件间的交互尽可能通过接口进行。
– 依赖注入:组件内部的依赖(如 Repository、Context)通过 DI 容器从外部注入,而不是在内部创建。
– 独立运行能力(Debug App):为每个组件创建一个小型的 application 模块(Debug App),它只依赖该组件及其必要的 Core 模块。这样可以单独编译、运行和测试该组件,极大地提高开发和调试效率。
– 生命周期管理:需要考虑组件自身的生命周期与宿主环境(如 Application)生命周期的协调。
五、路由与通信
模块化后,原本简单的Activity跳转或方法调用变得不再直接。
-
挑战:Feature A 不能直接引用 Feature B 中的 Activity 类或 Service 类。
-
解决方案——路由框架(Router Frameworks)
– 代表:ARouter(Alibaba)、WMRouter(Meituan Dianping)、TheRouter(货拉拉)、CC(JD)等。虽然具体实现有差异,但核心思想类似。
– 工作原理(通用模式)- 注册:在需要被外部访问的组件(Activity、Fragment、Service 实现类,甚至某个方法)上添加注解(如
@Route、@Autowired)。 - 编译期处理:Annotation Processor 扫描注解,生成映射关系代码或配置文件,记录路径(如”/user/profile”)与目标组件类(
com.example.feature.profile.UserProfileActivity)的对应关系。 - 运行时调用:业务代码通过 Router 框架提供的 API,使用路径字符串发起导航请求(如
Router.getInstance().build("/user/profile").withInt("userId", 123).navigation())。 - 查找与执行:Router 框架根据路径查找编译期生成的映射信息,找到目标类,创建 Intent(或直接调用方法),完成跳转或执行。
– 核心功能
– 页面路由:Activity、Fragment 跳转。
– 服务调用:获取其他模块提供的服务接口实现(类似 ServiceLoader 但更强大)。
– 参数传递与注入:通过withXxx()传递参数,通过@Autowired注解自动注入到目标页面。
– 拦截器(Interceptor):在路由执行前后添加逻辑,如登录状态检查、权限申请、埋点等。
– 降级策略:路由失败时的处理逻辑。
– 思考与选型
– 选择合适的框架:考虑其维护状态、社区活跃度、性能(反射 vs. 生成代码)、易用性、功能完备性。
– 理解内部机制:了解其是基于编译期代码生成还是运行时反射/类加载,这对性能和稳定性有影响。
– 路径管理:如何规范和管理大量的路由路径字符串,避免冲突和硬编码。
– 类型安全:路径参数传递通常是基于 Bundle 或基本类型,缺乏编译期类型安全检查。
– 调试:路由问题有时难以调试,需要熟悉框架的日志和错误处理。
– 与 Instant Run/Apply Changes 的兼容性:部分基于 APT 的框架可能与 IDE 的热/冷更新功能有兼容性问题。
- 注册:在需要被外部访问的组件(Activity、Fragment、Service 实现类,甚至某个方法)上添加注解(如
-
其他通信方式
– 接口下沉 + DI:将 Feature B 需要暴露给 Feature A 的功能定义成接口,放在两者都依赖的 Core 或 API 模块中。Feature B 实现该接口,并通过 DI 框架(如 Hilt 的@Binds@IntoSet)将其实现绑定到一个集合中。Feature A 注入这个接口集合,查找并调用。更类型安全,但可能引入更多接口和 DI 配置。
– 广播(BroadcastReceiver):适用于一对多的事件通知,但耦合松散,数据传递能力弱,难以追踪,不推荐作为常规的模块间通信或导航手段。
– EventBus(如 EventBus、RxBus):强烈不推荐在大型模块化项目中使用。全局事件总线使得数据流向极其混乱,难以追踪和调试,是许多难以排查的 bug 的根源。
六、模块化下的依赖注入(DI)
DI是实现模块解耦的关键。
-
挑战:如何在模块不知晓具体实现的情况下,获取其所需的依赖对象(如 Repository、DataSource、Analytics Logger)?如何管理跨模块的依赖图?
-
DI 框架的应用
– Dagger / Hilt- 跨模块依赖:通过 Dagger 的Component Dependencies机制。下游模块的 Component 可以依赖上游模块 Component 暴露的接口,从而获取上游提供的对象。
- 聚合贡献(
@Binds@IntoSet/@IntoMap):不同模块可以向同一个全局或父 Component 贡献接口的实现(放入一个 Set 或 Map 中),供其他模块注入和使用。常用于插件化扩展点、路由表构建等。 - Hilt 简化:Hilt 通过预定义的 Component 和
@InstallIn注解简化了模块化下的 DI 设置,但仍需理解其 Component 层级和注入机制。@EntryPoint可用于从非 Hilt 管理类中获取依赖。 - 实践要点:设计合理的 Component 层级和 Scope;管理好 Component 依赖关系;利用好 Multi-binding;关注 KAPT/KSP 对编译速度的影响。
- Koin
- 模块化支持:Koin 通过其 Module DSL 提供了加载和卸载模块的能力(
loadKoinModules、unloadKoinModules)。不同 Gradle 模块可以定义自己的 Koin Module,在 Application 启动时统一加载。 - 动态性:可以在运行时动态加载/卸载 Koin 模块,更灵活。
- 实践考量:运行时解析的开销;缺乏编译期安全检查;跨模块依赖关系不如 Dagger 直观;大型项目中的 Module 管理。
七、Gradle 依赖管理
模块数量增多后,依赖管理变得复杂。
-
api vs implementation
– implementation(默认):依赖只在当前模块编译和运行时可见,不会传递给依赖当前模块的其他模块。优点:隐藏内部实现细节,减少不必要的传递依赖,提高编译速度(下游模块无需因上游 implementation 依赖的改变而重新编译)。
– api:依赖会传递给依赖当前模块的其他模块。使用场景:当一个模块的公开 API(类、方法、接口)直接使用了另一个模块的类型时,必须使用 api 暴露该类型。应尽可能少用 api。 -
统一版本管理
– Platform / BOM(Bill of Materials):推荐方式。引入一个 BOM 依赖,它定义了一组相关库的版本,无需在每个库后面单独指定版本。如platform('androidx.compose:compose-bom:2024.03.00')。
– Version Catalogs(libs.versions.toml):Gradle 7.0+ 推荐。在项目根目录的gradle/libs.versions.toml文件中集中定义所有库的坐标和版本别名。在build.gradle(.kts)中使用别名引用依赖(如libs.androidx.core.ktx)。优点:极佳的可维护性、代码补全支持、易于共享。
– ext 块(传统方式):在根build.gradle中定义版本号变量。易用但不如 Version Catalog 规范。 -
Gradle Convention Plugins(约定插件)
– 目的:将通用的构建逻辑(如应用特定插件com.android.library,配置compileSdk、minSdk、testOptions,添加公共依赖如 Kotlin stdlib、JUnit)封装到自定义的 Gradle 插件中。
– 优点:避免在每个模块的build.gradle中重复配置;保证构建配置的一致性;便于统一修改。是大型多模块项目的必备实践。
八、多模块项目的编译性能优化
这是模块化的核心收益之一,但也需要持续关注和优化。
-
利用 Gradle 特性
– Configuration Cache(--configuration-cache):缓存配置阶段的结果,极大加速后续构建的配置过程。
– Build Cache(--build-cache):缓存任务的输出,避免重复执行未改变的任务。使用本地缓存,并考虑搭建远程构建缓存供团队共享。
– Parallel Execution(org.gradle.parallel=true):允许多个任务并行执行。 -
优化模块本身
– 增量编译:确保代码和资源修改能触发增量编译。
– 增量注解处理:使用支持增量的 Annotation Processor(查看其文档)。优先使用 KSP(Kotlin Symbol Processing)替代 KAPT,KSP 通常更快且支持增量更佳。
– 减少 api 依赖:如前述,implementation 有助于避免不必要的模块重编译。
– 使用纯 Java/Kotlin 模块:对于不依赖 Android 框架的代码,使用java-library或kotlin("jvm")模块,编译速度更快。 -
构建配置优化
– 按需配置(Configuration on demand):(Gradle 已默认或不再推荐显式开启)只配置当前任务需要的项目。
– 避免在配置阶段执行耗时操作:build.gradle中的逻辑应尽可能简单。
– 升级 Gradle 和 AGP:新版本通常包含性能改进。 -
分析与监控
– Gradle Build Scans(--scan):上传构建信息到 Gradle Enterprise(或本地 Docker 镜像),提供详细的构建分析报告,包括任务耗时、依赖解析、瓶颈分析等。强烈推荐使用!
– Gradle Profiler(--profile):生成本地 HTML 报告,分析任务执行时间。
– 监控 CI 构建时间:跟踪构建时间变化趋势,及时发现并解决性能劣化问题。
九、模块化架构下的测试策略
模块化为不同层级的测试提供了更好的隔离性。
-
单元测试(Unit Tests)
– 范围:单个类或方法。
– 优势:在模块化下更容易实现。特别是对于纯 Java/Kotlin 的 Domain 层和 Data 层模块,可以完全在 JVM 上快速执行。Presentation 层的 ViewModel 也可以通过 Mock 依赖进行测试。
– 实践:使用 JUnit、Mockito/MockK。 -
集成测试(Integration Tests)
– 范围:测试同一模块内或跨模块(通过接口)的多个组件的交互。例如,测试 ViewModel -> Use Case -> Repository 的完整流程。
– 环境:可以在 JVM 上运行(使用 Fake/Mock 实现外部依赖,如数据库、网络),也可以在 Android 设备/模拟器上运行(如果需要 Android 框架 API)。
– 实践:使用 JUnit、Mockito/MockK、Robolectric(JVM 上模拟 Android 环境)、Espresso(设备上)。 -
UI 测试 / 端到端测试(End-to-End Tests)
– 范围:模拟用户操作,测试完整的用户流程,通常涉及 UI 交互。
– 优势(模块化下)- 组件级 UI 测试:可以为每个 Feature 模块创建 Debug App,只包含该模块的 UI 和必要的依赖(用 Fake/Mock 替代其他 Feature),在该独立环境中运行 Espresso 测试。优点:运行速度快、稳定性高、独立性强。
- 完整 App E2E 测试:在最终组装好的 App 上运行,覆盖跨多个 Feature 的流程。数量应相对较少,覆盖核心路径即可,因为它们运行慢且不稳定。
- 实践:使用 Espresso、UI Automator。通过 DI 或特定 Test Runner 注入 Fake/Mock 依赖(如 MockWebServer 模拟网络请求)。
十、团队与流程适配
技术架构的演进必须伴随着团队结构和开发流程的调整。
-
代码所有权(Code Ownership):按模块(尤其是 Feature 模块)划分明确的团队或个人所有权,提高责任感和维护效率。
-
代码规范与审查(Code Style & Review):统一的代码规范和严格的 Code Review 对于保证跨模块代码质量至关重要。关注模块 API 的设计和演进。
-
分支策略(Branching Strategy):模块化使得基于特性的分支(Feature Branching)更加可行和独立。无论是 Gitflow 还是 Trunk-Based Development,都需要适应模块化的结构。
-
CI/CD 优化:持续集成/持续部署流水线可以根据代码变更范围进行优化,例如只构建和测试受影响的模块及其依赖项,减少 CI 运行时间。
-
API 契约与沟通:模块间的接口(API 模块、路由路径、数据契约)成为关键的「契约」。API 的设计、评审、版本管理和变更通知需要建立清晰的流程和沟通机制。
-
技术债务管理:模块化本身可能引入新的技术债务(如路由框架的维护、DI 配置的复杂度)。需要持续关注和偿还。
十一、结论:架构演进,永无止境
对于大型 Android 应用而言,从单体走向模块化、组件化是应对规模化挑战、提升工程效率和保障应用质量的必由之路。这并非一蹴而就的技术替换,而是一个涉及架构模式思辨、模块化策略权衡、技术栈(路由、DI、构建系统)精通、以及团队流程协同的系统性工程演进过程。
技术专家/Leader 在此过程中扮演着关键的设计者、决策者和推动者角色。需要深刻理解各种方案的优劣和适用场景,在编译速度、运行时性能、代码隔离性、开发效率、类型安全等多个维度间做出明智的权衡;需要掌握从 Gradle 优化到高级 DI 模式,再到跨模块通信的各种底层技术细节;还需要具备推动团队接受变革、建立规范、持续改进的领导力。
模块化/组件化本身并非终点,它带来的新问题(如通信复杂性、依赖管理挑战)也需要持续解决。架构演进是一个永无止境的循环,目标始终是构建一个更能适应未来变化、更能支撑业务发展、更能让开发者愉悦工作的、高质量的软件系统。
「大型 App 架构演进与模块化、组件化实践」系列目录
- 引言:应对规模化的必然演进
- 模块化策略:大卸八块的艺术
- 组件化:模块化的延伸与独立运行(本文)
夜雨聆风
