Android 应用开发面试学习清单
共 13 个主题分组、64 篇内容
面向 中级(3-5 年)Android 应用开发岗 的系统复习资料。
你的背景画像:设备指纹 / 风控 SDK 开发者,强项 C/C++ 与 NDK/JNI、系统底层、逆向视角; 短板在 UI 层(View / Compose)、Kotlin 协程 / Flow、Jetpack 全家桶、应用架构。 副技能 iOS 基础。
本资料策略:重点补短板(★),把强项包装成差异化亮点(☆),iOS 作为跨端谈资(辅)。
如何使用这套资料
- 先读
01-面试路线与自我定位.md,想清楚怎么把“风控 SDK 背景“讲成应用岗的优势。 - 按下面“复习路线“的优先级顺序学,先攻短板(协程 → UI → Jetpack → 架构 → 测试体系)。
- 每篇结构统一:知识点讲解 → 高频面试题 + 参考答案要点 → 易错点 / 追问。
- 学完一个知识点,回到本页的“自测清单“打勾
- [x],追踪进度。
复习路线(按优先级)
| 阶段 | 重点 | 对应文档 |
|---|---|---|
| 第一优先(短板,必攻) | 协程 / Flow、View 体系、Compose、Jetpack、架构、测试体系、并发、深度扩展 | 04, 08, 09, 10, 13, 14, 16, 20, 52 |
| 第二优先(高频,巩固) | Kotlin 核心及进阶、Java/JVM、四大组件、系统原理、性能、RxJava | 02, 03, 06, 18, 21, 05, 51 |
| 第三优先(亮点,包装) | NDK/JNI、主流第三方库、APM、推送/保活、音视频、跨端与动态化、SDK与调试 | 42, 39, 26, 37, 49, 44-45, 48, 43, 46 |
| 第四优先(加分,速览) | iOS 基础、KMP、项目经验与软技能、复盘与追问防御 | 44, 45, 59, 60, 61 |
| AI 辅助开发(趋势话题) | Vibe Coding、Harness Engineering、AI Coding 工程化进阶 | 62, 63, 64 |
| 贯穿始终(每日刷题) | LeetCode Hot 100 算法清单与解析 | 50 |
| 算法补充(查漏补缺) | 排序/位运算/并查集/前缀和差分/设计题、业务算法场景 | 51, 53 |
| 算法亮点(差异化) | 海量数据处理(结合你的 SDK 背景) | 52 |
| 计算机基础(必考四大件) | 网络排障、设计模式、操作系统与数据库进阶 | 29, 56, 54, 30, 55 |
| 安全亮点(核武器) | Android 安全与逆向、移动安全防护体系(你的本行) | 40, 41 |
| 工程化与设计(中高级) | Gradle构建与发布体系、鉴权订单隐私合规、性能工具、系统设计 | 31, 32, 33, 35, 36, 34, 25, 57, 58 |
自测清单(学完打勾)
01 面试路线与自我定位
- STAR 法则讲项目
- 怎么把风控 SDK 项目讲成应用岗亮点
- 常见 HR / 反问环节
- 算法准备方向
02 Kotlin 语言核心
- val/var、空安全(
?.?:!!)、平台类型 - 扩展函数 / 扩展属性的原理(静态分发)
- 高阶函数、Lambda、
inline/noinline/crossinline - 作用域函数 let/run/with/apply/also 区别
- data class / sealed class / object / 伴生对象
- 属性委托 by lazy / Delegates.observable / 自定义委托
- 泛型型变 in/out、reified
03 Kotlin 进阶专题
- inline / reified / value class / sealed 类型的面试表达
- Kotlin 泛型、协变逆变、委托与 DSL 设计能力
- 协程状态机、Flow 操作符与性能陷阱的深入理解
04 Kotlin 协程与 Flow ★
- suspend 原理:CPS 变换与状态机
- 结构化并发:CoroutineScope / Job / 父子关系
- Dispatchers(Main/IO/Default)与 withContext
- launch vs async、Deferred
- 取消机制(协作式)与异常传播
- CoroutineExceptionHandler / supervisorScope / SupervisorJob
- 冷流 Flow vs 热流;StateFlow vs SharedFlow
- flowOn / buffer / conflate / collectLatest
05 RxJava 与响应式编程
- Observable / Observer / Subscriber 核心模型
- 变换(map/flatMap)、过滤(filter/debounce)、合并(merge/zip)
- 线程切换 SubscribeOn / ObserveOn 原理
- 背压机制 Flowable / Single / Completable
- 生命周期管理与内存泄漏防护
06 Java 与 JVM 基础
- HashMap 原理(扩容、红黑树、并发问题)、ConcurrentHashMap
- synchronized vs ReentrantLock、volatile、CAS、AQS
- 线程池 ThreadPoolExecutor 七参数与拒绝策略
- JMM 内存模型、happens-before
- JVM 内存区域、GC 算法与垃圾回收器
- 类加载机制、双亲委派
07 Android 四大组件与基础
- Activity 生命周期、启动模式、taskAffinity
- Fragment 生命周期与 commit/commitNow、懒加载
- Service(前台/绑定)、IntentService、JobScheduler/WorkManager
- BroadcastReceiver(静态/动态、有序、LocalBroadcast)
- ContentProvider 与跨进程数据共享
- Intent、序列化 Serializable vs Parcelable、Context 类型
08 UI 体系 - View 与自定义 View ★
- View 绘制三大流程 measure / layout / draw
- MeasureSpec 与 onMeasure、wrap_content 处理
- 事件分发 dispatch / intercept / onTouchEvent
- 滑动冲突(外部 / 内部拦截法)
- 自定义 View / ViewGroup、自定义属性
- invalidate vs requestLayout、硬件加速
- Window / DecorView / ViewRootImpl 关系
09 UI 体系 - Jetpack Compose ★
- 声明式 UI 思想、@Composable 原理
- 重组(Recomposition)与跳过、稳定性
- 状态:remember / mutableStateOf / state hoisting
- 副作用:LaunchedEffect / DisposableEffect / SideEffect / derivedStateOf
- CompositionLocal、Modifier 原理与顺序
- 三大阶段 Composition / Layout / Drawing
- 与 View 互操作、性能优化(key、lambda)
10 Compose 深水区
- 重组跳过、稳定性、remember key 与状态提升
- Modifier、Layout、SubcomposeLayout、自定义绘制核心机制
- Compose 性能分析、列表优化与 View 互操作边界
11 Android 版本适配
- Android 版本演进中的行为变更与兼容策略
- targetSdkVersion 升级风险、灰度验证与回滚方案
- 权限、后台限制、通知、前台服务等高频适配点
12 图片加载与缓存
- 图片加载链路:请求、解码、变换、缓存、展示
- 内存缓存 / 磁盘缓存 / 网络缓存的命中与失效策略
- 大图、列表滑动、生命周期绑定与 OOM 防护
13 Jetpack 架构组件 ★
- ViewModel 原理、生命周期、SavedStateHandle
- Lifecycle / LifecycleObserver、LiveData 原理与粘性事件
- Room(实体 / DAO / 迁移 / 协程&Flow 支持)
- Navigation、Hilt 依赖注入(vs Dagger)
- DataStore(替代 SharedPreferences)、WorkManager、Paging3
14 应用架构 - MVVM 与 MVI ★
- MVC / MVP / MVVM / MVI 演进与对比
- 单向数据流、State / Intent / Effect
- 分层(data / domain / ui)、Repository 模式
- UseCase、依赖倒置、可测试性
15 App 架构落地案例
- 分层架构在真实业务中的模块职责划分
- Repository、UseCase、ViewModel 与 UI State 协作链路
- 架构取舍、团队协作、可测试性与演进成本表达
16 测试体系
- 单元测试 JUnit、Mockito、Robolectric
- UI 测试 Espresso、UI Automator
- 测试驱动开发 TDD 原理与实践
17 组件化路由与模块通信
- 组件化拆分、依赖管理与 ARouter 原理
- 模块间通信、接口暴露与服务发现
- 资源冲突处理、公共库提取与解耦策略
18 Android 系统原理
- Handler / Looper / MessageQueue、同步屏障、IdleHandler
- Binder 机制(一次拷贝、mmap、驱动)
- AMS / 应用启动流程 / 进程创建(Zygote)
- 类加载与热修复 / 插件化原理
- 进程间通信方式对比
19 Binder 与 IPC 深入
- Binder 通信模型、驱动、ServiceManager 与代理对象
- AIDL、Messenger、ContentProvider 等 IPC 方式对比
- 大数据传输、死亡监听、线程池与安全校验边界
20 多线程并发专题
- Java/Kotlin 并发原语、线程池、锁与协程调度对比
- 共享状态、可见性、竞态条件与死锁排查方法
- Android 主线程约束、后台任务调度与取消传播
21 性能优化
- 启动优化(冷 / 热启动、Splash、延迟初始化)
- 内存泄漏(常见场景、LeakCanary 原理)、内存抖动
- 卡顿与掉帧、Choreographer、ANR 原理与定位
- 包体积优化(R8/ProGuard、资源、so)
- 布局优化、工具(Profiler / Perfetto / Systrace)
22 启动优化专项
- 冷启动 / 温启动 / 热启动指标与阶段拆解
- Application 初始化治理、懒加载与异步预热边界
- SplashScreen、Baseline Profile、启动链路监控与回归验证
23 内存优化与泄漏排查
- Android 内存模型、Java 堆 / Native 堆 / 图形内存差异
- 常见泄漏场景与 LeakCanary / MAT 分析路径
- 内存抖动、Bitmap、缓存上限与线上告警指标
24 ANR 与卡顿排查
- ANR 类型、触发阈值、traces 与主线程阻塞定位
- Choreographer、VSync、掉帧指标与 Perfetto 分析
- 锁竞争、IO、Binder 调用、布局过深等排查路径
25 性能工具专题
- Android Studio Profiler、Perfetto、Systrace 的适用场景
- Macrobenchmark、Baseline Profile 与启动/滚动性能验证
- 线上性能指标、采样策略、问题复现与闭环治理
26 APM 与线上监控
- 崩溃监控、错误堆栈聚合与分发
- 性能指标采集:FPS、启动时间、网络耗时
- 线上治理体系与告警策略
27 存储体系与 Scoped Storage
- 内部 / 外部 / App 专属目录与 MediaStore 使用场景
- Scoped Storage 迁移路径、兼容开关与权限边界
- 大文件、缓存、数据库、日志文件的存储治理方案
28 网络协议
- HTTP / HTTPS / HTTP2 / QUIC 原理与握手流程
- TCP vs UDP、三次握手、滑动窗口与拥塞控制
- DNS 劫持、证书校验与网络安全防护
29 网络排障专项
- DNS、TCP/TLS、HTTP/2、QUIC 等链路问题定位
- OkHttp 日志、抓包、证书、代理与弱网复现方法
- 超时、重试、连接池、缓存、网关错误的排查流程
30 数据库进阶
- SQLite 索引、事务、WAL、锁与查询优化原则
- Room 迁移、缓存一致性、分页与离线数据同步
- 数据库排障:慢查询、损坏恢复、并发写入与监控
31 Gradle 与工程化
- Gradle 生命周期、Task Graph、增量构建与缓存机制
- 插件开发、Transform API、依赖管理技巧
- 项目工程化最佳实践
32 Gradle 构建性能专题
- Configuration Cache、Build Cache、并行构建适用条件
- 构建耗时定位、插件治理与 CI 构建优化策略
33 CI/CD 与发布体系
- CI 流水线阶段:检查、测试、构建、签名、发布
- 灰度发布、渠道包、回滚与版本门禁设计
- 自动化质量门禁、制品管理与敏感信息保护
34 隐私合规与权限治理
- 权限申请时机、最小化原则与用户拒绝后的降级体验
- 隐私政策、数据采集清单、SDK 合规审计流程
- 敏感信息脱敏、加密、日志治理与跨境/第三方共享风险
35 登录鉴权与账号体系
- 登录态、Token、Refresh Token 与会话续期机制
- OAuth2 / 单点登录 / 设备绑定的核心流程与风险点
- 账号安全、风控拦截、退出登录与多端一致性处理
36 支付订单与状态机
- 订单状态流转、幂等、防重放与超时关闭机制
- 支付渠道回调、客户端轮询、服务端对账协作
- 异常订单、补偿任务、退款与状态机设计表达
37 推送 / 长连接 / 保活
- 三方推送厂商集成与 FCM、保活策略
- 长连接协议、心跳设计与断线重连机制
- 消息送达率优化与推送链路监控
38 埋点与数据采集 SDK
- 埋点模型、事件属性、会话、用户标识与采样策略
- 数据缓存、批量上报、重试、压缩与弱网治理
- 隐私合规、SDK 性能开销与线上质量监控
39 主流第三方库
- OkHttp(拦截器链、连接池、缓存)
- Retrofit(动态代理、协程适配)
- Glide / Coil(三级缓存、生命周期绑定)
- Gson / Moshi / kotlinx.serialization
- EventBus / 依赖注入框架对比
40 Android 安全与逆向
- APK 结构、反编译、脱壳与签名校验原理
- 常用逆向工具链、静态/动态分析、Frida 挂钩
- 安全编码实践与加固保护方案
41 移动安全防护体系
- 应用加固、反调试、完整性校验与风险分层思路
- 数据加密、密钥管理、通信安全与本地存储防护
- 设备指纹、风控 SDK、防刷防薅羊毛的应用岗表达
42 NDK 与 JNI ☆
- JNI 数据类型映射、签名规则
- 局部 / 全局 / 弱全局引用、引用表溢出
- JNIEnv vs JavaVM、线程 AttachCurrentThread
- 静态注册 vs 动态注册 RegisterNatives
- native crash 捕获(signal / breakpad)
- CMake / ABI / so 加载与裁剪、现代 C++(RAII / 智能指针)
43 NDK Native 调试与 Crash 定位
- tombstone、addr2line、符号表、so 映射与崩溃还原
- lldb / ndk-stack / breakpad 等 Native 调试工具链
- JNI 边界、线程、信号处理与线上 Native Crash 归因
44 iOS 基础速览(辅)
- Swift 基础、可选值、ARC 内存管理
- UIViewController 生命周期、UIKit vs SwiftUI
- GCD、与 Android 概念对照
45 Kotlin Multiplatform
- KMP 架构模型、expect/actual 关键字与共享策略
- 跨端业务逻辑复用、依赖管理与构建流程
- KMP 在移动端的落地场景与优劣分析
46 跨端技术对比
- Flutter、React Native、KMP、WebView Hybrid 的架构差异
- 性能、研发效率、生态、包体积与团队成本取舍
- 跨端落地中的平台能力、调试、发布与降级策略
47 WebView 与 Hybrid
- WebView 生命周期、缓存、进程、性能与内存治理
- JSBridge 通信模型、线程切换与安全白名单校验
- Hybrid 页面加载优化、异常监控与降级兜底
48 插件化 / 热修复 / 动态化
- 插件化类加载、资源加载、组件代理基本原理
- 热修复方案对比、兼容风险与发布安全边界
- 动态化收益、性能成本、合规风险与降级策略
49 音视频 / Media3 / ExoPlayer
- 编解码、渲染流程与 Media3/ExoPlayer 核心架构
- 预加载、缓存、倍速、软硬解切换与性能监控
- 业务场景下的播放优化、异常处理与功能定制
50 LeetCode Hot 100 算法清单
- 数组 / 哈希表 / 链表 / 栈与队列常用算法
- 树 / 图 / 递归与回溯、动态规划、贪心策略
- 二分搜索、滑动窗口、位运算、并查集
51 算法补充专题
- 排序算法对比、复杂数据结构原理
- 深度优先搜索 DFS / 广度优先搜索 BFS 进阶
- 常见大厂面试题复现与思维模型
52 海量数据处理
- 分治、MapReduce 思想与海量数据过滤/排序
- 布隆过滤器、BitMap、Trie 树适用场景
- Top K 问题、外排序、内存受限下的算法方案
53 Android 业务算法场景
- LRU、滑动窗口、优先队列等算法在业务中的应用
- 推荐、搜索、去重、限流、风控规则的算法化表达
- 复杂度分析、边界条件与工程落地取舍说明
54 操作系统与数据库基础
- 进程 / 线程 / 调度 / 内存管理与 Android 运行时关系
- 数据库索引、事务、ACID、范式与规范化
- 文件系统、锁、信号、系统调用在移动端的应用
55 操作系统进阶
- 虚拟内存、mmap、文件 IO、Page Cache 与性能影响
- 操作系统内核机制、同步原语与 Android 底层关系
56 设计模式与 Android 源码应用
- 创建型、结构型、行为型模式在 Android 源码中的体现
- 观察者、工厂、适配器、代理等高频模式的实践
- 设计原则 SOLID、解耦与可扩展性评估
57 系统设计场景题
- 即时通信、Feed、推送、离线缓存等移动场景设计框架
- 客户端架构、数据同步、弱网容错与服务端协作边界
- 容量估算、性能指标、监控告警与灰度降级方案
58 移动端系统设计题库
- 核心业务场景的系统化表达、架构图绘制
- 应对三高(高并发/高可用/高性能)的客户端方案
59 项目经验与软技能
- 项目价值、技术难点、个人贡献与业务闭环表达
- 团队沟通、冲突处理、技术视野与职业规划
60 项目复盘专题
- STAR / 背景目标行动结果结构化复盘项目
- 指标提升、故障处理、协作冲突与技术取舍表达
- 失败经验、重做方案、个人贡献边界与证据准备
61 简历追问防御清单
- 简历每个技术点准备原理、实践、指标与追问答案
- 区分亲自负责、参与协作、学习了解的边界表述
- 针对夸大风险、数据真实性、项目细节进行反问演练
62 Vibe Coding
- AI 辅助下的快速原型、实验性开发流程
- 与 AI 协作的编码风格、意图表达与反馈循环
63 Harness Engineering
- AI 辅助开发中的自动化验证、反馈闭环设计
- 为 AI 构建上下文、工具链集成与质量门禁
64 AI Coding 工程化进阶
- Context Engineering、任务拆解与 AI 代码审查闭环
- MCP、工具调用、自动化验证与可追溯开发流程
- AI 生成代码的风险:幻觉、越权修改、测试缺口与治理
面试路线与自我定位
这一篇不讲技术,讲策略。你不是普通的应届/转行选手,你是“底层强、应用层有空白“的特殊画像。打法和别人不一样。
一、你的优势与劣势盘点
优势(面试中要主动亮出来)
| 能力 | 普通应用开发者 | 你 |
|---|---|---|
| C/C++ / NDK / JNI | 大多不会或只会调用 | 能独立写 SDK |
| 系统底层 / so / 内存 | 模糊 | 熟悉 |
| 逆向 / 安全 / 对抗视角 | 几乎没有 | 是你的饭碗 |
| 性能极限优化意识 | 一般 | 强(SDK 对体积/性能敏感) |
这些是你的护城河。很多 App 团队正缺懂 native、懂性能、懂安全的人(音视频、IM、支付、出海 App 尤其需要)。
劣势(面试中会被重点拷问,必须补)
- UI 层:View 绘制流程、事件分发、自定义 View、Compose —— 应用岗的核心,你接触少。
- Kotlin 协程 / Flow:现代 Android 异步基石,你不熟。
- Jetpack 全家桶:ViewModel/Room/Navigation/Hilt/DataStore,应用开发日常。
- 应用架构:MVVM/MVI、单向数据流、分层 —— 中级必考。
- 主流业务库实战:Retrofit/Glide/OkHttp 的工程化用法。
二、转型叙事:怎么讲你的故事
面试官一定会问:“你之前做风控 SDK,为什么转应用开发?能行吗?” 准备好这套话术:
“我在风控 SDK 做的是 Android 最底层的部分——native 采集、对抗、性能与体积优化。这让我对系统机制(Binder、进程、内存、so 加载)有比一般应用开发者更深的理解。我想往上走,把这份底层功底用在完整的产品上。UI 和上层框架是我最近重点补的,我已经系统学了协程、Compose、Jetpack 和 MVVM 架构。”
关键三点:
- 不贬低过去:底层经验是稀缺的,要包装成“比别人更懂系统“。
- 承认短板但展示行动:“我已经系统学了 X、Y、Z”(配合本资料的学习成果)。
- 给团队一个理由:“我能补团队在 native / 性能 / 安全上的短板”。
三、目标岗位选型(扬长避短)
优先投这些方向,你的底层背景是加分项而非累赘。每个方向都按“为什么契合 → 面试会问什么 → 怎么包装经历 → 可能追问“准备:
- 音视频 / 直播 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“拉回“你能给团队补什么能力“。
五、两个月冲刺计划(参考)
- 第 1-2 周:Kotlin 核心 + 协程/Flow(02 篇,最薄弱,先攻)。
- 第 3-4 周:UI 体系 View + Compose(05、06 篇)。
- 第 5 周:Jetpack + 架构(07、08 篇)。
- 第 6 周:系统原理 + 性能优化(09、10 篇)。
- 第 7 周:复习强项 NDK/JNI 做亮点(11),刷主流库(12)。
- 第 8 周:项目复盘(14)、算法、模拟面试、iOS 速览(13)。
六、心态提醒
你不是从零开始。底层功底是很多面试官给不出、也很羡慕的硬实力。你要补的是“上层广度“,这是可以在两个月内系统补齐的知识型短板,而不是需要多年积累的经验型短板。把姿态放在“我是带着系统功底来做应用“的位置,而不是“我是个 UI 新手“。
Kotlin 语言核心
Kotlin 是现代 Android 的第一语言。你会用,但要把“会用“升级到“讲得清原理“。本篇覆盖中级面试高频语法点的底层机制。
一、空安全(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,线程安全延迟初始化。
二、扩展函数 / 扩展属性
fun String.lastChar(): Char = this[length - 1]
原理(高频追问):扩展函数编译成静态方法,接收者作为第一个参数传入。所以:
- 扩展函数是静态分发,不是多态——调用哪个由声明类型决定,不是运行时类型。
- 不能真正修改类、不能访问 private 成员。
- 扩展属性没有 backing field,只能定义 get/set。
三、高阶函数与 inline
inline fun <T> measure(block: () -> T): T { ... }
- Lambda 默认会被编译成
Function对象,有对象创建开销。 inline把函数体和 lambda 直接内联到调用处,消除对象分配,还能让 lambda 内的return直接返回外层函数(非局部返回)。noinline:某个 lambda 参数不内联。crossinline:禁止该 lambda 非局部返回(用于会在别处调用的场景)。reified:配合 inline,让泛型类型在运行时可见(T::class),解决泛型擦除。- 边界:
reified只让内联函数调用点能拿到T的运行时类型,不能恢复集合元素的完整泛型实参;例如仍无法把List<String>和List<Int>的元素类型当作普通运行时类型安全区分。
四、作用域函数(let/run/with/apply/also)
| 函数 | 引用对象 | 返回值 | 典型用途 |
|---|---|---|---|
let | it | lambda 结果 | 非空判断后操作 x?.let { } |
run | this | lambda 结果 | 配置对象并计算结果 |
with | this | lambda 结果 | 对一个对象多次操作(非扩展) |
apply | this | 对象本身 | 初始化配置 Paint().apply { } |
also | it | 对象本身 | 副作用(打日志)不改链式 |
记忆法:返回结果用 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才持有外部类引用。
六、委托(Delegation)
- 类委托:
class B(b: Base) : Base by b,把接口实现委托给成员,组合优于继承。 - 属性委托:
by lazy { }:首次访问时计算,默认SYNCHRONIZED线程安全。Delegates.observable:值变化时回调。Delegates.notNull():非空但延迟赋值。by map:从 Map 读取属性。- 自定义委托需实现
getValue/setValue。
七、泛型型变
- out T(协变):只能作为输出(生产者),
List<out T>,List<String>可赋给List<Any>。 - in T(逆变):只能作为输入(消费者),
Comparator<in T>。 *星投影:不关心具体类型时使用。- PECS:Producer-Extends(out),Consumer-Super(in)。面试可用一句话落地:只从容器里读
T用out,只往容器里写T用in;既要读又要写具体T时通常不要加型变,否则编译器会限制不安全操作。
高频面试题
Q1:== 和 === 的区别?
== 比较值(调用 equals),=== 比较引用。Java 的 == 对应 Kotlin 的 ===。
Q2:lateinit 和 by lazy的区别?
lateinit 用于 var、非空、可多次赋值、不能用于基本类型、由开发者负责初始化时机;lazy 用于 val、首次访问自动初始化、线程安全可配置。
Q3:扩展函数能被重写吗?为什么? 不能。扩展函数是静态分发,编译成静态方法,调用哪个由声明类型决定而非运行时类型,所以没有多态。
Q4:inline 一定能提升性能吗? 不一定。inline 消除 lambda 对象分配,适合高阶函数;但内联会增大字节码,对大函数滥用反而增加体积、降低性能。Kotlin 编译器会对大 inline 函数告警。
Q5:Kotlin 的 Unit、Nothing、Any 区别?
Any 是所有非空类型的根(类比 Object);Unit 表示无返回值(类比 void,但是真实对象);Nothing 表示永不返回(抛异常或死循环),是所有类型的子类型。
Q6:data class 用作 HashMap 的 key 安全吗? 安全(自动生成了 hashCode/equals),但若字段可变,作为 key 后修改字段会导致查找失败 —— 应保证 key 不可变。
Kotlin 进阶专题
Kotlin 进阶不是背语法糖,而是能把语法、编译器改写、JVM 字节码和 Android 工程边界串起来。面试回答要从“怎么用”升级到“为什么这样设计、代价是什么、和 Java/协程怎么交互”。
一、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 会把实现暴露给调用方字节码,库开发要注意二进制兼容和实现细节泄露。
三、委托:类委托、属性委托与工程价值
委托强调“组合优于继承”。类委托把接口实现转交给成员对象,属性委托把 getter/setter 的通用逻辑抽出去。
| 类型 | 写法 | 典型场景 | 易错点 |
|---|---|---|---|
| 类委托 | class Repo(ds: DataSource) : DataSource by ds | 包装、增强、替换实现 | 覆盖的方法不会自动影响被委托对象内部的自调用 |
| lazy | val x by lazy { ... } | 延迟初始化重对象 | 默认同步锁有开销,可配置 LazyThreadSafetyMode |
| observable | var p by Delegates.observable(...) | 状态变更回调 | 回调里再改同一属性要避免递归 |
| notNull | var id by Delegates.notNull<Int>() | 非空但延后赋值 | 访问前未赋值会抛异常 |
| 自定义委托 | operator fun getValue | 埋点、配置、缓存、校验 | 关注线程安全和异常边界 |
Android 中常见落地是 ViewBinding 委托、SharedPreferences/DataStore 属性包装、配置中心读取和页面参数校验。
四、sealed interface、value class 与领域建模
sealed class / sealed interface 用来表达有限状态集合,让 when 具备穷尽检查。sealed interface 比 sealed class 更灵活:实现类仍可继承其他类,也能让枚举、data class、object 同时表达同一协议。
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 设计和文档约束。
五、context receivers 概览
context receivers 让函数声明“需要哪些上下文能力”,调用点处于对应上下文时才能调用。它适合 DSL、依赖环境显式化、避免参数列表过长。
// 概念示例:实际可用性取决于 Kotlin 版本和编译选项
context(Logger, CoroutineScope)
fun launchTracked(name: String, block: suspend () -> Unit) {
log("start $name")
launch { block() }
}
面试中不要把它讲成 Android 必备能力,而是讲成“更显式的上下文依赖建模”。工程落地要看团队 Kotlin 版本、IDE 支持、可读性和 Java 互操作成本。
六、JVM 字节码角度看 Kotlin
Kotlin 很多高级语法都会落到 JVM 的普通类、静态方法、字段和状态机上。理解这一点能解释性能、混淆、Java 互操作和调试问题。
| Kotlin 语法 | JVM 视角 | 面试意义 |
|---|---|---|
| top-level function | FileNameKt 静态方法 | Java 调用名、@JvmName |
| extension function | 静态方法,接收者是第一个参数 | 静态分发,不能多态重写 |
| default argument | 生成 $default 辅助方法和 bitmask | Java 不天然支持默认参数 |
| object | 单例类 + INSTANCE | 初始化时机、反射/混淆 |
| suspend function | 多一个 Continuation 参数 | 协程状态机、异常栈理解 |
| inline/value class | 调用点展开或底层值传递 | 性能收益和字节码膨胀边界 |
因此分析 Kotlin 问题时可以用 javap、Android Studio bytecode viewer 或反编译 Java 结果辅助理解,但不要机械迷信反编译代码,因为它只是语义近似。
七、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 的非空类型。
Kotlin 协程与 Flow ★
这是你的头号短板,也是中级面试的高频核心。 协程是现代 Android 异步编程的基石,几乎必问。本篇从原理到实战到面试题完整覆盖。
一、协程是什么(先建立心智模型)
协程不是线程。它是一种可挂起/恢复的计算,由编译器 + 运行时在用户态调度。
- 挂起(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):
- 编译器给每个 suspend 函数隐式加一个参数
Continuation(回调),返回值变成Any?。 - 函数体被编译成一个状态机:每个挂起点是一个状态。
- 挂起时函数返回特殊标记
COROUTINE_SUSPENDED,线程被释放。 - 当结果就绪,调用
continuation.resumeWith(result),状态机从上次挂起点恢复执行。
所以协程“挂起不阻塞“的本质:把后续代码包装成回调,挂起时退出函数让出线程,恢复时再回来。这是面试最爱的深挖点。
// 你写的:
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(),状态有 Active/Completing/Cancelled/Completed。 - 父子关系:协程内启动的子协程,其 Job 是父 Job 的子节点。
Android 常用现成 scope:
viewModelScope:绑定 ViewModel,onCleared 自动取消。lifecycleScope:绑定 Lifecycle。GlobalScope:不推荐,脱离结构化并发,易泄漏。
class MyViewModel : ViewModel() {
fun load() = viewModelScope.launch { // ViewModel 销毁自动取消
val data = repo.fetch() // suspend
_state.value = data
}
}
四、Dispatchers 与 withContext
| Dispatcher | 用途 | 线程池 |
|---|---|---|
Dispatchers.Main | UI 操作 | 主线程 |
Dispatchers.IO | 网络 / 磁盘 IO | 共享弹性池(默认上限 64) |
Dispatchers.Default | CPU 密集(排序/解析) | 核数大小 |
Dispatchers.Unconfined | 不限定(测试/特殊) | 当前线程,恢复后随挂起点 |
viewModelScope.launch { // Main
val data = withContext(Dispatchers.IO) { // 切到 IO
api.fetch()
}
textView.text = data // 自动回到 Main
}
withContext 切换上下文并挂起等待结果返回,是最常用的线程切换方式。IO 与 Default 共享底层线程池,两者间切换不一定真正换线程(优化)。
五、launch vs async
launch:返回Job,不返回结果,适合“发射后不管“的副作用;异常立即传播给父。async:返回Deferred<T>,await()取结果,适合并发计算多个值;异常在await()时抛出。
// 并发请求,总耗时 = max 而非 sum
val a = async { api.getA() }
val b = async { api.getB() }
val result = a.await() + b.await()
六、取消机制(协作式)
协程取消是协作式的:取消只是把 Job 标记为 Cancelling,真正停止需要协程代码配合检查。
- 所有
kotlinx.coroutines的 suspend 函数(delay 等)在恢复时会检查取消状态,抛出CancellationException。 - 纯 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,
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。 - 无初始值,不去重。
- 适合一次性事件(导航、Toast、SnackBar)—— 用 replay=0 避免事件重放。
对比表
| 维度 | LiveData | StateFlow | SharedFlow |
|---|---|---|---|
| 库 | Jetpack | 协程 | 协程 |
| 初始值 | 否 | 必须 | 否 |
| 生命周期感知 | 自带 | 需 repeatOnLifecycle | 需 repeatOnLifecycle |
| 去重 | 否 | 是 | 否 |
| 粘性 | 是 | 是 | 可配 replay |
| 适用 | 老项目状态 | 新项目状态 | 事件 |
Android 正确收集姿势(避免后台浪费):
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
进阶补充: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 必须释放监听器,否则会泄漏。
combine / zip / flatMapLatest
| 操作符 | 行为 | 场景 |
|---|---|---|
| combine | 任一上游变化就用最新值组合 | 表单状态、多个配置源 |
| zip | 等两边一一配对 | 两个一次性结果合并 |
| flatMapLatest | 新请求来时取消旧请求 | 搜索框、筛选条件变化 |
stateIn / shareIn
stateIn 把冷 Flow 转成有当前值的 StateFlow;shareIn 把冷 Flow 共享给多个订阅者。面试要说明 scope、started 策略和 replay。
协程测试
使用 runTest、StandardTestDispatcher 和虚拟时间,不要在测试里真实 delay。
**追问:**为什么 callbackFlow 里要写 awaitClose?因为 Flow 被取消时需要注销外部回调,否则回调继续持有对象导致泄漏。
高频面试题
Q1:协程和线程的区别? 线程是 OS 调度的重量级资源;协程是用户态的轻量“可挂起计算“,多个协程可复用少量线程。协程挂起不阻塞线程。一个线程能跑成千上万协程。
Q2:suspend 关键字做了什么?协程凭什么不阻塞线程? 编译器对 suspend 函数做 CPS 变换,加 Continuation 参数,函数体编译成状态机。挂起时返回 COROUTINE_SUSPENDED 并释放线程,结果就绪后通过 resumeWith 从挂起点恢复。所以是“挂起协程“而非“阻塞线程“。
Q3:launch 和 async 区别?async 不 await 会怎样? launch 返回 Job 无结果,async 返回 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 重新触发;热流独立于收集者。状态用 StateFlow(有初值、去重),事件用 SharedFlow(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)时启动收集,离开时取消,再次进入重启,是官方推荐的安全收集方式。
RxJava 与响应式编程
RxJava 在新项目里常被协程/Flow 替代,但大量老 Android 项目仍在使用。中级面试经常考“你能不能维护老项目,并解释迁移取舍“。
一、响应式编程心智模型
RxJava 把异步事件看成数据流:上游发射、操作符转换、下游订阅、调度器切线程。
二、Observable、Single、Maybe、Completable、Flowable
| 类型 | 含义 | 适用 |
|---|---|---|
| Observable | 0..N 个事件 | UI 事件、普通流 |
| Single | 1 个结果或错误 | 网络请求 |
| Maybe | 0 或 1 个结果 | 缓存查询 |
| Completable | 只关心完成/失败 | 写入、删除 |
| Flowable | 支持背压 | 高频数据流 |
三、调度器与线程切换
subscribeOn 影响上游订阅线程,通常只第一个生效;observeOn 切换下游观察线程,可多次使用。
api.getUser()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(::renderUser, ::showError)
四、常用操作符
- 变换:
map、flatMap、concatMap - 过滤:
filter、distinctUntilChanged - 组合:
zip、combineLatest、merge - 错误:
onErrorReturn、retryWhen - 生命周期:
takeUntil、AutoDispose/RxLifecycle
五、背压与资源释放
背压问题来自上游生产快、下游消费慢。Flowable 可配置 BUFFER、DROP、LATEST 等策略。Android 页面销毁时必须 dispose,否则可能泄漏 Activity。
六、RxJava 与协程/Flow 对比
| 维度 | RxJava | 协程/Flow |
|---|---|---|
| 学习成本 | 高,操作符多 | Kotlin 原生,结构化并发 |
| 取消 | Disposable | Job/coroutine cancellation |
| 背压 | Flowable 专门处理 | Flow 挂起/缓冲操作符 |
| 老项目生态 | 强 | 新项目更主流 |
高频面试题
Q1:subscribeOn 和 observeOn 区别?
答:subscribeOn 决定订阅发生在哪个线程,通常第一个生效;observeOn 决定后续观察者在哪个线程执行,可以多次切换。
Q2:RxJava 为什么容易内存泄漏? 答:订阅链持有 observer/lambda,如果页面销毁后未 dispose,异步结果回来仍可能持有 Activity/View。
Q3:老项目 RxJava 怎么迁移到协程? 答:先从边界层开始,比如 Retrofit 支持 suspend;内部复杂链路可逐步迁移,不要一次性重写全部业务流。
易错点 / 追问
- 不要把所有异步都写成复杂操作符链。
- 不要忘记错误分支,否则链路会终止。
- 不要在主线程做重 map/flatMap 计算。
Java 与 JVM 基础
即便项目用 Kotlin,面试官仍常问 Java/JVM——因为字节码、并发、GC 是共通的底层。这一篇覆盖高频考点。
一、集合框架
HashMap(必考)
- 结构:数组 + 链表 + 红黑树(JDK8)。链表长度 ≥ 8 且数组长度 ≥ 64 转红黑树;退化阈值 6。
- 初始容量 16,负载因子 0.75,扩容翻倍(2 倍)。容量始终是 2 的幂,便于用
(n-1) & hash取代取模。 - 扰动函数:
hash = h ^ (h >>> 16),让高位参与运算,减少碰撞。 - JDK8 头插改尾插:JDK7 头插法在并发扩容时会形成环形链表导致死循环;JDK8 改尾插缓解,但 HashMap 仍非线程安全。
- 并发问题:多线程 put 可能丢数据、扩容期间读到 null。
ConcurrentHashMap
- JDK7:分段锁 Segment;JDK8:CAS + synchronized 锁单个桶头节点,粒度更细,并发度更高。
- size() 用 baseCount + CounterCell 分散统计。
其他
- ArrayList(动态数组,扩容 1.5 倍)vs LinkedList(双向链表)。
- HashSet 底层是 HashMap;LinkedHashMap 维护插入/访问顺序(可做 LRU)。
二、并发
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
- 保证可见性(写立即刷主存,读从主存)和有序性(禁止指令重排,插入内存屏障)。
- 不保证原子性(如
i++仍不安全)。 - 经典用途:双重检查锁单例的实例字段。
CAS 与 AQS
- CAS(Compare-And-Swap):无锁原子操作,
compareAndSwap(内存值, 期望值, 新值),失败自旋重试。底层是 CPU 指令。 - ABA 问题:值从 A→B→A,CAS 误判没变。用版本号(AtomicStampedReference)解决。
- AQS(AbstractQueuedSynchronizer):用 volatile int state + CLH 队列实现的同步框架,ReentrantLock、CountDownLatch、Semaphore 都基于它。
线程池 ThreadPoolExecutor
七个参数:corePoolSize(核心线程)、maximumPoolSize(最大)、keepAliveTime、unit、workQueue(任务队列)、threadFactory、handler(拒绝策略)。
执行流程:核心线程未满 → 创建核心线程;满了 → 入队;队列满 → 创建非核心线程到 max;再满 → 触发拒绝策略(AbortPolicy 抛异常 / CallerRunsPolicy 调用者执行 / DiscardPolicy 丢弃 / DiscardOldestPolicy 丢最老)。
三、JVM 内存与 GC
内存区域
- 线程私有:程序计数器、虚拟机栈、本地方法栈。
- 线程共享:堆(对象实例,GC 主战场)、方法区/元空间(类信息,JDK8 后用本地内存)。
- 注意 Android 用 ART/Dalvik 而非标准 JVM,但内存模型概念相通。
GC
- 判活:引用计数(有循环引用问题)vs 可达性分析(GC Roots,主流)。
- GC Roots:虚拟机栈引用、静态变量、常量、JNI 引用等。
- 回收算法:标记-清除(碎片)、复制(浪费空间,适合新生代)、标记-整理(适合老年代)。
- 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 参数经验当作移动端结论。
类加载
- 过程:加载 → 验证 → 准备 → 解析 → 初始化。
- 双亲委派:类加载请求先委托父加载器,父无法加载才自己加载。保证核心类不被篡改(如自定义 String 不会覆盖系统的)。
- 打破双亲委派:SPI、热修复、插件化、Tomcat 的 WebappClassLoader。
高频面试题
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 密集型设为核数+1;IO 密集型设为核数×2 或更高(经验值,需压测)。
Q6:双亲委派的作用?为什么 Android 热修复要打破它? 保证类的唯一性和安全性。热修复需要让补丁类优先于原类被加载,所以要把补丁 dex 插到 classloader 的 dexElements 数组前面,本质是绕过/利用加载顺序。
四大组件与基础
四大组件是 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 等)可动态控制。
二、Fragment
- 生命周期比 Activity 多:
onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume ...→ onDestroyView → onDestroy → onDetach。 - commit vs commitNow vs commitAllowingStateLoss:commit 异步;commitNow 同步;前两者在 onSaveInstanceState 后调用会抛 IllegalStateException,allowingStateLoss 容忍但可能丢状态。
- 现代用
FragmentStateAdapter+ ViewPager2 替代旧懒加载;getViewLifecycleOwner 观察 LiveData 防泄漏。 - 为什么用 Fragment 而非多 Activity:轻量、复用、共享 ViewModel、单 Activity 架构配合 Navigation。
三、Service
- 启动方式:
startService(独立运行,需手动 stopSelf/stopService)vsbindService(绑定,随调用者销毁,可通信)。 - 前台服务:必须
startForeground显示通知(Android 8+ 后台限制),用于音乐、定位、下载。Android 12+ 还有前台服务类型限制。 - IntentService 已废弃:用
WorkManager或协程替代后台任务。 - Service 不是线程:默认运行在主线程,耗时操作仍需开子线程,否则 ANR。
四、BroadcastReceiver
- 注册方式:
- 静态注册(Manifest):适合系统或跨应用广播;Android 8.0 起对很多隐式广播做了限制,普通应用不要依赖静态注册来长期唤醒进程。
- 动态注册(
registerReceiver):跟随 Activity/Fragment/Service 生命周期,常在onStart/onResume注册、onStop/onPause反注册,用于只在界面可见或组件存活时接收。
- 分发流程:
sendBroadcast/sendOrderedBroadcast → ActivityManager/系统广播队列匹配 IntentFilter → 找到静态/动态 Receiver → 目标进程已在则直接回调,未启动且允许则拉起进程 → 主线程调用 onReceive。 - 类型边界:
- 普通广播:异步分发,多个 Receiver 接收顺序不应作为业务依赖。
- 有序广播:按优先级依次分发,前一个 Receiver 可通过
setResult*改结果,也可在允许的场景中abortBroadcast()终止后续接收;因此更像一条责任链。 - LocalBroadcastManager / 粘性广播:都已不推荐。应用内事件优先用
StateFlow/SharedFlow、回调、Lifecycle-aware observer 或明确的 Repository 状态流。
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 方法可能被跨进程并发调用,数据库访问要考虑线程安全和事务。
常见错误:
- 只记“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 控制页面生命周期。
前台 Service 类型
前台服务必须有用户可见通知,新版本要求声明类型,如 location、mediaPlayback、dataSync。不能把前台服务当万能保活工具。
**追问:**为什么 Fragment 会出现状态丢失?因为系统已保存 Activity 状态后,再提交 Fragment 事务无法保证恢复一致性。
高频面试题
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() 触发,依次走:
- measure(测量):确定 View 的宽高。
- layout(布局):确定 View 在父容器中的位置。
- 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 一个默认尺寸。
layout
onLayout 中调用子 View 的 layout(l, t, r, b) 确定位置。View 的 getWidth/getHeight(布局后的实际尺寸)与 getMeasuredWidth/Height(测量尺寸)区别:正常情况相等,但可被 layout 强行改变。
draw 顺序
- 绘制背景
drawBackground - 绘制内容
onDraw - 绘制子 View
dispatchDraw - 绘制前景/滚动条
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 请求父不要拦截(滑动冲突解决用)。
三、滑动冲突解决
当内外层都能滑动(如 ViewPager 嵌 ListView、横滑嵌竖滑),需解决冲突:
- 外部拦截法(推荐):重写父容器
onInterceptTouchEvent,按需要判断是否拦截。DOWN 不拦截(否则子 View 收不到),MOVE 时根据方向决定。 - 内部拦截法:父容器默认拦截所有,子 View 通过
requestDisallowInterceptTouchEvent动态控制父是否拦截,配合父重写 onInterceptTouchEvent 对 DOWN 不拦截。
判断依据:水平/垂直距离比较、速度、业务规则。
四、自定义 View
三种方式:
- 继承现有 View(如 TextView):扩展功能。
- 继承 View:完全自绘,重写 onMeasure(处理 wrap_content)+ onDraw。
- 继承 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 关闭。
六、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 是否参与,滚动前后分发消耗量。
典型场景:CoordinatorLayout + AppBarLayout + RecyclerView。
Choreographer 与 VSync
Choreographer 接收 VSync 信号,驱动 input、animation、traversal。60Hz 下每帧约 16.6ms,超时会掉帧。卡顿排查要看主线程是否阻塞、布局是否过重、GPU 是否过载。
RecyclerView 复用与预取
- ViewHolder 复用减少创建成本。
- DiffUtil 减少无效刷新。
- GapWorker 负责预取。
onBindViewHolder不做重 IO/复杂计算。
GestureDetector 与 VelocityTracker
复杂手势不要全靠手写坐标判断。点击、长按、fling 可用 GestureDetector;速度计算可用 VelocityTracker。
**追问:**为什么 RecyclerView 滑动卡顿?常见是 bind 太重、图片加载无占位/无取消、布局层级深、频繁全量刷新。
高频面试题
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 等应在构造时创建并复用。
UI 体系 - Jetpack Compose ★
你的重点短板,且 2024-2025 中级面试几乎必问。 Compose 是 Android 声明式 UI 的未来,新项目首选。即便面试官项目还在用 View,也常问 Compose 的理解。
一、声明式 UI 思想
- 命令式(View):你持有 View 引用,手动 findViewById、setText、setVisibility 去“改“UI。
- 声明式(Compose):你描述“UI 在某状态下长什么样“,状态变了框架自动重绘。
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 要无副作用、幂等、快。
- 执行顺序不保证、可能并行。
- 不要在 Composable 体内直接做副作用(网络、写变量),要用副作用 API。
稳定性(Stability)
- 编译器判断类型是否
@Stable/@Immutable。不稳定类型(如List接口、含 var 的 class)会导致无法跳过,引发不必要重组。 - 优化:用
data class+ 不可变属性、kotlinx.collections.immutable的ImmutableList。
三、状态管理
remember { }:在重组间记住值(存在 Composition 中),重组不重置。mutableStateOf():创建可观察状态,读取它的 Composable 会在它变化时重组。remember { mutableStateOf(x) }:最常见组合。rememberSaveable还能跨配置变更/进程恢复。
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("$count") }
- 状态提升(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。 - Modifier:装饰 Composable(尺寸、padding、点击、背景)。顺序敏感:
padding().background()与background().padding()效果不同(前者内边距区域无背景色)。Modifier 是链式不可变的。
六、三大阶段
Compose 渲染分三阶段:
- Composition(组合):执行 Composable,构建/更新 UI 树(发射什么)。
- Layout(布局):测量 + 摆放(多大、放哪)。
- Drawing(绘制):画到屏幕。
理解阶段有助于性能优化:只改绘制层的东西(如颜色)不必触发重组——用 lambda 形式的 Modifier(如 graphicsLayer { }、drawBehind)可跳过 Composition/Layout,直接在 Draw 阶段更新。
七、与 View 互操作 & 性能
- Compose 中嵌 View:
AndroidView { }。 - View 中嵌 Compose:
ComposeView.setContent { }。 - 性能优化要点:
- 用稳定类型,避免不必要重组。
- LazyColumn 给
key提升 diff 效率。 - 把状态读取推迟到最小范围(defer reads),用 lambda Modifier。
- 避免在 Composable 里做重计算(用 remember 缓存)。
@Stable/@Immutable标注帮助编译器跳过。
高频面试题
Q1:Compose 和传统 View 的本质区别? View 是命令式、保留模式(retained,持有可变 View 树,手动改);Compose 是声明式、即时模式思想(描述 UI=f(state),状态变自动重组)。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:为什么会发生不必要的重组?怎么优化? 参数类型不稳定(List 接口、含 var 的类)导致无法跳过。优化:用不可变类型、@Stable/@Immutable、derivedStateOf、给 LazyColumn 加 key、defer state reads。
Q6:Compose 的三个阶段是什么? Composition(发射 UI 树)、Layout(测量摆放)、Drawing(绘制)。只影响绘制的变化可用 lambda Modifier 跳过前两阶段。
Q7:Modifier 顺序为什么重要? Modifier 链按顺序应用,每个包裹前一个。padding 在 background 前后效果不同。size、padding、clickable 顺序都会改变最终表现。
Q8:状态提升是什么?为什么重要? 把状态从 Composable 内移到调用方,使 Composable 无状态(接 value + onValueChange)。好处:单一数据源、可复用、可测试、可被多处控制。
Compose 深水区
Compose 深水区考的不是“会写 Text/Button”,而是你是否理解重组、稳定性、Snapshot、Modifier、LazyColumn 性能和测试,能不能把声明式 UI 写得稳定、可测、不卡。
一、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)
}
}
二、Stability 与 Skippable
Compose 编译器会判断参数是否稳定,稳定且参数未变化时,Composable 可以被跳过(skippable)。
| 类型/写法 | 对跳过的影响 | 建议 |
|---|---|---|
| 不可变 data class(val 字段) | 更容易稳定 | UI State 尽量不可变 |
List<T> 接口 | 常被视为不稳定 | 用不可变集合或稳定 wrapper |
含 var 的普通 class | 不稳定 | 改为 State 驱动或不可变模型 |
| lambda 每次新建 | 可能导致参数变化 | 必要时 remember 或上提 |
怎么答:优化 Compose 性能不是“少写 Composable”,而是让状态和参数稳定,让框架能精准重组和跳过。
三、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 */ }
}
五、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)
}
}
七、View 互操作与生命周期收集
Compose 和 View 混用在迁移期很常见。
- Compose 中嵌 View:用
AndroidView,在update中同步参数,不要每次重组都重新创建重对象。 - View 中嵌 Compose:用
ComposeView.setContent,并设置合适的ViewCompositionStrategy。 - 收集 Flow:优先用
collectAsStateWithLifecycle(),避免后台生命周期仍持续收集。 - 副作用:进入组合加载用
LaunchedEffect(key),注册监听用DisposableEffect(key)清理。
| 场景 | 推荐 API | 注意点 |
|---|---|---|
| Compose 嵌地图/广告 View | AndroidView | factory 创建,update 更新 |
| Fragment 嵌 Compose | ComposeView | 销毁 View 时释放 Composition |
| Flow 渲染 UI | collectAsStateWithLifecycle | 需要 lifecycle-runtime-compose |
| 注册监听器 | DisposableEffect | onDispose 反注册 |
八、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 里用普通单元测试覆盖。
高频面试题
Q1:什么会触发 Compose 重组? Composable 在组合阶段读取的 State 变化会触发相关范围重组。重组是局部重新执行函数,不是整棵 UI 全量重建。Composable 要幂等、无副作用、快速执行。
Q2:稳定性和 skippable 是什么? 如果 Composable 参数稳定且值没变,编译器/运行时可以跳过它,减少重组成本。不稳定类型如可变 class、普通 List 接口会降低跳过概率,应使用不可变 UI State 和稳定集合。
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 版本适配
★ 版本适配是考察 Android 基础扎实度和工程经验的试金石。不要死记硬背每个 API,重点讲清“为什么改”以及“线上怎么平滑过渡”。
一、Android 6.0 - 9.0 核心适配回顾
虽然年代久远,但这是现代 Android 权限和后台限制的基石:
- Android 6.0 (M): 动态权限机制(Runtime Permissions)。核心思想:敏感权限必须在用到时申请,不能全靠安装时授权。
- Android 7.0 (N): FileProvider。严禁在 Intent 中传递
file://URI,必须使用content://,提升跨应用文件共享的安全性。 - Android 8.0 (O):
- 后台执行限制:应用在后台时不能随便启 Service,推荐用 JobScheduler 或 WorkManager。
- 通知渠道(Notification Channels):通知必须分类,把控制权交给用户。
- Android 9.0 (P): 限制 Http 明文请求(需配置 networkSecurityConfig),以及非 SDK 接口限制(深反射受限)。
二、Android 10 - 11:存储与隐私大修
这是近年来适配痛点最集中的版本,核心是分区存储与包可见性。
- Android 10 (Q):
- Scoped Storage (分区存储):应用默认只能访问自己的沙盒目录和公共媒体集合(MediaStore)。不能再随便遍历
/sdcard。 - 后台定位限制:细分了前台定位和后台定位权限。
- Scoped Storage (分区存储):应用默认只能访问自己的沙盒目录和公共媒体集合(MediaStore)。不能再随便遍历
- Android 11 (R):
- 强制分区存储:TargetSDK 30 时
requestLegacyExternalStorage失效,必须彻底适配 MediaStore 或 SAF。 - 包可见性 (Package Visibility):不能再无脑
getInstalledPackages()。必须在 Manifest 中用<queries>声明你要交互的应用包名,防流氓应用拉取用户应用列表。
- 强制分区存储:TargetSDK 30 时
三、Android 12:用户体验与安全加固
- SplashScreen API:系统强制的闪屏规范。启动时自动接管显示 App 图标,需适配新的主题属性,否则会出现“双闪屏”。
- PendingIntent 可变性:必须显式声明
FLAG_IMMUTABLE或FLAG_MUTABLE,防止组件劫持。 - 精确闹钟限制:
AlarmManager.setExact()需要申请SCHEDULE_EXACT_ALARM权限,防止滥用唤醒系统。 - 蓝牙权限细分:分离了定位和蓝牙权限,扫蓝牙不再必须申请定位权限(需声明
neverForLocation)。
四、Android 13:细粒度权限时代
- 通知运行时权限:发通知不再是默认开启的,必须动态申请
POST_NOTIFICATIONS权限。 - 细化的媒体权限:废弃了粗放的
READ_EXTERNAL_STORAGE,拆分为:READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO
- 照片选择器 (Photo Picker):无需任何存储权限即可让用户选择图片,强烈建议替换掉自研的图片选择器。
五、Android 14:前台服务与后台行为收紧
- 前台服务类型强制:启动 Foreground Service 必须声明对应的类型(如
location,mediaPlayback,dataSync),且必须附带具体的原因和对应权限。 - 隐式 Intent 限制:内部组件通信必须使用显式 Intent 或指定 package,防止被外部恶意截获。
- 精确闹钟默认拒绝:TargetSDK 34 的非日历/闹钟类应用,精确闹钟权限默认关闭,需引导用户去设置页开启。
六、版本适配速查矩阵与面试策略
为了在面试中快速调取记忆,可以参考以下核心版本适配矩阵:
| 版本与 TargetSDK | 核心变更 | 面试常问痛点 | 常见解决方案与对策 |
|---|---|---|---|
| Android 10 (29) | 分区存储初步引入、后台定位 | 读写 SD 卡崩溃 | 声明 requestLegacyExternalStorage 过渡,切沙盒目录 |
| Android 11 (30) | 强制分区存储、包可见性 | 第三方分享/支付失败 | Manifest 添加 <queries> 标签声明依赖的包名 |
| Android 12 (31) | PendingIntent 可变性、四大组件 Exported | 通知点击崩溃、安装失败 | 补充 FLAG_IMMUTABLE,强声明 android:exported |
| Android 13 (33) | 通知权限、细粒度媒体权限 | 发不出通知、读图失败 | 动态申请 POST_NOTIFICATIONS,接入 PhotoPicker |
| Android 14 (34) | 前台服务类型、精确闹钟收缩 | 后台服务崩溃 | 补充 foregroundServiceType 及对应权限,转用 WorkManager |
面试实战:版本适配的回答结构
当被问到“讲讲你做过哪些版本适配”时,用 STAR 法则 构建你的回答:
- 情境(Situation): “在升级 TargetSDK 到 33 时…”
- 任务(Task): “…我们遇到了图片选择和通知发送失效的问题。”
- 行动(Action): “…我将存储权限拆分请求,并接入了官方的 Photo Picker,同时利用一套版本判断工具类统一封装了通知权限的申请逻辑。”
- 结果(Result): “…不仅解决了崩溃,还把包体积减小了(因为移除了臃肿的第三方相册组件),并且减少了向用户索要危险权限的次数,合规性提升。”
高频面试题
Q1:你们是如何平滑过渡分区存储(Scoped Storage)的?
答:我们分了两步走。首先在 TargetSDK 29 时通过 requestLegacyExternalStorage="true" 作为过渡方案。其次,梳理了所有文件读写场景:将应用内缓存切到 Context.getExternalFilesDir();将需要分享给用户的图片/视频,改为通过 MediaStore API 插入;对于需要用户选择外部文件的场景,接入了 Storage Access Framework (SAF)。
Q2:Android 12 的 PendingIntent 崩溃问题怎么排查和解决?
答:这是因为 TargetSDK 31 后未声明可变性。我们首先通过全局搜索 PendingIntent.getActivity/getBroadcast 找到所有调用点。对于只用于拉起界面的点击通知,统一加上 PendingIntent.FLAG_IMMUTABLE;对于需要回传输入结果(如 Direct Reply)的场景,使用 FLAG_MUTABLE。同时检查了第三方推送 SDK 是否已升级到兼容版本。
Q3:如何处理 Android 14 前台服务的限制?
答:梳理业务中所有的 Foreground Service。比如定位打卡服务,在 Manifest 声明 foregroundServiceType="location",并在启动前确保已获得前台定位权限。如果是数据同步,尽量将其迁移到 WorkManager,因为 Android 14 极度不推荐长驻的后台同步服务。
易错点 / 追问
- TargetSDK 与 CompileSDK 混淆:CompileSDK 是编译时环境,TargetSDK 是运行时的兼容模式开关。只有提升了 TargetSDK,系统才会用新的限制策略约束你。
- 第三方 SDK 拖后腿:自己业务适配了,但旧版第三方 SDK 仍用老 API 导致崩溃。追问解决方案时,可以说“推动 SDK 更新、使用 ASM 字节码插桩拦截修改、或寻找替代方案”。
- 忽略条件判断:在调用新 API 时没有包裹
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU),导致在老机器上发生NoSuchMethodError崩溃。
图片加载与缓存
★ 图片是移动端内存消耗的第一大户,也是 OOM 的万恶之源。面试题通常从“怎么用 Glide”深入到“如果让你手写一个图片框架,你会怎么设计三级缓存”。
一、Bitmap 内存模型与大小计算
一张 Bitmap 占多大内存?
公式:分辨率宽 × 分辨率高 × 每个像素占用的字节数(还要考虑放错 drawable 文件夹带来的缩放系数)。
- ARGB_8888(默认):每个像素 4 字节。高质量,带透明度。
- RGB_565:每个像素 2 字节。无透明度,省一半内存。
注意:Android 8.0 之后,Bitmap 的像素数据移到了 Native 内存中,减少了对 JVM 堆的压力,降低了 Java 层的 OOM,但总内存占用并没变。
二、采样压缩:inSampleSize 的奥秘
加载 4K 高清图到 100x100 的 ImageView,直接加载必爆内存。必须使用 BitmapFactory.Options 进行采样压缩。
// 1. 只读取图片宽高,不将像素加载到内存
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeResource(resources, R.id.my_image, options)
// 2. 根据目标 View 尺寸计算采样率 (inSampleSize,必须是 2 的幂)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
// 3. 关闭只读边界,真正加载图片
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeResource(resources, R.id.my_image, options)
三、经典三级缓存设计
- 内存缓存 (LruCache):速度极快,空间小。基于强引用,利用
LinkedHashMap记录访问顺序,满时淘汰最近最少使用的图片。 - 磁盘缓存 (DiskLruCache):速度中等,空间大。保存下载的原始文件或转换后的图。
- 网络获取:最慢,消耗流量。内存和磁盘都没命中时才走网络。
流程:读(内存 → 磁盘 → 网络),写(网络回来后写入磁盘和内存)。
四、Glide 的多级缓存机制剖析
Glide 的缓存比经典三级缓存更精细:
- 活动资源 (ActiveResources):当前正在屏幕上显示的图片。使用弱引用持有,防止 GC 时被回收导致闪烁,同时分担 LruCache 压力。
- 内存缓存 (LruCache):刚被移出屏幕,但可能马上要用到的图片。
- 磁盘缓存 (DiskCache):
Resource:缓存经过转换、裁剪后的图(直接拿来就能显示)。Data:缓存网络下载的原始全尺寸数据。
五、列表滑动与超长图优化
- RecyclerView 滑动卡顿优化:
- 在
onBindViewHolder里绝对不能在主线程 decode 图片。 - 滑动时暂停加载 (
Glide.with(context).pauseRequests()),停止滑动时恢复加载。 - 给 ImageView 固定宽高,避免多次 measure。
- 在
- 巨图加载 (长图/清明上河图):
- 使用
BitmapRegionDecoder局部解码,结合手势监听,滑到哪里只加载那一部分的内存。
- 使用
高频面试题
Q1:如何设计一个图片加载框架?(架构题) 答:可以拆分为四大模块:
- 请求封装层:对外提供流式 API (如
with().load().into()),封装 Request 对象。 - 任务调度层:管理线程池,负责将加载任务派发给 IO 线程,把结果回调到主线程。并与生命周期绑定,在 Activity 销毁时取消任务。
- 缓存拦截层:实现 ActiveResource -> LruCache -> DiskCache -> Network 的责任链/拦截器模式。
- 解码/变换层:负责网络流到 Bitmap 的解码,处理 inSampleSize 采样,以及圆角、模糊等 Transformation。
Q2:排查线下和线上 OOM 崩溃,你的思路是什么? 答:线下通常通过 Android Profiler 监控内存,或者集成 LeakCanary,当触发 dump 后,通过 MAT 或 Shark 分析堆栈,找出是哪个组件持有大对象。 线上 OOM 通常缺乏具体堆栈。我们会分析 APM 收集的机型和发生页面,重点排查该页面的大图加载是否有采样压缩、列表图片是否使用了 ARGB_8888、是否有未关闭的动画或大 Bitmap 引用泄露。可以上报 OOM 时的内存使用统计,辅助定位。
Q3:Glide 的生命周期管理是怎么做的?为什么 Activity 销毁时它能停止加载?
答:Glide 通过向当前传入的 Context(Activity/Fragment)偷偷注入一个无 UI 的隐藏 Fragment。因为系统会回调这个 Fragment 的生命周期方法,Glide 借此感知到了宿主的 onStart、onStop、onDestroy,从而自动暂停、恢复或取消图片加载任务,防止内存泄漏和无效流量浪费。
易错点 / 追问
- 混淆 drawable 文件夹的缩放规则:把大图放在
drawable-mdpi,在高密度屏幕手机上加载时,系统为了保持物理尺寸一致,会对其进行放大采样,导致内存成倍暴增。图片应该尽量提供 xxhdpi,或者放在drawable-nodpi中。 - 误解 LruCache 的原理:追问 LruCache 底层数据结构时,必须答出是
LinkedHashMap并且开启了accessOrder=true模式,每次get或put都会把元素移到双向链表尾部。 - 忽略 Bitmap 回收机制:虽然 Bitmap 数据在 Native,但 Java 层的 wrapper 对象仍靠 GC。追问优化时可以提
inBitmap属性,即复用已分配的旧 Bitmap 内存块来加载新图片,避免频繁申请和销毁内存。
Jetpack 架构组件 ★
你的重点短板。 Jetpack 是应用开发日常,中级面试必问 ViewModel/LiveData/Room/Hilt 原理。
一、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) { } }。
三、LiveData
- 生命周期感知的可观察数据持有者:只在活跃状态(STARTED/RESUMED)通知观察者,组件销毁自动移除观察,避免泄漏和崩溃。
setValue(主线程)/postValue(任意线程)。- 粘性问题:新观察者会立即收到最新值,用于“事件“场景会重复触发(用 SingleLiveEvent/Flow 替代)。
- MediatorLiveData:合并多个源。
- 现代趋势:新项目用 StateFlow/SharedFlow 替代,但老项目仍大量使用,必须懂。
四、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 场景是NavHostComposable。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)
}
}
}
常见错误:
- 在多个页面散落手写跳转字符串,导致参数名/route 不一致;可用常量、封装 route builder 或 Safe Args 降低风险。
- 登录、首页、详情都压入同一栈后不清理,返回路径混乱;需要明确
popUpTo和inclusive。 - 把导航事件放进持久 State,旋转后重复 navigate;一次性导航应使用 Effect/SharedFlow。
- 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 实现)、统一管理对象创建。
七、DataStore / WorkManager / Paging
- DataStore:替代 SharedPreferences。基于协程 + Flow,异步、事务安全、无 ANR 风险。两种:Preferences DataStore(键值)、Proto DataStore(类型安全)。SP 的问题:同步 apply 仍可能阻塞、
getXXX可能主线程 IO、无类型安全。 - WorkManager:可延迟、保证执行的后台任务(即使 App 退出/重启)。支持约束(网络、充电)、链式、周期任务。底层根据系统版本选 JobScheduler/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 的粘性事件问题是什么?怎么解决? 新观察者注册时会立即收到最新值,对“一次性事件“(导航/Toast)会重复触发。解决:SingleLiveEvent、事件包装类、或改用 SharedFlow(replay=0)。
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 读,实现离线缓存和单一数据源。
进阶补充:Hilt、Room、WorkManager 与 Compose 生命周期
Hilt 组件层级与作用域
常见层级:SingletonComponent → ActivityRetainedComponent → ViewModelComponent → 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。
**追问:**为什么 Hilt scope 不能乱用?因为对象生命周期过长会造成泄漏,过短会导致重复创建和状态丢失。
应用架构 - MVVM 与 MVI ★
你的重点短板,中级必考。 面试常问“你项目用什么架构?为什么?“,要答得出演进逻辑和取舍。
一、架构演进
| 架构 | 核心 | 痛点 |
|---|---|---|
| MVC | Activity 既是 View 又当 Controller | 职责混乱,Activity 臃肿(“上帝类”) |
| MVP | Presenter 持有 View 接口,逻辑抽离 | 接口爆炸、Presenter 持 View 易泄漏、需手动解绑 |
| MVVM | ViewModel 暴露可观察数据,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 就是来解决这个的。
三、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 / SideEffect:一次性事件(导航、Toast),用 Channel/SharedFlow 表达(不放进 State,避免重放)。
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。
高频面试题
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? State 是持久状态,旋转重建后会重新渲染,若把事件放进 State 会重复触发(重复弹 Toast/重复导航)。应用 Channel/SharedFlow(replay=0)表达一次性 Effect。
Q7:你项目用什么架构?为什么这么选?(开放题) 结合实际答:中小页面 MVVM 足够轻量;复杂交互/状态多的页面用 MVI 保证可预测性。强调“按复杂度选型“,并说明分层(UI/Domain/Data)、单一数据源、依赖注入带来的可测试性收益。
进阶补充:端到端架构、MVI 状态机与 Offline-first
推荐端到端分层
ui(Screen/Fragment/Composable)
-> ViewModel(State/Effect)
-> UseCase(业务规则)
-> Repository(数据协调)
-> Local/Remote DataSource(Room/Retrofit)
依赖方向只能向内/向下的抽象走,UI 不直接依赖 Retrofit/Room。
MVI Reducer
MVI 的核心是事件驱动状态变化:
data class LoginState(val loading: Boolean = false, val error: String? = null)
fun reduce(state: LoginState, event: LoginEvent): LoginState = when (event) {
LoginEvent.Submit -> state.copy(loading = true, error = null)
is LoginEvent.Failed -> state.copy(loading = false, error = event.message)
}
一次性 Effect
Toast、导航、弹窗不适合放进持久 State,通常用 Effect/Channel/SharedFlow 处理。
Offline-first
离线优先通常以本地数据库为 single source of truth,网络同步更新本地,UI 观察本地。难点是冲突解决、同步状态、失败重试。
何时 MVVM 足够,何时 MVI 过重
简单表单/详情页 MVVM 足够;复杂交互、多状态、多事件流页面适合 MVI。不要为了追新把所有页面都写成复杂 reducer。
**追问:**Repository 是不是越多越好?不是。Repository 应按数据/业务聚合边界划分,过细会变成无意义转发层。
App 架构落地案例
架构题不要只背 MVVM/MVI 名词。面试官更想听到:一个真实页面从 UI 到 ViewModel、UseCase、Repository、DataSource 怎么流动,异常、分页、表单、离线缓存怎么统一收口。
一、完整页面流: UI → ViewModel → UseCase → Repository → DataSource
以“商品列表 + 筛选 + 收藏 + 分页”为例,推荐链路是单向的:
Screen/Fragment/Composable
-> ViewModel: 接收 UI Intent,维护 UiState/Effect
-> UseCase: 封装业务规则,如筛选参数校验、收藏权限判断
-> Repository: 决定网络/缓存/数据库数据来源,合并多源
-> RemoteDataSource / LocalDataSource: Retrofit/Room/DataStore
核心原则:
- UI 不直接访问 Retrofit/Room,只渲染
UiState并发送用户意图。 - ViewModel 不写复杂数据来源选择,把业务规则交给 UseCase/Repository。
- Repository 对上层暴露稳定模型,隐藏网络 DTO、数据库 Entity 的差异。
二、统一 UI 状态: loading / error / empty / content
不要用多个互相独立的 Boolean 到处散落,推荐一个不可变 UiState 表达完整页面状态。
data class ProductListState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val items: List<ProductUi> = emptyList(),
val error: UiError? = null,
val hasMore: Boolean = true,
) {
val isEmpty: Boolean get() = !isLoading && items.isEmpty() && error == null
}
| 状态 | UI 怎么渲染 | ViewModel 怎么产出 |
|---|---|---|
| 首次 loading | 骨架屏/全屏 loading | isLoading=true, items=[] |
| content | 列表内容 | items 非空,error 清空 |
| empty | 空态文案 + 引导按钮 | 请求成功但数据为空 |
| error | 错误态 + 重试 | 首屏失败时设置 error |
| refresh error | 保留旧列表 + toast/snackbar | 用 Effect 提示,State 保留 items |
三、一次性 Effect: Toast、导航、弹窗
一次性事件不要放进持久 State,否则配置变更或重新收集可能重复触发。常见做法是 Channel 或 MutableSharedFlow(replay = 0)。
- State:页面长期状态,旋转后重新渲染也合理。
- Effect:一次性动作,如 Toast、导航、打开弹窗、提交成功返回。
- Intent/Action:用户输入,如刷新、点击、提交、加载更多。
怎么答:ViewModel 处理 Intent 后同时可能产出新 State 和一次 Effect;UI 分别 collectAsStateWithLifecycle() 渲染 State,用 LaunchedEffect 收集 Effect。
四、分页: 刷新、加载更多与错误恢复
分页不是简单 append,要区分首次加载、下拉刷新、加载更多失败。
- Refresh:重置页码/游标,请求第一页,成功后替换列表。
- LoadMore:使用下一页 key,成功后 append,失败时保留旧列表并显示底部错误。
- 去重:按业务 ID 去重,避免刷新和加载更多交叉导致重复。
- 并发控制:同一时间只允许一个分页请求,或用
collectLatest取消旧请求。
| 场景 | 推荐状态 | 面试追问点 |
|---|---|---|
| 首屏失败 | error != null, items=[] | 显示全屏错误,可重试 |
| 加载更多失败 | items 保留,loadMoreError 或 Effect | 不清空已有内容 |
| 无更多 | hasMore=false | 避免继续触发下一页 |
| 参数变化 | 清空旧分页并刷新 | 防止旧筛选结果混入 |
五、表单提交: 校验、幂等与防重复点击
表单页面适合体现 UseCase 价值。ViewModel 收集输入状态,UseCase 做业务校验和提交编排,Repository 执行网络/本地持久化。
- 本地校验:手机号、验证码、必填项在提交前快速反馈。
- 提交中状态:
submitting=true禁用按钮,防重复点击。 - 服务端错误:映射成字段错误或页面错误,不要直接把接口文案散落到 UI。
- 幂等:订单/支付/注册类提交要有 requestId 或服务端幂等键。
- 成功 Effect:提交成功通常是导航/Toast,用 Effect 发出。
怎么排查重复提交:看按钮是否禁用、ViewModel 是否丢弃 submitting 期间的新 Intent、接口是否具备幂等键。
六、Offline-first 与离线缓存
离线优先不是“网络失败读缓存”这么简单,更推荐本地数据库作为 Single Source of Truth:
UI 观察 Room Flow
Repository 触发网络刷新
Remote 成功 -> 写入 Room
Room 变化 -> UI 自动更新
Remote 失败 -> UI 保留本地数据 + Effect 提示
优点:页面旋转、进程重建、短暂断网都能稳定展示;Repository 统一控制刷新策略、过期时间、同步状态。难点是冲突解决、脏数据标记、失败重试和缓存淘汰。
七、多源数据合并
真实页面经常需要合并用户信息、配置、列表、埋点开关、缓存状态。Repository/UseCase 可以用 Flow 组合多源:
combine:多个数据源任一变化都重新产出 UI 模型。flatMapLatest:筛选条件变化时取消旧查询。zip:严格一一配对,业务上较少用于 UI 持续状态。- 本地 + 远端:本地先出首屏,远端刷新后写库再更新。
| 多源类型 | 合并位置 | 注意点 |
|---|---|---|
| 用户权限 + 菜单配置 | UseCase | 权限变化要触发 UI 重新计算 |
| Room 缓存 + 网络刷新 | Repository | 网络只更新库,UI 观察库 |
| 表单输入 + 服务端校验 | ViewModel/UseCase | 避免每个字符都打接口,加 debounce |
八、落地检查清单
一个页面架构是否靠谱,可以按这张表自查:
| 检查项 | 好的表现 | 坏味道 |
|---|---|---|
| 数据流 | UI 只发 Intent、收 State/Effect | 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/Effect;UseCase 封装业务规则;Repository 决定网络、本地缓存和多源合并;DataSource 只负责具体 Retrofit/Room/DataStore 操作。依赖方向单向,上层不直接感知底层实现。
Q2:loading、error、empty 怎么统一管理?
用一个不可变 UiState 表达完整页面,例如 isLoading/items/error/hasMore。首屏错误显示全屏错误;刷新失败保留旧数据并发 Effect;请求成功但列表为空显示 empty,避免多个 Boolean 分散导致状态互相矛盾。
Q3:Toast/导航为什么不放进 State? State 是持久状态,配置变更或重新收集会再次渲染。如果把 Toast/导航放进 State,可能重复弹出或重复跳转。一次性动作应放 Effect,用 Channel 或 SharedFlow(replay=0) 发送。
Q4:分页加载更多失败时怎么处理?
不要清空已有列表。保留 items,记录底部错误或发一次 Effect,允许用户重试当前 pageKey;同时用 hasMore/loadingMore 防止重复触发并发请求。
Q5:Offline-first 怎么落地? 以 Room 作为 Single Source of Truth,UI 观察本地 Flow;Repository 触发网络刷新,成功后写入 Room,UI 因数据库变化自动更新;失败时保留本地数据并提示。难点是冲突、过期策略和失败重试。
易错点 / 追问
- 不要让 UI 直接依赖 Retrofit/Room,否则页面难测、数据策略分散。
- 不要把一次性事件塞进 UiState,旋转屏幕或重新订阅会重复消费。
- 分页失败要区分首屏失败和加载更多失败,后者不能清空已有内容。
- UseCase 不是越多越好;简单 CRUD 可省略,复杂业务规则/复用逻辑再引入。
- Offline-first 的核心是本地单一数据源,不是简单“catch 网络异常后读缓存”。
测试体系
★ 测试体系是中级 Android 面试的短板高频区。能讲清“怎么测“,比只会说 MVVM/MVI 更能证明工程能力。
一、测试金字塔与 Android 测试分层
| 层级 | 目标 | 工具 | 典型对象 |
|---|---|---|---|
| 单元测试 | 快速验证纯逻辑 | JUnit / Truth / MockK | UseCase、Repository、Reducer |
| 集成测试 | 验证多层协作 | Robolectric / fake data source | ViewModel + Repository |
| UI 测试 | 验证用户路径 | Espresso / Compose Test | 页面交互、导航、错误提示 |
原则:越靠下越多、越快、越稳定;越靠上越少、越接近真实用户。
二、单元测试:Junit、断言与 Mock
- JUnit 负责组织测试生命周期。
- Truth/AssertJ 让断言可读。
- MockK/Mockito 用于隔离外部依赖,但不要 mock 一切。
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 的转换。
| 对象 | 测什么 | 不测什么 |
|---|---|---|
| ViewModel | state/effect、错误处理、重试 | 具体控件绘制 |
| Repository | 缓存策略、数据源切换 | Retrofit/Room 本身 |
| UseCase | 业务规则 | 外部 IO |
五、UI 测试:Espresso 与 Compose Test
- Espresso 适合 View 体系页面。
- Compose Test 适合声明式 UI,优先通过 semantic matcher 找节点。
- UI 测试要覆盖核心路径,不要把所有边界都堆在 UI 层。
六、可测试性如何反推架构质量
如果一个 ViewModel 很难测,通常说明它持有太多 Android 依赖、业务逻辑没有下沉、状态/副作用没有分离。
高频面试题
Q1:你项目里怎么做测试分层? 答:核心业务规则放单元测试,ViewModel/Repository 做集成测试,关键用户路径做 UI 测试。比例上单元测试最多,UI 测试最少。
Q2:为什么 Repository 不应该直接测 Retrofit/Room? 答:Retrofit/Room 是框架能力,业务测试应关注 Repository 的缓存、错误兜底、数据源切换。框架集成可少量用 integration test 验证。
Q3:协程测试为什么不用真实 delay?
答:真实 delay 会让测试慢且不稳定;runTest 用虚拟时间推进,可稳定验证超时、重试、debounce 等逻辑。
易错点 / 追问
- 不要为了覆盖率 mock 所有东西,那会测到实现细节。
- UI 测试不要依赖真实网络和随机数据。
- Compose 测试要给关键节点加稳定语义,否则 matcher 容易脆弱。
组件化路由与模块通信
组件化不是把包拆成很多 module,而是让业务边界、依赖方向和发布协作更清晰。中高级面试常追问路由、通信和依赖治理。
一、组件化解决什么问题
| 问题 | 组件化目标 |
|---|---|
| 业务耦合 | feature 边界清晰 |
| 编译慢 | 模块增量构建 |
| 多团队协作 | 独立开发/测试 |
| 依赖混乱 | 依赖方向可控 |
二、模块拆分方式
- app 壳工程:组装入口。
- feature module:业务功能。
- core/common:基础能力。
- api/contract:跨模块接口。
依赖方向应从上层依赖抽象,避免 feature 之间直接互相依赖。
三、路由框架原理(以 ARouter 类框架理解)
核心流程:编译期扫描注解 → APT/KSP 生成路由表 → 运行时按 path 查找目标 → 拦截器处理登录/权限/降级 → 跳转或返回服务实例。
interface UserService {
fun currentUserId(): String?
}
跨模块不要直接调用实现类,而是依赖 UserService 这样的 contract。
四、模块通信方式
| 方式 | 适用 | 风险 |
|---|---|---|
| 路由跳转 | 页面级跳转 | path 字符串错误 |
| 接口下沉 | 同步能力调用 | contract 膨胀 |
| 事件总线 | 广播式通知 | 难追踪、生命周期问题 |
| Result API | 页面结果回传 | 复杂链路难维护 |
五、路由拦截、降级与灰度
登录校验、权限校验、A/B 实验、页面不存在降级都适合放在拦截器链里。路由失败必须有兜底,不能直接 crash。
六、和 Gradle 工程化的关系
组件化设计关注业务边界;Gradle 工程化关注构建配置、依赖版本、产物和性能。两者相关但不是一回事。
高频面试题
Q1:组件化和模块化区别? 答:模块化偏代码物理拆分,组件化更强调业务组件独立开发、独立测试、按契约通信和可组装。
Q2:ARouter 这类框架大概怎么实现? 答:编译期注解处理生成路由表,运行时初始化加载,按 path 找目标 class/provider,再通过拦截器处理统一逻辑。
Q3:跨模块通信为什么不直接依赖实现? 答:直接依赖会导致 feature 耦合、编译依赖变重、循环依赖风险;接口下沉能保持依赖方向稳定。
易错点 / 追问
- 不要为了组件化制造过多小 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。
为什么主线程死循环不卡死/不 ANR? loop() 是死循环,但无消息时阻塞在 epoll_wait 让出 CPU,有事件(触摸、绘制)再唤醒。ANR 是消息处理太久,不是循环本身。
进阶:
- 同步屏障(Sync Barrier):插入屏障后,同步消息被拦截,优先处理异步消息(如 UI 绘制 doFrame),保证及时刷新。
- IdleHandler:队列空闲时回调,可做延迟初始化(不阻塞关键路径)。
二、Binder 机制
Android 跨进程通信(IPC)的核心,理解它能体现你的底层深度。
- 为什么用 Binder 而非传统 IPC? 性能:常见说法是一次拷贝(传统管道/socket 通常需要用户态/内核态多次拷贝)。安全:内核知道调用方 UID/PID,服务端可做权限校验。
- 一次拷贝原理(实现层面直觉):Binder 驱动为进程维护映射区,传输时把发送方 Parcel 数据拷贝到驱动管理的
binder_buffer;接收进程可通过映射区读取,从而少一次“内核缓冲区 → 接收方用户空间”的显式拷贝。不同 Android 版本的驱动细节可能调整,面试回答用“当前主流实现/实现层面”表述更稳。 - 结构:Client、Server、ServiceManager(类似 DNS,管理服务注册查找)、Binder 驱动(/dev/binder,核心)。
- 代理模式:Client 拿到的是 BinderProxy,调用经驱动转发到 Server 的 Binder 实体。AIDL 自动生成这套 Stub/Proxy 代码。
一次 Binder 调用怎么走
- Client 调用 AIDL 生成的 Proxy 方法,把参数写入
Parcel。 transact()进入 Binder 驱动,驱动根据 handle 找到目标 Binder 实体和目标进程。- 目标进程的 Binder 线程池取到事务,回调 Stub 的
onTransact()反序列化参数并执行服务端方法。 - 同步调用会把返回值写回 reply Parcel,Client 线程在结果返回前阻塞;
oneway调用不等待业务返回,但仍受队列和线程调度影响。
| 面试追问 | 稳妥回答 |
|---|---|
| “Binder 真的零拷贝吗?” | 不是。普通 Binder 事务常说“一次拷贝”,不是零拷贝;大块数据应走共享内存/文件描述符等方案。 |
| “能传多大数据?” | Android 文档提到 Binder transaction buffer 当前约 1MB,且是进程内并发事务共享;工程上要把单次 IPC 控制得更小,避免 TransactionTooLargeException。 |
| “为什么容易死锁?” | 同步 Binder 会阻塞调用线程,服务端也可能回调客户端。持锁发 Binder 或主线程发耗时 Binder,都可能导致锁等待/ANR。 |
| “AIDL 和 Binder 的关系?” | AIDL 是代码生成工具,生成 Stub/Proxy/Parcel 编解码;底层事务仍通过 Binder 驱动。 |
三、应用启动流程
点击图标到界面显示的大致链路:
- Launcher 捕获点击,通过 Binder 调用系统服务请求启动 Activity。Android 10 之后 Activity 启动职责更多落在 ATMS(ActivityTaskManagerService),但面试可把 AMS/ATMS 作为同一条系统调度链讲清楚。
- 系统检查目标进程是否存在,不存在则经 Zygote 连接发送 fork 命令;历史和实现细节里常见 socket/zygote command 描述,不要把具体通信细节当公开 API 契约。
- Zygote fork 出应用进程,子进程执行
ActivityThread.main(),创建主线程 Looper。 - 新进程通过 Binder 向系统
attachApplication,系统再回调创建 Application 和目标 Activity。 - 执行
Application.onCreate → Activity.onCreate → onResume,ViewRootImpl 触发首帧绘制。
Zygote:所有应用进程的“母体“,预加载了核心类和资源,fork 时通过 COW(写时复制)共享,加速启动、省内存。
冷启动拆解与可优化点
| 阶段 | 机制 | 面试可讲的优化边界 |
|---|---|---|
| 进程创建 | Zygote fork,继承预加载类/资源,COW 降低重复加载成本 | App 无法直接优化 fork 本身,重点是减少 fork 后主线程工作。 |
ActivityThread.main() | 准备主线程 Looper,进入消息循环 | 主线程消息循环不是卡顿来源,卡顿来自消息处理超时。 |
bindApplication | 创建 Application、加载 Provider、初始化 SDK | 收敛 ContentProvider 自启动,延迟/按需初始化 SDK。 |
| Activity 首帧 | 创建 Activity、inflate、measure/layout/draw | 减少首屏布局层级和同步 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,再处理主题/资源 ID | addAssetPath 属实现层面方案,隐藏 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 改动运行时内部结构,要用设备矩阵验证,不要承诺“所有版本通用”。
- 热修复适合止血,根因仍要通过正常发版修复;插件化适合业务动态化,不应绕过系统权限和安装模型。
五、进程间通信方式对比
| 方式 | 特点 | 场景 |
|---|---|---|
| Binder | 一次拷贝、安全、面向对象 | Android 主流 IPC、AIDL |
| Socket | 通用、两次拷贝、慢 | 跨设备、Zygote 通信 |
| 共享内存 | 零拷贝、最快、需同步 | 大数据(匿名共享内存 Ashmem) |
| 管道/消息队列 | 传统 Linux IPC | 较少用 |
| 文件/ContentProvider | 简单、慢 | 持久化数据共享 |
高频面试题
Q1:主线程 Looper 死循环为什么不会卡死 App? loop() 是死循环,但 MessageQueue 无消息时通过 epoll 阻塞,让出 CPU 不占用资源;有消息(触摸/绘制等)再唤醒处理。ANR 是单条消息处理太久,不是循环本身导致。
Q2:Handler 内存泄漏怎么产生?如何避免? 非静态内部类 Handler 隐式持有外部 Activity,若有延迟消息未处理,Activity 无法回收。解决:静态内部类 + 弱引用,或在 onDestroy 中 removeCallbacksAndMessages(null)。
Q3:Binder 为什么只需一次拷贝?
实现层面可理解为 Binder 驱动维护映射区和 binder_buffer,发送方 Parcel 数据拷贝到驱动缓冲后,接收方通过映射区读取,省去一次显式拷贝。它不是零拷贝,且事务缓冲有大小限制,大数据要走共享内存/文件描述符等方案。
Q4:为什么用 Binder 而不是传统 IPC? 性能:一次拷贝优于管道/socket 的两次。安全:内核自动附带可靠的 UID/PID,支持身份鉴权,传统 IPC 需自行在数据层鉴权。
Q5:Zygote 的作用?为什么用 fork? Zygote 预加载核心类库和资源,作为所有应用进程的母体。fork 通过写时复制共享已加载内容,加速进程创建、减少内存占用(避免每个进程重复加载framework)。
Q6:热修复的基本原理?
利用类加载机制,把补丁 dex 插入 ClassLoader 的 dexElements 数组前面,使补丁类优先于有 bug 的旧类被加载。属于“类加载方案”,通常需要重启或确保旧类尚未加载;另有 native 方法替换方案,但强依赖 ART 内部结构,兼容风险更高。
Q7:ThreadLocal 在 Looper 里起什么作用? 保证一个线程只有一个 Looper。Looper.prepare 把 Looper 存入 ThreadLocal,各线程独立互不干扰,getMainLooper 拿主线程的。
Binder 与 IPC 深入
Binder 是 Android 系统面试里最能拉开层次的题:不要只背“一次拷贝”,要能把 mmap、ServiceManager、AIDL、线程池和大数据边界讲成一条工程链路。
一、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 等。 |
三、ServiceManager 与服务注册查找
ServiceManager 是 Binder 世界里的特殊服务,负责把服务名映射到 Binder handle。
- SystemServer 创建系统服务,把 Binder 实体注册到 ServiceManager。
- Client 通过服务名查询,拿到的是远端 Binder 的代理对象。
- 后续调用不再直接找服务名,而是通过 handle 让 Binder 驱动路由事务。
怎么答得更工程化:ServiceManager 解决“我怎么拿到远端服务入口”的问题;Binder 驱动解决“这个入口怎么跨进程调用”的问题;AIDL 解决“业务接口怎么自动生成序列化和代理代码”的问题。
四、AIDL、Messenger 与 ContentProvider IPC
不同 IPC 方式适合不同粒度,不要把 AIDL 当成唯一答案。
| 方式 | 本质 | 适合场景 | 注意点 |
|---|---|---|---|
| AIDL | 编译期生成 Stub/Proxy,底层 Binder | 多方法、强类型、需要回调或并发调用的服务 | 接口版本兼容、线程安全、权限校验 |
| Messenger | Handler + Binder 封装,消息串行处理 | 简单命令、低并发、只需传 Message | 单线程串行,不适合高吞吐 |
| ContentProvider | 系统组件 + Binder,以 URI 暴露数据 | 跨应用共享结构化数据、文件流 | 权限、URI 授权、查询不要阻塞主线程 |
AIDL 调用链
.aidl定义接口和 Parcelable 数据类型。- 编译器生成
Stub、Proxy、onTransact()、asInterface()。 - Client 调用 Proxy 方法,参数写入 Parcel。
- 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
七、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 字段只增不乱改语义 | 老新版本互调测试 |
高频面试题
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 是并发共享的。
易错点 / 追问
- 把 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 常驻后台。
三、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、自旋开销 |
private val running = AtomicBoolean(false)
fun startOnce() {
if (running.compareAndSet(false, true)) {
// only one caller can enter
}
}
六、死锁与线程安全排查
死锁通常满足互斥、持有并等待、不可抢占、循环等待。Android 里高频场景是“主线程等后台锁,后台线程切回主线程”或“持锁发同步 Binder”。
排查路径:
- 抓 ANR traces 或线程 dump,看主线程卡在哪把锁/哪个 Future/Binder。
- 找持锁线程,看它是否等待主线程、IO、网络或另一个锁。
- 检查锁顺序是否全局一致,是否在锁内做耗时操作。
- 用超时、tryLock、缩小临界区或串行队列替代复杂嵌套锁。
怎么答:不要只说“加锁解决线程安全”,还要补“锁粒度、锁顺序、锁内不做耗时/跨进程调用”。
七、线程池导致 ANR/OOM 的坑
线程池配置错会从“优化”变成“事故”。
- ANR:主线程等待线程池结果,但线程池被长任务占满;或回调切主线程后主线程被阻塞。
- OOM:无界队列堆积大量 Runnable/闭包持有 Activity/Bitmap;线程数过多导致栈内存暴涨。
- 优先级反转:低优先级长任务占满池,高优先级 UI 相关任务排队。
- 任务泄漏:页面销毁后任务仍持有 View/Context。
| 坑 | 规避方式 |
|---|---|
newCachedThreadPool 滥用 | 限制最大线程数,按任务类型隔离线程池 |
| 无界 LinkedBlockingQueue | 设置有界队列和拒绝策略 |
主线程 Future.get() | 用回调/协程挂起,不要阻塞主线程 |
| IO/CPU 混用一个池 | IO 与 CPU 任务隔离,避免互相饿死 |
八、并发设计落地模板
面试讲项目时可以按这个模板说明并发方案:
- UI 事件进入 ViewModel,用协程保证生命周期自动取消。
- Repository IO 任务切到
Dispatchers.IO,CPU 计算切到Default。 - SDK 内部串行状态用 HandlerThread 或单线程 Executor。
- 共享状态用不可变快照、Atomic 或小粒度锁。
- 线程池设置有界队列、命名线程、异常日志和拒绝策略。
高频面试题
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 对体积/性能极度敏感,你的经验比一般应用开发者更有说服力。
一、启动优化
启动分类:
- 冷启动:进程不存在,需创建进程 + Application + Activity,最慢。
- 温启动:进程在但 Activity 重建。
- 热启动:Activity 还在,仅恢复,最快。
冷启动耗时点 + 优化:
- Application.onCreate 过重:第三方 SDK 初始化阻塞。优化:异步初始化、延迟初始化、按需初始化(用 Jetpack App Startup 统一管理依赖顺序)。
- 闪屏页(Splash):用 Android 12+ SplashScreen API,避免白屏/黑屏。
- 主线程 IO:首屏不读大文件/SP。
- 测量:
adb shell am start -W看 TotalTime;Perfetto/Systrace 看主线程占用。 - 进阶:类预加载、ContentProvider 初始化收敛(很多 SDK 用 ContentProvider 自启,拖慢启动)。
二、内存优化
内存泄漏(常见场景)
- 非静态内部类/匿名类持有外部 Activity(Handler、Runnable、监听器)。
- 静态变量持有 Context/View。
- 单例持有 Activity Context(应用 Application Context)。
- 未注销监听器/广播/观察者。
- 资源未关闭(Cursor、Stream、Bitmap)。
LeakCanary 原理(以 LeakCanary 2.x 思路理解):默认会 watch Activity、Fragment、Fragment View、ViewModel 等应被回收的对象。对象销毁后交给 ObjectWatcher,内部用弱引用和引用队列观察;延迟一小段时间后触发/等待 GC,若对象仍未被回收,就认为是 retained object。达到阈值后 dump HPROF,再由 Shark 分析 GC Roots 到泄漏对象的最短强引用链。
| 步骤 | 看到的证据 | 面试边界 |
|---|---|---|
| watch 销毁对象 | 对象带 description 和 watch time | 不是所有 retained 都是业务泄漏,可能是系统/库已知泄漏或调试期延迟。 |
| 等待 + GC | 对象没有进入 ReferenceQueue | GC 触发时机不是业务可精确控制的 API,不要说“立刻判定”。 |
| heap dump | HPROF 文件、分析耗时 | dump 会暂停进程/占内存,通常只在 debug 或内测环境开。 |
| Shark 分析 | 引用链、Library Leak 标记 | 结论要结合生命周期判断,不是看到 Activity 就机械删除引用。 |
内存抖动
短时间大量创建小对象(如 onDraw / 循环里 new),频繁 GC 导致卡顿。优化:对象复用、避免在高频路径分配、用对象池。
三、卡顿与 ANR
掉帧原理
屏幕 60fps → 每帧 16.6ms 内必须完成 measure/layout/draw。超时就丢帧(jank)。Choreographer 接收 VSync 信号驱动每帧渲染,可监控帧耗时。
卡顿优化
- 布局优化:减少层级(ConstraintLayout 扁平化)、
<merge>/<ViewStub>、避免过度绘制(overdraw)。 - 主线程不做耗时(IO、解析、复杂计算),用协程切后台。
- RecyclerView:复用、DiffUtil、避免在 onBindViewHolder 做重活、预取。
ANR(Application Not Responding)
触发条件:
- 主线程阻塞:常见记忆口径是 Input 事件约 5s 未响应、前台 BroadcastReceiver 约 10s、后台广播约 60s、前台 Service 约 20s、后台 Service 约 200s。但这些阈值会受 Android 版本、前后台状态、组件类型和厂商策略影响,面试要说“常见阈值/典型场景”,不要当成所有版本的硬契约。 定位:
/data/anr/traces.txt看主线程堆栈。- 看是否主线程 IO、锁等待、Binder 阻塞、死锁。
- 工具:ANR-WatchDog、线上监控(主线程卡顿监控)。
卡顿/ANR 排查链路
| 症状 | 可能原因 | 工具 | 关键证据 | 修复方向 | 取舍 |
|---|---|---|---|---|---|
| 冷启动白屏久 | Application/Provider 初始化重、主线程 IO、首屏 inflate 慢 | am start -W、Android Studio Startup/Profiler、Perfetto | TotalTime 增大、main thread 上长任务、bindApplication/launchActivity slice 慢 | 延迟/按需初始化、Provider 收敛、首屏最小化 | 延迟初始化可能把耗时转移到首个功能入口,要配合预热和埋点。 |
| 滑动掉帧 | onBind 重活、图片解码过大、布局层级深、频繁 GC | Profiler、Perfetto Frame Timeline、Layout Inspector | 帧耗时超过预算、UI thread/RenderThread slice 长、GC 频繁 | DiffUtil、异步解码、固定尺寸、减少层级/overdraw | 过度缓存会增加内存,异步化要处理取消和错位。 |
| 偶发长卡顿 | 主线程锁等待、同步 Binder、磁盘 IO、类加载 | Perfetto/Systrace、StrictMode、线程 dump | main thread blocked/runnable 状态、binder transaction、disk read/write | 缩小锁范围、后台线程、缓存预热、异步 IPC | 后台化不等于无限开线程,要限制并发和生命周期。 |
| ANR | 主线程长时间不取消息、广播/服务超时、死锁 | ANR traces、Perfetto、日志埋点、线上 ANR 平台 | traces 中 main 堆栈、锁 owner、Binder 对端、CPU/IO 状态 | 移除主线程阻塞、拆分任务、避免持锁跨 Binder | 只看 main 堆栈可能误判,还要看 CPU 饥饿和对端进程。 |
| 内存泄漏 | Activity/Fragment 被静态对象、监听器、协程持有 | LeakCanary、Memory Profiler | retained object、GC Root 引用链、实例数量持续上涨 | 解绑监听、用 application context、取消协程/请求 | 弱引用不是万能解,优先修正生命周期所有权。 |
四、包体积优化
- 代码:R8/ProGuard 混淆 + 裁剪无用代码(shrinkResources)。
- 资源:无用资源删除、图片用 WebP、矢量图、资源混淆(AndResGuard)。
- so 库:按 ABI 拆分(abiFilters 通常只留 arm64-v8a)、动态下发。这是你的强项——SDK 开发对 so 体积敏感。
- dex:移除未用依赖、避免方法数膨胀。
- App Bundle(aab):按设备下发,Google Play 强制。
五、工具链
- Android Studio Profiler:适合开发期快速看 CPU / Memory / Network / Energy。证据是方法耗时、分配热点、网络请求时间线;局限是插桩/采样会有开销,线上问题要结合 trace/埋点。
- Memory Profiler:看 Java/Kotlin 堆、对象数量、分配调用栈、dump HPROF。用于“内存上涨/频繁 GC/疑似泄漏”,修复后要对比同一路径的实例数量是否回落。
- Perfetto / Systrace:系统级 trace,看 main thread、RenderThread、Binder、锁、调度、Frame Timeline。Perfetto 是现代首选;Systrace 常用于老项目/旧资料口径。证据是具体 slice 和线程状态,不是“感觉变流畅”。
- LeakCanary:自动发现 retained objects 并给出引用链。适合 debug/内测定位泄漏;不要在生产默认开启 heap dump。
- Layout Inspector:看视图层级、约束、重叠与 Compose/View 混合结构。适合布局层级深、过度绘制、首屏 inflate 慢的问题。
- StrictMode:开发期检测主线程磁盘/网络、资源未关闭等。适合把“偶发卡顿”提前暴露;惩罚策略可从 log 开始,不要在生产随意 crash 用户。
工具选择速答表
| 你观察到 | 先用 | 再用 | 面试表达 |
|---|---|---|---|
| “启动慢” | am start -W 定量 | Perfetto 找 bindApplication/首帧长任务 | 先量化,再定位主线程 slice,最后验证优化前后。 |
| “滑动卡” | Frame Timeline/Profiler | Layout Inspector + 图片库日志 | 区分 UI 线程、RenderThread、GC、图片解码。 |
| “内存涨” | Memory Profiler | LeakCanary/heap dump | 看趋势和引用链,不要只看一次快照。 |
| “线上 ANR” | ANR traces/平台聚合 | Perfetto/埋点复现 | 主线程栈 + 锁/Binder/CPU 状态一起看。 |
| “怀疑主线程 IO” | StrictMode | Perfetto disk slice | 开发期用 StrictMode 前置发现,trace 证明耗时。 |
高频面试题
Q1:冷启动优化有哪些手段?
异步/延迟/按需初始化 SDK、收敛 ContentProvider 自启动、避免主线程 IO、用 SplashScreen API、类与资源预加载。用 am start -W 和 Perfetto 量化。
Q2:内存泄漏常见场景?LeakCanary 怎么检测的? 非静态内部类/Handler 持 Activity、单例持 Context、未注销监听、资源未关。LeakCanary 2.x 可理解为 ObjectWatcher 监听销毁对象,用弱引用+引用队列观察是否回收;延迟和 GC 后仍 retained,再 dump HPROF 并用 Shark 分析引用链。结论要结合生命周期,不要把所有 retained 都当业务泄漏。
Q3:为什么 16.6ms 是关键?掉帧怎么排查? 60fps 下每帧预算 16.6ms,超时丢帧。用 Choreographer 监控帧耗时,Profiler/Perfetto 看主线程哪段超时,优化布局层级和主线程耗时操作。
Q4:ANR 触发条件?怎么定位? 主线程阻塞超阈值会触发 ANR,常见口径是输入约 5s、前台广播约 10s、前台 Service 约 20s,后台场景通常更长,但具体阈值和触发条件随版本/前后台/厂商策略变化。定位看 ANR traces 主线程堆栈,结合锁 owner、Binder 对端、CPU/IO 状态,排查主线程 IO、锁竞争、死锁、同步 Binder。
Q5:包体积优化怎么做?(你的强项,展开答) R8 裁剪混淆、资源压缩、WebP/矢量图、so 按 ABI 拆分(只留 arm64-v8a)+ 动态下发、移除无用依赖、用 App Bundle 按需下发。结合 SDK 经验讲 so 瘦身和符号裁剪更有说服力。
Q6:RecyclerView 卡顿如何优化? DiffUtil 局部刷新、onBindViewHolder 不做耗时、setHasFixedSize、预取(prefetch)、图片异步加载、减少 item 布局层级、复用 ViewHolder。
Q7:Serializable 和 Parcelable 哪个对性能更好,为什么? Parcelable。Serializable 用反射、产生大量临时对象触发 GC;Parcelable 专为内存/IPC 设计,无反射,速度快约一个数量级。
启动优化专项
启动速度是用户对 App 的第一印象。作为 SDK 开发者,你需要知道如何做到既不拖累宿主,又能精准测量性能瓶颈。
一、启动分类与指标
启动可划分为三种典型场景,各自的耗时与指标不同:
- 冷启动(Cold Start):最慢。系统为 App 创建进程 -> 实例化 Application -> 实例化首个 Activity -> 渲染第一帧。
- 温启动(Warm Start):较快。App 进程存在,但 Activity 被回收需重新创建,或者用户从退出后返回重新将后台转前台并重建视图。
- 热启动(Hot Start):最快。App 进程和 Activity 都在后台,仅将后台应用切到前台。
关键监控指标:
- 首帧时间 (TTID):首个可见 Activity 的第一帧渲染完成的时间。
- 可交互时间 (TTFI):主线程空闲,用户可以开始操作界面的时间。
二、Application 初始化治理
很多冷启动问题源头是 Application.onCreate 堆积了过多第三方 SDK 的初始化操作。
延迟与异步初始化
并非所有 SDK 都必须在 Application.onCreate 主线程同步初始化:
- 异步初始化:网络库、日志库等可在子线程初始化。
- 延迟/按需初始化:地图、分享等 SDK,等用户真正点击进入相关页面前再初始化。
Jetpack App Startup
用来管理库初始化的官方方案:
- 原理:利用一个统一的
ContentProvider集中分发和按序初始化各组件。 - 优势:减少多个第三方 SDK 各自带
ContentProvider自启带来的性能损耗(每个 Provider 的创建和调用都有 IPC 与生命周期开销)。
三、ContentProvider 自动初始化陷阱
很多 SDK(如早期的 Firebase、WorkManager 等)为了对开发者透明,悄悄在库内声明 ContentProvider。
- 问题:系统启动应用时,会先实例化所有声明的 Provider,再调
Application.onCreate。过多 Provider 会严重拖慢启动。 - 解法:通过
tools:node="remove"在 manifest 中剔除不需要自启的 Provider,转为手动在适当的时机初始化。
四、排查路径与流程(本地 vs 线上)
本地诊断与线上监控是有本质区别的:
| 阶段 | 关注点 | 工具 / 手段 |
|---|---|---|
| 本地诊断 | 找出具体的耗时方法与阻塞点 | adb shell am start -W 定量、Android Studio Profiler (CPU/Trace)、Perfetto / Systrace |
| 线上监控 | 掌握宏观大盘数据与用户体验情况 | Macrobenchmark、自定义埋点上报 TTID/TTFI、APM 平台聚合数据 |
定位流程:
- 定量分析:本地通过
adb shell am start -W 包名/Activity获取ThisTime和TotalTime,或线上看 APM 启动耗时分位数。 - 抓取 Trace:使用 Perfetto 抓取启动阶段的 trace 文件。
- 分析主线程 (Main Thread):在 Perfetto 中查看主线程的 slice,寻找导致阻塞的长任务(如文件 IO
Open/Read、锁等待、长计算等)。 - 改造与验证:针对长任务进行异步、懒加载改造,再用 Macrobenchmark(避免 JIT 与系统状态差异)进行 A/B 对比测试。
五、SplashScreen API (Android 12+)
Android 12 引入了标准的 SplashScreen API,应用冷/温启动时系统会自动显示闪屏。
- 优势:规范了启动体验,消除了应用自己实现闪屏页带来的白屏/黑屏,且首帧可立即进入业务 Activity,减少中间 Activity 跳转耗时。
- 适配:通过配置主题属性(如
windowSplashScreenBackground、windowSplashScreenAnimatedIcon)修改系统闪屏样式,或使用androidx.core:core-splashscreen兼容低版本。
高频面试题
Q1: 冷启动的整个系统流程是怎样的? 从点击桌面图标 -> Launcher 通过 Binder 通知 AMS -> AMS 创建进程请求 -> Zygote fork 进程 -> ActivityThread.main -> 实例化 Application -> 实例化 Activity -> setContentView -> 渲染完成第一帧。
Q2: 为什么有些第三方 SDK 接入后会拖慢冷启动,即使用户还没调用它?
可能是 SDK 内部通过 ContentProvider 实现了自动初始化。系统在进程创建后、调用 Application.onCreate 之前,会先实例化并调用所有注册 Provider 的 onCreate,若里面有耗时操作或 IO,就会阻塞启动。
Q3: 如何用工具定位启动慢的具体代码行? 使用 Perfetto 或 Android Studio CPU Profiler (Call Chart/Flame Chart) 录制启动阶段。如果是代码插桩模式能看到具体方法耗时,如果是 System Trace 模式,重点看 Main Thread 上的长耗时 Slice 和线程状态(Blocked/Runnable 等)。
易错点 / 追问
- 易错点: 把异步初始化当万能药。如果首屏 UI 恰好强依赖某个被异步到子线程初始化的数据/SDK,会导致数据还没准备好 UI 已经展示,或产生竞态崩溃。
- 追问: 如果启动优化已经把能异步的都异步了,还能怎么优化?(答:类预加载、资源预加载、减少首屏布局层级/延迟加载非首屏 Fragment、甚至更底层的 PGO 等技术)。
- 误区: 线上监控直接使用
System.currentTimeMillis()算差值。线上容易受到系统休眠、时间跳变影响,建议用SystemClock.uptimeMillis()。
内存优化与泄漏排查
内存问题直接影响 App 的存活率(OOM)和流畅度(频繁 GC 导致卡顿)。对于底层背景开发者,native 内存泄漏排查会是很好的加分项。
一、常见的内存泄漏场景
内存泄漏(Memory Leak)的本质是:长生命周期的对象(如单例、静态变量、系统级线程)持有了短生命周期对象(如 Activity/Fragment)的强引用,导致短生命周期对象无法被 GC 回收。
Java / Kotlin 泄漏重灾区
- 静态变量 / 单例:静态变量持有 Activity 的 Context 或 View 实例。
- 非静态内部类 / 匿名类:如
Handler延时消息未移除、Runnable在子线程还在跑,它们隐式持有外部类的引用。 - 未注销监听器:广播接收器
BroadcastReceiver、EventBus 等未在onDestroy注销。 - 协程 / Flow:未跟随组件生命周期(如在 Activity
onDestroy后协程仍在运行),或全局ViewModel持有了 UI 元素。 - 资源未关闭:
Cursor、Stream、FileDescriptor没有close,或者Bitmap未及时recycle。
二、内存抖动与 GC 机制
- 内存抖动 (Memory Churn):短时间内频繁创建大量临时小对象(如在
onDraw或大循环内new对象),导致可用内存迅速耗尽,触发频繁 GC。 - 影响:Dalvik/ART 的 GC 过程可能会产生应用停顿(Stop-The-World,尽管 ART 有并发 GC,但仍有停顿和 CPU 开销),从而引发界面卡顿甚至掉帧。
- 优化:使用对象池复用对象,避免在高频调用的路径(如 UI 渲染)中进行堆分配。
三、Bitmap 与 Native 内存
现代 Android (8.0+) 的 Bitmap 像素数据存放于 Native 内存中,这大大缓解了 Java 堆 OOM 的压力,但也带来了 Native 泄漏的风险。
- Native OOM:当系统总可用物理内存不足或虚拟地址空间耗尽时,Native 层同样会发生分配失败(OOM)。
- 排查手段:Native 内存的追踪较难,可以通过
malloc_debug或HWASan/ASan等底层工具进行内存越界和泄漏排查。
四、排查路径与流程(本地 vs 线上)
诊断策略需区分开发期和生产环境:
| 诊断层级 | 工具与手段 | 适用场景 |
|---|---|---|
| 本地定位 | LeakCanary、Memory Profiler、MAT | 开发期主动发现泄漏、详细分析引用链 |
| 线上监控 | 内存阈值报警、KOOM(快手开源的线上 OOM 治理)、APM 平台 | 捕获真实用户场景 OOM、轻量级 dump 和裁剪上报 |
泄漏排查流程:
- 现象发现:观察到应用内存占用持续走高或收到 OOM 奔溃报告。
- 触发 Dump:在开发阶段,利用 Memory Profiler 点击强制 GC 并抓取 Java Heap (hprof)。LeakCanary 会在后台对象保留超时后自动 dump。
- 寻找 GC Roots:将 hprof 导入 Memory Profiler 或 MAT (Memory Analyzer Tool)。寻找怀疑泄漏的对象实例(如
MainActivity)。 - 分析引用链 (Reference Chain):查看从 GC Root 到该泄漏实例的最短强引用路径。
- 切断引用:修改代码,将该路径上的长生命周期引用置为 null、改用弱引用 (
WeakReference),或在合适的生命周期节点手动解绑。
五、LeakCanary 原理剖析
LeakCanary 是如何自动发现泄漏的?
- Hook 生命周期:自动注册
Application.ActivityLifecycleCallbacks(或利用 Fragment/ViewModel 钩子)监听对象销毁。 - 弱引用观察:对象销毁后,用它构建一个带有
ReferenceQueue的弱引用,交由ObjectWatcher观察,并标记个超时时间(如 5s)。 - 判断回收:超时后强制触发一次 GC,如果该弱引用没有出现在
ReferenceQueue中,说明对象还被强引用,标记为retained。 - 抓取分析:若
retained对象数达阈值,dump 整个 hprof 文件,交由内嵌的 Shark 库解析 hprof,找出最短强引用链并发送通知。
高频面试题
Q1: 内部类一定会导致内存泄漏吗? 不一定。只有非静态内部类才会隐式持有外部类的引用。且只有当内部类的生命周期长于外部类时(比如被 static 变量引用,或跑在一个没结束的子线程里),才会造成外部类无法释放,形成泄漏。静态内部类不持有外部类引用。
Q2: Handler 导致的内存泄漏怎么解决?
将 Handler 声明为静态内部类,内部通过 WeakReference<Activity> 持有 Activity 以便更新 UI。更关键的是,在 Activity onDestroy 中调用 handler.removeCallbacksAndMessages(null) 清除所有未执行的消息和任务。
Q3: Android 8.0 之后 Bitmap 内存存在哪里?如果 OOM 了怎么排查? 存放于 Native 内存中。Java 层面的 OOM (OutOfMemoryError) 和 Native 内存溢出不同。排查时可以抓取 hprof,看是不是应用中有大量的 Bitmap 引用没有被回收。线上可以用 KOOM 等库,利用 fork 子进程的方式 dump 内存,减少对主进程的影响。
易错点 / 追问
- 误区: “使用弱引用可以解决所有内存泄漏”。很多时候泄漏是因为生命周期管理错乱,比如网络请求没取消导致回调还在等。粗暴地改成弱引用可能掩盖逻辑漏洞(回调突然没反应了)。正确的做法是绑定生命周期及时取消任务。
- 追问: LeakCanary 会影响生产环境的性能吗?(答:会,抓取 hprof 时进程会发生长达数秒的冻结,且分析过程耗 CPU 和内存,所以通常只在 debug 环境或灰度阶段启用。)
- 易错点: 分析 MAT 时死盯所有引用链。只需关注强引用 (Strong Reference),软/弱/虚引用不会阻止对象被垃圾回收。
ANR 与卡顿排查
ANR (Application Not Responding) 和卡顿 (Jank) 是导致用户流失的直接原因。作为中高级开发者,仅仅知道 ANR 的触发时间是不够的,必须具备看懂 traces.txt 和系统调度的能力。
一、ANR 的本质与类型
ANR 的触发条件往往被记忆为几个固定阈值(如输入事件 5s),但底层本质是:主线程无法在系统规定的时间预算内,响应系统派发的特定事件或完成相应的生命周期调度。
常见的 ANR 类型:
- KeyDispatchTimeout:输入事件(点击/触摸)超过 5 秒无响应。
- BroadcastTimeout:前台广播 10 秒,后台广播 60 秒未执行完毕。
- ServiceTimeout:前台 Service 20 秒,后台 Service 200 秒未启动/绑定完成。
- ContentProvider:
publish超时(通常为 10 秒)。
二、ANR / 卡顿的核心诱因
ANR 和卡顿(丢帧)在表现上严重程度不同,但诱因往往相似——主线程被占据。主要原因可分为三大类:
- 主线程做耗时操作:如直接在主线程解析大 JSON、读写 SharedPreferences (尤其是老版本)、或者加载庞大的布局导致 measure/layout 时间过长。
- 锁等待 (Lock Wait) 与死锁:主线程等待工作线程释放某个锁,而工作线程迟迟不释放;或者主线程与工作线程互相等待对方的锁(Deadlock)。
- 系统层面阻塞:
- Binder 阻塞:主线程发起同步 IPC 调用,对端进程卡住或系统负载过高导致超时。
- CPU 饥饿:后台大量高优先级线程抢占 CPU,或者系统整体处于高负载,分配给当前 App 主线程的时间片极少。
三、ANR 与卡顿 (Jank) 的区别联系
- 卡顿 (Jank):主线程并未完全死锁,只是单次处理事件/渲染耗时超过了 16.6ms(60Hz 屏幕),导致丢帧。用户觉得“不跟手”。
- ANR:主线程彻底罢工或执行超长任务(如长达 5 秒甚至更久),完全失去响应能力。
- 联系:持续的严重卡顿,若碰巧阻塞了系统的输入事件或广播调度,就会升级为 ANR。
四、排查路径与流程(本地 vs 线上)
解决此类问题同样依赖不同阶段的针对性工具。
| 环境 | 分析目标 | 工具使用 |
|---|---|---|
| 本地定位 | 发现掉帧原因、死锁确认 | StrictMode、Systrace/Perfetto、Android Studio Profiler |
| 线上监控 | 大盘 ANR 聚类、卡顿率统计 | ANR-WatchDog、线上监控平台、抓取 /data/anr/traces.txt 提取上报 |
ANR 定位流程:
- 获取 Traces 文件:当 ANR 发生时,系统会生成
traces.txt(位于/data/anr/下,高版本可能聚合成了 Bugreport 格式)。 - 分析 Main Thread 堆栈:打开 traces 文件,首先寻找
main线程的调用栈。- 状态是
Runnable?可能是死循环或大计算量代码。 - 状态是
Blocked?说明在等锁,需要顺藤摸瓜找当前占有这把锁的线程(held by owner)。
- 状态是
- 结合 CPU / IO 状态:看 traces 头部,检查当时的 CPU 负载(User/System/IOWait)。如果 IOWait 极高,说明主线程可能阻塞在磁盘操作;如果 CPU 总体负载达到 100%,哪怕主线程是简单的运算也会因分配不到时间片而 ANR。
- 验证 Binder 对端:如果卡在
BinderProxy.transact,需分析当时的系统服务或其他进程状态是否异常。
五、ANR-WatchDog (线上监控思路)
线上由于权限限制,难以直接去 /data/anr/ 拉取完整的 traces。
常见的应用层监控手段是基于 WatchDog 的思路:
- 启动一个后台线程(WatchDog 线程)。
- 每隔固定时间(如 5 秒),向主线程的消息队列
MessageQueue抛一个任务(修改某个标志位)。 - WatchDog 线程休眠 5 秒后醒来,检查标志位是否被修改。如果没有修改,说明主线程 5 秒内都没能处理这个极其简单的消息,说明主线程卡死了。
- 此时由 WatchDog 线程主动去
dump主线程的堆栈信息并上报服务器。
高频面试题
Q1: 看 traces.txt 定位 ANR,主线程处于 Runnable 状态就一定是代码在死循环吗? 不一定。Runnable 只代表在操作系统的就绪队列中,或者正在运行。如果系统 CPU 资源极度紧张被其他进程抢占,主线程虽然是 Runnable 但实际上得不到 CPU 时间片,也会发生 ANR。
Q2: 什么是 Binder 阻塞引起的 ANR?
当主线程调用诸如获取系统服务(如 ActivityManager、PackageManager)的方法时,通常是一个跨进程的同步调用(IPC)。如果当时系统服务端负载过高或卡死,主线程就会一直等对端返回,最终导致 ANR。
Q3: 如何用工具查出是布局层级过深导致的滑动卡顿?
可以使用 Layout Inspector 查看 View 树的层级结构。同时,抓取一段滑动的 Perfetto (或 Systrace) 文件,观察 RenderThread 和 Main Thread 上的 Choreographer#doFrame 切片,看里头的 measure/layout 子片段是否消耗了大量时间(远超 16ms)。
易错点 / 追问
- 误区: “发生 ANR 一定是我的代码写错了。” 很多时候线上的 ANR 是由于手机老化、后台其他应用疯狂抢占资源(CPU/IO)导致的,或者厂商系统的 Bug 导致 Binder 响应缓慢。
- 追问: 如果 traces 看到主线程卡在底层的
epoll_wait或者MessageQueue.nativePollOnce,说明什么?(答:这通常说明主线程是空闲的,在等待新的消息,并没有阻塞。如果此时发生了 ANR,极有可能是 ANR 发生瞬间主线程刚好处理完了阻塞任务进入空闲,或者是系统组件派发消息出现了时序错乱。) - 易错点: 将卡顿监控的阈值设得太低。线上网络和设备环境复杂,过于激进的卡顿阈值会产生海量无效上报,淹没真正的性能瓶颈。
性能工具专题
性能优化不是“凭感觉改几行代码”,而是先观测、再定位、再验证。工具章节的面试重点是:每个工具看什么、适合什么问题、输出如何转化为工程动作。
一、工具选型总览
| 问题类型 | 首选工具 | 看到什么 | 常见动作 |
|---|---|---|---|
| 启动慢 | Android Studio Profiler / Perfetto / Macrobenchmark | CPU、主线程、启动阶段耗时 | 延迟初始化、异步化、Baseline Profile |
| 卡顿掉帧 | Perfetto / Systrace / gfxinfo | Choreographer、RenderThread、帧耗时 | 减少主线程阻塞、优化布局/绘制 |
| 内存泄漏 | LeakCanary / Memory Profiler / meminfo | 引用链、堆大小、PSS | 解除生命周期引用、缓存上限 |
| I/O/网络违规 | StrictMode / Trace sections | 主线程磁盘/网络、慢调用 | 切线程、缓存、预加载 |
| Native 热点 | simpleperf / Perfetto | CPU sample、调用栈、符号 | 优化算法、减少 JNI 往返 |
| 线上复现困难 | dumpsys / bugreport / 自定义 trace | 系统状态快照 | 关联日志、设备维度排查 |
工具回答要避免“列名词”,而要说清楚“我怀疑什么 → 用什么采样 → 看哪个指标 → 怎么验证改动”。
二、Android Studio Profiler
Android Studio Profiler 适合本地开发阶段快速观察 CPU、Memory、Network、Energy。
- CPU Profiler:看主线程是否有长任务、方法调用耗时、线程调度。可用 Sampled/Instrumented/Java/Kotlin Method Trace,采样开销更低,插桩更细但扰动更大。
- 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 的常见路径:
- 先看主线程是否 Running 太久或频繁 Blocked。
- 看耗时段是否跨过 VSync 导致 missed frame。
- 看 RenderThread/GPU 是否被复杂绘制或纹理上传拖慢。
- 看 Binder、I/O、锁等待是否阻塞主线程。
- 结合自定义 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 + baseline-prof 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 版本、厂商和图形内存差异理解。
八、simpleperf 与 Native CPU 热点
simpleperf 是 Android 官方 native profiling 工具,可采样 CPU 指令、调用栈和符号,适合 NDK/JNI、音视频、加解密、图像处理等热点定位。
典型关注:
- native 函数是否占用异常 CPU。
- JNI 往返是否过多。
- 锁竞争、内存拷贝、算法复杂度是否是瓶颈。
- so 是否保留符号或能通过符号表/映射文件还原调用栈。
面试表达可以说:Java/Kotlin 层先用 Perfetto/Profiler 定位到 native 调用段,再用 simpleperf 下钻 native 热点,最后用基准测试和真实场景回归验证。
高频面试题
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 监控什么
| 类型 | 指标 | 关键证据 |
|---|---|---|
| Crash | Java 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/机型聚合定位。”
高频面试题
Q1:线上卡顿怎么监控? 答:轻量方案是主线程 watchdog 或 Choreographer 统计慢帧;深入定位要结合 Perfetto/trace。线上只采样关键指标,本地复现再做完整 trace。
Q2:native crash 怎么定位? 答:采集 signal、寄存器、线程栈、so build id/版本/ABI,结合未 strip 符号或 symbol server 还原堆栈,按版本和机型聚类。
Q3:APM SDK 会不会影响性能? 答:会,所以要采样、异步、批量、延迟上报,关键路径不做重 IO,监控逻辑本身也要被监控。
易错点 / 追问
- 不要把本地 Profiler 等同于线上 APM。
- 不要上传敏感业务数据。
- 不要只讲采集,还要讲聚合、告警、灰度回滚和修复闭环。
存储体系与 Scoped Storage
★ 数据持久化是客户端的根本。面试不仅考“怎么存”,更考“存哪里最安全”、“大文件怎么管”以及“多进程怎么防丢数据”。
一、轻量级 KV 存储:SharedPreferences 与 DataStore
| 特性 | SharedPreferences (SP) | Preferences DataStore |
|---|---|---|
| 线程安全 | 是(但容易造成主线程阻塞) | 是(基于协程与 Flow) |
| 类型安全 | 弱(可能抛 ClassCastException) | 弱(但有 Type DataStore 支持强类型) |
| 异步 API | 仅 apply() 异步,且可能阻塞生命周期 | 完全原生异步操作 |
| 多进程支持 | MODE_MULTI_PROCESS 已废弃,极不靠谱 | 支持,配合 MMKV 等替代方案更佳 |
SP 的核心痛点:
commit() 同步阻塞;apply() 虽然异步,但在 Activity/Service 销毁时,系统层(QueuedWork)会等待所有 apply 的磁盘写入完成,极易导致 ANR。
二、关系型数据库:SQLite 与 Room
- Room 的优势:编译时 SQL 语法检查;与协程/Flow/LiveData 无缝结合;基于注解的类型转换与实体映射。
- WAL 模式 (Write-Ahead Logging):开启后 (
setWriteAheadLoggingEnabled),读写可以并发执行,极大提升并发性能。写操作先追加到-wal文件,稍后再 Checkpoint 同步到.db。 - 数据库升级 (Migration):
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
}
}
三、文件目录与沙盒机制
Android 的私有目录分为内部存储和外部存储的私有部分,应用卸载时都会被删除:
- 内部存储 (Internal Storage):
Context.getFilesDir(),Context.getCacheDir()。空间非常有限,绝对不能放图片视频等大文件。 - 外部私有存储 (External Storage - App-specific):
Context.getExternalFilesDir(),Context.getExternalCacheDir()。空间较大,适合放应用的业务文件。不需要存储权限即可访问。
四、分区存储 (Scoped Storage) 与 SAF
在 Android 10+ 引入,为了防止应用在 SD 卡上“拉屎”。
- 自己产生的业务文件:放沙盒 (
getExternalFilesDir),不需要权限,卸载自动删。 - 需要分享/保存给用户的多媒体:通过 MediaStore 插入(图片、视频、音频)。插入不需要权限,读取别的应用插入的媒体需要
READ_MEDIA_IMAGES等权限。 - 让用户选择文件/非媒体文件:使用 SAF (Storage Access Framework),即
ACTION_OPEN_DOCUMENT。由系统选择器 UI 授权,返回一个不可伪造的 Document URI。
五、安全存储:Keystore 与 EncryptedSharedPreferences
- 敏感信息(如 Token、密码)怎么存?
不能明文写在 SP 或文件里。推荐使用 Jetpack Security 库中的
EncryptedSharedPreferences。 - 底层原理:它利用 Android Keystore 系统生成并保管主密钥(Master Key),主密钥存放在设备的硬件安全模块(TEE/SE)中,极难被提取。然后用主密钥加密真实的数据。
高频面试题
Q1:SharedPreferences 为什么会导致 ANR?怎么解决?
答:因为 apply() 提交的任务会进入系统级的 QueuedWork 队列。在组件(如 Activity)执行 onStop 时,系统为了保证数据不丢,会强制等待队列中的磁盘写入任务完成。如果写入量大或磁盘 IO 慢,就会阻塞主线程导致 ANR。
解决:迁移到 DataStore 或 MMKV;或者通过反射清理 QueuedWork(黑科技,不推荐)。
Q2:如何优雅地处理几十上百 MB 的大文件下载与缓存清理? 答:
- 存储位置:放在
Context.getExternalCacheDir()下,千万别放内部存储。 - 清理策略:实现 LRU 算法的磁盘缓存工具,设定最大容量(如 200MB)。在每次写入后检查总大小,淘汰最旧的未访问文件。
- 系统干预:可以覆写 Application 的
onTrimMemory,在设备空间不足时主动清理。
Q3:Room 数据库在跨版本升级时,如果用户跨了好几个版本(比如 V1 直接升级到 V4)怎么处理?
答:Room 支持定义多段 Migration,如 1-2, 2-3, 3-4。在构建 Database 时 addMigrations() 传入所有规则。Room 内部会自动寻找最短的迁移路径(按序应用 1-2,然后 2-3,然后 3-4)。如果没有找到路径且没有配置 fallbackToDestructiveMigration(),App 会崩溃。
易错点 / 追问
- 混淆 MediaStore 与 File API:在 Android 11+,即使你拿到了 MediaStore 中的文件真实绝对路径 (
_data字段),用FileAPI 去读写也可能被拒绝,必须使用ContentResolver.openInputStream()处理 URI。 - 忽略多进程 SP 的危险:多进程读写 SP 是数据损坏和丢失的重灾区。追问时一定要提到 MMKV 的共享内存 (mmap) 与文件锁机制是目前多进程 KV 存储的最佳实践。
- 数据库主线程读写:初学者常在主线程做 DB 查询。必须借助协程调度到
Dispatchers.IO。
网络协议
网络是中级面试必考硬通货,也是你的潜在主场——做风控/抓包对抗,HTTPS、证书、TLS 你比一般应用开发者懂得深,能讲到中间人、证书校验、双向认证的层面。
本篇含知识点讲解 + 高频面试题。
一、HTTP 基础
- 无状态:每次请求独立,靠 Cookie/Session/Token 维持状态。
- 请求结构:请求行(方法 + URL + 版本)+ 请求头 + 空行 + 请求体。
- 常用方法:GET(查,幂等)、POST(增,非幂等)、PUT(全量改,幂等)、DELETE、PATCH(部分改)、HEAD、OPTIONS(预检)。
- GET vs POST:GET 参数在 URL、有长度限制、可缓存、幂等;POST 参数在 body、无长度限制、不可缓存。语义区别 > 技术区别。
状态码
- 1xx 信息;2xx 成功(200、201、204)。
- 3xx 重定向:301(永久)、302(临时)、304(协商缓存命中)。
- 4xx 客户端错:400、401(未认证)、403(无权限)、404、405、429(限流)。
- 5xx 服务端错:500、502(网关错误)、503(不可用)、504(网关超时)。
缓存
- 强缓存:
Cache-Control(max-age)、Expires,不发请求直接用本地。 - 协商缓存:
ETag/If-None-Match、Last-Modified/If-Modified-Since,发请求由服务端判断,命中返 304。
二、HTTP 版本演进
| 版本 | 关键特性 | 解决的问题 |
|---|---|---|
| HTTP/1.0 | 短连接,每次请求新建 TCP | — |
| HTTP/1.1 | 长连接(keep-alive)、管线化、Host 头、分块传输 | 复用连接 |
| HTTP/2 | 二进制分帧、多路复用(一个连接并发多请求)、头部压缩(HPACK)、服务端推送 | 队头阻塞(应用层) |
| HTTP/3 | 基于 QUIC(UDP)、0-RTT、连接迁移 | TCP 队头阻塞、握手延迟 |
- HTTP/1.1 队头阻塞:同一连接请求需按序响应,前一个慢会卡后面。HTTP/2 多路复用在应用层缓解,但 TCP 层仍有队头阻塞;HTTP/3 基于 QUIC/UDP,通过独立 stream 降低 TCP 层队头阻塞影响。
- WebSocket:基于 HTTP 升级(Upgrade)的全双工长连接,用于实时通信(IM、推送)。
三、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 简化流程
- Client Hello:客户端发支持的 TLS 版本、加密套件、随机数。
- Server Hello:服务端选择套件、返回随机数、下发证书(含公钥)。
- 客户端验证证书(CA 链、域名、有效期、吊销状态等),生成
pre-master secret,用服务端 RSA 公钥加密后发送。 - 服务端用私钥解密,双方用
pre-master secret + client_random + server_random派生对称会话密钥。 - 双方发送 Finished 校验握手完整性,之后用对称加密通信。
TLS 1.2 ECDHE / TLS 1.3 直觉
- ECDHE:服务端发送临时 ECDHE 公钥并用证书私钥签名,客户端验证签名后也生成临时公钥;双方各自用“自己的临时私钥 + 对方临时公钥”算出共享秘密。网络上没有直接传输会话密钥。
- TLS 1.3:移除静态 RSA 密钥交换等旧套件,把常规握手压到 1-RTT:ClientHello 携带 key share,ServerHello 返回 key share 和证书相关消息,双方很快得到密钥。
- 0-RTT:客户端基于上次会话恢复提前发送早期数据,降低重连延迟;边界是早期数据可能被重放,所以只适合幂等 GET/查询类请求,不适合支付、下单、转账、登录态变更等非幂等操作。
面试一句话:旧 RSA 像“用证书公钥包住会话密钥”,现代 ECDHE/TLS 1.3 更像“证书负责证明你是谁,临时密钥交换负责生成本次会话密钥”,因此具备前向保密。
证书与信任链
- 证书由 CA 逐级签发,设备内置根 CA。验证时沿链校验到可信根。
- 为什么不能只用对称加密? 密钥分发难题:首次怎么安全传密钥?用非对称解决。
- 为什么不全用非对称? 慢。所以只用它协商对称密钥。
安全实战(你能讲、别人讲不了)
- 中间人攻击(MITM):攻击者伪造证书。防御靠客户端严格校验证书。
- 证书锁定(SSL Pinning):App 内置服务端证书/公钥指纹,只信任它,防抓包/MITM——风控/金融 App 标配。
- 双向认证(mTLS):服务端也验证客户端证书。
- 抓包对抗:Charles/Fiddler 装根证书抓 HTTPS;App 可用 Pinning + 检测代理对抗。这是你做风控的实战领域。
四、TCP / UDP
TCP 三次握手
- Client → SYN(seq=x)
- Server → SYN+ACK(seq=y, ack=x+1)
- Client → ACK(ack=y+1) 为什么三次? 确认双方收发能力都正常;两次无法确认客户端的接收能力,且防止历史失效连接请求建立连接。
TCP 四次挥手
- 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:快重传 → 快恢复 → 拥塞避免
面试答题流:先区分可靠性(序号/ACK/重传)与拥塞控制(保护网络),再解释 cwnd/ssthresh,最后用“超时更严重、快重传更温和”讲阶段转换。
TCP vs UDP
| TCP | UDP | |
|---|---|---|
| 连接 | 面向连接 | 无连接 |
| 可靠 | 可靠有序 | 不承诺可靠性 |
| 速度 | 慢 | 快 |
| 场景 | 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、服务端推送。
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(运行在 UDP 上),把传输层和 TLS 1.3 握手整合,改善队头阻塞和弱网迁移。面试不要说“UDP 就不可靠“,QUIC 在用户态实现可靠传输。
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 failed | TLS 版本/套件不兼容 | 老设备、服务端协议配置、ALPN |
Charles 调试与 Pinning 策略:
- 开发/测试包可使用 debug-only
network_security_config信任用户 CA。 - Pinning 必须区分 debug/release:debug 可关闭或使用测试 pin,release 严格校验。
- 不要为了抓包在线上包硬编码关闭 Pinning;应该用构建变体、白名单测试域名或内部证书。
- Pinning 要支持证书轮换:至少保留当前和备用公钥 pin。
四、HTTP 4xx/5xx 与业务错误定位
HTTP 状态码要先分清“协议层状态”和“业务层 code”。移动端常见误区是看到 500 就重试,看到 401 就清登录态,但真实原因可能更细。
- 400:参数格式、签名、时间戳、序列化字段缺失。
- 401:token 过期、刷新 token 失败、设备被踢、匿名接口误带错误凭证。
- 403:无权限、风控拦截、地区/灰度策略不允许。
- 404/405:路径、环境、方法不一致,常见于测试环境配置错。
- 429:限流,客户端要退避而不是立刻重试。
- 500/502/503/504:服务端、网关、上游依赖或超时;客户端要带 traceId 找服务端对日志。
Android 实践:
- Interceptor 统一注入 traceId、版本、网络类型,便于端云对齐。
- 401 刷新 token 要做单飞(single flight),避免并发请求同时刷新。
- 4xx 多数不应盲重试;5xx 可对幂等请求有限退避重试。
五、弱网络重试、连接池与超时设置
弱网策略要平衡成功率、电量、流量和业务副作用。不是“失败就重试三次”这么简单。
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,避免所有客户端同时重试放大故障。
六、OkHttp Interceptors、EventListener 与 Charles
OkHttp 排障工具分两类:Interceptor 看请求/响应内容,EventListener 看阶段耗时。两者结合才能回答“慢在哪里”。
- Application Interceptor:业务层,适合加公共 header、日志、签名、token、业务错误处理。
- Network Interceptor:网络层,能看到重定向、网络响应、缓存细节。
- EventListener:记录 DNS、connect、secureConnect、requestHeaders、responseHeaders 等阶段耗时。
- Charles:验证请求是否发出、header/body 是否正确、TLS 证书链、代理环境下服务端响应。
排障 checklist:
- 打印 URL、method、traceId、状态码、异常类型、各阶段耗时。
- Charles 对照请求头、body、证书和响应。
- 断网/弱网/代理/VPN/IPv6 场景复现。
- 和服务端用 traceId 对齐网关日志。
- 修复后保留监控指标,避免同类问题复发。
高频面试题
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 不该立即重试?它表示限流,立即重试会放大压力,应按服务端提示或指数退避。
数据库进阶
移动端数据库面试不是背 SQL,而是解释清楚 SQLite 在单文件、弱资源、强一致本地存储下如何工作。能把索引、事务、WAL、锁、Room 迁移和缓存一致性串起来,就能从“会用 Room”升级成“能治理本地数据层”。
一、SQLite 索引与查询优化
SQLite 常用 B+Tree 组织表和索引。Android 端最常见的优化目标是:列表页首屏快、搜索条件可命中索引、分页不扫全表、写入不要被过多索引拖慢。
| 主题 | 面试要点 | Android/Room 关联 |
|---|---|---|
| 单列索引 | 加速 where userId = ?、order by time | @Index("userId") |
| 联合索引 | 遵循最左前缀,适合多条件查询 | @Index(value=["uid","createdAt"]) |
| 覆盖索引 | 查询列都在索引里,减少回表 | 列表摘要页只查必要字段 |
| 索引代价 | 占空间、写入/更新变慢 | 埋点/日志表不要给每个字段建索引 |
索引失效常见场景:
- 对索引列做函数或计算:
where date(createdAt)=?。 like '%keyword'前缀模糊无法利用普通 BTree 范围。- 联合索引
(a,b,c)跳过a只查b。 - 隐式类型转换,如文本列传数字参数。
二、事务、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 文件,读者可继续读旧快照,写者追加日志,读写并发更好。
- Checkpoint:把 WAL 内容合并回主库;WAL 过大可能影响磁盘与启动恢复。
- Room 实践:批量 insert/update 用事务包住,避免每条 SQL 都 fsync,显著降低耗时和卡顿风险。
三、锁、并发与 Android 线程模型
SQLite 是嵌入式数据库,不是多进程数据库服务器。它的锁粒度会影响“UI 查询、后台同步、埋点写入”之间的互相阻塞。
- 共享锁:多个读事务可并发读。
- 保留锁/待提交锁:写事务准备提交时逐步升级。
- 排它锁:真正写主库时需要排它访问。
- WAL 模式下读写并发更好:读者看快照,写者追加 WAL,但同一时间通常仍只有一个 writer。
- Android 约束:不要在主线程做大查询或大事务;Room 默认禁止主线程数据库访问是为了防 ANR。
面试回答可以强调:SQLite 适合本地轻量存储,但不适合把所有模块都当成高并发中心库;日志、缓存、业务状态最好按表职责拆清楚,写入队列化。
四、Explain Query Plan 排查慢查询
EXPLAIN QUERY PLAN 用来确认 SQL 是否走索引、是否全表扫描、是否临时排序。它不是“猜测优化”,而是用证据定位慢查询。
EXPLAIN QUERY PLAN
SELECT id, title, createdAt
FROM message
WHERE conversationId = ?
ORDER BY createdAt DESC
LIMIT 20;
-- 关注输出里是否出现:
-- SEARCH TABLE message USING INDEX index_message_conversation_createdAt
-- 避免: SCAN TABLE message 或 USE TEMP B-TREE FOR ORDER BY
Android 排查路径:
- 先用日志记录慢 SQL、参数、耗时和线程。
- 用
EXPLAIN QUERY PLAN判断是否SCAN TABLE。 - 对
where + order by组合设计联合索引。 - 只查询 UI 需要的列,避免把大字段一次性读出。
- 回归验证首屏、翻页、搜索三个关键路径。
五、Room Migration 与数据演进
Room 迁移考的是“线上旧数据怎么安全升级”,不是只会改 entity。中级面试要说明 schema 版本、迁移 SQL、回滚策略和测试。
- 显式 Migration:从版本 N 到 N+1 写清
ALTER TABLE、建新表、搬数据、删旧表。 - AutoMigration:适合简单加列/改名,复杂数据变换仍建议手写。
- 破坏性迁移风险:
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)")
}
}
六、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 统一管理,离线也能展示。
缓存一致性:
- 单一可信源:UI 优先观察 Room,网络结果先入库再由数据库驱动 UI。
- 版本号/时间戳:解决本地缓存和服务端增量同步冲突。
- 事务更新:数据表和分页 key 同事务提交。
- 过期策略:TTL、etag、服务端版本号结合,不要永久相信本地缓存。
高频面试题
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)。
- 构建生命周期三阶段:
- 初始化(Initialization):确定哪些模块参与构建(settings.gradle)。
- 配置(Configuration):执行所有 build.gradle,构建任务依赖图(Task DAG)。这阶段慢会拖累整体。
- 执行(Execution):按依赖图执行需要的 Task。
- Task:构建的最小执行单元,有输入/输出,支持增量。
- 核心文件:
settings.gradle(.kts)(模块声明)、build.gradle(.kts)(模块配置)、gradle.properties(全局配置)。
二、依赖管理
- 依赖配置:
implementation:依赖不传递(只本模块可见),编译隔离、加快构建,首选。api:依赖传递给上层模块(谨慎用,会扩大编译范围)。compileOnly:只编译期(如注解处理、provided)。runtimeOnly、testImplementation、kapt/ksp(注解处理)。
- Version Catalog(libs.versions.toml):集中管理依赖版本,多模块统一,现代推荐。
- 依赖冲突:Gradle 默认选最高版本;可用
resolutionStrategy强制版本、exclude排除传递依赖。 - BOM:统一一组库的版本(如 Compose BOM)。
三、构建变体与产物
- buildTypes:debug / release(混淆、签名、是否可调试)。
- productFlavors:多渠道/多环境(免费版/付费版、国内/海外),组合成 variant。
- 签名配置 signingConfigs:配置 keystore。
- manifestPlaceholders / BuildConfig 字段:按变体注入不同配置(如不同 API 域名、key)。
四、组件化 / 模块化
中大型项目的核心架构:
- 为什么组件化:编译解耦(改一个模块不全量编)、并行开发、复用、可独立运行调试。
- 分层:app 壳 → 业务模块(feature) → 基础库(common/network/ui)。
- 模块通信(解耦):模块间不直接依赖,用路由(ARouter) + 接口下沉(sink) + DI 组合;Hilt 更偏依赖注入,不是页面路由本身。
- 路由 ARouter 机制:
- 业务页面用注解声明路径,如
@Route(path = "/user/profile")。 - 编译期 APT 扫描注解,为每个模块生成路由表类,记录
path → Activity/Fragment/Provider的映射。 - App 启动或首次使用时加载各模块路由表。
- 调用
ARouter.getInstance().build("/user/profile").withString("id", id).navigation()时,框架按 path 找到目标并完成参数注入/Intent 跳转。
- 业务页面用注解声明路径,如
- 接口下沉(sink):把跨模块能力抽到公共 API 模块,例如
:user-api只放UserService接口和数据模型,:feature-order依赖接口而不依赖:feature-user实现。实现模块通过路由 Provider 或 DI 绑定暴露能力。 - 与 Hilt/DI 的区别:路由解决“页面/服务如何按路径发现和跳转”,DI 解决“对象依赖如何创建和注入”。组件化里常组合使用:ARouter 做跨模块入口,Hilt 给模块内部或接口实现注入依赖。
// :user-api
interface UserService {
fun currentUserId(): String?
}
// :feature-order 只依赖 user-api,不依赖 feature-user
class OrderViewModel(
private val userService: UserService
) : ViewModel()
五、构建优化(你的兴趣点)
- 配置阶段优化:避免在配置期做耗时操作、用
configuration cache。 - configuration cache 边界:它缓存配置阶段产物,命中时可跳过配置阶段;但构建脚本、Version Catalog、
gradle.properties、环境变量/系统属性读取、配置期文件读取、自定义插件逻辑等配置输入变化都会导致失效。采用前要修复 Gradle 报告的 configuration cache problems,不要只开开关。 - 增量编译 / 构建缓存:
org.gradle.caching=true,复用未变模块的产物。 - 并行构建:
org.gradle.parallel=true,多模块并行。 - 守护进程:Gradle Daemon 常驻避免 JVM 重启。
- KSP 替代 KAPT:KSP 直接分析 Kotlin 符号,比 KAPT(生成 Java stub)快很多。
- 模块化:拆模块 + implementation 隔离,减少改动的重编范围。
- AGP 升级:新版本构建性能持续优化。
- 产物优化:R8 裁剪、资源压缩、so 按 ABI 拆分(见 10、11 篇,你的强项)。
六、测试
- 单元测试:JUnit + Mockito/MockK(mock 依赖),测纯逻辑(ViewModel、UseCase)。
- 协程测试:
runTest+TestDispatcher(StandardTestDispatcher/UnconfinedTestDispatcher),Turbine测 Flow。 - UI 测试:Espresso(View)、Compose Test Rule(
createComposeRule)。 - 测试金字塔:大量单元测试 + 适量集成 + 少量 UI 测试。
- 可测试性:依赖注入 + 接口抽象,让逻辑可替换 mock(见 08 篇架构)。
七、Android 版本适配(高频)
- 运行时权限(6.0+):危险权限动态申请;后续版本继续细分一次性权限、后台定位、照片/视频/音频等权限边界。
- 分区存储 Scoped Storage(10/11+):App 只能自由访问自己目录 + MediaStore,访问其他需 SAF;具体强制行为和兼容开关与 Android 10/11、
targetSdk有关,升级时要按官方行为变更表核对。 - 后台限制(8.0+):后台 Service 受限、隐式广播限制、用 WorkManager/JobScheduler;Android 12(API 31) 起对 target 31+ 的后台启动前台服务限制更严格,不满足例外会抛异常。
- 通知渠道(8.0+):必须建 NotificationChannel;Android 13(API 33) target 33+ 需要申请
POST_NOTIFICATIONS,用户拒绝后普通通知不可见,但前台服务仍会在系统规定位置保留可见性。 - targetSdk 升级:每次升级要处理对应行为变更,不要只改数字。
- Android 12 / API 31:target 31+ 创建
PendingIntent必须显式声明FLAG_IMMUTABLE或FLAG_MUTABLE;后台启动 FGS、通知 trampoline、精确闹钟等也有新限制。 - Android 13 / API 33:通知运行时权限、细分媒体权限、部分组件导出/Intent 安全要求需要排查。
- Android 14 / API 34:target 34+ 前台服务必须声明合适的 foreground service type;隐式 Intent 发送到应用内部未导出组件、mutable implicit PendingIntent 等行为更受限制。
- Android 12 / API 31:target 31+ 创建
- 适配排查清单:先读官方 behavior changes → 全局搜索权限/通知/FGS/PendingIntent/存储/API 调用 → 加兼容分支和自动化测试 → 用灰度观察崩溃、ANR、权限拒绝率。
- 64 位要求:Google Play 强制 arm64,需提供 64 位 so。
高频面试题
Q1:implementation 和 api 区别? implementation 依赖不传递,只本模块可见,编译隔离、加快构建;api 会把依赖暴露给上层模块(传递依赖),扩大编译范围。优先 implementation,仅需对外暴露时用 api。
Q2:Gradle 构建有哪几个阶段?哪个容易成为瓶颈? 初始化(定模块)、配置(执行所有 build.gradle 建任务图)、执行(跑 Task)。配置阶段会执行所有模块脚本,模块多/脚本重时成为瓶颈,可用配置缓存优化。
Q3:为什么要组件化?模块间怎么通信? 编译解耦(加快构建)、并行开发、复用、独立调试。模块间不直接依赖,通过路由(ARouter)+ 接口下沉 + 依赖注入通信,避免循环依赖。
Q4:怎么优化 Gradle 构建速度? 开启构建缓存、并行构建、配置缓存、Daemon;用 KSP 替代 KAPT;模块化 + implementation 隔离减小重编范围;升级 AGP/Gradle。
Q5:KAPT 和 KSP 区别? KAPT 为兼容 Java 注解处理器会生成 Java stub,慢;KSP 是 Kotlin 原生符号处理,直接分析 Kotlin 代码,速度快数倍,Room/Hilt 等已支持。
Q6:分区存储是什么?带来什么变化? Android 10+ 限制 App 只能自由访问自己的外部目录和 MediaStore,访问其他文件需通过 SAF 或 MediaStore API,不能再随意读写整个外部存储,提升隐私。
Q7:targetSdk 升级要注意什么?
每个版本有行为变更需适配,而且很多只对达到对应 targetSdk 的 App 生效。重点排查权限、存储、后台启动、通知、PendingIntent、前台服务类型、隐式 Intent 等;做法是对照官方 behavior changes 建清单,搜索代码命中点,加兼容分支和回归测试,灰度观察崩溃/ANR/权限拒绝率。
Gradle 构建性能专题
“构建工具是提高工程效率的核心。深刻理解 Gradle 生命周期和缓存机制,才能在大型工程里解决‘为什么编译这么慢’的痛点。”
面试策略: 结合你负责 SDK 开发的背景,聊聊你在解决依赖冲突、优化模块化编译速度、甚至推动 KSP 替代 KAPT 等方面的实战经验。
一、Gradle 构建生命周期
Gradle 构建的核心是确定执行图并执行,主要分为三个阶段:
- 初始化阶段 (Initialization): 决定哪些项目/模块参与构建,解析
settings.gradle(.kts),创建 Project 实例。 - 配置阶段 (Configuration): 执行所有参与构建的
build.gradle(.kts)脚本,配置 Project 对象,最关键的是构建出 Task 的依赖有向无环图 (DAG)。- 优化点: 这一阶段是容易拖慢构建的地方。不要在脚本里写耗时的 IO 或网络操作。
- 执行阶段 (Execution): 根据 DAG 确定执行顺序,执行具体的 Task 操作,生成构建产物。
二、Gradle 核心概念:Task、Plugin 与 Variant
- Task: 构建的最小工作单元。每个 Task 有输入 (Inputs) 和输出 (Outputs)。
- 增量构建 (UP-TO-DATE): 当 Task 的 Inputs 和 Outputs 没有发生变化时,Gradle 会跳过此 Task,这是加快构建的核心机制。
- Plugin: 插件是 Task 的集合并封装了配置逻辑。AGP (Android Gradle Plugin) 就是将编译 Android 相关的 Tasks 注入到工程中。
- Variant (构建变体): 由 Build Type(如 debug/release)和 Product Flavor(如免费版/付费版)组合而成,影响最终输出的 APK/AAB 结构。
三、依赖管理与冲突解决
随着项目增长,依赖管理往往成为灾难,特别是传递依赖导致的冲突。
| 策略/工具 | 说明 | 适用场景 |
|---|---|---|
implementation vs api | implementation 隐藏内部依赖,不向上传递,能避免雪崩式的重新编译。api 则传递给依赖方。 | 绝大多数情况用 implementation。 |
依赖排除 (exclude) | 在引入库时,手动剔除内部的某个冲突的子依赖。 | 解决具体的库版本冲突,如旧版 support 包。 |
resolutionStrategy | 在全局强制指定某个库的特定版本,或者遇到冲突时失败(Fail on version conflict)。 | 团队协作防止非预期的版本升级。 |
| Version Catalog (libs.versions.toml) | 将所有依赖的版本号统一在一处管理,支持类型安全的访问。 | 现代多模块项目的标配。 |
| BOM (Bill of Materials) | 强制一组相关的库使用协同测试过的版本(如 Compose BOM)。 | Compose、Firebase 等庞大的库集合。 |
四、构建性能深度优化
构建优化通常分为“配置期“与“执行期“优化。
- 开启 Configuration Cache:
- 缓存配置阶段的 Task Graph。第二次构建时,如果
build.gradle没有变化,直接跳过耗时的配置阶段。 - 要求极高:脚本中不能读取外部不可追踪的状态、自定义 Task 必须声明清楚输入输出等。
- 缓存配置阶段的 Task Graph。第二次构建时,如果
- 构建缓存 (Build Cache) 与并行 (Parallel):
org.gradle.caching=true: 复用之前或者其他机器(远程构建缓存)编译好的 Task 输出。org.gradle.parallel=true: 多核并行执行互相没有依赖的 Project/Task。
- 守护进程 (Daemon) 与 JVM 参数:
- 保证 Daemon 开启(默认已开启),复用 JVM 以减少启动时间和 JIT 预热开销。
- 调整
org.gradle.jvmargs给出充足的 Heap 空间。
五、KSP vs KAPT 与多模块治理
- KAPT (Kotlin Annotation Processing Tool):
- 传统注解处理。需要生成 Java Stub 文件,然后让 Java 的 AP 去处理。这会导致 Kotlin 编译变慢,并拖累整体速度。
- KSP (Kotlin Symbol Processing):
- 直接在 Kotlin 符号层面上解析注解,避免生成 Java Stub,构建速度能提升 2 倍甚至更多。目前 Room、Glide、Hilt 等都已支持。
- 多模块优化:
- 将大仓拆分更细粒度的业务模块和基础模块。
- 使用
implementation严格进行模块间隔离,当修改底层模块的非公开 API 时,上层模块不会触发重新编译。
高频面试题
Q1: 为什么有时候修改了一行代码,整个项目都要重新编译?
因为依赖隔离没做好。如果底层模块对外暴露了 API(使用了 api 声明依赖或者修改了 public 的方法签名),依赖它的上层模块在 ABI(应用二进制接口)发生变化时,都会触发重新编译。另外也可能是因为使用了不兼容增量编译的旧版 AP。
Q2: 讲讲 Gradle 中的 UP-TO-DATE 是怎么判断的?
Gradle 在执行 Task 前,会对比该 Task 的 Inputs(文件内容哈希、属性值等)和 Outputs(生成的文件)。如果两次构建的 Inputs 和 Outputs 的哈希值完全一致,说明没有改动,Task 就会被标记为 UP-TO-DATE,直接跳过执行。
Q3: KSP 相比 KAPT 为什么快那么多? KAPT 为了复用 Java 的注解处理器,需要先解析 Kotlin 代码,生成临时的 Java Stub 文件,这涉及到类型的推导和转换,非常耗时。KSP 是原生为 Kotlin 设计的,直接访问 Kotlin 的编译器符号树(AST 的上层),不需要生成 Stub,省去了解析和转换的巨大开销。
易错点 / 追问
- 忽略 Configuration Cache 的报错: 强行开启但忽略不兼容警告,可能导致拿到旧配置,打出来的包是错的。
- 过度拆分模块: 模块化虽然能并行,但每个模块配置和处理也会引入额外开销,拆分太碎反而可能拖慢配置阶段。
- 滥用
api代替implementation: 图省事把依赖全写成api,导致任何一个库的更新引发全工程重编。
CI/CD 与发布体系
“从代码提交到用户手里的安装包,中间的自动化流水线不仅是效率工程,更是质量把控的最后防线。”
面试策略: 讲清楚持续集成(CI)的核心质量卡点,以及持续交付/部署(CD)中的版本、签名和多渠道策略,这能展现你对工程闭环的把控力。
一、CI/CD 核心概念与工具
- CI (Continuous Integration, 持续集成): 开发者频繁提交代码,流水线自动触发编译、静态代码检查(Lint/SonarQube)和自动化测试。目标是尽早发现缺陷。
- CD (Continuous Delivery/Deployment, 持续交付/部署): 自动将打好的包发布到测试环境,或者打包带有生产环境签名的正式包上传到应用商店/分发平台。
- 常用工具:
- Jenkins:老牌开源,可高度定制,适合企业内网部署。
- GitLab CI / GitHub Actions:基于 YAML 配置文件直接存在代码库里(Pipeline as Code),开箱即用,越来越流行。
二、APK 与 AAB 构建体系
| 产物格式 | 核心原理 | 特点与应用场景 |
|---|---|---|
| APK (Android Package) | 包含所有的资源、ABI、DEX 文件。设备安装时全盘拷贝。 | 适合国内通过 CDN 下载直装、企业内部测试使用。容易出现包体积臃肿。 |
| AAB (Android App Bundle) | 一种发布格式(不能直接安装),Google Play 会根据下载设备(屏幕密度、ABI、语言)按需生成动态 APK。 | 大幅降低用户下载体积。Google Play 强制要求,适合海外出海应用。 |
三、渠道包与版本策略
- 多渠道打包 (Flavors):
- 国内通常需要向不同的应用商店(华为、小米、应用宝等)发布,使用 Flavor 或第三方工具(如 Walle/VasDolly)写入渠道标识,以便服务端统计各个渠道的激活率和转化率。
- 版本治理 (Version Code / Version Name):
versionCode必须递增,用于系统判断是否允许覆盖安装。versionName只是展示给用户的字符串。在 CI 中,通常将versionCode与 Git commit count 或者 CI 系统的 Build Number 绑定,保证其唯一和自增。
四、安全签名与构建产物管理
- 签名安全:
- 绝对不能把 Release Keystore 和密码放在代码库里。
- CI 中通常使用系统注入环境变量(Secrets),构建时通过临时解密或安全文件服务来签名。
- 产物归档 (Artifacts):
- 构建成功后,不仅要保留 APK/AAB,还要归档 Mapping 文件(混淆后的符号表)和 Native 符号表 (.so 的 debug symbol)。如果没有它们,线上的崩溃堆栈将是一本无字天书,无法定位问题。
五、灰度发布、降级与质量门禁
发布绝不是一键上线那么简单,需要配合严密的策略控制风险。
- 自动化质量门禁 (Quality Gates):
- PR (Pull Request) 阶段强制触发 CI。如果不满足:单元测试通过率 > 80%、静态扫描无 Critical 级警告,禁止合并代码。
- 灰度发布 (Gray Release):
- 先放量 1% 的用户,配合 APM 监控崩溃率、ANR 和核心业务指标。观察平稳后再放量 10%、50% 到全量。
- 兜底与回滚:
- 客户端发布覆水难收(旧版本无法撤回),必须要有热修复补丁(Hotfix)机制,或者通过配置中心下发开关进行功能降级,关闭引起崩溃的新功能。
高频面试题
Q1: AAB 相比 APK 有什么优势?如果国内应用商店不支持 AAB 怎么优化体积? 优势:AAB 将打包和分发的职责分离,Google Play 会根据用户设备具体情况(如只需要 hdpi 图片、arm64-v8a 库、中文语言包)裁切出特定的 APK,大幅减少体积。 国内不支持 AAB 时:可以利用 Gradle 构建配置分别打包特定 ABI 的分包(APK Splits),或者使用资源混淆(AndResGuard)、动态下发非核心动态库和大型资源等方式优化。
Q2: 如何保证 CI 环境打包和本地开发打包的结果一致性? 使用固定的基础镜像环境(Docker);强制使用统一版本的 JDK 和 Gradle Wrapper;禁止在脚本中读取个人计算机的环境变量;版本号和依赖全部锁定版本。
Q3: 线上出现严重的崩溃,无法发新版(审核慢),有哪些应急预案? 首先利用功能开关(Feature Toggle)从配置中心下发指令,关闭引发崩溃的入口(降级);如果无法关闭,利用热修复(Tinker、Robust)下发补丁修复逻辑;同时对未升级用户的应用商店渠道包进行下架或暂停灰度放量。
易错点 / 追问
- Mapping 文件丢失: 打完包丢弃了本次构建的 mapping 文件,导致崩溃上报后无法解混淆,这是灾难性的工程事故。
- 混淆配置未同步: CI 开启了 R8 混淆但没有在测试环境验证,直接上生产,由于反射或 JNI 类被裁减导致秒退。
- 密钥泄露: 将签名文件或 API Key 随代码 Push 到了公网 Github,安全风险极高。
隐私合规与权限治理
“在强监管时代,因为隐私合规不达标导致 App 被全面下架的惨痛教训比比皆是。合规不再是边角料工作,而是生命线工程。”
面试策略: 结合你 SDK 开发的经历,强调你在收拢采集入口、治理第三方 SDK 以及设备标识符(OAID 等)演进上的经验。
一、隐私合规的“生死红线“
国内工信部和网信办、海外的 GDPR 对隐私保护的底线要求非常严苛,核心原则是:未经同意,不取一毫;最小必要,严禁滥用。
- 隐私弹窗: 用户首次打开 App 时,必须弹窗展示《隐私政策》和《用户协议》,在用户点击“同意“之前,绝对不允许执行任何代码去读取敏感信息或初始化敏感 SDK。
- 默认勾选: 注册/登录页面的协议同意框,绝不能默认帮用户打勾。
- 超范围收集: 不能因为是一个手电筒 App 就要求读取通讯录和位置信息,这违反“最小必要“原则。
- 频次限制: 不允许在后台高频次地(如每秒一次)获取用户位置或读剪切板。
二、Android 权限治理演进
Android 系统本身的权限管理也顺应合规趋势,逐年收紧:
| 权限变化里程碑 | 影响与适配策略 |
|---|---|
| Android 6.0 动态权限 | 危险权限(相机、位置等)不能在安装时一揽子授权,必须在使用时动态弹窗申请。 |
| Android 10 分区存储 | 限制对 SD 卡的粗放读取。App 只能自由读取自己的目录和媒体库(通过 MediaStore),读取其他文件需请求特定授权。 |
| Android 11 单次授权 | 引入“仅限这一次”选项。App 进入后台后,该权限可能被系统立刻收回。代码里不能缓存授权状态,每次使用前都要检查。 |
| Android 12 近似位置 | 用户可以选择只给你“大致位置”(精确度几平方公里)而不是 GPS 坐标。位置功能必须兼容这种精度。 |
| Android 13 通知权限 | 发通知不再是默认开启的,需要显式申请 POST_NOTIFICATIONS 权限。 |
| Android 14 媒体部分授权 | 允许用户只授权照片库中的“几张指定图片”,而不是全量相册读取权限。 |
三、设备标识符的演变与 OAID
作为做设备指纹出身的开发者,这块是你的强项:
- MAC 地址 / IMEI: 曾经用来唯一追踪用户的神器。随着隐私收紧,Android 10 以后非系统级应用彻底无法获取真实的 IMEI 和全局不变的 MAC 地址(系统返回全 0 或抛出异常)。
- Android ID: 恢复出厂设置或签名改变会发生变化。在 Android 8.0 之后,不同的 App 读到的同一个设备的 Android ID 甚至是不一样的(基于签名的作用域隔离)。
- OAID (匿名设备标识符):
- 国内由于 Google 服务的缺失,由移动安全联盟 (MSA) 推出的替代方案。
- 特性:用户可以在系统设置中手动重置 OAID。
- 它满足了广告归因的需求,又给了用户重置追踪的权利,是目前国内合规体系下获取设备标识的主流手段。
四、第三方 SDK 合规治理
因为第三方 SDK 的违规采集导致主 App 被下架,这叫“背锅”。所以大型 App 会对接入的 SDK 进行严苛治理。
- 延迟初始化: 所有第三方 SDK(尤其是广告、推送、统计、风控)的初始化动作,必须放在隐私弹窗“同意”按钮的回调之后。
- 采集点收口: 收拢项目里的定位、剪切板、设备信息读取入口,封装成统一的 Manager 代理。在 Manager 中增加鉴权、缓存、频次控制和合规拦截。
- 静态扫描与字节码 Hook:
- 对于不受控的第三方 SDK 或陈旧的 Jar 包,在打包阶段(或运行时阶段,利用 ASM 插件 或类似 Epic 的机制),对
TelephonyManager.getDeviceId()等敏感 API 调用进行 Hook 或替换,在未同意隐私政策时返回空数据。
- 对于不受控的第三方 SDK 或陈旧的 Jar 包,在打包阶段(或运行时阶段,利用 ASM 插件 或类似 Epic 的机制),对
五、敏感行为的安全保障
- 剪切板滥用:
- iOS 14 和 Android 12 开始,App 读取剪切板系统会在顶部弹出明显提示。
- 如果应用为了淘口令等功能无脑轮询读取剪切板,会让用户极其反感甚至遭遇投诉。正确做法是切前台时且发现特定格式的文本才进行解析。
- 日志安全 / 脱敏:
- 本地日志绝不允许明文打印用户的手机号、身份证、真实姓名、密码。必须进行脱敏(如
138****1234)或强加密。
- 本地日志绝不允许明文打印用户的手机号、身份证、真实姓名、密码。必须进行脱敏(如
- 权限被拒处理:
- 不能因为用户拒绝了位置权限,就不让用户使用“扫一扫”功能,不能搞权限捆绑。
高频面试题
Q1: 在用户点击同意隐私政策前,App 到底能不能访问网络? 不能。任何网络请求都会向外暴露用户的 IP 地址等基础设备信息,因此在严格合规要求下,在隐私弹窗同意前,不允许发起任何向外部的请求,更不能拉起各种后台 Service 提前预热 SDK。
Q2: 怎么防止项目中接入的第三方广告 SDK 在后台偷读用户剪切板或位置信息?
- 商务层面: 选用正规、经过官方审核的版本,并在应用内隐私政策明确披露该 SDK。
- 工程层面: 不在后台启动和保活该 SDK 所在的组件;通过 ASM 字节码插桩,在打包期间将该 SDK 调用敏感 API 的方法替换为我们自己的代理方法,在代理中加入合规状态判断和频次拦截。
Q3: Android 10+ 无法获取 IMEI 后,风控和广告归因怎么做? 国内推荐使用 MSA 提供的 OAID 库获取匿名设备标识;海外使用 Google Advertising ID (AAID)。同时,风控系统转为采用弱特征组合设备指纹(通过分辨率、系统版本、传感器特征等计算生成),不再依赖单一的绝对不变标识符。
易错点 / 追问
- 粗暴对待权限拒绝: 用户拒绝权限后不断弹窗骚扰,或者直接
finish()应用,这会导致极差体验并被商店警告。 - 缓存单次授权状态: 在 Android 11+ 把获得的权限记录在
SharedPreferences中,下次直接用,结果权限已被系统回收导致崩溃。 - 过度索权: 开发图省事,不管用不用,先把
Manifest里的权限申请一大堆,上架时很容易被拒。
登录鉴权与账号体系
“账号是所有业务的基石,一次优秀的登录系统设计,需要兼顾安全防护、无缝体验以及应对多设备、状态同步的复杂性。”
面试策略: 把这道题当作架构设计题来答。不要只讲发送密码存一下 token,要从 双 Token 体系、状态机控制、安全存储 三个维度,展现对登录流程严密性的思考。
一、Token 与鉴权基础 (OAuth2/JWT)
移动端通常不再使用传统的 Session-Cookie 模型(有 CSRF 风险和跨端限制),主流方案是基于 Token 的鉴权机制。
- JWT (JSON Web Token): 自包含的令牌,服务端签发后无需查询数据库即可校验(只要验证签名)。
- 结构: Header(算法)+ Payload(用户 ID/过期时间)+ Signature(防篡改签名)。
- 弱点: 一旦签发,在过期前服务端难以主动使其失效(除非引入黑名单,但这会失去 JWT 无状态的优势)。
- OAuth2 核心流程: 用于第三方授权登录(微信/Google 登录),获取 Access Token 来访问资源。
二、双 Token 体系与无感刷新
为解决 JWT 无法撤销与长期有效带来的安全风险,业界标准是采用 双 Token (Access Token + Refresh Token) 机制:
- Access Token (AT): 生命周期极短(如 2 小时),每次网络请求放在 Header 中 (
Authorization: Bearer <token>)。即使泄漏,风险时间也短。 - 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 | 极高,Root 后一览无余 | 坚决禁止 |
| 自定义加密存储 | 中等,密钥可能被逆向提取 | 密钥需经过混淆、分段或者配合动态下发 |
| Android Keystore (EncryptedSharedPreferences) | 低,基于硬件 TEE 的密钥管理 | 推荐方案。即使拿到磁盘文件也无法解密,密钥不出安全区 |
设备绑定与风控检查: 登录不仅仅是密码匹配。服务端通常还会结合你收集的设备指纹进行判定:异地登录、新设备登录、同一设备高频切换账号等,需要触发短信验证码、滑块验证等二次认证机制。
五、多设备登录与单点登录 (SSO)
- 单点登录 (SSO): 企业内部多 App 互通登录状态。通常由一个主应用/网页提供统一鉴权,返回临时 Ticket,其他端用 Ticket 去认证中心换自己的 Token。
- 多设备互踢:
- 服务端维护一张
[用户ID - 设备ID - Token]映射表。 - 用户在 B 设备登录,服务端使得 A 设备的 Token 失效,或者通过 WebSocket/Push 主动推一条“踢出下线“指令给 A 设备。
- A 设备收到通知,清空本地状态,弹窗提示“您的账号在其他设备登录“。
- 服务端维护一张
高频面试题
Q1: Access Token 和 Refresh Token 的机制是什么?为什么要用两个 Token? 为了平衡安全与用户体验。单一长效 Token 泄漏风险极大且难以撤销;短效 Token 频繁过期会让用户反复登录体验极差。双 Token 用短效 Access Token 降低泄漏后的损失窗口,用长效 Refresh Token 实现静默续期,且 Refresh Token 只发往专门的刷新接口,截获概率低。
Q2: 在协程/RxJava 拦截器中,怎么处理多并发请求导致的多次 Token 刷新问题?
利用并发锁或者协程 Mutex。当第一个 401 触发刷新时,上锁,后续的 401 请求判断正在刷新中,则挂起等待。刷新成功后,通知所有等待的请求用新 Token 重试;如果刷新失败,则全部抛出未登录异常跳转登录页。
Q3: App 卸载重装后,如何保持依然处于登录状态(免密登录)? 通常通过将加密 Token 或者账号绑定凭证备份到系统级的机制中,如 Android 的 KeyChain、AccountManager,或者依靠读取稳定的硬件设备指纹作为辅助免密凭证。但出于安全合规考虑,现在的 App 大多重装后仍要求重新登录或进行验证码二次确认。
易错点 / 追问
- Token 存在内存中没有持久化: 导致 App 被系统杀进程重启后状态丢失。
- 未处理多进程的 Token 同步: 很多 App 有独立推送或后台进程,单进程刷新了 Token 没通知其他进程,导致 401 死循环。
- 退出登录未通知服务端: 本地清空了 Token,但 JWT 仍然没有过期,截获这段 JWT 的黑客仍能继续请求(应当让服务端将该 Token 暂时加入黑名单)。
支付订单与状态机
“支付业务的核心是‘绝不能丢钱,也绝不能多扣钱’。网络可能是不可靠的,但我们的系统必须是可靠的。”
面试策略: 在这部分重点体现你对分布式系统异常情况的理解。突出幂等性、重试、轮询、超时取消,以及状态机控制这五个关键武器。
一、客户端支付全链路流程
一次完整的第三方支付(如微信/支付宝)交互绝不是客户端拿着金额去请求 SDK 这么简单,它涉及三方交互:
- 客户端发起订单: 客户端请求自己服务器,传递商品信息。
- 服务端生成预支付单: 业务服务器调用微信/支付宝生成预支付交易单,将包含签名等核心信息的
PayInfo返给客户端。 - 客户端拉起收银台: 客户端使用 SDK,传入
PayInfo唤起支付 App 完成支付。 - 客户端获取同步结果: 支付 SDK 回调给客户端支付结果(成功/取消/失败)。此结果仅供 UI 展示参考,不能作为最终发货依据。
- 服务端接收异步通知: 微信/支付宝服务器将真实的支付成功通知发给你的业务服务器。
- 客户端轮询/长连同步真实结果: 客户端主动拉取或接收服务器推送,确认支付最终状态,展示成功页。
二、订单状态机设计
复杂业务中,订单绝不能只有“成功“和“失败“。必须用严谨的状态机约束状态流转,防止非法倒流(如“已取消“的订单被发货)。
[待支付]
/ | \
(超时未付) (支付) (用户主动取消)
/ | \
[已取消] [支付中] [已取消]
|
(服务端接收回调成功)
|
[已支付/待发货]
|
[已发货] → [已完成]
- 核心原则: 状态只能单向推进,或根据特定规则流转。客户端 UI 根据状态机的当前状态来渲染按钮(如:待支付展示“去支付“,已取消展示“重新购买“)。
三、网络异常与重试机制 (幂等性)
移动端面临弱网、断网、重切等各种网络异常。当发出“确认购买“请求,但遇到了网络超时,此时钱扣了吗?
- 幂等性 (Idempotency): 无论接口被调用多少次,产生的业务结果应该和调用一次相同。
- 防重点击机制:
- 前端 UI 防止连点(按钮变灰/Debounce拦截)。
- 核心防线在服务端: 客户端生成全局唯一的单号或携带防重 Token,服务端依赖数据库唯一索引或 Redis 分布式锁拦截重复请求。客户端重试时必须带上同样的 ID。
四、支付结果确认与轮询机制
如第一节所述,客户端 SDK 返回成功,不代表真的成功了(可能是网络劫持伪造的响应)。
- 确认机制: 支付完成后,客户端展示“支付确认中“的加载框。
- 轮询 (Polling):
- 客户端定时向业务服务器发起请求查询订单真实状态(比如:延时 1s、2s、4s 递增查询,最多查询 5 次)。
- 兜底策略: 如果轮询一直未确认,不能告诉用户“支付失败“,应该提示“结果确认中,请稍后查看订单列表“。服务端会在后续收到异步通知时更正订单状态。
五、超时取消与库存回退
- 当订单处于“待支付“状态,业务逻辑往往已经预占了商品的库存。
- 如果用户迟迟不付,必须有超时取消机制(例如 15 分钟未支付自动关闭),释放占用的库存。
- 客户端倒计时: 倒计时的计算一定要以服务端返回的时间戳为基准,切勿使用客户端本地的
System.currentTimeMillis(),因为用户可以随便修改手机时间。
六、安全边界与反作弊
- 金额不可信: 客户端绝不能自己提交
amount=100给支付 SDK。金额必须由业务服务端通过商品 ID 和促销规则计算生成,客户端只拿组装好的签名串。 - 拦截抓包与篡改: 防止用户抓包拦截服务端的预支付单,修改成别人的订单号或极小金额。这里结合第 20 篇提到的签名、HTTPS 双向校验和风控逻辑发挥。
高频面试题
Q1: 支付完 SDK 告诉客户端成功了,此时可以立刻更新本地状态为“已支付”并给用户发货吗? 绝对不可以。客户端环境不可控,SDK 返回的结果可能被篡改(如破解包或劫持回调)。发货的唯一凭证是业务服务器收到微信/支付宝的官方异步回调校验成功。客户端结果只用于触发向服务端查询的动作。
Q2: 弱网下用户点击“立即支付”由于没有响应,狂点了三下,怎么保证不产生三个订单?
- 客户端防重: 按钮点击后 disable,通过 RxBinding 或协程防抖。
- 状态机控制: 本地记录正在请求中,不响应二次点击。
- 服务端唯一防重: 客户端生成或服务端提前下发的 UUID 作为本次交易的凭据(幂等 Key),服务端收到多次相同 Key 的请求时,只处理一次,其余的直接返回旧订单信息。
Q3: 如果支付完成后回到 App,网络断了,无法轮询服务端拿到结果,应该怎么处理? 展示“订单处理中”或“网络异常,稍后请在订单列表查看”。绝不能显示“支付失败”(万一钱扣了会引发严重客诉),也绝不能显示“支付成功”(万一真没成功则产生资损)。等待网络恢复后,用户进入订单列表时再次向服务器同步最新状态。
易错点 / 追问
- 倒计时依赖本地时间: 利用修改手机系统时间可以无限延长支付时间或卡出倒计时负数 bug。
- 本地订单状态与服务端不一致: 客户端由于进程被杀等原因错过状态流转,重入时一定要从服务器重新拉取当前最新状态机节点。
- 支付 SDK 冲突: 集成多方支付 SDK 导致依赖库冲突(通常通过
exclude或使用精简版 SDK 解决)。
推送 / 长连接 / 保活
☆ 推送、长连接和保活经常和 Android 后台限制、厂商策略、功耗合规一起考。你的风控/对抗经验可以讲“知道边界、尊重系统、用监控验证“。
一、三类实时方案
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 轮询 | 简单 | 耗电、实时性差 | 低频状态刷新 |
| 长轮询 | 兼顾兼容性 | 服务端连接占用 | 简单实时通知 |
| WebSocket/TCP 长连接 | 实时、双向 | 心跳、重连、保活复杂 | IM、协作、行情 |
二、WebSocket 心跳与重连
心跳不是越频繁越好。移动端要在实时性、功耗、网络切换、后台限制之间取舍。
sealed class SocketState {
object Disconnected : SocketState()
object Connecting : SocketState()
object Connected : SocketState()
data class Backoff(val retryAfterMs: Long) : SocketState()
}
重连策略:指数退避 + 网络恢复触发 + 前后台状态感知 + 最大重试保护。
三、系统推送:FCM 与国内厂商通道
- 海外优先 FCM。
- 国内通常接入小米/华为/OPPO/vivo/荣耀等厂商推送。
- 应用内长连接适合前台实时,系统推送适合后台触达。
四、Android 后台限制与保活边界
Android 8.0 以后后台 Service、隐式广播、后台执行都有严格限制。面试要强调合规策略:前台服务、WorkManager、厂商推送、用户可见任务,而不是无限拉活。
五、进程保活与拉活的现实策略
历史方案包括双进程守护、1 像素 Activity、JobScheduler、账号同步、厂商白名单等,很多已被系统或厂商限制。中级面试更看重你能否说明风险:耗电、合规、用户体验、应用商店审核。
六、排障与监控
关注连接成功率、平均在线时长、心跳超时、重连次数、消息到达率、厂商通道回执。
高频面试题
Q1:IM 为什么不能只靠推送? 答:推送适合后台触达,不保证强实时和完整双向通信;前台聊天通常需要长连接保证低延迟、顺序和 ACK。
Q2:心跳间隔怎么定? 答:根据 NAT 超时、服务端成本、功耗和业务实时性实验确定,并区分前后台/网络类型,不能拍脑袋固定 5 秒。
Q3:怎么回答保活问题才安全? 答:讲系统允许的前台服务、WorkManager、厂商推送和业务必要性;历史黑科技可以说明了解但不建议作为长期方案。
易错点 / 追问
- 不要承诺后台永远在线。
- 不要忽略厂商 ROM 差异。
- 不要把推送到达率问题全归因于客户端。
埋点与数据采集 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 前日志用于回答“崩溃前用户做了什么、页面状态是什么、关键接口是否失败”。它不应无限制采集,而应维护一个轻量环形缓冲区。
- 记录最近 N 条关键事件:页面进入/退出、点击、网络错误、业务状态变更、SDK 关键状态。
- Crash 发生时将缓冲区快照随崩溃报告上传或落盘等待下次上传。
- 日志字段脱敏,避免 token、手机号、身份证、精确定位等敏感数据进入崩溃上下文。
- 控制大小,避免 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 成本。 |
本地队列通常使用内存队列 + 持久化存储。写入要异步,并设置容量上限;超过上限时按优先级丢弃或采样,不能无限增长。进程退出、断网、弱网、服务端限流都要可恢复。
五、离线缓存与可靠性
离线缓存解决“用户断网或服务端不可用时事件不丢失”。但可靠性不是越强越好,因为无限保留会带来隐私、磁盘和上传风暴问题。
- 容量上限:按条数、字节数、天数三重限制。
- 过期清理:超过有效期的行为数据直接删除。
- 分片存储:避免单个文件过大导致读写失败。
- 幂等标识:每批事件带 batchId/eventId,服务端去重。
- 启动削峰:App 启动后延迟上传,避免和冷启动抢资源。
- 网络约束:按 Wi-Fi/蜂窝、前后台、低电量模式调整上传策略。
面试可以强调:采集 SDK 的“可靠”是有边界的可靠,要在数据完整性、用户体验、隐私合规和资源成本之间平衡。
六、隐私、合规与最小化采集
隐私是数据采集 SDK 的红线。设计时要把“采什么、为什么采、保存多久、给谁用、如何撤回”说清楚。
| 合规点 | 工程做法 |
|---|---|
| 用户知情同意 | 隐私协议同意前不启动非必要采集;提供开关和撤回路径。 |
| 最小化采集 | 只采业务需要字段,敏感字段默认不采或脱敏。 |
| 数据脱敏 | 手机号、邮箱、设备标识、定位等做哈希、截断或分级授权。 |
| 存储期限 | 本地缓存和服务端数据设置过期清理。 |
| 权限隔离 | SDK 不主动申请无关权限,需要宿主显式授权。 |
| 跨境/共享 | 按公司和地区政策做数据分区、审计和用途限制。 |
对于设备标识、IP、定位、剪贴板、通讯录等敏感能力,必须按法规和平台政策处理。SDK 不能绕过宿主隐私弹窗自行采集,也不能把业务传入的敏感参数原样写入日志。
七、宿主性能影响控制
宿主性能是埋点 SDK 能否长期接入的关键。优秀 SDK 要“默认轻量、可观测、可关闭”。
- 主线程控制:事件组装、序列化、加密、压缩、磁盘写入、网络请求都应放到后台线程。
- 内存控制:队列有上限,大字段截断,避免持有 Activity/View/Context 强引用。
- CPU 控制:批量压缩和加密要限频;自动曝光计算要节流。
- 网络控制:批量、采样、退避、前后台策略,避免高频小包。
- 启动控制:延迟初始化非关键模块,不要阻塞 Application
onCreate。 - 可观测性:SDK 自身要上报队列长度、丢弃数、上传耗时、失败原因和资源占用。
对宿主来说,SDK 应提供远程开关、采样率、黑名单、最大缓存、上传周期等配置,当线上异常时可以快速降级。
高频面试题
Q1:手动埋点和自动埋点怎么选? 核心业务转化用手动埋点,因为语义准确、参数可控;页面浏览、基础点击、曝光可用自动埋点补充覆盖。大型项目通常混合使用,并通过配置中心控制开关和采样。
Q2:曝光埋点怎么避免重复和误报?
用可见比例、停留时长、页面可见状态和业务 ID 去重。RecyclerView 不能只按 position 去重,因为 item 会复用和移动;Fragment/ViewPager 也不能只看 onResume,要结合真实可见性。
Q3:埋点 SDK 如何保证离线不丢数据? 事件先进入内存队列并异步持久化,网络可用时批量上传;失败使用退避重试,每批带幂等 ID。与此同时设置容量、天数和优先级上限,避免无限缓存影响隐私和磁盘。
Q4:Crash 前日志怎么设计? 维护轻量环形缓冲区,记录最近页面、点击、接口错误和 SDK 状态;Crash 时附带快照。日志要脱敏、限长、限量,不能把它做成完整行为录屏或敏感数据仓库。
Q5:如何控制埋点 SDK 对宿主性能的影响? 主线程只做轻量入队,序列化/压缩/加密/IO/网络放后台;队列和缓存有上限;曝光计算节流;上传批量化、采样和退避;SDK 自身提供监控和远程降级开关。
易错点 / 追问
- 不要为了“数据完整”无限缓存,这会带来隐私、磁盘和上传风暴风险。
- 不要在隐私协议同意前启动非必要采集,也不要默认采集敏感字段。
- 不要在点击回调里同步写数据库或发网络请求,会直接伤害宿主性能。
- 追问“自动埋点为什么不准”:控件复用、动态 UI、页面可见性和业务语义缺失都会导致误差。
- 追问“采样会不会影响分析”:会,所以要按用户/会话稳定采样,并对关键错误或转化事件保留更高优先级。
主流第三方库
中级面试常问“X 库的原理“,尤其网络层和图片库。你做 SDK 偏底层,这些上层库要补实战理解。
一、OkHttp
Android 网络底座(Retrofit 默认基于它)。
- 核心:拦截器链(责任链模式)。请求依次经过拦截器:
- 应用拦截器(开发者添加,如加 header、log)。
- RetryAndFollowUp(重试、重定向)。
- Bridge(补全 header、gzip)。
- Cache(缓存)。
- Connect(建立连接)。
- 网络拦截器(开发者添加)。
- 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 支持和页面优先级做压测。
二、Retrofit
把 HTTP API 封装成 Kotlin/Java 接口。
- 核心:动态代理(Proxy.newProxyInstance)。
create()为接口生成代理对象,调用方法时被 InvocationHandler 拦截。 - 解析方法上的注解(@GET/@POST/@Query/@Body)生成 ServiceMethod/RequestFactory,构造 OkHttp 的 Request。
- CallAdapter:适配返回类型(Call、协程 suspend、Flow、RxJava)。协程版可理解为把 OkHttp Call 的异步回调桥接为挂起恢复,取消协程时也要取消底层 Call。
- Converter:序列化转换(Gson/Moshi/kotlinx.serialization),负责请求体/响应体和业务对象之间的转换。
- 协程用法:接口方法加
suspend直接返回data class,无需 Call.enqueue。
Retrofit 三层扩展点
| 扩展点 | 负责内容 | 常见坑 |
|---|---|---|
| 注解解析 | URL、HTTP method、Query/Header/Body 参数 | 动态 URL、编码、空参数要按接口契约处理。 |
| Converter | JSON/XML/Proto 与对象互转 | Kotlin 非空/默认值、混淆字段名、泛型类型擦除。 |
| CallAdapter | 把 Call<T> 适配成 suspend/RxJava/Result 包装 | 错误处理边界要统一:HTTP error、网络异常、解析异常不要混在一起。 |
面试回答结构:先说“接口不是实现类,Retrofit 用动态代理拦截方法调用”;再说“注解生成请求,Converter 负责数据转换,CallAdapter 负责返回类型”;最后补“底层网络仍交给 OkHttp”。
三、图片库(Glide / Coil)
- 三级缓存:内存(ActiveResources 弱引用 + LruCache)→ 磁盘(DiskLruCache)→ 网络。
- 生命周期绑定: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 + MemoryCache | memoryCache | 内存命中快,但占用大;列表页要控制目标尺寸。 |
| 磁盘缓存 | 原始数据/转换后资源策略可配 | diskCache | 磁盘命中省网络但有 IO;策略按图片来源选择。 |
| BitmapPool | 复用 Bitmap 内存降低 GC | bitmap pool/解码组件实现不同 | 高版本硬件位图不可随意复用/修改。 |
RecyclerView 中的核心不是“手动取消所有请求”,而是每次绑定都给可复用 View 发起新请求或显式 clear。Glide 文档也提示,如果某个复用 View 不再加载图片,要 clear() 避免旧请求回调把旧图设置回来。
四、序列化库
- Gson:反射解析,易用但运行时反射有性能开销、不感知 Kotlin 默认值/空安全(可能给非空字段塞 null)。
- Moshi:Square 出品,支持 codegen(编译期生成 adapter,无反射),对 Kotlin 友好。
- kotlinx.serialization:Kotlin 官方,编译期插件生成,类型安全、多平台、无反射,新项目推荐。
@Serializable注解。
| 库 | 机制 | 适合场景 | 面试注意 |
|---|---|---|---|
| Gson | 运行时反射为主 | 老项目、简单 Java model | Kotlin 非空/默认值坑多,混淆要保留字段/注解。 |
| Moshi | 反射或 codegen adapter | Kotlin + Square 生态 | codegen 性能和类型安全更好,但要配置 KSP/KAPT。 |
| kotlinx.serialization | 编译期插件生成 serializer | Kotlin/多平台/Compose 新项目 | 需要 @Serializable,与 Retrofit 配合要加 converter。 |
五、其他常见库
- EventBus:发布订阅解耦组件通信。现代趋势用 Flow/SharedFlow 替代(类型安全、可控生命周期)。
- 依赖注入:Hilt(主流,见 07)、Koin(纯 Kotlin、运行时解析、轻量但无编译期校验)。DI 的核心价值不是“少写 new”,而是把对象创建、作用域和替换点显式化,方便测试和模块解耦。
- 协程之外的响应式:RxJava(老项目多,操作符丰富但学习曲线陡),新项目协程 + Flow 已基本替代。
| 模式/库 | 解决的问题 | 现代替代/边界 |
|---|---|---|
| EventBus | 跨组件发布订阅,调用方不直接依赖接收方 | 容易隐藏数据流、难追踪生命周期;页面内优先 LiveData/StateFlow/SharedFlow,跨模块事件要定义清晰契约。 |
| Hilt | 编译期 DI、作用域管理、Android 组件注入 | 适合中大型项目;需要理解 Component/Scope,不要滥用单例。 |
| Koin | DSL 声明依赖,上手快 | 运行时解析,错误可能到运行时才暴露;适合小项目或快速迭代。 |
| RxJava | 复杂异步流组合 | 老项目维护常见;新代码可优先协程 Flow,但迁移要逐步做。 |
高频面试题
Q1:OkHttp 的拦截器链是什么设计模式?顺序? 责任链模式。顺序:应用拦截器 → RetryAndFollowUp → Bridge → Cache → Connect → 网络拦截器 → CallServer。每个拦截器处理后调 chain.proceed 传递。
Q2:应用拦截器和网络拦截器区别? 应用拦截器在最外层,只调用一次,能看到原始请求(重定向前),不一定有网络;网络拦截器在 Connect 之后,可能多次调用(重定向/重试),能看到真实网络请求和中间响应。
Q3:Retrofit 的核心原理? 动态代理。create() 用 Proxy 为接口生成代理对象,调用方法时 InvocationHandler 拦截,解析注解构造 OkHttp Request,经 CallAdapter 适配返回类型(协程/Call/Flow),Converter 做序列化。
Q4:Retrofit 怎么支持协程的? 方法加 suspend 后,Retrofit 内部把 OkHttp Call 的异步回调适配成协程挂起恢复,常见实现会通过类似 suspendCancellableCoroutine 的方式处理取消,直接返回结果或 Response,无需手动 enqueue。
Q5:Glide 如何感知生命周期防止泄漏? Glide.with(activity/fragment) 返回与宿主生命周期绑定的 RequestManager,宿主停止/销毁时可暂停或清理请求。实现层面常见解释是注入无 UI 的 RequestManagerFragment/生命周期组件监听回调,但这是库内部实现细节,回答时不要说成永久 API 契约。
Q6:Glide 三级缓存? 内存(活动资源弱引用 + LruCache)、磁盘(DiskLruCache,可缓存原图或转换后)、网络。命中内存最快,逐级回退。
Q7:Gson 在 Kotlin 里有什么坑? Gson 用反射、不走构造函数,可能绕过 Kotlin 的非空检查和默认值,给非空字段赋 null 导致后续 NPE。建议用 Moshi 或 kotlinx.serialization。
Android 安全与逆向 ☆
这一篇是你最大的差异化武器。 你做设备指纹/风控 SDK,签名、加固、抓包对抗、反调试、反 Hook 是你的本行,而 99% 的应用开发者答不出。
面试策略:投支付/金融/出海/风控相关团队时,主动把话题引到这里。即便普通应用岗,聊到安全也能瞬间拉开差距。这是你“从底层来做应用“叙事的最强证据。
一、APK 签名机制
签名用于校验 APK 完整性 和 来源可信(防篡改、防二次打包)。
| 方案 | 原理 | 特点 |
|---|---|---|
| v1(JAR 签名) | 对每个文件摘要写入 MANIFEST,签名 META-INF | 慢、不保护 ZIP 元数据、易被 hook |
| v2(APK 签名块) | 对整个 APK 做摘要签名,插入签名块 | 快、保护整体、防篡改强 |
| v3 | v2 基础上支持密钥轮转(换签名密钥) | 可升级密钥 |
| v4 | 基于 Merkle 树,增量签名,配合 ADB 增量安装 | 支持流式校验 |
- 签名校验流程:安装时系统校验签名;App 运行时也可自校验签名防二次打包。
- 面试可讲:v1 的弱点(可往 META-INF 塞文件不破坏签名,渠道包/恶意注入利用过),v2 怎么解决。
二、加固与脱壳
- 加固目的:防止反编译拿到源码逻辑(DEX)、保护 so、防调试。
- DEX 加固演进(防御/面试视角):
| 代际 | 核心机制 | 保护目标 | 主要边界 |
|---|---|---|---|
| 整体加固(一代) | APK 中 DEX 加密,启动后由壳代码解密并交给自定义 ClassLoader 加载 | 防静态反编译直接看到业务代码 | 运行时必须出现可执行代码形态,对动态分析防护有限 |
| 方法抽取(二代) | 方法体被抽离/替换为空实现或壳逻辑,运行时按需还原关键方法 | 降低整包还原收益,保护核心函数 | 复杂度、兼容性和性能开销更高,仍要关注崩溃率 |
| VMP / 指令虚拟化(三代) | 把部分 Dalvik/native 指令转换成私有虚拟指令,由自定义解释器执行 | 提高理解核心算法的成本 | 体积、性能、可维护性成本最高,通常只保护高价值逻辑 |
- 脱壳对抗的原则比较:从防守角度看,一代主要防静态读取,二代把攻击面缩到关键方法还原时机,三代把“读代码”变成“理解私有虚拟机”。面试只讲原理、成本和风险,不提供可执行流程。
- so 加固:字符串加密、符号裁剪/隐藏、控制流混淆、完整性校验、必要时对极少数核心算法做 native 侧虚拟化。注意 native 保护会引入兼容性、性能、可观测性问题,上线前要做灰度和崩溃监控。
- 你的发言点:讲你在 SDK 里怎么做 so 保护、防止采集逻辑被逆向,这是真实经验。
三、混淆
- R8 / ProGuard:重命名类/方法/字段(
a.a())、删除无用代码、内联、优化。keep规则保留反射/JNI 入口。 - 资源混淆:AndResGuard,缩短资源路径减体积 + 增加逆向难度。
- 控制流混淆 / 字符串加密:打乱执行流、加密敏感字符串(API key、URL),运行时解密。
- 注意:JNI 注册的 native 方法、反射调用的类、序列化字段要 keep,否则崩溃。
四、抓包与防抓包(你的实战领域)
- 抓包原理:Charles/Fiddler/mitmproxy 装根证书做中间人,解密 HTTPS;Wireshark 抓底层包。
- 防御手段:
- SSL Pinning(证书锁定):App 内置服务端证书/公钥指纹,只信任它,系统装的抓包根证书无效。
- 双向认证(mTLS):服务端也校验客户端证书。
- 代理检测:检测系统代理设置、VPN。
- 抓包工具检测:检测 Charles/Fiddler 证书、特征。
- Pinning 的边界:Pinning 能显著提高 MITM 成本,但不能作为单点方案。高安全 App 会把校验、参数签名、重放防护、完整性校验和风控策略组合使用,并把关键决策放到服务端复核。
- 关键参数加密 / 签名:即便被抓包,请求参数加密 + 签名(sign)防篡改和重放。
五、反调试与反 Hook
- 定位:反调试/反 Hook 不是为了单点阻止分析,而是为高价值动作提供环境风险信号,辅助服务端做分级处置(放行、二次验证、降级、拒绝)。
| 检测方向 | 原理直觉 | 局限与边界 | 防御用法 |
|---|---|---|---|
TracerPid / 调试状态 | 读取进程状态,判断是否存在调试附加迹象 | 系统版本、权限、工具行为会影响可靠性;单点信号容易误判 | 作为低成本信号,不单独决定封禁 |
ptrace 占位 | 让进程进入“已被跟踪”状态,提高再次附加成本 | 兼容性和稳定性风险较高,不同内核/ROM 表现可能不同 | 仅用于高风险 SDK/核心逻辑,需灰度验证 |
| 时间差/断点异常 | 单步调试会放大关键路径耗时 | 设备性能、GC、调度抖动也会造成耗时波动 | 结合统计阈值和多次采样,避免一次命中即处置 |
| Frida/Xposed/注入痕迹 | 观察进程模块、类加载、堆栈或运行环境异常 | 工具版本和加载方式会变化,特征库需要维护 | 只输出风险等级,与完整性/行为风控合并判断 |
| inline hook 完整性 | 对关键 native 函数入口或代码段做完整性校验 | 编译优化、热补丁、自更新都可能改变代码布局 | 对少量关键函数做校验,异常时降级或上报 |
- 反 Hook:关注“是否有运行时插桩/代理/代码段异常”的风险信号,而不是背具体工具特征。工具特征随版本变化,面试要强调多信号融合 + 服务端决策 + 误伤控制。
- 完整性校验:运行时校验 APK 签名、DEX/so 的 CRC,发现篡改就退出/降级。
- 官方安全能力补充:可结合 Play Integrity API / Play Protect / App Access Risk 等信号判断“是否为可信 App、可信设备、低风险环境”。这些能力也不是唯一依据,更适合接入服务端风控策略,并对高价值动作按风险分层处理。
- 这是你的主场:这些正是设备指纹/风控 SDK 的核心对抗,你能讲实战细节,面试官会眼前一亮。
六、运行环境检测(风控核心)
- Root 检测:su 文件、Magisk、busybox、可写系统分区、危险属性。
- 模拟器检测:特征文件、CPU 架构(x86)、传感器缺失、IMEI/型号特征、qemu 痕迹。
- Hook 框架检测:见上(Frida/Xposed)。
- 多开/虚拟环境检测:包路径异常、进程名、/proc 特征。
- 设备指纹:综合硬件/系统/行为特征生成唯一标识——你的本职工作,可深入讲采集维度、稳定性、对抗篡改。
七、数据安全
- 敏感数据存储:用 EncryptedSharedPreferences / KeyStore 加密,不明文存 token。
- Android Keystore:密钥存于硬件安全模块(TEE/StrongBox),不出安全区。
- 传输安全:HTTPS + Pinning + 参数加密 + 签名防重放。
高频面试题
Q1:APK v1 和 v2 签名区别?v1 有什么漏洞? v1 是 JAR 签名,逐文件摘要、不保护 ZIP 元数据,可往 META-INF 加文件不破坏签名(被渠道包/注入利用)。v2 对整个 APK 签名块做校验,保护整体、更快、防篡改强。v3 加密钥轮转,v4 基于 Merkle 树支持增量。
Q2:App 加固有哪几代?VMP 为什么最难逆向? 一代整体 DEX 加密、二代方法抽取/按需还原、三代 VMP 指令虚拟化。VMP 把标准指令语义迁移到私有虚拟机里,分析者即使看到运行过程也需要理解解释器和私有指令语义,成本最高;但它也有性能、体积、兼容性成本,通常只保护核心逻辑。
Q3:什么是 SSL Pinning?它的边界是什么? 客户端只信任内置的服务端证书或公钥指纹,降低伪造 CA/MITM 抓包风险。边界是客户端逻辑始终可能被动态分析或篡改,所以高安全场景要组合 native 加固、完整性校验、参数签名、重放防护和服务端风控复核,不能只靠 Pinning 一个点。
Q4:怎么检测 App 被 Frida hook 了? 从防御视角看,可以收集运行时注入、模块加载、堆栈、代码段完整性、异常调试状态等风险信号;但具体特征会随工具版本和加载方式变化,不能当最终结论。工程上应多信号融合、服务端分级处置,并控制误伤。
Q5:怎么做反调试? 常见方向包括调试状态、关键路径时间异常、运行环境完整性和 native 代码段完整性等。回答重点不是背某个检测点,而是说明它们都有版本/ROM/工具差异和误判风险,应作为风控信号参与分级决策,而不是单点封禁。
Q6:设备指纹怎么提升唯一性和稳定性?(你的本职) 综合多维特征(硬件、系统、网络、行为)加权生成,容忍部分特征变化(系统升级/重置),并结合风险信号对抗篡改和模拟。讲清采集维度、降级策略、对抗思路即可——这是你的真实经验,放开讲。
Q7:敏感数据怎么安全存储? 用 Android Keystore 管理密钥(存 TEE/StrongBox 硬件区),用 EncryptedSharedPreferences 加密存储,绝不明文存 token/密钥,传输再叠加 HTTPS + Pinning。
进阶补充:逆向工具链、Native 分析与面试表达边界
常见工具链定位
| 工具 | 主要用途 |
|---|---|
| jadx / jadx-gui | Java/Kotlin 层反编译阅读 |
| apktool | 资源、Manifest、smali 处理 |
| Frida | 运行时 hook、参数/返回值观察 |
| Xposed / LSPosed | 模块化 hook 框架 |
| IDA / Ghidra | native so 静态分析 |
| objection | 基于 Frida 的移动安全辅助工具 |
Native so 分析要点
关注 ELF 结构、导出符号、JNI_OnLoad、RegisterNatives、字符串/xref、PLT/GOT、反调试和混淆。符号被 strip 后,可结合字符串、常量、调用图和动态 trace 恢复语义。
OWASP Mobile Top 10 视角
面试中可以从不安全存储、不安全通信、认证授权、代码篡改、逆向风险、敏感信息泄露等角度回答安全设计。
设备可信与服务端风控
客户端检测只能提高攻击成本,不能单独作为信任根。更稳妥的设计是客户端采集信号 + 服务端风控决策 + 灰度策略 + 异常监控。
面试表达边界
讲原理、风险、检测、防护和合规边界;避免细讲绕过步骤、攻击脚本和可直接滥用的操作流程。
**追问:**为什么不能只靠客户端反调试?因为攻击者控制运行环境,客户端防护只能增加成本,关键决策要在服务端完成。
移动安全防护体系
移动安全的成熟回答不是“客户端能绝对防住攻击”,而是“客户端提高成本、服务端做最终风控、工程上控制误伤和可用性”。本章只从防御、检测、加固和风险联动角度讲,不提供绕过脚本或攻击步骤。
一、防护体系的分层模型
移动端运行在用户可控设备上,不能把客户端当绝对可信根。合理体系要分层:
| 层级 | 目标 | 常见手段 | 边界 |
|---|---|---|---|
| 传输安全 | 降低中间人和篡改风险 | HTTPS、SSL Pinning、请求签名、重放防护 | 客户端逻辑可被分析,不能单点依赖 |
| 应用完整性 | 发现二次打包/篡改 | 签名校验、DEX/so 完整性、R8/ProGuard | 需兼容热修复、渠道包和灰度 |
| 环境风险 | 识别高风险设备 | root/hook/emulator/Frida 检测、设备可信信号 | 单点误判高,特征会变化 |
| 代码保护 | 提高逆向成本 | 混淆、字符串加密、native obfuscation、shell/packing | 性能、稳定性、可观测性成本 |
| 服务端风控 | 最终决策 | 风险评分、设备指纹、行为模型、二次验证 | 依赖数据质量和策略治理 |
面试总原则:客户端防护用于“采集信号 + 提高成本 + 延迟攻击”,高价值决策必须回到服务端风控。
二、root / hook / emulator / Frida 检测
环境检测的目标不是“发现一个特征就封禁”,而是形成风险画像。常见信号包括:
- Root 检测:su/Magisk 痕迹、危险系统属性、可写系统分区、异常 SELinux 状态、敏感目录权限。
- Hook 检测:可疑框架痕迹、异常类加载、调用栈异常、运行时注入迹象、关键函数入口完整性异常。
- 模拟器检测:硬件/传感器缺失、qemu/虚拟化特征、CPU/ABI、设备型号和系统属性组合异常。
- Frida 检测:进程、端口、模块、线程名、内存映射、行为时序等风险信号的组合观察。
防御表达要强调限制:
- 特征会随工具版本变化,需要远端配置和灰度。
- 厂商 ROM、测试设备、无障碍工具、企业 MDM 可能造成误判。
- 检测结果应是风险分,而不是唯一封禁依据。
- 高风险动作可触发二次验证、降级、延迟处理或服务端复核。
三、SSL Pinning 与传输防护
SSL Pinning 是客户端内置服务端证书或公钥指纹,连接时只信任预期身份,降低用户安装恶意根证书后的 MITM 风险。
传输安全组合拳:
HTTPS/TLS
+ SSL Pinning(证书或公钥)
+ 请求参数签名(timestamp + nonce + body digest)
+ 重放防护(服务端校验 nonce/时间窗)
+ 高风险接口二次校验(设备风险 + 行为风险)
工程注意:
- Pinning 要支持证书轮换,通常 pin 公钥或准备备份 pin。
- 失败策略要区分网络异常、证书异常、系统时间错误和灰度问题。
- 不要把密钥硬编码当唯一保护,客户端 secret 只能提高成本。
- 与 OkHttp/Network Security Config/自研网络层配合时要有测试覆盖。
四、R8/ProGuard 与 Java/Kotlin 层加固
R8/ProGuard 的基础能力是压缩、优化、混淆,安全收益是增加静态分析成本并减少可读语义。
- 重命名:类、方法、字段改短名,降低可读性。
- 优化/内联:删除无用代码、调整调用结构,增加还原成本。
- keep 规则:反射、序列化、JNI、路由、依赖注入入口必须保留,否则线上崩溃。
- mapping 管理:mapping 是还原崩溃栈的关键敏感资产,要按版本安全保存。
Kotlin 项目还要注意 data class、默认参数、协程状态机、序列化字段、Compose/Hilt/Room 生成代码的 keep 要求。安全和稳定必须平衡,不能为了“混得更狠”破坏运行时反射/JNI 入口。
五、native obfuscation、shell/packing 与完整性检查
Native 层常用于保护高价值算法、设备指纹采集、加解密和环境检测。常见防护包括符号隐藏、字符串加密、控制流混淆、关键代码段完整性校验和少量核心逻辑 native 化。
shell/packing(加壳/壳保护)的概念是把 DEX/so 或关键方法以加密、抽取、虚拟化等方式保护,运行时再加载或还原。面试只需讲目标、成本和边界:
- 目标:提高静态反编译和批量篡改成本。
- 成本:启动耗时、兼容性、崩溃定位、包体积、灰度复杂度。
- 边界:代码运行时总要以某种形式执行,客户端无法做到绝对不可分析。
完整性检查常覆盖 APK 签名、安装来源、DEX/资源摘要、so 代码段、关键配置和运行时内存异常。对热修复、插件化、渠道包要设计白名单和版本策略,否则容易误伤。
六、device trust 与服务端风控联动
设备可信可以结合 Play Integrity API、厂商安全能力、设备指纹、账号行为、网络环境和业务风险事件。关键是把客户端信号传给服务端做统一决策。
客户端采集:
设备指纹 + 完整性 + root/hook/emulator + 网络风险 + 行为摘要
↓
服务端风控:
规则/模型评分 + 账号历史 + 交易/登录上下文 + 黑白名单
↓
分级处置:
放行 / 降级 / 二次验证 / 延迟审核 / 拒绝 / 人工复核
服务端联动的优势:
- 风控策略可动态调整,不依赖发版。
- 能结合账号、设备、IP、行为、交易等多维数据。
- 可以做灰度、AB、阈值回滚和误伤监控。
- 客户端只上报必要风险信号,避免暴露完整策略细节。
七、安全工程化与合规边界
安全防护要纳入工程流程,不是临上线才加检测点。
- 威胁建模:识别登录、支付、设备绑定、优惠券、风控 SDK 等高价值资产。
- 分级防护:普通页面不应引入高成本防护,核心链路才做更强检测和加固。
- 可观测性:记录风险命中、误伤、崩溃、性能影响和策略版本。
- 隐私合规:设备指纹和环境信号要遵守最小必要、告知同意、用途限定和数据安全要求。
- 应急响应:证书轮换、密钥泄露、加固兼容性事故、误封回滚都要有预案。
面试表达边界:讲检测、防护、限制、误伤控制和服务端风控,不要讲绕过流程、攻击脚本、hook 代码或可直接复现的利用步骤。
高频面试题
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 基础与类型映射
JNI(Java Native Interface)是 Java/Kotlin 与 C/C++ 互调的桥梁。
类型映射
| Java 类型 | JNI 类型 | 签名 |
|---|---|---|
| boolean | jboolean | Z |
| byte | jbyte | B |
| char | jchar | C |
| int | jint | I |
| long | jlong | J |
| float | jfloat | F |
| double | jdouble | D |
| Object | jobject | L 全限定名; |
| String | jstring | Ljava/lang/String; |
| int[] | jintArray | [I |
| void | void | V |
方法签名
格式 (参数)返回值,如 (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_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6) == JNI_EDETACHED) {
attached_ = (vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK);
}
}
~ScopedJniEnv() {
if (attached_) vm_->DetachCurrentThread();
}
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。
五、native 调用 Java 与异常
- 调用流程:
FindClass → GetMethodID/GetStaticMethodID → CallXxxMethod。 - 异常处理:JNI 调用后若 Java 抛异常,native 不会中断,必须
ExceptionCheck/ExceptionOccurred检查并ExceptionClear,否则后续 JNI 调用行为未定义。
六、native crash 捕获(你的强项可深讲)
- native 崩溃(SIGSEGV/SIGABRT)不走 Java 的 try/catch,默认直接挂掉进程。
- 捕获方式:注册信号处理器(sigaction) 捕获 SIGSEGV、SIGABRT、SIGBUS 等,在 handler 里 dump 寄存器、调用栈(unwind)、maps。
- 成熟方案:Google Breakpad / Crashpad、字节的 xCrash。它们生成 minidump,符号化后定位。
- 难点:信号处理器中只能调用 async-signal-safe 函数;栈展开(unwind)需结合 .eh_frame/.ARM.exidx;符号化需保留带符号 so。
- 工程流程:线上包可 strip 符号减体积,但 CI/符号服务器必须保存同 build-id 的未裁剪 so;拿到 tombstone/minidump 后用
addr2line、ndk-stack或 Breakpad/Crashpad 工具映射到函数、文件和行号。
七、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 owner | C++ 侧弱引用 + Java/Kotlin 生命周期取消 | 避免 native 长生命周期对象强持 Activity。 |
| 大对象返回/转移 | 移动构造/移动赋值,必要时 std::move | 减少拷贝,但 moved-from 对象只应处于可析构/可重新赋值状态。 |
struct GlobalRefDeleter {
JavaVM* vm;
void operator()(jobject ref) const {
if (!ref) return;
ScopedJniEnv scoped(vm);
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/xCrash 生成 minidump 后符号化定位。
Q5:JNI 调用 Java 方法后为什么要检查异常? JNI 调用若使 Java 抛异常,native 代码不会自动中断,异常处于 pending 状态,此时继续调用其他 JNI 函数行为未定义。必须 ExceptionCheck 后 ExceptionClear 处理。
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 等资源包装进析构函数,避免裸 new/delete 和异常路径泄漏。
NDK Native 调试与 Crash 定位 ☆
Native 能力是你的差异化强项,但面试要讲成“稳定性与诊断能力”,不是炫底层。 本章把 JNI 引用、线程 attach、RegisterNatives、so 加载、ABI/CMake、tombstone、addr2line、ndk-stack、RAII 和 native 泄漏串成一套线上排障语言。
一、JNI 引用与线程模型复盘
Native 崩溃和泄漏里很大一部分来自 JNI 生命周期误用。引用类型要和生命周期严格匹配。
| 引用类型 | 生命周期 | 常见用途 | 风险 |
|---|---|---|---|
| Local Reference | 当前 native 调用或 local frame | 临时对象、字符串、数组元素 | 循环中不释放会引用表溢出;不能跨线程缓存。 |
| Global Reference | DeleteGlobalRef 前有效 | 缓存 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_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6) == JNI_EDETACHED) {
attached_ = (vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK);
}
}
~ScopedAttach() {
if (attached_) vm_->DetachCurrentThread();
}
JNIEnv* env() const { return env_; }
private:
JavaVM* vm_ = nullptr;
JNIEnv* env_ = nullptr;
bool attached_ = false;
};
面试高频追问是“为什么不能保存 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 重点看:
- signal:崩溃类型,如 SIGSEGV 空指针/非法地址访问,SIGABRT 主动 abort。
- fault addr:访问的非法地址,0x0 附近通常是空指针。
- thread name / tid:崩溃线程,判断是主线程、渲染线程还是业务线程。
- registers:寄存器现场,可辅助判断参数和访问地址。
- backtrace:so 名称、偏移地址、函数符号。
- 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 偏移映射到源码行
$ANDROID_NDK/toolchains/llvm/prebuilt/darwin-x86_64/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 是否被重新打包或裁剪。
六、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 GlobalRef | unique_ptr + 自定义 deleter | 析构时要拿当前线程 JNIEnv。 |
| mutex | std::lock_guard / std::unique_lock | 手动 lock/unlock 容易死锁。 |
| native handle | 封装 owner class | 所有权不清导致 double free。 |
RAII 还能提升面试表达:你不仅会写 native,还知道如何让 native 在异常、崩溃和复杂生命周期下更稳定。
七、native 泄漏与调试方法
Native 泄漏包括内存泄漏、fd 泄漏、线程泄漏、JNI 全局引用泄漏和图形/音视频资源泄漏。它们不一定出现在 Java heap 中,所以只看 LeakCanary 不够。
排查思路:
- 确认现象:RSS/PSS 持续增长、fd 数增长、线程数增长、崩溃前内存压力。
- 区分 Java/native:Android Studio Profiler、
dumpsys meminfo、heapprofd、Perfetto。 - 定位分配点:debug 包可用 malloc 调试、ASan/HWASan、heapprofd 采样。
- 检查 JNI 引用:全局引用是否释放,循环局部引用是否 DeleteLocalRef。
- 检查所有权:对象是否 double free、use-after-free、跨线程释放。
- 修复后回归:压测相同路径,观察内存/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 是引用类型。Swift 鼓励用 struct。
- 枚举:带关联值的枚举很强大,类比 Kotlin sealed class。
二、内存管理 ARC
- ARC(Automatic Reference Counting):编译期自动插入 retain/release,不是 GC(无运行时回收线程)。引用计数为 0 即释放。
- 循环引用:两个对象互相强引用导致无法释放(类比内存泄漏)。
- 解决:
weak(弱引用,可为 nil)、unowned(无主引用,不为 nil)。闭包捕获用[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 就好理解。
四、并发 GCD
- GCD(Grand Central Dispatch):iOS 的并发框架,基于队列。
- 主队列:
DispatchQueue.main,UI 操作(类比 Android 主线程/Dispatchers.Main)。 - 全局队列:
DispatchQueue.global(qos:),后台任务(类比 Dispatchers.IO/Default)。 - 异步/同步:
async(不阻塞,类比 launch)、sync(阻塞当前)。 - 现代 Swift 也有
async/await+Task(类比 Kotlin 协程),概念可迁移。
DispatchQueue.global().async { // 后台
let data = loadData()
DispatchQueue.main.async { // 回主线程更新 UI
self.label.text = data
}
}
五、Android / iOS 概念对照表
| 概念 | Android | iOS |
|---|---|---|
| 页面 | Activity | UIViewController |
| 列表 | RecyclerView | UITableView/UICollectionView |
| 声明式 UI | Jetpack Compose | SwiftUI |
| 异步主线程切换 | Handler/协程 | GCD/async-await |
| 内存管理 | GC(可达性分析) | ARC(引用计数) |
| 空安全 | Kotlin 可空类型 | Optional |
| 接口 | interface | protocol |
| 依赖管理 | Gradle | CocoaPods/SPM |
| 本地存储 | SharedPreferences/DataStore | UserDefaults |
| 包格式 | APK/AAB | IPA |
高频面试题(iOS 通常只是辅助考察)
Q1:iOS 的内存管理和 Android 有什么不同? iOS 用 ARC,编译期插入 retain/release 做引用计数,计数为 0 即释放,无运行时 GC 线程,需手动用 weak/unowned 打破循环引用。Android 用 GC 可达性分析在运行时回收,无需手动管理引用计数。
Q2:weak 和 unowned 区别? 都不增加引用计数。weak 引用对象释放后变 nil(可选);unowned 假设对象在引用期间一直存在,释放后访问会崩溃(非可选)。生命周期不确定用 weak,确定不短于自身用 unowned。
Q3:struct 和 class 区别? struct 是值类型(赋值/传参拷贝,栈),class 是引用类型(共享,堆,有 ARC)。Swift 优先用 struct(线程安全、无副作用)。
Q4:SwiftUI 和 Compose 像吗? 非常像。都是声明式、状态驱动、UI=f(state)、单向数据流。@State 类比 remember+mutableStateOf,@Binding 类比状态提升。理解了 Compose 基本能迁移过去。
Q5:你做过 iOS 吗?能转吗?(开放题) 诚实说做过部分 iOS,理解 Swift/UIKit/ARC 基础,有跨端经验;强调底层(C/C++)和概念是相通的,跨端学习成本可控。不要夸大成“精通“。
Kotlin Multiplatform
☆ KMP 是跨端趋势话题。你有 Android + iOS 基础,可以把它讲成“共享业务逻辑,保留原生体验“的跨端判断力。
一、KMP 解决什么问题
KMP 不是用一套 UI 替代所有平台,而是优先共享业务逻辑、网络、数据、算法、领域模型,UI 仍可保持原生。
二、expect / actual 机制
公共代码声明 expect,各平台提供 actual 实现。
// commonMain
expect class DeviceInfoProvider {
fun deviceModel(): String
}
// androidMain
actual class DeviceInfoProvider {
actual fun deviceModel(): String = android.os.Build.MODEL
}
三、推荐分层
| 层 | 是否适合共享 | 说明 |
|---|---|---|
| domain/model | 很适合 | 业务规则稳定 |
| data/network | 适合 | Ktor/serialization 可共享 |
| platform capability | 部分适合 | 用 expect/actual 封装 |
| UI | 看项目 | Compose Multiplatform 或原生 UI |
四、和 Flutter / React Native 的区别
KMP 更偏共享逻辑,不是强制统一 UI;Flutter/RN 更偏统一 UI 技术栈。选择取决于团队能力、UI 一致性诉求、原生能力复杂度。
五、与 iOS 的互操作
KMP 可生成 framework 给 iOS 调用,但要注意 Swift/Objective-C 暴露、协程到 iOS async 的桥接、异常模型、包体积和调试体验。
高频面试题
Q1:KMP 适合什么场景? 答:适合共享业务逻辑、协议、数据层和算法;如果项目主要难点是高度定制 UI 或平台能力差异很大,要谨慎。
Q2:KMP 和 Flutter 怎么选? 答:想统一 UI 且团队接受新渲染栈可考虑 Flutter;想保留原生 UI、共享核心逻辑可考虑 KMP。
Q3:expect/actual 的意义是什么? 答:公共层定义平台能力抽象,各平台提供实际实现,既复用业务逻辑又不牺牲平台差异。
易错点 / 追问
- 不要说 KMP 能无成本一套代码跑所有端。
- 不要把平台差异强行塞进 commonMain。
- iOS 调试、构建、二进制大小都要提前评估。
跨端技术对比 ☆
跨端选型不是站队,而是在业务目标、团队能力、体验要求和长期维护成本之间做取舍。 面试中要能把 Flutter、React Native、Compose Multiplatform、WebView Hybrid、小程序容器、KMP 和原生开发放在同一张决策表里比较。
一、跨端技术解决的核心问题
跨端技术的目标通常有三类:降低多端重复开发、提升发布效率、统一体验或业务逻辑。但不同方案共享的层次不同,不能简单说“一套代码跑所有端”。
| 方案 | 主要共享内容 | UI 渲染 | 典型诉求 |
|---|---|---|---|
| Flutter | UI + 业务逻辑 | 自绘 Skia/Impeller | 高一致性 UI、快速迭代、多端统一。 |
| React Native / RN | UI 描述 + JS 业务逻辑 | Native 组件桥接/Fabric | Web/前端团队复用经验,保留部分原生体验。 |
| Compose Multiplatform | Compose UI + Kotlin 逻辑 | Compose 渲染 | Kotlin 技术栈统一,桌面/移动共享 UI。 |
| WebView Hybrid | Web 页面 + 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 生态团队,但成熟度和平台覆盖要按项目评估。
| 技术 | 共享层次 | 优势 | 风险 |
|---|---|---|---|
| KMP | domain、data、network、算法、平台抽象 | 保留原生 UI,共享核心逻辑,适合 Android 团队扩展 iOS | iOS 互操作、构建、调试、异常模型需要治理。 |
| Compose Multiplatform | Compose UI + Kotlin 逻辑 | Kotlin/Compose 技术栈统一,适合内部工具/桌面/部分移动场景 | 移动端生态和复杂平台 UI 适配仍需评估。 |
KMP 的成熟回答是:它不是替代 Flutter/RN 的“统一 UI”方案,而是“共享核心逻辑 + 平台 UI 自治”。如果团队最痛的是重复写接口、模型、业务规则和算法,KMP 很合适;如果最痛的是两端 UI 开发成本,则要考虑 Flutter、RN 或 CMP。
五、WebView Hybrid 与小程序容器
WebView Hybrid 依赖 WebView 承载页面,Native 提供 JSBridge、离线包、权限、路由和监控。它发布快、成本低,适合活动页、运营页、内容页和低复杂交互。
小程序容器则是更完整的运行时:用统一 DSL、组件、权限模型和包管理承载第三方或内部轻应用。
| 维度 | WebView Hybrid | 小程序容器 |
|---|---|---|
| 发布效率 | 高 | 高,且更标准化。 |
| 体验 | 受 WebView 和前端性能影响 | 比普通 H5 更受控,但仍受容器能力限制。 |
| Native 能力 | JSBridge 按需开放 | 权限模型和 API 网关更体系化。 |
| 适合场景 | 活动、内容、表单、轻业务 | 平台生态、商家/插件接入、可治理轻应用。 |
| 风险 | 白屏、Bridge 安全、缓存错配 | 容器研发成本、标准制定和生态治理。 |
面试时要强调 Hybrid 和小程序容器都是“受控运行时”,核心不只是加载页面,还包括离线包、预加载、权限、监控、灰度、回滚和安全边界。
六、原生开发仍然不可替代的场景
原生开发成本高,但在很多场景仍是最稳的选择:
- 高性能图形、音视频、游戏、实时通信、复杂动画。
- 深度系统能力:相机、传感器、蓝牙、支付、安全、风控 SDK、NDK。
- 对启动、内存、包体积、稳定性有极致要求的核心链路。
- 平台差异很大,强行跨端会制造大量条件分支。
- 长期维护团队以 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/小程序;如果要极致体验和复杂平台能力,坚持原生。
高频面试题
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、业务逻辑还是发布能力;再看体验要求、原生能力复杂度、团队技术栈、治理能力和长期维护成本。不要为了技术流行牺牲核心链路稳定性。
易错点 / 追问
- 不要说跨端一定省成本:桥接、容器、监控、兼容性和人才成本都要算进去。
- 不要把 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 | 避免各业务页配置不一致。 |
onResume | webView.onResume()、恢复 JS timer | 防止后台回来后页面定时器异常。 |
onPause | webView.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 scheme | JS 跳转自定义 URL,Native 拦截解析 | 兼容性好,隔离清晰 | 参数长度、编码、异步回调复杂。 |
evaluateJavascript | Native 执行 JS 字符串回传结果 | 适合 Native 主动通知 | 需要主线程;要处理转义和页面状态。 |
| WebMessage | 标准消息通道 | 边界更清晰 | 版本兼容和封装成本。 |
addJavascriptInterface 的核心风险是把 Native 能力暴露给不可信页面。安全设计要做到:
- 只允许可信 HTTPS 域名使用 Bridge,页面跳转后重新校验 origin。
- Bridge 方法白名单化,参数做类型、长度、业务权限校验。
- 不暴露通用反射、文件、命令、账号 token 等高危能力。
- Debug 包和 Release 包区分 WebView 调试能力。
- 所有敏感调用写审计日志,便于定位异常页面行为。
面试表达边界:只讲风险、隔离和校验原则,不要给出绕过或攻击脚本。
三、缓存、Cookie 与离线资源
WebView 缓存涉及 HTTP cache、Service Worker、DOM Storage、IndexedDB、Cookie 和 App 自己的离线包。面试中要区分“浏览器内建缓存”和“Hybrid 容器离线包”。
| 缓存类型 | 适用内容 | 控制点 |
|---|---|---|
| HTTP Cache | JS/CSS/图片等静态资源 | 服务端 Cache-Control、ETag、版本号。 |
| DOM Storage / IndexedDB | 页面本地数据 | 容量、清理策略、隐私合规。 |
| Cookie | 登录态、会话 | SameSite、Secure、HttpOnly、同步时机。 |
| 离线包 | H5 应用资源包 | 包签名、版本、灰度、回滚、增量更新。 |
离线包通常在 App 启动或空闲时下载,校验签名和摘要后解压到私有目录;页面加载时通过 URL 映射或请求拦截优先命中本地资源,未命中再走网络。这样能提升首屏速度和弱网可用性,但要避免缓存污染、版本错配和敏感数据落盘。
四、白屏诊断与性能优化
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 容器通常包含以下模块:
- 页面配置中心:域名白名单、Bridge 权限、离线包版本、降级策略。
- 离线包管理:下载、签名校验、解压、版本切换、失败回滚。
- Bridge 框架:方法注册、权限校验、异步回调、超时、日志。
- 预加载池:WebView 预创建、常用页面预取、内存上限、生命周期清理。
- 监控系统:白屏率、首屏耗时、JS error、资源失败、Bridge 成功率。
- 安全合规:隐私协议、权限最小化、敏感 API 审计、调试开关隔离。
成熟回答要强调 Hybrid 不是“把网页塞进 App”,而是一个受控运行时:发布快,但必须用容器能力把安全、性能、权限和回滚治理起来。
高频面试题
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,而是如何保证可控:
- 来源可信:动态包必须做签名、摘要、版本和灰度校验,不能把任意外部文件交给
DexClassLoader。 - 依赖隔离:插件依赖和宿主依赖可能类名冲突,要约定公共 API 层,避免插件直接依赖宿主内部实现。
- 生命周期控制:插件类、线程、单例和缓存要能随插件卸载或宿主退出释放,否则容易泄漏 Activity/Context。
- 性能控制:首次加载 dex 会有校验和优化成本,要结合预下载、空闲预热、灰度开关。
- 兼容性控制:Android 版本、厂商 ROM、hidden API 限制会影响反射修改内部结构的稳定性。
三、资源加载与 AssetManager
插件不仅有代码,还有 layout、drawable、string 等资源。插件化常见做法是通过反射或公开能力创建新的 AssetManager,把插件 apk 路径加入资源搜索路径,再构造 Resources 对象供插件使用。
| 问题 | 常见方案 | 风险点 |
|---|---|---|
| 插件资源 ID 与宿主冲突 | 插件独立编译,运行时用插件 Resources 查找 | 不能把宿主 R.xxx 和插件 R.xxx 混用。 |
| 主题 / 样式不生效 | 用插件 Context 包装 Resources 和 Theme | Activity/Window 主题链要处理完整。 |
| 资源更新后缓存旧值 | 插件版本化路径 + 清理旧 Resources 引用 | 全局 Drawable/Bitmap 缓存可能持有旧资源。 |
| 多语言 / 深色模式 | 同步宿主 Configuration | 配置变化时要通知插件刷新。 |
面试回答资源加载时,可以用一句话概括:代码靠 ClassLoader,资源靠 AssetManager/Resources,四大组件靠代理或 Hook,三者都要有版本、隔离和回滚机制。
四、Activity 插件化与组件代理
Android 四大组件需要在 Manifest 中声明,而插件 Activity 往往没有安装到系统。传统插件化会通过“坑位 Activity + 代理分发”解决这个问题。
- 宿主 Manifest 预先声明一个或多个 ProxyActivity。
- 启动插件页面时,实际启动 ProxyActivity,并在 Intent 中携带插件 Activity 类名。
- ProxyActivity 创建插件实例,把生命周期、Context、Window、资源访问转发给插件。
- 插件页面只实现约定接口或继承插件基类,不直接暴露给系统 AMS。
这种方式的难点在于生命周期和系统能力不是简单函数转发:启动模式、onActivityResult、权限回调、Fragment、主题、横竖屏、进程恢复都要适配。更激进的方案会 Hook Instrumentation / AMS 相关路径,但面试中应强调维护成本和系统版本风险,不要把 Hook 当默认答案。
五、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 实验 | 策略、开关、文案、流程 | 风险最低,但不能替换任意代码。 |
面试中的成熟表达是:动态化要根据业务价值选择最小可行方案。能用配置解决就不要下发代码;必须下发代码时,先设计安全校验、兼容性矩阵、灰度回滚和观测体系。
高频面试题
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
音视频不是所有 Android 岗都深考,但一旦岗位涉及播放器、短视频、直播,Media3/ExoPlayer、首帧、卡顿和缓存就是硬门槛。
一、音视频基础概念
| 概念 | 含义 |
|---|---|
| 容器 | MP4、MKV、FLV,负责封装音视频轨道 |
| 编码 | H.264/H.265/AAC,负责压缩 |
| 解码 | 将压缩数据还原为可播放帧 |
| 帧率/码率 | 影响流畅度、清晰度、带宽 |
| PTS/DTS | 播放时间戳/解码时间戳 |
二、Android 播放链路
数据源 → demux 解封装 → audio/video decoder → audio track / surface 渲染 → 同步播放。
三、Media3 / ExoPlayer 核心组件
| 组件 | 职责 |
|---|---|
| Player | 播放控制 API |
| MediaItem | 媒体描述 |
| MediaSource | 数据源与 timeline |
| Renderer | 音频/视频渲染 |
| LoadControl | 缓冲策略 |
| DataSource | 网络/文件读取 |
四、首帧、卡顿与缓冲优化
- 首帧:DNS/TCP/TLS、manifest、首包、解码器初始化都会影响。
- 卡顿:网络波动、缓冲不足、解码耗时、渲染阻塞。
- 优化:预加载、合适 buffer、缓存、降码率、错误重试。
五、缓存与离线播放
缓存要考虑 key、清理策略、空间上限、版权/加密、断点续传。不要把短视频缓存策略简单套到长视频。
六、常见业务场景
- Feed 自动播放:静音、预加载、滑出暂停、资源释放。
- 直播:低延迟优先,容忍少量画质波动。
- 长视频:稳定和清晰度优先,可接受较大缓冲。
高频面试题
Q1:首帧慢怎么排查? 答:拆链路看网络建连、首包、manifest、缓存命中、解码器初始化、surface ready,用埋点分段定位。
Q2:Media3 和 ExoPlayer 什么关系? 答:Media3 是 Jetpack 媒体库集合,ExoPlayer 已迁移到 Media3 命名空间,核心思想仍是 Player/MediaSource/Renderer 等组件。
Q3:Feed 视频怎么避免滑动卡顿? 答:控制同时播放数量、提前 prepare、复用 player 或控制 player 池、滑出及时释放 surface/解码资源,不要在 bind 阶段做重初始化。
易错点 / 追问
- 不要混淆容器格式和编码格式。
- 不要只说“加缓存“,要说明缓存 key、大小和清理策略。
- 低延迟和稳定性通常是 tradeoff。
LeetCode Hot 100 算法清单
本清单即 LeetCode 热题 HOT 100,共 100 题,业界公认的标准刷题集。分类与顺序合理:按「数据结构由易到难 + 算法思想递进」组织。
针对你的定位(中级 Android 转应用):Android 算法面试以中等题为主。下方每个分类标注了 ⭐(高频必刷)和 △(偏难/低频,可缓刷),按优先级投入精力。
如何使用
- 第一轮:只刷 ⭐ 高频题,建立每类的解题模板(约 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. 寻找重复数
知识点解析
下面按分类讲解每类的核心思想、解题模板、复杂度、常见陷阱。吃透模板比刷题数量更重要。
1. 哈希表
核心思想:用 O(1) 查找换取时间,典型“空间换时间“。统计频次、判断存在、查找配对。
模板(两数之和):边遍历边查表,查 target - num 是否已出现。
fun twoSum(nums: IntArray, target: Int): IntArray {
val map = HashMap<Int, Int>() // value -> index
for (i in nums.indices) {
val need = target - nums[i]
if (map.containsKey(need)) return intArrayOf(map[need]!!, i)
map[nums[i]] = i
}
return intArrayOf()
}
- 49 字母异位词分组:排序后的字符串作 key,或用字符计数数组作 key。
- 128 最长连续序列:用 HashSet,只从“序列起点“(
num-1不存在)开始向后扩展,保证 O(n)。 - 复杂度:多为 O(n) 时间、O(n) 空间。
- 陷阱:128 题必须从起点扩展,否则退化成 O(n²)。
2. 双指针
核心思想:用两个指针协同移动,避免暴力双层循环。常见三类:快慢指针、左右对撞、同向滑动。
模板(左右对撞 - 盛最多水):
fun maxArea(height: IntArray): Int {
var l = 0; var r = height.size - 1; var ans = 0
while (l < r) {
ans = maxOf(ans, minOf(height[l], height[r]) * (r - l))
if (height[l] < height[r]) l++ else r-- // 移动短板
}
return ans
}
- 283 移动零:快慢指针,慢指针指向待填位置。
- 15 三数之和:排序 + 固定一个数 + 双指针,注意去重(跳过重复值)。
- 42 接雨水(△):左右指针 + 维护 leftMax/rightMax;也可用单调栈或 DP。
- 陷阱:三数之和的去重是高频踩坑点;移动短板的贪心要想清楚为什么不移长板。
3. 滑动窗口
核心思想:双指针的进阶,维护一个 [left, right] 窗口,右扩入、左缩出,在 O(n) 内解决子串/子数组问题。
通用模板:
fun slidingWindow(s: String): Int {
val window = HashMap<Char, Int>()
var left = 0; var ans = 0
for (right in s.indices) {
window[s[right]] = (window[s[right]] ?: 0) + 1
while (/* 窗口不满足条件 */ window[s[right]]!! > 1) {
window[s[left]] = window[s[left]]!! - 1 // 左缩
left++
}
ans = maxOf(ans, right - left + 1) // 更新答案
}
return ans
}
- 3 无重复最长子串:窗口内无重复字符。
- 438 找异位词 / 76 最小覆盖子串(△):用计数 + need/valid 计数判断窗口达标。
- 560 和为 K 的子数组:注意这题不是滑窗(有负数),要用前缀和 + 哈希表。
- 239 滑动窗口最大值(△):用单调队列(双端队列),不是普通滑窗。
- 陷阱:560 和 239 是“伪滑窗“,真正要用前缀和/单调队列,别套错模板。
4. 普通数组
核心思想:原地操作、前缀和、边界处理、贪心。看似基础,实则技巧密集。
- 53 最大子数组和:经典 DP / Kadane ——
dp[i] = max(num, dp[i-1]+num),维护全局最大。
fun maxSubArray(nums: IntArray): Int {
var cur = nums[0]; var ans = nums[0]
for (i in 1 until nums.size) {
cur = maxOf(nums[i], cur + nums[i])
ans = maxOf(ans, cur)
}
return ans
}
- 56 合并区间:按起点排序,遍历合并重叠区间。
- 189 轮转数组:三次反转法(整体反转 → 前 k 反转 → 后 n-k 反转),O(1) 空间。
- 238 除自身以外乘积:前缀积 × 后缀积,不用除法。
- 41 缺失第一个正数(△):原地哈希,把
num放到nums[num-1]位置,O(1) 空间是难点。 - 陷阱:41 题要求 O(1) 空间 + O(n) 时间,只能用原地置换;189 的三次反转要记牢。
5. 矩阵
核心思想:二维坐标变换、原地标记、利用有序性。考验空间想象力。
- 73 矩阵置零:用首行首列做标记位,O(1) 额外空间(进阶);简单版用两个 set。
- 54 螺旋矩阵:维护上下左右四个边界,按层收缩遍历。
- 48 旋转图像:先转置(沿主对角线)再水平翻转,等价于顺时针 90°。
- 240 搜索二维矩阵 II:从右上角开始,大则左移、小则下移,O(m+n)。
- 陷阱:54 螺旋矩阵的边界判断和退出条件是 bug 高发区;48 旋转要记住“转置+翻转“组合。
// 48 旋转图像:转置 + 水平翻转
fun rotate(m: Array<IntArray>) {
val n = m.size
for (i in 0 until n) for (j in i + 1 until n) {
val t = m[i][j]; m[i][j] = m[j][i]; m[j][i] = t // 转置
}
for (row in m) row.reverse() // 每行翻转
}
6. 链表
核心思想:指针操作的细节功底。三大利器:虚拟头节点 dummy、快慢指针、递归。面试重中之重。
反转链表(母题,务必背熟):
fun reverseList(head: ListNode?): ListNode? {
var prev: ListNode? = null
var cur = head
while (cur != null) {
val next = cur.next
cur.next = prev // 反转指向
prev = cur
cur = next
}
return prev
}
- dummy 头节点:删除/合并/插入时,在头前加哨兵节点,统一处理头节点边界(21、19、2、24、25)。
- 快慢指针:141/142 判环(Floyd 龟兔,142 求入环点要推数学公式)、19 删倒数第 N 个(快指针先走 N 步)、找中点(148 排序、234 回文)。
- 160 相交链表:双指针走到头切到对方链表,相遇即交点。
- 138 随机链表复制:哈希表映射原节点→新节点,或原地交错复制。
- 148 排序链表:归并排序(快慢找中点 + 合并),O(n log n)。
- 23 合并 K 个(△):小顶堆 或 分治两两合并。
- ⭐ 146 LRU 缓存:哈希表 + 双向链表——这题和你工作里的 LruCache 直接相关,面试高频,必须手写到熟。get/put 都要 O(1),双向链表维护访问顺序,哈希表定位节点。
- 陷阱:142 入环点的数学推导;25 K 个一组翻转的边界;改指针时先存 next 防丢失。
7. 二叉树
核心思想:递归是灵魂。明确“以当前节点为根,左右子树要返回什么“,写好递归三要素(返回值、终止条件、单层逻辑)。
遍历模板:
// 递归中序
fun inorder(root: TreeNode?, res: MutableList<Int>) {
if (root == null) return
inorder(root.left, res)
res.add(root.`val`) // 中序:左-根-右
inorder(root.right, res)
}
// 层序(BFS,用队列)
fun levelOrder(root: TreeNode?): List<List<Int>> {
val res = mutableListOf<List<Int>>()
val queue = ArrayDeque<TreeNode>()
root?.let { queue.add(it) }
while (queue.isNotEmpty()) {
val level = mutableListOf<Int>()
repeat(queue.size) { // 固定本层节点数
val node = queue.removeFirst()
level.add(node.`val`)
node.left?.let { queue.add(it) }
node.right?.let { queue.add(it) }
}
res.add(level)
}
return res
}
- 递归类:104 深度、226 翻转、101 对称、543 直径、110 平衡——都是“后序+返回子树信息“。
- BST 性质:98 验证(中序递增)、230 第 K 小(中序第 k 个)、108 有序数组转 BST(取中点为根)。
- 构造/视图:102 层序、199 右视图(每层最后一个)、105 前序+中序建树、114 展开为链表。
- 路径/祖先:236 最近公共祖先(LCA 递归)、437 路径总和 III(前缀和)、124 最大路径和(△,后序+全局更新)。
- 陷阱:98 验证 BST 不能只比父子,要用上下界或中序;124/543 要区分“经过节点的路径“和“向上返回的贡献“。
8. 图论
核心思想:DFS/BFS 遍历 + 状态标记。网格类把矩阵当图,每个格子是节点。
- 200 岛屿数量:遍历网格,遇到陆地 DFS/BFS 把整片淹掉(标记访问),计数。
- 994 腐烂的橘子:多源 BFS,所有腐烂橘子同时入队,按层扩散记录分钟数。
- 207 课程表:拓扑排序,建图 + 入度,BFS(Kahn)或 DFS 判环。能完成所有课 = 无环。
// 200 岛屿 DFS 淹没
fun dfs(grid: Array<CharArray>, i: Int, j: Int) {
if (i !in grid.indices || j !in grid[0].indices || 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)
}
- 208 Trie 前缀树:每个节点 26 个子指针 + isEnd 标记,insert/search/startsWith。
- 陷阱:207 必须判环(有环则无法完成);994 是多源 BFS 不是单源。
9. 回溯
核心思想:系统穷举解空间。模板 = 选择 → 递归 → 撤销选择。画出决策树就清晰了。
通用模板:
fun backtrack(path: MutableList<Int>, choices: ...) {
if (/* 满足结束条件 */) { res.add(ArrayList(path)); return }
for (choice in choices) {
if (/* 剪枝:不合法跳过 */) continue
path.add(choice) // 做选择
backtrack(path, ...) // 进入下一层
path.removeAt(path.size - 1) // 撤销选择
}
}
- 排列 vs 组合:全排列(46)用 used 标记;子集(78)/组合(39)用 start 下标避免重复。
- 39 组合总和:可重复选,递归传 i(不是 i+1);需排序剪枝。
- 22 括号生成:左括号数 < n 可加左,右 < 左可加右。
- 79 单词搜索 / 51 N皇后(△):网格/棋盘回溯 + 状态标记 + 回撤。
- 131 分割回文串:每个切点判回文后递归。
- 陷阱:结果要
ArrayList(path)拷贝(否则引用同一个被清空);组合问题用 start 去重,排列用 used。
10. 二分查找
核心思想:有序(或部分有序)空间折半。难在边界:开闭区间、< vs <=、mid±1。坚持一种写法练到肌肉记忆。
模板(闭区间):
fun search(nums: IntArray, target: Int): Int {
var lo = 0; var hi = nums.size - 1 // 闭区间 [lo, hi]
while (lo <= hi) {
val mid = lo + (hi - lo) / 2 // 防溢出
when {
nums[mid] == target -> return mid
nums[mid] < target -> lo = mid + 1
else -> hi = mid - 1
}
}
return -1
}
- 35 搜索插入位置:返回 lo 即插入点。
- 34 查找首尾位置:两次二分找左边界、右边界。
- 33/153 旋转数组:判断哪半有序,再决定收缩方向。
- 74 搜索二维矩阵:展平成一维做二分。
- 4 两正序数组中位数(△):二分切分,O(log(m+n)),Hot100 最难之一,可缓刷。
- 陷阱:
mid = lo + (hi-lo)/2防溢出;旋转数组要先判断有序半区。
11. 栈
核心思想:LIFO 后进先出。匹配类、表达式类用普通栈;“找下一个更大/更小元素“用单调栈。
- 20 有效括号:左括号入栈,右括号匹配栈顶。
- 155 最小栈:辅助栈同步存当前最小值,getMin O(1)。
- 394 字符串解码:双栈(数字栈 + 字符串栈)处理嵌套。
- 739 每日温度:单调递减栈,存下标,出栈时算距离。
- 84 柱状图最大矩形(△):单调递增栈,找每根柱子左右第一个更矮的。
// 739 每日温度:单调栈
fun dailyTemperatures(t: IntArray): IntArray {
val res = IntArray(t.size)
val stack = ArrayDeque<Int>() // 存下标,栈内温度单调递减
for (i in t.indices) {
while (stack.isNotEmpty() && t[i] > t[stack.last()]) {
val j = stack.removeLast()
res[j] = i - j
}
stack.addLast(i)
}
return res
}
- 陷阱:单调栈存下标还是值要想清楚;84 题常加哨兵简化边界。
12. 堆(优先队列)
核心思想:动态维护极值。Top K、数据流问题首选。Kotlin 用 PriorityQueue。
- 215 第 K 大:小顶堆维护 K 个最大(堆顶是第 K 大);或快速选择 O(n) 平均。
- 347 前 K 高频:哈希计数 + 小顶堆按频次,或桶排序 O(n)。
- 295 数据流中位数(△):大顶堆(左半)+ 小顶堆(右半) 对顶,中位数从堆顶取。
// 215 小顶堆维护 K 个最大值
fun findKthLargest(nums: IntArray, k: Int): Int {
val heap = java.util.PriorityQueue<Int>() // 小顶堆
for (n in nums) {
heap.offer(n)
if (heap.size > k) heap.poll() // 弹出最小,留下 K 个最大
}
return heap.peek()
}
- 陷阱:求第 K 大用小顶堆(反直觉),求第 K 小用大顶堆;295 双堆要维护大小平衡。
13. 贪心
核心思想:每步取局部最优,期望达到全局最优。关键是证明贪心成立(否则要用 DP)。
- 121 买卖股票:遍历记录历史最低价,每天算“今天卖“的最大利润。
- 55 跳跃游戏:维护能到达的最远下标,遍历中若当前位置超过最远则失败。
- 45 跳跃游戏 II:贪心找每一步能跳到的最远范围,边界处步数+1。
- 763 划分字母区间:记录每个字母最后出现位置,遍历扩展当前区间右界,到界则切分。
// 55 跳跃游戏
fun canJump(nums: IntArray): Boolean {
var farthest = 0
for (i in nums.indices) {
if (i > farthest) return false // 到不了 i
farthest = maxOf(farthest, i + nums[i])
}
return true
}
- 陷阱:贪心不是万能,要确认局部最优能推全局;55/45 的“最远可达“思路要分清。
14. 动态规划
核心思想:把大问题拆成重叠子问题,用 dp 数组存历史结果避免重算。五步:定义状态 → 状态转移方程 → 初始化 → 遍历顺序 → 返回值。DP 是面试天花板,务必吃透母题。
单维 DP
- 70 爬楼梯:
dp[i] = dp[i-1] + dp[i-2](斐波那契),DP 入门母题。 - 198 打家劫舍:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])(偷或不偷)。 - 322 零钱兑换:完全背包,
dp[amount] = min(dp[amount - coin] + 1)。 - 300 最长递增子序列:
dp[i]= 以 i 结尾的 LIS,O(n²);进阶贪心+二分 O(n log n)。 - 139 单词拆分:
dp[i]= 前 i 个字符能否拆分。 - 152 乘积最大子数组:同时维护最大、最小(负负得正)。
- 416 分割等和子集:01 背包,目标 sum/2。
- 32 最长有效括号(△):
dp[i]= 以 i 结尾的最长有效长度,转移较绕。
// 322 零钱兑换(完全背包)
fun coinChange(coins: IntArray, amount: Int): Int {
val dp = IntArray(amount + 1) { amount + 1 } // 初始化为不可达
dp[0] = 0
for (i in 1..amount)
for (c in coins)
if (c <= i) dp[i] = minOf(dp[i], dp[i - c] + 1)
return if (dp[amount] > amount) -1 else dp[amount]
}
多维 DP
- 62 不同路径:
dp[i][j] = dp[i-1][j] + dp[i][j-1](网格路径母题)。 - 64 最小路径和:同上取 min + 当前值。
- 5 最长回文子串:区间 DP,
dp[i][j]= s[i..j] 是否回文;或中心扩展。 - 1143 最长公共子序列(LCS):
dp[i][j],相等 +1,否则取两侧 max——序列型 DP 母题。 - 72 编辑距离(△):
dp[i][j]= word1 前 i 转 word2 前 j 的最少操作(增删改三选一取 min)。
// 1143 LCS:序列型 DP 母题
fun longestCommonSubsequence(a: String, b: String): Int {
val dp = Array(a.length + 1) { IntArray(b.length + 1) }
for (i in 1..a.length) for (j in 1..b.length) {
dp[i][j] = if (a[i-1] == b[j-1]) dp[i-1][j-1] + 1
else maxOf(dp[i-1][j], dp[i][j-1])
}
return dp[a.length][b.length]
}
- 陷阱:dp 数组大小常为
n+1(含空状态);初始化和遍历顺序错是主要 bug;先吃透 LCS、01背包、完全背包三个母题,其余是变体。
15. 技巧题
核心思想:发现问题的特殊性质,用位运算、数学、特定结构巧解。考察活用能力。
- 136 只出现一次的数字:异或,相同抵消,剩下的就是答案,O(1) 空间。
- 169 多数元素:摩尔投票,众数计数抵消法,O(1) 空间。
- 75 颜色分类:荷兰国旗,三指针(0/1/2)一次遍历原地排序。
- 31 下一个排列:从右找第一个升序对,交换 + 反转后缀。
- 287 寻找重复数:把数组看成链表,快慢指针找环(Floyd),不修改数组、O(1) 空间。
// 136 只出现一次:异或
fun singleNumber(nums: IntArray): Int {
var x = 0
for (n in nums) x = x xor n // a^a=0, a^0=a
return x
}
- 陷阱:这类题“知道就秒、不知道想破头“,务必记住每题的 trick(异或、摩尔投票、Floyd 判环)。
刷题策略总结
高频母题(背到能默写)
反转链表、二叉树三种遍历 + 层序、二分模板、回溯模板、滑动窗口模板、01背包/完全背包/LCS。这 7 类模板覆盖了 Hot 100 的 70%。
针对中级 Android 的优先级
- 第一梯队(必刷,中小厂够用):本文 ⭐ 标记的 ~40 题。尤其 146 LRU(和 LruCache 直接相关,Android 超高频)、链表全套、二叉树遍历、岛屿数量、爬楼梯/打家劫舍/零钱兑换、买卖股票、20 有效括号。
- 第二梯队(冲大厂):其余中等题。
- 第三梯队(△ 困难,选刷):42 接雨水、4 中位数、23 合并K个、25 K个一组、84 柱状图、124 最大路径和、51 N皇后、72 编辑距离。时间不够可跳过,面到再说。
方法论
- 分类刷,不要顺序刷:一次攻一个专题,内化模板。
- 二刷三刷:第一遍看题解理解,第二遍独立写,第三遍默写模板。
- 手写 + 口述复杂度:面试要边写边讲思路和时间/空间复杂度。
- 限时:中等题目标 20-30 分钟内 AC。
- 错题本:记录卡壳点和 trick,面试前快速过。
算法补充专题
Hot 100 没专门成章、但面试(尤其国内)高频会问的几类经典主题。每节含知识点 + 模板代码 + 高频题 + 陷阱。
优先级:排序手写 ⭐ 和位运算 ⭐ 几乎必问,优先吃透;并查集、前缀和/差分、设计题中频,看公司。
进度自测
- ⭐ 手写快速排序(分区、复杂度、最坏情况)
- ⭐ 手写归并排序(稳定、O(n log n))
- ⭐ 手写堆排序(建堆、下沉)
- 排序对比:稳定性 / 复杂度 / 适用场景
- ⭐ 位运算基础(
&|^<<>>>>>) - ⭐
n & (n-1)、判 2 的幂、统计 1 的个数 - 出现 3 次只出 1 次、子集枚举、状态压缩
- 并查集模板(find 路径压缩 + union 按秩)
- 并查集应用:省份数量 547 / 冗余连接 684
- 一维前缀和 / 二维前缀和
- 差分数组(区间增减)
- 设计题:LFU 460 / 用栈实现队列 232 / 循环队列 622
1. 手写排序(⭐ 国内几乎必问)
面试常让你手写并分析,而不只是调 API。重点掌握快排、归并、堆排,能说清复杂度、稳定性、适用场景。
快速排序
分治:选 pivot → 分区(小的在左、大的在右)→ 递归两边。平均 O(n log n),最坏 O(n²)(已有序且固定选端点),不稳定,原地。
fun quickSort(a: IntArray, lo: Int, hi: Int) {
if (lo >= hi) return
val p = partition(a, lo, hi)
quickSort(a, lo, p - 1)
quickSort(a, p + 1, hi)
}
fun partition(a: IntArray, lo: Int, hi: Int): Int {
val pivot = a[hi] // 取末尾为基准
var i = lo
for (j in lo until hi) {
if (a[j] < pivot) { a[i] = a[j].also { a[j] = a[i] }; i++ }
}
a[i] = a[hi].also { a[hi] = a[i] } // pivot 归位
return i
}
优化:随机选 pivot 避免最坏;三数取中;小数组转插入排序。
归并排序
分治:对半拆 → 递归排序 → 合并两个有序数组。稳定,O(n log n) 恒定,需 O(n) 额外空间。链表排序(148)首选。
fun mergeSort(a: IntArray, lo: Int, hi: Int, tmp: IntArray) {
if (lo >= hi) return
val mid = lo + (hi - lo) / 2
mergeSort(a, lo, mid, tmp); mergeSort(a, mid + 1, hi, tmp)
var i = lo; var j = mid + 1; var k = lo
while (i <= mid && j <= hi) tmp[k++] = if (a[i] <= a[j]) a[i++] else a[j++]
while (i <= mid) tmp[k++] = a[i++]
while (j <= hi) tmp[k++] = a[j++]
for (x in lo..hi) a[x] = tmp[x]
}
堆排序
建大顶堆 → 反复把堆顶(最大)换到末尾并下沉。原地,不稳定,O(n log n)。理解它就理解了优先队列。
fun heapSort(a: IntArray) {
val n = a.size
for (i in n / 2 - 1 downTo 0) sink(a, i, n) // 建堆 O(n)
for (end in n - 1 downTo 1) {
a[0] = a[end].also { a[end] = a[0] } // 堆顶换到末尾
sink(a, 0, end) // 缩小范围下沉
}
}
fun sink(a: IntArray, i: Int, n: Int) {
var root = i
while (true) {
var largest = root; val l = 2*root+1; val r = 2*root+2
if (l < n && a[l] > a[largest]) largest = l
if (r < n && a[r] > a[largest]) largest = r
if (largest == root) break
a[root] = a[largest].also { a[largest] = a[root] }
root = largest
}
}
排序对比(高频问)
| 算法 | 平均 | 最坏 | 空间 | 稳定 | 备注 |
|---|---|---|---|---|---|
| 快排 | 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²) 的触发条件(已有序+固定 pivot)和随机化优化是必答点;Java 的
Arrays.sort基本类型用双轴快排、对象用 TimSort(稳定)。
2. 位运算专题(⭐)
核心运算:&(与)、|(或)、^(异或)、~(取反)、<<(左移)、>>(算术右移,补符号位)、>>>(逻辑右移,补 0)。
异或三大性质(高频):a^a=0、a^0=a、满足交换/结合律。用于找单身数、交换变量、加密。
必背技巧:
n and (n - 1) // 消除最低位的 1
n and (-n) // 取最低位的 1(lowbit,树状数组用)
(n and (n - 1)) == 0 // 判断是否 2 的幂(n>0)
x shr i and 1 // 取第 i 位
x or (1 shl i) // 置第 i 位为 1
x and (1 shl i).inv() // 清第 i 位
x xor (1 shl i) // 翻转第 i 位
经典题:
- 统计 1 的个数(191):
while (n != 0) { n = n and (n-1); count++ },循环次数 = 1 的个数。 - 2 的幂(231)/4 的幂:
n > 0 && (n and (n-1)) == 0。 - 只出现一次(136):全员异或。
- 出现 3 次只出 1 次(137):按位统计模 3,或用两个状态变量。
- 只出现一次 II(260,两个数):全异或得
a^b,用 lowbit 分组再各自异或。 - 子集枚举(78):用
0..(1<<n)-1每个 bit 表示选/不选。 - 状态压缩 DP:用 int 的 bit 表示集合状态(如旅行商、棋盘覆盖)。
// 78 子集:位运算枚举
fun subsets(nums: IntArray): List<List<Int>> {
val res = mutableListOf<List<Int>>()
val n = nums.size
for (mask in 0 until (1 shl n)) { // 2^n 种状态
val sub = mutableListOf<Int>()
for (i in 0 until n) if (mask shr i and 1 == 1) sub.add(nums[i])
res.add(sub)
}
return res
}
- 陷阱:Kotlin 用
and/or/xor/shl/shr/ushr关键字而非符号;>>与>>>区别(负数右移);判 2 的幂别忘了n > 0。
3. 并查集(Union-Find)
核心思想:维护不相交集合,支持两个近 O(1) 操作:find(找代表元/根)、union(合并两集合)。专治连通性 / 分组问题。
模板(路径压缩 + 按秩合并):
class UnionFind(n: Int) {
private val parent = IntArray(n) { it } // 初始各自为根
private val rank = IntArray(n) // 树高(秩)
var count = n; private set // 连通分量数
fun find(x: Int): Int {
if (parent[x] != x) parent[x] = find(parent[x]) // 路径压缩
return parent[x]
}
fun union(a: Int, b: Int) {
val ra = find(a); val rb = find(b)
if (ra == rb) return
when { // 按秩合并:矮树挂高树
rank[ra] < rank[rb] -> parent[ra] = rb
rank[ra] > rank[rb] -> parent[rb] = ra
else -> { parent[rb] = ra; rank[ra]++ }
}
count--
}
fun connected(a: Int, b: Int) = find(a) == find(b)
}
应用:
-
547 省份数量:把相连城市 union,最后数连通分量。
-
684 冗余连接:加边时若两端已连通,该边就是多余的。
-
200 岛屿数量:也可用并查集(陆地格子 union 相邻陆地)。
-
判环 / 等式方程可满足性(990)。
-
复杂度:加上路径压缩 + 按秩合并后,单次操作近乎 O(α(n))(反阿克曼,可视作常数)。
-
陷阱:别忘路径压缩(否则退化成链 O(n));带权并查集是进阶变体。
4. 前缀和 & 差分
互为逆运算的一对技巧。前缀和:O(1) 查区间和(适合“多次查询、不修改“)。差分:O(1) 做区间增减(适合“多次区间修改、最后查询“)。
一维前缀和
pre[i] = nums[0] + ... + nums[i-1],区间 [l, r] 和 = pre[r+1] - pre[l]。
val pre = IntArray(n + 1)
for (i in 0 until n) pre[i + 1] = pre[i] + nums[i]
// 区间 [l, r] 之和:
val sum = pre[r + 1] - pre[l]
- 560 和为 K 的子数组:前缀和 + 哈希表,找
pre[j] - pre[i] = k,即统计pre[j] - k出现次数。 - 724 寻找中心下标、304 二维区域和。
二维前缀和
pre[i][j] = 左上角到 (i-1,j-1) 的矩形和。容斥:
区域和 = pre[r2+1][c2+1] - pre[r1][c2+1] - pre[r2+1][c1] + pre[r1][c1]
差分数组
diff[i] = nums[i] - nums[i-1]。对区间 [l, r] 同时加 val,只需 diff[l] += val; diff[r+1] -= val,最后对 diff 求前缀和还原。
// 给区间 [l, r] 都加 val(多次操作)
diff[l] += value
if (r + 1 < n) diff[r + 1] -= value
// 最后还原:对 diff 求前缀和
for (i in 1 until n) diff[i] += diff[i - 1]
- 1109 航班预订统计、1094 拼车:典型差分。
- 陷阱:前缀和数组开
n+1长度用 0 哨兵简化边界;差分注意r+1越界判断;560 题有负数不能用滑窗,只能前缀和。
5. 设计题
考数据结构组合 + 工程抽象能力,和你做 SDK 的强项契合。核心套路:用合适的数据结构组合,让每个操作达到要求的复杂度。
LRU 缓存(146,Hot 100 已有,这里强调)
哈希表 + 双向链表:哈希表 O(1) 定位节点,双向链表维护访问顺序(头=最近,尾=最久)。get/put 均 O(1)。和你工作中的 LruCache 直接相关,务必手写到熟。
LFU 缓存(460,难)
按使用频次淘汰,频次相同淘汰最久未用。结构:key→节点 哈希 + freq→双向链表 哈希 + 维护 minFreq。比 LRU 复杂一档,大厂常考。
用栈实现队列(232)/ 用队列实现栈(225)
- 栈→队列:两个栈(in 栈、out 栈),out 空时把 in 全部倒过来。摊还 O(1)。
- 队列→栈:入队后把前面元素轮转到后面。
设计循环队列(622)
固定数组 + 头尾指针 + 取模。判空 head == tail,判满留一个空位或用 size 计数。
设计 HashMap(706)/ 设计 Trie(208)
- HashMap:数组 + 链表(拉链法)处理冲突,理解负载因子和扩容。
- Trie:见 Hot 100 图论节,26 叉树 + isEnd。
// 232 两个栈实现队列
class MyQueue {
private val inStack = ArrayDeque<Int>()
private val outStack = ArrayDeque<Int>()
fun push(x: Int) = inStack.addLast(x)
fun pop(): Int { transfer(); return outStack.removeLast() }
fun peek(): Int { transfer(); return outStack.last() }
fun empty() = inStack.isEmpty() && outStack.isEmpty()
private fun transfer() {
if (outStack.isEmpty())
while (inStack.isNotEmpty()) outStack.addLast(inStack.removeLast())
}
}
- 陷阱:设计题先和面试官确认接口和复杂度要求(像做 API 设计),再选数据结构;LFU 维护 minFreq 是难点;循环队列的判空判满边界。
小结:补充专题优先级
- 必吃透(几乎必问):手写快排/归并/堆排 + 三者对比、位运算技巧。
- 建议掌握:并查集模板 + 547、前缀和/差分(尤其前缀和配哈希)。
- 加分:设计题(LFU、栈队互换)——展示工程抽象,和你 SDK 背景契合。
海量数据处理
这一篇是你的差异化武器。 海量数据题考的是“内存放不下时怎么办“的系统思维——而你做设备指纹 SDK,天天和内存约束、大规模数据、性能极限打交道。一般应用开发者答不深,你能结合底层经验讲透,这是面试加分点。
核心套路就四招:分治(哈希拆分)、位图、布隆过滤器、堆/外部排序。
进度自测
- 哈希分治(大文件拆小文件)
- 位图 BitMap(去重/排序/查存在)
- 布隆过滤器(原理/误判/删除问题)
- Top-K(小顶堆 / 分治 / 快速选择)
- 外部排序(多路归并)
- 经典场景:40亿整数判存在 / 10亿URL去重 / Top100热词
一、核心思想:内存放不下怎么办
面试给的经典约束:数据量远超内存(如 40 亿整数、100GB 日志,但内存只有 1GB)。解题主线:
- 能不能压缩表示? → 位图(1 个整数用 1 bit)。
- 能不能拆分? → 哈希分治(相同 key 必落同一小文件,分而治之)。
- 只要近似/允许误判? → 布隆过滤器(极省空间)。
- 只要前 K / 排序? → 堆 / 外部多路归并。
二、位图 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 常用的空间压缩手段,可主动提及“做指纹去重时用过类似位压缩思路“。
三、布隆过滤器(Bloom Filter)
思想:位图 + 多个哈希函数。判断元素“一定不存在“或”可能存在“。极省空间,代价是有误判率(false positive)。
1. 核心机制与近似公式
插入 x:用 k 个哈希函数算出 k 个位置,全部置 1。
查询 x:k 个位置全为 1 → 可能存在;任一为 0 → 一定不存在。
- 误判率 p 的近似公式:
p ≈ (1 - e^(-kn/m))^km: 位数组的长度(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 ≈ 117KB。 - 面试话术: “比起把 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 是否可能在数据库。
-
联系你的背景:风控/反作弊里黑名单判断、设备去重就常用布隆过滤器,这是你能讲实战的点。
四、哈希分治(分而治之)
思想:大文件按 hash(key) % N 拆成 N 个小文件,相同 key 必进同一小文件。每个小文件能进内存后单独处理,再汇总。
模板流程:
- 遍历大文件,按哈希把记录分到 N 个小文件。
- 对每个小文件单独用哈希表/堆处理(统计频次、去重、Top-K)。
- 合并各小文件的结果(如各自 Top-K 再归并出全局 Top-K)。
- 应用:10 亿 URL 去重、统计每个词的频次、求两个大文件的交集。
- 要点:分治的前提是“相同 key 落同一桶“,所以必须用 key 的哈希分,不能随便切。
五、Top-K 问题
三种解法按场景选:
- 小顶堆(数据流/超大数据):维护大小为 K 的小顶堆,堆顶是第 K 大,O(n log K) 时间、O(K) 空间。海量数据首选(不用全载入)。
- 快速选择(数据能进内存):基于快排分区,平均 O(n) 找第 K 大,但会修改/需载入数据。
- 哈希分治 + 堆(数据放不下):先哈希分治统计频次,各桶取局部 Top-K,再归并。
→ Top-100 热搜词:哈希分治统计词频 + 每桶小顶堆 + 归并。
六、外部排序
思想:数据放不下内存时的排序。分块 + 多路归并:
- 把大文件切成能进内存的小块,各块在内存排序后写回磁盘(生成“顺串“)。
- 用多路归并(K 路败者树/小顶堆)把有序小块合并成全局有序。
- 应用:100GB 日志按时间排序、超大文件去重后排序。
- 联系:归并排序的磁盘版,体现你对 I/O 与内存权衡的理解。
七、经典场景速答
| 场景 | 解法 |
|---|---|
| 40 亿整数判断某数是否存在 | 位图(~500MB) |
| 40 亿整数找只出现一次的 | 2-bit 位图 |
| 10 亿 URL 去重 | 哈希分治 / 布隆过滤器(允许误判) |
| 100GB 日志 Top-100 热词 | 哈希分治统计词频 + 小顶堆 + 归并 |
| 求两个超大文件的交集 | 各自哈希分治到对应桶,桶内求交 |
| 100GB 文件排序 | 外部排序(分块 + 多路归并) |
| 缓存穿透防护 | 布隆过滤器挡在缓存前 |
| 数据流求中位数 | 大顶堆 + 小顶堆对顶(Hot 100 #295) |
面试怎么讲(结合你的优势)
回答海量数据题时,先问清约束(数据量、内存、是否允许误判、要精确还是近似),再选招式,最后说权衡。这套“先问约束再设计“的思路本身就是工程素养。
你可以主动关联:
“我做设备指纹 SDK 时,设备去重和黑名单判断都涉及大规模数据 + 内存约束。用过位压缩做去重、布隆过滤器做快速存在性判断,对空间/时间/误判率的权衡有实战体会。”
这一句话就把算法题变成了你的项目亮点,是普通应用开发者给不出的答案。
Android 业务算法场景
Android 面试里的算法不只 LeetCode。真实业务更常问:图片缓存怎么淘汰、埋点怎么采样、请求怎么限流、日志怎么去重、Feed 分页怎么合并、设备指纹怎么做相似度。本篇把算法落到移动端工程场景。
一、LRU cache 与图片缓存淘汰
LRU(Least Recently Used)适合“最近访问还会再访问”的局部性场景。Android 图片库、页面数据缓存、解码 Bitmap 复用池都常用 LRU 或近似 LRU。
| 场景 | Key | Value | 淘汰依据 | 注意点 |
|---|---|---|---|---|
| 内存图片缓存 | URL + resize + transform | Bitmap/Drawable | 占用字节数 | 防 OOM,按 maxMemory 比例设置 |
| 磁盘图片缓存 | 安全 hash 后的 URL | 文件 | 总大小/最近访问 | 避免文件名过长,写入原子性 |
| 页面接口缓存 | route + params | JSON/Entity | TTL + LRU | 过期和一致性比命中率更重要 |
| BitmapPool | width/height/config | 可复用 Bitmap | 大小分桶 | 避免频繁分配导致 GC |
class BitmapMemoryCache(maxBytes: Int) : LruCache<String, Bitmap>(maxBytes) {
override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
}
图片缓存面试要讲完整链路:内存 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
三、限流、滑动窗口与重试保护
限流(rate limiting)在移动端用于保护接口、SDK 回调、日志上报、按钮连点和弱网重试。常见算法要结合业务选择。
| 算法 | 机制 | 适用场景 | 缺点 |
|---|---|---|---|
| 固定窗口 | 每个时间窗最多 N 次 | 简单按钮防抖、低风险接口 | 窗口边界可能突刺 |
| 滑动窗口 | 记录最近 T 时间内请求数 | 登录、验证码、上报保护 | 需要维护时间队列 |
| 令牌桶 | 固定速率生成 token,允许突发 | 网络请求、日志上报 | 参数要调优 |
| 漏桶 | 匀速流出 | 平滑上传队列 | 突发吸收能力弱 |
滑动窗口移动端实现直觉:
- 用队列保存事件时间戳。
- 新事件到来时移除超过窗口的旧时间。
- 队列大小小于阈值则允许,否则拒绝或延迟。
- 对持久化场景可只存计数桶,避免内存无限增长。
四、设备指纹相似度、布隆过滤器与本地风控
设备指纹/风控 SDK 常需要“快速判断是否见过、是否相似、是否命中黑名单”。这类题能把你的业务背景讲成算法亮点。
- 指纹相似度:把设备属性向量化,对稳定字段加高权重(硬件、系统特征),对易变字段低权重(IP、网络);用加权 Jaccard/余弦相似判断是否同设备族。
- SimHash/局部敏感哈希:把高维特征压成指纹,海明距离小表示相似,适合快速近似匹配。
- 布隆过滤器:本地黑名单、已上报 ID、去重 key 的快速存在性判断;回答“一定不存在/可能存在”和误判率。
- Counting Bloom Filter:需要删除时使用计数器,但空间变大。
- 隐私注意:指纹算法要服务于合规风控,最小化采集、脱敏、加密存储,不能无限收集敏感信息。
五、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, ...]
查询:分词/拼音归一化 → 取倒排列表 → 交并集 → 按权重排序
六、分页合并、去重与一致性
Feed、IM、订单列表常见问题:分页返回重复、刷新和加载更多交错、服务端数据更新导致顺序变化。本质是有序流合并与去重。
- 唯一 key 去重:用 itemId/serverId 去重,不要用 position。
- 游标分页优先:使用 cursor/lastId/createdAt,比 offset 更稳。
- 本地合并:新页与旧列表按排序 key 归并,重复 item 更新内容。
- 状态分离:refresh、append、prepend 分别维护 loading/error/cursor。
- Room + Paging3:RemoteMediator 把网络页落库,UI 观察数据库,降低进程死亡和旋转带来的状态丢失。
常见坑:服务端删除或置顶会改变排序,客户端只 append 可能出现缺失或重复;要定期 refresh 或使用版本号/增量同步。
高频面试题
Q1:设计图片内存缓存为什么用 LRU? 列表和详情页存在时间局部性,最近展示过的图片很可能再次出现。LRU 能在固定内存预算下保留热点 Bitmap,但要按字节数计算 size,并结合磁盘缓存、采样解码和生命周期取消。
Q2:埋点日志太多怎么上报? 用稳定采样控制比例,错误链路提高采样;对短时间重复事件做 fingerprint 去重或合并计数;本地队列批量 gzip 上报,失败指数退避,并设置磁盘上限防止撑爆存储。
Q3:移动端限流怎么实现? 按钮防抖可固定窗口,接口/上报更适合滑动窗口或令牌桶。要限制次数、设置退避,对非幂等请求避免自动重发,必要时让服务端用幂等 key 去重。
Q4:布隆过滤器适合哪些 Android 业务? 适合本地黑名单、已上报事件去重、缓存穿透保护、设备指纹快速存在性判断。它能回答“一定不存在/可能存在”,有误判但不会漏判,需要删除时用 Counting Bloom Filter。
易错点 / 追问
- 易错:只背 LRU 的 HashMap+双向链表,不讲 Bitmap 字节数、OOM、磁盘缓存和生命周期。
- 追问:滑动窗口和固定窗口区别?滑动窗口按最近 T 时间精确限制,固定窗口边界可能出现双倍突刺。
- 易错:TopK 直接全量排序;数据流或日志聚合应使用大小为 K 的小顶堆或近似高频算法。
- 追问:分页去重为什么不能按 position?刷新、插入、置顶会改变位置,必须用稳定业务 id。
操作系统与数据库基础
计算机基础四大件的另外两件(网络在 18 篇)。操作系统这块你底层强,容易拿分;数据库移动端偏 SQLite,掌握核心概念即可。
第一部分:操作系统
一、进程、线程、协程
| 进程 | 线程 | 协程 | |
|---|---|---|---|
| 资源 | 独立地址空间 | 共享进程内存 | 共享线程,用户态 |
| 调度 | OS | OS | 用户/运行时 |
| 开销 | 大 | 中 | 小 |
| 通信 | IPC | 共享内存(需同步) | 直接 |
- 进程:资源分配的基本单位,有独立内存空间。
- 线程:CPU 调度的基本单位,共享进程资源,需同步(锁)。
- 协程:用户态轻量“线程“,由程序调度,挂起不阻塞内核线程(见 02 篇 Kotlin 协程)。
二、进程间通信(IPC)
管道、消息队列、共享内存(最快,需同步)、信号量、Socket、信号。Android 主用 Binder(见 09 篇,一次拷贝 + 安全)。
三、内存管理
- 虚拟内存:每个进程有独立虚拟地址空间,通过页表映射到物理内存,实现隔离 + 按需加载。
- 分页:内存分固定大小页,虚拟页↔物理页帧映射,缺页中断时从磁盘加载。
- 页面置换:内存不够时选择淘汰哪个页,目标是降低未来缺页率。
页面置换算法
| 算法 | 机制 | 优点 | 缺点/坑点 | 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) | 每个任务运行一个时间片,到期切换 | 响应公平,适合交互 | 时间片太小切换开销大,太大退化为 FCFS | UI/交互系统强调响应 |
| 优先级调度 | 高优先级先运行 | 能表达重要性/实时性 | 低优先级可能饥饿,需 aging 提升等待过久任务 | Android 线程优先级、后台任务降级 |
| 多级反馈队列(MLFQ) | 多队列不同优先级/时间片,任务按行为升降级 | 兼顾交互与吞吐 | 参数复杂,可能被行为模式影响 | 通过反馈识别短交互任务与长 CPU 任务 |
Linux/Android 关联:普通 Linux 任务主要由 CFS(Completely Fair Scheduler) 调度,它不是简单 RR,而是用虚拟运行时间 vruntime 近似公平:谁“用得少”谁更容易被调度。Android 在此基础上叠加线程优先级、cgroup/cpuset、前后台进程调度策略;UI 线程应避免长时间占 CPU,否则即使能被调度也会错过 16.6ms 帧预算。
面试答题流:先说目标(公平/响应/吞吐),再逐个算法讲机制和缺点,最后补“真实 Linux/Android 用 CFS + 优先级/cgroup,不是直接套书本算法”。
五、I/O 模型(进阶,可选)
阻塞 I/O、非阻塞 I/O、I/O 多路复用(select/poll/epoll)、信号驱动、异步 I/O。Android 的 Looper 底层就用了 epoll(无消息时阻塞等待,见 09 篇)。
第二部分:数据库
一、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 次数)、叶子节点链表(范围查询快)、非叶子只存索引(单页存更多键)。
- 聚簇索引 vs 非聚簇索引:聚簇索引叶子存整行数据(主键),非聚簇叶子存主键需回表。
- 最左前缀:联合索引
(a,b,c)从左匹配,where b=x用不上。 - 索引失效:对索引列函数运算、隐式类型转换、
like '%x'前缀模糊、OR 部分无索引。 - 代价:占空间、拖慢写入(增删改要维护索引)。
三、事务(ACID)
- A 原子性:全成功或全回滚。
- C 一致性:事务前后数据完整性约束不破坏。
- I 隔离性:并发事务互不干扰。
- D 持久性:提交后永久保存。
隔离级别(解决并发问题)
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 否 | 可能 | 可能 |
| 可重复读(MySQL默认) | 否 | 否 | 可能(InnoDB 用 MVCC+间隙锁基本解决) |
| 串行化 | 否 | 否 | 否 |
- 脏读:读到别的事务未提交的数据。
- 不可重复读:同一事务两次读同一行结果不同(被别人 update)。
- 幻读:同一查询两次返回行数不同(被别人 insert)。
四、SQLite / 移动端
- SQLite:嵌入式、单文件、无服务进程,Android 本地存储核心(Room 基于它)。
- WAL 模式:写前日志,提升并发(读写不互斥)。
- 移动端实践:用 Room 而非裸 SQLite(编译期校验 + 协程/Flow);大数据量分页;事务批量写;索引优化查询;数据库迁移 Migration。
高频面试题
Q1:进程和线程区别? 进程是资源分配单位有独立地址空间;线程是 CPU 调度单位共享进程资源。进程间隔离开销大,线程间共享内存需同步。
Q2:死锁产生的四个条件?怎么避免? 互斥、持有并等待、不可剥夺、循环等待。破坏任一即可:如资源一次性申请(破坏持有并等待)、按固定顺序申请资源(破坏循环等待)。
Q3:为什么数据库索引用 B+ 树? B+ 树矮胖减少磁盘 I/O,非叶子节点只存键能存更多、降低树高,叶子节点链表利于范围查询和排序。相比红黑树/B 树更适合磁盘存储。
Q4:事务隔离级别?分别解决什么问题? 读未提交→读已提交(解决脏读)→可重复读(解决不可重复读)→串行化(解决幻读)。级别越高越安全但并发越低。
Q5:什么情况索引会失效?
索引列做函数运算/类型转换、like '%x' 前缀模糊、不满足最左前缀、OR 连接非索引列。
Q6:Looper 为什么不会因为死循环耗尽 CPU?(联系 OS) 底层用 epoll,MessageQueue 无消息时阻塞在 epoll_wait 让出 CPU,有消息再唤醒,不是忙等。
Q7:虚拟内存的作用? 给每个进程独立的地址空间(隔离 + 安全)、突破物理内存限制(按需分页 + 换页)、简化内存管理(连续虚拟地址映射到不连续物理页)。
操作系统进阶
Android 应用跑在 Linux 之上,面试里的 OS 题不是纯书本题:进程、线程、协程会落到主线程/线程池/协程调度;虚拟内存、mmap、page fault 会落到 Binder、文件映射和启动性能;epoll 会落到 Looper 与网络连接。
一、进程、线程、协程的进阶对比
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 资源边界 | 独立虚拟地址空间 | 共享进程地址空间 | 运行在线程上 |
| 调度者 | Linux 内核 | Linux 内核 | Kotlin/运行时协作调度 |
| 切换开销 | 高,涉及地址空间等上下文 | 中,保存寄存器/栈等 | 低,挂起恢复状态机 |
| Android 例子 | App 进程、WebView 多进程、remote service | UI 线程、RenderThread、OkHttp Dispatcher | suspend、Flow、ViewModelScope |
- 进程解决隔离:一个 App crash 不应拖垮系统;多进程可隔离 WebView、推送、风控 SDK。
- 线程解决并行:CPU 密集放 Default/计算线程,IO 放 IO/线程池,UI 只能主线程更新 View。
- 协程解决异步表达:挂起不等于新线程,
suspend只是把回调/状态机写成顺序代码。 - 面试关键句:协程不是 OS 调度单位,最终仍要落在线程上执行;阻塞式 IO 放错调度器仍会占住真实线程。
二、虚拟内存、mmap 与 page fault
虚拟内存让每个进程看到连续独立地址空间,由页表映射到物理页。Android 中它直接影响大文件读取、so 加载、Dex/OAT 映射、Binder 缓冲区等。
进程虚拟地址
↓ 页表 / MMU / TLB
物理内存页
↑
缺页(page fault)时由内核把文件页/匿名页调入内存
- mmap:把文件或设备映射到进程虚拟地址,按需加载,减少显式 read/copy;Binder 驱动也利用 mmap 建立用户态可访问缓冲区。
- page fault:访问的虚拟页尚未在物理内存中,CPU 触发异常进入内核处理。轻微缺页可能只建映射,重大缺页可能要读磁盘。
- 启动性能关联:冷启动读取 dex、resources、so 时可能产生大量缺页;Baseline Profile、预加载和减少冷路径大文件访问都能降低抖动。
- 内存压力关联:匿名页、文件页、Ashmem/共享内存都会参与系统回收;低端机上大 Bitmap 和大 mmap 文件都要考虑峰值。
三、文件描述符、I/O 与 epoll
Linux 把文件、socket、pipe、eventfd 等都抽象成文件描述符(fd)。Android 的网络、数据库、日志、Looper 唤醒都离不开 fd。
| I/O 机制 | 特点 | Android 关联 |
|---|---|---|
| 阻塞 I/O | 调用线程等待结果 | 主线程网络/磁盘会 ANR |
| 非阻塞 I/O | 没数据立即返回 | 需要轮询或事件通知 |
| select/poll | 监听多个 fd,但扩展性一般 | 传统多路复用方案 |
| epoll | 事件驱动,适合大量 fd | Looper、网络框架、native event loop |
Looper 为什么不忙等? MessageQueue 没消息时,底层通过 epoll_wait 阻塞等待 fd 事件或超时;有消息、Binder、输入事件、定时器到期时再被唤醒。这也是“死循环不等于耗 CPU”的经典追问。
fd 常见问题:
- 文件/网络流未关闭导致 fd 泄漏,最终
Too many open files。 - 日志、图片、数据库 Cursor 未及时 close。
- 连接池过大或泄漏导致 socket fd 占用异常。
四、调度、优先级与 Android 卡顿
Linux 普通任务主要由 CFS 调度,目标是公平分配 CPU;Android 在此基础上叠加进程优先级、线程 nice 值、cgroup/cpuset、前后台策略。
- UI 线程不是绝对优先:它仍要参与调度,如果自己执行长任务或系统 CPU 被打满,就会错过 16.6ms 帧预算。
- 线程优先级要谨慎:后台下载/日志压缩不应抢 UI;音视频、渲染、输入链路要避免被低价值任务干扰。
- 协程调度器不是魔法:
Dispatchers.Default适合 CPU,Dispatchers.IO适合阻塞 IO,乱用会导致线程饥饿。 - ANR 本质:主线程长时间无法处理输入、广播、服务生命周期等消息,可能是锁等待、IO、CPU、Binder 调用卡住。
五、锁、死锁与并发安全
死锁四条件:互斥、持有并等待、不可剥夺、循环等待。Android 中常见死锁并不只来自 synchronized,还来自主线程等待后台、后台反向切主线程、Binder 同步调用互等。
Thread-A: 持有 dbLock → 等待 networkLock
Thread-B: 持有 networkLock → 等待 dbLock
结果:循环等待,两边都无法推进
实践建议:
- 固定锁顺序,避免 A→B 与 B→A 混用。
- 不在持锁期间做网络、磁盘、Binder 或回调外部代码。
- 优先缩小临界区,读多写少用读写锁或不可变快照。
- Kotlin 协程中区分
Mutex与 JVM 锁,不要在synchronized内调用可能挂起的逻辑。 - 主线程不要等待后台锁;后台也不要同步等待主线程回调。
六、Android Linux 基础速记
- Zygote:预加载类和资源后 fork App 进程,降低启动成本;fork 后进程拥有独立虚拟地址空间,通过 COW 共享只读页。
- Binder:Android 主要 IPC,结合驱动、mmap、线程池和引用计数,比 Socket 更适合系统服务调用。
- cgroup/cpuset:系统按前后台、任务类型限制 CPU/资源分配,解释为什么后台任务可能变慢。
- LMK/内存回收:低内存时系统按进程重要性回收;App 要保存状态,不能假设进程永生。
- SELinux/权限模型:限制进程访问系统资源,移动安全和文件访问都要考虑沙箱边界。
高频面试题
Q1:进程、线程、协程怎么区分? 进程是资源隔离单位,线程是内核调度单位,协程是用户态/运行时的异步抽象。协程挂起不阻塞线程,但执行仍要占用线程,因此阻塞操作放错调度器仍会影响性能。
Q2:mmap 和普通 read 有什么区别? read 把数据从内核缓冲复制到用户缓冲,mmap 把文件映射到虚拟地址空间,访问时按页加载,可减少拷贝和简化随机访问。但 mmap 仍可能触发 page fault,不是“免费加载”。
Q3:Looper 底层为什么用 epoll? Looper 要同时等待消息队列、输入、Binder/管道等 fd 事件。epoll 能高效等待多个 fd,无事件时阻塞让出 CPU,有事件再唤醒,避免忙等。
Q4:Android 死锁如何排查和避免? 看线程堆栈确认谁持有什么锁、谁在等待;避免嵌套锁顺序不一致、持锁做耗时操作、主线程同步等待后台。必要时用超时、锁顺序规范和异步化拆环。
易错点 / 追问
- 易错:把协程说成轻量线程;准确说协程不是内核线程,它运行在线程之上。
- 追问:page fault 是否一定是坏事?不是,按需分页依赖它;但冷启动大量重大缺页会带来磁盘 IO 抖动。
- 易错:以为 epoll 只用于服务端高并发;Android Looper 和 native 事件循环同样依赖 fd 多路复用思想。
- 追问:为什么主线程没有死循环占 CPU?因为 MessageQueue 空闲时阻塞在 epoll_wait,不是 while true 忙轮询。
设计模式与 Android 源码应用
设计模式几乎每场中级面试都问,且面试官爱问**“在 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),不要跨层级“摸内部对象”(迪米特)。
二、创建型模式
单例(最高频)
确保一个类只有一个实例。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 应用:
Application、各种 Manager(getSystemService 返回的)、OkHttpClient 单例。
工厂 / 抽象工厂
封装对象创建,调用方只描述“我要什么”,不关心具体构造细节。
- 角色:调用方(Client)→ 工厂(Factory)→ 具体产品(Product)。
- Android 应用:
BitmapFactory.decodeStream()根据输入流/Options 创建Bitmap;LayoutInflater.from(context)根据Context找到合适 inflater;Executors根据方法创建不同线程池。 - 为什么匹配:调用方不直接
new Bitmap(...),而把复杂创建逻辑、兼容分支、缓存/复用细节交给工厂。
建造者(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。它匹配原型模式,因为新对象来自已有对象快照,而不是从零组装。
三、结构型模式
代理(Proxy)
为真实对象提供一个代理入口,在调用前后做控制、延迟、跨进程或增强。
- 角色:接口 Subject、真实对象 RealSubject、代理 Proxy。
- Retrofit:
retrofit.create(Api::class.java)用Proxy.newProxyInstance生成接口实现;调用api.getUser()时进入InvocationHandler,解析注解并创建 HTTP 请求。 - Binder AIDL:客户端拿到的是
Stub.Proxy,方法调用先写入Parcel,再通过 Binder 驱动发给服务端Stub.onTransact()。 - 为什么匹配:调用方以为自己在调普通接口,实际被代理拦截并转成网络/跨进程调用。
适配器(Adapter)
把一个接口转换成调用方需要的另一个接口。
- 角色:目标接口(Target)、被适配者(Adaptee)、适配器(Adapter)。
- RecyclerView.Adapter:数据源可能是
List<Item>,但RecyclerView需要getItemCount/onCreateViewHolder/onBindViewHolder这组接口;Adapter 把数据转换成可复用的ViewHolder绑定流程。 - 为什么匹配:
RecyclerView不直接认识业务数据,只认识 Adapter 契约;业务侧实现契约即可接入框架。
装饰器(Decorator)
在不修改原对象的情况下动态叠加能力。
- 角色:组件接口 Component、被装饰对象 ConcreteComponent、装饰器 Decorator。
- Android 应用:
ContextWrapper持有一个 baseContext并转发大多数调用;ContextThemeWrapper在 base 能力上增加 theme 解析能力。 - 为什么匹配:外部仍按
Context使用,但功能被包装增强;这与继承扩展不同,装饰可以按需组合。
外观(Facade)
提供统一高层接口,隐藏子系统复杂度。Android 应用:Context.getSystemService() 把 ActivityManager、ClipboardManager、LayoutInflater 等系统能力收口到统一入口;调用方不需要知道服务发现和 Binder 细节。它匹配外观模式,因为 Context 是简化入口,不是具体业务实现本身。
四、行为型模式
观察者(Observer,高频)
一对多依赖,被观察者状态变化时通知观察者。
- 角色:Subject 保存观察者列表,Observer 接收回调。
- Android 应用:
LiveData.observe(owner) {}在生命周期活跃时通知观察者;OnClickListener是 View 事件的观察回调;Adapter.notifyDataSetChanged()通知 RecyclerView 数据变化。 - 为什么匹配:数据/事件源不直接依赖具体页面逻辑,只通知已注册观察者;注意 Flow 更偏响应式流,面试可类比观察者但要说明它还有冷/热流、背压、协程上下文语义。
责任链(Chain of Responsibility,高频)
请求沿链传递,每个节点决定处理、增强或继续传递。
- OkHttp:
RealInterceptorChain.proceed(request)把请求交给下一个拦截器;应用拦截器、重试重定向、桥接、缓存、连接、CallServer 等节点逐层处理。 - View 事件分发:
Activity.dispatchTouchEvent → ViewGroup.dispatchTouchEvent/onInterceptTouchEvent → View.dispatchTouchEvent/onTouchEvent,每层决定拦截、消费或继续下发/回传。 - 为什么匹配:发送方不需要知道最终谁处理,链上每个节点只关心自己职责;这也是和 12 篇 OkHttp 的跨章重复点,这里讲模式原因即可。
策略(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 决定。
其他
- 迭代器:集合的
Iterator。 - 备忘录:
onSaveInstanceState。
五、记忆法:按“框架反推模式“
面试被问设计模式,从你熟的框架反推最稳:
- OkHttp 拦截器 → 责任链
- Retrofit create → 代理(动态代理)
- 各种 Builder → 建造者
- LiveData/Flow → 观察者
- RecyclerView.Adapter → 适配器
- 事件分发 → 责任链
- Glide.with → 建造者 + 单例
高频面试题
Q1:手写一个线程安全的单例。
用 DCL(双重检查锁)+ volatile,或静态内部类(依赖 JVM 类加载的线程安全初始化 + 懒加载),Kotlin 直接 object。volatile 防止指令重排拿到半初始化对象。
Q2:DCL 里 volatile 为什么必须加?
instance = Singleton() 不是原子操作,分为分配内存、初始化、赋值引用三步。指令重排后其他线程可能拿到已赋值但未初始化完成的对象。volatile 禁止重排。
Q3:OkHttp 用了什么设计模式? 责任链(拦截器链,核心)、建造者(OkHttpClient.Builder)、工厂、外观。重点讲责任链:请求依次经过各拦截器,每个调 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 内部用到观察者(数据绑定)。区分“设计模式“(类级别)和“架构模式“(应用级别)。
系统设计场景题
中高级面试的开放设计题,考架构思维而非标准答案。重点是展示分析过程:先问清需求/约束 → 分模块 → 定接口 → 讲权衡 → 提优化。你的底层 + 性能 + 架构知识能在这里综合发挥。
一、答题通用框架(套路)
任何“设计一个 X“都按这个结构答:
- 澄清需求:功能边界、量级(QPS/数据量)、性能/内存约束、平台。先问再答,体现工程素养。
- 整体架构:分几层/几个模块,各自职责。
- 核心模块详细设计:数据结构、关键算法、接口设计。
- 关键问题处理:并发、缓存、生命周期、异常、内存。
- 权衡与优化:为什么这么选,有哪些 trade-off,怎么扩展。
记住:面试官要看思路和沟通,不是要你写完整代码。 边画图边讲。
二、设计图片加载库(最高频,如自研 Glide)
需求澄清:支持网络/本地、缓存、列表滑动场景、防 OOM、生命周期安全。
整体架构:
请求(with/load/into) → 生命周期绑定 → 缓存查找(内存→磁盘)
→ 未命中则下载 → 解码(采样压缩) → 缓存写入 → 显示
核心设计:
- 三级缓存:内存(LruCache,弱引用存活动资源)→ 磁盘(DiskLruCache)→ 网络。
- 生命周期绑定:注入空 Fragment 监听宿主,onStop 暂停、onDestroy 取消请求(防泄漏)。
- 防 OOM:按目标 View 尺寸 inSampleSize 采样压缩;Bitmap 复用池(BitmapPool)。
- 线程模型:下载/解码在后台线程(线程池/协程),回调切主线程。
- 请求管理:列表复用时取消旧请求(ImageView tag 绑定请求)。
- 设计模式:建造者(请求构建)+ 单例(引擎)+ 观察者(回调)。
权衡:内存缓存大小(占内存 vs 命中率)、缓存淘汰策略(LRU)、采样精度 vs 质量。
三、设计网络请求框架(如自研 Retrofit + OkHttp)
- 分层:接口定义层(注解/动态代理)→ 请求封装层 → 拦截器链 → 连接/IO 层。
- 拦截器链(责任链):日志、重试、缓存、鉴权、加解密可插拔。
- 连接池:复用 TCP 连接,减少握手。
- 线程/异步:回调 / 协程 suspend / Flow。
- 可扩展:Converter(序列化)、CallAdapter(返回类型)可替换。
- 你的加分点:加解密拦截器、证书 Pinning、参数签名(结合你的安全背景)。
四、设计断点续传下载器
需求:大文件、断点续传、多线程加速、暂停/恢复、进度。
核心设计:
- 分片下载:按文件大小切成 N 段,每段用 HTTP
Range: bytes=start-end请求,多线程并发。 - 断点记录:持久化每段已下载偏移(数据库/文件),恢复时从断点续传。
- 合并:各段写入同一文件的对应偏移(
RandomAccessFile.seek)。 - 状态管理:等待/下载中/暂停/完成/失败,状态机管理。
- 完整性校验:下载后校验 MD5/SHA。
- 优化:动态调整线程数、失败重试、弱网降级、用 WorkManager 保证后台续传。
五、设计 IM / 即时通讯
需求与约束:支持单聊/群聊、保证消息不丢不重不乱序、支持弱网、省电省流。
整体架构:
- 客户端层:UI 层 → 本地存储层(SQLite) → 消息同步层 → 连接层。
- 服务端层:接入层(负载均衡/长连网关) → 逻辑层(单聊/群聊) → 存储层(MySQL存会话, Redis存状态)。
数据流与状态机 (消息生命周期):
- 发送:生成本地唯一 ClientMsgID,状态设为
发送中,存入本地数据库。 - 传输:通过长连接发送给服务端,服务端返回 ServerMsgID 确认接收。
- 确认:客户端收到 ACK 后,更新本地状态为
发送成功;若超时未收到,状态转为发送失败(显示红点)。 - 接收:接收方收到 ServerMsgID,发送 ACK 确认,然后渲染到界面并存库。
核心问题处理:
- 连接层选型:
- WebSocket / TCP:主流采用自研 TCP 长连接或 WebSocket,心跳保活(按网络状态智能调节心跳间隔,减少电量消耗)。
- 可靠性与去重 (ACK 与 Idempotency):
- 必须有双向 ACK 机制。发送端根据 ClientMsgID 去重,接收端根据 ServerMsgID 去重。
- 有序性:
- 依赖服务端的递增序列号 (Seq) 保证消息严格有序。
- 离线与同步机制:
- 采用 推拉结合:服务端推送新消息通知,客户端拿着本地最大 Seq 拉取增量消息(Sync)。
- 弱网与存储优化:
- 采用本地 Room 数据库持久化聊天记录;断网时可看历史记录,发消息仅改本地状态,恢复网络后后台重发。
六、设计短视频 / Feed 流
需求与约束:无限滑动、秒开、播放不卡顿、降低 OOM 概率与发热。
客户端架构:
- UI 控制层(RecyclerView) → 预加载策略层 → 播放器池(PlayerPool) → 本地缓存层。
核心数据流与 Paging3 集成:
- 使用
Paging3实现分页流:数据层通过PagingSource从网络或本地数据库拉取,ViewModel 将流转为PagingData喂给 UI,UI 层滑动触发下一页。
核心问题处理:
- 预加载策略 (Preload):
- 提前请求下 N 条(如 3 条)视频的元数据与首帧图片。
- 视频缓冲:提前下载当前视频后的 1-2 个视频的前 1MB 块(Moov Box 优先)。
- 播放器复用池 (Player Reuse):
- 绝不能给每个 Item 创建 MediaPlayer/ExoPlayer。全局维护 1-3 个播放器实例池,滑动切换时动态将 DataSource 挂载到目标 Surface 上,降低内存分配与重建开销。
- 缓存策略:
- 边下边播(通过本地代理如 AndroidVideoCache):未缓存部分请求网络并写入文件,已缓存部分直接读本地。
- 性能与内存隔离:
- 滑出屏幕立即停止解码、释放播放器资源到池中。
- 控制预加载深度:防止疯狂往下滑动导致内存暴增与大量废弃请求。
- 失败与降级:
- 网络波动时降级播放低码率;拉取失败时展示已缓存内容并提示网络异常。
七、其他常见设计题
- 设计本地缓存框架:LRU + 磁盘 + 过期策略 + 序列化。
- 设计埋点/APM SDK:数据采集 → 本地队列 → 批量上报 → 失败重试。和你 SDK 背景契合,可深入讲采集、性能影响最小化、上报策略。
- 设计组件化路由:APT 生成路由表 + 按路径分发 + 拦截器。
答题要点总结
- 先问后答:澄清需求和约束是第一步,直接写代码是大忌。
- 分层分模块:展示你能把复杂系统拆解。
- 讲权衡:每个选择说清 trade-off(内存 vs 速度、一致性 vs 可用性)。
- 结合你的优势:涉及性能、内存、安全、SDK 的设计题,主动用你的底层经验加分——比如设计图片库讲 Bitmap 内存复用、设计网络框架讲 Pinning 和加解密、设计埋点 SDK 讲对宿主性能影响最小化。这些是普通应用开发者讲不出的深度。
- 画图沟通:边画架构图边讲,面试官看的是思路和表达。
进阶补充:标准追问清单、状态机与可靠性设计
每道系统设计题的追问清单
回答完主方案后,主动补 5 类追问:
- 容量与性能:QPS、并发、缓存、线程池。
- 失败与重试:超时、重试、幂等、降级。
- 状态机:任务有哪些状态,如何迁移,异常如何恢复。
- 一致性:本地/服务端状态如何同步,冲突如何处理。
- 可观测性:日志、指标、trace、告警。
下载器状态机示例
Idle -> WaitingNetwork -> Downloading -> Paused -> Downloading -> Completed
-> Failed -> Retrying -> Downloading
每个状态迁移都要说明触发条件、持久化字段和失败恢复。
限流、熔断、降级、幂等
| 机制 | 解决什么 |
|---|---|
| 限流 | 防止瞬时流量打爆系统 |
| 熔断 | 下游持续失败时快速失败 |
| 降级 | 非核心能力失败时保核心路径 |
| 幂等 | 重试不会产生重复副作用 |
Android 特有追问
- 进程死亡后如何恢复任务?
- 前后台切换如何处理?
- 弱网/断网如何重试?
- 页面旋转或配置变更如何保存状态?
- 本地缓存和服务端数据冲突时谁优先?
面试表达模板
“我会先定目标和约束,再拆模块,然后补状态机和失败处理。比如这个下载器,核心不是只把文件下下来,还要保证断点续传、进程死亡恢复、重复点击幂等、弱网重试和可观测性。”
**追问:**为什么系统设计不能只画模块图?因为真实系统最容易出问题的是状态迁移、失败恢复和边界条件,这些必须在设计里说明。
移动端系统设计题库
移动端系统设计题没有唯一答案,面试官看的是你能否 澄清需求、拆模块、讲状态机、处理弱网/缓存/生命周期、说明权衡。本篇给一套可直接复述的题库骨架,覆盖 Android 应用和 SDK 场景。
一、移动端系统设计答题框架
任何题都按同一套路展开,避免一上来堆模块名。
1. 澄清需求:用户规模、数据量、离线、实时性、平台约束
2. 画端侧架构:UI/API/存储/任务/监控/安全
3. 讲核心流程:请求、缓存、状态机、失败恢复
4. 补移动端约束:生命周期、弱网、进程死亡、电量、隐私
5. 讲权衡:一致性 vs 性能、内存 vs 命中率、实时 vs 省电
| 维度 | 必答点 | Android 关键词 |
|---|---|---|
| 生命周期 | 页面销毁、进程死亡、前后台 | ViewModel、WorkManager、Service |
| 存储 | 缓存、离线、一致性 | Room、DataStore、文件、Paging3 |
| 网络 | 弱网、重试、幂等 | OkHttp、WebSocket、退避 |
| 性能 | 内存、线程、卡顿 | LruCache、线程池、Profiler |
| 安全合规 | 权限、隐私、加密 | Pinning、最小化采集、加密存储 |
二、题 1:设计图片加载框架
澄清:支持网络/本地/资源图?是否要 GIF/WebP/缩略图?列表滑动场景规模?内存上限?
骨架:
- API 层:
with(context).load(url).placeholder().into(imageView)。 - 请求管理:按生命周期绑定 Activity/Fragment,页面销毁取消。
- 缓存:内存 LRU、磁盘 LRU、活动资源弱引用/引用计数。
- 解码:按 View 尺寸采样,BitmapPool 复用,后台线程解码。
- 并发:下载线程池、同 URL 请求合并、列表复用 tag 防错图。
权衡:内存缓存越大命中率越高但 OOM 风险越大;磁盘缓存提升离线体验但占存储;精确采样省内存但可能牺牲清晰度。
三、题 2:设计埋点/Tracking SDK
澄清:采集哪些事件?实时还是批量?是否跨进程?隐私合规要求?宿主接入成本?
骨架:
- 采集层:手动埋点、自动页面曝光、点击、性能指标。
- 本地队列:Room/文件追加写,限制大小,进程死亡不丢关键事件。
- 采样去重:按用户稳定采样,短窗口 fingerprint 去重。
- 上报层:批量 gzip,弱网退避,只在合适网络/电量条件下上传。
- 可观测:SDK 自身错误、丢弃原因、队列长度、上报成功率。
权衡:实时性 vs 电量/流量;自动采集覆盖高但侵入和误报风险高;隐私字段必须最小化、脱敏和可配置。
四、题 3:设计断点续传下载器
澄清:单文件还是多任务?是否后台下载?文件多大?是否多线程分片?是否需要校验?
骨架:
- 任务模型:taskId、url、目标路径、状态、已下载字节、etag、分片信息。
- 下载层:HTTP Range 分片,RandomAccessFile 写指定偏移。
- 状态机:Idle → Downloading → Paused/Failed/Completed,失败可 Retrying。
- 持久化:Room 保存分片进度,进程死亡后恢复。
- 后台:WorkManager/ForegroundService 处理长任务和通知。
权衡:多线程分片更快但占连接和服务端压力;校验更安全但耗 CPU;后台下载要平衡系统限制和用户可见性。
五、题 4:设计 IM 长连接与消息同步
澄清:单聊/群聊?是否强实时?消息是否端到端加密?离线多久?多端同步?
骨架:
- 连接层:WebSocket/TCP 长连接,心跳,断线重连,网络切换重连。
- 消息模型:clientMsgId、serverMsgId、conversationId、seq、状态。
- 可靠性:发送 ACK、接收 ACK、超时重发、幂等去重。
- 有序性:服务端 seq,客户端按 seq 拉增量补洞。
- 本地存储:Room 保存会话、消息、未发送队列,UI 观察本地库。
权衡:心跳越频繁实时性越好但耗电;推拉结合比纯推送更可靠;严格有序会增加服务端和端侧状态复杂度。
六、题 5:设计离线缓存框架
澄清:缓存接口响应、图片、配置还是业务数据?一致性要求?离线可编辑吗?
骨架:
- 缓存策略:Cache-Aside、单一可信源、TTL、版本号/etag。
- 存储层:Room 存结构化数据,文件存大对象,DataStore 存轻量配置。
- 同步层:网络成功后事务入库;离线修改进入待同步队列。
- 冲突处理:服务端版本、本地更新时间、业务合并规则。
- 清理:LRU/TTL/空间上限/用户登出清理。
权衡:强一致需要更多同步和冲突处理;最终一致更适合移动弱网;缓存越久离线体验越好但过期风险越高。
七、题 6:设计 Crash SDK 与日志系统
澄清:Java/Kotlin crash、native crash、ANR 都要吗?是否符号化?是否要求启动早期捕获?
骨架:
- 捕获:UncaughtExceptionHandler、native signal handler、ANR watchdog/系统 traces。
- 持久化:crash 发生时写最小安全信息,下次启动补充上报。
- 符号化:Java mapping、native so buildId/symbol 文件、版本匹配。
- 日志:环形缓冲记录关键 breadcrumb,避免无限写磁盘。
- 上报:启动后延迟上传,去重、采样、压缩、退避。
权衡:crash 时环境不安全,不能做复杂分配/网络;日志越详细越利于定位但越占隐私和存储。
八、题 7:设计权限治理框架
澄清:面向宿主 App 还是 SDK?覆盖 Android 版本适配吗?是否要合规审计?
骨架:
- 权限声明:集中注册每个业务使用的权限、目的、触发时机。
- 请求编排:按场景弹说明页,再调系统权限,处理拒绝/永久拒绝。
- 版本适配:通知、相册、蓝牙、定位、后台定位按系统版本差异处理。
- 审计:记录权限请求来源、结果、时间,给隐私合规看板。
- SDK 约束:SDK 不直接弹权限,由宿主授权后传入能力。
权衡:集中治理降低滥用但接入成本更高;权限前置会转化差,按需申请更符合合规和用户体验。
九、题 8:设计组件化 Router
澄清:只做页面跳转还是跨模块服务调用?是否支持降级、鉴权、动态化?
骨架:
- 路由表:编译期注解生成 path → Activity/Handler 映射。
- 参数:类型安全参数解析,必填校验,默认值。
- 拦截器:登录、权限、风控、灰度、降级。
- 跨模块:接口下沉到 api module,实现由业务模块注册。
- 监控:路由耗时、失败原因、未命中 path。
权衡:编译期生成性能好但灵活性低;运行时注册灵活但可能漏注册;路由太万能会变成隐藏依赖中心。
十、题 9:设计短视频 Feed
澄清:信息流还是沉浸式上下滑?是否直播?视频码率?离线缓存?推荐实时性?
骨架:
- 数据:Paging3 拉取 feed,Room 缓存元数据和游标。
- 播放:播放器池复用 1-3 个 ExoPlayer,滑动切换 Surface。
- 预加载:预取下 N 条元数据、封面、视频头部片段。
- 缓存:边下边播,磁盘 LRU,弱网切低码率。
- 性能:控制同时解码数量,滑出释放,监控首帧、卡顿、耗电。
权衡:预加载越多秒开越好但流量和内存更高;播放器复用降低成本但状态管理复杂。
十一、题 10:设计登录状态与 Token 刷新
澄清:单端/多端登录?access token/refresh token?是否支持游客态?安全等级?
骨架:
- 状态:未登录、游客、已登录、刷新中、过期、被踢。
- 存储:token 加密存储,用户信息 Room/DataStore 分层。
- 刷新:401 后单飞刷新,并发请求等待同一刷新结果。
- 安全:设备绑定、Pinning、风控校验、敏感操作二次验证。
- 清理:登出/被踢清理用户数据、取消队列、通知 UI。
权衡:token 有效期越长体验越好但风险越高;刷新太频繁增加服务端压力;多端策略要和业务安全一致。
十二、题 11:设计设备指纹 SDK
澄清:用于风控、反作弊还是统计?允许采集哪些字段?离线可用吗?合规边界?
骨架:
- 采集:系统、硬件、网络、App 环境等合规字段,按权限最小化。
- 归一化:字段清洗、稳定性分级、缺失值处理。
- 指纹生成:加权特征、hash/签名、相似度匹配,支持版本演进。
- 安全:本地加密、防篡改、请求签名、Pinning。
- SDK 工程:异步初始化、缓存、超时降级、宿主性能监控。
权衡:字段越多识别率越高但隐私风险越大;完全离线响应快但模型更新慢;端云结合更准但依赖网络。
十三、题 12:设计移动端配置/实验下发系统
澄清:配置实时性?是否灰度/AB 实验?失败默认值?是否影响启动?
骨架:
- 拉取:启动异步拉、前后台切换拉、长轮询/推送可选。
- 缓存:本地保存版本、etag、生效时间、默认兜底。
- 灰度:按 userId/deviceId hash 分桶,保证稳定命中。
- 安全:配置签名校验,防止中间人篡改关键开关。
- 回滚:服务端版本回退,客户端过期降级。
权衡:启动同步拉能拿最新配置但拖慢启动;异步拉体验好但首次可能用旧配置;强安全配置需要签名和审计。
高频面试题
Q1:移动端系统设计题第一步说什么? 先澄清需求和约束:功能边界、数据量、实时性、离线要求、Android 版本、内存/电量/隐私限制。直接画模块容易漏掉关键约束。
Q2:设计 SDK 和设计 App 功能有什么不同? SDK 更强调低侵入、宿主性能影响、初始化时机、可配置、隐私合规、版本兼容和故障隔离。SDK 不能随意弹权限、开线程或抢占宿主资源。
Q3:移动端系统设计为什么要讲状态机? 因为弱网、进程死亡、页面销毁和用户重复操作都会造成中间态。状态机能说明任务如何暂停、恢复、失败、重试和完成,比静态模块图更接近真实工程。
Q4:如何体现中高级工程思维? 每个方案都补权衡和可观测性:为什么选这个缓存/重试/同步策略,失败如何降级,怎么监控成功率、耗时、队列长度和错误原因。
易错点 / 追问
- 易错:只画端侧模块,不讲弱网、进程死亡、生命周期和数据一致性。
- 追问:如果用户清后台后任务如何恢复?需要持久化状态,用 WorkManager/前台服务或下次启动恢复。
- 易错:把所有题都回答成“加缓存 + 加重试”;要说明缓存过期、幂等、冲突和退避策略。
- 追问:系统设计如何结合你的风控 SDK 背景?在埋点、设备指纹、网络安全、Crash/日志 SDK 中讲隐私、性能、Pinning、端云协同和可观测性。
项目经验与软技能
技术答得好,项目讲不清照样挂。这一篇帮你把风控 SDK 经历讲成应用岗的财富,以及应对软性问题。
一、STAR 法则讲项目
回答项目/经历类问题的黄金结构:
- S(Situation 背景):项目是做什么的、你的角色、规模。
- T(Task 任务):你具体负责什么、面临什么挑战/目标。
- A(Action 行动):你怎么做的——重点,体现技术深度和决策思考。
- R(Result 结果):量化成果(性能提升 X%、体积减少 Y MB、崩溃率下降 Z%)。
讲项目时多用真实数字。“优化了启动速度“很弱,“冷启动从【真实值】降到【真实值】,降幅【真实比例】“才有说服力。没有真实数据时,宁可讲验证方法,不要临场编数字。
二、怎么把风控 SDK 讲成亮点
你的项目天然带“难度光环“,会讲就是加分。核心不是背一段固定话术,而是把“风控 SDK“翻译成面试官关心的语言:底层硬实力 + 性能优化 + 安全视角 + 工程化能力。
使用提醒:下面都是 STAR 模板,
【项目背景】、【我的动作】、【量化指标】等占位符必须在面试前替换成你真实经历和真实数据。没有真实数据就说“当时用内部指标验证“或“我会补充复盘数据“,不要把占位符当事实讲。
模板 1:讲底层深度(NDK/JNI/native 稳定性)
- S 背景:
【项目背景】:例如“某风控/设备指纹 SDK 需要在多个宿主 App 中稳定运行,同时兼顾采集完整性、兼容性和体积“。 - T 任务:
【我的职责】:说明你负责的模块边界,如 native 采集、JNI 桥接、so 加载、crash 捕获、动态注册等。 - A 行动:
【我的动作】:按 2-3 个动作讲清技术决策:为什么用 C++/JNI、如何控制跨层调用频率、如何处理线程/异常、如何做 ABI/符号/资源收敛。 - R 结果:
【量化指标】:替换为真实结果,如“so 体积从【真实值】降到【真实值】“、“native crash 率从【真实值】降到【真实值】”、“覆盖【真实机型/宿主范围】”。没有数据就只说“通过日志/灰度监控验证没有明显回归“。 - 转应用岗落点:这不是只会写底层,而是证明你能处理 App 里最难定位的一类问题:native crash、so 兼容、跨线程/跨语言边界和发布质量。
示例结构:
“【项目背景】。我负责【我的职责】。难点是【技术难点】,如果处理不好会影响宿主 App 的【启动/稳定性/体积/兼容性】。我的做法是:第一【动作 1】,第二【动作 2】,第三【动作 3】。最后用【量化指标/验证方式】确认效果。这个经验迁移到应用开发,就是我能在 native、稳定性和性能问题上直接下钻定位。”
模板 2:讲性能意识(不拖累宿主 App)
- S 背景:
【项目背景】:SDK 嵌入宿主 App,用户感知由宿主承担,所以启动、内存、线程、网络都要有预算。 - T 任务:
【性能目标】:用真实目标描述,如“降低初始化对冷启动影响“、“减少常驻内存”、“避免主线程 I/O”。 - A 行动:
【我的动作】:可按“延迟初始化/按需加载 → 后台线程与任务编排 → 缓存与采样 → 监控回滚“组织。 - R 结果:
【量化指标】:只能填真实数据,如“主线程耗时【真实值】“、“内存峰值【真实值】”、“灰度期间无新增【真实指标】”。没有真实数值时,说清你用什么指标看效果,不要编 X ms。 - 转应用岗落点:App 性能优化同样先定指标,再拆链路,最后用监控验证;SDK 经历能证明你不是只会口头说“异步“。
面试官追问“你怎么证明优化有效“时,不要只说“感觉不卡“。按这个顺序答:指标口径 → 对照组/灰度 → 工具或日志 → 回滚策略 → 剩余风险。
模板 3:讲对抗/安全视角(防御导向)
- S 背景:
【项目背景】:风控 SDK 面临模拟器、Hook、篡改、调试、环境伪造等风险,但安全能力必须兼顾误报、性能和用户体验。 - T 任务:
【防护目标】:说清目标是识别风险、提高攻击成本、保护关键链路,不要宣称“完全防住“。 - A 行动:
【我的动作】:用防御视角描述威胁建模、信号采集、完整性校验、异常上报、策略开关、灰度观察;避免讲可操作的攻击/绕过步骤。 - R 结果:
【量化指标】:替换成真实的风险识别、误报、稳定性或灰度指标;没有数据就说明“该类效果依赖服务端策略/内部评估,面试中只能讲端侧工程动作“。 - 转应用岗落点:支付、金融、出海和隐私敏感业务都需要安全意识;你的价值是能提前识别风险并把安全能力做成可控、可观测、可降级的工程能力。
模板 4:讲工程化能力(SDK 产品思维)
- S 背景:
【项目背景】:SDK 面向多个宿主/版本/机型,调用方不一定理解内部细节。 - T 任务:
【工程目标】:稳定接口、降低接入成本、保证兼容、控制发布风险。 - A 行动:
【我的动作】:讲 API 设计、配置开关、日志分级、异常兜底、灰度发布、版本兼容、文档和接入排查。 - R 结果:
【量化指标】:填真实接入规模、问题收敛、发布质量或反馈周期;无数据就讲“建立了可复用流程/排查清单“,不要编造宿主数量。 - 转应用岗落点:完整 App 也需要这种工程化意识:模块边界清楚、可观测、可回滚、对调用方友好。
常见坑:
- 不要只讲名词:JNI、Hook、加固、APM 都要落到你做了什么决策、解决了什么问题。
- 不要编指标:所有
【量化指标】必须来自真实记录;不确定就讲验证方法。 - 不要把安全讲成攻击教程:面试目标是证明防御意识和工程边界,不是展示绕过能力。
- 不要贬低应用开发:你的叙事是“底层能力上移“,不是“应用层简单“。
三、转应用开发的高频追问应对
Q:“你没怎么做过 UI/完整 App,能胜任吗?”
“UI 和上层框架确实是我之前接触少的部分,所以最近我系统学了 Compose、Jetpack、协程和 MVVM,也做了练手项目。我学得快,因为底层原理我都懂——比如 View 绘制、事件分发、Handler 消息机制这些,我从系统层就理解。补的是 API 熟练度,不是认知。”
追问处理:不要只说“我学得快“,要拿行动证据回应。可以按“已补知识 → 练习产物 → 下一步补齐“说:例如“我已经系统复盘了【真实学习内容】,用【真实练习/项目/代码仓库】验证了 Compose/Jetpack/MVVM 的基本链路;如果入职,我会优先从明确边界的页面或性能/稳定性任务切入,同时在 code review 中补齐团队规范“。括号里的内容必须是真实可展示的材料。
Q:“为什么不继续做风控/SDK?”
“风控 SDK 让我打下了很扎实的底层功底,但它的业务面窄、偏单点。我希望参与完整产品的构建,从架构到体验都能贡献,成长空间更大。”
Q:“你的优势是什么?”
“我比一般应用开发者更懂系统底层和性能,遇到 native 崩溃、内存、卡顿、so 体积这类硬问题我能直接下到底层解决;同时我有安全和对抗的视角,能帮团队规避风险。”
如果面试官继续质疑“这些和业务开发有什么关系“,按“业务价值“回答:底层能力不是替代业务开发,而是在复杂问题出现时缩短定位链路;你仍然要按团队规范写 UI、架构和业务代码,只是额外带来性能、稳定性、安全这三个补位能力。
四、反问环节(面试官问“你有什么想问的“)
永远要问,不问显得没兴趣。好问题示例:
- 团队目前的技术栈?Compose 用得多吗?有没有 native 模块?
- 团队现在最大的技术挑战是什么?
- 这个岗位期望我半年内达到什么状态?
- 团队的代码质量保障流程(CI、Review、测试覆盖)?
避免一上来只问薪资、加班(放到 HR 面或 offer 阶段)。
五、算法准备方向
中级 Android 算法通常不会太难(LeetCode 中等为主),重点:
- 数组/字符串双指针、滑动窗口。
- 哈希表、链表(反转、环检测)。
- 二叉树遍历(递归/迭代)、BFS/DFS。
- 排序、二分查找。
- 简单 DP(爬楼梯、最长子序列类)。
建议:LeetCode Hot 100 过一遍,手写常见题(快排、二分、链表反转)。大厂会考,中小厂偏少。
六、HR 面常见问题
- 离职原因:讲成长诉求,不抱怨前公司。
- 职业规划:技术深耕 + 逐步扩展广度,2-3 年成为团队能独当一面的工程师。
- 期望薪资:提前调研市场,给区间,留余地。
- 优缺点:优点结合岗位需求;缺点讲真实但在改进的(如“之前 UI 经验少,正在系统补齐“)。
七、临场提醒
- 不会的题诚实说,但展示思路:“这个我没深入研究过,但我推测是……,因为……”。
- 被怼住时别慌,可以说“让我理一下思路“。
- 全程体现你的底层思维和学习能力——这是你转型的最大卖点。
项目复盘专题
项目复盘不是流水账,而是把经历讲成背景清楚、责任明确、难点可信、取舍有依据、结果可验证的面试表达。所有涉及规模、收益、耗时、事故、用户量、业务线的数据,必须替换为真实记录;没有真实数据就讲验证方法,不要现场编。
一、项目复盘的总框架
项目类问题建议按“一条主线 + 三个证据“组织:
- 项目背景:项目服务谁、解决什么问题、为什么当时重要。
- 我的责任:我负责哪些模块、决策、交付物,边界在哪里。
- 技术难点:难点来自性能、稳定性、兼容性、安全、协作还是业务规则。
- 方案取舍:为什么选 A 不选 B,代价是什么,如何兜底。
- 结果证据:指标、灰度、日志、监控、线上反馈、复盘文档。
可直接套用的 STAR+复盘模板:
背景:【项目名】属于【业务线】,目标是解决【真实问题】,当时约束是【真实值】。
责任:我负责【模块/链路】,边界是【我负责什么】,不负责【其他团队/模块】。
行动:我先拆了【关键链路】,再针对【技术难点】做了【方案】,同时准备【监控/灰度/回滚】。
结果:上线后用【指标】验证,【真实值】从【真实值】变为【真实值】;如果没有指标,说明用【日志/灰度/测试】验证。
复盘:这件事沉淀了【流程/工具/规范】,如果重做我会提前补【风险点】。
二、项目背景与责任边界怎么讲
背景要回答“为什么做“,责任要回答“你到底做了什么“。中级 Android 面试最忌讳把团队成果全说成个人成果,也忌讳只说“参与开发“。
| 维度 | 应该讲 | 不要讲 |
|---|---|---|
| 背景 | 【项目名】服务【业务线】,要解决【真实问题】 | “公司让做的” |
| 规模 | 真实接入范围、版本、机型、调用量或验证环境 | 编造 DAU、收入、QPS |
| 责任 | 我负责的模块、接口、排查、上线、文档 | “整个项目都是我做的“但说不出细节 |
| 边界 | 哪些由服务端、客户端、产品、测试负责 | 把不了解的链路硬讲成自己的 |
表达模板:
“这个项目的背景是【项目名】在【业务线】里承担【真实职责】。我主要负责【客户端模块/SDK 模块/性能链路】,包括【接口设计/核心实现/排查上线/监控补充】。服务端策略、产品规则或运营配置由【真实协作方】负责,我这边重点保证端侧链路稳定、可观测、可回滚。”
三、技术难点与方案取舍
技术难点要讲“为什么难“,不是堆名词。方案取舍要讲“当时为什么这样选“,不是事后包装。
- 性能难点:启动、内存、包体、卡顿、线程调度。讲指标口径和预算,如主线程耗时、P95、峰值内存,用
【指标】和【真实值】替换。 - 稳定性难点:崩溃、ANR、弱网、进程死亡、兼容性。讲复现路径、日志补点、灰度观察、回滚开关。
- 安全/风控难点:环境异常、Hook、篡改、数据可信度。讲防御和工程化,不要讲可操作绕过细节。
- 协作难点:跨端、服务端、测试、产品口径不一致。讲接口契约、验收标准、对齐机制。
取舍回答公式:
“我们当时有两个方案:A 是【方案 A】,优点是【优点】,但代价是【代价】;B 是【方案 B】,优点是【优点】,但风险是【风险】。最后选【真实选择】,因为当时最重要的约束是【真实约束】。为了控制风险,我补了【灰度/开关/监控/回滚】。”
四、问题、事故与线上指标复盘
项目复盘一定要准备“出过什么问题“。没有严重事故也可以讲灰度问题、兼容问题、测试漏测、指标波动,但必须真实。
事故/问题表达不要甩锅,按下面顺序:
- 现象:什么指标或反馈异常,影响范围是多少。例:
【时间】发现【指标】异常,影响【真实值】。 - 定位:如何缩小范围,用了哪些日志、监控、灰度分组、复现设备。
- 处置:临时止血、开关回滚、版本修复、数据修复。
- 根因:代码、配置、流程、测试覆盖、依赖变更还是沟通问题。
- 预防:新增监控、用例、检查项、发布门禁、文档沉淀。
指标不要只说“提升明显“。至少准备三类证据:
- 业务/使用证据:覆盖【业务线】、接入【真实值】版本、使用【真实值】天/周/月。
- 技术指标:崩溃率、ANR、启动耗时、内存、包体、成功率、失败率,统一写成
【指标】=【真实值】。 - 过程证据:灰度批次、监控面板、日志字段、测试报告、复盘文档、Code Review 记录。
五、协作、推进与冲突处理
中级岗位会追问你是否能推动事情,不要只讲技术实现。协作复盘可以按“目标一致 → 契约明确 → 风险透明 → 结果闭环“说。
- 和产品:确认成功标准,避免只按口头需求开发。可说“我把验收口径从’体验更好’拆成【指标】和【真实场景】“。
- 和服务端:对齐接口字段、幂等、重试、错误码、灰度策略。强调契约和联调清单。
- 和测试:补充兼容机型、弱网、异常输入、升级覆盖、回滚路径。
- 和客户端同事:明确模块边界、代码 Review、公共能力沉淀。
冲突回答模板:
“当时分歧点是【真实分歧】。我没有直接争论实现偏好,而是把风险拆成【性能/稳定性/排期/可维护性】几项,用【真实数据或验证方式】对比。最后我们选择【真实方案】,并保留【降级/灰度/后续优化】。”
六、如果重做,你会怎么改
“如果重做“不是自我否定,而是展示复盘能力。回答要具体,不能只说“更早沟通”。
可以从 5 个方向选 2-3 个真实点:
- 更早定义指标:上线前就确定
【指标】、采集口径和看板,避免上线后补证据。 - 更细灰度和回滚:按版本、机型、渠道、业务线分层灰度,准备开关和降级。
- 更完善测试矩阵:覆盖弱网、低端机、进程死亡、升级、异常配置。
- 更清晰边界文档:把接口契约、错误码、调用时机、线程模型写清。
- 更早做风险评审:提前评估隐私合规、性能预算、安全误报、服务端依赖。
推荐回答:
“如果重做,我会先补两件事:第一,在开发前就把【指标】口径定下来,避免只靠上线后观察;第二,把【风险点】提前放进灰度和回滚方案。代码实现本身不是最大问题,真正容易出问题的是指标口径、协作边界和异常场景。”
高频面试题
Q1:请介绍一个你最有代表性的项目。
答题要点:按“背景 → 责任 → 难点 → 方案 → 结果 → 复盘“讲,控制在 3-5 分钟。所有数字替换为 【真实值】,例如“【指标】从【真实值】到【真实值】“;没有数字就说清验证方式。
Q2:这个项目最大的技术难点是什么?
答题要点:只选 1-2 个最能体现能力的难点。先讲难点来源,再讲方案取舍,最后讲验证。不要把所有技术栈都报一遍。
Q3:这个项目上线后出过什么问题?你怎么处理?
答题要点:讲真实问题,按“发现 → 定位 → 止血 → 根因 → 预防“回答。即使问题不大,也要体现监控、灰度、回滚和复盘意识。
Q4:你在项目里具体负责什么?哪些不是你负责的?
答题要点:主动划清边界,例如“我负责端侧【模块】和【指标】,服务端策略由【协作方】负责,但我参与了接口联调和异常码定义“。边界清楚反而更可信。
Q5:如果现在让你重做这个项目,会怎么优化?
答题要点:不要说“没什么问题“。从指标、灰度、测试、文档、风险评审中选真实改进点,说明为什么当时没做以及现在会怎么补。
易错点 / 追问
- 易错点:把团队成果全揽到自己身上。 追问一到接口细节、日志字段、灰度策略就露馅;要明确自己的负责范围。
- 易错点:编造指标。 面试官会追问口径、看板、样本量、时间窗口;没有真实数据就讲验证方法和补数计划。
- 易错点:只讲成功不讲问题。 中级面试更看重事故处理和复盘能力,要准备真实故障或灰度问题。
- 易错点:方案取舍讲成标准答案。 要回到当时约束,说明为什么在【时间】、人力、风险下做那个选择。
- 追问:你怎么证明这是你做的? 准备代码提交、设计文档、日志字段、联调记录、复盘文档等真实证据。
- 追问:项目结果和业务有什么关系? 用【业务线】、用户路径、稳定性、安全、效率或成本解释技术指标的业务意义。
简历追问防御清单
简历上的每一句项目描述都可能被追问。防御不是背话术,而是把真实参与、真实使用、真实指标、真实失败、真实取舍准备完整。凡是用户量、收益、性能、事故、业务规模、上线时间,都用
【真实值】、【项目名】、【指标】、【时间】等占位符替换,面试前再填真实证据。
一、5 分钟项目故事
5 分钟项目故事要能让面试官听懂“你做过、做深过、复盘过“。建议控制节奏:
- 30 秒背景:【项目名】属于【业务线】,解决【真实问题】。
- 45 秒职责:我负责【模块/链路】,交付【接口/能力/文档/上线】。
- 90 秒难点:选择 1-2 个难点,如性能、稳定性、兼容、安全、协作。
- 90 秒方案:讲拆解、取舍、实现、灰度、回滚。
- 45 秒结果:用【指标】和【真实值】说明结果;没有数据就讲验证链路。
- 20 秒复盘:讲失败、遗憾或如果重做会怎么改。
可背结构,不可背假内容:
【项目名】是【业务线】里的【项目定位】,当时要解决【真实问题】。
我负责【真实负责范围】,不是全链路都由我做,但我主导/参与了【真实动作】。
最大的难点是【技术/协作难点】,因为【真实约束】。
我的方案是【关键方案】,在【取舍点】上选择了【真实选择】,同时准备【监控/灰度/回滚】。
结果用【指标】验证,【真实值】;如果重做,我会提前补【改进点】。
二、真实使用证明与证据链
面试官怀疑“项目是不是包装的“时,最有效的不是强调“我真的做了“,而是拿出证据链。证据不一定是截图,也可以是你能讲清的细节。
| 追问方向 | 可信证据 | 回答重点 |
|---|---|---|
| 是否真实上线 | 【时间】上线、版本号、灰度批次、回滚记录 | 讲发布路径和风险控制 |
| 是否真实使用 | 接入【真实值】业务/版本/渠道/宿主 | 讲使用方、调用时机、限制条件 |
| 是否你负责 | 代码提交、接口设计、日志字段、联调记录 | 讲你的模块边界和关键决策 |
| 是否有结果 | 【指标】、监控、测试报告、复盘文档 | 讲指标口径,不编收益 |
| 是否遇到问题 | 缺陷单、事故复盘、灰度问题 | 讲处理流程和预防动作 |
防御性回答模板:
“这个项目可以从几个细节证明是真实做过的:第一,它在【时间】接入【真实使用范围】;第二,端侧关键日志有【字段/事件名】用于看【指标】;第三,我负责的是【真实范围】,所以我能讲清【接口/线程/异常/灰度】,但服务端【策略/运营配置】不是我主导。”
三、指标、失败案例与在线问题
简历上的“优化、提升、降低、支撑“最容易被追问。每个动词都要配一个真实口径。
- 优化了性能:准备优化前后
【指标】=【真实值】,工具来源是 Profiler、Perfetto、APM、日志还是内部看板。 - 提升了稳定性:准备崩溃率、ANR、失败率、重试成功率、灰度问题数量等真实指标。
- 支撑了业务:准备接入范围、版本、渠道、业务线、上线周期,不要编用户数或营收。
- 降低了成本:准备包体、网络流量、服务调用次数、排查耗时等真实数据。
- 保障了安全/合规:准备风险类型、检测/上报链路、误报控制、开关降级,不要夸口“完全防住“。
失败案例比成功更能证明真实经验。建议准备一个“小而真“的问题:
- 现象:
【时间】灰度时【指标】异常或用户反馈【真实问题】。 - 影响:影响范围是【真实值】;不知道精确范围就说“当时通过【方式】估算“。
- 处置:先【开关/回滚/降级】止血,再定位【根因】。
- 复盘:补了【监控/测试/发布检查/文档】。
四、方案取舍与 owned scope 防御
面试官会通过取舍题判断你是否真正参与决策。不要只说“用了某框架“,要讲为什么不用别的方案。
常见 owned scope 追问:
- “这个核心方案是谁定的?” 如果不是你定的,就说“方案由【角色/团队】主导,我负责端侧落地和风险验证“;再讲你自己的贡献。
- “服务端怎么做的?” 不懂不要硬编。可以说“服务端细节不是我负责,我对齐的是【接口契约/错误码/重试/幂等】“。
- “你写了多少代码?” 不用报行数,讲模块、接口、关键类、测试、上线动作。
- “为什么不选另一个方案?” 回到当时约束:【时间】、兼容性、性能预算、团队熟悉度、可回滚性。
owned scope 回答公式:
“我不把整个项目都说成自己做的。我的 owned scope 是【真实范围】,我做了【真实动作】,对结果负责的指标是【指标】。其他部分由【协作方】负责,但我通过【接口文档/联调/监控/灰度】保证端到端闭环。”
五、反假简历追问清单
下面这些问题要提前逐条准备,答不上来就把简历表述改窄。
| 简历表述 | 面试官可能追问 | 准备方式 |
|---|---|---|
| 负责【项目名】核心模块 | 核心类、接口、线程模型、异常处理是什么 | 准备 3 个实现细节和 1 个坑 |
| 优化【指标】 | 口径、样本、时间窗口、对照组是什么 | 准备【真实值】和工具来源 |
| 支撑多业务接入 | 哪些业务线、版本、接入差异 | 准备【业务线】和边界 |
| 解决线上问题 | 什么问题、影响多大、怎么止血 | 准备一次真实问题复盘 |
| 主导架构设计 | 为什么这样分层,替代方案是什么 | 准备 trade-off 和风险兜底 |
如果发现某条简历描述没有证据,立刻降级表达:
- “主导“改成“参与设计并负责端侧落地”。
- “显著提升“改成“通过【指标】观察到【真实值】变化”。
- “高并发/海量“改成“在【真实规模】下验证”。
- “全链路负责“改成“负责客户端【模块】,参与端到端联调”。
六、量化影响与表达边界
量化不是必须有漂亮数字,而是必须有真实口径。没有真实数据时,可以讲“当时如何验证“,但不要编。
推荐说法:
- 有真实指标:“上线后【指标】从【真实值】到【真实值】,统计窗口是【时间】,来源是【监控/日志/测试报告】。”
- 只有阶段性验证:“这个项目没有对外业务大盘指标,我能提供的是端侧验证:【测试范围】、【灰度范围】、【日志观察】。”
- 指标归因不完全确定:“这个结果受服务端策略和业务流量影响,我不把它全部归因到端侧。我负责的部分主要影响【端侧指标】。”
- 不能披露具体数字:“具体数值不方便展开,但口径是【指标】,我可以讲采集方式和优化路径。”
边界感会增加可信度。面试官更相信一个能说“这部分不是我负责“的人,而不是每个问题都说自己全做了的人。
高频面试题
Q1:你简历上说负责核心模块,核心体现在哪里?
答题要点:从调用链位置、故障影响、接口稳定性、性能预算、协作依赖解释“核心“。再讲自己负责的关键类/接口/日志/灰度,不要只说“业务很重要“。
Q2:你怎么证明这个项目真的上线并被使用了?
答题要点:用【时间】、版本、灰度、接入范围、日志指标、问题反馈证明。无法展示内部材料时,讲清证据链和细节,但不泄露敏感信息。
Q3:这个指标提升是不是你一个人的贡献?
答题要点:不要硬揽。说明端侧负责【指标】的哪一段,服务端/产品/测试分别影响什么,最后讲你可归因的贡献。
Q4:项目失败或效果不如预期时怎么办?
答题要点:讲真实失败案例,重点是止血、复盘、补监控、补测试、调整方案。不要说“没有失败过“。
Q5:如果我追代码细节,你能讲哪些?
答题要点:准备 3 个细节:核心接口、线程/生命周期、异常处理/降级、日志字段、测试用例。答不上来的细节不要写进简历。
易错点 / 追问
- 易错点:用大词包装小参与。 “主导、核心、架构、全链路“都要有证据;没有证据就降级成准确表达。
- 易错点:指标没有口径。 面试官会追问统计窗口、样本、对照组、工具来源;提前准备【指标】口径。
- 易错点:不了解协作方却硬讲。 服务端、算法、产品策略不了解就说边界,转回接口契约和端侧验证。
- 易错点:只讲成功故事。 至少准备一个真实失败/线上/灰度问题,否则像背模板。
- 追问:你离开项目后它还在用吗? 讲交接、文档、监控、版本维护人;不知道就说最后已知状态是【时间】的【真实值】。
- 追问:如果不能披露公司数据怎么办? 讲指标口径、相对变化、验证方法,不要泄露敏感信息也不要编虚假数字。
Vibe Coding
Vibe Coding 是 2025–2026 年非常热门的 AI 辅助开发话题。面试里不要把它讲成“让 AI 随便写“,而要讲成“用自然语言快速探索,再用工程纪律收口“。
一、什么是 Vibe Coding
Vibe Coding 指开发者用自然语言描述目标,让 AI 快速生成代码、页面、脚本或方案,再通过运行、审查、修改不断迭代。它强调速度和探索,但最终质量仍由开发者负责。
二、典型工作流
- 描述目标和约束:明确平台、现有模式、不可改范围。
- 让 AI 生成初版:适合脚手架、样例、重复代码、探索方案。
- 本地运行验证:构建、测试、手动体验。
- 人工审查:边界、异常、安全、可维护性。
- 小步迭代:每轮只改明确问题。
三、适合和不适合的场景
| 适合 | 不适合 |
|---|---|
| 原型、Demo、重复样板 | 支付、安全、隐私核心逻辑无人审查 |
| 文档、测试样例、重构草稿 | 不了解上下文的大范围改造 |
| API 使用探索 | 生产事故直接盲改 |
四、质量与风险控制
- 幻觉:AI 可能编不存在的 API。
- 过度设计:生成看似完整但难维护的抽象。
- 安全风险:泄露密钥、忽略权限、引入不安全依赖。
- 上下文缺失:不了解项目约束时容易破坏现有模式。
控制方式:提供真实文件上下文、要求小步 diff、跑测试/构建、人工 code review。
五、Android 场景怎么用
- 生成 ViewModel/State/Effect 的样板。
- 写单元测试和边界用例。
- 帮助解释崩溃堆栈、Perfetto trace、Gradle 报错。
- 草拟面试答案,再用自己的项目经历替换占位。
六、面试怎么答
可以说:“我会用 AI 做探索和提效,比如生成测试样例、梳理源码调用链、草拟重构方案。但生产代码一定会经过构建、测试和人工审查。我不把 AI 当替代判断力的工具,而是当加速器。”
高频面试题
Q1:你怎么看 Vibe Coding? 答:它适合快速探索和提效,但不能替代工程纪律。真正关键的是把 AI 产物纳入测试、review、验证闭环。
Q2:AI 生成代码最大风险是什么? 答:上下文不足导致错误假设,以及生成看似合理但不可维护/不安全的代码。解决方式是给上下文、小步修改、强制验证。
Q3:你用 AI 写过什么? 答:回答时结合真实经历:测试样例、文档、脚手架、错误排查、代码解释。不要声称核心安全逻辑完全交给 AI。
易错点 / 追问
- 不要把 Vibe Coding 包装成“不用懂代码“。
- 不要把未验证的 AI 输出直接进主分支。
- 面试时强调判断力、验证和责任归属。
Harness Engineering
Harness Engineering 是比“会用 AI 写代码“更高一层的能力:给 coding agent 搭建可靠的上下文、工具、验证和边界,让 AI 能长期、稳定、可审计地工作。
一、什么是 Harness Engineering
Harness 是围绕 AI coding agent 的工程脚手架。它不是模型本身,也不是简单 prompt,而是让 agent 在真实项目里知道规则、记住状态、正确使用工具、按验证闭环完成任务的一整套系统。
二、五个子系统
| 子系统 | 类比 | 作用 |
|---|---|---|
| Instructions | 菜谱架 | AGENTS.md/CLAUDE.md、项目规则、架构说明 |
| State | 备餐台 | feature_list.json、progress.md、session-handoff.md |
| Verification | 出餐质检口 | 测试、lint、build、验收证据 |
| Scope | 任务边界 | one-feature-at-a-time、Definition of Done |
| Lifecycle | 班次管理 | init.sh、启动检查、交接流程 |
三、为什么 AGENTS.md / CLAUDE.md 重要
这类文件是 agent 的项目级路由层,用于告诉 AI:
- 先读哪些文档。
- 哪些文件能改、哪些不能改。
- 运行什么验证命令。
- 完成前必须做什么检查。
- 当前项目的架构和命名约定。
四、状态与会话连续性
长任务容易跨会话,所以需要持久状态:
feature_list.json:记录功能、状态、依赖、证据。progress.md:记录当前进度和阻塞。session-handoff.md:让下一轮 agent 接上上下文。
原则:能从代码/仓库推导出的内容不放进记忆;只记录决策、进度、验证结果和非显然约束。
五、验证工作流与 completion gate
Harness 必须定义“什么时候算完成“:
- 相关测试通过。
- 构建/lint/type check 通过。
- diff 符合需求,没有范围外改动。
- 验证证据被记录。
- 代码审查级验收通过。
只说“测试过了“不够,还要说明跑了什么命令、输出是什么、哪些没跑以及原因。
六、Scope 控制与安全
Agent 最常见问题是过度改动、忘记上下文、为了修一个问题重构半个项目。Harness 通过权限、任务边界、计划、提交粒度和 review gate 限制风险。
七、面试怎么讲
可以说:“我不只是用 AI 生成代码,还会给 AI 建执行边界:项目说明、任务状态、验证命令、完成门禁和 handoff。这样 AI 适合做重复、检索、草稿和局部实现,但质量由工程化流程兜底。”
高频面试题
Q1:Harness Engineering 和 prompt engineering 区别? 答:prompt engineering 关注一次对话怎么问;Harness Engineering 关注长期项目里 agent 的规则、状态、工具、安全、验证和生命周期。
Q2:一个最小可用 harness 包含什么? 答:AGENTS.md/CLAUDE.md 项目规则、明确验证命令、任务状态记录、完成定义、session handoff。复杂项目再加多 agent 协作和权限控制。
Q3:为什么需要 completion gate? 答:防止 agent 只凭感觉宣布完成。完成必须有构建/测试/审查证据,并确认没有范围外改动。
易错点 / 追问
- 不要把 harness 讲成“写一个超长 prompt“。
- 不要让记忆保存可从仓库推导的信息。
- 不要让 agent 在没有验证命令的项目里自由发挥。
AI Coding 工程化进阶
AI Coding 的进阶能力不是“让模型多写代码”,而是把 prompt、上下文、工具、验证、代码审查和安全治理接成闭环。面试里要强调:AI 是加速器,工程师负责边界、判断和最终质量。
一、Prompt Engineering:把需求讲清楚
Prompt Engineering 关注单次任务如何描述清楚。好的 prompt 至少包含目标、上下文、约束、验收标准和禁止事项。
任务:为登录页 ViewModel 补充单元测试。
上下文:使用 Kotlin + Coroutines + Turbine,现有测试风格参考 LoginViewModelTest。
约束:只改测试文件,不改生产代码;覆盖成功、密码错误、网络异常。
验收:./gradlew testDebugUnitTest 通过;说明新增用例。
禁止:不要引入新依赖,不要重构 ViewModel。
常见技巧:
- 用“角色 + 任务 + 输入 + 输出格式 + 验证命令”减少歧义。
- 对大任务先让 AI 产出计划,再分步执行。
- 明确不可改文件、兼容版本、安全边界。
- 要求 AI 解释关键取舍,方便人工 review。
二、Context Engineering:让 AI 拿到正确上下文
Context Engineering 比 prompt 更重要。它解决“模型需要知道什么,不该知道什么,如何持续保持上下文准确”。
| 上下文类型 | 示例 | 作用 | 风险 |
|---|---|---|---|
| 代码上下文 | 相关类、测试、接口定义 | 避免编不存在 API | 上下文太多会稀释重点 |
| 项目规则 | AGENTS.md、架构约束、命名规范 | 避免破坏结构 | 规则过期会误导 |
| 历史决策 | ADR、issue、notepad、handoff | 继承非显然约束 | 记忆污染或重复记录 |
| 验证信息 | 测试命令、lint、构建结果 | 完成门禁 | 未实际运行会造成假通过 |
| 禁止范围 | 不改 README、不动依赖、不改生成物 | 控制 scope | 写得不清会范围膨胀 |
面试可说:我会先给 AI 最小但充分的相关文件,让它按现有模式改,完成后用测试、diff 和 review 校验,而不是让它凭记忆猜项目结构。
三、Agentic Coding 与工具调用
Agentic Coding 指 AI 不只是回答问题,还会读文件、搜索、编辑、运行测试、根据结果迭代。MCP(Model Context Protocol)和 tool calling 的价值是把外部系统能力以标准工具暴露给模型,例如代码仓库、浏览器、Figma、数据库、日志平台或移动设备。
典型闭环:
- 读取真实文件,确认现有模式。
- 制定小步计划和验收标准。
- 修改代码或测试。
- 调用构建、测试、lint、静态检查工具。
- 根据失败日志修复。
- 输出证据和剩余风险。
注意:工具调用提升能力,也放大风险。必须限制权限、敏感数据、可写目录和危险命令,并要求每次修改后有可复现验证。
四、AI Code Review 与防止结构性破坏
AI 生成代码常见问题不是语法错误,而是结构损伤:重复抽象、跨层调用、绕过既有架构、隐式全局状态、错误生命周期、测试只覆盖快乐路径。
AI Code Review 可以作为第一层筛查:
- spec review:是否满足需求,是否做了范围外功能。
- architecture review:是否符合分层、依赖方向和模块边界。
- security review:是否引入敏感日志、明文密钥、不安全网络或权限滥用。
- test review:是否覆盖边界、异常、并发和回归场景。
- maintainability review:命名、复杂度、重复、错误处理是否可维护。
防止 AI 结构 damage 的方法:
- 小 diff,一次只改一个明确目标。
- 先读现有实现和测试,禁止“重写式修复”。
- 让 AI 说明为什么改这些文件,并检查是否有更小改法。
- 用架构规则、lint、自定义检查和人工 review 双重门禁。
- 对核心模块要求先写失败测试,再实现。
五、AI Test Generation 与验证闭环
AI 很适合生成测试草稿、边界清单和 mock 数据,但测试有效性仍需人工确认。
Android 场景常见用法:
- ViewModel 状态机测试:输入 Intent,断言 State/Effect。
- Repository 测试:mock API/DAO,覆盖缓存、错误和重试。
- Coroutine/Flow 测试:使用
runTest、TestDispatcher、Turbine。 - UI 测试:生成 Espresso/Compose test 的骨架。
- 性能/稳定性:生成 Macrobenchmark 场景或 monkey 前置检查清单。
验证闭环必须包括:
AI 生成测试
→ 人工检查断言是否真的验证需求
→ 先确认测试能失败(避免无效测试)
→ 实现/修复代码
→ 运行测试 + lint/build
→ 记录命令和结果
易错点是 AI 可能写“只验证方法被调用”的弱测试,或者为了通过测试修改生产代码行为。工程上要优先验证用户可见行为和业务不变量。
六、AI 安全风险与治理
AI Coding 带来的安全风险包括代码风险、数据风险和供应链风险。
| 风险 | 例子 | 防护 |
|---|---|---|
| 敏感信息泄露 | 把 token、客户数据、日志上传给外部模型 | 脱敏、最小上下文、本地模型/企业网关 |
| 不安全代码 | 关闭证书校验、明文存储、宽权限 | 安全规则、SAST、人工安全 review |
| 依赖投毒 | 引入不存在或低信誉依赖 | 锁定依赖审批、SBOM、漏洞扫描 |
| 许可证风险 | 复制不明来源代码 | 代码来源审查、许可证扫描 |
| 过度自动化 | agent 自行改发布配置/密钥 | 权限隔离、只读默认、审批门禁 |
安全治理原则:不给 AI 不必要的秘密;不让 AI 绕过 review;不让 AI 单独决定安全策略;对生成代码使用和人工代码同等级别的审查、测试和审计。
七、移动开发工作流集成
在 Android 团队里,AI Coding 可以嵌入日常流程,但要和移动端特有验证结合。
- 需求阶段:让 AI 从 PRD 提取状态机、异常分支、埋点和权限影响。
- 开发阶段:生成 ViewModel、UseCase、Repository、Compose 预览、测试样例,但保持架构边界。
- 调试阶段:解释 crash stack、ANR trace、Perfetto 片段、Gradle 依赖冲突。
- 测试阶段:补充单元测试、UI 自动化、兼容性矩阵和回归清单。
- 发布阶段:检查隐私权限、混淆 keep、Baseline Profile、灰度开关和回滚预案。
- 安全阶段:辅助做敏感日志扫描、网络配置检查、权限最小化和依赖漏洞梳理。
移动端尤其要提醒 AI:生命周期、线程、协程取消、配置变更、低端机性能、Android 版本兼容、隐私合规和应用商店政策,这些是通用代码模型容易忽略的约束。
八、面试表达:从“会用 AI”到“能管 AI”
可以这样回答:
我会把 AI 放进工程闭环里:Prompt Engineering 负责把任务讲清楚,Context Engineering 负责给真实项目上下文,agentic coding 负责读写和运行验证,AI code review/test generation 提高覆盖率,最后由测试、lint、build、人工 review 和安全规则做 completion gate。我不把 AI 当成替代工程判断的工具,而是把它当成可审计、可回滚、受约束的协作者。
这类回答比“我用 AI 写页面很快”更高级,因为它体现了质量、验证、安全和团队协作意识。
高频面试题
Q1:Prompt Engineering 和 Context Engineering 有什么区别? Prompt Engineering 关注单次怎么问,让目标、约束和输出清楚;Context Engineering 关注给 AI 哪些真实项目上下文、规则、历史决策和验证信息,并控制上下文不过载或污染。
Q2:什么是 agentic coding? 它指 AI 能通过工具读文件、搜索、编辑、运行测试并根据结果迭代,而不是只生成文本。关键风险是权限和范围扩大,所以必须有工具权限、可写范围和验证门禁。
Q3:MCP / tool calling 对 AI Coding 的意义是什么? MCP 和 tool calling 把外部系统能力标准化地提供给模型,例如代码仓库、日志、浏览器、移动设备或设计系统。意义是让 AI 基于真实环境行动,但也需要权限控制、审计和敏感数据保护。
Q4:AI 生成测试靠谱吗? 可以作为草稿和边界补充,但不能盲信。要人工检查断言是否验证真实需求,最好先看到测试失败,再实现代码,最后运行测试、lint、build 形成证据。
Q5:如何防止 AI 破坏代码结构? 限制小 diff 和可改范围,要求先读现有模式,禁止重写式修复;用架构规则、review、测试和静态检查兜底;对核心模块先写失败测试,再让 AI 实现最小改动。
易错点 / 追问
- 不要把 AI Coding 讲成“模型替我负责”,责任仍在工程师和团队流程。
- 不要给 AI 过多无关上下文或敏感数据,Context Engineering 讲究最小充分。
- 不要接受未验证的 AI 输出,必须有测试、lint、build 或人工 review 证据。
- MCP/tool calling 不是越多越好,工具权限和审计比炫技更重要。
- AI 生成测试要防止弱断言和为了通过测试而改坏业务语义。