1. 项目概述:为什么模块化项目里资源重复会让人半夜改代码?
“Android 模块化项目不同模块防止资源重复解决方法”——这标题看着像一句技术文档的目录条目,但对真正做过中大型 Android 工程拆分的开发者来说,它背后藏着的是连续三天没合眼的 Gradle 报错、是提测前两小时发现图标被覆盖、是 QA 甩来的一张截图:“首页按钮点不动,点了跳转到登录页的头像”,而你翻了三遍代码才意识到——两个模块里都定义了R.id.avatar,且 ID 值在编译期被合并成了同一个整数。
我带过三个超过 20 个业务模块的 App 项目,从早期用android.library粗粒度拆分,到后来按功能域(account、profile、feed、search)+ 基础能力(network、ui-kit、logger)双维度切分,资源冲突问题不是“会不会出现”,而是“什么时候爆发、爆得多猛”。它不报编译错误,却在运行时埋下雷:布局 inflate 失败、ViewBinding 找不到控件、字符串翻译错位、甚至主题色在不同模块里渲染出两种蓝色。最要命的是,这种问题往往只在特定构建变体(比如 release + proguard)、特定设备(低内存机型触发资源加载顺序变化)或特定依赖版本(support → androidx 迁移过渡期)下才复现,查起来像在迷雾里找针。
核心关键词就三个:Android、模块化、资源重复。它们组合在一起,指向一个非常具体、高频、痛苦但又常被低估的工程实践痛点——不是“怎么写功能”,而是“怎么让几十个人写的代码,在同一个 APK 里和平共处”。它不涉及算法复杂度,却直接决定团队协作效率;它不考验单点技术深度,却暴露整个工程基建的成熟度。适合正在做模块化落地的 Android 中级开发者、技术负责人,以及刚从单体工程转向组件化开发、正被Duplicate resources警告刷屏的新手。如果你的项目里已经有:feature:home、:feature:profile、:core:ui这类模块名,或者build.gradle里开始出现api project(':lib:common'),那这篇就是为你写的实战笔记,不是理论科普。
2. 资源冲突的本质与模块化设计的底层逻辑
2.1 资源ID不是“变量”,而是编译期生成的“常量索引”
很多开发者第一次遇到资源冲突,第一反应是“我把两个模块里的ic_launcher.png改成不同名字不就行了?”——这思路没错,但只解决了表象。真正的问题根源,在于 Android 资源系统的设计哲学:所有资源(drawable、string、layout、id 等)在编译阶段,都会被 aapt2 工具统一扫描、去重、编号,最终生成一个全局唯一的R.java(或 R.txt)文件,其中每个资源对应一个 int 常量。
举个具体例子:
- 模块 A 定义了
res/values/strings.xml:<string name="btn_login">登录</string> <string name="title_home">首页</string> - 模块 B 也定义了同名字符串:
<string name="btn_login">立即登录</string> <string name="title_search">搜索</string>
aapt2 在构建时会把所有模块的资源合并进一个资源池。当它发现btn_login在两个地方都存在,就会触发“资源合并策略”:默认采用“后覆盖前”规则(即最后被扫描到的模块的值生效)。结果是,无论你在模块 A 还是模块 B 的代码里写getString(R.string.btn_login),拿到的永远是模块 B 里定义的“立即登录”。更隐蔽的是id类型资源——它不产生字符串值,只生成一个整数 ID,比如R.id.avatar可能被模块 A 和模块 B 同时声明,aapt2 会为它分配同一个整数值(如0x7f080001),但两个模块各自生成的R.java文件里,这个 ID 对应的 XML 元素却完全不同。运行时一旦findViewById(R.id.avatar)被调用,系统按 ID 去查找 View,找到的可能是模块 A 的头像 ImageView,也可能是模块 B 的头像 TextView,完全取决于资源加载顺序和缓存状态。
提示:这不是 Bug,是 Android 构建系统的既定行为。它的设计初衷是支持“主题覆盖”(比如 app module 覆盖 library module 的默认颜色),但模块化开发把它变成了双刃剑。
2.2 模块化不是“物理隔离”,而是“逻辑解耦+构建协同”
很多人误以为“把代码扔进不同 module 目录,再用implementation project(':moduleX')引入,就算模块化了”。这是典型误区。真正的模块化,必须同时满足三个条件:
- 编译期隔离:模块间不能直接访问彼此的私有资源(如
R.drawable.xxx),只能通过约定的 API(如ImageLoader.load(context, resId)); - 运行时解耦:模块间通信走接口或事件总线,避免硬编码依赖;
- 构建期可控:资源合并策略可配置、可审计、可追溯。
而资源重复问题,恰恰卡在第一个条件上——模块的资源在编译期就被强制合并了,根本没有“隔离”这回事。你无法阻止 aapt2 扫描:core:ui模块下的res/drawable/ic_arrow_back.xml,也无法阻止它把这个资源 ID 分配给:feature:settings模块里同名的箭头图标。所以,解决思路不能停留在“别起重名”,而必须深入到构建流程的控制层:要么让资源“不参与合并”,要么让合并“有据可依”,要么让冲突“提前暴露”。
2.3 为什么“简单加前缀”不够用?——来自真实项目的三重反例
我见过太多团队用“给所有资源名加模块前缀”来规避冲突,比如把btn_login改成home_btn_login、profile_btn_login。短期有效,长期灾难。以下是三个血泪教训:
反例一:布局嵌套导致前缀爆炸
某项目:feature:feed模块有个feed_item.xml,里面引用了:core:ui模块的通用卡片card_container.xml,而card_container.xml又引用了:core:theme模块的shape_rounded_bg.xml。如果每个资源都加前缀,最终 layout 文件里会充斥feed_card_container、ui_shape_rounded_bg、theme_shape_rounded_bg……维护成本指数级上升,且一旦模块重命名,所有前缀都要批量替换,CI 流水线直接挂掉。
反例二:第三方库资源不可控com.github.bumptech.glide:glide自带R.drawable.glide_custom_view_placeholder,你的:feature:gallery模块也定义了glide_custom_view_placeholder。你没法给 Glide 的资源加前缀,只能改自己的——但 Glide 内部硬编码了这个 ID,你一改,图片加载就失效。这类问题在使用ViewPager2、MaterialButton、LottieAnimationView等重度依赖自定义资源的库时高频出现。
反例三:多语言资源同步失衡res/values-zh-rCN/strings.xml里btn_login翻译成“登录”,res/values-en-rUS/strings.xml里却是“Sign In”。当你给资源加前缀后,home_btn_login和profile_btn_login需要分别维护两套翻译。但产品需求经常是“登录按钮文案全站统一”,此时前缀反而制造了维护孤岛,翻译团队要反复确认“home_btn_login 和 profile_btn_login 的英文是不是一样”,效率暴跌。
所以,纯靠命名规范,治标不治本。必须结合构建工具链的深度干预。
3. 四种经过生产环境验证的解决方案详解
3.1 方案一:启用android.useAndroidX=true+android.enableJetifier=true并升级至 AGP 8.0+(基础防线)
这看似是“迁移到 AndroidX”的常规操作,但它对资源冲突有隐性但关键的缓解作用。原因在于:AGP 8.0+ 的 aapt2 对资源合并的校验更严格,且 Jetifier 会重写第三方库的资源引用路径,减少外部污染。
具体操作步骤:
- 在项目根目录
gradle.properties中确保开启:android.useAndroidX=true android.enableJetifier=true - 升级
buildscript中的 AGP 版本(app/build.gradle的plugins块):plugins { id 'com.android.application' version '8.2.2' apply false id 'com.android.library' version '8.2.2' apply false } - 清理并重建:
./gradlew clean && ./gradlew assembleDebug
为什么有效?
- AGP 7.4 之前,aapt2 对
values类资源(string、dimen、color)的合并是“静默覆盖”,不会报 warning;AGP 8.0+ 默认开启--warn-duplicate-resources,遇到重复 string 会输出Warning: Duplicate resource string/btn_login,让你在构建日志里一眼看到冲突点。 - Jetifier 不仅转换
android.support.*包名,还会扫描第三方 AAR 的public.xml文件,将其中声明的public-id(如<public type="drawable" name="ic_launcher" id="0x7f020001"/>)重映射为 AndroidX 命名空间,避免你的ic_launcher和库的ic_launcherID 碰撞。
注意:这不是终极方案,而是“必做前置”。我见过团队跳过这步,直接上高级方案,结果发现 80% 的冲突其实是 support 库残留导致的,白白浪费两周排查时间。
3.2 方案二:模块级资源命名空间隔离(推荐指数 ★★★★★)
这是目前最主流、最稳妥、适配性最强的方案。核心思想:让每个模块的资源在编译期就拥有独立的命名空间,互不干扰,彻底规避合并冲突。
实现原理基于 AGP 的android.namespace属性。从 AGP 7.3 开始,Android Gradle Plugin 支持为每个 module 显式声明namespace,该 namespace 会作为资源 ID 的包名前缀,影响R.java的生成逻辑。例如:
:core:ui模块的build.gradle中设置:android { namespace 'com.example.core.ui' // ... 其他配置 }:feature:home模块设置:android { namespace 'com.example.feature.home' // ... 其他配置 }
此时,com.example.core.ui.R.drawable.ic_arrow_back和com.example.feature.home.R.drawable.ic_arrow_back是两个完全不同的类,ID 值也不同(虽然实际值仍是0x7f020001,但属于不同包下的不同常量)。模块 A 的代码只能访问com.example.core.ui.R,无法直接引用模块 B 的R,天然形成隔离。
实操要点与避坑指南:
- 必须全局统一 namespace 规则:建议采用
com.example.<domain>.<module>格式,<domain>可以是core、feature、data等,<module>用小写字母+下划线(如user_profile)。避免用com.example.moduleA这种模糊命名,否则后期模块拆分(如user_profile拆成user和profile)时 namespace 无法平滑迁移。 namespace必须与package一致:android.namespace值必须和src/main/AndroidManifest.xml中的package属性完全相同。否则 AGP 会报错The namespace value of the library's manifest (com.example.core.ui) does not match the namespace declared in the build file (com.example.core.ui)。- 资源引用方式要重构:模块内不再直接写
R.drawable.xxx,而是通过ContextCompat.getDrawable(context, R.drawable.xxx)或AppCompatResources.getDrawable(context, R.drawable.xxx)。因为getDrawable()方法内部会根据context.getPackageName()动态解析资源,确保拿到的是当前模块的资源。 ViewBinding和DataBinding需额外处理:它们生成的 binding 类默认绑定到模块的R类。如果要在模块 A 中 inflate 模块 B 的 layout,需显式传入模块 B 的R类实例,或改用LayoutInflater.from(context).inflate(R.layout.xxx, parent, false)。
效果验证:在:feature:home模块中创建res/values/strings.xml:
<string name="title">首页</string>在:core:ui模块中创建同名字符串:
<string name="title">UI 组件库</string>编译后,./app/build/intermediates/runtime_library_classes_jar/debug/classes.jar解压查看,你会发现com/example/feature/home/R$string.class和com/example/core/ui/R$string.class是两个独立文件,title字段的值分别是"首页"和"UI 组件库",互不影响。
3.3 方案三:Gradle 插件级资源扫描与冲突拦截(高阶管控)
当团队规模大、模块数量多(>30)、新人频繁加入时,仅靠命名规范和 namespace 隔离还不够。你需要一套“自动守门员”,在代码提交前就拦截潜在冲突。我们自研了一个轻量 Gradle 插件ResourceGuard,已在三个项目中稳定运行两年。
插件核心逻辑:在preBuild任务执行时,扫描所有 module 的res/目录,提取所有资源名(去除路径和扩展名),构建全局资源名哈希表。若发现同一资源类型(drawable、string、id)下存在同名资源,则中断构建并输出详细报告。
插件集成步骤:
- 创建
buildSrc/src/main/groovy/ResourceGuardPlugin.groovy:class ResourceGuardPlugin implements Plugin<Project> { void apply(Project project) { project.afterEvaluate { if (project.plugins.hasPlugin('com.android.library') || project.plugins.hasPlugin('com.android.application')) { project.tasks.register('checkResourceConflict', CheckResourceConflictTask) } } } } class CheckResourceConflictTask extends DefaultTask { @TaskAction void check() { def resourceMap = [:] // key: type_name, value: [module1, module2] project.subprojects.each { sub -> def resDir = sub.fileTree(dir: "${sub.projectDir}/src/main/res", include: "**/*") resDir.each { file -> def fileName = file.name.replaceFirst(/\.[^.]*/, '') // 去掉扩展名 def type = file.parentFile.name.replaceFirst('res/', '') // drawable, values def key = "${type}_${fileName}" if (!resourceMap.containsKey(key)) { resourceMap[key] = [] } resourceMap[key] << sub.name } } def conflicts = resourceMap.findAll { it.value.size() > 1 } if (!conflicts.isEmpty()) { logger.error("❌ 资源冲突检测失败!发现 ${conflicts.size()} 处重复资源:") conflicts.each { k, v -> logger.error(" - ${k} : ${v.join(', ')}") } throw new GradleException("资源冲突,请修正后重试") } else { logger.lifecycle("✅ 资源冲突检查通过") } } } - 在根项目
build.gradle中应用:buildscript { dependencies { classpath files('buildSrc/libs/resource-guard-plugin.jar') } } allprojects { apply plugin: ResourceGuardPlugin } - 将
checkResourceConflict任务注入preBuild:tasks.preBuild.dependsOn 'checkResourceConflict'
优势与适用场景:
- 零学习成本:开发者无需改变编码习惯,插件自动扫描,冲突即刻暴露;
- 精准定位:报告直接指出哪个模块、哪个资源类型、哪个文件名冲突,节省 80% 排查时间;
- CI 友好:可作为 PR 检查项,未通过则禁止合并;
- 可扩展性强:后续可增加“禁止在
:feature:*模块中定义color资源”等业务规则。
实操心得:我们最初只扫描
drawable和string,上线后发现id类资源冲突更隐蔽(因为不报错),于是增加了对layout/*.xml中android:id="@+id/xxx"的正则扫描。现在插件平均每天拦截 5~8 次潜在冲突,成为团队的“资源安全阀”。
3.4 方案四:资源动态加载与 Runtime ID 映射(终极灵活方案)
适用于极端场景:需要在运行时动态加载模块(如插件化、热更新)、或存在大量历史遗留模块无法统一 namespace、或需支持 A/B 测试不同资源包。其核心是绕过编译期资源合并,将资源加载权交给 Runtime。
技术栈组合:AssetManager+Resources+反射(Android 9+ 需适配createPackageContext)。
基本流程:
- 将模块的
resources.arsc和res/目录打包成独立 ZIP; - 运行时用
AssetManager加载该 ZIP; - 用
AssetManager创建新的Resources实例; - 通过
Resources.getIdentifier()动态获取资源 ID,再用Resources.getDrawable()加载。
简化版代码示例(Kotlin):
class DynamicResourceLoader(private val context: Context) { private var dynamicResources: Resources? = null private var assetManager: AssetManager? = null fun loadModuleResources(moduleZipPath: String) { try { assetManager = AssetManager() // Android 9+ 需用 addAssetPathAsSharedLibrary if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { assetManager!!.addAssetPathAsSharedLibrary(moduleZipPath) } else { assetManager!!.addAssetPath(moduleZipPath) } dynamicResources = Resources(assetManager, context.resources.displayMetrics, context.resources.configuration) } catch (e: Exception) { Log.e("DynamicRes", "Load failed", e) } } fun getDrawable(resName: String): Drawable? { return dynamicResources?.let { res -> val id = res.getIdentifier(resName, "drawable", "com.example.dynamic") if (id != 0) res.getDrawable(id) else null } } }注意事项:
- 性能开销:每次
getIdentifier()是线性搜索,频繁调用会卡顿。必须做 ID 缓存(如ConcurrentHashMap<String, Int>); - 主题兼容性差:动态加载的资源无法参与
AppCompatDelegate的主题适配,深色模式切换可能失效; - Proguard 风险:
getIdentifier()的字符串参数会被混淆,需在proguard-rules.pro中保留:-keep class com.example.dynamic.** { *; }; - Android 10+ 限制:
addAssetPath()在 targetSdkVersion >= 29 时被限制,必须使用addAssetPathAsSharedLibrary(),且 ZIP 包需签名。
我们只在
:plugin:pay(支付插件)模块中使用此方案。因为支付 SDK 要求资源必须与主 App 隔离,且需支持独立更新。其他模块一律用 namespace 方案,保证主干稳定性。
4. 实战配置与关键参数详解
4.1build.gradle中 namespace 的完整配置模板
以下是一个生产环境可用的:feature:home模块build.gradle配置,包含所有关键参数和注释说明:
plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' // 如果用 Kotlin } // ⚠️ 关键:必须声明 namespace,且与 AndroidManifest.xml 的 package 一致 android { namespace 'com.example.feature.home' // 【必填】模块唯一命名空间 compileSdk 34 defaultConfig { minSdk 21 targetSdk 34 // 【重要】testInstrumentationRunner 必须用 namespace 下的类 testInstrumentationRunner 'com.example.feature.home.HiltTestRunner' } buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } // 【关键配置】启用资源命名空间验证(AGP 8.1+) // 若发现资源引用跨 namespace,会报错提示 compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = '11' } } dependencies { implementation project(':core:ui') // ✅ 正确:依赖 core 模块 // ❌ 错误:禁止直接依赖其他 feature 模块,应通过 interface 解耦 // implementation project(':feature:profile') // 【资源安全】只暴露必要的资源 API api 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' // 【测试隔离】测试资源单独管理 androidTestImplementation 'androidx.test.ext:junit:1.1.5' }AndroidManifest.xml对应配置:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.feature.home"> <!-- ⚠️ 必须与 build.gradle 的 namespace 完全一致 --> <!-- 模块内 Activity 需声明完整包名 --> <application> <activity android:name=".HomeActivity" android:exported="true" /> </application> </manifest>为什么package和namespace必须一致?
AGP 在生成R.java时,会以namespace为包名创建R类。而AndroidManifest.xml的package是 APK 的唯一标识,也是Context.getPackageName()的返回值。ContextCompat.getDrawable(context, R.drawable.xxx)内部会调用context.getResources().getDrawable(id, context.getTheme()),其中context.getResources()返回的是Application的Resources实例,它关联的是package名。如果两者不一致,getDrawable()会尝试在错误的包路径下查找资源,导致Resources.NotFoundException。
4.2public.xml的正确用法:何时该用,何时禁用
res/values/public.xml是 Android 提供的“资源公开声明”机制,格式如下:
<resources> <public type="drawable" name="ic_launcher" id="0x7f020001" /> <public type="string" name="app_name" id="0x7f0b0001" /> </resources>它的本意是:为 library module 中的资源指定固定 ID,确保下游 app module 引用时 ID 不变。但在模块化项目中,它极易成为冲突源头。
正确使用场景(极少):
:core:ui模块提供一个BaseActivity,要求所有子类必须在onCreate()中调用setContentView(R.layout.base_activity_layout),而base_activity_layout.xml中引用了R.id.container。此时需在:core:ui的public.xml中声明<public type="id" name="container" />,保证所有业务模块继承BaseActivity时,R.id.container的 ID 值恒定。
必须禁用的场景:
- 任何业务模块(
:feature:*)禁止创建public.xml:业务逻辑变动频繁,ID 固化会导致后续修改困难; <public>中禁止声明string、color、dimen等可变资源:这些资源的值可能随主题、语言变化,ID 固化反而破坏灵活性;id类型资源慎用id属性:<public type="id" name="avatar" id="0x7f080001" />会强制占用该 ID,若其他模块也声明同名 ID,aapt2 会报错Duplicate public definition for id avatar,但错误信息不友好,排查困难。
实操心得:我们团队的 Code Review 规则明确禁止在
:feature:*模块中提交public.xml。只有:core:ui和:core:data这两个基础模块,在架构师审批后,才允许添加public.xml,且每次添加需附带《ID 固化影响评估报告》。
4.3 Proguard 混淆对资源 ID 的影响与规避策略
Proguard 不仅混淆 Java/Kotlin 代码,也会处理资源 ID。默认规则-keep class **.R$* { *; }会保留所有R类,但若你启用了shrinkResources true,Proguard 会删除未被代码引用的资源,这可能导致运行时Resources.NotFoundException。
安全配置模板(proguard-rules.pro):
# ✅ 保留所有 R 类(必需) -keep class **.R$* { *; } # ✅ 保留所有资源 ID 引用(防止 shrinkResources 误删) -keepclassmembers class **.R$* { public static <fields>; } # ✅ 针对动态资源加载(方案四)的特殊处理 # 保留资源名字符串,防止被混淆 -keepclassmembers class * { @android.content.res.RawRes *; @android.content.res.DrawableRes *; @android.content.res.StringRes *; } # ❌ 禁止以下规则(会导致资源 ID 被混淆) # -assumenosideeffects class android.util.Log { *; } # (此规则会删除 Log 语句,但若 Log 中拼接了 R.string.xxx,可能导致字符串被误删)验证方法:
- 执行
./gradlew assembleRelease; - 解压
app/build/outputs/apk/release/app-release.apk; - 查看
resources.arsc文件(用aapt dump resources app-release.apk); - 搜索
R.string.app_name,确认其 ID 值与app/build/intermediates/compile_only_not_namespaced_r_class_jar/debug/R.jar中的值一致。
若不一致,说明 Proguard 的shrinkResources删除了该资源,需检查代码中是否遗漏了对该资源的引用(如getString(R.string.app_name)是否被写在了if (false)块里)。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
Error: Duplicate resources编译失败 | 两个模块定义了同名drawable或raw文件 | find . -name "*.png" | xargs -I {} sh -c 'echo {}; md5sum {}' | sort | uniq -w32 -D | 删除冗余文件,或按方案二加 namespace |
Resources$NotFoundException: Resource ID #0x7f080001 | shrinkResources true删除了未引用资源 | aapt dump resources app-debug.apk | grep "0x7f080001" | 检查proguard-rules.pro是否保留了R$*,或在代码中添加@Keep注解 |
ViewBinding找不到控件,binding.avatar为 null | 模块 A 的 layout 引用了模块 B 的R.id.avatar,但 namespace 不同 | grep -r "R\.id\.avatar" :feature:home/src/ | 改用findViewById(R.id.avatar),或在模块 A 的 layout 中用tools:ignore="MissingPrefix" |
| 多语言字符串显示错乱(中文显示英文) | values-zh-rCN/strings.xml和values-en-rUS/strings.xml中同名 string 值不同,但被 aapt2 合并 | aapt dump configurations app-debug.apk | grep zh | 确保所有语言目录下strings.xml的<string name="xxx">定义完全一致,值差异通过productFlavors控制 |
DataBinding编译报错cannot find symbol variable xxx | layout文件中@={}绑定的变量名与BR类不匹配 | ./gradlew --stop && ./gradlew clean && ./gradlew build --stacktrace | 清理build/目录,重启 Gradle daemon,检查dataBinding.enabled = true是否全局开启 |
5.2 “诡异”的 ID 冲突:R.id的隐藏陷阱
R.id是资源冲突的重灾区,因为它不产生实际资源文件,只生成一个整数。最典型的案例是ViewStub和include标签:
<!-- :feature:home/res/layout/activity_home.xml --> <LinearLayout> <include layout="@layout/common_header" /> <ViewStub android:id="@+id/stub_content" ... /> </LinearLayout><!-- :core:ui/res/layout/common_header.xml --> <LinearLayout> <TextView android:id="@+id/title" ... /> <ViewStub android:id="@+id/stub_content" ... /> <!-- ❌ 冲突! --> </LinearLayout>表面看stub_content是ViewStub的 ID,但 aapt2 会为它生成R.id.stub_content,且两个模块的 ID 值相同。运行时findViewById(R.id.stub_content)可能返回common_header.xml里的ViewStub,也可能返回activity_home.xml里的ViewStub,取决于 inflate 顺序。
排查技巧:
- 在
adb shell中执行dumpsys activity top,查看当前 Activity 的 View Hierarchy; - 使用 Layout Inspector 工具,选中 View 后在右侧属性面板查看
mID字段,对比其值与R.id.stub_content是否一致; - 在
onCreate()中插入断点,打印findViewById(R.id.stub_content).hashCode(),多次运行观察是否变化。
根治方案:
- 所有
ViewStub、include的android:id必须加模块前缀,如home_stub_content、ui_stub_content; - 在
:core:ui模块中,将common_header.xml的stub_content改为ui_stub_content,并在CommonHeader.kt中提供showContent()方法封装ViewStub.inflate()逻辑,业务模块只需调用方法,不直接操作 ID。
5.3 CI/CD 流水线中的自动化检测脚本
我们将资源冲突检查集成到 GitLab CI 中,作为 MR(Merge Request)的准入门槛。脚本check-resources.sh如下:
#!/bin/bash set -e echo "🔍 开始资源冲突扫描..." # 1. 扫描所有 module 的 res 目录 declare -A resource_map while IFS= read -r -d '' file; do dir=$(dirname "$file") module=$(echo "$dir" | cut -d'/' -f2) # 假设 module 在 projectRoot/moduleName/src/main/res if [[ "$module" =~ ^[a-zA-Z0-9_]+$ ]]; then type=$(basename "$dir") name=$(basename "$file" | sed 's/\..*$//') key="${type}_${name}" resource_map["$key"]="${resource_map[$key]} $module" fi done < <(find . -path "./:*/src/main/res/*" -type f -print0) # 2. 检查重复 conflict_count=0 for key in "${!resource_map[@]}"; do modules=(${resource_map[$key]}) if [ ${#modules[@]} -gt 1 ]; then echo "❌ 冲突: $key -> ${modules[@]}" ((conflict_count++)) fi done if [ $conflict_count -gt 0 ]; then echo "💥 发现 $conflict_count 处资源冲突!请修正后重试。" exit 1 else echo "✅ 资源扫描通过,无冲突。" fiCI 配置(.gitlab-ci.yml):
stages: - validate validate:resources: stage: validate script: - chmod +x check-resources.sh - ./check-resources.sh only: - merge_requests该脚本在 MR 提交时自动运行,5 秒内完成扫描,比 Gradle 插件更快,且不依赖 AGP 版本,成为团队的第一道防线。
5.4 从“救火”到“防火”:建立模块化资源治理规范
技术方案只是工具,真正的稳定来自流程。我们推行了三项“非技术”措施:
- 模块资源 Owner 制:每个
:feature:*模块指定一名资源负责人,所有res/目录的新增、修改必须经其 Code Review; - 资源命名词典:在 Confluence 建立《公共资源命名词典》,规定
ic_arrow_back、btn_primary等通用资源的唯一归属模块(如:core:ui),禁止其他模块定义;
3