模拟面试记录一——Android 高级开发八问八答(含评分与改进点)
一场大厂 Android 高级岗综合模拟面试的完整复盘:8 道题覆盖播放缓存架构重构、MVI 选型与 Compose 重组优化、内存治理、协程取消机制、Handler 与事件分发、启动优化、ARouter 路由。每题给出面试官追问、参考答案与逐项评分,最后汇总短板与下一步训练重点。
面试系列:本文是面试准备系列的一篇,可搭配阅读 《口述版自我介绍》 与 《面试如何回答”遇到的最大难题”》。
候选人:何晓猛 | 模式:大厂高级岗综合模拟 | 本场共 8 题,覆盖项目深挖、架构设计、内存治理、Kotlin 协程、Framework 原理与性能优化。每题按「面试官追问 → 参考答案 → 评分与改进点」三段组织,适合自测时先遮住答案口述一遍,再对照补漏。
第 1 题 · 项目深挖:音视频播放缓存架构重构(Beem)
面试官问题: 原来的本地代理方案是怎么工作的?”安全隐患”和”并发写缓存冲突”具体是怎么发生的?新的读写分离方案如何落地,播放侧如何感知并命中下载引擎写入的缓存?
参考答案(优化后表达)
前置:开口先把需求对齐
讲架构之前,我先把这个模块的功能需求拆成三条——后面所有设计决策都是从这三条推出来的:
- 边播边缓存:播放的同时把数据落盘,用户回看时不再重复消耗流量。
- 缓存命中优先:再次播放同一个视频时,已经缓存的部分直接读本地、不回源,没缓存的部分才走网络。这条隐含一个硬约束——缓存必须按字节区间可寻址,能回答”第 0 到 30MB 有没有、在哪儿”,而不是”要么整个文件在、要么整个文件不在”。
- 支持下载到本地,而且必须能”续着下”:用户可以把视频完整存下来;关键是,如果之前播放时已经缓存了一部分,点击下载时不能从 0 开始重新拉一遍完整文件,只下载尚未缓存的那部分,最后合并导出成一个完整视频。
第三条是整套架构的分水岭。 它直接否掉了”播放一套缓存、下载一套缓存”的做法:两份缓存互不可见,播放时攒下的那 30% 在下载侧等于不存在,只能重头下——需求不满足,还多烧一份流量。所以缓存必须只有一份,播放侧和下载侧共用同一份存储、同一套任务管理:播放攒下的分片,下载启动时天然可见,缺哪段补哪段。
口述收口可以这么讲:「前两条需求决定了缓存必须是分片的、能按 position 随机读;第三条决定了缓存只能有一份、写入职责必须收敛。我后面的读写分离方案,本质上就是被第三条逼出来的。」
背景:旧方案及其缺陷
旧方案在 App 内部启动一个本地 HTTP 代理服务器(监听 127.0.0.1 的端口),播放器原本直接请求远端视频,接入代理后改为请求本地地址,由代理回源拉取真实数据,从而实现边播边缓存。功能可用,但存在结构性缺陷:
- 端口暴露的攻击面:本地代理本质上是一个开放的转发器,同一设备上的其他程序也能访问该端口,可能被恶意 App 当作跳板发起任意网络访问。
- 环回明文 HTTP 的泄露风险:播放器到本地代理这一跳走的是明文 HTTP,播放请求中携带的鉴权 Header(签名、token 等)在本地环回网络中存在被其他进程嗅探泄露的风险。
新方案:基于 Media3 SimpleCache 的读写分离
采用 Media3 提供的 SimpleCache 实现边播边缓存,并确立”播放侧只读、写入职责收敛到下载引擎”的读写分离设计:
- 只读配置:通过
CacheDataSource.Factory.setCacheWriteDataSinkFactory(null)将播放器的缓存策略改为只读,播放器不再写入缓存。 - 预加载替代边播边写:播放前调用
preload预加载视频,preload 与DownloadManager走同一套下载逻辑——用户播放中途点击下载时,因相同视频的下载任务已存在而直接复用,从机制上根除了多任务并发写缓存的冲突。 - 下载天然”续着下”,不需要单独写续传逻辑:因为 preload 和
DownloadManager共用同一份SimpleCache,点击下载时DownloadManager启动任务前会先查缓存里已有的 span 区间,只对缺失区间发网络请求。也就是说”已经缓存了 30%,就只补剩下 70%”不是额外加的一段业务代码,而是”共用一份缓存 + 统一任务管理”这个设计自动带来的结果——架构选对了,这条需求是顺带满足的。 - 缓存导出:Media3 的分片缓存没有提供导出完整文件的能力。通过阅读源码发现每个缓存分片(span)都带有
position字段,对应原始视频文件的绝对偏移量,因此可以按 position 排序,将所有分片顺序读取并合并写入一个文件,得到完整的视频文件。 - 导出前完整性校验:导出前先校验缓存完整度;若缓存不完整,则先由
DownloadManager下载完成后再导出。 - 任务生命周期管理:用户关闭播放界面时,在
onDestroy中停止视频预下载任务,避免无效任务占用资源。
根因分析:为什么并发写会导致播放卡死
SimpleCache 本身是线程安全的,操作数据块时会加锁。当 DownloadManager 正在写入视频的某个片段时,会长期持有该数据区间的写锁(默认写满 5MB 才释放);此时若播放器恰好播到同一位置,CacheDataSource 尝试写该区间的数据,播放器 IO 线程被阻塞等待写锁释放,缓存数据拿不到,直接表现为播放卡死。将播放侧改为只读后,锁竞争的源头被彻底消除。
评分与反馈
- ✅ 先对齐需求再讲架构(三条需求推出”缓存必须分片 + 必须只有一份”),体现设计推导而非堆砌结论
- ✅ 源码级细节扎实(分片 position、写锁机制),根因分析准确,方案从机制上消灭并发写
- ✅ “续着下”落到机制层:共用一份 SimpleCache,DownloadManager 只补缺失区间,不是额外写的续传代码
- ⚠️ 可主动交代权衡:preload 会增加流量与磁盘消耗(短观看场景)
- 📊 本题得分:7.5 / 10
第 2 题 · 架构设计:MVI 选型与 Compose 重组优化(Beem)
面试官问题: 选 MVI 而不是 MVVM 的决定性理由是什么?用 Beem 里的真实场景说明。Compose + MVI 下,一个大的 state data class 只有一个字段变了,怎么避免整屏重组?
参考答案(优化后表达)
选型理由:状态一致性与可回放性
选择 MVI 的决定性理由是:MVVM 在并发异步场景下容易产生非法组合状态,且状态散落在多个独立流中、缺乏统一快照,出问题时难以回放排查;MVI 则保证了状态更新的原子性和确定性。
以邮箱编辑界面为例。编辑界面中有附件列表、收件人/抄送人列表、草稿内容、发送按钮状态等多个状态,如果使用 MVVM,这些状态会散落在多个独立的 StateFlow 中。当用户点击发送时,业务逻辑需要逐个读取各分散流的 StateFlow.value,在异步并发下可能读到”最新的草稿内容 + 还未来得及更新的附件列表”这样的非法组合状态;而且由于没有全局快照,事后很难还原当时的状态现场,排查成本极高。
使用 MVI 后,所有状态收敛进一个不可变的 UiState,任何状态变更都经由 Intent → Reducer 产出新状态,UI 拿到的永远是一致的快照——”发送”动作处理时读取的是同一个原子版本的状态,一致性问题从机制上被消除。
避免整屏重组的手段
参数级拆分,不传整个 state:子组件不接收整个 UiState,而是从 state 中取出它实际需要的字段传入。这样即使 state 中某个字段变化,没有用到该字段的子组件参数未变,能命中 Compose 的 skip 优化跳过重组。
- 保证参数类型 Stable:Compose 跳过重组的前提是传入该 Composable 的所有参数都是 Stable 的。Kotlin 的
List等集合接口,编译器无法确定其实现类是否可变,默认判定为 unstable、无法跳过。解决方式:- 在 state 中使用不可变集合:
ImmutableList/PersistentList(kotlinx.collections.immutable); - 或使用
@Immutable/@Stable注解显式告知 Compose 编译器类型是稳定的。
- 在 state 中使用不可变集合:
Lambda 参数的稳定性:每次重组都新建 lambda 实例会导致子组件无法 skip,应使用
remember包裹或直接传方法引用。从 Compose Compiler 1.5.4+(并在 Kotlin 2.0 中成为默认规则) 引入了 Strong Skipping Mode(强跳过模式):
编译器变得足够聪明了,它会在编译期帮我们自动执行 Lambda Memoization:
只要你的 Lambda 捕获的对象是稳定的(Stable),编译器会在底层自动帮你套上等价于
remember的缓存代码!也就是说,今天我们直接写:
1
Button(onClick = { viewModel.deleteItem(item.id) }) // 编译器底层自动帮你 memoize 了!
- 机制收尾——不可性变如何反过来帮助性能:Reducer 通过
copy生成新状态时,只有变化的字段是新引用,未变字段保持旧引用;Compose 参数比较时 data class 的equals会命中引用相等而直接短路跳过比较——这就是”状态不可变”与”重组性能”的正向连接。
架构代价(主动交代)
MVI 的事件串行过 Reducer,不适合高频状态更新——例如输入框每字符刷新、播放进度条回调。这类高频信号若也走主状态机,会撑爆状态流。实践中需要为高频状态单独开通道(如独立的 progress 流或 effect 流)绕过主状态机,主 UiState 只保留低频、决定 UI 结构的状态。
评分与反馈
- ✅ 选型理由落在真实场景(邮箱发送时读分散流导致非法组合状态),重组优化讲到”List 为什么 unstable”的机制层
- ⚠️ 可再补充:lambda 稳定性手段、MVI 高频状态代价与旁路通道设计
- 📊 本题得分:8 / 10
第 3 题 · 内存治理(考虫项目)
第 1 小问 · 系统排查手段与线上 OOM 归因
面试官问题: 用什么工具组合系统性排查泄漏?线上 OOM 只有一句
OutOfMemoryError,怎么归因到具体代码?
参考答案(整理后表达)
本地:LeakCanary 快速定位引用链 + Android Studio Profiler 实时观察内存。
线上(Bugly):OOM 日志通常无详细堆栈,归因策略是先按异常信息分型,再结合场景收敛:
| 异常信息 | 类型 | 主要怀疑方向 |
|---|---|---|
java heap space | Java 堆耗尽 | Java 对象泄漏(Activity、静态集合、大对象) |
failed to allocate | native 堆耗尽 | 图片库、Bitmap(Android 8.0 起 Bitmap 在 native 堆)、native 代码 |
unable to create new native thread | 线程数过多 | 线程泄漏 |
分型后结合设备类型(低端机更易 OOM)与最后停留页面路径反推触发场景,缩小排查范围。
进阶归因手段(待补充)
- 线上”造堆栈”:接 KOOM(快手开源)或 Matrix Resource Canary,OOM 前
fork子进程 dump hprof 不卡主线程;或 LeakCanary 2.x 上传 heap dump; - hprof 离线分析:MAT 看 Dominator Tree / GC Roots 找最大持有者;
- 区分”存量泄漏”与”峰值过载”(一次性加载超大图也可能 OOM,未必是泄漏);
- 线程泄漏:
/proc/{pid}/status查 Threads 数;too many open files的 FD 泄漏常伪装成 OOM。
评分与反馈
- ✅ 本地工具组合合理、线上 OOM 异常分型归因思路扎实、结合设备与页面路径收敛
- ⚠️ 缺”线上造堆栈”手段(KOOM / Matrix / LeakCanary 上传 dump + MAT 分析)
- 📊 本小题得分:8 / 10
第 2 小问 · Handler 延迟消息导致 Activity 泄漏
面试官问题: 挑一个印象最深的泄漏:根因(谁持有谁)、为什么会泄漏(GC 可达性)、怎么修?
参考答案(整理后表达)
根因(引用链):匿名内部类 Handler 隐式持有外部 Activity;Message 持有 Handler;Message 被全局 Looper 的 MessageQueue 持有。完整引用链:GC Root (Looper.MessageQueue) → Message → Handler → Activity。
GC 角度解释:GC 从 GC Root 开始扫描。Looper 是全局单例、属于 GC Root。发送延迟消息后 Message 进入 MessageQueue;用户关闭 Activity 后,Activity 的本地引用断掉,但这条强引用链依然连通,Activity 保持 GC 可达,GC 不会回收它——直到延迟消息执行、Message 出队,引用链断开才能回收。
危害:泄漏的不仅是 Activity 对象本身,而是它连带持有的整个 View 树、Context、Bitmap、Drawable,动辄几十 MB,这是 Activity 泄漏最致命之处。
修复方案:
- Handler 改为静态内部类(不隐式持有外部类)+
WeakReference保存 Activity(弱引用不阻止回收);handleMessage中判空引用,使用前判断 Activity 是否isFinishing()/已销毁,避免野引用崩溃; - Activity 的
onDestroy中调用removeCallbacksAndMessages(null),清空所有待处理消息,主动切断引用链做兜底。 - 顺带规避:用 Lambda 替代匿名内部类,或
Handler(Looper.getMainLooper())构造。
评分与反馈
- ✅ 引用链完整(GC Root → Message → Handler → Activity)、GC 可达性解释准确、修复双保险(静态类 + 弱引用 / removeCallbacksAndMessages 兜底)
- ⚠️ 补危害量化(连带 View 树/Bitmap 数十 MB)与 WeakReference 判空细节
- 📊 本小题得分:9 / 10
第 3 小问 · 防复发机制
面试官问题: 治理完怎么保证不复发?有没有机制性的东西(防回归)而不是”修一波完事”?
参考答案(整理后表达)
实际做法:以编码规范为主,梳理常见内存泄漏点,尽量在上线前拦截;自动化手段暂未使用。
进阶方向(待补强,高级岗区分度题):仅靠规范本质是”靠人自觉”,需要至少一个不依赖人的机制——
- CI 接入 LeakCanary 内存测试:JVM/Robolectric 单测中做泄漏断言,每次发版自动跑,泄漏在合并前变红;
- 静态扫描:自定义 Lint 规则,在编译期拦截高危写法(匿名内部类 Handler、非静态内部类持有 Context 等);
- 线上内存看板:Bugly/Matrix 上报 Activity 泄漏数、内存峰值、OOM 率,设阈值报警;
- 规范落地化:Code Review checklist + 团队 Wiki 案例库(沉淀 Handler 泄漏等反例),新人 onboarding 过一遍。
一句话骨架:「规范 + 自动化双轨:规范沉淀成 checklist 与 Wiki 案例库;自动化用 LeakCanary 单测进 CI 门禁 + 自定义 Lint 编译期拦截 + 线上看板设阈值,让泄漏在上线前暴露。」
评分与反馈
- ✅ 坦诚(自动化未用过、未硬编)、方向正确(规范 + 拦截在上线前)
- ⚠️ 只有”意愿”无”机制”,未达高级岗预期;需补 CI 内存测试 / 自定义 Lint / 线上看板等不依赖人的手段
- 📊 本小题得分:5 / 10
- 第 3 题总分:约 7.5 / 10(8 + 9 + 5 加权)
第 4 题 · Kotlin 协程:取消机制三连问
第 1 小问 · 取消传播与”协作式”取消
面试官问题:
coroutineScope { }里启动的子协程,父协程 cancel 时取消是怎么传播到子协程的?为什么说协程的取消是协作式的——子协程什么情况下”拒绝被取消”?挂起函数的取消检查点在哪里?
参考答案(整理后表达)
取消传播机制:在 coroutineScope 中启动子协程时,子协程的 Job 会将自己注册到父协程的子节点链表中。当父协程 cancel 时,父协程状态流转到 Cancelling,接着遍历子节点链表,调用每个子 Job 的 cancel 方法;如果子协程还有自己的子协程,cancel 会沿 Job 树递归向下,直到所有子节点都收到取消信号。
为什么是”协作式”
父协程只能发送一个取消请求、将状态标记为已取消,它不能像强杀线程那样直接终止子协程的运行——子协程的代码必须主动检查取消标志并配合退出。
子协程”拒绝取消”的三种情况
- 密集 CPU 计算无检查点:如
while循环中没有显式调用挂起函数或主动检查isActive,取消信号无法生效,任务会继续执行至循环结束。 - 吞掉 CancellationException:过于宽泛的 try-catch 捕获了该异常且未重新抛出,取消流程被打断,协程继续运行。
- 主动使用
withContext(NonCancellable):该代码块内部的协程无视取消信号(典型用途:清理资源、写回数据等必须完成的收尾工作)。
取消检查点的两个时机(源码层面)
- 挂起发生前:调用
delay等可取消挂起函数时,第一件事就是检查当前 Job 的isActive——已取消则直接抛出CancellationException,根本不会挂起。 - 挂起等待中:协程挂起时,内部(
suspendCancellableCoroutine的 Continuation)会向 Job 注册取消监听;外部取消时触发监听,在挂起点以取消异常强行恢复执行,挂起函数被唤醒后立即抛出异常,协程退出。
评分与反馈
- ✅ 机制链完整(Job 树注册 → Cancelling → 递归下发)、三种拒绝取消场景全对、检查点讲到源码两个时机(挂起前检查 + 挂起中监听恢复)
- ⚠️ 措辞修正:”协程锁死在计算中”不准确——不是死锁,是取消失效,任务正常继续执行至完成,准确说法避免被追问
- ⚠️ 可补充 API 细节:无挂起点时的主动检查推荐
ensureActive()(或currentCoroutineContext().ensureActive())与isActive判断,报出 API 名更有说服力 - 📊 本小题得分:9 / 10
第 2 小问 · viewModelScope 的取消与网络请求的中断链路
面试官问题: ViewModel 里用
viewModelScope发起网络请求,用户中途退出页面。viewModelScope是怎么被取消的?Retrofit/OkHttp 这一层会配合取消吗?底层 HTTP 连接会发生什么?
参考答案(整理后表达)
viewModelScope 的取消时机
viewModelScope 是挂在 ViewModel 上的一个带 Closeable 的协程作用域。Activity 真正销毁时清理 ViewModelStore,ViewModelStore.clear() 会逐个调用 ViewModel 的清理流程,viewModelScope 的 Closeable 在其中被统一关闭——作用域随之 cancel,其下所有子协程进入取消状态(协作式取消,如第 1 小问所述的传播机制)。
Retrofit 层:取消回调桥接
请求正挂起在 Retrofit 的 suspend 函数上——Retrofit 内部实际走 enqueue 异步请求,外层用可取消挂起(suspendCancellableCoroutine)包了一层并注册取消回调。协程一被取消:① 协程侧以 CancellationException 恢复挂起点;② 取消回调转调 OkHttp 的 call.cancel()。
OkHttp 层:传输层的真实中断
- HTTP/1.1:直接关闭底层 socket,正在阻塞读的线程抛出 Socket 异常,请求立刻失败;
- HTTP/2:只发送
RST_STREAM帧取消当前流,TCP 连接保留、可继续复用。
整个链路从协程取消到传输层中断是真正贯通到底的,不只是标记状态。
评分与反馈
- ✅ 起点正确(ViewModelStore → Closeable → 作用域 cancel)、Retrofit 桥接机制(enqueue + 可取消挂起 + 取消回调)、传输层协议差异(socket 关闭 vs RST_STREAM 复用连接)
- ⚠️ 可补充:Retrofit 取消时协程侧同时以
CancellationException恢复(两条线:OkHttp 侧中断 + 协程侧异常恢复),链路完全闭合 - 📊 本小题得分:9 / 10
第 3 小问 · suspendCancellableCoroutine 的取消与 resume 竞态
面试官问题: 自定义
suspendCancellableCoroutine中,已调用resume之后取消才到达,invokeOnCancellation还会回调吗?怎么保证安全?
参考答案(整理后表达)
结论:resume 之后 invokeOnCancellation 不会执行。
注册的取消回调只在协程以取消方式终结时触发;成功 resume 后,之前注册的取消回调即被废弃。由此产生一个风险窗口:resume 返回到协程真正消费结果之间,如果取消恰好在这段间隙到达,监听器、回调注册等资源就没人负责释放——这正是官方文档专门警告的坑。
两个安全解法:
resume(value) { onCancellation }双参数版本兜底:onCancellation块伴随 resume 的结果一起提交——若取消发生在”已 resume 但结果尚未被消费”的窗口内,协程框架保证执行该块做清理,相当于把清理责任与成功结果原子绑定。典型场景:传感器/广播/动画回调封装成挂起函数时,resume 后必须反注册 listener,否则泄漏。tryResume+completeResume两段式提交:先tryResume(value)试探性恢复并拿到 token,再completeResume(token)提交;中间穿插检查取消状态,避免竞态。
评分与反馈
- ✅ 判断准确(resume 后取消回调不执行)、窗口期风险识别正确、双参数 resume 与 tryResume 两段式两个高级 API 全对
- ⚠️ 展开:讲清双参数版本为何有效(onCancellation 与结果原子绑定)+ 场景收尾(listener 反注册),理论落回实践
- 📊 本小题得分:8 / 10
- 第 4 题总分:9 / 10(9 + 9 + 8 加权)
第 5 题 · Handler 机制:Looper 死循环为什么不卡死
面试官问题: 主线程
Looper.loop()是死循环,为什么不卡死(不 ANR)?没消息时循环在干什么?CPU 是空转还是休眠?底层机制是什么?
参考答案(整理后表达)
先辨正概念:ANR 是什么
ANR 的本质是特定事件超时未响应(输入事件 5 秒、前台广播 10 秒、前台服务 20 秒),而不是“线程在等待任务”。等活干 ≠ 卡死。
无消息时:线程休眠,不空转
当 MessageQueue 中没有消息时,底层通过 Linux 的 epoll 机制将主线程挂起——线程进入休眠态,CPU 不空转,也就没有“卡死”和烧电问题。
完整机制链(源码层)
Looper.loop()死循环调用MessageQueue.next();next()进入 native 层pollOnce,本质是对一个管道 fd(mWakeEventFd)执行epoll_wait阻塞——这就是“休眠”的落点(有延时消息时 epoll 带超时参数休眠,到点自醒);- 其他线程
enqueueMessage新消息时,底层nativeWake向该 fd 写入一个字节,epoll 被唤醒,主线程恢复取消息、分发处理。
真正的 ANR 来源
不是循环本身,而是消息处理(dispatchMessage 里的业务代码)执行了耗时操作,导致后续消息迟迟得不到处理,事件响应超时——所以解法是耗时任务移出主线程,而不是担心 Looper 死循环。
评分与反馈
- ✅ 概念辨正(ANR = 事件超时 ≠ 线程等待)、epoll 休眠判断正确、ANR 真正来源(耗时消息处理)准确
- ⚠️ 补机制链:epoll_wait 阻塞在 eventfd 管道上、
nativeWake写字节唤醒、延时消息走 epoll 超时参数——说出“写 fd 唤醒”才是源码级答案 - ⚠️ ANR 类型可补:前台广播 10s、前台服务 20s
- 📊 本题得分:7 / 10
第 6 题 · View 事件分发机制
面试官问题:
ACTION_DOWN从系统到目标 View 的路径上,dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent分别在哪个类、什么时候被调用?最深处子 View 不消费时事件怎么走?
参考答案(整理后表达)
传递路径:Activity → Window → DecorView → ViewGroup → View
三个方法职责
dispatchTouchEvent:负责分发,事件到达该 View 就一定被调用。返回 true 表示该 View 及其子树消费了事件(ViewGroup 的返回值是子 View 消费结果或自身 onTouchEvent 结果的综合)。onInterceptTouchEvent:负责拦截,只有 ViewGroup 有。返回 true 表示拦截,后续事件交给当前 View 的onTouchEvent;返回 false 不拦截,继续向子 View 分发。onTouchEvent:负责处理。返回 true 消费;返回 false 不处理,事件交给父 View 的onTouchEvent。
ViewGroup 内部分发流程(DOWN)
ViewGroup 的 dispatchTouchEvent 被调用 → 内部调 onInterceptTouchEvent 判断是否拦截 → 拦截则执行自己的 onTouchEvent;不拦截则调用 child 的 dispatchTouchEvent 把事件继续下发。
回传路径
最深处子 View 不消费 → 逐级上抛,交给各级父 View 的 onTouchEvent → 全都不消费则最终传给 Activity.onTouchEvent。
事件序列行为(进阶要点)
- DOWN 是”资格赛”:某个 View 消费了 DOWN,就拿到整个事件序列(后续 MOVE/UP 直接发给它);DOWN 不消费,后续事件不会再传给它。
- 机制名:ViewGroup 记录 DOWN 接收者的引用是
mFirstTouchTarget——后续事件直接沿它分发,不再遍历子树。 - 子 View 可通过
requestDisallowInterceptTouchEvent禁止父容器拦截(内部滑动冲突的解法之一)。
评分与反馈
- ✅ 路径完整、三方法职责准确、回传路径正确、ViewGroup 内部分发流程清楚
- ⚠️ 漏了事件序列行为:DOWN 不消费则后续 MOVE/UP 不再下发;
mFirstTouchTarget、requestDisallowInterceptTouchEvent机制名 - 📊 本题得分:8 / 10
第 7 题 · 启动优化:量化口径与优化手段(VIPKID 启蒙)
面试官问题: “启动耗时降低约 50%”这个数字怎么测的?衡量口径(时间点、冷/热启动、本地/线上)+ 优化前耗时花在哪(用什么定位)+ 主要砍掉了什么?
参考答案(整理后表达)
启动类型:冷启动(进程不存在:创建进程 → Application 初始化 → Activity 生命周期 → 首帧渲染,优化重点)、温启动(进程在,重建 Activity)、热启动(进程和 Activity 都在,切回)。
衡量指标:TTID(首帧渲染时间)、TTFD(完全可交互时间)。
本地测量
adb shell am start -W粗测 TTID:多次杀进程重启取 TotalTime 平均值;- TTFD:Activity 完全加载完成时调用
reportFullyDrawn(),logcat 查指定日志获取时间。
线上埋点(神策/友盟/Bugly 等平台)
- 冷启动起点:
Application.attachBaseContext()记录; - 首帧回调:
ViewTreeObserver.OnPreDrawListener内计算与起点的差值; - 完全可交互:加载完成后计算与起点的差值。
优化手段
- Application.onCreate(重点):大量 SDK/工具类初始化集中于此——
- 异步初始化:不影响首屏的 SDK 丢子线程;
- 延迟初始化:真正用到再初始化,或丢
IdleHandler; - 分级依赖管理:Jetpack App Startup 框架收敛多个 SDK 的 ContentProvider 为单个,减少实例化开销,管理初始化依赖防乱序。
- 闪屏优化:windowBackground / SplashScreen API 避免黑白屏,体感更快;
- 首屏布局:减少布局层级、ViewStub 延迟加载非首屏内容、异步 inflate。
评分与反馈
- ✅ 答题框架体系化(类型→指标→本地→线上→优化),三套测量口径可落地,优化五方向覆盖全面且分级正确(含 App Startup 的依赖管理价值)
- ⚠️ 归因定位手段缺失:
am start -W只有总数——需补 Perfetto/systrace 看主线程时间线、Method Trace 看 onCreate 里各 init 耗时分布;”砍掉了什么”要有依据 - ⚠️ 给出具体数字(如 2.8s → 1.4s 及耗时构成),面试官对数字的信任度远高于形容词
- 📊 本题得分:8 / 10
第 8 题 · 组件化与路由:ARouter 跨组件无依赖调用原理(VIPKID)
面试官问题: ARouter 怎么做跨组件无直接依赖调用?
build("/login/activity").navigation(this)编译期和运行期各发生什么?路由表谁、何时、怎么生成?路径冲突能否构建期提前发现?
参考答案(整理后表达)
核心思想:编译期通过 APT 生成路由映射表,运行期通过反射/字节码插桩加载路由表,底层封装成原生 Intent 跳转。调用方只需知道 String 类型的 Path,无需拿到目标 Activity 的类引用,实现物理隔离与解耦。
编译期:加 @Route 注解后,ARouter 注解处理器扫描注解,取路径第一段为 Group,为每个模块生成两类辅助类——
- Group 类(
ARouter$$Group$$login):硬编码当前组下「路径 → 目标 Activity Class」映射; - Root 类(
ARouter$$Root$$app):记录当前模块有哪些 Group 及对应类引用。
因按模块独立编译,编译期没有全局大表,而是按模块、按组分散生成。
运行期(两步):
- 初始化(
ARouter.init):老版本扫描 APK 的 Dex 找生成类,耗时;新版本配合 Gradle 插件,在打 Dex 前用 ASM 字节码插桩把各模块 Root 类注册进内存(LogisticsCenter/Warehouse),优化启动速度。 - 跳转(
build().navigation()):提取路径里的 Group,采用懒加载——先查内存该 Group 详细表是否已加载;未加载则反射实例化对应的 Group 类,把「路径 → Activity」映射灌入内存;最后拿到目标 Class,new 一个 Intent、塞入参数,调用原生startActivity完成跳转。
路径冲突检测:
- 模块内重名:APT 生成 Group 类处理 Map 时发现 Key 冲突,编译报错;
- 跨模块同 Group:A、B 模块各自生成全限定名相同的
ARouter$$Group$$login类,分模块编译不报错,但合并 Dex 打包时 D8/R8 报 Duplicate class 导致打包失败。
因此 ARouter 从构建机制上强制「不同模块不能同 Group」,天然防跨模块路径冲突。
评分与反馈
- ✅ 编译期(Group/Root 分散生成)、运行期(init 扫 Dex→字节码插桩 + Group 懒加载 + 反射 + Intent)、冲突检测两阶段(编译期 Key 冲突 / 打包期 Duplicate class)全部讲对
- ⚠️ 可补 Provider 机制(
IProvider服务发现,跨组件拿接口实现,比跳转更常用的组件通信能力) - 📊 本题得分:9 / 10
本场总结:得分分布与下一步训练重点
| 题号 | 考察域 | 得分 | 主要失分点 |
|---|---|---|---|
| 1 | 项目深挖 · 播放缓存架构 | 7.5 | 未主动交代 preload 的流量/磁盘权衡 |
| 2 | 架构设计 · MVI 与 Compose 重组 | 8 | 高频状态旁路通道讲得不够前置 |
| 3 | 内存治理 · OOM 归因与防复发 | 7.5 | 第 3 小问只有”意愿”没有”机制”,仅 5 分 |
| 4 | Kotlin 协程 · 取消机制 | 9 | 措辞精度(”取消失效”≠”锁死”) |
| 5 | Framework · Handler / Looper | 7 | 缺 epoll + eventfd 唤醒的源码级链路 |
| 6 | Framework · View 事件分发 | 8 | 漏事件序列行为与 mFirstTouchTarget |
| 7 | 性能优化 · 启动优化 | 8 | 缺归因工具(Perfetto / Method Trace)与具体数字 |
| 8 | 组件化 · ARouter 路由 | 9 | 缺 Provider 服务发现机制 |
三条共性短板,按优先级排:
- 只有”做过”,没有”机制化”(第 3 题第 3 小问最典型)。高级岗区分度往往不在”你修没修过泄漏”,而在”你用什么手段让它不再发生”——CI 内存测试、自定义 Lint、线上看板,至少要能说出一条自己落过地的。
- 数字缺口径(第 7 题)。凡是要报数字的地方,先把”怎么测的、测的是哪一段”说在前面;说不清口径就降级成”明显改善”,别硬撑。
- 深挖到源码层会停在半路(第 5 题)。能答出”epoll 休眠”已经不错,但停在机制名不算源码级——补上
mWakeEventFd写字节唤醒、nativeWake、延时消息走 epoll 超时参数,这一题就能从 7 拉到 9。
flowchart LR
A["本场共性短板"] --> B["只有意愿<br/>没有机制"]
A --> C["数字缺口径"]
A --> D["源码层停在半路"]
B --> B1["CI 内存测试<br/>自定义 Lint<br/>线上看板"]
C --> C1["先讲测量口径<br/>再讲优化手段"]
D --> D1["读 Framework 源码<br/>补到唤醒/超时细节"]
下一篇继续按这个节奏练:一次一题,答完即时复盘,把”⚠️”项逐条转成能开口的完整答案。