ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android面试题及答案:四大组件、Handler机制与内存泄露考点梳理

Android面试题及答案:四大组件、Handler机制与内存泄露考点梳理

简介:这份文档面向准备Android开发岗位面试的初中级工程师与求职者,围绕常见面试考点整理试题及参考答案,可用于系统复习、查漏补缺和面试前自测。内容覆盖Activity生命周期与状态保存、五种常用布局、SharedPreferences与SQLite等数据存储方式、ContentProvider共享机制、Started与Bound Service启停、隐式与显式广播注册、Handler与Looper消息传递、AIDL接口定义、运行时权限与文件系统权限、浏览器调用、Bitmap与Canvas图片处理及嵌入式操作系统等知识点,并包含部分原理辨析、四大组件联系和项目经验类问题,便于读者按模块梳理答题思路、补齐薄弱环节。资源包共1个doc文件,约76KB,篇幅紧凑,轻量易读,适合打印成册或离线查阅。文档按知识模块组织,便于逐项对照复习,也可作为面试前的快速回顾提纲。已有296人学习,可作为Android面试知识清单和快速复盘材料使用。

1. 到底什么是 Android 面试题及答案:一张被反复补过的知识地图

Android 面试题及答案,说白了就是把「一个安卓开发者应该知道什么」压缩成一份问答清单的东西。它不像教科书那样从变量讲起,而是直接抛问题:Activity 的生命周期有几个回调、后台页面被系统回收前怎么存状态、Handler 和 Looper 到底谁管谁。每条问题后面跟一段答案,答案通常不长,但足够你在被追问时接上第二句。

这类资料大多不是某个人一次写完的,而是开发者面试完把被问住的题记下来,回头查明白,再补进文档,一轮轮传抄、修订、加批注,最后长成一份带着大量个人痕迹的公共笔记。所以它读起来不像教材那样工整,倒像一群人凑在一起对答案。值得搞懂它,是因为它其实是一张「知识地图」:顺着上面的问题走一遍,你能很快看出安卓这套体系里哪些东西是骨架、哪些只是边角。

2. 为什么面试官总盯着这几块:四大组件与生命周期的因果链

安卓面试题的分布很不均匀。翻十份不同的合集,你会发现题目高度集中在几个地方:Activity 生命周期、四大组件、Handler 消息机制、数据存储、内存泄露。这不是面试官偷懒,而是这几块刚好卡在「框架设计的必然结果」上——它们不是随便定的规矩,是被现实条件逼出来的解法。搞懂「为什么非这样不可」,比背下答案管用得多。

2.1 生命周期不是背出来的,是被系统「借走控制权」逼出来的

先接受一个前提:你写的应用,命运不完全在自己手里。手机在跑你的程序时,可能进来一个电话、一条短信,可能电量见底,也可能别的应用要用内存。系统必须优先保证打电话这种最基本的功能,所以它保留随时打断你、甚至直接回收你的权力。

既然随时会被打断,就必须有一套「提前打招呼」的机制,让你在被打断前后有机会做事。这套机制就是生命周期回调。一个 Activity 正常启动,回调顺序是onCreate -> onStart -> onResume;被正常销毁时是onPause -> onStop -> onDestroy。但真正有意思的是中间那几种「不完全打断」的情况:

  • 如果打断你的是一个全屏的新页面,你的页面先走onPause再走onStop,因为此时你已经完全看不见了;回来时是onStart -> onResume。
  • 如果打断你的是一个对话框样式或者半透明主题的页面,你的页面只走onPause,因为你还露在屏幕上,只是不能操作;回来时只走onResume。

提示:这两种情况的差别,是面试里区分「背过」和「真写过」的高频问法。

每个回调里,系统在做什么、你该做什么,也是有分工的:

回调此时的状态系统在做什么你该做什么
onCreate实例刚建好,不可见装配上下文、准备资源建界面、初始化数据
onStart可见但不可交互把它推到前台注册一些轻量监听
onResume可见且可交互页面位于栈顶,接收输入恢复数据、启动动画
onPause可见但不可交互准备切走,停掉耗 CPU 的动画保存关键数据,动作要快
onStop完全不可见被新页面盖住释放较重资源
onDestroy即将消失从栈里移除收尾、解绑

