☰
Android动态版本号、Release APK与Jar包打包:一条命令全搞定
2026/10/14 11:14:23 网站建设 项目流程

1. 这个"打包三件套",其实是同一个需求的两个出口

做Android开发时间长了,会在某个节点突然发现自己同时要干三件事:打一个release版本的APK、把APK的版本名动态生成、再产出一个.jar包。我第一次看到这种需求组合时觉得挺奇怪——APK和jar包明明是两种不同形态的东西,为什么非要凑在一起处理?后来真正动手做过一遍才明白,这个"奇怪组合"其实指向的是同一种项目形态:SDK开发,或者组件化输出。

我当时的场景是这样的:某个跨模块业务需要把核心能力独立成SDK对外输出,公司内部要给演示小组一个release版APK用于真机演示,同时又要给合作的第三方提供纯代码粒度的jar包方便对方内嵌。表面看是三件事,实际梳理下来是三个环节各有一个出口:构建系统统一产出一个release APK、动态写入一个能看懂出包时间和版本的versionName、再额外导出一份不依赖资源的代码jar包。

这篇文章要聊的就是这三件事连起来怎么做。适合谁看?准备把Android模块改成SDK形态的开发者、需要给合作方发工具的组件化团队、以及被"为什么打了半天版本号还是旧的"这种问题卡住的人。如果你只是单纯想找一个骗过验收的版本号写法,那不用看;如果你想把打包链路理顺、让自己一条命令同时产出APK和jar包,那接下来的内容应该能帮你省不少试错时间。

2. 动态生成版本名:从硬编码到一句话搞定一个版本号

2.1 为什么要动态生成,而不是手改build.gradle

很多项目的versionName是静态写在build.gradle里的,打一次包改一次,听起来也不麻烦。但真实出包场景里,版本名往往要承载更多语义。比如我那次需求里,演示版要求版本名能直接看出是哪天出的包、当天第几次构建;灰度给合作方的包又要体现一个独立的正式版本号。手动改?一天打包十几轮,每轮都改很容易出低级错误——上午打的包版本名还是2.3.1,中午稍微改了点代码忘了提版本号,下午客户拿到手以为拿的还是旧包。

这里说的动态生成,本质上是把版本号的决定权从"人"转移给"构建时机"。构建的时候自动拼接日期、序号、Git提交号,或者直接通过命令行参数传入本次想要的版本名,然后把这个值统一注入到所有构建产物里。好处是在持续集成环境里尤其明显:每次构建的产物版本信息都可追溯,谁哪天打的包、打的是哪版代码,一目了然。

2.2 三种注入方式,一次说清楚

在Android Gradle体系中,往产物里注入版本信息主要有三个入口:AndroidManifest里的android:versionName、BuildConfig里的静态常量、以及资源文件里的字符串。三种方式各有适用场景,我梳理成了一张表:

方式注入位置读取方式适用场景
manifestPlaceholdersAndroidManifest.xmlPackageManager获取系统设置页展示、应用详情页版本号
buildConfigFieldBuildConfig.javaBuildConfig.XXX直接引用代码内部逻辑判断、日志上报
resValueres/values/strings.xmlgetString(R.string.xxx)UI展示、对外接口透传

我的建议很简单:凡是需要给用户或系统看的,走manifestPlaceholders;凡是要在逻辑代码里用到的,走buildConfigField。两者同时注入同一个值,保持同步。因为动态版本名最常见的坑就是——设置页显示的版本号和日志上报的版本号对不上,排查线上问题的时候特别闹心。

2.3 用-P参数注入版本名,构建命令一句话搞定

如果只是写死一个拼接规则,比如versionName = "2.3." + 构建次数,那也太单薄了。更实用的方案是把版本名做成可配置项,默认有一套自动规则,需要特殊指定时用-P参数覆盖。我从项目中抽出来的一个可复用配置长这样:

