☰
2017阿里客户端附加题:聊天列表页设计背后的核心能力
2026/10/26 4:17:27 网站建设 项目流程

2017年秋招,客户端岗位的竞争已经相当激烈。我印象最深的不是某道算法题,而是最后那轮面试官临时加的“附加题”——一个看似简单的聊天列表页设计。当时心里第一反应是:这不就是RecyclerView加个接口嘛。但真开口回答时才发现,肚子里没货的人是连框架都搭不起来的。后来我专门复盘过这道“阿里巴巴2017秋招客户端附加题”,发现它几乎把客户端开发的核心能力全串起来了:UI渲染、数据存储、网络同步、性能优化、异常处理,全都藏在一个聊天页面里。今天就把这套东西完整拆开,聊一聊我当时怎么答的、后来怎么改的,以及准备客户端面试的人可以从里面学到什么。


1. 附加题为什么不是“附加”那么简单

1.1 附加题的本质是筛选器

常规面试题考的是知识量,比如“HashMap的底层实现”“TCP三次握手”,这些题只要背得够多就能过。但附加题不一样,它往往是一个开放式场景,没有标准答案,甚至没有唯一答案。面试官递出这道题的时候,想的不是“这个人能不能说出正确答案”,而是“这个人遇到一个真实问题的时候,脑子里有没有一套完整的思考路径”。

附加题通常放在面试结尾,时间不会太长,但它的权重往往比前面的题更高。因为前面几轮已经确认了基础能力,最后一题考察的是你把这些知识用到真实场景里的能力。换句话说,前面的题回答得好,能证明你“知道”;附加题回答得好,才能证明你“会做”。

我当时犯的第一个错误,就是把附加题当成了普通的设计题,想用一个模板糊弄过去。但实际上,面试官想听的是很具体的东西:消息从服务端推过来以后怎么走到UI、数据库怎么建、列表滑动卡顿怎么办、弱网条件下消息发不出去怎么处理。这些细节一点一点挖下去,你的项目经验和技术深度很快就暴露了。

1.2 客户端开发的能力模型决定了附加题的考法

客户端开发不像后端那样有一条清晰的业务链,它更像一个“端到端”的闭环。一个消息列表页涉及的东西,远比表面看起来复杂。我后来整理了一份能力模型对照表,基本可以解释为什么面试官喜欢用这类题目来试探候选人:

能力维度常规考察方式附加题中的表现
UI渲染RecyclerView的基本用法是否能回答列表复用、卡顿优化、增量更新
数据存储SQL语句是否能设计表结构、索引、缓存策略
网络通信HTTP协议是否能讲清长连接、消息推送、超时重试
异步并发线程池是否能处理消息乱序、状态同步、线程切换
稳定性崩溃日志是否能想到异常兜底、重试、幂等
性能优化卡顿、内存是否能提出可落地的监控和优化方案

一个人如果只是用RecyclerView做过列表,没做过真正的IM或消息类应用,面对附加题时大概率会在“消息状态同步”或者“本地缓存与服务器一致性”这样的点上卡住。这就像会开车的人很多,但能在暴雨天走山路还不心慌的人很少,面试官想找的就是后者。

所以,这道附加题表面上是在问你“聊天列表怎么设计”,实际上是在问你:你曾经在客户端项目中处理过哪些真实问题,有没有把这些问题背后的原理想清楚。


2. 题目复盘:2017年那道附加题到底长什么样

2.1 面试题常见的几种变体

严格来说,阿里巴巴2017秋招并没有一份公开的完整题库,不同岗位、不同面试官出的附加题会有差异。但从当时多个渠道的题目整理来看,客户端方向出现频率最高的附加题类型大致有四种:设计一个IM消息列表、设计一个电商首页、设计一个视频播放器、设计一个离线日志系统。这四种场景有一个共同点——都是客户端最典型的业务形态,能覆盖大多数核心知识点。

我遇到的是IM消息列表,这也是后来很多人讨论最多的题目。题目描述大概是这样的:用户打开一个聊天会话页,能看到历史消息;新消息可以通过推送实时到达;发送消息后需要显示“发送中”“已发送”“已读”等状态;弱网或断网时,消息不能丢,恢复网络后要自动补发。要求用10到15分钟说清整体设计思路,并说明关键技术点。

