从复制源码到本地Maven:Android多工程共享组件的完整实践
发布时间:2026/10/15 17:00:59 锦皓数字建站

做Android开发久了几乎都会被同一件事绊住自己封装好的通用组件比如网络层、图片加载封装、一套带基础ViewModel的UI库忽然要在第二个、第三个工程里使用。我最早的处理办法很粗暴——把源码文件夹直接复制进新工程改bug改到一半才发现两边已经各走各路了。后来改用Git submodule又遇到子模块地址、分支同步和主工程构建时间被拖长的问题。直到认真把本地Maven库跑通之后发布一个库变成一条Gradle命令引用方只需写一行implementation版本号一变就能整体切换。这篇文章就是我当时从零搭建到实际使用的完整记录按为什么选它、坐标和目录怎么对应、发布侧怎么配、使用侧怎么接、坑在哪里的顺序展开适合正在为多工程共享代码发愁的Android工程师。1. 分发姿势选型从复制源码到本地Maven这一步怎么走1.1 三种分发方式实际体验对比刚开始维护通用库时很多人第一反应是就一个小组件直接复制过去不就行了。库只有一个类、几个接口时确实没问题但一旦它开始依赖其他第三方库、开始有版本迭代复制源码的代价就变得非常明显B工程还停留在1.0逻辑A工程已经在调试2.0的重构方案修复的bug要么忘记同步要么同步时把本地的定制改动一起覆盖掉。相比之下模块依赖至少让每个工程编译的是同一份源码通过Git子模块的方式共享但它的绑定粒度是源码级的——主工程打开就要去拉子模块子模块改动会直接影响所有引用方想固定某个历史版本反而不自然。分发方式引用形式更新粒度典型问题复制源码源码直接入工程手动覆盖多份副本逻辑漂移Git Submodule / 工程模块依赖implementation project(:lib)随主工程构建需要同步整个源码树版本锚定不便本地Maven仓库implementation com.example:lib:1.0.0按版本号发布更新时需要重新发布私服 / 公开仓库坐标依赖按版本号发布需要服务端和团队规范模块依赖适合还在快速演进、需要频繁和主工程联动调试的库本地Maven适合已经相对稳定、但还没有条件上公司级私服的中小型团队。它的本质是把源码共享切换成构件共享引用方拿到的是一份带版本标识的AAR产物不需要关心库内部怎么组织。1.2 坐标依赖到底赢在哪里Maven坐标依赖看起来只是写法上的区别但它改变了库的交付形态。implementation com.example.common:network:1.0.0这条声明里com.example.common是groupnetwork是artifactId1.0.0是version三者合起来定位到一个不可变的产物。消费者工程构建时Gradle会根据坐标去声明的仓库里找对应文件并按POM里记录的传递依赖继续解析。这个模型最实用的价值是版本锚定。我用1.0.0发布一版即使第二天在库工程里修改了代码继续开发2.0.0引用1.0.0的工程完全不受影响。协作时也可以明确告诉对方我的库是哪个版本而不是你拉一下最新代码试试。对于还在自己折腾的中间态库它会逼着养成发版意识——每次对外可见的改动都应该对应一个版本号。虽然本地Maven多了一道发布步骤但实际执行成本约等于执行一个Gradle Task后面第三节会详细写配置。相比起来它换来的多工程之间彻底解耦是非常划算的。2. 核心基础目录布局与Maven坐标的对应关系2.1 一个坐标怎么变成一堆文件路径在配Gradle之前建议先弄明白Maven仓库在磁盘上长什么样因为后面所有的路径、缓存、清理操作都和它有关。一个标准的Maven仓库路径遵循groupId转目录 artifactId version的规律。以com.example.common:network:1.0.0为例它在仓库目录下会变成network-repo/ └── com/ └── example/ └── common/ └── network/ └── 1.0.0/ ├── network-1.0.0.aar ├── network-1.0.0.pom └── network-1.0.0-sources.jargroupId里的点号会替换成目录分隔符artifactId和version直接作为下一级目录名文件名的前缀是artifactId后半段是version扩展名对于Android Library通常是.aar而不是.jar。POM文件记录了这个构件的基本信息以及它依赖了哪些库Gradle解析传递依赖靠的就是它sources.jar则是IDE跳转源码时的素材后面会有专门说明。理解这个映射关系对排查问题很有帮助。比如去本地仓库目录翻了半天找不到network.aar很可能就是把version目录层级搞错了再比如某个构件在缓存目录里多出一串哈希值子目录那是Gradle的模块缓存不是发布时生成的。2.2 仓库目录放哪才更稳妥仓库目录的位置选择直接决定后面所有命令和配置文件里写什么路径。我见过两种主流做法一种是把仓库目录放在某个公共磁盘路径下比如Windows上的D:/repos/android-local-repo所有工程在settings.gradle里统一引用这个绝对路径另一种是把它放在某个工程根目录下发布和引用都通过相对位置定位。绝对路径适合自己单机使用或者团队把目录放到共享盘上但一旦换电脑或者克隆到别的目录路径变了引用就断。相对路径更适合把本地Maven仓库作为一个普通目录提交到Git仓库里管理团队克隆项目时仓库目录也一起带过来。我在团队协作阶段更倾向于后者在安卓工程根目录下建一个localMavenRepo目录库模块发布时写死输出到${rootDir}/localMavenRepo消费者工程在settings.gradle里用相对路径引用。这样新同学clone后不需要改任何配置就能构建。还需要考虑的是要不要直接用Gradle内置的mavenLocal()仓库。它默认指向用户主目录下的~/.m2/repository单独一台机器上确实省事但这个目录是全机共享的各个项目往里丢的构件混杂在一起清理时容易误删别人正在用的版本。一旦多个开发者之间共享同一台构建机mavenLocal()的隔离性问题会非常突出。相比之下显式指定一个目录所有构件都在自己掌控下出问题时整个文件夹删掉重来也不心疼。2.3 本地Maven、mavenLocal、私服的本质差异理论上mavenLocal()也是本地Maven的一种但它背后对应的是Maven构建工具的传统本地仓库Gradle的mavenLocal()只是把它当作一个远程仓库的本地缓存来看待。如果只是自己一个人开发用mavenLocal()确实最快。但到了两个人以上协作时它的缺点就暴露了A开发者发布到自己的~/.m2B开发者根本读不到如果团队约定大家把公共仓库放到同一台共享服务器上那么本地Maven库的定义也只是一条file://协议的URL而已发布端和消费端指向同一个网络共享路径。本地Maven库解决的是还没有私服/不方便上私服阶段的共享问题。它与私服最大的差别是缺少权限管理和全局构建面板但只要能保证团队所有人都指向同一个仓库路径版本流转就能跑通。等库数量多了、需要对外分发时再把repository地址从file://换成https://配置结构基本不需要大改。3. 发布侧实操把Android Library变成可发布构件3.1 最小可用的 publishing 配置理解了目录结构后发布侧配置就很好理解了。我以Gradle Groovy DSL为例在库模块的build.gradle里添加maven-publish插件并配置一个MavenPublicationplugins { id com.android.library id maven-publish } publishing { publications { release(MavenPublication) { groupId com.example.common artifactId network version 1.0.0 afterEvaluate { from components.release } } } repositories { maven { name localRepo url uri(${rootDir}/localMavenRepo) } } }这里有三个容易忽视的点。第一afterEvaluate不能省。Android Library的components.release是在Android插件执行阶段才注册的如果在配置阶段就调用from components.release会碰到Publication only contains dependencies and/or configuration metadata之类的报错最终生成的POM缺少关键的AAR文件信息。第二name localRepo不是随便写的。它会被拼进发布任务的名称里后面在命令行里会用得到。如果仓库名写成了中文或者带空格任务名会变得很奇怪所以尽量用驼峰英文。第三url的写法。uri(${rootDir}/localMavenRepo)会基于当前构建根目录拼接路径不需要关心是Windows还是macOS斜率方向Gradle会处理。不要直接写D:\\repo这样的字符串判断系统差异可移植性会差很多。3.2 发布任务名是怎么拼出来的Gradle的maven-publish插件会自动生成一组发布任务任务名遵循固定规则publish{Publication名}PublicationTo{仓库名}Repository。上面的配置中Publication名是release仓库名是localRepo生成的任务名就是publishReleasePublicationToLocalRepoRepository。执行./gradlew :network:publishReleasePublicationToLocalRepoRepository也可以直接执行./gradlew publish它会把所有Publication发布到所有配置好的仓库。如果库模块名是:network前面要带上模块路径否则Gradle会提示找不到任务。执行成功后去localMavenRepo目录看一眼应该能看到com/example/common/network/1.0.0/目录和对应的aar、pom文件。如果POM文件里packaging不是aar多半是from components.release没生效需要回来检查配置。3.3 顺手把 sources 一起发出去默认配置发布的只有AAR和POM。如果只做到这一步消费者虽然能正常编译但想在Android Studio里点进库的类看实现时看到的会是被反编译的字节码注释和变量名完全没法看。为了开发体验通常还会追加一个sources包android { // ...已有配置 } tasks.register(sourcesJar, Jar) { archiveClassifier.set(sources) from android.sourceSets.main.java.srcDirs from src/main/kotlin // 如果库有Kotlin源码把kotlin目录也打进去 } publishing { publications { release(MavenPublication) { // groupId/artifactId/version... afterEvaluate { from components.release artifact sourcesJar } } } }archiveClassifier会决定生成文件的中间名sources对应network-1.0.0-sources.jar。如果库是纯Kotlin写的单独留java.srcDirs可能拿不到代码需要显式把src/main/kotlin也加入from列表。发布后再去看目录应该能看到-sources.jar文件。3.4 components.release到底做了什么from components.release这行代码是最容易被跳过又最关键的。它告诉发布插件从Android插件注册的release组件里提取产物信息包括主AAR包、依赖列表、BuildConfig相关属性等。POM里为什么能自动带上dependencies节点就是因为AGP在组件里记录了这些依赖坐标。如果不使用组件而是手动artifact file(build/outputs/aar/network-release.aar)确实也能发布出AAR文件但POM里的依赖信息需要手动拼pom.withXml非常痛苦。所以那句afterEvaluate不是绕弯子而是为了让AGP先把组件准备好发布插件再去读取。理解了这一点以后遇到发布的库能编译但传递依赖全丢失时第一反应就应该是检查有没有正确用组件而不是手写artifact。4. 使用侧接线仓库声明、坐标引用与版本管理4.1 settings.gradle 里的仓库配置仓库声明写在根工程settings.gradle里而不是模块的build.gradle。Android Gradle Plugin 7.0之后的推荐写法是使用dependencyResolutionManagement统一管理// settings.gradle dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories { google() mavenCentral() maven { url uri(../localMavenRepo) } } }这里的路径是相对于settings.gradle所在目录的。如果仓库目录就在当前工程根目录下直接写../localMavenRepo即可如果放在本地磁盘固定位置则用绝对路径maven { url uri(file:///D:/repos/android-local-repo) }注意Windows盘符写三斜杠file:///D:/...第一个/表示协议后面的空主机名后面的D:/才是真实路径。很多人在这一步写错导致Gradle识别不了。仓库声明顺序同样影响依赖解析Gradle会按顺序查找仓库所以如果本地Maven和远程仓库里有同名同版本的构件先声明的生效。团队内部要求本地仓库放在google()之后就行不必刻意插到中间。4.2 dependencies 里如何引用配置好仓库后在需要的模块的build.gradle里添加依赖dependencies { implementation com.example.common:network:1.0.0 }这里的坐标要和发布侧严格一致哪怕大小写不对都会解析失败。构建时Gradle会先去google()和mavenCentral()里找找不到再去localMavenRepo里找。找到后会把AAR下载到Gradle模块缓存里之后即使你删除本地仓库里那个AAR文件项目依然能继续构建——因为已经缓存过了。版本切换时只需要改依赖里的version比如从1.0.0改成1.0.1重新构建就会去本地仓库找新版本。想验证一个还在开发中的库时也可以直接把版本写成最新并发布测试完再改回正式版本这个流程比模块依赖的代码切换干净得多。4.3 多模块下版本统一一个大型App往往有多个模块都依赖同一个基础库。如果每个模块的build.gradle里都写死版本号升级时漏改一个模块就会导致版本不一致运行时出现奇怪问题。两种常用做法第一种在gradle.properties中定义变量commonNetworkVersion1.0.0模块里引用dependencies { implementation com.example.common:network:${commonNetworkVersion} }第二种使用libs.versions.toml版本目录。在gradle/libs.versions.toml里定义[versions] commonNetwork 1.0.0 [libraries] common-network { group com.example.common, name network, version.ref commonNetwork }模块里配合version catalog自动生成的访问器dependencies { implementation libs.common.network }两种方案都比散落版本号强。我在项目里偏向第二种因为可以看到所有依赖的统一清单升级时一目了然。对于多个工程共用一个基础库版本的情况建议把这份toml也纳入版本管理发版时同步改动。5. 我实际踩过的五个坑及排查记录5.1 改了发布内容后消费者构建仍是旧版本这是本地Maven最常见的问题。明明删掉了localMavenRepo里的旧AAR重新发布后其他工程依赖的还是旧版本。原因在于Gradle会把解析到的构件缓存到~/.gradle/caches/modules-2下缓存键是坐标信息和时间戳。本地仓库的内容变了但Gradle认为这个坐标已经解析过直接用缓存。处理方法是在消费端执行./gradlew build --refresh-dependencies如果版本号完全没变比如还在用1.0.0但内容已经更新--refresh-dependencies有时仍不够因为Gradle会复查元数据但动态内容仍可能命中。最彻底的办法是手动清理缓存目录中对应坐标的文件夹rm -rf ~/.gradle/caches/modules-2/files-2.1/com.example.common/network删掉后再构建Gradle会重新去本地仓库读取。为了避免这种折腾我后来养成了一个习惯本地发布测试时每次都把version递增到带-SNAPSHOT的测试版本正式发布才用稳定版本号。这样至少能减少同版本内容更换的概率。5.2 “components.release”被 afterEvaluate 之外的时间点引用某次我图省事没有加afterEvaluate直接在publishing.publications里写了from components.release。构建时Gradle抛出类似的提示Publication only contains dependencies and/or configuration metadata。这是因为AGP的配置阶段还没执行完components里还没有实际的release组件信息。排查的完整过程是先执行./gradlew :network:tasks --group publishing看看发布任务是否正常接着加上afterEvaluate重新执行。这里afterEvaluate的意思是等待整个项目的配置阶段评估完成后再执行这段闭包所以组件信息已经被AGP填充了。理解这个先后顺序后类似的报错都不会再觉得玄学。5.3 POM里的依赖坐标解析不到构建反复联网失败使用侧除了引用基础库基础库内部可能还依赖第三方库。比如我的network库依赖了OkHttp发布时POM会记录OkHttp坐标。消费者工程解析network时会继续用消费者声明的仓库去解析OkHttp。如果消费者工程的settings.gradle里没有声明mavenCentral()或者仓库顺序不对这次传递解析就会失败。排查方法是在构建命令后加--info日志里能看到它尝试解析的完整坐标列表。这个现象也提醒我一个库在发布前必须想清楚它暴露了多少外部依赖尽量把不必要的外部依赖收进库内部或者确保消费者能访问到这些依赖所在的仓库。传递依赖是Maven生态的便利之处也是本地Maven工程最容易翻车的地方。5.4 IDE 无法跳转到库的源码发布库之后接入主工程发现按Ctrl/Cmd点击NetworkApi类只能看到反编译的Class文件。这个问题的根源就是上面提到的没有发布sources构件。Android Studio本质上也是通过下载sources包来提供源码跳转的本地仓库有sources文件时IDE会自动关联。处理办法自然是补发sources包。除了配置sourcesJar之外还要注意如果之前发布过同版本但是没有sources构件重新发布同版本后消费者工程需要清理模块缓存才能看到新文件。所以最好在第一次发版时就带上sources省得后面给同事带去为什么我不能点进去看的困惑。5.5 文件URL路径写错导致Gradle始终找不到本地仓库Windows路径和Unix路径的混用是另一个高发问题。有一次我在settings.gradle里写maven { url D://repos/android-local-repo }缺少file:协议前缀Gradle会把D://...当作一个不认识的URL scheme直接报错。正确写法只能是maven { url uri(file:///D:/repos/android-local-repo) }如果路径包含空格比如D:/My Repos/test-repo空格必须做URL编码或者用uri(new File(...))maven { url uri(new File(D:/My Repos/test-repo).toURI()) }路径问题排查起来很耗时因为报错信息不一定直接说file not found有时是Could not find com.example.common:network:1.0.0这种掩人耳目的提示。后来我学会先到磁盘上确认目录存在再去检查URL写法能少走很多弯路。本地Maven这套流程跑顺之后我最大的体会是它逼着你尽早建立版本意识。以前复制源码的时候根本不会想过哪天库会以版本的形式被其他工程引用也不会去设计API的兼容性。现在每次发布前都会先看一遍改动确认是否属于破坏性变化再决定递增主版本还是次版本。团队里几个人共用同一个仓库路径后协调方式也变得很直接——只需要约定好group和artifactId命名规则不互相覆盖就行。如果你也在多工程共享代码的泥潭里挣扎先从今天这篇里的最小配置开始把第一个库发布到本地仓库改成坐标依赖后面的一切都会顺畅很多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。