// app/build.gradle def appVersionCode = 1 def appVersionName = "1.0.0" // 允许通过 -PverCode 和 -PverName 动态指定 if (project.hasProperty("verCode")) { appVersionCode = project.property("verCode") as Integer } if (project.hasProperty("verName")) { appVersionName = project.property("verName") as String } else { // 默认生成规则:日期 + 当天构建序号,例如 20250618.3 def datePart = new Date().format("yyyyMMdd") def count = Integer.parseInt((new Date().format("HHmm")).toString()) appVersionName = "${datePart}.${count}" } android { defaultConfig { versionCode appVersionCode versionName appVersionName manifestPlaceholders = [ VERSION_NAME: appVersionName, VERSION_CODE: appVersionCode ] } buildTypes { release { buildConfigField "String", "VERSION_NAME", "\"${appVersionName}\"" buildConfigField "int", "VERSION_CODE", "${appVersionCode}" } debug { buildConfigField "String", "VERSION_NAME", "\"${appVersionName}-debug\"" buildConfigField "int", "VERSION_CODE", "${appVersionCode}" } } }

这样配置完之后,打正式包时这样执行:

./gradlew assembleRelease -PverCode=108 -PverName=2.3.1

如果不需要指定,直接./gradlew assembleRelease,版本名就会按照日期规则自动生成。这是一个比较中庸的方案:默认能自己跑,特殊场景才需要人工介入,不会让团队其他人在打包时被一堆参数搞懵。

2.4 注入过程中的两个大坑

先说一个我反复踩的坑:每次动态生成版本名时,Gradle可能由于构建缓存直接跳过某些Task,导致manifest里还是旧版本号。这个出现的概率一开始我低估了,后来发现只要两次构建间隔很短、没有改动关键输入,Gradle的增量构建就会认为"不需要重新生成"。这时候打包居然不打紧,但版本名就是旧的。解决办法是在构建前强制clean:

./gradlew clean assembleRelease -PverName=xxxx

还有一个坑是manifestPlaceholders里的值带特殊字符。版本名如果带了"+"或空格,在Manifest解析阶段会直接报错。我的处理方式简单粗暴但有奇效——自定义版本名时只允许数字、点、下划线,遇到其他字符统一替换成下划线。

3. release APK打包:签名、压缩、混淆,一次过不返工

3.1 release构建类型,需要配置的东西比想象多

很多人以为release就是debug去掉了android:debuggable="true",其实真要交付的release包至少要确认四件事:签名是否正式、混淆是否开启、资源是否压缩、log是否无法查看。我有一次打包演示版给客户,包是release的,结果用调试工具一抓还能看到业务日志,客户当场就问了句"你们这日志不关吗?"——当时虽然能解释是测试版,但如果是正式交付,这就是事故。

来看一个完整的release配置块:

