1. 先说说这场笔试的整体情况
1.1 考试形式与时间分配
先交代一下背景。这套题是2023年秋招提前批的第一批,也就是七月底八月初那会儿发的卷子。当时很多同学还在刷算法题,Android专项准备不足,就慌忙上了考场。整个笔试一共120分钟,题目分四个模块:单选(20题)、多选(10题)、简答(3题)、编程(2题)。总分100分,单选和多选各占30分,简答20分,编程20分。从分数占比能看出来,这家公司其实不太想靠选择题筛人,真正的分水岭在简答和编程。
选择题部分是典型的“题量大、时间紧”,平均每题只有不到两分钟。其中Android基础占了一半以上,涉及Activity启动模式、Handler机制、Binder通信、View绘制流程、进程间通信方式等。这些题目本身不算难,但出题人会在选项里埋很多“看似正确实则偷换概念”的陷阱。比如有一道题问“singleTask启动模式下,Activity的onNewIntent回调一定在onStart之前执行吗”,如果你只背过启动模式的概念,而不了解onNewIntent和生命周期方法之间的调用时序,这题基本就挂了。
多选则更狠,错选、漏选都不得分,完全不给蒙的机会。我记得有一道多选考的是“哪些情况会导致内存泄漏”,选项里出现了“静态变量持有Activity引用”“Handler未移除Message”“单例持有Context”“ViewModel持有Activity引用”四个选项。前三个自然是正确答案,但最后一个其实也正确,因为ViewModel的生命周期一般跟随宿主,如果你在ViewModel里持有Activity引用且没有在onCleared中释放,同样会泄漏。这种题考的就是你在真实项目里有没有踩过坑。
1.2 题目难易梯度与区分度
整套题的难度分布呈“低-中-高”阶梯式上升。前面十道单选基本是送分题,比如“四大组件有哪些”“IntentFilter的作用是什么”,这部分只要系统学过Android基础就能答对。但到了第十题往后,难度明显上来,开始考源码层面的东西,比如“ViewRootImpl在performTraversals中如何调度measure、layout、draw”,这种题如果没有真正读过Framework源码,大概率只能靠猜。
简答题属于典型的“会者不难、难者不会”。三道题分别考Handler机制、Activity启动流程和内存泄漏定位。题目看起来都很常规,但阅卷标准极其看重层次感。你如果只回答“Handler是用于线程间通信的”,对不起,这道题你只能拿及格分。你必须把Looper、MessageQueue、Message、Handler四者之间的关系讲清楚,并说明为什么主线程的Looper不需要主动调用Looper.loop()之后还能持续运行,以及epoll机制在其中的作用。只有把这些细节串联起来,才能踩中得分点。
编程题没有出偏题怪题,一道是二叉树的层序遍历,另一道是实现线程安全的LRU缓存。但第二道题加了一个Android场景的限制条件:要求在内存受限环境下,缓存容量不能超过给定阈值,并且当超出阈值时要按最近最少使用策略淘汰。这个限制非常贴近实际业务,毕竟移动端的缓存管理和服务端不太一样,你不能像后端一样无限扩容,必须在有限内存里做取舍。
2. 按题型拆解核心考点
2.1 选择题:最容易被阴的细节
选择题覆盖范围很广,但核心集中在四个方向:Java基础、Android四大组件、View体系、并发与内存管理。其中Android四大组件相关题目最多,差不多占了8道。考法也比较固定,基本都是围绕生命周期、启动模式、通信方式来出题。
我印象最深的一道单选是:“在onSaveInstanceState中保存的数据,一定能在onCreate的savedInstanceState参数中恢复吗?”答案是“不一定”。因为onSaveInstanceState只有在系统因配置变更或资源回收而销毁Activity时才会被调用,如果用户主动按返回键退出,系统不会调用它,自然也就没有数据可以恢复。这道题如果你只是看过生命周期图而不理解每种销毁场景的区别,很容易选错。
还有一道关于Binder的选择题也很典型:“Binder通信中,一次完整调用发生了几次内存拷贝?”答案是“一次”。传统IPC需要两次拷贝,Binder通过mmap映射只需要在内核空间和用户空间之间拷贝一次。这道题是Framework面试的经典考点,但在笔试里出现,说明这家公司确实想招对底层原理有了解的人。
关于Java基础的部分,主要考了HashMap的底层实现、synchronized和ReentrantLock的区别、泛型擦除、注解的保留策略等。这些内容不像Android题目那么有场景感,但也是在选择题里区分基础是否扎实的重要手段。有一个多选问“哪些集合是线程安全的”,选项包括Hashtable、ConcurrentHashMap、CopyOnWriteArrayList、Collections.synchronizedList包装后的List,四个选项全部正确。如果你平时只用过ConcurrentHashMap而对其他几个线程安全集合不熟悉,很容易漏选。
2.2 简答题:考的是源码深度
简答题只有三道,但每一道都能拉开差距。第一道问Handler机制,第二道问从点击桌面图标到Activity显示经历了哪些关键过程,第三道问如何定位和解决内存泄漏。这三道题分别对应消息机制、系统启动流程和性能优化,几乎覆盖了Android研发岗最核心的三个能力维度。
第二道题特别值得说道说道。“从点击桌面图标到Activity显示,经历了哪些过程”这道题的完整答案应该包含这样几个层次:Launcher进程通过Binder通知SystemServer进程中的ActivityManagerService启动目标Activity;AMS检查Activity是否已存在、是否需要创建进程;如果需要创建进程,通过Zygote进程fork出应用进程;应用进程入口是ActivityThread.main方法;ActivityThread通过Binder向AMS报告进程已就绪;AMS通知ActivityThread创建并启动Activity;Activity在onCreate中通过setContentView加载布局,经过measure、layout、draw三个流程后,ViewRootImpl将绘制结果提交到SurfaceFlinger合成显示。整个链路涉及Launcher、AMS、Zygote、ActivityThread、ViewRootImpl、SurfaceFlinger六个关键角色。
大部分面试者能答出“AMS通知Zygote创建进程”和“Activity执行onCreate”这两步,但会漏掉“ActivityThread通过Binder向AMS报告”这个关键环节。漏掉这一点,说明你对App冷启动流程的理解停留在表面,不清楚应用进程和应用代码初始化之间的桥梁是谁搭起来的。在笔试里这种隐含的“递进式追问”就是用来筛掉只背答案的选手的。
第三道内存泄漏题相对常规,只要答出LeakCanary、Android Profiler、MAT这三种工具,以及静态引用、Handler、匿名内部类、资源未关闭这几个常见泄漏场景,基本能拿满分。但如果你能额外写出“通过占用内存对象快照对比定位泄漏点”的方法,那会给阅卷官留下不错的印象,觉得你确实在项目里做过内存分析。
2.3 编程题:算法与Android场景题并重
两道编程题,一轻一重,一算法一工程。第一道二叉树层序遍历是LeetCode原题,属于“送分题”级别的经典题。但要注意的是,笔试平台要求实现BFS并返回List<List >,如果你平时习惯了用递归DFS写法,现场改成队列迭代得花几分钟适应。我当时写这道题只用了不到五分钟,但为了保险,还是加了一个注释说明“非递归版本,使用Queue实现层级遍历”,让阅卷人能看到你的思路。
第二道LRU缓存则是典型的“披着Android外衣的算法题”。题目要求实现一个线程安全的LRU缓存,容量固定,当put超过容量时淘汰最近最少使用的key。这个题在Java层面就是LinkedHashMap加synchronized的问题,但难点在于题目额外要求“并发访问时get和put都须保持线程安全”。这意味着你不仅要实现LRU逻辑,还要考虑锁的粒度。如果直接在get和put方法上加synchronized,性能会很差;更优的做法是用ConcurrentHashMap保证键值存取线程安全,同时用读写锁保护双向链表的修改。如果能答出“LinkedHashMap + synchronized”和“ConcurrentHashMap + 读写锁 + 手动链表”两种方案,并说明各自的适用场景和性能差异,这道题基本就稳了。
3. 五道典型真题的复盘与解题思路
3.1 编程题:二叉树层序遍历
原题大致是:
给定一个二叉树,返回其节点值的自底向上的层序遍历。即按从叶子层到根层的顺序,逐层从左向右遍历。
我是这样写的:
class Solution { public List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> result = new ArrayList<>(); if (root == null) return result; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { int size = queue.size(); List<Integer> level = new ArrayList<>(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); level.add(node.val); if (node.left != null) queue.offer(node.left); if (node.right != null) queue.offer(node.right); } result.add(level); } // 如果题目要求自底向上,就 Collections.reverse(result); return result; } }注意代码里的size变量必须先取出来,因为queue.size()在循环过程中会变化。很多人在白板编程时容易忽略这一点,导致内层循环边界错误。这类细节虽小,但在笔试平台的用例测试里会直接暴露。
3.2 编程题:线程安全LRU缓存
第二道题要求实现线程安全LRU缓存,完整题目描述是这样的:
设计一个LRU缓存类,支持get和put操作。构造时传入缓存容量capacity。get(key)返回value,不存在返回-1。put(key, value)若key已存在则更新value,否则插入。缓存满时淘汰最近最少使用的键。所有方法必须线程安全。
我当时是用LinkedHashMap + synchronized实现的,因为LinkedHashMap自带accessOrder模式,可以在get时把被访问的节点移动到链表尾部,天然满足LRU语义。但笔试平台按用例判分,所以如果你需要在非同步环境下追求更高并发度,可以考虑ConcurrentHashMap + 手动双向链表 + ReentrantReadWriteLock的方案。不过考虑到笔试时间有限,LinkedHashMap这种“一行代码搞定LRU”的方案在可读性和正确性上都更稳妥。
public class LRUCache { private final int capacity; private final Map<Integer, Integer> map; public LRUCache(int capacity) { this.capacity = capacity; this.map = new LinkedHashMap<Integer, Integer>(capacity, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) { return size() > capacity; } }; } public synchronized int get(int key) { return map.getOrDefault(key, -1); } public synchronized void put(int key, int value) { map.put(key, value); } }关于采用“LinkedHashMap + synchronized”这种方案,我需要说明一点:这属于笔试场景下兼顾正确性与时间成本的选择。它能够满足题目“线程安全”的要求,而且在代码审查时也好解释。但如果你在正式项目里遇到高并发场景,synchronized会让所有读操作串行化,成为性能瓶颈。到时候你再换成分段锁或者基于LongAdder的统计方案也不迟。
3.3 简答题:Handler机制的完整回答
简答题第一道“简述Handler机制”的完整答案,我在这里直接给你示范一下怎么组织逻辑。先总述Handler机制是Android消息处理的核心框架,由Looper、MessageQueue、Handler、Message四个核心类组成;然后依次说明每个类的职责。重点在Looper.loop()的循环判断上,为什么主线程looper无限循环却不会卡死UI。这里要提到主线程的MessageQueue在无消息时会触发epoll wait,进入阻塞状态让出CPU,等到有消息到来时才被唤醒,所以即使无限循环也不会造成CPU空转。
再往下要讲清楚线程和Looper的绑定关系。主线程在ActivityThread.main方法中通过Looper.prepareMainLooper()初始化Looper,子线程则需要手动调用Looper.prepare()和Looper.loop()。Handler在构造时通过Looper.myLooper()获取当前线程的Looper,从而将handler与特定线程绑定。这个“handler与创建它的线程绑定”是关键,很多人误以为handler属于主线程,其实handler属于创建它的线程。
最后补充内存泄漏的隐患。非静态内部类Handler默认持有外部Activity的强引用,当handler有延迟消息未处理时,Activity占用的内存无法被回收。解决办法是把Handler定义成静态内部类,并在onDestroy中调用removeCallbacksAndMessages(null)移除所有消息。把这个层次写到简答题里,基本上这道题可以拿满分。
3.4 简答题:从点击图标到Activity显示
这道题是整套卷子里最能体现“源码深度”的一道题。完整回答分六步:
第一,用户点击Launcher上的图标,Launcher进程通过Binder调用ActivityTaskManagerService.startActivity。
第二,ATMS里经过一系列Intent解析,找到目标Activity所在的进程是否存在。如果进程不存在,则向Zygote进程发送创建进程的请求。
第三,Zygote通过fork创建新的应用进程,进程入口是ActivityThread.main。ActivityThread.main做的事情非常关键:创建主线程Looper,同时调用Looper.loop()开启消息循环。
第四,ActivityThread在main方法中通过ActivityManagerService的Binder接口注册到AMS,告知进程已就绪。
第五,AMS通过Binder告诉ActivityThread,现在可以创建Activity了。ActivityThread通过Instrumentation反射创建Activity实例,然后启动生命周期。
第六,onCreate中setContentView解析布局,交给ViewRootImpl执行performTraversals,完成测量、布局、绘制,最终通过SurfaceFlinger合成上屏。
每一步之间都有明确的Binder调用关系。如果能把“ActivityManagerService”和“ActivityTaskManagerService”的区别说清楚(ATMS是Android 10之后从AMS中拆分出来的,专门负责ActivityTask管理),那会更加分。这个点在笔试时很少有人能答出来,因为大多数资料还停留在老的AMS名称。
3.5 Kotlin协程题:生命周期与调度
这套卷子里没有直接出Kotlin协程的大题,但在多选题里出现了几道与协程相关的题目。比如“关于协程的调度器,以下说法正确的是”,选项涉及Dispatchers.Main、Dispatchers.IO和Dispatchers.Default的区别。这里有一个很容易搞混的点:Dispatchers.IO用于网络和磁盘IO操作,Dispatchers.Default用于CPU密集型计算,而Dispatchers.Main用于更新UI。三者之间有大小不同的线程池,切换调度器不会阻塞当前线程。
另外一道多选问“协程的launch和async有什么区别”,考察点在于launch返回Job、async返回Deferred,async通过await获取返回值。如果你在项目里用协程做过网络请求封装,这题就是送分的。但如果只是熟悉基础语法而没真正写过协程,容易把两者的返回类型搞错。字节跳动的面试风格比较偏爱Runtime和C++底层,但这套笔试题明显更偏业务开发,说明这家公司更看重你日常开发中对Kotlin的熟练度。
4. 笔试现场踩过的坑与答题技巧
4.1 时间分配策略:先做编程题还是先做基础题
我不知道别人是怎么安排的,但我的建议是:拿到试卷后先花30秒扫一遍所有题目,然后立刻去做编程题。为什么?因为编程题除了正确性之外,还需要时间调试,而选择题如果一时想不起来可以先蒙一个答案标记一下,后面有时间再回来看。编程题如果放在最后做,一旦陷进去就来不及调整,而且心理压力会影响后面的答题质量。
我当时是在最开始十分钟内把二叉树层序遍历写完,确认用例通过后,才回头做前面的选择题。第二道LRU缓存题我留到简答题做完后,用大概四十分钟完成。整体节奏是前60分钟干掉所有基础题和简答,后面60分钟全部投入编程题。120分钟刚好用完,没有出现最后五分钟才匆忙交卷的情况。
如果你擅长算法题且刷题量足够,可以直接先攻编程题,用全神贯注的黄金时间把两道编程题先解出来,建立优势。如果算法底子一般,建议先花10-15分钟把选择题快速过一遍,把确定的答案填上,不确定的先标记,再回头花时间做简答题和编程题。
4.2 简答题的答题节奏与字数控制
简答题切忌长篇大论。每道题控制在200到300字,但必须分点作答。我见过很多同学在“Handler机制”这道题上写了整整一页A4纸,从Linux epoll原理讲到Android消息池复用,虽然知识面很广,但核心得分点反而被淹没在大量细节里。阅卷人的精力有限,你需要在每个得分点上用关键词打头,比如“Looper.prepare()”“Looper.loop()”“MessageQueue.next()”“epoll挂起”,在这些关键词后面展开一两句说明即可。
用“段落+关键词前置”的格式来组织答案很稳妥,比如第一句直接写“Handler机制的核心是Looper不断从MessageQueue中取出消息并分发”,然后分条展开。这种回答方式既能让阅卷人快速抓到重点,也方便你自己控制篇幅。
简答题部分如果时间充裕,可以适当补充一些“进阶内容”作为加分项。比如在Handler机制的答案末尾加一句“主线程的Looper在ActivityThread.main中通过prepareMainLooper初始化,而子线程Looper需要手动调用prepare和loop”,这不算凑字数,而是展示你真的理解Looper和线程的绑定关系。
4.3 编程题的环境与IDE问题
笔试平台一般用牛客或者赛码,这两个平台的代码编辑器在Android语法高亮方面支持并不算好。更麻烦的是,这两个平台默认支持的Java版本可能只到Java 8,如果你在本地一直用Java 11以上的新语法(比如var关键字),在平台上可能会直接编译报错。
第二个常见的坑是输入输出格式。LeetCode让你直接实现核心函数,但笔试平台很多题目需要自己处理输入输出。比如二叉树层序遍历,LeetCode版本直接给我们TreeNode根节点,但笔试平台可能会用一维字符串表示树,比如“1,2,3,null,null,4,5”,需要你先解析字符串,手动构建二叉树,再实现层序遍历。这一步解析逻辑很容易出错,建议提前熟悉“根据层序序列构建二叉树”的代码模板。
第三个坑是自动保存问题。我在牛客上做过几次笔试,遇到过代码写完后系统提示“草稿未保存”的情况。建议每写完一个函数就手动点一次保存,不要把所有代码写完再保存。虽然大多数平台支持自动保存,但自动保存有延迟,万一浏览器意外关闭或网络断开,代码可能丢失。
4.4 心态和取舍原则
第一批笔试通常不会直接给offer,更多是拿到后续面试的入场券,因此你要确保的是“过线”而不是“满分”。如果某道选择题你完全没思路,果断选一个看起来最合理的答案并标记,不要在上面停留超过一分钟。同样,如果简答题某道题只能答出一个层面,那就只写这个层面,不要硬撑篇幅,以免把确定的内容写错。
我在做题时给自己定了一个原则:编程题如果想了十五分钟还没有完整思路,立刻切换到简单题模式,先写出暴力解法,确保用例能过一部分,拿部分分。笔试平台通常是按通过的测试用例比例给分的,暴力解法能拿到30%到50%的分数,比交白卷强得多。
5. 针对这份笔试,怎么准备才有效
5.1 Framework源码要读到什么程度
笔试里考到的Framework内容,主要集中在Handler机制、Activity启动流程、AMS和Binder这四块。不要上来就想读全所有源码,那很容易劝退。我给你的建议是“由点到面”:先把Handler机制的Java层源码完整读一遍,从Handler发送消息到Looper取出消息再到dispatchMessage,整个过程一定要手画出时序图,把方法调用关系理清楚。然后读Activity启动流程,跟着startActivity方法一路往下走,不管中间经过多少层Binder调用,先记住主链路。
启动流程这块,2023年之后不少资料已经更新为ATMS(ActivityTaskManagerService),因为从Android 10开始,Activity任务管理从AMS中拆分到了ATMS。笔试如果考启动流程,你主动写出ATMS这个名称会比只写AMS要加分,它能说明你关注过较新版本的源码变化。
5.2 算法题怎么刷才有效
笔试的算法题难度基本集中在LeetCode中等题。如果你时间有限,优先刷这些高频题:二叉树遍历(前中后序、层序)、二叉树的最近公共祖先、链表反转、环形链表、最长不重复子串、最长回文子串、接雨水、LRU缓存、三数之和、岛屿数量、两个栈实现队列、手撕快速排序、二分查找变体、动态规划入门类(爬楼梯、打家劫舍、最长递增子序列)。
其中LRU缓存几乎是大厂的标配,不管笔试面试都可能出现,值得单独拿出来练习。建议你分别用LinkedHashMap和手写双向链表各写一遍,两套解法都能在白板上流畅写出来,才算真正掌握。
5.3 项目经验怎么在笔试里体现
笔试题虽然不直接考项目,但简答题的“加分项”往往来自项目的真实经验。比如内存泄漏那道题,如果你在项目里使用过LeakCanary,并且遇到过某个版本库导致的泄漏,随手就能举出具体的泄漏类型和修复方案。这类实战案例比干巴巴背“Activity泄漏有四种场景”要生动得多。
我建议每位参加秋招的同学都准备一段“我在项目里做过什么优化”的叙述性文字,包括优化前的问题数据、优化手段、优化后的效果。笔试简答题虽然没有直接问项目,但遇到“如何定位和解决XXX问题”这类问题时,项目经验就是你和其他候选人拉开差距的关键。
5.4 2023年特有的新技术点
2023年的Android技术栈相比前两年有明显变化。如果你是准备提前批,除了常规的Java和Android基础外,建议关注以下几个方向:
Kotlin协程已经成为必考,重点要看结构化并发、Job和CoroutineScope的关系、Flow的冷流与热流、withContext的线程切换成本。
Jetpack Compose虽然还没到“每个公司笔试都考”的程度,但很多公司在笔试里会出关于状态提升、重组范围、remember和rememberSaveable的区别这类选择题,这些内容即使没写过Compose,花半天看文档也能应付。
性能优化方向的热度一直很高,2023年尤为突出的是启动优化和包体积优化。笔试可能会通过选择题形式考你,比如“哪些方式能减少APK体积”“R8和ProGuard的区别”。如果你对R8的工作流程(shrink、optimize、obfuscate、preverify)有基本了解,这些题目不会难住你。
5.5 关于车载、OTA、AMS等进阶方向
如果你关注过2023年的招聘信息,会看到很多车企和硬件公司在大量招聘Android Framework工程师,他们在笔试中会更侧重AMS、OTA、蓝牙、蓝牙协议栈、OpenOCD调试这些方向。这批笔试虽然还是标准的通用Android技术栈,但在简答题里出现了AMS相关题目,说明公司可能在techlead层面就有OS定制类的项目。
具体来说,如果你投递的岗位偏向“车载方向”,笔试大概率会增加这几个考点:设备OTA升级流程、系统分区和动态分区、A/B分区的差异、如何通过OpenOCD在Android设备上连接JTAG调试、蓝牙A2DP和HFP协议的基本差异。这些内容没有实际参与过项目很难答好,如果你是非资深Android工程师,不建议笔试时硬写,猜到哪写到哪反而会让面试官觉得你不严谨。
其实我最想说的是,这张卷子整体并不难,难的是在有限时间内把每个知识点都答到得分点上。基础题所有人都能答,真正决定你能不能进面试的,是简答题里有没有写出“ActivityThread.main中初始化Looper”这样的细节,以及编程题第二题的实现有没有考虑线程安全。这些细节不是靠考前突击能记住的,更多来自平时写代码时的积累。如果时间重新来一次,我会在准备笔试时多花两周读Handler和AMS源码的Java层实现,而不是把时间全花在刷LeetCode简单题上。