表里有两个地方值得多说一句。onPause里保存数据,是因为这时候你的进程优先级开始下降,随时可能被系统盯上;但这个方法里做的事必须短,因为下一个页面不会等你做完才启动——在这里塞一个耗时操作,用户看到的就是卡顿。onResume里读回数据,是因为你可能是从「被回收后重建」的状态回来的,也可能是从「只是被盖住」的状态回来的,两种情况都得能接上。

还有一个细节:Activity 是被栈管理的,最上面的在运行,返回就弹出栈回到上一个。这个「栈」的比喻很好用——它解释了为什么你连按返回键能一层层退回去,也解释了为什么启动模式会影响返回行为。

2.2 四大组件为什么必须存在:把「谁调谁」反过来

有个经典追问:安卓为什么要设计四大组件,不设计行不行?

要回答这个,得先看 MVC 在安卓里怎么落地。MVC 拆成三块:模型管数据和业务,视图管显示,控制器管接收事件并决定改谁。问题在于,手机应用天生是「被别人调用」的——来电、短信、其他应用想分享一张图给你,这些事件都不是你的代码主动发起的。如果所有逻辑都塞在一个入口里,应用之间就没法互相复用功能,系统也没法在你没运行时叫醒你。

所以框架把控制权反了过来。有一句话概括得很准:不要主动去调框架,让框架来调你。你要做的不是「运行一个程序」,而是「注册一堆能力,等着被唤醒」。四大组件就是四种被唤醒的方式:

  • Activity:一块可以被用户看到的界面,被别人通过意图唤起。
  • Service:一段没有界面、在后台干活的能力。
  • BroadcastReceiver:一个「听到某类广播就动手」的接收器。
  • ContentProvider:一个对外的数据出口,让别的应用按规矩读写你的数据。

这四类东西共同实现了组件的复用:任何一个应用都可以发布自己的功能块,别的应用按约定就能用;用户也因此可以替换掉某个环节。这也解释了为什么框架层里有大量看起来「多余」的管理者——活动管理器负责生命周期和返回栈,通知管理器负责状态栏提示,资源管理器负责非代码资源(字符串、图片、布局)的访问,内容提供器负责跨应用数据。这些东西单独看都是配角,合起来才是「应用能被系统调度」这件事的地基。

2.3 被回收前的自救:状态保存这件事有两个入口

面试题里那句「如果后台的 Activity 由于某原因被系统回收了,如何在被回收之前保存当前状态」,问的其实是两个入口的区别。

第一个入口是onPause。它适合保存「业务数据」——用户填了一半的表单、正在播放的进度。因为这时候进程优先级已经降低,是最后一个相对安全的时机。存进去的东西,在onResume里读出来。

第二个入口是onSaveInstanceState(Bundle)。它适合保存「界面状态」——滚动位置、临时勾选、输入框里的文本。它的特别之处在于:它不是只在被回收时调用,配置变化(比如横竖屏切换导致重建)时也会走。系统把这个 Bundle 交给你,重建时再通过onCreate或onRestoreInstanceState还给你。

// 保存:把需要跨重建存活的轻量状态塞进 Bundle @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString("draft", editText.getText().toString()); // 只放轻量数据 outState.putInt("scrollY", listView.getScrollY()); } // 恢复:重建时读回来,注意判空——首次创建时 Bundle 为 null @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); if (savedInstanceState != null) { editText.setText(savedInstanceState.getString("draft", "")); listView.scrollTo(0, savedInstanceState.getInt("scrollY", 0)); } }

这里的关键判断是「放什么」。Bundle 会跟着系统走一遭,塞大对象(比如一整张位图)会拖慢重建甚至触发异常,所以只放能快速序列化的轻量状态;真正的大数据应该落到文件或数据库里,Bundle 里只存一个索引或路径。

