模块化按需加载并不是一个新概念,但它在国内大型Android项目中的热度始终居高不下。原因很简单:当业务膨胀到几十个模块、代码量突破百万行之后,全量初始化带来的启动卡顿、编译耗时和内存压力会同时爆发。按需加载的目标就是让每个模块只在真正被使用的那一刻才完成加载与初始化,其他时间保持沉默。这篇文章会从模块划分、通信解耦、按需初始化和动态加载四个层面,完整拆解一套可落地的架构设计。

一、模块划分与依赖治理:按需加载的地基
按需加载的前提是模块边界足够清晰。如果模块之间你中有我、我中有你,那么任何懒加载策略都会被隐式依赖击穿。划分模块时建议遵循三个原则:按业务域划分而不是按技术层划分、单一职责、依赖单向。比如电商类App可以拆出商品、购物车、订单、支付、客服等业务模块,再叠加一层基础能力模块(网络、存储、图片、日志),形成稳定的分层结构。
分层之后必须治理依赖方向。推荐采用经典的四层结构:宿主壳工程在最上层负责组装,业务模块层彼此不可见,公共业务层提供账号、分享等跨业务能力,最底层是纯技术的基础组件层。所有依赖只能自上而下,禁止同层互相引用。可以通过自定义的Gradle插件在编译期做依赖检查,一旦发现业务模块直接依赖另一个业务模块,直接让构建失败,把规则固化下来。
// 依赖检查的核心思路:遍历当前模块的 dependencies,发现非法依赖直接抛异常
project.configurations.each { config ->
config.dependencies.each { dep ->
if (isBusinessModule(dep) && currentModule != dep.name) {
throw new GradleException("业务模块间禁止直接依赖: ${project.name} -> ${dep.name}")
}
}
}
另一个容易被忽视的点是循环依赖检测。多人协作开发时,循环依赖往往是在不经意间引入的,比如A模块引用了B的工具类,B模块后来又需要A的常量定义。除了编译期检查,日常code review中也应该把新增依赖当作重点审查项。
二、模块通信与解耦:路由加SPI的双轨方案
模块拆开后第一个问题就是怎么互相调用。业界主流做法是路由加服务接口。路由负责页面跳转这类松耦合场景,比如从首页跳到商品详情页,只需要一个URI字符串,不持有目标类的引用;服务接口则用SPI(ServiceLoader)或类似机制解决强类型调用,比如订单模块需要调用登录态,就面向一个IUserService接口编程,运行时通过注册中心拿到实现。
路由框架可以基于ARouter或自研方案,但无论选哪个,都建议在接口层再包一层防腐层,避免业务代码直接拼接URI字符串,否则路由路径一改就是全局灾难。SPI方式在Android上需要注意一个问题:ServiceLoader默认会全量扫描并实例化实现类,这恰恰违背按需加载的初衷,所以通常要改造为带懒加载语义的注册中心。
public class ServiceCenter {
private static final Map<Class<?>, Provider<?>> CACHE = new ConcurrentHashMap<>();
// 用 Provider 延迟创建实例,只有真正 getService 时才反射初始化
public static <T> void register(Class<T> clazz, Provider<T> provider) {
CACHE.put(clazz, provider);
}
public static <T> T getService(Class<T> clazz) {
Provider<?> p = CACHE.get(clazz);
return p == null ? null : (T) p.get();
}
}
// 登录模块对外只暴露接口,宿主在模块加载完成后注册实现
ServiceCenter.register(IUserService.class, UserServiceImpl::new);
事件总线是第三种通信手段,适合一对多的广播场景,比如登录态变更后通知所有关心它的模块。但要严格控制使用范围,滥用事件总线会让调用链路变得完全不可追溯,排查问题时无从下手。一个实用建议是:能用服务接口就用接口,事件总线只留给真正的状态广播。
三、按需初始化:启动优化的关键战场
模块化之后最大的收益点在启动阶段。传统做法是在Application的onCreate里把所有SDK全部初始化,导致冷启动动辄两三秒。改造思路是把初始化任务建模成一个个Task,声明依赖关系,由启动框架做拓扑排序,再区分主线程串行、主线程空闲执行、子线程并行三种执行时机。
判断某个初始化能不能延迟,标准只有一条:它在首帧渲染前是否必须完成。像网络库、崩溃上报这类通常不能延,但推送、地图、支付SDK完全可以等到首次使用时再初始化。更极致的做法是结合页面路由,当用户真的跳转到支付页面时,才触发支付模块的初始化,这就是模块级别的按需加载。
public class ModuleLoader {
private static final Set<String> LOADED = Collections.newSetFromMap(
new ConcurrentHashMap<());
public static void ensureLoaded(String moduleName, Runnable onReady) {
if (LOADED.contains(moduleName)) {
onReady.run();
return;
}
// 串行执行:初始化SDK -> 注册服务 -> 通知完成
ModuleTasks.get(moduleName).execute(() -> {
LOADED.add(moduleName);
new Handler(Looper.getMainLooper()).post(onReady);
});
}
}
// 路由拦截器中挂载:跳转支付页前先确保支付模块就绪
@Interceptor(path = "/pay/checkout", priority = 1)
public class PayModuleInterceptor implements IInterceptor {
@Override
public void process(Postcard card, InterceptorCallback cb) {
ModuleLoader.ensureLoaded("pay", cb::onContinue);
}
}
延迟初始化要防一个坑:SDK初始化是异步的,但业务调用是同步的。比如懒加载的广告SDK,第一次调用展示接口时可能还没初始化完成。解决方案是给每个模块维护一个就绪状态,未就绪时把调用挂起或者降级,就绪后再回放,上面代码里的ensureLoaded就是这个模式的最简实现。
四、更进一步:动态化与插件化的边界
如果目标只是减少启动耗时,前面的方案已经够用。但如果还想压缩安装包体积、实现功能动态下发,就要进入动态化领域。常见的路径有三条:其一是使用官方的Dynamic Feature和Play Feature Delivery,国内市场基本不可行;其二是各类插件化框架,把模块编译成独立APK运行时加载;其三是自建DSL加脚本容器做轻量动态化,比如把部分UI逻辑用JSON或JS描述下发。
插件化在国内技术演进中积累了大量方案,核心难点始终是绕不开的:资源访问需要处理资源ID冲突,通常用修改aapt或运行时改资源段来解;四大组件的注册问题需要hook系统的Instrumentation或者用预埋桩Activity的方式。这些方案在Android 9到14的版本迭代中维护成本越来越高,官方对非公开API的限制也越来越严格。因此给一个务实建议:除非有强烈的动态发布诉求,否则优先选择编译期组件化加运行时按需初始化,稳定性风险小得多。
落地节奏上建议分三步走:先做模块拆分和依赖治理,这一步不改运行时行为,风险最低;再接入路由和服务中心,完成代码层面的解耦;最后逐步把初始化任务改造成按需触发,配合启动监控观察收益。每一步都应该有明确的度量指标,比如冷启动首帧时间、模块加载耗时分布,用数据验证每一项优化的效果,而不是凭感觉推进。模块化不是目的,可维护、可扩展、启动快才是。
Android模块化按需加载组件化架构修改时间:2026-09-13 07:28:34