Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Gradle 与工程化

中级面试常问构建系统和工程化能力, 体现你能不能搭 / 维护一个中大型项目. 你做 SDK 对构建, 依赖, 产物体积本就敏感, 这块容易讲出深度.

一, Gradle 基础

  • Gradle: 基于 JVM 的构建工具, 用 Groovy/Kotlin DSL(.kts) 写脚本. Android 用 AGP (Android Gradle Plugin).
  • 构建生命周期三阶段:
    1. 初始化 (Initialization): 确定哪些模块参与构建 (settings.gradle).
    2. 配置 (Configuration): 执行所有 build.gradle, 构建任务依赖图 (Task DAG).这阶段慢会拖累整体.
    3. 执行 (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 版本
JDKGradle 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 的组合另按项目采用的官方插件发布说明核对.

AGPGradle WrapperJDK最高支持 API / compileSdk核验日期官方依据
8.5.28.717API 34 / compileSdk = 342026-08-07AGP 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 -> variantflavor 覆盖, 渠道配置, 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. 它属于构建与依赖迁移问题, 不应与版本适配或隐私标识符主题混为一谈.

  1. 先锁定可回退的分支, 依赖锁定或制品, 清点 Support Library, 测试依赖, 注解处理器和本地 AAR/JAR.
  2. 在 gradle.properties 启用 android.useAndroidX=true; android.enableJetifier=true 只用于历史二进制依赖仍引用旧 Support Library 的过渡期.
  3. 使用 IDE 的 AndroidX Refactor 迁移源码和 XML import, 审核自动修改的包名, ProGuard/R8 规则及反射字符串.
  4. 检查第三方 SDK 是否已提供 AndroidX 版本. Jetifier 只能重写一部分旧二进制引用, 不能修复不兼容 API, 资源冲突, 动态加载或未受支持的字节码, 应尽快替换或升级旧依赖.
  5. 编译全部变体, 执行单元, UI, 升级安装和 release 产物验证; 发布前确认依赖图中不再混入旧 Support Library. 保留旧制品, 版本目录和回滚步骤, 发生兼容问题时可恢复已验证组合.

四, 组件化 / 模块化

中大型项目的核心架构:

  • 为什么组件化: 编译解耦 (改一个模块不全量编), 并行开发, 复用, 可独立运行调试.
  • 分层: app 壳 → 业务模块 (feature) → 基础库 (common/network/ui).
  • 模块通信 (解耦): 模块间不直接依赖, 用路由 (ARouter) + 接口下沉 (sink) + DI 组合; Hilt 更偏依赖注入, 不是页面路由本身.
  • 路由 ARouter 机制:
    1. 业务页面用注解声明路径, 如 @Route(path = "/user/profile").
    2. 编译期 APT 扫描注解, 为每个模块生成路由表类, 记录 path → Activity/Fragment/Provider 的映射.
    3. App 启动或首次使用时加载各模块路由表.
    4. 调用 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 等行为更受限制.
  • 适配排查清单: 先读官方 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.library vs com.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.
  • api vs implementation 的坑 (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; 发版走固定版本号坐标 + 变更记录, 升级前核对宿主兼容性.