Android模块化资源重复冲突的根源与四大解决方案
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.xmlstring namebtn_login登录/string string nametitle_home首页/string模块 B 也定义了同名字符串string namebtn_login立即登录/string string nametitle_search搜索/stringaapt2 在构建时会把所有模块的资源合并进一个资源池。当它发现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.useAndroidXtrueandroid.enableJetifiertrue并升级至 AGP 8.0基础防线这看似是“迁移到 AndroidX”的常规操作但它对资源冲突有隐性但关键的缓解作用。原因在于AGP 8.0 的 aapt2 对资源合并的校验更严格且 Jetifier 会重写第三方库的资源引用路径减少外部污染。具体操作步骤在项目根目录gradle.properties中确保开启android.useAndroidXtrue android.enableJetifiertrue升级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的合并是“静默覆盖”不会报 warningAGP 8.0 默认开启--warn-duplicate-resources遇到重复 string 会输出Warning: Duplicate resource string/btn_login让你在构建日志里一眼看到冲突点。Jetifier 不仅转换android.support.*包名还会扫描第三方 AAR 的public.xml文件将其中声明的public-id如public typedrawable nameic_launcher id0x7f020001/重映射为 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 librarys 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.xmlstring nametitle首页/string在:core:ui模块中创建同名字符串string nametitleUI 组件库/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.groovyclass ResourceGuardPlugin implements PluginProject { 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任务注入preBuildtasks.preBuild.dependsOn checkResourceConflict优势与适用场景零学习成本开发者无需改变编码习惯插件自动扫描冲突即刻暴露精准定位报告直接指出哪个模块、哪个资源类型、哪个文件名冲突节省 80% 排查时间CI 友好可作为 PR 检查项未通过则禁止合并可扩展性强后续可增加“禁止在:feature:*模块中定义color资源”等业务规则。实操心得我们最初只扫描drawable和string上线后发现id类资源冲突更隐蔽因为不报错于是增加了对layout/*.xml中android:idid/xxx的正则扫描。现在插件平均每天拦截 5~8 次潜在冲突成为团队的“资源安全阀”。3.4 方案四资源动态加载与 Runtime ID 映射终极灵活方案适用于极端场景需要在运行时动态加载模块如插件化、热更新、或存在大量历史遗留模块无法统一 namespace、或需支持 A/B 测试不同资源包。其核心是绕过编译期资源合并将资源加载权交给 Runtime。技术栈组合AssetManagerResources反射Android 9 需适配createPackageContext。基本流程将模块的resources.arsc和res/目录打包成独立 ZIP运行时用AssetManager加载该 ZIP用AssetManager创建新的Resources实例通过Resources.getIdentifier()动态获取资源 ID再用Resources.getDrawable()加载。简化版代码示例Kotlinclass 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 缓存如ConcurrentHashMapString, 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 version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.feature.home !-- ⚠️ 必须与 build.gradle 的 namespace 完全一致 -- !-- 模块内 Activity 需声明完整包名 -- application activity android:name.HomeActivity android:exportedtrue / /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 typedrawable nameic_launcher id0x7f020001 / public typestring nameapp_name id0x7f0b0001 / /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 typeid namecontainer /保证所有业务模块继承BaseActivity时R.id.container的 ID 值恒定。必须禁用的场景任何业务模块:feature:*禁止创建public.xml业务逻辑变动频繁ID 固化会导致后续修改困难public中禁止声明string、color、dimen等可变资源这些资源的值可能随主题、语言变化ID 固化反而破坏灵活性id类型资源慎用id属性public typeid nameavatar id0x7f080001 /会强制占用该 ID若其他模块也声明同名 IDaapt2 会报错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 trueProguard 会删除未被代码引用的资源这可能导致运行时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删除冗余文件或按方案二加 namespaceResources$NotFoundException: Resource ID #0x7f080001shrinkResources 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:ignoreMissingPrefix多语言字符串显示错乱中文显示英文values-zh-rCN/strings.xml和values-en-rUS/strings.xml中同名 string 值不同但被 aapt2 合并aapt dump configurations app-debug.apk | grep zh确保所有语言目录下strings.xml的string namexxx定义完全一致值差异通过productFlavors控制DataBinding编译报错cannot find symbol variable xxxlayout文件中{}绑定的变量名与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 layoutlayout/common_header / ViewStub android:idid/stub_content ... / /LinearLayout!-- :core:ui/res/layout/common_header.xml -- LinearLayout TextView android:idid/title ... / ViewStub android:idid/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 中作为 MRMerge 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_count0 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.ymlstages: - 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