
1. 笔试整体定位与考点分布商汤科技2018校招Android开发工程师笔试第二场放到今天来看依然有很强的参考价值。当年这轮笔试和第一场侧重点已经有所区分第一场更偏重基础编程能力第二场则明显增加了Android系统机制和性能调优的比例整体难度比第一场高一截。题目总共四类单选题、多选题、判断题、两道编程题限时90分钟时间压力不小。题型结构和很多大厂校招笔试基本一致但商汤的题目有明显AI公司的风格——对数据结构、状态管理、内存机制的考察特别执着。我在备考时把近三年的Android校招笔试真题都过了一遍商汤第二场的出题思路和某知名手机厂商的笔试风格最接近基础知识覆盖广、细节挖得深、编程题不考复杂算法但非常考验边界意识。整张卷子做完的感受是它并不追求你背了多少API而是考察你有没有真正做过程序有没有在线上环境踩过坑。举个例子同样是问Activity启动模式普通公司会问singleTask和singleTop的区别商汤会进一步问你当一个App在前台A页面通过通知栏点击跳转到B页面时B页面设置什么启动模式才能在返回时直接回到桌面而不是回到A页面。这种问题如果只是背过概念大概率会卡住。从考点分布来看我把当时能够回忆起来的题目整理成了下面的表格基本覆盖了整张卷子的内容比例考点模块大致占比出题方向Java基础与并发25%集合源码、HashMap原理、线程池参数、synchronized与volatileAndroid四大组件机制25%启动模式、AMS交互、BroadcastReceiver生命周期、Service两种启动方式消息机制与异步15%Handler/Looper/MessageQueue、主线程消息循环、同步屏障自定义View与事件分发10%dispatchTouchEvent流程、滑动冲突场景、measure/layout/draw内存与性能优化15%内存泄漏场景、ANR产生原因、Bitmap内存计算算法与数据结构10%链表操作、二叉树遍历、LRU缓存实现我拿到卷子之后大概花了三分钟把题目快速浏览了一遍第一反应是多选题占比比预期高很多而且每个选项都长得特别像正确答案。比如有一道多选问“哪些情况会导致内存泄漏”四个选项分别是非静态内部类持有Activity引用、Handler延迟消息未移除、单例持有Context、静态变量引用View。前三个都是典型泄漏场景第四个也有讲究——静态变量持有View确实会泄漏但如果持有的是ApplicationContext创建的无状态View泄漏后果会被弱化这个选项的出题意图就是考察你对泄漏本质的理解。这类题目平常不深入思考很容易选多或选少。2. 四大组件与AMS机制笔试里的系统设计考点2.1 Activity启动模式考核的深度边界Activity启动模式是Android笔试的标配考题商汤第二场在这块下了功夫。有一道选择题给了四个场景让判断每个场景中Task的栈变化情况其中有一个场景是应用A通过Intent拉起应用B的某个Activity且该Activity设置了taskAffinity并指定了singleTask启动模式问你返回时Task的切换情况。这个题目如果没有真正理解TaskAffinity和singleTask的配合逻辑基本只能靠猜。我后来查了不少源码分析文章再结合自己的理解把启动模式的核心逻辑重新梳理了一遍。singleTask的核心行为是查找是否存在与目标Activity相同taskAffinity的Task如果有则复用并清空该Task上方的所有Activity如果没有则新建一个Task。这个过程牵扯到AMS内部的startActivityLocked方法它会先通过getTaskForActivityLocked查找匹配的Task匹配条件就是taskAffinity是否一致。很多人在实际开发中会把taskAffinity和singleTask分开记忆但在笔试和面试中出题人恰恰喜欢考两者的联动。比如你在AndroidManifest中给某个Activity配置了singleTask但是不配置taskAffinity那么默认的taskAffinity是包名所以系统会在当前任务栈中查找是否有匹配taskAffinity的Task。当两个应用之间的taskAffinity不同时跨应用拉起Activity也会影响Task归属。商汤的这道题正确答案是B应用会新建一个Task栈用户按返回键时会先回到A应用的Task栈。这里隐藏的一个坑是如果B应用的Activity配置了singleTask但没有taskAffinity那么它不会新建Task而是直接进入A应用的Task栈返回行为就完全不同。这就是出题人挖的坑。2.2 Service两种启动方式与onStartCommand的返回值Service相关的题目商汤考了一道多选和一道判断。判断是“同一个Service可以被多个组件重复绑定onBind方法只会被调用一次。”这个说法初看是对的很多资料也会说“多个客户端绑定同一个Service时onBind只执行一次”但严格来说这个表述有歧义。实际的流程是当第一个组件调用bindService时系统会创建Service实例并调用onCreate随后调用onBind后续再有组件绑定同一个Service系统不会再次调用onCreate和onBind而是直接把之前返回的IBinder对象通过onServiceConnected传给客户端。所以“onBind只被调用一次”在服务存活期间是对的但命题里说的是“可以被多个组件重复绑定”这个表述的准确含义是“同一个Service可以被多个组件多次绑定”也就是说一个组件绑定后解绑再重新绑定这种情况下onBind是会被再次调用的因为解绑时系统会调用onUnbind如果onUnbind返回true则下次绑定时会调用onRebind如果返回false则需要重新回调onBind。这道判断的命题有歧义因为出题人想考察的是onUnbind返回值和onRebind的关系这也是实际开发中容易被忽略的细节。我在项目里曾经犯过类似的错误在音乐播放的Service中我用绑定方式获取播放控制接口某个页面销毁时解绑了Service再次进入页面时发现播放器状态丢失了。排查后发现是因为onUnbind返回了true系统走了onRebind而我在onRebind中没有重新初始化播放状态导致数据不一致。后来规范了绑定生命周期并让onUnbind返回false问题才真正解决。2.3 BroadcastReceiver的静态注册与动态注册商汤对BroadcastReceiver的考察不算深但有一道选择题把静态注册的行为挖得很细应用在AndroidManifest中静态注册了一个接收开机广播的Receiver如果用户手动在应用管理器中强制停止该应用此时广播还能收到吗答案是不能。Android 3.1版本之后系统为所有应用增加了停止状态stopped state处于该状态的应用无法收到任何静态注册的隐式广播。强制停止应用会将应用置于停止状态只有当用户手动启动应用、点击图标或系统通过精确Intent拉起该应用时才会退出停止状态。所以静态注册的Receiver在应用被强制停止后就失效了这也是很多清理类App反复引导用户“不要强制停止”的底层原因。这道题考察的是系统知识储备和版本兼容意识。2018年时Android O已经发布了静态注册的隐式广播已经被大量限制商汤在笔试中出现这个点说明出题人很关注系统版本演进对开发方式的冲击。我记得这道题在当年的错误率特别高很多人凭直觉觉得“开机广播不是一直都在吗”却忽略了停止状态的限制。2.4 AMS、Binder与进程间通信的隐藏考点多选题里有一道关于Binder机制的题目问哪些说法是正确的。选项包括Binder是基于C/S架构的、Binder一次拷贝比传统管道更高效、Binder传输数据有大小限制、Binder通信需要内核模块支持。除了最后一个选项太绝对Binder驱动是内核模块但题目中的表述改成“不需要内核支持”才是错的其他三个都是对的。这道题本身不难但反映了商汤对底层原理的重视。我在备考时专门研究过Binder的内存映射机制传统IPC方式需要两次拷贝用户态到内核态、内核态到发送方用户态而Binder通过mmap把接收方的一块物理内存同时映射到内核态和接收方用户态发送方只需将数据拷贝到内核态接收方用户态就直接可读了所以只需要一次拷贝。这个机制是理解Android进程通信的基础。笔试过程中我还在草稿纸上默画了一下Activity启动流程中涉及到的进程通信链应用进程通过Binder调用AMS的startActivityAMS通过Socket或Binder通知Zygote进程fork新进程新进程通过ActivityThread的main方法启动Looper再通过Binder向AMS报告进程启动完成。这条链路如果能在脑子里完整跑通很多选择题根本不用背直接推都能推出来。商汤第二场虽然没有直接考“画出Activity启动流程图”但整张卷子有多道题的答案都依赖这条链路比如问“新进程的Application和Activity谁先创建”就是考察ActivityThread启动流程的理解。3. Java基础与并发笔试里的高频陷阱题3.1 HashMap源码细节校招笔试的最爱商汤第二场单选题里有一道关于HashMap的题在JDK 1.8中HashMap链表转红黑树的阈值是8但为什么是8而不是其他数选项里有四个答案但都有一个共同点——和泊松分布有关的部分被放在了干扰项里。正确的思路是JDK源码注释里给出的解释是在理想随机的哈希码下桶中节点数量遵循泊松分布达到8个节点的概率是极低的千万分之六因此选择8作为树化阈值是时间和空间的平衡。但这道题真正的考点不在于你知不知道为什么是8而在于你是否知道树化过程的触发条件是“链表长度大于等于8且数组长度大于等于64”如果数组长度不足64即使链表长度达到8也只会进行扩容而不会树化。这个细节我在第一遍复习时忽略过。后来看了一篇文章里面提到一个案例某个App的崩溃日志显示HashMap的get操作出现死循环原因是Java 7中多线程并发put导致链表成环。虽然Java 8修复了这个问题但树化条件又是一个新知识点。我把HashMap的源码翻了一遍整理出几个必考细节扩容阈值是负载因子乘以数组长度、红黑树退化为链表的阈值是6、树化时链表长度至少为8且数组容量至少为64。这些细节在商汤的多选题中和线程安全的问题一起出现很容易错选。3.2 线程池参数与拒绝策略的实操理解关于线程池商汤考了一道构造函数参数的计算题给定核心线程数4、最大线程数8、阻塞队列容量10、任务总数15执行过程中会创建多少个线程这个题目的答案不是4也不是8而是5。计算逻辑是当任务提交时如果当前线程数小于核心线程数则创建新线程执行任务。所以前4个任务会创建4个核心线程。当核心线程都在忙碌时后续任务进入阻塞队列队列容量10所以第5到第14个任务会进入队列。当队列满了之后第15个任务会导致线程池创建非核心线程来执行。因此整个执行过程中创建了5个线程。这个题在笔试时考察的是对线程池执行流程的完整记忆而不是死记参数。我后来在项目里遇到过一个真实的性能问题某个图片上传模块把线程池的核心线程数设置成了20最大线程数20阻塞队列容量是Integer.MAX_VALUE结果在弱网环境下由于队列无限堆积任务迟迟不执行导致用户上传列表越积越多。排查后发现线程池在核心线程满载且队列未满时不会创建新线程队列容量设置过大意味着永远不会触发拒绝策略问题就藏在“队列容量”这个参数的选择上。3.3 synchronized与volatile的可见性辨析商汤对并发关键字的考察有一道多选关于volatile的描述正确的有哪些。选项大致是volatile保证可见性、volatile保证原子性、volatile可以防止指令重排序、volatile可以保证i操作的线程安全。正确的只有第一个和第三个。volatile不保证原子性这一点在i问题上特别经典。原因是i操作分三步读取i的值、将i加1、将i写回内存。volatile只能保证每一步操作在多线程环境中的可见性和有序性但三步之间可以被其他线程插入操作所以最终结果可能小于预期值。这道题本身不难但考察了Java内存模型JMM的基础理解我在回答时把这三个特性重新梳理了一遍才算万无一失。不过这道题后面还跟了一道追问多选题的第二个选项有干扰synchronized是否能保证可见性。答案是能因为synchronized在进入同步代码块时会清空工作内存重新从主内存读取最新值退出时会把工作内存中的修改刷新到主内存。所以synchronized既能保证原子性也能保证可见性这背后是锁的monitorenter和monitorexit机制在起作用。商汤在笔试中考察这个知识点是想确认候选人对线程安全的基础工具是否有扎实的理解。4. 自定义View与事件分发UI部分的实践题4.1 MotionEvent事件分发流程的考题思路商汤第二场有一道关于事件分发的大题给出了一段自定义ViewGroup的代码重写了dispatchTouchEvent和onInterceptTouchEvent要求分析点击一个子View时事件的走向。这种题在笔试卷子里难度中等偏上因为需要你把整个事件分发链条跑通。事件分发的基本流程是点击事件从Activity的dispatchTouchEvent开始依次传递到ViewGroup的dispatchTouchEventViewGroup先询问onInterceptTouchEvent是否拦截如果不拦截则遍历子View调用子View的dispatchTouchEvent子View再调用onTouchEvent处理。如果所有子View都不处理事件会冒泡回父View的onTouchEvent。但商汤的考题多了一层——子View调用了requestDisallowInterceptTouchEvent(true)。这个API的作用是禁止父View拦截事件但需要注意的是这个禁用只对当前正在处理的事件流有效一旦事件序列结束ACTION_UP或ACTION_CANCEL标志位就会重置。我在实际开发中曾经因为滑动冲突的问题反复调试最后发现就是这个标志位的生命周期没搞清楚导致一个问题修了半天。这种题目如果能用现成的案例来理解就简单了经典的滑动冲突场景——外层竖向滑动内层横向滑动的RecyclerView。如果内外层都需要处理滑动事件就必须在onInterceptTouchEvent中根据事件的方向决定是否拦截或者在内层通过requestDisallowInterceptTouchEvent强制禁止外层拦截。笔试考的是理论实际开发中更重要的是根据场景选择合适的方案。4.2 measure/layout/draw流程中的常见陷阱自定义View的测量流程是UI部分的另一个考点商汤有一道判断题View的measure方法中如果MeasureSpec的mode是EXACTLY那么View最终的大小一定等于MeasureSpec的size。这个说法是错误的。原因是View的measure方法会调用onMeasure如果我们在自定义View中重写了onMeasure并调用了setMeasuredDimension设置了固定大小那么即使父容器传过来的是EXACTLY的MeasureSpecView的最终测量大小也会被我们设置的固定大小覆盖。MeasureSpec的AT_MOST、EXACTLY、UNSPECIFIED只是父容器对子View的约束建议子View可以在onMeasure中根据自己的需求决定最终大小并不一定严格遵守。常见的例子是自定义一个固定宽高的圆形ImageView它的onMeasure中强行设置宽高为min(getMeasuredWidth(), getMeasuredHeight())此时父容器传EXACTLY也没用。这个知识点的延伸是为什么很多自定义View会在onMeasure中调用resolveSize或getDefaultSize来处理AT_MOST和EXACTLY的不同情况因为getDefaultSize在EXACTLY模式下直接返回specSize在AT_MOST模式下返回的是尺寸建议。如果不处理宽高可能不符合预期。我在当时笔试中花了比较大的精力去回忆这些细节因为这些点在实际项目里一旦踩坑调试成本很高。4.3 动画机制与属性动画的考题变形动画模块在商汤的笔试卷子里出现了一道选择题属性动画ValueAnimator改变View的translationY是否会触发onDraw重新绘制答案是会但具体机制值得展开。属性动画通过不断修改View的属性值并调用View.invalidate()来触发重绘。如果修改的是translationY改变的只是绘制时的位移矩阵View的layout位置没有变所以不会触发onLayout但会触发onDraw。这个知识点考的是对View绘制流程中invalidate与requestLayout区别的理解。稍微延伸一下如果动画修改的是View的宽高比如scaleX那么因为宽高变化可能影响父布局就会触发requestLayout导致onMeasure和onLayout重新执行。所以同样是属性动画不同的属性会触发不同的绘制流程。这个细节在笔试中出现出题人明显想筛选出真正做过复杂动画优化的开发者。我在做动画优化时也发现如果动画频繁触发requestLayout很容易出现掉帧性能优化的方法之一就是把动画属性限制在translationX、translationY、alpha、rotation这些只触发invalidate的属性上避免触发onMeasure和onLayout。5. 内存优化与性能问题定位商汤笔试的高频主题5.1 内存泄漏场景判断题的细节商汤第二场多选题里那道内存泄漏的题正确选项几乎把项目开发中最常见的泄漏场景全覆盖了。我在实际开发中逐个踩过这些坑所以看到题目时格外有感触。第一个典型的泄漏场景是非静态内部类比如Handler持有外部Activity的引用。Handler发送延迟消息后如果Activity已经销毁但消息还没执行完Handler持有的Activity引用就导致Activity无法被回收。正确的做法是在Activity销毁时移除所有消息和回调。如果用Java的写法可以在onDestroy中调用handler.removeCallbacksAndMessages(null)如果用Kotlin协程更推荐直接在onDestroy中取消协程的Job。第二个典型场景是单例持有Context。如果单例对象用静态字段持有Activity的ContextActivity退出后无法回收。正确做法是单例中持有ApplicationContext或者使用弱引用。第三个典型场景是BroadcastReceiver和ContentObserver没有在销毁时解绑。这些都是老生常谈的问题但笔试中出现说明出题人默认你有项目经验考的是“公司招人最基础的要求”。有意思的是这道题的干扰项静态变量持有View。如果静态变量持有的View是从Activity中获取的那Activity销毁后静态变量仍然引用View而View又持有Activity的引用这确实会导致泄漏。但如果理解到这里你就能看出出题人在考察“泄漏的本质是一个对象不再使用但无法被GC回收”而不是单纯枚举场景。所以无论选项怎么组合只要抓住这个本质就不会被带偏。5.2 ANR产生原因与定位手段商汤有一道单选问“哪些情况下会发生ANR”四个选项分别是主线程执行耗时操作、主线程等待一个死锁的线程锁、BroadcastReceiver的onReceive执行超过10秒、Service的onStartCommand执行超过20秒。这道题的正确答案应该是主线程耗时、死锁等待和BroadcastReceiver超时。而Service超时在旧版本中会触发ANR但Android 8.0之后对后台服务做了约束不再因为Service执行时间过长直接弹ANR而是通过Context.startForegroundService()等机制限定。当年这道题我记得答案有争议因为很多资料还在宣传旧版本的规则。这提示我笔试中遇到系统版本的变更时优先选择最新版本的行为逻辑除非题目明确标注了版本号。在项目里定位ANR问题的常用手段包括查看/data/anr/traces.txt文件、使用adb shell am trace-ipc、或者接入性能监控平台。如果能从Trace文件里找到主线程堆栈基本能快速定位是哪些代码阻塞了主线程。我自己的习惯是遇到ANR问题第一时间抓取Trace文件然后重点看主线程的锁等待状态和IPC调用耗时这两类问题占了ANR的大头。5.3 Bitmap内存计算与图片加载优化内存优化的另一高频考点是Bitmap。商汤出了一道计算题一张1920×1080的图片使用ARGB_8888格式加载到内存中占多少字节答案是1920×1080×4约等于7.9MB。这里有个很多人误以为和图片文件大小有关但实际上的内存占用只和像素尺寸、色彩格式、缩放比例有关。文件大小是压缩后的数据内存大小是解码后的像素数据两者不能混为一谈。我在项目里处理过大图加载导致OOM的问题那次经历让我对Bitmap的内存管理有了切身的体会。手机厂商相机拍摄的照片动辄4000×3000像素一张ARGB_8888的图就是48MB。如果不做采样压缩直接加载App内存直接爆掉。常用的优化方案是使用BitmapFactory.Options的inSampleSize根据控件的尺寸对图片进行采样比如控件只有720×1080那么inSampleSize可以取4图片像素缩小为原来的1/16内存占用大幅下降。更进一步的方案是使用Glide或Fresco这类图片加载框架它们内部会处理缓存、采样和生命周期。当时笔试中的另一个选项是Bitmap的像素格式ARGB_8888、RGB_565、ALPHA_8三种格式分别占4字节、2字节、1字节。RGB_565不支持透明通道但能省一半内存对于一些不需要透明的图片场景比如很多UI切图很实用。这道题的跨度蛮大从内存计算到格式选型如果平常只是用Glide调一下接口这些细节根本不会注意到。6. 构建链路与代码治理从R8到包体积优化6.1 R8与ProGuard的演进关系2018年时大家还在普遍使用ProGuard做代码混淆和压缩但R8当时已经随着Android Studio 3.4预览版开始出现在开发者的视野里。商汤笔试的多选题里有道题是关于R8和ProGuard的说法正确的有哪些。选项包括R8可以替代ProGuard、R8支持代码压缩和资源压缩、R8可以优化方法内联、R8只适用于release构建。前三个都是对的第四个是错的因为R8也可以在debug构建中启用只是不建议。R8的核心能力是结合了ProGuard的混淆、压缩、优化和脱糖四个步骤把整个构建流程中原本分开的环节合并起来从而提升构建速度并减少DEX体积。它的工作方式是把字节码分析后删除未使用的类、方法、字段同时进行方法内联、常量折叠等优化最后再对类名和方法名进行混淆。我在项目里从ProGuard迁移到R8的过程中最明显的感受是构建时间缩短了大概20%APK体积缩小了大约5%。但迁移过程中要特别小心keep规则。R8对keep规则的解析和ProGuard有些差异如果某些类只依赖于反射调用且没有配置keep规则很可能在运行时出现ClassNotFoundException。商汤在那个时间点考R8和ProGuard的对比某种程度上是在筛选对构建工具链有认知的候选人。很多应届生只会在Android Studio中勾选minifyEnabled却没有真正研究过构建过程中做了什么这种差距在笔试中会被放大。我现在的习惯是每次发布release包时都会开启R8并查看build/outputs/mapping/release/usage.txt来检查哪些类被移除了确保反射调用路径没有被破坏。6.2 包体积优化与APK分析笔试中有道选择题问“减小APK体积的常用手段有哪些”选项覆盖了图片压缩、移除无用资源、开启资源混淆、拆分DEX。大部分人都能选对前三个但第四个有讲究拆分DEX是为了绕过方法数限制并不直接减小APK体积反而因为增加了DEX文件数量包体可能会略微变大。这个选项放在这里就是用来拉分的。我做过一次APK瘦身优化方法比较常规但效果显著先把所有图片资源转成WebP格式包体直接减少了40%然后通过gradlew app:dependencies分析哪些依赖被传递引入但并未使用逐一剔除最后开启android:extractNativeLibsfalse和资源压缩又是百分之几的收益。这套组合拳走下来APK体积从45MB降到了29MB用户下载转化率有一定提升。值得一提的还有shrinkResources属性。它需要和minifyEnabled同时开启配合资源混淆器把未使用的资源删除。但这个工具偶尔会误删动态引用的资源所以在开启之后要重点回归测试通过getResources().getIdentifier()动态获取资源的分支。6.3 DEX与分包机制对启动的影响关于DEX多包和启动优化商汤笔试有一道判断题“使用MultiDex会导致应用启动变慢因此应该尽量减少DEX数量。”这个说法不准确。MultiDex确实会在低版本Android上增加启动时加载DEX的开销但DEX数量本身并不是决定启动速度的单一因素。DEX加载的耗时主要取决于DEX文件的大小、DEX在APK中的位置是否被压缩、以及设备对DEX的编译方式。在Android 5.0以上系统默认支持MultiDex且通过ART运行时在安装时执行AOT编译启动时不会重复执行MultiDex逻辑。所以这个判断题的答案是错误。这道题让我想到在项目中做的启动优化通过adb shell am start -W测量启动时间发现Application中的MultiDex.install()耗时占到了启动总时间的15%左右。优化方案是使用ReLinker或者按需加载DEX把非必要的模块拆成动态特性模块只在需要时加载。商汤笔试能考到这个细节说明出题人对Android构建和启动链路都有深入理解。7. 编程题复盘边界条件与状态设计7.1 链表反转与环形链表检测的变体商汤第二场的第一道编程题是链表相关给定一个单链表每K个节点反转一次如果剩余节点不足K个则保持原有顺序。这题在LeetCode上能找到原型25. Reverse Nodes in k-Group属于常见的hard题。但笔试时间有限我用了递归的方式来写核心思路是找到每K个节点的区间反转区间内节点然后递归处理后续链表。边界条件容易出错的地方有三个链表为空、链表长度不足K、K为1。我在草稿纸上先写了一个简单的辅助函数reverseRange来处理区间反转然后递归调用主函数。代码写完大概花了15分钟提交前又自行构造了几个测试用例来验证包括K3、链表长度7的情况确认为3-2-1-6-5-4-7符合预期。这道题在笔试中拿满分不难但需要基本功扎实尤其是对链表指针操作的熟练度。7.2 LRU缓存实现与双向链表HashMap第二道编程题是LRU缓存实现要求设计一个支持get和put操作的数据结构并且get的时间复杂度为O(1)。这是非常经典的设计题商汤在笔试中考察它可能是因为缓存管理在AI应用和服务端交互中都非常常见。我采用的方式是双向链表HashMap链表尾部表示最近最少使用头部表示最近使用。关键点是put时如果key已存在先更新value并把该节点移动到链表头部如果key不存在且缓存已满删除链表尾部的节点并同步移除HashMap中的对应项然后插入新节点到头部。这道题的坑在于删除节点时除了更新链表指针还要记得更新HashMap否则后续get会命中已有的旧key。我在笔试中把这两步拆开写并在删除操作后检查了HashMap的size与链表的size一致性这个习惯能从逻辑上避免同步遗漏的问题。这道题在项目里也有实际参考价值。比如图片加载框架的磁盘缓存、网络请求的响应缓存本质都是LRU思想。我在项目里也用LinkedHashMap实现过简单的内存缓存通过重写removeEldestEntry来控制容量上限代码量很少但效果稳定。笔试之后我把LeetCode上LRU的题目反复做了三遍确保闭着眼睛都能写出来因为这道题在后续多家公司的面试中又被问到了两次。7.3 算法题的时间复杂度与空间复杂度分析除了代码实现商汤笔试还要求对每道编程题的时间复杂度和空间复杂度进行分析。我记得当时的答案链表K个一组反转的时间复杂度是O(n)空间复杂度是O(n/k)递归栈的深度如果改成迭代实现空间复杂度可以降到O(1)。LRU缓存的get和put操作都是O(1)空间复杂度是O(capacity)。这个要求在一般的校招笔试中不常见更多出现在字节、Shopee这类公司的面试中。商汤在笔试中就提出这个要求说明他们对候选人的算法功底不仅要求能写出代码还要求有分析能力。我在后来的面试准备中刻意在每做完一道算法题后把复杂度和优化空间写下来这个习惯对面试帮助很大。8. 笔试复盘后的学习路线建议8.1 按知识模块整理的准备清单复盘完整场笔试之后我把知识点整理成了一份可以反复刷的清单这份清单后来成了我准备其他大厂Android岗位的利器。无论你是准备校招还是社招只要能把这几个模块吃透应对大部分Android笔试都没有问题。模块必背知识点推荐验证方式Java并发volatile、synchronized、ReentrantLock、线程池参数写多线程Demo观察执行结果消息机制Handler、Looper、MessageQueue、同步屏障源码阅读Trace验证组件机制Activity启动模式、Service绑定、广播限制构造多场景实验验证View体系事件分发、measure/layout/draw、属性动画自定义View实践内存优化泄漏场景、Bitmap计算、ANR定位LeakCanaryCPU Profiler实操构建工具R8、ProGuard、DEX分包、资源压缩对比release包体积和构建时长算法链表、二叉树、LRU、手写快排LeetCode高频题50道8.2 时间分配与备考顺序备考建议把时间分成三块第一块是基础知识扫盲重点过一遍Java并发、Android四大组件、View体系目标是能把概念讲清楚能举出实际开发中的例子。第二块是源码深挖这一阶段不要停留在API调用层面要能把Handler的源码流程、Activity启动流程、Binder通信机制在脑子里完整走通。第三块是刷题以LeetCode的链表、树、动态规划、LRU为主每天保持2-3道题的节奏并刻意练习手写代码。商汤第二场笔试给我的整体感觉是它不偏门但要求你在日常开发中真的爱思考。比如同样是在用Glide加载图片有人只是调个接口有人会去研究Glide的缓存策略、生命周期绑定和内存优化策略。笔试中那些容易拉分的题恰恰就是筛选出后面那类人的。所以备考的重点不是背题而是把日常开发中的“知其然”升级为“知其所以然”。8.3 笔试之外的附加值实际项目的经验沉淀最后说一点题外话。商汤的笔试虽然只考了两个小时但它折射出的知识结构恰恰是成为一名合格Android工程师的基本盘。我在那次笔试之后有意识地把项目里遇到的每一个疑难杂症都记录下来包括复现步骤、排查思路、解决方案半年下来积累了二十多个案例。这些案例在后续的面试中成了我最宝贵的素材每次面试官问“项目中遇到过什么难点”我都能从这些案例里挑一个讲得既详细又有深度。如果现在让我重新准备一次商汤的笔试我会把更多时间花在“为什么”上——为什么BadgeParser要这样做、为什么View的绘制流程是measure、layout、draw而不是其他顺序、为什么Android要使用Binder而不是System V共享内存。这些问题看似深奥但一旦想通了笔试中再遇到任何变化多端的题目都能回归到底层原理去推导答案。这才是校招笔试真正的意义不是考你知道多少个API而是考你能否在有限信息下依靠对系统的深度理解做出正确的判断。