这类题目最考验人的地方在于,它没有给出任何技术栈限制,也没有明确数据量级。候选人需要自己主动确认边界,比如:是点对点聊天还是群聊?消息量级是每天几十条还是几千条?用户手机的内存和存储空间有没有限制?这些都是决定设计方案的重要因素。

2.2 为什么要选IM场景来考

面试官选择IM场景绝对不是随手一拍。一个消息列表页,几乎是客户端技术点的“全家桶”。列表要流畅,涉及渲染优化;消息有状态,涉及状态管理;有新消息实时到达,涉及网络长连接和推送;需要保存历史记录,涉及本地数据库设计;消息发送失败要重试,涉及网络策略和任务队列。

往深处问,还能牵扯出更多问题:消息到达顺序怎么保证?消息ID怎么生成才能避免重复?本地数据库和服务器数据不一致怎么办?分页加载时如果用页码会不会漏消息?这些细节,面试官可以顺着你的回答一路追问,问到你答不出来为止。

所以我要提醒准备面试的朋友:不要只盯着“这道题的标准答案”,而是要把IM这个场景当成一个完整的小项目,亲手做一遍,踩一遍坑。只有这样,你才能在回答“为什么这么设计”的时候言之有物。


3. 我当时的回答:从架构设计到数据细节

3.1 先分层,再谈页面

我当时给面试官的框架是四层:UI层、状态管理层、数据层、网络层。每一层只做自己该做的事。UI层只负责渲染和用户操作;状态管理层负责把数据转换成UI需要的状态;数据层负责内存缓存、本地数据库、远程数据之间的协调;网络层负责HTTP接口和长连接消息。

这个分层的好处是:每一层都容易替换和测试。如果不用WebSocket,换成轮询,网络层改动就行,UI层不用管;如果本地数据库从SQLite换成Room,数据层内部改就行,状态管理层不受影响。这正是工程上最看重的“低耦合”思路。

具体到消息列表页,我会这样设计:

  • UI层:一个聊天列表Activity,RecyclerView承载消息展示,底部输入框负责发送。
  • 状态管理层:一个Presenter或ViewModel,持有消息列表数据,负责把数据库和网络源的数据合并后抛给UI。
  • 数据层:一个MessageRepository,对外提供getHistoryMessages、sendMessage、onNewMessage三个核心方法。
  • 网络层:一个ChatSocketService,维护长连接,接收服务端推送,并封装发送消息的接口。

面试官听完这个分层之后,通常会追问一句:如果用户快速滑动列表,数据库读取和网络消息同时到达,你怎么处理?这个问题其实就是想确认你是否理解“数据源合并”的复杂性。我的回答是:所有来自数据库、内存缓存、网络推送的消息,都统一进入一个消息队列,由状态管理层按消息时间戳顺序处理后,再一次性刷新UI。这样UI永远只面对一份有序的数据,不会出乱。

3.2 消息状态机:聊天列表的灵魂

IM里最容易被忽略、面试官最爱追问的,就是消息状态。一条消息从用户点击“发送”到对方真正看到,中间要经历好几种状态。如果状态设计得不对,会出现“消息明明发了,列表里却消失了”或“消息显示已读,对方其实没收到”的问题。

我当时画了一个很朴素的状态机:消息发送时先进入“发送中”,本地把内容写入数据库,状态为0;服务端收到后返回一个确认消息,状态变为“已发送”;如果接收方回执已读,则状态变为“已读”;如果网络异常或超时,状态变为“发送失败”,用户点击重发后回到“发送中”。

这里有一个容易被忽略的点:本地写入数据库的时机。有人会等服务端确认后再写库,这样做会导致弱网下UI上根本没有这条消息,体验很差。正确做法是用户点击发送后立刻把消息写进本地库,状态标记为“发送中”,同时把消息塞进一个待确认队列。等服务端确认之后,再更新本地状态并刷新UI。这既是产品体验的要求,也是工程上更合理的方案。

状态变化还需要考虑幂等性。如果服务端确认消息因网络问题重复到达,你不能把一条已读的消息改回发送中。所以每一条消息都要有一个全局唯一的消息ID,本地根据这个ID做去重和更新。

3.3 数据库表怎么建才不踩坑

消息列表页离不开本地数据库。2017年时SQLite还是主流,Room也没有完全普及,所以面试时聊SQLite是更稳妥的选择。我记得当时给面试官画了两张表:会话表和消息表。

会话表存的是每个聊天对象的基础信息,包括目标ID、最后一条消息、未读数、更新时间。消息表存的是具体消息内容,包括消息ID、会话ID、内容、状态、时间戳。建表SQL大概是:

