组件化做到一定规模,主模块和业务模块之间的关系迟早会变得很别扭:主入口谁也不认识谁,但每个业务模块都想在应用启动的第一时间做点自己的事——注册路由表、初始化埋点、预加载配置、建立推送通道。于是最常见的一幕出现了:主模块的 Application 里排着一长串初始化调用,每加一个模块就要回来改一次主模块代码,主模块反而成了整个工程里耦合度最高、最不敢动的那一坨。我这次要聊的就是把这段逻辑彻底搬走的一种做法:利用 ContentProvider 在进程启动早期被系统自动创建这一特性,把「谁需要初始化」这件事从主模块手里交还给模块自己。这套思路本质上是把 AndroidManifest 的合并机制当成一张注册表来用,改造量不大,但里面关于创建时机、多进程归属、authorities 冲突的细节如果不提前弄清楚,线上是会被教训的。下面我按自己实际改造的过程,从问题的发现一路讲到上线前的验证清单。
1. 从一次冷启动超标的排查说起:模块初始化为什么都挤在 App 入口
1.1 先看症状:那几百毫秒到底花在了哪里
事情的起点很朴素:版本迭代到某个阶段,冷启动的埋点数据开始变得难看,中端机上首帧时间比上个版本多了三百多毫秒。按照常规的排查路径,先在启动链路上打了时间戳,结果发现真正耗在 Application.onCreate 里的时间就占了大头,而且这部分时间还在随着业务模块的增加线性增长。
把这个 Application 打开来看,问题一目了然:里面躺着二十几次初始化调用,从日志组件到图片库、从统计 SDK 到推送通道、从数据库建表到本地配置读取,全都老老实实排在那里。更尴尬的是,这些调用分散在十几个业务模块里,主模块压根不应该知道它们的存在——为了让这些代码能编译通过,主模块不得不反向依赖所有业务模块,组件化分层在 Application 这一个文件里被彻底击穿了。
我当时做了两件事。第一件是把 Application.onCreate 里面每一条初始化调用的耗时单独打点,列了张表;第二件是问自己一个问题:这些工作里,有多少是「必须在用户看到第一帧之前完成」的?答案是只有很少一部分。图片库的初始化完全可以延后到第一次加载图片时,统计 SDK 的初始化可以推迟到首帧之后,数据库建表甚至可以是异步的。真正需要在启动阶段同步做完的,其实只是那些「后续任何一处代码都可能立刻用到」的轻量注册动作。
这个判断很关键,它决定了后面的方案方向:我需要的不是把所有初始化都加速,而是把「模块有权在启动阶段插入一段自己的逻辑」这个能力,用一种不依赖主模块的方式提供出来。至于插进去的那段逻辑有多重,那是每个模块自己应该控制的。
1.2 反射扫描初始化器能跑,但成本藏在后面
在正式改造之前,我试过一条更常见的路子:定义一个初始化接口,各模块实现它,主模块在运行时扫描 Dex 找到所有实现类然后反射实例化调用。这个方案写起来很快,半天就能跑通,但用了两个版本之后我就放弃了,原因不在性能——现代设备的反射开销完全可控——而在它引入的隐性成本。
第一是编译期检查彻底失效。某个模块的初始化类改了包名、或者被混淆规则误伤,编译阶段一点提示都没有,非要等到运行时日志里出现一条「未找到初始化器」才被发现,而且这条日志往往在持续集成环境里被淹没。第二是扫描本身需要遍历大量类,虽然可以只扫描指定包名前缀,但一旦有模块的开发同学把初始化类放错了包,就会静默失效。第三是这类反射调用在开启代码压缩之后行为会变得难以预测,排查时要在混淆映射表里来回对照,非常耗神。
提示:任何依赖「运行时扫描 + 反射」的注册机制,都要提前想清楚失效时如何被发现。没有编译期约束的约定,最终一定会有人踩到。
所以我对这个方案的评价是:能用,适合中小规模、模块数量个位数的项目;一旦模块超过十几个、团队有多人并行开发,它的维护成本会超过它省下的那点工作量。
1.3 Manifest 合并机制其实早就给了一张现成的注册表
转机来自一个很朴素的观察:Android 在安装应用时会解析并合并所有模块的 AndroidManifest.xml,包括所有 AAR 依赖里的 Manifest。也就是说,如果我在某个业务模块自己的 Manifest 里声明一个组件,这个声明会自动出现在最终 APK 的清单里,而主模块完全不需要知道这件事。
这就相当于系统替我们维护了一张注册表:谁想注册,谁就在自己的清单文件里写一行。更重要的是,系统对这个清单里的某些组件有明确的创建时机承诺——ContentProvider 就是其中之一。它在 Application.onCreate 之前就会被系统实例化并调用 onCreate,这个时机正好覆盖了绝大多数模块初始化的需求。
沿着这条线想下去,方案就成型了:每个业务模块在自己的 Manifest 里声明一个专属的 ContentProvider,在这个 Provider 的 onCreate 里完成本模块的初始化。主模块只需要删掉那些初始化调用,什么都不用加。模块之间的编译依赖被彻底切断,主模块回归成一个纯粹的壳。
2. 弄清 ContentProvider 的创建时机,才敢把它当启动钩子
2.1 进程启动到 Application.onCreate 之间的完整时序
要用好一个机制,得先知道它发生在什么位置。应用进程从被拉起,到 Application.onCreate 执行,中间的顺序大致是这样的:
- 系统 fork 出应用进程,执行 ActivityThread 的入口方法,主线程 Looper 准备就绪。
- 进程向系统服务注册自身,系统服务返回该应用需要加载的 ApplicationInfo,其中包含合并后的 ProviderInfo 列表。
- 创建 Application 实例,调用其 attachBaseContext,此时 Context 已经可用,但应用逻辑尚未开始。
- 遍历 ProviderInfo 列表,逐个实例化 ContentProvider 并调用其 attachInfo,attachInfo 内部会回调 onCreate。
- 全部 Provider 处理完毕后,才调用 Application 的 onCreate。
这个顺序说明两件事。第一,Provider 的 onCreate 确实早于 Application.onCreate,把它当作启动钩子在时机上是成立的;第二,它在主线程上同步执行,你在这里做的每一件耗时操作,都会直接算进冷启动时间里。这两点必须同时记住,只记住前一点的人很容易在 Provider 里塞一堆重活,然后把启动时间搞得更糟。
顺带说一句,attachBaseContext 阶段虽然也早,但那时 Application 还没构造完,很多框架的初始化依赖 Application 实例本身,放在那里容易出问题。Provider 的时机点相对更安全,因为它拿到的 Context 已经是完整的应用上下文。
2.2 系统在安装 Provider 时到底做了什么
理解内部动作有助于判断成本。系统在处理 Provider 列表时,对每一个条目大致做了这么几件事:通过反射根据类名创建实例、调用 attachInfo 把 Context 和 ProviderInfo 传进去、在 attachInfo 里回调 onCreate、然后把已经创建好的 Provider 通过跨进程调用发布给系统服务,让其他进程后续可以通过 authorities 找到它。
注意最后一步——发布是跨进程的。也就是说,即使你的 Provider 里一行代码都没写,光是「存在」这件事本身,就要付出一次进程间通信的代价。这个代价单次看很小,但它是串行累加的:十个 Provider 就是十次,而且全都压在主线程的启动关键路径上。
我在一台中端机上做过粗略测量,每个空 Provider 带来的额外耗时大概在 1 到 3 毫秒之间波动,系统版本和设备差异都会影响这个数字,所以只能当作量级参考,不能当准确值用。但这个量级足以说明一个原则:Provider 的数量要严格控制。一个模块一个 Provider 是合理的,一个功能点一个 Provider 就是灾难。如果某个模块内部有十几个零散的初始化点,应该合并到该模块唯一的那一个 Provider 里,而不是各写各的。
2.3 为什么它天然是一个「被系统主动调用」的解耦点
我特别看重这个方案的一点是:调用方是系统,不是你的代码。这句话听起来像废话,但它决定了耦合的方向。
如果主模块主动去调用各模块的初始化方法,那么主模块必须知道各模块的存在,依赖方向是从上到下的,每加一个模块就要改一次上层。而 Provider 模式下,模块把自己的声明写进清单,系统在启动时按清单逐个创建,主模块完全置身事外。依赖方向变成了系统到模块,主模块和业务模块之间没有任何编译期联系。
这跟广播、服务发现这类机制的思想是一致的:把「谁调用谁」变成「谁注册谁被调用」。区别在于 Provider 的注册是编译期完成的、由构建工具合并的,不需要运行时查找,也没有注册中心可用不可用的风险。对于启动初始化这种必须百分之百执行的场景,这种确定性非常重要。
3. 着手改造:把初始化声明从主入口拆到各业务模块
3.1 模块自己的 Manifest 里该写什么
每个需要初始化的业务模块,在自己的 AndroidManifest.xml 里加一段声明。以某个叫 feature-account 的模块为例,写法大致如下:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.feature.account"> <application> <provider android:name=".AccountInitProvider" android:authorities="${applicationId}.init.account" android:exported="false" android:multiprocess="false" /> </application> </manifest>几个字段逐个说明。android:name是这个 Provider 的完整类名,用相对路径写法时需要确保 package 属性正确。android:authorities是全局唯一的标识,这是整个方案里最容易出问题的地方,后面单独讲。android:exported="false"表示不对外暴露,我们的目的只是要一个启动回调,不需要任何外部访问,必须关掉。android:multiprocess="false"是默认值,写出来是给后来的人看的,明确表达「只在主进程初始化」的意图。
这里有个细节值得强调:${applicationId}这个占位符是构建工具在打包时替换的,库模块里用它来保证不同应用集成时 authorities 不会冲突。如果你的模块是直接编进主 App 而不是作为独立 AAR 分发,也可以写死包名前缀,但用占位符是更稳妥的习惯。
另外从 Android 12 开始,声明了 intent-filter 的组件必须显式写出 exported 属性,否则安装会失败。我们的 Provider 通常不带 intent-filter,所以不会被这条规则卡住,但养成显式声明的习惯总没坏处,也避免了后来有人给 Provider 加了个 filter 却忘了补属性。
3.2 初始化 Provider 的代码骨架
Provider 本身的实现非常薄,薄到你会怀疑它是不是真的干了活。一个典型的写法是这样:
class AccountInitProvider : ContentProvider() { override fun onCreate(): Boolean { val appContext = context?.applicationContext ?: return false return try { AccountModule.init(appContext) true } catch (t: Throwable) { // 记录但不抛出,避免单个模块拖垮整个启动流程 InitTrace.recordFailure("account", t) false } } override fun query( uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String? ): Cursor? = null override fun getType(uri: Uri): String? = null override fun insert(uri: Uri, values: ContentValues?): Uri? = null override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0 override fun update( uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>? ): Int = 0 }除了 onCreate,其余五个方法是 ContentProvider 的抽象方法,必须实现,但在这个用法下全部返回空即可。有人会觉得这些空实现很难看,能不能抽个基类?可以,而且建议这么做——抽一个 BaseInitProvider 把这些方法统一实现掉,各模块只覆写一个onInit(context): Boolean抽象方法,这样每个模块的代码就只有几行,职责也更清晰。
onCreate的返回值在系统层面其实不太关心(返回 false 也不会阻止后续流程),但保留这个返回值对我们自己的基类有意义:可以用它来判断初始化是否成功,做统一的记录。
还有一点必须提醒:context属性拿到的是应用上下文,不是 Activity,不要试图强转,也不要在这里做任何需要界面的事情。此时界面还远没有创建。
3.3 用一个薄接口层管住模块间的直接依赖
Provider 解决了「主模块不需要知道模块」的问题,但还有一个方向没解决:模块 A 的初始化可能依赖模块 B 提供的能力。比如账号模块初始化时需要读取配置模块提供的某个配置项,如果直接 import 配置模块的类,两个业务模块之间就产生了编译依赖,组件化的隔离又被破坏了。
我的做法是单独开一个极薄的能力层模块,里面只放接口和少量数据类,不包含任何实现,所有业务模块都可以依赖它。提供方在自己的 Provider 初始化时把实现注册进去,使用方在使用时通过接口查询。这个模块的体量要严格控制,它是一个「契约层」,不是工具库,一旦开始往里面塞工具方法,它就会变成新的耦合中心。
这里的关键判断是:接口层的存在是为了让依赖方向单向收敛,而不是为了让所有模块都能互相调用。如果一个能力只有一处使用,用 Provider 的跨进程通道或者干脆合并模块可能更简单,不要为了架构的纯洁性硬造接口。
3.4 初始化顺序这件事,别指望清单里写死的顺序
有一个很多人会踩的坑:以为 Provider 的创建顺序就是清单里声明的顺序,或者按模块依赖顺序。实际上,合并后的清单顺序受构建工具、依赖树顺序、模块引入先后等多种因素影响,非常不可靠,不同构建环境甚至可能给出不同结果。
我吃过一次亏:某个模块的初始化依赖另一个模块先完成注册,本地跑得好好的,换一台机器打出来的包就崩了,原因是合并后的清单顺序变了。这种问题极难排查,因为代码逻辑本身没有任何问题。
正确的做法是不要在 Provider 之间建立顺序依赖。如果两个模块确实有先后要求,就在依赖方里显式判断前置条件是否已满足,没有满足就注册一个回调等待,或者把初始化拆成两段:轻量的注册在前,真正需要前置条件的部分延后到首次使用时。这种「声明式注册 + 懒执行」的模式比强行控制顺序健壮得多。
4. 多进程、authorities 与合并冲突:几个上线后才爆的问题
4.1 Provider 会在哪些进程里被创建
这一点必须讲清楚,因为它和很多人的直觉不符。ContentProvider 会被创建在哪个进程,取决于它的android:process声明与当前启动进程是否匹配,而不是「每个进程都创建一遍」。
默认不声明 process 时,Provider 归属应用的主进程。也就是说,如果你的 App 开了推送子进程、图片处理子进程,这些子进程启动时不会创建这个 Provider,只有主进程启动才会。对大多数初始化需求来说,这正是想要的效果,也是最安全的配置。
如果不小心写成了android:process=":remote",那这个 Provider 就只在那一个子进程里创建,主进程里根本不会执行,初始化逻辑等同于没写。反过来,如果写了android:multiprocess="true",那么每个使用它的进程都会各自实例化一份独立的实例,初始化代码会被执行多次,任何带状态或有副作用的初始化都会出问题。
我的建议是永远不写 process 属性,让它待在主进程;同时假设模块初始化只会在主进程执行一次——如果某个模块的逻辑需要在子进程也生效,那属于另一类问题,应该用独立的进程启动回调去处理,而不是靠 Provider 的多进程属性。
4.2 authorities 的命名规范和冲突处理
authorities 必须全局唯一,这是硬约束。如果你的 App 集成进来的两个库模块恰好写了相同的 authorities,构建阶段就会直接失败,报错信息通常指向清单合并冲突,但不会告诉你该改哪一个。
我采用的命名规则是「应用包名 + 固定中缀 + 模块名」的三段式,例如com.example.app.init.account、com.example.app.init.payment。中缀统一用init,一眼就能看出这是初始化用途的 Provider,跟真正做数据共享的 Provider 区分开。模块名使用模块目录名而不是类名,保持一一对应。
在库模块里写com.example.app.init.xxx显然不合适,因为库可能被不同包名的应用集成。这里必须用${applicationId}占位符,让构建工具在打包时替换成宿主应用的包名,这样同一个库被两个应用集成也不会冲突。
还有一个小细节:authorities 不允许有前导点,也不允许包含大写字母之外的特殊字符混乱使用,建议全部小写加点和下划线。虽然构建工具通常不校验得那么严,但系统在解析时是区分大小写的,混用大小写容易在排查时看花眼。
4.3 三方依赖里的 Provider 合并冲突怎么解
集成第三方 SDK 时,它们往往也会带上自己的 Provider(统计数据上报、设备标识获取之类的场景很常见)。这些 Provider 一旦和我们自己的命名冲突,或者两个 SDK 之间互相冲突,构建就会失败。
处理手段主要是清单合并工具的节点操作,在宿主模块的清单里用 tools 命名空间对冲突节点做替换或移除:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <application> <provider android:name="com.some.sdk.ThirdPartyProvider" android:authorities="${applicationId}.thirdparty" android:exported="false" tools:replace="android:authorities" /> </application> </manifest>tools:replace用于覆盖属性值,tools:node="remove"用于直接移除某个节点。需要注意的是,移除第三方 Provider 有风险——有些 SDK 依赖自己的 Provider 完成初始化,移除之后功能会静默失效,而且往往在测试阶段不容易发现。所以除非你确认该 Provider 只做初始化、并且你有替代的初始化方式,否则优先用 rename 而不是 remove。
我一般会在改造前先跑一次完整的依赖分析,把所有模块和三方库声明过的 authorities 列成一张表,人工过一遍。这个动作看起来笨,但比在构建失败的报错里猜要快得多。
4.4 onCreate 里抛异常意味着什么
这是最需要警惕的一点。Provider 的 onCreate 在主线程执行,如果这里抛出未捕获异常,进程启动会直接失败,表现为应用一启动就闪退,日志里能看到「Unable to get provider」这类信息。也就是说,一个边缘模块的初始化 bug,可以让整个应用起不来。
这和放在 Application.onCreate 里的效果类似,但因为现在初始化分散在各模块,出问题的概率反而变高了——你无法在代码审查时一眼看到所有初始化逻辑。所以我坚持在每个 Provider 的 onCreate 外面包一层兜底,捕获所有异常并记录,返回 false,让启动流程继续走下去。
兜底之后还要配套做两件事:一是把失败信息落盘,保证下次启动时能读到,不至于用户反馈闪退而你什么日志都拿不到;二是在灰度阶段把失败事件上报,观察是否有某个模块的初始化成功率异常。这两件事不做,兜底就等于把问题藏起来了。
5. 不止是初始化:把 Provider 当成模块间的轻量通道
5.1 什么时候需要一个「不依赖对方模块」的调用方式
Provider 的常规用法是数据共享,但它还有一个被低估的能力:call方法。这个方法允许调用方传入方法名和参数包,由 Provider 端解析执行并返回结果包。对于组件化项目来说,这意味着模块之间可以建立一条不需要编译依赖的调用通道。
举个真实场景:某个运营活动模块需要判断用户是否已登录,但登录状态由账号模块维护。如果直接依赖,活动模块就绑死了账号模块,后续想把这个活动单独拆出去或者做成动态加载就不行了。用 Provider 通道的话,活动模块只需要知道一个约定的方法名,通过call发起请求,账号模块在自己的 Provider 里实现这个方法并返回结果,两边完全没有编译期联系。
不过我要说清楚,这不是万能方案,它的适用场景很窄:调用频率低、数据结构简单、允许同步返回。高频调用绝对不要走这条路,跨进程通信的开销远大于直接的方法调用。
5.2 call 的使用边界与几个必须注意的点
使用call有几个硬性约束。参数和返回值都必须是 Bundle,能放的数据类型受 Bundle 限制,复杂对象要么实现序列化,要么转成基本类型传输,序列化成本要提前评估。调用是同步的,如果 Provider 端在另一个进程,调用线程会被阻塞,绝对不能放在主线程调用跨进程的方法。
还有一个隐蔽的问题:如果目标 Provider 所在的进程没有启动,一次call会先把那个进程拉起来,拉起过程本身可能需要几百毫秒甚至更多。所以这类调用必须放在子线程,并且要做好超时处理,不能假设对方一定在线。
我个人的判断标准是:只有当一个能力确实需要跨模块、且调用次数在整个应用生命周期内是个位数时,才考虑用 Provider 通道。其他情况下,用接口层加依赖注入是更省心的选择。架构手段的选择应该服务于实际复杂度,不能因为机制存在就用它。
6. 和官方启动初始化库、路由框架自动初始化放在一起比
6.1 三者的机制其实是同一个
市面上常见的启动初始化方案,机制上大多同源:都是利用清单合并把注册信息带到最终产物里,然后在进程启动早期由系统或框架统一调度。官方后来推出的启动项初始化库,核心同样是声明一个 Provider 来完成入口调度;很多路由框架的自动初始化也是一样的路数。
真正的差别不在机制,而在「管理能力」。官方的那套方案额外提供了依赖关系声明和拓扑排序,可以把初始化项之间的前置条件显式写出来,由框架保证执行顺序。这一点是手写 Provider 方案做不到的——前面说过,Provider 之间的顺序不可控。
所以选型的核心问题是:你的初始化项之间有没有明确的顺序依赖?如果有且关系复杂,官方方案的排序能力就很有价值;如果各模块初始化彼此独立,手写 Provider 足够,而且更轻。
6.2 什么规模的项目适合自己动手写
我给自己的判断标准是三条,全部满足就自己写:模块数量在二十个以内、初始化项之间无明显顺序依赖、团队对清单合并机制有基本认知。三条里有一条不满足,就应该上官方方案或者做更明确的框架约束。
反过来,如果项目已经引入了官方的启动初始化库,就没必要再手写一套 Provider 方案,两套机制并存只会让启动链路更难追踪。改造的第一原则是减少机制种类,不是增加。
还有一个折中做法值得提:手写 Provider 只负责「把模块的初始化入口调起来」,模块内部的复杂初始化再交给启动库去排序。这样两层各司其职,既保住了模块自治,又拿到了顺序控制能力。我在一个中等规模的项目里用过这个组合,效果不错,代价是多了一层间接调用,排查时要多看一层日志。
6.3 我最后落地的分层策略
最终的形态大致是这样分层的:最底层是能力接口层,只有接口和数据结构;中间是各业务模块,每个模块自带一个初始化 Provider,在 onCreate 里完成本模块的轻量注册;最上层是壳工程,只负责组装,不写任何业务初始化代码。
壳工程的 Application 里只剩下真正全局的东西:崩溃捕获、全局配置的加载。凡是能归属到某个模块的,一律下沉。这条线划清楚之后,加新模块的流程变成了「建模块、写 Provider、加依赖」,主模块一次都不用改,改动范围的收敛是最实在的收益。
7. 上线前的验证清单与故障对照
7.1 怎么确认 Provider 真的被执行了
改造完最容易出现的状况是「以为执行了,其实没有」。验证方法我一般用三个层次。
第一层看日志。在每个 Provider 的 onCreate 里打一条带模块名的日志,冷启动后在日志里搜索,确认每个模块都出现了。注意要看冷启动的完整日志,不要从中间截取,否则可能漏掉。
第二层看合并后的清单。构建产物里的 AndroidManifest.xml 是合并后的最终版本,可以直接打开检查每个 Provider 是否都在、authorities 是否正确替换成了宿主包名。这一步能发现库模块声明没生效、或者被合并工具意外移除的问题。
第三层用系统命令反查。设备连上之后可以通过系统提供的组件查询命令,列出指定包名下所有已注册的 Provider,跟预期清单比对。这层验证最接近运行时真实状态,建议作为发布前的固定动作。
7.2 冷启动数据怎么量才准
量的方式不对,结论就会错。我的经验是:不要只看一次启动,也不要只看平均值。冷启动时间受设备状态影响极大,刚刷完机、后台干净的情况下测出来的数字没有参考价值。
比较靠谱的做法是固定设备、固定场景,连续冷启动二十次以上,去掉最前面的几次预热,看剩下的分布。同时对比改造前后的版本,用同一台设备、同一套测试流程,才有可比性。
另外要区分「首次安装后启动」和「非首次启动」,前者会触发更多的初始化工作,数据通常更差,但这才是真实用户第一次打开应用的体验,必须单独统计。
7.3 故障现象与原因对照
改造过程中遇到过的几个典型问题,整理成表方便对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 应用启动直接闪退,日志提示无法获取 Provider | onCreate 内抛出未捕获异常 | 检查初始化逻辑,补兜底 |
| 某模块初始化完全没执行 | 清单里没声明,或模块未被真正依赖 | 检查合并后清单是否包含该节点 |
| 构建失败,提示清单合并冲突 | 两个模块 authorities 重名 | 全量梳理 authorities 命名 |
| 子进程里初始化重复执行 | 误写了 multiprocess 或 process 属性 | 移除 process 相关声明 |
| 冷启动反而变慢 | Provider 数量过多,或 onCreate 里有重活 | 合并 Provider,把重活延后 |
| 部分设备上初始化时序异常 | 依赖了 Provider 之间的创建顺序 | 改为条件判断或懒执行 |
这张表里的每一条我都至少遇到过一次,其中「冷启动反而变慢」是最有欺骗性的——改造的初衷是优化启动,结果因为图省事在每个 Provider 里塞了数据库初始化和网络预请求,最后比改造前还慢。记住那句话:Provider 只是提供了一个时机,它不负责让你的初始化变快。真正让启动变快的,永远是把不必要的工作从关键路径上挪走。
我在几个项目里反复用这套方案,最大的体会是它真正的价值不在性能,而在边界的清晰。当每个模块都能自己决定「我在什么时候初始化什么」,主模块就退化成了一个纯粹的容器,这才是组件化想要的状态。至于性能收益,它是把初始化职责下放之后自然带来的副产品,不该被当成主要目标去追。