爱奇艺安卓秋招笔试复盘:从Java集合到Binder原理

爱奇艺安卓秋招笔试复盘:从Java集合到Binder原理 2018年9月我坐在宿舍的电脑前完成了爱奇艺秋季校招Android工程师的第一场在线笔试。那是整个秋招我投出的第一批简历也是我第一次正经接触大厂的在线笔试系统。两个小时后交卷对着错题记录翻来覆去地看我才意识到一件事自己过去两年对Android的理解大部分停留在“会用”而不是“懂原理”。为了不让这段经历白费我把这场笔试能回忆起来的考点、当时的作答思路、以及后来查漏补缺时补上的知识链条完整做了一个复盘。这篇文章就是整理后的结果适合准备Android校招、或者正处在求职节骨眼上的同学。不管你是刚开始刷题还是已经进入笔试阶段希望这份复盘能帮你少走一些弯路。1. 2018年秋招的“第一场”笔试到底意味着什么1.1 为什么我会对这场笔试记忆深刻秋招笔试和平时自己做题完全是两码事。自己做题的时候做错了可以查资料、可以慢慢想在线笔试不一样它是限时、限环境、还有实时监控的。我记得爱奇艺这场笔试的时间是晚上七点到九点两个小时的窗口期题目类型很全选择题、问答题、手写代码题最后还有一道开放设计题。第一场这个位置很特殊。它是秋招大部队正式开跑的信号也意味着很多人的状态其实还没调整到位。我当时就是这样暑期实习结束之后一直处于松懈状态投简历投得不算晚但知识体系还是零散的。所以这场笔试对我的最大意义不是拿到了什么结果而是把自己从“佛系刷题”的状态里拽了出来。如果你是准备投校招的同学我建议把所有公司里时间最早的那场笔试当作一次全真模拟不要指望它出分有多好看但一定要认真对待。它暴露出来的问题往往就是你接下来一个月复习的优先级清单。1.2 爱奇艺的技术栈属性视频App对Android工程师能力的独特要求复盘这场笔试之前我觉得有必要先搞清楚一件事对方到底想招什么样的人。爱奇艺App是典型的视频类应用它的核心业务场景是播放、弹幕、推荐流、会员体系这些场景决定了它对Android工程师的要求会和纯工具类、纯社交类App不完全一样。举几个例子。视频播放页涉及SurfaceView/TextureView选型、播放器内核对接、硬解软解切换、网络状态切换这些都需要对Android系统底层机制有较深理解长列表推荐流涉及RecyclerView的多ViewType复用、图片加载优化、内存抖动治理弹幕功能则涉及View层频繁刷新和主线程性能之间的平衡。所以我复盘这套题的时候能感觉到它虽然考的是通用基础点但出题方向明显偏向底层原理和性能优化而不是简单的API使用。这给后来备考的人一个启示投一家公司之前先分析一下它的产品形态。视频类公司大概率会问播放器相关的内存、Thread、Surface机制电商类公司大概率会问列表性能、图片加载、网络层封装社交类公司大概率会问IM长连接、消息推送、数据库设计。有针对性地准备比漫无目的地背八股有效得多。2. Java基础与Android基础题分数差距都藏在冷门细节里2.1 HashMap和ConcurrentHashMap从“背八股”到真正讲清线程安全Java基础部分几乎是每场Android笔试的家常菜但考得深不深完全是两回事。爱奇艺这场笔试的选择题里有一道让我印象很深考的是HashMap在JDK 1.8版本里的底层结构变化。选项围绕数组、链表、红黑树的组合来设陷阱看似简单实际考了三个层次的细节默认初始容量是16加载因子是0.75链表转红黑树的阈值是8红黑树退化为链表的阈值是6为什么转树要选8而不是16——因为泊松分布下链表长度达到8的概率已经极低。第二个坑是HashMap和ConcurrentHashMap的线程安全对比。很多人知道Hashtable线程安全、HashMap线程不安全却说不太清ConcurrentHashMap到底是怎么保证线程安全的。这里有一个很典型的版本差异JDK 1.7的ConcurrentHashMap用的是Segment分段锁锁粒度是每段一个JDK 1.8改成CAS配合synchronized锁住桶的首节点锁粒度更细并发度更高。笔试如果遇到这类题光答出“ConcurrentHashMap是线程安全的”肯定不够要把1.7和1.8的实现差异说清楚。我当时在这道题上栽过跟头因为复习的时候只背了结论没有真正理解链表转红黑树这个动作在并发场景下会出现什么问题——扩容时的线程安全问题、树化过程中对原链表的改变、size的统计方式这些都是面试官后续追问的好材料。后来我把HashMap源码完整过了一遍才发现之前背的很多结论根本经不起推敲。2.2 Activity生命周期与启动模式有一道题我至今记得Android基础题里Activity生命周期永远有一席之地。爱奇艺这道题的角度很刁钻问的不是常规的生命周期顺序而是Activity在异常销毁情况下onSaveInstanceState和onRestoreInstanceState的调用时机以及两个方法之间数据传递的差异。这道题考的是“懂不懂得用”和“懂不懂原理”的分界线。常规场景下生命周期顺序大家都会背onCreate到onDestroy、onStart到onStop、onResume到onPause。但异常场景比如屏幕旋转导致Activity重建系统会先调用onSaveInstanceState保存状态再走onPause、onStop、onDestroy最后重新创建Activity并调用onRestoreInstanceState恢复数据。这里有个细节onSaveInstanceState只在异常销毁时才会被调用正常返回栈销毁Activity时不会调用。我还记得另一道题是Activity启动模式。四种启动模式的标准答案谁都能背但爱奇艺把singleTask和singleInstance放在一个场景里考从Activity A启动singleTask的Activity BB再启动Activity C如果把C的launchMode设置为singleTask此时C所在的Task栈会发生什么。这个场景考的是TaskAffinity的概念——singleTask默认会寻找或创建与自身TaskAffinity相同Task而不是简单“回到B所在的Task”。如果不理解TaskAffinity机制这种多Activity嵌套启动模式的题基本靠蒙。2.3 四大组件里的Service和ContentProvider为什么不能只会“用”四大组件是Android笔试的固定板块但很多人的复习方式是把每个组件的生命周期背一遍就完事。爱奇艺这场笔试有一道Service相关的题问的是startService和bindService两种启动方式对Service生命周期的影响以及同一个Service先start后bind、先bind后start最后的停止流程分别是什么。这道题的常见坑点在于很多人知道startService对应onStartCommand、bindService对应onBind但不知道两种方式混用时stopService只会让Service进入Destroyed状态吗不是。绑定状态下调用stopService不会触发onDestroy必须解绑之后才停止。反过来如果Service被两个组件绑定先解绑一个Service依然存活要等所有绑定解除且没有startService状态时才会走onDestroy。这套逻辑在大学课程和普通教程里不太会细讲但在实际开发中非常常见。ContentProvider相关的题更偏向原理ContentProvider的onCreate和Application的onCreate谁先执行答案是ContentProvider的onCreate先于Application的onCreate因为ContentProvider的初始化发生在Application的attachBaseContext之后、onCreate之前。这个点我在实际项目里用到过——在ContentProvider里做SDK初始化在Application里做业务初始化它们之间是有严格先后顺序的。笔试出这道题考察的就是你对Android进程启动流程的理解深度。3. 源码深挖题Handler、Binder、RecyclerView缓存三层递进3.1 Handler消息机制主线程为什么不会死在loop里Handler的题目在Android面试里已经是“必考题中的必考题”爱奇艺这场也不例外。但它的问法比普通面试题更进一层我记得问的是Looper.loop()是一个死循环为什么主线程执行到这里不会卡死一个新线程里如果不调用Looper.prepare()就直接创建Handler会怎么样第一个问题答案的核心在于Linux的epoll机制。主线程的Looper在MessageQueue没有消息时会调用nativePollOnce进入阻塞等待此时主线程不会继续执行也不会消耗CPU资源。等到有消息写入MessageQueue系统通过pipe或eventfd机制唤醒主线程继续从nativePollOnce返回取出消息并分发。这就是为什么主线程可以一直“活”着同时又能响应触摸事件、刷新UI、处理消息。第二个问题更考察基础Handler在创建时会通过ThreadLocal获取当前线程的Looper如果当前线程没有调用Looper.prepare()ThreadLocal里取到的是nullHandler的构造方法里就会抛RuntimeException——“Cant create handler inside thread that has not called Looper.prepare()”。所以新线程里要创建Handler两行代码是标配先Looper.prepare()再new Handler()最后Looper.loop()。我当时在复盘时还额外补了一个点Handler机制里最容易被问到崩溃的是内存泄漏。非静态内部类Handler默认持有外部Activity的引用如果MessageQueue里还有延迟消息没处理完Activity就无法被回收。解决方案是把Handler声明为静态内部类用WeakReference持有Activity引用同时在onDestroy里removeCallbacksAndMessages(null)。这个点笔试可能不会直接考但面试一定会顺着Handler往下追。3.2 Binder驱动的“一次拷贝”是面试官最爱追问的点Binder是Android IPC的核心也是笔试里容易拉开差距的题。爱奇艺问的大方向是为什么Android要选择Binder作为跨进程通信的主要方式而不是传统的管道、消息队列、共享内存标准答案有几个维度。第一是性能Binder只需要一次内存拷贝而传统IPC机制如管道、Socket需要两次拷贝。这里的核心是mmap映射Binder驱动通过mmap把内核空间的一块地址和接收进程的用户空间地址映射到同一块物理内存数据从发送进程复制到内核缓冲区之后接收进程直接从映射区读取省去了从内核缓冲区到用户空间的第二次拷贝。第二是安全性Binder为每个App分配UID内核层可以校验调用方身份而传统IPC没有这套安全机制。第三是面向对象Binder的设计类似远程对象调用使用方可以像调本地方法一样调用远端方法。这道题如果能答到这里面试官极大概率会继续追问mmap的原理。我建议备考的同学把mmap的映射关系画一遍发送端调用transact数据进入Binder驱动驱动找到接收端的映射区域内核把数据写入共享映射区接收端从用户空间拿到数据。这条链路理解了Binder的“一次拷贝”就不再是背下来的结论而是推出来的结果。3.3 RecyclerView四级缓存一套会背但未必说得清的逻辑RecyclerView的缓存机制也是高频考点我记得爱奇艺这场笔试有一道题问的是RecyclerView的缓存分为哪几个层级每层缓存的区别是什么。四级缓存分别是mAttachedScrap、mCacheViews、mViewCacheExtension、mRecycledViewPool。mAttachedScrap缓存的是还在屏幕内、但被标记为“需要重新绑定”的ViewHolder比如调用notifyDataSetChanged时屏幕内的ViewHolder会先进入AttachedScrap复用时不需要重新执行onCreateViewHolder和onBindViewHoldermCacheViews缓存的是滑出屏幕的ViewHolder默认容量是2复用时也不需要重新绑定mViewCacheExtension是一个可以由开发者自定义的缓存层实际开发中很少直接用mRecycledViewPool是共享池滑出屏幕超过mCacheViews容量后ViewHolder会进入Pool复用时需要重新绑定数据。很多人卡在最后一级的“脏数据”问题上为什么从RecycledViewPool复用的ViewHolder会出现数据显示错乱因为复用时RecyclerView会调用onBindViewHolder重新绑定但如果你在onBindViewHolder里直接对ImageView设置图片而没有处理复用前的旧状态就可能出现滚动时图片闪烁、内容不对的情况。所以规范化写法是在onBindViewHolder里先对视图状态做重置再设置新数据。这套四级缓存的逻辑笔试会考选择题面试会考“缓存复用效率怎么优化”“预取机制是如何工作的”。当时我自知这块理解不到位复盘时专门把RecyclerView的LayoutManager和Recycler两个类的交互流程过了一遍才真正把缓存层级之间的关系理清。4. 手写题实战LRU缓存与线程安全单例两段代码讲透4.1 LRU缓存用LinkedHashMap实现但要能讲清accessOrder手写题是笔试的重头戏爱奇艺这场第一道手写题是设计一个LRU缓存支持get和put操作时间复杂度要求O(1)。这个题最直接的解法是利用LinkedHashMap的accessOrder特性。LinkedHashMap默认按插入顺序排序当accessOrder设为true时每次访问一个元素该元素会被移动到链表尾部头部就是最久未访问的元素。在此基础上重写removeEldestEntry方法当缓存大小超过容量时返回true底层就会自动移除最久未使用的节点。我复盘时把代码完整写了一遍import java.util.LinkedHashMap; import java.util.Map; public class LRUCacheK, V extends LinkedHashMapK, V { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity capacity; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() capacity; } public static void main(String[] args) { LRUCacheInteger, String cache new LRUCache(2); cache.put(1, one); cache.put(2, two); System.out.println(cache.get(1)); cache.put(3, three); System.out.println(cache.containsKey(2)); } }这段代码能跑通但笔试如果只写到这个程度只能算及格。面试官继续问“如果不用LinkedHashMap让你自己手写一个LRU你会怎么做”这时候需要换思路用双向链表加HashMap。HashMap负责O(1)的查找链表负责维护访问顺序头部是最新访问的尾部是最久未访问的。每次get命中把节点移到头部每次put新增插入头部如果超过容量删除尾部节点。这两套方案选哪个更好在笔试里直接写LinkedHashMap版本最快也最稳但面试里如果能先讲LinkedHashMap方案再说“如果面试官要求不用JDK自带结构可以改用自定义双向链表加HashMap”会显得你对底层实现逻辑有把握。我当时在复盘时把双向链表版本也手写了一遍这个对理解LRU的本质帮助很大——get和put的操作本质都是“维护一个有序的最近访问列表”而已。4.2 DCL单例volatile到底在防什么第二道手写题是线程安全的单例模式。但爱奇艺的题比较阴模式不限但要求说明为什么你的写法是线程安全的。这就把题目从“背模板”提升到了“讲原理”的层面。我写的是DCL双重检查锁这是笔试里最常被用到、也最容易讲不清的写法public class Singleton { private static volatile Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这个写法能线程安全的原因有两层。第一层是synchronized保证同一时间只有一个线程执行创建逻辑第二层是volatile禁止指令重排。这里的关键是instance new Singleton()在JVM层面不是原子操作它可以拆成三步分配内存、初始化对象、把引用赋值给变量。如果不加volatile由于指令重排第三步可能先于第二步执行此时另一个线程判断instance不为null直接返回了一个还没初始化完成的对象后续使用就会出问题。volatile在这里的核心作用就是禁止这个重排。这是所有Android面试里最经典的一个“为什么要加volatile”的场景。我后来面试时经常把这个点作为考察对方是否真正理解并发编程的信号——如果一个人能把DCL、指令重排、Happens-Before三者之间的关系讲清楚那他并发这块基本是过关的。笔试现场还有个细节很多人会因为紧张把volatile写成volitale把synchronized拼错导致代码没过编译。我建议备考的同学平时手写代码不要依赖IDE自动补全拼写错误本身就是笔试的一大失分点。5. 算法与开放设计题时间有限先拿稳确定的分5.1 TopK问题笔试里最“值钱”的算法题算法题在整个Android校招笔试里的占比不算高但一旦出现基本就是拉开进面名单和非进面名单的关键。爱奇艺这场考了一道TopK问题给定一个无序数组找出第K大的数。这类题最直接的思路是排序后取下标时间复杂度O(nlogn)。但笔试现场如果能写出O(n)平均复杂度的解法肯定更占优也就是快排的partition思想每次选一个pivot把数组分成左右两部分左边都大于等于pivot右边小于等于pivot。如果此时pivot的位置正好是K-1那pivot就是答案如果pivot位置在K-1左边就在右半部分继续找如果在右边就在左半部分继续找。期望复杂度O(n)最坏O(n^2)。我当时在笔试时写的是排序法虽然过了基础用例但后来复盘发现如果当时写出partition版本面试官在后续面试聊算法时会更容易给正面评价。所以笔试不只是为了过题也是在给面试攒加分项。备考的话PriorityQueue版本也建议顺手会写毕竟它体现的是另一种审视问题的角度——维护一个大小为K的小顶堆堆顶就是第K大。5.2 设计一个图片加载库别急着堆功能先画主链路开放设计题是爱奇艺这样的视频类公司特别喜欢出的题型因为它能考察一个人做技术方案时有没有全局观而不是只会点对点API调用。我遇到的设计题是如果让你设计一个图片加载库你会怎么设计这个题答案没有标准但考察点很明确。一个合格的图片加载库需要覆盖的完整链路是图片发起加载请求先查内存缓存再查磁盘缓存缓存都没有就走网络下载下载完成后解码成Bitmap压缩到目标尺寸再回写到各级缓存同时要有生命周期感知能力——在Activity或Fragment销毁时取消正在进行的加载任务避免ImageView持有已销毁Activity的引用导致内存泄漏。我当时的回答包括了缓存、生命周期、线程池三层结构但漏了“图片格式适配”和“请求优先级”。前者关系到不同分辨率的屏幕加载不同精度的图后者关系到列表快速滚动时低优先级请求会让位于高优先级请求。这两个点在后来的复盘里被我想起来恰好也是Glide和Fresco这类成熟库的核心特性。所以如果再做一次这道题我会把答案组织成四层上层是API入口处理生命周期和请求配置中间是数据层负责内存缓存、磁盘缓存、网络下载的三级链路底层是解码和转换层负责处理Bitmap复用、压缩、缓存键生成最后是线程调度层负责控制主线程和子线程的切换。这四层想清楚了后面不管面试官问“如果缓存失效怎么办”“如果图片加载到一半Activity退出了怎么办”都能顺着链路答上来。6. 笔试结束后的复盘我做对了三件事6.1 把所有错题按知识点归因而不是按题目归因笔试结束当天晚上我做的第一件事不是对答案而是把能回忆出来的题目分成三类Java基础、Android框架、算法与设计。这个分类动作看起来简单对后续复习的指导作用非常大。按知识点归因和按题目归因的最大区别在于按题目归因你会发现自己做错了一道HashMap的题然后只去补HashMap按知识点归因你会发现连续三家公司笔试在同一周内都考了“线程安全集合”的相关内容那这个问题才是真正需要系统攻克的薄弱点。爱奇艺这场之后我又参加了另外两家的笔试错题放在一起看最集中的短板已经浮出水面——并发编程和View的事件分发机制。明确短板之后再定计划比什么都想抓、什么都抓不牢有效得多。6.2 把“会写”变成“会讲”为后续面试做准备笔试和面试有一个很重要的衔接关系笔试考察你对知识的识别能力面试考察你对知识的表达能力。很多人笔试能做对选择题现场面试同一个知识点却讲不清楚问题往往出在“知识存储在记忆里的方式”。我做了一个比较笨但有效的动作把每个错题对应的知识点用自己的话写成一段不超过三分钟的讲解稿。比如“为什么ConcurrentHashMap比Hashtable并发度高”我会对着镜子讲一遍讲到能不看笔记把Segment分段锁和synchronized锁桶头节点的区别说清楚为止。这个习惯让我在后续几家公司的技术面试里受益很大因为面试官问来问去其实就是那些点但表达是否流畅、逻辑是否成链条直接决定他对你技术深度的判断。6.3 给下一届备考者的三点具体建议基于我对这套题以及整个秋招流程的复盘最后直接给三点可操作的建议第一8月中下旬就要把基础知识点过完第一轮学校的秋招不会等你。Java集合、JVM基础、Android四大组件、Handler机制、事件分发、View绘制流程这些是任何时候都绕不开的必考题。第二轮再针对目标公司的产品形态做定向强化。第二手写代码不要只在纸上写要真正在编辑器里跑一遍。LRU、单例、快排partition、二分查找、常规的字符串操作这些是笔试出现频率最高的素材。笔试环境下的代码检查和IDE里完全不一样没有自动补全没有编译提示拼写和语法错误全靠自己盯。第三开放设计题不要空着。即使不会答得很完整也要把自己能想到的主链路写出来。面试官考察的不是你能否一步到位设计出Glide而是你面对一个模糊问题时有没有分解问题的能力。哪怕只写出“缓存、线程池、生命周期管理”这三个关键字也比白卷好得多。最后再分享一个个人体会秋招笔试的成绩远不如你从这个过程中获得的自我认知重要。爱奇艺这场笔试我并没有拿到理想的分数但它让我知道了自己和目标岗位之间的距离也让我把后续复习的重心从“刷多少题”变成了“懂多少原理”。这个转变比多拿几个offer对我的长远影响更大。