顺带说一句,这份资料里还混进了不少「看着不像安卓题」的题:SIM 卡的 EF 文件是干什么的、嵌入式操作系统内存管理有页式段式段页几种、一条短信最多多少字节(中文 70 个字符含标点,英文 160 个)、安卓算不算实时操作系统。这些题出现,是因为早期招安卓开发的公司往往同时在招嵌入式方向,笔试卷子是共用的一张。看到这类题不必慌,它们考的是「你有没有超出应用层的视野」。

3. Handler、Looper、MessageQueue:一条消息流水线

如果只能留一道安卓面试题,大概率是这道:请解释单线程模型中 Message、Handler、MessageQueue、Looper 之间的关系。它之所以常考,是因为它同时考了三件事——你懂不懂线程、懂不懂框架的设计意图、有没有被「在主线程里直接改 UI」坑过。

3.1 单线程模型:为什么 UI 只能被一个线程碰

安卓的 UI 是单线程的。所有跟界面有关的操作,都必须发生在主线程(也叫 UI 线程)里。为什么不设计成多线程?因为界面是一棵共享的视图树,如果两个线程同时去改同一个控件的宽高,就得给每个控件加锁,加锁就会带来死锁和卡顿,而且绝大多数界面操作本身很轻,加锁的成本远大于收益。

单线程的代价是:你不能在主线程里做耗时操作。网络请求、大文件读写、复杂计算,一旦占住主线程超过几秒,用户看到的就是界面冻住,系统最终会弹出「应用无响应」。于是就有了一个固定矛盾——耗时的活必须在子线程干,但干完的结果必须回到主线程去显示。

这个矛盾不是安卓独有的,任何带界面的框架都要解决它。安卓给出的答案是:别让子线程直接碰界面,让它把「要做的事」打包成一个消息,投递到主线程的队列里,由主线程自己按顺序取出来执行。这样界面永远只有一个线程在改,跨线程的问题就消失了。

3.2 三个角色的分工:Looper 是传送带,MessageQueue 是货架,Handler 是投递口

用一条工厂流水线来打比方最省事:

  • MessageQueue 是货架,消息按顺序摆上去,等着被取走。
  • Looper 是那条不停转的传送带,它死循环地从货架上取消息,取到就交给该处理的人。一个线程想拥有消息循环,就得有一个 Looper;主线程在应用启动时,系统已经替它建好了一个。
  • Handler 是投递口兼加工位:你通过它往队列里塞消息,也通过它接收从队列里取出来的消息。
  • Message 就是那个包裹,里面装着「做什么」和「带了什么数据」。

原文把 Handler 的原则概括成「先进先出」,这是个便于记忆的简化。更准确地说,队列是按消息的触发时间排序的,延时消息会排在后面,同一时刻的消息才按投递先后处理。理解这一点,你就明白为什么postDelayed能实现定时,也明白为什么大量延时消息会让某些即时消息被推后。

// 主线程的 Handler:构造时绑定主线程的 Looper Handler mainHandler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(Message msg) { // 走到这里已经在主线程,可以安全操作 View textView.setText((String) msg.obj); } }; new Thread(new Runnable() { @Override public void run() { String result = loadFromNetwork(); // 耗时操作留在子线程 Message msg = mainHandler.obtainMessage(); // 复用消息对象,减少分配 msg.obj = result; // 把结果挂到消息上带回去 mainHandler.sendMessage(msg); // 投递进主线程队列 } }).start();

几个参数值得掰开说。Looper.getMainLooper()拿到的是主线程的 Looper,构造 Handler 时传它,等于声明「我这条消息要送到主线程去处理」;如果不传,Handler 会绑定当前线程的 Looper,在子线程里 new 一个 Handler 却不调Looper.prepare(),就会直接崩,这是新手最常踩的坑之一。obtainMessage()从消息池里取对象而不是 new,是因为消息在高频场景下产生量很大,复用能明显减少内存抖动。sendMessage是入队,真正的执行时机由 Looper 决定,所以它是异步的——这也解释了为什么在onCreate里发一条消息,它不会立刻执行,而是等当前这轮主线程任务做完。

3.3 从消息机制往外延伸:View 刷新和内存泄露

理解了消息机制,两个常考问题就顺了。

