iOS IM 模块 - 如何丝滑加载历史消息

如何优雅地、无感知地加载历史消息。

写在前面

做过 IM 的兄弟们都知道,聊天页面最核心的交互就两个:发消息(往下追加)和看历史(往上加载)。

发消息简单啊,数组 append,TableView insert,完事。但“往上加载历史消息”这个需求,简直就是新手的噩梦,老鸟的隐痛。

产品经理的需求描述通常只有一句话:“像微信那样,往上滑,丝滑地加载之前的消息,不要卡,不要闪。”

听起来很简单?TableView 数据源插入数据,刷新一下不就行了?如果你真这么想,那你离加班不远了。今天我就把我在这个坑里摸爬滚打的过程复盘一下,希望能帮你省下几根头发。


第一阶段:天真的“头部插入”法

最开始,我的脑回路非常线性。既然是历史消息,那肯定是在数组的最前面啊。

逻辑如下:

  1. 监听滚动,快到顶部时触发加载。

  2. 拿到 20 条旧消息。

  3. messages.insert(contentsOf: newMessages, at: 0)

  4. 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 + 自动高度

最后的实现方案,可以说是集大成者:

  1. 架构:MVVM。

  2. 布局CGAffineTransform(scaleX: 1, y: -1) 翻转方案。

  3. 数据源UITableViewDiffableDataSource。这东西比传统的 dataSource 更好用,自动处理 Crash 问题,而且动画更细腻。

  4. 高度automaticDimension,让 Auto Layout 自己算高度,别去手算,累死人。

  5. 预加载:在 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 {

    // 只管滚到最新消息

}

为什么它"似乎"能接受?

仔细想想,它的设计其实是一种产品上的妥协

  1. 用户主动点击:因为是点按钮触发的,用户会预期界面会变化,所以闪一下好像也能接受?

  2. 使用 CollectionView:CollectionView 在某些场景下的抖动可能没 TableView 那么明显(但依然存在)。

  3. 不追求完美:可能作者也被 Offset 搞烦了,干脆摆烂了(开个玩笑)。

方案对比

| 对比维度 | JSQMessagesViewController | 反转方案(本文) |

|---------|-------------------------|---------------|

| 触发方式 | 手动点击按钮 | 自动检测滚动位置 |

| 闪动问题 | ️ 存在(但因为主动点击,用户可能能接受) | ✅ 完全消除 |

| 用户体验 | 中等(需要额外操作) | 优秀(完全无感知) |

| 实现难度 | 简单(直接 reloadData) | 中等(需要处理反转细节) |

| 微信对标 | ❌ 与微信体验差距较大 | ✅ 高度还原微信交互 |

| 代码维护性 | 高(逻辑简单) | 中高(理解反转后也不复杂) |

我的看法

JSQMessagesViewController 选择了规避问题而不是解决问题。这不是说它做得不好,而是它在工程上做了权衡:

  • 如果你的产品对用户体验要求不那么极致,它的方案省事省力。

  • 但如果你想做到微信那种丝滑的体验,就必须直面技术难题。

反转方案的价值就在于:它用一个"非常规"的思路,彻底解决了这个在社区里长期没有完美方案的问题。


总结

回顾一下,其实这就是一个“发现问题 -> 暴力解决失败 -> 转换思维 -> 优雅解决”的过程。

很多时候我们在死磕一个技术难点时(比如那个该死的 Offset 闪动),往往是因为我们太局限于“常规做法”。试着打破常规,比如把世界颠倒过来看看,也许问题本身就不存在了。

反转方案的优势总结:

  • 0 闪动:物理层面的稳。

  • 0 计算:不需要手算 offset。

  • 高性能:append 操作比 insert 性能好(数据结构常识)。

唯一的代价就是需要多写几行代码处理一下 contentInsettransform,但这比起那无穷无尽的 Offset 抖动 bug,简直太划算了。

希望这篇“踩坑实录”能帮你搞定那个难缠的产品经理。Demo 代码我已经整理好了,欢迎自取研究。

Good luck, iOSers!

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容