简介:这套iOS示例项目演示了如何利用UITableView实现主评论与子评论的多层嵌套展示,适合社交、社区类App开发中需要构建评论区的场景。项目从数据模型CommentModel出发,通过自定义不同层级的评论单元格展现主楼与楼中楼的差异,并实现了评论展开与收起的动态交互。开发者可以学习UITableViewDataSource与Delegate的灵活运用、多级数据结构的递归设计,以及如何在点击展开按钮时按需加载子评论并刷新列表。压缩包共53个文件,以Objective-C源码(.m/.h)为主,配合storyboard界面布局、plist数据文件及完整Xcode工程结构,整体仅有65KB,轻量易读。已有567人浏览学习,适合具备一定iOS基础、希望进阶复杂列表控件的开发者。通过该项目还能掌握UITableViewCell复用机制、懒加载思路和展开收起动画,为实际评论功能开发提供一份可运行的参考范本。
1. 评论列表好写,评论的评论才是分水岭
评论功能本身没什么难度,一个 UITableView 绑定数据源就能跑起来。但产品经理一句“要能回复评论的评论”,界面层级马上从一层变成两层:外层滚动的是评论,内层滚动的是每个评论下的回复。这个 zip 工程给的就是这条路径的标准答案——用 UITableView 嵌套 UITableView 搭楼中楼。它的价值不在于代码量,而在于把 cell 高度计算、内嵌列表刷新时机、复用导致的状态串扰这三个最容易翻车的地方一次性趟平了。适合刚接触 iOS 表视图嵌套的开发者照着改,也适合正在重构评论模块的老手拿来对照检查自己的方案。
2. 先定数据模型与滚动手势归属:嵌套方案的两个前置决定
开始动手前,得先弄清楚一件事:所谓 TableView 嵌套,不是把两个 UITableViewController 直接叠起来,而是“评论列表”的 cell 内部再放一个 UITableView。外层列表的数据源是评论数组,内层列表的数据源是当前评论下的回复数组。数据怎么组织、滚动事件归谁处理,这两个决定直接决定后面的代码长什么样。
2.1 为什么选嵌套而不是平铺:两级数据的 UI 映射差异
“评论的评论”本质是一对多关系——一条评论对应若干回复。UI 上呈现这个关系通常只有两种做法。第一种就是这个工程采用的嵌套:回复天然挂在评论 cell 内部,视觉分组明确,评论和回复之间的父子关系不需要靠额外状态维护。第二种是平铺:把回复拆成独立 cell 排在评论 cell 后面,视觉上接近,但你必须引入 section 分组,或者维护一套折叠状态去决定到底渲染多少行。
我一般会先问一个问题:回复的数量分布是稀疏还是密集?如果绝大多数评论只有 0-3 条回复,嵌套方案非常合适,因为每个 cell 内部列表短,高度计算压力小,滑动流畅。如果一个评论下能挂几十条回复,嵌套方案的 cell 会变得异常高大,滑动时离屏 cell 的复用清理反而变成负担,这时候平铺展开方案更可控。接手这类工程,第一件事就是看服务端返回的回复条数分布,别急着写 UI。
嵌套方案还有一个隐藏收益:回复的删除、新增只需要刷新内层列表,不影响外层评论的索引。平铺方案里任何一条回复的增删都会打乱 index 连续性,动画和刷新都要重新计算。代价是内层 TableView 的滚动必须关闭,把滚动事件统一交给外层,否则两个列表同时响应手势,体感就是滚不动、乱跳。这是嵌套实现里最常见也最容易被忽视的坑,后面避坑章会单独说。
| 维度 | 嵌套方案 | 平铺方案 |
|---|---|---|
| UI 分组 | 回复天然挂在评论 cell 内 | 需要 section 或折叠状态管理 |
| 数据变更 | 局部刷新内嵌列表 | 需计算 index 变化,影响外层行数 |
| cell 高度 | 需监听 contentSize,复杂度较高 | 正常 Auto Layout,较直接 |
| 滚动控制 | 必须关闭内嵌滚动 | 全局只有一个滚动视图 |
| 维护成本 | 概念清晰但细节多 | 数据结构需要拍平,搜索、点击定位更麻烦 |
2.2 数据模型:两层模型加一个展开标记
评论区接口返回的 JSON 有平铺和树形两种形态。平铺形态是所有评论和回复混在一起,靠 parentID 关联;树形形态是回复直接嵌套在评论节点里。无论接口给哪种,客户端这边都建议统一转成本地模型,而不是直接拿着 JSON 去驱动 UI。
struct CommentModel { let commentID: String // 评论自身 ID let user: String // 评论者昵称 let content: String // 评论正文 var replies: [ReplyModel] // 该评论下的回复列表 var isExpanded: Bool = false // 是否展开全部回复 } struct ReplyModel { let replyID: String // 回复自身 ID let commentID: String // 所属评论 ID,用于回调解绑 let fromUser: String // 回复发起人 let toUser: String // 被回复人,空字符串表示回复顶层评论 let content: String // 回复正文 }外层 TableView 的数据源是[CommentModel],内层 TableView 的数据源是某一个CommentModel里的replies。commentID在内层 cell 的按钮回调里用来做回复的插入定位。注意这个工程做的是两层楼中楼——回复的回复不再嵌套新层级,而是归到同一层的replies数组里,靠toUser字段记录被回复者是谁。这样既能展示“A 回复 B”的语义,又避开了递归模型带来的性能黑洞。无限层级可以做,但不建议,避坑章会细讲原因。
接口数据转模型的代码,我习惯放在网络解析层而不是控制器里,保证控制器拿到的就是干净可绑定的数组:
func parseComments(json: [[String: Any]]) -> [CommentModel] { var comments: [CommentModel] = [] var repliesByParent: [String: [ReplyModel]] = [:] for item in json { guard let id = item["id"] as? String, let parentID = item["parentID"] as? String, let user = item["user"] as? String, let content = item["content"] as? String else { continue } if parentID.isEmpty { comments.append(CommentModel(commentID: id, user: user, content: content, replies: [])) } else { let reply = ReplyModel( replyID: id, commentID: parentID, fromUser: user, toUser: item["toUser"] as? String ?? "", content: content ) repliesByParent[parentID, default: []].append(reply) } } for i in comments.indices { comments[i].replies = repliesByParent[comments[i].commentID] ?? [] } return comments }这段代码有两个值得注意的参数逻辑。第一,parentID为空字符串代表这是顶层评论,其余全部按回复处理,这个判断要和服务端约定好,有的接口用 0 表示顶层,有的用 null,处理前先确认类型。第二,分组时把回复先暂存在字典里,遍历完再统一挂到评论下。不要边遍历边往comments里追加回复,那样同一时间修改数组和字典,数据容易错乱。这段转换逻辑写成单元测试的性价比极高,评论区数据错乱问题八成出在分组这一步。
3. 从父列表到内嵌列表:评论 cell 里再放一个 TableView
下面按工程最核心的三块展开:父控制器怎么把评论 cell 排出来、CommentCell 内部怎么装配内嵌列表、以及高度自适应和展开收起的交互怎么串起来。这三块装完,整个功能就跑起来了。
3.1 父 TableView 的 cellForRow 与回调出口
父控制器代码比想象中少,难点都在 cell 内部。核心是把数据通过bind方法传给 CommentCell,父控制器完全不关心回复有多少条、内嵌列表怎么布局。
final class CommentViewController: UIViewController { private let tableView = UITableView(frame: .zero, style: .plain) private var comments: [CommentModel] = [] override func viewDidLoad() { super.viewDidLoad() // 关键:cell 必须走 automaticDimension,回复的评论才能动态撑高 tableView.register(CommentCell.self, forCellReuseIdentifier: "CommentCell") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 80 tableView.dataSource = self tableView.delegate = self } } extension CommentViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return comments.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "CommentCell", for: indexPath) as! CommentCell // 回调出口:cell 内部任何按钮事件都通过闭包向上抛 cell.onReplyTap = { [weak self] commentID, replyToUser in self?.showReplyInput(commentID: commentID, replyToUser: replyToUser) } cell.onToggleExpand = { [weak self] commentID in self?.toggleExpand(commentID: commentID) } cell.bind(comment: comments[indexPath.row]) return cell } }tableView.rowHeight设为automaticDimension配合estimatedRowHeight,这是嵌套方案能跑起来的必要条件。estimatedRowHeight只是一个估算值,实际高度由 Auto Layout 在 cell 内部计算,理论失配不影响最终布局,但会影响滚动条平滑度。评论内容长短差异大的场景,这个值不要给得太离谱,取平均高度即可,我给 80 是因为多数评论在 1-2 行文本。
回调出口的设计要想清楚:CommentCell 暴露两个闭包,onReplyTap在用户点回复按钮时触发,onToggleExpand在用户点“查看全部回复”时触发。闭包全部用weak self捕获控制器,避免 cell 持有控制器形成循环引用。控制器收到回调后,操作的是comments数组里的模型,而不是直接改 cell 的显示状态,这保证了数据源永远唯一。
3.2 CommentCell 内部:内嵌列表装配与闭包透传
CommentCell 是这场戏的主角。它内部持有一个 UITableView 和一个高度约束,内嵌列表注册的是回复用的 ReplyCell。核心思路:内嵌 TableView 不允许滚动,滚动事件交给外层;内嵌列表的高度始终等于它自身contentSize的高度。
final class CommentCell: UITableViewCell { private let commentLabel = UILabel() private let replyTableView = UITableView(frame: .zero, style: .plain) private var replies: [ReplyModel] = [] private var heightConstraint: NSLayoutConstraint! private var contentSizeToken: NSKeyValueObservation? var onReplyTap: ((_ commentID: String, _ replyToUser: String) -> Void)? var onToggleExpand: ((_ commentID: String) -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() observeContentSize() } private func setupUI() { // 命根子:内层不滚动,滚动统一交给外层列表 replyTableView.isScrollEnabled = false replyTableView.register(ReplyCell.self, forCellReuseIdentifier: "ReplyCell") replyTableView.dataSource = self replyTableView.delegate = self replyTableView.estimatedRowHeight = 24 replyTableView.rowHeight = UITableView.automaticDimension heightConstraint = replyTableView.heightAnchor.constraint(equalToConstant: 0) NSLayoutConstraint.activate([ heightConstraint ]) } private func observeContentSize() { contentSizeToken = replyTableView.observe(\.contentSize, options: [.new]) { [weak self] tableView, _ in self?.heightConstraint.constant = tableView.contentSize.height } } func bind(comment: CommentModel) { commentLabel.text = comment.user + ":" + comment.content // 折叠态只展示前 2 条 if comment.isExpanded { replies = comment.replies } else { replies = Array(comment.replies.prefix(2)) } replyTableView.reloadData() } override func prepareForReuse() { super.prepareForReuse() // 复位内嵌列表,防止复用后串数据 replies = [] replyTableView.dataSource = nil replyTableView.delegate = nil replyTableView.reloadData() heightConstraint.constant = 0 } override func layoutSubviews() { super.layoutSubviews() // 兜底:contentSize 更新后布局系统刷新约束 heightConstraint.constant = replyTableView.contentSize.height } deinit { contentSizeToken?.invalidate() } }逻辑说明:isScrollEnabled = false是内嵌列表的命根子,不关掉就会出现两层滚动抢占,页面滚不动。reloadData必须放在bind里,不能放在init里,因为复用 cell 时replies已经换成新数据了。heightConstraint初始为 0,reloadData完成后需要把它的constant更新为replyTableView.contentSize.height。
这里要解释一个细节:reloadData是异步布局的,调用完立刻读contentSize得到的往往是上一次布局后的值。所以更新约束的时机放在两个地方:一个是 KVO 监听contentSize属性,内容高度一变立刻同步约束;另一个是在layoutSubviews里做兜底。KVO 方案响应快,layoutSubviews方案简单可靠,两者不冲突可以共存。NSKeyValueObservation要求 iOS 11+,如果工程最低版本低于 iOS 11,要换回addObserver写法,注意在deinit里移除观察者,否则 cell 复用时会野指针崩溃。
回复按钮的闭包链需要透传两层:ReplyCell 的按钮被点击后,先触发 CommentCell 的闭包,再通过 CommentCell 暴露给控制器的闭包向上传递。
final class ReplyCell: UITableViewCell { var onReplyTap: ((_ reply: ReplyModel) -> Void)? // cell 内部按钮的 action 里调用:onReplyTap?(reply) }CommentCell 在内嵌列表的cellForRowAt里做一层转发:
extension CommentCell: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return replies.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ReplyCell", for: indexPath) as! ReplyCell let reply = replies[indexPath.row] cell.bind(reply: reply) cell.onReplyTap = { [weak self] reply in guard let self = self else { return } // 透传:ReplyCell -> CommentCell -> 控制器 self.onReplyTap?(reply.commentID, reply.fromUser) } return cell } }闭包链每层都要用weak self,这是嵌套方案最容易漏的地方。ReplyCell 持有闭包,闭包捕获 CommentCell,CommentCell 又持有闭包,闭包捕获控制器——只要中间一环没有 weak,内存就只增不减。滑动评论区几分钟后内存波动变大,排查方向就在这条链上。
3.3 展开收起交互:数据层驱动 UI,不要直接改 cell
“查看全部回复”的交互设计,我一般把展开标记放在 CommentModel 的isExpanded字段上。数据层改状态,再 reload 对应行,让 cell 重新走一遍 bind 流程,内嵌列表自然刷新。
extension CommentViewController { private func toggleExpand(commentID: String) { guard let index = comments.firstIndex(where: { $0.commentID == commentID }) else { return } comments[index].isExpanded.toggle() tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .fade) } }这样设计的好处是:展开状态不依赖 cell 实例的局部变量,cell 滚出屏幕再滚回来也能保持正确状态。如果直接改 cell 里的replies数组而不 reload,高度约束不会重新计算,视觉上回复列表不会撑开,这是新手最容易踩的“数据变了 UI 不动”的坑。
内嵌列表的 cell 高度靠 ReplyCell 的 Auto Layout 约束决定,文本标签需要四边约束完整,否则内容高度算不对。回复的格式一般是“A 回复 B:内容”这种样式。我习惯用一个 label 显示整行富文本,A和B用高亮色,内容用正文色,避免在一个 cell 里放三个 label 去拼布局,约束少一个都可能导致高度计算失败。
4. 嵌套方案避坑:5 个高频翻车点与排查路径
这部分是我反复做这套方案攒下的血泪教训。每个问题按现象、原因、解决三段写,你可以直接对照自己的代码排查。
4.1 内嵌列表抢滚动:页面滚不动的第一嫌疑人
现象:手指放在回复区域滑动,整个页面纹丝不动,或者内嵌列表先滚到顶/底才轮到外层跟着滚。
原因:内嵌 UITableView 的isScrollEnabled默认是 true。它自己就是一个滚动视图,会拦截滑动手势。两个 scrollView 嵌套时,手势默认只会交给其中一个识别,表现就是“吃手势”。
解决:在 CommentCell 的setupUI里把replyTableView.isScrollEnabled = false。注意不要在bind方法里设置,虽然复用时不影响,但初始化阶段固定下来的配置更清晰。如果产品经理坚持要求回复区域内部也能滚动,那就要换成手势代理方案,允许手势同时响应,但那种体验非常分裂,滚着滚着不知道页面在哪,我通常不推荐。
4.2 reloadData 后 cell 高度跳变:展开回复时内容突然闪一下
现象:点击“查看全部回复”,评论 cell 先是缩到预估高度,再弹开变高,甚至连续抖几次才稳定。
原因:reloadData之后contentSize变化了,但高度约束的更新时机没跟上。系统先按旧的约束值渲染了一遍,KVO 回调触发后才重新布局。如果 KVO 没写,或者写在了 cell 初始化之前,展开后的回复直接会被裁掉一半。
解决:KVO 观察写在 cell 的init里,且用[weak self]。展开回复时只改replies数组并调用reloadData(),不要手动去改约束常量。如果对动画有要求,在闭包回调里同时更新约束并调用layoutIfNeeded,让系统一次性完成布局。要检查回复内容的文本是否设置了对齐方式和行数限制,长文本没有preferredMaxLayoutWidth时,高度计算会玄学性地差几个像素,高频率滑动时看起来就像在跳。
4.3 复用后回复内容串行:A 评论下面显示 B 的回复
现象:滑动一段距离后,部分评论 cell 里显示的是别的评论的回复。
原因:复用机制。cell 滚出屏幕进入复用池,滚回来时prepareForReuse没有清理旧数据。如果bind方法里先赋值新数组再reloadData,中间有极短的窗口期,内嵌列表还渲染着旧数据。还有一种隐蔽情况:外层列表reloadData和内层列表reloadData在同一次 RunLoop 里互相穿插,异步回调拿到过期数组。
解决:在prepareForReuse里做完整清理,这是复用 cell 的必经入口。最稳妥的做法是先把内嵌列表的 dataSource 和 delegate 置 nil,reloadData一次,然后heightConstraint.constant = 0。bind时再重新设置 dataSource、delegate 并reloadData。
4.4 复用 cell 内嵌列表滚动位置残留:回来看不到原来的位置
现象:内嵌列表本来可以独立滚动,滑出去再滑回来,回复列表停在之前滚到的位置,甚至出现空白区域。
原因:复用 cell 时,子视图的contentOffset不会自动重置。
解决:首选方案还是关闭内嵌滚动。如果产品必须要求内嵌列表独立滚动,那就在prepareForReuse里加一句replyTableView.contentOffset = .zero。同时必须处理手势冲突,常见做法是实现UIGestureRecognizerDelegate,允许外层和内层手势在特定条件下同时识别,这会引入非常复杂的状态判断,体验调起来成本高,很多成熟产品最终都会退回到整体滚动的方案。
4.5 内存与递归风险:别做无限层级的“评论的评论的评论”
现象:产品经理后续提需求,要支持“回复的回复”无限嵌套,照着设计做了递归模型后,评论一多内存和 CPU 肉眼可见地上涨。
原因:递归模型导致 UI 层递归嵌套 cell。每层 cell 内部都创建一个新的 TableView,视图层级深,内层 cell 的复用和外层不在同一个池子里,内存开销成倍增长。无限嵌套还会带来一个逻辑地狱:删除一条顶层评论,要遍历整棵递归树去清理所有子回复。
解决:设计阶段就限制为两层:评论 + 对评论的回复。服务端在返回数据时,把“回复的回复”在返回前拍平到所属顶层评论下,客户端用toUser字段表示被回复者是谁。UI 上展示“B 回复 A:内容”即可,语义完整且不牺牲性能。如果产品方坚持要第三层,成本要提前说清楚,这不是加个字段的事,是整个列表结构重建。
5. 进阶技巧:数据极端时平铺是后悔药,切换方法在这
嵌套方案不是万能的。当评论的回复数量分布很不均匀时,一个评论下挂上百条回复,嵌套 cell 会变得异常高大,滑动时离屏 cell 的创建和销毁开销激增,掉帧明显。这时候平铺方案是更好的选择:把回复拍平成独立 cell,紧跟所属评论 cell 后面,用数据模型里的枚举区分两种行类型。
enum FeedRow { case comment(CommentModel) case reply(ReplyModel) } struct FeedBuilder { static func build(_ comments: [CommentModel]) -> [FeedRow] { var rows: [FeedRow] = [] for comment in comments { rows.append(.comment(comment)) for reply in comment.replies { rows.append(.reply(reply)) } } return rows } }平铺方案里,展开收起变成对rows数组的动态插入和删除。点击“查看全部回复”时,找到评论对应的位置,把后面折叠起来的回复行insert进去,再用reloadRows或insertRows做动画。行号计算需要维护一个映射关系,折叠时记录每行对应的评论索引,展开时逆推回来。这段逻辑比嵌套方案难写,但 cell 高度交给了正常的 Auto Layout,不再需要监听contentSize,也没有内嵌列表复用的问题。
我判断切换时机的标准很简单:单条评论回复均值超过 10 条、评论本身只展示摘要、产品需要回复区域独立滚动,这三个信号出现任意一个,就值得先评估平铺。我的习惯是,先按嵌套方案把交互验证通过,确认评论与回复的归属视觉表达成立,再对照线上数据分布决定要不要换平铺。直接上手平铺当然也可以,但嵌套方案在逻辑理解上更贴近“一条评论是一组数据”的心智模型。等反馈说“评论多了滑动变卡”,那就说明你已经走到了嵌套方案的边界。换平铺,不用担心推倒重来,数据模型和接口解析层都不用动,只换 UI 层的组织方式。希望帮到你。
本文还有配套的精品资源,点击获取