Android 高频面试题与解答
-
- 第一部分:题目清单(自测用)
- 第二部分:逐题解答
-
- [Q1. Activity 完整生命周期?](#Q1. Activity 完整生命周期?)
- [Q2. A 跳 B 生命周期调用顺序?](#Q2. A 跳 B 生命周期调用顺序?)
- [Q3. 四种启动模式区别?](#Q3. 四种启动模式区别?)
- [Q4. onSaveInstanceState 何时调用?](#Q4. onSaveInstanceState 何时调用?)
- [Q5. Service 与 IntentService 区别?](#Q5. Service 与 IntentService 区别?)
- [Q6. 前台服务为什么必须通知?](#Q6. 前台服务为什么必须通知?)
- [Q7. 广播静态注册限制(Android 8+)?](#Q7. 广播静态注册限制(Android 8+)?)
- [Q8. Handler 原理?子线程能用吗?](#Q8. Handler 原理?子线程能用吗?)
- [Q9. 为什么 Handler 会内存泄漏?](#Q9. 为什么 Handler 会内存泄漏?)
- [Q10. Looper.loop 为什么不会阻塞主线程?](#Q10. Looper.loop 为什么不会阻塞主线程?)
- [Q11. 简述 MessageQueue 的阻塞(epoll)?](#Q11. 简述 MessageQueue 的阻塞(epoll)?)
- [Q12. SharedPreferences 的 apply 与 commit 区别?](#Q12. SharedPreferences 的 apply 与 commit 区别?)
- [Q13. 内部存储与外部存储区别?](#Q13. 内部存储与外部存储区别?)
- [Q14. 分区存储是什么?](#Q14. 分区存储是什么?)
- [Q15. OkHttp 拦截器有几种?区别?](#Q15. OkHttp 拦截器有几种?区别?)
- [Q16. Retrofit 原理?](#Q16. Retrofit 原理?)
- [Q17. 为什么 AsyncTask 被废弃?](#Q17. 为什么 AsyncTask 被废弃?)
- [Q18. 协程相比线程优势?](#Q18. 协程相比线程优势?)
- [Q19. 常见内存泄漏场景?](#Q19. 常见内存泄漏场景?)
- [Q20. LeakCanary 原理?](#Q20. LeakCanary 原理?)
- [Q21. Bitmap 如何防止 OOM?](#Q21. Bitmap 如何防止 OOM?)
- [Q22. 启动优化手段?](#Q22. 启动优化手段?)
- [Q23. 布局过度绘制如何排查?](#Q23. 布局过度绘制如何排查?)
- [Q24. ViewModel 为什么不会被重建销毁?](#Q24. ViewModel 为什么不会被重建销毁?)
- [Q25. Kotlin 空安全怎么实现?](#Q25. Kotlin 空安全怎么实现?)
定位:Android 开发岗面试自测与背诵。
用法:第一部分是纯题目清单,先遮住答案自己答一遍;第二部分逐题对照解答。
第一部分:题目清单(自测用)
- Activity 完整生命周期?
- A 跳 B 生命周期调用顺序?
- 四种启动模式区别?
- onSaveInstanceState 何时调用?
- Service 与 IntentService 区别?
- 前台服务为什么必须通知?
- 广播静态注册限制(Android 8+)?
- Handler 原理?子线程能用吗?
- 为什么 Handler 会内存泄漏?
- Looper.loop 为什么不会阻塞主线程?
- 简述 MessageQueue 的阻塞(epoll)?
- SharedPreferences 的 apply 与 commit 区别?
- 内部存储与外部存储区别?
- 分区存储是什么?
- OkHttp 拦截器有几种?区别?
- Retrofit 原理?
- 为什么 AsyncTask 被废弃?
- 协程相比线程优势?
- 常见内存泄漏场景?
- LeakCanary 原理?
- Bitmap 如何防止 OOM?
- 启动优化手段?
- 布局过度绘制如何排查?
- ViewModel 为什么不会被重建销毁?
- Kotlin 空安全怎么实现?
第二部分:逐题解答
Q1. Activity 完整生命周期?
核心结论 :onCreate → onStart → onResume → onPause → onStop → onDestroy,onRestart 在重新回到前台时触发。
onCreate → onStart → onResume (前台可交互)
↑ ↓
onRestart ← onStop ← onPause (被遮挡)
↓
onDestroy
- onCreate :创建,做初始化、
setContentView(只调一次)。 - onStart:已可见,但还不能交互。
- onResume:位于前台、可交互,此时才真正获取焦点。
- onPause :失去焦点(被新页面部分遮挡),做轻量暂停与数据保存,不能耗时,否则拖慢下一个页面启动。
- onStop:完全不可见,释放重量资源、注销监听。
- onDestroy:销毁,回收资源。
- onRestart :
onStop之后重新回到前台时先触发,再走onStart。
Q2. A 跳 B 生命周期调用顺序?
核心结论 :A.onPause → B.onCreate→onStart→onResume → A.onStop,保证新页面先起来、旧页面后停。
启动 A : A.onCreate → A.onStart → A.onResume
A 跳 B(标准): A.onPause → B.onCreate → B.onStart → B.onResume → A.onStop
按返回 : B.onPause → A.onRestart → A.onStart → A.onResume → B.onStop → B.onDestroy
- 为什么要这个顺序 :
A.onPause先执行,系统确认 A 能暂停后才会启动 B,所以onPause里做耗时操作会直接拖慢 B 的启动。 - 特例 :若 B 是透明主题或 Dialog 主题,A 只走到
onPause,不会 执行onStop。 - 横竖屏切换 (未配置
configChanges):onPause→onStop→onDestroy→onCreate→onStart→onResume,数据用ViewModel/onSaveInstanceState保存。
Q3. 四种启动模式区别?
核心结论 :standard 每次新建、singleTop 栈顶复用、singleTask 栈内唯一、singleInstance 独占任务栈。
| 模式 | 行为 |
|---|---|
| standard | 每次新建实例,可存在多个,谁启动就进谁的栈 |
| singleTop | 已在栈顶 则复用,走 onNewIntent,不新建 |
| singleTask | 栈内唯一,把其上的 Activity 全部出栈,走 onNewIntent |
| singleInstance | 独占一个任务栈,该栈内只有它,供多应用共享 |
- 复用时 都回调
onNewIntent(intent),需要在其中手动setIntent(intent)更新数据。 singleTask常配合taskAffinity指定任务栈;用于主页、登录页等"只应存在一个"的页面。- 另有一组 Intent flag 可覆盖清单声明:
FLAG_ACTIVITY_NEW_TASK、FLAG_ACTIVITY_SINGLE_TOP、FLAG_ACTIVITY_CLEAR_TOP。
Q4. onSaveInstanceState 何时调用?
核心结论 :在 Activity 非用户主动销毁、可能被重建之前调用,用于保存瞬态 UI 状态,不是生命周期的必经回调。
- 会触发:按 HOME、跳到其他应用、横竖屏切换、系统内存不足回收。
- 不会触发:用户按返回键主动退出(视为正常销毁,状态本就该丢弃)。
- 时机 :Android 9(API 28)之前相对
onPause的顺序不固定,Android 9+ 保证在onStop之后,不要依赖它的精确位置。 - 恢复 :
onCreate(savedInstanceState)或onRestoreInstanceState()(在onStart之后,有 Bundle 才调)。
kotlin
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("keyword", keyword)
}
- 限制 :数据经 Binder 事务传输,总量建议 < 50KB(超过有
TransactionTooLargeException风险);大数据用ViewModel或持久化存储。
Q5. Service 与 IntentService 区别?
核心结论 :Service 是默认跑在主线程的通用组件,IntentService 是自带单线程的串行任务 Service,且已废弃。
- Service 生命周期 :
onCreate → onStartCommand → onDestroy;bindService时走onBind,随调用者解绑而销毁。 - Service 默认运行在主线程,耗时操作必须自己开线程。
- IntentService :内部封装
HandlerThread,在子线程按串行顺序 执行onHandleIntent,任务全部完成后自动stopSelf。 - 废弃原因 :Android 8+ 对后台服务严格限制,
IntentService的后台模型已不适用。 - 替代方案 :Kotlin 协程 +
WorkManager;需要自带 Looper 的子线程直接用HandlerThread。
Q6. 前台服务为什么必须通知?
核心结论 :前台服务是"用户可感知"的服务,Android 8+ 强制要求 startForeground() 时携带一条常驻通知,否则系统抛异常终止应用。
- 目的:让用户明确知道有任务在跑(如音乐播放、导航、下载),并能从通知栏查看/停止,避免应用在后台偷偷常驻。
- 代价与收益:正因为"用户可见",前台服务进程优先级更高,不易被系统回收;后台服务在 Android 8+ 会被限制甚至被杀。
- 用法 :
startForeground(id, notification),停止时stopForeground(true)。 - 权限 :Android 9+ 需要声明
FOREGROUND_SERVICE;Android 13+ 通知还要POST_NOTIFICATIONS运行时权限。 - 可延迟、可约束的后台任务优先用
WorkManager,别滥用前台服务。
Q7. 广播静态注册限制(Android 8+)?
核心结论 :Android 8(API 26)起,Manifest 里静态注册的隐式广播绝大部分收不到,只有系统白名单例外。
- 原因:大量应用静态监听开机、网络变化等广播,一发广播就全场唤醒,严重耗电卡顿,故从源头限制。
- 仍可静态注册的例外 (白名单):
BOOT_COMPLETED、LOCKED_BOOT_COMPLETED、TIME_SET/时区变化、LOCALE_CHANGED、短信彩信类广播等。 - 不受限 :显式广播(明确指定
setPackage/ 目标类名)依然可以静态注册并送达。 - 实践 :应用内通信一律用动态注册
registerReceiver,并在对应生命周期unregister防泄漏;更推荐LiveData/ 事件总线 / 协程流。
Q8. Handler 原理?子线程能用吗?
核心结论 :Handler 负责"发送 + 处理",由 Looper 循环从 MessageQueue 取消息回调;子线程能用,但必须自己 prepare + loop。
- 四件套 :
Handler(发送/处理)、Looper(循环)、MessageQueue(队列)、Message(消息)。 - 流程 :
sendMessage→enqueueMessage入队 →Looper.loop()取消息 →msg.target.dispatchMessage→handleMessage。 - 主线程 :
ActivityThread.main()里已调用Looper.prepareMainLooper(),直接 new Handler 即可。
kotlin
// 子线程使用
Looper.prepare()
val handler = Handler(Looper.myLooper()!!) { msg -> /* 处理 */ true }
Looper.loop() // 死循环,之后的代码不会执行
handler.looper.quitSafely() // 用完必须退出,否则线程永不结束
- 省事做法 :
HandlerThread就是自带 Looper 的子线程;postDelayed的延时靠 MessageQueue 的 when 字段排序实现。
Q9. 为什么 Handler 会内存泄漏?
核心结论:非静态内部类/匿名 Handler 隐式持有 Activity,而 Message 又持有 Handler,队列中未处理完的消息会让 Activity 一直无法回收。
- 引用链 :
MessageQueue → Message → msg.target(Handler) → Activity。 - 典型场景 :
postDelayed(1000 * 60)后立刻退出页面,消息还在队列里,泄漏会持续到消息执行完。 - 解决方案 :静态内部类 +
WeakReference<Activity>;或在onDestroy中handler.removeCallbacksAndMessages(null)清空队列。 - 检测:接入 LeakCanary,或 Android Profiler 抓 HPROF 查看 GC Root 引用链。
Q10. Looper.loop 为什么不会阻塞主线程?
核心结论 :loop() 确实是死循环,但没消息时会阻塞在 MessageQueue.next() 的 epoll 等待上让出 CPU,有消息时被唤醒分发,不会空耗资源。
- 它是应用的动力源:UI 绘制、触摸事件、四大组件生命周期回调,全都是一条条 Message 靠这个循环驱动执行。
- 真正导致"卡"的是某条消息执行太久(超过 16ms 掉帧、超过 5s ANR),而不是循环本身。
- 一旦 loop 退出 (如调用
Looper.quit()),主线程会走到异常分支终止,所以ActivityThread.main()里loop()之后的代码只是兜底抛异常。 - IdleHandler:队列空闲时触发,常用于启动优化里把非必要任务延迟到首帧之后执行。
Q11. 简述 MessageQueue 的阻塞(epoll)?
核心结论 :Java 层用 synchronized 保证入/出队互斥,等待则由 native 层的 epoll 监听 eventfd 完成------没消息就睡眠,有人写入事件就唤醒。
next()主循环:先nativePollOnce(ptr, timeoutMillis)阻塞,再取队头消息。- timeoutMillis 取值 :
-1队列为空、无限等待;0立即返回;>0等到最近一条延时消息的触发时刻。 - 唤醒 :
enqueueMessage时发现新消息插到队头或需要立即处理,调用nativeWake往 eventfd 写 1,epoll 监听到可读事件 →next()返回继续分发。 - 这也是
postDelayed能按时触发的原因;但受系统调度影响,不保证绝对精确。
Q12. SharedPreferences 的 apply 与 commit 区别?
核心结论 :apply 异步落盘、无返回值;commit 同步落盘、返回是否成功。
| 对比项 | apply | commit |
|---|---|---|
| 落盘方式 | 异步(先改内存,再排队写盘) | 同步(当场写完) |
| 返回值 | 无 | boolean |
| 是否阻塞主线程 | 否 | 是 |
| 数据风险 | 进程立刻被杀可能丢失 | 可靠 |
- 建议 :主线程一律用
apply;只有需要确认写盘结果(写后立即退出、跨进程交接)才用commit。 - 其他坑 :不支持跨进程可靠读写(
MODE_MULTI_PROCESS已废弃),跨进程用 ContentProvider / MMKV。 - 全量 XML 会被整体加载进内存,不要存大数据或高频字段;新项目迁
DataStore。
Q13. 内部存储与外部存储区别?
核心结论:内部存储私有、随卸载清除、无需权限;外部存储共享、需权限,Android 10+ 受分区存储约束。
| 对比项 | 内部存储 | 外部存储 |
|---|---|---|
| 路径 | /data/data/包名/ |
/sdcard/ |
| 访问 | openFileOutput / getFilesDir / getCacheDir |
getExternalFilesDir / MediaStore |
| 权限 | 无需 | 公共目录需存储权限 |
| 卸载 | 一并清除 | 专属目录清除,公共目录保留 |
| 空间 | 小 | 大 |
- 应用专属外部目录 (
getExternalFilesDir)自 Android 4.4+ 免权限,但仍随卸载删除。 - 选择策略:私密小数据放内部;大文件/缓存放应用专属外部目录;需要共享或持久保留的媒体用 MediaStore / SAF 系统选择器。
Q14. 分区存储是什么?
核心结论:Android 10(API 29)起默认限制应用访问外部公共存储,只能自由读写自己的应用专属目录,公共文件须经 MediaStore / SAF 访问。
- 目的:杜绝应用在外置存储乱建目录、乱扫他人文件,解决存储混乱与隐私泄漏。
- 访问方式 :
getExternalFilesDir()专属目录免权限;图片/视频/音频等公共媒体走MediaStore;任意文档走 SAF(系统文件选择器,无需权限)。 - 过渡 :Android 10 可用
android:requestLegacyExternalStorage="true"临时关闭,Android 11+ 强制生效。 - 需要整盘访问(文件管理器类)要申请
MANAGE_EXTERNAL_STORAGE特殊权限,应用市场上架需说明用途。
Q15. OkHttp 拦截器有几种?区别?
核心结论 :分应用拦截器 和网络拦截器两大类,按责任链依次执行。
自定义应用拦截器 → RetryAndFollowUp → Bridge → Cache → Connect
→ 自定义网络拦截器 → CallServer(真正发起请求)
- 应用拦截器 (
addInterceptor):整个请求只调一次(重试/重定向不重复),可短路直接返回自己构造的 Response,适合加公共 Header、鉴权、统一日志。 - 网络拦截器 (
addNetworkInterceptor):每次真实网络交互都调,能看到重定向与重试全过程,可读取Connection、Cache-Control等头,适合抓包调试。 - 内置拦截器职责 :
Bridge补 Header + 自动 Gzip;Cache按服务端缓存头决定是否复用;Connect负责连接池复用与建连。
Q16. Retrofit 原理?
核心结论 :用动态代理把接口方法解析成 HTTP 请求描述,交给 OkHttp 执行,再用 Converter 解析响应。
- 动态代理 :
create()时用Proxy.newProxyInstance生成代理对象,所有方法调用都落到InvocationHandler.invoke()。 - 解析与缓存 :把
Method+ 注解(@GET/@POST/@Query/@Body/@Path)解析成ServiceMethod并缓存,避免重复反射。 - 两个扩展点 :
CallAdapter适配返回类型(Call/Observable/suspend),ConverterFactory(Gson/Moshi)负责序列化与反序列化。 - 执行 :最终构造 OkHttp 的
Request,由Call发出;Kotlinsuspend函数由 Retrofit 内部走挂起实现,无需返回 Call。 - 一句话:注解描述请求 → 动态代理生成实现 → OkHttp 干活。
Q17. 为什么 AsyncTask 被废弃?
核心结论:API 30 起官方标记废弃,因为它把太多坑藏在了便利 API 背后,推荐改用协程 / Executor。
- 内存泄漏 :非静态内部类持有 Activity,
doInBackground未结束时 Activity 无法回收。 - 屏幕旋转后回调失效 :
onPostExecute可能作用于已销毁的 Activity,或结果直接丢失。 - 行为不一致:串行/并行策略随系统版本反复变化(1.6 串行 → 2.3 并行 → 3.0 又串行),难以预期。
- 生命周期无感知 :
cancel()后doInBackground仍会跑完,无法真正中断。 - 替代 :Kotlin 协程(结构化并发 +
viewModelScope),后台可靠任务用WorkManager。
Q18. 协程相比线程优势?
核心结论 :协程是运行在线程之上的轻量任务,挂起不阻塞线程,且结构化并发让取消与生命周期管理天然安全。
- 轻量:一个线程可承载上万协程,切换只保存/恢复 Continuation,无内核态切换;线程默认栈在 MB 级,创建成本高。
- 同步写法 :
suspend让异步代码写成顺序代码,告别回调地狱。 - 结构化并发 :
CoroutineScope统一管辖,父协程取消则子协程自动取消,规避泄漏。 - 调度器 :
Dispatchers.Main / IO / Default一行切换线程,配合viewModelScope/lifecycleScope随页面自动销毁。 - 与 RxJava 对比:协程更轻、可读性更好;RxJava 操作符更全、老项目存量大,可按场景共存。
Q19. 常见内存泄漏场景?
核心结论:本质是"短生命周期对象被长生命周期对象持有",Android 里最典型就是 Activity 被持有。
- 静态变量持有 :单例、
companion object里存 Activity / Context / View → 改用applicationContext。 - 非静态内部类:Handler、Thread、AsyncTask 隐式持有外部类 → 改静态 + 弱引用。
- 未注销监听 :广播、
EventBus、Sensor、回调、RxJava 订阅 → 对应生命周期反注册。 - 资源未关闭 :Cursor、Stream、Bitmap、
Typeface。 - 其他 :
WebView未销毁、属性动画未cancel、Dialog窗口泄漏。 - 检测:LeakCanary 常规监控,Android Profiler 抓 HPROF 看引用链。
Q20. LeakCanary 原理?
核心结论 :在对象"本该被回收"的时机用弱引用 + ReferenceQueue 探测,存活则 dump 堆快照并解析出最短引用链。
- 监听时机 :
Activity.onDestroy/Fragment.onDestroyView之后,把对象包进WeakReference并关联ReferenceQueue。 - 探测 :触发 GC 并等待一小段时间,若对象未进入
ReferenceQueue,再二次确认后判定为泄漏。 - 分析 :
Debug.dumpHprofData生成 hprof,用 Shark 解析,从 GC Root 搜索到泄漏对象的最短路径。 - 展示:按应用/泄漏类分组,展示完整引用链并推送通知。
- 只在
debugImplementation引入,release 构建中是 no-op。
Q21. Bitmap 如何防止 OOM?
核心结论:按需加载合适尺寸 + 降格式 + 复用内存 + 及时释放,优先交给生命周期感知的图片库。
- 采样 :
inJustDecodeBounds = true先只读尺寸,按控件实际大小计算inSampleSize再解码。 - 降格式 :无透明通道场景用
RGB_565代替ARGB_8888,内存减半。 - 复用 :
inBitmap配合LruCache复用内存块,减少 GC 抖动(4.4+ 要求尺寸不小于被复用图)。 - 放对目录 :按密度放
drawable-xxhdpi等,避免低密度图在高密度机上被放大数倍。 - 释放 :Android 8 之前需手动
recycle;8+ 像素数据放 native,由 GC 管理。 - 工程实践 :统一用 Glide / Coil(内存 + 磁盘缓存、跟随生命周期清理);长图用
BitmapRegionDecoder局部加载。
Q22. 启动优化手段?
核心结论 :先量化冷/温/热启动耗时,再把启动路径上的任务异步化、延迟化、懒加载。
- 冷启动链路:加载并启动 App → 空白 Window → 创建 Application → 启动 Activity → 首帧绘制。
- 视觉优化 :给启动 Activity 设
windowBackground占位图,消除白屏/黑屏。 - 初始化治理 :三方 SDK 按优先级拆分,能并行的用启动器或协程并行,非必需的用
IdleHandler挪到首帧之后。 - 代码侧 :合并各 SDK 借
ContentProvider自启动的初始化(用 App Startup),避免主线程 IO 与锁等待,关注 MultiDex 开销。 - 度量 :
adb shell am start -W看ThisTime/TotalTime,或用 Perfetto 与埋点长期监控。
Q23. 布局过度绘制如何排查?
核心结论:用开发者选项的"调试 GPU 过度绘制"肉眼定位,再用 Layout Inspector 拆层级、逐个消掉重复背景。
- 开启:开发者选项 → 调试 GPU 过度绘制 → 显示过度绘制区域;颜色由浅到深代表 1x~4x+,目标是把多数区域压到原色/蓝色。
- 常见成因:多层不透明背景叠加(windowBackground 与根布局重复)、父容器已设背景子 View 又设、不可见 View 仍在绘制、复杂 shape 与阴影。
- 解决 :移除多余背景、用
ConstraintLayout减少嵌套、<merge>合并层级、<ViewStub>懒加载、自定义 View 用clipRect裁剪绘制区域。 - 配合"Profile GPU Rendering"看每帧耗时,或用
Choreographer做掉帧埋点定位卡顿。
Q24. ViewModel 为什么不会被重建销毁?
核心结论 :ViewModel 存在 ViewModelStore 中,配置变更时系统通过 NonConfigurationInstances 把同一个 Store 交给新 Activity,所以旋屏后取回的是同一实例。
- 流程 :配置变更 →
ActivityThread在ActivityClientRecord中保留lastNonConfigurationInstances→ 新 Activity 在onAttach/onCreate时取回旧的ViewModelStore→ViewModelProvider从 Store 中命中原对象。 - 何时真正清空 :
ComponentActivity在ON_DESTROY时判断isChangingConfigurations(),只有非配置变更 (真正 finish)才调ViewModelStore.clear();进程被杀也会清空。 - 注意 :ViewModel 生命周期长于 Activity,严禁持有 View / Activity 引用 ;需要 Context 时用
AndroidViewModel的application。
Q25. Kotlin 空安全怎么实现?
核心结论 :靠类型系统把"可空 / 非空"分开,在编译期 拦截空指针;对非空参数还会在字节码中插入 Intrinsics.checkNotNullParameter 做运行时兜底校验。
- 声明 :
String不可为 null,String?可为 null,把 null 赋给非空类型直接编译报错。 - 操作符 :
?.安全调用、?:Elvis 给默认值、!!强制断言(NPE 抛在调用处,慎用 )、as?安全转换、配合?.let {}。 - 智能转换 :判空后自动转为非空类型(
var需注意并发修改);lateinit var延迟初始化,未初始化就访问抛UninitializedPropertyAccessException。 - 边界 :与 Java 互调时 Java 类型是平台类型(如
String!),编译器不强制,需自行判空;反射、泛型擦除仍可能绕过检查。