如何优雅地、无感知地加载历史消息。
写在前面
做过 IM 的兄弟们都知道,聊天页面最核心的交互就两个:发消息(往下追加)和看历史(往上加载)。
发消息简单啊,数组 append,TableView insert,完事。但“往上加载历史消息”这个需求,简直就是新手的噩梦,老鸟的隐痛。
产品经理的需求描述通常只有一句话:“像微信那样,往上滑,丝滑地加载之前的消息,不要卡,不要闪。”
听起来很简单?TableView 数据源插入数据,刷新一下不就行了?如果你真这么想,那你离加班不远了。今天我就把我在这个坑里摸爬滚打的过程复盘一下,希望能帮你省下几根头发。
第一阶段:天真的“头部插入”法
最开始,我的脑回路非常线性。既然是历史消息,那肯定是在数组的最前面啊。
逻辑如下:
监听滚动,快到顶部时触发加载。
拿到 20 条旧消息。
messages.insert(contentsOf: newMessages, at: 0)。tableView.reloadData()。
信心满满地跑起来,手指一滑...... 闪瞎了!
为啥会闪?
这里得聊聊 UITableView 的机制。TableView 是基于 UIScrollView 的,它的可视区域是由 contentOffset 决定的。
假设你现在看着第 10 条消息,contentOffset.y 是 100。
当你把 20 条新消息插到最前面,TableView 的 contentSize 瞬间变高了(比如加高了 1000pt)。
但是!TableView 的 contentOffset.y 依然停留在 100。
原本 offset 100 的位置是第 10 条消息,现在内容变长了,offset 100 的位置可能变成了新加载进来的第 5 条历史消息。
视觉表现就是: 用户明明盯着屏幕中间的消息,突然画面一跳,瞬移到了更早的消息去了。这也就是所谓的“跳变”。
第二阶段:死磕 Offset 计算
既然是因为 contentSize 变了 offset 没跟上,那我手动算一下不就行了?
我在加载前记录一下旧的 contentHeight,加载完再拿新的 contentHeight 减一下,把差值加到 contentOffset 上。
// 伪代码
let oldHeight = tableView.contentSize.height
let oldOffset = tableView.contentOffset
// ... 插入数据,Reload ...
let newHeight = tableView.contentSize.height
let diff = newHeight - oldHeight
tableView.setContentOffset(CGPoint(x: 0, y: oldOffset.y + diff), animated: false)
逻辑无懈可击对吧?跑起来一看:还是闪!
这次不是位置跳变,而是画面抖动。
因为 reloadData 是异步的,布局计算需要时间。我们在数据源变更后强制去改 Offset,TableView 内部也在进行布局调整,两股力量在打架。哪怕用了 layoutIfNeeded() 甚至 UIView.performWithoutAnimation,也很难做到像微信那样绝对的“静止不动”。
我试过用 insertRows 代替 reloadData,甚至试过计算每个 Cell 的高度预估...... 越搞越复杂,代码写得像屎山,效果依然像个半成品。
第三阶段:打开新世界大门的“反转方案”
在被 Offset 折磨得睡不着觉的某个深夜,我看着天花板发呆。突然灵光一闪:
如果我把 TableView 倒过来呢?
想象一下,如果把 TableView 旋转 180 度:
它的 (0,0) 坐标跑到了视觉的最下方。
它的“底部”跑到了视觉的最上方。
这时候,我们眼里的“加载历史消息”(往上加),在 TableView 的坐标系里,其实是 “加载更多数据”(往下 append)!
Append 数据是不会影响 contentOffset 的! 这就是这一招最绝的地方。
180° 反转实战
说干就干。
第一步:翻转 TableView
// 这种写法既翻转了 Y 轴,也翻转了 X 轴(相当于旋转 180 度)
tableView.transform = CGAffineTransform(rotationAngle: .pi)
这时候界面倒过来了,字也是倒着的。
第二步:负负得正,翻转 Cell
// 在 Cell 初始化的时候
contentView.transform = CGAffineTransform(rotationAngle: .pi)
这时候字正过来了。
第三步:数据源反转
既然容器反转了,我们的数据结构也要跟着变。
最新的消息,放在数组的
index: 0(TableView 的 top,视觉的 bottom)。最老的消息,放在数组的最后。
加载历史消息时,我们只需要:
self.messages.append(contentsOf: oldMessages)
self.tableView.reloadData()
跑起来一试:丝般顺滑! 真的,那一瞬间感觉得到了救赎。没有任何计算,没有任何 Offset 修正,系统天然就帮你处理好了。
第四阶段:填补反转方案的“坑”
虽然反转方案解决了最大的痛点,但它也带来了一些副作用,需要一个个解决。这也是展示技术细腻度的地方。
坑一:只有两三条消息时,飘在下面?
反转后,TableView 的 top (0,0) 其实是视觉的底部。
当只有 1 条消息时,它自然就贴着 TableView 的 top 排列,也就是贴着屏幕底部。上面留出大片空白。
这不符合习惯。正常的聊天,消息少的时候应该贴着屏幕顶端。
解法:动态调整 contentInset
我们需要算一下内容高度。如果内容不够一屏,就给 TableView 加一个 contentInset.top,把它“顶”下去(视觉上就是顶上去)。
private func updateContentAlignment() {
tableView.layoutIfNeeded()
let contentHeight = tableView.contentSize.height
let visibleHeight = tableView.bounds.height
if contentHeight < visibleHeight {
// 算出差值,作为 inset
let topInset = visibleHeight - contentHeight
// 注意:这里是给翻转后的 top 加 inset,视觉效果就是把内容往上推
tableView.contentInset = UIEdgeInsets(top: topInset, left: 0, bottom: 0, right: 0)
} else {
tableView.contentInset = .zero
}
}
把这个方法挂在数据刷新后的回调里,完美解决。
坑二:滚动条怎么跑到左边去了?
细心的你会发现,用了 rotationAngle: .pi 后,因为是中心旋转 180 度,原本在右边的滚动条跑到了左边。这太怪了。
解法:只翻转 Y 轴
我们其实只需要上下颠倒,不需要左右颠倒。所以放弃 rotationAngle,改用 scale。
// x 轴不变,y 轴变成 -1
tableView.transform = CGAffineTransform(scaleX: 1, y: -1)
// Cell 同理
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
这样既实现了翻转,滚动条也乖乖回到了右边。而且从图形学的角度,Scale 的计算开销比 Rotation 还要小一丢丢。
终极形态:反转 + DiffableDataSource + 自动高度
最后的实现方案,可以说是集大成者:
架构:MVVM。
布局:
CGAffineTransform(scaleX: 1, y: -1)翻转方案。数据源:
UITableViewDiffableDataSource。这东西比传统的 dataSource 更好用,自动处理 Crash 问题,而且动画更细腻。高度:
automaticDimension,让 Auto Layout 自己算高度,别去手算,累死人。预加载:在
scrollViewDidScroll里判断,距离底部(视觉顶部)还有 500pt 的时候静默发起网络请求。用户划得快也感觉不到加载过程。
核心代码摘要
// 1. 初始化翻转
private func setupUI() {
tableView.transform = CGAffineTransform(scaleX: 1, y: -1)
tableView.rowHeight = UITableView.automaticDimension
// 给个估算值,别给 0,否则系统算得慢
tableView.estimatedRowHeight = 80
}
// 2. 数据更新(ViewModel 回调)
private func applySnapshot(animatingDifferences: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
snapshot.appendSections([0])
// 注意:messages 数组已经是 [最新 ... 最旧] 的顺序了
snapshot.appendItems(viewModel.messages.map { $0.id })
dataSource.apply(snapshot, animatingDifferences: animatingDifferences) { [weak self] in
// 更新完数据,检查一下是不是要处理“少消息贴顶”的问题
self?.updateContentAlignment()
}
}
// 3. Cell 配置
class MessageCell: UITableViewCell {
override init(...) {
super.init(...)
// 负负得正
contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
}
}
与主流开源方案对比
在折腾完我的方案后,我特意去研究了一下社区里知名的 IM 开源库是怎么处理这个问题的。
JSQMessagesViewController 的做法
JSQMessagesViewController 是 iOS 社区最知名的 IM 框架之一(虽然现在已经不维护了),我专门翻了它的源码,想看看大牛们是怎么解决这个头疼的问题的。
结果发现:它根本就没解决!
它的实现思路是:
1. 使用 Header View + 按钮
不是自动触发加载,而是在 CollectionView 顶部放了一个 Header,里面有个"Load Earlier Messages"按钮。用户想看历史消息?自己点按钮去。
// JSQMessagesLoadEarlierHeaderView.m
- (IBAction)loadButtonPressed:(UIButton *)sender {
[self.delegate headerView:self didPressLoadButton:sender];
}
2. 直接 reloadData,接受闪动
加载完数据后,直接粗暴地 reloadData(),完全不管 contentOffset 的问题。
- (void)setShowLoadEarlierMessagesHeader:(BOOL)showLoadEarlierMessagesHeader {
_showLoadEarlierMessagesHeader = showLoadEarlierMessagesHeader;
[self.collectionView reloadData]; // 就这?就这!
}
3. 没有任何 Offset 计算
我翻遍了整个项目,没找到任何关于保持滚动位置的代码。它只关心"滚动到底部":
- (void)scrollToBottomAnimated:(BOOL)animated {
// 只管滚到最新消息
}
为什么它"似乎"能接受?
仔细想想,它的设计其实是一种产品上的妥协:
用户主动点击:因为是点按钮触发的,用户会预期界面会变化,所以闪一下好像也能接受?
使用 CollectionView:CollectionView 在某些场景下的抖动可能没 TableView 那么明显(但依然存在)。
不追求完美:可能作者也被 Offset 搞烦了,干脆摆烂了(开个玩笑)。
方案对比
| 对比维度 | JSQMessagesViewController | 反转方案(本文) |
|---------|-------------------------|---------------|
| 触发方式 | 手动点击按钮 | 自动检测滚动位置 |
| 闪动问题 | ️ 存在(但因为主动点击,用户可能能接受) | ✅ 完全消除 |
| 用户体验 | 中等(需要额外操作) | 优秀(完全无感知) |
| 实现难度 | 简单(直接 reloadData) | 中等(需要处理反转细节) |
| 微信对标 | ❌ 与微信体验差距较大 | ✅ 高度还原微信交互 |
| 代码维护性 | 高(逻辑简单) | 中高(理解反转后也不复杂) |
我的看法
JSQMessagesViewController 选择了规避问题而不是解决问题。这不是说它做得不好,而是它在工程上做了权衡:
如果你的产品对用户体验要求不那么极致,它的方案省事省力。
但如果你想做到微信那种丝滑的体验,就必须直面技术难题。
反转方案的价值就在于:它用一个"非常规"的思路,彻底解决了这个在社区里长期没有完美方案的问题。
总结
回顾一下,其实这就是一个“发现问题 -> 暴力解决失败 -> 转换思维 -> 优雅解决”的过程。
很多时候我们在死磕一个技术难点时(比如那个该死的 Offset 闪动),往往是因为我们太局限于“常规做法”。试着打破常规,比如把世界颠倒过来看看,也许问题本身就不存在了。
反转方案的优势总结:
✅ 0 闪动:物理层面的稳。
✅ 0 计算:不需要手算 offset。
✅ 高性能:append 操作比 insert 性能好(数据结构常识)。
唯一的代价就是需要多写几行代码处理一下 contentInset 和 transform,但这比起那无穷无尽的 Offset 抖动 bug,简直太划算了。
希望这篇“踩坑实录”能帮你搞定那个难缠的产品经理。Demo 代码我已经整理好了,欢迎自取研究。
Good luck, iOSers!