CREATE TABLE conversation ( id INTEGER PRIMARY KEY, target_id TEXT NOT NULL, last_message TEXT, unread_count INTEGER DEFAULT 0, updated_at INTEGER NOT NULL ); CREATE TABLE message ( id INTEGER PRIMARY KEY AUTOINCREMENT, msg_id TEXT NOT NULL, conversation_id TEXT NOT NULL, content TEXT, status INTEGER DEFAULT 0, timestamp INTEGER NOT NULL, UNIQUE(conversation_id, msg_id) );

最核心的设计是conversation_id + msg_id的联合唯一约束。这样即使服务端重复推送同一条消息,数据库也不会插入两条。timestamp字段一定要建索引,因为历史消息分页基本就是按时间倒序去查。分页查询不要用页码,我推荐用“加载更多”时传上次最后一条消息的时间戳,SQL里用WHERE timestamp < ? ORDER BY timestamp DESC LIMIT 20。这样新消息插入时不会影响历史分页的位置,也不会出现翻页重复的问题。

数据库的操作还必须放到子线程,不能占用主线程,否则滑动列表时会有明显卡顿。这个点虽然基础,但面试时主动说出来,会显得你确实处理过真实项目。


4. 代码实现与细节优化:能跑到真机上才算数

4.1 列表复用与快速滑动的处理

既然题目里明确要求聊天列表,列表性能就是绕不开的话题。面试时如果只说一句“用RecyclerView”,基本等于白说。你要能回答出来:为什么不用ListView?因为RecyclerView强制了ViewHolder复用、布局管理更灵活、支持局部刷新。你怎么避免列表卡顿?核心是保证onBindViewHolder里不出现耗时操作。

我当时给面试官举了一个例子:不要在Adapter里直接做时间格式化。每条消息的时间戳要显示成“昨天 14:20”这样的格式,如果在绑定的时候用SimpleDateFormat格式化,快速滑动时会产生大量临时对象,造成卡顿。正确做法是在数据层把时间格式化好,或者写一个轻量级的缓存,按分钟粒度缓存格式化后的结果。

图片加载也要注意。聊天里的表情包、头像如果直接用原生方法加载,很容易在滑动时出现错位和卡顿。2017年Glide已经比较流行,可以在绑定ViewHolder时使用Glide,并通过skipMemoryCache()等参数控制缓存策略。更重要的是,加载完成后的回调里要判断ImageView是否还在原来的位置,避免图片错位。

代码上面的核心示范是这样的:

public class MessageAdapter extends RecyclerView.Adapter<MessageViewHolder> { @Override public void onBindViewHolder(MessageViewHolder holder, int position) { Message msg = messages.get(position); holder.contentTv.setText(msg.getContent()); // 不要在 bind 里做耗时格式化 holder.timeTv.setText(TimeCache.format(msg.getTimestamp())); } }

很多项目卡顿不是因为RecyclerView本身,而是因为你在bind里做了太多的事。提前把耗时工作做好,Adapter就只是一个搬运工。

4.2 增量更新:不要整表刷新

聊天列表很容易遇到这样的事:收到一条新消息,其实只需要在列表尾部插入一行,但很多人图省事,直接notifyDataSetChanged()。这个操作会把整个列表重新绘制一遍,数据量小的时候没问题,消息一多就是灾难,特别是正在快速滑动时突然刷新,直接掉帧。

更优的方案是DiffUtil。DiffUtil是Android Support Library 25.1.0加入的工具,能计算出新旧列表的差异,然后精确到具体item做插入、删除、更新操作。使用起来也不复杂,你需要在回调里比较两个对象的id和内容。聊天列表的messageId天然适配这个场景。

当然,DiffUtil也不是银弹。列表特别大时,计算diff本身会消耗一些时间。更极致的做法是,如果你能明确知道“新消息永远加在尾部”,那就直接用notifyItemInserted(list.size() - 1),连DiffUtil都不需要。

这里我想强调一个面试技巧:面试官问你怎么优化列表时,你不仅要说出方案,还要说出“这个方案在什么场景下不适用”。比如DiffUtil适合数据顺序可能变化的场景;如果只是尾部追加,用notifyItemInserted更轻量。这种对比回答会让面试官觉得你有真实项目经验。

4.3 弱网不好时,消息状态怎么保证

消息状态机设计得再好,落到真实网络环境里还是会出问题。最典型的场景是:用户点击发送,消息进入“发送中”,但网络突然断开,这个时候如果直接把消息状态改成“失败”,用户就要手动重发。但如果网络只是闪断,马上恢复,其实可以自动重试。

我当时的设计是:发送队列里维护一个重试次数变量,每次发送失败不是直接置为失败,而是先检查重试次数是否小于3次。如果小于3次,启动一个指数退避的定时器,分别隔1秒、2秒、4秒再试;如果超过3次,消息状态才变为“发送失败”,并提示用户点击重试。

这种设计还需要考虑服务端幂等。客户端发送消息时带上一个客户端生成的消息ID,服务端用这个ID做去重,即使客户端重试了多次,服务端也只会落一条消息。否则就会出现“用户点一下发送,对方收到三条一模一样消息”的事故。

在面试里表达这个点时,可以顺便说一下消息“乱序”问题:如果服务端把消息A和消息B推过来时顺序反了,UI不能盲目按到达顺序渲染,而是应该按消息时间戳排序。聊天场景下,时间戳才是真正的顺序依据,而不是网络包的到达顺序。


5. 面试现场怎么讲,才会让人觉得你“真的做过”

5.1 回答设计题的五段式框架

当年我面试时,虽然设计思路有了,但表达串得不好,导致面试官总觉得我在堆名词。后来复盘时,我总结出一个五段式表达框架,基本可以应对所有客户端开放型设计题:

