3月下旬,我参加了腾讯音乐2023年春招移动客户端岗的第二批笔试。腾讯音乐这个招牌不用多介绍,旗下的QQ音乐、酷狗、酷我基本覆盖了国内主流的在线音乐用户,移动客户端岗主要就是做这几款App的迭代和体验优化。第二批笔试比起第一批,能明显感觉到题目风格更稳定、覆盖也更全面,从计算机网络、操作系统基础,到Java/Kotlin、Android组件原理,再到三道编程题,基本把我学了两年的东西翻了个底朝天。
如果你也是冲着移动客户端方向投的春招,或者正在准备大厂客户端岗笔试,这篇复盘应该能让你少走不少弯路。我会把题型结构、核心考点、三道编程题的完整解题思路、问答题的答题框架,以及我自己在时间分配上的失误,逐个拆开讲。
1. 笔试概况:第二批到底考了什么
1.1 题型分布与考试节奏
腾讯音乐这场笔试用的是牛客网的在线笔试系统,总时长2小时,题型分成三大块:25道选择题、2道问答题、3道编程题。选择题里20道单选、5道多选,问答题和编程题可以自由选择先做哪块,系统会有翻页限制,但整体流程还算顺畅。
题目分布大致是这样的:
| 题型 | 数量 | 建议时长 | 考察重心 |
|---|---|---|---|
| 单选题 | 20题 | 20分钟 | 计算机网络、操作系统、Java基础、Android组件 |
| 多选题 | 5题 | 15分钟 | 容易混淆的细节,比如内存泄漏、GC、进程间通信 |
| 问答题 | 2题 | 20分钟 | 线上问题排查思路、业务场景设计 |
| 编程题 | 3题 | 65分钟 | 字符串处理、栈/递归、动态规划 |
说实话,这个题量不算少,2小时满打满算,平均每道选择题只有不到1分半钟。我周围几个同学考完最大的感受就是"选择做得快,编程写得赶"。特别是问答题,很多人直接跳过了,因为问答题没有强制提交,但我觉得那部分恰恰是区分度所在,我后面会详细讲。
1.2 这批笔试的难度定位
第二批的难度跟第一批相比,整体相当,但有一个明显变化:基础知识的选择题更偏向"常见但容易记错"的细节。比如TCP四次挥手的状态变化、HashMap底层在JDK 8之后的树化条件、Activity在异常情况下的生命周期回调顺序,这些都是平时开发里天天用、但真要较真时容易翻车的东西。
编程题难度梯度比较合理,第一题属于签到题,认真写基本都能AC;第二题是中等偏上的栈应用;第三题是动态规划,而且加了一个环形条件,比裸的线性DP多一个弯。这个梯度设置,明显是想筛掉"只会背八股文"的人——选择题可以靠刷题硬背过关,但编程题和问答题如果底子不扎实,分数会拉开很大差距。
2. 选择题复盘:知识点的查漏补缺
2.1 计算机网络与操作系统
笔试里计算机网络大概占了6-7题,操作系统占4-5题,这两块是最容易拿分也最容易丢分的"基础题"。
有一道多选问TCP连接释放过程中FIN报文的发送方和状态变化。这个题如果只记了三次握手的流程就容易懵,因为很多人把断开连接的四次挥手给忽略了。正确答案里有一个很容易漏掉的细节:主动关闭的一方发送FIN后进入FIN_WAIT_1状态,收到对端ACK后进入FIN_WAIT_2,此时只能接收数据;被动关闭方发送完最后一个ACK后进入TIME_WAIT,要经过2MSL才真正关闭。选项里把"被动关闭方进入TIME-WAIT"和"主动关闭方进入TIME-WAIT"混在一起,考察的就是对状态机细节的理解。
还有一道关于HTTP状态码的题,问504和502的区别。这俩在生产环境里特别常见:502是Bad Gateway,表示网关或代理服务器收到了无效响应;504是Gateway Timeout,表示源服务器响应超时。另外301和302的区别也考到了,一个是永久重定向、一个是临时重定向,缓存行为完全不同。
操作系统方面考了两个比较经典的:一是死锁的四个必要条件(互斥、持有并等待、不可剥夺、循环等待),问的是"哪些条件被破坏后死锁不会发生",这是死锁预防的基本思路;二是页面置换算法,问LRU和FIFO的差别,还带了一个Belady异常的判断题——FIFO在增加物理页面数时反而可能导致缺页增多,LRU不会出现这种异常。
2.2 Java/Kotlin语言与集合框架
Java基础在移动客户端岗笔试里占比不低,目测有5题左右。这些题不会出得像后端那么深,但集合框架和并发是绕不开的。
HashMap那道题考了三个点:底层结构(数组+链表+红黑树)、树化条件(链表长度达到8且数组容量达到64)、为什么是0.75的负载因子。这里面有个挺容易被忽略的点:链表的树化不是只看链表长度,数组容量不够64时根本不会树化,只是扩容。选项里故意把"链表长度达到8就转红黑树"当成正确说法放进去,很多不看源码的同学就栽了。
Kotlin的扩展函数也考了一道,问扩展函数在编译后是什么形式。正确答案是"变成静态方法,第一个参数是被扩展的对象实例"。这是因为Kotlin的扩展函数本质上是一个静态工具方法,调用时相当于把接收者对象作为参数传进去。如果做过真正的Kotlin开发,这个问题很直观,但如果只是看过语法糖,很容易选成"运行时动态绑定方法"。
2.3 Android专项与移动端开发
Android专项的选择题大概有6道,是移动客户端岗跟后端岗最大的区别所在。这里考的就不是通用计算机基础了,而是真正跟做App相关的知识。
Activity启动模式是必考的,四种启动模式的定义和适用场景都要清楚。我印象比较深的一道题是:两个Activity A和B,A启动模式是singleTask,B是standard,A通过Intent启动B后,再按返回键,回退栈里是什么情况?这道题考的是singleTask对任务栈的影响:A所在的Task会被置于前台,B入栈,返回时从B回到A,再返回退出。如果对singleTask的"栈内复用+clearTop"特性理解不深,容易在这里丢分。
Handler机制是另一个必考点,考得很细,问的是"一个线程可以有几个Looper、几个Handler"。答案是:一个线程最多一个Looper(通过ThreadLocal实现),但可以有很多个Handler。同时Looper.loop()是一个死循环,如果没消息会阻塞在MessageQueue.next(),而不是耗死CPU。这个设计很多人只知道"主线程不能做耗时操作",不知道背后是Looper消息循环在驱动一切。
多选里还考了一个内存泄漏场景排查,四个选项:静态Context引用、Handler内部类持有外部Activity、不再使用的Bitmap没有调用recycle、用完后没有关闭的Cursor。这里面Bitmap的recycle其实是个陷阱——在Android 3.0以后Bitmap的内存从native堆转移到了Java堆,由GC统一管理,recycle()并不是必须的,官方甚至不建议手动调用。这道题如果对GC机制了解不深,很容易把手动recycle当成正确答案选进去。
3. 编程题全解:思路、代码与复杂度分析
编程题是我这次笔试的重头戏,也是我下考场后觉得最有底气复盘的部分。三道题我最终AC了两道,第三道在最后5分钟换了个思路才跑通,整个过程很紧张,但也特别值得复盘。以下是我凭记忆复原的题目和完整解法。
3.1 第一题:字符串压缩
题目描述:给定一个仅包含大小写字母的字符串s,将连续相同的字符压缩为"字符+次数"的形式,例如"aabcccccaaa"压缩后为"a2b1c5a3"。如果压缩后的字符串长度不小于原字符串长度,则返回原字符串。
这道题很友好,没有设置复杂的前置条件。我的第一反应是用StringBuilder遍历一遍,一个计数器记录当前连续字符的数量,遇到字符变化就把上一个字符和计数拼接到结果里。需要注意的是边界情况:遍历结束后要手动处理最后一组连续字符;空串和单字符直接原样返回。
public String compress(String s) { if (s == null || s.length() <= 1) { return s; } StringBuilder sb = new StringBuilder(); int count = 1; for (int i = 1; i < s.length(); i++) { if (s.charAt(i) == s.charAt(i - 1)) { count++; } else { sb.append(s.charAt(i - 1)).append(count); count = 1; } } sb.append(s.charAt(s.length() - 1)).append(count); String compressed = sb.toString(); return compressed.length() < s.length() ? compressed : s; }时间复杂度O(n),空间复杂度O(n)。这道题唯一的坑就是"压缩后字符串长度不小于原串时返回原串"这个条件,如果不注意,测试用例里类似"aabbcc"这种压缩后反而更长的case就会挂掉。
3.2 第二题:简易加减法表达式计算
题目描述:给定一个字符串表达式s,包含数字、+、-、括号和空格,计算表达式的值。所有操作数都是非负整数,表达式没有乘除法。例如"(1+(4+5+2)-3)+(6+8)"的结果是23。
这题是LeetCode 224"基本计算器"的变体,没有乘除,只有加减和括号,核心难点在于括号会影响运算符的符号。我一开始用了双栈法(操作数栈+运算符栈),但写着写着发现括号栈的处理很容易出错,尤其是当连续多个括号嵌套时,状态特别容易混乱。
后来我换了一种更简洁的思路:用符号翻转。维护一个sign变量表示"当前数字前面的符号",再用一个栈保存括号外的整体符号状态。遇到'('时,把当前的sign入栈;遇到')'时,弹栈恢复之前的符号状态。这样每次碰到'+'或'-',只需要根据栈顶的符号状态决定实际的符号即可。
public int calculate(String s) { int result = 0; int number = 0; int sign = 1; ArrayDeque<Integer> stack = new ArrayDeque<>(); stack.push(1); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (Character.isDigit(c)) { number = number * 10 + (c - '0'); } else if (c == '+') { result += sign * number; number = 0; sign = stack.peek(); } else if (c == '-') { result += sign * number; number = 0; sign = -stack.peek(); } else if (c == '(') { stack.push(sign); } else if (c == ')') { stack.pop(); } } result += sign * number; return result; }这个解法的时间复杂度O(n),空间复杂度O(n)。我在笔试时一开始用的是"双栈法",写了一堆case后发现嵌套括号的优先级判断完全绕进去了。换了符号翻转之后,代码量直接减少了一半。所以这种题平时的训练一定要多过几遍,临场换思路是很浪费时间的。
3.3 第三题:环形街区打家劫舍
题目描述:你是一个专业的小偷,计划偷窃一条环形街道上沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组nums,计算你在不触动警报装置的情况下,今晚能够偷窃到的最高金额。例如输入[2,3,2],输出3。
环形打家劫舍的核心思路是把环拆成两个线性打家劫舍:第一种情况是偷第一间房,那么最后一间不能偷,可偷范围是[0, n-2];第二种情况是不偷第一间房,那么最后一间可以偷,可偷范围是[1, n-1]。两种情况的较大值就是答案。
public int rob(int[] nums) { if (nums == null || nums.length == 0) { return 0; } if (nums.length == 1) { return nums[0]; } return Math.max(robRange(nums, 0, nums.length - 2), robRange(nums, 1, nums.length - 1)); } private int robRange(int[] nums, int start, int end) { int prev2 = 0; int prev1 = 0; for (int i = start; i <= end; i++) { int cur = Math.max(prev1, prev2 + nums[i]); prev2 = prev1; prev1 = cur; } return prev1; }robRange里维护两个变量:prev1是到当前位置为止能偷的最大金额,prev2是到前一个位置为止能偷的最大金额。转移方程是dp[i] = max(dp[i-1], dp[i-2]+nums[i]),也就是"当前这家不偷"和"当前这家偷"两种情况取较大值。空间方面没有用数组,直接把滚动变量降到O(1)。
这道题我在笔试时第一次写的时候没处理nums长度为1的边界情况,直接跳进了robRange,结果数组越界。最后用了一个if提前返回才修正过来。边界条件真的值得考场上多检查两遍,这种失误丢分太冤了。
4. 问答题思路:怎么展示自己的思考深度
问答题虽然不强制提交,但阅卷的时候能看到加分项,尤其是对于移动客户端岗,面试官非常看重"排查问题的思路"和"对业务场景的设计能力"。这次的两道题恰好一个是线上排查,一个是业务组件设计,都很有代表性。
4.1 线上崩溃排查:从日志到修复的完整链路
题目大意:线上用户反馈App闪退,你手里只有一个崩溃日志堆栈,如何排查和定位问题?
我的答题框架分五步:第一步,看崩溃类型是Java崩溃还是Native崩溃,Java崩溃定位到具体类和行号,Native崩溃则要结合so库的符号表去分析;第二步,复现崩溃路径,根据堆栈里的业务代码推测用户操作路径,必要时接入Debug版本或者通过日志辅助追踪;第三步,分析崩溃现场的资源状态,比如内存、线程、文件句柄,判断是不是OOM、线程泄漏或者文件读写异常导致的;第四步,修复代码并做灰度验证,注意崩溃修复的回归测试要覆盖到触发路径;第五步,发布后持续关注该崩溃的监控指标,确认崩溃率降下来。
这里面我觉得最重要的是"先定性再定位"的思路。很多人一上来就奔着堆栈里的那行代码去改,但线上崩溃经常会遇到空指针异常,根因往往是上游某个接口返回的字段变了,而不是null判断本身的问题。所以回答时一定要体现出从表象追根因的意识。
4.2 歌词同步组件:贴合业务场景的设计题
第二题我记得很清楚,大意是"在音乐App中,需要实现一个歌词同步显示组件,请描述你的设计方案"。
这道题跟腾讯音乐的业务高度相关,也是移动客户端岗和后台岗区别最明显的一道题。我是从数据->视图->交互三个层面拆解的:
先说数据层。LRC歌词文本的格式是"[mm:ss.xx]歌词内容",解析时用正则或split按行切分,每一行转成一个LyricLine对象,包含time(毫秒时间戳)和content(歌词文本),解析完成后按时间排序存入数组。因为行数是固定有限的,不需要用复杂的数据结构,一个有序数组即可。
再说视图层。这是核心。自定义一个LyricView继承View,在onDraw里根据当前播放进度找到应该高亮的那一行。由于歌词是按时间排序的,定位当前行可以用二分查找,寻找第一个时间大于当前进度值的索引,然后往前取一行就是当前行。高亮那行的文字使用主题色,并且通过平移画布让当前行保持在垂直居中的位置,这样观感最好。
最后是交互层。要做到进度条拖动,需要重写onTouchEvent,在拖动时暂停自动滚动,松手后根据拖到的位置换算成时间,再回调给播放器seek到对应进度。歌词的刷新进度由一个Handler驱动,每100ms发一个消息更新一次当前时间,同时调用invalidate触发重绘。如果频繁刷新导致UI卡顿,可以改成Choreographer的帧回调,保证跟屏幕刷新率同步。
回答这种设计题,重要的是体现出"我做过、我踩过坑"。我当时还补充了一些细节,比如歌词解析要处理空行、重复时间戳、乱码编码,歌词滚动动画要考虑从旧行到新行的平滑过渡。这些细节比堆砌高深术语更有说服力。
5. 时间分配与应试策略:实战中总结的经验
5.1 我采用的答题顺序和时长分配
我这次笔试的时间分配其实不算成功,需要复盘一下。我先是按部就班做选择题,用了大约35分钟,比预设时长略快,但多选里有两道不确定的题耗了比较多时间,我当时不甘心,反复推断了几次。结果问答题我花了将近25分钟写第一题的排查思路,写到第二题歌词组件时发现时间已经过半,只能压缩答案长度。
后面编程题只剩55分钟左右,第一题花了10分钟顺利AC,第二题因为第一次用的双栈法写岔了,重写花了20多分钟,第三题写到一半就只剩15分钟了,差一点没调完。
如果重新来一次,我会调整顺序:先花15分钟快速扫一遍编程题,把能AC的第一题先做完,再集中精力攻第二题;拿不准的多选题直接标记,先跳过去;问答题控制在每题10分钟以内,重点写框架而不是长篇大论。说白了,笔试是分数导向的,编程题一题的分值远比一道单选题高,不能在低分题上恋战。
5.2 现场应急预案:遇到没复习到的东西怎么办
考试过程中难免会碰到完全没见过的知识点,我这次就遇到一道关于Kotlin协程调度器的选择题,选项里涉及Dispatchers.Main、IO、Default的区别。我当时对协程只停留在"能写、能跑"的水平,对调度器的底层切换逻辑并不清楚,只能靠排除法猜了一个。
我的经验是:遇到不会的题,先标记、后猜测、不空着。选择题的猜测也有技巧,比如Kotlin协程调度器这道题,Main切换需要主线程执行、IO用于网络和磁盘操作、Default用于CPU密集计算,这些基本常识是可以推导出来的。多选拿不准的选项,宁可少选不要多选——多选通常错选不得分,但漏选可能得一半分。
另外,编程题遇到卡壳时,一定要先想暴力解法,哪怕超时也能拿到部分测试用例的分数。牛客的判题系统是按测试用例比例给分的,不是一锤子买卖。你写完暴力法,至少能拿到一部分分数,总比留着空着强。这也是我第三题差点翻车之后最大的教训。
6. 复盘心得与后续准备建议
6.1 笔试暴露出的薄弱点
考完当天晚上我就做了一次完整的复盘,三个薄弱点我记得很清楚。第一个是我对Kotlin协程的底层原理了解不够,调度器、结构化并发、挂起函数的编译原理都停留在概念层面,选择题一考细节就露馅。第二个是我对Binder的掌握只停留在"会用"层面,笔试里有个选择题问"一次Binder调用过程中内存拷贝了几次",我虽然知道是1次,但对mmap的实现原理解释得不够透彻,问答题如果问深一点我可能就答不上来了。第三个是我的动态规划熟练度还是不够,第三题在边界处理上花了太久,环形DP的"拆环为数组"思路明明很固定,但真正落到代码时还是容易慌。
6.2 给后面批次同学的建议
如果你也是投腾讯音乐或类似大厂移动客户端岗位,我建议把复习重心放在三个地方:第一,计算机网络和操作系统的基础题一定要做到"秒答"级别,这三十分钟的选择题决定了你是不是能进到后面大题的阅卷池;第二,代码能力需要每天保持手感,LeetCode的字符串、栈、动态规划、链表、二叉树五个类型刷够50题就比裸考强很多,尤其是动态规划,大厂笔试基本必考一题;第三,要去了解目标公司的业务场景,腾讯音乐旗下有QQ音乐、酷狗、酷我,客户端岗很可能出跟音乐播放、歌词、音频相关的设计题,提前准备一个自己熟悉的业务组件设计方案,答题时会从容很多。
最后再分享一下我个人在准备过程中的体会:笔试其实就是把两年的积累浓缩到两个小时里,考的不只是知识量,更是你在压力下的判断力。遇到不会的题,果断跳过是一种能力;拿到熟悉的题,快速且准确地写完也是一种能力。这两点,比刷多少道题都更能决定你最终的分数。