每次帮人做模拟面试复盘,我都能感受到一个现象:大家背的题不少,但一到追问环节就露怯。原因很简单,高频题目虽然就那么几个区域,但真正的分水岭不在“会不会背答案”,而在“有没有从原理到实践都打通”。这篇文章把我在反复的面试复盘里见到的 30 个 Android 高频问题整理出来,每题给出答案要点、考察方向,以及我自己踩过坑后的一些真实体会。适合三种人:准备跳槽的 Android 开发者、刚开始系统性复习的中级工程师、以及想自查知识盲区的人。
1. Java 与 Kotlin 语法底层:最先翻车也最容易翻盘的语言题
1.1 equals 和 hashCode 为什么总被连在一起问
这道题几乎是 Java 类面试的开场白。大多数人都能背出“equals 相等则 hashCode 必须相等,反过来不成立”,但面试官想听的绝对不是这条结论,而是结论背后的哈希表原理。
HashMap 会用 hashCode 定位桶下标,再用 equals 处理落在同一桶里的碰撞。如果你重写了 equals 却没重写 hashCode,两个业务上完全相等的对象会发现 hashCode 不同,从而落进不同桶里,get 的时候永远找不到。我自己见过一个真实案例:有人拿订单号做强标识,只重写了 equals,放进 HashSet 后 contains 一直返回 false,找了大半天才发现是 hashCode 的锅。可以延伸一句:不同对象的 hashCode 相同叫碰撞,哈希表里每个槽位装的其实是一条链表或红黑树。把这个结构意识说出来,这道题就稳了。
1.2 String、StringBuffer、StringBuilder:不可变与可变的内在逻辑
String 为什么不可变?只背“被 final 修饰”是不够的。String 在 Java 里被广泛用作 HashMap 的 key、类加载的类名、网络地址标识,如果它可变,哈希表和常量池的语义就会全面崩掉。更关键的是,String 内部那个字符数组是 final 的,任何修改操作返回的都是新对象,原串不会变。
StringBuffer 和 StringBuilder 的区别大家都会说:前者方法加了 synchronized,线程安全;后者没有,单线程用。但这里有个容易露馅的细节:StringBuffer 的“线程安全”也只是单个方法级别的安全,比如多线程交替 append 没问题,但如果你先 append 再 insert,这两个操作之间没有整体加锁,复合操作依然不是原子的。很多人简历里写“熟悉线程安全”,一深问就在这里漏了。
实际做的过程中要记住一个铁律:循环拼接字符串一定要用 StringBuilder,否则每次循环都会产生新的 String 对象,GC 压力随之而来。这个问题在后面的性能优化章节还会再见面。
1.3 volatile 与 synchronized:并发题的第一道坎
volatile 和 synchronized 表面看都是并发工具,本质完全不同。volatile 解决可见性和有序性,它不加锁,允许多线程同时写;synchronized 解决原子性、可见性、有序性,靠监视器锁串行化临界区。
这道题最大的坑是:volatile 修饰的变量执行 i++ 并不是原子的。i++ 在字节码层面至少分读、加、写三步,volatile 只能让第三步的写入对其他线程可见,却挡不住多个线程同时读到旧值。如果面试官继续追问双重检查锁单例里为什么要加 volatile,答案不是防重入,而是防止指令重排序后,某个线程拿到了“构造方法还没执行完”的对象实例。能把这一层说得清清楚楚,比单纯背单例模板有用得多。
进阶一点可以提 Java 内存模型里的 happens-before 规则:锁释放、volatile 写、线程 start 和 join,都会建立先行发生关系。能随口把这些规则说出来,面试官基本会判断你是系统读过 JMM 的人。
1.4 Kotlin 协程真的比线程轻量吗
这题现在几乎每个 Android 岗位都会问。回答的第一句可以是“协程是轻量级线程”,但接下来一定要展开:协程并没有减少 CPU 计算量,它只是把并发模型从内核线程调度换成了用户态状态机挂起与恢复。挂起的本质不是阻塞线程,而是让出执行权,不占线程栈,所以可以同时挂起成千上万个协程。
重点要讲清楚挂起和恢复的原理。suspend 函数在编译后会被改造成一个带 Continuation 参数的状态机函数,每个挂起点就是状态机里的一个分支,resume 继续执行。所以协程底层是一段可挂起可恢复的代码块,而不是一个常驻线程。
面试官还会追问一个高频误区:GlobalScope 为什么不能随便用?因为它没有作用域约束,生命周期不受页面控制,页面销毁后协程还在跑,轻则浪费资源,重则回调已经销毁的 UI 实例。正确做法是使用 lifecycleScope 或 viewModelScope,让协程跟着宿主走。再补充一句 Dispatchers.IO 和 Dispatchers.Default 其实共用同一个线程池,仅靠任务标记区分,就意味着你真正读过协程源码。
1.5 重载与重写:静态分派和动态分派
重载是编译期决定的普通方法选择,编译器根据参数的静态类型选方法,这叫静态分派;重写是运行期根据对象的实际类型动态选择方法,这叫动态分派。典型的问法:一个 Parent 类型引用指向 Child 对象,调用某个被重写的方法,实际执行的是 Child 的实现;而如果方法只是重载,编译器只看变量声明类型。
加分项是提一下 invokevirtual 指令和虚方法表。JVM 在类加载时会给每个类建一张虚方法表,记录所有虚方法的入口地址,运行时的方法分派本质上就是查这张表。再深入一步,可以提接口默认方法出现之后,接口继承也带来了新的分派逻辑,语言本身也在不断演进。这题不难,但能体现你对字节码机制有没有真正理解。
2. 四大组件与启动流程:绕不开的基本盘
2.1 Activity 启动模式:不止在背四种 launchMode
standard、singleTop、singleTask、singleInstance 四个名词几乎是必背题,但面试官更想听的是你能不能结合真实场景选型。singleTop 适合接收推送通知详情页:通知来了页面已经在栈顶,就不重复创建,直接走 onNewIntent 更新数据。singleTask 适合主页场景:App 在后台很久了,点通知进二级页时能顺手把整个任务栈拉回前台。singleInstance 现在很少用了,因为从 Android 10 开始普通应用已经无法用它在后台启动 Activity,很多国产定制系统限制更早。
这里有个容易被忽略的考点:taskAffinity 和 allowTaskReparenting。设置 singleTask 时往往要配合 taskAffinity 指定任务栈,否则它会复用默认栈。自己做过 launcher 类应用的人应该很清楚,很多诡异现象最后都定位到 taskAffinity 没配。
2.2 屏幕旋转时的完整生命周期
不配置 configChanges 的情况下,旋转屏幕会走 onPause、onStop、onDestroy、onCreate、onStart、onResume 这一整串。很多人背了顺序,但答不出为什么:旋转意味着资源配置变化,系统认为需要重建一个适应新尺寸的 Activity,于是先销毁旧的,再创建新的。
onSaveInstanceState 的调用时机在 onPause 之后、onStop 之前。注意它只在非主动销毁时被调用,用户主动 finish 或按返回键不会走这里。恢复数据有两个选择:在 onCreate 里拿 Bundle,或者在 onRestoreInstanceState 里拿。一个真实踩坑点是千万不要把大对象塞进这个 Bundle,比如 Bitmap,超过一定大小会直接抛 TransactionTooLargeException。保存少量状态可以,大对象请交给 ViewModel。
能补充说明 Fragment 在旋转时也会被 FragmentManager 自动保存和恢复,本题的完成度会立刻高一截。
2.3 Service 的两种启动方式
startService 和 bindService 的区别是必背题。startService 启动后,Service 和调用方没有绑定关系,调用方走了 Service 还在跑,需要主动 stopService 或 stopSelf;bindService 则是客户端拿到 Binder 代理,双方生命周期绑定,最后一个客户端 unbind 时,Service 会走 onUnbind 并销毁。
真正容易丢分的是混合场景:先 startService 再 bindService,那 Service 必须同时 stopService 和 unbind 才能真正销毁。很多做音乐播放器的人在后台播放需求里把两个状态混在一起,导致 Service 杀不掉或者生命周期错乱。我在实际项目里推荐的处理方式是:onCreate 里 startForeground,页面启动时 bindService,页面销毁时 unbindService,最后退出整个播放器时 stopService。这样既能控制播放,又能保证系统不会轻易杀掉它。
另外不要忘记前台服务的限制。Android 8.0 之后后台服务无法长时间运行,Android 10 之后后台启动前台服务也被限制,后台播放音乐等场景必须声明 foregroundServiceType。如果面试官问 Service 却完全不提前台服务限制,那大概率是在等你的临场发挥。
2.4 广播:动态注册、静态注册与高版本限制
静态注册写在 Manifest 里,进程被杀也能被系统拉起;动态注册在代码里 registerReceiver,必须配合 unregisterReceiver,生命周期跟着注册方走。Android 8.0 限制了隐式广播的静态注册,大量自定义广播被迫改成动态注册,这是版本适配的经典考点。
动态注册最容易踩的坑是不反注册。registerReceiver 之后,系统端的注册表里就会留一个 Receiver 对象,如果 Activity 销毁了却没有 unregister,进程还活着,系统回调就会指向一个泄漏的 Receiver。真实项目里经常看到第三方广告 SDK 在 onResume 注册网络监听,onPause 漏了注销,导致内存泄漏警告刷屏。
高版本之后,LocalBroadcastManager 也不再被官方推荐,一般是用 LiveData、StateFlow 或轻量事件总线替代。能把这些限制和替代方案都理清楚,说明你适配过 Android 版本,不只是一个只会写 registerReceiver 的新手。
2.5 ContentProvider 为什么能抢先初始化
ContentProvider 很少被单独拎出来问,但它是理解启动流程的关键钥匙。App 启动时,Application 的 attachBaseContext 先执行,然后系统会按 Manifest 里声明的 provider 列表依次安装,最后才回调 Application 的 onCreate。也就是说,ContentProvider 的 onCreate 比 Application 的 onCreate 还要早。
这个时序被很多 SDK 利用来实现“无侵入初始化”,不需要用户手动调用 init 方法。WorkManager、数据上报组件、部分地图 SDK 就是通过 ContentProvider 自启动的。负责的方面也有:ContentProvider 太多会拖慢冷启动,所以启动优化的一个方向就是合并或移除无必要的 Provider。
考察这个点,面试官是在确认你真正读过系统启动流程,而不是只知道 Application 生命周期。
3. Handler 与 Binder:占据半壁江山的机制题
3.1 Handler 机制的完整链路
Handler 题几乎必问,而且一定会层层追问。完整链路是:Handler 发送 Message,Message 进入 MessageQueue,Looper.loop() 开启死循环不断从队列取消息并 dispatchMessage,最终回调 Handler 的 handleMessage。四个角色缺一不可。
容易丢分的点有三个。第一,MessageQueue 虽然叫 Queue,但结枀上是链表结构的消息池,Message 在 send 后会被回收进池子复用,不能长期持有 Message 里的数据。第二,Looper.loop() 为什么不会卡死主线程?因为主线程本身就是一个无限事件循环,所谓卡死是指事件来不及处理。如果移除 loop,MessageQueue 就没人消费了,所有 UI 操作都会堆积不执行。第三,IdleHandler 是加分点:当队列暂时没有紧急消息时,系统会执行空闲任务,比如延迟埋点、预加载下一屏。能主动聊到 IdleHandler 的候选人,基本就是认真啃过 Handler 源码的。
3.2 ThreadLocal 到底做了什么
ThreadLocal 经常和 Handler 一起考,因为 Looper 就是存在 ThreadLocal 里的。每个线程通过 ThreadLocal 拿到属于自己的 Looper,互不干扰。
原理要讲到 Thread 类内部有一个 ThreadLocalMap,key 是 ThreadLocal 对象本身,value 是你要存的值。同一个 ThreadLocal 在不同线程 get,拿到的是各自 Map 里对应的 value,因为每个线程的 Map 相互独立,所以实现了线程隔离。
但这里有个让很多人翻车的细节:ThreadLocalMap 的 key 被设计成弱引用,value 却是强引用。如果外部 ThreadLocal 引用被置空,而线程还活着,比如在线程池的核心线程里,value 就永远被这个 Map 强持有,造成内存泄漏。规范用法是用完就 remove。能把这点讲出来,说明你真的写过长驻场景的线程池,并且踩过内存坑。
3.3 Handler 内存泄漏的三种解法
Handler 持有 Activity 是经典泄漏场景。原理是:Handler 发送出去的 Message 带着 target 引用,如果 Message 还留在 MessageQueue 里没处理,而 Handler 是 Activity 的内部类,隐式持有外部类引用,Activity 就无法被回收。常见于延时 Runnable 和周期性消息。
解法至少有三种。第一种是静态内部类 Handler + WeakReference 持有 Activity,兜底写法;第二种是 onDestroy 时移除所有消息和回调;第三种是用生命周期感知的组件来管理任务,比如 Lifecycle 或 ViewModel。我个人推荐后两种做主线。原因是:弱引用只切断了引用链,并不能阻止延时回调本身就是你不想执行的那个动作,如果你根本不移除消息,回调还是会在错误的时间触发。
还有个更隐蔽的场景是 HandlerThread 没有主动 quit。子线程的 Looper 会一直阻塞在 loop 里,即使所有任务都跑完了。很多长连接项目把 HandlerThread 当常驻线程,必须记得在合适的生命周期里控制它,否则线程会一直占着资源。
3.4 Binder 为什么是 Android IPC 的首选
Binder 是 Android 面试里最深的一道题。回答框架是:先比较 Linux 原生 IPC 方式。管道、消息队列、信号量、Socket、共享内存都不太适合 Android,共享内存性能虽好但管理和同步太复杂,Socket 性能差,管道和消息队列性能也不行,更重要的是安全模型不满足“只暴露我想暴露的能力”。
Binder 采用 mmap 做一次内存拷贝。调用方数据拷到内核缓冲区,再通过 mmap 映射直接读到接收方用户空间,省掉了从内核缓冲区再拷到用户态的一次复制,所以叫“一次拷贝”。对比传统 IPC 往往是两次拷贝,这是 Binder 性能上的关键优势。
安全方面,Binder 在内核里为每个进程建立了 UID/PID 标识,协议自带身份校验,不像共享内存那样需要用户层自己维护权限。再补一句实操经验:每个 App 进程的 Binder 线程池默认数量有限,大量跨进程通信时可能把 BinderThread 占满,导致调用卡死。这一句往往能体现你处理过真正的跨进程性能问题。
3.5 子线程到底能不能更新 UI
这个问题看起来简单,但答案不能只说“不能”。早期 Android 确实在 ViewRootImpl 里通过 checkThread 检查调用线程,非 UI 线程更新 UI 会抛异常。但更准确的理解是:UI 操作必须在 View 指定的线程,通常是 UI 线程;而子线程可以通过 SurfaceView 或 TextureView 的 Surface 在自己的渲染线程绘制,也可以 post 到 UI 线程再执行。
面试官真正想听的是为什么采用单线程模型。因为 View 和绘制管线本身不是线程安全的,与其给整个绘制系统加一把大锁,不如规定所有操作都在一个线程里串行执行,这是 Handler 存在的重要理由。
补充一个细节:子线程调用 view.post() 为什么是安全的?post 内部会把 Runnable 通过 ViewRootImpl 的 Handler 投递到 UI 线程队列;如果 View 还没 attach 到 Window,它会等 attach 后再执行。所以 view.post 是安全更新 UI 的常用方式,比 runOnUiThread 更好用,因为它保证了 View 已经 attach。
4. View 体系与触摸事件:自定义 View 面试题的高频来源
4.1 measure / layout / draw 的完整链路
自定义 View 考察的核心就是这三个流程。measure 阶段根据父 View 的 MeasureSpec 和自身 LayoutParams 确定测量尺寸,有三种模式:EXACTLY、AT_MOST、UNSPECIFIED。最容易踩的坑是重写 onMeasure 时直接 setMeasuredDimension 写死高度,完全忽略父容器的约束。比如 RecyclerView 的 item 高度设置 wrap_content,你却在 MeasureSpec 是 AT_MOST 时给了一个超大尺寸,item 就会铺满屏幕。
layout 阶段由父 View 调用 child.layout 确定子 View 的位置,ViewGroup 则要在 onLayout 里遍历所有子 View 安排布局。draw 阶段按顺序执行:drawBackground、onDraw、dispatchDraw、onDrawForeground。能理解这个顺序,才能解释为什么自定义 View 的背景和前景总是出现微妙的问题。
回答时落到 ViewGroup 层面更容易加分。测量是双层循环:父 measure 子,子 measure 完可能又会触发父重新 measure;layout 是深度优先遍历;draw 是整棵树的递归绘制。能提到 getChildMeasureSpec 和 ViewGroup 的 measure 逻辑,面试官会认为你真的调过自定义 ViewGroup。
4.2 触摸事件的三个方法
事件分发考的是三个方法:dispatchTouchEvent、onInterceptTouchEvent(只有 ViewGroup 有)、onTouchEvent。整体流程是先自上而下分发,再自下而上回溯处理。
典型场景题是:RecyclerView 里嵌套一个可以横向滑动的自定义 View,如何处理事件冲突。标准做法是子 View 滑动时调用 parent.requestDisallowInterceptTouchEvent(true),请求父 View 不要拦截。但要注意,如果父 View 在 ACTION_UP 强制拦截回家,或者子 View 消费不了事件,父 View 还是会接管。
另一个加分点是 OnTouchListener 和 onTouchEvent 的执行顺序:OnTouchListener 返回 true 就会拦截事件,不再进入 onTouchEvent;onClick 在 onTouchEvent 的 UP 事件且 pressed 状态命中时触发。把消费链条完整说出来,就不像只会背三个方法名了。
往深答时还要提 ACTION_CANCEL。当一个事件流因为父 View 重新拦截而中断,系统会补发一个 CANCEL 让子 View 回滚状态。很多新手拦截代码不处理 CANCEL,导致按钮按压状态残留,这个小细节经常是实战里最难查的问题。
4.3 requestLayout 与 invalidate 的区别
这是自定义 View 高频分类题。invalidate 只触发 draw 阶段和 onDraw,不做测量和布局,标记当前 View 的 dirty 区域重刷。requestLayout 会触发 measure、layout、draw 的完整链路,不仅影响当前 View,还会影响整个 ViewGroup 子树。
有一个点非常重要:requestLayout 不代表马上执行。它会挂一个运行标志,由 Choreographer 在下一帧的遍历里执行,而且可能连续调多次只做一次完整流程。所以动画里如果只是更新颜色或文字位置,用 invalidate 局部重绘就够了;只有真正改变了宽高或子 View 的位置,才需要 requestLayout。频繁调用 requestLayout 会导致整棵子树反复测量和布局,这是卡顿优化里容易被低估的原因。
能提到 requestLayout 最终会调用 ViewRootImpl.requestLayout 并经过 Choreographer 合并,说明你对渲染线程的协作已经有概念了。
4.4 自定义 View 最容易被忽略的坑
面试时我常用的提问模板是:如果自定义 View 的宽高是 wrap_content,你怎么处理?很多人没有重写 onMeasure,直接在构造函数里给默认尺寸,但 wrap_content 场景下 Enter 的是 AT_MOST 的 MeasureSpec,你给固定值其实是拿最大可用空间当尺寸,表现出来就是控件铺满父布局。
第二个高频坑是 onDraw 里创建对象和分配内存。绘制阶段调用频率非常高,一帧 60 次可能每次都会走 onDraw。如果在这里 new 一个 Paint 或 Path,短时间内会分配大量短命对象,直接推高 GC 频率。正确做法是成员变量在初始化时建好,draw 时只复用它。
第三个是线程安全。很多人喜欢在子线程先改 View 的数据,再调 invalidate。invalidate 本身线程安全,它可以被任意线程调用,但真正的风险是你的数据源没有同步。如果 UI 线程一边读数据绘制,子线程一边改数据,即使每次 invalidate 都不会报错,画面也会偶发错乱。标准解法是数据改动也切回 UI 线程,或者子线程改完的是一份快照,拿到快照再更新 View。
4.5 Choreographer、帧率与卡顿的关系
自定义 View 讲深了必然会碰到 Choreographer。它负责协调输入、动画、绘制三个子系统,每 16.6 毫秒对着垂直同步信号发起一帧的绘制。一帧里要做的事情包括输入处理、动画更新、measure/layout/draw、同步屏障清理。如果这些工作超过了 16ms,帧率就掉到 30,用户感知就是卡顿。
常考的进阶点是同步屏障。MessageQueue 里可以插入一个同步屏障消息,之后所有同步消息都被拦住,只有异步消息能继续执行。Choreographer 的帧回调就是异步消息,通过同步屏障获得优先执行资格。这道题的关键是把 Handler 机制和渲染机制打通:主线程的“消息处理”和“一帧帧渲染”其实是同一套消息泵的两个不同角色。
到这里,基本就是中级向高级的分界线。能从容讲出 Choreographer 与 Looper、同步屏障、VSYNC 之间的关系,面试官会倾向于认为你有解决复杂渲染问题的能力。
5. 网络、数据与线程:业务开发的地基题
5.1 HTTPS 握手流程与证书校验
HTTPS 题基本都会考。要点不是“HTTP 加了一层加密”,而是整个握手流程。先建 TCP 连接,客户端发 ClientHello 指定 TLS 版本和加密套件列表,服务端返回 ServerHello、证书、密钥交换参数。客户端验证证书链,验证通过后生成对称密钥,用服务端公钥加密发过去,双方确认后切换对称加密传数据。之所以是混合加密,是因为非对称加密性能差但适合安全交换密钥,对称加密快但需要预先约定共同密钥。
容易被追问的是证书校验。客户端要验证证书是否由受信任 CA 签发、是否过期、域名是否匹配,Android 里如果要连自签名证书服务,必须自己配置 TrustManager。很多人图省事直接信任所有证书,这在线上是致命问题,等于中间人攻击畅通无阻。回答时如果能提一句 SNI,说明你在同一 IP 多证书的场景里踩过坑。
这道题里,自己抓过包、配过证书的人通常能拿到高分,因为细节一追问就能感觉到是真做过还是背过。
5.2 TCP 三次握手为什么不能是两次
三次握手的标准答案是同步初始序列号、确认双方收发能力。但更容易让面试官眼前一亮的是“防止历史重复连接”这个角度。假设客户端发了一个 SYN 因为网络延迟卡了很久,超时重传后又建立了连接,而老的 SYN 后来才到达服务端。服务端认为这是新连接,分配资源并回 SYN+ACK,可客户端根本不理会。两次握手的情况下,服务端会长期等待一个不存在的客户端,浪费资源。三次握手时,客户端可以通过确认的序列号判断这是不是历史的旧连接,如果是就回 RST 终止。
能答到这个层面,比单纯背“确认收发能力”更完整。顺带可以提四次挥手为什么需要四次:因为半关闭机制,主动关闭方发 FIN 后,另一方可能还有数据要发,所以 ACK 和 FIN 是分开的。虽然不是必考,但能接住追问总是好事。
5.3 线程池的七个参数和执行流程
这题考的是 ThreadPoolExecutor 的七个参数:核心线程数、最大线程数、空闲存活时间、时间单位、任务队列、线程工厂、拒绝策略。执行流程是:核心线程满了就入队,队列满了就扩到最大线程数,最大也满了就走拒绝策略。如果用无界队列,线程数永远不会超过核心线程数,这一点很多人理解反了。
拒绝策略在真实项目里更有价值。自带四种:AbortPolicy 抛异常、CallerRunsPolicy 调用者执行、DiscardPolicy 丢弃、DiscardOldestPolicy 丢弃最老任务。实际开发中很少用 AbortPolicy,因为会直接中断业务;CallerRunsPolicy 能起背压作用,但可能拖慢调用方本身;定制策略可以做日志和补偿入队。
Android 开发里这题还会关联到为什么不推荐 Executors.newFixedThreadPool,因为它的任务队列是无界的,任务堆积严重时内存会膨胀。这个补充非常加分,能证明你不是只看过教程,而是处理过线上 OOM。
5.4 SharedPreferences 的缺陷与数据存储替代
SP 是入门级知识,但现在越来越常被拿来追问。第一条是 apply 异步落盘,之前修改会在内存里直接生效,可写盘失败或进程被杀,数据就丢了。commit 虽然同步,但卡 UI。第二条是 SP 全量加载:第一次 getSharedPreferences 会把整个 XML 文件的所有键值读进内存,如果配置文件很大,冷启动阶段就会多出一块不小的 IO 耗时。
替代方案里 MMKV 用的是 mmap 映射,写操作直接改内存映射页,崩溃也不容易丢一半数据,性能明显优于 SP。Jetpack DataStore 也是推荐方向,Preferences DataStore 类似键值对但基于 Flow 支持协程,Proto DataStore 适合结构化数据。回答时如果结合自己项目里的存储选型讲,比单纯背对比表有说服力得多。
5.5 SQLite 事务、索引与 Room 场景
SQLite 基础题不会太难,但概念要清。事务的核心是原子性:要么全成功,要么全失败。SQLite 里每条 SQL 默认在一个事务里,批量插入时一定要用 beginTransaction 包裹,否则每条 insert 都要独立走一遍日志写盘,性能差距可能是几十倍。我见过真实项目里 for 循环逐条往大表里插数据,几千条等了非常久,改成事务后秒级完成。
索引为什么快?因为 B+ 树把查找从全表扫描的 O(n) 降到了 O(log n)。但索引也有代价,写入时要维护多棵 B+ 树,所以索引要建在查询频繁且写入不那么猛烈的字段上。有一个经典追问是:加了索引为什么反而更慢?大概率是优化器选了一个低选择性的索引,或者谓词条件不够匹配索引结构,需要重新 analyze。
Room 对 SQLite 的关系就是 ORM 加编译期 SQL 校验,加 LiveData/Flow 联动观察数据库变化。常规问题里能说清楚 @Transaction 的作用和与协程的整合,就够了。
6. 性能优化与架构:决定 offer 级别的分水岭
6.1 ANR 的本质与定位链路
ANR 题到高级岗位基本是必考。背五大数据类型只是基础:BroadcastReceiver 超时、Service 超时、InputDispatching 超时、ContentProvider 超时、JobService 超时。真正值钱的还是定位链路。
定位思路一般按三步走:第一步看 logcat 里的 ANR in 输出,里面包含进程名、耗时时间、主线程状态;第二步看 /data/anr/traces.txt 里主线程堆栈,直接从调用栈看出卡在哪个方法、在等哪把锁;第三步结合 CPU 占用判断,如果 CPU 跑满那可能有死循环或频繁 GC,如果 CPU 不高但 ANR,那大概率是主线程在等 IO 或锁。
我印象很深的一次线上 ANR,traces 里主线程卡在日志 SDK 的 synchronized 方法上。第三方库写日志时持锁,网络请求回调也在抢同一把锁,最后把日志库的同步写改成异步消息队列,问题才消失。能讲出这种案例,面试官通常会很愿意继续深聊。
6.2 内存泄漏常见场景与 LeakCanary 原理
第一问通常是泄漏和溢出的区别。泄漏是对象本可回收却被强引着,溢出是内存确实不够分,但因果关系紧密,泄漏多了自然会溢出。
高频泄漏场景至少有五类:Handler 和 Runnable 未移除、静态变量持有 Activity 或 View、单例持有 Activity Context、流未关闭、注册回调后未反注册。我在真实项目里排查到最多的其实是单例持有 Context:一个全局播放器单例保存了页面的 Activity Context,页面关闭后它一直持着,导致整个 Activity 无法回收。
LeakCanary 原理也要会讲。它注册 ActivityLifecycleCallbacks 监听页面销毁,销毁的实例放进弱引用队列,一定时间后没有被回收,就主动 GC 一次再查;还是没回收,就做堆转储并分析引用链。面试里能说出“二次 GC 确认”这个细节,已经是明显的加分项。
6.3 冷启动流程与启动耗时优化
冷启动完整流程是:系统 fork 进程、加载 Application 类、执行 attachBaseContext 和 onCreate、初始化 ContentProvider、启动 Activity、渲染第一帧。启动优化的本质就是看从点击图标到第一帧可交互花了多少时间。
优化手段我按收益排序。第一是减少 Application.onCreate 里的同步初始化,能延迟的都延迟到首帧后或 IdleHandler;第二是合并或移除自动初始化的 ContentProvider,它串行遍历会拖慢进程;第三是复杂项目可以用协程化启动器做任务依赖调度,但小项目要谨慎,引入复杂度可能大于收益;第四是 windowBackground 设置预览,但这是感知优化,不能当真正的性能提升。
容易被忽略的是类加载耗时。方法数太多,ClassLoader 加载类的时间也会变长,所以精简依赖、按需加载同样能改善冷启动。性能优化题很难靠背模板拿高分,结合案例给前后数据对比最有说服力。
6.4 MVC、MVP 与 MVVM:架构题的答题框架
架构对比几乎是高级岗位标配。MVC 里 Activity 同时承担 Controller 和 View 的职责,自然越来越臃肿;MVP 把 View 抽象成接口,Presenter 负责逻辑,但 View 接口会很碎片,Presenter 也会堆大量页面逻辑;MVVM 用 ViewModel 暴露状态给 View,数据驱动 UI 更新,靠 LiveData/Flow 或 DataBinding 解耦。
真正的加分点是讲清 ViewModel 和 LiveData 的细节。ViewModel 为什么旋转时不销毁?因为 ViewModelStore 是 ComponentActivity 的一个成员,旋转时 ViewModelStoreOwner 虽然换了新实例,但同一个 ViewModelStore 被复用了,ViewModel 只有真正 finish 时才会 onCleared。LiveData 为什么旋转不泄漏?因为 Observer 被 LifecycleOwner 自动管理,旧 Observer 会被移除。
同时也要说 MVVM 的坑。如果全部用 LiveData 和双向绑定流,事件追踪会变困难;ViewModel 里如果放太多状态,页面重建时要做很多还原计算。架构选型要结合团队和规模,不要为了架构而架构。
6.5 LiveData、Flow 与 Compose:现代 UI 方案还会怎么问
现在的面试题明显往 Compose 和 Kotlin Flow 靠。Compose 的数据驱动 UI 模型和传统 View 不同,状态变化会触发重组,重组范围是读取了该状态的 UI 部分,而不是整棵树。这个知识点可以直接解释很多性能问题:为什么把状态读取放到 lambda 外部会导致整个 Content 重组;用 derivedStateOf 可以减少不必要的重算。
Flow 与 LiveData 的选择也是热点。LiveData 优势是生命周期感知、用法简单、粘性事件直接;Flow 优势是更丰富的操作符、背压处理、不依赖 Android 框架,StateFlow 可以做类似 LiveData 的状态管理。StateFlow 有 distinctUntilChanged 特性,会过滤掉连续重复值,这是经常用来区分“只是背概念”和“真用过”的细节。
如果聊到 Compose 性能,最好提到 remember、mutableStateOf 和 stable 的概念。和传统自定义 View 一样,Compose 也有布局和重组代价的问题,答案里带上一个你自己写过的性能观察,会非常加分。
我自己的体会是:面试题永远不是背出来的,是用出来的。这 30 个高频问题,放在一起看是一份查漏补缺清单,拆开来看,每个知识点背后都连着至少一个真实场景的坑。准备的时候,建议对着自己最近做过的项目,把每个知识点对应到某一次调试、某一次性能优化、某一次崩溃排查里。全部对上之后,再去面试,状态会完全不一样。祝各位面到想要的岗位,也真的能从面试中反推出自己的短板。