ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

小米2019秋招安卓B卷复盘:核心考点与原理深度解析

小米2019秋招安卓B卷复盘:核心考点与原理深度解析 1. 写在前面这份B卷到底在考什么小米2019秋招安卓开发笔试题B卷在当年那批安卓求职者里算是流传度比较高的。我印象里这套题跟A卷最大的区别在于A卷偏重Java基础和数据结构B卷则把大量篇幅放在了安卓四大组件、Handler消息机制、自定义View绘制流程这些偏实战的方向上同时对算法也有一定要求。如果你准备过那个时间节点的安卓面试应该能感受到这套题基本代表了当时大厂对安卓应届生的核心预期基础扎实、源码有涉猎、动手能力强。那这篇博客就围绕这套题做一个完整的复盘。我会把考察的知识点拆开讲清楚每个考点背后的原理是什么、为什么这么设计、笔试遇到时该往哪个方向答同时把一些拓展内容也带上比如热词里提到的安卓Launcher开发、多屏适配、多应用同时录音这类实际开发场景跟考题之间能怎么联系起来。对正在准备安卓岗位笔试面试的同学来说这篇内容可以直接当复习提纲用对已经工作的开发者也可以借这套题回头检查一下自己的基础有没有漏洞。先说我个人对这份题的整体感受它的难度不算变态但覆盖面很广而且很考验你能不能把知识点串起来。很多题目单独拎出来都是基础题但放在一起就变成了一场对安卓知识体系完整度的体检。有一道题我印象很深问的是Handler导致内存泄漏的原因以及解决方案这题看着简单但想答全并不容易因为你需要同时讲清楚Handler机制、内存泄漏的判定、静态内部类与弱引用的组合使用甚至要能画得出消息循环的整体链路。这种题目就是典型的一题考一串也是面试官最喜欢用来区分候选人是背答案还是真理解的方式。2. 笔试题型结构与考点分布复盘2.1 从B卷看小米安卓岗的能力模型一套笔试题目往往能反映出招聘方对岗位的预期。小米2019秋招安卓B卷的整体结构大致可以分成几类单选题、多选题、填空题、简答题和编程题。虽然我没有办法把原题一字不差地复述出来但根据当时参加笔试的同学反馈和我自己的复盘考察点的分布是有明确侧重的。第一类是Java基础与并发占比大概20%到25%。这部分考察的是你对语言本身的掌握程度比如HashMap的底层实现原理、线程同步的方式、volatile关键字的作用、静态内部类与内部类的区别等。不要觉得安卓开发就不考这些实际上很多安卓面试官喜欢从这些基础问题切入因为语言基础决定了你写代码的下限。第二类是安卓核心机制占比最高可能到40%左右。这里包括了Activity的启动模式和生命周期、Service的两种启动方式与区别、BroadcastReceiver的注册方式、ContentProvider的用法、Handler消息机制、AsyncTask的缺陷等。这些内容几乎是所有大厂安卓笔试的必考题因为它们是安卓应用的骨架。第三类是自定义View与渲染机制占比大约15%到20%。考察点包括View的绘制流程、MeasureSpec的含义、事件分发机制、动画的几种实现方式等。这部分是区分普通开发者和资深开发者的关键也是很多应届生的薄弱环节。第四类是数据结构与算法占比20%左右。常见的有链表反转、二叉树的层序遍历、LRU缓存实现、字符串处理等。小米笔试有一个特点算法题不会出得太偏基本都是经典题型的变体只要你把LeetCode前200题刷扎实了问题不大。2.2 高频考点与准备优先级我把这套题里出现频率比较高的知识点整理成了一个表格大家在复习的时候可以按这个优先级来安排时间。优先级考点模块具体内容复习建议P0Activity与启动模式四种启动模式、onSaveInstanceState、任务栈必须能默写每个模式的使用场景P0Handler消息机制Looper、MessageQueue、Message、Handler的关系建议直接看源码别只背结论P0Java并发synchronized与Lock的区别、线程池参数结合考题能说出底层原理P1自定义View测量、布局、绘制三大流程能画流程图能解释MeasureSpecP1内存优化内存泄漏场景、ANR产生原因、LeakCanary原理结合项目经历来准备P1数据结构HashMap、ArrayList、LRU、二叉树遍历手写代码要熟练P2网络与序列化HTTP与HTTPS、JSON、Serializable与Parcelable掌握区别即可不深究P2第三方框架OkHttp、Retrofit、Glide的基本原理能说出核心流程即可从这个表能看出来复习的优先级应该从安卓基础向原理深入再向项目实践延展。我当时准备的时候就犯过一个错误一开始把大量时间花在了研究OkHttp的源码上结果笔试最基础的四套组件反而丢了不少分。后来我才意识到大厂笔试的定位是筛选基础没有明显短板的人而不是某一个点特别深的人你要做的第一件事是把所有基础考点的分数稳稳拿住。3. 核心考点深度拆解从题目到原理3.1 Activity启动模式送分题里的分水岭B卷里有一道关于Activity启动模式的题题目大概意思是在一个任务栈中栈顶是MainActivity此时通过Intent再次启动MainActivity假设MainActivity的启动模式是singleTop那么它的生命周期回调顺序是怎样的。这道题本身不难答案是onPause、onNewIntent、onResume并不会重新走onCreate和onStart。但很多人在onPause和onNewIntent的顺序上栽了跟头。要搞清楚这个问题你得理解singleTop的触发逻辑。当栈顶已经是目标Activity的实例时系统不会创建新实例而是调用它的onNewIntent方法把新的Intent传进去。此时旧Activity处于前台新Intent到达后系统会先让旧的Activity进入onPause状态然后回调onNewIntent最后重新回到onResume。注意这个过程不会触发onStop因为Activity并没有完全不可见。这道题的拓展价值在于它引出了一个经典小问题什么场景下适合用singleTop最常见的是消息推送的跳转页面、扫码页、搜索页这类用户可能频繁打开同一个界面的场景。如果不设置singleTop用户每点一次推送就会在栈里压入一个新的Activity实例按返回键的时候就会一层层倒退体验非常差。我在做即时通讯类的应用时就踩过这个坑单聊界面没有使用singleTop结果从会话列表连点两次进入同一个聊天返回时要按两次返回键才能回到桌面用户反馈了才意识到问题。至于其他几种启动模式比如singleTask和singleInstance笔试里一般会通过情境题来考。singleTask的核心特征是栈内复用它会在任务栈中查找是否存在该Activity的实例如果存在就把它上面的Activity全部出栈并回调它的onNewIntent。这个模式典型应用场景是应用的主页、WebView容器页等。singleInstance就更特殊了它单独占用一个任务栈常见于需要与外部应用共享且只允许一个实例的场景比如来电界面、闹钟提醒等。3.2 Handler消息机制从源码角度回答内存泄漏B卷有一道简答题问的是Handler为什么会导致内存泄漏以及如何避免。这题几乎年年考但想要答出亮点光背结论是不够的你需要真正理解消息机制的运行逻辑。先梳理一下Handler体系的基本关系。每个Handler在创建时会关联当前线程的Looper而Looper内部维护了一个MessageQueue。当你调用handler.sendMessage时消息会进入MessageQueue中等待处理。如果这个消息还没被处理而Activity已经销毁了这时会发生什么关键点在于MessageQueue中的Message对象持有Handler的引用Handler又持有外部Activity的引用。这条引用链是Message → Handler → Activity非静态内部类隐式持有外部类引用。即使Activity已经调用了onDestroy只要MessageQueue里还有未处理的消息GC就无法回收这个Activity内存泄漏就产生了。那怎么解决答案分两层。第一层是代码层面把Handler定义成静态内部类使其不持有Activity的引用同时通过WeakReference弱引用的方式持有Activity在Activity的onDestroy方法中调用handler.removeCallbacksAndMessages(null)来移除所有消息和回调。第二层是理解层面你需要解释清楚为什么静态内部类加弱引用能解决问题。静态内部类不持有外部类实例所以切断了引用链弱引用则保证在GC发生时Activity可以被正常回收不会因为Handler的存在而延长生命周期。我在准备这道题的时候专门去看了一遍Handler源码把发送消息到处理消息的全链路过了一遍。从sendMessage开始经过enqueueMessage把消息放到MessageQueue里Looper.loop()死循环不断从队列里取消息然后由dispatchMessage分发到handleMessage处理。当你把这整条链路看完了再回答内存泄漏的问题逻辑就非常清晰了。面试官最怕听到的答案是用弱引用解决因为这只说了结论没有说出为什么也看不出你是否有代码层面的实践。3.3 自定义ViewMeasureSpec与绘制流程的取舍B卷里有一道关于自定义View的题目问的是onMeasure方法中MeasureSpec的三种模式分别代表什么以及自定义View在wrap_content时需要注意什么。这道题考察的是你对View测量机制的理解深度。MeasureSpec是View测量时的一个重要参数它将父布局对子View的尺寸要求封装成了一个int值其中高两位代表模式低30位代表具体尺寸。三种模式分别是EXACTLY精确模式、AT_MOST最大值模式、UNSPECIFIED未指定模式。EXACTLY是当布局参数为match_parent或具体数值时生效父View已经确定了子View的精确尺寸AT_MOST是当布局参数为wrap_content时生效子View的大小不能超过父View给定的最大值UNSPECIFIED则代表父View对子View没有任何限制常见于ScrollView和ListView等可滚动容器中。这道题最经典的坑在于如果你自定义了一个View在onMeasure中不处理wrap_content那么它的实际尺寸会等同于match_parent的结果。因为系统默认的View.measure方法是在AT_MOST模式下会直接使用父View传入的约束最大值也就是说wrap_content被当成了match_parent来用。解决方法是在onMeasure方法中主动判断MeasureSpec的mode当为AT_MOST时设置一个默认尺寸作为测量结果。这个知识点在自定义View时非常重要但很多应届生在做自定义View项目时根本没注意过。我记得当时笔试的时候这道题我答得并不好只知道三种模式的名字但说不清在AT_MOST下应该如何手动设置默认尺寸。后来在真实的项目里做柱状图控件才发现如果不在onMeasure里处理wrap_content控件在xml布局里写wrap_content时会直接填满整个父布局非常尴尬。4. 典型笔试题目实战演练与思路拆解4.1 编程题手写LRU缓存实现B卷有一道比较有区分度的算法题要求实现一个LRU缓存。当时题目给了两种选择一种是直接使用LinkedHashMap另一种是自己实现双向链表加HashMap后者显然会更讨喜因为能展示你对数据结构的理解深度。先讲最直接的方案利用LinkedHashMap的accessOrder特性class LRUCache extends LinkedHashMapInteger, Integer { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity capacity; } Override protected boolean removeEldestEntry(Map.EntryInteger, Integer eldest) { return size() capacity; } }这段代码足够简洁关键点是构造函数里的第三个参数accessOrder必须传true它会让LinkedHashMap按照访问顺序来排序每次调用get或put时对应的entry会被移动到链表尾部。然后重写removeEldestEntry方法当容量超过上限时自动移除最久未被访问的entry。这种方式笔试时能拿大部分分但如果你想展示自己的水平可以手写双向链表实现。class LRUCache { private final int capacity; private final HashMapInteger, Node map new HashMap(); private final Node head new Node(); private final Node tail new Node(); class Node { int key, value; Node prev, next; Node() {} Node(int key, int value) { this.key key; this.value value; } } public LRUCache(int capacity) { this.capacity capacity; head.next tail; tail.prev head; } public int get(int key) { Node node map.get(key); if (node null) return -1; moveToHead(node); return node.value; } public void put(int key, int value) { Node node map.get(key); if (node ! null) { node.value value; moveToHead(node); return; } if (map.size() capacity) { Node last tail.prev; removeNode(last); map.remove(last.key); } Node newNode new Node(key, value); addToHead(newNode); map.put(key, newNode); } private void addToHead(Node node) { node.prev head; node.next head.next; head.next.prev node; head.next node; } private void removeNode(Node node) { node.prev.next node.next; node.next.prev node.prev; } private void moveToHead(Node node) { removeNode(node); addToHead(node); } }这道题背后体现的能力点很有意思。它考的不只是会不会背LRU算法而是你能不能把一个读多写多、需要淘汰策略的场景抽象成合适的数据结构。HashMap负责O(1)的查找双向链表负责O(1)的删除与插入两者组合起来才能满足LRU的全部需求。如果你是第一次写这道题建议不要直接看答案先想五分钟需要哪些操作每个操作的时间复杂度要求是什么再动手写写完再对照上面代码检查边界条件。4.2 简答题图片加载流程的设计思路B卷还出了一道开放性简答题要求描述一个图片加载框架的核心流程。这道题没有标准答案但考察的是你是否有独立设计能力。我当时给出的思路是把图片加载拆成四个步骤内存缓存、磁盘缓存、网络请求、异步调度。第一步是查找内存缓存。使用LruCache作为内存缓存容器键是图片的URL通常会做key的转换处理值是Bitmap的引用。查找命中就直接返回未命中进入下一步。第二步是查找磁盘缓存。这里可以使用DiskLruCache把网络下载的图片持久化到本地下次启动时即使内存缓存被清空也能从磁盘中读取。第三步是网络请求。如果内存和磁盘都没有命中就通过异步任务发起网络请求拿到字节流后先解析成Bitmap再按顺序写入磁盘缓存和内存缓存。第四步是异步调度。这里要注意线程模型的切换网络请求必须在子线程进行而ImageView的回调必须切回主线程更新UI一般可以通过Handler或Executor加回调接口实现。需要注意的一个典型细节图片加载到内存之前需要做一次采样压缩也就是inSampleSize的配置。根据ImageView的实际宽高计算采样比例可以大大降低Bitmap的内存占用。这个问题在笔试里不会直接问但在设计题里主动提到这个细节会显得你有实战经验。后来我去对比了Glide的源码发现它的核心流程跟我当时描述的几乎一致只是在磁盘缓存的策略上多做了很多优化比如根据图片的变换条件生成不同的缓存key这些就是在工作经验中积累出来的了。4.3 场景题B卷中的安卓Launcher相关考察热词里有安卓launcher开发这个方向在笔试题中也会有所涉猎。B卷里有一道场景题如果要为主屏制作一个类似Launcher的网格布局你会如何实现以及如何保证滚动的流畅性。这道题其实是在考察你对RecyclerView的掌握程度以及布局优化的意识。我当时的思路是这样第一使用RecyclerView配合GridLayoutManager来实现网格效果这是最直观的方案第二为了流畅性需要启用setHasFixedSize(true)让RecyclerView在item内容变化时避免重新测量第三item的布局层级要尽量浅避免嵌套太多LinearLayout第四如果数据量较大可以考虑局部刷新使用DiffUtil计算item差异而不是notifyDataSetChanged全量刷新。这道题其实还隐藏着一个考点就是Launcher本身有几个核心特性长按拖拽、翻页效果、文件夹功能、徽标数展示等。笔试不会要求你把这些全部实现但如果你能在答案里提到如果是做真正的Launcher桌面滑动效果我可能会用ViewPager或者自定义ViewGroup来实现并且要关注壁纸与桌面滚动的联动会明显加分。因为这说明你不仅知道怎么写代码还知道在真实的Launcher项目中会面临哪些设计挑战。5. 常见问题与避坑指南5.1 笔试最容易被扣分的三个细节结合我自己和身边同学参加小米B卷的经历总结出三个比较常见的失分点。第一个是Activity的启动模式与Intent Flags混在一起考时容易答偏。例如题目问使用singleTask启动MainActivity时原来在它上面的Activity会发生什么标准回答是它们会被出栈并销毁。很多人只记得栈内复用四个字却忽略了它会把栈顶的其他Activity清掉。这是最核心的区别一旦表述不清整个答案就废了。第二个是Service的绑定问题。B卷有一道题问通过bindService启动的Service在Activity销毁后Service还会存在吗答案是Activity销毁时会自动解绑如果此时没有其他绑定者Service会走向onDestroy。很多人会把startService和bindService混在一起忘了解绑时机对Service生命周期的影响。第三个是ANR触发条件的细节混淆。ANR有几种类型常见的是输入事件无响应、广播超时、Service超时各自的时间阈值不同。笔试时如果问为什么在BroadcastReceiver里做耗时操作会导致ANR你需要答出10秒这个时间上限并能解释为什么广播机制对耗时操作如此敏感。5.2 从笔试到面试安卓基础查漏补缺清单笔试只是第一关通常笔试后两周内会安排面试。B卷里的很多考点面试时会被追问得更深。比如笔试考了Handler内存泄漏面试官可能会接着问Looper.loop()为什么不会导致主线程卡死这个问题需要你答出MessageQueue的阻塞唤醒机制当队列没有消息时主线程会在native层进入阻塞状态等新消息到来时通过管道写入唤醒阻塞并不会占用CPU所以不会造成卡顿。再比如笔试考了AsyncTask的用法面试官可能会问AsyncTask在Activity旋转屏幕时会有什么问题。答案是屏幕旋转会销毁并重建Activity而AsyncTask持有旧Activity的引用任务执行完后回调的onPostExecute会操作一个已经销毁的Activity从而引发崩溃或内存泄漏。这个问题的标准解法是在重建Activity时取消旧任务或者用ViewModel持有任务。5.3 安卓开发路线笔试之外更要会扩展从B卷的考察范围来看小米对安卓开发者的要求是比较全面的。但我建议大家不要只盯着笔试题目因为题目是静态的而安卓生态是动态的。热词里提到uniapp开发安卓解决地图遮挡不适配的问题、安卓设备有两个屏幕、多应用同时录音这些内容在2019年的笔试中可能还不会出现但在今天的安卓开发中已经是实打实的业务场景了。我自己在工作后用到的很多知识其实在笔试时根本没有涉及过。比如多屏适配在车机或折叠屏项目里需要处理不同屏幕的配置差异要理解Display与Window的关系再比如音频焦点与多应用录音的冲突需要了解AudioPolicy的管理策略。这些能力不是靠一套笔试题就能考察完的但扎实的基础让你在遇到新问题时能够快速学习、举一反三这才是笔试的真正意义。6. 写在最后我的几点体会小米2019秋招安卓B卷给我的感觉是出题人并不想为难你而是想通过这套题看到你多年积累的厚度。有些题目你复习了一个月就能答上来有些题目你可能工作两年后再回头看才真正明白出题人想考察什么。我的建议是如果你正在准备安卓岗位的笔试不要只刷题更不要背答案。挑一个周末把Handler源码从头到尾读一遍把View的测量流程自己画一遍把LRU缓存手写三遍直到不再出错这些扎实的功夫会在笔试和面试中真正帮你拉开差距。最后分享一个小技巧笔试时遇到不会的题目不要空着。大厂笔试的评分通常会参考答题完整度你写上一部分思路、写出几个关键点哪怕最终答案不对也可能拿到步骤分。我当年B卷那道自定义View的测量题答得很勉强但把MeasureSpec三种模式的分析写了上去最后笔试还是通过了。很多时候面试官要的不是满分而是你有没有一个完整的思考链路。
RELATED READING

延伸阅读

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