1. 这套笔试卷到底考什么
先说结论:网易2018校园招聘Android开发工程师(BJ)笔试卷,基本代表了当年一线互联网公司校招笔试的最高水准。它不是那种“背背八股文就能过”的试卷,更像是一场对Android知识体系完整度的全面体检。作为一个在移动开发领域泡了十多年的老Android,我拿到这套题的第一感觉是:出题人很清楚自己想要什么样的人——不是会调接口的码农,而是真正理解系统运行机制、能独立解决复杂问题的工程师。
整套试卷的考察范围大致可以分成四块:Java基础与并发、Android系统核心机制、自定义View与事件分发、性能优化与网络。这个布局很有意思,它基本覆盖了一个Android开发者在实际工作中最常打交道的所有知识域。如果你在工作三五年后回头看这套题,会发现它考察的深度和广度,恰恰对应了一个中级工程师应该具备的完整知识栈。
值得说的是,2018年这个时间节点很微妙。那时候Kotlin刚被Google宣布为Android官方语言不久,但校招笔试里仍然以Java为主,说明企业在实际项目里的技术栈切换是有滞后性的。所以这套卷子里Java相关的题目占比不小,尤其是并发和内存模型那块,这些内容放在今天依然有很强的参考价值。
注意,这套题不是用来“刷”的,而是用来“照镜子”的。我见过太多人把校招笔试当成高考一样去背题,结果面试环节一追问就露馅。真正聪明的做法,是把每一道题当成一个知识锚点,沿着它把整个相关的知识网络梳理一遍。这篇文章我就按这个思路,把这套卷子里最有代表性的考点逐个拆开,讲清楚背后的原理、答题的思路,以及在真实开发中这些知识是怎么用的。
2. 基础考点:Java并发与内存模型
2.1 线程安全的本质:可见性、原子性、有序性
笔试卷子里关于Java并发的那几道题,核心就围绕一个问题:在什么情况下多线程程序会出问题?答案无非是三个——可见性、原子性、有序性。这三个概念很多人能背出来,但要真正理解,需要在代码里踩过坑。
先说可见性。Java内存模型规定,每个线程有自己的工作内存,线程对变量的操作其实是在工作内存里进行的,然后再同步回主内存。如果一个线程修改了变量,而另一个线程读到的还是旧值,这就是可见性问题。经典场景就是那个永远跑不出来的循环:
// 这段代码在理论上可能永远无法退出 private static boolean flag = true; // 线程A while (flag) { // 做点什么 } // 线程B flag = false;如果flag没有用volatile修饰,线程A可能永远看不到线程B的修改。用volatile修饰后,每次读都会强制从主内存读取,每次写都会强制刷回主内存。但volatile只能保证可见性和有序性,不能保证原子性。
再说原子性。i++这种操作表面上是一行代码,实际上对应了“读取-修改-写入”三步,在多线程环境下就会出问题。要保证原子性,要么用synchronized,要么用AtomicInteger这些原子类。我在实际项目里就遇到过类似的问题:统计在线用户数时用了普通的int变量加加,结果线上数据总是对不上,排查了半天才发现是并发问题。
最后是有序性。CPU和编译器为了优化性能,会对指令进行重排。在单线程下没问题,但多线程下可能导致诡异的现象。经典的DCL单例模式,如果instance不加volatile,就可能因为指令重排导致拿到一个未初始化完成的对象:
// 正确的DCL写法 private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; }那行new Singleton()在字节码层面会经历“分配内存、初始化对象、将引用指向内存”三个步骤,第二步和第三步可能被重排。volatile在这里的作用就是禁止重排,保证引用指向内存时对象一定初始化完毕。
2.2 线程池:为什么不用new Thread
笔试卷里线程池的题目,表面上是问参数,实际上是在考察你是否理解线程池存在的意义。直接new Thread的问题在于:每次都要创建和销毁线程,频繁发生时有性能损耗;线程数量不可控,多了会耗尽内存;缺少统一管理,出问题不好排查。
线程池的核心构造参数有七个:核心线程数、最大线程数、空闲存活时间、时间单位、任务队列、线程工厂、拒绝策略。这里最容易混淆的是核心线程数和最大线程数之间的关系。我给出的记忆方法是:核心线程数是“保底值班的人”,最大线程数是“紧急情况下最多能叫来的人”,任务队列是“排队等待的区域”。任务来了先给核心线程处理,核心线程满了进队列,队列满了才考虑创建新线程到最大线程数,再满了就用拒绝策略。
实际开发中,IO密集型和CPU密集型任务的线程池配置策略完全不同。CPU密集型任务,线程数设置为CPU核心数加一即可,因为线程多了反而因为上下文切换降低效率。IO密集型任务,线程数可以设置得更大一些,因为线程大部分时间在等待IO返回,不占CPU。
2.3 锁机制:synchronized与Lock的取舍
关于锁的题目,我认为出题人最想看到的是你不仅能说出两者的区别,还能结合实际场景说明怎么选。synchronized是JVM层面实现的,使用简单,锁的获取和释放由虚拟机自动管理,JDK 1.6之后引入了偏向锁、轻量级锁等一系列优化,性能已经不输Lock。Lock是JDK层面的接口,提供了tryLock、lockInterruptibly、公平锁等synchronized不具备的能力。
选择synchronized还是Lock,我的原则是:能用synchronized就尽量用synchronized。因为它在语义上更清晰,出问题的概率更低。只有遇到确实需要超时控制、可中断、读写分离这些场景,才考虑用Lock。比如一个文件缓存系统的读操作远多于写操作,就可以用ReentrantReadWriteLock来提升并发度。
3. Android核心机制:Handler与Binder
3.1 Handler的完整链路:从post到回调
Handler是Android面试中永远绕不开的知识点,这套笔试卷也不例外。往深了问,一道Handler的题目就可以覆盖Android的消息机制、线程通信、内存泄漏等多个考点。
先把完整链路理一遍:当你调用handler.post(runnable)时,这个runnable会被包装成一个Message对象,然后通过sendMessageDelayed发送出去。Message进入MessageQueue后,Looper通过无限循环调用queue.next()去取消息。取到消息后,调用msg.target.dispatchMessage(msg),最终在handleMessage或者runnable的run方法中执行。
这里有两个细节值得展开。第一,MessageQueue.next()并不是一个简单的队列出队操作。当队列中没有消息时,next方法会进入阻塞状态,让出CPU。当有延迟消息时,会调用nativePollOnce进行精确的定时等待。这个过程涉及到Linux的epoll机制,这也是为什么Handler能够实现精准定时的原因。
第二,主线程的Looper是在ActivityThread.main()方法中通过Looper.prepareMainLooper()创建的。它从启动开始就不会退出,一直在循环处理消息。这也就是为什么主线程可以一直响应用户操作,也是为什么UI操作必须放在主线程——因为View的绘制和事件响应都是通过Handler机制排队执行的。
关于Handler内存泄漏的经典问题:如果用内部类创建Handler,它会持有外部Activity的引用。如果Activity已经销毁,但消息队列里还有延迟消息没处理完,就会导致Activity无法被GC回收。解决办法是在onDestroy中移除所有消息,或者用静态内部类加弱引用。
3.2 Binder:一次拷贝的奇妙设计
Binder是Android系统进程间通信(IPC)的核心机制,也是笔试中的高频考点。很多初学者会问:Linux本身就有管道、Socket这些IPC方式,为什么Android还要搞一个Binder?原因主要有三点:性能好、安全性高、调用方便。
性能方面的核心优势是“一次拷贝”。传统IPC方式,比如管道,数据需要从发送进程的用户空间拷贝到内核空间,再从内核空间拷贝到接收进程的用户空间,总共两次拷贝。而Binder通过mmap技术,在接收进程的用户空间和内核空间之间映射同一块物理内存,数据只需要从发送进程用户空间拷贝一次到内核空间,接收进程就能直接访问,省掉了一次拷贝。
安全性方面,传统IPC方式无法可靠地知道对面的进程身份,而Binder本身就有UID/PID信息,内核可以据此进行权限校验。这也是为什么Android系统服务大多用Binder通信。
在具体实现上,Binder通信的基本过程是:客户端调用代理对象的transact方法,数据被序列化后通过内核的Binder驱动传递到服务端,服务端在Binder线程池中找到一个线程来处理请求,处理完结果再原路返回。这个过程涉及一个很重要的组件——Binder线程池。默认情况下,每个进程的Binder线程池最多支持16个线程,如果线程全部繁忙,新的Binder请求就会阻塞等待。
3.3 Activity启动流程:一次完整的系统服务调用
Activity的启动流程可以作为理解Android系统架构的典型样例。这个流程在18年的试卷里就已经是常客,后来更是衍生出了各种“启动优化”的面试问题。
简单的流程是这样的:Activity A调用startActivity后,这个请求会通过Binder传递给AMS(ActivityManagerService)。AMS经过一系列解析和校验后,通过Socket或Binder通知Zygote进程fork出新的应用进程(如果目标Activity不在当前进程)。Zygote fork出的新进程会创建Application、创建主线程的Looper,然后AMS再通过Binder通知新进程的ActivityThread,让它去创建Activity对象并执行onCreate。
在这个流程中,有两个容易被忽略的细节。第一,Activity对象的创建是由系统通过反射完成的,不是在代码里new出来的。第二,虽然Activity的onCreate等生命周期方法是在新进程里执行的,但这个进程是应用自己创建的还是系统帮忙创建的,取决于当前的进程情况。若Activity本来就运行在现有进程中,则不存在进程创建这一步。
这套流程被面试官问烂了,但我始终觉得能把它讲透的人确实不多。很多人卡在一个点:为什么AMS能跨进程调用到应用进程里的ActivityThread?答案是ApplicationThread,它就是ActivityThread的一个内部Binder对象,通过它系统可以反向调用应用里的方法。
4. 自定义View与事件分发:进阶必考
4.1 事件分发机制的完整流程
自定义View和事件分发,是网易这套笔试卷里技术含量最高的部分。很多人在这一块栽跟头,不是因为不了解单个方法的作用,而是无法把整个流程串起来。
Android触摸事件的分发核心是三个方法:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent。这三者的关系可以用一句口诀概括:分发先走,拦截看中间,处理在最后。流程大致是这样的:触摸事件从Activity进入,往下传给顶层ViewGroup的dispatchTouchEvent,ViewGroup先判断是否拦截,不拦截就传给子View;子View的dispatchTouchEvent如果处理不了,就把事件回传给父View的onTouchEvent处理。
这里最关键也最容易混淆的点是DOWN事件和MOVE/UP事件的处理差异。Android的事件流是以DOWN事件开始、以UP事件结束的一整条序列。DOWN事件决定了事件流的目标,一旦某个View在DOWN事件中返回了true,那么后续的MOVE和UP事件都会优先传给这个View。如果DOWN事件没有任何View处理,那么后续的MOVE和UP也不会有人处理。
再往深一层,事件分发的设计意图是什么?是为了实现灵活的触摸响应体系。ViewGroup可以在DOWN事件时决定是否拦截,也可以在MOVE事件时根据滑动距离等条件动态决定是否拦截。经典场景是在ScrollView内部嵌入一个水平滑动的ViewPager,如何在垂直滑动和水平滑动之间做出正确的拦截判断,这就是对事件分发机制理解程度的一次全面检验。
4.2 自定义View的绘制流程:measure、layout、draw
自定义View题目在笔试卷中的分量很重,知识点也很密集。从measure到layout到draw,每个阶段的每个方法都有其存在的意义。
measure阶段:View的measure方法接收父View传入的MeasureSpec(包含specMode和specSize),通过onMeasure方法计算出自己的宽高。MeasureSpec有三种模式:UNSPECIFIED表示父容器不对子View有任何约束,EXACTLY表示确定尺寸,AT_MOST表示子View不能超过某个尺寸。对于自定义View,如果需要支持wrap_content,就必须在onMeasure里处理AT_MOST模式——不然wrap_content的效果和match_parent一样,这是个非常经典的坑。
layout阶段:这个阶段的主要任务是根据measure阶段计算出的宽高,结合父容器传入的left、top、right、bottom参数,确定View在屏幕上的最终位置。对于ViewGroup,需要重写onLayout来安排子View的位置。
draw阶段:draw方法会依次执行几个步骤——绘制背景、调用onDraw绘制自身内容、分发绘制子View、绘制装饰。对于自定义View来说,onDraw是主要重写的方法。这里有个性能建议:不要在onDraw里做创建对象、分配内存等操作,因为onDraw在每次重绘时都会被调用,频繁创建对象会触发频繁的GC。
4.3 ViewGroup自定义:布局与测量的联动
自定义ViewGroup比自定义View要复杂一个量级,因为涉及到多个子View的测量和布局协调。比较典型的操作是重写onMeasure遍历每个子View,给它们分别调用measure,再结合自己的大小约束算出最终的宽高;在onLayout中遍历子View,用计算好的位置逐个调用layout方法。
一个经常被问到的点:为什么自定义ViewGroup时,需要关心子View的MeasureSpec生成规则?因为父View在measure子View时,会根据父View自己的MeasureSpec和子View的LayoutParams的宽高值,生成子View的MeasureSpec。这就是getChildMeasureSpec方法的职责。如果这个规则处理不好,子View的尺寸就会和预期不符。
我强烈建议初学者用ViewDragHelper实现一个简单的可拖动ViewGroup来练手,它能让你一次性接触事件分发、measure、layout和拦截逻辑,是极好的学习材料。动手写一遍,胜过背十遍八股文。
5. 性能优化与网络:决定上线质量的分水岭
5.1 内存泄漏:定位与修复的思路
性能优化题在这套笔试卷中有不小的比重,因为网易一直很看重应用的质量表现。内存泄漏是其中最重要的考点。常见的内存泄漏场景包括:Handler持有Activity引用、静态变量持有Context、非静态内部类创建的实例在生命周期较长的对象中被引用等等。
排查内存泄漏的实操路径,我建议是:先用Android Studio自带的Profiler工具抓取内存快照,然后分析Heap Dump文件,查看对象引用链。熟练之后可以配合LeakCanary这类工具做持续监控。定位到泄漏点后,修复方式通常有几种:用静态内部类替代非静态内部类、用弱引用持有外部对象、在合适的时机清理资源。
有一个案例值得说:一个音乐播放器项目,播放页的Activity每次进出都会内存上涨,查来查去发现是一个全局的单例Manager持有了播放页的Context。这个Manager需要访问资源文件,但直接把Activity的Context塞给了单例。修复方式其实很简单——改用getApplicationContext作为全局Context。这个案例特别经典,校招面试时讲到这个细节,往往是加分项。
5.2 卡顿优化:从帧率到帧耗时
Android卡顿优化的核心目标是保证16.6ms内完成一帧的渲染。超过这个时间,用户就会感知到掉帧。要分析掉帧原因,第一工具是Systrace,它能精确记录每个系统方法的时间消耗。通过Systrace可以看到是由Layout耗时、Draw耗时还是GPU渲染耗时导致的掉帧,再针对性地做优化。
接下来是代码层面的优化。常见问题包括:主线程做了耗时IO操作、onDraw中进行了复杂计算、布局层级过深导致measure和layout耗时过长等。布局优化建议使用扁平化的布局结构,合理使用ConstraintLayout和merge、ViewStub等标签。列表优化方面要注意ViewHolder复用、避免在getView中创建对象、减少ItemView的嵌套层级。
5.3 网络:从TCP到HTTP
网络模块的题目考察内容比较传统但也是必备的:TCP三次握手和四次挥手、HTTP与HTTPS的区别、HTTP 2.0的特性等。TCP三次握手的核心目的是确认双方的收发能力正常并协商好初始序列号。HTTP与HTTPS的区别,关键点是TLS握手过程和证书验证。这个考点在多益、字节等公司的面试中也是经常出现的。
HTTP 2.0相对于1.1的几个关键改进:多路复用、头部压缩、服务端推送。多路复用是一个连接可以同时并行处理多个请求,解决了1.1时代队头阻塞的问题。我在项目里遇到过的情况是:旧版本使用HTTP 1.1时,图片资源加载慢,切换到HTTP/2后有了明显提升,就是因为多个资源可以并行下载。
5.4 网络优化实践:从OkHttp到底层细节
从笔试角度说网络优化,最常考的还是OkHttp的原理。一个高质量的答案需要串起这些点:OkHttp通过Dispatcher维护请求队列,以责任链模式串联多个Interceptor,每个Interceptor各司其职——RetryAndFollowUpInterceptor负责重试和重定向,BridgeInterceptor负责请求头补充,CacheInterceptor处理缓存,ConnectInterceptor负责建立连接,CallServerInterceptor负责真正的网络请求。
缓存策略这块也要会聊。HTTP缓存的核心是Cache-Control头和ETag。客户端发起一个带缓存的请求,如果服务器返回的Cache-Control过期时间未到,就直接用本地缓存,不发网络请求。如果缓存过期,会带If-None-Match发起条件请求,服务器判断资源未修改则返回304,不返回实体内容,这样节省了流量和带宽。
6. 这套卷子对今天的启发
回过头来看这套2018年的网易Android笔试卷,有几处今天依然值得借鉴的思路。
第一,基础数据结构与算法的考察其实很有度。这套题的算法部分更偏向于考察“能用合适的数据结构解决问题”的思路,而不是一味地追求解难题。这对应到工作中的实际情况,我们每天都在处理列表的增删改查、字符串拼接、去重、排序这些操作,只是大多数时候这些操作都封装好了,不需要你手写。但如果对底层的数据结构(比如HashMap的扩容机制、ArrayList和LinkedList的差异)不够理解,就很容易写出线上性能不达标的代码。
第二,很多知识点的本质其实是对操作系统和网络原理的理解。比如Binder涉及到的内存映射、线程池涉及到的资源管理、HTTP缓存涉及的协议语义,这些都是计算机基础知识的应用场景。所以这套卷子验证了一个结论:好的Android开发工程师,一定不是只会写业务代码,而是对系统底层有足够的认知。
第三,如果放到今天再答这套卷子,还需要补充一些新东西:Jetpack Compose的UI体系、协程的并发模型、AGP的构建流程、Flutter/RN等跨端方案的原理对比。2018年那会儿没有这些概念,但底层的考察逻辑是一样的——你是否真正理解了你每天在用的框架和工具背后的设计思想。
另外一个观察:如今面试里已经越来越少见到纯记忆性的题目,更多是场景题——定义一个网络框架,你如何设计?App启动耗时,你如何排查?笔试的功能正在从“筛选知识量”转变成“筛选思维框架”。但这套卷子的价值恰在于它是一个很好的知识图谱,你能答出多少,基本就代表了你在Android生态里的地图有多大。
如果你想拿它来准备面试,我的建议不是逐题去找答案背,而是每道题都走这样一个循环:看到题目、先独立思考如何回答、然后查资料补全、最后落地写一段Demo验证。四个步骤走完一遍,这个知识点在你的知识体系里才算真正站稳了,这才是刷旧题的真正意义。