
「拿到笔试链接的时候说实话我心里是没底的。」2023年秋招我投的是小红书Android开发岗第一批笔试通知来得比预期要早。当时翻遍了牛客和各类技术社区关于这场笔试的具体内容分享非常少大多只有一句「考得比较基础」这种模糊描述。真正坐到电脑前开始答题才发现这套题和市面上流传的Android八股文差异比想象中大得多。这篇文章我想把整场笔试从收到邮件到交卷的完整过程复盘一遍包括题型结构、每题背后的考察点、考场上踩的坑、以及交卷之后我做了什么。如果你也在准备小红书或者同类公司Android岗位的秋招笔试这篇文章应该能帮你少走一段弯路。1. 这批笔试的基本盘平台、题量与时间分配1.1 笔试平台与考试流程我这场笔试用的是牛客网的在线笔试系统正式考试前半小时就要进入考场调试摄像头、麦克风和屏幕共享。这里有一个非常现实的提醒牛客网对切出页面、打开本地IDE、切后台这类行为会有记录不同公司对此的容忍度不一样。小红书的规则是没有明确的切屏次数上限但每一笔违规记录都会留在后台后续是否影响面试筛选谁也说不准。所以我全程只在网页编辑器里写题哪怕代码提示再难用也忍着不切出去。整个笔试时长是120分钟题量分成四块单选题、多选题、简答题、编程题。题量不算夸张但每道题都飘着一种「我知道你复习过但我要看看你是真懂还是假懂」的气息。选择题看似简单实际每个选项都要细看简答题和编程题则需要留出足够时间勉强赶完很容易在最后几道送分题上写崩。1.2 四类题型的结构分布我按记忆整理了一个大概分布每个批次的题量和分值可能有浮动以实际邮件通知为准模块题量分值占比建议用时单选题约10题25%20分钟多选题约5题15%15分钟简答题2题20%35分钟编程题2题40%40分钟时间分配上我最失策的地方就是前面对多选题纠缠太久导致简答题后半段写得很赶。如果重来一次单选题最多20分钟多选题最多15分钟遇到犹豫超过1分钟的题直接先标记跳过等所有会的题做完再回头。笔试的核心原则永远是「先拿稳分再啃难题」。1.3 第一批笔试的定位差异小红书的秋招笔试分批次安排提前批投递的简历基本都被分在第一批。第一批笔试的题量未必比其他批次大但特殊性在于它处在秋招最早期大家普遍还没进入刷题状态这时候笔试成绩对后续面试安排的参考权重会比想象中更高。所以别抱着「试试水」的心态去考每一场正式笔试都值得当面试一样认真对待。我后来复盘时发现这场笔试的题目整体难度控制在「基础扎实就能过、基础不牢就露馅」的水平这也是大厂校招笔试的主流策略。所谓的高难度题其实并不在于题目有多偏而在于考察链路的完整性你只有真正写过代码、看过源码、排过生产环境的问题才能在简答题里写出层次感。2. 选择与多选基础知识的考察边界到底划在哪2.1 单选覆盖的知识面单选题部分给我的整体印象是不难但非常细。考点集中在Java/Kotlin基础、JVM内存布局、并发与集合、Android四大组件与Handler机制、View绘制流程以及少量网络协议。几乎每一题都让我产生「这题我好像见过」的既视感但选项里一定会埋一个模棱两可的坑。几个我印象比较深的知识点HashMap在JDK 8里的put流程包括hash扰动函数、插入链表还是插入红黑树的判断条件。很多面试题只背了「链表长度超过8转红黑树」但真正准确的答案是链表长度超过8且数组长度不小于64时才会树化否则优先扩容。synchronized锁升级的偏向锁、轻量级锁、重量级锁各自适用的竞争场景。这里最容易丢分的是「偏向锁在JDK 15开始默认被禁用」这类时间线细节笔试题目可能故意写成「JDK 8里偏向锁默认开启」来试探你。String、StringBuilder、StringBuffer的差异以及字符串常量池在拼接时的行为。比如用final修饰的字符串变量拼接会被编译器优化进常量池没用final的引用拼接则走StringBuilder。View的requestLayout和invalidate的区别。一个会触发measure和layout一个只触发draw但如果你在子线程里调invalidate会得到CalledFromWrongThreadException而requestLayout正常情况下只允许主线程调用。Handler.postDelayed的消息延迟精度到底受什么因素影响。正确答案是受Looper所在线程的消息队列排队情况影响系统会按delay时间戳排序但前一个消息如果执行太久后面的消息就会顺延。这类题对工作一两年的Android开发其实是送分题因为都是一天到晚在用的机制。但对在校同学来说如果只看过《Android开发艺术探索》的章节标题而没有真正撸过源码很容易被选项里的细节坑到。2.2 多选题的边界陷阱多选题是本场笔试真正考验「知识边界感」的部分。它不像单选那样选一个正确答案就结束而是把某一个知识点的不同应用场景分到四个选项里问你哪些说法正确。少选、错选、多选按规则都不得分。举一个方向当例子比如ANR的多选题四个选项分别描述前台BroadcastReceiver超时时间、后台Service超时时间、ContentProvider启动超时是否算ANR、InputDispatching超时的具体阈值。你不仅要记住「前台广播10秒、后台广播60秒」这种数字还得知道Service前台是20秒、后台是200秒更要知道ContentProvider启动超时在部分版本上也算ANR。一个数字记错整道题就没了。我在考场上采用的策略是对没有绝对把握的选项宁可少选。牛客系统的多选评分规则通常是选错不得分、少选得部分分所以「选确定项」比「抢分」要稳得多。这个策略在后面的多选模块帮我至少保住了两道题的分数。2.3 新特性到底考不考我在考前花了不少时间准备Jetpack Compose、Kotlin协程、Flow这些新东西。结果选择题几乎没怎么涉及ComposeKotlin相关考察也停留在let/apply/run的区别、协程的launch/async返回值类型这些层面。不是说新特性不重要而是笔试题的视角在告诉你笔试考察的是你「能不能立刻上手干Android的活」而不是「能不能跟上技术新潮流」。新特性大概率会放到面试环节去聊。这也意味着大家在准备笔试时优先级应该是Java/Kotlin基础语法与集合框架、Handler/Looper/MessageQueue机制、View绘制流程、四大组件生命周期、网络协议基础这些要占掉七成精力。Compose这类新技术了解使用方式就够了不建议花大把时间死磕。3. 简答题Handler内存泄漏与ANR层次感决定分差3.1 Handler内存泄漏的完整答案链路这套笔试有两道简答题第一道就是经典中的经典Handler导致内存泄漏的原因以及处理方案。这个题在面经里被写烂了但真正能拿高分的答案并不多。如果只写「用WeakReference包一下」就是送死因为面试官想看到的是完整的分析链路。我当时是按「引用链描述 根因定位 解决方案 工程化措施」四层来写的第一层引用链描述。非静态内部类或匿名内部类默认持有外部类的引用这里的典型场景是Activity里new一个HandlerHandler未处理完的Message被MessageQueue持有Message持有Handler引用Handler持有Activity引用。由于主线程的Looper在App进程存活期间不会退出MessageQueue只要存在这条引用链就不会断裂GC就无法回收Activity。第二层根因定位。核心问题是Message的生命周期可能比Activity更长尤其用postDelayed发出的延迟消息即使Activity已经finish它也会在消息队列里躺到延迟时间到达。所以内存泄漏的根源不是Handler本身而是「延迟消息 长生命周期Looper 隐式持有Activity」这三个条件叠加。第三层解决方案。最经典的做法是使用静态内部类WeakReference持有Activity同时在onDestroy里调用handler.removeCallbacksAndMessages(null)把消息队列里与该Handler相关的所有消息一并移除。这两件事必须同时做只做前者解决不了「消息还在队列里占内存」的问题只做后者没法应对Handler可能被其他地方复用的情况。第四层工程化措施。在Kotlin环境里可以换用协程生命周期感知的Scope替代Handler利用Lifecycle框架在onDestroy时自动取消协程从根上避免这种问题。3.2 ANR的定位、分析与解决第二道简答围绕ANR展开什么是ANR、如何定位、如何解决。这道题很容易答成「主线程不能做耗时操作」一句话那就废了。我用的是「类型判断-获取现场-原因分析-修复思考」这个流程。类型判断上ANR有几种常见形态InputDispatchingTimeout是5秒前台BroadcastReceiver是10秒后台BroadcastReceiver是60秒前台Service是20秒后台Service是200秒ContentProvider启动超时在部分版本也被视为ANR。不同超时时间背后是不同的调度策略前置知识不能错。获取现场方面关键动作是从设备导出 /data/anr/traces.txt或者用 adb pull /data/anr/ 抓取ANR发生的调用栈。如果现场正好在Android 10以上设备可以用perfetto抓取systrace数据看主线程执行的时间线。原因分析上常见的主线程阻塞源有主线程直接做磁盘I/O、等待锁时发生锁竞争、Binder调用主线程同步等待、死锁、广播接收器里做耗时操作、View层级中的过度测量等。答案必须有具体场景而不是「主线程慢」这种空话。解决思路上要把耗时操作移到子线程或协程中减少主线程消息队列的积压避免在持有锁时做耗时操作。最关键的一点ANR机制是Android的自我保护我们要做的是「让主线程更快消费消息」而不是简单地把消息队列清掉。3.3 怎么答题才像「有实战经验的人」简答题在全卷里分值不是最高的但它留给面试官的印象是其他题型比不了的。因为笔试答卷会被当成面试时的参考材料面试官会从你简答题的排版和逻辑里判断这个人有没有debug的习惯。我当时的写法是分点加粗每个层次一段整体用「现象-原因-定位-解决-验证」的结构展开。另外提醒一点笔试答题框不是IDE不要在里面写大段代码。简答题的重点是思路清晰、逻辑完整代码展示反而会显得你表达不够凝练。写上关键API名和伪代码流程就够了。4. Activity启动流程与AMS这批题里真正的分水岭4.1 startActivity到首帧渲染的完整链路小红书这批笔试里有一道让我印象很深的题核心是让你描述从startActivity到Activity显示在屏幕上的完整流程。这种题是典型的Framework源码题单靠背《Android开发艺术探索》是答不全的。我当时按这条链路写的应用进程调用startActivity经过Instrumentation的execStartActivity方法通过Binder调用到系统进程的AMS也就是ActivityManagerService。这里要注意的是Android 10以后Activity调度职责被抽到了ATMS即ActivityTaskManagerServiceAMS更偏向进程管理和内存管理但整体上我习惯把它们合称AMS。ATMS收到请求后会校验调用方的权限、解析Intent、判断启动模式和任务栈配置。如果目标Activity所在进程还没启动就需要通过Socket向Zygote进程发送请求Zygote fork出新的应用进程。新进程启动后会创建ActivityThread实例调用main方法创建主线程Looper然后调用attach方法把ApplicationThread这个Binder对象注册给AMS告诉系统「我准备好了」。接下来AMS通过ApplicationThread这个Binder代理通知ActivityThread去创建Activity。ActivityThread通过ClassLoader加载目标Activity类创建实例并调用attachActivity的attach里会创建PhoneWindow和WindowManager。随后系统调度Activity生命周期依次执行onCreate、onStart、onResume。在onCreate里我们通过setContentView设置布局onResume之后ViewRootImpl接管视图发起measure、layout、draw流程最终由SurfaceFlinger合成并上屏。这道题的关键不仅是把流程背下来还要在每个节点标注出「这里是Binder跨进程调用」还是「这里是Handler消息驱动」这是能拉开差距的层次。4.2 Binder为什么是Android IPC的默认选择和启动流程一起考的还有一个Binder机制的简答。题目很直白为什么Android选Binder而不是共享内存、管道、Socket来做IPC。我当时的回答包含三个层面第一性能上Binder只有一次数据拷贝。传统的管道、Socket机制数据从发送进程到接收进程至少需要在用户态和内核态之间拷贝两次而Binder通过内核分配的一块匿名共享内存做映射数据从发送进程直接拷贝到内核缓冲区再通过内存映射让接收进程直接读取全程只有一次拷贝。第二安全上Binder具备调用方身份校验。Binder驱动在内核态就能拿到调用方进程的UID/PID系统服务可以基于这个信息做权限校验伪造调用方身份比传统IPC难得多。这套机制对整个Android系统安全模型至关重要。第三语义上Binder是面向对象的远程过程调用。客户端拿到的Binder代理对象可以像调用本地方法一样调用远程服务这种设计让系统服务上百个接口的管理变得简单清晰。如果在回答里能补充「Binder一次拷贝但MMAP依旧会有一次内存同步开销」这种细节会显得你真的研究过而不是背框架。4.3 AMS与ATMS的新旧术语辨析Android 10之后启动流程里最大的变化是Activity的管理职责从AMS拆分到了ATMS。传统框架在面试题里经常把AMS和Activity管理绑定说但新版系统里ActivityTaskManagerService负责Activity栈、Task和Window的调度AMS则聚焦于进程、内存、广播队列的管理。这道题我没等到面试官问直接在简答题末尾加了一句话「在Android 10以上的版本中Activity调度被拆分到了ATMS整体架构上是AMSATMS协作完成启动流程。」后面面试的时候面试官确实对这句话产生了兴趣追问我ATMS和AMS的边界到底怎么划分。从结果看这一句补充的性价比非常高。5. 工程化大题R8、启动优化与包体积控制5.1 R8在构建流程里干了什么小红书这套笔试里有一道工程化方向的题目题干给了一个和混淆日志相关的现象问应该如何配置保留规则防止指定类被裁剪或混淆。这题背后的知识点是R8的四个核心动作。R8现在已经替代ProGuard成为Android默认的代码压缩与混淆工具它做四件事压缩删除不可达的类和成员优化对字节码做常量折叠、方法内联等操作混淆把类名方法名改成无意义短名脱敏适配字符串资源对类名的引用。实际配置时最常踩的坑是被反射调用的类、被Gson序列化的模型类、被注解处理器生成的代码在混淆后找不到。最简单的处理方式是给这类类加Keep注解或者配置-keep class com.example.model.* { *; }。但更细致的做法是区分场景反射类要保留类名Gson模型要保留无参构造器和字段名Keep注解在R8里也会被正确识别。如果你在笔试里能写出-keepclassmembers和-keepattributes的区别说明你对混淆规则的颗粒度理解到位。前者是「只保留成员规则不保留类」后者是「保留注解、Signature等元信息」这也是很多线上崩溃的根因。5.2 启动优化的完整答题闭环另一道工程题问的是冷启动优化策略。这种题没有标准答案但答题一定要体现「定位问题-确定瓶颈-动手优化-验证效果」的闭环而不是堆几个「异步化」「懒加载」的术语。我的回答分三步第一步用Systrace或Perfetto记录冷启动时间线区分Application的onCreate、Activity的onCreate和首帧渲染各占多少时间。只有数据出来了才知道优化精力该投在哪。第二步针对主线程上的耗时点逐一处理非核心SDK初始化移到子线程使用有向无环图的任务调度框架做并行初始化SharedPreferences读取拆分并异步化内容加载改成懒加载。第三步首帧渲染层面简化布局层级避免过度绘制减少冷启动阶段的主线程磁盘IO。最后加一个验证手段用adb shell am start -W 包名/.MainActivity查看TotalTime或者用自定义的StartUpTimer埋点统计。有数据对比才能证明优化有效。5.3 包体积优化的规模化方案包体积的题通常不会问你「一张图能压多少」而是问「你怎么在团队里持续控制包体积增长」。当时我的回答分资源、代码和CI三条线资源线上开启shrinkResources配合资源混淆把大图库替换成WebP通过资源打包配置按需拆分多语言资源。代码线上R8裁剪无用的Java代码按ABI拆分so文件避免全量打包64位和32位两套库必要时用动态特性模块化把低频功能做成远程模块。持续化建设上在CI流水线里加上包体积Diff检查单次PR导致包体积超过阈值就直接拦截。这个思路比任何优化技巧都重要因为包体积问题本质上是一个增量的工程管理问题不是一次优化就能搞定的。我特意在答案里加了「小红书作为内容社区App启动速度和包体积对用户留存非常敏感」这个场景化表述让回答更贴近投递公司的业务形态。6. 编程题两道题从读题到提交的完整过程6.1 第一题字符串按字符频率降序输出编程题第一道是字符串处理给定一个字符串按字符出现频率降序输出频率相同时按字典序或原顺序输出。这题的核心是HashMap统计加排序思路很直白难点在代码的稳健性。我的Java解法public String frequencySort(String s) { if (s null || s.isEmpty()) { return ; } MapCharacter, Integer freq new HashMap(); for (char c : s.toCharArray()) { freq.put(c, freq.getOrDefault(c, 0) 1); } ListCharacter chars new ArrayList(freq.keySet()); chars.sort((a, b) - { int cf freq.get(b) - freq.get(a); return cf ! 0 ? cf : a.compareTo(b); }); StringBuilder sb new StringBuilder(); for (char c : chars) { int count freq.get(c); for (int i 0; i count; i) { sb.append(c); } } return sb.toString(); }这道题有四个容易丢分的地方字符范围不止字母可能有数字、下划线甚至中文所以容器类型必须用Character而不能写死a到z排序比较的是频率而不是字母本身空串和单字符的边界要处理最后拼接字符串的时候注意用capacity预估避免StringBuilder反复扩容。6.2 第二题矩阵连通区域的最大面积第二道是DFS/BFS的图论基础题描述是在一个由0和1组成的二维矩阵中找到相邻1组成的最大连通区域面积。核心是DFS每个格子最多遍历一次时间复杂度O(row*col)。public int maxAreaOfIsland(int[][] grid) { if (grid null || grid.length 0) { return 0; } int rows grid.length; int cols grid[0].length; int max 0; for (int i 0; i rows; i) { for (int j 0; j cols; j) { if (grid[i][j] 1) { max Math.max(max, dfs(grid, i, j, rows, cols)); } } } return max; } private int dfs(int[][] grid, int i, int j, int rows, int cols) { if (i 0 || i rows || j 0 || j cols || grid[i][j] 0) { return 0; } grid[i][j] 0; return 1 dfs(grid, i 1, j, rows, cols) dfs(grid, i - 1, j, rows, cols) dfs(grid, i, j 1, rows, cols) dfs(grid, i, j - 1, rows, cols); }有几个细节值得注意DFS会修改原数组来标记已访问如果题目要求不修改输入就得额外开布尔数组递归方案在大矩阵上可能栈溢出技术上可以用显式的循环栈替代递归但笔试环境里递归通常够用判断顺序上越界检查必须放在访问数组元素之前。6.3 考场上的一次翻车与补救实话说第二题我在考场上先写了一个BFS版本结果在出队时的visited标记时机上卡了几分钟。后来我果断删掉BFS代码退回写法最朴素的DFS一次通过。这在笔试里是很常见的情况不是你不会而是在时间压力下容易把方案想复杂。我的经验是笔试编程题的正确性远比代码优雅重要。如果你没有十成把握写出一个复杂的解法就选择最稳的解法先拿分。有的同学在考场上非要写一个带剪枝的优化版本结果边界条件处理不完反倒连基础分都没拿到。7. 复盘笔试之后我做的几件事以及一份备考清单7.1 用错题反向建立知识树交卷之后我没有立刻开始刷下一家的题而是花了半天把笔试里所有拿不准的选择题记录下来按「Java基础」「Android机制」「网络协议」「工程化」四类归到自己的知识点表格里。笔试里的错题是最宝贵的复习材料它直接暴露了你的盲区在哪个具体分支上比随机刷题高效得多。这次笔试之后我把Android岗的准备重心调整成了这样几块把《Android开发艺术探索》里Handler、AMS、View绘制三章重读一遍在源码层面画出startActivity和Binder的整体时序图不求背行号但要能讲清楚每一步是谁调用了谁每周固定做两到三道DFS/BFS、字符串处理和链表相关的算法题整理一份「一页纸答题模板」凡是遇到原理题就按「背景-机制-场景-边界-解决」来组织文字保证不丢层次。7.2 一份可以直接抄的备考清单结合这场笔试和我整个秋招的经验整理了一份实用性优先的清单Java/Kotlin基础集合源码、HashMap的put流程、锁机制与并发、String不可变性、Kotlin协程的launch与async差异。Android基础Handler/Looper/MessageQueue、View测量布局绘制流程、事件分发机制、四大组件生命周期、任务栈与启动模式。Framework进阶startActivity完整流程、Binder机制、Zygote进程创建、ANR原理、AMS与ATMS的分工。工程化与性能R8压缩混淆规则、冷启动优化、包体积控制、内存泄漏检测、稳定性监控思路。算法与数据结构字符串、哈希表、队列与栈、DFS/BFS、链表、二叉树层序遍历、双指针。7.3 非技术层面的几个提醒笔试当天提前调好设备摄像头、麦克风、网络都提前试一遍。答题遇到不会的选择题要学会战略性跳过把时间留给编程题。我见过不少人在多选题上死磕结果后面简答题只写两行、编程题超时这种失分是最可惜的。笔试本质上考的是「你有没有真正做过Android开发」。写过项目和只刷面试题的差距在简答题的组织层次和编程题的容错性上会体现得很明显。这也是为什么有些同学看起来什么都背过分数却并不理想。最后说一个我自己的体会小红书这批笔试结束后大概一周内收到了面试邀约整体推进节奏比想象中快。如果你笔试感觉还不错那几天就开始复盘自己的项目经历不要等面试通知来了再匆忙准备那时候真的会手忙脚乱。