面灵AI→

得物客户端开发秋招一面:启动链路、协程取消与渲染掉帧

轮次
一面
时间
2026-10
来源
牛客网

《面试题目》

  1. 请做一下自我介绍
  2. Android 应用从点击桌面图标到首个页面显示,大致经历了哪些过程?
  3. Activity 因配置变化重建时,哪些对象会被保留,哪些状态需要主动保存?
  4. Fragment 为什么容易出现状态错乱,如何避免异步结果更新错误页面?
  5. 一个页面反复进出后出现内存增长,你会从哪些方向排查?
  6. Looper、MessageQueue 和 Handler 是怎样协同工作的?
  7. 为什么在主线程中使用 runBlocking 容易导致卡顿甚至 ANR?
  8. Kotlin 协程取消为什么不是强制中断,如何保证任务能够及时停止?
  9. Android 的触摸事件从窗口到具体 View 是如何传递的?
  10. RecyclerView 滑动时出现明显掉帧,如何定位和优化?
  11. View 的测量、布局和绘制阶段分别解决什么问题?
  12. 解释一下 Choreographer 与屏幕刷新之间的关系
  13. Android 的进程优先级发生变化时,系统会如何处理应用?
  14. 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),而不是数据本身。