简介:《Android面试试题及答案》面向准备Android开发岗位面试的初中级工程师与计算机专业求职者,围绕常见考点提供题目与参考答案,适合用于面试前系统梳理知识框架、查漏补缺和自测复习。压缩包内共1个doc文档,约76KB,内容以问答形式组织,便于按主题检索和集中阅读。资料覆盖Activity生命周期、数据存储方式、ContentProvider数据共享、Service启停与生命周期、广播注册方式、AIDL、运行时权限与文件系统权限、浏览器调用、图片处理及嵌入式操作系统等知识点,并延伸至DVM与Linux进程关系、Handler与Looper消息机制、MVC模式、ListView优化、IPC原理和四大组件设计等高频问题。已有296人学习,读者可借助其中的题目清单与答案要点快速定位薄弱环节,理解面试官常问的技术细节,形成从基础概念到项目经验的回答思路。
1. 一份 Android 面试题集到底在考什么:从「背答案」到讲清一条链路
很多人第一次翻 Android 面试试题及答案时,期待的是标准答案。可真坐进面试间,对方问「描述下 Activity 的生命周期」,你从 onCreate 背到 onDestroy 一气呵成,他接着追问一句「那这时候进来一个电话呢」,空气就安静了。这份资料真正值钱的地方,不在于某道题的标准答案,而在于它把散落的知识点串成了一条条链路:组件为什么这么设计、系统在背后替你做了什么、边界又在哪里。搞懂这些链路,你会发现面试题只是个入口,它考的是你对「一台手机怎么跑程序」有没有整体概念。下面按认知顺序一层层剥开,从四大组件讲到线程通信、数据存储,再专门拎出最容易混淆的那几个点。
2. 四大组件与生命周期:Android 面试题的主干为什么绕不开它
四大组件这个词,几乎每份 Android 面试题都会出现,但大部分人只能背出名字。它之所以是主干,是因为手机这个运行环境有个前提:资源永远不够分。你写的应用不是唯一在跑的程序,电话、短信、系统服务随时可能插队。组件化的设计,本质上就是把「你的程序」拆成几块可被系统单独调度、单独回收的零件,这样系统才有办法在不问你的情况下做决定。
2.1 Activity 生命周期为什么必须是「系统说了算」
打个比方,你租了一间办公室,钥匙在你手里,但整栋楼的电闸在物业手里。什么时候断电、什么时候检修,你说了不算,你能做的只是记住断电流程,在断电前把手头的东西存好。Activity 就是这么个处境:它和其他手机平台的应用一样,生命周期是被统一掌控的,我们写的程序命运掌握在别人(系统)手里,改不了,只能学习和适应。
为什么必须这样?因为手机在运行一个应用的时候,有可能打进来电话、发进来短信,或者干脆没电了,这时候程序都会被中断,优先去服务打电话这类基本功能;另外系统也不允许你占用太多资源,至少要保证通话功能可用,所以资源不足的时候,你的后台界面也就有可能被干掉。这个前提想通了,后面所有回调都不是死记硬背,而是「系统在通知你,它要动手了」。
把几个回调按状态摆开,会清楚很多:
| 回调 | 是否可见 | 是否可交互 | 系统在做什么 |
|---|---|---|---|
| onCreate | 不可见 | 不可交互 | 创建实例,通常在这里建界面、初始化数据 |
| onStart | 可见 | 不可交互 | 界面已经能被看到,但还没准备好接收操作 |
| onResume | 可见 | 可交互 | 界面在最上层,能响应点击和输入 |
| onPause | 可见 | 不可交互 | 有别的界面盖上来,系统会停掉动画这类吃 CPU 的事 |
| onStop | 不可见 | 不可交互 | 被下一个界面完全覆盖 |
| onDestroy | 已销毁 | 已销毁 | 实例即将被回收 |
一个 Activity 正常启动,调用顺序是 onCreate → onStart → onResume;被干掉的时候是 onPause → onStop → onDestroy。这是最基础的一条链。真正拉开差距的是打断的情况:程序正常跑着,进来一个电话,或者弹出一个新界面,怎么办?
如果新出来的是一个全屏 Activity,那么旧界面走 onPause → onStop,回来的时候走 onStart → onResume;如果打断它的 Activity 主题是 Dialog 或者 Translucent,也就是那种半透明、没有完全盖住的样式,旧界面只会走 onPause,因为它还露在屏幕上,回来的时候直接 onResume 就行。这个区别很多人背答案时压根没注意,但它恰恰说明生命周期不是一套固定流程,而是由「可见性」和「可交互性」两个开关组合出来的状态机。
每个回调里该干什么,也有讲究。onCreate 里创建界面、做数据初始化,这个是躲不掉的。onStart 到这一步变成用户可见但不可交互。onResume 变成和用户可交互,Activity 通过栈的方式被系统管理,运行在最上面,弹出栈就回到上一个。onPause 是可见但不可交互,系统会停止动画等消耗 CPU 的事情——这里也是最该保存数据的地方,因为此时你的程序优先级降低了,随时可能被系统收回;而在这里保存的数据,应该在 onResume 里读回来。
注意:onPause 里做的事情时间要短。下一个 Activity 不会等到这个方法跑完才启动,你在里面塞一个网络请求或者一次数据库大批量写入,用户看到的就是卡顿,而且这段逻辑还不一定执行得完。
onStop 意味着界面变得不可见,被下一个界面覆盖了,适合释放比较重的资源,比如反注册监听器、停掉定时任务。onDestroy 是最后的清理,但别把「保存数据」的重任全压在这里,因为它不一定被调用——系统直接回收进程时,它是不走这条路的。这也是下一节要讲的那个问题的由来。
2.2 被回收之前保存状态:onSaveInstanceState 到底救的是什么
面试题里有一道经典问法:如果后台的 Activity 由于某种原因被系统回收了,怎么在被回收之前保存当前状态?答案是 onSaveInstanceState,但这个答案只说了一半,另一半是「它救的到底是什么」。
先看场景。用户在填写一个表单,填到一半切出去接了个电话,这时候系统内存吃紧,把这个后台 Activity 回收了。用户接完电话切回来,期望看到的是刚才填的内容还在,而不是一个干干净净的空表单。onSaveInstanceState 就是系统在你被回收之前给你的一个回调,它把一个 Bundle 交到你手里,你往里面塞东西,系统替你保管;等界面重建时,这个 Bundle 会通过 onCreate 的参数传回来。
@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); // 只存「重建界面所必需的最小信息」,比如输入框里的临时文本、列表滚动位置 outState.putString("draft_text", editText.getText().toString()); outState.putInt("scroll_y", listView.getScrollY()); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_edit); // 进程被回收后重建时,savedInstanceState 不为 null;正常启动时为 null if (savedInstanceState != null) { editText.setText(savedInstanceState.getString("draft_text")); listView.scrollTo(0, savedInstanceState.getInt("scroll_y")); } }这段代码里几个参数值得说清楚。outState 是系统给的容器,你只管往里放键值对,不用自己新建 Bundle;键名用常量字符串,避免重建时写错一个字母导致读不出来;savedInstanceState 在首次创建时是 null,重建时才非空,所以必须判空,这是新手最常见的翻车点之一——不判空直接 getString,一启动就空指针。
更关键的是「最小信息」这四个字。Bundle 不是用来做持久化的,它救的是这一次重建,不是长期存档。往里塞 Bitmap、大列表、长字符串,一旦超过传递上限就会抛异常,而这个异常通常在真机上才暴露,测试时一切正常,上线后用户一反馈你就傻眼。所以正确的姿势是:临时状态放 Bundle,需要长期留存的放数据库或文件,两者别混。
提示:onSaveInstanceState 不保证一定被调用。用户主动按返回键退出时,系统认为你不需要恢复,就不会走这个回调。指望它当「后悔药」存业务数据,迟早会踩坑。
2.3 Service 与广播:后台任务和跨应用通信的两条老路
Service 是四大组件里名字最容易被误解的一个。它常被描述成「在后台运行的组件,用于执行长时间运行的任务」,于是很多人第一反应是「那我耗时的活儿全丢进去就好了」。这个理解会出问题,原因放到第 5 章细说,这里先把它的两种形态讲清楚。
第一种是 Started Service,用 startService 启动。调用之后它就在后台跑着,和启动它的界面没有绑定关系,界面退出了它还在,直到有人调用 stopService 或者它自己 stopSelf。生命周期大致是 onCreate → onStartCommand → onDestroy,注意 onStartCommand 可以被调用多次,每次 startService 都触发一次,所以这里适合放「收到一个任务就处理一个」的逻辑。第二种是 Bound Service,用 bindService 绑定,客户端拿到一个 IBinder 接口,可以直接调用服务里的方法,属于「提供功能性服务」的路子。它的生命周期跟绑定它的组件走,onBind → onUnbind → onDestroy。一个 Service 可以同时被 start 又被 bind,只有两种方式都解除之后,系统才会真正销毁它。
广播机制解决的是另一个问题:跨应用、跨组件的通知。它把「谁发的」和「谁收的」彻底解耦,发的一方不需要知道有谁在听,收的一方也不需要认识发的人。用一句话概括 Android 框架的做事方式就是「别找我,我会回来找你」——你要做的不是主动去问,而是把接收消息的准备做好,等系统来找你。
注册广播有两种方式,优缺点差别很大:
| 对比项 | 静态注册 | 动态注册 |
|---|---|---|
| 声明位置 | AndroidManifest 的 receiver 节点 | 代码里调用 registerReceiver |
| 生效时机 | 应用安装后就存在,不必等进程先启动 | 注册那行代码执行后才生效,进程被杀就失效 |
| 生命周期 | 跟着应用走 | 跟着注册它的组件走,必须手动注销 |
| 典型用途 | 开机完成、系统级事件 | 页面内部通信、需要精细控制的场景 |
静态注册的最大好处是「应用没启动也能收到」,开机自启就是靠这个。系统启动完成时会发出一个内容为 ACTION_BOOT_COMPLETED 的广播,字符串常量是 android.intent.action.BOOT_COMPLETED,你只要在程序里捕捉到这个消息,再启动自己的界面就行。接收方派生自 BroadcastReceiver,在 onReceive 里判断收到的 Intent 动作对不对得上,对得上就干活。
<!-- AndroidManifest.xml 片段:静态注册接收开机广播 --> <receiver android:name=".BootReceiver" android:exported="true"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver> <!-- 别忘了声明权限,少这一行广播收不到 --> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />public class BootReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { Intent start = new Intent(context, MainActivity.class); // 从非 Activity 的上下文启动界面,必须加这个 flag,否则直接崩 start.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(start); } } }这两段配置里有两个容易漏的点。一个是权限节点,很多人只写了 receiver 忘了申请 RECEIVE_BOOT_COMPLETED,然后对着日志找半天,以为是系统限制。另一个是 FLAG_ACTIVITY_NEW_TASK,BroadcastReceiver 的上下文不是 Activity,没有任务栈的概念,不加这个标志启动界面会抛异常。
动态注册的优点是灵活、可控,代码里注册、代码里注销,什么时候生效一目了然。缺点也明显:注销这一步一旦忘了,接收器就会一直持有对界面的引用,界面销毁了它还在,内存泄漏就这么来的。而静态注册虽然常驻,但 onReceive 的执行时间有严格限制,超过十秒左右系统就会判定无响应,所以它只适合「收到消息,转手交给别处处理」,不适合在里面做耗时操作。
3. Handler、Looper、MessageQueue:Android 线程通信的三件套到底谁管谁
单线程模型这一章,是很多人从「会写界面」到「理解 Android」的分水岭。面试题里那句「请解释下在单线程模型中 Message、Handler、Message Queue、Looper 之间的关系」,看起来是在考四个类的定义,实际上是在考你有没有想明白:既然只有一个线程能碰界面,那子线程算出来的结果要怎么送回去?
3.1 单线程模型:为什么 UI 只能被一个线程碰
Android 启动程序时会为 UI 线程,也就是主线程,建立一个消息队列。整个应用里所有跟界面相关的操作,都得在这个线程上排队执行。为什么这么设计?打个比方,界面就像一本只有一个会计在记的账本,谁都能往上写一笔的话,两个人同时改同一行,最后留下的数字是哪个谁都说不准,要避免这种混乱就得加锁,而锁一多,卡顿和死锁就跟着来了。
所以 Android 选择了一条更省事的路:干脆规定只有一个人能写账本。你在子线程里算数据、读文件、发请求都没问题,但要改界面,就必须把这件事「寄」回主线程。寄的方式,就是 Handler 这套消息机制。
很多人的第一次翻车就发生在这里:在子线程里直接调用控件的 setText,程序当场抛异常。异常信息大意是「你不是创建这个视图的线程,别碰它」。这不是玄学,是系统主动做的检查,宁可让你立刻崩掉,也不让你在运行时留下一个难以复现的错乱界面。
3.2 Looper、Handler、MessageQueue 的分工类比
Android 提供了 Handler 和 Looper 来满足线程间的通信。Handler 遵循先进先出原则,Looper 类用来管理特定线程内对象之间的消息交换。这两句话是骨架,要长出肉来,得把四个角色摆清楚:
- Looper:一个线程可以产生一个 Looper 对象,由它管理这个线程里的 Message Queue。它是个死循环,不停问「有消息吗?有就拿出来」。
- Handler:你构造它来跟 Looper 沟通,既可以把新消息 push 进 Message Queue,也可以接收 Looper 从队列里取出来的消息。
- Message Queue:消息队列,用来存放线程放进去的消息,先进先出。
- 线程:UI 线程通常就是主线程,Android 启动程序时会替它建立一个 Message Queue。
用收信来打比方最直观。Message Queue 是门口那个收件箱,消息一件件按顺序码在里面;Looper 是那个不知疲倦的邮差,绕着收件箱转圈,取一件送一件;Handler 既是寄信人也是收信人,你用它把信投进去,也用它决定信送到之后干什么;Message 就是信封本身,上面写着 what、arg1、arg2 这些标记,好让收信人知道这是哪一类事。
邮差不是天生的,得先上岗。一个普通线程想要有自己的收件箱和邮差,必须先调用 Looper.prepare 把 Looper 建出来,再调用 Looper.loop 让它转起来。主线程是个例外,系统在启动程序的时候已经替它准备好了,所以你在 Activity 里 new Handler() 直接就能用。
// 工作线程自建 Looper:不调用 prepare 就 new Handler() 会直接抛异常 new Thread(new Runnable() { @Override public void run() { Looper.prepare(); // 给当前线程建一个消息队列 Handler workerHandler = new Handler(Looper.myLooper()) { @Override public void handleMessage(Message msg) { // 这里的代码运行在子线程,不是主线程 if (msg.what == 1) { // 处理耗时任务的结果 } } }; Looper.loop(); // 开始取消息,是个死循环,不调用就永远收不到 } }).start();这段代码里 prepare 和 loop 是配对出现的,少了前一个,构造 Handler 时会报「当前线程没有调用 Looper.prepare」;少了后一个,消息投进去也没人取,看起来像石沉大海。参数方面,Looper.myLooper() 取的是当前线程的 Looper,如果这里写成 Looper.getMainLooper(),消息就会被送到主线程去处理,两种写法对应完全不同的执行线程,这是排查问题时第一个要看的地方。
反过来的用法更常见:子线程干完活,把结果送回主线程刷界面。
// 绑定主线程 Looper 的 Handler,handleMessage / post 里的代码都在主线程执行 Handler uiHandler = new Handler(Looper.getMainLooper()); uiHandler.post(new Runnable() { @Override public void run() { textView.setText("加载完成"); // 这里已经回到主线程,可以安全改界面 } });post 和 sendMessage 的区别只在于形式:post 提交的是一个 Runnable,sendMessage 提交的是一个带标记的 Message,底层走的是同一条队列。带延时的版本 postDelayed 和 sendMessageDelayed 会让消息按到点时间排队,通俗地说,同一时刻到点的消息还是按进来的先后处理,但后到的可以插到更晚的位置去。
这套机制常被当成黑匣子用,反正能跑就行。可一旦出现「界面更新延迟」「消息丢了」「内存一直涨」这类问题,不打开黑匣子看一眼就很难收场。比如在 Activity 里 post 了一个延时十秒的任务,用户两秒后就退出了界面,那个 Runnable 仍然持有 Activity 的引用,十秒内它不会被回收。血泪经验是:延时消息在 onDestroy 里 removeCallbacks 掉,成本很低,省下的是一整晚的排查时间。
3.3 AIDL 与跨进程:什么时候才需要这么重的东西
前面讲的都是同一个进程内部的线程通信。跨进程又是另一套秩序,因为两个进程的内存空间根本不互通,你不能直接把一个对象递给对方。这时候就要用到 AIDL。
AIDL 的全称是 Android Interface Definition Language,也就是 Android 接口定义语言,用于定义 Android 应用程序中的接口。它的工作方式分三步:你先写一个 .aidl 文件描述接口方法,编译工具据此生成对应的 .java 文件,里面已经搭好了跨进程调用的架子;服务端实现这个接口,在 onBind 里返回实例;客户端 bindService 拿到 IBinder,转成接口类型就能像调本地方法一样调用。
能处理哪些数据类型,是有明确范围的:Java 的基本类型、String 和 CharSequence、List 和 Map(里面的元素也得是支持的类型)、实现了 Parcelable 的自定义对象,以及其他的 AIDL 接口本身。不支持的类型别硬塞,比如一个普通的自定义类不实现 Parcelable 就传不过去,编译阶段就会报错。这个限制背后还是那个原因:跨进程传的是字节,不是对象引用,任何东西都得先能序列化。
把这几种跨进程方式按轻重排一下会更清楚:最简单的场景用 Intent 传参就够了;需要双向发消息但不想写接口的,用 Messenger,它内部其实就是基于 AIDL 封装的;只有当你需要多个方法、需要同步返回值、需要并发调用的时候,才值得上 AIDL 这套重型装备。上手就选最重的那个,往往是因为没想清楚需求。
顺带说一个常被追问的概念:Dalvik 的进程、Linux 的进程、应用程序的进程是不是同一个概念?答案是可以认为是同一个。每个 Android 应用程序都在它自己的进程中运行,都拥有一个独立的 Dalvik 虚拟机实例,而每一个虚拟机实例在 Linux 看来就是一个进程。所以站在应用层的角度叫「应用进程」,站在内核的角度叫「Linux 进程」,说的是同一件事的两个视角。
4. 布局、存储与共享:那些「看起来会背就行」的题其实在考边界
这一块的知识点有个共同特点:单独问哪个都能答两句,一旦追问「什么情况下不该用它」,就露馅了。布局、存储、数据共享,本质上都是在做选择,而选择的前提是知道每个选项的边界在哪。
4.1 五种布局与 View 刷新、ListView 优化
常被问到的五种布局,各自解决的问题不一样:
| 布局 | 排列方式 | 适用与现状 |
|---|---|---|
| LinearLayout | 按水平或垂直方向依次排列 | 结构简单、层级清晰,最常用 |
| RelativeLayout | 按相对位置定位,如「在某个控件下方」 | 适合复杂对齐,但层级深了测量开销大 |
| FrameLayout | 层叠放置,后加的盖在前面 | 适合叠加元素,如 loading 遮罩 |
| TableLayout | 按行列组织 | 适合规整的表单式界面 |
| AbsoluteLayout | 按绝对坐标定位 | 屏幕适配性差,现在基本不用了 |
这份清单真正想考的是「你会不会选」。一层 LinearLayout 能搞定的,别硬用 RelativeLayout 堆;RelativeLayout 里套 RelativeLayout,测量次数会翻倍,滑动就卡。布局优化的核心思路一直是减少层级,而不是记住哪个布局性能最好。
View 的刷新也是同一类问题。在需要刷新的地方用 Handler 发送消息,然后在 Handler 处理消息时执行 invalidate 或者 postInvalidate。这两个方法的区别在于调用线程:invalidate 要在主线程调用,postInvalidate 可以在子线程调用,它内部会帮你把这件事转回主线程。选错了不会立刻崩,但会出现「偶尔不刷新」这种最难查的现象。
ListView 的优化是面试里的常客,核心就一句话:别在滑动过程中反复创建对象。具体做法是复用 convertView,再用一个静态的 ViewHolder 把子控件的引用缓存起来,省掉每次 getView 都去 findViewById 的开销。
@Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { // 只有第一次才真正加载布局文件,之后都靠复用 convertView = inflater.inflate(R.layout.item_user, parent, false); holder = new ViewHolder(); holder.name = convertView.findViewById(R.id.name); convertView.setTag(holder); // 把子控件引用挂在行布局上 } else { holder = (ViewHolder) convertView.getTag(); // 复用,跳过 findViewById } holder.name.setText(items.get(position).name); return convertView; } // 静态内部类:不持有外部 Adapter 的隐式引用,避免内存泄漏 static class ViewHolder { TextView name; }convertView 是系统回收的旧行布局,滑出屏幕的行会被拿回来重用,所以它的类型必须判断,为 null 说明手里没有存货,得新建。setTag 和 getTag 是配套的,把引用存进去再取出来,中间不要写错 key。ViewHolder 声明成静态内部类这一点也别省,非静态内部类会隐式持有外部类的引用,配合 Handler 或者延时任务,很容易演变成内存泄漏。
除了复用,还有两条经验:一是 getView 里别做耗时操作,图片解码、字符串拼接这类活儿要么提前做好,要么异步加载;二是数据量大时用延迟加载或者分页,一次把几千条数据全塞进去,再好的复用也救不回来。至于「怎么让 ListView 滚得更快」这种问题,答案往往不在代码里,而在数据准备阶段。
4.2 数据存储的五个层级
Android 给数据留了五个层次,选哪个取决于三件事:数据量、是否结构化、要不要给别人用。
| 方式 | 存什么 | 特点 |
|---|---|---|
| SharedPreferences | 少量键值对,如开关状态、用户偏好 | 轻量,读写简单,不适合大量数据 |
| Internal Storage | 应用私有文件 | 存在应用沙箱内,其他应用访问不到 |
| External Storage | 较大的文件,如图片、缓存 | 空间大,但可用性和权限受外部环境影响 |
| SQLite Database | 结构化数据,需要查询和关联 | 关系型数据库,支持 SQL,适合列表类数据 |
| ContentProvider | 跨应用共享的数据 | 本身是共享机制,对外提供统一的访问接口 |
前四种是「存储」,第五种是「共享」,这个区别很关键,第 5 章会专门展开。这里先记住一个判断顺序:先问数据有多大,再问要不要查,最后问要不要给别人。三个问题答完,基本就剩下一个选项了。轻量配置塞数据库是杀鸡用牛刀,几百条结构化记录塞 SharedPreferences 则是自找麻烦,序列化和反序列化的开销比查询本身还大。
顺带说一个常被归到「资源」里的知识点:国际化与本地化。国际化简称 i18n,因为 internationalization 这个词在 i 和 n 之间还有 18 个字符;本地化简称 L10n,同理。Android 没有专门的国际化 API,而是靠对不同 resource 的命名来达成,同时这种命名方法还能用来区分硬件,比如不同屏幕用不同的图片。
具体做法是:res 目录下默认的 values 放一套字符串,再建 values-zh-rCN、values-zh-rTW 这样的目录,各放一份 strings.xml,里面字符串的 name 和 id 完全一样,只有内容不同。代码里统一写 R.string.main_title 这种引用,或者布局里写 @string/hello,程序运行时 Resource 类会根据手机当前的设置去挑该加载哪一份。挑的依据包括语言和地区、屏幕尺寸和方向、像素密度、触摸屏类型、SDK 版本等等。目录名必须全小写,不同选项之间用短横线连接,顺序也有约定,写错一个字母在某些系统上就会找不到资源,而且不一定报错,只是默默显示了默认值。
4.3 ContentProvider 与权限:跨应用共享的边界
ContentProvider 解决的是「数据在别人手里」的问题。比如你想读联系人,那份数据不属于你的应用,你又不能直接去翻别人的数据库文件,怎么办?ContentProvider 就是那个开在墙上的窗口:数据拥有方决定开几个窗口、窗口能办哪些事,访问方拿着一个 Uri 来办事,办完走人,从头到尾没进过仓库。
实现上,它对外提供增删改查四类操作,内部可以包着 SQLite,也可以包着文件,甚至可以是网络数据,访问方不需要知道。Uri 相当于单号,形如 content:// 开头的一段地址,标识要访问哪张表、哪条记录。权限方面,提供方可以分别声明读权限和写权限,也可以只对某些路径开放,访问方则在清单文件里申请对应的权限。这种设计的好处是边界清晰:能做什么写在窗口上,而不是靠彼此信任。
与之配套的是权限体系。面试题里有一道「程序运行时权限与文件系统权限的区别」,问的就是这个。运行时权限是应用向系统申请的一种能力授权,比如用相机、读通讯录,用户在运行过程中可以授予也可以撤销,它管的是「你这个应用能不能使用某项系统能力」。文件系统权限则是 Linux 那一套,基于用户、用户组和读写执行位,它管的是「这个进程能不能动这个文件」。应用沙箱的本质,就是给每个应用分配一个独立的用户身份,你的私有目录别人按文件权限就进不来,所以两套机制解决的是两个层面的问题,混在一起答就会显得没想清楚。
5. 常见误区:这几个 Android 面试概念最容易张冠李戴
背下来的知识最容易在追问下散架,因为很多概念名字相近、场景相邻,光看定义分不出来。下面这五组是踩坑率最高的,每一组都按「容易混在哪、为什么会混、怎么区分」来拆。
5.1 Service 不是线程,它只是没有界面
易混点:很多人把「后台运行」直接等同于「在另一个线程里跑」,于是把网络请求、大文件解析往 Service 里一丢,觉得主线程就轻松了。
为什么会混:Service 的中文解释里常出现「后台」两个字,而日常语境中「后台」总带着「另开一处悄悄干活」的意味。加上 Service 确实能在界面退出后继续存在,就更像一条独立的执行线了。
怎么区分:Service 运行在它所属应用的主线程里,它和 Activity 是同一个线程,只是没有界面。真正的耗时操作要自己在 Service 里开子线程或者用线程池,否则一样会卡住界面甚至触发无响应。判断方法很简单:问一句「这段代码在哪个线程执行」,答案永远是「创建它的那个进程的主线程」,除非你显式地换了线程。Service 的价值在于生命周期和优先级,不在于并发。
5.2 onPause 存数据和 onSaveInstanceState 不是一回事
易混点:两道题都叫「保存状态」,于是有人把它们当成同一个动作的两种写法,随便挑一个用。
为什么会混:它们触发的时机确实挨得很近,都发生在界面即将离开视线之前,代码里看起来都是往某个容器里塞键值对。
怎么区分:onPause 是流程回调,你做的是业务层面的持久化,比如把草稿写进数据库、把播放进度存到文件,目的是「下次打开还在」;onSaveInstanceState 是系统给的临时容器,你放进去的东西只在这一次重建中被还回来,目的是「刚才那一眼看到的样子别丢」。前者要可靠,后者要轻量。反过来的错误也常见:在 onSaveInstanceState 里写数据库,或者在 onPause 里塞一个巨大的 Bundle,两种做法都会在真机上出问题。
5.3 ContentProvider 是共享机制,不是第五种存储
易混点:在列「Android 数据存储方式」的时候,很多人把 ContentProvider 和 SharedPreferences、文件、SQLite 并列成五种存储,答完就完了。
为什么会混:几乎所有资料都是这么并列着写的,五个名词排在一起,自然被当成同一层级的东西。
怎么区分:前四种回答的是「数据放在哪」,ContentProvider 回答的是「数据怎么给别人用」。它自己不规定底层用什么,内部完全可以是 SQLite 也可以是文件。所以正确的表述是四种存储方式加一种共享机制。这个区分在实际开发里很实用:当你需要跨应用读数据时,想到的应该是 ContentProvider;当你要存自己的数据时,它根本不在候选列表里。
5.4 Handler 不是线程,它只是一座桥
易混点:看到「Handler 用于线程间通信」,就以为新建一个 Handler 就等于开了一个新线程,或者以为在 Handler 里执行的代码一定在主线程。
为什么会混:Handler 总是和「子线程做完事回来更新界面」这个场景绑在一起出现,久而久之,桥和被桥连接的两岸就被混成一体了。
怎么区分:Handler 自己不产生线程,它绑定的是某个 Looper,而 Looper 属于某个线程。消息最终在哪个线程执行,完全取决于构造 Handler 时用的是哪个 Looper。用 Looper.getMainLooper() 构造,代码回主线程;用 Looper.myLooper() 在工作线程里构造,代码就留在工作线程。判断一个 Handler 在哪跑,看它的 Looper 就行,这是排查线程问题最快的一刀。另外,工作线程里构造 Handler 之前必须调用 Looper.prepare,否则会直接抛异常,这一点在第 3 章已经提过,但仍然是新手最常撞的一堵墙。
5.5 运行时权限和文件系统权限,管的是两件事
易混点:两道题都带「权限」二字,答案就写成一锅粥,把申请相机权限和读写文件权限混着说。
为什么会混:在用户视角里,它们都表现为「弹一个框问你能不能干这件事」,体验上确实很像。
怎么区分:运行时权限是应用向系统申请的能力授权,颗粒度是「某项系统能力」,用户可以随时在设置里关掉,关掉之后应用再调用相关接口就会失败。文件系统权限是操作系统层面的访问控制,基于用户身份和读写执行位,它不需要弹框,而是直接以「能不能打开这个路径」的形式生效。应用沙箱正是靠后者实现的:每个应用一个独立身份,你的私有目录别人按文件权限就进不来,所以很多「为什么读不到别人应用的文件」的问题,答案在文件系统权限,而不是运行时权限。
6. 什么时候该翻这份题集,什么时候别翻:给不同阶段的边界判断
说了这么多概念,最后落到一个很实际的问题上:这份 Android 面试试题及答案,到底该怎么用,什么时候用不上。
先说什么情况适合翻。第一种是准备面试前的系统梳理期。你已经写过一些界面,但知识点是散的,这时候按组件、线程、数据三条线过一遍题集,能很快发现自己的窟窿在哪。第二种是被单点问题卡住的时候,比如「onSaveInstanceState 和 onPause 到底啥区别」这种,带着具体疑问去查,比从头到尾刷一遍效率高得多。第三种是自查。题集里那些你答得含糊的题目,往往就是下一次面试会被追问的地方。
再说什么时候别翻。如果你连一个能跑起来的界面都没写过,直接背答案,效果接近于背字典学说话,面试官追问两句就露馅。如果你已经在做实际项目,只靠刷题不回头看自己写的代码,也会停在「知道」而不是「会用」的层面。还有一种情况值得警惕:把题集当成知识全图。它覆盖的是高频考点,不是完整体系,系统架构分层、内存管理、动画实现这些内容在里面只是零星出现,指望它补齐全部认知不现实。
按用途分一下会更清楚:
| 你的处境 | 建议怎么用 |
|---|---|
| 完全没接触过 Android | 先把界面和四大组件跑通,题集当索引,别当教材 |
| 写过一些小项目,准备面试 | 按章节过一遍,重点看自己答不上来的部分 |
| 工作两三年,想查漏 | 只看误区类和边界类题目,其余跳过 |
| 只想知道某个概念是什么 | 直接定位到对应题目,读完就停,不用通读 |
有个判断标准挺好用:读完一道题,如果你能用自己的话把它讲给一个不懂的人听,并且能举出一个「什么时候不该这么用」的例子,这道题才算过。做不到,说明还停在记忆层。
我自己有过一次教训。早些年准备面试,把生命周期那七个方法背得滚瓜烂熟,自以为稳了,结果对面问「半透明主题的界面盖上来,旧界面走哪几个回调」,当场卡住。那次之后我改了个习惯:每准备一个知识点,都强制自己走一遍「如果我把它用错,会发生什么」,答不上来就说明没真懂。这个习惯一直留到现在,比任何一份答案都管用。希望帮到你。
本文还有配套的精品资源,点击获取