ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

小红书Android笔试复盘:启动模式、Handler与源码细节

小红书Android笔试复盘:启动模式、Handler与源码细节 2024年春招那阵子我投了小红书的Android开发岗简历筛选通过后收到了第一批笔试邀请。这批笔试和我想象中不太一样它不单纯是算法题堆砌而是把Android基础、源码理解、代码设计、项目场景判断都揉在一起考。我整理了一下当时的复盘笔记把考点分布、真题思路、错题原因都梳理出来今天一次性写清楚给接下来准备Android大厂笔试的朋友做个参考。1. 笔试整体情况与考点分布先说这批笔试的基本盘。整场笔试大约两个小时题量不小总分100分题型分三块单选题、多选题、编程题。多选题和单选题大概有30道左右覆盖Java/Kotlin语法、Android四大组件、Handler消息机制、自定义View绘制流程、性能优化、三方框架原理这些。编程题有两道一道是纯Java/Kotlin编码题一道是偏Android场景的代码设计题。我当时的感受是笔试难度属于“中偏上”但它难在考察点很细细到你在项目里根本不会注意的角落。比如有一道题问startActivityForResult废弃后新的ActivityResultLauncher在不同场景下的回调时机这个知识点平时写业务代码基本不会深究但笔试就是要考你对API演进的掌握程度。另一个印象很深的是多选题的判分很严格少选、错选都拿不到分。所以做题策略上没有十足把握的选项就不要选保住确定项至少能拿到部分分。从考点热度来看我统计了一下自己错的题和周围同学交流后的反馈排在前几位的知识点分别是Activity启动模式与返回栈、Handler/Looper/MessageQueue机制、Binder原理与AIDL、协程的调度与异常处理、Gradle与AGP版本适配、RecyclerView复用机制、内存泄漏的场景判断。这些知识点其实都是Android面试的“老八股”但笔试的考法更倾向于“给你一段代码让你判断输出结果或运行状态”而不是直接问你“什么是启动模式”。这就要求你在准备时不能只背概念必须把每个机制的核心源码和运行流程吃透。2. 单选与多选细节题的坑2.1 单项选择题的考察方式单选和多选混在一起考察面很广。我记得有一道题是给出了一段代码让你判断在哪种启动模式下会执行onNewIntent()。这是典型的源码级考察不熟悉Activity的启动流程就很容易选错。我当时靠的是自己总结的一张表这里直接分享出来启动模式是否新建实例是否回调onNewIntent与现有Task的关系standard每次新建否进入启动者所在TasksingleTop栈顶复用否则新建是栈顶复用时启动者所在TasksingleTask栈内唯一否则清除其上Activity是已有实例时指定Task或启动者所在TasksingleInstance全局唯一独享Task是独立Task这道题问的是一段代码Intent intent new Intent(MainActivity.this, SecondActivity.class); startActivity(intent);然后SecondActivity声明为singleTask而且已经在其他Task中存在。答案就是会复用已有实例并回调onNewIntent同时把栈顶以上的Activity全部出栈。这类题准备起来其实有套路核心就是理解四种模式与Task的关系以及onNewIntent触发条件。我在复习时做了一个小Demo把四种模式都跑了一遍打印出onCreate、onNewIntent、onStart、onResume、onDestroy的调用顺序这样比死记结论有用得多。2.2 一道类似AIDL的易错题还有一道题让我印象很深题目本身不复杂但考察得很细。大意是给你一段定义AIDL接口的代码让你判断编译后自动生成的Stub类和Proxy类的职责。我当时差点选错了onTransact()相关的选项。正确的理解是Stub是Binder的服务端接收客户端IPC请求在onTransact()中根据code分发到对应的接口方法Proxy是Binder的客户端代理把接口方法调用包装成transact()流程。两者通过Binder驱动完成数据序列化和反序列化。这种题如果没有真正用AIDL写过跨进程通信很容易停留在“知道Binder是Android的IPC机制”这个层面试但笔试偏偏考的就是你在实现层面的理解程度。我当时没有直接死在Binder原理上而是把Service、AIDL、Messenger三种跨进程通信方式都手写了一遍对比了各自的适用场景。多说一句笔试里有好几道题都是这样表面在考API使用实际在考底层实现。所以如果你准备时间充裕建议把Binder驱动层的基本流程也过一遍不用深入到内核源码但至少要能讲清楚数据如何从客户端APP进程拷贝到Binder驱动再从驱动拷贝到服务端进程以及mmap在内核空间映射中的作用。3. 编程题启动模式复用与状态提升3.1 第一道编程题模拟返回栈与启动模式编程题第一道其实是“披着Android外衣的栈模拟题”。题目内容是给定一组启动序列每个Activity有指定的启动模式要求模拟运行后每个Task栈的栈内元素。这题说白了就是考察你对启动模式的理解加上基础的数据结构运用能力。我当时的实现思路是用ArrayListActivityStack模拟多个Task每个Task内部用一个ArrayListActivityRecord模拟栈。处理singleTask时要先检查所有Task中是否存在该Activity的实例若存在则需要把该实例之上的Activity全部弹出然后把它移到栈顶standard模式就直接压入启动者的栈singleTop需要判断栈顶是否是同一Activity。这题的难点不在代码量而在边界条件的处理。比如singleTask实例存在的时候如果它不在栈顶需要先把其上方的Activity弹出再把该Activity移到栈顶同时要维护一个全局列表中Activity的存活状态。还有一点容易漏就是启动一个singleInstance的Activity时它需要独享一个Task如果该Activity已经存在则需要将其Task整体移到前台。我最后用Kotlin实现的核心逻辑大概长这样data class ActivityRecord(val name: String, val launchMode: LaunchMode) class ActivityStack { val tasks ArrayListArrayDequeActivityRecord() fun launch(record: ActivityRecord, fromTaskIndex: Int) { when (record.launchMode) { LaunchMode.STANDARD - { tasks[fromTaskIndex].addLast(record) } LaunchMode.SINGLE_TOP - { val stack tasks[fromTaskIndex] if (stack.isNotEmpty() stack.peekLast().name record.name) { // 复用栈顶不新建实例 } else { stack.addLast(record) } } LaunchMode.SINGLE_TASK - { var targetIndex -1 var indexInStack -1 tasks.forEachIndexed { idx, stack - val pos stack.indexOfLast { it.name record.name } if (pos 0) { targetIndex idx indexInStack pos } } if (targetIndex 0) { val stack tasks[targetIndex] while (stack.size - 1 indexInStack) { stack.removeLast() } } else { tasks[fromTaskIndex].addLast(record) } } LaunchMode.SINGLE_INSTANCE - { // 查找是否已有独立Task中存在该Activity var targetIndex -1 tasks.forEachIndexed { idx, stack - if (stack.size 1 stack.peekFirst().name record.name) { targetIndex idx } } if (targetIndex -1) { val newStack ArrayDequeActivityRecord() newStack.addLast(record) tasks.add(newStack) } } } } }这段代码我后续在IDE里跑通了处理了大部分边界情况。不过笔试的时候时间有限我建议优先把题意理解清楚再动手写。这类题对代码能力的要求不算很高更看重你能不能像计算机一样“模拟”系统的调度逻辑。3.2 第二道编程题实现一个响应式事件总线第二道题偏实际工程要求用Kotlin实现一个简化版的事件总线支持主线程和子线程的事件分发并具备一定的生命周期安全能力。这题直接命中了我在项目里用过EventBus和LiveData的痛点但因为平时只是调用API真正手写底层时发现坑不少。核心要求大概是支持在任意线程发布事件通过注解或注册目标方法反射调用。事件分发时可以指定在发布者线程执行还是切到主线程执行。需要处理订阅者生命周期避免内存泄漏。我先写了接口定义再写注册表再写分发逻辑。关键点在于反射获取订阅方法的时候需要区分方法的线程模式。我当时用的是注解方式类似Target(AnnotationTarget.FUNCTION) Retention(AnnotationRetention.RUNTIME) annotation class Subscribe(val threadMode: ThreadMode ThreadMode.POSTING) enum class ThreadMode { POSTING, MAIN }然后通过注册表把所有订阅类中带有Subscribe注解的方法保存起来。事件发送时遍历所有注册过的实例找到方法参数与事件类型匹配的方法再根据线程模式决定是直接调用还是post到主线程Handler。我当时写这题的过程比较顺利因为项目的模块通信用的就是轻量级事件分发我对订阅管理和线程切换都很熟。但有一个点差点写错反射获取方法时需要把isAccessible设为true否则在Java 8的环境下非公共方法反射调用会抛IllegalAccessException。还有一个细节是订阅者的注册需要支持lifecycleOwner绑定。简单实现就是在注册表里保存一个自定义封装对象里面同时持有目标实例和关联的Lifecycle实例在ON_DESTROY时自动解绑。这个如果笔试要求没有那么严可以做成简化版只支持手动unregister但写清楚设计意图也能加分。这题总结下来考察的是工程能力。你不光要把API设计得足够好读还要清楚反射、回调、线程切换这些基础操作背后的坑。备考时我建议大家不要停留在“用过EventBus”而是自己动手写一次简化版哪怕只支持一个线程模式也能让你对这些框架的理解上一个台阶。4. 高频Android知识点自查从原理到实战4.1 Handler/Looper机制不可缺失的底层功底笔试中Handler相关的题几乎是必考的。要么给你一段代码让你判断handleMessage的执行顺序要么问你ThreadLocal在Looper中的作用要么问IdleHandler什么时候被调用。说实话Handler这套机制本身不难难的是把它放到真实场景里推演。比如这样一道题主线程创建一个Handler子线程通过Handler.post一个任务紧接着主线程的MessageQueue里还有之前执行的延迟消息问任务执行顺序。要准确回答必须清楚MessageQueue是按时间排序的优先队列Handler.post不指定延迟时when0会排在队首。还有一种常见考法就是让你判断以下代码是否会造成内存泄漏public class MainActivity extends Activity { private Handler handler new Handler() { Override public void handleMessage(Message msg) { // 更新UI } }; }答案显然会漏。原因在于非静态内部类持有了外部类的引用而Handler又通过Looper在消息队列中持有MessageMessage持有Handler于是形成了一条“主线程Looper - MessageQueue - Message - Handler - Activity”的强引用链。如果消息还没处理完Activity就无法被回收。我当时答这类题的时候会有意识地把整个引用链写出来这样既能帮助自己理清思路也能在答案中体现出你对对象的引用关系足够敏感。4.2 Binder机制与IPC不止是说概念小红书这批笔试没有直接问你“Binder有什么优点”而是考了AIDL生成的Java类结构和事务调用过程。这说明现在的Android笔试已经进入了“源码理解”时代至少需要你熟悉AIDL的编码流程、Stub与Proxy的分工、transact和onTransact的调用时机以及linkToDeath和unlinkToDeath的使用场景。我给自己的复习方法是在Android Studio里新建一个AIDL文件编译后打开build/generated/source/aidl下的生成代码逐行阅读。你会发现Stub是一个继承Binder的抽象类内部实现了onTransact方法而客户端通过Proxy类调用接口方法时会构造_data、_reply两个Parcel然后调用transact方法把调用请求发给远程服务。这个过程如果你自己手动写过一遍笔试再怎么变形都不怕。还有一个需要留意的点是从Android 8.0开始Service必须使用前台服务才能保证不被系统频繁回收而bindService的调用方式、Context.BIND_AUTO_CREATE的作用以及onServiceConnected回调所在的线程这些都是常考的点。这里补一句onServiceConnected默认回调在绑定者的主线程所以里面可以直接更新UI但跨进程的耗时操作不能放在里面。4.3 内存与性能优化笔试里的“场景判断”性能优化在笔试中不会让你直接写一个大项目而是给你一个具体的现象让你推断可能的原因。比如“首页启动时出现明显卡顿可能的原因有哪些”。这类多选题往往需要你从多个维度去分析。我整理了一个自查表笔试前反复看了几遍现象可能原因启动卡顿主线程做IO、布局层级过深、过渡绘制严重、频繁GC列表滑动掉帧RecyclerView嵌套、Item布局过度重绘、图片没有缓存、频繁notifyDataSetChanged内存上涨静态集合持有Activity、Handler延迟消息未移除、匿名内部类持有外部引用电量消耗异常频繁唤醒CPU、WakeLock未释放、网络轮询短、定位回调不注销笔试考的就是你把原因和现象对应起来的能力。在准备这类题目时我建议把Android性能优化的几个方向都过一遍包括布局优化用ConstraintLayout降低嵌套层级、绘制优化onDraw中避免新建对象、内存优化合理使用SparseArray替代HashMap、启动优化异步初始化非必要组件、IdleHandler延迟加载、APK瘦身移除无用资源、使用App Bundle。当时有一道题问“冷启动时在Application.onCreate中做大量初始化有什么影响”选项包括“延长冷启动时间”“增加卡顿概率”“可能导致ANR”。这些都是正确的。但你选了这些之后还要想一想为什么Application的onCreate执行在主线程且早于第一个Activity的onCreate如果在这里做大量磁盘IO或者网络请求会直接阻塞UI渲染。5. 容易被忽略的细节从语言到构建工具5.1 Kotlin与Java混编时的可见性笔试里有几道题老老实实考Kotlin语法但角度比较刁钻。比如Kotlin的internal关键字在Java中会被编译成public这导致如果你在混合项目中Java代码可以绕过internal限制访问到Kotlin模块内部的API。这种题如果不踩一次坑很难答对。我实习的时候就遇到过一个问题一个Kotlin库模块中定义的internal类居然在Java代码里能直接引用。后来查了文档才知道internal在JVM平台并不是真正的“模块私有”编译后会变成public其名称也会被混淆成Modifier之类的名字。所以笔试一旦涉及Kotlin可见性记得务必选择“internal对Java不生效”这个选项。另外还有一个点Kotlin的空安全在Java互操作时也会失效。Java传入null给Kotlin非空参数运行时才会抛出NullPointerException而不是编译期。这意味着你不能说“Kotlin代码一定没有NPE”。5.2 Android Gradle Plugin 与新版工具链2024年春招这批笔试也考了一些跟构建工具链相关的题目。比如AGP版本与Gradle版本的适配关系、compileSdk、targetSdk、minSdk的区别以及BuildConfig字段如何开启。有一道题问Android Studio Hedgehog2023.1.1 Patch 2到底支持哪些AGP版本我复习的时候查过官网映射表Hedgehog比之前的Giraffe版本新一些支持AGP 8.1到8.3左右的版本如果你本地使用AGP 8.0整体也能兼容运行。但笔试不会问你具体版本号而是会给你几个组合让你判断哪些是可用的。这类题其实说穿了就是考察你是否关注过Android官方发布页的版本兼容表。我的建议是准备笔试时把当前主流Android Studio版本对应支持的AGP和Gradle版本范围记下来不用背精确版本号但要能分辨“AGP 8.2配Gradle 7.6”这种组合大概率是错的。另一个经常被忽略的点是namespace的配置。AGP 8.0之后build.gradle中的package属性被移除了必须替换为namespace否则编译报错。笔试虽然不会让你现场编译但会给你一段配置代码让你判断哪里写错了。如果你平时升级过项目大概率一眼就能看穿。5.3 文件Provider与跨应用URI访问热词搜索里连续出现了好几个content://com.baidu.searchbox.fileprovider/...和content://com.ss.android.uri.key/external_root/...这样的访问链接这说明实际开发中FileProvider的配置和URI授权是非常高频的问题。笔试不会直接给你这些链接字符串但会考你对FileProvider的理解。核心考点包括从Android 7.0开始file://URI直接暴露给其他APP会抛FileUriExposedException必须改用content://URI。通过FileProvider.getUriForFile()获取content://URI并在Intent中设置FLAG_GRANT_READ_URI_PERMISSION权限。file_paths.xml中配置的路径必须是应用私有目录的子集。如果你想分享外部存储中Android/data/目录下的文件路径要配成external-path并指向Android/data/包名。这类配置问题往往项目里用过一次就不会忘。但笔试考的是细节比如FileProvider的authorities必须全局唯一同一个手机里两个APP用同一个authorities会冲突导致运行时崩溃。6. 实操过程我是怎么准备这批笔试的6.1 我的复习时间分配说实话从收到笔试通知到正式考试中间只有大约一周时间。我给自己定了一个复习计划核心思路是用“高频考点源码验证手写Demo”三件套来闭环。前两天集中过了一遍Java/Kotlin基础、集合类源码、泛型擦除、协程原理。中间三天死磕Android四大组件、Handler、Binder、View绘制流程、事件分发机制。最后两天刷题主要是翻看近两年的Android大厂真题尤其是小红书的风格。我复习时有一个习惯每个基础知识点不只是看博客和文档而是会打开Android Studio写一个最小Demo验证说明中的结论。比如为了验证View.post为什么可以在onCreate中拿到宽高我会真的打印一下onCreate和post回调中的width/height。这种实验会让你对机制有肌肉记忆笔试时靠直觉也能选对。6.2 笔试现场的时间控制技巧笔试题量不小编程题尤其容易卡住。我给自己定的时间线是选择题部分最多60分钟剩下60分钟全部给编程题。实际操作时选择题大约用了50分钟第一道编程题写了25分钟第二道编程题写了30分钟最后剩下15分钟检查。检查时重点看几个地方一是变量的初始值判断二是ArrayList/ArrayDeque的索引边界三是判空逻辑是否完整。我当时为了省时间几乎没有写单元测试全靠肉眼推理这种习惯其实不太好。如果你时间允许建议在本地IDE或者白板上至少手工跑一遍简单的示例。还有一个小技巧如果编程题卡住了先跳过做下一道不要在一棵树上吊死。笔试最终看的是总分编程题也不是完全不拿分就没机会。我有一个同学第一道编程题完全没做出来但选择题正确率很高最后还是进入了面试轮。7. 给后来人的几条实在建议7.1 重点知识点和题目反思准备这一类大厂Android岗笔试我个人的体会是基础决定下限源码决定上限。所谓的“基础”包括Java/Kotlin语法、集合、线程、网络这些必须非常熟练。所谓的“源码”指的是你不光会用Handler、Binder、HashMap还能从底层理解它们的设计思路。笔试的题目往往不会超纲但会变着花样考你对源码的理解。我整理了下面几个自己认为值得反复锤炼的知识点分享出来Activity启动模式的代码级模拟尤其是singleTask和singleInstance的栈操作。Handler消息机制的完整链路MessageQueue的enqueueMessage、nextLooper.loop取出消息后如何回调Handler.dispatchMessage。RecyclerView的回收复用机制ViewHolder如何进入RecycledViewPool和CachedView以及在嵌套滚动时缓存命中率的问题。协程的调度器与异常处理Dispatchers.Main和Dispatchers.IO的切换SupervisorJob与Job的区别CoroutineExceptionHandler的使用。内存泄漏的经典场景识别非静态内部类、匿名Handler、单例持有Context、静态集合、未注销的BroadcastReceiver。7.2 最后分享一个小技巧笔试前我会把手机里高频热词相关的文章快速扫一遍比如“android studio”“android framework”“android AMS”“android OpenOCD”这些不是为了临时抱佛脚而是看一下最近社区里大家都在聊什么。有些笔试题目确实会结合当下的行业热点比如新的Android 14特性、动态图标主题、R8混淆规则、APEX模块机制。你未必能猜到原题但至少能让自己对这些新名词有印象不至于看到选项完全陌生。另外笔试前一晚不要刷题到太晚保证睡眠。Android笔试题目量大且细非常考验注意力。我那次做选择题时有几道题差点因为粗心选错回去检查时才改过来。清醒的头脑比什么都重要。这批笔试让我最大的收获是意识到自己平时开发中对底层原理的依赖太少了。如果你也准备投Android岗我真心建议在刷题之余把平时项目里的常用框架比如OkHttp、Retrofit、Glide、EventBus的源码挑一两个核心类从头到尾读一遍。笔试考的不只是那几道题而是你有没有持续追踪、深入理解技术系统的习惯。祝大家好运也欢迎考完来交流你的复盘心得。
返回列表