第一个是 View 怎么刷新。界面不是随时能改的,正确的做法是在需要刷新的地方,通过 Handler 发一条消息,然后在处理消息时调用重绘请求,让系统在下一帧统一重画。直接在主线程的循环里反复请求重绘,会把消息队列塞满,反而更卡。

第二个是内存泄露。这个题几乎每份合集都有,因为它是「消息机制 + 生命周期」的交叉考点。常见的四种情况:

  • 数据库的游标用完没关闭,底层持有资源不放。
  • 构造列表适配器时没有复用条目视图,每次滚动都新建一堆对象;正确做法是复用那个代表整行视图的对象,并用一个静态的持有类来缓存子控件引用,避免每次findViewById。
  • 位图对象不再使用时没有主动释放,大图会长期占着内存。
  • Activity 里的对象生命周期比 Activity 本身还长,比如一个匿名的 Handler 持有外部 Activity 引用、一个没取消的延时任务、一个没注销的监听器。Activity 已经销毁,引用还在,内存就回收不掉。

注意:第四条是最容易被追问的一类。判断标准很简单——「这个对象能活多久,它手里的 Activity 引用就有多危险」。

调的时候,常用的观察手段是内存快照:抓一份堆数据,按对象总量排序,看看谁在异常增长。这套方法不玄学,就是「先看总量、再看类型、最后看引用链」,一步一步缩小范围。

4. 数据存储与跨进程:五种存法、ContentProvider、AIDL 各管哪一段

「请介绍下安卓的数据存储方式」这道题,看着像送分题,其实最能看出一个人有没有真做过项目。因为背出五种名字很容易,说清「什么场景该用哪种」才是目的。

4.1 五种存储方式:按数据量和结构选,不是按习惯选

安卓提供的存储方式大致五类,它们不是互相替代的关系,而是各管一段:

方式适合存什么特点典型误用
键值偏好存储少量配置、开关、登录标记读写极快,键值对结构拿来存列表和大段文本
内部存储应用私有的文件其他应用访问不到,卸载即清把大文件也往里塞,占满空间
外部存储图片、音频、导出文件容量大,可被其他程序看到存敏感数据
数据库结构化、要查询和排序的数据支持条件查询、事务用文件自己拼格式来模拟
内容提供器要共享给别的应用的数据统一接口,可限制访问只在自己应用内用,白白绕一圈

选择逻辑其实只有三步:先问数据量有多大,再问有没有结构(要不要按条件查),最后问要不要给别的应用看。三个答案一确定,用哪种基本就定死了。把一份几百条的聊天记录塞进键值存储,然后在代码里自己做字符串拼接和解析,就是典型的「用错工具」——能跑,但每次查询都要全量解析,数据一多就崩。

4.2 ContentProvider 与 AIDL:共享数据和跨进程调用的分界

这两个概念经常被混在一起问,因为都涉及「跨应用」,但定位完全不同。

ContentProvider 是一个数据出口。它把自家的数据包装成一套统一的增删改查接口,别的应用通过内容解析器来访问,不需要知道你的数据到底存在数据库里还是文件里。它解决的是「数据怎么被安全地共享」——可以只开放部分数据,也可以限制访问权限。它更像一个对外的 API 门面,而不是通信通道。

AIDL 是一门接口定义语言。当你需要让另一个进程真正调用你这边的方法、并且要处理多种数据类型时,用它来描述接口,工具会据此生成中间代码,把跨进程调用「包装」成看起来像本地方法调用的样子。它解决的是「方法怎么跨进程调」。

区别可以这样记:ContentProvider 管的是「数据进出」,AIDL 管的是「方法调用」。前者通常面向数据共享,后者通常面向服务调用。实际项目里,如果只是想让别的应用读到你的联系人列表,用内容提供器;如果是想让别的应用调用你封装好的一个业务方法并拿到返回值,用 AIDL。至于 Service,它只是「在哪运行」的载体——启动式服务适合长期在后台跑任务,绑定式服务适合给别的组件提供功能接口,两者可以同时存在。

4.3 权限的两套体系:运行时权限与文件系统权限

「安卓程序运行时权限与文件系统权限的区别」这道题,问的是一个很容易被含混过去的点:权限到底分几种层面。