  1. 先确认需求边界,不要立刻开答。问数据量级、问场景约束,这本身就加分。
  2. 说明技术选型,并解释为什么不用其他方案。比如“这里用长连接而不是轮询,因为实时性要求高”。
  3. 画出整体架构,分层讲清楚每一层的职责和层与层之间的数据流向。
  4. 把核心流程走一遍,从一条消息发送到状态更新,再到推送给对方,从头到尾串一次。
  5. 讲风险点和你如何处理,比如弱网、乱序、数据库膨胀、内存泄漏。

框架不复杂,但能帮你把散落的知识点串成一条线。

5.2 三个最容易失分的细节

我后来模拟过很多次面试,发现大家在回答这类设计题时容易踩三个坑。

第一个坑是“只谈界面不谈数据”。面试官问的是“设计一个消息列表页”,你如果从布局文件讲到Adapter,再讲到ViewHolder,却始终不提数据库怎么存、消息怎么同步、状态怎么更新,那在面试官眼里你只是一个“会写UI的”而不是“做客户端的”。

第二个坑是“只谈方案不谈取舍”。一上来就丢出一堆名词:MVP、MVVM、RxJava、EventBus、组件化。听着很唬人,但面试官只要追问“为什么用MVP而不是MVVM?”,就答不上来。正确做法是,每提出一个方案,都要说出它解决了什么问题、牺牲了什么。比如用RxJava可以简化异步回调的嵌套,但也会增加调试难度和团队学习成本。

第三个坑是“忽略边界条件”。面试官特别爱问“如果用户手机存储空间满了怎么办”“如果服务端返回的数据格式错了怎么办”“如果消息有几万条怎么办”。这些问题往往没有标准答案,但你要提前想到。你主动提一句“我还会做消息本地存储的清理策略”,就会比被动等问要强很多。

5.3 对于准备客户端面试,我的几点实在建议

如果你正在准备客户端岗位,不要只刷面试题。建议你拿一个真实的IM demo,从数据库表设计开始,一路写到RecyclerView展示。每一步都问自己为什么要这样写。这个流程走下来,你对“客户端”三个字的理解会完全不一样。

另外,要把“性能优化”当成习惯,而不是加分项。写列表时想想会不会卡顿,写网络请求时想想弱网表现,写数据存储时想想会不会越存越多。面试官很愿意听到候选人说“我测过卡顿”“我统计过内存”,哪怕你没有太多性能调优经验,只要表现出有这方面的意识,就已经比很多人强了。


说到底,这道2017年的附加题,考的并不仅仅是当时那15分钟里的回答。它撬动的是你对客户端这个领域的完整认知。我现在带项目时,依然会拿类似的问题去考团队新人,因为一个人面对模糊场景时的思考方式,很难伪装。如果你正在准备面试,不妨亲手写一个带消息状态机的列表页,再对着镜子把整个思路讲一遍。你会发现,面试官真正想看的,从来不是你背过多少答案,而是你遇到问题时候脑子里的那张地图是否清晰。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询