Gradle 与工程化
中级面试常问构建系统和工程化能力, 体现你能不能搭 / 维护一个中大型项目. 你做 SDK 对构建, 依赖, 产物体积本就敏感, 这块容易讲出深度.
一, Gradle 基础
- Gradle: 基于 JVM 的构建工具, 用 Groovy/Kotlin DSL(.kts) 写脚本. Android 用 AGP (Android Gradle Plugin).
- 构建生命周期三阶段:
- 初始化 (Initialization): 确定哪些模块参与构建 (settings.gradle).
- 配置 (Configuration): 执行所有 build.gradle, 构建任务依赖图 (Task DAG).这阶段慢会拖累整体.
- 执行 (Execution): 按依赖图执行需要的 Task.
- Task: 构建的最小执行单元, 有输入 / 输出, 支持增量.
- 核心文件:
settings.gradle(.kts)(模块声明),build.gradle(.kts)(模块配置),gradle.properties(全局配置).
二, 依赖管理
- 依赖配置:
implementation: 依赖仍可出现在运行时依赖图中, 但不暴露到消费者的 compile classpath/API surface, 因而有利于编译隔离. 当公共 API 签名暴露该类型时不能使用它来隐藏契约.api: 把依赖暴露给消费者的编译 classpath, 适合公共 API 确实包含该类型的库模块, 但会扩大重编译影响面.compileOnly: 只在当前组件编译时可见, 不进入运行时产物; 适合由宿主 / 平台提供的 API, 不是常规注解处理器配置的同义词.runtimeOnly: 只在运行期需要; 测试使用testImplementation/androidTestImplementation; 代码生成按处理器要求使用ksp或kapt.
- Version Catalog(libs.versions.toml): 集中管理依赖版本, 多模块统一, 现代推荐.
- 依赖冲突: Gradle 默认选最高版本; 可用
resolutionStrategy强制版本,exclude排除传递依赖. - BOM: 统一一组库的版本 (如 Compose BOM).
工具链兼容矩阵
AGP, Gradle, JDK, Kotlin, KSP 与 Compose Compiler 必须作为一个组合维护. 正文不散落 “最新版本”, 项目中记录经过验证的矩阵:
| 维度 | 记录内容 |
|---|---|
| AGP / Gradle | 官方兼容范围与 Wrapper 版本 |
| JDK | Gradle daemon 与 Android toolchain 使用版本 |
| Kotlin / Compose Compiler | 插件版本, language/api version, Compose 编译插件 |
| KSP/KAPT | 各处理器支持的 Kotlin/KSP 版本与迁移状态 |
升级时一次改变一个关键维度, 或提供完整兼容验证和回退点.
核验记录 (2026-08-07): 下表是用于本篇片段的历史教学基线, 不是本仓库已验证的工具链或 “当前最新”.AGP 与 Gradle/JDK/API 的可用组合以 Android 官方 AGP 版本说明 的版本兼容表为依据; Gradle Wrapper 还必须与项目 gradle-wrapper.properties 一致. Kotlin, KSP, Compose Compiler 的组合另按项目采用的官方插件发布说明核对.
| AGP | Gradle Wrapper | JDK | 最高支持 API / compileSdk | 核验日期 | 官方依据 |
|---|---|---|---|---|---|
| 8.5.2 | 8.7 | 17 | API 34 / compileSdk = 34 | 2026-08-07 | AGP 8.5 release notes |
三, 构建变体与产物
- buildTypes: debug / release (混淆, 签名, 是否可调试).
- productFlavors: 多渠道/多环境 (免费版/付费版, 国内/海外), 组合成 variant.
- 签名配置 signingConfigs: 配置 keystore.
- manifestPlaceholders / BuildConfig 字段: 按变体注入不同配置 (如不同 API 域名, key).
工程上下文片段: 应用模块与 Version Catalog
以下是工程上下文片段, 不是可直接运行的完整工程. 它刻意未包含 settings.gradle.kts 的 pluginManagement/dependency repositories, 根插件声明, 应用源码, manifest 与 CI 凭据传递闭环; 虽在模块片段中显式启用 BuildConfig, 仍不能据此承诺 ./gradlew :app:assembleDemoDebug 一定成功. 版本号是示例, 不代表 “当前最新” 或已通过官方矩阵验证; 不要将真实 keystore 密码或服务端密钥写入这些文件.
# gradle/libs.versions.toml
[versions]
androidGradlePlugin = "8.5.2" # 历史教学基线;矩阵限定 Gradle 8.7,JDK 17,API 34
kotlin = "2.0.21"
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
// app/build.gradle.kts
plugins { alias(libs.plugins.android.application); alias(libs.plugins.kotlin.android) }
android {
namespace = "com.example.interview"
compileSdk = 34 // 与上表 AGP 8.5.2 历史教学基线一致
buildFeatures { buildConfig = true }
defaultConfig { applicationId = "com.example.interview"; minSdk = 23; targetSdk = 34; versionCode = 42; versionName = "1.4.0" }
flavorDimensions += "environment"
productFlavors {
create("demo") { dimension = "environment"; applicationIdSuffix = ".demo"; buildConfigField("String", "API_BASE_URL", "\"https://api.demo.example.test\"") }
create("prod") { dimension = "environment"; buildConfigField("String", "API_BASE_URL", "\"https://api.example.test\"") }
}
signingConfigs {
create("release") {
storeFile = providers.gradleProperty("signingStoreFile").orNull?.let(::file)
storePassword = providers.gradleProperty("signingStorePassword").orNull
keyAlias = providers.gradleProperty("signingKeyAlias").orNull
keyPassword = providers.gradleProperty("signingKeyPassword").orNull
}
}
buildTypes {
debug { applicationIdSuffix = ".debug" }
release {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
}
}
}
signingConfigs 应从 CI secret 或开发机未纳入版本控制的属性文件读取. Gradle property 的标准环境变量映射为 ORG_GRADLE_PROJECT_signingStoreFile, ORG_GRADLE_PROJECT_signingStorePassword, ORG_GRADLE_PROJECT_signingKeyAlias, ORG_GRADLE_PROJECT_signingKeyPassword; 它们对应片段中的四个 property 名, 并与第 35 篇一致. debug 不需要这些值, 缺失时 release 构建应在签名校验失败而非回退到 debug 签名.
自测 (在完整工程中执行): 以表中基线将 Wrapper 固定到 Gradle 8.7, 构建 JDK 固定到 17, 并用 compileSdk = 34 运行 ./gradlew :app:assembleDemoDebug; 预期任务解析出 demoDebug 且生成 APK. 运行 ./gradlew :app:assembleProdRelease 时仅通过上述 CI 环境变量提供签名, 预期产物使用 release 签名, 缺任一变量时失败而不使用 debug 签名. CI 输出应记录实际 AGP/Gradle/JDK/API, 并逐项链接到核验日期对应的官方矩阵; 本文不声称本仓库已执行该验证.
targetSdk 迁移是行为变更项目
上下文片段, 不是只改整数的提交模板: 固定目标 API 后阅读对应 behavior changes, 搜索 PendingIntent, 权限, 通知, 前台服务, 存储和隐式 Intent 调用; 在目标系统设备 / 模拟器验证关键路径; 再以灰度指标决定推进或回退. Android 平台与商店 targetSdk 要求会变化, 发布日必须核验官方要求. 构建性能的测量与归因见 Gradle 构建性能专题, 插件和字节码实现见 AGP 插件与字节码工程.
APK / AAB 构建全链路
应用源码和资源从变体选择开始: Gradle 先合并 main, flavor, build type 的源码, 资源和 Manifest, 再由 Kotlin/Java 编译器生成 JVM 字节码. AAPT2 编译和链接资源. 启用 minifyEnabled 时, R8 执行 shrink, optimize 和 obfuscate, 并直接产出 DEX; 未启用时, D8 负责将 class 转换为 DEX. 随后构建工具将 DEX, 编译资源, native 库和 Manifest 打包. APK 交付时先运行 zipalign, 再由 apksigner 使用受控 keystore 完成最终签名; 签名后的 APK 不应再重新对齐.
APK 是可直接安装的单一包或拆分包. AAB 是上传给应用商店的发布格式, 可由 bundletool 在本地生成测试用 APK 集, 或由应用商店按设备配置生成并签名设备 APK. AAB 交付链路不套用 APK 的 zipalign 流程. 因此不能把本地 AAB 当作可直接安装的 APK, 也不能只检查 universal APK 就宣称所有设备交付正确.
| 阶段 | 主要输入 / 输出 | 排查重点 |
|---|---|---|
| 变体与合并 | source set, 资源, Manifest -> variant | flavor 覆盖, 渠道配置, applicationId 与权限是否符合发布地区要求 |
| 编译与资源处理 | Kotlin/Java, XML/资源 -> class, 编译资源 | 编译错误, 资源冲突, 未使用资源与错误限定符 |
| DEX 转换 | 启用 minifyEnabled: R8 shrink/optimize/obfuscate -> DEX; 未启用: D8 class -> DEX | 引用数, keep 规则, 反射/序列化/JNI 入口与 release 回归 |
| 打包 | DEX, 资源, .so, Manifest -> 未对齐 APK 或 AAB | 包内容, ABI 与资源拆分 |
| APK 对齐与签名 | 未对齐 APK -> zipalign -> apksigner 最终签名 APK | 对齐先于最终签名, 签名校验与安装测试 |
| AAB 交付 | AAB -> bundletool 测试 APK 集或商店生成的设备 APK | 不套用 APK zipalign; 验证商店生成的设备 APK 与安装测试 |
体积, 多渠道与 64K DEX
APK 瘦身应先分析制品而非猜测: 检查资源重复与无用资源, ABI 及 native 库, DEX/代码体积, 传递依赖, 图片尺寸和格式. 可用 R8, 资源压缩, ABI/语言/密度拆分和按需功能模块, 但每项调整都要验证反射入口, 设备兼容性和用户实际下载包. 图片应按显示尺寸和解码成本选择, 不只看压缩率.
多渠道使用 productFlavors 与 buildTypes 组合 variant, 可覆盖资源, Manifest placeholder 和 BuildConfig 字段. 渠道差异必须可追踪到业务目的与发布地区; 不得用 flavor 绕过权限披露, 隐私门禁或商店审核. 在 CI 中逐变体核验 application ID, 签名, 域名, 资源, 权限和最终产物.
单个 DEX 的方法引用上限约为 65,536. 当 minSdk 低于 21 且无法通过依赖治理或代码裁剪降到上限时, 历史上需要配置 MultiDex 并在应用启动时安装额外 DEX, 启动和主 DEX 内容都需额外关注. Android 5.0/API 21 及以上由运行时原生支持多 DEX, 但引用膨胀仍会影响构建, 安装和启动成本. 现代优先级是删除无用依赖, 避免重复 SDK, 启用合理裁剪并持续观察方法数, 而不是把 MultiDex 当作无限扩容方案.
Support Library 到 AndroidX 迁移
AndroidX 是 Support Library 的继任命名空间与依赖坐标体系, 例如 android.support.v7.app.AppCompatActivity 迁为 androidx.appcompat.app.AppCompatActivity. 它属于构建与依赖迁移问题, 不应与版本适配或隐私标识符主题混为一谈.
- 先锁定可回退的分支, 依赖锁定或制品, 清点 Support Library, 测试依赖, 注解处理器和本地 AAR/JAR.
- 在
gradle.properties启用android.useAndroidX=true;android.enableJetifier=true只用于历史二进制依赖仍引用旧 Support Library 的过渡期. - 使用 IDE 的 AndroidX Refactor 迁移源码和 XML import, 审核自动修改的包名, ProGuard/R8 规则及反射字符串.
- 检查第三方 SDK 是否已提供 AndroidX 版本. Jetifier 只能重写一部分旧二进制引用, 不能修复不兼容 API, 资源冲突, 动态加载或未受支持的字节码, 应尽快替换或升级旧依赖.
- 编译全部变体, 执行单元, UI, 升级安装和 release 产物验证; 发布前确认依赖图中不再混入旧 Support Library. 保留旧制品, 版本目录和回滚步骤, 发生兼容问题时可恢复已验证组合.
四, 组件化 / 模块化
中大型项目的核心架构:
- 为什么组件化: 编译解耦 (改一个模块不全量编), 并行开发, 复用, 可独立运行调试.
- 分层: app 壳 → 业务模块 (feature) → 基础库 (common/network/ui).
- 模块通信 (解耦): 模块间不直接依赖, 用路由 (ARouter) + 接口下沉 (sink) + DI 组合; Hilt 更偏依赖注入, 不是页面路由本身.
- 路由 ARouter 机制:
- 业务页面用注解声明路径, 如
@Route(path = "/user/profile"). - 编译期 APT 扫描注解, 为每个模块生成路由表类, 记录
path → Activity/Fragment/Provider的映射. - App 启动或首次使用时加载各模块路由表.
- 调用
ARouter.getInstance().build("/user/profile").withString("id", id).navigation()时, 框架按 path 找到目标并完成参数注入 / Intent 跳转.
- 业务页面用注解声明路径, 如
- 接口下沉 (sink): 把跨模块能力抽到公共 API 模块, 例如
:user-api只放UserService接口和数据模型,:feature-order依赖接口而不依赖:feature-user实现. 实现模块通过路由 Provider 或 DI 绑定暴露能力. - 与 Hilt/DI 的区别: 路由解决 “页面 / 服务如何按路径发现和跳转”, DI 解决 “对象依赖如何创建和注入”.组件化里常组合使用: ARouter 做跨模块入口, Hilt 给模块内部或接口实现注入依赖.
// :user-api
interface UserService {
fun currentUserId(): String?
}
// :feature-order 只依赖 user-api,不依赖 feature-user
class OrderViewModel(
private val userService: UserService
) : ViewModel()
五, 工程使用侧的构建卫生
- 构建脚本保持声明式, 避免配置期 I/O, 并让依赖使用
implementation/api语义准确. - 缓存, 并行, KSP/KAPT 和 configuration cache 是测量问题, 不在本篇给出调优结论; 诊断流程见 Gradle 构建性能专题.
- 模块化: 拆模块 + implementation 隔离, 减少改动的重编范围.
- AGP 升级: 新版本构建性能持续优化.
- 产物优化: R8 裁剪, 资源压缩, ABI/语言/密度拆分; Android 行为适配见 Android 版本适配, Compose 性能见 Compose 深水区.
六, 测试
- 单元测试: JUnit + Mockito/MockK(mock 依赖), 测纯逻辑 (ViewModel, UseCase).
- 协程测试:
runTest+TestDispatcher(StandardTestDispatcher/UnconfinedTestDispatcher),Turbine测 Flow. - UI 测试: Espresso(View), Compose Test Rule(
createComposeRule). - 测试金字塔: 大量单元测试 + 适量集成 + 少量 UI 测试.
- 可测试性: 依赖注入 + 接口抽象, 让逻辑可替换 fake/mock, 详见应用架构 - MVVM 与 MVI.
七, Android 版本适配 (高频)
- 运行时权限 (6.0+): 危险权限动态申请; 后续版本继续细分一次性权限, 后台定位, 照片/视频/音频等权限边界.
- 分区存储 Scoped Storage (10/11+): App 只能自由访问自己目录 + MediaStore, 访问其他需 SAF; 具体强制行为和兼容开关与 Android 10/11,
targetSdk有关, 升级时要按官方行为变更表核对. - 后台限制 (8.0+): 后台 Service 受限, 隐式广播限制, 用 WorkManager/JobScheduler; Android 12 (API 31) 起对 target 31+ 的后台启动前台服务限制更严格, 不满足例外条件会抛异常.
- 通知渠道 (8.0+): 必须建 NotificationChannel; Android 13 (API 33) target 33+ 需要申请
POST_NOTIFICATIONS, 用户拒绝后普通通知不可见, 但前台服务仍会在系统规定位置保留可见性. - targetSdk 升级: 每次升级要处理对应行为变更, 不要只改数字.
- Android 12 / API 31: target 31+ 创建
PendingIntent必须显式声明FLAG_IMMUTABLE或FLAG_MUTABLE; 后台启动 FGS, 通知 trampoline, 精确闹钟等也有新限制. - Android 13 / API 33: 通知运行时权限, 细分媒体权限, 部分组件导出 / Intent 安全要求需要排查.
- Android 14 / API 34: target 34+ 前台服务必须声明合适的 foreground service type; 隐式 Intent 发送到应用内部未导出组件, mutable implicit PendingIntent 等行为更受限制.
- Android 12 / API 31: target 31+ 创建
- 适配排查清单: 先读官方 behavior changes → 全局搜索权限/通知/FGS/PendingIntent/存储/API 调用 → 加兼容分支和自动化测试 → 用灰度观察崩溃, ANR, 权限拒绝率.
- 64 位要求: Google Play 自 2019-08 (新应用) / 2021-08 (更新) 起要求包含 64 位 native 库, 仅 32 位会被拒; 具体政策按商店现行文本核验.
八, SDK 工程视角 (library 模块与发布)
本篇其余部分以 App 视角为主. 做 SDK / 设备指纹 / 风控库的人最常被追问 SDK 怎么发版, 怎么不污染宿主, 本节补上 library 工程面.
library 模块与 App 模块的差异
com.android.libraryvscom.android.application: library 模块不产出 APK, 而是生成 AAR (Android Archive), 没有applicationId, 只有namespace; 它不能被独立安装, Manifest 会合并进宿主. App 模块才有applicationId, 签名和启动入口.- 为什么 SDK 用 library 模块: SDK 要被打进宿主的 APK, 天然是库; library 的
consumerProguardFiles能让 R8 keep 规则随 AAR 带给宿主, 由宿主统一裁剪. - 混淆边界: library 自身的
minifyEnabled只影响该库的独立构建; 发布给宿主的 SDK 通常不自行混淆, 而是把必须保留的入口写进consumer-rules, 等宿主打 release 包时由 R8 统一 shrink/obfuscate. SDK 不能替宿主决定混淆策略, 只能声明自己需要保留的入口.
AAR 发布与依赖传递
- 用
maven-publish插件把 AAR 发布到 maven 仓库 (mavenLocal, 私有 Nexus/Artifactory 或 Maven Central). - AAR 里的内容:
classes.jar(编译后的 class, 不是 DEX),AndroidManifest.xml,res/,jni/(native 库),R.txt,consumer-rules.pro(keep 规则); 依赖关系写进随附的pom. - pom 依赖传递:
api依赖发布为 pom 的 compile scope, 会进入宿主编译 classpath;implementation发布为 runtime scope, 只进宿主运行时. 这就是 SDK 控制「哪些依赖泄漏给宿主」的开关.
// sdk/build.gradle.kts (上下文片段, 非完整工程)
plugins {
id("com.android.library")
id("maven-publish")
}
android {
namespace = "com.example.devicefingerprint"
consumerProguardFiles("consumer-rules.pro")
}
publishing {
publications {
register<MavenPublication>("release") {
from(components["release"]) // AGP 生成 AAR + pom, 依赖按 api/implementation 映射 scope
groupId = "com.example"
artifactId = "device-fingerprint"
version = "1.4.0"
}
}
}
consumer-rules 的边界
- SDK 的 keep 规则只覆盖必须保留的入口: 反射调用, 序列化模型 (如 JSON 数据类), JNI 方法, 注解处理器产物, 被宿主或系统反射访问的公共 API.
- 反例 (太宽的 keep): 不要对 SDK 整体 blanket keep. 例如:
-keep class com.example.sdk.** { *; }
这会保留 SDK 所有类及其全部成员: 内部实现类本可被宿主打包时裁剪/混淆, 全 keep 后一律保留, 直接抹掉宿主全局 shrink 的收益, 宿主体积膨胀, 也影响加固. 原则是 keep 收窄到公共 API 与反射/JNI/序列化入口, 内部实现交给 R8.
build-tag / 构建标记
埋点与 SDK 常做编译期注入: 用 buildConfigField 写入 SDK_BUILD_ID/SDK_VERSION_TAG, 或用 manifestPlaceholder 写进 Manifest metadata, 运行时随埋点 / crash 上报, 让线上数据能定位到具体 CI 构建号而不只是版本号. AGP 8.0+ 需要显式开启 buildFeatures { buildConfig = true }.
与宿主冲突
- 依赖冲突: 宿主也可能用 OkHttp/Gson 等, 版本不同时 Gradle 默认取高版本, SDK 若依赖旧 API 会 NoSuchMethodError. 对策: SDK 少引重量级依赖; 版本对齐宿主生态主版本; 仅需编译期可见的用
compileOnly; 必要时exclude. apivsimplementation的坑 (SDK 场景):implementation把依赖留在 SDK 内部 (pom 为 runtime scope), 不泄漏到宿主编译 classpath, 避免把 OkHttp/Gson 强加给宿主;api会暴露依赖并扩大宿主重编译面. 但 SDK 公共 API 签名若暴露某类型, 必须用api, 否则宿主编译期找不到类型.
高频面试题
Q1: implementation 和 api 区别?
implementation 不把依赖暴露到消费者的 compile classpath/API surface, 有助于编译隔离, 但运行时依赖仍可能传递; api 适用于公共 API 签名确实暴露该依赖类型的库模块, 会扩大消费者的编译可见范围和重编译影响面.
Q2: Gradle 构建有哪几个阶段? 哪个容易成为瓶颈? 初始化 (定模块), 配置 (执行所有 build.gradle 建任务图), 执行 (跑 Task).配置阶段会执行所有模块脚本, 模块多 / 脚本重时成为瓶颈, 可用配置缓存优化.
Q3: 为什么要组件化? 模块间怎么通信? 编译解耦 (加快构建), 并行开发, 复用, 独立调试. 模块间不直接依赖, 通过路由 (ARouter)+ 接口下沉 + 依赖注入通信, 避免循环依赖.
Q4: 怎么优化 Gradle 构建速度? 先用 Build Scan/Build Analyzer 区分配置, 执行, 下载和代码生成瓶颈; 再按证据评估构建缓存, 配置缓存, 并行, 模块/API 边界和 KSP 迁移. 每项优化都要在固定工具链组合下做 clean/incremental A/B 对比.
Q5: KAPT 和 KSP 区别? KAPT 为 Java 注解处理器生成 stub; KSP 面向 Kotlin symbol, 可避免部分 stub 成本. 是否更快以及某个处理器是否支持, 取决于具体版本和增量实现, 不能承诺固定倍数.
Q6: 分区存储是什么? 带来什么变化? Android 10+ 限制 App 只能自由访问自己的外部目录和 MediaStore, 访问其他文件需通过 SAF 或 MediaStore API, 不能再随意读写整个外部存储, 提升隐私.
Q7: targetSdk 升级要注意什么?
每个版本有行为变更需适配, 而且很多只对达到对应 targetSdk 的 App 生效. 重点排查权限, 存储, 后台启动, 通知, PendingIntent, 前台服务类型, 隐式 Intent 等; 做法是对照官方 behavior changes 建清单, 搜索代码命中点, 加兼容分支和回归测试, 灰度观察崩溃/ANR/权限拒绝率.
Q8: 你们 SDK 怎么发版? 怎么和宿主避免依赖冲突?
SDK 以 library 模块产出 AAR, 用 maven-publish 发到私有 maven 仓库, pom 记录依赖, 用 api/implementation 控制哪些依赖泄漏给宿主. 冲突上用 implementation/compileOnly 收窄依赖面, 与宿主生态主版本对齐, 必要时 exclude; 发版走固定版本号坐标 + 变更记录, 升级前核对宿主兼容性.