Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Android 应用开发面试手册使用指南

本手册面向中级 Android 应用开发岗位复习, 也为经验较少的开发者提供从基础到案例的学习路径. 章节目录, 顺序和标题以 SUMMARY.md 为唯一事实源; 本页只说明使用方法, 依赖和验收约定, 不重复维护完整章节清单和逐章自测标题.

读者分流: 本书部分章节围绕 “风控 SDK 背景转应用开发” 人设组织 (01 的转型话术, 知识域里的 SDK 落点注记, 42-45 的主场安排). 经历不同的通用读者: 跳过转型话术, 把示例替换为真实项目故事, SDK 落点注记可直接忽略; 通用考点主体不受影响.

如何使用

  1. 先读面试路线与自我定位, 用真实经历确定目标岗位, 优势和短板. 第 01 章的 “底层强, 应用层有空白” 画像只是条件化模板, 其中事实必须替换为自己有证据支撑的内容.
  2. 经验较少时, 先按一条建议路径连续学习. 每章先写下自己已有的前置知识和一个待验证问题, 再看机制, 示例和失败排查; 遇到术语不懂时回到前置域, 不以跳读 Q&A 代替正文学习.
  3. 从 SUMMARY.md 打开章节, 每次围绕一个主题完成 “定义 → 机制 → 示例 → 失败 → 证据 → 复述”.
  4. 平台, 工具链, 法规和产品能力会变化; 优先阅读章节中的版本基线, 最后核验日期和官方来源.
  5. 面试答案只使用自己能解释, 能复现, 能说明边界的内容; 指标必须保留基线, 样本和测量方法.

知识域依赖与章节角色

八个知识域按依赖从低到高组织, 但目录中的主题顺序不等于每个人唯一的学习顺序:

  • 语言与运行时 (02-06) 是协程, UI 状态, 并发和工程代码阅读的前置.
  • Android 基础与 UI (07-17) 建立组件, 生命周期, View/Compose, Jetpack, 架构, 测试和模块通信的日常应用主线.
  • 系统机制与性能稳定性 (18-27) 以组件和并发基础为前置, 将 Framework, Binder, 线程与性能问题串起来.
  • 数据, 网络与存储 (28-31) 支撑业务状态, 数据一致性和网络排障; 工程化, 构建与发布 (32-36) 建立在这些应用实践之上.
  • 业务场景与 SDK 能力 (37-41) 需要网络, 存储和状态机基础; 安全, 风控与逆向视角 (42-45) 再补充信任边界与 Native 风险.
  • 跨端, 动态化与音视频 (46-51) 是专项选型和工程实践, 应在掌握 Android 应用主线后按岗位需要进入.
  • 算法与计算机基础 (52-58) 服务于编码题, 性能推理和源码阅读, 可与前述主线并行, 但海量数据, 业务算法和系统机制依赖基础算法与 OS / 数据库知识.
  • 系统设计, 项目表达与 AI 辅助开发 (59-67) 要求读者先有可核验的工程经历或练习产出, 用来综合表达, 复盘和治理开发过程.

基础章节用于建立术语, 最小机制和可操作起点; 深入章节解释边界, 原理, 诊断或选型; 案例 / 题库章节要求把前两类知识用于完整场景, 练习或个人证据. 学习机制按章型分轨: 02-06 与 52-67 以章内学习目标为准 (52-54 另附进度自测); 07-51 技术章以章末高频面试题与易错点自测; 01 的完成定义表与 65 的完成定义分别约束路线与 Harness 篇. 以各章实际给出的目标, 自测或面试题为完成标准, 不把编号本身当作难度标签.

章节标题或开头的标记含义: ★ 必考核心 (短板且面试高频); ☆ 差异化主场 (强项, 面试差异化武器); (辅) 辅助速览 (低概率直接考, 用于建立上下文).

建议复习路径

路径 A: 应用基础与架构

Kotlin/协程 → View/Compose → Jetpack → MVVM/MVI → 测试 → 组件化.

适合补齐日常 Android 应用开发的主干能力. 重点练习生命周期, 状态恢复, 线程 / 取消, 导航和失败重试, 不只背 API.

路径 B: 系统, 性能与稳定性

Framework/Binder/并发 → 性能方法论 → 启动/内存/ANR → 工具 → APM.

每个优化结论都要回答: 指标是什么, 如何复现, 用什么工具, 前后基线如何, 有哪些副作用, 如何回滚.

路径 C: 工程化与业务安全

存储/网络/数据库 → Gradle/AGP/CI/CD → 隐私 → 鉴权/支付/推送/埋点 → 安全/NDK.

区分客户端与服务端信任边界, 区分技术能力与应用商店 / 法律要求, 对版本和地区敏感结论保留核验日期.

路径 D: 系统设计, 项目表达与 AI Coding

系统设计方法论 → 题库 → 项目证据 → 简历核验 → AI Coding 四章.

系统设计先给目标, 容量, 状态机, 一致性, 失败恢复, 观测和验收; AI Coding 强调小范围修改, 最小权限, 独立验证, 审计与终止条件.

按能力目标选择

  • 想独立完成常见功能: 从路径 A 开始, 再按功能需要插入路径 C 的存储, 网络和数据库.
  • 想定位线上问题或面试性能题: 先补 Kotlin 并发与组件生命周期, 再走路径 B; 不先学专项工具而跳过问题模型.
  • 想应聘业务 / 平台岗位: 路径 A 后走路径 C, 然后选择安全, 跨端或音视频等专项.
  • 想准备综合面试: 保留一段可披露的练习或项目证据, 最后走路径 D; 系统设计答案和项目故事不能替代基础机制说明.

通用自测清单

学完一章后, 不再回到首页勾选重复目录, 而是按 “定义 → 机制 → 示例 → 失败 → 证据 → 复述” 保留本章自己的完成证据:

  • 定义: 能用 1 分钟说清概念, 解决的问题, 适用前提和一个反例.
  • 机制: 能画出核心数据流, 状态机或调用链, 并解释关键步骤为什么发生.
  • 示例: 能运行, 补全或手推本章的示例, 并记录输入, 环境, 实际结果与预期是否一致.
  • 失败: 能说出至少一个症状, 给出 “症状 → 证据 → 定位 → 修复 → 验证” 的排查路径.
  • 证据: 能区分平台事实, 工程经验, 本文工作定义和个人观点; 指标包含基线, 样本量, 分位数, 环境和个人贡献.
  • 复述: 能脱离正文说明取舍, 版本/设备/地区/商店/实现边界, 并回答一个追问.
  • 对不确定或易变结论, 已回到官方资料重新核验并记录核验日期.

示例性质约定

  • 可运行示例必须给出依赖, 调用上下文, 执行方式和预期结果; 只有实际在声明环境运行后, 才能把实际结果作为完成证据.
  • 上下文片段必须说明省略的工程代码和应放置的位置; 它用于解释局部实现, 不承诺可独立编译.
  • 伪代码只解释机制, 流程或取舍, 不声称可编译或已验证.
  • 数字示例必须列出假设, 公式, 计算过程和结论的适用范围; 数字练习不能替代真实性能指标.

维护约定

  • 目录变更只修改 SUMMARY.md.
  • 章节间引用使用相对 Markdown 链接, 不使用 “见第 N 篇” 这类易失效文本编号.
  • 历史计划和设计文档只作为决策记录, 不作为当前导航或平台事实来源.
  • 易变章节应在相关结论处标明适用范围 / 版本, 最后核验日期, 并在适用时提供一手来源.
  • 新增或修订章节时, 作者应在该章保留适合其类型的完成定义和自测证据: 概念 / 机制, 示例或案例, 失败排查, 边界与复述; 不要求所有章节采用相同标题或都包含可执行代码.
  • 维护验收至少检查目录链接与相邻职责, 示例性质标注, 相对链接目标, 代码围栏成对以及章节是否有可核验的学习证据. 目录仍只在 SUMMARY 维护, README 不新增第二份完整目录.

面试路线与自我定位

这一篇不讲技术, 讲策略. 如果你的真实经历确实是 “底层强, 应用层有空白”, 可以把下面内容当作转型模板; 否则请按自己的经历重写, 不要套用不符合事实的画像. 通用读者分流: 本篇的转型话术和各知识域的 “SDK 落点” 注记围绕 “风控 SDK 背景转应用开发” 人设组织; 经历不同的读者把话术替换为真实故事, 只保留方法与节奏, SDK 落点注记可直接跳过.

使用方式与完成定义

前置知识: 能如实列出自己的项目, 职责和学习时间; 本篇的目标不是制造统一人设, 而是把事实组织成可验证的求职叙事. 完成本篇后, 你应能选择目标岗位, 说明一项真实能力的证据链, 并完成一次有记录的模拟面试.

章节完成定义可出示的证据
01 路线与定位完成能力盘点, 岗位筛选和两版自我介绍一页证据表, 投递清单, 录音或复盘笔记
02 Kotlin 核心能手写空安全, 委托和型变小例子, 并解释类型约束小练习源码与自测答案, 见 02
03 Kotlin 进阶能从编译器改写角度解释 inline, value class 和 Java 边界javap 观察记录, 见 03
04 协程与 Flow能画出 Job 父子关系并实现可取消的状态流最小项目与生命周期验证, 见 04
05 RxJava能按事件语义选择类型, 调度器和背压策略操作符时间线与 dispose 复盘, 见 05
06 Java/JVM能解释安全发布, 锁选择, 集合和类加载边界并发场景判断与字节码/日志观察, 见 06

不要把 “读完” 当作完成: 没有真实项目证据的经历只能表述为学习或练习, 不应写入工作职责.

一, 你的优势与劣势盘点

优势 (先用证据自诊断, 再在面试中主动亮出来)

能力普通应用开发者你的真实证据
C/C++ / NDK / JNI大多不会或只会调用是否有可说明的模块, 职责和结果?
系统底层 / so / 内存模糊是否能用真实问题说明理解和定位过程?
逆向 / 安全 / 对抗视角几乎没有是否是实际工作职责, 且能在合规边界内说明?
性能极限优化意识一般是否有体积 / 性能基线, 方案和验证结果?

只把能说明项目背景, 个人职责, 方案取舍和结果的能力当作优势. 若确有这些证据, native, 性能和安全经验对音视频, IM, 支付, 出海 App 等方向可能是加分项.

劣势 (面试中会被重点拷问, 必须补)

  1. UI 层: View 绘制流程, 事件分发, 自定义 View, Compose: 应用岗的核心, 你接触少.
  2. Kotlin 协程 / Flow: 现代 Android 异步基石, 你不熟.
  3. Jetpack 全家桶: ViewModel/Room/Navigation/Hilt/DataStore, 应用开发日常.
  4. 应用架构: MVVM/MVI, 单向数据流, 分层: 中级必考.
  5. 主流业务库实战: Retrofit/Glide/OkHttp 的工程化用法.

二, 转型叙事: 怎么讲你的故事

面试官一定会问:“你之前做风控 SDK, 为什么转应用开发? 能行吗?” 准备好这套话术:

“我过去在 [真实项目 / 模块] 负责 [可核验的底层工作], 积累了 [能举例说明的系统, 性能或稳定性经验]. 我希望把这些经验用在完整产品中. UI 和上层框架是我正在补的方向; [仅在完成学习和练习并能展示证据时] 我已经系统学了协程, Compose, Jetpack 和 MVVM 架构.”

关键三点:

  • 不贬低过去: 如有可核验的底层经验, 可说明它带来的系统理解, 但不要用 “比别人更懂系统” 这类无法举证的比较性表述.
  • 承认短板但展示行动: 只有完成 X, Y, Z 的学习, 练习或项目验证后, 才说 “我已经系统学了 X, Y, Z”; 否则如实说明当前进度和下一步计划.
  • 给团队一个理由: 仅在有对应经历时说 “我能补团队在 native / 性能 / 安全上的短板”, 并准备事实依据.

本手册的 SDK 视角: 这本手册不是通用 Android 考点清单, 而是围绕 “设备指纹 / 风控 SDK 背景” 组织的一条差异化叙事线. 通用篇负责补应用侧短板 (UI, 架构, 协程), 让 “没做过完整 App” 不再是硬伤; 主场篇 (42-43) 负责拉开差距, 把背景转成别人难以复制的优势. 各技术篇都能与 SDK 背景挂钩: Gradle 篇讲 SDK 的发布与 consumer-rules 视角, 合规篇讲设备标识怎么采集才合规, 协程篇落到事件上报的竞态去重与并发限流. 读每一篇时按 “能讲我哪段经历 / 补我哪块短板” 对位, 而不是逐篇背考点.

面试策略: 被问到与主线无关的问题时, 可以用 “这个问题在我的 SDK 场景里是…” 的句式主动把话题引回风控背景, 与 42 篇开头的主动引导策略呼应; 前提是确有对应经历可讲, 不要强行安插.

三, 目标岗位选型 (扬长避短)

优先投这些方向, 你的底层背景是加分项而非累赘. 每个方向都按 “为什么契合 → 面试会问什么 → 怎么包装经历 → 可能追问” 准备:

  • 音视频 / 直播 App
    • 为什么契合: 音视频链路常接触 FFmpeg, 编解码, OpenGL/音频采集, native crash 与性能调优, 你的 C++/NDK/JNI 经验能直接迁移.
    • 面试会问: JNI 调用开销怎么控制? native 崩溃怎么定位? 音视频卡顿如何拆解到采集, 编码, 网络, 渲染各环节?
    • 怎么包装: 把风控 SDK 里的 native 模块讲成 “在宿主 App 约束下做稳定, 低开销, 可回滚的 native 能力”, 突出跨层调用, 线程模型, so 体积和崩溃治理.
    • 可能追问: 如果没做过播放器, 不要硬装; 回答 “音视频业务链路我需要补, 但 native 性能, JNI 边界, crash 定位这些底层问题我能快速接住”.
  • IM / 通讯
    • 为什么契合: IM 重视连接稳定性, 弱网, 消息可靠性, 离线 / 重连和端侧性能, 很多问题不是纯 UI, 而是协议与状态机.
    • 面试会问: 弱网下如何保证体验? 长连接断线重连怎么设计? 消息去重, ACK, 重试和本地落库怎么取舍?
    • 怎么包装: 强调你做 SDK 时对宿主环境, 网络 / 设备差异, 异常采集和后台约束的敏感度, 把它转成 “端侧可靠性” 语言.
    • 可能追问: 若被问到完整 IM 架构, 先按 “连接层 → 消息状态 → 本地缓存 → UI 同步 → 失败补偿” 分层, 不要一上来堆大规模服务端名词.
  • 出海 App / 工具类
    • 为什么契合: 出海和工具类常受包体积, 机型兼容, 启动速度, 隐私合规, 多语言环境影响, SDK 背景天然关注 “不能拖累宿主”.
    • 面试会问: 包体积怎么拆? so ABI 如何收敛? 低端机启动 / 内存如何优化? 兼容性问题怎么排查?
    • 怎么包装: 讲你如何在 SDK 约束里做延迟初始化, 按需加载, ABI / 符号裁剪, 异常兜底; 这些都能转化为 App 侧工程质量.
    • 可能追问: 遇到隐私 / 合规问题时, 用 “最小化采集, 用户授权, 可配置开关, 日志脱敏” 这类工程原则回答, 不要编造具体合规项目.
  • 支付 / 金融 App
    • 为什么契合: 支付金融对安全, 风控, 稳定性和审计非常敏感, 你的对抗视角能帮助团队提前发现风险点.
    • 面试会问: 如何防 Hook/篡改/重放? 敏感数据怎么保护? 安全能力如何避免影响性能和用户体验?
    • 怎么包装: 把风控经验翻译成 “安全风险识别 + 端侧防护 + 性能可控 + 可观测”; 强调防御和工程治理, 而不是炫攻击细节.
    • 可能追问: 被追具体安全方案时, 保持防御视角, 说清威胁模型, 边界和误报成本; 不要夸大成 “完全防住”.
  • 大厂基础架构 / 性能团队
    • 为什么契合: 基础架构和性能团队关注启动, APM, 稳定性, 包体积, native 质量和跨业务复用, 与你的 SDK 工程经验最接近.
    • 面试会问: 启动耗时如何拆解? APM 指标怎么定义? native crash 符号化怎么做? 一个优化如何灰度和回滚?
    • 怎么包装: 突出 “做给多个宿主 / 业务使用” 的 SDK 思维: 接口稳定, 兼容性, 监控, 降级, 性能预算, 发布质量.
    • 可能追问: 如果问到业务产品经验不足, 回应 “我更适合先从性能/稳定性/native 基建切入, 同时补齐 UI 和业务架构”.

谨慎投: 纯营销活动类, 纯 UI 堆叠的业务岗: 这类岗位你的优势用不上, 而短板暴露无遗.

四, 典型面试流程与各轮重点

轮次考察重点典型问题回答结构易翻车点
一面 (基础)Kotlin, 四大组件, UI, 协程, 集合并发“协程取消怎么生效?” “View 事件分发怎么走?” “Activity 重建怎么保状态?”先给一句结论, 再按 “机制 → 场景 → 易错点” 三段答; 不会展开源码时, 至少说清生命周期/线程/状态边界只会背概念, 不敢承认 UI/Jetpack 短板; 把底层强项当借口跳过基础题
二面 (深入)架构, 性能, 原理, 项目细节“一个页面你怎么分层?” “启动慢怎么定位?” “SDK 怎么不拖累宿主?”用 “问题现象 → 指标拆解 → 定位工具 → 方案取舍 → 验证结果” 讲; 项目题用 STAR, 结果处只填真实数据讲项目只有职责没有决策; 性能优化只说 “异步 / 缓存” 不讲指标和验证
三面 (亮点)难题, 系统设计, native, 稳定性“native crash 怎么治理?” “设计一个端侧 APM/埋点 SDK” “安全能力如何灰度?”把主场拉到 “NDK/JNI → 稳定性 → 性能预算 → 监控回滚”; 系统设计按需求, 约束, 模块, 数据流, 失败处理讲炫技过多, 忽略业务约束; 安全话题讲成攻击教程; 承诺无法验证的效果
HR 面稳定性, 薪资, 转型动机“为什么转应用?” “短板怎么补?” “未来 2-3 年规划?”用第二节叙事: 肯定过去价值, 承认上层短板, 展示学习行动和岗位匹配; 薪资提前准备区间抱怨前公司 / 旧方向; 把转型说成逃离; 用空话证明学习能力

每轮都要准备一个 “桥接句”:我过去做的是底层 SDK, 现在补的是应用层 API 和架构实践; 底层经验能帮助我在性能, 稳定性, 安全问题上比普通应用开发者更快定位根因. 这句话不是万能答案, 而是把话题从 “你没做过完整 App” 拉回 “你能给团队补什么能力”.

五, 两个月冲刺计划 (示例, 按诊断结果调整)

这只是排期示例, 不承诺固定两个月能补齐真实项目经验. 先用模拟面试和自测确定短板, 再调整每阶段时长.

六, 心态提醒

如果你已有可核验的底层经验, 这不是从零开始; 如果没有, 就从真实基础出发. 上层知识可通过阶段性学习逐步补齐, 但项目实践, 结果和协作经验仍需要真实证据积累. 把姿态放在 “我带着已有能力并持续补齐应用实践” 的位置, 不夸大也不回避短板.

职业规划证据练习表单

以下方括号字段是读者练习表单, 不是出版正文的未完成内容, 也不替读者生成个人答案. 只填写自己可证明的事实; 指标, 职责或成果没有证据时, 填写 [未测量/待补证据], 不以推测替代.

字段读者填写可复核证据
目标岗位[你的目标岗位][岗位描述链接或保存日期]
已验证技能[你的已完成作品或学习证据][仓库/提交/练习记录/演示]
待补能力[你尚未证明的能力][对应章节, 练习或岗位要求]
行动时间窗[你的开始日期, 结束日期或复盘日期][学习计划, 日历或任务记录]
复盘证据[你的复盘链接, 录音或笔记][追问记录, 反馈或修订说明]

完成条件: 能用上述证据说明目标岗位与当前能力的匹配和缺口, 并能区分已完成, 进行中与未知项. 在读者填入可核验证据前, “职业规划” 类开放题只能视为部分完成.

七, 把学习变成可核验经历

简历 before / after (上下文片段, 方括号内容必须替换为事实)

不要在简历中把课程目录改写成工作成果. 下面是同一事实的表达对比:

Before:熟悉 Kotlin,协程,Compose,参与 Android SDK 开发.

After:[时间] 在 [项目/练习仓库] 中负责 [具体模块];用 [技术] 解决了
[可描述的问题].我负责 [个人决策与实现],通过 [测试,日志,人工步骤]
验证 [实际观察到的结果];限制是 [尚未覆盖的边界].

“After” 只有在每个占位项都有证据时才能使用. 常见失败是把团队成果, 无法复现的数字或尚未完成的学习写成个人结果. 排查路径: 症状是追问时无法复述输入和验证; 证据是没有 PR, 文档, 测试记录或演示; 定位为证据链断裂; 修复是删去推测性成果并改成职责和学习状态; 验证是请同事或自己按 STAR 追问五分钟仍能回答.

最小练习项目

建立一个本地, 可公开或私有的 “任务列表” 练习项目, 不必伪造线上数据. 它至少包含: 一个输入表单, ViewModel 持有的 StateFlow, 一个可取消的模拟加载, 加载/空/错误状态, 以及一次本地持久化或 fake repository. 分别在 README 或笔记中写下:

  1. 每个状态由什么事件触发, 以及状态不可恢复时如何处理.
  2. 旋转屏幕或离开页面时协程如何取消 / 重新收集.
  3. 一个你亲自遇到的失败及 “症状→证据→定位→修复→验证”.

项目完成标准是能够从零演示上述流程, 并在不看稿的情况下说明为什么用 StateFlow 而不是一次性事件; 具体实现学习路径链接到协程与 Flow, 不在本篇重复技术细节.

20 分钟模拟面试脚本

时间面试官问题你的任务复盘标准
0-3 分钟“请介绍一个与你目标岗位相关的项目.”按背景, 职责, 决策, 验证说明一件真事不出现无法解释的指标或团队成果归属
3-8 分钟“为什么从底层转 Android 应用?”使用第二节叙事, 明确已会, 正在学和未做过的部分能说出下一项练习和完成时间, 而非泛泛说学习快
8-14 分钟“加载页面时协程取消如何生效?”画 Job 父子图, 解释生命周期和取消边界能区分挂起与阻塞, 状态与瞬时效果
14-18 分钟“一次性能问题怎么定位?”只用真实案例; 没有案例时说明会怎样测量和收集证据不承诺未经测量的收益
18-20 分钟“你想问什么?”询问团队技术栈, 交付约束和岗位成功标准问题能帮助判断岗位匹配

录音后逐题标记 “事实, 推断, 未知”.未知不是失败; 将其转成下一周的学习任务, 完成后再更新说法.

Kotlin 语言核心

Kotlin 是现代 Android 的第一语言. 你会用, 但要把 “会用” 升级到 “讲得清原理”.本篇覆盖中级面试高频语法点的底层机制.

章节边界与示例说明: 本篇讲日常 Kotlin/JVM 基础语法, 类型系统和对象模型; inline 字节码, value class 装箱, context parameters 与 Java ABI 等编译器视角问题见进阶专题. 除标注 “可运行示例” 的小程序外, 本文既有 Kotlin 代码均为需要放入 Android/JVM 工程的上下文片段; 带 “伪代码” 标记的块只解释机制.

Kotlin 是什么: Kotlin 是静态类型语言, 以 JVM/Android 为重要运行环境, 也可通过 Kotlin Multiplatform 面向其他平台, 并非 “Android 专属”. 它以空安全与 Java 互操作降低边界风险, 以扩展函数, 高阶函数和简洁语法提升表达力; 协程由语言提供 suspend 等语法与编译支持, 调度, CoroutineScope 等能力属于协程库. 这些特性可组合使用, 但仍需根据运行平台和 API 边界判断实际行为.

学习目标: 读者应能解释 null 为何仍会进入 Kotlin, 何时选择作用域函数和类类型, 并能从编译器报错判断型变与可见性边界. 完成标准见文末练习.

一, 空安全 (Null Safety)

Kotlin 把 null 检查提前到编译期.

  • val a: String 不可空; val b: String? 可空.
  • 安全调用 b?.length: b 为 null 时整体返回 null.
  • Elvis b?.length ?: 0: 为 null 时给默认值.
  • 非空断言 b!!: 为 null 时抛 NPE (慎用).
  • 平台类型 String!: 来自 Java 的类型, Kotlin 不知其可空性, 调用方负责.

易错: lateinit var 用于非空且延迟初始化 (只能用于 var, 非基本类型), 访问前未初始化抛 UninitializedPropertyAccessException, 可用 ::x.isInitialized 判断. by lazy 用于 val, 线程安全延迟初始化.

null 从哪里进入 Kotlin

非空类型是编译器在 Kotlin 边界内提供的约束, 不是运行时世界永远不产生 null 的保证. 常见入口如下:

  • Java 注解缺失或平台类型: 无可靠 @Nullable/@NonNull 注解的 Java 返回值在 Kotlin 看作平台类型; 把它当 String 使用, 实际返回 null 时仍可能 NPE.
  • 反射或序列化: 反射, JSON/Parcel 等框架可绕过正常构造/类型检查, 缺字段或不可信数据可能得到 null 或未初始化状态.
  • Java 集合污染: Java 可把 null 写入 Kotlin 声明为 MutableList<String> 的底层集合, Kotlin 遍历时才暴露问题.
  • 并发时序: 检查非空后, 另一线程可能修改共享可变引用; 用不可变快照, 锁或其他安全发布方式, 而不是依赖一次 null 检查.

边界层应将平台类型和外部数据先归一化为明确的可空/非空领域类型, 再交给核心逻辑. 症状是 “类型明明非空却 NPE”; 证据是 Java 调用链, 反序列化输入, 集合内容或跨线程写入记录; 定位输入首次跨越 Kotlin 边界的位置; 修复为注解, 显式校验/默认值, 不可变快照或同步; 验证用 null, 缺字段, 污染集合和并发交错的测试输入覆盖该边界.

Any, Any? 与 Java Object

Any 是 Kotlin 所有非空类型的根, Any? 则是包括 null 在内的可空类型根. Java Object 本身可为 null; 未标注空性时, Kotlin 将 Java Object 视为需在调用边界处理的 platform type, 不能把它直接等同于 Any 或 Any?. Kotlin 的基本类型在 JVM 上通常映射为原始类型, 在泛型, 可空类型或其他需要对象表示的位置会装箱; 与 Java 互操作时应依据签名的空性和装箱形式处理, 而不是只按源码名称判断.

Int 在可空 / 泛型位置会装箱为 Integer. JVM 的 Integer.valueOf 缓存 -128~127 (规范下限, 实现可扩大, 如 HotSpot 可用 -XX:AutoBoxCacheMax 调大), 区间内装箱复用同一实例, 区间外每次新建对象:

val a: Int? = 100
val b: Int? = 100
check(a === b)   // true,  缓存区间内复用同一 Integer

val c: Int? = 1000
val d: Int? = 1000
check(c === d)   // false, 缓存区间外各自装箱

Kotlin == 走 equals 按值比较, 所以 a == b 与 c == d 均为 true; 装箱陷阱只出现在 Java 互操作的 == 或 Kotlin === 引用比较. 热路径上高频装箱会产生对象分配, 增加 GC 压力, 应优先用原始 Int/Long.

if, when, try 都是表达式

Kotlin 的控制结构可产生值, 因此能减少 “先声明可变变量, 再在分支赋值” 的样板. Nothing 表示不会正常返回的计算, 是所有类型的子类型, 常用于让失败分支保持穷尽.

// 可运行示例(Kotlin/JVM,无额外依赖;保存为 ExpressionDemo.kt 后执行
// kotlinc ExpressionDemo.kt -include-runtime -d demo.jar && java -jar demo.jar).
sealed interface LoadState {
    data object Loading : LoadState
    data class Content(val text: String) : LoadState
    data class Failed(val cause: Throwable) : LoadState
}

fun fail(message: String): Nothing = error(message)

fun label(state: LoadState, cached: String?): String = when (state) {
    LoadState.Loading -> if (cached != null) "cached: $cached" else "loading"
    is LoadState.Content -> state.text
    is LoadState.Failed -> try {
        throw state.cause
    } catch (_: IllegalArgumentException) {
        "bad input"
    } catch (error: Throwable) {
        fail("unrecoverable: ${error.message}")
    }
}

fun main() {
    check(label(LoadState.Loading, null) == "loading")
    check(label(LoadState.Content("ready"), null) == "ready")
}

二, 日常语法与 Java 互操作

默认参数与 @JvmOverloads

Kotlin 调用方可直接省略默认参数; JVM 通过编译器生成的 synthetic 默认方法处理省略值: 普通函数通常生成带 bitmask 的 方法名$default, 构造函数则生成带 bitmask 与 DefaultConstructorMarker 的 synthetic constructor; Java 不会自动识别这些桥接. 对确实需要让 Java 省略末尾参数的构造函数或方法使用 @JvmOverloads, 编译器会从右向左生成重载, 每个重载省略一个或多个连续的末尾默认参数.

class Request @JvmOverloads constructor(
    val url: String,
    val timeoutMillis: Long = 3_000,
    val retryCount: Int = 1
)
// Java 可调用: new Request(url), new Request(url, timeoutMillis), new Request(url, timeoutMillis, retryCount)

它不会为跳过中间参数生成任意组合; 默认参数很多时会扩大 Java API 表面. 仅为稳定, 常用的 Java 调用路径添加它, 其他情况提供命名工厂方法或让 Java 显式传参.

Unit, Java void 与 java.lang.Void

Unit 是 Kotlin 表示 “正常完成但无业务返回值” 的类型; 普通返回 Unit 的 JVM 方法通常以 Java void 暴露. Unit 仍有单例值 Unit, 因而在函数类型或泛型位置会盒装为 kotlin.Unit, 例如 Result<Unit>. java.lang.Void 是可空的 Java 引用类型, 没有可供正常返回的实例; Java 泛型 API 通常以 null 表示无结果, 不应把它当作 Kotlin Unit 的等价替代.

fun save(): Unit = println("saved")
fun <T> completed(value: T): Result<T> = Result.success(value)

val result: Result<Unit> = completed(Unit)

infix, 解构和初始化顺序

infix 只能标记成员函数或扩展函数, 且函数必须恰好有一个参数. 该参数不能是 vararg, 也不能声明默认值; 调用时可写成 a merge b, 但没有点号和括号的写法不适合复杂表达式或 API 含义不直观的场景.

infix fun String.merge(other: String): String = this + other
val message = "Hello, " merge "Kotlin"

data class Point(val x: Int, val y: Int)
val (x, y) = Point(3, 5) // 依次调用 component1() 与 component2()

data class 自动生成 componentN; 普通类也可自行定义 operator fun componentN(). 解构只适合少量, 语义明确的字段, 不应用它隐藏昂贵计算或可空副作用.

主构造参数先成为属性参数或传给父类; 接着按源码顺序执行属性初始化器和 init 块; 最后才执行次构造函数体. 次构造函数必须先委托给主构造函数或另一个次构造函数, 因而不能绕过前面的初始化.

class InitOrder(val name: String) {
    private val normalized = name.trim().also { println("property: $it") }

    init {
        println("init: $normalized")
    }

    constructor(name: String, suffix: String) : this(name) {
        println("secondary: $suffix")
    }
}

// InitOrder(" Ada ", "!") 依次输出 property, init, secondary.

数值转换, 遍历与可见性映射

Kotlin 不做隐式数值扩大或缩小转换, 例如 Int 不能直接赋给 Long; 这避免 Java 风格隐式转换在重载, 精度和空值边界中掩盖错误. 根据意图显式调用 toLong(), toInt() 等, 并在窄化转换前处理溢出语义.

遍历时, for (item in items) 直接使用迭代器协议, 适合需要 break, continue 或清晰索引控制的主流程; forEach 是函数调用, 不能用普通 break/continue; map/filter 用于产生新集合或后续流水线, 不要只为副作用创建被丢弃的中间集合.

for (item in items) {
    if (item.isInvalid()) continue
    consume(item)
}
val visibleNames = users.filter { it.visible }.map { it.name }

Kotlin 与 Java 的可见性不是完全一一对应的源码规则. public 在两侧都对外可见; Kotlin internal 面向同一 Kotlin module, 但 JVM 字节码通常仍是 public 并带模块名改写, 不是 Java 安全边界; Kotlin protected 允许类和子类访问, Java 调用方还受 Java 的包 / 子类规则约束; Kotlin 没有 Java package-private 关键字. 需要给 Java 限制包内访问时, 应将 Java API 放在合适包中并通过公开入口封装, 不要假设 Kotlin internal 等于 package-private.

三, 扩展函数 / 扩展属性

fun String.lastChar(): Char = this[length - 1]

原理 (高频追问): 扩展函数编译成静态方法, 接收者作为第一个参数传入. 所以:

  • 扩展函数是静态分发, 不是多态: 调用哪个由声明类型决定, 不是运行时类型.
  • 不能真正修改类, 不能访问 private 成员.
  • 扩展属性没有 backing field, 只能定义 get/set.

四, 高阶函数与 inline

// 伪代码:省略计时实现,只说明 inline 的函数签名.
inline fun <T> measure(block: () -> T): T
  • 捕获外部变量的 lambda 常需要保存捕获状态; 非捕获 lambda 在具体 Kotlin 后端上可能复用单例. 两者的实际对象分配还受 JVM/IR 后端, 目标版本, 调用位置和优化器影响, 不能只凭源码断言.
  • inline 会把函数体和可内联 lambda 展开到调用处, 常可避免该调用点的 lambda 对象 / 虚调用, 并支持 lambda 内的非局部返回; 它不保证所有后端, 所有调用形态都 “零分配”.
  • noinline: 某个 lambda 参数不内联.
  • crossinline: 禁止该 lambda 非局部返回 (用于会在别处调用的场景).
  • reified: 配合 inline, 让泛型类型在运行时可见 (T::class), 解决泛型擦除.
  • 边界: reified 只让内联函数调用点能拿到 T 的运行时类型, 不能恢复集合元素的完整泛型实参; 例如仍无法把 List<String> 和 List<Int> 的元素类型当作普通运行时类型安全区分.

五, 作用域函数 (let/run/with/apply/also)

函数引用对象返回值典型用途
letitlambda 结果非空判断后操作 x?.let { }
runthislambda 结果配置对象并计算结果
withthislambda 结果对一个对象多次操作 (非扩展)
applythis对象本身初始化配置 Paint().apply { }
alsoit对象本身副作用 (打日志) 不改链式

记忆法:返回结果用 let/run/with, 返回自身用 apply/also; 用 it 是 let/also, 用 this 是 run/with/apply.

六, class 家族

  • data class: 自动生成 equals/hashCode/toString/copy/componentN. 注意 copy 是浅拷贝; 只有主构造参数参与生成.
  • sealed class / sealed interface: 密封类型, 子类受限在同一模块. 配合 when 可穷尽分支 (无需 else), 适合表达状态 (Loading/Success/Error).
  • object: 单例; companion object 伴生对象 (类级别成员, 可实现接口, 可命名).
  • enum: 枚举, 可带属性和方法.
  • 嵌套 vs 内部类: Kotlin 嵌套类默认是静态的; 加 inner 才持有外部类引用.

反例: copy () 是浅拷贝, 可变字段共享同一引用:

data class Profile(val name: String, var tags: MutableList<String>)

val a = Profile("Ada", mutableListOf("a"))
val b = a.copy()            // tags 与 a 指向同一个 MutableList
b.tags.add("b")             // 就地修改共享列表, a.tags 同步变为 ["a","b"]
check(a == b)               // 仍相等: 两边看到同一个列表

val c = Profile("Ada", mutableListOf("a"))
val d = c.copy()
d.tags = mutableListOf("a", "b") // 替换为新列表, 内容不同
check(c == d)                    // false: MutableList.equals 按内容比较

data class 的 equals 逐字段调用各属性 equals, 因此可变成员内容或引用一旦出现分歧, 相等性就随内容漂移. 用可变字段做集合 key 或状态比较前, 应保证字段不可变或复制出独立快照.

可见性与 backing field

可见性决定 “谁能在源码层引用声明”, 不是线程安全或不可变性的同义词. public 对所有模块可见; internal 对同一 Kotlin 模块可见; protected 对类及子类可见; private 仅在声明范围可见. 公共 Android SDK 的 internal 在 JVM 字节码中并非安全边界, 敏感能力应通过 API 设计来保护, 而非依赖它.

可运行示例 (Kotlin/JVM, 无额外依赖; 保存为 VisibilityDemo.kt, 执行 kotlinc VisibilityDemo.kt -include-runtime -d demo.jar && java -jar demo.jar):

class Profile {
    var name: String = "anonymous"
        set(value) {
            field = value.trim()
        }
    val nameLength: Int
        get() = name.length
}

fun main() {
    val profile = Profile()
    profile.name = "  Ada  "
    check(profile.name == "Ada")
    check(profile.nameLength == 3)
    println(profile.name)
}

field 是编译器在 accessor 中提供的 backing field (后备字段) 引用. 带默认 getter/setter 的属性通常有后备字段; 只有自定义 getter, 且不引用 field 的计算属性通常没有. 例如 nameLength 每次读取计算结果, 不存储独立值. 症状是以为计算属性会缓存而得到过期性能判断; 证据是 getter 每次都会执行; 定位看是否引用 field; 修复是需要缓存时明确保存状态; 验证是在 getter 中断点或写测试确认读取次数.

域案例 (设备指纹 / 风控 SDK): 用 value class + sealed 把 “设备 ID” 与 “设备状态” 建模成类型, 让业务函数不可能收到裸字符串或乱传枚举值:

@JvmInline value class DeviceId(val raw: String) // 包装设备指纹 hash

sealed class DeviceState {
    data object Normal : DeviceState()
    data object Emulator : DeviceState()
    data object Unknown : DeviceState()
    data class RiskFlagged(val reason: String) : DeviceState()
}

fun report(id: DeviceId, state: DeviceState) {
    // 编译期约束: id 只能是 DeviceId, 不会误传原始字符串
}

sealed 保证 when (state) 穷尽所有状态, 新增状态分支时编译器强制检查; value class 让指纹字符串与普通 String 在类型上区分开.

七, 委托 (Delegation)

委托的心智模型是:我不亲自做, 把某件事交给另一个对象做. 它解决的是 “复用行为” 问题, 不是 “继承层级” 问题.

Kotlin 里有两类常见委托:

类型解决什么问题典型写法面试关键词
类委托把接口实现转交给另一个对象class B(a: A) : A by a组合优于继承, 编译器生成转发
属性委托把 getter/setter 的通用逻辑抽出去val x by lazy { }getValue / setValue 约定

1. 类委托: 组合优于继承

interface DataSource {
    fun query(): String
}

class RemoteDataSource : DataSource {
    override fun query(): String = "remote"
}

class LoggingDataSource(
    private val real: DataSource
) : DataSource by real {
    override fun query(): String {
        println("before query")
        return real.query()
    }
}

: DataSource by real 的意思是: 如果 LoggingDataSource 没有自己实现某个 DataSource 方法, 编译器就自动生成转发代码, 把调用交给 real.

这比继承更灵活: 你可以包装不同实现, 增强行为, 替换数据源, 而不用把类层级设计得很深. Android 里常见于 Repository 包装, 缓存层包装, 埋点 / 日志包装, 测试 fake 实现替换.

易错点: 外层类 override 的成员, 不会改变委托对象内部自己的调用逻辑. 委托对象在执行自己的方法时, 访问的是它自己的成员, 不是外层包装类 override 后的成员.

2. 属性委托: 把属性访问逻辑抽出去

val config by lazy {
    loadConfigFromDisk()
}

var name: String by Delegates.observable("unknown") { property, old, new ->
    println("${property.name}: $old -> $new")
}

属性委托的本质是编译器把属性访问改写为对委托对象的调用:

class StringDelegate {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return "value of ${property.name}"
    }
}

val title: String by StringDelegate()

读取 title 时, 实际调用的是 StringDelegate.getValue(...). 如果是 var, 还需要 operator fun setValue(...).

常见标准库委托:

  • by lazy { }: 首次访问才初始化. 默认 SYNCHRONIZED, 多线程安全但有锁开销; 单线程场景可考虑 LazyThreadSafetyMode.NONE.
  • Delegates.observable: 属性变化时回调, 适合状态监听.
  • Delegates.notNull(): 非空但延迟赋值, 访问前未赋值会抛异常.
  • by map: 属性名作为 key, 从 Map 中读值, 常见于 JSON / 配置映射示例.

Android 落地可以这样理解: ViewBinding 委托, SharedPreferences/DataStore 属性包装, 页面参数校验, 配置中心读取, 本质都是把重复的 getter/setter 或初始化逻辑集中管理.

八, 泛型型变

泛型型变解决的是:泛型类型之间能不能保持原来的父子关系.

List 只读不等于深度不可变

List<T> 是只读接口 / 视图, 它不提供写操作, 但不保证底层集合或元素深度不可变; 同一底层集合仍可经其他 MutableList 引用被修改. MutableList<T> 才提供 add, remove 等写操作. 需要跨模块或并发边界隔离可变状态时, 使用 defensive copy 创建独立快照; 需要持续派生而不共享可变状态时, 选择持久化不可变集合, 并约束元素自身的可变性.

先看一个反直觉点:

val strings: MutableList<String> = mutableListOf("a")
// val anys: MutableList<Any> = strings // 编译不允许

虽然 String 是 Any 的子类, 但 MutableList<String> 不是 MutableList<Any> 的子类. 否则下面这种写法就会破坏类型安全:

val strings: MutableList<String> = mutableListOf("a")
val anys: MutableList<Any> = strings // 假设允许
anys.add(123)                        // 往 String 列表塞 Int
val s: String = strings[1]           // 运行时炸掉

所以 Kotlin 默认让泛型不型变 (invariant). 只有当编译器能确认安全时, 才允许你声明 out 或 in.

关键字心智模型只能做什么典型例子Java 类比
out T生产者 Producer主要把 T 读出来List<out T> / Source<out T>? extends T
in T消费者 Consumer主要把 T 写进去 / 传进去Comparator<in T> / Sink<in T>? super T
不加既读又写精确类型MutableList<T>普通泛型
*不关心具体类型安全读取为上界, 不能安全写入具体 TList<*>?

1. out: 只生产, 所以可以协变

interface Source<out T> {
    fun next(): T
}

val stringSource: Source<String> = object : Source<String> {
    override fun next(): String = "hello"
}

val anySource: Source<Any> = stringSource // 安全:String 一定也是 Any

Source<out T> 只把 T 作为返回值 “吐出来”, 外部不会把错误类型塞进去, 所以 Source<String> 可以当成 Source<Any> 使用.

2. in: 只消费, 所以可以逆变

interface Sink<in T> {
    fun accept(value: T)
}

val anySink: Sink<Any> = object : Sink<Any> {
    override fun accept(value: Any) = println(value)
}

val stringSink: Sink<String> = anySink // 安全:能消费 Any,当然能消费 String

Sink<in T> 只接收 T, 不把具体 T 返回给你. 一个能处理 Any 的消费者, 当然也能处理 String, 所以赋值方向和 out 反过来.

3. 什么时候用什么

记住 PECS: Producer Extends, Consumer Super. Kotlin 写法就是: 生产者用 out, 消费者用 in.

  • 只从容器里读 T: 考虑 out T.
  • 只往对象里传入 T: 考虑 in T.
  • 既要读又要写具体 T: 通常不要加型变, 保持 T 不型变.
  • 不知道具体类型, 只想安全遍历或打印: 用 * 星投影.

面试表达可以这样说: 型变不是为了 “更高级”, 而是为了在不牺牲类型安全的前提下, 让 API 参数更灵活.


版本基线与示例边界

Kotlin/JVM 结论应记录 Kotlin compiler, JDK, JVM target 与 Android desugaring 基线. inline, sealed 类型, 集合实现和标准库 API 可能受编译目标与版本影响; 示例在项目当前 toolchain 中验证后再声称可运行. 官方语义优先参考 Kotlin language/stdlib 文档, 不用反编译某一版本产物外推所有版本. 最后核验: 2026-08-07.

练习与掌握检查

  1. 把 Profile.name 改为只允许非空且长度 1 到 20 的值. 预期: 非法输入在 setter 处失败, nameLength 始终由当前 name 计算.
  2. 为 “网络返回的 Java 平台类型” 写一个边界转换函数: 输入可空 String?, 输出非空展示文案. 说明 !! 在这里为何不是默认方案.
  3. 用 let, run, apply, also 各写一行, 并说明接收者名称和返回值; 能解释选择理由而非只背表格即通过.
  4. 解释为什么 MutableList<String> 不能赋值给 MutableList<Any>, 再把只读参数改成合适的 List 类型.

高频面试题

Q1: == 和 === 的区别? == 比较值 (调用 equals), === 比较引用. Java 的 == 对应 Kotlin 的 ===.

Q2: lateinit 和 by lazy 的区别? lateinit 用于 var, 非空, 可多次赋值, 不能用于基本类型, 由开发者负责初始化时机; lazy 用于 val, 首次访问自动初始化, 线程安全可配置.

Q3: 扩展函数能被重写吗? 为什么? 不能. 扩展函数是静态分发, 编译成静态方法, 调用哪个由声明类型决定而非运行时类型, 所以没有多态.

Q4: inline 一定能提升性能吗? 不一定. inline 在常见调用形态可消除函数对象与调用开销, 适合小型高阶函数; 但 noinline, 捕获, 函数引用, 跨模块调用及不同后端 / 优化器都会影响结果, 不能承诺所有形态零分配. 内联还会增大字节码, 对大函数或大量调用点滥用可能增加体积, 降低性能; 应以目标工具链的字节码和基准测量判断.

Q5: Kotlin 的 Unit, Nothing, Any 区别? Any 是所有非空 Kotlin 类型的根, Any? 才包含 null; Java Object 未标注空性时进入 Kotlin 会形成平台类型, 不能简单等同于二者. Kotlin 基本类型在 JVM 上通常映射为原始类型, 在泛型或可空位置按需装箱. Unit 表示正常完成但无业务结果, 普通 JVM 方法通常映射为 void, 泛型位置则使用真实的 kotlin.Unit 对象; Nothing 表示永不正常返回 (抛异常或死循环), 是所有 Kotlin 类型的子类型.

Q6: data class 用作 HashMap 的 key 安全吗? 安全 (自动生成了 hashCode/equals), 但若字段可变, 作为 key 后修改字段会导致查找失败: 应保证 key 不可变.

Q7: Kotlin 委托的本质是什么? 类委托本质是编译器为接口方法生成转发代码, 把调用交给被委托对象; 属性委托本质是把 getter/setter 改写为 getValue / setValue 调用. 它的价值是用组合复用行为, 减少继承和重复样板代码.

Q8: 为什么 MutableList<String> 不能赋给 MutableList<Any>? 因为 MutableList 既能读也能写. 如果允许赋值, 就可以通过 MutableList<Any> 往原本的 MutableList<String> 里写入 Int, 破坏类型安全. 只读的生产者可以用 out, 只写 / 消费的对象可以用 in, 既读又写通常保持不型变.

Kotlin 进阶专题

Kotlin 进阶不是背语法糖, 而是能把语法, 编译器改写, JVM 字节码和 Android 工程边界串起来. 面试回答要从 “怎么用” 升级到 “为什么这样设计, 代价是什么, 和 Java / 协程怎么交互”.

章节边界与示例说明: 本篇从编译器改写, JVM 表示和跨语言边界深化 Kotlin; 空安全, 作用域函数, 可见性, 普通 class 家族和基础型变先学习语言核心. 除明确标注的 “可运行示例” 外, 代码均为上下文片段, 需要补齐 import, Android / 协程依赖或工程函数;“伪代码” 不保证可编译.

学习目标: 能把高级语法映射到字节码层的普通结构, 说明收益和代价, 并能用一个最小样例验证自己的判断.

一, inline / noinline / crossinline 的真实作用

inline 的核心是让编译器把函数体和可内联 lambda 展开到调用点, 减少高阶函数的对象分配与虚调用开销, 同时支持 lambda 内的非局部返回.

inline fun <T> around(name: String, block: () -> T): T {
    val start = System.nanoTime()
    return try {
        block()
    } finally {
        println("$name cost=${System.nanoTime() - start}")
    }
}
  • inline: 适合小型高阶函数, 频繁调用路径, DSL, 类型检查工具函数.
  • noinline: 某个 lambda 需要被保存, 传递给其他函数或作为对象使用时, 不能内联.
  • crossinline: lambda 可能在另一个对象/线程/回调中被调用, 禁止 return 直接返回外层函数, 避免控制流不安全.
  • 代价: 内联会复制字节码, 函数太大或调用点太多会增加包体积, 影响指令缓存和 R8 优化空间.

面试追问可以补一句: Kotlin 标准库很多集合 / 作用域函数都依赖 inline, 所以 list.forEach { } 并不一定比手写循环多出 lambda 对象.

二, reified 与泛型擦除

JVM 泛型默认擦除, 运行时通常不知道 T 是什么. reified 必须配合 inline, 因为编译器在调用点展开函数时可以把真实类型填进去.

inline fun <reified T> Any?.castOrNull(): T? = this as? T

inline fun <reified T> requireType(value: Any) {
    check(value is T) { "Expected ${T::class.java.name}" }
}

注意边界:

  • reified T 可以做 value is T, T::class, T::class.java.
  • 它不能彻底恢复嵌套泛型实参, 例如 List<String> 与 List<Int> 的元素类型仍受擦除影响.
  • public inline API 会把实现暴露给调用方字节码, 库开发要注意二进制兼容和实现细节泄露.

域案例 (设备指纹 / 风控 SDK): 边界 JSON 解析用 inline reified 在调用点保留类型, 省去到处传 Class<T>:

inline fun <reified T> JsonParser.decode(json: String): T? =
    decode(json, T::class.java) // 内部仍是 Gson/自研解析器 + Class<T>

val device: DeviceProfile? = parser.decode(rawJson) // 调用点推断 T
val rules: RiskRules? = parser.decode(rulesJson)

reified 让 T 在调用点展开, 比显式传 Class<T> 更简洁; 但它仍只保留顶层类型实参, 嵌套泛型 (如 List<RiskRule>) 的元素类型仍受擦除影响, 要靠解析器自身策略处理.

三, 委托与型变的 JVM/ABI 边界

委托的使用语义, getValue/setValue 约定, 以及 out/in/星投影的类型安全基础见 Kotlin 语言核心. 本篇只聚焦编译后与公开 API 的边界: 类委托通常被编译成持有委托对象的字段与接口转发方法, 不是运行时动态代理; 显式 override 只影响经外层对象进入的调用, 不能改写委托对象的内部自调用. 属性委托的 operator 调用, KProperty 元数据以及 provideDelegate 会出现在生成调用路径中, 公共委托类型或 inline 委托实现的变更可能影响二进制兼容.

型变声明也会映射为 JVM 泛型签名 / wildcard: Kotlin 为 Java 调用方生成的 ? extends/? super 受声明位置和 @JvmSuppressWildcards, @JvmWildcard 等注解影响. 它们是 Java ABI 设计工具, 不应用来绕过 Kotlin 的类型安全; 发布 Kotlin/Java 双用 SDK 前, 应编译一个 Java 调用方并检查生成签名.

四, Sequence 的惰性流水线

Iterable 的 filter, map 等操作通常立刻创建中间集合; Sequence 把中间操作串成惰性流水线, 在终止操作拉取元素时才逐个计算. 这适合大输入, 可提前结束的筛选或转换链, 但不是所有集合操作的默认优化.

val firstActiveName = users
    .asSequence()
    .filter { it.active }
    .map { it.name }
    .firstOrNull() // 终止操作, 此时开始按需迭代

Sequence 的中间操作本身不会得到结果; toList(), count(), firstOrNull() 等终止操作才会消费它. sequence { } 通常会在每次调用 iterator() 时重新执行 builder, 因而可重复消费; iterator { } 返回的是 Iterator, 而非 Sequence. 典型的一次性或受限 Sequence 来自单个 Iterator.asSequence(), Enumeration.asSequence() 或 constrainOnce(), 第二次消费可能抛异常. 小集合上的简单链式操作常不值得引入额外迭代器层; 需要随机访问, 反复遍历或调试中间结果时, 直接用集合通常更清晰.

五, 对象表达式, 对象声明与 Java SAM

对象表达式在执行到表达式时创建新对象, 每次执行都是不同实例; 对象声明 (object) 在其作用域内表示单例. 前者适合一次性实现接口或捕获当前局部状态, 后者适合无状态或集中管理的唯一对象.

val first = object : Runnable { override fun run() = println("first") }
val second = object : Runnable { override fun run() = println("second") }
check(first !== second)

object AppLogger {
    fun log(message: String) = println(message)
}

Java SAM 接口只有一个抽象方法, Kotlin 可用 lambda 直接转换为该接口, 例如 Thread { work() }. Kotlin lambda 捕获的是外层变量所代表的状态; 访问外层 this 时仍是外层接收者, lambda 内 return 还可能在 inline 调用中发生非局部返回. 对象表达式有自己的 this, 可实现多个成员并覆盖方法, 但不能使用 lambda 的非局部返回语义. 选择 lambda 表达单一行为; 需要命名状态, 多个重写成员或独立 this 时使用对象表达式.

六, sealed interface, value class 与领域建模

sealed class / sealed interface 用来表达有限状态集合, 让 when 具备穷尽检查. sealed interface 比 sealed class 更灵活: 实现类仍可继承其他类, 也能让枚举, data class, object 同时表达同一协议.

普通直接子类必须与 sealed 声明处于同一 package 和编译 module; 在 multiplatform 项目中还受同一 source set 约束. expect/actual sealed 声明是例外: 可在各自的 source set 声明直接子类, 层级 source set 还可继续扩展该层级; 具体可见性和层级规则应以 Kotlin sealed classes 官方文档 与项目的 source set 结构核验. 间接子类可以在其直接 sealed 子类允许继承的位置继续扩展. 因而跨模块公开 sealed 层级前, 需要把 “未来能否新增分支” 视为 API / 二进制兼容决策.

sealed interface LoginState {
    data object Idle : LoginState
    data object Loading : LoginState
    data class Success(val token: Token) : LoginState
    data class Failed(val message: String) : LoginState
}

@JvmInline
value class Token(val raw: String)

value class 用单字段包装领域概念, 在很多场景下可被编译为底层字段, 减少运行时对象包装. 它适合 UserId, OrderId, Token 这类 “类型不同但底层都是 String/Long” 的值, 能减少参数传错.

限制也要会讲:

  • value class 只能有一个主构造属性, 不能有 backing field 的额外状态.
  • 遇到泛型, 可空, 接口装箱, 反射等场景可能仍会 box.
  • Java 调用时可能看到 mangled 方法名或底层类型, 需要 @JvmName, API 设计和文档约束.

value class 在 JVM 上会生成 mangled 名称, 但主构造属性的 getter 不受影响 (如 DeviceId.raw 的 getter 就是普通的 getRaw()); 带 <hash> 后缀的是涉及 value class 类型的函数签名, 例如接收 DeviceId 参数的顶层函数会编译为 greet-<hash>(String) 形式, 用于区分共享同一底层类型的不同 value class. <hash> 是类全名 (含包名) 的稳定哈希, 同一构建内稳定但不应当作业务 ABI. Java 需要稳定名字时用 @JvmName 显式命名:

@JvmInline
value class DeviceId(val raw: String) {
    @JvmName("rawValue")   // Java 侧调用 deviceId.rawValue()
    fun rawAsString(): String = raw
}

七, context parameters 与迁移边界

Context parameters 让函数显式声明 “调用时必须提供哪些上下文能力”, 适合 DSL, 横切能力和受控作用域. 本文锁定 Kotlin 2.2.0: 该版本的 context parameters 是实验性语言特性, 必须显式启用, 不能把它作为稳定公共 API 能力. 核验入口:Kotlin 2.2.0 What’s new 与 Context parameters 官方文档.

上下文片段 (Gradle Kotlin DSL; 模块已使用 Kotlin 2.2.0 插件):

kotlin {
    compilerOptions {
        languageVersion.set(KotlinVersion.KOTLIN_2_2)
        freeCompilerArgs.add("-Xcontext-parameters")
    }
}

启用条件不只是升级 IDE: 还要确认 Android 构建链, KSP/注解处理器, IDE 和 Java 调用方支持该编译器与实验 flag. 升级 Kotlin 后须重新核验上述官方文档和项目 release note; 公共, 长期稳定或需要 Java 调用的接口, 优先普通参数/构造注入. context parameters 更适合受控 Kotlin 调用链, 不能用来隐藏核心业务依赖.

interface Logger {
    fun log(message: String)
}

context(logger: Logger, scope: CoroutineScope)
fun launchTracked(name: String, block: suspend () -> Unit) {
    logger.log("start $name")
    scope.launch { block() }
}

工程使用前要确认 Kotlin 版本, IDE 支持和对应语言开关. 迁移时将旧写法:

context(Logger, CoroutineScope)
fun launchTracked(...) { ... }

改为带名称的 context parameters, 并在函数体中通过参数名访问能力.

边界判断:

  • Context parameter: 调用链中天然存在, 多个函数共同依赖的环境能力, 例如日志作用域或 DSL 上下文.
  • Extension receiver: 函数主要是在扩展某个对象的操作集合时使用, 一次只能有一个扩展接收者.
  • 普通参数或构造注入: 依赖需要被保存, 替换, 测试或跨较长生命周期使用时, 通常更清晰.
  • 不要为减少几个参数而滥用 context parameters; 隐藏依赖会降低可读性, 并增加 Java 互操作成本.

面试中应把旧 context receivers 作为迁移背景, 而不是当前推荐语法.

八, JVM 字节码角度看 Kotlin

Kotlin 很多高级语法都会落到 JVM 的普通类, 静态方法, 字段和状态机上. 理解这一点能解释性能, 混淆, Java 互操作和调试问题.

Kotlin 语法JVM 视角面试意义
top-level functionFileNameKt 静态方法Java 调用名, @JvmName
extension function静态方法, 接收者是第一个参数静态分发, 不能多态重写
default argument生成 $default 辅助方法和 bitmaskJava 不天然支持默认参数
object单例类 + INSTANCE初始化时机, 反射 / 混淆
suspend function多一个 Continuation 参数协程状态机, 异常栈理解
inline/value class调用点展开或底层值传递性能收益和字节码膨胀边界

因此分析 Kotlin 问题时可以用 javap, Android Studio bytecode viewer 或反编译 Java 结果辅助理解, 但不要机械迷信反编译代码, 因为它只是语义近似.

用 javap 验证, 而不是猜测

可运行示例 (Kotlin/JVM; 需要已安装 kotlinc 与 JDK 的 javap). 将下列内容保存为 BytecodeDemo.kt:

@JvmInline
value class UserId(val raw: String)

fun greeting(id: UserId): String = "hello ${id.raw}"
fun nullableGreeting(id: UserId?): String = id?.raw ?: "guest"

执行: kotlinc BytecodeDemo.kt -d out, 再执行: javap -classpath out -p -c BytecodeDemoKt. 预期观察到顶层函数位于 BytecodeDemoKt, 并可能看到为避免 JVM 签名冲突而生成的 mangled 名称; 实际名称受 Kotlin 编译器版本影响, 不能把某次输出当作稳定 ABI. 再用 javap -classpath out -p UserId 观察包装类.

value class 装箱微示例 (上下文片段):

@JvmInline value class UserId(val raw: String)

fun direct(id: UserId) = id.raw          // 常可使用底层 String 表示
fun nullable(id: UserId?) = id?.raw      // 可空位置需要能表示 null,可能装箱
fun generic(value: List<UserId>) = value // 泛型边界通常需要对象表示

不要依据 “可能装箱” 直接下性能结论. 失败场景是 Java 调用方找不到预期方法或反射名称变化; 证据是 javap 输出和实际 Java 调用编译错误; 定位先检查 mangling 与 nullable/generic/interface 边界; 修复是提供经过设计的 Java 友好桥接 API (如适当的 @JvmName 或普通底层类型入口); 验证是在目标 Kotlin/JDK/Android 工具链重新编译调用方.

九, Java 互操作与空安全陷阱

Kotlin 的空安全在纯 Kotlin 里很强, 但 Java 互操作会出现平台类型 String!, 编译器无法判断可空性.

  • Java 方法无注解返回 String 时, Kotlin 看到的是 String!, 你可以当可空或非空用, 风险由调用方承担.
  • Java 集合可把 null 塞进 MutableList<String> 的底层对象, 导致 Kotlin 侧遍历时 NPE.
  • @Nullable / @NonNull, JSR-305, AndroidX 注解能改善推断, 但依赖库注解质量不一.
  • Kotlin 默认参数, 命名参数, 顶层函数, internal, value class 对 Java 调用并不总是友好, 公共 SDK 要专门设计 Java API.

经验回答: 跨 Java 边界时要把平台类型当 “不可信输入”, 在边界层做 null 归一化, 参数校验和异常转换, 不要让平台类型扩散到核心业务层.

十, 更深一层的协程状态机

suspend 不等于切线程, 它表示函数可挂起. 编译器会把 suspend 函数改写成 Continuation Passing Style (CPS):多一个 Continuation 参数, 局部变量被保存到 Continuation 派生对象字段里, 挂起点用 label 区分.

suspend fun load() {
    val user = api.getUser()   // label 0 挂起点
    db.save(user)              // label 1 挂起点
}

// 直觉模型:
// when(label) {
//   0 -> 调用 getUser,挂起则返回 COROUTINE_SUSPENDED
//   1 -> 恢复 user,继续 save
// }

深入点:

  • 挂起时不阻塞线程, 只是把后续执行封装进 Continuation, 等待回调恢复.
  • 局部变量跨挂起点会变成状态机字段, 所以大对象跨挂起点存活可能延长生命周期.
  • 异常传播仍遵循协程 Job 层级, 不是普通线程未捕获异常那一套.
  • withContext(Dispatchers.IO) 是切换协程恢复的调度器, 不是直接创建新线程.
  • 调试协程栈时要结合 Coroutine Debugger, 结构化并发父子关系和业务日志.

高频面试题

Q1: inline, noinline, crossinline 分别解决什么问题? inline 把函数和 lambda 展开到调用点, 减少对象分配并支持非局部返回; noinline 用于需要把 lambda 当对象保存或传递的参数; crossinline 用于 lambda 会在其他上下文调用时禁止非局部返回, 保证控制流安全.

Q2: reified 为什么必须配合 inline? 因为 JVM 泛型擦除后运行时没有普通 T 的类型信息. inline 会在调用点展开代码, 编译器能把真实类型写入调用点, 所以 T::class, value is T 才可用.

Q3: value class 一定没有对象分配吗? 不一定. 它在很多直接使用场景可用底层值表示, 但遇到泛型, 可空, 接口, 多态, 反射等场景可能装箱. 回答时要强调它主要提升类型安全, 性能收益要看字节码和基准测试.

Q4: Kotlin 空安全为什么仍可能 NPE? 主要来自平台类型, !!, lateinit 未初始化, Java 集合污染, 反射 / 序列化以及并发时序. 跨 Java 边界要做显式 null 校验, 不要让平台类型扩散.

Q5: suspend 函数底层是什么? 编译器把 suspend 函数改写成带 Continuation 参数的状态机, 挂起点用 label 记录进度, 跨挂起点局部变量保存到状态机字段里. 挂起不是阻塞线程, 恢复由调度器和 Continuation 驱动.

易错点 / 追问

  • 不要说 inline 一定更快; 它减少 lambda 开销, 但可能造成字节码膨胀.
  • 不要说 reified 能完整解决所有泛型擦除; 嵌套泛型实参仍有限制.
  • sealed interface 适合有限状态建模, 但跨模块扩展边界和二进制兼容要提前设计.
  • value class 首要价值是领域类型安全, 不是 “零成本对象” 的绝对承诺.
  • Java 互操作时平台类型要当作风险边界处理, 而不是盲信 Kotlin 的非空类型.
  • 不要把类委托讲成运行时动态代理; 它主要是编译器生成接口方法转发.
  • 属性委托不是反射魔法, 核心是 getValue / setValue 约定; KProperty 只是让委托知道属性元信息.
  • 不要把 out / in 当成 “输入输出参数名”; 它们描述的是类型参数在 API 中的安全使用位置.
  • List<*> 不等于 List<Any?>; 星投影表达未知类型, 因此写入能力会被限制.

练习与掌握检查

  1. 对本篇 around 和一个非 inline 的等价函数分别运行 javap, 写下你观察到的调用差异; 未观察到差异时记录编译器版本和命令, 不要臆测.
  2. 将 UserId 分别用于直接参数, 可空参数, 泛型集合和接口参数. 预测哪些位置可能装箱, 再以目标工具链字节码验证.
  3. 为一个需要 logger 的函数比较普通参数, 扩展接收者和 context parameter; 说明为何公共 Java API 不选择后者.
  4. 能解释一例平台类型 NPE 的 “输入来源→边界校验→修复→验证”, 即达到本篇掌握标准.

Kotlin 协程与 Flow ★

这是你的头号短板, 也是中级面试的高频考点. 协程是现代 Android 异步编程的基石, 几乎必问. 本篇从原理到实战到面试题完整覆盖.

前置知识与示例说明: 先掌握 Kotlin 空安全, lambda 和异常 (见 Kotlin 语言核心).除明确标注 “可运行示例” 的代码外, 本文为 Android / 协程依赖下的上下文片段; 其中 api, repo, render 等名称由所在工程提供. 伪代码只用于解释状态机, 不能直接编译.

学习目标: 能画出一个请求的挂起 / 恢复与 Job 父子关系, 能在 ViewModel 中把冷流转换为可恢复的 UI 状态, 并能说明取消, 异常和缓冲的边界.

一, 协程是什么 (先建立心智模型)

协程不是线程. 它是一种可挂起 / 恢复的计算, 由编译器 + 运行时在用户态调度.

  • 挂起 (suspend): 不阻塞线程. 协程挂起时, 它所在的线程被释放去做别的事, 等条件满足再恢复执行.
  • 核心价值: 用同步顺序的写法表达异步逻辑, 消灭回调地狱.
// 回调写法
api.login(user) { token ->
    api.getProfile(token) { profile -> updateUI(profile) }
}
// 协程写法
val token = api.login(user)        // suspend,挂起不阻塞
val profile = api.getProfile(token) // suspend
updateUI(profile)

二, suspend 原理 (高频追问: 协程为什么不阻塞线程?)

suspend 函数由 Kotlin 编译器做 CPS 变换 (Continuation-Passing Style):

  1. 编译器给每个 suspend 函数隐式加一个参数 Continuation(回调), 返回值变成 Any?.
  2. 函数体被编译成一个状态机: 每个挂起点是一个状态.
  3. 挂起时函数返回特殊标记 COROUTINE_SUSPENDED, 线程被释放.
  4. 当结果就绪, 调用 continuation.resumeWith(result), 状态机从上次挂起点恢复执行.

所以协程 “挂起不阻塞” 的本质:把后续代码包装成回调, 挂起时退出函数让出线程, 恢复时再回来. 这是面试最爱的深挖点.

一次请求可以按下列顺序追踪. 这里的 “恢复” 由挂起 API 和 dispatcher 决定, 不能假定回到同一物理线程:

viewModelScope.launch (Job A, Main)
  -> api.fetch() 发起异步 I/O
  -> 返回 COROUTINE_SUSPENDED,Main 可处理其他消息
  -> I/O 完成,Continuation 恢复 Job A
  -> dispatcher 安排后续代码,更新 _uiState
// 你写的:
suspend fun login(user: User): Token
// 编译后近似:
fun login(user: User, cont: Continuation<Token>): Any?

三, 结构化并发 (Structured Concurrency)

协程必须在 CoroutineScope 中启动. 核心:协程有父子层级, 父协程等待所有子协程完成, 父被取消则子全部取消, 避免协程泄漏.

  • CoroutineScope: 协程作用域, 持有 CoroutineContext.
  • CoroutineContext: 元素集合, 关键有 Job(生命周期), CoroutineDispatcher(线程), CoroutineName, CoroutineExceptionHandler.
  • Job: 协程生命周期句柄, 可 cancel() / join(), 状态有 New/Active/Completing/Cancelling/Cancelled/Completed.
  • Job vs Deferred: Deferred<T> 是携带结果的 Job, 继承 cancel()/join() 并增加 await(); async 返回 Deferred. 取消一个 async 的任务后, await() 会抛出 CancellationException; 若该 Deferred 已成功完成, 取消不再影响其结果.
  • 父子关系: 协程内启动的子协程, 其 Job 是父 Job 的子节点.

Android 常用现成 scope:

  • viewModelScope: 绑定 ViewModel, onCleared 自动取消.
  • lifecycleScope: 绑定 Lifecycle.
  • GlobalScope:不推荐, 脱离结构化并发, 易泄漏.

父子 Job 图 (伪图) 应当能在面试时画出:

ViewModel.onCleared()
  └─ viewModelScope 的 SupervisorJob
       └─ launch load()(子 Job)
            ├─ async loadProfile()(子 Job)
            └─ async loadFeed()(子 Job)

viewModelScope 可理解为 SupervisorJob, Dispatchers.Main.immediate 和 ViewModel 清理回调组合出的作用域, 具体实现随 lifecycle 版本演进. 父 scope 取消会向下取消所有子 Job; 其中的 supervisor 语义避免一个直接子任务失败自动取消其他直接子任务, 但你仍要在各任务处把错误归约为明确状态. 不要把 “使用 viewModelScope” 误解为所有异常都会自动显示给用户.

coroutineScope 与 supervisorScope 都等块内子协程全部完成才返回, 区别在失败传播: coroutineScope 下任一子协程失败会取消整个作用域 (包括所有兄弟) 并向外抛出异常; supervisorScope 隔离失败, 直接子协程的异常不会取消兄弟或自身, 但该异常仍要由对应子协程处理或 await() 观察. 选型: 多个步骤是同一事务, 失败即整体回滚 → coroutineScope; 多个相互独立的卡片 / 接口并行加载, 一个失败不拖垮其他 → supervisorScope.

class MyViewModel : ViewModel() {
    fun load() = viewModelScope.launch {       // ViewModel 销毁自动取消
        val data = repo.fetch()                 // suspend
        _state.value = data
    }
}

四, Dispatchers 与 withContext

Dispatcher用途线程池
Dispatchers.MainUI 操作主线程
Dispatchers.IO网络 / 磁盘 IO共享弹性调度器; 默认并行度至少为 64 或可用处理器数 (取较大者)
Dispatchers.DefaultCPU 密集 (排序 / 解析)核数大小
Dispatchers.Unconfined不限定 (测试 / 特殊)当前线程, 恢复后随挂起点
viewModelScope.launch {                         // Main
    val data = withContext(Dispatchers.IO) {    // 切到 IO
        api.fetch()
    }
    textView.text = data                        // 自动回到 Main
}

withContext 切换上下文并挂起等待结果返回, 是最常用的线程切换方式. 当前 kotlinx.coroutines 文档的 Dispatchers.IO 默认并行度为 “至少 64 或可用处理器数” 的较大值, 仍可由系统属性配置; IO.limitedParallelism(n) 创建的弹性视图可拥有独立并行度配额, 因此不能把总线程数简单等同于表中的默认值. IO 与 Default 共享底层线程资源, 两者间切换不一定真正换线程; 版本升级时应以目标 kotlinx.coroutines 文档和实际负载重新核验. 其中 “64” 是默认值, 随版本调整.

limitedParallelism 与 Semaphore 隔离层次不同: 前者在调度器层给视图划独立并发配额, 该视图上的任务最多 n 个同时执行, 不会与他处代码互相挤占线程; Semaphore 只在应用逻辑层限制在途任务数, 底层仍共享同一 IO 池, 不预留资源. 需要 “为某模块独立线程预算” 用 limitedParallelism, 需要 “限制打某下游的并发请求数” 用 Semaphore.

五, launch vs async

  • launch: 返回 Job, 不返回结果, 适合副作用; 未处理异常会按父 Job / 根协程规则传播.
  • async: 返回 Deferred<T>, await() 用于取得结果; 失败会存为 Deferred 的完成结果, await() 在观察它时重新抛出该异常.
// 并发请求,总耗时 = max 而非 sum
val a = async { api.getA() }
val b = async { api.getB() }
val result = a.await() + b.await()

await() 不是异常传播的唯一开关. 普通父 Job 中, 一个子 async 失败会立即取消父及兄弟, 即使尚未 await(); 随后 await 只是在调用点重新抛出该失败. supervisorScope 中失败的 async 不会取消兄弟或 supervisor, 但其异常仍保存在对应 Deferred, 调用方必须 await()/检查或以其他方式处理. 根 async(没有结构化父 Job, 例如不推荐的独立 scope) 若既没有 await 又没有显式观察 / 处理, 异常可能只停留在 Deferred, 因而容易被遗漏; 这不是 “所有不 await 都一定静默”.

// 上下文片段:三种异常边界,省略 dispatcher 与错误呈现.
coroutineScope {
    async { error("ordinary child failure") } // 立即取消本 coroutineScope
}

supervisorScope {
    val independent = async { error("independent failure") }
    // 兄弟可继续;此处必须在合适位置 independent.await() 并处理异常.
}

六, 取消机制 (协作式)

协程取消是协作式的: 取消只是把 Job 标记为 Cancelling, 真正停止需要协程代码配合检查.

  • 可取消的 suspend 挂起点 (delay 等) 在恢复时会检查取消状态, 抛出 CancellationException; withContext(NonCancellable) 显式排除或未真正挂起即返回的挂起点除外.
  • 纯 CPU 循环不会自动响应取消, 需手动检查 isActive / ensureActive() / yield().
val job = scope.launch {
    while (isActive) {        // 配合检查,否则取消无效
        doHeavyWork()
    }
}
job.cancel()                  // 标记取消

易错点:

  • CancellationException 是正常的取消信号, 不要在 catch 中吞掉它, 否则破坏取消. try/catch (e: Exception) 会误捕获它, 应 catch (e: CancellationException) { throw e } 或只 catch 具体异常.
  • 取消后想做清理用 try/finally, 但 finally 中若要再调挂起函数, 需用 withContext(NonCancellable).

七, 异常处理

  • launch: 异常会立即向上传播给父 Job, 触发父及所有兄弟取消.
  • async: 失败会记录在 Deferred; 普通父 Job 下子 async 失败会立刻取消父与兄弟, await() 只在调用处重新抛出该结果. supervisorScope 下失败不自动取消兄弟, 但 await 仍会重新抛出; 根 async 通常由 await 或其他显式观察结果的方式暴露失败.
  • CoroutineExceptionHandler: 只对 launch 的根协程生效, 作为 “兜底” 处理未捕获异常.
  • SupervisorJob / supervisorScope: 子协程失败不影响兄弟和父协程 (单向传播).适合多个独立任务的场景 (如同时加载多个卡片, 一个失败不拖垮其他).
supervisorScope {
    launch { riskyA() }   // A 失败不影响 B
    launch { riskyB() }
}

对比记忆: 普通 Job 一个子失败全家取消; SupervisorJob 子失败各自负责.

八, Flow (冷流)

Flow 是协程版的 “异步数据流”, 可以发射多个值 (协程的 suspend 函数只返回一个值).

  • 冷流 (Cold): Flow 默认是冷的: 没有收集者就不执行, 每个 collect 都重新触发上游. 类比 “按需播放的录像”.
  • 构建: flow { emit(x) }, flowOf(), asFlow().
  • 操作符: map/filter/transform(中间操作, 惰性), collect/first/toList(末端操作, 触发执行).
  • 线程切换: flowOn(Dispatchers.IO) 只影响上游; 收集所在线程由 collect 处的 scope 决定. 不要在 flow {} 里用 withContext 切线程 (会报错), 要用 flowOn.
  • 背压: buffer() (并发缓冲, 让上游发射和下游收集可在缓冲容量内解耦), conflate()(下游慢时只保留最新值, 适合 UI 进度 / 状态), collectLatest(新值到来取消上个收集块, 适合搜索联想 / 列表刷新).边界是: 这些操作符优化的是 “生产快, 消费慢” 的处理方式, 不应掩盖下游耗时任务本身; 被取消的收集块仍要遵守协作式取消.
flow {
    emit(fetchFromNetwork())   // 上游在 IO
}.flowOn(Dispatchers.IO)
 .map { it.toUiModel() }
 .collect { render(it) }       // 收集在调用方线程(如 Main)

九, StateFlow 与 SharedFlow (热流)

热流 (Hot): 不管有没有收集者都 “活着”, 发射独立于收集者. 用于状态 / 事件, 是 Android MVVM 中替代 LiveData 的主力.

StateFlow

  • 持有一个最新值, 新收集者立即拿到当前值 (类似 LiveData).
  • 必须有初始值, value 可读可写 (MutableStateFlow).
  • 去重: 值相等 (equals) 时不发射 (conflate 语义).
  • 适合表达 UI State.
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
_uiState.value = UiState.Success(data)

SharedFlow

  • 可配置 replay, extraBufferCapacity 和 onBufferOverflow, 适合广播允许丢失或明确允许重放的信号.
  • replay = 0 在没有订阅者时不会保留值, 因此不能保证 UI 在 STOPPED, 旋转, 切后台或进程重建期间一定收到事件.
  • 对于必须保证被处理的业务结果, ViewModel 应将其立即转换为可恢复的 UI state; UI 观察状态并在处理后回调 ViewModel 清除或推进状态.
  • 纯 UI 局部且允许丢失的瞬时效果, 才可以在明确语义并有测试的前提下使用 SharedFlow/Channel.

对比表

维度LiveDataStateFlowSharedFlow
库Jetpack协程协程
初始值否必须否
生命周期感知自带需 repeatOnLifecycle需 repeatOnLifecycle
去重否是否
粘性是是可配 replay
适用老项目状态新项目状态事件

Android 正确收集姿势 (避免后台浪费):

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}

repeatOnLifecycle 由 lifecycle-runtime-ktx 2.4.0 引入. 旧写法 lifecycleScope.launch { viewModel.uiState.collect{...} } 会一直收集直到整个 scope 取消, 退后台不停止, 回前台不重启; repeatOnLifecycle 在进入指定状态时开启收集, 离开时取消, 再次进入重新启动.

进阶补充: Channel, callbackFlow 与测试

Channel 与 callbackFlow

Channel 更像协程里的阻塞队列, 适合一对一事件传递; callbackFlow 用于把回调式 API 包装成 Flow.

fun locationFlow(): Flow<Location> = callbackFlow {
    val listener = LocationListener { location -> trySend(location) }
    locationClient.addListener(listener)
    awaitClose { locationClient.removeListener(listener) }
}

关键点: awaitClose 必须释放监听器, 否则会泄漏.

Mutex / Semaphore: 限流与串行化

协程版 Mutex 提供互斥, Semaphore 限制并发进入数量; 与线程锁不同, 竞争时挂起协程而不是阻塞线程.

// 上下文片段: 上报串行化, 事件必须按产生顺序出网, 避免乱序与重复.
private val reportMutex = Mutex()

suspend fun enqueueReport(event: RiskEvent) {
    reportMutex.withLock {          // 排队: 同一时刻只有一个上报在途
        sender.send(event)          // send 可取消时, withLock 自动释放
    }
}

// 限流: 最多 3 个并发请求打采集服务
private val permits = Semaphore(3)
suspend fun rateLimitedCollect() {
    permits.withPermit { api.collect() }
}

风控场景用它保证 “上报串行 + 限流”: 事件队列消费者逐个上报, 采集失败重试时也不会乱序; 需要节流并发请求时用 Semaphore, 而不是无限开协程.

combine / zip / flatMapLatest

操作符行为场景
combine任一上游变化就用最新值组合表单状态, 多个配置源
zip等两边一一配对两个一次性结果合并
flatMapLatest新请求来时取消旧请求搜索框, 筛选条件变化

stateIn / shareIn

stateIn 把冷 Flow 转成有当前值的 StateFlow; shareIn 把冷 Flow 共享给多个订阅者. 面试要说明 scope, SharingStarted 策略, replay, 以及订阅者消失后上游是否继续运行. UI 常用 WhileSubscribed(stopTimeoutMillis=...), 但超时时间必须依据返回前台频率, 上游成本和数据新鲜度测量, 不能机械套固定值.

上下文片段 (Android ViewModel, 省略 UiState, repository, 依赖注入与 import):

class FeedViewModel(
    repository: FeedRepository
) : ViewModel() {
    val uiState: StateFlow<UiState> = repository.observeFeed()
        .map<Feed, UiState> { UiState.Content(it) }
        .onStart { emit(UiState.Loading) }
        .catch { error -> emit(UiState.Error(error.message ?: "Unknown error")) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
            initialValue = UiState.Loading
        )

    val sharedRefreshes: SharedFlow<RefreshResult> = repository.observeRefreshes()
        .shareIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
            replay = 1
        )
}

上例的 5_000 是待测量的示例值, 不是推荐常量: 若短暂旋转或切换导航页很常见, 上游建立昂贵且数据允许短暂复用, 可从短超时开始; 若数据敏感, 上游成本低或后台保持订阅有风险, 应选择 0 或更严格策略. 测量前后台切换频率, 上游重连 / 查询次数, 内存和数据陈旧窗口, 再调整并记录依据. Eagerly 会立刻启动且在 scope 存活期间持续; Lazily 首次订阅后持续; WhileSubscribed 在订阅者为零后按超时停止并可配置 replay 过期. 选择错误的症状是离屏仍持续请求或回前台频繁闪烁加载; 用日志/网络拦截和生命周期事件取证, 定位 started/replay/scope, 调整后重复前后台与旋转步骤验证.

高频 Flow 的错误, 重试与时间语义

catch 能捕获它上游抛出的异常: 它可捕获 flow {}, 上游操作符和其上游内部流抛出的异常, 不能捕获位于它下游的 collect 块或后续操作符异常. 它同样不应把取消转成业务错误. retry/retryWhen 只重试暂时性且幂等安全的失败, 并必须限制次数或总时限. 搜索中应把 retry 放在每次 flatMapLatest 产生的内部请求流内, 否则外层重试会重新订阅输入流, 语义不是 “重试当前请求”. debounce 表示一段静默后才发送, 适合文本搜索; sample 表示按周期取期间最新值, 适合允许丢失的高频展示. 二者都改变事件语义, 不能用于每一条都必须处理的交易, 消息或写入.

// 上下文片段:query 是用户输入 Flow;search 为 suspend 且可取消的 repository 方法.
val results: Flow<SearchState> = query
    .debounce(300)
    .map(String::trim)
    .distinctUntilChanged()
    .flatMapLatest { keyword ->
        flow { emit(repository.search(keyword)) }
            .retry(retries = 2) { error -> error is IOException }
    }
    .catch { error -> emit(SearchState.Error(error.message ?: "search failed")) }

val sampledProgress: Flow<Int> = downloadProgress.sample(250)

300 和 250 同样只是假设值. 用输入事件时间戳, 请求数, 首个结果延迟和用户可感知刷新频率测量; 当请求仍过多提高 debounce, 当等待感明显则降低, 前提是业务允许. 以下才是会吞掉取消的反例:

// 上下文片段:不要这样写;Exception 包含 CancellationException.
suspend fun loadSafely(): UiState = try {
    UiState.Content(repository.load())
} catch (error: Exception) {
    UiState.Error(error.message ?: "load failed")
}

失败链路: 症状是取消页面后仍显示错误或不断重试; 证据是上述手写 try/catch (Exception) 将 CancellationException 归约为错误状态, 或 retry 未限制; 定位异常类型与 owner Job; 修复是先 catch (error: CancellationException) { throw error }, 再处理具体业务失败, 并限定可重试错误; 验证是开始请求后销毁 owner, 确认无新请求和无错误 UI 更新.

竞态叙事 (双请求竞态, 事件上报去重核心): 症状是刷新后偶发旧数据覆盖新结果; 证据是同一 key 两次请求的完成顺序与 _uiState 最终值矛盾; 定位为旧请求后完成, 覆盖了新值; 修复是启动新请求时取消旧请求 (协程取消或 flatMapLatest), 并把响应经版本号 update 归约进 StateFlow; 验证是注入两个不同延迟的响应, 断言最终 StateFlow 值为新请求结果.

协程测试

使用 runTest, StandardTestDispatcher 和虚拟时间, 不要在测试里真实 delay.

runTest 用虚拟时间执行: delay 不真实等待, 而是把任务排入虚拟时钟. 用 advanceTimeBy(ms) 快进虚拟时钟触发定时任务, 用 runCurrent() 立即执行当前虚拟时刻上已排队的任务; 断言 delay(300) 之后的逻辑时, 应先 advanceTimeBy(300) 再检查. 虚拟时间不要与真实 Thread.sleep 混用, 否则测试变慢且时序不稳.

需要测 Dispatchers.Main 相关逻辑时, 用 Dispatchers.setMain(testDispatcher) 替换主线程调度器, 测试结束用 Dispatchers.resetMain() 还原; 配合 StandardTestDispatcher 可手动控制协程的调度时机, 精确复现 “挂起后恢复” 的时序.

追问: 为什么 callbackFlow 里要写 awaitClose? 因为 Flow 被取消时需要注销外部回调, 否则回调继续持有对象导致泄漏.


高频面试题

Q1: 协程和线程的区别? 线程是 OS 调度的重量级资源; 协程是用户态的轻量 “可挂起计算”, 多个协程可复用少量线程. 协程挂起不阻塞线程. 一个线程能跑成千上万协程.

Q2: suspend 关键字做了什么? 协程凭什么不阻塞线程? 编译器对 suspend 函数做 CPS 变换, 加 Continuation 参数, 函数体编译成状态机. 挂起时返回 COROUTINE_SUSPENDED 并释放线程, 结果就绪后通过 resumeWith 从挂起点恢复. 所以是 “挂起协程” 而非 “阻塞线程”.

Q3: launch 和 async 区别? async 不 await 会怎样? launch 返回 Job 无结果, async 返回 Deferred 有结果. 普通父 Job 的子 async 失败会立刻取消父和兄弟, await 负责在调用点重新抛出结果; supervisorScope 下失败不取消兄弟, 但仍应 await / 处理对应 Deferred; 只有没有结构化父级且从未观察的根 async 才容易遗漏异常, 不能概括为 “不 await 一定静默”.

Q4: 协程的取消是怎样的? 为什么有时 cancel 无效? 协作式取消. cancel 只标记状态, suspend 函数恢复时检查并抛 CancellationException. 纯 CPU 循环不调用 suspend 函数, 不会响应取消, 需手动 isActive/ensureActive/yield.

Q5: Job 和 SupervisorJob 区别? 普通 Job: 子协程异常会取消父和所有兄弟. SupervisorJob: 子异常只影响自己, 不向上传播取消兄弟. 多个独立任务用 supervisorScope.

Q6: CoroutineScope, CoroutineContext, Job 的关系? Scope 持有 Context; Context 是元素集合 (Job, Dispatcher 等); Job 管理生命周期与父子层级. 三者共同实现结构化并发.

Q7: Flow 冷热的区别? StateFlow 和 SharedFlow 怎么选? 冷流无收集者不执行, 每次 collect 重新触发; 热流的生产生命周期由其 scope 和 started 策略决定. 持久页面状态通常用 StateFlow; 必须保证处理的业务结果先归约为 UI state. SharedFlow/Channel 只适合已经明确允许丢失, 重放和多订阅语义的信号, 不能靠 replay=0 获得 “恰好一次” 保证.

Q8: 为什么用 StateFlow 替代 LiveData? LiveData 有什么坑? StateFlow 不依赖 Android, 可在纯 Kotlin 层用, 操作符丰富, 与协程统一. LiveData 的坑: 粘性事件 (新观察者收到旧值), 只能主线程 setValue, observeForever 易泄漏. 但 StateFlow 不感知生命周期, 需 repeatOnLifecycle.

Q9: flowOn 和 withContext 区别? 能在 flow {} 里 withContext 切线程吗? 不能. flow {} 内 emit 必须在收集协程的上下文, 直接 withContext 会抛异常 (违反上下文保留).要切上游线程用 flowOn, 它只影响上游操作符.

Q10: repeatOnLifecycle 解决什么问题? 解决 “App 退后台时仍在收集 Flow 浪费资源 / 可能崩溃”.它在进入指定状态 (如 STARTED) 时启动收集, 离开时取消, 再次进入重启, 是官方推荐的安全收集方式.

练习与掌握检查

  1. 画出一次 “页面加载后立即返回” 的 Job 图, 标出哪个 owner 取消, 哪个 Flow 停止, 哪个状态会由新 collector 得到.
  2. 在最小 ViewModel 中实现上面的 stateIn, 用 fake repository 统计订阅次数; 旋转 / 停止再恢复页面, 验证策略是否符合你选择的超时语义.
  3. 为搜索输入分别用 debounce 和 sample 记录时间线, 说明为何前者更符合 “输入停止后查询”.
  4. 制造一个手写 try/catch (Exception) 吞掉取消的版本, 按 “症状→证据→定位→修复→验证” 复盘; 能复盘并修复即达到掌握标准.

RxJava 与响应式编程

RxJava 仍常见于存量 Android 项目. 面试重点不是背操作符, 而是能解释 RxJava 2/3 版本边界, 流语义, 背压, 资源释放, 错误终止以及向协程/Flow 的渐进迁移.

前置知识与示例说明: 先理解线程, 异常和集合基础 (见 Java 与 JVM 基础); 与协程 Flow 的冷热流, 取消语义对照见协程与 Flow. 本文代码均为上下文片段: 须按项目的 RxJava 2 或 RxJava 3 主版本补齐依赖, Android scheduler, api 和 UI 方法, 不能混用包名后声称可运行.

学习目标: 能根据事件是否可丢, 是否有序, 消费是否跟得上来选择 Rx 类型与操作符, 且能给出订阅释放和错误终止的验证方法.

一, 版本与类型选择

RxJava 2 与 RxJava 3 的核心模型相近, 但包名, 依赖坐标和生态 adapter 不兼容. 维护项目时先确认实际主版本及 RxAndroid/Retrofit adapter 的匹配关系, 不要在同一业务链路中无计划混用.

类型发射约束常见场景
Observable<T>0..N, 无背压协议UI 事件, 低频流
Flowable<T>0..N, 有背压协议高频或跨异步边界的数据流
Single<T>1 个成功值或错误必须有结果的请求
Maybe<T>0 或 1 个成功值或错误可为空的缓存查询
Completable完成或错误, 无值写入, 删除, 同步动作

类型选择应表达业务基数, 不要把所有接口都包装为 Observable.

二, 变换操作符的语义差异

  • map: 一对一同步变换; mapper 返回普通值.
  • flatMap: 一对多并发合并, 结果顺序通常不保证与输入一致.
  • concatMap: 按上游顺序串行订阅内部流, 以吞吐换顺序.
  • switchMap: 新值到来时取消 / 切换旧内部流, 适合搜索联想等 “只关心最新结果” 的场景.
  • zip: 按位置配对, 任一源无法继续配对时可能结束.
  • combineLatest: 各源至少发射一次后, 任一源更新都与其他源最新值组合.

面试回答要给出并发, 顺序, 取消和错误传播差异, 不能只说 “都是把一个流变成另一个流”.

三, 调度器与线程边界

subscribeOn 影响订阅及其上游执行位置, 多次调用时通常由最靠近源且实际生效的调度决定; observeOn 从出现位置起切换下游, 可以多次使用. 不要把 “第一个 / 最后一个生效” 背成脱离具体 operator 的绝对口诀.

val disposable = api.getUser()
    .subscribeOn(Schedulers.io())
    .map(::toUiModel)
    .observeOn(AndroidSchedulers.mainThread())
    .doFinally { hideLoading() }
    .subscribe(::render, ::showError)

doFinally 在完成, 错误或 dispose 后都会执行, 适合对称收尾; doOnComplete 不覆盖错误和取消. 重 CPU 计算应使用合适 scheduler, 不能因为上游是网络请求就把所有变换都放在主线程.

四, 背压不是 “加个 BUFFER”

当生产速度持续高于消费速度时, 无界缓存会把速度问题转成内存和延迟问题. Flowable 策略要按业务语义选择:

  • BUFFER: 不丢数据的诉求不等于天然有界; 必须明确容量, 上限和溢出策略.
  • DROP: 丢弃无法及时消费的数据, 适合允许采样的场景.
  • LATEST: 只保留最新值, 适合状态刷新, 不适合交易或消息流水.
  • ERROR: 让过载显式失败, 由上层降级或限流.

还应优先考虑上游限速, 批处理, 窗口, 采样和减少无效工作. 背压策略必须与 “是否允许丢失, 是否要求顺序, 最大延迟” 一起回答.

Subject 家族: 热源, 状态与生命周期

Subject 同时是 Observer 和 Observable, 常被用作桥接回调的热源; 它不是默认的全局事件总线. 应优先把状态放在单一 owner, 避免任意位置都能 onNext 造成来源不可追踪. 大多数 Subject 的 onNext/onError/onComplete 不可由多个线程并发调用; 若确实有多个生产线程, 创建后立即用 subject.toSerialized() 暴露串行化入口, 并仍由一个 owner 定义事件顺序. 终止后不得再发射: onComplete/onError 是终止信号, 后续 onNext 无效; 未被订阅者消费的错误还可能进入全局错误处理器, 因此必须在 source 边界处理.

Subject新订阅者看到什么适合什么主要风险
PublishSubject订阅后的新事件纯瞬时且允许错过的局部信号订阅前事件丢失
BehaviorSubject最新一个值 (若尚未终止)有当前状态的热源不接受 null; 把事件误当状态会重放旧动作
ReplaySubject历史缓存明确需要回放的有限历史无界 replay 会增长内存
AsyncSubject完成前最后一个值只关心最终结果的特殊桥接未完成就不会发射
// 上下文片段:RxJava 3 写法;RxJava 2 的包名不同.
private val searchInput = PublishSubject.create<String>().toSerialized()

val results = searchInput
    .map(String::trim)
    .debounce(300, TimeUnit.MILLISECONDS)
    .distinctUntilChanged()
    .switchMapSingle { keyword -> api.search(keyword) }

这里 debounce 的时间线是 a(0ms) -> an(100ms) -> and(250ms) -> 550ms 发射 and; throttleLatest(300ms, unit, emitLast) 按窗口尽快发射一个值并保留窗口内最新值, emitLast = true 会在上游完成时补发最后暂存值, false 则不补发; 它适合允许周期性刷新. sample(300ms) 在采样时刻取最近值, 可能一个值也不发射; throttleFirst(300ms) 保留每个窗口第一值, 适合防连点. 300ms 只是示例: 用输入间隔, 请求数和可感知等待时间测量后调整. 搜索用错 flatMap 的症状是旧请求晚返回覆盖新结果; 证据是请求 ID 与返回顺序相反; 定位为没有取消旧 inner source; 修复为 switchMap 并确认 source 支持 dispose; 验证为故意让旧请求更慢, 检查 UI 只显示最新关键词结果.

背压策略的可操作选择

Observable 没有下游 request 协议; 从它转为 Flowable 时必须声明过载语义. Flowable.create(source, BackpressureStrategy.BUFFER) 本身使用无界缓冲, 不能称为有限 BUFFER. 需要容量上限时, 选择能提供有界队列的 source, 或在合适边界调用 onBackpressureBuffer(capacity, onOverflow, strategy); 也要区分 observeOn 等异步 operator 可能拥有自己的有界预取缓冲, 这不等于 source 已具备端到端背压策略. 示例: 传感器 UI 仪表可以丢中间读数, 审计流水不能丢也必须保序.

// 上下文片段:仅当业务允许丢弃中间仪表值.
val latestReading = sensorObservable
    .toFlowable(BackpressureStrategy.LATEST)
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(::renderReading, ::showError)

先记录生产频率, 下游处理耗时, 队列深度, 允许的最大陈旧时间和丢失比例; 若所有记录必须处理, 应采取上游限速/批处理/持久化队列或 ERROR 后明确降级, 而不是无界 BUFFER. 典型失败是内存增长和 UI 显示数秒前状态; 证据是队列长度, 堆快照和事件时间戳; 定位为生产速率长期大于消费速率; 修复按上述业务语义选择限速, 批处理或策略; 验证在压测 / 可控 fake 高频源下观察队列上限和延迟, 不虚构结果.

五, 生命周期与资源释放

页面或 owner 应集中管理订阅:

private val disposables = CompositeDisposable()

fun load() {
    disposables.add(repository.observeUser()
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(::render, ::showError))
}

fun clear() {
    disposables.clear() // owner 仍可能复用; dispose() 后容器不可再接收新订阅
}

注意:

  • clear() 取消当前集合但允许继续 add; dispose() 会永久标记容器已释放.
  • 长生命周期单例订阅短生命周期 View/Activity 容易泄漏.
  • dispose 是取消请求继续向下游交付, 不等于底层工作一定瞬时停止; 取决于 source 是否支持取消.
  • 生命周期库只能帮助触发 dispose, 不能修复错误的 owner 和共享链设计.

六, 错误, 重试与终止

Rx 流一旦向下游发送 terminal error 就结束. onErrorReturn/onErrorResumeNext 会把错误转换为值或替代流, 需要避免吞掉不可恢复错误.

retryWhen 必须有终止条件:

  • 仅重试可恢复错误, 不重试鉴权失败, 参数错误等确定性失败.
  • 指数退避并加入 jitter, 避免客户端同时重试.
  • 设置最大次数或总时限.
  • owner 销毁, 网络条件不满足或用户取消时停止.
  • 重试写操作前确认服务端幂等键和业务语义.

七, 向协程与 Flow 渐进迁移

不要一次性重写整个存量项目. 推荐从边界开始:

  1. 固定 RxJava 主版本并补行为测试.
  2. 新 Retrofit API 优先使用 suspend; DAO/SDK 边界按真实支持选择适配方式.
  3. 在 repository 边界集中做 Rx/Flow/suspend 转换, 避免 UI 层同时理解两套模型.
  4. 明确取消, 热/冷流, 背压/缓冲, 错误终止和线程语义的差异.
  5. 逐模块迁移并比较稳定性, 复杂度和性能, 不以 “新技术” 本身作为改造收益.

Flow 的挂起可形成自然背压, 但 buffer, conflate, SharedFlow 等仍可能丢失或积压; 不能把 Flow 简化为 “自动解决所有背压”.

高频面试题

Q1: flatMap, concatMap, switchMap 怎么选?
看是否允许并发, 是否要求顺序, 旧任务是否应取消. 并发合并用 flatMap, 严格顺序用 concatMap, 只关心最新结果用 switchMap.

Q2: CompositeDisposable.clear() 和 dispose() 的区别?
二者都释放当前订阅; clear() 后可继续 add, dispose() 后容器保持 disposed, 新加入项会立即被释放.

Q3: 重试为什么危险?
无限重试会放大故障和功耗, 写操作还可能重复扣款或写入. 必须分类错误, 限次, 退避, 加 jitter, 并依赖服务端幂等.

练习与掌握检查

  1. 为搜索, 下载进度, 审计流水分别选择 Subject/Rx 类型和时间操作符, 写出是否允许丢失, 重放和乱序的理由.
  2. 用一个可控 fake source 让上游快于下游, 记录队列或时间戳, 比较 LATEST 与有限 BUFFER 的业务后果.
  3. 在页面销毁后调用 CompositeDisposable.clear(), 验证订阅不再更新 View; 若仍更新, 按 “owner→订阅位置→dispose 支持→验证” 排查.
  4. 能解释上述搜索时间线, 旧请求覆盖问题和背压选择, 即达到本篇掌握标准.

版本与参考资料

  • 最后核验: 2026-08-07.
  • 适用基线: 先确认项目使用 RxJava 2 还是 RxJava 3, 以及 RxAndroid/Retrofit/Room 等 adapter 的对应版本.
  • 官方资料: RxJava 项目, RxAndroid 项目.

Java 与 JVM 基础

即便项目用 Kotlin, 面试官仍常问 Java/JVM: 因为字节码, 并发, GC 是共通的底层. 这一篇覆盖高频考点.

前置知识与示例说明: 先会使用 Java/Kotlin 的类, 集合和异常; Kotlin 语言细节见 Kotlin 语言核心, 协程并发的结构化生命周期见协程与 Flow. 本文 Java/Kotlin 代码为上下文片段, 除明确标注 “可运行示例” 外需补齐工程类型, 线程和 import; HotSpot 实现与 Android ART 必须按文中的版本边界区分.

学习目标: 能用术语准确解释共享内存并发, 集合扩容和类加载, 能为一个线程池任务选择合适的同步工具, 并能识别 ThreadLocal 的生命周期泄漏.

一, Java 对象契约与语言基础

equals / hashCode

  • equals 表示逻辑相等, 必须满足自反, 对称, 传递, 一致和非空.
  • 重写 equals 时必须同时重写 hashCode: 相等对象必须有相同哈希值.
  • 作为 HashMap key 的对象应保持不可变; 插入后修改参与相等判断的字段会导致对象仍在桶中, 但无法按新 key 找到.
  • Kotlin data class 会按主构造参数生成 equals/hashCode; copy() 是浅拷贝, 可变成员仍可能共享.

String 与拷贝

  • String 不可变, 便于常量池复用, 线程安全和作为 Map key. StringBuilder 是非同步的可变字符序列, 适合单线程或局部拼接; StringBuffer 的多数公开方法带同步, 仅在确有同一缓冲区跨线程共享且无法更好地隔离状态时考虑. 不要因为 “线程安全” 就在每次普通拼接中选择 StringBuffer.
  • 大量拼接应使用 StringBuilder 或 Kotlin 的 buildString, 不要在循环中反复用 + 创建中间 String.
  • 字符串字面量可能进入 String Pool, == 在 Java 中比较引用, equals 比较内容; Kotlin == 会调用 equals, === 才比较引用.
  • 浅拷贝只复制对象外壳, 深拷贝还要复制可变引用图. Parcelable, 序列化和缓存快照都要明确这个边界.

String.length() 返回的是 UTF-16 code unit 数, 不等于用户看到的字符数. 例如以下示意代码中, 基本平面 (BMP) 以外的字符由代理对表示, 因此 length() 为 2:

// 示意代码: 用 codePointCount 统计 Unicode code point, 不等同于所有用户感知字素簇.
String emoji = "\uD83D\uDE00";
int utf16Units = emoji.length(); // 2
int codePoints = emoji.codePointCount(0, emoji.length()); // 1

截断用户名, 计算展示宽度或按用户可见字符计数时, 还要按产品语言规则处理组合字符, 变体选择符和 emoji 序列; 不应只按 length() 截断.

抽象类, 接口与修饰符

抽象类用于表达共享状态或部分实现, 类只能继承一个父类; 接口用于表达能力契约, 一个类可实现多个接口. 接口的 default 方法可在不破坏既有实现类的情况下演进 API, 但多个父接口出现同签名默认方法时, 实现类必须显式消歧. 不要为了复用少量工具方法建立继承层次, 可优先组合或静态工具方法.

形式状态与实现何时用不适用边界
抽象类可持有实例字段, 构造器和受保护的模板实现子类共享稳定状态与骨架需要跨多个无关类型组合能力
接口定义契约, 可有 default/static 方法, 不保存每个实现对象的实例状态多实现, 替换实现或对外暴露能力强依赖共享可变状态和构造流程
  • final 变量只能赋值一次, final 方法不可被覆写, final 类不可继承. 它有助于表达不可变和限制扩展, 但不使被引用的可变对象自动线程安全.
  • static 成员属于类而非实例, 生命周期通常与类加载器一致. 静态集合或回调若持有 Activity, View 等短生命周期对象, 会形成长生命周期引用链.
  • synchronized 为同一监视器提供互斥, 并在解锁与后续加锁之间建立可见性关系. 它不自动缩短临界区, 也不应包住网络, 磁盘或长时间计算. 线程状态, 等待协作和锁实践见 多线程并发专题.

泛型, 反射与注解

  • JVM 泛型通常经过类型擦除, 运行时不能直接得到普通 T 的实参; Kotlin inline + reified 只能在调用点保留有限类型信息.
  • bridge method 示例: 泛型父类 abstract class Base<T> { abstract T get(); }, 子类 class StringBase extends Base<String> { @Override String get() {...} }. 擦除后父类 Base.get() 返回 Object, 子类实际方法签名是 ()Ljava/lang/String;, 与父类不一致, 编译器自动生成桥方法 Object get() 转发到 String get(), 保证多态可用; 反射 getDeclaredMethods() 能看到 isBridge() == true 的额外方法.
  • 反射的典型流程是从 Class 获取类型, 用 getDeclaredMethod/getDeclaredConstructor 查找成员, 根据访问控制决定是否允许访问, 再以 invoke 或 newInstance 调用. getDeclared* 能找到本类声明的非公开成员, 但不等于一定有访问权限; Java 模块边界, 安全策略或 Android 混淆配置都可能限制访问.
  • 以下是反射流程示意代码, 仅说明访问检查和构造调用顺序, 不应直接用于高频业务路径:
// 示意代码: 真实代码需处理参数类型, 异常, 模块/混淆配置和访问边界.
Class<?> type = Class.forName("example.Profile");
Constructor<?> constructor = type.getDeclaredConstructor(String.class);
if (!constructor.canAccess(null)) {
    constructor.setAccessible(true); // 仍可能因运行时访问规则失败.
}
Object profile = constructor.newInstance("Ada");
Method method = type.getDeclaredMethod("displayName");
Object value = method.invoke(profile);
  • 反射适合框架扩展和低频配置, 代价是查找, 访问检查, 装箱, 启动, 混淆适配和更晚暴露的运行时错误; 高频路径优先使用接口或编译期代码生成. 量级感: 单次 invoke/newInstance 相对直接调用常慢一到两个数量级, 且高频反射调用难以被 JIT 内联优化, 具体随 JDK/ART 版本与调用热点变化, 应以测量为准而非套固定数字.
  • 注解由声明位置, 元素和保留策略组成. SOURCE 仅供源码工具, CLASS 写入 class 文件但通常不能反射读取, RUNTIME 才能通过反射读取. Room, Hilt, 路由和序列化库经常把注解处理放在编译期; 运行时扫描前应确认确实声明了 RetentionPolicy.RUNTIME.

异常与资源

  • Throwable 分为 Error 与 Exception. Error 通常表示 JVM 或运行环境难以由业务恢复的问题, 例如 OutOfMemoryError; Exception 再分为 RuntimeException 及其子类, 与需要声明或捕获的受检异常.
  • RuntimeException 常用于非法参数, 状态不满足或程序错误, 可以传播到统一边界处理, 但不能用它掩盖可预期的业务失败. 受检异常适合调用方确实能采取恢复动作的外部失败; 不要机械地在每层 throws Exception 或捕获后吞掉上下文.
  • 不要用 Throwable 兜底后吞掉取消, 线程中断或进程终止信号. 捕获 InterruptedException 时, 要么结束当前操作并向上处理, 要么在无法抛出的边界恢复中断标记 Thread.currentThread().interrupt().
  • 文件, Cursor, Socket 等资源必须有明确所有权和关闭路径. Kotlin 使用 use, 协程代码还要保证取消时执行清理.
  • OOM 分类 (风控 SDK 常排查内存): 堆 OOM (OutOfMemoryError: Java heap space) 多来自对象泄漏或大对象 (采集缓存, Bitmap), 用堆 dump 沿 GC Root 引用链定位; 栈溢出 StackOverflowError 通常是无界递归或过深调用链 (如 JSON 自引用反序列化), 看异常栈的重复帧定位递归点; 元空间 OOM (Metaspace) 多来自类加载器泄漏或反射 / 动态生成类过多 (如反复生成代理类), 排查加载器存活与类数量.

变量生命周期与 I/O 分类

  • 局部变量位于方法执行上下文, 超出作用域后不再由该局部变量引用; 实例字段随对象存活, 只要对象仍能从 GC Roots 可达就不会回收; 类变量随类及其类加载器存活. 因而 static 缓存, 监听器和单例是常见的长生命周期持有点.
  • GC 判断的是对象是否可从 GC Roots 沿强引用链到达, 不是变量名是否还在源码中出现. 将对象赋给局部, 字段或类变量会改变可达性, 但实际回收时机仍由运行时决定.
  • Java I/O 可按数据单位分为字节流 InputStream/OutputStream 与字符流 Reader/Writer; 可按处理层次分为节点流直接连接文件, 内存或 socket, 与缓冲, 编码, 对象序列化等处理流组合包装. 二进制内容按字节处理, 文本按明确字符集的字符流处理, 不要依赖平台默认编码.
  • 传统阻塞 I/O 在读写未就绪时会阻塞调用线程; NIO 的 ‘Channel’, ‘Buffer’ 与 ‘Selector’ 可支持非阻塞和多路复用, 但也增加状态管理复杂度. 少量顺序文件读写优先清晰的阻塞 I/O; 大量连接或需要事件驱动时再考虑 NIO, 并正确处理半包, 背压和资源关闭.

二, 集合框架

HashMap (必考)

  • 结构: 数组 + 链表 + 红黑树 (JDK8).链表长度 ≥ 8 且数组长度 ≥ 64 转红黑树; 退化阈值 6.
  • 默认初始容量为 16, 负载因子为 0.75; 桶数组通常在首次 put 时才延迟分配, 之后达到阈值时扩容翻倍. 容量保持为 2 的幂, 便于用 (n-1) & hash 取代取模.
  • 扰动函数: hash = h ^ (h >>> 16), 让高位参与运算, 减少碰撞.
  • JDK 8 扩容会按 hash & oldCap 将单链表拆为 low/high 两条链, 并分别保持原相对顺序; 这不使 HashMap 线程安全. JDK 7 并发扩容的头插重排曾可能形成环, 但任何版本都不能用 HashMap 承担并发写入.
  • 并发问题: 多线程 put 可能丢数据, 扩容期间读到 null.

扩容的数字推演

术语:容量 (capacity) 是桶数组长度; 负载因子 (load factor) 是达到扩容的比例; 阈值 (threshold) 通常是 capacity * loadFactor; 桶 (bin) 是同一索引上的链表或树节点集合. 以下是 JDK 8 HashMap 常见规则的数字示例, 不适用于并发写入或所有替代 Map 实现.

假设首次插入已经分配桶数组为 16, 负载因子为 0.75, 则阈值为 16 * 0.75 = 12. 插入第 13 个, 且触发检查时需要扩容的条目后, 容量扩为 32, 新的阈值为 32 * 0.75 = 24. 容量翻倍时, 一个节点的新索引不是任意重算: 设旧容量 oldCap = 16, 节点 hash 为 19, 则 19 & 16 != 0, 新索引为旧索引加 16; 若 hash & oldCap == 0, 索引保持不变. 原桶链中的节点会进入 low/high 链且各自保持相对顺序. 这就是容量为 2 的幂能用位运算拆分低位/高位的原因.

常见失败是把 HashMap 用于并发写入或把可变 key 插入后修改. 症状是丢值, 读取异常或查不到已插入键; 证据是并发调用栈, key 修改前后的 equals/hashCode 和最小复现; 定位为无同步访问, 扩容竞争或 key 契约破坏; 修复是选择 ConcurrentHashMap/外部同步并令 key 不可变; 验证是在受控并发循环和断言中检查映射完整性. 不要把一次未复现当作线程安全证明.

ConcurrentHashMap

  • JDK7: 分段锁 Segment; JDK8:CAS + synchronized 锁单个桶头节点, 粒度更细, 并发度更高.
  • size () 用 baseCount + CounterCell 分散统计.

其他

接口重复与顺序语义何时用不适用边界
List保留元素顺序, 允许重复, 通过索引访问需要有序序列或允许重复项只关心成员关系或按键查询
Set按相等性去重, 一般不承诺顺序去重, 成员判断需要重复项或位置语义
Map键唯一, 每个键关联一个值通过稳定键查找值只需无键序列或键本身需要重复
  • ArrayList 是连续动态数组, 随机访问通常为 O (1), 尾部追加摊销为 O (1), 中间插入和删除需要移动元素. 它是大多数按索引读取场景的默认选择, 但频繁在中间插入删除大量元素时要测量移动成本.
  • 扩容发生在追加元素且当前数组已满时. 常见 OpenJDK 实现中, 无参构造会先使用共享空数组, 首次 add 再延迟分配通常为 10 的容量; 指定初始容量的构造则按请求容量分配. 之后容量通常按旧容量约 1.5 倍增长, 并至少满足本次所需的最小容量. 扩容会通过 Arrays.copyOf 分配新数组并复制既有引用, 因而一次扩容是 O (n) 成本, 多次尾部追加的均摊成本才接近 O (1).
  • 已知要写入大量元素时, 可在写入前以 new ArrayList<>(expectedSize) 或 ensureCapacity(expectedSize) 减少重复扩容和复制. 不要为不确定或过大的上限盲目预分配, 否则会增加堆占用和 GC 压力. 首次容量, 增长公式与内部空数组均是具体 JDK 实现细节, 需按目标 JDK 版本源码或文档核验, 不应视为 Java 语言规范承诺.
  • LinkedList 是双向链表, 已定位节点后的插入删除为 O (1), 但按索引随机访问必须遍历, 节点对象也有额外内存和缓存局部性成本. 不要仅因为 “插入快” 就把它用于按索引遍历的热路径.
  • HashSet 底层通常基于 HashMap; LinkedHashMap 额外维护双向链表, 可保持插入顺序, 或在 access-order 模式下将最近访问的节点移到末尾. 后者可作为受控大小 LRU 缓存的构件, 但并不自动解决并发, 过期, 加载抖动或缓存穿透.

Hashtable 与小型映射

Hashtable 自 Java 1.0 即存在, 历史原理是为早期 Java 提供同步键值容器. 其大部分公开方法以对象监视器保护, 因而常形成全表串行访问; 它还禁止 null 键和值. 当前 JDK 仍可使用且未被废弃, 但属于遗留同步容器, 新代码不应将它作为默认 Map. 多读写并发映射优先使用 ConcurrentHashMap; 非并发场景或可由短临界区明确保护的场景, 使用私有 HashMap 加受控外部同步. HashMap 即使由外部同步保护, 仍允许一个 null 键和多个 null 值; ConcurrentHashMap 则禁止 null 键和值, 以避免并发查询时无法区分键不存在与值为 null.

Android 的 ArrayMap 和 SparseArray 采用数组保存键和值或紧凑索引, 用较低对象数量换取查找时的二分, 移动或装箱取舍. 小规模映射, 特别是 Android 框架边界可考虑它们: SparseArray 适合 int 键以避免 Integer 装箱, ArrayMap 适合数量有限的对象键映射. 映射规模大, 频繁变更, 查找极热或需要并发访问时, 数组移动和查找成本可能不合适, 应基于数据规模和 profile 选择 HashMap 或并发容器. Android 组件持有与生命周期语境见 Android 四大组件与基础; 此处只定义容器的 Java 语义.

引用类型与 ReferenceQueue

引用类型回收语义适用场景不应用于
强引用从 GC Roots 可达时不会因 GC 回收正常对象所有权期待自动释放缓存
软引用内存紧张时可被清理, 时机不保证有明确内存压力策略的可重建缓存承诺缓存一定保留或替代容量上限
弱引用下一次 GC 发现仅剩弱引用时可清理不拥有对象的关联, 监听器辅助结构关键业务状态或资源生命周期管理
虚引用get() 始终为 null, 对象进入 phantom reachable 阶段后引用会被处理并入队配合队列跟踪堆外资源回收流程通过引用重新取得对象

ReferenceQueue 让程序在引用被 GC 入队后执行后续清理或登记. 虚引用入队表示对象已进入对应的可达性处理阶段, 不表示对象内存已完成回收; 它不提供确定的回收时间, 也不能替代 Closeable.close(), use 或显式资源所有权. 对文件, socket, 数据库游标和 native 资源, 应在不再使用时主动关闭, 不要等待引用队列.

三, 并发

synchronized

  • 对象头 Mark Word 记录锁状态, GC 年龄, hashCode 或指向锁记录 / monitor 的指针. synchronized 进入临界区时会围绕 Mark Word 做 CAS 或 monitor 竞争, 所以面试讲锁升级不能只背流程, 要能说清 “对象头里记录了什么”.
  • 无锁: 对象未被线程持有; 第一次进入同步块时, 运行时会尝试在 Mark Word 中记录当前锁形态或把对象头复制到线程栈上的 Lock Record.
  • 偏向锁: 面向 “同一线程反复进入同一把锁” 的场景, Mark Word 记录偏向线程 ID, 后续同线程进入几乎不需要 CAS. 出现其他线程竞争, 调用 hashCode() 等需要占用 Mark Word 的信息时, 可能触发偏向撤销或重偏向. 版本边界要说清: JDK 15 起偏向锁被废弃并默认关闭, JDK 18 之后 HotSpot 已移除相关实现, 新版本面试更应把它当历史优化理解.
  • 轻量级锁: 多线程交替进入但竞争不激烈时, 线程在栈帧创建 Lock Record, 用 CAS 把对象 Mark Word 指向该记录; 失败后可能自旋, 希望持锁线程很快退出, 避免立即阻塞到内核态.
  • 重量级锁: 竞争持续, 线程自旋失败或需要阻塞 / 唤醒时, 锁膨胀为 ObjectMonitor, 未抢到锁的线程进入阻塞队列, 由操作系统互斥量参与调度, 吞吐更稳定但上下文切换成本更高.
  • 升级边界: 常见路径可概括为无锁 → 偏向锁 → 轻量级锁 → 重量级锁, 但这主要描述 HotSpot 早期到 JDK 14 默认配置下的优化路径; 锁可以膨胀, 退出同步块后不等于立刻恢复到最轻状态, 具体是否偏向, 是否自旋和阈值会受 JDK 版本与 JVM 参数影响.
  • 修饰实例方法锁 this, 静态方法锁 Class, 代码块锁指定对象.

volatile

  • 对同一变量的 volatile 写与后续读建立 happens-before, 提供可见性和必要的顺序约束; 不要机械解释成 “每次都立即刷主存”.
  • 不保证原子性 (如 i++ 仍不安全).
  • 经典用途: 双重检查锁单例的实例字段.

CAS 与 AQS

  • CAS(Compare-And-Swap): 无锁原子操作, compareAndSwap(内存值, 期望值, 新值), 失败自旋重试. 底层是 CPU 指令.
  • ABA 问题: 值从 A→B→A, CAS 误判没变. 用版本号 (AtomicStampedReference) 解决.
  • AQS(AbstractQueuedSynchronizer): 用 volatile int state + CLH 队列实现的同步框架, ReentrantLock, CountDownLatch, Semaphore 都基于它.

ReentrantLock 与并发工具怎么选

ReentrantLock 是显式锁: 同一线程可重入, 并可选择公平锁, tryLock 超时 / 可中断获取, 多个 Condition 队列和诊断能力. synchronized 语法更短, 异常退出会自动释放 monitor, 适合简单且作用域固定的临界区. 两者都不应包住网络, 磁盘或长计算.

工具解决的问题适用场景不适用边界
synchronized互斥与可见性小临界区, 无超时 / 中断需求需要可取消等待或多个条件队列
ReentrantLock可控互斥限时抢锁, 可中断等待, 复杂条件协调可能忘记 unlock() 的简单场景
CountDownLatch等待固定次数完成初始化并行任务后一次性汇合需要重复使用的屏障
CyclicBarrier一组线程重复会合多轮并行计算阶段同步任务数不固定或异步回调链
Semaphore限制同时访问数量连接池, 并发请求配额需要表达数据依赖而非资源数量
BlockingQueue生产者 / 消费者交接有界任务队列, 批处理需要非阻塞响应式背压的场景
// 上下文片段:必须在 finally 中释放,避免异常路径永久占锁.
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
    try {
        updateSharedState();
    } finally {
        lock.unlock();
    }
} else {
    reportContention();
}

锁竞争的症状是线程长期 BLOCKED/WAITING 或请求超时; 证据可来自线程 dump, 锁等待耗时和临界区日志; 定位持锁时间, 锁顺序和资源泄漏; 修复是缩短临界区, 固定锁顺序, 采用限时/可中断获取或改为消息传递; 验证用受控并发任务确认不会死锁且超时路径可见. 锁/线程池参数不能只套 “核数公式”, 应以任务 CPU/IO 比例, 队列等待, 拒绝次数和端到端延迟测量后调整.

ThreadLocal: 弱键不等于弱值

ThreadLocal 让每个线程在自己的 ThreadLocalMap 中保存一份变量. Map 的 entry 对 key (ThreadLocal 对象) 使用弱引用, 但 value 是强引用: 当 key 被 GC 后, entry 可能变成 key = null, value = largeObject. 在线程池的工作线程长期存活时, 若后续没有触发清理陈旧 entry, value 会继续被线程→ThreadLocalMap→entry 链路持有.

线程池工作线程(长期存活)
  -> ThreadLocalMap
       -> Entry(key 弱引用已清空,value 强引用仍在)
            -> 大对象 / Context / 缓冲区

上下文片段: 每次设置后都在同一任务的 finally 调用 remove(), 不要依赖弱引用或下一次 map 操作的机会性清理.

private static final ThreadLocal<StringBuilder> BUFFER = new ThreadLocal<>();

void handle(Request request) {
    try {
        BUFFER.set(new StringBuilder());
        process(request, BUFFER.get());
    } finally {
        BUFFER.remove();
    }
}

典型症状是线程池空闲后堆中仍保留请求对象或 Activity/Context; 证据是堆 dump 的引用链指向 worker thread 的 ThreadLocalMap; 定位为 set 没有同任务 remove, 或在线程池中存放了不该跨请求复用的数据; 修复为 try/finally remove(), 缩小 value, 改为显式参数或受控对象池; 验证是重复提交任务并在任务结束后重新抓取堆引用链. Android 中也要避免将 Activity, View 等短生命周期对象放进长生命周期线程的 ThreadLocal.

线程池 ThreadPoolExecutor

七个参数: corePoolSize(核心线程), maximumPoolSize(最大), keepAliveTime, unit, workQueue(任务队列), threadFactory, handler(拒绝策略).

执行流程: 核心线程未满 → 创建核心线程; 满了 → 入队; 队列满 → 创建非核心线程到 max; 再满 → 触发拒绝策略 (AbortPolicy 抛异常 / CallerRunsPolicy 调用者执行 / DiscardPolicy 丢弃 / DiscardOldestPolicy 丢最老).

队列选型决定 “先排队还是先开线程”: SynchronousQueue 无缓冲, 任务直接交接给工作线程; 核心线程全忙时 offer 立即失败, 立刻创建非核心线程 (上限 max), 适合短而频繁, 阻塞占比高的 IO 任务: 阻塞线程不占 CPU, 多一线即多一线并发. ArrayBlockingQueue/LinkedBlockingQueue 有界缓冲, 核心线程满后先入队等待再开新线程, 适合 CPU 密集或需要削峰排队, 平滑负载的场景; 队列越大峰值越稳, 但任务滞留越久, 内存越高. 结合 CPU/IO 分类: IO 密集偏 “弹性线程 + 无缓冲” 提升并发, CPU 密集偏 “核数级线程 + 有界队列” 减少上下文切换, 最终仍以队列等待, 拒绝次数和延迟测量为准.

线程数不能用 “CPU 核数 + 1” 或 “IO 核数 × 2” 当固定答案. 先写清目标: 允许的同时在途任务数, 任务 CPU 时间与阻塞时间, 外部服务并发配额, 内存能承受的队列容量, 以及过载时是快速失败, 调用方降速还是可安全丢弃. 以保守 core/max, 有界队列和与业务匹配的拒绝策略开始, 在压测或生产观测中记录队列等待, 活跃线程, 拒绝次数, CPU 利用率, 外部依赖饱和度和端到端 p95/p99 延迟; 再一次只调整一个变量并比较结果. 长阻塞 IO 还应优先考虑超时, 限流和异步客户端, 而不是无限增大线程或队列.

四, Java 内存模型 (JMM)

JMM 是并发语义模型, 不是 “堆, 栈, 方法区” 那张 JVM 运行时区域图. 它回答的是: 一个线程写入共享变量后, 另一个线程在什么条件下必须看到这次写入.

三个核心性质

  • 原子性: 操作不可被线程切开观察. 单次引用读写通常是原子的, i++ 仍是读, 改, 写三步.
  • 可见性: 一个线程的写入何时对另一个线程可见. volatile, 锁释放 / 获取和线程 join 等建立可见性关系.
  • 有序性: 编译器和 CPU 可以重排没有同步约束的指令. volatile 和锁会提供必要的顺序保证.

happens-before 规则

面试至少能说出以下规则, 并说明它们可以传递:

  1. 同一线程内, 前面的操作 happens-before 后面的操作.
  2. 对同一把锁, unlock happens-before 后续 lock.
  3. 对同一个 volatile 变量, 写 happens-before 后续读.
  4. Thread.start() happens-before 新线程中的操作.
  5. 线程中的全部操作 happens-before 另一个线程成功 join() 返回.
  6. 若 A happens-before B 且 B happens-before C, 则 A happens-before C.
private val ready = AtomicBoolean(false)
private var payload: String? = null

fun publish(value: String) {
    payload = value
    ready.set(true)
}

fun read(): String? = if (ready.get()) payload else null

这里 ready 的 volatile 写读建立了顺序边界, 使读线程在观察到 true 后能看到之前对 payload 的写入. 业务代码更推荐直接发布不可变快照, 避免让多个可变字段组成隐含协议.

安全发布与 Android 边界

  • 通过静态初始化, 锁, volatile 引用, 线程安全容器或协程状态流发布对象, 才能避免读到半初始化状态.
  • final 字段在构造完成后有额外的初始化可见性保证, 但对象引用暴露后仍不能把可变成员当成线程安全.
  • JMM 规定的是语言级可见性和顺序; Android ART, 主线程 Looper, Binder 和协程 Dispatcher 还会叠加各自的调度语义.

五, JVM 内存与 GC

内存区域

  • 线程私有: 程序计数器, 虚拟机栈, 本地方法栈.
  • 线程共享: 堆 (对象实例, GC 主战场), 方法区 / 元空间 (类信息, JDK8 后用本地内存).
  • 注意 Android 用 ART/Dalvik 而非标准 JVM, 但内存模型概念相通.

GC

  • 判活: 引用计数 (有循环引用问题) vs 可达性分析 (GC Roots, 主流).
  • GC Roots: 虚拟机栈引用, 静态变量, 常量, JNI 引用等.
  • 判活与泄漏定位对应: 泄漏对象未被回收, 是因为它仍能从 GC Roots 沿强引用链到达: 本质是被 static 缓存, 单例或长期存活线程等长生命周期对象强引用; 用堆 dump 沿引用链找根, 即可定位 “谁还持有它”.
  • 回收算法: 标记 - 清除 (碎片), 复制 (浪费空间, 适合新生代), 标记 - 整理 (适合老年代).
  • HotSpot 分代模型: 服务端 JVM 常按新生代/老年代理解, Eden + Survivor, Minor GC, Major/Full GC 是典型面试语言; 但比例 (如 8:1:1) 和具体算法不是 Java 语言规范, 会受 JVM 版本, 收集器和参数影响.
  • HotSpot 回收器边界: Serial 适合小堆/单核或客户端场景; Parallel 关注吞吐; CMS 关注低停顿但有碎片和浮动垃圾问题, 已在 JDK 9 标记废弃, JDK 14 移除; G1 将堆划分为 Region, 兼顾可预测停顿; ZGC/Shenandoah 面向大堆低停顿. 回答时应说 “这些是 HotSpot 收集器”, 不要直接套到 Android.
  • Android ART 区分: Android App 运行在 ART(早期为 Dalvik) 上, 不是标准 HotSpot Server VM. ART 也有堆, 线程栈, JNI 引用, 可达性分析和并发/分代等 GC 思路; Android Runtime 文档里的 CMS/CC 是 ART 自己的 GC plan, 不等同于 HotSpot CMS/G1/ZGC, 普通应用也不是通过 HotSpot collector 参数来选择它们. Android 面试更关注内存泄漏, 对象分配抖动, Bitmap/native 内存, GC pause 对掉帧的影响.
  • 安全表述: 讲 JVM 基础时可用 HotSpot 解释 Serial/Parallel/G1/ZGC; 讲 Android 性能时应切到 ART 语境, 用 “ART 的具体 GC 策略随 Android 版本和设备实现演进” 这类条件措辞, 避免把服务端 JVM 参数经验当作移动端结论.

类加载

  • 过程: 加载 → 验证 → 准备 → 解析 → 初始化.
  • 父优先委派: 默认 ClassLoader.loadClass 通常先检查已加载类, 再请求父加载器, 父无法加载才尝试自身查找; 它帮助保持核心类唯一性, 但具体行为由 loadClass 实现决定.
  • 主动引用触发初始化: new 实例, 读写静态字段 (非常量), 调用静态方法, 反射调用, 或初始化某个子类 (父类先初始化) 等场景, 会触发目标类的初始化阶段.
  • 被动引用不触发: 引用 final static 编译期常量 (值在编译期内联进常量池), 通过子类引用父类的静态字段, 或用数组定义引用类, 都不会触发目标类初始化.
  • 初始化阶段才真正执行: 准备阶段只给静态字段分配默认值 (零值), static 块与静态赋值在初始化阶段才运行. 与类不同, 初始化接口不会自动初始化其父接口 (除非父接口的非编译期常量静态字段被访问).

类加载器层级与术语

在现代 HotSpot 中常见层级为 Bootstrap ClassLoader (由 JVM 实现, 加载核心平台类)→ Platform ClassLoader (平台模块)→ Application/System ClassLoader (应用 classpath).旧资料中的 Extension ClassLoader 对应旧 JDK 机制, 不能直接套到模块化 JDK.

四种容易混淆的情形应分别表述:

  • SPI/TCCL: 接口通常由较高层加载器加载; ServiceLoader 或框架可通过线程上下文类加载器 (TCCL) 发现子加载器可见的实现. 这是选择发现实现的加载器, 不等于该加载器普遍 child-first.
  • 容器 child-first: 为隔离应用依赖, 某些容器的 WebAppClassLoader 对非受保护包可先尝试自身, 再委派父加载器; 核心 / 受保护包仍可能 parent-first. 规则由容器实现和配置决定.
  • 自定义 loadClass: 只有覆写 loadClass/定义查找顺序的加载器才可能改变默认父优先顺序; 覆写时须处理已加载检查, 并发锁和受保护类, 不能笼统称所有插件都 “打破双亲委派”.
  • Android dexElements: ART 的 BaseDexClassLoader/DexPathList 会按 elements 列表查找 dex 路径; 将补丁 element 前插改变的是该加载器自己的 path 查找优先级, 仍要与其 parent 委派, Android 版本, 加载时机和签名 / 兼容性限制分开讨论.

Android 的 ART/DexPathList 类加载与标准 HotSpot 层级不是同一实现. 谈热修复时应说明其依赖特定 Android 版本, 加载时机, 签名/兼容性与平台限制, 不能将 dexElements 前插说成通用 Java 机制或一律称为 “打破双亲委派”.


HotSpot, ART 与 desugaring 边界

JVM 是 Java 虚拟机规范及其实现类别, HotSpot 只是常见的 JVM 实现, 不能把 JVM 一律等同于 HotSpot. 传统 Java 工具链通常将 Java/Kotlin 源码编译为 class 字节码, HotSpot 以栈式字节码执行并结合解释器和 JIT; 其 JIT, GC 和对象布局结论不能直接等同于 Android Runtime.

Android 早期 Dalvik 以 DEX 为主要执行格式, 使用寄存器式指令集, 与 class 文件及栈式 JVM 字节码不同. Dalvik 早期以解释执行为主, 后续 Android 版本引入 JIT 等优化; 具体策略随系统版本演进, 不应把早期行为当作所有设备结论. ART 在 Android 5.0/API 21 起取代 Dalvik, 先以安装期 AOT 为主要策略, 后续版本逐步演进为结合运行时 JIT, profile 引导 AOT 和安装/空闲期编译的模式. ART 的 GC 和运行时实现也随 Android 版本, 设备和厂商演进, 因此 Android 面试应按目标 API 级别说明, 而不是套用 HotSpot 收集器或参数.

Android 还受 DEX, AOT/JIT profile, 设备系统版本和厂商实现影响. Java 语言/API 能否在 Android 使用还取决于 minSdk, desugaring, AGP/JDK 与 library desugaring 配置; 面试时分别说明 “Java/JMM 规范”, “JVM 实现如 HotSpot” 和 “Dalvik/ART 与 Android 工具链”. 最后核验: 2026-08-08.

高频面试题

Q1: HashMap 为什么线程不安全? ConcurrentHashMap 怎么优化? HashMap 并发 put 会丢数据, JDK7 扩容还会成环死循环. ConcurrentHashMap JDK8 用 CAS + synchronized 锁桶头, 只锁单个桶, 并发度高.

Q2: volatile 能保证原子性吗? 不能. 只保证可见性和有序性. i++ 是读 - 改 - 写三步, volatile 不能保证复合操作原子, 需用 Atomic 类或锁.

Q3: synchronized 锁升级过程? 先讲对象头 Mark Word: 它保存锁标志位, GC 年龄, hashCode 或指向 Lock Record/ObjectMonitor 的指针. 典型 HotSpot 路径是无锁 → 偏向锁 (同一线程反复进入, Mark Word 记录线程 ID)→ 轻量级锁 (栈上 Lock Record + CAS, 少量竞争时自旋)→ 重量级锁 (ObjectMonitor, 竞争线程阻塞/唤醒).边界是: 偏向锁在 JDK 15 起废弃并默认关闭, JDK 18 后 HotSpot 移除; 新版本里不要把偏向锁当成必经阶段. 锁膨胀后通常不会在退出同步块时立刻退回最轻状态, 具体策略受 JVM 版本和参数影响.

Q4: 线程池核心线程会被回收吗? 默认不会. 设置 allowCoreThreadTimeOut(true) 后核心线程空闲超时也回收.

Q5: 为什么用线程池? 核心线程数怎么定? 复用线程降低创建销毁开销, 控制并发并统一治理. 先按目标并发, 任务阻塞比例, 外部依赖配额, 队列内存预算和拒绝语义设保守初值, 再测量队列等待, 拒绝次数, CPU, 外部依赖饱和度及端到端 p95/p99 延迟, 逐项调整. CPU/IO 分类只是输入, 不能推出固定线程数公式.

Q6: 双亲委派的作用? 为什么 Android 热修复要打破它? 父优先的默认查找顺序有助于核心类唯一性和一致性. SPI/TCCL, 容器 child-first, 自定义 loadClass 和 Android dexElements 前插是不同机制: 后者改变的是 DexPathList 中 path element 的查找优先级, 并不自动等于绕过 parent 委派. 热修复方案必须按对应 Android 版本, 加载器, 加载时机, 签名和兼容性说明其实际查找链.

Q7: JMM 和 JVM 内存区域有什么区别? JMM 描述线程之间共享变量的可见性, 顺序和同步规则; JVM 内存区域描述程序计数器, 栈, 堆, 方法区等运行时数据位置. 一个是并发语义, 一个是运行时结构, 不能混为一谈.

Q8: 什么是 happens-before? volatile 为什么能安全发布引用? happens-before 表示前一个操作的结果对后一个操作可见, 且顺序受约束. 对同一 volatile 变量的写 happens-before 后续读, 因此先完成对象初始化再写入 volatile 引用, 读线程观察到该引用后也能看到初始化写入.

Q9: 为什么重写 equals 必须重写 hashCode? HashMap 先用 hash 定位桶, 再用 equals 判断逻辑相等. 如果两个相等对象的 hashCode 不同, 它们可能落入不同桶, 导致查找, 去重和 Set 语义失效.

Q10: 反射和编译期代码生成怎么选? 反射灵活, 但错误更晚暴露, 还会增加启动, 混淆和性能风险; KSP/APT 等代码生成在编译期校验并生成直接调用, 更适合高频和强类型路径. 小规模插件扩展或调试工具仍可使用反射.

易错点 / 追问

  • 不要把 volatile 说成 “保证变量操作原子”; 它不能让 i++ 变成原子操作.
  • 不要把 JMM 的主内存 / 工作内存抽象直接等同于物理 RAM 和 CPU Cache.
  • 不要把 String 常量池, 堆和方法区的具体位置说成所有 JDK/ART 版本都固定不变.
  • HashMap key, StateFlow state 和跨线程快照优先使用不可变对象, 降低发布后被修改的风险.
  • Android 使用 ART, 不能把 HotSpot 的 GC 收集器参数和锁实现细节当成 Android 的稳定 API.

四大组件与基础

四大组件是 Android 的根基, 中级面试常深挖生命周期, 启动模式, 跨进程.

一, Activity

生命周期

onCreate → onStart → onResume →(运行)→ onPause → onStop → onDestroy; onRestart 用于从 onStop 返回.

关键场景:

  • A 启动 B: A.onPause → B.onCreate → B.onStart → B.onResume → A.onStop(B 的 onResume 在 A 的 onStop 之前).
  • 透明 / Dialog 主题的 B 覆盖 A: A 只 onPause 不 onStop.
  • 横竖屏旋转: 默认销毁重建, 走完整生命周期 + onSaveInstanceState/onRestoreInstanceState; 配 configChanges 可避免重建只回调 onConfigurationChanged.

启动模式 (LaunchMode)

  • standard: 默认, 每次新建实例, 入当前任务栈.
  • singleTop: 栈顶复用, 栈顶是它则走 onNewIntent, 否则新建.(防通知重复打开)
  • singleTask: 栈内唯一, 已存在则复用并清除其上的 Activity (clearTop), 走 onNewIntent.(主页 / 首页)
  • singleInstance: 独占一个任务栈.(来电, 闹钟)

taskAffinity 指定任务栈归属; Intent flag (FLAG_ACTIVITY_NEW_TASK 等) 可动态控制.

生命周期与任务栈推演

上下文片段: 以下日志放在同一应用的 MainActivity(A) 和 DetailActivity(B) 各生命周期回调中; 省略 Manifest 声明和 Log import. 点击 A 的按钮启动 B, 预期日志顺序为 A.onPause -> B.onCreate -> B.onStart -> B.onResume -> A.onStop. 按返回键后, 预期 B 依次 onPause/onStop/onDestroy, A 依次 onRestart/onStart/onResume.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<View>(R.id.open_detail).setOnClickListener {
            startActivity(Intent(this, DetailActivity::class.java))
        }
    }
}

任务栈初始为 [A], A 启动 standard 模式的 B 后为 [A, B]; 从 B 按 Back 只弹出 B, 恢复 A. 若 B 已在栈内且以 singleTask 启动, 例如栈为 [A, B, C], 再次启动 B 后会清除 C, 变为 [A, B] 并回调 B 的 onNewIntent(). 这是针对该模式的推演, 不应把它外推到所有 flag, 跨 task 或厂商定制场景.

二, Fragment

  • 实例与 View 是两套生命周期: Fragment 实例通常经历 onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume; 离开前台时先 onPause → onStop. View 被销毁时走 onDestroyView, Fragment 实例最终被移除或宿主销毁时才走 onDestroy → onDetach.
  • onDestroyView() 是分界点: Fragment 仍可能留在 FragmentManager 或返回栈中, 但其 View 树, View Binding 和所有仅服务于界面的引用都已失效. 在此处置空 _binding, 取消对旧 View 的回调, 不要在实例字段中持有 View.
  • 返回栈与重建: 执行 replace(...).addToBackStack(...) 后, 被覆盖 Fragment 的 View 通常销毁而实例保留; 返回时实例可复用但会重新执行 onCreateView/onViewCreated. 配置变更或进程重建会由 FragmentManager 恢复 Fragment, 应把可恢复 UI 状态放在 ViewModel/SavedStateHandle, 而非假设旧实例或旧 View 仍存在.
  • 观察 UI 状态: 涉及 View 的 LiveData 观察, Flow 收集和 Adapter 绑定使用 viewLifecycleOwner; 这样收集会在 View 销毁时停止, 在新 View 创建后按其生命周期恢复. 不依赖 View 的 Fragment 级工作才使用 Fragment 自身的 lifecycle.
  • commit vs commitNow vs commitAllowingStateLoss: commit 异步; commitNow 同步; 前两者在 onSaveInstanceState 后调用会抛 IllegalStateException, allowingStateLoss 容忍但可能丢状态.
  • 现代用 FragmentStateAdapter + ViewPager2 替代旧懒加载.
  • 为什么用 Fragment 而非多 Activity: 轻量, 复用, 共享 ViewModel, 单 Activity 架构配合 Navigation.

Activity / Fragment 通信: 先确定状态所有权

方式谁拥有数据生命周期与返回栈适用范围错误选型后果
Fragment Result API发送方产出一次性结果, FragmentManager 暂存至接收方可用接收方在 STARTED 后接收, 正常交付后清除; 不是共享可观察状态, 也不替代返回栈选择器返回值, 一次确认结果用于持续状态会丢失状态来源
共享 ViewModelActivity 或 navigation graph 作用域的 ViewModel随作用域存活; 具体页面是否仍在返回栈由 Navigation 管理多个 Fragment 协同编辑或展示同一 UI 状态作用域过大造成状态串页, 把导航事件当持久状态会重复触发
接口回调父 Fragment 或宿主 Activity回调只在当前对象引用有效时发生, 不保存结果局部, 同步的子组件事件持有已销毁 View/Activity 的引用会泄漏或回调到失效页面
Navigation SavedStateHandle当前 NavBackStackEntry结果写回前一个 entry, 与返回操作配合; entry 弹出后状态不再可取从详情页向前一页返回一个结果错把它当全局共享仓库会让结果依赖特定返回栈

选择前先回答 “这是单向结果还是共享状态, 结果应留在哪个返回栈 entry”. Fragment Result 以同一 key 正常交付后会清除; 若出现重复事件, 通常应检查发送方是否再次调用 setFragmentResult, 是否重注册监听器, 以及业务是否把一次性事件建模成可重复消费的状态. 例如详情页编辑完成可写入前一个 SavedStateHandle, 前页消费后清除该键; 跨多个同级页面的持续筛选条件则归共享 ViewModel 管理. 以上是 API 归属示意, 仍需结合页面销毁和进程重建路径验证.

Fragment 懒加载的边界

旧式 “根据可见回调才创建或请求数据” 容易与 FragmentStateAdapter, 返回栈和 onDestroyView() 交错: Fragment 可已创建但 View 不存在, 或不可见页面仍持有请求结果. 现代做法是以 View 生命周期收集 UI state, 由数据层按真正需求惰性加载, 取消或缓存; 可见性只影响渲染或触发意图, 不应成为唯一的数据正确性条件. 列表布局与 View.post 的时机区别见 UI 体系 - View 与自定义 View.

三, Service

  • 启动方式: startService(独立运行, 需手动 stopSelf/stopService) vs bindService(提供绑定通信; 当没有启动状态且最后一个客户端解绑时, 系统可销毁它). 同一 Service 也可同时被启动和绑定, 此时解绑不会停止其启动状态.
  • 前台服务: 必须 startForeground 显示通知 (Android 8+ 后台限制), 用于音乐, 定位, 下载. Android 14+ 还有前台服务类型限制.
  • IntentService 已废弃: 用 WorkManager 或协程替代后台任务.
  • Service 不是线程: 默认运行在主线程, 耗时操作仍需开子线程, 否则 ANR.

IntentService: 历史串行后台组件

  1. 历史原理: IntentService 在内部工作线程按顺序处理每个 Intent, 任务完成后可自行停止, 用来避免普通 Service 在主线程执行耗时工作.
  2. 限制与状态: 平台 android.app.IntentService 自 Android 11/API 30 废弃; 它不提供任务约束, 可靠重试或现代后台执行保证, Android 8.0 及以上的后台执行限制也使其不适合新任务. 新项目不应继续采用.
  3. 现代替代: 可延迟且需保证执行的工作使用 WorkManager; 用户明确感知, 必须持续运行的工作使用声明正确类型并展示通知的前台服务; 与页面生命周期绑定的短任务使用 ViewModel 作用域协程. 线程协作原语见 多线程并发专题.

回到主线程: runOnUiThread 不是生命周期保护

Activity.runOnUiThread { ... } 会在非主线程时把代码投递到主线程, 调用方已在主线程时可直接执行. 它只解决线程亲和性, 不保证 Activity, Fragment View 或业务请求仍有效: 回调排队期间页面可能销毁. 页面逻辑优先由 ViewModel 暴露可恢复的 UI state, 用生命周期感知收集协程渲染; 临时 View 操作须先确认 View 生命周期有效. runOnUiThread 可用于遗留回调的窄桥接, 不应成为异步架构.

绑定 Service: 客户端与服务端的最小闭环

上下文片段: 将 Service 声明在同一应用 Manifest 中, Activity 在可见期间绑定. 此示例只演示同进程 Binder 通信, 不是跨进程 AIDL 示例. 调用 nextCount() 两次, 预期依次得到 1, 2; 离开页面会解绑.

class CounterService : Service() {
    private var count = 0
    inner class LocalBinder : Binder() { fun nextCount(): Int = ++count }
    override fun onBind(intent: Intent): IBinder = LocalBinder()
}

class CounterActivity : AppCompatActivity() {
    private var binder: CounterService.LocalBinder? = null
    private var bound = false // bindService() 已成功注册该 ServiceConnection
    private var connected = false
    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName, service: IBinder) { binder = service as CounterService.LocalBinder; connected = true }
        override fun onServiceDisconnected(name: ComponentName) { binder = null; connected = false }
    }
    override fun onStart() { super.onStart(); bound = bindService(Intent(this, CounterService::class.java), connection, BIND_AUTO_CREATE) }
    override fun onStop() { if (bound) { unbindService(connection); bound = false }; binder = null; connected = false; super.onStop() }
}

bound 表示 bindService() 返回成功, 因此必须配对解绑; connected 表示 Binder 已通过 onServiceConnected() 到达, 只有此时才可调用 binder. 存在 “已绑定但尚未连接” 的竞态窗口: 页面可能在回调到达前进入 onStop(), 仍要按 bound 解绑并清理本地引用. onServiceDisconnected() 表示连接意外断开 (例如承载 Service 的进程被杀), 正常调用 unbindService() 不会因此回调.

四, BroadcastReceiver

  • 注册方式:
    • 静态注册 (Manifest): 适合系统或跨应用广播; Android 8.0 起对很多隐式广播做了限制, 普通应用不要依赖静态注册来长期唤醒进程.
    • 动态注册 (registerReceiver): 跟随 Activity/Fragment/Service 生命周期, 常在 onStart/onResume 注册, onStop/onPause 反注册, 用于只在界面可见或组件存活时接收.
  • 分发流程: sendBroadcast/sendOrderedBroadcast → ActivityManager/系统广播队列匹配 IntentFilter → 找到静态/动态 Receiver → 目标进程已在则直接回调,未启动且允许则拉起进程 → 主线程调用 onReceive.
  • IntentFilter 匹配: action 必须匹配; filter 声明 category 时, Intent 中的每个 category 都必须被 filter 包含; data 的 URI, MIME type, scheme/host/path 还须满足声明. 不要以为只写同一个 action 就必然接收, 也不要用宽泛 data 暴露未校验输入.
  • 类型边界:
    • 普通广播: 异步分发, 多个 Receiver 接收顺序不应作为业务依赖.
    • 有序广播: 按优先级依次分发, 前一个 Receiver 可通过 setResult* 改结果, 也可在允许的场景中 abortBroadcast() 终止后续接收; 因此更像一条责任链.

系统广播与 LocalBroadcastManager 的边界

维度BroadcastReceiver / 系统广播机制LocalBroadcastManager
进程与分发范围由系统按 IntentFilter 分发, 可在同一应用, 其他应用和系统组件之间传递仅在同一应用进程内向已注册接收者分发, 不经过系统广播机制
跨进程可以, 目标组件可位于其他进程; 是否可达还受 Intent, 系统限制和 Manifest 影响不可以; 多进程应用的不同进程彼此不可见
注册与接收者支持 Manifest 静态注册和运行时动态注册; 系统还可能在允许时启动目标进程仅支持运行时注册, 不会拉起进程, 进程死亡后没有接收或缓存
安全边界导出的 Receiver, 隐式 Intent 和宽泛 Filter 可能接受外部输入; 应设置 exported, 发送 / 接收权限或显式组件, 并校验 action, data 与 extras不会被其他应用直接发送或接收, 但不是天然绝对安全: 同进程不可信代码, 错误的事件设计和敏感数据日志仍是风险, 也不提供授权或输入校验的替代品
性能与历史场景适合系统事件或跨应用通知; 不应把广播当高频应用内事件总线, 分发, 进程和生命周期成本会放大问题曾用于旧架构的应用内事件总线, 避免跨进程 IPC 并非 “零成本”; 生命周期管理, 全局耦合和高频事件问题依然存在

LocalBroadcastManager 1.1.0 已完全废弃, 新代码不应使用. 应用内持续状态优先由 Repository 暴露 StateFlow/LiveData, 短暂事件可用作用域明确的 SharedFlow 或显式回调; 观察者仍应绑定恰当生命周期, 不把 “本地” 误当成完整的安全模型.

粘性广播: 旧状态快照不是状态流

平台粘性广播 API 仍存在, 但多数应用不应发送或依赖它, 不应杜撰统一的废弃版本. 粘性广播会保存最后一次 Intent, 之后注册的接收者可能立即拿到该旧值; 这会带来数据陈旧, 来源与时序难以判断的问题. 若广播可被外部接收或伪造, 还会扩大敏感数据泄露和错误状态注入的风险. 平台保留的少数系统状态场景应按其文档处理; 应用自己的状态以 Repository + StateFlow/LiveData 等可追踪, 可建模的状态流保存和观察, 而非用 sticky Intent 充当状态仓库.

class BatteryReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        // onReceive 运行在主线程,只做轻量解析;耗时任务交给 WorkManager/协程/Service
        val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
        Log.d("BatteryReceiver", "level=$level")
    }
}

private val receiver = BatteryReceiver()

override fun onStart() {
    super.onStart()
    registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}

override fun onStop() {
    unregisterReceiver(receiver)
    super.onStop()
}

常见错误:

  • 在 onReceive 中做网络 / 数据库等耗时操作, 导致主线程卡顿甚至 ANR; 需要异步任务时用 goAsync() 争取短暂收尾, 或转交 WorkManager / 前台 Service.
  • 动态注册后忘记反注册, 导致组件泄漏或重复收到广播.
  • 把广播当作应用内事件总线, 导致来源不清, 生命周期难控; 中级面试更推荐说明 “跨组件 / 跨应用通知可以用广播, 应用内状态流优先用 Flow”.
  • 忽略 Android 8.0+ 隐式广播限制, 把 Manifest 静态注册当成稳定后台唤醒手段.

面试答题结构: 先说 “注册方式与生命周期”→ 再说 “普通 / 有序广播分发差异”→ 补 “8.0+ 限制与现代替代”→ 最后强调 “onReceive 主线程, 轻量处理, 及时反注册”.

五, ContentProvider

  • 定位: 跨进程数据共享的标准组件, 用 content://authority/path/id 形式的 URI 寻址, 对外暴露 query/insert/update/delete/openFile 等接口. 系统通讯录, 媒体库就是典型例子.
  • 对象关系:
    • ContentResolver: 调用方入口, 不直接依赖 Provider 实现类.
    • ContentProvider: 被调用方组件, 负责权限校验, URI 分发, 数据读写.
    • UriMatcher: 把不同 URI path 映射到不同业务分支, 避免手写大量字符串判断.
  • 跨进程调用流: 调用方 ContentResolver.query(uri) → 系统根据 authority 找到目标 Provider → 必要时启动目标进程并创建 Provider → 通过 Binder 进入 Provider 的 query/insert/update/delete → Provider 访问 SQLite/文件/内存数据 → Cursor/结果返回调用方.
class UserProvider : ContentProvider() {
    private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI("com.example.user", "users", 1)
        addURI("com.example.user", "users/#", 2)
    }

    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?,
    ): Cursor? = when (matcher.match(uri)) {
        1 -> queryAllUsers()
        2 -> queryUser(uri.lastPathSegment!!.toLong())
        else -> throw IllegalArgumentException("Unknown uri: $uri")
    }
}

// 调用方只依赖 URI + ContentResolver
val cursor = context.contentResolver.query(
    Uri.parse("content://com.example.user/users/42"),
    null,
    null,
    null,
    null,
)
  • 为什么能早于 Application 初始化: 系统在创建应用进程时会先安装并创建该进程声明的 ContentProvider, 然后再进入 Application.onCreate(). Jetpack App Startup 利用这个时机做库初始化, 但现代工程应收敛自动初始化, 避免启动链路不可见, 冷启动变慢.
  • 权限与边界: 对外暴露 Provider 要配置读写权限, exported, URI grant 或签名级权限; 内部初始化 Provider 不应暴露敏感数据. Provider 方法可能被跨进程并发调用, 数据库访问要考虑线程安全和事务.

ContentObserver 订阅的是 URI 变化

ContentResolver.registerContentObserver(uri, notifyForDescendants, observer) 订阅的是某个 Provider URI 的数据变更通知, Provider 在数据成功修改后通过 notifyChange(uri, null) 告知观察者. 它不读取数据, 也不替代 ContentResolver.query()/insert()/update()/delete() 的访问链路; 收到通知后, 调用方通常重新查询或让 Repository 刷新状态. 观察者必须按注册者生命周期注销, 跨进程调用仍需遵循 Provider 的权限和数据一致性约束.

常见错误:

  • 只记 “CRUD + URI”, 说不清 ContentResolver → authority → Provider → Binder 的调用链.
  • 把 Provider 当普通单例初始化器滥用, 导致 SDK 隐式启动, 冷启动耗时难定位.
  • 忽略 exported/permission/grantUriPermission, 把内部数据暴露给其他应用.

面试答题结构: 先解释 “URI + ContentResolver/Provider 解耦”→ 再画出 “跨进程 Binder 调用链”→ 举 “通讯录/媒体库/App Startup” 例子 → 最后补 “权限, 并发, 启动成本” 的坑.

六, 序列化与 Context

  • Serializable vs Parcelable: Serializable 是 Java 反射序列化, 慢, 产生大量临时对象; Parcelable 是 Android 专为内存 / IPC 设计, 快但代码繁琐 (Kotlin 用 @Parcelize 自动生成).跨进程 / Intent 传对象用 Parcelable.
  • Context 类型: Application Context (全局, 生命周期最长, 不能用于 UI/Dialog), Activity Context (带主题, 可弹窗, 注意泄漏). getApplicationContext 用于单例避免持有 Activity.

进阶补充: 现代组件 API 与版本限制

Activity Result API

startActivityForResult 已不推荐. Activity Result API 把启动和结果回调绑定到 lifecycle, 避免配置变更后的回调丢失.

运行时权限

Android 6.0 后危险权限运行时申请; Android 13 引入通知权限; Android 14 对前台服务类型和后台启动有更多限制. 面试要按版本讲, 不要只背一套老流程.

Fragment 返回栈与状态丢失

commit() 是异步; commitNow() 立即执行但不能加入 back stack. onSaveInstanceState 后再提交可能 state loss. setMaxLifecycle 常用于 ViewPager2 控制页面生命周期.

状态丢失时间线: 系统为恢复保存 Activity 状态 -> 异步网络回调到达 -> 回调试图 commit() 新 Fragment -> 进程若随后被杀, 已保存状态没有这笔事务 -> 恢复后的界面与用户最后看到的界面不一致. 因此不要把 “ 捕获异常后改用 commitAllowingStateLoss()“ 当默认修复. 症状是 Can not perform this action after onSaveInstanceState; 证据是生命周期日志与事务提交时机; 定位到晚到回调后, 应将结果放进 ViewModel/UI state, 在 RESUMED 时由 UI 渲染可恢复状态; 最后旋转, 后台恢复与返回栈均需验证.

前台 Service 类型

前台服务必须有用户可见通知, 新版本要求声明类型, 如 location, mediaPlayback, dataSync. 不能把前台服务当万能保活工具.

追问: 为什么 Fragment 会出现状态丢失? 因为系统已保存 Activity 状态后, 再提交 Fragment 事务无法保证恢复一致性.


版本化平台规则矩阵

后台启动 Activity, 广播接收, 前台服务, 通知权限和 android:exported 都是版本 /targetSdk 敏感规则. 设计与面试回答按 “OS/API, targetSdk 条件, 组件类型, 是否用户可见, 允许的豁免, 商店政策” 记录矩阵, 不背一个跨版本绝对结论. Manifest 暴露组件遵循最小权限, exported 组件还需权限, 输入验证和调用方鉴权. 具体规则统一回到 Android 版本适配.

高频面试题

Q1: singleTask 和 singleInstance 区别? singleTask 在所属任务栈内唯一, 可与其他 Activity 共栈; singleInstance 独占一个任务栈, 栈内只有它一个.

Q2: 横竖屏切换 Activity 生命周期? 如何保存数据? 默认销毁重建 (onPause→onStop→onDestroy→onCreate→…).用 onSaveInstanceState 保存临时数据, 或用 ViewModel (配置变更时存活) 保存. 配 android:configChanges 可避免重建.

Q3: onSaveInstanceState 何时调用? 和 onPause 顺序? 在 Activity 可能被系统销毁前调用 (如旋转, 内存不足, 切后台). Android P 后在 onStop 之后调用. 正常返回键退出不调用 (因为是用户主动销毁).

Q4: Serializable 和 Parcelable 怎么选? 内存传递, IPC, 性能敏感场景用 Parcelable; 持久化到磁盘或简单场景可用 Serializable. Kotlin 用 @Parcelize 减少模板代码.

Q5: 为什么不能用 Application Context 弹 Dialog? Dialog 需要 Activity 的主题和 Window token, Application Context 没有, 会抛 BadTokenException.

Q6: Service 和 Thread 区别? Service 能做耗时操作吗? Service 是组件, 运行在主线程, 不是线程; 它的意义是 “后台运行, 可被系统管理”: 承载它的进程会被系统判为更重要而更不易被杀, 但线程调度优先级不变. 耗时操作必须在 Service 内另开线程, 否则 ANR.

Q7: 8.0 之后后台限制有哪些? 后台 Service 受限 (需前台服务 + 通知), 隐式广播大量禁止静态注册, 后台定位受限. 推荐用 WorkManager 处理可延迟的后台任务.

UI 体系 - View 与自定义 View ★

你的重点短板. View 体系是应用开发的日常, 绘制流程, 事件分发是中级面试必考的硬核题.

一, View 绘制三大流程

从 ViewRootImpl.performTraversals() 触发, 依次走:

  1. measure (测量): 确定 View 的宽高.
  2. layout (布局): 确定 View 在父容器中的位置.
  3. draw (绘制): 把 View 画到画布上.

measure 与 MeasureSpec

MeasureSpec 是 32 位 int: 高 2 位是模式, 低 30 位是尺寸. 三种模式:

  • EXACTLY: 精确值 (match_parent 或具体 dp).
  • AT_MOST: 最大不超过 (wrap_content).
  • UNSPECIFIED: 不限制 (ScrollView 子 View).

父 View 的 MeasureSpec + 子 View 的 LayoutParams 共同决定子 View 的 MeasureSpec. 自定义 View 必须处理 wrap_content: 否则 AT_MOST 模式下表现得和 match_parent 一样 (因为默认用了父给的最大尺寸), 需在 onMeasure 里给 wrap_content 一个默认尺寸.

推演 getDefaultSize/resolveSize: getDefaultSize(size, spec) 只有 UNSPECIFIED 才回退到传入的 size (建议尺寸), EXACTLY/AT_MOST 一律返回 specSize, 这就是 “不处理 wrap_content 就等同 match_parent” 的来源. resolveSize(size, spec) 与之类似但更精细: EXACTLY 取 specSize, AT_MOST 取 min(size, specSize), UNSPECIFIED 返回 size, 自定义 View 常用来给 wrap_content 一个期望默认值. View.onMeasure 默认实现即 setMeasuredDimension(getDefaultSize(suggestedMinimumWidth, widthSpec), getDefaultSize(suggestedMinimumHeight, heightSpec)), 其中 suggestedMinimum 来自背景与 minWidth/minHeight; 所以覆盖 onMeasure 的常规套路是: 先按子 View 算出内容尺寸, 再用 resolveSize 决定最终测量值.

layout

onLayout 中调用子 View 的 layout(l, t, r, b) 确定位置. View 的 getWidth/getHeight(布局后的实际尺寸) 与 getMeasuredWidth/Height(测量尺寸) 区别: 正常情况相等, 但可被 layout 强行改变.

draw 顺序

  1. 绘制背景 drawBackground
  2. 绘制内容 onDraw
  3. 绘制子 View dispatchDraw
  4. 绘制前景 / 滚动条 onDrawForeground

二, 事件分发机制

三个核心方法, 贯穿 Activity → ViewGroup → View:

  • dispatchTouchEvent: 分发事件, 返回 true 表示消费.
  • onInterceptTouchEvent:仅 ViewGroup 有, 返回 true 拦截, 交给自己的 onTouchEvent.
  • onTouchEvent: 处理事件, 返回 true 表示消费.

传递规律 (U 型): 事件从 Activity 向下分发 (dispatch), ViewGroup 可在 intercept 拦截; 子 View 不消费则向上回传 (onTouchEvent 冒泡).

关键规则:

  • 一旦某 View 在 ACTION_DOWN 返回 true 消费了事件, 后续 MOVE/UP 都直接交给它 (形成事件序列).
  • 若 DOWN 没被消费, 后续事件不再传给它.
  • requestDisallowInterceptTouchEvent(true): 子 View 请求父不要拦截 (滑动冲突解决用).

必考反例链: 子 View 已消费 DOWN 后, 父 ViewGroup 在某个 MOVE 的 onInterceptTouchEvent 返回 true, 父会先向子 View 补发一个 ACTION_CANCEL, 子 View 收到后应复位按下状态, 否则会 “卡在按下态”; 之后的 MOVE/UP 全部交给父, 子 View 收不到. 若 DOWN 一开始就被父拦截, 子 View 从头到尾收不到该序列, 也不会收到 CANCEL. getX/getRawX 坐标系差异: getX 是相对父容器的坐标 (含自身 left 偏移), getRawX 是相对屏幕的原始坐标; 跨容器比较滑动方向或手势位置时应优先用 raw 值, 否则父容器移动后 getX 已不反映真实屏幕位移.

三, 滑动冲突解决

当内外层都能滑动 (如 ViewPager 嵌 ListView, 横滑嵌竖滑), 需解决冲突:

  • 外部拦截法 (推荐):重写父容器 onInterceptTouchEvent, 按需要判断是否拦截. DOWN 不拦截 (否则子 View 收不到), MOVE 时根据方向决定.
  • 内部拦截法: 父容器默认拦截所有, 子 View 通过 requestDisallowInterceptTouchEvent 动态控制父是否拦截, 配合父重写 onInterceptTouchEvent 对 DOWN 不拦截.

判断依据: 水平 / 垂直距离比较, 速度, 业务规则.

四, 自定义 View

三种方式:

  1. 继承现有 View(如 TextView):扩展功能.
  2. 继承 View: 完全自绘, 重写 onMeasure (处理 wrap_content)+ onDraw.
  3. 继承 ViewGroup: 自定义布局, 重写 onMeasure (测量子 View)+ onLayout (摆放子 View).

要点:

  • 自定义属性: attrs.xml 定义 → obtainStyledAttributes 读取.
  • 支持 padding (onDraw 中考虑 paddingLeft 等).
  • 避免在 onDraw 中 new 对象 (每帧调用, 造成内存抖动), Paint 等提前创建.
  • 状态保存: 重写 onSaveInstanceState/onRestoreInstanceState.

五, invalidate vs requestLayout

  • invalidate(): 触发重绘 (只走 draw), 不重新测量布局. UI 内容变了用它. 必须在主线程; 子线程用 postInvalidate().
  • requestLayout(): 触发重新 measure + layout (不一定 draw), 尺寸 / 位置变了用它.
  • 硬件加速: GPU 渲染, 部分 Canvas API 不支持 (老版本), 可按 View 关闭.

六, 动画, 布局与旧列表机制

动画类型: 选错会让视觉状态和交互状态分离

类型作用对象交互性与适用场景错误选型后果
补间动画View 的视觉变换适合简单的 alpha, 缩放, 位移动画通常不改变 View 实际属性和命中区域, 动画后点击位置可能不符合画面
帧动画一组 drawable 帧少量, 短时的逐帧效果大量或高分辨率帧会占用内存并掉帧
属性动画任意对象属性, 可驱动 translationX, alpha 等需要真实属性变化, 可交互或组合动画在每帧做重布局或重 IO 会挤占帧预算

插值器决定时间进度如何从 0 到 1, 例如先快后慢; 估值器根据该进度计算属性值, 例如颜色的 ARGB 插值. 把两者混为一谈会导致无法解释自定义动画的速度曲线和数值计算.

容器与列表的成本边界

容器测量与层级特征何时使用错误选型后果
LinearLayout顺序测量, 使用 layout_weight 可能引入额外测量简单线性排列为表达复杂相对关系嵌套多层, 增加 traversal 成本
RelativeLayout依赖相对规则, 复杂规则可能需要多次测量遗留 View 页面的小型相对布局继续堆叠规则难以维护, 复杂页面测量成本上升
ConstraintLayout在单层表达约束, 仍有约束求解成本需要减少嵌套的复杂页面把所有约束塞入一个巨大布局, 调试和求解都变复杂

ListView 的 convertView 和 ViewHolder 是历史上的行复用优化: 复用旧行并缓存子 View 查找结果. 它受单一布局, 局部更新, 动画和预取能力限制. 现代列表使用 RecyclerView 与 LayoutManager, DiffUtil 和 Recycler; 迁移不等于在 onBindViewHolder 做同步 IO. 更多复用链路见本章的 RecyclerView 复用与预取.

ListView 优化的完整回答

面试中可以按 “复用, 绑定, 数据更新, 滑动状态” 回答:

  • 多 viewType 复用: getViewTypeCount() 返回布局类型总数, getItemViewType(position) 返回稳定且合法的类型下标. convertView 只能在同一 viewType 内复用; 不同类型的行不能强转或混用. 类型数量过多会分散复用池, 但把结构不同的行硬塞成一种类型更容易引入错位和状态残留.
  • ViewHolder: 首次 inflate 时创建 Holder, 用 setTag 保存子 View 引用; 后续从 tag 取引用, 避免每次 findViewById. 每次 bind 都要显式覆盖文本, 可见性, 选中态和监听器等可变状态, 否则复用后会留下上一行的显示结果.
  • bind 热路径: getView() 会在滚动时高频执行, 不要在其中做磁盘 / 网络 IO, 大图解码, 重复资源解析或无必要的对象分配. 预先准备轻量模型, 缓存可复用资源, 把昂贵工作移到后台; 主线程 bind 只做必要的 UI 写入. 优化目标不是 “完全不分配”, 而是避免随滚动频率增长的分配和同步阻塞.
  • 图片生命周期: 图片请求应绑定到行或 ImageView 的当前身份, bind 前先设置占位图并取消或替换旧请求; 回调落地前校验目标仍对应同一 item/key, 避免图片回写到已复用的另一行. 在 adapter/listener 生命周期结束时取消请求或清理观察者, 避免泄漏和无效回调. 具体图片库的缓存与取消 API 以其生命周期约定为准.
  • 分页与增量更新: 不要一次性把无限数据全部放进内存. 按滚动位置和加载状态触发下一页, 防抖并避免重复请求; 加载成功后在主线程追加快照并调用合适的更新通知. 同时处理 loading, empty, error, 重试和末页状态, 不要在 getView() 里直接发起不可控的请求.
  • 滑动监听误区: OnScrollListener 的回调发生在滚动 / 布局过程中, 不能把 “接近末尾” 当成只触发一次的信号. 需要用 loading 标志, 当前数据量或请求 key 去重, 并区分 SCROLL_STATE_IDLE 与正在滚动; 不要在每次 onScroll() 中全量扫描, 刷新 adapter 或触发同步 IO. firstVisibleItem + visibleItemCount >= totalItemCount - threshold 只是触发条件, 不是分页完成条件.

迁移到 RecyclerView 的边界: 新功能通常优先 RecyclerView, 当需要多类型, 局部更新 / 动画, 横向或网格布局, 预取, 稳定 ID 与 DiffUtil 时迁移收益明显. 仅为修复单个旧页面的小问题不必机械迁移; 迁移会带来 ViewHolder, LayoutManager, item 装饰 / 动画, 无障碍和状态恢复等适配工作. RecyclerView 也不能自动解决 bind 热路径 IO, 图片错位或分页重复请求, 这些约束仍然成立.

自定义 LayoutManager 至少负责: 计算可见范围并摆放 child, 响应滚动距离, 回收离屏 View 并向 Recycler 取 View, 保存和恢复滚动位置. 下面只展示职责骨架, 不是可直接用于生产的完整实现:

// 示意代码: 未处理测量,预测布局,动画,边界与状态恢复细节.
class VerticalLayoutManager : RecyclerView.LayoutManager() {
    override fun generateDefaultLayoutParams() = RecyclerView.LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.WRAP_CONTENT,
    )

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        detachAndScrapAttachedViews(recycler)
        // 依次 obtain/bind/layout 可见 item, 并记录首个 position 与 offset.
    }
}

scrollVerticallyBy(dy, recycler, state) 不是必须实现, 但不实现列表无法手动滚动 (只能显示首屏): 实现里算出实际滚动量, 用 offsetChildrenVertical(-dy) 移动 child, 回收移出屏幕的 View, 返回实际消费的距离. canScrollVertically() 同理返回能否继续滚动, 影响嵌套滚动和事件消费判断.

RemoteViews: 受限的跨进程 View 描述

通知和 App Widget 不能把任意 View 实例跨进程传给宿主. RemoteViews 传递的是受支持布局和操作的描述, 由宿主进程 inflate 并应用, 因而只支持有限 View 类型和方法. 它适合通知与小部件, 不适合自定义 View, 任意事件逻辑或频繁复杂 UI 更新.

七, 布局时机, 文本与渲染表面

View.post 与布局时机

获取 View 宽高可以用下面的矩阵回答, 关键是区分 “本轮是否已经 layout” 和 “是否要等待一次布局回调”:

时机 / 方法能读到什么适用边界
layout 完成之后读取 width/heightView 的实际布局边界尺寸, 分别约等于 right - left 和 bottom - top适合依赖最终摆放结果; layout() 可被父容器以不同于 measured 值的边界调用
onMeasure 完成之后读取 measuredWidth/measuredHeight测量结果, 尚不代表父容器最终摆放的尺寸适合自定义 View 的测量逻辑; 不能代替 layout 后的 width/height
doOnLayout { ... }下一次 layout 完成后的回调一次性读取尺寸的常用方式; 需要 AndroidX Core KTX, 回调执行后会移除自身
addOnLayoutChangeListener每次边界变化后的回调, 含新旧 left/top/right/bottom需要持续观察尺寸变化时使用, 不需要时及时移除, 防止重复回调和生命周期泄漏
ViewTreeObserver.OnGlobalLayoutListenerView 树全局布局状态变化通知监听范围较宽且可能频繁触发; 只关心一个 View 时优先局部监听, 并注意 observer 存活状态和移除
onWindowFocusChanged(true)窗口获得焦点时的一个时机, 此时通常已经完成首轮布局不是尺寸就绪回调, 焦点可能反复变化且会重复调用; 不应依赖它只执行一次
手动 measure()主动得到 measured 尺寸仅在能根据父约束构造正确 MeasureSpec 时使用; 不能随意用屏幕宽高或 UNSPECIFIED 模拟真实布局, 否则结果与实际层级不一致

手动 measure 更适合脱离 View 树测量可独立构造的内容, 例如已知父约束的自定义布局预计算. 普通页面不要把它当作 “提前拿到最终宽高” 的捷径; 最终 width 仍由后续 layout 决定.

View.post 历史上 “经常” 能取到宽高, 是因为 View attach 后 Runnable 会投递到所属主线程消息队列, 通常排在当前 traversal 的 measure/layout 之后执行. 这只是队列顺序带来的常见结果, 不是 Android 对布局完成的保证: 首次 attach, requestLayout(), 异步数据更新或队列中其他任务都可能改变先后关系, 也可能读到旧尺寸或 0. 需要等待一次布局时推荐 doOnLayout; 需要连续观察变化时使用并正确移除 OnLayoutChangeListener.

资源尺寸 API 的差别也在取整时显现: getDimension() 返回按 density 换算的浮点像素值; getDimensionPixelSize() 四舍五入为整数且非零尺寸会至少为 1px; getDimensionPixelOffset() 向 0 截断. 绘制可保留浮点值, 像素边界或 LayoutParams 通常需要选择明确的整数取整策略.

StaticLayout 用于在 Canvas 上测量并绘制多行文本, 包含换行, 宽度, 对齐和行距处理; 不能只用 Paint.measureText() 计算整段文本宽度. 长文本应缓存可复用布局, 文本或可用宽度变化后才重建, 否则在 onDraw() 重建会造成抖动.

LayoutInflater 解析 XML, 根据标签创建 View 并应用属性. <merge> 省去多余根节点, 但要求提供且 attach 到实际 root; attachToRoot = true 会立即加入 root, false 则只创建供调用方稍后添加. View.inflate() 是常用便利入口, 仍应理解它委托给 Inflater. 错误传入 root 或 attach 标志常导致 LayoutParams 丢失, 重复添加或 ClassCastException.

SurfaceView 与 TextureView

视图渲染与合成边界适合场景限制
SurfaceView独立 Surface, 可由独立线程生产缓冲并交给 SurfaceFlinger 合成视频, 相机预览等高吞吐内容与普通 View 的层级, 裁剪, 动画和过渡效果协调受限
TextureView内容进入 View 树纹理, 可参与普通 View 的 alpha, 旋转, 缩放需与 View 动画和变换融合的预览纹理合成通常有额外开销, 也受硬件加速和生命周期限制

两者都跨越普通 Canvas 绘制边界. 按变换需求和实测帧性能选择, 不能仅因 “TextureView 可变换” 就用于所有视频场景.

八, 位移, 拖动与屏幕适配

layout() 改变 View 的实际边界和命中区域, 常用于父容器摆放子 View; translationX/Y 保留 layout 边界但改变渲染与通常的命中位置, 适合属性动画; scrollTo/scrollBy 改变容器内容的绘制偏移, 不改变子 View 的 layout 坐标; 动画是随时间改变上述属性的过程. 错误地用 layout 做每帧动画会触发更多布局, 用 translation 表达永久结构位置又会使读取坐标和状态恢复混乱.

两个嵌套 ViewPager 的冲突可按手势协商: DOWN 先让子页面收到事件, MOVE 比较 abs(dx) 与 abs(dy); 垂直手势交给可纵向滚动的父容器, 水平手势由子 pager 处理, 到子 pager 的首尾页且继续向外滑时允许父 pager 拦截. 阈值应基于 touch slop, 并处理多指, CANCEL 与 RTL 方向; 单凭第一次 MOVE 的固定方向会造成抖动.

ViewDragHelper 将复杂拖动拆为 tryCaptureView() 决定可捕获对象, clampViewPositionHorizontal/Vertical() 限制边界, onViewReleased() 决定释放后的停靠或回弹, 再在容器 computeScroll() 中持续推进. 它不是自动解决嵌套手势和无障碍的完整组件, 仍需协调父拦截和状态恢复.

适配清单

  • 使用 dp 表示几何尺寸, sp 表示文字并尊重用户字体缩放; 不把固定 px 或关闭 font scale 当适配方案.
  • 用限定符资源和可伸缩约束处理宽度, 高度, 语言和方向差异, 避免以单一屏幕尺寸硬编码 margin.
  • 通过 WindowInsets 处理 system bars, 屏幕缺口, 手势区和 IME, 让输入框与列表内容避让而非猜测状态栏高度.
  • 在折叠屏和大屏上根据窗口尺寸类别, 折叠状态和多窗口重新组织布局, 不假定横屏只是手机旋转.
  • 在实际字体倍率, RTL, 分屏, 键盘展开和横竖屏下检查文本截断, 触摸目标与焦点顺序.

完整自定义 View: 可选评分条

可运行示例 (工程内): 在 Android App 中新增 res/values/attrs.xml, 以下 Kotlin 类和布局引用. 它涵盖 wrap_content 测量, 绘制, 自定义属性, 触摸和状态保存. 点击评分条的不同位置, 预期分数在 1–5 之间变化; 旋转后分数保持.

<!-- res/values/attrs.xml -->
<resources><declare-styleable name="RatingBarView">
    <attr name="starCount" format="integer" /><attr name="ratingColor" format="color" />
</declare-styleable></resources>
<!-- res/layout/fragment_rating.xml;父层须参与 View hierarchy state 保存 -->
<com.example.RatingBarView
    android:id="@+id/product_rating"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:starCount="5"
    app:ratingColor="@color/yellow" />
class RatingBarView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var starCount = 5; private var rating = 0; private var color = Color.YELLOW
    init { context.obtainStyledAttributes(attrs, R.styleable.RatingBarView).use {
        starCount = it.getInt(R.styleable.RatingBarView_starCount, 5).coerceAtLeast(1)
        color = it.getColor(R.styleable.RatingBarView_ratingColor, Color.YELLOW)
    }; isSaveEnabled = true }
    override fun onMeasure(widthSpec: Int, heightSpec: Int) {
        setMeasuredDimension(resolveSize(paddingLeft + paddingRight + starCount * 48.dp, widthSpec), resolveSize(paddingTop + paddingBottom + 48.dp, heightSpec))
    }
    override fun onDraw(canvas: Canvas) { val cell = (width - paddingLeft - paddingRight).toFloat() / starCount; if (cell <= 0f) return; paint.color = color
        repeat(rating) { canvas.drawCircle(paddingLeft + cell * (it + .5f), height / 2f, cell * .3f, paint) } }
    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (!isEnabled) return false
        val usableWidth = width - paddingLeft - paddingRight
        if (usableWidth <= 0) return false
        return when (event.actionMasked) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_CANCEL -> true
            MotionEvent.ACTION_UP -> { rating = (((event.x - paddingLeft) / usableWidth) * starCount).toInt().plus(1).coerceIn(1, starCount); updateStateDescription(); performClick(); invalidate(); true }
            else -> false
        }
    }
    override fun performClick(): Boolean = super.performClick()
    override fun onSaveInstanceState(): Parcelable = SavedState(super.onSaveInstanceState()).also { it.rating = rating }
    override fun onRestoreInstanceState(state: Parcelable?) { val saved = state as? SavedState; super.onRestoreInstanceState(saved?.superState ?: state); rating = saved?.rating ?: 0; updateStateDescription(); invalidate() }
    private class SavedState : BaseSavedState {
        var rating = 0
        constructor(superState: Parcelable?) : super(superState)
        constructor(source: Parcel) : super(source) { rating = source.readInt() }
        override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags); out.writeInt(rating) }
        companion object CREATOR : Parcelable.Creator<SavedState> {
            override fun createFromParcel(source: Parcel) = SavedState(source)
            override fun newArray(size: Int) = arrayOfNulls<SavedState>(size)
        }
    }
    private fun updateStateDescription() { ViewCompat.setStateDescription(this, "$rating / $starCount") }
    private val Int.dp get() = (this * resources.displayMetrics.density).toInt()
}

需导入 android.os.Parcel, android.os.Parcelable, android.view.View.BaseSavedState 和 androidx.core.view.ViewCompat. ViewCompat.setStateDescription 负责 API 兼容; performClick() 仅委托 super 发送标准无障碍点击语义, 不重复手动发事件. 稳定 android:id 与父层 View hierarchy state 保存是恢复前提. 数字推演: 父容器给 AT_MOST 300px, density 为 1,5 星 期望宽 5 x 48 = 240px, resolveSize 得 240px; 给 EXACTLY 180px 则得 180px. 事件 trace: DOWN 获取序列, MOVE 保持消费, UP 更新分数并触发标准点击语义, CANCEL 不改分数. 该示例未在本仓库构建验证, 预期旋转后恢复最后评分.

九, Window / DecorView / ViewRootImpl

  • Window: 抽象窗口, PhoneWindow 是唯一实现.
  • DecorView: Window 的顶层 View (含 status bar, content).
  • ViewRootImpl: 连接 WindowManager 和 DecorView, 是绘制流程的发起者, 事件分发的入口.
  • 关系: Activity → PhoneWindow → DecorView → ViewRootImpl 驱动绘制.
  • 触发时机: View attach 到窗口后, requestLayout/invalidate 等请求会经 ViewRootImpl 调度下一帧 traversal; 面试说到这里即可, 不要把具体内部调度函数当稳定 API 背诵.

进阶补充: 嵌套滑动, 帧管线与 RecyclerView

NestedScrolling

嵌套滑动解决父子 View 都想消费滑动的问题. child 先询问 parent 是否参与, 滚动前后分发消耗量.

接口方法概览: child 调 startNestedScroll 发起协商, parent 的 onStartNestedScroll 决定是否参与; 滚动前 dispatchNestedPreScroll 让 parent 先消费, child 处理剩余后经 dispatchNestedScroll 分发, 回调 parent 的 onNestedScroll 落账; fling 同理走 dispatchNestedPreFling/dispatchNestedFling. requestDisallowInterceptTouchEvent(true) 只禁止父拦截 touch 事件, 与 NestedScrolling 的 scroll 协商是两套独立机制; CoordinatorLayout + AppBarLayout 正是靠这套协商协调 header 折叠与子列表滚动.

典型场景: CoordinatorLayout + AppBarLayout + RecyclerView.

Choreographer 与 VSync

Choreographer 接收 VSync 信号, 在主线程依次调度 input, animation, traversal 和 commit 等回调. traversal 完成 measure/layout 并记录绘制命令, 硬件加速路径再由 RenderThread 和 GPU 处理, 最终通过 Surface 提交缓冲, 由 SurfaceFlinger 合成显示:

VSync
  -> Choreographer
     -> UI Thread: input / animation / traversal
        -> RenderThread
           -> GPU
              -> Surface / BufferQueue
                 -> SurfaceFlinger
                    -> Display
刷新率单帧理论预算
60Hz约 16.7ms
90Hz约 11.1ms
120Hz约 8.3ms

理论预算不等于业务代码可以全部占满的时间, 系统调度和流水线阶段也需要预算. 掉帧可能来自主线程布局和绑定, RenderThread, 纹理上传, GPU 或 SurfaceFlinger 合成, 用 Perfetto 和 Frame Timeline 确认实际瓶颈, 不能只盯 onDraw().

RecyclerView 复用与预取

RecyclerView 的性能来自布局, 复用, 数据差分和预取共同协作, 不是只有一个 ViewHolder 缓存:

角色主要职责高频追问
LayoutManager测量和摆放可见 Item, 决定滚动方向, 回收与预取位置它决定布局, 但不持有业务数据
Recycler按 position/viewType 查找 scrap, cache 和 pool, 必要时创建并绑定 ViewHolder命中缓存不代表一定跳过 bind
RecycledViewPool跨 RecyclerView 按 viewType 共享已回收 ViewHolder嵌套同构列表可共享, 不同语义的 viewType 不要误复用
Adapter创建 ViewHolder, 绑定数据并报告数据变化bind 路径不能做重 IO 或重复解码
GapWorker根据滚动信息协调多个 RecyclerView, 利用帧间剩余预算做预取预取过多会挤占 CPU 和内存预算

一次滚动可以这样回答: LayoutManager 计算即将进入屏幕的 position, Recycler 优先从 scrap/cache/pool 找可用 ViewHolder, Adapter 在需要时创建或绑定, 离屏 ViewHolder 被回收, GapWorker 根据预取位置提前准备后续 Item.

数据更新与局部绑定

  • DiffUtil 比较旧列表和新列表, 计算插入, 删除, 移动和内容变化. 大列表应在后台计算, ListAdapter 和 AsyncListDiffer 已封装异步差分.
  • areItemsTheSame 判断是否为同一业务实体, areContentsTheSame 判断展示内容是否变化. 两者不能都用 position.
  • stable IDs 用稳定业务 ID 表达 Item 身份, 可帮助状态和动画保持, 但它不能替代正确的 DiffUtil 实现.
  • payload 表达局部字段变化, 例如只更新点赞数, 避免重新绑定图片和复杂子树. Adapter 仍要保留完整 bind 作为兜底.
  • 提交给 DiffUtil 的列表和 Item 应按快照理解. 原地修改旧对象可能让差分看不到变化.

Adapter 通知 API 的取舍

API绑定范围与动画位置一致性与成本
notifyDataSetChanged()不描述具体变化, 可见项通常需要重新 bind; 默认无法得到可靠的插入 / 删除动画, 可能造成闪烁调用方必须已更新完整数据源且保持 position 语义一致; 最粗粒度, 成本和无效绑定最多
notifyItemChanged(position)只通知一个位置重新 bind, 通常可产生该 Item 的 change 动画只适用于该位置确实仍代表同一 Item; position 由 adapter 当前快照解释, 不要把旧 position 跨异步回调长期保存
notifyItemChanged(position, payload)仍只影响一个位置, 非空 payload 可走 partial bind (见「数据更新与局部绑定」)payload 是优化提示而非完整数据; 需完整 bind 兜底, 多个 payload 可能合并
notifyItemInserted/Removed(position)插入 / 删除位置及其后的布局位置更新, RecyclerView 可播放结构变化动画先改数据快照再通知, position 必须对应修改前后的契约; 单项通知成本低于全量通知, 但大量逐项通知可能不如批量 range 或差分
notifyItemRangeChanged/Inserted/Removed(start, count)对连续范围做同类更新, 范围内 Item 可能重新 bind 或移动并参与相应动画范围必须连续且数量准确; 误报范围会造成错绑或额外工作, 不连续变化不要用一个范围硬覆盖
DiffUtil / ListAdapter.submitList()按 Item 身份与内容差分计算移动与变化 (见「数据更新与局部绑定」); 通常保留局部动画差分有 CPU 和临时内存成本, 适合快照式列表; ListAdapter 异步计算并按提交顺序处理结果, Item 不应在计算期间原地修改

DiffUtil / stable IDs / payload 的机制, 比较原则与配合边界统一见「数据更新与局部绑定」.

位置通知的共同原则是 “先更新数据, 再发与变更完全匹配的通知”. 不要在异步回调里依赖过期 adapter position; 需要当前绑定位置时读取 bindingAdapterPosition, 并处理 NO_POSITION. 如果业务变化难以可靠地手工描述, 用不可变的新列表交给 DiffUtil/ListAdapter; 如果只是一个已知字段变化且身份和位置都未变, 单项 payload 通知更便宜. 通知越粗, 无效 bind 越多; 通知越细, 调用方维护位置和快照的正确性成本越高.

嵌套横向列表可让多个子 RecyclerView 共享 RecycledViewPool, 并根据首屏需求设置预取数量. 前提是相同 viewType 的 ViewHolder 结构和绑定契约一致; 还要避免每次父 Item bind 都创建新 Adapter, Pool 或 LayoutManager.

GestureDetector 与 VelocityTracker

复杂手势不要全靠手写坐标判断. 点击, 长按, fling 可用 GestureDetector; 速度计算可用 VelocityTracker.

追问: 为什么 RecyclerView 滑动卡顿? 常见是 bind 太重, 图片加载尺寸失控或复用回调错位, 布局层级深, 频繁全量刷新, 预取配置不合理以及共享 Pool 使用错误. 先用 Perfetto, Frame Timeline 和 Layout Inspector 判断是主线程, 渲染线程还是 GPU 瓶颈.

进阶补充: WindowInsets, 无障碍与 RecyclerView 细节

Edge-to-edge 与 WindowInsets

新版本 Android 对 edge-to-edge 的要求逐步收紧. View 体系里不要靠写死 status bar/nav bar 高度, 而是通过 WindowInsets 把系统栏, 屏幕缺口, IME 输入法区域当作布局输入.

常见处理方式:

ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
    val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
    view.updatePadding(top = bars.top, bottom = bars.bottom)
    insets
}

面试重点: Insets 应该在基础容器统一处理, 否则每个页面各写一套 padding, 很容易出现输入框被键盘遮挡, 列表最后一项被导航栏遮挡, 沉浸式页面返回后状态不一致.

无障碍 Accessibility

中级面试常追问 “你做 UI 有没有考虑可访问性”.View 体系至少要说清:

  • 图片按钮要有 contentDescription, 纯装饰图可标记为不重要.
  • 触摸目标建议不小于 48dp.
  • 自定义 View 要补充语义, 状态和点击行为, 不要只画图不暴露给 TalkBack.
  • 表单错误要能被读屏感知, 不能只靠红色边框.
  • 列表/弹窗/底部 Sheet 要考虑焦点顺序和返回键.

RecyclerView 现代实践

问题推荐做法易错点
全量刷新卡顿DiffUtil 差分 (见「数据更新与局部绑定」)notifyDataSetChanged() 一把梭
局部字段更新payload partial bind (见「数据更新与局部绑定」)每次都重绑图片 / 复杂布局
Item 身份稳定stable IDs 或业务唯一 key (见「数据更新与局部绑定」)用 position 当 ID
动画闪烁评估 ItemAnimator / payload默认动画导致图片闪烁
嵌套列表共享 RecycledViewPool, 控制预取每个子列表重复创建大量 ViewHolder

ConstraintLayout / MotionLayout

ConstraintLayout 适合减少层级, 表达复杂相对约束; MotionLayout 适合声明式描述状态间动画. 面试回答时不要只说 “性能好”, 要补一句: 复杂约束也会增加求解成本, 真正优化要用 Layout Inspector/Perfetto 看 measure/layout 时间.


刷新率, Insets 与 Compose 互操作

16.7 ms 只对应 60 Hz 的单帧周期示例, 不是所有设备固定 deadline; 90/120 Hz 与动态刷新率下预算更短且帧调度受系统影响. 自定义 View 要处理 window insets, 手势导航, 键盘, RTL, 字体缩放和无障碍. View/Compose 混用时明确状态 owner, ComposeView composition disposal, View 生命周期与重组边界. 卡顿证据统一见 ANR 与卡顿排查.

高频面试题

Q1: View 的绘制流程? 从哪里开始? 从 ViewRootImpl.performTraversals 开始, 依次 performMeasure (measure)→ performLayout (layout)→ performDraw (draw). measure 确定大小, layout 确定位置, draw 绘制内容.

Q2: MeasureSpec 是什么? 三种模式? 32 位 int, 高 2 位模式 + 低 30 位尺寸. EXACTLY (精确, match_parent / 具体值), AT_MOST (最大, wrap_content), UNSPECIFIED (不限, 如 ScrollView 子 View).由父 MeasureSpec + 子 LayoutParams 共同决定.

Q3: 自定义 View 直接继承 View, wrap_content 不生效怎么办? 在 onMeasure 中判断模式为 AT_MOST 时, 给一个默认尺寸 (不能直接用父给的最大值, 否则等同 match_parent).

Q4: 事件分发三个方法? 返回值含义? dispatchTouchEvent (分发), onInterceptTouchEvent (ViewGroup 拦截), onTouchEvent (处理).返回 true 表示消费, 事件序列后续都给它; 返回 false 向上回传.

Q5: 滑动冲突怎么解决? 外部拦截法 (父重写 onInterceptTouchEvent 按需拦截, DOWN 不拦截) 或内部拦截法 (子用 requestDisallowInterceptTouchEvent 控制).判断依据是滑动方向 / 距离.

Q6: invalidate 和 requestLayout 区别? 可以在子线程调用吗? invalidate 重绘 (走 draw), requestLayout 重新测量布局. invalidate 必须主线程, 子线程用 postInvalidate.

Q7: getWidth 和 getMeasuredWidth 区别? getMeasuredWidth 是 measure 后的测量值, getWidth 是 layout 后的实际值 (= right - left).通常相等, 但 layout 可强行改变实际尺寸使其不等.

Q8: onDraw 里能不能 new 对象? 不是绝对禁止, 而是避免: onDraw 每帧调用, 频繁创建对象导致内存抖动, 频繁 GC, 卡顿. Paint/Path 等应在构造时创建并复用; 偶发的低频分配并非违规, 重点是别在热路径每帧 new.

Q9: Edge-to-edge 页面为什么容易被系统栏或键盘遮挡? 因为内容区域延伸到了系统栏 / IME 后面, 但页面没有正确消费 WindowInsets. 正确做法是统一监听 systemBars/ime insets, 给根容器, 列表或输入框加动态 padding/margin, 不要写死状态栏高度.

Q10: View 体系里怎么做无障碍适配? 给可点击图片 / 图标补 contentDescription, 保证触摸目标大小, 自定义 View 暴露语义和状态, 错误提示可被 TalkBack 读到, 并检查焦点顺序和返回行为.

Q11: RecyclerView 的 Recycler, RecycledViewPool, LayoutManager 和 GapWorker 如何协作? LayoutManager 决定可见范围, 摆放和预取位置; Recycler 按 position 和 viewType 从 scrap, cache, pool 中找 ViewHolder; RecycledViewPool 保存可跨 RecyclerView 复用的回收 Holder; GapWorker 汇总预取位置并提前准备. Adapter 只负责创建和绑定, 不能把所有复用能力都归因于 Adapter.

Q12: DiffUtil, stable IDs 和 payload 分别解决什么问题? 三者分别解决列表差分, Item 身份稳定与局部字段更新, 可配合使用; 比较原则与边界 (stable IDs 不能替代 DiffUtil, payload 需完整 bind 兜底) 见「数据更新与局部绑定」.

Q13: 一帧从 VSync 到显示经历什么? Choreographer 接收 VSync 并在主线程调度输入, 动画和 traversal; 主线程记录绘制命令后, RenderThread/GPU 完成渲染并向 Surface 提交缓冲; SurfaceFlinger 合成各图层后交给显示系统. 60Hz, 90Hz 和 120Hz 的理论预算分别约为 16.7ms, 11.1ms 和 8.3ms.

UI 体系 - Jetpack Compose ★

你的重点短板, 且 2024-2025 中级面试几乎必问. Compose 是 Android 声明式 UI 的未来, 新项目首选. 即便面试官项目还在用 View, 也常问 Compose 的理解.

一, 声明式 UI 思想

  • 命令式 (View): 你持有 View 引用, 手动 findViewById, setText, setVisibility 去 “改” UI.
  • 声明式 (Compose): 你描述 “UI 在某状态下长什么样”, 状态变了框架自动重组 (recomposition, 重跑受影响的 Composable). UI = f(State).
@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name")   // 不持有引用,name 变了自动重组
}

二,@Composable 与重组 (Recomposition)

  • @Composable 函数不返回 UI 对象, 而是向 Composition (组合树) 发射 (emit) 描述节点.
  • 直觉上可把 @Composable 理解为 “带有组合上下文的函数”:编译器会改写调用以便框架记录调用位置, 参数和状态读取, 从而决定哪里需要重组; 具体改写细节属于实现层面, 面试重点是理解 “状态读取驱动局部重组”.
  • 重组: 当 Composable 读取的 State 变化时, 框架重新执行受影响的 Composable 来更新 UI.
  • 重组特性 (必须理解, 易踩坑):
    • 可能频繁发生, 所以 Composable 要无副作用, 幂等, 快速.
    • 执行顺序不保证, 可能并行.
    • 不要在 Composable 体内直接做副作用 (网络, 写变量), 要用副作用 API.

稳定性, strong skipping, compiler reports 与性能测量属于 Compose 深水区; 本篇只要求先写出正确, 可读, 可测试的页面.

三, 状态管理

  • remember { }: 在重组间记住值 (存在 Composition 中), 重组不重置.
  • mutableStateOf(): 创建可观察状态, 读取它的 Composable 会在它变化时重组.
  • remember { mutableStateOf(x) }: 最常见组合. rememberSaveable 还能跨配置变更 / 进程恢复.
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("$count") }
  • rememberSaveable 自定义 Saver: 不能直接存入 Bundle 的类型用 rememberSaveable(saver = ...) 提供 Saver(save = { ... }, restore = { ... }). 示意: rememberSaveable(saver = Saver(save = { it.id }, restore = { findById(it) })) { mutableStateOf(Task()) }, 让非 Parcelable 对象也能跨配置恢复.
  • lambda 捕获陷阱: remember { count } 的初值 lambda 只在首次组合执行, 捕获的是当时旧值; 需要随 count 变化重建时应把 count 放进 key (remember(count) { ... }), 长生命周期对象里要用 rememberUpdatedState 拿最新值.
  • 状态提升 (State Hoisting): 把状态移到调用者, Composable 变成无状态 (stateless), 通过参数接收 value + 回调 onValueChange. 利于复用, 测试, 单一数据源.

四, 副作用 API (Side Effects)

副作用是 “在 Composable 之外发生的影响”.因为重组随时可能发生, 副作用必须用专门 API 管理生命周期:

  • LaunchedEffect(key): 进入组合时启动一个协程, key 变化时取消重启, 离开组合时取消. 用于一次性挂起操作 (加载数据, 监听 Flow).
  • rememberCoroutineScope(): 拿到一个绑定组合生命周期的 scope, 在事件回调 (如 onClick) 里启动协程.
  • DisposableEffect(key): 需要清理的副作用 (注册 / 反注册监听器), 提供 onDispose {}.
  • SideEffect: 每次重组成功后执行, 用于把 Compose 状态同步给非 Compose 代码.
  • derivedStateOf: 从其他 state 派生计算值, 只有结果变化才触发重组 (避免过度重组, 如根据滚动位置算 “是否显示回顶按钮”).
  • rememberUpdatedState: 在长生命周期 effect 中引用最新值而不重启 effect.
  • produceState / snapshotFlow: State 与 Flow 互转.
LaunchedEffect(userId) {              // userId 变才重新加载
    viewModel.load(userId)
}

五, CompositionLocal 与 Modifier

  • CompositionLocal: 隐式向下传递数据 (主题, Context), 避免逐层传参. 如 LocalContext.current, MaterialTheme.
  • compositionLocalOf 追踪读取: 某 Composable 读取它后, 值变化会触发读取处重组; staticCompositionLocalOf 不追踪读取, 值变化时使 Provider 子树整体重组, 适合几乎不变的默认值 (如主题).
  • Provider 边界: CompositionLocalProvider(LocalX provides v) { ... } 只影响其子树, 子树外读到的仍是外层值; Local 定义应在文件顶层, 不要在重组路径上创建新 Local.
  • Modifier: 装饰 Composable (尺寸, padding, 点击, 背景).顺序敏感: padding().background() 与 background().padding() 效果不同 (前者内边距区域无背景色). Modifier 是链式不可变的.

六, 三大阶段

Compose 渲染分三阶段:

  1. Composition (组合): 执行 Composable, 构建 / 更新 UI 树 (发射什么).
  2. Layout (布局): 测量 + 摆放 (多大, 放哪).
  3. Drawing (绘制): 画到屏幕.

三个阶段用于建立渲染直觉; 重组范围, 跳过条件和性能诊断见 Compose 深水区.

七, 常用布局, Material 3 与完整页面

Column 纵向摆放, Row 横向摆放, Box 用于叠放; Scaffold 提供 top bar, bottom bar, snackbar 和内容 padding 的页面骨架; LazyColumn 只组合可见附近元素, 适合动态长列表. Material 3 组件通过 MaterialTheme 读取颜色, 排版和形状.

@Composable fun AvatarWithBadge() = Box {
    Icon(Icons.Default.Person, contentDescription = "用户")
    Text("3", Modifier.align(Alignment.TopEnd))
}

可运行示例 (工程内): 在已配置 Compose Material 3 的模块, 以 setContent { MaterialTheme { TodoPage() } } 调用. Preview 显示初始态; 运行后输入文本并点击添加, 预期列表新增一项, 输入框清空, 复选框可切换完成状态.

@Composable
fun TodoPage() {
    var input by rememberSaveable { mutableStateOf("") }
    var tasks by remember { mutableStateOf(listOf("阅读 Compose" to false)) }
    Scaffold(topBar = { TopAppBar(title = { Text("待办") }) }) { padding ->
        Column(Modifier.padding(padding).padding(16.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                OutlinedTextField(input, { input = it }, label = { Text("新任务") }, modifier = Modifier.weight(1f))
                Button(onClick = { if (input.isNotBlank()) { tasks = tasks + (input.trim() to false); input = "" } }) { Text("添加") }
            }
            LazyColumn { itemsIndexed(tasks) { index, task ->
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Checkbox(task.second, { checked -> tasks = tasks.toMutableList().also { it[index] = task.first to checked } })
                    Text(task.first)
                }
            } }
        }
    }
}
@Preview(showBackground = true) @Composable
private fun TodoPagePreview() { MaterialTheme { TodoPage() } }

这里 TodoPage 拥有演示状态; 复用组件应提升状态, 例如 TaskRow(task, checked, onCheckedChange), 让调用方持有状态和业务 ID. 列表来自服务端或可删除时, 不能用 index 作稳定 key, 应使用业务 ID.

八, 与 View 互操作

  • Compose 中嵌 View: AndroidView(factory = { ctx -> View(ctx) }, update = { view, state -> ... }), factory 只在首次组合创建 View 一次, update 在每次重组同步参数, 不要在 factory 里放会变的状态.
  • View 中嵌 Compose: ComposeView.setContent { }, 并设置合适的 ViewCompositionStrategy.
  • ViewCompositionStrategy 决定 Composition 何时销毁: 默认 DisposeOnViewTreeLifecycleDestroyed (View attach 到带 Lifecycle 的树, 其销毁时释放); 也可用 DisposeOnDetachedFromWindow (脱离窗口即释放) 或 DisposeOnLifecycleDestroyed(lifecycle) (显式绑定 Lifecycle).
  • 性能诊断, 稳定性与跳过规则统一见 Compose 深水区.

高频面试题

Q1: Compose 和传统 View 的本质区别? View 是命令式, 保留模式 (retained, 持有可变 View 树, 手动改); Compose 是声明式, 同样维护持续存在的组合树 (retained Composition), 状态变化时只增量重组受影响部分, 不是每帧全量重绘的即时模式 (immediate mode). Compose 无 findViewById, 无 XML, Kotlin 写 UI.

Q2: 什么是重组? 它有什么注意事项? State 变化时重新执行受影响的 Composable 更新 UI. 注意: 可能频繁/并行/乱序执行, 所以 Composable 必须无副作用, 幂等, 快, 副作用要用专门 API.

Q3: remember 和 rememberSaveable 区别? remember 在重组间保留值, 但配置变更 (旋转)/进程重建会丢失; rememberSaveable 额外通过 Bundle 保存, 能跨配置变更恢复 (类型需可 Parcelize).

Q4: LaunchedEffect 和 rememberCoroutineScope 区别? LaunchedEffect 在组合期启动协程, 随 key 重启, 离开自动取消, 用于进入界面就执行的副作用; rememberCoroutineScope 返回 scope, 在事件回调里手动启动协程.

Q5: 为什么要让 Composable 保持无副作用? 因为状态变化可使受影响范围再次执行, 函数体内直接发请求, 写数据库或修改外部变量会被重复执行. 把这类工作交给 LaunchedEffect, 事件回调或 ViewModel; 具体重组诊断见 Compose 深水区.

Q6: Compose 的三个阶段是什么? Composition 发射 UI 树, Layout 测量和摆放, Drawing 绘制内容. 它们帮助理解一次 UI 更新的路径; 性能诊断与跳过规则见 Compose 深水区.

Q7: Modifier 顺序为什么重要? Modifier 链按顺序应用, 每个包裹前一个. padding 在 background 前后效果不同. size, padding, clickable 顺序都会改变最终表现.

Q8: 状态提升是什么? 为什么重要? 把状态从 Composable 内移到调用方, 使 Composable 无状态 (接 value + onValueChange).好处: 单一数据源, 可复用, 可测试, 可被多处控制.

易错点 / 追问

  • remember { } 的初值 lambda 只在首次组合执行, 捕获的是当时旧值: 需要随状态重建时把变化值放进 key, 长生命周期 effect 中要用 rememberUpdatedState.
  • 不要在 Composable 体内直接做副作用 (网络, 写库, 改外部变量): 重组可能反复执行函数体, 副作用要交给 LaunchedEffect / DisposableEffect 或事件回调.
  • LaunchedEffect 的 key 一变就取消重启: 把每次重组都新建的对象放进去会导致无限重启; key 应使用稳定标识 (ID 或业务值).
  • rememberSaveable 只能保存 Bundle 兼容类型: 自定义对象必须提供 Saver, 否则配置变更后恢复失败.
  • Modifier 顺序敏感: padding().background() 与 background().padding() 表现不同; 说不清顺序时按 “每个 Modifier 包裹前一个” 推导.
  • AndroidView 的 factory 只在首次组合执行: 把会变的状态放进 factory 是常见错误, 同步参数要放 update.
  • View 中嵌 Compose 时 ViewCompositionStrategy 选错会导致组合不销毁或过早销毁: 默认 DisposeOnViewTreeLifecycleDestroyed 覆盖大多数场景, 脱离 Lifecycle 树时改用 DisposeOnDetachedFromWindow.

Compose 深水区

本篇只讨论 Snapshot, 重组范围, 跳过与性能诊断; Column, Scaffold, Material 3, Preview 和状态提升的入门页面见 Compose 入门.

一, Recomposition: 状态读取驱动局部重组

Compose 的核心是 UI = f(State). 当某个 Composable 在组合阶段读取了 State, 这个读取关系会被记录; State 改变后, 相关范围进入重组.

  • 重组是重新执行 Composable, 不是重新创建整个 Activity/View 树.
  • 重组可能很频繁, Composable 必须幂等, 无副作用, 执行快.
  • 不要在 Composable 体内直接发网络请求, 写数据库, 改全局变量.
  • 把状态读取推迟到最小范围, 减少被重组的 UI 面积.
@Composable
fun ProfileScreen(state: ProfileState, onRetry: () -> Unit) {
    when {
        state.loading -> Loading()
        state.error != null -> ErrorView(state.error, onRetry)
        else -> ProfileContent(state.user)
    }
}

Phase 细分 (与 09「三大阶段」断链补齐): 一次 UI 更新依次走 Composition (组合, 执行 Composable 生成节点), Layout (布局, 测量与摆放), Drawing (绘制, 画到 Canvas). State 在哪个阶段被读取决定后续触发范围: 组合阶段读取 → 该 State 变化触发重组; 布局阶段读取 (如自定义 Layout 测量中读) → 变化只触发重新测量布局, 不重组; 绘制阶段读取 (如 Canvas 绘制中读) → 只触发重绘. 所以应把状态读取尽量放在组合阶段交给 Compose 追踪; 布局 / 绘制阶段直接读可变值会绕过快照追踪, 改值不一定刷新.

二, Stability, Strong Skipping 与 Skippable

Compose 的跳过规则依赖 Kotlin 与 Compose Compiler 版本及项目配置. 对采用 Kotlin 2.0.20+ 且未显式关闭 strong skipping 的项目, 它通常默认启用; 许多参数类型不稳定但函数可重启的 Composable 也可以被标记为 skippable. 因此, 下面这种旧口诀不再准确:

参数不稳定 → 整个 Composable 一定不能跳过.

当前更可靠的判断方式:

  1. 先确认函数是否 restartable/skippable, 以及项目是否使用匹配版本的 Compose Compiler Gradle plugin.
  2. 用 compiler reports/metrics 查看稳定性推断, 不凭肉眼猜测.
  3. 用重组计数, Layout Inspector, Perfetto 或 Macrobenchmark 证明是否存在实际性能问题.
  4. 优先修正状态读取范围, 对象频繁重建和列表 key/contentType; 不要为了 “稳定” 盲目添加注解.
@Immutable
// 只有字段及其可达状态确实不可变时才成立
 data class UserUiModel(
    val id: String,
    val name: String,
)

@Stable/@Immutable 是正确性契约, 不是性能开关. 若对象内部变化无法被 Compose 观察, 错误标注可能让界面漏更新. 对于第三方或集合类型, 可在边界转换成不可变 UI model, 或通过 stability configuration 谨慎声明, 并用报告验证结果.

strong skipping 具体例子: 参数稳定且 lambda 未变时可整段跳过, 例如 ProfileRow(user, onClick = onClickRef) 中 user 与 onClickRef 都未变 (strong skipping 下捕获稳定值的 lambda 会被自动 remember 为同一实例), 编译器跳过 ProfileRow. 反例: var count by remember { mutableStateOf(0) } 后传 ProfileRow(user, onClick = { log(count) }): lambda 捕获了会变的 count, count 一变 lambda 便重建, 参数引用不再相等, 即使 user 相同也无法跳过. strong skipping 只能忽略 “真的没变” 的参数, 不能阻止 “捕获了变化状态” 的 lambda 重建.

三, remember / rememberSaveable / derivedStateOf

remember 把值保存在 Composition 中, 重组不丢; rememberSaveable 额外通过 Bundle/Saver 支持配置变更和进程恢复; derivedStateOf 用于从一个或多个 State 派生计算结果, 只有派生结果变化时才通知.

val listState = rememberLazyListState()
val showTopButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 3 }
}

使用边界:

  • remember: 缓存计算结果, 对象实例, 滚动状态等组合内状态.
  • rememberSaveable: 输入框, tab, 筛选条件等需要旋转后恢复的轻量状态.
  • derivedStateOf: 高频变化中只关心派生布尔 / 分组结果, 如滚动位置.
  • 不要滥用 derivedStateOf, 普通字符串拼接 / 低频计算没有必要.

四, Snapshot 系统

Compose 的 mutableStateOf 背后是 Snapshot 状态系统. 它记录状态读取和写入, 在写入提交后通知受影响的组合范围.

  • Snapshot 让 Compose 能知道 “谁读了这个状态”.
  • 状态写入应发生在主线程或受 Snapshot 管理的上下文中, 避免并发写冲突.
  • snapshotFlow { } 可以把 Compose State 读取转换成 Flow, 适合观察滚动状态等.
  • 不要直接修改普通可变集合后期待 UI 更新; 要替换 State 值或使用 Snapshot-aware 集合.
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .collect { index -> /* analytics or load trigger */ }
}

提交推演: 写入先落在当前 Snapshot, 由 global snapshot 推进提交后统一使读取方失效并调度重组, 一次事务内多次写会合并, 不是每次写都立即重组. snapshotFlow 采用合并 (conflate) 语义: 两次 collect 之间状态变化多次, 只发射最新一次结果, 高频滚动位置经它收集天然去抖, 下游不会收到中间值.

五, Modifier 顺序与布局语义

Modifier 是链式包装, 顺序会改变测量, 绘制, 点击区域.

写法效果
background().padding()背景覆盖 padding 外层区域, 内容向内缩
padding().background()只有 padding 后的内部区域有背景
clickable().padding()点击区域包含 padding 前的范围
padding().clickable()点击区域通常只覆盖 padding 后内容

怎么落地: 先想 “尺寸/约束”, 再想 “点击/语义”, 最后想 “绘制”; 可访问性相关 semantics 要放在能覆盖正确节点的位置.

六, LazyColumn 性能

LazyColumn 不是解决所有问题的银弹, 关键是 key, contentType, 状态位置和 item 复杂度.

  • 给稳定业务 ID 作为 key, 避免插入 / 删除导致 item 状态错位.
  • 使用 contentType 帮助复用相同类型 item.
  • 避免在 item lambda 里做重计算, 创建大对象或直接收集 Flow.
  • 分页加载用列表滚动状态 + derivedStateOf/Paging, 避免每帧触发请求.
  • item 内状态要么跟业务 ID 绑定, 要么上提到 ViewModel.
LazyColumn(state = listState) {
    items(
        items = users,
        key = { it.id },
        contentType = { "user" },
    ) { user ->
        UserRow(user)
    }
}

movableContentOf { ... } 把一段组合与其组合状态绑定: 在组合树中移动位置 (列表重排) 时状态随之迁移而非重建, 适合需要保留 item 内部 remember 状态的场景. LazyColumn 可用 beyondViewportItemCount 扩大组合 / 预取窗口, 提前准备即将滑入的 item, 减少滚动期卡顿但增加前期组合成本.

七, View 互操作与生命周期收集

Compose 和 View 混用在迁移期很常见.

  • Compose 中嵌 View: 用 AndroidView, 在 update 中同步参数, 不要每次重组都重新创建重对象.
  • View 中嵌 Compose: 用 ComposeView.setContent, 并设置合适的 ViewCompositionStrategy.
  • 收集 Flow: 优先用 collectAsStateWithLifecycle(), 避免后台生命周期仍持续收集.
  • 副作用: 进入组合加载用 LaunchedEffect(key), 注册监听用 DisposableEffect(key) 清理.
场景推荐 API注意点
Compose 嵌地图 / 广告 ViewAndroidViewfactory 创建, update 更新
Fragment 嵌 ComposeComposeView销毁 View 时释放 Composition
Flow 渲染 UIcollectAsStateWithLifecycle需要 lifecycle-runtime-compose
注册监听器DisposableEffectonDispose 反注册

八, Compose Testing

Compose 测试关注语义树而不是具体 View ID.

  • 用 createComposeRule() 设置内容.
  • 通过 onNodeWithText, onNodeWithTag, onNodeWithContentDescription 查找节点.
  • 给关键节点设置 Modifier.testTag().
  • 对异步状态变化使用 waitUntil 或测试时注入可控 Dispatcher/Repository.
composeTestRule.setContent { LoginScreen(state, onSubmit = {}) }
composeTestRule.onNodeWithTag("login_button").assertIsDisplayed()

怎么答: Compose 测试不是截图测试优先, 而是验证语义, 状态渲染和用户交互; 业务逻辑仍应在 ViewModel/UseCase 里用普通单元测试覆盖.

九, Compiler reports 与 Macrobenchmark: 先测量再优化

上下文片段: 这是 Gradle Kotlin DSL 的诊断配置示意, 键名与产物位置随 Kotlin/Compose 插件版本变化, 必须以项目当前插件文档为准; 它不声称已经在本仓库执行. 目标是产出 report 后检查 composable 的 restartable/skippable 推断, 再结合真实交互验证.

composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

可运行示例 (独立 benchmark module): 在 AndroidX Macrobenchmark 模块配置被测 app package, 并准备可冷启动的 MainActivity. 运行后预期报告含多次 timeToInitialDisplayMs; 不能把一次数字或未运行示例写成收益结论.

@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
    @get:Rule val benchmarkRule = MacrobenchmarkRule()
    @Test fun startup() = benchmarkRule.measureRepeated(
        packageName = "com.example.app", metrics = listOf(StartupTimingMetric()),
        iterations = 10, startupMode = StartupMode.COLD,
    ) { pressHome(); startActivityAndWait() }
}

排障路径: 症状是滚动或点击掉帧; 证据先取 Macrobenchmark 分布和 Perfetto trace; 定位主线程 composition/layout, RenderThread/GPU 或 I/O 的超预算阶段; 修复方向可能是缩小 state 读取, 避免对象重建, 补 key/contentType 或降低 item 工作量; 最后在同一设备, 构建类型和场景重复测量.60Hz 的 16.7ms 只是一帧周期示例, 90/120Hz 预算更短, 不能作为固定业务阈值.


高频面试题

Q1: 什么会触发 Compose 重组? Composable 在组合阶段读取的 State 变化会触发相关范围重组. 重组是局部重新执行函数, 不是整棵 UI 全量重建. Composable 要幂等, 无副作用, 快速执行.

Q2: 稳定性和 skippable 是什么? 稳定性是编译器用于推断参数变化的属性, skippable 表示可在本次重组跳过该函数的能力. 稳定参数且值未变通常有利于跳过; 但在 Kotlin/Compose Compiler 版本和项目配置满足 strong skipping 条件时, 部分不稳定参数的 restartable Composable 也可能跳过. 不要凭口诀判断, 应查看当前 compiler report 并测量实际重组/性能.

Q3: remember, rememberSaveable, derivedStateOf 怎么区分? remember 保留组合内状态, 重组不丢但配置变更会丢; rememberSaveable 通过 Bundle/Saver 支持恢复; derivedStateOf 用于从高频状态派生低频结果, 结果不变时不触发下游重组.

Q4: Snapshot 系统解决什么问题? 它跟踪 Compose State 的读取和写入, 让框架知道哪些组合范围依赖某个状态, 写入提交后精准通知重组. 普通可变集合不受 Snapshot 自动追踪, 直接 mutate 可能不刷新 UI.

Q5: LazyColumn 怎么优化? 提供稳定 key 和 contentType, 避免 item lambda 重计算和直接收集 Flow, 把状态按业务 ID 管理, 分页触发用 derivedStateOf/snapshotFlow 控制频率, 不要每次滚动都发请求.

易错点 / 追问

  • 在 Composable 函数体里直接发请求或写外部状态, 会因重组重复执行.
  • remember 不能跨配置变更保存状态, 输入框 / 筛选条件要考虑 rememberSaveable 或 ViewModel.
  • Modifier 顺序会改变背景, padding, 点击区域和语义范围, 不是随便链式调用.
  • 普通 mutableList.add() 不一定触发 UI 更新, 应替换 State 或使用 Snapshot-aware 状态集合.
  • LazyColumn 不加稳定 key 时, 插入 / 删除可能导致 item 状态错位和不必要重组.

Android 版本适配

最后核验: 2026-08-10. 版本适配必须同时说明系统版本 / API, targetSdk 条件, 安装或升级状态, 设备条件与分发渠道; 不要把系统行为变化和 Google Play 上架要求混为一谈.

一, 适配心智模型

每条变化都按以下四元组记录:

  1. 运行系统版本 / API: 用户设备实际运行什么版本.
  2. targetSdk 条件: 变化对所有应用生效, 还是只对达到某个 target 的应用生效.
  3. 安装状态: 新安装, 覆盖升级和从旧系统升级是否不同.
  4. 设备与豁免条件: 屏幕尺寸, 硬件能力, 权限角色, 企业设备或系统应用是否例外.

Google Play 的 target API 截止日期是分发政策, 不等于某个 Android 版本已经正式发布, 也不等于系统行为对所有应用同时生效.

targetSdk 门槛速查表

集中参考表: 系统侧门槛只对该 targetSdk 的应用生效, 商店侧门槛由 Google Play 分发政策强制; 二者不应混为一谈. 各行详细论证见本篇各版本小节 (二至六节).

targetSdk强制门槛 (系统 / 商店)可选 / 新行为版本
23危险权限运行时申请 (系统)—Android 6
26后台服务执行受限; 大多数隐式广播不可静态注册 (系统)通知渠道Android 8
28FGS 须及时 startForeground 并显示通知; 后台禁止启动 FGS; 明文 HTTP 默认禁用 (系统)FOREGROUND_SERVICE 权限Android 9
29Scoped Storage 默认开启; 后台定位须独立 ACCESS_BACKGROUND_LOCATION (系统)存储可临时 opt-outAndroid 10
30Scoped Storage 强制 (legacy opt-out 失效); 包可见性受限 (系统)单次授权Android 11
31组件须显式 android:exported; 通知 trampoline 受限 (target 31+ 封禁); 精确闹钟需 SCHEDULE_EXACT_ALARM (系统)近似定位可选Android 12
33POST_NOTIFICATIONS 运行时权限; 媒体权限拆分 READ_MEDIA_IMAGES/VIDEO/AUDIO (系统)Photo Picker 优先Android 13
34FGS 须声明类型并匹配权限; 动态广播 / 隐式 Intent 须声明 exported; 后台启动 Activity 收紧 (系统)—Android 14
35edge-to-edge 强制; 16 KB page 支持 (Play 强制, 2025-11-01 起); FGS dataSync/mediaProcessing 单日 6h 上限 (系统)部分屏幕共享 opt-inAndroid 15
36edge-to-edge opt-out 失效; 部分屏幕共享可选 (用户选单窗口); 大屏 resizability/orientation 受限 (系统, 临时 opt-out 见官方文档)MediaStore 版本锁定; 健康权限拆分; 本地网络权限 rollout 中Android 16
37大屏 resizability/orientation 无 opt-out, 须全自适应 (系统); ACCESS_LOCAL_NETWORK 运行时权限; 明文默认禁用; DCL 原生库须只读 (系统)MessageQueue lock-free; static final 不可变; 证书透明度默认开启Android 17

二, Android 6–13 核心变化回顾

系统版本API代表性变化适配动作与常见坑
Android 623运行时权限, Doze请求前说明用途, 拒绝后保留可继续路径; 坑: 把首次拒绝误判为永久拒绝
Android 826后台执行与广播限制, 通知渠道建渠道, 选择 WorkManager / 前台服务; 坑: 后台直接 startService 或依赖隐式广播唤醒
Android 1029Scoped Storage 起步, 后台定位限制用 MediaStore/SAF, 前台后再请求后台位置; 坑: 把文件路径当稳定 API
Android 1130包可见性, 单次授权, 存储继续收紧为确需查询的包写 <queries>; 坑: 查询不到就假定对方未安装
Android 1231exported 强制声明, 精确闹钟与前台服务限制审计含 intent-filter 的 exported 与权限; 坑: 合并 Manifest 后暴露组件
Android 1333通知运行时权限, 媒体权限拆分按场景请求通知 / 媒体权限, 优先 Photo Picker; 坑: 仍用宽泛存储权限

Android 7/8/9 历史迁移矩阵

下表用于补齐历史版本的迁移思路. 每一行都应同时在对应系统版本与受影响的 targetSdk 条件下验证, 并覆盖新安装和从旧版覆盖升级.

系统版本API关键行为变化影响条件迁移动作验证场景
Android 7.0 文件 URI24跨应用暴露 file:// URI 会触发 FileUriExposedException运行在 API 24+ 且 targetSdk >= 24 的目标应用用 FileProvider 生成 content:// URI, 授予临时 URI 权限在 API 24+ 上分别以目标 API 23 与 24 分享相机附件, 验证 URI 授权和接收方读取
Android 7.0 网络广播24CONNECTIVITY_ACTION 不能通过 Manifest 静态接收运行在 API 24+ 且 targetSdk >= 24 的目标应用; 不将此规则推广到所有隐式广播在需要接收网络变化的期间运行时注册, 并在恢复或执行网络工作前主动校验当前网络状态覆盖冷启动, 已运行进程, 网络切换和恢复场景, 验证业务不依赖静态广播唤醒
Android 7.0 媒体新增广播24ACTION_NEW_PICTURE 与 ACTION_NEW_VIDEO 不再发送在 API 24+ 上影响所有应用, 不取决于 targetSdk不以这两个广播触发媒体扫描或业务流程, 改用明确的写入完成或查询机制在 API 24+ 新增图片和视频, 验证未收到广播时业务仍可完成
Android 8.0 后台服务26后台应用进入受限状态后, 后台服务受执行时限约束运行 OS: API 26+; 默认 target 条件: 平台的 Android 8 background execution limits 面向 targetSdk >= 26 应用. 低 target 应用不自动完全等同, 但用户可在系统设置中为其启用部分后台限制. 前台状态, 前台服务, 绑定服务和系统豁免各有独立条件, 须按具体路径及平台规则核验可延迟工作迁至 WorkManager/JobScheduler; 确需用户可见的持续工作按前台服务要求实现在 API 26+ 上分别以目标 API 25/26 及用户启用限制的低 target 配置覆盖: App 切后台, 短暂宽限期结束, 前台/绑定服务, 系统回收和重启, 并验证任务语义, 通知与恢复策略
Android 8.0 隐式广播26大多数隐式广播不能再通过 Manifest 静态接收运行在 API 26+ 且 targetSdk >= 26 的目标应用; 平台列出的豁免广播和显式广播不适用这一概括逐项核对 action 是否在豁免名单, 改为运行时注册, JobScheduler 或其他适用机制在目标 API 25 与 26 上分别验证冷启动, 已运行进程和豁免 action, 不假定全部广播规则相同
Android 8.0 通知渠道26NotificationChannel API 在 API 26+ 可用, 通知可由用户在系统设置中按渠道管理运行 API 26+ 时创建渠道并使用 channel ID; targetSdk >= 26 的具体行为和发布要求须按当前平台规则核验, 不与运行 OS 条件混同为每个用户可理解的通知类别创建稳定渠道, 读取用户在系统设置中的关闭或重要级别选择, 不以代码强行覆盖在 API 25 与 26+, 新安装与升级安装, 渠道关闭和重要级别变更后验证通知展示与降级路径
Android 928面向 API 28+ 的应用默认禁止明文 HTTP, 同时限制后台访问硬件标识符目标 API 28+; 明文策略还受应用与网络安全配置影响服务端优先仅提供 HTTPS, 排查重定向, 图片和 SDK 域名; 确有受控遗留端点时以最小域名范围配置 networkSecurityConfig, 制定迁移下线计划HTTPS 主链路, 明文端点失败提示, WebView/图片/SDK 子请求, 代理和企业网络

Android P 的明文限制不是要求以全局 cleartextTrafficPermitted="true" 放开流量的理由. 这种做法会扩大中间人攻击和误配置暴露面, 不能规避安全或合规风险. 新服务与迁移服务应使用 HTTPS; 遗留兼容必须经安全评审, 仅针对必要域名设置最小例外, 并在发布前复核当前平台和地区要求.

多机型适配矩阵

版本回归不等于只换一个系统镜像. 以业务关键路径建立最小设备矩阵, 记录机型, 系统版本, targetSdk, 安装状态, 应用版本和结果证据.

维度至少覆盖的条件重点验证
系统版本最低支持版本, 关键行为变更版本, 当前目标版本权限, 后台任务, 存储, 通知和网络安全配置
密度与屏幕mdpi / 常用高密度, 小屏, 平板或大屏窗口资源选择, 图片清晰度, 触控区域与布局裁切
字体默认字体与放大字体文本换行, 截断, 可点击区域和无障碍朗读
厂商行为至少一个目标市场主流厂商设备后台限制, 通知, 权限设置页和省电策略差异
方向与窗口横竖屏, 分屏, 自由窗口或折叠展开状态状态恢复, Insets, 列表位置和布局重算
安装路径新安装, 覆盖升级, 从旧系统升级数据迁移, 权限状态, 通知渠道和旧缓存兼容

三, Android 14 / API 34

  • 前台服务类型, 声明与启动条件收紧; 必须结合具体 service type 和豁免条件核对.
  • 精确闹钟不是 “声明权限即可无条件使用”, 需要判断应用类别, 用户授权和系统策略.
  • 后台启动 Activity, 隐式 Intent 和动态注册广播接收器的安全边界继续收紧.
  • 升级 targetSdk 前应做真实设备与升级安装回归, 不能只做新安装冒烟测试.

四, Android 15 / API 35

  • 对达到相应 targetSdk 的应用, edge-to-edge 成为默认行为; 需要系统处理状态栏, 导航栏 Insets, 而不是依赖旧式固定高度.
  • 前台服务, 后台启动, 通知, 媒体投屏等变化应逐项标注 targetSdk 条件.
  • 16 KB page size 是 native 依赖兼容问题之一, 需要检查 APK/AAB 中所有 .so, 包括第三方 SDK; 不能只验证自研 C++.
  • 大屏和可调整窗口行为应按设备尺寸, 窗口模式和 targetSdk 条件测试.

五, Android 16 / API 36

Android 16 正式版 2025-06 发布, API 36. 以下为对 targetSdk 36 应用生效的关键变化 (按 Android Developers 官方行为变更页核验):

  • 继续关注 edge-to-edge, 大屏适配, 后台任务与权限行为的 targetSdk 条件.
  • 对含 native 库的应用, 将 16 KB page size 纳入 CI 制品检查和真机 / 模拟器测试.
  • 部分屏幕共享: 用户可通过系统选择器只共享单个窗口, 需处理 “单窗口 vs 整屏” 的差异; 锁屏或系统停止共享时须处理 onStop 并释放资源 (targetSdk 36).
  • edge-to-edge 的临时 opt-out (windowOptOutEdgeToEdgeEnforcement) 在 API 36 失效, 必须正式消费 Insets.
  • 大屏 resizability / orientation 限制自 16 起对 targetSdk 36 生效 (游戏, sw600dp 以下及用户显式选择除外; 仍保留临时 opt-out), 17 起移除 opt-out.
  • MediaStore getVersion() 改为每应用唯一标识, 防止指纹滥用; 设备指纹 / 风控 SDK 不应依赖其格式或内容.
  • 健康类权限拆分: BODY_SENSORS 拆为 READ_HEART_RATE 等粒度权限; 本地网络权限 ACCESS_LOCAL_NETWORK 开始 rollout (阶段 opt-in), 涉及局域网通信的 SDK 需提前适配.
  • 不把预览期或厂商特性写成所有设备统一行为; 以正式 API 文档和兼容性框架结果为准.

六, Android 17 / API 37

截至 2026-08-10, Android 17/API 37 正式版已于 2026-06-16 发布. 当前 Beta 指 Android 17 后续 QPR (季度平台发布) 预览, 不应倒推为 Android 17 正式版仍处于 Beta. 正式 API 37 的适配以 Android 17 overview/release notes 为准; QPR Beta 的新增或变更行为须按对应 QPR release notes 单独核验.

targetSdk 37 关键变化 (按 Android 17 行为变更页核验):

  • 大屏 resizability / orientation 无开发者 opt-out: 目标 API 37 应用在 sw ≥ 600dp 大屏上须全自适应, 系统忽略 screenOrientation, resizeableActivity 与 aspectRatio 设置 (游戏与用户显式选择除外).
  • 本地网络访问须运行时权限 ACCESS_LOCAL_NETWORK (隶属 NEARBY_DEVICES 组); 已授权 NEARBY_DEVICES 的用户不重复弹窗.
  • Safer DCL 扩展到 native: System.load() 加载的 .so 必须只读, 否则抛 UnsatisfiedLinkError; 设备指纹 / 风控 SDK 需复核 so 加载路径与写入.
  • static final 字段真正不可变 (反射写入抛异常, JNI SetStaticLongField 等致崩溃); MessageQueue 改 lock-free 实现, 反射其私有字段或方法的代码可能失效.
  • 明文 HTTP 默认禁用 (usesCleartextTraffic 废弃); 证书透明度默认开启, 影响自定义 CA 与抓包场景 (targetSdk 37).
  • SMS OTP 消息对非默认短信应用默认拦截 3 小时; 背景音频播放 / 音频焦点请求收紧.
维度当前结论
SDK/APIAndroid 17 正式 API 37; QPR Beta 使用其对应预览 SDK/API
发布状态Android 17 正式发布; 后续 QPR 仍可能处于 Beta
开发行动以 API 37 正式 SDK 做兼容与 target 升级验证; 需要采用 QPR 行为时另建预览验证矩阵
文档策略正式版结论引用 Android 17 一手资料; 每次 QPR Beta/RC 更新后重新核验预览结论

七, Google Play target API 要求

截至 2026-08-10, Google Play 的 API 36 target 要求从 2026-08-31 起生效; API 37 不是当前强制线. 具体截止日期会变化, 发布前必须重新核验 Play Console 和官方 target API requirements 页面.

八, TargetSDK 升级 Playbook

  1. 建立变化清单: 按系统版本, targetSdk, 安装状态和设备条件分类.
  2. 扫描 manifest, 权限, 前台服务, 通知, 存储, 闹钟, 后台启动和 native 库.
  3. 分阶段提高 compileSdk 与 targetSdk, 不要把工具链升级和业务行为修改混成一次不可回退的大提交.
  4. 覆盖新安装, 覆盖升级, 权限已拒绝, 旧数据迁移, 后台恢复, 大屏和低内存设备.
  5. 通过灰度, Android vitals, 崩溃/ANR/权限漏斗观察影响, 并保留回滚路径.

九, 权限矩阵与 edge-to-edge 迁移清单

业务目标推荐入口用户拒绝后的行为版本条件
发送通知POST_NOTIFICATIONS不发通知, 页面仍可使用Android 13+ 运行时权限
选一张照片系统 Photo Picker允许取消, 不要求存储权限可用时优先; 否则按兼容路径
读取用户选择的媒体picker 返回的 URI + 持久授权 (若适用)显示重新选择入口不将 URI 权限等同文件路径权限
持续定位先前台定位, 再按业务解释后台需求降级为前台功能需结合系统, targetSdk 和政策核验

迁移 edge-to-edge 时逐项完成: 确认目标系统 /targetSdk 条件; 根布局启用 edge-to-edge; 将 systemBars, cutout, IME Insets 作为布局输入; 验证列表末项, 底部按钮和输入框的导航栏/键盘遮挡; 测试手势导航, 三键导航, 深色模式, RTL, 大屏和旋转; 移除固定状态栏高度. 症状 “底部 CTA 被遮挡” 的证据是对应窗口模式下 Insets 日志/截图, 修复是消费正确 Insets, 验证是设备矩阵而非单一模拟器.

高频面试题

Q1: compileSdk, targetSdk, minSdk 有什么区别?
compileSdk 决定编译时可用 API; targetSdk 表示应用已针对相应行为变化完成适配, 并会触发对应兼容规则; minSdk 决定最低安装版本. 三者不是同一个 “支持版本”.

Q2: 如何回答一次 targetSdk 升级?
先给版本与截止日期, 再按行为变化清单, 代码改造, 安装 / 升级测试, 灰度指标和回滚方案展开, 并说明哪些变化对所有应用生效, 哪些由 targetSdk 触发.

Q3: Android 17 是否已经正式发布?
截至 2026-08-10 已于 2026-06-16 正式发布; 后续 QPR Beta 与 Android 17 正式版是不同发布轨道.

易错点 / 追问

  • 不要把 Play 上架政策当成 Android 系统发布时间线.
  • 不要只说 “适配 Android 15/16”, 必须补 targetSdk 与设备条件.
  • 不要把预览版 release notes 当成不会变化的最终契约.
  • 权限, 后台任务, 前台服务和商店政策属于易变内容, 发布前应由工程与合规共同复核.

版本与参考资料

图片加载与缓存

图片问题要区分泄漏, 瞬时解码峰值, Java heap, native/graphics 内存, 磁盘缓存和网络获取, 不能只用 “压缩图片” 概括.

一, Bitmap 内存模型与大小计算

Bitmap 像素内存可近似按 宽 × 高 × 每像素字节数 估算, 还要考虑 density 缩放, row bytes, 颜色空间, 硬件 Bitmap 和解码中间缓冲.

  • ARGB_8888 通常每像素 4 字节.
  • RGB_565 通常每像素 2 字节, 但色彩精度和透明度能力受限, 不能为了省内存无条件使用.
  • Android 8.0 以后像素数据主要位于 native heap, 但仍计入进程内存压力; 这不等于 “移出 Java heap 后就不会 OOM”.
  • density 缩放推演: 解码尺寸会被 scale = inTargetDensity / inDensity 放大, inDensity 是资源所在 density bucket (如 drawable-mdpi = 160), inTargetDensity 是设备 density (如 xxhdpi = 480). 一张 100x100 的 mdpi 资源在 xxhdpi 设备上放大 3 倍 (480/160) 解码为 300x300, 内存按放大后尺寸计算: 300 x 300 x 4 = 360KB, 而非原资源的 40KB: 面积放大 scale² 倍 (此例 9 倍), 这正是误放 mdpi 图到高密度设备导致内存暴涨的原因.

二, 按目标尺寸解码

可运行示例 (工程内): 下列函数与调用放在 Activity, Fragment 或 Repository 的资源解码路径中; 输入是存在的 R.drawable.my_image 和目标像素尺寸, 输出是采样后的 Bitmap?. 原图 4000x3000, 目标 1000x750 时选取 sample 4, 实际解码尺寸约为目标尺寸, 最终仍以解码结果为准.

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    var sample = 1
    while (options.outHeight / (sample * 2) >= reqHeight && options.outWidth / (sample * 2) >= reqWidth) sample *= 2
    return sample
}
fun decodeSampledResource(resources: Resources, @DrawableRes id: Int, reqWidth: Int, reqHeight: Int): Bitmap? {
    require(reqWidth > 0 && reqHeight > 0)
    val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
    BitmapFactory.decodeResource(resources, id, options)
    if (options.outWidth <= 0 || options.outHeight <= 0) return null
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
    options.inJustDecodeBounds = false
    return BitmapFactory.decodeResource(resources, id, options)
}

inSampleSize 表示解码采样比例. 不同解码器和历史 Android 版本对非 2 次幂取值的处理存在差异, 因此实现应以目标尺寸, 实际解码结果和当前平台文档为准, 而不是写成 “永远必须是 2 的幂”.生产代码通常优先使用成熟图片库完成尺寸协商, 解码复用和线程调度.

  • inBitmap 复用: 把 BitmapFactory.Options.inBitmap 设为一块可复用的旧 Bitmap, 解码直接复用其像素内存, 减少分配与 GC; 约束是目标尺寸与配置需能被旧对象容纳 (Android 4.4+ 允许尺寸不更大即可) 且解码器兼容, 不满足时需回退普通分配并严格校验.
  • ImageDecoder (API 28+): 替代 BitmapFactory 的现代解码入口, 支持 setTargetSize() 目标尺寸, 缩放, 裁剪, 并自动按 EXIF 方向校正; 新代码优先考虑, 解码复用与后处理语义也更明确.
  • Hardware Bitmap (Bitmap.Config.HARDWARE, API 26+): 像素存于 graphics 内存, 只能用于渲染不能读像素, 适合列表与 GPU 合成, 但取样, 序列化等需要读像素的 API 不可用.

三, 缓存与数据获取层次

通用图片管线可以分为:

  1. 活跃资源 / 内存缓存: 最快, 但受进程生命周期和内存压力影响.
  2. 转换后资源磁盘缓存: 缓存裁剪, 缩放或变换结果.
  3. 原始数据磁盘缓存: 缓存下载或读取到的原始数据.
  4. 数据源: 网络, 文件, ContentProvider 或应用资源, 不属于缓存层.

缓存 key 必须包含尺寸, 变换, 签名, 数据版本和必要的请求参数, 否则会出现错图或旧图.

四, Glide 缓存与生命周期边界

以 Glide 4 的公开行为模型说明, 请将内部类名视为版本相关实现细节. 典型查找顺序是:

  1. Active resources;
  2. Memory cache;
  3. Resource disk cache;
  4. Data disk cache;
  5. Source.

网络只是可能的数据源之一, 不是 “第三级缓存”.Glide 会根据传入的 Activity, Fragment, View 或 Application Context 选择请求管理和生命周期作用域;“偷偷注入隐藏 Fragment” 只适合解释部分历史实现, 不能作为所有版本和所有 Context 的稳定 API 契约.

上下文片段 (Glide 4): 在 Fragment 的 onViewCreated 中, imageView 是该 Fragment 的 ViewBinding 成员, URL 是可信业务数据. 普通 Glide.with().load() 只需 Glide 依赖; 只有使用生成 API 或 AppGlideModule 时才需要相应注解处理器. 预期: 按 320x180 请求, 加载期间显示占位, 失败显示错误图; 离开 Fragment View 生命周期后请求会按宿主生命周期管理.

Glide.with(this)
    .load(imageUrl)
    .override(320, 180)
    .placeholder(R.drawable.placeholder)
    .error(R.drawable.image_error)
    .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
    .into(binding.imageView)

缓存层级图:

请求(key = model + size + transform + signature)
  -> Active resource -> Memory LRU -> Resource disk -> Data disk -> source(network/file/provider)

数字示例: 可给自建 LruCache 的预算为运行时 maxMemory / 8, 若 maxMemory=256MiB, 容量是 32MiB; 一张 1080x720 ARGB_8888 约为 1080 x 720 x 4 = 2.97MiB, 理论仅约容纳 10 张, 未计对象与其他内存. 应通过低内存设备, 命中率和 OOM/GC 证据调整比例, 不把 1/8 当固定标准.

五, 列表, 长图与预取

  • 不在主线程做大图 decode, 磁盘 I/O 或同步网络请求.
  • 给列表项提供稳定尺寸, 避免用原始大图填充很小的 ImageView.
  • 是否在滚动时暂停请求, 应通过 trace, 首屏命中率, 滑动帧时间和网络浪费测量; 现代图片库通常可以调度, 取消和复用请求, 不能把 “滑动必暂停” 写成通用最佳实践.
  • 对可预测列表使用适量预取, 并同时约束并发数, 解码尺寸和缓存预算.
  • 超长图可使用区域解码, 分块渲染或专用大图组件, 避免一次创建完整像素缓冲.

六, OOM 排查流程

  1. 先判断是 Java heap, native heap, graphics, mmap 还是总体进程限制.
  2. 区分持续增长的泄漏和瞬时解码 / 并发峰值.
  3. 记录图片来源, 原始尺寸, 目标尺寸, 配置, 并发请求数和页面生命周期.
  4. 线下结合 Memory Profiler, heap dump, LeakCanary, Perfetto/heapprofd; 线上按版本, 机型, 页面和内存档位聚合.
  5. 检查缓存预算, 动画帧, 硬件 Bitmap, 列表预取和第三方库版本, 而不是看到 Bitmap 就直接调用 recycle().

七, WebP, SVG 与 GIF 的格式边界

格式选型不能只比较文件体积, 还要看透明度, 动画, 解码峰值, 目标 Android 版本和图片库的实际解码器.

格式能力与原理适用场景不适合的情况
WebP支持有损和无损编码, 可携带透明通道; 动画 WebP 由多帧组成可控客户端范围内的照片, 图标或短动画资源未验证目标版本和解码器的动画资源, 或解码 CPU 峰值已是瓶颈的列表
SVG用路径, 形状和文本描述矢量图, 缩放时不直接按位图像素放大简单图标, 单色或少量路径的插画照片, 复杂滤镜, 大量路径或频繁动画的图形
GIF由多帧索引色图像和帧时间组成, 解码器需持续读取和合成帧兼容性要求高且动画较短的内容长时间自动播放, 大尺寸或高帧率动画

WebP: 能力不等于无成本

WebP 的有损模式通常适合照片类资源, 无损模式适合需要精确边缘或透明度的图形. Android 平台从 Android 4.0 (API 14) 支持有损 WebP, 从 Android 4.2.1 (API 17) 支持无损和透明 WebP; 基于 AnimatedImageDrawable/ImageDecoder 的动画 WebP 原生支持从 Android 9 (API 28) 开始. 第三方图片库可能自带解码器, 其能力不能等同于平台能力, 必须按库版本和最低支持设备验证. 发布前应在目标设备验证静态, 透明和动画资源的显示与降级路径.

文件更小不必然加载更快. 编码格式会改变网络传输量, 也会改变解码 CPU 时间和瞬时内存. 仍应按目标视图尺寸请求, 控制并发解码, 并用 trace 或 profiler 观察滚动期间的解码与帧时间.

SVG 与 VectorDrawable: 转换有子集边界

SVG 在 Web 或专用解析器中可按视口缩放, 但 Android 的 VectorDrawable 不是任意 SVG 的完整运行时实现. 将 SVG 转为 VectorDrawable 前要检查路径, 渐变, mask, filter, text 和动画等特性是否被目标工具链支持. 对复杂 SVG, 可在构建阶段栅格化为多个目标密度资源, 或简化路径后再转矢量; 不应在列表滚动时反复解析复杂 XML / 路径.

VectorDrawable 适合尺寸变化明显的简单图标, 但路径数量过多仍会增加测量, 绘制和动画成本. 需要频繁变换或复杂动画时, 应依据目标设备上的渲染 trace 选择位图, 简化的矢量资源或其他动画方案.

GIF: 帧解码与生命周期管理

GIF 播放的成本不仅在于压缩文件体积, 还会产生当前帧, 合成缓冲和解码工作. 大尺寸, 多帧或同时播放的 GIF 容易造成内存峰值, CPU 竞争和掉帧. 将请求绑定到 Activity/Fragment/View 生命周期, 在不可见, 滚动离屏或页面停止时暂停或清理; 是否释放由所用图片库的公开 API 和版本决定, 不直接操作内部帧缓冲.

若内容需要透明, 更平滑的颜色或更高效的现代动画编码, 可评估动画 WebP 或视频; 若只是状态反馈, 优先使用短小的属性动画或 Lottie 等矢量动画方案. 替代方案都要结合最低版本, 包体, 解码成本和无障碍降动效设置验证.

高频面试题

Q1: 如何设计图片加载框架?
拆分请求建模, 生命周期, 调度与取消, 缓存 key, 多级缓存, 数据源, 解码 / 变换和观测模块. 说明去重, 优先级, 并发限制, 失败重试和内存压力下的降级策略.

Q2: Bitmap 在 native heap 后为什么仍可能 OOM?
像素内存仍属于进程内存预算, 且可能与 Java 对象, GPU 资源, 解码临时缓冲和并发请求叠加. 只观察 Java heap 会漏掉 native/graphics 峰值.

Q3: Glide 为什么能跟随页面生命周期?
RequestManager 会绑定合适的 Lifecycle / 宿主作用域, 在宿主停止或销毁时暂停或清理请求. 具体内部实现随版本和传入 Context 类型变化, 不应把某个隐藏 Fragment 实现当成永久契约.

易错点 / 追问

  • 资源 ID 应使用 R.drawable.* 等可解码资源, 而不是 R.id.*.
  • drawable-mdpi 等目录会参与 density 缩放; 不希望缩放的资源应评估 drawable-nodpi.
  • LruCache 的容量应按字节成本计算, 而不是只按图片张数.
  • inBitmap 复用受尺寸, 配置和解码器条件约束, 应交给成熟图片库或严格校验.
  • 不把 Bitmap.recycle() 当现代 Android 的常规泄漏治理方法.
  • EXIF 方向与采样顺序: 应先按方向校正再计算采样尺寸, 否则旋转后宽高互换会让目标尺寸算错, 解码出与预期相反的图; ImageDecoder 默认自动应用 EXIF, 手写 BitmapFactory 采样要自行处理方向.
  • 异步加载竞态: 复用 ImageView 时若未在请求开始前 setImageDrawable(null) 或按请求校验, 旧请求晚返回会覆盖新位置的内容, 造成错图; 应在复用前取消旧请求或用请求 id / target 校验回写.
  • Coil / AsyncImage: 基于 Kotlin 协程的图片库, AsyncImage(model = ...) 是 Compose 入口, 2024-2025 年在 Kotlin / Compose 项目中高频使用; ImageLoader 负责缓存与解码, 请求随组合作用域生命周期管理.

版本与参考资料

Jetpack 架构组件 ★

你的重点短板. Jetpack 是应用开发日常, 中级面试必问 ViewModel/LiveData/Room/Hilt 原理. 架构组织与选型见 MVVM 与 MVI, 完整落地见 App 架构落地案例.

一, ViewModel

  • 作用: 持有 UI 相关数据, 在配置变更 (旋转) 时存活, 与 UI 解耦.
  • 存活原理: 对外契约是同一 ViewModelStoreOwner 在配置变更后可取回同一个 ViewModel, 直到该 owner 真正结束才调用 onCleared(). 实现层面可理解为 ViewModelStore 被保留并在重建后重新关联, 历史实现中会经过 Activity 的 NonConfigurationInstance 保留链路; 面试中不要把某个内部方法名当成稳定 API 保证.
  • 不要持有 View/Context 引用 (会泄漏); 需要 Context 用 AndroidViewModel (持 Application).
  • SavedStateHandle: 在进程被杀重建后恢复关键数据 (配合 savedState).
  • viewModelScope: 绑定 ViewModel 的协程作用域, onCleared 时取消.

二, Lifecycle

  • LifecycleOwner: Activity/Fragment 实现它, 暴露 Lifecycle.
  • LifecycleObserver: 观察生命周期事件, 把生命周期相关逻辑 (如开始 / 停止定位) 从组件中解耦出去.
  • 现代用 DefaultLifecycleObserver 或 lifecycleScope.launch { repeatOnLifecycle(STATE) { } }.

LifecycleRegistry 状态机与派发

LifecycleRegistry 将 Owner 的生命周期表示为状态机, 对外重点是状态和事件的对应语义, 不应依赖其某个内部观察者集合或遍历实现. 常见状态为 INITIALIZED, CREATED, STARTED, RESUMED 和终态 DESTROYED.

状态方向事件Owner 状态变化
上行ON_CREATEINITIALIZED -> CREATED
上行ON_STARTCREATED -> STARTED
上行ON_RESUMESTARTED -> RESUMED
下行ON_PAUSERESUMED -> STARTED
下行ON_STOPSTARTED -> CREATED
下行ON_DESTROYCREATED -> DESTROYED

新观察者加入后会被同步到 Owner 当前状态: 例如 Owner 已处于 STARTED, 新观察者会依序补收上行事件直到 STARTED, 而不是只等待下一次事件. Owner 上行时, 已注册观察者按前向顺序接收事件; Owner 回退时, 以相反顺序派发下行事件, 使后建立的依赖先释放. 回调中新增或移除观察者, 或再次触发状态变化属于重入情形; Registry 会以重新同步为目标处理这类变化, 因此观察者回调应保持短小, 幂等, 不应假设某次遍历期间的观察者集合或回调次数是跨版本固定的实现细节.

DESTROYED 是终态. Owner 销毁后, 与其绑定的观察关系和资源应清理, 不应期待该 Owner 或其观察者再回到 CREATED/STARTED 并恢复使用. 需要重新展示页面或重新订阅时, 应使用新建的 Owner 和新的观察关系.

使用边界:

  • Activity 自身的长期 UI 资源可绑定 this 的 Lifecycle, 但仍应在不可见或停止时按业务停止昂贵工作.
  • Fragment 的 View, View Binding, RecyclerView adapter 和收集 UI 状态应绑定 viewLifecycleOwner, 而不是 Fragment 生命周期, 避免 View 销毁后继续访问旧 View.
  • 定位, 相机预览, 传感器和网络订阅等资源按可见性或交互状态启停; 观察者只协调开始 / 停止, 资源本身仍需处理权限, 失败, 取消和释放.
  • Flow 收集使用 viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { ... } }; 进入目标状态时启动子协程, 低于该状态时取消, View 销毁时随 owner 清理. 它适合可重新收集的 UI 流, 不替代需要持久化的业务状态或跨进程任务机制.

三, LiveData

  • 生命周期感知的可观察数据持有者: 只在活跃状态 (STARTED/RESUMED) 通知观察者, 组件销毁自动移除观察, 避免泄漏和崩溃.
  • setValue(主线程)/ postValue(任意线程).
  • 粘性语义: 新观察者会立即收到最新状态. 不要用 SingleLiveEvent 或 SharedFlow (replay=0) 作为统一 “事件修复”; 必须处理的业务结果应归约为可恢复 UI state, 纯 UI 局部动作由 UI 直接处理.
  • MediatorLiveData: 合并多个源.
  • 现代项目通常用 StateFlow 表达持久状态; SharedFlow 是否适用取决于允许丢失, 重放和多订阅语义, 不能与 LiveData 做机械的一对一替换.

四, Room

  • SQLite 之上的 ORM, 编译期校验 SQL.
  • 三要素: @Entity(表), @Dao(数据访问接口), @Database(数据库持有者).
  • 支持 协程 suspend 和 Flow 返回 (数据变化自动推送).
  • 数据库迁移 Migration: 版本升级写 Migration, 否则崩溃; 开发期可 fallbackToDestructiveMigration(清库).
  • 查询返回 Flow 时, 表数据变化会自动重新发射, 天然支持响应式 UI.

五, Navigation

  • 定位: 单 Activity 多 Fragment/Composable 架构的导航框架, 把 “目的地, 参数, Action, Deep Link, 回退栈” 集中声明, 减少手写 FragmentTransaction/Intent flag 的分散逻辑.
  • 对象关系:
    • NavHost: 承载导航内容的容器, Fragment 场景常见 NavHostFragment, Compose 场景是 NavHost Composable.
    • NavController: 执行 navigate/popBackStack 的控制器, 维护当前目的地和 back stack.
    • NavGraph: 目的地和跳转关系的图, 可来自 XML, 也可在 Compose 中用 Kotlin DSL 声明.
    • NavBackStackEntry: 回退栈条目, 携带 arguments, Lifecycle, SavedStateHandle, 也可作为 ViewModel 作用域边界.
  • 导航调用流程: View 点击/Intent → 调用 NavController.navigate(route/action) → 按 NavGraph 匹配目的地与参数 → 创建或恢复目的地 → 新 NavBackStackEntry 入栈 → 目标 Fragment/Composable 渲染 → 返回时 popBackStack 出栈并恢复上一 entry.
  • 回退栈 API:
    • navigate() 入栈新目的地; 可用 popUpTo 清理中间页面, 登录后清空登录流常用.
    • popBackStack() 返回上一目的地; 指定 destination/route 时可弹到某个节点.
    • launchSingleTop 避免重复点击把同一目的地压多份.
  • Deep Link: 可以在图中声明 URI/Action/MIME 匹配. 外部 Intent 进入后由 Navigation 匹配目标目的地并补齐必要的 back stack; 面试要强调参数校验和未登录拦截, 不要只说 “配置一个 deepLink”.
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController = navController, startDestination = "home") {
        composable("home") {
            HomeScreen(onOpenDetail = { id -> navController.navigate("detail/$id") })
        }
        composable(
            route = "detail/{id}",
            arguments = listOf(navArgument("id") { type = NavType.StringType }),
        ) { entry ->
            DetailScreen(id = entry.arguments?.getString("id") ?: return@composable)
        }
    }
}

类型安全路由 (Navigation 2.8+): Navigation Compose / Kotlin DSL 内置, 官方视作 XML 图时代 Safe Args 的等价物. 用 @Serializable 标注路由类型 (object Home / data class Profile(val id: String)), composable<T>() 声明目的地, navigate(Profile(id)) 跳转, toRoute<T>() 取参, 免手写字符串 route 与 navArgument.

  • 弃字符串 route 的原因: 拼写错误无编译期校验, 参数名 / 类型易错且运行期才暴露; 新代码优先类型安全, 字符串 route 退居旧项目 / 简单跳转.

常见错误:

  • 在多个页面散落手写跳转字符串, 导致参数名 / route 不一致; 可用常量, 封装 route builder 或 Safe Args 降低风险.
  • 登录, 首页, 详情都压入同一栈后不清理, 返回路径混乱; 需要明确 popUpTo 和 inclusive.
  • 把未建模的 navigate=true 永久留在 State 会造成重复导航. 用户点击产生的导航由 UI 直接执行; 若导航依赖业务完成结果, ViewModel 先写入可消费 / 可清除的状态, UI 观察后导航并回调确认. 不使用 SharedFlow (replay=0) 假装获得可靠投递.
  • Deep Link 只做跳转不做鉴权和参数兜底, 容易打开非法状态页面.

面试答题结构: 先说 “NavGraph + NavHost + NavController” 三件套 → 再讲 back stack entry 的入栈/出栈流程 → 补 Compose/XML 两种写法 → 最后说 deep link, 参数安全, 重复导航与登录栈清理.

六, Hilt (依赖注入)

  • 基于 Dagger 的 Android 专用 DI 封装, 大幅减少模板代码.
  • 核心注解: @HiltAndroidApp(Application), @AndroidEntryPoint(注入到组件), @Inject(构造注入), @Module + @Provides/@Binds(提供无法构造注入的依赖), @HiltViewModel(注入 ViewModel).
  • 作用域: @Singleton, @ActivityScoped, @ViewModelScoped 等, 控制实例生命周期.
  • Hilt vs Dagger: Hilt 预定义了 Android 组件的标准注入点和组件层级, 开箱即用; Dagger 更灵活但配置繁琐.
  • DI 的意义: 解耦, 可测试 (可替换 mock 实现), 统一管理对象创建.

Room + Hilt + ViewModel + DataStore 最小链路

上下文片段: 此最小工程片段需 Room, Hilt, DataStore Preferences, Lifecycle ViewModel 和 KSP/KAPT 依赖; Application 标注 @HiltAndroidApp, Activity/Fragment 标注 @AndroidEntryPoint. 省略 imports, Gradle 配置与 UI 收集. 插入用户后 observeUsers() 预期发射包含该用户的列表; 从数据库版本 1 升至 2 时执行列新增 migration, 而非清库.

@Entity(tableName = "users") data class UserEntity(@PrimaryKey val id: String, val name: String)
@Dao interface UserDao { @Query("SELECT * FROM users") fun observeUsers(): Flow<List<UserEntity>>; @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(user: UserEntity) }
@Database(entities = [UserEntity::class], version = 2) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao }
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''") } }

@Module @InstallIn(SingletonComponent::class) object AppModule {
    @Provides @Singleton fun db(@ApplicationContext context: Context) = Room.databaseBuilder(context, AppDatabase::class.java, "app.db").addMigrations(MIGRATION_1_2).build()
    @Provides fun dao(db: AppDatabase): UserDao = db.userDao()
}
val Context.dataStore by preferencesDataStore("settings")
@HiltViewModel class UsersViewModel @Inject constructor(dao: UserDao, @ApplicationContext context: Context, private val savedState: SavedStateHandle) : ViewModel() {
    val users = dao.observeUsers().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
    val darkMode = context.dataStore.data.map { it[booleanPreferencesKey("dark_mode")] ?: false }
}

DataStore 适合保存小型偏好而不是用户表, 上例已将其读取实际接入 ViewModel. Hilt 层级是 SingletonComponent -> ActivityRetainedComponent -> (ViewModelComponent, ActivityComponent -> FragmentComponent): ViewModelComponent 与 ActivityComponent 是并列分支, 不能把前者说成后者的父/子组件. 对象 scope 必须与其持有资源的生命周期匹配. 迁移失败的症状是升级崩溃或 schema 校验失败, 证据是 migration test/升级安装日志, 修复是补齐从旧版本到新版本的迁移并验证保留旧数据.

上下文片段 (RemoteMediator 写库): db 是同一 Room 数据库, productDao/remoteKeyDao 分别操作列表和分页 key; 网络页已成功映射为 entities. 刷新时必须在一个 withTransaction 中同时清旧数据, 写新数据和更新 remote keys, 预期任一步抛异常时整笔事务回滚, UI 继续观察旧的一致快照而不会看到 “新列表配旧 key”.

db.withTransaction {
    if (loadType == LoadType.REFRESH) {
        remoteKeyDao.clear()
        productDao.clear()
    }
    productDao.insertAll(page.products.map(::ProductEntity))
    remoteKeyDao.insertAll(page.products.map { RemoteKey(it.id, page.nextKey) })
}

七, DataStore / WorkManager / Paging

  • SharedPreferences 一致性边界: SP 会维护进程内缓存, 同一进程内的常规读写由实现同步协调, 但这不等于它提供跨进程事务或多进程一致性保证. commit() 会在调用线程同步写磁盘并返回成功与否, 因此不应放在主线程热路径; apply() 会先更新内存并异步调度落盘, 不返回写入结果, 进程异常终止或写入失败时不能把它当成已持久化确认. 多个进程同时访问, 旧的多进程模式或非原子修改会带来陈旧读, 覆盖和文件损坏风险, 不应将 SP 作为跨进程共享状态通道.
  • 迁移方向与版本边界: 新代码优先使用 DataStore 保存小型偏好, 其中 Preferences DataStore 保留键值模型, Proto DataStore 提供 schema 与类型约束; 通过迁移在首次访问时将已知 SP 键复制到目标存储并验证迁移后行为. 默认的单进程 DataStoreFactory/preferencesDataStore 不是自动多进程方案. DataStore 1.1.0+ 的 MultiProcessDataStoreFactory 才用于多个进程访问同一文件; 每个进程对该文件只能创建一个实例, 且所有进程必须使用多进程工厂, 不可与普通实现混用同一路径. 不需要多进程存储时, 跨进程数据仍应由带明确并发与访问控制契约的 Provider, 数据库或服务接口承担. 保留 SP 的旧版本应先定义数据归属, 迁移窗口和回退读取策略, 不让两端长期双写.
  • DataStore: 替代 SharedPreferences. 基于协程 + Flow, 异步, 事务安全; 正确使用异步 API 可降低常见主线程磁盘 IO 风险, 但阻塞式误用, 迁移或上游工作仍可能造成卡顿. 两种: Preferences DataStore (键值), Proto DataStore (类型安全). SP 的 apply() 会先更新内存再异步持久化, 但待落盘写入及其与生命周期的交互仍可能导致卡顿; getXXX 也可能带来主线程 IO 风险, 且无类型安全.
  • WorkManager: 用于可延迟, 可持久化且期望最终执行的后台任务, 即使 App 退出 / 重启后也可由系统重新调度. 执行时间不精确, 强行停止应用和系统约束等是边界. 支持约束 (网络, 充电), 链式, 周期任务. 底层 API 23+ 用 JobScheduler, 低版本历史上用 GcmNetworkManager (已弃用), 现退化为 AlarmManager 兜底与重启重调度. 适合上传日志, 同步数据.
  • Paging3: 分页加载大列表, 核心是 PagingSource → Pager → Flow<PagingData<T>> → UI Adapter/Compose.
    • PagingSource<Key, Value>: 定义 load(params) 如何按页加载数据, 返回 LoadResult.Page/Error; getRefreshKey() 决定刷新时从哪里继续.
    • Pager: 接收 PagingConfig 和 pagingSourceFactory, 把分页请求包装成 Flow<PagingData<T>>.
    • PagingData: 一次分页会话的数据流, UI 侧通过 collectAsLazyPagingItems() 或 PagingDataAdapter.submitData() 消费.
    • RemoteMediator: 网络 + 本地数据库场景的协调器, 负责判断何时从网络拉新页, 写入 Room, UI 实际从 Room 的 PagingSource 读取, 形成单一数据源.
class UserPagingSource(private val api: UserApi) : PagingSource<Int, User>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> = try {
        val page = params.key ?: 1
        val users = api.users(page = page, size = params.loadSize)
        LoadResult.Page(
            data = users,
            prevKey = if (page == 1) null else page - 1,
            nextKey = if (users.isEmpty()) null else page + 1,
        )
    } catch (t: Throwable) {
        LoadResult.Error(t)
    }

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

val users: Flow<PagingData<User>> = Pager(
    config = PagingConfig(pageSize = 20, prefetchDistance = 5),
    pagingSourceFactory = { UserPagingSource(api) },
).flow.cachedIn(viewModelScope)

RemoteMediator 流程: UI 触发刷新/滑到底 → Paging 判断需要加载 → RemoteMediator.load(REFRESH/PREPEND/APPEND) 请求网络 → 事务写入 Room + remote keys → Room 的 PagingSource 失效并重新发射 → UI 收到新的 PagingData. 这种方式适合离线缓存, 列表恢复, 网络与本地一致性要求高的页面.

UI 集成要点:

  • RecyclerView 用 PagingDataAdapter + LoadStateAdapter 展示刷新, 加载更多, 错误重试.
  • Compose 用 collectAsLazyPagingItems() 和 LazyColumn.items(count); 根据 loadState.refresh/append 渲染首屏 loading, 尾部 loading, 错误重试.
  • 在 ViewModel 中 cachedIn(viewModelScope), 避免旋转屏幕后重新创建分页流导致重复请求.

常见错误:

  • PagingSource 同时读网络和本地且没有一致性策略, 刷新 / 翻页状态难维护; 复杂缓存场景应考虑 RemoteMediator + Room.
  • 忘记实现合理的 getRefreshKey, 刷新后列表跳到错误位置.
  • 不处理 LoadState, 用户看不到加载, 错误, 重试状态.
  • 每次 UI 重组 / 重建都新建 Pager, 导致重复加载; 应让分页流由 ViewModel 持有并缓存.

面试答题结构: 先说 “PagingSource 负责加载一页, Pager 产出 PagingData Flow, UI 订阅渲染” → 再补 “RemoteMediator 用于网络 + 数据库单一数据源” → 最后讲 cachedIn, LoadState, 刷新 key, 错误重试这些工程坑.


高频面试题

Q1: ViewModel 为什么能在旋转屏幕后存活? 旋转是配置变更, Activity/Fragment 会重建, 但同一 ViewModelStoreOwner 的 ViewModelStore 会在配置变更后重新关联, 因此可取回同一个 ViewModel 实例. 历史实现可从 NonConfigurationInstance 保留链路理解, 但对外稳定契约是 “配置变更存活, owner 真正结束时 onCleared”, 不要依赖内部方法名.

Q2: ViewModel 能持有 Context 吗? 不能持有 Activity/View Context (泄漏).需要 Application Context 时用 AndroidViewModel. 原则: ViewModel 不感知 UI.

Q3: LiveData 的粘性语义对 UI 事件有什么影响? 新观察者会收到最新状态, 因而不适合直接承载未设计消费语义的动作. 不推荐用 SingleLiveEvent, Event wrapper 或 SharedFlow (replay=0) 作为统一补丁: 必须处理的业务结果进入可恢复 UI state, UI 处理后回调确认; 用户本地导航由 UI 直接触发; 允许丢失的瞬时效果才使用事件流.

Q4: LiveData 和 StateFlow 怎么选? 新项目优先 StateFlow (纯 Kotlin, 操作符丰富, 与协程统一), 需配 repeatOnLifecycle 做生命周期安全收集; 老项目 / 简单场景 LiveData 自带生命周期感知更省事.

Q5: Room 相比直接用 SQLite 的优势? 编译期校验 SQL, 减少样板代码, 对象映射, 原生支持协程和 Flow (数据变化自动推送), 迁移管理.

Q6: 为什么用 DataStore 替代 SharedPreferences? SP 有主线程 IO 风险, apply() 会先更新内存再异步持久化, 但待落盘写入及其与生命周期的交互仍可能导致卡顿, 且无类型安全, 无错误信号. DataStore 基于协程 + Flow, 异步, 事务, 类型安全 (Proto), 但仍要避免阻塞式误用, 迁移或上游工作造成卡顿.

Q7: Hilt 和 Dagger 关系? DI 解决什么问题? Hilt 是 Dagger 的 Android 封装, 预置组件层级和注入点. DI 解决对象创建与依赖管理: 解耦, 便于替换实现做单元测试, 避免手动 new 的耦合.

Q8: 什么任务适合 WorkManager? 和协程 / Service 怎么区分? 适合 “可延迟, 可持久化且期望最终执行” 的后台任务 (日志上传, 数据同步), 即使进程被杀 / 重启也可由系统重新调度; 不保证精确时间, 强行停止应用和系统约束是边界. 即时 UI 相关异步用协程; 需要持续运行的用前台 Service.

Q9: Navigation 的回退栈怎么理解? NavController 根据 NavGraph 导航时会把目的地包装成 NavBackStackEntry 入栈, entry 携带参数, 生命周期和状态. 返回时 popBackStack 弹出当前 entry 并恢复上一个; 登录流, 首页清栈要用 popUpTo/launchSingleTop 控制, 避免重复页面和错误返回路径.

Q10: Paging3 的三件套是什么? RemoteMediator 解决什么? PagingSource 负责按 key 加载一页数据, Pager 把它包装成 PagingData 流, UI 用 PagingDataAdapter 或 Compose LazyPagingItems 渲染. RemoteMediator 用于网络 + 本地数据库: 网络结果写入 Room, UI 从 Room 读, 实现离线缓存和单一数据源.

易错点 / 追问

  • ViewModel 持有 Activity/View/Context 引用是泄漏: 需要 Application Context 时用 AndroidViewModel, 页面级资源一律绑定 viewLifecycleOwner.
  • 混用 viewLifecycleOwner 与 Fragment 的 lifecycleOwner: View 销毁后仍访问旧 View; Flow 收集用 repeatOnLifecycle.
  • 用 SingleLiveEvent / SharedFlow (replay=0) 当统一 “事件修复”: 无 collector 时丢值; 必须处理的业务结果归约为可恢复 UI state.
  • Room 升级不写 Migration: 升级后 schema 校验失败直接崩溃; fallbackToDestructiveMigration 只清库, 不能当发布方案.
  • Navigation 字符串 route 散落各处且参数无编译期校验, 登录后不清栈导致返回路径混乱; 新代码优先类型安全路由 + popUpTo.
  • Hilt scope 与资源生命周期不匹配: 过长泄漏, 过短重复创建; ViewModelComponent 与 ActivityComponent 是并列分支, 不是父子.
  • 把 SP apply() 当已持久化确认, 或用默认 DataStore 工厂跨进程访问: 都不提供跨进程一致性; 多进程须用 MultiProcessDataStoreFactory 并全进程统一.
  • Paging 不实现 getRefreshKey / 不处理 LoadState / 每次重组新建 Pager: 刷新跳位, 用户看不到加载错误态, 重复请求; 分页流应由 ViewModel 持有并 cachedIn.

进阶补充: Hilt, Room, WorkManager 与 Compose 生命周期

Hilt 组件层级与作用域

常见层级: SingletonComponent → ActivityRetainedComponent, 其下分为 ViewModelComponent 与 ActivityComponent, 再由 ActivityComponent → FragmentComponent. 作用域要和生命周期匹配, 不要把短生命周期对象注入成单例.

Qualifier 与 Assisted Injection

同类型多个实现用 @Qualifier 区分; 运行时参数可用 assisted injection 或 ViewModel 的 SavedStateHandle.

Room 迁移与 TypeConverter

Room migration 要显式描述 schema 变化, 不能依赖删除重建. 复杂类型用 TypeConverter, 关系查询要注意 N+1 和事务一致性.

collectAsStateWithLifecycle

Compose 收集 Flow 推荐用 lifecycle-aware API, 避免页面不可见时仍持续收集.

WorkManager 约束和退避

WorkManager 适合可延迟, 可持久化且期望最终执行的后台任务. 约束包括网络, 充电, 空闲; 失败可配置 backoff 和 unique work policy, 但执行时间不精确且受系统与强行停止应用等边界影响.

后台任务与状态恢复决策矩阵

场景推荐机制进程死亡后的语义关键边界
页面内短任务生命周期感知的协程不保证继续, 页面销毁时应取消使用 viewModelScope 或 repeatOnLifecycle, 不要脱离所有权创建全局协程
配置变更期间保留页面状态ViewModel同进程配置变更可保留, 进程死亡后旧实例丢失不持有 View 或 Activity Context
进程死亡后恢复少量 UI 状态SavedStateHandle系统重建 owner 时可恢复已保存的小数据不存大对象和关键业务事实
可延迟但要求最终可靠执行WorkManager任务记录可持久化并由系统重新调度约束, 重试, 唯一任务和幂等必须明确
用户可见的持续工作Foreground Service进程仍可能被终止, 需要恢复设计必须及时展示通知, 遵守前台服务类型和后台启动限制
指定时间触发AlarmManager系统负责触发, 业务状态仍需持久化精确闹钟需要权限和合理业务理由, 到点后的耗时工作通常再交给其他机制

选择机制时依次回答五个问题:

  1. 工作是否必须跨页面, 进程死亡或设备重启继续?
  2. 用户是否需要实时看到持续执行和通知?
  3. 是要求精确触发时间, 还是只要求满足约束后最终执行?
  4. 取消由谁触发, 已完成一半时如何清理或恢复?
  5. 重试会不会重复扣款, 重复上传或覆盖新数据?

WorkManager 的 Result.retry() 只是请求以后重试, 不等于业务天然可靠. Worker 应使用稳定业务 ID 做幂等, 持久化阶段进度, 为网络和电量设置约束, 区分可重试错误与永久错误, 并为取消实现协作式清理. 周期任务也不是精确定时器, 系统可以为了省电合并和延迟执行.

追问: 为什么 Hilt scope 不能乱用? 因为对象生命周期过长会造成泄漏, 过短会导致重复创建和状态丢失.

应用架构 - MVVM 与 MVI ★

你的重点短板, 中级必考. 面试常问 “你项目用什么架构? 为什么?”, 要答得出演进逻辑和取舍. 组件基础见 Jetpack 架构组件, 完整落地见 App 架构落地案例.

一, 架构演进

架构核心痛点
MVCActivity 既是 View 又当 Controller职责混乱, Activity 臃肿 (“上帝类”)
MVPPresenter 持有 View 接口, 逻辑抽离接口爆炸, Presenter 持 View 易泄漏, 需手动解绑
MVVMViewModel 暴露可观察数据, View 订阅数据流方向多, 状态分散难追踪
MVI单一 State + 单向数据流模板代码多, 小页面偏重

核心趋势:职责分离 → 解耦 View 与逻辑 → 数据驱动 UI → 单向数据流.

二, MVVM

  • View(Activity/Fragment/Composable):只负责展示和转发用户操作, 订阅 ViewModel 的数据.
  • ViewModel: 持有 UI 状态和业务逻辑入口, 不引用 View, 通过 LiveData/StateFlow 暴露数据.
  • Model: 数据层 (Repository + 数据源).
  • 数据绑定: View 观察 ViewModel 的可观察数据, 数据变 UI 自动更新.
class UserViewModel(private val repo: UserRepo) : ViewModel() {
    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user.asStateFlow()
    fun load(id: String) = viewModelScope.launch {
        _user.value = repo.getUser(id)
    }
}

MVVM 的问题: 多个 LiveData/StateFlow 分散表达状态 (loading/data/error 各一个), 状态可能不一致, 难追踪 “当前完整 UI 状态”.MVI 就是来解决这个的.

三, MVP 的 View 解绑与异步边界

MVP 中 Presenter 可以持有 View 接口, 但必须在 View 销毁时释放引用. 否则异步任务完成后可能回调已销毁的 Activity 或 Fragment, 既造成错误 UI 更新, 也可能延长 View 的存活时间.

下例是最小示意代码, 用于说明生命周期回调和异步竞态, 不是完整的生产实现. onStop() 是否解绑要按页面是否会在后台继续展示结果决定; 对不应跨出页面生命周期的工作, 应优先取消任务.

interface ProfileView {
    fun showProfile(profile: Profile)
    fun showError(message: String)
}

class ProfilePresenter(
    private val repository: ProfileRepository,
    private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    // This scope is created for each attached View and closed in detachView().
    private var scope: CoroutineScope? = null
    private var view: ProfileView? = null
    private var loadJob: Job? = null

    fun attachView(view: ProfileView) {
        scope?.cancel()
        scope = CoroutineScope(SupervisorJob() + mainDispatcher)
        this.view = view
    }

    fun loadProfile(id: String) {
        val activeScope = scope ?: return
        loadJob?.cancel()
        loadJob = activeScope.launch {
            try {
                val profile = withContext(ioDispatcher) {
                    repository.loadProfile(id)
                }
                view?.showProfile(profile)
            } catch (error: CancellationException) {
                throw error
            } catch (error: Exception) {
                view?.showError(error.message ?: "加载失败")
            }
        }
    }

    fun detachView() {
        view = null
        loadJob?.cancel()
        loadJob = null
        scope?.cancel()
        scope = null
    }
}

class ProfileFragment : Fragment(), ProfileView {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        presenter.attachView(this)
    }

    override fun onDestroyView() {
        presenter.detachView()
        super.onDestroyView()
    }

    override fun showProfile(profile: Profile) {
        // Render the profile in the current Fragment View.
    }

    override fun showError(message: String) {
        // Render an error only while the Fragment View exists.
    }
}

示例中的 scope 由 Presenter 所有, 每次 attachView() 都基于 mainDispatcher 创建一个绑定 View 生命周期的 scope; 默认值是 Dispatchers.Main.immediate, 因而所有 View 回调在主线程. detachView() 会取消并清空 scope, 所以同一个 Fragment 实例在 onDestroyView() 后再次 onViewCreated() 时可重新绑定; 未绑定 View 时 loadProfile() 直接返回. Repository 调用默认切到 Dispatchers.IO, 返回后自动回到 Main. Dispatcher 可注入, 使测试以确定性 dispatcher 驱动; 生产注入值仍必须保证 UI 更新在 Main. view?.showProfile(...) 只是在异步返回时避免调用已解绑的 View, 不能替代任务管理: 长期任务即使不更新 UI, 仍可能持续占用网络, CPU 或持有其他资源. CancellationException 必须重抛; 业务或预期运行失败才按 Exception 处理, 不要捕获 Throwable. 按工作所有权选择处理方式:

方式解决的问题仍需注意的边界
仅 detachView()清除 Presenter 到 View 的引用, 避免回调已销毁 View任务仍在运行, 其资源和其他引用仍可能泄漏
detachView() + 取消任务页面离开后不再需要结果的请求可停止取消需要由协程或请求实现配合, 并处理取消后的状态
由 ViewModel 持有状态配置变更后 UI 可重新订阅状态, ViewModel 不引用 View仍应按 viewModelScope 和业务所有权决定任务何时结束

Fragment 的 View 生命周期与 Fragment 实例不同, 因此在 onDestroyView() 解绑比等到 onDestroy() 更符合 View 引用的实际存活期. 生命周期语境见 Android 四大组件与基础, 协程取消与结构化并发边界见 多线程并发专题.

MVP Presenter 的最小测试策略

MVP 的可测性来自边界设计: Presenter 只依赖 View 接口与 Repository 接口, 不持有 Android 框架对象, 可在纯 JVM 上注入 fake/mock 观察回调, 并注入 TestDispatcher 驱动虚拟时间. 测试的重点是解绑与取消: 至少覆盖成功回调, 预期失败回调, 以及 detachView() 后任务被取消且不再回调旧 View.

具体测试代码, 分层和工具见 见 16 的「协程与 Flow 测试」与「Fake 优先于过度 Mock」小节, 本节只保留可测性原理, 不重复其配置细节.

四, MVI (Model-View-Intent)

核心思想:单一数据源 + 单向数据流 (UDF).

  • State: 用一个不可变对象描述整个 UI 状态 (data class UiState(val isLoading, val data, val error)).
  • Intent(也叫 Event/Action):用户意图 (点击, 刷新), 是进入系统的唯一入口.
  • 单向流: Intent → ViewModel 处理 → 产出新 State → View 渲染. 状态只能由 ViewModel 产出, View 不能直接改.
  • Effect / 动作: 先区分可靠性. 用户点击触发的导航由 UI 直接处理; 必须保证处理的业务结果进入 State; 只有允许丢失的纯 UI 瞬时效果才使用独立事件流.
data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null,
)
sealed interface UiIntent {
    data object Refresh : UiIntent
    data class Click(val id: String) : UiIntent
}
class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state.asStateFlow()
    fun onIntent(intent: UiIntent) = when (intent) {
        UiIntent.Refresh -> refresh()
        is UiIntent.Click -> openDetail(intent.id)
    }
}

五, 官方推荐分层架构

Google 推荐三层 (配合单 Activity + Jetpack), 但它是可按复杂度裁剪的参考架构, 不是所有页面都必须机械套满三层:

  • UI 层: Composable/Fragment + ViewModel + UiState. 只做展示和事件转发.
  • Domain 层 (可选): UseCase 封装单一业务用例, 复用复杂逻辑, 降低 ViewModel 体积.
  • Data 层: Repository (对外唯一数据入口)+ DataSource (网络 / 本地). Repository 决定数据来源, 缓存策略, 做单一数据源 (SSOT).

依赖方向单向向下: UI → Domain → Data, 上层依赖下层抽象 (依赖倒置).

六, 可测试性

  • ViewModel 不依赖 Android 框架 (不持 Context/View), 可纯 JUnit 测试.
  • Repository/UseCase 通过接口注入 (Hilt), 测试时替换为 fake/mock.
  • 协程测试用 runTest + TestDispatcher, Flow 测试用 Turbine.

七, 同一页面的 MVVM/MVI 对比与选型

以 “商品列表刷新” 为例, 二者都可以使用 StateFlow 和 Repository, 区别在状态组织和事件入口, 不是 “一个先进, 一个落后”.

维度MVVM 写法MVI 写法
输入viewModel.refresh() 等语义方法onIntent(Refresh) 统一入口
输出可有 items/loading/error 多个流, 建议收敛为页面 state一个不可变 ProductState
适用简单详情, 表单和低交互页面多事件, 多加载阶段, 可回放状态机
代价过度分散时状态组合困难Intent/reducer 模板与状态设计成本

上下文片段: 下例是 MVI reducer 的可单元测试核心, Product, Repository 和协程启动代码省略. 输入 Refresh 时预期 loading 为 true 且清除旧错误; 成功事件后预期替换 items, 关闭 loading. 它不是完整 Android 页面.

data class ProductState(val loading: Boolean = false, val items: List<Product> = emptyList(), val error: String? = null)
sealed interface ProductEvent { data object Refresh : ProductEvent; data class Loaded(val items: List<Product>) : ProductEvent; data class Failed(val message: String) : ProductEvent }
fun reduce(old: ProductState, event: ProductEvent): ProductState = when (event) {
    ProductEvent.Refresh -> old.copy(loading = true, error = null)
    is ProductEvent.Loaded -> old.copy(loading = false, items = event.items)
    is ProductEvent.Failed -> old.copy(loading = false, error = event.message)
}

UDF trace: UI click/refresh -> Intent/Event -> ViewModel reducer + use case -> new immutable UiState -> UI render; 数据访问为 ViewModel -> Repository -> local/remote data.

选型顺序: 先画页面状态, 事件和失败恢复; 状态少且调用链短时使用收敛状态的 MVVM; 存在并发事件, 分页, 步骤流或需严格审计转移时用 reducer 风格 MVI; 若 reducer 只做转发, 阅读成本高, 则回退到 MVVM. 对风控 / 设备指纹这类需要排查与审计的场景, MVI 的单一 State 可序列化回放, 便于复现问题链路和审计每次状态转移, 是选型时的一个加分论点. 两者都应保持 UI 不直接访问数据源, 业务状态可恢复, 依赖可替换. 完整 UI 到 DataSource, 分页和离线实现见 App 架构落地案例.


高频面试题

Q1: MVP 和 MVVM 区别? MVP: Presenter 持有 View 接口, 双向手动调用, 需解绑防泄漏, 接口多. MVVM: ViewModel 不持有 View, 通过可观察数据 (LiveData/StateFlow) 单向通知, View 订阅, 解耦更彻底.

Q2: MVVM 和 MVI 区别? MVI 解决什么? MVVM 状态分散在多个 LiveData/StateFlow, 可能不一致, 难追踪. MVI 用单一不可变 State 描述整个 UI, 单向数据流, Intent 作唯一入口, 状态可预测, 易调试, 易回放. 代价是模板代码多.

Q3: 为什么 ViewModel 里不能直接更新 UI? ViewModel 不应感知 View (否则耦合 + 泄漏 + 不可测).它只产出状态, 由 View 订阅后自行渲染, 保持单向数据流.

Q4: Repository 模式的作用? 作为数据层唯一入口, 对上层屏蔽数据来源 (网络/缓存/数据库), 实现单一数据源, 缓存策略, 离线支持, 使 ViewModel 不关心数据从哪来.

Q5: UseCase (Interactor) 有必要吗? 非必须. 当业务逻辑复杂, 需被多个 ViewModel 复用, 或 ViewModel 过于臃肿时引入, 封装单一业务用例, 提升复用和可测试性. 简单页面可省略.

Q6: Toast / 导航应该放进 State 还是事件流? 不能按控件类型简单二分. 用户点击产生的导航由 UI 直接处理; 必须保证处理的业务结果进入可恢复 State, UI 处理后回调确认; 只有明确允许丢失的瞬时效果才使用 Channel/SharedFlow. replay=0 在无 collector 时会丢值, 不能提供恰好一次保证.

Q7: 你项目用什么架构? 为什么这么选?(开放题) 结合实际答: 中小页面 MVVM 足够轻量; 复杂交互/状态多的页面用 MVI 保证可预测性. 强调 “按复杂度选型”, 并说明分层 (UI/Domain/Data), 单一数据源, 依赖注入带来的可测试性收益.

易错点 / 追问

  • MVP 等到 onDestroy() 才解绑: View 生命周期在 onDestroyView() 已结束, 之后回调会更新已销毁的 View; 解绑时机匹配 View 引用实际存活期.
  • 捕获 CancellationException 不重抛: 破坏协程取消; 业务失败才按 Exception 处理, 不要捕获 Throwable.
  • 状态分散在多个 LiveData/StateFlow: loading/data/error 各自为流容易不一致; 页面状态应收敛为单一 state, 再决定是否需要 MVI.
  • MVI reducer 只做转发不归约: 模板成本高于收益; 状态少且调用链短时回退到收敛状态的 MVVM.
  • 用 SharedFlow (replay=0) 传递必须可靠的事件: 无 collector 时丢值; 必须处理的业务结果进 State, UI 处理后回调确认.
  • UI 层直接访问 Repository/DataSource, 或 Repository 按方法而非业务聚合切分: 破坏单向数据流和可测性, 过细会退化成无意义转发层.

落地边界

本篇止于定义, 同页对照, UDF 与选型. UI 到 Repository/DataSource 的完整链路, Offline-first, 分页失败恢复及 requestId 幂等, 统一见 App 架构落地案例, 避免同一案例在两篇重复维护.

追问: Repository 是不是越多越好? 不是. Repository 应按数据 / 业务聚合边界划分, 过细会变成无意义转发层.

App 架构落地案例

架构题不要只背 MVVM/MVI 名词. 面试官更想听到: 一个真实页面从 UI 到 ViewModel, UseCase, Repository, DataSource 怎么流动, 异常, 分页, 表单, 离线缓存怎么统一收口. 前置: 组件与架构概念见 Jetpack 架构组件 与 MVVM 与 MVI.

一, 完整页面流: UI → ViewModel → UseCase → Repository → DataSource

以 “商品列表 + 筛选 + 收藏 + 分页” 为例, 推荐链路是单向的:

Screen/Fragment/Composable
  -> ViewModel: 接收 UI Intent,维护 UiState
  -> UseCase: 封装业务规则,如筛选参数校验,收藏权限判断
  -> Repository: 决定网络/缓存/数据库数据来源,合并多源
  -> RemoteDataSource / LocalDataSource: Retrofit/Room/DataStore

核心原则:

  • UI 不直接访问 Retrofit/Room, 只渲染 UiState 并发送用户意图.
  • ViewModel 不写复杂数据来源选择, 把业务规则交给 UseCase/Repository.
  • Repository 对上层暴露稳定模型, 隐藏网络 DTO, 数据库 Entity 的差异.

二, 统一 UI 状态: loading / error / empty / content

不要用多个互相独立的 Boolean 到处散落, 推荐一个不可变 UiState 表达完整页面状态.

跨文件上下文骨架

跨文件上下文骨架: 以下案例需要 Compose Material 3, lifecycle-runtime-compose, Room, Retrofit (或等价实现) 和 Hilt/手动注入. 为聚焦链路, 省略实体/DTO 映射, 数据库建表, DI, imports, 网络实现和错误文案资源; 因此它不是可直接复制编译的单文件示例. MVVM/MVI 概念与选型见 架构概念与选型.

data class ProductUi(val id: String, val title: String)
data class ProductFilter(val categoryId: String?)
data class RequestToken(
    val generation: Long,
    val filterSnapshot: ProductFilter,
)
data class AppendState(
    val hasMore: Boolean,
    val nextPageKey: String?,
    val failedPageKey: String?,
)
data class ActivationResult(val token: RequestToken, val initialAppendState: AppendState)
sealed interface GuardedWriteResult {
    data class Applied(val appendState: AppendState) : GuardedWriteResult
    data object Stale : GuardedWriteResult
}
data class ProductListState(val items: List<ProductUi> = emptyList(), val isLoading: Boolean = false, val isRefreshing: Boolean = false, val error: UiError? = null, val append: AppendState? = null) { val isEmpty get() = !isLoading && items.isEmpty() && error == null }
interface ProductRepository {
    fun observeProducts(filter: ProductFilter): Flow<List<Product>>
    suspend fun activateGeneration(filter: ProductFilter): ActivationResult
    suspend fun refresh(token: RequestToken): Result<GuardedWriteResult>
    suspend fun loadMore(pageKey: String, token: RequestToken): Result<GuardedWriteResult>
}
interface LocalProductDataSource {
    fun observeProducts(filter: ProductFilter): Flow<List<Product>>
    suspend fun activateGeneration(filter: ProductFilter): ActivationResult
    suspend fun writeRefreshIfCurrent(token: RequestToken, expectedPageKey: String?, page: ProductPage): GuardedWriteResult
    suspend fun writeAppendIfCurrent(token: RequestToken, expectedPageKey: String, page: ProductPage): GuardedWriteResult
}
interface RemoteProductDataSource { suspend fun fetchPage(pageKey: String?): ProductPage }
// Repository.refresh/loadMore: remote.fetchPage(...) 后直接返回 Local 的 guarded write 结果.
// Local 的两个 guarded write 均在一个 Room 事务中:验证 -> 写实体和 remote key ->
// return Applied(remoteKey.toAppendState());验证失败 return Stale.
private enum class RequestPhase { Idle, Loading, Failed }
private data class RequestState(val phase: RequestPhase = RequestPhase.Loading, val error: UiError? = null, val append: AppendState? = null)
class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
    // 仓库快照和请求阶段是唯一事实来源;不要把展示用 items 再存一份到 transient state.
    private val filter = MutableStateFlow(ProductFilter(categoryId = null))
    private val products = filter.flatMapLatest(repository::observeProducts)
        .map { source -> source.map { ProductUi(it.id, it.title) } }
        .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
    private val request = MutableStateFlow(RequestState())
    private var currentToken: RequestToken? = null
    private fun isCurrent(token: RequestToken): Boolean =
        currentToken == token && currentToken?.generation == token.generation
    val state = combine(products, request) { items, current -> ProductListState(
        items = items,
        isLoading = current.phase == RequestPhase.Loading && items.isEmpty(),
        isRefreshing = current.phase == RequestPhase.Loading && items.isNotEmpty(),
        error = current.error,
        append = current.append,
    ) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProductListState(isLoading = true))
    init { refresh() }
    fun refresh(newFilter: ProductFilter = filter.value) = viewModelScope.launch {
        // 此调用的成功提交是刷新线性化点;返回前绝不发布新 token.
        val activation = repository.activateGeneration(newFilter)
        val token = activation.token
        // 多个 refresh 协程恢复顺序可不同;只发布尚未被更高持久化 generation 取代的 token.
        if (currentToken?.generation?.let { it >= token.generation } == true) return@launch
        currentToken = token
        filter.value = token.filterSnapshot
        request.value = RequestState(phase = RequestPhase.Loading, append = activation.initialAppendState)
        repository.refresh(token).onSuccess { write ->
            if (!isCurrent(token)) return@onSuccess
            when (write) {
                is GuardedWriteResult.Applied -> request.update {
                    it.copy(phase = RequestPhase.Idle, error = null, append = write.appendState)
                }
                GuardedWriteResult.Stale -> Unit
            }
        }.onFailure { error ->
            if (isCurrent(token)) request.update {
                it.copy(phase = RequestPhase.Failed, error = UiError(error.message ?: "刷新失败"))
            }
        }
    }
    fun retryMore() = request.value.append?.failedPageKey?.let(::loadMore)
    fun loadMore(pageKey: String) = viewModelScope.launch {
        val token = currentToken ?: return@launch
        val append = request.value.append ?: return@launch
        if (!append.hasMore || pageKey != append.nextPageKey) return@launch
        // Flight registry 只按 (token, pageKey) 原子预留或共享网络 Deferred.
        repository.loadMore(pageKey, token).onSuccess { write ->
            if (!isCurrent(token)) return@onSuccess
            when (write) {
                is GuardedWriteResult.Applied -> request.update { it.copy(append = write.appendState) }
                GuardedWriteResult.Stale -> Unit
            }
        }.onFailure {
            if (isCurrent(token)) request.update {
                it.copy(append = it.append?.copy(failedPageKey = pageKey))
            }
        }
    }
}
@Composable fun ProductPage(vm: ProductViewModel) {
    val state by vm.state.collectAsStateWithLifecycle()
    Scaffold(topBar = { TopAppBar(title = { Text("商品") }) }) { padding -> Column(Modifier.padding(padding)) {
        Button(onClick = vm::refresh, enabled = !state.isLoading && !state.isRefreshing) { Text("刷新") }
        when {
            state.isLoading -> CircularProgressIndicator()
            state.error != null && state.items.isEmpty() -> Button(onClick = vm::refresh) { Text("重试") }
            state.isEmpty -> Text("暂无商品")
            else -> LazyColumn { items(state.items, key = { it.id }) { Text(it.title) }; state.append?.failedPageKey?.takeIf { !state.isRefreshing }?.let { item { TextButton(onClick = vm::retryMore) { Text("重试加载更多") } } } }
        }
        if (state.error != null && state.items.isNotEmpty()) Text("刷新失败:${state.error}")
    } }
}

RequestToken 是持久化层分配的一次请求围栏: 包含单调递增的 generation 和不可变 filterSnapshot. ActivationResult 同时返回 token 和由新 remote key 构造的初始 AppendState. Local 与 Repository 的 activateGeneration(filter): ActivationResult 完全一致; Local 在同一个 Room 数据库事务中读取并递增 generation, 写入当前 token 元数据, 重置 / 建立 remote key, 并构造后提交该结果. Repository 直接委托. 进程或 ViewModel 重建后, generation 从持久化值继续递增, 绝不从 0 重新计数.

同一 Room 数据库中的 generation/token 元数据行, 以及所有 guarded write 事务, 是唯一线性化边界. activate 的事务提交点才是刷新线性化点, 不是 ViewModel 发起函数调用的瞬间. ViewModel 只能在 activateGeneration 成功返回后发布 token; 并发 activate 恢复顺序相反时, 只接受 generation 大于 currentToken 的结果. 旧页写入和 activate 事务竞争时, 旧写先提交则属于刷新线性化点之前; activate 先提交则旧写验证失败. writeRefreshIfCurrent 与 writeAppendIfCurrent 都在同一 Room 事务内验证当前 token, filter 和各自的 expectedPageKey, 再写实体和 remote key, 并从该事务写后的 remote key 构造 GuardedWriteResult.Applied(AppendState); 任一验证失败返回 Stale, 不写实体或 key. Repository 直接传播该结果. 取消网络, flatMapLatest 与 ViewModel 丢弃回调仅节省资源, 不承担正确性.

products 保存 observeProducts(filter) 的当前快照, request 保存请求阶段, 刷新错误和由 Activation/Applied 返回的 AppendState. ViewModel 不默认构造或猜测分页元状态: 激活使用 ActivationResult.initialAppendState, 写入成功只使用 Applied.appendState, Stale 不更新 UI. 每个 refresh/loadMore 回调都先以 isCurrent(token) 验证 token 相等且 generation 未回退; 新 token 发布后旧调用的成功或失败均被忽略, 不能恢复 RequestState 或 AppendState. 刷新错误放在 RequestState.error; 追加错误只写当前 AppendState.failedPageKey.

append 的 single-flight 属于 Repository 的 flight registry, 不是 ViewModel 的 Mutex.registry 在短临界区按 (token, pageKey) 原子预留: 已有项共享同一个, 最终携带 GuardedWriteResult 的网络 Deferred, 没有才登记唯一 identity 后发网络; 新 token 发布时 registry 在自己的短临界区替换旧 generation 的项, 完成仅在 (token, pageKey, identity) 都匹配时清理, 因此旧 completion 不能清除新 flight. 它只负责网络请求去重, 不参与 generation 的持久化正确性, 也不是线性化边界. registry 的替换与网络取消都只是资源优化, 正确性始终由 Room guarded write 保证. retryMore() 选择 failedPageKey; loadMore(pageKey) 接受当前合法的 nextPageKey, 并不限定为重试调用. 底部重试仅在当前 failedPageKey 存在且未刷新时渲染.

状态UI 怎么渲染ViewModel 怎么产出
首次 loading骨架屏 / 全屏 loadingisLoading=true, items=[]
content列表内容items 非空, error 清空
empty空态文案 + 引导按钮请求成功但数据为空
error错误态 + 重试首屏失败时设置 error
refresh error保留旧列表 + 错误提示RequestState.error, State 保留 items

三, UI 事件, 导航与可靠处理

不要把 Toast, 导航, 弹窗全部塞进 Channel 或 SharedFlow(replay=0). 先按可靠性建模:

场景推荐处理原因
用户点击打开详情UI 回调中直接导航动作来源就在 UI, 无需绕 ViewModel
提交成功后必须进入结果页ViewModel 更新流程 State, UI 观察后导航并确认UI 暂停或旋转时结果不会静默丢失
必须展示的错误消息State 中保存带 ID 的消息, 展示后清除可测试重复消费与恢复策略
动画, 轻提示等允许丢失效果明确语义后使用事件流无 collector 时丢失是可接受设计
data class FormUiState(
    val submitting: Boolean = false,
    val completedOrderId: String? = null,
    val message: UiMessage? = null,
)

interface PendingOrderStore { suspend fun loadOrCreateRequestId(draftId: String): String; suspend fun clearRequestId(draftId: String) }
class OrderViewModel(private val store: PendingOrderStore, private val orders: OrderRepository) : ViewModel() {
    fun submit(draftId: String) = viewModelScope.launch {
        val requestId = store.loadOrCreateRequestId(draftId) // 同一草稿的重试复用该值
        orders.submit(draftId, requestId).onSuccess { orderId -> store.clearRequestId(draftId); _state.update { it.copy(completedOrderId = orderId, submitting = false) } }
            .onFailure { _state.update { it.copy(submitting = false, message = UiMessage("提交失败,可重试")) } }
    }
}

fun onCompletionHandled() {
    _state.update { it.copy(completedOrderId = null) }
}

测试至少覆盖: UI 处于 STOPPED 时业务完成, 旋转后重新收集, 重复回调, 进程重建和用户重复点击.

四, 分页: 刷新, 加载更多与错误恢复

分页不是简单 append, 要区分首次加载, 下拉刷新, 加载更多失败.

  1. Refresh: 重置页码 / 游标, 请求第一页, 成功后替换列表.
  2. LoadMore: 使用下一页 key, 成功后 append, 失败时保留旧列表并显示底部错误.
  3. 去重: 按业务 ID 去重, 避免刷新和加载更多交叉导致重复.
  4. 并发控制: Repository 的 flight registry 必须以 generation-scoped (token,pageKey) 原子预留实现 append single-flight; 重复触底立即共享同一个 Deferred 或返回已在途, 绝不因 Mutex 排队后再发一次网络. 它不是持久化线性化边界; collectLatest/网络取消不能替代 Room guarded write 事务.

分页重试保存当前 token 内失败的 pageKey: retryMore() 取该 key, loadMore(pageKey) 也可处理当前合法的 nextPageKey. activate 提交会在 Room 中原子重置 / 建立新 generation 的 remote key; ViewModel 收到更高 token 后才发布它和 ActivationResult.initialAppendState. refresh/append 的 Applied 只能使用该写事务 remote key 构造出的 AppendState; 失败不得恢复旧 key. 旧请求晚返回时, Room guarded write 必须保证不新增旧实体, 不改旧 remote key. 症状 “重试后重复项” 可通过请求日志中的 generation/key/筛选版本和列表 ID 定位.

预期测试至少覆盖:

  • 旧 loadMore 写事务先提交, 随后 activate 提交: 旧页可见, 且它属于刷新线性化点之前; activate 后的新 remote key/append state 被重置.
  • activate 事务先提交, 旧 loadMore 随后尝试写: 事务内 token 校验失败, Room 不新增旧实体, 不改 remote key, failedPageKey 不回填.
  • 旧 refresh 写事务先提交, 随后 activate 提交: 旧世代的实体替换和 remote key 更新属于线性化点之前; activate 随后原子建立新世代的 token 与初始 append state.
  • activate 事务先提交, 旧 refresh 随后尝试写: 整个 guarded write 返回 Stale, 实体替换与 remote key 更新都不发生.
  • 分别制造 token, filter snapshot, expectedPageKey 不匹配: 每一种失配都必须使事务整体返回 Stale, Room 的实体和 remote key 均保持不变.
  • 进程或 ViewModel 重建后再次 activate: 持久化 generation 继续递增, 不会从 0 或旧内存值回退.
  • 两个 activate 返回顺序相反: currentToken 仅接受更高 generation, 不能被较旧返回覆盖.
  • 对同一 generation, 同一 pageKey 重复触底: 只发起一个网络 append; 其他调用共享同一 Deferred 或立即得知已在途.
  • 新 generation 激活后旧 append completion 到达: 它不能删除新 generation 的 flight, 因清理必须匹配 token, pageKey 和 flight identity.
  • 匹配 identity 的 append 分别以成功, 失败和取消结束: 三条路径都清理自己的 flight; 清理完成后, 相同 (token, pageKey) 可以发起一次新的重试.
  • Local guarded write Applied: 返回值中的 AppendState 来自同一事务写后的 remote key, 不由 Repository/ViewModel 猜测或默认构造.
  • guarded write 返回 Stale: 不更新 RequestState 或 AppendState.
  • 新 token 发布后旧 refresh/loadMore 的成功与失败: 均不更新 RequestState 或 AppendState.
  • 切换筛选或 refresh: 成功后 hasMore, nextPageKey, failedPageKey 来自新 generation 的 remote key; 不得继承旧筛选的 hasMore=false 或失败 key.
场景推荐状态面试追问点
首屏失败error != null, items=[]显示全屏错误, 可重试
加载更多失败items 保留, append.failedPageKey != null不清空已有内容
无更多append.hasMore=false避免继续触发下一页
参数变化activate 提交后发布新 token, 使用新 append state 刷新防止旧筛选结果写入 Room

五, 表单提交: 校验, 幂等与防重复点击

表单页面适合体现 UseCase 价值. ViewModel 收集输入状态, UseCase 做业务校验和提交编排, Repository 执行网络 / 本地持久化.

  • 本地校验: 手机号, 验证码, 必填项在提交前快速反馈.
  • 提交中状态: submitting=true 禁用按钮, 防重复点击.
  • 服务端错误: 映射成字段错误或页面错误, 不要直接把接口文案散落到 UI.
  • 幂等: 订单/支付/注册类提交要有 requestId 或服务端幂等键.
  • 成功结果: 必须处理时写入流程 State, UI 导航 / 展示后回调确认; 只有允许丢失的局部效果才使用事件流.

怎么排查重复提交: 看按钮是否禁用, ViewModel 是否丢弃 submitting 期间的新 Intent, 接口是否具备幂等键.

PendingOrderStore 可由 Room/DataStore-backed 实现保存草稿到 requestId 的映射; 新草稿创建新值, 进程重建后的重试仍复用旧值, 成功后清除. 客户端禁用按钮只减少重复, 服务端必须以幂等键保证重复请求返回同一业务结果. 预期测试: 同一 requestId 连续提交两次只创建一笔订单; 不同 requestId 按业务规则独立处理.

面试叙事模板: 事件上报幂等去重 (风控 / 设备指纹)

  • 症状: 设备指纹 / 事件上报出现同一事件被服务端重复处理 (重复入库, 重复计费, 风控重复拦截).
  • 证据: 服务端日志同一 requestId 多次出现, 客户端在弱网重试或超时重发时未带幂等键.
  • 定位: 确认重复发生在网络层重试, 而非业务逻辑多写.
  • 修复: 上报事件生成并持久化 requestId, 服务端按 requestId 幂等去重; 客户端重试复用同一 requestId.
  • 验证: 构造弱网 / 超时重发场景, 服务端只处理一次, 压测与日志核对无重复.

六, Offline-first 与离线缓存

离线优先不是 “网络失败读缓存” 这么简单, 更推荐本地数据库作为 Single Source of Truth:

UI 观察 Room Flow
Repository 触发网络刷新
Remote 成功 -> 写入 Room
Room 变化 -> UI 自动更新
Remote 失败 -> UI 保留本地数据 + 可恢复错误状态/按语义允许丢失的提示

优点: 页面旋转, 进程重建, 短暂断网都能稳定展示; Repository 统一控制刷新策略, 过期时间, 同步状态. 难点是冲突解决, 脏数据标记, 失败重试和缓存淘汰.

七, 多源数据合并

真实页面经常需要合并用户信息, 配置, 列表, 埋点开关, 缓存状态. Repository/UseCase 可以用 Flow 组合多源:

  • combine: 多个数据源任一变化都重新产出 UI 模型.
  • flatMapLatest: 筛选条件变化时取消旧查询.
  • zip: 严格一一配对, 业务上较少用于 UI 持续状态.
  • 本地 + 远端: 本地先出首屏, 远端刷新后写库再更新.
多源类型合并位置注意点
用户权限 + 菜单配置UseCase权限变化要触发 UI 重新计算
Room 缓存 + 网络刷新Repository网络只更新库, UI 观察库
表单输入 + 服务端校验ViewModel/UseCase避免每个字符都打接口, 加 debounce

八, 落地检查清单

一个页面架构是否靠谱, 可以按这张表自查:

检查项好的表现坏味道
数据流UI 发 Intent/回调并观察 State; 局部 UI 动作在 UI 处理UI 直接调 Repository/Retrofit
状态单个 UiState 表达完整页面loading/error/data 多处散落
错误Domain/UI error 统一映射到处 try-catch + Toast
分页刷新/更多/失败/无更多分开失败就清空列表
测试ViewModel/UseCase 可纯 JVM 测试业务逻辑写在 Fragment/Composable

高频面试题

Q1: 请讲一个页面从 UI 到数据层的完整链路. UI 负责渲染 State 和发送 Intent; ViewModel 接收 Intent, 维护 UiState; UseCase 封装业务规则; Repository 决定网络, 本地缓存和多源合并; DataSource 只负责具体 Retrofit/Room/DataStore 操作. 依赖方向单向, 上层不直接感知底层实现.

Q2: loading, error, empty 怎么统一管理? 用一个不可变 UiState 表达完整页面, 例如 isLoading/items/error/append.hasMore. 首屏错误显示全屏错误; 刷新失败保留旧数据并更新可恢复错误状态; 请求成功但列表为空显示 empty, 避免多个 Boolean 分散导致状态互相矛盾.

Q3: Toast / 导航应该放 State 还是事件流? 不能按控件类型二分. 用户点击产生的导航由 UI 直接处理; 必须保证处理的业务结果进入可恢复 State, UI 处理后回调确认; 只有明确允许丢失的瞬时效果才考虑 Channel/SharedFlow. replay=0 不能提供可靠投递或恰好一次保证.

Q4: 分页加载更多失败时怎么处理? 不要清空已有列表. 保留 items, 在 AppendState 记录当前 generation 的 failedPageKey, 允许用户重试该 pageKey; 用 append.hasMore 限制无更多页, 并由 Repository 的 (token,pageKey) single-flight 防止重复触发并发请求.

Q5: Offline-first 怎么落地? 以 Room 作为 Single Source of Truth, UI 观察本地 Flow; Repository 触发网络刷新, 成功后写入 Room, UI 因数据库变化自动更新; 失败时保留本地数据并提示. 难点是冲突, 过期策略和失败重试.

易错点 / 追问

  • 不要让 UI 直接依赖 Retrofit/Room, 否则页面难测, 数据策略分散.
  • 不要把消息永久留在 UiState, 也不要把所有结果都扔进事件流. 对可恢复消息使用唯一 ID 和消费确认, 并明确旋转与进程重建语义.
  • 分页失败要区分首屏失败和加载更多失败, 后者不能清空已有内容.
  • UseCase 不是越多越好; 简单 CRUD 可省略, 复杂业务规则 / 复用逻辑再引入.
  • Offline-first 的核心是本地单一数据源, 不是简单 “catch 网络异常后读缓存”.

测试体系

★ 测试体系是中级 Android 面试的高频考点, 也是常见短板区. 能讲清 “怎么测”, 比只会说 MVVM/MVI 更能证明工程能力.

一, 测试金字塔与 Android 测试分层

层级目标工具典型对象
单元测试快速验证纯逻辑JUnit / Truth / MockKUseCase, Repository, Reducer
集成测试验证多层协作Robolectric / fake data sourceViewModel + Repository
UI 测试验证用户路径Espresso / Compose Test页面交互, 导航, 错误提示

原则: 越靠下越多, 越快, 越稳定; 越靠上越少, 越接近真实用户.

金字塔成立的前提是架构可测: 逻辑下沉到纯 JVM 层, 依赖可注入替换; MVP/MVVM 的可测性原理见 应用架构 的「MVP Presenter 的最小测试策略」. 本篇聚焦测试方法与工具.

二, 单元测试: JUnit, 断言与 Mock

  • JUnit 负责组织测试生命周期.
  • Truth/AssertJ 让断言可读.
  • MockK/Mockito 用于隔离外部依赖, 但不要 mock 一切.
  • MockK 常见坑: 挂起函数要用 coEvery/coVerify, 直接 every/verify 匹配不上; relaxed = true 会对未 stub 的调用静默返回默认值, 掩盖漏 stub 的调用, 属反模式.
class LoginUseCaseTest {
    @Test
    fun `blank username returns validation error`() {
        val useCase = LoginUseCase(fakeRepository)
        val result = useCase.execute(username = "", password = "123456")
        assertThat(result).isEqualTo(LoginResult.InvalidUsername)
    }
}

三, 协程与 Flow 测试

  • 用 runTest 控制虚拟时间.
  • 用 StandardTestDispatcher 替换真实 dispatcher.
  • Flow 可用 Turbine 验证 emit 顺序.
@Test
fun `flow emits loading then success`() = runTest {
    repository.userFlow().test {
        assertThat(awaitItem()).isEqualTo(UiState.Loading)
        assertThat(awaitItem()).isInstanceOf(UiState.Success::class.java)
        awaitComplete()
    }
}

四, ViewModel / Repository 怎么测

ViewModel 测试重点不是测试 Android 框架, 而是测试输入事件到 UI State 的转换.

对象测什么不测什么
ViewModelstate/effect, 错误处理, 重试具体控件绘制
Repository缓存策略, 数据源切换Retrofit/Room 本身
UseCase业务规则外部 IO

五, UI 测试: Espresso 与 Compose Test

  • Espresso 适合 View 体系页面.
  • Compose Test 适合声明式 UI, 优先通过 semantic matcher 找节点.
  • UI 测试要覆盖核心路径, 不要把所有边界都堆在 UI 层.
  • 现状定位: Compose UI Test 与 Robolectric 仍是主流工具链, 具体 API 行为以当前版本为准.

可运行示例 (instrumented test): 以下代码放在 src/androidTest. 前提是模块配置 AndroidJUnitRunner, androidx.test.ext:junit, Espresso 依赖, LoginActivity 使用 R.id.login_button, 并用 fake/IdlingResource 消除异步网络. 运行到设备/模拟器后, 预期按钮可见且点击成功; 不能依赖 sleep.

class LoginActivityTest {
    @get:Rule val scenarioRule = ActivityScenarioRule(LoginActivity::class.java)
    @Test fun loginButton_isClickable() {
        onView(withId(R.id.login_button)).check(matches(isDisplayed())).perform(click())
    }
}

可运行示例 (Compose instrumented test): 前提是 Compose UI test 依赖与 AndroidJUnitRunner; 下例内联被测组件, 测试 rule 来自 createComposeRule(). 预期点击后回调被调用一次.

@get:Rule val composeRule = createComposeRule()
@Composable fun SubmitButton(modifier: Modifier = Modifier, onSubmit: () -> Unit) {
    Button(onClick = onSubmit, modifier = modifier) { Text("提交") }
}
@Test fun submit_invokesCallback() { var calls = 0
    composeRule.setContent { SubmitButton(Modifier.testTag("submit")) { calls++ } }
    composeRule.onNodeWithTag("submit").performClick()
    composeRule.runOnIdle { assertThat(calls).isEqualTo(1) }
}

可运行示例 (Robolectric JVM test): 适用于依赖资源或轻量 Android 生命周期的代码, 不证明真机权限弹窗. 启动 Activity 后预期标题资源被正确解析.

@RunWith(RobolectricTestRunner::class)
class MainActivityTest {
    @Test fun title_isShown() {
        val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
        assertThat(activity.title).isEqualTo("首页")
    }
}

六, 可测试性如何反推架构质量

如果一个 ViewModel 很难测, 通常说明它持有太多 Android 依赖, 业务逻辑没有下沉, 状态 / 副作用没有分离.

七, Android 测试 Harness: 让测试稳定, 可重复

中级面试不只问 “会不会写 JUnit”, 更常追问 “为什么你的测试稳定”.核心是把时间, 线程, 数据源和 Android 框架依赖都收口.

MainDispatcherRule 与协程调度

ViewModel 默认使用 Dispatchers.Main, 本地单元测试里没有真实 Main Looper, 需要在测试规则里替换.

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    private val dispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

面试表达: 生产代码不要在业务逻辑里硬编码 Dispatchers.IO/Main, 可以通过 DispatcherProvider 注入, 测试时替换成 TestDispatcher.

Fake 优先于过度 Mock

Mock 适合验证交互, Fake 更适合表达业务状态. Repository/DataSource 可以写内存 fake, 让测试更接近真实流程.

依赖推荐测试替身原因
RepositoryFakeRepository可控制成功/失败/缓存命中
Clock/TimeProviderFakeClock避免真实时间导致 flake
DispatcherTestDispatcher控制协程执行和虚拟时间
Network APIMockWebServer / Fake API验证协议或业务分支

Robolectric vs Instrumentation

类型跑在哪里适合验证不适合
RobolectricJVMViewModel, 资源, 轻量 Android API真机硬件, 厂商 ROM, 系统权限弹窗
Instrumentation设备 / 模拟器UI 交互, 权限, 真实生命周期大量边界逻辑单测

Flaky Test 治理

  • 不用 Thread.sleep 等待异步, 改用虚拟时间, IdlingResource, Compose test clock.
  • UI 测试用稳定语义节点, 不依赖文案频繁变化或 RecyclerView 位置.
  • 每个测试自己准备数据并清理, 不依赖执行顺序.
  • CI 对 flaky 用隔离重跑和标记治理, 不能简单 “失败就 rerun 到绿”.

CI 质量门禁

PR 阶段至少跑快速单元测试, lint 和关键模块测试; 夜间或合并后跑更慢的 UI / 端到端测试. 性能回归用 Macrobenchmark 单独门禁, 不要混在普通单元测试里.

八, 测试类型与 CI 运行条件

类型运行环境主要验证CI 建议
Local unit testJVM纯 Kotlin/Java, UseCase, ReducerPR 必跑, 快速失败
RobolectricJVM + Android shadow资源, 轻量生命周期和 Android API 协作PR 或模块级门禁; 不替代真机
Instrumented test模拟器 / 真机Framework, 权限, 数据库, 进程和设备行为关键路径在合并前或设备农场运行
Compose/Espresso UI模拟器/真机用户交互, 语义, 导航和恢复核心旅程; 控制数量和 flake
Macrobenchmark独立 benchmark module/设备启动, 帧, 滚动等性能分布稳定设备的夜间/发布门禁, 与普通单测分开

测试报告必须说明设备/API, 构建类型, 是否使用 mock/fake, 重试情况和未覆盖项. “Robolectric 通过” 不能证明厂商 ROM, 系统权限弹窗或真实性能正确.

高频面试题

Q1: 你项目里怎么做测试分层? 答: 核心业务规则放单元测试, ViewModel/Repository 做集成测试, 关键用户路径做 UI 测试. 比例上单元测试最多, UI 测试最少.

Q2: 为什么 Repository 不应该直接测 Retrofit/Room? 答: Retrofit/Room 是框架能力, 业务测试应关注 Repository 的缓存, 错误兜底, 数据源切换. 框架集成可少量用 integration test 验证.

Q3: 协程测试为什么不用真实 delay? 答: 真实 delay 会让测试慢且不稳定; runTest 用虚拟时间推进, 可稳定验证超时, 重试, debounce 等逻辑.

Q4: 怎么让 Android 测试稳定不 flaky? 答: 把不可控因素收口. 协程用 runTest 和 TestDispatcher, 时间用 FakeClock, 网络用 Fake/MockWebServer, UI 用稳定 semantic matcher 或 IdlingResource, 测试数据每次独立准备. CI 上把快测和慢测分层, 失败要定位原因, 不能靠无限重跑掩盖问题.

易错点 / 追问

  • 不要为了覆盖率 mock 所有东西, 那会测到实现细节.
  • UI 测试不要依赖真实网络和随机数据.
  • Compose 测试要给关键节点加稳定语义, 否则 matcher 容易脆弱.

组件化路由与模块通信

组件化不是把包拆成很多 module, 而是让业务边界, 依赖方向和发布协作更清晰. 中高级面试常追问路由, 通信和依赖治理.

一, 组件化解决什么问题

问题组件化目标
业务耦合feature 边界清晰
编译慢模块增量构建
多团队协作独立开发 / 测试
依赖混乱依赖方向可控

二, 模块拆分方式

  • app 壳工程: 组装入口.
  • feature module: 业务功能.
  • core/common: 基础能力.
  • api/contract: 跨模块接口.

依赖方向应从上层依赖抽象, 避免 feature 之间直接互相依赖.

三, 路由框架原理 (以 ARouter 类框架理解)

核心流程: 编译期扫描注解 → APT/KSP 生成路由表 → 运行时按 path 查找目标 → 拦截器处理登录/权限/降级 → 跳转或返回服务实例.

APT/KSP 生成表示意 (伪代码, 仅解释生成物, 不声称可编译):

@Route(path = "/user/profile") ProfileActivity
          -> processor scans feature-user
          -> generated RouteTable { "/user/profile" -> ProfileActivity::class }
          -> app 打包 feature-user-impl 的生成表,并在启动时显式注册;若框架用类路径发现,则需保证生成表未被裁剪

演进提示: ARouter 官方维护已趋缓 (最后一次实质更新约 2021 年前后), 社区有迁移 AndroidX / 适配新 AGP 的维护 fork. 新一代方案 (KRouter 等) 多转向 KSP 编译期生成路由表, 强调类型安全与减少运行期反射 / 扫描; 各方案现状以社区 / 官方最新文档为准, 面试讲清选型理由即可.

工程权衡: 路由表规模与冷启动注册耗时成正比, 路由表越大启动扫描 / 注册成本越高, 大项目需懒加载或异步注册, 避免阻塞冷启动.

interface UserService {
    fun currentUserId(): String?
}

跨模块不要直接调用实现类, 而是依赖 UserService 这样的 contract.

四, 模块通信方式

方式适用风险
路由跳转页面级跳转path 字符串错误
接口下沉同步能力调用contract 膨胀
事件总线广播式通知难追踪, 生命周期问题
Result API页面结果回传复杂链路难维护

五, 路由拦截, 降级与灰度

登录校验, 权限校验, A/B 实验, 页面不存在降级都适合放在固定顺序的路由 pipeline 中: 规范化参数 -> 登录 -> 权限/风控 -> 实验/动态模块 -> 最终 navigator. 路由失败必须有兜底, 不能直接 crash.

上下文片段: 这是与具体路由库无关的顺序 pipeline 伪代码 (不是带 proceed() 的责任链).每个步骤可变换 request 或短路返回; 预期: 未登录访问支付页时 LoginInterceptor 返回登录路由, 后续步骤和最终 navigator 不执行; 通过所有检查后才由 navigator 负责实际跳转.

interface RouteInterceptor { fun intercept(request: RouteRequest): RouteResult }
fun dispatch(request: RouteRequest, pipeline: List<RouteInterceptor>, navigator: Navigator): RouteResult {
    var current = request
    for (interceptor in pipeline) {
        val result = interceptor.intercept(current)
        if (result !is RouteResult.Continue) return result
        current = result.request
    }
    return navigator.navigate(current)
}

失败排查: 症状是页面未打开; 证据记录 path, 参数 schema, 拦截器名称和结果; 先区分未注册, 参数非法, 登录 / 权限拒绝和动态模块未安装; 修复为注册生成表, 校验 contract 或提供降级; 最后覆盖每条拦截分支与目标跳转.

六, 和 Gradle 工程化的关系

组件化设计关注业务边界; Gradle 工程化关注构建配置, 依赖版本, 产物和性能. 两者相关但不是一回事.

七, 现代组件化治理: 契约, 构建逻辑与类型安全路由

面试官往往不满足于 “用了 ARouter”, 会继续追问模块怎么拆, 依赖怎么防腐, 路由怎么治理.

依赖方向

下图中的箭头表示 Gradle 依赖方向: app 负责组装实现模块, feature-*-impl 依赖自身 feature-*-api, 其他 feature 只能依赖 API 契约, 不能依赖实现模块.

app
 ├─ feature-home-impl  ──> feature-home-api
 ├─ feature-pay-impl   ──> feature-pay-api
 └─ core-ui / core-network / core-common

从单体迁移时可分三步: 先在单模块内抽出 feature API 和实现包; 再移动实现到 feature module, 让 app 组装; 最后用 CI 检查 feature 不反向依赖 app 或其他 feature 的 impl. 正确依赖图如下 (箭头为 Gradle 依赖):

app -> feature-user-impl -> feature-user-api
app -> feature-pay-impl  -> feature-pay-api
feature-user-impl -> core-*
feature-pay-impl  -> core-*
other feature -> feature-*-api (never feature-*-impl)

单体到组件化的迁移图如下 (箭头为 Gradle 依赖):

before: app(ui + user + pay + data)
after:  app -> feature-user-impl -> feature-user-api
        app -> feature-pay-impl  -> feature-pay-api
        other feature -> feature-*-api
        feature-* -> core-common / core-ui / core-network
  • api/contract 模块只放接口, 路由契约, DTO, 不放具体页面和重业务逻辑.
  • impl 模块实现页面和业务, 依赖自己的 api 与公共 core.
  • feature 之间不直接依赖实现, 避免循环依赖和 “改 A 编译半个项目”.

Convention Plugin 与 Version Catalog

组件化后如果每个 module 都复制 Android/Kotlin/Compose 配置, 会迅速失控. 更好的做法是把公共配置沉淀到 Gradle convention plugin, 并用 Version Catalog 管理依赖版本.

治理对象推荐方式面试表达
compileSdk/minSdk/Kotlin 版本convention plugin全项目一致, 减少漂移
依赖版本version catalog可审计, 可统一升级
lint/ktlint/测试配置convention plugin新模块默认接入质量门禁
feature 开关配置中心 / 构建变体支持灰度和降级

类型安全路由契约

字符串 path 容易写错, 参数缺失难发现. 可以在 contract 层定义类型安全入口:

data class PayRoute(val orderId: String, val source: String)

interface PayNavigator {
    fun openPay(route: PayRoute)
}

底层可以仍然用路由框架, 但上层业务依赖的是类型化 contract, 而不是到处拼 "/pay/detail?orderId=...".

Dynamic Feature / 插件化边界

动态交付适合低频, 体积大的功能, 但会带来下载失败, 版本兼容, 冷启动和路由降级问题. 面试中要强调: 动态化不是为了炫技, 而是为了控制包体积, 灰度风险或业务隔离.

路由治理清单

  1. path 命名规范和归属人.
  2. 参数 schema 与必填校验.
  3. 未命中, 版本不兼容, 未安装动态模块时的降级页.
  4. 登录/权限/风控拦截器顺序.
  5. 路由耗时, 失败率, 来源页面埋点.

编译期路由与 API 稳定性

评估路由方案不能只比较运行时反射能力. 可使用 KSP/注解生成编译期路由表, 但要评估增量构建, 错误提示和跨模块 API 演进. Dynamic Feature 取决于分发渠道和安装状态. 组件 API 应有 owner, 版本/废弃周期和依赖可视化, CI 阻止反向依赖与循环依赖; “彻底解耦” 不是目标, 可理解且可演进的契约才是目标.

高频面试题

Q1: 组件化和模块化区别? 答: 模块化偏代码物理拆分, 组件化更强调业务组件独立开发, 独立测试, 按契约通信和可组装.

Q2: ARouter 这类框架大概怎么实现? 答: 编译期注解处理生成路由表, 运行时初始化加载, 按 path 找目标 class/provider, 再通过拦截器处理统一逻辑.

Q3: 跨模块通信为什么不直接依赖实现? 答: 直接依赖会导致 feature 耦合, 编译依赖变重, 循环依赖风险; 接口下沉能保持依赖方向稳定.

Q4: 组件化项目怎么避免 Gradle 配置复制和依赖版本失控? 答: 把公共 Android/Kotlin/Compose/lint/test 配置沉淀为 convention plugin, 新模块默认套用; 依赖版本放 Version Catalog 或统一依赖管理. 这样既减少重复, 也能在升级 compileSdk, Kotlin, AGP 时集中治理.

易错点 / 追问

  • 不要为了组件化制造过多小 module.
  • 不要把所有通信都塞进 EventBus.
  • 路由 path 要集中治理, 否则重构时容易断链.

Android 系统原理

这一篇偏底层; 如果你已有操作系统或并发基础, 理解会更顺畅. 面试常问 Handler, Binder, 启动流程, 应结合自己的基础和项目经验组织回答.

一, Handler / Looper / MessageQueue

Android 的线程间通信与主线程消息循环核心机制.

  • Looper: 每个线程最多一个 Looper, 内部持有 MessageQueue, loop() 死循环不断取消息分发. 主线程的 Looper 由系统在 ActivityThread.main 中创建.
  • MessageQueue: 消息队列, 按时间排序的单链表; next() 取消息, 无消息时通过 epoll 阻塞 (不耗 CPU).
  • Handler: 发送 (sendMessage/post) 和处理 (handleMessage) 消息; Message 持有 target (发送它的 Handler).
  • ThreadLocal: Looper 通过 ThreadLocal 与线程绑定, 保证一个线程一个 Looper.

子线程 Looper 的创建与退出

子线程默认没有 Looper, 需先 Looper.prepare() 把 Looper 放入当前线程的 ThreadLocal, 创建绑定它的 Handler, 再 loop() 持续分发. 无参构造 Handler() 自 API 30 起已废弃, 应显式传 Handler(looper) 或 Handler(looper, callback). 退出用 quit() (立即丢弃未处理消息) 或 quitSafely() (处理已到期消息后退出); 不要创建没有退出策略的常驻 Looper. 工程上更常用 HandlerThread, 用法与生命周期见 多线程并发专题 - Android 线程模型与 HandlerThread.

// 示意代码: 生命周期 owner 应保存 handler, 在任务完成或销毁时发送 STOP.
Thread {
    Looper.prepare()
    val handler = Handler(Looper.myLooper()!!) { message ->
        when (message.what) {
            1 -> {
                refreshIndex(message.obj as String)
                true
            }
            2 -> {
                Looper.myLooper()?.quitSafely()
                true
            }
            else -> false
        }
    }
    handler.sendMessage(handler.obtainMessage(1, "books"))
    handler.sendEmptyMessage(2) // STOP: 处理已到期消息后退出
    Looper.loop()
}.start()

post 与 sendMessage 的职责

API队列载荷与分发适用场景错误选型后果
Handler.post(Runnable)Runnable 作为回调入队, 运行时直接执行该闭包一次性 UI 更新或明确任务用闭包携带复杂协议会难以取消, 分类和追踪
Handler.sendMessage(Message)Message.what 用于类别, obj/Bundle 携带数据, 最终到 handleMessage 或 Callback多类事件需要统一分发, 延迟和移除把大对象或 Activity 放入 obj 会延长引用生命周期并增加泄漏风险

二者都只是在目标 Looper 所在线程执行, 不自动切换到后台. Handler.post 是消息队列投递, 而 View.post 还依附于 View 的 attach 状态; 后者不等同于 “布局已完成”, 布局时机见 UI 体系 - View 与自定义 View.

UI 线程亲和性

View 层级, Window 和大多数 UI toolkit API 都由创建它们的主线程拥有. 子线程直接修改 View 会破坏遍历和事件分发的串行假设, 通常抛出 CalledFromWrongThreadException, 即使偶发未立即失败也属于竞态. 将结果投递回主线程只是最后一步; 若页面已经停止或 View 已销毁, 仍应丢弃过期渲染或通过生命周期感知的 state 收集处理.

为什么主线程死循环不卡死 / 不 ANR? loop () 是死循环, 但无消息时阻塞在 epoll_wait 让出 CPU, 有事件 (触摸, 绘制) 再唤醒. ANR 是消息处理太久, 不是循环本身.

进阶:

  • 同步屏障 (Sync Barrier): 插入屏障后, 同步消息被拦截, 优先处理异步消息 (如 UI 绘制 doFrame), 保证及时刷新.
  • IdleHandler: 队列空闲时回调, 可做延迟初始化 (不阻塞关键路径).

二, Binder 机制

Android 跨进程通信 (IPC) 的核心. 相比管道 / socket 的多次拷贝, Binder 通过 mmap 映射实现一次拷贝, 且内核自动附带调用方 UID/PID 便于安全鉴权. 结构上是 Client, Server, ServiceManager (服务注册查找), Binder 驱动; Client 拿到的是 BinderProxy, AIDL 自动生成 Stub/Proxy 代码.

一次拷贝简图

发送进程用户空间                 内核 / Binder 驱动              接收进程用户空间
Parcel 数据 -- 拷贝一次 --> binder_buffer -- mmap 同一页 --> Binder 线程读取 Parcel

发送方 Parcel 拷贝到驱动管理的 binder_buffer 是一次拷贝, 接收方经预先 mmap 的映射区直接读取, 省去一次显式拷贝. 它是 “一次拷贝, 不是零拷贝”, 大数据应走共享内存 / 文件描述符; 实现层 (mmap, binder_alloc, 线程池上限) 见 Binder 与 IPC 深入 - 一次拷贝模型与 mmap.

一次 Binder 调用怎么走

Client 的 Proxy 把参数写入 Parcel, 经驱动按 handle 路由到目标进程, 由 Binder 线程池回调 Stub 的 onTransact() 执行并回写 reply; 同步调用阻塞调用线程, oneway 不等待. 调用链见 Binder 与 IPC 深入 - AIDL 调用链, 线程池 / 死锁风险见 Binder 与 IPC 深入 - Binder 线程池与调用风险.

常见追问: 真零拷贝? 不是, 一次拷贝; 能传多大? 约 1MB 且并发共享, 见 TransactionTooLargeException 与 Parcelable 边界; 为何死锁? 同步调用持锁或主线程发 Binder 易锁等待 / ANR; AIDL 与 Binder? AIDL 是代码生成器, 底层仍走驱动. 完整问答见 Binder 与 IPC 深入 - 高频面试题.

三, 应用启动流程

点击图标到界面显示的大致链路: Launcher 经 Binder 请求 ATMS → 目标进程不存在时 Zygote fork 出应用进程, 执行 ActivityThread.main() 准备主线程 Looper → 进程经 Binder attachApplication, 系统回调创建 Application 与目标 Activity, 走 onCreate → onResume, ViewRootImpl 触发首帧. Zygote 是所有应用进程的 “母体”, 预加载核心类和资源, fork 时通过 COW (写时复制) 共享加速启动. 完整链路与源码细节见 Framework 源码与进程生命周期 - Activity 启动完整链路.

冷启动拆解与可优化点

阶段面试可讲的优化边界
进程创建 (Zygote fork)App 无法直接优化 fork 本身, 重点是减少 fork 后主线程工作.
ActivityThread.main()主线程消息循环不是卡顿来源, 卡顿来自消息处理超时.
bindApplication收敛 ContentProvider 自启动, 延迟 / 按需初始化 SDK.
Activity 首帧减少首屏布局层级和同步 IO, 用 Perfetto/Startup Timing 量化.

回答启动流程时要区分公开生命周期和系统实现细节: 对业务开发者稳定的是 Application/Activity 生命周期与进程可能被系统创建/杀死; AMS/ATMS, Zygote 命令格式, 预加载列表属于实现层面, 可作为理解但不宜写成永久不变的 API. 优化深度见 启动优化专项.

四, 类加载与热修复 / 插件化

  • 类加载器: PathClassLoader(加载已安装 APK), DexClassLoader(可加载外部 dex/jar, 热修复/插件化基础).它们持有 DexPathList, 内部是 dexElements 数组.
  • 热修复原理: 把修复后的补丁 dex 插到 dexElements 数组最前面, 根据双亲委派 + 数组顺序查找, 补丁类先被加载, 从而 “覆盖” 有 bug 的旧类 (如 Tinker, QFix). AndFix 则走 native 方法替换.
  • 插件化: 动态加载未安装的 APK, 需解决类加载, 资源加载 (AssetManager.addAssetPath), 组件生命周期 (Hook AMS / 占坑 Activity) 三大问题.

热修复方案对比

方案核心机制优点风险 / 边界
类加载补丁反射拿到宿主 ClassLoader 的 pathList.dexElements, 把补丁 dex 的 element 前插不直接改 ART 方法结构, 兼容性相对好; 适合 Java/Kotlin 逻辑修复已加载类通常不能被重新定义, 常需要冷启动生效; 受 multidex, 混淆, 校验和厂商改动影响.
native 方法替换修改 ART/Dalvik 方法入口或 native bridge, 让旧方法跳到新实现可做到即时生效的效果强依赖运行时内部结构, Android 版本/厂商 ROM 兼容风险高, 安全合规要求更高.
资源补丁构造新的 AssetManager/Resources, 通过 addAssetPath 或等价实现加入补丁资源包可修复图片, 布局, 字符串等资源资源 ID, 主题, 缓存对象可能已被解析; 高版本隐藏 API 限制和厂商差异需评估.

dexElements 前插流程可以按四步讲: 下载补丁并校验签名 / 版本 → 用 DexClassLoader 加载补丁 dex → 反射合并补丁与宿主的 dexElements → 重启进程或在安全时机让新类优先命中. 重点不是 “反射代码背诵”, 而是说明类查找顺序改变.

插件化三大难点

难点常见做法面试边界
类加载为插件创建独立 DexClassLoader, 或把插件 dex 合入宿主 ClassLoader独立加载隔离性好但共享类 / 类型转换要小心; 合并加载简单但冲突风险高.
资源加载为插件创建 Resources, 把插件 apk 路径加入 AssetManager, 再处理主题 / 资源 IDaddAssetPath 属实现层面方案, 隐藏 API/资源缓存/AssetLoader 演进会影响兼容.
组件生命周期manifest 未注册的 Activity/Service 不能直接被系统识别, 常用 “宿主占坑组件 + Hook Intent/Instrumentation/AMS 调度”Hook 系统调用链风险高, 受 Android 版本和厂商 ROM 影响; 线上要有降级与灰度.

占坑思路: 宿主 manifest 预注册一个透明 / 通用 Activity, 启动插件 Activity 前把 Intent 替换成占坑 Activity; 系统校验通过后, 在回调到应用侧时再把真实插件 Intent 换回来, 由插件框架接管生命周期与资源. 这个概念能说明 “为什么插件 Activity 不在 manifest 也能跑”, 但面试中要补一句: 这不是官方组件模型, 兼容性和合规风险需要框架兜底.

工程风险清单:

  • 补丁必须做签名, 版本, 灰度, 回滚, 不能把远端 dex 加载当成无约束能力.
  • 反射/隐藏 API 在高版本可能受限制, 厂商 ROM 对 ClassLoader/Resources 实现也可能不同.
  • native 替换与组件 Hook 改动运行时内部结构, 要用设备矩阵验证, 不要承诺 “所有版本通用”.
  • 热修复适合止血, 根因仍要通过正常发版修复; 插件化适合业务动态化, 不应绕过系统权限和安装模型.

Android 9 非 SDK 接口限制: 面试回答与工程边界

Android 9/API 28 起, 平台限制应用访问非 SDK 接口, 即未作为公开 Android SDK 契约提供的 framework 类, 方法和字段. 限制会随 Android 版本, targetSdk 与非 SDK 名单演进: 较低 targetSdk 在部分版本上可能仅收到警告或适用较宽的兼容行为, targetSdk 提升或名单收紧后则可能被阻止. 因此不能把某一设备或版本上反射成功当作长期兼容承诺.

面试若原题问 “如何绕过限制”, 应先纠正前提: 反射, 修改豁免名单或依赖第三方绕过都不是可维护方案, 也不应作为应用交付路径, 本章不提供规避代码. 先查找公开 SDK API, AndroidX 组件或有文档的系统服务契约; 无法替代时, 将最小依赖隔离在版本 / ROM 适配层, 做能力检测, 准备可用功能降级, 通过灰度和监控观察失败率, 并为移除该依赖制定版本化计划. 插件化和热修复的完整工程边界见 插件化, 热修复与动态化.

五, 进程间通信方式对比

方式特点场景
Binder一次拷贝, 安全, 面向对象Android 主流 IPC, AIDL
Socket通用, 两次拷贝, 慢跨设备, Zygote 通信
共享内存零拷贝, 最快, 需同步大数据 (匿名共享内存 Ashmem)
管道 / 消息队列传统 Linux IPC较少用
文件 / ContentProvider简单, 慢持久化数据共享

章节边界与源码版本

本章讲系统总览; 进程生命周期细节见 Framework 源码与进程生命周期, IPC 见 Binder 与 IPC 深入, 通用 OS 基础见操作系统与数据库基础. 描述 ActivityThread, AMS/ATMS, Zygote, LMKD 等内部实现时注明 Android 版本/AOSP tag; 内部类名和调用链不是稳定 SDK 契约.

高频面试题

Q1: 主线程 Looper 死循环为什么不会卡死 App? loop () 是死循环, 但 MessageQueue 无消息时通过 epoll 阻塞, 让出 CPU 不占用资源; 有消息 (触摸 / 绘制等) 再唤醒处理. ANR 是单条消息处理太久, 不是循环本身导致.

Q2: Handler 内存泄漏怎么产生? 如何避免? 非静态内部类 Handler 隐式持有外部 Activity, 若有延迟消息未处理, Activity 无法回收. 解决: 静态内部类 + 弱引用, 或在 onDestroy 中 removeCallbacksAndMessages (null).

Q3: Binder 为什么只需一次拷贝? 发送方 Parcel 拷贝到驱动 binder_buffer 一次, 接收方经 mmap 映射区读取, 省去一次显式拷贝; 它是 “一次拷贝, 不是零拷贝”, 且约 1MB 并发共享限制. 细节与边界见 Binder 与 IPC 深入.

Q4: 为什么用 Binder 而不是传统 IPC? 性能上一次拷贝优于管道/socket 的两次, 安全上内核自动附带调用方 UID/PID 支持鉴权. 详见 Binder 与 IPC 深入.

Q5: Zygote 的作用? 为什么用 fork? Zygote 预加载核心类库和资源, 作为所有应用进程的母体. fork 通过写时复制共享已加载内容, 加速进程创建, 减少内存占用 (避免每个进程重复加载 framework).

Q6: 热修复的基本原理? 利用类加载机制, 把补丁 dex 插入 ClassLoader 的 dexElements 数组前面, 使补丁类优先于有 bug 的旧类被加载. 属于 “类加载方案”, 通常需要重启或确保旧类尚未加载; 另有 native 方法替换方案, 但强依赖 ART 内部结构, 兼容风险更高.

Q7: ThreadLocal 在 Looper 里起什么作用? 保证一个线程只有一个 Looper. Looper.prepare 把 Looper 存入 ThreadLocal, 各线程独立互不干扰, getMainLooper 拿主线程的.

实践补充: 消息, 线程与启动时序

机制伪代码, 不能直接编译: Handler.post 把带 target 的 Message 入队; Looper.loop() 从 MessageQueue.next() 取消息 (空队列时 epoll 等待), 再经 target.dispatchMessage() 调用 Runnable 或 handleMessage(). 异步消息不等于后台线程, 主线程 Handler 的 Runnable 仍在主线程运行; 应用不应依赖隐藏 API 手工插同步屏障.

上下文片段 (放在 Activity): Activity 是该 HandlerThread 的生命周期 owner: 创建时启动, 销毁时调用 quitSafely(). Handler(worker.looper) 显式绑定 worker 的 Looper, 不使用已废弃的无参构造; 用法详见 多线程并发专题 - Android 线程模型与 HandlerThread.

private lateinit var worker: HandlerThread
override fun onCreate(state: Bundle?) { super.onCreate(state); worker = HandlerThread("thumbnail-worker").apply { start() }; Handler(worker.looper).post { loadThumbnailIndex() } }
override fun onDestroy() { worker.quitSafely(); super.onDestroy() }

预期是任务不占 UI 线程; 若 loadThumbnailIndex 长时间 I/O, 后续消息会排队. 症状是同一 worker 任务不执行, 证据是线程 dump 卡在 I/O; 修复为超时或有限并发执行器, 重复路径确认队列恢复.

Launcher -> ATMS/AMS (Binder) -> Zygote fork -> app ActivityThread.main
app -> AMS: attachApplication (Binder) -> ApplicationThread callback
-> ActivityThread main Handler -> 生命周期/首帧 -> SurfaceFlinger 合成

COW (写时复制) 是 fork 后先共享物理页, 写入时才复制该页的机制. 此图是 AOSP 心智模型, 具体类名和事务随版本变化. 启动测量见启动优化专项, AIDL 实现见 Binder 与 IPC 深入.

Android Framework 源码与进程生命周期

本篇承接四大组件, View 和 Binder 章节, 把系统服务, Activity 启动, Window 显示, 应用安装和进程死亡串成一条链. 面试重点不是背某个 Android 版本的私有方法名, 而是说明跨进程边界, 线程切换, 状态所有权和版本边界.

一, Android 系统启动与核心角色

从 init 到应用进程

Linux kernel
  -> init
     -> servicemanager / native services
     -> Zygote
        -> system_server
        -> application process
  • init: 用户空间第一个进程, 解析 rc 配置并启动 Zygote, ServiceManager 和部分 native 服务.
  • ServiceManager: Binder 服务注册与查询中心. 系统服务把 Binder 入口注册进去, Client 按名称取得代理.
  • Zygote: 预加载常用类和资源, 通过 fork 创建 system_server 与应用进程, 利用写时复制减少启动成本.
  • system_server: 承载 AMS, ATMS, WMS, PMS 等大量 Java 系统服务.
  • 应用进程: 从 ActivityThread.main() 建立主线程 Looper, 再通过 Binder 与 system_server 协作.

核心系统服务怎么分工

服务主要职责高频追问
AMS进程, Service, Broadcast, Provider 等运行管理谁创建进程, 谁维护进程状态
ATMSActivity 与 Task 调度, 启动模式和回退栈Activity 启动和任务栈如何变化
WMSWindow token, 层级, 焦点, 输入和 Surface 协调Window 如何跨进程加入屏幕
PMSAPK/Manifest 解析, 组件和权限信息, 安装更新点击图标前系统如何知道组件
SurfaceFlinger合成各应用和系统提交的图层View draw 后为什么还没有直接显示

Android 10 前后部分职责从 AMS 拆分到 ATMS. 面试可按当前架构区分职责, 同时说明旧资料可能仍把 Activity 调度统称为 AMS.

二, Activity 启动完整链路

从点击图标到目标进程

  1. Launcher 根据 PMS 返回的可启动组件构造 Intent.
  2. startActivity() 最终通过 Binder 请求 ATMS 启动目标 Activity.
  3. ATMS 解析 Intent, 权限, 启动模式, taskAffinity 和 Intent flags, 决定复用哪个 Task/Activity 或创建新实例.
  4. 如果目标进程不存在, system_server 请求 Zygote fork 应用进程.
  5. 新进程进入 ActivityThread.main(), 准备主线程 Looper, 创建 ActivityThread 并向 AMS 执行 attachApplication.
  6. system_server 通过应用侧的 Binder 入口下发生命周期事务, 应用主线程处理事务并创建目标组件.

应用进程内如何创建 Activity

现代 Android 使用 ClientTransaction 及一组 transaction item 描述启动和生命周期变更. 可以用下面的稳定心智模型回答:

system_server
  -> ApplicationThread Binder callback
     -> ActivityThread main Handler
        -> create/load Activity
           -> Instrumentation.newActivity
           -> Activity.attach
           -> Instrumentation.callActivityOnCreate
           -> onStart / onResume
  • ActivityThread: 应用进程主线程的管理者, 负责调度组件和生命周期, 它本身不是 Thread 子类.
  • ApplicationThread: 应用暴露给 system_server 的 Binder 入口, 接收系统调度.
  • Instrumentation: 参与 Activity 创建和生命周期调用, 测试框架也利用它观察或替换组件行为.
  • LoadedApk/ClassLoader: 提供 APK 的类, 资源和组件信息.
  • ClientTransaction: 把启动, 暂停, 恢复等事务统一下发; 私有 item 名称会随版本演进, 不应当作公开 API 背诵.

启动模式到底在哪里生效

启动模式不是 Activity 自己在 onCreate() 里决定的. ATMS 在创建实例前根据当前 Task, Manifest 配置和 Intent flags 计算启动结果:

  • singleTop: 目标已在当前栈顶时复用, 回调 onNewIntent().
  • singleTask: 寻找合适任务中的既有实例, 常伴随清理其上的 Activity.
  • FLAG_ACTIVITY_NEW_TASK: 根据调用方和 affinity 寻找或创建 Task.
  • FLAG_ACTIVITY_CLEAR_TOP: 目标存在时清除它上方实例, 是否复用还要结合启动模式和其他 flag.

面试画任务栈时要同时写出原栈, Intent flag, 最终栈和回调, 不能只背四种 launchMode 定义.

三, Window 与一帧如何显示

Activity, Window 和 View 的对象关系

Activity
  -> PhoneWindow
     -> DecorView
        -> content View tree

WindowManagerImpl
  -> WindowManagerGlobal
     -> ViewRootImpl
        -> IWindowSession / WMS
  • Activity 在 attach 阶段创建 PhoneWindow.
  • setContentView() 把业务布局放进 DecorView 的 content 区域.
  • WindowManager 把 DecorView 加入窗口系统, 应用侧为这棵 View 树创建 ViewRootImpl.
  • ViewRootImpl 通过 Binder 与 WMS 的 Session 通信, 申请窗口, Surface, 焦点和输入相关资源.

Window 是窗口能力抽象, DecorView 是 View 树根节点, ViewRootImpl 是应用侧 View 树与系统窗口服务之间的桥梁. ViewRootImpl 不是 DecorView 的父 View.

从 requestLayout 到屏幕像素

  1. View 请求布局或重绘, ViewRootImpl 安排下一次 traversal.
  2. Choreographer 接收 VSync, 在主线程执行 input, animation, traversal 等阶段.
  3. 主线程完成 measure/layout, 并记录绘制命令或更新渲染节点.
  4. RenderThread/GPU 在硬件加速路径处理渲染命令, 把结果写入 BufferQueue 对应的图形缓冲.
  5. SurfaceFlinger 收集各窗口图层, 结合 Z-order, 变换和硬件合成能力生成最终画面.
  6. 显示系统在合适的刷新时序把合成结果送到屏幕.

掉帧不只可能发生在 onDraw(). 主线程布局, RenderThread, 纹理上传, GPU, SurfaceFlinger 合成和系统调度都可能超过帧预算, 所以要用 Perfetto/Frame Timeline 确认瓶颈位置.

高刷新率下的帧预算

刷新率单帧理论预算
60Hz约 16.7ms
90Hz约 11.1ms
120Hz约 8.3ms

16.7ms 不是所有设备的固定标准. 面试应回答 “预算由当前刷新率和系统调度决定”, 并用 Frame Timeline 的实际 deadline 判断 jank.

BufferQueue, 缓冲与 VSync

BufferQueue 是生产者 (应用渲染侧) 与消费者 (通常是 SurfaceFlinger) 交换图形 buffer 的队列:

Producer(应用):       VSync -> dequeueBuffer -> 绘制 -> queueBuffer
BufferQueue(队列):              空闲 buffer <- 就绪 buffer <- 被 Consumer 获取
Consumer(SurfaceFlinger): VSync -> acquireBuffer -> 合成 -> present/release

应用渲染与 SurfaceFlinger 合成是异步节拍: Producer 的 queueBuffer 不保证被同一个 VSync 的 Consumer 合成, 队列可能等待下一次消费. 双缓冲可理解为显示和绘制各占一个 buffer; 三缓冲多保留一个就绪 buffer, 能减少生产者等待但可能增加排队延迟和内存. 它们是教学模型, 不是每台设备固定配置; 应以 Frame Timeline 的实际 deadline 判断 jank.

四, PMS 与应用安装 / 更新

系统为什么能找到 Activity

PMS 维护已安装包, 组件, IntentFilter, 权限, 签名和版本等信息. Launcher 查询可启动 Activity, ATMS 解析显式 / 隐式 Intent, 系统校验 exported 和 permission 都依赖这份包信息.

安装链路的稳定模型

PackageInstaller session
  -> 校验安装请求和包结构
  -> 复制/准备 APK 与 native library
  -> 解析 Manifest 和组件
  -> 校验签名,版本和权限
  -> 注册包信息并准备 dex 优化
  -> 发送包变更通知
  • 覆盖安装通常要求 applicationId 一致, 签名兼容且 versionCode 满足系统策略.
  • APK Signature Scheme v2/v3/v4 覆盖范围和增量安装能力不同, 但业务面试重点是 “签名保护身份和完整性, 更新必须维持签名连续性”.
  • DEX 优化会随 Android 版本, 安装方式, 设备状态和 ART Service 策略变化, 不要把 dex2oat 的某个命令流程说成所有版本都固定.
  • 四大组件, Provider authority, 权限和 exported 配置在安装 / 扫描阶段进入系统可查询的数据结构.

五, 进程优先级与 LMK

Android 会根据组件状态和用户可见性调整进程重要性, 再由系统内存管理与 lmkd 在压力下选择回收目标.

常见层级例子被回收风险
前台进程顶部 Activity, 执行关键前台回调最低
可见进程Activity 可见但未获得焦点较低
服务进程执行受系统认可的 Service 工作中等
缓存进程没有活跃组件, 仅为下次启动缓存最高

进程被杀时通常不会给应用可靠的 Application.onTerminate() 或完整清理回调. 关键数据必须在业务操作时持久化, 不能把希望寄托在 “被杀前保存”.

进程优先级不是固定五档枚举实现. Android Framework 内部会计算进程 adj, 再最终映射为内核暴露的 oom_score_adj; 旧资料中的 oom_adj 是历史接口 / 术语, 不能与 oom_score_adj 混称. 系统还会结合组件关系, 前台服务, 厂商策略和实时内存压力计算, 面试应使用层级模型而不是承诺固定阈值.

oom_adj 与任务栈推演

oom_score_adj 是内核 OOM 选择使用的调整值, 数值通常越大越易被杀. 以 Android 10+ AOSP 的概念性区间示例, 前台/可见/服务/缓存常见值约在 0, 100, 500, 900 一带; 这不是 API 或设备承诺. 应在目标设备读取 adb shell cat /proc/<pid>/oom_score_adj, 并结合 dumpsys activity processes 验证.

假设性演练, 非设备实测: Task 为 [A, B], B 在顶端; 从 B 启动 B 且 B 为 singleTop, 最终仍为 [A, B], 既有 B 收到 onNewIntent(). 从 B 用 CLEAR_TOP 启动 A 会清除 A 之上的 B, A 是否复用还受启动模式影响. 推演先写初始栈, 调用方, manifest/flags, 再写最终栈和回调, 不能把 singleTask 说成永远全局唯一.

六, 配置变更, 进程死亡与状态恢复

三类 “重建” 必须分开

场景进程Activity 实例ViewModelSavedState持久化数据
旋转等配置变更通常保留重建同 owner 可保留可恢复保留
后台进程被杀后返回已重建重建旧实例丢失系统保留的少量状态可恢复保留
用户返回键退出 / 清除业务可能保留销毁清除不应依赖自动恢复按业务决定
  • ViewModel: 解决配置变更期间的内存状态保留, 不能跨进程死亡保存实例.
  • SavedStateHandle/Bundle: 保存少量可序列化 UI 状态, 受 Binder/Bundle 大小与恢复时机约束.
  • Room/DataStore/文件: 保存必须跨进程和设备重启存在的数据.
  • Repository: 决定恢复时从网络, 本地数据库或缓存重建页面状态.

推荐把页面状态拆成三层:

  1. 可由业务数据重新计算的派生 UI 状态, 不持久化.
  2. 当前 tab, 输入草稿, 滚动锚点等轻量状态, 使用 SavedState.
  3. 订单, 消息, 上传进度等关键状态, 持久化并设计幂等恢复.

七, Framework 源码题的答题方法

  1. 先说业务入口和最终结果.
  2. 标出进程边界: 应用进程, system_server, Zygote/SurfaceFlinger.
  3. 标出 Binder 调用和线程切换: Binder 线程还是应用主线程.
  4. 说清状态归属: Task 在系统侧, View 树在应用侧, 图层由 SurfaceFlinger 合成.
  5. 最后补版本边界, 不把私有类名和方法名说成稳定 API.

帧预算统一口径

一帧 16.7 ms 仅是 60 Hz 示例, 不能作为固定性能标准. View/Compose/动画章节统一用实际显示刷新率, FrameTimeline 和用户可见 jank 证据说明 deadline; 详细排查见 ANR 与卡顿排查. Framework 调用链应标注所读 AOSP tag, 不把内部实现当稳定 API.

高频面试题

Q1: ActivityThread 是线程吗? 不是. 它是应用进程主线程的组件调度管理者, 真正的主线程从 ActivityThread.main() 启动并进入 Looper. ApplicationThread 则是提供给 system_server 的 Binder 回调入口.

Q2: AMS 和 ATMS 有什么区别? AMS 更关注进程以及 Service, Broadcast, Provider 等运行管理; ATMS 从 AMS 中拆出, 专门处理 Activity, Task, 启动模式和窗口容器相关调度. 旧资料可能把两者统称为 AMS 链路.

Q3: Activity 的生命周期是谁调用的? system_server 决定生命周期事务, 通过应用侧 Binder 入口下发; ActivityThread 在应用主线程处理事务, 并通过 Instrumentation 调用 Activity 的生命周期方法.

Q4: ViewRootImpl 是 View 吗? 是 DecorView 的父节点吗? 都不是. ViewRootImpl 是应用侧 View 树与 WindowManager/WMS 之间的桥梁, 驱动 traversal, 输入和 Surface 协作; DecorView 仍是 View 树的根 View.

Q5: View 调用 draw 后为什么还没有直接显示到屏幕? 应用生成绘制命令并把缓冲提交到 Surface, SurfaceFlinger 还要把多个窗口图层按层级和变换合成, 再交给显示系统. 主线程, RenderThread, GPU 和合成都可能影响一帧.

Q6: 进程被系统杀死前一定会回调 onDestroy/onTerminate 吗? 不一定. 内存压力下系统可以直接终止缓存进程, 不能依赖清理回调保存关键数据. 状态应在发生变化时持久化, 返回页面时按持久状态和 SavedState 重建.

Q7: ViewModel 能解决进程死亡吗? 不能保存旧实例. ViewModel 只保证同一 owner 在配置变更中的保留语义. 进程死亡后需要 SavedStateHandle 恢复少量 UI 状态, 用 Room/DataStore/文件恢复关键业务数据.

Q8: 安装 APK 时系统主要做什么? 接收 PackageInstaller session, 校验包结构, 签名, 版本和权限, 解析 Manifest 与组件, 准备 APK/native library 和 dex 优化, 更新 PMS 包信息并通知包变化. 具体内部服务和优化时机会随 Android 版本变化.

易错点 / 追问

  • 不要说 AMS 负责所有 Activity 细节; 新版本应说明 ATMS 的拆分.
  • 不要把 ActivityThread 说成 Thread 子类, 也不要把 ApplicationThread 当作应用主线程.
  • 不要说 ViewRootImpl 是 View 或 DecorView 的父 View.
  • 不要把 onSaveInstanceState 当数据库使用, 大对象会增加事务和恢复风险.
  • 不要依赖 onDestroy(), Application.onTerminate() 保存关键状态.
  • 源码类名和事务 item 会随版本变化, 回答重点应是跨进程模型和职责边界.

Binder 与 IPC 深入

Binder 是 Android 系统面试里最能拉开层次的题: 不要只背 “一次拷贝”, 要能把 mmap, ServiceManager, AIDL, 线程池和大数据边界讲成一条工程链路. 前置: 系统 / 进程模型见 Android 系统原理 与 Framework 源码与进程生命周期, 并发模型见 多线程并发专题.

一, Binder 总体模型

Binder 是 Android 最核心的 IPC 机制, 把跨进程调用包装成 “像调用本地对象一样调用远端服务”.它同时解决三件事:

  • 通信: Client 通过 Binder 驱动把 Parcel 发给 Server.
  • 寻址: ServiceManager 负责服务注册与查询, 类似系统服务的 “电话簿”.
  • 安全: Binder 驱动天然知道调用方 UID/PID, 服务端可以做权限校验.
Client(BinderProxy)
  -> /dev/binder 驱动
  -> Server(Binder Stub / Binder 实体)
  -> Binder 线程池执行 onTransact

面试回答要强调: Binder 不是单个类, 而是 用户态 Stub/Proxy + Binder 驱动 + ServiceManager + 线程池调度 的组合机制.

二, 一次拷贝模型与 mmap

常见说法是 Binder “一次拷贝”, 不是零拷贝. 传统 socket/pipe 往往需要 “发送方用户态 → 内核缓冲区 → 接收方用户态” 两次拷贝; Binder 通过内核维护的映射区减少一次显式拷贝.

  • Server 进程启动 Binder 线程池时, 会和 Binder 驱动建立映射区 (mmap).
  • Client 把参数序列化到 Parcel, 调用 transact() 进入驱动.
  • 驱动把数据拷贝到目标进程可通过映射区访问的 Binder buffer.
  • Server 的 Binder 线程从映射区读取 Parcel 并分发到 onTransact().
说法面试稳妥表达
Binder 零拷贝不准确. 普通 Binder 事务仍有一次用户态到内核 / 驱动缓冲的拷贝.
Binder 一次拷贝可以作为高层理解, 核心是 mmap 映射减少 “内核到接收方用户态” 的额外拷贝.
Binder 适合传大图 / 大文件不适合. 大数据应走共享内存, 文件描述符, ContentProvider stream 等.

mmap 实现层推演 (面试按需深度): 下面这些是驱动 / 内核实现细节, 答到 “一次拷贝 + mmap + 线程池上限” 就够, 不用死背.

  • Server 端建立 Binder 线程池时通过 mmap 映射一段内核缓冲区, 该区域由驱动的 binder_alloc 管理. “一次拷贝” 的完整路径: 发送方 copy_from_user 把 Parcel 拷进内核 Binder buffer, 接收方通过自己 mmap 的映射区直接读这块 buffer, 无需第二次显式拷贝到用户态.
  • 驱动与用户态用 binder 协议命令交互: 客户端发起事务向驱动写 BC_TRANSACTION, 服务端从驱动读到事务通知是 BR_TRANSACTION.
  • 进程的 Binder 线程池有上限: 内核需要更多线程时发 BR_SPAWN_LOOPER, 由用户态增开线程, 受 max_threads 限制, 默认 15 (默认值, 随版本 / 驱动调整).
  • “约 1MB” 只是经验值: 内核 buffer 大小与分配计数随版本 / 驱动调整 (如 Android 15 前后内核修正过异步事务空间计算, 以 AOSP 当前实现为准), 面试别把 1MB 当精确上限.

三, ServiceManager 与服务注册查找

ServiceManager 是 Binder 世界里的特殊服务, 负责把服务名映射到 Binder handle.

  1. SystemServer 创建系统服务, 把 Binder 实体注册到 ServiceManager.
  2. Client 通过服务名查询, 拿到的是远端 Binder 的代理对象.
  3. 后续调用不再直接找服务名, 而是通过 handle 让 Binder 驱动路由事务.

怎么答得更工程化: ServiceManager 解决 “我怎么拿到远端服务入口” 的问题; Binder 驱动解决 “这个入口怎么跨进程调用” 的问题; AIDL 解决 “业务接口怎么自动生成序列化和代理代码” 的问题.

四, AIDL, Messenger 与 ContentProvider IPC

不同 IPC 方式适合不同粒度, 不要把 AIDL 当成唯一答案.

方式本质适合场景注意点
AIDL编译期生成 Stub/Proxy, 底层 Binder多方法, 强类型, 需要回调或并发调用的服务接口版本兼容, 线程安全, 权限校验
MessengerHandler + Binder 封装, 消息串行处理简单命令, 低并发, 只需传 Message单线程串行, 不适合高吞吐
ContentProvider系统组件 + Binder, 以 URI 暴露数据跨应用共享结构化数据, 文件流权限, URI 授权, 查询不要阻塞主线程

AIDL 调用链

  1. .aidl 定义接口和 Parcelable 数据类型.
  2. 编译器生成 Stub, Proxy, onTransact(), asInterface().
  3. Client 调用 Proxy 方法, 参数写入 Parcel.
  4. Server Binder 线程执行 Stub 的业务方法, 再把返回值写回 reply.

五, Binder 线程池与调用风险

Binder 回调不是自动跑在主线程. 服务端通常由 Binder 线程池处理事务, 这带来两个面试重点:

  • 线程安全: 多个 Client 可并发调用同一服务方法, 共享状态要加锁或串行化.
  • 阻塞风险: 同步 Binder 会阻塞调用方线程; 如果主线程发耗时 Binder, 可能导致 ANR.
  • 线程池耗尽: 服务端 Binder 线程被慢任务占满后, 新事务排队, 上游看起来像 “系统服务卡住”.
  • 反向调用死锁: Client 持锁调用 Server, Server 回调 Client 又等待同一把锁, 容易死锁.

怎么落地: 服务端 Binder 方法里只做参数校验和轻量分发, 耗时工作转到业务线程池; Client 侧避免主线程同步调用不可信远端服务.

六, DeathRecipient 与远端进程死亡

Binder 可以监听远端服务死亡, 常用 linkToDeath() 注册 DeathRecipient.

  • 用途: 远端进程崩溃 / 被杀后, Client 能清理缓存代理, 重连服务, 更新 UI 状态.
  • 触发: binderDied() 在 Binder 线程里回调, 不要直接更新 UI 或做重活.
  • 清理: 服务恢复或不再需要监听时调用 unlinkToDeath().

常见落地流程:

获取远端 Binder -> linkToDeath
调用失败或 binderDied -> 标记服务不可用 -> 清理代理
后台重连/重新 bind -> 成功后重新注册 DeathRecipient

真实踩坑 (风控 SDK 尤其要关注):

  • binderDied 与 unlinkToDeath 竞态: 远端断开与本地解注册之间没有原子保证, 连接断开与 unlinkToDeath() 并发时可能漏收死亡通知. 风控场景漏掉一次 “远端进程死亡” 会导致状态残留或误判, 要把死亡处理做成幂等的重连流程.
  • 回调洪峰: 服务端高频回调 (如每秒多次上报) 会同时压垮客户端 Binder 线程池和 Handler 队列, 表现为回调堆积, 主线程卡顿. 对策: 回调合并 / 节流, 客户端给回调接口配独立 Binder 线程池, 或改批量拉取加背压.
  • 代理被系统静默回收: Binder 代理只在获取它的进程内有效; 长生命周期对象缓存的代理, 一旦所在进程被杀 / 重建就全部失效, 必须重新 bind 拿新代理, 不能把旧代理当永久对象缓存复用.

七, TransactionTooLargeException 与 Parcelable 边界

Binder transaction buffer 通常按约 1MB 级别理解, 而且是进程内并发事务共享, 不是 “每次调用都稳定可用 1MB”.因此大 Bundle, 大 Bitmap, 大列表都可能触发 TransactionTooLargeException.

  • Activity/Fragment 传参: 不要把大对象塞进 Intent/Bundle, 传 ID, 详情从数据库/Repository 取.
  • Parcelable: 适合轻量结构化对象, 不是大数据通道; 字段要控制数量和嵌套深度.
  • 大文件 / 图片: 用 Uri, FileDescriptor, ContentProvider openFile, 共享内存等方式.
  • 并发影响: 多个事务同时发生时共享缓冲区, 单次数据即使小于 1MB 也可能失败.

怎么排查: 看异常堆栈里的组件跳转 / 状态保存路径, 重点检查 Intent extras, onSaveInstanceState, AIDL 返回列表, Provider Cursor Window.

八, IPC 设计与排查模板

设计一个稳定 IPC 接口时, 按下面清单回答会更像工程落地:

维度怎么设计怎么排查
数据大小传 ID/分页/FD, 避免大 Parcelable统计 Parcel/Bundle 大小, 压测并发事务
线程模型Binder 方法轻量, 耗时转线程池看 Binder 线程堆栈是否被 IO / 锁占住
生命周期linkToDeath + 重连检查远端进程死亡后代理是否清理
安全校验 UID/PID/签名/权限确认 exported, permission, 调用方身份
兼容AIDL 字段只增不乱改语义老新版本互调测试

九, 现代 Binder 工程细节: oneway, 版本, 安全与排障

同步调用 vs oneway

普通 AIDL 调用是同步的: Client 线程等待 Server 执行完并返回. oneway 是异步事务, Client 发出后不等结果, 适合 fire-and-forget 通知.

类型特点风险
同步 Binder有返回值, 调用方等待主线程调用慢服务会 ANR
oneway不等待返回, 不能抛业务异常给调用方发送过快会排队, 没有天然背压

面试要点: oneway 不是 “无限快”, 它仍然占 Binder 队列和服务端处理能力. 高频事件要节流, 合并或改共享内存 / 批量拉取.

补充完整语义: oneway 调用不阻塞调用方 (发出即返回), 同一线程对同一 Binder 发出的多条 oneway 仍按发送顺序到达, 但调用方不等返回结果, 也感知不到业务层异常. 需要确认 “已生效” 语义时, 应改用同步调用或让服务端显式回调.

AIDL 版本演进

  • Parcelable 字段只增不乱删, 新增字段要有默认语义.
  • 方法语义不要悄悄改变, 老 Client 和新 Server 必须能互通.
  • 系统/跨团队接口可提到 stable AIDL / versioned AIDL: 核心是让接口版本, 兼容性和生成代码可审计.
  • 回调接口也要考虑版本, 否则 Server 调用老 Client 新方法会失败.

大数据 IPC 选择

数据类型推荐通道原因
大图片 / 大文件Uri + ContentProvider openFile避免 Binder buffer 限制
大块二进制SharedMemory / FD少拷贝, 适合共享内存读写
大列表分页查询 / Cursor控制单次事务大小
高频状态订阅 + 批量拉取避免 oneway 消息风暴
  • SharedMemory: 生命周期由引用计数管理, 内核在所有持有者 close() fd 后回收共享内存; 适合双方同时读同一份大块数据的场景.
  • FD 跨进程传递: Binder 由驱动在事务中处理 BINDER_TYPE_FD, 在接收进程文件表安装指向同一内核对象的独立 fd; 适合把数据 “所有权” 或流式通道转移给对方, 而不是把数据本体塞进事务. SCM_RIGHTS 是 Unix domain socket 的机制, 不要与 Binder 混淆.

Binder 安全边界

服务端不能因为 “是本机进程调用” 就信任参数. 跨进程入口要做:

  1. Binder.getCallingUid()/getCallingPid() 识别调用方.
  2. 检查签名权限, manifest permission 或自定义鉴权 token.
  3. 对 exported Service/Provider/Receiver 做最小暴露.
  4. 必要时结合 AppOps/SELinux/系统权限边界理解平台限制.
  5. 所有入参重新校验, 不要信任 Parcelable 内部字段.

Binder 排障线索

  • 主线程栈卡在 Binder transact: 怀疑同步远端调用慢或系统服务阻塞.
  • 服务端 Binder 线程都在 IO / 锁等待: 线程池饥饿, 上游大量超时.
  • TransactionTooLargeException: 检查 Bundle/Intent/AIDL 返回值和并发事务.
  • 可通过 dumpsys, ANR traces, Perfetto binder/sched 轨迹定位调用链; 面试里说思路即可, 不要背厂商差异命令输出.

Binder 边界与失败模式

“一次拷贝” 只是在特定 Binder 数据路径下相对传统 IPC 的简化表述, 不包含序列化, 用户态读写和大对象成本. 还要考虑事务 buffer 有限, 线程池耗尽, 同步调用阻塞, binder death, TransactionTooLargeException, FD / 共享内存生命周期和调用方身份校验. 大数据优先文件描述符, 共享内存或分块协议, Binder 只传控制信息和小 payload.

上下文片段: 从 AIDL 到死亡回调

以下内容是 Android Gradle 工程的上下文片段, 不能脱离 manifest, imports, executor, Service 声明和实际权限独立运行; 两个 AIDL 文件放在 src/main/aidl/com/example/ipc/, 构建会生成 Stub/Proxy.

// IProgressCallback.aidl
package com.example.ipc; interface IProgressCallback { void onProgress(int value); }
// IDownloadService.aidl
package com.example.ipc; import com.example.ipc.IProgressCallback;
interface IDownloadService { void start(String taskId); void registerCallback(IProgressCallback callback); void unregisterCallback(IProgressCallback callback); }

上下文片段 (服务端 Service): RemoteCallbackList 在远端死亡时移除回调, 不是事件持久化队列.

private val callbacks = RemoteCallbackList<IProgressCallback>()
private val binder = object : IDownloadService.Stub() {
    override fun start(taskId: String) { executor.execute { notifyProgress(100) } }
    override fun registerCallback(cb: IProgressCallback) { callbacks.register(cb) }
    override fun unregisterCallback(cb: IProgressCallback) { callbacks.unregister(cb) }
}
private fun notifyProgress(value: Int) { val n = callbacks.beginBroadcast(); try { for (i in 0 until n) callbacks.getBroadcastItem(i).onProgress(value) } finally { callbacks.finishBroadcast() } }
override fun onBind(intent: Intent): IBinder = binder

上下文片段 (Client Activity/Repository): 回调必须是 AIDL 生成接口的 Stub; 在解绑时先注销回调, 再解除死亡监听, 避免保留旧 Binder.

private var service: IDownloadService? = null
private var remoteBinder: IBinder? = null
private val callback = object : IProgressCallback.Stub() {
    override fun onProgress(value: Int) { mainHandler.post { renderProgress(value) } }
}
private val deathRecipient = IBinder.DeathRecipient {
    service = null
    mainHandler.post { showServiceUnavailable() }
}
private val connection = object : ServiceConnection {
    override fun onServiceConnected(name: ComponentName, raw: IBinder) {
        remoteBinder = raw
        service = IDownloadService.Stub.asInterface(raw)
        raw.linkToDeath(deathRecipient, 0)
        service?.registerCallback(callback)
    }
    override fun onServiceDisconnected(name: ComponentName) { service = null; remoteBinder = null }
}
private fun unbindDownloadService() {
    service?.unregisterCallback(callback)
    remoteBinder?.unlinkToDeath(deathRecipient, 0)
    unbindService(connection)
    service = null; remoteBinder = null
}

省略项包括 mainHandler, renderProgress, showServiceUnavailable, bind 时的 Intent 和异常处理. 服务端 Binder 方法仍需权限和入参校验, 耗时工作转线程池. 失败演练: 持锁同步 Binder, 服务端反调同一把锁时, 症状为 transact 与 lock wait 互等, 证据是 ANR trace/Binder 链; 修复为不持锁跨进程调用, 杀掉服务进程验证死亡通知和重连.

高频面试题

Q1: Binder 为什么说是一次拷贝? mmap 起什么作用? 普通 Binder 事务不是零拷贝. Server 与 Binder 驱动建立 mmap 映射区后, 驱动把 Client Parcel 数据拷贝到目标进程可访问的 Binder buffer, 接收方通过映射区读取, 减少一次 “内核缓冲区到接收方用户态” 的显式拷贝.

Q2: ServiceManager 的作用是什么? 它负责服务注册和查询, 把服务名映射到 Binder handle. Client 先通过 ServiceManager 找到远端服务代理, 后续事务由 Binder 驱动根据 handle 路由到目标 Binder 实体.

Q3: AIDL, Messenger, ContentProvider 怎么选? AIDL 适合强类型, 多方法, 高并发服务; Messenger 是 Handler + Binder, 适合简单串行消息; ContentProvider 适合跨应用共享结构化数据或文件流. 它们底层都可能走 Binder, 差异在抽象层和使用场景.

Q4: Binder 线程池会带来什么问题? 服务端方法可能被多个 Binder 线程并发调用, 共享状态要线程安全. 耗时任务占满 Binder 线程池会导致后续事务排队; 主线程同步调用慢 Binder 还可能 ANR.

Q5: TransactionTooLargeException 怎么避免? 不要在 Intent, Bundle, AIDL, Parcelable 里传大对象. 传 ID, Uri, 分页数据或 FileDescriptor; 图片 / 文件走 ContentProvider stream 或共享内存. 还要记住 Binder buffer 是并发共享的.

Q6: oneway AIDL 是不是就不会卡? 不是. oneway 只是调用方不等待返回, 事务仍会进入 Binder 队列并占用服务端处理能力. 高频 oneway 可能造成队列堆积和服务端线程压力, 需要节流, 合并, 批量拉取或换共享内存 / FD 方案.

Q7: 跨进程服务怎么做安全校验? 服务端用 getCallingUid/getCallingPid 识别调用方, 结合签名权限, manifest permission, AppOps 或业务 token 做鉴权; 同时对所有参数重新校验, 不要因为本机 IPC 就信任调用方.

易错点 / 追问

  • 把 Binder 说成 “零拷贝” 是常见错误, 更稳妥是 “普通事务一次拷贝, 大数据另走共享内存 / FD”.
  • AIDL 方法默认可能在 Binder 线程并发执行, 不要默认它运行在主线程或天然串行.
  • 持锁发同步 Binder, 在 Binder 回调里再反向调用, 是死锁和 ANR 高频追问.
  • Parcelable 只解决序列化效率, 不突破 Binder transaction buffer 限制.
  • binderDied() 不是 UI 回调, 要切线程并做轻量清理 / 重连.

多线程并发专题

Android 并发题要同时答 Java 基础和移动端场景: 线程池参数背得出只是起点, 还要知道 HandlerThread, IntentService, 协程调度, 锁, CAS, ANR/OOM 怎么落地排查.

一, Android 线程模型与 HandlerThread

主线程负责 UI, 输入事件和生命周期回调, 耗时任务必须离开主线程. HandlerThread 是带 Looper 的后台线程, 适合串行处理一类任务.

val thread = HandlerThread("worker").apply { start() }
val handler = Handler(thread.looper)
handler.post { /* 串行后台任务 */ }

特点:

  • 一个 HandlerThread 对应一个 Looper/MessageQueue, 任务按消息顺序串行执行.
  • 适合相机回调, 轻量 IO, SDK 内部串行状态机.
  • 不适合大量并行任务; 任务太慢会阻塞后续消息.
  • 退出时调用 quitSafely(), 避免线程泄漏.

二, IntentService / JobIntentService 的边界

IntentService 本质是 Service + HandlerThread, 按 Intent 串行执行, 执行完自动停止. 它曾适合后台串行任务, 但在后台执行限制增强后使用场景变窄.

机制特点现状 / 边界
IntentService后台线程串行处理 Intent已不推荐作为新方案, 受后台限制影响
JobIntentService兼容低版本, 高版本走 JobScheduler 思路也不是长期首选, 复杂任务更推荐 WorkManager
WorkManager可约束, 可重试, 可持久化延迟 / 保证执行类后台任务首选

怎么答: 如果是页面内短任务, 用协程 / 线程池; 如果是可延迟, 需约束, 进程死后仍要执行的任务, 用 WorkManager, 不要滥用 Service 常驻后台.

三, Thread, Runnable, Callable 与 Future

Thread 是执行线程的对象, Runnable 表示不返回结果的任务, Callable<T> 可以返回 T 且声明受检异常. ExecutorService.submit() 会把 Runnable 或 Callable 封装为 Future, 调用方可查询完成状态, 获取结果或取消任务.

方式结果与异常取消适用场景不适用边界
Thread无返回值; 未捕获异常交给线程异常处理器interrupt() 只是协作信号极少量需直接控制线程生命周期的底层代码页面或业务任务逐个新建线程
Runnable无返回值; 直接 run() 的异常由调用方处理由执行器或线程协作中断无结果的短任务需要返回值或检查受检异常的任务
Callable<T>返回 T; 异常会由 Future.get() 包装为 ExecutionExceptionFuture.cancel(mayInterruptIfRunning) 请求取消有结果, 可失败的后台计算忽略取消和异常的长任务
Future<T>get() 取得结果或抛出取消 / 执行异常可查询, 取消和判断完成后台协调与超时控制主线程直接 get() 等待结果

run() 只是当前调用线程中的普通方法调用, 不会创建新线程; start() 才会让 JVM 创建并调度新线程, 再由该新线程调用 run(). 同一个 Thread 不能重复 start().

NEW -- start() --> RUNNABLE -- 运行或让出 CPU --> RUNNABLE

RUNNABLE -- 竞争 synchronized 监视器 --> BLOCKED
BLOCKED -- 获得监视器 ----------------> RUNNABLE

RUNNABLE -- wait() ------------------------> WAITING
WAITING -- notify()/notifyAll() -----------> BLOCKED -- 获得监视器 --> RUNNABLE

RUNNABLE -- join()/park() -----------------> WAITING
WAITING -- join 目标结束/unpark() ---------> RUNNABLE

RUNNABLE -- wait(timeout) -----------------> TIMED_WAITING
TIMED_WAITING -- notify()/notifyAll()/超时 -> BLOCKED -- 获得监视器 --> RUNNABLE

RUNNABLE -- sleep()/join(timeout)/park(timeout) --> TIMED_WAITING
TIMED_WAITING -- sleep 超时/join 目标结束或超时/unpark() --> RUNNABLE

任意可执行状态 -- run() 结束或未捕获异常 --> TERMINATED

这是 Java Thread.State 的语言级状态, 不等同于操作系统调度器的运行, 就绪或内核等待状态. 从 RUNNABLE 竞争 synchronized 监视器进入 BLOCKED; 无超时的 wait(), join() 或 park() 进入 WAITING; sleep() 或带超时的 wait(), join(), park() 进入 TIMED_WAITING. Object.wait() 被 notify()/notifyAll() 唤醒或超时后, 必须重新竞争同一监视器, 竞争期间为 BLOCKED, 获得监视器后才回到 RUNNABLE. sleep() 到期直接回到 RUNNABLE; join() 的目标结束或超时, 以及 park()/unpark() 的返回不涉及监视器时也直接回到 RUNNABLE.

wait/notify 与 sleep

wait(), notify() 和 notifyAll() 必须由持有同一对象监视器的线程在 synchronized (monitor) 中调用, 否则抛出 IllegalMonitorStateException. wait() 会释放该监视器并进入等待集; 被通知后还要重新竞争锁. 通知可能早于等待, 也可能发生虚假唤醒, 因此条件必须使用 while 重查. sleep() 仅让当前线程在一段时间内不运行, 不释放已持有的锁, 不能替代条件协作.

// 上下文片段:多个消费者等待同一队列时使用 notifyAll, 并始终在 while 中重查条件.
synchronized (monitor) {
    while (queue.isEmpty()) {
        monitor.wait();
    }
    Item item = queue.remove();
}

synchronized (monitor) {
    queue.add(item);
    monitor.notifyAll();
}

手写监视器协作容易遗漏中断, 关闭和异常路径. 生产者消费者优先选择后文的 BlockingQueue; 需要多个条件队列或可中断获取时选择 Condition/ReentrantLock.

四, Executor 与 ThreadPoolExecutor

线程池用于复用线程, 限制并发, 隔离任务类型. ThreadPoolExecutor 关键参数不是背诵, 要能讲执行流程:

提交任务
  -> 工作线程数 < corePoolSize: 新建核心线程
  -> 否则尝试入队 workQueue
  -> 队列满且线程数 < maximumPoolSize: 新建非核心线程
  -> 仍无法处理: RejectedExecutionHandler
参数含义Android 坑点
corePoolSize核心线程数太大增加调度和内存压力
maximumPoolSize最大线程数配无界队列时通常不起作用
workQueue等待队列无界队列可能堆积导致 OOM
keepAliveTime非核心线程存活时间可回收突发线程
rejectedHandler拒绝策略要可观测, 不要静默丢任务

五, 协程 Dispatchers 与线程池关系

Kotlin 协程不是 “没有线程”, 它是挂起 / 恢复的调度模型, 最终仍运行在线程上.

  • Dispatchers.Main: 主线程, 更新 UI.
  • Dispatchers.IO: IO 密集型任务, 适合网络, 磁盘, 底层有弹性线程池策略.
  • Dispatchers.Default: CPU 密集型任务, 如排序, JSON 大计算, 图片算法.
  • withContext: 切换执行上下文并等待结果.

怎么落地: Repository 做网络 / 数据库可用 withContext(IO); CPU 计算不要丢到 IO; UI 收集 Flow 用生命周期感知 API, 避免页面销毁后继续更新.

六, 锁, volatile, CAS 与 Atomic

并发控制要区分 “可见性, 原子性, 有序性”.

工具解决什么适合场景注意点
synchronized互斥 + 可见性简单临界区持锁不要做 Binder/IO
ReentrantLock可中断/可尝试/公平锁复杂锁控制必须 finally unlock
volatile可见性 + 禁止部分重排状态标记, 双检锁引用不能保证复合操作原子性
CAS/Atomic无锁原子更新计数, 状态机引用ABA (值从 A 变 B 又变回 A, CAS 只比较 A 会误判未变化), 自旋开销
private val running = AtomicBoolean(false)

fun startOnce() {
    if (running.compareAndSet(false, true)) {
        // only one caller can enter
    }
}

悲观锁与乐观锁

悲观锁假设冲突常见, 先通过 synchronized 或 ReentrantLock 排他地保护临界区, 适合写入冲突高且失败重试代价大的共享资源. 乐观锁假设冲突较少, 先读后以 CAS 或版本号提交; 冲突时重读并重试, 适合短小的无阻塞更新.

CAS 的重试不是没有成本: 高竞争下自旋会消耗 CPU, 复杂操作也无法自然地一次 CAS 完成. 值从 A 变为 B 又回到 A 时, 只比较值的 CAS 看不出中间变化, 即 ABA 问题; 需要识别版本语义时使用 AtomicStampedReference 或显式版本号. 不能因为使用了 Atomic 就假定跨多个字段的业务不变量已经成立.

BlockingQueue 与并发集合

BlockingQueue 将生产者消费者的交接, 等待与容量边界封装起来. 有界队列能把过载显式化: 队列满时 put() 阻塞, offer() 可按超时失败, 调用方可降速, 合并或拒绝任务, 而不是让无界积压持续占用内存.

// 上下文片段:容量和超时应按业务延迟, 内存预算及丢弃语义确定.
BlockingQueue<Job> queue = new ArrayBlockingQueue<>(64);
if (!queue.offer(job, 100, TimeUnit.MILLISECONDS)) {
    rejectOrCoalesce(job);
}
容器读写特征适用场景误用边界
BlockingQueue可阻塞交接; 有界实现可提供背压生产者消费者, 线程池任务队列在主线程无超时 put/take, 或用无界队列掩盖过载
ConcurrentHashMap多线程读写, 不锁整张表; 单个复合业务操作仍需原子 API 或外部协议并发缓存, 按 key 聚合把 “先查再改” 多步骤当成天然原子, 或存入非线程安全的可变 value
CopyOnWriteArrayList写时复制整个数组, 读迭代无需锁且是快照读多写极少的监听器列表高频写入, 大列表或要求迭代实时反映修改
ConcurrentLinkedQueue非阻塞链表队列, 高并发入队 / 出队不需要容量控制的短暂并发交接需要背压, 严格容量或任务无限堆积的场景

七, ThreadLocal 与共享状态

ThreadLocal 为每个线程保存独立副本, 解决的是上下文隔离, 不是共享对象的互斥. synchronized 则让多个线程按顺序访问同一份共享状态. 两者不能互相替代.

// 上下文片段:requestId 在线程内隔离; balance 是共享状态, 必须在同一把锁下更新.
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
private final Object balanceLock = new Object();
private int balance;

void handle(String requestId, int delta) {
    try {
        REQUEST_ID.set(requestId);
        synchronized (balanceLock) {
            balance += delta;
        }
    } finally {
        REQUEST_ID.remove();
    }
}

在线程池中, 工作线程会被下一个任务复用. 未调用 remove() 的值可能被后续请求观察到, 或长期持有 Activity, Context, 大缓冲区等对象. 因此每次 set() 后都应在同一任务的 finally 调用 remove(); 能通过方法参数传递的上下文不应滥用 ThreadLocal. Java 定义和弱键泄漏机制可回看 Java 与 JVM 基础.

八, AsyncTask 的历史边界

历史原理: AsyncTask 将 onPreExecute(), 后台 doInBackground() 和主线程 onPostExecute() 串成便捷模板, 内部使用线程池和 Handler 回投结果. Android 3.0 (API 11) 起, execute() 默认串行执行; 需要并行时曾可调用 executeOnExecutor(), 因此不能把它笼统说成始终串行或始终并行.

废弃或限制状态: AsyncTask 在 API 30 起废弃. 它的回调容易脱离 Activity/Fragment 生命周期, 造成页面销毁后的更新, 取消语义不清和任务持有 View/Context. 新项目不应继续使用, 也不应以 executeOnExecutor() 规避这些问题.

现代替代: 页面内短任务使用生命周期绑定的协程, 在 viewModelScope/lifecycleScope 中调度并将状态暴露给 UI; 需约束, 重试或跨进程存活的延迟后台任务使用 WorkManager. Activity/Fragment 的生命周期语境见 四大组件与基础.

九, 死锁与线程安全排查

死锁通常满足互斥, 持有并等待, 不可抢占, 循环等待. Android 里高频场景是 “主线程等后台锁, 后台线程切回主线程” 或 “持锁发同步 Binder”.

排查路径:

  1. 抓 ANR traces 或线程 dump, 看主线程卡在哪把锁/哪个 Future/Binder.
  2. 找持锁线程, 看它是否等待主线程, IO, 网络或另一个锁.
  3. 检查锁顺序是否全局一致, 是否在锁内做耗时操作.
  4. 用超时, tryLock, 缩小临界区或串行队列替代复杂嵌套锁.

怎么答: 不要只说 “加锁解决线程安全”, 还要补 “锁粒度, 锁顺序, 锁内不做耗时 / 跨进程调用”.

十, 线程池导致 ANR/OOM 的坑

线程池配置错会从 “优化” 变成 “事故”.

  • ANR: 主线程等待线程池结果, 但线程池被长任务占满; 或回调切主线程后主线程被阻塞.
  • OOM: 无界队列堆积大量 Runnable/闭包持有 Activity/Bitmap; 线程数过多导致栈内存暴涨.
  • 线程饥饿 / 队头阻塞: 低优先级长任务占满池, 高优先级 UI 相关任务排队.
  • 任务泄漏: 页面销毁后任务仍持有 View/Context.
坑规避方式
newCachedThreadPool 滥用限制最大线程数, 按任务类型隔离线程池
无界 LinkedBlockingQueue设置有界队列和拒绝策略
主线程 Future.get()用回调 / 协程挂起, 不要阻塞主线程
IO/CPU 混用一个池IO 与 CPU 任务隔离, 避免互相饿死

十一, 并发设计落地模板

面试讲项目时可以按这个模板说明并发方案:

  1. UI 事件进入 ViewModel, 用协程保证生命周期自动取消.
  2. Repository IO 任务切到 Dispatchers.IO, CPU 计算切到 Default.
  3. SDK 内部串行状态用 HandlerThread 或单线程 Executor.
  4. 共享状态用不可变快照, Atomic 或小粒度锁.
  5. 线程池设置有界队列, 命名线程, 异常日志和拒绝策略.

结构化并发与可验证性

Android 并发不仅是锁和线程池: 协程任务应绑定 owner scope, 父子取消和异常传播可解释; 不用 GlobalScope 逃避生命周期. 原子性, 可见性和竞态结论用可重复测试, stress test/trace 辅助, 不能以 “本机没复现” 证明安全. UI 状态只在主线程或受控单写者模型更新, 阻塞 I/O 和重 CPU 任务按性质调度.

参数决策与故障演练

CPU 密集任务以可用核心数附近的有限并发起步; 阻塞 I/O 可更高, 但受远端容量, 超时, 内存和队列等待约束. UI 只在主线程提交状态, 不能在主线程 get() 等待. 以下上下文片段的数字仅是压测起始假设; 省略 java.util.concurrent imports, downloadAndDecode(), recordRejected() 和应用退出时的 ioPool.shutdown(), 须记录 active count, 队列长度, 拒绝次数和 P95 等待时间后调整:

val ioPool = ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS, ArrayBlockingQueue(32), ThreadFactory { r -> Thread(r, "image-io") }, ThreadPoolExecutor.AbortPolicy())
try { ioPool.execute { downloadAndDecode() } } catch (e: RejectedExecutionException) { recordRejected("image-io") }

拒绝频繁时先合并, 取消或施加背压, 不要无限扩队列. volatile 适合停止标志但不能保证 count++ 原子性. 需要版本语义时用 AtomicStampedReference 或版本号.

伪造 trace (教学, 非真实事故): main: waiting lock B held by worker, worker: waiting lock A held by main 是循环等待证据. 先查锁 owner 是否又在 I/O, Binder 或等待主线程, 再统一锁顺序或移除嵌套锁; 修复后用并发重复测试与线程 dump 验证.

高频面试题

Q1: HandlerThread 适合什么场景? 和线程池区别? HandlerThread 是一个带 Looper 的单后台线程, 任务按消息串行执行, 适合相机, SDK 状态机, 轻量 IO 等需要顺序的任务. 线程池适合多个独立任务并发执行, 但要控制队列和线程数.

Q2: ThreadPoolExecutor 的执行流程是什么? 先看工作线程是否小于 corePoolSize, 是则建核心线程; 否则入队; 队列满且线程数小于 maximumPoolSize 时建非核心线程; 仍处理不了就走拒绝策略. 无界队列会让 maximumPoolSize 基本失效.

Q3: volatile 能保证 i++ 线程安全吗? 不能. volatile 保证可见性和一定有序性, 但 i++ 是读 - 改 - 写复合操作, 不具备原子性. 需要 synchronized, Lock 或 AtomicInteger/CAS.

Q4: 协程 Dispatchers.IO 和 Default 怎么选? IO 用于网络, 磁盘, 数据库等阻塞 IO; Default 用于 CPU 密集计算. 协程最终仍在线程上执行, 选错调度器会导致线程饥饿或 CPU 争用.

Q5: 线程池为什么会导致 ANR 或 OOM? 主线程等待线程池结果会 ANR; 线程池被长任务占满会让关键任务排队. 无界队列堆积大量 Runnable, 线程数过多导致栈内存增长, 任务闭包持有大对象, 都可能 OOM.

易错点 / 追问

  • 协程不是替代线程的魔法, 挂起恢复最终仍依赖 Dispatcher 背后的线程.
  • volatile 不能保证复合操作原子性, 回答时要区分可见性和原子性.
  • 锁内不要做 IO, 网络, 同步 Binder 或切主线程等待, 这是死锁 / ANR 高频点.
  • 无界队列 + 大量任务比 “线程数很多” 更隐蔽, 也更容易拖到 OOM.
  • IntentService/JobIntentService 不是现代后台任务万能解, 可延迟可靠任务优先考虑 WorkManager.

性能优化

性能优化是中级面试的高频区, 也是你的潜在主场: 风控 SDK 对体积 / 性能敏感, 你的经验比一般应用开发者更有说服力.

一, 性能方法论

性能问题先定义用户路径与指标, 再建立可比较的基线, 最后才改代码. 每个结论须包含设备/OS/构建, 场景, 样本量, 分位数与观测开销; 一次耗时, 平均值或 “感觉变快” 均不足以证明收益.

  1. 指标与基线: 定义里程碑和分母, 固定冷/温/热等场景, 记录 P50/P90/P95/P99 及环境.
  2. 归因假设: 将退化按主线程, 渲染, CPU, I/O, 锁, Binder, 内存或网络分类, 收集能证伪假设的证据.
  3. 最小改动: 只改变已定位路径的一个变量, 保留功能, 线程安全和失败降级.
  4. 验证: 在同基线复测分布, 并检查耗时没有转移到首次交互, 内存或电耗.
  5. 灰度与回滚: 在线上按版本 / 设备分群观察预设指标, 达到回退条件时关闭开关或回滚制品.
专项问题操作与完整案例
启动, TTID/TTFD, Splash, am start -W启动优化专项
Java/native/graphics 内存与 Heap Dump内存优化与泄漏排查
ANR, 帧, traces 和锁 / Binder 判断ANR 与卡顿排查
Perfetto, gfxinfo, simpleperf, Baseline Profile性能工具专题
线上采样, 符号化, 聚类和告警APM 与线上监控

总览章节边界

本章只维护 “指标 → 复现 → 定位 → 假设 → 最小改动 → 基线对比 → 灰度 / 回滚” 方法论. 专项内容分别见启动, 内存,ANR / 卡顿, 工具和线上监控. 具体阈值和工具步骤放在专项章, 避免多处维护过时数字.

方法论演练: 先测量, 再回滚

这是假设性教学案例, 不是实测结果. 发现某个用户路径退化时, 先定义该路径的起止指标和分母, 在相同设备, OS, 构建和场景下取得分位数基线; 再用适当证据提出一个可证伪归因假设. 一次只改一个已定位变量, 复测同一分布并检查没有把成本转移到其他路径, 内存或电耗. 最后以版本 / 设备分群灰度, 预先约定暂停放量和回滚条件. 启动, 内存, ANR, 工具和线上观测的具体操作分别见本章前述链接.

高频面试题

Q1: 如何开始一次性能优化? 先定义用户路径, 指标和分母, 建立可复现基线; 没有基线, 不应先改代码.

Q2: 如何从相关性走到归因? 写出可证伪假设, 收集线程, 渲染, I/O, 锁, 内存或网络证据; 单个慢方法或一次 trace 只能支持下一步调查, 不能单独证明因果.

Q3: 如何验证优化没有制造回归? 在原场景复测分布, 再检查关联路径, 功能正确性, 内存 / 电耗, 并按版本和设备分群灰度; 达到预设回退条件则关闭开关或回滚制品.

Q4: 优化在多个指标间冲突 (如启动更快但内存上涨) 怎么取舍? 先明确业务目标和可接受边界: 为关键路径设预算 (启动耗时预算, 内存预算), 收益按用户可感知指标量化, 成本按回归风险量化; 无法同时满足时按业务优先级取舍, 把取舍和测量证据写进方案记录, 不留无依据的 “平衡”.

Q5: 为什么不能只用平均值评估性能? 平均值掩盖长尾: 缓存命中, GC, 网络抖动会让 P99 远高于均值, 一次冷启动就能拉高整体耗时. 要看 P50/P90/P95/P99 与样本分布, 并固定场景 (冷/温/热, 低端机) 和环境; 具体采样方法见 性能工具专题.

Q6: 没有现成工具和基线时, 怎么开始一次性能排查? 先定义用户路径和可观测指标, 从系统自带能力 (dumpsys, am start -W, logcat, Perfetto) 收集分布; 复现问题路径后用二分定位到具体环节, 建立基线后再改代码. 顺序是 “先测量, 再假设, 最后最小改动”, 不是先改代码再补测量.

Q7: 性能优化和代码可维护性冲突怎么办? 优先优化已定位的热路径, 不牺牲抽象和可读性; 用注释记录优化原因和测量证据; 若优化依赖易碎假设 (版本行为, 厂商差异), 加兼容分支与回归测试并标注核验日期, 避免后续维护者无法回退.

启动优化专项

启动优化要先定义用户可感知里程碑, 再区分本地 / CI 基准测试与线上真实用户监控. 不要用单次 am start -W 或平均值代替完整证据. 方法论, 指标口径与跨域取舍见 性能优化总览.

一, 启动类型与官方指标

  • 冷启动: 进程不存在, 系统创建进程, Application 和首个 Activity, 再绘制首帧.
  • 温启动: 进程仍在, 但 Activity 需要重新创建或恢复部分状态.
  • 热启动: 进程和 Activity 基本保留, 主要恢复到前台.

Android 常用指标:

  • TTID(Time to Initial Display): 从启动到第一帧显示, 代表用户第一次看到界面.
  • TTFD(Time to Full Display): 从启动到应用报告 “关键内容已可用”, 通常通过 reportFullyDrawn() 或相关 API 标记.

如果项目使用 TTFI 等自定义指标, 必须定义起止点, 交互条件和采集方法, 不能与官方 TTFD 混用.

二, Application 与初始化治理

  1. 盘点 Application, ContentProvider, Startup Initializer 和首屏依赖.
  2. 只有首帧前必需且线程安全要求明确的任务才同步执行.
  3. 可延迟功能按首次使用初始化; 可并行任务仍需避免主线程等待和锁竞争.
  4. SDK 必须公开初始化成本, 线程模型, 幂等性和禁用自动初始化的方法.
  5. 异步并不天然更快: 如果首屏马上等待结果, 只是把阻塞换了位置.

三, ContentProvider 与 App Startup

系统会在 Application.onCreate() 前实例化清单中的 Provider. 成本来自组件实例化, 类加载, 初始化逻辑和 manifest 合并; 同进程 Provider 初始化不等于每个 Provider 都发生 IPC.

Jetpack App Startup 用单个 InitializationProvider 统一声明依赖和初始化顺序, 可减少散落 Provider, 但不会自动让耗时工作变快. 应删除不需要的自动初始化项, 并为必要初始化建立 trace 与预算.

四, 本地 / CI 与线上观测

场景目标工具与证据
本地定位找到主线程阻塞, 锁, I/O, 类加载和布局瓶颈Perfetto, System Trace, CPU Profiler, am start -W 辅助
CI 回归在受控设备上比较版本差异Macrobenchmark, Baseline Profile, 稳定迭代与统计分布
线上监控观察真实用户分布和版本退化Android vitals, 项目启动埋点, P50/P90/P95/P99, 版本/机型分层

Macrobenchmark 是本地或 CI 基准测试工具, 不是线上 APM. 线上指标要考虑采样, 设备性能, 安装状态, 网络, 实验分组和异常值.

五, 排查与验证流程

  1. 定义启动场景, TTID/TTFD 里程碑和目标分位数.
  2. 固定设备, 构建类型, 预热状态和迭代次数, 建立基线.
  3. 用 Perfetto 定位主线程长任务, Binder 等待, I/O, 锁和 RenderThread/GPU 阶段.
  4. 改造初始化依赖图, 首屏数据路径, 布局和 Profile.
  5. 用 Macrobenchmark 做 A/B 对比, 并在线上按版本和设备档位观察回归.

实操: 命令, Splash 与 trace

命令示例 (需 USB 调试, 已安装目标包): 先执行 adb shell am force-stop com.example.app, 再执行 adb shell am start -W -n com.example.app/.MainActivity.

示意输出, 非真实测量:

Status: ok
LaunchState: COLD
Activity: com.example.app/.MainActivity
ThisTime: 318
TotalTime: 421
WaitTime: 438
Complete

Status 表示启动请求是否成功; LaunchState 是系统判断的启动状态, 需结合本次是否 force-stop 解读; Activity 是最终启动组件. ThisTime 是目标 Activity 启动相关耗时, TotalTime 是本次启动链路耗时, WaitTime 包含 shell 等待启动完成的时间, 因此三者不可互换, 也不能用单次值宣布收益. 重复同一冷/温/热条件并记录分布.

Android 12 (API 31) 及以上用 SplashScreen API; 低版本由 AndroidX 兼容实现.上下文片段 (Activity): installSplashScreen() 必须在 super.onCreate 前调用, 保留条件只应等待极短且首屏必需的状态, 不能把重初始化藏在闪屏后.

override fun onCreate(state: Bundle?) { installSplashScreen().setKeepOnScreenCondition { !uiState.value.ready }; super.onCreate(state); setContentView(R.layout.main) }

在 Perfetto 中为 Application 初始化, 首 Activity 创建, 首屏数据绑定加入稳定的 trace section, 按时间看 Provider/Application/Activity/RenderThread slice 是否跨越首帧; 示例 slice 名是定位线索, 不是工具已生成的测量结论.

高频面试题

Q1: TTID 与 TTFD 有什么区别?
TTID 表示第一帧已经显示; TTFD 表示应用定义的关键内容已准备完成. 只有明确调用 fully drawn 报告并定义业务条件, TTFD 才有可比性.

Q2: ContentProvider 为什么可能拖慢启动?
它在 Application 之前实例化, 可能触发类加载, 对象创建和同步初始化. 问题是初始化工作本身及数量, 不是 “每个 Provider 都必然 IPC”.

Q3: 如何证明优化有效?
本地 trace 证明瓶颈消失, Macrobenchmark 证明受控环境下分布改善, 线上分位数证明真实用户没有回退; 三类证据不能互相替代.

易错点 / 追问

  • 不把 TTFI 当官方统一指标; 保留时写清项目定义.
  • 不把 Macrobenchmark 放在线上监控栏.
  • 不用单次耗时或平均值宣布优化成功.
  • 不把所有初始化都扔到后台线程; 先检查首屏依赖和线程安全.

版本与参考资料

内存优化与泄漏排查

内存问题不是只有 Java heap 泄漏. 中级以上排查必须区分 Java, native, graphics, mmap / 文件映射, 线程栈和瞬时峰值, 并用证据判断增长来源. 方法论, 指标口径与跨域取舍见 性能优化总览.

一, 内存问题分类

类型常见现象典型证据
Java/Kotlin 对象泄漏GC 后占用持续增长heap dump, Dominator Tree, LeakCanary
Native 泄漏Java heap 正常但 RSS/PSS 上升heapprofd, Perfetto, native allocation
Graphics/Bitmap 峰值图片, 动画, Surface 页面突增graphics memory, 图片尺寸/并发记录
mmap / 文件映射数据库, 字体, dex, 模型映射占用/proc, Perfetto, memory map
瞬时并发峰值请求结束后回落, 但峰值触发 OOM时间序列, 并发数, 解码 / 序列化路径

Retained set (保留集合): 移除对象 A 后变得不可达, 可被回收的对象集合.Retained size (保留大小): 该集合中所有对象 shallow size (对象自身占用, 不含其引用对象) 的总和, 是一个数值而非集合.

Dominator Tree (支配树): 若任意一条从 GC Root 到对象 B 的引用路径都经过对象 A, 则 A 支配 B. 树记录对象之间的直接支配关系; A 的支配子树可帮助近似识别 A 的 retained set, 并据此计算其 retained size. 它不是 retained set 本身, 也不是 retained size 这个数值.

二, Android 常见泄漏

  • Activity/Fragment/View 被单例, 静态集合, 长生命周期回调或未取消任务持有.
  • Fragment View 生命周期结束后仍保留 binding, adapter 或 listener.
  • 非静态内部类, Handler/Runnable, 匿名回调隐式持有外部对象.
  • 协程或 Flow 使用了错误 scope, 页面销毁后仍持续收集.
  • Cursor, 文件, ParcelFileDescriptor, 线程和 native 资源未关闭.

ViewModel 不应持有 Activity, Fragment 或 View; 需要 Context 时优先注入 Application Context, 并检查对象是否真的需要跨页面生命周期.

三, 标准排查流程

  1. 复现并记录页面路径, 设备内存档位, 版本和前后台次数.
  2. 观察 GC 后基线是否持续上升, 区分泄漏和峰值.
  3. Java 对象使用 heap dump/LeakCanary 找 shortest path to GC root 和 dominator.
  4. native/graphics/mmap 使用 Perfetto, heapprofd 和进程内存映射, 不只盯 Android Studio Java heap 图.
  5. 修复后重复相同场景, 比较峰值, 回落基线和线上 OOM 分布.

四, Bitmap 与资源释放

现代 Android 中不应把 Bitmap.recycle() 当常规泄漏治理方法. 只要没有引用, 普通 Bitmap 生命周期应交给 GC/native 内存管理; 手动 recycle 可能导致仍在绘制或缓存中的 Bitmap 被提前释放.

更有效的手段:

  • 按目标尺寸解码, 限制并发和预取.
  • 使用成熟图片库管理请求, 缓存和生命周期.
  • 对动画, 视频帧和大图建立内存预算.
  • 在明确所有权的底层组件中关闭 Closeable/native 资源.

五, 线上 OOM 治理

  • 按系统版本, ABI, 机型内存档位, 页面和前后台状态聚合.
  • 上报进程内存快照, 关键业务上下文和图片 / 模型尺寸, 但避免收集敏感数据.
  • Java OOM 堆栈不一定指向真正的长期持有者; 需要与趋势, heap dump 和 native 指标关联.
  • 为高风险页面设置并发, 缓存和降级策略, 不依赖崩溃后猜测.

完整泄漏演练: Heap Dump 到回归

上下文片段 (Activity): 错误示例把页面引用交给单例; 它用于复现, 不应进入生产代码. 省略 Activity, Bundle 等 imports, 布局中名为 title 的 View, 以及完整生命周期代码.

object EventBus { var listener: (() -> Unit)? = null }
override fun onCreate(state: Bundle?) { super.onCreate(state); EventBus.listener = { title.text = "updated" } }

进入并退出页面后, 在 debug 环境用 Memory Profiler 或 LeakCanary 捕获 Heap Dump. Dominator Tree 的直接支配关系用于定位 retained size 较大的支配子树; 它不等同于 GC Root, 也不能把树本身说成 “可释放集合”.沿 Activity 到 GC Root 的强引用链可得到 EventBus.listener -> lambda -> Activity/View. 修复是在 onDestroy(或更合适的 View 生命周期) 清除回调, 并避免长期对象持有 View: EventBus.listener = null. 随后重复相同进退路径, 检查引用链消失, GC 后基线回落并验证事件功能未丢失. 此流程是操作演练, 未声称任何实际 heap 数字.

高频面试题

Q1: LeakCanary 的核心思路是什么?
在对象生命周期本应结束后使用弱引用观察是否被回收, 必要时触发 heap dump 并分析到 GC Root 的引用链. 它主要帮助定位 Java/Kotlin 对象泄漏, 不能覆盖所有 native/graphics 问题.

Q2: 内存上涨就是泄漏吗?
不是. 缓存, JIT, 图片解码, 线程, mmap 和延迟 GC 都可能使占用上升. 关键看相同场景重复后基线是否持续增长, 资源是否可回收及增长归属.

易错点 / 追问

  • 不把 System.gc(), recycle() 当通用修复.
  • 不只看 Java heap 就排除 OOM 风险.
  • 不把 Application Context 当成 “永远不会泄漏” 的万能对象; 它仍可能持有大缓存和注册项.
  • 修复泄漏后还要验证页面功能, 并发取消和缓存命中是否被破坏.

版本与参考资料

ANR 与卡顿排查

ANR, 掉帧和 “页面感觉慢” 是不同问题. 阈值依赖触发类型, Android 版本和设备状态; 不要背一个固定秒数覆盖所有场景. 方法论, 指标口径与跨域取舍见 性能优化总览.

一, ANR 类型与版本条件

常见类型包括输入分发超时, BroadcastReceiver, Service, ContentProvider 和前台服务相关超时. 具体阈值与判定逻辑可能随 Android 版本, 前后台状态, CPU / 内存压力和组件类型变化, 排查时应以对应系统版本官方文档和 traces 为准.

回答面试题时不要只说 “主线程卡 5 秒就是 ANR”.更完整的表达是: 系统监控特定组件或交互的完成 deadline; 应用在 deadline 内没有响应时, 系统记录 ANR, 随后结合进程状态决定提示, 杀进程或上报.

二, 掉帧与帧预算

16.7 ms 只是 60 Hz 下一帧周期的近似示例, 不是所有设备的固定标准:

刷新率单帧周期近似值
60 Hz16.7 ms
90 Hz11.1 ms
120 Hz8.3 ms

实际是否掉帧还受 VSYNC, 输入, 主线程, RenderThread, GPU, SurfaceFlinger 和可变刷新率影响. 应使用 frame timeline/jank 证据, 而不是看到某方法超过 16.7 ms 就直接下结论.

三, 标准排查路径

  1. 先分类: ANR, 主线程长任务, 锁等待, Binder 阻塞, I/O, GC, GPU/渲染还是系统资源不足.
  2. 收集 ANR traces, ApplicationExitInfo, logcat, Perfetto 和 Android vitals 聚合数据.
  3. 对齐时间线: 主线程状态, 锁 owner, Binder 对端, CPU 调度, I/O 和内存压力.
  4. 修复后验证功能正确性, 帧时间分布和线上 ANR rate, 而不是只验证 “不再弹框”.

四, 常见根因

  • 主线程同步网络, 磁盘 I/O, 数据库大查询或大对象序列化.
  • 主线程等待锁/Future.get(), 锁 owner 又被低优先级线程或 Binder 对端阻塞.
  • BroadcastReceiver/Service 中执行过长任务, 没有转交合适的调度机制.
  • 大量布局, 重组, 图片解码, GC 或 GPU overdraw 造成连续 jank.
  • 进程处于严重 CPU/内存/thermal 压力, 正常工作也错过 deadline.

五, 线上治理

  • 使用 Android vitals 观察 user-perceived ANR 和版本趋势.
  • 用 ApplicationExitInfo 补充近期退出原因与 traces (受系统和版本支持约束).
  • 按版本, 机型, ABI, 页面, 前后台状态和实验组聚合.
  • 采集调用栈和 trace 时遵守隐私, 采样与存储预算, 并归档匹配的 mapping/native symbols.

实操: 逐段阅读 traces 与最小帧采样

以下是伪造 traces 片段, 仅用于教学, 不是某次真实 ANR:

"main" tid=1 WAITING
  at java.lang.Object.wait(Native Method)
  - waiting on <0x123> (a java.lang.Object)
  - locked <0x456> (a com.example.UiLock)
"worker" tid=31
  at android.os.BinderProxy.transactNative(Native Method)
  - locked <0x123> (a java.lang.Object)

示意 CPU usage 段, 非真实 ANR:

CPU usage from 0ms to 5120ms later:
  32% 1842/com.example.app: 24% user + 8% kernel
  20% 621/surfaceflinger: 9% user + 11% kernel
  71% TOTAL: 52% user + 18% kernel + 1% iowait

先确认发生时间和进程, 再读 main 的状态与等待对象; 这里 Object.wait() 与 WAITING 一致, main 等 <0x123>, owner worker 又停在同步 Binder. 进程汇总 CPU 不能直接归因给该 worker: 即使 app 进程 CPU 高, 也可能来自同进程其他线程. 下一步必须先核对 tid=31 的线程栈, 调度状态与采样证据; 若 TOTAL 接近满载而 app 低 CPU, 再看调度竞争; iowait 高则继续找磁盘路径, 不能只改锁.

真实 traces 常见的 Binder 对端线索形态可能是 BinderProxy.transactNative, nativePollOnce, binder thread 或 outgoing transaction, 并不保证直接打印 “对端进程名”.用 transaction ID, 时间戳, 服务名 (若 trace 可得) 和 Perfetto 的 binder/sched 轨道关联发起方与服务端; 若服务端线程又在锁或 I/O, 才形成 “main 等 worker, worker 等 Binder 对端” 的下一步判断. 修复可能是缩小锁范围, 禁止持锁 Binder, 给工作设置超时; 验证要重放触发路径, 检查 main 不再等待, 对端完成, 并观察线上 ANR rate.

最小采样上下文片段 (API 24+ Activity): FrameMetrics 统计的是窗口帧指标, 适合轻量趋势, 不替代 Perfetto 归因.

val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ ->
    val totalNs = metrics.getMetric(FrameMetrics.TOTAL_DURATION)
    if (totalNs > 16_700_000L) recordSlowFrame(totalNs) // 60Hz 近似阈值示例
}
window.addOnFrameMetricsAvailableListener(listener, Handler(Looper.getMainLooper()))
// onDestroy: window.removeOnFrameMetricsAvailableListener(listener)

必须保存 listener 并在 onDestroy 调用 window.removeOnFrameMetricsAvailableListener(listener). 代码中的 16_700_000L 仅是 60Hz 的粗筛示例, 不是通用慢帧阈值; Choreographer 相邻 frameTimeNanos 也只反映回调节奏, 需按刷新率, Frame Timeline 和用户可见 jank 分析.

高频面试题

Q1: ANR 和卡顿有什么区别?
卡顿是帧或交互未按预期 deadline 完成; ANR 是系统针对特定组件 / 交互超时做出的诊断结果. 短暂掉帧不一定 ANR, ANR 前也可能经历较长的不可响应.

Q2: 如何分析一个 ANR traces?
先看主线程状态和栈, 再找锁, Binder, I/O 或 CPU 调度证据; 如果主线程在等待锁, 要继续追 owner 线程, 不能停在表面栈帧.

易错点 / 追问

  • 不把所有 ANR 都写成固定 5 秒.
  • 不把 16.7 ms 当所有刷新率和所有阶段的硬阈值.
  • 不用 “全部放 IO 线程” 替代线程模型与取消设计.
  • 线上聚合和本地 Perfetto 各自解决不同问题, 需要关联使用.

版本与参考资料

性能工具专题

性能优化不是 “凭感觉改几行代码”, 而是先观测, 再定位, 再验证. 工具章节的面试重点是: 每个工具看什么, 适合什么问题, 输出如何转化为工程动作. 方法论, 指标口径与跨域取舍见 性能优化总览.

一, 工具选型总览

问题类型首选工具看到什么常见动作
启动慢Android Studio Profiler / Perfetto / MacrobenchmarkCPU, 主线程, 启动阶段耗时延迟初始化, 异步化, Baseline Profile
卡顿掉帧Perfetto / Systrace / gfxinfoChoreographer, RenderThread, 帧耗时减少主线程阻塞, 优化布局/绘制
内存泄漏LeakCanary / Memory Profiler / meminfo引用链, 堆大小, PSS解除生命周期引用, 缓存上限
I/O/网络违规StrictMode / Trace sections主线程磁盘/网络, 慢调用切线程, 缓存, 预加载
Native 热点simpleperf / PerfettoCPU sample, 调用栈, 符号优化算法, 减少 JNI 往返
线上复现困难dumpsys / bugreport / 自定义 trace系统状态快照关联日志, 设备维度排查

工具回答要避免 “列名词”, 而要说清楚 “我怀疑什么 → 用什么采样 → 看哪个指标 → 怎么验证改动”.

二, Android Studio Profiler

Android Studio Profiler 适合本地开发阶段快速观察 CPU, Memory, Network, Energy.

  • CPU Profiler: 看主线程是否有长任务, 方法调用耗时, 线程调度. 可用 Java/Kotlin Method Trace (分 Sampled 与 Instrumented 两种模式), 采样开销更低, 插桩更细但扰动更大.
  • Memory Profiler: 看 Java/Kotlin 堆, 对象分配, GC 频率, Heap Dump. 适合发现短时间内对象抖动和明显泄漏.
  • Network Profiler: 看请求时序, 流量大小, 频率, 但复杂线上网络问题仍要配合 OkHttp event listener, 服务端日志和抓包合规流程.
  • Energy Profiler: 关注 wakelock, 定位, 网络等耗电行为.

局限: Profiler 连接调试进程会带来观测扰动, 结论要用 release-like 包, Macrobenchmark 或 Perfetto 再验证.

三, Perfetto, Systrace 与 Trace sections

Perfetto 是现代 Android 系统级 tracing 工具, 可观察 CPU 调度, Binder, 线程状态, Choreographer, SurfaceFlinger, RenderThread, I/O 等. Systrace 是旧版工具, 很多概念仍沿用, 但新项目优先用 Perfetto.

import androidx.tracing.Trace

fun bindFeed(items: List<Item>) {
    Trace.beginSection("Feed.bind")
    try {
        adapter.submitList(items)
    } finally {
        Trace.endSection()
    }
}

Trace sections 的价值是把业务阶段写进系统 trace, 让你在 Perfetto 里看到 “哪段业务代码” 对应主线程长任务. 命名要稳定, 短, 能定位模块, 避免把用户敏感信息写入 trace 名称.

看 Perfetto 的常见路径:

  1. 先看主线程是否 Running 太久或频繁 Blocked.
  2. 看耗时段是否跨过 VSync 导致 missed frame.
  3. 看 RenderThread/GPU 是否被复杂绘制或纹理上传拖慢.
  4. 看 Binder, I/O, 锁等待是否阻塞主线程.
  5. 结合自定义 Trace sections 还原业务阶段.

四, Macrobenchmark 与 Baseline Profile

Macrobenchmark 用独立测试 APK 驱动目标 App, 在接近真实环境下测启动, 滚动, 页面跳转等宏观性能. 它适合做性能回归门禁, 比普通单元测试更贴近用户体验.

@Test
fun startup() = benchmarkRule.measureRepeated(
    packageName = "com.example.app",
    metrics = listOf(StartupTimingMetric()),
    iterations = 10,
    startupMode = StartupMode.COLD
) {
    pressHome()
    startActivityAndWait()
}

Baseline Profile 是把关键路径的类和方法提前提供给 ART, 安装后可更早编译热点代码, 改善冷启动和首帧性能. 典型流程是用 profileinstaller + androidx.baselineprofile Gradle 插件生成 / 合并 profile, 再用 Macrobenchmark 验证收益.

面试要点:

  • Macrobenchmark 关注稳定设备, 固定版本, 足够迭代次数和噪声控制.
  • Baseline Profile 不是万能优化, 主要改善解释执行 / JIT 预热带来的启动和关键路径抖动.
  • 性能数据要保存历史趋势, 否则无法判断回归.

五, LeakCanary 与内存定位

LeakCanary 自动观察 Activity, Fragment, ViewModel 等对象生命周期, 对象应被回收却仍被引用时触发 heap dump 并给出泄漏引用链.

常见泄漏原因:

  • 静态单例持有 Activity/Context.
  • Handler/Runnable/Coroutine 未随生命周期取消.
  • Adapter, Listener, Callback 未解绑.
  • Dialog/PopupWindow/Animator 持有 View.
  • 全局缓存无上限或 key/value 持有页面对象.

Memory Profiler 更适合看对象分配和堆变化, LeakCanary 更适合自动发现泄漏. 线上内存问题则要结合 OOM 日志, dumpsys meminfo, 业务埋点和设备分布分析.

六, StrictMode 与开发期红线

StrictMode 用来在开发/测试阶段发现主线程磁盘 I/O, 网络, 资源未关闭, Activity 泄漏等问题.

if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .penaltyLog()
            .build()
    )
}

它不是线上性能监控方案, 而是把 “本不该发生的开发期违规” 尽早暴露. 不要为了消除告警简单 permitAll, 应该定位调用链并把 I/O 移到合适线程或启动阶段之外.

七, dumpsys, gfxinfo, meminfo

dumpsys 是 Android 系统服务状态入口, 适合连接真机后快速拿到系统级快照.

命令关注点用法场景
adb shell dumpsys gfxinfo <pkg>帧耗时, Janky frames, 渲染统计页面滑动 / 动画掉帧
adb shell dumpsys meminfo <pkg>PSS, Java/Native/Graphics 内存OOM, 内存增长
adb shell dumpsys activity <pkg>Activity/Service/进程状态生命周期, 进程保活排查
adb shell dumpsys batterystats耗电归因后台任务, 网络, 唤醒

gfxinfo framestats 可导出每帧各阶段时间, 适合脚本化对比优化前后. meminfo 的 PSS 更接近进程实际占用视角, 但仍要结合 Android 版本, 厂商和图形内存差异理解.

gfxinfo 示意输出, 非真实测量:

Stats since: 123456789ns
Total frames rendered: 240
Janky frames: 18 (7.50%)
50th percentile: 10ms
90th percentile: 21ms
95th percentile: 28ms
99th percentile: 44ms
Number Missed Vsync: 12

Total frames rendered 是统计窗口内的渲染帧数, Janky frames 和百分比是工具口径下的提示, 分位数展示长尾, Number Missed Vsync 表示错过 VSync 的计数. 先确认采样窗口, 刷新率, 设备和场景, 再与同条件基线比较; 这些字段不能单独证明根因, 仍需用 Frame Timeline/Perfetto 区分 UI, RenderThread, GPU 或调度问题.

八, simpleperf 与 Native CPU 热点

simpleperf 是 Android 官方 native profiling 工具, 可采样 CPU 指令, 调用栈和符号, 适合 NDK/JNI, 音视频, 加解密, 图像处理等热点定位.

典型关注:

  • native 函数是否占用异常 CPU.
  • JNI 往返是否过多.
  • 锁竞争, 内存拷贝, 算法复杂度是否是瓶颈.
  • so 是否保留符号或能通过符号表 / 映射文件还原调用栈.

面试表达可以说: Java/Kotlin 层先用 Perfetto/Profiler 定位到 native 调用段, 再用 simpleperf 下钻 native 热点, 最后用基准测试和真实场景回归验证.

九, 电量优化的证据链

电量问题要先说明耗电来源, 再在相同条件下测量. 不应根据一次使用感受或工具截图宣称节电百分比.

耗电来源常见触发条件优先核查可能的工程动作
Wakelock后台任务未结束, 播放或下载未正确收尾batterystats 中的持锁时间, 任务生命周期缩短持锁范围, 用系统调度约束任务, 确保取消与完成路径释放资源
网络无线电高频小请求, 失败重试, 前后台切换请求批次, 网络类型, 传输字节, 重试次数合并可延迟请求, 使用缓存和退避, 避免非必要轮询
定位高精度持续订阅, 未随页面/任务停止精度, 间隔, 前后台状态, 定位回调次数按业务精度分级, 设置时长/距离条件, 任务结束后移除更新
后台任务无约束的定时任务, 重复调度WorkManager/Job 约束, 执行次数, 失败链路使用网络, 充电和空闲约束, 合并唯一工作, 设置合理退避
渲染负载长时间动画, 高频重组 / 绘制, 屏幕常亮Frame Timeline, RenderThread, GPU 和刷新率减少无意义重绘, 在不可见时暂停动画, 降低刷新频率或质量

测量步骤与记录表

  1. 写明唯一假设, 例如 “后台同步的网络批次会减少无线电激活次数”; 不把多个改动混入同一次对比.
  2. 固定或记录设备型号, 电池健康状态, Android 版本, App 构建版本, 屏幕亮度, 网络类型, 账号数据量和测试时长.
  3. 准备优化前后两个构建, 使用相同起始电量与业务脚本, 至少保留空闲或未执行业务的对照组.
  4. 用 Battery Historian 解析 batterystats, 用 Perfetto 观察调度, wakelock 和网络相关轨道, 或使用系统电量报告交叉核对. 工具字段和可见数据会随系统版本变化.
  5. 记录原始报告路径或可复现命令, 比较任务次数, 持锁时长, 网络字节, 定位回调和帧时间等中间证据; 再判断是否需要扩大样本或回滚改动.
字段本次记录
假设与改动版本[填写可复核的假设和 commit/build 标识]
设备与系统[型号, Android 版本, 电池状态]
网络, 屏幕与数据条件[Wi-Fi/蜂窝, 亮度, 账号/数据集]
测试时长与重复次数[填写]
对照组与工具报告[填写报告位置或命令]
观察到的中间证据[wakelock/网络/定位/帧时间等]
结论与限制[只填写可由上述条件复核的结论]

该表是读者练习和实验记录模板, 其中字段不是本项目实测数据. 电量受设备温度, 基带状态, 电池老化和系统调度影响; 单次测量只能形成假设证据, 不应外推为普遍指标.

命令, 输出与 Baseline Profile 操作

以下命令要求已启用 adb 调试, 目标设备允许 profile/trace, 且应在 release-like, 可复现场景中执行; 工具和权限限制随 Android 版本, user/userdebug 构建变化. 命令输出字段仅作解读示例, 不是本项目实测结果.

adb shell perfetto -o /data/misc/perfetto-traces/app.perfetto-trace -t 10s sched freq binder_driver gfx view
adb pull /data/misc/perfetto-traces/app.perfetto-trace .
adb shell dumpsys gfxinfo com.example.app framestats
adb shell simpleperf record -p <pid> -g --duration 10 -o /data/local/tmp/perf.data
adb pull /data/local/tmp/perf.data ./perf.data
simpleperf report -i ./perf.data

Perfetto 要先选择与假设对应的 data source; 打开 trace 后看 main/RenderThread 的 slice, sched 状态和 Binder/I/O 是否跨过 frame deadline. gfxinfo framestats 的每行代表一帧时间戳, Janky frames 是统计提示, 不能脱离刷新率和采样窗口判断根因. simpleperf 的顺序是: 设备端 record 写入 /data/local/tmp/perf.data, adb pull 生成其主机端副本 ./perf.data, 主机端 simpleperf report -i ./perf.data 读取该副本. 它们是同一采样数据文件的设备端与主机端副本, 并非 report 直接读取设备路径. report 按 sample 占比列函数; 先确认符号可用和调用栈, 再检查热点是否在目标场景稳定出现. 设备必须允许目标进程被 profile, user 构建, SELinux, profileable/debuggable 状态可能限制采样; 应按权限错误调整到获准的构建, 不能伪造结果.

Baseline Profile 配置片段 (需 androidx.baselineprofile 插件和独立 benchmark/profile module; 版本按项目 Gradle 统一管理):

baselineProfile {
    filter { include("com.example.app") }
}

Profile 生成场景应覆盖冷启动和关键交互, 生成后随 APK/AAB 打包; 用 Macrobenchmark 在固定设备, 构建和迭代数下对比. 若关键路径变动, 重生成 profile; 不把 Profile 当作替代主线程, 算法或 I/O 优化的手段.


问题, 工具, 证据与限制

选工具先从问题出发: 启动/帧用 Macrobenchmark 与 Perfetto, Java heap 用 heap dump, native allocation 用 heapprofd, 线上退出原因用平台 API/vitals. 每次记录设备, OS, 构建类型, profileable/debuggable, trace 配置和噪声. Profiler UI 与命令可能随 Android Studio/Perfetto 版本变化; 操作步骤应带版本, 单次 trace 只支持一个假设, 不直接证明因果.

高频面试题

Q1: Profiler, Perfetto, Systrace 怎么选? Profiler 适合开发期快速看单进程 CPU/内存/网络; Perfetto 适合系统级时序分析, 能看调度, 渲染, Binder, I/O; Systrace 是旧链路但概念相通. 复杂卡顿优先 Perfetto, 内存泄漏优先 LeakCanary/Memory Profiler.

Q2: Trace.beginSection 有什么价值? 它把业务阶段标记进系统 trace, 让 Perfetto 里能把主线程长任务和具体模块对应起来. 注意 section 名称要稳定, 简洁, 无敏感信息, 并保证 begin/end 成对.

Q3: Macrobenchmark 和普通 benchmark 区别? Macrobenchmark 从 App 外部驱动真实启动, 滚动, 跳转等宏观场景, 更贴近用户体验; 普通 microbenchmark 更适合测小函数 / 算法. 性能门禁通常用 Macrobenchmark 看趋势和回归.

Q4: Baseline Profile 解决什么问题? 它把启动和关键路径热点提前提供给 ART 编译, 减少冷启动 / 首帧阶段解释执行和 JIT 预热成本. 它不是替代业务优化, 仍要用 Macrobenchmark 验证收益.

Q5: LeakCanary 报出泄漏后怎么处理? 先看泄漏对象和引用链, 判断是否生命周期结束后仍被持有; 再定位静态引用, 回调, 协程, Handler, Adapter 等持有者; 修复后重新进入 / 退出页面并确认泄漏不再出现.

易错点 / 追问

  • 不要只贴工具截图, 要能说明指标含义和下一步工程动作.
  • 不要在 debug, 插桩, 连接 Profiler 的环境下直接宣称线上性能收益.
  • Perfetto 看到主线程长任务后, 要继续区分 CPU 忙, 锁等待, I/O, Binder 等原因.
  • Baseline Profile 需要随关键路径变化更新, 否则 profile 会逐渐失效.
  • StrictMode 告警不能靠关闭规则解决, 应修正线程和资源使用.

APM 与线上监控

☆ APM 把 “性能优化” 从本地经验升级成线上体系. 你的风控 SDK 背景可以重点讲 native crash, 采样, 灰度和宿主影响控制. 方法论, 指标口径与跨域取舍见 性能优化总览.

一, APM 监控什么

类型指标关键证据
CrashJava crash, native crash, 崩溃率堆栈, 版本, 机型, ABI
ANR主线程阻塞, 输入超时, 广播 / Service 超时traces, 主线程栈, 锁等待
卡顿慢帧, 冻结帧, 帧率Choreographer/FrameMetrics/Perfetto
启动冷启动, 首帧, 可交互时间launch trace, 业务埋点
网络成功率, 耗时, 错误码, 弱网interceptor, DNS/TLS/connect/read 分段

二, Crash 监控: Java 与 Native

Java crash 常通过 Thread.setDefaultUncaughtExceptionHandler 捕获; native crash 需要 signal handler 或 Breakpad/Crashpad 这类方案.

面试边界: 不要说 “所有 native crash 都能优雅恢复”.多数情况下只能采集现场, 下次启动上报, 灰度回滚.

三, ANR 与卡顿监控

  • ANR: 系统判定, 重点是拿到 traces 和主线程阻塞证据.
  • 卡顿: 应用侧可用主线程 watchdog, Choreographer 帧回调, FrameMetrics 监控.
  • 线上采样必须控制开销, 避免监控本身制造卡顿.

四, 启动与网络监控

启动监控要区分进程创建, Application, 首 Activity, 首帧, 业务首页可交互. 网络监控要拆 DNS, TCP, TLS, 请求, 响应, 解析, 不要只报一个总耗时.

五, 上报链路, 采样与隐私

  • 本地缓存: 避免 crash 当场丢失日志.
  • 批量上报: 减少电量和流量开销.
  • 采样: 高频事件不能全量.
  • 隐私: 不上传明文 token, 手机号, 身份证, 设备敏感字段.

六, 怎么把 SDK 经历讲成亮点

可以这样组织:“SDK 嵌入宿主 App 后, 我关注的不只是功能成功, 还要保证不拖累宿主稳定性. 我们会监控 native crash, 初始化耗时, 线程/网络开销, 灰度阶段看指标, 异常时能按版本/ABI/机型聚合定位.”

七, 线上定位闭环: 符号化, 聚类, 告警与回归检测

APM 面试的加分点是讲清 “采集之后怎么用”.只说捕获 crash 不够, 还要能把问题聚类, 定位, 告警, 灰度拦截.

Crash 符号化流水线

环节关键数据作用
构建归档versionCode, git sha, mapping, native symbols, build id确保线上堆栈能还原
崩溃采集Java stack, signal, 寄存器, 线程栈, ABI, 机型保留现场
符号化R8 mapping, so debug symbols还原混淆方法和 native 函数
聚类崩溃栈 fingerprint (稳定归类键: 规范化后的相同故障栈使用同一键), 版本, 设备维度找 Top N 问题而不是看单条日志

Native crash 尤其要强调 build id / 符号文件匹配. 没有对应版本的 symbols, 即使采集到了 tombstone 也很难定位.

ANR 与卡顿聚类

  • ANR 不只看主线程栈, 还要看锁等待, Binder 调用, IO, CPU 占用和发生场景.
  • 卡顿指标要用慢帧/冻结帧比例, P90/P99, 不要只报平均 FPS.
  • traces 需要按主线程栈 fingerprint 聚类, 否则线上会被大量重复样本淹没.

Dashboard 与告警

指标常用维度典型用途
crash-free users/sessions版本, 渠道, 系统, 机型, ABI判断能否继续灰度
ANR 率页面, 进程, 系统版本发现主线程 / 厂商 ROM 问题
启动 P50/P90/P99冷/温/热启动, 渠道, 机型档位识别长尾体验
网络成功率 / 耗时域名, 接口, 地区, 网络类型区分端侧和服务端问题

告警不要只设固定阈值, 还要看环比 / 同比和灰度版本对照. 小流量灰度时, 一个新增 Top crash 比整体 crash 率更敏感.

客户端可维护轻量环形日志, 记录页面跳转, 关键按钮, 网络错误, 配置版本等 breadcrumb. 注意只记录定位所需上下文, 不要写入 token, 手机号, 身份证, 精确位置等敏感数据.

APM SDK 自身开销

APM SDK 也要被监控: 初始化耗时, 线程数, 磁盘占用, 上报流量, 采样命中率, 丢弃原因. 面试可以说 “监控系统本身不能成为性能问题”.

采样, 聚合与告警预算

APM 与本地专项的分工是线上发现, 分群和归因. 客户端采样要稳定且可解释, 敏感日志最小化并有保留/删除策略; Crash/native 符号, mapping 和 build-id 必须与制品可追溯. 指标按版本, 设备, OS, 地区和网络聚合, 告警定义窗口, 分母, 基线和错误预算, 避免少量异常样本触发全量回滚.

可执行闭环: 符号还原到灰度验证

以下命令在本机构建产物齐全时使用, 路径和 ABI 必须与线上制品精确匹配; 输出仅说明用途, 不代表已执行或真实 crash. 命令中的 mapping.txt, obfuscated-stack.txt, app/build/intermediates/..., libfeature.so, tombstone.txt 和 0xOFFSET 都是必须替换的输入占位符. 其中 mapping 和混淆栈必须来自同一 Java 构建; 符号目录, so, tombstone 必须匹配 ABI/build-id, so 必须是未 strip 符号文件; 0xOFFSET 必须是根据正确 load bias 计算的该 so 相对地址.

retrace mapping.txt obfuscated-stack.txt
ndk-stack -sym app/build/intermediates/.../arm64-v8a -dump tombstone.txt
addr2line -Cfpe libfeature.so 0xOFFSET

retrace 用同一构建的 R8 mapping.txt 将混淆 Java 栈还原; ndk-stack 用未 strip 符号处理 tombstone; addr2line 把与正确 so build-id 对应的地址定位到函数 / 行. 若 mapping, 符号, ABI, load bias 或 build-id 不匹配, 输出不可作为定位证据. 构建阶段应归档这些制品并为每个上传事件记录 versionCode, build-id 与符号版本.

卡顿采样应只在超过定义阈值或按稳定采样率时收集轻量主线程栈/页面上下文, 设置时间与大小上限, 禁止采集敏感输入. 将 Java/native crash, ANR 和卡顿按规范化栈的 fingerprint (指稳定归类键) 聚类, 再按新增版本, 影响用户数, 核心路径和设备集中度排序.

假设性闭环演练, 非线上事件: 灰度版本出现新增 fingerprint, 先核验符号化与分母, 比较同机型的对照版本; 若影响扩大, 暂停放量并用远程开关降级, 保留样本与 trace. 修复进入下一小批灰度后, 观察 crash-free users, ANR rate, 启动分位数和该 fingerprint 的趋势, 满足预设观察窗口才扩大; 异常反弹则回滚. 这样避免用单条日志或平均值宣布问题已解决.

高频面试题

Q1: 线上卡顿怎么监控? 答: 轻量方案是主线程 watchdog 或 Choreographer 统计慢帧; 深入定位要结合 Perfetto/trace. 线上只采样关键指标, 本地复现再做完整 trace.

Q2: native crash 怎么定位? 答: 采集 signal, 寄存器, 线程栈, so build id/版本/ABI, 结合未 strip 符号或 symbol server 还原堆栈, 按版本和机型聚类.

Q3: APM SDK 会不会影响性能? 答: 会, 所以要采样, 异步, 批量, 延迟上报, 关键路径不做重 IO, 监控逻辑本身也要被监控.

Q4: 线上 crash 很多时怎么快速定位优先级? 答: 先按版本, 机型, ABI, 系统和堆栈 fingerprint 聚类, 看新增问题, 影响用户数, 是否阻断核心路径. Java crash 用 mapping 还原, native crash 用 build id 匹配 symbols. 灰度期如果出现新增 Top crash, 应暂停放量并用配置降级或热修复处理.

Q5: 为什么性能指标要看 P90/P99 而不只看平均值? 答: 平均值会掩盖长尾问题. 移动端用户体验常被低端机, 弱网, 特定 ROM 拉垮, 所以启动, 卡顿, 网络耗时都要看分位数和维度拆分.

易错点 / 追问

  • 不要把本地 Profiler 等同于线上 APM.
  • 不要上传敏感业务数据.
  • 不要只讲采集, 还要讲聚合, 告警, 灰度回滚和修复闭环.

存储体系与 Scoped Storage

存储选型要同时考虑数据模型, 容量, 进程模型, 备份, 加密, 迁移和用户可见性.“某个库最快” 或 “内部存储绝对不能放图片” 都不是完整结论.

一, 结构化配置: Preferences 与 Proto DataStore

方案适用场景关键边界
Preferences DataStore少量无 schema 的键值配置类型约束较弱, 默认实现不能笼统视为自动多进程安全
Proto DataStore有明确 schema, 默认值和演进需求的配置需要 protobuf schema 与序列化器
Room查询, 关系, 事务和较大结构化数据需要 migration, 索引和数据库测试

DataStore 1.1.0 及以上提供 MultiProcessDataStoreFactory; 它面向 Android 5.0(API 21) 及以上, 多个进程访问同一文件时每个进程仍只能创建一个实例. 单进程 DataStoreFactory/preferencesDataStore 与多进程工厂不能混用来访问同一路径, 否则可能破坏一致性. 先确认依赖版本和进程模型; 普通 Preferences/Proto DataStore 不是天然多进程方案.

以下为上下文片段: 依赖 DataStore 1.1+, SettingsSerializer 为应用的 Proto serializer. 所有进程传入同一个绝对 produceFile 路径; 每个进程由其进程内单例容器持有一个实例. 只要该文件被多进程访问, 所有进程都必须使用 MultiProcessDataStoreFactory, 不得有任何普通 DataStore 实例访问同一路径.

object SettingsStoreContainer {
    private var instance: DataStore<Settings>? = null

    fun get(context: Context): DataStore<Settings> = synchronized(this) {
        instance ?: MultiProcessDataStoreFactory.create(
            serializer = SettingsSerializer,
            produceFile = {
                File(context.filesDir, "datastore/settings.pb").absoluteFile
            },
        ).also { instance = it }
    }
}

class AppGraph(context: Context) {
    val settings: DataStore<Settings> = SettingsStoreContainer.get(context.applicationContext)
}

二, App-specific, MediaStore 与 SAF

  • App-specific 内部/外部目录适合应用私有文件, 缓存和可清理数据; 是否存图片/视频取决于容量, 生命周期, 备份和清理责任, 不是绝对禁止.
  • 用户可见媒体优先通过 MediaStore 管理.
  • 用户主动选择任意文档 / 目录使用 Storage Access Framework, 并持久化必要 URI 权限.
  • 缓存目录随时可能被系统或应用清理, 业务关键数据不能只放 cache.

三, 敏感数据与 Android Keystore

Jetpack Security Crypto 中 EncryptedSharedPreferences 等 API 已弃用, 不应作为新项目默认推荐. 迁移策略应根据数据威胁模型选择:

  1. 用 Android Keystore 生成 / 保存不可导出的密钥材料.
  2. 使用经过审查的加密库对数据做 AEAD 加密, 并保存版本, nonce/IV 和迁移信息.
  3. 对 token 优先考虑服务端短期凭据, 轮换, 撤销和最小存储, 而不是只做本地加密.
  4. 为旧密文设计分阶段读取, 重写和失败回退.

Keystore 密钥是否由 TEE, StrongBox 或其他硬件保护取决于设备与密钥属性, 不能保证 “一定在 TEE/SE”.需要高保证时读取 key security level/attestation, 并设计不支持设备的降级策略.

安全等级推演: 对 SecretKey 用 SecretKeyFactory/KeyStore.getKeyInfo() 取 KeyInfo, 读 isInsideSecureHardware(), getSecurityLevel() 等属性; 非对称密钥可看 key.getOrigin() 判断密钥是否在安全硬件内生成. 需要强绑定时用密钥证明 (attestation) 校验证书链, 判断密钥是否由可信 TEE/StrongBox 签发, 也可用于识别模拟器或降级环境. 模拟器 /root 等低保证环境下应降级: 不把本地密钥当作设备身份的唯一依据, 提高服务端校验强度并缩短敏感数据留存.

设备指纹 / 风控 SDK 的存储策略 (风控域案例)

风控 SDK 的存储策略与普通应用差异在于: 既要尽量维持设备身份跨重装的稳定性, 又要满足隐私最小化, 两者需要在存储层显式权衡.

  • 跨重装持久化: app-specific 数据在卸载后会被系统清除, 不能把 “本地唯一标识文件” 当作跨重装的持久凭据. 更稳妥的是 “重装后生成的稳定标识 + 服务端关联”: 本地只保存本次安装随机生成的标识与派生密钥, 由服务端用多信号把新标识关联回既有风险画像, 而不是依赖本地唯一文件跨卸载存活.
  • 卸载后的 Keystore 行为: Keystore 密钥通常随应用数据一并被清除, 是否残留取决于设备 / ROM 与厂商实现, 不能假设卸载后密钥仍可用; 密钥默认不可导出, 卸载清库后不存在 “导出恢复” 路径, 依赖该密钥加密的历史数据应视为不可恢复.
  • Keystore 与设备绑定: 不可导出属性保证私钥不离开安全硬件, 取证即使拿到设备镜像也拿不到私钥明文, 只能看到受密钥保护的密文; 但这依赖设备安全等级 (TEE/StrongBox 或纯软件实现), 不能一概而论.
  • 敏感采集数据的存储: 本地化, 最小化, 加密. 客户端只落盘风控所需的少量信号, 不存可还原身份的明文; 完整判断交给服务端, 客户端不长期留存采集原始数据.

实操: SAF 与 MediaStore 的边界

以下为上下文片段: 使用 ActivityResultContracts.StartActivityForResult 读取返回 Intent.flags, 放在 ComponentActivity 或 Fragment 中; 省略界面, 持久 URI 元数据表和错误提示. ACTION_OPEN_DOCUMENT 让用户选择既有文件, 拿到的是 content:// URI, 不是稳定文件路径.

private val openDocument = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult(),
) { result ->
    val uri = result.data?.data ?: return@registerForActivityResult
    val requested = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
    val granted = result.data!!.flags and requested
    if (granted == 0) return@registerForActivityResult
    try {
        contentResolver.takePersistableUriPermission(uri, granted)
        persistedUris.save(uri, granted) // 保存 URI 和同一组实际 grant flags
    } catch (error: SecurityException) {
        // 用户撤销授权或提供方拒绝持久化;提示重新选择.
    } catch (error: UnsupportedOperationException) {
        // 提供方未支持持久化授权;本次临时授权仍须按生命周期使用.
    }
    if (granted and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) {
        try {
            val input = contentResolver.openInputStream(uri)
                ?: throw IOException("Provider returned no input stream for $uri")
            input.bufferedReader().use { it.readLine() }
        } catch (error: IOException) {
            // 读取失败不阻断随后独立的写入尝试;按业务提示或重试.
        } catch (error: SecurityException) {
            // 已获 grant 也可能被撤销;写入仍由其独立边界决定.
        }
    }
    if (granted and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
        try {
            val payload = "updated by InterviewDemo\n".toByteArray(Charsets.UTF_8)
            val output = contentResolver.openOutputStream(uri, "wt")
                ?: throw IOException("Provider returned no output stream for $uri")
            output.use { it.write(payload) }
        } catch (error: IOException) {
            // 写入失败不影响已完成的读取;按业务提示或重试.
        } catch (error: SecurityException) {
            // 写入授权可能已被撤销;读取结果不受影响.
        }
    }
}

fun chooseTextFile() = openDocument.launch(
    Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE)
        .setType("text/plain")
        .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION),
)

fun releaseDocument(uri: Uri, persistedFlags: Int) {
    contentResolver.releasePersistableUriPermission(uri, persistedFlags)
    persistedUris.remove(uri)
}

预期: 只对实际返回的 read/write grant 持久化, 读写和释放; 若提供方未授予写权限, 绝不尝试写入. openOutputStream(uri, "wt") 的 wt 请求写入时覆盖 / 截断既有内容, 不能用于追加语义; 示例写入明确的非空 UTF-8 payload. 持久化成功后保存的 flags 是释放时唯一可用的同一组 flags, 不能凭请求时 flags 猜测. 用户在系统设置撤销授权, 提供方删除文件或应用主动释放授权后都必须处理 SecurityException. 适用于支持 SAF 的 Android 4.4(API 19) 及以上, 具体文档提供方能力不同.

以下为上下文片段: 保存用户明确要求导出的 JPEG 到共享图片库. API 29+ 用 IS_PENDING/RELATIVE_PATH, 应用创建自己的媒体通常不需要广泛存储写权限; API 28 及以下使用 DATA, 并须在 Manifest 声明且运行时取得 WRITE_EXTERNAL_STORAGE 后再写入. 读取其他媒体时, API 33+ 按媒体类型请求 READ_MEDIA_IMAGES 等权限, 旧版本使用 READ_EXTERNAL_STORAGE; 优先考虑 Photo Picker. 权限和行为仍须按设备 API, targetSdk 与商店政策复核.

val legacyFile = if (Build.VERSION.SDK_INT < 29) {
    val parent = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
    check(parent.exists() || parent.mkdirs()) { "Cannot create ${parent.absolutePath}" }
    File(parent, "export-${System.currentTimeMillis()}-${UUID.randomUUID()}.jpg")
} else {
    null
}
val values = ContentValues().apply {
    put(MediaStore.Images.Media.DISPLAY_NAME, "export-${System.currentTimeMillis()}.jpg")
    put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
    if (Build.VERSION.SDK_INT >= 29) {
        put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/InterviewDemo")
        put(MediaStore.Images.Media.IS_PENDING, 1)
    } else {
        put(MediaStore.Images.Media.DATA, requireNotNull(legacyFile).absolutePath)
    }
}
val collection = if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
var uri: Uri? = null
try {
    uri = requireNotNull(contentResolver.insert(collection, values))
    requireNotNull(contentResolver.openOutputStream(requireNotNull(uri))).use { it.write(jpegBytes) }
    if (Build.VERSION.SDK_INT >= 29) contentResolver.update(requireNotNull(uri), ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
} catch (failure: Exception) {
    uri?.let { contentResolver.delete(it, null, null) }
    legacyFile?.delete() // insert 或写入失败均清理旧版残留文件
    throw failure
}

选择路径: 应用私有草稿用 app-specific 目录; 用户可见的图片, 视频, 音频用 MediaStore; 用户选择的任意文档或目录用 SAF; 关系查询和事务用 Room; 少量配置用 DataStore. 媒体读取权限, Photo Picker 和旧版外部存储兼容规则随 API/targetSdk 变化, 应在发布时按官方行为变更表复核.

自测 (在 Android 工程执行):(1) 选只读 provider 的文档, 预期只保存 read grant 且不写入;(2) 选可写测试文档, 先写入非空 UTF-8 payload, 再验证 wt 会覆盖 / 截断旧内容, 不能以空写成功作为证据;(3) 重启后用保存的 flags 读取, 再释放后预期得到 SecurityException;(4) 在 API 28 与 API 29+ 设备各导出一张图, 预期前者有唯一文件名, 父目录与运行时写权限, 后者写完前不出现在图库, 且 insert / 写入失败时 MediaStore 行和残留文件均被删除;(5) 在两个进程同时读写同一路径, 预期均经各自单例的 MultiProcess DataStore 实例访问, 日志中没有普通 DataStore 访问该路径.

四, 空间, 内存与清理信号

Application.onTrimMemory() 是内存压力 / 进程状态回调, 不是磁盘空间不足通知. 磁盘治理应主动检查可用空间, 控制缓存配额, 响应写入失败, 并提供用户可理解的清理策略.

  • 缓存使用 LRU/TTL/配额并允许重建.
  • 关键写入采用临时文件 + fsync / 原子替换等策略 (按数据重要性权衡成本).
  • 大文件下载支持断点, 校验, 失败清理和容量预估.
  • 断点上传的时序与校验细节见 30 网络排障专项.

五, Room 迁移

Room schema 演进本质是数据库文件的原地升级: 迁移失败或破坏性迁移会直接清空既有本地数据, 属于存储层备份与清理责任的范畴. Room 会在已注册的 migration 图中找一条可用路径, 但不要承诺 “自动寻找最短路径”; 每次 schema 变化都应有明确迁移或可接受的破坏性策略, 并用导出的 schema + MigrationTestHelper 覆盖多版本升级测试. 迁移类写法, AutoMigration 边界与 fallbackToDestructiveMigration() 的代价见 31 数据库进阶.

六, MMKV 等第三方 KV

MMKV 可以作为特定性能 / 多进程场景的候选, 但不是无条件最佳实践. 选型前比较:

  • 数据一致性, 崩溃恢复和多进程语义;
  • 加密与密钥管理;
  • 数据迁移, 可观测性和维护状态;
  • 实际设备上的读取/写入/启动 benchmark;
  • 与 DataStore/Room 的团队维护成本.

高频面试题

Q1: Preferences DataStore 和 Proto DataStore 怎么选?
少量简单配置可用 Preferences; 需要 schema, 类型安全, 默认值和演进时使用 Proto. 多进程需求要单独选择对应实现, 不能由名字推断.

Q2: Keystore 是否保证硬件安全?
不保证所有设备都一样. 密钥可能由 TEE/StrongBox 等级保护, 也可能只有软件级保护, 应读取安全等级或使用 attestation 判断.

Q3: Scoped Storage 下如何让用户选择文件?
通过 SAF 获取用户授权的 URI, 按需持久化 URI permission, 并使用 ContentResolver 读写; 不要依赖真实文件路径.

易错点 / 追问

  • Type DataStore 的正确名称是 Proto DataStore.
  • onTrimMemory() 不表示磁盘空间不足.
  • 不继续把已弃用的 EncryptedSharedPreferences 当新项目默认方案.
  • 不把 MMKV, Room, DataStore 按 “谁最快” 简单排名, 先看数据语义.

版本与参考资料

网络协议

网络是中级面试必考硬通货. 如果你有风控, 抓包对抗或 TLS 相关的可核验经历, 可用 HTTPS, 证书, TLS 以及中间人防护, 证书校验, 双向认证等内容说明经验边界; 否则按本篇知识点和练习证据准备.

本篇含知识点讲解 + 高频面试题.

一, HTTP 基础

  • 无状态: 每次请求独立, 靠 Cookie/Session/Token 维持状态.
  • 请求结构: 请求行 (方法 + URL + 版本)+ 请求头 + 空行 + 请求体.
  • 常用方法: GET (查, 幂等), POST (增, 非幂等), PUT (全量改, 幂等), DELETE, PATCH (部分改), HEAD, OPTIONS (预检).
  • GET vs POST: 两者首先由方法语义区分, 不是 “参数放哪” 的固定规则. GET 通常用于安全, 幂等的资源读取, 请求内容常编码在 URI / 查询参数中; 实际 URI 长度受客户端, 代理和服务端实现限制, HTTP 规范没有一个通用固定上限. POST 用于让目标资源按请求内容执行处理, 默认不幂等, 但可以通过明确的新鲜度信息和缓存键被缓存; 请求体也受服务器, 网关, 客户端和业务配置限制, 不能说 “无长度限制”.

状态码

  • 1xx 信息;2xx 成功 (200, 201, 204).
  • 3xx 重定向: 301 (永久), 302 (临时), 304 (协商缓存命中).
  • 4xx 客户端错: 400, 401 (未认证), 403 (无权限), 404, 405, 429 (限流).
  • 5xx 服务端错: 500, 502 (网关错误), 503 (不可用), 504 (网关超时).

逐码的排障定位, 重试与退避策略见 见 30.

缓存

  • 强缓存: Cache-Control(max-age), Expires, 不发请求直接用本地.
  • 协商缓存: ETag/If-None-Match, Last-Modified/If-Modified-Since, 发请求由服务端判断, 命中返 304.

报文逐行解读与登录态边界

下面是数字示例, 用于解释一对 HTTP/1.1 POST/200 报文, 非可直接执行代码.

POST /v1/sessions HTTP/1.1
Host: api.example.test
Accept: application/json
Content-Type: application/json
Content-Length: 15

{"email":"a@b"}

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 11
Cache-Control: no-store

{"ok":true}

请求行给出方法/目标/版本; Host 路由虚拟主机; Content-Type 说明 body 表示; 请求 JSON 的 UTF-8 字节数为 15, 因此 Content-Length: 15. 响应 body {"ok":true} 是 11 字节, 故为 Content-Length: 11. no-store 禁止保存敏感响应; no-cache 是复用前重验证. max-age 到期后可携带 ETag 的 If-None-Match, 匹配时 304 且复用旧 body.

HTTP/1.1 也可用 Transfer-Encoding: chunked: 每块以十六进制长度, CRLF, 数据, CRLF 传输, 最后以 0 块结束. 接收端不得同时依赖 Content-Length 和 Transfer-Encoding 定界: CL/TE 冲突可能导致 request smuggling, 应按 RFC/网关策略拒绝或规范化. 没有 CL/TE 时响应可由连接关闭定界, 但不适用于可复用连接. HTTP/2/3 使用各自帧层, 不使用 HTTP/1.1 chunk framing; 204, 304 和对 HEAD 的响应不得带 message body.

Cookie 是客户端随匹配域 / 路径规则自动附带的状态载体; Session 是服务端用 cookie 值关联的状态; Token 是客户端显式放入如 Authorization: Bearer <ACCESS_TOKEN> 的凭据格式, 未必天然无状态. 移动端应使用 HTTPS, 限制 token 生命周期和刷新流程, 日志只保留是否存在 / 哈希或长度等诊断信息, 不能输出原文.

Cookie 的 Domain 决定可发送的主机范围, 省略时为 host-only; Path 限制路径但不是访问控制; Secure 仅经 HTTPS 发送; HttpOnly 阻止脚本读取; SameSite 限制跨站自动携带; Expires/Max-Age 决定持久期. 登录成功, 提权和密码变更后轮换 session ID, 防 session fixation.access token 应短期, 仅通过 TLS 发送; refresh token 仅在受控刷新端点使用, 可轮换/撤销, 移动端放在按威胁模型选择的受保护存储而不是日志/URL. 登录, 刷新, 注销以关联 trace 记录事件结果和 token 版本: 登录签发 access/refresh, access 过期仅走单一刷新流程, 刷新失败或注销即撤销 refresh 并清除本地凭据; 不能记录 token 原文. 撤销 refresh 和删除本地凭据不会使已经签发的自包含 access token 立即无效, 默认只能等它到期; 高风险系统应使用更短 TTL, 并按风险采用 denylist, token version 校验或可撤销的引用 token. Cookie 自动附带时需防 CSRF; 将 token 置入可被脚本读取的位置会扩大 XSS 泄漏风险; 二者都不能被 “使用 HTTPS” 替代.

自测: (1) 用 curl --raw 向测试服务发送 chunked body, 预期服务按块重组; (2) 发送 CL/TE 冲突报文, 预期网关拒绝而非转发歧义请求; (3) 登录后检查 session 轮换, 注销后 refresh token 被拒绝, 日志只含 trace 和结果.

二, TCP/IP 分层与数据封装

TCP/IP 是面向实际网络通信的四层抽象, 与 OSI 七层模型不是逐层一一对应的关系. 例如 OSI 的会话层和表示层常由 TCP/IP 应用层协议或应用程序承担, 排障时应先说明所用模型, 不要把两者硬性等同.

TCP/IP 层主要职责常见协议或机制数据单元
应用层定义业务语义, 消息格式和应用认证HTTP, HTTPS, DNS, WebSocket数据 / 报文
传输层端到端进程通信, 端口复用与传输语义TCP, UDP, QUICTCP 段 / UDP 数据报 / QUIC 包
网际层跨网络寻址与路由转发IP, ICMPIP 数据报
网络接口层在本地链路上传送帧Ethernet, Wi-Fi, ARP帧

一次 HTTPS 请求的封装可按下列路径理解: 应用数据先形成 HTTP 报文, 通常交给 TLS 记录层保护, 再由 TCP 分段, 封装进 IP 数据报和链路帧. 接收端按相反方向解封装. HTTP/3 的路径不同: HTTP/3 报文由 QUIC 承载, QUIC 包再进入 UDP 数据报; QUIC 在用户态实现连接, 可靠传输, 流控制和流的有序交付等能力, UDP 只提供无连接的数据报承载.

HTTP/1.1 或 HTTP/2 over HTTPS:
HTTP data -> TLS records -> TCP segments -> IP packets -> link frames

HTTP/3:
HTTP/3 data -> QUIC packets -> UDP datagrams -> IP packets -> link frames

HTTPS, TCP, UDP 与 Socket 的边界

HTTPS 是 HTTP 叠加 TLS 的应用层协议. TCP 和 UDP 是传输层协议. Socket 不是网络协议, 而是操作系统提供的编程接口抽象: 应用通过 socket 选择地址族和传输类型, 再读写字节流或数据报. 一个常见的 HTTPS 客户端通常使用 TCP socket; 支持 HTTP/3 的客户端则使用 UDP socket 与 QUIC 栈通信.

对象所在抽象层连接与交付语义加密常见适用场景
HTTPS应用层HTTP 语义; 通常经 TLS over TCP 可靠有序传输, HTTP/3 时经 QUICTLS 提供登录, 支付, API 和网页
TCP传输层面向连接, 可靠, 有序字节流不自带文件传输, 传统 HTTP/HTTPS
UDP传输层无连接数据报, 不保证到达, 顺序或去重不自带DNS, 实时音视频的媒体数据, QUIC 承载
SocketOS 编程接口取决于选择 TCP, UDP 或其他协议族取决于上层协议客户端, 服务端和本地进程间通信

不能因为 UDP 不提供 TCP 的可靠性保证就称 UDP “不安全”: 机密性和身份认证由 TLS 等安全协议决定, 可靠性, 顺序和拥塞控制也可以由 QUIC 或应用协议按业务需求实现. 反过来, TCP 可靠有序也不等于加密; 未叠加 TLS 的 TCP 流量仍可能被窃听或篡改.

三, HTTP 版本演进

版本关键特性解决的问题
HTTP/1.0短连接, 每次请求新建 TCP—
HTTP/1.1长连接 (keep-alive), 管线化, Host 头, 分块传输复用连接
HTTP/2二进制分帧, 多路复用 (一个连接并发多请求), 头部压缩 (HPACK)队头阻塞 (应用层)
HTTP/3基于 QUIC over UDP, 0-RTT, 连接迁移TCP 队头阻塞, 握手延迟
  • HTTP/1.1 队头阻塞: 同一连接请求需按序响应, 前一个慢会卡后面. HTTP/2 多路复用在应用层缓解, 但 TCP 层仍有队头阻塞; HTTP/3 基于 QUIC/UDP, 通过独立 stream 降低 TCP 层队头阻塞影响.
  • WebSocket: 提供双向消息通道. HTTP/1.1 常通过 Upgrade 建连; HTTP/2 可使用扩展 CONNECT, HTTP/3 也有相应扩展 CONNECT 机制. 具体客户端/服务端支持需要按协议版本和实现核验, 不能只描述 HTTP/1.1 Upgrade.

四, HTTPS 与 TLS (按证据准备)

HTTPS = HTTP + TLS/SSL, 解决三个问题:加密 (防窃听), 完整性 (防篡改), 身份认证 (防冒充).

TLS 握手: RSA 密钥交换 vs ECDHE

TLS 的核心思想是:先认证身份并协商出对称会话密钥, 后续用对称加密传 HTTP 数据. 对称加密快, 但首次密钥分发困难; 非对称 / 密钥交换负责解决首次协商与身份认证.

维度RSA 密钥交换 (TLS 1.2 旧式)ECDHE 密钥交换 (现代主流)
证书公钥用途客户端用证书里的 RSA 公钥加密 pre-master secret证书用于验证服务端身份; 临时 ECDHE 公钥用于密钥交换
会话密钥来源pre-master secret + 双方随机数派生双方临时椭圆曲线私钥 / 公钥计算共享秘密 + 随机数派生
前向保密没有. 服务端私钥泄露后, 历史抓包可能被解密有. 临时私钥握手后丢弃, 长期证书私钥泄露也难解历史流量
面试风险点容易把 “证书公钥加密密钥” 当成所有 TLS 的固定流程要说清 “证书认证身份, ECDHE 协商密钥”

TLS 1.2 RSA 简化流程

  1. Client Hello: 客户端发支持的 TLS 版本, 加密套件, 随机数.
  2. Server Hello: 服务端选择套件, 返回随机数, 下发证书 (含公钥).
  3. 客户端验证证书 (CA 链, 域名, 有效期, 吊销状态等), 生成 pre-master secret, 用服务端 RSA 公钥加密后发送.
  4. 服务端用私钥解密, 双方用 pre-master secret + client_random + server_random 派生对称会话密钥.
  5. 双方发送 Finished 校验握手完整性, 之后用对称加密通信.

TLS 1.2 ECDHE / TLS 1.3 直觉

  • ECDHE: 服务端发送临时 ECDHE 公钥并用证书私钥签名, 客户端验证签名后也生成临时公钥; 双方各自用 “自己的临时私钥 + 对方临时公钥” 算出共享秘密. 网络上没有直接传输会话密钥.
  • TLS 1.3: 移除静态 RSA 密钥交换等旧套件, 把常规握手压到 1-RTT: ClientHello 携带 key share, ServerHello 返回 key share 和证书相关消息, 双方很快得到密钥.
  • TLS 1.3 握手序列 (1-RTT): ClientHello (含 key_share) → ServerHello (返回 key_share) 连同服务端证书 / CertificateVerify / Finished 一起到达 → 客户端验证证书并回 Finished → 应用数据. 相比 TLS 1.2 完整握手典型 2-RTT 后才能发应用数据, TLS 1.3 把证书与密钥协商结果合并在第一个 RTT 返回, 第二个 RTT 客户端即可发应用数据.
  • 0-RTT: 客户端基于上次会话恢复提前发送早期数据, 降低重连延迟; 边界是早期数据可能被重放, 所以只适合幂等 GET / 查询类请求, 不适合支付, 下单, 转账, 登录态变更等非幂等操作.

面试一句话: 旧 RSA 像 “用证书公钥包住会话密钥”, 现代 ECDHE/TLS 1.3 更像 “证书负责证明你是谁, 临时密钥交换负责生成本次会话密钥”, 因此具备前向保密.

证书与信任链

  • 证书由 CA 逐级签发, 设备内置根 CA. 验证时沿链校验到可信根.
  • 为什么不能只用对称加密? 密钥分发难题: 首次怎么安全传密钥? 用非对称解决.
  • 为什么不全用非对称? 慢. 所以只用它协商对称密钥.

证书校验至少包含: 验证链能否连到受信任锚点, 当前时间是否在有效期, 叶子证书 SAN 是否匹配请求主机名, 签名 / 用途和平台策略是否允许. 吊销检查, CT, 用户安装 CA 与网络安全配置的具体行为受 Android 版本, 网络栈和服务端能力影响. 不要为 “连通” 跳过 hostname verifier 或自定义信任所有证书; 这会把 HTTPS 降回可被中间人替换的明文等价风险.

主机名校验不能省: 即使证书链验证到受信根, 若叶子证书 SAN 不含所请求的 hostname, 也必须拒绝连接. 两步缺一不可: 链验证证明 “证书可信”, 主机名校验证明 “对方就是你要连的域名”, 跳过任一步都可能被中间人替换.

安全实战 (按可核验证据展开)

  • 中间人攻击 (MITM): 攻击者伪造证书. 防御靠客户端严格校验证书.
  • 证书锁定 (SSL Pinning): App 内置服务端证书/公钥指纹, 只信任它, 防抓包/MITM: 风控/金融 App 标配.
  • 双向认证 (mTLS): 服务端也验证客户端证书.
  • 抓包对抗: Charles/Fiddler 可通过安装根证书抓取 HTTPS; App 可用 Pinning + 检测代理对抗. 仅在有可披露, 可核验经历时, 将其作为实战案例说明.

五, TCP / UDP

TCP 三次握手

  1. Client → SYN(seq=x)
  2. Server → SYN+ACK(seq=y, ack=x+1)
  3. Client → ACK(ack=y+1) 为什么三次? 确认双方收发能力都正常; 两次无法确认客户端的接收能力, 且防止历史失效连接请求建立连接.

TCP 四次挥手

  1. Client → FIN 2. Server → ACK 3. Server → FIN 4. Client → ACK 为什么四次? 关闭是双向的, 服务端收到 FIN 后可能还有数据要发, 所以 ACK 和 FIN 分开. TIME_WAIT(2MSL): 主动关闭方等待, 确保最后 ACK 到达 + 让旧报文消散.

可靠性机制

序列号 + 确认应答, 超时重传, 滑动窗口 (流量控制), 拥塞控制.

拥塞控制: 看懂 cwnd / ssthresh 转换

  • cwnd(congestion window): 发送端根据网络拥塞程度维护的拥塞窗口, 限制 “未确认在途数据量”.
  • ssthresh(slow start threshold): 慢启动阈值, 决定从指数增长切到线性增长.
  • 实际发送窗口通常取 min(cwnd, rwnd): 拥塞控制看网络承载能力, 流量控制看接收端缓存能力.
阶段触发 / 进入条件cwnd 变化退出条件
慢启动连接刚建立或超时后重新探测每个 RTT 近似翻倍 (指数增长)cwnd >= ssthresh 转拥塞避免; 或丢包
拥塞避免cwnd 达到 ssthresh每个 RTT 近似 +1 MSS (线性增长)丢包 / 重复 ACK
快重传收到 3 个重复 ACK, 推测某段丢失但网络仍有流动不等超时, 立即重传疑似丢失段进入快恢复
快恢复快重传之后通常把 ssthresh 设为丢包前 cwnd 的一半, cwnd 降低后线性恢复新 ACK 到达后回到拥塞避免

超时 vs 快重传的区别:

  • 超时重传说明网络可能严重拥塞或 ACK 完全回不来, 处理更保守: 常见做法是 ssthresh = cwnd / 2, 然后 cwnd 回到很小值重新慢启动.
  • 3 个重复 ACK 说明后续包还能到达, 网络没有完全断流, 所以只减半窗口并快恢复, 比超时温和.
新连接/超时
  ↓ cwnd 指数增长
慢启动 ── cwnd >= ssthresh ──> 拥塞避免(线性增长)
  │                              │
  └── 超时:ssthresh=cwnd/2,cwnd 重置 ─┘
                                 │
                                 └── 3 dup ACK:快重传 → 快恢复 → 拥塞避免

数字推演 (示意值): 现代 Linux 常见初始 cwnd 为 10 MSS (RFC 6928, 标注为常见默认而非协议强制). 慢启动每个 RTT 近似翻倍: 第 1 个 RTT 后约 20 MSS, 第 2 个约 40 MSS, 第 3 个约 80 MSS. 若 ssthresh 为 64 MSS (示意), 则约在第 3 个 RTT 触达阈值并切到拥塞避免的线性增长; 具体第几个 RTT 取决于 ssthresh 与丢包 / 显式拥塞反馈, 不能当固定值.

面试答题流: 先区分可靠性 (序号/ACK/重传) 与拥塞控制 (保护网络), 再解释 cwnd/ssthresh, 最后用 “超时更严重, 快重传更温和” 讲阶段转换.

TCP vs UDP

TCPUDP
连接面向连接无连接
可靠可靠有序不承诺可靠性
速度慢快
场景HTTP, 文件音视频, DNS, QUIC

高频面试题

Q1: HTTP 和 HTTPS 区别? HTTPS = HTTP + TLS, 提供加密, 完整性, 身份认证. HTTP 默认明文 80 端口, HTTPS 默认加密 443 端口, 需证书, 有握手开销但安全.

Q2: TLS 握手为什么用非对称 + 对称结合? 非对称 / 密钥交换解决首次协商和身份认证, 对称加密负责后续高性能传输. 旧 RSA 是客户端用证书公钥加密 pre-master secret; 现代 ECDHE/TLS 1.3 用临时密钥交换生成会话密钥, 证书主要证明服务端身份, 并提供前向保密.

Q3: TCP 为什么三次握手不是两次? 三次才能确认双方收发能力都正常, 并防止已失效的历史连接请求突然到达导致错误建连. 两次无法确认客户端接收能力.

Q4: TIME_WAIT 是什么? 为什么等 2MSL? 主动关闭方在四次挥手后进入 TIME_WAIT, 等 2 倍报文最大生存时间: 确保最后的 ACK 能到达对端 (否则对端重传 FIN), 并让本连接的旧报文在网络中消散.

Q5: HTTP/2 相比 1.1 的核心改进? 二进制分帧, 多路复用 (一个连接并发多请求, 解决应用层队头阻塞), 头部压缩 HPACK. 服务端推送是早期规范 (RFC 7540) 特性, 但 Chrome 等主流浏览器已移除支持, 不属当前核心特性.

Q6: 什么是 SSL Pinning? 为什么风控 App 要用? 客户端内置服务端证书或公钥指纹, 握手时只信任它而非系统 CA, 防止中间人用伪造证书抓包 / 篡改. 金融, 风控 App 用它对抗抓包和 MITM.

Q7: 输入 URL 到页面展示发生了什么? DNS 解析 → 建立 TCP 连接 (三次握手)→ TLS 握手 (HTTPS)→ 发 HTTP 请求 → 服务端响应 → 客户端解析渲染 → 关闭 / 复用连接.

进阶补充: DNS, QUIC, TCP 状态与 TLS 细节

DNS 解析链路

域名访问前通常经历缓存查询, 本地 DNS, 递归解析. 移动端排查网络慢时要区分 DNS, TCP, TLS, 请求, 响应各阶段.

HTTP/3 与 QUIC

HTTP/3 基于 QUIC, 而 QUIC 运行在 UDP 之上. QUIC 将 TLS 1.3 握手与自身连接建立结合, 并在用户态实现可靠传输, 流级有序交付和拥塞控制; UDP 仍只是底层数据报承载. 这避免了 TCP 丢包导致同一连接所有 HTTP/2 流停顿的问题, 也支持连接迁移. 面试不要把 “UDP 不保证可靠性” 延伸为 “HTTP/3 不可靠” 或 “UDP 不安全”.

TIME_WAIT / CLOSE_WAIT 排障

状态常见原因排查方向
TIME_WAIT 多主动关闭方等待旧包消失连接复用, 服务端参数
CLOSE_WAIT 多本端未 close socket代码资源释放, 连接池

TLS 补充: SNI, ALPN, OCSP, 会话恢复

  • SNI: 客户端握手时告诉服务端目标域名.

  • ALPN: 协商 HTTP/1.1, HTTP/2 等应用协议.

  • OCSP: 检查证书吊销状态.

  • Session Ticket/PSK: 减少重复握手成本.

  • 追问: HTTP/2 和 HTTP/3 都解决什么问题? HTTP/2 多路复用仍受 TCP 队头阻塞影响; HTTP/3 基于 QUIC 改善连接迁移和队头阻塞.

网络排障专项

网络排障的重点不是背 HTTP 状态码, 而是把一次请求拆成 DNS → TCP → TLS → HTTP → 业务解析 → 本地缓存 / 重试. Android 面试尤其看你能否用 OkHttp, Charles, 日志和弱网策略定位真实线上问题.

一, 移动端网络排障总流程

先按链路分层, 不要一上来就改超时或重试. 每一层都要有证据: 耗时, 错误码, 异常类型, 抓包结果, 服务端日志.

用户反馈慢/失败
  ↓
确认网络类型/Wi-Fi/蜂窝/代理/VPN
  ↓
DNS 解析 → TCP 连接 → TLS 握手 → HTTP 请求/响应
  ↓
OkHttp EventListener/Interceptor 日志
  ↓
Charles/服务端 trace 对照
  ↓
重试,降级,缓存或服务端修复
现象优先看什么常见原因
首次请求慢DNS/TCP/TLS 阶段耗时DNS 慢, 冷连接, 证书链慢
只有 HTTPS 失败TLS 与证书证书过期, SNI, Pinning, 系统时间错误
4xx请求与鉴权token 过期, 参数错, 权限不足, 限流
5xx服务端 / 网关上游超时, 发布故障, 容量不足
弱网下大量失败超时/重试/连接池超时太短, 非幂等重试, 连接复用异常

二, DNS 慢与解析失败

DNS 问题常表现为 “首包慢, 部分地区失败, 切 Wi-Fi/蜂窝表现不同”.Android 端要区分 DNS 解析耗时和后续连接耗时.

  • 原因: 运营商 DNS 慢/污染, IPv6/IPv4 选择问题, DNS 缓存过期, 内网域名不可达, DoH/HTTPDNS 配置异常.
  • 证据: OkHttp EventListener.dnsStart/dnsEnd 耗时, 解析到的 IP, 网络类型, 地区, 失败异常.
  • 策略: 合理 DNS 缓存, HTTPDNS/DoH, Happy Eyeballs, 失败 IP 黑名单, 按域名维度监控.
  • Android 注意: 不要在主线程解析域名; 多域名, 多 IP 兜底要避免无限重试导致电量和流量浪费.

面试话术: 先证明是不是 DNS 慢, 再谈替代方案; HTTPDNS 能绕过运营商 DNS, 但要处理 HTTPS 证书域名校验, 调度准确性和缓存过期.

三, TLS 失败, 证书错误与 Pinning 调试

TLS 失败要按 “证书链, 域名, 时间, 协议套件, Pinning, 代理抓包” 逐项排除.

错误类型可能原因排查方向
证书过期 / 未生效服务端证书时间错误检查证书有效期与设备时间
Hostname verification failed证书 SAN 不含域名检查域名, SNI, CDN 证书
Trust anchor not found自签 / 链不完整补齐中间证书, network security config
Pinning failure公钥 / 证书指纹不匹配检查发布环境 pin 列表与轮换策略
Handshake failedTLS 版本 / 套件不兼容老设备, 服务端协议配置, ALPN

Charles 调试与 Pinning 策略:

  1. 开发 / 测试包可使用 debug-only network_security_config 信任用户 CA.
  2. Pinning 必须区分 debug/release: debug 可关闭或使用测试 pin, release 严格校验.
  3. 不要为了抓包在线上包硬编码关闭 Pinning; 应该用构建变体, 白名单测试域名或内部证书.
  4. Pinning 要支持证书轮换: 至少保留当前和备用公钥 pin.

Charles 配置步骤 (操作步骤, 未在本仓库执行验证):(1) 让测试设备与 Charles 主机处在可达网络, 按工具显示的主机和端口设置 Wi-Fi HTTP 代理;(2) 在设备浏览器访问 Charles 提示的地址并仅为测试设备安装其 CA;(3) 在 Charles 的 SSL Proxying 中仅加入测试域名 / 端口;(4) 使用仅 debug 变体信任用户 CA;(5) 完成后移除代理和测试 CA. Android 7 (API 24) 起, 默认不信任用户安装 CA 的应用应通过 debug-only networkSecurityConfig 明确配置, release 不应携带该信任配置. 抓包内容仍按敏感数据规则脱敏.

以下为上下文片段, 资源文件位于 src/debug/res/xml/network_security_config.xml, 并只允许测试域名; 引用它的 <application> 节位于 src/debug/AndroidManifest.xml. 仅在 targetSdk >= 24 且需要信任用户安装 CA 的 debug 测试场景配置, release source set 不引用它. 用户 CA 信任与 CertificatePinner 是两层独立策略: debug 可对测试 host 不配置 pin, release 仍为生产 host 配置当前 / 备用 pin.

<network-security-config><domain-config cleartextTrafficPermitted="false"><domain includeSubdomains="true">api.test.example</domain><trust-anchors><certificates src="system" /><certificates src="user" /></trust-anchors></domain-config></network-security-config>
<application android:networkSecurityConfig="@xml/network_security_config" />

system 与 user 同时列出, 表示仅为 debug 测试域名在正常系统公网信任锚上追加 Charles 用户 CA, 并非用用户 CA 替换系统锚; release 继续只使用其生产信任与 pinning 策略.

四, HTTP 4xx/5xx 与业务错误定位

HTTP 状态码要先分清 “协议层状态” 和 “业务层 code”.移动端常见误区是看到 500 就重试, 看到 401 就清登录态, 但真实原因可能更细.

  • 400: 参数格式, 签名, 时间戳, 序列化字段缺失.
  • 401: token 过期, 刷新 token 失败, 设备被踢, 匿名接口误带错误凭证.
  • 403: 无权限, 风控拦截, 地区 / 灰度策略不允许.
  • 404/405: 路径, 环境, 方法不一致, 常见于测试环境配置错.
  • 429: 限流, 客户端要退避而不是立刻重试.
  • 500/502/503/504: 服务端, 网关, 上游依赖或超时; 客户端要带 traceId 找服务端对日志.

谁在报错 (区分): 500 是应用自身异常; 502 是网关 (nginx/LB) 连不上上游或上游不可达; 503 是服务过载或正在维护, 常伴随 Retry-After; 504 是网关转发给上游后等待响应超时. 客户端对应动作: 502/504 偏网关与上游链路, 带 traceId 查网关和上游日志; 503 按 Retry-After 退避或降级, 不要无脑重试放大过载.

Android 实践:

  • Interceptor 统一注入 traceId, 版本, 网络类型, 便于端云对齐.
  • 401 刷新 token 要做单飞 (single flight), 避免并发请求同时刷新.
  • 4xx 多数不应盲重试; 5xx 可对幂等请求有限退避重试.

状态码协议层语义分类见 见 29.

五, 弱网络重试, 连接池与超时设置

弱网策略要平衡成功率, 电量, 流量和业务副作用. 不是 “失败就重试三次” 这么简单.

val client = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(15, TimeUnit.SECONDS)
    .writeTimeout(15, TimeUnit.SECONDS)
    .callTimeout(30, TimeUnit.SECONDS)
    .retryOnConnectionFailure(true)
    .connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
    .build()
  • connectTimeout: TCP 建连超时, 过短会误伤弱网, 过长会拖慢失败反馈.
  • readTimeout/writeTimeout: 读写单次阻塞超时, 适合控制传输阶段.
  • callTimeout: 一次 call 总预算, 避免 DNS/TLS/重试叠加无限拉长.
  • 连接池: 复用 TCP/TLS 连接降低握手成本; 但域名/IP/证书变化, 长时间 idle, 网络切换会导致旧连接失效.
  • 重试原则: GET / 查询类可退避重试; 下单, 支付, 登录态变更必须依赖幂等 key 或服务端去重.
  • 退避策略: 指数退避 + jitter, 避免所有客户端同时重试放大故障.

为什么是这个量级 (推导逻辑, 示意值): 超时是端到端预算, 不是拍脑袋. 先按 RTT 分位数估算: 正常网络 P50 在几十 ms 级, 3G/弱 WiFi 的 P99 可达秒级; 一次 HTTPS 请求要 1~2 个 RTT 建连 + 1~2 个 RTT TLS 握手 + 1 个 RTT 请求/响应, 所以 connectTimeout=10s 是给最差网络建连的预算, callTimeout=30s 是一次 call 的总预算兜底, 防止 DNS/TLS/重试叠加无限拉长. 具体值应基于线上耗时分位数重新校准: 与其让 1% 的请求拖 60s, 不如让 99% 的请求在预算内尽快失败并走降级/重试.

下面为伪代码, 说明可观测的退避计算, 不能直接作为 OkHttp 自动重试实现. Retry-After, 业务截止时间与服务端幂等语义优先于客户端猜测.

if request is idempotent OR has server-supported idempotency key
   AND failure is transient
   AND attempt < 3
   AND now < requestDeadline:
    cap = min(8 seconds, 500 ms * 2^attempt)
    delay = random(0, cap)       # full jitter
    schedule one retry after delay
else:
    surface failure or enqueue business-defined compensation

POST /orders 只有服务端按 Idempotency-Key 去重并返回同一业务结果时才可安全重试; 仅 “客户端生成了 UUID” 不足以保证. 每次重试须携带同一 traceId 的关联字段和新的 attempt 序号, 避免把一次操作误计为多次业务请求.

六, OkHttp Interceptors, EventListener 与 Charles

OkHttp 排障工具分两类: Interceptor 看请求 / 响应内容, EventListener 看阶段耗时. 两者结合才能回答 “慢在哪里”.

  • Application Interceptor: 业务层, 适合加公共 header, 日志, 签名, token, 业务错误处理.
  • Network Interceptor: 网络层, 能看到重定向, 网络响应, 缓存细节.
  • EventListener: 记录 DNS, connect, secureConnect, requestHeaders, responseHeaders 等阶段耗时.
  • Charles: 验证请求是否发出, header/body 是否正确, TLS 证书链, 代理环境下服务端响应.

排障 checklist:

  1. 打印 URL, method, traceId, 状态码, 异常类型, 各阶段耗时.
  2. Charles 对照请求头, body, 证书和响应.
  3. 断网/弱网/代理/VPN/IPv6 场景复现.
  4. 和服务端用 traceId 对齐网关日志.
  5. 修复后保留监控指标, 避免同类问题复发.

七, 断点上传: 协商, 恢复与服务端一致性

断点上传不是客户端记住已上传字节数就完成. 客户端和服务端必须以同一个上传会话和已确认范围为准, 否则网络中断, 重试或并发上传会造成重复片段和错误合并.

环节客户端责任服务端责任
创建会话请求上传会话, 保存 uploadId, 文件大小, 分片大小和文件摘要返回唯一会话与过期时间, 记录目标对象和协议版本
上传分片为每片携带分片序号或字节范围, 长度和分片校验值校验范围, 长度和摘要, 原子记录已确认分片
中断恢复查询服务端已确认范围 / 分片清单, 仅补传缺失部分返回权威状态, 拒绝过期或不匹配的会话
完成合并请求完成并携带文件级摘要或幂等完成键确认所有片段齐全后合并, 校验完整性, 原子发布结果

最小协议流程

  1. 客户端计算或读取文件大小与文件级校验值, 请求创建上传会话, 保存服务端返回的 uploadId 和分片规则.
  2. 每个分片使用稳定的分片标识, 或使用 Content-Range 等明确偏移 / 结束位置的协议字段. 同一片的重试必须携带相同会话和分片标识.
  3. 服务端按 (uploadId, partNumber) 或等价范围建立幂等写入: 已收到且校验相同的重复请求返回已确认结果, 校验不同则拒绝而非覆盖.
  4. 网络失败后, 客户端先查询服务端状态. 本地偏移只能作为恢复提示, 不能覆盖服务端权威记录. 对可恢复的超时采用有限退避重试, 对 4xx, 校验失败或会话过期提示重新创建或重新选择文件.
  5. 客户端调用完成接口后, 服务端检查片段连续性, 总长度和文件级校验值. 合并与发布状态须具备原子性, 使重复完成请求返回同一结果而不会产生多个对象.

并行分片可提高吞吐, 但要限制并发以免争抢带宽和内存. 文件被用户替换, 分片大小改变, 登录身份切换或服务端会话过期时, 不能沿用旧的 uploadId. 上传进度应区分 “已发送” 和 “服务端已确认”, 后者才可用于断点恢复.

八, Apache HttpClient, HttpURLConnection 与 OkHttp

Apache HttpClient 的历史原理

Apache HttpClient 以请求对象, 连接管理和可配置的 HTTP 执行链封装网络访问, 曾作为 Android 平台提供的 Apache HTTP 客户端之一. 它的连接复用和请求配置模型解决了早期 Java/Android 网络调用的工程需求.

当前状态与版本边界

Android 在 Android 6.0 (API 23) 移除了平台内置 Apache HTTP 客户端. 依赖平台版本的旧代码在 API 23 及以上不能继续假定该实现存在; 即使通过额外依赖恢复兼容, 也会引入维护, 安全更新和依赖冲突成本. 新项目不应继续选择 Apache HttpClient.

现代替代与差异

HttpURLConnection 与 OkHttp 是并列的独立 HTTP 客户端 API / 实现选择. HttpURLConnection 是 Android 平台提供的基础 API, 适合依赖较少且愿意自行处理线程, 流关闭, 状态码, 缓存和重试边界的场景. OkHttp 自身提供连接池, HTTP/2, 拦截器, 同步/异步 Call 模型和更完整的取消与超时控制; Retrofit 通常构建在 OkHttp 之上. 两者都必须在非主线程执行 I/O, 并依据业务幂等性而非客户端库自动重试行为决定写请求恢复策略.


分层证据与日志边界

按 DNS → TCP/QUIC → TLS → HTTP → 代理/VPN → 应用协议分层保存证据, 同时覆盖 IPv4/IPv6, NAT64, 网络切换和系统时间. OkHttp EventListener 可观察调用阶段但不是抓包器; Cronet/QUIC 日志能力和字段受版本影响. 生产日志不得记录 token, cookie, 完整请求体或证书私钥, 诊断开关要限时, 采样和审计.

以下为上下文片段, 挂到 OkHttpClient 的 eventListenerFactory. MetricSink 是调用方提供的线程安全接口; host 先小写, 去尾点后必须命中 allowlist, 字段名经白名单限制, 不记录 URL query, Authorization, Cookie 或 body. 工厂为每个 call 生成唯一 call_id 和独立 listener, 不能在 listener 实例间共享可变计时状态; 同一 call 的 EventListener 回调按顺序发生, 但 sink 仍须能接收并发 call 的写入.

interface MetricSink { fun record(name: String, valueMs: Long, tags: Map<String, String>) }

class SafeEventListenerFactory(private val sink: MetricSink) : EventListener.Factory {
    private val allowedHosts = setOf("api.test.example")
    override fun create(call: Call): EventListener {
        val host = call.request().url.host.lowercase().removeSuffix(".")
        return SafeEventListener(sink, UUID.randomUUID().toString(), host, host in allowedHosts)
    }
}

class SafeEventListener(
    private val sink: MetricSink,
    private val callId: String,
    private val host: String,
    private val enabled: Boolean,
) : EventListener() {
    private val starts = mutableMapOf<String, Long>()
    private var failureRecorded = false
    private fun start(name: String) { if (enabled) starts[name] = System.nanoTime() }
    private fun end(name: String) {
        starts.remove(name)?.let { started ->
            sink.record(name, (System.nanoTime() - started) / 1_000_000, mapOf("call_id" to callId, "host" to host))
        }
    }
    private fun activeStage(): String = starts.keys.lastOrNull() ?: "unknown"
    private fun finishActiveStages() = starts.keys.toList().forEach(::end)
    private fun failureCategory(error: IOException): String = when (error) {
        is UnknownHostException -> "dns"
        is SSLException -> "tls"
        is SocketTimeoutException -> "timeout"
        is ConnectException -> "connect"
        else -> "io"
    }
    private fun recordFailure(category: String, stage: String) {
        if (enabled && !failureRecorded) {
            failureRecorded = true
            sink.record("failure", 0, mapOf("call_id" to callId, "host" to host, "category" to category, "stage" to stage))
        }
    }
    override fun callStart(call: Call) = start("call")
    override fun dnsStart(call: Call, domainName: String) = start("dns")
    override fun dnsEnd(call: Call, domainName: String, inetAddressList: List<InetAddress>) = end("dns")
    override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) = start("connect")
    override fun connectEnd(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?) = end("connect")
    override fun connectFailed(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?, ioe: IOException) {
        end("secure_connect")
        end("connect")
        if (enabled) {
            sink.record(
                "connect_attempt_failure",
                0,
                mapOf("call_id" to callId, "host" to host, "category" to failureCategory(ioe)),
            )
        }
    }
    override fun secureConnectStart(call: Call) = start("secure_connect")
    override fun secureConnectEnd(call: Call, handshake: Handshake?) = end("secure_connect")
    override fun requestHeadersStart(call: Call) = start("request_headers")
    override fun requestHeadersEnd(call: Call, request: Request) = end("request_headers")
    override fun requestBodyStart(call: Call) = start("request_body")
    override fun requestBodyEnd(call: Call, byteCount: Long) = end("request_body")
    override fun responseHeadersStart(call: Call) = start("response_headers")
    override fun responseHeadersEnd(call: Call, response: Response) = end("response_headers")
    override fun responseBodyStart(call: Call) = start("response_body")
    override fun responseBodyEnd(call: Call, byteCount: Long) = end("response_body")
    override fun callEnd(call: Call) = end("call")
    override fun callFailed(call: Call, ioe: IOException) {
        val failedStage = activeStage()
        finishActiveStages() // connectEnd/secureConnectEnd 可能不会在失败路径到达
        recordFailure(failureCategory(ioe), failedStage)
    }
}

val client = OkHttpClient.Builder()
    .eventListenerFactory(SafeEventListenerFactory(metricSink))
    .build()

案例: 仅 Android 7 测试机 HTTPS 失败

现象: 同一 release candidate 在新设备成功, Android 7 测试机报 SSLHandshakeException.证据: EventListener 显示 DNS/connect 成功, secureConnect 前失败; Charles 不参与时仍复现; 网关日志没有 traceId.根因: CDN 部署遗漏中间证书, 部分平台无法从缓存 / 网络补全链.修复: 在预发补齐服务器发送的证书链并保留旧链兼容验证.验证: 用目标 API 级别真机, 无 Charles 代理和新旧网络各发起请求, 确认握手成功, 业务 200 以及没有放宽客户端信任策略. 该案例说明证据不能被 “debug 包能抓到包” 替代.

自测: (1) debug 测试 host 经 Charles 成功, 预期 system 公网证书仍可验证; release 不信任用户 CA, 且生产 CertificatePinner 不被关闭;(2) 生产 host 不在 allowlist 时不输出 host 标签;(3) 并发 100 个 call, 分别覆盖冷连接, 连接复用, 无 body, 缓存命中以及 DNS/connect/TLS/request/response 各阶段失败, 预期每个 call_id 的事件不串线且符合实际路径: 复用连接没有新的 connect/TLS, no-body 没有 body 事件, 缓存命中按实现可能没有网络阶段; 每次 route/attempt 的 connectFailed 结束其 secure_connect/connect 计时并记录可重复的 connect_attempt_failure, 若后续 route 成功则没有 call 级 failure, 只有最终 callFailed 才关闭其余活跃计时并记录低基数 category 与 failed stage.

高频面试题

Q1: 用户反馈接口慢, 你怎么定位? 先拆阶段: DNS, TCP, TLS, 请求上传, 服务端处理, 响应下载. 用 OkHttp EventListener 记录阶段耗时, Interceptor 打 traceId, Charles / 服务端日志对照, 再判断是客户端网络, 证书, 连接池还是服务端问题.

Q2: TLS 证书错误常见原因有哪些? 证书过期, 设备时间错误, 证书链不完整, 域名与 SAN 不匹配, 老设备 TLS 套件不兼容, Pinning 指纹不匹配, Charles 代理证书未被 debug 包信任.

Q3: 弱网重试怎么设计? 只对幂等或有幂等 key 的请求重试, 设置总 callTimeout, 使用指数退避和 jitter, 限制次数, 网络恢复后再补偿. 非幂等业务必须服务端去重, 不能客户端盲重发.

Q4: OkHttp Interceptor 和 EventListener 区别? Interceptor 适合改请求, 加 header, 签名, 日志和处理响应; EventListener 更适合记录 DNS/TCP/TLS/请求/响应各阶段耗时, 用于定位慢在哪里.

易错点 / 追问

  • 易错: 把 5xx 全部归因客户端; 应带 traceId 找服务端 / 网关日志.
  • 追问: HTTPDNS 访问 IP 时 HTTPS 证书怎么校验? 仍要用原始域名做 SNI/Hostname verification, 不能简单把 URL host 改成 IP 后跳过校验.
  • 易错: 为了 Charles 抓包关闭 release Pinning; 正确做法是 debug-only 策略或测试域名.
  • 追问: 为什么 429 不该立即重试? 它表示限流, 立即重试会放大压力, 应按服务端提示或指数退避.
  • 追问: TIME_WAIT 和 CLOSE_WAIT 怎么区分? TIME_WAIT 出现在主动关闭方, 等 2MSL 让旧报文消散; CLOSE_WAIT 是被动关闭方收到对端 FIN 后没调用 close, socket 未释放: 看到 CLOSE_WAIT 增多先查代码里连接是否关闭, TIME_WAIT 增多则查连接复用. 语义与完整对照表见 见 29.

数据库进阶

移动端数据库面试不是背 SQL, 而是解释清楚 SQLite 在单文件, 弱资源, 强一致本地存储下如何工作. 能把索引, 事务, WAL, 锁, Room 迁移和缓存一致性串起来, 就能从 “会用 Room” 升级成 “能治理本地数据层”.

一, SQLite 索引与查询优化

SQLite 常用 B+Tree 组织表和索引. Android 端最常见的优化目标是: 列表页首屏快, 搜索条件可命中索引, 分页不扫全表, 写入不要被过多索引拖慢.

B+Tree 为什么够快 (推演, 数字为示意): 页大小常见 4KB (SQLite 编译期可选, 需为 2 的幂). 每个节点能放多少 key 由 key/子指针大小决定, 假设约 100~200 个 key/节点 (扇出示意); 百万行 (约 2^20) 只需要约 log_200(1e6) ≈ 2.6 层, 即 3 层 B+Tree: 根 + 一层中间节点 + 一层叶子, 一次点查约 3 次页 I/O. 相比 B-Tree, 非叶节点只存 key 不存数据, 扇出更高, 树更矮; 叶子节点按 key 逻辑有序, 天然支持范围查询 (SQLite 的叶页之间并无兄弟指针, 续扫依赖沿父页路径定位下一叶页). 相比哈希索引 (只支持等值), B+Tree 支持范围, 前缀匹配和排序, 这正好覆盖移动端最常见的 where + order by 场景.

主题面试要点Android/Room 关联
单列索引加速 where userId = ?, order by time@Index("userId")
联合索引遵循最左前缀, 适合多条件查询@Index(value=["uid","createdAt"])
覆盖索引查询列都在索引里, 减少回表列表摘要页只查必要字段
索引代价占空间, 写入/更新变慢埋点/日志表不要给每个字段建索引

常见不命中 (不是 “必然失效”): 索引是否被选中由 SQLite 版本, 统计信息, 绑定参数和数据分布共同决定; 不能只凭 SQL 外形下结论. 每次以同一 schema 和真实绑定参数运行 EXPLAIN QUERY PLAN, 并保留对照证据:

场景schema / SQL计划对照与条件
函数包列CREATE INDEX i_created ON message(createdAt); WHERE date(createdAt)=?常见 SCAN message. 建立 CREATE INDEX i_day ON message(date(createdAt)) 后, 只有查询表达式与 expression index 完全匹配时才可能 SEARCH ... USING INDEX i_day.
前后缀匹配CREATE INDEX i_title ON message(title); WHERE title LIKE ?绑定 '%foo' 不能形成普通前缀范围, 常见 scan; 绑定 'foo%' 才可能 search, 仍受 COLLATE, case_sensitive_like 与索引 collation 是否一致影响.
联合索引非首列CREATE INDEX i_ab ON t(a,b); WHERE b=?常见 scan; 在支持且统计信息足够准确的 SQLite 上, 低基数 a 可能选择 skip-scan. 执行 ANALYZE 后仍须以实际 EXPLAIN 验证, 不能承诺必走索引.
排序规则CREATE INDEX i_code ON item(code COLLATE NOCASE); WHERE code COLLATE BINARY=?查询显式要求 BINARY 而索引是 NOCASE 时, 二者的相等语义不同, 不能用该索引完成该比较, 常见 SCAN. TEXT 列绑定数字参数通常会先按列 affinity 转为 TEXT; 它本身不是 “必然扫描” 的依据.

OR, 否定条件和低选择性条件也常不命中. 优化前后同时核对返回行正确性, 避免为了看到 SEARCH 改变查询语义.

二, 事务, WAL 与持久性

事务保证一组本地状态变更要么一起成功, 要么一起回滚. 移动端常见场景是 “网络响应入库 + 更新本地缓存版本 + 删除旧分页游标” 必须在同一事务内完成.

@Transaction
suspend fun replacePage(page: Int, items: List<ItemEntity>, nextKey: String?) {
    remoteKeyDao.upsert(RemoteKeyEntity(page, nextKey))
    itemDao.deletePage(page)
    itemDao.insertAll(items)
}
  • Rollback Journal: 修改前先备份旧页, 提交后删除 journal; 读写互斥更明显.
  • WAL(Write-Ahead Logging): 先写入 WAL 文件, 读者可继续读旧快照, 写者追加日志, 读写并发更好.
  • 事务隔离级别 (审查点名补齐): SQLite 默认隔离级别是 SERIALIZABLE. PRAGMA read_uncommitted = ON 可请求 READ UNCOMMITTED (读未提交), 但该 pragma 在 WAL 模式下是 no-op (WAL 读者读各自快照, 不会看到未提交数据); 仅在 rollback journal 模式 (通常配合 shared-cache) 下才可能读出未提交数据. 在 WAL 下读者读快照, 写者追加 WAL, 读者与写者互不阻塞, 这是 WAL 提升并发的主要原因.
  • Checkpoint: 把 WAL 内容合并回主库; WAL 过大可能影响磁盘与启动恢复.
  • Room 实践: 批量 insert/update 用事务包住, 避免每条 SQL 都 fsync, 显著降低耗时和卡顿风险.
  • PRAGMA synchronous 的持久性取舍: 默认通常是 FULL, 在关键提交点 fsync, 保证已提交事务在掉电后不丢 (对应 ACID 的 D). 性能敏感时降为 NORMAL: 在 WAL 模式下可能丢失最近一次提交, 但不会损坏数据库; 取舍本质是 fsync 频率与吞吐 / 卡顿的权衡, 具体默认值以目标 SQLite 编译选项为准.

三, 锁, 并发与 Android 线程模型

SQLite 是嵌入式数据库, 不是多进程数据库服务器. 它的锁粒度会影响 “UI 查询, 后台同步, 埋点写入” 之间的互相阻塞.

  1. Rollback journal 使用 SHARED, RESERVED, PENDING, EXCLUSIVE 锁状态; 写入提交需要排它阶段, 读写互斥更明显.
  2. WAL 是快照读加 WAL writer 锁模型: 读者读各自 end mark, writer 追加 WAL; 通常仍只有一个 writer, 不应把 rollback 的 PENDING/EXCLUSIVE 图直接套用到 WAL.
  3. Android 约束: 不要在主线程做大查询或大事务; Room 默认禁止主线程数据库访问是为了防 ANR.

面试回答可以强调: SQLite 适合本地轻量存储, 但不适合把所有模块都当成高并发中心库; 日志, 缓存, 业务状态最好按表职责拆清楚, 写入队列化.

database is locked / SQLITE_BUSY 的定位

SQLITE_BUSY 表示当前连接在可等待的时间内无法取得所需锁, 常见于长事务, 两个连接竞争 writer, 跨进程同时打开同一数据库或 checkpoint 受长期 reader 阻塞. 它不是 “加一个无限重试” 就能修复的问题.

读事务开始 ── shared snapshot ─────────────────── commit
写事务 A    ── acquire writer ─ write WAL ── commit
写事务 B    ── SQLITE_BUSY / 等待 busy_timeout ── acquire writer

该图是机制示意: WAL 允许读者读旧快照, 但一般仍只允许一个 writer. PRAGMA busy_timeout 设置的是当前物理 SQLite 连接的 busy handler, 可为短暂竞争留出等待时间; 它不是数据库文件级或 Room 全连接池级开关. 具体默认值和连接创建方式取决于 SQLite driver, Room 和应用配置, 不能把某个毫秒数当通用标准.

排查顺序:

  1. 记录异常, 数据库版本, 事务开始 / 结束时间, 线程与写入队列长度, 禁止记录业务敏感字段.
  2. 找出持锁最长的事务, 缩短其中网络, 文件 I/O 或大对象转换等非 SQL 工作.
  3. 将同一库的业务写入串行化, 例如由 repository 单写入协程 / 队列提交短事务; 不要在多个进程各自 “重试到成功”.
  4. 评估 WAL 与 checkpoint 行为, 并为长时间 Flow/cursor 读取设计及时释放和分页.
  5. 在目标设备用并发读写压测复现, 验证 busy 计数, 写入延迟和数据不变量, 而非只验证 “不再抛异常”.

DEFERRED 读转写的 rollback-journal trace (机制示意): 连接 A BEGIN DEFERRED; SELECT ... 持有 SHARED; 连接 B BEGIN IMMEDIATE; UPDATE ... 持有 RESERVED, 并等待提交时取得 EXCLUSIVE; A 随后 UPDATE ... 需要升级到写锁, 但 B 已有 RESERVED, A 直接收到 SQLITE_BUSY. 此时 B 又要等 A 释放 SHARED 才能完成提交, 等待不会让双方都前进; busy_timeout 不能解决这种互相等待, 也不应把它当作重试策略. 修复是把需写的事务一开始就用 BEGIN IMMEDIATE 取得写意图, 缩短读写事务, 或让业务 writer 串行化. WAL 的旧快照写入冲突是另一套诊断路径, 不能把本 trace 的锁状态照搬过去.

以下为当前执行连接的验证 / 实验片段, 不是 Room 全池生产配置方案: 以项目所用 Room androidx.room:room-runtime 和 androidx.sqlite 的公开 API 为准. RoomDatabase.Callback.onOpen 提供的 SupportSQLiteDatabase 只能对该回调拿到的物理连接执行 PRAGMA busy_timeout, 不能保证初始化 Room 连接池中的每一条连接; 若所锁定的 Room/SQLite driver 版本没有可靠的 per-connection 配置 hook, 就不能将它作为全池配置方案. WAL 可在 builder 上用公开的 setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) 选择. 生产治理仍以短事务和业务 writer 串行化为主; busy handler 仅是已确认连接入口上的有限等待策略, 必须按目标版本选择明确的连接配置入口并在设备上验证.

val callback = object : RoomDatabase.Callback() { // 仅验证本次回调拿到的连接
    override fun onOpen(db: SupportSQLiteDatabase) { db.execSQL("PRAGMA busy_timeout=2500") }
}
Room.databaseBuilder(context, AppDb::class.java, "app.db")
    .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
    .addCallback(callback).build()

自测: (1) 两连接复现上述 DEFERRED trace, 预期 A 得 SQLITE_BUSY;(2) 在 callback 拿到的连接查询 PRAGMA busy_timeout, 预期为设置值, 但不得推论池内其他连接已设置;(3) 直接执行下一节的 SQL, 保存 EXPLAIN QUERY PLAN 输出并验证结果集.

四, Explain Query Plan 排查慢查询

EXPLAIN QUERY PLAN 用来确认 SQL 是否走索引, 是否全表扫描, 是否临时排序. 它不是 “猜测优化”, 而是用证据定位慢查询.

可直接执行的索引计划自测

以下 SQL 可直接粘贴到 SQLite shell 或数据库检查工具执行; 它会重建 message 表. 建索引前无索引可用, 预期为 SCAN message; 建索引后左前缀等值命中, 预期为 SEARCH message USING COVERING INDEX .... ANALYZE 只影响多个候选计划间的行数估计, 不改变 “无索引 vs 单索引等值” 的选择, 因此本例不依赖 ANALYZE. SQLite 版本和代价模型可能让输出附带 COVERING 等字样, 但检查重点是 SEARCH/SCAN 与所列索引名.

DROP TABLE IF EXISTS message;
CREATE TABLE message (
  id INTEGER PRIMARY KEY,
  conversation_id INTEGER NOT NULL,
  created_at TEXT NOT NULL,
  title TEXT NOT NULL
);
WITH RECURSIVE n(i) AS (
  VALUES(1) UNION ALL SELECT i + 1 FROM n WHERE i < 1000
)
INSERT INTO message(id, conversation_id, created_at, title)
SELECT i, (i % 10) + 1, printf('2026-08-%02d', (i % 28) + 1),
       CASE WHEN i % 2 = 0 THEN printf('alpha-%04d', i) ELSE printf('beta-%04d', i) END
FROM n;

-- 尚无索引:预期 SCAN message,且可能有 USE TEMP B-TREE FOR ORDER BY.
EXPLAIN QUERY PLAN
SELECT id, created_at FROM message
WHERE conversation_id = 3 ORDER BY created_at DESC LIMIT 20;

CREATE INDEX i_message_conversation_created ON message(conversation_id, created_at DESC);
-- 左前缀命中:预期 SEARCH message USING COVERING INDEX i_message_conversation_created (conversation_id=?).
EXPLAIN QUERY PLAN
SELECT id, created_at FROM message
WHERE conversation_id = 3 ORDER BY created_at DESC LIMIT 20;

-- 跳过联合索引最左列:预期 SCAN message,而不是按 created_at 的直接 SEARCH.
EXPLAIN QUERY PLAN
SELECT id FROM message WHERE created_at = '2026-08-03';

-- 普通索引不能匹配函数表达式:预期 SCAN message.
EXPLAIN QUERY PLAN
SELECT id FROM message WHERE date(created_at) = '2026-08-03';
CREATE INDEX i_message_created_day ON message(date(created_at));
-- expression index 与查询表达式一致:预期 SEARCH message USING INDEX i_message_created_day (<expr>=?).
EXPLAIN QUERY PLAN
SELECT id FROM message WHERE date(created_at) = '2026-08-03';

CREATE INDEX i_message_title ON message(title);
PRAGMA case_sensitive_like = ON;
-- 前缀 LIKE 与 BINARY 索引一致:预期 SEARCH message USING COVERING INDEX i_message_title (title>? AND title<?).
EXPLAIN QUERY PLAN SELECT id FROM message WHERE title LIKE 'alpha%';
-- 前导通配符没有 B-tree 前缀范围:预期 SCAN message.
EXPLAIN QUERY PLAN SELECT id FROM message WHERE title LIKE '%0001';

该组 SQL 同时覆盖联合索引左前缀, expression index 和 LIKE/collation 条件. 若目标 SQLite 的输出与预期不同, 记录 sqlite_version(), PRAGMA compile_options, 是否已执行 ANALYZE, 完整 schema 和数据规模后再判断, 不要只因某次计划出现 SCAN 就改写业务语义.

EXPLAIN QUERY PLAN
SELECT id, title, created_at
FROM message
WHERE conversation_id = 3
ORDER BY created_at DESC
LIMIT 20;

-- 关注输出里是否出现:
-- SEARCH message USING INDEX i_message_conversation_created (conversation_id=?)
-- 避免: SCAN TABLE message 或 USE TEMP B-TREE FOR ORDER BY

线上慢→定位→建索引→验证的串联模板: 线上反馈列表慢, 先抓慢 SQL 与真实绑定参数; 跑 EXPLAIN QUERY PLAN 看是否 SCAN/回表 (查询列不全在索引里时会回表取行); 若命中回表或全表扫描, 按 where + order by 建联合索引或覆盖索引; 再用真实参数重跑 EXPLAIN 并以 SEARCH ... USING INDEX 和耗时下降作为证据回归, 而不是只看 SQL 外形.

Android 排查路径:

  1. 先用日志记录慢 SQL, 参数, 耗时和线程.
  2. 用 EXPLAIN QUERY PLAN 判断是否 SCAN TABLE.
  3. 对 where + order by 组合设计联合索引.
  4. 只查询 UI 需要的列, 避免把大字段一次性读出.
  5. 回归验证首屏, 翻页, 搜索三个关键路径.

五, Room Migration 与数据演进

Room 迁移考的是 “线上旧数据怎么安全升级”, 不是只会改 entity. 中级面试要说明 schema 版本, 迁移 SQL, 回滚策略和测试.

  • 显式 Migration: 从版本 N 到 N+1 写清 ALTER TABLE, 建新表, 搬数据, 删旧表.
  • AutoMigration: 可自动处理加列, 新建表, 改默认值等简单变更; 列 / 表重命名须在 AutoMigrationSpec 中显式声明, 复杂数据变换仍建议手写 migration.
  • 破坏性迁移风险: fallbackToDestructiveMigration() 会清库, 只适合非核心缓存库.
  • 迁移测试: 用旧版本 schema 创建数据库, 插入旧数据, 跑 migration, 再校验新 DAO 能正常读写.
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE User ADD COLUMN riskLevel INTEGER NOT NULL DEFAULT 0")
        db.execSQL("CREATE INDEX IF NOT EXISTS index_User_riskLevel ON User(riskLevel)")
    }
}

数据库文件作为存储体系的一部分, 其备份, 清理与迁移责任属于存储选型范畴, 见 28 存储体系与 Scoped Storage.

六, N+1 查询, 分页与缓存一致性

N+1 查询: 先查 1 次列表, 再对每个 item 查一次关联数据, 列表 50 条就变成 51 次数据库访问. Android 列表滑动时会放大成卡顿.

  • 用 JOIN, @Relation + @Transaction, 批量 where id in (...) 解决.
  • 对 RecyclerView/Compose 列表只暴露聚合后的 UI model, 不要在 bind 阶段查库.
  • Room Flow 监听表变化时要避免过宽查询, 否则任意字段变更都触发大列表重算.

分页策略:

  • Offset 分页: LIMIT 20 OFFSET 10000 越往后越慢, 因为仍要跳过大量行.
  • Keyset/Cursor 分页: where createdAt < ? order by createdAt desc limit 20, 适合消息 / Feed.
  • Paging3 + RemoteMediator: 网络页, 本地 Room, RemoteKey 统一管理, 离线也能展示.

缓存一致性:

  1. 单一可信源: UI 优先观察 Room, 网络结果先入库再由数据库驱动 UI.
  2. 版本号 / 时间戳: 解决本地缓存和服务端增量同步冲突.
  3. 事务更新: 数据表和分页 key 同事务提交.
  4. 过期策略: TTL, etag, 服务端版本号结合, 不要永久相信本地缓存.

七, SQLite/Room 与 MySQL/InnoDB 的边界

本章 Android 实践默认是 SQLite/Room. MySQL/InnoDB 的聚簇索引, 隔离实现, 锁和服务端连接模型不能直接套到 SQLite. Android 慢查询应保存真实参数并使用 EXPLAIN QUERY PLAN, 关注是否全表扫描, 索引选择, 临时 B-tree 和返回行数; Room 升级必须用导出的 schema 做 migration test, 同时覆盖跨多个历史版本升级, 失败回滚和数据不变量.

高频面试题

Q1: SQLite WAL 为什么能提升并发? WAL 把写入追加到日志文件, 读事务继续读主库旧快照, 因此读写不必像 rollback journal 那样频繁互斥. 但通常仍只有一个 writer, 且需要 checkpoint 把 WAL 合并回主库.

Q2: 如何排查 Room 列表查询慢? 先记录 SQL 耗时和线程, 再用 EXPLAIN QUERY PLAN 看是否全表扫描或临时排序; 根据 where/order by 设计联合索引, 只查必要列, 最后用首屏和分页场景回归.

Q3: Room Migration 为什么不能随便 destructive migration? 线上用户的本地业务数据, 离线缓存, 登录态关联数据可能被清空. 除非是可重建缓存库, 否则要写显式 migration 并用旧 schema 数据测试.

Q4: N+1 查询在 Android 为什么危险? 它把列表渲染放大成大量小查询, 在 RecyclerView/Compose 滑动和 Flow 重新计算时容易造成 IO 抖动和掉帧. 应改成 JOIN, 批量查询或一次性聚合.

易错点 / 追问

  • 追问: 联合索引 (uid, createdAt) 能否支持只按 createdAt 查? 一般不能, 因为不满足最左前缀.
  • 易错: 以为 WAL 下写入完全并行; 实际多读一写更友好, 但 writer 之间仍会竞争.
  • 追问: Offset 分页为什么越翻越慢? 数据库仍要扫描 / 跳过前面大量记录, 移动端大表应优先考虑 keyset 分页.
  • 易错: 把 Room @Transaction 只理解成注解, 忽略它对一致性和批量写性能的价值.

Gradle 与工程化

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

一, Gradle 基础

  • Gradle: 基于 JVM 的构建工具, 用 Groovy/Kotlin DSL(.kts) 写脚本. Android 用 AGP (Android Gradle Plugin).
  • 构建生命周期三阶段:
    1. 初始化 (Initialization): 确定哪些模块参与构建 (settings.gradle).
    2. 配置 (Configuration): 执行所有 build.gradle, 构建任务依赖图 (Task DAG).这阶段慢会拖累整体.
    3. 执行 (Execution): 按依赖图执行需要的 Task.
  • Task: 构建的最小执行单元, 有输入 / 输出, 支持增量.
  • 核心文件: settings.gradle(.kts)(模块声明), build.gradle(.kts)(模块配置), gradle.properties(全局配置).

二, 依赖管理

  • 依赖配置:
    • implementation: 依赖仍可出现在运行时依赖图中, 但不暴露到消费者的 compile classpath/API surface, 因而有利于编译隔离. 当公共 API 签名暴露该类型时不能使用它来隐藏契约.
    • api: 把依赖暴露给消费者的编译 classpath, 适合公共 API 确实包含该类型的库模块, 但会扩大重编译影响面.
    • compileOnly: 只在当前组件编译时可见, 不进入运行时产物; 适合由宿主 / 平台提供的 API, 不是常规注解处理器配置的同义词.
    • runtimeOnly: 只在运行期需要; 测试使用 testImplementation/androidTestImplementation; 代码生成按处理器要求使用 ksp 或 kapt.
  • Version Catalog(libs.versions.toml): 集中管理依赖版本, 多模块统一, 现代推荐.
  • 依赖冲突: Gradle 默认选最高版本; 可用 resolutionStrategy 强制版本, exclude 排除传递依赖.
  • BOM: 统一一组库的版本 (如 Compose BOM).

工具链兼容矩阵

AGP, Gradle, JDK, Kotlin, KSP 与 Compose Compiler 必须作为一个组合维护. 正文不散落 “最新版本”, 项目中记录经过验证的矩阵:

维度记录内容
AGP / Gradle官方兼容范围与 Wrapper 版本
JDKGradle daemon 与 Android toolchain 使用版本
Kotlin / Compose Compiler插件版本, language/api version, Compose 编译插件
KSP/KAPT各处理器支持的 Kotlin/KSP 版本与迁移状态

升级时一次改变一个关键维度, 或提供完整兼容验证和回退点.

核验记录 (2026-08-07): 下表是用于本篇片段的历史教学基线, 不是本仓库已验证的工具链或 “当前最新”.AGP 与 Gradle/JDK/API 的可用组合以 Android 官方 AGP 版本说明 的版本兼容表为依据; Gradle Wrapper 还必须与项目 gradle-wrapper.properties 一致. Kotlin, KSP, Compose Compiler 的组合另按项目采用的官方插件发布说明核对.

AGPGradle WrapperJDK最高支持 API / compileSdk核验日期官方依据
8.5.28.717API 34 / compileSdk = 342026-08-07AGP 8.5 release notes

三, 构建变体与产物

  • buildTypes: debug / release (混淆, 签名, 是否可调试).
  • productFlavors: 多渠道/多环境 (免费版/付费版, 国内/海外), 组合成 variant.
  • 签名配置 signingConfigs: 配置 keystore.
  • manifestPlaceholders / BuildConfig 字段: 按变体注入不同配置 (如不同 API 域名, key).

工程上下文片段: 应用模块与 Version Catalog

以下是工程上下文片段, 不是可直接运行的完整工程. 它刻意未包含 settings.gradle.kts 的 pluginManagement/dependency repositories, 根插件声明, 应用源码, manifest 与 CI 凭据传递闭环; 虽在模块片段中显式启用 BuildConfig, 仍不能据此承诺 ./gradlew :app:assembleDemoDebug 一定成功. 版本号是示例, 不代表 “当前最新” 或已通过官方矩阵验证; 不要将真实 keystore 密码或服务端密钥写入这些文件.

# gradle/libs.versions.toml
[versions]
androidGradlePlugin = "8.5.2" # 历史教学基线;矩阵限定 Gradle 8.7,JDK 17,API 34
kotlin = "2.0.21"

[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
// app/build.gradle.kts
plugins { alias(libs.plugins.android.application); alias(libs.plugins.kotlin.android) }

android {
    namespace = "com.example.interview"
    compileSdk = 34 // 与上表 AGP 8.5.2 历史教学基线一致
    buildFeatures { buildConfig = true }
    defaultConfig { applicationId = "com.example.interview"; minSdk = 23; targetSdk = 34; versionCode = 42; versionName = "1.4.0" }
    flavorDimensions += "environment"
    productFlavors {
        create("demo") { dimension = "environment"; applicationIdSuffix = ".demo"; buildConfigField("String", "API_BASE_URL", "\"https://api.demo.example.test\"") }
        create("prod") { dimension = "environment"; buildConfigField("String", "API_BASE_URL", "\"https://api.example.test\"") }
    }
    signingConfigs {
        create("release") {
            storeFile = providers.gradleProperty("signingStoreFile").orNull?.let(::file)
            storePassword = providers.gradleProperty("signingStorePassword").orNull
            keyAlias = providers.gradleProperty("signingKeyAlias").orNull
            keyPassword = providers.gradleProperty("signingKeyPassword").orNull
        }
    }
    buildTypes {
        debug { applicationIdSuffix = ".debug" }
        release {
            isMinifyEnabled = true
            signingConfig = signingConfigs.getByName("release")
        }
    }
}

signingConfigs 应从 CI secret 或开发机未纳入版本控制的属性文件读取. Gradle property 的标准环境变量映射为 ORG_GRADLE_PROJECT_signingStoreFile, ORG_GRADLE_PROJECT_signingStorePassword, ORG_GRADLE_PROJECT_signingKeyAlias, ORG_GRADLE_PROJECT_signingKeyPassword; 它们对应片段中的四个 property 名, 并与第 35 篇一致. debug 不需要这些值, 缺失时 release 构建应在签名校验失败而非回退到 debug 签名.

自测 (在完整工程中执行): 以表中基线将 Wrapper 固定到 Gradle 8.7, 构建 JDK 固定到 17, 并用 compileSdk = 34 运行 ./gradlew :app:assembleDemoDebug; 预期任务解析出 demoDebug 且生成 APK. 运行 ./gradlew :app:assembleProdRelease 时仅通过上述 CI 环境变量提供签名, 预期产物使用 release 签名, 缺任一变量时失败而不使用 debug 签名. CI 输出应记录实际 AGP/Gradle/JDK/API, 并逐项链接到核验日期对应的官方矩阵; 本文不声称本仓库已执行该验证.

targetSdk 迁移是行为变更项目

上下文片段, 不是只改整数的提交模板: 固定目标 API 后阅读对应 behavior changes, 搜索 PendingIntent, 权限, 通知, 前台服务, 存储和隐式 Intent 调用; 在目标系统设备 / 模拟器验证关键路径; 再以灰度指标决定推进或回退. Android 平台与商店 targetSdk 要求会变化, 发布日必须核验官方要求. 构建性能的测量与归因见 Gradle 构建性能专题, 插件和字节码实现见 AGP 插件与字节码工程.

APK / AAB 构建全链路

应用源码和资源从变体选择开始: Gradle 先合并 main, flavor, build type 的源码, 资源和 Manifest, 再由 Kotlin/Java 编译器生成 JVM 字节码. AAPT2 编译和链接资源. 启用 minifyEnabled 时, R8 执行 shrink, optimize 和 obfuscate, 并直接产出 DEX; 未启用时, D8 负责将 class 转换为 DEX. 随后构建工具将 DEX, 编译资源, native 库和 Manifest 打包. APK 交付时先运行 zipalign, 再由 apksigner 使用受控 keystore 完成最终签名; 签名后的 APK 不应再重新对齐.

APK 是可直接安装的单一包或拆分包. AAB 是上传给应用商店的发布格式, 可由 bundletool 在本地生成测试用 APK 集, 或由应用商店按设备配置生成并签名设备 APK. AAB 交付链路不套用 APK 的 zipalign 流程. 因此不能把本地 AAB 当作可直接安装的 APK, 也不能只检查 universal APK 就宣称所有设备交付正确.

阶段主要输入 / 输出排查重点
变体与合并source set, 资源, Manifest -> variantflavor 覆盖, 渠道配置, applicationId 与权限是否符合发布地区要求
编译与资源处理Kotlin/Java, XML/资源 -> class, 编译资源编译错误, 资源冲突, 未使用资源与错误限定符
DEX 转换启用 minifyEnabled: R8 shrink/optimize/obfuscate -> DEX; 未启用: D8 class -> DEX引用数, keep 规则, 反射/序列化/JNI 入口与 release 回归
打包DEX, 资源, .so, Manifest -> 未对齐 APK 或 AAB包内容, ABI 与资源拆分
APK 对齐与签名未对齐 APK -> zipalign -> apksigner 最终签名 APK对齐先于最终签名, 签名校验与安装测试
AAB 交付AAB -> bundletool 测试 APK 集或商店生成的设备 APK不套用 APK zipalign; 验证商店生成的设备 APK 与安装测试

体积, 多渠道与 64K DEX

APK 瘦身应先分析制品而非猜测: 检查资源重复与无用资源, ABI 及 native 库, DEX/代码体积, 传递依赖, 图片尺寸和格式. 可用 R8, 资源压缩, ABI/语言/密度拆分和按需功能模块, 但每项调整都要验证反射入口, 设备兼容性和用户实际下载包. 图片应按显示尺寸和解码成本选择, 不只看压缩率.

多渠道使用 productFlavors 与 buildTypes 组合 variant, 可覆盖资源, Manifest placeholder 和 BuildConfig 字段. 渠道差异必须可追踪到业务目的与发布地区; 不得用 flavor 绕过权限披露, 隐私门禁或商店审核. 在 CI 中逐变体核验 application ID, 签名, 域名, 资源, 权限和最终产物.

单个 DEX 的方法引用上限约为 65,536. 当 minSdk 低于 21 且无法通过依赖治理或代码裁剪降到上限时, 历史上需要配置 MultiDex 并在应用启动时安装额外 DEX, 启动和主 DEX 内容都需额外关注. Android 5.0/API 21 及以上由运行时原生支持多 DEX, 但引用膨胀仍会影响构建, 安装和启动成本. 现代优先级是删除无用依赖, 避免重复 SDK, 启用合理裁剪并持续观察方法数, 而不是把 MultiDex 当作无限扩容方案.

Support Library 到 AndroidX 迁移

AndroidX 是 Support Library 的继任命名空间与依赖坐标体系, 例如 android.support.v7.app.AppCompatActivity 迁为 androidx.appcompat.app.AppCompatActivity. 它属于构建与依赖迁移问题, 不应与版本适配或隐私标识符主题混为一谈.

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

四, 组件化 / 模块化

中大型项目的核心架构:

  • 为什么组件化: 编译解耦 (改一个模块不全量编), 并行开发, 复用, 可独立运行调试.
  • 分层: app 壳 → 业务模块 (feature) → 基础库 (common/network/ui).
  • 模块通信 (解耦): 模块间不直接依赖, 用路由 (ARouter) + 接口下沉 (sink) + DI 组合; Hilt 更偏依赖注入, 不是页面路由本身.
  • 路由 ARouter 机制:
    1. 业务页面用注解声明路径, 如 @Route(path = "/user/profile").
    2. 编译期 APT 扫描注解, 为每个模块生成路由表类, 记录 path → Activity/Fragment/Provider 的映射.
    3. App 启动或首次使用时加载各模块路由表.
    4. 调用 ARouter.getInstance().build("/user/profile").withString("id", id).navigation() 时, 框架按 path 找到目标并完成参数注入 / Intent 跳转.
  • 接口下沉 (sink): 把跨模块能力抽到公共 API 模块, 例如 :user-api 只放 UserService 接口和数据模型, :feature-order 依赖接口而不依赖 :feature-user 实现. 实现模块通过路由 Provider 或 DI 绑定暴露能力.
  • 与 Hilt/DI 的区别: 路由解决 “页面 / 服务如何按路径发现和跳转”, DI 解决 “对象依赖如何创建和注入”.组件化里常组合使用: ARouter 做跨模块入口, Hilt 给模块内部或接口实现注入依赖.
// :user-api
interface UserService {
    fun currentUserId(): String?
}

// :feature-order 只依赖 user-api,不依赖 feature-user
class OrderViewModel(
    private val userService: UserService
) : ViewModel()

五, 工程使用侧的构建卫生

  • 构建脚本保持声明式, 避免配置期 I/O, 并让依赖使用 implementation/api 语义准确.
  • 缓存, 并行, KSP/KAPT 和 configuration cache 是测量问题, 不在本篇给出调优结论; 诊断流程见 Gradle 构建性能专题.
  • 模块化: 拆模块 + implementation 隔离, 减少改动的重编范围.
  • AGP 升级: 新版本构建性能持续优化.
  • 产物优化: R8 裁剪, 资源压缩, ABI/语言/密度拆分; Android 行为适配见 Android 版本适配, Compose 性能见 Compose 深水区.

六, 测试

  • 单元测试: JUnit + Mockito/MockK(mock 依赖), 测纯逻辑 (ViewModel, UseCase).
  • 协程测试: runTest + TestDispatcher(StandardTestDispatcher/UnconfinedTestDispatcher), Turbine 测 Flow.
  • UI 测试: Espresso(View), Compose Test Rule(createComposeRule).
  • 测试金字塔: 大量单元测试 + 适量集成 + 少量 UI 测试.
  • 可测试性: 依赖注入 + 接口抽象, 让逻辑可替换 fake/mock, 详见应用架构 - MVVM 与 MVI.

七, Android 版本适配 (高频)

  • 运行时权限 (6.0+): 危险权限动态申请; 后续版本继续细分一次性权限, 后台定位, 照片/视频/音频等权限边界.
  • 分区存储 Scoped Storage (10/11+): App 只能自由访问自己目录 + MediaStore, 访问其他需 SAF; 具体强制行为和兼容开关与 Android 10/11, targetSdk 有关, 升级时要按官方行为变更表核对.
  • 后台限制 (8.0+): 后台 Service 受限, 隐式广播限制, 用 WorkManager/JobScheduler; Android 12 (API 31) 起对 target 31+ 的后台启动前台服务限制更严格, 不满足例外条件会抛异常.
  • 通知渠道 (8.0+): 必须建 NotificationChannel; Android 13 (API 33) target 33+ 需要申请 POST_NOTIFICATIONS, 用户拒绝后普通通知不可见, 但前台服务仍会在系统规定位置保留可见性.
  • targetSdk 升级: 每次升级要处理对应行为变更, 不要只改数字.
    • Android 12 / API 31: target 31+ 创建 PendingIntent 必须显式声明 FLAG_IMMUTABLE 或 FLAG_MUTABLE; 后台启动 FGS, 通知 trampoline, 精确闹钟等也有新限制.
    • Android 13 / API 33: 通知运行时权限, 细分媒体权限, 部分组件导出 / Intent 安全要求需要排查.
    • Android 14 / API 34: target 34+ 前台服务必须声明合适的 foreground service type; 隐式 Intent 发送到应用内部未导出组件, mutable implicit PendingIntent 等行为更受限制.
  • 适配排查清单: 先读官方 behavior changes → 全局搜索权限/通知/FGS/PendingIntent/存储/API 调用 → 加兼容分支和自动化测试 → 用灰度观察崩溃, ANR, 权限拒绝率.
  • 64 位要求: Google Play 自 2019-08 (新应用) / 2021-08 (更新) 起要求包含 64 位 native 库, 仅 32 位会被拒; 具体政策按商店现行文本核验.

八, SDK 工程视角 (library 模块与发布)

本篇其余部分以 App 视角为主. 做 SDK / 设备指纹 / 风控库的人最常被追问 SDK 怎么发版, 怎么不污染宿主, 本节补上 library 工程面.

library 模块与 App 模块的差异

  • com.android.library vs com.android.application: library 模块不产出 APK, 而是生成 AAR (Android Archive), 没有 applicationId, 只有 namespace; 它不能被独立安装, Manifest 会合并进宿主. App 模块才有 applicationId, 签名和启动入口.
  • 为什么 SDK 用 library 模块: SDK 要被打进宿主的 APK, 天然是库; library 的 consumerProguardFiles 能让 R8 keep 规则随 AAR 带给宿主, 由宿主统一裁剪.
  • 混淆边界: library 自身的 minifyEnabled 只影响该库的独立构建; 发布给宿主的 SDK 通常不自行混淆, 而是把必须保留的入口写进 consumer-rules, 等宿主打 release 包时由 R8 统一 shrink/obfuscate. SDK 不能替宿主决定混淆策略, 只能声明自己需要保留的入口.

AAR 发布与依赖传递

  • 用 maven-publish 插件把 AAR 发布到 maven 仓库 (mavenLocal, 私有 Nexus/Artifactory 或 Maven Central).
  • AAR 里的内容: classes.jar (编译后的 class, 不是 DEX), AndroidManifest.xml, res/, jni/ (native 库), R.txt, consumer-rules.pro (keep 规则); 依赖关系写进随附的 pom.
  • pom 依赖传递: api 依赖发布为 pom 的 compile scope, 会进入宿主编译 classpath; implementation 发布为 runtime scope, 只进宿主运行时. 这就是 SDK 控制「哪些依赖泄漏给宿主」的开关.
// sdk/build.gradle.kts (上下文片段, 非完整工程)
plugins {
    id("com.android.library")
    id("maven-publish")
}

android {
    namespace = "com.example.devicefingerprint"
    consumerProguardFiles("consumer-rules.pro")
}

publishing {
    publications {
        register<MavenPublication>("release") {
            from(components["release"]) // AGP 生成 AAR + pom, 依赖按 api/implementation 映射 scope
            groupId = "com.example"
            artifactId = "device-fingerprint"
            version = "1.4.0"
        }
    }
}

consumer-rules 的边界

  • SDK 的 keep 规则只覆盖必须保留的入口: 反射调用, 序列化模型 (如 JSON 数据类), JNI 方法, 注解处理器产物, 被宿主或系统反射访问的公共 API.
  • 反例 (太宽的 keep): 不要对 SDK 整体 blanket keep. 例如:
-keep class com.example.sdk.** { *; }

这会保留 SDK 所有类及其全部成员: 内部实现类本可被宿主打包时裁剪/混淆, 全 keep 后一律保留, 直接抹掉宿主全局 shrink 的收益, 宿主体积膨胀, 也影响加固. 原则是 keep 收窄到公共 API 与反射/JNI/序列化入口, 内部实现交给 R8.

build-tag / 构建标记

埋点与 SDK 常做编译期注入: 用 buildConfigField 写入 SDK_BUILD_ID/SDK_VERSION_TAG, 或用 manifestPlaceholder 写进 Manifest metadata, 运行时随埋点 / crash 上报, 让线上数据能定位到具体 CI 构建号而不只是版本号. AGP 8.0+ 需要显式开启 buildFeatures { buildConfig = true }.

与宿主冲突

  • 依赖冲突: 宿主也可能用 OkHttp/Gson 等, 版本不同时 Gradle 默认取高版本, SDK 若依赖旧 API 会 NoSuchMethodError. 对策: SDK 少引重量级依赖; 版本对齐宿主生态主版本; 仅需编译期可见的用 compileOnly; 必要时 exclude.
  • api vs implementation 的坑 (SDK 场景): implementation 把依赖留在 SDK 内部 (pom 为 runtime scope), 不泄漏到宿主编译 classpath, 避免把 OkHttp/Gson 强加给宿主; api 会暴露依赖并扩大宿主重编译面. 但 SDK 公共 API 签名若暴露某类型, 必须用 api, 否则宿主编译期找不到类型.

高频面试题

Q1: implementation 和 api 区别? implementation 不把依赖暴露到消费者的 compile classpath/API surface, 有助于编译隔离, 但运行时依赖仍可能传递; api 适用于公共 API 签名确实暴露该依赖类型的库模块, 会扩大消费者的编译可见范围和重编译影响面.

Q2: Gradle 构建有哪几个阶段? 哪个容易成为瓶颈? 初始化 (定模块), 配置 (执行所有 build.gradle 建任务图), 执行 (跑 Task).配置阶段会执行所有模块脚本, 模块多 / 脚本重时成为瓶颈, 可用配置缓存优化.

Q3: 为什么要组件化? 模块间怎么通信? 编译解耦 (加快构建), 并行开发, 复用, 独立调试. 模块间不直接依赖, 通过路由 (ARouter)+ 接口下沉 + 依赖注入通信, 避免循环依赖.

Q4: 怎么优化 Gradle 构建速度? 先用 Build Scan/Build Analyzer 区分配置, 执行, 下载和代码生成瓶颈; 再按证据评估构建缓存, 配置缓存, 并行, 模块/API 边界和 KSP 迁移. 每项优化都要在固定工具链组合下做 clean/incremental A/B 对比.

Q5: KAPT 和 KSP 区别? KAPT 为 Java 注解处理器生成 stub; KSP 面向 Kotlin symbol, 可避免部分 stub 成本. 是否更快以及某个处理器是否支持, 取决于具体版本和增量实现, 不能承诺固定倍数.

Q6: 分区存储是什么? 带来什么变化? Android 10+ 限制 App 只能自由访问自己的外部目录和 MediaStore, 访问其他文件需通过 SAF 或 MediaStore API, 不能再随意读写整个外部存储, 提升隐私.

Q7: targetSdk 升级要注意什么? 每个版本有行为变更需适配, 而且很多只对达到对应 targetSdk 的 App 生效. 重点排查权限, 存储, 后台启动, 通知, PendingIntent, 前台服务类型, 隐式 Intent 等; 做法是对照官方 behavior changes 建清单, 搜索代码命中点, 加兼容分支和回归测试, 灰度观察崩溃/ANR/权限拒绝率.

Q8: 你们 SDK 怎么发版? 怎么和宿主避免依赖冲突? SDK 以 library 模块产出 AAR, 用 maven-publish 发到私有 maven 仓库, pom 记录依赖, 用 api/implementation 控制哪些依赖泄漏给宿主. 冲突上用 implementation/compileOnly 收窄依赖面, 与宿主生态主版本对齐, 必要时 exclude; 发版走固定版本号坐标 + 变更记录, 升级前核对宿主兼容性.

AGP 插件与字节码工程

本篇承接 Gradle 基础和构建性能专题, 回答 “如何写构建插件, 如何改 Android 字节码, 为什么插件会拖慢构建, R8 为什么会让线上代码表现不同” 等中高级面试题.

一, Gradle Plugin, Extension 与 Task

三个核心角色

角色负责什么常见错误
Plugin在项目中注册规则, 扩展和任务apply() 中直接执行耗时工作
Extension给构建脚本暴露类型安全配置用普通可变字段绕开 Provider API
Task在执行阶段读取输入并产生输出配置阶段读取文件或访问网络

最小插件模型:

abstract class InterviewExtension {
    abstract val enabled: Property<Boolean>
    abstract val outputDir: DirectoryProperty
}

abstract class GenerateReportTask : DefaultTask() {
    @get:Input
    abstract val enabled: Property<Boolean>

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    @TaskAction
    fun generate() {
        if (!enabled.get()) return
        outputDir.file("report.txt").get().asFile.writeText("generated")
    }
}

class InterviewPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val extension = project.extensions.create<InterviewExtension>("interview")
        project.tasks.register<GenerateReportTask>("generateInterviewReport") {
            enabled.set(extension.enabled)
            outputDir.set(extension.outputDir)
        }
    }
}

面试重点不是背 DSL, 而是说清:

  • tasks.register 做延迟注册, 避免配置阶段立刻创建所有 Task.
  • Extension 的 Property/Provider 保留惰性依赖关系, 不要过早调用 get().
  • Task 输入和输出必须可声明, Gradle 才能判断是否 up-to-date, 支持增量执行并命中 Build Cache.
  • Task action 中才能做真正的文件处理; 配置阶段不要遍历 APK, 解析 Git 或访问网络.

二, 增量, 缓存与配置缓存

三种 “缓存” 不是一回事

机制跳过什么命中依据
Up-to-date check当前工作区内重复 Task输入, 输出是否变化
Build Cache复用本地或远端 Task 结果可缓存 Task 的输入指纹
Configuration Cache复用配置阶段构建模型构建脚本和配置输入是否兼容且未变化

一个可缓存 Task 需要稳定, 可序列化的输入输出, 不能依赖未声明的环境状态. 时间戳, 随机数, 绝对机器路径, 网络响应和任意全局变量都会破坏可重复性.

增量任务怎么设计

  • 用 @InputFile/@InputFiles/@InputDirectory 声明输入, 并指定合理的路径敏感度.
  • 用 @OutputFile/@OutputDirectory 声明产物.
  • 需要按文件差异处理时使用 Gradle 的增量输入能力, 区分 added, modified, removed.
  • 删除输入时必须同步删除对应输出, 否则增量结果会和 clean build 不一致.
  • 同一输入必须得到等价输出, 才适合远程 Build Cache.

三, Android Variant API

Android 插件会组合 build type, product flavor 等配置生成 Variant. 自定义插件如果要读取或变换 Android 产物, 应在 AGP 提供的 Variant/Artifacts API 上工作.

val androidComponents = project.extensions
    .getByType(AndroidComponentsExtension::class.java)

androidComponents.onVariants { variant ->
    project.logger.lifecycle("configure ${variant.name}")
    // 按 variant 注册任务,连接 artifacts 或配置 instrumentation
}

旧 Transform API 为什么不再推荐

旧 Transform API 允许插件扫描和改写全量 class/jar, 曾被埋点, 路由, 热修复和字节码框架广泛使用. 它的问题是作用域过大, 增量协议复杂, 不同插件容易重复扫描, 并妨碍 AGP 优化流水线.

AGP 7 开始推动迁移, AGP 8 移除旧 Transform API. 现代方案通常是:

  • 用 androidComponents.onVariants 按 Variant 配置行为.
  • 用 Artifacts API 读取或变换 APK, AAB, Manifest, 资源等产物.
  • 用 Instrumentation API/ASM visitor 变换 class.
  • 用 Scoped Artifacts 处理指定范围内的类和 jar, 不要默认扫描整个工程.

回答面试题时应说 “理解旧 Transform 的工作模型, 新代码使用 Variant/Artifacts/Instrumentation API”, 而不是继续给出已移除 API 的实现模板.

四, ASM 字节码插桩

典型使用场景

  • 自动点击埋点和页面耗时监控.
  • Trace section 自动插入.
  • 权限 / API 合规扫描.
  • 禁止调用某些高风险 API.
  • 路由表, 依赖图或调用关系采集.

典型流水线:

class/jar input
  -> ClassReader
  -> ClassVisitor
  -> MethodVisitor / AdviceAdapter
  -> ClassWriter
  -> transformed class output

插桩必须回答的五个问题

  1. 匹配范围: 只处理业务包名还是包含依赖, 如何排除 generated class, Compose, 协程和自身 runtime.
  2. 插入位置: 方法进入 / 退出, 异常路径, 构造方法和 synchronized 方法如何处理.
  3. 栈帧正确性: 局部变量, 操作数栈和 stack map frame 是否由 ASM 正确计算.
  4. 增量与并行: 未变化 class 能否复用, visitor 是否有共享可变状态.
  5. 运行时依赖: 插入的方法调用在宿主中是否一定存在, R8 是否会改名或裁剪.
class TimingMethodVisitor(
    api: Int,
    methodVisitor: MethodVisitor,
    access: Int,
    private val methodName: String,
    descriptor: String,
    private val owner: String,
) : AdviceAdapter(api, methodVisitor, access, methodName, descriptor) {
    override fun onMethodEnter() {
        visitLdcInsn("$owner#$methodName")
        visitMethodInsn(
            INVOKESTATIC,
            "com/example/TraceRuntime",
            "begin",
            "(Ljava/lang/String;)V",
            false,
        )
    }

    override fun onMethodExit(opcode: Int) {
        visitMethodInsn(
            INVOKESTATIC,
            "com/example/TraceRuntime",
            "end",
            "()V",
            false,
        )
    }
}

这段代码只展示访问器结构, 真实项目还要处理异常退出, 构造方法, 重复插桩, runtime 缺失和包名过滤. 插桩失败往往不是 “ASM API 不会用”, 而是边界条件没有定义.

从插件到产物的最小骨架

以下为上下文片段, 展示完整连接顺序, 不保证脱离已配置的 build-logic, AGP 与 ASM 依赖独立编译. 它必须应用在 Android application/library 插件之后, 并固定在已验证的 AGP 版本矩阵中.

abstract class TimingParams : InstrumentationParameters {
    abstract val enabled: Property<Boolean>
}

class TimingPlugin : Plugin<Project> {
    override fun apply(project: Project) = with(project) {
        fun register(pluginId: String) = pluginManager.withPlugin(pluginId) {
            val components = extensions.getByType(AndroidComponentsExtension::class.java)
            components.onVariants { variant ->
                variant.instrumentation.transformClassesWith(
                    TimingVisitorFactory::class.java,
                    InstrumentationScope.PROJECT,
                ) { params -> params.enabled.set(true) }
                variant.instrumentation.setAsmFramesComputationMode(
                    FramesComputationMode.COPY_FRAMES,
                )
            }
        }
        register("com.android.application")
        register("com.android.library")
    }
}

abstract class TimingVisitorFactory : AsmClassVisitorFactory<TimingParams> {
    override fun isInstrumentable(classData: ClassData): Boolean =
        parameters.enabled.get() &&
            classData.className.startsWith("com.example.") &&
            classData.className != "com.example.TraceRuntime"
    override fun createClassVisitor(classContext: ClassContext, next: ClassVisitor): ClassVisitor =
        TimingClassVisitor(next)
}

class TimingClassVisitor(next: ClassVisitor) : ClassVisitor(Opcodes.ASM9, next) {
    private lateinit var owner: String
    override fun visit(
        version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array<out String>?,
    ) {
        owner = name
        super.visit(version, access, name, signature, superName, interfaces)
    }

    override fun visitMethod(
        access: Int, name: String, descriptor: String, signature: String?, exceptions: Array<out String>?,
    ): MethodVisitor {
        val delegate = super.visitMethod(access, name, descriptor, signature, exceptions)
        val isAbstractOrNative = access and (Opcodes.ACC_ABSTRACT or Opcodes.ACC_NATIVE) != 0
        val isSynthetic = access and Opcodes.ACC_SYNTHETIC != 0
        return if (name == "<clinit>" || isAbstractOrNative || isSynthetic) delegate
        else TimingMethodVisitor(Opcodes.ASM9, delegate, access, name, descriptor, owner)
    }
}

TimingClassVisitor.visitMethod 必须把每个被选中的方法连接到 TimingMethodVisitor; 过滤必须同时覆盖 class 包名, runtime 自身, abstract/native/synthetic 方法, 并按业务决定是否跳过 coroutine/Compose 生成类. AdviceAdapter 对构造器的 onMethodEnter 会在合法的 super/this 构造调用之后触发; onMethodExit 可覆盖显式 return 与 ATHROW, 但不能自动覆盖被调用代码抛出的隐式异常. 需要 “所有异常出口都 end” 时, 应在验证过的实现中生成 try/finally 异常处理器, 而不是只复制本片段.

变换类型frame 策略适用和限制
只插入不改变控制流的调用COPY_FRAMES保留输入 frame, 适合本例的线性 enter/exit 调用; 不得新增跳转, handler 或改变局部变量布局.
新增跳转, try/catch/finally 或复杂控制流COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS 或目标 AGP 所支持的更宽计算模式让 AGP/ASM 重算受影响方法的 frame; 构造器, 异常出口和层级解析必须以目标 AGP 的 API/验证结果为准.

注册路径是 Plugin -> AndroidComponents Variant API -> instrumentation -> AsmClassVisitorFactory -> TimingClassVisitor -> TimingMethodVisitor -> variant classes -> DEX/APK/AAB. 本插件分别在 com.android.application 与 com.android.library 应用后注册, 避免假设 library 没有插桩需求. 产物验证至少包括: (1) 对一个明确目标方法执行 release/minified 变体的 instrumentation test 或受控手测;(2) 解包/反编译仅用于确认插入调用存在;(3) 检查插入 runtime 没有被 R8 裁剪;(4) 以对应 mapping retrace 崩溃堆栈. 不要以 debug 成功替代 release 验证.

五, 构建逻辑如何组织

方式优点缺点适用场景
根 build script直接易膨胀, 复用和测试差少量一次性配置
buildSrc自动加入构建 classpath, 上手快改动容易让整个构建逻辑失效重编小型项目
Convention Plugin类型安全, 规则集中, 模块脚本变薄需要设计清晰插件边界中大型多模块项目
Included Build构建逻辑隔离, 可独立缓存和复用初始结构更复杂大型项目或多仓库共享
发布插件版本化, 跨仓库复用发布和兼容成本最高多团队统一工程平台

Convention Plugin 应按职责拆分, 如 android-application, android-library, android-compose, android-feature, 不要再做一个包含所有开关的巨型插件.

六, KAPT, KSP 与编译器插件

机制输入层级适合场景风险
KAPT/APTJava 注解处理模型兼容历史 Java processorKotlin 需要 stub, 构建开销高
KSPKotlin 符号模型Room, 路由, 序列化等代码生成不能任意改写已有函数体
Kotlin Compiler Plugin编译器 IR / 语义阶段深度语言或 Compose 类能力强绑定编译器版本, 维护成本高
ASMJVM class 产物通用字节码扫描和插桩已丢失部分源码语义, 要保证字节码正确

选择原则: 能用普通代码解决就不用生成; 能用 KSP 生成就不进入编译器内部; 只有必须改写已有字节码时才用 ASM/Instrumentation.

七, R8, 混淆与线上边界

R8 的工作不只是改类名:

  • shrink: 移除不可达代码和资源引用.
  • optimize: 内联, 常量传播, 去虚调用等优化.
  • obfuscate: 重命名类, 字段和方法.
  • desugar / 配套编译步骤: 让部分新语言 / API 语法适配目标字节码和设备能力, 具体阶段由工具链版本决定.

必须显式保护或验证的入口:

  • 反射按类名 / 方法名访问.
  • Gson 等按字段名序列化.
  • JNI 静态注册和 native 查找.
  • WebView JSBridge, 路由和插件入口.
  • 由外部配置, Manifest 或服务端下发名称引用的类型.

插桩发生在 R8 之前时, 直接写入 invokestatic com/example/TraceRuntime.begin/end 是 R8 可见的静态调用边, 通常无需因 “插桩” 本身添加 keep. 只有 runtime 通过反射, 外部名称, JNI/Manifest/服务端配置等非静态可见边界进入, 或确实需要保留特定类/成员名称时, 才添加收窄到该边界的 keep. 流水线为 AGP instrumentation -> R8 shrink/optimize/obfuscate -> DEX/APK/AAB, 因此要以 minified 产物验证调用仍存在且行为正确.

以下是上下文片段, 示例仅适用于 runtime 被反射按完整名称调用的边界; 直接 invokestatic 场景不应机械复制该规则.

# Only for a reflection/external-name boundary requiring these names.
-keep class com.example.TraceRuntime { public static void begin(java.lang.String); public static void end(); }

retrace 使用与崩溃 APK/AAB 完全同一 build ID 归档的 mapping.txt, 例如 retrace <mapping.txt> <obfuscated-stacktrace.txt>; 命令可用性随 R8/AGP 发行包变化, 应使用该构建所带工具. R8, retrace 和 release 验证属于本篇; 构建耗时测量请转至 Gradle 构建性能专题, 常规工程 DSL 请转至 Gradle 与工程化.

发布流水线必须归档与 APK/AAB 一一对应的 mapping 和 native symbols. 修复混淆问题后要用 release/minified 变体回归, debug 包通过不能证明 R8 配置正确.

自测 (在已固定兼容矩阵的样例 Android 工程中执行): 为 com.example 下的普通方法, 构造器和抛出异常的方法各写一个受控用例; 构建 application 与 library 的 debug/release 变体. 预期目标普通方法出现一次 TraceRuntime.begin/end 调用, runtime 包和 synthetic 方法没有调用; 线性插桩使用 COPY_FRAMES 可通过字节码校验. 若加入 finally/handler 控制流, 切换到目标 AGP 支持的 compute-frame 模式并验证构造器与异常路径. minified APK 中直接调用的 runtime 仍可达; 仅反射用例需要上述精确 keep, 且 mapping 能 retrace 受控崩溃.

八, 插件性能边界

插件只负责避免自己制造性能问题: 不在 apply() 扫描文件 / 执行网络命令, 声明 Task 输入输出, 收窄 Variant, 模块和包名范围, 避免共享可变状态. 性能测量, profile, Build Scan, configuration cache 报告和优化归因统一见 Gradle 构建性能专题.


九, API 与版本治理

新插件优先使用当前 AGP 的 Variant API 与 Instrumentation API. Transform API 只用于理解存量实现, 不应作为新项目入口. AGP 内部类, task 名和构建目录不是稳定公共 API; 每次升级都要在兼容矩阵中固定 AGP/Gradle/JDK/Kotlin 版本, 对插桩结果做字节码级回归, 并测量 configuration/build cache 命中与增量失效范围.

高频面试题

Q1: Gradle Plugin, Extension 和 Task 分别做什么? Plugin 注册构建规则, Extension 暴露用户配置, Task 在执行阶段消费声明的输入并生成输出. Plugin apply 阶段应保持轻量, 真正工作放在 Task action.

Q2: 为什么推荐 tasks.register 而不是 tasks.create? register 使用配置规避, 只有任务进入执行图或被真正需要时才创建和配置; create 会立即实例化, 多模块项目中会放大配置阶段成本.

Q3: Transform API 为什么被替换? 旧 API 常要求大范围扫描 class/jar, 增量和插件组合成本高. 现代 AGP 用 Variant/Artifacts/Instrumentation API 提供更明确的产物和作用域, 让增量, 缓存和并行优化更可控.

Q4: 写一个 ASM 插桩插件最容易踩什么坑? 重复插桩, 构造/异常路径, stack frame, 全量扫描, 共享状态线程安全, runtime 被 R8 裁剪以及对 Kotlin 协程/Compose 生成类误处理. 必须先定义过滤范围和验证 release 产物.

Q5: KSP 和 ASM 怎么选? KSP 读取源码符号并生成新代码, 类型信息丰富, 适合路由, 序列化和 DI; ASM 处理编译后的 class, 适合修改已有方法或扫描字节码. 能生成新代码时优先 KSP, 必须改原方法才考虑 ASM.

Q6: 如何让自定义 Task 支持 Build Cache? 声明完整, 稳定的输入输出, 保证相同输入得到等价输出, 避免读取未声明环境状态和写入输出目录之外. 再根据任务性质标记可缓存, 用报告确认命中原因.

Q7: 为什么 debug 正常但 release 崩溃? 常见原因是 R8 裁剪或重命名了反射, JNI, 序列化, 路由入口, 也可能是 release 专属资源, 签名或变体配置. 要复现 minified 变体, 用 mapping 还原堆栈并补精确 keep 规则.

易错点 / 追问

  • 不要把 configuration cache, build cache 和 up-to-date 混成一个概念.
  • 不要在插件 apply() 或配置块里执行文件扫描, 网络请求和产物修改.
  • 不要继续把旧 Transform API 当作新项目推荐方案.
  • 不要为了 “自动化” 默认扫描所有依赖和 Variant, 先收窄包名, 模块和构建类型.
  • 插桩和 R8 都会改变最终代码, 必须以 release 产物和 mapping 为准验证.
  • AGP 和 Kotlin 编译器内部 API 兼容性有限, 插件需要版本矩阵, 降级开关和清晰错误信息.

Gradle 构建性能专题

构建优化必须先用 Build Scan, Build Analyzer, profile 或 CI 时间序列定位瓶颈. 任何 “KSP 固定快 2 倍” 或 “开启配置缓存一定更快” 的结论都需要在当前项目和处理器上测量.

一, 先建立可比基线

分别记录 clean build, 增量 build, 无改动 build, 本地开发机与 CI, 并保存配置时间, 任务执行时间, 缓存命中率和关键路径. 基线必须记录 AGP/Gradle/JDK/Kotlin/KSP/处理器版本与 daemon 参数.

避免把依赖下载, 首次 daemon/JIT, IDE 索引和远程缓存网络波动混入结论.

可复现实验记录

以下是可运行命令示例; 在项目根目录执行, 命令本身不会证明优化有效. 先固定 Wrapper, JDK, AGP, 网络缓存状态和目标任务, 再将输出与同条件的基线对比.

./gradlew :app:assembleDemoDebug --profile
./gradlew :app:assembleDemoDebug --configuration-cache --configuration-cache-problems=warn
./gradlew :app:assembleDemoDebug --scan

--profile 会生成 HTML profile 报告, 适合初步定位配置和任务耗时; Build Scan 需要组织允许, 服务端可用以及可能的条款 / 数据审查, 不能把 --scan 视为默认可上传的命令. configuration cache 的问题报告说明构建逻辑兼容性, 不等于性能收益. 记录每次运行是否为 clean, 源码改动类型, local/remote cache 状态, 总时长, 配置时长, 关键路径 task 和失败/未命中原因.

二, 常见优化方向

问题证据优先动作
配置阶段慢configuration timeline, 配置缓存报告修复不兼容插件, 减少配置期 I/O, 评估 configuration cache
任务重复执行task history, up-to-date 原因声明精确 inputs/outputs, 避免读取不稳定环境
多模块重编译dependency graph, ABI 变化收敛 api, 使用 implementation, 拆分稳定 API 模块
代码生成慢KAPT/KSP task 时间, 处理器日志升级或替换处理器, 减少聚合处理, 验证增量能力
CI 冷缓存缓存命中率, 下载时间设计安全的 local/remote build cache 与依赖缓存

三, KSP 与 KAPT

KSP 直接面向 Kotlin symbol, 通常能减少 KAPT 的 Java stub 开销, 但收益取决于处理器实现, 增量支持, 源码规模和 Kotlin/KSP 版本. 不能给出无条件 “快 2 倍” 承诺.

迁移检查:

  1. 处理器是否正式支持当前 Kotlin/KSP 组合.
  2. 生成代码语义是否与 KAPT 版本一致.
  3. 是否支持 isolating/aggregating 增量模式.
  4. clean 与 incremental build 分别 benchmark.
  5. 保留回退方案, 避免多个处理器一次迁移导致难以归因.

四, Configuration Cache 与 Build Cache

  • Configuration Cache 复用配置阶段结果; 插件和脚本必须满足兼容约束.
  • Build Cache 复用任务输出; 任务要正确声明输入输出, 并避免不可重现数据.
  • 二者解决不同阶段, 不能混称 “Gradle 缓存”.
  • 远程缓存需考虑凭据, 租户隔离, 敏感产物, 缓存投毒和网络收益.

归因案例: 配置缓存未命中

现象: 连续两次无源码变更构建, configuration cache 均未复用.证据: 使用 --configuration-cache --configuration-cache-problems=warn 后, 报告指向 convention plugin 在配置期读取 git rev-parse 的未声明外部进程输出.根因: 配置模型依赖每次不可追踪的外部状态, 不能安全序列化复用.修复: 把版本信息改为受控 CI 注入的 Gradle property, 并仅在需要该值的 task 中以声明输入消费; 同时保留本地缺失时的明确失败提示.验证: 在相同 commit/JDK/属性下运行无改动构建, 检查报告显示 configuration cache reused, 并回归产物版本字段和 CI 冷缓存时间. 不要只因一次变快就合并, 也不要为了命中缓存丢失版本可追溯性.

本篇只讨论测量, 报告解释与因果归因; 常规 DSL, 变体与 targetSdk 操作见 Gradle 与工程化, 插件, Variant API, ASM, R8/retrace 见 AGP 插件与字节码工程.

五, 工程治理

  • 固定 Wrapper, JDK 和插件版本, 维护兼容矩阵.
  • 使用 convention plugins 收敛重复配置, 避免 subprojects {} 中不可追踪的全局副作用.
  • 定期检查依赖图, 动态版本, 重复资源和注解处理器.
  • CI 对关键构建场景保存趋势和回归阈值, 而不是只看单次成功 / 失败.

高频面试题

Q1: 如何定位 Gradle 构建慢?
先区分配置, 任务执行, 依赖下载和 daemon/JVM, 再用 Build Scan/Analyzer 找关键路径和未命中原因, 最后做单变量 A/B 对比.

Q2: KSP 一定比 KAPT 快吗?
不一定. 它避免了 KAPT 的部分 stub 模型, 但最终取决于处理器实现和增量能力, 必须在相同版本和场景下测量.

Q3: 为什么 implementation 有助于增量编译?
因为它不把依赖暴露到消费者 compile classpath, 内部实现变化不会触发上游重编译. 完整对比见 见 32.

Q4: 如何证明某项 Gradle 优化真的有效? 固定工具链和场景, 分别比较 clean, 代表性增量, 无改动以及 CI 冷 / 热缓存构建; 用 profile, Scan 或 Build Analyzer 证实关键路径和未命中原因变化, 并回归产物与测试门禁. 不能只报一次总耗时或把下载波动算成插件收益.

易错点 / 追问

  • 不把 clean build 优化等同于日常增量构建体验.
  • 不为了并行而无限提高 worker/heap, 避免内存抖动和机器争用.
  • 不开启缓存后只看一次结果; 先检查正确性, 命中率和可复现性.

版本与参考资料

CI/CD 与发布体系

CI/CD 的目标是让每个变更可验证, 每个产物可追溯, 每次发布可控制风险. 门禁测试应全部通过;“80%” 若使用, 通常指经过讨论的覆盖率目标, 而不是测试通过率.

一, 流水线分层

  1. PR / 变更验证: 格式, 静态分析, 单元测试, 受影响模块构建, 安全检查.
  2. 主干验证: 完整测试矩阵, 制品构建, 集成 / 设备测试和性能回归.
  3. 候选发布: 签名, R8, mapping/native symbols, SBOM, 渠道配置和人工审批.
  4. 灰度发布: 按指标逐步放量, 可暂停, 降级和覆盖修复.
  5. 发布后监控: crash-free, ANR, 启动, 核心业务漏斗和用户反馈.

二, 正确的质量门禁

  • 被纳入门禁的测试必须 100% 通过; flaky test 应隔离, 跟踪和修复, 不能用 “80% 通过率” 放行.
  • 代码覆盖率只是风险信号, 可按模块设置目标, 但不能替代断言质量, 边界测试和集成测试.
  • 静态分析, 安全扫描和许可证策略应定义严重级别, 豁免责任人和到期时间.
  • 性能门禁使用稳定设备, 足够迭代和统计阈值, 避免单次波动阻塞所有发布.

最小 CI 骨架

以下为流程片段, 以 GitHub Actions 为例; Action SHA, secret 名称, Android SDK 组件, 变体和任务名必须按项目实际情况填充和核验. 因为仍使用占位符且未列出项目的 SDK/签名校验细节, 它不是可直接生成候选制品的工作流. 所有密钥均为占位符, 必须在 CI secret/受控身份中替换, 不能提交到仓库或回显到日志.

name: android-verify
on:
  pull_request:
  push:
    branches: [main]
jobs:
  verify:
    runs-on: ubuntu-latest
    permissions: { contents: read }
    steps:
      - uses: actions/checkout@<PINNED_ACTION_COMMIT>
      - uses: actions/setup-java@<PINNED_ACTION_COMMIT>
        with: { distribution: temurin, java-version: "17" }
      - run: ./gradlew :app:testDemoDebugUnitTest :app:assembleDemoDebug --no-daemon
  release-candidate:
    if: github.ref == 'refs/heads/main'
    needs: verify
    runs-on: ubuntu-latest
    permissions: { contents: read, id-token: write }
    steps:
      - uses: actions/checkout@<PINNED_ACTION_COMMIT>
      - uses: actions/setup-java@<PINNED_ACTION_COMMIT>
        with: { distribution: temurin, java-version: "17" }
      - uses: android-actions/setup-android@<PINNED_ACTION_COMMIT>
      - name: Install required Android SDK components
        shell: bash
        run: sdkmanager "platforms;android-<COMPILE_SDK>" "build-tools;<BUILD_TOOLS>"
      - name: Materialize signing key
        shell: bash
        run: |
          set -euo pipefail
          key_store="$RUNNER_TEMP/release-signing.jks"
          printf '%s' '${{ secrets.REPLACE_WITH_KEYSTORE_BASE64 }}' | base64 --decode > "$key_store"
          test -s "$key_store"
          chmod 600 "$key_store"
          printf 'ORG_GRADLE_PROJECT_signingStoreFile=%s\n' "$key_store" >> "$GITHUB_ENV"
      - name: Build signed bundle
        shell: bash
        run: ./gradlew :app:bundleProdRelease --no-daemon
        env:
          ORG_GRADLE_PROJECT_signingStorePassword: ${{ secrets.REPLACE_WITH_KEYSTORE_PASSWORD }}
          ORG_GRADLE_PROJECT_signingKeyAlias: ${{ secrets.REPLACE_WITH_KEY_ALIAS }}
          ORG_GRADLE_PROJECT_signingKeyPassword: ${{ secrets.REPLACE_WITH_KEY_PASSWORD }}
      - uses: actions/upload-artifact@<PINNED_ACTION_COMMIT>
        with: { name: release-evidence, path: "app/build/outputs" }
      - name: Remove temporary signing key
        if: ${{ always() }}
        shell: bash
        run: rm -f "$RUNNER_TEMP/release-signing.jks"

该 job 独立完成 checkout, JDK 与必要 Android SDK 准备; 将 base64 keystore 临时写到 runner 临时目录, 并映射 storeFile, storePassword, keyAlias, keyPassword 至 ORG_GRADLE_PROJECT_signingStoreFile, ORG_GRADLE_PROJECT_signingStorePassword, ORG_GRADLE_PROJECT_signingKeyAlias, ORG_GRADLE_PROJECT_signingKeyPassword. 构建失败时 shell 的非零退出会停止后续普通步骤; 清理步骤以 always() 执行. Gradle 配置也必须读取这四个同名 properties, 例如 providers.gradleProperty("signingStoreFile"), 否则此片段不能签名.

预期: PR 只生成可验证的 debug 产物; 主干在门禁通过后才进入签名流程. 该片段不声称已运行. 实际流水线还必须限制谁能触发签名, 验证 secret 映射和归档 mapping/native symbols/SBOM, 且将 Action 固定为真实不可变 commit SHA.

流水线自测与预期证据

在隔离的测试仓库或受控分支执行以下检查, 不在 PR 日志中输出 secret:

  1. 让 release-candidate 单独启动 (不依赖 verify 的 workspace), 检查 job 日志包含 checkout, Java 和 Android SDK 准备, 且 bundleProdRelease 找到所需 SDK; 预期证据为 job URL, 实际 Action commit SHA 和 Gradle 成功日志.
  2. 注入仅用于测试的 base64 keystore 及四个 signing secret, 检查 Gradle 收到四个 ORG_GRADLE_PROJECT_signing* 属性并产出已签名 AAB; 预期证据为签名验证输出, 制品 SHA-256 和不含 secret 值的日志.
  3. 故意令 bundle 任务失败, 检查上传步骤不执行而 Remove temporary signing key 仍执行; 预期证据为失败 job 记录及 runner 临时目录清理审计 (仅记录文件名 / 退出状态).

版本与发布决策

versionCode 是 Android 安装 / 升级比较用的单调递增整数; versionName 可采用语义化版本如 2.4.0 供人识别, 但不会替代 versionCode. 每次候选制品把二者, commit, CI build ID 和制品 SHA-256 绑定. 语义化版本对 Android 客户端是团队约定, 不应虚构为系统强制规则.

信号决策操作验证
crash-free/ANR 在预设观察窗内正常扩大灰度进入下一人群或渠道分层看版本, 机型, 网络和核心漏斗
新版本严重崩溃或数据风险立即暂停停止放量, 关闭风险功能, 评估服务端兼容确认新安装不再获取问题版本
可由开关规避且数据兼容降级后观察保留审计记录并限时修复观察指标恢复且修复版回归
已安装包必须修复覆盖发布发布更高 versionCode 的修复版本核对 mapping, 签名, 版本追溯

三, APK, AAB 与分发渠道

AAB 是 Google Play 新应用和其动态交付体系的主要发布格式, Play 根据设备配置生成 APK. 它不是所有应用商店和企业分发场景的统一格式; 国内商店, 企业 MDM 或侧载可能仍需要 APK / 渠道包.

发布系统应根据渠道生成, 签名和归档对应制品, 不把 “AAB 必须用于所有 Android 发布” 写成平台绝对规则.

四, 签名与供应链安全

  • 上传密钥, 应用签名密钥和 CI 身份分离, 使用最小权限与审计.
  • keystore, 密码和 API token 不进入代码库, 构建日志或普通制品缓存.
  • 固定 Gradle Wrapper, JDK, AGP 和依赖锁, 记录构建环境.
  • 生成并归档 APK/AAB 校验值, mapping, native symbols/build-id, SBOM, 依赖清单和签名信息.
  • 第三方 Action / 插件固定版本或 commit, 评估供应链和权限边界.

五, 灰度, 降级与回滚

灰度比例不是固定 1% → 10% → 50% 模板, 应由样本量, 风险, 渠道和指标决定. 每阶段定义 crash-free, ANR, 启动, 关键业务漏斗, 分层维度, 暂停条件, 责任人和观察窗口.

客户端通常无法把已安装用户直接回退到旧包. 可用手段包括暂停放量, 下架问题渠道包, 服务端兼容, 功能开关降级和发布更高版本覆盖. 热修复受技术风险和商店政策约束, 不是所有项目 “必须有” 的能力, 也不能替代正常发布与回滚设计.

六, 制品追溯

每个制品至少绑定: git commit, versionCode/versionName, CI build ID, 渠道, ABI, 构建变体, 依赖锁, mapping, native symbols/build-id, 签名身份, 审批记录, 灰度配置和测试报告.

线上事件携带同一组版本标识, 才能把崩溃, ANR 和性能回归映射回唯一源码与制品.

高频面试题

Q1: 单元测试通过率 80% 能否作为门禁?
不能. 纳入门禁的测试应全部通过; 80% 如果出现, 通常是覆盖率目标, 而且仍需解释统计范围和风险例外.

Q2: AAB 是否是所有 Android 渠道的强制格式?
不是. 它主要适用于 Google Play 的发布和动态交付要求, 其他商店或企业分发可能使用 APK.

Q3: 线上严重崩溃如何处置?
先暂停放量和入口, 评估服务端兼容 / 功能开关降级, 必要时发布覆盖版本; 只有在合规, 可验证且风险可控时才考虑热修复.

Q4: 如何保证发布可追溯?
将源码, 环境, 依赖, 签名, 制品, 符号表, 测试和灰度记录绑定到同一 build ID, 并保证归档不可被无审计覆盖.

易错点 / 追问

  • 不把覆盖率当测试通过率.
  • 不把固定灰度比例当通用规则.
  • 不承诺客户端可以让已安装用户直接回退版本.
  • 不把热修复作为绕过商店审核, 测试或安全审查的默认方案.

版本与参考资料

隐私合规与权限治理

“在强监管时代, 因为隐私合规不达标导致 App 被全面下架的惨痛教训比比皆是. 合规不再是边角料工作, 而是生命线工程.”

面试策略: 如有可核验的 SDK 开发经历, 可结合其中收拢采集入口, 治理第三方 SDK 或设备标识符 (OAID 等) 演进的事实说明经验; 否则以本篇的治理原则, 练习和证据表为准.

一, 隐私合规的 “生死红线”

国内监管, Google Play 与 GDPR 等规则并不以一句绝对口号替代判断. 每项处理须按目的, 数据类型, 适用地区, 合法基础, 面向用户的披露与接收方逐项确认, 并实施最小化, 可撤回, 可审计的工程控制.

  • 隐私弹窗: 用户首次打开 App 时, 应在适用地区, 商店政策, 业务目的和合法基础的框架下展示隐私政策与同意选择. 网络本身不等同于个人信息采集: 例如加载本地同意页不需网络; 必要的安全, 崩溃防护或法律要求的联网能力是否可在同意前运行, 必须经隐私 / 法务评估, 最小化, 披露并留存依据, 不能由开发者一概断言.
  • 默认勾选: 注册 / 登录页面的协议同意框, 绝不能默认帮用户打勾.
  • 超范围收集: 不能因为是一个手电筒 App 就要求读取通讯录和位置信息, 这违反 “最小必要” 原则.
  • 频次限制: 后台高频轮询位置或剪切板是否违规, 取决于适用地区法规与商店政策对最小必要和频率的认定, 应逐项核验并留存依据; 不存在放之四海皆准的固定频次上限.

二, Android 权限治理演进

Android 系统本身的权限管理也顺应合规趋势, 逐年收紧:

权限变化里程碑影响与适配策略
Android 6.0 动态权限危险权限 (相机, 位置等) 不能在安装时一揽子授权, 必须在使用时动态弹窗申请.
Android 10 分区存储限制对 SD 卡的粗放读取. target 29+ 的 App 只能自由访问自身目录与 MediaStore, 读取其他文件需 SAF; 强制范围随 targetSdk 与系统版本而定.
Android 11 单次授权引入 “仅限这一次” 选项. App 进入后台且无前台服务在运行时, 系统会在短暂延迟后收回该权限. 代码里不能缓存授权状态, 每次使用前都要检查.
Android 12 近似位置用户可以选择只给你 “大致位置”(精确度几平方公里) 而不是 GPS 坐标. 位置功能必须兼容这种精度.
Android 13 通知权限发通知不再是默认开启的, 需要显式申请 POST_NOTIFICATIONS 权限.
Android 14 媒体部分授权允许用户只授权照片库中的 “几张指定图片”, 而不是全量相册读取权限.
Android 15 局部屏幕共享 + 敏感内容保护MediaProjection 可限定共享 / 录制单个 App 窗口; 屏幕共享时系统自动隐藏通知内容并支持标记敏感字段不入录屏; 后台身体传感器新增独立权限 BODY_SENSORS_BACKGROUND (API 35).
Android 16 本地网络与通知权限收紧局域网保护为 opt-in (compat flag 开启), 需 NEARBY_WIFI_DEVICES 恢复; 锁屏自动隐藏 OTP 等敏感通知 (API 36); POST_NOTIFICATIONS 未授权时通知仍为静默丢弃 (Android 13 起行为, 16 无新增异常).
Android 17 局域网权限 + OTP 访问延迟新增 ACCESS_LOCAL_NETWORK 运行时权限 (target 37 起默认拦截); 系统级联系人选择器与一次性授权所选联系人; 短信 OTP 程序化读取延迟 3 小时: 该机制源自 Android 16 QPR2, 17 起对 target 37 生效 (API 37, 2026-06 正式版, 部分细则以官方文档核验).

对风控 / 埋点 SDK 的影响: 部分屏幕共享与敏感内容保护会缩小录屏/屏幕采集窗口, 依赖屏幕内容的采集方案需改为逐窗口授权并处理黑屏与系统脱敏; 锁屏 OTP 隐藏与短信 OTP 延迟读取意味着依赖通知/短信验证码的流程需改用 SMS Retriever 等官方通道, 并避免采集已被系统脱敏的通知内容.

三, 唯一 ID 的作用域与隐私边界

唯一 ID 的选择首先由目的决定, 不是先收集再寻找用途. 登录账户, 一次安装, 同一应用数据去重, 广告归因和受管设备管理的标识范围不同, 不应互相替代或拼接为规避用户选择的跟踪方案.

标识类别常见作用域与可变性可接受的判断方向禁止的推断
应用自建安装 ID通常限单次安装; 卸载重装或清除数据后可变化用于本应用内非跨应用的安装级状态, 并定义删除与轮换规则不应承诺跨重装, 跨应用或跨设备稳定识别
Android IDAndroid 8.0+ 新安装时通常按应用签名 key, 用户和设备作用域; OTA/系统升级, 卸载重装, 恢复出厂和签名 key 轮换是不同场景, 不能概括为 “签名变化一般必然改变”. 具体稳定性及备份恢复/轮换能力须按当前官方文档和目标设备验证仅在经批准的应用内目的下评估, 验证多用户/工作资料及上述安装, 升级和恢复场景不将其视为所有 App 共享的永久设备 ID, 也不以其变化或未变化推断用户身份
广告标识符 (AAID/OAID)为广告或归因设计, 具有用户重置, 限制或地区/设备可用性边界仅在适用政策, 用户选择, 披露和接收方合同均满足时使用不得以重置后关联, 弱特征拼接或备用硬件 ID 绕过限制
IMEI, MAC 等硬件标识普通第三方应用通常受限; 例外依赖受管理设备角色, carrier privileges 或 default SMS 等条件仅在明确角色, 权限, 法律与商店政策均允许的受控场景使用不作为一般分析, 广告或设备指纹的默认方案

最小化策略按以下顺序决策: 能否不使用标识符; 能否使用账号或短期会话 ID; 能否缩小到应用内, 目的内和最短留存期; 是否可向用户提供清晰告知, 同意或撤回路径; 是否已限制接收方, 访问权限与二次使用. 每个结论应记录数据类别, 目的, 地区, 合法基础, 保留期, 接收方和删除 / 撤回后的处理.

OAID 是国内生态常见的匿名设备标识符方案, 通常允许用户在系统设置中重置. 可重置不等于天然合规: 使用 OAID 仍须按目的, 数据类型, 地区, 合法基础, 用户选择, 接收方披露, 平台 / 商店政策和留存期逐项确认. 当标识符不可用, 用户拒绝或政策不允许时, 应降级为不依赖跨应用跟踪的功能或聚合统计, 而不是寻找替代硬件标识或组合弱特征.

最小安全示意

以下代码和伪代码只说明空值, 异常, 作用域与降级边界, 不是已经通过隐私, 法务, 商店或地区政策审核的生产实现. 调用前仍须确认目的, 最小化, 告知同意, 留存期和接收方.

// 示意: Android ID 只作为经批准的应用内用途的可选输入.
// Android 8.0+ 新安装通常按应用签名 key, 用户和设备作用域; OTA/系统升级, 卸载重装, 恢复出厂和签名 key 轮换须分别验证.
// 不将 "签名变化" 概括为一般必然改变; 具体稳定性及备份恢复/轮换能力以当前官方文档和目标设备验证为准.
fun readApprovedAndroidId(context: Context): String? = try {
    Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
        ?.takeIf { it.isNotBlank() }
} catch (_: SecurityException) {
    null // 记录不含原值的失败类别, 调用方走无标识符路径.
}
// 示意: 安装 ID 是本应用的随机 UUID, 不从硬件或其他信号推导.
// 存在应用私有存储; 卸载, 清除数据或用户触发的重置会使其失效.
fun installationId(store: SharedPreferences): String {
    store.getString("installation_id", null)?.let { return it }
    return UUID.randomUUID().toString().also { id ->
        check(store.edit().putString("installation_id", id).commit())
    }
}

fun resetInstallationId(store: SharedPreferences) {
    store.edit().remove("installation_id").commit()
}

广告标识符只能通过发布时仍受支持的官方 API 或 SDK 获取, 并尊重用户选择, 地区与商店限制. 不硬编码过时接口, 不将缺失, 限制, 重置或异常解释为可以改用其他设备信号. 以下是失败降级伪代码:

if purpose is not approved or user choice does not permit advertising ID:
    use no advertising identifier
else if current official API/SDK is unavailable, returns unavailable, or reports restricted:
    use no advertising identifier and keep core function available
else:
    use the identifier only for the approved purpose and retention period

不能将 Android ID, 安装 ID, 广告标识符, 网络, 传感器或其他弱信号拼接成设备指纹, 也不能在用户限制, 重置或标识符不可用时寻找替代标识来绕过平台或监管限制. 降级路径应保留核心体验或采用经批准的聚合统计, 不产生跨应用或跨重置关联.

合规的设备标识怎么做

“禁止拼接设备指纹” 只是底线, 正面工程答案是不追求唯一 ID, 而用 “多维信号 + 概率性关联” 完成设备识别:

  • 信号构成: 组合硬件 / 系统 / 行为特征 (系统版本, 语言, 时区, 分辨率, 传感器可用性, 使用节奏等), 不依赖单一唯一 ID. 每类信号按隐私敏感度分级: 低敏感系统特征可低频采集; 高敏感可标识字段 (IMEI, 序列号, 精确位置) 默认不采集或需单独批准.
  • 不可逆处理: 对确需使用的可标识字段做不可逆哈希 / 加盐, 本地存储不落明文, 明文也不出现在日志, 埋点和崩溃附件.
  • 信号漂移与降级: 系统升级, 恢复出厂或权限重置后字段会变化, 设计上要容忍漂移 (同一设备允许部分信号不一致); 采集失败或权限拒绝时降级为更粗粒度信号或仅会话级关联, 不因缺一个信号中断核心功能.
  • 隐私最小化: 按用途收集 (只采风控所需字段), 提供删除 / 撤回路径, 明确保留期, 到期删除.
  • 与服务端风控的分工: 客户端只负责采集信号并上报, 关联 / 打分 / 拉黑等判定由服务端完成; 客户端不承担 “是不是同一人” 的判断, 也不在本地留存完整画像.

与对抗检测的边界: root/模拟器/端侧 Hook 检测输出的是 “环境风险信号”, 属于环境判断, 不等于采集敏感个人信息; 边界在于检测不应顺带读取或上传可标识用户内容, 检测结果本身也按目的限定留存.

四, 第三方 SDK 合规治理

因为第三方 SDK 的违规采集导致主 App 被下架, 这叫 “背锅”.所以大型 App 会对接入的 SDK 进行严苛治理.

  • 延迟初始化: SDK 不能按 “所有 SDK 同意后初始化” 的单一规则处理; 应由同意前允许/禁止/待法务确认矩阵驱动. 广告, 统计, 归因和设备标识 SDK 通常属于同意前禁止项; 安全, 反欺诈, 崩溃防护或履约相关 SDK 是否可早期初始化, 要结合实际数据流, 地区, 合法基础, 披露与最小化评估.
  • 采集点收口: 收拢项目里的定位, 剪切板, 设备信息读取入口, 封装成统一的 Manager 代理. 在 Manager 中增加鉴权, 缓存, 频次控制和合规拦截.
  • 静态扫描与字节码 Hook:
    • 对于不受控的第三方 SDK 或陈旧 Jar, 可在打包期以 ASM 发现 / 拦截已知敏感调用, 并使其进入受审计代理; 不能仅靠 “返回空值” 伪装授权, 也不能把 Hook 当成披露, 数据流审查或 SDK 治理的替代品.

同意门禁时序与敏感 API Manager

下面是伪代码, 描述门禁状态机而非可编译的 Android 实现. 它区分必要联网与采集目的, 具体状态, 数据字段和保留期要经过对应地区的隐私 / 法务复核.

app launch
  -> load local policy text and saved consent record
  -> consent unknown: show choice; initialize only policy matrix marked "allowed" SDKs
  -> consent granted: record policy version/time/source; initialize only matrix-approved, disclosed SDKs
  -> consent declined: start core non-collection experience; keep optional SDKs disabled
  -> consent withdrawn: stop future optional collection, notify SDKs, clear eligible local data,
     create auditable completion record and show the remaining service limitations

以下为上下文片段, Manager 是唯一业务调用入口; 省略权限请求 UI, 数据删除实现, 审计后端和地区策略注入. 它不应用 “返回空值” 伪装权限已授予, 而是让调用方处理明确状态.

sealed interface SensitiveResult<out T> {
    data class Allowed<T>(val value: T) : SensitiveResult<T>
    data object ConsentRequired : SensitiveResult<Nothing>
    data object PermissionRequired : SensitiveResult<Nothing>
    data object Unavailable : SensitiveResult<Nothing>
}

interface SensitiveApiManager {
    suspend fun currentCoarseLocation(purpose: String): SensitiveResult<Location>
}

Manager 应校验已批准目的, 同意状态, 运行时权限, 频次 / 前后台条件和地区策略, 并输出不含原始敏感值的审计事件. ASM 只能作为发现或防御深层 SDK 直连的补充, 不能替代 SDK 合同, 数据流审查和运行时验证; 其工程实现边界见 AGP 插件与字节码工程.

同意前初始化矩阵

矩阵是发布配置的一部分, 由地区策略, SDK 版本和数据流清单共同生成; 新增 SDK, 字段, 域名或目的时必须重新评审. 待法务确认 不得被初始化代码当作允许.

类别 / 示例数据与目的同意前状态初始化控制与所需证据
本地隐私页, 用户选择存储本地政策版本, 选择状态; 展示与记录选择允许不联网或不上传; 保留版本, 时间和策略版本审计
广告, 统计, 归因, OAID/AAID SDK标识符, 事件, 广告归因禁止延后类加载/初始化/网络调用; 同意后仍核对地区, 披露, 合法基础和商店政策
核心功能 SDK为用户请求的功能所必需的数据待法务确认逐地区确认合法基础, 最小字段, 披露, 接收方和留存; 未批准走本地降级路径
安全, 反欺诈, 崩溃防护 SDK安全事件, 诊断数据待法务确认证明必要性与比例性, 限制字段 / 网络与保留期; 批准前不采集非必要标识或内容
法律义务或受管设备能力法定字段或企业设备管理数据待法务确认确认适用义务 / 设备角色, 告知与审计; 不能以此扩展一般跟踪

披露与拒绝流程模板

披露模板 (文本模板, 须由隐私 / 法务批准后替换): 我们为 <REPLACE_WITH_PURPOSE> 在 <REPLACE_WITH_TRIGGER> 收集 <REPLACE_WITH_DATA_CATEGORY>,发送给 <REPLACE_WITH_RECIPIENT>,保留 <REPLACE_WITH_RETENTION>;您可在 <REPLACE_WITH_SETTINGS_PATH> 拒绝或撤回. 不要将模板中的占位符直接发布.

权限拒绝流程: 解释当前功能为何需要权限 -> 用户拒绝后保持无权限可用的核心路径或明确功能受限 -> 用户主动再次触发时再请求 -> shouldShowRequestPermissionRationale 为 false 且未授权时, 引导至系统设置但不循环弹窗 -> 从设置返回和每次敏感操作前重新检查权限. 永久拒绝, 单次授权, 部分照片授权和 AppOps 限制均可能让 “曾授权” 失效.

五, 敏感行为的安全保障

  • 剪切板滥用:
    • iOS 14 在顶部弹出横幅提示, Android 12 在底部弹出 toast 提示.
    • 如果应用为了淘口令等功能无脑轮询读取剪切板, 会让用户极其反感甚至遭遇投诉. 正确做法是切前台时且发现特定格式的文本才进行解析.
  • 日志安全 / 脱敏:
    • 首先禁止非必要原值进入日志, 埋点和崩溃附件, 不以 “加密后可采” 为默认理由. 确有已批准诊断目的时, 才在权限控制, 用途限定, 最短留存, 访问审计与密钥管理下受控加密; 密码, 认证凭据和密钥不应记录.
  • 权限被拒处理:
    • 不能因为用户拒绝了位置权限, 就不让用户使用 “扫一扫” 功能, 不能搞权限捆绑.

六, 平台政策与 SDK 合规工程化清单

隐私合规面试不要只背权限名, 要能讲 “政策披露 + 技术门禁 + 审计证据”.

国内与海外披露材料

场景需要准备什么重点
国内应用商店 / 监管个人信息清单, 第三方 SDK 清单, 权限使用说明, 注销账号入口采集目的, 频率, 共享对象, 撤回同意
Google PlayData Safety, 权限声明, SDK Index 风险提示数据类型, 用途, 是否共享, 是否加密传输
广告/归因Privacy Sandbox, SDK Runtime, AAID/OAID 合规说明不依赖不可重置标识, 用户可选择和撤回

SDK Runtime / Privacy Sandbox 怎么答

Privacy Sandbox 的方向是减少跨 App 跟踪, 把广告归因, 受众, SDK 隔离等能力平台化. 面试中不需要背 API, 但要表达趋势: 第三方 SDK 权限会被进一步收紧, 宿主 App 需要清楚 SDK 采集什么, 什么时候初始化, 数据发到哪里.

SDK 合规门禁

  1. 接入前审查: SDK 来源, 版本, 隐私政策, 采集字段, 域名, 权限, 包体积, 初始化成本.
  2. 初始化门禁: 按同意前允许/禁止/待法务确认矩阵控制 SDK; 未批准或用户拒绝时提供降级路径.
  3. 敏感 API 收口: 定位, 剪切板, 设备标识, 应用列表, 传感器等统一代理.
  4. 静态扫描: 扫描 Manifest 权限, 敏感 API 调用, 第三方域名, 隐私政策披露是否一致.
  5. 运行时审计: 记录权限请求来源, SDK 初始化时间, 敏感 API 调用栈, 网络域名.
  6. 撤回同意: 用户撤回后停止采集, 清理本地缓存, 通知 SDK 更新状态.

权限排查方法

  • Runtime permission: 检查是否按场景申请, 是否处理拒绝 / 永久拒绝.
  • AppOps: 排查系统层是否允许对应操作, 有些权限 “有授权但 AppOps 被限制” 仍会失败.
  • Photo Picker / 部分媒体授权: 不要假设拿到一次媒体权限就能遍历全相册.
  • 前台服务类型/精确闹钟/通知权限: 不仅要 Manifest 声明, 还要运行时路径和用户开关都正确.

七, 地区, 日期与法律复核

隐私法规, Android 权限, 应用商店政策和第三方 SDK 条款都具有地区与时间属性. 本章只提供工程检查框架, 不构成法律意见. 上线前应记录:

  • 目标国家/地区, 应用商店, OS/API 与 targetSdk.
  • 处理数据的目的, 字段, 合法基础 / 同意方式, 保留期和接收方.
  • SDK 名称, 版本, 数据流, 初始化时机和退出 / 删除路径.
  • 法务/隐私/安全 owner 的复核日期与批准记录.
  • 政策变化后的重新评估, 远程停用和删除补偿方案.

最后核验: 2026-08-07. 任何具体法规期限, 权限行为和商店要求都应在发布日重新核验官方文本.

一手政策来源, 适用地区与核验日期

来源用于核对适用地区 / 范围核验日期
Android permissions 与 TelephonyManager#getImei运行时权限及 IMEI 的受限角色 / API 行为Android 平台; 实际行为随 API, targetSdk 和设备角色变化2026-08-07
Google Play User Data policy 与 Data safety数据披露, 收集 / 共享与商店上架要求在 Google Play 分发的应用2026-08-07
GDPR(EUR-Lex Regulation (EU) 2016/679)合法基础, 透明度, 目的限制与数据主体权利欧盟 / 欧洲经济区及法规适用的处理活动2026-08-07
个人信息保护法 (中国政府网)个人信息处理, 告知同意与个人权利中华人民共和国境内及法律规定的域外处理2026-08-07

合规门禁自测与预期证据

  1. 在每个目标地区加载未同意, 已同意, 拒绝和撤回策略, 尝试初始化每类 SDK; 预期证据为初始化审计仅出现矩阵 “允许” 项, 待法务确认 和 “禁止” 项没有类初始化, 敏感 API 或网络域名记录.
  2. 为广告 / OAID SDK 与安全 SDK 分别抓取测试流量和字段清单; 预期证据为前者在同意前零请求, 后者仅在审批矩阵允许时发送经批准的最小字段, 并能关联政策版本, 合法基础, 披露和保留期记录.
  3. 触发诊断错误并检查日志, 埋点和崩溃附件; 预期证据为不含手机号, 证件号, 认证凭据, 设备标识等不必要原值. 若存在批准的受控加密日志, 应有访问授权, 留存删除和审计记录.
  4. 用普通第三方应用, default SMS/carrier privilege/device owner/profile owner 的测试配置分别覆盖设备标识路径; 预期证据为普通应用不依赖 IMEI, 受限角色仅在授权角色和适用策略下调用, 且不改变 OAID 的合规评估结论.

高频面试题

Q1: 在用户点击同意隐私政策前, App 到底能不能访问网络? 不能给出脱离地区, 目的和披露的绝对答案. 可选统计, 广告, 设备标识和未披露 SDK 初始化应在同意门禁后; 必要安全, 崩溃防护或履行服务所需的联网活动是否可在同意前发生, 应由隐私 / 法务按适用政策确认, 做到最小化, 可审计和透明披露. 开发者不能以 “预热” 为由绕过门禁.

Q2: 怎么防止项目中接入的第三方广告 SDK 在后台偷读用户剪切板或位置信息?

  1. 商务层面: 选用正规, 经过官方审核的版本, 并在应用内隐私政策明确披露该 SDK.
  2. 工程层面: 不在后台启动和保活该 SDK 所在的组件; 通过 ASM 字节码插桩, 在打包期间将该 SDK 调用敏感 API 的方法替换为我们自己的代理方法, 在代理中加入合规状态判断和频次拦截.

Q3: Android 10+ 无法获取 IMEI 后, 风控和广告归因怎么做? 不能将 OAID/AAID 或 “弱特征组合” 描述为天然合规的万能替代. 广告标识, 设备信号和归因 SDK 的使用须按地区, 平台政策, 用户选择与披露逐项确认; 风控应采用目的限定, 最小化, 保留期和人工复核等治理措施, 避免依赖不可重置或规避用户选择的跟踪标识.

Q4: 第三方 SDK 合规治理怎么落地? 答: 先做接入前审查, 明确 SDK 采集字段, 权限, 域名, 初始化时机和隐私披露; 工程上用初始化门禁保证同意前只启动矩阵明确允许的 SDK, 禁止项和待法务确认项不启动. 用户同意也不自动授权超出已披露目的的处理; 用敏感 API 代理 / ASM 扫描收口调用, 运行时记录权限请求和网络域名. 用户撤回同意后要停止采集并通知 SDK 降级.

Q5: Google Play Data Safety 或国内个人信息清单怎么和代码对应? 答: 不能只靠法务文案. 要把权限, SDK, 接口域名, 采集字段和代码调用点建立映射, 通过静态扫描和运行时审计证明 “文档披露的就是代码实际做的”.

Q6: 风控 SDK 怎么做设备标识才合规? 答: 不用唯一 ID 拼接, 而是 “多维信号 + 概率性关联”: 客户端按用途采集硬件 / 系统 / 行为特征, 对可标识字段做不可逆哈希 / 加盐, 本地不落明文; 容忍系统升级 / 重置带来的信号漂移, 采集失败或权限拒绝时降级为粗粒度信号; 明确保留期和删除路径; 判定交给服务端, 客户端只上报信号不承担 “是否同一人” 的判断. root/模拟器/Hook 检测属于环境风险信号, 本身不等于采集敏感数据, 但要限定目的与留存.

易错点 / 追问

  • 粗暴对待权限拒绝: 用户拒绝权限后不断弹窗骚扰, 或者直接 finish() 应用, 这会导致极差体验并被商店警告.
  • 缓存单次授权状态: 在 Android 11+ 把获得的权限记录在 SharedPreferences 中, 下次直接用, 结果权限已被系统回收导致崩溃.
  • 过度索权: 开发图省事, 不管用不用, 先把 Manifest 里的权限申请一大堆, 上架时很容易被拒.

登录鉴权与账号体系

“账号是所有业务的基石, 一次优秀的登录系统设计, 需要兼顾安全防护, 无缝体验以及应对多设备, 状态同步的复杂性.”

面试策略: 把这道题当作架构设计题来答. 不要只讲发送密码存一下 token, 要从 双 Token 体系, 状态机控制, 安全存储 三个维度, 展现对登录流程严密性的思考.

一, Token 与鉴权基础 (OAuth2/JWT)

移动端通常不再使用传统的 Session-Cookie 模型 (有 CSRF 风险和跨端限制), 主流方案是基于 Token 的鉴权机制.

  • JWT (JSON Web Token): 自包含的令牌, 服务端签发后无需查询数据库即可校验 (只要验证签名).
    • 结构: Header (算法)+ Payload (用户 ID / 过期时间)+ Signature (防篡改签名).
    • 弱点: 一旦签发, 在过期前服务端难以主动使其失效 (除非引入黑名单, 但这会失去 JWT 无状态的优势).
  • OAuth2 核心流程: 只做授权, 通过授权码 + PKCE 换取 Access Token 访问受保护资源; 微信 / Google “登录” 的身份认证由构建于其上的 OIDC (id_token / userinfo) 承担, 不要把 OAuth2 说成认证协议.

二, 双 Token 体系与无感刷新

为解决 JWT 无法撤销与长期有效带来的安全风险, 业界标准是采用 双 Token (Access Token + Refresh Token) 机制:

  1. Access Token (AT): 生命周期极短 (如 2 小时), 每次网络请求放在 Header 中 (Authorization: Bearer <token>).即使泄漏, 风险时间也短.
  2. Refresh Token (RT): 生命周期较长 (如 30 天), 仅用于获取新的 AT.绝不能在普通业务接口中传输.

无感刷新流程设计 (并发拦截控制): 当业务请求收到 401 Unauthorized(AT 过期) 时:

  • 网络层拦截器捕获到 401.
  • 挂起当前及后续其他需要鉴权的请求.
  • 发起使用 RT 换取新 AT 的请求.
  • 刷新成功后, 保存新 token, 并自动重试刚才挂起的业务请求.
  • 如果 RT 也过期 (返回特定错误), 则清空本地登录状态, 跳转到登录页.

三, 登录状态机管理

客户端的登录状态往往很乱 (如闪屏页, 其他请求触发掉线), 必须引入状态机或单向数据流来集中管理.

未登录 (LoggedOut) 
  ↓ (输入账号密码/授权)
登录中 (LoggingIn) → 失败回 [未登录]
  ↓ (获取 Token 成功)
已登录 (LoggedIn) 
  ↓ (401触发刷新)
刷新中 (Refreshing) → 成功回 [已登录],失败去 [未登录]

使用单一可信源 (如全局的 StateFlow/LiveData) 来分发当前状态, UI 根据这个状态决定是展示个人中心还是弹出登录框. 避免到处散落 if (isLogin()).

四, 本地安全存储与风险对抗

Token 是用户的钥匙, 保存在本地必须做安全防护:

存储方案风险级别防御手段 / 面试加分项
明文 SharedPreferences / SQLite文件泄露后 token 可直接使用不保存长期高价值凭据; 日志和备份同样要治理
把固定 secret 写进客户端做自定义加密secret 可被提取, 难以轮换不把混淆 / 分段当密钥管理; 使用版本化 envelope encryption 与服务端撤销
Android Keystore + 受控密文存储可降低密钥导出风险, 但保护等级因设备/属性而异读取 security level/必要时 attestation, 设计软件级设备降级; EncryptedSharedPreferences 已弃用, 不作为新项目默认方案

设备绑定与风控检查: 登录不仅仅是密码匹配. 服务端通常还会结合你收集的设备指纹判定: 异地登录, 新设备登录, 同一设备高频切换账号等, 需要触发短信验证码, 滑块验证等二次认证机制.

五, 多设备登录与单点登录 (SSO)

  • 单点登录 (SSO): 企业内部多 App 互通登录状态. 通常由一个主应用 / 网页提供统一鉴权, 返回临时 Ticket, 其他端用 Ticket 去认证中心换自己的 Token.
  • 多设备互踢:
    • 服务端维护一张 [用户ID - 设备ID - Token] 映射表.
    • 用户在 B 设备登录, 服务端使得 A 设备的 Token 失效, 或者通过 WebSocket/Push 主动推一条 “踢出下线” 指令给 A 设备.
    • A 设备收到通知, 清空本地状态, 弹窗提示 “您的账号在其他设备登录”.

六, Token 轮换, 并发刷新与撤销

  • Access token 短期有效; refresh token 采用 rotation 时, 每次刷新返回新 RT 并使旧 RT 失效. 服务端检测旧 RT 重放后可撤销该 token family.
  • 客户端刷新必须 single-flight: 并发 401 只允许一个刷新请求, 其他请求等待同一结果; 刷新完成后最多重放一次原请求, 防止循环 401.
  • 登出, 改密, 设备解绑和高风险事件要由服务端撤销会话; 本地删 token 不能替代服务端撤销.
  • JWT 的 exp/nbf/iat 校验要考虑设备时钟偏差. 安全决策以服务端时间为准, 客户端只做体验优化; 退避计时优先单调时钟.
  • 重试写请求必须有业务幂等键; 不要在 authenticator/interceptor 中无条件重放不可重复 body.
  • token 存储与日志遵循最小暴露, 崩溃日志, 埋点和 URL 中不得包含完整 token.

OAuth 2.0 授权码 + PKCE 的移动端边界

上下文片段, 不是可独立运行示例: 移动 App 属于公开客户端, 无法安全保存 client_secret. 第三方登录应优先使用授权码流程加 PKCE, 而不是把密码交给 App 或使用隐式流程.

App: 生成高熵 code_verifier
App: code_challenge = BASE64URL(SHA-256(code_verifier))
App -> 浏览器/授权服务器: authorization request + code_challenge(S256) + state + redirect_uri
授权服务器 -> App redirect_uri: code + state
App -> token endpoint: code + 原始 code_verifier + redirect_uri
授权服务器: 校验 code,redirect_uri,code_challenge,签发 token
  • state 用于将回调与本次发起请求关联, 回调时必须校验; PKCE 防止截获授权码的一方在没有 code_verifier 时兑换 token.
  • 使用系统浏览器或受信任的授权会话承载登录, 不在 WebView 中收集第三方账号密码; 回调 URI 必须是预登记的精确地址, 不能接受客户端任意传入的重定向地址.
  • 授权码只能兑换一次. 用户取消, state 不匹配, 回调被重复触发或网络超时都回到可重新登录的终态, 不能把未知结果当作已登录.

JWT 校验责任与刷新 single-flight

资源服务至少验证签名算法白名单, 签发者, 受众, 过期时间和必要声明; 不能只 “解码 payload” 后相信 userId. 客户端可读取过期声明优化体验, 但授权结论由服务端签名校验和会话撤销记录决定.

// 上下文片段:TokenRepository 由单进程内的网络层共享.
private val refreshMutex = Mutex()
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

private data class RefreshFlight(val generation: Long, val deferred: Deferred<Result<Token>>)
private var inFlight: RefreshFlight? = null

suspend fun refreshOnce(expectedGeneration: Long): Result<Token> {
    val shared = refreshMutex.withLock {
        inFlight?.takeIf { it.generation == expectedGeneration }?.deferred ?: run {
            val deferred = repositoryScope.async {
                val refreshToken = tokenStore.refreshToken(expectedGeneration)
                    ?: return@async Result.failure(LoginRequired())
                api.refresh(refreshToken).onSuccess { token ->
                    tokenStore.replaceAtomically(expectedGeneration, token)
                }
            }
            val created = RefreshFlight(expectedGeneration, deferred)
            deferred.invokeOnCompletion {
                repositoryScope.launch {
                    refreshMutex.withLock {
                        if (inFlight?.generation == created.generation &&
                            inFlight?.deferred === created.deferred) inFlight = null
                    }
                }
            }
            inFlight = created
            deferred
        }
    }
    return shared.await() // 取消仅终止当前等待,不取消或注销共享刷新任务
}

共享刷新任务由独立 repositoryScope 所有, 等待者只 await(); 调用方取消只终止自身等待, 既不取消也不从 inFlight 注销该任务. 任务自身成功或失败完成后, completion handler 才在短 Mutex 临界区按 generation + Deferred identity 清理. Mutex 不包住网络请求, 因此不是把所有刷新串行化; 所有并发 401 仍共享同一个成功或失败结果, refresh-token rotation 不会并发刷新. generation 在登出, 账号切换或 RT 轮换失效时递增, 新会话创建自己的 flight, 旧任务完成不能覆盖新会话. 原子替换指 AT, 轮换 RT 及版本号在同一持久化事务提交; 原请求最多重放一次. 写请求仍需业务幂等键, 见支付订单与状态机.

多进程与弃用存储迁移

DataStore 和进程内 StateFlow 都不是跨进程一致性协议. 若推送/SDK 使用独立进程, 应指定唯一 “账号状态所有者”, 通过受权限保护的 Binder/ContentProvider 或服务端重新鉴权同步版本化快照; 不得让两个进程同时用同一 RT 刷新. 退出登录时递增会话版本, 接收方发现版本变化即关闭旧连接和内存缓存.

新项目不要把已弃用的 EncryptedSharedPreferences 当默认方案. 可将版本化密文放在普通受控存储中, 把 AES 密钥交给 Android Keystore 管理. 系统备份恢复时 Keystore 密钥通常不能随应用数据恢复; 密文无法解密时删除本地凭据并重新认证, 绝不降级为明文. 设备信号只供服务端风控辅助, 不能恢复 token 或替代登录.

高频面试题

Q1: Access Token 和 Refresh Token 的机制是什么? 为什么要用两个 Token? 为了平衡安全与用户体验. 单一长效 Token 泄漏风险极大且难以撤销; 短效 Token 频繁过期会让用户反复登录体验极差. 双 Token 用短效 Access Token 降低泄漏后的损失窗口, 用长效 Refresh Token 实现静默续期, 且 Refresh Token 只发往专门的刷新接口, 截获概率低.

Q2: 在协程 / RxJava 拦截器中, 怎么处理多并发请求导致的多次 Token 刷新问题? 利用并发锁或者协程 Mutex. 当第一个 401 触发刷新时, 上锁, 后续的 401 请求判断正在刷新中, 则挂起等待. 刷新成功后, 通知所有等待的请求用新 Token 重试; 如果刷新失败, 则全部抛出未登录异常跳转登录页.

Q3: App 卸载重装后, 如何保持依然处于登录状态 (免密登录)? 默认重新认证. 卸载会删除应用数据; 即使系统备份恢复了密文, 关联 Keystore 密钥通常不可恢复, 解密失败必须清理并重新登录. 设备信号只能辅助服务端评估风险, 不能作为稳定免密凭证.

学完能做什么与练习

你应能画出授权码 + PKCE, 会话 generation 和刷新共享任务的边界. 练习: 让三个并发请求同时收到 401, 取消其中一个等待者, 再在 refresh 未完成时登出; 预期证据是只发起一次刷新, 取消不注销或取消共享任务, 其余等待者共享同一结果, 旧任务不能写回新 generation, 且本地密文解密失败时进入登录页.

易错点 / 追问

  • Token 存在内存中没有持久化: 导致 App 被系统杀进程重启后状态丢失.
  • 未处理多进程的 Token 同步: 很多 App 有独立推送或后台进程, 单进程刷新了 Token 没通知其他进程, 导致 401 死循环.
  • 退出登录未通知服务端: 本地清空了 Token, 但 JWT 仍然没有过期, 截获这段 JWT 的黑客仍能继续请求 (应当让服务端将该 Token 暂时加入黑名单).

支付订单与状态机

“支付业务的核心是 ’ 绝不能丢钱, 也绝不能多扣钱’. 网络可能是不可靠的, 但我们的系统必须是可靠的.”

面试策略: 在这部分重点体现你对分布式系统异常情况的理解. 突出幂等性, 重试, 轮询, 超时取消, 以及状态机控制这五个关键武器.

一, 客户端支付全链路流程

一次完整的第三方支付 (如微信 / 支付宝) 交互绝不是客户端拿着金额去请求 SDK 这么简单, 它涉及三方交互:

  1. 客户端发起订单: 客户端请求自己服务器, 传递商品信息.
  2. 服务端生成预支付单: 业务服务器调用微信 / 支付宝生成预支付交易单, 将包含签名等核心信息的 PayInfo 返给客户端.
  3. 客户端拉起收银台: 客户端使用 SDK, 传入 PayInfo 唤起支付 App 完成支付.
  4. 客户端获取同步结果: 支付 SDK 回调给客户端支付结果 (成功/取消/失败).此结果仅供 UI 展示参考, 不能作为最终发货依据.
  5. 服务端接收异步通知: 微信 / 支付宝服务器将真实的支付成功通知发给你的业务服务器.
  6. 客户端轮询 / 长连同步真实结果: 客户端主动拉取或接收服务器推送, 确认支付最终状态, 展示成功页.

二, 订单状态机设计

复杂业务中, 订单绝不能只有 “成功” 和 “失败”.必须用严谨的状态机约束状态流转, 防止非法倒流 (如 “已取消” 的订单被发货).

           [待支付]
          /    |   \
 (超时未付)   (支付)  (用户主动取消)
    /          |        \
[已取消]    [支付中]     [已取消]
               |
      (服务端接收回调成功)
               |
           [已支付/待发货]
               |
           [已发货] → [已完成]
  • 核心原则: 状态只能单向推进, 或根据特定规则流转. 客户端 UI 根据状态机的当前状态来渲染按钮 (如: 待支付展示 “去支付”, 已取消展示 “重新购买”).

三, 网络异常与重试机制 (幂等性)

移动端面临弱网, 断网, 重切等各种网络异常. 当发出 “确认购买” 请求, 但遇到了网络超时, 此时钱扣了吗?

  • 幂等性 (Idempotency): 无论接口被调用多少次, 产生的业务结果应该和调用一次相同.
  • 防重点击机制:
    • 前端 UI 防止连点 (按钮变灰 / Debounce 拦截).
    • 核心防线在服务端: 客户端生成全局唯一的单号或携带防重 Token, 服务端依赖数据库唯一索引或 Redis 分布式锁拦截重复请求. 客户端重试时必须带上同样的 ID.

四, 支付结果确认与轮询机制

如第一节所述, 客户端 SDK 返回成功, 不代表真的成功了 (可能是网络劫持伪造的响应).

  • 确认机制: 支付完成后, 客户端展示 “支付确认中” 的加载框.
  • 轮询 (Polling):
    • 客户端定时向业务服务器发起请求查询订单真实状态 (比如: 延时 1s, 2s, 4s 递增查询, 最多查询 5 次).
  • 兜底策略: 如果轮询一直未确认, 不能告诉用户 “支付失败”, 应该提示 “结果确认中, 请稍后查看订单列表”.服务端会在后续收到异步通知时更正订单状态.

五, 超时取消与库存回退

  • 当订单处于 “待支付” 状态, 业务逻辑往往已经预占了商品的库存.
  • 如果用户迟迟不付, 必须有超时取消机制 (例如 15 分钟未支付自动关闭), 释放占用的库存.
  • 客户端倒计时: 倒计时的计算一定要以服务端返回的时间戳为基准, 切勿使用客户端本地的 System.currentTimeMillis(), 因为用户可以随便修改手机时间.

六, 安全边界与反作弊

  • 金额不可信: 客户端绝不能自己提交 amount=100 给支付 SDK. 金额必须由业务服务端通过商品 ID 和促销规则计算生成, 客户端只拿组装好的签名串.
  • 拦截抓包与篡改: 防止用户抓包拦截服务端的预支付单, 修改成别人的订单号或极小金额. 这里结合 Android 安全与逆向中的签名, 传输安全和风控边界分析.

七, 对账, 补单, 订阅与风控

支付题的高级追问通常不是 “怎么调 SDK”, 而是异常路径如何兜底.

三类 ID 不要混

名称谁生成用途
业务订单号自家服务端业务系统里的订单主键
支付流水号自家支付服务 / 网关一次支付尝试, 可多次重试
三方交易号微信/支付宝/Google Play和外部支付平台对账

客户端展示可以用业务订单号, 但对账和退款必须关联三方交易号与支付流水.

掉单, 补单与对账

  • 掉单: 用户已扣款, 业务服务端没有及时收到或处理回调.
  • 补单: 客户端进入订单页或服务端定时任务主动向支付平台查询真实交易状态, 补齐本地订单.
  • 对账: 每天按支付平台账单和本地流水比对, 发现长时间不一致的订单, 进入人工 / 自动处理流程.

Google Play Billing / 订阅

如果面试涉及海外或会员订阅, 要补充:

  • 客户端拿到 purchase token 后必须发给服务端校验, 不能只信本地回调.
  • 消耗型商品要 consume, 订阅要处理续费, 取消, 宽限期, 暂停和退款.
  • 服务端保存 token, 商品 ID, 订单号和用户关系, 并处理重复通知.

支付风控

支付风控不是客户端单点判断, 而是服务端综合评估设备, 账号, IP, 行为, 金额, 频率, 历史风险等因素判断. 客户端可提供设备风险信号, 但不能在本地决定 “是否可信”.

异常状态补全

真实状态机还应覆盖: 支付中超时, 已支付待确认, 已退款, 部分退款, 已关闭, 重复回调, 风控拦截, 人工审核中. 面试时可以说 “主链路简单, 复杂度都在异常状态和补偿任务”.

迁移推演示例: 以 PAYING 为源态: 收到验签通过的成功回调则推进 PENDING_CONFIRMATION -> PAID(触发条件: 渠道成功事实幂等消费); 超过支付时限且轮询 / 对账无果则转 CLOSED(触发条件: 服务端权威超时, 释放预占库存); 收到退款 / 部分退款通知则进入 REFUNDING -> REFUNDED / PARTIALLY_REFUNDED(触发条件: 渠道退款回调, 且累计退款不超过已支付金额). “人工审核中” 会挂起流转, 复核后按上述规则收敛.


八, 服务端是支付信任边界

客户端传入的金额, 商品, 优惠, 支付结果和本地时间都不可信. 服务端应根据受信商品/订单数据重新计算金额, 生成不可混淆的业务订单号和支付请求, 验证支付渠道签名/通知, 并以幂等事务推进状态机. 客户端 SDK 回调只用于改善 UI, 不能直接把订单标记为成功.

关键控制:

  • 创建, 支付, 回调, 查询, 退款分别使用明确 idempotency key 和状态迁移条件.
  • 验证回调签名, 商户 / 应用标识, 金额, 币种, 订单号和通知唯一 ID.
  • 重复 / 乱序通知安全幂等; 非法倒流拒绝并记录审计.
  • 对账任务以渠道账单 / 服务端流水补偿, 客户端不承担最终对账.
  • 本地倒计时只做展示, 超时和库存释放由服务端权威时间决定.

可执行的状态迁移规则

本段以枚举 + 条件更新 (转移表) 表达状态机, 客户端也可用状态模式 (状态类 + reduce) 表达, 实现视角见设计模式与 Android 源码应用. 以下是状态机伪代码, 用于说明服务端条件更新, 不代表某个支付渠道的接口:

onVerifiedChannelFact(fact):  # 回调,主动查询,对账先验证为渠道事实
  verify signature when supplied; verify merchant/app id, transaction id, amount, currency
  payment = findByChannelTransactionId(fact.transactionId)
  if payment missing: record UNKNOWN_TRANSACTION; return without fulfillment
  transaction:
    insert processed_channel_fact(fact.source, fact.uniqueId) on conflict do nothing
    if fact already processed: return current order
    update order
      set status = PAID, paid_at = server_now
      where id = payment.order_id and status in (PAYING, PENDING_CONFIRMATION)
    if rowCount == 1: enqueue fulfillment outbox event
    else: record illegal/late transition for reconciliation

服务端验证的渠道事实才是支付最终真相: 验签回调, 主动查询和对账差异都走同一幂等状态机与 outbox. 验签必须使用渠道提供材料, 并校验商户/应用标识, 订单号, 金额, 币种和唯一事实 ID;“验签通过” 不等于可更新任意订单. 回调早于本地流水提交时隔离并重试/查询; 未知交易号只审计和人工/受控补偿, 绝不直接发货. 退款通知可早于本地退款任务或乱序到达, 应按原交易和累计退款约束进入 REFUNDING/REFUNDED, 不能倒流为支付成功.

CREATED -> PAYING -> PENDING_CONFIRMATION -> PAID -> FULFILLING -> FULFILLED
   |          |              |                |                 |
   +-> CLOSED +-> CLOSED     +-> RECONCILING  +-> REFUNDING -> REFUNDED/PARTIALLY_REFUNDED
                         \-> RISK_REVIEW -----> CLOSED or PAYING

CLOSED 后收到成功通知不是简单丢弃: 冻结自动发货, 进入 RECONCILING, 查询渠道真相并按退款 / 人工处理规则收敛. 退款也应引用原支付流水, 并以累计退款金额不超过已支付金额作为服务端不变量.

RISK_REVIEW(风控拦截态) 是等待人工 / 风控复核的中间态: 订单在 PAYING 或 PENDING_CONFIRMATION 命中风控规则 (设备 / 账号 / 频次 / 金额异常) 时被挂起, 不发货, 不改账. 收敛只有两条路径: 复核放行则回到 PAYING 继续原支付流程, 复核确认风险则进入拒绝终态 (如 CLOSED / REJECTED), 按退款 / 人工补偿处理, 不能从拦截态直接推进到已支付.

失败排查: 用户称 “扣款未到账”

  1. 症状: 客户端支付页未确认, 用户提供渠道交易号.
  2. 证据: 按业务订单号, 支付流水号, 三方交易号查询创建记录, 回调审计, 验签结果和渠道查询结果.
  3. 定位: 区分未收到通知, 验签 / 金额校验拒绝, 事务回滚, 发货消费者失败和客户端仅未同步五类情况.
  4. 修复: 由补单任务或人工审核按受控状态迁移补偿; 不能直接改数据库状态绕过审计和库存 / 权益逻辑.
  5. 验证: 确认订单, 支付流水, 权益 / 库存和渠道账单一致, 并让客户端从服务端重新拉取结果.

资损 / 掉单事故叙事模板

面试可把一次 “用户已扣款但订单未完成” 的事故讲成五段: 症状: 用户已扣款但订单未完成, 出现客诉或对账报警; 证据: 渠道账单与本地流水比对出差异, 还原回调到达时序与幂等键消费记录; 定位: 区分回调丢失未进补单流程, 还是幂等键设计错误 (重试未带同一 ID) 导致重复回调被误吞; 修复: 补单任务补齐状态, 修正幂等键与回调消费, 对账任务按渠道事实补偿; 验证: 线上对账持续跑通, 历史差异订单收敛一致且无新增资损.

学完能做什么与练习

你应能让三种渠道事实来源收敛到同一状态机. 练习: 依次投递 “回调先到, 本地流水后提交”“ 未知交易号 ““重复成功回调”“ 退款先于退款任务 “ 四个事实; 预期证据是只有已匹配且验证通过的订单生成一次 outbox, 未知项留审计, 累计退款不超过支付额.

高频面试题

Q1: 支付完 SDK 告诉客户端成功了, 此时可以立刻更新本地状态为 “已支付” 并给用户发货吗? 绝对不可以. 客户端 SDK 结果只用于改善 UI 和触发查询. 发货凭证是服务端验证后的渠道事实: 验签异步回调, 服务端主动查询或渠道对账均可成为事实来源, 且必须经同一幂等状态机和 outbox 推进.

Q2: 弱网下用户点击 “立即支付” 由于没有响应, 狂点了三下, 怎么保证不产生三个订单?

  1. 客户端防重: 按钮点击后 disable, 通过 RxBinding 或协程防抖.
  2. 状态机控制: 本地记录正在请求中, 不响应二次点击.
  3. 服务端唯一防重: 客户端生成或服务端提前下发的 UUID 作为本次交易的凭据 (幂等 Key), 服务端收到多次相同 Key 的请求时, 只处理一次, 其余的直接返回旧订单信息.

Q3: 如果支付完成后回到 App, 网络断了, 无法轮询服务端拿到结果, 应该怎么处理? 展示 “订单处理中” 或 “网络异常, 稍后请在订单列表查看”.绝不能显示 “支付失败”(万一钱扣了会引发严重客诉), 也绝不能显示 “支付成功”(万一真没成功则产生资损).等待网络恢复后, 用户进入订单列表时再次向服务器同步最新状态.

Q4: 什么是掉单? 怎么补? 掉单是用户侧或三方平台显示已支付, 但业务服务端没有及时把订单推进到已支付. 补单通常由客户端重进订单页触发查询, 服务端定时任务扫 “支付中 / 待确认” 订单并调用三方查询接口, 以及每日对账发现差异后修正.

Q5: Google Play Billing 的 purchase token 能不能只在客户端校验? 不能. 客户端环境不可信, purchase token 必须发给服务端调用 Google Play Developer API 校验, 并在服务端维护商品, 用户, 订单和 token 状态. 订阅还要处理续费, 取消, 退款, 宽限期和重复通知.

易错点 / 追问

  • 倒计时依赖本地时间: 利用修改手机系统时间可以无限延长支付时间或卡出倒计时负数 bug.
  • 本地订单状态与服务端不一致: 客户端由于进程被杀等原因错过状态流转, 重入时一定要从服务器重新拉取当前最新状态机节点.
  • 支付 SDK 冲突: 集成多方支付 SDK 导致依赖库冲突 (通常通过 exclude 或使用精简版 SDK 解决).

推送 / 长连接 / 保活

实时系统只能在网络, 功耗, 后台限制和厂商策略允许的范围内提高送达概率, 不能承诺后台永远在线或消息必达. 可靠性来自 sequence/ACK/去重/补拉和服务端状态, 不是单靠心跳或 “拉活”.

一, 通道定位

通道适用主要限制
轮询 / 长轮询低频状态, 兼容方案延迟, 连接和功耗成本
WebSocket/TCP前台低延迟双向通信网络切换, NAT, 心跳, 后台冻结
系统 / 厂商推送后台触达和唤醒用户不保证实时, 顺序, 完整或必达
补拉 API恢复完整状态需要游标, 分页, 幂等和限流

常见组合是 “前台长连接 + 后台系统推送 + 回前台按游标补拉”. 推送 payload 只作为提示, 业务真相仍从受鉴权的服务端同步.

二, 消息可靠性协议

建议每条消息至少包含:

conversation/stream id
sequence or cursor
message id / idempotency key
server timestamp
schema version
payload or payload reference

客户端处理:

  1. 持久化已确认 cursor 和去重集合.
  2. 收到消息先校验身份, 版本和顺序.
  3. 重复 messageId 幂等忽略; 发现 sequence 缺口立即补拉.
  4. 先落库再驱动 UI; ACK 的时机要区分 “已收到, 已持久化, 业务已读”.
  5. 重连后携带最后确认 cursor, 服务端重放缺失区间.
  6. 超出服务端保留窗口时获取状态快照, 不能无限依赖增量日志.

TCP/WebSocket 连接可靠不等于业务消息可靠. 进程死亡, 数据库失败, ACK 丢失和多设备并发都需要业务层协议处理.

三, 心跳与重连

心跳间隔应依据 NAT 超时, 前后台, 网络类型, 服务端成本和功耗实验决定. 重连采用有上限的指数退避和 jitter, 并在网络恢复, 回前台, 用户主动刷新等事件触发.

sealed interface SocketState {
    data object Disconnected : SocketState
    data object Connecting : SocketState
    data object Connected : SocketState
    data class Backoff(val retryAtElapsedRealtimeMs: Long) : SocketState
}

使用单调时钟计算本地退避, 避免用户修改系统墙上时钟时间 (wall-clock time). 连续鉴权失败, 协议版本不兼容或账号撤销应停止自动重连并进入明确状态.

四, 推送 Token 生命周期

推送 token 可能刷新, 失效或因卸载/清数据/恢复备份变化. 客户端应:

  • token 变化后与当前账号, 应用安装实例和环境重新绑定.
  • 登出或账号切换时解除旧绑定, 防止消息发给错误用户.
  • 服务端根据无效 token 回执清理注册关系.
  • 多设备分别维护 endpoint, 不把账号当成唯一 token.
  • payload 最小化, 锁屏敏感内容由本地策略决定是否展示.

FCM 与各厂商通道的优先级, 权限, 配额和回执能力不同, 应按目标市场和当前官方文档维护矩阵.

差异样例 (面试话术素材, 具体以各厂商最新文档为准): 通道: FCM 依赖 Google Play Services 系统级长连, 华为走 HMS Push 通道, 小米走 MiPush 通道, 三者相互独立不能互投; token 机制: FCM 由 FirebaseMessaging 回调下发并监听刷新, 华为 / 小米 token 由各自推送 SDK 注册获取, 都要上报服务端绑定并按刷新重绑; 厂商进程存活策略: 厂商通道由系统级常驻进程转发消息, 宿主无需双进程守护等拉活手段, 但各厂商对自启动 / 后台白名单策略不同, 通道送达不等于 App 进程存活.

五, Android 后台与 Doze 边界

Doze, App Standby, 后台执行限制和厂商电源策略会延迟网络, 任务和闹钟. WorkManager 适合可延迟的持久工作, 不是实时长连接保活器; 前台服务只用于用户可感知且符合类型 / 权限要求的持续任务.

具体边界: targetSdk 34+ 要求前台服务必须声明类型并运行时申请对应权限, 长连接场景常用 dataSync 或 remoteMessaging, 分别对应 FOREGROUND_SERVICE_DATA_SYNC / FOREGROUND_SERVICE_REMOTE_MESSAGING; 未声明类型启动会抛 MissingForegroundServiceTypeException. 但 Doze 下系统仍限制后台网络与消息实时性, 前台服务不豁免全部限制.

双进程守护, 1 像素 Activity, 滥用闹钟等历史 “黑科技” 不应作为现代主方案: 它们不可靠, 增加功耗和合规风险, 还可能违反商店或厂商政策.

六, 观测与排障

至少按应用版本, OS, 厂商, 网络和通道拆分:

  • 注册 / token 更新成功率与无效 token 比例.
  • 连接成功率, 握手时延, 在线时长, 重连原因和退避轮次.
  • 服务端发送, 通道接收, 设备到达, 落库, 展示, 点击各阶段漏斗.
  • sequence 缺口, 重复率, 补拉成功率和最大恢复时间.
  • 后台 / 前台, Doze, 网络切换和进程死亡后的恢复率.
  • 心跳和重连带来的电量, 流量和服务端连接成本.

“消息到达率” 必须定义分母, 窗口, 回执含义和超时, 不同通道的回执不能直接等同于用户已看到.

ACK 时序与断线恢复状态机

服务端 -> 客户端: message(seq=42, id=m42)
客户端: seq=42 若连续,事务写消息,去重记录与连续 cursor=42
客户端 -> 服务端: ACK(kind=PERSISTED, cursor=42)
服务端: 记录该设备已持久化至 42,可回收其重放窗口
客户端: 用户阅读后,可选发 READ(cursor=42);READ 不替代 PERSISTED ACK

Disconnected -> Connecting -> Authenticating -> Syncing(cursor) -> Online
      ^              |              |             |               |
      |              +-> Backoff <--+             +-> Backoff <----+
      +------------------ terminal auth/revoked -> LoggedOut

cursor 只能推进到最高连续已持久化 sequence. 若先收到 44 而连续 cursor 为 42, 事务落库消息和去重记录但 cursor 仍为 42, 同时把 44 放入乱序缓存并补拉 43; 43 到达后同一事务将 cursor 推到 44. 可选 SACK 表示已持久化的非连续范围, 用于减少重发, 但不能替代连续 cursor. ACK 只能在上述事务提交后发送; 鉴权失败, 账号被踢或协议不兼容是终止条件.

NAT 与到达率排障实验

心跳参数不能凭经验固定.实验方案, 不声称已有测量结果: 使用受控测试账号和静默连接, 分别制造 NAT 空闲回收, Doze, Wi-Fi/蜂窝切换及服务端主动断开; 服务端记录连接 ID, 最后收发, 主动 close 原因和探测超时, 客户端记录单调时间, 网络回调, 前后台与 pong. 仅 “长时间无流量后服务端探测失败” 才支持 NAT 回收假设; Doze 和网络切换要按各自证据归因. 按运营商/网络/前后台分桶比较恢复时延, 掉线率, 耗电流量和连接成本, 再灰度调整与回退.

到达率排障从端到端漏斗开始: 服务端入队成功 -> 推送通道接受 -> 设备回执 / 长连持久化 ACK -> 本地展示. 先固定事件 ID 与时间窗口, 再按 app 版本, 厂商, 权限, Doze, 网络和 token 状态分组. 没有设备 ACK 的 “渠道接受成功” 只能说明上游接受, 不能称为用户到达.

到达率下降排障叙事模板 (面试可套用): 症状: 某版本 / 某厂商机型到达率明显下滑; 证据: 端到端漏斗各段 (服务端入队成功 -> 通道接受 -> 设备回执 / 长连 ACK -> 本地展示) 看衰减发生在哪段; 定位: 无 ACK 且服务端探测失败指向 NAT 回收或心跳参数失效, 单段下降常是厂商通道或 token 失效, 后台段下降则归因 Doze / 后台限制; 修复: 调整心跳与重连参数, 补厂商通道降级或补拉, 灰度放量; 验证: 按 app 版本 / 厂商 / 网络分桶对比恢复后的到达率与在线时长.

学完能做什么与练习

你应能实现连续 cursor 而不是 “最大已见 seq”.练习: 按 42, 44, 43 的顺序注入消息并模拟 43 补拉失败后重启; 预期证据是首次 ACK 不超过 42, 消息/去重/cursor 事务一致, 恢复后重放 43, 44 不产生重复 UI.

高频面试题

Q1: IM 为什么不能只靠推送?
推送用于后台触达, 不保证实时, 顺序和完整. 前台通常用长连接降低延迟, 但最终仍靠 sequence, ACK, 去重, 持久化和补拉保证业务一致性.

Q2: 重连怎么避免雪崩?
指数退避 + jitter + 最大次数 / 时限, 按网络恢复分批唤醒, 服务端下发 retry-after, 鉴权和协议错误不盲目重试.

Q3: 如何回答保活?
先声明系统与厂商限制, 再给 “系统推送 + 合规前台能力 + 可延迟任务 + 状态补拉” 的组合, 并用功耗和送达指标验证; 不承诺永久在线.

版本与参考资料

  • 最后核验: 2026-08-10.
  • Android 后台限制, 前台服务类型和推送产品能力会持续变化; 实施时按目标 OS, targetSdk, 地区和厂商官方文档核验.

埋点与数据采集 SDK ☆

埋点 SDK 的价值不是 “多采集”, 而是 “准, 稳, 少打扰, 可合规”. 你有设备指纹 / 风控 SDK 背景, 面试时可以把采集准确性, 隐私边界, 离线队列和宿主性能控制讲成工程亮点.

一, 埋点体系: 手动埋点与自动埋点

埋点用于理解用户行为, 业务转化和产品质量. 常见分为手动埋点, 自动埋点和可视化埋点.

类型原理优点缺点
手动埋点业务代码显式调用 SDK 上报事件语义准确, 参数可控研发成本高, 容易漏埋 / 错埋.
自动埋点通过生命周期, View 点击, 页面曝光自动采集接入成本低, 覆盖广业务语义弱, 去重和误采集难.
可视化埋点后台圈选控件, 客户端匹配路径非研发可配置控件路径易变, 复杂列表 / 动态 UI 难稳定.
代码生成 / 编译期埋点AOP, Transform, 字节码插桩侵入低, 一致性好构建复杂度和兼容性成本高.

成熟方案通常是 “手动埋点保证核心业务, 自动埋点补足基础行为, 服务端配置控制采样和开关”.SDK 要提供统一事件模型, 例如 eventName, timestamp, sessionId, pageName, properties, device/app context.

{
  "event": "product_click",
  "time": 1710000000000,
  "page": "HomePage",
  "session_id": "s_abc",
  "properties": {
    "product_id": "masked-id",
    "position": 3
  }
}

二, 曝光与点击采集

点击采集相对直观, 但曝光采集更容易出错. 曝光通常要求 “元素进入可见区域 + 可见比例达到阈值 + 停留时间达到阈值 + 去重策略满足”.

场景采集要点常见坑
Button 点击绑定 View ID/业务 ID/页面上下文只采 View 文案会受多语言影响.
RecyclerView 曝光监听滚动和可见 item, 结合 adapter 数据 ID复用导致位置变化, 要用业务主键去重.
Compose 点击 / 曝光Modifier 或业务组件封装声明式重组会导致重复注册.
Fragment 页面曝光生命周期 + 可见性 + ViewPager 状态onResume 不等于用户可见.

自动点击埋点可通过 View.OnClickListener 包装, Window callback, 字节码插桩等方式实现. 工程上要尽量避免破坏宿主原有监听器, 不要在主线程做复杂序列化, 并允许业务对敏感控件关闭自动采集.

三, Crash 前日志与上下文采集

Crash 前日志用于回答 “崩溃前用户做了什么, 页面状态是什么, 关键接口是否失败”.它不应无限制采集, 而应维护一个轻量环形缓冲区.

  1. 记录最近 N 条关键事件: 页面进入 / 退出, 点击, 网络错误, 业务状态变更, SDK 状态.
  2. Crash 发生时将缓冲区快照随崩溃报告上传或落盘等待下次上传.
  3. 日志字段脱敏, 避免 token, 手机号, 身份证, 精确定位等敏感数据进入崩溃上下文.
  4. 控制大小, 避免 crash report 过大影响上传成功率.
RingBuffer(size=100)
  -> page_view: Home
  -> click: PayButton
  -> api_error: /order/create 500
  -> sdk_state: queue_size=42
  -> crash: attach last 100 breadcrumbs

这种设计对排查线上问题很有价值, 但要明确它是诊断上下文, 不是完整行为录像.

四, 批量上传, 采样与本地队列

数据采集 SDK 不能每条事件都立即上报, 否则会增加网络流量, 电量和宿主性能开销. 常见策略是本地入队, 批量上传, 失败重试, 采样控制.

策略说明目的
批量上传满 N 条, 满 T 秒, App 退后台时触发降低请求数和耗电.
采样按用户, 会话, 事件或错误等级采样控制成本, 保留统计代表性.
重试退避失败后指数退避, 限制最大次数避免弱网下打爆网络.
优先级队列Crash / 关键转化高优, 普通点击低优保证关键事件及时性.
压缩gzip/zstd 等压缩批量 payload降低流量, 但要控制 CPU 成本.

本地队列通常使用内存队列 + 持久化存储. 写入要异步, 并设置容量上限; 超过上限时按优先级丢弃或采样, 不能无限增长. 进程退出, 断网, 弱网, 服务端限流都要可恢复.

批量参数选型: 满 N 条或满 T 秒触发, N 与 T 是 “容量 / 实时性” 和 “延迟 / 资源” 的权衡: N 太小请求数增多, 太大延迟变明显; T 同理. 示意 50 条 / 10 秒可作为起点, 具体按业务事件量与实时性要求标定, 不写成通用标准.

五, 离线缓存与可靠性

离线缓存解决 “用户断网或服务端不可用时事件不丢失”.但可靠性不是越强越好, 因为无限保留会带来隐私, 磁盘和上传风暴问题.

  1. 容量上限: 按条数, 字节数, 天数三重限制.
  2. 过期清理: 超过有效期的行为数据直接删除.
  3. 分片存储: 避免单个文件过大导致读写失败.
  4. 幂等标识: 每批事件带 batchId/eventId, 服务端去重.
  5. 启动削峰: App 启动后延迟上传, 避免和冷启动抢资源.
  6. 网络约束: 按 Wi-Fi/蜂窝, 前后台, 低电量模式调整上传策略.

面试可以强调: 采集 SDK 的 “可靠” 是有边界的可靠, 要在数据完整性, 用户体验, 隐私合规和资源成本之间平衡.

六, 隐私, 合规与最小化采集

隐私是数据采集 SDK 的红线. 设计时要把 “采什么, 为什么采, 保存多久, 给谁用, 如何撤回” 说清楚.

合规点工程做法
用户知情同意隐私协议同意前不启动非必要采集; 提供开关和撤回路径.
最小化采集只采业务需要字段, 敏感字段默认不采或脱敏.
数据脱敏手机号, 邮箱, 设备标识, 定位等做哈希, 截断或分级授权.
存储期限本地缓存和服务端数据设置过期清理.
权限隔离SDK 不主动申请无关权限, 需要宿主显式授权.
跨境 / 共享按公司和地区政策做数据分区, 审计和用途限制.

对于设备标识, IP, 定位, 剪贴板, 通讯录等敏感能力, 必须按法规和平台政策处理. SDK 不能绕过宿主隐私弹窗自行采集, 也不能把业务传入的敏感参数原样写入日志.

埋点信号与风控 SDK 复用衔接

埋点采集的设备型号, 系统版本, 网络状态等基础信号与风控 SDK 的设备指纹信号有大量交集, 可共用一份采集结果与缓存, 避免同一信号被采集两次. 但两者隐私边界不同: 埋点数据走行为分析的用途声明, 风控指纹走风险评估的用途声明, 各自的用途, 留存与共享授权要分开声明, 不能把埋点当风控信号用, 也不能把指纹混入行为报表.

七, 宿主性能影响控制

宿主性能是埋点 SDK 能否长期接入的关键. 优秀 SDK 要 “默认轻量, 可观测, 可关闭”.

  • 主线程控制: 事件组装, 序列化, 加密, 压缩, 磁盘写入, 网络请求都应放到后台线程.
  • 内存控制: 队列有上限, 大字段截断, 避免持有 Activity/View/Context 强引用.
  • CPU 控制: 批量压缩和加密要限频; 自动曝光计算要节流.
  • 网络控制: 批量, 采样, 退避, 前后台策略, 避免高频小包.
  • 启动控制: 延迟初始化非关键模块, 不要阻塞 Application onCreate.
  • 可观测性: SDK 自身要上报队列长度, 丢弃数, 上传耗时, 失败原因和资源占用.

对宿主来说, SDK 应提供远程开关, 采样率, 黑名单, 最大缓存, 上传周期等配置, 当线上异常时可以快速降级.

曝光重复 / 拖垮宿主启动故障叙事

面试可讲成五段: 症状: 某版本后冷启动变慢, 曝光上报量级异常翻倍; 证据: 分事件对比曝光计数与 UV 找出同 item 多版本重复曝光, 用启动 profiling 定位到曝光计算 / 序列化占用主线程; 定位: 列表只按 position 去重或可见性判断缺失导致离屏 item 也曝光, 启动时全量曝光入队阻塞主线程; 修复: 改用业务 ID + 版本去重并加可见比例 / 停留时长, 曝光计算移到后台线程并节流, 非关键模块延迟初始化; 验证: 对比修复前后曝光去重率与冷启动耗时, 回归滚动 / 旋转 / 列表复用场景.


八, Schema, 背压与数据质量

  • 每个事件包含稳定 event_name, schema_version, event_id, 业务时间与采集时间; schema 演进遵循兼容策略, 不在同名字段中悄悄改变类型 / 语义.
  • 离线队列按字节, 条数和时长设上限. 过载时按事件等级采样 / 丢弃并上报 drop reason, 不能无限缓存.
  • 上传端处理 429/5xx 的 retry-after, 指数退避和 jitter; 进程恢复后削峰, 避免启动上传风暴.
  • 数据质量监控覆盖缺失率, 枚举越界, 重复率, 时钟偏差, 版本分布, 漏斗突变和客户端 / 服务端对账.
  • 用户撤回同意或提出删除请求时, 要停止后续采集, 清理本地队列, 向服务端传递删除 / 隔离流程并保留合规审计.
  • 采样必须稳定且可解释: 用户级采样避免同一用户时有时无, 关键错误保留独立策略, 报表按采样概率校正.

SDK 边界与曝光判定示例

业务 API -> EventValidator -> 内存有界队列 -> 持久化队列 -> UploadScheduler -> HTTP
                    |                 |                  |
                 schema 拒绝        丢弃原因            429/网络退避

伪代码: 曝光不是 “绑定 View 就上报”.对每个业务 item ID, 只有页面处于前台, 可见面积比例达到产品定义阈值并连续停留达到定义时长后才产生一次曝光; 滚出区域, 页面暂停或 item 数据版本变化时取消本轮候选. 阈值, 停留时长与去重窗口应由产品口径定义, 并在真实设备滚动, 旋转和列表复用场景验证, 不能把示例数值写成通用标准.

onFrame(item):
  now = elapsedRealtime()
  key = item.businessId + ":" + item.version
  if !pageVisible || visibleRatio(item) < configuredRatio:
    candidates.remove(key); return
  candidate = candidates.getOrPut(key) { Candidate(startElapsedMs = now) }
  if now - candidate.startElapsedMs >= configuredDwell && !dedupe.contains(key):
    emitExposure(item); dedupe.mark(key); candidates.remove(key)

稳定采样固定为: bytes = UTF-8(saltVersion + "\u0000" + subjectId), bucket = unsignedBigEndian64(SHA-256(bytes)[0..7]) mod 10_000, 当 bucket < rateBasisPoints 命中. 算法, UTF-8 编码, 无符号取模和 saltVersion 都是协议的一部分; salt 仅在策略变更时版本化. 匿名 ID 登录合并时要定义归因规则. 该算法只解决稳定抽样, 不替代隐私同意, 身份隔离或报表校正.

三个异常边界

  • 磁盘满 / 写入失败: 停止低优事件落盘, 保留受限内存缓冲或直接丢弃并计数; 不能阻塞 UI 线程无限重试. 恢复空间后按限速上传, 并上报明确的 disk_full 丢弃原因.
  • HTTP 429: 尊重 Retry-After; 无该头时采用有上限指数退避和 jitter. 不要把 429 当网络故障立即重试, 否则会加剧限流.
  • schema 冲突: 同名字段类型或语义改变时拒绝 / 隔离该事件并记录 SDK 版本与 schema 版本. 通过新增字段或提升版本演进, 不能客户端静默把字符串转换成数字来 “修复” 数据.

高频面试题

Q1: 手动埋点和自动埋点怎么选? 核心业务转化用手动埋点, 因为语义准确, 参数可控; 页面浏览, 基础点击, 曝光可用自动埋点补充覆盖. 大型项目通常混合使用, 并通过配置中心控制开关和采样.

Q2: 曝光埋点怎么避免重复和误报? 用可见比例, 停留时长, 页面可见状态和业务 ID 去重. RecyclerView 不能只按 position 去重, 因为 item 会复用和移动; Fragment/ViewPager 也不能只看 onResume, 要结合真实可见性.

Q3: 埋点 SDK 如何保证离线不丢数据? 事件先进入内存队列并异步持久化, 网络可用时批量上传; 失败使用退避重试, 每批带幂等 ID. 与此同时设置容量, 天数和优先级上限, 避免无限缓存影响隐私和磁盘.

Q4: Crash 前日志怎么设计? 维护轻量环形缓冲区, 记录最近页面, 点击, 接口错误和 SDK 状态; Crash 时附带快照. 日志要脱敏, 限长, 限量, 不能把它做成完整行为录屏或敏感数据仓库.

Q5: 如何控制埋点 SDK 对宿主性能的影响? 主线程只做轻量入队, 序列化/压缩/加密/IO/网络放后台; 队列和缓存有上限; 曝光计算节流; 上传批量化, 采样和退避; SDK 自身提供监控和远程降级开关.

易错点 / 追问

  • 不要为了 “数据完整” 无限缓存, 这会带来隐私, 磁盘和上传风暴风险.
  • 不要在隐私协议同意前启动非必要采集, 也不要默认采集敏感字段.
  • 不要在点击回调里同步写数据库或发网络请求, 会直接伤害宿主性能.
  • 追问 “自动埋点为什么不准”:控件复用, 动态 UI, 页面可见性和业务语义缺失都会导致误差.
  • 追问 “采样会不会影响分析”:会, 所以要按用户 / 会话稳定采样, 并对关键错误或转化事件保留更高优先级.

学完能做什么与练习

你应能实现有界, 可解释的采集链路. 练习: 对同一 item 连续渲染, 不可见后再可见, 并在磁盘满和 429 下上报; 预期证据是每个版本仅一次曝光, 候选删除后不 emit, 同一 subjectId/saltVersion 稳定落入同一 bucket, 且 drop/retry 原因可观测.

主流第三方库

中级面试常问 “X 库的原理”, 尤其网络层和图片库. 你做 SDK 偏底层, 这些上层库要补实战理解.

一, OkHttp

Android 网络底座 (Retrofit 默认基于它).

  • 版本现状 (核验 2026-08): 4.12.0 是 4.x 最后一版; 5.0.0 已于 2025-07 发布稳定版, 当前 5.x 为主流稳定线. 4.x→5.x 主要变化: JVM/Android 分构件, 原生 Happy Eyeballs (RFC 8305), Kotlin API 更简洁, 支持 GraalVM; 升级需结合 targetSdk 与包体积评估.

  • 核心: 拦截器链 (责任链模式). 请求依次经过拦截器:

    1. 应用拦截器 (开发者添加, 如加 header, log).
    2. RetryAndFollowUp (重试, 重定向).
    3. Bridge (补全 header, gzip).
    4. Cache (缓存).
    5. Connect (建立连接).
    6. 网络拦截器 (开发者添加).
    7. CallServer (真正发送收发).
  • 连接池 (ConnectionPool): 复用 TCP/HTTP 连接, 常见默认配置是最多空闲连接约 5 个, 保活约 5 分钟, 但这是 OkHttp 版本/构造参数相关的可配置默认值. 复用可减少 TCP/TLS 握手; HTTP/2 下同一连接还能多路复用多个 stream.

  • 缓存: 基于 HTTP 缓存语义 (Cache-Control, ETag), 磁盘 LRU.

  • 分发器 (Dispatcher): 异步请求用线程池执行, 控制最大并发和单 host 并发; 常见默认值是 maxRequests=64, maxRequestsPerHost=5, 可通过属性调整. WebSocket 等特殊连接是否计入限制要看版本文档.

OkHttp 请求链路与调优

机制解决什么问题面试追问
Dispatcher控制异步 Call 何时进入执行队列, 避免无限并发打爆线程/服务端并发不是越大越好, 要结合服务端限流, HTTP/2, 业务优先级调整.
ConnectionPool复用连接, 减少 TCP/TLS 握手和慢启动空闲连接会占 fd/内存; 移动端网络切换时要能重建连接.
HTTP/2 multiplexing一个 TCP 连接上并发多个 stream, 减少同域名多连接开销仍受服务端支持, 流控, 丢包影响; TCP 层队头阻塞没有完全消失.
Cache利用 HTTP 语义减少网络请求只对可缓存响应生效; 动态接口要和服务端协商 Cache-Control/ETag.
val client = OkHttpClient.Builder()
    .dispatcher(Dispatcher().apply {
        maxRequests = 32
        maxRequestsPerHost = 8
    })
    .connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
    .build()

上面数字不是 “最佳实践模板”, 只是说明默认项可配置. 真实项目要根据接口耗时, 弱网, 服务端限流, HTTP/2 支持和页面优先级做压测.

取消, 超时与重试

Dispatcher 管理异步 Call 的 ready queue 和 running queue. 请求未达到全局及单 host 并发限制时进入执行, 否则继续排队; 同步 execute() 由调用线程执行, 但仍会被 Dispatcher 统计. 调大并发只能减少排队, 不能消除服务端限流, 带宽竞争和线程调度成本.

Retrofit 的 suspend 调用被取消时, 取消应传播到底层 OkHttp Call.cancel(). 但取消只能终止客户端仍可控制的等待或 IO, 服务端可能已经收到并执行请求. 因此扣款, 下单和提交表单不能把 “客户端取消” 当作 “服务端回滚”.

超时控制范围常见误区
connect timeout建立 TCP 或代理连接不包含整个请求生命周期
read timeout一次读取等待不是响应总耗时上限
write timeout一次写入等待大文件上传还要考虑整体 deadline
call timeout从 Call 开始到结束的总体预算包含 DNS, 排队, 重试, 请求和响应等阶段

重试前先判断三件事: 操作是否幂等, 请求体能否重新发送, 错误是否瞬时可恢复. GET 等安全读取通常更适合有限重试; POST 下单, 支付和上传必须使用幂等键或服务端去重. 重试要设置次数上限, 指数退避和抖动, 避免弱网时形成重试风暴.

缓存与网络切换

HTTP 缓存由 Cache-Control, ETag, Last-Modified 等协议语义决定, 解决可缓存响应的复用和重新验证. 业务缓存保存经过建模的数据, 还要处理登录身份, 数据版本, 离线编辑, 数据库事务和失效策略. OkHttp Cache 不能直接替代 Room 或 Repository 的离线数据源.

Wi-Fi 和蜂窝网络切换时, 旧连接可能仍在连接池中但已经不可达. 新请求通常会在连接失败后重新选路和建连; 长请求或长连接则要监听业务层失败并恢复. 不要在每次网络回调时无条件清空连接池和重放所有请求, 这会丢失复用收益并可能造成重复写入.

WebSocket 可靠性

  • ping/pong 用于检测连接活性, 不是业务消息确认. 心跳间隔要结合代理空闲超时, 电量和服务端容量.
  • 断线后使用带抖动的指数退避重连, 在网络恢复或 App 回到前台时允许适度加速, 但要设置上限并避免所有客户端同时重连.
  • 重连成功不代表消息连续. 需要业务序列号, ack, 游标或会话恢复协议判断哪些消息已送达, 哪些需要补拉或重发.
  • 待发送队列必须有容量, 过期和持久化策略. 对不可重复操作使用幂等 ID, 不能简单重放所有未确认帧.
  • 页面退后台, 账号切换和进程重建时要明确连接所有者, 避免多个 WebSocket 并存或旧账号消息串入新会话.

二, Retrofit

把 HTTP API 封装成 Kotlin/Java 接口.

  • 版本现状 (核验 2026-08): 2.x 最新为 2.12.0; 3.0.0 已于 2025-05 发布, 将 OkHttp 升到 4.12 (引入 Kotlin 传递依赖), 与 2.x 保持前向二进制兼容, 新项目以 3.x 为准; 具体以官方仓库当前版本为准.

  • 核心: 动态代理 (Proxy.newProxyInstance). create() 为接口生成代理对象, 调用方法时被 InvocationHandler 拦截.

  • 解析方法上的注解 (@GET/@POST/@Query/@Body) 生成 ServiceMethod/RequestFactory, 构造 OkHttp 的 Request.

  • CallAdapter: 适配 Call, RxJava 或自定义返回包装. Retrofit 原生支持 Kotlin suspend 接口, 但不内置 Kotlin Flow call adapter; 返回 Flow 需要第三方 / 自定义 adapter, 或在 Repository 中用 flow { emit(service.load()) } 等方式建模并处理重复订阅语义.

  • Converter: 序列化转换 (Gson/Moshi/kotlinx.serialization), 负责请求体/响应体和业务对象之间的转换.

  • 协程用法: 接口方法加 suspend 直接返回 data class, 无需 Call.enqueue.

Retrofit 三层扩展点

扩展点负责内容常见坑
注解解析URL, HTTP method, Query/Header/Body 参数动态 URL, 编码, 空参数要按接口契约处理.
ConverterJSON/XML/Proto 与对象互转Kotlin 非空/默认值, 混淆字段名, 泛型类型擦除.
CallAdapter把 Call<T> 适配成 suspend/RxJava/Result 包装错误处理边界要统一: HTTP error, 网络异常, 解析异常不要混在一起.

面试回答结构: 先说 “接口不是实现类, Retrofit 用动态代理拦截方法调用”; 再说 “注解生成请求, Converter 负责数据转换, CallAdapter 负责返回类型”; 最后补 “底层网络仍交给 OkHttp”.

可操作排障片段: 拦截器, 代理与 Compose 图片

// 上下文片段:仅记录脱敏的诊断字段,不能读取或打印 Authorization/body.
class RequestIdInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request().newBuilder()
            .header("X-Request-Id", UUID.randomUUID().toString())
            .build()
        return chain.proceed(request)
    }
}

interface UserApi {
    @GET("users/{id}") suspend fun load(@Path("id") id: String): UserDto
}
// Retrofit.create(UserApi::class.java) 返回动态代理;真正请求在调用 load 时按注解构造.
// 上下文片段:需已引入 Coil Compose;model 和 size 共同影响缓存与解码成本.
// 160 x 160 为像素尺寸;若 UI 以 dp 约束,应由布局约束/密度换算得到实际 px.
AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(url)
        .size(160, 160) // px
        .crossfade(false)
        .build(),
    contentDescription = null
)

列表 OOM 或图片排队时, 先取证而非盲目调大缓存: 确认 ImageView/Compose 目标实际尺寸, 请求数量, 解码尺寸, 内存/磁盘命中, 队列等待时间与取消次数. 常见修复是约束目标尺寸, 让可见项请求随绑定变化取消, 避免把原图或 Context 强引用放入业务缓存, 并按低优预取限额. 调大 Dispatcher 或内存缓存只能改变排队 / 淘汰, 可能恶化峰值内存和网络竞争, 须依据上述指标验证.

Glide 的统一模型是 Active Resources -> Memory Cache -> Resource Disk Cache -> Data Disk Cache -> 数据源获取; 数据源可以是网络, 文件或 ContentProvider, 网络不是 “第三级缓存”.

学完能做什么与练习

你应能按请求链和图片数据路径定位问题. 练习: 让列表复用同一 View 并分别请求同 URL 的两种像素尺寸; 预期证据是缓存 key / 解码尺寸不同, 不出现旧图回填, 且 OOM 排查记录包含尺寸, 命中和队列等待证据.

三, 图片库 (Glide / Coil)

  • Glide 查找链: active resources → memory cache → resource disk cache → data disk cache → source. 网络只是可能的数据源之一, 不是缓存层; 具体内部类和默认策略需绑定 Glide 主版本说明.
  • 生命周期绑定: Glide 官方 API 强调传入 Activity/Fragment 后会随宿主销毁自动清理请求. 实现层面常见解释是注入无 UI 的 RequestManagerFragment/等价生命周期组件监听宿主生命周期; 这是库内部实现, 版本可能演进, 面试要说 “实现层面”.Coil 基于协程 + Lifecycle, 更贴近 Kotlin 项目.
  • Bitmap 复用 (BitmapPool): 复用可复用的 Bitmap 内存, 减少频繁分配和 GC; 硬件位图, 尺寸 / 配置不匹配时不一定能复用.
  • Coil: Kotlin 优先, 协程实现, API 轻量, Compose 场景友好; Glide 生态成熟, 缓存/变换/集成丰富, 老项目更多.
  • 优化点: 指定尺寸 (override) 避免加载过大图, 合适的缓存策略, 占位图.

图片加载机制拆解

机制Glide 侧重点Coil 对照注意点
生命周期Glide.with(activity/fragment) 绑定宿主, 停止/销毁时暂停或清理请求ImageLoader + coroutine/lifecycle, Compose 集成自然不要在长生命周期 context 上加载短生命周期 View.
缓存 key通常由 model, 尺寸, 变换, 选项, 签名等共同决定同样需要区分 data/size/transformation“同 URL” 不代表同一缓存条目, 尺寸和变换会影响命中.
内存缓存ActiveResources + MemoryCachememoryCache内存命中快, 但占用大; 列表页要控制目标尺寸.
磁盘缓存原始数据 / 转换后资源策略可配diskCache磁盘命中省网络但有 IO; 策略按图片来源选择.
BitmapPool复用 Bitmap 内存降低 GCbitmap pool/解码组件实现不同高版本硬件位图不可随意复用/修改.

RecyclerView 中的核心不是 “手动取消所有请求”, 而是每次绑定都给可复用 View 发起新请求或显式 clear. Glide 文档也提示, 如果某个复用 View 不再加载图片, 要 clear() 避免旧请求回调把旧图设置回来.

四, 序列化库

  • Gson: 反射解析, 易用但运行时反射有性能开销, 不感知 Kotlin 默认值 / 空安全 (可能给非空字段塞 null).
  • Moshi: Square 出品, 支持 codegen (编译期生成 adapter, 无反射), 对 Kotlin 友好.
  • kotlinx.serialization: Kotlin 官方, 编译期插件生成, 类型安全, 多平台, 无反射, 新项目推荐. @Serializable 注解.
库机制适合场景面试注意
Gson运行时反射为主老项目, 简单 Java modelKotlin 非空/默认值坑多, 混淆要保留字段/注解.
Moshi反射或 codegen adapterKotlin + Square 生态codegen 性能和类型安全更好, 但要配置 KSP/KAPT.
kotlinx.serialization编译期插件生成 serializerKotlin/多平台/Compose 新项目需要 @Serializable, 与 Retrofit 配合要加 converter.

五, 其他常见库

EventBus

历史原理: EventBus 通过注册订阅者与事件类型建立发布订阅关系. 运行时可用反射发现带注解的订阅方法, 也可使用订阅者索引减少反射查找; ThreadMode 决定回调在发布线程, 主线程或后台线程等上下文执行.

当前状态与限制: 它能降低调用方对接收方的直接依赖, 但也让事件生产者, 消费者和时序分散在全局通道中. 未在合适生命周期取消注册会泄漏 Activity, Fragment 或 View; 错误的线程模式会使 UI 更新越过主线程边界. Event 名称和载荷缺少显式调用图, 也会增加追踪和测试成本. 新项目不应将它作为默认组件通信机制.

现代替代: 页面内状态优先由 ViewModel 持有 StateFlow/LiveData, 一次性事件可在明确所有者和收集生命周期下使用 SharedFlow 或 Channel. Fragment 间返回结果优先使用 Fragment Result API, 导航返回值可由 SavedStateHandle 建模. 跨模块通知应定义显式接口或领域事件契约, 并明确发布者, 消费者, 线程和生命周期.

ButterKnife

历史原理: ButterKnife 在 Java Android 项目中通过注解处理器扫描 @BindView 等注解, 于编译期生成绑定类, 用生成代码代替运行时反射查找. 绑定对象持有 View 引用, 因而 Fragment 的 View 生命周期通常需要在 onDestroyView 等时机调用 unbind().

当前状态与限制: ButterKnife 10.2.3 是最终发布版本, 项目已声明不再积极维护. Kotlin Android Extensions 的 synthetic views 在 Kotlin 1.4.20 标记废弃, 并在 Kotlin 1.8 移除; kotlin-parcelize 是独立插件, 不能把仍可使用 Parcelize 误解为 synthetic views 仍受支持. 两者都不适合作为新项目依赖; 继续使用可能带来构建兼容, 维护和 View 生命周期误用风险.

迁移选择:

  • Java/XML 或 Kotlin/XML 的普通 View 访问: 使用 View Binding; Fragment 在 onDestroyView 清空可空 binding 引用.

  • Java/XML 或 Kotlin/XML 确有 XML 表达式, Observable 字段或双向绑定需求: 评估 Data Binding; 不因简单视图访问引入其额外复杂度.

  • Compose 工具链: 直接由可组合函数和状态建模 UI, 不使用 View Binding, Data Binding 或 synthetic views; 仅在互操作的 XML View 边界按需保留相应绑定方案.

  • 依赖注入:Hilt (主流, 见 Jetpack 架构组件的 Hilt 章节), Koin (纯 Kotlin, 运行时解析, 轻量但无编译期校验). DI 的核心价值不是 “少写 new”, 而是把对象创建, 作用域和替换点显式化, 方便测试和模块解耦.

  • 协程之外的响应式: RxJava (老项目多, 操作符丰富但学习曲线陡), 新项目协程 + Flow 已基本替代.

模式/库解决的问题现代替代/边界
EventBus跨组件发布订阅, 调用方不直接依赖接收方反射 / 索引发现订阅者与线程模式是其历史机制; 容易隐藏数据流, 难追踪生命周期; 新项目优先显式状态流或通信契约.
Hilt编译期 DI, 作用域管理, Android 组件注入适合中大型项目; 需要理解 Component/Scope, 不要滥用单例.
KoinDSL 声明依赖, 上手快运行时解析, 错误可能到运行时才暴露; 适合小项目或快速迭代.
RxJava复杂异步流组合老项目维护常见; 新代码可优先协程 Flow, 但迁移要逐步做.

高频面试题

Q1: OkHttp 的拦截器链是什么设计模式? 顺序? 责任链模式. 顺序: 应用拦截器 → RetryAndFollowUp → Bridge → Cache → Connect → 网络拦截器 → CallServer. 每个拦截器处理后调 chain.proceed 传递.

Q2: 应用拦截器和网络拦截器区别? 应用拦截器在最外层, 只调用一次, 能看到原始请求 (重定向前), 不一定有网络; 网络拦截器在 Connect 之后, 可能多次调用 (重定向 / 重试), 能看到真实网络请求和中间响应.

Q3: Retrofit 的核心原理? 动态代理. create () 用 Proxy 为接口生成代理对象, 调用方法时 InvocationHandler 拦截, 解析注解构造 OkHttp Request, 经原生 suspend 支持或 CallAdapter 适配为 Call/RxJava/自定义类型, Converter 做序列化. Retrofit 不内置 Flow adapter.

Q4: Retrofit 怎么支持协程的? 方法加 suspend 后, Retrofit 内部把 OkHttp Call 的异步回调适配成协程挂起恢复, 常见实现会通过类似 suspendCancellableCoroutine 的方式处理取消, 直接返回结果或 Response, 无需手动 enqueue.

Q5: Glide 如何感知生命周期防止泄漏? Glide.with(activity/fragment) 返回与宿主生命周期绑定的 RequestManager, 宿主停止/销毁时可暂停或清理请求. 实现层面常见解释是注入无 UI 的 RequestManagerFragment/生命周期组件监听回调, 但这是库内部实现细节, 回答时不要说成永久 API 契约.

Q6: Glide 的缓存与数据源链路? 按 Active Resources -> Memory Cache -> Resource Disk Cache -> Data Disk Cache -> 数据源获取 理解; 数据源可以是网络, 文件或 ContentProvider, 不属于缓存层. 尺寸, 变换和请求选项会影响缓存 key.

Q7: Gson 在 Kotlin 里有什么坑? Gson 用反射, 不走构造函数, 可能绕过 Kotlin 的非空检查和默认值, 给非空字段赋 null 导致后续 NPE. 建议用 Moshi 或 kotlinx.serialization.

Q8: OkHttp 的四类超时有什么区别? connect, read 和 write timeout 分别约束建连, 单次读取等待和单次写入等待; call timeout 约束整个 Call 的总体时间, 会覆盖排队, DNS, 建连, 重试和收发等阶段. 具体值应按接口类型和弱网数据确定, 不能所有请求共用一个拍脑袋数字.

Q9: 协程取消后, 服务端操作一定取消吗? 不一定. Retrofit 可以把协程取消传播到 OkHttp Call, 从而终止客户端等待或 IO, 但请求可能已到达服务端. 写操作仍要依赖服务端幂等键, 状态查询或补偿机制确认最终结果.

Q10: HTTP 缓存和业务缓存有什么区别? HTTP 缓存按响应头和验证器复用原始响应; 业务缓存保存领域数据和同步状态, 负责身份隔离, 数据版本, 离线能力和冲突处理. 前者不能替代 Room/Repository, 两者可以分层配合.

Q11: WebSocket 重连后如何避免丢消息和重复消息? 心跳只判断连接是否活跃. 可靠恢复需要业务序列号或游标, ack, 补拉接口, 幂等消息 ID 和有上限的重发队列; 重连使用带抖动的指数退避, 并明确账号和进程生命周期下的单一连接所有者.

Android 安全与逆向 ☆

这一篇是你最大的差异化武器. 设备指纹 / 风控 SDK, 签名, 加固, 抓包对抗, 反调试, 反 Hook 都是你的本行, 而 99% 的应用开发者答不出.

面试策略: 投支付/金融/出海/风控相关团队时, 主动把话题引到这里. 即便普通应用岗, 聊到安全也能瞬间拉开差距. 这是你 “从底层来做应用” 叙事的最强证据.

一, APK 签名机制

签名用于校验 APK 完整性 和 来源可信性 (防篡改, 防二次打包).

方案原理特点API 门槛
v1 (JAR 签名)对已签名条目摘要写入 MANIFEST/META-INF不覆盖全部 ZIP 元数据或未签名条目; 现代安装校验边界取决于设备/签名方案API 1 (所有版本)
v2 (APK 签名块)对整个 APK 做摘要签名, 插入签名块快, 保护整体, 防篡改强API 24+
v3v2 基础上支持密钥轮转 (换签名密钥)可升级密钥API 28+
v4基于 Merkle 树的整包哈希校验 (配合 ADB 增量安装的流式校验)支持流式校验API 30+
  • minSdk 决定可用方案: 例如 minSdk 24 的设备可纯 v2/v3 而无需 v1; minSdk < 24 必须保留 v1 以兼容低版本设备; v4 只服务于增量安装场景, 不影响常规安装兼容.
  • 签名校验流程: 安装时系统校验签名; App 运行时也可自校验签名防二次打包.
  • 面试可讲: v1 的覆盖范围与 v2 的整包签名块不同; 未覆盖条目 / ZIP 元数据问题不等于 “可直接执行注入”, 实际安装与运行仍受 Android 平台校验和包内容约束.

二, 加固与脱壳

  • 加固目的: 防止反编译拿到源码逻辑 (DEX), 保护 so, 防调试.
  • DEX 加固演进 (防御 / 面试视角):
代际核心机制保护目标主要边界
整体加固 (一代)APK 中 DEX 加密, 启动后由壳代码解密并交给自定义 ClassLoader 加载防静态反编译直接看到业务代码运行时必须出现可执行代码形态, 对动态分析防护有限
方法抽取 (二代)方法体被抽离 / 替换为空实现或壳逻辑, 运行时按需还原关键方法降低整包还原收益, 保护核心函数复杂度, 兼容性和性能开销更高, 仍要关注崩溃率
VMP / 指令虚拟化 (三代)把部分 Dalvik/native 指令转换成私有虚拟指令, 由自定义解释器执行提高理解核心算法的成本体积, 性能, 可维护性成本最高, 通常只保护高价值逻辑
  • 脱壳对抗的原则比较: 从防守角度看, 一代主要防静态读取, 二代把攻击面缩到关键方法还原时机, 三代把 “读代码” 变成 “理解私有虚拟机”.面试只讲原理, 成本和风险, 不提供可执行流程.
  • so 加固: 字符串加密, 符号裁剪 / 隐藏, 控制流混淆, 完整性校验, 必要时对极少数核心算法做 native 侧虚拟化. 注意 native 保护会引入兼容性, 性能, 可观测性问题, 上线前要做灰度和崩溃监控.
  • 平台演进: Android 15 (API 35) 起部分新设备采用 16 KB 页大小, 未按 16 KB 对齐的 so 可能加载失败; 含自研 / 加固 so 的方案需做对齐适配并在灰度设备上验证.
  • 你的发言点: 讲你在 SDK 里怎么做 so 保护, 防止采集逻辑被逆向, 这是真实经验.

三, 混淆

  • R8 / ProGuard: 重命名类/方法/字段 (a.a()), 删除无用代码, 内联, 优化. keep 规则保留反射 / JNI 入口.
  • 资源混淆: AndResGuard, 缩短资源路径减体积 + 增加逆向难度.
  • 控制流混淆 / 字符串加密: 打乱执行流, 加密敏感字符串 (API key, URL), 运行时解密.
  • 注意: JNI 注册的 native 方法, 反射调用的类, 序列化字段要 keep, 否则崩溃.

四, 传输攻击面

  • 抓包原理: Charles/Fiddler/mitmproxy 装根证书做中间人, 解密 HTTPS; Wireshark 抓底层包.
  • 代理, 用户安装根证书, 证书替换和客户端运行时篡改均属于传输攻击面; pinning, mTLS 和请求签名的策略, 轮换与回退治理见移动安全防护体系.
  • 这些机制会提高中间人攻击成本, 但不能让用户可控客户端成为可信根; 本章只解释攻击面, 不定义处置策略.

五, 反调试与反 Hook

  • 定位: 反调试 / 反 Hook 不是为了单点阻止分析, 而是为高价值动作提供环境风险信号, 辅助服务端做分级处置 (放行, 二次验证, 降级, 拒绝).
检测方向原理直觉局限与边界防御用法
TracerPid / 调试状态读取进程状态, 判断是否存在调试附加迹象系统版本, 权限, 工具行为会影响可靠性; 单点信号容易误判作为低成本信号, 不单独决定封禁
ptrace 占位让进程进入 “已被跟踪” 状态, 提高再次附加成本兼容性和稳定性风险较高, 不同内核/ROM 表现可能不同仅用于高风险 SDK/核心逻辑, 需灰度验证
时间差 / 断点异常单步调试会放大关键路径耗时设备性能, GC, 调度抖动也会造成耗时波动结合统计阈值和多次采样, 避免一次命中即处置
Frida/Xposed/注入痕迹观察进程模块, 类加载, 堆栈或运行环境异常工具版本和加载方式会变化, 特征库需要维护只输出风险等级, 与完整性/行为风控合并判断
inline hook 完整性对关键 native 函数入口或代码段做完整性校验编译优化, 热补丁, 自更新都可能改变代码布局对少量关键函数做校验, 异常时降级或上报
  • 反 Hook: 关注 “是否有运行时插桩/代理/代码段异常” 的风险信号, 而不是背具体工具特征. 工具特征随版本变化, 面试要强调多信号融合 + 服务端决策 + 误伤控制.
  • 工程化: 多信号加权打分并设阈值, 在命中率与误报率之间权衡; 灰度期人工抽看误报样本校准特征与权重, 而不是单信号一次命中即处置.
  • 完整性校验: APK 签名 / 受信服务端证明可作为完整性证据; CRC 仅用于偶发损坏检测且可被重算, 不能单独作为可信的篡改检测依据.
  • 官方安全能力补充: 可结合 Play Integrity API / Play Protect / App Access Risk 等信号判断 “是否为可信 App, 可信设备, 低风险环境”.这些能力也不是唯一依据, 更适合接入服务端风控策略, 并对高价值动作按风险分层处理.
  • 版本演进: SafetyNet Attestation 已弃用, 新接入应使用 Play Integrity; Play Integrity 返回分层 verdict (如 MEETS_DEVICE_INTEGRITY / MEETS_STRONG_INTEGRITY 等), 具体 verdict 字段与枚举以接入时官方文档为准.
  • 这是你的主场: 这些正是设备指纹 / 风控 SDK 的核心对抗, 你能讲实战细节, 面试官会眼前一亮.

术语卡: 攻击面与逆向阅读对象

  • TracerPid: Linux 进程状态信息中的跟踪者进程 ID; 非零可作为 “可能被调试” 的信号, 零不能证明安全, 非零也不能单独定罪.
  • PLT/GOT: ELF 中用于延迟 / 间接解析外部函数调用的表结构. 关键调用经由可改写的间接入口是完整性检测可能关注的攻击面; 正常加载器, 兼容层和系统差异也会影响其表现.
  • Merkle 树: 把分块哈希逐层组合为根哈希的树, 用于高效验证大文件部分内容; APK v4 与增量安装相关, 不等于应用在所有场景自动获得运行时防篡改.
  • smali: Dalvik/ART 字节码的可读汇编表示, 适合解释 APK 静态代码结构; 它不是 Kotlin/Java 源码的完全还原.
  • xref (交叉引用): 静态分析中 “谁引用了某字符串, 函数或地址” 的关系, 用于理解依赖和缩小审计范围, 不能单独证明运行时行为.

签名, 壳/混淆, smali 与 ELF/PLT/GOT 等 “攻击面和原理” 在本章解释; 如何把这些信号接入灰度, 白名单, 回退与服务端治理, 见移动安全防护体系.

六, 运行环境检测 (风控核心)

  • Root 检测: su 文件, Magisk/busybox 二进制, 危险属性; 可写系统分区依赖分区挂载方式, Android 10+ system-as-root 下已基本失效, 现代设备改用 Magisk 二进制, 启动镜像篡改与 /proc 特征等信号.
  • 模拟器检测: 特征文件, CPU 架构 (仅对 x86 模拟器有效, ARM 模拟器不适用), 传感器缺失, IMEI / 型号特征, qemu 痕迹.
  • Hook 框架检测: 见上 (Frida/Xposed).
  • 多开 / 虚拟环境检测: 包路径异常, 进程名,/proc 特征.
  • 设备指纹: 综合硬件/系统/行为信号形成概率性关联标识, 不保证唯一或稳定; 用于风险辅助并受隐私最小化约束.

七, 机制边界

Android Keystore 的密钥不可导出属性及 TEE/StrongBox 保护等级取决于设备, 属性和安全等级, 不能一概保证. 受控密文存储, 请求签名字段, pinning 轮换和服务端消费状态属于工程策略, 统一见移动安全防护体系.


版本, 误报与防御边界

动态加载, 完整性和环境检测结论都受 OS, ART, 厂商 ROM, 加固/热修复和工具版本影响. root/hook/emulator/Frida 特征只能作为可变化的风险信号, 需要灰度, 远端配置, 误报样本和服务端分级处置. 本章只用于授权防御分析; 不把 “可检测” 描述为 “不可绕过”, 也不提供可直接滥用的绕过或利用步骤. 最后核验: 2026-08-07.

高频面试题

Q1: APK v1 和 v2 签名区别? v1 有什么漏洞? v1 对已签名条目摘要签名, 覆盖边界不含全部 ZIP 元数据和未签名条目; 这不等于任意新增内容都可执行. v2 使用 APK Signing Block 覆盖整包内容, v3 支持密钥轮换, v4 的 Merkle 树服务于增量安装相关校验. 实际接受哪些方案取决于目标设备与平台校验.

学完能做什么与练习

你应能区分签名格式覆盖边界, 逆向阅读对象和风险信号局限. 练习: 拿一个受控 APK 的签名信息与 zip 条目清单对照, 写下 v1/v2 的覆盖差异; 预期证据是能说明 CRC 不能证明可信完整性, 设备信号不保证唯一, 且不产出绕过步骤.

Q2: App 加固有哪几代? VMP 为什么最难逆向? 一代整体 DEX 加密, 二代方法抽取 / 按需还原, 三代 VMP 指令虚拟化. VMP 把标准指令语义迁移到私有虚拟机里, 分析者即使看到运行过程也需要理解解释器和私有指令语义, 成本最高; 但它也有性能, 体积, 兼容性成本, 通常只保护核心逻辑.

Q3: 为什么传输检测不能成为信任根? 客户端逻辑仍可被分析或篡改, 代理/证书/运行时异常只能说明攻击面或风险信号. 具体 pinning, 请求签名和服务端处置的工程治理见移动安全防护体系.

Q4: 怎么检测 App 被 Frida hook 了? 从防御视角看, 可以收集运行时注入, 模块加载, 堆栈, 代码段完整性, 异常调试状态等风险信号; 但具体特征会随工具版本和加载方式变化, 不能当最终结论. 工程上应多信号融合, 服务端分级处置, 并控制误伤.

Q5: 怎么做反调试? 常见方向包括调试状态, 关键路径时间异常, 运行环境完整性和 native 代码段完整性等. 回答重点不是背某个检测点, 而是说明它们都有版本/ROM/工具差异和误判风险, 应作为风控信号参与分级决策, 而不是单点封禁.

Q6: 设备指纹怎么提升唯一性和稳定性?(你的本职) 综合多维特征 (硬件, 系统, 网络, 行为) 加权生成, 容忍部分特征变化 (系统升级 / 重置), 并结合风险信号对抗篡改和模拟. 讲清采集维度, 降级策略, 对抗思路即可: 这是你的真实经验, 放开讲.

Q7: CRC 能证明 so 未被恶意篡改吗? 不能. CRC 能检测偶发损坏但可被重算; 可信完整性需要 APK 签名, 受信服务端证明或其他有密钥 / 可信链的校验, 并仍受运行时攻击面限制.

易错点 / 追问

  • 把 “可检测” 表述成 “不可绕过”: 客户端防护只能提高攻击成本; 讲威胁模型, 边界和误报成本, 不承诺无法验证的效果.
  • 单信号一次命中即处置: TracerPid, 时间差, 特征文件都有版本 / ROM / 工具差异; 应多信号融合 + 服务端分级 + 误伤灰度.
  • 把客户端检测当信任根: 客户端逻辑可被分析或篡改; 关键决策放服务端, pinning 轮换与降级要有治理.
  • 用 CRC 证明完整性: CRC 可被重算, 只能检测偶发损坏; 可信校验需要签名, 受信服务端证明或其他带密钥的机制.
  • 在面试或文档里细讲绕过步骤和攻击脚本: 只讲原理, 风险和防护边界; 具体利用流程不合规且会被追问到底.
  • 把工具特征当永久事实: Frida/Xposed 特征, 内核行为随版本变化; 结论标核验日期和适用版本.
  • 宣称设备指纹 “绝对唯一 / 稳定”: 指纹是概率性关联标识; 讲熵, 碰撞率和降级策略, 不保证唯一.

进阶补充: 逆向工具链, Native 分析与面试表达边界

常见工具链定位

工具主要用途
jadx / jadx-guiJava/Kotlin 层反编译阅读
apktool资源, Manifest, smali 处理
Frida运行时 hook, 参数 / 返回值观察
Xposed / LSPosed模块化 hook 框架
IDA / Ghidranative so 静态分析
objection基于 Frida 的移动安全辅助工具

Native so 分析要点

关注 ELF 结构, 导出符号, JNI_OnLoad, RegisterNatives, 字符串/xref, PLT/GOT, 反调试和混淆. 符号被 strip 后, 可结合字符串, 常量, 调用图和动态 trace 恢复语义.

OWASP Mobile Top 10 视角

面试中可以从不安全存储, 不安全通信, 认证授权, 代码篡改, 逆向风险, 敏感信息泄露等角度回答安全设计.

设备可信与服务端风控

客户端检测只能提高攻击成本, 不能单独作为信任根. 更稳妥的设计是客户端采集信号 + 服务端风控决策 + 灰度策略 + 异常监控.

面试表达边界

讲原理, 风险, 检测, 防护和合规边界; 避免细讲绕过步骤, 攻击脚本和可直接滥用的操作流程.

事故 / 对抗复盘模板

叙事骨架: 检测命中 → 服务端分级 → 误伤回滚. 例如: 新检测上线后某信号命中率激增 (症状) → 服务端按 ROM / 设备 / 策略版本聚合, 定位命中集中在企业 MDM 与老 ROM 误报样本 (证据) → 灰度组对照转化率与申诉确认回归 (定位) → 调低该信号权重并加时效例外, 观察风险漏放后再扩大 (修复). 套用时讲清信号, 分级动作与回滚条件, 不展开绕过细节.

追问: 为什么不能只靠客户端反调试? 因为攻击者控制运行环境, 客户端防护只能增加成本, 关键决策要在服务端完成.

移动安全防护体系

移动安全的成熟回答不是 “客户端能绝对防住攻击”, 而是 “客户端提高成本, 服务端做最终风控, 工程上控制误伤和可用性”.本章只从防御, 检测, 加固和风险联动角度讲, 不提供绕过脚本或攻击步骤.

一, 防护体系的分层模型

移动端运行在用户可控设备上, 不能把客户端当绝对可信根. 合理体系要分层:

层级目标常见手段边界
传输安全降低中间人攻击和数据篡改的风险HTTPS, SSL Pinning, 请求签名, 重放防护客户端逻辑可被分析, 不能单点依赖
应用完整性发现二次打包/篡改签名校验, DEX/so 完整性, R8/ProGuard需兼容热修复, 渠道包和灰度
环境风险识别高风险设备root/hook/emulator/Frida 检测, 设备可信信号单点误判高, 特征会变化
代码保护提高逆向成本混淆, 字符串加密, native obfuscation, shell/packing性能, 稳定性, 可观测性成本
服务端风控最终决策风险评分, 设备指纹, 行为模型, 二次验证依赖数据质量和策略治理

面试总原则: 客户端防护用于 “采集信号 + 提高成本 + 延迟攻击”, 高价值决策必须回到服务端风控.

二, 风险信号的策略接入

检测机制, 签名格式与逆向术语见 Android 安全与逆向. 本章只定义它们如何以版本化风险信号进入策略, 不将任何单一信号直接等同封禁.

防御表达要强调限制:

  1. 特征会随工具版本变化, 需要远端配置和灰度.
  2. 厂商 ROM, 测试设备, 无障碍工具, 企业 MDM 可能造成误判.
  3. 检测结果应是风险分, 而不是唯一封禁依据.
  4. 高风险动作可触发二次验证, 降级, 延迟处理或服务端复核.

三, SSL Pinning 与传输防护

SSL Pinning 是客户端内置服务端证书或公钥指纹, 连接时只信任预期身份, 降低用户安装恶意根证书后的 MITM 风险.

传输安全组合拳:
HTTPS/TLS
  + SSL Pinning(证书或公钥)
  + 请求签名(timestamp + nonce + requestId + body digest)
  + 重放防护(服务端校验时间窗,nonce/requestId 未消费状态)
  + 高风险接口二次校验(设备风险 + 行为风险)

工程注意:

  • Pinning 要支持证书轮换, 通常 pin 公钥或准备备份 pin.
  • 失败策略要区分网络异常, 证书异常, 系统时间错误和灰度问题.
  • 不要把密钥硬编码当唯一保护, 客户端 secret 只能提高成本.
  • 与 OkHttp/Network Security Config/自研网络层配合时要有测试覆盖.

请求签名推演: 服务端校验 timestamp 落在 ±5 分钟时间窗内 (示意值, 按业务标定); nonce 需有消费状态存储并设置过期 (已用 nonce 拒绝, 过期后清理), 配合 requestId 做幂等, 防止同一请求在时间窗内被重复提交的重放场景.

Pinning 轮换与失效设计

生产 pinning 至少准备当前 pin 与备份 pin, 轮换顺序应是 “先发布包含新 pin 的客户端, 再切服务端证书, 最后在足够升级覆盖后移除旧 pin”. 需要监控 pin 失败原因和客户端版本分布, 并设计经过审批的失效 / 降级策略; 不能用永久关闭证书校验作为应急方案. Pinning 不能替代正常 PKI 校验, 服务端鉴权, 请求幂等和重放防护.

反例复盘: 若先切服务端证书, 后发新 pin 客户端, 老版本仍用旧 pin 校验新证书会全部失败, 造成大面积登录 / 支付不可用. 顺序之所以重要, 是因为必须让 “客户端先认识新 pin” 先行, 覆盖足够升级率后再切换证书, 才能把失败窗口收敛到灰度可控.

四, 防护方案的发布治理

R8, 加壳, native 保护, 签名和完整性机制的原理见 Android 安全与逆向. 本章治理它们的发布影响: 每个方案绑定 mapping/native symbols, 兼容矩阵, 策略版本和灰度指标; 热修复, 插件化和渠道包使用带有效期的白名单/版本策略, 防止误伤. 任何完整性动作都要有失败分类, 审计与受控回退, 而不是把校验失败直接等同永久封禁.

五, device trust 与服务端风控联动

设备可信可以结合 Play Integrity API, 厂商安全能力, 设备指纹, 账号行为, 网络环境和业务风险事件. 关键是把客户端信号传给服务端做统一决策.

客户端采集:
  设备指纹 + 完整性 + root/hook/emulator + 网络风险 + 行为摘要
        ↓
服务端风控:
  规则/模型评分 + 账号历史 + 交易/登录上下文 + 黑白名单
        ↓
分级处置:
  放行 / 降级 / 二次验证 / 延迟审核 / 拒绝 / 人工复核

服务端联动的优势:

  • 风控策略可动态调整, 不依赖发版.
  • 能结合账号, 设备, IP, 行为, 交易等多维数据.
  • 可以做灰度, AB, 阈值回滚和误伤监控.
  • 客户端只上报必要风险信号, 避免暴露完整策略细节.

服务端视角: 关注设备指纹冲突率 (多账号共享同一指纹) 与指纹稳定性分布, 作为团伙 / 设备农场信号; 将 Play Integrity verdict 作为融合打分的一个维度, 与指纹, 行为信号加权决策, 不单点定论.

六, 安全工程化与合规边界

安全防护要纳入工程流程, 不是临上线才加检测点.

  • 威胁建模: 识别登录, 支付, 设备绑定, 优惠券, 风控 SDK 等高价值资产.
  • 分级防护: 普通页面不应引入高成本防护, 核心链路才做更强检测和加固.
  • 可观测性: 记录风险命中, 误伤, 崩溃, 性能影响和策略版本.
  • 隐私合规: 设备指纹和环境信号要遵守最小必要, 告知同意, 用途限定和数据安全要求.
  • 应急响应: 证书轮换, 密钥泄露, 加固兼容性事故, 误封回滚都要有预案.

风险分档示例 (示意)

分档示例信号组合处置动作
低无明显环境风险, 指纹与 verdict 正常放行
中少量可疑信号 (如弱模拟器特征或单点命中)二次验证 / 降级 / 加验证码
高多信号命中叠加行为异常拒绝 / 延迟审核 / 人工复核

阈值为示意, 按业务灰度标定, 不写死为固定分数.

面试表达边界: 讲检测, 防护, 限制, 误伤控制和服务端风控, 不要讲绕过流程, 攻击脚本, hook 代码或可直接复现的利用步骤.

误伤, 白名单与灰度回退闭环

本章不重复签名版本, 逆向术语或检测原理; 这些攻击面见 Android 安全与逆向. 防护策略的交付物应是可治理的规则, 而不是把任意检测命中写成客户端 exitProcess():

风险信号 -> 策略版本/阈值 -> 命中审计 -> 分级动作 -> 指标观察
                                  |                 |
                         受控白名单/例外         二次验证/降级/拒绝
                                                    |
                                               一键回退到上一策略
  • 白名单只用于经过审批, 带有效期和可审计原因的兼容例外 (如企业 MDM, 测试设备或已知 ROM 误报); 不能把白名单当永久绕过通道, 也不应由客户端静态硬编码.
  • 灰度按低风险人群, 版本或地区逐步扩大, 比较命中率, 二次验证完成率, 支付/登录成功率, 崩溃率和申诉/客服反馈. 阈值变化必须可追溯到策略版本.
  • 回退预先定义触发条件和负责人. 出现 pin 失败激增, 关键转化异常或崩溃回归时, 服务端先降级非必要风险动作或切回上一策略; 仍保持正常 TLS 校验, 绝不以 “信任所有证书” 作为应急方案.

这构成典型失败路径: 新规则将企业管理设备误判为高风险 (症状)-> 审计按 ROM/MDM/策略版本聚合 (证据)-> 对照灰度组确认回归 (定位)-> 加入有时效例外并调低单信号权重 (修复)-> 观察登录成功率与风险漏放指标后再扩大 (验证).

学完能做什么与练习

你应能将来自攻击面章节的信号治理为可回退策略. 练习: 为一次 pin 轮换和一次企业 MDM 误伤写策略版本, 短期白名单, 灰度指标与回退条件; 预期证据是签名请求含 nonce/时间窗/requestId/消费状态, 且应急方案不关闭 TLS 校验.


高频面试题

Q1: 客户端 root/hook/Frida 检测能完全防住攻击吗? 不能. 客户端处于用户可控环境, 检测只能提高成本并提供风险信号. 工程上应多信号融合, 服务端风控决策, 灰度策略和误伤监控, 不能单点封禁.

Q2: SSL Pinning 的作用和边界是什么? 作用是降低伪造 CA, 中间人抓包和传输篡改风险. 边界是客户端校验逻辑仍可能被分析或篡改, 所以要结合请求签名, 重放防护, 完整性校验和服务端风险联动.

Q3: R8/ProGuard 和加壳有什么区别? R8/ProGuard 主要做压缩, 优化, 重命名混淆, 属于基础构建能力; 加壳/packing 更强调加密, 抽取, 运行时加载或虚拟化, 保护更强但兼容性, 性能和排障成本更高.

Q4: 为什么安全决策要放服务端? 因为客户端环境不可完全可信, 本地策略容易被分析和篡改. 服务端能结合账号, 设备, 行为, IP, 历史风险等多维信息, 动态调整风控策略并控制误伤.

Q5: 怎么在面试中安全地讲 Frida 检测? 只讲防御视角: 运行时注入, 模块, 线程, 堆栈, 内存映射, 代码完整性等风险信号的组合; 强调特征变化, 误判, 服务端分级处置. 不要提供 hook 脚本或绕过步骤.

易错点 / 追问

  • 不要承诺 “客户端绝对安全”, 正确说法是提高攻击成本并联动服务端风控.
  • 不要把 root, hook, emulator 任一单点命中当封禁依据, 要考虑误伤和灰度.
  • SSL Pinning 要考虑证书轮换和失败策略, 否则可能造成大面积不可用.
  • 混淆 / 加固要和 crash 符号化, mapping 管理, 性能监控一起设计.
  • 设备指纹和环境检测涉及隐私合规, 必须遵守最小必要和用途限定.

NDK 与 JNI ☆

这是你的强项, 也是你的差异化武器. 一般应用开发者只会调用别人的 so, 你能独立写 SDK. 这一篇帮你把这份功底结构化成面试语言, 在三面 / 技术亮点环节碾压竞争者. 面试讲这块时要主动, 自信, 深入.

代码示例上下文: 本章 JNI/C++ 片段用于解释边界, 默认需要 #include <jni.h>, 对应 Java/Kotlin native 声明, 正确的包名/签名以及 CMake/NDK 配置; 除非明确标注, 不保证单个片段可脱离上下文独立编译.

一, JNI 基础与类型映射

JNI (Java Native Interface) 是 Java/Kotlin 与 C/C++ 互调的桥梁.

类型映射

Java 类型JNI 类型签名
booleanjbooleanZ
bytejbyteB
charjcharC
intjintI
longjlongJ
floatjfloatF
doublejdoubleD
ObjectjobjectL 全限定名;
StringjstringLjava/lang/String;
int []jintArray[I
voidvoidV

方法签名

格式 (参数)返回值, 如 (ILjava/lang/String;)V 表示 (int, String) -> void. 用 javap -s 可查看签名.

二, 引用管理 (高频深挖)

JNI 有三种引用, 管理不当会泄漏或崩溃:

  • 局部引用 (Local Reference): 默认创建的引用 (如 FindClass, NewObject 返回值).方法返回后自动释放, 不能跨方法 / 线程缓存. JNI 规范保证进入 native 方法前至少可创建 16 个局部引用; ART / 设备上的引用表容量和扩展行为可能不同, 不要把 “512” 当通用契约. 循环中大量创建不释放仍可能触发 ReferenceTable overflow: 要及时 DeleteLocalRef, 或用 EnsureLocalCapacity / PushLocalFrame 管理容量.
  • 全局引用 (Global Reference): NewGlobalRef 创建, 跨方法 / 线程有效, 必须手动 DeleteGlobalRef 否则泄漏. 用于缓存常用 jclass, jobject.
  • 弱全局引用 (Weak Global Reference): NewWeakGlobalRef, 不阻止 GC. 使用前推荐通过 NewLocalRef 提升成强局部引用, 若返回 NULL 说明对象已回收; 单纯 IsSameObject(ref, NULL) 只能做瞬时判断, 之后仍可能被 GC.

实战经验 (面试可讲): 缓存 jclass/jmethodID 用全局引用提升性能; 在 native 循环处理数组时注意局部引用释放, 避免引用表溢出.

引用类型生命周期典型用途易错点
Local当前 native 调用 / 当前 local frame临时对象, 字符串, 数组元素循环中不删会堆积; 不能保存到全局变量或跨线程用.
Global手动删除前有效缓存 jclass, 跨线程回调对象忘记 DeleteGlobalRef 会泄漏 Java 对象.
Weak Global对象未被 GC 前可观察缓存可被回收的 Java owner使用前要提升为强引用, 不要假设检查后就安全.
// 批量处理对象数组:用 local frame 一次释放本轮临时引用
for (jsize i = 0; i < count; i += 64) {
    if (env->PushLocalFrame(64) < 0) return; // OOM pending
    for (jsize j = i; j < count && j < i + 64; ++j) {
        jobject item = env->GetObjectArrayElement(array, j);
        // ... 使用 item ...
    }
    env->PopLocalFrame(nullptr); // 释放本 frame 内局部引用
}

三, JNIEnv 与 JavaVM, 线程

  • JavaVM: 进程唯一, 代表整个虚拟机, 可跨线程共享.
  • JNIEnv:线程私有, 每个线程一份, 不能跨线程缓存使用.
  • native 线程访问 JVM: 在 native 自己创建的线程里要调用 Java, 必须先 JavaVM->AttachCurrentThread 拿到该线程的 JNIEnv, 线程退出前 DetachCurrentThread, 否则可能造成线程相关资源泄漏. 由 JVM 调进 native 的线程已经 attached, 通常不应随意 detach.
  • 拿 JavaVM 的方式: JNI_OnLoad 回调里保存全局 JavaVM 指针.
JavaVM* g_vm;
jint JNI_OnLoad(JavaVM* vm, void*) {
    g_vm = vm;                       // 保存,供后续线程 attach
    return JNI_VERSION_1_6;
}
// native 线程中:
JNIEnv* env;
g_vm->AttachCurrentThread(&env, nullptr);
// ... 调用 Java ...
g_vm->DetachCurrentThread();

更稳的工程写法是 RAII 包一层, 确保异常路径 / 提前 return 也会 detach:

class ScopedJniEnv {
public:
    explicit ScopedJniEnv(JavaVM* vm) : vm_(vm) {
        if (vm_ == nullptr) return;
        const jint state = vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6);
        if (state == JNI_EDETACHED) attached_ = vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK;
        else if (state != JNI_OK) env_ = nullptr;
    }
    ~ScopedJniEnv() {
        if (attached_) vm_->DetachCurrentThread();
    }
    bool ok() const { return env_ != nullptr; }
    JNIEnv* get() const { return env_; }
private:
    JavaVM* vm_ = nullptr;
    JNIEnv* env_ = nullptr;
    bool attached_ = false;
};

四, 静态注册 vs 动态注册

  • 静态注册: 按命名约定 Java_包名_类名_方法名 命名 native 函数, 运行时按名查找. 缺点: 函数名长, 首次调用需查找, 暴露包名 (易逆向).
  • 动态注册: 在 JNI_OnLoad 里用 RegisterNatives 把 Java 方法和 native 函数指针映射注册. 优点: 函数名自由, 效率高, 隐藏映射关系 (更安全, SDK / 加固常用).
static JNINativeMethod methods[] = {
    {"nativeFoo", "(I)I", (void*)foo_impl},
};
env->RegisterNatives(clazz, methods, 1);

面试可讲: 做 SDK / 对抗时用动态注册隐藏 native 入口, 增加逆向难度: 这是你风控背景的天然加分点.

维度静态注册动态注册
绑定方式函数名按 Java_package_Class_method 约定匹配RegisterNatives 显式绑定 Java 方法名/签名/函数指针
可维护性简单直观, Demo / 少量方法够用集中注册, 重构包名时更可控
性能 / 加载首次调用按名解析JNI_OnLoad 一次注册, 调用路径更直接
安全/逆向导出符号暴露 Java 包名和方法可隐藏 C++ 函数名, 但不能替代混淆/加固/权限校验

动态注册要检查 FindClass, RegisterNatives 返回值和 pending exception; 注册失败如果继续运行, 后续 Java 调 native 会变成难定位的 UnsatisfiedLinkError.

JNI 最小三件套: 声明, 实现, 构建

以下是可放入 Android 工程验证的最小示例. 假设 Kotlin 文件为 com.example.jnidemo.NativeBridge, 模块已配置 NDK 与 CMake; 输出取决于输入, 不声称已在本仓库执行.

// app/src/main/java/com/example/jnidemo/NativeBridge.kt
package com.example.jnidemo

object NativeBridge {
    init { System.loadLibrary("native_demo") }

    external fun add(left: Int, right: Int): Int
    external fun greet(name: String): String
    external fun sum(values: IntArray): Long
}

// 调用:check(NativeBridge.add(2, 3) == 5)
// 预期:greet("Ada") 返回 "Hello, Ada";sum(intArrayOf(1, 2, 3)) 返回 6L.
// app/src/main/cpp/native_demo.cpp
#include <jni.h>
#include <string>

static jint add_impl(JNIEnv*, jobject, jint left, jint right) {
    return left + right;
}

static jstring greet_impl(JNIEnv* env, jobject, jstring name) {
    if (name == nullptr) return env->NewStringUTF("Hello, anonymous");
    const char* utf8 = env->GetStringUTFChars(name, nullptr);
    if (utf8 == nullptr) return nullptr; // VM 已挂起 OOM,立即返回
    std::string message = "Hello, ";
    message += utf8;
    env->ReleaseStringUTFChars(name, utf8);
    return env->NewStringUTF(message.c_str());
}

static jlong sum_impl(JNIEnv* env, jobject, jintArray values) {
    if (values == nullptr) return 0;
    const jsize size = env->GetArrayLength(values);
    jlong total = 0;
    for (jsize index = 0; index < size; ++index) {
        jint value = 0;
        env->GetIntArrayRegion(values, index, 1, &value);
        if (env->ExceptionCheck()) return 0; // 保留 pending exception 给 Kotlin
        total += value;
    }
    return total;
}

static JNINativeMethod kMethods[] = {
    {const_cast<char*>("add"), const_cast<char*>("(II)I"), reinterpret_cast<void*>(add_impl)},
    {const_cast<char*>("greet"), const_cast<char*>("(Ljava/lang/String;)Ljava/lang/String;"), reinterpret_cast<void*>(greet_impl)},
    {const_cast<char*>("sum"), const_cast<char*>("([I)J"), reinterpret_cast<void*>(sum_impl)},
};

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) {
    JNIEnv* env = nullptr;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) return JNI_ERR;
    jclass clazz = env->FindClass("com/example/jnidemo/NativeBridge");
    if (clazz == nullptr || env->ExceptionCheck()) return JNI_ERR;
    const jint result = env->RegisterNatives(clazz, kMethods, sizeof(kMethods) / sizeof(kMethods[0]));
    env->DeleteLocalRef(clazz);
    return result == JNI_OK ? JNI_VERSION_1_6 : JNI_ERR;
}
# app/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(native_demo)
add_library(native_demo SHARED native_demo.cpp)

Gradle 的 externalNativeBuild.cmake.path 指向该 CMakeLists.txt, 然后以 ./gradlew :app:assembleDebug 构建并在设备 / 模拟器 ABI 匹配时调用 Kotlin 示例. 动态注册的三个签名必须分别是 (II)I, (Ljava/lang/String;)Ljava/lang/String;, ([I)J; 任一差异都会使 RegisterNatives 失败.

调用方必须先检查 ScopedJniEnv.ok(); 失败时返回错误, 不能解引用空 JNIEnv*. Kotlin object 未标记 @JvmStatic 的成员使用 JNI 实例 receiver, 本例第二参数为 jobject. GetStringUTFChars 返回 Modified UTF-8 缓冲, 只在 ReleaseStringUTFChars 前有效. 数组若使用 GetIntArrayElements, 必须在所有提前返回路径调用 ReleaseIntArrayElements; 此例使用 GetIntArrayRegion 避免取得需要释放的元素指针.

五, native 调用 Java 与异常

  • 调用流程: FindClass → GetMethodID/GetStaticMethodID → CallXxxMethod.
  • 异常处理: JNI 调用后先 ExceptionCheck. 若继续向 Java 传播, 立即返回并保留 pending exception; 只有 native 已处理或转换异常时才 ExceptionClear, 清除后才能继续 JNI 调用.

六, native crash 捕获 (你的强项可深讲)

  • native 崩溃 (SIGSEGV/SIGABRT) 不走 Java 的 try/catch, 默认直接挂掉进程.
  • 捕获方式: 注册信号处理器 (sigaction) 捕获 SIGSEGV, SIGABRT, SIGBUS 等, 在 handler 里 dump 寄存器, 调用栈 (unwind), maps; 难点是 handler 中只能调用 async-signal-safe 函数, 栈展开需结合 .eh_frame/.ARM.exidx.
  • 成熟方案:Google Breakpad / Crashpad(生成 minidump), 爱奇艺的 xCrash(输出 tombstone 格式文件). 都需要符号化后定位.
  • 拿到 tombstone/minidump 后的崩溃现场解读与符号化 (tombstone 字段分析, addr2line / ndk-stack, build-id 匹配与 strip 符号管理) 属于排障主线, 详见 NDK Native 调试与 Crash 定位 的「tombstone 与 native crash 现场」和「addr2line / ndk-stack 符号化流程」.

七, CMake / ABI / so

  • 构建: CMakeLists.txt + externalNativeBuild; add_library, target_link_libraries, find_library(log).
  • ABI: arm64-v8a (主流), armeabi-v7a (老设备), x86/x86_64 (模拟器). abiFilters 控制打包哪些, 通常只留 arm64-v8a 减体积.
  • so 加载: System.loadLibrary("name") 加载 libname.so, 触发 JNI_OnLoad.
  • 现代 C++: RAII (资源随对象析构释放), 智能指针 (unique_ptr 独占 / shared_ptr 共享 / weak_ptr 防循环引用), 移动语义 (std::move 转移资源所有权). NDK 用这些写出更安全的 native 代码.

现代 C++ 在 NDK 里的落地

场景推荐写法为什么
管理 native buffer / 句柄std::unique_ptr + 自定义 deleter, 或封装 RAII class避免异常 / return 分支漏 free/close/DeleteGlobalRef.
多模块共享对象明确所有权后再用 std::shared_ptr引用计数有成本, 还可能循环引用; 默认优先 unique_ptr.
回调持有 Java ownerC++ 侧弱引用 + Java/Kotlin 生命周期取消避免 native 长生命周期对象强持 Activity.
大对象返回/转移移动构造/移动赋值, 必要时 std::move减少拷贝, 但 moved-from 对象只应处于可析构 / 可重新赋值状态.
struct GlobalRefDeleter {
    JavaVM* vm;
    void operator()(jobject ref) const {
        if (!ref) return;
        ScopedJniEnv scoped(vm);
        if (scoped.ok()) scoped.get()->DeleteGlobalRef(ref);
    }
};

using UniqueGlobalRef = std::unique_ptr<_jobject, GlobalRefDeleter>;

移动语义陷阱: std::move 只是强制把对象当右值, 真正移动取决于类型是否实现移动构造 / 赋值; 对还要继续读取的对象不要随手 move. JNI 场景中尤其要避免把 JNIEnv*, 局部引用这类线程 / 生命周期受限资源包装后跨线程 move.


高频面试题

Q1: JNI 的局部引用和全局引用区别? 什么时候会引用表溢出? 局部引用方法返回或 local frame 弹出后释放, 不可跨线程; 规范保证至少 16 个局部引用, 具体容量/扩展行为看 VM 实现, 不要死记 512. 全局引用需手动 DeleteGlobalRef, 可跨线程缓存. 循环中大量创建局部引用不及时 DeleteLocalRef/PushLocalFrame, 就可能触发 ReferenceTable overflow.

Q2: JNIEnv 和 JavaVM 区别? native 线程怎么调 Java? JavaVM 进程唯一可共享; JNIEnv 线程私有不可跨线程. native 自建线程要调 Java, 需先 AttachCurrentThread 拿当前线程的 JNIEnv, 线程退出前 DetachCurrentThread; 从 Java 调进来的线程已经 attached, 不要误 detach.

Q3: 静态注册和动态注册区别? 为什么 SDK 喜欢动态注册? 静态按 Java_包名_方法名 命名约定查找; 动态用 RegisterNatives 在 JNI_OnLoad 注册函数指针映射. 动态注册函数名自由, 效率高, 隐藏入口映射, 增加逆向难度, 适合 SDK / 加固.

Q4: native 崩溃为什么 Java 的 try/catch 抓不到? 怎么捕获? native 崩溃是 OS 信号 (SIGSEGV 等), 不经过 JVM 异常机制. 需注册 sigaction 信号处理器捕获, 在 handler 中 dump 栈和寄存器, 用 Breakpad/Crashpad (minidump) 或 xCrash (tombstone 格式) 记录后符号化定位. tombstone 解读与符号化流程详见 NDK Native 调试与 Crash 定位.

Q5: JNI 调用 Java 方法后为什么要检查异常? JNI 调用若使 Java 抛异常, native 代码不会自动中断. 要传播就检查后立即返回并保留 pending exception; ExceptionClear 只用于 native 已处理 / 转换的分支.

学完能做什么与练习

你应能让声明, 注册, 线程和引用生命周期一致. 练习: 故意改错 sum 签名, 让 Java 回调抛异常, 并模拟 attach 失败; 预期证据是加载期注册失败, 异常保留回 Java, ok()==false 时不解引用 JNIEnv*.

Q6: 为什么大多 App 只打包 arm64-v8a? 现代设备基本都是 arm64, 只留 arm64-v8a 可显著减小 so 体积; armeabi-v7a 仅老设备需要. 按 ABI 拆分 + App Bundle 动态下发是体积优化常用手段.

Q7: 智能指针怎么选?(现代 C++) 独占所有权用 unique_ptr; 确实有共享所有权才用 shared_ptr (引用计数有成本); 打破 shared_ptr 循环引用用 weak_ptr. 默认优先 unique_ptr 和 RAII, 把 JNI 全局引用, fd, native buffer 等资源封装成 RAII 对象, 在析构函数中统一释放, 避免裸 new/delete 和异常路径泄漏.

NDK Native 调试与 Crash 定位 ☆

Native 能力是你的差异化强项, 但面试要讲成 “稳定性与诊断能力”, 不是炫底层. 本章把 JNI 引用, 线程 attach, RegisterNatives, so 加载, ABI/CMake, tombstone, addr2line, ndk-stack, RAII 和 native 泄漏串成一套线上排障语言.

符号化前提: 工具路径按本机 NDK 安装位置解析, 不绑定单一 host tag. 必须保存与线上制品相同 build-id 的未裁剪符号; PIE/ASLR 场景要使用 tombstone 已归一化的相对 PC, 或先结合模块加载基址做地址归一化.

一, JNI 引用与线程模型复盘

Native 崩溃和泄漏里很大一部分来自 JNI 生命周期误用. 引用类型要和生命周期严格匹配.

引用类型生命周期常见用途风险
Local Reference当前 native 调用或 local frame临时对象, 字符串, 数组元素循环中不释放会引用表溢出; 不能跨线程缓存.
Global ReferenceDeleteGlobalRef 前有效缓存 jclass, 跨线程回调对象忘删会泄漏 Java 对象和 Activity.
Weak Global Reference对象未被 GC 前可观察缓存 owner, 监听对象使用前要提升为 Local, 否则可能已被回收.

JNIEnv* 是线程私有的, 不能跨线程保存; JavaVM* 进程唯一, 可以保存后在 native 线程里 AttachCurrentThread 获取当前线程的 JNIEnv*, 线程结束前 DetachCurrentThread.

class ScopedAttach {
public:
    explicit ScopedAttach(JavaVM* vm) : vm_(vm) {
        if (vm_ == nullptr) return;
        const jint state = vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6);
        if (state == JNI_EDETACHED) attached_ = vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK;
        else if (state != JNI_OK) env_ = nullptr;
    }
    ~ScopedAttach() {
        if (attached_) vm_->DetachCurrentThread();
    }
    bool ok() const { return env_ != nullptr; }
    JNIEnv* env() const { return env_; }
private:
    JavaVM* vm_ = nullptr;
    JNIEnv* env_ = nullptr;
    bool attached_ = false;
};

调用处先判断 scoped.ok(); false 时不得解引用 env(), 而应返回失败或放弃本次 Java 回调. wrapper 只 detach 自己成功 attach 的线程; GetEnv 的其他错误和 attach 失败均不会伪造可用环境. JNI 调用产生 pending exception 时, 若要传播给 Java 就立即返回并保留异常; 只有 native 已记录并转换 / 恢复后才 ExceptionClear.

面试高频追问是 “为什么不能保存 JNIEnv 到全局变量”:因为它绑定当前线程, 换线程使用会导致未定义行为甚至崩溃.

二, RegisterNatives, so 加载与初始化顺序

Native 方法注册分静态注册和动态注册. 工程 SDK 更常用 RegisterNatives, 在 JNI_OnLoad 中集中绑定 Java 方法和 C/C++ 函数.

环节关键点常见问题
System.loadLibrary加载 libxxx.so, 触发 JNI_OnLoad库名不匹配, ABI 缺失, 依赖 so 未找到.
JNI_OnLoad保存 JavaVM, 查找类, 注册 native 方法FindClass 失败, 签名错误, pending exception 未处理.
RegisterNatives方法名 + 签名 + 函数指针绑定混淆后未 keep, 签名写错, 返回值未检查.
初始化顺序Java 层先加载 so 再调用 native多 so 依赖顺序, 懒加载导致首次调用失败.

UnsatisfiedLinkError 常见原因包括: 没有打包对应 ABI 的 so, System.loadLibrary 名称错误, native 方法签名不匹配, R8 混淆改了 Java native 方法名, 依赖库加载失败. 定位时先看 logcat 的 linker 错误和 ABI 路径, 再查 JNI_OnLoad 返回值和注册日志.

三, ABI, CMake 与构建产物

ABI 决定 so 能在哪类 CPU 上运行. 现代 Android 主流是 arm64-v8a, 但模拟器, 老设备和第三方 SDK 可能需要其他 ABI.

cmake_minimum_required(VERSION 3.22)
project(native_demo)

add_library(native_demo SHARED native_demo.cpp)
find_library(log-lib log)
target_link_libraries(native_demo ${log-lib})
维度面试回答
ABI 选择通常优先 arm64-v8a; 是否保留 armeabi-v7a/x86 看用户设备和 SDK 依赖.
符号保留线上 so 可 strip 减体积, 但 CI 必须保存同 build-id 的未裁剪符号文件.
CMake 配置关注 add_library, target_link_libraries, 编译选项和 STL 选择.
依赖管理多 so 依赖要保证打包完整, 避免运行时 linker 找不到.
体积优化ABI 拆分, 只打必要架构, 裁剪无用符号和资源.

构建产物治理很重要: 没有符号文件, 线上 native crash 只能看到地址, 无法高效定位到源码行.

四, tombstone 与 native crash 现场

Android native crash 通常由信号触发, 例如 SIGSEGV, SIGABRT, SIGBUS. 系统会生成 tombstone 或在 logcat 中输出 crash 现场.

一份 tombstone 重点看:

  1. signal: 崩溃类型, 如 SIGSEGV 空指针 / 非法地址访问, SIGABRT 主动 abort.
  2. fault addr: 访问的非法地址, 0x0 附近通常是空指针.
  3. thread name / tid: 崩溃线程, 判断是主线程, 渲染线程还是业务线程.
  4. registers: 寄存器现场, 可辅助判断参数和访问地址.
  5. backtrace: so 名称, 偏移地址, 函数符号.
  6. memory map: 模块加载基址, 用于符号化和判断版本.
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
backtrace:
  #00 pc 0000000000012344  /data/app/.../lib/arm64/libdemo.so (foo+24)
  #01 pc 0000000000015678  /data/app/.../lib/arm64/libdemo.so (bar+80)

分析时先确认 so 版本和 build-id 是否对应, 再做符号化. 不要只凭函数名猜结论, 要结合入参, 线程, 最近业务行为和复现路径.

五, addr2line / ndk-stack 符号化流程

符号化的目标是把 tombstone 中的 pc 地址映射到函数, 源码文件和行号. 常用工具包括 addr2line, ndk-stack, Crashpad/Breakpad 符号化工具.

# 使用未 strip 的同版本 so,将 pc 偏移映射到源码行
# HOST_TAG 由当前主机决定, 例如 darwin-arm64/linux-x86_64; 不要写死
"$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin/llvm-addr2line" \
  -f -C -e obj/local/arm64-v8a/libdemo.so 0000000000012344

# 对包含 backtrace 的日志整体符号化
ndk-stack -sym obj/local/arm64-v8a -dump tombstone.txt
工具适用场景注意事项
addr2line / llvm-addr2line单个地址快速定位输入应是相对 so 的 pc 偏移, so 版本必须匹配.
ndk-stack对 tombstone/logcat 批量符号化-sym 指向未 strip 符号目录.
Breakpad/Crashpad线上 minidump 平台化处理需要符号服务器和 build-id 管理.
IDE Debugger本地复现调试适合断点, 变量观察, 不替代线上符号化.

如果符号化结果对不上, 优先检查: 线上包和符号文件是否同一版本, ABI 是否一致, 地址是否需要减去加载基址, so 是否被重新打包或裁剪.

从崩溃平台到源码行的可操作闭环

下面 tombstone 是结构示例, 地址, 进程和函数名均为虚构, 仅用于标注字段含义:

Build fingerprint: 'vendor/device:14/BUILD/123:user/release-keys'
BuildId: 'f00dbabe1234567890abcdef1234567890abcdef'
ABI: 'arm64'
pid: 1234, tid: 1260, name: worker-1  >>> com.example.demo <<<
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000000
    x0  0000000000000000  x1  0000007a10000000  ... registers omitted ...
backtrace:
      #00 pc 0000000000012344  /data/app/.../lib/arm64/libdemo.so (demo::use(Node*)+20)
      #01 pc 0000000000015678  /data/app/.../lib/arm64/libdemo.so (demo::run()+48)
      #02 pc 000000000000abcd  /apex/.../lib64/libc.so (__pthread_start(void*)+208)
memory map:
      0000007a20000000-0000007a20040000 r-xp ... /data/app/.../lib/arm64/libdemo.so

这个现场先说明 worker-1 因访问接近零地址而触发 SEGV_MAPERR; #00 的 pc 是待映射的 libdemo.so 偏移. 它是空指针假设的证据之一, 不是根因结论, 还需检查 Node* 的所有权, 竞态和调用路径.

  1. 归档: CI 为每个 variant/ABI 归档 APK/AAB, mapping, 未裁剪 .so 或独立 debug symbol, 构建提交, 版本号和 ELF build-id. 符号目录按 build-id 检索, 不按 “最新 release” 猜测.
  2. 接收: 崩溃平台保存原始 tombstone/minidump, app version, ABI, build-id, 崩溃线程和脱敏 breadcrumbs; 平台先用 build-id 自动匹配符号, 匹配失败标记为不可符号化, 不使用相近版本代替.
  3. 符号化: tombstone 标注的 pc 通常已是 module-relative PC, 可直接交给匹配 so 的 llvm-addr2line. 绝对 PC 不能机械减 maps 起点: 结合 maps 的虚拟地址, file offset 和 ELF PT_LOAD 的 p_vaddr/p_offset 计算 load bias, 再归一化为模块地址.
  4. 定位和验证: 以源码行, 反混淆后的调用链, 寄存器/参数线索和复现路径形成假设; 修复后在相同 ABI/variant 复现或做目标测试, 并检查新版本是否携带可匹配的符号. 没有复现证据时记录为假设, 不虚构 “已验证”.
# 命令模板:变量必须替换为本机构建产物;PC 来自同一份 tombstone.
"$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin/llvm-readelf" -n "$SYMBOL_SO"
"$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin/llvm-addr2line" -f -C \
  -e "$SYMBOL_SO" "$RELATIVE_PC"

上面的两条命令应分别执行; 第一条输出中的 Build ID 是匹配依据, 第二条才输出函数和源码行. ndk-stack 与 addr2line 结果若不一致, 优先保留原始 tombstone 并核对 build-id, ABI, 地址种类和符号是否被再次 strip.

学完能做什么与练习

你应能从原始现场走到可追溯源码假设. 练习: 为虚构 BuildId 建立符号索引, 再输入 module-relative PC 与含 maps file offset 的绝对 PC; 预期证据是只匹配同 BuildId/ABI 符号, 绝对地址按 ELF load bias 归一化, 符号不匹配时拒绝猜测源码行.

六, RAII 与 native 资源安全

Native 代码没有 Java/Kotlin GC 帮你管理所有资源. RAII (Resource Acquisition Is Initialization) 的思想是 “资源在构造时获取, 析构时释放”, 用对象生命周期减少异常路径和提前 return 的泄漏.

资源推荐管理方式易错点
malloc/new 内存std::unique_ptr, std::vector, 智能指针裸指针多分支释放容易漏.
文件描述符自定义 RAII fd wrapper忘记 close 导致 fd 泄漏.
JNI GlobalRefunique_ptr + 自定义 deleter析构时要拿当前线程 JNIEnv.
mutexstd::lock_guard / std::unique_lock手动 lock/unlock 容易死锁.
native handle封装 owner class所有权不清导致 double free.

RAII 还能提升面试表达: 你不仅会写 native, 还知道如何让 native 在异常, 崩溃和复杂生命周期下更稳定.

七, native 泄漏与调试方法

Native 泄漏包括内存泄漏, fd 泄漏, 线程泄漏, JNI 全局引用泄漏和图形 / 音视频资源泄漏. 它们不一定出现在 Java heap 中, 所以只看 LeakCanary 不够.

排查思路:

  1. 确认现象: RSS/PSS 持续增长, fd 数增长, 线程数增长, 崩溃前内存压力.
  2. 区分 Java/native: Android Studio Profiler, dumpsys meminfo, heapprofd, Perfetto.
  3. 定位分配点: debug 包可用 malloc 调试, ASan/HWASan, heapprofd 采样.
  4. 检查 JNI 引用: 全局引用是否释放, 循环局部引用是否 DeleteLocalRef.
  5. 检查所有权: 对象是否 double free, use-after-free, 跨线程释放.
  6. 修复后回归: 压测相同路径, 观察内存/fd/线程曲线回落.

线上治理要保存符号, 增加 native 关键路径日志, 对高风险模块做灰度, 并用崩溃率和内存指标观察修复效果.


高频面试题

Q1: JNIEnv 和 JavaVM 有什么区别? JavaVM 进程唯一, 可以跨线程保存; JNIEnv 是线程私有的, 只能在当前线程使用. native 自建线程要调用 Java, 必须先 AttachCurrentThread 获取 JNIEnv, 退出前 DetachCurrentThread.

Q2: RegisterNatives 有什么好处? 它在 JNI_OnLoad 中集中注册 Java native 方法与 C/C++ 函数指针, 避免静态注册的长函数名和包名暴露, 重构更可控. 工程上要检查 FindClass, 签名和 RegisterNatives 返回值, 并配置混淆 keep.

Q3: 拿到 tombstone 后怎么定位 native crash? 先看 signal, fault addr, 崩溃线程和 backtrace; 确认 so 版本, ABI 和 build-id 匹配; 再用 addr2line 或 ndk-stack 将 pc 地址符号化到函数和源码行, 最后结合入参, 业务路径和最近日志分析根因.

Q4: 为什么线上一定要保存未 strip 的 so? 线上包为了体积会 strip 符号, crash 里常只有 so 偏移地址. 没有同版本未 strip 符号文件, addr2line / ndk-stack 无法准确映射源码行, 定位效率会大幅下降.

Q5: RAII 在 NDK 中解决什么问题? RAII 把资源释放绑定到对象析构, 可以避免 return 分支, 异常路径或错误处理遗漏释放. 它适合管理 native 内存, fd, mutex, JNI GlobalRef 等资源, 能显著降低泄漏和 double free 风险.

易错点 / 追问

  • 不要跨线程缓存和使用 JNIEnv*, 跨线程只保存 JavaVM*.
  • 不要忘记删除 GlobalRef, 也不要把 LocalRef 存到全局变量里.
  • 不要只看 tombstone 函数名就下结论, 必须确认符号文件, ABI, build-id 和业务上下文匹配.
  • 追问 “addr2line 结果不对怎么办”:检查 so 版本是否一致, 地址是否为相对偏移, ABI 是否匹配, 符号是否被裁剪.
  • 追问 “native 泄漏怎么发现”:看 PSS/RSS, fd, 线程, heapprofd/Perfetto/ASan, 并结合 JNI 引用和所有权审查.

iOS 基础速览 (辅)

这是辅助篇, 目标不是把你培养成 iOS 工程师, 而是让你在面试里接得住 iOS 相关提问, 展示 “跨端潜力”.重点是用 Android 概念类比, 快速建立对照.

一, Swift 语言基础

  • 变量: let(常量, 类比 Kotlin val), var(变量).类型推断.
  • 可选值 (Optional): var name: String?, 概念等同 Kotlin 可空类型.
    • 可选绑定 if let x = name { } / guard let x = name else { return }.
    • 强制解包 name!(类比 Kotlin !!).
    • 可选链 name?.count(类比 ?.).
    • 空合运算 name ?? "default"(类比 Elvis ?:).
  • 闭包 (Closure): 类比 Kotlin Lambda, { (x: Int) -> Int in x * 2 }, 尾随闭包语法.
  • 协议 (Protocol): 类比接口, 但更强大 (可扩展, 面向协议编程 POP).
  • 结构体 vs 类: struct 是值语义, class 是引用语义. 实际存储位置由编译器根据逃逸分析和运行时优化决定, 不能简化成 “struct 一定在栈, class 一定在堆”.
  • 枚举: 带关联值的枚举很强大, 类比 Kotlin sealed class.

二, 内存管理 ARC

  • ARC(Automatic Reference Counting): 编译期自动插入 retain/release, 不是 GC(无运行时回收线程).引用计数为 0 即释放.
  • 循环引用: 两个对象互相强引用导致无法释放 (类比内存泄漏).
  • 解决: weak(弱引用, 可为 nil), unowned(非持有引用, 常用于生命周期确定的关系).闭包捕获用 [weak self](类比避免 Handler 持有 Activity).
  • 与 Android 对比: Android 用 GC 可达性分析自动回收; iOS 用 ARC 引用计数, 开发者需主动打破循环引用.

三, UI 体系

  • UIViewController 生命周期: viewDidLoad(类比 onCreate)→ viewWillAppear → viewDidAppear → viewWillDisappear → viewDidDisappear.
  • UIKit: 命令式 UI (类比 View 体系), 用 Storyboard/XIB 或纯代码.
  • SwiftUI: 声明式 UI (类比 Jetpack Compose), @State/@Binding/@ObservedObject 管理状态, 概念和 Compose 高度相似: 你学了 Compose 就好理解.

最小 iOS 页面, 权限与沙盒认知

以下是上下文片段, 放在 UIKit 的 UIViewController 子类中; 它不是完整 App 工程. viewDidLoad 只表示 View 已加载, 不等于页面已经可见; 网络请求, 埋点和动画应按实际可见性分别放在合适的生命周期节点.

final class ProfileViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // 页面已出现在前台;适合开始需要可见页面的工作.
    }
}

应用级前后台事件由 AppDelegate/SceneDelegate(或 SwiftUI 的 scenePhase) 接收, 不能只依赖单个 ViewController. 权限也不是 “声明后即可访问”:先在 Info.plist 写清用途说明, 再在用户触发操作时请求系统授权, 并处理拒绝, 受限和到设置页恢复后的状态. 沙盒目录按数据语义选择: 用户创建且不可重建的文档放 Documents; 内部持久数据放 Library/Application Support; 可重建缓存放 Library/Caches; 临时文件放 tmp, 后两者均可能被系统清理. 是否备份和是否在 Files 中可见并不会由目录自动保证, 应按业务, 备份排除配置和文件共享配置分别验证. 不要把 token, 密钥或敏感明文放进 UserDefaults, 应使用 Keychain.

ARC 循环引用的可观察例子

以下是可在 Xcode Playground 或 App 工程运行的示例: driver 创建对象, 建立双向连接后将两个外部变量置空. 强引用版不会打印 deinit; 弱引用版预期打印两个 deinit. 该结果只用于说明引用关系, 不代表已在本仓库执行.

final class Owner {
    var child: Child?
    deinit { print("Owner released") }
}

final class Child {
    weak var owner: Owner? // 改成 `var owner: Owner?` 会形成强引用环
    deinit { print("Child released") }
}

func makeAndReleasePair() {
    var owner: Owner? = Owner()
    var child: Child? = Child()
    owner!.child = child
    child!.owner = owner
    owner = nil
    child = nil
}

makeAndReleasePair() // weak 版:预期打印 Owner released,Child released(顺序不作为契约)

将 weak var owner 改为强引用 var owner: Owner? 后再运行, 预期不打印任何 deinit, 因为二者互相持有. 排查泄漏时, 症状可能是退出页面后内存持续增长; 证据是 Xcode Memory Graph 中页面, 闭包或 delegate 仍被持有; 定位强引用边, 再按所有权关系选择 weak 或在任务结束时清理闭包. unowned 的重点是不持有对象; 它适用于生命周期关系确定的场景, 若访问已释放对象会崩溃. 常见写法是非可选 unowned, 但 Swift 也支持 optional unowned, 不能据此把它绝对描述为 “不为 nil”.

本篇只建立 iOS 原生基础对照; 共享 Kotlin 代码的工程配置见 Kotlin Multiplatform, 框架取舍见 跨端技术对比.

四, 并发 GCD

  • GCD(Grand Central Dispatch): iOS 的并发框架, 基于队列.
  • 主队列: DispatchQueue.main, UI 操作 (类比 Android 主线程 / Dispatchers.Main).
  • 全局队列: DispatchQueue.global(qos:), 后台任务 (类比 Dispatchers.IO/Default).
  • 异步 / 同步: async(不阻塞, 类比 launch), sync(阻塞当前).
  • 现代 Swift Concurrency 使用 async/await, Task, actor 和 Sendable. 可与 Kotlin 协程类比建立直觉, 但取消, actor 隔离和错误传播规则不能机械等同.
DispatchQueue.global().async {        // 后台
    let data = loadData()
    DispatchQueue.main.async {         // 回主线程更新 UI
        self.label.text = data
    }
}

五, Android / iOS 概念对照表

概念AndroidiOS
页面ActivityUIViewController
列表RecyclerViewUITableView/UICollectionView
声明式 UIJetpack ComposeSwiftUI
异步主线程切换Handler/协程GCD/async-await
内存管理GC (可达性分析)ARC (引用计数)
空安全Kotlin 可空类型Optional
接口interfaceprotocol
依赖管理GradleCocoaPods/SPM
本地存储SharedPreferences/DataStoreUserDefaults
包格式APK/AABIPA

高频面试题 (iOS 通常只是辅助考察)

Q1: iOS 的内存管理和 Android 有什么不同? iOS 用 ARC, 编译期插入 retain/release 做引用计数, 计数为 0 即释放, 无运行时 GC 线程, 需手动用 weak/unowned 打破循环引用. Android 用 GC 可达性分析在运行时回收, 无需手动管理引用计数.

Q2: weak 和 unowned 区别? 都不增加引用计数. weak 引用对象释放后变 nil (通常声明为可选); unowned 假设对象在引用期间一直存在, 访问已释放对象会崩溃. unowned 常见为非可选, 但 Swift 也支持 optional unowned; 生命周期不确定用 weak, 能严格证明被引用对象活得更久时再用 unowned.

Q3: struct 和 class 区别? struct 提供值语义, 赋值和传参在语义上产生独立值, 但编译器可以用 copy-on-write, 逃逸分析等方式优化; class 提供对象身份和共享引用, 由 ARC 管理. 值语义有助于减少无意共享, 但如果字段包含可变引用或跨并发域传递不满足 Sendable / 隔离规则, 并不会自动线程安全.

Q4: SwiftUI 和 Compose 像吗? 相似. 都是声明式, 状态驱动, UI=f (state), 单向数据流.@State 类比 remember+mutableStateOf,@Binding 类比状态提升. 理解了 Compose 基本能迁移过去.

Q5: 你做过 iOS 吗? 能转吗?(开放题) 诚实说做过部分 iOS, 理解 Swift/UIKit/ARC 基础, 有跨端经验; 强调底层 (C/C++) 和概念是相通的, 跨端学习成本可控. 不要夸大成 “精通”.

Kotlin Multiplatform

KMP 适合 “共享可验证的业务逻辑, 保留平台能力与原生体验”.具体 API 的稳定性不同, 不能把 KMP 整体稳定与某个实验性互操作功能混为一谈.

一, 共享边界

优先共享领域模型, 业务规则, 协议, 序列化, 网络编排和算法. 平台 UI, 权限, 存储, 安全硬件, 后台执行和系统集成通常保留平台实现或建立窄接口.

层共享建议风险
domain/model高公共模型需兼顾 Swift/Objective-C 暴露
data/network中高取消, 异常, 缓存和线程模型要跨端统一
platform capability通过接口适配不把 Context/Keychain/Keystore 泄漏到 commonMain
UI按项目选择原生 UI 与 Compose Multiplatform 的团队成本不同

二, expect / actual 的稳定性

expect/actual 函数和属性可用于平台能力连接, 但 expect/actual classes 截至 2026-08-07 仍为 Beta. 公共 SDK 不应只因为语法方便就大面积暴露 expect/actual class; 优先考虑 common interface + 平台实现注入.

// commonMain
interface DeviceInfoProvider {
    fun deviceModel(): String
}

// androidMain 通过构造注入到 common 代码
class AndroidDeviceInfoProvider : DeviceInfoProvider {
    override fun deviceModel(): String = android.os.Build.MODEL
}

如使用 expect/actual class, 应固定 Kotlin 版本, 记录 Beta 状态并准备迁移边界.

三, Source sets 与依赖

shared
 ├─ commonMain     # domain,协议,Ktor,序列化,算法
 ├─ androidMain    # Android Context,Keystore,Room 适配
 ├─ iosMain        # Keychain,Foundation/平台能力适配
 └─ commonTest     # 共享业务规则测试

库选择要同时评估 Android/iOS 调试, 包体积, 二进制兼容, 构建缓存, 维护状态和团队 owner, 不能只看 “支持 KMP” 标签.

最小工程: 共享平台名

以下是教学骨架, 锁定 AGP 8.x 的经典 androidTarget() 基线; 截至 2026-08-07 此 DSL 仍受支持. 前提是工程已应用与版本匹配的 kotlin("multiplatform") 插件, Android App 已配置 SDK; 版本号由项目的 version catalog 或插件管理统一提供. 未在本仓库执行构建.

Google 的新路线使用 com.android.kotlin.multiplatform.library, 但 DSL 必须随锁定的 AGP 版本核验, 不能把整个 AGP 9.x 概括为 androidLibrary {}: AGP 8.12+ 的新插件使用 kotlin { android { ... } }; androidLibrary {} 在 AGP 9.1 alpha 后已弃用. 迁移新插件时, 按目标版本的 Android KMP 插件文档 与 AGP release notes 选择 DSL; 本文不提供容易随预览版变化而失效的可复制新插件配置.

// shared/build.gradle.kts
kotlin {
    androidTarget()

    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared" // Swift 侧 `import Shared` 使用的 framework 名
            isStatic = true
        }
    }
    sourceSets {
        commonMain.dependencies { }
        androidMain.dependencies { }
        iosMain.dependencies { }
    }
}
// commonMain/kotlin/Platform.kt
expect fun platformName(): String

class Greeting {
    fun greet(): String = "Hello from ${platformName()}"
}

// androidMain/kotlin/Platform.android.kt
actual fun platformName(): String = "Android"

// iosMain/kotlin/Platform.ios.kt
actual fun platformName(): String = "iOS"

Android 可从 Activity 调用 Greeting().greet(). 声明 binaries.framework 后, 才会生成 :<module>:embedAndSignAppleFrameworkForXcode 任务; Xcode 工程需在 Run Script 中调用该任务, 随后才能链接 / 嵌入 framework. 构建生成 framework 并由 Xcode 工程链接后, Swift 侧的调用上下文片段为:

import Shared

let text = Greeting().greet() // 预期为 "Hello from iOS"

若 iOS 无法 import Shared, 先检查 framework 是否已加入 target 的 Frameworks, Search Paths 和 Embed/Sign 配置; 若 actual 缺失, Gradle 会在对应 target 编译时报告 expect/actual 不匹配. 不要在 commonMain 引入 android.* 或 UIKit: 应以接口或 expect/actual 保持边界. 本篇讲 KMP 工程与互操作, 不比较框架优劣; 选型流程见 跨端技术对比.

四, iOS 导出与 Swift 互操作

KMP 可生成 framework / 库供 iOS 使用, 但 Objective-C export, Swift interop 和 Swift export 的能力与稳定性不同.Swift export 截至核验日仍为 Experimental, 不应写成生产环境无条件稳定方案.

  • suspend 可桥接 async/callback, 但要统一取消和错误映射.
  • 不直接把复杂 Flow 类型泄漏给 Swift; 可提供观察接口, AsyncSequence 风格 facade 或显式订阅句柄.
  • 泛型, sealed hierarchy, 异常和默认参数在 Swift 侧不一定自然, 应以实际生成 API 为准.
  • CI 必须构建 iOS framework/Swift 调用 demo, 不能只跑 commonTest.

五, 与 Flutter/React Native 的选择

KMP 的典型价值是共享逻辑并保留平台 UI; Flutter/React Native 更偏统一 UI 技术栈. 选择时使用同一场景比较: 交付速度, UI 一致性, 平台 API 深度, 性能证据, 包体, 调试, 招聘和长期 owner, 而不是给框架做无来源性能排名. 七种方案的横向对比与统一场景 PoC 流程见 见 48.

六, Android SDK 迁移到 KMP

  1. 盘点纯业务, 协议, 算法与 Android 特有能力.
  2. 先抽 common interface 和可测试模型, 再迁移实现.
  3. 平台能力使用小接口注入, 避免 commonMain 出现平台类型.
  4. 为 iOS 提供 Swift 友好 facade, 设计取消, 错误和线程语义.
  5. CI 同时跑 commonTest, Android 测试, iOS framework 构建和最小集成 demo.
  6. 记录包体, 构建时间, 崩溃符号化和版本兼容成本, 分阶段扩大共享范围.

高频面试题

Q1: KMP 适合什么场景?
适合共享规则稳定, 可测试且平台差异可被窄接口隔离的逻辑. 高度依赖平台 UI / 系统能力且团队没有跨端 owner 时要谨慎.

Q2: expect/actual 是否全部稳定?
不能笼统回答. 具体声明形态稳定性不同; 截至 2026-08-07, expect/actual classes 仍为 Beta, 应按 Kotlin 版本核验.

Q3: Swift export 可以直接用于稳定公共 SDK 吗?
截至核验日仍是 Experimental, 应先验证生成 API, 二进制兼容和迁移成本, 不作为无条件承诺.

易错点 / 追问

  • 不说 “一套代码无成本运行所有端”.
  • 不把平台差异强行塞进 commonMain.
  • 不把 Beta/Experimental 功能包装成稳定 API.
  • 不忽略 iOS 调试, 符号化, 构建和团队 ownership.

版本与参考资料

跨端技术对比 ☆

跨端选型不是站队, 而是在业务目标, 团队能力, 体验要求和长期维护成本之间做取舍. 面试中要能把 Flutter, React Native, Compose Multiplatform, WebView Hybrid, 小程序容器, KMP 和原生开发放在同一张决策表里比较.

一, 跨端技术解决的核心问题

跨端技术的目标通常有三类: 降低多端重复开发, 提升发布效率, 统一体验或业务逻辑. 但不同方案共享的层次不同, 不能简单说 “一套代码跑所有端”.

方案主要共享内容UI 渲染典型诉求
FlutterUI + 业务逻辑自绘 Skia/Impeller高一致性 UI, 快速迭代, 多端统一.
React Native / RNUI 描述 + JS 业务逻辑Native 组件桥接/FabricWeb/前端团队复用经验, 保留部分原生体验.
Compose MultiplatformCompose UI + Kotlin 逻辑Compose 渲染Kotlin 技术栈统一, 桌面 / 移动共享 UI.
WebView HybridWeb 页面 + Native 容器能力WebView运营活动, 内容页, 快速发布.
小程序容器小程序 DSL / 运行时容器渲染生态开放, 插件式业务接入.
KMP业务逻辑/数据层/算法原生 UI 或 CMP共享核心逻辑, 保留平台体验.
原生开发少量公共协议/设计规范Android/iOS 原生极致体验, 复杂平台能力, 稳定长期维护.

选型时先问: 要共享的是 UI, 业务逻辑, 数据层, 还是发布能力? 答案不同, 方案就不同.

二, Flutter: 自绘 UI 与高一致性

Flutter 使用 Dart 编写, 通过自绘渲染实现跨平台 UI 一致性. 它的优势是开发体验完整, 热重载, 组件体系统一, 动画和复杂 UI 表达强.

维度Flutter 表现
UI 一致性强, 同一套 Widget 在多端表现接近.
性能大多数业务足够好, 复杂页面要关注首帧, shader, 列表和图片.
原生能力通过 Platform Channel 接入, 复杂能力需要原生桥接.
包体积通常比纯原生更大, 要评估引擎和资源成本.
团队要求需要 Dart/Flutter 经验和原生兜底能力.

Flutter 适合 UI 一致性强, 需要快速多端交付, 团队愿意接受新技术栈的业务. 不适合只想在原生 App 中轻量嵌几页, 或大量依赖复杂原生 SDK 且团队没有桥接能力的场景.

三, React Native / RN: 前端生态与原生桥接

React Native 用 JavaScript/TypeScript 描述 UI 和业务逻辑, 通过桥接或新架构 Fabric/TurboModules 与 Native 交互. 它的优势是前端生态, 动态性和招聘/团队迁移成本.

RN 简化链路:
JS/TS 业务逻辑
  -> React 渲染描述
  -> Fabric/TurboModules/Bridge
  -> Android/iOS Native 组件与模块

RN 的核心取舍是桥接成本和运行时复杂度. 高频 UI 更新, 复杂手势动画, 大量 Native 模块通信都要谨慎设计. 新架构改善了旧 Bridge 的一些瓶颈, 但并不意味着所有性能问题自动消失.

适合场景: 已有 React / 前端团队, 业务 UI 中等复杂, 需要一定动态发布能力, 原生模块边界清晰. 不适合极致动画, 重度图形, 复杂平台底层能力密集的模块.

四, Compose Multiplatform 与 KMP

KMP (Kotlin Multiplatform) 关注共享业务逻辑, Compose Multiplatform (CMP) 进一步尝试共享 UI. 二者都适合 Kotlin 生态团队, 但成熟度和平台覆盖要按项目评估.

技术共享层次优势风险
KMPdomain, data, network, 算法, 平台抽象保留原生 UI, 共享核心逻辑, 适合 Android 团队扩展 iOSiOS 互操作, 构建, 调试, 异常模型需要治理.
Compose MultiplatformCompose UI + Kotlin 逻辑Kotlin/Compose 技术栈统一, 适合内部工具/桌面/部分移动场景移动端生态和复杂平台 UI 适配仍需评估.

KMP 的成熟回答是: 它不是替代 Flutter/RN 的 “统一 UI” 方案, 而是 “共享核心逻辑 + 平台 UI 自治”.如果团队最痛的是重复写接口, 模型, 业务规则和算法, KMP 很合适; 如果最痛的是两端 UI 开发成本, 则要考虑 Flutter, RN 或 CMP. KMP 工程实现, expect/actual 稳定性与 iOS 互操作细节见 见 47.

五, WebView Hybrid 与小程序容器

WebView Hybrid 用 WebView 承载页面并由 Native 容器补足体验; 小程序容器是更完整的运行时, 用统一 DSL, 组件, 权限模型和包管理承载第三方或内部轻应用. 两者选型对比见下表.

维度WebView Hybrid小程序容器
发布效率高高, 且更标准化.
体验受 WebView 和前端性能影响比普通 H5 更受控, 但仍受容器能力限制.
Native 能力JSBridge 按需开放权限模型和 API 网关更体系化.
适合场景活动, 内容, 表单, 轻业务平台生态, 商家 / 插件接入, 可治理轻应用.
风险白屏, Bridge 安全, 缓存错配容器研发成本, 标准制定和生态治理.

面试时可强调 Hybrid 和小程序容器都是 “受控运行时”, 核心不只是加载页面; 离线包, 预加载, 权限, 监控, 灰度, 回滚和安全边界等容器模块的实现细节见 见 49.

六, 原生开发仍然不可替代的场景

原生开发成本高, 但在很多场景仍是最稳的选择:

  1. 高性能图形, 音视频, 游戏, 实时通信, 复杂动画.
  2. 深度系统能力: 相机, 传感器, 蓝牙, 支付, 安全, 风控 SDK, NDK.
  3. 对启动, 内存, 包体积, 稳定性有极致要求的核心链路.
  4. 平台差异很大, 强行跨端会制造大量条件分支.
  5. 长期维护团队以 Android/iOS 为主, 跨端技术栈缺少 owner.

原生不是 “落后”, 跨端也不是 “银弹”.成熟团队常见组合是: 核心链路原生, 活动和内容 Hybrid, 业务逻辑部分 KMP, 独立新业务可尝试 Flutter/RN.

七, 选型决策框架

选型可以用 “业务, 体验, 团队, 工程, 风险” 五维评估.

维度关键问题倾向方案
业务形态内容/活动/表单还是复杂交易/实时交互?轻业务 Hybrid/小程序; 核心交易原生/KMP.
UI 一致性是否要求多端像素级一致?强一致 Flutter; 平台体验优先原生 / KMP.
发布效率是否需要高频动态发布?Hybrid/小程序/RN; 高风险逻辑仍需原生发版.
团队能力团队熟悉前端, Kotlin 还是原生?React 团队 RN; Kotlin 团队 KMP/CMP; 原生团队原生.
原生能力是否大量调用平台 SDK/NDK/安全能力?原生或 KMP 共享逻辑, 跨端 UI 慎选.
长期成本是否有容器, 监控, 桥接, 灰度 owner?没有治理能力时不要贸然引入重跨端.

一个可用于面试的简洁结论: 如果要统一 UI, 看 Flutter/RN/CMP; 如果要共享业务逻辑, 看 KMP; 如果要快速发布轻业务, 看 Hybrid/小程序; 如果要极致体验和复杂平台能力, 坚持原生.

八, 2025/2026 跨端选型必须补的平台约束

跨端方案最终仍运行在 Android/iOS 平台上, 新系统限制和商店政策不能被框架 “屏蔽”.

约束对跨端方案的影响面试表达
Edge-to-edge / WindowInsetsFlutter/RN/Hybrid 都要处理系统栏, 键盘, 刘海不能只看页面能显示, 要看不同系统版本和设备形态
Predictive Back自定义导航栈必须和系统返回手势一致跨端路由要接入原生 back 分发
Privacy Sandbox / SDK Runtime广告, 统计, 风控 SDK 能力受限SDK 合规和数据边界要前置设计
Credential Manager / Passkeys登录体验趋向系统级凭证跨端登录页也要保留原生能力扩展点
Play Integrity / App Access Risk安全信号来自平台和服务端客户端检测只是信号, 不能本地定生死
折叠屏/大屏/XRUI 不能只按手机竖屏设计需要 adaptive layout 和平台特性兜底

为何风控 / 指纹 SDK 默认原生: 设备指纹的本质是采集设备深层系统信号 (系统属性, 传感器, 图形栈, 文件系统特征等), WebView / 小程序 / 跨端运行时对这些信号访问受限, 拿到的字段同质化高, 熵低, 易被模拟器 / 群控批量伪造; 原生也便于做 native 层加固, 混淆与完整性自检, 且对包体和启动性能更敏感. 因此指纹采集与设备信任判定一般走原生或 KMP 共享算法, 跨端只承载展示层.

选型模板可以升级为: 需求 → 平台约束 → 跨端架构 → 原生能力边界 → 灰度/回滚 → 指标验证. 这样比单纯比较 Flutter/RN/KMP 更像中高级回答.


九, 用统一场景和指标选型

不要给 Flutter, React Native, KMP/CMP, Hybrid 或原生做无来源的绝对性能排名. 选型 PoC 应固定同一业务场景, 设备和版本, 比较:

  • 首屏 / 交互时延, 帧时间分布, 内存峰值, 包体和网络成本.
  • 原生能力覆盖, 无障碍, 后台 / 生命周期, 调试和崩溃归因.
  • iOS/Android 共享比例, 平台特化代码, 升级兼容和依赖维护.
  • 招聘与培训, CI 设备矩阵, 发布节奏和长期 owner 成本.

结论必须保留框架 / 编译器版本, 设备, 构建模式和样本. 团队熟悉度与现有代码资产往往比微基准差异更影响总成本.

同一场景 PoC 操作清单

以下是 PoC 操作清单, 不是框架性能结论. 例如固定实现 “带登录态的商品列表: 冷启动进入列表, 滚动 30 秒, 打开详情, 调用一次相机上传入口”, 分别用候选方案完成同样的接口, 图片, 埋点和错误页. KMP 方案只共享模型和规则, Android/iOS 页面仍原生实现; 不要在本篇重复其实现细节, 参见 Kotlin Multiplatform.

  1. 固定 “冷启动” 为进程不存在后从 launcher 进入首屏,“热启动” 为进程仍在但从后台回到同一首屏; 固定设备型号, 系统版本, 网络条件, 服务端数据, 候选框架/编译器版本和 release/debug 构建模式, 并记录在结果表.
  2. 每个方案先按同一流程预热, 再采集至少 30 个冷启动和 30 个热启动样本; 重复列表滚动, 详情返回和弱网失败流程. Android 用 Macrobenchmark 采集启动/帧指标, 并以 Perfetto 的 FrameTimeline 定位掉帧; iOS 用 XCTest 的 signpost 度量并在 Instruments 中复核. 记录 P50/P95, 内存峰值, 崩溃/异常与包体增量.
  3. 人工验收登录恢复, 无障碍, 横竖屏 / 大屏, 返回手势, 相机权限拒绝, 后台返回和离线错误页, 记录 “能否实现” 及平台特化代码量.
  4. 记录构建耗时, 调试 / 符号化体验, 依赖升级, 原生 SDK 接入以及开发者完成同一改动的时间; 这些是成本证据而非个人感受.
  5. 以业务权重评审结果: 核心交易优先稳定性和原生能力, 内容活动优先交付与回滚, 明确 owner, 灰度和退出方案.
方案与版本设备/系统/构建冷启动 P50/P95热启动 P50/P95滚动帧 P50/P95/掉帧内存峰值包体增量备注与原始证据
待测待填待填待填待填待填待填Perfetto/Instruments trace 链接或编号

典型失败是只在一台高端设备跑一次列表后宣称 “更快”.症状是线上低端机或弱网体验与 PoC 结论相反; 证据是缺失设备/网络/构建模式和分位数数据; 修复是补齐矩阵并保留原始测量; 验证是由另一位成员按记录复现实验. 该流程不虚构任何测量结果.

高频面试题

Q1: Flutter 和 RN 最大区别是什么? Flutter 更偏自绘 UI, 用 Dart 和自己的 Widget/渲染体系保证多端一致性; RN 用 JS/TS 和 React 思想描述 UI, 更多依赖 Native 组件和桥接. Flutter UI 一致性强, RN 更容易复用前端生态, 二者都需要原生能力兜底.

Q2: KMP 和 Flutter 怎么选? KMP 适合共享业务逻辑, 数据层, 协议和算法, 同时保留 Android/iOS 原生 UI; Flutter 适合统一 UI 和多端一致体验. 如果团队最痛是重复写业务逻辑, 选 KMP; 如果最痛是多端 UI 重复开发, 再评估 Flutter.

Q3: Hybrid 为什么适合活动页但不适合所有核心链路? Hybrid 发布快, 成本低, 适合内容, 活动, 表单和低复杂交互. 但它受 WebView 性能, 白屏, Bridge 安全, 离线包一致性影响, 对强性能, 强安全, 复杂原生能力的核心链路要谨慎.

Q4: Compose Multiplatform 的定位是什么? 它尝试用 Compose/Kotlin 共享 UI 和逻辑, 对 Kotlin 团队有吸引力. 当前选型要看目标平台, 组件生态, 调试体验和团队经验, 不能简单等同于成熟 Flutter 替代品.

Q5: 跨端选型最重要看什么? 先看共享目标是 UI, 业务逻辑还是发布能力; 再看体验要求, 原生能力复杂度, 团队技术栈, 治理能力和长期维护成本. 不要为了技术流行牺牲核心链路稳定性.

Q6: 跨端框架能不能绕过 Android 新版本适配? 不能. edge-to-edge, predictive back, 通知 / 媒体权限, 前台服务, 隐私 SDK 政策最终都落到宿主 App. 跨端层可以封装体验, 但原生壳, 插件和 SDK 仍要按 targetSdk 和商店政策适配.

Q7: 你们风控 SDK 为什么不用跨端方案? 设备指纹 / 风控 SDK 的核心价值在采集端侧深层信号并被服务端判定; 跨端运行时访问系统信号受限, 字段同质化高, 加固 / 混淆与 native 完整性自检较弱, 且对包体和启动敏感. 因此采集与设备信任判定走原生 (或 KMP 共享算法), 跨端仅承载展示层; 被问 “能否用 RN/Flutter 重写” 时, 先澄清共享边界, 而不是直接换语言.

易错点 / 追问

  • 不要说跨端一定省成本: 桥接, 容器, 监控, 兼容性和人才成本都要算进去.
  • 不要把 KMP 说成 “一套 UI 跑所有端”; 它更典型的价值是共享业务逻辑.
  • 不要忽略原生兜底能力: Flutter/RN/Hybrid 都会遇到平台 SDK, 权限, 性能和稳定性问题.
  • 追问 “为什么不用全 Hybrid”:核心链路体验, 白屏风险, Bridge 安全和复杂原生能力会限制它.
  • 追问 “团队怎么渐进式引入”:先选低风险模块试点, 建立监控, 桥接规范和回滚机制, 再扩大范围.

WebView 与 Hybrid ☆

Hybrid 的本质是 “用 Web 的发布效率承载部分业务, 用 Native 的能力兜住体验和安全”. 面试时不要只会说 JSBridge, 要能讲生命周期, 缓存, 白屏排查, 权限, 下载, 离线包和容器治理.

一, WebView 生命周期与基础配置

WebView 是一个重量级组件, 既有 View 生命周期, 也有页面加载, 渲染进程, JS 执行和网络缓存状态. Activity/Fragment 中使用 WebView 时, 要把生命周期显式转发并在销毁时释放资源.

阶段常见处理原因
创建统一 WebSettings, UA, Cookie, WebViewClient, WebChromeClient避免各业务页配置不一致.
onResumewebView.onResume(), 恢复 JS timer防止后台回来后页面定时器异常.
onPausewebView.onPause(), 暂停音视频 / 动画降低后台耗电和资源占用.
onDestroy从父容器移除, 停止加载, 清空 client, destroy()避免持有 Activity 导致泄漏.
override fun onDestroy() {
    (webView.parent as? ViewGroup)?.removeView(webView)
    webView.stopLoading()
    webView.webChromeClient = null
    webView.webViewClient = null
    webView.destroy()
    super.onDestroy()
}

基础配置要遵循 “最小权限” 原则: 不需要 JS 就不开启 JavaScript; 不需要文件访问就关闭 file/content 访问; 混合内容, 第三方 Cookie, 调试开关都要按环境和业务域名控制.

二, JSBridge 设计与 addJavascriptInterface 风险

JSBridge 负责 JS 与 Native 双向通信. 常见模式包括 addJavascriptInterface 注入对象, URL scheme 拦截, postMessage/evaluateJavascript 回调.

方案原理优点风险 / 限制
addJavascriptInterface注入 Java 对象给 JS 调用使用简单, 同步语义直观低版本历史安全风险; 暴露面大; 必须控制域名和方法.
URL schemeJS 跳转自定义 URL, Native 拦截解析兼容性好, 隔离清晰参数长度, 编码, 异步回调复杂.
evaluateJavascriptNative 执行 JS 字符串回传结果适合 Native 主动通知需要主线程; 要处理转义和页面状态.
WebMessage标准消息通道边界更清晰版本兼容和封装成本.

addJavascriptInterface 的核心风险是把 Native 能力暴露给不可信页面. 安全设计要做到:

  1. 只允许可信 HTTPS 域名使用 Bridge, 页面跳转后重新校验 origin.
  2. Bridge 方法白名单化, 参数做类型, 长度, 业务权限校验.
  3. 不暴露通用反射, 文件, 命令, 账号 token 等高危能力.
  4. Debug 包和 Release 包区分 WebView 调试能力.
  5. 所有敏感调用写审计日志, 便于定位异常页面行为.

面试表达边界: 只讲风险, 隔离和校验原则, 不要给出绕过或攻击脚本.

WebView 缓存涉及 HTTP cache, Service Worker, DOM Storage, IndexedDB, Cookie 和 App 自己的离线包. 面试中要区分 “浏览器内建缓存” 和 “Hybrid 容器离线包”.

缓存类型适用内容控制点
HTTP CacheJS/CSS/图片等静态资源服务端 Cache-Control, ETag, 版本号.
DOM Storage / IndexedDB页面本地数据容量, 清理策略, 隐私合规.
Cookie登录态, 会话SameSite, Secure, HttpOnly, 同步时机.
离线包H5 应用资源包包签名, 版本, 灰度, 回滚, 增量更新.

离线包通常在 App 启动或空闲时下载, 校验签名和摘要后解压到私有目录; 页面加载时通过 URL 映射或请求拦截优先命中本地资源, 未命中再走网络. 这样能提升首屏速度和弱网可用性, 但要避免缓存污染, 版本错配和敏感数据落盘.

最小安全 JSBridge 往返链路

以下是上下文片段, 不是可直接复制到生产的完整容器. 前提是页面已通过 WebViewClient 校验为受信 HTTPS origin, 且每次导航完成后重新评估资格. addJavascriptInterface 注入的对象会暴露给 WebView 中所有 frame, 不能可靠地识别调用 frame 的 origin; 禁止不可信 iframe 和 mixed content, 并结合 CSP 与导航控制限制页面来源. 需要 origin-aware 通道时, 优先使用 AndroidX WebKit 的 WebViewCompat.addWebMessageListener 和 allowedOriginRules.

class ProfileBridge(private val isTrustedPage: () -> Boolean) {
    @JavascriptInterface
    fun requestProfile(messageJson: String): String {
        fun error(code: String) = org.json.JSONObject().put("ok", false).put("error", code).toString()
        if (!isTrustedPage()) return error("untrusted_page")
        val requestId = runCatching {
            org.json.JSONObject(messageJson).getString("requestId")
        }.getOrElse { return error("bad_request") }
        if (requestId.length !in 1..64) return error("bad_request")
        return org.json.JSONObject()
            .put("ok", true)
            .put("requestId", requestId)
            .put("name", "guest")
            .toString()
    }
}

webView.settings.javaScriptEnabled = true // 仅可信业务确有需要时开启
webView.addJavascriptInterface(ProfileBridge(::isTrustedPage), "AndroidBridge")

对应页面的上下文片段应在 Native 回调前先注册处理器:

window.NativeBridgeCallbacks = {
  onProfile(result) { console.log(result.requestId, result.name); }
};
const result = JSON.parse(
  window.AndroidBridge.requestProfile(JSON.stringify({ requestId: "req-42" }))
);
window.NativeBridgeCallbacks.onProfile(result);

完整往返使用两个固定, 职责分离的顶层 namespace: 可信页面先注册回调接收端 window.NativeBridgeCallbacks → JS 将结构化请求 JSON 交给 Native 注入的请求对象 window.AndroidBridge.requestProfile → Native 校验导航资格, 参数, 登录态和用户授权并序列化响应 JSON → JS 解析响应后调用 window.NativeBridgeCallbacks.onProfile. 两者不是同一 namespace: AndroidBridge 是 Native 注入, 由 JS 发起请求的入口; NativeBridgeCallbacks 是页面自行创建, 供 JS 分发响应或 Native 异步通知的接收端. 若 Native 必须异步推送, 应通过结构化 WebMessage, 或将 JSON 先作为 evaluateJavascript 的单一字符串参数交给固定接收函数, 不能把不可信值手拼到可执行脚本中; 序列化和转义必须由 JSON 库完成. 页面刷新, 跳转或渲染进程死亡时回调可能丢失, 故生产协议需要超时, 取消和页面会话 ID; 敏感结果不应通过 Bridge 返回 token. 示例中的固定 profile 仅解释调用链, 不表示已验证.

Remote debugging 仅在 debug 构建中启用 WebView.setWebContentsDebuggingEnabled(true); 设备通过 USB 连接并完成 ADB 授权后, 确认桌面 Chrome 与设备 WebView 版本兼容, 再访问 chrome://inspect 选择目标 WebView. Release 构建必须禁止该开关. 离线包监控至少记录 “配置期望版本, 已下载并验签版本, 当前激活版本, 请求命中版本, 回滚原因”, 这样资源 404 或 JS 与 native 协议不匹配时才能区分网络, 包版本和容器问题.

四, 白屏诊断与性能优化

WebView 白屏通常不是单一原因, 要按 “容器, 网络, 资源, JS, 渲染, 业务” 分层排查.

白屏排查路径:
1. 容器是否创建成功:WebView 初始化,渲染进程,内核版本,硬件加速.
2. URL 是否正确:重定向,scheme,证书,DNS,代理,HTTP 状态码.
3. 资源是否可用:HTML/JS/CSS 是否 200,离线包是否命中正确版本.
4. JS 是否异常:console error,bridge 超时,Promise rejection,入口脚本未执行.
5. 首屏是否被阻塞:大 JS,同步 Bridge,主线程长任务,图片过大.
6. 业务状态是否异常:登录态丢失,接口失败,AB 配置错误.

性能优化重点包括: WebView 预创建/预热, DNS/连接预取, 离线包, 骨架屏, 关键资源内联, 减少同步 JSBridge, 控制首屏 JS 体积, 监控 FCP/LCP/JS error/bridge timeout. 预加载要有池大小和生命周期控制, 否则会用内存换速度并引入泄漏.

五, 文件选择, 权限, 下载与上传

Hybrid 容器经常承接上传头像, 拍照, 选择文件, 下载附件等能力, 这些能力要通过 WebChromeClient, 权限回调和下载监听统一封装.

  • 文件选择: 实现 onShowFileChooser, 根据 accept type 决定相册, 文件, 相机入口, 并处理 Activity Result.
  • 权限申请: 摄像头, 麦克风, 定位等要结合 Android runtime permission 和 WebChromeClient 的 permission request, 展示清晰授权说明.
  • 下载处理: 通过 DownloadListener 或拦截响应交给系统 DownloadManager / 业务下载器, 校验文件类型, 大小和来源.
  • 上传安全: 限制可上传文件类型和大小, 避免把私有路径或敏感文件暴露给页面.
  • 兼容性: Android 版本, 厂商文件选择器, Scoped Storage 都会影响 Uri 读取.

权限和文件能力是容器的高风险边界, 要按业务域名, 用户授权, 最小能力和审计日志来治理.

六, URL 拦截, 路由与安全边界

Hybrid 容器常通过 URL 拦截承接 App 内跳转, 登录, 支付, 分享和资源替换. 设计时要明确哪些 URL 交给 WebView, 哪些交给 Native Router, 哪些直接拒绝.

拦截点用途注意事项
shouldOverrideUrlLoading页面导航, scheme 跳转不要误拦截普通 http (s) 导航; 校验来源.
shouldInterceptRequest资源替换, 离线包命中不要阻塞过久; 注意 MIME, 编码和缓存头.
onReceivedSslError证书错误处理生产环境不要无条件 proceed, 应失败并上报.
onRenderProcessGone渲染进程崩溃恢复展示降级页, 清理旧 WebView 并重建; 若进程反复崩溃, 降级为错误页并记录崩溃现场.

路由设计建议: 业务页面使用 HTTPS URL 作为稳定入口; Native 私有能力使用明确 scheme 和白名单; 参数签名或一次性 token 用于高价值动作; 所有外跳都经过风险校验和用户确认.

七, Hybrid 容器设计: 离线包, 预加载与治理

一个可维护的 Hybrid 容器通常包含以下模块:

  1. 页面配置中心: 域名白名单, Bridge 权限, 离线包版本, 降级策略.
  2. 离线包管理: 下载, 签名校验, 解压, 版本切换, 失败回滚.
  3. Bridge 框架: 方法注册, 权限校验, 异步回调, 超时, 日志.
  4. 预加载池: WebView 预创建, 常用页面预取, 内存上限, 生命周期清理.
  5. 监控系统: 白屏率, 首屏耗时, JS error, 资源失败, Bridge 成功率.
  6. 安全合规: 隐私协议, 权限最小化, 敏感 API 审计, 调试开关隔离.

成熟回答要强调 Hybrid 不是 “把网页塞进 App”, 而是一个受控运行时: 发布快, 但必须用容器能力把安全, 性能, 权限和回滚治理起来.


八, WebView 安全基线与更新策略

  • 启用并监控 Safe Browsing; 仅在明确兼容问题下按受控范围调整.
  • 默认禁止 mixed content, 不为兼容 HTTP 资源全局放开; 页面和子资源统一 HTTPS.
  • 关闭不需要的文件 / 内容访问能力, 谨慎处理 file://, 自定义 scheme 和外部 Intent.
  • addJavascriptInterface 对所有 frame 暴露对象, 不能据此可靠识别调用 frame origin; 禁止不可信 iframe/mixed content, 结合 CSP, 可信导航控制与最小方法集. 需要 origin-aware 消息通道时优先 WebViewCompat.addWebMessageListener + allowedOriginRules, 并校验消息 schema, 调用频率和登录态.
  • 下载, 上传, 相机, 定位, 麦克风和剪贴板必须走明确用户交互与 Android 权限治理.
  • WebView/Chromium 能力随系统组件更新; 维护最低支持版本, 灰度开关, 崩溃回退和离线包签名/版本策略.

九, WebView 内的指纹 / 风控风险

WebView 内运行 JS 采集到的指纹 / 风控信号 (Canvas, UA, WebGL, 时区等) 本质不可信: 页面 JS 环境可被注入 / hook, 信号可被批量伪造或篡改, 无法区分真机与模拟 WebView 环境. 端侧检测应落在 native 层, 关键路径用 native 采集并做完整性自检; 检测 WebView 篡改可做整包签名校验与关键流程 native 化, 不能把 WebView 内 JS 的采集结果当设备信任依据. Canvas / UA 等信号仅作辅助特征, 单独使用识别率低且易漂移.

高频面试题

Q1: JSBridge 常见实现方式有哪些? 常见有 addJavascriptInterface, URL scheme 拦截, evaluateJavascript 和 WebMessage. 回答时要说明同步 / 异步, 兼容性和安全边界, 尤其是 Bridge 方法白名单, 域名校验和参数校验.

Q2: addJavascriptInterface 有什么风险? 怎么规避? 它会把 Java 对象暴露给 JS, 如果页面不可信或方法设计过宽, 可能让网页调用敏感 Native 能力. 规避方式是只给可信 HTTPS 域名开放, 方法白名单, 参数校验, 最小权限, Release 关闭调试, 敏感调用审计.

Q3: WebView 白屏怎么排查? 按容器初始化, URL / 网络, 资源加载, JS 异常, 渲染进程, 业务接口和登录态分层排查. 关键监控包括 HTTP 状态, 资源失败, console error, Bridge 超时, 首屏指标和 onRenderProcessGone.

Q4: 离线包怎么设计? 服务端下发版本和资源包, App 下载后做签名 / 摘要校验, 解压到私有目录; 加载时通过 URL 映射或请求拦截优先读取本地资源, 失败再降级网络. 必须支持灰度, 回滚, 过期清理和版本兼容.

Q5: Hybrid 容器如何处理文件上传和权限? 通过 onShowFileChooser, Activity Result, runtime permission 和 WebChromeClient 权限回调统一封装. 要限制域名, 文件类型, 大小和敏感路径, 并给用户清晰授权说明.

易错点 / 追问

  • 不要在生产环境对 onReceivedSslError 无条件 proceed, 证书错误应失败, 降级并上报.
  • 不要把所有 Native 能力都挂到 JSBridge; Bridge 是权限边界, 不是工具箱.
  • 不要只靠 WebView 缓存做 “离线包”; 离线包需要签名, 版本, 灰度和回滚.
  • 追问 “预加载越多越好吗”:不是, WebView 很重, 要用池大小, 内存阈值和页面优先级控制.
  • 追问 “页面跳转后 Bridge 权限是否还有效”:需要重新校验当前 URL/origin, 不能只在首次加载时校验.

插件化 / 热修复 / 动态化 ☆

动态化不是 “炫技”, 而是为发布效率, 风险控制和业务扩展服务. 面试中要把 ClassLoader, 资源加载, 组件代理和热修复边界讲清楚: 能解释原理, 也能说明为什么今天大多数团队会更谨慎地使用它.

一, ClassLoader 与 Android 类加载

Android 运行时通过 ClassLoader 加载 DEX 中的类. 理解插件化和热修复, 先要理解 “类从哪里来, 按什么顺序找, 找到后能不能替换”.

ClassLoader典型来源主要用途面试要点
PathClassLoader安装包内的 apk/dex/so 路径加载宿主 App 正常代码系统默认用于已安装应用, 通常不直接加载外部 dex.
DexClassLoader指定 dex/jar/apk 路径和优化目录插件, 动态下发代码, 部分 SDK 容器可加载外部 dex, 但要关注安全校验, 兼容性和启动性能.
BaseDexClassLoader二者共同父类维护 DexPathList 与 dexElements热修复常围绕 dexElements 顺序做文章.

Android 的类查找大致是 “父加载器优先 + 当前 DexPathList 顺序查找”.补丁方案常把修复 dex 插到 dexElements 前面, 让同名类优先命中补丁版本. 但这并不等于所有代码都能无条件替换: 已经加载过的类不能简单卸载重载, 类结构变化, 反射/JNI/混淆都可能引入风险.

宿主 PathClassLoader
  └── DexPathList
        ├── patch.dex       # 热修复:排在前面,优先找修复类
        ├── classes.dex
        └── classes2.dex

插件 DexClassLoader
  └── plugin.apk/classes.dex # 插件:独立路径,通常配合代理/容器运行

二, DexClassLoader / PathClassLoader 的工程边界

PathClassLoader 更适合 “安装时已确定” 的代码, DexClassLoader 更适合 “运行时确定” 的插件或动态模块. 真正工程落地时, 重点不是能不能 load, 而是如何保证可控:

  1. 来源可信: 动态包必须做签名, 摘要, 版本和灰度校验, 不能把任意外部文件交给 DexClassLoader.
  2. 依赖隔离: 插件依赖和宿主依赖可能类名冲突, 要约定公共 API 层, 避免插件直接依赖宿主内部实现.
  3. 生命周期控制: 插件类, 线程, 单例和缓存要能随插件卸载或宿主退出释放, 否则容易泄漏 Activity/Context.
  4. 性能控制: 首次加载 dex 会有校验和优化成本, 要结合预下载, 空闲预热, 灰度开关.
  5. 兼容性控制: Android 版本, 厂商 ROM, hidden API 限制会影响反射修改内部结构的稳定性.

hidden API 具体影响: 热修复用反射改 dexElements 属访问非 SDK 接口, 自 Android 9 (API 28) 起系统按名单限制反射/JNI 调用, 并随 targetSdk 收紧 (至今仍生效); 热修复框架需按目标 SDK 调整, 或改用公开/官方接口, 否则部分版本会抛 NoSuchMethodError 或受限.

三, 资源加载与 AssetManager

插件不仅有代码, 还有 layout, drawable, string 等资源. 插件化常见做法是通过反射或公开能力创建新的 AssetManager, 把插件 apk 路径加入资源搜索路径, 再构造 Resources 对象供插件使用.

问题常见方案风险点
插件资源 ID 与宿主冲突插件独立编译, 运行时用插件 Resources 查找不能把宿主 R.xxx 和插件 R.xxx 混用.
主题 / 样式不生效用插件 Context 包装 Resources 和 ThemeActivity/Window 主题链要处理完整.
资源更新后缓存旧值插件版本化路径 + 清理旧 Resources 引用全局 Drawable/Bitmap 缓存可能持有旧资源.
多语言 / 深色模式同步宿主 Configuration配置变化时要通知插件刷新.

面试回答资源加载时, 可以用一句话概括: 代码靠 ClassLoader, 资源靠 AssetManager/Resources, 四大组件靠代理或 Hook, 三者都要有版本, 隔离和回滚机制.

四, Activity 插件化与组件代理

Android 四大组件需要在 Manifest 中声明, 而插件 Activity 往往没有安装到系统. 传统插件化会通过 “坑位 Activity + 代理分发” 解决这个问题.

  1. 宿主 Manifest 预先声明一个或多个 ProxyActivity.
  2. 启动插件页面时, 实际启动 ProxyActivity, 并在 Intent 中携带插件 Activity 类名.
  3. ProxyActivity 创建插件实例, 把生命周期, Context, Window, 资源访问转发给插件.
  4. 插件页面只实现约定接口或继承插件基类, 不直接暴露给系统 AMS.

这种方式的难点在于生命周期和系统能力不是简单函数转发: 启动模式, onActivityResult, 权限回调, Fragment, 主题, 横竖屏, 进程恢复都要适配. 更激进的方案会 Hook Instrumentation / AMS 相关路径, 但面试中应强调维护成本和系统版本风险, 不要把 Hook 当默认答案.

三个机制骨架: 只用于理解, 不可直接上线

以下均为伪代码 / 上下文片段, 用于解释传统动态化的机制, 未声明为可编译, 可在所有 Android 版本运行, 也不应用于绕过商店审核或分发未受控代码. dexElements 与 AssetManager 的相关反射属于实现细节, 可能受 hidden API, ROM 和版本限制; 生产优先选择官方动态特性, 配置化或受控容器.

// 伪代码:校验过的 patch dex 必须在任意 ClassLoader 操作之前处理.
verifySignatureAndHostVersion(patchFile)
val hostElements = reflectDexElements(hostClassLoader)
val patchElements = makeDexElements(patchFile) // 平台私有实现,可能失败
setDexElements(hostClassLoader, patchElements + hostElements)
// 已加载的类不会因数组顺序变化而重新定义;通常要求冷启动.
// 伪代码:插件资源与宿主资源分开查询,不能混用两者的 R.id.
val pluginAssets = AssetManager::class.java.newInstance()
invokeAddAssetPath(pluginAssets, verifiedPluginApkPath)
val pluginResources = Resources(pluginAssets, hostMetrics, hostConfiguration)
// 上下文片段:宿主 Manifest 中已注册的 ProxyActivity 才是系统可启动组件.
class ProxyActivity : Activity() {
    override fun onCreate(state: Bundle?) {
        super.onCreate(state)
        val entry = intent.getStringExtra("plugin_entry") ?: return finish()
        val page = verifiedPluginRegistry.create(entry) ?: return finish()
        page.attach(this) // 受限宿主接口,而非把所有 Activity 能力裸露给插件
        page.onCreate(state)
    }
}

一个完整的安全闭环是: 受控流水线产出制品并签名 → 客户端下载到私有目录, 校验来源/签名/摘要/宿主版本/ABI/过期时间 → 原子激活候选版本 → 下次冷启动加载 → 监测启动, Crash/ANR 和业务成功率 → 阈值异常时禁用候选版本并回滚到上一个已知稳定版本. 不要在同一次启动中反复尝试坏补丁; 启动保护应先读取本地失败计数, 超过阈值直接跳过加载并上报诊断信息.

五, Tinker / Robust 热修复原理

热修复主要分两类: 类级别补丁和方法级别补丁.

方案核心思路优点限制
Tinker 类方案生成差分补丁, 合成补丁 dex, 让补丁类优先加载覆盖面较广, 可修复 Java/Kotlin 逻辑和部分资源/so通常需要重启生效; 已加载类, 四大组件声明变化受限.
Robust 方法方案编译期给方法插入跳转逻辑, 运行时分发到补丁实现可做到较快生效, 方法粒度明确插桩有性能 / 包体积成本; 未插桩代码无法修.
Instant Run / Apply Changes 类方案开发期快速替换代码提升开发效率不是线上热修复方案.

Tinker 的关键是 “补丁包校验 + dex 合成 / 加载 + 类加载顺序调整 + 重启后生效”.Robust 的关键是 “编译期埋好可替换入口, 运行时判断是否走补丁”.二者都不是万能药, 都要配合灰度, 监控和回滚.

六, 热修复限制与上线治理

热修复最容易被问到 “哪些不能修”.可以按下面维度回答:

  • 类已加载: 同名类已被加载后, 不能简单用新 dex 覆盖当前 Class 对象.
  • 结构变化: 字段, 方法签名, 继承关系变化可能影响反射, 序列化, 混淆和运行时验证.
  • Manifest 变化: 新增 Activity/Service/Provider, 权限, 进程等系统声明通常不能靠普通补丁完整解决.
  • native 变化: so 替换涉及 ABI, 加载时机, 符号兼容, 比 Java 补丁更需要谨慎.
  • 合规与商店政策: 动态下发代码必须符合平台政策和企业安全要求, 不能绕过审核分发高风险逻辑.
  • 风控信任冲突: 风控是最高信任边界, 动态下发代码天然被风控引擎与商店视为可疑; 需对补丁来源白名单化, 签名校验并留审计日志, 否则易被风控策略误杀.

工程治理上要做到: 补丁签名校验, 版本匹配, 灰度发布, 崩溃监控, 失败回滚, 补丁过期清理, 并在下个正式版本合入源码, 避免长期依赖补丁堆叠.

七, 动态容器与现代替代方案

今天的动态化更多会走 “容器化 + 配置化 + 服务端控制” 的路线, 而不是无限扩张传统插件化.

方向适合场景取舍
插件化容器大型 App 业务模块隔离, SDK 扩展能力强, 但维护成本高, 兼容性风险大.
Hybrid / 小程序容器活动页, 运营页, 轻业务发布快, 但体验和原生能力受容器限制.
Server Driven UI表单, 配置页, 低交互页面安全可控, 但复杂 UI 表达能力有限.
Play Feature Delivery / 动态特性海外 Google Play 场景官方能力更稳, 但依赖分发渠道.
远程配置 / AB 实验策略, 开关, 文案, 流程风险最低, 但不能替换任意代码.

面试中的成熟表达是: 动态化要根据业务价值选择最小可行方案. 能用配置解决就不要下发代码; 必须下发代码时, 先设计安全校验, 兼容性矩阵, 灰度回滚和观测体系.


八, 技术可行性不等于允许上线

动态加载, 补丁和 native 替换首先要核对目标应用商店, 企业分发和地区政策. 即使技术上可行, 也不能绕过审核改变高风险业务, 权限或安全行为.

制品必须来自受控流水线, 固定宿主/插件/ABI/签名兼容关系, 校验签名, 摘要, 来源, 版本和过期时间. 下载, 存储, 解压, 加载都要防路径穿越和替换攻击. 灰度监控覆盖启动, Crash/ANR, 业务成功率和回滚成功率; 启动失败计数达到阈值后自动禁用补丁并回到已知稳定版本.

高频面试题

Q1: PathClassLoader 和 DexClassLoader 有什么区别? PathClassLoader 主要加载已安装 APK 内的 dex/so, 是宿主默认类加载器; DexClassLoader 可以指定外部 dex/jar/apk 路径, 常用于插件或动态代码. 面试重点是说明二者都继承 BaseDexClassLoader, 内部通过 DexPathList 查找类, 动态加载一定要做来源校验和版本控制.

Q2: 热修复为什么常说 “补丁 dex 要排在前面”? 类查找按 DexPathList 中 dexElements 的顺序进行. 把补丁 dex 插到前面后, 同名类会优先从补丁中加载, 从而覆盖原实现. 但如果原类已经加载, 或者类结构变化很大, 就不能保证安全替换.

Q3: 插件 Activity 没有在 Manifest 注册, 为什么还能启动? 常见做法是宿主预注册 ProxyActivity, 系统实际启动代理页面; 代理再根据 Intent 中的插件类名创建插件对象, 并分发生命周期, 资源和 Context. 难点在启动模式, 权限, 主题, Fragment 和进程恢复等系统行为适配.

Q4: Tinker 和 Robust 的核心差异是什么? Tinker 更偏类 / 包级补丁, 通过补丁 dex 优先加载和差分合成修复问题, 通常重启后生效; Robust 更偏方法级补丁, 编译期插桩, 运行时把方法调用分发到补丁实现. 二者都需要灰度, 监控, 回滚, 不是替代正常发版.

Q5: 动态化方案怎么保证安全? 动态包要做签名, 摘要, 版本, 渠道和灰度校验; 加载前后要有完整日志, 崩溃监控和回滚; 插件只暴露受控 API, 避免直接访问宿主内部敏感能力. 同时要遵守商店政策和隐私合规要求.

易错点 / 追问

  • 不要把插件化等同于热修复: 插件化解决模块动态加载, 热修复解决线上缺陷快速止血.
  • 不要说 “ClassLoader 能替换所有代码”:已加载类, Manifest, so, 资源缓存都有边界.
  • 不要只讲 Hook AMS/Instrumentation: 面试更看重你是否知道兼容性, 灰度和回滚成本.
  • 追问 “为什么现在插件化少了”:可以答官方动态特性, Hybrid / 小程序, 远程配置和合规要求分流了需求.
  • 追问 “补丁失败怎么办”:启动保护, 失败计数, 禁用补丁, 回滚到宿主稳定版本, 并上报诊断信息.

音视频 / Media3 / ExoPlayer

本章以 AndroidX Media3 的公开组件为基线. 预加载深度, 缓存大小, buffer 和播放器数量都是业务参数, 必须通过设备, 网络, 内容和 QoE 数据测量, 不能给固定通用数字.

一, 播放链路

数据源 → manifest/容器解析 → 音视频轨道 → decoder → AudioTrack/Surface → A/V 同步与渲染. 首帧可能受 DNS/TCP/TLS, 首包, DRM, manifest, 缓存, 解码器初始化和 Surface ready 影响.

二, Media3 核心组件

组件职责
Player / ExoPlayer播放控制, timeline, 状态与错误
MediaItem / MediaSource媒体描述和实际数据源 / 轨道
DataSourceHTTP, 文件, 缓存等字节读取
Renderer音频/视频/字幕渲染
LoadControl缓冲策略
MediaSession向系统, 通知, 车机, 耳机等暴露播放会话
MediaController跨组件 / 进程控制 MediaSession
MediaLibraryService媒体浏览目录与后台播放服务

后台播放应围绕 MediaSession/Service 生命周期设计, 而不是让 Activity 持有全局播放器. ExoPlayer 2.x 到 Media3 1.x (androidx.media3) 的迁移是高频题 (对应包名 com.google.android.exoplayer2 → androidx.media3), 核心是包名与依赖替换以及 API 更名, 例如 Player.EventListener → Player.Listener.

三, 最小播放示例

val player = ExoPlayer.Builder(context).build().apply {
    setMediaItem(MediaItem.fromUri(uri))
    prepare()
    playWhenReady = true
}

// 在明确的 owner 生命周期结束时释放
player.release()

真实项目还需要处理音频焦点, 耳机拔出, 通知, 后台限制, 字幕, DRM, 错误分类和恢复位置.

有 Surface 与释放路径的最小播放页

以下是上下文片段, 以 2026-08 核验时的 Media3 稳定版为基线 (示例用 <media3-version> 占位, 应替换为当前稳定版). 本文不存在可依赖的官方 BOM 写法, 所有模块显式使用同一版本. 还需要在 manifest 声明 android.permission.INTERNET, 并在布局中提供 androidx.media3.ui.PlayerView 的 @+id/player_view; 未在本仓库执行.

固定测试素材为 ExoPlayer 公开测试媒体 https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4(MP4, 含视频轨); URL 核验日期为 2026-08-07. 开始播放后, 预期首帧是 Big Buck Bunny 动画的非纯黑视频画面, 而非只有声音. 只有使用含视频轨的素材, 才可用首帧, Surface 绑定和黑屏排障来验证视频渲染; MP3 只能验证音频路径.

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

<!-- res/layout/activity_video.xml 的上下文 -->
<androidx.media3.ui.PlayerView
    android:id="@+id/player_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
// app/build.gradle.kts
dependencies {
    implementation("androidx.media3:media3-exoplayer:<media3-version>")
    implementation("androidx.media3:media3-ui:<media3-version>")
    implementation("androidx.media3:media3-session:<media3-version>") // MediaSession/后台播放需要
    implementation("androidx.media3:media3-exoplayer-hls:<media3-version>") // HLS 才需要
    implementation("androidx.media3:media3-exoplayer-dash:<media3-version>") // DASH 才需要
}
// imports: android.os.Bundle; android.widget.Button; androidx.activity.viewModels;
// androidx.appcompat.app.AppCompatActivity;
// androidx.lifecycle.ViewModel;
// androidx.media3.common.AudioAttributes; androidx.media3.common.C;
// androidx.media3.common.MediaItem; androidx.media3.exoplayer.ExoPlayer;
// androidx.media3.ui.PlayerView
class VideoActivity : AppCompatActivity() {
    companion object {
        private const val VIDEO_TEST_URI =
            "https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4"
    }

    private var player: ExoPlayer? = null
    private lateinit var playerView: PlayerView
    private val playbackState: VideoPlaybackViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_video) // 布局内含 id 为 player_view 的 PlayerView
        playerView = findViewById(R.id.player_view)
        playerView.useController = false // 禁用内置控制器;页面提供 id 为 play_pause 的自定义 Button
        findViewById<Button>(R.id.play_pause).setOnClickListener {
            setPlaybackIntent(!playbackState.shouldPlay)
        }
    }

    override fun onStart() {
        super.onStart()
        if (player != null) return // 防御重复 onStart,避免泄漏第二个 player

        val attributes = AudioAttributes.Builder()
            .setUsage(C.USAGE_MEDIA)
            .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
            .build()
        val activePlayer = ExoPlayer.Builder(this)
            .setAudioAttributes(attributes, true) // 管理音频焦点
            .setHandleAudioBecomingNoisy(true) // 默认 false;启用后处理耳机拔出/路由断开
            .build()
        player = activePlayer
        playerView.player = activePlayer
        activePlayer.setMediaItem(MediaItem.fromUri(VIDEO_TEST_URI))
        activePlayer.prepare()
        activePlayer.seekTo(playbackState.positionMs)
        activePlayer.playWhenReady = playbackState.shouldPlay
    }

    override fun onStop() {
        val activePlayer = player
        if (activePlayer != null) {
            playbackState.positionMs = activePlayer.currentPosition
            // 生命周期自动暂停不改变用户已经保存的播放意图.
            activePlayer.pause()
        }
        playerView.player = null // PlayerView 只负责绑定/解绑;重复 onStop 也保持解绑
        activePlayer?.release()
        player = null
        super.onStop()
    }

    // 播放/暂停控件调用此函数,而不是只改 player 的瞬时状态.
    private fun setPlaybackIntent(shouldPlay: Boolean) {
        playbackState.shouldPlay = shouldPlay
        player?.playWhenReady = shouldPlay
    }

    override fun onDestroy() {
        playerView.player = null
        player?.release() // onStop 未运行时的兜底;released player 不可复用
        player = null
        super.onDestroy()
    }
}

class VideoPlaybackViewModel : ViewModel() {
    // true 表示首次进入或一次生命周期自动暂停后应恢复播放.
    var shouldPlay = true
    var positionMs = 0L
}

这个简单前台页面禁用 PlayerView 内置控制器, 所有用户播放 / 暂停操作都经过 setPlaybackIntent, 由该方法同时更新 VideoPlaybackViewModel.shouldPlay 和 player.playWhenReady. Activity 是短生命周期 owner: 每次正常 onStart 新建 player, 绑定 view, 设置媒体, 恢复保存的位置, 并只按保存的用户意图恢复; 重复 onStart 不创建第二个实例. 每次 onStop 先保存 currentPosition, 再暂停, 解绑, release() 并置空; 即使重复 onStop 也维持 view 已解绑. onDestroy 只覆盖 onStop 未运行的异常销毁路径, 不能用它让 Activity 长期持有 codec. 因此 “用户主动暂停 → 后台 → 返回” 仍暂停, 而 “播放中 → 后台自动暂停 → 返回” 会在保存的位置恢复播放. 不要在 onStart 无条件设为 true, 也不要用无法区分用户与系统原因的泛化监听覆盖该状态.

示例中页面播放意图由 ViewModel owner 持有, 可跨配置变化保留; 需要进程重建恢复时改用 SavedStateHandle 或持久化页面状态. 若项目保留 PlayerView 内置控制器, 必须通过 Player.Listener 结合变更 reason 同步用户意图, 并将页面自身的生命周期命令与用户请求区分; 不能直接套用本示例的状态处理. 后台连续播放时, player 的 owner 应是 MediaSessionService, Activity 只连接 controller, 不能沿用页面级 release 模型.

若使用 Compose, PlayerView 仍可经 AndroidView 承载; 这是上下文片段. onRelease 只 detach view, DisposableEffect 由创建 player 的 Composable owner 释放. 不能同时由 Activity 和 Composable 对同一 player 重复 release.

val player = remember { ExoPlayer.Builder(context).build() }
AndroidView(
    factory = { PlayerView(it) },
    update = { view -> view.player = player },
    onRelease = { view -> view.player = null },
)
DisposableEffect(player) {
    onDispose { player.release() }
}

setAudioAttributes(attributes, true) 管理音频焦点, 其中 usage 仅选择 C.USAGE_MEDIA 或 C.USAGE_GAME; 焦点丢失通过 PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS 和 PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS 等 Player 状态观察. setHandleAudioBecomingNoisy(true) 是另一项设置, 默认值为 false; 启用后才自动监听耳机拔出或路由断开并暂停, 可由 PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY 观察. Player.Listener 同时记录 STATE_BUFFERING 与 ended, 分别处理缓冲和播放结束; 不要虚构原始焦点回调.

// imports: androidx.media3.common.AudioAttributes; androidx.media3.common.C;
// androidx.media3.common.Player
val attributes = AudioAttributes.Builder()
    .setUsage(C.USAGE_MEDIA)
    .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
    .build()
player.setAudioAttributes(attributes, true)
player.addListener(object : Player.Listener {
    override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
        when (reason) {
            Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS -> Unit
            Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY -> Unit
        }
    }
    override fun onPlaybackSuppressionReasonChanged(reason: Int) {
        if (reason == Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS) Unit
    }
    override fun onPlaybackStateChanged(state: Int) = Unit // STATE_BUFFERING / STATE_ENDED
})

错误分类与黑屏排障

PlaybackException 应记录错误码, cause, URI (脱敏), 网络类型, 设备/decoder, 是否已有 Surface, timeline 和播放位置; 网络/HTTP/DRM/解析/decoder/渲染错误的恢复动作不同, 不能一律无限重试. HLS/DASH 的 MediaItem 用 URI 即可由支持模块识别; 若缺少对应扩展依赖, 表现通常是 source / 解析失败, 应先检查依赖与 manifest 响应.

症状证据定位与修复验证
无声焦点状态, 音量, AudioTrack/route 日志请求焦点, 处理 duck/拔耳机, 检查蓝牙/系统音量有线, 蓝牙和其他媒体抢占下分别播放
黑屏有声音PlayerView.player, Surface 创建, decoder 日志, 以及素材实际含视频轨先确认测试素材含视频轨, 再确认 view 已绑定 player, Surface ready, 编码 / DRM 支持仅以含视频轨素材检查切后台返回, 旋转和不同设备的首帧可见
卡顿rebuffer, 加载时长, 码率切换, 网络/CDN 数据区分网络, 缓存, manifest 与 decoder; 按 QoE 调整策略比较同条件下 rebuffer P50/P95 和废弃流量

这些步骤是排障方法, 表中不代表既有线上数据或已完成测试.

四, 缓存, 下载与预加载

  • 流式缓存: 使用 Media3 Cache/DataSource 组合, 定义 cache key, 配额, 淘汰和并发写入.
  • 离线下载: 使用 Media3 download 组件管理任务, 约束, 断点, 状态与 DRM 许可, 不把普通 HTTP 文件缓存冒充离线下载系统.
  • 预加载: 使用 Media3 preload 相关组件或受控 prepare 策略, 根据滑动速度, 命中率, 废弃流量, 内存, 温度和首帧分布调参.
  • AndroidVideoCache 等第三方本地代理不是当前主推荐路径; 如历史项目保留, 应标维护状态, 安全边界和迁移计划.

五, Feed 播放器设计

不要给每个 Item 无限制创建播放器, 也不要把 “全局 1–3 个播放器池” 写成通用答案. 可选方案包括单 active player 切换 Surface, 少量预热 player, Media3 preload manager 或按页面生命周期管理实例.

决策指标:

  • 同屏可播放数量和切换频率;
  • decoder/Surface/内存上限;
  • 首帧 P50/P95, rebuffer ratio, 播放失败率;
  • 预加载命中率与废弃字节;
  • 发热, 电量和后台网络消耗.

六, 音频焦点, DRM 与字幕

  • 请求 / 响应音频焦点, 处理 duck, pause, 耳机拔出和其他媒体抢占.
  • DRM 设计包含 license 获取, 离线许可, 过期, 设备时间, 错误恢复和日志脱敏.
  • 字幕要处理轨道选择, 语言, 样式, 同步和无障碍.
  • 直播低延迟, 长视频稳定性和短视频秒开目标不同, 不能复用一组 buffer 参数.

七, QoE 与排障

分段记录: 建连, 首包, manifest, DRM, prepare, decoder, Surface, 首帧, rebuffer, 码率切换和错误原因. 线上按内容, CDN, 网络类型, 设备 decoder, 版本和实验组聚合.

指标含义
Time to first frame从用户动作到首个视频帧
Rebuffer ratio/count播放中断占比/次数
Playback failure rate无法开始或中途失败比例
Average bitrate/resolution体验质量与流量权衡
Preload hit/waste预加载收益和废弃成本

八, 音视频版权与风控交叉

音视频与风控常交叉在版权与风控场景: 设备绑定与并发播放检测可基于设备指纹或账号维度判断多端同时播放 / 共享账号滥用; 防盗链依赖签名 URL 与 Referer 校验, 对盗播可叠加数字水印 (视频帧水印 / 音频指纹) 用于溯源; DRM (如 Widevine L1/L3) 依赖设备信任环境, 客户端只能上报设备信任级别, 最终授权与判定在服务端.

高频面试题

Q1: Media3 与 ExoPlayer 什么关系?
ExoPlayer 已迁移到 AndroidX Media3 命名空间; Media3 还包含 Session, Controller, Library, Transformer, Download 等媒体组件.

Q2: Feed 如何避免滑动卡顿?
限制活跃解码器与 Surface, 稳定复用 / 切换策略, 按目标尺寸和网络预加载, 用首帧, rebuffer, 废弃流量和热量验证; 不背固定池大小.

Q3: 缓存与离线下载有什么区别?
缓存是播放路径的可淘汰加速层; 离线下载是用户可管理, 可恢复, 有状态和版权约束的任务系统.

易错点 / 追问

  • 不混淆容器, 编码和传输协议.
  • 不把固定预加载条数, 字节数或播放器池大小当最佳实践.
  • 不只说 “加缓存”, 要说明 key, 配额, 版权, 并发和清理.
  • 不忽略 MediaSession, 音频焦点, 后台播放和 DRM.

版本与参考资料

LeetCode Hot 100 算法清单

本清单对应 LeetCode Hot 100, 共 100 题. 题目和官方列表会随时间调整, 本章按面试复习需要重新分组, 内容截至 2026-08-06.

针对你的定位 (中级 Android 应用方向): Android 算法面试以中等题为主. 下方每个分类标注了 ⭐(高频必刷) 和 △(偏难 / 低频, 可缓刷), 按优先级投入精力.

内容边界

本章列出 Hot 100 学习清单并提供高频母题 / 模板, 不是 100 题逐题完整题解, 也不承诺每个代码块可独立运行. 手册统一使用 C++ 主要是为了突出算法结构, 减少 Android API 干扰, 与作者的 NDK/C++ 背景一致; Android 面试仍应能用岗位要求的 Kotlin/Java 写出数组, 链表, 队列, 堆, DFS/BFS 和动态规划基础模板.

如何使用

  • 第一轮: 只刷 ⭐ 高频题, 建立每类的解题模板 (约 40 题), 覆盖大部分中小厂.
  • 第二轮: 补齐其余中等题, 冲大厂.
  • 第三轮:△ 难题 (困难 / 低频) 按目标公司选刷.
  • 每类先吃透 1-2 道 “母题” 模板, 其余是变体. 重在模板内化而非数量.

进度自测

勾选格式 - [x].⭐= 高频必刷,△= 可缓刷.

哈希表 (3)

  • ⭐ 1. 两数之和 (简单)
  • 49. 字母异位词分组 (中等)
  • 128. 最长连续序列 (中等)

双指针 (4)

  • ⭐ 283. 移动零
  • ⭐ 11. 盛最多水的容器
  • ⭐ 15. 三数之和
  • △ 42. 接雨水 (困难)

滑动窗口 / 前缀和 (5)

  • ⭐ 3. 无重复字符的最长子串
  • 438. 找到字符串中所有字母异位词
  • ⭐ 560. 和为 K 的子数组
  • △ 239. 滑动窗口最大值 (困难)
  • △ 76. 最小覆盖子串 (困难)

普通数组 (5)

  • ⭐ 53. 最大子数组和
  • ⭐ 56. 合并区间
  • ⭐ 189. 轮转数组
  • 238. 除自身以外数组的乘积
  • △ 41. 缺失的第一个正数 (困难)

矩阵 (4)

  • 73. 矩阵置零
  • ⭐ 54. 螺旋矩阵
  • 48. 旋转图像
  • 240. 搜索二维矩阵 II

链表 (14)

  • ⭐ 160. 相交链表
  • ⭐ 206. 反转链表
  • 234. 回文链表
  • ⭐ 141. 环形链表
  • ⭐ 142. 环形链表 II
  • ⭐ 21. 合并两个有序链表
  • 2. 两数相加
  • ⭐ 19. 删除链表的倒数第 N 个结点
  • 24. 两两交换链表中的节点
  • △ 25. K 个一组翻转链表 (困难)
  • 138. 随机链表的复制
  • 148. 排序链表
  • △ 23. 合并 K 个升序链表 (困难)
  • ⭐ 146. LRU 缓存

二叉树 (15)

  • ⭐ 94. 二叉树的中序遍历
  • ⭐ 104. 二叉树的最大深度
  • ⭐ 226. 翻转二叉树
  • ⭐ 101. 对称二叉树
  • 543. 二叉树的直径
  • ⭐ 102. 二叉树的层序遍历
  • 108. 将有序数组转换为二叉搜索树
  • ⭐ 98. 验证二叉搜索树
  • 230. 二叉搜索树中第 K 小的元素
  • 199. 二叉树的右视图
  • 114. 二叉树展开为链表
  • 105. 从前序与中序遍历序列构造二叉树
  • 437. 路径总和 III
  • ⭐ 236. 二叉树的最近公共祖先
  • △ 124. 二叉树中的最大路径和 (困难)

图论 (4)

  • ⭐ 200. 岛屿数量
  • 994. 腐烂的橘子
  • ⭐ 207. 课程表
  • 208. 实现 Trie (前缀树)

回溯 (8)

  • ⭐ 46. 全排列
  • ⭐ 78. 子集
  • 17. 电话号码的字母组合
  • 39. 组合总和
  • 22. 括号生成
  • 79. 单词搜索
  • 131. 分割回文串
  • △ 51. N 皇后 (困难)

二分查找 (6)

  • ⭐ 35. 搜索插入位置
  • 74. 搜索二维矩阵
  • ⭐ 34. 在排序数组中查找元素的第一个和最后一个位置
  • ⭐ 33. 搜索旋转排序数组
  • 153. 寻找旋转排序数组中的最小值
  • △ 4. 寻找两个正序数组的中位数 (困难)

栈 (5)

  • ⭐ 20. 有效的括号
  • ⭐ 155. 最小栈
  • 394. 字符串解码
  • ⭐ 739. 每日温度
  • △ 84. 柱状图中最大的矩形 (困难)

堆 (3)

  • ⭐ 215. 数组中的第 K 个最大元素
  • ⭐ 347. 前 K 个高频元素
  • △ 295. 数据流的中位数 (困难)

贪心 (4)

  • ⭐ 121. 买卖股票的最佳时机
  • ⭐ 55. 跳跃游戏
  • 45. 跳跃游戏 II
  • 763. 划分字母区间

动态规划 - 单维 (10)

  • ⭐ 70. 爬楼梯
  • 118. 杨辉三角
  • ⭐ 198. 打家劫舍
  • 279. 完全平方数
  • ⭐ 322. 零钱兑换
  • 139. 单词拆分
  • ⭐ 300. 最长递增子序列
  • 152. 乘积最大子数组
  • 416. 分割等和子集
  • △ 32. 最长有效括号 (困难)

动态规划 - 多维 (5)

  • ⭐ 62. 不同路径
  • 64. 最小路径和
  • ⭐ 5. 最长回文子串
  • ⭐ 1143. 最长公共子序列
  • △ 72. 编辑距离

技巧题 (5)

  • ⭐ 136. 只出现一次的数字
  • ⭐ 169. 多数元素
  • 75. 颜色分类
  • 31. 下一个排列
  • 287. 寻找重复数

知识点解析

这一章不要把 Hot 100 当成 “100 道孤立的题”.真正要学的是: 读题时识别题型 → 选算法 → 套模板 → 解释复杂度. 下面按算法分类讲: 如何理解题目, 为什么选这个算法, 核心思想, 常用写法和易错点. 代码以 C++ 为主; 链表小节并列给出 Java 模板, 面试时应按岗位要求改写其他模板.

代码上下文: 示例使用 C++17, 省略重复的标准库头文件和 using namespace std;. 除非示例显式处理空输入, 否则默认遵循对应 LeetCode 题目的输入约束. 复制到独立文件时, 需要补齐该片段使用的标准库头文件.

先学会读题: 怎么从题目判断算法

读题时先问 5 个问题:

  1. 输入规模多大?
    • n <= 100: O (n²) 甚至回溯也可能可以.
    • n <= 10^5: 通常要 O (n), O (n log n), 不能双重循环.
    • n <= 10^9: 通常不能遍历, 要数学, 二分, 位运算.
  2. 有没有 “有序” 条件?
    • 数组有序: 优先想二分, 双指针.
    • 矩阵行列有序: 想右上角 / 左下角搜索, 或二分.
  3. 题目问的是子数组/子串/连续区间吗?
    • 固定长度或可伸缩窗口: 滑动窗口.
    • 任意区间和: 前缀和.
    • 有负数的 “和为 K”:不能随便滑窗, 要前缀和 + 哈希表.
  4. 题目要求 “所有方案/所有排列/所有组合” 吗?
    • 通常是回溯. 关键词: 所有, 任意一种路径, 组合, 排列, 切分, 棋盘.
  5. 题目是否有 “最优值/计数/能否完成”?
    • 有重叠子问题: 动态规划.
    • 每步局部最优能保证全局: 贪心.
    • 连通性/分组/合并集合: 并查集或图遍历.

面试回答顺序建议固定为:

我先看题目特征:[题目特征].暴力做法是 [暴力方案], 复杂度为 [暴力复杂度], 输入规模不允许. 所以我选 [算法名称], 核心是 [核心性质], 实现上维护 [关键状态], 最终复杂度为 [目标复杂度].

1. 哈希表

如何理解题目

只要题目需要 “快速判断一个东西是否出现过 / 出现了几次 / 某个配对值是否存在”, 就优先想哈希表. 暴力查找是 O (n), 哈希查找平均 O (1).

典型信号:

  • “两数之和”, “是否存在另一个数”.
  • “分组”, “频次”, “出现次数”.
  • “最长连续序列” 这种需要快速判断相邻值是否存在.

为什么选哈希表

哈希表本质是用空间换时间. 你多开一个 unordered_map 或 unordered_set, 把 “查找” 从线性扫描降到平均 O (1).

核心思想和用法

  • unordered_map<Key, Value>: 需要保存 “值 → 下标” 或 “元素 → 次数”.
  • unordered_set<T>: 只关心存在性.
  • 频次统计: 遍历一遍, cnt[x]++.
  • 配对查找: 当前是 x, 就查需要的另一个值 target - x 是否已经出现.

母题: 1 两数之和

#include <vector>
#include <unordered_map>
using namespace std;

vector<int> twoSum(vector<int>& nums, int target) {
    unordered_map<int, int> pos; // value -> index
    for (int i = 0; i < nums.size(); ++i) {
        int need = target - nums[i];
        if (pos.count(need)) return {pos[need], i};
        pos[nums[i]] = i;
    }
    return {};
}

理解重点: 不要先把所有数放进去再找, 否则容易把同一个元素用两次. 边遍历边查, 保证查到的是之前的元素.

题目映射

  • 49 字母异位词分组: 同一组字符串排序后相同, 排序串作 key; 或用 26 个字符计数作 key.
  • 128 最长连续序列: 把所有数放进 set, 只从 x - 1 不存在的起点开始向后数, 这样每个数最多被访问一次.
int longestConsecutive(vector<int>& nums) {
    unordered_set<int> s(nums.begin(), nums.end());
    int ans = 0;
    for (int x : s) {
        if (s.count(x - 1)) continue; // 不是序列起点,跳过
        int cur = x;
        while (s.count(cur)) cur++;
        ans = max(ans, cur - x);
    }
    return ans;
}

易错点

  • unordered_map 平均 O (1), 最坏可能退化, 但面试按平均复杂度说即可.
  • 128 题如果每个数都向后扩展, 会退化成 O (n²); 必须只从起点扩展.
  • 频次 key 如果是数组, 要转换成字符串或固定格式, 否则不能直接当哈希 key.

2. 双指针

如何理解题目

双指针适合 “两个位置一起移动” 的题. 常见三类:

  1. 左右对撞: 数组有序, 找两数, 容器, 接雨水.
  2. 快慢指针: 链表找环, 删除倒数第 N 个, 原地压缩数组.
  3. 同向指针: 滑动窗口的基础版.

为什么选双指针

暴力通常枚举两个位置 O (n²).如果能根据条件移动某一个指针, 就可以把复杂度降到 O (n).

母题: 11 盛最多水的容器

int maxArea(vector<int>& height) {
    int l = 0, r = (int)height.size() - 1;
    int ans = 0;
    while (l < r) {
        ans = max(ans, min(height[l], height[r]) * (r - l));
        if (height[l] < height[r]) l++;
        else r--;
    }
    return ans;
}

核心理解: 面积由短板决定. 移动长板, 短板不变, 宽度变小, 面积不可能更大; 所以只能移动短板寻找更高的板.

题目映射

  • 283 移动零: 慢指针表示下一个非零应该放的位置, 快指针扫描.
  • 15 三数之和: 排序后固定第一个数, 剩下两个数用左右指针找和.
  • 42 接雨水: 左右指针维护 leftMax/rightMax, 谁小先处理谁.
vector<vector<int>> threeSum(vector<int>& nums) {
    sort(nums.begin(), nums.end());
    vector<vector<int>> res;
    int n = nums.size();
    for (int i = 0; i < n; ++i) {
        if (i > 0 && nums[i] == nums[i - 1]) continue;
        int l = i + 1, r = n - 1;
        while (l < r) {
            long long sum = static_cast<long long>(nums[i]) + nums[l] + nums[r];
            if (sum == 0) {
                res.push_back({nums[i], nums[l], nums[r]});
                while (l < r && nums[l] == nums[l + 1]) l++;
                while (l < r && nums[r] == nums[r - 1]) r--;
                l++; r--;
            } else if (sum < 0) l++;
            else r--;
        }
    }
    return res;
}

易错点

  • 三数之和必须排序, 并且 i, l, r 三处都要去重.
  • 双指针不是 “两个变量” 就行, 关键是要有单调移动理由.
  • 接雨水要理解 “较小一侧的最大值已经能确定当前水量”.

3. 滑动窗口

如何理解题目

看到 “连续子串 / 连续子数组 / 最长 / 最短 / 满足条件的窗口”, 先想滑动窗口.

但注意: 滑动窗口要求窗口变化有单调性. 比如全是正数时, 右边加入会让和变大, 左边移出会让和变小; 有负数时这个性质消失.

为什么选滑动窗口

暴力枚举所有子串是 O (n²).滑动窗口中每个元素最多进窗口一次, 出窗口一次, 所以 O (n).

核心模板

int lengthOfLongestSubstring(string s) {
    vector<int> cnt(128, 0);
    int left = 0, ans = 0;
    for (int right = 0; right < s.size(); ++right) {
        cnt[s[right]]++;
        while (cnt[s[right]] > 1) {
            cnt[s[left]]--;
            left++;
        }
        ans = max(ans, right - left + 1);
    }
    return ans;
}

模板理解:

  • right 负责扩张, 把新字符放进窗口.
  • 如果窗口不合法, 就不断移动 left 缩小.
  • 每次窗口合法后更新答案.

题目映射

  • 3 无重复字符最长子串: 窗口内每个字符最多出现一次.
  • 438 找到字符串中所有字母异位词: 固定长度窗口 + 频次比较.
  • 76 最小覆盖子串: 可变窗口, 窗口满足条件后尽量左缩.
  • 560 和为 K 的子数组: 不是滑窗, 因为数组可能有负数, 要用前缀和 + 哈希表.
  • 239 滑动窗口最大值: 不是普通窗口统计, 要用单调队列.

易错点

  • “最长” 通常是在窗口合法时更新;“最短” 通常是在窗口满足条件后边缩边更新.
  • 有负数的和问题不要套滑窗.
  • 字符集大小明确时用数组比哈希表简单.

4. 普通数组: 前缀, 原地, 边界

如何理解题目

数组题看似杂, 其实先问:

  • 是否要原地修改? 如果要求 O (1) 额外空间, 通常要交换, 反转, 原地哈希.
  • 是否频繁查询区间? 用前缀和.
  • 是否求连续最优? 可能是 Kadane 或 DP.
  • 是否跟区间重叠有关? 排序后合并.

母题: 53 最大子数组和

int maxSubArray(vector<int>& nums) {
    int cur = nums[0], ans = nums[0];
    for (int i = 1; i < nums.size(); ++i) {
        cur = max(nums[i], cur + nums[i]);
        ans = max(ans, cur);
    }
    return ans;
}

理解重点: cur 表示 “必须以当前位置结尾的最大子数组和”.如果前面的和是负担, 就从当前重新开始.

题目映射

  • 56 合并区间: 先按左端点排序, 再维护当前合并区间.
  • 189 轮转数组: 三次反转, 避免额外数组.
  • 238 除自身以外数组的乘积: 左边乘积 × 右边乘积, 不能用除法.
  • 41 缺失的第一个正数: 把值 x 放到下标 x - 1, 用数组本身当哈希表.
void rotate(vector<int>& nums, int k) {
    if (nums.empty()) return;
    int n = nums.size();
    k %= n;
    reverse(nums.begin(), nums.end());
    reverse(nums.begin(), nums.begin() + k);
    reverse(nums.begin() + k, nums.end());
}

易错点

  • 189 的 k 要先 % n.
  • 238 要先从左到右存前缀积, 再从右到左乘后缀积.
  • 41 原地置换时要用 while, 不是 if, 因为换来的数可能还要继续归位.

5. 矩阵

如何理解题目

矩阵题本质是二维数组坐标控制. 先判断是哪类:

  • 遍历顺序: 螺旋矩阵.
  • 原地变换: 旋转图像.
  • 标记行列: 矩阵置零.
  • 行列有序: 从右上角或左下角搜索.

核心思想和用法

矩阵题最重要的是边界: top/bottom/left/right 或 i/j 的范围. 不要凭感觉写, 先在纸上画 3x3, 1x4, 4x1 三种边界.

母题: 48 旋转图像

void rotate(vector<vector<int>>& m) {
    int n = m.size();
    for (int i = 0; i < n; ++i) {
        for (int j = i + 1; j < n; ++j) {
            swap(m[i][j], m[j][i]); // 主对角线转置
        }
    }
    for (int i = 0; i < n; ++i) {
        reverse(m[i].begin(), m[i].end()); // 每行翻转
    }
}

理解重点: 顺时针 90° = 先转置, 再每行左右翻转.

题目映射

  • 73 矩阵置零: 简单版用两个 set; 进阶用第一行第一列作标记.
  • 54 螺旋矩阵: 维护四条边, 每走完一条就收缩边界.
  • 240 搜索二维矩阵 II: 从右上角开始, 当前值大就左移, 小就下移.

易错点

  • 螺旋矩阵每次移动后要检查 top <= bottom, left <= right.
  • 矩阵置零如果用第一行第一列作标记, 要额外记录第一行 / 第一列原本是否有 0.

6. 链表

如何理解题目

链表题考的是指针, 不是复杂算法. 先问:

  • 会不会改到头节点? 会, 就加 dummy.
  • 要不要找中点, 倒数, 环? 用快慢指针.
  • 要不要反转一段? 先保存 next, 防止链断掉.

为什么选这些技巧

链表不能 O (1) 随机访问, 只能顺着 next 走. 所以数组里的下标技巧, 在链表里要改成指针技巧.

母题: 206 反转链表

struct ListNode {
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

ListNode* reverseList(ListNode* head) {
    ListNode* prev = nullptr;
    ListNode* cur = head;
    while (cur) {
        ListNode* nxt = cur->next;
        cur->next = prev;
        prev = cur;
        cur = nxt;
    }
    return prev;
}

理解重点: 每次只改变一条边: cur->next = prev. 改之前必须保存 nxt, 否则后半段链表丢失.

Java 单链表模板

下列是与上方 C++ 模板对应的 Java 上下文片段. dummy 统一处理删头和空链表; 反转前保存 next; 快慢指针循环条件先保护 fast 与 fast.next. 涉及局部反转或重连后, 从哨兵节点遍历并检查尾节点是否正确断链, 防止旧 next 形成环或丢失后缀.

class ListNode {
    int val;
    ListNode next;

    ListNode(int val) {
        this.val = val;
    }
}

ListNode reverseList(ListNode head) {
    ListNode previous = null;
    ListNode current = head;
    while (current != null) {
        ListNode next = current.next;
        current.next = previous;
        previous = current;
        current = next;
    }
    return previous;
}

boolean hasCycle(ListNode head) {
    ListNode slow = head;
    ListNode fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) return true;
    }
    return false;
}

ListNode removeNthFromEnd(ListNode head, int n) {
    if (n <= 0) throw new IllegalArgumentException("n must be positive");
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode fast = dummy;
    ListNode slow = dummy;
    for (int i = 0; i <= n; i++) {
        if (fast == null) throw new IllegalArgumentException("n exceeds list length");
        fast = fast.next;
    }
    while (fast != null) {
        fast = fast.next;
        slow = slow.next;
    }
    ListNode removed = slow.next;
    slow.next = removed.next;
    removed.next = null; // 断开被删除节点,便于复用或调试时检查
    return dummy.next;
}

// 示意/练习检查: 从哨兵的 next 遍历,确认剩余链表无环且未丢失节点.
void assertListShapeFromSentinel(ListNode sentinel, int expectedNodeCount) {
    if (sentinel == null || expectedNodeCount < 0) {
        throw new IllegalArgumentException("invalid list expectation");
    }
    if (hasCycle(sentinel.next)) throw new IllegalStateException("cycle detected");

    int actualNodeCount = 0;
    ListNode tail = null;
    for (ListNode current = sentinel.next; current != null; current = current.next) {
        tail = current;
        actualNodeCount++;
    }
    if (actualNodeCount != expectedNodeCount) {
        throw new IllegalStateException("unexpected reachable node count");
    }
    if (tail != null && tail.next != null) {
        throw new IllegalStateException("tail must terminate with null");
    }
}

例如, 已知原链表有 size 个节点时, 调用方先接收返回的新头节点, 再构造测试哨兵并检查形状:

ListNode newHead = removeNthFromEnd(head, n);
ListNode sentinel = new ListNode(0);
sentinel.next = newHead;
assertListShapeFromSentinel(sentinel, size - 1);

removed.next = null 的断链语义仍应覆盖: 若要验证它, 应在 removeNthFromEnd 方法内部断言, 或让测试专用的结果类型显式暴露 removed; 保持当前标准 ListNode removeNthFromEnd(ListNode head, int n) 签名时, 调用方无法访问该局部节点, 不应伪装成可检查. 上述形状检查用于面试练习或单元测试, 不是生产热路径代码. 空链表反转返回 null, 空链表的环检测返回 false; 删除倒数第 n 个节点会显式拒绝 n <= 0 和超过链表长度的 n. 本模板是面试手写骨架, 生产代码还应按所有权, 并发和非法输入约定完善.

题目映射

  • 160 相交链表: 两个指针分别走 A+B 和 B+A, 长度差被抵消.
  • 141/142 环形链表: 快慢指针; 相遇后一个回头, 两个每次走一步, 再次相遇是入环点.
  • 19 删除倒数第 N 个: 快指针先走 n 步, 再一起走.
  • 21 合并有序链表: dummy + tail.
  • 24/25 翻转节点: 本质是局部反转, 边界最容易错.
  • 138 随机链表复制: 哈希表 old -> new, 或原地穿插复制.
  • 148 排序链表: 归并排序最适合链表.
  • 146 LRU 缓存: 哈希表 + 双向链表.

LRU 的算法理解

题目要求 get/put 都 O (1).单独用数组或链表查找不是 O (1), 单独用哈希表又无法知道谁最久未使用. 所以要组合:

  • 哈希表: key → 链表节点, 负责 O (1) 定位.
  • 双向链表: 维护访问顺序, 头部最新, 尾部最旧.
#include <list>
#include <unordered_map>

class LRUCache {
    int cap;
    list<pair<int, int>> cache; // front = most recent, back = least recent
    unordered_map<int, list<pair<int, int>>::iterator> pos;
public:
    LRUCache(int capacity) : cap(capacity) {}

    int get(int key) {
        if (!pos.count(key)) return -1;
        cache.splice(cache.begin(), cache, pos[key]); // 移到头部
        return pos[key]->second;
    }

    void put(int key, int value) {
        if (cap <= 0) return;
        if (pos.count(key)) {
            pos[key]->second = value;
            cache.splice(cache.begin(), cache, pos[key]);
            return;
        }
        if (cache.size() == cap) {
            int oldKey = cache.back().first;
            pos.erase(oldKey);
            cache.pop_back();
        }
        cache.push_front({key, value});
        pos[key] = cache.begin();
    }
};

面试手写 Java 版见 53 算法补充专题.

易错点

  • 删除, 插入, 合并题优先写 dummy.
  • 反转链表一定先保存 next.
  • LRU 必须同步更新 map 和 list; 删除尾节点时别忘 map erase.

7. 二叉树

如何理解题目

二叉树题先判断遍历顺序:

  • 前序: 根 → 左 → 右, 适合复制/序列化/展开.
  • 中序: 左 → 根 → 右, BST 中序是升序.
  • 后序: 左 → 右 → 根, 适合从子树收集信息, 如高度, 直径, 最大路径.
  • 层序: BFS, 适合每层, 右视图, 最短距离.

递归怎么想

不要一上来想整棵树. 只问当前节点:

  1. 空节点返回什么?
  2. 左右子树分别能给我什么信息?
  3. 我用这些信息算出什么并返回给父节点?

母题: 中序遍历 + 层序遍历

struct TreeNode {
    int val;
    TreeNode *left, *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

void inorder(TreeNode* root, vector<int>& res) {
    if (!root) return;
    inorder(root->left, res);
    res.push_back(root->val);
    inorder(root->right, res);
}

vector<vector<int>> levelOrder(TreeNode* root) {
    vector<vector<int>> res;
    if (!root) return res;
    queue<TreeNode*> q;
    q.push(root);
    while (!q.empty()) {
        int sz = q.size();
        vector<int> level;
        for (int i = 0; i < sz; ++i) {
            TreeNode* node = q.front(); q.pop();
            level.push_back(node->val);
            if (node->left) q.push(node->left);
            if (node->right) q.push(node->right);
        }
        res.push_back(level);
    }
    return res;
}

题目映射

  • 104 最大深度: 后序, 深度 = max (左, 右) + 1.
  • 226 翻转二叉树: 交换左右子树.
  • 101 对称二叉树: 比较左子树的左/右和右子树的右/左.
  • 543 直径: 每个节点尝试 leftDepth + rightDepth 更新答案.
  • 98 验证 BST: 不能只比较父子, 要用上下界或中序递增.
  • 230 第 K 小: BST 中序第 k 个.
  • 236 最近公共祖先: 左右子树分别找 p/q, 左右都非空则当前是 LCA.
  • 437 路径总和 III: 路径不一定从根开始, 用前缀和.
  • 124 最大路径和: 后序返回 “向父节点贡献的最大单边路径”.

易错点

  • BST 验证要考虑整棵子树范围.
  • 直径 / 最大路径和这类题通常有 “全局答案” 和 “返回给父节点的值”, 二者不是同一个东西.
  • 层序遍历必须先固定当前层 size, 否则会把下一层混进来.

8. 图论

如何理解题目

图论不一定直接给你 “图”.矩阵, 课程依赖, 单词转换, 岛屿都可以抽象成图.

  • 网格: 每个格子是节点, 上下左右是边.
  • 课程表: 课程是节点, 先修关系是有向边.
  • 腐烂橘子: 多个起点同时扩散, 是多源 BFS.

DFS / BFS 怎么选

  • DFS: 适合 “把一整块连通区域全部访问掉”, 如岛屿数量.
  • BFS: 适合 “最短步数 / 按层扩散”, 如腐烂橘子.
  • 拓扑排序: 适合 “有依赖关系, 问能不能完成”.

母题: 200 岛屿数量

void dfs(vector<vector<char>>& grid, int i, int j) {
    int m = grid.size(), n = grid[0].size();
    if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != '1') return;
    grid[i][j] = '0'; // 标记访问过,等价于把岛淹掉
    dfs(grid, i + 1, j);
    dfs(grid, i - 1, j);
    dfs(grid, i, j + 1);
    dfs(grid, i, j - 1);
}

int numIslands(vector<vector<char>>& grid) {
    if (grid.empty() || grid[0].empty()) return 0;
    int ans = 0;
    for (int i = 0; i < grid.size(); ++i) {
        for (int j = 0; j < grid[0].size(); ++j) {
            if (grid[i][j] == '1') {
                ans++;
                dfs(grid, i, j);
            }
        }
    }
    return ans;
}

课程表: 拓扑排序

bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
    vector<vector<int>> graph(numCourses);
    vector<int> indeg(numCourses, 0);
    for (auto& p : prerequisites) {
        int a = p[0], b = p[1]; // 学 a 前要先学 b: b -> a
        graph[b].push_back(a);
        indeg[a]++;
    }
    queue<int> q;
    for (int i = 0; i < numCourses; ++i)
        if (indeg[i] == 0) q.push(i);

    int learned = 0;
    while (!q.empty()) {
        int cur = q.front(); q.pop();
        learned++;
        for (int next : graph[cur]) {
            if (--indeg[next] == 0) q.push(next);
        }
    }
    return learned == numCourses;
}

易错点

  • DFS 网格访问要标记, 否则死循环.
  • 994 腐烂橘子要把所有初始腐烂橘子同时入队, 不是一个一个单独 BFS.
  • 207 课程表本质是有向图判环.

9. 回溯

如何理解题目

回溯就是 “带撤销的穷举”.看到这些词优先想回溯:

  • 所有排列, 所有组合, 所有子集.
  • 棋盘摆放, 路径搜索.
  • 字符串切分成所有可能.

核心思想

回溯模板只有三步:

  1. 做选择.
  2. 递归进入下一层.
  3. 撤销选择.

这对应一棵决策树. 每一层决定一个位置选什么.

母题: 46 全排列

void backtrack(vector<int>& nums, vector<int>& path, vector<bool>& used,
               vector<vector<int>>& res) {
    if (path.size() == nums.size()) {
        res.push_back(path);
        return;
    }
    for (int i = 0; i < nums.size(); ++i) {
        if (used[i]) continue;
        used[i] = true;
        path.push_back(nums[i]);
        backtrack(nums, path, used, res);
        path.pop_back();
        used[i] = false;
    }
}

vector<vector<int>> permute(vector<int>& nums) {
    vector<vector<int>> res;
    vector<int> path;
    vector<bool> used(nums.size(), false);
    backtrack(nums, path, used, res);
    return res;
}

排列, 组合, 子集怎么区分

  • 排列: 顺序不同算不同, 需要 used, 每层都从 0 开始枚举.
  • 组合 / 子集: 顺序不同不算不同, 用 start 控制只能往后选.
  • 可重复选择: 递归传 i.
  • 不可重复选择: 递归传 i + 1.

题目映射

  • 78 子集: 每个元素选或不选, 也可以用 start 枚举.
  • 39 组合总和: 可以重复选, 下一层仍传 i.
  • 22 括号生成: 左括号数量 < n 可放左; 右括号数量 < left 可放右.
  • 79 单词搜索: 网格回溯, 访问过的格子要临时标记再恢复.
  • 131 分割回文串: 枚举切割点, 只有当前段是回文才递归.
  • 51 N 皇后: 逐行放皇后, 列, 主对角线, 副对角线不能冲突.

易错点

  • 保存结果时要拷贝当前 path, C++ 的 res.push_back(path) 会拷贝, 没问题.
  • 撤销必须和选择严格对称.
  • 组合题如果忘了 start, 会出现重复答案甚至死循环.

10. 二分查找

如何理解题目

二分不是只用于 “在有序数组里找数”.只要答案空间有单调性, 也可以二分答案.

常见信号:

  • 有序数组找目标, 找边界.
  • 旋转有序数组.
  • “最小的最大值”, “最大的最小值” 这类答案单调问题.

为什么选二分

每次排除一半搜索空间, 复杂度 O (log n).二分真正难点不是思想, 而是边界写法要稳定.

推荐闭区间模板

int search(vector<int>& nums, int target) {
    int lo = 0, hi = (int)nums.size() - 1; // [lo, hi]
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] == target) return mid;
        if (nums[mid] < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}

找左边界模板

int lowerBound(vector<int>& nums, int target) {
    int lo = 0, hi = nums.size(); // [lo, hi)
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] >= target) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

题目映射

  • 35 搜索插入位置: 返回第一个 >= target 的位置.
  • 34 首尾位置: 左边界是 lower_bound(target), 右边界可用 lower_bound(target + 1) - 1.
  • 33 搜索旋转排序数组: 每次判断哪一半有序, 再决定去哪边.
  • 153 寻找旋转数组最小值: 和右端比较, 决定最小值在哪半边.
  • 74 搜索二维矩阵: 把矩阵下标映射成一维.
  • 4 两正序数组中位数: 困难题, 核心是二分切分两个数组, 使左半都小于右半.

易错点

  • mid = lo + (hi - lo) / 2 防溢出.
  • 不要混用闭区间和左闭右开模板.
  • 旋转数组要先判断有序半区, 不能直接和普通二分一样写.

11. 栈与单调栈

如何理解题目

普通栈用于 “最近的未匹配项”.单调栈用于 “找左边 / 右边第一个更大或更小的元素”.

典型信号:

  • 括号匹配, 表达式解析, 字符串解码: 普通栈.
  • 每日温度, 柱状图最大矩形, 下一个更大元素: 单调栈.

母题: 20 有效括号

bool isValid(string s) {
    stack<char> st;
    for (char c : s) {
        if (c == '(' || c == '[' || c == '{') st.push(c);
        else {
            if (st.empty()) return false;
            char t = st.top(); st.pop();
            if ((c == ')' && t != '(') ||
                (c == ']' && t != '[') ||
                (c == '}' && t != '{')) return false;
        }
    }
    return st.empty();
}

母题: 739 每日温度

vector<int> dailyTemperatures(vector<int>& t) {
    vector<int> ans(t.size(), 0);
    stack<int> st; // 存下标,栈内温度单调递减
    for (int i = 0; i < t.size(); ++i) {
        while (!st.empty() && t[i] > t[st.top()]) {
            int j = st.top(); st.pop();
            ans[j] = i - j;
        }
        st.push(i);
    }
    return ans;
}

理解重点: 栈里放的是 “还没等到更高温度的日子”.当前温度更高时, 就能结算栈顶.

题目映射

  • 155 最小栈: 一个数据栈 + 一个最小值栈.
  • 394 字符串解码: 遇到 [ 保存当前数字和字符串, 遇到 ] 弹出组合.
  • 84 柱状图最大矩形: 单调递增栈, 出栈时确定当前柱子的左右边界.

易错点

  • 单调栈通常存下标, 不只存值, 因为答案常需要距离或宽度.
  • 84 柱状图常在两端加哨兵 0, 简化清栈边界.

12. 堆 / 优先队列

如何理解题目

堆适合动态维护最大值或最小值. 看到 Top K, 数据流, 中位数, 频率排名, 就想堆.

为什么选堆

排序是 O (n log n).如果只要 K 个元素, 用大小为 K 的堆可以做到 O (n log K).

母题: 215 数组中的第 K 个最大元素

int findKthLargest(vector<int>& nums, int k) {
    priority_queue<int, vector<int>, greater<int>> pq; // 小顶堆
    for (int x : nums) {
        pq.push(x);
        if (pq.size() > k) pq.pop(); // 弹出最小,留下 K 个最大
    }
    return pq.top();
}

理解重点: 求第 K 大, 用小顶堆维护 K 个最大值. 堆顶是这 K 个最大值里最小的, 也就是全局第 K 大.

题目映射

  • 347 前 K 高频元素: 先哈希计数, 再按频率建堆.
  • 295 数据流中位数: 左边大顶堆, 右边小顶堆, 维护两边大小差不超过 1.
  • 23 合并 K 个有序链表: 小顶堆每次取当前最小节点.

易错点

  • C++ 默认 priority_queue<int> 是大顶堆; 小顶堆要写 greater<int>.
  • Top K 大用小顶堆, Top K 小用大顶堆.
  • 数据流中位数要同时维护大小平衡和左右大小关系.

13. 贪心

如何理解题目

贪心是 “每一步做当前看起来最好的选择”.但不是所有最优问题都能贪心. 你要能解释为什么局部最优不会破坏全局最优.

典型信号:

  • 只需要一次扫描维护某个最优状态.
  • 区间切分, 跳跃覆盖, 买卖股票一次交易.
  • 每一步选择有明确不可逆优势.

母题: 121 买卖股票的最佳时机

int maxProfit(vector<int>& prices) {
    if (prices.empty()) return 0;
    int minPrice = prices[0], ans = 0;
    for (int p : prices) {
        minPrice = min(minPrice, p);
        ans = max(ans, p - minPrice);
    }
    return ans;
}

理解重点: 如果今天卖出, 要让利润最大, 就应该在今天之前最低价买入. 所以扫描时维护历史最低价.

母题: 55 跳跃游戏

bool canJump(vector<int>& nums) {
    int farthest = 0;
    for (int i = 0; i < nums.size(); ++i) {
        if (i > farthest) return false;
        farthest = max(farthest, i + nums[i]);
    }
    return true;
}

核心思想: 不关心具体怎么跳, 只关心目前能覆盖到的最远位置.

题目映射

  • 45 跳跃游戏 II: 在当前步数能覆盖的范围内, 选择下一步能到的最远位置.
  • 763 划分字母区间: 每段必须覆盖段内所有字符的最后出现位置.

易错点

  • 贪心需要证明, 不能看到 “最大 / 最小” 就硬贪.
  • 55 问能不能到, 45 问最少几步, 状态变量不同.

14. 动态规划

如何理解题目

动态规划适合 “最优值 / 方案数 / 可行性”, 并且问题能拆成重复子问题.

读 DP 题按五步:

  1. 状态定义: dp[i] 或 dp[i][j] 表示什么?
  2. 转移方程: 当前状态从哪些旧状态来?
  3. 初始化: 空串, 0 金额, 第一行第一列是什么?
  4. 遍历顺序: 从前往后, 从后往前, 外层物品还是容量?
  5. 答案位置: 返回 dp[n], max(dp) 还是别的?

单维 DP 母题: 70 爬楼梯

int climbStairs(int n) {
    if (n <= 2) return n;
    int a = 1, b = 2;
    for (int i = 3; i <= n; ++i) {
        int c = a + b;
        a = b;
        b = c;
    }
    return b;
}

理解重点: 到第 i 阶只有两种来源: 从 i-1 走一步, 或从 i-2 走两步.

背包母题: 322 零钱兑换

int coinChange(vector<int>& coins, int amount) {
    const int INF = amount + 1;
    vector<int> dp(amount + 1, INF);
    dp[0] = 0;
    for (int i = 1; i <= amount; ++i) {
        for (int c : coins) {
            if (i >= c) dp[i] = min(dp[i], dp[i - c] + 1);
        }
    }
    return dp[amount] == INF ? -1 : dp[amount];
}

理解重点: dp[i] 表示凑出金额 i 的最少硬币数. 最后一枚硬币如果是 c, 那么前面要先凑出 i - c.

多维 DP 母题: 1143 最长公共子序列

int longestCommonSubsequence(string a, string b) {
    int m = a.size(), n = b.size();
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (a[i - 1] == b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
            else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
        }
    }
    return dp[m][n];
}

理解重点: dp[i][j] 表示 a 的前 i 个字符和 b 的前 j 个字符的 LCS 长度. 数组开 m+1/n+1 是为了表示空串.

题目映射

  • 198 打家劫舍: 当前位置偷或不偷.
  • 279 完全平方数: 类似零钱兑换.
  • 139 单词拆分: dp[i] 表示前 i 个字符能否拆分.
  • 300 LIS: dp[i] 表示以 i 结尾的最长递增子序列; 进阶用贪心 + 二分.
  • 152 乘积最大子数组: 负数会让最大最小互换, 所以同时维护 max/min.
  • 416 分割等和子集: 转成 01 背包, 能否凑出 sum/2.
  • 62/64 网格路径: 状态来自上方和左方.
  • 5 最长回文子串: 中心扩展更直观; DP 也可.
  • 72 编辑距离: 增, 删, 改三种操作取最小.

易错点

  • DP 最大难点不是代码, 是状态定义. 状态定义错, 后面全错.
  • 01 背包一维优化容量要倒序; 完全背包容量通常正序.
  • 初始化经常决定成败, 比如 dp[0] = 0, 空串状态, 第一行第一列.

15. 技巧题

如何理解题目

技巧题通常有特殊限制: O (1) 空间, 不能修改数组, 线性时间, 数字出现次数有规律. 它们不像常规模板, 更像固定套路.

位运算: 136 只出现一次的数字

int singleNumber(vector<int>& nums) {
    int x = 0;
    for (int n : nums) x ^= n;
    return x;
}

理解重点: a ^ a = 0, a ^ 0 = a, 所有成对数字抵消, 剩下单独的数.

摩尔投票: 169 多数元素

int majorityElement(vector<int>& nums) {
    int cand = 0, vote = 0;
    for (int x : nums) {
        if (vote == 0) cand = x;
        vote += (x == cand) ? 1 : -1;
    }
    return cand;
}

理解重点: 多数元素出现次数超过一半, 它和其他元素两两抵消后一定还能剩下.

题目映射

  • 75 颜色分类: 三指针, 0 放左边, 2 放右边, 1 留中间.
  • 31 下一个排列: 从右找第一个升序对, 交换后反转后缀.
  • 287 寻找重复数: 把数组值当 next 指针, 转成链表找环.

易错点

  • 技巧题要记 “触发条件”:看到成对抵消想异或, 看到超过一半想摩尔投票, 看到数组值指向下标想 Floyd 环.
  • 287 不允许修改数组时, 不能排序, 不能原地哈希.

核心题完整教学单元

学习目标: 能为下列高频题从约束推导出算法, 手写代码, 并用复杂度和边界用例验证. 这里是题库中的具体题解; 排序, 并查集和二维前缀和等通用专题见算法补充专题, 内存放不下时的变体见海量数据处理. 以下均为遵守题目输入约束的 C++17 上下文片段.

142. 环形链表 II: 相遇不是入口, 第二次相遇才是

Floyd 快慢指针相遇时, 慢指针走了 a+b 步, 快指针走了 2(a+b) 步; 设环长为 L, 则 a+b 是 L 的倍数. 令一指针回头, 两者每次走一步, 回头指针走 a 步到入口, 另一指针也从相遇点走 a 步回到入口.

ListNode* detectCycle(ListNode* head) {
    ListNode *slow = head, *fast = head;
    do {
        if (!fast || !fast->next) return nullptr;
        slow = slow->next;
        fast = fast->next->next;
    } while (slow != fast);
    ListNode* entry = head;
    while (entry != slow) { entry = entry->next; slow = slow->next; }
    return entry;
}

时间 O(n), 额外空间 O(1). 边界: 空链表, 单节点无环返回 nullptr; 单节点自环返回该节点. 不要在第一次相遇时直接返回.

98. 验证二叉搜索树: 约束来自全部祖先

只比较父子节点会漏掉 “根为 5, 右子树里有 4”.递归时携带开区间 (low, high); 当前值必须严格位于其中, 左右子树分别收紧上界或下界.

bool valid(TreeNode* node, long long low, long long high) {
    if (!node) return true;
    if (node->val <= low || node->val >= high) return false;
    return valid(node->left, low, node->val) && valid(node->right, node->val, high);
}
bool isValidBST(TreeNode* root) {
    return valid(root, LLONG_MIN, LLONG_MAX);
}

时间 O(n), 递归栈 O(h). 边界: 空树合法; 节点值为 INT_MIN/INT_MAX 时仍正确, 因为边界使用 long long; BST 不允许重复值 (本题约束).

236. 最近公共祖先: 子树报告 “找到谁”

后序递归的返回值不是 “祖先”, 而是 “ 当前子树找到的 p 或 q“.当前节点等于目标则向上报告; 左右各报告一个目标时, 当前节点首次汇合, 故为 LCA.

TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
    if (!root || root == p || root == q) return root;
    TreeNode* left = lowestCommonAncestor(root->left, p, q);
    TreeNode* right = lowestCommonAncestor(root->right, p, q);
    if (left && right) return root;
    return left ? left : right;
}

时间 O(n), 栈 O(h). 边界: 一个目标是另一个目标祖先时返回祖先; 题目保证两个节点存在, 若业务代码不保证, 应先验证两个节点是否均被找到.

124. 二叉树最大路径和: 向上只能交出单边

一条路径在当前节点可以取 leftGain + value + rightGain, 但给父节点的路径不能分叉, 只能选较大的单边. 负贡献应丢弃为 0, 避免拖低路径.

int maxPathSum(TreeNode* root) {
    int answer = INT_MIN;
    function<int(TreeNode*)> gain = [&](TreeNode* node) {
        if (!node) return 0;
        int left = max(0, gain(node->left));
        int right = max(0, gain(node->right));
        answer = max(answer, node->val + left + right);
        return node->val + max(left, right);
    };
    gain(root);
    return answer;
}

时间 O(n), 栈 O(h). 边界: 全负树必须返回最大的那个节点, 故全局答案初始化为 INT_MIN, 不能初始化为 0.

33 与 34: 二分先固定区间语义

33 每轮至少一半严格有序, 先判有序半区, 再判断 target 是否落在该半区.34 则把 “找位置” 转成 “找第一个满足谓词的位置”.

int searchRotated(vector<int>& a, int target) {
    int lo = 0, hi = static_cast<int>(a.size()) - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] == target) return mid;
        if (a[lo] <= a[mid]) {
            if (a[lo] <= target && target < a[mid]) hi = mid - 1;
            else lo = mid + 1;
        } else {
            if (a[mid] < target && target <= a[hi]) lo = mid + 1;
            else hi = mid - 1;
        }
    }
    return -1;
}
vector<int> searchRange(vector<int>& a, int target) {
    auto firstGE = [&](int x) {
        int lo = 0, hi = static_cast<int>(a.size());
        while (lo < hi) { int mid = lo + (hi - lo) / 2; if (a[mid] >= x) hi = mid; else lo = mid + 1; }
        return lo;
    };
    int left = firstGE(target), right = firstGE(target + 1) - 1;
    return left < static_cast<int>(a.size()) && a[left] == target ? vector<int>{left, right} : vector<int>{-1, -1};
}

二者时间均为 O(log n), 空间 O(1). 33 的前提是元素互异; 34 要测试空数组, 目标在首 / 尾, 全数组相等. target + 1 只适用于本题值域不会使 int 溢出的约束; 通用代码应写 “ 第一个 > target“ 的谓词.

239. 滑动窗口最大值: 队首永远是候选最大值

双端队列存下标, 且对应值递减. 新值进入前, 尾部所有不大于它的元素以后不可能成为最大值, 全部删除; 队首过期 (下标 < i-k+1) 则删除.

vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    deque<int> q; vector<int> ans;
    for (int i = 0; i < static_cast<int>(nums.size()); ++i) {
        while (!q.empty() && q.front() <= i - k) q.pop_front();
        while (!q.empty() && nums[q.back()] <= nums[i]) q.pop_back();
        q.push_back(i);
        if (i >= k - 1) ans.push_back(nums[q.front()]);
    }
    return ans;
}

时间 O(n)(每个下标最多进出一次), 空间 O(k). 边界: k=1 输出原数组; k=n 只输出一个最大值; 实际接口应拒绝 k<=0 或 k>n, LeetCode 输入保证合法.

287. 寻找重复数: 数组值构成链表

值域为 [1,n] 且数组长度 n+1, 因此从下标 0 沿 next=nums[index] 行走必进环; 重复值就是环入口. 不能排序或修改数组时, 这是 O(1) 空间解.

int findDuplicate(vector<int>& nums) {
    int slow = nums[0], fast = nums[0];
    do { slow = nums[slow]; fast = nums[nums[fast]]; } while (slow != fast);
    int entry = nums[0];
    while (entry != slow) { entry = nums[entry]; slow = nums[slow]; }
    return entry;
}

时间 O(n), 空间 O(1). 边界: 重复值可以出现两次以上; 索引从 0 起但值从 1 起正是构图成立的条件, 业务数组不满足该值域时不可直接复用.

31, 41, 238: 原地不变量与双向前后缀

31 从右找第一个 a[i] < a[i+1] 的位置; 右侧已是最大降序后缀, 交换为刚好更大的值后反转后缀得到最小增序.41 仅关心 [1,n], 不断把 x 放在 a[x-1]. 238 先写每个位置左积, 再乘右积, 避开除法与零.

void nextPermutation(vector<int>& a) {
    int i = static_cast<int>(a.size()) - 2;
    while (i >= 0 && a[i] >= a[i + 1]) --i;
    if (i >= 0) { int j = static_cast<int>(a.size()) - 1; while (a[j] <= a[i]) --j; swap(a[i], a[j]); }
    reverse(a.begin() + i + 1, a.end());
}
int firstMissingPositive(vector<int>& a) {
    int n = a.size();
    for (int i = 0; i < n; ++i)
        while (a[i] >= 1 && a[i] <= n && a[a[i] - 1] != a[i]) swap(a[i], a[a[i] - 1]);
    for (int i = 0; i < n; ++i) if (a[i] != i + 1) return i + 1;
    return n + 1;
}
vector<int> productExceptSelf(vector<int>& a) {
    int n = a.size(); vector<int> ans(n, 1); int left = 1, right = 1;
    for (int i = 0; i < n; ++i) { ans[i] = left; left *= a[i]; }
    for (int i = n - 1; i >= 0; --i) { ans[i] *= right; right *= a[i]; }
    return ans;
}

三题均为时间 O(n); 31 额外空间 O(1), 41 O(1), 238 除输出数组外 O(1). 边界: 31 全降序如 [3,2,1] 变 [1,2,3]; 41 [1,1] 返回 2, 全负数返回 1; 238 含一个或多个 0 时仍由前后缀自然得到正确结果.

可复用模板: 窗口, 队列, BFS, 迭代树, 背包顺序

// 返回任一可达格子到最近源点的最大边数;无源点返回 -1,源点距离为 0.
int multiSourceBfsMaxDistance(vector<vector<int>>& grid, vector<pair<int, int>> sources) {
    if (sources.empty()) return -1;
    if (grid.empty() || grid[0].empty()) throw invalid_argument("sources require a non-empty grid");
    const int rows = static_cast<int>(grid.size());
    const int cols = static_cast<int>(grid[0].size());
    for (const auto& row : grid) if (static_cast<int>(row.size()) != cols) {
        throw invalid_argument("grid must be rectangular");
    }

    queue<pair<int, int>> q;
    vector<vector<bool>> visited(rows, vector<bool>(cols, false));
    for (auto [r, c] : sources) {
        if (r < 0 || r >= rows || c < 0 || c >= cols) throw out_of_range("source out of bounds");
        if (!visited[r][c]) { visited[r][c] = true; q.push({r, c}); }
    }
    int distance = 0;
    constexpr int dr[] = {1, -1, 0, 0}, dc[] = {0, 0, 1, -1};
    while (!q.empty()) {
        int levelSize = static_cast<int>(q.size());
        bool expandedNextLevel = false;
        while (levelSize-- > 0) {
            auto [r, c] = q.front(); q.pop();
            for (int d = 0; d < 4; ++d) {
                int nr = r + dr[d], nc = c + dc[d];
                if (nr < 0 || nr >= rows || nc < 0 || nc >= cols || visited[nr][nc]) continue;
                visited[nr][nc] = true; // 入队时标记,避免重复入队.
                q.push({nr, nc});
                expandedNextLevel = true;
            }
        }
        if (expandedNextLevel) ++distance;
    }
    return distance;
}
vector<int> inorderIterative(TreeNode* root) {
    vector<int> out; stack<TreeNode*> st;
    while (root || !st.empty()) {
        while (root) { st.push(root); root = root->left; }
        root = st.top(); st.pop(); out.push_back(root->val); root = root->right;
    }
    return out;
}

滑动窗口的不变量是 “扩张后, 持续左缩直到重新合法”; 单调队列的不变量是 “候选值单调且下标未过期”.上述 BFS 将全部格子视为可通行; 障碍格应在扩展前增加可通行判断. BFS 必须在入队时标记, 否则同一节点可能被重复入队; 空图和无源点分别按接口约定返回. 时间 O(rows*cols), 额外空间 O(rows*cols). 迭代中序对空树输出空数组, 时间 O(n), 栈 O(h).

01 背包的每件物品只能取一次, 所以容量必须倒序: for (c = cap; c >= w; --c), 否则当前物品刚写入的 dp[c-w] 会在同轮被再次使用. 完全背包允许重复取, 故容量正序: for (c = w; c <= cap; ++c). 练习边界: 容量小于全部重量应保持 dp[cap]=0; 重量为 0 的物品要单独按题意处理, 不能套上述循环.

主题练习与预期证据

  1. 手写 239, 输入 [1,3,-1,-3,5,3,6,7], k=3, 预期 [3,3,5,5,6,7], 并能解释每个下标为何只出队一次.
  2. 为 124 写全负树 [-3,-2,-1] 的测试, 预期 -1; 为 41 写 [3,4,-1,1], 预期 2.
  3. 不看答案说明 287 为什么不能使用 “值为 0” 或越界的业务数组. 预期证据是写出值域到有环映射的前提, 而不是只背 Floyd 名称.

刷题策略总结

高频母题 (背到能默写)

反转链表, 二叉树三种遍历 + 层序, 二分模板, 回溯模板, 滑动窗口模板, 01 背包 / 完全背包 / LCS. 这些模板覆盖了 Hot 100 的大多数题.

针对中级 Android 的优先级

  1. 第一梯队 (必刷, 中小厂够用): 本文 ⭐ 标记的题. 尤其 146 LRU, 链表全套, 二叉树遍历, 岛屿数量, 爬楼梯/打家劫舍/零钱兑换, 买卖股票, 20 有效括号.
  2. 第二梯队 (冲大厂): 其余中等题.
  3. 第三梯队 (△ 困难, 选刷): 42 接雨水, 4 中位数, 23 合并 K 个, 25 K 个一组, 84 柱状图, 124 最大路径和, 51 N 皇后, 72 编辑距离. 时间不够可以缓刷, 但要知道题型.

学习方法

  • 先学模板再刷题: 不要一上来硬刷. 先把每类母题理解清楚.
  • 每题都问 “为什么”: 为什么不是暴力? 为什么这个算法能降复杂度? 为什么边界这样写?
  • 手写 + 口述复杂度: 面试时要边写边讲, 不能只会提交.
  • 二刷三刷: 第一遍看题解理解, 第二遍独立写, 第三遍默写模板.
  • 建立错题本: 记录卡在哪个判断, 哪个边界, 哪个数据结构.

算法补充专题

Hot 100 没专门成章, 但面试 (尤其国内) 高频会问的经典主题. 本章按 “如何理解题目 → 为什么选算法 → 核心思想 → C++ 模板 → 易错点” 来写. 目标不是让你背代码, 而是让你知道题目出现某些信号时该选什么算法.

优先级: 排序手写 ⭐, 位运算 ⭐, 前缀和 / 差分, 并查集, 设计题. 排序和位运算几乎必问; 并查集和前缀和是中高频; LFU 等设计题偏大厂.

代码上下文: 示例使用 C++17, 省略重复的标准库头文件和 using namespace std;. 片段按文中给出的前置条件使用; 复制到独立文件时, 需要补齐该片段使用的标准库头文件.

进度自测

  • ⭐ 手写快速排序: 能解释分区, 平均 / 最坏复杂度, 为什么不稳定
  • ⭐ 手写归并排序: 能解释稳定性, 为什么链表排序常用归并
  • ⭐ 手写堆排序: 能解释建堆, 下沉, 为什么堆适合 Top K
  • 排序对比: 稳定性 / 复杂度 / 适用场景
  • ⭐ 位运算基础: & | ^ ~ << >>
  • ⭐ n & (n - 1), 判 2 的幂, 统计 1 的个数
  • 出现 3 次只出 1 次, 子集枚举, 状态压缩
  • 并查集模板: find 路径压缩 + union 按秩 / 按大小
  • 并查集应用: 省份数量 547 / 冗余连接 684
  • 一维前缀和 / 二维前缀和
  • 差分数组: 区间增减
  • 设计题: LFU 460 / 用栈实现队列 232 / 循环队列 622

学习目标与章节边界

本章训练可迁移的算法专题: 排序性质, 并查集, 前缀和与数据结构组合; 具体 Hot 100 题的逐题推导, 代码与边界统一在 LeetCode Hot 100 算法清单的 “核心题完整教学单元”, 数据超过单机内存时改看海量数据处理. 完成后应能写出专题模板, 给出复杂度推导, 并用一个反例解释选型边界.


学算法的通用方法

很多人学算法痛苦, 是因为一上来就背代码. 正确顺序应该是:

  1. 先翻译题目: 题目到底让你求什么? 是存在性, 数量, 最大最小, 所有方案, 还是设计接口?
  2. 看限制条件: 时间, 空间, 输入规模决定算法上限.
  3. 识别关键词: 有序, 连续, 频次, 连通, 区间修改, Top K, 出现次数.
  4. 先说暴力: 暴力怎么做? 复杂度多少? 为什么不够?
  5. 再选算法: 说明这个算法利用了题目的哪个性质.
  6. 最后写模板: 写稳定模板, 不要临场发明边界.

面试中可以这样表达:

暴力做法是 [暴力方案], 复杂度为 [暴力复杂度].题目要求 / 数据规模不允许. 这里有 [题目特征], 所以我选 [算法名称].核心是 [核心性质], 实现上维护 [关键状态], 复杂度为 [目标复杂度].


时间复杂度和空间复杂度

令 n 为输入规模, 例如数组长度, 节点数或字符串长度. 复杂度描述 n 增大时资源消耗的渐进增长, 不是某台机器上的精确耗时. 忽略常数和低阶项: 3n^2 + 2n + 1 是 Theta(n^2), 因为大 n 时二次项主导; 但常数在实际约束下仍会影响能否通过.

  • O(f(n)) 是渐进上界, Omega(f(n)) 是渐进下界, Theta(f(n)) 同时给出紧确的上, 下界. 面试通常报 O 作为可保证的上界, 但不能把 O(n^2) 误说成一定是 Theta(n^2).
  • 最好, 平均和最坏复杂度分别对应最有利, 按输入分布期望和最不利的输入. 摊还复杂度把一系列操作的总成本均摊到单次操作, 例如动态数组扩容的单次追加摊还为 O(1), 不表示每次追加都只执行常数次复制.
  • 空间复杂度要说明口径: 额外空间是不含输入本身的辅助内存; 递归还要计入调用栈. 例如归并排序通常额外 O(n), 二分查找的迭代写法额外 O(1), 递归写法还占 O(log n) 栈空间.

常见增长从低到高是 O(1), O(log n), O(n), O(n log n), O(n^2), O(2^n), O(n!). 二分查找每轮将候选规模减半, 经过 k 轮满足 n / 2^k <= 1, 所以 k = O(log n); 两层各扫描 n 次的嵌套循环执行约 n * n 次, 因此是 O(n^2). 选型还必须代入约束: n 为 10^5 时通常应避免 O(n^2), n 为 20 左右时 O(2^n) 的状态枚举可能可行, O(n!) 则往往只适合更小的 n 或强剪枝.


1. 手写排序 (⭐ 国内几乎必问)

如何理解排序题

面试让你手写排序, 不是考你会不会调用 sort, 而是考你是否理解:

  • 为什么平均是 O (n log n)?
  • 最坏情况什么时候出现?
  • 是否稳定?
  • 是否原地?
  • 适合数组还是链表?
  • 工程里什么时候不用自己写?

常见选择:

  • 快速排序: 数组通用排序, 平均快, 原地, 但不稳定, 最坏 O (n²).
  • 归并排序: 稳定, 复杂度稳定 O (n log n), 需要 O (n) 空间, 适合链表和外部排序.
  • 堆排序: 原地, 最坏 O (n log n), 不稳定; 思想和优先队列 / Top K 相关.

1.1 快速排序

核心思想

快排是分治:

  1. 选一个基准值 pivot.
  2. 分区: 小于 pivot 的放左边, 大于等于 pivot 的放右边.
  3. pivot 归位后, 递归排序左右两边.

关键理解: 分区后 pivot 的最终位置已经确定, 后续不需要再动它.

C++ 模板

#include <vector>
#include <cstdlib>
#include <algorithm>
using namespace std;

int partition(vector<int>& a, int lo, int hi) {
    int pivot = a[hi];
    int i = lo; // [lo, i) 都小于 pivot
    for (int j = lo; j < hi; ++j) {
        if (a[j] < pivot) {
            swap(a[i], a[j]);
            i++;
        }
    }
    swap(a[i], a[hi]);
    return i;
}

void quickSort(vector<int>& a, int lo, int hi) {
    if (lo >= hi) return;
    int p = partition(a, lo, hi);
    quickSort(a, lo, p - 1);
    quickSort(a, p + 1, hi);
}

怎么向面试官解释

  • 平均复杂度 O (n log n):每次分区 O (n), 理想情况下递归深度 log n.
  • 最坏 O (n²):如果数组已经有序且每次选端点作 pivot, 每次只减少一个元素.
  • 额外空间平均 O (log n), 最坏 O (n):递归栈深度取决于分区是否均衡.
  • 不稳定: 交换会打乱相等元素的原始顺序.

优化

  • 随机 pivot: 降低遇到最坏情况的概率.
  • 三数取中: 从头, 中, 尾选中位数作 pivot.
  • 重复值很多时使用三路分区, 避免大量相等元素导致分区极度不均衡.
  • 小数组用插入排序: 工程优化.

一次分区 trace: 数组 [4,2,5,2,3], 以末尾 3 为 pivot. 扫描到 2 时交换到左区, 扫描到第二个 2 时再交换, 扫描结束得到 [2,2,3,4,5], pivot 位于 2; 递归只处理 [2,2] 与 [4,5]. 这里的 “不稳定” 也可见于携带原始序号的 (2,A),(2,B): 跨 pivot 的交换可能改变 A/B 顺序.

int randomizedPartition(vector<int>& a, int lo, int hi) {
    int pivotIndex = lo + rand() % (hi - lo + 1);
    swap(a[pivotIndex], a[hi]);
    return partition(a, lo, hi); // 依赖本节已有 Lomuto partition
}

void quickSort3Way(vector<int>& a, int lo, int hi) {
    if (lo >= hi) return;
    int pivotIndex = lo + rand() % (hi - lo + 1);
    int pivot = a[pivotIndex];
    swap(a[lo], a[pivotIndex]);
    int lt = lo, i = lo + 1, gt = hi;
    while (i <= gt) {
        if (a[i] < pivot) swap(a[lt++], a[i++]);
        else if (a[i] > pivot) swap(a[i], a[gt--]);
        else ++i;
    }
    quickSort3Way(a, lo, lt - 1);
    quickSort3Way(a, gt + 1, hi);
}

随机 pivot 不改变最坏 O(n^2), 但使对固定输入的期望复杂度为 O(n log n); 三路分区将等于 pivot 的元素一次性跳过, 重复值很多时明显减少递归. 边界: 全相等数组应令三路分区一次结束; rand() 仅作教学随机化, 生产代码使用 <random> 的确定性种子策略或标准库排序.

1.2 归并排序

核心思想

归并排序也是分治:

  1. 把数组从中间拆成两半.
  2. 分别递归排序.
  3. 合并两个有序数组.

它稳定, 是因为合并时如果左右元素相等, 先放左边元素, 就保持了原始相对顺序.

C++ 模板

void mergeSort(vector<int>& a, int lo, int hi, vector<int>& tmp) {
    if (lo >= hi) return;
    int mid = lo + (hi - lo) / 2;
    mergeSort(a, lo, mid, tmp);
    mergeSort(a, mid + 1, hi, tmp);

    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) {
        if (a[i] <= a[j]) tmp[k++] = a[i++]; // <= 保证稳定
        else tmp[k++] = a[j++];
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= hi) tmp[k++] = a[j++];

    for (int x = lo; x <= hi; ++x) a[x] = tmp[x];
}

void mergeSort(vector<int>& a) {
    if (a.empty()) return;
    vector<int> tmp(a.size());
    mergeSort(a, 0, static_cast<int>(a.size()) - 1, tmp);
}

适用场景

  • 需要稳定排序.
  • 链表排序: 链表合并两个有序链表很方便, 不需要随机访问.
  • 外部排序: 数据太大放不进内存时, 可以分块排序再多路归并.

1.3 堆排序

核心思想

堆是一棵完全二叉树, 用数组表示:

  • 下标 i 的左孩子: 2*i + 1
  • 下标 i 的右孩子: 2*i + 2
  • 下标 i 的父节点: (i - 1) / 2

大顶堆满足: 每个节点都大于等于孩子. 因此堆顶是最大值.

堆排序流程:

  1. 建大顶堆.
  2. 把堆顶最大值交换到数组末尾.
  3. 缩小堆范围, 对新堆顶下沉.
  4. 重复直到有序.

C++ 模板

void sink(vector<int>& a, int root, int n) {
    while (true) {
        int largest = root;
        int l = root * 2 + 1;
        int r = root * 2 + 2;
        if (l < n && a[l] > a[largest]) largest = l;
        if (r < n && a[r] > a[largest]) largest = r;
        if (largest == root) break;
        swap(a[root], a[largest]);
        root = largest;
    }
}

void heapSort(vector<int>& a) {
    int n = a.size();
    for (int i = n / 2 - 1; i >= 0; --i) sink(a, i, n); // 建堆
    for (int end = n - 1; end > 0; --end) {
        swap(a[0], a[end]);
        sink(a, 0, end);
    }
}

怎么理解建堆 O (n)

不是每个节点都下沉 log n. 底层节点很多但下沉距离短, 上层节点少但下沉距离长, 总和是 O (n).面试一般说结论即可.

1.4 冒泡排序的可验证优化

冒泡排序相邻比较并交换逆序元素, 每轮会把当前无序区的最大值送到右侧. 只在 a[j] > a[j + 1] 时交换, 相等元素不交换, 因此它是稳定排序. 基础实现最好, 平均, 最坏时间分别为 O(n), O(n^2), O(n^2) (最好情况依赖提前退出), 空间 O(1); 工程中通常使用标准库排序, 此处用于理解交换和边界.

void bubbleSort(vector<int>& a) {
    int unsortedEnd = static_cast<int>(a.size()) - 1;
    while (unsortedEnd > 0) {
        int lastSwap = -1;
        for (int j = 0; j < unsortedEnd; ++j) {
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
                lastSwap = j;
            }
        }
        if (lastSwap == -1) break; // 本轮没有交换,数组已有序
        unsortedEnd = lastSwap;
    }
}

lastSwap 之后的区间在本轮没有发生交换, 已经有序, 下一轮无需再扫描. -1 专门表示本轮没有交换, 因此不会与索引 0 发生交换混淆. 用 [1,2,3,4] 验证首次扫描即退出; 用 [3,2,1] 验证连续交换得到 [1,2,3].

1.5 红黑树: 有界高度的有序映射基础

红黑树是带颜色约束的二叉搜索树. 它满足二叉搜索树顺序, 并维持以下关键性质:

  • 根节点为黑色.
  • 所有空叶子节点 (概念上的 NIL) 为黑色.
  • 红色节点的子节点都是黑色, 不存在连续红节点.
  • 从任一节点到其所有后代 NIL 叶子的路径, 黑色节点数相同, 称为黑高一致.

这些约束限制了最长路径不会超过最短路径的两倍, 因而树高为 O(log n), 查找, 插入和删除都能保持 O(log n). 工程上它适合需要按键有序遍历, 范围查询或有序映射 / 集合的场景; 具体 JDK 类采用何种内部实现和阈值会随版本变化, 不应把某个 JDK 的细节当作通用承诺.

插入新节点通常先按普通二叉搜索树插入并标红, 以免立即改变各路径黑高. 若出现红父子冲突:

  1. 叔节点为红: 将父和叔染黑, 祖父染红, 把检查上移到祖父.
  2. 叔节点为黑或 NIL: 先将 “内侧” 形状旋转为 “外侧”, 再围绕祖父旋转, 并交换父 / 祖父的颜色.

修复循环结束后必须无条件将根节点染黑, 包括首次插入以及叔节点为红, 检查上移到根的情形; 这样才能恢复根为黑色的性质.

以左左形状为例, grandparent 的左孩子 parent 和 parent 的左孩子 node 均为红. 对祖父右旋, 然后将 parent 染黑, grandparent 染红; 中序顺序仍是 left < parent < grandparent < right, 所以旋转没有破坏二叉搜索树顺序, 只调整了局部父子关系.

删除若移除了黑节点, 可能使某一路少一个黑色. 修复围绕 “带额外黑色” 的节点及其兄弟进行: 兄弟为红时先旋转并换色, 转成兄弟为黑的情况; 兄弟及其子节点都黑时把兄弟染红并把问题上移; 兄弟有靠外的红孩子时, 通过一次或两次旋转和重新着色让路径黑高恢复. 面试回答重点是 “旋转保持中序顺序, 变色恢复红节点和黑高约束”, 不必背诵某个库的全部分支代码.

排序对比 (高频问)

算法平均最坏额外空间稳定核心用途
快排O (n log n)O (n²)O (log n)否数组通用排序, 实际很快
归并O (n log n)O (n log n)O (n)是稳定排序, 链表排序, 外部排序
堆排O (n log n)O (n log n)O (1)否原地排序, 理解 Top K
插入O (n²)O (n²)O (1)是小数组, 近乎有序数组
冒泡O (n²)O (n²)O (1)是教学, 提前退出时最好 O (n)

易错点

  • 快排分区时循环边界最容易错, 建议固定一种 partition 写法.
  • 快排不是稳定排序.
  • 归并合并时 <= 才能保持稳定.
  • 堆排序下沉范围 n 是当前堆大小, 不是数组总长度.
  • Java 中基本类型 Arrays.sort(int[]) 不是稳定排序; 对象数组排序是稳定的 TimSort.

2. 位运算专题 (⭐)

如何理解位运算题

位运算题常见信号:

  • 数字出现次数有规律: 一个出现一次, 其余出现两次 / 三次.
  • 要求 O (1) 额外空间.
  • 需要表示集合状态: 选/不选, 访问/未访问.
  • 需要判断奇偶, 2 的幂, 某一位是否为 1.

位运算本质是把整数看成二进制位数组.

基础运算

运算含义例子
&两位都为 1 才为 1判断某位是否为 1
``有一位为 1 就为 1
^相同为 0, 不同为 1成对抵消
~按位取反掩码处理
<<左移, 乘 21 << i 表示第 i 位
>>右移取高位 / 除 2

必背技巧

以下是表达式速记, 不是可独立编译的函数:

n & (n - 1);       // 消除最低位的 1
n & -n;            // 取最低位的 1,也叫 lowbit
(n & (n - 1)) == 0 // 判断 2 的幂,前提 n > 0
(x >> i) & 1;      // 取第 i 位
x | (1 << i);      // 把第 i 位置 1
x & ~(1 << i);     // 把第 i 位置 0
x ^ (1 << i);      // 翻转第 i 位

异或为什么能找单身数

异或有三个性质:

  • a ^ a = 0
  • a ^ 0 = a
  • 交换律和结合律成立

所以一堆数里, 成对出现的数字都会抵消, 最后剩下只出现一次的数.

int singleNumber(vector<int>& nums) {
    int ans = 0;
    for (int x : nums) ans ^= x;
    return ans;
}

统计 1 的个数

int hammingWeight(unsigned int n) {
    int cnt = 0;
    while (n != 0) {
        n &= (n - 1); // 每次消掉一个最低位的 1
        cnt++;
    }
    return cnt;
}

复杂度不是固定 32 次, 而是和 1 的个数有关.

判 2 的幂

bool isPowerOfTwo(int n) {
    return n > 0 && (n & (n - 1)) == 0;
}

理解: 2 的幂二进制只有一个 1, 例如 8 是 1000, 7 是 0111, 相与为 0.

出现 3 次, 只出现 1 次

思路: 每一位单独统计 1 的个数. 如果其他数都出现 3 次, 那么每一位的计数对 3 取模后, 剩下的就是只出现一次的数在这一位上的值.

#include <cstdint>

int32_t singleNumberII(vector<int>& nums) {
    uint32_t ans = 0;
    for (int i = 0; i < 32; ++i) {
        int cnt = 0;
        for (int x : nums) {
            uint32_t bits = static_cast<uint32_t>(x);
            if ((bits >> i) & 1u) cnt++;
        }
        if (cnt % 3) ans |= (uint32_t{1} << i);
    }
    return static_cast<int32_t>(ans);
}

子集枚举

如果 n 比较小, 可以用一个整数 mask 表示选了哪些元素.

vector<vector<int>> subsets(vector<int>& nums) {
    int n = nums.size();
    if (n >= 31) throw invalid_argument("subsets requires n < 31");
    vector<vector<int>> res;
    uint32_t total = uint32_t{1} << n;
    for (uint32_t mask = 0; mask < total; ++mask) {
        vector<int> cur;
        for (int i = 0; i < n; ++i) {
            if ((mask >> i) & 1u) cur.push_back(nums[i]);
        }
        res.push_back(cur);
    }
    return res;
}

易错点

  • 判断 2 的幂必须加 n > 0.
  • 左移注意溢出, 1 << 31 对有符号 int 有风险, 必要时用 1LL.
  • C++ 负数右移和符号位相关, 题目涉及无符号时用 unsigned 更清晰.

3. 并查集 (Union-Find)

如何理解题目

并查集专门处理 “分组” 和 “连通性”.看到这些表达可以优先想并查集:

  • 两个元素是否属于同一组.
  • 不断合并集合.
  • 最后有几个连通分量.
  • 加一条边会不会形成环.

典型题: 省份数量, 冗余连接, 等式方程可满足性.

为什么选并查集

如果每次都 DFS/BFS 判断两个点是否连通, 多次查询会很慢. 并查集把每个集合用一个代表元表示, 合并和查询都接近 O (1).

核心思想

  • parent[x] 表示 x 的父节点.
  • 如果 parent[x] == x, x 是这个集合的根.
  • find(x) 找 x 的根.
  • union(a, b) 把两个集合合并.

两个优化:

  1. 路径压缩: find 时把沿途节点直接挂到根上.
  2. 按秩 / 按大小合并: 小树挂到大树下, 避免树太高.

C++ 模板

class UnionFind {
    vector<int> parent;
    vector<int> size;
public:
    int count;

    UnionFind(int n) : parent(n), size(n, 1), count(n) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }

    bool unite(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (size[ra] < size[rb]) swap(ra, rb);
        parent[rb] = ra;
        size[ra] += size[rb];
        count--;
        return true;
    }

    bool connected(int a, int b) {
        return find(a) == find(b);
    }
};

应用: 547 省份数量

下面的函数依赖上一小节定义的 UnionFind 类型, 不是独立代码块.

int findCircleNum(vector<vector<int>>& isConnected) {
    int n = isConnected.size();
    UnionFind uf(n);
    for (int i = 0; i < n; ++i) {
        for (int j = i + 1; j < n; ++j) {
            if (isConnected[i][j] == 1) uf.unite(i, j);
        }
    }
    return uf.count;
}

应用: 684 冗余连接

遍历边 (u, v):

  • 如果 u 和 v 已经连通, 再加这条边就成环, 这条边就是答案.
  • 否则合并它们.
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
    UnionFind uf(static_cast<int>(edges.size()) + 1); // 题目节点编号从 1 开始
    for (const auto& edge : edges) {
        if (!uf.unite(edge[0], edge[1])) return edge;
    }
    return {};
}

对 n 条边, 时间 O(n alpha(n)), 空间 O(n), 其中 alpha 为反阿克曼函数, 实践中近似常数. 边界: 若输入不保证 “恰有一条冗余边”, 空返回仅表示没有找到, 调用方不能把它当有效边.

易错点

  • find 不做路径压缩, 最坏会退化.
  • 合并时要合并根, 不是直接 parent[a] = b.
  • 节点编号有时从 1 开始, 要开 n + 1.

4. 前缀和 & 差分

如何理解题目

前缀和和差分是一对逆运算.

  • 前缀和: 适合多次查区间和. 先预处理, 后面 O (1) 查询.
  • 差分: 适合多次做区间修改. 每次 O (1) 标记, 最后统一还原.

关键词: 区间和, 子数组和, 区域和, 区间加, 批量更新.

4.1 一维前缀和

定义: pre[i] 表示前 i 个元素的和, 也就是 nums[0..i-1].

区间 [l, r] 的和: pre[r + 1] - pre[l].

vector<long long> buildPrefix(const vector<int>& nums) {
    int n = nums.size();
    vector<long long> pre(n + 1, 0);
    for (int i = 0; i < n; ++i) {
        pre[i + 1] = pre[i] + nums[i];
    }
    return pre;
}

long long rangeSum(const vector<long long>& pre, int l, int r) {
    return pre[r + 1] - pre[l];
}

为什么开 n + 1? 因为 pre[0] = 0 表示空前缀, 可以统一处理从 0 开始的区间.

4.2 前缀和 + 哈希表: 560 和为 K 的子数组

普通滑动窗口不适用, 因为数组可能有负数. 用前缀和:

如果 pre[j] - pre[i] = k, 说明 (i, j] 这段子数组和为 k. 遍历到当前前缀和 sum 时, 只要知道之前有多少个 sum - k.

int subarraySum(vector<int>& nums, int k) {
    unordered_map<long long, int> cnt;
    cnt[0] = 1;
    long long sum = 0;
    int ans = 0;
    for (int x : nums) {
        sum += x;
        if (cnt.count(sum - k)) ans += cnt[sum - k];
        cnt[sum]++;
    }
    return ans;
}

4.3 二维前缀和

pre[i][j] 表示从左上角到 (i-1, j-1) 的矩形和.

查询 (r1, c1) 到 (r2, c2):

sum = pre[r2+1][c2+1]
    - pre[r1][c2+1]
    - pre[r2+1][c1]
    + pre[r1][c1]

最后加回左上角, 是因为它被减了两次.

vector<vector<long long>> buildPrefix2D(const vector<vector<int>>& matrix) {
    int m = matrix.size(), n = m ? matrix[0].size() : 0;
    vector pre(m + 1, vector<long long>(n + 1, 0));
    for (int r = 0; r < m; ++r)
        for (int c = 0; c < n; ++c)
            pre[r + 1][c + 1] = matrix[r][c] + pre[r][c + 1] + pre[r + 1][c] - pre[r][c];
    return pre;
}
long long sumRegion(const vector<vector<long long>>& pre, int r1, int c1, int r2, int c2) {
    return pre[r2 + 1][c2 + 1] - pre[r1][c2 + 1] - pre[r2 + 1][c1] + pre[r1][c1];
}

预处理时间 / 空间 O(mn), 单次合法矩形查询 O(1). 边界: 空矩阵只能构建 1x1 前缀表, 不能查询; 单格查询 (r,c,r,c) 必须返回原值.

4.4 差分数组

如果有很多次 “区间 [l, r] 都加 val”, 逐个元素加会 O (n*m).差分可以每次 O (1):

class Difference {
    vector<long long> diff;
public:
    explicit Difference(const vector<int>& nums) : diff(nums.size()) {
        if (nums.empty()) return;
        diff[0] = nums[0];
        for (size_t i = 1; i < nums.size(); ++i) {
            diff[i] = nums[i] - nums[i - 1];
        }
    }

    void increment(int l, int r, long long val) {
        if (l < 0 || l > r || r >= static_cast<int>(diff.size())) {
            throw out_of_range("invalid difference range");
        }
        diff[l] += val;
        if (r + 1 < static_cast<int>(diff.size())) diff[r + 1] -= val;
    }

    vector<long long> result() const {
        if (diff.empty()) return {};
        vector<long long> res(diff.size());
        res[0] = diff[0];
        for (size_t i = 1; i < diff.size(); ++i) {
            res[i] = res[i - 1] + diff[i];
        }
        return res;
    }
};

典型题

  • 560 和为 K 的子数组: 前缀和 + 哈希表.
  • 724 寻找中心下标: 左和等于右和.
  • 304 二维区域和检索: 二维前缀和.
  • 1109 航班预订统计: 差分数组.
  • 1094 拼车: 差分 + 判断任意位置是否超载.

易错点

  • 前缀和数组建议开 n + 1.
  • 560 必须先 cnt[0] = 1, 代表空前缀.
  • 差分更新右边界时要判断 r + 1 是否越界.
  • 有负数时不要用滑动窗口求和为 K.

5. 设计题

如何理解设计题

设计题不是让你炫复杂代码, 而是考 “数据结构组合”.先问清楚:

  • 每个接口是什么?
  • 每个操作要求什么复杂度?
  • 数据量多大?
  • 是否有淘汰策略或顺序要求?

设计题的套路是: 单一数据结构不够, 就组合两个或多个结构.

5.1 LRU 缓存 (146)

完整实现见 LeetCode Hot 100 算法清单第 6 节 “链表” 中的 LRU 缓存代码. 第 52 篇目前没有 LRU 专属标题; 该真实锚点的目标节包含 LRUCache 完整实现. 这里不重复代码, 只保留选型逻辑:

  • 要 O (1) 查 key: 用哈希表.
  • 要 O (1) 删除最久未使用: 用双向链表维护顺序.
  • get/put 后都要把节点移动到最新位置.

这是 “哈希表 + 双向链表” 的经典组合.

第 52 篇给的是 C++ 版; Android 面试手写通常要求岗位语言, 下面补 Java 手写版, 分 “简单版” 和 “面试手写版”. Kotlin 可等价改写 (data class + LinkedHashMap, 或 HashMap + 双向链表).

简单版: 继承 LinkedHashMap

LinkedHashMap 自带按访问顺序排列与 removeEldestEntry 淘汰钩子, 十几行即可实现:

class LRUCache extends LinkedHashMap<Integer, Integer> {
    private final int capacity;

    public LRUCache(int capacity) {
        // accessOrder = true: get/put 命中的节点移到队尾, 队尾即最近使用.
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    public int get(int key) {
        Integer value = super.get(key);
        // 未命中返回 -1; 命中时 super.get 已更新访问顺序.
        return value == null ? -1 : value;
    }

    public void put(int key, int value) {
        super.put(key, value); // 复用 LinkedHashMap, put 后会自动触发淘汰检查.
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        // 队头是最久未使用; 超容量才淘汰, 由 LinkedHashMap 自动调用.
        return size() > capacity;
    }
}

面试手写版: HashMap + 双向链表

面试常要求不依赖 LinkedHashMap, 手写 “哈希表定位 + 双向链表维护顺序”:

class LRUCache {
    // 节点同时存 key/value: 淘汰尾部时用 key 去删哈希表.
    static class Node {
        int key, value;
        Node prev, next;
        Node(int key, int value) { this.key = key; this.value = value; }
    }

    private final int capacity;
    private final Map<Integer, Node> map = new HashMap<>();
    // 哨兵头尾节点, 避免判空; head 之后是最近使用, tail 之前是最久未使用.
    private final Node head = new Node(0, 0);
    private final Node tail = new Node(0, 0);

    public LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        Node node = map.get(key);
        if (node == null) return -1;
        moveToHead(node); // 访问过就升级为最近使用
        return node.value;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {
            node.value = value;
            moveToHead(node); // 覆盖值并升级为最近使用
            return;
        }
        if (map.size() == capacity) removeTail(); // 满则淘汰最久未使用
        Node fresh = new Node(key, value);
        map.put(key, fresh);
        addToHead(fresh);
    }

    private void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    private void addToHead(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }

    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void removeTail() {
        Node last = tail.prev;
        removeNode(last);
        map.remove(last.key); // 链表和哈希表必须同步删除
    }
}

为什么 get/put 都是 O (1)

  • 哈希表: 查 key 平均 O (1), 负责 “定位”.
  • 双向链表: 已知节点后增删都是 O (1), 直接改前驱 / 后继指针即可. 这正是不能用单链表的原因: 删尾节点要先遍历找前驱, 是 O (n).
  • 两个操作都只是 “一次哈希查找 + 常数次指针改动”, 所以 get/put 均为 O (1).

5.2 LFU 缓存 (460)

如何理解题目

LFU 是 Least Frequently Used: 淘汰访问频次最低的 key; 如果频次相同, 淘汰最久未使用的.

比 LRU 多了一个维度: 访问频次.

选用结构

为了让 get/put 都接近 O (1), 需要:

  • key -> 节点: 快速找到 key.
  • freq -> 双向链表: 同一频次内部按 LRU 排序.
  • minFreq: 当前最小频次, 淘汰时直接定位.

C++ 可用 list + unordered_map 简化.

class LFUCache {
    struct Node { int key, value, freq; };
    int cap, minFreq = 0;
    unordered_map<int, list<Node>::iterator> keyIt;
    unordered_map<int, list<Node>> freqList;

    void touch(list<Node>::iterator it) {
        Node node = *it;
        int f = node.freq;
        freqList[f].erase(it);
        if (freqList[f].empty()) {
            freqList.erase(f);
            if (minFreq == f) minFreq++;
        }
        node.freq++;
        freqList[node.freq].push_front(node);
        keyIt[node.key] = freqList[node.freq].begin();
    }

public:
    LFUCache(int capacity) : cap(capacity) {}

    int get(int key) {
        if (!keyIt.count(key)) return -1;
        int val = keyIt[key]->value;
        touch(keyIt[key]);
        return val;
    }

    void put(int key, int value) {
        if (cap <= 0) return;
        if (keyIt.count(key)) {
            keyIt[key]->value = value;
            touch(keyIt[key]);
            return;
        }
        if (keyIt.size() == cap) {
            auto& lst = freqList[minFreq];
            int oldKey = lst.back().key;
            lst.pop_back();
            keyIt.erase(oldKey);
            if (lst.empty()) freqList.erase(minFreq);
        }
        minFreq = 1;
        freqList[1].push_front({key, value, 1});
        keyIt[key] = freqList[1].begin();
    }
};

易错点

  • 更新已有 key 也算一次访问, 要提升频次.
  • 淘汰后要同步删除 keyIt.
  • 某个频次链表空了, 如果它等于 minFreq, 要更新 minFreq.

5.3 用栈实现队列 (232)

核心思想

队列是先进先出, 栈是后进先出. 用两个栈:

  • in: 负责入队.
  • out: 负责出队.
  • 当 out 空时, 把 in 全部倒进去, 顺序就反过来了.
class MyQueue {
    stack<int> in, out;

    void transfer() {
        if (out.empty()) {
            while (!in.empty()) {
                out.push(in.top());
                in.pop();
            }
        }
    }

public:
    void push(int x) { in.push(x); }

    int pop() {
        transfer();
        int x = out.top(); out.pop();
        return x;
    }

    int peek() {
        transfer();
        return out.top();
    }

    bool empty() { return in.empty() && out.empty(); }
};

复杂度: 单次最坏 O (n), 但每个元素最多进出两个栈一次, 所以摊还 O (1).

5.4 设计循环队列 (622)

核心思想

固定数组 + 头尾指针. 为了区分空和满, 最简单是额外维护 size.

class MyCircularQueue {
    vector<int> data;
    int head = 0, tail = 0, sz = 0, cap;
public:
    MyCircularQueue(int k) : data(k), cap(k) {}

    bool enQueue(int value) {
        if (isFull()) return false;
        data[tail] = value;
        tail = (tail + 1) % cap;
        sz++;
        return true;
    }

    bool deQueue() {
        if (isEmpty()) return false;
        head = (head + 1) % cap;
        sz--;
        return true;
    }

    int Front() { return isEmpty() ? -1 : data[head]; }

    int Rear() {
        if (isEmpty()) return -1;
        return data[(tail - 1 + cap) % cap];
    }

    bool isEmpty() { return sz == 0; }
    bool isFull() { return sz == cap; }
};

5.5 设计 HashMap / Trie

  • HashMap: 数组 + 链表 / 红黑树处理冲突; 核心概念是哈希函数, 冲突, 负载因子, 扩容.
  • Trie: 前缀树, 适合字符串前缀查询. 每个节点保存子节点数组和 isEnd.
class Trie {
    struct Node {
        Node* child[26]{};
        bool isEnd = false;
    };
    Node* root = new Node();
public:
    ~Trie() { destroy(root); }

    void insert(string word) {
        Node* cur = root;
        for (char c : word) {
            int i = c - 'a';
            if (!cur->child[i]) cur->child[i] = new Node();
            cur = cur->child[i];
        }
        cur->isEnd = true;
    }

    bool search(string word) {
        Node* node = find(word);
        return node && node->isEnd;
    }

    bool startsWith(string prefix) {
        return find(prefix) != nullptr;
    }

private:
    void destroy(Node* node) {
        if (!node) return;
        for (Node* child : node->child) destroy(child);
        delete node;
    }

    Node* find(const string& s) {
        Node* cur = root;
        for (char c : s) {
            int i = c - 'a';
            if (!cur->child[i]) return nullptr;
            cur = cur->child[i];
        }
        return cur;
    }
};

易错点

  • 设计题先确认接口, 再写数据结构.
  • LRU/LFU 的关键不是 “能跑”, 而是所有操作是否满足复杂度.
  • 循环队列最容易错的是 Rear() 和取模.
  • Trie 如果字符集不是小写字母, 不能直接用 26 长度数组, 要改成哈希表.

小结: 补充专题优先级

  1. 必吃透: 快排/归并/堆排 + 三者对比; 位运算基础和经典 trick.
  2. 建议掌握: 并查集模板, 前缀和 + 哈希表, 差分数组.
  3. 冲大厂加分: LFU, 循环队列, HashMap/Trie 设计.
  4. 学习方式: 每个专题至少掌握一道母题, 能解释 “为什么选它”, 再去刷变体.

主题练习与预期证据

  1. 用三路快排处理 [2,2,2,1,3], 预期有序输出 [1,2,2,2,3], 并说明等值区间为何不递归.
  2. 为 684 输入 [[1,2],[1,3],[2,3]], 预期返回 [2,3]; 证据是 unite 返回 false 的时刻.
  3. 为矩阵 [[1,2],[3,4]] 查询 (0,1) 到 (1,1), 预期 6; 手算四项容斥证明没有错位.

海量数据处理

这一篇是你的差异化武器. 海量数据题考的是 “内存放不下时怎么办” 的系统思维: 而你做设备指纹 SDK, 天天和内存约束, 大规模数据, 性能极限打交道. 一般应用开发者答不深, 你能结合底层经验讲透, 这是面试加分点.

核心套路就四招:分治 (哈希拆分), 位图, 布隆过滤器, 堆 / 外部排序.

进度自测

  • 哈希分治 (大文件拆小文件)
  • 位图 BitMap (去重/排序/查存在)
  • 布隆过滤器 (原理/误判/删除问题)
  • Top-K (小顶堆 / 分治 / 快速选择)
  • 外部排序 (多路归并)
  • 经典场景: 40 亿整数判存在 / 10 亿 URL 去重 / Top100 热词

学习目标与章节边界

本章只处理 “单机内存放不下” 的精确或近似计算, 强调容量, 磁盘 I/O 与分桶倾斜. 内存内题解见 LeetCode Hot 100 算法清单, Android 端的采样, 限流和图片解码见 Android 业务算法场景. 完成后应能先列假设, 再给出容量计算, 分阶段算法与失败处理.


一, 核心思想: 内存放不下怎么办

面试给的经典约束:数据量远超内存 (如 40 亿整数, 100GB 日志, 但内存只有 1GB).解题主线:

  1. 能不能压缩表示? → 位图 (1 个整数用 1 bit).
  2. 能不能拆分? → 哈希分治 (相同 key 必落同一小文件, 分而治之).
  3. 只要近似 / 允许误判? → 布隆过滤器 (极省空间).
  4. 只要前 K / 排序? → 堆 / 外部多路归并.

C++17 示例前置条件

本章以下标记为 C++ 的代码块均为可组合的教学片段, 不是单独的完整程序. 将某一类复制到独立 C++17 文件时, 先加入以下公共头文件和 using 声明; TwoBitCounter 与 BloomFilter 各自是符号闭合的独立类, kthLargest 是独立函数. 还需由调用方提供 main, 测试数据和错误处理策略.

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <limits>
#include <stdexcept>
#include <string>
#include <vector>

using std::invalid_argument;
using std::hash;
using std::length_error;
using std::numeric_limits;
using std::out_of_range;
using std::size_t;
using std::string;
using std::swap;
using std::uint8_t;
using std::vector;

二, 位图 BitMap

思想: 用一个 bit 表示一个数是否存在.40 亿个 int 若用 int 数组要 16GB, 用位图只需 40 亿 / 8 ≈ 500MB, 省 32 倍.

判断整数 x 是否存在:
  字节下标 = x / 8,位下标 = x % 8
  set:  bitmap[x/8] |= (1 << (x%8))
  get:  bitmap[x/8] &  (1 << (x%8))
  • 应用: 40 亿无符号整数判某数是否存在 / 去重 / 排序 (置位后顺序扫描).
  • 进阶 Bitmap (2-bit 图): 每个数用 2 bit, 表示 “出现 0 次 / 1 次 / 多次”, 可解 “找出现一次的数 / 找重复的数”.
  • 局限: 只适合整数且范围有限; 数据稀疏时浪费 (此时用哈希或 Roaring Bitmap 压缩位图).
  • 联系你的背景: 这正是底层 / SDK 常用的空间压缩手段, 可主动提及 “做指纹去重时用过类似位压缩思路”.

2-bit 位图 C++17 类片段 (值域 [0,maxValue]): 状态 00=未出现, 01=一次, 10=至少两次, 不需要区分第三次以后. 与上方 “C++17 示例前置条件” 代码块组合后, 类定义符号闭合.

class TwoBitCounter {
    size_t maxValue_;
    vector<uint8_t> data;
    static size_t bytesFor(size_t maxValue) {
        if (maxValue == numeric_limits<size_t>::max()) throw length_error("maxValue is too large");
        size_t valueCount = maxValue + 1; // 已排除 maxValue + 1 溢出.
        size_t bytes = valueCount / 4 + (valueCount % 4 != 0);
        if (bytes > vector<uint8_t>().max_size()) throw length_error("bitmap is not allocatable");
        return bytes;
    }
    void checkRange(size_t x) const {
        if (x > maxValue_) throw out_of_range("value outside bitmap range");
    }
public:
    explicit TwoBitCounter(size_t maxValue) : maxValue_(maxValue), data(bytesFor(maxValue), 0) {}
    void add(size_t x) {
        checkRange(x);
        size_t byte = x / 4, shift = (x % 4) * 2;
        uint8_t state = (data[byte] >> shift) & 0b11;
        uint8_t next = state == 0 ? 1 : 2;
        data[byte] = static_cast<uint8_t>((data[byte] & ~(0b11u << shift)) | (next << shift));
    }
    bool appearsOnce(size_t x) const {
        checkRange(x);
        return ((data[x / 4] >> ((x % 4) * 2)) & 0b11) == 1;
    }
};

例如值域 40 亿需 2 * 4e9 bit = 1e9 byte, 约 0.93 GiB, 外加对象 / 页对齐开销; 这已接近 1 GiB 内存, 不能忽略运行时余量. 边界: maxValue 必须覆盖输入最大值, 负数需映射或另建结构; 两位计数饱和后不能恢复精确次数.

三, 布隆过滤器 (Bloom Filter)

思想: 位图 + 多个哈希函数. 判断元素 “ 一定不存在 “或” 可能存在 “.极省空间, 代价是有误判率 (false positive).

1. 核心机制与近似公式

插入 x:用 k 个哈希函数算出 k 个位置,全部置 1.
查询 x:k 个位置全为 1 → 可能存在;任一为 0 → 一定不存在.
  • 误判率 p 的近似公式: p ≈ (1 - e^(-kn/m))^k
    • m: 位数组的长度 (bit 数)
    • n: 预计插入的元素个数
    • k: 哈希函数的个数
  • 最优 k 值的直觉推导: k ≈ (m/n)·ln2 ≈ 0.7·(m/n)
    • 为什么 k 不能太小? 如果哈希函数太少, 位图中会有大量闲置的 0 没被利用, 区分度低, 导致误判率变高.
    • 为什么 k 不能太大? 如果哈希函数过多, 每次插入都会将大量的 bit 置为 1, 位图很快就被填满, 导致后续查询大概率全命中 1, 误判率急剧上升.

2. 空间估算示例 (Android 面试语境)

假设在风控 SDK 中, 我们需要在本地拦截 10 万个恶意设备黑名单 (n = 100,000), 且要求误判率低于 1%(p = 0.01). 根据估算公式 m ≈ -n·ln(p) / (ln2)^2:

  • 所需位数组大小 m 约为 96 万 bit.
  • 折算成内存: 960,000 / 8 / 1024 ≈ 117 KB.
  • 面试话术: “比起把 10 万个 32 字节的 String 设备指纹存入内存 (约 3MB), 使用布隆过滤器可以将黑名单压缩到 100KB 左右, 这对 Android 端内存很友好.” 查询是 O(k), 但这不等于必然适合主线程; 还要在目标设备上测量哈希, 内存访问和调用频率.

3. 删除与计数权衡 (Counting Bloom Filter)

  • 不支持删除: 标准布隆过滤器如果将某个位置 0, 可能会影响其他同样映射到该位的元素.

  • 解法 (Counting Bloom Filter): 将原本的 1 个 bit 扩展为一个计数器 (例如 4-bit 数组).插入时计数器 +1, 删除时计数器 -1.

  • 空间权衡: 虽然支持了删除, 但空间开销直接膨胀 (如 4-bit 计数器使占用翻 4 倍), 且计数器有溢出风险. 需在 “是否必须删除” 与 “内存占用上限” 间做取舍.

  • 应用: 缓存穿透防护 (Redis 前挡一层), 爬虫 URL 去重, 垃圾邮件过滤, 判断 key 是否可能在数据库.

  • 联系你的背景: 风控 / 反作弊里黑名单判断, 设备去重就常用布隆过滤器, 这是你能讲实战的点. 可落地链路: 10 万黑名单 → 布隆 117 KB → 误判标定 → 回源精确表; 端侧判定场景详见 Android 业务算法场景・设备指纹相似度与本地风控.

Bloom Filter C++17 类片段 (双重哈希模拟 k 个散列, 非对抗安全哈希):与上方 “C++17 示例前置条件” 代码块组合后, 类定义符号闭合; 要成为可执行程序仍须由调用方提供 main.

class BloomFilter {
    vector<bool> bits; size_t k;
    size_t index(const string& s, size_t i) const {
        size_t h1 = hash<string>{}(s), h2 = hash<string>{}("#" + s) | 1;
        return (h1 + i * h2) % bits.size();
    }
public:
    BloomFilter(size_t bitCount, size_t hashes) : bits(bitCount), k(hashes) { if (!bitCount || !hashes) throw invalid_argument("positive m/k required"); }
    void add(const string& key) { for (size_t i = 0; i < k; ++i) bits[index(key, i)] = true; }
    bool mightContain(const string& key) const { for (size_t i = 0; i < k; ++i) if (!bits[index(key, i)]) return false; return true; }
};

这里的 mightContain=true 只能作为后端精确表查询前的候选, 不能据此拒绝合法用户. std::hash<string> 的结果只适用于同一进程的内存态过滤器, 不应当作持久化, 跨进程或分布式协议; 这些场景必须固定散列算法, 种子和格式版本. 边界: m=0/k=0 显式拒绝; 预计 n 增长超过设计值时误判率升高, 应按目标 n,p 重建过滤器.

四, 哈希分治 (分而治之)

思想: 大文件按 hash(key) % N 拆成 N 个小文件, 相同 key 必进同一小文件. 每个小文件能进内存后单独处理, 再汇总.

模板流程:

  1. 遍历大文件, 按哈希把记录分到 N 个小文件.
  2. 对每个小文件单独用哈希表 / 堆处理 (统计频次, 去重, Top-K).
  3. 合并各小文件的结果 (如各自 Top-K 再归并出全局 Top-K).
  • 应用: 10 亿 URL 去重, 统计每个词的频次, 求两个大文件的交集.
  • 要点: 分治的前提是 “相同 key 落同一桶”, 所以必须用 key 的哈希分, 不能随便切.

容量数字示例: 100 GiB URL 日志, 估计可用内存 512 MiB, 单桶处理期望只用 256 MiB. 先按放大系数 1.5 预留哈希表, 字符串和负载因子开销, 桶数至少为 ceil(100 * 1.5 / 0.25)=600; 实际选 1024 个桶以方便掩码分桶并留倾斜余量. 掩码分桶只有桶数为 2 的幂时才等价于取模, 且要求哈希低位质量足够; 否则应用 % bucketCount 或先混合散列. 分桶阶段读取约 100 GiB, 写约 100 GiB, 共约 200 GiB, 桶内读取和临时结果另计. 若某桶超过 256 MiB, 不是 “哈希分治失败”, 而是继续对该桶用另一种盐二次分桶, 并记录热点 key / 恶意输入证据.

五, Top-K 问题

三种解法按场景选:

  • 小顶堆 (数据流 / 超大数据):维护大小为 K 的小顶堆, 堆顶是第 K 大, O (n log K) 时间, O (K) 空间.海量数据首选 (不用全载入).
  • 快速选择 (数据能进内存):基于快排分区, 平均 O (n) 找第 K 大, 但会修改 / 需载入数据.
  • 哈希分治 + 堆 (数据放不下):先哈希分治统计频次, 各桶取局部 Top-K, 再归并.

→ Top-100 热搜词: 哈希分治统计词频 + 每桶小顶堆 + 归并.

快速选择 C++17 函数片段 (数据可完全装入内存): 与上方 “C++17 示例前置条件” 代码块组合后, 函数定义符号闭合; 要成为可执行程序仍须由调用方提供 main. 分区后只迭代包含目标下标的一侧, 期望线性, 但最坏仍 O(n^2), 会改写数组.

int kthLargest(vector<int>& a, int k) {
    if (a.empty() || k < 1 || k > static_cast<int>(a.size())) throw invalid_argument("k out of range");
    int target = static_cast<int>(a.size()) - k, lo = 0, hi = static_cast<int>(a.size()) - 1;
    while (lo <= hi) {
        int pivot = a[hi], i = lo;
        for (int j = lo; j < hi; ++j) if (a[j] <= pivot) swap(a[i++], a[j]);
        swap(a[i], a[hi]);
        if (i == target) return a[i];
        if (i < target) lo = i + 1; else hi = i - 1;
    }
    throw invalid_argument("k out of range");
}

边界: k 必须在 [1,n]; 重复值不影响第 k 大的数值定义; 流式数据不能使用此法, 因为它需要保留全部元素.

六, 外部排序

思想: 数据放不下内存时的排序.分块 + 多路归并:

  1. 把大文件切成能进内存的小块, 各块在内存排序后写回磁盘 (生成 “顺串”).
  2. 用多路归并 (K 路败者树 / 小顶堆) 把有序小块合并成全局有序.
  • 应用: 100GB 日志按时间排序, 超大文件去重后排序.
  • 联系: 归并排序的磁盘版, 体现你对 I/O 与内存权衡的理解.

I/O 推演示例: 100 GiB 输入, 每次可排序 256 MiB, 先产生约 400 个顺串. 若内存可给每个输入缓冲 1 MiB, 输出缓冲 1 MiB, 则一次 128 路归并约需 129 MiB 缓冲, 可在两轮完成 (400→4→1).每一轮都读取 100 GiB, 写入 100 GiB, 共 200 GiB; 初始生成加两个归并轮共约 600 GiB 顺序 I/O. 临时磁盘至少要容纳输入外加一轮输出. 打开 fd 数, 磁盘带宽, 压缩比和记录大小会改变这个估算.

七, 经典场景速答

场景解法
40 亿整数判断某数是否存在位图 (~500MB)
40 亿整数找只出现一次的2-bit 位图
10 亿 URL 去重哈希分治 / 布隆过滤器 (允许误判)
100GB 日志 Top-100 热词哈希分治统计词频 + 小顶堆 + 归并
求两个超大文件的交集各自哈希分治到对应桶, 桶内求交
100GB 文件排序外部排序 (分块 + 多路归并)
缓存穿透防护布隆过滤器挡在缓存前
数据流求中位数大顶堆 + 小顶堆对顶 (Hot 100 #295)

八, 先写容量与误差假设

海量数据题先声明 key 空间, 数据量, 可用内存, 磁盘, 机器数, 是否允许误判 / 漏判, 输出是否精确以及时限. 位图的 500 MB 只是 40 亿 bit 的理论量级, 还需考虑索引范围, 元数据和实现开销. Bloom Filter 要给 n/m/k 与目标误判率, 哈希函数相关性和对抗性输入会影响结果. 哈希分治必须处理倾斜和碰撞, 外排要估算读写轮次, 临时空间, 块大小和归并路数. 未给这些假设时, “用位图/布隆/分治” 只是候选名, 不是完整方案.

面试怎么讲 (结合你的优势)

回答海量数据题时, 先问清约束 (数据量, 内存, 是否允许误判, 要精确还是近似), 再选招式, 最后说权衡. 这套 “先问约束再设计” 的思路本身就是工程素养.

你可以主动关联:

“我做设备指纹 SDK 时, 设备去重和黑名单判断都涉及大规模数据 + 内存约束. 用过位压缩做去重, 布隆过滤器做快速存在性判断, 对空间/时间/误判率的权衡有实战体会.”

这一句话就把算法题变成了你的项目亮点, 是普通应用开发者给不出的答案.

主题练习与预期证据

  1. 以 10 亿个, 值域 40 亿的非负整数为假设, 分别算 1-bit 与 2-bit 位图的理论字节数; 预期约 477 MiB 与 0.93 GiB, 注明 MB/GiB 口径.
  2. 为 Bloom Filter 设计 add("a") 后查询 "a" 的用例, 预期为可能存在; 再说明为什么测试不能证明无误判.
  3. 用上述 100 GiB 外排假设画出顺串轮次, 预期证据是给出路数, 轮数和临时空间, 而不是只写 “多路归并”.

Android 业务算法场景

Android 面试里的算法不只 LeetCode. 真实业务更常问: 图片缓存怎么淘汰, 埋点怎么采样, 请求怎么限流, 日志怎么去重, Feed 分页怎么合并, 设备指纹相似度怎么算. 本篇把算法落到移动端工程场景.

学习目标与章节边界

本章给出 Android 进程内可操作的业务算法 Kotlin 片段, 重点是生命周期, 时钟, 内存和持久化边界; 通用题解见 LeetCode Hot 100 算法清单, 超内存的分桶, 位图与外排见海量数据处理. 完成后应能说明一个算法的输入规模, 线程模型, 进程重启行为和测试证据.

一, LRU cache 与图片缓存淘汰

LRU (Least Recently Used) 适合 “最近访问还会再访问” 的局部性场景. Android 图片库, 页面数据缓存, 解码 Bitmap 复用池都常用 LRU 或近似 LRU.

场景KeyValue淘汰依据注意点
内存图片缓存URL + resize + transformBitmap/Drawable占用字节数防 OOM, 按 maxMemory 比例设置
磁盘图片缓存安全 hash 后的 URL文件总大小 / 最近访问避免文件名过长, 写入原子性
页面接口缓存route + paramsJSON/EntityTTL + LRU过期和一致性比命中率更重要
BitmapPoolwidth/height/config可复用 Bitmap大小分桶避免频繁分配导致 GC
class BitmapMemoryCache(maxBytes: Int) : LruCache<String, Bitmap>(maxBytes) {
    override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
}

面试手写 LRU 见 53 算法补充专题.

图片缓存面试要讲完整链路: 内存 LRU → 磁盘 LRU → 网络下载 → 解码采样 → 写缓存 → 生命周期取消. 只讲 “用 HashMap + 双向链表” 不够, 要补 Android 内存预算, 列表复用和 OOM 风险.

二, 日志采样, 去重与压缩上报

埋点/APM/Crash SDK 都会遇到 “数据太多不能全传” 的问题. 业务算法目标是降低流量, 电量和服务端压力, 同时保留可分析性.

  • 固定比例采样: 按随机数或用户 hash 采样, 适合普通埋点.
  • 稳定采样: 按 hash(userId/deviceId) % 100 < rate 决定, 同一用户长期一致, 便于漏斗分析.
  • 分层采样: 错误, Crash, 支付链路高采样; 普通曝光低采样.
  • 去重: 短时间重复日志用 (eventName, page, keyParams) 做 fingerprint, 窗口内只保留一次或计数.
  • 批量压缩: 本地队列按数量/大小/时间触发 gzip 上报, 失败后退避重试.
event → fingerprint → sliding window dedup
      → sampling decision
      → local queue(Room/file)
      → batch gzip upload with retry

以下是纯 Kotlin/JVM 上下文片段. 调用方应在单一串行 dispatcher 或锁保护下使用同一个实例; 它们不负责 Room 写入, 网络或隐私授权.

import java.nio.charset.StandardCharsets
import java.security.MessageDigest

fun isStablySampled(subjectId: String, ratePercent: Int): Boolean {
    require(ratePercent in 0..100)
    val digest = MessageDigest.getInstance("SHA-256")
        .digest(subjectId.toByteArray(StandardCharsets.UTF_8))
    val bucket = ((digest[0].toInt() and 0xff) shl 8 or (digest[1].toInt() and 0xff)) % 100
    return bucket < ratePercent
}

class FingerprintDeduplicator(private val windowMs: Long, private val maxKeys: Int) {
    private val seenAt = LinkedHashMap<String, Long>()
    fun shouldKeep(fingerprint: String, nowElapsedMs: Long): Boolean {
        require(windowMs >= 0 && maxKeys > 0)
        val iterator = seenAt.entries.iterator()
        while (iterator.hasNext()) if (nowElapsedMs - iterator.next().value > windowMs) iterator.remove()
        if (seenAt.containsKey(fingerprint)) return false
        seenAt[fingerprint] = nowElapsedMs
        while (seenAt.size > maxKeys) seenAt.entries.iterator().also { it.next(); it.remove() }
        return true
    }
}

执行时 Android 应传入 SystemClock.elapsedRealtime(), 不要传 currentTimeMillis(), 后者会因校时倒退. 稳定采样对同一实现 / ID 每次结果一致, 但哈希实现或 ID 规范变更会改变分桶, 需版本化. 容量淘汰会让很旧的 key 提前再次通过; 进程死亡会清空内存窗口, 关键幂等仍须服务端用事件 ID 保证.

三, 限流, 滑动窗口与重试保护

限流 (rate limiting) 在移动端用于保护接口, SDK 回调, 日志上报, 按钮连点和弱网重试. 常见算法要结合业务选择.

算法机制适用场景缺点
固定窗口每个时间窗最多 N 次简单按钮防抖, 低风险接口窗口边界可能突刺
滑动窗口记录最近 T 时间内请求数登录, 验证码, 上报保护需要维护时间队列
令牌桶固定速率生成 token, 允许突发网络请求, 日志上报参数要调优
漏桶匀速流出平滑上传队列突发吸收能力弱

滑动窗口移动端实现直觉:

  1. 用队列保存事件时间戳.
  2. 新事件到来时移除超过窗口的旧时间.
  3. 队列大小小于阈值则允许, 否则拒绝或延迟.
  4. 对持久化场景可只存计数桶, 避免内存无限增长.
// 非线程安全;一个实例由单一串行 dispatcher 所有,或由调用方同步.
class SlidingWindowLimiter(private val limit: Int, private val windowMs: Long) {
    private val timestamps = ArrayDeque<Long>()
    fun tryAcquire(nowElapsedMs: Long): Boolean {
        require(limit > 0 && windowMs > 0)
        while (timestamps.isNotEmpty() && nowElapsedMs - timestamps.first() >= windowMs) timestamps.removeFirst()
        if (timestamps.size >= limit) return false
        timestamps.addLast(nowElapsedMs)
        return true
    }
}

// 非线程安全;一个实例由单一串行 dispatcher 所有,或由调用方同步.
class TokenBucket(private val capacity: Double, private val tokensPerSecond: Double) {
    private var tokens = capacity
    private var lastMs: Long? = null
    fun tryAcquire(nowElapsedMs: Long, cost: Double = 1.0): Boolean {
        require(capacity > 0 && tokensPerSecond > 0 && cost > 0)
        val previousMs = lastMs
        require(previousMs == null || nowElapsedMs >= previousMs) { "elapsed time must not move backwards" }
        if (previousMs != null) tokens = minOf(capacity, tokens + (nowElapsedMs - previousMs) * tokensPerSecond / 1_000.0)
        lastMs = nowElapsedMs
        if (tokens < cost) return false
        tokens -= cost
        return true
    }
}

参数示例: 上传希望稳定平均 2 req/s, 允许网络恢复后最多突发 5 个批次, 设 capacity=5, tokensPerSecond=2. 这不是通用推荐值, 应在真实 429, 耗电和服务端配额数据下调整. 滑窗测试: limit=2, window=1_000, 在 0,100,200ms 三次请求预期通过, 通过, 拒绝, 1_000ms 再请求通过. 令牌桶测试: capacity=2, tokensPerSecond=2, 在 0ms 请求成本 2 后, 在 500ms 请求成本 1 应通过, 证明 0ms 是有效初始化时间而非未初始化哨兵; 随后传入更小时间必须拒绝.

四, 设备指纹相似度, 布隆过滤器与本地风控

设备指纹 / 风控 SDK 常需要 “快速判断是否见过, 是否相似, 是否命中黑名单”.这类题能把你的业务背景讲成算法亮点.

  • 指纹相似度: 把设备属性向量化, 对稳定字段加高权重 (硬件, 系统特征), 对易变字段低权重 (IP, 网络); 用加权 Jaccard / 余弦相似判断是否同设备族.
  • SimHash / 局部敏感哈希: 把高维特征压成指纹, 海明距离小表示相似, 适合快速近似匹配.
  • 布隆过滤器: 本地黑名单, 已上报 ID, 去重 key 的快速存在性判断; 回答 “一定不存在 / 可能存在” 和误判率. 原理与误判公式详见 54 海量数据处理.
  • Counting Bloom Filter: 需要删除时使用计数器, 但空间变大.
  • 隐私注意: 指纹算法要服务于合规风控, 最小化采集, 脱敏, 加密存储, 不能无限收集敏感信息.
fun simHash64(weightedFeatures: Map<String, Int>): Long? {
    if (weightedFeatures.isEmpty()) return null
    require(weightedFeatures.size <= 10_000) { "too many features" }
    val sums = LongArray(64)
    for ((feature, weight) in weightedFeatures) {
        require(weight in 1..1_000_000) { "feature weight must be positive and bounded" }
        val h = feature.hashCode().toLong() * -0x61c8864680b583ebL
        for (bit in 0 until 64) sums[bit] += if (((h ushr bit) and 1L) == 1L) weight.toLong() else -weight.toLong()
    }
    var fingerprint = 0L
    for (bit in 0 until 64) if (sums[bit] >= 0) fingerprint = fingerprint or (1L shl bit)
    return fingerprint
}
fun hammingDistance(a: Long, b: Long): Int = java.lang.Long.bitCount(a xor b)

这是近似相似度候选生成, 不是身份判定: 阈值需在标注样本上评估误报 / 漏报, 并按版本记录特征集合. hashCode() 仅为教学 hash; 生产指纹需稳定, 可版本化的散列和合规评审. 权重必须为正且受上限保护, 以免累加溢出; 空特征返回 null, 表示没有可比较的指纹.

五, TopK, 热点统计与本地搜索

移动端也有 TopK: 热门搜索词, 最近联系人, 异常日志 TopN, 耗时接口 TopN. 核心是不要全量排序.

  • 小顶堆 TopK: 维护大小 K 的堆, 新元素大于堆顶才替换, O (n log K).适合日志 / 性能指标本地聚合.
  • Space Saving/Misra-Gries: 近似高频统计, 适合内存很小但数据流很大的场景.
  • Trie / 倒排索引: 本地搜索联系人, 城市, 商品名; 前缀匹配用 Trie, 关键词搜索用倒排.
  • 拼音 / 模糊搜索: 联系人搜索要建立 name, pinyin, 首字母索引, 并做结果排序.
  • Room FTS: 正文搜索可用 SQLite FTS, 比 like '%x%' 更适合大文本.
本地搜索索引:
keyword/token → [entityId1, entityId2, ...]
查询:分词/拼音归一化 → 取倒排列表 → 交并集 → 按权重排序
// 非线程安全;一个实例由单一串行 dispatcher 所有,或由调用方同步.
class InvertedIndex {
    private val postings = mutableMapOf<String, MutableSet<Long>>()
    fun add(documentId: Long, normalizedTokens: Iterable<String>) {
        normalizedTokens.filter { it.isNotBlank() }.forEach { postings.getOrPut(it) { linkedSetOf() }.add(documentId) }
    }
    fun andQuery(tokens: List<String>): Set<Long> {
        if (tokens.isEmpty()) return emptySet()
        val lists = tokens.map { postings[it] ?: return emptySet() }
        return lists.drop(1).fold(lists.first().toSet()) { result, ids -> result.intersect(ids) }
    }
}

加入文档 1 的 android cache, 文档 2 的 android room 后, 查询 android, cache 预期只得到 1. 真实联系人检索还要统一大小写, 拼音和分词版本; 删除 / 更新必须删除旧 posting. 文本规模大时优先 Room FTS, 而不是把全量索引常驻内存.

图片采样解码: 按目标尺寸而非原图解码

以下为 Android 上下文片段, 调用方应在后台线程读取流, 并负责关闭流或让图片库管理生命周期.

import android.graphics.Bitmap
import android.graphics.BitmapFactory

fun calculateInSampleSize(width: Int, height: Int, requestedWidth: Int, requestedHeight: Int): Int {
    require(requestedWidth > 0 && requestedHeight > 0)
    var sample = 1
    while (width / (sample * 2) >= requestedWidth && height / (sample * 2) >= requestedHeight) sample *= 2
    return sample
}

fun decodeSampled(bytes: ByteArray, requestedWidth: Int, requestedHeight: Int): Bitmap? {
    val options = BitmapFactory.Options()
    with(options) {
        inJustDecodeBounds = true
        BitmapFactory.decodeByteArray(bytes, 0, bytes.size, this)
        if (outWidth <= 0 || outHeight <= 0) return null
        inSampleSize = calculateInSampleSize(outWidth, outHeight, requestedWidth, requestedHeight)
        inJustDecodeBounds = false
        return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, this)
    }
}

4000x3000 ARGB_8888 原图约 45.8 MiB; 目标 1000x750, sample=4 时约 2.86 MiB. 损坏输入可能使 outWidth/outHeight <= 0, 应返回失败而非进入采样循环; 解码结果可为 null, UI 必须有错误状态.

六, 分页合并, 去重与一致性

Feed, IM, 订单列表常见问题: 分页返回重复, 刷新和加载更多交错, 服务端数据更新导致顺序变化. 本质是有序流合并与去重.

  1. 唯一 key 去重: 用 itemId/serverId 去重, 不要用 position.
  2. 游标分页优先: 使用 cursor/lastId/createdAt, 比 offset 更稳.
  3. 本地合并: 新页与旧列表按排序 key 归并, 重复 item 更新内容.
  4. 状态分离: refresh, append, prepend 分别维护 loading/error/cursor.
  5. Room + Paging3: RemoteMediator 把网络页落库, UI 观察数据库, 降低进程死亡和旋转带来的状态丢失.

常见坑: 服务端删除或置顶会改变排序, 客户端只 append 可能出现缺失或重复; 要定期 refresh 或使用版本号 / 增量同步.


七, 从抽象算法到真实约束

回答业务算法题要给实际规模和失败边界: 缓存按字节而非条目评估, LRU 命中率需按真实访问分布基准; 去重窗口要说明最大事件数和误判/漏判; 限流使用单调时钟并处理进程重启; TopK 指明是否允许近似和多线程更新; 分页合并处理重复, 删除, 乱序和刷新边界. 性能结论应在代表性低端设备和数据分布上用 benchmark/trace 验证, 不只给复杂度.

高频面试题

Q1: 设计图片内存缓存为什么用 LRU? 列表和详情页存在时间局部性, 最近展示过的图片很可能再次出现. LRU 能在固定内存预算下保留热点 Bitmap, 但要按字节数计算 size, 并结合磁盘缓存, 采样解码和生命周期取消.

Q2: 埋点日志太多怎么上报? 用稳定采样控制比例, 错误链路提高采样; 对短时间重复事件做 fingerprint 去重或合并计数; 本地队列批量 gzip 上报, 失败指数退避, 并设置磁盘上限防止撑爆存储.

Q3: 移动端限流怎么实现? 按钮防抖可固定窗口, 接口 / 上报更适合滑动窗口或令牌桶. 要限制次数, 设置退避, 对非幂等请求避免自动重发, 必要时让服务端用幂等 key 去重.

Q4: 布隆过滤器适合哪些 Android 业务? 适合本地黑名单, 已上报事件去重, 缓存穿透保护, 设备指纹快速存在性判断. 它能回答 “一定不存在 / 可能存在”, 有误判但不会漏判, 需要删除时用 Counting Bloom Filter.

易错点 / 追问

  • 易错: 只背 LRU 的 HashMap+ 双向链表, 不讲 Bitmap 字节数, OOM, 磁盘缓存和生命周期.
  • 追问: 滑动窗口和固定窗口区别? 滑动窗口按最近 T 时间精确限制, 固定窗口边界可能出现双倍突刺.
  • 易错: TopK 直接全量排序; 数据流或日志聚合应使用大小为 K 的小顶堆或近似高频算法.
  • 追问: 分页去重为什么不能按 position? 刷新, 插入, 置顶会改变位置, 必须用稳定业务 id.

主题练习与预期证据

  1. 固定 subjectId 连续稳定采样 100 次, 预期结果完全相同; 更换 ID 不承诺必然改变结果.
  2. 为去重器在 0ms/500ms/1001ms 同一指纹调用, 窗口为 1000ms, 预期为通过, 拒绝, 通过.
  3. 用 4000x3000 输入和 1000x750 目标手算采样内存, 预期能解释为何按条目数做图片 LRU 会有 OOM 风险.

操作系统与数据库基础

本章覆盖通用操作系统概念, MySQL/InnoDB 面试术语和 Android SQLite/Room 实践. 网络见网络协议, Binder 见 Binder 与 IPC 深入. 不同数据库实现不能混用同一套结论.

学习目标与章节边界

本章定义 OS, 并发和数据库的基础术语, 并给出 SQL/SQLite 的基础机制. mmap, I/O 多路复用, 传统 CFS 的 vruntime 心智模型, EEVDF 版本边界和死锁现场排查在操作系统进阶展开, 避免重复. 完成后应能画出事务并发时序, 解释索引是否覆盖查询, 并为 SQLite 锁问题收集证据.

第一部分: 操作系统

一, 进程, 线程, 协程

进程线程协程
资源独立地址空间共享进程内存共享线程, 用户态
调度OSOS用户 / 运行时
开销大中小
通信IPC共享内存 (需同步)直接
  • 进程: 资源分配的基本单位, 有独立内存空间.
  • 线程: CPU 调度的基本单位, 共享进程资源, 需同步 (锁).
  • 协程: 由语言 / 运行时调度的可挂起任务, 挂起本身不阻塞承载线程, 详见 Kotlin 协程与 Flow.

二, 进程间通信 (IPC)

管道, 消息队列, 共享内存, 信号量, Socket 和信号各有语义与代价. Android 进程间调用主要使用 Binder;“一次拷贝” 只是在特定数据路径和实现语境下的简化, 详见 Binder 与 IPC 深入.

方式数据路径适合典型失败边界
管道/Unix domain socket内核缓冲传递字节流父子进程, 本机服务无消息边界或未处理背压会阻塞/丢协议边界
共享内存多进程映射同一页大块数据只共享数据, 不共享同步; 仍需原子/锁/信号量
BinderParcel 请求, 内核驱动路由, 线程池分发Android 服务 RPC同步调用可阻塞调用线程; 大 payload 应改文件描述符 / 共享内存方案

选择 IPC 时先明确 “传命令还是传大数据”“ 同步还是异步 ““谁负责生命周期”.例如 UI 进程给 remote service 发小控制命令可用 AIDL/Binder; 把几十 MB 图片直接塞进 Transaction 会触及 Binder 事务大小与内存压力边界. 证据路径: 查看调用线程堆栈是否等待 Binder, 再看服务端线程池/耗时操作, 最后验证改为异步或流式传输后调用是否恢复.

三, 内存管理

  • 虚拟内存: 每个进程有独立虚拟地址空间, 通过页表映射到物理内存, 实现隔离 + 按需加载.
  • 分页: 内存分固定大小页, 虚拟页↔物理页帧映射, 缺页中断时从磁盘加载.
  • 页面置换: 内存不够时选择淘汰哪个页, 目标是降低未来缺页率.

页面置换算法

算法机制优点缺点/坑点Android/Linux 关联
FIFO淘汰最早进入内存的页实现简单可能出现 Belady 异常: 分配页框更多反而缺页更多面试用来说明 “简单策略不等于命中率高”
LRU淘汰最长时间未访问的页符合时间局部性精确维护访问顺序成本高系统常做近似 LRU, App 侧 LruCache 是同类思想
LFU淘汰访问次数最低的页适合长期热点稳定场景旧热点可能因历史计数过高难淘汰, 需衰减更常见于缓存策略讨论, OS 页面置换较少直接精确使用
Clock / 二次机会页形成环, 访问位为 1 则清零并跳过, 为 0 才淘汰近似 LRU, 实现成本低只能粗略表达 “最近是否访问过”操作系统常用近似策略, 兼顾成本与效果

Belady 异常: FIFO 不考虑局部性, 在某些访问序列中增加页框会改变淘汰顺序, 导致缺页次数反而上升; LRU 属于栈算法, 不会出现这种异常.

面试答题流: 先讲缺页中断和淘汰目标, 再用 FIFO/LRU/Clock 对比 “实现成本 vs 命中率”, 最后联系 Android: App 内存压力会触发 LMK/进程回收, 而 Linux 内核页回收也会用近似 LRU 思路维护活跃/非活跃页.

  • MMU / TLB: 硬件做地址翻译, TLB 缓存页表项加速.
  • 用户态 vs 内核态: 特权级隔离, 系统调用 / 中断时从用户态陷入内核态.

四, 并发与同步

  • 死锁四条件: 互斥, 持有并等待, 不可剥夺, 循环等待.破坏任一即可避免 (如按序申请资源破坏循环等待).
  • 临界区: 互斥访问共享资源的代码段.
  • 同步原语: 互斥锁, 信号量 (Semaphore), 条件变量, 读写锁.
  • CPU 调度: 在多个可运行任务之间分配 CPU, 目标通常是吞吐, 响应时间, 公平性和实时性之间折中.

CPU 调度算法

算法机制优点缺点 / 饥饿问题适用直觉
FCFS先来先服务, 非抢占简单, 无饥饿短任务可能被长任务堵住 (护航效应)批处理直觉, 交互系统不理想
SJF / SRTF优先执行预计时间最短任务; SRTF 是抢占版平均等待时间低难准确预估执行时间, 长任务可能饥饿理论题常考, 实际需估算
时间片轮转 (RR)每个任务运行一个时间片, 到期切换响应公平, 适合交互时间片太小切换开销大, 太大退化为 FCFSUI / 交互系统强调响应
优先级调度高优先级先运行能表达重要性 / 实时性低优先级可能饥饿, 需 aging 提升等待过久任务Android 线程优先级, 后台任务降级
多级反馈队列 (MLFQ)多队列不同优先级 / 时间片, 任务按行为升降级兼顾交互与吞吐参数复杂, 可能被行为模式影响通过反馈识别短交互任务与长 CPU 任务

Linux/Android 关联: 传统 / 常见的 Linux 普通任务调度心智模型是 CFS (Completely Fair Scheduler): 它不是简单 RR, 而是用虚拟运行时间 vruntime 近似公平, 谁 “用得少” 谁更容易被调度. 该模型适合解释经典 CFS 行为, 但不能冒充所有当前系统事实: 新上游 Linux 内核可能采用 EEVDF; Android 设备实际采用 CFS 还是 EEVDF, 以及是否有厂商回移植, 必须以实际 kernel 版本, 源码配置和厂商补丁为准, 详见操作系统进阶. 无论具体选任务机制如何, Android 仍会叠加线程优先级, cgroup/cpuset 和前后台进程调度策略; UI 线程应避免长时间占 CPU, 否则仍可能错过显示 deadline.16.7 ms 只是 60 Hz 的单帧周期示例, 多刷新率口径见 ANR 与卡顿排查.

面试答题流: 先说目标 (公平/响应/吞吐), 再逐个算法讲机制和缺点, 最后补 “ 可用 CFS + vruntime 解释传统/常见模型; 新上游内核可能采用 EEVDF, Android 必须按实际 kernel 版本, 源码配置和厂商回移植确认, 且还会叠加优先级/cgroup“, 不是直接套书本算法.

五, I/O 模型 (进阶, 可选)

阻塞 I/O, 非阻塞 I/O, I/O 多路复用 (select/poll/epoll), 信号驱动, 异步 I/O. Android 的 Looper 底层就用了 epoll(无消息时阻塞等待, 详见 Android 系统原理).


第二部分: 数据库

一, SQL 基础

  • 增删改查: INSERT / DELETE / UPDATE / SELECT.
  • JOIN: INNER (交集), LEFT (左全保留), RIGHT, FULL.
  • 聚合: COUNT/SUM/AVG/MAX/MIN + GROUP BY + HAVING.
  • 子查询, UNION, ORDER BY, LIMIT.

二, 索引 (高频)

  • 作用: 加速查询, 空间换时间. 底层多用 B+ 树.
  • 为什么 B+ 树而非 B 树 / 红黑树? B+ 树矮胖 (减少磁盘 I/O 次数), 叶子节点链表 (范围查询快), 非叶子只存索引 (单页存更多键).
  • MySQL/InnoDB 聚簇索引: 主键 B+ 树叶子保存行记录; 二级索引叶子保存主键值, 查询其他列时可能回表. 这是 InnoDB 语境, 不能直接套到 SQLite 的存储实现.
  • 最左前缀: 联合索引 (a,b,c) 通常需要从左侧条件开始才能高效定位; where b=x 通常不能用 a 的左前导键高效定位, 优化器仍可能选择全索引扫描, 或在少数实现 / 数据分布下使用 skip-scan, 必须以 EXPLAIN 验证.
  • 索引失效: 对索引列函数运算, 隐式类型转换, like '%x' 前缀模糊, OR 部分无索引.
  • 代价: 占空间, 拖慢写入 (增删改要维护索引).

三, 事务 (ACID)

  • A 原子性: 全成功或全回滚.
  • C 一致性: 事务前后数据完整性约束不破坏.
  • I 隔离性: 并发事务互不干扰.
  • D 持久性: 提交后永久保存.

隔离级别 (解决并发问题)

级别脏读不可重复读幻读
读未提交可能可能可能
读已提交否可能可能
可重复读 (MySQL/InnoDB 默认)否否SQL 标准允许; InnoDB 的 MVCC/next-key locking 行为需按查询与索引条件说明
串行化否否否
  • 脏读: 读到别的事务未提交的数据.
  • 不可重复读: 同一事务两次读同一行结果不同 (被别人 update).
  • 幻读: 同一查询两次返回行数不同 (被别人 insert).

SQL 并发时序: 先指明数据库与隔离级别

下列为 MySQL 8.0 / InnoDB 教学 SQL 时序, 假设表 account(id PRIMARY KEY, balance) 中已有 (1,100), 且 T1/T2 是两个不同的物理连接. 每段都在两个会话中显式设置隔离级别; 若连接池复用连接, 实验结束后应恢复会话设置. 不同引擎, 隔离级别, 索引条件和锁读会改变细节, 不能把这三段直接当作 SQLite 或其他数据库的全部行为.

脏读 (READ UNCOMMITTED 才可能):

-- T1                                      -- T2
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE account SET balance=0 WHERE id=1;
                                            SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
                                            START TRANSACTION;
                                            SELECT balance FROM account WHERE id=1; -- 读到 0
ROLLBACK;
                                            SELECT balance FROM account WHERE id=1; -- 100
                                            COMMIT;

T2 第一次读到了从未提交, 随后回滚的值. 解决是至少 READ COMMITTED; 证据是两个连接的事务日志与隔离级别, 而不是单次 SELECT 输出.

不可重复读 (READ COMMITTED 可出现):

-- T1                                      -- T2
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE id=1; -- 100
                                            SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
                                            START TRANSACTION;
                                            UPDATE account SET balance=120 WHERE id=1;
                                            COMMIT;
SELECT balance FROM account WHERE id=1; -- 120
COMMIT;

同一行两次读取不同. 可重复读快照或适当锁读可避免, 但会牺牲新鲜度 / 并发.

幻读 (谓词范围新增行):

-- T1                                      -- T2
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT COUNT(*) FROM account WHERE balance >= 100; -- 1
                                            SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
                                            START TRANSACTION;
                                            INSERT INTO account(id,balance) VALUES (2,150);
                                            COMMIT;
SELECT COUNT(*) FROM account WHERE balance >= 100; -- 2
COMMIT;

这里变化的是符合条件的 “行集合”, 不是已读行的列值. 串行化, 范围锁或引擎的 next-key locking 行为要结合实际 SQL / 索引验证.

MVCC 与覆盖索引

MVCC (多版本并发控制): 写事务产生行版本; 一致性读依据 read view 判断哪个已提交版本可见, 因此读者通常无需阻塞写者, 写者也不必等待普通快照读. 它不是 “没有锁”:写写冲突, 显式锁读, 范围约束仍可能等待. InnoDB 版本链 / undo log 是实现细节; SQLite 的 WAL 以读者快照和 WAL 文件实现读写并行语义, 不能逐项等同.

覆盖索引指查询所需列都在索引条目中, 执行器可直接返回索引数据, 避免按主键回表. 例如 InnoDB 中索引 (user_id, created_at, title) 可覆盖:

SELECT created_at, title
FROM article
WHERE user_id = 42
ORDER BY created_at;

若查询 SELECT body 而 body 不在该二级索引中, 仍需回表. 是否真覆盖应以 EXPLAIN/执行计划为证, 索引越宽也会增加写入和缓存成本.

四, SQLite / 移动端

  • SQLite: 嵌入式, 单文件, 无服务进程, Android 本地存储核心 (Room 基于它).
  • WAL 模式: 写入追加到 WAL, 通常允许读者与写者并行, 但 SQLite 仍是单写者模型, checkpoint, 长读事务和锁竞争仍会阻塞.
  • 移动端实践: 用 Room 而非裸 SQLite (编译期校验 + 协程 / Flow); 大数据量分页; 事务批量写; 索引优化查询; 数据库迁移 Migration.

SQLite WAL 操作与排障: 对 Room 使用公开的 setJournalMode() 配置 WAL, 并保持事务短小. WAL 让读取旧快照的读者与一个写者通常并行, 但同一时刻仍只有一个写者; 长读事务会阻止 checkpoint 回收 WAL, 导致 -wal 文件增长或写者等待. 连接池, 事务与锁竞争的进阶边界见数据库进阶.

// Room 的公开配置;不要在回调中以 PRAGMA 覆盖其 journal mode 管理.
val database = Room.databaseBuilder(appContext, AppDatabase::class.java, "app.db")
    .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
    .build()

// 仅用于当前拿到的物理 SQLite 连接的排障实验片段.
val connection = database.openHelper.writableDatabase
connection.execSQL("PRAGMA busy_timeout=2000")

busy_timeout 是物理连接级设置. 上面的片段只说明当前连接的实验行为, 不能推导 Room 全部连接池, 其他进程连接或生产配置都已设置; 生产策略应由实际 Room/SQLite 版本, 连接创建路径和压测结果确认. 排查 database is locked/busy: 症状是写入失败/超时; 证据是记录事务开始结束, SQL 耗时和 WAL 文件大小; 定位是否有长事务, 并发写或主线程事务; 修复为批量拆分, 单写串行化/重试退避, 并缩短读事务; 验证在代表性并发压测下锁等待和 WAL 增长受控. busy_timeout=2000 只是示例, 必须根据交互 deadline 和数据可靠性要求测量调整.


高频面试题

Q1: 进程和线程区别? 进程是资源分配单位有独立地址空间; 线程是 CPU 调度单位共享进程资源. 进程间隔离开销大, 线程间共享内存需同步.

Q2: 死锁产生的四个条件? 怎么避免? 互斥, 持有并等待, 不可剥夺, 循环等待. 破坏任一即可: 如资源一次性申请 (破坏持有并等待), 按固定顺序申请资源 (破坏循环等待).

Q3: 为什么数据库索引用 B+ 树? B+ 树矮胖减少磁盘 I/O, 非叶子节点只存键能存更多, 降低树高, 叶子节点链表利于范围查询和排序. 相比红黑树/B 树更适合磁盘存储.

Q4: 事务隔离级别? 分别解决什么问题? 读未提交→读已提交 (解决脏读)→可重复读 (解决不可重复读)→串行化 (解决幻读).级别越高越安全但并发越低.

Q5: 什么情况索引会失效? 索引列做函数运算 / 类型转换, like '%x' 前缀模糊, 不满足最左前缀, OR 连接非索引列.

Q6: Looper 为什么不会因为死循环耗尽 CPU?(联系 OS) 底层用 epoll, MessageQueue 无消息时阻塞在 epoll_wait 让出 CPU, 有消息再唤醒, 不是忙等.

Q7: 虚拟内存的作用? 给每个进程独立的地址空间 (隔离 + 安全), 突破物理内存限制 (按需分页 + 换页), 简化内存管理 (连续虚拟地址映射到不连续物理页).

主题练习与预期证据

  1. 用两个数据库连接按上述不可重复读时序操作, 预期记录两次结果与实际隔离级别; 若行为不同, 说明引擎 / 隔离级别, 而非强行套结论.
  2. 为 article 写一个查询只选覆盖列, 一个查询额外选 body, 预期用 EXPLAIN 说明是否需要回表.
  3. 制造一个持有事务的后台任务和一次写入, 预期收集锁等待 / SQL 时长; 修复后证明事务缩短或写入串行化.

操作系统进阶

本章只讨论 Linux/Android 的进阶机制: 地址映射与文件 I/O, 多路复用, Linux 普通任务调度与死锁现场. 进程/线程/协程, 虚拟内存, IPC 和数据库基础定义请先读操作系统与数据库基础; Binder 协议细节见 Binder 与 IPC 深入.

学习目标与章节边界

完成后应能比较 read 与 mmap 的成本和故障面, 解释 LT/ET 与 select/poll/epoll 的触发语义, 理解 CFS 的 vruntime 与 EEVDF 的版本边界, 并由线程 dump 还原死锁等待环. 本文不重新讲基础 OS/SQL 定义, 避免与 56 重复.

一, read 与 mmap: 同为文件 I/O, 故障面不同

虚拟内存让每个进程看到连续独立地址空间, 由页表映射到物理页. Android 中它直接影响大文件读取, so 加载, Dex/OAT 映射, Binder 缓冲区等.

进程虚拟地址
  ↓ 页表 / MMU / TLB
物理内存页
  ↑
缺页(page fault)时由内核把文件页/匿名页调入内存
  • read: 内核将文件页数据复制到调用方提供的用户缓冲; 调用边界清晰, 可按块处理并显式控制读取大小.
  • mmap: 把文件或设备映射到进程虚拟地址, 按需加载, 访问时发生缺页; 少一次显式用户缓冲复制且随机访问方便, 但错误会延迟到内存访问点, 地址空间 / 页缓存仍有成本. Binder 驱动也利用 mmap 建立用户态可访问缓冲区.
  • page fault: 访问的虚拟页尚未在物理内存中, CPU 触发异常进入内核处理. 轻微缺页可能只建映射, 重大缺页可能要读磁盘.
  • 启动性能关联: 冷启动读取 dex, resources, so 时可能产生大量缺页; Baseline Profile, 预加载和减少冷路径大文件访问都能降低抖动.
  • 内存压力关联: 匿名页, 文件页, Ashmem / 共享内存都会参与系统回收; 低端机上大 Bitmap 和大 mmap 文件都要考虑峰值.
选择更适合常见失败证据与修复
read + 小缓冲顺序流式解析, 明确背压主线程阻塞, 缓冲过大Perfetto 看 I/O 阻塞; 移后台, 按吞吐测缓冲大小
mmap随机读取, 多个访问点共享页缓存冷页 fault 抖动, 映射后文件截断的异常访问Perfetto/page-fault 指标与 tombstone; 预热关键页, 限制映射生命周期

示例边界: 不能因为 mmap “少复制” 就用它映射全部超大文件; 32 位进程或地址空间碎片下映射可能失败. 对同一设备, 同一访问模式测量端到端耗时, 缺页和峰值 RSS 后再选择.

二, 文件描述符与多路复用: 监听集合而不是轮询所有连接

Linux 把文件, socket, pipe, eventfd 等都抽象成文件描述符 (fd). Android 的网络, 数据库, 日志, Looper 唤醒都离不开 fd.

I/O 机制特点Android 关联
阻塞 I/O调用线程等待结果主线程网络/磁盘会 ANR
非阻塞 I/O没数据立即返回需要轮询或事件通知
select/poll监听多个 fd, 但扩展性一般传统多路复用方案
epoll事件驱动, 适合大量 fdLooper, 网络框架, native event loop

Looper 为什么不忙等? MessageQueue 没消息时, 底层通过 epoll_wait 阻塞等待 fd 事件或超时; 有消息, Binder, 输入事件, 定时器到期时再被唤醒. 这也是 “死循环不等于耗 CPU” 的经典追问.

fd 常见问题:

  • 文件 / 网络流未关闭导致 fd 泄漏, 最终 Too many open files.
  • 日志, 图片, 数据库 Cursor 未及时 close.
  • 连接池过大或泄漏导致 socket fd 占用异常.

select, poll, epoll 与 LT/ET

select 用位图集合, 受 FD_SETSIZE 等实现限制, 返回后要重新设置集合; poll 用数组, 移除位图上限但每次仍线性扫描; epoll 将关注 fd 注册到内核, epoll_wait 返回就绪事件列表, 适合大量长期连接. 三者都不是自动让业务处理变快, 回调中的阻塞工作仍会卡住事件线程.

触发方式语义处理规则典型坑
LT (水平触发, 默认)只要 fd 仍可读 / 写, 就会持续通知可每次读一部分, 下一轮仍会收到未及时处理会反复唤醒, 形成忙循环
ET (边沿触发)状态从未就绪变为就绪时通知fd 设非阻塞, 并循环读 / 写到 EAGAIN只读一次就返回会遗漏剩余数据, 可能永不再通知

ET 伪 trace: socket 到达 8 KiB → 一次可读事件 → handler 用非阻塞 read 循环读取直到 EAGAIN → 等待下一次 “无数据到有数据” 边沿. 边界: EOF 是 read=0, 不是 EAGAIN, 必须关闭 / 回收 fd; 错误事件必须同时读取 errno/socket error, 而非把它当普通可读.

三, Linux 普通任务调度, vruntime 与 Android 卡顿

传统 CFS 的目标是公平分配 CPU: 它用按权重归一的虚拟运行时间 vruntime 排序, vruntime 较小的可运行任务优先获得 CPU.nice 值影响权重: 较高权重的任务消耗相同真实运行时间时 vruntime 增长较慢. 新上游 Linux 正逐步以 EEVDF (Earliest Eligible Virtual Deadline First) 取代传统 CFS 的选任务机制, 因此不能把 vruntime 的具体选择规则外推到所有新内核. Android 设备实际使用 CFS 还是 EEVDF, 以及厂商是否回移植补丁, 必须以该设备的 kernel 版本与源码/配置为准; Android 仍会叠加进程优先级, 线程 nice 值, cgroup/cpuset 与前后台策略.

  1. UI 线程不是绝对优先: 它仍要参与调度, 如果自己执行长任务或系统 CPU 被打满, 就会错过约 16.7ms 的 60 Hz 单帧周期.
  2. 线程优先级要谨慎: 后台下载 / 日志压缩不应抢 UI; 音视频, 渲染, 输入链路要避免被低价值任务干扰.
  3. 协程调度器不是魔法: Dispatchers.Default 适合 CPU, Dispatchers.IO 适合阻塞 IO, 乱用会导致线程饥饿.
  4. ANR 本质: 主线程长时间无法处理输入, 广播, 服务生命周期等消息, 可能是锁等待, IO, CPU, Binder 调用卡住.

四, 锁, 死锁与现场排查

Android 中的死锁常见于 synchronized 交叉持锁, 主线程等待后台且后台反向同步切主线程, 或 Binder 同步调用互等; 四个形成条件等基础定义见操作系统与数据库基础.

Thread-A: 持有 dbLock → 等待 networkLock
Thread-B: 持有 networkLock → 等待 dbLock
结果:循环等待,两边都无法推进

实践建议:

  • 固定锁顺序, 避免 A→B 与 B→A 混用.
  • 不在持锁期间做网络, 磁盘, Binder 或回调外部代码.
  • 优先缩小临界区, 读多写少用读写锁或不可变快照.
  • Kotlin 协程中区分 Mutex 与 JVM 锁, 不要在 synchronized 内调用可能挂起的逻辑.
  • 主线程不要等待后台锁; 后台也不要同步等待主线程回调.

排查 trace (示意, 不是实际工具输出):

"main"  BLOCKED on <0xA> (dbLock), owned by "worker-1"
"worker-1" BLOCKED on <0xB> (networkLock), owned by "main"

步骤: 1) 收集所有线程栈和锁 owner (Java kill -3/ANR traces, native 看 tombstone); 2) 从 BLOCKED 线程画 “线程 → 等待锁 → 持锁线程” 边; 3) 找环, 而不是只看第一个卡住的线程; 4) 对照锁顺序, 同步 Binder/主线程等待点; 5) 固定全局锁序或移除持锁 I/O; 6) 用回归并发测试与 trace 验证等待环消失. 超时只能避免无限等待, 不能修复共享状态一致性, 不能替代锁序设计.

五, Android/Linux 关联速记

  • Zygote: 预加载类和资源后 fork App 进程, 降低启动成本; fork 后进程拥有独立虚拟地址空间, 通过 COW 共享只读页.
  • Binder: Android 主要 IPC, 结合驱动, mmap, 线程池和引用计数, 比 Socket 更适合系统服务调用.
  • cgroup/cpuset: 系统按前后台, 任务类型限制 CPU / 资源分配, 解释为什么后台任务可能变慢.
  • LMK / 内存回收: 低内存时系统按进程重要性回收; App 要保存状态, 不能假设进程永生.
  • SELinux / 权限模型: 限制进程访问系统资源, 移动安全和文件访问都要考虑沙箱边界.

六, 可观测工具与版本边界

Android Framework 进程与生命周期见 Framework 源码与进程生命周期. 内核, LMKD, 调度和 /proc 字段可能随 Android 版本与厂商内核变化, 引用实现时注明 AOSP tag / 设备 kernel 版本.

排查优先使用可观察证据: Perfetto/atrace 看调度与 I/O, heapprofd 看 native allocation, dumpsys meminfo/procstats 看进程状态, tombstone 看 native 现场. 工具输出是某个时间点和设备的观测, 不能直接外推为所有版本机制.

高频面试题

Q1: mmap 和普通 read 有什么区别? read 把数据从内核缓冲复制到用户缓冲, mmap 把文件映射到虚拟地址空间, 访问时按页加载, 可减少拷贝和简化随机访问. 但 mmap 仍可能触发 page fault, 不是 “免费加载”.

Q2: Looper 底层为什么用 epoll? Looper 要同时等待消息队列, 输入, Binder / 管道等 fd 事件. epoll 能高效等待多个 fd, 无事件时阻塞让出 CPU, 有事件再唤醒, 避免忙等.

Q3: Android 死锁如何排查和避免? 看线程堆栈确认谁持有什么锁, 谁在等待; 避免嵌套锁顺序不一致, 持锁做耗时操作, 主线程同步等待后台. 必要时用超时, 锁顺序规范和异步化拆环.

易错点 / 追问

  • 易错: 把协程说成轻量线程; 准确说协程不是内核线程, 它运行在线程之上.
  • 追问: page fault 是否一定是坏事? 不是, 按需分页依赖它; 但冷启动大量重大缺页会带来磁盘 IO 抖动.
  • 易错: 以为 epoll 只用于服务端高并发; Android Looper 和 native 事件循环同样依赖 fd 多路复用思想.
  • 追问: 为什么主线程没有死循环占 CPU? 因为 MessageQueue 空闲时阻塞在 epoll_wait, 不是 while true 忙轮询.

主题练习与预期证据

  1. 为一个顺序读取和一个随机读取场景选择 read 或 mmap, 预期说明页 fault/复制/地址空间三项权衡及一个实际测量指标.
  2. 用 ET 的 8 KiB trace 解释为何必须读到 EAGAIN, 并说明 read=0 的不同处理.
  3. 对两线程交叉持锁的示意栈画等待图, 预期明确锁环和固定锁顺序后的验证方式.

设计模式与 Android 源码应用

设计模式几乎每场中级面试都问, 且面试官爱问 “在 Android 源码 / 常用库里的实际应用”: 光背定义不够, 要能举出框架里的真实例子. 本篇按这个思路组织.

学习目标与使用边界

完成后应能从变化点选择模式, 写出最小 Kotlin/Java 模板, 并说明生命周期, 并发和失败转移. 本章以模式机制和 Android 对照为主; 支付领域完整验签, 幂等与对账链路见支付订单与状态机, 不能用模式名替代业务可靠性设计.

一, SOLID 五项原则与补充原则

原则核心含义Android 反例 → 正例面试落点
单一职责 (SRP)一个类只承担一个变化原因Activity 同时写 UI, 网络, 缓存, 埋点 → 拆成 ViewModel, Repository, Tracker不是 “类越小越好”, 而是变更原因隔离
开闭原则 (OCP)对扩展开放, 对修改关闭支付页新增渠道就改 when(type) → 抽 PaymentStrategy, 新增渠道只加实现用多态 / 注册表替代反复改老代码
里氏替换 (LSP)子类可替换父类而不破坏调用方预期自定义 View 重写 onMeasure 却不尊重 MeasureSpec → 保持父类契约继承要遵守父类语义, 否则组合优先
接口隔离 (ISP)接口最小化, 不强迫实现无用方法UserModuleService 同时暴露登录, 头像, 支付状态 → 拆 AuthService/ProfileService组件化接口下沉时尤其重要
依赖倒置 (DIP)高层依赖抽象, 不依赖具体实现ViewModel 直接 new RetrofitApi → 依赖 UserRepository 接口, 由 Hilt / 工厂注入实现DI 的理论基础, 也是可测试性的基础
迪米特法则最少知识, 只和直接朋友通信页面跨模块拿 OrderManager.userManager.tokenStore.token → 通过 AuthService.getToken()减少调用链泄漏, 降低跨模块耦合

Android 重构口诀: Activity 变薄 (SRP), 新增业务走扩展点 (OCP), 继承守契约 (LSP), 跨模块接口要小 (ISP), 高层依赖接口 (DIP), 不要跨层级 “摸内部对象”(迪米特).

SOLID 只有五项原则. 迪米特法则 (Law of Demeter) 和 “组合优于继承” 是常用的补充原则, 不是 SOLID 的第六项.

设计模式题的统一答法

不要先背模式名, 先描述变化点和约束:

  1. 哪一部分会变化, 谁应该拥有这份变化.
  2. 调用方依赖什么稳定契约, 新实现如何接入.
  3. 生命周期, 并发, 错误恢复和测试替换点在哪里.
  4. 引入模式增加了多少类型和间接层, 当前规模是否值得.

这套顺序能把 “我知道这是观察者模式” 变成 “我为什么在这里选择观察者, 以及它的代价是什么”.

二, 创建型模式

单例 (最高频)

确保一个类只有一个实例. Kotlin 用 object 天然单例. Java 重点是双重检查锁 (DCL):

class Singleton private constructor() {
    companion object {
        @Volatile private var instance: Singleton? = null
        fun get(): Singleton =
            instance ?: synchronized(this) {
                instance ?: Singleton().also { instance = it }
            }
    }
}
  • volatile 的作用: 防止指令重排导致拿到未初始化完成的对象.
  • Android 应用: 进程内共享的 OkHttpClient, 数据库实例和 Repository. Application 由框架在进程内创建和管理, 常表现为单实例, 但不是业务代码通过私有构造器实现的 GoF 单例; getSystemService() 返回对象是否缓存也取决于具体服务实现.

Java 静态内部类模板: 类初始化由 JVM 保证线程安全, 且内部类仅在第一次 getInstance() 时初始化, 兼具懒加载与无需显式锁.

public final class SettingsStore {
    private SettingsStore() {}
    private static class Holder {
        static final SettingsStore INSTANCE = new SettingsStore();
    }
    public static SettingsStore getInstance() { return Holder.INSTANCE; }
}

边界: 这只保证单个 ClassLoader 内的单例, Android 多进程会各有实例; 它也不自动保证所持有的 Context, 缓存和 I/O 的线程安全. 测试可并发获取多个引用并断言相同, 再单独验证初始化副作用只执行一次.

工厂 / 抽象工厂

封装对象创建, 调用方只描述 “我要什么”, 不关心具体构造细节.

  • 角色: 调用方 (Client)→ 工厂 (Factory)→ 具体产品 (Product).
  • Android 应用: BitmapFactory.decodeStream() 根据输入流 / Options 创建 Bitmap; LayoutInflater.from(context) 根据 Context 找到合适 inflater; Executors 根据方法创建不同线程池.
  • 为什么匹配: 调用方不直接 new Bitmap(...), 而把复杂创建逻辑, 兼容分支, 缓存 / 复用细节交给工厂.

简单工厂, 工厂方法, 抽象工厂怎么区分

形式变化点Android / 业务例子主要代价
简单工厂一个入口按参数选择产品PaymentFactory.create(channel)新增产品通常要修改工厂分支
工厂方法把创建延迟给子类 / 实现不同环境创建不同 DataSource类型数量增加, 需要维护创建者层次
抽象工厂一次创建一组相互匹配的产品同一渠道的 ApiClient + Serializer + Signer适合产品族, 不适合只有一个可变对象

面试时不要把所有 create() 都叫抽象工厂. 只有 “创建一组有关联的产品” 时才需要抽象工厂.

简单工厂模板: 一个集中入口按稳定枚举选择产品, 新增渠道必须修改分支, 因此适合渠道数量少且受控的场景.

enum class ParserType { JSON, CSV }
interface Parser { fun parse(input: String): List<String> }
object ParserFactory {
    fun create(type: ParserType): Parser = when (type) {
        ParserType.JSON -> JsonParser()
        ParserType.CSV -> CsvParser()
    }
}
class JsonParser : Parser { override fun parse(input: String) = listOf(input) }
class CsvParser : Parser { override fun parse(input: String) = input.split(',') }

这是可运行的 Kotlin/JVM 最小示例, JsonParser 仅为了展示构造分派, 并未真正解析 JSON. 边界: 外部插件可扩展渠道时应改注册表 / 工厂方法, 避免每次改中心 when; 未知输入必须在解析为 ParserType 时显式报错.

建造者 (Builder)

链式构造复杂对象, 适合可选参数多, 构建过程需要校验的场景.

  • 角色: Builder 暂存配置, build()/show() 生成最终对象.
  • Android 应用: AlertDialog.Builder.setTitle().setPositiveButton().show(), OkHttpClient.Builder.addInterceptor().build(), Retrofit.Builder.baseUrl().addConverterFactory().build(), Notification.Builder, Glide 请求构建.
  • 为什么匹配: 避免超长构造函数, 把 “配置过程” 和 “不可变/可执行对象” 分离; 面试讲 OkHttp/Retrofit 时可顺带讲 Builder.

原型 (Prototype)

通过拷贝已有对象创建新对象, 适合 “保留大部分配置, 只改少数字段”.Android 应用: Intent.clone()/复制 Bundle 后修改 extras. 它匹配原型模式, 因为新对象来自已有对象快照, 而不是从零组装.

深浅拷贝边界: Kotlin data class.copy() 是浅拷贝, 引用字段仍共享; 只有嵌套可变对象也被复制才是所需意义上的深拷贝.

data class RetryPolicy(val delaysMs: MutableList<Long>)
data class RequestConfig(val url: String, val retry: RetryPolicy)

val original = RequestConfig("/pay", RetryPolicy(mutableListOf(100, 500)))
val shallow = original.copy()
shallow.retry.delaysMs += 1_000 // original 也会看到 1_000
val deep = original.copy(retry = original.retry.copy(delaysMs = original.retry.delaysMs.toMutableList()))

练习证据: 修改 deep.retry.delaysMs 后 original 不变; 修改 shallow 后原对象变化. 不可变集合能减少此类风险, 但不等同于跨进程 / 磁盘快照.

三, 结构型模式

代理 (Proxy)

为真实对象提供一个代理入口, 在调用前后做控制, 延迟, 跨进程或增强.

  • 角色: 接口 Subject, 真实对象 RealSubject, 代理 Proxy.
  • Retrofit: retrofit.create(Api::class.java) 用 Proxy.newProxyInstance 生成接口实现; 调用 api.getUser() 时进入 InvocationHandler, 解析注解并创建 HTTP 请求.
  • Binder AIDL: 客户端拿到的是 Stub.Proxy, 方法调用先写入 Parcel, 再通过 Binder 驱动发给服务端 Stub.onTransact().
  • 为什么匹配: 调用方以为自己在调普通接口, 实际被代理拦截并转成网络 / 跨进程调用.

动态代理定义与最小模板: 运行时代理由 Proxy 为接口生成实现, 所有方法调用进入 InvocationHandler; 它要求目标类型是接口, 类代理需要其他机制.

interface Greeting { String hello(String name); }
Greeting logged = (Greeting) java.lang.reflect.Proxy.newProxyInstance(
    Greeting.class.getClassLoader(),
    new Class<?>[] { Greeting.class },
    (proxy, method, args) -> {
        if (method.getName().equals("hello")) return "hello, " + args[0];
        throw new UnsupportedOperationException(method.toString());
    }
);
// logged.hello("Ada") -> "hello, Ada"

此 Java 片段可放入普通 JVM main 运行; 生产 handler 还要正确处理 equals/hashCode/toString, 异常包装, 线程与反射开销. Retrofit 的代理还会解析注解, 缓存 service method, 并非这个最小 handler 的全部逻辑.

适配器 (Adapter)

把一个接口转换成调用方需要的另一个接口.

  • 角色: 目标接口 (Target), 被适配者 (Adaptee), 适配器 (Adapter).
  • RecyclerView.Adapter: 数据源可能是 List<Item>, 但 RecyclerView 需要 getItemCount/onCreateViewHolder/onBindViewHolder 这组接口; Adapter 把数据转换成可复用的 ViewHolder 绑定流程.
  • 为什么匹配: RecyclerView 不直接认识业务数据, 只认识 Adapter 契约; 业务侧实现契约即可接入框架.

装饰器 (Decorator)

在不修改原对象的情况下动态叠加能力.

  • 角色: 组件接口 Component, 被装饰对象 ConcreteComponent, 装饰器 Decorator.
  • Android 应用: ContextWrapper 持有一个 base Context 并转发大多数调用; ContextThemeWrapper 在 base 能力上增加 theme 解析能力.
  • 可验证点: AOSP ContextWrapper.java 持有 mBase 字段, getSystemService()/startActivity() 等方法直接转发给 mBase 对应方法, base 在构造时经 attachBaseContext() 注入; ContextThemeWrapper 重写 onApplyThemeResource() 把 theme 解析叠加到 base 能力上.
  • 为什么匹配: 外部仍按 Context 使用, 但功能被包装增强; 这与继承扩展不同, 装饰可以按需组合.

外观 (Facade)

提供统一高层接口, 隐藏子系统复杂度.Android 应用: Context.getSystemService() 把 ActivityManager, ClipboardManager, LayoutInflater 等系统能力收口到统一入口; 调用方不需要知道服务发现和 Binder 细节. 它匹配外观模式, 因为 Context 是简化入口, 不是具体业务实现本身.

组合 (Composite)

把单个对象和对象树用同一套接口处理.Android 应用: ViewGroup 同时作为 View 使用, 又持有和遍历子 View; 调用方可以对整棵 View 树执行测量, 布局和绘制, 不需要区分叶子节点和容器.

  • 适用边界: 层级天然存在, 且父节点和子节点需要统一操作时适合. 如果只是多个对象的顺序调用, 不要为了形式引入树结构.
  • 面试追问: ViewGroup 的 measure/layout/draw 为什么是组合模式? 需要补 “父节点递归驱动子节点, 但子节点仍遵守 View 契约”.
  • 可验证点: ViewGroup 通过 getChildAt()/getChildCount() 管理子节点, dispatchTouchEvent() 中遍历 children 逐个调用 dispatchTransformedTouchEvent() 完成事件分发; 绘制同理由 dispatchDraw() 递归驱动整棵 View 树.

桥接 (Bridge)

把抽象和实现拆成两个可以独立变化的层次.Android 工程应用: 业务定义统一 ImageLoader 抽象, 再接入 Glide, Coil 或测试实现; 图片加载能力和具体库可以分别演进, 调用页面不依赖第三方 API. 框架级例子是 Window(抽象窗口行为) 与其平台实现 PhoneWindow: Activity 只依赖 Window 抽象, 具体窗口实现可替换 (以当前 AOSP 源码核验).

桥接和适配器的区别是: 适配器解决已有接口不兼容, 桥接从设计之初就把两个变化维度拆开, 避免继承组合爆炸.

享元 (Flyweight)

共享可复用的内部状态, 降低大量相似对象的内存成本.Android 应用: Drawable.ConstantState 可让多个 Drawable 实例共享相同资源对应的常量状态, 各实例再保留边界, 状态等外部数据; 调用 mutate() 时要理解共享状态会被分离. StateListDrawable 同样经 ConstantState 共享同一份 selector 资源的子 drawable 列表, 多个实例共用这份不变数据, 各自只保留当前选中状态; 布局中多处引用同一 drawable 时它们共享同一份 ConstantState.

注意享元必须区分可共享的内部状态和每次调用的外部状态. RecyclerView ViewHolder 和 BitmapPool 更准确地说是对象池 / 复用机制, 不应仅因 “复用” 就机械归为享元. 把带有页面生命周期的对象做全局共享, 还会造成泄漏和状态污染.

四, 行为型模式

观察者 (Observer, 高频)

一对多依赖, 被观察者状态变化时通知观察者.

  • 角色: Subject 保存观察者列表, Observer 接收回调.
  • Android 应用: LiveData.observe(owner) {} 在生命周期活跃时通知观察者; OnClickListener 是 View 事件的观察回调; Adapter.notifyDataSetChanged() 通知 RecyclerView 数据变化.
  • 为什么匹配: 数据/事件源不直接依赖具体页面逻辑, 只通知已注册观察者; 注意 Flow 更偏响应式流, 面试可类比观察者但要说明它还有冷/热流, 背压, 协程上下文语义.
  • 踩坑: 注册了忘注销会造成泄漏: onDestroy 中必须对称 unsubscribe/removeObserver, 把监听器绑定到宿主生命周期可减少遗漏; 通知顺序依赖注册顺序, 订阅者相互影响时异常顺序难排查.

经典 Subject / Observer 最小完整模板: Subject 只依赖 Observer 接口; 订阅, 取消订阅与通知均由它负责. 实际 Android 场景还必须把生命周期和线程切换纳入实现.

fun interface Observer<T> {
    fun onChanged(value: T)
}

class Subject<T> {
    private val observers = mutableSetOf<Observer<T>>()

    fun subscribe(observer: Observer<T>) { observers += observer }
    fun unsubscribe(observer: Observer<T>) { observers -= observer }
    fun publish(value: T) { observers.toList().forEach { it.onChanged(value) } }
}

val subject = Subject<String>()
val observer = Observer<String> { value -> println(value) }
subject.subscribe(observer)
subject.publish("updated")
subject.unsubscribe(observer)
+----------+    subscribe / unsubscribe    +------------+
| Observer | <---------------------------- |  Subject   |
+----------+                               +------------+
     ^                                           |
     +-------------- onChanged(value) -----------+

责任链 (Chain of Responsibility, 高频)

请求沿链传递, 每个节点决定处理, 增强或继续传递.

  • OkHttp: RealInterceptorChain.proceed(request) 把请求交给下一个拦截器; 应用拦截器, 重试重定向, 桥接, 缓存, 连接, CallServer 等节点逐层处理.
  • View 事件分发: Activity.dispatchTouchEvent → ViewGroup.dispatchTouchEvent/onInterceptTouchEvent → View.dispatchTouchEvent/onTouchEvent, 每层决定拦截, 消费或继续下发 / 回传.
  • 为什么匹配: 发送方不需要知道最终谁处理, 链上每个节点只关心自己职责; 这也是和主流第三方库中 OkHttp 内容的跨章重复点, 这里讲模式原因即可.
  • 踩坑: 链上顺序直接决定结果: OkHttp 中缓存 / 重试拦截器位置不同, 缓存命中与重试行为就不同; 每个节点要显式返回 “已处理” 或 “继续传递”, 不能把 null 同时表达失败和未处理.

策略 (Strategy)

封装可互换算法, 调用方依赖统一接口.Android 应用: 属性动画依赖 TimeInterpolator.getInterpolation(input), 线性, 加速, 回弹等插值器都可替换. 它匹配策略模式, 因为动画框架不改主流程, 只替换 “时间进度如何映射为动画进度” 的算法.

模板方法 (Template Method)

父类 / 框架定义流程骨架, 子类填充步骤.Android 应用: ActivityThread/框架按生命周期调度, 业务 Activity 重写 onCreate/onStart/onResume; 自定义 View 重写 onMeasure/onDraw. 它匹配模板方法, 因为整体流程由框架控制, 应用只实现钩子步骤. AsyncTask 也体现该模式但已废弃, 面试只作历史例子.

命令 (Command)

把请求封装成对象, 便于排队, 延迟, 取消或跨线程传递.Android 应用: Handler.post(Runnable) 把一段动作封装进 MessageQueue; Message.what/obj/callback 描述要执行的命令. 它匹配命令模式, 因为发送方只投递命令对象, 执行时机由 Looper 决定. 可验证点: Message.callback 字段即命令对象, Handler.dispatchMessage() 优先执行 msg.callback, 否则回调 handleMessage(msg); Looper.loop() 从 MessageQueue.next() 取出消息后交回 msg.target.dispatchMessage() 执行.

状态 (State, 高频)

把对象在不同状态下的行为封装起来, 避免一个巨大的 when(state) 分支不断膨胀.Android / 业务应用: 支付订单可有 Created, Paying, Success, Failed, Cancelling, Cancelled; 每个状态定义允许的事件和转移结果, UI 只观察状态快照.

sealed interface OrderEvent {
    data object Pay : OrderEvent
    data object Paid : OrderEvent
    data class Error(val reason: String) : OrderEvent
    data object Retry : OrderEvent
    data object Cancel : OrderEvent
    data object Cancelled : OrderEvent
}

sealed interface OrderState {
    data object Created : OrderState
    data object Paying : OrderState
    data class Failed(val reason: String) : OrderState
    data object Success : OrderState
    data object Cancelling : OrderState
    data object Cancelled : OrderState
}

fun reduce(state: OrderState, event: OrderEvent): OrderState = when (state) {
    OrderState.Created -> when (event) {
        OrderEvent.Pay -> OrderState.Paying
        OrderEvent.Cancel -> OrderState.Cancelled
        else -> state
    }
    OrderState.Paying -> when (event) {
        OrderEvent.Paid -> OrderState.Success
        is OrderEvent.Error -> OrderState.Failed(event.reason)
        OrderEvent.Cancel -> OrderState.Cancelling
        else -> state
    }
    is OrderState.Failed -> if (event is OrderEvent.Retry) OrderState.Paying else state
    OrderState.Cancelling -> if (event is OrderEvent.Cancelled) OrderState.Cancelled else state
    OrderState.Success, OrderState.Cancelled -> state
}

State vs Strategy: Strategy 是调用方主动选择一个可替换算法; State 是对象状态变化后决定下一步行为, 通常由状态机驱动转移. 支付, 下载, 连接, 登录等有明确生命周期和非法转移时优先考虑 State.

支付状态图 (简化示例):

Created --Pay--> Paying --Paid--> Success
   |                |  \--Error--> Failed --Retry--> Paying
   \--Cancel------> Cancelled
Paying --Cancel--> Cancelling --Cancelled--> Cancelled

图与 reduce 使用同一组状态和事件: Created/Paying/Failed/Success/Cancelling/Cancelled 以及 Pay/Paid/Error/Retry/Cancel/Cancelled. 其中 Paid 必须绑定服务端交易号 / 验签结果, 不能仅凭客户端回调进入 Success; 网络超时通常应进入 “待确认” 或触发查询, 而不是直接 Failed. 每一次持久化转移应带幂等 key. 此处的 OrderState/reduce 为演示状态模式的简化教学模型 (状态类 / 转移表 / 非法转移兜底), 生产级完整订单状态机 (含 PENDING_CONFIRMATION, REFUNDING, RISK_REVIEW 等异常态) 见支付订单与状态机.

踩坑: else -> state 静默忽略非法转移会让状态机 “没有兜底”, 非法事件被吞掉后线上难排查; 应在遇到未定义转移时记录日志 / 埋点, 或显式落入 Illegal 状态, 而不是静默返回原状态.

中介者 (Mediator)

用一个中介对象协调多个对象, 避免对象之间形成网状依赖.Android 应用: FragmentManager 协调 Fragment 生命周期和事务, CoordinatorLayout 协调 AppBarLayout, 滚动子 View 等参与者; 组件化中也可以用一个明确的导航 / 服务入口协调跨模块调用.

中介者不是把所有业务都塞进一个 “万能 Manager”. 中介者自身必须有清晰边界, 否则只是把网状耦合集中成一个巨型类.

其他

  • 迭代器: 集合的 Iterator.
  • 备忘录: onSaveInstanceState.

五, 高频模式选择表

面试场景优先考虑不要混淆
多种支付/排序/校验算法可替换Strategy有状态转移时用 State
订单/下载/连接生命周期State只替换算法时不要做 State
旧接口接入新框架Adapter运行时增强用 Decorator, 调用拦截用 Proxy
给对象叠加日志, 缓存, 重试Decorator跨进程 / 动态代理更接近 Proxy
隐藏子系统复杂调用Facade不是把所有逻辑都放进一个 Manager
请求逐层处理Chain of Responsibility每层都必须能决定终止或继续
页面/模块之间互相协调Mediator需要长期状态时仍要配合 Repository/StateFlow
组装一个多参数复杂对象BuilderFactory 关注创建哪一种产品, Builder 关注如何分步配置一个产品
一对多同步回调ObserverFlow 还定义冷 / 热流, 取消, 操作符和协程上下文语义

高频手写模板

Builder 模板: Builder 暂存可选配置, build() 校验后产出不可变对象; 避免超长构造函数和半初始化对象.

class HttpClient private constructor(val baseUrl: String, val timeoutMs: Long) {
    class Builder {
        private var baseUrl: String? = null
        private var timeoutMs: Long = 10_000
        fun baseUrl(v: String) = apply { baseUrl = v }
        fun timeout(v: Long) = apply { timeoutMs = v }
        fun build(): HttpClient = HttpClient(
            requireNotNull(baseUrl) { "baseUrl is required" },
            timeoutMs,
        )
    }
}

Strategy 模板见下方 PaymentStrategy: 它把渠道选择与支付算法解耦, 是策略模式的可运行骨架, 不再重复写第二个接口.

支付策略的关键不是写一个接口, 而是让渠道选择和支付算法都可替换, 同时把未知渠道变成显式错误:

enum class PayChannel { ALIPAY, WECHAT }

sealed interface PaymentResult {
    data class Submitted(val transactionId: String) : PaymentResult
    data class Failed(val reason: String) : PaymentResult
}

fun interface PaymentStrategy {
    suspend fun pay(orderId: String): PaymentResult
}

class PaymentProcessor(
    private val strategies: Map<PayChannel, PaymentStrategy>,
) {
    suspend fun pay(channel: PayChannel, orderId: String): PaymentResult {
        val strategy = requireNotNull(strategies[channel]) {
            "Unsupported channel: $channel"
        }
        return strategy.pay(orderId)
    }
}

责任链模板要明确 “已处理” 和 “继续传递”, 不能让 null 同时表达失败和未处理:

data class Request(val token: String?)

sealed interface HandleResult {
    data class Accepted(val token: String) : HandleResult
    data class Rejected(val reason: String) : HandleResult
    data object Continue : HandleResult
}

abstract class RequestHandler(
    private val next: RequestHandler? = null,
) {
    fun handle(request: Request): HandleResult = when (val result = doHandle(request)) {
        HandleResult.Continue -> next?.handle(request) ?: HandleResult.Continue
        else -> result
    }

    protected abstract fun doHandle(request: Request): HandleResult
}

事件订阅需要把生命周期, 缓冲和一次性语义写进实现. SharedFlow 可以承载发布订阅, 但它比经典 Observer 多了协程取消和流操作语义:

sealed interface AppEvent {
    data class Message(val text: String) : AppEvent
}

class EventHub {
    private val mutableEvents = MutableSharedFlow<AppEvent>(
        replay = 0,
        extraBufferCapacity = 32,
    )
    val events: SharedFlow<AppEvent> = mutableEvents.asSharedFlow()

    fun publish(event: AppEvent): Boolean = mutableEvents.tryEmit(event)
}

fun LifecycleOwner.observeEvents(
    eventHub: EventHub,
    onEvent: suspend (AppEvent) -> Unit,
) = lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        eventHub.events.collect(onEvent)
    }
}

tryEmit() 返回 false 时要有丢弃, 持久化或降级策略. 订单结果等关键业务事实不应只放在进程内事件流中, 应由 Repository 持久化状态, UI 再观察状态源.

设计模式的工程检查项

  • 生命周期: 模式对象的作用域是否比它持有的 Context, View 或回调更长.
  • 并发: 单例, 缓存, 观察者列表和状态转移是否有明确线程模型.
  • 错误恢复: 责任链, 策略和状态机是否定义失败, 重试, 取消和超时.
  • 可测试性: 依赖是否能替换为 fake, 是否需要测试观察通知顺序和非法状态转移.
  • 复杂度: 引入模式后类数量, 调用链和调试成本是否真的下降.

主题练习与预期证据

  1. 并发调用静态内部类 getInstance(), 预期全部引用相等; 另说明为何 Android remote process 不共享该对象.
  2. 给简单工厂输入未知字符串, 预期在输入转换层返回可观察错误, 而不是落入默认 Parser.
  3. 画出一次支付超时后的 “待确认 / 查询” 转移, 预期不允许客户端仅因超时将已扣款订单置为 Failed.

六, 记忆法: 按 “框架反推模式”

面试被问设计模式, 从你熟的框架反推最稳:

  • OkHttp 拦截器 → 责任链
  • Retrofit create → 代理 (动态代理)
  • 各种 Builder → 建造者
  • LiveData/Flow → 观察者
  • RecyclerView.Adapter → 适配器
  • 事件分发 → 责任链
  • RequestBuilder 链式配置图片请求 → 建造者
  • View/ViewGroup → 组合
  • 支付 / 下载状态机 → 状态
  • FragmentManager/CoordinatorLayout → 中介者

高频面试题

Q1: 手写一个线程安全的单例. 用 DCL (双重检查锁)+ volatile, 或静态内部类 (依赖 JVM 类加载的线程安全初始化 + 懒加载), Kotlin 直接 object. volatile 防止指令重排拿到半初始化对象.

Q2: DCL 里 volatile 为什么必须加? instance = Singleton() 不是原子操作, 分为分配内存, 初始化, 赋值引用三步. 指令重排后其他线程可能拿到已赋值但未初始化完成的对象. volatile 禁止重排.

Q3: OkHttp 用了什么设计模式? 责任链 (拦截器链, 核心), 建造者 (OkHttpClient.Builder). 不要生搬 “工厂 / 外观”: OkHttpClient 是 Builder 的产物, 不是 GoF 外观. 重点讲责任链: 请求依次经过各拦截器, 每个调 chain.proceed 传递.

Q4: Retrofit 怎么用设计模式把接口变成实现? 动态代理 (代理模式). create () 用 Proxy.newProxyInstance 生成接口代理, 方法调用被 InvocationHandler 拦截, 解析注解构造请求.

Q5: 观察者模式在 Android 哪里用到? LiveData/Flow 的数据观察, RxJava, 事件监听器, BroadcastReceiver. 一对多, 被观察者变化时通知所有观察者.

Q6: 事件分发是什么设计模式? 责任链. 事件沿 View 树 (Activity→ViewGroup→View) 传递, 每层决定拦截处理还是向下 / 向上传递.

Q7: MVC/MVP/MVVM 算设计模式吗? 算架构模式 (架构层面的模式), 不是 GoF 23 种设计模式. MVVM 内部用到观察者 (数据绑定).区分 “设计模式”(类级别) 和 “架构模式”(应用级别).

Q8: Strategy 和 State 的区别? Strategy 由调用方选择一个可互换算法, 算法之间通常相互独立; State 由当前状态决定行为和下一状态, 重点是合法转移和生命周期. 支付, 下载, 连接状态机优先讲 State.

Q9: Adapter, Decorator, Proxy 怎么区分? Adapter 改接口, 让不兼容的对象接入; Decorator 保持同一接口并叠加能力; Proxy 通常代表另一个对象, 在调用前后做控制, 延迟, 权限, 跨进程或远程调用.

Q10: 什么时候不应该使用设计模式? 只有一个实现且没有稳定变化点时, 直接函数或简单类更清晰. 模式应减少变化成本和耦合, 不能为了展示知识引入无意义的工厂, Manager 或层级.

移动端系统设计方法论

** 章节边界:** 本章讲统一方法, 并以图片系统完成一次端到端设计示范; 业务算法实现见 Android 业务算法场景, 按题练习见移动端系统设计题库, 图片库 API 细节见图片加载与缓存.

学习目标

完成本章后, 你能在 25 分钟内按 “澄清→估算→接口→状态→失败→验收” 回答一个移动端系统题, 并能明确哪些数字只是待验证的示例假设.

一, 先澄清, 再画图

先确认用户旅程, 非目标, DAU / 峰值, 在线或离线, 端侧资源预算, 权威数据源, 一致性, 隐私与发布约束. 没有容量, 恢复路径和验收口径的模块图不能证明设计可行.

需求与约束 -> 数据 owner/状态 -> 接口与数据流 -> 缓存/并发/幂等
-> 失败恢复与降级 -> 安全/平台约束 -> 指标 -> 灰度,回滚与验收

参数不是固定最佳实践. 缓存大小, 下载并发, 预取深度和重试次数都要说明内容分布, 设备档位, 网络, 测量方法和调整阈值.

二, 完整示范: 设计商品图片加载与预览系统

1. 澄清与假设

目标: 商品列表首屏和详情大图可展示, 网络波动可恢复, 列表快速滚动不出现错图. 非目标: 本题不设计 CDN 选型, 图片审核和服务端转码集群.

** 数字示例, 非真实业务数据也不是推荐固定参数:** 假设 DAU 为 100 万; 每人每天 20 次列表会话, 每次请求 30 张缩略图; 峰值系数 12; 平均缩略图传输 80 KiB. 则日请求数为 1,000,000 × 20 × 30 = 6 亿; 平均请求速率约为 600,000,000 / 86,400 ≈ 6,944 req/s; 示例峰值约 6,944 × 12 ≈ 83,328 req/s; 若都未命中, 峰值下行约 83,328 × 80 KiB ≈ 6.36 GiB/s. 实际应按 CDN 日志, 分辨率分布, 命中率和区域流量重算.

端侧内存也按目标尺寸估算. 示例中一张 360 × 240 的 ARGB_8888 位图约为 360 × 240 × 4 = 345,600 B, 约 338 KiB. 屏幕同时保留 12 张约 3.96 MiB, 这只是位图像素数据, 尚未包含对象, 解码瞬时峰值, 其他 UI 和 GPU/图形内存. 缓存预算应基于低端设备压测, OOM/GC 与命中率曲线决定.

2. 契约和架构

以下为接口草图, 需放进真实 Android 工程并补齐线程, 错误类型和依赖; 本章以 callback 风格示意 (bind/callback/RequestHandle), 题库统一用 Async<T> 抽象, 二者映射见 60 顶部契约说明:

interface ImageRepository {
    suspend fun load(request: ImageRequest): ResourceResult
}

enum class Priority { VISIBLE, PREFETCH }

data class ImageRequest(
    val model: String,
    val widthPx: Int,
    val heightPx: Int,
    val transformKey: String,
    val signature: String?,
    val priority: Priority
)

/** Repository payload; the concrete implementation may wrap a Bitmap, Drawable, or GPU resource. */
interface ImageResource

sealed interface ResourceResult {
    data class Success(
        val resource: ImageResource,
        val source: ImageSource
    ) : ResourceResult
    data class Failure(val error: ImageError) : ResourceResult
    data object Cancelled : ResourceResult
}

interface ImageTarget {
    var currentRequestId: String?
}

interface RequestHandle {
    val requestId: String
    fun cancel()
}

interface ImageRequestManager {
    fun bind(
        target: ImageTarget,
        request: ImageRequest,
        callback: (ImageResult) -> Unit
    ): RequestHandle
}

sealed interface ImageResult {
    val requestId: String
    data class Success(
        override val requestId: String,
        val resource: ImageResource,
        val source: ImageSource
    ) : ImageResult
    data class Failure(
        override val requestId: String,
        val error: ImageError
    ) : ImageResult
    data class Cancelled(override val requestId: String) : ImageResult
}

enum class ImageSource { MEMORY, DISK, NETWORK }
sealed interface ImageError {
    data object NetworkTransient : ImageError // timeout, unavailable, 5xx
    data object AuthOrRequest : ImageError // 401/403 and non-retryable 4xx
    data object DecodeOrUnsupported : ImageError
    data object LocalIoOrStorage : ImageError
}

cacheKey = hash(model, widthPx, heightPx, transformKey, signature); 尺寸, 变换或资源版本不同就不能复用同一结果. signature 可对应服务端版本 / ETag, 资源更新后使旧缓存失效.

ImageRepository.load 只接收可合并的资源请求, 返回不含 target 身份的闭合 ResourceResult, 不能从中推导或伪造 requestId. request-manager wrapper 在每次 bind(target, request, callback) 时生成新的 requestId, 保存 target.currentRequestId, 并返回含该 ID 的 RequestHandle; repository 的 ResourceResult.Success/Failure/Cancelled 由 wrapper 映射为同一 ID 的 ImageResult.Success/Failure/Cancelled 后才交给 listener/target.wrapper 取消 handle 时只向该 owner 交付 ImageResult.Cancelled(requestId), 不要求 repository 生成 target 结果. 合并下载可以服务多个 listener, 但每个 listener 都得到带其自身 requestId 的 ImageResult. VISIBLE 可抢占 / 优先于 PREFETCH, 具体并发数和抢占策略由首图 P95, 废弃字节, 设备内存及网络测量后调整. 取消是本地 listener/owner 语义, 不把 requestId 当服务端幂等键.

Compose/ImageView target
  -> request owner(可见性与取消)
  -> active request 合并 -> 内存缓存 -> 变换后磁盘缓存 -> 源文件缓存
  -> local URI 或 HTTPS/CDN -> 下载 -> 按目标尺寸解码 -> 显示/写缓存

列表行只持有 wrapper 分配的当前 requestId. 复用时 wrapper 先取消或替换旧 listener, 再写入新 ID; 回调仅当 target.currentRequestId == result.requestId 且结果为 Success 才显示. Failure 按错误类别显示错误态或等待重试, Cancelled 不更新 UI; 这避免异步回调把旧图写入新行.

3. 缓存, 状态与时序

缓存策略可为内存 LRU 保存已解码资源, 磁盘缓存保存原始数据或变换结果; 取舍取决于 CPU, 磁盘和重复变换比例. 不要把 onStop() 一律暂停请求当规则, 应按可见性, 预取价值, 流量和业务语义决定.

Idle --bind(target, ImageRequest, callback)--> Loading(requestId)
Loading --ResourceResult.Success(resource, source) / wrapper maps ImageResult.Success(requestId)--> Ready(requestId)
Loading --ResourceResult.Cancelled or owner cancel / wrapper maps ImageResult.Cancelled(requestId)--> Cancelled(requestId)
Loading --cancel/rebind by owner--> Cancelled(requestId) --bind(new requestId)--> Loading
Loading --ResourceResult.Failure(NetworkTransient) / wrapper maps ImageResult.Failure(requestId)--> RetryWaiting(requestId) --timer/network change--> Loading(requestId)
Loading --ResourceResult.Failure(AuthOrRequest/DecodeOrUnsupported/LocalIoOrStorage) / wrapper maps ImageResult.Failure(requestId)--> Failed(requestId) --user retry/new requestId--> Loading
Ready --resource version changes--> Stale(requestId) --bind(new requestId)--> Loading
UI bind target to R1 -> wrapper creates requestId=A, sets target.currentRequestId=A, returns handle A -> repository.load(R1) -> ResourceResult.Success/Failure/Cancelled
wrapper maps ResourceResult.Success/Failure/Cancelled for listener A -> matching ImageResult(requestId=A) -> callback A
UI reuses target for R2 -> wrapper cancels/detaches handle A, creates requestId=B, sets target.currentRequestId=B -> repository.load(R2)
late callback A or ImageResult.Cancelled(A) -> A != target.currentRequestId -> discard for this target
repository ResourceResult.Success for R2 -> wrapper maps ImageResult.Success(requestId=B) -> B == target.currentRequestId -> render and retain according to owner policy

Ready 的 owner 是请求管理器, UI 仅渲染结果; 跨进程死亡不保存 Bitmap, 而保存可重新构造的 ImageRequest/页面数据. 下载中的临时文件必须原子落盘, 不能把半文件当缓存命中.

4. 失败, 证据与修复路径

症状证据与定位修复 / 降级验证
快速滚动出现错图target/requestId, 取消日志, 复用录屏绑定时替换旧 listener 并校验 wrapper requestId固定测试集 (覆盖 N 次 bind/reuse) 中, 窗口 [bind, target detach] 内 “显示结果的 cacheKey 不等于当前绑定 cacheKey” 的次数 / 有结果显示的绑定次数 = 0; RecyclerView/Compose 滚动测试记录分子, 分母和版本
OOM 或频繁 GCheap, allocation, 缓存命中和像素尺寸按视图尺寸采样, 缩小预算或淘汰策略低内存设备滚动压测, 对比峰值和帧时间
弱网长时间占位DNS/TLS/下载分段耗时, 网络类型超时, 带 jitter 的有限重试, 低清图或错误态飞行模式/切网/限速矩阵
资源更新仍显示旧图响应 ETag / 版本, cacheKey 命中日志将版本纳入 signature, 失效旧项更新同 URL 资源的集成测试

对幂等 GET 可在满足请求预算时重试; NetworkTransient 可进入 RetryWaiting, AuthOrRequest, DecodeOrUnsupported 与 LocalIoOrStorage 不应盲目重试, Cancelled 终止该 listener. 重试上限, 退避和网络约束必须由实测错误分布和用户等待预算决定.

5. 指标, 发布与验收

示例验收不是通用 SLO: 定义 image_display_latency 为 bind 到首个非占位像素的耗时, 按缓存层和网络分组看 P50/P95; 另看内存/磁盘命中率, 解码失败率, 取消后废弃字节, 错图率, 峰值内存和滚动帧. 灰度前记录基线, 按版本/设备档位/网络分层; 超过预先约定的崩溃, OOM 或 P95 回退阈值时关闭新策略并回滚配置. 验收证据为测试矩阵, 仪表盘链接/导出, 配置版本和 owner, 而非 “看起来更快”.

三, IM: 可靠性时序图

消息可靠性不是由 WebSocket 或推送单独保证, 而由服务端日志, 序列号, 持久化, ACK, 去重和补拉组成.

Client A -> Server: send(clientMsgId, conversationId, body)
Server -> durable message log: assign serverMsgId, sequence
Server -> Client A: accepted(serverMsgId, sequence)
Server -> Client B: deliver(message, sequence)
Client B -> local DB: transactionally persist + advance cursor
Client B -> Server: receivedAck(serverMsgId)
Client B -> UI: render from local DB
reconnect -> Server: pull(afterCursor) -> Client B: dedupe by serverMsgId, fill gaps

发送 clientMsgId 是幂等键; 接收端以 serverMsgId 去重, 以 sequence 发现缺口. 推送仅做唤醒 / 提示, 后台收到后仍以游标补拉. 多设备编辑, 撤回, 过期和已读状态必须有明确的服务端权威规则.

四, 断点续传: 恢复时序图

create task(url, target, expected ETag) -> persist PENDING
GET Range: bytes=start-end + If-Range: ETag
  -> write temp file at offset -> fsync/checkpoint range -> persist progress
network/process death -> read task + ranges -> validate ETag -> resume missing ranges
all ranges complete -> length/hash verify -> atomic rename -> COMPLETED
ETag changed / checksum mismatch -> delete temp metadata -> restart or ask user

分片数由文件大小, 网络/服务端连接限制, 随机写成本, 电量和吞吐实测决定; 不是越多越快. 必须处理无 Range 支持, 磁盘满, 权限/URI 失效, 进程死亡, 响应成功但进度尚未持久化等情形.

练习与预期证据

  1. 用自己的假设重算图片峰值 QPS 和字节流量, 写出公式, 单位和至少一个会使结论失效的条件.
  2. 为图片请求画出取消, 资源更新和进程重建分支; 预期证据是状态 owner, 持久化字段和每条转移触发条件.
  3. 为 IM 或下载器列出一个 “请求成功但响应丢失” 恢复方案; 预期证据是幂等键 / 游标或 ETag, 去重和补偿步骤.

版本与参考资料

移动端系统设计题库

章节边界:本章是逐题口述练习卡, 不重复移动端系统设计方法与完整图片示范或业务算法实现. 所有数字均为练习假设, 答题时必须根据题干, 埋点和设备测量替换, 不能背成固定参数.

学习目标与使用法

每题用 8 分钟准备, 5 分钟口述: 先补题干缺失条件, 再讲接口, 状态 / 时序, 一个算式, 失败恢复和验收. 预期证据是一页设计草图及一条可证伪的指标定义.

题目练习卡

统一异步契约: Async<T> 由调用方持有的 operation/handle 所有, 最终且恰好交付一次, 通过 Completed(T), Failed(error) 或 Cancelled 交付; cancel() 由该 owner 发起, 终止本地等待和对已离开 owner 的回调, 不承诺撤销已送达服务端的副作用. 表中 Async<T> 的 T 仅表示成功值, 错误均走 Failed(error), 取消均走 Cancelled. 各题示例可能混用 callback 与 Async<T> 两种示意: callback 贴近传统 Java/OkHttp 习惯, Async<T> 是统一抽象; 面试时能讲清「底层 callback -> 上层统一异步契约」的映射即可, 不必强求所有题目同一种签名.

题目关键接口与条件状态 / 时序示例估算 (均为假设)失败恢复与本题练习
1 图片加载bind(target, ImageRequest, callback): RequestHandle, handle 含 requestId/cancel(); callback 收 ImageResult.Success/Failure/Cancelled, wrapper 每次绑定生成 ID, 回调仅匹配当前 target, GET 可有限重试 (本卡为 callback 示意, 等价映射为 Async<ImageResult>)bind -> cache -> fetch/decode -> ready/failed/cancelled; 复用先 cancel/detach 旧 handle360×240×4=345,600 B/图; 12 张约 3.96 MiB 像素数据; 尺寸来自布局测量, 预算按低端机峰值内存, 命中率和首图 P95 调整切网, 半缓存, 错图, OOM; 固定测试集窗口内错图率 错 cacheKey 显示数 / 有结果显示的绑定数 = 0. 完整示范见 59.
2 埋点 SDKtrack(name, attrs, occurredAt): Accepted(queueId) / Rejected(reason) 仅入持久队列; flush(reason): Async<Uploaded(ackId)>, 429/磁盘/协议错误走 Failed(error), 事件 ID 去重, ACK 后删除event -> validate/redact -> dedup/sample -> durable queue -> batch/ack2,000 event/s × 500 B = 1,000,000 B/s, 约 0.95 MiB/s 原始写入; 采样率, 批量和压缩由丢失率, 队列水位, 耗电和网络实测调整磁盘满, 429, schema 冲突, 进程死; 关键事件与普通事件按可接受丢失率分级. 算法见 55.
3 断点下载enqueue(url, uri, etag): Task(id); pause/resume(id): Async<TaskState>; 成功值为 Completed(uri, checksum), RangeUnsupported/ETagChanged/Storage/Network 均走 Failed(error); 同一 taskId 的 resume 幂等PENDING->RUNNING->PAUSED/RETRYING->VERIFYING->DONE200 MiB / 4 MiB = 50 块; 块大小, 并发由吞吐, 随机写, 服务端连接限制, 电量测量调整ETag 变化, 磁盘满, URI 失效, 进程死; 画 Range, checkpoint, 原子 rename 时序.
4 IM 同步send(clientMsgId, body): Async<Accepted(serverMsgId, sequence)>, sync(afterCursor): Async<Page(messages, nextCursor)>, ack(id,type): Async<Acknowledged>; 错误走 Failed(error), clientMsgId 幂等, serverMsgId 去重send -> durable log -> accepted -> deliver -> local transaction -> ack; 缺口补拉假设 100 万 active users × 40 条/日 = 4,000 万 消息 / 日; 平均写入 4,000 万 / 86,400 ≈ 463 QPS, 峰值 463 × 15 ≈ 6,945 QPS; active users 和峰值系数由活跃埋点 / 峰值窗口调整ACK 丢失, 乱序, 重连, 重复; sequence 发现缺口. 完整时序见 59.
5 离线缓存get(key, policy): Value/Freshness/Miss; mutate(opId, baseVersion): Async<Applied(version)>; sync(cursor): Async<Page(nextCursor)>; 冲突 / 网络走 Failed(error), opId 幂等read local -> stale/fresh -> refresh; write pending -> sync -> applied/conflict10,000 × 2 KiB = 20,000 KiB, 约 19.5 MiB 实体; 索引 / 操作日志预留比例由数据库页, 离线时长和写放大实测调整过期, 冲突, 重复提交, 迁移失败; 为一种冲突定义权威端和人工介入点.
6 Crash / 日志 SDKrecordBreadcrumb(): Stored/Dropped; capture(exception): reportId 只做最小安全写; upload(reportId): Async<Uploaded>, 重复 ACK 仍为 Completed(Uploaded), 其余上传错误走 Failed(error), reportId 幂等crash -> minimal safe write -> next launch validate/dedupe -> upload环形 200 × 300 B = 60,000 B, 约 58.6 KiB; 条数 / 字段以崩溃路径耗时, 磁盘和诊断完整度测量调整损坏文件, 重复上报, mapping 不匹配; 写出 buildId 与符号化核验.
7 权限治理request(capability, rationale): Async<PermissionDecision>, 成功值为 Granted/Denied/PermanentDenied/Unavailable; resolve(): PermissionState 从持久状态和系统状态重建; 重复调用同一未决 request 不重复弹窗feature intent -> rationale -> system result -> granted/denied/permanent-denied授权漏斗需最少 曝光数,发起数,系统弹窗数,授权数, 授权率为 授权数 / 系统弹窗数; 状态规模示例为 12 capabilities × 1 current-state record/capability/host × 64 B/record × 3 hosts × 2 retained policy versions = 4,608 B, 约 4.5 KiB; 记录大小按实际序列化和保留策略测量调整旋转, 进程死, 永久拒绝, 策略变更; 持久化 capability × state × policyVersion, 并画拒绝后的降级旅程.
8 Routernavigate(route,args): Async<Navigated(destination)>; resolve(route): Resolved/NotFound, 参数, 鉴权或未注册均走 Failed(errorCode), 解析只读幂等, 鉴权中断可重试parse -> validate -> intercept -> resolve -> navigate/fallback500 × 200 B = 100,000 B, 约 97.7 KiB 元数据; 生成表与运行时注册按启动耗时, 包体和扩展频率测量未注册, 参数错, 登录取消, 旧链接; 给出可观测错误码与安全白名单.
9 短视频 Feedfeed(cursor): Async<Page(items,nextCursor)>; play(item): Async<FirstFrame>; preload(item,bytes): RequestHandle 可取消, 播放 / 解码错误走 Failed(error), 重复 cursor 请求由服务端游标语义去重page -> local DB -> visible item owns player -> preload/cancel -> release1.5 Mbps × 3 s / 8 = 0.5625 MB, 约 0.54 MiB/条; 预取深度由废弃流量, 首帧 P95, 网络类型和缓存命中测量调整decoder 失败, 切后台, 网络降级, 滚动跳过; 画 Surface/player owner 迁移. Media3 细节见 51.
10 登录刷新authorized(request): Async<Response>; refresh(refreshToken): Async<NewTokens>; logout(reason): Async<LoggedOut>; AuthRequired/InvalidGrant/Retryable 走 Failed(error), 同一 token epoch 的 401 合并为 single-flight, waiter 可取消401 -> single-flight refreshing -> waiters retry/logout; token 更新原子化峰值 100 个并发请求同时 401, 经合并应为 1 次 refresh, 最多 99 个 waiter; 合并窗口由 401 聚集度, 刷新 P95 和服务端限额调整refresh 失效, 响应丢失, 多进程竞态; 写出 waiters 取消和清理用户数据. 协议见 37.
11 设备指纹 SDKcollect(consentedSignals): Signals/ConsentMissing; evaluate(timeout): Async<Decision>, Unknown 是成功降级值, 超时 / 网络走 Failed(error); 请求带 nonce, 服务端结果按 requestId 去重但 Unknown 不重试放大consent -> collect -> normalize -> signed request/cache -> result/unknown30 × 100 B = 3,000 B, 约 2.93 KiB 原始字段上限; 字段数由授权率, 字段缺失率, 误报成本和传输开销调整缺字段, 超时, 篡改, 误报; 设计 unknown 降级, 不能宣称绝对识别.
12 配置 / 实验fetch(etag): Async<FetchResult>, 成功值为 NotModified/Config(version); 网络 / 签名错误走 Failed(error); evaluate(flag, subject): Variant/Default; rollback(version): Async<Activated>, 同版本 activate 幂等bundled default -> cached -> async fetch -> validate -> activate/versioned rollback200 × 1 KiB = 200 KiB 配置; 是否启动同步取决于关键 flag 覆盖率, TTID 和缓存命中测量签名失败, 过期, 分桶漂移, 服务不可用; 给出稳定 hash 与默认安全值.

题目 11 高频追问 (设备指纹采集 SDK)

  • 指纹熵 / 独特性评估: 如何度量采集字段的区分度与稳定性? 常用做法是算归一化熵与两两碰撞率, 并给出唯一设备识别比例; 单纯叠加字段会稀释信号质量.
  • 字段漂移稳定性: App / OS 升级后字段名或取值变化会让同一设备指纹漂移, 需要定义字段版本号与归一化映射, 并用升级前后同一设备的匹配率验证.
  • 反模拟器 / 群控 / 改机对抗: 模拟器与真机在传感器, 系统属性, 图形栈上存在差异, 采集侧可做特征校验与一致性检查; 服务端结合设备信任分数与行为信号, 不能只信单一字段.
  • Unknown 降级的误报代价量化: Unknown 是低置信度成功值而非失败, 要按业务口径估算放行后漏报与拦截后误报的代价, 用混淆 / 代价矩阵决定阈值, 不能宣称绝对识别.

追加题目: 设计一个风控决策系统 / 反欺诈引擎 (题目骨架)

  • 需求: 客户端采集信号 (设备指纹, 行为序列, 位置, 环境特征) 上报, 服务端综合规则 / 风险评分 / 模型给出 Allow / Challenge / Deny; 决策按 requestId 去重且可审计.
  • 关键 trade-off: 误报 vs 漏报成本 (拦截一个真实用户 vs 放过一笔欺诈, 用代价矩阵定阈值); 决策降级 (规则引擎或模型超时 / 不可用时按兜底阈值放行或转入人工审核).
  • 验收: 命中率 / 误报率 / 决策延迟 P95, 灰度分层上线, 事后复盘闭环 (申诉, 标签回流, 模型迭代).

统一答题检查

每题最后补: 指标定义 (分母, 窗口, 分位数), 故障注入矩阵, 灰度层级, 暂停阈值, 配置 / 协议版本和回滚 owner. 参数若没有设备, 网络, 服务端和内容数据支撑, 应明确回答 “先以此假设压测, 再按命中率, P95, 资源峰值调整”.

自测

任选两题, 限时 10 分钟各写一张卡. 合格证据: 接口有输入 / 输出边界; 状态图含至少一个异常分支; 算式带单位; 恢复方案覆盖进程死亡或响应丢失; 没有把示例数值说成生产事实.

项目经验与软技能

技术答得好, 项目讲不清照样挂. 本章只训练口头项目叙事, 个人边界和方案取舍; 证据时间线与根因复盘见项目复盘专题, 简历逐句发布门禁见简历追问防御清单.

任何结果数据都必须来自真实记录. 下文的教学假设只用于练习表达, 不能包装成候选人的实际经历.

学习目标

能在一分钟内说明自己与岗位的匹配, 并在五分钟内讲清一个项目的业务价值, 个人边界, 接口和线程决策, 方案取舍及发布控制. 事故证据, 根因和指标口径链接到 62, 简历语句真实性链接到 63.

一分钟自我介绍完整范例

“我是 [姓名], 过去主要在 [真实团队/领域] 做 Android [SDK/客户端].我的 owned scope 是 [模块], 日常涉及 [两项真实技术] 和 [一项交付责任].这段经历让我形成了先定义指标, 再定位链路, 最后灰度回滚的习惯, 例如在 [真实项目] 中我负责 [端侧动作], 通过 [真实证据] 验证 [真实结果或验证结论].我现在希望转向 [目标岗位], 因为我想把稳定性/性能/安全能力用于完整用户旅程; 为补齐 [UI/业务等真实短板], 我已经完成 [真实练习或项目].我期待先在 [明确模块类型] 交付可验证结果, 同时承担性能和稳定性问题.”

范例中的方括号必须替换为可展示事实; 它是表达结构, 不是可直接声称的经历. 录音后检查是否在 60 秒内, 是否明确说出边界, 以及是否没有把团队结果归为个人.

五分钟完整项目故事: 教学假设时间脚本

以下是明确标注的教学假设, 用于练习 “初始化链路在灰度中出现异常” 的项目叙述. [候选人替换证据], [真实值] 和 [真实项目] 必须由候选人的代码, 工单, 日志, 监控或发布记录替换; 未替换前不得陈述为真实经历.

时间口头叙述必答细节与追问分支
0:00-0:40 背景与边界“ 在这个教学假设中,[真实项目] 为宿主提供端侧风险信号. 目标是在不阻塞启动的前提下返回可用结果. 我的边界是 Android 初始化模块, 接口契约, 端侧监控和灰度开关; 服务端策略, 风险判定和运营配置由 [协作方] 负责.“追问“ 你负责到哪里?“:只说自己的模块, 交付物和协作接口; 用 [候选人替换证据:模块/PR/设计文档] 支撑.
0:40-1:25 问题与接口“ 入口是 initialize(config): InitResult. config 包含超时和开关, InitResult 只暴露 ready, deferred, failed 三类端侧状态, 并带可关联的 requestId; 错误码与服务端共同约定. 这样调用方不依赖采集细节, 失败时能走既定降级.“追问“ 接口为何不是直接抛异常?“:说明宿主启动路径需要稳定返回; 无法证明的错误码设计降级为” 参与联调 “.
1:25-2:05 线程与定位“主线程仅做配置校验和上次缓存读取; 信号采集与 I/O 进入受控后台执行器, 结果通过一次性回调返回. 教学假设中的异常先按 requestId, 版本, 设备档位和线程名分组, 再对照正常组的 trace, 确认耗时集中在 [候选人替换证据: 真实 span/日志字段].”追问 “为什么不用协程/线程池?”:只陈述真实实现; 若当时不是 owner, 应说明线程模型由谁设计, 自己验证了什么.
2:05-2:50 A/B 取舍“A 是同步收集全部信号, 完整性高, 但可能挤占启动预算; B 是同步最小信号, 其余延后, 并使用上次可信缓存, 代价是首次可能返回 deferred. 在教学假设中, 因为业务允许服务端二次判定, 所以选 B; 若真实业务不允许该状态, 则不能套用这个结论.”追问“ 如何证明 B 更合适?“:给出真实约束, 接口契约和验证记录; 具体指标, 样本与结论到 62 补证据.
2:50-3:35 实现与失败分支“我会把超时, 取消, 缓存失效和异常结果都收敛为明确状态: 超时不无限重试, 缓存不满足版本或有效期即废弃, 采集异常记录最小诊断字段并走安全降级. 调用方可通过开关禁用延后采集, 避免把未知问题扩大到宿主.”追问 “失败后怎么恢复?”:区分自动可恢复与需要人工 / 服务端处理的边界, 不能用虚构的恢复率作答.
3:35-4:20 灰度与回退“上线前先确认开关默认值, 目标版本, 灰度维度和回退 owner. 教学假设按宿主版本, 设备档位分批放量; 出现预先约定的异常条件时, 先关闭延后路径或回退版本, 再保留日志用于定位. 真实阈值, 批次和结果必须替换为候选人发布记录.”追问 “谁有权限回退?”:说明真实审批与操作边界; 不知道的流程不要补全.
4:20-5:00 结果, 反思与落点“我只陈述已验证的端侧结论:[候选人替换证据: 真实指标口径, 窗口, 样本或待验证状态].服务端策略变化带来的业务结果不归因到我. 这个案例体现的不是’我做过所有风险决策’, 而是我能把接口, 线程, 降级和发布风险讲清, 并在应用场景复用这套判断.”追问 “还有什么风险?”:说出一个真实未解决风险及 owner; 根因, 验证与预防的完整时间线见 62.

练习时先逐格填候选人证据, 再录制五分钟版本. 没有证据的格子应说 “未测量” 或 “非本人负责”, 不能用教学假设替代.

教学假设二: 崩溃治理 (crash 治理) STAR 骨架

以下是明确标注的教学假设, 用于练习 “线上崩溃率上升后的治理闭环” 项目叙述. 与假设一不同, 它覆盖 “归因 -> 最小修复 -> 验证 -> 预防” 这一常见项目类型:

时间口头叙述必答细节与追问分支
0:00-0:40 背景与边界“ 在这个教学假设中,[真实项目] 的线上崩溃率从 [真实基线] 上升, 影响 [真实功能范围]. 我的边界是端侧归因, 最小修复与灰度验证; 服务端异常聚合与业务影响归因由 [协作方] 负责.“追问“ 你负责到哪里?“: 只说归因, 修复与验证; 用 [候选人替换证据: 崩溃看板/工单] 支撑.
0:40-1:25 问题与定位“按崩溃栈, 版本, 设备档位, 系统版本和线程名聚类, 排除资源缺失与热更新等假设, 得到最小复现与一个候选根因.”追问 “为什么不是统一回滚?”: 区分 RuntimeException 与 native 崩溃的归因路径; 不能证明的假设标为未证实.
1:25-2:05 最小修复“只改归因确认的最小模块; 对在网旧版本用 [热修复 / 开关] 收敛, 新版本走正式发布.”追问 “如何保证不扩大范围?”: 给出未改范围与理由, 不能虚构热修复覆盖度.
2:05-2:50 验证与灰度“在设备矩阵和灰度窗口内, 比对崩溃率与同口径基线, 确认修复未引入新崩溃.”追问 “样本够吗?”: 说明窗口, 样本与结论; 不足则标待验证.
2:50-3:35 预防与沉淀“沉淀为崩溃监控告警, 聚合口径文档和发布前检查项, 明确维护 owner.”追问 “谁来维护?”: 说明真实角色与触发条件.
3:35-4:20 结果与反思“只陈述已验证的端侧结论; 业务影响归因到 [协作方]. 这个案例体现的是可复用的 ’ 归因 - 修复 - 验证 ’ 闭环, 而非某个具体修复技巧.”追问 “还有什么风险?”: 说出一个真实未解决风险及 owner.

STAR 口头结构

STAR 用来组织口头表达, 不替代证据:

  • S (背景): 项目服务谁, 为何要做, 有哪些真实约束.
  • T (任务): 本人负责的模块, 交付物和边界.
  • A (行动): 围绕一个难点, 讲接口, 实现, 线程或协作中的关键决策.
  • R (结果): 只报可核验的真实结果; 无数据时说明验证方法和未测量项.

结果数据的来源, 样本和时间窗不在本章展开, 使用 62 的证据时间线准备; 一条话能否写进简历, 使用 63 的发布门禁核验.

将 SDK 经历翻译为应用岗位能力

不要堆砌 JNI, Hook, APM 等名词. 每个亮点都按 “真实场景 -> 我的边界 -> 一个技术判断 -> 可验证结论 -> 可迁移能力” 讲:

真实能力口头叙述重点转应用岗位的落点
NDK/JNI/native 稳定性跨语言接口, 异常边界, ABI 或崩溃定位中的一个真实决策能下钻复杂稳定性问题, 但仍按应用层规范交付.
性能意识启动预算, 线程编排, I/O 延后或缓存中的真实取舍先定预算和验证方式, 而不是泛称 “做了异步”.
防御性安全威胁建模, 信号完整性, 开关和误报控制讲防御与可控发布, 不讲绕过步骤, 也不承诺 “完全防住”.
SDK 工程化API 稳定性, 接入文档, 日志分级, 兼容和发布边界面向调用方设计可观测, 可降级, 可回滚的能力.

转应用开发的高频追问应对

Q: 你没怎么做过 UI / 完整 App, 能胜任吗?

“UI 和上层框架确实是我之前接触少的部分, 所以最近我系统学习了 [真实学习内容], 并用 [真实练习/项目/代码仓库] 验证. 我的底层经验能帮助我处理性能和稳定性问题, 但我需要在真实业务, UI 规范和团队流程中继续补齐; 入职后会从边界明确的页面或性能任务开始交付.”

Q: 为什么不继续做风控 / SDK?

“SDK 经历让我积累了底层稳定性和工程化能力. 我希望把这些能力用于完整用户旅程, 同时继续补齐业务和 UI 交付能力. 这个选择不是否定应用开发, 也不把 SDK 能力夸大为全栈产品经验.”

Q: 这些能力和业务开发有什么关系?

“它们不能替代业务开发, 但复杂问题出现时可以缩短定位链路. 我仍会遵循团队的 UI, 架构和业务规范, 并额外在性能, 稳定性和安全风险上承担可验证的工作.”

协作与软技能

面试追问协作时, 不堆砌 “沟通能力强”, 按四个真实动作讲边界与可验证结果:

  • 与 PM 对齐需求边界: 把需求翻译成 “端侧能做到 / 做不到 / 代价” 三类可评估项, 不承诺无法验证的效果.
  • 与测试共建质量: 提供最小复现, 崩溃栈与查询口径, 把回归用例固化为自动化, 减少来回定位.
  • 与服务端对齐接口契约: 错误码, 超时, 重试与幂等写进契约文档; 前后端各改一处时先跑契约测试.
  • 接手老项目先立基线: 先补监控与发布开关, 再动代码; 用历史数据立行为基线, 改动逐项对照.

练习与预期证据

为一个真实项目分别录制 60 秒和 5 分钟版本. 预期证据: 项目背景, 个人 owner, 一个接口或线程细节, 一个取舍, 发布控制, 验证来源和一个未解决风险. 事故时间线链接到 62, 简历句子发布前链接到 63.

项目复盘专题

本章只处理复盘的证据时间线, 根因和预防, 不重复 STAR 口头故事或通用指标教程. 口头项目表达见项目经验与软技能, 简历逐句发布门禁见简历追问防御清单.

学习目标

从真实交付记录产出一份可追问的复盘: 发现, 止血, 证据排除, 根因, 修复, 验证和预防. 每一步都能区分已知事实, 数字假设, 未测量项及个人责任边界.

完整复盘演练: 虚构演练与数字假设

以下案例为虚构演练. 其中出现的时间, 比例, 版本, 影响范围都是数字假设, 只用于演示复盘写法, 绝不是候选人的实际事故或成果. 候选人需用自己的工单, 日志, 监控, 提交和发布记录替换 “候选人真实证据替换项”; 没有证据时保留 “未测量”, 不要填入虚构数字.

场景与职责边界

虚构场景: SDK vX.Y 的一个灰度批次中, 初始化返回 deferred 的比例从假设基线 0.8% 升至假设观测值 3.2%. 该场景中的缓存仍在有效期内, 且已通过版本与完整性校验, 因此是可返回的有效缓存结果; 随后串行采集的非关键慢源超时, 错误的合并顺序让 Timeout/Unknown 覆盖了这个有效缓存, 最终返回不稳定或空结果. 候选人的端侧边界是假设中的初始化状态机, 日志字段和开关; 服务端风险判定, 策略调整与业务影响归因由协作方负责.

候选人真实证据替换项: [缺陷/工单链接], [发布批次], [端侧日志查询], [个人负责模块], [协作方与边界]. 上述任何一项缺失, 都不得将本案例改写为本人经历.

证据时间线

阶段虚构演练动作数字假设候选人真实证据替换项
发现T0 从灰度看板发现 deferred 异常, 冻结查询条件并导出 requestId, 缓存校验结果, 版本, 设备档位, 慢源状态, 线程名与最终合并状态.假设灰度 10%, 异常 3.2%.[看板链接/截图编号], [查询条件], [样本范围].
止血T0+15m 由发布 owner 关闭非关键慢源采集开关, 使初始化直接返回已通过版本 / 完整性校验且仍在有效期内的缓存; 记录决策人, 影响范围和未受影响路径.假设 15 分钟完成开关生效.[开关记录], [回退 owner], [实际生效时间].
证据排除T0+50m 将异常样本按版本, 设备, 网络和线程聚类, 与正常组 trace 对照; 排除 “服务端响应慢” 和 “弱网重试放大” 两个假设.假设 80% 异常集中于低端设备的本地采集 span.[原始日志], [trace], [排除依据]; 不能证明的假设标为未证实.
根因T0+2h 确认有效缓存已通过版本 / 完整性校验且未过期; 状态机仍串行等待非关键慢源, 慢源超时生成的 Timeout/Unknown 在错误的合并顺序中覆盖了有效缓存.无真实结论; 这是虚构机制.[提交 diff], [栈/trace], [复现条件], [根因 reviewer].
修复T0+1d 在合并前确认缓存仍有效且已校验, 后台执行非关键慢源, 并规定 Timeout/Unknown 只能作为诊断状态, 不能覆盖有效缓存; 未改动服务端策略.假设最小端侧修复.[PR], [接口/线程变更], [未改范围与理由].
验证T0+2d 在设备矩阵和下一批灰度中比对相同查询口径, 检查有效缓存命中时的最终返回状态, 慢源 Timeout/Unknown 是否仅留诊断记录, 主线程 span 与错误日志; 无足够样本则结论为待验证.假设恢复至 1.0%, 仅作格式示例.[测试环境], [灰度窗口], [真实结果或待验证].
预防增加 “ 有效且已校验缓存不可被后续 Timeout/Unknown 覆盖 “ 的不变量测试, 状态分布告警, 发布前合并顺序检查项及开关回退演练.无数字假设.[测试用例], [告警规则], [发布清单], [维护 owner].

根因链与边界

根因必须写成 “证据 -> 机制 -> 违反的不变量”, 而不是 “代码有问题”.本虚构演练的链条是: trace 显示有效缓存已通过版本/完整性校验且未过期 -> 状态机仍串行等待非关键慢源 -> 慢源 Timeout/Unknown 在错误合并顺序中覆盖有效缓存 -> 启动路径返回不稳定/空结果并得到不必要的 deferred. 不变量是 “有效且已校验的缓存可作为最终结果; 后续慢源的 Timeout/Unknown 只能记录诊断, 不能覆盖它”.这只说明端侧假设机制; 不推断用户影响, 服务端效果或候选人贡献.

候选人替换时需额外写清:

  • 本人实际范围: 负责了哪个模块, 排查或修复中的哪一段; 未负责的服务端, 产品, 测试或发布操作分别由谁承担.
  • 证据强度: 日志, trace, 提交, 复现和灰度结果分别支持什么结论; 相关性不能替代因果.
  • 未测量项: 没有样本, 没有权限或无法披露的数据直接标为 “未测量 / 不可披露”, 不作估算.

修复与验证记录模板

修复目标:恢复的端侧不变量是"[真实有效且已校验缓存] 不被 [真实后续 Timeout/Unknown] 覆盖".
最小修复:[真实代码/配置变更];明确谁合并缓存与慢源结果,以及未改动 [范围],原因是 [真实边界].
验证环境:[真实设备/网络/版本];验证窗口:[真实时间窗].
对照证据:[基线查询] 与 [方案后查询];确认有效缓存命中时最终返回仍为缓存结果,Timeout/Unknown 仅作诊断;结论:[真实结论/待验证].
回退条件:[真实告警或阈值];操作 owner:[真实角色].

复盘演练二: 风控误报率 / 误伤上升 (虚构演练)

虚构场景: 风控策略灰度引入新规则后, 误报率从假设基线 X% 升至 Y%, 一段 Z 用户的正常行为被误判. 候选人端侧边界是信号采集, 日志字段与开关; 策略判定, 规则回退与用户申诉由协作方负责.

影响面量化: 假设受影响用户 Z 万, 从 T0 到开关生效 T0+20m 持续 W 小时, 误伤用户经申诉 / 重判在 T0+1h 内恢复; 具体数字必须由候选人真实记录替换.

阶段虚构演练动作数字假设候选人真实证据替换项
发现T0 从风控看板发现误报率上升, 冻结查询条件, 导出命中用户, 命中规则, 信号来源, 决策版本与接入方.假设误报率 X% -> Y%, 影响用户 Z 万.[看板链接/截图编号], [查询条件], [样本范围].
止血T0+20m 由策略 owner 关停异常规则或回退策略版本, 误伤用户走申诉 / 重判恢复; 端侧只配合补日志, 不做策略判定.假设 20 分钟生效, 用户 1 小时内恢复.[开关记录], [回退 owner], [实际恢复时长].
证据排除T0+1h 将命中样本按规则, 信号来源, 设备类型与接入方聚类, 排除信号丢失与版本升级两个假设.假设 70% 命中集中在 [具体信号 / 接入方].[原始日志], [聚类结果], [排除依据].
根因T0+3h 确认新规则对 [某类正常用户] 命中, 因 [信号字段] 的语义或阈值偏差被误判为风险; 误伤来自规则, 非服务端判定引擎缺陷.无真实结论; 这是虚构机制.[策略 diff], [命中样例], [复现条件], [根因 reviewer].
修复T0+1d 修正信号语义或阈值, 对 [受影响正常用户] 增加豁免与重判路径; 端侧只配合补日志字段, 不扩大范围.假设最小策略修复.[PR/策略变更], [未改范围与理由].
验证T0+2d 在灰度窗口比对同口径误报率与申诉量, 确认恢复且未引入新的误放.假设恢复至 X% 附近.[测试环境], [灰度窗口], [真实结果或待验证].
预防增加规则上线前的样本回放与误伤评估, 误报率告警, 申诉 / 重判演练, 明确维护 owner.无数字假设.[测试用例], [告警规则], [发布清单], [维护 owner].

根因链必须写成 “证据 -> 机制 -> 违反的不变量”: 日志显示命中集中在 [具体信号] -> 新规则把 [信号语义] 误读为风险 -> 阈值/语义偏差使正常用户被误伤 -> 误报率上升. 不变量是 “规则上线前须经过样本回放, 命中阈值需区分信号语义与判定边界”. 这只是虚构机制说明, 不推断真实影响或候选人贡献.

预防清单

复盘完成后只沉淀能够落实的预防项, 并明确维护 owner:

风险类型可落实预防需要留存的证据
状态机回归对 “有效且已校验缓存优先返回, 后续 Timeout/Unknown 不得覆盖” 的合并顺序补测试; 缓存已过期或校验失败时走另一条明确分支, 不与本案例混用.用例名, 有效缓存和无效缓存的覆盖分支, 维护人.
定位信息不足固化 requestId, 版本, 线程和状态字段字段定义, 采样边界, 查询示例.
发布不可控发布前核对开关默认值, 回退路径和决策 owner发布清单, 操作记录, 回退演练.
根因结论过度将假设, 排除项和已证实结论分栏记录复盘文档中的证据链接与 reviewer.

不要把预防清单写成 “加强测试 / 加强沟通”.每项必须能回答谁维护, 何时触发, 如何证明执行.

练习与预期证据

从一个真实缺陷单写出七段时间线, 并附每一段的日志字段, 复现条件, 修复 diff, 验证环境和预防 owner. 先完成口头讲述的, 回到 61; 准备简历表述的, 使用 63 逐句发布门禁. 预期结果是能区分 “事实”“ 假设 ““未测量”, 并说明为何没有扩大修复范围.

最大难题证据练习表单

以下方括号字段是读者练习表单, 不是对读者经历的假定答案. 只填写本人可证明的事实; 无法证明时写明范围和未知项, 不伪造事故, 职责, 数据或影响.

字段读者填写可复核证据或边界
背景[你的问题发生在哪个项目, 模块或练习中][工单, 需求, 练习说明或时间范围]
约束[你的时间, 兼容性, 资源或协作约束][约束来源和你负责的范围]
可复核现象[你实际观察到的日志, 复现步骤或行为][日志, 截图, trace 或复现记录]
候选原因[你提出但尚待验证的原因][支持证据, 反证或未知项]
采取行动[你亲自执行或参与的排查, 修复或沟通][提交, 评审, 操作记录及个人职责]
结果证据[你的真实结果或待验证状态][测试, 发布记录, 对照查询或未测量说明]
反思[下一次会保留, 改变或补充的做法][预防项, 维护人或验证计划]
追问[可能被追问的因果, 范围或替代方案][你能提供的证据与不能确认的部分]

完成条件: 只有在读者填入可核验证据, 能说明本人职责边界, 并对未知项如实标注后, “最大难题” 类开放题才可从部分完成转为完整.

简历追问防御清单

本章是逐句简历的发布门禁: 判断一句话能否写, 应降到什么熟练度, 被追问时能否提供真实细节. 口头项目叙事见项目经验与软技能, 证据时间线, 根因和预防见项目复盘专题.

学习目标

将每句简历变成可验证声明: 知道会什么, 实际做过什么, 谁负责相邻范围, 以及不能证明什么. 无法通过门禁的陈述要降级或删除, 而不是补充更大的形容词.

发布门禁: 逐句检查

对简历中每一句包含 “负责, 主导, 设计, 优化, 提升, 降低, 支持, 熟练” 的描述, 逐句走完下表; 任一必填项为空, 则不得发布原句.

门禁必填回答不通过时的动作
陈述范围这句话中的主语, 模块, 版本和个人动作分别是什么?删除无法指向的 “核心”“ 全链路 ““全面” 等词.
事实来源能提供代码, PR, 设计文档, 工单, 发布记录或可披露的学习产物吗?没有来源则删除; 不能披露具体信息则收窄到可说明的端侧动作.
个人边界本人负责什么, 服务端, 产品, 测试, 发布等相邻部分由谁负责?改成 “参与联调”“ 负责端侧落地 “等准确表达.
代码追问能讲出一个真实接口/状态, 线程或生命周期, 异常分支和测试入口吗?降低熟练度, 或移除该技术/项目表述.
反例与限制什么条件下该方案无效, 未测量或不归因于本人?在面试备注中保留限制; 不要把不确定结果写成确定收益.
发布结论该句是否有证据, 有边界, 能承受两轮追问?仅在三项均为“ 是 “ 时发布.

发布记录卡

每句简历配一张记录卡, 面试前更新:

原句:
拟发布句:
技术/项目熟练度:
真实证据位置:
个人 owned scope 与协作边界:
第一轮代码追问与真实回答:
第二轮失败/限制追问与真实回答:
降级版本:
发布结论:通过 / 降级后通过 / 删除

熟练度降级规则

等级可写的准确表达发布前必须能回答
了解“了解 X 的用途和限制”一个学习来源或片段, 以及不适用场景.
可在指导下使用“参与使用 X 完成 Y”调用路径, 参考资料和一次真实卡点.
独立交付“负责 X 模块的 Y”接口 / 状态, 测试或排障记录, 发布边界和个人责任.
能设计和治理“主导/设计 X 的端侧方案”真实替代方案, 风险控制, 灰度/回退及协作证据.

“熟悉 / 精通” 不是等级. 不能说明版本, 调用链, 失败分支或本人边界时, 向前降一级; 仍不能成立则删除. 示例:

  • “主导下载器架构” 无设计与治理证据, 降为 “参与下载模块实现, 负责 [真实子模块]”.
  • “熟练 Compose” 只有课程或练习, 降为 “了解 Compose 基础用法, 并完成 [真实练习]”.
  • “优化启动性能” 没有可归因证据, 降为 “参与 [真实链路] 排查, 并通过 [真实方式] 验证端侧行为”.

追问检查表

发布每句前, 按顺序自问并用真实材料回答:

  1. 这句中我亲自写, 改, 排查或交付的对象是什么? 关键接口参数 / 返回值或状态 owner 在哪里?
  2. 哪段工作运行在哪个线程或生命周期? 异常, 进程重启, 重复调用或取消时的真实行为是什么?
  3. 有哪个失败分支, 限制或未达到预期的地方? 我如何处理, 哪些部分不归我负责?
  4. 面试官要求证据时, 我能提供什么可披露材料或精确位置? 不能公开的内容如何说明口径而不泄露信息?
  5. 被连续追问两轮后, 原句仍准确吗? 若不准确, 立即采用记录卡里的降级版本.

逐句演练示例: 仅作格式, 不是经历

原句: 负责下载器断点续传优化.

  • 第一轮: 任务表实际保存哪些字段? 恢复时如何判断远端版本变化? 回答只能来自本人实现或真实阅读过的代码.
  • 第二轮: 磁盘满, 权限失效, ETag 变化, 响应丢失时分别如何处理? 答不出时, 将 “负责” 降为 “参与”, 或删除 “优化”.
  • 发布判断: 不要借用本示例来证明经历; 完整项目故事到 61 练习, 真实问题时间线到 62 准备.

边界声明速查

面试中可以直接使用准确边界, 但方括号必须替换为真实事实:

“我的 owned scope 是 [真实端侧范围], 我完成了 [真实动作], 能证明的是 [真实证据].服务端 [策略/接口实现] 由 [协作方] 负责; 我参与的是 [联调/错误码/灰度观察].对 [未测量或不可披露项], 我只能说明口径和限制, 不把它归为个人结果.”

通用的指标口径, 失败复盘和 owned scope 叙述不在此重复: 分别链接到 62 的证据时间线和 61 的口头项目故事. 本章只判断这些内容能否安全, 准确地写入简历.

练习与预期证据

逐句审查一段真实简历: 为每句填熟练度, 事实来源, 一个代码追问, 一个失败或限制分支和降级表述. 预期结果是删掉或收窄无证据陈述; 项目故事用 61 练习, 事故证据用 62 补全.

Vibe Coding

术语边界: Vibe Coding 不是统一标准术语. 本章采用本书工作定义: 用自然语言快速生成和迭代软件产物, 再由工程师通过运行结果, 测试, 审查和风险门禁收口. 该词由 Andrej Karpathy 在 2025 年的公开讨论带火, 其原始表述属于术语出处和个人观点, 不能替代平台事实或工程标准.

适用范围: 本章用于 AI-assisted software delivery 教学和面试表达; 不定义行业标准, 也不代表任一产品版本或能力. 核验日期与来源见文末.

本章是 AI Coding 四章的概览入口:

学习目标

完成一次从任务定义到人工审查的受控小改动, 并能指出 prompt 缺少上下文时为什么不能直接执行. 本文的流程是工作示例, 不宣称任一 AI 产品必然具有相同功能.

一, 本书如何定义 Vibe Coding

开发者用自然语言描述目标和约束, AI 生成代码, 页面, 脚本或方案, 人再根据真实反馈修改. 它强调探索速度, 但不转移质量责任. 如果没有检查需求, diff, 测试和运行结果, 就只是未受控生成, 不是可靠的软件交付流程.

二, 最小工作流

  1. 写清目标, 非目标, 平台版本, 可改范围和验收标准.
  2. 让 AI 读取真实代码和项目规则, 而不是只凭一段 prompt 猜测.
  3. 生成最小 patch, 避免顺手升级依赖或重构无关模块.
  4. 运行与改动匹配的测试, 构建, 静态检查或设备路径.
  5. 人工审查业务不变量, 生命周期, 安全, 隐私和维护成本.
  6. 记录证据和剩余风险; 不满足终止条件时回退或升级人工处理.

三, 适合和不适合的场景

适合不适合直接无人审查
原型, Demo, 重复样板, 文档草稿支付, 身份, 密钥, 隐私和发布配置
测试骨架, API 使用探索, 局部重构上下文不完整的大范围架构改造
日志和堆栈摘要, 排障假设清单生产事故中未经复现和验证的盲改

四, 端到端 walkthrough: 修复旋转后重复加载

** 练习任务 (示例, 不是某仓库的真实缺陷):** 搜索页旋转后重复发起请求. 目标是同一 query 在状态已恢复时不重复网络加载; 非目标是更换架构, 升级依赖或改导航. 验收为单元测试覆盖恢复分支, 相关 UI / 设备路径验证, diff 仅在约定范围内.

  1. ** 定义任务.** 写下 write scope (例如 feature/search), 禁改项, 当前分支 / commit, 风险 (生命周期, 缓存一致性) 和停止条件 (缺少测试环境或发现跨模块 API 变更则升级人工).
  2. ** 收集最小上下文.** 读取项目 instructions, SearchViewModel, state 数据类, Repository 接口, 现有测试和 Gradle 测试命令; 记录这些文件的版本. 不要把整个仓库, 密钥或无关 issue 放进上下文.
  3. ** 写 prompt.** 要求 agent 先概括当前状态 owner 和请求触发点, 再提出最小 patch; 禁止它臆测不存在的类, 执行写范围外动作或声称未运行的命令.
  4. ** 审阅 patch.** 检查 SavedStateHandle/持久化边界只保存 query 或可恢复参数, 而不保存网络 Job; 确认已加载 key, 取消和异常状态的迁移一致; 确认没有把 “旋转” 误当 “进程死亡”.
  5. ** 验证.** 先让新增测试在旧实现失败, 再应用 patch 并运行允许的单元测试; 有环境时补旋转 / 重建设备路径. 记录命令, 环境, 结果和未执行原因.
  6. ** 人工审查和收口.** 检查 diff, 线程 / 协程取消, 错误态, 访问控制和范围外文件; 满足验收才合入, 否则回退 patch 或转为人工排查.

上下文片段: 好与坏 prompt 对比.

坏:搜索页旋转重复请求,修好并运行所有测试.

它没有模块, 状态 owner, 非目标, 验收或权限, 容易诱发大范围猜测.

好:仅修改 feature/search.先读取 AGENTS.md,SearchViewModel,SearchUiState,
SearchRepository 和对应单元测试.目标:恢复已有 query 时不重复 load;非目标:不升级
依赖,不改导航.先说明当前触发链和你的最小方案,再给 patch.新增测试必须能在旧实现
失败.只运行 ./gradlew :feature:search:testDebugUnitTest;若命令或文件不存在,停止并报告,
不要猜测或扩大范围.报告 diff,命令结果和未验证风险.

这里的类名和命令只是示例. 真实仓库必须用实际模块, 允许命令和已验证符号替换.

五, Android 场景的质量边界

场景AI 适合做人和流水线必须兜底
ViewModel 状态机生成 runTest/Turbine 用例草稿断言是否覆盖真实业务不变量和恢复路径
ANR/Perfetto 排查摘要线程栈, 锁等待和耗时片段回到 trace, 源码和真实设备验证根因
权限适配梳理 Photo Picker, 通知, FGS 等迁移点版本矩阵, 商店政策和隐私法律复核
Gradle 报错解释依赖冲突和兼容性候选不为了过编译而盲目升级 AGP/Kotlin/AndroidX
Compose 页面生成 Preview, 状态样例和测试骨架生命周期, 重组, 无障碍, 截图和设备表现

按验证层级从单元测试到设备矩阵的分层清单, 见 Harness Engineering 的验证阶梯.

AI 产物进入 PR 前至少检查:

  • 是否引用不存在的 API, 依赖或设计系统组件.
  • 生命周期, 线程, 协程取消和进程恢复是否正确.
  • 是否新增权限, SDK, 网络域名, 敏感日志或数据出境.
  • 测试是否验证用户可见行为, 而不是只验证 mock 被调用.
  • 结论是否有模型版本, 工具版本, 命令输出或人工证据支撑.

六, 常见失败模式

  • 幻觉: 编造 API, 参数或产品能力.
  • 上下文污染: 过期文档和错误记忆覆盖当前代码事实.
  • 过度设计: 为局部问题生成难维护的抽象.
  • 弱验证: AI 自己写弱断言, 再用它证明自己的实现正确.
  • 权限扩大: 为完成任务读取秘密, 修改发布配置或执行高风险命令.

控制方式不是 “再写一个更长的 prompt”, 而是最小充分上下文, 小步 diff, 最小权限, 独立验证, 人工 gate 和可回滚提交.

七, 面试怎么答

我把 Vibe Coding 当作快速探索方式, 不是交付标准. 我会让 AI 先读项目规则和真实代码, 生成小范围 patch, 再用测试, 构建, trace, 截图或设备路径验证. 支付, 隐私, 权限, 发布和依赖升级必须人工审批. AI 提高吞吐量, 但工程师仍对需求判断, 证据质量和最终结果负责.

高频面试题

Q1: Vibe Coding 等于所有 AI-assisted programming 吗?
不等于. 业界没有统一定义. 面试时应先声明采用的工作定义, 再说明受控工程流程与 “凭感觉接受输出” 的区别.

Q2: 最大风险是什么?
不是单一 “幻觉”, 而是错误假设在缺少验证, 权限边界和人工审查时进入生产.

Q3: 如何证明 AI 真正提效?
在固定任务集上保留基线与样本再对比, 不能只展示一次成功 Demo; 具体评估指标维度详见 Loop Engineering 第六节.

练习与预期证据

选择一个低风险 bug, 写任务卡, 最小 context pack, 好 prompt 和坏 prompt 各一份. 预期证据是: 范围内 patch, 旧实现失败的新测试, 验证命令及结果, 人工审查项和剩余风险; 未运行的检查必须明确标记.

版本与参考资料

  • 最后核验: 2026-08-07.
  • 稳定性: “Vibe Coding” 的语义仍在演化; 本章定义仅用于本书教学和面试表达.
  • Andrej Karpathy 的公开讨论可作为术语出处, 不作为工程标准.
  • Simon Willison 对 vibe coding 与一般 AI-assisted programming 的区分可作为观点参考, 不作为产品事实来源.
  • 工程落地以具体模型/API/工具的官方文档, 仓库约束和团队 eval 结果为准.

Harness Engineering

术语边界: Harness Engineering 不是跨厂商统一学科定义. 本章用它指围绕 coding agent 配置 instructions, context, tools, sandbox/permissions, tests/evals, approval 和 audit 的工程系统. 方法是否有效必须由固定任务集和 CI/eval 结果证明, 不能只靠一次演示.

适用范围: 本章用于 coding-agent harness 工程实践; 本章工作定义不构成跨厂商标准, 也不代表任一客户端版本或能力. 核验日期与来源见文末.

本章聚焦 agent 的 “运行环境与护栏”. 概览见 Vibe Coding, context/MCP/安全治理见 AI Coding 工程化进阶, 反馈循环见 Loop Engineering.

学习目标

能设计一个用于阻止范围外写入, 评估小型任务并保留证据的最小 Harness 目录与配置契约草图. 文件名和 YAML 字段是团队示例, 不是所有 agent 客户端都自动支持的产品事实.

一, Harness 的核心构成

构成最小内容失败时的风险
Instructions项目规则, 架构边界, 禁止事项, 完成定义agent 按通用经验破坏本地约定
Context/State当前任务, 决策, 进度, 非显然约束跨会话遗忘或使用过期事实
Tools读写代码, 搜索, 构建, 测试, 设备或浏览器只能猜测, 或工具能力失控
Sandbox/Permissions可读写路径, 网络, 秘密和命令白名单越权读取, 破坏性写入或数据泄露
Tests/Evals固定任务, 断言, 质量指标, 回归基线Demo 成功被误当成稳定能力
Approval发布, 密钥, 权限, 依赖升级等人工门禁高风险决策无人负责
Auditprompt/context/tool call/diff/命令/结果记录结果不可复现, 事故无法追踪

AGENTS.md 与 CLAUDE.md 不能当作等价的通用标准:

  • AGENTS.md 是面向 coding agents 的开放格式, 具体客户端支持范围需查其文档.
  • CLAUDE.md 是 Claude Code 的产品约定, 其加载和作用域语义以 Anthropic 官方文档为准.
  • 团队可以从同一事实源生成不同客户端文件, 但不能假设所有工具会读取同名文件或采用相同优先级.

二, 最小 Harness 目录与配置契约草图

repo/
├── AGENTS.md                 # 项目规则,可改范围,完成定义
├── docs/architecture.md      # 架构和依赖方向
├── agent/
│   ├── task.yaml             # goal/non-goal/owner/risk/acceptance
│   ├── permissions.yaml      # 可写路径,网络,命令和审批点
│   └── eval-cases.yaml       # 固定任务集,预期结果和评分规则
└── scripts/
    ├── run-harness.sh        # 拟实现:解析策略,调用 agent,执行断言并产出结果
    ├── verify-change.sh      # 面向任务的测试/检查入口
    └── collect-evidence.sh   # 保存命令,版本,diff 和结果
<!-- AGENTS.md: team example; a future runner must read this file explicitly. -->
# Search harness rules

- Only modify paths permitted by `agent/permissions.yaml` for the current task.
- Do not read secrets, request network access, or execute commands outside the policy allowlist.
- Return a patch plus the evidence required by `task.yaml`; report blocked and unrun work instead of bypassing policy.

一个最小任务记录应包含:

goal: 修复搜索页旋转后重复请求
non_goals: 不升级依赖, 不重写导航
write_scope: [feature/search]
required_checks: [unit_test, process_recreation_test]
approval_required: [dependency_change, manifest_change]
audit: [model_version, tool_version, commands, diff, results]

若实现 run-harness.sh, 其最小输入应为 task.yaml, permissions.yaml, eval-cases.yaml, 固定 base_ref 与 agent 输出的 patch; 其最小输出应为机器可读的 assertion-results.json 和审计事件流. 该 runner 必须在应用 patch 前后分别计算 canonical path 的变更集, 执行声明的检查, 并以输出中的 passed 决定是否允许该 case 通过.

{
  "case_id": "search-state-restoration",
  "base_ref": "<pinned-commit>",
  "patch_id": "sha256:<patch-bytes>",
  "assertions": [
    {"id": "changed_paths_subset_of_allowed_paths", "required": true, "passed": true, "evidence": "changed-paths.txt"},
    {"id": "regression_test", "required": true, "passed": true, "evidence": "verify-change.log"},
    {"id": "no_dependency_or_manifest_change", "required": true, "passed": true, "evidence": "changed-paths.txt"}
  ],
  "score": 3,
  "max_score": 3,
  "passed": true,
  "unrun": [],
  "audit_event_ids": ["audit-001", "audit-002"]
}

实现后的 runner 的通过条件应固定为: 所有 required assertion 为 passed: true, unrun 为空, 没有未批准的策略事件, 并且 score == max_score. 示例字段不是 YAML 或 JSON 自带执行语义; 当前仓库未实现 runner, 策略执行, 断言或结果输出, 本节只是 “最小 Harness 目录与配置契约草图”, 不能声称 harness 可运行或已经运行.

配置文件本身不是证据. Harness 只有在 agent 确实遵守写入范围, 门禁能拦住失败, eval 能复现结果时才成立.

最小搭建步骤

  1. 将稳定规则写入 AGENTS.md: 目录职责, 允许命令, 禁止动作, 完成时必须提交的证据; 不要记录 token, 客户数据或临时任务细节.
  2. 用 permissions.yaml 声明只读默认, 可写 glob, 禁止网络/秘密, 需要审批的删除/依赖/发布动作. 执行器必须实际解析或人工执行该策略, 否则它只是文档.
  3. 选 3 至 5 个有黄金答案的低风险任务, 写入 eval-cases.yaml: 初始 commit, 任务, 允许文件, 断言, 禁止修改和评分规则.
  4. 在实现 runner, 策略执行, 断言和结果输出后, 才可用干净工作区运行每个 case, 并保存 prompt/context 摘要, 工具调用, diff, 命令, 退出码和人工 review 结果.
  5. 只在固定任务, 模型 / 工具版本和预算不变时比较 harness 变更前后; 出现越权, 回归或证据缺失时回退规则.

配置契约片段, 需由尚未实现的 runner 执行:

# agent/permissions.yaml
default: deny
precedence: deny_overrides_allow
read_scope:
  - AGENTS.md
  - docs/**
  - feature/search/**
write_allow:
  - feature/search/**
deny:
  - .github/workflows/**
  - gradle/libs.versions.toml
  - "**/*.keystore"
  - "**/.env"
  - "**/.env.*"
  - "**/credentials/**"
  - "**/*credential*"
  - "**/*secret*"
command_allowlist:
  - "./scripts/verify-change.sh"
  - "./gradlew :feature:search:testDebugUnitTest"
network: deny
approval_required: [delete, dependency_change, network, release]
audit_events: [policy_evaluated, path_resolved, command_requested, approval_requested, approval_granted, assertion_completed]

策略解析顺序应为: 先把请求路径相对仓库根目录 canonicalize, 再拒绝逃出根目录, 包含符号链接跳转或命中 deny 的路径; 之后才匹配 read_scope 或 write_allow. deny_overrides_allow 在所有冲突中优先. 命令必须以结构化 argv 与 allowlist 精确匹配, 不能接受任意 shell 拼接; 网络默认拒绝, 只有批准事件才能临时开启. 每次拒绝, 路径解析, 命令请求, 审批及断言完成都写入审计事件, 且不记录秘密原文.

# agent/eval-cases.yaml
- id: search-state-restoration
  task: Restore a saved query without duplicate network load
  base_ref: <pinned-commit>
  allowed_paths: [feature/search/**]
  required_checks: [":feature:search:testDebugUnitTest"]
  assertions:
    - old implementation fails the new regression test
    - changed_paths_subset_of_allowed_paths
    - no dependency_or_manifest_change

这些断言中的 changed_paths_subset_of_allowed_paths 代表 runner/CI 需要实现的检查, 不是 YAML 自带语义.

三, Instructions 与状态连续性

Instructions 应写稳定且可执行的规则, 例如先读哪些文档, 依赖方向, 禁止引入的库, 允许的验证命令和完成前必须提交的证据. 不要把临时任务细节无限堆入全局说明.

跨会话状态只记录:

  • 已确认的决策和理由.
  • 当前进度, 阻塞和 owner.
  • 已运行检查的命令, 环境和结果.
  • 无法从仓库推导的约束.

能从代码或 Git 推导的信息应按需重新读取, 避免 “记忆” 成为过期事实源.

四, 权限, 审批与审计

默认采用最小权限:

  1. 先只读探索, 再开放必要写入路径.
  2. 网络, 秘密, 生产数据和发布凭据默认不可用.
  3. 删除, 发布, 权限变更, 依赖升级和安全策略修改需要人工批准.
  4. 工具输入要校验; 不可信仓库内容和网页可能包含 prompt injection.
  5. 审计记录至少包括模型 / 工具版本, 输入来源, tool calls, diff, 验证结果和批准者.

多 agent 的 Planner/Builder/Reviewer/Verifier 分工只有在上下文隔离, owner 清楚且验证独立时才有价值; 多角色共享同一错误不构成独立证据的边界, 详见 Loop Engineering 第七节.

五, 用 Eval/CI 证明方法有效

维护一组代表真实工作的固定任务, 按固定指标集对比 harness 变更前后; 指标维度与示例详见 Loop Engineering 第六节.

比较 harness 变更前后必须固定任务集, 代码基线, 模型/工具版本和最大预算. CI 应保存失败样本, 而不是只汇报平均分. 指标下降时应回滚 instructions/tool/permission 变更.

六, Android Harness 的验证阶梯

  1. 纯逻辑: 单元测试和边界用例.
  2. ViewModel/Flow: runTest, test dispatcher, fake repository, 状态恢复.
  3. UI: Compose/Espresso, screenshot, 字号, 深色模式, 无障碍和键盘路径.
  4. 性能: Macrobenchmark/Perfetto/Profiler 的前后基线.
  5. 权限/发布/隐私: 多版本设备矩阵, 商店政策和法务复核.

每项都应记录 “运行了什么, 在哪个环境, 结果是什么, 哪些没运行及原因”. 各层中 AI 适合做什么, 人 / 流水线必须兜底什么, 见 Vibe Coding 的质量边界.

七, 面试怎么答

面试官问「你配过什么 harness」→ 我讲实际配置过的事实: 一份 instructions 规则 (AGENTS.md/CLAUDE.md 的目录职责, 禁止事项, 完成定义), 一份权限策略 (默认只读, 可写 glob, 命令白名单, 网络默认拒绝), 和一组固定任务 eval; 没有实现 runner 的部分我会说成「配置契约草图」, 不夸大成已运行的产出. 面试官问「CLAUDE.md 怎么落地」→ 它是 Claude Code 的产品约定, 加载和作用域语义以官方文档为准, 不假设其它客户端读同名文件; 落地只写稳定规则, 不把临时任务细节堆进全局说明. 面试官问「权限与验证怎么控制」→ 高风险动作人工审批, 校验走闭环: eval 在干净工作区跑固定任务, 记录模型/工具版本与命令结果, 只在基线一致时比较配置变更前后; 无法执行的检查必须标记, 不让配置文件冒充证据.

高频面试题

Q1: Harness Engineering 和 Prompt Engineering 的区别?
Prompt 解决单次表达; harness 约束整个运行环境, 工具权限, 状态, eval, 审批和审计.

Q2: 为什么不能只看编译通过?
编译不能证明需求语义, 恢复路径, 安全边界, 性能和设备行为正确.

Q3: 怎样避免 agent 越改越多?
明确 non-goals 和 write scope, 默认只读, 小步 diff, 范围外修改自动失败, 高风险动作人工批准.

练习与预期证据

为一个真实小模块创建一份 instructions 配置, 一个权限规则和两个 eval case (修复与拒绝越权各一条).预期结果: runner 或 reviewer 能报告越权路径, 缺失验证与审批事件; 若没有执行器, 明确记录其为待接入的治理文档.

版本与参考资料

AI Coding 工程化进阶

术语边界: 本章把 Context Engineering 定义为 “选择, 组织, 更新和裁剪模型完成任务所需的最小充分上下文”. 这是本文使用的工程定义, 不是唯一行业标准. 本章聚焦上下文, 工具协议, 隐私, 供应链, 审查与回退; harness 配置见 65, 反馈循环见 67.

适用范围: 本章用于 context, tool calling, MCP 与治理工程实践; 本章工作定义不构成行业标准, 也不代表协议或产品的当前能力. 核验日期与来源见文末.

学习目标

能为 Android 小任务打包可追溯上下文, 审查 AI 生成测试, 并记录一次模型 / 工具执行的可回放证据. 本文 schema 是示意, MCP 和具体 tool calling 的字段, 能力与认证方式须以接入时官方规范和客户端文档为准.

一, 从 Prompt 到 Context

Prompt 主要描述本轮目标和输出; Context Engineering 还管理真实代码, 架构规则, 依赖版本, 历史决策, 失败证据和工具结果. 好上下文不是越多越好, 而是:

  • 与当前决策直接相关.
  • 有来源, 时间和版本.
  • 能区分事实, 推断和用户偏好.
  • 过期后可失效或重新检索.
  • 不包含任务不需要的秘密和个人数据.

对 Android 任务, context pack 通常包括目标模块, 相邻实现, Gradle 版本矩阵, min/target SDK, UI/架构约定, 可运行命令和设备条件.

Android context pack 范例

task: Fix duplicate refresh after process recreation
facts:
  repository_commit: <commit>
  module: feature/orders
  sdk: { min: <actual-minSdk>, target: <actual-targetSdk> }
  architecture: UI observes Room; network writes through repository
sources:
  - path: AGENTS.md
  - path: feature/orders/OrderViewModel.kt
  - path: feature/orders/OrderRepository.kt
  - path: feature/orders/OrderViewModelTest.kt
constraints:
  write_scope: [feature/orders/**]
  non_goals: [dependency upgrade, database schema change]
  required_checks: [":feature:orders:testDebugUnitTest"]
unknowns: [whether device recreation test environment is available]

facts 必须有文件 / commit 来源; unknowns 不能由模型补成事实. 敏感日志, 访问令牌, 完整生产数据不进入 pack.

二, Agentic Coding 的能力边界

Agentic coding 指模型通过工具读取, 搜索, 编辑, 运行命令并根据反馈迭代. 自主性越高, 越需要:

  • 明确可写范围和非目标.
  • 破坏性动作和高风险领域的人工审批.
  • 超时, 重试上限, 成本预算和终止条件.
  • 可重放的工具结果与审计日志.
  • 独立测试或 reviewer, 不能由同一输出自证正确.

三, MCP 与 Tool Calling 不是同一概念

  • Model Context Protocol (MCP) 是开放协议, 用于客户端与 server 之间发现和交换能力. Server 可暴露 tools, resources 和 prompts; 支持哪些能力取决于协议版本和具体实现.
  • Tool calling/function calling 是模型或 API 产品让模型选择并参数化调用工具的能力, schema, 执行方式和安全语义由具体产品定义.
  • MCP 可以成为 tool calling 的能力来源之一, 但两者不能合称为同一个 “标准工具接口”.

接入 MCP/server 或任何工具系统时必须处理:

  1. 用户同意: 让用户知道将读取或发送什么数据, 会执行什么动作.
  2. 最小权限: 限制 server, tool, 资源范围, 文件路径, 网络和凭据.
  3. Server trust: 校验来源, 维护者, 传输, 更新渠道和供应链风险.
  4. 输入验证: 参数 schema, 路径, URL, 命令和返回内容都视为不可信输入.
  5. 审计: 记录协议/客户端/server 版本, 调用参数摘要, 结果和批准事件.
  6. 版本协商: 不能假设客户端与 server 永远支持相同协议版本和可选能力.
  7. Prompt injection 防护: 工具返回, issue, 文档和网页内容不能自动升级为高优先级指令.

Tool schema 的最小审查点

下面是示意 JSON Schema, 不代表 MCP 或任一 API 的必需字段. 它展示了路径应被约束在已批准根目录, 而不是将任意 shell 文本直接交给模型执行:

{
  "name": "read_source",
  "description": "Read a UTF-8 source file under an approved repository root",
  "input_schema": {
    "type": "object",
    "properties": {
      "path": {
        "type": "string",
        "minLength": 19,
        "maxLength": 240,
        "pattern": "^feature/orders/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\\.kt$"
      },
      "start_line": { "type": "integer", "minimum": 1 },
      "end_line": { "type": "integer", "minimum": 1, "maximum": 2000 },
      "max_bytes": { "type": "integer", "minimum": 1, "maximum": 262144 }
    },
    "required": ["path"],
    "additionalProperties": false
  }
}

该模式由允许的路径段组成: 目录段只能是 [A-Za-z0-9_-]+, 文件名也只能由该集合组成后接 .kt, 因此根目录后的首段, 任意中间段及末段均不可能是完整的 . 或 ... 正例: feature/orders/OrderViewModel.kt, feature/orders/ui/Order_List.kt; 反例: feature/orders/../Secrets.kt, feature/orders/ui/../../Secrets.kt, feature/orders/./OrderViewModel.kt. additionalProperties: false 防止未声明参数混入; max_bytes 是调用方可要求的上限, 服务端仍须以实际文件大小强制限制. 普通 JSON Schema 无法以可移植的方式表达 end_line >= start_line 这类跨字段比较, 因此 runner 必须在解析后显式拒绝反向范围. 运行时仍必须做 canonical-path 校验, 符号链接防护, 大小限制, 审计与权限判断; schema 匹配不等于安全授权.

四, 测试生成与代码审查

AI 可生成测试骨架, mock 数据和边界清单, 但有效闭环应是:

需求不变量
  -> 人工确认预期
  -> 测试先在错误实现上失败
  -> 最小实现
  -> 运行相关测试和静态检查
  -> 独立审查断言,diff 和剩余风险

重点防止只验证 mock 调用, 用实现细节复制生产逻辑, 删除失败断言, 或为了过测试改变业务语义.

测试生成前后审查对照

阶段审查问题通过证据
生成前用户不变量是什么? 旧实现为何应失败?用例名称, 状态转移, 反例.
生成后测试是否只断言 mock 调用? 是否与生产代码复制同一分支?将旧实现运行为红; 断言可观察状态 / 副作用.
修复后覆盖旋转, 取消, 异常或进程恢复了吗?对应 fake/dispatcher/恢复测试及环境说明.
Review测试是否因实现而被放宽?独立 reviewer 的 diff 和断言审查记录.

弱测试草稿与人工修订版

下列 Kotlin 对照是示意: 需求不变量为 “ 重建后恢复的已有订单不额外刷新; 一次失败后重试从 Error 恢复为 Content“.草稿只验证 mock 调用, 错误实现即使重建时额外刷新或把状态永久留在 Loading 也可能通过; 修订版分别验证恢复和失败重试, 能拦截这两类错误实现.

// AI 弱测试草稿:实现细节断言,不能证明 UI 可恢复.
@Test fun refresh_calls_repository_once() = runTest {
    val repository = mockk<OrderRepository>()
    coEvery { repository.refresh() } returns Unit
    val viewModel = OrderViewModel(repository)

    viewModel.refresh()

    coVerify(exactly = 1) { repository.refresh() }
}

// 人工修订版:重建时错误调用 refresh() 会使调用计数断言失败.
@Test fun recreated_view_model_with_restored_orders_does_not_refresh_again() = runTest {
    val restoredOrders = listOf(order)
    val repository = FakeOrderRepository()
    val recreated = OrderViewModel(
        repository = repository,
        savedState = OrderSavedState(orders = restoredOrders)
    )

    advanceUntilIdle()

    assertEquals(OrderUiState.Content(restoredOrders), recreated.uiState.value)
    assertEquals(0, repository.refreshAttempts)
}

// 人工修订版:错误实现若在失败后永久保留 Error 或 Loading,会在状态断言失败.
@Test fun retry_after_failure_transitions_from_error_to_content() = runTest {
    val repository = FakeOrderRepository(
        results = listOf(Result.failure(IOException()), Result.success(listOf(order)))
    )
    val viewModel = OrderViewModel(repository)

    viewModel.refresh()
    assertEquals(OrderUiState.Error, viewModel.uiState.value)

    viewModel.retry()
    assertEquals(OrderUiState.Content(listOf(order)), viewModel.uiState.value)
    assertEquals(2, repository.refreshAttempts)
}

五, 模型与工具版本治理

每次重要结果至少记录:

  • 模型名称 / 版本或快照, 推理配置和最大预算.
  • 客户端, agent, MCP server 和关键插件版本.
  • 仓库 commit, 依赖锁文件和运行环境.
  • 输入数据来源, 权限范围和人工批准.
  • 命令, 结果, diff, 回退点和剩余风险.

模型或工具升级应像依赖升级一样经过代表性 eval 和分阶段 rollout. 不要用 “同一品牌模型” 推断行为不变.

可回放记录范例

run_id: 2026-08-07-search-recovery-01
repository: <commit>; dirty_worktree: false
model/client/tool-server: <exact identifiers and versions>
context_manifest: <paths plus content hashes>
permission_policy: <version/hash>; approvals: <none or approver>
commands: <literal command, environment, exit status>
diff: <commit or patch hash>; evaluation: <case ids and results>
residual_risk: <unrun device path, known limitation>

这记录的是治理要求, 不应伪装为本章已经执行过的输出.

六, 隐私, 安全与供应链

风险控制
token, 客户数据或日志外泄数据分类, 脱敏, 最小上下文, 保留策略和区域 / 合同复核
生成不安全代码SAST/secret scan, 安全 review, 高风险模块人工 owner
虚构或投毒依赖允许列表, lockfile, SBOM, 签名 / 来源和漏洞扫描
工具或 MCP server 被替换固定版本, 校验发布渠道, 最小权限和隔离运行
许可证 / 来源不明代码来源与许可证审查, 不能把生成等同于无版权风险
自动发布或改密钥环境隔离, 短期凭据, 双人审批和可撤销操作

隐私和商店政策具有地区与日期属性, 必须由安全/法务/发布 owner 按实际部署复核.

七, 回退与降级

可靠 AI Coding 流程必须允许:

  • 回退 agent 生成的 commit 或配置变更.
  • 在模型 / API 不可用时切换人工流程.
  • 关闭有问题的工具/server/自动审批.
  • 保留上一个已通过 eval 的模型和 harness 版本.
  • 对连续失败, 不确定安全影响或超预算任务主动停止.

八, 面试怎么答

面试官问「MCP 和 tool calling 有什么区别」→ MCP 是客户端与 server 交换 tools/resources/prompts 的开放协议, tool calling 是具体模型/API 发起结构化调用的产品能力, 二者可结合但不是同一标准; 接入时按协议版本与客户端文档核验, 不凭记忆断言能力. 面试官问「AI 生成测试怎么审查」→ 先确认业务不变量, 要求测试在旧实现上先失败 (红), 再检查断言是否验证可观察状态而非 mock 被调用, 防止测试复制生产分支或被实现放宽; 审查记录留 diff 与断言证据. 面试官问「模型/工具版本怎么治理」→ 每次重要结果记录模型/客户端/server 版本, commit, 命令结果与批准事件; 升级像依赖升级一样先过代表性 eval 再分阶段 rollout, 保留上一个已通过 eval 的版本作回退, 用可回放记录支撑结论.

高频面试题

Q1: MCP 和 tool calling 的区别?
MCP 是客户端与 server 交换 tools/resources/prompts 等能力的开放协议; tool calling 是具体模型/API 发起结构化工具调用的产品能力. 它们可以结合, 但不是同一个标准.

Q2: Context Engineering 为什么不是塞入整个仓库?
无关或过期内容会增加成本和错误锚定, 秘密还会扩大泄露面. 应按任务检索最小充分且可追溯的上下文.

Q3: 如何上线 agent 工具升级?
固定 eval 集和基线, 记录版本, 比较正确率/越权率/成本, 小流量试用, 保留回退版本, 高风险能力重新审批.

练习与预期证据

为一个 ViewModel bug 创建 context pack, 受限 read tool schema 和测试审查表. 预期证据: 每个事实有来源, 每条未知项显式保留, 旧实现失败的测试记录, 以及模型/工具/命令/diff/风险的版本记录.

版本与参考资料

Loop Engineering 与前沿 Vibe Coding

本书工作定义: 本文把可观察, 可验证, 可终止的 agent feedback loop 称为 Loop Engineering. 该名称不是统一官方学科分类; 可靠性来自真实反馈, 独立验证和明确终止条件, 不是 “循环调用模型直到看起来能跑”.

适用范围: 本章用于 agent feedback loop 教学和面试表达; 本章 taxonomy 与工作定义不构成行业标准, 也不代表任一产品版本或能力. 核验日期与来源见文末.

本章承接 Vibe Coding 概览, Harness Engineering 和 AI Coding 工程化进阶, 聚焦任务如何在多轮执行中收敛.

学习目标

能让每轮 agent 操作产生新证据, 将失败归入可行动类别, 并在 ANR 等高风险问题上停止猜测, 回到 trace 和独立验证. 下文 loop taxonomy 与案例都是本书工作材料, 不是产品能力声明.

一, Loop 的最小状态机

Goal + Constraints + Acceptance
  -> Retrieve current context
  -> Plan minimal change
  -> Execute with scoped tools
  -> Observe tests/build/trace/device/review
  -> Classify failure
  -> Fix one cause or escalate
  -> Accept, rollback, or stop

每轮至少记录输入版本, 执行动作, 观察结果, 失败分类和下一步. 没有新证据时重复同一 prompt 不算有效 loop.

二, 可观察, 可验证, 可终止

  • 可观察: 能看到 tool calls, diff, 命令输出, trace, 截图, 设备矩阵和成本.
  • 可验证: 验收标准可执行; 验证者与实现输出尽量独立; 失败能复现.
  • 可终止: 有最大轮次/预算/时间, 连续失败升级条件, 高风险人工 gate 和回退点.

常见终止条件:

  1. 验收标准满足且相关检查通过.
  2. diff 没有范围外修改, 高风险项已批准.
  3. 证据包含环境, 版本, 命令和结果.
  4. 剩余风险已记录并有 owner.
  5. 若证据冲突, 工具不可信或连续失败, 停止自动循环而不是扩大权限.

三, 失败分类表

分类典型信号首个动作不该做什么升级条件
需求不清验收冲突, owner 不同意澄清不变量和非目标用实现猜产品意图无法得到决策 owner
上下文缺失/过期文件/版本与回答冲突重新读取源和 commit用记忆补全 API关键事实仍不可得
实现缺陷可复现失败, diff 指向单处最小修复和回归测试顺带重构 / 升级依赖修复触及跨模块契约
测试 / 环境缺陷测试不稳定, 设备不可用保存原始输出, 隔离环境把失败测试删掉无法区分产品与环境
权限 / 安全阻断需秘密, 网络, 发布权限停止并请求批准扩大权限或绕过门禁高风险操作被拒绝
性能 / ANR 不确定trace 不足, 噪声大收集 trace, 基线, 设备条件靠代码阅读定根因多轮无新证据或影响扩大

每轮在失败分类改变前不重复相同 prompt; 连续两轮没有新增证据时停止, 重新澄清或转人工.

四, Android 常见反馈循环

状态恢复 Loop

业务不变量 -> 失败测试 -> 最小实现
-> STOPPED/旋转/进程重建路径 -> 独立 review -> 收敛或回退

ANR / 性能 Loop

线上指标或可复现问题 -> trace/frame timeline
-> 提出可证伪假设 -> 最小改动 -> Macrobenchmark/设备复测
-> 对比基线, 环境和噪声 -> 决定保留或回退

完整 ANR loop 案例

** 案例为演练, 不是线上事故记录.** 症状: 特定设备上的输入 ANR 聚类. 首先冻结原始证据: 应用/系统版本, ANR trace, 发生前 breadcrumb, CPU 负载, 主线程和 Binder 对端栈. 假设 A 是主线程磁盘 I/O, 假设 B 是 Binder 调用等待; 二者都必须从 trace 证伪或支持.

T0: APM 聚类 -> 保存 trace 与筛选条件
T1: 主线程 stack 显示 wait? -> 同时查看锁 owner/Binder 对端/CPU
T2: 最小复现或可控注入 -> trace 对比基线
T3: 若确认同步 I/O -> 移出主线程,保留取消/超时/结果回投边界
T4: 加入 regression instrumentation/trace marker -> 设备复测 -> 灰度
T5: 观察 ANR 分母,版本/机型分层和新回归 -> 保留,回退或升级人工

若 trace 显示主线程在等待远端 Binder, 不能仅把调用移动到后台就宣称解决, 仍要检查调用是否必须同步, 对端为何阻塞, 超时 / 降级是否符合业务. 验证以同条件 trace, 响应性和 ANR 指标口径为准; 没有线上样本时只写 “实验验证”, 不写线上收益.

Gradle / 依赖 Loop

保存原始失败 -> 核对 AGP/Gradle/JDK/Kotlin/KSP 兼容矩阵
-> 最小配置改动 -> 运行目标 task -> 检查依赖图与范围外升级

UI 设备在环 Loop

patch -> Preview/UI test/screenshot -> 真机或模拟器
-> 系统栏/键盘/深色/字号/无障碍/性能 -> 反馈具体差异 -> 收敛

targetSdk / 隐私 Loop

列出行为变化与地区要求 -> 扫描受影响路径 -> 分模块改造
-> 多版本设备矩阵/隐私复核 -> 灰度指标 -> 回滚预案

五, 前沿话题地图

以下九项是本文用于教学的 taxonomy, 不是统一官方分类. 各产品能力与可用区域变化较快, 必须按日期和官方文档核验.

  1. 异步 coding agents: 后台读取 issue/spec, 修改代码并产出 PR.
  2. 多 agent 分工: Planner/Builder/Reviewer/Verifier, 但需要独立证据和清晰 owner.
  3. Repository instructions: 把项目规则放入可版本化的仓库文件.
  4. Context retrieval: 按任务检索代码, 文档, issue 和运行证据.
  5. MCP 与工具生态: 通过开放协议暴露 tools/resources/prompts, 同时治理 server trust.
  6. Evals 与 benchmark: 固定任务集衡量正确率, 越权率, 成本和回归.
  7. 设备 / 浏览器在环: 用真实 UI, 日志, trace 和截图作为反馈.
  8. Human-in-the-loop: 在支付, 隐私, 发布, 依赖和架构变更处审批.
  9. Provenance 与审计: 记录模型 / 工具版本, 数据来源, diff, 命令和批准事件.

六, 如何评估 Agent Loop

不要只看 “是否最终生成 patch”. 建议指标:

维度示例指标
正确性固定任务通过率, 缺陷逃逸率, 恢复路径覆盖率
约束遵守越权文件修改率, 未批准工具调用率
收敛平均轮次, 重复失败率, 人工升级率
质量review 返工, 弱测试比例, 安全问题数
效率wall-clock, token / 计算成本, 人工介入时间
可恢复回退成功率, 审计完整率, 失败可复现率

评估应固定仓库基线, 任务集, 模型/工具版本和预算. OpenAI Evals 是评估框架/方法资料之一; SWE-bench 是独立的真实软件工程 benchmark 项目. 两者不能写成同一个产品或标准.

七, 多 agent 产出示例

下面复用上文 “特定设备输入 ANR 聚类” 的同一演练. 四个角色共享 task_id: anr-input-2026-08-07-01, base_ref: <pinned-commit>, 而不是共享结论; trace-17, patch-8f31 等均为最小示例 ID, 不是已执行的生产记录.

Planner
  task_id: anr-input-2026-08-07-01
  evidence: trace-17 shows main thread waiting during input; device matrix device-A/API-34
  hypothesis: synchronous repository lookup is on the input path
  non_goals: no dependency upgrade; no Binder timeout change without trace evidence
  acceptance: same-condition trace removes lookup from main-thread input path; cancellation remains safe

Builder
  task_id: anr-input-2026-08-07-01
  patch_id: patch-8f31 (commit: <not-created>)
  change: move repository lookup behind existing dispatcher; preserve cancellation and result handoff
  evidence: diff patch-8f31; command record command-22
  unexecuted: device-A trace capture and Macrobenchmark were not run

Verifier
  task_id: anr-input-2026-08-07-01
  independence: starts from base_ref plus patch-8f31; does not accept Builder's command result
  evidence: independent trace attempt trace-18 still shows a synchronous Binder wait on input path
  finding: Builder hypothesis is not proven; moving lookup did not establish the Binder dependency is absent
  decision: rework
  unexecuted: production rollout metrics unavailable in this exercise

Reviewer
  task_id: anr-input-2026-08-07-01
  evidence: compares trace-17, trace-18, patch-8f31, and cancellation invariant
  disagreement: accepts Verifier's rejection; disagrees with Builder's implied sufficiency claim
  decision: rework; require Binder peer/lock-owner evidence before changing timeout or accepting patch
  unexecuted: no release approval and no production experiment

四份产出必须引用同一 task ID, 并明确 patch 或 commit ID, 证据, 分歧和未执行项. Verifier 可以独立否定 Builder; Reviewer 的 accept/rework 决策应基于证据冲突, 而不是角色投票. 若 Verifier 使用同一 mock, 同一错误 context 或无实际检查, 多角色不构成独立证据.

八, 产品趋势的陈述边界

截至 2026-08-07, GitHub Copilot coding agent, Google Jules 和 OpenAI Codex 可作为异步或 agentic coding 产品方向的例子; 具体能力, 运行环境, 权限, 计划, 地区和计费均可能随版本变化. 面试中应按引用日期陈述各自官方文档确认的信息, 不将产品描述外推为行业标准或绝对能力保证.

  • GitHub Copilot coding agent: 以 GitHub 官方文档和 changelog 为准.
  • Google Jules: 以 Google 官方 Jules 文档和公告为准.
  • OpenAI Codex: 以 OpenAI 官方 Codex 文档为准.
  • Model Context Protocol (MCP): 以官方规范和仓库为准. 可说明其最初由 Anthropic 推出, 现按开放项目和正式治理信息理解, 不称为 “Anthropic 私有标准”.

九, 观点来源与事实来源分开

  • Andrej Karpathy 和 Simon Willison 的公开讨论可用于解释 Vibe Coding 的术语出处和个人观点.
  • 产品能力, 协议语义, 稳定性和安全要求应引用对应官方文档 / 规范.
  • 团队方法是否有效应引用自己的 eval/CI 数据, 任务集和失败样本.
  • 观点影响力不能替代版本化产品事实, 单次案例也不能替代统计证据.

十, 面试怎么答

面试官问「你怎么保证 AI 持续迭代不失控」→ 我给 loop 设可观察, 可验证, 可终止三条边界: 每轮工具调用, diff 和命令输出可见, 验收标准可执行且验证尽量独立, 终止条件含最大轮次/预算/连续失败升级, 证据冲突或工具不可信时停止而不是扩大权限. 面试官问「怎么区分 AI 完成任务 vs 你负责任务」→ AI 完成任务指产出 patch 并通过检查; 我负责任务指需求判断, 验收定义, 证据质量和终止决定, 连续两轮无新证据时由我重新澄清或回退. 面试官问「多 agent 演练的价值与局限」→ 价值在 Planner/Builder/Verifier/Reviewer 上下文隔离, owner 清晰且验证独立时, 能发现单一角色的自证问题; 局限是共享同一 mock 或错误 context 时多角色只是互相确认, 不构成独立证据, 所以 Verifier 要能基于证据冲突否定 Builder, 而不是角色投票.

高频面试题

Q1: Loop Engineering 与 Prompt Engineering 的区别?
Prompt 关注单次表达; Loop 关注多轮状态, 工具执行, 真实反馈, 失败归因, 审批和终止.

Q2: 多 agent 一定更可靠吗?
不一定. 如果共享同一错误上下文或互相确认结论, 只会放大错误. 需要上下文隔离, 独立验证和清晰 owner.

Q3: 怎样防止 loop 越改越乱?
限制写入范围和预算, 每轮只处理一个已分类失败, 对范围外 diff 自动失败, 连续失败停止并重新澄清或交给人工.

练习与预期证据

为一个 ANR 演练填写失败分类, 每轮新证据, 假设, 停止条件和验证计划; 再为同一任务写 Planner/Builder/Verifier/Reviewer 四份最小产出. 合格证据是每一轮都能引用 trace, 命令, diff 或人工决策, 而不是重复模型结论.

版本与参考资料

时效性清单

维护用途: 全书把易变结论标注 “最后核验” 日期, 但没有集中清单时回访依赖记忆. 本页集中记录各章核验日期与回访触发器. 修改任何易变结论时, 同步更新正文日期与下表; 新增章节时按此格式补一行.

回访触发器 (命中任一即重核对应章节)

触发器说明影响章节
Android 18 / API 38 正式版预计 2027-06 发布, 行为变更与新权限11, 19, 25, 27, 36, 39
Google Play target API 强制线变化API 36 自 2026-08-31 强制, 后续线会再抬11, 35
AGP / Gradle 主版本稳定AGP 9.x 稳定版, androidLibrary {} DSL 演进32, 33, 34, 35, 47
Kotlin 新稳定版expect/actual classes, context parameters 转 stable03, 47
Compose BOM / Compose Multiplatform 稳定版API 与编译器规则变化09, 10, 48
16 KB page 强制范围变化影响 native 库对齐与打包11, 44, 45
法规或商店政策变更隐私, 权限, 广告标识与地区要求36, 37, 39
第三方库主版本Glide / OkHttp / Room / WorkManager / Media312, 13, 29, 31, 51

各章最后核验日期

章节最后核验
02 Kotlin 语言核心2026-08-07
05 RxJava 与响应式编程2026-08-07
06 Java 与 JVM 基础2026-08-08
11 Android 版本适配2026-08-10
12 图片加载与缓存2026-08-07
23 启动优化专项2026-08-07
24 内存优化与泄漏排查2026-08-07
25 ANR 与卡顿排查2026-08-07
28 存储体系与 Scoped Storage2026-08-07
34 Gradle 构建性能专题2026-08-07
35 CI/CD 与发布体系2026-08-07
36 隐私合规与权限治理2026-08-07
39 推送 / 长连接 / 保活2026-08-10
42 Android 安全与逆向2026-08-07
47 Kotlin Multiplatform2026-08-07
51 音视频 / Media32026-08-07
52 LeetCode 算法清单2026-08-06
59 移动端系统设计方法论2026-08-07
64 Vibe Coding2026-08-07
65 Harness Engineering2026-08-07

其余章节尚未标注核验日期: 回访时补记, 并把易变结论标上日期 (约定见根 README “工具盲区” 与 src/README “维护约定”).

流程

  1. 改易变结论: 更新正文日期 + 本表对应行, 触发器中命中的章节一并重核.
  2. 触发器命中: 重核对应章节, 更新内容与日期, 标注变更依据.
  3. 面试 / 发布前: 优先抽查命中触发器的章节, 不依赖记忆.