运行时权限,是应用向系统申请的、访问受保护能力或数据的许可。比如读联系人、用相机、定位。它的特点是:由系统统一裁决,用户在运行过程中授权,被拒绝后应用必须优雅降级而不是直接崩。

文件系统权限,是操作系统层面给文件设的访问控制。它约束的是「这个进程能不能读写这个路径」,跟应用层申请什么权限是两回事。你的应用拿到了读外部存储的许可,也不代表它能读另一个应用私有目录里的文件——那是文件系统权限在挡着。

提示:把这两层分开看,很多「明明申请了权限还是报错」的困惑就自动消失了。

另外还有个常被忽略的细节:内容提供器本身也可以设访问限制,而且这种限制是在数据出口那一层做的,跟上面两层又不完全重合。三层各管一段,混着讲就容易乱。

顺便提一个和「共享」相关的小题:手机上装了多个浏览器时,能不能指定某一个去打开某个页面?答案是能,靠的是显式指定目标组件的意图;如果只写一个通用的「打开网页」意图,系统就会弹出选择器让用户挑。这道题真正考的是「隐式意图和显式意图的区别」——隐式是「谁能干这活谁上」,显式是「我点名要谁干」。

5. 几个最容易搞混的概念:误区与易混点

有些概念单独看都懂,放在一起就糊。这一章挑四个最高频的混淆点,按「容易混在哪、为什么会混、怎么区分」的顺序拆开。

5.1 虚拟机进程、Linux 进程、应用进程是同一个吗

易混点:这三个词经常在同一段话里出现,读起来像是三个不同的东西。

为什么混:因为它们描述的其实是同一个东西在不同视角下的样子。每个安卓应用运行在自己的进程里,拥有一个独立的虚拟机实例;而这个虚拟机,在 Linux 看来就是一个普通的进程。所以从「是不是同一个」的角度答,可以认为是一个东西;从「站在哪一层看」的角度答,是三个层次的描述。

怎么区分:记住一句话就够——「应用进程是身份,虚拟机实例是运行环境,Linux 进程是操作系统眼里的实体」。答题时把这三层说清楚,比单纯回答「是」或「不是」有价值得多。这也顺带解释了虚拟机执行的文件格式:代码先编译成中间产物,再转成一种针对小内存优化过的可执行格式,由虚拟机加载执行;虚拟机本身又依赖内核提供的线程和内存管理能力。这条链条串起来,架构题也就答得动了。

5.2 补间动画和逐帧动画:两种完全不同的思路

易混点:都叫动画,都被用来「让界面动起来」。

为什么混:因为它们最终呈现的效果可能一模一样,一个按钮从左边滑到右边,两种做法都能实现。

怎么区分:看「动的是谁」。补间动画动的是「属性」——位置、大小、旋转角度、透明度,你只给起点和终点,中间过程由系统算,视图本身还是那一个;逐帧动画动的是「画面」——你准备一串排好序的图片,按顺序一张张播,像翻页动画书,本质是替换显示内容。前者轻、灵活、适合做位移和渐变;后者重、表现力强、适合做角色动作这类无法用属性描述的变化。面试里如果被追问「补间动画改了位置之后,点击事件还在原地怎么办」,那就是在考你知不知道它只改了绘制、没改真实布局——这是它和属性动画最容易被忽略的差别。

5.3 广播的两种注册方式:不是「新旧」关系

易混点:静态注册和动态注册,容易被理解成「老写法」和「新写法」。

为什么混:因为它们实现的是同一件事——接收广播,代码看起来也只是换个地方写。

怎么区分:看「生命周期归谁管」。静态注册写在清单文件里,应用没启动也能被系统唤起,但一旦注册就一直有效,数量多了会拖慢系统,所以后来被大幅收紧;动态注册写在代码里,跟着注册它的组件走,组件销毁时注销,灵活但要记得配对注销,忘了注销就是内存泄露。至于广播机制为什么存在,本质是「一对多的解耦通信」:发送方不需要知道谁在听,接收方也不需要知道谁在说。这也解释了开机自启那类需求为什么能做——系统开机时会发一条完成广播,你只要注册一个接收器去接,就能在开机后把界面拉起来,当然别忘了在清单里声明对应权限。

