Android 应用开发实战

Kotlin · Jetpack · Compose · Kotlin 速成、Gradle 工程结构、四大组件与生命周期、传统 View 与 Jetpack Compose 双
← 返回教程馆
6阶段
36章节
36源文档
12 周计划配套生态

📊 我的学习进度 本地自动保存

加载中…
🧭 导览0/0
正文0/0
docs 1-80/0
docs 9-160/0
docs 17-240/0
docs 25-300/0
sample0/0
💡 每章卡片右上角「✓ 标记已学」;教程间互链已自动映射到本页章节;代码示例请配合 Gitee 仓库 使用。

📖 系统化 Android 开发教程:从入门到企业级 README.md

一份面向「有面向对象编程基础、但没有 Android 经验」读者的完整学习路径。 主线 Kotlin,Compose 与 View/XML 两套 UI 体系并列讲解; 全书以一个贯穿始终的企业级内容社区 App「TechFeed」为实战载体, 并配套一份可导入 Android Studio 的多模块工程源码(sample/)。


一、读者起点与你能得到什么

需要你具备

  • 至少熟悉一门面向对象语言(Java / Kotlin / C# / Python 均可):类与对象、继承与接口、泛型、异常。
  • 能看懂命令行、会用 Git 做最基本的提交。
  • 一台 8GB 以上内存、可用磁盘 40GB 以上的开发机(Windows / macOS / Linux 均可)。

不需要你具备

  • 任何 Android 开发经验(第 00、01 章就是为你准备的缓冲带)。
  • Kotlin 经验(第 01 章是 Kotlin 速成,够用到能读懂全书)。

读完你能做到

  1. 从零搭建 Android 开发环境,独立创建、运行、调试一个 App。
  2. 熟练使用 Compose 与 View/XML 两套 UI 体系构建界面,并能在存量项目中做迁移与互操作。
  3. 掌握网络、存储、异步、依赖注入四大进阶能力,写出不会阻塞主线程、不会内存泄漏的代码。
  4. 按企业级标准完成分层架构 → 模块化拆分 → 性能优化 → 崩溃监控 → 安全加固 → 构建发布的完整闭环。
  5. 在团队中按规范协作:分支模型、Code Review、静态检查、单元测试与 UI 测试。

二、技术栈与版本基线

[!IMPORTANT] Android 工具链每季度都在演进,本教程只讲原理与结构,不锁死小版本。 一切版本号以工程内的 sample/gradle/libs.versions.toml 为准; 新建工程时请直接用 Android Studio 向导生成的版本,再用 AGP Upgrade Assistant 升级。

类别 基线(2026-09) 说明
Android Studio 最新稳定版 自带 JDK、SDK Manager、AVD Manager
JDK 17 AGP 要求的编译 JDK 版本
AGP / Gradle 9.x / 9.x 构建脚本统一 Kotlin DSL(.kts)
Kotlin 2.4.x(KSP 2.x) 注解处理一律用 KSP,不用 kapt
compileSdk 37 编译期 SDK,可高于 targetSdk
targetSdk 36+ 应用商店有最低要求,取当年要求值
minSdk 26 覆盖绝大多数存量设备
UI(新) Compose BOM + Material3 新功能默认 Compose
UI(旧) View + XML + ViewBinding 存量项目维护 / 互操作
异步 Coroutines + Flow 取代 AsyncTask、RxJava
网络 Retrofit + OkHttp + kotlinx.serialization 拦截器链路、缓存与 Mock
存储 Room + DataStore SQLite 与键值存储
依赖注入 Hilt 基于 Dagger,Android 官方推荐
后台任务 WorkManager 取代 JobScheduler / 自维护 Service
测试 JUnit4/5 + Truth + Turbine + Compose UI Test 见第 21 章

三、学习路线图

flowchart LR
    S0["阶段 0 起步<br/>环境 + Kotlin"]
    S1["阶段 1 入门<br/>结构 / 组件 / UI / 资源"]
    S2["阶段 2 进阶<br/>网络 / 存储 / 异步 / DI"]
    S3["阶段 3 企业级<br/>架构 / 模块化 / 性能 / 稳定 / 安全 / 发布"]
    S4["阶段 4 协作与测试<br/>测试金字塔 / 分支 / CR"]
    S5["阶段 5 实战<br/>TechFeed 全链路"]
    S0 --> S1 --> S2 --> S3 --> S4 --> S5

建议节奏:

  • 零基础(0 → 第 08 章):按章节顺序读,每章做完动手练习再往下走,约 4–6 周。
  • 有其他移动端经验:第 01 章可速读,重点看第 04–07 章的双体系对照,约 2–3 周。
  • 已能做 App、想进阶:直接从第 09 章开始,第 14–20 章是核心,实战章节对照 sample/ 阅读。

四、总目录

分章索引的另一个入口:docs/README.md;配套源码:sample/README.md。

阶段 0 · 起步

章节 标题 关键词
00 开发环境搭建 Android Studio、SDK、AVD、adb、真机调试、Profiler
01 Kotlin 速成与协程预热 空安全、data class、扩展、密封类、协程

阶段 1 · 入门

章节 标题 关键词
02 工程结构与 Gradle 目录树、KTS、版本目录、构建流程
03 应用组件与生命周期 Activity、Fragment、Service、BroadcastReceiver、Manifest
04 UI 基础:View 与 XML 布局、ConstraintLayout、ViewBinding、事件
05 UI 基础:Jetpack Compose 可组合函数、状态提升、重组、Material3
06 列表与导航 RecyclerView ↔ LazyColumn、Navigation 双体系
07 资源、主题与自适应 资源限定符、深色模式、多语言、窗口尺寸类
08 Intent、IPC 与权限 显式/隐式 Intent、运行时权限、Binder/AIDL 概览

阶段 2 · 进阶

章节 标题 关键词
09 网络请求 OkHttp 拦截器、Retrofit、序列化、缓存、Mock
10 数据存储 Room/DAO/Migration、DataStore、文件与加密存储
11 异步处理:协程与 Flow 结构化并发、StateFlow、异常处理、测试调度器
12 依赖注入 Hilt 作用域、模块、限定符、可测试性
13 后台任务与通知 WorkManager、通知渠道、推送通道抽象

阶段 3 · 企业级

章节 标题 关键词
14 分层架构与 MVI UI/Domain/Data、单向数据流、UiState、UseCase
15 模块化与组件化拆分 多模块、依赖倒置、路由、API/Impl 分离
16 Compose 与 View 互操作 渐进式迁移、ComposeView、AndroidView
17 性能优化 启动、渲染、内存、包体、ANR、基准测试
18 稳定性与崩溃监控 崩溃/ANR 采集、符号表、平台接入、灰度
19 安全加固 R8、Keystore、网络安全配置、防逆向
20 构建配置与发布 变体/风味、签名、CI/CD、AAB、上架

阶段 4 · 团队协作与测试

章节 标题 关键词
21 测试策略 测试金字塔、单元测试、Flow 测试、UI 测试
22 团队协作规范 分支模型、Code Review、ktlint/detekt、ADR

阶段 5 · 实战项目 TechFeed

章节 标题 关键词
23 TechFeed 总览 需求拆解、模块图、里程碑、验收标准
24 信息流与详情 列表、分页、详情、双 UI 实现对照
25 登录鉴权与离线缓存 Token 刷新、单一数据源、离线同步
26 推送、监控与收尾 通道抽象、监控接入、安全与性能收尾

附录

章节 标题
27 术语表、API 速查、常见报错速查、延伸阅读

五、贯穿全书的实战项目:TechFeed

一个内容社区 App:浏览技术资讯信息流 → 进入详情 → 登录鉴权 → 收藏与离线缓存 → 消息推送。 它并不复杂,但足以覆盖企业级 App 的全部技术点:

技术点 在 TechFeed 中的落点
网络请求 信息流分页、详情接口、统一错误处理与重试(第 09 章)
本地存储 文章缓存、收藏表、Token 加密存储(第 10 章)
异步 下拉刷新、并发聚合首页数据(第 11 章)
依赖注入 Hilt 多模块装配、测试期替换 Fake(第 12 章)
分层架构 UI / Domain / Data 三层 + 单向数据流(第 14 章)
模块化 5 个 feature 模块 + 8 个 core 模块,feature 间零依赖(第 15 章)
性能 启动耗时、列表卡顿、图片内存、包体积(第 17 章)
稳定性 崩溃/ANR 采集、符号表上传、灰度(第 18 章)
安全 代码混淆、证书绑定、敏感数据加密(第 19 章)
发布 多风味、多渠道包、CI 自动构建(第 20 章)
测试 ViewModel 单测、Repository 测试、Compose UI 测试(第 21 章)
协作 分支模型、PR 模板、Review 清单(第 22 章)

源码位于 sample/,第 23–26 章的代码清单与工程文件逐行对应。


六、全书统一约定

每章结构(8 段式)

学习目标 → 核心概念 → 示例代码 → 常见坑 ⚠️ → 调试技巧 → 最佳实践 → 本章小结 → 动手练习

排版与代码规范

  • 术语首次出现写作「中文(English, 缩写)」,例如「依赖注入(Dependency Injection, DI)」。
  • 中英文之间加空格;类名、API、文件路径一律用反引号包起来。
  • 每个代码块首行注释标明文件路径,例如 // sample/feature/feed/.../FeedViewModel.kt。
  • 关键代码行附中文注释,注释解释为什么这么写,不复述语法。
  • 风险点统一前缀 ⚠️,提示用 > [!TIP],警告用 > [!WARNING]。
  • Java / 旧 API(如 LiveData、AsyncTask)对照只在易混处出现,用可折叠的 <details> 块承载,不占主线篇幅。

关于双 UI 体系

第 04–07 章、第 16 章、第 24 章会同时给出 Compose 与 View/XML 两套实现。 阅读建议:新项目以 Compose 为主,View 部分按需查阅;维护存量项目则反过来。


七、常见问题

Q:需要先学 Java 吗? 不需要。本教程以 Kotlin 为主,Java 只在关键处做对照。但如果你的团队维护大量 Java 老代码,建议额外补 Java 基础。

Q:只学 Compose 可以吗? 短期可以,长期不建议。国内存量 App 绝大多数仍是 View/XML 体系,第 16 章的互操作与迁移是企业里最常遇到的真实场景。

Q:示例工程能直接编译吗? sample/ 按完整可编译的多模块工程组织。但 Android 工具链版本迭代快,首次导入请先看 sample/README.md 的「首次同步检查清单」:确认 JDK、SDK 版本、KSP 与 Kotlin 版本匹配。本教程编写环境无 Android SDK,工程未做过实际编译验证,若遇到版本相关问题请以 Android Studio 的报错提示为准。

Q:章节之间有依赖吗? 阶段 0–1 是线性依赖;阶段 2 内部可按兴趣跳读;阶段 3 建议按序;实战章节(23–26)建议最后读,并对照 sample/ 源码。


八、如何阅读源码与练习

D:/Documents/tencent/workbuddy/learning/
├─ README.md            # 本文件:总目录与学习路线
├─ docs/                # 教程正文 28 个 Markdown
└─ sample/              # TechFeed 多模块工程源码

每章末尾的「动手练习」分三档:基础题(照着做)、进阶题(自己设计)、思考题(理解取舍)。 做完基础题就算通过,进阶题建议配合 sample/ 源码对照思考题一起消化。

祝你写得开心。🚀

📄 android-doc 全量 Review 报告 REVIEW-2026-09-29.md

审查日期:2026-09-29 审查范围:android-doc/ 全部 131 个文件 —— 28 篠教程文档(docs/)+ 双 README + TechFeed 示例工程(sample/,16 个 Gradle 模块:app + benchmark + 9 core + 5 feature + build-logic) 方法:四线并行审查(文档内容 / 构建体系 / core 模块 / feature+app),关键结论均经源码二次核实


一、总评

维度 评价
教程文档(28 章) ★★★★☆ 无 P0,结构完整、一致性极强,但示例代码有约 15 处编译级/逻辑级错误、4 处引用不存在的版本目录别名、多处跨章签名矛盾
架构设计 ★★★★★ core/feature/app 三层 + feature 零依赖 + 依赖倒置(TokenProvider、FeedRepository)是教科书级
示例工程可编译性 ★☆☆☆☆ 当前代码无法通过编译/注解处理,共 11 处编译级阻断(P0)
功能完整性 ★★☆☆☆ 多处核心链路为死代码或名不副实(Token 无感刷新空转、深链仅冷启动、登录页不可达等)
测试 ★★☆☆☆ 基建专业(MainDispatcherRule/Turbine/Truth)但覆盖极低:core 9 模块 0 测试,全工程仅 7 个用例
工程化配套 ★★★★☆ 版本目录纪律、约定插件、CODEOWNERS、CI 拆分、R8 规则、安全默认值均到位

一句话结论:这是一套设计图纸质量远高于实现质量的项目——文档叙事、架构意识、工程规范均属上乘;但 sample 工程从未实际编译过(README 自己也坦承了这一点),导致 11 处编译级错误全部沉淀下来。文档侧无阻断问题,修复优先级应放在 sample 工程的 P0 清单上。


二、P0 —— sample 工程编译/装配阻断(11 处,全部经源码核实)

A. 构建体系(6 处)

# 位置 问题 修法
1 build-logic/convention/build.gradle.kts:19-24 + StaticAnalysisConventionPlugin.kt:8-9 detekt/ktlint 扩展类不在编译 classpath(只 compileOnly 了 AGP/Kotlin/KSP),build-logic 复合构建编译失败 补 compileOnly(libs.detekt.gradlePlugin) 及 ktlint 插件别名+依赖
2 根 build.gradle.kts + StaticAnalysisConventionPlugin.kt:18-21 按字符串 ID apply ktlint/detekt,但两个插件不在宿主 classpath(根脚本未 alias(...) apply false,toml 中别名声明了却无人引用)→ 运行时 "Plugin not found" 根脚本补 alias(libs.plugins.ktlint) apply false 等
3 AndroidComposeConventionPlugin.kt:17 硬编码 extensions.configure<LibraryExtension>,而 :app 应用了该插件(注册的是 ApplicationExtension,平级类型无继承)→ 应用即抛异常 改为 findByType<ApplicationExtension>() ?: findByType<LibraryExtension>() 或统一 CommonExtension
4 benchmark/build.gradle.kts:4-5,25 用 android.library 插件却访问 targetProjectPath(com.android.test 专属属性)→ 脚本编译失败;且整体绕过约定插件、benchmark 依赖全被注释 换 com.android.test 插件或先移除该模块
5 sample/gradle/ Gradle Wrapper 完全缺失(无 gradlew/wrapper.properties),CI 的全部 ./gradlew 命令与 README 指引必然失败,Gradle 版本(AGP 9 需 9.x)不受锁定 gradle wrapper --gradle-version 9.x
6 app/build.gradle.kts:38-39 + TechFeedApplication.kt:22 定义了 buildConfigField 且代码读 BuildConfig.DEBUG,但 app 模块未开启 buildFeatures { buildConfig = true }(AGP 8+ 默认 false)→ BuildConfig 不生成,编译失败 app 模块或 Application 约定插件中开启

B. 代码装配(5 处)

# 位置 问题 修法
7 core/network/build.gradle.kts:21 + NetworkModule.kt:14,40 main 源集引用 HttpLoggingInterceptor,依赖却只有 debugImplementation → release 变体编译失败 改 implementation(libs.okhttp.logging)(代码已用 BuildConfig.DEBUG 控制级别)
8 core/network/.../FeedRemoteDataSource.kt + NetworkModule.kt TechFeedRemoteDataSource 有 @Inject 但无 @Binds 绑定到接口;feature 层按接口注入 → Hilt missing binding 编译失败 network 模块补抽象 @Binds 模块
9 TechFeedApplication.kt:20 调用 NotificationHelper.ensureChannels(this),但缺 com.techfeed.feature.push.NotificationHelper import(跨包)→ Unresolved reference 补 import
10 feature/push/.../SyncFeedWorker.kt:9,21 + feature/push/build.gradle.kts Worker 注入 FeedRepository(feature:feed 的类型),但 push 模块未声明该依赖 → 编译失败;同时违反项目自己强调的 "feature 间零依赖" 原则 接口下沉 core,或由 :app 装配
11 LocalPushChannel.kt:10 + PushModule.kt:22 LocalPushChannel 无 @Inject 构造函数却参与 @Binds → Dagger/MissingBinding 注解处理失败 补 @Inject constructor()

说明:README 的"首次导入检查清单"已坦承工程未实际编译验证——本次审查证实该预判准确,上述 11 处即沉淀的证据。


三、P1 —— 功能缺陷(sample 工程核心链路)

网络/鉴权(影响核心承诺)

  1. TokenAuthenticator "无感刷新"空转(core/network/TokenAuthenticator.kt:28-39):Mutex 内只读回旧 token 重发,从未调用刷新接口(TokenProvider.refreshToken() 全工程零调用,是死 API)。实际行为 = 重试一次必然再失败。
  2. 登录凭据走 URL query(TechFeedApi.kt:30-31):@POST + @Query 把用户名密码放进 URL,会进服务端日志/代理/自家 BODY 级日志。对第 19 章讲安全的教程是反例。应改 @Body。
  3. BODY 级日志未脱敏 Authorization 头(NetworkModule.kt:40-46):缺 .redactHeader("Authorization")。

UI 可用性(决定 demo 体验)

  1. Feed 加载指示器永不显示(FeedScreen.kt:51-53 + FeedViewModel):UI 判断 state.isLoading,但 VM 只设置 isRefreshing(且无人消费,无 PullToRefresh)→ 冷启动白屏无任何反馈。
  2. 底部导航选中态永假(TechFeedApp.kt:55-57):类型安全路由的 route 是全限定名,与 toString().substringAfterLast('.').lowercase()("feedroute")永不相等 → 三个 tab 永不高亮。
  3. LoginRoute 不可达:导航图注册了登录页,但全仓库无任何 navigate(LoginRoute) 调用,事件分支是 -> Unit → 登录页是孤岛,auth 模块整套无法触达。
  4. LoginViewModel 成功后 isLoading 卡死(LoginViewModel.kt:53-57):成功分支不复位 loading;叠加 MutableSharedFlow() 无 replay,事件竞态丢失时按钮永久转圈。
  5. 非紧凑屏没有任何导航 UI(TechFeedApp.kt:52):Medium/Expanded 下 bottomBar 为空且无 NavigationRail → 平板/横屏无法切换页面。
  6. toggleFavorite 静默失败(FeedViewModel.kt:82-86):注释声称"失败由 error 流暴露",但 error 流不存在,断网点收藏无任何反馈。

后台任务/深链

  1. SyncScheduler 全工程零调用:周期同步从未被调度;showMessageNotification 同样零调用 → push 模块除 ensureChannels 外全是死代码。
  2. 通知点击不深链(NotificationHelper.kt:45-47):注释说深链到文章,实际只构造 launcher intent。
  3. onNewIntent 空实现(MainActivity.kt:32-36):App 前台/后台点深链无任何反应;NavHost 的 composable 也没注册 deepLinks 参数 → 深链仅冷启动有效,且返回栈断裂(直接退出应用)。

稳定性/存储

  1. AnrWatchDog 三重缺陷(core/crash/AnrWatchDog.kt):判定在 threshold/2 即触发(阈值缩水);阻塞期间回调洪泛(无去重标志);无法停止(重复调用即多个 watchdog 并发误报);mainThreadStack() 是死代码且引用疑似不存在的 Debug.getThreadTid API。
  2. CrashStore 补传闭环缺失:pendingReports() 无调用方,crash 文件无限累积无清理。
  3. SecureTokenStorage 主线程做 Keystore+磁盘 IO(SecureTokenStorage.kt:20-28):加密本身正确(MasterKey AES256_GCM),但构造注入发生在主线程,叠加 StrictMode 检测大概率违规。
  4. CI "全模块单元测试" 只跑 :app(ci.yml:47-48):testProdStoreDebugUnitTest 任务名只在 app 存在(flavor 仅 app 声明),feature 模块测试永远不会在 CI 运行。
  5. Coil3 缺网络引擎:coil-network-okhttp 在 toml 声明但无模块使用,UI 用了 coil3.compose.AsyncImage → 运行时网络图全部加载失败(虽然当前 AsyncImage import 未使用,coverUrl 未渲染——两个问题互相掩盖)。
  6. .gitignore 忽略 schemas/ 与 Room schema 入库策略自相矛盾(.gitignore:23-24):注释说要提交 schema,模式却恰好把它忽略。

四、文档(docs/)问题

P0:无。全书可读、无断链、无乱码、无未完成段落。

P1(应修复)

A. 引用了 libs.versions.toml 中不存在的别名(4 处,照抄即 Unresolved reference)

位置 别名
18-stability-crash-monitoring.md:~166 libs.sentry.android
20-build-variants-release.md:~341 libs.huawei.push
21-testing-strategy.md:165 libs.androidx.core.testing(注释中 "ArchivedTaskExecutor" 亦为笔误,应为 InstantTaskExecutorRule)
21-testing-strategy.md:843-846 libs.plugins.kover(注释"版本在 toml 收口"与事实不符)

B. 示例代码编译级/逻辑级错误(要点)

  • 04-ui-view-xml-basics.md:~52,369:顶层扩展函数访问不存在的 resources;Int 接收者调 Float 扩展——两处均无法编译。
  • 12-dependency-injection-hilt.md §2.12:匿名 object 继承 final class;对 WhileSubscribed 的 StateFlow 无收集者断言非空——断言必失败。
  • 13-background-work-notifications.md §2.7/2.8:Fake 继承带必填构造的 final 类;dispatch 的 if/else 两分支均走本地通知(远程分支死代码)。
  • 17-performance-optimization.md §2.1:startup_config.xml 是无效 XML(<resources> 内放占位元素),正确机制是 Manifest 的 tools:node="remove"。
  • 19-security-hardening.md:~477:顶层函数标 @Singleton 且不在 @Module 内——Hilt 不会装配。
  • 25-techfeed-auth-offline.md:131-144:TokenAuthenticator 文档版同样不调用刷新,且并发测试断言 refreshCount == 1 与 Mutex 实现(只串行化不去重,实际为 3)矛盾——文档与 sample 的 P0-3 同源。
  • 09/10/12 章:DI Provider 缺 File 绑定、Room 测试用 ApplicationProvider 却放 src/test、测试示例含必错代码等。

C. 跨章矛盾(同一类在不同章给出冲突实现)

  • NetworkModule 在第 09 章(完整版)与第 12 章(简化版)声称同一路径却内容不同;
  • EncryptedSharedPreferences 第 10 章称"已进维护状态、推荐替代",第 19/25 章却作为标准方案——立场相反;
  • GetFeedUseCase 第 11 章当接口、第 12 章是 final 类;
  • FeedViewModel 构造签名全书出现四个版本(11/12/21/24 章),未标注演进关系,读者按第 11 章写的测试在第 24 章工程里无法编译。

低成本化解方案:在涉及演进的章节开头加一句"本章对第 X 章的 Y 进行了重构"。

P2(择要)

  • 第 27 章标注的 docs/samples/ch27/* 文件实际不存在,与第 23 章"每个文件都真实存在于 sample/"的承诺不符;
  • 第 22 章 .versionrc.json 代码块用 // 注释(JSON 不支持);根 build.gradle.kts 片段缺 detekt classpath(与 sample 的 P0-1/P0-2 同源);
  • 深度失衡:第 09-13 章动辄 1000+ 行,收官实战章(23-26)仅 254-303 行,学习者拼不出完整可运行代码;
  • 第 26 章练习编号重复、apkalyzer 应为 apkanalyzer;第 21 章测试断言与意图矛盾(断言未截断);第 06 章 API 命名与第 09 章不一致等。

文档整体优点(值得保持)

  1. 28 章 + 双 README 目录与实际文件零偏差,"环境 → Kotlin → … → TechFeed 实战 → 附录"的认知曲线合理无缺口;
  2. 一致性执行罕见:全书统一 8 段式章节结构、术语首现带英文、代码块标注 sample/ 真实路径、交叉引用抽查全部正确;
  3. 版本纪律好:正文版本号与 libs.versions.toml 一致(仅 4 处别名例外);
  4. 第 11 章(协程/Flow,1320 行)与第 21、22 章达到企业内训/出版水准——CancellationException 永不吞、WhileSubscribed(5000) 动机、"Fake 优先于 Mock"等论述属中文同类资料上乘。

五、sample 工程其他亮点与 P2 择要

亮点:模块治理(16 模块 include 与磁盘零偏差,feature 零依赖仅在 :app 聚合);TokenProvider 依赖倒置教科书级;Room Flow SSOT 响应式链路完整;测试基建(MainDispatcherRule/Turbine/Truth)专业;安全默认值到位(allowBackup=false、cleartext=false、token 真加密、userId 哈希脱敏);R8 规则覆盖官方推荐项。

P2 择要:

  • 测试缺口:core 9 模块 0 测试;app 声明了整套 androidTest 依赖但一个 androidTest 都不存在;Feed 的 combine 过滤、.catch 分支等核心逻辑零覆盖。
  • core/testing/FakeFeedRemoteDataSource 未实现目标接口、缺方法,无法作为测试替身(应改 testFixtures 或接口下沉)。
  • Room:ArticleEntity 无任何索引(isFavorite/cachedAt 均全表扫);upsertKeepingFavorite N+1;clearExpiredCache 从未调用。
  • UI:全项目零 @Preview;硬编码中文文案弃用了已定义的 strings.xml;observeFeed(query) 参数被静默忽略;详情页不落库断网必挂;windowBackground 与 Compose 令牌色差不一致。
  • 其他:Result 与 kotlin.Result 双轨;model 模块注释称纯 JVM 却用 Parcelable;多处未使用依赖/别名(viewpager2、paging、robolectric 等);configureondemand=true 与 configuration-cache 兼容性存疑。

六、修复路线图(建议顺序)

第一批 · 让工程编译通过(半天)

  1. 生成 Gradle Wrapper(#5)→ 2. 修 build-logic classpath + 宿主 apply false(#1/#2)→ 3. Compose 插件改 CommonExtension(#3)→ 4. app 开 buildConfig(#6)→ 5. logging 拦截器改 implementation(#7)→ 6. 补 FeedRemoteDataSource @Binds(#8)→ 7. 补 NotificationHelper import(#9)→ 8. FeedRepository 接口下沉解耦 push→feed(#10)→ 9. LocalPushChannel 补 @Inject(#11)→ 10. benchmark 模块二选一处理(#4)。完成后全量 assembleDebug + testDebugUnitTest 验证。

第二批 · demo 可用性(1-2 天) isLoading/selected 永假修复、LoginRoute 可达、TokenAuthenticator 真实刷新、登录凭据改 @Body、日志脱敏、通知深链 + onNewIntent、SyncScheduler 接线、CI 测试任务改 ./gradlew test。

第三批 · 文档一致性(1 天) 补 4 处 toml 别名、修 dpToPx/测试示例等编译级错误、为 FeedViewModel 四个签名版本加演进标注、统一 EncryptedSharedPreferences 立场、补 ch27 引用文件或改标注。

第四批 · 长线质量 core 层补测试(DAO/safeCall/TokenAuthenticator 优先)、Room 索引与 N+1、AnrWatchDog 重写、Preview 与 strings 资源化、testFixtures 化 Fake。


报告完。各问题的精确行号与代码引证见审查过程记录;如需任何单项的详细修复 patch,可指定问题编号继续。

📄 android-doc 全量修复记录 REVIEW-FIXES-2026-09-29.md

日期:2026-09-29(紧随全量 review,见 REVIEW-2026-09-29.md) 范围:sample 工程全部 P0/P1 与高价值 P2 + 文档全部 P1 与主要 P2


一、sample 工程

批次 1 · 构建体系 P0(6/6 修复)

# 修复 涉及文件
1 补齐 Gradle Wrapper(Gradle 9.1.0 = AGP 9.0 最低要求;官方 jar/脚本 + properties) sample/gradlew(.bat)、gradle/wrapper/*
2 build-logic 补 detekt/ktlint 插件 compileOnly 依赖 build-logic/convention/build.gradle.kts
3 根脚本补 ktlint/detekt/android-test 的 apply false sample/build.gradle.kts
4 Compose 约定插件改为 findByType 按模块类型查找(:app 不再崩溃) AndroidComposeConventionPlugin.kt
5 benchmark 模块改用 com.android.test 插件 + missingDimensionStrategy + 宏基准依赖 benchmark/build.gradle.kts
6 Application 约定插件开启 buildConfig AndroidApplicationConventionPlugin.kt
附 detektPlugins 改用 detekt-formatting 规则集;Lint 配置迁移到新 DSL(弃用 BaseExtension);14 个 library 模块补 consumer-rules.pro;.gitignore 不再忽略 schemas/、移除 !benchmark/build/;gradle.properties 注释掉 configureondemand;CI 单测任务改为 testDebugUnitTest testProdStoreDebugUnitTest(真正覆盖全模块);app 脚本去重(ksp/compose BOM/bundle/datastore) StaticAnalysisConventionPlugin.kt 等

批次 1 · 代码装配 P0(5/5 修复)

# 修复
7 HttpLoggingInterceptor 改 implementation(release 可编译),并加 redactHeader("Authorization"/"Cookie") 脱敏
8 新增 DataSourceModule:@Binds FeedRemoteDataSource(接口注入可装配)
9 TechFeedApplication 补 NotificationHelper import
10 SyncFeedWorker 重写为注入 FeedRemoteDataSource + ArticleDao(解除 push→feed 跨 feature 依赖,保住模块化红线)
11 LocalPushChannel 补 @Inject constructor()

批次 2 · P1 功能(全部修复)

  • TokenAuthenticator 真实刷新:Mutex + 双重检查(并发 401 只刷一次)+ 独立裸 OkHttpClient 调 POST v1/auth/refresh(防递归)+ priorResponse 计数限重试 + tokenProvider.refresh() 落盘;新增 NetworkConfig.BASE_URL 单一真相
  • 登录凭据改 @Body LoginRequestDto(不再走 URL query);新增 refresh 端点定义
  • Feed 可用性:isLoading 首次加载置位(进度条真正出现);错误态带重试按钮;空态引导文案;封面 AsyncImage 渲染
  • 底部导航选中态:hasRoute(KClass) 正确匹配类型安全路由;非紧凑屏补 NavigationRail
  • LoginRoute 可达:401 → NavigateToLogin 事件 + 收藏页空态登录按钮,双入口
  • LoginViewModel:成功分支复位 isLoading
  • 深链闭环:通知点击 ACTION_VIEW techfeed://article/{id};暖启动 addOnNewIntentListener 分发;冷启动深链改为 Feed→Detail 入栈(返回键回信息流);空 articleId 校验
  • SyncScheduler 接线(Application.onCreate)+ watchAnr() 接线
  • AnrWatchDog 重写:完整阈值判定、reported 去重、stopWatchdog();CrashReporter 支持 install 安全转型、watchAnr 先停旧、启动时 dispatchPendingReports;CrashStore 上限 10 份 + clearPending
  • SecureTokenStorage:双重检查懒加载 + AuthInterceptor runBlocking(Dispatchers.IO) + LoginUseCase withContext(IO) 落盘
  • Coil3 网络引擎:feed/detail/profile 三模块补 coil-network-okhttp

批次 4 · 高价值 P2

  • Room:ArticleEntity 补 isFavorite/cachedAt 索引;upsertKeepingFavorite 改批量(2 查询 + upsertAll,消灭 N+1);clearExpiredCache 在 refresh 后接线(7 天策略)
  • observeFeed(query) 真正 DB 级过滤,VM 改 flatMapLatest 订阅;FeedViewModel 改用官方 flow.update,删除自造扩展
  • FeedViewModelTest 扩到 7 例(新增:搜索过滤链路、401→登录事件、加载态复位);Fake 遵守 query 契约
  • FakeFeedRemoteDataSource 实现真实接口;runSuspendCatching 改 suspend 签名
  • strings.xml 资源化(tab 文案);windowBackground 与 Compose surface 令牌同色(消除冷启动闪色);收藏图标动态 contentDescription;AppModule 清理过时注释
  • sample/README 检查清单更新(wrapper 已补、schemas 不再被忽略)

二、docs 文档(28 章全部处理,P1 全清、P2 主要清)

别名缺失(4/4):sentry-android / huawei-push / androidx-core-testing / kover 全部落入 libs.versions.toml,文档引用全部可解析("ArchivedTaskExecutor" 笔误 → InstantTaskExecutorRule)。

编译级示例错误(全部修复):ch01 Article 补 publishedAt、ch04 dpToPx 两处、ch05 listSaver 标准形式、ch06 Paging API 与 import、ch09 DI File 绑定 + redactHeader + BASE_URL、ch10 测试移 androidTest + observeFeed(query) 对齐、ch11/12/13 的 Fake 继承 final 类与测试必错断言、ch17 无效 XML 改 Manifest 机制、ch18 SentryAndroid.init 上下文、ch19 顶层 @Singleton 包进 @Module、ch21 截断断言/重复依赖/toDisplayDate 出处、ch22 JSON 注释与 detekt classpath、ch26 apkanalyzer 与练习编号。

跨章矛盾(全部化解):FeedViewModel 四版签名加演进标注(11/12/14/21 → 24 收敛);ch12 NetworkModule 标注"DI 节选,完整版见 ch09";ch09 TokenAuthenticator 加"基础版,实际实现见 ch25"指引;EncryptedSharedPreferences 三章立场统一(维护状态但沿用,迁移属进阶练习);ch20/21/22 变体命名统一 prodStore 风格;ch21 DAO 测试对齐 observeFeed(null);ch22 detekt 路径对齐 sample 实际。

实战章对齐(ch23-26):TokenAuthenticator 全段重写(含并发测试双重检查语义)、SyncFeedWorker/SyncScheduler/NotificationHelper/TechFeedApplication 清单与 sample 最终形态逐行对齐、FeedViewModel/FeedScreen 对齐(flatMapLatest/isLoading/401 事件/重试/封面)、LoginUseCase/LoginViewModel/SecureTokenStorage 对齐、AnrWatchDog/CrashReporter 对齐、benchmark 与 CI 对齐。

悬空引用(全部落地):新建 docs/samples/chXX/ 共 121 个示例文件——全书所有 文件路径: 标注现在均指向真实存在的文件(sample/ 或 docs/samples/);ch10 的文档自洽版 DAO 等 4 处加"教学简化版"说明;纯片段 3 处改"教学示例"措辞。

三、遗留项(不影响编译与阅读,建议后续单独立项)

  1. ch09 网络章的"信封版"教学叙事(ApiResponse + FeedApi + safeApiCall)与 sample 最终形态(TechFeedApi + FeedPageDto + safeCall)是两套并存的演进式写法,已有演进指引,但深度统一需重写约半章。
  2. View 体系的 sample 落地:ch04/06/14/16/24 的 View 对照代码现均在 docs/samples/ 下;若希望 sample 工程本身也包含 View 体系模块(FeedFragment/ArticleAdapter 等),影响面大,建议单独立项。
  3. 未实际编译验证:本机无 Android SDK/Gradle 环境,所有修复经人工交叉核对但未经 assembleDebug 实测;首次真机导入仍按 sample/README 检查清单排查。
  4. core 层测试仍偏薄(database/network 各自的 test 依赖已声明但测试文件未写);LoginViewModel 测试需先为 LoginUseCase 抽接口。

📄 第 00 章 开发环境搭建:Android Studio、SDK 与调试 00-environment-setup.md

阶段:起步 | 预计阅读 40 min | 代码落地模块:无(本章产出第一个可运行 App) 相关章节:第 01 章 Kotlin 速成 | 第 02 章 工程结构与 Gradle

学习目标

读完本章,你应该能够:

  • [ ] 独立完成 Android Studio、JDK、Android SDK 的安装与配置,并能解释三者之间的关系。
  • [ ] 创建并启动一个模拟器(AVD),也能用一台真机跑通调试。
  • [ ] 创建第一个 App 并成功运行到设备上。
  • [ ] 熟练使用 Logcat 过滤日志、用 adb 排查安装与启动问题。
  • [ ] 知道在国内网络环境下该配哪些镜像源,避免"卡在下载"上浪费两小时。

一、核心概念

1.1 三层工具链的关系

初学者最容易混淆的就是这一堆名词。记住这张图就够了:

flowchart TB
    subgraph IDE["Android Studio(IDE)"]
        A["代码编辑 / 布局预览 / 调试器 / Profiler"]
    end
    subgraph Build["构建层"]
        B["Gradle(构建工具)"]
        C["AGP(Android Gradle Plugin)"]
        D["Kotlin 插件 / KSP"]
    end
    subgraph SDK["Android SDK(平台层)"]
        E["platform-tools → adb"]
        F["platforms → android-37"]
        G["build-tools → 编译打包工具"]
        H["emulator / system-images"]
    end
    subgraph Device["运行载体"]
        I["模拟器 AVD"]
        J["真机(USB / Wi-Fi 调试)"]
    end
    A --> B --> C --> G
    C --> F
    A --> E --> I
    A --> E --> J

一句话总结:Android Studio 是编辑器,Gradle + AGP 负责把代码变成 APK,Android SDK 提供编译和运行所需的平台与工具,设备负责跑起来。

概念 是什么 常见误区
JDK Java 开发工具包,Gradle 与 Kotlin 编译器跑在它上面 "我要写 Java 才需要 JDK" —— 错,写 Kotlin 同样需要
Android SDK 各 Android 版本的 API + 构建工具 + adb "装了 Studio 就有了" —— SDK 需要单独下载对应版本
AGP Android Gradle Plugin,Gradle 的 Android 插件 版本必须与 Gradle 版本匹配,否则同步直接失败
AVD Android Virtual Device,模拟器实例 只是个配置,真正跑的是 emulator + 系统镜像
adb Android Debug Bridge,电脑与设备通信的桥梁 所有"装不上 / 起不来"的问题,一半都靠它排查

1.2 为什么强调 JDK 版本

AGP 要求固定的最低 JDK 版本(当前基线为 JDK 17)。版本不匹配时你会看到类似报错:

Android Gradle plugin requires Java 17 to run. You are currently using Java 11.

[!TIP] Android Studio 自带一个 JDK(称为 Gradle JDK 或 Embedded JDK)。新手不要自己下载 JDK 再手动指路径,直接在 Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK 选择 jbr-17 即可。


二、操作步骤

2.1 安装 Android Studio

  1. 从官网下载最新稳定版安装包(Windows 为 .exe,macOS 为 .dmg,Linux 为 .tar.gz)。
  2. Windows 安装时路径不要包含中文和空格,例如用 D:\Android\Android Studio。中文路径会导致 Gradle 同步出现各种诡异错误。
  3. 首次启动选择 Standard 安装类型,让它自动下载 SDK、模拟器与必要组件。
  4. 中途出现的 "Android SDK License" 全部 Accept。

[!WARNING] 安装目录、SDK 目录、项目目录、Gradle 缓存目录,全部不要出现中文、空格或特殊字符。这是国内开发者踩得最多的坑之一。

2.2 SDK Manager:装什么

Settings → Android SDK 或工具栏的 SDK Manager 图标,打开后按以下勾选:

  • SDK Platforms:勾选你需要的版本。至少要装 targetSdk 对应版本(如 Android 16 / API 36)和一个低版本(如 API 26)用于兼容性验证。
  • SDK Tools(务必勾选):
    • Android SDK Build-Tools(编译打包)
    • Android SDK Platform-Tools(含 adb)
    • Android SDK Command-line Tools(含 sdkmanager、avdmanager)
    • Android Emulator
    • Google Play services(需要地图/推送时)
    • NDK / CMake(只在做 C/C++ 开发时才装,体积巨大)

对应的命令行写法(服务器上搭建 CI 时会用到):

# 用命令行工具安装指定组件(无图形界面环境必备)
# 路径:sdkmanager 位于 $ANDROID_HOME/cmdline-tools/latest/bin/ 下
sdkmanager --install "platforms;android-36" "build-tools;36.0.0" "platform-tools"

# 查看已安装与可安装的全部组件
sdkmanager --list

# 接受所有许可证(CI 环境必须,否则会卡在交互提示)
yes | sdkmanager --licenses

2.3 国内网络环境配置

Gradle 默认从 services.gradle.org 与 Google Maven 拉取依赖,国内常常超时。两种解决方式:

方式一:配置 gradle.properties(推荐,随项目走)

# 文件路径:<项目根>/gradle.properties
# 分配更大内存,避免大工程 OOM 与频繁 GC
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
# 开启并行构建与配置缓存,大幅缩短构建时间
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
# 按需配置模块(多模块工程收益明显)
org.gradle.configureondemand=true
# AndroidX 必需
android.useAndroidX=true
# 非传递性 R 类(多模块下减少 R 文件体积、加快编译)
android.nonTransitiveRClass=true

方式二:镜像源

在 settings.gradle.kts 中声明仓库(见第 02 章),把 google() / mavenCentral() 换成国内镜像地址即可。注意:镜像源可能滞后,遇到"某个新版本依赖拉不到"时先切回官方源验证。

[!TIP] 判断是"网络问题"还是"配置问题"的最快方法:看报错是 Could not resolve ...(依赖拉不到,网络/源问题) 还是 Could not find method ...(DSL 写法问题,版本不匹配)。

2.4 创建模拟器(AVD)

Device Manager → Create Device:

  1. Hardware:选 Pixel 系列即可(有 Play Store 标记的可直接登录 Google 账号)。
  2. System Image:选 Tiramisu 及以上的 x86_64 镜像,ABI 必须匹配你的 CPU。
    • Intel CPU:开启 BIOS 中的 VT-x,装 HAXM 或直接用 Windows 的 WHPX。
    • Apple Silicon Mac:选 arm64-v8a 镜像。
    • AMD CPU:需开启 SVM 并使用 Windows Hypervisor Platform。
  3. AVD 配置:RAM 建议 2GB 以上;打开 Enable Device Frame 便于截图;Graphics 设为 Automatic(若启动黑屏则改为 Software)。

启动后用 adb 验证:

# 列出当前连接的所有设备与状态
adb devices
# 期望输出:emulator-5554   device   (状态必须是 device,offline/unauthorized 都有问题)

真机调试(推荐,性能与真实度都远好于模拟器):

  1. 手机 设置 → 关于手机 → 版本号 连点 7 次打开开发者选项。
  2. 开发者选项中打开 USB 调试,部分厂商还需打开"USB 安装""USB 调试(安全设置)"。
  3. 用数据线连接,手机上授权弹窗点"允许"。
  4. Android 11+ 支持无线调试:开发者选项 → 无线调试 → 使用配对码配对设备,然后:
# 无线调试配对(Android 11+),ip:port 在手机"无线调试"页面查看
adb pair 192.168.1.20:37211
# 配对成功后连接
adb connect 192.168.1.20:41573

2.5 创建并运行第一个 App

File → New → New Project → Empty Activity(或 Empty Compose Activity),填写:

字段 建议值 说明
Name HelloAndroid 应用名
Package name com.example.helloandroid 反向域名 + 应用名,上架后不可改,务必想好
Save location 无中文无空格路径 见上文警告
Minimum SDK API 26 教程基线
Build configuration language Kotlin DSL (build.gradle.kts) 新工程一律用 KTS

点击 Run ▶,选择设备,等待 Gradle 同步 + 构建 + 安装。第一次会慢(几分钟),后续增量构建通常几秒到几十秒。


三、示例代码:第一个可运行的界面

新建工程默认生成一个 Activity 和布局。我们把它改成能体现"数据 → 界面"的最小闭环。

Compose 版

// sample/.../MainActivity.kt(示意:任何 Empty Compose Activity 工程都可直接替换 MainActivity)
package com.example.helloandroid

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 让内容绘制到系统栏之下,实现沉浸式(Android 15+ 默认生效)
        enableEdgeToEdge()
        // setContent 是 Activity 与 Compose 的桥梁,取代了 setContentView
        setContent {
            // rememberSaveable:横竖屏旋转、进程重建后仍能保留计数
            var count by rememberSaveable { mutableIntStateOf(0) }
            Scaffold { innerPadding ->
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(innerPadding)
                        .padding(16.dp),
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    Text(text = "点击次数:$count")
                    Button(onClick = { count++ }) {
                        Text("点我 +1")
                    }
                }
            }
        }
    }
}

View / XML 版

<!-- app/src/main/res/layout/activity_main.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp">

    <!-- TextView 与 Button 通过约束确定位置,避免了多层嵌套 -->
    <TextView
        android:id="@+id/tvCount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="点击次数:0"
        android:textSize="20sp"
        app:layout_constraintBottom_toTopOf="@id/btnAdd"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <Button
        android:id="@+id/btnAdd"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="点我 +1"
        app:layout_constraintTop_toBottomOf="@id/tvCount"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
// app/src/main/java/com/example/helloandroid/MainActivity.kt
package com.example.helloandroid

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import com.example.helloandroid.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    // ViewBinding:编译期生成,类型安全,取代 findViewById
    private lateinit var binding: ActivityMainBinding
    private var count = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 从 savedInstanceState 恢复计数,处理横竖屏重建
        count = savedInstanceState?.getInt(KEY_COUNT) ?: 0
        renderCount()

        binding.btnAdd.setOnClickListener {
            count++
            renderCount()
        }
    }

    private fun renderCount() {
        binding.tvCount.text = getString(R.string.click_count, count)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putInt(KEY_COUNT, count)
    }

    private companion object {
        const val KEY_COUNT = "key_count"
    }
}
<!-- app/src/main/res/values/strings.xml -->
<resources>
    <string name="app_name">HelloAndroid</string>
    <!-- 用占位符而非拼接,方便国际化 -->
    <string name="click_count">点击次数:%1$d</string>
</resources>

[!TIP] 上面两套写法请各跑一遍。你会直观感受到:Compose 用状态驱动 UI(count 变了 UI 自动更新), View 体系需要手动 setText 刷新。这个差异是后续所有 UI 知识点的主线。


四、常见坑 ⚠️

现象 根因 解法
Gradle 同步卡住 / 超时 默认仓库在国内访问慢 配镜像源;或用代理;检查 gradle.properties 代理配置
SDK location not found 未配置 ANDROID_HOME 或 local.properties 在 local.properties 写 sdk.dir=D\:\\Android\\Sdk(注意转义反斜杠)
模拟器启动黑屏 / 卡在开机动画 显卡驱动或 Graphics 配置问题 把 AVD 的 Graphics 改为 Software;更新显卡驱动;加大 RAM
adb devices 显示 unauthorized 手机未授权调试 撤销 USB 调试授权后重新连接,手机上点"允许"
安装时报 INSTALL_FAILED_VERSION_DOWNGRADE 设备上已装更高版本 adb uninstall <包名> 后重装
中文路径导致 No such file or directory 路径含中文/空格 把工程移到纯英文路径,重新导入
修改代码后运行没变化 增量构建未生效 / Instant Run 历史包袱 Build → Clean Project 后重新 Run;确认没在跑旧进程
模拟器极慢 未开启硬件加速(VT-x / SVM / HVF) BIOS 开启虚拟化;Windows 启用 WHPX;Mac 用 arm64 镜像
Minimum supported Gradle version is X Gradle 与 AGP 版本不匹配 用 AGP Upgrade Assistant 自动升级,不要手改版本号

五、调试技巧

5.1 Logcat 过滤表达式

Logcat 是 Android 开发的"第一现场"。别再靠肉眼扫日志:

# 只看当前进程的日志(推荐作为默认过滤)
package:com.example.helloandroid

# 只看错误级别
package:com.example.helloandroid level:error

# 按 tag 过滤(支持正则)
tag:MyViewModel

# 组合过滤:本进程 + 排除系统的无用噪声
package:com.example.helloandroid & -tag:OpenGLRenderer & -tag:chatty

在代码里这样输出日志(不要直接用 println):

// 定义 tag,便于过滤;常量长度限制 23 个字符
private const val TAG = "FeedViewModel"

Log.d(TAG, "加载信息流 page=$page")        // 调试
Log.w(TAG, "接口降级,使用缓存数据")        // 警告
Log.e(TAG, "请求失败", exception)          // 错误,务必带上异常对象

[!WARNING] 正式发布前必须移除敏感日志(Token、手机号、请求体)。用第 19 章介绍的 R8 规则或在 BuildConfig.DEBUG 判断后输出。

5.2 常用 adb 命令速查

# 安装 APK(-r 覆盖安装,-t 允许测试包)
adb install -r app-debug.apk

# 启动某个 Activity(调试深链、指定启动页时非常好用)
adb shell am start -n com.example.helloandroid/.MainActivity

# 查看崩溃与异常的堆栈(配合包名过滤最实用)
adb logcat -v time | grep "AndroidRuntime"

# 强制停止应用(模拟冷启动)
adb shell am force-stop com.example.helloandroid

# 清除应用数据(测试首次启动、清空缓存)
adb shell pm clear com.example.helloandroid

# 截图与录屏(写 bug 单必备)
adb exec-out screencap -p > screen.png
adb shell screenrecord /sdcard/demo.mp4 && adb pull /sdcard/demo.mp4

# 查看当前栈顶 Activity(排查跳转问题)
adb shell dumpsys activity activities | grep mResumedActivity

5.3 断点与调试器

  • 条件断点:右键断点 → 设 Condition,只在 page == 3 时停下,避免循环里反复手动放行。
  • 日志断点(不暂停):右键断点 → 取消 Suspend → 勾选 "Evaluate and log",相当于动态加日志,不用重新编译。
  • Attach to Process:App 已经跑起来时,用 Run → Attach Debugger to Android Process 挂上去,不必重启。
  • Evaluate Expression:断点处按 Alt+F8,可以现场执行任意表达式,验证猜想最快的方式。

5.4 Layout Inspector 与 Profiler

  • Layout Inspector:Tools → Layout Inspector,查看真实运行时的视图树与每个 View 的属性。布局错乱、控件被遮挡时用它定位最快。Compose 工程同样支持,可看到重组次数(Recomposition Counts)。
  • Profiler:View → Tool Windows → Profiler,实时看 CPU、内存、网络、电量。第 17 章会详细展开。

六、最佳实践

  1. 目录全英文、无空格:安装路径、SDK 路径、工程路径、Gradle 缓存路径,一视同仁。
  2. 版本号统一收口:所有依赖版本放 gradle/libs.versions.toml,绝不在 build.gradle.kts 里硬编码(第 02 章)。
  3. 包名一次定终身:applicationId 上架后不可更改,立项时就按公司反向域名确定。
  4. 优先真机调试:模拟器只在需要验证特定系统版本/屏幕尺寸时使用。
  5. 日志分级与脱敏:Log.d 调试、Log.e 带异常对象;Release 包关闭调试日志。
  6. 构建失败先读第一行报错:Gradle 报错的有效信息几乎总在最上面几行,后面的 Caused by 是补充。
  7. 善用 Gradle 缓存与并行:打开 parallel / caching / configuration-cache,多模块工程构建时间可减半。
  8. 遇到诡异问题先 Clean / Invalidate Caches:File → Invalidate Caches / Restart 能解决相当比例的 IDE 玄学问题。
旧写法对照:Java + findViewById
// 旧写法(不推荐):findViewById 需要强转,空指针风险高,控件多了代码冗长
TextView tv = (TextView) findViewById(R.id.tv_count);
Button btn = (Button) findViewById(R.id.btn_add);
tv.setText("点击次数:" + count);
// 过渡方案:ButterKnife(已停止维护,仅存量项目可见)
@BindView(R.id.tv_count) TextView tv;

现代做法统一用 ViewBinding(类型安全、空安全、编译期生成),或直接用 Compose 彻底告别 XML。


七、本章小结

  1. 工具链三层:Android Studio(IDE)→ Gradle + AGP(构建)→ Android SDK(平台与工具)→ 设备(运行)。
  2. JDK 版本由 AGP 决定,用 Studio 自带的 Embedded JDK 最省心;路径全英文是硬性要求。
  3. 模拟器解决"有没有设备"的问题,真机解决"像不像真实用户"的问题,两者都要会。
  4. adb 是排查安装/启动/跳转问题的瑞士军刀,adb devices、adb logcat、adb shell am 三个命令要形成肌肉记忆。
  5. Logcat 过滤表达式、条件断点、日志断点是日常调试效率的分水岭。

下一章:先补一门 Kotlin 速成课,把空安全、data class、扩展函数、密封类和协程这几个高频语法掌握,后面读代码就不会卡壳。


八、动手练习

基础题

  1. 按本章步骤搭建环境,创建 HelloAndroid 工程,分别在模拟器和真机上跑通。
  2. 用 adb devices 确认设备状态为 device;用 adb shell am start 手动启动一次 MainActivity。
  3. 在 Logcat 中用 package:你的包名 过滤,只看到自己应用的日志。

进阶题 4. 分别用 Compose 和 View/XML 实现同一个计数器,对比两者的状态恢复方式(旋转屏幕后计数是否保留)。 5. 编写一个 debug.sh 脚本,一键完成:卸载旧包 → 安装新包 → 启动首页 → 抓取最近 200 行日志到文件。

思考题 6. 为什么 Android Studio 要自带 JDK,而不能直接用系统里装的 JDK?(提示:CI 环境 reproducibility) 7. 如果你的 App 在模拟器上正常、真机上崩溃,你会按什么顺序排查?(提示:ABI、系统版本、厂商 ROM、权限) 8. applicationId 与代码里的 package 属性有什么区别?为什么上架后不能改 applicationId?

📄 第 01 章 Kotlin 速成与协程预热 01-kotlin-crash-course.md

阶段:起步 | 预计阅读 50 min | 代码落地模块::core:common、:core:model 前置:第 00 章 环境搭建 相关章节:第 11 章 协程与 Flow

学习目标

  • [ ] 掌握 Android 开发中最高频的 Kotlin 语法:val/var、空安全、data class、扩展函数、密封类、作用域函数、泛型。
  • [ ] 理解 Kotlin 与 Java 的关键差异,能读懂老项目的 Java 代码并改写成 Kotlin。
  • [ ] 理解协程的"挂起"到底挂起了什么,能写出不阻塞主线程的异步代码。
  • [ ] 能识别常见的 Kotlin 误用(!!、GlobalScope、runBlocking 等)。

一、核心概念

1.1 为什么 Android 选择了 Kotlin

2019 年 Google 宣布 Kotlin-First,原因不是"语法糖多",而是三点硬收益:

收益 具体表现
空安全(Null Safety) 空指针异常(NPE)在编译期被消灭,Android 崩溃里 NPE 常年排前三
协程(Coroutines) 用同步写法表达异步逻辑,彻底告别回调地狱与 AsyncTask
互操作性 与 Java 100% 双向调用,存量项目可以逐文件迁移,不必重写

1.2 一张图理解 Kotlin 类型系统

flowchart LR
    A["Any?(可空顶层类型)"] --> B["Any(非空顶层类型)"]
    B --> C["String / Int / 自定义类"]
    A --> D["String? / Int? / 自定义类?"]
    C -.->|"可以赋值给"| D
    D -.->|"需要判空后才能"| C

核心规则:类型默认非空,想允许为空必须显式加 ?。要从一个可空类型拿到非空值,必须过一道"关卡":?.、?:、!! 或智能转换。


二、示例代码

2.1 变量、常量与类型推断

// 文件路径:docs/samples/ch01/Basics.kt
package com.techfeed.sample.basic

// val = 只读引用(不等于不可变对象),var = 可变引用
val appName: String = "TechFeed"   // 显式类型
val version = 1                     // 类型推断为 Int
var retryCount = 0                  // var 可重新赋值

// 常量:编译期常量,必须是顶层或 object 成员,用 const 修饰
const val MAX_RETRY = 3
const val BASE_URL = "https://api.techfeed.dev/"

// ⚠️ 常见误用:val 修饰的对象内部仍可变
val userList = mutableListOf("A", "B")
userList.add("C")   // 合法:引用没变,是对象内容变了

// 字符串模板:比 Java 拼接清晰得多
fun describe(): String = "$appName v$version, retry=$retryCount"

2.2 空安全:四种判空武器

// 文件路径:docs/samples/ch01/NullSafety.kt
package com.techfeed.sample.basic

data class User(val id: String, val nickname: String?)

fun safeCall(user: User?) {
    // 1. 安全调用 ?. —— 为空则整条链返回 null,不会抛异常
    val length: Int? = user?.nickname?.length

    // 2. Elvis 操作符 ?: —— 为空时提供默认值
    val name: String = user?.nickname ?: "匿名用户"

    // 3. let 作用域函数 —— 仅在非空时执行块内逻辑
    user?.nickname?.let { nickname ->
        println("昵称长度=${nickname.length}")
    }

    // 4. 非空断言 !! —— 为空直接抛 NPE,除非你 100% 确定,否则不要用
    val forced: String = user!!.nickname!!

    // 5. 提前返回 + 智能转换(推荐写法):之后 user 被智能转为非空类型
    val checked = user ?: return
    println(checked.id)   // 此处 user 已智能转换为 User(非空)
}

// 平台类型(Java 返回的 String!):Kotlin 无法判断其可空性,需自行判空
fun fromJava(javaValue: String) {
    // 建议:Java API 的结果统一当作可空处理
    val safe: String = javaValue.ifBlank { "default" }
}

[!WARNING] 在业务代码里看到 !! 就应该本能警惕。唯一的合理场景是:onCreate 中你已经明确知道 savedInstanceState != null, 或某个 lateinit 属性在 init 阶段必然赋值。其他情况下,用 ?: return、?: error(...) 或 checkNotNull 替代。

2.3 data class、密封类与枚举

这三个是 Android 分层架构里出镜率最高的语法:

// 文件路径:sample/core/model/src/main/java/com/techfeed/core/model/Article.kt
package com.techfeed.core.model

/**
 * 领域模型:一篇文章。
 * data class 自动生成 equals/hashCode/toString/copy,非常适合做不可变模型。
 */
data class Article(
    val id: String,
    val title: String,
    val summary: String,
    val coverUrl: String?,      // 允许为空:有的文章没有封面
    val author: Author,
    val publishedAt: Long,      // 时间戳,展示层再格式化
    val isFavorite: Boolean = false,  // 默认值,避免每次构造都传
)

data class Author(val id: String, val name: String, val avatarUrl: String?)

/**
 * 结果封装:密封类表达"有限种可能",配合 when 可做到穷尽检查(无需 else)。
 * 这是全书所有数据层的统一返回类型。
 */
sealed interface Result<out T> {
    data class Success<out T>(val data: T) : Result<T>
    data class Failure(val error: AppError) : Result<Nothing>
    object Loading : Result<Nothing>
}

/** 错误同样用密封类建模,UI 层可以针对不同错误给出不同文案 */
sealed interface AppError {
    data class Network(val cause: Throwable) : AppError
    data class Http(val code: Int, val message: String?) : AppError
    data class Auth(val reason: String) : AppError
    data class Unknown(val cause: Throwable) : AppError
}

使用密封类时,when 必须是穷尽的,编译器会帮你检查:

// 文件路径:docs/samples/ch01/SealedWhen.kt
fun render(result: Result<List<Article>>): String = when (result) {
    is Result.Loading -> "加载中"
    is Result.Success -> "共 ${result.data.size} 篇"
    is Result.Failure -> when (val e = result.error) {
        is AppError.Network -> "网络不可用,请检查连接"
        is AppError.Http -> if (e.code == 404) "内容不存在" else "服务异常(${e.code})"
        is AppError.Auth -> "登录已失效,请重新登录"
        is AppError.Unknown -> "未知错误"
        // 不需要 else:新增子类时这里会编译报错,强制你处理
    }
}
对比:密封类 vs 枚举 vs 接口
方案 适用场景 特点
enum class 固定常量集合(如 Status.LOADING) 每个枚举值是单例,不能携带不同的数据
sealed interface/class 有限种状态,且每种状态携带不同数据 每个子类可有不同字段;when 可穷尽检查
interface 开放扩展、允许外部实现 无法穷尽,when 必须写 else

UI 状态建模一律用密封类(第 14 章的 UiState),不要用枚举——因为 Loading 没有数据,而 Success 有数据。

2.4 扩展函数与作用域函数

// 文件路径:sample/core/common/src/main/java/com/techfeed/core/common/Extensions.kt
package com.techfeed.core.common

import java.text.SimpleDateFormat
import java.util.Locale

/**
 * 扩展函数:在不修改类源码、不继承的前提下给类"加方法"。
 * 编译后是静态方法,没有运行时开销。
 */
fun Long.toDisplayDate(): String =
    SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(this)

fun String.truncate(max: Int): String =
    if (length <= max) this else substring(0, max) + "…"

// 扩展属性
val String.isHttpUrl: Boolean get() = startsWith("http://") || startsWith("https://")

// 泛型扩展 + 高阶函数:安全执行并统一兜底
inline fun <T> runCatchingOrDefault(default: T, block: () -> T): T = try {
    block()
} catch (t: Throwable) {
    default
}

五个作用域函数怎么选(面试与 CR 高频):

函数 引用上下文的方式 返回值 典型场景
let it 块最后一行的值 非空判断后执行;转换
run this 块最后一行的值 对象配置 + 计算
with this 块最后一行的值 对已有对象批量操作
apply this 对象自身 对象初始化配置(Builder 风格)
also it 对象自身 副作用:打日志、打点
val intent = Intent(this, DetailActivity::class.java).apply {
    putExtra("article_id", article.id)      // this = Intent
}.also {
    Log.d("Nav", "跳转到详情:${it.extras}")   // it = Intent,仅做副作用
}

2.5 集合与函数式操作

// 文件路径:docs/samples/ch01/Collections.kt
data class Article(val id: String, val title: String, val readMinutes: Int, val tags: List<String>, val publishedAt: Long)

val articles: List<Article> = emptyList()

// 链式操作:注意 map/filter 每次都会产生新的中间集合,超大列表用 asSequence 惰性求值
val titles: List<String> = articles
    .filter { it.readMinutes <= 10 }        // 过滤:10 分钟内可读完
    .sortedByDescending { it.publishedAt }  // 排序
    .map { it.title.truncate(30) }          // 转换

// groupBy / associateBy:把列表变成 Map,列表渲染分组时常用
val byTag: Map<String, List<Article>> = articles
    .flatMap { a -> a.tags.map { tag -> tag to a } }
    .groupBy({ it.first }, { it.second })

// 惰性序列:数据量 > 1万 时避免中间集合开销
val count = articles.asSequence()
    .filter { it.tags.contains("Android") }
    .count()

2.6 协程预热:挂起到底挂起了什么

这是理解协程最关键的一点:

suspend 函数挂起的是协程,不是线程。 当协程挂起时,它所在的线程被释放去做别的事;恢复时可能换到另一个线程继续执行。

sequenceDiagram
    participant M as 主线程
    participant C as 协程
    participant IO as IO 线程
    M->>C: launch { 请求接口 }
    C->>IO: withContext(Dispatchers.IO) 切线程
    Note over M: 主线程空闲,继续渲染 UI、响应点击
    IO-->>C: 网络返回
    C->>M: 自动切回主线程更新 UI
// 文件路径:docs/samples/ch01/CoroutineIntro.kt
import kotlinx.coroutines.*

// suspend:标记这个函数可以"挂起",只能在协程或其他 suspend 函数中调用
suspend fun loadArticle(id: String): Article {
    // withContext 切换执行线程,执行完自动切回来 —— 这就是"用同步写法表达异步"
    return withContext(Dispatchers.IO) {
        // 这里是 IO 线程,可以做网络 / 数据库操作
        api.getArticle(id)
    }
}

fun main() = runBlocking {   // runBlocking 会阻塞当前线程,仅用于 main 与测试,业务代码禁用
    val job = launch {       // launch 启动一个不返回结果的协程
        val article = loadArticle("a-001")
        println("拿到文章:${article.title}")   // 已在主线程,可直接更新 UI
    }
    job.join()               // 等待协程结束
}

三个必须记住的协程概念:

概念 作用 Android 中的对应物
CoroutineScope 协程的生命周期边界,用于结构化并发 viewModelScope、lifecycleScope
Dispatcher 决定协程跑在哪个线程 Main(UI)/ IO(网络磁盘)/ Default(CPU 密集)
Job 协程句柄,可 cancel 页面销毁时自动取消,避免泄漏
// Android 中的标准写法(第 11 章展开)
class FeedViewModel : ViewModel() {
    fun refresh() {
        // viewModelScope 随 ViewModel 销毁自动取消,无需手动管理
        viewModelScope.launch {
            try {
                val list = repository.getFeed(page = 1)
                _uiState.update { it.copy(articles = list) }
            } catch (e: CancellationException) {
                throw e   // ⚠️ 取消异常必须向上抛,不能被 catch 吞掉
            } catch (e: Exception) {
                _uiState.update { it.copy(error = e.message) }
            }
        }
    }
}

[!TIP] CancellationException 是协程取消的正常信号,不是错误。用 catch (e: Exception) 会把它一起吞掉,导致协程无法真正取消——这是协程最隐蔽的坑之一。


三、常见坑 ⚠️

现象 根因 解法
NullPointerException 依旧出现 用了 !!;或 Java 平台类型未判空;或 lateinit 未初始化就访问 消灭 !!,用 ?: return;lateinit 前用 ::prop.isInitialized 检查
lateinit property has not been initialized 在 onCreate 之前访问,或 Fragment 的视图销毁后访问 binding 依赖注入用 Hilt;Fragment 中 binding 置空(第 03 章)
协程里更新 UI 崩溃 在 Dispatchers.IO 中直接操作 View UI 操作必须在 Dispatchers.Main(默认协程通常已是 Main)
页面销毁后回调仍在跑,导致泄漏 用了 GlobalScope 或未绑定生命周期的 Scope 一律用 viewModelScope / lifecycleScope,禁用 GlobalScope
runBlocking 导致界面卡死 在主线程调用了阻塞式桥接 只在 main() 和测试中用;业务代码改用 launch
when 报 "must be exhaustive" 密封类新增子类但没处理 这是编译器的保护,正说明密封类的价值,补齐分支即可
列表操作性能差 大量 map/filter 链产生中间集合 超过万级数据改用 asSequence()
扩展函数"看起来像成员"造成误读 扩展是静态解析,不参与多态 记住:扩展函数不能被子类覆写

四、调试技巧

  1. 看 Kotlin 字节码:Tools → Kotlin → Show Kotlin Bytecode,再点 Decompile 反编译成 Java。想搞懂 data class、扩展函数、when 到底生成了什么,这是最直接的方法。
  2. 协程调试:在 Run/Debug Configurations 中开启协程调试,或运行时加 VM 参数:
    -Dkotlinx.coroutines.debug=on
    
    之后日志里的线程名会带上协程信息:@coroutine#3。
  3. 打印当前协程上下文:
    println("当前线程=${Thread.currentThread().name}, dispatcher=${coroutineContext[ContinuationInterceptor]}")
    
  4. 异常堆栈丢失:协程抛出但没打印?在 Application 里设置 Thread.setDefaultUncaughtExceptionHandler(第 18 章会做成崩溃采集)。
  5. kotlin.runCatching 陷阱:它会连 CancellationException 一起捕获。协程中优先手写 try/catch 并重新抛出取消异常。

五、最佳实践

  1. 默认 val,只有真正需要重新赋值才用 var;字段默认不可变能消掉大量并发与状态 bug。
  2. 消灭 !!:把"它可能为空"当作设计信号,用 ?: return、requireNotNull、checkNotNull 明确表达。
  3. 数据模型用 data class + 不可变字段:配合 Compose 与 StateFlow,能天然避免"改了对象但 UI 没刷新"。
  4. 状态与错误用密封类建模:让编译器帮你做穷尽检查,新增状态时不会漏改 UI。
  5. 扩展函数放在 core:common:命名以 toXxx / asXxx 为主,不要把复杂业务逻辑塞进扩展。
  6. 禁止 GlobalScope:任何协程都要挂在有明确生命周期的 Scope 上(结构化并发)。
  7. suspend 函数必须是主线程安全的:内部自行 withContext 切线程,让调用方无需关心线程。
  8. 集合操作链式不超过 3 层:可读性优先,超过就拆成中间变量并加注释。

六、本章小结

  1. 空安全是 Kotlin 最大的收益:类型默认非空,可空必须显式 ?,用 ?. / ?: / let 而非 !!。
  2. data class 承担数据模型,密封类承担状态与结果建模,两者是分层架构的语法基石。
  3. 扩展函数零成本增强类能力,作用域函数五选一按"返回值 + 上下文名"来记。
  4. 协程挂起的是协程不是线程:suspend + withContext 让你用同步代码写异步逻辑。
  5. 结构化并发:viewModelScope 自动取消、禁用 GlobalScope、CancellationException 不能吞。

下一章:进入工程内部,看清一个 Android 项目的目录结构与 Gradle 构建脚本到底怎么组织。


七、动手练习

基础题

  1. 定义 data class Comment(val id: String, val content: String, val liked: Boolean),写一个函数把 List<Comment> 按 liked 分组统计数量。
  2. 给 String 写扩展函数 toMaskedPhone(),把 13812345678 转成 138****5678,并处理好长度不足 11 位的情况(不要抛异常)。
  3. 用密封类建模"登录状态":Idle / Loading / Success(user) / Error(message),写一个 when 渲染函数做到穷尽。

进阶题 4. 把下面这段回调式代码改写成协程版本,并说明改写后为什么不会阻塞主线程:

api.getFeed(object : Callback<List<Article>> {
    override fun onSuccess(data: List<Article>) { runOnUiThread { render(data) } }
    override fun onFailure(e: Throwable) { runOnUiThread { showError(e) } }
})
  1. 写一个 suspend fun <T> retry(times: Int, block: suspend () -> T): T,要求:失败重试、可取消(遇到 CancellationException 立即抛出)、最后一次失败才抛异常。

思考题 6. 为什么 val list = mutableListOf<Int>() 里的 list 是只读的,但内容却能改?这在多模块架构里会带来什么风险?如何设计不可变 API? 7. runBlocking 在 Android 主线程调用会发生什么?为什么测试代码里反而推荐用它? 8. 密封类相比"用 Int 常量 + when else"的方案,在团队协作与重构安全上有什么具体优势?

📄 第 02 章 工程结构与 Gradle 构建 02-project-structure-and-gradle.md

阶段:入门 | 预计阅读 45 min | 代码落地模块:工程根目录、build.gradle.kts、gradle/libs.versions.toml 前置:第 00 章 环境搭建 相关章节:第 15 章 模块化与组件化 | 第 20 章 构建配置与发布

学习目标

  • [ ] 能说出 Android 工程每个目录的作用,知道代码、资源、清单各自放在哪。
  • [ ] 读懂并修改 build.gradle.kts,理解 AGP、Gradle、Kotlin 插件三者的职责边界。
  • [ ] 掌握 Version Catalog(libs.versions.toml),把依赖版本统一收口。
  • [ ] 理解一次构建的完整流程,能定位"构建慢""同步失败"的原因。
  • [ ] 知道 applicationId、namespace、R 类、BuildConfig 这些容易混淆的概念。

一、核心概念

1.1 工程目录树

一个新建的 Android 工程长这样(只列出需要你真正理解的目录):

MyApp/
├─ settings.gradle.kts            # 声明"这个工程包含哪些模块" + 仓库地址
├─ build.gradle.kts               # 根工程脚本:声明插件(通常 apply false)
├─ gradle.properties              # Gradle / JVM 参数开关
├─ gradle/
│  ├─ libs.versions.toml          # 【版本目录】所有依赖版本集中在此
│  └─ wrapper/gradle-wrapper.properties   # Gradle 版本(团队必须一致)
├─ app/
│  ├─ build.gradle.kts            # 模块脚本:插件、SDK 版本、依赖
│  ├─ proguard-rules.pro          # 混淆规则(第 19 章)
│  └─ src/
│     ├─ main/
│     │  ├─ AndroidManifest.xml   # 组件声明、权限、应用配置
│     │  ├─ java/...              # Kotlin/Java 源码(按包名分目录)
│     │  └─ res/                  # 资源(见第 07 章)
│     │     ├─ layout/ values/ drawable/ mipmap/ xml/ navigation/
│     ├─ debug/                   # 变体专属源码(sourceSet)
│     ├─ release/
│     ├─ test/                    # 单元测试(JVM,跑在本地)
│     └─ androidTest/             # 仪器化测试(跑在设备上)
└─ build/                          # 构建产物,不进版本库

[!TIP] src/main/java 目录里放 Kotlin 文件是完全正常的(历史遗留)。新工程也可以用 src/main/kotlin,二者等价,选定后团队统一即可。

1.2 三份 Gradle 脚本的分工

文件 职责 常见操作
settings.gradle.kts 模块注册、仓库(dependencyResolutionManagement)、构建缓存 新建模块、加 maven 源
根 build.gradle.kts 声明插件版本(apply false,只在子模块按需应用) 升级 AGP / Kotlin / Hilt 插件
模块 build.gradle.kts 应用插件、配置 SDK 版本、声明依赖与构建类型 日常最常改的文件

1.3 命名空间 vs 应用 ID

这是初学者最容易混淆的一对概念:

概念 作用 能否改
namespace 决定 R 类的包名(com.techfeed.feature.feed.R) 重构时可改
applicationId 应用在安装市场与系统中的唯一标识 上架后不可改
源码 package Kotlin 文件的包名 应与 namespace 保持一致
// 文件路径:sample/feature/feed/build.gradle.kts
android {
    namespace = "com.techfeed.feature.feed"   // 决定 R / BuildConfig 包名
    // 只有 application 模块才写 applicationId
    compileSdk = 37
}

[!WARNING] 多模块工程里,模块 A 引用模块 B 的资源时要用 B 的 namespace 对应的 R 类(com.techfeed.core.designsystem.R)。 打开 android.nonTransitiveRClass=true 后,不再有"万能 R",这是有意为之——它让模块边界更清晰、编译更快。


二、示例代码

2.1 settings.gradle.kts

// 文件路径:sample/settings.gradle.kts
pluginManagement {
    // 约定插件工程(build-logic)作为复合构建引入,供模块脚本使用 techfeed.* 插件
    includeBuild("build-logic")
    repositories {
        google {
            content {
                // 限定这个仓库只提供这些 group 的构件,加快解析
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    // 严格模式:禁止在模块脚本里单独声明仓库,避免依赖来源混乱
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        // 国内加速可在此追加镜像源
        // maven("https://maven.aliyun.com/repository/google")
    }
}

rootProject.name = "TechFeed"
// 开启构建缓存与配置缓存(配合 gradle.properties)
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

// —— 模块注册:每一条 include 对应一个 Gradle 模块 ——
include(":app")
include(":core:model")
include(":core:common")
include(":core:designsystem")
include(":core:network")
include(":core:database")
include(":core:datastore")
include(":core:navigation-api")
include(":core:crash")
include(":core:testing")
include(":feature:feed")
include(":feature:detail")
include(":feature:auth")
include(":feature:profile")
include(":feature:push")

2.2 版本目录:唯一版本真相

# 文件路径:sample/gradle/libs.versions.toml
[versions]
agp = "9.0.0"
kotlin = "2.4.0"
ksp = "2.4.0-1.0.0"
compileSdk = "37"
targetSdk = "36"
minSdk = "26"
composeBom = "2026.08.00"
hilt = "2.57.0"
hiltNavigationCompose = "1.2.0"
retrofit = "3.0.0"
okhttp = "5.1.0"
retrofitKotlinxSerialization = "1.0.0"
kotlinxSerialization = "1.9.0"
room = "2.8.0"
datastore = "1.2.0"
coroutines = "1.10.2"
lifecycle = "2.9.0"
activityCompose = "1.11.0"
navigationCompose = "2.9.0"
workManager = "2.11.0"
coil = "3.2.0"
junit = "4.13.2"
truth = "1.4.4"
turbine = "1.2.1"
mockk = "1.14.0"

[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workManager" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofitKotlinxSerialization" }
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
junit = { module = "junit:junit", version.ref = "junit" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room = { id = "androidx.room", version.ref = "room" }

[bundles]
compose = ["androidx-compose-ui", "androidx-compose-ui-graphics", "androidx-compose-ui-tooling-preview", "androidx-compose-material3"]
room = ["androidx-room-runtime", "androidx-room-ktx"]
network = ["retrofit-core", "retrofit-kotlinx-serialization", "okhttp-core", "kotlinx-serialization-json"]
testing-unit = ["junit", "truth", "turbine", "kotlinx-coroutines-test"]

[!NOTE] 上面是实际 libs.versions.toml 的节选(完整文件还包含监控、测试、约定插件等分组,见 sample/gradle/libs.versions.toml),且有一处 version.ref = "coreKtx" 未在 [versions] 中定义——实际工程里请补上 coreKtx = "1.17.0"。 这类"引用了不存在的 version"是版本目录最常见的同步错误,报错信息是 Version 'coreKtx' wasn't found。

2.3 根 build.gradle.kts

// 文件路径:sample/build.gradle.kts
// 插件只声明、不应用;子模块各自 apply,避免所有模块都背上不必要的插件开销
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.kotlin.serialization) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt) apply false
    alias(libs.plugins.room) apply false
}

2.4 模块脚本:application 与 library 的差异

// 文件路径:sample/app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
}

android {
    namespace = "com.techfeed.app"
    compileSdk = libs.versions.compileSdk.get().toInt()

    defaultConfig {
        applicationId = "com.techfeed.app"     // 唯一切勿随意改
        minSdk = libs.versions.minSdk.get().toInt()
        targetSdk = libs.versions.targetSdk.get().toInt()
        versionCode = 1                         // 递增整数,市场用来判断新旧
        versionName = "1.0.0"                   // 展示给用户的版本
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        debug {
            // debug 包加后缀,便于与 release 共存
            applicationIdSuffix = ".debug"
            versionNameSuffix = "-debug"
            isMinifyEnabled = false
        }
        release {
            isMinifyEnabled = true
            isShrinkResources = true            // 移除未引用资源
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
            signingConfig = signingConfigs.getByName("debug")   // 演示用,正式见第 20 章
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = "17"
        // 开启显式 API 模式(库模块推荐):强制写返回值与可见性,防止 API 失控
        // freeCompilerArgs += "-Xexplicit-api=strict"
    }
    buildFeatures {
        compose = true
        buildConfig = true
    }
    packaging {
        resources {
            // 排除重复许可文件,避免打包失败
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

dependencies {
    implementation(projects.core.designsystem)
    implementation(projects.core.navigationApi)
    implementation(projects.feature.feed)
    implementation(projects.feature.detail)
    implementation(projects.feature.auth)
    implementation(projects.feature.profile)
    implementation(projects.feature.push)

    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.bundles.compose)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.navigation.compose)
    implementation(libs.androidx.hilt.navigation.compose)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    implementation(libs.kotlinx.coroutines.android)

    testImplementation(libs.bundles.testing.unit)
    debugImplementation(libs.androidx.compose.ui.tooling)
}

library 模块(以 :core:network 为例)的差异点只有三处:插件用 android.library、没有 applicationId、对外 API 建议开显式模式。

// 文件路径:sample/core/network/build.gradle.kts
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
}

android {
    namespace = "com.techfeed.core.network"
    compileSdk = libs.versions.compileSdk.get().toInt()

    defaultConfig {
        minSdk = libs.versions.minSdk.get().toInt()
        // 库模块对外暴露的 API 建议开启显式模式,避免内部实现泄漏
        consumerProguardFiles("consumer-rules.pro")
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

dependencies {
    // api:传递给依赖方(本模块的类型出现在对外 API 签名里时用 api)
    api(projects.core.model)
    // implementation:不传递,编译更快、边界更清晰(默认就用这个)
    implementation(projects.core.common)
    implementation(libs.bundles.network)
    debugImplementation(libs.okhttp.logging)

    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)

    testImplementation(libs.bundles.testing.unit)
}

[!TIP] api vs implementation 的抉择:如果依赖类型出现在你模块的公开签名里(函数返回值、参数、父类),用 api;否则一律 implementation。 滥用 api 会让改动一个底层模块触发全量重编译,是多模块工程变慢的头号原因。

2.5 抽公共配置:约定插件

15 个模块都写一遍 compileSdk、JavaVersion、compileOptions 太蠢。企业级做法是用 Convention Plugin(约定插件)收口:

// 文件路径:sample/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure

/** 所有 library 模块共用的 Android 配置,模块脚本只需 apply 这一行 */
class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
            }
            extensions.configure<LibraryExtension> {
                compileSdk = 37
                defaultConfig { minSdk = 26 }
                compileOptions {
                    sourceCompatibility = JavaVersion.VERSION_17
                    targetCompatibility = JavaVersion.VERSION_17
                }
                // 统一开启:非传递 R 类、资源排除
                androidResources { }
                packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
            }
        }
    }
}

模块脚本随之简化为:

plugins {
    alias(libs.plugins.techfeed.android.library)   // 自定义约定插件
    alias(libs.plugins.techfeed.hilt)
}

[!NOTE] build-logic 需要在 settings.gradle.kts 中用 includeBuild("build-logic") 引入。 这是 Google 官方 nowinandroid 示例工程的做法,中大型项目强烈建议照搬。


三、构建流程全景

flowchart LR
    A["源码 .kt<br/>资源 res/"] --> B["Kotlin 编译"]
    B --> C["KSP 注解处理<br/>Room / Hilt 生成代码"]
    C --> D["AAPT2 编译资源<br/>生成 R 类"]
    B --> E["DEX 转换<br/>.class → .dex"]
    D --> E
    E --> F["打包 APK<br/>签名 / 对齐"]
    F --> G["R8 混淆压缩<br/>仅 release"]
    G --> H["可安装产物"]

Gradle 的三个阶段:初始化(读 settings,确定模块)→ 配置(执行所有 build.gradle.kts,建立任务图)→ 执行(跑需要的 Task)。

理解这一点就能明白:

  • 配置阶段每次同步都会全量执行,所以配置代码里不能做耗时操作(读文件、发网络请求)。
  • 打开 configuration-cache 后,配置结果会被缓存,第二次构建显著变快。

四、常见坑 ⚠️

现象 根因 解法
Version 'xxx' wasn't found TOML 里引用了未定义的 version 检查 [versions] 是否有对应 key,注意大小写
Could not find method android() 忘了应用 AGP 插件 模块脚本 plugins 里加 alias(libs.plugins.android.library)
Unresolved reference: libs 用了版本目录但没在 settings.gradle.kts 里启用(旧 Gradle) Gradle 8+ 默认启用;确认 libs.versions.toml 在 gradle/ 目录
Duplicate class / 依赖冲突 两个库带相同类(如不同版本的 okhttp) ./gradlew :app:dependencies 看依赖树,用 exclude 或统一 BOM
R 类找不到符号 namespace 与 import 不一致;或开了 nonTransitiveRClass 引用 模块namespace.R;不要 import 别的模块的 R
每次改一行代码都全量编译 滥用 api;配置缓存未开 改回 implementation;打开 configuration-cache
Gradle sync failed: Unsupported class file major version JDK 版本低于 Gradle 要求 切换 Gradle JDK 到 17
META-INF 重复文件导致打包失败 多个库带相同 license 文件 加 packaging { resources { excludes += ... } }
团队里每个人构建结果不一样 Gradle Wrapper 版本不一致 提交 gradle-wrapper.properties,禁止用本地 gradle

五、调试技巧

# 1. 查看依赖树,定位冲突与意外引入的传递依赖
./gradlew :app:dependencies --configuration releaseRuntimeClasspath

# 2. 谁引入了 okhttp?(dependencyInsight 是依赖冲突排查利器)
./gradlew :app:dependencyInsight --dependency okhttp --configuration debugRuntimeClasspath

# 3. 构建性能剖析:生成 HTML 报告,看哪个 Task 最慢
./gradlew assembleDebug --profile

# 4. 只构建某个模块,缩短反馈周期
./gradlew :feature:feed:assembleDebug

# 5. 查看某个 Task 为什么被认为是 out-of-date
./gradlew :app:assembleDebug --info | grep -i "task.*not up-to-date"

# 6. 强制刷新动态版本 / 缓存
./gradlew build --refresh-dependencies

# 7. 列出所有可执行任务(找不着入口时用)
./gradlew :app:tasks --all

Android Studio 里:

  • Build → Analyze Build Performance(或 Build Analyzer)能可视化显示每个 Task 耗时与配置阶段瓶颈。
  • File → Project Structure → Dependencies 可以图形化看模块间依赖关系,发现循环依赖。

六、最佳实践

  1. 版本全部收口到 libs.versions.toml:模块脚本里出现硬编码版本号一律视为 CR 不通过。
  2. 优先 implementation:api 只在类型出现在公开签名时使用,滥用会让多模块构建退化成单体。
  3. minSdk/compileSdk 用约定插件统一:避免 A 模块 26、B 模块 28 这种混乱。
  4. 提交 Gradle Wrapper:团队协作必须用同一 Gradle 版本,CI 与本地才一致。
  5. 打开 configuration-cache 与 parallel:多模块工程收益巨大,但要确认所有插件都支持。
  6. namespace 与源码包名保持一致:不一致会让 R 类、Manifest 引用变得难以理解。
  7. 不要在配置阶段做 I/O:读文件、发请求、解析 JSON 都会拖慢每次同步。
  8. .gitignore 必须排除 build/、.gradle/、local.properties:local.properties 含本机 SDK 路径,绝不能提交。

七、本章小结

  1. 目录职责:src/main(代码+资源+清单)、src/test(本地单测)、src/androidTest(仪器化测试)、变体源码集。
  2. 三份脚本分工:settings(模块与仓库)、根脚本(插件版本)、模块脚本(插件+SDK+依赖)。
  3. namespace 决定 R 类包名,applicationId 是市场唯一标识,上架后不可改。
  4. 版本目录 libs.versions.toml 是唯一版本真相,api 与 implementation 的选择直接影响构建速度。
  5. 构建三阶段:初始化 → 配置 → 执行;配置阶段要做的事越少越好。

下一章:进入 Android 的核心——四大应用组件与生命周期,理解系统在什么时候创建和销毁你的代码。


八、动手练习

基础题

  1. 新建一个工程,把 compileSdk、minSdk 改成从 libs.versions.toml 读取(用 libs.versions.xxx.get().toInt())。
  2. 新建一个 core:common library 模块,在 app 中用 implementation(projects.core.common) 依赖它,调用其中的一个函数。
  3. 用 ./gradlew :app:dependencies 找出工程里 okhttp 的版本,并说明它是被谁引入的。

进阶题 4. 把 app 与 core:common 中重复的 compileOptions、minSdk 抽成一个约定插件 techfeed.android.library,让两个模块脚本各只剩 plugins 与 dependencies。 5. 为工程添加 debug / release 两种构建类型,debug 加 applicationIdSuffix = ".debug",验证两个包能同时安装在手机上。

思考题 6. 为什么 applicationId 上架后不能改,而 namespace 可以改?如果公司要求给不同渠道打不同的包,应该改哪个字段?(提示:第 20 章 productFlavors) 7. 一个 20 模块工程把某个底层库从 implementation 改成 api,构建时间会发生什么变化?为什么? 8. 为什么 Gradle 要区分"配置阶段"和"执行阶段"?如果允许在配置阶段发网络请求,会带来什么问题?

📄 第 03 章 应用组件与生命周期 03-app-components-and-lifecycle.md

阶段:入门 | 预计阅读 55 min | 代码落地模块::app、:feature:* 前置:第 02 章 工程结构与 Gradle 相关章节:第 06 章 列表与导航 | 第 14 章 分层架构

学习目标

  • [ ] 理解 Android 四大组件(Activity、Service、BroadcastReceiver、ContentProvider)各自的定位与使用场景。
  • [ ] 能画出 Activity / Fragment 生命周期图,并说清每个回调该干什么、不该干什么。
  • [ ] 理解配置变更(旋转屏幕)与"进程被系统杀死后重建"的区别,能正确保存与恢复状态。
  • [ ] 理解任务栈(Task)与启动模式(launchMode),能处理"重复打开页面""回退顺序异常"这类问题。
  • [ ] 知道 ViewModel 为什么能"跨越"配置变更,以及它到底能解决什么问题、不能解决什么。

一、核心概念

1.1 四大组件

组件 一句话定位 现在还常用吗
Activity 一个与用户交互的"窗口",UI 的载体 ✅ 仍然必需(Compose 也要跑在 Activity 里)
Service 无界面的后台执行单元 ⚠️ 后台任务已被 WorkManager 取代,仅前台服务/音乐播放等场景保留
BroadcastReceiver 响应系统或应用广播(开机、网络变化、时区) ⚠️ 静态注册受严格限制,动态注册仍有用
ContentProvider 跨应用数据共享(联系人、相册、文件) ✅ 特定场景必需;应用内数据共享不用它

[!IMPORTANT] 所有组件都必须在 AndroidManifest.xml 中声明,未声明的组件无法启动(Activity 会抛 ActivityNotFoundException)。 唯一例外是动态注册的 BroadcastReceiver(代码中注册,随上下文生命周期)。

1.2 Activity 生命周期

stateDiagram-v2
    [*] --> onCreate
    onCreate --> onStart : 创建视图
    onStart --> onResume : 可见
    onResume --> onPause : 部分遮挡/跳转
    onPause --> onStop : 完全不可见
    onStop --> onDestroy : 销毁
    onStop --> onRestart : 再次回到前台
    onRestart --> onStart
    onPause --> onResume : 遮挡消失
    onDestroy --> [*]

各回调的"该做 / 不该做":

回调 该做 不该做
onCreate setContentView、初始化 ViewModel、读取 Intent 参数、注册监听 耗时 I/O(拖慢启动)、重复初始化(配置变更会重走)
onStart 启动只在前台才需要的数据刷新 重量级资源申请
onResume 恢复动画、相机、定位等独占资源 频繁网络请求(会被反复触发)
onPause 暂停动画、保存草稿 耗时操作(会阻塞下一个页面启动)
onStop 释放重量级资源、注销监听器 —
onDestroy 清理引用,防止泄漏 依赖它做数据保存(不保证被调用)

[!WARNING] onDestroy 不保证被调用(系统杀进程时直接终止)。因此"退出时保存数据"必须放在 onPause 或 onStop, 或用 ViewModel + SavedStateHandle 承载。

1.3 Fragment 生命周期:为什么会多出一倍

Fragment 的生命周期比 Activity 多了一套视图生命周期(onCreateView / onViewCreated / onDestroyView):

flowchart LR
    A["onAttach"] --> B["onCreate"]
    B --> C["onCreateView<br/>创建 View"]
    C --> D["onViewCreated"]
    D --> E["onStart"] --> F["onResume"]
    F --> G["onPause"] --> H["onStop"]
    H --> I["onDestroyView<br/>销毁 View,Fragment 仍在"]
    I --> J["onDestroy"] --> K["onDetach"]
    I -.->|"回退栈返回时重新创建视图"| C

关键点:Fragment 实例可以比它的 View 活得更久(进入回退栈时 View 被销毁但 Fragment 对象保留)。

// ⚠️ 经典泄漏:Fragment 实例还在,但 View 已销毁,binding 持有整个视图树
class FeedFragment : Fragment() {
    private var _binding: FragmentFeedBinding? = null
    private val binding get() = _binding!!   // 视图销毁后访问会崩

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, saved: Bundle?): View {
        _binding = FragmentFeedBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null   // 必须置空,切断对视图树的引用
    }
}

[!TIP] Compose 项目里还需要 Fragment 吗? 单 Activity + 纯 Compose 架构下,不需要 Fragment。Fragment 只在两种场景保留: ① 存量 View 项目渐进迁移;② 需要嵌入到别人的 Fragment 容器里。新项目直接用 Navigation-Compose(第 06 章)。

1.4 配置变更与状态恢复

两种"页面重建",处理方式完全不同:

场景 触发 Activity 是否重建 进程是否保留 保存方式
配置变更 旋转屏幕、切换深色模式、语言切换 ✅ 重建 ✅ 保留 ViewModel(内存级)
系统回收 内存不足杀进程后用户返回 ✅ 重建 ❌ 重建 SavedStateHandle / onSaveInstanceState(磁盘级)
// ViewModel 跨越配置变更,但不跨进程重建 —— 所以要配合 SavedStateHandle
class FeedViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val repository: FeedRepository,
) : ViewModel() {

    // 从 SavedStateHandle 读取:直接返回一个 StateFlow,读写都落盘到 Bundle
    val query: StateFlow<String> = savedStateHandle.getStateFlow(KEY_QUERY, "")

    fun onQueryChanged(text: String) {
        savedStateHandle[KEY_QUERY] = text   // 写入后即使进程被杀也能恢复
    }

    companion object {
        private const val KEY_QUERY = "query"
    }
}

1.5 任务栈与启动模式

Android 用**任务栈(Task)**管理 Activity,后进先出。启动模式决定"新 Activity 如何入栈":

模式 行为 典型场景
standard(默认) 每次都新建实例入栈 普通页面
singleTop 若栈顶已是该 Activity,复用并回调 onNewIntent 通知栏点击、搜索页
singleTask 栈内唯一,若已存在则清空其上所有 Activity App 主页
singleInstance 独占一个任务栈 极少用,如来电界面

Intent Flag 更灵活,常用于运行时控制:

val intent = Intent(this, MainActivity::class.java).apply {
    // 清空栈顶之上的所有 Activity 并复用已存在的 MainActivity(回到主页的标准做法)
    flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(intent)

二、示例代码

2.1 Manifest 声明

<!-- 文件路径:sample/app/src/main/AndroidManifest.xml -->
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- 权限声明:危险权限还需运行时申请(见第 08 章) -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

    <application
        android:name=".TechFeedApplication"
        android:allowBackup="false"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.TechFeed"
        android:usesCleartextTraffic="false"
        tools:targetApi="31">

        <!-- 启动 Activity:MAIN + LAUNCHER 决定了桌面图标点开哪个页面 -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:windowSoftInputMode="adjustResize"
            android:theme="@style/Theme.TechFeed">

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <!-- 深链:点击 https://techfeed.dev/article/123 直接进详情 -->
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="https" android:host="techfeed.dev" android:pathPrefix="/article" />
            </intent-filter>
        </activity>

        <!-- 内部页面不导出,避免被其他应用调起 -->
        <activity android:name=".host.HostActivity" android:exported="false" />

        <!-- 前台服务必须声明 foregroundServiceType(Android 14+ 强制) -->
        <service
            android:name=".push.SyncService"
            android:exported="false"
            android:foregroundServiceType="dataSync" />

        <!-- 静态广播:Android 8+ 大部分隐式广播已被禁,这里只保留显式 -->
        <receiver android:name=".push.BootReceiver" android:exported="false" />
    </application>
</manifest>

[!WARNING] Android 12+ 所有带 intent-filter 的组件必须显式声明 android:exported,否则安装时报错 Targeting S+ requires that an explicit value for android:exported be defined。 不需要被外部调起的一律写 false,这是安全加固的第一道门(第 19 章)。

2.2 Activity:Compose 宿主

// 文件路径:sample/app/src/main/java/com/techfeed/app/MainActivity.kt
package com.techfeed.app

import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.DisposableEffect
import androidx.core.util.Consumer
import androidx.navigation.NavController
import com.techfeed.core.designsystem.theme.TechFeedTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint   // Hilt 注入入口(第 12 章)
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        // 处理深链:onNewIntent 在 singleTop 复用时被调用,必须手动更新 Intent
        handleDeepLink(intent)

        setContent {
            TechFeedTheme {
                TechFeedApp()   // 导航壳,见第 06 章
            }
        }
    }

    // singleTop 模式下,新的 Intent 走这里而不是 onCreate
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        handleDeepLink(intent)
    }

    private fun handleDeepLink(intent: Intent?) {
        val articleId = intent?.data?.lastPathSegment ?: return
        DeepLinkHolder.articleId.value = articleId   // 交给导航层消费
    }
}

2.3 Activity:View 体系 + ViewModel 绑定

// 文件路径:docs/samples/ch03/DetailHostActivity.kt
package com.techfeed.feature.detail.ui

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.techfeed.feature.detail.databinding.ActivityDetailBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

@AndroidEntryPoint
class DetailHostActivity : AppCompatActivity() {

    private lateinit var binding: ActivityDetailBinding

    // viewModels() 委托:同一 Activity 实例内多次调用返回同一个 ViewModel
    private val viewModel: DetailViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        binding = ActivityDetailBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.btnFavorite.setOnClickListener {
            viewModel.toggleFavorite()
        }

        observeState()
    }

    private fun observeState() {
        // repeatOnLifecycle(STARTED):只在页面可见时收集,自动在 STOPPED 时取消
        // ⚠️ 这是 Flow 在 UI 层收集的标准写法,直接 lifecycleScope.launch{} 会浪费资源
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch {
                    viewModel.uiState.collect { state ->
                        binding.progress.isVisible = state.isLoading
                        binding.tvTitle.text = state.article?.title.orEmpty()
                        binding.btnFavorite.isSelected = state.article?.isFavorite == true
                    }
                }
                launch {
                    // 一次性事件(Toast/导航)用单独的 Flow,避免重建时重复触发
                    viewModel.events.collect { event ->
                        when (event) {
                            is DetailEvent.ShowToast -> toast(event.message)
                            is DetailEvent.NavigateUp -> finish()
                        }
                    }
                }
            }
        }
    }

    private fun toast(msg: String) {
        android.widget.Toast.makeText(this, msg, android.widget.Toast.LENGTH_SHORT).show()
    }
}

2.4 ViewModel:生命周期感知的状态容器

// 文件路径:sample/feature/detail/src/main/java/com/techfeed/feature/detail/ui/DetailViewModel.kt
package com.techfeed.feature.detail.ui

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.techfeed.core.model.Article
import com.techfeed.feature.detail.domain.GetArticleUseCase
import com.techfeed.feature.detail.domain.ToggleFavoriteUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

/** 详情页 UI 状态:一个不可变快照,UI 只负责渲染它 */
data class DetailUiState(
    val isLoading: Boolean = true,
    val article: Article? = null,
    val errorMessage: String? = null,
)

/** 一次性事件:不进入 UiState,避免旋转屏幕后重复弹 Toast */
sealed interface DetailEvent {
    data class ShowToast(val message: String) : DetailEvent
    data object NavigateUp : DetailEvent
}

@HiltViewModel
class DetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val getArticle: GetArticleUseCase,
    private val toggleFavorite: ToggleFavoriteUseCase,
) : ViewModel() {

    // 从导航参数中取文章 id(Navigation-Compose / Navigation 都会写进 SavedStateHandle)
    private val articleId: String = checkNotNull(savedStateHandle["articleId"])

    private val _uiState = MutableStateFlow(DetailUiState())
    val uiState: StateFlow<DetailUiState> = _uiState.asStateFlow()

    private val _events = MutableSharedFlow<DetailEvent>()
    val events: SharedFlow<DetailEvent> = _events.asSharedFlow()

    init {
        load()
    }

    private fun load() {
        viewModelScope.launch {
            _uiState.value = DetailUiState(isLoading = true)
            runCatching { getArticle(articleId) }
                .onSuccess { article ->
                    _uiState.value = DetailUiState(isLoading = false, article = article)
                }
                .onFailure { e ->
                    _uiState.value = DetailUiState(isLoading = false, errorMessage = e.message)
                }
        }
    }

    fun toggleFavorite() {
        viewModelScope.launch {
            runCatching { toggleFavorite(articleId) }
                .onFailure { _events.emit(DetailEvent.ShowToast("操作失败,请重试")) }
        }
    }
}

2.5 Service 与 BroadcastReceiver(最小可用示例)

// 文件路径:docs/samples/ch03/SyncService.kt
package com.techfeed.feature.push

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat

/**
 * 前台服务:必须在 5 秒内调用 startForeground,否则抛 ForegroundServiceStartNotAllowedException。
 * ⚠️ 现代 Android 中,能用 WorkManager 就不要用 Service(第 13 章)。
 */
class SyncService : Service() {

    override fun onCreate() {
        super.onCreate()
        createChannel()
        // 前台服务必须有可见通知,用户可随时在系统设置中停止
        startForeground(NOTIFICATION_ID, buildNotification())
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // START_STICKY:被杀后尝试重建(音乐类适用);数据同步应该用 START_NOT_STICKY
        return START_NOT_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? = null   // 非绑定服务返回 null

    private fun createChannel() {
        val channel = NotificationChannel(
            CHANNEL_ID, "数据同步", NotificationManager.IMPORTANCE_LOW,
        )
        getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
    }

    private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
        .setContentTitle("正在同步数据")
        .setSmallIcon(android.R.drawable.stat_notify_sync)
        .build()

    companion object {
        private const val CHANNEL_ID = "sync"
        private const val NOTIFICATION_ID = 1001
    }
}
// 文件路径:docs/samples/ch03/NetworkReceiver.kt
package com.techfeed.feature.push

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner

/**
 * 动态注册的广播:必须成对注册/注销,否则泄漏 Context。
 * 用 LifecycleObserver 绑定生命周期是最安全的写法。
 */
class NetworkReceiver(
    private val onAvailable: () -> Unit,
) : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == ConnectivityManager.CONNECTIVITY_ACTION) {
            onAvailable()
        }
    }

    companion object {
        /** 返回一个随生命周期自动注册/注销的观察者 */
        fun observer(context: Context, onAvailable: () -> Unit) = object : DefaultLifecycleObserver {
            private val receiver = NetworkReceiver(onAvailable)
            override fun onStart(owner: LifecycleOwner) {
                context.registerReceiver(receiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
            }
            override fun onStop(owner: LifecycleOwner) {
                context.unregisterReceiver(receiver)
            }
        }
    }
}

[!NOTE] Android 7+ 已禁用大部分静态隐式广播(包括 CONNECTIVITY_ACTION),所以网络监听应改用 ConnectivityManager.NetworkCallback 或 WorkManager 的网络约束。上面示例仅用于演示注册/注销配对。


三、常见坑 ⚠️

现象 根因 解法
启动 Activity 报 ActivityNotFoundException 未在 Manifest 声明;或 exported 未设置 补声明;带 intent-filter 时必须写 exported
旋转屏幕后数据重新加载、列表回到顶部 ViewModel 未使用 / 状态放在 Activity 字段里 状态提到 ViewModel;列表位置用 LazyListState 保存
旋转屏幕后 Toast 又弹了一次 一次性事件放在 UiState 里,重建时被重新收集 事件用 SharedFlow 单独通道(见 2.4)
Fragment 崩溃 Binding must not be null onDestroyView 后仍访问 binding onDestroyView 中置空 _binding,用 _binding?. 访问
onActivityResult 收不到结果 用了旧 API 或被系统杀死重建 改用 Activity Result API(下面示例)
回到主页时栈里堆了很多页面 未指定 launchMode / Flag 用 singleTask 或 CLEAR_TOP or SINGLE_TOP
startForegroundService() did not then call startForeground() 5 秒内未调用 startForeground onCreate 立即调用;或改用 WorkManager
静态广播收不到 Android 8+ 隐式广播限制 改动态注册,或用 WorkManager / FCM
CalledFromWrongThreadException 子线程更新 UI 切回主线程(withContext(Dispatchers.Main))

Activity Result API(取代 onActivityResult)

// 文件路径:docs/samples/ch03/ActivityResult.kt
// 在 Activity/Fragment 的初始化阶段注册(不能在点击事件里注册)
private val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { granted: Boolean ->
    if (granted) startSync() else showRationale()
}

// 触发
fun askPermission() {
    requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}

// 多个权限
private val requestMultiple = registerForActivityResult(
    ActivityResultContracts.RequestMultiplePermissions()
) { result: Map<String, Boolean> ->
    val allGranted = result.values.all { it }
}

四、调试技巧

# 1. 查看当前任务栈结构(排查"为什么回到这个页面")
adb shell dumpsys activity activities | head -40

# 2. 模拟旋转屏幕(验证配置变更处理是否正确)
adb shell settings put system accelerometer_rotation 0
adb shell content insert --uri content://settings/system --bind name:s:user_rotation --bind value:i:1

# 3. 模拟进程被系统杀死后重建(验证 SavedStateHandle 是否生效)
adb shell am kill com.techfeed.app        # 杀死进程但保留任务栈
# 然后从最近任务列表切回 App

# 4. 不保留活动(开发者选项:Don't keep activities)—— 调试生命周期的最佳开关
adb shell settings put global always_finish_activities 1

# 5. 强制触发配置变更(密度变化)
adb shell wm density 420 && adb shell wm density reset

IDE 内:

  • Logcat 过滤 ActivityThread / ActivityManager 可看到组件的创建销毁轨迹。
  • 在 onCreate、onStart、onResume 等加临时日志,配合"不保留活动"开关,能快速暴露状态保存问题。
  • App Inspection → Background Task Inspector 可查看 WorkManager 任务状态(第 13 章)。

五、最佳实践

  1. 状态一律放 ViewModel:Activity/Fragment 只做"把状态渲染出来"和"把用户事件上报"。
  2. 持久化状态用 SavedStateHandle:ViewModel 只跨配置变更,不跨进程死亡。
  3. 一次性事件用 SharedFlow:Toast、导航、弹窗都不应进 UiState。
  4. UI 层收集 Flow 用 repeatOnLifecycle(STARTED):避免后台仍在收集浪费资源。
  5. Fragment 的 binding 必须置空:onDestroyView 中 _binding = null,这是内存泄漏第一大来源。
  6. 后台任务优先 WorkManager:Service 只用于前台服务(音乐、导航、长连接)。
  7. 不需要被外部调起的组件全部 exported="false":这是安全基线。
  8. 避免 singleInstance:它会导致任务栈行为难以预测,99% 的场景用 singleTop/singleTask 就够了。
  9. 用 Activity Result API:类型安全、可测试,且不再依赖 requestCode 常量。
旧写法对照:startActivityForResult / onActivityResult
// 旧写法(已废弃):requestCode 魔法数字、类型不安全、在 Fragment 中容易丢回调
static final int REQ_LOGIN = 1001;
startActivityForResult(new Intent(this, LoginActivity.class), REQ_LOGIN);

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == REQ_LOGIN && resultCode == RESULT_OK) { /* ... */ }
}

新版 ActivityResultContract 把"请求—结果"封装成对象,可以在任意类中复用,也更容易单元测试。


六、本章小结

  1. 四大组件:Activity(UI 窗口)、Service(后台)、BroadcastReceiver(广播)、ContentProvider(跨应用数据);全部需在 Manifest 声明。
  2. 生命周期的核心是"什么时候该释放资源":onStop 释放重量级资源,onDestroy 不保证被调用。
  3. Fragment 有视图生命周期,onDestroyView 必须置空 binding;纯 Compose 新项目可以不用 Fragment。
  4. 配置变更(ViewModel 解决)与进程重建(SavedStateHandle 解决)是两件事,别混为一谈。
  5. 任务栈决定回退行为:standard / singleTop / singleTask 配合 Intent Flag 使用。

下一章:开始写界面。先看传统 View + XML 体系——它仍然是国内存量项目的主力。


七、动手练习

基础题

  1. 新建两个 Activity:MainActivity 与 DetailActivity。从 Main 点击按钮跳到 Detail,并传一个 articleId 字符串,在 Detail 中打印出来。
  2. 给 DetailActivity 加一个计数器,旋转屏幕后用 SavedStateHandle 保证计数不丢。
  3. 用 adb shell am start -n 直接启动 DetailActivity,验证 Manifest 声明是否正确。

进阶题 4. 开启开发者选项"不保留活动",反复在两个页面间跳转,观察是否存在崩溃或状态丢失,并修复。 5. 把详情页改成 "UiState + Event" 双通道:加载中/成功/失败放 UiState,Toast 放 SharedFlow。验证旋转屏幕时 Toast 不会重复弹出。

思考题 6. 为什么 Android 要设计"配置变更时销毁重建 Activity"这套机制,而不是自己适配屏幕方向?(提示:资源限定符与状态一致性) 7. ViewModel 在进程被杀后还能保留吗?如果不能,SavedStateHandle 又是怎么做到"跨越进程死亡"的?(提示:onSaveInstanceState 的 Bundle 序列化到系统进程) 8. 什么场景下你会坚持使用 Fragment 而不是纯 Compose?(至少说出两个真实理由)

📄 第 04 章 UI 基础:View 与 XML 布局 04-ui-view-xml-basics.md

阶段:入门 | 预计阅读 60 min | 代码落地模块::core:designsystem、:feature:detail 前置:第 03 章 应用组件与生命周期 相关章节:第 05 章 Compose 基础 | 第 07 章 资源与主题

学习目标

  • [ ] 掌握 View 体系的坐标概念(px / dp / sp)与三种常用布局的适用场景。
  • [ ] 能用 ConstraintLayout 搭建中等复杂度页面,且布局层级不超过 3 层。
  • [ ] 熟练使用 ViewBinding 访问控件,不再写 findViewById。
  • [ ] 理解 View 的绘制流程(measure → layout → draw)与事件分发(dispatchTouchEvent)的基本模型。
  • [ ] 能写一个简单的自定义 View,并解释 onMeasure / onDraw 中各自要注意什么。

一、核心概念

1.1 View 体系全景

flowchart TB
    W["Window(PhoneWindow)"] --> D["DecorView<br/>最顶层容器"]
    D --> B["ContentView<br/>setContentView 的根布局"]
    B --> VG["ViewGroup(容器)"]
    VG --> V1["TextView"]
    VG --> V2["ImageView"]
    VG --> V3["ViewGroup...<br/>递归嵌套"]

三个必须分清的概念:

概念 含义 常见误区
View 屏幕上最小的可交互矩形,一切控件的基类 "View 就是要显示的东西"——它还要承担测量与事件
ViewGroup 容器 View,负责排列子 View 嵌套越深越慢,这是性能问题第一大来源(第 17 章)
LayoutInflater 把 XML 解析成 View 对象树 XML 是运行时解析的,比代码构建慢,这是 Compose 出现的原因之一

1.2 尺寸与字号单位

单位 含义 用途
dp 密度无关像素,1dp = 160dpi 屏幕上的 1px 控件尺寸、间距
sp 与 dp 类似,但会跟随用户系统字号缩放 文字大小(尊重用户的无障碍设置)
px 实际像素 几乎不直接用;绘制/动画的像素级计算时转换
wrap_content 包裹内容 文字、图标
match_parent 撑满父容器 根布局、列表项
0dp + ConstraintLayout 匹配约束(MATCH_CONSTRAINT) ConstraintLayout 专用,用于均分与比例
// dp 与 px 的转换(自定义 View / 绘制时需要)
// 注意:顶层扩展函数访问不到 View 的 resources,必须把 Context 传进来
fun Float.dpToPx(context: Context): Float = this * context.resources.displayMetrics.density
fun Float.pxToDp(context: Context): Float = this / context.resources.displayMetrics.density

1.3 三种布局的取舍

布局 适用场景 禁忌
LinearLayout 单方向排列(列表项、表单行) 用嵌套 LinearLayout 模拟网格
FrameLayout 叠放(图片角标、Fragment 容器、蒙层) 排版
ConstraintLayout 复杂相对关系、需要比例/Barrier/Guideline 只有一两个控件也要套一层
RelativeLayout 不要新用,能力是 ConstraintLayout 子集 —
AbsoluteLayout 已废弃 —

[!TIP] 布局选型的经验法则:能用一层 ConstraintLayout 解决的,不要用三层 LinearLayout 嵌套。 测量是递归的,嵌套每深一层,整棵树的测量成本就乘一次。

1.4 ConstraintLayout 心智模型

ConstraintLayout 的每个子 View 需要水平、垂直各一组约束,否则会"飘"到 (0,0):

水平约束:start/end 相对父容器或兄弟控件的 start/end
垂直约束:top/bottom 相对父容器或兄弟控件的 top/bottom
bias:约束链的偏向(0~1),默认居中 0.5

常用工具一览:

属性 作用
app:layout_constraintXxx_toYyyOf 建立约束
app:layout_constraintDimensionRatio 宽高比(如 16:9 封面图)
app:layout_constraintHorizontal_bias 水平偏向
app:layout_constraintHorizontal_weight / weightSum 权重分配(配合 0dp)
Guideline 不可见参考线(百分比定位)
Barrier 动态边界(多个控件的最右/最左边界)
app:layout_constrainedWidth="true" wrap_content 时仍受约束限制

二、示例代码

我们做一个 TechFeed 的文章详情页头部:封面图、标题、作者信息、收藏按钮。这个例子覆盖了最常见的排版需求。

View / XML 版

<!-- 文件路径:docs/samples/ch04/view_article_header.xml -->
<?xml version="1.0" encoding="utf-8"?>
<!-- 根布局用 merge 避免 ConstraintLayout 之外多一层无意义容器(配合 include 使用) -->
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <!-- 封面:16:9 比例 + 圆角(ShapeableImageView 来自 Material 组件库) -->
    <com.google.android.material.imageview.ShapeableImageView
        android:id="@+id/ivCover"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scaleType="centerCrop"
        android:contentDescription="@string/article_cover"
        app:layout_constraintDimensionRatio="16:9"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:shapeAppearanceOverlay="@style/RoundedCover" />

    <!-- 标题:在封面下方,宽度撑满 -->
    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:maxLines="3"
        android:ellipsize="end"
        android:textAppearance="?attr/textAppearanceHeadlineSmall"
        app:layout_constraintEnd_toStartOf="@id/btnFavorite"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/ivCover"
        tools:text="深入理解 Compose 的重组机制" />

    <!-- 头像 -->
    <ImageView
        android:id="@+id/ivAvatar"
        android:layout_width="32dp"
        android:layout_height="32dp"
        android:layout_marginTop="12dp"
        android:contentDescription="@string/author_avatar"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tvTitle"
        tools:src="@drawable/ic_avatar_placeholder" />

    <!-- 作者名:垂直居中对齐头像(相对头像,而非相对标题,避免文字两行时错位) -->
    <TextView
        android:id="@+id/tvAuthor"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:textAppearance="?attr/textAppearanceBodyMedium"
        app:layout_constraintBottom_toBottomOf="@id/ivAvatar"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/ivAvatar"
        app:layout_constraintTop_toTopOf="@id/ivAvatar"
        tools:text="张三 · 2026-09-29" />

    <!-- 收藏按钮:标题右侧,与标题顶部对齐 -->
    <ImageButton
        android:id="@+id/btnFavorite"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:background="?attr/selectableItemBackgroundBorderless"
        android:contentDescription="@string/toggle_favorite"
        android:src="@drawable/ic_favorite_border"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@id/tvTitle" />

</merge>
// 文件路径:docs/samples/ch04/ArticleHeaderView.kt
package com.techfeed.feature.detail.ui

import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.techfeed.core.model.Article
import com.techfeed.feature.detail.R
import com.techfeed.feature.detail.databinding.ViewArticleHeaderBinding

/**
 * 自定义组合控件:把"头部"这块 UI 收成一个组件,外界只暴露一个 bind()。
 * 好处:布局复用、调用方零感知内部实现、方便做单测与截图测试。
 */
class ArticleHeaderView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : ConstraintLayout(context, attrs) {

    // ViewBinding 在 inflate 时同步创建,这里绑定的是"本视图自身",不再走 setContentView
    private val binding: ViewArticleHeaderBinding =
        ViewArticleHeaderBinding.bind(
            inflate(context, R.layout.view_article_header, this)
        )

    var onFavoriteClick: (() -> Unit)? = null

    init {
        binding.btnFavorite.setOnClickListener { onFavoriteClick?.invoke() }
    }

    /** 唯一的数据入口:UI 逻辑(格式化、图标切换)全部收敛在这里 */
    fun bind(article: Article, isFavorite: Boolean) {
        binding.tvTitle.text = article.title
        binding.tvAuthor.text = context.getString(
            R.string.author_and_date, article.author.name, article.publishedAt.toDisplayDate()
        )
        // isSelected 驱动 selector 状态图标,避免手动换 drawable
        binding.btnFavorite.isSelected = isFavorite
        // 图片加载用 Coil(第 09 章的网络体系配套)
        binding.ivCover.load(article.coverUrl) {
            crossfade(true)
            placeholder(R.drawable.ic_cover_placeholder)
        }
    }
}

需要的依赖(-module :feature:detail):

// 文件路径:sample/feature/detail/build.gradle.kts(节选)
android {
    buildFeatures {
        viewBinding = true   // ViewBinding 开关:只生成绑定类,不做任何注入,无运行时开销
    }
}

dependencies {
    implementation(libs.bundles.compose)   // 双体系并存的设计系统模块两者都要
    implementation(libs.androidx.constraintlayout)
    implementation(libs.material)
    implementation(libs.coil.view)
}

Compose 版(对照)

// 文件路径:docs/samples/ch04/ArticleHeader.kt
package com.techfeed.feature.detail.ui

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.techfeed.core.designsystem.theme.rounded16
import com.techfeed.core.model.Article
import com.techfeed.core.common.toDisplayDate

/** 与 XML 版一一对应的 Compose 实现:无状态组件,数据从参数进来,事件从回调出去 */
@Composable
fun ArticleHeader(
    article: Article,
    isFavorite: Boolean,
    onFavoriteClick: () -> Unit,
    modifier: Modifier = Modifier,      // modifier 永远是第一个带默认值的参数
) {
    Column(modifier = modifier.fillMaxWidth()) {
        AsyncImage(
            model = article.coverUrl,
            contentDescription = null,   // 纯装饰性图片传 null,避免朗读软件重复播报
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(16f / 9f)   // 对应 XML 的 dimensionRatio="16:9"
                .clip(MaterialTheme.shapes.rounded16),
        )
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text(
                text = article.title,
                style = MaterialTheme.typography.headlineSmall,
                maxLines = 3,
                modifier = Modifier.weight(1f),   // 对应 XML 的 0dp + 约束
            )
            IconButton(onClick = onFavoriteClick) {
                Icon(
                    // 用状态切换图标,替代 XML 的 isSelected + selector
                    imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
                    contentDescription = null,
                    tint = if (isFavorite) MaterialTheme.colorScheme.primary
                           else MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
        }
        Row(verticalAlignment = Alignment.CenterVertically) {
            AsyncImage(
                model = article.author.avatarUrl,
                contentDescription = null,
                modifier = Modifier.size(32.dp),
            )
            Text(
                text = "${article.author.name} · ${article.publishedAt.toDisplayDate()}",
                style = MaterialTheme.typography.bodyMedium,
                modifier = Modifier.padding(start = 8.dp),
            )
        }
    }
}

自定义 View 入门:绘制一个"阅读进度条"

// 文件路径:docs/samples/ch04/ReadingProgressView.kt
package com.techfeed.core.designsystem.view

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import com.techfeed.core.designsystem.R

/**
 * 阅读进度条:演示 onMeasure 与 onDraw 的最小闭环。
 * 关键点:
 *  1. onMeasure 必须调用 setMeasuredDimension,否则崩溃;
 *  2. 绘制用的对象(Paint)在构造时创建并复用,onDraw 里禁止 new 对象(每帧调用,会引发 GC 抖动);
 *  3. 属性变化后调用 invalidate() 触发重绘。
 */
class ReadingProgressView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : View(context, attrs) {

    private var progress = 0f

    private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = context.getColor(R.color.progress_track)
        style = Paint.Style.FILL
    }
    private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = context.getColor(R.color.progress_fill)
        style = Paint.Style.FILL
    }

    fun setProgress(value: Float) {
        progress = value.coerceIn(0f, 1f)
        invalidate()   // 请求重绘(主线程);子线程用 postInvalidate()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 默认高度 4dp:wrap_content 时给一个合理值,而不是 0
        val defaultHeight = 4f.dpToPx(context).toInt()
        val height = resolveSize(defaultHeight, heightMeasureSpec)
        setMeasuredDimension(getDefaultSize(suggestedMinimumWidth, widthMeasureSpec), height)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val width = width.toFloat()
        // 背景轨道
        canvas.drawRect(0f, 0f, width, height.toFloat(), trackPaint)
        // 前景进度
        canvas.drawRect(0f, 0f, width * progress, height.toFloat(), fillPaint)
    }
}

三、常见坑 ⚠️

现象 根因 解法
控件"消失"或挤在左上角 ConstraintLayout 子 View 缺少水平或垂直约束 每个方向至少一条约束;用 Layout Inspector 查看约束警告
NullPointerException 在 binding 上 Fragment 视图销毁后仍访问 binding onDestroyView 中 _binding = null(第 03 章)
布局渲染卡顿 嵌套过深(NestedScrollView 套 RecyclerView 再套 LinearLayout) 用 ConstraintLayout 压平;用 Layout Inspector 看层级
文字大小不跟随系统设置 写死了 android:textSize="14px" 或用了 px 一律 sp;代码里动态设置也用 sp 转换
RecyclerView 滚动掉帧 onBindViewHolder 里创建对象、加载大图 复用与懒加载;图片用 Coil 请求合适的尺寸
自定义 View 宽度为 0 wrap_content 时未处理 MeasureSpec,返回了 0 在 onMeasure 里给 wrap_content 提供默认尺寸
点击无响应但无报错 上层 View 消费了事件(如设置了 clickable=true 的父容器) 理解事件分发:子 View 优先;排查父容器的 onInterceptTouchEvent
深色模式下文字看不清 颜色硬编码 #FF000000 颜色全部走主题属性(?attr/colorOnSurface,见第 07 章)
include 后 findViewById 找不到 include 的布局根是 <merge>,id 在子 View 上 直接用 ViewBinding 的 bind(root),或给 include 加 id 后再往里找
旧写法对照:findViewById 与 ButterKnife
// 旧写法 1:findViewById 需要强转(旧版本),每个控件一行,多控件页面极其冗长
val tvTitle = findViewById<TextView>(R.id.tv_title)
val btnFavorite = findViewById<ImageButton>(R.id.btn_favorite)

// 旧写法 2:Kotlin synthetics(已废弃):视图 id 直接当属性用,但存在空指针与跨布局误用风险
import kotlinx.android.synthetic.main.view_article_header.*
tvTitle.text = article.title

// 旧写法 3:ButterKnife(停止维护)
@BindView(R.id.tv_title) lateinit var tvTitle: TextView

现代替代:ViewBinding(编译期生成、类型与空安全、零反射)。若团队使用 DataBinding 的双向绑定/表达式, 注意它编译更慢、报错更难读,新代码不建议引入。


四、调试技巧

  1. Layout Inspector(Tools → Layout Inspector):
    • 看真实运行的视图树、每个 View 的边界与属性(含 Compose 的重组次数)。
    • "Overlay" 模式可以在真机上叠加显示边界,排查"控件为什么被遮挡"。
  2. 显示布局边界(开发者选项):
    adb shell setprop debug.layout true
    
    直接在设备上看到每个 View 的裁剪边界与过度绘制染色。
  3. 过度绘制检测:开发者选项 → 调试 GPU 过度绘制。蓝色以上区域越多越要警惕(第 17 章)。
  4. 约束调试:ConstraintLayout 的警告会打印在 Logcat(tag ConstraintLayout),如 "do not constrain child vertically"。
  5. 只重绘验证:怀疑某个 View 绘制有问题时,用 View.setLayerType(LAYER_TYPE_HARDWARE, null) 隔离它,观察掉帧是否消失。
  6. 自动化布局检查:./gradlew :feature:detail:lintDebug,Lint 会报告 MissingConstraints、UselessParent 等问题(第 22 章接入 CI)。

五、最佳实践

  1. 层级越少越好:目标 3 层以内;能用 ConstraintLayout 一层解决的,不用嵌套。
  2. 一律 ViewBinding:findViewById、synthetics、ButterKnife 都是历史。
  3. 组合控件收敛 UI 逻辑:像 ArticleHeaderView 那样,外界只调用 bind(model),页面代码更薄。
  4. tools: 命名空间做预览数据:tools:text 不影响运行时,却让预览接近真实效果。
  5. 颜色、字号、间距全部走主题资源:禁止硬编码色值与字号(第 07 章主题体系)。
  6. 无障碍不是可选项:可点击控件用 ImageButton/Button 而不是裸 View;图片给 contentDescription(装饰图给 null)。
  7. 自定义 View 的 onDraw 里零分配:Paint、Rect 在构造时创建复用。
  8. 新页面直接用 Compose:View/XML 用于维护存量页面与互操作场景(第 16 章)。

六、本章小结

  1. View 体系的三层结构:Window → DecorView → 内容布局;布局 XML 是运行时解析的。
  2. 单位约定:dp 管尺寸、sp 管文字;wrap_content / match_parent / 0dp+约束 各司其职。
  3. 布局选型:单方向用 LinearLayout、叠放用 FrameLayout、复杂关系用 ConstraintLayout,RelativeLayout 不再新用。
  4. ViewBinding 是访问控件的唯一现代方式;Fragment 中记得在 onDestroyView 置空。
  5. 自定义 View 的最小闭环是 onMeasure(必须 setMeasuredDimension)+ onDraw(零对象分配)。

下一章:走进 Compose——同样的详情页头部,用声明式 UI 写出来是什么样,状态驱动和命令式刷新的差异到底在哪。


七、动手练习

基础题

  1. 用 ConstraintLayout 实现"点赞数 + 评论数 + 分享按钮"的三栏底部操作条:左中右三组,间距均分,高度 48dp。
  2. 把第 00 章的计数器页面改成 ViewBinding 版本,并用 tools:text 让预览显示非零计数。
  3. 写一个自定义 View TagView:绘制圆角矩形背景 + 居中文字,支持 wrap_content(提示:测量文字用 Paint.measureText)。

进阶题 4. 实现 ArticleHeaderView 的深色模式适配:颜色改用主题属性,验证切换深色模式后无需改代码(结合第 07 章)。 5. 把"标题 + 作者行"抽成一个可复用控件,同时提供 XML 与 Compose 两个实现,保证视觉一致(字号/间距用同一套设计令牌)。

思考题 6. 为什么说"XML 布局是运行时解析的"这件事,从根上限定了 View 体系的能力上限?(提示:类型安全、编译期检查、条件逻辑表达力) 7. ConstraintLayout 的 Barrier 和 Guideline 分别解决什么问题?如果不用它们,你需要付出什么代价? 8. 事件分发中"父容器拦截、子 View 消费"各发生在哪个回调?如果要实现"侧滑返回与横滑列表共存",你会怎么设计?

📄 第 05 章 UI 基础:Jetpack Compose 05-ui-compose-basics.md

阶段:入门 | 预计阅读 65 min | 代码落地模块::core:designsystem、:feature:feed 前置:第 04 章 View 与 XML | 第 01 章 Kotlin 速成 相关章节:第 06 章 列表与导航 | 第 17 章 性能优化

学习目标

  • [ ] 理解"声明式 UI"与"命令式 UI"的本质差异,说清**重组(Recomposition)**是怎么发生的。
  • [ ] 熟练使用 remember / rememberSaveable / mutableStateOf 管理状态。
  • [ ] 掌握**状态提升(State Hoisting)**模式,能写出可复用、可测试的无状态组件。
  • [ ] 能解释 LaunchedEffect / DisposableEffect / rememberCoroutineScope 各自的使用时机。
  • [ ] 会用 Material3 主题令牌(color/typography/shape)组织一套设计系统。

一、核心概念

1.1 命令式 vs 声明式

flowchart LR
    subgraph Imperative["命令式(View 体系)"]
        A1["数据变化"] --> A2["手动找到控件"]
        A2 --> A3["手动调用 setText / setVisibility"]
        A3 --> A4["UI 更新"]
    end
    subgraph Declarative["声明式(Compose)"]
        B1["状态变化"] --> B2["自动触发重组"]
        B2 --> B3["重新执行 Composable 函数"]
        B3 --> B4["生成新 UI 树并做最小差异更新"]
    end
维度 View 体系 Compose
UI 描述 XML(静态)+ 代码(动态) 纯 Kotlin 函数
状态 → UI 手动同步,容易漏 自动,漏不掉
局部更新 直接改 View 属性 智能重组:只重新执行读取了变化状态的函数
预览 需构建 + 渲染 @Preview 即时预览
双向沟通 View 持有数据引用 数据单向流动(UDF,第 14 章)

心智模型一句话:UI = f(state)。界面只是状态的函数,状态变了,界面自然重算。

1.2 重组的三个铁律

  1. 重组是"重新执行函数":Composable 函数可能被频繁调用,因此函数体内不要有副作用(写文件、改全局变量、发请求)。
  2. 只有读取了变化状态的代码才会重新执行:这就是"跳过(skipping)",是 Compose 性能的基石。
  3. 重组可能随时中断和重放:不要依赖执行次数(比如在函数体里做 count++)。
// ⚠️ 反例:副作用直接写在组合期
@Composable
fun BadExample(articleId: String) {
    // 每次重组都会发一次请求!旋转屏幕、键盘弹出、任何状态变化都会触发
    repository.loadArticle(articleId)   // ❌
}

// ✅ 正例:副效应放入 LaunchedEffect,以 articleId 为 key
@Composable
fun GoodExample(articleId: String) {
    LaunchedEffect(articleId) {
        repository.loadArticle(articleId)
    }
}

1.3 状态 API 速查

API 作用 典型场景
mutableStateOf() 创建可观察状态 UI 局部状态(输入框、开关)
remember {} 重组间记住对象 缓存计算结果、Controller
rememberSaveable {} 记住且能跨进程重建恢复 输入框内容、滚动位置
collectAsStateWithLifecycle() 把 Flow 转成 Compose 状态 收集 ViewModel 的 StateFlow
derivedStateOf {} 从其他状态派生,减少重组 list.contains(x) 之类的高频派生
LaunchedEffect(key) 进入组合时启动协程,key 变化或离开组合时取消 副效应、一次性加载
DisposableEffect(key) 需要清理的副效应 注册监听器
rememberCoroutineScope() 拿一个绑定组合的 Scope 事件回调里启动协程(如滚动列表)

1.4 状态提升(State Hoisting)

无状态组件的模式固定为三段式:

@Composable
fun XxxCard(
    value: T,                 // ① 状态:由调用方传入
    onValueChange: (T) -> Unit,  // ② 事件:向上报告
    modifier: Modifier = Modifier,  // ③ modifier 放最后(带默认值)
)

好处:组件可预览、可测试、可在多处复用;状态集中在拥有它的层级(通常是 ViewModel 或父组件)。


二、示例代码

2.1 状态与事件:搜索框

// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/SearchBar.kt
package com.techfeed.feature.feed.ui

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction

/**
 * 无状态搜索框:状态由调用方持有(提升),事件向上回调。
 * 这样它可以在预览中直接展示任意内容,也可以被任意页面复用。
 */
@Composable
fun FeedSearchBar(
    query: String,                       // ① 状态:提升到调用方
    onQueryChange: (String) -> Unit,     // ② 事件:上报变化
    onSearch: () -> Unit,                // ② 事件:上报搜索动作
    modifier: Modifier = Modifier,
) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = modifier.fillMaxWidth(),
        placeholder = { Text("搜索文章…") },
        leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
        trailingIcon = {
            if (query.isNotEmpty()) {
                IconButton(onClick = { onQueryChange("") }) {
                    Icon(Icons.Filled.Close, contentDescription = "清空")
                }
            }
        },
        singleLine = true,
        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
        keyboardActions = KeyboardActions(onSearch = { onSearch() }),
    )
}
// 有状态容器:在 ViewModel 侧持有真实状态(对应第 03 章的 UiState 模式)
// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedScreen.kt(节选)
@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
    // collectAsStateWithLifecycle:页面进入后台自动停止收集,避免浪费与泄漏
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    Column {
        FeedSearchBar(
            query = state.query,
            onQueryChange = viewModel::onQueryChange,   // 方法引用,避免 lambda 新建
            onSearch = viewModel::search,
        )
        // ……列表部分见第 06 章
    }
}

2.2 remember / rememberSaveable 的边界

// 文件路径:docs/samples/ch05/ReadingStateDemo.kt
package com.techfeed.feature.feed.ui

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.*

// 演示用简单类型(真实业务里是 core/model 的领域模型)
data class ReadingProgress(val articleId: String, val percent: Int)

@Composable
fun ReadingStateDemo() {
    Column {
        // 场景 A:临时 UI 状态。旋转屏幕会丢(remember 不落盘)——但它丢了也无妨
        var sliderExpanded by remember { mutableStateOf(false) }

        // 场景 B:用户输入。必须 rememberSaveable,旋转/进程重建都不能丢
        var keyword by rememberSaveable { mutableStateOf("") }
        OutlinedTextField(value = keyword, onValueChange = { keyword = it })

        // 场景 C:派生状态。字数统计随 keyword 自动重算,不需要手动刷新
        val charCount by remember(keyword) { derivedStateOf { keyword.length } }
        Text("已输入 $charCount 字")

        // 场景 D:自定义 Saver。ReadingProgress 是自定义类型,不在自动保存白名单里,
        // 用 listSaver 拆成基础类型列表:save 负责"拆"、restore 负责"组"
        var progress by rememberSaveable(stateSaver = listSaver(
            save = { listOf(it.articleId, it.percent) },
            restore = { ReadingProgress(it[0] as String, it[1] as Int) },
        )) { mutableStateOf(ReadingProgress("article-1", 0)) }

        Button(onClick = { sliderExpanded = !sliderExpanded }) { Text("展开") }
    }
}

[!WARNING] remember 的值在进程被杀后必然丢失。任何"用户会心疼的数据"(输入、选择、滚动位置)都要用 rememberSaveable 或上移到 ViewModel + SavedStateHandle(第 03 章)。

2.3 Material3 主题:设计令牌

// 文件路径:docs/samples/ch05/Color.kt
package com.techfeed.core.designsystem.theme

import androidx.compose.ui.graphics.Color

// 浅色配色(示例值,实际项目由设计师给色板)
val md_light_primary = Color(0xFF1B6EF3)
val md_light_onPrimary = Color(0xFFFFFFFF)
val md_light_surface = Color(0xFFFDFBFF)
val md_light_onSurface = Color(0xFF1A1C1E)

// 深色配色
val md_dark_primary = Color(0xFFA6C8FF)
val md_dark_onPrimary = Color(0xFF00315F)
val md_dark_surface = Color(0xFF111318)
val md_dark_onSurface = Color(0xFFE2E2E9)
// 文件路径:sample/core/designsystem/src/main/java/com/techfeed/core/designsystem/theme/Theme.kt
package com.techfeed.core.designsystem.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable

private val LightColors = lightColorScheme(
    primary = md_light_primary,
    onPrimary = md_light_onPrimary,
    surface = md_light_surface,
    onSurface = md_light_onSurface,
)

private val DarkColors = darkColorScheme(
    primary = md_dark_primary,
    onPrimary = md_dark_onPrimary,
    surface = md_dark_surface,
    onSurface = md_dark_onSurface,
)

@Composable
fun TechFeedTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),   // 默认跟随系统
    content: @Composable () -> Unit,
) {
    MaterialTheme(
        colorScheme = if (darkTheme) DarkColors else LightColors,
        typography = TechFeedTypography,
        shapes = TechFeedShapes,
        content = content,
    )
}

使用时永远通过令牌取值,而不是硬编码颜色:

Text(
    text = title,
    // ✅ 令牌:深浅色自动适配
    color = MaterialTheme.colorScheme.onSurfaceVariant,
    style = MaterialTheme.typography.bodyMedium,
)

2.4 副效应:什么时候用哪个

// 文件路径:docs/samples/ch05/SideEffects.kt
package com.techfeed.sample.sideeffects

import androidx.compose.runtime.*

/** 1. LaunchedEffect:进入组合启动协程;key 变化时取消旧协程、重启新协程 */
@Composable
fun ArticlePage(articleId: String, viewModel: ArticleViewModel) {
    LaunchedEffect(articleId) {           // articleId 变了 → 重新加载
        viewModel.load(articleId)
    }
}

/** 2. DisposableEffect:需要手动清理的资源(监听器、广播、回调) */
@Composable
fun ConnectivityBanner(connectivity: ConnectivityObserver) {
    val status by connectivity.status.collectAsStateWithLifecycle()

    DisposableEffect(connectivity) {
        // 开始:注册
        val handle = connectivity.register()
        onDispose {
            // 结束:注销 —— 与注册严格配对,漏了就是泄漏
            handle.unregister()
        }
    }
}

/** 3. rememberCoroutineScope:在事件回调中启动协程(不能在组合期直接 launch) */
@Composable
fun ScrollToTopButton(listState: LazyListState) {
    val scope = rememberCoroutineScope()
    Button(onClick = {
        scope.launch { listState.animateScrollToItem(0) }   // 事件回调里启动
    }) { Text("回到顶部") }
}

/** 4. SideEffect:每次成功重组后执行(把 Compose 状态同步给非 Compose 世界) */
@Composable
fun AnalyticsScreen(screenName: String) {
    SideEffect {
        Analytics.trackScreen(screenName)   // 埋点 SDK 是普通 Java 对象,不是 Compose 状态
    }
}

三、常见坑 ⚠️

现象 根因 解法
界面不刷新 状态不是 mutableStateOf,或读取方没有订阅 状态必须用 mutableStateOf 包装并在组合中读取
输入框无法输入 每次重组都创建了新的 mutableStateOf 状态放 remember 里,别在组合期无条件新建
LaunchedEffect 反复执行 key 写了不稳定对象(如 lambda/列表) key 用稳定值(id、Boolean);复杂对象用 remember 缓存
列表整页重组,卡顿 列表项函数直接读外层大状态对象 列表项只接收所需字段(item.article),缩小读取范围
旋转屏幕状态丢失 用了 remember 存用户数据 换 rememberSaveable 或上移 ViewModel
IllegalStateException: Reading a state that was created after the snapshot was taken 在 LaunchedEffect 里同步读组合后新创建的状态 把状态创建提到 effect 之前;或用 snapshotFlow
预览正常、运行时崩溃 预览没有 Android 运行时(LocalContext 等不可用) 预览组件保持无状态、无平台依赖,逻辑交给参数回调
CompositionLocal 传值失效 在组合外读取 CompositionLocal 只在 @Composable 内读取;staticCompositionLocalOf 与 compositionLocalOf 的选择要想清楚
Modifier 顺序导致点击失效 .clickable 放在了 .clip 之前/之后与预期不符 Modifier 是从外到内依次应用,顺序有语义,调整并验证

四、调试技巧

  1. 重组计数:Layout Inspector → Composition Counts,直接看每个组件重组了多少次(第 17 章性能优化入口)。
  2. 双击修饰符面板:在 @Preview 中,Layout Inspector 可以叠加显示 Compose 语义树边界。
  3. 语义树打印:UI 测试排障利器——
    composeTestRule.onRoot().printToLog("semantics")   // 在 Logcat 打印语义树
    
  4. debugInspectorInfo:给自定义 Modifier 写元数据,Layout Inspector 中能看到每个 modifier 的参数。
  5. 快照观察:Snapshot.withMutableSnapshot / snapshotFlow 可以把"状态变化"转成 Flow,用于打印状态轨迹:
    snapshotFlow { state.articles.size }
        .distinctUntilChanged()
        .collect { Log.d("Feed", "列表长度变化: $it") }
    
  6. 预览参数化:@Preview(name = "深色", uiMode = Configuration.UI_MODE_NIGHT_YES)、@Preview(fontScale = 1.3f),一次写多个预览覆盖关键形态。

五、最佳实践

  1. 组件分层:无状态叶子组件(可预览、可测试)→ 有状态容器(连 ViewModel)→ Screen(组装)。
  2. 状态提升三段式:value: T, onValueChange: (T) -> Unit, modifier: Modifier = Modifier。
  3. 状态下沉与上提的判断标准:谁能"读懂"这个状态就放在谁那里;多个组件共享就上提到最近公共父级或 ViewModel。
  4. 列表项只接收需要的数据:不要把整个 UiState 传给 item,否则任何字段变化都会让整列重组。
  5. 副效应 API 对号入座:协程用 LaunchedEffect、需要清理用 DisposableEffect、事件回调里用 rememberCoroutineScope、同步到非 Compose 世界用 SideEffect。
  6. 主题令牌优先:颜色/字体/形状一律从 MaterialTheme 取,杜绝硬编码。
  7. key() 明确标识列表项身份:配合 LazyColumn(第 06 章),让重组最小化。
  8. @Stable / @Immutable 慎用:先让数据类天然稳定(全 val + 基本类型/String),标注是兜底不是常规手段(第 17 章)。
对照:同样的搜索框在 View 体系里怎么写
<!-- View 体系需要:布局 + 代码双向同步 -->
<com.google.android.material.textfield.TextInputLayout ...>
    <com.google.android.material.textfield.TextInputEditText
        android:id="@+id/etSearch" ... />
</com.google.android.material.textfield.TextInputLayout>
// 命令式:状态与控件是两份事实,需要手动保持同步
binding.etSearch.doOnTextChanged { text, _, _, _ ->
    viewModel.onQueryChange(text.toString())
}
viewModel.query.observe(this) { query ->      // 状态回来还要防抖、防重复 set
    if (binding.etSearch.text.toString() != query) {
        binding.etSearch.setText(query)
    }
}

Compose 中 value = query, onValueChange = onQueryChange 一行完成双向绑定,且只有一份事实(ViewModel 里的状态)。


六、本章小结

  1. UI = f(state):状态是唯一事实源,Compose 通过智能重组自动把状态映射为界面。
  2. 重组铁律:函数体无副作用;只有读取变化状态的代码会重跑;执行次数不可依赖。
  3. 状态 API:mutableStateOf + remember(Saveable) 管局部状态,ViewModel + StateFlow 管业务状态,collectAsStateWithLifecycle 桥接。
  4. 状态提升三段式让组件可预览、可测试、可复用。
  5. 主题令牌(color/typography/shape)是设计系统的最小内核,深浅色与无障碍都从它受益。

下一章:把状态驱动应用到最重的 UI 场景——列表与页面导航,对比 RecyclerView 与 LazyColumn、Navigation 与 Navigation-Compose 两套方案。


七、动手练习

基础题

  1. 实现一个"标签选择器":横排若干标签,点击选中(多选),选中态用 MaterialTheme.colorScheme.primary 高亮;要求组件无状态、可预览。
  2. 写一个带 rememberSaveable 的倒计时组件:旋转屏幕后剩余秒数不重置。(提示:保存"结束时间戳"而不是"剩余秒数"更稳。)
  3. 用 derivedStateOf 实现"输入长度超过 140 字时变红提示"。

进阶题 4. 把第 04 章的 ArticleHeaderView(XML 版)与本章的 ArticleHeader(Compose 版)放在一起,逐项列出"手动同步点"的消失(至少列 5 处)。 5. 实现"折叠式详情页":点击标题展开/收起正文,展开动画用 animateContentSize();思考为什么不用 if/else 硬切。 6. 给 ArticleHeader 写 3 个 @Preview:浅色、深色、字体放大 1.3 倍。

思考题 7. remember { } 与 rememberSaveable { } 的实现差异是什么?为什么后者能跨进程死亡?(提示:Bundle 序列化进系统进程) 8. 为什么 Compose 要求"组合期无副作用"?如果不遵守,会出现哪三类具体 bug? 9. CompositionLocal 与"参数逐层传递"各适合什么场景?滥用 CompositionLocal 会让什么变差?(提示:可读性、可测试性)

📄 第 06 章 列表与导航 06-lists-and-navigation.md

阶段:入门 | 预计阅读 65 min | 代码落地模块::feature:feed、:app 前置:第 04 章 View 与 XML | 第 05 章 Compose 基础 相关章节:第 09 章 网络请求 | 第 14 章 分层架构

学习目标

  • [ ] 理解 RecyclerView 的回收复用机制,能写出带 DiffUtil 的 ListAdapter。
  • [ ] 会用 LazyColumn(key、contentType、分页),并解释它与 RecyclerView 的异同。
  • [ ] 掌握 Navigation 组件的核心概念(NavHost、NavController、back stack entry、参数)。
  • [ ] 会用 Navigation-Compose 搭建单 Activity 导航图,安全地传参与返回。
  • [ ] 知道列表性能的三板斧:稳定 key、缩小重组范围、避免过度测量。

一、核心概念

1.1 列表的本质:回收复用

屏幕只能显示十几个列表项,但数据可能有上万条。两套体系的核心思想一致——只创建"屏幕上正在显示的"那些视图对象:

flowchart LR
    subgraph RV["RecyclerView"]
        A1["onCreateViewHolder<br/>创建(很少发生)"] --> A2["onBindViewHolder<br/>绑定数据(滚动时反复)"]
        A2 --> A3["滚出屏幕"] --> A4["回收池 RecycledViewPool"]
        A4 --> A2
    end
    subgraph LC["LazyColumn"]
        B1["只组合可见项"] --> B2["滚出屏幕即销毁组合"]
        B2 --> B3["滚回屏幕重新组合<br/>(状态由 rememberSaveable 保存)"]
    end
维度 RecyclerView LazyColumn
模型 命令式:Adapter 手动通知增删改 声明式:数据变了自动重算可见项
复用 视图对象复用(ViewHolder) 组合复用(有 key 时),无 key 时整体重组
多类型 getItemViewType + 多 ViewHolder contentType + when 分支
局部更新 DiffUtil 计算最小变更 状态驱动 + key 自动最小化
分页 Paging3 + LoadState Paging3 + collectAsLazyPagingItems

1.2 为什么 key 如此重要

列表项没有稳定 id 时,框架只能按位置匹配新旧数据。典型事故:

  • 列表头部插入一条新数据,所有 item 的状态(勾选、动画、输入框内容)串位。
  • 删除中间一项后,动画错乱、图片闪烁。

稳定 key = 数据的身份(id),不是位置。 这是列表正确性的第一原则,两套体系通用。

1.3 导航的核心模型

flowchart LR
    NC["NavController<br/>导航的指挥者"] --> NH["NavHost<br/>承载目的地的容器"]
    NH --> D1["Destination A<br/>(Compose 路由 / Fragment)"]
    NH --> D2["Destination B"]
    NH --> D3["Destination C"]
    D1 -->|"navigate()"| D2 -->|"navigate()"| D3
    D3 -->|"popBackStack()"| D2 -->|"popBackStack()"| D1
  • 单 Activity 架构下,NavHost 是唯一的"页面容器",目的地(Destination)之间的切换不产生新的 Activity。
  • 返回栈(back stack)由 NavController 管理:navigate 压栈,系统返回键 / popBackStack 出栈。
  • 目的地之间不共享 ViewModel;跨页面共享数据用 Activity 级 ViewModel 或数据层(第 14 章)。

二、示例代码

2.1 RecyclerView 版信息流列表

// 文件路径:docs/samples/ch06/ArticleAdapter.kt
package com.techfeed.feature.feed.ui.view

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import com.techfeed.core.model.Article
import com.techfeed.feature.feed.databinding.ItemArticleBinding

/**
 * ListAdapter:内置 DiffUtil 的异步差量更新。
 * 调用方只需 submitList(newList),框架自动算出最小增删改并带动画。
 */
class ArticleAdapter(
    private val onClick: (Article) -> Unit,
) : ListAdapter<Article, ArticleAdapter.ArticleViewHolder>(DIFF) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
        val binding = ItemArticleBinding.inflate(
            LayoutInflater.from(parent.context), parent, false
        )
        return ArticleViewHolder(binding, onClick)
    }

    override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
        holder.bind(getItem(position))   // getItem 内部处理了 position 与数据的映射
    }

    class ArticleViewHolder(
        private val binding: ItemArticleBinding,
        onClick: (Article) -> Unit,
    ) : RecyclerView.ViewHolder(binding.root) {

        private var current: Article? = null

        init {
            // 点击监听只在创建时设置一次,绑定当前 item 引用 —— 避免每次 bind 都 new 监听器
            binding.root.setOnClickListener { current?.let(onClick) }
        }

        fun bind(article: Article) {
            current = article
            binding.tvTitle.text = article.title
            binding.tvMeta.text = "${article.author.name} · ${article.readMinutes} 分钟"
            binding.ivCover.load(article.coverUrl) {
                crossfade(true)
                placeholder(com.techfeed.core.designsystem.R.drawable.ic_cover_placeholder)
            }
        }
    }

    companion object {
        /**
         * DiffUtil:areItemsTheSame 比"身份"(id),areContentsTheSame 比"内容"。
         * ⚠️ 身份比较绝不能用位置或 equals 全量比对(贵且会误判)。
         */
        private val DIFF = object : DiffUtil.ItemCallback<Article>() {
            override fun areItemsTheSame(oldItem: Article, newItem: Article) =
                oldItem.id == newItem.id

            override fun areContentsTheSame(oldItem: Article, newItem: Article) =
                oldItem == newItem   // data class 自动实现 equals,直接比
        }
    }
}

2.2 Compose 版信息流列表

// 文件路径:docs/samples/ch06/FeedList.kt
package com.techfeed.feature.feed.ui

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.techfeed.core.model.Article

@Composable
fun FeedList(
    articles: List<Article>,
    onArticleClick: (String) -> Unit,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(16.dp),
) {
    LazyColumn(
        modifier = modifier.fillMaxSize(),
        contentPadding = contentPadding,   // 用 contentPadding 而非外层 padding:滚动时内容能画到边距外
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        items(
            items = articles,
            key = { it.id },           // ⭐ 稳定 key:身份是 id,不是位置
            contentType = { "article" },  // 提示同类型项可复用组合
        ) { article ->
            // ⭐ 只把 item 需要的数据传进来:article 变化只重组这一个 item
            ArticleListItem(
                article = article,
                onClick = { onArticleClick(article.id) },
            )
        }
    }
}

@Composable
private fun ArticleListItem(article: Article, onClick: () -> Unit) {
    Card(onClick = onClick) {
        Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
            AsyncImage(
                model = article.coverUrl,
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier
                    .size(72.dp)
                    .clip(MaterialTheme.shapes.small),
            )
            Column(modifier = Modifier.padding(start = 12.dp)) {
                Text(
                    text = article.title,
                    style = MaterialTheme.typography.titleMedium,
                    maxLines = 2,
                )
                Text(
                    text = "${article.author.name} · ${article.readMinutes} 分钟",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
        }
    }
}

2.3 Navigation-Compose:单 Activity 导航图

// 文件路径:sample/app/src/main/java/com/techfeed/app/navigation/TechFeedNavHost.kt
package com.techfeed.app.navigation

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.NavType
import androidx.navigation.toRoute
import com.techfeed.feature.detail.ui.DetailScreen
import com.techfeed.feature.feed.ui.FeedScreen
import kotlinx.serialization.Serializable

/**
 * 路由用密封类/序列化对象建模(类型安全导航):
 * 好处是参数名、类型都在编译期检查,字符串拼错直接编译失败。
 */
@Serializable
data object FeedRoute            // 列表页:无参数

@Serializable
data class DetailRoute(val articleId: String)   // 详情页:携带文章 id

@Composable
fun TechFeedNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier,
) {
    NavHost(
        navController = navController,
        startDestination = FeedRoute,   // 起始目的地
        modifier = modifier,
    ) {
        composable<FeedRoute> {
            FeedScreen(
                onArticleClick = { articleId ->
                    // 单一入口跳详情:所有页面跳转都经 NavHost,屏幕组件保持无导航依赖
                    navController.navigate(DetailRoute(articleId))
                },
            )
        }
        composable<DetailRoute> { backStackEntry ->
            // 从返回栈条目中取出路由对象,参数类型安全
            val route = backStackEntry.toRoute<DetailRoute>()
            DetailScreen(
                articleId = route.articleId,
                onBack = navController::popBackStack,
            )
        }
    }
}
// 文件路径:sample/app/src/main/java/com/techfeed/app/MainActivity.kt(节选)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TechFeedTheme {
                val navController = rememberNavController()
                Scaffold { innerPadding ->
                    TechFeedNavHost(
                        navController = navController,
                        modifier = Modifier.padding(innerPadding),
                    )
                }
            }
        }
    }
}

2.4 Navigation(View 体系)对照

<!-- 文件路径:docs/samples/ch06/nav_graph.xml -->
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_graph"
    app:startDestination="@id/feedFragment">

    <fragment
        android:id="@+id/feedFragment"
        android:name="com.techfeed.feature.feed.ui.view.FeedFragment"
        android:label="信息流"
        tools:layout="@layout/fragment_feed"
        xmlns:tools="http://schemas.android.com/tools">
        <!-- 声明式参数:跳转时用 navArgs 生成的类型安全类 -->
        <action
            android:id="@+id/action_feed_to_detail"
            app:destination="@id/detailFragment" />
    </fragment>

    <fragment
        android:id="@+id/detailFragment"
        android:name="com.techfeed.feature.detail.ui.view.DetailFragment"
        android:label="详情"
        tools:layout="@layout/fragment_detail">
        <argument
            android:name="articleId"
            app:argType="string"
            android:defaultValue="" />
    </fragment>
</navigation>
// Fragment 中跳转(View 体系)
private fun navigateToDetail(articleId: String) {
    findNavController().navigate(
        FeedFragmentDirections.actionFeedToDetail(articleId = articleId)
    )
}

// 目的地接收参数:navArgs 插件生成 Properties 类
class DetailFragment : Fragment() {
    private val args: DetailFragmentArgs by navArgs()
    // args.articleId 即为类型安全的参数
}

[!TIP] 两套 Navigation 怎么选? 新项目 / Compose 项目:Navigation-Compose + 类型安全路由。 存量 View 项目:Navigation XML + navArgs(比手写 Bundle 强一个量级)。 两者概念一致(目的地、返回栈、action),学会一套另一套自然通。

2.5 分页:Paging3 最小接入

// 文件路径:docs/samples/ch06/FeedPagingSource.kt
package com.techfeed.feature.feed.data

import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.techfeed.core.model.Article
import com.techfeed.core.network.api.TechFeedApi

/** 数据源:把"第 N 页"的请求翻译成 Paging 能理解的 Page */
class FeedPagingSource(
    private val api: TechFeedApi,
) : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val page = params.key ?: 1   // 首次加载 key 为 null
        return try {
            val response = api.getFeed(page = page, pageSize = params.loadSize)
            LoadResult.Page(
                data = response.articles,
                prevKey = if (page == 1) null else page - 1,   // 首页没有"上一页"
                nextKey = if (response.hasMore) page + 1 else null,  // 没有更多了
            )
        } catch (e: Exception) {
            LoadResult.Error(e)   // 错误交给 LoadState 处理,不要直接 crash
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Article>): Int? =
        state.anchorPosition?.let { anchor -> state.closestPageToPosition(anchor)?.prevKey?.plus(1) }
}

[!NOTE] 这里注入的是 TechFeedApi(getFeed 返回 FeedPageDto,字段为 articles / hasMore),与第 23/24 章及 sample/ 工程的最终形态一致;第 09 章网络层教学版(FeedApi.getArticles + ApiResponse 信封)是其演进前身。

// Compose 中消费分页流
@Composable
fun PagedFeedList(viewModel: FeedViewModel = hiltViewModel()) {
    val items = viewModel.articles.collectAsLazyPagingItems()

    when {
        items.loadState.refresh is LoadState.Loading -> FullScreenLoading()
        items.loadState.refresh is LoadState.Error -> ErrorRetry(
            onRetry = { items.retry() }
        )
        else -> LazyColumn {
            items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
                items[index]?.let { article -> ArticleListItem(article, onClick = {}) }
            }
            // 底部加载更多
            if (items.loadState.append is LoadState.Loading) {
                item { LoadingFooter() }
            }
        }
    }
}

三、常见坑 ⚠️

现象 根因 解法
列表项状态串位(勾选、动画错乱) 没有稳定 key RecyclerView 用 DiffUtil + itemId;LazyColumn 用 key = { it.id }
submitList 传入同一个 List 引用不刷新 ListAdapter 认为"没变化" 提交新列表对象(toList() 复制);根因是改了共享可变列表
Compose 列表滚动掉帧 item 内做复杂计算、把大状态对象传进 item item 只接收最小数据;耗时计算用 remember(item.id) 缓存
返回键直接退出 App 而不是回列表 popUpTo 配置错误或重复 navigate 检查 launchSingleTop、popUpTo(startDestination) { saveState = true }
重复点击按钮跳转两个详情页 快速连点触发两次 navigate 跳转前判断当前目的地;或用 launchSingleTop = true
IllegalStateException: no destination with ID action id 与 nav graph 不匹配;或 Fragment 未注册到图 检查 action 归属的 destination;确保 findNavController() 找的是 NavHostFragment
Fragment 跳转后 onCreateView 拿不到参数 用 arguments 手动解析且时机错误 用 navArgs 委托,或 savedStateHandle(第 03 章)
图片闪烁 key 不稳定导致 item 整体重建 稳定 key;Coil 请求加内存缓存策略
LazyColumn 嵌套 verticalScroll 崩溃 同方向嵌套滚动冲突 去掉 verticalScroll;或改用 LazyColumn 单一容器

四、调试技巧

# 1. 观察导航动作(logcat 里 Nav 报告每次入栈出栈)
adb logcat -s NavController

# 2. 查看返回栈
adb shell dumpsys activity activities | grep -A 20 "Historical Record"

Compose 侧:

  • navController.addOnDestinationChangedListener { _, dest, _ -> Log.d("Nav", "→ ${dest.route}") } 打印路由切换轨迹。
  • 列表调试:在 item 的 key 里临时拼上 hashCode(),如果滚动时频繁变化说明 key 不稳定。
  • LazyColumn 的 beyondBoundsPageCount/userScrollEnabled 等参数可用于复现滚动边界问题。

RecyclerView 侧:

  • RecyclerView.setItemViewCacheSize() 与 RecycledViewPool 状态可在 Profiler 的 Memory 面板观察。
  • Adapter.setHasStableIds(true)(配 getItemId)是 DiffUtil 的替代,但只适合全部 item 类型一致的简单列表。

五、最佳实践

  1. key 永远用业务 id,位置、索引、hashCode 都不是身份。
  2. 列表项最小化数据:传 Article 就够,别传 UiState;Compose 中每个 item 一个重组边界。
  3. 跳转收敛到 NavHost:Screen 组件只暴露 onArticleClick: (String) -> Unit 回调,不 import 导航库——组件才能脱离导航独立测试。
  4. 路由用类型安全建模(@Serializable data class),告别字符串拼接。
  5. 分页与缓存分离:Paging 管数据流,本地缓存策略交给 Repository(第 10、25 章)。
  6. 防重复跳转:统一在 NavHost 层做 launchSingleTop,而不是每个点击回调里各自判断。
  7. DiffUtil 只比较"身份 + 内容":areContentsTheSame 直接用 data class 的 equals;如有高频字段(如未读数),实现 ChangePayload 做局部刷新。
  8. 多类型列表用 contentType / getItemViewType 明确声明,复用效率显著提升。
旧写法对照:ListView 与手写 Bundle 跳转
// ListView:无强制复用约定、无差量更新、需要手动 notifyDataSetChanged()
listView.adapter = object : BaseAdapter() {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        var view = convertView
        if (view == null) view = layoutInflater.inflate(R.layout.item, parent, false) // 手写复用
        // 手动 findViewById……
        return view!!
    }
}

// 手写 Bundle 跳转:参数名是魔法字符串,类型不安全
val intent = Intent(context, DetailActivity::class.java)
intent.putExtra("article_id", id)     // 拼错不会报错,运行时才炸

六、本章小结

  1. 列表的灵魂是回收复用:RecyclerView 复用 View 对象,LazyColumn 只组合可见项。
  2. 稳定 key(业务 id) 是列表正确性的第一原则,两套体系通用。
  3. RecyclerView 用 ListAdapter + DiffUtil 获得差量更新;LazyColumn 用 key + contentType + 最小数据 获得高效重组。
  4. 导航统一心智模型:NavController 指挥 NavHost 里的目的地入栈出栈;参数用类型安全建模。
  5. 屏幕组件只暴露回调(onArticleClick),导航逻辑收敛在 NavHost——组件可独立预览与测试。

下一章:列表和页面都依赖的正确"皮肤"从哪来——资源体系、主题与深浅色、多语言与大屏自适应。


七、动手练习

基础题

  1. 用 ListAdapter + DiffUtil 实现一个"文章收藏列表",支持删除单项,验证删除中间项时动画与状态都正确。
  2. 用 LazyColumn 重写第 1 题,注意给 items 加 key;对比删除时的行为差异。
  3. 搭建一个两页导航图:列表页 → 详情页(传 articleId),用类型安全路由。

进阶题 4. 给信息流加"Paging3 分页 + 底部加载中/到底提示 + 失败重试",用 MockWebServer 模拟接口(第 09 章)。 5. 实现多类型列表:文章卡片、广告卡片、日期分隔头三种类型,两套体系各写一遍(contentType / 多 ViewHolder)。 6. 实现"列表滚动位置保存与恢复":旋转屏幕后 LazyColumn 停留在原位置(提示:rememberLazyListState + rememberSaveable)。

思考题 7. DiffUtil 的 areItemsTheSame 与 areContentsTheSame 为什么必须分开?如果只用 equals 一个函数,会损失什么? 8. 为什么 Navigation 组件要在页面切换时保存并恢复"视图状态"而不是"重建一切"?它与 ViewModel 的保存是同一层吗? 9. LazyColumn 中 item 里调用 remember(article.id) { ... } 的语义是什么?如果 key 换成 position 会出什么问题?

📄 第 07 章 资源、主题与自适应 07-resources-theming-adaptive.md

阶段:入门 | 预计阅读 55 min | 代码落地模块::core:designsystem 前置:第 04 章 View 与 XML | 第 05 章 Compose 基础 相关章节:第 19 章 安全加固(关于国际化合规) | 第 20 章 构建与发布

学习目标

  • [ ] 掌握 res/ 目录的资源分类与限定符(Qualifier)匹配规则,能解释"系统如何选中正确的资源"。
  • [ ] 会管理 strings、colors、dimens、styles/themes,理解"资源驱动"的设计思维。
  • [ ] 能正确实现深色模式与多语言(含 RTL),并知道为什么禁止硬编码颜色和文案。
  • [ ] 会用矢量图(VectorDrawable)与 WebP 控制图片资源体积与质量。
  • [ ] 理解窗口尺寸类(WindowSizeClass),能做手机/平板/折叠屏的自适应布局。

一、核心概念

1.1 res 目录与资源类型

res/
├─ drawable/            # 矢量图、Shape、LayerList、位图
├─ mipmap-*/            # 启动图标(系统会按密度选择,只放 launcher icon)
├─ layout/              # 布局
├─ values/              # strings.xml / colors.xml / dimens.xml / themes.xml / styles.xml
├─ values-night/        # 深色模式限定资源
├─ values-zh-rCN/       # 简体中文限定资源
├─ xml/                 # backup_rules、network_security_config 等
├─ font/                # 自定义字体
└─ raw/                 # 原始文件(音视频、JSON 种子数据)

编译后每个资源都有唯一整数 ID,通过 R 类访问。资源名就是 API:R.string.app_name。

1.2 限定符匹配规则

限定符是目录名的"后缀",系统在运行时按设备当前配置挑选最匹配的资源:

values-zh-rCN/  → 中文(中国)
values-night/   → 深色模式
layout-land/    → 横屏
drawable-xxhdpi/ → 超高密度屏
values-w600dp/  → 最小宽度 ≥600dp(平板)

匹配规则(重点):

  1. 淘汰:先剔除与设备配置冲突的目录(设备是中文,values-en/ 直接出局)。
  2. 逐级选择:按限定符优先级(语言 > 布局方向 > 夜间模式 > 密度 > 尺寸…)选最匹配的。
  3. 兜底:无匹配限定目录时,使用不带限定符的默认目录——所以 values/、drawable/ 永远必须存在。

[!WARNING] "只在 values-night/ 放深色、忘了 values/ 放浅色"是高频事故:部分设备/预览环境拿不到 night 资源时直接崩。

1.3 主题 vs 样式

概念 作用域 典型用途
style 单个 View 按钮的统一外观
theme Activity / Application / View 子树 全局配色、窗口背景、控件默认样式

主题的本质是属性查询表:XML 里写 ?attr/colorPrimary,运行时从当前主题查值。这也是深浅色"零代码适配"的机制——同一属性在不同主题下指向不同色值。

Compose 中的对应物是 MaterialTheme(colorScheme, typography, shapes)(第 05 章),两者靠设计令牌对齐。

1.4 窗口尺寸类(WindowSizeClass)

折叠屏与平板普及后,"按屏幕尺寸"判断已经过时,官方推荐按窗口可用宽高分类:

类别 断点 典型设备
Compact 宽 < 600dp 竖屏手机
Medium 600dp ≤ 宽 < 840dp 平板竖屏 / 大折叠展开内屏
Expanded 宽 ≥ 840dp 平板横屏、桌面模式

判断依据是窗口而非物理屏幕:分屏、自由窗口、折叠态变化时窗口类随之变化。


二、示例代码

2.1 资源文件组织

<!-- 文件路径:sample/core/designsystem/src/main/res/values/strings.xml -->
<resources>
    <string name="app_name">TechFeed</string>
    <!-- 带占位符的文案:位置参数用 %1$s,翻译人员可自由调整语序 -->
    <string name="author_and_date">%1$s · %2$s</string>
    <!-- 复数:不同数量用不同模板,英文会处理 "1 article" / "3 articles" -->
    <plurals name="article_count">
        <item quantity="one">%d 篇文章</item>
        <item quantity="other">%d 篇文章</item>
    </plurals>
</resources>
<!-- 文件路径:sample/core/designsystem/src/main/res/values/colors.xml -->
<resources>
    <!-- 语义命名,不写"浅蓝/深蓝"这种视觉描述,避免主题切换时语义失效 -->
    <color name="surface">#FDFBFF</color>
    <color name="on_surface">#1A1C1E</color>
    <color name="primary">#1B6EF3</color>
    <color name="progress_track">#1F1A1C1E</color>
    <color name="progress_fill">#FF1B6EF3</color>
</resources>
<!-- 文件路径:sample/core/designsystem/src/main/res/values-night/colors.xml -->
<resources>
    <!-- 深色模式:同名资源覆盖值,业务代码零改动 -->
    <color name="surface">#111318</color>
    <color name="on_surface">#E2E2E9</color>
    <color name="primary">#A6C8FF</color>
</resources>
// 代码中访问资源的正确姿势(View 体系)
binding.tvTitle.text = context.getString(R.string.author_and_date, author.name, date)
binding.root.setBackgroundColor(ContextCompat.getColor(context, R.color.surface))
val count = context.resources.getQuantityString(R.plurals.article_count, n, n)

2.2 主题:XML 侧

<!-- 文件路径:sample/core/designsystem/src/main/res/values/themes.xml -->
<resources>
    <!-- Material3 主题:定义DayNight父主题,深浅色自动切换 -->
    <style name="Theme.TechFeed" parent="Theme.Material3.DayNight.NoActionBar">
        <item name="colorPrimary">@color/primary</item>
        <item name="android:colorBackground">@color/surface</item>
        <item name="android:windowBackground">@color/surface</item>
        <!-- 状态栏跟随 surface 颜色(Android 15+ 已默认沉浸,这里兼容旧版本) -->
        <item name="android:statusBarColor">@android:color/transparent</item>
        <item name="android:windowLightStatusBar">true</item>
    </style>

    <!-- 复用样式:圆角封面 -->
    <style name="RoundedCover">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">16dp</item>
    </style>
</resources>
<!-- 文件路径:sample/core/designsystem/src/main/res/values-night/themes.xml -->
<resources>
    <style name="Theme.TechFeed" parent="Theme.Material3.DayNight.NoActionBar">
        <item name="colorPrimary">@color/primary</item>
        <item name="android:colorBackground">@color/surface</item>
        <item name="android:windowBackground">@color/surface</item>
        <item name="android:statusBarColor">@android:color/transparent</item>
        <item name="android:windowLightStatusBar">false</item>
    </style>
</resources>

2.3 主题:Compose 侧(与 XML 对齐)

// 文件路径:sample/core/designsystem/src/main/java/com/techfeed/core/designsystem/theme/Tokens.kt
package com.techfeed.core.designsystem.theme

import androidx.compose.material3.Typography
import androidx.compose.material3.Shapes
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

/** 设计令牌:与 XML 主题共用同一套数值,保证双体系视觉一致 */
val TechFeedTypography = Typography(
    headlineSmall = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.SemiBold, lineHeight = 32.sp),
    titleMedium = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 24.sp),
    bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 20.sp),
    bodySmall = TextStyle(fontSize = 12.sp, lineHeight = 16.sp),
)

val TechFeedShapes = Shapes(
    small = RoundedCornerShape(8.dp),
    medium = RoundedCornerShape(12.dp),
    large = RoundedCornerShape(16.dp),
)

[!TIP] 双体系并行时,设计令牌是唯一对齐点:XML 的 styles.xml 与 Compose 的 Theme.kt 都从同一份设计规范生成。 团队可以写一个脚本从 Figma 导出令牌同时生成两份代码,杜绝"两边颜色不一致"的低级 bug。

2.4 矢量图与图片资源

<!-- 文件路径:docs/samples/ch07/ic_favorite_border.xml -->
<!-- 矢量图:一个文件适配所有密度,体积只有位图的几百分之一 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="24"
    android:viewportHeight="24"
    android:tint="?attr/colorControlNormal">
    <path
        android:fillColor="@android:color/white"
        android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z" />
</vector>

图片资源决策表:

类型 适用 注意
VectorDrawable 图标、简单插画 复杂插画转矢量会暴涨体积
WebP(有损/无损) 内容图片、运营图 比 PNG 小 25-35%
PNG 需要透明的位图兜底 尽量转 WebP
JPG 无透明需求的照片 渐变易出现色带
网络加载 运营可变内容 客户端只放占位图(Coil,第 09 章)

[!TIP] mipmap 目录只放启动图标,其余图片一律放 drawable;启动图标要提供自适应图标(ic_launcher.xml + 前景/背景层)。

2.5 多语言与 RTL

values/            # 默认(英语),翻译缺失时的兜底 —— 必须完整
values-zh-rCN/     # 简体中文
values-zh-rTW/     # 繁体中文(注意:使用中国台湾等地区代码,遵循 ISO 639-1/3166 规范)
values-ar/         # 阿拉伯语:布局需支持 RTL 镜像

RTL 适配清单:

<!-- 用 start/end 而不是 left/right,RTL 下自动镜像 -->
android:layout_marginStart="8dp"
android:paddingEnd="16dp"
<!-- 图标是否需要镜像 -->
android:autoMirrored="true"
// 强制应用内语言切换(AppCompat 1.6+,无需重启 Activity)
val localeList = LocaleListCompat.forLanguageTags("zh-CN")
AppCompatDelegate.setApplicationLocales(localeList)

[!WARNING] 文案绝不能写在代码或布局里(android:text="确认")。一是不走翻译流程,二是 Lint 会报 HardcodedText。所有用户可见文案进 strings.xml。

2.6 窗口尺寸类自适应

// 文件路径:sample/app/src/main/java/com/techfeed/app/ui/TechFeedApp.kt(节选)
package com.techfeed.app.ui

import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.activity.ComponentActivity

@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun TechFeedApp(activity: ComponentActivity) {
    // 窗口尺寸类:随分屏、折叠、旋转自动变化
    val windowSize = calculateWindowSizeClass(activity)

    when (windowSize.widthSizeClass) {
        WindowWidthSizeClass.Compact -> FeedScreenCompact()      // 手机:单栏 + 底部导航
        WindowWidthSizeClass.Medium -> FeedScreenMedium()        // 小平板:导航栏 + 单列表
        WindowWidthSizeClass.Expanded -> FeedScreenExpanded()    // 平板横屏:列表+详情 双栏
        else -> FeedScreenCompact()
    }
}
// 双栏(列表-详情)是平板体验的关键:不能只是"放大版手机 UI"
@Composable
fun FeedScreenExpanded() {
    Row {
        ArticleList(modifier = Modifier.width(360.dp))
        VerticalDivider()
        DetailPane(modifier = Modifier.weight(1f))   // 详情占据剩余宽度
    }
}

三、常见坑 ⚠️

现象 根因 解法
图标在高分屏模糊 只放了一套 drawable-mdpi,系统放大绘制 用矢量图;位图必须提供至 xxhdpi
深色模式下白底黑字刺眼 颜色硬编码,或只写了 values-night 忘了默认值 颜色全走主题属性;默认目录必须齐全
切换语言后部分文案变英文 values-zh-rCN/ 缺失条目回落到默认英语 用 lint 的 MissingTranslation 检查;翻译平台同步
NullPointerException 取资源 R 类 import 错模块(nonTransitiveRClass) 用本模块 namespace 对应的 R
横屏后布局错乱 只做了竖屏布局 layout-land/ 提供横屏变体;或改用约束+权重
折叠屏展开后 UI 异常 以物理屏幕尺寸做判断 改用 WindowSizeClass,监听窗口变化重新计算
RTL 语言下箭头方向错误 用了 left/right 属性与未镜像图标 全局替换为 start/end;方向图标 autoMirrored
APK 里塞了十几个密度的位图 美术导出全套密度位图 转 WebP/矢量图,只保留最高密度一套
strings.xml 格式化崩(% 报错) 字符串含 % 未转义 用 %%;多占位符用 %1$s 显式编号

四、调试技巧

  1. 预览任意配置:Android Studio 布局预览可切换深色、语言、字号、屏幕尺寸,不用真机来回折腾。
  2. 强制深色模式:
    adb shell cmd uimode night yes
    adb shell cmd uimode night no
    
  3. 强制 RTL 布局:开发者选项 → 强制 RTL 布局方向,立刻暴露所有 left/right 残留。
  4. 资源混淆排查:怀疑资源名冲突时用 aapt2 dump resources app-debug.apk | grep <name> 查看 APK 内资源表。
  5. 图片内存估算:一张 1080×1920 的 ARGB_8888 位图占 8.3MB 内存(1080×1920×4 字节),与文件大小无关——这就是为什么要让 Coil 下载"合适尺寸"的图(第 17 章)。
  6. 翻译完整性:./gradlew lint 会输出 MissingTranslation 与 PluralsCandidate 报告。

五、最佳实践

  1. 资源名语义化:color/surface、dimen/spacing_m、style/TextTitle,杜绝 color/blue1。
  2. 默认目录永远完整:values/、drawable/ 是兜底,不能为空壳。
  3. 一切用户可见文案进 strings.xml,占位符用 %1$s 显式编号。
  4. 图标优先矢量图,内容图用 WebP;mipmap 只放启动图标。
  5. 深浅色/多语言零代码适配:靠限定符与主题属性,不要在代码里 if (isDark) ...。
  6. 大屏不是放大版手机:用 WindowSizeClass 切换导航形态与双栏布局。
  7. 设计令牌双端同源:XML 主题与 Compose 主题从同一份令牌生成。
  8. 无障碍从资源开始:contentDescription、对比度(4.5:1)、sp 字号、触控目标 ≥48dp。

六、本章小结

  1. res/ 是声明式配置中心:资源名即 API,限定符即条件,运行时按设备配置自动匹配。
  2. 主题是属性查询表:?attr/colorXxx 让深浅色切换零代码;Compose 与 XML 靠设计令牌对齐。
  3. 文案进 strings.xml(显式占位符 + plurals),颜色走语义命名,图标优先矢量图。
  4. 多语言三件套:默认语言兜底完整、RTL 用 start/end、应用内切换用 AppCompatDelegate。
  5. 自适应的正解是 WindowSizeClass:手机单栏、平板双栏,窗口变化驱动布局重构。

下一章:跨出"页面"的边界——Intent 如何发起与接收通信、权限如何申请、进程之间如何对话。


七、动手练习

基础题

  1. 为 TechFeed 补全深色模式:把第 04 章 ArticleHeaderView 的所有硬编码色替换为主题属性,用 adb shell cmd uimode night yes/no 验证零代码切换。
  2. 新建 values-zh-rCN/strings.xml,给现有页面补一份中文文案,验证系统语言切换后即时生效。
  3. 把一张 512×512 的 PNG 图标转成 VectorDrawable,对比资源体积与各密度下的清晰度。

进阶题 4. 实现"应用内语言切换":设置页提供中文/英文选项,用 AppCompatDelegate.setApplicationLocales 即时生效并持久化。 5. 用 WindowSizeClass 实现列表-详情双栏:手机上点击跳转详情页,平板(Expanded)上右侧面板内嵌显示。 6. 把 App 的全部图标资源从 PNG 迁移到矢量图,统计 APK 体积变化(用 ./gradlew assembleDebug 前后对比)。

思考题 7. 为什么 Android 要求"默认资源目录必须存在"?如果不存在,资源解析失败时会发生什么? 8. "设计令牌"解决的是设计系统的什么问题?如果没有令牌,双体系(XML+Compose)并行开发会积累哪类 bug? 9. WindowSizeClass 为什么以"窗口"而非"屏幕"为判断依据?举两个会导致两者不一致的真实场景。

📄 第 08 章 Intent、IPC 与权限 08-intent-ipc-and-permissions.md

阶段:入门 | 预计阅读 55 min | 代码落地模块::feature:auth、:app 前置:第 03 章 应用组件与生命周期 | 第 06 章 导航 相关章节:第 13 章 后台任务与通知 | 第 19 章 安全加固

学习目标

  • [ ] 能区分显式与隐式 Intent,掌握 Intent 的组成(action、data、extras、flags)。
  • [ ] 会实现与调试深链(Deep Link)与 App Links,处理"从外部拉起 App"的完整链路。
  • [ ] 理解运行时权限模型(普通/危险/特殊权限),能写出规范的通知/定位权限申请流程。
  • [ ] 理解 Android 的进程隔离与 Binder 通信模型,能解释 AIDL 解决什么问题。
  • [ ] 知道跨进程传数据的边界(Bundle 大小限制、Serializable vs Parcelable)。

一、核心概念

1.1 Intent 的解剖

flowchart TB
    I["Intent(意图)"]
    I --> ACT["action:要做什么(VIEW / SEND / ...)"]
    I --> DATA["data + type:对什么做(URI + MIME)"]
    I --> CAT["category:附加语境(BROWSABLE / DEFAULT)"]
    I --> EX["extras:键值对参数(Bundle)"]
    I --> CMP["component:明确目标(显式 Intent)"]
类型 特点 安全性 场景
显式 Intent 直接指定 component(类名) 高,目标明确 应用内跳转
隐式 Intent 只描述意图,由系统匹配 低,可能被任意 App 截获 分享、拨号、打开网页、深链
// 显式:应用内跳转(新代码应优先走 Navigation,Intent 用于启动 Activity 场景)
val intent = Intent(context, DetailActivity::class.java)
    .putExtra("articleId", articleId)

// 隐式:交给系统匹配合适的处理者
val share = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_TEXT, "推荐阅读:$title $url")
}
// 用 chooser 让用户每次选择分享目标,而不是记忆默认应用
context.startActivity(Intent.createChooser(share, "分享文章"))

[!WARNING] 隐式 Intent 的响应方是全系统。发起前必须检查有没有 App 能接: intent.resolveActivity(packageManager) != null(Android 11+ 还需在 Manifest 声明 <queries>,见下文)。

1.2 深链与 App Links

方案 触发方式 可信度 要求
Deep Link(自定义 scheme) techfeed://article/123 低,任何 App 都可注册相同 scheme Manifest 声明即可
App Links(https + 域名验证) https://techfeed.dev/article/123 高,系统验证域名归属 服务器放 assetlinks.json
<!-- 文件路径:sample/app/src/main/AndroidManifest.xml(节选,第 03 章已出现,这里补全说明) -->
<!-- 自定义 scheme:开发调试方便,但可被劫持,正式收单等敏感场景必须用 App Links -->
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="techfeed" android:host="article" />
</intent-filter>

<!-- App Links:autoVerify=true 触发系统域名验证 -->
<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="techfeed.dev" android:pathPrefix="/article" />
</intent-filter>

服务器侧验证文件(路径固定,不可改):

// 文件路径(服务器):https://techfeed.dev/.well-known/assetlinks.json
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.techfeed.app",
    "sha256_cert_fingerprints": [
      "AB:CD:EF:...(签名证书 SHA-256,用 keytool 获取,debug 与 release 各一条)"
    ]
  }
}]

1.3 Android 权限模型

级别 授权方式 例子
普通权限 安装时自动授予 INTERNET、VIBRATE、POST_NOTIFICATIONS(13+ 为运行时)
危险权限 运行时弹窗,用户可拒绝/随时撤销 定位、相机、麦克风、存储、通讯录
特殊权限 需跳系统设置或专用流程 悬浮窗、无障碍、精确闹钟、电池优化白名单

核心流程:

flowchart LR
    A["需要危险权限"] --> B{"清单已声明?"}
    B -->|否| F["直接崩溃 SecurityException"]
    B -->|是| C{"已授权?"}
    C -->|是| E["执行"]
    C -->|否| D{"shouldShowRequestPermissionRationale?"}
    D -->|"true(拒绝过)"| G["先解释为什么需要,再申请"]
    D -->|"false(首次)"| H["直接申请"]
    G --> H
    H --> I{"用户授予?"}
    I -->|否| J["降级运行 / 引导设置页"]

[!IMPORTANT] 权限三原则:最小化(不要一次申请一串)、按需申请(用到才申请,且解释用途)、优雅降级(拒绝后功能不可用但 App 可用)。 上架审核(包括国内应用商店)都会检查权限与功能的匹配度。

1.4 进程隔离与 Binder

flowchart LR
    subgraph P1["App A 进程(沙箱)"]
        A["A 的内存空间"]
    end
    subgraph K["内核空间"]
        BD["Binder 驱动"]
    end
    subgraph P2["App B / 系统服务进程"]
        B["B 的内存空间"]
    end
    A -->|"copy_from_user(一次拷贝)"| BD
    BD -->|"copy_to_user"| B
  • 每个 App 运行在独立进程、独立沙箱,不能直接读写对方内存。
  • 所有跨进程调用(IPC)都经过 Binder 驱动:系统服务(AMS、WMS、PMS)本质都是 Binder 服务,你调用的 startActivity 最终都是一次 IPC。
  • 应用层跨进程通信的选择:AIDL(强类型接口)、Messenger(消息队列式)、ContentProvider(结构化数据)、Broadcast(一对多通知)、文件/SharedPreferences(不推荐跨进程,有并发问题)。

二、示例代码

2.1 运行时权限:通知权限完整流程

// 文件路径:docs/samples/ch08/NotificationPermissionManager.kt
package com.techfeed.feature.push

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts

/**
 * 通知权限管理(Android 13+ POST_NOTIFICATIONS 为运行时权限)。
 * 设计要点:
 *  1. 权限逻辑集中在一个类里,UI 层只调用 askIfNeeded();
 *  2. 拒绝后不重复骚扰:第二次拒绝引导去系统设置。
 */
class NotificationPermissionManager(
    private val activity: ComponentActivity,
    private val onResult: (granted: Boolean) -> Unit,
) {
    private val launcher = activity.registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted -> onResult(granted) }

    fun isGranted(): Boolean =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            ContextCompat.checkSelfPermission(
                activity, Manifest.permission.POST_NOTIFICATIONS
            ) == PackageManager.PERMISSION_GRANTED
        } else {
            true   // Android 13 以下安装即授予
        }

    /** 入口:先判断,被拒过一次则先解释再申请 */
    fun askIfNeeded(explainRationale: () -> Unit) {
        when {
            isGranted() -> onResult(true)
            activity.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) ->
                explainRationale()   // 用户拒绝过:先弹解释对话框,确认后再 launch
            else -> launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
    }

    /** 引导用户去系统设置(彻底拒绝后唯一路径) */
    fun openAppSettings() {
        activity.startActivity(
            android.content.Intent(
                android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS
            ).putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, activity.packageName)
        )
    }
}

2.2 深链接收与分发

// 文件路径:sample/app/src/main/java/com/techfeed/app/deeplink/DeepLinkParser.kt
package com.techfeed.app.deeplink

import android.net.Uri

/** 深链解析:把 URI 翻译成强类型的路由指令,白名单校验是安全底线 */
sealed interface DeepLink {
    data class Article(val id: String) : DeepLink
    data class Profile(val userId: String) : DeepLink
    data object Home : DeepLink
}

object DeepLinkParser {

    fun parse(uri: Uri?): DeepLink {
        if (uri == null) return DeepLink.Home
        return when {
            // techfeed://article/123 或 https://techfeed.dev/article/123
            uri.host == "article" || (uri.host == "techfeed.dev" && uri.pathSegments.firstOrNull() == "article") ->
                DeepLink.Article(uri.lastPathSegment.orEmpty())

            // https://techfeed.dev/u/42
            uri.host == "techfeed.dev" && uri.pathSegments.firstOrNull() == "u" ->
                DeepLink.Profile(uri.pathSegments.getOrNull(1).orEmpty())

            else -> DeepLink.Home   // 未识别一律回首页,绝不能白屏
        }
    }
}

[!NOTE] Android 11+ 的包可见性限制:隐式 Intent 调用 resolveActivity / queryIntentActivities 前, 必须在 Manifest 里声明 <queries>,否则查询结果永远为 null(且不报错,极难排查):

<!-- 文件路径:sample/app/src/main/AndroidManifest.xml(节选) -->
<queries>
    <!-- 允许查询能处理网页/分享的应用 -->
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" />
    </intent>
    <intent>
        <action android:name="android.intent.action.SEND" />
        <data android:mimeType="text/plain" />
    </intent>
</queries>

2.3 AIDL:跨进程接口的最小示例

// 文件路径:docs/samples/ch08/ICounterService.aidl
// AIDL 文件:定义跨进程接口(服务端与客户端共享同一份)
package com.techfeed.core.common;

interface ICounterService {
    int getCount();
    void increment();
}
// 文件路径:docs/samples/ch08/CounterService.kt
package com.techfeed.core.common

import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.Process
import android.util.Log

/** 服务端:实现 AIDL 生成的 Stub,运行在独立进程(Manifest 里 android:process=":counter") */
class CounterService : Service() {

    private var count = 0

    // Stub 是 AIDL 编译期生成的抽象类,实现了跨进程调用到本地调用的翻译
    private val binder = object : ICounterService.Stub() {
        override fun getCount(): Int = count
        override fun increment() {
            count++
            Log.d("CounterService", "increment in pid=${Process.myPid()}")
        }
    }

    override fun onBind(intent: Intent?): IBinder = binder
}
// 客户端绑定与调用(异步!所有 IPC 调用都可能阻塞,要在子线程做)
suspend fun bindAndIncrement(context: Context) = withContext(Dispatchers.IO) {
    val result = suspendCancellableCoroutine { cont ->
        var service: ICounterService? = null
        val conn = object : ServiceConnection {
            override fun onServiceConnected(name: ComponentName, binder: IBinder) {
                service = ICounterService.Stub.asInterface(binder)
                cont.resume(service)
            }
            override fun onServiceDisconnected(name: ComponentName) { service = null }
        }
        context.bindService(Intent(context, CounterService::class.java), conn, Context.BIND_AUTO_CREATE)
    }
    result?.increment()
}

[!TIP] 大多数业务不需要 AIDL。先问自己:真的要跨进程吗?多数"看似跨进程"的需求(后台任务、定时同步) 用 WorkManager + 单进程就能解决。跨进程的正当理由:独立于主进程的存活需求、复用另一个 App 的能力、内存隔离。

2.4 Parcelable:跨进程/跨页面传对象

// 文件路径:sample/core/model/src/main/java/com/techfeed/core/model/ArticleParcel.kt
package com.techfeed.core.model

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

/**
 * @Parcelize 自动生成读写代码,告别手写 Parcelable 的几百行样板。
 * Parcel 用于:Intent 传参、Bundle、SavedStateHandle、跨进程(同 App 的多进程间)。
 */
@Parcelize
data class ArticleParcel(
    val id: String,
    val title: String,
    val authorId: String,
) : Parcelable

[!WARNING] Bundle 不是数据库。TransactionTooLargeException 的阈值约 1MB(且共享全局缓冲区), Intent 里只放 id/少量字段,详情数据由目标页面自己加载。把整个文章列表塞进 Intent 是最常见的崩溃来源之一。


三、常见坑 ⚠️

现象 根因 解法
隐式 Intent 报 ActivityNotFoundException 设备上没有能处理的应用 发起前 resolveActivity 判空并引导下载
resolveActivity 永远返回 null Android 11+ 包可见性限制 Manifest 声明 <queries>
深链跳到 App 却白屏 解析了 URI 但没有路由兜底 未知深链回首页/提示;解析器写单测
App Links 验证失败 assetlinks.json 路径错 / 指纹不匹配 / 域名被重定向 用 adb shell pm verify-app-links --re-verify <pkg> 排查;确认 https 可直达
权限申请弹窗一闪而过或不弹 之前选了"不再询问" 检测 shouldShowRequestPermissionRationale,引导去设置页
前台服务启动失败 Android 12+ 限制后台启动 FGS;14+ 要求类型与权限 用 WorkManager 加急任务;或申请 SYSTEM_ALERT_WINDOW 等特例
TransactionTooLargeException Bundle/Intent 塞了大数据 只传 id,数据由接收方加载
跨进程 SharedPreferences 数据不同步 SP 不支持多进程并发 改用 ContentProvider 或 MMKV 等支持多进程方案
广播收不到 / 收到两次 Android 8+ 静态注册限制;或注册注销不对称 动态注册 + 生命周期配对(第 03 章)
AIDL 调用阻塞主线程导致 ANR Binder 调用默认同步 IPC 一律放子线程;注意 inout 参数开销

四、调试技巧

# 1. 模拟深链(不经过浏览器,直接测试解析逻辑)
adb shell am start -a android.intent.action.VIEW -d "techfeed://article/a-001"
adb shell am start -a android.intent.action.VIEW -d "https://techfeed.dev/article/a-001"

# 2. 重新触发 App Links 域名验证并查看结果
adb shell pm verify-app-links --re-verify com.techfeed.app
adb shell pm get-app-links com.techfeed.app

# 3. 授予/撤销权限(免得反复点弹窗)
adb shell pm grant com.techfeed.app android.permission.POST_NOTIFICATIONS
adb shell pm revoke com.techfeed.app android.permission.POST_NOTIFICATIONS

# 4. 查看某应用声明的全部权限与授权状态
adb shell dumpsys package com.techfeed.app | grep -A 30 "requested permissions"

# 5. 查看跨进程服务
adb shell dumpsys activity services com.techfeed.app

IDE 内:

  • App Links Assistant(Tools → App Links Assistant)可视化生成 intent-filter 与 assetlinks.json。
  • 权限调试:Android Studio 的 App Quality Insights / dumpsys 输出配合使用。
  • Binder 死锁排查:anr traces(/data/anr/)里的 "held by thread" 是定位 Binder 线程池耗尽的关键。

五、最佳实践

  1. 应用内跳转优先 Navigation(第 06 章);Intent 留给跨应用通信与启动 Activity 场景。
  2. 对外暴露的组件一律 exported="false",深链目的地单独控制并做白名单校验(第 19 章)。
  3. 深链解析器独立成类并写单测:URI → 强类型路由,未知输入回首页。
  4. 权限集中管理 + 三段式话术:申请前解释(为什么需要)、拒绝后引导(去设置)、降级可用(功能不崩)。
  5. IPC 一律异步,且优先评估 WorkManager/单进程方案;AIDL 是最后手段。
  6. Intent/Bundle 只传轻量参数(id、标志位),对象传输用 @Parcelize。
  7. 敏感操作不用自定义 scheme 深链直接触发(如支付回调),必须用 App Links + 签名校验。
  8. 权限申请体验即转化率:在上下文场景中申请(点"开启通知"按钮时),不要在启动页一波带走。
旧写法对照:onRequestPermissionsResult 与存储权限
// 旧写法:requestCode 魔法数字 + 回调集中处理,多权限时 if-else 地狱
private fun askCamera() {
    requestPermissions(arrayOf(Manifest.permission.CAMERA), 100)
}

override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
    if (requestCode == 100 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { /* ... */ }
}

此外,旧的 READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE 在 Android 10+ 已被 Scoped Storage 取代:应用专属目录免权限,媒体文件用 MediaStore,文件选择用 SAF (ActivityResultContracts.OpenDocument)。不要再用旧存储权限写新代码。


六、本章小结

  1. Intent 是组件间的"意图快递单":显式(指定目标)用于应用内,隐式(描述意图)用于跨应用,发起前必须判空 + <queries>。
  2. 深链是流量入口也是攻击面:自定义 scheme 便利但可劫持,敏感场景用 App Links(域名 + 签名指纹双重验证)。
  3. 权限分普通/危险/特殊三级;危险权限走"检查 → 解释 → 申请 → 降级"四步,永远最小化与按需。
  4. Android 进程互相隔离,一切 IPC 走 Binder;应用层方案按需求选 AIDL/Messenger/Provider/Broadcast。
  5. Bundle 有 ~1MB 的隐含上限:Intent 传 id,不传数据。

下一章:进入进阶篇——网络请求。从一条 HTTP 请求在 OkHttp 拦截器链里的旅程讲起。


七、动手练习

基础题

  1. 实现"分享文章"功能:构造隐式 ACTION_SEND Intent,处理"没有任何可分享应用"的降级提示。
  2. 为 TechFeed 添加 techfeed://article/{id} 深链,写 DeepLinkParser 并为它写 5 个单元测试(正常、缺参、未知 host、https、空 URI)。
  3. 给 App 加通知权限申请:首次进入消息中心时申请,拒绝一次后弹解释对话框,彻底拒绝后引导设置页。

进阶题 4. 用 App Links Assistant 完成 https://techfeed.dev/article/{id} 的配置,并用 pm verify-app-links 验证通过(可自建本地服务放 assetlinks.json)。 5. 实现一个跨进程计数服务(:counter 进程 + AIDL),主进程 UI 实时显示计数值,并验证进程隔离(adb shell ps | grep counter)。 6. 把第 03 章的详情页改造为"只传 articleId",用 SavedStateHandle 恢复,构造一个 2MB 的数据场景验证 TransactionTooLargeException 的触发与修复。

思考题 7. 为什么 Binder 只需要一次内存拷贝,而传统 Socket IPC 需要两次?(提示:mmap 映射接收方缓冲区) 8. "深链白名单"应该校验哪些东西?只有 host 够吗?参数(articleId)层面还要防什么? 9. Android 13 把通知列为运行时权限,这对"推送到达率"产品指标意味着什么?你会如何设计权限引导时机?

📄 第 09 章 网络请求:OkHttp 与 Retrofit 09-networking-retrofit-okhttp.md

阶段:进阶 | 预计阅读 60 min | 代码落地模块::core:network、:core:model 前置:第 01 章 Kotlin 速成与协程预热 | 第 02 章 工程结构与 Gradle 相关章节:第 10 章 数据存储 | 第 11 章 协程与 Flow | 第 12 章 依赖注入 | 第 19 章 安全加固

学习目标

  • [ ] 理解一次网络请求从发起到拿到数据的完整链路,说清 OkHttp 与 Retrofit 各自的职责边界。
  • [ ] 掌握应用拦截器与网络拦截器的区别,能独立写出鉴权注入、日志、重试、错误处理、缓存五类拦截器。
  • [ ] 用 kotlinx.serialization 定义数据传输对象(Data Transfer Object, DTO),处理字段重命名、字段缺失与未知字段。
  • [ ] 能把后端的统一响应封装映射为 Result<T> / AppError,让 UI 层拿到结构化错误而不是裸异常。
  • [ ] 会用 MockWebServer 为接口层写测试,并为第 19 章的证书绑定(Certificate Pinning)预留接入点。

一、核心概念

1.1 一次请求的完整链路

Android 上的网络请求不是"一个库干完所有事",而是四层协作:

flowchart TB
    A["UI / ViewModel<br/>发起请求"] --> B["Repository<br/>业务语义 + 线程切换"]
    B --> C["Retrofit 接口<br/>把 Kotlin 函数翻译成 HTTP 请求"]
    C --> D["Converter<br/>对象 ↔ JSON"]
    D --> E["OkHttp<br/>拦截器链 + 连接池 + 缓存 + TLS"]
    E --> F["Socket / 网络"]
    F --> E
    E --> D
    D --> C
    C --> B
    B -->|"Result&lt;T&gt;"| A

四层各自的职责,混用会让代码难以测试:

层 库 职责 不该做的事
接口声明 Retrofit 用注解描述 URL、方法、参数、请求体 业务判断、错误处理
传输 OkHttp 连接复用、重试、缓存、TLS、拦截器 认识你的业务模型
序列化 kotlinx.serialization JSON ↔ data class 网络策略
语义 Repository 把 HTTP 结果翻译成 Result<T> / AppError 直接持有 OkHttpClient

[!TIP] 判断代码放错层的标准:Repository 里出现 @GET / @POST 注解就说明 Retrofit 泄漏到了业务层; 反过来,拦截器里出现 Article 这种领域模型,就说明业务泄漏到了传输层。

1.2 拦截器链:OkHttp 最值得理解的设计

OkHttp 把"发请求"拆成一条责任链(Chain of Responsibility),每个拦截器拿到 Request,可以改写它、短路它、或交给下一个。

flowchart TB
    R["原始 Request"] --> A1["应用拦截器 ①<br/>AuthInterceptor 注入 Token"]
    A1 --> A2["应用拦截器 ②<br/>HttpLoggingInterceptor"]
    A2 --> A3["应用拦截器 ③<br/>ErrorInterceptor 统一错误"]
    A3 --> B1["RetryAndFollowUp<br/>重定向 / 路由失败重试"]
    B1 --> B2["Bridge<br/>补 Content-Type/gzip 头"]
    B2 --> B3["Cache<br/>读写本地缓存"]
    B3 --> B4["Connect<br/>建立连接 / 连接池复用"]
    B4 --> B5["CallServer<br/>真正的 Socket 读写"]
    B5 --> B4 --> B3 --> B2 --> B1
    B1 --> A3 --> A2 --> A1 --> P["最终 Response"]

应用拦截器(Application Interceptor) 与 网络拦截器(Network Interceptor) 的差异是面试与实战的高频分水岭:

维度 应用拦截器 addInterceptor 网络拦截器 addNetworkInterceptor
调用次数 每次请求只调用一次 重定向、重试时可能被调用多次
能否短路 可以(不调 chain.proceed 直接返回) 可以,但语义更接近传输层
看到的 Response 最终响应(含缓存命中结果) 网络原始响应(含 Cache-Control 头)
是否受缓存影响 缓存命中时也会调用 缓存命中时不会调用
典型用途 鉴权头、统一错误、业务日志、离线缓存改写 抓包级别的原始日志、统计真实网络耗时

[!WARNING] 不要在应用拦截器里做"只关心真实网络流量"的统计——缓存命中时它照样执行,你的埋点会虚高。 反过来,鉴权头必须用应用拦截器注入:网络拦截器在缓存命中时不执行,会导致缓存路径下丢 token。

1.3 Retrofit 到底帮你做了什么

Retrofit 不是 HTTP 客户端,它是"把 Kotlin 接口翻译成 OkHttp 调用"的动态代理。你写的 suspend fun 方法,在运行时被解析成 Request,交给 OkHttp 执行,返回值再交给 Converter 反序列化。

关键收益有三点:

  1. suspend 函数天然在 OkHttp 的线程池执行,不需要你手动 withContext(Dispatchers.IO)(第 11 章会解释为什么)。
  2. 接口是 Kotlin 接口,可以直接用 Fake 实现做单元测试,不必碰 HTTP。
  3. 参数注解(@Query / @Path / @Body / @Header)把 URL 拼装的样板代码消灭掉。

1.4 序列化:为什么选 kotlinx.serialization

方案 优点 缺点
kotlinx.serialization 编译期生成、无反射、Kotlin 官方、支持默认值与 @SerialName 对复杂多态需要额外注册
Moshi 成熟、注解友好 早期版本依赖反射(codegen 可解)
Gson 存量项目多 反射 + 不认识 Kotlin 非空类型,可能把 null 塞进非空字段

kotlinx.serialization 的两个必开配置:ignoreUnknownKeys = true(后端加字段不会让 App 崩)、coerceInputValues = true(字段类型异常时回落到默认值而非抛异常)。

1.5 统一响应封装与 Result 映射

国内后端通常返回统一信封:

{ "code": 0, "message": "ok", "data": { "id": "a-001", "title": "..." } }

处理方式:DTO 层保留信封结构,Repository 层把它翻译成第 01 章定义的 Result<T> / AppError。UI 层永远不认识 code 这个字段——这样后端换协议(比如改 gRPC、改 GraphQL)时,改动被关在 :core:network 里。

1.6 缓存:Cache-Control 与离线可用

OkHttp 的 Cache 完全遵循 HTTP 语义:Cache-Control: max-age=60 表示 60 秒内直接用缓存。麻烦在于很多后端不给这个头,于是你有两个选择:

  • 服务端协商:推动后端补 Cache-Control(最优)。
  • 客户端改写:写一个应用拦截器,给响应补上 Cache-Control(务实做法,本章示例采用)。

离线场景(完全没网)需要 Cache-Control: only-if-cached + max-stale,配合第 10 章的 Room 做"网络为源、本地为缓存"的单一数据源(Single Source of Truth, SSOT)。

1.7 Mock 与安全的两个"预留口"

  • Mock:接口层测试不该依赖真实后端。MockWebServer 起一个本地 HTTP 服务,能精确控制响应码、延迟、畸形 JSON——这是验证"错误处理写对了没有"的唯一可靠方式。
  • 安全:证书绑定放在第 19 章展开,但接入点必须现在就留:OkHttpClient.Builder 里留一个 certificatePinner 的注入位,否则第 19 章要改所有构造点。

二、示例代码

2.1 模块依赖:版本一律收口

// 文件路径:sample/core/network/build.gradle.kts
plugins {
    alias(libs.plugins.techfeed.android.library)
    alias(libs.plugins.kotlin.serialization)   // 开启 kotlinx.serialization 编译器插件
    alias(libs.plugins.hilt)
    alias(libs.plugins.ksp)
}

dependencies {
    // api:Article/Result 出现在对外签名里,必须传递
    api(projects.core.model)
    implementation(projects.core.common)
    implementation(libs.bundles.network)          // retrofit + okhttp + serialization
    debugImplementation(libs.okhttp.logging)      // ⚠️ 日志拦截器只进 debug 包
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    testImplementation(libs.bundles.testing.unit)
}

[!NOTE] 具体版本号全部在 gradle/libs.versions.toml 中定义,正文不硬编码任何版本。升级时只改 TOML 一处。

2.2 DTO 定义与 @Serializable

// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/model/ArticleDto.kt
package com.techfeed.core.network.model

import com.techfeed.core.model.Article
import com.techfeed.core.model.Author
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/** 后端返回的统一信封:code 为 0 表示成功 */
@Serializable
data class ApiResponse<T>(
    val code: Int = 0,
    val message: String? = null,
    val data: T? = null,
)

/** DTO 只负责"描述线上 JSON 长什么样",不要在这里写业务逻辑 */
@Serializable
data class ArticleDto(
    @SerialName("id") val id: String,
    @SerialName("title") val title: String,
    @SerialName("summary") val summary: String? = null,     // 可空 + 默认值,后端不返回也不崩
    @SerialName("cover_url") val coverUrl: String? = null,  // 下划线命名靠 @SerialName 映射
    @SerialName("author") val author: AuthorDto? = null,
    @SerialName("published_at") val publishedAt: Long = 0L,
    @SerialName("tags") val tags: List<String> = emptyList(),
)

@Serializable
data class AuthorDto(
    @SerialName("id") val id: String,
    @SerialName("nickname") val name: String,
    @SerialName("avatar") val avatarUrl: String? = null,
)

/** DTO → 领域模型。转换集中在一处,后端改字段名时只改这里 */
fun ArticleDto.toDomain(isFavorite: Boolean = false): Article = Article(
    id = id,
    title = title,
    summary = summary.orEmpty(),
    coverUrl = coverUrl,
    author = author?.toDomain() ?: Author(id = "", name = "佚名", avatarUrl = null),
    publishedAt = publishedAt,
    isFavorite = isFavorite,
)

fun AuthorDto.toDomain(): Author = Author(id = id, name = name, avatarUrl = avatarUrl)
// 文件路径:docs/samples/ch09/AppJson.kt
package com.techfeed.core.network.json

import kotlinx.serialization.json.Json

/**
 * 全局唯一的 Json 实例。
 * ignoreUnknownKeys:后端新增字段时老版本 App 不会崩溃(线上事故高发点)
 * coerceInputValues:类型不匹配时回落到默认值,避免单个脏数据毁掉整个列表
 * explicitNulls = false:缺失字段用默认值,减少 DTO 里的 ? 噪音
 */
val AppJson = Json {
    ignoreUnknownKeys = true
    coerceInputValues = true
    explicitNulls = false
    encodeDefaults = true
    isLenient = true          // 容忍非标准 JSON(如 NaN、单引号)
}

2.3 鉴权注入:应用拦截器 + Token 提供器

// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/AuthInterceptor.kt
package com.techfeed.core.network

import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton

/** Token 由 :core:datastore 实现,:core:network 只依赖接口 —— 依赖倒置(第 15 章) */
interface TokenProvider {
    fun accessToken(): String?
    suspend fun refreshToken(): String?   // 刷新是挂起操作,不能阻塞拦截器线程
}

@Singleton
class AuthInterceptor @Inject constructor(
    private val tokenProvider: TokenProvider,
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenProvider.accessToken()
        // 无 token(未登录)时保持原请求,避免给匿名接口强加无效头
        val request = if (token.isNullOrBlank()) {
            chain.request()
        } else {
            chain.request().newBuilder()
                .header("Authorization", "Bearer $token")
                .header("X-Client", CLIENT_ID)
                .build()
        }
        return chain.proceed(request)
    }

    private companion object {
        const val CLIENT_ID = "android"
    }
}
// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/TokenAuthenticator.kt
package com.techfeed.core.network

import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton

/**
 * 401 时刷新 Token 并重发请求。
 * ⚠️ Authenticator 接口是同步的,这里用 runBlocking 是"必要的妥协":
 * 线程已是 OkHttp 的后台线程,不会阻塞主线程;但要确保 refreshToken 自带超时。
 */
@Singleton
class TokenAuthenticator @Inject constructor(
    private val tokenProvider: TokenProvider,
) : Authenticator {

    override fun authenticate(route: Route?, response: Response): Request? {
        // 关键:限制重试次数,否则刷新接口本身也返回 401 时会无限循环
        if (response.count() >= MAX_RETRY) return null

        val newToken = runBlocking { tokenProvider.refreshToken() } ?: return null
        return response.request.newBuilder()
            .header("Authorization", "Bearer $newToken")
            .build()
    }

    /** 统计同一请求被 401 挑战的次数(response.priorResponse 链) */
    private fun Response.count(): Int {
        var result = 1
        var prior = priorResponse
        while (prior != null) {
            result++
            prior = prior.priorResponse
        }
        return result
    }

    private companion object {
        const val MAX_RETRY = 2
    }
}

[!WARNING] Authenticator 只在服务端返回 401 时触发,且必须配合 OkHttpClient.authenticator() 使用。 如果你的后端用 code == 1001 表示登录失效(HTTP 状态仍为 200),它不会触发——这种情况只能在 ErrorInterceptor 或 Repository 里处理。

[!NOTE] 上面是"基础版":拿到 refresh token 直接换新。sample 工程的实际实现(同一路径)比它多了三件事: Mutex + 双重检查(并发 401 只刷新一次)、独立裸 OkHttpClient 调刷新接口(防止刷新请求自己 401 时无限递归)、 刷新结果经 tokenProvider.refresh() 落盘。完整实现与并发语义详见第 25 章。

2.4 超时、重试与统一错误处理

// 文件路径:docs/samples/ch09/RetryInterceptor.kt
package com.techfeed.core.network

import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.net.SocketTimeoutException
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class RetryInterceptor @Inject constructor() : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        var lastException: IOException? = null

        repeat(MAX_RETRY) { attempt ->
            try {
                val response = chain.proceed(request)
                // 5xx 是服务端临时故障,值得重试;4xx 是客户端错误,重试没有意义
                if (response.isSuccessful || response.code !in RETRYABLE_CODES) return response
                response.close()   // ⚠️ 丢弃的响应必须关闭,否则连接泄漏
            } catch (e: SocketTimeoutException) {
                lastException = e
            } catch (e: IOException) {
                // ConnectException 等:连接层失败可重试
                lastException = e
            }
            if (attempt < MAX_RETRY - 1) backoff(attempt)
        }
        throw lastException ?: IOException("请求失败")
    }

    private fun backoff(attempt: Int) {
        // 指数退避 + 抖动,避免大量客户端同时重试把服务端打垮(惊群)
        val delayMs = BASE_DELAY_MS * (1 shl attempt)
        Thread.sleep(delayMs + (Math.random() * JITTER_MS).toLong())
    }

    private companion object {
        const val MAX_RETRY = 3
        const val BASE_DELAY_MS = 300L
        const val JITTER_MS = 100L
        val RETRYABLE_CODES = 500..599
    }
}

[!WARNING] 只对幂等请求重试。上面示例对 POST 也会重试,实际项目请用 @Headers("X-Idempotent: true") 或自定义标记区分; 下单、支付类接口无脑重试会造成重复扣款。另外 Thread.sleep 在拦截器里是可接受的(拦截器本就运行在后台线程), 但在协程业务代码里请改用 delay()(第 11 章)。

// 文件路径:docs/samples/ch09/ErrorInterceptor.kt
package com.techfeed.core.network

import com.techfeed.core.model.AppError
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

/** 把 HTTP 层异常统一翻译成领域错误 AppError,业务层不再 catch IOException */
class ApiException(val appError: AppError) : IOException(appError.toString())

@Singleton
class ErrorInterceptor @Inject constructor() : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val response = try {
            chain.proceed(chain.request())
        } catch (e: IOException) {
            // 网络不可达 / DNS 失败 / 超时:统一包装为 AppError.Network
            throw ApiException(AppError.Network(cause = e))
        }

        if (response.isSuccessful) return response

        val error = when (response.code) {
            401 -> AppError.Auth(reason = "登录已失效")
            403 -> AppError.Auth(reason = "无访问权限")
            404 -> AppError.Http(code = 404, message = "内容不存在")
            in 400..499 -> AppError.Http(code = response.code, message = "请求有误")
            else -> AppError.Http(code = response.code, message = "服务异常")
        }
        response.close()
        throw ApiException(error)
    }
}

2.5 日志拦截器:只进 debug 包、必须脱敏

// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/di/NetworkModule.kt
package com.techfeed.core.network.di

import com.techfeed.core.network.AppJson
import com.techfeed.core.network.AuthInterceptor
import com.techfeed.core.network.ErrorInterceptor
import com.techfeed.core.network.NetworkConfig
import com.techfeed.core.network.RetryInterceptor
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.Cache
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)   // 作用域见第 12 章
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(
        // ⚠️ 不能直接声明 cacheDir: File——裸 File 类型没有绑定,Dagger 会报 missing binding;
        // 正确做法是注入 @ApplicationContext Context,再从 context.cacheDir 取缓存目录
        @ApplicationContext context: Context,
        authInterceptor: AuthInterceptor,
        errorInterceptor: ErrorInterceptor,
        retryInterceptor: RetryInterceptor,
        loggingInterceptor: HttpLoggingInterceptor?,   // 可空:release 变体下由 Hilt 提供 null
    ): OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)     // 建立 TCP/TLS 连接
        .readTimeout(15, TimeUnit.SECONDS)        // 读取响应;大文件接口应单独配置
        .writeTimeout(15, TimeUnit.SECONDS)
        .callTimeout(30, TimeUnit.SECONDS)        // 整体上限,防止慢请求无限占用
        .retryOnConnectionFailure(true)           // OkHttp 内建:路由/连接失败自动换路由
        .cache(Cache(File(context.cacheDir, "http_cache"), CACHE_SIZE_BYTES))
        .addInterceptor(errorInterceptor)         // ⚠️ 顺序即责任链顺序,见 1.2
        .addInterceptor(retryInterceptor)
        .addInterceptor(authInterceptor)
        .apply { loggingInterceptor?.let(::addInterceptor) }   // 日志放最后:看到的是最终发出的请求
        // .certificatePinner(...)  ← 第 19 章在此处接入证书绑定,接入点现在就留好
        .build()

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder()
        // BASE_URL 收口在 NetworkConfig.BASE_URL 常量(NetworkConfig.kt):
        // TokenAuthenticator 构造裸刷新请求时必须复用同一常量,避免两处硬编码漂移。
        // ⚠️ 值必须以 / 结尾,否则报 "baseUrl must end in /"
        .baseUrl(NetworkConfig.BASE_URL)
        .client(client)
        // kotlinx.serialization 的 Converter 需要显式声明媒体类型
        .addConverterFactory(AppJson.asConverterFactory("application/json".toMediaType()))
        .build()

    private const val CACHE_SIZE_BYTES = 10L * 1024 * 1024   // 10MB,按业务数据量调
}

[!TIP] 日志拦截器在 debugImplementation 依赖下,release 编译会直接找不到类——所以用 @Provides ... : HttpLoggingInterceptor? 配合 @Nullable 处理,或者把日志放进 src/debug/ 源码集。别用 if (BuildConfig.DEBUG) 硬编码依赖, 那样 release 包里依然会带上日志库。此外,BODY 级日志会把 Authorization 头原样打进 Logcat——token 可能随日志 进入崩溃报告或用户反馈截图,务必给日志拦截器加 .redactHeader("Authorization") 脱敏(第 19 章展开)。

2.6 缓存:给"没有 Cache-Control 的后端"兜底

// 文件路径:docs/samples/ch09/CacheInterceptor.kt
package com.techfeed.core.network

import okhttp3.Interceptor
import okhttp3.Response
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

/**
 * 有网:强制缓存 30 秒(覆盖服务端缺失的 Cache-Control)
 * 无网:允许读取 7 天内的过期缓存(only-if-cached + max-stale)
 */
@Singleton
class OfflineCacheInterceptor @Inject constructor(
    private val networkMonitor: NetworkMonitor,
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        // 只缓存 GET:POST/PUT 的响应缓存是非法的
        if (request.method != "GET") return chain.proceed(request)

        return if (networkMonitor.isOnline()) {
            val response = chain.proceed(request)
            response.newBuilder()
                .removeHeader("Pragma")                       // 旧时代头,会禁用缓存
                .header("Cache-Control", "public, max-age=$ONLINE_MAX_AGE_S")
                .build()
        } else {
            val requestOffline = request.newBuilder()
                .cacheControl(
                    okhttp3.CacheControl.Builder()
                        .onlyIfCached()                       // 只走缓存,绝不发起网络
                        .maxStale(OFFLINE_MAX_STALE_D, TimeUnit.DAYS)
                        .build(),
                )
                .build()
            val response = chain.proceed(requestOffline)
            // 无网且无缓存时返回 504,交给上层走 Room 兜底(第 10 章)
            if (response.code == 504) response.close()
            response
        }
    }

    private companion object {
        const val ONLINE_MAX_AGE_S = 30
        const val OFFLINE_MAX_STALE_D = 7
    }
}

/** 网络状态抽象:实现见 :core:common,避免拦截器直接依赖 Context */
interface NetworkMonitor {
    fun isOnline(): Boolean
}

2.7 Retrofit 接口:suspend 函数与参数注解

// 文件路径:docs/samples/ch09/FeedApi.kt
package com.techfeed.core.network.api

import com.techfeed.core.network.model.ApiResponse
import com.techfeed.core.network.model.ArticleDto
import com.techfeed.core.network.model.PageDto
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query

interface FeedApi {

    /** suspend:Retrofit 会在后台线程池执行,调用方无需切线程 */
    @GET("v1/articles")
    suspend fun getArticles(
        @Query("page") page: Int,
        @Query("page_size") pageSize: Int = 20,
        @Query("tag") tag: String? = null,          // null 参数会被自动省略,不会拼成 tag=null
        @Header("X-Request-Id") requestId: String,
    ): ApiResponse<PageDto<ArticleDto>>

    @GET("v1/articles/{id}")
    suspend fun getArticleDetail(@Path("id") id: String): ApiResponse<ArticleDto>
}
// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/api/TechFeedApi.kt
package com.techfeed.core.network.api

import retrofit2.create

/**
 * 所有 Api 的聚合入口,由 Hilt 提供单例。
 * 好处:新增接口只需加一个 val,调用方无需感知 Retrofit 的存在。
 */
class TechFeedApi internal constructor(retrofit: retrofit2.Retrofit) {
    val feed: FeedApi = retrofit.create()
    val auth: AuthApi = retrofit.create()
}

2.8 结果映射:从 ApiResponse 到 Result

// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/safeCall.kt
package com.techfeed.core.network

import com.techfeed.core.model.AppError
import com.techfeed.core.model.Result
import com.techfeed.core.network.model.ApiResponse
import kotlinx.coroutines.CancellationException

/**
 * 网络调用的统一包装:
 * 1. 捕获 ApiException(来自 ErrorInterceptor)并转成 Result.Failure
 * 2. 拆信封:code != 0 视为业务失败
 * 3. CancellationException 必须原样抛出,绝不能吞(第 11 章)
 */
suspend inline fun <T, R> safeApiCall(
    crossinline call: suspend () -> ApiResponse<T>,
    crossinline map: (T) -> R,
): Result<R> = try {
    val response = call()
    if (response.code == SUCCESS_CODE && response.data != null) {
        Result.Success(map(response.data))
    } else {
        // 业务错误码:登录失效单独归类,便于 UI 跳登录页
        val error = if (response.code == CODE_TOKEN_EXPIRED) {
            AppError.Auth(reason = response.message ?: "登录已失效")
        } else {
            AppError.Http(code = response.code, message = response.message)
        }
        Result.Failure(error)
    }
} catch (e: ApiException) {
    Result.Failure(e.appError)
} catch (e: CancellationException) {
    throw e                     // ⚠️ 取消不是错误,必须向上传递
} catch (e: Exception) {
    Result.Failure(AppError.Unknown(cause = e))
}

private const val SUCCESS_CODE = 0
private const val CODE_TOKEN_EXPIRED = 1001
// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/datasource/FeedRemoteDataSource.kt
package com.techfeed.core.network.datasource

import com.techfeed.core.model.Article
import com.techfeed.core.model.Result
import com.techfeed.core.network.api.TechFeedApi
import com.techfeed.core.network.model.toDomain
import com.techfeed.core.network.safeApiCall
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class FeedRemoteDataSource @Inject constructor(
    private val api: TechFeedApi,
) {
    /** 数据源层只做"取数据",不做缓存策略(那是 Repository 的事,见第 14 章) */
    suspend fun getArticles(page: Int, pageSize: Int = 20): Result<List<Article>> =
        safeApiCall(
            call = { api.feed.getArticles(page, pageSize, requestId = UUID.randomUUID().toString()) },
            map = { page -> page.items.map { it.toDomain() } },
        )
}
旧写法对照:RxJava / 回调式网络请求
// 旧写法:Observable + 线程切换 + 订阅管理,还需要手动处理 dispose
api.getArticles(page)
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe({ list -> render(list) }, { e -> showError(e) })
// 更早的回调写法:嵌套地狱 + 生命周期泄漏风险
api.getArticles(page, object : Callback<List<Article>> {
    override fun onSuccess(data: List<Article>) {
        api.getAuthor(data[0].authorId, object : Callback<Author> { /* ... */ })
    }
})

为什么换成 suspend:链式调用把"先后依赖"写成顺序语句,viewModelScope 自动取消取代手动 dispose, 异常用 try/catch 而非 onError 回调。RxJava 仍有价值(复杂流变换),但在 Android 业务层已被 Flow 全面替代(第 11 章)。

2.9 UI 层:加载 / 错误态的双体系实现

网络结果最终要落到界面上。这里给同一个"错误 + 重试"界面的两套写法。

// 文件路径:docs/samples/ch09/FeedErrorContent.kt
package com.techfeed.feature.feed.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

/** Compose 版:UI 是状态的函数,state 变化自动重组,无需手动 setText */
@Composable
fun FeedErrorContent(
    message: String,
    onRetry: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.fillMaxSize().padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text = message, style = MaterialTheme.typography.bodyLarge)
        Button(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
            Text("重试")
        }
    }
}
<!-- 文件路径:docs/samples/ch09/view_feed_error.xml -->
<?xml version="1.0" encoding="utf-8"?>
<!-- View 版:布局与刷新逻辑分离,需要手动切换 visibility -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/errorContainer"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="24dp"
    android:visibility="gone">

    <TextView
        android:id="@+id/tvError"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="?attr/textAppearanceBodyLarge" />

    <Button
        android:id="@+id/btnRetry"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="重试" />
</LinearLayout>
// 文件路径:docs/samples/ch09/FeedErrorView.kt
package com.techfeed.feature.feed.ui

import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.core.view.isVisible
import com.techfeed.feature.feed.databinding.ViewFeedErrorBinding

/** 自定义 View 封装错误态,让 XML 体系也能做到"状态驱动 UI" */
class FeedErrorView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : LinearLayout(context, attrs) {

    private val binding = ViewFeedErrorBinding.inflate(LayoutInflater.from(context), this, true)

    fun render(message: String?, onRetry: () -> Unit) {
        isVisible = message != null            // 状态驱动:有错误才显示,与 Compose 思路一致
        binding.tvError.text = message.orEmpty()
        binding.btnRetry.setOnClickListener { onRetry() }
    }
}

2.10 测试:MockWebServer 验证接口层

// 文件路径:docs/samples/ch09/FeedRemoteDataSourceTest.kt
package com.techfeed.core.network

import com.google.common.truth.Truth.assertThat
import com.techfeed.core.model.AppError
import com.techfeed.core.network.api.FeedApi
import com.techfeed.core.network.api.TechFeedApi
import com.techfeed.core.network.datasource.FeedRemoteDataSource
import com.techfeed.core.network.model.toDomain
import kotlinx.coroutines.test.runTest
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.create

class FeedRemoteDataSourceTest {

    private lateinit var server: MockWebServer
    private lateinit var dataSource: FeedRemoteDataSource

    @Before
    fun setUp() {
        server = MockWebServer()
        server.start()
        val retrofit = Retrofit.Builder()
            .baseUrl(server.url("/"))         // 指向本地 Mock 服务
            .addConverterFactory(AppJson.asConverterFactory("application/json".toMediaType()))
            .build()
        // 用一个只实现了 FeedApi 的假聚合入口,隔离被测目标
        val api = TechFeedApi(retrofit)
        dataSource = FeedRemoteDataSource(api)
    }

    @After
    fun tearDown() {
        server.close()        // ⚠️ 不关会端口泄漏,CI 上表现为用例互相干扰
    }

    @Test
    fun `正常响应映射为 Success`() = runTest {
        server.enqueue(
            MockResponse(code = 200, body = """
                {"code":0,"message":"ok","data":{"items":[{"id":"a-1","title":"标题","published_at":1700000000}]}}
            """.trimIndent()),
        )

        val result = dataSource.getArticles(page = 1)

        assertThat(result).isInstanceOf(Result.Success::class.java)
        assertThat((result as Result.Success).data.first().title).isEqualTo("标题")

        // 顺带断言请求:验证 Query 参数真的发出去了
        val recorded = server.takeRequest()
        assertThat(recorded.path).contains("page=1")
    }

    @Test
    fun `未知字段不导致崩溃`() = runTest {
        server.enqueue(
            MockResponse(code = 200, body = """
                {"code":0,"data":{"items":[{"id":"a-1","title":"t","future_field":{}}]}}
            """.trimIndent()),
        )

        val result = dataSource.getArticles(page = 1)
        assertThat(result).isInstanceOf(Result.Success::class.java)   // ignoreUnknownKeys 生效
    }

    @Test
    fun `业务错误码映射为 AppError`() = runTest {
        server.enqueue(
            MockResponse(code = 200, body = """{"code":1001,"message":"token expired"}"""),
        )

        val result = dataSource.getArticles(page = 1)

        assertThat(result).isInstanceOf(Result.Failure::class.java)
        assertThat((result as Result.Failure).error).isInstanceOf(AppError.Auth::class.java)
    }
}

[!TIP] 想要覆盖"超时""连接失败"这类场景,用 MockResponse 的 socket 策略: MockResponse().apply { socketPolicy = SocketPolicy.NoResponse },配合把 OkHttpClient 的超时调短, 就能在本地稳定复现线上最难复现的网络异常。

2.11 为第 19 章预留:证书绑定接入点

// 文件路径:docs/samples/ch09/PinningConfig.kt
package com.techfeed.core.network

/**
 * 证书绑定配置:本章只定义占位,实现与密钥轮换策略见第 19 章。
 * 提前定义接口是为了让 OkHttpClient 的构造点从一开始就预留 pinning 位置,
 * 避免第 19 章接入时改遍所有构造代码。
 */
interface PinningConfig {
    /** 返回 null 表示不启用绑定(如 debug 包 / Mock 环境) */
    fun pinner(): Any?
}

三、常见坑 ⚠️

现象 根因 解法
IllegalStateException: closed response.body.string() 只能读一次,日志拦截器先读了 日志用拦截器读,业务侧只读一次并保存到变量
配了 Cache 却每次都发请求 服务端没返回 Cache-Control;或请求是 POST 用离线缓存拦截器改写响应头;只缓存 GET
401 后无限循环请求 Authenticator 刷新失败仍返回带旧 token 的请求 统计 priorResponse 次数,超过 2 次返回 null
超时改了没生效 改的是另一个 OkHttpClient 实例 client 由 Hilt 单例提供,超时只在 Builder 处配置
Unable to create converter for ... 忘了加 asConverterFactory,或媒体类型不匹配 加 kotlinx.serialization converter,媒体类型用 application/json
后端加了个字段,App 全线崩溃 kotlinx.serialization 默认严格模式 ignoreUnknownKeys = true(上线必备)
publishedAt 永远是 0 后端字段是 published_at,没做映射 加 @SerialName("published_at")
NetworkOnMainThreadException 在主线程调了阻塞的 execute() 一律用 suspend 接口;Retrofit 会自动切后台线程
日志里出现明文 Token 日志拦截器进了 release 包且未脱敏 debugImplementation 引入 + HttpLoggingInterceptor 设 REDACTED 头
重试导致重复下单 对非幂等接口无脑重试 只对 GET / 幂等接口重试,服务端配合 Idempotency-Key
baseUrl must end in / baseUrl 不以斜杠结尾 写成 https://api.techfeed.dev/
MockWebServer 用例随机失败 未 enqueue 足够响应、未关闭 server、用了真实 Dispatcher 每个用例自建 server,@After 中 close()
release 包体积异常增大 日志拦截器打进主源码集 依赖声明改 debugImplementation
弱网下白屏 只依赖网络,没有本地兜底 配合 Room 做单一数据源(第 10 章)

四、调试技巧

  1. Network Inspector(首选):App Inspection → Network Inspector 能看到每个请求的 URL、Header、Body 与时序,不需要改代码。
  2. adb 侧验证网络可达性:
    # 在设备上测 DNS 与连通(设备网络与电脑网络可能不同)
    adb shell ping -c 3 api.techfeed.dev
    adb shell curl -sS -o /dev/null -w "%{http_code}" https://api.techfeed.dev/v1/articles?page=1
    # 查看 App 建立的连接
    adb shell dumpsys connectivity | grep -i "techfeed"
    
  3. OkHttp 事件监听:实现 EventListener 统计 DNS、TLS 握手、首字节耗时,能定位"慢到底是慢在哪一段"。
    class TimingEventListener : EventListener() {
        override fun dnsStart(call: Call, domainName: String) { /* 打时间戳 */ }
        override fun responseHeadersStart(call: Call) { /* 计算 TTFB */ }
    }
    
  4. MockWebServer 复现线上问题:把线上拿到的响应体原样 enqueue,可以在本地 100% 复现解析崩溃。
  5. Charles / mitmproxy 抓 HTTPS:Android 7+ 默认不信任用户 CA,需要在 res/xml/network_security_config.xml 中 为 debug 变体配置 <debug-overrides>(详见第 19 章)。抓包失败时先确认这一条。
  6. 日志分级:HttpLoggingInterceptor.Level.BODY 会把图片/大列表刷爆 Logcat,调试时先用 HEADERS,必要时才升到 BODY。
  7. 对比验证:用 curl 请求同一接口,如果 curl 也失败,问题在服务端或网络,别在客户端浪费时间。

五、最佳实践

  1. 拦截器顺序有语义:Error → Retry → Auth → Logging。错误处理在最外层才能捕获内层抛出的异常;日志在最内层才能看到最终发出的请求。
  2. 鉴权与业务解耦::core:network 只依赖 TokenProvider 接口,实现放在 :core:datastore(第 10 章),避免网络层反向依赖存储层实现。
  3. DTO 与领域模型分离:ArticleDto 只在网络层可见,出 :core:network 之前必须转成 Article。后端改字段名时改动被关在一个文件里。
  4. ignoreUnknownKeys 必开:这是成本最低的线上稳定性保险。
  5. 错误统一在拦截器层翻译:业务代码里不该出现 catch (IOException),只该看到 Result.Failure(AppError)。
  6. 重试只对幂等请求:并在重试中加指数退避 + 抖动,避免惊群。
  7. 日志只进 debug:用 debugImplementation 而非 if (BuildConfig.DEBUG),同时给 Authorization 等头设 REDACTED。
  8. 测试用 MockWebServer 而非真实环境:真实后端不稳定会让 CI 变红,且无法覆盖错误分支。
  9. 单例 OkHttpClient:连接池、TLS 会话复用都在 client 上,每次新建会显著变慢并耗尽 socket。
推荐 ✅ 避免 ❌
suspend fun getArticles(): ApiResponse<T> fun getArticles(): Call<T> 或回调式接口
Result.Failure(AppError.Auth) 让 UI 跳登录 在 Repository 里直接 startActivity
一个全局 OkHttpClient 单例 每个接口 new 一个 client
ignoreUnknownKeys = true 依赖后端永远不改字段
MockWebServer 覆盖 4xx / 5xx / 畸形 JSON 只测成功路径
超时时间按接口特性区分(大文件更长) 全用默认 10s
certificatePinner 预留位(第 19 章填) 上线后再回头改所有构造点

六、本章小结

  1. 职责分层:Retrofit 描述请求、OkHttp 负责传输、kotlinx.serialization 负责序列化、Repository 负责语义,四层不能混。
  2. 拦截器是 OkHttp 的灵魂:应用拦截器每次调用一次、可短路、缓存命中也执行;网络拦截器只在真实网络发生时执行、可能多次。鉴权用前者,原始流量统计用后者。
  3. 序列化要宽容:ignoreUnknownKeys + coerceInputValues + @SerialName,让后端演进不摧毁客户端。
  4. 错误要结构化:ErrorInterceptor 把 HTTP 异常翻译成 AppError,safeApiCall 把信封翻译成 Result<T>,UI 只消费 Result。
  5. 可测试性来自边界:MockWebServer 让接口层测试不依赖真实后端,也能覆盖超时、畸形 JSON、业务错误码这些最难复现的分支。

上一章回顾:第 03 章给出的 UiState + Event 双通道,正是本章 Result<T> 在 UI 层的落点。 下一章:网络拿到的数据需要落地——进入 第 10 章 数据存储:Room、DataStore 与文件存储, 学习如何把 Article 存进本地数据库,并实现"离线也能看"的单一数据源。


七、动手练习

基础题

  1. 给 FeedApi 增加一个 @POST("v1/articles/{id}/favorite") 接口(无请求体),用 MockWebServer 验证请求方法与路径正确。
  2. 写一个 HeaderInterceptor,统一注入 X-App-Version、X-Device-Id 与 Accept-Language,并用 server.takeRequest() 断言头已发出。
  3. 定义 UserDto 并配置 AppJson,让它能正确解析 {"user_id":"u1","nick_name":"张三","vip_level":3},且忽略多余的 extra 字段。

进阶题 4. 实现 OfflineCacheInterceptor:有网时缓存 60 秒,无网时允许读取 3 天内的过期缓存。用 MockWebServer 验证"第二次请求未发出网络调用"(提示:server.requestCount == 1)。 5. 给 RetryInterceptor 增加"只对幂等请求重试"的判断(通过自定义请求头标记),并写一个测试证明 POST 请求不会被重试。 6. 用 EventListener 统计一次请求的 DNS / 连接 / TLS / 首字节耗时,输出到 Logcat,对比首次与第二次请求(验证连接复用)。

思考题 7. 为什么 Authenticator 里必须限制重试次数?如果刷新 Token 的接口本身返回 401,不加限制会发生什么? 8. 缓存应该放在 OkHttp 层还是 Room 层?两者分别解决什么问题?如果一个接口既要"秒开"又要"可编辑",你会怎么分层? 9. 拦截器里出现了 Article 这类领域模型,为什么是设计问题?它会带来哪些具体的维护成本?

📄 第 10 章 数据存储:Room、DataStore 与文件存储 10-persistence-room-datastore.md

阶段:进阶 | 预计阅读 60 min | 代码落地模块::core:database、:core:datastore 前置:第 01 章 Kotlin 速成与协程预热 | 第 09 章 网络请求 相关章节:第 11 章 协程与 Flow | 第 14 章 分层架构与 MVI | 第 19 章 安全加固 | 第 25 章 登录鉴权与离线缓存

学习目标

  • [ ] 能按"数据形态 + 访问频率 + 安全等级"三要素选型,说清 SharedPreferences / DataStore / Room / 文件各自的适用边界。
  • [ ] 掌握 Room 三件套(Entity / Dao / Database)的写法,能用 Flow 返回可观察查询并用 @Transaction 保证一致性。
  • [ ] 理解数据库迁移的三种策略(自动迁移、手动 Migration、destructiveMigration)及其代价,会做预填充数据库。
  • [ ] 掌握 DataStore 的 Preferences 与 Proto 两种形态,能从 SharedPreferences 平滑迁移。
  • [ ] 能正确选择内部存储 / 外部存储 / MediaStore,并知道敏感数据该用什么方式加密落盘。

一、核心概念

1.1 存储方案怎么选

先别急着写代码,用这张表选型:

数据形态 例子 方案 理由
简单键值、小 主题模式、是否首次启动、搜索历史 DataStore Preferences 异步、事务性、类型安全,取代 SharedPreferences
结构化键值对 用户配置对象、筛选条件 DataStore Proto 有 schema,可演进,编译期类型安全
结构化、量大、需查询 文章缓存、收藏、草稿 Room(SQLite) 支持 SQL 查询、索引、关联、分页
大块二进制 图片、音视频、下载包 文件系统 / MediaStore 数据库存大 blob 会拖垮查询性能
高敏感小数据 Token、刷新令牌、支付凭据 加密存储(EncryptedFile / Keystore) 见第 19 章

[!IMPORTANT] SharedPreferences 已被官方标记为弃用,新代码一律用 DataStore。它的问题不是"慢",而是: 同步 I/O 可能在主线程造成卡顿与 ANR、apply() 的异步提交不保证落盘时机、没有事务、没有类型安全、没有错误信号。

1.2 Room 在 SQLite 之上加了什么

flowchart TB
    A["Entity<br/>@Entity 数据表定义"] --> D["RoomDatabase<br/>编译期生成实现"]
    B["Dao<br/>@Dao SQL 接口"] --> D
    C["TypeConverter<br/>复杂类型 ↔ 基础类型"] --> D
    D --> E["SQLiteOpenHelper<br/>建表 / 版本管理"]
    E --> F["SQLite 文件<br/>/data/data/pkg/databases/"]
    D -->|"KSP 编译期校验 SQL"| G["编译期报错<br/>列名写错当场发现"]

Room 的三个硬收益:

  1. 编译期校验 SQL:列名、表名写错会编译失败,而不是运行时崩。
  2. 直接返回 Flow / suspend:查询结果变化自动推送,无需手动 ContentObserver。
  3. 迁移可验证:导出 schema JSON,让版本演进可追溯。

1.3 Room 三件套的职责

组件 注解 职责 注意
Entity @Entity 描述一张表,字段即列 必须有 @PrimaryKey;类名默认即表名
Dao @Dao 描述增删改查;Room 生成实现 接口或抽象类均可;返回 Flow 时方法不必是 suspend
Database @Database 持有数据库、暴露 Dao、管理版本 抽象类,继承 RoomDatabase

1.4 Flow 返回与事务

Dao 返回 Flow<List<T>> 时,Room 会在事务内监听相关表的变化,表一变就重新查询并推送。这意味着:

  • 查询方法不需要 suspend(Flow 的收集本身就是异步的)。
  • 不要在 Flow 查询里做耗时计算——它在事务线程上执行,会阻塞其他写入。
  • 一次性的"读—改—写"必须用 @Transaction,否则中间状态可能被其他线程读到。

1.5 迁移策略的三条路

策略 适用场景 代价
自动迁移 @AutoMigration 加表、加列(非空列需有默认值)、改索引 只能处理 Room 能推断的简单变更
手动 Migration 改列名、拆表、数据转换 必须写 SQL,且要测试
fallbackToDestructiveMigration 早期开发、缓存型数据 会清空用户数据,正式版本禁用

[!WARNING] fallbackToDestructiveMigration() 出现在 release 代码里应视为 P0 事故隐患。 如果某些表的数据可以丢(纯缓存),至少用 fallbackToDestructiveMigrationOnDowngrade() 或按表精确控制。

1.6 DataStore 的两种形态

形态 数据模型 类型安全 适用
Preferences DataStore Preferences 键值(preferencesKey<T>()) 运行时(key 与类型绑定) 简单配置,迁移自 SharedPreferences
Proto DataStore 你定义的 @Serializable 类 + Serializer 编译期(有 schema) 复杂配置对象、需要演进

共同优点:基于协程与 Flow 的异步 API、原子读写(事务)、可读的错误信号(IOException 而非静默失败)。

1.7 文件存储的三块地

位置 路径 是否需要权限 卸载是否删除 适用
内部存储 /data/data/pkg/files 否 是 私有数据,其他 App 不可见
内部缓存 /data/data/pkg/cache 否 是(系统可自动清) 临时文件,需自己清理
外部私有 /sdcard/Android/data/pkg/files 否(Android 19+) 是 大文件,避免占用内部空间
公共媒体库 MediaStore 否(Android 10+ 分区存储) 否 用户可见的照片、下载、音频

[!NOTE] Android 10 起强制分区存储(Scoped Storage):不能再直接用 File API 写公共目录, 必须通过 MediaStore 或系统文件选择器(SAF)。想临时兼容可加 android:requestLegacyExternalStorage, 但 targetSdk 提高后失效,不建议依赖。

1.8 加密存储简述

  • EncryptedFile(androidx.security:security-crypto):基于流式加密(AES256-GCM),适合大文件。
  • EncryptedSharedPreferences:security-crypto 库处于维护状态,但仍是官方对 Android Keystore 的最简可靠封装,本教程第 19/25 章沿用它存 token;迁移到 Tink 或 DataStore + Keystore 自行加密属进阶练习。
  • 密钥一律存放于 Android Keystore,由系统级安全硬件保护,App 无法导出私钥(第 19 章展开)。

二、示例代码

2.1 模块配置与 schema 导出

// 文件路径:sample/core/database/build.gradle.kts
plugins {
    alias(libs.plugins.techfeed.android.library)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.room)      // androidx.room 插件:配置 schema 目录
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
}

room {
    // ⚠️ 一定要导出 schema:它是迁移可验证、可回溯的唯一依据,需提交到版本库
    schemaDirectory("$projectDir/schemas")
}

dependencies {
    api(projects.core.model)
    implementation(libs.bundles.room)      // room-runtime + room-ktx
    ksp(libs.androidx.room.compiler)       // KSP 代替 kapt,编译更快
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    testImplementation(libs.bundles.testing.unit)
}

2.2 Entity:表结构定义

// 文件路径:sample/core/database/src/main/java/com/techfeed/core/database/entity/ArticleEntity.kt(教学简化版,sample 实际实现见同路径文件)
package com.techfeed.core.database.entity

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.techfeed.core.model.Article
import com.techfeed.core.model.Author

@Entity(
    tableName = "articles",
    // 索引:列表按时间倒序查询,必须建索引,否则数据量大时全表扫描
    indices = [Index(value = ["published_at"]), Index(value = ["is_favorite"])],
)
data class ArticleEntity(
    @PrimaryKey @ColumnInfo(name = "id") val id: String,
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "summary") val summary: String,
    @ColumnInfo(name = "cover_url") val coverUrl: String?,
    @ColumnInfo(name = "author_id") val authorId: String,
    @ColumnInfo(name = "author_name") val authorName: String,
    @ColumnInfo(name = "author_avatar") val authorAvatar: String?,
    @ColumnInfo(name = "published_at") val publishedAt: Long,
    @ColumnInfo(name = "is_favorite") val isFavorite: Boolean = false,
    @ColumnInfo(name = "cached_at") val cachedAt: Long = System.currentTimeMillis(),
    // 分页需要:信息流是多页拼接的,保存页码才能按序还原
    @ColumnInfo(name = "page") val page: Int = 0,
)

fun ArticleEntity.toDomain(): Article = Article(
    id = id,
    title = title,
    summary = summary,
    coverUrl = coverUrl,
    author = Author(authorId, authorName, authorAvatar),
    publishedAt = publishedAt,
    isFavorite = isFavorite,
)

fun Article.toEntity(page: Int = 0): ArticleEntity = ArticleEntity(
    id = id,
    title = title,
    summary = summary,
    coverUrl = coverUrl,
    authorId = author.id,
    authorName = author.name,
    authorAvatar = author.avatarUrl,
    publishedAt = publishedAt,
    isFavorite = isFavorite,
    page = page,
)

[!TIP] 这里是扁平化存储:把 Author 拆成三列而不是单独建表做外键关联。 缓存型数据追求"一次查询出列表",扁平化比关联查询快得多。真正需要独立查询 Author 时才拆表。

2.3 Dao:Flow、事务与 Upsert

// 文件路径:sample/core/database/src/main/java/com/techfeed/core/database/dao/ArticleDao.kt(教学简化版,sample 实际实现见同路径文件)
package com.techfeed.core.database.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.techfeed.core.database.entity.ArticleEntity
import kotlinx.coroutines.flow.Flow

@Dao
interface ArticleDao {

    /** 返回 Flow:表变化自动重新查询并推送。方法不必是 suspend。
     *  query 非空时在 DB 层按标题过滤(LIKE 大小写不敏感只对 ASCII,教学够用),
     *  比取全量到内存再 filter 省得多(第 17 章性能意识的落点) */
    @Query(
        "SELECT * FROM articles WHERE is_favorite = 0 " +
            "AND (:query IS NULL OR title LIKE '%' || :query || '%') " +
            "ORDER BY published_at DESC",
    )
    fun observeFeed(query: String?): Flow<List<ArticleEntity>>

    @Query("SELECT * FROM articles WHERE is_favorite = 1 ORDER BY published_at DESC")
    fun observeFavorites(): Flow<List<ArticleEntity>>

    @Query("SELECT * FROM articles WHERE id = :id")
    fun observeById(id: String): Flow<ArticleEntity?>

    /** 一次性查询:需要 suspend(首次访问会触发 IO) */
    @Query("SELECT COUNT(*) FROM articles")
    suspend fun count(): Int

    /**
     * Upsert:存在则更新、不存在则插入。
     * 用于"网络数据落地"——后端可能重复下发同一篇文章。
     */
    @Upsert
    suspend fun upsertAll(items: List<ArticleEntity>)

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertAllIgnoring(items: List<ArticleEntity>)

    @Query("UPDATE articles SET is_favorite = :favorite WHERE id = :id")
    suspend fun setFavorite(id: String, favorite: Boolean)

    /** 只保留最近 N 页,防止缓存无限膨胀(配合 WorkManager 定期清理,见第 13 章) */
    @Query("DELETE FROM articles WHERE page < :minPage AND is_favorite = 0")
    suspend fun deleteStale(minPage: Int): Int

    /**
     * @Transaction 保证"清空 + 插入"是原子的:
     * 否则中途查询会看到空列表,UI 可能出现闪烁的白屏。
     */
    @Transaction
    suspend fun replacePage(page: Int, items: List<ArticleEntity>) {
        deleteByPage(page)
        upsertAll(items)
    }

    @Query("DELETE FROM articles WHERE page = :page AND is_favorite = 0")
    suspend fun deleteByPage(page: Int)
}

2.4 Database:版本、迁移与预填充

// 文件路径:sample/core/database/src/main/java/com/techfeed/core/database/TechFeedDatabase.kt(教学简化版,sample 实际实现见同路径文件)
package com.techfeed.core.database

import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.techfeed.core.database.dao.ArticleDao
import com.techfeed.core.database.dao.DraftDao
import com.techfeed.core.database.entity.ArticleEntity
import com.techfeed.core.database.entity.DraftEntity

@Database(
    entities = [ArticleEntity::class, DraftEntity::class],
    version = 3,
    // 自动迁移:Room 能推断的简单变更(v2 加列、v3 加索引)
    autoMigrations = [
        AutoMigration(from = 2, to = 3),
    ],
    exportSchema = true,     // 必须 true,schema JSON 是迁移审计的依据
)
@TypeConverters(Converters::class)
abstract class TechFeedDatabase : RoomDatabase() {
    abstract fun articleDao(): ArticleDao
    abstract fun draftDao(): DraftDao

    companion object {
        const val NAME = "techfeed.db"
    }
}
// 文件路径:docs/samples/ch10/Converters.kt
package com.techfeed.core.database

import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json

/** Room 只认基础类型,List 这类复杂字段必须做类型转换 */
class Converters {
    @TypeConverter
    fun fromStringList(value: List<String>?): String? =
        value?.let { Json.encodeToString(it) }

    @TypeConverter
    fun toStringList(value: String?): List<String>? =
        value?.let { Json.decodeFromString(it) }
}
// 文件路径:sample/core/database/src/main/java/com/techfeed/core/database/di/DatabaseModule.kt(教学简化版,sample 实际实现见同路径文件)
package com.techfeed.core.database.di

import android.content.Context
import androidx.room.Room
import androidx.sqlite.db.SupportSQLiteDatabase
import com.techfeed.core.database.TechFeedDatabase
import com.techfeed.core.database.dao.ArticleDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): TechFeedDatabase =
        Room.databaseBuilder(context, TechFeedDatabase::class.java, TechFeedDatabase.NAME)
            // v1 → v2 无法自动推断(列重命名),必须手写 SQL
            .addMigrations(MIGRATION_1_2)
            // 预填充:把 assets 中打包好的初始数据作为首次创建的数据库
            // .createFromAsset("database/prepopulated.db")
            // ⚠️ 下面这行仅供开发期;release 出现即为数据丢失风险
            // .fallbackToDestructiveMigration()
            .addCallback(object : RoomDatabase.Callback() {
                override fun onCreate(db: SupportSQLiteDatabase) {
                    // 建库时的初始化钩子:也可以在这里插入预置数据
                    db.execSQL("CREATE INDEX IF NOT EXISTS idx_articles_page ON articles(page)")
                }
            })
            .build()

    @Provides
    fun provideArticleDao(db: TechFeedDatabase): ArticleDao = db.articleDao()

    /** 手动迁移:一次写好,永不删除(用户可能停留在任何旧版本) */
    private val MIGRATION_1_2 = object : androidx.room.migration.Migration(1, 2) {
        override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
            db.execSQL("ALTER TABLE articles ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0")
            db.execSQL("ALTER TABLE articles ADD COLUMN cached_at INTEGER NOT NULL DEFAULT 0")
        }
    }
}

[!WARNING] 迁移对象永远不要删。用户可能半年没更新,从 v1 直接升到 v5,Room 会依次执行 1→2、2→3…… 少任何一段,那批用户就会崩(或触发 destructive 丢数据)。

2.5 Repository:单一数据源(SSOT)

这是第 09 章网络能力与本章存储能力的结合点,也是第 14 章分层架构的雏形:

// 文件路径:docs/samples/ch10/OfflineFirstFeedRepository.kt
package com.techfeed.core.database.repository

import com.techfeed.core.database.dao.ArticleDao
import com.techfeed.core.database.entity.toDomain
import com.techfeed.core.database.entity.toEntity
import com.techfeed.core.model.Article
import com.techfeed.core.model.Result
import com.techfeed.core.network.datasource.FeedRemoteDataSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton

/**
 * 离线优先(Offline First)仓库:
 * - 对外只暴露 Flow,UI 永远读本地,天然"秒开"
 * - 网络只负责刷新本地,不直接给 UI
 * 这是"单一数据源(Single Source of Truth, SSOT)"的标准实现。
 */
@Singleton
class OfflineFirstFeedRepository @Inject constructor(
    private val remote: FeedRemoteDataSource,
    private val dao: ArticleDao,
) {
    fun observeFeed(query: String? = null): Flow<List<Article>> =
        dao.observeFeed(query).map { list -> list.map { it.toDomain() } }   // 映射在 Flow 内,保持流式

    /** 刷新:先写库,UI 通过 Flow 自动收到新数据,不需要返回值 */
    suspend fun refresh(page: Int): Result<Unit> = when (val result = remote.getArticles(page)) {
        is Result.Success -> {
            dao.replacePage(page, result.data.map { it.toEntity(page) })
            Result.Success(Unit)
        }
        // ⚠️ 失败不覆盖本地:旧的缓存仍然可用,这正是离线优先的价值
        is Result.Failure -> Result.Failure(result.error)
        Result.Loading -> Result.Loading
    }
}

2.6 DataStore Preferences

// 文件路径:sample/core/datastore/src/main/java/com/techfeed/core/datastore/UserPreferences.kt
package com.techfeed.core.datastore

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton

/**
 * preferencesDataStore 委托:name 唯一,重复定义同名 DataStore 会抛
 * IllegalStateException("There are multiple DataStores active for the same file")。
 * ⚠️ 因此它必须是单例(放顶层或由 Hilt 单例提供),绝不能每次 new。
 */
private val Context.userDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "user_preferences",
    // 从 SharedPreferences 迁移:只执行一次,读完后旧文件会被删除
    produceMigrations = { context ->
        listOf(androidx.datastore.preferences.SharedPreferencesMigration(context, "user_sp"))
    },
)

data class AppSettings(
    val darkMode: Boolean = false,
    val fontSizeSp: Int = 16,
    val lastChannel: String = "recommend",
)

@Singleton
class UserPreferencesDataSource @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    val settings: Flow<AppSettings> = context.userDataStore.data.map { prefs ->
        AppSettings(
            darkMode = prefs[KEY_DARK_MODE] ?: false,
            fontSizeSp = prefs[KEY_FONT_SIZE] ?: 16,
            lastChannel = prefs[KEY_CHANNEL] ?: "recommend",
        )
    }

    /** edit 是原子的:要么全写入,要么全不写 */
    suspend fun setDarkMode(enabled: Boolean) {
        context.userDataStore.edit { prefs -> prefs[KEY_DARK_MODE] = enabled }
    }

    suspend fun setFontSize(sp: Int) {
        context.userDataStore.edit { prefs -> prefs[KEY_FONT_SIZE] = sp.coerceIn(12, 24) }
    }

    private companion object {
        val KEY_DARK_MODE = booleanPreferencesKey("dark_mode")
        val KEY_FONT_SIZE = intPreferencesKey("font_size_sp")
        val KEY_CHANNEL = stringPreferencesKey("last_channel")
    }
}

[!WARNING] dataStore.data 抛出的 IOException 会让 Flow 终止。生产代码应加一层兜底:

.catch { if (it is IOException) emit(emptyPreferences()) else throw it }

否则一次磁盘 IO 失败会让设置页永久空白。

2.7 Proto DataStore:有 schema 的配置

// 文件路径:docs/samples/ch10/SettingsSerializer.kt
package com.techfeed.core.datastore

import androidx.datastore.core.Serializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream

/** Proto DataStore 的模型(示例用 kotlinx.serialization 承载,工程里也可用 protobuf-lite) */
@Serializable
data class SettingsProto(
    val darkMode: Boolean = false,
    val fontSizeSp: Int = 16,
    val autoPlayVideo: Boolean = true,
)

object SettingsSerializer : Serializer<SettingsProto> {
    override val defaultValue: SettingsProto = SettingsProto()

    override suspend fun readFrom(input: InputStream): SettingsProto = try {
        Json.decodeFromString(input.readBytes().decodeToString())
    } catch (e: Exception) {
        // 解析失败返回默认值,避免配置损坏导致 App 打不开
        defaultValue
    }

    override suspend fun writeTo(t: SettingsProto, output: OutputStream) {
        output.write(Json.encodeToString(t).toByteArray())
    }
}
// 文件路径:docs/samples/ch10/DataStoreModule.kt
package com.techfeed.core.datastore.di

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.dataStore
import com.techfeed.core.datastore.SettingsProto
import com.techfeed.core.datastore.SettingsSerializer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

private val Context.settingsDataStore: DataStore<SettingsProto> by dataStore(
    fileName = "settings.pb",
    serializer = SettingsSerializer,
)

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
    @Provides
    @Singleton
    fun provideSettingsDataStore(@ApplicationContext context: Context): DataStore<SettingsProto> =
        context.settingsDataStore
}

2.8 文件存储:内部、缓存与清理

// 文件路径:docs/samples/ch10/FileStorage.kt
package com.techfeed.core.datastore.file

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class AppFileStorage @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    /** 内部存储:私有、无需权限、随卸载删除 —— 放用户草稿、离线包 */
    fun draftsDir(): File = File(context.filesDir, "drafts").apply { mkdirs() }

    /** 缓存目录:系统内存不足时可能自动清理,必须容忍文件不存在 */
    fun imageCacheDir(): File = File(context.cacheDir, "images").apply { mkdirs() }

    /** 外部私有目录:放下载的大文件,避免撑爆内部存储 */
    fun downloadsDir(): File? = context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOWNLOADS)

    /**
     * 缓存清理:只删超过 maxAgeMs 的文件。
     * ⚠️ 不要无脑 deleteRecursively,会把正在使用的文件删掉。
     */
    fun evictCache(maxAgeMs: Long = DEFAULT_MAX_AGE_MS): Long {
        var freed = 0L
        val now = System.currentTimeMillis()
        imageCacheDir().listFiles()?.forEach { file ->
            if (now - file.lastModified() > maxAgeMs) {
                freed += file.length()
                file.delete()
            }
        }
        return freed
    }

    /** 查询缓存大小,用于设置页展示"清理缓存"入口 */
    fun cacheSizeBytes(): Long =
        imageCacheDir().walkTopDown().filter { it.isFile }.sumOf { it.length() }

    private companion object {
        const val DEFAULT_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000   // 7 天
    }
}

2.9 MediaStore:保存图片到公共相册

// 文件路径:docs/samples/ch10/MediaStoreSaver.kt
package com.techfeed.core.datastore.file

import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.OutputStream
import javax.inject.Inject

/**
 * Android 10+ 分区存储:公共目录只能通过 MediaStore 写入,不能再直接 new File("/sdcard/DCIM/...")。
 * Android 10 及以上用 IS_PENDING 占位,写完置 0 后其他应用才可见。
 */
class MediaStoreSaver @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    fun saveImage(displayName: String, mimeType: String = "image/jpeg"): OutputStream? {
        val values = ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
            put(MediaStore.Images.Media.MIME_TYPE, mimeType)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/TechFeed")
                put(MediaStore.Images.Media.IS_PENDING, 1)   // 写入期间对外不可见
            }
        }
        val resolver = context.contentResolver
        val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null
        val stream = resolver.openOutputStream(uri) ?: return null
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            object : java.io.FilterOutputStream(stream) {
                override fun close() {
                    super.close()
                    values.clear()
                    values.put(MediaStore.Images.Media.IS_PENDING, 0)   // 写完公开
                    resolver.update(uri, values, null, null)
                }
            }
        } else {
            stream
        }
    }
}

2.10 加密存储:EncryptedFile 与 Token 落地

// 文件路径:sample/core/datastore/src/main/java/com/techfeed/core/datastore/secure/SecureTokenStorage.kt
package com.techfeed.core.datastore.secure

import android.content.Context
import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Token 的落地方案:EncryptedFile + Android Keystore。
 * 为什么不用 DataStore 明文存?——Root/备份导出场景下明文 Token 等于账号被盗。
 * Keystore 的密钥不可导出,且可配置需要生物识别才能使用(第 19 章)。
 */
@Singleton
class SecureTokenStorage @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    private val masterKey by lazy {
        MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build()
    }

    private fun encryptedFile() = EncryptedFile.Builder(
        context,
        java.io.File(context.filesDir, "token.enc"),
        masterKey,
        EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB,
    ).build()

    suspend fun save(token: String) = withContext(Dispatchers.IO) {
        encryptedFile().openFileOutput().use { it.write(token.toByteArray()) }
    }

    suspend fun read(): String? = withContext(Dispatchers.IO) {
        runCatching { encryptedFile().openFileInput().use { it.readBytes().decodeToString() } }
            .getOrNull()   // 文件不存在或密钥失效时返回 null,交由上层走重新登录
    }

    suspend fun clear() = withContext(Dispatchers.IO) {
        java.io.File(context.filesDir, "token.enc").delete()
    }
}

2.11 UI 双体系:收藏列表

// 文件路径:sample/feature/profile/src/main/java/com/techfeed/feature/profile/ui/FavoriteListScreen.kt
package com.techfeed.feature.profile.ui

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.techfeed.core.model.Article

/** Compose 版:collectAsStateWithLifecycle 让收集随生命周期自动启停(第 11 章) */
@Composable
fun FavoriteListScreen(
    modifier: Modifier = Modifier,
    viewModel: FavoriteViewModel = hiltViewModel(),
) {
    val favorites by viewModel.favorites.collectAsStateWithLifecycle()
    LazyColumn(modifier = modifier.fillMaxSize()) {
        // key 必填:否则数据变化时的动画与状态会错位
        items(items = favorites, key = { it.id }) { article ->
            ListItem(
                headlineContent = { Text(article.title) },
                supportingContent = { Text(article.summary) },
            )
        }
    }
}
// 文件路径:docs/samples/ch10/FavoriteFragment.kt
package com.techfeed.feature.profile.ui

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.techfeed.feature.profile.databinding.FragmentFavoriteBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

/** View 版:RecyclerView + Adapter,数据同样来自 Room 的 Flow */
@AndroidEntryPoint
class FavoriteFragment : Fragment() {

    private var _binding: FragmentFavoriteBinding? = null
    private val binding get() = requireNotNull(_binding)

    private val viewModel: FavoriteViewModel by viewModels()
    private val adapter = FavoriteAdapter()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, saved: Bundle?): View {
        _binding = FragmentFavoriteBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
        binding.recyclerView.adapter = adapter
        // 设置固定尺寸:列表项高度固定时能跳过测量,滚动更顺
        binding.recyclerView.setHasFixedSize(true)

        viewLifecycleOwner.lifecycleScope.launch {
            // repeatOnLifecycle:页面不可见时停止收集,避免后台仍然触发数据库查询
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.favorites.collect { adapter.submitList(it) }
            }
        }
    }

    override fun onDestroyView() {
        binding.recyclerView.adapter = null   // 断开 Adapter 对视图的引用
        _binding = null                       // ⚠️ 必须置空,否则泄漏整棵视图树
        super.onDestroyView()
    }
}

2.12 测试:Room 与 DataStore

// 文件路径:docs/samples/ch10/ArticleDaoTest.kt
// ⚠️ 必须放 androidTest(仪器化测试):ApplicationProvider 需要真实的应用 Context,
// src/test 的纯 JVM 环境里会抛 IllegalStateException。
package com.techfeed.core.database

import androidx.room.Room
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Before
import org.junit.Test

/**
 * 仪器化测试用 Room.inMemoryDatabaseBuilder + ApplicationProvider;
 * 若想在 JVM(src/test)上跑,可给 builder 加 `.setDriver(BundledSQLiteDriver())`
 * 并去掉对 ApplicationProvider 的依赖。
 */
class ArticleDaoTest {

    private lateinit var db: TechFeedDatabase

    @Before
    fun setUp() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            TechFeedDatabase::class.java,
        ).build()
    }

    @After
    fun tearDown() {
        db.close()      // ⚠️ 不关会泄漏内存数据库实例
    }

    @Test
    fun `replacePage 后 Flow 推送新数据`() = runTest {
        val dao = db.articleDao()
        dao.replacePage(1, listOf(createEntity("a-1", page = 1)))

        val list = dao.observeFeed(null).first()
        assert(list.size == 1 && list[0].id == "a-1")
    }

    @Test
    fun `收藏的文章不会被清页删除`() = runTest {
        val dao = db.articleDao()
        dao.upsertAll(listOf(createEntity("a-1", page = 1), createEntity("a-2", page = 1)))
        dao.setFavorite("a-1", true)
        dao.deleteStale(minPage = 2)     // 只删 page < 2 且未收藏的

        val remaining = dao.observeFeed(null).first()
        assert(remaining.any { it.id == "a-1" })      // 收藏项保留
        assert(remaining.none { it.id == "a-2" })
    }

    private fun createEntity(id: String, page: Int) = com.techfeed.core.database.entity.ArticleEntity(
        id = id, title = "t-$id", summary = "", coverUrl = null,
        authorId = "au", authorName = "n", authorAvatar = null,
        publishedAt = System.currentTimeMillis(), page = page,
    )
}
旧写法对照:SharedPreferences 与 SQLiteOpenHelper
// 旧写法:SharedPreferences —— 同步 IO、无事务、无错误信号
val sp = getSharedPreferences("user", MODE_PRIVATE)
sp.edit().putBoolean("dark_mode", true).apply()   // apply 异步但不保证何时落盘
val dark = sp.getBoolean("dark_mode", false)      // 主线程读盘,可能卡顿
// 旧写法:裸 SQLite —— SQL 拼错运行时才崩,变更需手动通知 UI
val db = writableDatabase
db.rawQuery("SELECT * FROM article WHERE id = ?", arrayOf(id)).use { cursor -> /* 手动解析 */ }
contentResolver.notifyChange(uri, null)   // 忘了这行 UI 永远不刷新

现代做法:键值用 DataStore(异步 + 事务 + Flow),结构化用 Room(编译期校验 SQL + Flow 自动推送)。 裸 SQLite 唯一还值得用的场景是全文检索 FTS 等 Room 尚未覆盖的能力,此时也应通过 SupportSQLiteDatabase 接入。


三、常见坑 ⚠️

现象 根因 解法
Cannot access database on the main thread 在主线程调用了 suspend 之外的阻塞查询 查询改返回 Flow 或标记为 suspend;不要在 runBlocking 里查库
IllegalStateException: There are multiple DataStores active 同一个文件名创建了多个 DataStore 实例 DataStore 必须单例(顶层委托或 Hilt @Singleton)
升级后用户数据被清空 用了 fallbackToDestructiveMigration 删除该调用,补写 Migration;只在不关心数据的缓存库上按需降级
迁移崩 Migration didn't properly handle 手写 SQL 与实际 schema 不一致 打开 exportSchema,比对 schemas 目录下 JSON
Entity 加了非空字段,编译报迁移错误 Room 无法推断"非空列的默认值" 给字段默认值,或用 @ColumnInfo(defaultValue = "0") + 自动迁移
Flow 查询不再推送 数据库实例被关闭;或用了不同实例 Database 单例由 Hilt 提供,测试结束才 close()
列表闪烁一下才显示数据 先返回空列表再返回真实数据 用 @Transaction 原子替换;或 UI 层保留上一次非空数据
存储暴涨、被用户投诉 缓存无上限、无清理 定期清理(WorkManager,第 13 章)+ 设置页展示缓存大小
Android 10+ 写公共目录失败 FileNotFoundException 分区存储禁止直接写 用 MediaStore 或 SAF
明文 Token 被安全扫描通报 Token 存在普通 DataStore / SP 用 EncryptedFile + Keystore(第 19 章)
TypeConverter 不生效 忘了在 @Database 上加 @TypeConverters 在 Database 抽象类上标注
主线程卡顿/ANR 大列表查询结果在主线程做映射 Flow.map 配合 flowOn(Dispatchers.Default)(第 11 章)
测试互相干扰 用了文件数据库且未清理 用 inMemoryDatabaseBuilder,@After 中 close()

四、调试技巧

  1. Database Inspector(首选):App Inspection → Database Inspector,可实时查看表内容、直接执行 SQL, 还能勾选"Live updates"观察 Flow 查询的实时变化。
  2. 导出真实数据库文件:
    # debug 包可用 run-as 读取私有目录(release 包不行)
    adb exec-out run-as com.techfeed.app cat databases/techfeed.db > techfeed.db
    # 用 sqlite3 或 DB Browser 打开分析
    sqlite3 techfeed.db ".schema articles"
    
  3. 校验 schema 演进:schemas/ 目录下每个版本一个 JSON,Code Review 时 diff 一下就能看出迁移是否安全。
  4. DataStore 文件位置:/data/data/pkg/files/datastore/*.preferences_pb,同样用 run-as 导出排查配置没生效的问题。
  5. 开启 Room 的查询日志:Room 会在编译期把 SQL 写入生成代码,可在 build/generated/ksp/... 下查看最终 SQL。
  6. StrictMode 检测主线程磁盘读写(开发期必开,第 17 章):
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().penaltyLog().build()
    )
    
  7. 模拟"数据库被杀":用 adb shell am kill 后重进 App,验证缓存与恢复逻辑(第 03 章)。

五、最佳实践

  1. 一库一数据源:对外只暴露 Flow,UI 只读本地;网络只写本地,不直接喂 UI(SSOT)。
  2. 缓存型数据扁平化:减少 join,用空间换查询速度;真正需要独立查询的实体才拆表。
  3. exportSchema = true 并提交:迁移的事故几乎都源于"不知道线上到底是哪版 schema"。
  4. 迁移对象永不删除:用户可能跨越多个版本升级。
  5. release 禁用 destructiveMigration:缓存库可用 onDowngrade 精确降级,不要全局兜底。
  6. DataStore 单例化:重复实例会直接抛异常;并给 data 加 catch (IOException) 兜底。
  7. 缓存要有上限:配合 WorkManager 定期清理,并在设置页提供手动清理入口。
  8. 敏感数据一律加密:Token、支付凭据走 EncryptedFile + Keystore,不进明文 DataStore。
  9. 大文件不进数据库:数据库只存路径或 Uri,文件放文件系统 / MediaStore。
  10. 查询映射切线程:Flow.map 后加 flowOn(Dispatchers.Default),避免大列表解析阻塞。
推荐 ✅ 避免 ❌
fun observeAll(): Flow<List<T>> fun getAll(): List<T>(一次性 + 无更新通知)
@Upsert 落地网络数据 先 deleteAll 再 insertAll(中间态被读到)
手写 Migration + 导出 schema fallbackToDestructiveMigration() 兜一切
EncryptedFile 存 Token 明文存 SharedPreferences / DataStore
MediaStore / SAF 写公共目录 直接 File("/sdcard/DCIM/...")
Room.inMemoryDatabaseBuilder 测试 测试复用真实数据库文件
单一数据源(本地为真相) UI 有时读网络、有时读本地

六、本章小结

  1. 选型三要素:数据形态(键值/结构/二进制)+ 访问频率 + 安全等级,决定用 DataStore、Room 还是文件系统。
  2. Room 三件套:Entity 描述表、Dao 描述查询、Database 管理版本;返回 Flow 让数据变化自动驱动 UI,编译期校验 SQL 消灭运行时崩溃。
  3. 迁移是长期负债:自动迁移处理简单变更,手写 Migration 处理复杂变更,destructiveMigration 是 release 禁区,迁移对象永不删除。
  4. DataStore 取代 SharedPreferences:异步、事务、类型安全;Preferences 简单、Proto 有 schema;迁移用 SharedPreferencesMigration 一次性完成。
  5. 文件与加密:内部/缓存/外部私有/MediaStore 各有边界;敏感数据用 EncryptedFile + Keystore,不进明文存储。

上一章衔接:第 09 章 网络请求 的 Result<List<Article>> 正是本章 dao.replacePage() 的输入, 两者组合成了离线优先仓库。 下一章:Flow 与 suspend 贯穿了本章全部代码,但你还需要系统理解它们——进入 第 11 章 异步处理:协程与 Flow,掌握结构化并发、异常处理与 StateFlow/SharedFlow 的取舍。


七、动手练习

基础题

  1. 新建 DraftEntity(字段:id、articleId、content、updatedAt),写出对应 Dao,要求:按 updatedAt 倒序返回 Flow,并支持 Upsert。
  2. 写一次手动迁移 MIGRATION_3_4(给 articles 加一列 read_progress INTEGER NOT NULL DEFAULT 0),并在 Database Inspector 中验证旧数据未丢失。
  3. 用 DataStore Preferences 实现"记住上次选择的频道",杀进程后重新进入 App 验证值被恢复。

进阶题 4. 实现 OfflineFirstFeedRepository 的刷新竞态处理:快速下拉刷新两次时,保证第一次的结果不会覆盖第二次(提示:Mutex 或请求序号,见第 11 章)。 5. 给 AppFileStorage 加一个"缓存超过 100MB 时按 LRU 淘汰"的策略,并写单元测试验证淘汰顺序正确。 6. 把 SecureTokenStorage 接入第 09 章的 TokenProvider 接口,实现"App 冷启动从加密文件恢复 Token",并覆盖"文件损坏/密钥失效"的降级路径。

思考题 7. 为什么 Room 的 Flow 查询方法不需要 suspend,而一次性查询却需要?如果给 Flow 查询也加上 suspend 会发生什么? 8. fallbackToDestructiveMigration() 在什么场景下是可以接受的?如果某个表是用户草稿(不可丢)、另一个表是缓存(可丢),你怎么设计迁移策略? 9. 分区存储(Scoped Storage)限制 App 直接写公共目录,这对"导出日志到 SD 卡""保存用户头像到相册"这类需求分别意味着什么?

📄 第 11 章 异步处理:协程与 Flow 11-coroutines-and-flow.md

阶段:进阶 | 预计阅读 90 min | 代码落地模块::core:common、:core:testing、:feature:feed 前置:第 01 章 Kotlin 速成与协程预热 | 第 03 章 应用组件与生命周期 | 第 09 章 网络请求 相关章节:第 10 章 数据存储 | 第 14 章 分层架构与 MVI | 第 21 章 测试策略 | 第 17 章 性能优化

学习目标

  • [ ] 理解结构化并发:协程树如何随生命周期自动取消,为什么禁用 GlobalScope。
  • [ ] 能正确选择 Job / SupervisorJob、launch / async、coroutineScope / supervisorScope,并解释它们的失败传播差异。
  • [ ] 掌握协程异常处理三件套:try/catch、CoroutineExceptionHandler、以及"CancellationException 绝不能吞"。
  • [ ] 理解 Flow 的冷流本质与常用操作符,能写出搜索防抖、多源聚合等真实场景。
  • [ ] 说清 StateFlow 与 SharedFlow 的差异与 replay 配置,会用 stateIn + WhileSubscribed 做生命周期感知的共享。
  • [ ] 会用 runTest、TestDispatcher 与 Turbine 为协程与 Flow 写确定性测试(不再 sleep)。

一、核心概念

1.1 为什么 Android 必须异步

Android 的主线程(Main Thread / UI Thread)负责绘制与事件分发,单线程循环处理消息队列。 主线程被阻塞 5 秒 → ANR(Application Not Responding,应用无响应);阻塞 16ms 以上 → 掉帧。

因此有一条铁律:主线程只做两件事——更新 UI 和分发事件,其他一律挪走。

flowchart LR
    A["用户点击"] --> B["主线程消息队列"]
    B --> C["事件分发 / 测量布局绘制"]
    C --> D{"是否有耗时操作?"}
    D -->|"是:网络/DB/大图"| E["切到后台线程"]
    E --> F["withContext(Dispatchers.IO)"]
    F --> G["回到主线程更新 UI"]
    D -->|"否"| H["直接执行"]
    G --> B

协程解决的是**"切过去再切回来"这件本来需要回调嵌套的事**。它的杀手锏是:用同步的书写顺序表达异步逻辑。

1.2 结构化并发:协程是一棵树

这是协程最重要、也最容易被忽略的概念。每个协程都有父协程,取消父协程会递归取消所有子协程。

flowchart TB
    VM["viewModelScope<br/>CoroutineScope(SupervisorJob + Main)"] --> A["Job A:加载信息流"]
    VM --> B["Job B:加载用户信息"]
    VM --> C["Job C:上报埋点"]
    A --> A1["子协程:网络请求"]
    A --> A2["子协程:写入 Room"]
    B --> B1["子协程:读取 DataStore"]
    VM -.->|"ViewModel.onCleared()<br/>cancel 所有子协程"| A
    VM -.->|"递归取消"| A1

结构化并发带来三条推论:

  1. 不需要手动管理生命周期:viewModelScope 在 onCleared() 时自动取消,页面销毁后不会有回调继续执行。
  2. 父协程会等待所有子协程完成:launch 的父作用域退出前,会等它的所有子协程结束(这就是"结构化"的含义)。
  3. GlobalScope 是反模式:它不属于任何生命周期,取消不了,是内存泄漏与"页面销毁后更新 UI 崩溃"的头号来源。

[!WARNING] 只有一种场景可以接受全局作用域:进程级别的、本就应该活到 App 结束的任务(如崩溃上报的兜底)。 即便如此也应自建 @Singleton class AppScope(...) : CoroutineScope,而不是直接用 GlobalScope——前者至少可测试、可取消。

1.3 Job 与 SupervisorJob:失败是否连坐

类型 子协程失败的影响 适用场景
Job(默认) 子协程抛出未捕获异常 → 取消父协程与其他兄弟协程 一组任务必须"要么全成、要么全败"
SupervisorJob 子协程失败向上传播被阻断,兄弟协程不受影响 UI 场景:一个请求失败不该连带取消其他请求
// viewModelScope 内部就是 SupervisorJob + Dispatchers.Main.immediate
// 所以 ViewModel 里两个并行的请求互不影响 —— 这是官方的有意设计

注意 SupervisorJob 只对子协程生效:如果你在自己的 Scope 里 coroutineScope { } 再包一层,内层仍是普通 Job,仍会连坐。

1.4 Dispatcher 怎么选

Dispatcher 线程池 适用 不适用
Dispatchers.Main 主线程(1 线程) 更新 UI、调用 UI 相关 API 任何 I/O、JSON 解析、图片处理
Dispatchers.Main.immediate 主线程 已在主线程时不重新调度,直接执行 —
Dispatchers.IO 弹性线程池(默认 64) 网络、磁盘、数据库 CPU 密集计算(会耗尽线程)
Dispatchers.Default 固定线程池(= CPU 核数) JSON 解析(大)、排序、图片变换、加密 阻塞式 I/O
newSingleThreadContext 单线程 串行化访问非线程安全资源 通用场景(用完必须 close())

[!TIP] suspend 函数必须是主线程安全的:它内部自己 withContext 切线程,调用方无需关心。 所以 Retrofit 的 suspend 接口、Room 的 suspend 查询,你直接调就行——它们已经切好了。

1.5 launch 与 async

launch async
返回 Job Deferred<T>(有结果)
取结果 — await()
异常时机 未捕获异常立即向上抛 在 await() 时才抛出(若不 await 可能被静默吞掉)
典型场景 触发即忘(刷新、上报) 需要并发聚合多个结果

⚠️ async 最容易犯的错:启动了却没 await,异常被吞且任务白跑。并发聚合请优先用 coroutineScope { } 包住。

1.6 coroutineScope 与 supervisorScope

两者都是挂起函数,会等待内部所有子协程完成后才返回,区别在于失败传播:

  • coroutineScope:任一子协程失败 → 取消其余兄弟、自己抛出(全败)。
  • supervisorScope:子协程失败互不影响,你可以逐个 try/catch 处理(部分成功)。

1.7 取消是协作式的

协程取消不是强制杀线程,而是:job.cancel() 把协程标记为取消 → 协程在下一个挂起点(delay、withContext、await、Flow 的 emit)抛出 CancellationException。

推论:

  • 纯 CPU 循环不会响应取消。要手动检查:while (isActive) 或 ensureActive()。
  • Thread.sleep() 不响应取消,必须用 delay()。
  • 阻塞式 I/O 也不响应取消,包在 withContext(Dispatchers.IO) 里也不行——得用可中断的 API 或超时。

1.8 异常处理:三条规则

flowchart TB
    A["协程抛出异常"] --> B{"是 CancellationException?"}
    B -->|"是"| C["正常取消信号<br/>必须重新抛出"]
    B -->|"否"| D{"用 launch 还是 async?"}
    D -->|"launch"| E["交给 CoroutineExceptionHandler<br/>或向上冒泡到根作用域崩溃"]
    D -->|"async"| F["暂存在 Deferred<br/>await() 时才抛出"]
  1. CancellationException 必须重新抛出。用 catch (e: Exception) 会把它一起吞掉,导致协程"取消不了", ViewModel 销毁后任务仍在跑——这是最隐蔽的泄漏。
  2. launch 的异常无法用外层 try/catch 捕获(因为 launch 立即返回)。要在协程体内 try/catch,或装 CoroutineExceptionHandler。
  3. CoroutineExceptionHandler 只对 launch 生效,对 async 无效(async 的异常在 await 处抛出)。它不是"吞异常"的兜底, 更不能被用来替代正确的错误处理——它拿不到协程上下文之外的业务信息。

1.9 Flow:冷流

Flow 是冷流(Cold Stream):没有收集者(collector)时,上游代码根本不会执行;每次 collect 都会重新跑一遍上游。

flowchart LR
    A["flow { emit(1); emit(2) }"] -->|"map"| B["转换"]
    B -->|"filter"| C["过滤"]
    C -->|"flowOn(IO)"| D["切换上游线程"]
    D --> E["collect 收集"]
    E -->|"每次 collect 重新执行"| A

这与 StateFlow(热流,Hot Stream)形成对比:热流独立于收集者存在,数据缓存在对象里。

特性 Flow(冷) StateFlow / SharedFlow(热)
无收集者时 不生产数据 照样生产(StateFlow 有值)
多个收集者 各自独立执行一遍上游 共享同一份数据
典型用途 一次性数据流(网络、数据库查询) UI 状态、事件总线

1.10 StateFlow 与 SharedFlow 的分工

维度 StateFlow SharedFlow
本质 状态容器,.value 可读可写 事件流,无 .value
初始值 必须有(构造时传) 无
新订阅者收到 立即收到最新值(replay=1 固定) 取决于 replay(默认 0,收不到历史)
是否去重 是(Any.equals 相同不发射) 否(每次 emit 都算)
适用 页面状态(UiState)、配置项 一次性事件(Toast、导航、弹窗)

[!WARNING] 用 SharedFlow(replay = 1) 当事件通道是可行的,但不要用 StateFlow 承载一次性事件: 旋转屏幕后重新收集,StateFlow 会把上次的 Toast 事件再推一次,用户会看到重复弹窗。

1.11 stateIn:把冷流变成生命周期感知的热流

Repository 返回的冷流 Flow<List<Article>>,如果每个收集者都自己跑一遍(重查一次数据库、重发一次请求),就是浪费。 stateIn 把它变成共享的 StateFlow:

SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000)

含义:最后一个订阅者消失后,再等 5 秒才停止上游。为什么要等?因为旋转屏幕、短暂跳页会造成"订阅者瞬间消失又回来", 5 秒的宽限期能避免上游被反复启停(数据库被关了又开)。其他策略:

策略 行为 适用
Eagerly 立即启动,永不停止 数据必须常驻(如登录态)
Lazily 首个订阅者到来时启动,永不停止 启动后就不该停
WhileSubscribed(5000) 有订阅者才跑,停 5 秒后关闭 UI 数据的默认选择

1.12 flowOn 与 launchIn

  • flowOn(Dispatchers.IO):只影响它上游的执行线程(下游仍在收集者的线程)。多次调用可分段切换。
  • launchIn(scope):在指定作用域中启动收集,返回 Job,等价于 scope.launch { flow.collect() },链式写起来更顺。

1.13 callbackFlow:把回调 API 变成 Flow

第三方 SDK(定位、传感器、网络回调、广播)只给回调接口。用 callbackFlow 可以把它包装成 Flow, 从而享受 Flow 的操作符与生命周期管理。两个关键约束:

  1. 必须 awaitClose { 注销回调 }——否则收集结束后回调还在跑,必然泄漏。
  2. trySend 而非阻塞的 send(在 callbackFlow 内推荐用 trySend 避免意外挂起)。

1.14 测试:为什么必须换掉调度器

生产代码里的 Dispatchers.Main 在 JVM 单元测试中不存在(会抛 IllegalStateException: Module with the Main dispatcher had failed to initialize), Dispatchers.IO 又是真实的线程池——导致测试不确定、需要 sleep。解法是 kotlinx-coroutines-test:

工具 作用
runTest 测试协程的入口,自动跳过 delay,结束时会校验没有遗留任务
StandardTestDispatcher 不立即执行,需 advanceUntilIdle() / advanceTimeBy() 推进,确定性最强
UnconfinedTestDispatcher 立即执行,适合测 StateFlow 的当前值
TestDispatcher + Dispatchers.setMain 替换主线程调度器
Turbine Flow 测试库:awaitItem() 逐个断言事件

1.15 背压与限流

背压(Backpressure)指"生产快于消费"时的处理策略。Flow 默认通过挂起 emit 自动适配(生产者等消费者), 但当生产来自非挂起源头(回调、callbackFlow、传感器)时,就必须显式选择策略:

操作符 行为 适用
buffer(capacity) 缓冲 N 个,满了再挂起生产者 允许少量堆积,提高吞吐
conflate() 只保留最新值,丢弃中间 UI 场景:只关心最新状态(如进度、定位)
collectLatest { } 新值到来时取消上一次的处理 渲染耗时,旧值已无意义
debounce(timeout) 静默一段时间后才发射 搜索输入、窗口尺寸变化

另一类问题是并发数量失控:循环里 launch 一万个协程会把线程池撑爆。正确做法是用 Semaphore(信号量)限流,或用 Flow 的背压机制把批量任务变成可控的流。

1.16 Flow 与 Channel 怎么选

Channel 是热的、带缓冲的协程间通信原语,支持多生产多消费。 绝大多数业务场景应优先用 Flow:

  • Flow 更声明式,能享受操作符、生命周期与测试设施;Channel 需要手动管理发送与关闭。
  • 需要"跨协程共享事件队列"时才用 Channel(如生产者消费者模型、多路复用 select)。
  • Channel 的发送方如果忘了 close(),消费者会永远挂起——这是最常见的死锁来源。

[!TIP] 判断口诀:"我想要一份可组合的数据流" → Flow;"我要一个协程往里塞、另一个协程往外取的队列" → Channel。

1.17 性能视角:协程不是免费的

协程很轻(一个对象几十字节),但不是零成本。三条需要记住的性能常识:

  1. 创建成本 ≈ 几十纳秒,但一万个协程仍会占用一万个 Job 对象与调度开销。批量任务要限流。
  2. Dispatchers.IO 默认最多 64 线程,且每个 withContext(IO) 切换都是一次线程调度。 在循环里逐条插入数据库并每次切 IO,比"一次批量插入"慢一个数量级——批量优于频繁切换。
  3. withContext 是安全的挂起点,但每次切换都会经历"挂起—入队—恢复"。 不要写 withContext(IO) { 1 + 1 } 这种粒度小到不值得切换的代码。

与之相对的是真正的性能杀手:runBlocking 在主线程调用会阻塞整条消息队列, 造成掉帧甚至 ANR;Thread.sleep 会占住线程不放。这两者在业务代码里应被视为禁词。


二、示例代码

2.1 依赖

// 文件路径:sample/core/common/build.gradle.kts
dependencies {
    implementation(libs.kotlinx.coroutines.android)   // 提供 Dispatchers.Main
    testImplementation(libs.bundles.testing.unit)     // junit + truth + turbine + coroutines-test
    // 版本统一由 gradle/libs.versions.toml 收口,正文不写死版本号
}

2.2 结构化并发:Scope 的正确用法

// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedViewModel.kt
package com.techfeed.feature.feed.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.techfeed.core.model.AppError
import com.techfeed.core.model.Article
import com.techfeed.core.model.Result
import com.techfeed.feature.feed.domain.GetFeedUseCase
import com.techfeed.feature.feed.domain.RefreshFeedUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

/** UI 状态:一个不可变快照,UI 只是它的函数 */
data class FeedUiState(
    val isLoading: Boolean = false,
    val articles: List<Article> = emptyList(),
    val error: AppError? = null,
    val isRefreshing: Boolean = false,
)

/** 一次性事件:不进 UiState,避免重建时重复触发 */
sealed interface FeedEvent {
    data class ShowToast(val message: String) : FeedEvent
    data class NavigateToDetail(val articleId: String) : FeedEvent
    data object ScrollToTop : FeedEvent
}

@HiltViewModel
class FeedViewModel @Inject constructor(
    private val getFeed: GetFeedUseCase,
    private val refreshFeed: RefreshFeedUseCase,
) : ViewModel() {

    private val _uiState = MutableStateFlow(FeedUiState())
    val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()

    private val _events = MutableSharedFlow<FeedEvent>(
        // extraBufferCapacity:允许在没人收集时(如页面刚重建)先缓存一个事件不丢
        extraBufferCapacity = 1,
    )
    val events: SharedFlow<FeedEvent> = _events.asSharedFlow()

    // 保存刷新任务的句柄:用于"重复点击只保留最后一次"
    private var refreshJob: Job? = null

    init {
        load()
    }

    private fun load() {
        // viewModelScope 在 onCleared 时自动 cancel,无需手动管理
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true)
            when (val result = getFeed()) {
                is Result.Success -> _uiState.value = FeedUiState(articles = result.data)
                is Result.Failure -> _uiState.value = FeedUiState(error = result.error)
                Result.Loading -> Unit
            }
        }
    }

    fun onRefresh() {
        // 取消上一次未完成的刷新:避免两次结果乱序覆盖(竞态)
        refreshJob?.cancel()
        refreshJob = viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isRefreshing = true)
            try {
                when (val result = refreshFeed()) {
                    is Result.Success -> _events.emit(FeedEvent.ScrollToTop)
                    is Result.Failure -> _events.emit(FeedEvent.ShowToast("刷新失败"))
                    Result.Loading -> Unit
                }
            } catch (e: CancellationException) {
                // ⚠️ 取消是正常信号,必须原样抛出,否则协程无法真正取消
                throw e
            } finally {
                // finally 中恢复标志位;注意:取消时 finally 仍会执行(但里面的挂起调用会立即再抛取消)
                _uiState.value = _uiState.value.copy(isRefreshing = false)
            }
        }
    }

    fun onArticleClick(id: String) {
        viewModelScope.launch { _events.emit(FeedEvent.NavigateToDetail(id)) }
    }
}

2.3 launch vs async:并发聚合首页数据

// 文件路径:docs/samples/ch11/LoadHomeUseCase.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.common.DispatcherProvider
import com.techfeed.core.model.Article
import com.techfeed.core.model.Banner
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import javax.inject.Inject

data class HomeData(val articles: List<Article>, val banners: List<Banner>, val unreadCount: Int)

class LoadHomeUseCase @Inject constructor(
    private val articleRepo: ArticleRepository,
    private val bannerRepo: BannerRepository,
    private val messageRepo: MessageRepository,
    private val dispatchers: DispatcherProvider,
) {
    /**
     * 三个接口并发请求,全部成功后一起返回(总耗时 = 最慢的那个,而非三者之和)。
     * coroutineScope:任一失败则整体失败并取消其余请求 —— 首页数据缺一不可,这是正确的语义。
     */
    suspend operator fun invoke(): HomeData = withContext(dispatchers.default) {
        coroutineScope {
            val articles = async { articleRepo.topArticles() }
            val banners = async { bannerRepo.activeBanners() }
            val unread = async { messageRepo.unreadCount() }
            // await 时才抛异常;三个都 await 了,异常不会被吞
            HomeData(articles.await(), banners.await(), unread.await())
        }
    }
}
// 文件路径:docs/samples/ch11/LoadHomeTolerantUseCase.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.model.Article
import com.techfeed.core.model.Banner
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
import javax.inject.Inject

/**
 * 容错版:某个接口挂了不影响其他数据("有总比没有好")。
 * supervisorScope 阻断失败向上传播,对每个 Deferred 单独 try/catch。
 */
class LoadHomeTolerantUseCase @Inject constructor(
    private val articleRepo: ArticleRepository,
    private val bannerRepo: BannerRepository,
) {
    suspend operator fun invoke(): HomeData = supervisorScope {
        val articles = async { runCatching { articleRepo.topArticles() }.getOrDefault(emptyList()) }
        val banners = async { runCatching { bannerRepo.activeBanners() }.getOrDefault(emptyList()) }
        HomeData(
            articles = articles.await(),
            banners = banners.await(),
            unreadCount = 0,
        )
    }
}

[!TIP] 选 coroutineScope 还是 supervisorScope,本质是业务问题:这一组数据缺一不可,还是缺了也能用? 首页主内容用前者,首页的"运营弹窗 + 消息红点"用后者。

2.4 Dispatcher 注入:让测试可控

// 文件路径:sample/core/common/src/main/java/com/techfeed/core/common/DispatcherProvider.kt
package com.techfeed.core.common

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Inject

/**
 * 不要在业务代码里直接写 Dispatchers.IO —— 那样测试时无法替换。
 * 注入接口,测试期传入 TestDispatcher,生产期传真实 Dispatcher。
 */
interface DispatcherProvider {
    val main: CoroutineDispatcher
    val io: CoroutineDispatcher
    val default: CoroutineDispatcher
}

class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider {
    override val main: CoroutineDispatcher get() = Dispatchers.Main
    override val io: CoroutineDispatcher get() = Dispatchers.IO
    override val default: CoroutineDispatcher get() = Dispatchers.Default
}
// 文件路径:sample/core/common/src/main/java/com/techfeed/core/common/di/DispatcherModule.kt
package com.techfeed.core.common.di

import com.techfeed.core.common.DefaultDispatcherProvider
import com.techfeed.core.common.DispatcherProvider
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class DispatcherModule {
    /** @Binds 比 @Provides 更高效:只是把实现类型映射到接口,无方法体 */
    @Binds
    @Singleton
    abstract fun bindDispatcherProvider(impl: DefaultDispatcherProvider): DispatcherProvider
}

2.5 取消、超时与资源释放

// 文件路径:docs/samples/ch11/CoroutineExt.kt
package com.techfeed.core.common

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext

/**
 * 带超时的重试:
 * 1. 用 withTimeoutOrNull 而非 withTimeout —— 后者抛 TimeoutCancellationException,需要额外 catch
 * 2. 用 delay 而非 Thread.sleep —— delay 可被取消,sleep 不能
 * 3. 遇到 CancellationException 立即抛出
 */
suspend fun <T> retryWithTimeout(
    times: Int = 3,
    initialDelayMs: Long = 500,
    timeoutMs: Long = 5_000,
    block: suspend () -> T,
): T? {
    var currentDelay = initialDelayMs
    repeat(times - 1) { attempt ->
        withTimeoutOrNull(timeoutMs) { block() }?.let { return it }
        delay(currentDelay)
        currentDelay *= 2      // 指数退避
    }
    return withTimeoutOrNull(timeoutMs) { block() }   // 最后一次尝试
}

/** CPU 密集循环必须手动检查 isActive,否则协程无法被取消 */
suspend fun computeHeavy(iterations: Int): Long = coroutineScope {
    var sum = 0L
    for (i in 1..iterations) {
        ensureActive()          // 等价于 if (!isActive) throw CancellationException()
        sum += i
        if (i % 1000 == 0) delay(1)   // 定期让出,给取消一个挂起点
    }
    sum
}

2.6 异常处理:CoroutineExceptionHandler 的正确位置

// 文件路径:docs/samples/ch11/CoroutineExceptionHandlerExt.kt
package com.techfeed.core.crash

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

/**
 * ⚠️ CoroutineExceptionHandler 只对 launch 的根协程生效:
 * 装在子协程上不会起作用(异常会交给父协程);装在 async 上也不起作用。
 * 它的定位是"兜底上报",不是业务流程的错误处理。
 */
val GlobalReportHandler = CoroutineExceptionHandler { _, throwable ->
    CrashReporter.record(throwable)   // 上报,见第 18 章
}

fun CoroutineScope.safeLaunch(block: suspend CoroutineScope.() -> Unit) =
    launch(GlobalReportHandler, block = block)
// 文件路径:docs/samples/ch11/FeedViewModelErrorHandling.kt
package com.techfeed.feature.feed.ui

import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch

/** 推荐的错误处理写法:在协程体内 try/catch,粒度最细、信息最全 */
fun FeedViewModel.loadSafely() {
    viewModelScope.launch {
        try {
            val data = getFeed()
            // 处理数据
        } catch (e: CancellationException) {
            throw e                                  // ① 取消必须重抛
        } catch (e: java.io.IOException) {
            _uiState.value = _uiState.value.copy(error = com.techfeed.core.model.AppError.Network(e))
        } catch (e: Exception) {
            _uiState.value = _uiState.value.copy(error = com.techfeed.core.model.AppError.Unknown(e))
        }
    }
}

[!WARNING] 不要用 runCatching { } 包住整个协程体:它会把 CancellationException 一起捕获, 导致协程取消失效(ViewModel 销毁后任务继续跑)。要么手写 try/catch 并重抛取消, 要么用 runCatching 后显式判断 if (e is CancellationException) throw e。

2.7 Flow 构建与常用操作符

// 文件路径:docs/samples/ch11/ObserveFeedUseCase.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.model.Article
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

class ObserveFeedUseCase @Inject constructor(
    private val repository: ArticleRepository,
    private val settings: SettingsRepository,
) {
    /**
     * 组合多个数据源:文章内容 + 用户的"只看收藏"开关。
     * combine:任一方变化就用最新的另一方重新计算(区别于 zip 的一对一配对)。
     */
    operator fun invoke(): Flow<List<Article>> = combine(
        repository.observeArticles(),
        settings.observeFavoritesOnly(),
    ) { articles, favoritesOnly ->
        if (favoritesOnly) articles.filter { it.isFavorite } else articles
    }
        .distinctUntilChanged()                    // 内容没变就不发射,减少无意义重组
        .flowOn(kotlinx.coroutines.Dispatchers.Default)   // 过滤/映射这类 CPU 操作放 Default
        .catch { emit(emptyList()) }               // Flow 异常兜底;注意它也会终止上游
}

@OptIn(ExperimentalCoroutinesApi::class)
class SearchUseCase @Inject constructor(private val repository: ArticleRepository) {
    /**
     * 搜索防抖:
     * debounce —— 停止输入 300ms 后才发请求
     * distinctUntilChanged —— 相同关键词不重复请求
     * flatMapLatest —— 新关键词到来时取消上一次未完成的搜索(关键!否则结果乱序)
     */
    operator fun invoke(queryFlow: Flow<String>): Flow<List<Article>> = queryFlow
        .map { it.trim() }
        .distinctUntilChanged()
        .debounce(300)
        .filter { it.length >= 2 }
        .flatMapLatest { query -> repository.search(query) }
        .retryWhen { cause, attempt ->
            // 网络类错误重试 2 次,其他直接抛出
            if (cause is java.io.IOException && attempt < 2) {
                delay(2.0.seconds.inWholeMilliseconds * (attempt + 1))
                true
            } else {
                false
            }
        }
}

/** 轮询:用 flow + delay 实现,天然可被取消(比 Timer/Handler 安全得多) */
fun tickerFlow(intervalMs: Long): Flow<Unit> = flow {
    while (true) {
        emit(Unit)
        delay(intervalMs)
    }
}

[!NOTE] debounce、flatMapLatest、retryWhen 在早期版本需要 @OptIn(ExperimentalCoroutinesApi::class), 较新的 coroutines 版本已转正。以工程中实际编译提示为准。

2.8 StateFlow / SharedFlow 与 stateIn

// 文件路径:docs/samples/ch11/FeedStateHolder.kt
package com.techfeed.feature.feed.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.techfeed.feature.feed.domain.ObserveFeedUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update

/**
 * 冷流转热流的标准写法:
 * - scope = viewModelScope:随 ViewModel 销毁而取消
 * - started = WhileSubscribed(5000):最后一个订阅者离开 5 秒后才停上游
 *   → 旋转屏幕、短暂跳页不会导致数据库被关闭重开
 * - initialValue:StateFlow 必须有初始值,这里给 Loading
 */
class FeedStateHolder(
    observeFeed: ObserveFeedUseCase,
    private val scope: kotlinx.coroutines.CoroutineScope,
) {
    val uiState: StateFlow<FeedUiState> = combine(
        observeFeed(),
        MutableStateFlow(false),
    ) { articles, refreshing ->
        FeedUiState(articles = articles, isRefreshing = refreshing)
    }.stateIn(
        scope = scope,
        started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS),
        initialValue = FeedUiState(isLoading = true),
    )

    private companion object {
        const val STOP_TIMEOUT_MS = 5_000L
    }
}
// 文件路径:docs/samples/ch11/EventFlow.kt
package com.techfeed.core.common

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow

/**
 * 事件通道的统一配置。三个参数必须理解清楚:
 * replay = 0        → 新订阅者不会收到历史事件(避免旋转屏幕重复弹 Toast)
 * extraBufferCapacity = 1 → 允许在无人收集时暂存 1 个事件(页面刚重建时不丢)
 * onBufferOverflow = DROP_OLDEST → 缓冲满时丢最旧的,保证 emit 不挂起
 */
class EventFlow<T> {
    private val _flow = MutableSharedFlow<T>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val flow: SharedFlow<T> = _flow.asSharedFlow()

    /** tryEmit 非挂起:在 UI 回调、init 块中也能安全发送 */
    fun tryEmit(event: T) { _flow.tryEmit(event) }
}

2.9 callbackFlow:把回调 API 变成 Flow

// 文件路径:docs/samples/ch11/NetworkStatusFlow.kt
package com.techfeed.core.common

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged

/** 把 ConnectivityManager.NetworkCallback 包装成 Flow,替换已失效的 CONNECTIVITY_ACTION 广播 */
fun networkStatusFlow(context: Context): Flow<Boolean> = callbackFlow {
    val manager = context.getSystemService(ConnectivityManager::class.java)
    val request = NetworkRequest.Builder()
        .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
        .build()

    val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { trySend(true) }
        override fun onLost(network: Network) { trySend(false) }
    }

    manager.registerNetworkCallback(request, callback)
    trySend(manager.isCurrentlyOnline())   // 先推一次当前状态,冷流不至于一开始没值

    // ⚠️ awaitClose 必须存在:收集结束时注销回调,否则 Context 泄漏
    awaitClose { manager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()

private fun ConnectivityManager.isCurrentlyOnline(): Boolean =
    activeNetwork?.let { getNetworkCapabilities(it) }
        ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true

2.10 并发安全:Mutex 与 Semaphore

// 文件路径:docs/samples/ch11/ConcurrencyExt.kt
package com.techfeed.core.common

import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit

/**
 * 场景一:保护共享可变状态(如内存缓存、写入同一文件)。
 * Mutex 是协程版的锁,但它是"挂起等待"而不是阻塞线程 —— 不会占用线程池。
 */
class InMemoryCache<T> {
    private val mutex = Mutex()
    private val map = mutableMapOf<String, T>()

    suspend fun put(key: String, value: T) = mutex.withLock { map[key] = value }
    suspend fun get(key: String): T? = mutex.withLock { map[key] }
}

/**
 * 场景二:批量任务限流。
 * Semaphore(3) 表示同时最多 3 个任务在执行,其余挂起等待许可。
 * 不用限流的话,1000 个并发上传会瞬间创建 1000 个协程并把连接池打满。
 */
suspend fun <T> List<T>.mapConcurrent(
    limit: Int,
    block: suspend (T) -> Unit,
) = coroutineScope {
    val semaphore = Semaphore(limit)
    map { item ->
        async {
            semaphore.withPermit { block(item) }   // 用完自动释放许可,异常也会释放
        }
    }.forEach { it.await() }
}

[!WARNING] Mutex 不是线程锁的替代品:它只在协程内生效,不能保护跨线程的共享可变状态。 另外不要用它包住整个网络请求——那会把并发退化成串行。它只应保护极短的临界区。

2.11 UI 双体系:如何安全收集 Flow

// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedScreen.kt
package com.techfeed.feature.feed.ui

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun FeedScreen(
    onArticleClick: (String) -> Unit,
    modifier: Modifier = Modifier,
    viewModel: FeedViewModel = hiltViewModel(),
) {
    // collectAsStateWithLifecycle:生命周期感知的收集,页面不可见时自动停止(省电、省资源)
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // 一次性事件:用 LaunchedEffect 收集,它的协程随 Composable 退出自动取消
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is FeedEvent.NavigateToDetail -> onArticleClick(event.articleId)
                is FeedEvent.ShowToast -> { /* 显示 Snackbar */ }
                FeedEvent.ScrollToTop -> { /* listState.animateScrollToItem(0) */ }
            }
        }
    }

    if (uiState.isLoading) {
        CircularProgressIndicator(modifier = modifier.fillMaxSize())
        return
    }

    LazyColumn(modifier = modifier.fillMaxSize()) {
        items(items = uiState.articles, key = { it.id }) { article ->
            ListItem(
                headlineContent = { Text(article.title) },
                supportingContent = { Text(article.summary) },
            )
        }
    }
}
// 文件路径:docs/samples/ch11/FeedFragment.kt
package com.techfeed.feature.feed.ui

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.techfeed.feature.feed.databinding.FragmentFeedBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

/**
 * View 体系下的标准收集姿势:
 * ⚠️ 直接 lifecycleScope.launch { collect{} } 是错的 —— 页面进入后台仍会收集,
 * 数据库查询与 UI 更新照跑,白白耗电。必须用 repeatOnLifecycle。
 */
@AndroidEntryPoint
class FeedFragment : Fragment() {

    private var _binding: FragmentFeedBinding? = null
    private val binding get() = requireNotNull(_binding)
    private val viewModel: FeedViewModel by viewModels()
    private lateinit var adapter: FeedAdapter

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, saved: Bundle?): View {
        _binding = FragmentFeedBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        adapter = FeedAdapter(onItemClick = { viewModel.onArticleClick(it) })
        binding.recyclerView.adapter = adapter
        binding.swipeRefresh.setOnRefreshListener { viewModel.onRefresh() }

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // 两个收集独立 launch:互不阻塞
                launch { viewModel.uiState.collect { render(it) } }
                launch { viewModel.events.collect { handle(it) } }
            }
        }
    }

    private fun render(state: FeedUiState) {
        adapter.submitList(state.articles)
        binding.swipeRefresh.isRefreshing = state.isRefreshing
        binding.progress.isVisible = state.isLoading
    }

    private fun handle(event: FeedEvent) { /* Toast / 导航 */ }

    override fun onDestroyView() {
        binding.recyclerView.adapter = null
        _binding = null      // ⚠️ 必须置空
        super.onDestroyView()
    }
}

2.12 测试:runTest、TestDispatcher 与 Turbine

// 文件路径:sample/core/testing/src/main/java/com/techfeed/core/testing/MainDispatcherRule.kt
package com.techfeed.core.testing

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description

/**
 * 把 Dispatchers.Main 替换为测试调度器。
 * 所有用到 viewModelScope / Dispatchers.Main 的测试都要加这个 Rule。
 */
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}
// 文件路径:sample/core/testing/src/main/java/com/techfeed/core/testing/TestDispatcherProvider.kt
package com.techfeed.core.testing

import com.techfeed.core.common.DispatcherProvider
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.TestDispatcher

/** 测试期替换 DispatcherProvider:所有业务都跑在同一个可控调度器上 */
class TestDispatcherProvider(private val dispatcher: TestDispatcher) : DispatcherProvider {
    override val main: CoroutineDispatcher get() = dispatcher
    override val io: CoroutineDispatcher get() = dispatcher
    override val default: CoroutineDispatcher get() = dispatcher
}
// 文件路径:sample/feature/feed/src/test/java/com/techfeed/feature/feed/ui/FeedViewModelTest.kt
package com.techfeed.feature.feed.ui

import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.techfeed.core.model.Article
import com.techfeed.core.model.Result
import com.techfeed.core.testing.MainDispatcherRule
import com.techfeed.feature.feed.domain.FeedRepository
import com.techfeed.feature.feed.domain.GetFeedUseCase
import com.techfeed.feature.feed.domain.RefreshFeedUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class FeedViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())

    /**
     * GetFeedUseCase / RefreshFeedUseCase 是 final class(构造参数是 repository),
     * 不能用"继承 + override invoke"的方式伪造 —— 正确做法是伪造接口 FeedRepository,
     * 再把真实 UseCase 套在 Fake 仓库外面(UseCase 本身只是薄薄的委托)。
     * 注:本章的仓库契约为 getFeed/refresh 的简化形态;第 12 章起 FeedRepository
     * 演进为 observeFeed/refresh/toggleFavorite(演进见各章说明)。
     */
    private val fakeRepository = FakeFeedRepository()

    private fun createViewModel() = FeedViewModel(
        GetFeedUseCase(fakeRepository),
        RefreshFeedUseCase(fakeRepository),
    )

    @Test
    fun `首屏加载成功后状态带数据`() = runTest {
        fakeRepository.feedResult = Result.Success(listOf(createArticle("a-1")))

        val viewModel = createViewModel()
        advanceUntilIdle()      // StandardTestDispatcher 需要手动推进虚拟时间

        val state = viewModel.uiState.value
        assertThat(state.isLoading).isFalse()
        assertThat(state.articles).hasSize(1)
    }

    @Test
    fun `加载失败时状态带错误`() = runTest {
        fakeRepository.feedResult = Result.Failure(
            com.techfeed.core.model.AppError.Network(cause = RuntimeException("boom")),
        )

        val viewModel = createViewModel()
        advanceUntilIdle()

        assertThat(viewModel.uiState.value.error).isNotNull()
        assertThat(viewModel.uiState.value.articles).isEmpty()
    }

    @Test
    fun `点击文章发出导航事件`() = runTest {
        val viewModel = createViewModel()

        // Turbine:逐个断言 Flow 发射的事件
        viewModel.events.test {
            viewModel.onArticleClick("a-42")
            val event = awaitItem()
            assertThat(event).isInstanceOf(FeedEvent.NavigateToDetail::class.java)
            assertThat((event as FeedEvent.NavigateToDetail).articleId).isEqualTo("a-42")
            cancelAndConsumeRemainingEvents()   // ⚠️ 不调用会报"还有未消费事件"
        }
    }

    @Test
    fun `重复刷新只保留最后一次`() = runTest {
        val viewModel = createViewModel()
        viewModel.onRefresh()
        viewModel.onRefresh()      // 第二次应取消第一次
        advanceUntilIdle()

        assertThat(fakeRepository.completedCount).isEqualTo(1)
        assertThat(fakeRepository.cancelledCount).isEqualTo(1)
    }

    private fun createArticle(id: String) = Article(
        id = id, title = "标题-$id", summary = "摘要", coverUrl = null,
        author = com.techfeed.core.model.Author("au", "作者", null),
        publishedAt = 1_700_000_000L,
    )
}

/** Fake 只实现接口 FeedRepository —— 接口才是可替换的边界 */
private class FakeFeedRepository : FeedRepository {
    var feedResult: Result<List<Article>> = Result.Success(emptyList())
    var completedCount = 0
    var cancelledCount = 0

    override suspend fun getFeed(): Result<List<Article>> = feedResult

    override suspend fun refresh(page: Int): Result<Unit> {
        try {
            kotlinx.coroutines.delay(1_000)    // 虚拟时间,runTest 会瞬间跳过
            completedCount++
            return Result.Success(Unit)
        } catch (e: kotlinx.coroutines.CancellationException) {
            cancelledCount++
            throw e
        }
    }
}
// 文件路径:docs/samples/ch11/RetryTest.kt
package com.techfeed.core.common

import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class RetryTest {

    @Test
    fun `重试三次后成功`() = runTest {
        var attempts = 0
        val result = retryWithTimeout(times = 3, initialDelayMs = 500) {
            attempts++
            if (attempts < 3) error("失败") else "成功"
        }
        assertThat(result).isEqualTo("成功")
        assertThat(attempts).isEqualTo(3)
    }

    @Test
    fun `延迟可被虚拟时间跳过`() = runTest {
        var finished = false
        kotlinx.coroutines.launch {
            kotlinx.coroutines.delay(10_000)
            finished = true
        }
        advanceTimeBy(10_000)     // 不用真等 10 秒,测试瞬间完成
        assertThat(finished).isTrue()
    }
}

2.13 把回调式 SDK 包装成 suspend

// 文件路径:docs/samples/ch11/SuspendBridge.kt
package com.techfeed.core.common

import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

/** 假设这是第三方 SDK 的回调接口:无法修改,且回调在它自己的线程池 */
interface LegacyCallback<T> {
    fun onSuccess(value: T)
    fun onError(error: Throwable)
}

interface LegacySdk {
    fun fetch(id: String, callback: LegacyCallback<String>)
}

/**
 * suspendCancellableCoroutine:把一次性回调变成可取消的 suspend 函数。
 * ⚠️ 三个要点:
 * 1. 用 suspendCancellableCoroutine 而非 suspendCoroutine —— 前者才支持取消
 * 2. 只在未取消时 resume,否则会抛 IllegalStateException
 * 3. invokeOnCancellation 里要释放资源 / 注销回调
 */
suspend fun LegacySdk.awaitFetch(id: String): String = suspendCancellableCoroutine { cont ->
    val callback = object : LegacyCallback<String> {
        override fun onSuccess(value: String) {
            if (cont.isActive) cont.resume(value)
        }
        override fun onError(error: Throwable) {
            if (cont.isActive) cont.resumeWithException(error)
        }
    }
    fetch(id, callback)
    cont.invokeOnCancellation { /* 取消时:注销回调或标记作废 */ }
}

[!TIP] 这是把老 SDK 接入协程体系的标准姿势:一次性回调用 suspendCancellableCoroutine, 持续性回调用 callbackFlow。两者都支持结构化取消,是替换 CountDownLatch 等阻塞桥接的正确方式。

旧写法对照:AsyncTask / Handler / LiveData
// 旧写法:AsyncTask —— 内部类持有 Activity 引用导致泄漏,配置变更后结果丢失,已被弃用
private class LoadTask extends AsyncTask<Void, Void, List<Article>> {
    protected List<Article> doInBackground(Void... v) { return api.load(); }
    protected void onPostExecute(List<Article> data) { adapter.submitList(data); }  // 泄漏 Activity
}
// 过渡写法:LiveData —— 生命周期安全,但只在主线程 setter、无线程切换能力、操作符贫乏
val articles: LiveData<List<Article>> = repository.observeArticles()
viewModel.articles.observe(viewLifecycleOwner) { adapter.submitList(it) }
// 想做"搜索防抖 + 请求取消"?LiveData 需要 switchMap + MediatorLiveData,写法冗长且难以测试

为什么迁到 Flow:Flow 与协程共享同一套取消、异常、线程切换与测试设施;stateIn 一行替代 LiveData 的多种变换; runTest 让异步逻辑可确定性测试。存量代码可用 flow.asLiveData() / liveData.asFlow() 渐进迁移(第 16 章)。


三、常见坑 ⚠️

现象 根因 解法
页面销毁后崩溃 / 更新了已销毁的 UI 用了 GlobalScope 或自建无生命周期 Scope 一律 viewModelScope / lifecycleScope;进程级任务自建 AppScope
取消后任务还在跑 catch (e: Exception) 吞了 CancellationException;或用了 Thread.sleep 重抛取消异常;改用 delay()
launch 里的 try/catch 没捕获到异常 异常发生在子协程,launch 已返回 在协程体内 try/catch,或用 CoroutineExceptionHandler(仅根协程有效)
async 的异常被静默吞掉 启动了但没 await 每个 async 都必须 await;或用 coroutineScope 包住
一个请求失败导致其他请求也被取消 用了 coroutineScope / 普通 Job 改 supervisorScope / SupervisorJob
单元测试报 Module with the Main dispatcher had failed to initialize 用了 Dispatchers.Main 但未替换 加 MainDispatcherRule;或注入 DispatcherProvider
测试必须 Thread.sleep 才能通过 未用 runTest / 未推进虚拟时间 runTest + StandardTestDispatcher + advanceUntilIdle()
Turbine 报 "Unconsumed events" 未消费完所有事件 结尾调用 cancelAndConsumeRemainingEvents()
UI 收到重复 Toast 事件用 StateFlow 承载,重建后重放 事件用 SharedFlow(replay = 0)
后台仍在刷新 UI / 耗电增加 直接 lifecycleScope.launch { collect } 用 repeatOnLifecycle(STARTED) / collectAsStateWithLifecycle
Flow 上游被反复启停(数据库频繁开关) stateIn 用了 Eagerly/Lazily 且无人订阅 改用 WhileSubscribed(5000)
flowOn 没生效 放在了 collect 之后;或以为它影响下游 flowOn 只影响其上游,需放在操作符链中间
搜索结果乱序 多个请求并发且未取消旧的 flatMapLatest 或手动保存 Job 并 cancel()
大量协程堆积导致 OOM 循环里 launch 了成千上万个协程 用 Semaphore 限流,或改用 Flow 背压处理
callbackFlow 泄漏 忘了 awaitClose 必须在 awaitClose 中注销回调
主线程卡顿 在 Dispatchers.IO 上做大量 JSON 解析(IO 线程被占满) CPU 密集型用 Dispatchers.Default

四、调试技巧

  1. 开启协程调试开关:VM 参数 -Dkotlinx.coroutines.debug=on(第 01 章),线程名会带上 @coroutine#3, 看堆栈时能一眼分清是哪个协程。
  2. 打印当前上下文:
    println("thread=${Thread.currentThread().name}, dispatcher=${coroutineContext[ContinuationInterceptor]}, job=${coroutineContext[Job]}")
    
  3. 协程调试面板:Android Studio 的 Run → Debugging → Coroutines(较新版本)可查看协程树与挂起点。
  4. 检测泄漏的协程:StrictMode / LeakCanary 对"页面销毁后仍在跑的任务"很有帮助(第 18 章)。
  5. Profiler 看线程:Profiler → CPU 中新建的 coroutine 线程名形如 DefaultDispatcher-worker-1, 主线程出现长条绿色(Running)就说明有耗时操作跑错了线程。
  6. Flow 打点:调试期用 onEach { log(it) } / onStart / onCompletion 观察发射时机,别用 println 污染生产代码。
  7. 测试不确定时:runTest 结束会报 UncompletedCoroutinesError,这恰好是它帮你抓出了"有协程没跑完"的问题—— 别用 runBlocking 绕过,去修真正的原因。
  8. adb 验证 ANR:/data/anr/traces.txt(需 root 或用 adb bugreport),主线程堆栈卡在哪个方法就是元凶。

五、最佳实践

  1. 协程必须挂在有明确生命周期的 Scope 上:viewModelScope / lifecycleScope;GlobalScope 视为 CR 不通过。
  2. suspend 函数要主线程安全:内部自行 withContext,调用方不必关心线程。
  3. Dispatcher 用注入而非硬编码:DispatcherProvider 让测试可以替换成 TestDispatcher。
  4. CancellationException 永不吞:catch (e: CancellationException) { throw e } 写在所有 catch (Exception) 之前。
  5. 并发聚合用 coroutineScope + async,容错场景用 supervisorScope;选择依据是业务语义而非习惯。
  6. UI 状态用 StateFlow,一次性事件用 SharedFlow(replay = 0):这是 UiState + Event 双通道的基础。
  7. 共享冷流用 stateIn(WhileSubscribed(5000)):避免旋转屏幕导致上游反复启停。
  8. CPU 密集用 Default,阻塞 I/O 用 IO:混用会让线程池被占满,排查起来非常痛苦。
  9. 收集 Flow 必须生命周期感知:Compose 用 collectAsStateWithLifecycle,View 用 repeatOnLifecycle(STARTED)。
  10. 测试用 runTest + Turbine + TestDispatcher:不写 sleep,不依赖真实时序。
  11. 回调 API 一律包成 callbackFlow 并 awaitClose:让第三方 SDK 也享受结构化并发。
  12. 限流高并发:批量任务用 Semaphore 或 Flow 的背压机制,不要无脑 launch 上万个协程。
推荐 ✅ 避免 ❌
viewModelScope.launch { } GlobalScope.launch { } / CoroutineScope(Job()) 随手建
catch (e: CancellationException) { throw e } catch (e: Exception) { /* 吞掉 */ }
supervisorScope 做容错聚合 一个接口失败就取消整屏请求
StateFlow 存状态 + SharedFlow 发事件 用 StateFlow 发 Toast
collectAsStateWithLifecycle lifecycleScope.launch { collect {} }
delay() + isActive 检查 Thread.sleep() / 无检查的死循环
runTest + advanceUntilIdle() runBlocking + Thread.sleep(1000)
callbackFlow + awaitClose 裸回调 + 手动注销(容易忘)
注入 DispatcherProvider 业务代码里硬写 Dispatchers.IO

六、本章小结

  1. 结构化并发是协程的第一原则:协程成树、父亡子亡、GlobalScope 是反模式——它把"页面销毁后更新 UI"这类崩溃从根源消灭。
  2. Job 与 SupervisorJob 决定失败是否连坐:全成全败用 coroutineScope,部分可用用 supervisorScope;选哪个是业务决策。
  3. 异常处理三件事:launch 内部 try/catch、CoroutineExceptionHandler 只兜底根协程、CancellationException 必须重抛。
  4. 冷流与热流分工明确:冷流(Room/网络)每次收集重跑,靠 stateIn 共享;StateFlow 承载状态、SharedFlow(replay=0) 承载事件。
  5. 可测试性是协程的巨大红利:runTest 跳过真实等待、TestDispatcher 消除不确定性、Turbine 让 Flow 断言可读——异步代码第一次变得像同步代码一样好测。

上一章衔接:第 10 章 数据存储 里 Room 返回的 Flow<List<Article>>, 正是本章 stateIn 与 collectAsStateWithLifecycle 的上游。 下一章:这些 ViewModel、Repository、UseCase 目前都是手动 new 出来的——进入 第 12 章 依赖注入:Hilt,学习如何让框架替你装配依赖,并在测试中一键替换成 Fake。


七、动手练习

基础题

  1. 写 suspend fun loadTwoPages(): List<Article>,用 coroutineScope + async 并发请求第 1、2 页并合并结果,验证总耗时接近单页耗时而非两倍。
  2. 给上面的并发聚合补一个测试:让第 2 页抛异常,验证第 1 页的请求被取消(提示:在 Fake 中记录 CancellationException)。
  3. 把 MutableStateFlow 换成 EventFlow,写一个 Turbine 测试验证"页面重建后不会收到历史 Toast 事件"。

进阶题 4. 实现搜索防抖:输入框 → debounce(300) → distinctUntilChanged() → flatMapLatest。用 Turbine 验证"输入 a、ab、abc 后只发出一次 abc 的请求"。 5. 实现一个带并发上限的批量上传器:Semaphore(3) 限流 20 个文件,并在任一个失败时用 SupervisorJob 保证其余继续上传,最后汇总成功/失败数量。 6. 用 callbackFlow 封装一个"模拟蓝牙扫描回调"的 SDK(每 200ms 回调一个设备),要求:收集停止时回调必须注销,写一个测试证明没有泄漏。

思考题 7. 为什么 CoroutineExceptionHandler 装在子协程上不生效?这种设计是想逼你形成什么样的编码习惯? 8. StateFlow 的 WhileSubscribed(5000) 为什么是 5 秒而不是 0 秒?如果用户切到后台 10 秒再回来,你会观察到什么行为? 9. 一个 suspend 函数内部既做了网络请求又做了大 JSON 解析,withContext 应该用 IO 还是 Default?分两段写又如何? 10. 为什么 runTest 在结束时要检查"是否有未完成的协程"?如果它不检查,你会错过哪些真实 bug?

📄 第 12 章 依赖注入:Hilt 12-dependency-injection-hilt.md

阶段:进阶 | 预计阅读 60 min | 代码落地模块::app、:core:network、:core:database、:feature:feed 前置:第 02 章 工程结构与 Gradle | 第 10 章 数据存储 | 第 11 章 协程与 Flow 相关章节:第 13 章 后台任务与通知 | 第 15 章 模块化与组件化 | 第 21 章 测试策略

学习目标

  • [ ] 说清依赖注入(Dependency Injection, DI)解决什么问题,以及它与"工厂""单例""服务定位器"的本质区别。
  • [ ] 掌握 Hilt 的核心注解:@HiltAndroidApp、@AndroidEntryPoint、@Inject、@Module、@Provides、@Binds、@Qualifier、@EntryPoint。
  • [ ] 理解 Hilt 作用域与 Android 生命周期的对应关系,能判断一个对象该用 @Singleton 还是 @ViewModelScoped。
  • [ ] 能在多模块工程中分层装配 DI:core 模块提供实现,feature 模块只依赖接口。
  • [ ] 会用 @BindValue / @UninstallModules / HiltAndroidRule 在测试中替换依赖,并理解 Hilt 与 Koin 的取舍。

一、核心概念

1.1 为什么需要依赖注入

先看没有 DI 时代码的典型形态:

class FeedViewModel : ViewModel() {
    // 自己 new 依赖 → 要知道 Retrofit 怎么建、OkHttp 怎么配、DAO 从哪来
    private val repository = FeedRepository(
        remote = FeedRemoteDataSource(RetrofitProvider.api),
        local = AppDatabaseProvider.get(application).articleDao(),
    )
}

问题有四:

  1. 构造知识泄漏:ViewModel 不该知道 Retrofit 的存在,却被它绑架。
  2. 无法测试:想换成 Fake,要么改源码,要么借助反射/服务定位器 hack。
  3. 生命周期错配:谁负责保证 OkHttpClient 全局唯一?靠自觉,而自觉不可靠。
  4. 改动涟漪:新增一个参数,所有 new 出来的地方全要改。

依赖注入的核心思想只有一句:对象不自己创建依赖,而是声明"我需要什么",由外部把它需要的东西送进来。

flowchart TB
    subgraph Before["无 DI:自己造依赖"]
        A1["FeedViewModel"] --> B1["new FeedRepository()"]
        B1 --> C1["new Retrofit()"]
        C1 --> D1["new OkHttpClient()"]
    end
    subgraph After["有 DI:声明需求,框架装配"]
        A2["FeedViewModel<br/>@Inject constructor(repo)"] -.->|"声明需要"| H["Hilt 依赖图"]
        H --> B2["FeedRepository"]
        H --> C2["Retrofit"]
        H --> D2["OkHttpClient"]
        H --> E2["ArticleDao"]
    end

1.2 三种"送进来"的方式

方式 写法 评价
构造函数注入 class A @Inject constructor(val b: B) ✅ 首选:依赖显式、不可变、易测试
字段注入 @Inject lateinit var b: B ⚠️ 只在框架创建的类(Activity/Fragment/Service)上用,因为无法控制其构造
方法/参数注入 @Inject fun setup(b: B) 极少用

[!IMPORTANT] 还有两种常被误当作 DI 的反模式: 服务定位器(ServiceLocator.get<Repo>())——依赖被隐藏在方法体里,编译期看不出依赖,测试要重置全局状态; 全局单例(object RepositoryProvider)——生命周期不可控、无法替换、无法销毁。 它们的共同问题是:把"依赖"从编译期可见的契约退化为运行期的隐式约定。

1.3 Hilt 是什么

Hilt 是 Google 在 Dagger 之上封装的 Android 专用 DI 框架。它做了三件事:

  1. 预定义组件(Component)与作用域:不用你自己写 @Component、@Subcomponent。
  2. 预定义注入入口:Activity、Fragment、View、Service、BroadcastReceiver、ViewModel、WorkManager 的 Worker 都能直接注入。
  3. 编译期生成代码:与 Dagger 一样是编译期校验,依赖缺失在编译时就报错,而不是运行时 null。

1.4 核心注解速查

注解 作用 用在
@HiltAndroidApp 生成应用级 DI 容器,必须有一个 Application 类
@AndroidEntryPoint 为该 Android 类开启注入 Activity / Fragment / View / Service / Receiver
@Inject constructor 声明构造函数注入 你自己写的类
@HiltViewModel 让 ViewModel 可被注入 ViewModel
@Module + @InstallIn 提供无法构造函数注入的对象 第三方库、接口实现
@Provides 模块内"怎么做出来" 模块里的方法
@Binds 把实现类映射到接口 抽象方法,比 @Provides 高效
@Qualifier 同类型多实现时区分 自定义注解
@EntryPoint 在 Hilt 不支持的类中拿到依赖 接口 + @InstallIn

1.5 作用域与生命周期:Hilt 最需要背下来的一张表

作用域的本质是"这个对象在谁的生命周期内保持同一个实例"。

作用域注解 对应组件 存活范围 典型对象
@Singleton SingletonComponent 整个 App 进程 OkHttpClient、Retrofit、Room 实例、全局 Repository
@ActivityRetainedScoped ActivityRetainedComponent 跨配置变更(旋转)存活,随 Activity 真正销毁而结束 导航状态、跨页面临时流程
@ActivityScoped ActivityComponent 单个 Activity 实例(旋转即重建) 与具体 Activity 绑定的控制器(如 PermissionRequester)
@FragmentScoped FragmentComponent 单个 Fragment 实例 Fragment 级临时状态
@ViewModelScoped ViewModelComponent 单个 ViewModel 实例 ViewModel 内部共享的状态容器、分页器
@ServiceScoped ServiceComponent 单个 Service 实例 前台服务的控制器
无注解(默认) — 每次注入都新建 无状态、轻量的对象(UseCase、Mapper)
flowchart TB
    S["SingletonComponent<br/>@Singleton"] --> AR["ActivityRetainedComponent<br/>@ActivityRetainedScoped"]
    AR --> A["ActivityComponent<br/>@ActivityScoped"]
    A --> F["FragmentComponent<br/>@FragmentScoped"]
    A --> V["ViewComponent"]
    AR --> VM["ViewModelComponent<br/>@ViewModelScoped"]
    S --> SC["ServiceComponent<br/>@ServiceScoped"]

[!WARNING] 作用域越大,对象的存活越久,内存占用也越高。能用小作用域就用小作用域: 把带 Context 的对象标成 @Singleton 是内存泄漏的经典来源(Activity Context 被长生命周期对象持有)。

1.6 多模块下的 DI 装配

Hilt 的组件是全局聚合的:每个模块里 @InstallIn(SingletonComponent::class) 的 Module,在编译期会被合并进同一个依赖图。这带来两个好处与一个纪律:

  • 好处::core:network 提供 OkHttpClient,:feature:feed 直接注入即可,不需要 feature 依赖 network 的实现细节。
  • 好处:模块间不需要互相 include,装配是自动的。
  • 纪律:feature 模块之间不应互相依赖(第 15 章);共享的东西下沉到 core 模块,由 core 的 Module 提供。
:app            ← @HiltAndroidApp,聚合所有模块
 ├─ :feature:*  ← 只依赖 core,注入 core 提供的接口
 └─ :core:*     ← @InstallIn(SingletonComponent) 提供实现

1.7 与 Koin 的对比

维度 Hilt(Dagger) Koin
时机 编译期代码生成 + 编译期校验 运行期反射/DSL 解析
错误发现 编译时(缺依赖直接编译失败) 运行时(首次注入才抛 NoBeanDefFound)
构建耗时 需要 KSP,构建稍慢 无代码生成,构建快
启动耗时 几乎为零(代码已生成) 需要扫描模块、建图,大工程有感知
学习曲线 陡(组件、作用域、Module) 平缓(DSL 直观)
与 Android 集成 官方深度集成(ViewModel、Worker、Compose) 官方支持,但深度略逊
适合 中大型、长期维护、多人协作 小中型、快速迭代、KMP 多平台

[!TIP] 选型的实际判据:团队规模与项目寿命。5 人以上、维护 3 年以上的项目,编译期校验省下的线上事故远超学习成本; 而 KMP(Kotlin Multiplatform)项目由于要跨平台共享,通常选 Koin 或手动 DI。本书统一用 Hilt。


二、示例代码

2.1 依赖与插件

// 文件路径:sample/build.gradle.kts(根工程,声明插件版本由 libs.versions.toml 收口)
plugins {
    alias(libs.plugins.hilt) apply false
    alias(libs.plugins.ksp) apply false
}
// 文件路径:sample/app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.ksp)        // Hilt 用 KSP 生成代码,不用 kapt
    alias(libs.plugins.hilt)
}

dependencies {
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)                       // 注解处理器
    implementation(libs.androidx.hilt.navigation.compose)  // hiltViewModel() 支持
    // 测试替换
    androidTestImplementation(libs.hilt.android.testing)
    kspAndroidTest(libs.hilt.compiler)
}

[!NOTE] Hilt 依赖图是跨模块聚合的,但聚合发生在 :app 模块(有 @HiltAndroidApp 的那个)。 因此 :app 必须直接或间接依赖所有带 @InstallIn 模块的工程模块,否则该模块里的 Module 不会进图—— 这是"注入时报 Unresolved reference / 找不到 Provider"的常见原因。

2.2 Application:启动 DI 容器

// 文件路径:sample/app/src/main/java/com/techfeed/app/TechFeedApplication.kt
package com.techfeed.app

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

/**
 * 整个 App 必须有且仅有一个 @HiltAndroidApp 的 Application。
 * 它会生成 App 级组件(SingletonComponent),是所有依赖图的根。
 */
@HiltAndroidApp
class TechFeedApplication : Application()
<!-- 文件路径:sample/app/src/main/AndroidManifest.xml -->
<application
    android:name=".TechFeedApplication"
    android:allowBackup="false"
    android:theme="@style/Theme.TechFeed">
    <!-- ... -->
</application>

2.3 Android 类注入:@AndroidEntryPoint

// 文件路径:sample/app/src/main/java/com/techfeed/app/MainActivity.kt
package com.techfeed.app

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.techfeed.core.designsystem.theme.TechFeedTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

/**
 * @AndroidEntryPoint 让字段注入生效。
 * ⚠️ 规则:注入了 Activity,它附着的 Fragment 也必须加 @AndroidEntryPoint。
 */
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    // 字段注入只用于"框架创建、无法控制构造"的类
    @Inject lateinit var crashReporter: CrashReporter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        crashReporter.install()
        enableEdgeToEdge()
        setContent { TechFeedTheme { TechFeedApp() } }
    }
}

2.4 构造函数注入:推荐的主路径

// 文件路径:docs/samples/ch12/GetFeedUseCase.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.model.Article
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

/**
 * 无作用域注解 = 每次注入都新建实例。
 * UseCase 无状态、创建成本极低,这正好 —— 不要给无状态类乱加 @Singleton。
 */
class GetFeedUseCase @Inject constructor(
    private val repository: FeedRepository,   // 接口,实现由 core 模块提供(见 2.7)
) {
    operator fun invoke(): Flow<List<Article>> = repository.observeArticles()
}
// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedViewModel.kt
package com.techfeed.feature.feed.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.techfeed.feature.feed.domain.GetFeedUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject

/**
 * @HiltViewModel + @Inject constructor:
 * ViewModel 由 Hilt 工厂创建,构造参数自动从依赖图里取。
 * ⚠️ 加了 @HiltViewModel 后,获取方式必须是 hiltViewModel()(Compose)
 *    或 by viewModels()(View 体系,需 @AndroidEntryPoint),不能用 ViewModelProvider 手动 new。
 */
@HiltViewModel
class FeedViewModel @Inject constructor(
    getFeed: GetFeedUseCase,
) : ViewModel() {

    val uiState: StateFlow<FeedUiState> = getFeed()
        .map { articles -> FeedUiState(articles = articles) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = FeedUiState(isLoading = true),
        )
}

[!NOTE] FeedViewModel 的构造签名在各章有演进:第 11 章为(getFeed、refreshFeed)两用例构造签名(配合该章的协程与状态管理教学); 本章示例简化为单用例以聚焦 DI;第 24 章工程实际为 FeedViewModel(repository) 单参签名(演进见各章说明)。

2.5 @Module + @Provides:给"自己造不了"的第三方类

// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/di/NetworkModule.kt
package com.techfeed.core.network.di

import com.techfeed.core.network.AppJson
import com.techfeed.core.network.TokenProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Named
import javax.inject.Singleton

/**
 * @Module:告诉 Hilt "这些对象怎么做出来"。
 * @InstallIn(SingletonComponent):装进 App 级组件,全进程可见。
 * @Provides 用于第三方类(你改不了它的构造函数);自己写的类请优先 @Inject constructor。
 */
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton          // 连接池与 TLS 会话复用都在 client 上,必须全局唯一
    fun provideOkHttpClient(
        tokenProvider: TokenProvider,      // 参数由依赖图自动填入
    ): OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .callTimeout(30, TimeUnit.SECONDS)
        .addInterceptor { chain ->
            val token = tokenProvider.accessToken()
            val request = chain.request().newBuilder()
                .apply { if (!token.isNullOrBlank()) header("Authorization", "Bearer $token") }
                .build()
            chain.proceed(request)
        }
        .build()

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient, @Named("base_url") baseUrl: String): Retrofit =
        Retrofit.Builder()
            .baseUrl(baseUrl)
            .client(client)
            .addConverterFactory(AppJson.asConverterFactory("application/json".toMediaType()))
            .build()
}

[!NOTE] 本节 NetworkModule 是为聚焦 DI 讲解的节选,只保留了 @Provides / @Singleton / @Named 相关元素; 生产级完整实现(拦截器链、缓存目录绑定、TokenAuthenticator、redactHeader 脱敏、NetworkConfig.BASE_URL)见第 09 章 §2.5。

2.6 @Qualifier:同类型多实例如何区分

// 文件路径:docs/samples/ch12/Qualifiers.kt
package com.techfeed.core.common.di

import javax.inject.Qualifier

/** 限定符:同一种类型有多个提供者时,用自定义注解区分 */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class BaseUrl

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Authenticated      // 带鉴权头的 client
// 文件路径:sample/core/common/src/main/java/com/techfeed/core/common/di/DispatcherModule.kt
package com.techfeed.core.common.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers

@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
    // 用限定符提供三个不同的 CoroutineDispatcher —— 它们的类型完全相同
    @Provides @IoDispatcher
    fun provideIo(): CoroutineDispatcher = Dispatchers.IO

    @Provides @DefaultDispatcher
    fun provideDefault(): CoroutineDispatcher = Dispatchers.Default

    @Provides @BaseUrl
    fun provideBaseUrl(): String = "https://api.techfeed.dev/"
}

注入侧:

// 文件路径:docs/samples/ch12/RefreshFeedUseCase.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.common.di.IoDispatcher
import com.techfeed.core.model.Result
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject

class RefreshFeedUseCase @Inject constructor(
    private val repository: FeedRepository,
    @IoDispatcher private val io: CoroutineDispatcher,   // 限定符指明要哪一个
) {
    suspend operator fun invoke(page: Int = 1): Result<Unit> = withContext(io) {
        repository.refresh(page)
    }
}

[!TIP] @Named("base_url") 是 Hilt 内置的限定符,但字符串没有编译期校验,写错只能在运行时报错。 自定义 @Qualifier 注解是更安全的做法:写错名字直接编译不过。

2.7 @Binds:接口绑定到实现

// 文件路径:docs/samples/ch12/RepositoryModule.kt
package com.techfeed.core.database.di

import com.techfeed.feature.feed.domain.FeedRepository
import com.techfeed.core.database.repository.OfflineFirstFeedRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
 * @Binds 把实现类型映射到接口:
 * - 必须是 abstract 方法(或 abstract class 模块)
 * - 参数类型必须能赋值给返回类型
 * - 比 @Provides 生成的代码更少、更快(无工厂方法调用)
 */
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindFeedRepository(impl: OfflineFirstFeedRepository): FeedRepository
}

[!IMPORTANT] 这里有一个关键的架构约定:接口 FeedRepository 定义在 :feature:feed(或 :core:navigation-api 这类 API 模块), 实现 OfflineFirstFeedRepository 在 :core:database。 于是 feature 只依赖接口、不依赖实现,实现可以被测试 Fake 一键替换。这就是依赖倒置(第 15 章展开)。

2.8 作用域实战:什么时候用 @ViewModelScoped

// 文件路径:docs/samples/ch12/DetailStateHolder.kt
package com.techfeed.feature.detail.ui

import dagger.hilt.android.lifecycle.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject

/**
 * @ViewModelScoped:同一个 ViewModel 实例内共享,ViewModel 销毁即释放。
 * 适合"与页面强相关、但仍需独立成类"的对象(分页器、表单草稿、滚动状态容器)。
 * ⚠️ 它内部不能持有 Activity/Fragment/View,否则必泄漏。
 */
@ViewModelScoped
class DetailScrollHolder @Inject constructor() {
    val scrollOffset = MutableStateFlow(0)
    fun onScroll(delta: Int) { scrollOffset.value += delta }
}
// 文件路径:docs/samples/ch12/LoginFlowController.kt
package com.techfeed.feature.auth

import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject

/**
 * @ActivityRetainedScoped:旋转屏幕后仍是同一个实例,但 Activity 真正销毁时释放。
 * 适合"跨配置变更保留、但不该活到 App 结束"的多步流程(如登录向导、发布流程)。
 */
@ActivityRetainedScoped
class LoginFlowController @Inject constructor() {
    private val _step = MutableStateFlow(LoginStep.Phone)
    val step: kotlinx.coroutines.flow.StateFlow<LoginStep> get() = _step
    fun next() { /* 推进流程 */ }
}

2.9 @EntryPoint:在非 Hilt 类中拿到依赖

// 文件路径:docs/samples/ch12/CrashEntryPoint.kt
package com.techfeed.core.crash

import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

/**
 * 有些类 Hilt 管不了(如 ContentProvider、自定义 View 的构造、第三方 SDK 回调)。
 * 用 @EntryPoint 开一个"后门"显式取依赖,比把它做成全局单例干净得多。
 */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CrashEntryPoint {
    fun crashReporter(): CrashReporter
}

// 使用处
fun Context.crashReporter(): CrashReporter =
    dagger.hilt.android.EntryPointAccessors.fromApplication(
        applicationContext, CrashEntryPoint::class.java,
    ).crashReporter()

2.10 多模块装配全景

flowchart TB
    subgraph App["':app'"]
        A["TechFeedApplication<br/>@HiltAndroidApp"]
    end
    subgraph Core["':core:*' 提供实现"]
        N["NetworkModule<br/>@InstallIn(Singleton)"]
        D["DatabaseModule<br/>@InstallIn(Singleton)"]
        S["DataStoreModule<br/>@InstallIn(Singleton)"]
    end
    subgraph Feature["':feature:*' 只注入接口"]
        F1["':feature:feed'<br/>FeedViewModel"]
        F2["':feature:auth'<br/>LoginViewModel"]
    end
    A --> N
    A --> D
    A --> S
    N --> F1
    D --> F1
    S --> F2
    F1 -.->|"依赖 :core:model / :core:navigation-api"| Core
    F1 -.->|"零依赖"| F2

[!WARNING] feature 模块之间不允许互相依赖。若 :feature:feed 需要跳到 :feature:detail, 应依赖 :core:navigation-api 中定义的路由接口,由 :app 装配实现(第 15 章)。 DI 层面同理:跨 feature 共享的对象必须下沉到 core。

2.11 UI 双体系:两种获取 ViewModel 的方式

// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedScreen.kt
package com.techfeed.feature.feed.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel

/** Compose:hiltViewModel() 从 Hilt 工厂取 ViewModel(需 androidx.hilt:hilt-navigation-compose) */
@Composable
fun FeedScreen(
    modifier: Modifier = androidx.compose.ui.Modifier,
    viewModel: FeedViewModel = hiltViewModel(),     // 默认参数方便 Preview 传入 Fake
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    FeedContent(uiState = uiState, modifier = modifier)
}
// 文件路径:docs/samples/ch12/FeedFragment.kt
package com.techfeed.feature.feed.ui

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.techfeed.feature.feed.databinding.FragmentFeedBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

/** View 体系:@AndroidEntryPoint + by viewModels(),两者缺一不可 */
@AndroidEntryPoint
class FeedFragment : Fragment() {

    private var _binding: FragmentFeedBinding? = null
    private val binding get() = requireNotNull(_binding)

    // 只有加了 @AndroidEntryPoint,Hilt 才会给 ViewModel 提供构造参数
    private val viewModel: FeedViewModel by viewModels()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, saved: Bundle?): View {
        _binding = FragmentFeedBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state -> render(state) }
            }
        }
    }

    private fun render(state: FeedUiState) {
        binding.tvEmpty.isVisible = state.articles.isEmpty()
    }

    override fun onDestroyView() {
        _binding = null      // ⚠️ 必须置空
        super.onDestroyView()
    }
}

2.12 测试替换:三种手段

手段一(首选):单元测试根本不用 Hilt。 因为构造函数注入,直接 new Fake 即可。

// 文件路径:sample/feature/feed/src/test/java/com/techfeed/feature/feed/ui/FeedViewModelTest.kt
package com.techfeed.feature.feed.ui

import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.techfeed.core.testing.MainDispatcherRule
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test

/** 构造函数注入的最大收益:测试里不需要任何 DI 框架 */
class FeedViewModelTest {

    // viewModelScope 使用 Dispatchers.Main,必须先用 Rule 替换成测试调度器
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun `ViewModel 直接接收 Fake`() = runTest {
        // GetFeedUseCase 是 final class,不能被匿名 object 继承——
        // 正确做法:伪造接口 FeedRepository,再真实构造 UseCase 套在外面
        val fake = com.techfeed.feature.feed.domain.GetFeedUseCase(
            repository = FakeFeedRepository(),
        )
        val viewModel = FeedViewModel(fake)

        // ⚠️ WhileSubscribed 的 StateFlow:没有收集者时上游根本不启动,
        // value 永远停在 initialValue(articles 为空)——直接断言非空必失败。
        // 必须先订阅(Turbine 的 test 即订阅),再断言数据流出。
        viewModel.uiState.test {
            assertThat(awaitItem().isLoading).isTrue()      // 第一个值是 initialValue
            assertThat(awaitItem().articles).isNotEmpty()    // 订阅后上游才发射真实数据
            cancelAndIgnoreRemainingEvents()
        }
    }
}

private class FakeFeedRepository : com.techfeed.feature.feed.domain.FeedRepository {
    override fun observeArticles() = flowOf(
        listOf(
            com.techfeed.core.model.Article(
                id = "a-1", title = "t", summary = "", coverUrl = null,
                author = com.techfeed.core.model.Author("au", "n", null), publishedAt = 0L,
            ),
        ),
    )
    override suspend fun refresh(page: Int) = com.techfeed.core.model.Result.Success(Unit)
    override suspend fun toggleFavorite(id: String) = com.techfeed.core.model.Result.Success(Unit)
}

手段二(仪器化测试):@UninstallModules + @BindValue。

// 文件路径:docs/samples/ch12/FeedFragmentTest.kt
package com.techfeed.feature.feed.ui

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.techfeed.feature.feed.domain.FeedRepository
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.inject.Inject

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
// 卸载生产模块,避免真实网络/数据库被拉起
@UninstallModules(com.techfeed.core.database.di.RepositoryModule::class)
class FeedFragmentTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)   // ⚠️ Rule 必须在 @Before 之前声明

    // @BindValue 把 Fake 塞进依赖图,替代被卸载模块提供的 FeedRepository
    @BindValue
    @JvmField
    val fakeRepository: FeedRepository = FakeFeedRepository()

    @Inject lateinit var okHttpClient: okhttp3.OkHttpClient   // 验证注入是否成功

    @Before
    fun setUp() {
        hiltRule.inject()      // ⚠️ 别漏了这行,否则 @Inject 字段为 null
    }

    @Test
    fun 列表渲染出假数据() {
        // 启动 Fragment 并断言
    }
}

手段三(可复用):@TestInstallIn。 适合多个测试共用的 Fake 模块。

// 文件路径:docs/samples/ch12/TestRepositoryModule.kt
package com.techfeed.core.testing

import com.techfeed.feature.feed.domain.FeedRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton

/** @TestInstallIn = @InstallIn + 自动替换指定的生产模块,无需在每个测试类上写 @UninstallModules */
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [com.techfeed.core.database.di.RepositoryModule::class],
)
abstract class TestRepositoryModule {
    @Binds
    @Singleton
    abstract fun bindFakeFeedRepository(impl: FakeFeedRepository): FeedRepository
}
旧写法对照:手写 Dagger Component 与服务定位器
// 旧写法:原生 Dagger 需要手写 Component / Subcomponent / Builder,样板代码极多
@Singleton
@Component(modules = [NetworkModule::class, DatabaseModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
    fun activityComponentBuilder(): ActivityComponent.Builder
}
// 每个 Activity 都要写 Subcomponent + Builder + 手动 inject()

// 更早的"服务定位器":依赖藏在运行期,编译期看不出来,测试要重置全局状态
object ServiceLocator {
    lateinit var repository: FeedRepository
}

Hilt 的价值:把 Component 层级、作用域、注入点全部预置,你只写 @InstallIn 与 @Provides。 代价是需要理解这套预置规则——这正是本章的作用。


三、常见坑 ⚠️

现象 根因 解法
编译报 @AndroidEntryPoint 的 Fragment 未生效 宿主 Activity 没加 @AndroidEntryPoint 父级组件必须先注入(Activity → Fragment → View 逐层)
Hilt Fragments must be attached to an @AndroidEntryPoint Activity 同上 给 Activity 加注解
注入报 Unresolved reference / 找不到 Provider 模块未被 :app 依赖;或忘了 @InstallIn 检查 :app 是否依赖该模块;Module 是否有 @InstallIn
Cannot create an instance of ViewModel 用了 @HiltViewModel 却用 ViewModelProvider(this) 手动取 改 hiltViewModel() / by viewModels()
@Inject lateinit var 为 null 忘了 @AndroidEntryPoint;或测试里忘了 hiltRule.inject() 补注解 / 补 inject()
编译报 [Dagger/DuplicateBindings] 同一类型多个绑定 两个 @Provides 提供了同类型 用 @Qualifier 区分,或去掉多余的
编译报 [Dagger/MissingBinding] 依赖链上某个类型没人提供 从报错类型往上补 @Provides / @Binds
@Provides 与 @Binds 混用报错 两者对模块与方法的要求不同 @Binds 只能用在 abstract 方法(模块为 abstract class/interface)
内存泄漏:Activity 被长生命周期对象持有 把 @Singleton 对象注入了 Activity Context 用 @ApplicationContext;或缩小作用域
单例对象拿到了错误的 Context 注入了 Context 却未加限定符 统一用 @ApplicationContext context: Context
object 模块里写 @Binds 报错 @Binds 需要 abstract 改 abstract class,或直接改用 @Provides
测试里 Hilt 报 "Hilt test, ... but no field annotated with @Inject" 忘了 hiltRule.inject() 补上
ActivityRetainedScoped 对象在旋转后是新实例 用了 @ActivityScoped(不跨配置变更) 换成 @ActivityRetainedScoped
Worker 注入失败 未用 @HiltWorker + 自定义 WorkerFactory 见第 13 章
KSP 报错找不到生成类 忘了 ksp(libs.hilt.compiler) 每个用了 Hilt 的模块都要加

四、调试技巧

  1. 读编译错误:Hilt 的错误信息非常有信息量,格式是 [Dagger/MissingBinding] 或 [Dagger/DuplicateBindings], 并且自带依赖链(com.techfeed.FeedRepository is requested at ...)。从最下面一行往上读最快。
  2. 看生成代码:build/generated/ksp/debug/kotlin/... 下有 *_HiltModules、*_Factory, 搞清楚"到底生成了什么",很多疑惑会立刻解开。
  3. 生成依赖图:Dagger 支持输出可视化图(需在构建中开启),能直观看到组件与绑定的关系。
  4. Logcat 过滤 Hilt:注入失败、组件未初始化等问题会在这里留痕。
  5. 确认模块是否入图:临时把某个 @Provides 的返回类型打印日志,或故意在 @Inject 处写一个不存在的依赖看报错链。
  6. 单元测试优先绕开 Hilt:如果某个类的测试必须启动 Hilt 才能写,多半说明它该改成构造函数注入。
  7. ./gradlew :app:kspDebugKotlin --info:KSP 阶段报错时用它看完整堆栈。

五、最佳实践

  1. 构造函数注入优先:自己写的类一律 @Inject constructor,@Provides 只留给第三方类。
  2. 面向接口注入:业务类依赖 FeedRepository 接口,实现在 core 模块用 @Binds 绑定——这是可测试性的前提。
  3. 作用域从大到小收敛:默认不加作用域;确实需要复用才加,且优先选更小的(@ViewModelScoped 优于 @Singleton)。
  4. 自定义 @Qualifier 而不是 @Named:编译期校验能消灭拼写错误。
  5. Context 一律 @ApplicationContext:除非你明确需要 Activity 上下文(且此时作用域不能大于 Activity)。
  6. Module 按模块归属放::core:network 的 Module 放 :core:network,不要堆在 :app。
  7. feature 之间零依赖:共享对象下沉 core,跳转走 :core:navigation-api。
  8. 测试不用 Hilt:构造函数注入让单元测试可以直接 new Fake;只有仪器化测试才用 @UninstallModules / @TestInstallIn。
  9. @Binds 优于 @Provides:生成代码更少、运行更快。
  10. 别给无状态类加 @Singleton:UseCase、Mapper 每次新建的成本远低于常驻内存的成本。
推荐 ✅ 避免 ❌
class A @Inject constructor(val b: B) private val b = B() / ServiceLocator.get()
@Binds 绑定接口与实现 @Provides 里手写 return impl as Interface
自定义 @Qualifier 注解 @Named("io") 字符串
@ApplicationContext 裸 Context 注入
默认无作用域,按需加小作用域 所有类都标 @Singleton
单元测试 new Fake 单测里启动 Hilt
Module 跟随所属模块 所有 Module 堆在 :app
hiltViewModel() / by viewModels() ViewModelProvider(this).get()

六、本章小结

  1. DI 的本质是把"创建依赖"的职责外移:构造函数注入是最干净的形式,服务定位器与全局单例是反模式。
  2. Hilt 是 Dagger 的 Android 封装:@HiltAndroidApp 建根、@AndroidEntryPoint 开注入点、@Module + @InstallIn 提供第三方对象。
  3. 作用域必须与生命周期匹配:@Singleton 跨进程、@ActivityRetainedScoped 跨旋转、@ViewModelScoped 随 ViewModel;作用域越大越危险。
  4. 多模块靠依赖倒置装配:接口在 feature(或 API 模块),实现在 core,用 @Binds 连接;feature 之间零依赖。
  5. 可测试性是 DI 的最大红利:构造函数注入让单测无需框架;仪器化测试用 @UninstallModules / @TestInstallIn 一键换 Fake。

上一章衔接:第 11 章 协程与 Flow 中的 DispatcherProvider、Repository、UseCase 在真实工程里正是由 Hilt 装配的。 下一章:有些任务不该绑在页面上——进入 第 13 章 后台任务与通知, 学习用 WorkManager 做可靠的后台任务,以及通知渠道、运行时权限与推送通道抽象。


七、动手练习

基础题

  1. 给 :core:datastore 加一个 DataStoreModule,用 @Provides @Singleton 提供 DataStore<Preferences>,并在 :feature:profile 的 ViewModel 中注入它。
  2. 定义 @Qualifier 注解 @Authenticated 与 @Anonymous,提供两个不同的 OkHttpClient(一个带鉴权拦截器、一个不带),并在两个 Api 中分别注入。
  3. 把 FeedRepository 改成接口(已定义),用 @Binds 在 :core:database 中绑定实现,验证 :feature:feed 不再依赖 :core:database 的实现类。

进阶题 4. 把一个多步"发布文章"流程的状态抽成 @ActivityRetainedScoped 的 PublishFlowController,验证旋转屏幕后流程步骤不丢失,而 Activity 真正销毁后实例被释放。 5. 写一个 @TestInstallIn 的假模块,把 FeedRepository 替换为内存实现,并跑通一个 Fragment 的仪器化测试(不触碰网络与数据库)。 6. 用 @EntryPoint 给一个 ContentProvider(Hilt 无法直接注入的类)提供 CrashReporter,说明为什么这里不能用字段注入。

思考题 7. 为什么 Hilt 要求"注入了 Fragment 就必须注入宿主 Activity"?这与 Fragment 的生命周期有什么关系? 8. 把一个对象错误地标成 @Singleton 会有哪些后果?除了内存泄漏,还有哪些更隐蔽的问题(提示:状态污染、测试互相干扰)? 9. 如果团队决定从 Hilt 迁到 Koin(或反之),迁移成本主要在哪?哪些代码是不需要改的(提示:构造函数注入的部分)? 10. 为什么说"单元测试里需要启动 Hilt"往往是一个设计信号?它提示了类存在什么问题?

📄 第 13 章 后台任务与通知 13-background-work-notifications.md

阶段:进阶 | 预计阅读 60 min | 代码落地模块::feature:push、:core:common、:core:database 前置:第 03 章 应用组件与生命周期 | 第 08 章 Intent、IPC 与权限 | 第 12 章 依赖注入:Hilt 相关章节:第 10 章 数据存储 | 第 19 章 安全加固 | 第 26 章 推送、监控与收尾

学习目标

  • [ ] 理解 Android 后台执行限制的演进(Doze、应用待机、后台服务限制),知道"为什么以前的写法现在不管用"。
  • [ ] 掌握 WorkManager 的四类任务(一次性、周期、加急、链式)与约束配置,能替代 Service / JobScheduler 实现可靠后台。
  • [ ] 会观察任务状态、做重试退避与唯一任务去重,并能为 Worker 写单元测试。
  • [ ] 掌握通知渠道(Android 8+)、通知创建、PendingIntent 可变性标志、以及 Android 13+ 的 POST_NOTIFICATIONS 运行时权限。
  • [ ] 能设计推送通道抽象:国内 FCM 不可用的现实下,用接口隔离厂商通道,并具备本地通知降级能力。

一、核心概念

1.1 后台执行限制的演进

Android 从 6.0 起系统性地收紧后台权限,目的只有一个:续航。理解这条时间线,就不会再疑惑"为什么我的后台任务不执行了"。

版本 机制 影响
6.0(API 23) Doze(打盹) 灭屏静止一段时间后,网络访问、SyncAdapter、JobScheduler 被推迟到维护窗口
6.0 App Standby(应用待机) 长期未使用的 App,其后台任务被降频
8.0(API 26) 后台服务限制 startService() 在后台抛 IllegalStateException;后台位置、广播进一步受限
9.0 App Standby Bucket 按使用频率分桶(活跃/常用/极少),配额递减
12(API 31) 前台服务启动限制 后台启动前台服务抛 ForegroundServiceStartNotAllowedException;加急任务(Expedited Work)成为替代
13(API 33) 通知运行时权限 POST_NOTIFICATIONS 需动态申请,否则通知静默丢弃
14(API 34) 前台服务类型强制 Manifest 必须声明 foregroundServiceType,且类型与用途匹配

[!IMPORTANT] 关键认知:"我的代码想什么时候跑"和"系统允许你什么时候跑"是两件事。 现代 Android 后台任务的正确姿势是——向系统声明"我要做什么、什么条件下做、是否必须立刻做",由系统统一调度。 WorkManager 就是这个声明的载体。

1.2 为什么不用 Service / JobScheduler

方案 问题 何时可用
普通 Service 后台启动被禁;进程易被杀;无约束、无重试、无持久化 仅前台服务(音乐、导航、通话、长连接)
IntentService 已弃用;串行队列 + 后台限制,等同不可用 无
JobScheduler API 21+ 才可用;API 差异大;无链式任务;无持久化保证(进程被杀后任务丢失) 一般不用,WorkManager 已封装它
AlarmManager 精确闹钟需 SCHEDULE_EXACT_ALARM 权限且受省电策略影响;不保证持久化 真正的定点提醒(日历、闹钟)
WorkManager — 默认选择:延迟可接受的后台任务(同步、上传、清理、预取)

WorkManager 的三个核心保证:

  1. 持久化:任务写入本地数据库,进程被杀、设备重启后仍会执行。
  2. 约束感知:只在满足条件(有网、充电、空闲)时才跑。
  3. 版本自适应:底层自动选择 JobScheduler(API 23+)、GcmNetworkManager(旧版)或自研调度器,你不用关心。

[!WARNING] WorkManager 不适合以下场景:需要精确到秒的闹钟(用 AlarmManager)、 需要持续运行且与用户交互的任务(用前台服务)、下载大文件(用 DownloadManager)。

1.3 WorkManager 的组成

flowchart TB
    A["WorkRequest<br/>任务描述:做什么 + 什么条件下做"] --> B["WorkManager.enqueue()"]
    B --> C[("内部 Room 数据库<br/>任务持久化")]
    C --> D["系统调度器<br/>JobScheduler / GCMNM / AlarmManager"]
    D --> E{"约束满足?"}
    E -->|"否"| D
    E -->|"是"| F["Worker.doWork()<br/>你的代码"]
    F --> G["Result.success / failure / retry"]
    G --> H["WorkInfo 状态更新"]
    H --> I["UI 通过 Flow 观察"]
概念 作用
Worker / CoroutineWorker 任务执行体;运行在后台线程,可以放心做同步阻塞操作
WorkRequest 任务描述:约束、输入、退避策略、是否加急
Constraints 触发条件:网络类型、是否充电、是否空闲、电量是否充足、存储空间
WorkManager 提交与查询入口
WorkInfo 任务状态与进度,可通过 Flow 观察

任务状态机:

stateDiagram-v2
    [*] --> ENQUEUED : enqueue()
    ENQUEUED --> BLOCKED : 约束未满足
    BLOCKED --> ENQUEUED : 约束满足
    ENQUEUED --> RUNNING
    RUNNING --> SUCCEEDED : Result.success()
    RUNNING --> FAILED : Result.failure()
    RUNNING --> ENQUEUED : Result.retry()
    ENQUEUED --> CANCELLED : cancelWorkById()
    RUNNING --> CANCELLED
    SUCCEEDED --> [*]
    FAILED --> [*]
    CANCELLED --> [*]

1.4 四类任务

类型 构造 特点 场景
一次性 OneTimeWorkRequestBuilder 执行一次,支持退避重试 同步数据、上传日志
周期 PeriodicWorkRequestBuilder 最小间隔 15 分钟,不保证精确时刻 定期拉取、缓存清理
加急 setExpedited(OutOfQuotaPolicy) 尽快执行,受配额限制(Android 12+) 用户触发的即时任务(发评论、保存草稿)
链式 beginWith().then() 顺序/并行编排,前驱输出作为后继输入 压缩 → 上传 → 通知

1.5 通知体系

概念 说明
通知渠道(Notification Channel) Android 8+ 引入,按用途分组;用户可在系统设置里关闭某一渠道;创建后重要性不可改
重要性(Importance) IMPORTANCE_HIGH(响铃+悬浮)/ DEFAULT / LOW / MIN(静默折叠)
PendingIntent 通知点击后由系统代为发起的 Intent;Android 12+ 必须显式声明 FLAG_IMMUTABLE 或 FLAG_MUTABLE
POST_NOTIFICATIONS Android 13+ 危险权限,需动态申请;未授权时通知被静默丢弃(不报错、不崩溃)

1.6 推送通道抽象:国内现实

国内 Android 生态的特点是:Google Play 服务不可用,FCM(Firebase Cloud Messaging)在绝大多数设备上无法工作; 同时各手机厂商(华为、小米、OPPO、vivo、荣耀)都提供自己的系统级推送通道,且互相不兼容。

应对策略是通道抽象:

flowchart TB
    A["业务层<br/>PushMessage 领域模型"] --> B["PushDispatcher<br/>选路 + 去重 + 降级"]
    B --> C["VendorPushChannel<br/>厂商系统通道(在线时)"]
    B --> D["LocalPushChannel<br/>本地通知兜底"]
    B --> E["PollingWorkChannel<br/>WorkManager 轮询(最弱兜底)"]
    C --> F["NotificationHelper<br/>统一发通知"]
    D --> F
    E --> F

关键设计原则:

  • 业务层只认识 PushMessage(标题、内容、deeplink、消息 ID),不认识任何厂商 SDK 的类型。
  • 每个通道实现 PushChannel 接口,声明 available(该设备是否可用)。
  • 选路逻辑(优先厂商、失败降级本地)集中在 PushDispatcher,便于替换与测试。
  • 本章不绑定任何具体 SDK:厂商通道只给接口与"如何接入"的说明,避免示例代码因 SDK 版本失效。

二、示例代码

2.1 依赖

// 文件路径:sample/feature/push/build.gradle.kts
plugins {
    alias(libs.plugins.techfeed.android.library)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
}

dependencies {
    implementation(projects.core.model)
    implementation(projects.core.common)
    implementation(libs.androidx.work.runtime.ktx)   // WorkManager + CoroutineWorker
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    // Hilt 与 WorkManager 的桥接
    implementation(libs.androidx.hilt.work)
    ksp(libs.androidx.hilt.compiler)
    testImplementation(libs.bundles.testing.unit)
    androidTestImplementation(libs.androidx.work.testing)   // 测试 Worker
}

2.2 CoroutineWorker + Hilt 注入

// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/worker/SyncFeedWorker.kt
package com.techfeed.feature.push.worker

import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.techfeed.core.database.dao.ArticleDao
import com.techfeed.core.database.entity.toEntity
import com.techfeed.core.model.Result
import com.techfeed.core.network.datasource.FeedRemoteDataSource
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject

/**
 * @HiltWorker + @AssistedInject:让 Worker 也能享受依赖注入。
 * ⚠️ context 与 params 必须用 @Assisted 标记 —— 它们是系统传入的,不在依赖图里。
 *
 * Worker 只依赖 core 层(FeedRemoteDataSource + ArticleDao),不依赖 feature:feed ——
 * "feature 之间零依赖"是本工程的模块化红线(第 15 章)。刷新写库逻辑与
 * FeedRepositoryImpl.refresh 同构,教学上取舍:宁可小段重复,不破模块边界;
 * 若逻辑变复杂,正确做法是把"刷新用例"下沉到 core,而不是让 feature 互相依赖。
 */
@HiltWorker
class SyncFeedWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val remote: FeedRemoteDataSource,
    private val dao: ArticleDao,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = when (val r = remote.getFeed(page = 1, query = null)) {
        is Result.Success -> {
            // 同步与前台刷新共用同一条"保留收藏"的写库路径
            dao.upsertKeepingFavorite(r.data.map { it.toEntity() })
            Result.success()
        }
        // failure:受 runAttemptCount 限制地 retry,超过上限放弃(业务明确的失败)
        is Result.Failure -> if (runAttemptCount < MAX_RETRY) Result.retry() else Result.failure()
        is Result.Loading -> Result.retry()
    }

    private companion object {
        const val MAX_RETRY = 3
    }
}

[!TIP] 若任务通过 setExpedited(...) 以加急方式提交,在 Android 12 以下的系统上会退化为前台服务执行, 此时 Worker 必须实现 getForegroundInfo() 返回带通知的 ForegroundInfo(通知构建见 §2.5 的 NotificationHelper)。

// 文件路径:docs/samples/ch13/WorkerModule.kt
package com.techfeed.app.di

import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
 * Hilt 接管 WorkerFactory 必须做两件事:
 * 1. @Binds 提供自定义的 WorkerFactory
 * 2. 在 AndroidManifest 中移除 WorkManager 的默认初始化器(见下方 XML)
 */
@Module
@InstallIn(SingletonComponent::class)
abstract class WorkerModule {
    @Binds
    @Singleton
    abstract fun bindWorkerFactory(factory: HiltWorkerFactory): androidx.work.WorkerFactory
}

/** 可选:自定义 Configuration(如设置日志级别、最小周期) */
@Module
@InstallIn(SingletonComponent::class)
object WorkManagerConfigModule {
    @Provides
    @Singleton
    fun provideWorkManagerConfiguration(factory: HiltWorkerFactory): Configuration =
        Configuration.Builder()
            .setWorkerFactory(factory)
            .setMinimumLoggingLevel(android.util.Log.INFO)
            .build()
}
<!-- 文件路径:sample/app/src/main/AndroidManifest.xml -->
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <!-- 移除 WorkManager 的默认初始化,改由 Hilt 在需要时初始化 -->
    <meta-data
        android:name="androidx.work.WorkManagerInitializer"
        android:value="androidx.startup"
        tools:node="remove" />
</provider>

<!-- Application 需实现 Configuration.Provider -->

2.3 提交任务:约束、输入、退避

// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/SyncScheduler.kt
package com.techfeed.feature.push

import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.techfeed.feature.push.worker.CacheCleanupWorker
import com.techfeed.feature.push.worker.SyncFeedWorker
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class SyncScheduler @Inject constructor(
    private val workManager: WorkManager,
) {
    /** 一次性同步:要求联网且电量充足,失败按线性退避重试 */
    fun syncOnce(page: Int = 1, expedited: Boolean = false) {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)      // 有网即可;UNMETERED 表示仅 WiFi
            .setRequiresBatteryNotLow(true)                     // 低电量时不跑
            .setRequiresCharging(false)
            .setRequiresDeviceIdle(false)                       // true 表示只在 Doze 维护窗口跑
            .setRequiresStorageNotLow(true)
            .build()

        val request = OneTimeWorkRequestBuilder<SyncFeedWorker>()
            .setConstraints(constraints)
            // 初始延迟:避免用户刚打开 App 就抢占资源
            .setInitialDelay(30, TimeUnit.SECONDS)
            // 退避:失败后 10s、20s、40s… 线性增长(EXPONENTIAL 也可)
            .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
            .setInputData(workDataOf("page" to page))
            .apply {
                // 加急:用户主动触发时用,受系统配额限制
                if (expedited) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
            }
            .addTag(TAG_SYNC)
            .build()

        // 唯一任务:同名任务只保留一个(APPEND 会排队,REPLACE 会取消旧的)
        workManager.enqueueUniqueWork(UNIQUE_SYNC, ExistingWorkPolicy.KEEP, request)
    }

    /** 周期清理:最小间隔 15 分钟,系统会对齐唤醒以省电 */
    fun scheduleCleanup() {
        val request = PeriodicWorkRequestBuilder<CacheCleanupWorker>(12, TimeUnit.HOURS)
            .setConstraints(
                Constraints.Builder()
                    .setRequiresCharging(true)          // 清理放在充电时做,对用户无感
                    .setRequiresDeviceIdle(true)
                    .build(),
            )
            .build()
        // KEEP:已存在就不重复提交,避免每次启动 App 都重置周期
        workManager.enqueueUniquePeriodicWork(UNIQUE_CLEANUP, ExistingWorkPolicy.KEEP, request)
    }

    /** 链式任务:先同步,再清理,最后上报 */
    fun syncThenCleanup() {
        val sync = OneTimeWorkRequestBuilder<SyncFeedWorker>().build()
        val cleanup = OneTimeWorkRequestBuilder<CacheCleanupWorker>().build()
        val report = OneTimeWorkRequestBuilder<ReportWorker>().build()

        workManager
            .beginUniqueWork(UNIQUE_CHAIN, ExistingWorkPolicy.REPLACE, sync)
            .then(listOf(cleanup, report))    // 两个后继并行执行
            .enqueue()
    }

    fun cancelAllSync() = workManager.cancelAllWorkByTag(TAG_SYNC)

    private companion object {
        const val TAG_SYNC = "sync"
        const val UNIQUE_SYNC = "unique_sync"
        const val UNIQUE_CLEANUP = "unique_cleanup"
        const val UNIQUE_CHAIN = "unique_chain"
    }
}

2.4 观察任务状态

// 文件路径:docs/samples/ch13/SyncStatusViewModel.kt
package com.techfeed.feature.profile.ui

import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

@HiltViewModel
class SyncStatusViewModel @Inject constructor(
    workManager: WorkManager,
) : ViewModel() {

    /** 观察所有带 sync tag 的任务:数据库驱动,进程重启后依然能查到 */
    val syncStatus: Flow<SyncStatus> = workManager
        .getWorkInfosFlow(WorkQuery.fromTags("sync"))
        .map { infos ->
            when {
                infos.any { it.state == WorkInfo.State.RUNNING } -> SyncStatus.Running
                infos.any { it.state == WorkInfo.State.ENQUEUED } -> SyncStatus.Pending
                infos.any { it.state == WorkInfo.State.FAILED } -> SyncStatus.Failed
                else -> SyncStatus.Idle
            }
        }
}

enum class SyncStatus { Idle, Pending, Running, Failed }

2.5 通知:渠道、PendingIntent 与权限

// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/NotificationHelper.kt
package com.techfeed.feature.push

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.net.toUri

/**
 * 通知的统一出口。三条铁律:
 * 1. Android 8+ 必须先建渠道,否则通知不显示(Logcat 会提示 "No channel found")
 * 2. PendingIntent 必须显式指定 FLAG_IMMUTABLE / FLAG_MUTABLE(Android 12+ 强制)
 * 3. 发送前检查 areNotificationsEnabled(),Android 13+ 未授权时通知会被静默丢弃
 */
class NotificationHelper(private val context: Context) {

    private val manager = NotificationManagerCompat.from(context)

    fun createChannels() {
        // 渠道只在首次创建时生效;之后修改 importance 不会生效,只能改名字/描述
        create(CHANNEL_ARTICLE, "文章推荐", NotificationManager.IMPORTANCE_DEFAULT)
        create(CHANNEL_SYNC, "数据同步", NotificationManager.IMPORTANCE_LOW)   // 低优先级:静默
        create(CHANNEL_IM, "互动消息", NotificationManager.IMPORTANCE_HIGH)     // 高优先级:响铃+悬浮
    }

    private fun create(id: String, name: String, importance: Int) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
        val channel = NotificationChannel(id, name, importance).apply {
            description = "$name 类通知"
            enableVibration(importance >= NotificationManager.IMPORTANCE_DEFAULT)
        }
        context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
    }

    fun showArticlePush(message: PushMessage) {
        if (!manager.areNotificationsEnabled()) return       // 未授权:静默返回,不要抛异常

        val notification = NotificationCompat.Builder(context, CHANNEL_ARTICLE)
            .setSmallIcon(com.techfeed.core.designsystem.R.drawable.ic_notification)
            .setContentTitle(message.title)
            .setContentText(message.content)
            .setContentIntent(buildDeepLinkIntent(message.deeplink))
            .setAutoCancel(true)                              // 点击后自动移除
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setOnlyAlertOnce(true)                           // 同 id 多次更新只提醒一次
            .build()

        // ⚠️ Android 13+ 未授权会抛 SecurityException,这里做保护性 catch
        runCatching { manager.notify(message.id.hashCode(), notification) }
    }

    fun buildSyncNotification() = NotificationCompat.Builder(context, CHANNEL_SYNC)
        .setSmallIcon(com.techfeed.core.designsystem.R.drawable.ic_notification)
        .setContentTitle("正在同步数据")
        .setOngoing(true)
        .setContentIntent(buildDeepLinkIntent("techfeed://feed"))
        .build()

    /**
     * 深链 PendingIntent:
     * FLAG_IMMUTABLE —— Android 12+ 必须显式声明;这里不需要被系统修改 Intent,故用 IMMUTABLE
     * FLAG_UPDATE_CURRENT —— 复用已有 PendingIntent 并更新 extras,避免通知堆积
     * TaskStackBuilder —— 保证点击后回退栈正确(回到主页而不是直接退出)
     */
    private fun buildDeepLinkIntent(deeplink: String) = TaskStackBuilder.create(context).run {
        addNextIntentWithParentStack(
            Intent(Intent.ACTION_VIEW, deeplink.toUri()).apply {
                setPackage(context.packageName)   // 只允许本应用响应,防止劫持
            },
        )
        getPendingIntent(
            0,
            android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE,
        )
    }

    companion object {
        const val CHANNEL_ARTICLE = "channel_article"
        const val CHANNEL_SYNC = "channel_sync"
        const val CHANNEL_IM = "channel_im"
    }
}

/** 推送消息的领域模型:业务层只认识它,不认识任何厂商 SDK 的类型 */
data class PushMessage(
    val id: String,
    val title: String,
    val content: String,
    val deeplink: String,
    val channel: String = NotificationHelper.CHANNEL_ARTICLE,
    val receivedAt: Long = System.currentTimeMillis(),
)

2.6 权限:POST_NOTIFICATIONS 的双体系实现

// 文件路径:docs/samples/ch13/PushPermissionCard.kt
package com.techfeed.feature.push.ui

import android.Manifest
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext

/**
 * Compose 版权限申请。
 * Android 13(API 33)以下无需申请,直接放行。
 */
@Composable
fun PushPermissionCard(onGranted: () -> Unit, onDenied: () -> Unit) {
    val context = LocalContext.current
    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission(),
    ) { granted -> if (granted) onGranted() else onDenied() }

    LaunchedEffect(Unit) {
        // 已授权或系统版本低于 33 时直接视为可用
        val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
            androidx.core.content.ContextCompat.checkSelfPermission(
                context, Manifest.permission.POST_NOTIFICATIONS,
            ) == android.content.pm.PackageManager.PERMISSION_GRANTED
        if (granted) onGranted()
    }

    Column {
        Text("开启通知,第一时间收到你关注的技术更新")
        Button(onClick = {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        }) { Text("开启通知") }
    }
}
// 文件路径:docs/samples/ch13/PushPermissionActivity.kt
package com.techfeed.feature.push.ui

import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.techfeed.feature.push.databinding.ActivityPushPermissionBinding
import dagger.hilt.android.AndroidEntryPoint

/** View 版权限申请:与 Compose 版的差异只在"如何注册 launcher"与"如何刷新 UI" */
@AndroidEntryPoint
class PushPermissionActivity : AppCompatActivity() {

    private lateinit var binding: ActivityPushPermissionBinding

    // ⚠️ 必须在 Activity 创建阶段注册,不能在点击事件里注册
    private val requestPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission(),
    ) { granted ->
        if (granted) finishGranted() else showRationale()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        binding = ActivityPushPermissionBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.btnEnable.setOnClickListener {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            } else {
                finishGranted()     // Android 13 以下默认有通知权限
            }
        }
    }

    private fun finishGranted() { setResult(RESULT_OK); finish() }

    /** 被拒绝时给出解释,而不是反复弹系统弹窗(会招致用户反感并被系统限制) */
    private fun showRationale() {
        binding.tvHint.text = "未开启通知权限,可在系统设置中开启"
    }
}

2.7 推送通道抽象

// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/PushChannel.kt
package com.techfeed.feature.push

/**
 * 推送通道抽象:业务层只依赖这个接口。
 * 国内 FCM 不可用,因此需要同时具备厂商通道与本地降级通道;
 * 抽象的目的正是让"接哪家、接几家"成为配置问题,而不是代码改造问题。
 */
interface PushChannel {
    /** 通道标识,用于埋点与排障 */
    val id: String
    /** 通道展示名 */
    val displayName: String
    /** 当前设备该通道是否可用(如非小米设备小米通道不可用) */
    val available: Boolean
    /** 优先级,数值越大越优先 */
    val priority: Int

    /** 获取该通道的设备令牌;失败返回 null */
    suspend fun getToken(): String?

    /** 收到新令牌时回调(令牌可能轮换) */
    suspend fun onTokenRefresh(token: String)

    /** 订阅/取消主题 */
    suspend fun subscribe(topic: String)
    suspend fun unsubscribe(topic: String)

    /** 把消息送达本设备:厂商通道经厂商长连接下发,本地通道直接发通知 */
    suspend fun send(message: PushMessage)
}
// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/LocalPushChannel.kt
package com.techfeed.feature.push

import javax.inject.Inject
import javax.inject.Singleton

/**
 * 本地通道:不依赖任何 SDK,用 WorkManager 周期轮询 + 本地通知实现"最弱兜底"。
 * 它的价值在于:任何时候 App 都能发通知 —— 产品功能不会因为没接厂商 SDK 而不可用。
 */
@Singleton
class LocalPushChannel @Inject constructor(
    private val notificationHelper: NotificationHelper,
    private val scheduler: SyncScheduler,
) : PushChannel {
    override val id: String = "local"
    override val displayName: String = "本地通知"
    override val available: Boolean = true      // 永远可用
    override val priority: Int = 0              // 优先级最低

    override suspend fun getToken(): String? = null          // 本地通道没有设备令牌
    override suspend fun onTokenRefresh(token: String) = Unit
    override suspend fun subscribe(topic: String) = Unit
    override suspend fun unsubscribe(topic: String) = Unit

    /** 直接发本地通知:也可用于"定时提醒""下载完成"等纯本地场景 */
    fun show(message: PushMessage) = notificationHelper.showArticlePush(message)

    /** 本地通道的"送达"就是直接发通知 */
    override suspend fun send(message: PushMessage) = show(message)
}
// 文件路径:docs/samples/ch13/VendorPushChannel.kt
package com.techfeed.feature.push

import android.content.Context
import javax.inject.Inject
import javax.inject.Singleton

/**
 * 厂商通道占位实现:本章**不绑定任何具体 SDK**。
 * 接入方式(以任意厂商为例):
 *   1. 引入该厂商的 push SDK 依赖(版本写入 libs.versions.toml)
 *   2. 在 getToken() 中调用 SDK 的 getToken / getAAID 等 API
 *   3. 在 SDK 的 Token 回调里调用 onTokenRefresh()
 *   4. 在 available 中判断是否为该厂商设备(如读取 Build.BRAND / 系统属性)
 * ⚠️ 接入要点:
 *   - 厂商 SDK 通常要求在主线程或特定时机初始化,放在 Application 的异步初始化里(第 17 章启动优化)
 *   - 透传消息(非通知栏消息)需要自己创建通知,走 NotificationHelper 统一出口
 *   - 令牌与账号的绑定关系要上报服务端,并在登出时解绑(第 25 章)
 */
@Singleton
class VendorPushChannel @Inject constructor(
    private val context: Context,
) : PushChannel {
    override val id: String = "vendor"
    override val displayName: String = "厂商系统通道"
    override val available: Boolean by lazy { detectVendorDevice() }
    override val priority: Int = 100

    override suspend fun getToken(): String? = runCatching {
        // TODO: return VendorPushSdk.getToken()
        null
    }.getOrNull()

    override suspend fun onTokenRefresh(token: String) {
        // TODO: 上报服务端,绑定当前账号
    }

    override suspend fun subscribe(topic: String) {
        // TODO: VendorPushSdk.subscribe(topic)
    }

    override suspend fun unsubscribe(topic: String) {
        // TODO: VendorPushSdk.unsubscribe(topic)
    }

    override suspend fun send(message: PushMessage) {
        // TODO: VendorPushSdk.sendMessage(message) —— 消息经厂商长连接下发,由系统通知栏展示
    }

    private fun detectVendorDevice(): Boolean {
        val manufacturer = android.os.Build.MANUFACTURER.lowercase()
        return VENDOR_KEYWORDS.any { manufacturer.contains(it) }
    }

    private companion object {
        val VENDOR_KEYWORDS = listOf("huawei", "honor", "xiaomi", "oppo", "vivo", "meizu")
    }
}
// 文件路径:docs/samples/ch13/PushDispatcher.kt
package com.techfeed.feature.push

import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
import javax.inject.Singleton

/**
 * 推送选路与分发:
 * - 选路:按 priority 从可用通道里挑一个
 * - 去重:同一 messageId 只处理一次(厂商通道与轮询通道可能重复触达)
 * - 降级:所有远程通道不可用时,回落到本地通知
 */
@Singleton
class PushDispatcher @Inject constructor(
    channels: Set<@JvmSuppressWildcards PushChannel>,
    private val local: LocalPushChannel,
    private val seenMessages: SeenMessageStore,
) {
    private val ordered = channels.filter { it.available }.sortedByDescending { it.priority }

    suspend fun dispatch(message: PushMessage) {
        // 去重:推送到达重复是常态(多通道 + 网络重试)
        if (!seenMessages.markIfNew(message.id)) return

        val channel = ordered.firstOrNull()
        if (channel == null || channel === local) {
            local.show(message)          // 无远程通道:本地直接展示
        } else {
            // 远程通道负责把消息经厂商长连接送达并展示,本地不再重复弹一条通知
            channel.send(message)
        }
    }

    suspend fun reportToken(): String? =
        ordered.firstNotNullOfOrNull { runCatching { it.getToken() }.getOrNull() }
}

/** 已处理消息的记录(可用 Room 或 DataStore 实现,见第 10 章) */
interface SeenMessageStore {
    suspend fun markIfNew(messageId: String): Boolean
}
// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/di/PushModule.kt
package com.techfeed.feature.push.di

import com.techfeed.feature.push.LocalPushChannel
import com.techfeed.feature.push.NotificationHelper
import com.techfeed.feature.push.PushChannel
import com.techfeed.feature.push.VendorPushChannel
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class PushModule {

    /**
     * @IntoSet:把多个实现收集成 Set<PushChannel>。
     * 这是"插件式通道"的关键 —— 新增一个厂商通道只需加一个 @Binds,无需改 PushDispatcher。
     */
    @Binds
    @IntoSet
    @Singleton
    abstract fun bindLocalChannel(impl: LocalPushChannel): PushChannel

    @Binds
    @IntoSet
    @Singleton
    abstract fun bindVendorChannel(impl: VendorPushChannel): PushChannel

    companion object {
        @Provides
        @Singleton
        fun provideNotificationHelper(@ApplicationContext context: android.content.Context) =
            NotificationHelper(context).also { it.createChannels() }
    }
}

2.8 测试 Worker

// 文件路径:docs/samples/ch13/SyncFeedWorkerTest.kt
package com.techfeed.feature.push.worker

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import com.google.common.truth.Truth.assertThat
import com.techfeed.core.database.dao.ArticleDao
import com.techfeed.core.database.entity.ArticleEntity
import com.techfeed.core.model.AppError
import com.techfeed.core.model.Article
import com.techfeed.core.model.Author
import com.techfeed.core.model.Result
import com.techfeed.core.network.datasource.FeedRemoteDataSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import java.io.IOException

class SyncFeedWorkerTest {

    private lateinit var context: Context
    private lateinit var remote: FakeFeedRemoteDataSource
    private lateinit var dao: FakeArticleDao

    @Before
    fun setUp() {
        context = ApplicationProvider.getApplicationContext()
        remote = FakeFeedRemoteDataSource()
        dao = FakeArticleDao()
    }

    /**
     * Worker 的依赖(FeedRemoteDataSource / ArticleDao)都是接口,
     * Fake 直接实现接口即可;OfflineFirstFeedRepository 那样的 final 类不能被继承伪造。
     * 用 TestListenableWorkerBuilder + 匿名 WorkerFactory 手动组装 Worker(测试期不走 Hilt)。
     */
    private fun buildWorker(): SyncFeedWorker =
        TestListenableWorkerBuilder<SyncFeedWorker>(context)
            .setWorkerFactory(object : WorkerFactory() {
                override fun createWorker(
                    appContext: Context,
                    workerClassName: String,
                    workerParameters: WorkerParameters,
                ): ListenableWorker = SyncFeedWorker(appContext, workerParameters, remote, dao)
            })
            .build()

    @Test
    fun `同步成功 返回 success 且数据落库`() = runBlocking {
        remote.feedResult = Result.Success(listOf(createArticle("a-1")))

        val result = buildWorker().doWork()

        assertThat(result).isEqualTo(ListenableWorker.Result.success())
        assertThat(dao.upserted.map { it.id }).containsExactly("a-1")
    }

    @Test
    fun `同步失败 返回 retry(由退避策略重排)`() = runBlocking {
        remote.feedResult = Result.Failure(AppError.Network(IOException("no net")))

        assertThat(buildWorker().doWork()).isEqualTo(ListenableWorker.Result.retry())
    }

    private fun createArticle(id: String) = Article(
        id = id, title = "t-$id", summary = "", coverUrl = null,
        author = Author("au", "作者", null), publishedAt = 0L,
    )
}

/** Fake 只实现接口 FeedRemoteDataSource —— 接口才是可替换的边界 */
private class FakeFeedRemoteDataSource : FeedRemoteDataSource {
    var feedResult: Result<List<Article>> = Result.Success(emptyList())

    override suspend fun getFeed(page: Int, query: String?): Result<List<Article>> = feedResult
    override suspend fun getArticle(id: String): Result<Article> = error("测试未用到")
    override suspend fun toggleFavorite(id: String): Result<Article> = error("测试未用到")
}

/** Fake 只实现接口 ArticleDao;upsertKeepingFavorite 有默认实现(事务内查收藏位再批量 upsert),自动复用 */
private class FakeArticleDao : ArticleDao {
    val upserted = mutableListOf<ArticleEntity>()

    override fun observeFeed(query: String?): Flow<List<ArticleEntity>> = flowOf(emptyList())
    override fun observeFavorites(): Flow<List<ArticleEntity>> = flowOf(emptyList())
    override fun observeArticle(id: String): Flow<ArticleEntity?> = flowOf(null)
    override suspend fun getArticle(id: String): ArticleEntity? = null
    override suspend fun upsertAll(articles: List<ArticleEntity>) { upserted += articles }
    override suspend fun insert(article: ArticleEntity) { upserted += article }
    override suspend fun updateFavorite(id: String, favorite: Boolean) = Unit
    override suspend fun existingIds(ids: List<String>): List<String> = emptyList()
    override suspend fun favoriteIdsAmong(ids: List<String>): List<String> = emptyList()
    override suspend fun clearExpiredCache(expireAt: Long) = Unit
}

[!TIP] 上面用 TestListenableWorkerBuilder + 匿名 WorkerFactory 直接组装 Worker,无需启动 Hilt 或真实调度。 更完整的做法是把 Worker 的业务逻辑抽成独立的 UseCase,Worker 只做薄薄一层适配—— 这样 Worker 本身几乎不需要测试,逻辑测试回归到普通单元测试。这是更好的设计。

旧写法对照:IntentService / 裸 Service / JobScheduler
// 旧写法:IntentService —— 已在 API 30 弃用,后台启动受限,进程被杀后任务丢失
public class SyncService extends IntentService {
    protected void onHandleIntent(Intent intent) { doSync(); }   // 后台启动会抛 IllegalStateException
}
// 旧写法:JobScheduler —— 需自行处理 API 兼容、无持久化、无链式、无加急
JobInfo job = new JobInfo.Builder(JOB_ID, new ComponentName(this, SyncJobService.class))
    .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
    .setPeriodic(15 * 60 * 1000)
    .build();
jobScheduler.schedule(job);

WorkManager 的改进:任务写入本地数据库(重启可恢复)、自动选择底层调度器、提供加急任务与链式编排、 提供 WorkInfo 的可观察状态与统一测试工具。你写的 doWork() 在 API 14+ 上行为一致。


三、常见坑 ⚠️

现象 根因 解法
通知完全不显示(无报错) Android 8+ 未创建渠道;或 Android 13+ 未获 POST_NOTIFICATIONS 启动时创建渠道;动态申请权限后发送
Targeting S+ requires FLAG_IMMUTABLE or FLAG_MUTABLE PendingIntent 未指定可变性 显式加 FLAG_IMMUTABLE(绝大多数场景)
后台启动 Service 崩溃 IllegalStateException Android 8+ 后台服务限制 改 WorkManager;确需前台服务时用 startForegroundService 并 5 秒内 startForeground
周期任务实际间隔远大于设定值 系统会对齐唤醒以省电;且最小 15 分钟 接受不精确;需精确时刻改用 AlarmManager
任务一直 ENQUEUED 不执行 约束永远不满足(如 requiresCharging 且从不充电) 检查约束;用 adb shell dumpsys jobscheduler 看原因
加急任务被降级 超出系统配额(前台不可见时更严格) 只在用户主动触发时用;处理降级后的行为
任务重复执行 每次启动 App 都 enqueue;用了 APPEND 策略 用 enqueueUniqueWork + KEEP
进程被杀后任务丢失 用了 JobScheduler/Service 而非 WorkManager 换 WorkManager(任务持久化在数据库)
Hilt 注入的 Worker 构造报错 忘了 @HiltWorker / @Assisted;或未移除默认初始化器 补注解;Manifest 里 remove WorkManagerInitializer
ForegroundServiceStartNotAllowedException 后台启动前台服务(Android 12+) 改用加急任务;或等待前台时机
通知点击跳转后回退栈异常 直接 PendingIntent.getActivity 未建返回栈 用 TaskStackBuilder 构建父栈
多通道推送导致重复弹窗 厂商通道与轮询通道重复触达 PushDispatcher 中按 messageId 去重
Doze 模式下任务延迟 系统维护窗口才允许执行 属预期行为;必要时用加急任务或引导用户加入电池优化白名单
收不到厂商推送 未接该厂商 SDK;或被系统省电策略限制 按通道抽象接入;引导用户开启自启动/通知权限

四、调试技巧

# 1. 查看 WorkManager 的任务状态(最常用)
adb shell dumpsys jobscheduler | grep -A 20 "com.techfeed.app"

# 2. 强制进入 Doze 模式,验证约束是否生效
adb shell dumpsys battery unplug          # 模拟断开充电
adb shell dumpsys deviceidle force-idle   # 强制进入 idle
adb shell dumpsys deviceidle step         # 逐步推进 Doze 状态

# 3. 手动触发一个 Worker(需测试环境)
adb shell am broadcast -a androidx.work.diagnostics.REQUEST_DIAGNOSTICS -p com.techfeed.app

# 4. 查看通知渠道与权限状态
adb shell dumpsys notification --noredact | grep -A 5 "techfeed"
adb shell cmd appops get com.techfeed.app POST_NOTIFICATION

# 5. 直接发一条测试通知(不写代码验证渠道是否正确)
adb shell cmd notification post -S bigtext -t '标题' 'tag' '内容'

# 6. 模拟进程被杀,验证任务是否持久化
adb shell am kill com.techfeed.app

IDE 内:

  • App Inspection → Background Task Inspector:可视化查看 WorkManager 任务的状态、约束、重试次数与执行历史——排查"为什么不执行"的第一工具。
  • Logcat 过滤 WM- 可以看到 WorkManager 的内部日志(WM-WorkerWrapper、WM-SystemJobScheduler)。

五、最佳实践

  1. 后台任务默认选 WorkManager:Service 只用于前台服务(音乐、导航、长连接),JobScheduler 交给 WorkManager 封装。
  2. Worker 只做适配,逻辑放 UseCase:doWork() 里只调用一个 UseCase,这样业务逻辑可单测、Worker 几乎不用测。
  3. CoroutineWorker 优于 Worker:前者直接给你 suspend 上下文,不再需要阻塞式 API。
  4. 约束要克制:约束越多,任务越难触发。requiresCharging + requiresDeviceIdle 是清理类任务的合理组合,同步类任务通常只需要网络。
  5. 唯一任务用 KEEP:避免每次启动 App 都重置周期或堆积重复任务。
  6. 退避策略显式配置:默认退避是 30s 指数增长,按业务调整。
  7. 渠道在 Application 启动时创建:按用途分渠道(推荐/同步/互动),让用户能分别开关。
  8. PendingIntent 一律显式可变性标志:99% 场景用 FLAG_IMMUTABLE,需要 PendingIntent 被系统填充额外数据时才用 FLAG_MUTABLE。
  9. 权限申请要有解释与降级:被拒绝后不要反复弹窗,给出设置入口;通知不可用时功能仍应可用。
  10. 推送走通道抽象:业务只依赖 PushMessage 与 PushChannel,新增厂商通道不改业务代码。
  11. 推送必须去重:多通道重复触达是常态,按 messageId 幂等处理。
推荐 ✅ 避免 ❌
CoroutineWorker + @HiltWorker 后台 Service / IntentService
enqueueUniqueWork(..., KEEP, ...) 每次启动都 enqueue 同一任务
约束:网络 + 电量 约束堆满(永远不触发)
逻辑抽成 UseCase,Worker 做壳 几百行业务逻辑写在 doWork()
TaskStackBuilder 构建返回栈 裸 PendingIntent.getActivity
FLAG_IMMUTABLE 不指定可变性标志(Android 12+ 崩)
按用途建多个渠道 所有通知共用一个渠道
发送前 areNotificationsEnabled() 未授权也照发(静默丢弃还查不到原因)
通道抽象 + messageId 去重 业务代码里 if (isXiaomi) ... else if (isHuawei) ...

六、本章小结

  1. 后台限制是续航权衡的结果:Doze、应用待机、后台服务限制层层收紧,现代做法是"向系统声明、由系统调度"——WorkManager 是唯一推荐。
  2. WorkManager 提供持久化、约束感知与版本自适应:CoroutineWorker 写 suspend 逻辑,四类任务(一次性/周期/加急/链式)覆盖绝大多数场景,WorkInfo 可被 Flow 观察。
  3. 可测试性靠分层:把逻辑抽到 UseCase,Worker 只做薄适配,避免"为了测 Worker 而搭建整套 WorkManager"。
  4. 通知有三道门槛:渠道(8+)、PendingIntent 可变性(12+)、POST_NOTIFICATIONS 权限(13+),缺一个通知就静默消失。
  5. 推送必须抽象:国内 FCM 不可用,用 PushChannel 接口 + PushDispatcher 选路去重,让"接哪家厂商"成为配置问题而非改造问题。

上一章衔接:第 12 章 依赖注入 的 @HiltWorker 与 @IntoSet 多绑定, 正是本章 Worker 注入与多推送通道装配的技术基础。 下一章:至此阶段 2 的四项能力(网络、存储、异步、DI)与后台通知已齐备,进入 第 14 章 分层架构与 MVI,把这些零件按 UI / Domain / Data 三层组织成企业级架构。


七、动手练习

基础题

  1. 写一个 CacheCleanupWorker(CoroutineWorker),要求:只在充电且空闲时运行,删除 7 天前的缓存文件,返回删除的文件数量到 outputData。
  2. 用 enqueueUniquePeriodicWork 提交 12 小时一次的清理任务,用 Background Task Inspector 观察它的状态变化。
  3. 创建两个通知渠道("文章推荐" DEFAULT、"互动消息" HIGH),用 adb shell cmd notification post 验证渠道创建成功。

进阶题 4. 实现链式任务:同步 → 生成摘要 → 发通知。要求前一步的输出作为后一步的输入,并用 getWorkInfosFlow 在 UI 上展示进度(提示:setProgressAsync)。 5. 给 SyncFeedWorker 加"加急"分支:用户下拉刷新失败后点击"重试"时提交加急任务;用 OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST 处理超配额情况,并写测试验证降级行为。 6. 扩展 PushChannel:再加一个 PollingPushChannel(用 WorkManager 每 6 小时轮询服务端未读消息并转本地通知),验证 PushDispatcher 无需改动即可纳入新通道。

思考题 7. 为什么 WorkManager 的周期任务最小间隔是 15 分钟?如果产品要求"每 5 分钟检查一次新消息",你会怎么设计(至少给出两种方案及代价)? 8. 加急任务(Expedited Work)与前台服务(Foreground Service)的区别是什么?为什么 Android 12 要引入前者并限制后者? 9. 如果你的 App 在某国产 ROM 上完全收不到推送,你会按什么顺序排查?通道抽象在这条排查链上能提供什么帮助? 10. 通知被用户关闭了渠道(而不是关闭权限),App 侧能感知吗?产品上应该如何设计降级体验?

📄 第 14 章 分层架构与单向数据流(MVI) 14-architecture-layering-mvi.md

阶段:企业级 | 预计阅读 60 min | 代码落地模块::core:model、:feature:feed、:feature:detail 前置:第 03 章 应用组件与生命周期 | 第 11 章 协程与 Flow | 第 12 章 依赖注入 相关章节:第 15 章 模块化与组件化拆分 | 第 16 章 Compose 与 View 互操作 | 第 21 章 测试策略 | 第 23 章 TechFeed 总览

学习目标

  • [ ] 说清 MVC / MVP 在 Android 上为什么会失控,理解 MVP 的 IView 接口与 MVVM 双向绑定各自的代价。
  • [ ] 掌握 UI / Domain / Data 三层职责与依赖方向,能画出依赖图并解释"为什么 UI 不能直接依赖 Data"。
  • [ ] 能独立设计一个单一不可变 UiState,严格区分状态(State)与事件(Event),写出可测试、可重放的 ViewModel。
  • [ ] 理解 UseCase、Repository、单一数据源(Single Source of Truth, SSOT)的价值边界,知道什么时候不该写 UseCase。
  • [ ] 能区分领域模型、DTO、Entity 三层模型并完成映射,识别 ViewModel 职责越界的常见反模式。

一、核心概念

1.1 Activity 为什么会变成 3000 行

Android 官方从没指定过架构,于是大家照搬 Web 时代的 MVC(Model-View-Controller):Model 是数据,View 是 XML,Controller 是 Activity。这套映射在 Android 上从一开始就错位了:

// ⚠️ 反模式:一个 Activity 同时扮演了 Controller、半个 View、半个 Model
class FeedActivity : AppCompatActivity() {
    private val articles = mutableListOf<Article>()   // Model 状态混在这里
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_feed)        // View 的创建
        findViewById<Button>(R.id.btn_refresh)        // View 的查找
            .setOnClickListener {                     // Controller 的事件处理
                Thread {                              // 自己做线程切换
                    val json = URL("https://api.techfeed.dev/feed").readText()  // 自己做网络
                    val list = Gson().fromJson(json, Array<Article>::class.java) // 自己做解析
                    runOnUiThread {                   // 自己做回调
                        articles.clear(); articles.addAll(list)
                        (recyclerView.adapter as FeedAdapter).submitList(articles.toList())
                        progressBar.visibility = View.GONE   // 自己做状态同步
                    }
                }.start()
            }
    }
}

这份代码的致命问题不是"丑",而是四条结构性缺陷:

缺陷 后果
Controller 持有 View 与 Context 网络回调晚于页面销毁时 Activity 泄漏;单元测试必须启动模拟器
生命周期由系统驱动 旋转屏幕重建,model 状态在 Activity 字段里直接丢失
状态散落在多个控件 progressBar.visibility、adapter.submitList、emptyView 三者可能互相矛盾(既转圈又显示空态)
业务逻辑写在 UI 里 想给"刷新信息流"加个离线缓存或埋点,必须改 Activity

MVP(Model-View-Presenter) 的补救是把 View 抽成接口:

interface FeedView { fun showLoading(); fun showList(list: List<Article>); fun showError(msg: String) }

Presenter 只依赖接口,于是可以脱离 Android 跑单元测试。但 MVP 在企业项目里同样会失控:

  1. 接口爆炸:每屏一个 IView,稍有改动就要改接口 + 实现类 + Presenter 三处。
  2. 生命周期手工绑定:Presenter 什么时候 attachView/detachView 全靠自觉,漏一次就是泄漏或空指针。
  3. Presenter 依旧是 God Object: Presenter 取代 Activity 变成新的 3000 行。
  4. 状态一致性没解决:showLoading / showList 是命令式调用,"当前到底什么状态"没有唯一答案。

MVVM(Model-View-ViewModel) 用数据绑定把 View 和 ViewModel 解耦,Android 早期用 DataBinding / LiveData。但一旦引入双向绑定(@={}),"这个字段到底被谁改了"就彻底失控——调试时你追不到修改来源。

于是 Google 在 2020 年前后明确了官方推荐:单向数据流(Unidirectional Data Flow, UDF),在 Android 语境下常被称作 MVI(Model-View-Intent)。它不是一个新框架,而是三条硬约束。

先用一张表把四种范式的取舍一次看清(" Model 维个数"指状态是否有唯一存放地):

范式 状态存放地 View 与逻辑的耦合 单测成本 典型失控点
MVC Activity 字段 + 各个控件自己的状态 Activity 同时是 View 和 Controller 需 Robolectric / 真机 Activity 成为 God Object
MVP Presenter 字段 Presenter 调 IView 接口方法(命令式) 可脱离 Android,但需 hand-written fake view IView 接口与 Presenter 同步膨胀
MVVM(双向绑定) ViewModel + View 双向回写 ViewModel 暴露可变字段 可单测,但"谁改的"难追踪 双向绑定导致数据流不可追踪
MVI / UDF 唯一 UiState 快照 UI 只收 State、只发 Intent ViewModel 与 UseCase 均可纯 JUnit 样板代码略多、事件通道易误用

一句话概括差别:MVC/MVP 回答的是"谁去改 View",MVI 回答的是"现在是什么状态"。前者是命令式调用链,后者是声明式快照。当你能用"给同一份 UiState,画出同一幅画面"来定义 UI 时,旋转屏幕、进程重建、UI 测试这三件以往最头疼的事,会自动变成同一件事。

1.2 三层架构与依赖方向

官方分层只有三层:UI 层 → Domain 层 → Data 层。关键在于依赖方向:

flowchart TD
    UI["<b>UI 层</b><br/>Compose / View + XML<br/>ViewModel · UiState · Event"]
    DOM["<b>Domain 层</b>(可选但推荐)<br/>UseCase · Repository 接口<br/>领域模型 Article / Author"]
    DATA["<b>Data 层</b><br/>Repository 实现 · Remote/Local 数据源<br/>DTO · Entity · 数据源门面"]

    UI -->|"① 依赖:调用 UseCase"| DOM
    DATA -->|"② 依赖并实现:Repository 接口"| DOM
    UI -.->|"❌ 禁止:UI 直接 import Data"| DATA

    style DOM fill:#e8f0fe,stroke:#4a76c8
    style UI fill:#fff6e0,stroke:#c8a04a
    style DATA fill:#e9f7ee,stroke:#4aa86a

这张图有三点容易看漏:

  1. Domain 层不依赖任何外层,它是纯 Kotlin(理想情况下),不 import android.*、不 import Retrofit、不 import Room。这是它能被秒级单测的根本原因。
  2. Data 层依赖 Domain 层(实现它的接口),箭头方向反直觉但必须如此——这就是依赖倒置原则(Dependency Inversion Principle, DIP)。
  3. UI 层不许 import Data 层的具体类。UI 拿到的永远是 Domain 层定义的抽象(Repository 接口、UseCase)。

三层的职责边界:

层 职责 应该有什么 不该有什么
UI 层 把状态渲染出来、把用户意图上报 ViewModel、UiState、Event、@Composable、Fragment/Activity、导航、主题 Retrofit 接口、Room DAO、Context 字段、业务规则判断
Domain 层 表达业务规则与业务意图 UseCase、Repository 接口、领域模型、领域级错误 Context、Toast、 Retrofit/Room 实现、Android 依赖
Data 层 取数与缓存,屏蔽数据来源 Repository 实现、Remote/Local 数据源、DTO/Entity、Mapper、数据库、SP/DataStore ViewModel、UiState、UI 文案

[!TIP] Domain 层是可选的。 如果你的 App 只是"请求 → 展示",UseCase 全都是一行转发,那么加一层只会让文件数量翻倍。判断标准见 1.6 节。

分层最容易被写口号。要让它真的生效,必须落在可被工具检查的位置:

  1. 包结构上先分层:feature/feed/ui/、feature/feed/domain/、feature/feed/data/ 三个包,Review 时一眼看出某个类是否放错层。
  2. 再落成 Gradle 模块:第 15 章会把 ui / data 拆成独立模块,届时"UI 不许 import Retrofit"不再靠自觉,而是编译期直接报错。
  3. 用静态检查兜底:Detekt 的自定义规则可以禁止 ui 包中出现 import androidx.room.*,CI 卡住而非口头约定。

换句话说:分层不是画图,是用编译器和 CI 把边界固定下来。在团队超过 5 人之后,这一点比选哪种范式更重要。

1.3 单向数据流(UDF):状态向下,事件向上

UDF 的完整闭环只有四个环节:

sequenceDiagram
    participant U as UI(Compose / View)
    participant V as ViewModel
    participant D as Domain / Data
    U->>V: ① 用户意图 Event(onClick / 下拉刷新)
    V->>D: ② 调用 UseCase(suspend / Flow)
    D-->>V: ③ 返回数据或 Result
    V->>V: ④ 计算出新的 UiState(copy)
    V-->>U: ⑤ StateFlow 推送新快照
    U->>U: ⑥ UI 按快照重新渲染(幂等)

三条铁律:

  1. 状态向下流动,事件向上流动(State down, Events up),不存在反向。
  2. UI 是状态的函数:同一份 UiState 渲染出的画面必须完全一致(幂等),旋转屏幕只是重新执行这个函数。
  3. 唯一修改入口:UiState 只能在 ViewModel 内部被 _uiState.update {} 修改,UI 永远只读。

这三条约束带来的收益可以量化:

  • 可重放:把历史上产生过的 UiState 序列记下来,就能在测试或崩溃复现时逐帧重现画面(第 21 章的截图测试正是靠这一点)。
  • 可观性:出问题时你不需要猜"哪个调用改了这个控件",只需要看"这一刻的 UiState 是什么"和"上一个 Action 是什么"——调试维度从"所有代码"降到"两个变量"。
  • 可并行改写:状态入口唯一,意味着两个人同时加功能时,冲突面从"多个控件的可见性判断"收缩到"一个 copy 表达式"。

代价也要说清楚:MVI 比 MVVM 多一些样板(State、Event、Action 三个密封类),对极其简单的页面显得啰嗦。评判标准很简单——如果这个屏幕有三种以上的视觉状态(加载/空/错误/内容),用 MVI 一定回本。

1.4 UiState:一个屏幕,一个不可变快照

UiState 是全章最重要的数据结构。四条设计原则:

① 单一状态对象。 一个屏幕(或一个顶层目的地)对应一个 UiState,而不是每个控件一个 LiveData。

// ❌ 反模式:状态分散,三者可能同时为真(既加载中又显示错误又显示空列表)
val isLoading: StateFlow<Boolean>
val articles: StateFlow<List<Article>>
val error: StateFlow<String?>

// ✅ 正确:单一快照,状态天然互斥自洽
data class FeedUiState(
    val isLoading: Boolean = false,
    val articles: List<Article> = emptyList(),
    val errorMessage: String? = null,
) {
    // 派生属性:不要再开一个 StateFlow 去维护它
    val isEmpty: Boolean get() = !isLoading && errorMessage == null && articles.isEmpty()
}

② 屏幕级而非控件级。 UiState 描述"这一屏长什么样",而不是"这个 Button 是不是选中"。控件级的局部状态(TextField 的输入中字符、滚动位置、动画进度)留在 UI 内部即可,不要塞进 UiState——否则每敲一个字符都要走一轮 ViewModel 往返,白白增加延迟与重组。

③ 不可变 + copy。 全部字段用 val,容器类型用只读接口 List/Map。更新必须用 copy,因为 StateFlow 的相等判定依赖 equals:直接 mutableList.add() 引用没变,StateFlow 认为值没变,UI 不会刷新。

④ 不做基站式巨型状态。 一个 UiState 里出现 40 个字段说明这个屏幕该拆了。常见做法是把底部弹窗、次级区块拆成独立的小 UiState。

1.5 State 与 Event 必须分离

这是新手最常踩的一条:

对比项 State(状态) Event(事件)
语义 "现在是什么样" "刚刚发生了一件事"
幂等性 重复消费结果一致 重复消费有害(弹两次 Toast、跳两次页)
载体 StateFlow<UiState> SharedFlow<UiEvent>(replay = 0)
典型例子 列表数据、加载中、错误文案 Toast、Snackbar、导航、打开系统相册、播放音效

如果把 Toast 塞进 UiState,旋转屏幕后 UI 重新收集 StateFlow 会再弹一次。这就是必须为一次性事件单独开一个通道的原因。

// ⚠️ 反模式:偷懒把一次性事件做成 UiState 字段
data class FeedUiState(val toastMessage: String? = null)   // 旋转屏幕就会被重放

// ✅ 事件通道:SharedFlow 默认 replay = 0,不缓存、不重放
private val _events = MutableSharedFlow<FeedEvent>(
    extraBufferCapacity = 1,   // 允许在 UI 尚未开始收集时先缓存一个事件,避免 send 挂起
)

[!NOTE] 为什么不用 Channel? Channel 默认是 rendezvous(会挂起等待接收方),而且把事件交给它之后你无法同时给多个订阅者;SharedFlow 支持多订阅、tryEmit、replay/extraBufferCapacity 可调,行为更可控。只有在明确需要"单个消费者、绝不重放"的极端场景下才考虑 Channel。

事件通道还有两个容易忽略的细节:

  • 回执机制:事件消费完(例如 Snackbar 已关闭)后 UI 应上报一个动作(见 2.1 的 FeedAction.MessageShown),让 ViewModel 清掉对应状态,避免"错误提示存在于状态里但已被用户关掉"的不一致。
  • 谁来消费:事件必须在有生命周期的一方消费(Compose 的 LaunchedEffect、View 的 repeatOnLifecycle),否则页面进入后台时发出的事件会在回来时被一次性补执行。

1.6 UseCase:什么时候该写,什么时候不该写

一个 UseCase 就是一个命名的业务意图,通常只有一个方法 operator fun invoke(...):

class GetFeedUseCase @Inject constructor(private val repository: FeedRepository) {
    suspend operator fun invoke(page: Int, forceRefresh: Boolean = false): List<Article> = ...
}

该写 UseCase 的四类场景:

  1. 跨多个数据源编排:「首屏 = 用户信息 + 信息流 + 未读数」三个 Repository 并发聚合。
  2. 业务规则集中:购买前校验(登录态 + 库存 + 风控),多个页面共用。
  3. 被多个 ViewModel 复用:收藏操作在列表页、详情页、个人页都要用。
  4. 作为清晰的测试边界:单测一个 UseCase 只需 mock 几个 Repository。

不该写 UseCase 的场景(写了就是负担):

  • 只是 return repository.getFeed() 的一行转发,且只有一个调用方。
  • 纯 UI 行为:排序顺序切换、展开/收起、Tab 选中。
  • 纯数据转换:那是 Mapper 或 UiState 派生属性的活儿。
  • 把 Repository 方法换个名字包一层,只为"看起来架构整齐"。

[!WARNING] 每增加一层,就多一层样板代码与 Review 成本。先写薄的 ViewModel,等出现第二处复用或业务变复杂时再抽取 UseCase,比一开始就全铺开要健康得多。

1.7 Repository 与单一数据源(SSOT)

Repository 是数据层的门面:调用方不知道数据来自网络还是数据库。

flowchart LR
    VM["ViewModel"] --> UC["UseCase"] --> R["FeedRepository(接口在 Domain)"]
    R --> IMPL["FeedRepositoryImpl(实现在 Data)"]
    IMPL --> REM["RemoteDataSource<br/>Retrofit"]
    IMPL --> LOC["LocalDataSource<br/>Room"]
    LOC -.->|"单一数据源:先落库"| DB[("Room DB")]
    DB -->|"Flow 持续推送缓存"| VM

单一数据源(Single Source of Truth, SSOT) 的落地形态通常是"网络写库 → 数据库作为唯一真相 → UI 只订阅数据库 Flow":

override fun observeFeed(): Flow<List<Article>> =
    dao.observeArticles().map { list -> list.map { it.toDomain() } }  // UI 只订阅本地

这样做的好处:离线可用、天然缓存、数据源切换不影响 UI、旋转屏幕不会重新请求。

三种常见的 Repository 读策略,先把它们摆清楚再选:

策略 行为 适合场景
缓存优先(Cache-first) 先发本地旧数据,网络成功后自动推送新数据 信息流、列表,绝大多数场景的默认选择 ✅
网络优先(Network-first) 先请求网络,失败才回退本地 强实时数据(余额、库存)
仅本地 / 仅网络 单一来源 设置项、一次性提交类接口

代价都要直面一件事:数据新鲜度。缓存优先会让首帧显示几分钟前的旧数据,因此需要配套的过期机制(shouldRefresh() 判定、 stamps 时间戳、或 WorkManager 周期同步),这部分实战在第 25 章展开。

[!WARNING] 别把三种策略混在一个 Repository 里随机使用。团队里最常见的 bug 是" A 页面缓存优先、B 页面网络优先",同一份数据在不同页面显示不一致。策略要写进模块文档,并让所有调用方走同一个 Repository 方法。

1.8 三层模型:DTO / Entity / Domain

为什么不能一个类走天下?因为三种模型的变更原因完全不同:

模型 所在层 服务对象 变更原因 示例
DTO(Data Transfer Object) Data 层(远程) 服务端 JSON schema 后端改字段名、改类型 ArticleDto:@SerialName("publish_time") val publishTime: String
**Entity Data 层(本地) 数据库表结构 加索引、加列、迁移 ArticleEntity:@Entity(tableName = "articles")
**领域模型(Domain Model) Domain 层 业务与 UI 业务语义变化 Article:publishedAt: Long、isFavorite: Boolean

不分层会发生什么?后端把 publish_time 从秒改成毫秒,你的 UI 代码、数据库迁移、单元测试一起炸。分层之后,改动被限制在 Mapper 里,一行改完。

Mapper 的方向与归属也要约定清楚,否则很快会出现循环依赖:

映射 方向 放哪儿
DTO → Domain 进入 app 时 Data 层的 XxxMappers.kt
Entity → Domain 出数据库时 Data 层的 XxxMappers.kt
Domain → Entity 写数据库时 Data 层
Domain → UiState 中间模型 进入 UI 时 UI 层(ViewModel 内或 XxxUiMapper.kt)

原则是:内层不认识外层。写 Article.toArticleDto() 意味着 Domain 层要知道 Retrofit 的存在,这就破坏了依赖方向;所以映射函数一律写成 ArticleDto.toDomain()(外层认识内层),放在外层模块里。

[!NOTE] 有些团队会把 Entity 与 Domain 合一(用 Room 的 @Entity 类直接当领域模型)。这在小项目里完全可接受,省一层样板;一旦你的领域模型需要表达"Entity 里没有的业务字段"(例如由两张表拼出来的 isFavorite),就会被迫重新拆开。

1.9 ViewModel 的职责边界

ViewModel 是 UI 层的状态容器,它恰好做四件事:

  1. 持有并计算 UiState(唯一写入者)。
  2. 接收 UI 上报的事件,翻译成 UseCase 调用。
  3. 在 viewModelScope 中启动协程,生命周期结束时自动取消(第 03 章)。
  4. 通过 SavedStateHandle 保存跨进程重建的关键状态。

除此之外一概不做:

反模式 ❌ 为什么错 正确做法 ✅
持有 View / Context / Activity 引用 Activity 销毁后仍被 ViewModel 持有 → 泄漏 需要资源字符串时 from Domain 层返回文案枚举或 StringRes id,或用第 03 章的一次性 Event 通知 UI 层自己去取
把 Activity 当 God Object 一个页面塞进五个业务 → 无法单测 按目的地拆分 ViewModel,共享逻辑下沉 UseCase
在 ViewModel 里 Toast.makeText 依赖 Context,且轮换前后不一致 发 ShowToast 事件,UI 层消费
依赖 android.view.* Domain/Data 无法脱离 Android 单测 只依赖 androidx.lifecycle.*
在 init 里塞大量阻塞初始化 拖慢页面打开,也难以测试 延迟加载;务必懒触发(第 17 章启动优化)

1.10 端到端走一遍:一次下拉刷新发生了什么

把抽象落到具体,才能在 Review 时对得上直觉。用户下拉一次,完整链路是这样的:

  1. SwipeRefreshLayout / PullToRefreshBox 回调 → UI 上报 FeedAction.Refresh(事件向上)。
  2. ViewModel 在 viewModelScope 里先 _uiState.update { copy(isRefreshing = true) },UI 立刻显示转圈——注意此时网络还没开始。
  3. ViewModel 调用 suspend 的 RefreshFeedUseCase → Repository → RemoteDataSource 请求网络。
  4. 网络返回 DTO → Mapper 转 Entity → 写入 Room。
  5. Room 数据变动触发 Flow 推送 → Repository 的 observeFeed() 收到新列表 → Mapper 转领域模型 → ViewModel 的 onEach 里 copy(articles = new),状态向下回到 UI。
  6. UI 收到新快照,adapter.submitList() / LazyColumn 差分刷新,同时 isRefreshing = false 收起指示器;ViewModel 再发一个 FeedEvent.ScrollToTop 事件。

三条容易被忽略的结论:

  • 第 2 步是先改 UI 再干活:用户等待反馈的耐心只有 100ms 量级,真实数据到了再更新同一份 UiState 即可。
  • 第 5 步是自动的:ViewModel 并没有手动合并列表或请求下一页,本地数据库 Flow 推什么就渲染什么——这是 SSOT 最具体的收益。
  • 失败不影响正确性:网络失败只是不写库,旧数据照常显示,额外置一个错误字段或发一个事件即可。

二、示例代码

下面以一个真实信息流页面串起全链路:UiState/Event → ViewModel → UseCase → Repository → 数据源 → 映射 → Compose UI → View/XML UI。

2.1 UiState 与 Event 契约

[!NOTE] 演进说明:本章为聚焦 MVI 概念,FeedUiState 的错误字段使用最朴素的 errorMessage: String?。工程最终形态(第 21 章测试与第 24 章代码清单)已将其演进为 error: AppError?(领域错误类型),UI 层通过 error.userMessage 取展示文案——错误分类与文案映射的动机见第 09 章 AppError。两种写法都满足"状态收敛于单一快照"的原则。

// sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedContract.kt
package com.techfeed.feature.feed.ui

import com.techfeed.core.model.Article

/** 屏幕级单一快照:所有字段 val(不可变),默认值代表初始态 */
data class FeedUiState(
    val isLoading: Boolean = false,
    val isRefreshing: Boolean = false,   // 下拉刷新与首次加载是两种视觉反馈,但要同源于一个状态对象
    val articles: List<Article> = emptyList(),
    val errorMessage: String? = null,
    val keyword: String = "",
) {
    /** 派生属性:不额外占一个 Flow,避免状态不同步 */
    val showEmpty: Boolean get() = !isLoading && errorMessage == null && articles.isEmpty()
}

/** 一次性事件:绝不进 UiState,否则旋转屏幕会重复消费 */
sealed interface FeedEvent {
    data class ShowToast(val message: String) : FeedEvent
    data class NavigateToDetail(val articleId: String) : FeedEvent
    data object ScrollToTop : FeedEvent
}

/** 用户意图:UI 只上报"我做了什么",不指挥"你怎么改状态" */
sealed interface FeedAction {
    data object Refresh : FeedAction
    data object LoadNextPage : FeedAction
    data class QueryChanged(val text: String) : FeedAction
    data class ArticleClicked(val articleId: String) : FeedAction
    data object MessageShown : FeedAction   // UI 消费完错误后回执,让 VM 清除状态
}

[!TIP] 把 Action 也建模成密封类型(而不是 fun refresh() / fun loadMore() 一堆方法),好处是:所有用户行为集中一处,when 穷尽检查,埋点与日志可以统一挂在一个入口上。

2.2 ViewModel:唯一的 state 写入者

[!NOTE] 演进说明:本章沿用第 11/12 章的构造签名(注入 GetFeedUseCase + RefreshFeedUseCase);第 24 章起工程实际为 FeedViewModel(repository: FeedRepository)——UseCase 层数减少、VM 直接依赖 Repository 接口,且用 query.flatMapLatest 订阅 DB 级过滤流。签名变化的动机与完整代码见第 24 章。

// sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedViewModel.kt
package com.techfeed.feature.feed.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.techfeed.core.model.AppError
import com.techfeed.feature.feed.domain.GetFeedUseCase
import com.techfeed.feature.feed.domain.RefreshFeedUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class FeedViewModel @Inject constructor(
    private val getFeed: GetFeedUseCase,
    private val refreshFeed: RefreshFeedUseCase,
) : ViewModel() {

    private val _uiState = MutableStateFlow(FeedUiState(isLoading = true))
    val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()   // 对外只读,封住写权限

    private val _events = MutableSharedFlow<FeedEvent>()           // 默认 replay=0:不重放历史事件
    val events: SharedFlow<FeedEvent> = _events.asSharedFlow()

    private var observeJob: Job? = null

    init {
        // 单一数据源:订阅本地数据库 Flow,而不是一次性的网络结果
        observeJob = getFeed()
            .onEach { articles -> _uiState.update { it.copy(isLoading = false, articles = articles) } }
            .catch { e -> _uiState.update { it.copy(isLoading = false, errorMessage = e.message) } }
            .launchIn(viewModelScope)
    }

    /** UI 只调用这一个方法,所有分支都在 when 里,便于统一埋点 */
    fun onAction(action: FeedAction) {
        when (action) {
            FeedAction.Refresh -> refresh()
            FeedAction.LoadNextPage -> loadNextPage()
            is FeedAction.QueryChanged -> _uiState.update { it.copy(keyword = action.text) }
            is FeedAction.ArticleClicked ->
                // viewModelScope 自动随 ViewModel 销毁取消,无需担心泄漏
                viewModelScope.launch { _events.emit(FeedEvent.NavigateToDetail(action.articleId)) }
            FeedAction.MessageShown -> _uiState.update { it.copy(errorMessage = null) }
        }
    }

    private fun refresh() {
        viewModelScope.launch {
            _uiState.update { it.copy(isRefreshing = true, errorMessage = null) }
            runCatching { refreshFeed() }
                .onFailure { error ->
                    _uiState.update {
                        it.copy(isRefreshing = false, errorMessage = error.toMessage())
                    }
                }
                .onSuccess {
                    _uiState.update { it.copy(isRefreshing = false) }
                    _events.emit(FeedEvent.ScrollToTop)
                }
        }
    }

    private fun loadNextPage() { /* 第 24 章分页展开 */ }

    /** 错误到文案的映射放在 UI 层:Domain 层不认识中文文案 */
    private fun Throwable.toMessage(): String = when (this) {
        is AppError.Network -> "网络不可用,请检查连接"
        is AppError.Auth -> "登录已失效,请重新登录"
        else -> "加载失败,请稍后重试"
    }
}

关键点:asStateFlow() 对外暴露只读类型;update {} 保证原子更新;所有 String 文案留在 UI 层(Domain 层不应出现用户可见语言,除非你在做多语言抽象)。

2.3 UseCase:命名一个业务意图

// sample/feature/feed/src/main/java/com/techfeed/feature/feed/domain/GetFeedUseCase.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.model.Article
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

/**
 * 观察信息流。
 * 注意:这里返回 Flow 而非 suspend List —— 因为本地数据库是单一数据源,数据会持续变化。
 * 这一层不 import 任何 android.* / Retrofit / Room,纯 Kotlin 可直接跑 JUnit。
 */
class GetFeedUseCase @Inject constructor(
    private val repository: FeedRepository,
) {
    operator fun invoke(keyword: String = ""): Flow<List<Article>> =
        repository.observeFeed(keyword)
}
// sample/feature/feed/src/main/java/com/techfeed/feature/feed/domain/RefreshFeedUseCase.kt
package com.techfeed.feature.feed.domain

import javax.inject.Inject

/** 拉取远端并落库。suspend 语义:一次性的动作,不返回持续流 */
class RefreshFeedUseCase @Inject constructor(
    private val repository: FeedRepository,
) {
    suspend operator fun invoke() = repository.refresh()
}
// sample/feature/feed/src/main/java/com/techfeed/feature/feed/domain/FeedRepository.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.model.Article
import kotlinx.coroutines.flow.Flow

/** 接口定义在 Domain 层(依赖倒置):Data 层来实现它,UI 层只认它 */
interface FeedRepository {
    fun observeFeed(keyword: String): Flow<List<Article>>
    suspend fun refresh()
}

2.4 Data 层:Repository 实现 + 三层模型映射

// sample/feature/feed/src/main/java/com/techfeed/feature/feed/data/ArticleDto.kt
package com.techfeed.feature.feed.data

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/** DTO:严格镜像后端 JSON,字段名可以不遵循 Kotlin 规范,用 @SerialName 对齐 */
@Serializable
data class ArticleDto(
    val id: String,
    val title: String,
    val summary: String? = null,
    @SerialName("cover_image") val coverImage: String? = null,
    @SerialName("publish_time") val publishTime: Long,   // 后端给毫秒时间戳
    val author: AuthorDto,
)

@Serializable
data class AuthorDto(val id: String, val name: String, @SerialName("avatar") val avatar: String? = null)
// sample/feature/feed/src/main/java/com/techfeed/feature/feed/data/FeedRepositoryImpl.kt
package com.techfeed.feature.feed.data

import com.techfeed.core.model.Article
import com.techfeed.feature.feed.domain.FeedRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

class FeedRepositoryImpl @Inject constructor(
    private val remote: FeedRemoteDataSource,
    private val local: FeedLocalDataSource,
) : FeedRepository {

    // 单一数据源:UI 订阅的永远是数据库,网络只负责往库里写
    override fun observeFeed(keyword: String): Flow<List<Article>> =
        local.observeArticles(keyword)
            .map { entities -> entities.map { it.toDomain() } }   // Entity → 领域模型

    override suspend fun refresh() {
        val dtos = remote.fetchFeed()          // 网络 → DTO
        local.replaceAll(dtos.map { it.toEntity() })   // DTO → Entity → 落库
    }
}
// sample/feature/feed/src/main/java/com/techfeed/feature/feed/data/ArticleMappers.kt
package com.techfeed.feature.feed.data

import com.techfeed.core.model.Article
import com.techfeed.core.model.Author

/**
 * 三层模型的转换集中在 Mapper。
 * 好处:后端改字段名、数据库改列名,改动范围都被限制在这一个文件里。
 */
fun ArticleDto.toDomain(): Article = Article(
    id = id,
    title = title,
    summary = summary.orEmpty(),
    coverUrl = coverImage,
    author = author.toDomain(),
    publishedAt = publishTime,   // 单位统一在这里收敛,UI 不再关心毫秒还是秒
)

fun AuthorDto.toDomain(): Author = Author(id = id, name = name, avatarUrl = avatar)

2.5 UI 之一:Compose 渲染 UiState

// sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedScreen.kt
package com.techfeed.feature.feed.ui

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedScreen(
    onNavigateToDetail: (String) -> Unit,
    modifier: Modifier = Modifier,
    viewModel: FeedViewModel = hiltViewModel(),   // Hilt 提供 VM(第 12 章)
) {
    // collectAsStateWithLifecycle:只在 STARTED 之后收集,后台自动停,省资源
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // 一次性事件:LaunchedEffect(Unit) 保证只注册一次消费者
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is FeedEvent.NavigateToDetail -> onNavigateToDetail(event.articleId)
                is FeedEvent.ShowToast -> { /* Snackbar 宿主显示 */ }
                FeedEvent.ScrollToTop -> { /* listState.animateScrollToItem(0) */ }
            }
        }
    }

    FeedContent(
        uiState = uiState,                       // 状态向下
        onAction = viewModel::onAction,          // 事件向上
        modifier = modifier,
    )
}

/** 无状态(Stateless)内容组件:只依赖入参,天然可在 @Preview / 截图测试中复用 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FeedContent(
    uiState: FeedUiState,
    onAction: (FeedAction) -> Unit,
    modifier: Modifier = Modifier,
) {
    PullToRefreshBox(
        isRefreshing = uiState.isRefreshing,
        onRefresh = { onAction(FeedAction.Refresh) },
        modifier = modifier.fillMaxSize(),
    ) {
        when {
            uiState.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
            uiState.errorMessage != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Text(uiState.errorMessage)
            }
            uiState.showEmpty -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Text("还没有内容,下拉试试")
            }
            else -> LazyColumn(Modifier.fillMaxSize()) {
                items(items = uiState.articles, key = { it.id }) { article ->   // key 保证重组复用正确
                    ListItem(
                        headlineContent = { Text(article.title) },
                        supportingContent = { Text(article.author.name) },
                        modifier = Modifier.clickable { onAction(FeedAction.ArticleClicked(article.id)) },
                    )
                }
            }
        }
    }
}

[!NOTE] 拆出 FeedContent 这种无状态组件是 MVI 在 Compose 下的关键收益:FeedScreen 负责"接 ViewModel",FeedContent 只吃参数。Preview、截图测试(第 21 章)、换另一套 ViewModel 都不用改它。

2.6 UI 之二:View / XML 渲染同一份 UiState

同一个 ViewModel,在 View 体系里渲染,只需把 StateFlow 收集起来刷新控件:

// sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedFragment.kt
package com.techfeed.feature.feed.ui

import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.techfeed.feature.feed.databinding.FragmentFeedBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

@AndroidEntryPoint
class FeedFragment : Fragment(R.layout.fragment_feed) {

    private val viewModel: FeedViewModel by viewModels()
    private var _binding: FragmentFeedBinding? = null
    private val binding get() = _binding!!
    private val adapter = FeedAdapter { article -> viewModel.onAction(FeedAction.ArticleClicked(article.id)) }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        _binding = FragmentFeedBinding.bind(view)   // ViewBinding:避免 Kotlin synthetics 的 id 冲突
        binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
        binding.recyclerView.adapter = adapter
        binding.swipeRefresh.setOnRefreshListener { viewModel.onAction(FeedAction.Refresh) }

        observeUiState()
        observeEvents()
    }

    private fun observeUiState() {
        viewLifecycleOwner.lifecycleScope.launch {
            // repeatOnLifecycle(STARTED):页面不可见时停止收集,避免后台刷新 UI
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state -> render(state) }
            }
        }
    }

    /** 纯函数式渲染:给定 state,产出画面;与 Compose 的 FeedContent 逻辑一一对应 */
    private fun render(state: FeedUiState) = with(binding) {
        progressBar.isVisible = state.isLoading
        swipeRefresh.isRefreshing = state.isRefreshing
        emptyView.isVisible = state.showEmpty
        errorView.isVisible = state.errorMessage != null
        errorView.text = state.errorMessage.orEmpty()
        // submitList 内部做差分:传新 List 实例才会触发 DiffUtil
        adapter.submitList(state.articles)
    }

    private fun observeEvents() {
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.events.collect { event ->
                    when (event) {
                        is FeedEvent.NavigateToDetail -> parentFragmentManager.navigateToDetail(event.articleId)
                        is FeedEvent.ShowToast -> toast(event.message)
                        FeedEvent.ScrollToTop -> binding.recyclerView.smoothScrollToPosition(0)
                    }
                }
            }
        }
    }

    // Fragment 的 View 被销毁但实例还在,必须切断 binding 引用(第 03 章)
    override fun onDestroyView() {
        binding.recyclerView.adapter = null
        _binding = null
        super.onDestroyView()
    }
}

[!TIP] 双 UI 体系共用一套 ViewModel 是可行的,前提是 ViewModel 不 import android.view.*(本节的写法满足)。这也是第 16 章渐进式迁移能成立的基础:先迁 UI,ViewModel 与下层一行不动。

2.7 Hilt 装配接口与实现

// sample/feature/feed/src/main/java/com/techfeed/feature/feed/di/FeedDataModule.kt
package com.techfeed.feature.feed.di

import com.techfeed.feature.feed.data.FeedRepositoryImpl
import com.techfeed.feature.feed.domain.FeedRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class FeedDataModule {
    // @Binds 把实现类绑定到接口:UI 层永远拿的是 Domain 层的抽象
    @Binds
    @Singleton
    abstract fun bindFeedRepository(impl: FeedRepositoryImpl): FeedRepository
}
旧写法对照:LiveData / SingleLiveEvent

2017–2020 年的老项目里,上述通道通常用 LiveData + SingleLiveEvent 实现:

private val _uiState = MutableLiveData(FeedUiState())
val uiState: LiveData<FeedUiState> = _uiState         // 生命周期感知,但只在主线程
private val _events = SingleLiveEvent<FeedEvent>()    // 社区 hack:用一个 Boolean 标记消费

差异要点:

维度 LiveData + SingleLiveEvent StateFlow + SharedFlow
线程 只能主线程 setValue 任意线程 update,suspend 友好
背压/丢事件 主线程 post 可能丢中间值 SharedFlow 可配 extraBufferCapacity
一次性事件 需要 SingleLiveEvent 这类 hack SharedFlow(replay = 0) 原生支持
测试 需 InstantTaskExecutorRule 直接 Turbine + runTest(第 21 章)

新代码统一用 Flow;维护老码时,只在"必须给 Java 调用方"的地方保留 LiveData,并用 .asLiveData() 桥接。


三、常见坑 ⚠️

现象 根因 解法
改了 mutableList 内容但 UI 没刷新 引用没变,StateFlow 靠 equals 判重认为是同一个值 字段用 List,每次 copy(list = old + new) 产生新实例
旋转屏幕后 Toast 又弹一次 一次性事件塞进了 UiState,重建时被重新收集 Toast/导航/弹窗走 SharedFlow 事件通道
屏幕进入后台再回来,事件丢失 MutableSharedFlow 无订阅者时 emit 挂起(replay=0)或丢弃 给 MutableSharedFlow(extraBufferCapacity = 1),或用 tryEmit
UiState 出现互相矛盾的组合 状态分散在多个 Flow / 多个可变变量 合并成单一 data class,把非法组合在类型上消灭
ViewModel 里出现 import android.content.Context 资源管理混进了状态层 返回事件由 UI 层取资源;或依赖注入 ResourcesProvider 抽象
Activity 旋转后崩溃 View not attached ViewModel 直接持有 View 引用 只暴露 Flow;UI 层自己订阅并 repeatOnLifecycle
Repository 既查库又查网络,UI 显示两份不一致数据 违反 SSOT,UI 同时订阅两个源 UI 只订阅本地 Flow,网络结果统一写库
后端改字段名导致全工程编译失败 缺 DTO 层,JSON 结构直接当领域模型用 三层模型 + Mapper,改动收口到一个文件
每个 Journal都存在 UseCase 但正文只有一行转发 过度设计,样板代码淹没业务 只有存在复用/编排/规则时才抽 UseCase
init 里发网络请求,ViewModel 一创建就请求(含 Preview 与测试) 初始化副作用不可控 用 LaunchedEffect(Unit) 或显式的 onAction(FeedAction.Load) 触发
单元测试里 StateFlow 收不到值 测试调度器未统一 / 协程没跑起来 用 UnconfinedTestDispatcher + Turbine(第 21 章)
Compose 中输入框每敲一个字符都卡 输入中字符进了 UiState,走了 ViewModel 往返 控件级文本留在 UI 局部状态,失焦或提交时才上报 Action

四、调试技巧

  1. 给 UiState 打变更日志:在一个地方集中观察所有流转,比到处打 log 高效得多。
    // 仅 debug 包:sandbox 观察每一次状态变化
    fun ViewModel.debugLogState(state: FeedUiState) {
        if (BuildConfig.DEBUG) Log.d("UiState", "isLoading=${state.isLoading}, size=${state.articles.size}")
    }
    
  2. StateFlow.value 断点:在 Android Studio 里对 _uiState.update {} 打断点(勾 Suspend: All),能看清是谁触发了这一次更新,尤其是多线程写入场景。
  3. Layout Inspector 看 Compose 重组次数:View → Tool Windows → Layout Inspector,开启 Show Recomposition Counts,瞬间定位失控重组的位置(第 17 章会讲透)。
  4. 证明泄漏是否存在:旋转屏幕 10 次后执行 adb shell dumpsys meminfo com.techfeed.app,看 Activities 数量是否累积;也可以在 Profiler 里看 Activity 实例的引用链。
  5. 测试 DataSource 与 ViewModel 的边界:给 FeedRepository 写一个 Fake 实现注入 Hilt(@TestInstallIn,第 12 章),比 mock Retrofit 稳定得多。
  6. adb shell am kill <pkg> 验证 SavedStateHandle 是否真的 recover 了关键字段(比如当前 Tab、搜索词)。
  7. 用编译期护栏代替口头约定:Detekt 自定义规则禁止 :feature:*:ui 包中出现 android.content.Context 字段,或禁止 UI 模块出现 :core:network 依赖(第 22 章)。

五、最佳实践

  1. 一个顶层目的地 = 一个 ViewModel + 一个 UiState,不要把整 App 塞到一个 UiState,也不要一个按钮一个 Flow。

  2. UiState 必须是不可变 data class:字段全 val,容器用 List/Map,更新一律 copy/update。

  3. Event 与 State 物理隔离:StateFlow 放状态,SharedFlow(replay = 0) 放一次性事件,二者不混用。

  4. UI 层不做业务判断:when 里只允许出现"怎么显示",不允许出现"是否允许收藏"这类规则——那是 Domain 层的事。

  5. 文案、单位换算、格式化放在 UI 层,让 Domain 层保持业务纯净(也便于多语言与截图测试)。

  6. 依赖永远指向抽象:UI 层只知道 UseCase / Repository 接口,实现在 Data 层由 Hilt @Binds 装配。

  7. 组件粒度宜小不宜大:@Composable 组件保持单一职责,既便于 Preview,也能让重组范围收窄(第 17 章会量化这一点)。

  8. ViewModel 对外只暴露一个 onAction(action: XxxAction) 入口:统一入口之后,埋点、登录态校验、连点防抖都能在一个地方完成,不必散落在几十个回调方法里。

推荐 / 避免对照表:

推荐 ✅ 避免 ❌
UiState 单一不可变快照 + 派生属性 多个 LiveData/StateFlow 各管一个字段
UI 上报"意图"(FeedAction.Refresh) UI 直接调业务方法或直接改 ViewModel 字段
事件用 SharedFlow,消费后发回执清空状态 用 Boolean 标记"已消费",或丢在 UiState 里
UseCase 承载复用/编排/规则 一行转发的"包装型 UseCase"
SSOT:UI 只订阅本地数据库 Flow UI 同时订阅网络与数据库
三层模型 + Mapper 隔离变化 JSON 结构直接当领域模型传全层
无状态组件 + 预览 / 截图测试 所有 @Composable 都 hiltViewModel(),无法 Preview
只允许 ViewModel 写 UiState UI 层回调里 _uiState.value = ...(外部可写)

六、本章小结

  1. MVC/MVP 在 Android 上失控的根因是生命周期由系统驱动 + Controller 持有 View 引用 + 状态分散;MVI 用"状态向下、事件向上"的单向数据流解决了状态归属问题。
  2. 三层依赖方向是 UI → Domain ← Data,Domain 层不依赖 Android 与任何框架,依赖倒置让 UI 永远面向抽象编程。
  3. UiState 是屏幕级单一不可变快照:字段全 val、更新靠 copy、非法组合在类型层面消灭、控件级临时状态留在 UI 内部。
  4. State 幂等重放、Event 一次性消费,二者必须物理隔离;UseCase 只在有复用、编排或业务规则时才写。
  5. Repository + SSOT(UI 只订阅本地)+ 三层模型 + Mapper 保证了离线可用、变更隔离;ViewModel 只管状态,绝不持有 View / Context。

到这一步,单个 destination 的内部秩序已经建立。但 TechFeed 有 5 个 feature、十余个 module——这些类应该放在哪些模块里、模块之间能不能互相依赖,决定了这套架构能不能在 20 人的团队里活得下去。

下一章:第 15 章 模块化与组件化拆分 —— 把分层落到 Gradle 模块上,用 core:* / feature:* 与 :core:navigation-api 实现 feature 之间零依赖。


七、动手练习

基础题

  1. 把 2.1 的 FeedUiState 补上一个 isLoadingMore: Boolean,并在 FeedContent(Compose)与 FeedFragment(View)两侧同时渲染出来,验证两者使用的是同一份 ViewModel。
  2. 给 ArticleDto 增加 @SerialName("read_minutes") 字段(后端新字段),完成 DTO → Entity → 领域模型三处映射,要求 UI 层除了展示以外,不做任何单位或空值兜底处理。
  3. 写一个 ToggleFavoriteUseCase,体会"两个页面复用同一段业务"的用法:先在列表页调用,再在详情页调用。

进阶题

  1. 首屏需要"用户信息 + 信息流 + 未读数"三份数据并发返回。请设计一个 LoadHomeUseCase,用 coroutineScope { async {} + async {} } 聚合,并说明为什么把它放在 Domain 层而不是 ViewModel 里。
  2. 现有 FeedViewModel 的 init 会立即拉 data。请改成"惰性加载"(UI 首次可见时才触发),并分别给出 Compose(LaunchedEffect)与 View(repeatOnLifecycle)两侧写法。
  3. 为 FeedViewModel 写三个单元测试:初次加载成功、刷新失败后写入 errorMessage、MessageShown 回执清空错误。要求用 Fake Repository + Turbine,不启动模拟器。

思考题

  1. 「一个 GraphQL 项目可以直接把 query 结果当领域模型」和「三层模型必须隔离」,这两种主张各适合什么场景?如果后端给你的是一个稳定的、带版本号的 BFF,你的结论会变吗?
  2. 有人认为「 Repository 应该对每个方法同时暴露 suspend 的一次拉取和 Flow 的持续观察两套 API」,有人则认为「只暴露 Flow 就够了」。结合 SSOT 的原则,谈谈你倾向哪一种、代价是什么。
  3. 如果你的 App 只有一个屏幕、一个接口,全套三层 + UseCase 会引入约 10 个文件。这种情况下,你会保留哪些层级、砍掉哪些?请给出你的"最小可行架构"清单并说明理由。

📄 第 15 章 模块化与组件化拆分 15-modularization.md

阶段:企业级 | 预计阅读 55 min | 代码落地模块::app、:core:*、:feature:* 前置:第 02 章 工程结构与 Gradle | 第 14 章 分层架构与 MVI 相关章节:第 12 章 依赖注入 | 第 16 章 Compose 与 View 互操作 | 第 20 章 构建配置与发布 | 第 23 章 TechFeed 总览

学习目标

  • [ ] 能用四条理由(编译、并行、复用、边界)说服团队做模块化,并知道它的真实代价。
  • [ ] 掌握"按层拆 / 按特性拆"的取舍,能画出 TechFeed 的 9 个 core:* + 5 个 feature:* + 1 个 benchmark 共 16 个 Gradle 模块图。
  • [ ] 理解依赖倒置与 API/Impl 分离,能独立设计 :core:navigation-api 并实现 feature 之间零依赖跳转。
  • [ ] 会用约定插件收口 Gradle 配置,正确选择 api 与 implementation,能检测与治理循环依赖。
  • [ ] 了解动态特性模块(Dynamic Feature)、组件化与插件化在国内的演进路径,能给出单体 → 分层 → 按特性的迁移路线。

一、核心概念

1.1 单体 App 的四个天花板

一个 10 万行以内的 App 放在单模块里完全能跑,直到撞上这四堵墙:

问题 单模块表现 模块化后
编译速度 改一行也要全量 javac/kotlinc + kapt/KSP + dex;20 分钟一次构建 Gradle 增量 + 缓存,改动只重编译受影响模块
团队并行 所有人改同一个 src,每天解冲突,PR 排队 5 条业务线 5 个 feature:*,互不干扰,OWNER 制而不是全组 Review
复用与复用隔离 想复用某个页面只能 copy 代码,改起来到处漏 提取 core:* 被多个 feature 依赖,改一处全生效
边界失控 任何人都能 import 任何类,"这个类的主讲人是谁"没有答案 模块依赖构成编译期围栏,乱引用直接红

收益里最容易被低估的是第四条:模块图就是架构图。口头约定的分层(第 14 章)会随时间腐烂,而 Gradle 依赖表是 CI 可以机器校验的硬约束。

代价必须同步讲清楚,否则团队三个月后会反过来骂你:

  1. 构建配置成本:每个模块一份脚本,需要约定插件收口,否则 15 份脚本各自漂移。
  2. 样板代码:跨模块跳转要多一层路由契约;Hilt 要在多处 @InstallIn 装配。
  3. 同步开销:模块数 > 30 后配置阶段本身会慢,需要打开 configuration cache / parallel。
  4. 过度设计风险:一开始就按"未来可能有 20 条业务线"来拆,往往先把自己绊倒。

那到底该拆多少个模块? 给三条经验法则,比给数字更有用:

  • 人数守门:模块数 ≈ 团队人数 / 2。五个开发者却拆了 20 个模块,一定有一半是没人认领的"孤儿模块"。
  • 每个模块必须有 OWNER:找不到 OWNER 的模块,说明它要么该合并,要么该降级成一个包。
  • 拆的理由要写下来:每个 feature 模块的 README 里写清"为什么它是一个模块"(并行开发 / 独立交付 / 可裁剪),理由消失时就合并回去。

一句话:模块化是手段,把地盘分清楚让人各行其是才是目的。 如果今天的团队一条业务线都跑不满,先把第 14 章的分层做好,比抢着拆模块更划算。

1.2 按层拆 vs 按特性拆

两种切法回答的是不同问题:

flowchart LR
    subgraph L["按层拆(Layer-first)"]
      direction TB
      L1["ui/"] --> L2["domain/"] --> L3["data/"]
    end
    subgraph F["按特性拆(Feature-first)"]
      direction TB
      F1[":feature:feed"] 
      F2[":feature:auth"]
      F3[":feature:profile"]
    end
    L -.->|"边界清晰但特性被切碎<br/>改一个需求要动 3 个模块"| X1["适合 infra"]
    F -.->|"改动内聚、可并行<br/>但需 core 层兜住复用"| X2["适合业务"]
维度 按层拆(ui / domain / data 各一个模块) 按特性拆(每个业务一个模块)
改动范围 一个需求横跨 3 个模块,PR 依然很大 绝大多数需求落在 1 个模块
复归特性 想删掉"商城"业务要横跨所有层去找 删模块即可
团队归属 无天然归属,谁都能改 天然对应业务小组 OWNER
适合对象 基础设施(core:network、core:database) 业务(feature:*)

结论:基础设施按层拆,业务按特性拆。 这就是本教程采用的混合方案,core:* 横向被依赖,feature:* 纵向承载业务。

业务边界怎么画?三个实操判据(画错了不要紧,后面还能合,怕的是不敢开始):

  1. 按"产品入口"画线:底部 Tab、独立的深链入口、一条完整的业务流程,各自是一个 feature 的候选。
  2. 按"数据所有权"画线:如果 A 页面写、B 页面读的是同一张表,它们应该在同一个 feature 内;跨 feature 的数据交换必须经过 core: 层的契约。
  3. 按"团队归属"画线:一个 feature 最好只由一个小组 OWNER。出现"两个组都改这块"时,通常说明该抽公共部分是了,而不是该合模块。

在此基础上,每个 feature 内部仍然保留第 14 章的三层包结构(ui / domain / data)——分层管依赖方向,模块化管编译边界与团队归属,二者解决的问题不同,不要互相替代。

1.3 TechFeed 模块图

flowchart TD
    APP["<b>:app</b><br/>组装层:Application · NavHost · 路由实现 · DI 根组件"]

    subgraph FEAT["feature 层:彼此零依赖 ❌"]
        F1[":feature:feed"]
        F2[":feature:detail"]
        F3[":feature:auth"]
        F4[":feature:profile"]
        F5[":feature:push"]
    end

    subgraph CORE["core 层:可被任意 feature 依赖 ✅"]
        C1[":core:model<br/>领域模型"]
        C2[":core:common<br/>工具·扩展·结果类型"]
        C3[":core:designsystem<br/>主题·组件"]
        C4[":core:network<br/>Retrofit·OkHttp"]
        C5[":core:database<br/>Room"]
        C6[":core:datastore<br/>DataStore"]
        C7[":core:navigation-api<br/>路由契约"]
        C8[":core:crash<br/>崩溃采集"]
        C9[":core:testing<br/>测试工具·Fake"]
    end

    BM[":benchmark<br/>Macrobenchmark·Baseline Profile"]

    APP --> FEAT
    APP --> CORE
    BM -.->|"com.android.test<br/>targetProject = :app"| APP
    FEAT --> C1 & C2 & C3 & C7 & C8
    F1 & F3 & F5 --> C4
    F1 & F2 --> C5
    F3 & F4 --> C6
    C9 -.->|"testImplementation"| FEAT

    style APP fill:#fff6e0,stroke:#c8a04a
    style FEAT fill:#e8f0fe,stroke:#4a76c8
    style CORE fill:#e9f7ee,stroke:#4aa86a

四条依赖规则:

  1. 箭头只能从上往下:feature 可以依赖 core,反向一律禁止(core:network 绝不能 import feature:feed 的类)。
  2. feature 之间零依赖:feed 不许 import detail,跳转走 :core:navigation-api 的路由(见 1.4)。
  3. core 之间也要克制:core:database 不依赖 core:network;只有明显的基础依赖才允许(如各 core 都可依赖 :core:model)。
  4. :app 是唯一知道全部模块的地方:装配、壳导航、SDK 初始化都在这里。

[!TIP] 组件独立调试:让某个 feature 可以单独跑起来(不必编译整个 App),是国内组件化里最实用的一招。做法是在模块脚本里用一个开关切换插件:

// sample/feature/detail/build.gradle.kts(节选)
// gradle.properties 里:runAlone=true 时该模块作为 app 独立运行
if (providers.gradleProperty("runAlone").getOrElse("false").toBoolean()) {
    plugins.apply("com.android.application")   // 独立运行:需要 applicationId 与一个壳 Activity
    android { defaultConfig { applicationId = "com.techfeed.feature.detail.alone" } }
} else {
    plugins.apply("com.android.library")       // 集成模式:正常被 :app 依赖
}

好处是改详情页时只需编译这一个模块,冷启动 IDE 反馈从分钟级降到秒级。代价是维护两套模式下的 Manifest 与 Hilt 入口,建议只在体量最大的两三个 feature 上启用,否则收益抵不过维护成本。

此外别忽视一点:模块化对编译速度的收益不是线性的。真正的提速来自"改动没波及的模块被 Gradle 缓存命中",所以务必同时打开构建缓存与配置缓存(第 02 章),否则模块再多也只是把编译拆成了 N 份。

1.4 依赖倒置与 API/Impl 分离

feature 之间零依赖听起来美好,但业务上必然要跳:"信息流点卡片 → 进详情页"。如果 feed 直接依赖 detail,两者便绑成一团,detail 想跳 auth(未登录)时又会形成环。

解法是把"跳转能力"抽象成 API 模块,让被跳转方与跳转方都只依赖抽象:

flowchart LR
    FEED[":feature:feed<br/>只读路由契约"] --> NAV[":core:navigation-api<br/>interface FeedNavigator · class TechFeedRoute"]
    DETAIL[":feature:detail<br/>提供目的地实现"] --> NAVC["navigation-compose / fragment"]
    APP[":app<br/>实现 FeedNavigator,拼装 NavHost"] --> NAV
    APP --> FEED
    APP --> DETAIL

这套结构叫 API/Impl 分离(也称依赖倒置),三条约定:

  • API 模块只放接口、数据契约与常量,不依赖 Android 具体实现,编译更快、依赖更少。
  • Impl 由 :app(或专门的 :core:navigation-impl)实现,注入方通过 DI 拿到实例。
  • feature 只持有接口引用,运行时由 DI 提供真正实现,编译期完全不知道对方模块存在。

[!IMPORTANT] 判断一个模块要不要拆 API/Impl,看它是否被多个 feature 反向需要。:core:navigation-api 就是典型:不是"数据层抽象",而是"能力抽象"。

1.5 路由:让 feature 互不认识却还能跳转

具体实现有三种流派,本教程选契约即字符串路由(最简单、跨 Compose/View 通用):

流派 做法 优点 缺点
字符串路由 techfeed://detail/{id},:app 解析并导航 简单、Compose/View 通吃、App Links 天然兼容 参数靠约定,改名无编译期检查(可用生成代码补)
类型安全路由对象 @Serializable data class Detail(val id: String) 编译期检查参数(Navigation Compose 2.8+ 支持) 目的地类要么放公共模块,要么序列化
接口 Navigator fun openDetail(id: String) 语义清晰、可 mock、便于埋点 每加一个页面都要改接口,实现类会膨胀

工程上的稳妥做法是:路由常量与 Navigator 接口都放 :core:navigation-api,:app 实现并挂到 DI 上,feature 调用 navigator.openDetail(id)。

把跳转统一到 URI 之后,还会顺带解决三件以前各自为政的小事:

  1. 外部唤起:深链(techfeed://detail/123)、App Links(https://techfeed.dev/article/123)、推送消息携带的跳转参数,都可以收敛到同一份路由解析逻辑里,在 :app 一个地方处理。
  2. 埋点与鉴权:所有跳转都经过 Navigator,于是"跳转埋点""未登录先跳登录再回跳"这类横切逻辑只写一次(openDetail 内部先检查登录态,未登录则转 openAuth 并带上原目标)。
  3. 降级:某个 feature 尚未迁移完成、或被打入 delayed 的开关关闭时,Navigator 可以直接降级到 H5 或提示页,调用方无感知。

[!NOTE] 你可能会问:国内主流方案(ARouter 等)用注解处理器生成路由表,为什么本教程不这么做?两条现实原因:其一,官方 Navigation 组件近年已补齐类型安全路由与深链能力,多一层注解处理器就是多一份 KSP 开销与迁移成本;其二,注解生成解决的是"表的自动生成",并没有解决"feature 之间零依赖"——后者靠的是 :core:navigation-api 这个抽象层,与用什么框架无关。老项目已有 ARouter 的话,保留即可,把它当作 :app 里的路由实现,契约层照样可以抽出来。

[!TIP] 不要为了"统一"而把 App 内所有跳转都改成 URI 字符串。 同一 feature 内部的目的地跳转(比如一张 Tab 页内的二级页),直接用 Navigation Compose 的路由对象或 Fragment 事务即可;只有跨 feature 的跳转才需要走 :core:navigation-api。路由层越薄,导航图越容易读懂。

1.6 循环依赖从哪来、怎么治

Gradle 允许直接依赖(A → B)与间接依赖(A → B → C),一旦出现 A → B → A 就是环,Gradle 在学术界墙到"循环依赖"时通常报错 Circular dependency between the following tasks。但更隐蔽的是逻辑环:

  • 特征环:feature:auth 需要"登录后回个人页",于是依赖 feature:profile;而 profile 需要"退出登录",又回头依赖 auth。
  • 层间环:core:database 为了某个字段跑去依赖 core:network 的 DTO。
  • UI 环:两个 feature 的 Util 类互相调用。

治理手段按成本从小到大:

  1. 抽公共部分下移:把公共模型、公共工具降到 :core:model / :core:common。
  2. 依赖倒置:把被依赖的能力抽成接口放到 API 模块(1.4 的做法)。
  3. 事件/路由解耦:改直接调用为发事件或走路由,双方不再互相感知。
  4. 合并模块:如果两个模块循环到"你中有我",说明边界画错了,合并成一个通常更健康。

1.7 动态特性模块(Dynamic Feature)

动态特性模块(Dynamic Feature Module, DFM) 允许某些功能不打包进基础包,用户需要时由 Play 下发:

特性 说明
com.android.dynamic-feature 插件 产物是独立的 split APK
splitInstallManager 运行时请求安装某个模块,可监听下载进度
只能被 :app 依赖 feature 依赖 DFM 是不允许的(构建即失败)
资源/代码需特殊处理 类查找要用 SplitCompat,资源需 SplitInstallHelper 或反射

现实建议:国内环境基本用不上(分发渠道不依赖 Play),但如果你的产品明确做出海,DFM 对首包体积的收益不可替代。替代方案是把"体量大但不常用"的功能做成远程容器(H5 / 小程序 / Flutter 容器),这与下一节的"插件化"是一脉相承的。

1.8 组件化与插件化:国内的一段演进史

这一段必须知道,因为你维护的老项目很可能就在某个阶段上:

flowchart LR
    A["2015-2016<br/>单体巨型 App"] --> B["2017-2019<br/>组件化:路由 + Gradle 多模块<br/>ARouter / WMRouter"]
    B --> C["2018-2020<br/>插件化:宿主 + 插件 APK<br/>热修复 · Atlas · RePlugin · VirtualApk"]
    C --> D["2020 之后<br/>回归稳定<br/>官方限制 + Compose/Flutter 跨端<br/>大量回归组件化"]
  • 组件化:编译期多模块 + 路由跳转,本质是 Gradle 模块的组合,和本章讲的是一回事。代表产物是 ARouter(自动生成路由表,@Route(path="/detail") 注解)。
  • 插件化:运行期加载另一个 APK/DEX,靠自定义 ClassLoader(DexClassLoader)+ 资源注入(AssetManager.addAssetPath)+ 四大组件占坑(Hook Instrumentation / ActivityThread)。收益是热更新与解耦包大小,代价是与 Android 系统的大量私有 API 博弈——每个 Android 大版本都要重新适配。
  • 现状:Android 9+ 限制隐藏 API(hiddenapi 黑名单),加上商店推送补丁的限制与性能/稳定性成本,纯插件化与当年那种"黑科技热修"在新项目中已极少作为首选。今天的稳健选择是"彻底组件化 + 少量跨端容器(H5/Flutter/RN)",把"动态下发"交给 Web 容器而不是 Hook 系统。

[!NOTE] 结论要明确:模块化(编译期)解决 90% 的问题,插件化(运行期)只在极特殊的合规/发版诉求下才值得。别为了酷而引入一套要与系统对抗的框架。

1.9 迁移路线:单体 → 分层 → 按特性拆

别指望一次性重构成功,推荐三段式,每段都可独立发布:

阶段 动作 收益 风险
第 1 段:分层(包级别) 在不建模块的前提下,按 ui/domain/data 整理包;统一 ViewModel + UiState 几乎零风险,立即获得可测试性 边界仍靠自觉
第 2 段:抽 core 把 model、common、network、database、designsystem 提成模块 编译提速,复用成型 资源 R 类、资源重复需处理
第 3 段:抽 feature 按业务边界一个个把屏幕搬进 :feature:xxx,用 :core:navigation-api 解耦跳转 团队并行、owner 清晰 迁移期存在新旧两套路径并存,需要统一的开关与清单管理

执行细则:

  • 先抽依赖最少的部分:core:model → core:common → core:designsystem → feature。
  • 一次一个 feature:搬完 :feature:feed 并稳定一周后再搬下一个,避免"全公司在同一个大 PR 里打架"。
  • git mv 而非复制:保留文件历史,方便 git log --follow 追问题。
  • UI 迁移可以分两步走:先把屏幕搬进 feature 模块(仍是 View/XML),再按第 16 章改 Compose。搬模块与换 UI 体系不要同时进行。

1.10 多模块下的依赖注入与测试装配

模块化把"谁认识谁"打散之后,两件事必须有统一答案,否则每个 feature 都会长出一套自己的做法。

其一是依赖注入(第 12 章)。 规则只有三条:

场景 Hilt 约定
数据源、Repository 这类单例 在数据所在的 core:* 模块里用 @InstallIn(SingletonComponent::class) 提供
feature 内部的实现 在该 feature 模块用 @InstallIn(ViewModelComponent::class) 或 Singleton 提供均可,尽量不外泄
跨 feature 的能力(如 FeedNavigator) 接口在 :core:navigation-api,实现与 @Binds 都在 :app

注意 Hilt 是编译期生成:你的 Singleton 图里必须包含实现了的那个模块(通常是 :app),否则 feature 单独编译时能通过 :feature:feed:assembleDebug,但打包 App 时报 missing binding。

其二是测试替身。 如果每个 feature 都写一份 FakeFeedRepository,很快就会出现三份行为不一致的 Fake。做法是统一放进 :core:testing:

// sample/core/testing/src/main/java/com/techfeed/core/testing/FakeFeedRepository.kt
package com.techfeed.core.testing

import com.techfeed.core.model.Article
import com.techfeed.feature.feed.domain.FeedRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow

/**
 * 全团队共用的假实现:放在 :core:testing,任何 feature 都能 testImplementation 引用。
 * 好处:所有单测面对的行为完全一致,改一次全部生效。
 */
class FakeFeedRepository : FeedRepository {
    private val backing = MutableStateFlow<List<Article>>(emptyList())
    override fun observeFeed(keyword: String): Flow<List<Article>> = backing
    override suspend fun refresh() = Unit

    /** 测试替身的操纵方法:让测试主动"喂"数据 */
    fun emit(articles: List<Article>) { backing.value = articles }
}

[!WARNING] :core:testing 里如果放了 Repository 的 Fake,它就必须依赖 :core:model(还可能依赖 feature 的接口),这会让 core 反向接触 feature。更干净的做法是把跨 feature 共享的契约再抽一层(如 :core:data-api),或者在各 feature 内部放自己的 Fake、只在 :core:testing 放通用工具(测试调度器规则、断言扩展、JUnit 扩展)。选型取决于你的 feature 接口共享程度,别教条。


二、示例代码

2.1 settings.gradle.kts:模块注册即架构

// sample/settings.gradle.kts
pluginManagement {
    repositories {
        google {
            content {
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories { google(); mavenCentral() }
}

rootProject.name = "TechFeed"

// 引入 build-logic:约定插件的工程(第 02 章)
includeBuild("build-logic")

// 类型安全的项目访问器:依赖写 projects.feature.feed 而不是字符串
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

include(":app")

// —— core:基础设施,横向被依赖 ——
include(":core:model")
include(":core:common")
include(":core:designsystem")
include(":core:network")
include(":core:database")
include(":core:datastore")
include(":core:navigation-api")
include(":core:crash")
include(":core:testing")

// —— feature:业务,彼此零依赖 ——
include(":feature:feed")
include(":feature:detail")
include(":feature:auth")
include(":feature:profile")
include(":feature:push")

// —— 基准与性能:Macrobenchmark 模块(com.android.test,targetProject = :app,第 17 章)——
include(":benchmark")

2.2 :core:navigation-api:路由契约(只有接口与常量)

// sample/core/navigation-api/src/main/java/com/techfeed/core/navigation/TechFeedRoute.kt
package com.techfeed.core.navigation

/**
 * 路由常量:所有跨 feature 跳转的"地址簿"。
 * 放在 navigation-api 里,谁都能引用,但不依赖任何具体页面。
 */
object TechFeedRoute {
    const val FEED = "techfeed://feed"
    const val DETAIL = "techfeed://detail/{articleId}"
    const val AUTH = "techfeed://auth"
    const val PROFILE = "techfeed://profile"

    fun detail(articleId: String) = "techfeed://detail/$articleId"
}
// sample/core/navigation-api/src/main/java/com/techfeed/core/navigation/FeedNavigator.kt
package com.techfeed.core.navigation

/**
 * 能力抽象:信息流页需要"打开详情""打开登录"这两个行为,
 * 但它不需要知道详情页里的 Activity/Composable 叫什么。
 */
interface FeedNavigator {
    fun openDetail(articleId: String)
    fun openAuth(reason: String)
}
// sample/core/navigation-api/build.gradle.kts
plugins {
    alias(libs.plugins.techfeed.android.library)  // 约定插件,见 2.4
    alias(libs.plugins.kotlin.android)
}

android { namespace = "com.techfeed.core.navigation" }

dependencies {
    // 只有 model 的类型会暴露在接口签名里才用 api
    api(projects.core.model)
    implementation(libs.androidx.core.ktx)
}

2.3 :feature:feed:只认接口,不认兄弟模块

// sample/feature/feed/build.gradle.kts
plugins {
    alias(libs.plugins.techfeed.android.feature)   // feature 约定插件:已含 Compose + Hilt
    alias(libs.plugins.kotlin.android)
}

android { namespace = "com.techfeed.feature.feed" }

dependencies {
    // ❌ 绝不出现:implementation(projects.feature.detail)
    // ✅ 只依赖 core 与路由契约
    implementation(projects.core.model)
    implementation(projects.core.common)
    implementation(projects.core.designsystem)
    implementation(projects.core.navigationApi)
    implementation(projects.core.network)

    implementation(libs.bundles.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.hilt.navigation.compose)

    // 测试期才需要的依赖:不进正式产物
    testImplementation(projects.core.testing)
    testImplementation(libs.bundles.testing.unit)
}
// sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedNavigatorBridge.kt
package com.techfeed.feature.feed.ui

import com.techfeed.core.navigation.FeedNavigator
import com.techfeed.feature.feed.domain.OpenDetailUseCase
import javax.inject.Inject

/**
 * ViewModel 通过注入的 FeedNavigator 发起跳转。
 * 编译期它只知道 core:navigation-api 里的接口,运行时由 :app 提供实现。
 */
class FeedNavigationHandler @Inject constructor(
    private val navigator: FeedNavigator,
) : OpenDetailUseCase {
    override fun invoke(articleId: String) = navigator.openDetail(articleId)
}

2.4 约定插件:让 15 个模块脚本保持一致

// sample/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies

/**
 * feature 模块的一键配置:Compose + Hilt + KSP + 统一 SDK 版本。
 * 收益:新增一个 feature 模块只需 3 行 plugin + namespace,不会有人漏配 compileSdk。
 */
class AndroidFeatureConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
                apply("org.jetbrains.kotlin.plugin.compose")
                apply("com.google.devtools.ksp")
                apply("com.google.dagger.hilt.android")
            }
            extensions.configure<LibraryExtension> {
                compileSdk = 37
                defaultConfig { minSdk = 26 }
                // 非传递 R 类:模块边界更清晰,编译也更快
                androidResources {  }
                buildFeatures { compose = true }
                testOptions { unitTests.isIncludeAndroidResources = true }
                packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
            }
            // 每个 feature 默认带上这一组基础依赖,避免逐个手写导致版本漂移
            dependencies {
                "implementation"(project(":core:common"))
                "implementation"(project(":core:model"))
                "implementation"(project(":core:designsystem"))
                "testImplementation"(project(":core:testing"))
            }
        }
    }
}
// sample/build-logic/convention/build.gradle.kts(节选)
// 约定插件自身的构建脚本:把插件 id 注册出来,模块脚本才能 alias
gradlePlugin {
    plugins {
        register("androidFeature") {
            id = "techfeed.android.feature"
            implementationClass = "AndroidFeatureConventionPlugin"
        }
    }
}

[!TIP] build-logic 里还可以放一个 TechFeedModuleRule 检查任务:禁止 feature:* 的构建脚本出现 projects.feature. 依赖,违反直接 fail CI。这比 Code Review 可靠一万倍。

2.5 :app:唯一知道全貌的装配层

// sample/app/src/main/java/com/techfeed/app/navigation/AppNavigator.kt
package com.techfeed.app.navigation

import android.content.Context
import android.content.Intent
import com.techfeed.core.navigation.FeedNavigator
import com.techfeed.core.navigation.TechFeedRoute
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject
import javax.inject.Singleton

/** 真正的实现:把抽象路由翻译成一次 Intent 跳转 */
@Singleton
class AppNavigator @Inject constructor(
    private val context: Context,   // 只有 :app 允许持有 Application Context 做跳转
) : FeedNavigator {

    override fun openDetail(articleId: String) {
        context.startActivity(
            Intent(Intent.ACTION_VIEW, android.net.Uri.parse(TechFeedRoute.detail(articleId))).apply {
                // 显式指定宿主 Activity,避免被其他 App 拦截
                setPackage(context.packageName)
                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            },
        )
    }

    override fun openAuth(reason: String) {
        context.startActivity(
            Intent(Intent.ACTION_VIEW, android.net.Uri.parse(TechFeedRoute.AUTH))
                .setPackage(context.packageName)
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
        )
    }
}

@Module
@InstallIn(SingletonComponent::class)
abstract class NavigatorModule {
    // 把实现绑到接口:feature 模块注入 FeedNavigator 时拿到的是 AppNavigator
    @Binds
    @Singleton
    abstract fun bindFeedNavigator(impl: AppNavigator): FeedNavigator
}

[!WARNING] 持有 Application Context(context.applicationContext)做 startActivity 是安全的(生命周期与应用一致),但绝对不要持有 Activity——那是第 17 章内存泄漏的主角。

2.6 路由落地之一:Compose 侧(Navigation Compose)

// sample/app/src/main/java/com/techfeed/app/navigation/TechFeedNavGraph.kt
package com.techfeed.app.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import com.techfeed.feature.detail.ui.DetailRoute      // 详情页的目的地入口 Composable
import com.techfeed.feature.feed.ui.FeedRoute

@Composable
fun TechFeedNavGraph() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = "techfeed://feed") {
        composable(
            route = "techfeed://feed",
            deepLinks = listOf(navDeepLink { uriPattern = "techfeed://feed" }),
        ) { FeedRoute() }

        composable(
            route = "techfeed://detail/{articleId}",   // 与 TechFeedRoute.DETAIL 保持一致
            deepLinks = listOf(navDeepLink { uriPattern = "techfeed://detail/{articleId}" }),
            arguments = listOf(androidx.navigation.navArgument("articleId") { type = androidx.navigation.NavType.StringType }),
        ) { DetailRoute() }
    }
}
// sample/feature/detail/src/main/java/com/techfeed/feature/detail/ui/DetailRoute.kt
package com.techfeed.feature.detail.ui

import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle

/**
 * 每个 feature 对外只暴露一个 Route 入口 Composable。
 * :app 的 NavHost 只认识这个函数,不需要知道详情页内部有几个 ViewModel。
 */
@Composable
fun DetailRoute(viewModel: DetailViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    DetailScreen(state = state, onAction = viewModel::onAction)
}

2.7 路由落地之二:View / Fragment 侧

存量 View 项目用同样一套 TechFeedRoute 常量,只是把 URI 交给 Activity 或 Fragment 处理:

// sample/app/src/main/java/com/techfeed/app/host/HostActivity.kt
package com.techfeed.app.host

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.commit
import com.techfeed.core.navigation.TechFeedRoute
import com.techfeed.feature.detail.ui.DetailFragment
import com.techfeed.feature.feed.ui.FeedFragment
import dagger.hilt.android.AndroidEntryPoint

/**
 * 单 Activity + 多 Fragment 的 View 版宿主。
 * 与 Compose 版共享同一份 TechFeedRoute 常量,后期双体系并存(第 16 章)也不会漂移。
 */
@AndroidEntryPoint
class HostActivity : AppCompatActivity(R.layout.activity_host) {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (savedInstanceState == null) dispatch(intent.dataString)
    }

    override fun onNewIntent(intent: android.content.Intent) {
        super.onNewIntent(intent)
        dispatch(intent.dataString)
    }

    private fun dispatch(uri: String?) {
        when {
            uri == null || uri.startsWith(TechFeedRoute.FEED) -> replace(FeedFragment(), "feed")
            uri.startsWith("techfeed://detail/") -> {
                val id = uri.removePrefix("techfeed://detail/")
                replace(DetailFragment.newInstance(id), "detail")
            }
            uri.startsWith(TechFeedRoute.AUTH) -> openAuthFallback()   // 路由模块尚未提供登录页时的降级处理
        }
    }

    private fun replace(fragment: androidx.fragment.app.Fragment, tag: String) {
        supportFragmentManager.commit {
            replace(R.id.container, fragment, tag)
            setReorderingAllowed(true)   // 优化 Fragment 事务,减少重复布局与过渡
        }
    }
}

[!NOTE] 双体系共用路由常量的好处在第 16 章会兑现:迁移期 Compose 屏与 Fragment 屏混跑时,跳转地址永远一致,不会出现"改了 Compose 路由忘了改 Intent"的经典事故。

2.8 循环依赖检测

# 1. 查看某个 feature 的完整编译依赖链,肉眼找环
./gradlew :feature:feed:dependencies --configuration debugCompileClasspath

# 2. 谁把无关的模块拉进来了?(最常见的隐式环来源)
./gradlew :app:dependencyInsight --dependency core-database --configuration debugRuntimeClasspath
// sample/build-logic/convention/src/main/kotlin/ModuleBoundaryCheckPlugin.kt
import org.gradle.api.Plugin
import org.gradle.api.Project

/**
 * 自定义边界检查:feature 之间禁止互相依赖,违反即构建失败。
 * 这是最便宜、最有效的架构护栏——比写文档有用得多。
 */
class ModuleBoundaryCheckPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        target.afterEvaluate {
            val isFeature = target.path.startsWith(":feature:")
            if (!isFeature) return@afterEvaluate
            target.configurations.configureEach {
                if (name.endsWith("Implementation") || name.endsWith("api")) {
                    dependencies.configureEach {
                        if (this is org.gradle.api.artifacts.ProjectDependency) {
                            val depPath = this.path
                            check(!(depPath.startsWith(":feature:") && depPath != target.path)) {
                                "禁止 feature 模块互相依赖:${target.path} -> $depPath,请改用 :core:navigation-api 路由。"
                            }
                        }
                    }
                }
            }
        }
    }
}

2.9 一个 feature 模块内部长什么样

模块边界定了之后,feature 内部仍遵循第 14 章的三层:

feature/feed/
├─ build.gradle.kts              # 只 apply 约定插件 + 声明本模块特有依赖
└─ src/
   ├─ main/
   │  ├─ AndroidManifest.xml     # 通常只剩一个空壳 application 节点
   │  ├─ java/com/techfeed/feature/feed/
   │  │  ├─ ui/                  # FeedRoute · FeedScreen · FeedViewModel · FeedUiState
   │  │  ├─ domain/              # GetFeedUseCase · FeedRepository 接口
   │  │  ├─ data/                # FeedRepositoryImpl · DTO · Entity · Mapper
   │  │  └─ di/                  # FeedDataModule(@Binds 接口到实现)
   │  └─ res/                    # 仅本模块用到的布局与资源(View 体系)
   └─ test/                      # 单元测试,用 :core:testing 提供的 Fake

[!NOTE] ui / domain / data 在这里是包而不是模块。要不要进一步拆成 :feature:feed-ui / :feature:feed-data?除非你的团队已经大到需要它(> 30 人同时改这个 feature),否则不要——每多一层模块,就多一份 R 类、Manifest 与 Hilt 装配成本。这里是"模块边界"与"团队并行"的平衡点,也是多数企业项目停下来的地方。


三、常见坑 ⚠️

现象 根因 解法
改一行代码仍编译 10 分钟 底层模块用了 api;未开 configuration cache / parallel 改回 implementation;gradle.properties 开 org.gradle.parallel=true、caching=true
Unresolved reference: R 开了 nonTransitiveRClass 后引用了别的模块的 R 引用资源时用本模块 R;跨模块资源统一走 :core:designsystem
资源重复打包,APK 变大 多个 feature 各自放同名资源也只是重复计数 公共资源下移到 :core:designsystem;开启资源去重检查
Manifest 合并失败 多个 AAR 声明了同名权限/主题 在 :app 的 Manifest 用 tools:replace / tools:node="remove" 显式裁决
feature 编译期看不到 AppNavigator 忘了 @InstallIn / @Binds 装配 检查 Hilt 模块是否在 :app 的 SingletonComponent 中(第 12 章)
feature 之间出现隐式依赖(ClassNotFoundException 只在运行时炸) 通过反射/字符串类名跨模块调用 编译期依赖 + 契约(navigator 接口),禁用字符串反射
循环依赖:Circular dependency between tasks A → B → A 抽公共下移 / 依赖倒置 / 事件解耦 / 合并模块
拆分后构建反而更慢 模块过碎(> 40 个)或每个模块都跑 KSP/注解处理器 合并碎片模块;KSP/ Room 只保留在少数数据模块
单元测试跑不起来:not mocked / 缺 kotlinx 规则 feature 未配置 Robolectric / 测试依赖 :core:testing 统一提供 test rules,feature 只引一行
迁移一半时两套代码同时存在,QA 分不清 没有标注"已迁移/未迁移"清单 用 ADR + feature 开关记录每个页面的迁移状态(第 22 章)
Hilt 在多模块下报 "missing binding" 接口与实现跨模块,实现方未参与当前 Singleton 图 把实现放在 :app 或专门的 -impl 模块并由 :app 依赖
资源在 feature 之间串味(改了 A 的资源,B 页面变色) 公共资源仍散落在多个模块,各有一份拷贝 颜色/圆角/字体一律下沉 :core:designsystem,feature 只允许声明私有资源前缀
需要鉴权的页面被直接跳进去了 鉴权逻辑散落在每个调用方 统一由 Navigator 实现内部做登录态检查与回跳(见 1.5)
独立调试模式(runAlone=true)下编译不过 Manifest 缺启动 Activity,或 Hilt 缺 @HiltAndroidApp 为独立模式准备一份 debug/ sourceSet 的 Manifest 与 Application
拆完模块后发布的产品做不到"按需裁剪" feature 之间仍有隐式依赖(SPI/反射/全局单例) 显式依赖 + 契约接口;用第 2.8 的边界检查插件卡住

四、调试技巧

  1. 可视化查看模块依赖图:
    ./gradlew :app:dependencies --configuration debugRuntimeClasspath | head -80
    
    配合第三方插件(com.autonomousapps.dependency-analysis)能直接输出"哪些依赖是多余的 / 应该改成 implementation"的报告。
  2. 只构建某个模块缩短反馈:./gradlew :feature:feed:assembleDebug,避免每次验证都打包整个 App。
  3. Build Analyzer(Build → Analyze Build Performance)看配置阶段与单个 Task 的耗时,判断是环、还是 KSP 拖后腿。
  4. 人工找环最快的方法:在 Android Studio 的 Project Structure → Dependencies 里按模块逐个查看,feature 行里出现另一个 feature 就是违规。
  5. 用 --scan 生成构建扫描(企业内网可用 Develocity 自建),一次看清全模块的任务拓扑与缓存命中率。
  6. 验证 API/Impl 分离是否真的生效:临时在 :feature:feed 里 import com.techfeed.feature.detail...,如果能编译通过,说明边界检查没生效,回去补 2.8 的插件。
  7. Manifest 合并冲突定位:打开 app/build/outputs/logs/manifest-merger-debug-report.txt,里面逐条列出了每个冲突来源模块。

五、最佳实践

  1. 基础设施按层拆、业务按特性拆,feature 内部保留 ui/domain/data 三层包结构。
  2. feature 之间零依赖是硬约束:用 :core:navigation-api 承载跳转契约,并用 CI 插件机器校验。
  3. 默认 implementation:只有当依赖类型出现在你的公开签名(返回值/参数/父类)时才用 api。
  4. 用约定插件收口 Gradle 配置:compileSdk、JVM、Compose、Hilt 各配一处,杜绝 15 份脚本漂移。
  5. ** :core:model 必须是纯 Kotlin**:不依赖 Android 与任何框架,它是全工程的"通用语言"。
  6. 版本号一律走 libs.versions.toml:模块脚本里出现硬编码版本号视为 CR 不通过。
  7. 每个模块配 OWNER 与 README:说明职责、对外 API、禁止依赖谁(第 22 章的团队协作会展开)。
  8. 先薄后厚:不要预先拆 30 个模块,等业务真的体现出边界再拆,迁移成本远低于纠偏成本。

推荐 / 避免对照表:

推荐 ✅ 避免 ❌
feature 通过 Navigator 接口跳转 feature 直接 startActivity(DetailActivity::class.java)
跨模块共享模型放 :core:model 每个 feature 复制一份 Article 数据类
公共资源与组件放 :core:designsystem 五个 feature 各自定义同名 primary_color
业务逻辑内联在 feature 模块里 为"将来可能复用"提前抽 20 个模块
KSP / Room 只集中在少数数据模块 每个模块都跑一遍 Room / Hilt 注解处理器
依赖方向清晰(app → feature → core) core 反向依赖 feature(上帝类模式)
逐步迁移,一个 feature 一个 PR 一次性大重构,几百个文件冲突三天

六、本章小结

  1. 模块化的四条收益是编译速度、团队并行、复用与边界;其中最有价值的是"模块图就是可机器校验的架构图"。
  2. 混合切法:基础设施(:core:*)按层拆,业务(:feature:*)按特性拆;分层管依赖方向,模块化管编译边界。
  3. 依赖倒置 + API/Impl 分离解决了"feature 想互相跳转"与"feature 必须零依赖"的矛盾,:core:navigation-api 是关键枢纽。
  4. api vs implementation 直接决定构建速度,约定插件与 libs.versions.toml 是多模块不失控的两把锁;环依赖靠"下移公共 + 依赖倒置 + 事件解耦 + 必要时合并"治理。
  5. 动态特性与插件化要知道但慎用了:国内现状是"彻底组件化 + 少量跨端容器",别为一时之快引入与系统对抗的框架。

到目前为止,我们假定每个 feature 内部只有一套 UI 体系。但真实项目里,同一个 App 往往 Compose 屏与 View 屏并存——它们如何互相嵌入、主题如何统一、怎么做到一次迁移不回归?

下一章:第 16 章 Compose 与 View 互操作 —— 用 ComposeView 与 AndroidView 打通两套管体系,并给出自底向上的渐进迁移路线。


七、动手练习

基础题

  1. 在现有工程里新建一个 :feature:profile 模块(应用 2.4 的约定插件),让它只依赖 :core:designsystem 与 :core:navigation-api,验证它无法引用 :feature:feed 的类。
  2. 在 :core:navigation-api 中新增 ProfileNavigator 接口(openProfile(userId: String)),在 :app 中实现并 @Binds 装配,然后在 :feature:feed 里注入调用完成跳转。
  3. 把两个 feature 里重复的一份颜色/圆角资源下移到 :core:designsystem,用 ./gradlew lint 确认没有资源重复告警。

进阶题

  1. 实现 2.8 的 ModuleBoundaryCheckPlugin:先在两个 feature 之间故意加一条依赖,确认构建失败;删除依赖后恢复通过。把它挂到 CI 上(第 20 章)。
  2. 现有 :feature:auth 需要跳 :feature:profile(登录成功回个人页),同时 :feature:profile 又需要拉起登录。请给出不产生环的设计:说明契约放哪个模块、谁实现、调用方怎么写。
  3. 用 ./gradlew :app:dependencyInsight --dependency room-runtime 找出 Room 被哪些模块引入,判断是否存在"feature 直接依赖 Room"的越界情况,并给出修正后的依赖表。

思考题

  1. 一个 30 模块的工程,如果为了编译速度把所有 core 依赖都改成 api,会发生什么?反过来全用 implementation 时,哪些场景会编译不过?请给出你的决策流程。
  2. 什么条件下你会考虑引入动态特性模块或插件化?如果公司的分发渠道全在国内、也没有月ota热修诉求,你的答案会变吗?为什么?
  3. 单体 → 分层 → 按特性拆这条迁移路线中,哪一段最容易失败?如果让你给一个 15 人团队排期,你会如何设置"可回滚的检查点"?

📄 第 16 章 Compose 与 View 互操作 16-compose-view-interop.md

阶段:企业级 | 预计阅读 50 min | 代码落地模块::core:designsystem、:feature:feed 前置:第 05 章 Compose 基础 | 第 15 章 模块化 相关章节:第 17 章 性能优化 | 第 24 章 TechFeed 信息流

学习目标

  • [ ] 会用 ComposeView 把 Compose 组件嵌入 XML/Fragment 页面,也会用 AndroidView 把 View 嵌入 Compose。
  • [ ] 理解互操作中的状态同步方向与生命周期陷阱(回收、dispose、保存状态)。
  • [ ] 能制定一套渐进式迁移策略:先迁什么、后迁什么、如何验证不回归。
  • [ ] 会处理高频互操作场景:地图、WebView、广告 SDK、相机预览等"必须用 View"的组件。
  • [ ] 知道互操作的性能代价与规避方法。

一、核心概念

1.1 为什么需要互操作

现实中的存量项目不可能一夜重写。迁移期会长期处于双体系共存状态:

场景 需要的桥
存量 XML 页面里新加一块 Compose 卡片 ComposeView
Compose 页面里嵌 WebView / 地图 / 广告 SDK AndroidView
迁移验证:新写法截图与旧页面比对 两端共享主题与设计令牌(第 07 章)
flowchart LR
    subgraph Host1["View 世界(Activity/Fragment)"]
        X["XML 布局"] --> CV["ComposeView<br/>(普通 View 子类)"]
    end
    CV -.-> C["Compose 组合树"]

    subgraph Host2["Compose 世界"]
        CC["@Composable"] --> AV["AndroidView(factory = ...)<br/>(一个 Composable)"]
    end
    AV -.-> V["View 对象树"]

1.2 两条铁律

  1. 状态只有一个所有者:互操作边界上,要么状态提升到 Compose(mutableStateOf),要么提升到 ViewModel,绝不两边各存一份。
  2. 生命周期以宿主为准:ComposeView 是 View,遵守 View 体系生命周期;AndroidView 是 Composable,遵守组合生命周期。边界处的回收/重建最易出 bug。

二、示例代码

2.1 在 XML / Fragment 中嵌入 Compose

// 文件路径:docs/samples/ch16/FeedFragment.kt
package com.techfeed.feature.feed.ui.view

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.techfeed.core.designsystem.theme.TechFeedTheme
import com.techfeed.feature.feed.databinding.FragmentFeedBinding
import com.techfeed.feature.feed.ui.FeedList
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

@AndroidEntryPoint
class FeedFragment : Fragment() {

    private var _binding: FragmentFeedBinding? = null
    private val binding get() = _binding!!
    private val viewModel: FeedViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?,
    ): View {
        _binding = FragmentFeedBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.composeContainer.apply {
            // ⭐ 关键配置:View 从窗口分离(onViewDetachedFromWindow)时立刻释放组合树。
            // 默认策略是延迟到 View 被回收(POOL 时机),在 RecyclerView 复用场景会出问题
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)

            setContent {
                TechFeedTheme {   // View 宿主里必须手动套主题,Compose 不会继承 XML 主题
                    val state by viewModel.uiState.collectAsStateWithLifecycle()
                    FeedList(
                        articles = state.articles,
                        onArticleClick = viewModel::openArticle,
                    )
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}
<!-- 文件路径:docs/samples/ch16/fragment_feed.xml -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- 旧的工具栏仍是 View -->
    <com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content">
        <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" />
    </com.google.android.material.appbar.AppBarLayout>

    <!-- 新的列表是 Compose:迁移即"一块一块换" -->
    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        xmlns:app="http://schemas.android.com/apk/res-auto" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

2.2 在 Compose 中嵌入 View

// 文件路径:docs/samples/ch16/WebViewHolder.kt
package com.techfeed.core.designsystem.interop

import android.annotation.SuppressLint
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView

/**
 * Compose 中嵌入 WebView(地图、广告 SDK、相机预览同理)。
 * 三个关键点:
 *  1. factory 只做"创建",不做配置,保证重建复用逻辑清晰;
 *  2. update 块响应状态变化,每次重组都可能调用,要幂等;
 *  3. onRelease 做清理(WebView 必须显式销毁,否则泄漏 native 内存)。
 */
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ArticleWebView(url: String, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    // remember:重组间复用同一个 WebView,避免反复创建
    val webView = remember {
        WebView(context).apply {
            settings.javaScriptEnabled = true
            webViewClient = WebViewClient()
        }
    }

    AndroidView(
        factory = { webView },                       // 返回已配置好的实例
        modifier = modifier.fillMaxSize(),
        update = { view ->
            // 状态 → View 的单向同步:url 变化才重新加载,幂等
            if (view.url != url) view.loadUrl(url)
        },
        onRelease = { view ->
            view.apply {
                stopLoading()
                loadDataWithBaseURL(null, "", "text/html", "utf-8", null)
                clearHistory()
                (view.parent as? android.view.ViewGroup)?.removeView(view)
                destroy()
            }
        },
    )
}

工厂 + 更新的职责划分(互操作最容易写乱的地方):

AndroidView(
    // factory:创建一次(remember 缓存时甚至可能不重建)
    factory = { context -> CustomTextView(context) },
    // update:每次重组调用 —— 只放"状态 → 属性"的同步,禁止创建对象/注册监听
    update = { view -> view.text = value },
    // onReset / onRelease:配合 RecyclePool 复用与释放(1.5+)
)

2.3 状态同步:LiveData/Flow ↔ Compose 状态

// 文件路径:docs/samples/ch16/StateBridge.kt
// View 侧观察 Compose 状态(少见,但迁移期需要)
composeView.setContent {
    val selected by remember { mutableStateOf(false) }
    // 用 SideEffect 把 Compose 状态"推"回 View 世界
    SideEffect {
        (composeView.tag as? StateSink)?.onSelected(selected)
    }
}

// Compose 侧观察 View 世界的事件(如广告 SDK 回调)
@Composable
fun AdBanner(adView: AdView) {
    var adLoaded by remember { mutableStateOf(false) }
    AndroidView(
        factory = { adView.apply { adListener = object : AdListener() {
            override fun onAdLoaded() { adLoaded = true }   // View 回调 → Compose 状态
        } } },
        update = { /* 幂等同步 */ },
    )
}

2.4 主题桥接

互操作时最容易被忽略的是主题不通。第 07 章的设计令牌在这里兑现价值:

// 文件路径:docs/samples/ch16/MdcTheme.kt
// 思路:从 XML 主题读取属性值,转换成 Compose 的 ColorScheme(MDC-Compose 的简化版)
@Composable
fun BridgedTheme(content: @Composable () -> Unit) {
    val context = LocalContext.current
    val typedValue = TypedValue()
    // 从当前 View 主题解析 colorPrimary —— XML 与 Compose 共用同一份令牌
    context.theme.resolveAttribute(R.attr.colorPrimary, typedValue, true)
    val primary = Color(typedValue.data)

    MaterialTheme(
        colorScheme = lightColorScheme(primary = primary),
        content = content,
    )
}

[!TIP] 更工程化的做法是引入 com.google.android.material:material 官方的 MDC-Compose Theme Adapter(MdcTheme), 自动把 XML 主题完整映射为 Compose 主题。自研桥接仅适合令牌极少的项目。

2.5 RecyclerView 中嵌入 Compose 的正确姿势

// 文件路径:docs/samples/ch16/ComposeItemAdapter.kt
package com.techfeed.feature.feed.ui.view

import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.recyclerview.widget.RecyclerView
import com.techfeed.core.designsystem.theme.TechFeedTheme

/**
 * RecyclerView 的 item 用 Compose 渲染(迁移期常用)。
 * ⭐ 必须用 DisposeOnDetachedFromWindowOrReleasedFromPool:
 *   默认策略下,被回收进 pool 的 ComposeView 仍持有组合树,
 *   复用旧 item 时会出现"旧内容闪现"甚至内存泄漏。
 */
class ComposeItemViewHolder(private val composeView: ComposeView) :
    RecyclerView.ViewHolder(composeView) {

    fun bind(article: Article, onClick: (String) -> Unit) {
        composeView.setContent {
            TechFeedTheme {
                ArticleListItem(article = article, onClick = { onClick(article.id) })
            }
        }
    }

    companion object {
        fun create(parent: ViewGroup): ComposeItemViewHolder {
            val view = ComposeView(parent.context).apply {
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT,
                )
                setViewCompositionStrategy(
                    ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
                )
            }
            return ComposeItemViewHolder(view)
        }
    }
}

[!WARNING] RecyclerView 嵌 Compose 的性能低于纯 LazyColumn(多一层 View 测量与回收协议)。 它是迁移期的过渡手段,不是终态。整个列表迁 Compose 时优先整体替换为 LazyColumn。


三、常见坑 ⚠️

现象 根因 解法
ComposeView 内容显示为旧数据 复用池里的 View 未释放组合树 DisposeOnDetachedFromWindowOrReleasedFromPool
嵌入的 WebView/地图黑屏或消失 在 AndroidView.update 里重新创建/添加子 View factory 只创建,update 只做属性同步
Compose 嵌入页主题不对(颜色错) Compose 不继承 XML 主题 手动套 TechFeedTheme 或用 MdcTheme 桥接
AndroidView 内点击不生效 父级拦截了触摸(如 clickable 修饰符在外层) 去掉外层 clickable;或调整 Modifier 顺序
迁移后返回页面内容丢失 ComposeView 未保存 UI 状态 rememberSaveable;Fragment 场景确认 SavedStateRegistry 可用
内存持续增长 AndroidView 的 onRelease 没清理(WebView 尤甚) 实现 onRelease:stopLoading + destroy
嵌入组件尺寸异常 Compose 的 Modifier.fillMaxSize() 与 View 的 LayoutParams 冲突 明确父容器约束;AndroidView 外层给固定尺寸
状态"两边都改"导致回环刷新 互操作边界双向写状态 单向:状态只有一个所有者,另一端只监听
软键盘弹出后嵌入 View 错位 adjustResize 下 Compose 与 View 的 insets 处理不一致 统一用 WindowInsetsCompat,Compose 用 imePadding()

四、调试技巧

  1. 确认组合策略生效:在 setViewCompositionStrategy 处打断点,观察回收复用时机是否与预期一致。
  2. Layout Inspector 双树观察:互操作页面会同时显示 View 树(含 ComposeView)与 Compose 组合树,检查两边状态是否一致。
  3. 状态回环检测:怀疑双向同步时,在边界两端各加日志打印状态值与时间戳,回环刷新会呈现"乒乓"曲线。
  4. WebView 内存:Profiler 里过滤 WebView 实例个数,离开页面后应为 0(配合 onRelease)。
  5. 性能基线对比:迁移前后用 Macrobenchmark 各跑一轮(第 17 章),用数据决定迁移节奏。

五、最佳实践

  1. 迁移策略自底向上:叶子组件(按钮、卡片)→ 业务组件 → 整屏 → 移除 Fragment 容器;每步都可独立验证与回滚。
  2. 边界窄、桥面小:互操作只发生在"组件替换"处,不要在互操作层写业务逻辑。
  3. 单一状态所有者:跨边界状态一律放 ViewModel,两端只是观察者。
  4. factory / update / onRelease 职责分离:创建、同步、清理各就各位,update 保持幂等。
  5. 主题桥接一次做对:设计令牌统一生成两端主题(第 07 章),杜绝"嵌入块颜色不一样"。
  6. 高成本 View(WebView/地图)用 remember 复用,并实现完整释放。
  7. 新模块全面 Compose,存量模块按"改到哪迁到哪"推进,避免为迁移而迁移。
  8. 每步迁移有验收:截图对比 + 冒烟测试 + 性能基线,三关全过才合入(第 21、22 章)。
旧写法对照:Fragment 之间互相嵌套

迁移前,很多项目用"Fragment 嵌 Fragment"实现区块复用(childFragmentManager)——生命周期错综复杂, 事务嵌套极易崩溃。互操作出现后,这类场景的正确演进路径是:

Fragment 嵌 Fragment(旧)
  → 单 Fragment + 内部区块组件化(View/Compose 均可)
  → Compose 区块 + LazyColumn 组合(目标态)

Fragment 不再是"区块容器",只承担"页面容器"职责;区块复用交给组件层。


六、本章小结

  1. 互操作的两座桥:ComposeView(View 世界嵌 Compose)与 AndroidView(Compose 世界嵌 View)。
  2. 两条铁律:状态单一所有者、生命周期以宿主为准。
  3. AndroidView 三件套:factory(创建)、update(幂等同步)、onRelease(清理)。
  4. ComposeView 在复用容器(RecyclerView/ViewPager)中必须用 DisposeOnDetachedFromWindowOrReleasedFromPool。
  5. 迁移自底向上、每步可验证;主题靠设计令牌桥接,性能靠基线数据说话。

下一章:互操作只是企业级的入口。真正的硬仗是性能——启动、渲染、内存、包体与 ANR 的系统治理。


七、动手练习

基础题

  1. 在一个 XML 页面里嵌入第 05 章的 ArticleHeader 组件,并验证深浅色切换正常(提示:主题桥接)。
  2. 用 AndroidView 在 Compose 页面中嵌入一个 EditText,实现双向同步:Compose 输入框与 EditText 内容一致。
  3. 给第 2 题的实现加上 onRelease 清理与焦点恢复,旋转屏幕后焦点回到原输入框。

进阶题 4. 把第 06 章的 RecyclerView 列表替换为"RecyclerView 嵌 ComposeView"的过渡版本,再用 LazyColumn 整体替换,用 Macrobenchmark 对比两者的滚动帧率。 5. 设计一个"迁移看板":列出 TechFeed 全部页面与组件,标注 View/Compose 状态与迁移顺序,并给出每一步的验证标准(可放 ADR,第 22 章)。

思考题 6. 为什么 AndroidView.update 要求幂等?举一个"不幂等导致 bug"的具体场景。 7. ViewCompositionStrategy 的默认策略在 RecyclerView 中为什么有问题?DisposeOnDetachedFromWindow 与 ...OrReleasedFromPool 的差异是什么? 8. 互操作边界上,SavedStateHandle(ViewModel)与 rememberSaveable(组合)各能恢复什么?两者叠加时谁先恢复?

📄 第 17 章 性能优化 17-performance-optimization.md

阶段:企业级 | 预计阅读 80 min | 代码落地模块::app、:core:*、:feature:* 前置:第 05 章 Compose 基础 | 第 14 章 分层架构 | 第 16 章 互操作 相关章节:第 18 章 稳定性与崩溃监控 | 第 20 章 构建与发布

学习目标

  • [ ] 会定义与度量核心性能指标:冷启动、卡顿率(掉帧)、内存、包体积、ANR 率。
  • [ ] 掌握启动优化闭环:测量 → 定位(启动耗时拆解)→ 治理(延迟初始化、App Startup、Baseline Profile)→ 防劣化。
  • [ ] 理解渲染管线(VSync/Choreographer)与掉帧根因,会排查 View 体系的过度绘制与 Compose 的重组风暴。
  • [ ] 能定位内存泄漏(LeakCanary + Profiler),理解图片内存模型与 onTrimMemory。
  • [ ] 会治理 ANR:主线程红线、StrictMode、trace 分析与 WatchDog 思路。
  • [ ] 建立包体积预算与 CI 防劣化卡口。

一、核心概念

1.1 性能指标体系

没有指标就没有优化。先定口径,再谈优化:

指标 定义 行业参考线
冷启动(Cold Start) 进程不存在 → 首帧可见 → 可交互 TTID(首帧)< 500ms,TTFD(可交互)< 2s
卡顿率 掉帧超过阈值(如 >700ms 冻帧)的会话占比 < 0.5%
帧率 流畅页面平均帧间隔 60/90/120Hz 下 P95 帧时间不超预算
内存 PSS 峰值、泄漏对象数 无 Leaked 实例;后台被杀率(LMK)低
ANR 率 ANR 会话 / 总会话 Google Play 红线 0.47%,内部 < 0.1%
包体积 APK/AAB 下载体积 每增长 6MB,下载转化率约降 1%
耗电/流量 后台唤醒次数、冗余请求 见 第 13 章

[!IMPORTANT] 先建立测量,再动手优化。没有线上的指标采集与基线,"优化"很快退化成"玄学 + 回归"。

1.2 启动阶段拆解

flowchart LR
    A["点击图标"] --> B["进程创建<br/>Zygote fork"]
    B --> C["Application.onCreate<br/>⚠️ 重灾区"]
    C --> D["Activity onCreate/onStart/onResume"]
    D --> E["首帧绘制 TTID<br/>布局 + 测量 + 绘制"]
    E --> F["首屏数据就绪 TTFD"]
    F --> G["用户可交互"]

启动耗时的大头几乎总在这四处:

  1. Application.onCreate 里的同步初始化(SDK 一锅端)。
  2. 首个 Activity 的布局过深 / onCreate 做磁盘 I/O。
  3. 首屏接口串行请求。
  4. 多进程/多 dex(旧机制)带来的类加载开销。

1.3 渲染与掉帧

  • VSync 信号驱动渲染,60Hz 屏幕每 16.6ms 一帧;90Hz/120Hz 对应 11.1ms/8.3ms。
  • 主线程一帧内没做完 measure/layout/draw(或 Compose 的组合/布局/绘制),就掉帧。
  • View 体系的两个经典病灶:过度绘制(同一像素被画多次)与层级过深(递归测量)。
  • Compose 体系的对应病灶:重组风暴(不必要的大范围重组)与不稳定参数。

1.4 Compose 重组与稳定性

Compose 通过比较参数决定跳过(skip)还是重组。可跳过的前提是参数"稳定":

类型 稳定性
基本类型、String、函数类型 ✅ 稳定
全 val 属性、且属性类型都稳定的 data class ✅ 稳定(推断得出)
List / Map / MutableState 泛型接口 ⚠️ 默认不稳定(实现可变)
来自 Java 的类、含 var 的类 ❌ 不稳定

不稳定参数会导致每次重组都重跑该函数——即使数据没变。

1.5 ANR 的机理

主线程被阻塞超过阈值,系统弹"应用无响应":

场景 阈值
输入事件 5 秒无响应 5s
前台 Service 20 秒 20s
广播前台 10 秒 / 后台 60 秒 10s/60s
ContentProvider 发布超时 10s

根因 90% 是主线程做了不该做的事:磁盘 I/O、锁等待、Binder 同步调用、大量 JSON 解析、GC 风暴。


二、示例代码

2.1 启动治理:App Startup 统一初始化

// 文件路径:docs/samples/ch17/CrashInitializer.kt
package com.techfeed.app.startup

import android.content.Context
import androidx.startup.Initializer
import com.techfeed.core.crash.CrashReporter

/**
 * 崩溃采集必须在一切业务代码之前就绪(否则启动期崩溃采不到),
 * 但网络、图片、推送等 SDK 必须延迟 —— 用 Initializer 声明依赖关系。
 */
class CrashInitializer : Initializer<CrashReporter> {
    override fun create(context: Context): CrashReporter =
        CrashReporter.install(context)   // 纯本地操作,微秒级

    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
// 文件路径:docs/samples/ch17/CoilInitializer.kt
package com.techfeed.app.startup

import android.content.Context
import androidx.startup.Initializer

class CoilInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        // 图片库只做最轻配置;磁盘缓存在后台线程初始化
    }
    override fun dependencies() = listOf(CrashInitializer::class.java)   // 声明依赖顺序
}
<!-- 文件路径:sample/app/src/main/AndroidManifest.xml(节选) -->
<!-- 移除组件的自动初始化:App Startup 没有"XML 配置文件"这种机制,
     正确做法是在 Manifest 里覆盖 InitializationProvider,对具体条目声明 tools:node="remove"。
     下例关闭 WorkManager 的默认初始化,改为 Application 实现 Configuration.Provider 按需初始化。 -->
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <meta-data
        android:name="androidx.work.WorkManagerInitializer"
        android:value="androidx.startup"
        tools:node="remove" />
</provider>
// 文件路径:sample/app/src/main/java/com/techfeed/app/TechFeedApplication.kt
package com.techfeed.app

import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.techfeed.core.crash.CrashReporter
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

@HiltAndroidApp
class TechFeedApplication : Application(), Configuration.Provider {

    @Inject lateinit var workerFactory: HiltWorkerFactory

    override fun onCreate() {
        super.onCreate()
        // ⭐ Application.onCreate 的红线:只做"必须同步做"的事(崩溃采集、StrictMode)
        // 其余一切初始化(网络预热、推送注册、数据库迁移检查)延迟到首帧之后或按需触发
        if (BuildConfig.DEBUG) {
            enableStrictMode()   // 见 2.4
        }
    }

    override val workManagerConfiguration: Configuration
        get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
}

延迟初始化的标准姿势——首帧后再干活(示例为教学演示,完整接线见第 26 章的周期同步):

// 延迟初始化:用生命周期回调把非紧急初始化挪到首帧之后,不阻塞启动
fun Application.initAfterFirstFrame(activity: androidx.activity.ComponentActivity) {
    activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
        override fun onResume(owner: LifecycleOwner) {
            // 推送注册、预拉取配置、图片磁盘缓存预热等非紧急初始化放这里
            activity.lifecycle.removeObserver(this)   // 只执行一次,避免重复触发
        }
    })
}

Baseline Profile:把"启动路径上的代码"预编译成机器码,避免启动期 JIT:

# 生成基线配置(Macrobenchmark 模块)
./gradlew :benchmark:pixel6Api33BaselineProfileProfileGenerator

# 产物纳入 app 模块后,冷启动通常可再提 20-30%

2.2 渲染:View 体系压平与 Compose 重组优化

View 体系:布局压平示例

<!-- 反例:三层嵌套才能实现的布局 -->
<LinearLayout orientation="vertical">
    <LinearLayout orientation="horizontal">
        <TextView/><TextView/>
    </LinearLayout>
</LinearLayout>

<!-- 正例:一层 ConstraintLayout 搞定 -->
<androidx.constraintlayout.widget.ConstraintLayout>
    <!-- 约束直接表达关系,测量从 O(嵌套深度) 降为 O(1) -->
</androidx.constraintlayout.widget.ConstraintLayout>

Compose 体系:避免重组风暴的三板斧

// 文件路径:docs/samples/ch17/FeedList.kt(优化版)

/** ① 状态类全部不可变 + 稳定:让编译器能推断"可跳过" */
@Immutable
data class FeedUiState(
    val articles: List<Article> = emptyList(),
    val query: String = "",
    val isLoading: Boolean = false,
)

@Composable
fun FeedList(state: FeedUiState, onArticleClick: (String) -> Unit) {
    LazyColumn {
        items(
            items = state.articles,
            key = { it.id },              // ② 稳定 key:数据变化只影响对应 item
            contentType = { "article" },
        ) { article ->
            // ③ 最小参数:只传 article 与回调,不传整个 state
            //    query 变化时,item 函数因参数未变而被跳过
            ArticleListItem(article, onClick = { onArticleClick(article.id) })
        }
    }
}

/** 回调也用 remember 缓存(或传递方法引用),避免不稳定 lambda 导致 item 失去跳过资格 */

派生状态减少重组:

// 高频读取的派生值用 derivedStateOf:只有结果变化才触发下游重组
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
AnimatedVisibility(visible = showScrollToTop) { ScrollToTopButton() }

2.3 内存:泄漏定位与图片治理

// 教学示例(未随 sample 工程落地,节选):LeakCanary 只进 debug 包,release 自动移除(no-op 依赖)
// LeakCanary 只进 debug 包,release 自动移除(no-op 依赖)
// debugImplementation(libs.leakcanary)

五类高频泄漏与修法:

泄漏源 为什么漏 修法
静态变量持有 Context/View 生命周期远长于页面 持 applicationContext;或 WeakReference
未注销的监听/广播/协程 注册与注销不对称 DisposableEffect、repeatOnLifecycle、LifecycleObserver 配对
内部类持有外部 非静态内部类隐式持有外部实例 改静态 + 弱引用;Kotlin lambda 默认不持有,注意显式 this@Activity
Handler 延迟消息 Message 持有 Handler → Activity onDestroy 里 removeCallbacksAndMessages(null)
Fragment binding Fragment 比 View 活得久 onDestroyView 置空(第 03 章)
// 图片内存治理:Coil 请求精确尺寸 + 合理缓存策略
imageView.load(url) {
    size(300, 200)              // ⭐ 按显示尺寸请求,不让 4000px 原图进内存
    crossfade(true)
    memoryCachePolicy(CachePolicy.ENABLED)
}

// 响应系统内存信号,主动释放可重建缓存
override fun onTrimMemory(level: Int) {
    super.onTrimMemory(level)
    if (level >= TRIM_MEMORY_RUNNING_LOW) {
        imageLoader.memoryCache?.clear()
    }
}

[!TIP] 一张 4000×3000 的 ARGB_8888 图 = 48MB 内存。列表页秒级 OOM 的头号元凶就是"不指定尺寸加载大图"。

2.4 ANR:主线程红线与 StrictMode

// 文件路径:docs/samples/ch17/StrictModeSetup.kt
package com.techfeed.app

import android.os.StrictMode

/** debug 包开启主线程违规检测:磁盘 I/O 与网络一律视为错误 */
fun Application.enableStrictMode() {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .detectCustomSlowCalls()
            .penaltyLog()            // 打印违规日志(可再加 penaltyDeath 让 CI 冒烟失败)
            .build()
    )
    StrictMode.setVmPolicy(
        StrictMode.VmPolicy.Builder()
            .detectLeakedClosableObjects()
            .detectActivityLeaks()
            .detectLeakedSqlLiteObjects()
            .penaltyLog()
            .build()
    )
}
// 主线程红线自查清单(CR 时逐条对照):
// ❌ SharedPreferences.commit()(同步写盘)→ 用 edit { } (KTX 扩展,异步 apply)
// ❌ JSON 在主线程解析大对象 → 移入 Dispatchers.Default
// ❌ 数据库同步查询(Room suspend/Flow 之外)→ 全部协程化
// ❌ Bitmap decode / 采样在主线程 → Coil 异步
// ❌ 同步 Binder 调用高频执行 → 缓存结果或子线程

2.5 包体积治理

// 文件路径:sample/app/build.gradle.kts(节选)
android {
    buildTypes {
        release {
            isMinifyEnabled = true        // R8:代码压缩+混淆+优化
            isShrinkResources = true      // 资源压缩:删除未引用资源
            // ABI 拆分:armeabi-v7a 与 arm64-v8a 各出一份,单包体积近乎减半
            // (配合分发渠道按机型下发,见第 20 章)
        }
    }
    splits {
        abi {
            isEnable = true
            reset()
            include("arm64-v8a", "armeabi-v7a")
            isUniversalApk = false        // 不再出全量包
        }
    }
}

体积预算清单(团队约定 + CI 卡口):

项 预算 手段
基础包 ≤ 8MB R8 全开、资源压缩、无冗余 so
图片资源 ≤ 1.5MB 矢量图 + WebP(第 07 章)
第三方 SDK 新增 >300KB 需评审 用"按需/精简版",警惕全家桶 SDK
so 库 只保留 arm64 + v7a 排除 x86;动态下发大 so
每次发版 体积涨幅 >2% 需说明 CI 对比上个版本体积并卡口

2.6 度量:Macrobenchmark 与 JankStats

Macrobenchmark 模块(:benchmark)必须使用 com.android.test 插件(而非 com.android.library)——targetProjectPath 是它专属的扩展属性,模块自身作为 instrumentation 测试注入被测 App:

// 文件路径:sample/benchmark/build.gradle.kts(节选)
plugins {
    alias(libs.plugins.android.test)   // ⚠️ com.android.test,不是 com.android.library
    alias(libs.plugins.kotlin.android)
}

android {
    namespace = "com.techfeed.benchmark"
    defaultConfig {
        // :app 有 env × channel 两个 flavor 维度,benchmark 模块没有,
        // 必须声明 missingDimensionStrategy 才能完成变体匹配
        missingDimensionStrategy("env", "prod")
        missingDimensionStrategy("channel", "store")
    }
    // 被测目标:benchmark 模块作为 instrumentation 测试注入 :app
    targetProjectPath = ":app"
}
// Macrobenchmark 用例(sample/benchmark 模块的典型用例写法)
package com.techfeed.benchmark

import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
 * 宏基准:测"用户可感知"的启动指标(不完全等价于线上,但可横向对比优化前后)。
 * 优化决策必须基于同一基线下的对照数据。
 */
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun coldStartup() = benchmarkRule.measureRepeated(
        packageName = "com.techfeed.app",
        metrics = listOf(StartupTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.COLD,            // 每次冷启动
        compilationMode = CompilationMode.Partial(  // 模拟 Baseline Profile 生效
            baselineProfileMode = androidx.benchmark.macro.BaselineProfileMode.Require,
        ),
    ) {
        pressHome()
        startActivityAndWait()
        // 等待首屏内容出现(用 view/waitForContent 描述"首帧就绪")
        device.waitForIdle()
    }
}
// 线上帧率采集:JankStats(发布到灰度包,量化真实卡顿率)
// 教学示例(未随 sample 工程落地):线上帧率采集 JankStats(发布到灰度包,量化真实卡顿率)
val jankStats = JankStats.createAndTrack(window, OnFrameListener { frameData ->
    if (frameData.isJank) {
        // 上报掉帧:帧耗时 + 页面标识,用于定位"哪个页面在哪种设备上掉帧"
        PerfReporter.reportJank(frameData.frameDurationUiNanos, screenName)
    }
}, jankHeuristic = JankStats.androidXDefaultJankHeuristic)

三、常见坑 ⚠️

现象 根因 解法
优化后线上指标没变化 实验室数据 ≠ 线上;或没控制变量(机型/系统/网络) 线上分桶对比 + 实验室固定环境;A/B 验证
Compose 列表越滚越卡 item 读取大状态对象导致大范围重组 item 只接收最小数据;@Immutable 状态类
图片导致 OOM 未指定尺寸加载原图 Coil size();列表用低分辨率、详情加载大图
Application.onCreate 越改越长 SDK 按需初始化变成"全量同步初始化" App Startup 分组 + 首帧后延迟初始化
getMeasuredHeight() 为 0 就操作视图 View 还没测量完成 doOnLayout { } / doOnPreDraw { }
后台返回卡顿明显 onResume 做了重活;或被系统回收后全量恢复 onResume 轻量化;状态恢复走 ViewModel/缓存
混淆后启动反而变慢 keep 规则过多,R8 无法优化;缺 Baseline Profile keep 最小化(第 19 章);生成 Baseline Profile
StrictMode 全开导致测试崩一片 一次性加严,历史债务集中爆发 先 penaltyLog 观察存量,再按模块逐步清零
ANR 率降不下来 只修了个案,没有 trace 聚合分析 建立 ANR 样本聚合(堆栈聚类),按 TopN 治理
Lottie/动画掉帧 硬件层未开启、动画在软件层绘制 setLayerType(HARDWARE);动画期间避免布局抖动

四、调试技巧

# 1. 启动耗时(官方口径,看 TotalTime / WaitTime)
adb shell am start -W com.techfeed.app/.MainActivity

# 2. 逐帧耗时代码埋点(找出慢在哪个阶段)
adb shell atrace --async_start -b 16000 view gfx input view wm am
# 操作 App 后
adb shell atrace --async_stop > trace.txt && python systrace.py --from-file trace.txt

# 3. 内存快照对比(泄漏确认:反复进出页面后 heap 中该 Activity 实例数应归零)
adb shell am dumpheap -n com.techfeed.app /data/local/tmp/heap.hprof

# 4. GPU 渲染柱状图(掉帧可视化)
adb shell settings put global hwui_profile true

# 5. 查看包体积构成(APK Analyzer 命令行版)
apkanalyzer files app-release.apk

Android Studio 工具链:

  • Profiler → CPU:录制 method trace / system trace,一帧时间线逐段看(推荐 system trace,开销小)。
  • Profiler → Memory:抓 heap dump,按类聚合看实例数;配合 LeakCanary 的泄漏路径。
  • Layout Inspector → Recomposition Counts:Compose 重组热力图,越红越要治理。
  • APK Analyzer:体积构成一目了然,找 so/dex/资源的大头。
  • Background Task Inspector:WorkManager 任务耗时(第 13 章)。

五、最佳实践

  1. 指标先行:任何优化开工前先回答"当前值多少、目标值多少、怎么测"。
  2. 启动治理三板斧:同步初始化最小化(App Startup)→ 首帧后延迟加载 → Baseline Profile。
  3. 主线程红线写进 CR 清单:磁盘、网络、大 JSON、同步锁,一律不许出现在主线程。
  4. Compose 稳定性优先:不可变状态类 + 稳定 key + 最小 item 参数,重组问题消失 80%。
  5. 图片"按显示尺寸"加载:列表小图、详情大图,杜绝原图进内存。
  6. 泄漏靠工具不靠自觉:LeakCanary 常开(debug),关键页面用 Profiler 抽查。
  7. 体积有预算:新增 SDK/资源超过阈值走评审;CI 对比体积并卡口。
  8. 防劣化机制化:StrictMode、Macrobenchmark 回归、体积/启动卡口进 CI(第 22 章),性能是"守住"的不是"优化出来"的。
对照:View 体系与 Compose 的性能问题映射
症状 View 体系根因 Compose 体系根因
列表滚动卡顿 bind 里建对象/层级深/图片大 item 参数不稳定/无 key/读取大状态
界面刷新慢 全量 notifyDataSetChanged 无 key 导致整体重组
页面切换顿挫 布局过深测量贵 动画期间重组风暴
静态页面也掉帧 过度绘制 稳定性不足的频繁重组

两套体系的性能思维同源:少做事、做对的事、增量地做。


六、本章小结

  1. 先测量后优化:冷启动/卡顿率/内存/ANR 率/包体积都有行业红线,线上指标才是真相。
  2. 启动 = Application.onCreate + 首帧 + 首屏数据三段治理;延迟初始化 + App Startup + Baseline Profile 是标准组合拳。
  3. 渲染:View 治层级与过度绘制,Compose 治稳定性与重组范围;共同敌人是"主线程一帧超预算"。
  4. 内存:泄漏用 LeakCanary 常态化发现,图片按显示尺寸加载,响应 onTrimMemory 释放缓存。
  5. ANR 与体积:主线程红线写进 CR 与 StrictMode;体积有预算、涨幅有卡口;一切防劣化进 CI。

下一章:性能之外,线上质量的另一半是稳定——崩溃与 ANR 如何被采集、还原、告警与止损。


七、动手练习

基础题

  1. 用 adb shell am start -W 测量 TechFeed 冷启动基线,记录三组数据;再开启 Baseline Profile 对比。
  2. 给 debug 包开启 StrictMode,故意在主线程读一次 SharedPreferences,观察日志输出。
  3. 用 LeakCanary 制造并修复一个静态变量持有 Activity 的泄漏,写出泄漏路径截图。

进阶题 4. 用 Layout Inspector 找出信息流页重组次数最多的组件,用"最小参数 + 稳定 key"优化,前后对比重组计数。 5. 用 Macrobenchmark 给启动建立回归基线,写一个本地脚本:每次发版跑一次并输出与上版的对比表。 6. 治理包体积:对 release 包做 R8 + 资源压缩 + ABI 拆分,用 APK Analyzer 输出体积构成饼图数据。

思考题 7. 为什么"实验室优化了 30%"在线上可能只有 5%?列出至少三个影响因素与对应的验证方法。 8. @Stable 注解是"承诺"而不是"证明":如果你给一个内部含 var 的类标了 @Stable,会埋下什么雷? 9. 包体积预算卡口(超了不让合入)会不会伤害业务迭代速度?如何设计阈值与例外流程才算合理?

📄 第 18 章 稳定性与崩溃监控 18-stability-crash-monitoring.md

阶段:企业级 | 预计阅读 55 min | 代码落地模块::core:crash、:core:common、:app 前置:第 15 章 模块化与组件化拆分 | 第 17 章 性能优化 相关章节:第 19 章 安全加固 | 第 20 章 构建配置与发布 | 第 26 章 推送、监控与收尾

学习目标

  • [ ] 说清崩溃率、ANR(Application Not Responding,应用无响应)率、启动失败率的定义与计算口径,能跟数据与后端对齐同一套指标。
  • [ ] 能用 Thread.setDefaultUncaughtExceptionHandler 采集 Java/Kotlin 崩溃,理解 native 崩溃与 ANR 的采集差异。
  • [ ] 理解 R8 与 mapping.txt 符号表的关系,能把混淆堆栈还原成可读堆栈,并在 CI 中自动上传。
  • [ ] 设计一个可替换的 CrashReporter 抽象层,接入 Sentry / Firebase Crashlytics / Bugly 之一,并说明选型维度。
  • [ ] 掌握 breadcrumb(面包屑)与用户行为轨迹的埋法,以及告警、灰度、功能开关构成的止损闭环。

一、核心概念

1.1 稳定性到底在衡量什么

"稳定性"不是"不崩溃",而是用户能不能完成他想做的事。一个 App 崩溃率 0.1% 但启动后白屏 3 秒,用户体感依然是"这应用坏了"。所以企业级项目通常把稳定性拆成三层:

层次 表现 典型归因 用户感知
崩溃(Crash) 进程抛未捕获异常后终止 空指针、越界、状态错乱、OOM(Out Of Memory,内存溢出) "闪退"
无响应(ANR) 主线程被阻塞超过阈值,系统弹"无响应"对话框 主线程做 I/O、锁竞争、Binder 调用阻塞 "卡死、点不动"
不可用(Failure) 启动失败、核心链路失败、白屏 启动期死锁、依赖服务不可用、数据损坏 "打不开、刷不出"

第 17 章解决的是"慢",本章解决的是"崩"和"死"——但两者的治理闭环是同一套:发现 → 定位 → 修复 → 验证 → 防复发。

1.2 指标体系与计算口径

指标一旦口径不一致,团队就会陷入"你的 0.3% 和我的 0.5%"这类无意义的争论。先把口径钉死:

指标 公式 口径说明 参考水位
崩溃率(次数口径) 崩溃次数 / 会话数 一次会话内多次崩溃多次计数,用于衡量"崩溃有多频繁" ≤ 0.2%
崩溃率(用户口径) 崩溃用户数 / 日活用户数 同一用户多次崩溃只算一次,用于衡量"影响面" ≤ 0.5%
ANR 率 ANR 次数 / 会话数 与崩溃同分母,才能和崩溃率直接比较 ≤ 0.1%
启动失败率 启动失败次数 / 冷启动次数 "冷启动后 30s 内发生崩溃或 ANR,且未到达首页渲染完成"记一次 ≤ 0.3%
崩溃影响留存 崩溃用户的次日留存 − 全体留存 最能说服业务方投入治理的指标 差值 ≤ 2pp

三个必须写进文档的计算口径细节:

  1. 会话(Session)的定义:前台连续使用,切后台超过 30s(行业常用)视为新会话。不定义会话,崩溃率就是不可比的。
  2. 分母去重:同一用户同一版本同一堆栈只计一次会低估严重度;建议主看次数口径、辅看用户口径。
  3. 启动失败必须单独看:启动期崩溃会被"会话数"稀释(因为根本没形成正常会话),所以要用冷启动次数做分母。

[!TIP] 计算口径写进 ADR(Architecture Decision Record,架构决策记录)和监控平台的看板注释里(第 22 章)。 一个团队最贵的成本不是算错,而是每次复盘都要重新解释一次口径。

1.3 崩溃采集链路

flowchart TB
    A["异常发生<br/>Java / Kotlin / native / ANR"]
    B["采集端<br/>UncaughtExceptionHandler<br/>Signal Handler<br/>WatchDog"]
    C["本地落盘<br/>加密 + 队列 + 限流"]
    D["上报时机<br/>下次启动 / 有网时 / 立即"]
    E["平台汇聚<br/>Sentry / Crashlytics / Bugly"]
    F["符号还原<br/>mapping.txt 按 versionCode+variant 匹配"]
    G["聚类分组 + 告警<br/>按 issue / 版本 / 设备分派"]
    H["修复 + 灰度验证<br/>功能开关 / 热修复"]
    A --> B --> C --> D --> E --> F --> G --> H
    H -.->|"新版本上线后重新观测"| A

关键点:采集端只负责"可靠地把现场保存下来",不要在上报里做复杂逻辑。崩溃那一刻进程状态已经不可信——内存可能即将耗尽、主线程可能已经卡死、磁盘可能写不进去。所有采集代码都要按"随时可能失败"来写。

1.4 Java/Kotlin 崩溃 vs native 崩溃 vs ANR

类型 信号 采集方式 难点
Java/Kotlin 崩溃 未捕获的 Throwable Thread.setDefaultUncaughtExceptionHandler 协程异常不经过它(1.5 节);需要保留原 handler 链
native 崩溃 SIGSEGV / SIGABRT / SIGBUS / SIGFPE Breakpad / Crashpad 注册 signal handler 需要带符号的 .so;堆栈需 addr2line/ndk-stack 还原
ANR 主线程消息超时,系统发送 SIGQUIT WatchDog 轮询 / 监听 SIGQUIT / ApplicationExitInfo traces.txt 在高版本无法直接读取
OOM OutOfMemoryError 或系统 LMK 杀进程 作为崩溃的一种特殊类型单独打标 堆栈往往"无辜"——真正的泄漏点在别处
启动失败 启动期崩溃 / ANR 在 Application 与首页之间打时间戳 启动期 SDK 尚未初始化完,采集本身可能来不及

native 崩溃在纯 Kotlin 项目里占比不高,但只要引入了地图、音视频、游戏引擎、加固壳,就必须覆盖。做法是依赖监控平台的 NDK 模块(Sentry 的 sentry-android-ndk、Crashlytics 的 NDK 支持),自己写 signal handler 的收益极低且极易引入二次崩溃。

1.5 ANR 采集的三种思路

ANR 的本质是主线程消息队列被堵住。系统的判定阈值大致是:

场景 超时阈值
输入事件 / 按键分发 5s
BroadcastReceiver(前台 / 后台) 10s / 60s
Service 生命周期(前台 / 后台) 20s / 200s
ContentProvider 调用 10s

采集手段有三代:

  1. WatchDog 线程(自研,兼容性最好):起一个后台线程,往主线程 Handler 投递一个"心跳",5s 后检查心跳是否被执行;未执行则认为 ANR,抓取主线程堆栈。
  2. 监听 SIGQUIT(BlockCanary / Matrix 的做法):系统判定 ANR 时会向进程发 SIGQUIT,用 signal hook 捕获它即可拿到最准的时机。缺点是需要 native 代码,且不同 ROM 有差异。
  3. ActivityManager.getHistoricalProcessExitReasons()(Android 11+,官方推荐):系统帮你记录了最近若干次进程退出的原因,ANR 还会附带 traces 输入流。这是最合规、最省事的方式,但拿不到"没被用户感知到的轻微卡顿"。

推荐组合:WatchDog 做在线监测 + ApplicationExitInfo 做离线补捞。前者负责当下抓堆栈,后者负责补上"进程已被杀、来不及上报"的那部分。

[!WARNING] Android 10 起 /data/anr/traces.txt 对其他进程不可读,直接 File("/data/anr/traces.txt").readText() 会拿到空内容或权限异常。 线上不要依赖读文件这条路,改用 ApplicationExitInfo.getTraceInputStream()(API 31+)。

1.6 符号表:为什么 release 堆栈是"天书"

release 包开启 R8 后,类名、方法名、字段名被替换成 a.b.c(),堆栈彻底不可读。还原靠的是 mapping.txt——R8 在构建时生成的一份"混淆前后名字对照表"。

flowchart LR
    A["原始堆栈<br/>a.a.b(Unknown Source:7)"]
    B["mapping.txt<br/>com.techfeed...FeedViewModel -> a.a"]
    C["还原后堆栈<br/>FeedViewModel.refresh(FeedViewModel.kt:42)"]
    A --> B --> C

三条铁律:

  1. mapping.txt 与构建一一对应:由 versionCode + versionName + variant + 构建时间戳共同确定。丢了它,那个包的崩溃就永久不可读。
  2. 必须随构建自动上传,不能靠人手动传:在 CI 里做(2.8 节)。
  3. 保留行号:混淆规则里必须保留 SourceFile 与 LineNumberTable,否则还原出来只有类名没有行号(第 19 章展开 R8 规则)。

1.7 止损:灰度、功能开关、热修复

采集只是手段,止损才是目的。三板斧的边界:

手段 生效速度 覆盖范围 风险
功能开关(Kill Switch) 秒级(下次拉取配置) 只能关闭"已埋开关"的功能 开关本身要有兜底值;配置服务挂了要默认安全
灰度发布 小时级(重新分发) 阻止新版本扩散到全量 只对新版本有效,老版本仍在崩
热修复 分钟级 理论上可修任意代码 合规风险最高:国内应用市场对代码下发有明确限制,Google Play 对动态代码加载亦有政策约束

[!WARNING] 热修复(Tinker / Sophix 等)属于"运行时加载可执行代码",在多个应用市场的审核规则中属于高风险能力。 落地前必须过法务与合规评审,并且只用于紧急止损,不用于常规需求发布。常规需求走正常发版流程(第 20 章)。


二、示例代码

2.1 :core:crash 模块脚本

// 文件路径:sample/core/crash/build.gradle.kts
plugins {
    alias(libs.plugins.techfeed.android.library)   // 约定插件,见第 02 章
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.hilt)
}

android {
    namespace = "com.techfeed.core.crash"
    // 库模块自带混淆规则,让使用方不必关心内部实现
    defaultConfig { consumerProguardFiles("consumer-rules.pro") }
}

dependencies {
    api(projects.core.model)          // AppError 出现在对外 API 里,用 api
    implementation(projects.core.common)
    implementation(libs.androidx.core.ktx)
    implementation(libs.kotlinx.coroutines.android)

    // 只依赖抽象:具体平台实现由 app 模块决定,feature 模块全程不感知
    // 这里以 Sentry 为例,实际用哪个由 app 的 implementation 决定
    compileOnly(libs.sentry.android)

    testImplementation(libs.bundles.testing.unit)
}

[!NOTE] compileOnly 是抽象层的关键::core:crash 只编译期看得见 SDK,不把它传递给下游。 换监控平台时只有 app 模块一行依赖会变,其余 15 个模块零改动。所有版本号仍以 libs.versions.toml 收口。

2.2 抽象接口:先定义"要什么",再谈"用谁"

// 文件路径:sample/core/crash/src/main/java/com/techfeed/core/crash/CrashReporter.kt
package com.techfeed.core.crash

/**
 * 崩溃监控的抽象层。
 * 设计原则:业务只依赖这个接口,平台 SDK 永远不出现在 feature 模块里。
 */
interface CrashReporter {

    /** 初始化,必须在 Application.onCreate 中尽早调用 */
    fun install(appVersion: String, buildType: String, isRelease: Boolean)

    /** 记录一条已捕获的异常(不导致崩溃,仅上报,用于"静默失败"观测) */
    fun record(throwable: Throwable, message: String? = null, extra: Map<String, String> = emptyMap())

    /** 记录一条 breadcrumb:崩溃前的行为轨迹,最多保留 N 条 */
    fun breadcrumb(category: String, message: String, data: Map<String, String> = emptyMap())

    /** 设置用户标识(用于影响面统计);注意不要传手机号、身份证等个人信息 */
    fun setUser(id: String)

    /** 设置自定义维度:渠道、变体、灰度批次、实验分组等 */
    fun setTag(key: String, value: String)

    /** 主动触发一次测试上报,用于验证链路是否接通 */
    fun sendTestEvent()

    /** 是否启用了功能开关兜底(用于止损看板) */
    fun reportKillSwitch(name: String, enabled: Boolean)

    /** 消费上次启动遗留的崩溃报告(补传后清空):崩溃那一刻网络发不出去,先落盘、下次启动补报 */
    fun dispatchPendingReports()
}

/** 空实现:debug 构建或测试中使用,保证"不装监控也能跑" */
object NoOpCrashReporter : CrashReporter {
    override fun install(appVersion: String, buildType: String, isRelease: Boolean) = Unit
    override fun record(throwable: Throwable, message: String?, extra: Map<String, String>) = Unit
    override fun breadcrumb(category: String, message: String, data: Map<String, String>) = Unit
    override fun setUser(id: String) = Unit
    override fun setTag(key: String, value: String) = Unit
    override fun sendTestEvent() = Unit
    override fun reportKillSwitch(name: String, enabled: Boolean) = Unit
    override fun dispatchPendingReports() = Unit
}

2.3 采集 Java/Kotlin 未捕获异常

// 文件路径:docs/samples/ch18/UncaughtCrashHandler.kt
package com.techfeed.core.crash

import android.os.Process
import kotlin.system.exitProcess

/**
 * 全局未捕获异常采集器。
 * ⚠️ 三个必须做对的点:
 *   1. 保存并链式调用原有 handler(可能被其他 SDK 先装过,覆盖会导致别人的监控失效)
 *   2. 落盘要同步写,进程马上就要死,异步来不及
 *   3. 处理完之后必须把异常交还给系统默认行为,让进程正常退出并弹"已停止运行"
 */
class UncaughtCrashHandler(
    private val reporter: CrashReporter,
    private val store: CrashStore,          // 本地落盘,见 2.4
    private val delegate: Thread.UncaughtExceptionHandler?,
) : Thread.UncaughtExceptionHandler {

    override fun uncaughtException(thread: Thread, throwable: Throwable) {
        try {
            // 1. 补齐上下文:线程名、前台状态、最近 breadcrumb 都在这一步拼进 payload
            reporter.setTag("thread", thread.name)
            reporter.setTag("crash_source", "uncaught_handler")
            // 2. 同步落盘,供下次启动时补报(本次进程大概率已无法完成网络请求)
            store.saveSync(CrashRecord(thread = thread.name, stacktrace = throwable.stackTraceToString()))
            // 3. 交给 SDK 尽力上报
            reporter.record(throwable, message = "uncaught: ${throwable::class.java.simpleName}")
        } catch (t: Throwable) {
            // 采集代码自身绝不能再抛异常,否则会递归触发 handler
        } finally {
            // 4. 链式调用:先交给前一个 handler(通常是系统默认的)
            delegate?.uncaughtException(thread, throwable)
        }
        // 5. 兜底退出:某些 ROM 上系统默认 handler 不会立即杀进程,会导致白屏卡死
        Process.killProcess(Process.myPid())
        exitProcess(10)
    }

    companion object {
        fun install(reporter: CrashReporter, store: CrashStore) {
            val current = Thread.getDefaultUncaughtExceptionHandler()
            Thread.setDefaultUncaughtExceptionHandler(
                UncaughtCrashHandler(reporter, store, current),
            )
        }
    }
}

协程异常是不走这个 handler 的:launch 中未捕获的异常会交给根 CoroutineExceptionHandler 或线程级 handler,而 async 中的异常要等 await 才抛出。所以业务侧必须自己加一层:

// 文件路径:docs/samples/ch18/CoroutineSafety.kt
package com.techfeed.core.common

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CancellationException

/**
 * 统一的协程异常处理:把"静默失败"变成可观测数据。
 * CancellationException 是取消信号不是错误,必须放行(第 01 章)。
 */
fun silentFailureHandler(onError: (Throwable) -> Unit) = CoroutineExceptionHandler { _, e ->
    if (e is CancellationException) return@CoroutineExceptionHandler
    onError(e)
}

2.4 Application 中装配

// 文件路径:sample/app/src/main/java/com/techfeed/app/TechFeedApplication.kt
package com.techfeed.app

import android.app.Application
import com.techfeed.core.crash.CrashReporter
import com.techfeed.core.crash.UncaughtCrashHandler
import com.techfeed.core.crash.CrashStore
import com.techfeed.core.crash.AnrWatchDog
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

@HiltAndroidApp
class TechFeedApplication : Application() {

    @Inject lateinit var crashReporter: CrashReporter
    @Inject lateinit var crashStore: CrashStore

    override fun onCreate() {
        super.onCreate()
        // ⚠️ 顺序很重要:崩溃采集必须是第一个初始化的组件,
        // 否则初始化阶段崩溃(启动失败率的主要来源)会完全丢失
        crashReporter.install(
            appVersion = BuildConfig.VERSION_NAME,
            buildType = BuildConfig.BUILD_TYPE,
            isRelease = !BuildConfig.DEBUG,
        )
        crashReporter.setTag("channel", BuildConfig.FLAVOR)
        crashReporter.setTag("version_code", BuildConfig.VERSION_CODE.toString())

        UncaughtCrashHandler.install(crashReporter, crashStore)

        // 补报上一次崩溃时来不及上报的记录
        crashStore.flushPending { reporter, record ->
            reporter.record(Throwable(record.stacktrace), message = "pending_from_last_launch")
        }
        // 等价入口:平台实现自带"读暂存 → 上报 → 清空"闭环时,直接调 crashReporter.dispatchPendingReports()

        // ANR 监测:只在非 debug 开启,避免开发时误报
        if (!BuildConfig.DEBUG) {
            AnrWatchDog(thresholdMillis = 5_000) { stack ->
                crashReporter.record(Throwable("ANR detected"), message = stack.joinToString("\n"))
            }.start()
        }
        // 其他 SDK 初始化...
    }
}

2.5 ANR 监测:WatchDog + ApplicationExitInfo

// 文件路径:sample/core/crash/src/main/java/com/techfeed/core/crash/AnrWatchDog.kt
package com.techfeed.core.crash

import android.os.Handler
import android.os.Looper
import android.util.Log

/**
 * ANR 看门狗:后台线程周期性向主线程投递任务,超时未执行说明主线程阻塞。
 * 仅观测与上报,不改变系统 ANR 判定。
 *
 * 三个必须做对的点:
 *  1. 每轮等待完整阈值(thresholdMillis),而不是阈值的一半 —— 判定语义与配置一致;
 *  2. reported 标志保证一次阻塞只上报一次,主线程恢复后重置(避免阻塞期间回调洪泛);
 *  3. stopWatchdog() 提供停止句柄(重复启动观测前先停旧的,避免多个看门狗并发误报)。
 */
internal class AnrWatchDog(
    private val thresholdMillis: Long,
    private val onAnr: (Array<StackTraceElement>) -> Unit,
) : Thread("techfeed-anr-watchdog") {

    @Volatile
    private var mainHandlerTick = 0L

    /** 一次阻塞期间只上报一次;主线程恢复后重置 */
    @Volatile
    private var reported = false

    @Volatile
    private var stopped = false

    private val mainThread: Thread = Looper.getMainLooper().thread

    private val ticker = Runnable { mainHandlerTick = System.nanoTime() }

    init {
        isDaemon = true
    }

    fun stopWatchdog() {
        stopped = true
        interrupt()
    }

    override fun run() {
        val mainHandler = Handler(Looper.getMainLooper())
        while (!stopped && !isInterrupted) {
            mainHandlerTick = 0L
            mainHandler.post(ticker)
            try {
                sleep(thresholdMillis)
            } catch (_: InterruptedException) {
                break
            }
            if (mainHandlerTick == 0L) {
                if (!reported) {
                    reported = true
                    val stack = mainThread.stackTrace   // 抓主线程堆栈:定位 ANR 唯一的直接证据
                    Log.w("AnrWatchDog", "主线程阻塞超过 ${thresholdMillis}ms")
                    onAnr(stack)
                }
                // 阻塞未恢复:继续下一轮探测,但不再重复上报
            } else {
                // 主线程恢复了:重置,允许下次阻塞再次上报
                reported = false
            }
        }
    }
}

[!NOTE] 看门狗由 CrashReporter.watchAnr() 接线:重复调用 watchAnr 会先 stopWatchdog() 停掉旧实例再启动新的,否则两个看门狗并发探测,误报率翻倍。

// 文件路径:docs/samples/ch18/ExitReasonCollector.kt
package com.techfeed.core.crash

import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi

/**
 * 补捞历史退出原因:能覆盖"进程被杀、来不及上报"的 ANR 与崩溃。
 * Android 11(API 30)起可用;traces 输入流需要 API 31。
 */
object ExitReasonCollector {

    fun collect(context: Context, limit: Int = 20): List<ExitReason> {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return emptyList()
        val am = context.getSystemService(ActivityManager::class.java) ?: return emptyList()
        return am.getHistoricalProcessExitReasons(context.packageName, 0, limit).map { info ->
            ExitReason(
                reason = describe(info.reason),
                timestamp = info.timestamp,
                description = info.description,          // ANR 时通常含 "Input dispatching timed out"
                trace = readTrace(info),
            )
        }
    }

    private fun readTrace(info: ApplicationExitInfo): String? = runCatching {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return@runCatching null
        info.traceInputStream?.bufferedReader()?.readText()
    }.getOrNull()   // 采集代码一律吞异常,绝不让监控拖垮业务

    private fun describe(reason: Int) = when (reason) {
        ApplicationExitInfo.REASON_ANR -> "ANR"
        ApplicationExitInfo.REASON_CRASH -> "CRASH"
        ApplicationExitInfo.REASON_CRASH_NATIVE -> "NATIVE_CRASH"
        ApplicationExitInfo.REASON_LOW_MEMORY -> "LOW_MEMORY"
        ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> "EXCESSIVE_RESOURCE"
        ApplicationExitInfo.REASON_USER_REQUESTED -> "USER_REQUESTED"
        ApplicationExitInfo.REASON_OTHER -> "OTHER"
        else -> "UNKNOWN_$reason"
    }
}

data class ExitReason(
    val reason: String,
    val timestamp: Long,
    val description: String?,
    val trace: String?,
)

2.6 breadcrumb 与用户行为轨迹(Compose / View 双体系)

崩溃堆栈告诉你"死在哪里",breadcrumb 告诉你"死之前干了什么"。只埋页面进出 + 关键操作,不要全量埋点击——后者会让 payload 变成噪音,还可能踩到隐私合规红线(第 19 章)。

Compose 体系:用 DisposableEffect 跟随可组合项的生命周期。

// CompositionLocal 定义:让任意 @Composable 无需参数透传即可拿到 CrashReporter。
// 不给默认值,迫使根组件必须显式提供,避免"静默漏埋"。
val LocalCrashReporter = staticCompositionLocalOf<CrashReporter> {
    error("LocalCrashReporter 未提供:请在 Activity 根部用 CompositionLocalProvider 提供")
}
// 文件路径:docs/samples/ch18/TrackScreen.kt
package com.techfeed.core.crash

import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf

/**
 * 页面级 breadcrumb:进入/离开各记一条。
 * 用 DisposableEffect 而非 LaunchedEffect,是因为需要在离开时执行 onDispose。
 */
@Composable
fun TrackScreen(name: String, args: Map<String, String> = emptyMap()) {
    val reporter = LocalCrashReporter.current   // 通过 CompositionLocal 注入
    DisposableEffect(name) {
        reporter.breadcrumb("navigation", "enter:$name", args)
        onDispose { reporter.breadcrumb("navigation", "exit:$name") }
    }
}

// 用法示例:
@Composable
fun FeedRoute(viewModel: FeedViewModel = hiltViewModel()) {
    TrackScreen("feed", mapOf("tab" to "recommend"))
    // ...
}

View/XML 体系:在 Fragment 或 Activity 的生命周期回调里埋,并配合 ViewBinding 的点击。

<!-- 文件路径:docs/samples/ch18/fragment_feed.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- 只给"关键操作"的控件打标记,普通控件不埋 -->
    <Button
        android:id="@+id/btnRefresh"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/feed_refresh" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvArticles"
        android:layout_width="0dp"
        android:layout_height="0dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
// 文件路径:docs/samples/ch18/FeedFragment.kt
package com.techfeed.feature.feed.ui

import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import com.techfeed.core.crash.CrashReporter
import com.techfeed.feature.feed.databinding.FragmentFeedBinding
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class FeedFragment : Fragment(R.layout.fragment_feed) {

    @Inject lateinit var crashReporter: CrashReporter

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = FragmentFeedBinding.bind(view)
        binding.btnRefresh.setOnClickListener {
            // 关键操作埋一条 breadcrumb,带上"用户主动触发"的信息
            crashReporter.breadcrumb("user_action", "tap_refresh")
            viewModel.refresh()
        }
    }

    override fun onResume() {
        super.onResume()
        crashReporter.breadcrumb("navigation", "enter:feed")
    }

    override fun onPause() {
        crashReporter.breadcrumb("navigation", "exit:feed")
        super.onPause()
    }
}

2.7 具体实现:以 Sentry 为例

// 文件路径:docs/samples/ch18/SentryCrashReporter.kt
package com.techfeed.app.monitoring

import android.content.Context
import com.techfeed.core.crash.CrashReporter
import dagger.hilt.android.qualifiers.ApplicationContext
import io.sentry.Sentry
import io.sentry.SentryLevel
import io.sentry.android.core.SentryAndroid
import io.sentry.protocol.User
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Sentry 实现。换成 Crashlytics / Bugly 只需要替换这个文件 + Hilt 模块的一行 @Binds。
 * DSN 从 BuildConfig 注入,绝不硬编码在源码里(第 19 章)。
 */
@Singleton
class SentryCrashReporter @Inject constructor(
    // Hilt 提供应用级 Context(@ApplicationContext 限定,避免误注入 Activity)
    @ApplicationContext private val context: Context,
) : CrashReporter {

    override fun install(appVersion: String, buildType: String, isRelease: Boolean) {
        SentryAndroid.init(context) { options ->
            options.dsn = BuildConfig.SENTRY_DSN          // 由 CI 注入,本地默认为空串
            options.environment = buildType               // debug / release,便于看板过滤
            options.release = "$appVersion"               // 必须与 mapping 上传时使用的 release 一致
            options.isEnableAutoSessionTracking = true    // 会话口径的崩溃率依赖它
            options.setBeforeBreadcrumb { breadcrumb, _ ->
                // 脱敏:URL 里的 token 一律抹掉再上报
                breadcrumb.data["url"]?.let { breadcrumb.data["url"] = it.redactQuery() }
                breadcrumb
            }
            options.setBeforeSend { event, _ ->
                // 采样:release 全量,debug 不上报,避免污染线上数据
                if (!isRelease) null else event
            }
        }
    }

    override fun record(throwable: Throwable, message: String?, extra: Map<String, String>) {
        Sentry.captureException(throwable) { scope ->
            message?.let { scope.setTag("hint", it) }
            extra.forEach { (k, v) -> scope.setExtra(k, v) }
        }
    }

    override fun breadcrumb(category: String, message: String, data: Map<String, String>) {
        Sentry.addBreadcrumb(io.sentry.Breadcrumb().apply {
            this.category = category
            this.message = message
            this.level = SentryLevel.INFO
            data.forEach { (k, v) -> this.data[k] = v }
        })
    }

    override fun setUser(id: String) {
        Sentry.setUser(User().apply { this.id = id })   // 只传不可逆的匿名 ID
    }

    override fun setTag(key: String, value: String) = Sentry.setTag(key, value)

    override fun sendTestEvent() {
        if (BuildConfig.DEBUG) Sentry.captureMessage("TechFeed 崩溃链路自检")
    }

    override fun reportKillSwitch(name: String, enabled: Boolean) = Sentry.setTag("killswitch_$name", enabled.toString())

    private fun String.redactQuery(): String = substringBefore("?") + "?<redacted>"
}
// 文件路径:docs/samples/ch18/MonitoringModule.kt
package com.techfeed.app.di

import com.techfeed.core.crash.CrashReporter
import com.techfeed.app.monitoring.SentryCrashReporter
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class MonitoringModule {
    // 换平台只改这一行:@Binds 把实现绑到抽象上,15 个模块无感
    @Binds
    @Singleton
    abstract fun bindCrashReporter(impl: SentryCrashReporter): CrashReporter
}

选型维度(不要只看价格):

维度 说明 典型结论
数据出境与合规 崩溃堆栈可能含包名、路径、用户标识 国内版必须选境内存储(Bugly / 国内 Sentry SaaS),或私有化部署
符号表体验 上传、自动匹配、命令行工具是否顺手 影响团队愿不愿意真的去看堆栈
采样与限流 大 DAU App 崩溃量巨大 需要服务端采样 + 客户端限流,否则账单爆炸
聚合算法 按堆栈指纹聚类是否准确 聚类差 = 一个 bug 拆成 50 个 issue = 没人愿意看板
与 CI 集成 是否有官方 Gradle 插件 / CLI 决定 mapping 上传能否自动化
多端统一 是否同时覆盖 Android / iOS / 后端 全端统一能省掉大量对齐成本

2.8 符号表上传:必须在 CI 里做

// 文件路径:sample/app/build.gradle.kts(片段)
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
    // 版本信息统一暴露给上报逻辑,保证 release 名与 mapping 上传时完全一致
    defaultConfig {
        buildConfigField("String", "SENTRY_DSN", "\"${providers.gradleProperty("sentryDsn").getOrElse("")}\"")
    }
}

// 让 mapping.txt 有个稳定的输出位置,便于 CI 收集
tasks.register<Copy>("collectMapping") {
    dependsOn("assembleRelease")
    from(layout.buildDirectory.file("outputs/mapping/release/mapping.txt"))
    into(layout.buildDirectory.dir("artifacts/mapping"))
    rename { "mapping-${project.property("VERSION_CODE")}.txt" }
}
# 文件路径:.github/workflows/release.yml(片段)
- name: 构建 Release AAB
  run: ./gradlew :app:bundleRelease :app:collectMapping

- name: 上传符号表
  # ⚠️ 必须在产物归档之前,且与上一步同一个 job,否则 job 结束时 workspace 已清空
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
    SENTRY_ORG: techfeed
    SENTRY_PROJECT: android
  run: |
    sentry-cli upload-proguard \
      --app-id "${{ steps.version.outputs.applicationId }}" \
      --version "${{ steps.version.outputs.versionName }}+${{ steps.version.outputs.versionCode }}" \
      --no-upload-source-maps \
      app/build/artifacts/mapping/mapping-*.txt

- name: 归档产物与符号表
  uses: actions/upload-artifact@v4
  with:
    name: release-artifacts
    # mapping.txt 一并归档:CI 上传失败时还有人工补救的余地
    path: |
      app/build/outputs/bundle/release/*.aab
      app/build/artifacts/mapping/

2.9 止损:功能开关

// 文件路径:docs/samples/ch18/KillSwitch.kt
package com.techfeed.core.common

/**
 * 功能开关:远程可配,本地有兜底。
 * 核心约束:配置服务不可用时必须回落到"最保守"的默认值,
 * 否则配置一挂,被打过开关的崩溃功能反而全量放开。
 */
interface KillSwitch {
    fun isEnabled(name: String): Boolean
}

class RemoteKillSwitch(
    private val localCache: Map<String, Boolean>,   // 上次成功拉取的快照,进程启动时加载
) : KillSwitch {
    override fun isEnabled(name: String): Boolean {
        // 查不到 → 默认开启(保持业务可用);
        // 但对已知高危功能,应在 DEFAULT_POLICY 里显式写 false
        return localCache[name] ?: DEFAULT_POLICY[name] ?: true
    }

    private companion object {
        val DEFAULT_POLICY = mapOf(
            "feature.push_v2" to false,        // 曾在 1.4.2 引发 ANR,默认关
            "feature.webview_preload" to false, // 内存压力大,默认关
        )
    }
}

2.10 崩溃现场的落盘

// 文件路径:sample/core/crash/src/main/java/com/techfeed/core/crash/CrashStore.kt
package com.techfeed.core.crash

import android.content.Context
import java.io.File

/**
 * 崩溃记录本地落盘。崩溃那一刻网络请求大概率发不出去,所以先存文件,下次启动补报。
 * 三个约束:单条限长、总量限大小、只保留最近 N 条。
 */
class CrashStore(context: Context) {

    private val dir = File(context.filesDir, "crash").apply { mkdirs() }
    private val lock = Any()

    fun saveSync(record: CrashRecord) {
        synchronized(lock) {
            runCatching {
                val file = File(dir, "crash_${System.currentTimeMillis()}.log")
                // 截断堆栈到 32KB:超长堆栈会拖慢下次启动
                file.writeText(record.stacktrace.take(32_000))
                trimTo(MAX_FILES)
            }
        }
    }

    fun flushPending(block: (CrashReporter, CrashRecord) -> Unit) { /* 读 dir 并上报后删除 */ }

    private fun trimTo(max: Int) {
        dir.listFiles()?.sortedByDescending { it.lastModified() }
            ?.drop(max)?.forEach { it.delete() }
    }

    private companion object { const val MAX_FILES = 20 }
}

data class CrashRecord(val thread: String, val stacktrace: String)
旧写法对照:Java 时代的崩溃处理与 Kotlin 的差异
// 旧写法(Java):匿名内部类 + 手写字符串拼接,且常忘记链式调用原 handler
public class CrashHandler implements Thread.UncaughtExceptionHandler {
    private final Thread.UncaughtExceptionHandler mDefault;

    public void init(Context ctx) {
        mDefault = Thread.getDefaultUncaughtExceptionHandler();
        Thread.setDefaultUncaughtExceptionHandler(this);
    }

    @Override
    public void uncaughtException(Thread t, Throwable e) {
        StringWriter sw = new StringWriter();
        e.printStackTrace(new PrintWriter(sw));   // 需要手动把堆栈转成字符串
        saveToFile(sw.toString());
        if (mDefault != null) mDefault.uncaughtException(t, e);
    }
}

Kotlin 版的差异只有两点是实质性的:Throwable.stackTraceToString() 一行替代 StringWriter 模板代码; Thread.setDefaultUncaughtExceptionHandler 的行为本身完全一样——采集机制与语言无关,别误以为 Kotlin 有什么特殊通道。 真正需要注意的是协程:launch 里的异常走 CoroutineExceptionHandler,不会触发这个 handler。


三、常见坑 ⚠️

现象 根因 解法
release 包崩溃堆栈全是 a.b.c(Unknown Source) mapping.txt 未上传,或上传的 release 名与运行时 options.release 不一致 统一用 $versionName+$versionCode 作为 release 名;CI 校验上传返回值
本地 debug 不复现,线上大量崩溃 debug 未开混淆、未开严苛模式;或崩溃只在特定 ROM / 特定 ABI 出现 用 release 变体 + debuggable false 复现;看设备/系统维度分布
崩溃率突然翻倍,但代码没改 分母(会话定义)被改了;或灰度放量导致样本结构变化 看板同时展示次数与用户口径;发布前后对比固定人群
采集代码自身抛异常导致"崩溃套崩溃" handler 里做了复杂逻辑(JSON 序列化、网络请求) handler 内全部 runCatching 包裹,只做落盘
用户反馈闪退但平台没数据 初始化太晚(启动期崩溃丢失);或 SDK 未初始化完成就崩 崩溃采集必须是 Application.onCreate 第一件事
装了自家 handler 后其他 SDK 的监控失效 覆盖了 getDefaultUncaughtExceptionHandler 的原有值 链式调用:delegate?.uncaughtException(...)
ANR 抓不到堆栈 读 /data/anr/traces.txt 被系统限制;或 WatchDog 阈值 > 系统阈值 Android 11+ 用 ApplicationExitInfo.getTraceInputStream()
ANR 堆栈指向"无辜"的方法 WatchDog 抓的是"当前"主线程栈顶,未必是最初的阻塞点 连续采样多次堆栈做比对;结合 StrictMode 与 Perfetto(第 17 章)
OOM 堆栈指向分配点而非泄漏点 OOM 的堆栈只说明"哪里刚好放不下了" 用 onTrimMemory 打点 + Profiler 抓 hprof(第 17 章)
崩溃日志里出现手机号、Token breadcrumb / extra 里塞了原始请求参数 上报前统一脱敏(setBeforeBreadcrumb、setBeforeSend)
上报量暴涨导致成本失控 单点崩溃在循环里被反复上报 客户端限流(同指纹 X 分钟内最多 N 条)+ 服务端采样
热修复上线后被应用市场警告 动态代码下发违反市场政策 热修复仅做紧急止损并过合规评审;常规改动走发版

四、调试技巧

  1. 手动触发崩溃验证链路:开发时加一个隐藏入口(如连点版本号 7 次)调用 throw RuntimeException("manual crash test"),确认平台能收到、能还原行号。每接一次监控、每次升级 SDK 都要验一次。
  2. 立刻查看崩溃现场:
    # 只看崩溃与 ANR 的日志(Android 11 以下 traces 仍可读)
    adb logcat -b crash
    # 手动触发一次 ANR(主线程睡 10 秒)后拉取 traces
    adb shell pull /data/anr/traces.txt ./traces.txt
    # 查看系统记录的进程退出原因(Android 11+)
    adb shell dumpsys activity processes | grep -A 5 "lastExitInfo"
    
  3. 模拟 OOM:开发者选项开启"不保留活动"、把堆上限调小,或用 adb shell setprop dalvik.vm.heapsize 64m 逼出内存边界。
  4. 还原混淆堆栈(本地):
    # 用 R8 自带的 retrace 把混淆堆栈还原
    ./gradlew :app:retraceRelease --stacktrace-file ./obfuscated.txt
    # 或直接用 SDK 工具
    java -jar $ANDROID_HOME/tools/proguard/lib/retrace.jar mapping.txt obfuscated.txt
    
  5. 验证 mapping 是否匹配:随便找一个 release 包的方法名,在 mapping.txt 里 grep 它,能找到说明匹配;找不到说明版本对不上。
  6. 确认 ANR WatchDog 没误报:在 debug 构建把阈值调到 10s 并打印心跳日志,避免开发时断点调试触发假 ANR。
  7. 看板排错的固定顺序:先看"影响用户数"排序 → 再看"是否新引入(首次出现版本)" → 再看"设备/系统/渠道分布"。80% 的高优先级崩溃是新版本引入的 top3 issue。

五、最佳实践

  1. 先定口径再做采集:崩溃率的分母、会话定义、启动失败判定必须写成一页文档,否则数据永远吵不清楚。
  2. 崩溃采集是 Application.onCreate 的第一行:启动失败率的主要来源就是启动期崩溃,装晚了等于没装。
  3. 抽象层隔离平台 SDK:feature 模块只依赖 :core:crash 的接口,换 Sentry / Crashlytics / Bugly 只改 app 模块。
    • ✅ 推荐:@Binds abstract fun bind(impl: SentryCrashReporter): CrashReporter
    • ❌ 避免:在 FeedViewModel 里直接 Sentry.captureException(e)
  4. 链式调用原 handler:Thread.getDefaultUncaughtExceptionHandler() 的返回值必须保存并透传。
    • ✅ 推荐:UncaughtCrashHandler(reporter, store, delegate = current)
    • ❌ 避免:直接 setDefaultUncaughtExceptionHandler { t, e -> report(e) }
  5. 采集代码永不抛异常:handler 内所有操作用 runCatching 包裹,且只做"落盘"这一件事。
  6. breadcrumb 只埋关键节点:页面进出 + 核心操作 + 网络关键请求,不做全量点击埋点。
    • ✅ 推荐:breadcrumb("navigation", "enter:detail", mapOf("id" to articleId))
    • ❌ 避免:把整个请求 body 塞进 breadcrumb
  7. 上报前统一脱敏:在 setBeforeSend / setBeforeBreadcrumb 里抹掉 URL query、手机号、Token——这是隐私合规的硬要求(第 19 章)。
  8. 符号表上传自动化:mapping.txt 与产物在同一个 CI job 内上传并归档,绝不允许"发版后手动传"。
  9. 每次发版后 24 小时盯盘:新版本崩溃率高于基线 1.5 倍立即启动回滚评估(第 20 章)。
  10. 止损优先级:功能开关 > 灰度暂停 > 热修复:成本与合规风险依次升高,能关开关解决的绝不热修。
  11. OOM 与 ANR 单独建治理专项:它们不是"某个 bug",而是架构与资源使用习惯的产物,需要内存画像与主线程耗时画像(第 17 章)配套治理。
  12. 每个 issue 必须有 owner:看板上无人认领的崩溃等于没有崩溃。把"崩溃分派"放进迭代流程而不是靠自觉。

六、本章小结

  1. 稳定性是三件事:崩溃(进程终止)、ANR(主线程阻塞)、不可用(启动失败/核心链路失败),三者口径不同、治理手段也不同。
  2. 指标口径比指标数值重要:崩溃率按次数与按用户是两回事,会话定义必须统一,启动失败要用冷启动次数做分母。
  3. 采集要分层:Java/Kotlin 崩溃用 UncaughtExceptionHandler(注意链式透传与协程例外),ANR 用 WatchDog + ApplicationExitInfo,native 崩溃交给平台 SDK。
  4. 符号表是 release 可读性的唯一钥匙:mapping.txt 与 versionName+versionCode 一一对应,由 CI 自动上传并归档。
  5. 采集只是起点:breadcrumb 提供上下文,功能开关与灰度提供止损能力,最终形成"发现 → 定位 → 修复 → 验证 → 防复发"的闭环。

至此,企业级四部曲的"稳定性"这一环合上了:性能优化让 App 更快(第 17 章),崩溃监控让 App 更稳(本章)。下一章解决"更安全"——代码混淆、数据加密、网络安全与防逆向。

下一章:进入安全加固,从 R8 混淆规则写到 Keystore 加密、证书绑定与组件防导出。


七、动手练习

基础题

  1. 新建 :core:crash 模块,实现 2.2 节的 CrashReporter 接口与 NoOpCrashReporter,在 :app 中通过 Hilt 的 @Binds 装配,并在 debug 包里用隐藏入口触发一次崩溃,确认能收到日志。
  2. 按 2.5 节实现 AnrWatchDog,在主线程 Thread.sleep(8000) 模拟 ANR,验证能抓到主线程堆栈并打印出阻塞点。
  3. 给详情页加上 breadcrumb:进入页面记 enter:detail,点击收藏记 tap_favorite,退出记 exit:detail,分别用 Compose 的 DisposableEffect 与 View 生命周期回调各实现一版。

进阶题 4. 实现 CrashStore 的落盘与补报:崩溃时同步写文件(上限 20 条、单条 32KB),下次启动读取并上报后删除;写一个 JVM 单测验证"超过上限会淘汰最旧记录"。 5. 在 CI 中接入符号表上传:构建 release 后自动上传 mapping.txt,并故意传一个错误版本号,观察平台上堆栈还原失败的表现,再修对。 6. 设计一套"告警规则":按新版本引入、影响用户数、增长率三个维度给出 P0/P1/P2 的分级条件,并说明每级对应的止损动作(关开关 / 暂停灰度 / 回滚 / 热修)。

思考题 7. 为什么 OOM 的崩溃堆栈几乎没有定位价值?如果要真正定位内存泄漏,你需要在崩溃上报里额外采集哪些信息?(提示:onTrimMemory 等级历史、Activity 实例数、图片缓存占用) 8. 热修复生效最快、覆盖面最广,为什么本教程仍把它列为"最后手段"?请从合规、可维护性、测试覆盖三个角度各给出一条理由。 9. 如果产品要求"崩溃率降到 0",这个目标合理吗?你会用什么指标来替代它说服产品方?

📄 第 19 章 安全加固 19-security-hardening.md

阶段:企业级 | 预计阅读 60 min | 代码落地模块::core:datastore、:core:network、:core:common、:app、:feature:auth 前置:第 10 章 数据存储 | 第 18 章 稳定性与崩溃监控 相关章节:第 09 章 网络请求 | 第 20 章 构建配置与发布 | 第 26 章 推送、监控与收尾

学习目标

  • [ ] 能写出最小化的 R8 混淆规则,理解 keep 的粒度代价、资源压缩与行号保留之间的取舍。
  • [ ] 掌握敏感数据的三层存储方案:Android Keystore(密钥库系统)、EncryptedSharedPreferences、EncryptedFile,以及生物识别(Biometric)认证的接入姿势。
  • [ ] 能配置 network_security_config(网络安全配置):禁明文、证书绑定(Certificate Pinning)、debug-overrides。
  • [ ] 能堵住组件导出、Intent 伪造、deeplink 劫持与 WebView 越权这四类入口漏洞。
  • [ ] 理解防逆向(防调试、签名校验、加固)的成本与合规边界,以及隐私合规的"最小化采集、最小化权限"原则。

一、核心概念

1.1 移动端的威胁模型

做安全加固前先搞清楚"敌人是谁"。移动端 App 有三个特殊性:

  1. 客户端完全在攻击者手里:你的 APK 可以被任意反编译、重打包、在模拟器里跑、被 hook(Xposed / Frida)。所以任何写在客户端的"秘密"都不算秘密。
  2. 攻击面在边界:组件导出、deeplink、WebView、Intent 是外部输入的入口,绝大多数真实漏洞都出在这几处。
  3. 合规是硬约束:过度采集、明文上传个人信息、动态代码下发都会直接导致应用市场下架或监管处罚——很多时候"合规"比"技术防破"更优先。

据此,加固的优先级应该是:合规与数据安全 > 边界入口防护 > 抗逆向。抗逆向永远只能"提高门槛",做不到"阻止"。

1.2 R8:压缩、优化、混淆三件事

R8 是 AGP 默认的编译器后端,一个工具干三件事:

能力 作用 常见开关
Shrinking(代码压缩) 移除未被引用的类/方法/字段 isMinifyEnabled = true
Optimization(优化) 内联、死代码消除、类合并 默认开启(-optimize 规则集)
Obfuscation(混淆) 重命名类/方法/字段为短名 同上;由 mapping.txt 记录映射
资源压缩 移除未引用资源、缩短资源路径 isShrinkResources = true
flowchart LR
    A["源码 + 依赖 .class"] --> B["R8 收缩<br/>可达性分析,从 keep 规则出发"]
    B --> C["R8 优化<br/>内联 / 去死代码"]
    C --> D["R8 混淆<br/>重命名 + 输出 mapping.txt"]
    D --> E["DEX + 资源 + mapping"]
    E -.->|"崩溃堆栈还原"| F["监控平台"]

-keep 是"从这个类开始,保留它能触及的一切"。写 -keep class com.techfeed.model.** { *; } 会把整个模型包及其引用链全部钉住,混淆收益直接归零。这就是"最小化原则"的由来:能 -keepclassmembers 就别 -keep,能指定成员就别写 { *; }。

1.3 敏感数据存哪里

数据 方案 说明
加密密钥 Android Keystore 密钥材料不进应用进程,由系统/安全硬件持有,无法被导出
Token、用户偏好 EncryptedSharedPreferences 键与值分别用 AES-256-GCM 与 AES-256-SIV 加密
大文件、离线快照 EncryptedFile 基于流式加密,适合大于几 KB 的内容
密码 不存 只在登录时提交,服务端加盐哈希
生物识别凭据 Biometric + Keystore 绑定 密钥需用户生物验证后才能使用(setUserAuthenticationRequired)

Keystore 的核心价值不是"加密更强",而是密钥本身不出现在应用内存里:你把密钥生成进 Keystore,之后只能拿到一个"句柄"去做加解密,永远拿不到原始密钥字节。反编译、dump 内存都拿不到。

1.4 网络安全配置与证书绑定

network_security_config.xml 是声明式的网络安全策略,四个能力:

  1. 禁明文:cleartextTrafficPermitted="false",杜绝 HTTP 被劫持。
  2. 证书绑定:把服务端证书公钥的 SHA-256 指纹写进配置或代码,中间人证书直接拒绝。
  3. debug-overrides:debug 变体允许自签 CA / 明文,方便抓包,但 release 绝不能带。
  4. trust-anchors 限定:只信任系统 CA,不信任用户安装的 CA。

[!WARNING] 证书绑定一旦上线就绑死了证书。证书到期或轮换时,未更新的老版本客户端会全部连不上——这是真实的线上事故来源。 必须同时绑定主证书与备份证书的指纹,并确保备份证书的私钥已经就绪。

1.5 组件与入口防护

漏洞 原理 防线
组件越权导出 exported="true" 且无权限校验,任意 App 可调起 不需要外部调用的一律 exported="false"
Intent 伪造/注入 攻击者构造 Intent 传入恶意参数或 extra 校验 action、scheme、来源包名,参数白名单
deeplink 劫持 恶意 App 注册相同 scheme 抢先响应 autoVerify="true" + 域名 assetlinks 校验 + 路径白名单
WebView 越权 file:// 读取私有文件、addJavascriptInterface 暴露原生方法 关闭 file 访问、JS 接口加 @JavascriptInterface 且做白名单、URL 白名单
剪贴板/截屏泄露 后台截屏泄露敏感页 敏感页加 FLAG_SECURE

1.6 防逆向与合规边界

手段 效果 代价 合规提示
防调试检测(isDebuggerConnected / TracerPid) 抬高动态分析门槛 可被绕过;有误判风险 无
签名校验(本地/服务端比对) 阻止重打包分发 客户端校验可被 hook 掉 无
完整性校验(APK 哈希、类加载校验) 发现篡改 维护成本高 无
第三方加固(加壳、DEX 虚拟化) 显著提高静态分析成本 启动变慢、崩溃率上升、兼容性问题、部分市场不接受 需评审
动态代码加载(热修复、插件化) 运行时改变行为 安全风险高 多市场受限,见第 18 章

[!IMPORTANT] 加固与热修复都会改变"运行时加载代码"的形态,在国内多家应用市场属于需要申报或明令受限的能力。 上线前必须过合规评审,并在灰度阶段重点观察启动耗时与崩溃率的变化。


二、示例代码

2.1 R8 规则:最小化的 keep

# 文件路径:sample/app/proguard-rules.pro
# ---------- 1. 通用保留(必留,否则崩溃堆栈无法定位) ----------
# 保留行号:没有它,还原后的堆栈只有类名没有行号(第 18 章)
-keepattributes SourceFile,LineNumberTable
# 保留注解与签名:Room / Retrofit / kotlinx.serialization 大量依赖反射与泛型签名
-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod

# ---------- 2. 序列化模型:只保留成员,不要 -keep 整个类 ----------
# 原因:kotlinx.serialization 生成的 Companion.serializer() 依赖字段名与构造函数
-keepclassmembers class com.techfeed.core.model.** {
    <fields>;
    <init>(...);
}
# ⚠️ 不要写成 -keep class com.techfeed.core.model.** { *; }
# 那会把模型包及其全部引用链钉死,混淆收益直接归零

# ---------- 3. Room:实体与 DAO 的实现类 ----------
-keep class * extends androidx.room.RoomDatabase { *; }
-keep @androidx.room.Entity class * { *; }
-dontwarn androidx.room.paging.**

# ---------- 4. 反射创建的对象(Hilt 生成代码已自带规则,这里只补自定义入口) ----------
-keepclassmembers class * {
    @com.techfeed.core.common.annotations.* <methods>;
}

# ---------- 5. JNI 与 View 构造函数(XML 中引用的自定义 View) ----------
-keepclasseswithmembernames class * { native <methods>; }
-keepclassmembers class * extends android.view.View {
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
}

# ---------- 6. 对外 API 的库模块:用 consumerProguardFiles 而不是塞进 app ----------
# 见 sample/core/network/consumer-rules.pro

# ---------- 7. 调试与日志:release 直接移除 ----------
# 假设日志工具类叫 com.techfeed.core.common.log.TimberLike
-assumenosideeffects class com.techfeed.core.common.log.ReleaseLog {
    public static *** d(...);
    public static *** v(...);
    public static *** i(...);
}

# ---------- 8. 不要混淆的东西(反例说明写在注释里) ----------
# -keep class com.techfeed.** { *; }   ← 灾难级规则,等于没开混淆
# -dontoptimize                        ← 关掉优化,包体收益全部丢失
# 文件路径:sample/core/network/consumer-rules.pro
# 库模块自带规则:消费方打 release 时会自动合并,不需要使用方手写
-keepclassmembers class com.techfeed.core.network.dto.** {
    <fields>;
}
// 文件路径:sample/app/build.gradle.kts(片段)
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true           // 资源压缩:移除未引用资源
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }
}

[!TIP] 资源压缩会把 res/drawable 里"只被字符串拼接引用"的资源误删。用 tools:keep="@drawable/xxx" 或 res/raw/keep.xml 显式保留动态引用的资源,否则上线后会出现图片不显示的诡异 bug。

2.2 Android Keystore + EncryptedSharedPreferences

[!NOTE] 维护状态说明(与第 10 章口径一致):EncryptedSharedPreferences 所在的 androidx.security:security-crypto 库已进入维护状态(只修 bug、不新增功能)。但它仍是最简可靠的 Keystore 封装——MasterKey 生命周期、密文落盘、损坏文件恢复这些细节自己重写极易出错,因此本教程继续沿用;迁移到 DataStore + 自行加密属于进阶练习。

// 文件路径:docs/samples/ch19/SecurePrefs.kt
package com.techfeed.core.datastore.secure

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

/**
 * 加密键值存储:用于 Token、匿名设备 ID 等小体积敏感数据。
 * 底层:MasterKey 存在 Android Keystore,值用 AES-256-GCM,键用 AES-256-SIV(保证键可查询但不可逆推)。
 */
object SecurePrefs {

    private const val FILE_NAME = "techfeed_secure_prefs"

    fun create(context: Context): SharedPreferences {
        // MasterKey 由 Keystore 持有,密钥材料不进入应用进程
        val masterKey = MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            // 高风险场景可要求用户验证后才能使用密钥(需配合 Biometric)
            // .setUserAuthenticationRequired(true, 30)
            .build()

        return EncryptedSharedPreferences.create(
            context,
            FILE_NAME,
            masterKey,
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
        )
    }
}
// 文件路径:sample/core/datastore/src/main/java/com/techfeed/core/datastore/secure/SecureTokenStorage.kt
package com.techfeed.core.datastore.secure

import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Token 加密存储:Keystore 主密钥 + AES-256 加密文件。
 * ⚠️ 明文 SharedPreferences 存 Token 是安全评审的一票否决项。
 *
 * 线程模型:EncryptedSharedPreferences.create 涉及 Keystore 初始化 + 磁盘建文件,
 * 首次可达数十毫秒 —— 因此用双重检查懒加载:构造函数零 IO,首次访问才创建;
 * 所有调用方(AuthInterceptor / LoginUseCase)负责在 IO 调度器上触发首次访问,
 * 避免主线程命中 StrictMode 磁盘违规(第 17 章)。
 */
@Singleton
class SecureTokenStorage @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    @Volatile
    private var cachedPrefs: SharedPreferences? = null
    private val createLock = Any()

    private fun prefs(): SharedPreferences = cachedPrefs ?: synchronized(createLock) {
        // 双重检查:第二个线程拿锁进来时,第一个线程可能已创建完毕
        cachedPrefs ?: create(context).also { cachedPrefs = it }
    }

    private fun create(context: Context): SharedPreferences = EncryptedSharedPreferences.create(
        context,
        "secure_token",
        MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build(),
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
    )

    var accessToken: String?
        get() = prefs().getString(KEY_ACCESS, null)
        set(value) = prefs().edit().putString(KEY_ACCESS, value).apply()

    var refreshToken: String?
        get() = prefs().getString(KEY_REFRESH, null)
        set(value) = prefs().edit().putString(KEY_REFRESH, value).apply()

    fun clear() {
        prefs().edit().clear().apply()
    }

    private companion object {
        const val KEY_ACCESS = "access_token"
        const val KEY_REFRESH = "refresh_token"
    }
}

大文件用 EncryptedFile:

// 文件路径:docs/samples/ch19/SecureFile.kt
package com.techfeed.core.datastore.secure

import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.MasterKey
import java.io.File

/**
 * 流式加密文件:适合离线文章快照、导出数据等体积较大的内容。
 * 注意:EncryptedFile 不支持随机写,每次都是整体覆写。
 */
fun encryptedFile(context: Context, file: File): EncryptedFile {
    val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()
    return EncryptedFile.Builder(
        context,
        file,
        masterKey,
        EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB,
    ).build()
}

2.3 生物识别认证(Compose / View 双体系)

Compose 体系:

// 文件路径:docs/samples/ch19/BiometricGate.kt
package com.techfeed.feature.auth.ui

import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

/**
 * 生物识别门禁:验证通过后才允许读取敏感内容。
 * 关键:BiometricPrompt 需要 FragmentActivity(它需要 FragmentManager 承载对话框)。
 */
@Composable
fun BiometricGate(onUnlocked: () -> Unit) {
    val context = LocalContext.current
    val activity = context as? FragmentActivity ?: return
    val scope = rememberCoroutineScope()

    val prompt = remember {
        BiometricPrompt(activity, object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                // ⚠️ 只在这里放行;cryptoObject 可用于解锁 Keystore 中的密钥
                onUnlocked()
            }
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                // 不要弹 Toast 暴露具体错误码含义,统一文案即可
            }
            override fun onAuthenticationFailed() = Unit   // 单次失败,系统 UI 自己会提示
        })
    }

    Button(onClick = {
        scope.launch {
            prompt.authenticate(
                BiometricPrompt.PromptInfo.Builder()
                    .setTitle("验证身份")
                    .setSubtitle("用于保护你的登录凭证")
                    // ⚠️ 允许设备凭证(PIN/图案)兜底,否则无指纹的机型直接走不通
                    .setAllowedAuthenticators(
                        BiometricManager.Authenticators.BIOMETRIC_STRONG or
                            BiometricManager.Authenticators.DEVICE_CREDENTIAL,
                    )
                    .build(),
            )
        }
    }) { Text("解锁") }
}

View/XML 体系:

<!-- 文件路径:docs/samples/ch19/fragment_biometric.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="24dp"
    android:gravity="center">

    <ImageView
        android:id="@+id/ivFingerprint"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:src="@drawable/ic_fingerprint"
        android:contentDescription="@string/auth_biometric_desc" />

    <Button
        android:id="@+id/btnUnlock"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/auth_unlock" />
</LinearLayout>
// 文件路径:docs/samples/ch19/BiometricFragment.kt
package com.techfeed.feature.auth.ui

import android.os.Bundle
import android.view.View
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.Fragment
import com.techfeed.feature.auth.databinding.FragmentBiometricBinding

class BiometricFragment : Fragment(R.layout.fragment_biometric) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = FragmentBiometricBinding.bind(view)
        binding.btnUnlock.setOnClickListener { showPrompt() }
    }

    private fun showPrompt() {
        val prompt = BiometricPrompt(this, object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                // 验证成功:此处可继续读取 Keystore 中需用户认证才能用的密钥
            }
        })
        prompt.authenticate(
            BiometricPrompt.PromptInfo.Builder()
                .setTitle(getString(R.string.auth_biometric_title))
                .setAllowedAuthenticators(
                    BiometricManager.Authenticators.BIOMETRIC_STRONG or
                        BiometricManager.Authenticators.DEVICE_CREDENTIAL,
                )
                .build(),
        )
    }
}

2.4 网络安全配置

<!-- 文件路径:docs/samples/ch19/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <!-- release 基线:禁止明文流量 -->
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <!-- 只信任系统预装 CA;不信任用户在设置里安装的 CA(防中间人) -->
            <certificates src="system" />
        </trust-anchors>
    </base-config>

    <!-- 主域名:开启证书绑定 -->
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">api.techfeed.dev</domain>
        <pin-set expiration="2027-01-01">
            <!-- 主证书公钥 SHA-256 指纹 -->
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
            <!-- ⚠️ 必须配备份指纹:主证书轮换/泄露时老版本客户端不会全量失联 -->
            <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
        </pin-set>
    </domain-config>

    <!-- 第三方域名(统计、图片 CDN):不绑定,避免对方换证书导致我们线上故障 -->
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">cdn.techfeed.dev</domain>
    </domain-config>

    <!-- 仅 debug 生效:允许本机代理抓包;release 构建中这段会被忽略 -->
    <debug-overrides>
        <trust-anchors>
            <certificates src="system" />
            <certificates src="user" />   <!-- 允许 Charles/mitmproxy 的用户 CA -->
        </trust-anchors>
    </debug-overrides>
</network-security-config>
<!-- 文件路径:sample/app/src/main/AndroidManifest.xml(片段) -->
<application
    android:name=".TechFeedApplication"
    android:networkSecurityConfig="@xml/network_security_config"
    android:usesCleartextTraffic="false"
    android:allowBackup="false"
    ... >
</application>

需要在代码层做灵活绑定时(例如按灰度切换),用 OkHttp 的 CertificatePinner:

// 文件路径:docs/samples/ch19/PinnerModule.kt
package com.techfeed.core.network.pinning

import dagger.Module
import dagger.Provides
import okhttp3.CertificatePinner
import javax.inject.Singleton

/**
 * OkHttp 证书绑定:与 XML 配置二选一即可,代码版更适合"按环境动态开关"。
 * ⚠️ 指纹必须同时含主备两份;上线前用一次真实的证书轮换演练验证过。
 */
@Module
object PinnerModule {

    // ⚠️ @Provides 必须在 @Module 里:顶层函数标 @Singleton Hilt 不会装配
    @Provides
    @Singleton
    fun provideCertificatePinner(): CertificatePinner = CertificatePinner.Builder()
        .add("api.techfeed.dev", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
        .add("api.techfeed.dev", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
        .build()
}
# 获取证书公钥指纹(上线前必做)
openssl s_client -connect api.techfeed.dev:443 -servername api.techfeed.dev \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

2.5 组件与 Intent 防护

<!-- 文件路径:sample/app/src/main/AndroidManifest.xml(片段) -->
<!-- 1. 内部页面:一律不导出 -->
<activity android:name=".host.HostActivity" android:exported="false" />

<!-- 2. 需要被外部调起的:显式加权限 + 精确 intent-filter -->
<activity
    android:name=".share.ShareEntryActivity"
    android:exported="true"
    android:permission="com.techfeed.app.permission.SHARE_ENTRY">

    <intent-filter>
        <action android:name="android.intent.action.SEND" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:mimeType="text/plain" />
    </intent-filter>
</activity>

<!-- 3. deeplink:autoVerify + 精确路径,避免被劫持 -->
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="techfeed.dev" android:pathPrefix="/article" />
    </intent-filter>
</activity>
// 文件路径:docs/samples/ch19/DeepLinkGuard.kt
package com.techfeed.app.deeplink

import android.content.Intent
import android.net.Uri

/**
 * deeplink 白名单校验:任何来自外部的 URL 都必须过这一关。
 * 原则:先校验 scheme/host/path 是否匹配,再解析参数,顺序不能反。
 */
object DeepLinkGuard {

    private val ALLOWED_HOSTS = setOf("techfeed.dev", "www.techfeed.dev")
    private val ALLOWED_PATHS = setOf("/article", "/topic", "/user")

    sealed interface Target {
        data class Article(val id: String) : Target
        data class Topic(val name: String) : Target
        object Unknown : Target
    }

    fun parse(intent: Intent?): Target {
        val uri: Uri = intent?.data ?: return Target.Unknown
        // ⚠️ 必须先校验来源,再取参数——否则恶意 App 可以用任意 URI 调起内部页面
        if (uri.scheme != "https") return Target.Unknown
        if (uri.host !in ALLOWED_HOSTS) return Target.Unknown
        val path = uri.path ?: return Target.Unknown
        if (path.substringBeforeLast('/') !in ALLOWED_PATHS) return Target.Unknown

        return when {
            path.startsWith("/article") -> {
                val id = uri.lastPathSegment.orEmpty()
                // 参数也要校验形态:id 只允许字母数字与短横线,防止注入
                if (!id.matches(Regex("[A-Za-z0-9_-]{1,64}"))) Target.Unknown else Target.Article(id)
            }
            path.startsWith("/topic") -> Target.Topic(uri.lastPathSegment.orEmpty())
            else -> Target.Unknown
        }
    }
}

发送隐式 Intent 时也要防"被第三方接收":

// 文件路径:docs/samples/ch19/IntentExt.kt
package com.techfeed.core.common

import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager

/**
 * 安全启动外部 Intent:显式指定目标包名,避免"选择器劫持"。
 * 未指定包名时,系统会让用户选一个 App,恶意 App 可以抢先注册同类 filter 拿到我们的 extra。
 */
fun Context.startExternalSafely(intent: Intent, targetPackage: String): Boolean {
    val pm = packageManager
    val resolved = pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
    if (resolved.none { it.activityInfo.packageName == targetPackage }) return false
    return runCatching {
        startActivity(intent.setPackage(targetPackage))
        true
    }.getOrDefault(false)
}

2.6 WebView 安全(Compose / View 双体系)

Compose 体系(WebView 是 View,用 AndroidView 包一层):

// 文件路径:docs/samples/ch19/SecureWebView.kt
package com.techfeed.feature.detail.ui

import android.annotation.SuppressLint
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.ui.viewinterop.AndroidView

/**
 * Compose 中的安全 WebView:所有安全开关集中在这里,业务侧只传 URL。
 */
@SuppressLint("SetJavaScriptEnabled")   // 只在白名单页面开启 JS,且已关闭 file 访问
@Composable
fun SecureWebView(url: String, bridge: JsBridge?) {
    AndroidView(
        factory = { context ->
            WebView(context).apply {
                with(settings) {
                    javaScriptEnabled = true                  // 业务需要,但配合下面的白名单
                    allowFileAccess = false                   // ⚠️ 禁止 file:// 读取沙盒文件
                    allowContentAccess = false
                    allowFileAccessFromFileURLs = false       // 防 file 域跨源读取
                    allowUniversalAccessFromFileURLs = false
                    domStorageEnabled = false
                    // 不缓存敏感页面,退出即失效
                    cacheMode = WebSettings.LOAD_NO_CACHE
                }
                // ⚠️ 只有白名单 URL 才注入 JS 桥,其余页面不给原生能力
                if (bridge != null && UrlWhitelist.matches(url)) {
                    addJavascriptInterface(bridge, "TechFeedBridge")
                }
                webViewClient = WhitelistWebViewClient()      // 拦截非白名单跳转
            }
        },
        update = { it.loadUrl(url) },
    )
}

View/XML 体系:

<!-- 文件路径:docs/samples/ch19/fragment_web.xml -->
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <WebView
        android:id="@+id/webView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</FrameLayout>
// 文件路径:docs/samples/ch19/WebFragment.kt
package com.techfeed.feature.detail.ui

import android.os.Bundle
import android.view.View
import android.webkit.WebSettings
import androidx.fragment.app.Fragment
import com.techfeed.feature.detail.databinding.FragmentWebBinding

class WebFragment : Fragment(R.layout.fragment_web) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = FragmentWebBinding.bind(view)
        with(binding.webView.settings) {
            javaScriptEnabled = true
            allowFileAccess = false
            allowContentAccess = false
            allowFileAccessFromFileURLs = false
            allowUniversalAccessFromFileURLs = false
            cacheMode = WebSettings.LOAD_NO_CACHE
        }
        binding.webView.webViewClient = WhitelistWebViewClient()
        binding.webView.loadUrl(requireArguments().getString("url").orEmpty())
    }

    override fun onDestroyView() {
        // ⚠️ 必须销毁 WebView,否则会泄漏整个 Activity;Compose 的 AndroidView 同理会在 dispose 时处理
        view?.findViewById<View>(R.id.webView)?.let { (it as? android.webkit.WebView)?.destroy() }
        super.onDestroyView()
    }
}
// 文件路径:docs/samples/ch19/UrlWhitelist.kt
package com.techfeed.feature.detail.ui

import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

/** URL 白名单:只允许本域 https 页面加载与跳转 */
object UrlWhitelist {
    private val HOSTS = setOf("techfeed.dev", "m.techfeed.dev")
    fun matches(url: String): Boolean = runCatching {
        val uri = android.net.Uri.parse(url)
        uri.scheme == "https" && uri.host in HOSTS
    }.getOrDefault(false)
}

class WhitelistWebViewClient : WebViewClient() {
    override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
        // 返回 true = 由我们处理(这里是拦截);非白名单一律拦掉,防钓鱼跳转
        return !UrlWhitelist.matches(request.url.toString())
    }
}

/**
 * JS 桥:方法必须加 @JavascriptInterface 才会暴露(Android 4.2+ 的硬性要求)。
 * ⚠️ 绝不要暴露"打开任意 Activity""读取文件""获取 Token"这类能力。
 */
class JsBridge(private val onShare: (String) -> Unit) {
    @JavascriptInterface
    fun share(title: String) {
        if (title.length > 200) return       // 入参也要校验,防止恶意页面传超大数据
        onShare(title)
    }
}

2.7 防调试、签名校验与防截屏

// 文件路径:docs/samples/ch19/Integrity.kt
package com.techfeed.core.common.security

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Debug
import android.util.Base64
import java.security.MessageDigest

/**
 * 完整性自检:防调试 + 签名校验。
 * 定位:提高门槛,不是银弹——客户端校验本身可被 hook,真正的防线在服务端。
 */
object Integrity {

    /** 是否被调试器附加(可被绕过,仅作参考信号) */
    fun isDebuggerAttached(): Boolean =
        Debug.isDebuggerConnected() || Debug.waitingForDebugger()

    /** 是否被 Frida 等注入(检测常见端口与特征库,属"猫鼠游戏") */
    fun isHooked(): Boolean = runCatching {
        java.io.File("/proc/self/maps").readLines()
            .any { it.contains("frida") || it.contains("xposed") }
    }.getOrDefault(false)

    /** 当前 APK 签名证书的 SHA-256(用于与服务端下发的白名单比对) */
    fun signatureSha256(context: Context): String? = runCatching {
        val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            context.packageManager.getPackageInfo(
                context.packageName, PackageManager.GET_SIGNING_CERTIFICATES,
            ).signingInfo?.apkContentsSigners?.firstOrNull()
        } else {
            @Suppress("DEPRECATION")
            context.packageManager.getPackageInfo(
                context.packageName, PackageManager.GET_SIGNATURES,
            ).signatures?.firstOrNull()
        }
        val digest = MessageDigest.getInstance("SHA-256").digest(info!!.toByteArray())
        Base64.encodeToString(digest, Base64.NO_WRAP)
    }.getOrNull()

    /** release 自检:发现异常就上报,不要本地弹窗"检测到风险"吓唬用户 */
    fun selfCheck(context: Context, onRisk: (String) -> Unit) {
        if (isDebuggerAttached()) onRisk("debugger")
        if (isHooked()) onRisk("hook")
        val sig = signatureSha256(context)
        if (sig != null && sig !in EXPECTED_SIGNATURES) onRisk("signature:$sig")
    }

    private val EXPECTED_SIGNATURES = setOf(
        "RELEASE_SIGNATURE_SHA256_BASE64",   // ⚠️ 发布签名指纹,由 CI 注入,勿写死在源码
    )
}
// 文件路径:docs/samples/ch19/SensitiveFlag.kt
package com.techfeed.feature.profile.ui

import android.view.WindowManager
import androidx.fragment.app.FragmentActivity

/**
 * 防截屏/防录屏:敏感页面(实名、支付、凭证)加上 FLAG_SECURE。
 * 副作用:该页面无法被系统截屏,也无法在最近任务列表显示缩略图。
 */
fun FragmentActivity.enableSecureFlag() {
    window.setFlags(
        WindowManager.LayoutParams.FLAG_SECURE,
        WindowManager.LayoutParams.FLAG_SECURE,
    )
}

2.8 隐私合规:最小采集与权限最小化

<!-- 文件路径:sample/app/src/main/AndroidManifest.xml(片段) -->
<!-- 1. 权限最小化:只声明真正需要的,绝不"先申请再说" -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- 2. 不需要的硬件特性声明为可选,避免应用市场据此过滤设备 -->
<uses-feature android:name="android.hardware.camera" android:required="false" />

<!-- 3. Android 13+ 的广告 ID 等敏感权限需要声明用途;国内还需声明隐私清单 -->
<uses-permission
    android:name="com.google.android.gms.permission.AD_ID"
    tools:node="remove" />

<!-- 4. 关闭备份:allowBackup=false 防止数据被 adb backup 导出 -->
<application android:allowBackup="false" android:fullBackupContent="false" ... />
// 文件路径:docs/samples/ch19/PrivacyLog.kt
package com.techfeed.core.common

import android.util.Log

/**
 * 合规日志:release 下自动失效,且内置脱敏。
 * 铁律:任何含手机号、身份证、Token、坐标的字段都不得进入日志与崩溃上报(第 18 章)。
 */
object PrivacyLog {

    var enabled = false   // 由 BuildConfig.DEBUG 在 Application 中设置;release 恒为 false

    fun d(tag: String, message: () -> String) {
        if (!enabled) return
        // 用 lambda 传入,避免 release 下仍执行字符串拼接(省性能也省信息泄露)
        Log.d(tag, message().redact())
    }

    private fun String.redact(): String = this
        .replace(Regex("1[3-9]\\d{9}"), "1**********")            // 手机号
        .replace(Regex("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+"), "***@***") // 邮箱
        .replace(Regex("(?i)(token|access_token)=\\S+"), "$1=***")
}
旧写法对照:ProGuard 与 R8、明文 SharedPreferences
维度 旧(ProGuard / 明文存储) 现在(R8 / 加密存储)
混淆工具 ProGuard,独立于编译流程,需要自己接 R8 内置于 AGP,一次流程完成收缩+优化+混淆
规则文件 proguard-project.txt,常整段 -keep proguard-rules.pro + 库模块 consumer-rules.pro,按最小面保留
行号保留 常被 -keepattributes 漏掉,堆栈无行号 必须保留 SourceFile,LineNumberTable 才能还原(第 18 章)
键值存储 SharedPreferences 明文 XML,adb 可读 EncryptedSharedPreferences,密钥由 Keystore 持有
明文流量 usesCleartextTraffic="true" 或默认允许 network_security_config 禁明文 + 证书绑定

迁移存量项目时的顺序建议:先开混淆但只 -keep 反射入口 → 跑一轮全量 UI 测试 → 再逐步收紧 keep 规则。 一次性把规则收紧到极致,翻车概率极高。


三、常见坑 ⚠️

现象 根因 解法
release 包一启动就崩,debug 正常 反射/GSON/序列化需要的类被混淆或移除 用 -keepclassmembers 精确保留;看 mapping.txt 确认是否被改名
崩溃堆栈有类名但行号全是 Unknown Source 未保留 SourceFile,LineNumberTable 补 -keepattributes SourceFile,LineNumberTable
图片/图标在 release 上不显示 shrinkResources 误删了动态拼接引用的资源 用 tools:keep 或 res/raw/keep.xml 保留
release 包体积没怎么变小 keep 规则过宽(整包 -keep ... { *; }) 逐个规则评审,改 -keepclassmembers;用 -whyareyoukeeping 查原因
EncryptedSharedPreferences 抛 GeneralSecurityException 跨版本密钥派生参数变了,或数据被外部改写 捕获后清空加密存储并让用户重新登录,别让 App 卡在启动页
加了证书绑定后老版本 App 全部连不上 只绑了主证书,证书轮换后无备份指纹 至少配两个指纹,且备份证书私钥已就绪;上线前演练轮换
抓包时 App 报网络错误 release 配置禁明文 + 只信任系统 CA 用 debug-overrides 允许 user CA,且只在 debug 变体生效
android:exported 缺失导致安装失败 Android 12+ 强制要求带 intent-filter 的组件声明 显式写 exported;不需要外部调用的一律 false
deeplink 被别的 App 抢走 未开 autoVerify,或 assetlinks 校验失败 开 autoVerify 并验证 /.well-known/assetlinks.json 可访问
WebView 加载了 file:// 私有文件 allowFileAccess / allowUniversalAccessFromFileURLs 为 true 全部置 false,并加 URL 白名单
加了 JS 接口后被页面调用任意原生能力 addJavascriptInterface 暴露了过宽的对象 只暴露窄接口 + 白名单 URL + 入参校验
加固后启动变慢、崩溃率上升 壳在启动时做解密/校验,且部分 ROM 不兼容 灰度验证启动耗时与崩溃率;在 ADR 中记录取舍
加固包被应用市场拒审 加壳/动态加载属于受限能力 上线前过合规评审,准备说明材料
上架被驳回:隐私政策未覆盖实际采集项 埋点/三方 SDK 采集了未声明的数据 列三方 SDK 清单,逐项对齐隐私清单
Token 出现在日志或崩溃 extra 里 日志与上报未脱敏 统一走 PrivacyLog 与 setBeforeSend 脱敏(第 18 章)

四、调试技巧

  1. 验证混淆效果:
    # 查看某个类为什么被保留(R8 官方诊断开关)
    ./gradlew :app:assembleRelease --info | grep -i "why are you keeping"
    # 直接在规则里加 -whyareyoukeeping class com.techfeed.xxx.Yyy
    # 对比两个版本的 mapping,看新增/消失了哪些类
    diff old-mapping.txt app/build/outputs/mapping/release/mapping.txt | head -50
    
  2. 本地还原混淆堆栈(第 18 章已给命令,配合本章的行号保留一起验证):
    ./gradlew :app:retraceRelease --stacktrace-file ./obfuscated.txt
    
  3. 检查 APK 的攻击面:
    # 列出所有导出的组件(安全评审必做)
    aapt2 dump xmltree app/build/outputs/apk/release/app-release.apk AndroidManifest.xml | grep -B3 exported
    # 检查是否残留明文 HTTP 配置
    grep -r "usesCleartextTraffic" app/src/main/
    
  4. 抓包验证证书绑定:用 mitmproxy 装用户 CA 抓 debug 包应能成功;抓 release 包应直接失败——失败才说明绑定生效。
  5. 检查敏感数据是否落盘明文:
    adb shell run-as com.techfeed.app ls -R /data/data/com.techfeed.app/shared_prefs
    adb shell run-as com.techfeed.app cat /data/data/com.techfeed.app/shared_prefs/*.xml
    # 加密存储的 key 应该是 base64 密文,看到明文 token 就是没接上
    
  6. Android Studio 的 App Inspection → Database Inspector / Device Explorer:直观看 EncryptedFile 产物的内容,确认是密文。
  7. 渗透自查清单跑一遍:adb shell am start -n 尝试直接启动内部 Activity;adb shell am broadcast 尝试触发内部 Receiver——成功的都说明 exported 配错了。

五、最佳实践

  1. keep 规则最小化:-keepclassmembers 优先于 -keep,指定成员优先于 { *; }。
    • ✅ 推荐:-keepclassmembers class com.techfeed.core.model.** { <fields>; }
    • ❌ 避免:-keep class com.techfeed.** { *; }
  2. 行号必须保留:-keepattributes SourceFile,LineNumberTable 是 release 可维护性的底线。
  3. 密钥交给 Keystore,不要自己生成对称密钥存文件:客户端里任何"写死的密钥"都等于公开。
    • ✅ 推荐:MasterKey.Builder(context).setKeyScheme(AES256_GCM)
    • ❌ 避免:val key = "my-secret-1234567890abcdef".toByteArray()
  4. 证书绑定务必配备份指纹,并在上线前演练一次证书轮换。
  5. 组件默认不导出:exported="false" 是默认值之外的"显式声明",评审时逐个过。
    • ✅ 推荐:所有组件显式写 android:exported
    • ❌ 避免:依赖系统默认推断
  6. 外部输入一律白名单:deeplink 的 scheme/host/path、WebView 的 URL、JS 桥的入参,先校验再解析。
  7. WebView 四件套全关:allowFileAccess、allowContentAccess、allowFileAccessFromFileURLs、allowUniversalAccessFromFileURLs。
  8. 防逆向只做"提高门槛":防调试、签名校验作为上报信号而非本地拦截——本地弹"检测到风险"既可被绕过又伤害正常用户。
  9. 加固需评审:评估启动耗时、崩溃率、包体、兼容性四项成本,写进 ADR(第 22 章)再决定。
  10. 日志与上报统一脱敏:release 关闭日志、崩溃 extra 走 setBeforeSend 过滤,禁止打印 Token/手机号。
  11. 权限最小化 + 隐私清单对齐:每引入一个三方 SDK,就同步更新隐私清单与"采集项—用途"对照表。
  12. 安全变更也要灰度:证书绑定、加固、加密存储迁移都属于"改了就全量炸"的改动,必须走灰度与回滚预案(第 20 章)。

六、本章小结

  1. R8 是压缩+优化+混淆三合一,keep 越宽收益越低;保留 SourceFile,LineNumberTable 才能还原堆栈。
  2. 敏感数据分层存:密钥进 Keystore、小数据用 EncryptedSharedPreferences、大文件用 EncryptedFile、密码永不落盘。
  3. 网络安全靠声明式配置:network_security_config 禁明文、限定信任锚、证书绑定(含备份指纹),debug-overrides 只服务调试。
  4. 入口是主要攻击面:组件 exported=false、Intent 显式指定包名、deeplink 白名单 + autoVerify、WebView 关 file 访问并限制 JS 桥。
  5. 合规优先于对抗:防逆向只能抬高门槛,热修复与加固存在市场政策风险;最小采集、最小权限、日志脱敏是必须交付的硬指标。

安全加固让 App "更难被攻破"(本章),接下来要解决的是"怎么稳定、可重复地把它交付出去"。

下一章:进入构建配置与发布流程——构建变体、签名管理、AAB 与 APK 的取舍,以及一条从提交到灰度的 CI/CD 流水线。


七、动手练习

基础题

  1. 给 release 构建类型开启 R8 与资源压缩,故意不加任何 -keep,跑一遍 App 找出崩在哪里,再逐个补上最小化的规则并说明为什么必须保留。
  2. 把 Token 存储改造成 2.2 节的 TokenStorage,然后用 adb shell run-as 查看 shared_prefs,确认磁盘上是密文。
  3. 配置 network_security_config.xml:禁明文、只信任系统 CA、debug 允许 user CA。用抓包工具验证 debug 能抓、release 抓不到。

进阶题 4. 实现 DeepLinkGuard 并写 JVM 单测,覆盖:非法 scheme、非法 host、非白名单 path、id 含特殊字符四类输入,断言全部返回 Unknown。 5. 给详情页的 WebView 加上白名单 WebViewClient 与受限 JS 桥(Compose 用 AndroidView、View 用 XML 各写一份),验证 file:///data/data/... 被拦截。 6. 设计一个"证书轮换演练"方案:在不发版的前提下,验证主证书过期时备份指纹能让客户端继续正常工作,并写出回滚步骤。

思考题 7. 为什么"客户端里的密钥"本质上都不安全?Keystore 凭什么能做到相对安全?它依赖的是什么硬件/系统能力?如果设备没有可信执行环境(TEE)会怎样? 8. 团队负责人要求"上加固,防住所有逆向"。你会怎么回应?请列出评估表(启动耗时、崩溃率、包体、兼容性、合规)并给出你的建议结论。 9. 证书绑定提高了安全性,却也引入了"老版本失联"的风险。如果让你设计一套可演进的方案,你会如何在安全与可用性之间取舍?

📄 第 20 章 构建配置与发布流程 20-build-variants-release.md

阶段:企业级 | 预计阅读 60 min | 代码落地模块::app、:core:*、:feature:* 前置:第 02 章 工程结构与 Gradle | 第 19 章 安全加固 相关章节:第 15 章 模块化与组件化拆分 | 第 18 章 稳定性与崩溃监控 | 第 22 章 团队协作规范

学习目标

  • [ ] 理解 Build Type × Product Flavor = Variant(构建变体) 的笛卡尔积关系,能设计渠道包/环境包的维度划分。
  • [ ] 掌握 productFlavors 与 flavorDimensions、manifestPlaceholders、buildConfigField / resValue 的用法边界。
  • [ ] 会生成与管理签名密钥,理解 v1/v2/v3 签名与 Play App Signing(应用签名)的分工,以及"签名不进版本库"的落地方式。
  • [ ] 能说清 APK 与 AAB(Android App Bundle)的区别与选择依据,会用 splits 控制 ABI/密度分包。
  • [ ] 能搭出一条 CI/CD 流水线(checkout → 校验 → ktlint → 单测 → 构建 → 签名 → 分发),并执行发布检查清单。

一、核心概念

1.1 变体是怎么算出来的

Android 构建有两个正交的维度:

维度 含义 典型取值
Build Type(构建类型) 怎么构建:是否混淆、是否可调试、签名用哪个 debug / release(可加 benchmark、staging)
Product Flavor(产品风味) 构建什么:渠道、环境、付费/免费版 googlePlay / huawei / xiaomi,或 dev / staging / prod

变体数 = Build Type 数 × Flavor 组合数。任务名也随之生成:assembleGooglePlayRelease、assembleDevDebug。

flowchart LR
    subgraph BT["Build Type"]
        D["debug<br/>可调试 + debug 签名"]
        R["release<br/>混淆 + 正式签名"]
    end
    subgraph PF["Product Flavor"]
        G["googlePlay"]
        H["huawei"]
        X["xiaomi"]
    end
    BT -->|"笛卡尔积"| V["6 个 Variant<br/>googlePlayDebug / googlePlayRelease<br/>huaweiDebug / huaweiRelease<br/>xiaomiDebug / xiaomiRelease"]
    PF --> V

理解这一点就能解释很多现象:为什么改了 flavor 后构建变慢了(变体数翻倍)、为什么某个资源只在一个渠道生效(源码集优先级),以及为什么多渠道包要用 flavorDimensions 而不是建 N 个 flavor。

1.2 什么时候用 flavorDimensions

当"风味"本身有两类互不相关的含义时(既要区分环境,又要区分渠道),就要用维度:

flowchart TB
    A["flavorDimensions: env, channel"]
    A --> B["env: dev / staging / prod"]
    A --> C["channel: googlePlay / huawei / xiaomi"]
    B --> D["3 × 3 = 9 个 flavor 组合"]
    C --> D
    D --> E["× 2 buildType = 18 个 Variant"]

[!WARNING] 变体不是越多越好。18 个变体意味着 CI 上 18 倍的构建时间,而其中真正会发布的可能只有 3 个。 用 variantFilter 把不需要的组合(如 prodDebug)直接裁掉,这是中大型项目的必做优化。

1.3 三种"注入配置"的方式

方式 注入到 适用场景 代价
buildConfigField BuildConfig 常量 代码里读的开关、URL、开关位 编译进类,改动触发重编译
resValue 资源(string/bool) XML 里引用,如 android:authorities、deeplink host 触发资源重编译
manifestPlaceholders AndroidManifest.xml 占位符 FileProvider authority、deeplink scheme、SDK key 最轻量

原则:能在 Manifest 里解决的用占位符,需要代码分支的用 buildConfigField,需要 XML 引用的用 resValue。三者都不要塞敏感信息——它们最终都在 APK 里明文可见(第 19 章)。

1.4 签名:三件事必须分开

概念 是什么 谁来持有
keystore(.jks / .p12) 存放密钥对的容器文件 公司的安全存储,绝不能进 Git
signingConfig Gradle 里的签名配置(别名、密码) 从 keystore.properties 或环境变量读取
应用签名(Play App Signing) 由 Google 托管真正的分发签名 平台;你只持有上传密钥(upload key)
flowchart LR
    A["开发者本地 keystore<br/>(上传密钥)"] --> B["上传 AAB 到 Google Play"]
    B --> C["Play App Signing<br/>用平台密钥重签"]
    C --> D["按设备下发优化后的 APK"]
    A2["国内渠道"] --> E["各自用本地 keystore 直接签 APK"]

Play App Signing 的价值:上传密钥丢了可以找 Google 重置;而如果你自己持有分发签名且丢了,这个应用就永远无法更新了。

签名方案的演进:

方案 版本 特点
v1(JAR 签名) 全部 只签 DEX 与资源条目,APK 的其余部分不受保护;已不推荐单独使用
v2(APK 签名方案 v2) Android 7.0+ 对整个 APK 做签名,防篡改能力强,安装校验更快
v3 Android 9+ 支持签名密钥轮换,配合 v2 使用
v4 Android 11+ 支持 ADB 增量安装,随 v2/v3 一起生成

1.5 APK 还是 AAB

维度 APK AAB
内容 完整可安装的包 发布格式,包含所有资源,不含最终安装形态
安装 可直接安装 不能直接安装,需 bundletool 转成分发 APK
体积 全量资源都打进去 Google Play 按设备(ABI/密度/语言)下发最小集,通常省 15%–35%
分发 任意渠道 Google Play 强制;国内渠道多数仍需 APK
调试 — 用 bundletool build-apks 生成本地测试包

结论:Google Play 走 AAB;国内渠道与内测分发走 APK。所以企业级项目往往两套产物都要出,CI 里配两个 job。

1.6 版本管理

字段 作用 规则
versionCode 系统与市场判断新旧,只增不减 正整数,建议 major*10000 + minor*100 + patch 或直接用 CI 构建号
versionName 展示给用户 语义化版本(Semantic Versioning, SemVer):MAJOR.MINOR.PATCH

语义化版本的约定:MAJOR 不兼容变更,MINOR 向后兼容的新功能,PATCH 向后兼容的修复。预发布版加后缀:1.2.0-beta.1。

[!TIP] versionCode 建议用 CI 构建号(GitHub Actions 的 github.run_number、Jenkins 的 BUILD_NUMBER)自动递增, 彻底消灭"发版时忘了改 versionCode"这个高频事故。

三个容易踩坑的细节:

  1. versionCode 上限是 2^31-1(约 21 亿)。用 major*10000 + minor*100 + patch 这类编码方案时,major 超过 21 万就会溢出——虽然那一天大概率不会到来,但用 CI 构建号递增也要注意它单调增长的上限。
  2. 多渠道包必须共用同一个 versionCode(或按 ABI 有确定偏移)。同一个应用在不同市场出现不同 versionCode,会让"哪个版本更新"这件事变得无法判断,也会让监控平台按版本聚合时产生分裂。
  3. versionName 要能被用户复述。客服收到反馈"我的版本是 1.2.0-beta.3 (4567)"时,最好能一眼对应到 CI 的第 4567 次构建,所以建议在 versionName 后缀带上构建号,或至少在"关于"页里同时展示两者。

1.7 发布节奏与回滚

flowchart LR
    A["内测<br/>内部/测试人群"] --> B["灰度 1%<br/>看崩溃率与核心指标"]
    B --> C["灰度 10%<br/>看业务指标"]
    C --> D["全量 100%"]
    B -->|"崩溃率超阈值"| E["暂停灰度"]
    C -->|"劣化"| E
    D -->|"严重问题"| F["回滚<br/>下架 + 推老版本"]
    E --> G["修复后重新发补丁版"]

四个阶段各自要回答的问题:

阶段 人群 观察重点 通过条件
内测 内部 + 测试(10–50 人) 功能完整性、真机兼容性、低端机表现 无阻塞缺陷,核心链路可走通
灰度 1% → 10% 真实用户 崩溃率、ANR 率、启动耗时、核心转化率 各项指标不劣化于上一版本基线
全量 100% 崩溃率、业务大盘、用户反馈 —
回滚 已升级用户 修复版的到达率 修复版到达率 > 70% 视为止血成功

回滚在 Android 上永远是"减速带"不是"撤销键":已经装上的客户端不会自动退回。所以回滚动作实际是"停止分发新版本 + 引导用户升级到修复版",这意味着止损的重点在灰度阶段,而不是全量之后。

热修复的边界:它能在一个小时内把补丁推到存量客户端,看起来是最快的止损手段,但它的定位必须严格限定为"紧急止血",原因是三条硬约束——① 补丁走的是运行时加载代码,多家应用市场对此有明确限制,存在下架风险(第 18、19 章);② 补丁不经过完整的测试与灰度流程,本身就是新的稳定性风险;③ 补丁与版本的对应关系会随时间失控,"线上到底跑的是哪个代码"越来越难回答。因此:能用功能开关关掉的(第 18 章的 KillSwitch)绝不用热修复;必须热修的,也要在下一个正式版本里把补丁逻辑正式合入并重新发版,避免补丁长期游离在版本体系之外。


二、示例代码

2.1 双维度变体配置

[!NOTE] 下面是一份完整教学版配置(三渠道 × 三环境,用于展示笛卡尔积与裁剪技巧)。sample 工程实际采用更精简的 env: dev/prod × channel: store/direct 两维度四变体(见 sample/app/build.gradle.kts),后文 CI 示例的任务名(如 testProdStoreDebugUnitTest)以此为准。

// 文件路径:sample/app/build.gradle.kts
import java.util.Properties
import java.io.FileInputStream

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
}

// ---------- 签名配置:从 keystore.properties 读取,绝不硬编码 ----------
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
    if (keystorePropertiesFile.exists()) {
        load(FileInputStream(keystorePropertiesFile))
    }
}

android {
    namespace = "com.techfeed.app"
    compileSdk = libs.versions.compileSdk.get().toInt()

    defaultConfig {
        applicationId = "com.techfeed.app"
        minSdk = libs.versions.minSdk.get().toInt()
        targetSdk = libs.versions.targetSdk.get().toInt()
        // versionCode 由 CI 注入,本地默认 1
        versionCode = (providers.gradleProperty("versionCode").getOrNull() ?: "1").toInt()
        versionName = providers.gradleProperty("versionName").getOrNull() ?: "1.0.0"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

        // Manifest 占位符:不同渠道用不同 authority,避免多包共存冲突
        manifestPlaceholders["deepLinkHost"] = "techfeed.dev"
        manifestPlaceholders["fileProviderAuthority"] = "com.techfeed.app.fileprovider"
    }

    // ---------- 签名:正式签名只在文件存在时才注册 ----------
    signingConfigs {
        if (keystorePropertiesFile.exists()) {
            create("release") {
                storeFile = file(keystoreProperties["storeFile"] as String)
                storePassword = keystoreProperties["storePassword"] as String
                keyAlias = keystoreProperties["keyAlias"] as String
                keyPassword = keystoreProperties["keyPassword"] as String
                enableV3Signing = true    // 启用 v3,支持未来密钥轮换
                enableV4Signing = true    // 便于 ADB 增量安装调试
            }
        }
    }

    // ---------- 两个维度:环境 + 渠道 ----------
    flavorDimensions += listOf("env", "channel")

    productFlavors {
        // 环境维度
        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
            versionNameSuffix = "-dev"
            buildConfigField("String", "API_BASE_URL", "\"https://dev-api.techfeed.dev/\"")
            resValue("string", "app_name", "TechFeed Dev")
            manifestPlaceholders["deepLinkHost"] = "dev.techfeed.dev"
        }
        create("staging") {
            dimension = "env"
            applicationIdSuffix = ".staging"
            versionNameSuffix = "-staging"
            buildConfigField("String", "API_BASE_URL", "\"https://staging-api.techfeed.dev/\"")
            resValue("string", "app_name", "TechFeed 预发")
        }
        create("prod") {
            dimension = "env"
            buildConfigField("String", "API_BASE_URL", "\"https://api.techfeed.dev/\"")
            resValue("string", "app_name", "TechFeed")
        }

        // 渠道维度
        create("googlePlay") {
            dimension = "channel"
            buildConfigField("String", "CHANNEL", "\"google_play\"")
            buildConfigField("boolean", "USE_FCM", "true")
        }
        create("huawei") {
            dimension = "channel"
            buildConfigField("String", "CHANNEL", "\"huawei\"")
            buildConfigField("boolean", "USE_FCM", "false")   // 国内渠道走厂商推送
        }
        create("xiaomi") {
            dimension = "channel"
            buildConfigField("String", "CHANNEL", "\"xiaomi\"")
            buildConfigField("boolean", "USE_FCM", "false")
        }
    }

    buildTypes {
        debug {
            applicationIdSuffix = ".debug"
            versionNameSuffix = "-debug"
            isMinifyEnabled = false
            buildConfigField("boolean", "CRASH_REPORT_ENABLED", "false")
        }
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
            // 未配置正式签名时回落到 debug 签名,保证 CI 上"没密钥也能构建"
            signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
            buildConfigField("boolean", "CRASH_REPORT_ENABLED", "true")
        }
        // 基准测试变体:与 release 同配置但关闭混淆,方便 Profiler 定位(第 17 章)
        create("benchmark") {
            initWith(buildTypes.getByName("release"))
            isMinifyEnabled = false
            signingConfig = signingConfigs.getByName("debug")
            matchingFallbacks += listOf("release")
        }
    }

    // ---------- 裁剪不需要的变体:18 个砍到 12 个 ----------
    variantFilter {
        val env = flavors.first { it.dimension == "env" }.name
        val channel = flavors.first { it.dimension == "channel" }.name
        val type = buildType.name
        // dev 只需要一个渠道(内测用),prod 的 debug 包没意义
        if ((env == "dev" && channel != "googlePlay") || (env == "prod" && type == "debug")) {
            ignore = true
        }
    }

    // ---------- 按 ABI / 密度分包(仅 APK 场景) ----------
    splits {
        abi {
            isEnable = true
            reset()
            include("arm64-v8a", "armeabi-v7a")
            isUniversalApk = true     // 同时产出通用包,兜底未知 ABI
        }
        density {
            isEnable = false          // 密度分包收益小、管理成本高,通常不开
        }
    }

    // 分包后每个 APK 必须有独立 versionCode,否则渠道市场无法区分新旧
    val abiCodes = mapOf("armeabi-v7a" to 1, "arm64-v8a" to 2, "x86_64" to 4)
    androidComponents {
        onVariants { variant ->
            variant.outputs.forEach { output ->
                val abi = output.filters.firstOrNull { it.filterType == "ABI" }?.identifier
                // 通用包无 ABI filter,偏移为 0;单 ABI 包按映射追加偏移
                val offset = abiCodes[abi] ?: 0
                val base = output.versionCode.getOrNull() ?: 0
                output.versionCode.set(base * 10 + offset)
            }
        }
    }

    buildFeatures { compose = true; buildConfig = true }
    packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
}

dependencies {
    implementation(projects.core.designsystem)
    implementation(projects.core.navigationApi)
    implementation(projects.feature.feed)
    implementation(projects.feature.detail)
    implementation(projects.feature.auth)
    implementation(projects.feature.profile)
    implementation(projects.feature.push)

    // 渠道专属依赖:只有 huawei 变体才引入厂商推送 SDK
    "huaweiImplementation"(libs.huawei.push)

    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.bundles.compose)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    testImplementation(libs.bundles.testing.unit)
}
# 文件路径:docs/samples/ch20/keystore.properties.example(提交到仓库的模板,真实文件在 .gitignore 里)
storeFile=../keystore/techfeed-release.jks
storePassword=CHANGE_ME
keyAlias=techfeed
keyPassword=CHANGE_ME
# 文件路径:sample/.gitignore(片段)
keystore.properties
keystore/
*.jks
*.p12
local.properties
build/
.gradle/

2.2 生成 keystore

# 生成正式签名密钥(一次性操作,生成后妥善保管,丢失 = 应用无法更新)
keytool -genkeypair -v \
  -keystore techfeed-release.jks \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -alias techfeed \
  -storepass "$STORE_PASS" -keypass "$KEY_PASS" \
  -dname "CN=TechFeed, OU=Mobile, O=TechFeed Inc, L=Beijing, S=Beijing, C=CN"

# 查看签名指纹(上架、接第三方 SDK 都要用)
keytool -list -v -keystore techfeed-release.jks -alias techfeed

# 验证 APK 签名方案版本(确认 v2/v3 已生效)
apksigner verify --verbose --print-certs app-release.apk | grep -i "verified using"

2.3 Manifest 中的占位符与渠道差异

<!-- 文件路径:sample/app/src/main/AndroidManifest.xml(片段) -->
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${fileProviderAuthority}"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <!-- 占位符:dev 变体用 dev.techfeed.dev,prod 用 techfeed.dev -->
        <data android:scheme="https" android:host="${deepLinkHost}" android:pathPrefix="/article" />
    </intent-filter>
</activity>

渠道专属源码集:sample/app/src/huawei/java/... 下的代码只在 huawei 变体编译,用来放厂商推送实现(第 26 章)。

2.4 变体专属资源与"环境横幅"(Compose / View 双体系)

非 prod 环境需要在界面上明示,避免测试同学拿预发包当正式版验收。

Compose 体系:

// 文件路径:docs/samples/ch20/EnvBanner.kt
package com.techfeed.app.ui

import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

/**
 * 非 prod 环境顶部横幅。BuildConfig 由 buildConfigField 注入,编译期常量。
 * ⚠️ 不要用它承载任何敏感信息:BuildConfig 里的字符串在 APK 中明文可见。
 */
@Composable
fun EnvBanner(modifier: Modifier = Modifier) {
    if (BuildConfig.BUILD_TYPE == "release" && BuildConfig.FLAVOR.contains("prod")) return
    Text(
        text = "环境:${BuildConfig.FLAVOR} / ${BuildConfig.BUILD_TYPE} · ${BuildConfig.VERSION_NAME}",
        modifier = modifier.background(MaterialTheme.colorScheme.errorContainer),
        color = MaterialTheme.colorScheme.onErrorContainer,
        style = MaterialTheme.typography.labelMedium,
    )
}

View/XML 体系:

<!-- 文件路径:docs/samples/ch20/view_env_banner.xml -->
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/tvEnvBanner"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="?attr/colorErrorContainer"
    android:padding="6dp"
    android:textColor="?attr/colorOnErrorContainer"
    android:textSize="12sp"
    android:visibility="gone" />
// 文件路径:docs/samples/ch20/EnvBannerView.kt
package com.techfeed.app.ui

import android.view.View
import android.widget.TextView

/** View 体系:用 resValue 注入的 app_name 与 BuildConfig 组合出环境标识 */
fun TextView.bindEnvBanner() {
    val text = "环境:${BuildConfig.FLAVOR} / ${BuildConfig.BUILD_TYPE} · ${BuildConfig.VERSION_NAME}"
    val isProdRelease = BuildConfig.BUILD_TYPE == "release" && BuildConfig.FLAVOR.contains("prod")
    visibility = if (isProdRelease) View.GONE else View.VISIBLE
    this.text = text
}

2.5 AAB 与本地测试

# 构建 AAB(Google Play 用)
./gradlew :app:bundleProdGooglePlayRelease

# 用 bundletool 把 AAB 转成本地可安装的 APK 集合(真机验证必做)
java -jar bundletool.jar build-apks \
  --bundle=app/build/outputs/bundle/prodGooglePlayRelease/app-prod-googlePlay-release.aab \
  --output=app.apks \
  --ks=keystore/techfeed-release.jks --ks-key-alias=techfeed \
  --ks-pass=pass:$STORE_PASS --key-pass=pass:$KEY_PASS

# 安装到已连接设备(bundletool 会自动按设备配置挑合适的 APK)
java -jar bundletool.jar install-apks --apks=app.apks

# 查看包体积构成(第 17 章包体优化的输入)
./gradlew :app:analyzeProdGooglePlayRelease

2.6 CI/CD:GitHub Actions

# 文件路径:.github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  # 同一分支新提交自动取消旧构建,省 CI 资源
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  verify:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'

      - uses: gradle/actions/setup-gradle@v4   # 自带缓存与构建扫描

      - name: 授予 gradlew 执行权限
        run: chmod +x ./gradlew

      # 1) 静态检查:能自动判定的一律交给机器(第 22 章)
      - name: ktlint 检查
        run: ./gradlew ktlintCheck

      - name: detekt 检查
        run: ./gradlew detekt

      - name: Android Lint
        run: ./gradlew :app:lintProdStoreDebug

      # 2) 单元测试:快,PR 阶段必须全绿。
      # flavor 只在 :app 声明:core/feature 模块的任务是 testDebugUnitTest,
      # app 模块的任务带变体名 —— 两个都要跑,feature 的测试才不会漏
      - name: 单元测试
        run: ./gradlew testDebugUnitTest testProdStoreDebugUnitTest

      - name: 单元测试覆盖率(Kover)
        run: ./gradlew koverHtmlReportProdStoreDebug

      - name: 上传测试报告
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: '**/build/reports/tests'

  build:
    needs: verify
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'   # 只有主干才出正式产物
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - uses: gradle/actions/setup-gradle@v4

      # 3) 还原签名:CI 里从 Secrets 写回 keystore 文件,构建后删除
      - name: 还原签名文件
        env:
          KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
        run: |
          echo "$KEYSTORE_BASE64" | base64 -d > keystore/techfeed-release.jks
          cat > keystore.properties <<EOF
          storeFile=../keystore/techfeed-release.jks
          storePassword=${{ secrets.STORE_PASSWORD }}
          keyAlias=techfeed
          keyPassword=${{ secrets.KEY_PASSWORD }}
          EOF

      # 4) 构建:versionCode 用 run_number 自动递增,杜绝人工遗忘
      - name: 构建 AAB 与 APK
        env:
          ORG_GRADLE_PROJECT_versionCode: ${{ github.run_number }}
          ORG_GRADLE_PROJECT_versionName: ${{ vars.VERSION_NAME }}
        run: |
          ./gradlew :app:bundleProdStoreRelease
          ./gradlew :app:assembleProdDirectRelease

      # 5) 上传符号表(第 18 章):必须在归档前完成
      - name: 上传符号表
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
        run: ./gradlew :app:collectMapping && sentry-cli upload-proguard app/build/artifacts/mapping/*.txt

      # 6) 分发:内测平台 / 应用市场
      - name: 上传到内测分发平台
        run: |
          curl -F "file=@app/build/outputs/apk/prodDirect/release/app-prod-direct-release.apk" \
               -F "uKey=$FIR_UKEY" -F "_api_key=$FIR_API_KEY" \
               https://upload.bq04.com/apps

      - name: 归档产物
        uses: actions/upload-artifact@v4
        with:
          name: release-artifacts
          path: |
            app/build/outputs/bundle/**/*.aab
            app/build/outputs/apk/**/*.apk
            app/build/artifacts/mapping/

      - name: 清理签名文件
        if: always()
        run: rm -f keystore/techfeed-release.jks keystore.properties

2.7 CI/CD:Jenkins(Jenkinsfile 对照)

// 文件路径:Jenkinsfile
pipeline {
    agent any
    options { timeout(time: 40, unit: 'MINUTES'); buildDiscarder(logRotator(numToKeepStr: '30')) }

    environment {
        JAVA_HOME = tool 'jdk17'
        // 凭据统一从 Jenkins Credentials 读取,绝不写进 Jenkinsfile
        STORE_PASSWORD = credentials('techfeed-store-password')
        KEY_PASSWORD   = credentials('techfeed-key-password')
    }

    stages {
        stage('Checkout') {
            steps { checkout scm }
        }
        stage('静态检查') {
            steps { sh './gradlew ktlintCheck detekt' }
        }
        stage('单元测试') {
            steps { sh './gradlew testDebugUnitTest testProdStoreDebugUnitTest' }
            post {
                always { junit '**/build/test-results/**/*.xml' }   // 失败也能看到报告
            }
        }
        stage('构建') {
            steps {
                sh './gradlew :app:assembleProdDirectRelease -PversionCode=${BUILD_NUMBER}'
            }
        }
        stage('签名校验') {
            steps {
                sh 'apksigner verify --print-certs app/build/outputs/apk/prodDirect/release/*.apk'
            }
        }
        stage('分发') {
            when { branch 'main' }
            steps {
                sh './scripts/upload_to_channel.sh'   // 渠道上传脚本,含失败重试
            }
        }
    }

    post {
        failure { /* 通知到企业微信/钉钉群 */ }
        always  { archiveArtifacts artifacts: 'app/build/outputs/**/*.apk', fingerprint: true }
    }
}

2.8 发布检查清单

# 文件路径:docs/release-checklist.md(模板,每次发版逐项打勾)

## 构建与产物
- [ ] versionCode 已递增且大于线上版本;versionName 符合语义化版本
- [ ] 构建变体正确(prod + 对应渠道),非 prod 环境包未混入
- [ ] R8 已开启,mapping.txt 已上传并归档(第 18 章)
- [ ] 签名方案为 v2/v3,签名指纹与线上一致(apksigner verify 通过)

## 代码与配置
- [ ] 日志开关:release 下 PrivacyLog.enabled == false(第 19 章)
- [ ] 崩溃监控开启,release 名与 mapping 匹配,验证过一次测试上报
- [ ] 调试入口、隐藏菜单、Mock 数据开关在 release 中不可达
- [ ] 接口地址指向生产环境;证书绑定配置正确且含备份指纹

## 权限与合规
- [ ] 权限清单与隐私清单一致,无新增未声明权限
- [ ] 三方 SDK 清单已更新,采集项在隐私政策中覆盖
- [ ] 无明文 HTTP、无硬编码密钥/密钥写在源码
- [ ] 加固/热修复若使用,已完成合规评审(第 19 章)

## 质量
- [ ] 全量单元测试通过;核心链路 UI 测试通过
- [ ] 启动耗时、列表帧率未劣化于基线(第 17 章)
- [ ] 内测包已被至少 5 人真机验证(含 1 台低端机)
- [ ] 埋点校验:核心事件上报正常,无重复/丢失

## 发布
- [ ] 灰度计划已定(比例、观察时长、止损阈值)
- [ ] 回滚预案已定(停止分发 + 修复版 versionCode)
- [ ] 应用商店文案、截图、更新说明已准备

2.9 产物命名与可追溯性

默认产物名是 app-prod-huawei-release.apk,一堆包放在一起时根本分不清。统一改名能让分发、归档、回溯都省事:

// 文件路径:sample/app/build.gradle.kts(片段)
androidComponents {
    onVariants(selector().withBuildType("release")) { variant ->
        variant.outputs.forEach { output ->
            val abi = output.filters.firstOrNull { it.filterType == "ABI" }?.identifier
            // 命名格式:TechFeed-<versionName>-<versionCode>-<flavor>-<abi>.apk
            // 反查线上问题时,看到文件名就知道版本、渠道与 ABI,无需解压
            val name = listOfNotNull(
                "TechFeed",
                variant.versionName.getOrNull(),
                variant.versionCode.getOrNull()?.toString(),
                variant.flavorName,
                abi,
            ).joinToString("-")
            output.outputFileName.set("$name.apk")
        }
    }
}

同时在"关于"页暴露版本信息,让客服与测试能一键复制:

// 文件路径:docs/samples/ch20/VersionInfo.kt
package com.techfeed.feature.profile.ui

/**
 * 版本指纹:一行文本包含排查问题所需的全部信息。
 * ⚠️ 不要在里面放设备标识符等个人信息,它会被用户复制后贴到工单里。
 */
fun buildVersionFingerprint(
    versionName: String,
    versionCode: Int,
    flavor: String,
    buildType: String,
): String = listOf(
    "v$versionName",
    "($versionCode)",
    "flavor=$flavor",
    "type=$buildType",
).joinToString(" ")

2.10 灰度与止损的配置化

// 文件路径:docs/samples/ch20/ReleaseGate.kt
package com.techfeed.app.release

import com.techfeed.core.common.KillSwitch

/**
 * 发布门禁:把"哪些功能在本次灰度中开放"集中在一处,
 * 便于发版前一次性复核,也便于止损时远程关停(第 18 章)。
 */
object ReleaseGate {

    fun enabledFeatures(killSwitch: KillSwitch): Set<String> = buildSet {
        if (killSwitch.isEnabled("feature.push_v2")) add("push_v2")
        if (killSwitch.isEnabled("feature.feed_redesign")) add("feed_redesign")
        if (killSwitch.isEnabled("feature.webview_preload")) add("webview_preload")
    }
}
旧写法对照:v1(JAR)签名与手工打包
// 旧写法:Groovy DSL + 只开 v1 签名 + 版本号写死
android {
    signingConfigs {
        release {
            storeFile file("techfeed.jks")
            storePassword "123456"        // ⚠️ 密码硬编码进版本库
            keyAlias "techfeed"
            keyPassword "123456"
        }
    }
    defaultConfig {
        versionCode 42                     // ⚠️ 手工维护,迟早会忘
        versionName "4.2"
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
            v2SigningEnabled false         // ⚠️ 关掉 v2,篡改防护弱、安装校验慢
        }
    }
}

三处必须改掉:密码硬编码(改从 keystore.properties/环境变量读)、versionCode 手工维护(改由 CI 注入)、 关闭 v2 签名(现代项目应保持 v2+v3 开启)。另外 Groovy DSL(.gradle)建议迁移到 Kotlin DSL(.kts), 获得类型安全与 IDE 补全——本教程全书统一使用 KTS。


三、常见坑 ⚠️

现象 根因 解法
变体数量爆炸,CI 构建时间翻倍 flavor 维度组合过多 用 variantFilter 裁掉无意义组合;非必要不新增维度
Could not resolve all variants 库模块缺少对应 flavor,且未提供 matchingFallbacks 库模块配 matchingFallbacks,或把 flavor 下沉到 defaultConfig
签名配置提交后被安全扫描告警 keystore.properties / .jks 进了 Git 加 .gitignore;CI 用 Secrets 注入 base64 keystore
换了机器构建报 keystore.properties not found 文件本地私有 提供 keystore.properties.example 模板;无签名时回落 debug 签名保证 CI 可跑
上传市场提示"签名不一致" 用了不同 keystore 或不同 alias 核对指纹;Google Play 走 Play App Signing 后应始终用同一上传密钥
versionCode 未递增导致用户收不到更新 手工维护遗漏 由 CI 用 run_number / BUILD_NUMBER 自动注入
分包后渠道市场上只识别到一个包 各 ABI 包用了相同 versionCode 按 ABI 偏移生成独立 versionCode,或只上传 universal 包
release 包体积远大于预期 未开 R8 / 资源压缩;或 keep 规则过宽(第 19 章) 开 isMinifyEnabled + isShrinkResources;用 Analyzer 看构成
AAB 本地装不上 AAB 不是可安装格式 用 bundletool build-apks + install-apks
灰度后崩溃率飙升才发现 未设观察窗口与止损阈值 灰度 1% 观察 24h,崩溃率超基线 1.5 倍自动告警(第 18 章)
回滚后用户仍在用坏版本 Android 无法强制降级已装客户端 回滚 = 停止分发 + 快速发修复版;止损重点是灰度阶段
buildConfigField 里放了密钥被扫描出来 BuildConfig 常量在 APK 中明文 敏感值走服务端下发或 NDK;见第 19 章
CI 上构建成功但产物没上传 artifact 路径写错或 job 顺序错 用 actions/upload-artifact 指定明确路径;符号表上传必须在归档前

四、调试技巧

  1. 列出当前工程所有变体:
    ./gradlew :app:tasks --all | grep -i assemble
    
  2. 看某个变体最终解析出的配置(排查"为什么这个渠道的 URL 不对"):
    ./gradlew :app:sourceSetsProdHuaweiRelease
    # 或者打印 BuildConfig 生成结果
    cat app/build/generated/source/buildConfig/prodHuawei/release/com/techfeed/app/BuildConfig.java
    
  3. 验证签名与渠道信息:
    apksigner verify --verbose --print-certs app-release.apk
    aapt2 dump badging app-release.apk | grep -E "package|application-label|uses-permission"
    
  4. 对比两个版本的包体差异(发版前必做,防止意外引入大资源):
    ./gradlew :app:analyzeProdGooglePlayRelease    # AGP 自带的 APK Analyzer 报告
    
  5. 本地复现 CI 环境:用 ORG_GRADLE_PROJECT_versionCode=999 ./gradlew assembleRelease 模拟 CI 注入,验证版本号逻辑。
  6. 只构建单一变体加速反馈:./gradlew :app:assembleDevGooglePlayDebug,不要动不动 assembleRelease 全变体。
  7. 排查变体专属代码没生效:确认目录名与 flavor 名完全一致(src/huawei/java,不是 src/huaweiFlavor/java),且维度名没写错。

五、最佳实践

  1. 变体维度不超过 2 个:env + channel 足够,再多就用 variantFilter 裁剪。
    • ✅ 推荐:flavorDimensions += listOf("env", "channel") + 过滤无用组合
    • ❌ 避免:为"免费版/付费版/员工版/渠道/环境"各建一个维度
  2. 签名永不进版本库:keystore.properties + .jks 双保险进 .gitignore,CI 用 Secrets 注入。
  3. versionCode 由 CI 注入:彻底消灭"忘了改版本号"这类低级事故。
  4. 开启 v2 + v3 签名:v1 仅作为兼容保留;enableV3Signing = true 为未来密钥轮换留后路。
  5. Google Play 用 AAB,国内渠道用 APK:CI 出两套产物,不要指望一个包通吃。
  6. 敏感信息不进 buildConfigField:它是明文常量,反编译即可见。
    • ✅ 推荐:接口地址、功能开关位、渠道标识
    • ❌ 避免:API Secret、加密密钥、第三方 Secret
  7. 每次构建都上传 mapping:与产物同 job 上传并归档(第 18 章)。
  8. 发布检查清单 checklist 化:写进仓库,发版时逐项打勾,而不是靠记忆。
  9. 灰度必须有观察窗口和止损阈值:1% → 观察 24h → 10% → 全量;崩溃率超基线立即暂停。
  10. 回滚预案先写再发:明确"停止分发 + 修复版 versionCode + 引导升级"三步,而不是等出事再想。
  11. PR 阶段跑快测,主干跑全量:单测 + 静态检查在 PR;UI 测试与全变体构建在主干(第 21、22 章)。
  12. 构建脚本也要 Review:build.gradle.kts 是代码,改签名、改 flavor 与改业务逻辑同等风险。

六、本章小结

  1. 变体 = Build Type × Product Flavor:维度负责"是什么/给谁",构建类型负责"怎么构建",用 variantFilter 控制爆炸。
  2. 三种注入方式各司其职:manifestPlaceholders 最轻、buildConfigField 给代码、resValue 给 XML;都不能放敏感信息。
  3. 签名是应用的身份:keystore 与密码不进版本库、CI 注入、开启 v2/v3、Google Play 走 Play App Signing。
  4. 产物按场景选:Google Play 用 AAB 拿体积收益,国内渠道用 APK,splits 分包要配套独立 versionCode。
  5. 发布是流程不是动作:CI 流水线(校验 → 构建 → 签名 → 分发)+ 检查清单 + 灰度观察 + 回滚预案,四件套缺一不可。

到这里,"企业级"阶段正式收官:架构(第 14 章)→ 模块化(第 15 章)→ 性能(第 17 章)→ 稳定性(第 18 章)→ 安全(第 19 章)→ 发布(本章)形成了完整闭环。接下来进入团队协作与测试——把这套能力在多人协作下稳定地复现出来。

下一章:测试策略。从测试金字塔讲起,覆盖 JVM 单测、协程与 Flow 测试、ViewModel / Repository / Room / Hilt 测试,以及 Compose UI Test 与 Espresso 的双体系对照。


七、动手练习

基础题

  1. 给工程加上 env(dev/staging/prod)× channel(googlePlay/huawei)两个维度,用 variantFilter 裁掉 prodDebug 与 dev 下的非 googlePlay 渠道,用 ./gradlew :app:tasks --all 验证变体数量。
  2. 用 keytool 生成一份测试 keystore,配置 keystore.properties(并加进 .gitignore),构建 release 后用 apksigner verify 确认 v2/v3 已生效。
  3. 把 versionCode 改成从 Gradle 属性读取,本地用 -PversionCode=999 构建,验证 BuildConfig.VERSION_CODE 生效。

进阶题 4. 搭一条 GitHub Actions 流水线:PR 触发 ktlint + detekt + 单测;主干触发构建 + 签名 + 归档产物。故意提交一处格式错误,确认 CI 真的会红。 5. 用 bundletool 把构建出的 AAB 装到真机,并用 analyze 报告对比开 R8 前后的包体构成,写出三个最大的体积来源。 6. 按 2.8 节的清单给自己的一次真实发版做一次"模拟评审",列出至少 3 项当前不满足的条目并给出整改方案。

思考题 7. 为什么说 Android 的"回滚"不能真正让已安装用户退回旧版本?这会对灰度策略产生什么影响?(提示:灰度比例应如何在 1%/10%/全量之间分配风险) 8. 什么情况下你会坚持用 APK 而不是 AAB?除了"国内渠道",还有哪些技术或流程因素会影响这个选择? 9. buildConfigField 与"服务端下发开关"都能控制功能,二者的适用边界在哪里?如果一个开关需要"秒级关闭线上崩溃功能",你会怎么设计?

📄 第 21 章 测试策略 21-testing-strategy.md

阶段:协作与测试 | 预计阅读 65 min | 代码落地模块::core:testing、:core:database、:feature:feed、:feature:detail 前置:第 11 章 异步处理:协程与 Flow | 第 12 章 依赖注入 | 第 14 章 分层架构与 MVI 相关章节:第 20 章 构建配置与发布 | 第 22 章 团队协作规范 | 第 24 章 信息流与详情

学习目标

  • [ ] 理解测试金字塔三层(单元 / 集成 / UI)的成本与收益,能为自己项目定出合理比例。
  • [ ] 能写 JVM 单元测试:JUnit4 + Truth 断言 + MockK,并理解"Fake 优先于 Mock"的原则。
  • [ ] 会用 runTest、StandardTestDispatcher、advanceUntilIdle 与 Turbine 测试协程和 Flow。
  • [ ] 能测 ViewModel(MainDispatcherRule、SavedStateHandle)、Repository、Room(内存数据库 + Migration)与 Hilt 替换。
  • [ ] 能写 Compose UI Test 与 Espresso 测试,理解 semantics、testTag 与双体系对照;知道覆盖率与截图测试的定位。

一、核心概念

1.1 测试金字塔:为什么不是越多越好

flowchart TB
    subgraph UI["UI 测试(10%)"]
        U["Compose UI Test / Espresso<br/>慢、脆、覆盖端到端链路"]
    end
    subgraph IT["集成测试(20%)"]
        I["Repository + Room + 网络 Fake<br/>中速、验证模块间接线"]
    end
    subgraph UT["单元测试(70%)"]
        T["UseCase / ViewModel / 工具类<br/>毫秒级、验证业务规则"]
    end
    UI --> IT --> UT

三层的核心差异不是"测什么",而是反馈速度与维护成本:

层次 运行环境 单条耗时 稳定性 覆盖范围
单元测试 本地 JVM 毫秒级 极高(无时序依赖) 单个类/函数
集成测试 本地 JVM(Robolectric)或真机 百毫秒级 高 多个真实组件的组合
UI 测试 真机 / 模拟器 秒级 中(受动画、时序影响) 完整用户路径

经验比例 70 / 20 / 10。最常见的错误是把 UI 测试当主力:它最贴近用户,但也最慢最脆——一个 400 条 UI 测试的套件会让 CI 跑 40 分钟,最后没人愿意等,于是测试被跳过,投入全部沉没。

反过来也要承认:不是所有代码都值得测。getter/setter、纯转发的一行函数、自动生成的 Dagger/Room 代码,写测试只是增加维护负担。把预算花在下面四类上,收益最高:① 有分支与边界的业务规则(分页、去重、金额、状态机);② 曾经出过 bug 的地方(回归测试,防止二次踩坑);③ 跨层接线(Repository 的缓存与回源策略、Migration);④ 一次性事件的触发条件(导航、埋点、Toast)。

[!IMPORTANT] 判断该写哪层测试的标准只有一条:这段逻辑的"错误"最可能发生在哪一层。 金额计算、状态机、分页逻辑 → 单元测试;SQL 是否正确、缓存是否命中 → 集成测试; 页面能否打开、点击是否有反应、列表是否渲染 → UI 测试。

1.2 Fake 优先于 Mock

方案 是什么 优点 缺点
Fake(假实现) 一个能跑的简化实现(内存版 Repository) 行为真实、多个测试共享、重构友好 需要维护一份实现
Mock(模拟对象) 用 MockK/mockito 声明"调用 X 返回 Y" 无需实现,验证交互很方便 与实现细节强绑定,重构必改测试
Stub 只提供固定返回值 最简单 无法验证交互

原则:依赖用 Fake,交互用 Mock。也就是说——"被测对象要从别人那拿数据"时用 Fake 数据源;"被测对象是否正确调用了某个副作用接口"(如埋点、导航)时才用 Mock 去 verify。

一个直观的判断:如果你的测试里出现大量 every { a.getX() } returns y 且被测逻辑只用了其中一两个返回值,那这个 Mock 就是在复述实现,应该换成 Fake。

1.3 测试替身的选择顺序

遇到"被测对象依赖了别的东西"时,按下面顺序选,不要一上来就 Mock:

  1. 能直接用真实实现就用(纯函数、工具类、Article 这种模型类)——测的是真东西,最可信。
  2. 涉及 I/O 或跨层依赖,用 Fake(FakeFeedRepository、内存数据库、Fake 时钟)——行为真实、多个测试共享。
  3. 只关心"有没有调用某个副作用",用 Mock(埋点上报、导航回调、一次性 UseCase)。
  4. 只在极端场景用 Spy/部分 Mock——它最脆,通常是设计问题的信号。

顺带一条命名约定:测试名描述"在什么条件下应该发生什么",而不是描述被测方法名。Android 里用反引号可以让测试名带空格与中文:

// ✅ 好:失败时一眼看出坏了哪条业务规则
@Test fun `refresh 失败时 error 有值且 isLoading 为 false`() { ... }

// ❌ 差:失败后还得翻代码才知道在测什么
@Test fun testRefresh2() { ... }

测试体内部建议遵循 Given–When–Then 三段结构,并在段间空一行:

@Test fun `收藏成功后发出 Toast 事件`() = runTest {
    // Given:构造前置条件(Fake 处于失败态、用户已登录……)
    repository.shouldFail = false
    // When:执行被测行为(通常只有一行)
    viewModel.toggleFavorite()
    // Then:断言结果(可以有多个断言,但要属于同一件事)
    assertThat(viewModel.uiState.value.isFavorite).isTrue()
}

1.4 协程测试:为什么需要测试调度器

生产代码里 viewModelScope.launch 跑在真实主线程上,测试里没有 Android 主线程,直接跑会报 Module with the Main dispatcher had failed to initialize。解决方案有两个层次:

  1. MainDispatcherRule:把 Dispatchers.Main 替换成测试调度器(JUnit TestWatcher)。
  2. runTest + StandardTestDispatcher:让协程变成"手动推进时间"的确定性执行——不调用 advanceUntilIdle() 就不执行,delay(1000) 不会真的等 1 秒。
sequenceDiagram
    participant T as 测试
    participant D as StandardTestDispatcher
    participant VM as ViewModel
    T->>D: runTest { ... }
    T->>VM: viewModel.refresh()
    Note over VM,D: 协程入队,尚未执行
    T->>D: advanceUntilIdle()
    D-->>VM: 执行完所有挂起点
    VM-->>T: uiState 已更新,可断言

两种测试调度器怎么选:

调度器 行为 适用
UnconfinedTestDispatcher 协程立即执行到第一个挂起点,无需手动推进 大多数 ViewModel 测试;不关心中间时序
StandardTestDispatcher 协程入队,必须 advanceUntilIdle() / advanceTimeBy() 才执行 需要验证"中间态"(如 loading → success)、含 delay 的轮询逻辑

runTest 本身还有一层保护:测试结束时若有未完成的协程,会直接失败——这能帮你抓出"忘记取消的作用域"这类真实缺陷。

1.5 覆盖率不是目标

覆盖率(Coverage)回答的是"哪些代码被执行过",不回答"这些代码是否被正确验证"。一个只有 assertNotNull(result) 的测试能把覆盖率刷到 90%,却在业务逻辑写反时依然全绿。

正确使用方式:

  • 看增量覆盖率(本次 PR 改动的新行有多少被测到),而不是全量覆盖率。
  • 把覆盖率当发现盲区的雷达:哪个模块覆盖率异常低,说明它要么很难测(设计有问题),要么没人关心。
  • 不要设"覆盖率必须达到 X%"作为 CI 卡口,那会催生出大量无断言测试。设"增量覆盖率不低于 60%"这类宽松兜底即可。

1.6 CI 中的分层执行

触发 跑什么 时间预算
本地保存/提交前 改动模块的单测 秒级
PR 全量单测 + 静态检查 < 5 min
合入主干 单测 + 集成测试 + 关键路径 UI 测试 < 20 min
夜间 全量 UI 测试 + 基准测试 + 截图测试 不限

二、示例代码

2.1 :core:testing 模块:所有测试基建集中一处

// 文件路径:sample/core/testing/build.gradle.kts
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
}

android { namespace = "com.techfeed.core.testing" }

dependencies {
    api(libs.junit)
    api(libs.truth)
    api(libs.turbine)
    api(libs.kotlinx.coroutines.test)
    api(libs.mockk)
    api(libs.androidx.core.testing)        // 提供 InstantTaskExecutorRule 等 LiveData/Arch 测试规则
}
// 文件路径:sample/core/testing/src/main/java/com/techfeed/core/testing/MainDispatcherRule.kt
package com.techfeed.core.testing

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description

/**
 * 把 Dispatchers.Main 替换成测试调度器。
 * 没有它,任何用到 viewModelScope 的测试都会报
 * "Module with the Main dispatcher had failed to initialize"。
 *
 * 默认用 UnconfinedTestDispatcher:协程立即执行,写断言最省事;
 * 需要精确控制时序时改用 StandardTestDispatcher 并手动 advanceUntilIdle()。
 */
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

2.2 JVM 单元测试:Truth + Fake

// 文件路径:docs/samples/ch21/ArticleTest.kt
package com.techfeed.core.model

// 注意:truncate / toDisplayDate 是 core:common 的扩展函数(Extensions.kt)。
// 在 core:model 的测试里使用需加 testImplementation(projects.core.common),
// 否则应把这两个用例挪到 core:common 自己的测试里(更推荐,依赖方向更干净)。
import com.google.common.truth.Truth.assertThat
import com.techfeed.core.common.toDisplayDate
import com.techfeed.core.common.truncate
import org.junit.Test

class ArticleTest {

    @Test
    fun `摘要超长时截断并保留省略号`() {
        val article = Article(
            id = "a-1",
            title = "Kotlin 协程",
            summary = "x".repeat(300),
            coverUrl = null,
            author = Author("u-1", "张三", null),
            publishedAt = 0L,
        )

        // Truth 的断言可读性远好于 assertEquals(报错信息会直接给出期望/实际)
        // truncate(max = 200):前 200 个字符 + 1 个省略号
        val truncated = article.summary.truncate(max = 200)
        assertThat(truncated.length).isEqualTo(201)
        assertThat(truncated.last()).isEqualTo('…')
    }

    @Test
    fun `publishedAt 格式化后包含年月日`() {
        val ts = 1_700_000_000_000L
        assertThat(ts.toDisplayDate()).contains("-")
    }
}
// 文件路径:docs/samples/ch21/FakeFeedRepository.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.model.Article
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map

/**
 * Fake 数据源:一个能跑的内存实现。
 * 相比 Mock 的好处:行为真实(真的会累加分页),多个测试共用一份,重构时不用改。
 * shouldFail 用于驱动异常分支,比 "every { } throws" 直观得多。
 */
class FakeFeedRepository : FeedRepository {

    private val backing = MutableStateFlow<List<Article>>(emptyList())
    var shouldFail = false
    var requestCount = 0

    fun emit(articles: List<Article>) { backing.value = articles }

    override fun observeFeed(): Flow<List<Article>> = backing

    override suspend fun refresh(page: Int): List<Article> {
        requestCount++
        if (shouldFail) throw AppError.Network(RuntimeException("fake network down")) as Throwable
        val fresh = (1..20).map { i ->
            Article(
                id = "p${page}-$i",
                title = "文章 $i",
                summary = "摘要",
                coverUrl = null,
                author = Author("u-$i", "作者 $i", null),
                publishedAt = 0L,
            )
        }
        backing.value = fresh
        return fresh
    }

    override fun observeArticle(id: String): Flow<Article?> = backing.map { it.firstOrNull { a -> a.id == id } }
}

有了 Fake,Repository 与 UseCase 的测试就是纯粹的 JVM 单测,毫秒级、零 Android 依赖:

// 文件路径:docs/samples/ch21/FeedRepositoryTest.kt
package com.techfeed.feature.feed.domain

import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class FeedRepositoryTest {

    private val repository = FakeFeedRepository()

    @Test
    fun `refresh 后 observeFeed 能观察到新数据`() = runTest {
        repository.observeFeed().test {
            assertThat(awaitItem()).isEmpty()      // 初始为空

            repository.refresh(page = 1)
            assertThat(awaitItem()).hasSize(20)    // 刷新后推送新列表

            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `refresh 抛异常时调用方可以捕获`() = runTest {
        repository.shouldFail = true
        val result = runCatching { repository.refresh(page = 1) }
        assertThat(result.isFailure).isTrue()
    }
}
// 文件路径:docs/samples/ch21/GetArticleUseCaseTest.kt
package com.techfeed.feature.feed.domain

import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class GetArticleUseCaseTest {

    @Test
    fun `命中缓存时不再请求网络`() = runTest {
        val repository = FakeFeedRepository().apply { emit(listOf(article("a-1"))) }
        val useCase = GetArticleUseCase(repository)

        useCase("a-1")               // 第一次:走缓存
        val before = repository.requestCount
        useCase("a-1")               // 第二次:仍走缓存
        assertThat(repository.requestCount).isEqualTo(before)
    }

    @Test
    fun `本地无缓存时回源并写入本地`() = runTest {
        val repository = FakeFeedRepository()
        val useCase = GetArticleUseCase(repository)

        val result = useCase("a-1")
        assertThat(result).isNotNull()
        assertThat(repository.requestCount).isEqualTo(1)
    }
}

[!TIP] FakeFeedRepository 放在 src/test 还是 :core:testing? 只被一个模块使用 → 放在该模块的 src/test;被多个模块共享(如 FakeUserRepository)→ 放到 :core:testing 的 main 源码集, 用 testImplementation(projects.core.testing) 引入。原则是不要为了共享而共享。

2.3 协程与 Flow 测试:runTest + Turbine

[!NOTE] 演进说明:下面的测试沿用第 11/12 章的构造签名(注入 RefreshFeedUseCase + ObserveFeedUseCase);第 24 章起工程实际为 FeedViewModel(repository: FeedRepository),测试直接用 Fake Repository 构造即可(见 sample/feature/feed/src/test/.../FeedViewModelTest.kt)。另外,错误字段在最终形态中是 error: AppError?(第 14 章的 errorMessage: String? 是教学简化版),UI 层经 error.userMessage 取展示文案。

// 文件路径:sample/feature/feed/src/test/java/com/techfeed/feature/feed/ui/FeedViewModelTest.kt
package com.techfeed.feature.feed.ui

import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.techfeed.core.testing.MainDispatcherRule
import com.techfeed.feature.feed.domain.FakeFeedRepository
import com.techfeed.feature.feed.domain.RefreshFeedUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class FeedViewModelTest {

    // 用 StandardTestDispatcher 才能手动控制时序
    private val dispatcher = StandardTestDispatcher()

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule(dispatcher)

    private val repository = FakeFeedRepository()
    private val viewModel = FeedViewModel(
        refreshFeed = RefreshFeedUseCase(repository),
        observeFeed = ObserveFeedUseCase(repository),
    )

    @Test
    fun `refresh 成功后 uiState 从 loading 变为成功`() = runTest {
        // Flow 断言用 Turbine:它会自动处理"订阅—取值—取消",避免漏掉首个默认值
        viewModel.uiState.test {
            // StateFlow 总有初始值,先消费掉
            assertThat(awaitItem().isLoading).isFalse()

            viewModel.refresh()
            // 因为用的是 StandardTestDispatcher,这里必须手动推进
            advanceUntilIdle()

            val loading = awaitItem()
            val success = awaitItem()
            assertThat(success.isLoading).isFalse()
            assertThat(success.articles).isNotEmpty()
            cancelAndIgnoreRemainingEvents()
        }
        assertThat(repository.requestCount).isEqualTo(1)
    }

    @Test
    fun `refresh 失败时 error 有值且不崩溃`() = runTest {
        repository.shouldFail = true

        viewModel.refresh()
        advanceUntilIdle()

        // 最终形态断言错误对象本身;需要文案时用 error.userMessage
        assertThat(viewModel.uiState.value.error).isNotNull()
        assertThat(viewModel.uiState.value.isLoading).isFalse()
    }
}
// 文件路径:docs/samples/ch21/RetryTest.kt
package com.techfeed.core.common

import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class RetryTest {

    @Test
    fun `retry 在第二次成功时不抛异常`() = runTest {
        var attempts = 0
        // runTest 里 delay 不会真的等待,虚拟时间自动推进
        val result = retry(times = 3) {
            attempts++
            if (attempts < 2) error("boom") else "ok"
        }
        assertThat(result).isEqualTo("ok")
        assertThat(attempts).isEqualTo(2)
    }

    @Test
    fun `retry 用尽次数后抛出最后一次异常`() = runTest {
        var attempts = 0
        val result = runCatching {
            retry(times = 2) { attempts++; error("always fail") }
        }
        assertThat(result.isFailure).isTrue()
        assertThat(attempts).isEqualTo(2)
    }
}

2.4 MockK:只在"验证交互"时用

// 文件路径:sample/feature/detail/src/test/java/com/techfeed/feature/detail/ui/DetailViewModelTest.kt
package com.techfeed.feature.detail.ui

import androidx.lifecycle.SavedStateHandle
import com.google.common.truth.Truth.assertThat
import com.techfeed.core.testing.MainDispatcherRule
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class DetailViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun `收藏成功后发出 Toast 事件`() = runTest {
        // 一次性副作用(UseCase 调用)用 Mock 验证交互,这是 Mock 的正当用途
        val toggleFavorite: ToggleFavoriteUseCase = mockk()
        coEvery { toggleFavorite(any()) } returns Unit

        val savedStateHandle = SavedStateHandle(mapOf("articleId" to "a-1"))
        val viewModel = DetailViewModel(savedStateHandle, mockk(relaxed = true), toggleFavorite)

        viewModel.toggleFavorite()

        // 验证"确实调用了"而不是"返回值对不对"
        coVerify(exactly = 1) { toggleFavorite("a-1") }
    }

    @Test
    fun `SavedStateHandle 中缺少 articleId 时快速失败`() {
        // 用不含 articleId 的 handle 构造,应抛出明确异常而不是静默出错
        val result = runCatching {
            DetailViewModel(SavedStateHandle(), mockk(relaxed = true), mockk(relaxed = true))
        }
        assertThat(result.isFailure).isTrue()
    }
}

2.5 Room 测试:内存数据库 + Migration

// 文件路径:docs/samples/ch21/ArticleDaoTest.kt
package com.techfeed.core.database

import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ArticleDaoTest {

    // inMemoryDatabaseBuilder:进程结束即销毁,测试间零污染
    private lateinit var db: TechFeedDatabase
    private lateinit var dao: ArticleDao

    @Before
    fun setup() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            TechFeedDatabase::class.java,
        ).allowMainThreadQueries()   // 仅测试用;生产代码禁止主线程查库
            .build()
        dao = db.articleDao()
    }

    @After
    fun teardown() { db.close() }

    @Test
    fun insertAndRead() = runTest {
        dao.upsertAll(listOf(articleEntity("a-1", "标题")))
        val list = dao.observeFeed(null).first()   // query 为 null:不过滤
        assertThat(list).hasSize(1)
        assertThat(list[0].title).isEqualTo("标题")
    }

    @Test
    fun upsertReplacesSamePrimaryKey() = runTest {
        dao.upsertAll(listOf(articleEntity("a-1", "旧标题")))
        dao.upsertAll(listOf(articleEntity("a-1", "新标题")))
        assertThat(dao.observeFeed(null).first()).hasSize(1)
        assertThat(dao.observeFeed(null).first()[0].title).isEqualTo("新标题")
    }
}
// 文件路径:docs/samples/ch21/MigrationTest.kt
package com.techfeed.core.database

import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test

/**
 * Migration(数据库迁移)测试:这是最容易被跳过、也最容易出线上事故的测试。
 * 一次失败的新增列迁移 = 老用户升级后直接崩溃。
 */
class MigrationTest {

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        TechFeedDatabase::class.java,
        listOf(),                                   // 若有 AutoMigration 之外的手工迁移在此列出
        FrameworkSQLiteOpenHelperFactory(),
    )

    @Test
    fun migrate1To2() {
        // 先建 v1 并塞入数据
        helper.createDatabase(TEST_DB, 1).apply {
            execSQL("INSERT INTO article VALUES ('a-1','标题','摘要',0)")
            close()
        }
        // 再迁移到 v2 并校验结构与数据都还在
        val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
        val cursor = db.query("SELECT is_favorite FROM article WHERE id='a-1'")
        cursor.moveToFirst()
        check(cursor.getInt(0) == 0)
    }

    private companion object { const val TEST_DB = "migration-test" }
}

2.6 Hilt 测试:替换模块

// 文件路径:docs/samples/ch21/FeedHiltTest.kt
package com.techfeed.feature.feed.ui

import com.techfeed.feature.feed.domain.FeedRepository
import com.techfeed.feature.feed.domain.FakeFeedRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import dagger.hilt.components.SingletonComponent
import org.junit.Before
import org.junit.Rule
import javax.inject.Inject
import javax.inject.Singleton

/**
 * 两种替换方式:
 *  1. @UninstallModules + 测试 Module:替换"一整块"绑定(适合 Repository 这类顶层依赖)
 *  2. @BindValue:只替换"一个"绑定(最轻量,优先用这个)
 */
@HiltAndroidTest
@UninstallModules(FeedRepositoryModule::class)
class FeedHiltTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    // 方式 2:直接把 Fake 塞进依赖图
    @BindValue
    @JvmField
    val fakeRepository: FeedRepository = FakeFeedRepository()

    @Inject lateinit var useCase: RefreshFeedUseCase

    @Before
    fun init() { hiltRule.inject() }
}

/** 方式 1 用的测试模块:只在 test/androidTest 源码集中存在,不污染生产代码 */
@Module
@InstallIn(SingletonComponent::class)
abstract class TestFeedModule {
    @Binds
    @Singleton
    abstract fun bindRepository(fake: FakeFeedRepository): FeedRepository
}

2.7 Compose UI Test

// 文件路径:docs/samples/ch21/FeedScreenTest.kt
package com.techfeed.feature.feed.ui

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.printToLog
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test

class FeedScreenTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun 列表渲染并响应点击() {
        var clickedId: String? = null
        composeRule.setContent {
            TechFeedTheme {
                FeedScreen(
                    uiState = FeedUiState(
                        articles = listOf(article("a-1", "Kotlin 协程"), article("a-2", "Compose 入门")),
                    ),
                    onArticleClick = { clickedId = it },
                )
            }
        }

        // 语义树断言:优先用 text / contentDescription(用户能感知的),其次才用 testTag
        composeRule.onNodeWithText("Kotlin 协程").assertIsDisplayed()
        composeRule.onNodeWithText("Kotlin 协程").performClick()
        assertThat(clickedId).isEqualTo("a-1")

        // 长列表滚动到指定下标再断言
        composeRule.onNodeWithTag("feed_list").performScrollToIndex(1)
        composeRule.onNodeWithText("Compose 入门").assertIsDisplayed()
    }

    @Test
    fun 加载中显示进度条() {
        composeRule.setContent {
            TechFeedTheme { FeedScreen(uiState = FeedUiState(isLoading = true), onArticleClick = {}) }
        }
        composeRule.onNodeWithTag("feed_loading").assertIsDisplayed()
    }

    @Test
    fun 调试用_打印语义树() {
        composeRule.setContent { TechFeedTheme { FeedScreen(uiState = FeedUiState(), onArticleClick = {}) } }
        // 找不到节点时,先 printToLog("FeedScreen") 看语义树长什么样,比盲猜快十倍
        composeRule.onRoot().printToLog("FeedScreen")
    }
}

关键 API 对照:

API 作用
onNodeWithText / onNodeWithContentDescription 按用户可见信息找节点(首选)
onNodeWithTag + Modifier.testTag() 按测试标记找节点(无文案的组件用)
assertIsDisplayed / assertIsNotDisplayed / assertTextContains 断言可见性与内容
performClick / performTextInput / performScrollToIndex 执行操作
onRoot().printToLog() 打印语义树,调试利器
waitForIdle() / runOnIdle {} 等待动画与重组结束

[!TIP] testTag 会被打进 release 包。可以用一个只在 debug 生效的修饰符收口:

fun Modifier.debugTestTag(tag: String): Modifier = if (BuildConfig.DEBUG) testTag(tag) else this

2.8 Espresso(View/XML 体系对照)

// 文件路径:docs/samples/ch21/FeedActivityTest.kt
package com.techfeed.feature.feed.ui

import androidx.test.core.app.launchActivity
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class FeedActivityTest {

    @Test
    fun 列表渲染并响应点击() {
        launchActivity<FeedHostActivity>()

        // View 体系靠 withId / withText 定位,语义等价于 Compose 的 testTag / text
        onView(withText("Kotlin 协程")).check(matches(isDisplayed()))
        onView(withId(R.id.rvArticles))
            .perform(scrollToPosition<androidx.recyclerview.widget.RecyclerView.ViewHolder>(1))
        onView(withText("Kotlin 协程")).perform(click())
    }
}

双体系对照表:

意图 Compose UI Test Espresso
按文案找 onNodeWithText("x") onView(withText("x"))
按 id 找 onNodeWithTag("x") onView(withId(R.id.x))
断言可见 assertIsDisplayed() check(matches(isDisplayed()))
点击 performClick() perform(click())
输入 performTextInput("x") perform(typeText("x"))
列表滚动 performScrollToIndex(i) perform(RecyclerViewActions.scrollToPosition<VH>(i))
设置内容 composeRule.setContent { } launchActivity<T>() / FragmentScenario
等待空闲 waitForIdle() Espresso 内置 IdlingResource 自动等待
调试 printToLog() onView(withId(...)).check(...) + Espresso.openOverflowMenu()

[!WARNING] 两套体系的"等待空闲"机制不同:Compose 依赖 waitForIdle()(重组与动画结束), 而 Espresso 依赖 IdlingResource。在 Compose 里混用 View(第 16 章)时, 需要同时考虑 IdlingResource,否则会出现"偶发找不到节点"的脆测试。

2.9 截图测试与覆盖率

截图测试(Screenshot Testing)比较的是"渲染结果的像素/语义快照",适合守护设计系统与主题(第 07 章):

// 文件路径:docs/samples/ch21/ButtonScreenshotTest.kt
package com.techfeed.core.designsystem

import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode

/**
 * Roborazzi 截图测试:跑在 JVM 上(Robolectric),比真机快一个数量级。
 * 适用:设计系统组件、主题、深色模式、多字号;不适用:包含真实网络图片的页面。
 */
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ButtonScreenshotTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun primaryButton_default() {
        composeRule.setContent { TechFeedTheme { TechFeedPrimaryButton(text = "收藏", onClick = {}) } }
        composeRule.onRoot().captureRoboImage("src/test/screenshots/primary_button.png")
    }

    @Config(qualifiers = "+night")
    @Test
    fun primaryButton_dark() {
        composeRule.setContent { TechFeedTheme(darkTheme = true) { TechFeedPrimaryButton(text = "收藏", onClick = {}) } }
        composeRule.onRoot().captureRoboImage("src/test/screenshots/primary_button_dark.png")
    }
}

覆盖率用 Kover(Kotlin 原生,比 JaCoCo 对 Kotlin 内联、默认参数的统计更准):

// 文件路径:sample/build.gradle.kts(片段)
plugins {
    alias(libs.plugins.kover) apply false   // 版本在 libs.versions.toml 收口
}

// 文件路径:sample/core/model/build.gradle.kts(片段)
// 每个模块单独开启,避免一次跑全工程
// plugins { id("org.jetbrains.kover") }
# 生成 HTML 覆盖率报告
./gradlew koverHtmlReportProdStoreDebug
# 输出合并报告,便于 CI 归档
./gradlew koverXmlReportProdStoreDebug
旧写法对照:JUnit4 与 JUnit5、AsyncTask 时代的测试
维度 JUnit4(本教程基线) JUnit5(Jupiter)
入口 org.junit.Test + @RunWith org.junit.jupiter.api.Test + @ExtendWith
生命周期 @Before / @After @BeforeEach / @AfterEach
规则机制 TestRule / TestWatcher(MainDispatcherRule 基于此) Extension
Android 支持 官方默认,无需额外配置 需要 android-junit5 插件

Android 生态里 JUnit4 仍是默认与最省事的选择;迁移到 JUnit5 的收益主要是参数化测试与嵌套测试类, 如果你的团队没有强诉求,不必为了"新"而迁移。

另一处历史对照:早期异步代码用 AsyncTask,测试要靠 Robolectric 的 ShadowLooper 或 CountDownLatch 等待; 协程时代统一用 runTest + TestDispatcher,不再需要任何真实等待——这是协程给可测试性带来的最大红利。


三、常见坑 ⚠️

现象 根因 解法
Module with the Main dispatcher had failed to initialize 未替换 Dispatchers.Main 加 MainDispatcherRule
runTest 里断言不生效,StateFlow 还是旧值 用了 StandardTestDispatcher 但没推进时间 调 advanceUntilIdle();或改用 UnconfinedTestDispatcher
Turbine 报 expected item but got TimeoutCancellationException 期望的事件没有发出(逻辑没执行或被跳过) 先确认协程已推进;再检查 Flow 是否真的 emit
Turbine 报 "Unconsumed events found" 断言结束后 Flow 还在发事件 末尾加 cancelAndIgnoreRemainingEvents()
测试相互干扰(前一个测试的数据出现在后一个里) Fake / 数据库是单例未重置 每个测试新建 Fake;Room 用内存数据库并在 @After 关闭
MockK 报 no answer found 非 relaxed mock 调用了未 stub 的方法 用 mockk(relaxed = true),或显式 coEvery { }
重构实现后大批 Mock 测试变红 Mock 与实现细节强绑定 换成 Fake;只保留"副作用验证"类 Mock
delay 让测试真的等了几秒 用了真实调度器 一律在 runTest 内,虚拟时间自动跳过
UI 测试偶发失败(flaky) 动画未关闭、依赖真实网络、依赖时序 关闭动画(adb shell settings put global transition_animation_scale 0);替换网络为 Fake
Compose 找不到节点 节点未合并语义 / 用了 testTag 但 release 里被移除 用 printToLog() 看语义树;确认 debug 才加 tag
Espresso 报 NoMatchingViewException 视图还未渲染 / 在 Fragment 未附加时查找 用 IdlingResource;或改 Compose UI Test
Migration 测试从未写过 觉得"加个字段不会出问题" 每个 Migration 必写 MigrationTestHelper 测试,这是线上崩溃高发区
Hilt 测试报 missing binding 测试模块未 @UninstallModules,或未 hiltRule.inject() 检查注解与 inject() 调用顺序
覆盖率很高但 bug 依旧 测试无有效断言,只"跑过"代码 关注增量覆盖率;CR 时检查断言质量而非覆盖率数字
CI 测试太慢没人等 UI 测试比例过高、没做分层 PR 只跑单测,UI 测试放合入后/夜间

四、调试技巧

  1. 跑单个测试类/方法:右键 IDE 的运行按钮,或用 --tests 过滤:
    ./gradlew :feature:feed:testDebugUnitTest --tests "*FeedViewModelTest*"
    
  2. 看完整失败堆栈:Gradle 默认截断输出,加 --info 或看 HTML 报告:
    ./gradlew :feature:feed:testDebugUnitTest --info
    # 报告位置:模块/build/reports/tests/testDebugUnitTest/index.html
    
  3. Compose 找不到节点时,先打语义树:
    composeRule.onRoot().printToLog("DEBUG")
    
    看 Logcat 里 DEBUG tag 的输出,节点是否合并、文案是否被截断一目了然。
  4. 关闭动画消除 flaky(UI 测试的头号解药):
    adb shell settings put global window_animation_scale 0
    adb shell settings put global transition_animation_scale 0
    adb shell settings put global animator_duration_scale 0
    
  5. 协程调试开关:-Dkotlinx.coroutines.debug=on(第 01 章),在测试里能看到协程编号,排查挂起点很方便。
  6. Turbine 报错时先加日志:在 awaitItem() 之间打 println(state),确认事件序列与预期是否一致,往往一眼看出多/少发了什么。
  7. 截图测试的基线更新:确认是有意改 UI 后,用 ./gradlew recordRoborazziDebug 重录基线;无脑重录等于取消截图测试的意义。
  8. 查看数据库内容:App Inspection → Database Inspector,直接在测试运行后看表内容比写断言更快定位问题。

五、最佳实践

  1. 金字塔比例 70/20/10:业务规则写单测,模块接线写集成测试,关键用户路径写 UI 测试。
    • ✅ 推荐:一个 UseCase 配多个边界用例的 JVM 单测
    • ❌ 避免:为每个分支都写一条 UI 测试
  2. Fake 优先于 Mock:数据源用 Fake,只验证交互时才用 Mock。
    • ✅ 推荐:FakeFeedRepository 内存实现,多个测试共用
    • ❌ 避免:每个测试都 every { repo.getX() } returns ... 复述实现
  3. 测试名用中文反引号描述意图:`refresh 失败时 error 有值` 比 testRefreshFail 可读得多,失败时一眼知道坏了什么。
  4. 一个测试只验证一件事:多个断言可以,但多个"行为"应该拆成多个测试。
  5. 测试要快:单测毫秒级。任何需要 sleep、真实网络、真实磁盘的测试都要重新设计。
  6. Migration 测试是强制项:一个漏测的迁移就是一次线上崩溃。
  7. UI 测试优先用用户可见语义(文案、contentDescription),testTag 作为补充。
  8. 覆盖率看增量不看全量:用 Kover 统计,不当 CI 硬卡口,而是用来发现盲区。
  9. CI 分层执行:PR 跑单测 + 静态检查(< 5 min),合入跑集成与关键 UI 测试,夜间跑全量。
  10. flaky 测试当天修或当天删:容忍一个偶发失败的测试,等于训练团队忽略 CI 红灯。
  11. 测试代码也要 Review:断言是否真的验证了业务意图,比"有没有测试"更重要。
  12. 截图测试只守护设计系统:不要给包含真实数据的页面做截图测试,维护成本会爆炸。

六、本章小结

  1. 测试金字塔 70/20/10:单元测业务规则、集成测模块接线、UI 测关键路径;速度与稳定性决定了层次比例。
  2. Fake 优先于 Mock:Fake 行为真实、重构友好;Mock 只用于验证副作用交互。
  3. 协程测试三件套:MainDispatcherRule 换掉 Main、runTest 提供虚拟时间、StandardTestDispatcher + advanceUntilIdle 控制时序;Flow 断言用 Turbine。
  4. 可测试性是设计出来的:ViewModel 依赖 UseCase 接口、Repository 是接口、Hilt 提供替换点——这三条让每一层都能被独立测试(第 14 章的分层正是为此)。
  5. 覆盖率和截图测试是工具不是目标:增量覆盖率发现盲区,截图测试守护设计系统,二者都不能替代有效的断言。

测试保证了"改得动",而协作规范保证"多人一起改也不会乱"。下一章把视角从代码转向团队。

下一章:团队协作规范——代码规范与静态检查落地、分支模型、提交规范、Code Review 清单、ADR 与 CODEOWNERS。


七、动手练习

基础题

  1. 为 FeedViewModel 写三条单测:加载成功、加载失败、重复刷新只发一次请求。要求用 FakeFeedRepository 与 MainDispatcherRule,全部跑在 JVM 上。
  2. 用 Turbine 给一个 StateFlow<List<Article>> 写测试,覆盖"初始值 → 加载中 → 成功"三态,并处理"未消费事件"报错。
  3. 给 ArticleDao 写内存数据库测试:插入、按主键 upsert 覆盖、按收藏过滤,各一条。

进阶题 4. 为数据库新增一个字段(如 isFavorite),写 MigrationTestHelper 测试验证:v1 → v2 后旧数据仍在、新列有默认值。 5. 给 FeedScreen 写 Compose UI 测试(列表渲染 + 点击回调 + 加载中态),再给等价的 FeedHostActivity 写一条 Espresso 测试,对照两套 API 的差异并写下你的体会。 6. 配置 Kover 输出模块级覆盖率报告,找出当前覆盖率最低的三个类,判断它们是"难测"还是"不该存在",并给出结论。

思考题 7. 为什么说"Mock 与实现细节强绑定"?请举一个具体例子:把被测函数的内部实现从"先查缓存再查网络"改成"先查网络再查缓存",Mock 测试和 Fake 测试分别会怎样。 8. 你的团队想设"覆盖率必须 ≥ 80%"作为 CI 卡口。请列出至少三条它会带来的负面行为,并给出一条替代方案。 9. UI 测试最贴近用户却占比最低。如果产品坚持"每个需求都要有 UI 测试",你会如何与他协商出一个双方都能接受的方案?

📄 第 22 章 团队协作规范 22-team-collaboration.md

阶段:协作与测试 | 预计阅读 55 min | 代码落地模块:工程根目录、build-logic、.github/、:core:* 前置:第 15 章 模块化与组件化拆分 | 第 20 章 构建配置与发布 | 第 21 章 测试策略 相关章节:第 14 章 分层架构与 MVI | 第 23 章 TechFeed 总览 | 第 26 章 推送、监控与收尾

学习目标

  • [ ] 建立一套可执行的 Kotlin 编码规范:命名、可见性最小化、函数长度与圈复杂度、注释约定。
  • [ ] 把静态检查真正落地:.editorconfig + ktlint + detekt + Android Lint 基线,并用 Git Hook 与 CI 双卡口。
  • [ ] 能说清 trunk-based(主干开发)与 GitFlow 的取舍,落地"主干开发 + 短生命周期特性分支 + 发布分支"。
  • [ ] 会写 Conventional Commits(约定式提交),并用工具自动生成变更日志(Changelog)。
  • [ ] 能执行一套有优先级的 Code Review 清单,写出可执行的评论,并用 ADR 与 CODEOWNERS 固化决策与所有权。

一、核心概念

1.1 规范的价值:把"审美分歧"从 CR 里赶出去

团队协作中最大的时间黑洞不是技术难题,而是无休止的风格争论:空几格、要不要 it、常量放哪。这些争论每一分钟都在消耗团队的注意力,且永远没有正确答案。

解法是分三层处理:

层级 处理什么 手段
自动判定 格式、导入顺序、命名、明显的代码坏味道 .editorconfig + ktlint + detekt(机器说了算)
团队约定 分层边界、模块依赖方向、错误处理方式 ADR + 模块 README(一次性讨论,写下来)
人来判断 业务正确性、架构合理性、命名是否表意 Code Review(人只在真正需要人的地方发言)

凡是机器能判定的,就别让人做。 这是本章所有配置的出发点。

1.2 静态检查四件套

flowchart LR
    A["编写代码<br/>IDE 实时提示"] --> B["保存/提交<br/>Git Hook:ktlintFormat"]
    B --> C["提交 PR<br/>CI:ktlintCheck + detekt + Lint"]
    C -->|"不通过"| D["PR 红灯,禁止合入"]
    C -->|"通过"| E["Code Review<br/>只关注设计与正确性"]
    E --> F["合入主干"]
工具 职责 特点
.editorconfig 编辑器级格式(缩进、换行、字符集) 最底层,IDE 与 ktlint 都遵守它
ktlint Kotlin 代码风格(基于官方编码规范) 只管格式,规则少、争议小、可自动修复
detekt 代码质量(复杂度、命名、潜在 bug) 规则多、可定制,需要裁剪规则集
Android Lint Android 特有正确性(性能、可用性、安全) 官方规则,用 baseline 管理存量告警

三者职责不重叠:ktlint 管"好看",detekt 管"不坏",Lint 管"Android 上不出事"。

1.3 分支模型:为什么推荐主干开发

模型 分支结构 优点 缺点
GitFlow main + develop + feature/* + release/* + hotfix/* 版本边界清晰,适合多版本并行维护 分支长命,合并地狱;与持续交付冲突
Trunk-Based(主干开发) 单一 main + 短命 feature/*(1–3 天) 集成早、冲突小、随时可发布 要求特性开关、测试与 CI 足够可靠

移动端有个现实约束:发版节奏由应用市场决定,通常一周到两周一次,且已发布版本无法回滚。所以纯粹的 trunk-based 需要"发布分支"来做兜底。推荐组合:

flowchart LR
    M["main(主干)<br/>随时可发布"] --> F["feature/xxx<br/>1-3 天,PR 合入"]
    F --> M
    M --> R["release/1.2.0<br/>发版冻结期从这里切"]
    R --> H["hotfix/1.2.1<br/>紧急修复,双向合回"]
    H --> R
    H --> M

三条铁律:① 特性分支存活不超过 3 天;② main 永远可构建可发布;③ hotfix 修复必须同时合回 main 和 release,否则下一个版本会"复活"这个 bug。

[!IMPORTANT] 长生命周期的特性分支是万恶之源。如果一个功能 3 天做不完,正确做法是用特性开关把它关着合入主干, 而不是让分支活到两周——后者会导致:冲突爆炸、代码无法被他人复用、CR 变成一次性读几千行。

1.4 Conventional Commits(约定式提交)

格式:<type>(<scope>): <subject>,例如 feat(feed): 支持下拉刷新。

type 含义 是否触发发版
feat 新功能 minor
fix 修复缺陷 patch
refactor 重构(无行为变化) 否
perf 性能优化 patch
test 测试 否
docs 文档 否
build / ci 构建与流水线 否
chore 其他杂项 否

破坏性变更在 type 后加 !,或在 footer 写 BREAKING CHANGE:。提交信息的价值在 6 个月后:当你要查"这个崩溃是哪个改动引入的",一份可读的提交历史比任何文档都管用。

1.5 Code Review 的优先级

Review 的时间是有限的,按下面顺序分配注意力,前面的没看完就不要纠结后面的:

  1. 正确性:逻辑对不对?边界条件(空列表、网络失败、并发)处理了吗?
  2. 安全性与隐私:有没有引入敏感数据泄露、越权、明文传输(第 19 章)?
  3. 架构与边界:是否破坏了分层与模块依赖方向(第 14、15 章)?
  4. 可测试性:新代码能不能被单测覆盖(第 21 章)?
  5. 可读性与命名:别人三个月后还能读懂吗?
  6. 风格:交给 ktlint,不看。

1.6 ADR 与 CODEOWNERS

  • ADR(Architecture Decision Record,架构决策记录):把"为什么这么定"写下来。代码回答"是什么",ADR 回答"为什么"。一条 ADR 只记录一个决策,包含背景、备选方案、结论与影响。
  • CODEOWNERS:把文件路径映射到负责人,PR 会自动请求对应 owner 评审。对多模块工程,这是防止"底层模块被随意改动"的关键机制。

二、示例代码

2.1 .editorconfig

# 文件路径:.editorconfig
# IDE 与 ktlint 都读取本文件;放在仓库根目录,子目录可覆盖
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
max_line_length = 120

[*.{kt,kts}]
indent_size = 4
# Kotlin 官方风格:续行缩进同样 4 空格
continuation_indent_size = 4
# 导入不允许通配
ij_kotlin_imports_layout = *
ij_kotlin_allow_trailing_comma = true

[*.{yml,yaml,json,xml,md}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false   # Markdown 的行尾双空格是换行语义,不能裁

[gradle-wrapper.properties]
end_of_line = lf                   # 必须是 LF,否则 Linux CI 上 gradlew 报 "no such file"

2.2 ktlint 配置与 Gradle 接入

# 文件路径:.editorconfig(ktlint 规则段,追加到根文件)
[*.{kt,kts}]
# ktlint 官方规则:用 ktlint_official 代码风格(与 Android Studio 默认基本一致)
ktlint_code_style = ktlint_official
# 禁用历史规则:4 空格缩进已是官方规范,不再需要 no-wildcard-imports 之外的额外限制
ktlint_standard_no-wildcard-imports = enabled
ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled
# 函数名允许用反引号写中文测试名(第 21 章)
ktlint_standard_function-naming = disabled
ktlint_standard_backing-property-naming = enabled
// 文件路径:sample/build.gradle.kts(节选)
// 根脚本只声明插件(apply false):子模块各自按需 apply。
// ⚠️ build-logic 的 StaticAnalysisConventionPlugin 按字符串 ID apply ktlint/detekt,
//    这两个插件必须在根脚本 apply false 进 classpath,否则运行时报 "Plugin not found"。
plugins {
    // ...android / kotlin / ksp / hilt 等其余插件同样在此 apply false(完整清单见 sample/build.gradle.kts)
    alias(libs.plugins.ktlint) apply false   // 版本在 libs.versions.toml 收口
    alias(libs.plugins.detekt) apply false
}
// 文件路径:sample/build-logic/convention/src/main/kotlin/StaticAnalysisConventionPlugin.kt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jlleitschuh.gradle.ktlint.KtlintExtension

/**
 * 静态检查约定插件:所有模块 apply 一次即获得 ktlint 与 detekt 任务。
 * 关键设计:ktlintCheck 挂到 check 上,让 `./gradlew check` 一次性跑完所有校验。
 */
class StaticAnalysisConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        pluginManager.apply("org.jlleitschuh.gradle.ktlint")
        pluginManager.apply("io.gitlab.arturbosch.detekt")

        extensions.configure<KtlintExtension> {
            // 例外说明:这里 set 的是 ktlint 引擎版本(KtlintExtension.version),
            // 不是 Gradle 插件版本——插件版本仍由 toml 的 libs.plugins.ktlint 收口;
            // 引擎版本没有对应的 catalog 别名,只能在此显式声明
            version.set("1.3.1")
            android.set(true)                 // 用 Android 官方 kotlin 代码风格
            ignoreFailures.set(false)         // CI 上必须为 false,失败即红灯
            filter { exclude { it.file.path.contains("build/") } }
            reporters {
                reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.HTML)
                reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
            }
        }

        extensions.configure<io.gitlab.arturbosch.detekt.extensions.DetektExtension> {
            // 单文件配置,团队只维护这一份(sample 工程实际路径:detekt/detekt.yml)
            config.setFrom(files("$rootDir/detekt/detekt.yml"))
            buildUponDefaultConfig = true
            allRules = false                  // 只跑显式开启的规则,避免升级后突然冒出几百条
            parallel = true
        }

        // 让 check 任务同时跑测试与静态检查
        tasks.named("check") {
            dependsOn("ktlintCheck", "detekt")
        }
    }
}

2.3 detekt 规则集(裁剪过的可粘贴版本)

# 文件路径:detekt/detekt.yml(sample 工程实际路径)
# 原则:只开启"误报少、团队认可"的规则。规则越多越容易被整体禁用,那就等于没有。
build:
  maxIssues: 0          # 超过 0 条即失败;存量项目先用 baseline 过渡
  excludeCorrectable: false

complexity:
  active: true
  LongParameterList:
    active: true
    functionThreshold: 6        # 超过 6 个参数就应当封装成参数对象
    constructorThreshold: 7
  CyclomaticComplexMethod:
    active: true
    threshold: 12               # 圈复杂度 > 12 说明该函数需要拆分
  LongMethod:
    active: true
    threshold: 60               # 函数体 60 行上限(不含注释与空行)
  TooManyFunctions:
    active: true
    thresholdInFiles: 30
    thresholdInClasses: 20
  NestedBlockDepth:
    active: true
    threshold: 4

naming:
  active: true
  ClassNaming:
    active: true
    classPattern: '[A-Z][A-Za-z0-9]*'
  FunctionNaming:
    active: false               # 关闭:测试里用反引号中文函数名(第 21 章)
  VariableNaming:
    active: true
    variablePattern: '[a-z][A-Za-z0-9]*'
  ObjectPropertyNaming:
    active: true
    constantPattern: '[A-Za-z][_A-Za-z0-9]*'
  NonBooleanPropertyPrefixedWithIs:
    active: true

style:
  active: true
  MagicNumber:
    active: false               # 关闭:UI 与布局里魔法数字太多,误报率高
  UnusedPrivateMember:
    active: true
  MaxLineLength:
    active: true
    maxLineLength: 120
    excludePackageStatements: true
    excludeImportStatements: true
  ForbiddenComment:
    active: true
    # 禁止把 TODO 无期限地留在代码里:必须带责任人或 issue 号
    comments:
      - value: 'FIXME:'
        reason: 'FIXME 必须在本次迭代内解决或转为 TODO(issue)'
  UseCheckNotNull:
    active: true
  UseRequireNotNull:
    active: true

potential-bugs:
  active: true
  UnsafeCallOnNullableType:
    active: true                # 禁止 !!,见第 01 章
  UnsafeCast:
    active: false
  ImplicitDefaultLocale:
    active: true
  LateinitUsage:
    active: false               # Android 里 lateinit 有合理场景(注入、binding)

coroutines:
  active: true
  GlobalCoroutineUsage:
    active: true                # 禁止 GlobalScope(第 01 章)
  SuspendFunWithFlowReturnType:
    active: true

exceptions:
  active: true
  TooGenericExceptionCaught:
    active: true
    # 允许在顶层兜底处捕获通用异常,业务代码里不允许
    excludes: ['**/Application.kt', '**/*CrashHandler.kt', '**/test/**']
  SwallowedException:
    active: true
    ignoredExceptionTypes:
      - InterruptedException
      - NumberFormatException

2.4 Android Lint 基线

// 文件路径:sample/app/build.gradle.kts(片段)
android {
    lint {
        // baseline 记录存量告警,让 CI 只对"新增问题"失败
        baseline = file("lint-baseline.xml")
        // 新增问题直接失败,不允许累积
        abortOnError = true
        checkAllWarnings = false       // 只看 error + 指定 check,避免噪音
        warningsAsErrors = false
        // 明确开启的关键检查项
        check += setOf(
            "NewApi",                  // API 兼容性
            "SetTextI18n",             // 硬编码文本
            "HardcodedText",
            "UnusedResources",
            "MissingClass",
            "GradleCompatible",
        )
        disable += setOf("ObsoleteLintCustomCheck", "TypographyQuotes")
        // 忽略测试源码集的 Lint(测试里硬编码字符串是正常的)
        ignoreTestSources = true
        // 生成 HTML 报告,便于 CR 时直接看
        htmlReport = true
    }
}
# 首次接入:一次性生成基线,把存量告警"冻结"住
./gradlew :app:lintDebug -Dlint.baselines.continue=true
# 之后每次 lint 都只报新增问题;基线文件要提交进版本库

2.5 Git Hook(本地快速反馈)

# 文件路径:scripts/install-git-hooks.sh
#!/usr/bin/env bash
# 安装方式:./gradlew installHooks  或手动执行一次本脚本
set -e

HOOK_DIR="$(git rev-parse --show-toplevel)/.githooks"
git config core.hooksPath "$HOOK_DIR"
echo "Git hooks 已指向 $HOOK_DIR"
# 文件路径:.githooks/pre-commit
#!/usr/bin/env bash
# 本地提交前只做"快"的检查:格式自动修复 + 改动模块的相关性检查
set -e

echo "▶ ktlintFormat(自动修复格式)"
./gradlew ktlintFormat --quiet

# 把自动修复后的改动重新加进暂存区,避免提交的是旧内容
git add -u

echo "▶ 检查是否误提交敏感文件"
if git diff --cached --name-only | grep -E "(keystore\.properties|\.jks$|\.p12$|local\.properties)"; then
  echo "❌ 检测到签名/本地配置文件,已阻止提交(第 20 章)"
  exit 1
fi
echo "✅ pre-commit 检查通过"
# 文件路径:.githooks/commit-msg
#!/usr/bin/env bash
# 校验提交信息符合 Conventional Commits
set -e
MSG_FILE="$1"
MSG=$(cat "$MSG_FILE")
PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9\-]+\))?!?: .{1,72}$'

if ! echo "$MSG" | grep -Eq "$PATTERN"; then
  echo "❌ 提交信息不符合 Conventional Commits:"
  echo "   格式:<type>(<scope>): <subject>,如 feat(feed): 支持下拉刷新"
  echo "   你的提交:$MSG"
  exit 1
fi

[!TIP] Git Hook 只能做"快"的事情(< 10 秒),慢检查一律放 CI。 把 detekt 或 Lint 放进 pre-commit 会让大家用 --no-verify 绕过——规范一旦被普遍绕过,就比没有规范更糟。

2.6 CI 卡口(含静态检查与测试分层)

# 文件路径:.github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main, 'release/**']
  push:
    branches: [main]

# 同一 PR 的新提交自动取消旧任务,省机器也省等待
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # —— 快速反馈:PR 阶段必须全绿,目标 5 分钟内 ——
  quality:
    name: 静态检查
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - uses: gradle/actions/setup-gradle@v4

      - name: ktlint
        run: ./gradlew ktlintCheck

      - name: detekt
        run: ./gradlew detekt

      - name: Android Lint(新增问题阻断)
        run: ./gradlew :app:lintProdStoreDebug

      - name: 上传检查报告
        if: failure()      # 只在失败时上传,成功时省带宽
        uses: actions/upload-artifact@v4
        with:
          name: lint-reports
          path: '**/build/reports/'

  test:
    name: 单元测试
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - uses: gradle/actions/setup-gradle@v4

      - name: 运行全部 JVM 单元测试
        # flavor 只在 :app 声明:无 flavor 模块是 testDebugUnitTest,app 是带变体名的任务
        run: ./gradlew testDebugUnitTest testProdStoreDebugUnitTest

      - name: 覆盖率报告(仅统计,不作硬性卡口)
        run: ./gradlew koverHtmlReportProdStoreDebug

      - name: 归档测试报告
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: '**/build/reports/tests/'

  # —— 慢反馈:只有合入主干或打上标签才跑 ——
  ui-test:
    name: UI 测试
    needs: [quality, test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 40
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }

      # 关闭动画是消除 UI 测试 flaky 的第一步(第 21 章)
      - name: 在模拟器上运行仪器化测试
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          disable-animations: true
          script: ./gradlew connectedProdStoreDebugAndroidTest

2.7 PR 模板(可粘贴)

# 文件路径:.github/pull_request_template.md

## 这个 PR 做了什么
<!-- 一句话说清变更意图。 reviewers 先看这里,再看代码。 -->

## 为什么这么做
<!-- 背景、需求来源、或者它修复了哪个问题。关联 issue:Closes #123 -->

## 改动范围
- [ ] `:feature:feed`
- [ ] `:core:network`
- [ ] 构建 / CI 配置
<!-- 勾选实际涉及的模块,便于 CODEOWNERS 自动分派 -->

## 影响面自查
- [ ] 是否改动了对外 API(`:core:*` 的公开签名)→ 需要同步更新模块 README
- [ ] 是否新增权限 / 采集项 → 需要同步更新隐私清单(第 19 章)
- [ ] 是否影响启动路径 → 需要跑一轮启动耗时基准(第 17 章)
- [ ] 是否需要特性开关 → 大改动默认加开关(第 18 章)

## 怎么验证
<!-- 具体步骤,而不是"已自测" -->
1. 打开信息流,下拉刷新,确认列表更新且无重复项
2. 断网后刷新,确认展示错误态且可重试
3. 低端机(Android 8 / 2GB RAM)上确认无卡顿

## 截图 / 录屏
<!-- UI 变更必须贴:before / after 对比,深色模式各一张 -->

| before | after |
| --- | --- |
|  |  |

## 测试
- [ ] 已补充单元测试
- [ ] 已补充 UI 测试(仅关键路径)
- [ ] 全部测试本地通过

## 提交信息
<!-- 本 PR 的提交符合 Conventional Commits;合入建议用 Squash and merge -->

2.8 CODEOWNERS

# 文件路径:.github/CODEOWNERS
# 语法:路径 + owner;后面的规则覆盖前面的
# 作用:PR 触及这些文件时自动请求对应 owner 评审,且可设为"必须批准"

# 默认 owner:兜底,避免无人评审
*                                   @techfeed/android-all

# 构建与 CI:改动风险最高,需资深同学批准
/build-logic/                       @techfeed/android-core
/gradle/                            @techfeed/android-core
/.github/                           @techfeed/android-core
*.kts                               @techfeed/android-core
/keystore.properties.example        @techfeed/android-core

# 核心模块:跨模块影响面大
/core/model/                        @techfeed/android-core
/core/network/                      @techfeed/backend-android
/core/database/                     @techfeed/android-core
/core/designsystem/                 @techfeed/design-android
/core/crash/                        @techfeed/android-core

# 业务模块:按特性团队划分
/feature/feed/                      @techfeed/feed-team
/feature/detail/                    @techfeed/feed-team
/feature/auth/                      @techfeed/account-team
/feature/profile/                    @techfeed/account-team
/feature/push/                      @techfeed/growth-team

# 隐私与安全相关:需合规同学过目
/AndroidManifest.xml                @techfeed/android-core @techfeed/compliance

2.9 ADR 模板与示例

# 文件路径:docs/adr/0001-采用-trunk-based-分支模型.md

# 1. 采用主干开发(Trunk-Based)分支模型

- 状态:已接受
- 日期:2026-09-20
- 决策者:Android 组全体
- 关联:ADR-0002(特性开关方案)

## 背景

当前使用 GitFlow,`develop` 与 `feature/*` 长期并存。近三个迭代出现两次"合并地狱":
一次特性分支存活 18 天,合入时产生 400+ 冲突文件,回滚耗时半天。
同时移动端发版受应用市场节奏约束(约两周一次),`develop` 与 `release` 的双主干并未带来实际收益。

## 备选方案

| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| A. 维持 GitFlow | 版本边界清晰 | 冲突成本高,与持续交付冲突 |
| B. 纯 Trunk-Based | 集成最快 | 移动端需要发布分支兜底,纯主干无法冻结 |
| C. Trunk-Based + 发布分支 | 集成早 + 发版可冻结 | 需要特性开关与可靠 CI |

## 决策

采用方案 C:
- 唯一主干 `main`,必须随时可构建、可发布。
- 特性分支命名 `feature/<短描述>`,存活不超过 3 天,通过 PR 合入。
- 发版前从 `main` 切 `release/<version>` 冻结;hotfix 从 release 切出并**双向合回** `main` 与 `release`。
- 超过 3 天的功能必须用特性开关关着分批合入。

## 影响

- 正面:集成冲突显著减少,发版前无需大规模合并。
- 代价:需要建立特性开关基础设施(ADR-0002);CI 必须 5 分钟内给出结论。
- 需要跟进:为 `main` 配置分支保护(要求 CI 全绿 + 1 个 CODEOWNERS 批准)。

2.10 变更日志自动生成

standard-version 的行为由仓库根目录的 .versionrc.json 配置(注意 JSON 不支持注释,文件里只放纯配置;hidden: true 表示该类型不进 CHANGELOG):

{
  "types": [
    { "type": "feat", "section": "✨ 新功能" },
    { "type": "fix", "section": "🐛 缺陷修复" },
    { "type": "perf", "section": "⚡ 性能优化" },
    { "type": "refactor", "section": "♻️ 重构" },
    { "type": "test", "section": "✅ 测试", "hidden": true },
    { "type": "chore", "section": "🔧 其他", "hidden": true },
    { "type": "docs", "section": "📝 文档", "hidden": true }
  ],
  "commitUrlFormat": "https://github.com/techfeed/android/commit/{{hash}}",
  "issueUrlFormat": "https://github.com/techfeed/android/issues/{{id}}"
}
# 根据 Conventional Commits 自动生成 CHANGELOG.md 并打 tag
npx standard-version --release-as minor
# 只预览不落地(CR 前自检用)
npx standard-version --dry-run

2.11 代码规范示例(命名、可见性、函数长度)

// 文件路径:docs/samples/ch22/RefreshFeedUseCase.kt
package com.techfeed.feature.feed.domain

import com.techfeed.core.common.DispatcherProvider
import com.techfeed.core.model.Article
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

/** 常量放 companion object 顶层,命名 SCREAMING_SNAKE_CASE */
private const val MAX_RETRY = 3
private val RETRY_DELAY = 1.seconds

/**
 * UseCase 命名:<动词><对象>UseCase,一个类只做一件事。
 * 依赖全部通过构造函数注入(第 12 章),字段一律 private val —— 可见性最小化。
 */
class RefreshFeedUseCase @Inject constructor(
    private val repository: FeedRepository,
    private val dispatchers: DispatcherProvider,
) {
    /**
     * operator fun invoke 让调用方写成 refreshFeed(),语义更自然。
     * suspend 函数必须是主线程安全的:内部自行切线程(第 01 章)。
     */
    suspend operator fun invoke(page: Int): List<Article> = withContext(dispatchers.io) {
        require(page >= 1) { "page 必须从 1 开始,传入 $page" }   // 前置校验用 require 而非 !!

        repeat(MAX_RETRY) { attempt ->
            val result = runCatching { repository.refresh(page) }
            if (result.isSuccess) return@withContext result.getOrThrow()
            if (attempt == MAX_RETRY - 1) throw result.exceptionOrNull()!!
        }
        emptyList()
    }
}

命名约定速查:

对象 规则 示例
类 / 接口 大驼峰 FeedRepository、GetArticleUseCase
函数 / 属性 小驼峰 refresh()、isLoading
常量 全大写下划线 MAX_RETRY、KEY_ARTICLE_ID
布尔 is / has / can 前缀 isFavorite、hasMore
组合函数 名词短语,返回 Unit FeedScreen、ArticleCard
UI 状态 <页面>UiState FeedUiState
测试 反引号描述意图 `refresh 失败时展示错误态`

注释规范:

  • 注释解释"为什么",不解释"是什么":// 主线程不能做 I/O 有价值,// 设置标题 没有。
  • 公开 API 写 KDoc,说明契约(入参约束、返回值、是否主线程安全)。
  • 禁止注释掉的代码进版本库——想找回去看 Git 历史。
  • TODO 必须带责任人或 issue:// TODO(#123): 分页加载失败后需要重试。
旧写法对照:checkstyle/PMD 与"口头规范"
维度 老做法 现在
规范载体 Wiki 里的一篇《Java 编码规范》,没人看 .editorconfig + ktlint,机器强制执行
检查工具 checkstyle / PMD(面向 Java,对 Kotlin 支持差) ktlint(Kotlin 官方风格)+ detekt(Kotlin 专属规则)
执行时机 季度代码评审时抽查 保存时 IDE 提示 → 提交时 Hook → PR 时 CI
违规处理 人工争论 CLI 报错 + 报告链接,ktlintFormat 一键修复

如果团队里还有"规范写在 Confluence 上"的情况,第一步就是把其中能被机器判定的部分迁移到配置文件里, 剩下的(架构约定、分层边界)写成 ADR。人只需要记住"机器管不了的那部分"。


三、常见坑 ⚠️

现象 根因 解法
ktlint 一接入就报几千条错误 存量代码一次性上严格规则 先 ktlintFormat 批量修格式;再用 baseline / 分模块推进
detekt 升级后突然冒出几百条新告警 用了 allRules = true 显式列出规则(allRules = false),升级前先跑一次预览
团队普遍用 --no-verify 绕过 Hook Hook 太慢或太严格 Hook 只放 < 10 秒的检查;慢检查全部移到 CI
Lint baseline 越滚越大 baseline 生成后无人清理 每个迭代固定清理 N 条;只允许"不增加",不允许"随意加"
CI 红灯没人管 一次引入几十条告警,团队绝望 门禁必须是"新增问题阻断",存量走 baseline,保证红灯总是可修复的
PR 巨大(2000+ 行)无人认真审 特性分支存活过久 分支 ≤ 3 天;大改动用特性开关分批合入;PR 超过 400 行建议拆分
CR 变成风格争论 格式问题没有被机器拦住 先上 ktlint,再在 PR 模板里写"风格问题由工具保证,评论请聚焦设计与正确性"
hotfix 合回 release 后下一版又复现 忘记双向合回 main 在发布检查清单里写死"hotfix 必须合回 main";用 CI 校验 release 分支是 main 的后代
提交信息全是 "update" 没有提交规范与校验 上 Conventional Commits + commit-msg Hook;CI 校验 PR 标题
CHANGELOG 长期没人维护 手工整理 用 standard-version 从提交自动生成
CODEOWNERS 配了但没人被自动指派 路径写法与仓库结构不匹配 路径是相对仓库根的 glob;用 gh api 或直接改文件触发一次验证
ADR 写了一堆没人读 ADR 太长或没关联决策 一页纸,包含背景/备选/决策/影响;在 PR 里引用 ADR 链接
新人对着仓库发呆三天 缺 Onboarding 文档 写一份"从克隆到跑起来"的 README,含常见报错速查
模块边界被随意跨越 无 CODEOWNERS + 无依赖检查 配 CODEOWNERS;CI 加模块依赖方向校验(第 15 章)

四、调试技巧

  1. 本地一键跑完所有检查:./gradlew check 会同时跑 ktlintCheck、detekt 与单测,提交前跑一次最省事。
  2. ktlint 自动修复:./gradlew ktlintFormat;只想看不改:./gradlew ktlintCheck 后看 build/reports/ktlint/ktlintMainSourceSetCheck/report.html。
  3. detekt 快速定位:报告在 模块/build/reports/detekt/detekt.html,按规则分组,点进去直接跳文件行号。
  4. 验证 CODEOWNERS 是否生效:
    gh api repos/{owner}/{repo}/pulls/123/requested_reviewers
    # 或在 GitHub 仓库 Settings → Code review 中查看解析结果
    
  5. 验证 Git Hook 有没有被加载:git config core.hooksPath,为空说明没装;chmod +x .githooks/* 别忘(Windows 上用 Git Bash 提交)。
  6. Lint 基线清理进度:grep -c "<issue" app/lint-baseline.xml,把这个数字写进迭代目标,每次减 20 条。
  7. 排查 CI 与本地结果不一致:确认本地用的是 Wrapper(./gradlew 而非 gradle),且 JDK 版本与 CI 一致(第 02 章)。
  8. 提交信息校验本地试跑:bash .githooks/commit-msg .git/COMMIT_EDITMSG。

五、最佳实践

  1. 机器能判定的绝不让人做:格式、导入、命名、复杂度全部交给 ktlint/detekt/Lint。
    • ✅ 推荐:ktlintFormat + CI 卡口
    • ❌ 避免:CR 里评论"这里多了一个空行"
  2. 可见性最小化:字段默认 private,模块依赖默认 implementation,能用 internal 就不用 public。
  3. 函数短、职责单一:长度 ≤ 60 行、圈复杂度 ≤ 12、参数 ≤ 6 个;超了就拆或封装参数对象。
  4. 注释解释"为什么":公开 API 写 KDoc,禁止注释掉的代码,TODO 必须带 issue 号。
  5. 主干开发 + 短分支 + 发布分支:特性分支 ≤ 3 天,main 随时可发布,hotfix 双向合回。
    • ✅ 推荐:大功能用特性开关分批合入
    • ❌ 避免:让分支活到两周再一次性合并
  6. 提交信息用 Conventional Commits:6 个月后排查问题时的救命稻草,也支撑 CHANGELOG 自动生成。
  7. PR 控制在 400 行以内:超过就拆分或用开关分批;大 PR 等于没人认真审。
  8. CR 按优先级走:正确性 → 安全隐私 → 架构边界 → 可测试性 → 可读性 → 风格(不看)。
  9. 评论要可执行:给出"改成什么"和"为什么",并区分 blocker 与 nit。
    • ✅ 推荐:【阻塞】这里在 IO 线程更新了 UI,应该切回 Main;参考 DetailViewModel:42 的写法
    • ❌ 避免:这里不太好
  10. ADR 一页纸:背景、备选方案、决策、影响四段;只记录"不可逆或代价高"的决策。
  11. CODEOWNERS 覆盖核心路径::core:*、构建脚本、Manifest 必须有明确 owner 与强制批准。
  12. 规范要能被绕过检查:任何规范如果 80% 的人习惯性绕过,就说明它设计错了,应该改规范而不是抓人。
  13. 知识沉淀三件套:仓库 README(新人 30 分钟跑起来)、模块 README(边界与 API 契约)、技术分享(为什么这么选型)。

六、本章小结

  1. 规范分三层:机器判定(ktlint/detekt/Lint)、团队约定(ADR + 模块 README)、人来判断(CR 聚焦设计与正确性)。
  2. 静态检查要能落地:.editorconfig 打底、ktlint 管格式、detekt 管质量、Lint baseline 冻结存量,本地 Hook 快、CI 全、门禁只卡"新增问题"。
  3. 分支模型选主干开发 + 短分支 + 发布分支:main 随时可发布,特性分支 ≤ 3 天,hotfix 双向合回。
  4. 提交规范与 PR 模板是协作的润滑剂:Conventional Commits 支撑自动化,PR 模板把"该说什么"结构化,减少来回追问。
  5. 决策与所有权要固化:ADR 记录"为什么",CODEOWNERS 保证"改哪里找谁",两者共同防止架构在无人注意时腐化。

测试(第 21 章)让代码"改得动",协作规范让多人"一起改也不乱"。至此阶段 4 完成,下一阶段将把全书能力落到 TechFeed 这一个完整项目上。

下一章:TechFeed 总览——需求拆解、模块图、里程碑与验收标准,正式开始实战阶段。


七、动手练习

基础题

  1. 把 2.1 的 .editorconfig 与 2.2 的 ktlint 配置加进你的工程,跑一次 ./gradlew ktlintFormat,统计自动修复了多少处,再跑 ktlintCheck 确认全绿。
  2. 装好 pre-commit 与 commit-msg 两个 Hook,故意提交一条 update code 的消息和一个 keystore.properties 文件,确认都被拦下。
  3. 按 2.9 的模板为团队写一份真实 ADR:主题从"选 Compose 还是 View""用 Hilt 还是 Koin""单 Activity 还是多 Activity"中任选一个,要求写全四个段落。

进阶题 4. 配置 detekt 并接入 CI:把 LongMethod、CyclomaticComplexMethod、UnsafeCallOnNullableType、GlobalCoroutineUsage 四条规则打开,找出工程里违反最多的三个类并重构其中之一。 5. 生成 lint-baseline.xml 并记录当前告警数,配置 CI 让"新增告警"导致失败;然后清理其中 10 条告警,观察基线文件的变化。 6. 给仓库写 .github/CODEOWNERS 与 PR 模板,用一个测试 PR 验证:触及 :core:network 时对应 owner 被自动请求评审,且 PR 描述自动带上模板内容。

思考题 7. 团队里有人坚持"GitFlow 更适合我们,因为要同时维护 3 个历史版本"。你会如何评估这个说法?请列出判断依据(至少三条)以及在什么条件下你会同意他。 8. "自动化能解决的就别让人做"这句话的边界在哪里?请举出两个不适合自动化的评审场景,并说明理由。 9. 如果 CI 已经全绿但 PR 依然引入了线上崩溃,说明流程中缺了哪一环?你会如何在不显著拖慢交付速度的前提下补上它?(提示:第 18 章的灰度与第 21 章的测试分层)

📄 第 23 章 实战:TechFeed 项目总览 23-techfeed-overview.md

阶段:实战 | 预计阅读 45 min | 代码落地模块:全部 前置:第 14 章 分层架构 | 第 15 章 模块化 相关章节:第 24 章 信息流与详情 | 第 25 章 鉴权与离线 | 第 26 章 推送与收尾

学习目标

  • [ ] 能把一个产品需求拆解为"页面 → 模块 → 接口 → 数据模型"的工程方案。
  • [ ] 理解 TechFeed 的模块划分与依赖规则,能解释每个模块为什么存在。
  • [ ] 掌握里程碑切分方法:每个里程碑都可独立验收、可独立发布。
  • [ ] 会编写 API 契约与错误码约定,让前后端"说同一种语言"。
  • [ ] 知道验收标准怎么写才可执行(可勾选、可测量、可复现)。

一、核心概念

1.1 需求 → 工程方案的翻译过程

拿到 PRD(产品需求文档)后,固定四步翻译:

flowchart LR
    A["PRD<br/>业务语言"] --> B["页面与流程<br/>信息架构"]
    B --> C["模块与接口<br/>工程语言"]
    C --> D["数据模型与契约<br/>前后端共识"]
    D --> E["里程碑与验收<br/>交付节奏"]

以 TechFeed 为例:PRD 说"用户可以浏览技术文章、登录后收藏、离线可读、有新文章时提醒"。 翻译结果:

PRD 句子 工程落点
浏览技术文章 信息流列表页(分页、搜索)+ 详情页 → :feature:feed、:feature:detail
登录后收藏 登录流程 + Token 管理 + 收藏接口 → :feature:auth、:core:datastore、:core:network
离线可读 本地缓存为单一数据源 → :core:database(Offline First,第 10 章)
有新文章提醒 后台同步 + 通知 → :feature:push(WorkManager + 通道抽象,第 13 章)

1.2 模块图与依赖规则

flowchart TB
    APP[":app<br/>导航壳 / 路由实现 / DI 装配"]
    subgraph Feature["feature 业务层(彼此零依赖)"]
        FEED[":feature:feed"]
        DETAIL[":feature:detail"]
        AUTH[":feature:auth"]
        PROFILE[":feature:profile"]
        PUSH[":feature:push"]
    end
    subgraph Core["core 能力层"]
        MODEL[":core:model"]
        COMMON[":core:common"]
        DESIGN[":core:designsystem"]
        NET[":core:network"]
        DB[":core:database"]
        STORE[":core:datastore"]
        NAVAPI[":core:navigation-api"]
        CRASH[":core:crash"]
        TESTING[":core:testing"]
    end
    APP --> Feature
    Feature --> Core
    APP --> Core
    BM[":benchmark<br/>性能基线(com.android.test)"]
    BM -.->|"targetProject"| APP
    NET -.->|"TokenProvider 接口"| STORE
    APP -.->|"绑定实现(倒置)"| NET

三条铁律(第 15 章已定,实战中必须守住):

  1. feature 之间零依赖:详情页不知道信息流页存在,跳转走路由。
  2. 依赖方向只向下:feature → core → model;core 之间通过接口倒置(如 TokenProvider)。
  3. :app 是唯一聚合点:所有 feature 在这里拼装,settings.gradle.kts 之外没有任何地方认识全部模块。

1.3 里程碑切分

每个里程碑都要满足:可独立验收、可独立发布(哪怕只在测试群)、不超过两周。

里程碑 内容 验收物 对应章节
M0 地基 工程骨架、约定插件、CI 卡口 空壳 App 可安装,CI 绿 02 / 22
M1 阅读 信息流列表 + 详情(无登录) 列表流畅滚动、详情可打开、离线可见上次数据 04-06 / 09-10 / 24
M2 身份 登录、Token 无感刷新、收藏 登录后收藏可同步,Token 过期不掉登录 12 / 25
M3 留存 离线缓存完善、后台同步、通知 飞行模式能读缓存,新文章有提醒 13 / 25
M4 质量 崩溃监控、安全加固、性能达标 崩溃率看板可用,混淆包正常,启动达标 17-20 / 26

[!TIP] 里程碑的排序逻辑:先能读(M1)、再能存(M2)、后能留(M3)、最后硬化(M4)。 质量(M4)不是"最后才做",而是从 M0 起每个 MR 都在积累——监控与 CI 从第一天就在。

1.4 API 契约约定

前后端共识文档(节选,真实项目放接口平台):

# 文件路径:docs/samples/ch23/api-contract.yaml(教学示意)
# 信息流分页
GET /v1/articles?page=1&page_size=20&query=
200 Response:
  articles: Article[]     # 文章数组
  has_more: boolean       # 是否还有下一页(客户端据此决定是否再请求)

# Article 字段契约
  id: string              # 全局唯一,客户端列表 key
  title: string
  summary: string         # 可空,默认 ""
  cover_url: string?      # 可空:无封面
  author: { id, name, avatar_url? }
  published_at: int64     # 毫秒时间戳(UTC),展示层格式化
  read_minutes: int       # 预计阅读分钟数
  is_favorite: bool       # 当前用户视角;未登录恒为 false

# 错误码约定(HTTP 状态码 + 业务码)
401: token 失效           # 客户端触发静默刷新(第 25 章)
404: 内容不存在
429: 限流                 # 客户端指数退避
5xx: 服务端异常           # 客户端兜底文案,不打断浏览

字段命名规则:网络层 snake_case(DTO 用 @SerialName 映射)、代码层 camelCase(第 09 章)。


二、示例代码:从需求到第一个接口落地的完整链路

以"信息流分页"为例,走一遍 M1 的完整实现链(每个文件都真实存在于 sample/,可对照阅读):

// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/api/TechFeedApi.kt
// ① 契约 → Retrofit 接口:字段与上面的 yaml 一一对应
interface TechFeedApi {
    @GET("v1/articles")
    suspend fun getFeed(
        @Query("page") page: Int,
        @Query("page_size") pageSize: Int = 20,
        @Query("query") query: String? = null,
    ): FeedPageDto
}
// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/datasource/FeedRemoteDataSource.kt
// ② 远端数据源:safeCall 统一翻译异常为 Result(第 09 章)
class TechFeedRemoteDataSource @Inject constructor(
    private val api: TechFeedApi,
) : FeedRemoteDataSource {
    override suspend fun getFeed(page: Int, query: String?): Result<List<Article>> =
        safeCall { api.getFeed(page = page, query = query).articles.map { it.toDomain() } }
}
// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/data/FeedRepositoryImpl.kt
// ③ 仓库:Offline First —— 网络结果写入 Room,UI 只订阅 Room(第 10/25 章)
class FeedRepositoryImpl @Inject constructor(
    private val remote: FeedRemoteDataSource,
    private val dao: ArticleDao,
) : FeedRepository {
    override suspend fun refresh(query: String?): Result<Unit> = when (val r = remote.getFeed(1, query)) {
        is Result.Success -> { dao.upsertKeepingFavorite(r.data.map { it.toEntity() }); Result.Success(Unit) }
        is Result.Failure -> r
        is Result.Loading -> Result.Failure(AppError.Unknown(IllegalStateException("unexpected")))
    }
}
// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedViewModel.kt
// ④ ViewModel:订阅 Room(响应式)+ 暴露 UiState/Event 双通道(第 14 章)
// observeFeed() → combine(query) → collect → _uiState.update { ... }
// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedScreen.kt
// ⑤ UI:无状态组件 + collectAsStateWithLifecycle(第 05/06 章)

这条链路是全书所有知识点的"收口处":契约 → 接口 → 数据源 → 仓库 → ViewModel → Screen。 后面三章(24-26)都按这个套路展开,只是业务不同。


三、常见坑 ⚠️

现象 根因 解法
里程碑总是延期 把"联调完成"当验收,实际产品行为没验证 验收标准写"用户可做的事",不写"接口通了"
前后端字段对不上 没有契约文档,靠聊天记录对齐 契约先行(yaml/OpenAPI),字段变更走评审
模块越拆越乱 拆分时没定依赖规则,后来互相 import 三条铁律写进 CODEOWNERS 与 CI 检查
收藏状态刷新后丢失 网络数据直接覆盖本地收藏位 upsertKeepingFavorite(保留收藏位再插入)
列表 key 用了位置索引 偷懒没定义业务 id 契约里明确 id 唯一性,列表一律用 id 做 key
需求评审后直接开写 没做"翻译",边写边发现缺口 固定四步翻译法,产出模块图与里程碑再动手
离线功能上线才发现不可用 缓存策略没在 M1 就设计 Offline First 从 M1 开始:Room 是唯一事实源

四、调试技巧

  1. 对照源码读教程:每一章的代码清单都标注了 sample/ 中的真实路径,边读边打开对应文件。
  2. 模块隔离验证:单独构建某个 feature 验证依赖是否干净——
    ./gradlew :feature:feed:assembleDebug
    
    若失败提示缺依赖,说明 feature 之间出现了不该有的引用。
  3. 依赖规则巡检:
    # 检查是否有 feature 引用了另一个 feature(应零结果)
    grep -r "projects.feature.feed" sample/feature --include=build.gradle.kts | grep -v "feature/feed"
    
  4. 用 MockWebServer 先行开发:后端接口未就绪时,用 MockWebServer 起本地假服务(第 09 章),前后端并行不阻塞。
  5. 契约变更验证:改 DTO 后跑 ./gradlew :core:network:test,序列化测试会暴露字段错位。

五、最佳实践

  1. 契约先行:接口字段、错误码、命名规则在动手前冻结;变更走评审并同步 DTO。
  2. 模块图是活的文档:结构变更必须同步更新本节 Mermaid 图与 sample/README.md。
  3. 里程碑可发布:每个里程碑结束打一个 tag,坏了随时回滚到上一个可运行版本。
  4. 验收标准三要素:可勾选(明确动作)、可测量(有指标)、可复现(有步骤)。
  5. 先立骨架后填肉:M0 的工程骨架(约定插件 + CI + 模块注册)值得花整周打磨——它是所有后续工作的地基。
  6. 数据模型三层分明:DTO(网络)→ Entity(存储)→ 领域模型(业务),映射显式书写(第 10/14 章)。

六、本章小结

  1. 需求翻译四步法:PRD → 页面流程 → 模块接口 → 数据契约 → 里程碑验收。
  2. TechFeed 的结构 = 5 个 feature + 9 个 core + 1 个聚合 app + 1 个 benchmark(性能基线)模块;三条依赖铁律保证边界不腐化。
  3. 里程碑按"能读 → 能存 → 能留 → 硬化"排序,每个都可独立验收与发布。
  4. API 契约是前后端的最大公约数:字段、可空性、错误码全部书面化。
  5. 全书知识点的收口链路:契约 → Retrofit 接口 → 数据源 → Repository → ViewModel → Screen。

下一章:进入第一个完整功能——信息流列表与详情页,Compose 与 View 两套实现逐一对照。


七、动手练习

基础题

  1. 在纸上(或 Markdown)完成"消息中心"需求的四步翻译:页面 → 模块 → 接口 → 验收标准。
  2. 打开 sample/ 工程,跑通 M1 链路:从 TechFeedApi.getFeed 断点到 FeedList 渲染,画出数据流向图。
  3. 用 MockWebServer 写一个假的信息流接口,验证 FeedRepositoryImpl.refresh 能正确写入 Room。

进阶题 4. 为 TechFeed 设计"搜索历史"功能:需求翻译 + 模块落点(提示:DataStore 存历史、Room 存结果)+ 里程碑归属。 5. 检查 sample/ 工程是否违反三条依赖铁律,写一个脚本在 CI 中自动巡检 feature 间依赖。

思考题 6. 为什么 Repository 接口定义在 feature 的 domain 层而不是 core 层?如果两个 feature 需要同一份数据,接口该放哪?(提示:core:data 中间层的取舍) 7. "验收标准可测量"在性能类需求(如"启动要快")上如何落地?(提示:第 17 章的指标红线) 8. 如果产品要求支持"未登录也能收藏(本地收藏)",架构上要动哪些模块?收藏的"单一数据源"会变成什么?

📄 第 24 章 实战:信息流与详情页 24-techfeed-feed-and-detail.md

阶段:实战 | 预计阅读 60 min | 代码落地模块::feature:feed、:feature:detail 前置:第 23 章 TechFeed 总览 | 第 06 章 列表与导航 相关章节:第 10 章 数据存储 | 第 17 章 性能优化

学习目标

  • [ ] 能独立实现"分页列表 + 搜索 + 下拉刷新 + 空态/错误态"的完整信息流页面。
  • [ ] 掌握 Compose 与 View/XML 双体系实现同一页面的对照方法,能评估各自成本。
  • [ ] 理解"收藏"在 Offline First 架构下的完整链路(UI → 仓库 → 网络 + 本地)。
  • [ ] 会为列表页编写单元测试与 UI 测试。

一、核心概念

1.1 信息流页面的状态机

列表页看似简单,状态组合却不少。先画状态机再写代码:

stateDiagram-v2
    [*] --> Loading: 首次进入
    Loading --> Success: 首屏返回
    Loading --> Error: 网络失败且无缓存
    Success --> Refreshing: 下拉刷新
    Refreshing --> Success: 刷新完成
    Refreshing --> Error: 刷新失败(保留旧数据 + Toast)
    Success --> Empty: 搜索无结果
    Empty --> Success: 清空搜索词
    Error --> Loading: 点击重试

这些状态全部收敛在 FeedUiState 一个不可变快照里(第 14 章),UI 只做映射渲染。

1.2 双体系实现策略

决策点 Compose 版 View/XML 版
状态订阅 collectAsStateWithLifecycle repeatOnLifecycle(STARTED) + collect
列表 LazyColumn + key ListAdapter + DiffUtil
刷新 SwipeRefresh/PullToRefreshBox SwipeRefreshLayout
局部更新 状态驱动,自动最小重组 submitList 差量动画
预览/测试 @Preview + Compose UI Test Layout Inspector + Espresso

本工程策略(与第 16 章一致):Compose 为主实现,View 版保留在 ui/view/ 用于教学对照与存量迁移参考。


二、示例代码(与 sample/ 逐行对应)

[!NOTE] 演进说明:FeedViewModel 的构造签名在本书中经历了一次收敛——第 11/12 章为 FeedViewModel(getFeed: GetFeedUseCase, refreshFeed: RefreshFeedUseCase)(双 UseCase 注入),第 21 章测试沿用该签名;本章是工程最终形态:FeedViewModel(repository: FeedRepository)。原因:信息流场景的两个 UseCase 都只剩一行转发,按第 14 章"什么时候不该写 UseCase"的原则直接删除,VM 依赖 Repository 接口即可。

2.1 信息流列表(Compose 主实现)

// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedScreen.kt
// 核心:稳定 key + contentType + 最小参数(第 06/17 章)
@Composable
fun FeedList(
    articles: List<Article>,
    onArticleClick: (String) -> Unit,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(16.dp),
) {
    LazyColumn(
        modifier = modifier.fillMaxSize(),
        contentPadding = contentPadding,
    ) {
        items(
            items = articles,
            key = { it.id },              // ⭐ 身份是业务 id
            contentType = { "article" },  // ⭐ 同类型复用
        ) { article ->
            ArticleListItem(article, onClick = { onArticleClick(article.id) })
        }
    }
}

@Composable
private fun ArticleListItem(article: Article, onClick: () -> Unit) {
    Card(onClick = onClick) {
        Column(modifier = Modifier.padding(12.dp)) {
            // 有封面时展示(Coil3 异步加载)
            article.coverUrl?.let { url ->
                AsyncImage(
                    model = url,
                    contentDescription = null,   // 封面是装饰性内容,无需朗读
                    modifier = Modifier.fillMaxWidth().height(140.dp),
                )
            }
            Text(article.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
            Text(
                "${article.author.name} · ${article.readMinutes} 分钟",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
        }
    }
}

错误态必须给重试入口(只给文案不给重试是糟糕的可用性):

// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedScreen.kt(错误态分支)
// state.error 类型是 AppError?(第 09 章),文案经 error.userMessage 取
error?.let { e ->
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = e.userMessage, color = MaterialTheme.colorScheme.error)
        Button(onClick = viewModel::refresh, modifier = Modifier.padding(top = 8.dp)) {
            Text("重试")
        }
    }
}

2.2 信息流 ViewModel(状态机收口处)

// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/ui/FeedViewModel.kt
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class FeedViewModel @Inject constructor(
    private val repository: FeedRepository,
) : ViewModel() {

    private val query = MutableStateFlow("")

    private val _uiState = MutableStateFlow(FeedUiState())
    val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()

    private val _events = MutableSharedFlow<FeedEvent>()
    val events: SharedFlow<FeedEvent> = _events.asSharedFlow()

    init {
        // 响应式订阅:搜索词变化 → 重新订阅 DB 流(flatMapLatest + DB 级过滤,
        // 过滤发生在 SQL 里而不是内存里,第 10 章)
        viewModelScope.launch {
            query
                .flatMapLatest { q -> repository.observeFeed(q.ifBlank { null }) }
                .catch { e -> _uiState.update { s -> s.copy(error = AppError.Unknown(e)) } }
                .collect { articles ->
                    _uiState.update { it.copy(articles = articles, error = null) }
                }
        }
        refresh()
    }

    fun onQueryChange(text: String) {
        query.value = text
        _uiState.update { it.copy(query = text) }
    }

    fun refresh() {
        viewModelScope.launch {
            // 首次加载(列表为空)显示全屏进度;已有内容则只标记"刷新中"
            _uiState.update { it.copy(isLoading = it.articles.isEmpty(), isRefreshing = true) }
            when (val result = repository.refresh(query.value.ifBlank { null })) {
                is Result.Failure -> {
                    _uiState.update { it.copy(error = result.error) }
                    // 会话失效:引导去登录(第 25 章鉴权链路)
                    val err = result.error
                    if (err is AppError.Http && err.code == 401) {
                        _events.emit(FeedEvent.NavigateToLogin)
                    }
                }
                else -> Unit
            }
            _uiState.update { it.copy(isLoading = false, isRefreshing = false) }
        }
    }

    fun toggleFavorite(articleId: String) {
        viewModelScope.launch {
            // 失败反馈给用户(断网点收藏不能静默吞掉)
            when (val result = repository.toggleFavorite(articleId)) {
                is Result.Failure -> _uiState.update { it.copy(error = result.error) }
                else -> Unit
            }
        }
    }

    fun onArticleClick(articleId: String) {
        viewModelScope.launch { _events.emit(FeedEvent.OpenArticle(articleId)) }
    }
}

2.3 信息流列表(View 对照实现)

// 文件路径:docs/samples/ch24/ArticleAdapter.kt
// ListAdapter + DiffUtil:差量更新与稳定 key 的 View 体系实现(第 06 章)
class ArticleAdapter(
    private val onClick: (Article) -> Unit,
) : ListAdapter<Article, ArticleAdapter.ArticleViewHolder>(DIFF) {

    class ArticleViewHolder(private val binding: ItemArticleBinding) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(article: Article) {
            binding.title.text = article.title
            binding.author.text = article.author.name
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder =
        ArticleViewHolder(
            ItemArticleBinding.inflate(LayoutInflater.from(parent.context), parent, false),
        )

    override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
        val article = getItem(position)
        holder.bind(article)
        holder.itemView.setOnClickListener { onClick(article) }
    }

    companion object {
        private val DIFF = object : DiffUtil.ItemCallback<Article>() {
            override fun areItemsTheSame(oldItem: Article, newItem: Article) = oldItem.id == newItem.id
            override fun areContentsTheSame(oldItem: Article, newItem: Article) = oldItem == newItem
        }
    }
}
// 文件路径:docs/samples/ch24/FeedFragment.kt
// View 体系订阅状态的标配:repeatOnLifecycle(STARTED)(第 03 章)
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            adapter.submitList(state.articles)
            binding.progress.isVisible = state.isRefreshing
            binding.errorBar.isVisible = state.error != null
        }
    }
}

2.4 详情页与收藏链路

// 文件路径:sample/feature/detail/src/main/java/com/techfeed/feature/detail/ui/DetailViewModel.kt
// 收藏切换:UI 只发命令,状态由仓库回写(Offline First 单向流)
fun toggleFavorite() {
    viewModelScope.launch {
        when (val r = toggleFavorite(articleId)) {
            is Result.Success -> _uiState.update { it.copy(article = r.data) }
            is Result.Failure -> _events.emit(DetailEvent.ShowToast("操作失败,请重试"))
            is Result.Loading -> Unit
        }
    }
}
// 文件路径:sample/feature/feed/src/main/java/com/techfeed/feature/feed/data/FeedRepositoryImpl.kt
// 收藏的落地:服务端 + 本地双写,本地收藏位在刷新时被保留(第 23 章 upsertKeepingFavorite)
override suspend fun toggleFavorite(id: String): Result<Unit> = when (val r = remote.toggleFavorite(id)) {
    is Result.Success -> { dao.updateFavorite(id, r.data.isFavorite); Result.Success(Unit) }
    is Result.Failure -> r
    is Result.Loading -> Result.Failure(AppError.Unknown(IllegalStateException("unexpected")))
}

2.5 列表页测试

// 文件路径:sample/feature/feed/src/test/java/com/techfeed/feature/feed/ui/FeedViewModelTest.kt
// 测试内私有 Fake(第 21 章 Fake 优先于 Mock):按真实仓库契约实现接口,
// query 非空时按标题过滤——这是 DB 级过滤的内存模拟
private class FakeRepo(
    var fail: Boolean = false,
    var failError: AppError = AppError.Network(IOException("offline")),
) : FeedRepository {
    val data = listOf(/* 预置两条 Article,见 sample 同名文件 */)
    var favoriteToggled = false
    var lastQuery: String? = null

    override fun observeFeed(query: String?): Flow<List<Article>> {
        lastQuery = query
        return flowOf(
            if (query.isNullOrBlank()) data
            else data.filter { it.title.contains(query, ignoreCase = true) },
        )
    }

    override suspend fun refresh(query: String?): Result<Unit> =
        if (fail) Result.Failure(failError) else Result.Success(Unit)

    override suspend fun toggleFavorite(id: String): Result<Unit> {
        favoriteToggled = true
        return Result.Success(Unit)
    }
}

// 关键用例之一:刷新失败(第 21 章模式,完整四个用例见 sample 同名文件)
@Test
fun `刷新失败 错误进入 UiState 而不是崩溃`() = runTest {
    val viewModel = FeedViewModel(FakeRepo(fail = true))
    advanceUntilIdle()
    assertThat(viewModel.uiState.value.error).isInstanceOf(AppError.Network::class.java)
}

三、常见坑 ⚠️

现象 根因 解法
搜索输入卡顿 每个字符都触发 Room 查询 + 全列表重组 输入防抖(snapshotFlow + debounce);本地过滤放 combine
刷新时列表闪一下 刷新走了"清空再填充" Offline First:旧数据保留,仅更新 isRefreshing
收藏后返回列表状态不同步 两个页面各自缓存状态 单一数据源:都订阅 Room,收藏落库后自动同步
滚动时图片闪烁 key 不稳定 + 无内存缓存 稳定 key;Coil 默认内存缓存按 URL-key 生效
详情页参数丢失(进程重建) 从 Intent 手动读参数且未存 参数走 Navigation → SavedStateHandle(第 03 章)
错误 Toast 重复弹 错误放进了 UiState 且页面重建 一次性提示走 SharedFlow 事件通道
空态与错误态同时出现 状态机没有互斥设计 FeedUiState 用单一 sealed 状态或在渲染层明确优先级

四、调试技巧

  1. 验证离线:adb shell svc wifi disable && adb shell svc data disable(或开飞行模式),确认列表仍可读缓存、刷新报错但不清空。
  2. 观察 Room 变化:在 DAO 方法加临时日志,或用 Database Inspector(App Inspection)直接查看表内容与 SQL。
  3. Compose 重组热点:Layout Inspector 打开 Recomposition Counts,滚动信息流,item 重组应为 0(数据未变时)。
  4. 验证收藏同步:列表页收藏 → 进入详情页 → 返回,状态应一致(都来自 Room)。
  5. 接口契约校验:MockWebServer 返回缺字段的 JSON,验证 ignoreUnknownKeys 与默认值兜底(第 09 章)。

五、最佳实践

  1. 状态机先行:写 UI 前先画状态图,所有分支在 FeedUiState 中有唯一表达。
  2. 列表三件套:稳定 key、contentType、最小参数——性能与正确性的地基(第 06/17 章)。
  3. Offline First 从第一天开始:即使 M1 不要求离线,也用 Room 做单一数据源,M3 就不用重构。
  4. 双体系对照阅读:读 Compose 实现时打开 ui/view/ 对照,理解"手动同步消失"的过程。
  5. 每个功能带测试落地:ViewModel 单测是底线(四个用例模板见 2.5)。
  6. 空态/错误态/加载态一样设计:它们是真实用户最常看到的状态。

六、本章小结

  1. 列表页是状态机:Loading/Success/Empty/Error/Refreshing 五态收敛于一个不可变 FeedUiState。
  2. Compose 实现的三根支柱:collectAsStateWithLifecycle、LazyColumn(key+contentType)、无状态子组件。
  3. View 对照实现的核心是 ListAdapter + DiffUtil + repeatOnLifecycle,与 Compose 一一映射。
  4. 收藏链路 = UI 命令 → Repository → 网络 + Room 双写 → 响应式回传,全链路单向。
  5. 测试先行:ViewModel 四用例(成功/失败/事件/副作用)覆盖 M1 的核心行为。

下一章:给 App 加上"身份"——登录鉴权、Token 无感刷新与离线缓存的同步策略。


七、动手练习

基础题

  1. 给信息流页补上"空态"(搜索无结果时的插画 + 文案)与"错误态"(重试按钮),两种态互斥。
  2. 为 ArticleListItem 写 Compose UI 测试:验证点击回调携带正确的 articleId(第 21 章)。
  3. 给搜索加 300ms 防抖,用 advanceTimeBy 写一个单元测试验证"连续输入只触发一次过滤"。

进阶题 4. 实现分页加载:用 Paging3 改造 FeedRepository(保留 Room 缓存),底部显示"加载中/到底了"。 5. 把 View 版信息流(ui/view/)与 Compose 版做像素级对照,输出一份"手动同步点清单"(至少 6 处,参照第 05 章练习)。

思考题 6. FeedUiState 里同时有 isLoading 和 isRefreshing,为什么不用一个 Boolean 表达"转圈"?两种转圈的用户预期差在哪? 7. 如果产品要求"收藏操作离线可用,联网后同步",2.4 的收藏链路要怎么改?(提示:本地先写 + 待同步队列) 8. 列表 key 用 id 后,"插入到列表头部"为什么不再引起整列重组?从 Diff/重组两个角度分别解释。

📄 第 25 章 实战:登录鉴权与离线缓存 25-techfeed-auth-offline.md

阶段:实战 | 预计阅读 60 min | 代码落地模块::feature:auth、:core:network、:core:datastore 前置:第 24 章 信息流与详情 | 第 09 章 网络请求 相关章节:第 19 章 安全加固 | 第 10 章 数据存储

学习目标

  • [ ] 能实现完整的登录流程:表单校验 → 接口调用 → Token 加密存储 → 导航跳转。
  • [ ] 理解 AuthInterceptor + Authenticator 的分工,实现 401 无感刷新且不重入。
  • [ ] 掌握"单一数据源 + 保留本地态"的离线同步模式。
  • [ ] 会为鉴权链路编写单元测试(含并发刷新场景)。

一、核心概念

1.1 鉴权链路全景

sequenceDiagram
    participant UI as 界面
    participant VM as ViewModel
    participant Repo as Repository
    participant I as AuthInterceptor
    participant A as TokenAuthenticator
    participant S as 服务端

    UI->>VM: 提交用户名密码
    VM->>Repo: login()
    Repo->>S: POST /v1/auth/login
    S-->>Repo: access_token + refresh_token
    Repo->>Repo: 写入 SecureTokenStorage(加密)
    Repo-->>VM: Success
    VM-->>UI: 跳转回列表

    Note over UI,S: 之后任意请求……
    UI->>I: 携带请求
    I->>I: 附加 Authorization: Bearer access
    I->>S: 请求
    S-->>A: 401(access 过期)
    A->>S: 静默刷新(refresh_token)
    S-->>A: 新 access
    A-->>I: 用新 token 重发原请求(用户无感知)

分工记住一句话:Interceptor 负责"带上凭据",Authenticator 负责"凭据失效后怎么办"。

1.2 无感刷新的三个坑

  1. 并发 401:列表页同时发 5 个请求都 401 → 必须用 Mutex + 双重检查保证只刷新一次(只加锁不去重仍会刷 N 次,见 2.2)。
  2. 刷新也失败:refresh_token 也过期 → 清凭据、广播"需要重新登录"事件。
  3. 重试死循环:刷新后的请求再 401 → 限制重试次数(本工程 2 次)。

1.3 离线同步模式

Offline First 的读路径已在前两章确立(Room 是唯一事实源)。写路径(收藏/点赞)的难点是本地态与服务端态的一致性:

策略 行为 适用
服务端优先(本工程 M2) 先请求服务端,成功后写本地 收藏等低频操作,网络差时明确失败
本地优先 + 待同步队列(M3 演进) 先写本地 + 入队,后台补发 离线可用是硬需求时
乐观更新 UI 立即变更,失败回滚 点赞等高频弱一致操作

本工程采用服务端优先 + upsertKeepingFavorite 保护本地收藏位(第 23 章契约),M3 练习引导演进到队列方案。


二、示例代码(与 sample/ 逐行对应)

2.1 登录用例:唯一能触碰 Token 的地方

// 文件路径:sample/feature/auth/src/main/java/com/techfeed/feature/auth/domain/LoginUseCase.kt
class LoginUseCase @Inject constructor(
    private val api: TechFeedApi,
    private val tokenStorage: SecureTokenStorage,
) {
    sealed interface LoginResult {
        data object Success : LoginResult
        data class Failure(val error: AppError) : LoginResult
    }

    suspend operator fun invoke(username: String, password: String): LoginResult = try {
        // 凭据必须走请求体(@Body)。
        // ⚠️ 反例是 @Query —— URL 会进服务端 access log、代理与网关,
        //    密码等于明文广播(第 19 章安全红线)
        val response = api.login(LoginRequestDto(username, password))
        // 落盘是 Keystore+磁盘 IO:切到 IO 调度器,避免主线程 StrictMode 违规
        withContext(Dispatchers.IO) {
            tokenStorage.apply {
                accessToken = response.accessToken
                refreshToken = response.refreshToken
                userId = response.userId
            }
        }
        LoginResult.Success
    } catch (ce: kotlinx.coroutines.CancellationException) {
        throw ce   // 取消向上传播(第 11 章红线)
    } catch (e: retrofit2.HttpException) {
        LoginResult.Failure(AppError.Http(e.code(), e.message()))
    } catch (e: java.io.IOException) {
        LoginResult.Failure(AppError.Network(e))
    } catch (e: kotlinx.serialization.SerializationException) {
        // 响应体不是合法 JSON:转成业务失败而不是让协程崩溃
        LoginResult.Failure(AppError.Unknown(e))
    }
}
// 文件路径:sample/core/datastore/src/main/java/com/techfeed/core/datastore/secure/SecureTokenStorage.kt
// Token 加密存储:Keystore 主密钥 + AES-256(第 19 章;明文 SP 是评审一票否决项)
@Singleton
class SecureTokenStorage @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    // create 涉及 Keystore 初始化 + 磁盘建文件(首次可达数十毫秒),
    // 因此用双重检查懒加载:构造函数零 IO,首次访问才创建;
    // 调用方(LoginUseCase / TokenAuthenticator)负责在 IO 线程触发首次访问
    @Volatile
    private var cachedPrefs: SharedPreferences? = null
    private val createLock = Any()

    private fun prefs(): SharedPreferences = cachedPrefs ?: synchronized(createLock) {
        cachedPrefs ?: create(context).also { cachedPrefs = it }
    }

    @Suppress("DEPRECATION")   // 五参重载在 security-crypto 更新版本已标记 deprecated,
                                // 推荐三参重载 create(context, fileName, masterKey)(默认即 AES256_SIV/GCM);
                                // 教程沿用工程锁定版本的显式写法
    private fun create(context: Context): SharedPreferences = EncryptedSharedPreferences.create(
        context, "secure_token",
        MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
    )

    var accessToken: String?
        get() = prefs().getString(KEY_ACCESS, null)
        set(value) = prefs().edit().putString(KEY_ACCESS, value).apply()
    // …refreshToken / userId / clear() 同理
}

2.2 无感刷新:Interceptor 与 Authenticator 的配合

// 文件路径:sample/core/network/src/main/java/com/techfeed/core/network/TokenAuthenticator.kt
// ⭐ 本类是"无感刷新"的核心,三个设计点缺一不可:
//    ① Mutex + 双重检查:并发 401 只刷新一次;
//    ② 刷新走独立裸 OkHttpClient:刷新接口自己 401 时不会递归回本 Authenticator;
//    ③ priorResponse 链计数:限制重试次数,防死循环。
class TokenAuthenticator @Inject constructor(
    private val tokenProvider: TokenProvider,
    private val json: Json,
) : Authenticator {

    private val refreshMutex = Mutex()

    // 裸客户端:无鉴权拦截器、无 Authenticator,天然不会递归
    private val refreshClient = OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .readTimeout(20, TimeUnit.SECONDS)
        .build()

    override fun authenticate(route: Route?, response: Response): Request? {
        // 链路上重试超过 2 次仍 401:放弃,交给上层走登录流程
        if (responseCount(response) >= MAX_RETRY) return null

        return runBlocking {
            refreshMutex.withLock {
                val currentToken = tokenProvider.accessToken()
                    ?: return@runBlocking null   // 未登录:不刷,让上层处理
                // 双重检查:失败请求头里的 token 与当前 access token 比较
                val failedToken = response.request.header("Authorization")
                    ?.removePrefix("Bearer ")

                if (currentToken != failedToken) {
                    // 不一致 → 别的线程已刷新过,直接用新 token 重发,本线程不再刷新
                    return@runBlocking response.request.newBuilder()
                        .header("Authorization", "Bearer $currentToken")
                        .build()
                }

                // 一致 → 本线程负责刷新
                val refreshToken = tokenProvider.refreshToken()
                    ?: return@runBlocking null   // 没有 refresh token(未登录/已登出)
                val newTokens = runCatching { callRefreshApi(refreshToken) }
                    .getOrElse { return@runBlocking null }   // 刷新失败:放弃重试

                // 新 token 落盘(tokenProvider.refresh 内部写 SecureTokenStorage)
                tokenProvider.refresh(newTokens.accessToken, newTokens.refreshToken)
                response.request.newBuilder()
                    .header("Authorization", "Bearer ${newTokens.accessToken}")
                    .build()
            }
        }
    }

    /** 在裸客户端上同步调用刷新接口(Authenticator 本就运行在 OkHttp 的网络线程上) */
    private fun callRefreshApi(refreshToken: String): AuthResponseDto {
        val body = json.encodeToString(
            RefreshRequestDto.serializer(),
            RefreshRequestDto(refreshToken),
        ).toRequestBody("application/json".toMediaType())

        val request = Request.Builder()
            .url(NetworkConfig.BASE_URL + "v1/auth/refresh")   // POST v1/auth/refresh
            .post(body)
            .build()

        refreshClient.newCall(request).execute().use { resp ->
            check(resp.isSuccessful) { "refresh token failed: HTTP ${resp.code}" }
            return json.decodeFromString(AuthResponseDto.serializer(), resp.body.string())
        }
    }

    /** 沿 priorResponse 链统计本请求已被 Authenticator 重试过几次(经典写法) */
    private fun responseCount(response: Response): Int =
        generateSequence(response) { it.priorResponse }.count()

    private companion object {
        const val MAX_RETRY = 2
    }
}

[!IMPORTANT] 为什么"只加 Mutex"不够:Mutex 只能把并发 401 串行化,不能去重——3 个请求都拿着同一个旧 token 依次进锁,仍会刷 3 次。去重靠的是拿到锁之后的二次检查:比较"当前 access token"与"失败请求头里的 token",不一致说明前面的线程已经刷过,直接重发即可。只有两者一致(都还是旧 token)才真正调刷新接口。

// 文件路径:sample/app/src/main/java/com/techfeed/app/di/AppModule.kt
// 依赖倒置的装配点:core:network 定义接口,:app 绑定到加密存储实现(第 15 章)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideTokenProvider(storage: SecureTokenStorage): TokenProvider =
        object : TokenProvider {
            override suspend fun accessToken(): String? = storage.accessToken
            override suspend fun refreshToken(): String? = storage.refreshToken
            override suspend fun refresh(newAccess: String, newRefresh: String) {
                storage.accessToken = newAccess
                storage.refreshToken = newRefresh
            }
        }
}

2.3 登录页(Compose)

// 文件路径:sample/feature/auth/src/main/java/com/techfeed/feature/auth/ui/LoginViewModel.kt(submit 节选)
fun submit() {
    val state = _uiState.value
    if (state.username.isBlank() || state.password.isBlank()) {
        _uiState.update { it.copy(errorMessage = "请输入用户名与密码") }
        return
    }
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true) }
        when (val result = login(state.username, state.password)) {
            // ⭐ 成功分支也必须复位 isLoading —— 否则按钮永远消失、转圈不停
            is LoginUseCase.LoginResult.Success -> {
                _uiState.update { it.copy(isLoading = false) }
                _events.emit(LoginEvent.LoginSuccess)
            }
            is LoginUseCase.LoginResult.Failure ->
                _uiState.update { it.copy(isLoading = false, errorMessage = result.error.userMessage) }
        }
    }
}
// 文件路径:sample/feature/auth/src/main/java/com/techfeed/feature/auth/ui/LoginScreen.kt(节选)
@Composable
fun LoginScreen(onLoginSuccess: () -> Unit, viewModel: LoginViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        viewModel.events.collect { if (it is LoginEvent.LoginSuccess) onLoginSuccess() }
    }

    Column(modifier = Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) {
        OutlinedTextField(
            value = state.username,
            onValueChange = viewModel::onUsernameChange,
            label = { Text("用户名") },
            singleLine = true,
        )
        OutlinedTextField(
            value = state.password,
            onValueChange = viewModel::onPasswordChange,
            label = { Text("密码") },
            visualTransformation = PasswordVisualTransformation(),
        )
        state.errorMessage?.let {
            Text(it, color = MaterialTheme.colorScheme.error)
        }
        Button(onClick = viewModel::submit, enabled = !state.isLoading) { Text("登录") }
    }
}

2.4 鉴权并发测试

新实现下(Mutex + 双重检查),3 个并发 401 的行为是:第一个进锁的线程发现 token 未变、真正调用刷新接口;后两个进锁时发现"当前 token ≠ 失败请求头里的 token",命中双重检查直接重发。因此断言 refreshCount == 1 成立:

// 教学示例(未随 sample 工程落地,示意):鉴权并发测试——Mutex + 双重检查下 3 个并发 401 只触发一次刷新
@Test
fun `并发401 只触发一次刷新`() = runTest {
    var refreshCount = 0
    val provider = FakeTokenProvider(
        onAccess = { "old-token" },
        // refresh() 被调用时才换新 token,并计数
        onRefresh = { newAccess, _ ->
            refreshCount++
            it.accessToken = newAccess   // 模拟落盘:此后 onAccess 返回新 token
        },
    )
    val authenticator = TokenAuthenticator(provider, json)

    // 模拟 3 个并发 401 响应(请求头都带着同一个旧 token)
    val responses = (1..3).map { unauthorizedResponse(token = "old-token") }
    val requests = responses.map { authenticator.authenticate(null, it) }

    assertThat(requests).hasSize(3)
    // 第 1 个线程:双重检查发现 token 未变 → 真正调刷新接口(count = 1);
    // 第 2/3 个线程:此时 accessToken 已是"new-token",与各自失败请求头里的
    // "old-token" 不一致 → 命中双重检查直接重发,不再刷新
    assertThat(refreshCount).isEqualTo(1)
    // 且 3 个重发请求都带着新 token
    assertThat(requests.filter { it.header("Authorization") == "Bearer new-token" }).hasSize(3)
}

[!NOTE] 这条测试同时是双重检查的回归护栏:如果删掉"比较失败请求头 token"的判断(只保留 Mutex),refreshCount 会变成 3,测试立刻变红——这正是练习 4 要验证的现象。


三、常见坑 ⚠️

现象 根因 解法
刷新风暴:一次过期触发 N 次刷新 并发 401 各自刷新 Mutex + 刷新前二次检查 token 是否已被更新
登录后仍收到 401 Interceptor 缓存了旧 token / OkHttp 连接复用 Interceptor 每次实时读取;Authenticator 兜底
Token 明文可被 root 提取 存在普通 SharedPreferences EncryptedSharedPreferences + Keystore(第 19 章)
401 后卡死无提示 Authenticator 返回 null 后无人处理 全局监听 401:发"去登录"事件(EventBus/SharedFlow)
刷新接口自身 401 死循环 刷新请求也走了带鉴权的 client 刷新用独立无鉴权 OkHttp client 或跳过鉴权头
登出后数据残留 只清了 Token 没清业务数据 登出 = 清 Token + 清 Room 用户数据 + 取消 WorkManager
离线时点击收藏无反应 服务端优先策略下直接失败 明确失败提示;M3 演进为本地优先 + 待同步队列
密码出现在日志里 日志拦截器打了 BODY release 用 BASIC 级别;表单字段脱敏

四、调试技巧

# 1. 模拟 Token 过期:把加密存储里的 token 改成非法值后任意请求
#    (debug 包可用 chrome://inspect 或临时 debug 入口注入无效 token)

# 2. 观察刷新链路:OkHttp 日志过滤
adb logcat | grep -E "OkHttp|TokenAuthenticator"

# 3. 验证并发刷新:MockWebServer 返回连续 401,观察刷新请求次数
#    (对应 2.4 的测试用例,比手工点快得多)

# 4. 检查加密存储文件确实存在且非明文
adb shell run-as com.techfeed.app.debug ls files/  # EncryptedSharedPreferences 文件

MockWebServer 场景模板(第 09 章):

// 401 → 刷新成功 → 原请求重试成功的三段式脚本
server.enqueue(MockResponse().setResponseCode(401))
server.enqueue(MockResponse().setBody("""{"access_token":"new","refresh_token":"r2","user_id":"u1"}"""))
server.enqueue(MockResponse().setBody("""{"articles":[],"has_more":false}"""))

五、最佳实践

  1. Token 的读写收敛到一个类(SecureTokenStorage),其他模块只经 TokenProvider 接口访问。
  2. 登录状态是数据不是全局单例:需要登录态的模块订阅 TokenProvider/用户流,而不是到处 isLoggedIn 静态变量。
  3. 刷新失败必须"可感知":清凭据 + 通知 UI 去登录,绝不静默吞掉。
  4. 登出要清理三样:凭据、本地用户数据、后台任务(WorkManager 取消)。
  5. 离线同步分层演进:M2 服务端优先 → M3 本地优先 + 待同步队列,按业务需要升级而非一步到位。
  6. 鉴权链路必须有测试:并发 401、刷新失败、重试上限,这三个场景手测很难覆盖。

六、本章小结

  1. 鉴权分工:Interceptor 带凭据、Authenticator 救场;刷新用 Mutex+双重检查防并发重复、刷新走独立裸客户端防递归、用 priorResponse 计数防死循环。
  2. Token 存储必须加密(Keystore + AES),读写收敛在单一存储类,跨模块经接口倒置访问。
  3. 登录 = 校验 → 请求 → 加密落盘 → 事件通知跳转;全程 UiState/Event 双通道(第 14 章模式复用)。
  4. 离线一致性从"服务端优先 + 保护本地态"起步,演进方向是本地优先 + 待同步队列。
  5. 鉴权测试三场景:并发刷新、刷新失败、重试上限——用 MockWebServer 脚本化覆盖。

下一章:最后一战——推送通道、监控接入与全项目收尾验收。


七、动手练习

基础题

  1. 跑通登录流程:用 MockWebServer 模拟登录接口,登录成功后验证 SecureTokenStorage 中已有 Token。
  2. 给登录页加"密码可见性切换"图标,并把错误文案改为按错误类型区分(401/网络/未知)。
  3. 实现"退出登录":清空凭据 + 清空 Room 数据 + 取消后台同步任务。

进阶题 4. 完成 2.4 的并发刷新测试,并故意去掉 Mutex,观察测试如何抓出这个 bug。 5. 实现"刷新失败全局兜底":401 刷新失败后清凭据并发出全局事件,导航层收到后跳转登录页(提示:SharedFlow + Navigation 监听)。 6. 把收藏改造成"本地优先 + 待同步队列":离线收藏先落库并入队,WorkManager 恢复网络后补发。

思考题 7. Authenticator 返回 null 意味着什么?OkHttp 拿到 null 后会把原始 401 响应交给谁? 8. 为什么刷新接口必须绕过 AuthInterceptor?如果绕不开,代码上如何区分"登录请求/刷新请求/业务请求"? 9. "乐观更新收藏"在弱网下用户体验最好,但会带来什么一致性风险?什么业务绝对不能用乐观更新?

📄 第 26 章 实战:推送、监控与项目收尾 26-techfeed-push-and-hardening.md

阶段:实战 | 预计阅读 55 min | 代码落地模块::feature:push、:core:crash、:app 前置:第 25 章 鉴权与离线 | 第 13 章 后台任务与通知 相关章节:第 17 章 性能优化 | 第 18 章 稳定性监控 | 第 20 章 构建与发布

学习目标

  • [ ] 能落地"推送通道抽象 + 本地通知"方案,理解接入厂商通道时只改一处绑定。
  • [ ] 会把 WorkManager 后台同步、通知渠道、权限引导串成完整用户旅程。
  • [ ] 能完成项目收尾:监控接入、性能达标验证、发布检查清单执行。
  • [ ] 理解"收尾"的本质:把 M0-M3 的积累固化成可发布、可运维的形态。

一、核心概念

1.1 推送的用户旅程

flowchart LR
    A["首次进入消息中心"] --> B{"通知权限?"}
    B -->|"已授权"| F["订阅通道<br/>注册 token"]
    B -->|"未申请"| C["上下文解释<br/>为什么需要通知"]
    C --> D["系统弹窗申请"]
    D -->|"允许"| F
    D -->|"拒绝"| E["降级:应用内消息中心<br/>下次关键节点再引导"]
    F --> G["WorkManager 周期同步<br/>发现新文章"]
    G --> H["本地通知提醒<br/>点击深链到文章"]

国内环境没有 FCM,方案是通道抽象(第 13 章):业务只依赖 PushChannel 接口, 厂商通道(华为/小米/OPPO/vivo 等)与本地通知都是它的实现,切换只动 Hilt 绑定。

1.2 收尾 = 三个"固化"

固化对象 手段 对应章节
质量 → 可观测 崩溃/ANR 采集接入、告警阈值、灰度 18
体验 → 达标 启动/帧率/包体过基线,Macrobenchmark 回归 17
交付 → 可重复 签名、变体、CI 构建、发布检查清单 20

二、示例代码(与 sample/ 逐行对应)

2.1 推送通道抽象与本地实现

// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/PushChannel.kt
interface PushChannel {
    val id: String
    fun isSupported(context: Context): Boolean
    suspend fun register(context: Context): String?
    fun unregister(context: Context)
}
// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/di/PushModule.kt
// 接厂商通道时:新增 VendorPushChannel 实现,这里改一行绑定,业务零改动(第 13 章)
@Module
@InstallIn(SingletonComponent::class)
abstract class PushModule {
    @Binds
    @Singleton
    abstract fun bindPushChannel(impl: LocalPushChannel): PushChannel
}

2.2 通知:渠道、权限与深链

// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/NotificationHelper.kt
fun showMessageNotification(context: Context, title: String, body: String, articleId: String?) {
    // Android 13+ 发送前检查运行时权限,未授权静默跳过(引导在设置页做,第 08 章)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
        context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
        PackageManager.PERMISSION_GRANTED
    ) return

    // 点击通知深链到具体文章(techfeed://article/{id},由 MainActivity 的
    // intent-filter 接住并解析,返回栈完整,第 08 章);没有 articleId 时回首页
    val intent = if (articleId != null) {
        Intent(Intent.ACTION_VIEW, Uri.parse("techfeed://article/$articleId"))
            .setPackage(context.packageName)   // 显式指定宿主,避免被其他 App 拦截
    } else {
        context.packageManager.getLaunchIntentForPackage(context.packageName) ?: Intent()
    }
    val pending = PendingIntent.getActivity(
        context, REQUEST_CODE, intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,   // Android 12+ 强制
    )

    val notification = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MESSAGES)
        .setSmallIcon(android.R.drawable.ic_dialog_info)
        .setContentTitle(title)
        .setContentText(body)
        .setAutoCancel(true)
        .setContentIntent(pending)
        .build()

    NotificationManagerCompat.from(context).notify(articleId?.hashCode() ?: 1001, notification)
}

2.3 后台同步驱动新文章通知

// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/worker/SyncFeedWorker.kt
// ⚠️ Worker 只依赖 core 层(FeedRemoteDataSource + ArticleDao),不依赖 feature:feed ——
// "feature 之间零依赖"是模块化红线(第 15 章)。刷新写库逻辑与 FeedRepositoryImpl.refresh
// 同构,教学取舍:宁可小段重复,不破模块边界;逻辑变复杂时把"刷新用例"下沉到 core。
@HiltWorker
class SyncFeedWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val remote: FeedRemoteDataSource,
    private val dao: ArticleDao,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = when (val r = remote.getFeed(page = 1, query = null)) {
        is Result.Success -> {
            // 同步与前台刷新共用同一条"保留收藏"的写库路径(upsertKeepingFavorite)
            dao.upsertKeepingFavorite(r.data.map { it.toEntity() })
            // 真实工程:对比刷新前后的最新 id,发现新文章才发通知(避免每次同步都打扰)
            Result.success()
        }
        is Result.Failure -> if (runAttemptCount < MAX_RETRY) Result.retry() else Result.failure()
        is Result.Loading -> Result.retry()
    }

    companion object {
        const val WORK_NAME = "sync_feed"
        private const val MAX_RETRY = 3
    }
}
// 文件路径:sample/feature/push/src/main/java/com/techfeed/feature/push/SyncScheduler.kt
// 约束 + 退避 + 唯一任务名:后台任务三件套(第 13 章)
@Singleton
class SyncScheduler @Inject constructor() {
    fun schedulePeriodicSync(context: Context) {
        val request = PeriodicWorkRequestBuilder<SyncFeedWorker>(6, TimeUnit.HOURS)
            .setConstraints(
                Constraints.Builder()
                    .setRequiredNetworkType(NetworkType.CONNECTED)
                    .setRequiresBatteryNotLow(true)
                    .build()
            )
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
            .build()

        WorkManager.getInstance(context).enqueueUniquePeriodicWork(
            SyncFeedWorker.WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request
        )
    }
}

[!NOTE] 接线别忘了:SyncScheduler 要在 TechFeedApplication.onCreate 中调用(见 2.4),否则周期同步从未被调度——调度入口全工程零调用是收尾阶段最常见的"死代码"事故。

2.4 崩溃监控接入(第 18 章的落地)

// 文件路径:sample/app/src/main/java/com/techfeed/app/TechFeedApplication.kt
// 启动红线内只做"必须同步"的事:崩溃采集最先、通知渠道其次(第 17 章)
override fun onCreate() {
    super.onCreate()
    val crashReporter = CrashReporter.install(this)      // 兜底 UncaughtExceptionHandler 立即就位
    NotificationHelper.ensureChannels(this)              // 渠道创建是本地微秒级操作
    crashReporter.watchAnr()                              // ANR 观测(教学演示阈值)

    // 周期同步接线:KEEP 策略保证重复启动不会重建任务(第 13 章)
    syncScheduler.schedulePeriodicSync(this)

    if (BuildConfig.DEBUG) enableStrictMode()
}
// 文件路径:sample/core/crash/src/main/java/com/techfeed/core/crash/CrashReporter.kt
// 兜底处理器:崩溃写本地文件,下次启动补传(离线也不丢崩溃)
init {
    val previous = Thread.getDefaultUncaughtExceptionHandler()
    Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
        runCatching { CrashStore.save(app, thread, throwable, breadcrumbs.toList()) }
        previous?.uncaughtException(thread, throwable)
    }
}

2.5 发布检查清单(M4 验收物)

<!-- 文件路径:docs/samples/ch26/release-checklist.md(示意) -->
## 发版检查清单

- [ ] versionCode 递增、versionName 符合语义化版本(第 20 章)
- [ ] release 构建通过:R8 + 资源压缩 + 无 keep 报错
- [ ] mapping.txt 已归档并上传符号表(第 18 章)
- [ ] 混淆包冒烟:登录、信息流、详情、收藏、通知 五链路手测
- [ ] 网络安全配置:无明文流量、证书绑定生效(第 19 章)
- [ ] 日志级别 release 为 BASIC,无 Token/手机号输出
- [ ] 权限最小化复核:申请的每个权限都有对应功能
- [ ] 崩溃监控看板可用,告警阈值已配置
- [ ] 包体积与上版对比,涨幅 >2% 已说明原因(第 17 章预算)
- [ ] 灰度计划:1% → 10% → 50% → 全量,观察崩溃率与 ANR 率

三、常见坑 ⚠️

现象 根因 解法
通知不显示 未建渠道 / 未授权 / 小图标缺失 ensureChannels 在 Application 调用;发前检查权限
厂商通道收不到推送 各厂商有自启动/省电白名单 引导用户加白名单;通道抽象后按设备选实现
后台同步从不执行 约束过强(如要求充电)或电池优化拦截 约束最小化;引导关闭电池优化要谨慎合规
每次同步都弹通知 没做"新旧对比" 记录上次最新文章 id,只有增量才通知
崩溃了但平台没收到 采集初始化太晚 / 网络未恢复 Application 最先安装;崩溃落盘 + 下次启动补传
混淆后崩溃堆栈不可读 mapping.txt 未归档 CI 构建自动上传符号表(第 18/20 章)
灰度阶段崩溃率飙升但无法回滚 没有功能开关 关键新功能挂开关,灰度异常可秒级关闭
收尾时才发现性能不达标 指标验证留到最后 M1 起每个里程碑跑 Macrobenchmark 基线(第 17 章)

四、调试技巧

# 1. 手动触发一次后台同步(不等 6 小时周期)
adb shell am force-stop com.techfeed.app.debug
# 重新打开 App 后用 Background Task Inspector 观察任务执行

# 2. 查看通知是否被系统拦截
adb shell dumpsys notification --noredact | grep -A 10 techfeed

# 3. 验证崩溃落盘与补传
adb shell am crash com.techfeed.app.debug       # 制造一次崩溃
# 重新打开 App,检查 files/crash/ 下的报告文件

# 4. 验证发布包的混淆效果
apkanalyzer dex packages app-prod-store-release.apk | head -20

灰度观测:监控平台按版本筛选崩溃率曲线;灰度阈值建议——崩溃率环比上涨 >50% 或绝对值 >0.5% 时暂停放量(第 18 章)。


五、最佳实践

  1. 通知是打扰艺术:有增量才通知、可深链到内容、可在设置里关——三件事缺一不可。
  2. 通道抽象先于厂商接入:先让业务依赖接口,接厂商 SDK 时才是"插拔"而不是"重构"。
  3. 崩溃采集零依赖启动顺序:它必须是 Application.onCreate 的第一行。
  4. 发版靠清单不靠记忆:检查清单进仓库、进 CI,人与流程双保险。
  5. 灰度是最后防线:功能开关 + 分阶段放量 + 明确的回滚阈值。
  6. 收尾不是结束:M4 交付后,监控数据会驱动下一轮迭代的优先级。

六、本章小结

  1. 推送方案 = 通道抽象(接口 + Hilt 绑定切换)+ 权限引导(上下文解释)+ 增量通知(新旧对比)。
  2. 后台同步三件套:约束(网络/电量)、退避(指数)、唯一任务名(防重复)。
  3. 监控落地的关键顺序:崩溃采集最先安装 → 落盘补传 → 符号表归档 → 看板与告警。
  4. 收尾三固化:质量可观测、体验可度量、交付可重复——分别对应 18、17、20 三章的产物。
  5. 发布检查清单是 M4 的核心验收物,灰度与回滚机制是最后的防线。

下一章:附录——术语表、API 速查与常见报错速查,作为你后续开发中的案头手册。


七、动手练习

基础题

  1. 跑通通知链路:申请权限 → 手动触发 showMessageNotification → 点击通知回到 App。
  2. 给 SyncFeedWorker 增加"新文章才通知"逻辑:刷新前记录最新 id,刷新后对比,写单元测试覆盖"无新文章不发通知"。
  3. 用 adb shell am crash 制造崩溃,验证 CrashStore 落盘文件内容完整(含 breadcrumb 与堆栈)。

进阶题 4. 实现 VendorPushChannel 的空壳(isSupported 按 Build.MANUFACTURER 判断),并把 Hilt 绑定改为"运行时按设备选择实现"(提示:@IntoSet 或工厂)。 5. 完成 release-checklist.md 的真实执行:打一个 prodStoreRelease 包,逐项勾选并记录证据(截图/命令输出)。 6. 为项目配置灰度开关:一个远程布尔配置控制"新详情页",异常时可关闭(可Mock 配置接口)。

思考题 7. "每次同步都弹通知"为什么是产品灾难?从打扰成本与通知渠道降权两个角度分析。 8. 崩溃落盘 + 下次启动补传的方案,在"启动即崩溃"(秒崩)场景下会失效吗?如何兜底?(提示:安全模式/禁用模块) 9. 如果让你为 TechFeed 制定"下一版本规划",你会基于哪些数据排优先级?列出数据来源与决策规则。

📄 第 27 章 附录:术语表、API 速查与常见报错速查 27-appendix-glossary-and-faq.md

阶段:附录 | 用法:随查随用,不必顺序阅读 相关章节:全书各章

一、术语表

术语 英文 一句话解释 详见
应用组件 App Component 系统可进入的应用入口:Activity/Service/BroadcastReceiver/ContentProvider 03
生命周期 Lifecycle 组件从创建到销毁的状态序列,资源释放的依据 03
配置变更 Configuration Change 旋转/深色/语言切换导致 Activity 重建 03
任务栈 Back Stack / Task Activity 的后进先出栈,决定返回行为 03
深链 Deep Link 通过 URI 直接打开 App 内特定页面 08
应用链接 App Links 经域名验证的可信深链(https) 08
运行时权限 Runtime Permission 危险权限需在运行时弹窗申请 08
挂起函数 Suspend Function 可暂停/恢复的函数,不阻塞线程 01/11
结构化并发 Structured Concurrency 协程随作用域取消,不泄漏 11
冷流/热流 Cold/Hot Flow 冷流订阅才生产;热流(StateFlow/SharedFlow)始终存在 11
单一数据源 Single Source of Truth UI 只信任一个数据源(通常 Room) 10/25
依赖注入 Dependency Injection, DI 依赖由外部提供而非自建,便于替换与测试 12
单向数据流 Unidirectional Data Flow, UDF 状态下行、事件上行的架构约束 14
重组 Recomposition Compose 重新执行读取了变化状态的函数 05
状态提升 State Hoisting 状态上移使组件无状态、可复用 05
稳定性 Stability Compose 判断参数是否可跳过比较的属性 17
离线优先 Offline First 本地缓存为主,网络为更新来源 10/25
差量更新 Diff 只通知列表最小变更(DiffUtil/状态驱动) 06
版本目录 Version Catalog libs.versions.toml 统一管理依赖版本 02
约定插件 Convention Plugin build-logic 中统一模块配置的自定义插件 02/22
依赖倒置 Dependency Inversion 高层与低层都依赖抽象(如 TokenProvider) 15
混淆 Minify/R8 压缩、优化、混淆字节码;需 keep 规则 19
符号表 mapping.txt 混淆前后类名映射,还原崩溃堆栈 18
灰度 Staged Rollout 分阶段放量发布,控制风险 18/20
ANR Application Not Responding 主线程阻塞超阈值,系统弹"无响应" 17
测试金字塔 Test Pyramid 单元多、集成中、UI 少的成本结构 21
ADR Architecture Decision Record 架构决策记录:背景/决策/后果 22

二、Jetpack / 常用库 API 速查

生命周期与 ViewModel

// 文件路径:docs/samples/ch27/ApiCheatSheet.kt
// ViewModel 获取
val vm: FeedViewModel by viewModels()                       // Activity/Fragment
val vm: FeedViewModel = hiltViewModel()                     // Compose
val vm: FeedViewModel by activityViewModels()               // Fragment 共享宿主

// 状态收集(UI 层标准写法)
lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { flow.collect { } } }  // View
val state by viewModel.uiState.collectAsStateWithLifecycle()                                 // Compose

// Flow 转状态(ViewModel 层)
val uiState: StateFlow<X> = flow.stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial)

// SavedStateHandle 读写
savedStateHandle["key"] = value
val flow = savedStateHandle.getStateFlow("key", default)

协程与 Flow

// 文件路径:docs/samples/ch27/ApiCheatSheet2.kt
viewModelScope.launch { }                          // UI 事件
withContext(Dispatchers.IO) { }                    // 切线程
suspendCoroutine / suspendCancellableCoroutine      // 回调转协程
flowOn(Dispatchers.io)                             // 上游切线程
debounce(300); distinctUntilChanged(); combine(f1, f2) { a, b -> }
callbackFlow { trySend(v); awaitClose { unregister() } }
runTest { advanceUntilIdle(); advanceTimeBy(300) }  // 测试
Turbine: flow.test { awaitItem(); cancelAndIgnoreRemainingEvents() }

网络与存储

// Retrofit
@GET("v1/x") suspend fun get(...): Dto              // suspend 接口
OkHttp: addInterceptor(...) / authenticator(...) / connectTimeout(10, SECONDS)

// Room
@Dao fun observe(): Flow<List<T>>                    // 响应式查询
@Upsert / @Transaction / @Query("UPDATE ... WHERE ...")
Room.databaseBuilder(ctx, Db::class.java, "x.db").addMigrations(MIGRATION_1_2).build()

// DataStore
val Context.ds by preferencesDataStore("name")
context.ds.data.map { it[key] } / context.ds.edit { it[key] = v }

导航与 UI

// Navigation-Compose(类型安全路由)
navController.navigate(DetailRoute(id)) { launchSingleTop = true }
composable<DetailRoute> { e -> val r = e.toRoute<DetailRoute>() }
popUpTo(start) { saveState = true }; restoreState = true

// Compose 常用
remember { mutableStateOf(v) } / rememberSaveable { }
LaunchedEffect(key) { } / DisposableEffect(key) { onDispose { } }
derivedStateOf { } / rememberLazyListState() / LazyColumn(items = ..., key = { it.id })

// View 体系
ViewBinding / repeatOnLifecycle / ListAdapter + DiffUtil / ConstraintLayout

三、常见报错速查

报错(关键词) 根因 解法 详见
Unresolved reference: libs 版本目录未启用或路径不对 确认 gradle/libs.versions.toml 位置;Gradle 8+ 默认支持 02
Version 'x' wasn't found TOML 引用未定义的 version 检查 [versions] 的 key 拼写 02
Unsupported class file major version JDK 版本过低 Gradle JDK 切到 17 00
SDK location not found 无 ANDROID_HOME/local.properties 写 sdk.dir 或配置环境变量 00
Duplicate class 传递依赖冲突 dependencyInsight 定位,exclude 或统一 BOM 02
Manifest merger failed Manifest 冲突 看 merger 报告,用 tools:replace 03
requires that an explicit value for android:exported Android 12+ 强制 带 intent-filter 的组件显式声明 exported 03
ActivityNotFoundException 隐式 Intent 无接收者 resolveActivity 判空 + <queries> 声明 08
lateinit property has not been initialized 未初始化即访问 Hilt 注入;Fragment binding 判空模式 01/03
Can't create handler inside thread that has not called Looper.prepare() 子线程操作 UI/Handler 切回主线程 11
CancellationException 被吞 catch (e: Exception) 拦截 单独 catch 并 rethrow 11
TransactionTooLargeException Bundle 超 ~1MB Intent 只传 id 08
ForegroundServiceStartNotAllowedException 后台启动前台服务受限 WorkManager 加急任务替代 13
Room cannot verify the data integrity schema 变更无迁移 写 Migration;禁用 fallbackToDestructive 10
Not sure how to convert a Cursor to this method's return type DAO 返回类型不匹配 检查 Flow/List/单对象声明 10
Hilt 找不到 @HiltAndroidApp 插件/编译器缺失 apply hilt 插件 + ksp(hilt-compiler) 12
Compose 界面不刷新 状态未用 mutableStateOf 检查状态包装与读取位置 05
@Composable invocations can only happen... 在非组合处调用组件 移入 Composable 上下文或副效应 API 05
列表项状态串位 无稳定 key key = { it.id } / DiffUtil 06
混淆后崩溃 ClassNotFoundException keep 缺失 按反射/序列化依赖补最小 keep 19
崩溃堆栈是 a.b.c 乱码 未还原符号表 上传 mapping.txt 到监控平台 18
INSTALL_FAILED_VERSION_DOWNGRADE 设备已有更高版本 adb uninstall <pkg> 00
模拟器黑屏/卡开机 硬件加速问题 Graphics 改 Software;开 VT-x/SVM 00
KSP version is not compatible KSP 与 Kotlin 不同代 两者版本同步升级 02

四、调试命令速查

# 文件路径:docs/samples/ch27/adb-cheatsheet.sh
# 设备与安装
adb devices                                    # 设备列表
adb install -r app-debug.apk                   # 覆盖安装
adb shell pm clear <pkg>                       # 清数据
adb shell am force-stop <pkg>                  # 杀进程

# 启动与调试
adb shell am start -W <pkg>/<Activity>         # 冷启动计时
adb shell am start -a android.intent.action.VIEW -d "techfeed://article/1"
adb shell am crash <pkg>                       # 制造崩溃(测试采集)
adb shell am kill <pkg>                        # 模拟系统杀进程

# 日志
adb logcat -s <TAG>                            # 按 tag 过滤
adb logcat -v time | grep AndroidRuntime       # 崩溃堆栈

# 权限
adb shell pm grant <pkg> android.permission.POST_NOTIFICATIONS
adb shell dumpsys package <pkg> | grep permission

# 系统状态
adb shell dumpsys activity activities | head -40   # 任务栈
adb shell dumpsys notification                     # 通知状态
adb shell cmd uimode night yes|no                  # 深色模式
adb shell settings put global always_finish_activities 1   # 不保留活动
adb shell svc wifi disable && adb shell svc data disable   # 模拟离线

五、延伸阅读(官方文档索引)

主题 入口
Android 开发者官网 developer.android.com(中文:developer.android.google.cn)
Kotlin 官方文档 kotlinlang.org/docs
Jetpack 指南 developer.android.com/jetpack
Compose 路线图与示例 developer.android.com/develop/ui/compose
官方示例工程 Now in Android github.com/android/nowinandroid(本书约定插件架构的参照)
性能最佳实践 developer.android.com/topic/performance
Material 3 设计规范 m3.material.io
Kotlin 编码规范 kotlinlang.org/docs/coding-conventions.html

[!TIP] 遇到库的版本/API 疑问,优先查官方文档与 Now in Android 示例,其次看库的 CHANGELOG。 博客文章(包括本书)可能滞后于工具链演进。


六、全书记忆卡

  1. 架构:UI 订阅状态、事件向上、数据向下;Room 是唯一事实源;feature 之间零依赖。
  2. 异步:一切挂起、一切可取消、CancellationException 永不吞。
  3. 列表:稳定 key、最小参数、contentType。
  4. 稳定:崩溃采集最先启动、主线程红线、StrictMode 常开。
  5. 交付:版本收口 TOML、CI 卡口、灰度发布、检查清单。

学完这本书,你已经走完"从面向对象基础到企业级交付"的完整路径。 接下来最好的学习材料,是你自己的第一个真实项目。祝顺利。🚀

📖 Android 开发教程 · 章节目录 README.md

本目录存放《系统化 Android 开发教程:从入门到企业级》的正文分章文件。 总目录、学习路线与技术栈基线见上级目录的 ../README.md。


阶段 0 · 起步

文件 标题 关键词
00-environment-setup.md 开发环境搭建 Android Studio、SDK、AVD、adb、真机调试、Profiler
01-kotlin-crash-course.md Kotlin 速成与协程预热 空安全、data class、扩展、密封类、协程

阶段 1 · 入门

文件 标题 关键词
02-project-structure-and-gradle.md 工程结构与 Gradle 目录树、KTS、版本目录、构建流程
03-app-components-and-lifecycle.md 应用组件与生命周期 Activity、Fragment、Service、BroadcastReceiver、Manifest
04-ui-view-xml-basics.md UI 基础:View 与 XML 布局、ConstraintLayout、ViewBinding、事件
05-ui-compose-basics.md UI 基础:Jetpack Compose 可组合函数、状态提升、重组、Material3
06-lists-and-navigation.md 列表与导航 RecyclerView ↔ LazyColumn、Navigation 双体系
07-resources-theming-adaptive.md 资源、主题与自适应 资源限定符、深色模式、多语言、窗口尺寸类
08-intent-ipc-and-permissions.md Intent、IPC 与权限 显式/隐式 Intent、运行时权限、Binder/AIDL 概览

阶段 2 · 进阶

文件 标题 关键词
09-networking-retrofit-okhttp.md 网络请求 OkHttp 拦截器、Retrofit、序列化、缓存、Mock
10-persistence-room-datastore.md 数据存储 Room/DAO/Migration、DataStore、文件与加密存储
11-coroutines-and-flow.md 异步处理:协程与 Flow 结构化并发、StateFlow、异常处理、测试调度器
12-dependency-injection-hilt.md 依赖注入 Hilt 作用域、模块、限定符、可测试性
13-background-work-notifications.md 后台任务与通知 WorkManager、通知渠道、推送通道抽象

阶段 3 · 企业级

文件 标题 关键词
14-architecture-layering-mvi.md 分层架构与 MVI UI/Domain/Data、单向数据流、UiState、UseCase
15-modularization.md 模块化与组件化拆分 多模块、依赖倒置、路由、API/Impl 分离
16-compose-view-interop.md Compose 与 View 互操作 渐进式迁移、ComposeView、AndroidView
17-performance-optimization.md 性能优化 启动、渲染、内存、包体、ANR、基准测试
18-stability-crash-monitoring.md 稳定性与崩溃监控 崩溃/ANR 采集、符号表、平台接入、灰度
19-security-hardening.md 安全加固 R8、Keystore、网络安全配置、防逆向
20-build-variants-release.md 构建配置与发布 变体/风味、签名、CI/CD、AAB、上架

阶段 4 · 团队协作与测试

文件 标题 关键词
21-testing-strategy.md 测试策略 测试金字塔、单元测试、Flow 测试、UI 测试
22-team-collaboration.md 团队协作规范 分支模型、Code Review、ktlint/detekt、ADR

阶段 5 · 实战项目 TechFeed

文件 标题 关键词
23-techfeed-overview.md TechFeed 总览 需求拆解、模块图、里程碑、验收标准
24-techfeed-feed-and-detail.md 信息流与详情 列表、分页、详情、双 UI 实现对照
25-techfeed-auth-offline.md 登录鉴权与离线缓存 Token 刷新、单一数据源、离线同步
26-techfeed-push-and-hardening.md 推送、监控与收尾 通道抽象、监控接入、安全与性能收尾

附录

文件 标题
27-appendix-glossary-and-faq.md 术语表、API 速查、常见报错速查、延伸阅读

每章结构(8 段式)

学习目标 → 核心概念 → 示例代码 → 常见坑 ⚠️ → 调试技巧 → 最佳实践 → 本章小结 → 动手练习

排版与代码规范

  • 术语首次出现写作「中文(English, 缩写)」,例如「依赖注入(Dependency Injection, DI)」。
  • 中英文之间加空格;类名、API、文件路径一律用反引号包起来。
  • 每个代码块首行注释标明文件路径,例如 // sample/feature/feed/.../FeedViewModel.kt。
  • 关键代码行附中文注释,注释解释为什么这么写,不复述语法。
  • 风险点统一前缀 ⚠️,提示用 > [!TIP],警告用 > [!WARNING]。
  • Java / 旧 API(如 LiveData、AsyncTask)对照只在易混处出现,用可折叠的 <details> 块承载,不占主线篇幅。

关于双 UI 体系

第 04–07 章、第 16 章、第 24 章会同时给出 Compose 与 View/XML 两套实现。 阅读建议:新项目以 Compose 为主,View 部分按需查阅;维护存量项目则反过来。

版本基线

一切版本号以工程内的 sample/gradle/libs.versions.toml 为准,本目录正文不锁死小版本。

📄 release-checklist release-checklist.md

发版检查清单

  • [ ] versionCode 递增、versionName 符合语义化版本(第 20 章)
  • [ ] release 构建通过:R8 + 资源压缩 + 无 keep 报错
  • [ ] mapping.txt 已归档并上传符号表(第 18 章)
  • [ ] 混淆包冒烟:登录、信息流、详情、收藏、通知 五链路手测
  • [ ] 网络安全配置:无明文流量、证书绑定生效(第 19 章)
  • [ ] 日志级别 release 为 BASIC,无 Token/手机号输出
  • [ ] 权限最小化复核:申请的每个权限都有对应功能
  • [ ] 崩溃监控看板可用,告警阈值已配置
  • [ ] 包体积与上版对比,涨幅 >2% 已说明原因(第 17 章预算)
  • [ ] 灰度计划:1% → 10% → 50% → 全量,观察崩溃率与 ANR 率

📄 ADR 0001:采用多模块架构与依赖倒置 0001-use-modular-architecture.md

  • 状态:已接受(Accepted)
  • 日期:2026-09-29
  • 决策人:TechFeed Android 团队

背景(Context)

TechFeed 初期为单体模块工程:编译时间随代码量线性增长、团队并行开发频繁冲突、 core 与业务代码互相耦合导致复用困难。同时存在双 UI 体系(Compose + View)并行的迁移需求, 需要清晰的边界防止"新代码混进旧页面"。

决策(Decision)

  1. 采用 core + feature 双层模块划分:core:* 提供能力(网络/存储/设计系统等), feature:* 承载业务(信息流/详情/登录/收藏/推送)。
  2. feature 之间零依赖,跨 feature 跳转通过 core:navigation-api 定义的类型安全路由实现, :app 作为唯一聚合点与路由实现方(依赖倒置)。
  3. 每个模块内部统一 ui / domain / data / di 四段分层,Repository 接口定义在 feature 的 domain 层。
  4. 双 UI 体系并存:新页面 Compose 优先,存量 View 页面按第 16 章策略渐进迁移。

备选方案(Options)

  • 按层拆分(data / domain / ui 三个模块):粒度太粗,feature 依旧耦合。
  • 全单体 + 包结构约束:零成本但边界只靠自觉,编译速度问题无解。
  • 动态插件化:解决"按需加载",但复杂度高、平台兼容成本大,当前业务体量不需要。

后果(Consequences)

  • 正面:PR 影响面清晰、并行开发冲突减少、implementation 依赖让增量编译显著加速、 feature 模块可独立出团队复用。
  • 负面:模块数量增加带来的配置成本(已用约定插件 build-logic 收口); 跨 feature 通信必须走路由,轻微增加理解成本。
  • 缓解:新模块创建脚本 + CODEOWNERS 明确所有权;禁止 feature 间依赖由 CI 检查兜底。

📖 TechFeed 示例工程 README.md

《系统化 Android 开发教程:从入门到企业级》的配套源码——一个内容社区 App 的完整多模块实现。 教程正文见 ../README.md,第 23–26 章的代码清单与本工程文件一一对应。

工程结构

flowchart TB
    APP[":app<br/>导航壳 + DI 装配"]
    subgraph Core["core 能力层"]
        M[":core:model"] C[":core:common"] DS[":core:designsystem"]
        N[":core:network"] DB[":core:database"] STORE[":core:datastore"]
        NAV[":core:navigation-api"] CRASH[":core:crash"] TEST[":core:testing"]
    end
    subgraph Feature["feature 业务层(彼此零依赖)"]
        F1[":feature:feed"] F2[":feature:detail"] F3[":feature:auth"]
        F4[":feature:profile"] F5[":feature:push"]
    end
    APP --> Feature
    Feature --> Core
    APP --> Core
模块 职责 教程章节
:app 单 Activity 宿主、导航实现、DI 装配、深链分发 03 / 06 / 08 / 15
:core:model 领域模型 + Result/AppError 密封类(纯 Kotlin) 01 / 14
:core:common Dispatcher 抽象、扩展函数 11
:core:designsystem 主题令牌(Compose + View 双体系) 05 / 07
:core:network Retrofit/OkHttp、鉴权拦截器、Token 刷新 09 / 25
:core:database Room:缓存 + 收藏(单一数据源) 10
:core:datastore DataStore 偏好 + 加密 Token 存储 10 / 19
:core:navigation-api 类型安全路由(feature 解耦关键) 15
:core:crash 崩溃/ANR 采集门面 18
:core:testing MainDispatcherRule、Fake 基建 21
:feature:feed 信息流:列表/搜索/刷新/收藏 24
:feature:detail 详情 + 收藏切换 24
:feature:auth 登录鉴权、登出 25
:feature:profile 收藏列表 25
:feature:push 推送通道抽象、WorkManager 同步、通知 13 / 26
:benchmark Macrobenchmark 性能基线 17
build-logic 约定插件(SDK 版本/Compose/Hilt/静态检查收口) 02 / 22

首次导入检查清单(务必先看)

本工程经过一轮全量代码审查修复(2026-09),已补齐 Gradle Wrapper(9.1.0)并修掉全部 编译级问题;但教程编写环境仍未做真机/模拟器运行验证,首次同步请按以下清单排查。

  1. JDK 17:Settings → Build Tools → Gradle → Gradle JDK 选择 17(或 Studio 内置 jbr-17)。
  2. Android SDK:安装 platforms;android-37 与 build-tools;37(SDK Manager 勾选)。 若 AGP 版本与 SDK 组合报错,优先按 Android Studio 的 AGP Upgrade Assistant 提示升级, 并同步修改 gradle/libs.versions.toml。
  3. Gradle Wrapper:已包含 gradlew(Gradle 9.1.0,AGP 9.0 的最低要求版本); 首次运行会自动下载发行版,超时请配置镜像或代理。
  4. KSP 与 Kotlin 版本匹配:ksp = "2.4.0-1.0.0" 必须与 kotlin = "2.4.0" 同代, 不匹配的报错形如 KSP version is not compatible。
  5. Room schema:core/database/schemas/ 目录由 KSP 生成,首次构建后提交入库 (注意:schemas/ 已从 .gitignore 移除,不要重新加回去)。
  6. 签名:release 暂用 debug 签名(演示用);正式发布见教程第 20 章。
  7. 模块缺失报错:确认 settings.gradle.kts 的 16 个模块全部保留,且目录名一致。

[!TIP] 首次构建建议执行 ./gradlew assembleProdStoreDebug。 工程预置了 dev/prod × store/direct 四个变体(教程第 20 章),默认命令行构建请带上维度: assembleDevStoreDebug 或在 Run Configuration 中选择对应变体。

与教程的对应关系

  • 第 01 章 Article/Result/AppError → core/model
  • 第 03 章 ViewModel/UiState/Event 模式 → feature/*/ui/*ViewModel.kt
  • 第 09 章 拦截器链与 safeCall → core/network
  • 第 10 章 Room/DataStore/加密存储 → core/database、core/datastore
  • 第 11 章 Flow/stateIn/WhileSubscribed → feature/profile/ui/FavoriteViewModel.kt
  • 第 12 章 Hilt 装配与 @Binds → 各模块 di/
  • 第 14 章 UiState/事件分离 → feature/feed/ui/FeedViewModel.kt
  • 第 15 章 路由与依赖倒置 → core/navigation-api、app/navigation
  • 第 17 章 StrictMode/启动红线 → TechFeedApplication.kt、benchmark/
  • 第 19 章 混淆与加密 → app/proguard-rules.pro、SecureTokenStorage
  • 第 20 章 变体与签名 → app/build.gradle.kts
  • 第 21 章 测试基建 → core/testing、各模块 src/test
  • 第 22 章 协作规范 → .editorconfig、detekt/、.github/、docs/adr/

License

仅用于教程学习。禁止将示例代码中的占位域名/密钥用于生产环境。

📄 pull_request_template pull_request_template.md

变更说明

变更类型

  • [ ] 新功能(feat)
  • [ ] 缺陷修复(fix)
  • [ ] 重构(refactor,不改行为)
  • [ ] 文档/注释(docs)
  • [ ] 构建/工具链(build)
  • [ ] 性能优化(perf)

自查清单

  • [ ] 单元测试已覆盖新逻辑(或说明了为什么不需要)
  • [ ] ./gradlew ktlintCheck detekt 本地通过
  • [ ] UI 变更附截图/录屏(含深色模式)
  • [ ] 涉及公开 API 的改动已更新文档注释
  • [ ] 新增依赖已说明体积与必要性(第 17 章体积预算)
  • [ ] 不包含调试代码、敏感信息(Token/密钥/内网地址)

风险与影响面

验证方式

📁 配套文件目录

android-doc/
  README.md
  REVIEW-2026-09-29.md
  REVIEW-FIXES-2026-09-29.md
  docs/
    00-environment-setup.md
    01-kotlin-crash-course.md
    02-project-structure-and-gradle.md
    03-app-components-and-lifecycle.md
    04-ui-view-xml-basics.md
    05-ui-compose-basics.md
    06-lists-and-navigation.md
    07-resources-theming-adaptive.md
    08-intent-ipc-and-permissions.md
    09-networking-retrofit-okhttp.md
    10-persistence-room-datastore.md
    11-coroutines-and-flow.md
    12-dependency-injection-hilt.md
    13-background-work-notifications.md
    14-architecture-layering-mvi.md
    15-modularization.md
    16-compose-view-interop.md
    17-performance-optimization.md
    18-stability-crash-monitoring.md
    19-security-hardening.md
    20-build-variants-release.md
    21-testing-strategy.md
    22-team-collaboration.md
    23-techfeed-overview.md
    24-techfeed-feed-and-detail.md
    25-techfeed-auth-offline.md
    26-techfeed-push-and-hardening.md
    27-appendix-glossary-and-faq.md
    README.md
    samples/
  sample/
    .editorconfig
    .gitignore
    README.md
    build.gradle.kts
    gradle.properties
    gradlew
    gradlew.bat
    settings.gradle.kts
    .github/
      CODEOWNERS
      pull_request_template.md
    app/
      build.gradle.kts
      proguard-rules.pro
    benchmark/
      build.gradle.kts
    build-logic/
      settings.gradle.kts
    core/
    detekt/
      detekt.yml
    docs/
    feature/
    gradle/
      libs.versions.toml

完整源码 Gitee 仓库浏览 →