android { buildTypes { release { // 开启混淆 minifyEnabled true // 开启资源压缩 shrinkResources true // 移除无用的debug日志输出代码 pseudoLocalesEnabled false // 指定混淆规则 proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" // 自定义的BuildConfig字段,用于代码里动态控制日志开关 buildConfigField "boolean", "LOG_DEBUG", "false" } } }

其中shrinkResources true和minifyEnabled true是绑定关系,前者必须依赖后者才能工作。资源压缩会把代码里没引用到的资源从包里删掉,但这个"没引用"的判断有局限性——凡是动态拼接的资源ID、通过反射访问的资源,都可能被当作无用资源。所以开启后一定要检查一遍所有getIdentifier和Resources.getDrawable(resId, theme)这类动态获取路径。

3.2 签名信息的安全管理

签名文件直接放在项目目录里而且密码写在build.gradle中,曾经是很多小团队的默认操作,但问题是仓库一公开,等于签名和密码一起暴露了。想对所有人都安全,可以改成从本地配置文件或环境变量读取:

// gradle.properties(不提交到版本库) RELEASE_STORE_FILE=/Volumes/secure/android_keys/release.jks RELEASE_STORE_PASSWORD=xxxx RELEASE_KEY_ALIAS=release RELEASE_KEY_PASSWORD=xxxx
// app/build.gradle def storeFile = rootProject.file(project.hasProperty("RELEASE_STORE_FILE") ? RELEASE_STORE_FILE : "debug.keystore") def storePassword = project.hasProperty("RELEASE_STORE_PASSWORD") ? RELEASE_STORE_PASSWORD : "android" def keyAlias = project.hasProperty("RELEASE_KEY_ALIAS") ? RELEASE_KEY_ALIAS : "androiddebugkey" def keyPassword = project.hasProperty("RELEASE_KEY_PASSWORD") ? RELEASE_KEY_PASSWORD : "android" android { signingConfigs { release { storeFile storeFile storePassword storePassword keyAlias keyAlias keyPassword keyPassword } } buildTypes { release { signingConfig signingConfigs.release } } }

这样本地开发时可以正常签名,CI环境通过环境变量注入,唯一要做的就是确保gradle.properties不提交进Git。从源头避免"仓库泄密码"这个长期隐患。

3.3 产物验证,别等交付了才发现问题

打包完成不等于可以交付。我习惯在构建产物目录里拿到APK后,先用构建工具验证一下版本信息、签名和路径是否正常。Android SDK自带的apksigner和aapt就能干这个事:

# 查看APK里的versionName aapt dump badging app-release.apk | grep versionName # 校验签名是否正规、签名方案是否生效 apksigner verify --verbose app-release.apk

如果期望的是v1+v2+v3全套签名,输出里会列出每个方案的验证结果。另一个经常被忽略的点是,release包里的BuildConfig.VERSION_NAME和Manifest里的versionName必须一致。前文我在两个入口同时注入同一个值就是出于这个原因。

3.4 一次release打包失败的排查实例

有次我在一个项目里打release包,Gradle任务跑完没有报错,但app启动到某个功能页就闪退,debug包完全正常。这类问题的典型根因基本都指向混淆。定位思路是先把混淆后的堆栈反解成可读堆栈:

# 把release崩溃堆栈还原成原始类名和方法名 retrace ProGuard -verbose mapping.txt stacktrace.txt

在这个案例里,最终定位到的是某个网络库的模型类,因为反射访问而被混淆规则抹掉了方法,运行时抛NoSuchMethodException。解决办法是在proguard-rules.pro里加上:

-keep class com.example.network.model.** { *; }

这也说明了一个经验:release打包一上来就开全量混淆,等于在给自己埋雷。稳妥流程是先只开minifyEnabled不开混淆(proguardFiles只留默认文件),确认功能正常后再逐步收紧规则。之前为了省事把混淆一起开了,结果光定位问题就花了半个下午。

4. 从aar碎片到干净jar:三条打包路径和一条取舍逻辑

4.1 为什么Android模块默认产不出jar

Android Library模块在构建时默认输出的是AAR包,AAR本质是一个zip压缩包,里面除了classes.jar之外还有res/、AndroidManifest.xml、R.txt等一系列资源相关文件。如果需求方只是把一段算法逻辑或者业务核心打成jar包集成进他们的非Android环境(比如服务端或者纯Java项目),那AAR里的资源文件反而成了累赘。这就是为什么需要单独做"打jar包"这一步。

4.2 路径一:从AAR里提取classes.jar

一条最省事的路径:先构建出AAR,再用zip工具把里面的classes.jar提取出来。

# 构建AAR ./gradlew :sdk:assembleRelease # 解压 unzip sdk-release.aar -d aar_temp/ # 此时 aar_temp/classes.jar 就是我们需要的jar

但直接用这条路径产出的jar有一个问题:classes.jar中包含R.class和BuildConfig.class这类构建期生成的类,对纯Java接入方毫无意义,而且可能在他们的工程里引起同名冲突。所以需要在提取时排除掉这些:

cd aar_temp zip -d classes.jar '**/R.class' '**/R$*.class' '**/BuildConfig.class'

然后把这个处理后的classes.jar改名输出。这个方法适合不常改代码、不想维护额外构建逻辑的场景,缺点是每次都要手动解压再重新压缩,效率不高,而且容易漏掉排除项。

4.3 路径二:用Gradle Task直接打包源码

如果思路是"我们本来就在构建系统里,为什么不直接让Gradle产出于jar",那推荐用Jar任务。有个前提:纯Java或者纯Kotlin的模块打jar最干净,一旦模块里引用了Android资源和R类,打出来的jar在纯Java工程里根本跑不起来。所以这条路径通常只适用于"逻辑下沉"得比较干净的模块。

// 在某个Library模块的build.gradle里 task packSourcesJar(type: Jar) { archiveBaseName = "sdk-core" // 只打包java源码目录,资源目录不参与 from sourceSets.main.java.srcDirs exclude("**/R.class", "**/R\$*.class", "**/BuildConfig.class") // 依赖编译产物,确保执行前代码是编译过的 dependsOn "compileReleaseJavaWithJavac" }

执行./gradlew :sdk:packSourcesJar后,在build/libs目录下就能看到生成的jar。优点是整个过程在Gradle里闭环,可以配置成发布流程的一环;缺点是如果模块依赖了其他库,这些依赖不会自动打进jar,接收方还得自己补依赖。

4.4 路径三:把App模块的多余产物也收拾干净

还有一类需求场景:整个项目是App形态,只想要把里面某部分代码抽成jar交付。这种情况下直接给App模块建一个Jar任务也可以做到,但要注意App模块通常依赖大量Android SDK类,jar成品的可移植性较差。

我在真实项目里是用了一个变通方案:单独建立了一个core模块,把需要交付的逻辑全部迁进去,保持该模块零资源依赖,然后只对该模块执行打包jar。这样做会让模块划分前期烧一点脑筋,但后续每次交付都特别省心。如果你也在做类似的"从App里抽jar"尝试,我的建议也是先抽模块,而不是把打包工具硬塞进App模块里。

4.5 到底选哪条路

用一张表来说清楚适用场景:

路径操作复杂度产物纯净度是否依赖额外模块拆分最佳场景
提取AAR里classes.jar低一般(需手动排除)不需要已有Library模块,偶尔交付
Gradle Task打源码jar中高推荐纯逻辑模块,希望交付流程自动化
App模块直接打jar高低几乎必须拆模块从现有App抽公共代码给别家用

我给自己定了一条规则:凡是打算做超过一次对外交付,就尽早把要交付的代码抽到独立模块里,用Gradle Task产jar,不碰手工zip那套。顺手把版本号也动态注入到jar的Manifest里,保证所有产物的版本一致性。

5. 一条命令三件套收尾:统一Task、版本同步和自动化钩子

5.1 用FinalizedBy把APK和jar串起来

真实交付场景里,我不希望打APK是一套命令,打jar又是另一套命令,然后还得记住先后顺序。Gradle提供finalizedBy可以让我们定义"当一个任务跑完,自动去执行另一个任务"。为此我在SDK模块的设计是:

// sdk/build.gradle tasks.whenTaskAdded { task -> if (task.name == "bundleReleaseAar") { task.finalizedBy "packSdkJar" } } task packSdkJar(type: Jar) { // 从构建中间产物取classes.jar再过滤 from(zipTree(tasks.named("bundleReleaseAar").flatMap { it.archiveFile })) { include("classes.jar") exclude("**/R.class", "**/R\$*.class", "**/BuildConfig.class") } archiveBaseName = "sdk-api" version = appVersionName }

这样执行./gradlew :sdk:assembleRelease的时候,AAR打包任务一旦完成,packSdkJar就会自动跟着执行,最终在build/libs下生成的jar文件名会带上当前版本号,比如sdk-api-2.3.1.jar。APK和jar,一次构建同时产出,版本名用的还是同一套动态规则,两头对得上。

5.2 绕不开的"版本同步"问题

当APK和jar的版本不是同一套逻辑时,交付后对不上是必然的。我在实践里把"版本同步"做到了三层:

第一层是版本源头一致。APK的versionName、BuildConfig、jar的archiveName都引用同一个变量appVersionName,从根上消灭多份配置不一致的问题。

第二层是把构建时间和版本号写进jar包内的一个属性文件里。比如在打包jar时顺带生成META-INF/sdk-version.properties:

task generateVersionFile { def versionFile = file("${buildDir}/sdk-version.properties") outputs.file(versionFile) doLast { versionFile.text = "version=${appVersionName}\nbuildTime=${new Date().format("yyyy-MM-dd HH:mm:ss")}\n" } }

jar里多了这个文件后,接收方不管什么时候拿到jar,都能自己读版本,不用打电话问我们拿的是哪个包。

第三层是强制校验。我在CI脚本里加了产物检查步骤:构建完成后用aapt验证APK的versionName,再用解压工具读取jar里version.properties的version字段,两个不一致直接让流水线失败。

5.3 我在真实打包里踩过的几个坑

先是最常见的"Gradle认为任务没有变化,跳过执行"。前面提到过,动态版本号场景里Gradle的增量缓存有时会让"看起来执行了但没执行"成为常态。彻底做法是在CI脚本里固定走clean再构建,代价是构建时间变长,但版本信息正确性更重要。如果不想牺牲速度,可以给打版本号相关的Task单独配置outputs.upToDateWhen { false },强制每次执行:

tasks.whenTaskAdded { task -> if (task.name == "processReleaseManifest") { task.outputs.upToDateWhen { false } } }

然后是非法字符问题。动态版本名如果从分支名或环境变量读取,稀里糊涂带了斜杠或冒号,Manifest解析直接报错。我在注入前加了统一处理:

def safeVersionName = appVersionName.replaceAll(/[^a-zA-Z0-9\.\-_]/, "_")

还有一个经常被忽略的是不同模块同时引用BuildConfig.VERSION_NAME的路径问题。主App模块的BuildConfig和应用包名绑定,SDK模块的BuildConfig跟SDK包名绑定,代码里如果直接引用BuildConfig.VERSION_NAME,在不同模块下拿到的值可能一个是App版本、一个是SDK版本。更稳妥的做法是封装一个SdkVersionProvider类,内部从Manifest读取,这样所有模块都通过同一个入口获取版本。

5.4 从手动到半自动:把参数留给CI

流程稳定之后,我建议把动态版本号的决策位再上移一层,让CI或发布脚本统一注入。比如Jenkins或GitLab CI的构建参数里维护一个版本号输入框,触发构建时环境变量传入:

# CI脚本片段 ./gradlew assembleRelease \ -PverCode=${BUILD_NUMBER} \ -PverName=${VERSION_NAME} \ :sdk:assembleRelease

这样本地开发的人不用知道版本策略,CI上点一下就能出一个标准产物。到这一步,"动态生成版本名"这个需求才算真正落地——它不再是一个写死在代码里的规则,而是成为整个发版流程的可配置输入。

我在实际维护这套流程时,最深的体会是:宁可前期在构建脚本上多花点时间,也不要每次发版前手动改版本号、手动包顺序。构建系统的价值就是把容易出错、重复度高的事情固化成确定性的流程。这套方案从一开始的"三件事分开做要一上午",到后来一条命令五分钟全部产出,省下的时间远超过当时研究Gradle配置花掉的。

如果现在的项目也正被"打release包版本号永远是旧"、"AAR和jar两头版本对不上"这类问题困扰,不妨按上述方案分两步走:先把动态版本号统一好,再为jar单独设计一条干净的产出路径。不用一次全改完,先把版本信息同步解决了,后面的打包问题会好处理很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询