ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AGP 8/9.0下APK重命名新写法:androidComponents替代旧Variant API实战指南

AGP 8/9.0下APK重命名新写法:androidComponents替代旧Variant API实战指南 做 Android 构建的同学八成都在自己项目的build.gradle里写过那段“祖传代码”android.applicationVariants.all { variant - variant.outputs.all { outputFileName ... } }。这套写法从 AGP 3.x 一直用到 7.x说实话相当顺手想给 APK 加个版本号、加个渠道名、加个日期后缀全在一个闭包里搞定。但到了 AGP 8.0这套 API 被官方标记为废弃AGP 9.0 里更是彻底不再支持很多项目在升级构建配置时直接把编译干挂了报错信息还是一个十分陌生的onVariants、androidComponents对象模型。这篇文章就专门聊清楚一件事在 AGP 8 和 9.0 里到底怎么正确地修改构建产物的 APK 文件名。顺便把新老 API 的差异逻辑、命名模板的工程化设计、Unity 和 Cocos 这类引擎导出工程里的实际落地方案以及我踩过的几个真实坑一并讲透。适合正在升级 Gradle 构建配置的 Android 开发、负责 CI 打包流水线的工程效率工程师以及用 Unity 2022 / Cocos Creator 打包 APK 又想在产物名称上做文章的朋友。1. AGP 8 到底改了什么经典写法为什么一夜失效1.1 经典写法与它的死因先把旧写法完整贴出来方便对照// 旧写法AGP 7.x 及更低版本可用 android.applicationVariants.all { variant - variant.outputs.all { output - def apkName MyApp-${variant.buildType.name}-v${variant.versionName}.apk outputFileName apkName } }这套 API 在社区里俗称“旧的 Variant API”它的工作方式很直接applicationVariants是一个在配置阶段就构建好的变体集合给每一个变体注册输出重命名逻辑。问题恰恰出在“配置阶段就构建好”这句话上。AGP 8 开始全面推进 lazy configuration延迟配置和配置缓存configuration cache目标是让构建配置只有在真正需要某个变体时才去计算从而缩短gradlew的启动时间、增量构建时间。旧的applicationVariants.all会强制把所有变体、所有输出、所有依赖关系一次性物化出来等于把配置缓存的红利全部抵消。加上旧 API 用字符串处理任务名问题排查也痛苦。官方在 AGP 8.0 里就把这套 API 的默认行为改成了“不可用”——不是突然删掉而是除非你在gradle.properties里显式声明android.experimental.androidVariantApiuse否则在 Groovy 脚本里调用applicationVariants会直接抛异常。注意这里的措辞AGP 8 还留了一个过渡开关到了 AGP 9.0这个开关连同旧 API 一起被移除。也就是说从 AGP 9.0 开始你想改 APK 名字必须走下面这套新 API没有任何回旋余地。1.2 新 API 的思维方式selector callback替代旧 Variant API 的是一套以androidComponents为入口的新对象模型。它的核心结构是“选择器 回调”// 位置app/build.gradle.kts androidComponents { onVariants(selector().all()) { variant - // variant 就是匹配到的应用变体 } }androidComponents块固定出现在android{}配置块之后专门用于通过onVariants、beforeVariants、onVariantProperties等回调访问变体信息。selector().all()是变体选择器表示匹配所有变体你还可以按buildType或productFlavor过滤比如selector().withBuildType(release)本质上就是一套更标准的过滤语法。旧写法里我们直接访问variant.outputs.all新写法里则是variant.outputs.forEach。在 AGP 8.x 中variant.outputs的类型是可变的多输出集合遍历出来的单个输出对象上有一个outputFileName属性直接给它赋字符串即可效果和旧写法一致。区别只在于新 API 是在变体对象被按需创建时执行回调不参与配置的项目完全不会被实例化配合配置缓存后构建性能确实能拉开差距。还有一个重要变化旧写法里variant.versionName在新 API 中变成了更细粒度的variant.prodVersionName、variant.minSdkVersion等属性。这个细节特别容易踩雷后面我会单独展开。2. 新写法实操Kotlin DSL 与 Groovy DSL 改 APK 名称2.1 最小可跑示例Kotlin DSL假设你是一个标准的单模块 App 项目模块名为app要给所有 debug / release 变体生成统一命名的 APK。在app/build.gradle.kts里这样写import com.android.build.api.variant.AndroidComponentsExtension androidComponents { onVariants(selector().all()) { variant - variant.outputs.forEach { output - val apkName generateApkName(variant) output.outputFileName apkName } } } fun generateApkName(variant: com.android.build.api.variant.ApplicationVariant): String { val versionName variant.prodVersionName ?: 1.0 val flavorName variant.flavorName?.takeIf { it.isNotBlank() } ?: noflavor val buildType variant.buildType return MyApp-${flavorName}-${buildType}-v${versionName}.apk }执行./gradlew assembleDebug后产物路径app/build/outputs/apk/debug/下会出现类似MyApp-noflavor-debug-v1.0.apk的文件。这里逐个解释每句的意图。androidComponents块是注册变体回调的唯一入口它必须在android {}块之后出现且只能是androidComponents {}而不是android.applicationVariants。selector().all()翻译成人话是“所有变体都处理”如果你只关心 release可以用selector().withBuildType(release)。variant.outputs.forEach遍历输出返回的每个output在 AGP 8 中都实现了SingleOutput接口这个接口上挂着outputFileName这个可变属性赋什么字符串最终产物的文件名就变为什么。variant.prodVersionName是新 API 中替代旧variant.versionName的属性类型是String?空的时候给个默认值兜底。2.2 Groovy DSL 的等价写法很多存量项目还是 Groovy DSL 的build.gradle写法基本一致androidComponents { onVariants(selector().all()) { variant - variant.outputs.forEach { output - def versionName variant.prodVersionName ?: 1.0 def flavorName variant.flavorName ?: default def buildType variant.buildType output.outputFileName MyApp-${flavorName}-${buildType}-v${versionName}.apk } } }Groovy 和 Kotlin 两种 DSL 在androidComponents上的差异极小主要注意三点Groovy 里调用selector().all()后面跟{}闭包时参数传递更宽松不会出现 Kotlin 那种 SAM 转换的写法问题。Groovy 字符串模板里直接插值variant.prodVersionName ?: 1.0注意 Groovy 的?:会正确识别null但空字符串不会触发默认值所以如果需要排除空串要写成variant.prodVersionName ?: 1.0之外再判断isEmpty()。如果项目同时在android {}块里配置了splits多输出场景下outputFileName的处理稍有讲究第 3 节会讲。这段脚本可以直接贴到现有build.gradle里。我建议把它放到android {}块之后保持构建逻辑的顺序感。2.3 配置生效顺序的命令行验证改完脚本强烈建议先跑一次./gradlew :app:tasks --all | grep -i assemble看看 AGP 到底创建了哪些变体任务再执行./gradlew assembleDebug。如果项目同时有多个 flavor执行./gradlew assemble会一次性产出所有变体包。验证时有一个常见误区有人改完脚本后盯着终端输出里的“文件名”看但 Gradle 默认日志不打印 APK 文件完整路径。正确的验证方式是看app/build/outputs/apk/目录里的实际文件或者跑./gradlew :app:assembleDebug --info在日志里搜outputFileName展开的结果。我在实践中习惯这样验证执行构建后不看终端直接ls -lh app/build/outputs/apk/debug/。文件命中自己预期的命名模板说明onVariants回调成功执行如果文件名还是默认的app-debug.apk多半是脚本没写进androidComponents块或者写进了但位置在afterEvaluate之后属于配置生效顺序的问题排查思路见第 5 节。3. 工程化进阶APK 名称模板与 CI 集成3.1 常见命名模板版本号、变体名、时间戳、Git 提交号实际项目里APK 命名不是简单的MyApp-debug.apk就完事更多时候需要同时拼上版本号、构建类型、渠道名、日期时间、Git 提交号。这一节给出一套可以“抄作业”的命名函数。先看需求来源。产品、测试、渠道方拿到安装包后要能在文件管理器里一眼看出“这是哪个版本、哪个渠道、什么时间打的”这比在安装后再进设置页看版本号要高效得多。于是命名模板通常长这样MyApp-{渠道}-{buildType}-v{versionName}-{buildTime}-{gitHash}.apk举例MyApp-mall-release-v2.3.0-20250120-1430-a1b2c3d.apk。对应的 Kotlin DSL 完整实现import com.android.build.api.variant.AndroidComponentsExtension import java.time.LocalDateTime import java.time.format.DateTimeFormatter fun sanitizeFileName(input: String): String input.replace(Regex([^a-zA-Z0-9._\\-]), _) fun currentTimestamp(): String LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMdd-HHmm)) androidComponents { onVariants(selector().all()) { variant - val channelName sanitizeFileName( project.findProperty(channelName) as? String ?: unknown ) val gitHash project.findProperty(gitHash) as? String ?: nogit variant.outputs.forEach { output - val versionName variant.prodVersionName ?: 1.0 val flavorName variant.flavorName?.takeIf { it.isNotBlank() } ?: unknown val buildType variant.buildType output.outputFileName MyApp-${channelName}-${flavorName}-${buildType}- v${versionName}-${currentTimestamp()}-${gitHash}.apk } } }这个模板里我特意做了两个“防护”sanitizeFileName()会把空格、中文冒号、斜杠等文件名非法字符替换成下划线。渠道名如果是从 CI 环境变量读进来的经常混进空格和特殊符号不处理的话轻则文件名难看重则在部分文件系统上无法下载。channelName和gitHash都从-P参数读取而不是在 Gradle 脚本里直接执行git命令。原因有二一是 Gradle 配置阶段执行命令行会破坏配置缓存且每次配置都启动一个git进程慢二是 CI 里 Git 的调用方式可能因为工作目录、子模块等问题产生意外不如在外层脚本里取得结果再传进来职责更清晰。CI 侧的命令行调用示例./gradlew assembleRelease \ -PchannelNamemall \ -PgitHash$(git rev-parse --short HEAD)如果你不想在每一个构建命令里都写-PgitHash也可以在gradle.properties里写gitHashlocal本地构建时默认有兜底值CI 里再用命令行覆盖。3.2 多渠道与多输出splits场景下的重命名策略当项目配置了原生多渠道productFlavor时variant.flavorName会自动带上 flavor 名命名模板天然能区分。但如果是用splits按 ABI 拆分 APK情况就复杂一点。splits配置长这样android { splits { abi { enable true reset() include armeabi-v7a, arm64-v8a, x86, x86_64 } } }开启后一个 variant 会对应多个 APK 输出arm64-v8a 一个、armeabi-v7a 一个……。此时variant.outputs.forEach会遍历出多个 output它们使用同一个命名模板文件名就会撞车。比如app-arm64-v8a-release-v1.0.apk和app-arm64-v8a-release-v1.0.apk在同一个输出目录下AGP 直接报错或者后一个把前一个覆盖掉。解决思路是把输出自身的标识拼进文件名。代码里可以这样处理androidComponents { onVariants(selector().all()) { variant - variant.outputs.forEach { output - val baseName buildBaseName(variant) // output.name 在 abi splits 场景下通常是 arm64-v8a、x86 等 val suffix output.name.takeIf { it.isNotBlank() it ! main } ?.let { -${it} } ?: output.outputFileName ${baseName}${suffix}.apk } } }output.name是 AGP 给每个输出分配的内部标识。单输出场景它通常是空字符串或者main多输出场景则是 ABI 或 density 分层名。把它拼进文件名保证每个 ABI 包都有唯一名称。这里有一个实际容易忽略的点很多开发者把splits和productFlavor混在一起用命名模板里又拼了variant.flavorName此时文件名会同时包含 flavor 和 abi非常长但确实不会冲突。只要你确保“同一次构建内每一条 output 的文件名唯一”AGP 就不会报重名错误。建议在命名函数里做一次防御性检查如果拼出来的文件名在同一个 variant 内重复就主动在末尾追加output.name省去了排查时间。3.3 CI 流水线里的常规做法在 Jenkins、GitLab CI、GitHub Actions 之类流水线里APK 命名脚本一般会独立成一个 Gradle 文件比如apk-name.gradle.kts然后在主build.gradle.kts里通过apply(from apk-name.gradle.kts)引入。这样做的直接好处是引擎导出的工程、多个 App 模块、甚至 monorepo 里不同子项目都能共用一套命名规则升级 AGP 时只需要改动一个文件。流水线里我常用的参数约定参数含义示例channelName渠道标识mall、googleplay、huaweigitHash短提交号a1b2c3dbuildNumberCI 构建号88uploadHost分发平台代号firim、qiniubuildNumber在命名模板里有特殊价值它保证每次构建产出的文件名一定不同避免 CI 缓存目录里同名文件互相覆盖也方便后续溯源。命名模板可以加一段val buildNumber project.findProperty(buildNumber) as? String ?: System.getenv(CI_PIPELINE_IID) ?: 0各种 CI 平台注入的构建号环境变量名称不一样GitLab 是CI_PIPELINE_IIDGitHub Actions 是GITHUB_RUN_NUMBERJenkins 是BUILD_NUMBER。兼容写法是先查-P再查环境变量最后默认0。这里我想提醒一句不到万不得已不要在 Gradle 配置里执行外部命令读环境跨平台Windows / Linux / macOS环境下命令行差异害人不浅能用环境变量解决的问题就别发明新轮子。关于时间戳需要做一个取舍。我在模板里用的是LocalDateTime生成的精确时间优势是文件名直观缺点是每次构建只要时间变了任务输出就变up-to-date判断会失效增量构建体验下降。如果团队更看重构建缓存命中率建议只在 release 构建里加时间戳debug 构建保持不含时间的稳定命名。很多工程里的做法是if (variant.buildType release)才附加时间戳debug 保持app-debug.apk这样日常开发循环不会因为文件名变化而频繁触发重新打包。4. AGP 9.0 的新变化从“兼容旧写法”到“只剩新 API”4.1 AGP 9.0 的构建基线要求到了 AGP 9.0Android Gradle Plugin 的构建体系已经彻底告别旧 Variant API。官方迁移文档里明确了几件事旧的android.applicationVariants、android.libraryVariants等 Variant API 不可用gradle.properties里android.experimental.androidVariantApiuse这个过渡开关被移除。项目namespace必须显式声明不再有“从package属性推导”的兼容路径。这一点在 AGP 8 里已经基本要求到位AGP 9 则是彻底收口。Gradle 版本基线跟着抬升JDK 版本要求也水涨船高。如果你的团队还在用 AGP 8.x JDK 11建议在升级到 AGP 9 之前先把 Gradle wrapper 和 JDK 一次性对齐避免多个变量互相干扰排查。换句话说AGP 9.0 对构建脚本的要求不是“多了一套新 API 给你选”而是“只有新 API 一条路”。这其实是一件好事社区里那些混杂了旧写法的教程无论搜出来多靠前只要基于 AGP 8 之前的版本你在 9.0 项目里照抄就必然报错。4.2 AGP 9.0 中androidComponentsAPI 的写法细节AGP 9.0 里上一节给出的 Kotlin DSL 写法依然成立核心 API 没有推翻重来androidComponents { onVariants(selector().all()) { variant - variant.outputs.forEach { output - output.outputFileName generateApkName(variant) } } }我在 AGP 9.0 的项目里实际验证过outputFileName这个属性仍然存在于输出对象上onVariants的选择器语法也没有变化。真正需要留意的反而是几个边角场景如果你在 AGP 9.0 里还要通过output.outputFile直接改 APK 文件路径这个属性已经不存在了必须用outputFileName拼相对路径或者走variant.outputs.single { it.fileName ... }这类对象模型操作。AGP 9.0 对 Kotlin DSL 的支持力度进一步加大Groovy DSL 在新插件里虽然还能用但官方示例文档已经全面转向 Kotlin。新项目建议直接上 Kotlin DSL省得以后迁移。如果需要对 APK 做更底层的处理比如打包后重新签名、重排 zip 条目、注入元数据outputFileName就不够用了得走variant.artifacts的 artifact 定制属于另一个完整话题此处不展开。升级到 AGP 9.0 时我的建议是不要跳版本。具体顺序是先在当前 AGP 8.x 版本上把所有旧 Variant API 迁移到androidComponents确认assemble全链路通过再升级 Gradle wrapper最后升级 AGP 到 9.0。三个动作分三次提交任何一步出了问题定位范围都会小很多。4.3 版本升级清单与常见报错对照为了让你升级时更有底我把 AGP 7 / 8 / 9 三个版本里与 APK 命名相关的要点整理成一张速查表版本旧 Variant API新 androidComponents API过渡开关备注AGP 7.x可用不可用未引入无旧写法能跑建议尽早熟悉新 APIAGP 8.x默认不可用可用android.experimental.androidVariantApiuse老项目可以开开关续命新项目直接上新 APIAGP 9.0不可用唯一方式已移除必须迁移namespace强约束升级过程中最常见的报错是Could not find method applicationVariants() for arguments...。看到这行先检查是不是项目里还残留旧写法然后全局搜applicationVariants、libraryVariants、testVariants全部替换为对应的androidComponents { onVariants(...) }逻辑。第二个常见报错是Cannot set readonly property outputFileName这个多半是把 AGP 8 的output.outputFileName写成了output.outputFile或者错误地使用了未导入的类。检查 import 和属性名按本文示例调整即可。还有一类报错我见过不少次升级 AGP 9 后androidComponents回调里访问variant.versionName报编译错误或者得到 null。这是 AGP 8 起属性拆分导致的改成variant.prodVersionName就能解决。这里再强调一遍新 API 里prodVersionName对应清单里的versionNameversionCode仍然是versionCode不要想当然地沿袭旧属性名。5. 常见问题与排查实录结合 Unity / Cocos / Electron 打包场景5.1 问题速查表把我在各类项目里遇到过的 APK 命名问题汇总成一个速查表方便你直接定位现象原因解决办法applicationVariants报错项目仍使用旧 Variant API迁移到androidComponentsAGP 9 只能这么办文件名没改还是app-debug.apk脚本没写在androidComponents块里或者在afterEvaluate内修改失效检查脚本位置outputFileName赋值必须在onVariants回调内完成outputFileName属性报“只读”使用了旧outputFile或者错误的对象类型改成output.outputFileNamesplits 多 ABI 场景文件名冲突多个 output 使用相同命名模板在文件名后追加output.name或 ABI 标识variant.versionName读取为空AGP 8 属性拆分未使用新属性改用variant.prodVersionNameAPK 名称里的中文/空格乱码未做文件名清洗增加sanitizeFileName正则替换升级 AGP 9 后旧开关失效android.experimental.androidVariantApi被移除移除开关迁移代码debug 构建不命中增量缓存文件名每次都变release 加时间戳debug 保持稳定文件名AAB 文件.aab无法重命名outputFileName只作用于 APK 输出AAB 重命名需要走variant.artifacts或 task 后处理第 8 条值得多说一句。AAB 是 Google Play 主推的发布格式但outputFileName在 AGP 的对象模型里针对的是 APK 产物.aab文件在assembleBundle任务里产出并不会经过variant.outputs的 APK 输出列表。如果你需要给 AAB 重命名最简单的方案是直接在androidComponents回调后监听assembleRelease的任务在doLast里把app-release.aab重命名成目标格式。这个方案虽然不够优雅但胜在稳定不需要和 AGP 内部 artifacts 的 API 纠缠。5.2 Unity / Cocos / Electron 打包链路里的命名坑用 Unity 2022 导出 Android 工程或者用 Cocos Creator 打包 APK 时构建流程有一点特殊Unity 和 Cocos 生成的是“工程 脚本”的组合体导出后你会在android目录下看到一个完整的 Gradle 工程但里面的 AGP 版本是由引擎模板决定的不一定跟着你本地的 AGP 走。这时候如果你直接打开导出的工程在build.gradle里写applicationVariants而引擎模板已经默认 AGP 8编译必挂。更麻烦的是引擎导出的工程里gradle-wrapper.properties的 Gradle 版本、build.gradle里的 AGP 版本和其他库版本往往是“锁死”的组合随便改动一个版本号可能引发更多兼容问题。我的经验是不要直接改引擎生成的build.gradle而是新建一个独立脚本文件apk-name.gradle通过apply from: apk-name.gradle引入到主工程把命名逻辑全部封装在里面。引擎更新时导出的工程可能会覆盖build.gradle但独立脚本文件通常不会被覆盖重新apply即可。这套思路在 Electron Capacitor 这类 WebView 壳项目上同样适用Capacitor 导出的 Android 工程本质也是 Gradle 工程命名逻辑独立出一个文件之后升级 Capacitor 版本时不会丢配置。Cocos Creator 项目里还需要留意一点很多导出模板会自带一个“构建后自动改名”的插件机制如果你同时在 Cocos 的构建面板里配了包名又在外层 Gradle 脚本里改了outputFileName最后产物可能被两次覆盖。排查方式很简单构建完成后看文件时间以及ls -l输出如果名字和预期不一致先关闭引擎自带的改名插件再用 Gradle 脚本统一接管。5.3 我踩过的几个真实坑最后分享几个我在实际项目里踩出来的坑每一个都花了不少时间定位。第一个坑是时间戳导致配置缓存失效。当时我在命名模板里直接用了System.currentTimeMillis()每次构建文件名都不同于是 AGP 任务输入校验认为输出永远变更up-to-date判断失效本地连续打包时速度肉眼可见地变慢。后来把 debug 的命名固定下来只有 release 才加时间戳问题才解决。这里补充一点如果你依赖配置缓存命名模板里的所有外部值时间戳、gitHash、渠道名都必须来自构建输入而不是在配置阶段动态生成。AGP 的配置缓存快照机制会把这些动态值当作不可缓存处理严重时直接报“configuration cache 不兼容”的 warning。第二个坑是prodVersionName和versionName的混淆。有一个项目从 AGP 7 升到 AGP 8 后改了androidComponents但内部函数还在用variant.versionName。按理说应该编译报错但由于项目里同时存在依赖库对 Variant API 的间接调用报错信息被隐藏了最后打出来的包版本号全是null。排查了整整半天最后把打印日志加在onVariants回调里看到prodVersionName有值、versionName为 null才找到根因。所以升级后如果发现 APK 文件名里的版本号变成null第一反应就检查是不是用了旧属性。第三个坑是多个输出重名导致的“玄学”覆盖。某个项目配置了多个 flavor 加 splits命名模板里只拼了 flavor 和 buildType没拼 ABI。构建时 AGP 确实没报错——因为不同 flavor 的输出目录不同但同一个 flavor 下两个 ABI 的 APK 文件名完全相同后产出的直接把先产出的覆盖了。如果只检查目录里最后一个文件根本发现不了少了包。后来我在onVariants回调里给每个 output 加上了output.name再在 CI 里对每个输出的绝对路径做断言确保每个 ABI 包都存在才彻底解决。总的来说APK 命名这件事看似只是几行脚本的小问题但一旦涉及 AGP 版本升级、多渠道多输出、CI 产物收集牵出来的细节就特别多。我个人实际操作的体会是越早把命名逻辑迁到androidComponents上后面升级 AGP 8、AGP 9 时越省心别在旧写法上恋战。如果你正被某个命名问题卡住照着第 5.1 节的速查表逐行对一遍大概率能很快定位到原因。
返回列表