5.4 软实时也是实时,但不等于「实时操作系统」

易混点:题目问「安卓属于实时操作系统吗」,很多人直接答「是」,因为看到内核是可抢占的。

为什么混:「实时」这个词在中文里太日常了,容易理解成「反应快」。

怎么区分:实时操作系统的实时,指的是「结果必须在规定时间内给出」,超时就是失败,不是「快一点」。它分硬实时和软实时:硬实时不允许超时,通常用在工业控制、飞行控制这类场合;软实时允许偶尔超时,只是性能下降。安卓基于 Linux 内核,用户空间可抢占,内核空间后来也支持局部抢占,所以按定义属于软实时系统,而不是硬实时。答题时把这个「规定时间」的判断标准说出来,比给结论更有说服力。

6. 什么时候该翻这份资料,什么时候别翻

这份问答合集的价值,取决于你拿它干什么。用对了,它是查漏补缺的索引;用错了,它就是一叠让你产生「我都懂了」幻觉的纸。

6.1 三个真能用上的场景

第一个场景是面试前的定向复习。你已经写过项目,只是有些点模糊了,比如四种导致内存泄露的写法分别长什么样、五种存储方式怎么选。这时候按目录扫一遍,看到某个问题答不上来,回去把它对应的那一节吃透,效率比从头读一本教程高得多。

第二个场景是被某个概念卡住时当索引用。比如你第一次写跨进程调用,不知道 AIDL 到底在中间帮你做了什么;或者你不明白为什么在子线程 new 一个 Handler 会崩。这类问题在合集里通常有一两句直击要害的话,比翻文档快。

第三个场景是准备「追问」。面试官很少只问一层,比如问了图片像素处理,接着会问时间复杂度和边界情况。合集里那些带代码的题,正好用来练「把答案讲成一段有逻辑的话」。

6.2 三个容易带偏的场景

第一种是把它当唯一教材。合集里的答案普遍偏短,很多是「结论」而不是「推导」。只记结论,遇到稍微变形的追问就会露馅。

第二种是把它当版本无关的真理。这类资料有很强的年代痕迹,比如五种布局的经典分法、静态注册广播的宽松程度、权限模型的收紧,都在随系统版本变化。看到一个答案,先问一句「这是哪个时期的行为」。

第三种是拿它去背代码。里面那道图片题很典型:给一个一维整型数组,存着一张宽高已知的图片像素,要求把所有纯白不透明像素的透明度调成 50%。

// 每个 int 是一个 ARGB_8888 像素,最高 8 位是透明度 A,低 24 位是 RGB final int WHITE_OPAQUE = 0xffffffff; // 纯白且完全不透明 final int ALPHA_50 = 0x7f000000; // 50% 透明度,255/2 取整约 0x7f for (int i = 0; i < data.length; i++) { if (data[i] == WHITE_OPAQUE) { // 只处理纯白且完全不透明的点 data[i] = ALPHA_50 | (data[i] & 0x00ffffff); // 保留 RGB,只替换 A } }

这段代码的重点不在记住0x7f,而在三件事:为什么用位运算而不是拆成四个分量再拼回去(快且不丢精度)、为什么判断条件是「等于纯白不透明」而不是「是白色」(半透明白色不该被动)、为什么循环里不做内存分配(一趟扫完,时间与空间都最优)。真正被考的是这三层判断,把代码抄下来毫无意义。

用法结果
当索引,按需查证快速补齐盲区
当唯一材料,逐条背追问一层就崩
当代码库,照抄实现换个问法就不会
当追问清单,练表达面试时讲得清楚

我最早的教训来自一次复盘:面试官问生命周期,我把六个回调顺序背得滚瓜烂熟,他接着问「如果打断你的是个对话框样式的页面,会走哪几个」,我愣在那里。从那以后我每次看这类合集,都强制走一遍「这个答案在什么条件下会变」——把每条结论都当成有前提的判断,而不是无条件的事实。希望帮到你。这份问答资料我放在下载区了,卡在某道题上时再去翻对应的那一页,比通读一遍管用。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进