考试通知
简介这套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初始为 0reloadData完成后需要把它的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 持有闭包闭包捕获 CommentCellCommentCell 又持有闭包闭包捕获控制器——只要中间一环没有 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 置 nilreloadData一次然后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 层的组织方式。希望帮到你。本文还有配套的精品资源点击获取
评论楼中楼:UITableView嵌套UITableView实现方案与避坑
NEXT STEP
看完公告,下一步怎么走?
把报考交给靠谱的人:材料预审、批次抢报、考前辅导、复审提醒,全程有人跟。