
2018年9月我坐在宿舍的電腦前完成了愛奇藝秋季校招Android工程師的第一場在線筆試。那是整個秋招我投出的第一批簡歷也是我第一次正經(jīng)接觸大廠的在線筆試系統(tǒng)。兩個小時后交卷對著錯題記錄翻來覆去地看我才意識到一件事自己過去兩年對Android的理解大部分停留在“會用”而不是“懂原理”。為了不讓這段經(jīng)歷白費我把這場筆試能回憶起來的考點、當時的作答思路、以及后來查漏補缺時補上的知識鏈條完整做了一個復盤。這篇文章就是整理后的結果適合準備Android校招、或者正處在求職節(jié)骨眼上的同學。不管你是剛開始刷題還是已經(jīng)進入筆試階段希望這份復盤能幫你少走一些彎路。1. 2018年秋招的“第一場”筆試到底意味著什么1.1 為什么我會對這場筆試記憶深刻秋招筆試和平時自己做題完全是兩碼事。自己做題的時候做錯了可以查資料、可以慢慢想在線筆試不一樣它是限時、限環(huán)境、還有實時監(jiān)控的。我記得愛奇藝這場筆試的時間是晚上七點到九點兩個小時的窗口期題目類型很全選擇題、問答題、手寫代碼題最后還有一道開放設計題。第一場這個位置很特殊。它是秋招大部隊正式開跑的信號也意味著很多人的狀態(tài)其實還沒調(diào)整到位。我當時就是這樣暑期實習結束之后一直處于松懈狀態(tài)投簡歷投得不算晚但知識體系還是零散的。所以這場筆試對我的最大意義不是拿到了什么結果而是把自己從“佛系刷題”的狀態(tài)里拽了出來。如果你是準備投校招的同學我建議把所有公司里時間最早的那場筆試當作一次全真模擬不要指望它出分有多好看但一定要認真對待。它暴露出來的問題往往就是你接下來一個月復習的優(yōu)先級清單。1.2 愛奇藝的技術棧屬性視頻App對Android工程師能力的獨特要求復盤這場筆試之前我覺得有必要先搞清楚一件事對方到底想招什么樣的人。愛奇藝App是典型的視頻類應用它的核心業(yè)務場景是播放、彈幕、推薦流、會員體系這些場景決定了它對Android工程師的要求會和純工具類、純社交類App不完全一樣。舉幾個例子。視頻播放頁涉及SurfaceView/TextureView選型、播放器內(nèi)核對接、硬解軟解切換、網(wǎng)絡狀態(tài)切換這些都需要對Android系統(tǒng)底層機制有較深理解長列表推薦流涉及RecyclerView的多ViewType復用、圖片加載優(yōu)化、內(nèi)存抖動治理彈幕功能則涉及View層頻繁刷新和主線程性能之間的平衡。所以我復盤這套題的時候能感覺到它雖然考的是通用基礎點但出題方向明顯偏向底層原理和性能優(yōu)化而不是簡單的API使用。這給后來備考的人一個啟示投一家公司之前先分析一下它的產(chǎn)品形態(tài)。視頻類公司大概率會問播放器相關的內(nèi)存、Thread、Surface機制電商類公司大概率會問列表性能、圖片加載、網(wǎng)絡層封裝社交類公司大概率會問IM長連接、消息推送、數(shù)據(jù)庫設計。有針對性地準備比漫無目的地背八股有效得多。2. Java基礎與Android基礎題分數(shù)差距都藏在冷門細節(jié)里2.1 HashMap和ConcurrentHashMap從“背八股”到真正講清線程安全Java基礎部分幾乎是每場Android筆試的家常菜但考得深不深完全是兩回事。愛奇藝這場筆試的選擇題里有一道讓我印象很深考的是HashMap在JDK 1.8版本里的底層結構變化。選項圍繞數(shù)組、鏈表、紅黑樹的組合來設陷阱看似簡單實際考了三個層次的細節(jié)默認初始容量是16加載因子是0.75鏈表轉紅黑樹的閾值是8紅黑樹退化為鏈表的閾值是6為什么轉樹要選8而不是16——因為泊松分布下鏈表長度達到8的概率已經(jīng)極低。第二個坑是HashMap和ConcurrentHashMap的線程安全對比。很多人知道Hashtable線程安全、HashMap線程不安全卻說不太清ConcurrentHashMap到底是怎么保證線程安全的。這里有一個很典型的版本差異JDK 1.7的ConcurrentHashMap用的是Segment分段鎖鎖粒度是每段一個JDK 1.8改成CAS配合synchronized鎖住桶的首節(jié)點鎖粒度更細并發(fā)度更高。筆試如果遇到這類題光答出“ConcurrentHashMap是線程安全的”肯定不夠要把1.7和1.8的實現(xiàn)差異說清楚。我當時在這道題上栽過跟頭因為復習的時候只背了結論沒有真正理解鏈表轉紅黑樹這個動作在并發(fā)場景下會出現(xiàn)什么問題——擴容時的線程安全問題、樹化過程中對原鏈表的改變、size的統(tǒng)計方式這些都是面試官后續(xù)追問的好材料。后來我把HashMap源碼完整過了一遍才發(fā)現(xiàn)之前背的很多結論根本經(jīng)不起推敲。2.2 Activity生命周期與啟動模式有一道題我至今記得Android基礎題里Activity生命周期永遠有一席之地。愛奇藝這道題的角度很刁鉆問的不是常規(guī)的生命周期順序而是Activity在異常銷毀情況下onSaveInstanceState和onRestoreInstanceState的調(diào)用時機以及兩個方法之間數(shù)據(jù)傳遞的差異。這道題考的是“懂不懂得用”和“懂不懂原理”的分界線。常規(guī)場景下生命周期順序大家都會背onCreate到onDestroy、onStart到onStop、onResume到onPause。但異常場景比如屏幕旋轉導致Activity重建系統(tǒng)會先調(diào)用onSaveInstanceState保存狀態(tài)再走onPause、onStop、onDestroy最后重新創(chuàng)建Activity并調(diào)用onRestoreInstanceState恢復數(shù)據(jù)。這里有個細節(jié)onSaveInstanceState只在異常銷毀時才會被調(diào)用正常返回棧銷毀Activity時不會調(diào)用。我還記得另一道題是Activity啟動模式。四種啟動模式的標準答案誰都能背但愛奇藝把singleTask和singleInstance放在一個場景里考從Activity A啟動singleTask的Activity BB再啟動Activity C如果把C的launchMode設置為singleTask此時C所在的Task棧會發(fā)生什么。這個場景考的是TaskAffinity的概念——singleTask默認會尋找或創(chuàng)建與自身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狀態(tài)嗎不是。綁定狀態(tài)下調(diào)用stopService不會觸發(fā)onDestroy必須解綁之后才停止。反過來如果Service被兩個組件綁定先解綁一個Service依然存活要等所有綁定解除且沒有startService狀態(tài)時才會走onDestroy。這套邏輯在大學課程和普通教程里不太會細講但在實際開發(fā)中非常常見。ContentProvider相關的題更偏向原理ContentProvider的onCreate和Application的onCreate誰先執(zhí)行答案是ContentProvider的onCreate先于Application的onCreate因為ContentProvider的初始化發(fā)生在Application的attachBaseContext之后、onCreate之前。這個點我在實際項目里用到過——在ContentProvider里做SDK初始化在Application里做業(yè)務初始化它們之間是有嚴格先后順序的。筆試出這道題考察的就是你對Android進程啟動流程的理解深度。3. 源碼深挖題Handler、Binder、RecyclerView緩存三層遞進3.1 Handler消息機制主線程為什么不會死在loop里Handler的題目在Android面試里已經(jīng)是“必考題中的必考題”愛奇藝這場也不例外。但它的問法比普通面試題更進一層我記得問的是Looper.loop()是一個死循環(huán)為什么主線程執(zhí)行到這里不會卡死一個新線程里如果不調(diào)用Looper.prepare()就直接創(chuàng)建Handler會怎么樣第一個問題答案的核心在于Linux的epoll機制。主線程的Looper在MessageQueue沒有消息時會調(diào)用nativePollOnce進入阻塞等待此時主線程不會繼續(xù)執(zhí)行也不會消耗CPU資源。等到有消息寫入MessageQueue系統(tǒng)通過pipe或eventfd機制喚醒主線程繼續(xù)從nativePollOnce返回取出消息并分發(fā)。這就是為什么主線程可以一直“活”著同時又能響應觸摸事件、刷新UI、處理消息。第二個問題更考察基礎Handler在創(chuàng)建時會通過ThreadLocal獲取當前線程的Looper如果當前線程沒有調(diào)用Looper.prepare()ThreadLocal里取到的是nullHandler的構造方法里就會拋RuntimeException——“Cant create handler inside thread that has not called Looper.prepare()”。所以新線程里要創(chuàng)建Handler兩行代碼是標配先Looper.prepare()再new Handler()最后Looper.loop()。我當時在復盤時還額外補了一個點Handler機制里最容易被問到崩潰的是內(nèi)存泄漏。非靜態(tài)內(nèi)部類Handler默認持有外部Activity的引用如果MessageQueue里還有延遲消息沒處理完Activity就無法被回收。解決方案是把Handler聲明為靜態(tài)內(nèi)部類用WeakReference持有Activity引用同時在onDestroy里removeCallbacksAndMessages(null)。這個點筆試可能不會直接考但面試一定會順著Handler往下追。3.2 Binder驅(qū)動的“一次拷貝”是面試官最愛追問的點Binder是Android IPC的核心也是筆試里容易拉開差距的題。愛奇藝問的大方向是為什么Android要選擇Binder作為跨進程通信的主要方式而不是傳統(tǒng)的管道、消息隊列、共享內(nèi)存標準答案有幾個維度。第一是性能Binder只需要一次內(nèi)存拷貝而傳統(tǒng)IPC機制如管道、Socket需要兩次拷貝。這里的核心是mmap映射Binder驅(qū)動通過mmap把內(nèi)核空間的一塊地址和接收進程的用戶空間地址映射到同一塊物理內(nèi)存數(shù)據(jù)從發(fā)送進程復制到內(nèi)核緩沖區(qū)之后接收進程直接從映射區(qū)讀取省去了從內(nèi)核緩沖區(qū)到用戶空間的第二次拷貝。第二是安全性Binder為每個App分配UID內(nèi)核層可以校驗調(diào)用方身份而傳統(tǒng)IPC沒有這套安全機制。第三是面向?qū)ο驜inder的設計類似遠程對象調(diào)用使用方可以像調(diào)本地方法一樣調(diào)用遠端方法。這道題如果能答到這里面試官極大概率會繼續(xù)追問mmap的原理。我建議備考的同學把mmap的映射關系畫一遍發(fā)送端調(diào)用transact數(shù)據(jù)進入Binder驅(qū)動驅(qū)動找到接收端的映射區(qū)域內(nèi)核把數(shù)據(jù)寫入共享映射區(qū)接收端從用戶空間拿到數(shù)據(jù)。這條鏈路理解了Binder的“一次拷貝”就不再是背下來的結論而是推出來的結果。3.3 RecyclerView四級緩存一套會背但未必說得清的邏輯RecyclerView的緩存機制也是高頻考點我記得愛奇藝這場筆試有一道題問的是RecyclerView的緩存分為哪幾個層級每層緩存的區(qū)別是什么。四級緩存分別是mAttachedScrap、mCacheViews、mViewCacheExtension、mRecycledViewPool。mAttachedScrap緩存的是還在屏幕內(nèi)、但被標記為“需要重新綁定”的ViewHolder比如調(diào)用notifyDataSetChanged時屏幕內(nèi)的ViewHolder會先進入AttachedScrap復用時不需要重新執(zhí)行onCreateViewHolder和onBindViewHoldermCacheViews緩存的是滑出屏幕的ViewHolder默認容量是2復用時也不需要重新綁定mViewCacheExtension是一個可以由開發(fā)者自定義的緩存層實際開發(fā)中很少直接用mRecycledViewPool是共享池滑出屏幕超過mCacheViews容量后ViewHolder會進入Pool復用時需要重新綁定數(shù)據(jù)。很多人卡在最后一級的“臟數(shù)據(jù)”問題上為什么從RecycledViewPool復用的ViewHolder會出現(xiàn)數(shù)據(jù)顯示錯亂因為復用時RecyclerView會調(diào)用onBindViewHolder重新綁定但如果你在onBindViewHolder里直接對ImageView設置圖片而沒有處理復用前的舊狀態(tài)就可能出現(xiàn)滾動時圖片閃爍、內(nèi)容不對的情況。所以規(guī)范化寫法是在onBindViewHolder里先對視圖狀態(tài)做重置再設置新數(shù)據(jù)。這套四級緩存的邏輯筆試會考選擇題面試會考“緩存復用效率怎么優(yōu)化”“預取機制是如何工作的”。當時我自知這塊理解不到位復盤時專門把RecyclerView的LayoutManager和Recycler兩個類的交互流程過了一遍才真正把緩存層級之間的關系理清。4. 手寫題實戰(zhàn)LRU緩存與線程安全單例兩段代碼講透4.1 LRU緩存用LinkedHashMap實現(xiàn)但要能講清accessOrder手寫題是筆試的重頭戲愛奇藝這場第一道手寫題是設計一個LRU緩存支持get和put操作時間復雜度要求O(1)。這個題最直接的解法是利用LinkedHashMap的accessOrder特性。LinkedHashMap默認按插入順序排序當accessOrder設為true時每次訪問一個元素該元素會被移動到鏈表尾部頭部就是最久未訪問的元素。在此基礎上重寫removeEldestEntry方法當緩存大小超過容量時返回true底層就會自動移除最久未使用的節(jié)點。我復盤時把代碼完整寫了一遍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)); } }這段代碼能跑通但筆試如果只寫到這個程度只能算及格。面試官繼續(xù)問“如果不用LinkedHashMap讓你自己手寫一個LRU你會怎么做”這時候需要換思路用雙向鏈表加HashMap。HashMap負責O(1)的查找鏈表負責維護訪問順序頭部是最新訪問的尾部是最久未訪問的。每次get命中把節(jié)點移到頭部每次put新增插入頭部如果超過容量刪除尾部節(jié)點。這兩套方案選哪個更好在筆試里直接寫LinkedHashMap版本最快也最穩(wěn)但面試里如果能先講LinkedHashMap方案再說“如果面試官要求不用JDK自帶結構可以改用自定義雙向鏈表加HashMap”會顯得你對底層實現(xiàn)邏輯有把握。我當時在復盤時把雙向鏈表版本也手寫了一遍這個對理解LRU的本質(zhì)幫助很大——get和put的操作本質(zhì)都是“維護一個有序的最近訪問列表”而已。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保證同一時間只有一個線程執(zhí)行創(chuàng)建邏輯第二層是volatile禁止指令重排。這里的關鍵是instance new Singleton()在JVM層面不是原子操作它可以拆成三步分配內(nèi)存、初始化對象、把引用賦值給變量。如果不加volatile由于指令重排第三步可能先于第二步執(zhí)行此時另一個線程判斷instance不為null直接返回了一個還沒初始化完成的對象后續(xù)使用就會出問題。volatile在這里的核心作用就是禁止這個重排。這是所有Android面試里最經(jīng)典的一個“為什么要加volatile”的場景。我后來面試時經(jīng)常把這個點作為考察對方是否真正理解并發(fā)編程的信號——如果一個人能把DCL、指令重排、Happens-Before三者之間的關系講清楚那他并發(fā)這塊基本是過關的。筆試現(xiàn)場還有個細節(jié)很多人會因為緊張把volatile寫成volitale把synchronized拼錯導致代碼沒過編譯。我建議備考的同學平時手寫代碼不要依賴IDE自動補全拼寫錯誤本身就是筆試的一大失分點。5. 算法與開放設計題時間有限先拿穩(wěn)確定的分5.1 TopK問題筆試里最“值錢”的算法題算法題在整個Android校招筆試里的占比不算高但一旦出現(xiàn)基本就是拉開進面名單和非進面名單的關鍵。愛奇藝這場考了一道TopK問題給定一個無序數(shù)組找出第K大的數(shù)。這類題最直接的思路是排序后取下標時間復雜度O(nlogn)。但筆試現(xiàn)場如果能寫出O(n)平均復雜度的解法肯定更占優(yōu)也就是快排的partition思想每次選一個pivot把數(shù)組分成左右兩部分左邊都大于等于pivot右邊小于等于pivot。如果此時pivot的位置正好是K-1那pivot就是答案如果pivot位置在K-1左邊就在右半部分繼續(xù)找如果在右邊就在左半部分繼續(xù)找。期望復雜度O(n)最壞O(n^2)。我當時在筆試時寫的是排序法雖然過了基礎用例但后來復盤發(fā)現(xiàn)如果當時寫出partition版本面試官在后續(xù)面試聊算法時會更容易給正面評價。所以筆試不只是為了過題也是在給面試攢加分項。備考的話PriorityQueue版本也建議順手會寫畢竟它體現(xiàn)的是另一種審視問題的角度——維護一個大小為K的小頂堆堆頂就是第K大。5.2 設計一個圖片加載庫別急著堆功能先畫主鏈路開放設計題是愛奇藝這樣的視頻類公司特別喜歡出的題型因為它能考察一個人做技術方案時有沒有全局觀而不是只會點對點API調(diào)用。我遇到的設計題是如果讓你設計一個圖片加載庫你會怎么設計這個題答案沒有標準但考察點很明確。一個合格的圖片加載庫需要覆蓋的完整鏈路是圖片發(fā)起加載請求先查內(nèi)存緩存再查磁盤緩存緩存都沒有就走網(wǎng)絡下載下載完成后解碼成Bitmap壓縮到目標尺寸再回寫到各級緩存同時要有生命周期感知能力——在Activity或Fragment銷毀時取消正在進行的加載任務避免ImageView持有已銷毀Activity的引用導致內(nèi)存泄漏。我當時的回答包括了緩存、生命周期、線程池三層結構但漏了“圖片格式適配”和“請求優(yōu)先級”。前者關系到不同分辨率的屏幕加載不同精度的圖后者關系到列表快速滾動時低優(yōu)先級請求會讓位于高優(yōu)先級請求。這兩個點在后來的復盤里被我想起來恰好也是Glide和Fresco這類成熟庫的核心特性。所以如果再做一次這道題我會把答案組織成四層上層是API入口處理生命周期和請求配置中間是數(shù)據(jù)層負責內(nèi)存緩存、磁盤緩存、網(wǎng)絡下載的三級鏈路底層是解碼和轉換層負責處理Bitmap復用、壓縮、緩存鍵生成最后是線程調(diào)度層負責控制主線程和子線程的切換。這四層想清楚了后面不管面試官問“如果緩存失效怎么辦”“如果圖片加載到一半Activity退出了怎么辦”都能順著鏈路答上來。6. 筆試結束后的復盤我做對了三件事6.1 把所有錯題按知識點歸因而不是按題目歸因筆試結束當天晚上我做的第一件事不是對答案而是把能回憶出來的題目分成三類Java基礎、Android框架、算法與設計。這個分類動作看起來簡單對后續(xù)復習的指導作用非常大。按知識點歸因和按題目歸因的最大區(qū)別在于按題目歸因你會發(fā)現(xiàn)自己做錯了一道HashMap的題然后只去補HashMap按知識點歸因你會發(fā)現(xiàn)連續(xù)三家公司筆試在同一周內(nèi)都考了“線程安全集合”的相關內(nèi)容那這個問題才是真正需要系統(tǒng)攻克的薄弱點。愛奇藝這場之后我又參加了另外兩家的筆試錯題放在一起看最集中的短板已經(jīng)浮出水面——并發(fā)編程和View的事件分發(fā)機制。明確短板之后再定計劃比什么都想抓、什么都抓不牢有效得多。6.2 把“會寫”變成“會講”為后續(xù)面試做準備筆試和面試有一個很重要的銜接關系筆試考察你對知識的識別能力面試考察你對知識的表達能力。很多人筆試能做對選擇題現(xiàn)場面試同一個知識點卻講不清楚問題往往出在“知識存儲在記憶里的方式”。我做了一個比較笨但有效的動作把每個錯題對應的知識點用自己的話寫成一段不超過三分鐘的講解稿。比如“為什么ConcurrentHashMap比Hashtable并發(fā)度高”我會對著鏡子講一遍講到能不看筆記把Segment分段鎖和synchronized鎖桶頭節(jié)點的區(qū)別說清楚為止。這個習慣讓我在后續(xù)幾家公司的技術面試里受益很大因為面試官問來問去其實就是那些點但表達是否流暢、邏輯是否成鏈條直接決定他對你技術深度的判斷。6.3 給下一屆備考者的三點具體建議基于我對這套題以及整個秋招流程的復盤最后直接給三點可操作的建議第一8月中下旬就要把基礎知識點過完第一輪學校的秋招不會等你。Java集合、JVM基礎、Android四大組件、Handler機制、事件分發(fā)、View繪制流程這些是任何時候都繞不開的必考題。第二輪再針對目標公司的產(chǎn)品形態(tài)做定向強化。第二手寫代碼不要只在紙上寫要真正在編輯器里跑一遍。LRU、單例、快排partition、二分查找、常規(guī)的字符串操作這些是筆試出現(xiàn)頻率最高的素材。筆試環(huán)境下的代碼檢查和IDE里完全不一樣沒有自動補全沒有編譯提示拼寫和語法錯誤全靠自己盯。第三開放設計題不要空著。即使不會答得很完整也要把自己能想到的主鏈路寫出來。面試官考察的不是你能否一步到位設計出Glide而是你面對一個模糊問題時有沒有分解問題的能力。哪怕只寫出“緩存、線程池、生命周期管理”這三個關鍵字也比白卷好得多。最后再分享一個個人體會秋招筆試的成績遠不如你從這個過程中獲得的自我認知重要。愛奇藝這場筆試我并沒有拿到理想的分數(shù)但它讓我知道了自己和目標崗位之間的距離也讓我把后續(xù)復習的重心從“刷多少題”變成了“懂多少原理”。這個轉變比多拿幾個offer對我的長遠影響更大。