一份面向「有面向对象编程基础、但没有 Android 经验」读者的完整学习路径。 主线 Kotlin,Compose 与 View/XML 两套 UI 体系并列讲解; 全书以一个贯穿始终的企业级内容社区 App「TechFeed」为实战载体, 并配套一份可导入 Android Studio 的多模块工程源码(
sample/)。
[!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
建议节奏:
sample/ 阅读。分章索引的另一个入口:
docs/README.md;配套源码:sample/README.md。
| 章节 | 标题 | 关键词 |
|---|---|---|
| 00 | 开发环境搭建 | Android Studio、SDK、AVD、adb、真机调试、Profiler |
| 01 | Kotlin 速成与协程预热 | 空安全、data class、扩展、密封类、协程 |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 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 概览 |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 09 | 网络请求 | OkHttp 拦截器、Retrofit、序列化、缓存、Mock |
| 10 | 数据存储 | Room/DAO/Migration、DataStore、文件与加密存储 |
| 11 | 异步处理:协程与 Flow | 结构化并发、StateFlow、异常处理、测试调度器 |
| 12 | 依赖注入 | Hilt 作用域、模块、限定符、可测试性 |
| 13 | 后台任务与通知 | WorkManager、通知渠道、推送通道抽象 |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 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、上架 |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 21 | 测试策略 | 测试金字塔、单元测试、Flow 测试、UI 测试 |
| 22 | 团队协作规范 | 分支模型、Code Review、ktlint/detekt、ADR |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 23 | TechFeed 总览 | 需求拆解、模块图、里程碑、验收标准 |
| 24 | 信息流与详情 | 列表、分页、详情、双 UI 实现对照 |
| 25 | 登录鉴权与离线缓存 | Token 刷新、单一数据源、离线同步 |
| 26 | 推送、监控与收尾 | 通道抽象、监控接入、安全与性能收尾 |
| 章节 | 标题 |
|---|---|
| 27 | 术语表、API 速查、常见报错速查、延伸阅读 |
一个内容社区 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 章的代码清单与工程文件逐行对应。
学习目标 → 核心概念 → 示例代码 → 常见坑 ⚠️ → 调试技巧 → 最佳实践 → 本章小结 → 动手练习
// sample/feature/feed/.../FeedViewModel.kt。> [!TIP],警告用 > [!WARNING]。<details> 块承载,不占主线篇幅。第 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/ 源码对照思考题一起消化。
祝你写得开心。🚀
审查日期: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 清单上。
| # | 位置 | 问题 | 修法 |
|---|---|---|---|
| 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 约定插件中开启 |
| # | 位置 | 问题 | 修法 |
|---|---|---|---|
| 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 处即沉淀的证据。
core/network/TokenAuthenticator.kt:28-39):Mutex 内只读回旧 token 重发,从未调用刷新接口(TokenProvider.refreshToken() 全工程零调用,是死 API)。实际行为 = 重试一次必然再失败。TechFeedApi.kt:30-31):@POST + @Query 把用户名密码放进 URL,会进服务端日志/代理/自家 BODY 级日志。对第 19 章讲安全的教程是反例。应改 @Body。NetworkModule.kt:40-46):缺 .redactHeader("Authorization")。FeedScreen.kt:51-53 + FeedViewModel):UI 判断 state.isLoading,但 VM 只设置 isRefreshing(且无人消费,无 PullToRefresh)→ 冷启动白屏无任何反馈。TechFeedApp.kt:55-57):类型安全路由的 route 是全限定名,与 toString().substringAfterLast('.').lowercase()("feedroute")永不相等 → 三个 tab 永不高亮。navigate(LoginRoute) 调用,事件分支是 -> Unit → 登录页是孤岛,auth 模块整套无法触达。LoginViewModel.kt:53-57):成功分支不复位 loading;叠加 MutableSharedFlow() 无 replay,事件竞态丢失时按钮永久转圈。TechFeedApp.kt:52):Medium/Expanded 下 bottomBar 为空且无 NavigationRail → 平板/横屏无法切换页面。FeedViewModel.kt:82-86):注释声称"失败由 error 流暴露",但 error 流不存在,断网点收藏无任何反馈。showMessageNotification 同样零调用 → push 模块除 ensureChannels 外全是死代码。NotificationHelper.kt:45-47):注释说深链到文章,实际只构造 launcher intent。MainActivity.kt:32-36):App 前台/后台点深链无任何反应;NavHost 的 composable 也没注册 deepLinks 参数 → 深链仅冷启动有效,且返回栈断裂(直接退出应用)。core/crash/AnrWatchDog.kt):判定在 threshold/2 即触发(阈值缩水);阻塞期间回调洪泛(无去重标志);无法停止(重复调用即多个 watchdog 并发误报);mainThreadStack() 是死代码且引用疑似不存在的 Debug.getThreadTid API。pendingReports() 无调用方,crash 文件无限累积无清理。SecureTokenStorage.kt:20-28):加密本身正确(MasterKey AES256_GCM),但构造注入发生在主线程,叠加 StrictMode 检测大概率违规。ci.yml:47-48):testProdStoreDebugUnitTest 任务名只在 app 存在(flavor 仅 app 声明),feature 模块测试永远不会在 CI 运行。coil-network-okhttp 在 toml 声明但无模块使用,UI 用了 coil3.compose.AsyncImage → 运行时网络图全部加载失败(虽然当前 AsyncImage import 未使用,coverUrl 未渲染——两个问题互相掩盖)。.gitignore:23-24):注释说要提交 schema,模式却恰好把它忽略。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 进行了重构"。
docs/samples/ch27/* 文件实际不存在,与第 23 章"每个文件都真实存在于 sample/"的承诺不符;.versionrc.json 代码块用 // 注释(JSON 不支持);根 build.gradle.kts 片段缺 detekt classpath(与 sample 的 P0-1/P0-2 同源);apkalyzer 应为 apkanalyzer;第 21 章测试断言与意图矛盾(断言未截断);第 06 章 API 命名与第 09 章不一致等。CancellationException 永不吞、WhileSubscribed(5000) 动机、"Fake 优先于 Mock"等论述属中文同类资料上乘。亮点:模块治理(16 模块 include 与磁盘零偏差,feature 零依赖仅在 :app 聚合);TokenProvider 依赖倒置教科书级;Room Flow SSOT 响应式链路完整;测试基建(MainDispatcherRule/Turbine/Truth)专业;安全默认值到位(allowBackup=false、cleartext=false、token 真加密、userId 哈希脱敏);R8 规则覆盖官方推荐项。
P2 择要:
.catch 分支等核心逻辑零覆盖。core/testing/FakeFeedRemoteDataSource 未实现目标接口、缺方法,无法作为测试替身(应改 testFixtures 或接口下沉)。ArticleEntity 无任何索引(isFavorite/cachedAt 均全表扫);upsertKeepingFavorite N+1;clearExpiredCache 从未调用。@Preview;硬编码中文文案弃用了已定义的 strings.xml;observeFeed(query) 参数被静默忽略;详情页不落库断网必挂;windowBackground 与 Compose 令牌色差不一致。Result 与 kotlin.Result 双轨;model 模块注释称纯 JVM 却用 Parcelable;多处未使用依赖/别名(viewpager2、paging、robolectric 等);configureondemand=true 与 configuration-cache 兼容性存疑。第一批 · 让工程编译通过(半天)
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,可指定问题编号继续。
日期:2026-09-29(紧随全量 review,见 REVIEW-2026-09-29.md) 范围:sample 工程全部 P0/P1 与高价值 P2 + 文档全部 P1 与主要 P2
| # | 修复 | 涉及文件 |
|---|---|---|
| 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 等 |
| # | 修复 |
|---|---|
| 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() |
POST v1/auth/refresh(防递归)+ priorResponse 计数限重试 + tokenProvider.refresh() 落盘;新增 NetworkConfig.BASE_URL 单一真相@Body LoginRequestDto(不再走 URL query);新增 refresh 端点定义hasRoute(KClass) 正确匹配类型安全路由;非紧凑屏补 NavigationRailNavigateToLogin 事件 + 收藏页空态登录按钮,双入口ACTION_VIEW techfeed://article/{id};暖启动 addOnNewIntentListener 分发;冷启动深链改为 Feed→Detail 入栈(返回键回信息流);空 articleId 校验watchAnr() 接线runBlocking(Dispatchers.IO) + LoginUseCase withContext(IO) 落盘coil-network-okhttpArticleEntity 补 isFavorite/cachedAt 索引;upsertKeepingFavorite 改批量(2 查询 + upsertAll,消灭 N+1);clearExpiredCache 在 refresh 后接线(7 天策略)observeFeed(query) 真正 DB 级过滤,VM 改 flatMapLatest 订阅;FeedViewModel 改用官方 flow.update,删除自造扩展FakeFeedRemoteDataSource 实现真实接口;runSuspendCatching 改 suspend 签名别名缺失(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 处改"教学示例"措辞。
assembleDebug 实测;首次真机导入仍按 sample/README 检查清单排查。阶段:起步 | 预计阅读 40 min | 代码落地模块:无(本章产出第一个可运行 App) 相关章节:第 01 章 Kotlin 速成 | 第 02 章 工程结构与 Gradle
读完本章,你应该能够:
初学者最容易混淆的就是这一堆名词。记住这张图就够了:
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,电脑与设备通信的桥梁 | 所有"装不上 / 起不来"的问题,一半都靠它排查 |
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即可。
.exe,macOS 为 .dmg,Linux 为 .tar.gz)。D:\Android\Android Studio。中文路径会导致 Gradle 同步出现各种诡异错误。[!WARNING] 安装目录、SDK 目录、项目目录、Gradle 缓存目录,全部不要出现中文、空格或特殊字符。这是国内开发者踩得最多的坑之一。
Settings → Android SDK 或工具栏的 SDK Manager 图标,打开后按以下勾选:
targetSdk 对应版本(如 Android 16 / API 36)和一个低版本(如 API 26)用于兼容性验证。Android SDK Build-Tools(编译打包)Android SDK Platform-Tools(含 adb)Android SDK Command-line Tools(含 sdkmanager、avdmanager)Android EmulatorGoogle 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
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 写法问题,版本不匹配)。
Device Manager → Create Device:
Tiramisu 及以上的 x86_64 镜像,ABI 必须匹配你的 CPU。
HAXM 或直接用 Windows 的 WHPX。arm64-v8a 镜像。Enable Device Frame 便于截图;Graphics 设为 Automatic(若启动黑屏则改为 Software)。启动后用 adb 验证:
# 列出当前连接的所有设备与状态
adb devices
# 期望输出:emulator-5554 device (状态必须是 device,offline/unauthorized 都有问题)
真机调试(推荐,性能与真实度都远好于模拟器):
设置 → 关于手机 → 版本号 连点 7 次打开开发者选项。开发者选项 → 无线调试 → 使用配对码配对设备,然后:# 无线调试配对(Android 11+),ip:port 在手机"无线调试"页面查看
adb pair 192.168.1.20:37211
# 配对成功后连接
adb connect 192.168.1.20:41573
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 和布局。我们把它改成能体现"数据 → 界面"的最小闭环。
// 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")
}
}
}
}
}
}
<!-- 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 自动升级,不要手改版本号 |
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判断后输出。
# 安装 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
page == 3 时停下,避免循环里反复手动放行。Run → Attach Debugger to Android Process 挂上去,不必重启。Alt+F8,可以现场执行任意表达式,验证猜想最快的方式。Tools → Layout Inspector,查看真实运行时的视图树与每个 View 的属性。布局错乱、控件被遮挡时用它定位最快。Compose 工程同样支持,可看到重组次数(Recomposition Counts)。View → Tool Windows → Profiler,实时看 CPU、内存、网络、电量。第 17 章会详细展开。gradle/libs.versions.toml,绝不在 build.gradle.kts 里硬编码(第 02 章)。applicationId 上架后不可更改,立项时就按公司反向域名确定。Log.d 调试、Log.e 带异常对象;Release 包关闭调试日志。Caused by 是补充。parallel / caching / configuration-cache,多模块工程构建时间可减半。File → Invalidate Caches / Restart 能解决相当比例的 IDE 玄学问题。// 旧写法(不推荐):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。
adb devices、adb logcat、adb shell am 三个命令要形成肌肉记忆。下一章:先补一门 Kotlin 速成课,把空安全、data class、扩展函数、密封类和协程这几个高频语法掌握,后面读代码就不会卡壳。
基础题
HelloAndroid 工程,分别在模拟器和真机上跑通。adb devices 确认设备状态为 device;用 adb shell am start 手动启动一次 MainActivity。package:你的包名 过滤,只看到自己应用的日志。进阶题
4. 分别用 Compose 和 View/XML 实现同一个计数器,对比两者的状态恢复方式(旋转屏幕后计数是否保留)。
5. 编写一个 debug.sh 脚本,一键完成:卸载旧包 → 安装新包 → 启动首页 → 抓取最近 200 行日志到文件。
思考题
6. 为什么 Android Studio 要自带 JDK,而不能直接用系统里装的 JDK?(提示:CI 环境 reproducibility)
7. 如果你的 App 在模拟器上正常、真机上崩溃,你会按什么顺序排查?(提示:ABI、系统版本、厂商 ROM、权限)
8. applicationId 与代码里的 package 属性有什么区别?为什么上架后不能改 applicationId?
阶段:起步 | 预计阅读 50 min | 代码落地模块:
:core:common、:core:model前置:第 00 章 环境搭建 相关章节:第 11 章 协程与 Flow
val/var、空安全、data class、扩展函数、密封类、作用域函数、泛型。!!、GlobalScope、runBlocking 等)。2019 年 Google 宣布 Kotlin-First,原因不是"语法糖多",而是三点硬收益:
| 收益 | 具体表现 |
|---|---|
| 空安全(Null Safety) | 空指针异常(NPE)在编译期被消灭,Android 崩溃里 NPE 常年排前三 |
| 协程(Coroutines) | 用同步写法表达异步逻辑,彻底告别回调地狱与 AsyncTask |
| 互操作性 | 与 Java 100% 双向调用,存量项目可以逐文件迁移,不必重写 |
flowchart LR
A["Any?(可空顶层类型)"] --> B["Any(非空顶层类型)"]
B --> C["String / Int / 自定义类"]
A --> D["String? / Int? / 自定义类?"]
C -.->|"可以赋值给"| D
D -.->|"需要判空后才能"| C
核心规则:类型默认非空,想允许为空必须显式加 ?。要从一个可空类型拿到非空值,必须过一道"关卡":?.、?:、!! 或智能转换。
// 文件路径: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"
// 文件路径: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替代。
这三个是 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:新增子类时这里会编译报错,强制你处理
}
}
| 方案 | 适用场景 | 特点 |
|---|---|---|
enum class |
固定常量集合(如 Status.LOADING) |
每个枚举值是单例,不能携带不同的数据 |
sealed interface/class |
有限种状态,且每种状态携带不同数据 | 每个子类可有不同字段;when 可穷尽检查 |
interface |
开放扩展、允许外部实现 | 无法穷尽,when 必须写 else |
UI 状态建模一律用密封类(第 14 章的 UiState),不要用枚举——因为 Loading 没有数据,而 Success 有数据。
// 文件路径: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,仅做副作用
}
// 文件路径: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()
这是理解协程最关键的一点:
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() |
| 扩展函数"看起来像成员"造成误读 | 扩展是静态解析,不参与多态 | 记住:扩展函数不能被子类覆写 |
Tools → Kotlin → Show Kotlin Bytecode,再点 Decompile 反编译成 Java。想搞懂 data class、扩展函数、when 到底生成了什么,这是最直接的方法。Run/Debug Configurations 中开启协程调试,或运行时加 VM 参数:-Dkotlinx.coroutines.debug=on
之后日志里的线程名会带上协程信息:@coroutine#3。println("当前线程=${Thread.currentThread().name}, dispatcher=${coroutineContext[ContinuationInterceptor]}")
Thread.setDefaultUncaughtExceptionHandler(第 18 章会做成崩溃采集)。kotlin.runCatching 陷阱:它会连 CancellationException 一起捕获。协程中优先手写 try/catch 并重新抛出取消异常。val,只有真正需要重新赋值才用 var;字段默认不可变能消掉大量并发与状态 bug。!!:把"它可能为空"当作设计信号,用 ?: return、requireNotNull、checkNotNull 明确表达。data class + 不可变字段:配合 Compose 与 StateFlow,能天然避免"改了对象但 UI 没刷新"。core:common:命名以 toXxx / asXxx 为主,不要把复杂业务逻辑塞进扩展。GlobalScope:任何协程都要挂在有明确生命周期的 Scope 上(结构化并发)。suspend 函数必须是主线程安全的:内部自行 withContext 切线程,让调用方无需关心线程。?,用 ?. / ?: / let 而非 !!。suspend + withContext 让你用同步代码写异步逻辑。viewModelScope 自动取消、禁用 GlobalScope、CancellationException 不能吞。下一章:进入工程内部,看清一个 Android 项目的目录结构与 Gradle 构建脚本到底怎么组织。
基础题
data class Comment(val id: String, val content: String, val liked: Boolean),写一个函数把 List<Comment> 按 liked 分组统计数量。String 写扩展函数 toMaskedPhone(),把 13812345678 转成 138****5678,并处理好长度不足 11 位的情况(不要抛异常)。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) } }
})
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"的方案,在团队协作与重构安全上有什么具体优势?
阶段:入门 | 预计阅读 45 min | 代码落地模块:工程根目录、
build.gradle.kts、gradle/libs.versions.toml前置:第 00 章 环境搭建 相关章节:第 15 章 模块化与组件化 | 第 20 章 构建配置与发布
build.gradle.kts,理解 AGP、Gradle、Kotlin 插件三者的职责边界。libs.versions.toml),把依赖版本统一收口。applicationId、namespace、R 类、BuildConfig 这些容易混淆的概念。一个新建的 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,二者等价,选定后团队统一即可。
| 文件 | 职责 | 常见操作 |
|---|---|---|
settings.gradle.kts |
模块注册、仓库(dependencyResolutionManagement)、构建缓存 | 新建模块、加 maven 源 |
根 build.gradle.kts |
声明插件版本(apply false,只在子模块按需应用) |
升级 AGP / Kotlin / Hilt 插件 |
模块 build.gradle.kts |
应用插件、配置 SDK 版本、声明依赖与构建类型 | 日常最常改的文件 |
这是初学者最容易混淆的一对概念:
| 概念 | 作用 | 能否改 |
|---|---|---|
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",这是有意为之——它让模块边界更清晰、编译更快。
// 文件路径: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")
# 文件路径: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。
// 文件路径: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
}
// 文件路径: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]
apivsimplementation的抉择:如果依赖类型出现在你模块的公开签名里(函数返回值、参数、父类),用api;否则一律implementation。 滥用api会让改动一个底层模块触发全量重编译,是多模块工程变慢的头号原因。
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 可以图形化看模块间依赖关系,发现循环依赖。libs.versions.toml:模块脚本里出现硬编码版本号一律视为 CR 不通过。implementation:api 只在类型出现在公开签名时使用,滥用会让多模块构建退化成单体。minSdk/compileSdk 用约定插件统一:避免 A 模块 26、B 模块 28 这种混乱。configuration-cache 与 parallel:多模块工程收益巨大,但要确认所有插件都支持。namespace 与源码包名保持一致:不一致会让 R 类、Manifest 引用变得难以理解。.gitignore 必须排除 build/、.gradle/、local.properties:local.properties 含本机 SDK 路径,绝不能提交。src/main(代码+资源+清单)、src/test(本地单测)、src/androidTest(仪器化测试)、变体源码集。settings(模块与仓库)、根脚本(插件版本)、模块脚本(插件+SDK+依赖)。namespace 决定 R 类包名,applicationId 是市场唯一标识,上架后不可改。libs.versions.toml 是唯一版本真相,api 与 implementation 的选择直接影响构建速度。下一章:进入 Android 的核心——四大应用组件与生命周期,理解系统在什么时候创建和销毁你的代码。
基础题
compileSdk、minSdk 改成从 libs.versions.toml 读取(用 libs.versions.xxx.get().toInt())。core:common library 模块,在 app 中用 implementation(projects.core.common) 依赖它,调用其中的一个函数。./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 要区分"配置阶段"和"执行阶段"?如果允许在配置阶段发网络请求,会带来什么问题?
阶段:入门 | 预计阅读 55 min | 代码落地模块:
:app、:feature:*前置:第 02 章 工程结构与 Gradle 相关章节:第 06 章 列表与导航 | 第 14 章 分层架构
| 组件 | 一句话定位 | 现在还常用吗 |
|---|---|---|
| Activity | 一个与用户交互的"窗口",UI 的载体 | ✅ 仍然必需(Compose 也要跑在 Activity 里) |
| Service | 无界面的后台执行单元 | ⚠️ 后台任务已被 WorkManager 取代,仅前台服务/音乐播放等场景保留 |
| BroadcastReceiver | 响应系统或应用广播(开机、网络变化、时区) | ⚠️ 静态注册受严格限制,动态注册仍有用 |
| ContentProvider | 跨应用数据共享(联系人、相册、文件) | ✅ 特定场景必需;应用内数据共享不用它 |
[!IMPORTANT] 所有组件都必须在
AndroidManifest.xml中声明,未声明的组件无法启动(Activity 会抛ActivityNotFoundException)。 唯一例外是动态注册的 BroadcastReceiver(代码中注册,随上下文生命周期)。
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承载。
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 章)。
两种"页面重建",处理方式完全不同:
| 场景 | 触发 | 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"
}
}
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)
<!-- 文件路径: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 章)。
// 文件路径: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 // 交给导航层消费
}
}
// 文件路径: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()
}
}
// 文件路径: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("操作失败,请重试")) }
}
}
}
// 文件路径: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)) |
// 文件路径: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 内:
ActivityThread / ActivityManager 可看到组件的创建销毁轨迹。onCreate、onStart、onResume 等加临时日志,配合"不保留活动"开关,能快速暴露状态保存问题。repeatOnLifecycle(STARTED):避免后台仍在收集浪费资源。onDestroyView 中 _binding = null,这是内存泄漏第一大来源。exported="false":这是安全基线。singleInstance:它会导致任务栈行为难以预测,99% 的场景用 singleTop/singleTask 就够了。requestCode 常量。// 旧写法(已废弃):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 把"请求—结果"封装成对象,可以在任意类中复用,也更容易单元测试。
onStop 释放重量级资源,onDestroy 不保证被调用。onDestroyView 必须置空 binding;纯 Compose 新项目可以不用 Fragment。standard / singleTop / singleTask 配合 Intent Flag 使用。下一章:开始写界面。先看传统 View + XML 体系——它仍然是国内存量项目的主力。
基础题
SavedStateHandle 保证计数不丢。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?(至少说出两个真实理由)
阶段:入门 | 预计阅读 60 min | 代码落地模块:
:core:designsystem、:feature:detail前置:第 03 章 应用组件与生命周期 相关章节:第 05 章 Compose 基础 | 第 07 章 资源与主题
ConstraintLayout 搭建中等复杂度页面,且布局层级不超过 3 层。findViewById。onMeasure / onDraw 中各自要注意什么。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 出现的原因之一 |
| 单位 | 含义 | 用途 |
|---|---|---|
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
| 布局 | 适用场景 | 禁忌 |
|---|---|---|
LinearLayout |
单方向排列(列表项、表单行) | 用嵌套 LinearLayout 模拟网格 |
FrameLayout |
叠放(图片角标、Fragment 容器、蒙层) | 排版 |
ConstraintLayout |
复杂相对关系、需要比例/Barrier/Guideline | 只有一两个控件也要套一层 |
RelativeLayout |
不要新用,能力是 ConstraintLayout 子集 | — |
AbsoluteLayout |
已废弃 | — |
[!TIP] 布局选型的经验法则:能用一层 ConstraintLayout 解决的,不要用三层 LinearLayout 嵌套。 测量是递归的,嵌套每深一层,整棵树的测量成本就乘一次。
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 的文章详情页头部:封面图、标题、作者信息、收藏按钮。这个例子覆盖了最常见的排版需求。
<!-- 文件路径: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)
}
// 文件路径: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),
)
}
}
}
// 文件路径: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 后再往里找 |
// 旧写法 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 的双向绑定/表达式, 注意它编译更慢、报错更难读,新代码不建议引入。
Tools → Layout Inspector):
adb shell setprop debug.layout true
直接在设备上看到每个 View 的裁剪边界与过度绘制染色。ConstraintLayout),如 "do not constrain child vertically"。View.setLayerType(LAYER_TYPE_HARDWARE, null) 隔离它,观察掉帧是否消失。./gradlew :feature:detail:lintDebug,Lint 会报告 MissingConstraints、UselessParent 等问题(第 22 章接入 CI)。ConstraintLayout 一层解决的,不用嵌套。findViewById、synthetics、ButterKnife 都是历史。ArticleHeaderView 那样,外界只调用 bind(model),页面代码更薄。tools: 命名空间做预览数据:tools:text 不影响运行时,却让预览接近真实效果。ImageButton/Button 而不是裸 View;图片给 contentDescription(装饰图给 null)。onDraw 里零分配:Paint、Rect 在构造时创建复用。Window → DecorView → 内容布局;布局 XML 是运行时解析的。wrap_content / match_parent / 0dp+约束 各司其职。LinearLayout、叠放用 FrameLayout、复杂关系用 ConstraintLayout,RelativeLayout 不再新用。onDestroyView 置空。onMeasure(必须 setMeasuredDimension)+ onDraw(零对象分配)。下一章:走进 Compose——同样的详情页头部,用声明式 UI 写出来是什么样,状态驱动和命令式刷新的差异到底在哪。
基础题
tools:text 让预览显示非零计数。TagView:绘制圆角矩形背景 + 居中文字,支持 wrap_content(提示:测量文字用 Paint.measureText)。进阶题
4. 实现 ArticleHeaderView 的深色模式适配:颜色改用主题属性,验证切换深色模式后无需改代码(结合第 07 章)。
5. 把"标题 + 作者行"抽成一个可复用控件,同时提供 XML 与 Compose 两个实现,保证视觉一致(字号/间距用同一套设计令牌)。
思考题
6. 为什么说"XML 布局是运行时解析的"这件事,从根上限定了 View 体系的能力上限?(提示:类型安全、编译期检查、条件逻辑表达力)
7. ConstraintLayout 的 Barrier 和 Guideline 分别解决什么问题?如果不用它们,你需要付出什么代价?
8. 事件分发中"父容器拦截、子 View 消费"各发生在哪个回调?如果要实现"侧滑返回与横滑列表共存",你会怎么设计?
阶段:入门 | 预计阅读 65 min | 代码落地模块:
:core:designsystem、:feature:feed前置:第 04 章 View 与 XML | 第 01 章 Kotlin 速成 相关章节:第 06 章 列表与导航 | 第 17 章 性能优化
remember / rememberSaveable / mutableStateOf 管理状态。LaunchedEffect / DisposableEffect / rememberCoroutineScope 各自的使用时机。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)。界面只是状态的函数,状态变了,界面自然重算。
count++)。// ⚠️ 反例:副作用直接写在组合期
@Composable
fun BadExample(articleId: String) {
// 每次重组都会发一次请求!旋转屏幕、键盘弹出、任何状态变化都会触发
repository.loadArticle(articleId) // ❌
}
// ✅ 正例:副效应放入 LaunchedEffect,以 articleId 为 key
@Composable
fun GoodExample(articleId: String) {
LaunchedEffect(articleId) {
repository.loadArticle(articleId)
}
}
| API | 作用 | 典型场景 |
|---|---|---|
mutableStateOf() |
创建可观察状态 | UI 局部状态(输入框、开关) |
remember {} |
重组间记住对象 | 缓存计算结果、Controller |
rememberSaveable {} |
记住且能跨进程重建恢复 | 输入框内容、滚动位置 |
collectAsStateWithLifecycle() |
把 Flow 转成 Compose 状态 | 收集 ViewModel 的 StateFlow |
derivedStateOf {} |
从其他状态派生,减少重组 | list.contains(x) 之类的高频派生 |
LaunchedEffect(key) |
进入组合时启动协程,key 变化或离开组合时取消 | 副效应、一次性加载 |
DisposableEffect(key) |
需要清理的副效应 | 注册监听器 |
rememberCoroutineScope() |
拿一个绑定组合的 Scope | 事件回调里启动协程(如滚动列表) |
无状态组件的模式固定为三段式:
@Composable
fun XxxCard(
value: T, // ① 状态:由调用方传入
onValueChange: (T) -> Unit, // ② 事件:向上报告
modifier: Modifier = Modifier, // ③ modifier 放最后(带默认值)
)
好处:组件可预览、可测试、可在多处复用;状态集中在拥有它的层级(通常是 ViewModel 或父组件)。
// 文件路径: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 章
}
}
// 文件路径: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 章)。
// 文件路径: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,
)
// 文件路径: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 是从外到内依次应用,顺序有语义,调整并验证 |
@Preview 中,Layout Inspector 可以叠加显示 Compose 语义树边界。composeTestRule.onRoot().printToLog("semantics") // 在 Logcat 打印语义树
debugInspectorInfo:给自定义 Modifier 写元数据,Layout Inspector 中能看到每个 modifier 的参数。Snapshot.withMutableSnapshot / snapshotFlow 可以把"状态变化"转成 Flow,用于打印状态轨迹:snapshotFlow { state.articles.size }
.distinctUntilChanged()
.collect { Log.d("Feed", "列表长度变化: $it") }
@Preview(name = "深色", uiMode = Configuration.UI_MODE_NIGHT_YES)、@Preview(fontScale = 1.3f),一次写多个预览覆盖关键形态。value: T, onValueChange: (T) -> Unit, modifier: Modifier = Modifier。UiState 传给 item,否则任何字段变化都会让整列重组。LaunchedEffect、需要清理用 DisposableEffect、事件回调里用 rememberCoroutineScope、同步到非 Compose 世界用 SideEffect。MaterialTheme 取,杜绝硬编码。key() 明确标识列表项身份:配合 LazyColumn(第 06 章),让重组最小化。@Stable / @Immutable 慎用:先让数据类天然稳定(全 val + 基本类型/String),标注是兜底不是常规手段(第 17 章)。<!-- 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 里的状态)。
mutableStateOf + remember(Saveable) 管局部状态,ViewModel + StateFlow 管业务状态,collectAsStateWithLifecycle 桥接。下一章:把状态驱动应用到最重的 UI 场景——列表与页面导航,对比 RecyclerView 与 LazyColumn、Navigation 与 Navigation-Compose 两套方案。
基础题
MaterialTheme.colorScheme.primary 高亮;要求组件无状态、可预览。rememberSaveable 的倒计时组件:旋转屏幕后剩余秒数不重置。(提示:保存"结束时间戳"而不是"剩余秒数"更稳。)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 会让什么变差?(提示:可读性、可测试性)
阶段:入门 | 预计阅读 65 min | 代码落地模块:
:feature:feed、:app前置:第 04 章 View 与 XML | 第 05 章 Compose 基础 相关章节:第 09 章 网络请求 | 第 14 章 分层架构
DiffUtil 的 ListAdapter。LazyColumn(key、contentType、分页),并解释它与 RecyclerView 的异同。屏幕只能显示十几个列表项,但数据可能有上万条。两套体系的核心思想一致——只创建"屏幕上正在显示的"那些视图对象:
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 |
列表项没有稳定 id 时,框架只能按位置匹配新旧数据。典型事故:
稳定 key = 数据的身份(id),不是位置。 这是列表正确性的第一原则,两套体系通用。
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
NavHost 是唯一的"页面容器",目的地(Destination)之间的切换不产生新的 Activity。navigate 压栈,系统返回键 / popBackStack 出栈。// 文件路径: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,直接比
}
}
}
// 文件路径: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,
)
}
}
}
}
// 文件路径: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),
)
}
}
}
}
}
<!-- 文件路径: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),学会一套另一套自然通。
// 文件路径: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}") } 打印路由切换轨迹。key 里临时拼上 hashCode(),如果滚动时频繁变化说明 key 不稳定。beyondBoundsPageCount/userScrollEnabled 等参数可用于复现滚动边界问题。RecyclerView 侧:
RecyclerView.setItemViewCacheSize() 与 RecycledViewPool 状态可在 Profiler 的 Memory 面板观察。Adapter.setHasStableIds(true)(配 getItemId)是 DiffUtil 的替代,但只适合全部 item 类型一致的简单列表。hashCode 都不是身份。Article 就够,别传 UiState;Compose 中每个 item 一个重组边界。onArticleClick: (String) -> Unit 回调,不 import 导航库——组件才能脱离导航独立测试。@Serializable data class),告别字符串拼接。launchSingleTop,而不是每个点击回调里各自判断。areContentsTheSame 直接用 data class 的 equals;如有高频字段(如未读数),实现 ChangePayload 做局部刷新。contentType / getItemViewType 明确声明,复用效率显著提升。// 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) // 拼错不会报错,运行时才炸
ListAdapter + DiffUtil 获得差量更新;LazyColumn 用 key + contentType + 最小数据 获得高效重组。onArticleClick),导航逻辑收敛在 NavHost——组件可独立预览与测试。下一章:列表和页面都依赖的正确"皮肤"从哪来——资源体系、主题与深浅色、多语言与大屏自适应。
基础题
ListAdapter + DiffUtil 实现一个"文章收藏列表",支持删除单项,验证删除中间项时动画与状态都正确。LazyColumn 重写第 1 题,注意给 items 加 key;对比删除时的行为差异。进阶题
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 会出什么问题?
阶段:入门 | 预计阅读 55 min | 代码落地模块:
:core:designsystem前置:第 04 章 View 与 XML | 第 05 章 Compose 基础 相关章节:第 19 章 安全加固(关于国际化合规) | 第 20 章 构建与发布
res/ 目录的资源分类与限定符(Qualifier)匹配规则,能解释"系统如何选中正确的资源"。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。
限定符是目录名的"后缀",系统在运行时按设备当前配置挑选最匹配的资源:
values-zh-rCN/ → 中文(中国)
values-night/ → 深色模式
layout-land/ → 横屏
drawable-xxhdpi/ → 超高密度屏
values-w600dp/ → 最小宽度 ≥600dp(平板)
匹配规则(重点):
values-en/ 直接出局)。values/、drawable/ 永远必须存在。[!WARNING] "只在
values-night/放深色、忘了values/放浅色"是高频事故:部分设备/预览环境拿不到night资源时直接崩。
| 概念 | 作用域 | 典型用途 |
|---|---|---|
style |
单个 View | 按钮的统一外观 |
theme |
Activity / Application / View 子树 | 全局配色、窗口背景、控件默认样式 |
主题的本质是属性查询表:XML 里写 ?attr/colorPrimary,运行时从当前主题查值。这也是深浅色"零代码适配"的机制——同一属性在不同主题下指向不同色值。
Compose 中的对应物是 MaterialTheme(colorScheme, typography, shapes)(第 05 章),两者靠设计令牌对齐。
折叠屏与平板普及后,"按屏幕尺寸"判断已经过时,官方推荐按窗口可用宽高分类:
| 类别 | 断点 | 典型设备 |
|---|---|---|
| Compact | 宽 < 600dp | 竖屏手机 |
| Medium | 600dp ≤ 宽 < 840dp | 平板竖屏 / 大折叠展开内屏 |
| Expanded | 宽 ≥ 840dp | 平板横屏、桌面模式 |
判断依据是窗口而非物理屏幕:分屏、自由窗口、折叠态变化时窗口类随之变化。
<!-- 文件路径: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)
<!-- 文件路径: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>
// 文件路径: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。
<!-- 文件路径: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+ 前景/背景层)。
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。
// 文件路径: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 显式编号 |
adb shell cmd uimode night yes
adb shell cmd uimode night no
left/right 残留。aapt2 dump resources app-debug.apk | grep <name> 查看 APK 内资源表。./gradlew lint 会输出 MissingTranslation 与 PluralsCandidate 报告。color/surface、dimen/spacing_m、style/TextTitle,杜绝 color/blue1。values/、drawable/ 是兜底,不能为空壳。strings.xml,占位符用 %1$s 显式编号。mipmap 只放启动图标。if (isDark) ...。contentDescription、对比度(4.5:1)、sp 字号、触控目标 ≥48dp。res/ 是声明式配置中心:资源名即 API,限定符即条件,运行时按设备配置自动匹配。?attr/colorXxx 让深浅色切换零代码;Compose 与 XML 靠设计令牌对齐。strings.xml(显式占位符 + plurals),颜色走语义命名,图标优先矢量图。start/end、应用内切换用 AppCompatDelegate。下一章:跨出"页面"的边界——Intent 如何发起与接收通信、权限如何申请、进程之间如何对话。
基础题
ArticleHeaderView 的所有硬编码色替换为主题属性,用 adb shell cmd uimode night yes/no 验证零代码切换。values-zh-rCN/strings.xml,给现有页面补一份中文文案,验证系统语言切换后即时生效。进阶题
4. 实现"应用内语言切换":设置页提供中文/英文选项,用 AppCompatDelegate.setApplicationLocales 即时生效并持久化。
5. 用 WindowSizeClass 实现列表-详情双栏:手机上点击跳转详情页,平板(Expanded)上右侧面板内嵌显示。
6. 把 App 的全部图标资源从 PNG 迁移到矢量图,统计 APK 体积变化(用 ./gradlew assembleDebug 前后对比)。
思考题 7. 为什么 Android 要求"默认资源目录必须存在"?如果不存在,资源解析失败时会发生什么? 8. "设计令牌"解决的是设计系统的什么问题?如果没有令牌,双体系(XML+Compose)并行开发会积累哪类 bug? 9. WindowSizeClass 为什么以"窗口"而非"屏幕"为判断依据?举两个会导致两者不一致的真实场景。
阶段:入门 | 预计阅读 55 min | 代码落地模块:
:feature:auth、:app前置:第 03 章 应用组件与生命周期 | 第 06 章 导航 相关章节:第 13 章 后台任务与通知 | 第 19 章 安全加固
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>,见下文)。
| 方案 | 触发方式 | 可信度 | 要求 |
|---|---|---|---|
| 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 各一条)"
]
}
}]
| 级别 | 授权方式 | 例子 |
|---|---|---|
| 普通权限 | 安装时自动授予 | 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 可用)。 上架审核(包括国内应用商店)都会检查权限与功能的匹配度。
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
startActivity 最终都是一次 IPC。// 文件路径: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)
)
}
}
// 文件路径: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>
// 文件路径: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 的能力、内存隔离。
// 文件路径: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。dumpsys 输出配合使用。anr traces(/data/anr/)里的 "held by thread" 是定位 Binder 线程池耗尽的关键。exported="false",深链目的地单独控制并做白名单校验(第 19 章)。@Parcelize。// 旧写法: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)。不要再用旧存储权限写新代码。
<queries>。下一章:进入进阶篇——网络请求。从一条 HTTP 请求在 OkHttp 拦截器链里的旅程讲起。
基础题
ACTION_SEND Intent,处理"没有任何可分享应用"的降级提示。techfeed://article/{id} 深链,写 DeepLinkParser 并为它写 5 个单元测试(正常、缺参、未知 host、https、空 URI)。进阶题
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 把通知列为运行时权限,这对"推送到达率"产品指标意味着什么?你会如何设计权限引导时机?
阶段:进阶 | 预计阅读 60 min | 代码落地模块:
:core:network、:core:model前置:第 01 章 Kotlin 速成与协程预热 | 第 02 章 工程结构与 Gradle 相关章节:第 10 章 数据存储 | 第 11 章 协程与 Flow | 第 12 章 依赖注入 | 第 19 章 安全加固
Result<T> / AppError,让 UI 层拿到结构化错误而不是裸异常。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<T>"| A
四层各自的职责,混用会让代码难以测试:
| 层 | 库 | 职责 | 不该做的事 |
|---|---|---|---|
| 接口声明 | Retrofit | 用注解描述 URL、方法、参数、请求体 | 业务判断、错误处理 |
| 传输 | OkHttp | 连接复用、重试、缓存、TLS、拦截器 | 认识你的业务模型 |
| 序列化 | kotlinx.serialization | JSON ↔ data class | 网络策略 |
| 语义 | Repository | 把 HTTP 结果翻译成 Result<T> / AppError |
直接持有 OkHttpClient |
[!TIP] 判断代码放错层的标准:Repository 里出现
@GET/@POST注解就说明 Retrofit 泄漏到了业务层; 反过来,拦截器里出现Article这种领域模型,就说明业务泄漏到了传输层。
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。
Retrofit 不是 HTTP 客户端,它是"把 Kotlin 接口翻译成 OkHttp 调用"的动态代理。你写的 suspend fun 方法,在运行时被解析成 Request,交给 OkHttp 执行,返回值再交给 Converter 反序列化。
关键收益有三点:
suspend 函数天然在 OkHttp 的线程池执行,不需要你手动 withContext(Dispatchers.IO)(第 11 章会解释为什么)。@Query / @Path / @Body / @Header)把 URL 拼装的样板代码消灭掉。| 方案 | 优点 | 缺点 |
|---|---|---|
| kotlinx.serialization | 编译期生成、无反射、Kotlin 官方、支持默认值与 @SerialName |
对复杂多态需要额外注册 |
| Moshi | 成熟、注解友好 | 早期版本依赖反射(codegen 可解) |
| Gson | 存量项目多 | 反射 + 不认识 Kotlin 非空类型,可能把 null 塞进非空字段 |
kotlinx.serialization 的两个必开配置:ignoreUnknownKeys = true(后端加字段不会让 App 崩)、coerceInputValues = true(字段类型异常时回落到默认值而非抛异常)。
国内后端通常返回统一信封:
{ "code": 0, "message": "ok", "data": { "id": "a-001", "title": "..." } }
处理方式:DTO 层保留信封结构,Repository 层把它翻译成第 01 章定义的 Result<T> / AppError。UI 层永远不认识 code 这个字段——这样后端换协议(比如改 gRPC、改 GraphQL)时,改动被关在 :core:network 里。
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)。
OkHttpClient.Builder 里留一个 certificatePinner 的注入位,否则第 19 章要改所有构造点。// 文件路径: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 一处。
// 文件路径: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、单引号)
}
// 文件路径: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 章。
// 文件路径: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)
}
}
// 文件路径: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 章展开)。
// 文件路径: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
}
// 文件路径: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()
}
// 文件路径: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() } },
)
}
// 旧写法: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 章)。
网络结果最终要落到界面上。这里给同一个"错误 + 重试"界面的两套写法。
// 文件路径: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() }
}
}
// 文件路径: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的超时调短, 就能在本地稳定复现线上最难复现的网络异常。
// 文件路径: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 章) |
App Inspection → Network Inspector 能看到每个请求的 URL、Header、Body 与时序,不需要改代码。# 在设备上测 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"
EventListener 统计 DNS、TLS 握手、首字节耗时,能定位"慢到底是慢在哪一段"。class TimingEventListener : EventListener() {
override fun dnsStart(call: Call, domainName: String) { /* 打时间戳 */ }
override fun responseHeadersStart(call: Call) { /* 计算 TTFB */ }
}
enqueue,可以在本地 100% 复现解析崩溃。res/xml/network_security_config.xml 中
为 debug 变体配置 <debug-overrides>(详见第 19 章)。抓包失败时先确认这一条。HttpLoggingInterceptor.Level.BODY 会把图片/大列表刷爆 Logcat,调试时先用 HEADERS,必要时才升到 BODY。Error → Retry → Auth → Logging。错误处理在最外层才能捕获内层抛出的异常;日志在最内层才能看到最终发出的请求。:core:network 只依赖 TokenProvider 接口,实现放在 :core:datastore(第 10 章),避免网络层反向依赖存储层实现。ArticleDto 只在网络层可见,出 :core:network 之前必须转成 Article。后端改字段名时改动被关在一个文件里。ignoreUnknownKeys 必开:这是成本最低的线上稳定性保险。catch (IOException),只该看到 Result.Failure(AppError)。debugImplementation 而非 if (BuildConfig.DEBUG),同时给 Authorization 等头设 REDACTED。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 章填) |
上线后再回头改所有构造点 |
ignoreUnknownKeys + coerceInputValues + @SerialName,让后端演进不摧毁客户端。ErrorInterceptor 把 HTTP 异常翻译成 AppError,safeApiCall 把信封翻译成 Result<T>,UI 只消费 Result。上一章回顾:第 03 章给出的 UiState + Event 双通道,正是本章 Result<T> 在 UI 层的落点。
下一章:网络拿到的数据需要落地——进入 第 10 章 数据存储:Room、DataStore 与文件存储,
学习如何把 Article 存进本地数据库,并实现"离线也能看"的单一数据源。
基础题
FeedApi 增加一个 @POST("v1/articles/{id}/favorite") 接口(无请求体),用 MockWebServer 验证请求方法与路径正确。HeaderInterceptor,统一注入 X-App-Version、X-Device-Id 与 Accept-Language,并用 server.takeRequest() 断言头已发出。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 这类领域模型,为什么是设计问题?它会带来哪些具体的维护成本?
阶段:进阶 | 预计阅读 60 min | 代码落地模块:
:core:database、:core:datastore前置:第 01 章 Kotlin 速成与协程预热 | 第 09 章 网络请求 相关章节:第 11 章 协程与 Flow | 第 14 章 分层架构与 MVI | 第 19 章 安全加固 | 第 25 章 登录鉴权与离线缓存
Flow 返回可观察查询并用 @Transaction 保证一致性。Migration、destructiveMigration)及其代价,会做预填充数据库。先别急着写代码,用这张表选型:
| 数据形态 | 例子 | 方案 | 理由 |
|---|---|---|---|
| 简单键值、小 | 主题模式、是否首次启动、搜索历史 | DataStore Preferences | 异步、事务性、类型安全,取代 SharedPreferences |
| 结构化键值对 | 用户配置对象、筛选条件 | DataStore Proto | 有 schema,可演进,编译期类型安全 |
| 结构化、量大、需查询 | 文章缓存、收藏、草稿 | Room(SQLite) | 支持 SQL 查询、索引、关联、分页 |
| 大块二进制 | 图片、音视频、下载包 | 文件系统 / MediaStore | 数据库存大 blob 会拖垮查询性能 |
| 高敏感小数据 | Token、刷新令牌、支付凭据 | 加密存储(EncryptedFile / Keystore) | 见第 19 章 |
[!IMPORTANT] SharedPreferences 已被官方标记为弃用,新代码一律用 DataStore。它的问题不是"慢",而是: 同步 I/O 可能在主线程造成卡顿与 ANR、
apply()的异步提交不保证落盘时机、没有事务、没有类型安全、没有错误信号。
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 的三个硬收益:
Flow / suspend:查询结果变化自动推送,无需手动 ContentObserver。| 组件 | 注解 | 职责 | 注意 |
|---|---|---|---|
| Entity | @Entity |
描述一张表,字段即列 | 必须有 @PrimaryKey;类名默认即表名 |
| Dao | @Dao |
描述增删改查;Room 生成实现 | 接口或抽象类均可;返回 Flow 时方法不必是 suspend |
| Database | @Database |
持有数据库、暴露 Dao、管理版本 | 抽象类,继承 RoomDatabase |
Dao 返回 Flow<List<T>> 时,Room 会在事务内监听相关表的变化,表一变就重新查询并推送。这意味着:
suspend(Flow 的收集本身就是异步的)。Flow 查询里做耗时计算——它在事务线程上执行,会阻塞其他写入。@Transaction,否则中间状态可能被其他线程读到。| 策略 | 适用场景 | 代价 |
|---|---|---|
自动迁移 @AutoMigration |
加表、加列(非空列需有默认值)、改索引 | 只能处理 Room 能推断的简单变更 |
手动 Migration |
改列名、拆表、数据转换 | 必须写 SQL,且要测试 |
fallbackToDestructiveMigration |
早期开发、缓存型数据 | 会清空用户数据,正式版本禁用 |
[!WARNING]
fallbackToDestructiveMigration()出现在 release 代码里应视为 P0 事故隐患。 如果某些表的数据可以丢(纯缓存),至少用fallbackToDestructiveMigrationOnDowngrade()或按表精确控制。
| 形态 | 数据模型 | 类型安全 | 适用 |
|---|---|---|---|
| Preferences DataStore | Preferences 键值(preferencesKey<T>()) |
运行时(key 与类型绑定) | 简单配置,迁移自 SharedPreferences |
| Proto DataStore | 你定义的 @Serializable 类 + Serializer |
编译期(有 schema) | 复杂配置对象、需要演进 |
共同优点:基于协程与 Flow 的异步 API、原子读写(事务)、可读的错误信号(IOException 而非静默失败)。
| 位置 | 路径 | 是否需要权限 | 卸载是否删除 | 适用 |
|---|---|---|---|---|
| 内部存储 | /data/data/pkg/files |
否 | 是 | 私有数据,其他 App 不可见 |
| 内部缓存 | /data/data/pkg/cache |
否 | 是(系统可自动清) | 临时文件,需自己清理 |
| 外部私有 | /sdcard/Android/data/pkg/files |
否(Android 19+) | 是 | 大文件,避免占用内部空间 |
| 公共媒体库 | MediaStore | 否(Android 10+ 分区存储) | 否 | 用户可见的照片、下载、音频 |
[!NOTE] Android 10 起强制分区存储(Scoped Storage):不能再直接用
FileAPI 写公共目录, 必须通过 MediaStore 或系统文件选择器(SAF)。想临时兼容可加android:requestLegacyExternalStorage, 但 targetSdk 提高后失效,不建议依赖。
androidx.security:security-crypto):基于流式加密(AES256-GCM),适合大文件。security-crypto 库处于维护状态,但仍是官方对 Android Keystore 的最简可靠封装,本教程第 19/25 章沿用它存 token;迁移到 Tink 或 DataStore + Keystore 自行加密属进阶练习。// 文件路径: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)
}
// 文件路径: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时才拆表。
// 文件路径: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)
}
// 文件路径: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 丢数据)。
这是第 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
}
}
// 文件路径: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 失败会让设置页永久空白。
// 文件路径: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
}
// 文件路径: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 天
}
}
// 文件路径: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
}
}
}
// 文件路径: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()
}
}
// 文件路径: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()
}
}
// 文件路径: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 —— 同步 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() |
App Inspection → Database Inspector,可实时查看表内容、直接执行 SQL,
还能勾选"Live updates"观察 Flow 查询的实时变化。# 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"
schemas/ 目录下每个版本一个 JSON,Code Review 时 diff 一下就能看出迁移是否安全。/data/data/pkg/files/datastore/*.preferences_pb,同样用 run-as 导出排查配置没生效的问题。build/generated/ksp/... 下查看最终 SQL。StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().penaltyLog().build()
)
adb shell am kill 后重进 App,验证缓存与恢复逻辑(第 03 章)。Flow,UI 只读本地;网络只写本地,不直接喂 UI(SSOT)。exportSchema = true 并提交:迁移的事故几乎都源于"不知道线上到底是哪版 schema"。onDowngrade 精确降级,不要全局兜底。data 加 catch (IOException) 兜底。EncryptedFile + Keystore,不进明文 DataStore。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 有时读网络、有时读本地 |
Flow 让数据变化自动驱动 UI,编译期校验 SQL 消灭运行时崩溃。Migration 处理复杂变更,destructiveMigration 是 release 禁区,迁移对象永不删除。SharedPreferencesMigration 一次性完成。EncryptedFile + Keystore,不进明文存储。上一章衔接:第 09 章 网络请求 的 Result<List<Article>> 正是本章 dao.replacePage() 的输入,
两者组合成了离线优先仓库。
下一章:Flow 与 suspend 贯穿了本章全部代码,但你还需要系统理解它们——进入
第 11 章 异步处理:协程与 Flow,掌握结构化并发、异常处理与 StateFlow/SharedFlow 的取舍。
基础题
DraftEntity(字段:id、articleId、content、updatedAt),写出对应 Dao,要求:按 updatedAt 倒序返回 Flow,并支持 Upsert。MIGRATION_3_4(给 articles 加一列 read_progress INTEGER NOT NULL DEFAULT 0),并在 Database Inspector 中验证旧数据未丢失。进阶题
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 卡""保存用户头像到相册"这类需求分别意味着什么?
阶段:进阶 | 预计阅读 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 绝不能吞"。StateFlow 与 SharedFlow 的差异与 replay 配置,会用 stateIn + WhileSubscribed 做生命周期感知的共享。runTest、TestDispatcher 与 Turbine 为协程与 Flow 写确定性测试(不再 sleep)。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
协程解决的是**"切过去再切回来"这件本来需要回调嵌套的事**。它的杀手锏是:用同步的书写顺序表达异步逻辑。
这是协程最重要、也最容易被忽略的概念。每个协程都有父协程,取消父协程会递归取消所有子协程。
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
结构化并发带来三条推论:
viewModelScope 在 onCleared() 时自动取消,页面销毁后不会有回调继续执行。launch 的父作用域退出前,会等它的所有子协程结束(这就是"结构化"的含义)。GlobalScope 是反模式:它不属于任何生命周期,取消不了,是内存泄漏与"页面销毁后更新 UI 崩溃"的头号来源。[!WARNING] 只有一种场景可以接受全局作用域:进程级别的、本就应该活到 App 结束的任务(如崩溃上报的兜底)。 即便如此也应自建
@Singleton class AppScope(...) : CoroutineScope,而不是直接用GlobalScope——前者至少可测试、可取消。
| 类型 | 子协程失败的影响 | 适用场景 |
|---|---|---|
Job(默认) |
子协程抛出未捕获异常 → 取消父协程与其他兄弟协程 | 一组任务必须"要么全成、要么全败" |
SupervisorJob |
子协程失败向上传播被阻断,兄弟协程不受影响 | UI 场景:一个请求失败不该连带取消其他请求 |
// viewModelScope 内部就是 SupervisorJob + Dispatchers.Main.immediate
// 所以 ViewModel 里两个并行的请求互不影响 —— 这是官方的有意设计
注意 SupervisorJob 只对子协程生效:如果你在自己的 Scope 里 coroutineScope { } 再包一层,内层仍是普通 Job,仍会连坐。
| 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查询,你直接调就行——它们已经切好了。
launch |
async |
|
|---|---|---|
| 返回 | Job |
Deferred<T>(有结果) |
| 取结果 | — | await() |
| 异常时机 | 未捕获异常立即向上抛 | 在 await() 时才抛出(若不 await 可能被静默吞掉) |
| 典型场景 | 触发即忘(刷新、上报) | 需要并发聚合多个结果 |
⚠️ async 最容易犯的错:启动了却没 await,异常被吞且任务白跑。并发聚合请优先用 coroutineScope { } 包住。
两者都是挂起函数,会等待内部所有子协程完成后才返回,区别在于失败传播:
coroutineScope:任一子协程失败 → 取消其余兄弟、自己抛出(全败)。supervisorScope:子协程失败互不影响,你可以逐个 try/catch 处理(部分成功)。协程取消不是强制杀线程,而是:job.cancel() 把协程标记为取消 → 协程在下一个挂起点(delay、withContext、await、Flow 的 emit)抛出 CancellationException。
推论:
while (isActive) 或 ensureActive()。Thread.sleep() 不响应取消,必须用 delay()。withContext(Dispatchers.IO) 里也不行——得用可中断的 API 或超时。flowchart TB
A["协程抛出异常"] --> B{"是 CancellationException?"}
B -->|"是"| C["正常取消信号<br/>必须重新抛出"]
B -->|"否"| D{"用 launch 还是 async?"}
D -->|"launch"| E["交给 CoroutineExceptionHandler<br/>或向上冒泡到根作用域崩溃"]
D -->|"async"| F["暂存在 Deferred<br/>await() 时才抛出"]
CancellationException 必须重新抛出。用 catch (e: Exception) 会把它一起吞掉,导致协程"取消不了",
ViewModel 销毁后任务仍在跑——这是最隐蔽的泄漏。launch 的异常无法用外层 try/catch 捕获(因为 launch 立即返回)。要在协程体内 try/catch,或装 CoroutineExceptionHandler。CoroutineExceptionHandler 只对 launch 生效,对 async 无效(async 的异常在 await 处抛出)。它不是"吞异常"的兜底,
更不能被用来替代正确的错误处理——它拿不到协程上下文之外的业务信息。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 状态、事件总线 |
| 维度 | StateFlow |
SharedFlow |
|---|---|---|
| 本质 | 状态容器,.value 可读可写 |
事件流,无 .value |
| 初始值 | 必须有(构造时传) | 无 |
| 新订阅者收到 | 立即收到最新值(replay=1 固定) | 取决于 replay(默认 0,收不到历史) |
| 是否去重 | 是(Any.equals 相同不发射) |
否(每次 emit 都算) |
| 适用 | 页面状态(UiState)、配置项 |
一次性事件(Toast、导航、弹窗) |
[!WARNING] 用
SharedFlow(replay = 1)当事件通道是可行的,但不要用StateFlow承载一次性事件: 旋转屏幕后重新收集,StateFlow 会把上次的 Toast 事件再推一次,用户会看到重复弹窗。
Repository 返回的冷流 Flow<List<Article>>,如果每个收集者都自己跑一遍(重查一次数据库、重发一次请求),就是浪费。
stateIn 把它变成共享的 StateFlow:
SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000)
含义:最后一个订阅者消失后,再等 5 秒才停止上游。为什么要等?因为旋转屏幕、短暂跳页会造成"订阅者瞬间消失又回来", 5 秒的宽限期能避免上游被反复启停(数据库被关了又开)。其他策略:
| 策略 | 行为 | 适用 |
|---|---|---|
Eagerly |
立即启动,永不停止 | 数据必须常驻(如登录态) |
Lazily |
首个订阅者到来时启动,永不停止 | 启动后就不该停 |
WhileSubscribed(5000) |
有订阅者才跑,停 5 秒后关闭 | UI 数据的默认选择 |
flowOn(Dispatchers.IO):只影响它上游的执行线程(下游仍在收集者的线程)。多次调用可分段切换。launchIn(scope):在指定作用域中启动收集,返回 Job,等价于 scope.launch { flow.collect() },链式写起来更顺。第三方 SDK(定位、传感器、网络回调、广播)只给回调接口。用 callbackFlow 可以把它包装成 Flow,
从而享受 Flow 的操作符与生命周期管理。两个关键约束:
awaitClose { 注销回调 }——否则收集结束后回调还在跑,必然泄漏。trySend 而非阻塞的 send(在 callbackFlow 内推荐用 trySend 避免意外挂起)。生产代码里的 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() 逐个断言事件 |
背压(Backpressure)指"生产快于消费"时的处理策略。Flow 默认通过挂起 emit 自动适配(生产者等消费者),
但当生产来自非挂起源头(回调、callbackFlow、传感器)时,就必须显式选择策略:
| 操作符 | 行为 | 适用 |
|---|---|---|
buffer(capacity) |
缓冲 N 个,满了再挂起生产者 | 允许少量堆积,提高吞吐 |
conflate() |
只保留最新值,丢弃中间 | UI 场景:只关心最新状态(如进度、定位) |
collectLatest { } |
新值到来时取消上一次的处理 | 渲染耗时,旧值已无意义 |
debounce(timeout) |
静默一段时间后才发射 | 搜索输入、窗口尺寸变化 |
另一类问题是并发数量失控:循环里 launch 一万个协程会把线程池撑爆。正确做法是用
Semaphore(信号量)限流,或用 Flow 的背压机制把批量任务变成可控的流。
Channel 是热的、带缓冲的协程间通信原语,支持多生产多消费。
绝大多数业务场景应优先用 Flow:
select)。Channel 的发送方如果忘了 close(),消费者会永远挂起——这是最常见的死锁来源。[!TIP] 判断口诀:"我想要一份可组合的数据流" → Flow;"我要一个协程往里塞、另一个协程往外取的队列" → Channel。
协程很轻(一个对象几十字节),但不是零成本。三条需要记住的性能常识:
Job 对象与调度开销。批量任务要限流。Dispatchers.IO 默认最多 64 线程,且每个 withContext(IO) 切换都是一次线程调度。
在循环里逐条插入数据库并每次切 IO,比"一次批量插入"慢一个数量级——批量优于频繁切换。withContext 是安全的挂起点,但每次切换都会经历"挂起—入队—恢复"。
不要写 withContext(IO) { 1 + 1 } 这种粒度小到不值得切换的代码。与之相对的是真正的性能杀手:runBlocking 在主线程调用会阻塞整条消息队列,
造成掉帧甚至 ANR;Thread.sleep 会占住线程不放。这两者在业务代码里应被视为禁词。
// 文件路径: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 收口,正文不写死版本号
}
// 文件路径: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)) }
}
}
// 文件路径: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,本质是业务问题:这一组数据缺一不可,还是缺了也能用? 首页主内容用前者,首页的"运营弹窗 + 消息红点"用后者。
// 文件路径: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
}
// 文件路径: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
}
// 文件路径: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。
// 文件路径: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 版本已转正。以工程中实际编译提示为准。
// 文件路径: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) }
}
// 文件路径: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
// 文件路径: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不是线程锁的替代品:它只在协程内生效,不能保护跨线程的共享可变状态。 另外不要用它包住整个网络请求——那会把并发退化成串行。它只应保护极短的临界区。
// 文件路径: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()
}
}
// 文件路径: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()
}
}
// 文件路径: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 —— 内部类持有 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 |
-Dkotlinx.coroutines.debug=on(第 01 章),线程名会带上 @coroutine#3,
看堆栈时能一眼分清是哪个协程。println("thread=${Thread.currentThread().name}, dispatcher=${coroutineContext[ContinuationInterceptor]}, job=${coroutineContext[Job]}")
Run → Debugging → Coroutines(较新版本)可查看协程树与挂起点。Profiler → CPU 中新建的 coroutine 线程名形如 DefaultDispatcher-worker-1,
主线程出现长条绿色(Running)就说明有耗时操作跑错了线程。onEach { log(it) } / onStart / onCompletion 观察发射时机,别用 println 污染生产代码。runTest 结束会报 UncompletedCoroutinesError,这恰好是它帮你抓出了"有协程没跑完"的问题——
别用 runBlocking 绕过,去修真正的原因。/data/anr/traces.txt(需 root 或用 adb bugreport),主线程堆栈卡在哪个方法就是元凶。viewModelScope / lifecycleScope;GlobalScope 视为 CR 不通过。suspend 函数要主线程安全:内部自行 withContext,调用方不必关心线程。DispatcherProvider 让测试可以替换成 TestDispatcher。CancellationException 永不吞:catch (e: CancellationException) { throw e } 写在所有 catch (Exception) 之前。coroutineScope + async,容错场景用 supervisorScope;选择依据是业务语义而非习惯。StateFlow,一次性事件用 SharedFlow(replay = 0):这是 UiState + Event 双通道的基础。stateIn(WhileSubscribed(5000)):避免旋转屏幕导致上游反复启停。Default,阻塞 I/O 用 IO:混用会让线程池被占满,排查起来非常痛苦。collectAsStateWithLifecycle,View 用 repeatOnLifecycle(STARTED)。runTest + Turbine + TestDispatcher:不写 sleep,不依赖真实时序。callbackFlow 并 awaitClose:让第三方 SDK 也享受结构化并发。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 |
GlobalScope 是反模式——它把"页面销毁后更新 UI"这类崩溃从根源消灭。Job 与 SupervisorJob 决定失败是否连坐:全成全败用 coroutineScope,部分可用用 supervisorScope;选哪个是业务决策。launch 内部 try/catch、CoroutineExceptionHandler 只兜底根协程、CancellationException 必须重抛。stateIn 共享;StateFlow 承载状态、SharedFlow(replay=0) 承载事件。runTest 跳过真实等待、TestDispatcher 消除不确定性、Turbine 让 Flow 断言可读——异步代码第一次变得像同步代码一样好测。上一章衔接:第 10 章 数据存储 里 Room 返回的 Flow<List<Article>>,
正是本章 stateIn 与 collectAsStateWithLifecycle 的上游。
下一章:这些 ViewModel、Repository、UseCase 目前都是手动 new 出来的——进入
第 12 章 依赖注入:Hilt,学习如何让框架替你装配依赖,并在测试中一键替换成 Fake。
基础题
suspend fun loadTwoPages(): List<Article>,用 coroutineScope + async 并发请求第 1、2 页并合并结果,验证总耗时接近单页耗时而非两倍。CancellationException)。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?
阶段:进阶 | 预计阅读 60 min | 代码落地模块:
:app、:core:network、:core:database、:feature:feed前置:第 02 章 工程结构与 Gradle | 第 10 章 数据存储 | 第 11 章 协程与 Flow 相关章节:第 13 章 后台任务与通知 | 第 15 章 模块化与组件化 | 第 21 章 测试策略
@HiltAndroidApp、@AndroidEntryPoint、@Inject、@Module、@Provides、@Binds、@Qualifier、@EntryPoint。@Singleton 还是 @ViewModelScoped。@BindValue / @UninstallModules / HiltAndroidRule 在测试中替换依赖,并理解 Hilt 与 Koin 的取舍。先看没有 DI 时代码的典型形态:
class FeedViewModel : ViewModel() {
// 自己 new 依赖 → 要知道 Retrofit 怎么建、OkHttp 怎么配、DAO 从哪来
private val repository = FeedRepository(
remote = FeedRemoteDataSource(RetrofitProvider.api),
local = AppDatabaseProvider.get(application).articleDao(),
)
}
问题有四:
OkHttpClient 全局唯一?靠自觉,而自觉不可靠。依赖注入的核心思想只有一句:对象不自己创建依赖,而是声明"我需要什么",由外部把它需要的东西送进来。
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
| 方式 | 写法 | 评价 |
|---|---|---|
| 构造函数注入 | 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)——生命周期不可控、无法替换、无法销毁。 它们的共同问题是:把"依赖"从编译期可见的契约退化为运行期的隐式约定。
Hilt 是 Google 在 Dagger 之上封装的 Android 专用 DI 框架。它做了三件事:
@Component、@Subcomponent。null。| 注解 | 作用 | 用在 |
|---|---|---|
@HiltAndroidApp |
生成应用级 DI 容器,必须有一个 | Application 类 |
@AndroidEntryPoint |
为该 Android 类开启注入 | Activity / Fragment / View / Service / Receiver |
@Inject constructor |
声明构造函数注入 | 你自己写的类 |
@HiltViewModel |
让 ViewModel 可被注入 | ViewModel |
@Module + @InstallIn |
提供无法构造函数注入的对象 | 第三方库、接口实现 |
@Provides |
模块内"怎么做出来" | 模块里的方法 |
@Binds |
把实现类映射到接口 | 抽象方法,比 @Provides 高效 |
@Qualifier |
同类型多实现时区分 | 自定义注解 |
@EntryPoint |
在 Hilt 不支持的类中拿到依赖 | 接口 + @InstallIn |
作用域的本质是"这个对象在谁的生命周期内保持同一个实例"。
| 作用域注解 | 对应组件 | 存活范围 | 典型对象 |
|---|---|---|---|
@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 被长生命周期对象持有)。
Hilt 的组件是全局聚合的:每个模块里 @InstallIn(SingletonComponent::class) 的 Module,在编译期会被合并进同一个依赖图。这带来两个好处与一个纪律:
:core:network 提供 OkHttpClient,:feature:feed 直接注入即可,不需要 feature 依赖 network 的实现细节。include,装配是自动的。core 模块,由 core 的 Module 提供。:app ← @HiltAndroidApp,聚合所有模块
├─ :feature:* ← 只依赖 core,注入 core 提供的接口
└─ :core:* ← @InstallIn(SingletonComponent) 提供实现
| 维度 | Hilt(Dagger) | Koin |
|---|---|---|
| 时机 | 编译期代码生成 + 编译期校验 | 运行期反射/DSL 解析 |
| 错误发现 | 编译时(缺依赖直接编译失败) | 运行时(首次注入才抛 NoBeanDefFound) |
| 构建耗时 | 需要 KSP,构建稍慢 | 无代码生成,构建快 |
| 启动耗时 | 几乎为零(代码已生成) | 需要扫描模块、建图,大工程有感知 |
| 学习曲线 | 陡(组件、作用域、Module) | 平缓(DSL 直观) |
| 与 Android 集成 | 官方深度集成(ViewModel、Worker、Compose) | 官方支持,但深度略逊 |
| 适合 | 中大型、长期维护、多人协作 | 小中型、快速迭代、KMP 多平台 |
[!TIP] 选型的实际判据:团队规模与项目寿命。5 人以上、维护 3 年以上的项目,编译期校验省下的线上事故远超学习成本; 而 KMP(Kotlin Multiplatform)项目由于要跨平台共享,通常选 Koin 或手动 DI。本书统一用 Hilt。
// 文件路径: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"的常见原因。
// 文件路径: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>
// 文件路径: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() } }
}
}
// 文件路径: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)单参签名(演进见各章说明)。
// 文件路径: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。
// 文件路径: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注解是更安全的做法:写错名字直接编译不过。
// 文件路径: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 章展开)。
// 文件路径: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() { /* 推进流程 */ }
}
// 文件路径: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()
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。
// 文件路径: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()
}
}
手段一(首选):单元测试根本不用 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 / 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 的模块都要加 |
[Dagger/MissingBinding] 或 [Dagger/DuplicateBindings],
并且自带依赖链(com.techfeed.FeedRepository is requested at ...)。从最下面一行往上读最快。build/generated/ksp/debug/kotlin/... 下有 *_HiltModules、*_Factory,
搞清楚"到底生成了什么",很多疑惑会立刻解开。Hilt:注入失败、组件未初始化等问题会在这里留痕。@Provides 的返回类型打印日志,或故意在 @Inject 处写一个不存在的依赖看报错链。./gradlew :app:kspDebugKotlin --info:KSP 阶段报错时用它看完整堆栈。@Inject constructor,@Provides 只留给第三方类。FeedRepository 接口,实现在 core 模块用 @Binds 绑定——这是可测试性的前提。@ViewModelScoped 优于 @Singleton)。@Qualifier 而不是 @Named:编译期校验能消灭拼写错误。Context 一律 @ApplicationContext:除非你明确需要 Activity 上下文(且此时作用域不能大于 Activity)。:core:network 的 Module 放 :core:network,不要堆在 :app。:core:navigation-api。@UninstallModules / @TestInstallIn。@Binds 优于 @Provides:生成代码更少、运行更快。@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() |
@HiltAndroidApp 建根、@AndroidEntryPoint 开注入点、@Module + @InstallIn 提供第三方对象。@Singleton 跨进程、@ActivityRetainedScoped 跨旋转、@ViewModelScoped 随 ViewModel;作用域越大越危险。@Binds 连接;feature 之间零依赖。@UninstallModules / @TestInstallIn 一键换 Fake。上一章衔接:第 11 章 协程与 Flow 中的 DispatcherProvider、Repository、UseCase
在真实工程里正是由 Hilt 装配的。
下一章:有些任务不该绑在页面上——进入 第 13 章 后台任务与通知,
学习用 WorkManager 做可靠的后台任务,以及通知渠道、运行时权限与推送通道抽象。
基础题
:core:datastore 加一个 DataStoreModule,用 @Provides @Singleton 提供 DataStore<Preferences>,并在 :feature:profile 的 ViewModel 中注入它。@Qualifier 注解 @Authenticated 与 @Anonymous,提供两个不同的 OkHttpClient(一个带鉴权拦截器、一个不带),并在两个 Api 中分别注入。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"往往是一个设计信号?它提示了类存在什么问题?
阶段:进阶 | 预计阅读 60 min | 代码落地模块:
:feature:push、:core:common、:core:database前置:第 03 章 应用组件与生命周期 | 第 08 章 Intent、IPC 与权限 | 第 12 章 依赖注入:Hilt 相关章节:第 10 章 数据存储 | 第 19 章 安全加固 | 第 26 章 推送、监控与收尾
PendingIntent 可变性标志、以及 Android 13+ 的 POST_NOTIFICATIONS 运行时权限。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 就是这个声明的载体。
| 方案 | 问题 | 何时可用 |
|---|---|---|
普通 Service |
后台启动被禁;进程易被杀;无约束、无重试、无持久化 | 仅前台服务(音乐、导航、通话、长连接) |
IntentService |
已弃用;串行队列 + 后台限制,等同不可用 | 无 |
JobScheduler |
API 21+ 才可用;API 差异大;无链式任务;无持久化保证(进程被杀后任务丢失) | 一般不用,WorkManager 已封装它 |
AlarmManager |
精确闹钟需 SCHEDULE_EXACT_ALARM 权限且受省电策略影响;不保证持久化 |
真正的定点提醒(日历、闹钟) |
| WorkManager | — | 默认选择:延迟可接受的后台任务(同步、上传、清理、预取) |
WorkManager 的三个核心保证:
[!WARNING] WorkManager 不适合以下场景:需要精确到秒的闹钟(用
AlarmManager)、 需要持续运行且与用户交互的任务(用前台服务)、下载大文件(用DownloadManager)。
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 --> [*]
| 类型 | 构造 | 特点 | 场景 |
|---|---|---|---|
| 一次性 | OneTimeWorkRequestBuilder |
执行一次,支持退避重试 | 同步数据、上传日志 |
| 周期 | PeriodicWorkRequestBuilder |
最小间隔 15 分钟,不保证精确时刻 | 定期拉取、缓存清理 |
| 加急 | setExpedited(OutOfQuotaPolicy) |
尽快执行,受配额限制(Android 12+) | 用户触发的即时任务(发评论、保存草稿) |
| 链式 | beginWith().then() |
顺序/并行编排,前驱输出作为后继输入 | 压缩 → 上传 → 通知 |
| 概念 | 说明 |
|---|---|
| 通知渠道(Notification Channel) | Android 8+ 引入,按用途分组;用户可在系统设置里关闭某一渠道;创建后重要性不可改 |
| 重要性(Importance) | IMPORTANCE_HIGH(响铃+悬浮)/ DEFAULT / LOW / MIN(静默折叠) |
PendingIntent |
通知点击后由系统代为发起的 Intent;Android 12+ 必须显式声明 FLAG_IMMUTABLE 或 FLAG_MUTABLE |
POST_NOTIFICATIONS |
Android 13+ 危险权限,需动态申请;未授权时通知被静默丢弃(不报错、不崩溃) |
国内 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,便于替换与测试。// 文件路径: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
}
// 文件路径: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 -->
// 文件路径: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"
}
}
// 文件路径: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 }
// 文件路径: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(),
)
// 文件路径: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 = "未开启通知权限,可在系统设置中开启"
}
}
// 文件路径: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() }
}
}
// 文件路径: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 —— 已在 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 任务的状态、约束、重试次数与执行历史——排查"为什么不执行"的第一工具。WM- 可以看到 WorkManager 的内部日志(WM-WorkerWrapper、WM-SystemJobScheduler)。doWork() 里只调用一个 UseCase,这样业务逻辑可单测、Worker 几乎不用测。CoroutineWorker 优于 Worker:前者直接给你 suspend 上下文,不再需要阻塞式 API。requiresCharging + requiresDeviceIdle 是清理类任务的合理组合,同步类任务通常只需要网络。KEEP:避免每次启动 App 都重置周期或堆积重复任务。PendingIntent 一律显式可变性标志:99% 场景用 FLAG_IMMUTABLE,需要 PendingIntent 被系统填充额外数据时才用 FLAG_MUTABLE。PushMessage 与 PushChannel,新增厂商通道不改业务代码。| 推荐 ✅ | 避免 ❌ |
|---|---|
CoroutineWorker + @HiltWorker |
后台 Service / IntentService |
enqueueUniqueWork(..., KEEP, ...) |
每次启动都 enqueue 同一任务 |
| 约束:网络 + 电量 | 约束堆满(永远不触发) |
| 逻辑抽成 UseCase,Worker 做壳 | 几百行业务逻辑写在 doWork() |
TaskStackBuilder 构建返回栈 |
裸 PendingIntent.getActivity |
FLAG_IMMUTABLE |
不指定可变性标志(Android 12+ 崩) |
| 按用途建多个渠道 | 所有通知共用一个渠道 |
发送前 areNotificationsEnabled() |
未授权也照发(静默丢弃还查不到原因) |
| 通道抽象 + messageId 去重 | 业务代码里 if (isXiaomi) ... else if (isHuawei) ... |
CoroutineWorker 写 suspend 逻辑,四类任务(一次性/周期/加急/链式)覆盖绝大多数场景,WorkInfo 可被 Flow 观察。PendingIntent 可变性(12+)、POST_NOTIFICATIONS 权限(13+),缺一个通知就静默消失。PushChannel 接口 + PushDispatcher 选路去重,让"接哪家厂商"成为配置问题而非改造问题。上一章衔接:第 12 章 依赖注入 的 @HiltWorker 与 @IntoSet 多绑定,
正是本章 Worker 注入与多推送通道装配的技术基础。
下一章:至此阶段 2 的四项能力(网络、存储、异步、DI)与后台通知已齐备,进入
第 14 章 分层架构与 MVI,把这些零件按 UI / Domain / Data 三层组织成企业级架构。
基础题
CacheCleanupWorker(CoroutineWorker),要求:只在充电且空闲时运行,删除 7 天前的缓存文件,返回删除的文件数量到 outputData。enqueueUniquePeriodicWork 提交 12 小时一次的清理任务,用 Background Task Inspector 观察它的状态变化。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 侧能感知吗?产品上应该如何设计降级体验?
阶段:企业级 | 预计阅读 60 min | 代码落地模块:
:core:model、:feature:feed、:feature:detail前置:第 03 章 应用组件与生命周期 | 第 11 章 协程与 Flow | 第 12 章 依赖注入 相关章节:第 15 章 模块化与组件化拆分 | 第 16 章 Compose 与 View 互操作 | 第 21 章 测试策略 | 第 23 章 TechFeed 总览
IView 接口与 MVVM 双向绑定各自的代价。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 在企业项目里同样会失控:
IView,稍有改动就要改接口 + 实现类 + Presenter 三处。attachView/detachView 全靠自觉,漏一次就是泄漏或空指针。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 测试这三件以往最头疼的事,会自动变成同一件事。
官方分层只有三层: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
这张图有三点容易看漏:
android.*、不 import Retrofit、不 import Room。这是它能被秒级单测的根本原因。三层的职责边界:
| 层 | 职责 | 应该有什么 | 不该有什么 |
|---|---|---|---|
| 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 节。
分层最容易被写口号。要让它真的生效,必须落在可被工具检查的位置:
feature/feed/ui/、feature/feed/domain/、feature/feed/data/ 三个包,Review 时一眼看出某个类是否放错层。ui / data 拆成独立模块,届时"UI 不许 import Retrofit"不再靠自觉,而是编译期直接报错。ui 包中出现 import androidx.room.*,CI 卡住而非口头约定。换句话说:分层不是画图,是用编译器和 CI 把边界固定下来。在团队超过 5 人之后,这一点比选哪种范式更重要。
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 按快照重新渲染(幂等)
三条铁律:
UiState 渲染出的画面必须完全一致(幂等),旋转屏幕只是重新执行这个函数。UiState 只能在 ViewModel 内部被 _uiState.update {} 修改,UI 永远只读。这三条约束带来的收益可以量化:
copy 表达式"。代价也要说清楚:MVI 比 MVVM 多一些样板(State、Event、Action 三个密封类),对极其简单的页面显得啰嗦。评判标准很简单——如果这个屏幕有三种以上的视觉状态(加载/空/错误/内容),用 MVI 一定回本。
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。
这是新手最常踩的一条:
| 对比项 | 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。
事件通道还有两个容易忽略的细节:
FeedAction.MessageShown),让 ViewModel 清掉对应状态,避免"错误提示存在于状态里但已被用户关掉"的不一致。LaunchedEffect、View 的 repeatOnLifecycle),否则页面进入后台时发出的事件会在回来时被一次性补执行。一个 UseCase 就是一个命名的业务意图,通常只有一个方法 operator fun invoke(...):
class GetFeedUseCase @Inject constructor(private val repository: FeedRepository) {
suspend operator fun invoke(page: Int, forceRefresh: Boolean = false): List<Article> = ...
}
该写 UseCase 的四类场景:
不该写 UseCase 的场景(写了就是负担):
return repository.getFeed() 的一行转发,且只有一个调用方。[!WARNING] 每增加一层,就多一层样板代码与 Review 成本。先写薄的 ViewModel,等出现第二处复用或业务变复杂时再抽取 UseCase,比一开始就全铺开要健康得多。
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 方法。
为什么不能一个类走天下?因为三种模型的变更原因完全不同:
| 模型 | 所在层 | 服务对象 | 变更原因 | 示例 |
|---|---|---|---|---|
| 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),就会被迫重新拆开。
ViewModel 是 UI 层的状态容器,它恰好做四件事:
UiState(唯一写入者)。viewModelScope 中启动协程,生命周期结束时自动取消(第 03 章)。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 章启动优化) |
把抽象落到具体,才能在 Review 时对得上直觉。用户下拉一次,完整链路是这样的:
SwipeRefreshLayout / PullToRefreshBox 回调 → UI 上报 FeedAction.Refresh(事件向上)。viewModelScope 里先 _uiState.update { copy(isRefreshing = true) },UI 立刻显示转圈——注意此时网络还没开始。suspend 的 RefreshFeedUseCase → Repository → RemoteDataSource 请求网络。Flow 推送 → Repository 的 observeFeed() 收到新列表 → Mapper 转领域模型 → ViewModel 的 onEach 里 copy(articles = new),状态向下回到 UI。adapter.submitList() / LazyColumn 差分刷新,同时 isRefreshing = false 收起指示器;ViewModel 再发一个 FeedEvent.ScrollToTop 事件。三条容易被忽略的结论:
下面以一个真实信息流页面串起全链路:UiState/Event → ViewModel → UseCase → Repository → 数据源 → 映射 → Compose UI → View/XML UI。
[!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穷尽检查,埋点与日志可以统一挂在一个入口上。
[!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 层不应出现用户可见语言,除非你在做多语言抽象)。
// 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()
}
// 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)
// 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 都不用改它。
同一个 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 与下层一行不动。
// 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
}
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 |
// 仅 debug 包:sandbox 观察每一次状态变化
fun ViewModel.debugLogState(state: FeedUiState) {
if (BuildConfig.DEBUG) Log.d("UiState", "isLoading=${state.isLoading}, size=${state.articles.size}")
}
StateFlow.value 断点:在 Android Studio 里对 _uiState.update {} 打断点(勾 Suspend: All),能看清是谁触发了这一次更新,尤其是多线程写入场景。View → Tool Windows → Layout Inspector,开启 Show Recomposition Counts,瞬间定位失控重组的位置(第 17 章会讲透)。adb shell dumpsys meminfo com.techfeed.app,看 Activities 数量是否累积;也可以在 Profiler 里看 Activity 实例的引用链。FeedRepository 写一个 Fake 实现注入 Hilt(@TestInstallIn,第 12 章),比 mock Retrofit 稳定得多。adb shell am kill <pkg> 验证 SavedStateHandle 是否真的 recover 了关键字段(比如当前 Tab、搜索词)。:feature:*:ui 包中出现 android.content.Context 字段,或禁止 UI 模块出现 :core:network 依赖(第 22 章)。一个顶层目的地 = 一个 ViewModel + 一个 UiState,不要把整 App 塞到一个 UiState,也不要一个按钮一个 Flow。
UiState 必须是不可变 data class:字段全 val,容器用 List/Map,更新一律 copy/update。
Event 与 State 物理隔离:StateFlow 放状态,SharedFlow(replay = 0) 放一次性事件,二者不混用。
UI 层不做业务判断:when 里只允许出现"怎么显示",不允许出现"是否允许收藏"这类规则——那是 Domain 层的事。
文案、单位换算、格式化放在 UI 层,让 Domain 层保持业务纯净(也便于多语言与截图测试)。
依赖永远指向抽象:UI 层只知道 UseCase / Repository 接口,实现在 Data 层由 Hilt @Binds 装配。
组件粒度宜小不宜大:@Composable 组件保持单一职责,既便于 Preview,也能让重组范围收窄(第 17 章会量化这一点)。
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 = ...(外部可写) |
val、更新靠 copy、非法组合在类型层面消灭、控件级临时状态留在 UI 内部。到这一步,单个 destination 的内部秩序已经建立。但 TechFeed 有 5 个 feature、十余个 module——这些类应该放在哪些模块里、模块之间能不能互相依赖,决定了这套架构能不能在 20 人的团队里活得下去。
下一章:第 15 章 模块化与组件化拆分 —— 把分层落到 Gradle 模块上,用 core:* / feature:* 与 :core:navigation-api 实现 feature 之间零依赖。
基础题
FeedUiState 补上一个 isLoadingMore: Boolean,并在 FeedContent(Compose)与 FeedFragment(View)两侧同时渲染出来,验证两者使用的是同一份 ViewModel。ArticleDto 增加 @SerialName("read_minutes") 字段(后端新字段),完成 DTO → Entity → 领域模型三处映射,要求 UI 层除了展示以外,不做任何单位或空值兜底处理。ToggleFavoriteUseCase,体会"两个页面复用同一段业务"的用法:先在列表页调用,再在详情页调用。进阶题
LoadHomeUseCase,用 coroutineScope { async {} + async {} } 聚合,并说明为什么把它放在 Domain 层而不是 ViewModel 里。FeedViewModel 的 init 会立即拉 data。请改成"惰性加载"(UI 首次可见时才触发),并分别给出 Compose(LaunchedEffect)与 View(repeatOnLifecycle)两侧写法。FeedViewModel 写三个单元测试:初次加载成功、刷新失败后写入 errorMessage、MessageShown 回执清空错误。要求用 Fake Repository + Turbine,不启动模拟器。思考题
suspend 的一次拉取和 Flow 的持续观察两套 API」,有人则认为「只暴露 Flow 就够了」。结合 SSOT 的原则,谈谈你倾向哪一种、代价是什么。阶段:企业级 | 预计阅读 55 min | 代码落地模块:
:app、:core:*、:feature:*前置:第 02 章 工程结构与 Gradle | 第 14 章 分层架构与 MVI 相关章节:第 12 章 依赖注入 | 第 16 章 Compose 与 View 互操作 | 第 20 章 构建配置与发布 | 第 23 章 TechFeed 总览
core:* + 5 个 feature:* + 1 个 benchmark 共 16 个 Gradle 模块图。:core:navigation-api 并实现 feature 之间零依赖跳转。api 与 implementation,能检测与治理循环依赖。一个 10 万行以内的 App 放在单模块里完全能跑,直到撞上这四堵墙:
| 问题 | 单模块表现 | 模块化后 |
|---|---|---|
| 编译速度 | 改一行也要全量 javac/kotlinc + kapt/KSP + dex;20 分钟一次构建 | Gradle 增量 + 缓存,改动只重编译受影响模块 |
| 团队并行 | 所有人改同一个 src,每天解冲突,PR 排队 |
5 条业务线 5 个 feature:*,互不干扰,OWNER 制而不是全组 Review |
| 复用与复用隔离 | 想复用某个页面只能 copy 代码,改起来到处漏 | 提取 core:* 被多个 feature 依赖,改一处全生效 |
| 边界失控 | 任何人都能 import 任何类,"这个类的主讲人是谁"没有答案 |
模块依赖构成编译期围栏,乱引用直接红 |
收益里最容易被低估的是第四条:模块图就是架构图。口头约定的分层(第 14 章)会随时间腐烂,而 Gradle 依赖表是 CI 可以机器校验的硬约束。
代价必须同步讲清楚,否则团队三个月后会反过来骂你:
@InstallIn 装配。那到底该拆多少个模块? 给三条经验法则,比给数字更有用:
一句话:模块化是手段,把地盘分清楚让人各行其是才是目的。 如果今天的团队一条业务线都跑不满,先把第 14 章的分层做好,比抢着拆模块更划算。
两种切法回答的是不同问题:
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:* 纵向承载业务。
业务边界怎么画?三个实操判据(画错了不要紧,后面还能合,怕的是不敢开始):
core: 层的契约。在此基础上,每个 feature 内部仍然保留第 14 章的三层包结构(ui / domain / data)——分层管依赖方向,模块化管编译边界与团队归属,二者解决的问题不同,不要互相替代。
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
四条依赖规则:
feature 可以依赖 core,反向一律禁止(core:network 绝不能 import feature:feed 的类)。feature 之间零依赖:feed 不许 import detail,跳转走 :core:navigation-api 的路由(见 1.4)。core 之间也要克制:core:database 不依赖 core:network;只有明显的基础依赖才允许(如各 core 都可依赖 :core:model)。: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 份。
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 分离(也称依赖倒置),三条约定:
:app(或专门的 :core:navigation-impl)实现,注入方通过 DI 拿到实例。[!IMPORTANT] 判断一个模块要不要拆 API/Impl,看它是否被多个 feature 反向需要。
:core:navigation-api就是典型:不是"数据层抽象",而是"能力抽象"。
具体实现有三种流派,本教程选契约即字符串路由(最简单、跨 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 之后,还会顺带解决三件以前各自为政的小事:
techfeed://detail/123)、App Links(https://techfeed.dev/article/123)、推送消息携带的跳转参数,都可以收敛到同一份路由解析逻辑里,在 :app 一个地方处理。Navigator,于是"跳转埋点""未登录先跳登录再回跳"这类横切逻辑只写一次(openDetail 内部先检查登录态,未登录则转 openAuth 并带上原目标)。Navigator 可以直接降级到 H5 或提示页,调用方无感知。[!NOTE] 你可能会问:国内主流方案(ARouter 等)用注解处理器生成路由表,为什么本教程不这么做?两条现实原因:其一,官方 Navigation 组件近年已补齐类型安全路由与深链能力,多一层注解处理器就是多一份 KSP 开销与迁移成本;其二,注解生成解决的是"表的自动生成",并没有解决"feature 之间零依赖"——后者靠的是
:core:navigation-api这个抽象层,与用什么框架无关。老项目已有 ARouter 的话,保留即可,把它当作:app里的路由实现,契约层照样可以抽出来。
[!TIP] 不要为了"统一"而把 App 内所有跳转都改成 URI 字符串。 同一 feature 内部的目的地跳转(比如一张 Tab 页内的二级页),直接用 Navigation Compose 的路由对象或 Fragment 事务即可;只有跨 feature 的跳转才需要走
:core:navigation-api。路由层越薄,导航图越容易读懂。
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。治理手段按成本从小到大:
:core:model / :core:common。动态特性模块(Dynamic Feature Module, DFM) 允许某些功能不打包进基础包,用户需要时由 Play 下发:
| 特性 | 说明 |
|---|---|
com.android.dynamic-feature 插件 |
产物是独立的 split APK |
splitInstallManager |
运行时请求安装某个模块,可监听下载进度 |
只能被 :app 依赖 |
feature 依赖 DFM 是不允许的(构建即失败) |
| 资源/代码需特殊处理 | 类查找要用 SplitCompat,资源需 SplitInstallHelper 或反射 |
现实建议:国内环境基本用不上(分发渠道不依赖 Play),但如果你的产品明确做出海,DFM 对首包体积的收益不可替代。替代方案是把"体量大但不常用"的功能做成远程容器(H5 / 小程序 / Flutter 容器),这与下一节的"插件化"是一脉相承的。
这一段必须知道,因为你维护的老项目很可能就在某个阶段上:
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/>大量回归组件化"]
@Route(path="/detail") 注解)。ClassLoader(DexClassLoader)+ 资源注入(AssetManager.addAssetPath)+ 四大组件占坑(Hook Instrumentation / ActivityThread)。收益是热更新与解耦包大小,代价是与 Android 系统的大量私有 API 博弈——每个 Android 大版本都要重新适配。Android 9+ 限制隐藏 API(hiddenapi 黑名单),加上商店推送补丁的限制与性能/稳定性成本,纯插件化与当年那种"黑科技热修"在新项目中已极少作为首选。今天的稳健选择是"彻底组件化 + 少量跨端容器(H5/Flutter/RN)",把"动态下发"交给 Web 容器而不是 Hook 系统。[!NOTE] 结论要明确:模块化(编译期)解决 90% 的问题,插件化(运行期)只在极特殊的合规/发版诉求下才值得。别为了酷而引入一套要与系统对抗的框架。
别指望一次性重构成功,推荐三段式,每段都可独立发布:
| 阶段 | 动作 | 收益 | 风险 |
|---|---|---|---|
| 第 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:feed 并稳定一周后再搬下一个,避免"全公司在同一个大 PR 里打架"。git mv 而非复制:保留文件历史,方便 git log --follow 追问题。模块化把"谁认识谁"打散之后,两件事必须有统一答案,否则每个 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 接口共享程度,别教条。
// 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")
: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)
}
: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)
}
// 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 可靠一万倍。
: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 章内存泄漏的主角。
// 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)
}
存量 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"的经典事故。
# 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 路由。"
}
}
}
}
}
}
}
}
模块边界定了之后,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 的边界检查插件卡住 |
./gradlew :app:dependencies --configuration debugRuntimeClasspath | head -80
配合第三方插件(com.autonomousapps.dependency-analysis)能直接输出"哪些依赖是多余的 / 应该改成 implementation"的报告。./gradlew :feature:feed:assembleDebug,避免每次验证都打包整个 App。Build → Analyze Build Performance)看配置阶段与单个 Task 的耗时,判断是环、还是 KSP 拖后腿。Project Structure → Dependencies 里按模块逐个查看,feature 行里出现另一个 feature 就是违规。--scan 生成构建扫描(企业内网可用 Develocity 自建),一次看清全模块的任务拓扑与缓存命中率。:feature:feed 里 import com.techfeed.feature.detail...,如果能编译通过,说明边界检查没生效,回去补 2.8 的插件。app/build/outputs/logs/manifest-merger-debug-report.txt,里面逐条列出了每个冲突来源模块。ui/domain/data 三层包结构。:core:navigation-api 承载跳转契约,并用 CI 插件机器校验。implementation:只有当依赖类型出现在你的公开签名(返回值/参数/父类)时才用 api。:core:model 必须是纯 Kotlin**:不依赖 Android 与任何框架,它是全工程的"通用语言"。libs.versions.toml:模块脚本里出现硬编码版本号视为 CR 不通过。推荐 / 避免对照表:
| 推荐 ✅ | 避免 ❌ |
|---|---|
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 | 一次性大重构,几百个文件冲突三天 |
:core:*)按层拆,业务(:feature:*)按特性拆;分层管依赖方向,模块化管编译边界。:core:navigation-api 是关键枢纽。api vs implementation 直接决定构建速度,约定插件与 libs.versions.toml 是多模块不失控的两把锁;环依赖靠"下移公共 + 依赖倒置 + 事件解耦 + 必要时合并"治理。到目前为止,我们假定每个 feature 内部只有一套 UI 体系。但真实项目里,同一个 App 往往 Compose 屏与 View 屏并存——它们如何互相嵌入、主题如何统一、怎么做到一次迁移不回归?
下一章:第 16 章 Compose 与 View 互操作 —— 用 ComposeView 与 AndroidView 打通两套管体系,并给出自底向上的渐进迁移路线。
基础题
:feature:profile 模块(应用 2.4 的约定插件),让它只依赖 :core:designsystem 与 :core:navigation-api,验证它无法引用 :feature:feed 的类。:core:navigation-api 中新增 ProfileNavigator 接口(openProfile(userId: String)),在 :app 中实现并 @Binds 装配,然后在 :feature:feed 里注入调用完成跳转。:core:designsystem,用 ./gradlew lint 确认没有资源重复告警。进阶题
ModuleBoundaryCheckPlugin:先在两个 feature 之间故意加一条依赖,确认构建失败;删除依赖后恢复通过。把它挂到 CI 上(第 20 章)。:feature:auth 需要跳 :feature:profile(登录成功回个人页),同时 :feature:profile 又需要拉起登录。请给出不产生环的设计:说明契约放哪个模块、谁实现、调用方怎么写。./gradlew :app:dependencyInsight --dependency room-runtime 找出 Room 被哪些模块引入,判断是否存在"feature 直接依赖 Room"的越界情况,并给出修正后的依赖表。思考题
core 依赖都改成 api,会发生什么?反过来全用 implementation 时,哪些场景会编译不过?请给出你的决策流程。阶段:企业级 | 预计阅读 50 min | 代码落地模块:
:core:designsystem、:feature:feed前置:第 05 章 Compose 基础 | 第 15 章 模块化 相关章节:第 17 章 性能优化 | 第 24 章 TechFeed 信息流
ComposeView 把 Compose 组件嵌入 XML/Fragment 页面,也会用 AndroidView 把 View 嵌入 Compose。现实中的存量项目不可能一夜重写。迁移期会长期处于双体系共存状态:
| 场景 | 需要的桥 |
|---|---|
| 存量 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 对象树"]
mutableStateOf),要么提升到 ViewModel,绝不两边各存一份。ComposeView 是 View,遵守 View 体系生命周期;AndroidView 是 Composable,遵守组合生命周期。边界处的回收/重建最易出 bug。// 文件路径: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>
// 文件路径: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+)
)
// 文件路径: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 = { /* 幂等同步 */ },
)
}
互操作时最容易被忽略的是主题不通。第 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 主题。自研桥接仅适合令牌极少的项目。
// 文件路径: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() |
setViewCompositionStrategy 处打断点,观察回收复用时机是否与预期一致。WebView 实例个数,离开页面后应为 0(配合 onRelease)。迁移前,很多项目用"Fragment 嵌 Fragment"实现区块复用(childFragmentManager)——生命周期错综复杂, 事务嵌套极易崩溃。互操作出现后,这类场景的正确演进路径是:
Fragment 嵌 Fragment(旧)
→ 单 Fragment + 内部区块组件化(View/Compose 均可)
→ Compose 区块 + LazyColumn 组合(目标态)
Fragment 不再是"区块容器",只承担"页面容器"职责;区块复用交给组件层。
AndroidView 三件套:factory(创建)、update(幂等同步)、onRelease(清理)。ComposeView 在复用容器(RecyclerView/ViewPager)中必须用 DisposeOnDetachedFromWindowOrReleasedFromPool。下一章:互操作只是企业级的入口。真正的硬仗是性能——启动、渲染、内存、包体与 ANR 的系统治理。
基础题
ArticleHeader 组件,并验证深浅色切换正常(提示:主题桥接)。AndroidView 在 Compose 页面中嵌入一个 EditText,实现双向同步:Compose 输入框与 EditText 内容一致。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(组合)各能恢复什么?两者叠加时谁先恢复?
阶段:企业级 | 预计阅读 80 min | 代码落地模块:
:app、:core:*、:feature:*前置:第 05 章 Compose 基础 | 第 14 章 分层架构 | 第 16 章 互操作 相关章节:第 18 章 稳定性与崩溃监控 | 第 20 章 构建与发布
onTrimMemory。没有指标就没有优化。先定口径,再谈优化:
| 指标 | 定义 | 行业参考线 |
|---|---|---|
| 冷启动(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] 先建立测量,再动手优化。没有线上的指标采集与基线,"优化"很快退化成"玄学 + 回归"。
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["用户可交互"]
启动耗时的大头几乎总在这四处:
Application.onCreate 里的同步初始化(SDK 一锅端)。onCreate 做磁盘 I/O。Compose 通过比较参数决定跳过(skip)还是重组。可跳过的前提是参数"稳定":
| 类型 | 稳定性 |
|---|---|
| 基本类型、String、函数类型 | ✅ 稳定 |
全 val 属性、且属性类型都稳定的 data class |
✅ 稳定(推断得出) |
List / Map / MutableState 泛型接口 |
⚠️ 默认不稳定(实现可变) |
来自 Java 的类、含 var 的类 |
❌ 不稳定 |
不稳定参数会导致每次重组都重跑该函数——即使数据没变。
主线程被阻塞超过阈值,系统弹"应用无响应":
| 场景 | 阈值 |
|---|---|
| 输入事件 5 秒无响应 | 5s |
| 前台 Service 20 秒 | 20s |
| 广播前台 10 秒 / 后台 60 秒 | 10s/60s |
| ContentProvider 发布超时 | 10s |
根因 90% 是主线程做了不该做的事:磁盘 I/O、锁等待、Binder 同步调用、大量 JSON 解析、GC 风暴。
// 文件路径: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%
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() }
// 教学示例(未随 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 的头号元凶就是"不指定尺寸加载大图"。
// 文件路径: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 调用高频执行 → 缓存结果或子线程
// 文件路径: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 对比上个版本体积并卡口 |
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 工具链:
| 症状 | View 体系根因 | Compose 体系根因 |
|---|---|---|
| 列表滚动卡顿 | bind 里建对象/层级深/图片大 | item 参数不稳定/无 key/读取大状态 |
| 界面刷新慢 | 全量 notifyDataSetChanged |
无 key 导致整体重组 |
| 页面切换顿挫 | 布局过深测量贵 | 动画期间重组风暴 |
| 静态页面也掉帧 | 过度绘制 | 稳定性不足的频繁重组 |
两套体系的性能思维同源:少做事、做对的事、增量地做。
onTrimMemory 释放缓存。下一章:性能之外,线上质量的另一半是稳定——崩溃与 ANR 如何被采集、还原、告警与止损。
基础题
adb shell am start -W 测量 TechFeed 冷启动基线,记录三组数据;再开启 Baseline Profile 对比。进阶题 4. 用 Layout Inspector 找出信息流页重组次数最多的组件,用"最小参数 + 稳定 key"优化,前后对比重组计数。 5. 用 Macrobenchmark 给启动建立回归基线,写一个本地脚本:每次发版跑一次并输出与上版的对比表。 6. 治理包体积:对 release 包做 R8 + 资源压缩 + ABI 拆分,用 APK Analyzer 输出体积构成饼图数据。
思考题
7. 为什么"实验室优化了 30%"在线上可能只有 5%?列出至少三个影响因素与对应的验证方法。
8. @Stable 注解是"承诺"而不是"证明":如果你给一个内部含 var 的类标了 @Stable,会埋下什么雷?
9. 包体积预算卡口(超了不让合入)会不会伤害业务迭代速度?如何设计阈值与例外流程才算合理?
阶段:企业级 | 预计阅读 55 min | 代码落地模块:
:core:crash、:core:common、:app前置:第 15 章 模块化与组件化拆分 | 第 17 章 性能优化 相关章节:第 19 章 安全加固 | 第 20 章 构建配置与发布 | 第 26 章 推送、监控与收尾
Thread.setDefaultUncaughtExceptionHandler 采集 Java/Kotlin 崩溃,理解 native 崩溃与 ANR 的采集差异。mapping.txt 符号表的关系,能把混淆堆栈还原成可读堆栈,并在 CI 中自动上传。CrashReporter 抽象层,接入 Sentry / Firebase Crashlytics / Bugly 之一,并说明选型维度。"稳定性"不是"不崩溃",而是用户能不能完成他想做的事。一个 App 崩溃率 0.1% 但启动后白屏 3 秒,用户体感依然是"这应用坏了"。所以企业级项目通常把稳定性拆成三层:
| 层次 | 表现 | 典型归因 | 用户感知 |
|---|---|---|---|
| 崩溃(Crash) | 进程抛未捕获异常后终止 | 空指针、越界、状态错乱、OOM(Out Of Memory,内存溢出) | "闪退" |
| 无响应(ANR) | 主线程被阻塞超过阈值,系统弹"无响应"对话框 | 主线程做 I/O、锁竞争、Binder 调用阻塞 | "卡死、点不动" |
| 不可用(Failure) | 启动失败、核心链路失败、白屏 | 启动期死锁、依赖服务不可用、数据损坏 | "打不开、刷不出" |
第 17 章解决的是"慢",本章解决的是"崩"和"死"——但两者的治理闭环是同一套:发现 → 定位 → 修复 → 验证 → 防复发。
指标一旦口径不一致,团队就会陷入"你的 0.3% 和我的 0.5%"这类无意义的争论。先把口径钉死:
| 指标 | 公式 | 口径说明 | 参考水位 |
|---|---|---|---|
| 崩溃率(次数口径) | 崩溃次数 / 会话数 | 一次会话内多次崩溃多次计数,用于衡量"崩溃有多频繁" | ≤ 0.2% |
| 崩溃率(用户口径) | 崩溃用户数 / 日活用户数 | 同一用户多次崩溃只算一次,用于衡量"影响面" | ≤ 0.5% |
| ANR 率 | ANR 次数 / 会话数 | 与崩溃同分母,才能和崩溃率直接比较 | ≤ 0.1% |
| 启动失败率 | 启动失败次数 / 冷启动次数 | "冷启动后 30s 内发生崩溃或 ANR,且未到达首页渲染完成"记一次 | ≤ 0.3% |
| 崩溃影响留存 | 崩溃用户的次日留存 − 全体留存 | 最能说服业务方投入治理的指标 | 差值 ≤ 2pp |
三个必须写进文档的计算口径细节:
[!TIP] 计算口径写进 ADR(Architecture Decision Record,架构决策记录)和监控平台的看板注释里(第 22 章)。 一个团队最贵的成本不是算错,而是每次复盘都要重新解释一次口径。
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
关键点:采集端只负责"可靠地把现场保存下来",不要在上报里做复杂逻辑。崩溃那一刻进程状态已经不可信——内存可能即将耗尽、主线程可能已经卡死、磁盘可能写不进去。所有采集代码都要按"随时可能失败"来写。
| 类型 | 信号 | 采集方式 | 难点 |
|---|---|---|---|
| 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 的收益极低且极易引入二次崩溃。
ANR 的本质是主线程消息队列被堵住。系统的判定阈值大致是:
| 场景 | 超时阈值 |
|---|---|
| 输入事件 / 按键分发 | 5s |
BroadcastReceiver(前台 / 后台) |
10s / 60s |
Service 生命周期(前台 / 后台) |
20s / 200s |
ContentProvider 调用 |
10s |
采集手段有三代:
Handler 投递一个"心跳",5s 后检查心跳是否被执行;未执行则认为 ANR,抓取主线程堆栈。SIGQUIT(BlockCanary / Matrix 的做法):系统判定 ANR 时会向进程发 SIGQUIT,用 signal hook 捕获它即可拿到最准的时机。缺点是需要 native 代码,且不同 ROM 有差异。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+)。
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
三条铁律:
mapping.txt 与构建一一对应:由 versionCode + versionName + variant + 构建时间戳共同确定。丢了它,那个包的崩溃就永久不可读。SourceFile 与 LineNumberTable,否则还原出来只有类名没有行号(第 19 章展开 R8 规则)。采集只是手段,止损才是目的。三板斧的边界:
| 手段 | 生效速度 | 覆盖范围 | 风险 |
|---|---|---|---|
| 功能开关(Kill Switch) | 秒级(下次拉取配置) | 只能关闭"已埋开关"的功能 | 开关本身要有兜底值;配置服务挂了要默认安全 |
| 灰度发布 | 小时级(重新分发) | 阻止新版本扩散到全量 | 只对新版本有效,老版本仍在崩 |
| 热修复 | 分钟级 | 理论上可修任意代码 | 合规风险最高:国内应用市场对代码下发有明确限制,Google Play 对动态代码加载亦有政策约束 |
[!WARNING] 热修复(Tinker / Sophix 等)属于"运行时加载可执行代码",在多个应用市场的审核规则中属于高风险能力。 落地前必须过法务与合规评审,并且只用于紧急止损,不用于常规需求发布。常规需求走正常发版流程(第 20 章)。
: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收口。
// 文件路径: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
}
// 文件路径: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)
}
// 文件路径: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 初始化...
}
}
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?,
)
崩溃堆栈告诉你"死在哪里",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()
}
}
// 文件路径: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 / 后端 | 全端统一能省掉大量对齐成本 |
// 文件路径: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/
// 文件路径: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, // 内存压力大,默认关
)
}
}
// 文件路径: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):匿名内部类 + 手写字符串拼接,且常忘记链式调用原 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 条)+ 服务端采样 |
| 热修复上线后被应用市场警告 | 动态代码下发违反市场政策 | 热修复仅做紧急止损并过合规评审;常规改动走发版 |
throw RuntimeException("manual crash test"),确认平台能收到、能还原行号。每接一次监控、每次升级 SDK 都要验一次。# 只看崩溃与 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"
adb shell setprop dalvik.vm.heapsize 64m 逼出内存边界。# 用 R8 自带的 retrace 把混淆堆栈还原
./gradlew :app:retraceRelease --stacktrace-file ./obfuscated.txt
# 或直接用 SDK 工具
java -jar $ANDROID_HOME/tools/proguard/lib/retrace.jar mapping.txt obfuscated.txt
mapping.txt 里 grep 它,能找到说明匹配;找不到说明版本对不上。Application.onCreate 的第一行:启动失败率的主要来源就是启动期崩溃,装晚了等于没装。:core:crash 的接口,换 Sentry / Crashlytics / Bugly 只改 app 模块。
@Binds abstract fun bind(impl: SentryCrashReporter): CrashReporterFeedViewModel 里直接 Sentry.captureException(e)Thread.getDefaultUncaughtExceptionHandler() 的返回值必须保存并透传。
UncaughtCrashHandler(reporter, store, delegate = current)setDefaultUncaughtExceptionHandler { t, e -> report(e) }runCatching 包裹,且只做"落盘"这一件事。breadcrumb("navigation", "enter:detail", mapOf("id" to articleId))setBeforeSend / setBeforeBreadcrumb 里抹掉 URL query、手机号、Token——这是隐私合规的硬要求(第 19 章)。mapping.txt 与产物在同一个 CI job 内上传并归档,绝不允许"发版后手动传"。UncaughtExceptionHandler(注意链式透传与协程例外),ANR 用 WatchDog + ApplicationExitInfo,native 崩溃交给平台 SDK。mapping.txt 与 versionName+versionCode 一一对应,由 CI 自动上传并归档。至此,企业级四部曲的"稳定性"这一环合上了:性能优化让 App 更快(第 17 章),崩溃监控让 App 更稳(本章)。下一章解决"更安全"——代码混淆、数据加密、网络安全与防逆向。
下一章:进入安全加固,从 R8 混淆规则写到 Keystore 加密、证书绑定与组件防导出。
基础题
:core:crash 模块,实现 2.2 节的 CrashReporter 接口与 NoOpCrashReporter,在 :app 中通过 Hilt 的 @Binds 装配,并在 debug 包里用隐藏入口触发一次崩溃,确认能收到日志。AnrWatchDog,在主线程 Thread.sleep(8000) 模拟 ANR,验证能抓到主线程堆栈并打印出阻塞点。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",这个目标合理吗?你会用什么指标来替代它说服产品方?
阶段:企业级 | 预计阅读 60 min | 代码落地模块:
:core:datastore、:core:network、:core:common、:app、:feature:auth前置:第 10 章 数据存储 | 第 18 章 稳定性与崩溃监控 相关章节:第 09 章 网络请求 | 第 20 章 构建配置与发布 | 第 26 章 推送、监控与收尾
keep 的粒度代价、资源压缩与行号保留之间的取舍。EncryptedSharedPreferences、EncryptedFile,以及生物识别(Biometric)认证的接入姿势。network_security_config(网络安全配置):禁明文、证书绑定(Certificate Pinning)、debug-overrides。做安全加固前先搞清楚"敌人是谁"。移动端 App 有三个特殊性:
据此,加固的优先级应该是:合规与数据安全 > 边界入口防护 > 抗逆向。抗逆向永远只能"提高门槛",做不到"阻止"。
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,能指定成员就别写 { *; }。
| 数据 | 方案 | 说明 |
|---|---|---|
| 加密密钥 | Android Keystore | 密钥材料不进应用进程,由系统/安全硬件持有,无法被导出 |
| Token、用户偏好 | EncryptedSharedPreferences |
键与值分别用 AES-256-GCM 与 AES-256-SIV 加密 |
| 大文件、离线快照 | EncryptedFile |
基于流式加密,适合大于几 KB 的内容 |
| 密码 | 不存 | 只在登录时提交,服务端加盐哈希 |
| 生物识别凭据 | Biometric + Keystore 绑定 | 密钥需用户生物验证后才能使用(setUserAuthenticationRequired) |
Keystore 的核心价值不是"加密更强",而是密钥本身不出现在应用内存里:你把密钥生成进 Keystore,之后只能拿到一个"句柄"去做加解密,永远拿不到原始密钥字节。反编译、dump 内存都拿不到。
network_security_config.xml 是声明式的网络安全策略,四个能力:
cleartextTrafficPermitted="false",杜绝 HTTP 被劫持。debug-overrides:debug 变体允许自签 CA / 明文,方便抓包,但 release 绝不能带。trust-anchors 限定:只信任系统 CA,不信任用户安装的 CA。[!WARNING] 证书绑定一旦上线就绑死了证书。证书到期或轮换时,未更新的老版本客户端会全部连不上——这是真实的线上事故来源。 必须同时绑定主证书与备份证书的指纹,并确保备份证书的私钥已经就绪。
| 漏洞 | 原理 | 防线 |
|---|---|---|
| 组件越权导出 | 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 |
| 手段 | 效果 | 代价 | 合规提示 |
|---|---|---|---|
防调试检测(isDebuggerConnected / TracerPid) |
抬高动态分析门槛 | 可被绕过;有误判风险 | 无 |
| 签名校验(本地/服务端比对) | 阻止重打包分发 | 客户端校验可被 hook 掉 | 无 |
| 完整性校验(APK 哈希、类加载校验) | 发现篡改 | 维护成本高 | 无 |
| 第三方加固(加壳、DEX 虚拟化) | 显著提高静态分析成本 | 启动变慢、崩溃率上升、兼容性问题、部分市场不接受 | 需评审 |
| 动态代码加载(热修复、插件化) | 运行时改变行为 | 安全风险高 | 多市场受限,见第 18 章 |
[!IMPORTANT] 加固与热修复都会改变"运行时加载代码"的形态,在国内多家应用市场属于需要申报或明令受限的能力。 上线前必须过合规评审,并在灰度阶段重点观察启动耗时与崩溃率的变化。
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。
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()
}
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(),
)
}
}
<!-- 文件路径: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
<!-- 文件路径: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)
}
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)
}
}
// 文件路径: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,
)
}
<!-- 文件路径: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 / 加密存储) |
|---|---|---|
| 混淆工具 | 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 章) |
# 查看某个类为什么被保留(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
./gradlew :app:retraceRelease --stacktrace-file ./obfuscated.txt
# 列出所有导出的组件(安全评审必做)
aapt2 dump xmltree app/build/outputs/apk/release/app-release.apk AndroidManifest.xml | grep -B3 exported
# 检查是否残留明文 HTTP 配置
grep -r "usesCleartextTraffic" app/src/main/
mitmproxy 装用户 CA 抓 debug 包应能成功;抓 release 包应直接失败——失败才说明绑定生效。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 就是没接上
EncryptedFile 产物的内容,确认是密文。adb shell am start -n 尝试直接启动内部 Activity;adb shell am broadcast 尝试触发内部 Receiver——成功的都说明 exported 配错了。-keepclassmembers 优先于 -keep,指定成员优先于 { *; }。
-keepclassmembers class com.techfeed.core.model.** { <fields>; }-keep class com.techfeed.** { *; }-keepattributes SourceFile,LineNumberTable 是 release 可维护性的底线。MasterKey.Builder(context).setKeyScheme(AES256_GCM)val key = "my-secret-1234567890abcdef".toByteArray()exported="false" 是默认值之外的"显式声明",评审时逐个过。
android:exportedallowFileAccess、allowContentAccess、allowFileAccessFromFileURLs、allowUniversalAccessFromFileURLs。setBeforeSend 过滤,禁止打印 Token/手机号。keep 越宽收益越低;保留 SourceFile,LineNumberTable 才能还原堆栈。EncryptedSharedPreferences、大文件用 EncryptedFile、密码永不落盘。network_security_config 禁明文、限定信任锚、证书绑定(含备份指纹),debug-overrides 只服务调试。exported=false、Intent 显式指定包名、deeplink 白名单 + autoVerify、WebView 关 file 访问并限制 JS 桥。安全加固让 App "更难被攻破"(本章),接下来要解决的是"怎么稳定、可重复地把它交付出去"。
下一章:进入构建配置与发布流程——构建变体、签名管理、AAB 与 APK 的取舍,以及一条从提交到灰度的 CI/CD 流水线。
基础题
release 构建类型开启 R8 与资源压缩,故意不加任何 -keep,跑一遍 App 找出崩在哪里,再逐个补上最小化的规则并说明为什么必须保留。TokenStorage,然后用 adb shell run-as 查看 shared_prefs,确认磁盘上是密文。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. 证书绑定提高了安全性,却也引入了"老版本失联"的风险。如果让你设计一套可演进的方案,你会如何在安全与可用性之间取舍?
阶段:企业级 | 预计阅读 60 min | 代码落地模块:
:app、:core:*、:feature:*前置:第 02 章 工程结构与 Gradle | 第 19 章 安全加固 相关章节:第 15 章 模块化与组件化拆分 | 第 18 章 稳定性与崩溃监控 | 第 22 章 团队协作规范
productFlavors 与 flavorDimensions、manifestPlaceholders、buildConfigField / resValue 的用法边界。splits 控制 ABI/密度分包。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。
当"风味"本身有两类互不相关的含义时(既要区分环境,又要区分渠道),就要用维度:
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)直接裁掉,这是中大型项目的必做优化。
| 方式 | 注入到 | 适用场景 | 代价 |
|---|---|---|---|
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 章)。
| 概念 | 是什么 | 谁来持有 |
|---|---|---|
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 一起生成 |
| 维度 | APK | AAB |
|---|---|---|
| 内容 | 完整可安装的包 | 发布格式,包含所有资源,不含最终安装形态 |
| 安装 | 可直接安装 | 不能直接安装,需 bundletool 转成分发 APK |
| 体积 | 全量资源都打进去 | Google Play 按设备(ABI/密度/语言)下发最小集,通常省 15%–35% |
| 分发 | 任意渠道 | Google Play 强制;国内渠道多数仍需 APK |
| 调试 | — | 用 bundletool build-apks 生成本地测试包 |
结论:Google Play 走 AAB;国内渠道与内测分发走 APK。所以企业级项目往往两套产物都要出,CI 里配两个 job。
| 字段 | 作用 | 规则 |
|---|---|---|
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"这个高频事故。
三个容易踩坑的细节:
versionCode 上限是 2^31-1(约 21 亿)。用 major*10000 + minor*100 + patch 这类编码方案时,major 超过 21 万就会溢出——虽然那一天大概率不会到来,但用 CI 构建号递增也要注意它单调增长的上限。versionCode(或按 ABI 有确定偏移)。同一个应用在不同市场出现不同 versionCode,会让"哪个版本更新"这件事变得无法判断,也会让监控平台按版本聚合时产生分裂。versionName 要能被用户复述。客服收到反馈"我的版本是 1.2.0-beta.3 (4567)"时,最好能一眼对应到 CI 的第 4567 次构建,所以建议在 versionName 后缀带上构建号,或至少在"关于"页里同时展示两者。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)绝不用热修复;必须热修的,也要在下一个正式版本里把补丁逻辑正式合入并重新发版,避免补丁长期游离在版本体系之外。
[!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/
# 生成正式签名密钥(一次性操作,生成后妥善保管,丢失 = 应用无法更新)
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"
<!-- 文件路径: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 章)。
非 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
}
# 构建 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
# 文件路径:.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
// 文件路径: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 }
}
}
# 文件路径: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)
- [ ] 应用商店文案、截图、更新说明已准备
默认产物名是 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(" ")
// 文件路径: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")
}
}
// 旧写法: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 指定明确路径;符号表上传必须在归档前 |
./gradlew :app:tasks --all | grep -i assemble
./gradlew :app:sourceSetsProdHuaweiRelease
# 或者打印 BuildConfig 生成结果
cat app/build/generated/source/buildConfig/prodHuawei/release/com/techfeed/app/BuildConfig.java
apksigner verify --verbose --print-certs app-release.apk
aapt2 dump badging app-release.apk | grep -E "package|application-label|uses-permission"
./gradlew :app:analyzeProdGooglePlayRelease # AGP 自带的 APK Analyzer 报告
ORG_GRADLE_PROJECT_versionCode=999 ./gradlew assembleRelease 模拟 CI 注入,验证版本号逻辑。./gradlew :app:assembleDevGooglePlayDebug,不要动不动 assembleRelease 全变体。src/huawei/java,不是 src/huaweiFlavor/java),且维度名没写错。variantFilter 裁剪。
flavorDimensions += listOf("env", "channel") + 过滤无用组合keystore.properties + .jks 双保险进 .gitignore,CI 用 Secrets 注入。versionCode 由 CI 注入:彻底消灭"忘了改版本号"这类低级事故。enableV3Signing = true 为未来密钥轮换留后路。buildConfigField:它是明文常量,反编译即可见。
build.gradle.kts 是代码,改签名、改 flavor 与改业务逻辑同等风险。variantFilter 控制爆炸。manifestPlaceholders 最轻、buildConfigField 给代码、resValue 给 XML;都不能放敏感信息。splits 分包要配套独立 versionCode。到这里,"企业级"阶段正式收官:架构(第 14 章)→ 模块化(第 15 章)→ 性能(第 17 章)→ 稳定性(第 18 章)→ 安全(第 19 章)→ 发布(本章)形成了完整闭环。接下来进入团队协作与测试——把这套能力在多人协作下稳定地复现出来。
下一章:测试策略。从测试金字塔讲起,覆盖 JVM 单测、协程与 Flow 测试、ViewModel / Repository / Room / Hilt 测试,以及 Compose UI Test 与 Espresso 的双体系对照。
基础题
env(dev/staging/prod)× channel(googlePlay/huawei)两个维度,用 variantFilter 裁掉 prodDebug 与 dev 下的非 googlePlay 渠道,用 ./gradlew :app:tasks --all 验证变体数量。keytool 生成一份测试 keystore,配置 keystore.properties(并加进 .gitignore),构建 release 后用 apksigner verify 确认 v2/v3 已生效。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 与"服务端下发开关"都能控制功能,二者的适用边界在哪里?如果一个开关需要"秒级关闭线上崩溃功能",你会怎么设计?
阶段:协作与测试 | 预计阅读 65 min | 代码落地模块:
:core:testing、:core:database、:feature:feed、:feature:detail前置:第 11 章 异步处理:协程与 Flow | 第 12 章 依赖注入 | 第 14 章 分层架构与 MVI 相关章节:第 20 章 构建配置与发布 | 第 22 章 团队协作规范 | 第 24 章 信息流与详情
runTest、StandardTestDispatcher、advanceUntilIdle 与 Turbine 测试协程和 Flow。MainDispatcherRule、SavedStateHandle)、Repository、Room(内存数据库 + Migration)与 Hilt 替换。semantics、testTag 与双体系对照;知道覆盖率与截图测试的定位。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 测试。
| 方案 | 是什么 | 优点 | 缺点 |
|---|---|---|---|
| Fake(假实现) | 一个能跑的简化实现(内存版 Repository) | 行为真实、多个测试共享、重构友好 | 需要维护一份实现 |
| Mock(模拟对象) | 用 MockK/mockito 声明"调用 X 返回 Y" | 无需实现,验证交互很方便 | 与实现细节强绑定,重构必改测试 |
| Stub | 只提供固定返回值 | 最简单 | 无法验证交互 |
原则:依赖用 Fake,交互用 Mock。也就是说——"被测对象要从别人那拿数据"时用 Fake 数据源;"被测对象是否正确调用了某个副作用接口"(如埋点、导航)时才用 Mock 去 verify。
一个直观的判断:如果你的测试里出现大量 every { a.getX() } returns y 且被测逻辑只用了其中一两个返回值,那这个 Mock 就是在复述实现,应该换成 Fake。
遇到"被测对象依赖了别的东西"时,按下面顺序选,不要一上来就 Mock:
Article 这种模型类)——测的是真东西,最可信。FakeFeedRepository、内存数据库、Fake 时钟)——行为真实、多个测试共享。顺带一条命名约定:测试名描述"在什么条件下应该发生什么",而不是描述被测方法名。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()
}
生产代码里 viewModelScope.launch 跑在真实主线程上,测试里没有 Android 主线程,直接跑会报 Module with the Main dispatcher had failed to initialize。解决方案有两个层次:
MainDispatcherRule:把 Dispatchers.Main 替换成测试调度器(JUnit TestWatcher)。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 本身还有一层保护:测试结束时若有未完成的协程,会直接失败——这能帮你抓出"忘记取消的作用域"这类真实缺陷。
覆盖率(Coverage)回答的是"哪些代码被执行过",不回答"这些代码是否被正确验证"。一个只有 assertNotNull(result) 的测试能把覆盖率刷到 90%,却在业务逻辑写反时依然全绿。
正确使用方式:
| 触发 | 跑什么 | 时间预算 |
|---|---|---|
| 本地保存/提交前 | 改动模块的单测 | 秒级 |
| PR | 全量单测 + 静态检查 | < 5 min |
| 合入主干 | 单测 + 集成测试 + 关键路径 UI 测试 | < 20 min |
| 夜间 | 全量 UI 测试 + 基准测试 + 截图测试 | 不限 |
: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()
}
}
// 文件路径: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)引入。原则是不要为了共享而共享。
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)
}
}
// 文件路径: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()
}
}
// 文件路径: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" }
}
// 文件路径: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
}
// 文件路径: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
// 文件路径: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,否则会出现"偶发找不到节点"的脆测试。
截图测试(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(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 测试放合入后/夜间 |
--tests 过滤:./gradlew :feature:feed:testDebugUnitTest --tests "*FeedViewModelTest*"
--info 或看 HTML 报告:./gradlew :feature:feed:testDebugUnitTest --info
# 报告位置:模块/build/reports/tests/testDebugUnitTest/index.html
composeRule.onRoot().printToLog("DEBUG")
看 Logcat 里 DEBUG tag 的输出,节点是否合并、文案是否被截断一目了然。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
-Dkotlinx.coroutines.debug=on(第 01 章),在测试里能看到协程编号,排查挂起点很方便。awaitItem() 之间打 println(state),确认事件序列与预期是否一致,往往一眼看出多/少发了什么。./gradlew recordRoborazziDebug 重录基线;无脑重录等于取消截图测试的意义。FakeFeedRepository 内存实现,多个测试共用every { repo.getX() } returns ... 复述实现`refresh 失败时 error 有值` 比 testRefreshFail 可读得多,失败时一眼知道坏了什么。testTag 作为补充。MainDispatcherRule 换掉 Main、runTest 提供虚拟时间、StandardTestDispatcher + advanceUntilIdle 控制时序;Flow 断言用 Turbine。测试保证了"改得动",而协作规范保证"多人一起改也不会乱"。下一章把视角从代码转向团队。
下一章:团队协作规范——代码规范与静态检查落地、分支模型、提交规范、Code Review 清单、ADR 与 CODEOWNERS。
基础题
FeedViewModel 写三条单测:加载成功、加载失败、重复刷新只发一次请求。要求用 FakeFeedRepository 与 MainDispatcherRule,全部跑在 JVM 上。StateFlow<List<Article>> 写测试,覆盖"初始值 → 加载中 → 成功"三态,并处理"未消费事件"报错。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 测试",你会如何与他协商出一个双方都能接受的方案?
阶段:协作与测试 | 预计阅读 55 min | 代码落地模块:工程根目录、
build-logic、.github/、:core:*前置:第 15 章 模块化与组件化拆分 | 第 20 章 构建配置与发布 | 第 21 章 测试策略 相关章节:第 14 章 分层架构与 MVI | 第 23 章 TechFeed 总览 | 第 26 章 推送、监控与收尾
.editorconfig + ktlint + detekt + Android Lint 基线,并用 Git Hook 与 CI 双卡口。团队协作中最大的时间黑洞不是技术难题,而是无休止的风格争论:空几格、要不要 it、常量放哪。这些争论每一分钟都在消耗团队的注意力,且永远没有正确答案。
解法是分三层处理:
| 层级 | 处理什么 | 手段 |
|---|---|---|
| 自动判定 | 格式、导入顺序、命名、明显的代码坏味道 | .editorconfig + ktlint + detekt(机器说了算) |
| 团队约定 | 分层边界、模块依赖方向、错误处理方式 | ADR + 模块 README(一次性讨论,写下来) |
| 人来判断 | 业务正确性、架构合理性、命名是否表意 | Code Review(人只在真正需要人的地方发言) |
凡是机器能判定的,就别让人做。 这是本章所有配置的出发点。
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 上不出事"。
| 模型 | 分支结构 | 优点 | 缺点 |
|---|---|---|---|
| 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 变成一次性读几千行。
格式:<type>(<scope>): <subject>,例如 feat(feed): 支持下拉刷新。
| type | 含义 | 是否触发发版 |
|---|---|---|
feat |
新功能 | minor |
fix |
修复缺陷 | patch |
refactor |
重构(无行为变化) | 否 |
perf |
性能优化 | patch |
test |
测试 | 否 |
docs |
文档 | 否 |
build / ci |
构建与流水线 | 否 |
chore |
其他杂项 | 否 |
破坏性变更在 type 后加 !,或在 footer 写 BREAKING CHANGE:。提交信息的价值在 6 个月后:当你要查"这个崩溃是哪个改动引入的",一份可读的提交历史比任何文档都管用。
Review 的时间是有限的,按下面顺序分配注意力,前面的没看完就不要纠结后面的:
.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"
# 文件路径:.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")
}
}
}
# 文件路径: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
// 文件路径: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 都只报新增问题;基线文件要提交进版本库
# 文件路径: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绕过——规范一旦被普遍绕过,就比没有规范更糟。
# 文件路径:.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
# 文件路径:.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 -->
# 文件路径:.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
# 文件路径: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 批准)。
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
// 文件路径: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 有价值,// 设置标题 没有。TODO 必须带责任人或 issue:// TODO(#123): 分页加载失败后需要重试。| 维度 | 老做法 | 现在 |
|---|---|---|
| 规范载体 | 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 章) |
./gradlew check 会同时跑 ktlintCheck、detekt 与单测,提交前跑一次最省事。./gradlew ktlintFormat;只想看不改:./gradlew ktlintCheck 后看 build/reports/ktlint/ktlintMainSourceSetCheck/report.html。模块/build/reports/detekt/detekt.html,按规则分组,点进去直接跳文件行号。gh api repos/{owner}/{repo}/pulls/123/requested_reviewers
# 或在 GitHub 仓库 Settings → Code review 中查看解析结果
git config core.hooksPath,为空说明没装;chmod +x .githooks/* 别忘(Windows 上用 Git Bash 提交)。grep -c "<issue" app/lint-baseline.xml,把这个数字写进迭代目标,每次减 20 条。./gradlew 而非 gradle),且 JDK 版本与 CI 一致(第 02 章)。bash .githooks/commit-msg .git/COMMIT_EDITMSG。ktlintFormat + CI 卡口private,模块依赖默认 implementation,能用 internal 就不用 public。TODO 必须带 issue 号。main 随时可发布,hotfix 双向合回。
【阻塞】这里在 IO 线程更新了 UI,应该切回 Main;参考 DetailViewModel:42 的写法这里不太好:core:*、构建脚本、Manifest 必须有明确 owner 与强制批准。.editorconfig 打底、ktlint 管格式、detekt 管质量、Lint baseline 冻结存量,本地 Hook 快、CI 全、门禁只卡"新增问题"。main 随时可发布,特性分支 ≤ 3 天,hotfix 双向合回。测试(第 21 章)让代码"改得动",协作规范让多人"一起改也不乱"。至此阶段 4 完成,下一阶段将把全书能力落到 TechFeed 这一个完整项目上。
下一章:TechFeed 总览——需求拆解、模块图、里程碑与验收标准,正式开始实战阶段。
基础题
.editorconfig 与 2.2 的 ktlint 配置加进你的工程,跑一次 ./gradlew ktlintFormat,统计自动修复了多少处,再跑 ktlintCheck 确认全绿。pre-commit 与 commit-msg 两个 Hook,故意提交一条 update code 的消息和一个 keystore.properties 文件,确认都被拦下。进阶题
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 章的测试分层)
阶段:实战 | 预计阅读 45 min | 代码落地模块:全部 前置:第 14 章 分层架构 | 第 15 章 模块化 相关章节:第 24 章 信息流与详情 | 第 25 章 鉴权与离线 | 第 26 章 推送与收尾
拿到 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 章) |
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 章已定,实战中必须守住):
TokenProvider)。:app 是唯一聚合点:所有 feature 在这里拼装,settings.gradle.kts 之外没有任何地方认识全部模块。每个里程碑都要满足:可独立验收、可独立发布(哪怕只在测试群)、不超过两周。
| 里程碑 | 内容 | 验收物 | 对应章节 |
|---|---|---|---|
| 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 从第一天就在。
前后端共识文档(节选,真实项目放接口平台):
# 文件路径: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 是唯一事实源 |
sample/ 中的真实路径,边读边打开对应文件。./gradlew :feature:feed:assembleDebug
若失败提示缺依赖,说明 feature 之间出现了不该有的引用。# 检查是否有 feature 引用了另一个 feature(应零结果)
grep -r "projects.feature.feed" sample/feature --include=build.gradle.kts | grep -v "feature/feed"
./gradlew :core:network:test,序列化测试会暴露字段错位。sample/README.md。下一章:进入第一个完整功能——信息流列表与详情页,Compose 与 View 两套实现逐一对照。
基础题
sample/ 工程,跑通 M1 链路:从 TechFeedApi.getFeed 断点到 FeedList 渲染,画出数据流向图。FeedRepositoryImpl.refresh 能正确写入 Room。进阶题
4. 为 TechFeed 设计"搜索历史"功能:需求翻译 + 模块落点(提示:DataStore 存历史、Room 存结果)+ 里程碑归属。
5. 检查 sample/ 工程是否违反三条依赖铁律,写一个脚本在 CI 中自动巡检 feature 间依赖。
思考题
6. 为什么 Repository 接口定义在 feature 的 domain 层而不是 core 层?如果两个 feature 需要同一份数据,接口该放哪?(提示:core:data 中间层的取舍)
7. "验收标准可测量"在性能类需求(如"启动要快")上如何落地?(提示:第 17 章的指标红线)
8. 如果产品要求支持"未登录也能收藏(本地收藏)",架构上要动哪些模块?收藏的"单一数据源"会变成什么?
阶段:实战 | 预计阅读 60 min | 代码落地模块:
:feature:feed、:feature:detail前置:第 23 章 TechFeed 总览 | 第 06 章 列表与导航 相关章节:第 10 章 数据存储 | 第 17 章 性能优化
列表页看似简单,状态组合却不少。先画状态机再写代码:
stateDiagram-v2
[*] --> Loading: 首次进入
Loading --> Success: 首屏返回
Loading --> Error: 网络失败且无缓存
Success --> Refreshing: 下拉刷新
Refreshing --> Success: 刷新完成
Refreshing --> Error: 刷新失败(保留旧数据 + Toast)
Success --> Empty: 搜索无结果
Empty --> Success: 清空搜索词
Error --> Loading: 点击重试
这些状态全部收敛在 FeedUiState 一个不可变快照里(第 14 章),UI 只做映射渲染。
| 决策点 | 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 接口即可。
// 文件路径: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("重试")
}
}
}
// 文件路径: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)) }
}
}
// 文件路径: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
}
}
}
// 文件路径: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")))
}
// 文件路径: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 状态或在渲染层明确优先级 |
adb shell svc wifi disable && adb shell svc data disable(或开飞行模式),确认列表仍可读缓存、刷新报错但不清空。ignoreUnknownKeys 与默认值兜底(第 09 章)。FeedUiState 中有唯一表达。ui/view/ 对照,理解"手动同步消失"的过程。FeedUiState。collectAsStateWithLifecycle、LazyColumn(key+contentType)、无状态子组件。ListAdapter + DiffUtil + repeatOnLifecycle,与 Compose 一一映射。下一章:给 App 加上"身份"——登录鉴权、Token 无感刷新与离线缓存的同步策略。
基础题
ArticleListItem 写 Compose UI 测试:验证点击回调携带正确的 articleId(第 21 章)。advanceTimeBy 写一个单元测试验证"连续输入只触发一次过滤"。进阶题
4. 实现分页加载:用 Paging3 改造 FeedRepository(保留 Room 缓存),底部显示"加载中/到底了"。
5. 把 View 版信息流(ui/view/)与 Compose 版做像素级对照,输出一份"手动同步点清单"(至少 6 处,参照第 05 章练习)。
思考题
6. FeedUiState 里同时有 isLoading 和 isRefreshing,为什么不用一个 Boolean 表达"转圈"?两种转圈的用户预期差在哪?
7. 如果产品要求"收藏操作离线可用,联网后同步",2.4 的收藏链路要怎么改?(提示:本地先写 + 待同步队列)
8. 列表 key 用 id 后,"插入到列表头部"为什么不再引起整列重组?从 Diff/重组两个角度分别解释。
阶段:实战 | 预计阅读 60 min | 代码落地模块:
:feature:auth、:core:network、:core:datastore前置:第 24 章 信息流与详情 | 第 09 章 网络请求 相关章节:第 19 章 安全加固 | 第 10 章 数据存储
AuthInterceptor + Authenticator 的分工,实现 401 无感刷新且不重入。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 负责"凭据失效后怎么办"。
Offline First 的读路径已在前两章确立(Room 是唯一事实源)。写路径(收藏/点赞)的难点是本地态与服务端态的一致性:
| 策略 | 行为 | 适用 |
|---|---|---|
| 服务端优先(本工程 M2) | 先请求服务端,成功后写本地 | 收藏等低频操作,网络差时明确失败 |
| 本地优先 + 待同步队列(M3 演进) | 先写本地 + 入队,后台补发 | 离线可用是硬需求时 |
| 乐观更新 | UI 立即变更,失败回滚 | 点赞等高频弱一致操作 |
本工程采用服务端优先 + upsertKeepingFavorite 保护本地收藏位(第 23 章契约),M3 练习引导演进到队列方案。
sample/ 逐行对应)// 文件路径: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() 同理
}
// 文件路径: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
}
}
}
// 文件路径: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("登录") }
}
}
新实现下(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}"""))
SecureTokenStorage),其他模块只经 TokenProvider 接口访问。TokenProvider/用户流,而不是到处 isLoggedIn 静态变量。下一章:最后一战——推送通道、监控接入与全项目收尾验收。
基础题
SecureTokenStorage 中已有 Token。进阶题 4. 完成 2.4 的并发刷新测试,并故意去掉 Mutex,观察测试如何抓出这个 bug。 5. 实现"刷新失败全局兜底":401 刷新失败后清凭据并发出全局事件,导航层收到后跳转登录页(提示:SharedFlow + Navigation 监听)。 6. 把收藏改造成"本地优先 + 待同步队列":离线收藏先落库并入队,WorkManager 恢复网络后补发。
思考题
7. Authenticator 返回 null 意味着什么?OkHttp 拿到 null 后会把原始 401 响应交给谁?
8. 为什么刷新接口必须绕过 AuthInterceptor?如果绕不开,代码上如何区分"登录请求/刷新请求/业务请求"?
9. "乐观更新收藏"在弱网下用户体验最好,但会带来什么一致性风险?什么业务绝对不能用乐观更新?
阶段:实战 | 预计阅读 55 min | 代码落地模块:
:feature:push、:core:crash、:app前置:第 25 章 鉴权与离线 | 第 13 章 后台任务与通知 相关章节:第 17 章 性能优化 | 第 18 章 稳定性监控 | 第 20 章 构建与发布
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 绑定。
| 固化对象 | 手段 | 对应章节 |
|---|---|---|
| 质量 → 可观测 | 崩溃/ANR 采集接入、告警阈值、灰度 | 18 |
| 体验 → 达标 | 启动/帧率/包体过基线,Macrobenchmark 回归 | 17 |
| 交付 → 可重复 | 签名、变体、CI 构建、发布检查清单 | 20 |
sample/ 逐行对应)// 文件路径: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
}
// 文件路径: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)
}
// 文件路径: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),否则周期同步从未被调度——调度入口全工程零调用是收尾阶段最常见的"死代码"事故。
// 文件路径: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)
}
}
<!-- 文件路径: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 章)。
Application.onCreate 的第一行。下一章:附录——术语表、API 速查与常见报错速查,作为你后续开发中的案头手册。
基础题
showMessageNotification → 点击通知回到 App。SyncFeedWorker 增加"新文章才通知"逻辑:刷新前记录最新 id,刷新后对比,写单元测试覆盖"无新文章不发通知"。adb shell am crash 制造崩溃,验证 CrashStore 落盘文件内容完整(含 breadcrumb 与堆栈)。进阶题
4. 实现 VendorPushChannel 的空壳(isSupported 按 Build.MANUFACTURER 判断),并把 Hilt 绑定改为"运行时按设备选择实现"(提示:@IntoSet 或工厂)。
5. 完成 release-checklist.md 的真实执行:打一个 prodStoreRelease 包,逐项勾选并记录证据(截图/命令输出)。
6. 为项目配置灰度开关:一个远程布尔配置控制"新详情页",异常时可关闭(可Mock 配置接口)。
思考题 7. "每次同步都弹通知"为什么是产品灾难?从打扰成本与通知渠道降权两个角度分析。 8. 崩溃落盘 + 下次启动补传的方案,在"启动即崩溃"(秒崩)场景下会失效吗?如何兜底?(提示:安全模式/禁用模块) 9. 如果让你为 TechFeed 制定"下一版本规划",你会基于哪些数据排优先级?列出数据来源与决策规则。
阶段:附录 | 用法:随查随用,不必顺序阅读 相关章节:全书各章
| 术语 | 英文 | 一句话解释 | 详见 |
|---|---|---|---|
| 应用组件 | 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 |
// 文件路径: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)
// 文件路径: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 }
// 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。 博客文章(包括本书)可能滞后于工具链演进。
CancellationException 永不吞。学完这本书,你已经走完"从面向对象基础到企业级交付"的完整路径。 接下来最好的学习材料,是你自己的第一个真实项目。祝顺利。🚀
本目录存放《系统化 Android 开发教程:从入门到企业级》的正文分章文件。 总目录、学习路线与技术栈基线见上级目录的
../README.md。
| 文件 | 标题 | 关键词 |
|---|---|---|
| 00-environment-setup.md | 开发环境搭建 | Android Studio、SDK、AVD、adb、真机调试、Profiler |
| 01-kotlin-crash-course.md | Kotlin 速成与协程预热 | 空安全、data class、扩展、密封类、协程 |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 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 概览 |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 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、通知渠道、推送通道抽象 |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 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、上架 |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 21-testing-strategy.md | 测试策略 | 测试金字塔、单元测试、Flow 测试、UI 测试 |
| 22-team-collaboration.md | 团队协作规范 | 分支模型、Code Review、ktlint/detekt、ADR |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 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 速查、常见报错速查、延伸阅读 |
学习目标 → 核心概念 → 示例代码 → 常见坑 ⚠️ → 调试技巧 → 最佳实践 → 本章小结 → 动手练习
// sample/feature/feed/.../FeedViewModel.kt。> [!TIP],警告用 > [!WARNING]。<details> 块承载,不占主线篇幅。第 04–07 章、第 16 章、第 24 章会同时给出 Compose 与 View/XML 两套实现。 阅读建议:新项目以 Compose 为主,View 部分按需查阅;维护存量项目则反过来。
一切版本号以工程内的 sample/gradle/libs.versions.toml 为准,本目录正文不锁死小版本。
TechFeed 初期为单体模块工程:编译时间随代码量线性增长、团队并行开发频繁冲突、
core 与业务代码互相耦合导致复用困难。同时存在双 UI 体系(Compose + View)并行的迁移需求,
需要清晰的边界防止"新代码混进旧页面"。
core:* 提供能力(网络/存储/设计系统等),
feature:* 承载业务(信息流/详情/登录/收藏/推送)。core:navigation-api 定义的类型安全路由实现,
:app 作为唯一聚合点与路由实现方(依赖倒置)。ui / domain / data / di 四段分层,Repository 接口定义在 feature 的 domain 层。implementation 依赖让增量编译显著加速、
feature 模块可独立出团队复用。CODEOWNERS 明确所有权;禁止 feature 间依赖由 CI 检查兜底。《系统化 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)并修掉全部 编译级问题;但教程编写环境仍未做真机/模拟器运行验证,首次同步请按以下清单排查。
Settings → Build Tools → Gradle → Gradle JDK 选择 17(或 Studio 内置 jbr-17)。platforms;android-37 与 build-tools;37(SDK Manager 勾选)。
若 AGP 版本与 SDK 组合报错,优先按 Android Studio 的 AGP Upgrade Assistant 提示升级,
并同步修改 gradle/libs.versions.toml。gradlew(Gradle 9.1.0,AGP 9.0 的最低要求版本);
首次运行会自动下载发行版,超时请配置镜像或代理。ksp = "2.4.0-1.0.0" 必须与 kotlin = "2.4.0" 同代,
不匹配的报错形如 KSP version is not compatible。core/database/schemas/ 目录由 KSP 生成,首次构建后提交入库
(注意:schemas/ 已从 .gitignore 移除,不要重新加回去)。settings.gradle.kts 的 16 个模块全部保留,且目录名一致。[!TIP] 首次构建建议执行
./gradlew assembleProdStoreDebug。 工程预置了dev/prod × store/direct四个变体(教程第 20 章),默认命令行构建请带上维度:assembleDevStoreDebug或在 Run Configuration 中选择对应变体。
Article/Result/AppError → core/modelfeature/*/ui/*ViewModel.ktcore/networkcore/database、core/datastorefeature/profile/ui/FavoriteViewModel.ktdi/feature/feed/ui/FeedViewModel.ktcore/navigation-api、app/navigationTechFeedApplication.kt、benchmark/app/proguard-rules.pro、SecureTokenStorageapp/build.gradle.ktscore/testing、各模块 src/test.editorconfig、detekt/、.github/、docs/adr/仅用于教程学习。禁止将示例代码中的占位域名/密钥用于生产环境。
./gradlew ktlintCheck detekt 本地通过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 仓库浏览 →