得物客户端开发秋招一面:启动链路、协程取消与渲染掉帧
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 请做一下自我介绍
- Android 应用从点击桌面图标到首个页面显示,大致经历了哪些过程?
- Activity 因配置变化重建时,哪些对象会被保留,哪些状态需要主动保存?
- Fragment 为什么容易出现状态错乱,如何避免异步结果更新错误页面?
- 一个页面反复进出后出现内存增长,你会从哪些方向排查?
- Looper、MessageQueue 和 Handler 是怎样协同工作的?
- 为什么在主线程中使用 runBlocking 容易导致卡顿甚至 ANR?
- Kotlin 协程取消为什么不是强制中断,如何保证任务能够及时停止?
- Android 的触摸事件从窗口到具体 View 是如何传递的?
- RecyclerView 滑动时出现明显掉帧,如何定位和优化?
- View 的测量、布局和绘制阶段分别解决什么问题?
- 解释一下 Choreographer 与屏幕刷新之间的关系
- Android 的进程优先级发生变化时,系统会如何处理应用?
- Binder 调用为什么不能传递过大的数据?
《参考解析》
启动到首帧的链路要能一路讲到绘制。 大致是:Launcher 通过 Binder 请求 ActivityTaskManagerService → 系统让 Zygote fork 出应用进程 → ActivityThread.main() 进入主线程消息循环 → 创建 Application → 创建目标 Activity → onCreate()/setContentView() → ViewRootImpl.setView() → Choreographer 驱动测量、布局、绘制 → 首帧上屏。答题时最有价值的补充是卡顿的归因:如果 onCreate() 或首帧绘制前做了大量磁盘 IO、复杂 JSON 解析或同步网络请求,就会直接推迟首屏甚至触发 ANR——所以启动优化的抓手就是把这些工作移出关键路径(异步化、延迟初始化、或提前到 Application 阶段并行做)。
状态保存的边界是「短小可序列化」。 配置变化(如旋转)默认会销毁并重建 Activity,普通成员变量不保留。适合放 onSaveInstanceState() 的是短小的临时状态(查询词、选中 tab),因为它走的是 Bundle 且会被系统序列化;ViewModel 适合配置变化期间需要存活的数据;SavedStateHandle 适合进程被系统回收后仍要恢复的小数据;长期状态才落数据库或文件。最关键的一条纪律:不要把大列表、Bitmap 或大对象塞进 Bundle,会触发 TransactionTooLargeException。
Fragment 状态错乱的根因是视图生命周期短于实例生命周期。 Fragment 实例可能在 onDestroyView() 之后仍然存在,此时异步任务回来还去操作已经被销毁的旧 View,就会出现崩溃、内存泄漏或数据显示到错误页面。正确做法是用基于 View 生命周期的协程:viewLifecycleOwner.lifecycleScope 配合 repeatOnLifecycle(STARTED) 收集数据;同时不要在 Fragment 里长期持有 binding,在 onDestroyView() 里把它置空。判断异步结果能否更新 UI 时要同时检查页面生命周期和请求身份(这次结果是不是当前这次请求的),只判断 isAdded() 是不够的。
内存增长的排查顺序是先分类再定位。 第一步先确认涨的是哪种内存——Java/Kotlin 堆、Native 内存、显存还是线程数量,用 adb shell dumpsys meminfo 看整体构成,dumpsys activity processes 看进程状态。常见根因有三类:Activity/Fragment 泄漏(被静态引用、未注销的监听器、Handler 延迟消息持有外部类)、资源未释放(Cursor、流、Bitmap)、以及缓存无上限。定位手段是连续进出页面后在关键节点主动触发 GC 再抓 heap dump,比对对象数量,找「只增不减」的引用链,而不是凭感觉猜。
Handler 机制与 runBlocking 的坑。 Looper 不断从 MessageQueue 取消息,Handler 负责把消息投递进队列并在取出时执行——主线程的 Looper 就是 UI 事件循环,所以任何阻塞主线程的操作都会卡住整个 UI。runBlocking 会用当前线程的事件循环去阻塞等待协程完成,在主线程上用它等于把主线程停住:轻则掉帧,重则输入事件(包括 ANR 的判定)无法处理而触发 ANR;更糟的是如果被等待的协程里还需要回到主线程派发(比如 withContext(Dispatchers.Main)),就直接死锁了。替代方案是用 lifecycleScope.launch 这类非阻塞的挂起方式,把「等待」交给回调而不是线程。
协程取消为什么不是强制中断。 协程的取消是协作式的:cancel() 只是把 Job 置为 cancelling 状态并抛出 CancellationException 到下一个挂起点,如果协程正在一段没有挂起点的纯计算循环里,它根本不会停下来。所以要保证及时停止,就得让代码可取消:在耗时循环里定期调 ensureActive() 或 yield() 检查取消状态,用支持取消的挂起函数(如 delay 而非 Thread.sleep、可取消的 IO)而不是阻塞调用,并在 finally 里用 withContext(NonCancellable) 做必要的资源清理(因为取消后普通挂起调用会立刻失败)。另两个易错点:不要吞掉 CancellationException(吞掉就真的取消失败了);被取消的协程里的异常不会向上传播,别把它当业务错误处理。
触摸事件分发、RecyclerView 掉帧与 View 三阶段。 事件从窗口到 View 的传递顺序是 Activity.dispatchTouchEvent() → Window/DecorView → ViewGroup.dispatchTouchEvent() → 按子 View 逆序询问是否消费,期间配合 onInterceptTouchEvent() 拦截;子 View 的 onTouchEvent() 返回 true 才表示消费,都不消费则回传给父级。RecyclerView 掉帧的定位要用工具而不是猜:先用 dumpsys gfxinfo 或 Systrace/Perfetto 看每一帧的耗时分布,再区分是布局/测量过重(item 布局层级太深、onBindViewHolder 干了重活)、过度绘制、还是 item 重建(notifyDataSetChanged 触发全量刷新)。优化方向对应着来:把 onBindViewHolder 里的工作做轻、复用稳定、用 DiffUtil 做增量更新、避免在滚动中触发同步 IO。而 View 的三阶段本身就是这条链路的基础——测量决定每个 View 多大,布局决定放在哪,绘制生成要上屏的内容;三者都由 Choreographer 按屏幕刷新节奏(如 60Hz 约 16.6ms 一帧)驱动,错过一个 vsync 周期就是一次掉帧。
进程优先级与 Binder 传输限制。 Android 会按前台/可见/服务/后台/空进程的优先级回收内存,后台进程在内存紧张时被优先杀掉,这也是「回到后台一会儿再回来数据没了」的原因——所以重要状态要落盘或用 SavedStateHandle 恢复,onSaveInstanceState() 是应用被杀前最后的机会。Binder 用共享内核缓冲区传递数据,单个进程的 Binder 事务缓冲区大小有限(约 1MB,且是所有并发事务共享),所以传大对象(Bitmap、大集合、长字符串)会抛 TransactionTooLargeException;正确做法是传 Uri、文件描述符或大数据的内存地址(如 ParcelFileDescriptor、Ashmem),而不是数据本身。