资讯详情

资讯详情

UITableViewCell内嵌WKWebView高度自适应与复用优化实战

做iOS开发的朋友应该都遇到过一个特别“拧巴”的需求要在UITableViewCell里塞一个WKWebView。第一反应是挺简单的直接加约束、setHTML字符串就完事了。但真跑起来你会发现高度不对、滚动冲突、Cell复用后内容串台、列表滑动狂掉帧各种问题像打地鼠一样冒出来。这篇文章就把我自己的实战过程完整拆一遍从为什么会有这种需求到高度自适应的原理再到复用机制下踩过的坑最后给出一段可用的完整实现。无论是刚接触WKWebView的新人还是被这个问题折磨过的老手照着这个思路走至少不会再走大弯路。1. 这种需求从哪来的以及为什么“塞进去”就完蛋了1.1 典型场景与需求本质先说说什么场景会用到这个组合。最常见的是信息流里带富文本卡片比如某App的详情页列表上面有图文混排内容下面串着相关推荐列表。正常做法是用HTML展示图文混排内容因为没法预测内容多长而且里面有各种图片、列表、标题样式用UILabel TextKit去做排版的成本非常高。还有一种场景是App内嵌网页工具页比如活动说明、会员协议、客服消息中的富文本气泡。这类内容天然是HTML如果直接“弹一个WebViewController”会打断用户操作流产品经理就会说“我只要一个小小的窗口塞进列表里就行”。需求本质其实只有一件事**不知道WebView内容有多高所以UITableView无法返回rowHeight。**因为UITableView在布局Cell之前会先问heightForRowAtIndexPath如果你给不了一个确定数字后面的所有布局都是空中楼阁。1.2 直接添加的三种后果我刚做这个功能的时候第一版非常简单粗暴在cell里创建WKWebView然后设置约束top、leading、trailing、bottom都贴合contentView觉得用系统自动布局就能搞定高度。结果出现了三个经典问题第一Cell高度无法确定。UITableView是用返回的高度去做布局的虽然iOS 10之后有了self-sizing cell设置estimatedRowHeight UITableViewAutomaticDimension但WKWebView在内容加载出来之前它的frame宽度可能正确高度却始终是0或者初始值。约束链里的高度算不出来Cell最后只能使用estimatedHeight于是列表渲染是错乱的。第二WebView滚动冲突。默认WKWebView内部有个scrollView它自己是可以滚动的。当它嵌在Cell里用户手指滑动到底是让tableView滚还是让webView滚两个手势同时响应页面直接抽搐。第三Cell复用导致内容串台。列表滚动时Cell会被复用你的webView可能还在加载上一个HTML或者没加载完就改了数据源导致新Cell里出现旧内容而且高度回调还可能是旧URL的全乱了。1.3 提前想清楚一件事内层高度决定外层布局所以第一步不是写代码而是把架构想明白。WKWebView在Cell里的核心矛盾不是“能不能嵌”而是**“内层WebView的高度必须被外层Cell知道而且要在合适的时间点用正确的方式刷新”**。如果你把WebView当作一个固定高度的自定义视图那一切都好说先算高度再返回高度。但问题就是高度是异步获取的。所以整个方案本质上是一个“异步高度反馈 表格局部刷新”的流程Cell加载HTML字符串开始渲染。WebView加载完成或者通过JS/监听方式拿到内容的实际高度。把高度存到模型或缓存里。调用tableView.reloadRows或者performBatchUpdates让对应行重新布局。顺序错一点都不行。先reload再拿高度等于白刷新。先拿高度但没存模型下次滚动复用又乱了。2. 高度自适应的三种主流实现以及我选型的理由高度获取是整个方案的地基。我前后试过三种方式都各有优缺点下面说清楚原理和适用场景。2.1 方案AKVO监听scrollView.contentSize这是比较经典的方式。每个WKWebView都有一个scrollViewKVO监听它的contentSize属性内容变化时就能拿到内部实际高度。webView.scrollView.addObserver(self, forKeyPath: contentSize, options: [.new], context: nil)拿到之后用webView.scrollView.contentSize.height即可。这个方法的好处是响应及时WebView内容变化时会实时回调。但坑也很明显contentSize初始为0且变化频率可能很高图片逐张加载、字体渲染延迟。必须在deinit里移除observer否则崩溃。依赖KVO时机如果监听回调不在主线程要先切主线程再刷新UI。不同iOS版本下contentSize的取值会有细微差异尤其是有viewport设置的时候。不过它的实现最直接适合快速验证。2.2 方案B通过JS Bridge回调高度如果HTML本身是你自己控制的就用JS主动汇报高度。在HTML页面onload或合适时机通过window.webkit.messageHandlers.height.postMessage(height)把document.documentElement.scrollHeight发给iOS端。function reportHeight() { var height document.documentElement.scrollHeight; window.webkit.messageHandlers.height.postMessage(String(height)); } window.onload reportHeight;iOS端在WKScriptMessageHandler里接收func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name height { let height CGFloat(Double(message.body as? String ?? 0) ?? 0) onHeightChanged?(height) } }这个方式精度最高因为scrollHeight是HTML渲染后的实际高度而且不依赖KVO时机。但代价是要求HTML里注入一段脚本如果网页内容来自第三方这种方式就不可行。2.3 方案CdidFinish后执行JavaScript评估如果你不控制HTML内容也没关系。在WKWebView的navigationDelegate回调didFinish里执行一段JavaScript获取文档高度func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { let heightScript Math.max( document.body.scrollHeight, document.body.offsetHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight ) webView.evaluateJavaScript(heightScript) { (result, error) in guard error nil, let height result as? CGFloat else { return } onHeightChanged?(height) } }之所以要Math.max取最大值是因为不同HTML写法的文档高度来源不同有body高度、html高度、scrollHeight、offsetHeight只有取最大值才能兼容大部分页面。2.4 三个方案的对比与选型结论我后来把三个方案做了张表对比方便你结合实际场景选择方案依赖条件精度实时性实现复杂度推荐场景KVO监听contentSize无中高低快速实现、内部页面JS Bridge可控HTML高高中自己团队的H5页面didFinish evaluateJavaScript需等加载完成较高中低第三方页面、通用方案我的选型结论是如果HTML完全可控优先用JS Bridge如果不确定HTML来源就用方案C它在didFinish之后执行能拿到渲染后的真实高度而且不侵入HTML。KVO方案我一般只用来做调试辅助不当作主方案因为它容易出现多次回调、高度跳动的问题。3. 高度拿到了怎么让Cell“服”才是核心高度拿到之后你不要高兴太早。直接在回调里reloadRowsCell大概率还是会闪烁、跳动甚至高度不生效。原因是UITableView的布局机制它需要先知道预估高度再根据实际约束算出确切高度。你返回的高度和内部约束必须对应。3.1 模型缓存是前提不要每次都在heightForRowAtIndexPath里实时获取这样既慢又危险。正确做法是把高度存到对应的数据模型里。struct FeedItem { let html: String var htmlHeight: CGFloat 0 var isHeightLoaded false }heightForRowAtIndexPath里的逻辑就变成func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) - CGFloat { let item dataList[indexPath.row] if item.isHeightLoaded { return item.htmlHeight } return estimatedHeight }当webView回调里得到高度后先更新数据模型再执行刷新item.htmlHeight height item.isHeightLoaded true tableView.performBatchUpdates({ tableView.reloadRows(at: [indexPath], with: .none) }, completion: nil)这样UITableView在问高度时模型里已经有值Cell的约束高度也对得上刷新就不会闪烁。3.2 刷新时机与防抖动我之前遇到一个很头疼的问题高度回调来了两次第一次拿到的是图片没加载完的高度第二次是完整高度页面就会上下跳两次。解决办法是只信任最终值。如果用方案CdidFinish里执行JS天然只会回调一次问题不大。如果用KVO方案就要加一个标记位var isHeightSettled false // KVO回调里 guard !isHeightSettled else { return } isHeightSettled true // 更新模型 刷新表格还有一种抖动原因是Cell里WebView自己有个默认的背景色从透明变白或者从白变透明视觉上也会引起闪烁。给WebView设置和Cell背景一致的backgroundColor并且让它isOpaque true能有效减少这种视觉跳动。3.3 Auto Layout约束的摆放细节很多人忽略WebView自身的约束。我推荐的做法是给WebView的高度单独设一个Constraint而不是同时设置top和bottom因为只有top和bottom都固定时约束系统才会决定高度但如果你期望高度跟着内容走就要让cell的bottom约束等于webView的bottom同时严格控制top。示例约束webView.translatesAutoresizingMaskIntoConstraints false NSLayoutConstraint.activate([ webView.topAnchor.constraint(equalTo: contentView.topAnchor), webView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), webView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), webView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ])然后手动维护高度约束webViewHeightConstraint webView.heightAnchor.constraint(equalToConstant: 0) webViewHeightConstraint.priority .defaultHigh webViewHeightConstraint.isActive true拿到高度后更新webViewHeightConstraint.constant height即可。注意把bottom约束和height约束一起用就没有歧义。如果Cell里还有其他元素比如标题Label就把WebView的bottom约束和那个Label/top之类的串起来。4. Cell复用机制下我栽过的那些跟斗这一节是我损失最多时间的地方。也是做这个功能最容易被劝退的地方。4.1 内容残留与回调串CellUITableView的复用机制大家都熟滚出屏幕的Cell会被放回复用池新Cell实例直接复用同一个对象。如果你的Cell里有个WebView它内部还保留着上一个Item的HTML内容直接给新Item赋值时新内容还没加载出来显示的却还是旧内容视觉上就翻车了。更隐蔽的是异步回调串号。比如Cell被复用了上一个Item的WebView加载完成回调触发了高度更新但你这时候已经给Cell赋了新的Item这个回调就把新Item的高度刷新成了旧Item的高度。要解决串号我给每个加载任务生成一个唯一ID在回调里校验这个ID是否依然有效var currentLoadID: UUID? func configure(with item: FeedItem) { let loadID UUID() currentLoadID loadID webView.loadHTMLString(item.html, baseURL: nil) onHeightChanged { [weak self] height in guard let self self, self.currentLoadID loadID else { return } item.htmlHeight height item.isHeightLoaded true self.heightChanged?(item) } }4.2 prepareForReuse到底该做什么很多人习惯在prepareForReuse里把WebView从SuperView移除甚至直接重建。这样做确实能清干净内容但会产生另一个问题频繁创建WKWebView的开销非常大列表稍微长一点滑动就开始掉帧。我的经验是不要在prepareForReuse里remove掉WebView因为WebView本身是可复用的。你只需要把当前加载任务ID清掉避免旧回调触发。调用webView.stopLoading()。把高度约束设置为0避免复用瞬间高度错误。重新设置WebView的delegate。override func prepareForReuse() { super.prepareForReuse() currentLoadID nil webView.stopLoading() webView.navigationDelegate nil webView.uiDelegate nil webViewHeightConstraint.constant 0 }这样既有复用性能又不会把上一个内容带过来。4.3 离屏模板Cell预计算高度如果你对列表流畅度要求很高有一种进阶方案是“离屏Cell预计算高度”。维护一个脱离UITableView的模板Cell实例或者直接用一个WKWebView实例在数据源到来后用它预先加载HTML把算好的高度缓存起来再等列表实际展示时直接用缓存。final class WebHeightPreloader { private var preloadWebView: WKWebView? private var heightCache: [String: CGFloat] [:] func preload(url: String, key: String, completion: escaping (CGFloat) - Void) { if let cache heightCache[key] { completion(cache) return } let webView WKWebView(frame: CGRect(x: 0, y: 0, width: screenWidth, height: 1)) // 加载htmldidFinish后取高度存入cache再completion } }这个方案的好处是正式列表里的Cell不会频繁刷新因为高度已经算好了坏处是内存里多一个WebView实例而且如果你的HTML加载很慢预计算会积压。我只在内容量小但渲染重的场景用——比如首页首屏只有三四个WebView卡片我预先算好高度避免用户看到加载跳动。4.4 长列表下WebView的回收策略如果是无限滚动信息流Cell里每个都是WebView即使有复用池内存也会很紧张。WKWebView不是轻量控件每个实例的系统内存开销很大。我之前在项目里做首屏8个卡片WebView内存上涨明显。我的策略是滚出可视区一段距离比如300pt后把WebView从Cell里移除并回收滚回来时再重新创建并赋值。判断滚动方向和位置可以在scrollViewDidScroll里做func scrollViewDidScroll(_ scrollView: UIScrollView) { for cell in tableView.visibleCells { guard let webCell cell as? ContentWebCell else { continue } let rect tableView.rectForRow(at: tableView.indexPath(for: cell)!) let intersection rect.intersection(tableView.bounds) if intersection.height 0 { webCell.recycleWebView() } } }这个策略能显著控制内存但代价是滑回来时WebView需要重新加载如果HTML有缓存可能还好没缓存的话会出现白屏闪现。实际项目要看业务取舍如果HTML来源是固定几个页面建议用WKWebsiteDataStore做缓存如果是用户发帖内容这种独有页面用回收策略性价比更高。5. 滚动、手势与链接跳转的边界处理5.1 内层滚动必须关干净只要WebView在Cell里它里面的scrollView就要禁掉否则用户手指一滑动里外两个scrollView抢手势体验直接崩盘。禁用的代码很简单webView.scrollView.isScrollEnabled false webView.scrollView.bounces false webView.scrollView.alwaysBounceVertical false webView.scrollView.contentInset .zero注意不要忘了bounces和alwaysBounceVertical因为即使isScrollEnabled设为false某些内容高度超过视图高度时仍可能出现弹性滚动效果。还有一个细节WebView默认的scrollView会有一些系统内边距如果不设为zero可能造成内容顶部或底部有白条。5.2 链接点击拦截与站内跳转Cell里的WebView会点击链接但直接跳转会离开App或新开页面体验很差。最好通过navigationDelegate拦截分类处理。func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: escaping (WKNavigationActionPolicy) - Void) { guard let url navigationAction.request.url else { decisionHandler(.cancel) return } if navigationAction.navigationType .linkActivated { // 调用外层回调让Controller去push一个WebViewController或者打开Safari linkClicked?(url) decisionHandler(.cancel) } else { decisionHandler(.allow) } }这里有个坑内部HTML里的iframe子资源请求如图片懒加载也是navigationAction但你只想拦截用户主动点击的链接。通过判断navigationType .linkActivated可以把两者区分开。5.3 长按文本/图片引发的交互问题WebView在Cell里还有一个很容易被忽略的交互长按文本会弹出系统的选择框长按图片会弹出保存菜单。这在信息流卡片里非常突兀。如果你不需要这些交互可以注入CSS禁用* { -webkit-touch-callout: none; -webkit-user-select: none; }通过WKUserScript注入在创建WebView的配置里设置let userScript WKUserScript( source: cssScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true ) config.userContentController.addUserScript(userScript)如果某些文本需要可选中复制那就别一刀切禁用可以在HTML里给特定class开启user-select: text。6. 性能与内存实测数据和使用建议6.1 一个WKWebView的代价WKWebView表面上是UIView底层却是独立进程渲染。实测单个WKWebView在空载状态下内存占用大约在10-30MB之间加载复杂页面后会更高。如果在Cell里同时存在8个WebView内存直接多出100MB以上这在iOS设备上是非常可怕的。所以我的建议很简单粗暴同一屏可视区域里WebView的数量不要超过4个。超出这个范围要么回收要么改用别的方案。6.2 用滚动回收换内存前面提到的滚动回收策略我建议把它做成正式的复用组件。尤其是列表很长、卡片很多的信息流页面你不希望用户滑到底部时内存被撑爆。回收要注意一个细节不要真的把WKWebView销毁因为创建WebView的成本很高你可以在Controller里持有一个“空闲WebView池”Cell滚出屏幕时把它的webView放入池中滚进来时从池里捞一个重用。final class WebViewPool { private var idleWebViews: [WKWebView] [] func dequeue() - WKWebView { if let webView idleWebViews.popLast() { return webView } return createWebView() } func recycle(_ webView: WKWebView) { webView.stopLoading() webView.loadHTMLString(, baseURL: nil) idleWebViews.append(webView) } }但注意池子也不能无限大建议控制上限3个超过的直接置nil销毁。6.3 什么时候根本不该用WKWebView我见过很多为了一个HTML图文排版功能把整个列表性能拖垮的案例。有些场景真的不该用WebView纯文本少量图片展示用UILabel NSAttributedString或TextKit完全能搞定。需要复制、折叠、动态字体等富文本交互用WKWebView不一定比原生好做。只有几行文字的内容用WebView有点杀鸡用牛刀的味道初始化成本远大于实际收益。我通常会在方案评审阶段先问一个问题内容是谁产生的如果是后端运营配的富文本且编辑格式很复杂那WebView合理如果只是固定格式的用户评论、公告文本用原生方案性能会好得多。这不仅是技术选型问题也是为后续维护责任做取舍。我见过一个项目因为早期用WebView展示所有富文本后来业务要求文本首行缩进、禁止长按复制每一条都要在HTML里加样式维护成本极高最后还是要迁移回原生渲染。7. 一段可以直接拿去用的完整实现说了这么多最后给出一段相对完整的代码。我以Swift SnapKit风格的自动布局为例原生的NSLayoutConstraint当然也可以但SnapKit可读性好不少。7.1 核心Cell代码import UIKit import WebKit final class RichTextCell: UITableViewCell { var onHeightChanged: ((CGFloat) - Void)? private var webView: WKWebView! private var heightConstraint: Constraint? private var currentLoadID: UUID? private var isHeightSettled false override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupWebView() } required init?(coder: NSCoder) { fatalError(init(coder:) has not been implemented) } private func setupWebView() { let config WKWebViewConfiguration() let userScript WKUserScript( source: * { -webkit-touch-callout: none; -webkit-user-select: none; } , injectionTime: .atDocumentEnd, forMainFrameOnly: true ) config.userContentController.addUserScript(userScript) webView WKWebView(frame: .zero, configuration: config) webView.navigationDelegate self webView.scrollView.isScrollEnabled false webView.scrollView.bounces false webView.translatesAutoresizingMaskIntoConstraints false contentView.addSubview(webView) webView.snp.makeConstraints { make in make.top.equalToSuperview() make.leading.trailing.equalToSuperview() heightConstraint make.height.equalTo(0).constraint } } func configure(with html: String) { let loadID UUID() currentLoadID loadID isHeightSettled false webView.loadHTMLString(html, baseURL: nil) } override func prepareForReuse() { super.prepareForReuse() currentLoadID nil isHeightSettled false webView.stopLoading() heightConstraint?.update(offset: 0) } // 滚动回收时调用 func recycleWebView() { currentLoadID nil isHeightSettled false webView.stopLoading() webView.loadHTMLString(, baseURL: nil) heightConstraint?.update(offset: 0) } deinit { webView.navigationDelegate nil webView.uiDelegate nil } } extension RichTextCell: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { guard currentLoadID ! nil, !isHeightSettled, let heightConstraint heightConstraint else { return } let script Math.max( document.body.scrollHeight, document.body.offsetHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight ) webView.evaluateJavaScript(script) { [weak self] result, error in guard let self self, error nil, let value result as? NSNumber else { return } let height CGFloat(value.doubleValue) guard height 0 else { return } self.isHeightSettled true self.heightConstraint?.update(offset: height) self.onHeightChanged?(height) } } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: escaping (WKNavigationActionPolicy) - Void) { if navigationAction.navigationType .linkActivated { decisionHandler(.cancel) // 交给外部打开站内WebView return } decisionHandler(.allow) } }7.2 控制器侧刷新逻辑在Controller里核心逻辑是按indexPath维护高度缓存extension ViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) - Int { items.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) - UITableViewCell { let cell tableView.dequeueReusableCell(withIdentifier: RichTextCell, for: indexPath) as! RichTextCell let item items[indexPath.row] cell.configure(with: item.html) cell.onHeightChanged { [weak self] height in guard let self self else { return } item.htmlHeight height item.isHeightLoaded true self.tableView.performBatchUpdates({ self.tableView.reloadRows(at: [indexPath], with: .none) }, completion: nil) } return cell } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) - CGFloat { let item items[indexPath.row] if item.isHeightLoaded { return item.htmlHeight } return 150 } }7.3 最后一点经验这套代码我用了很久新项目里遇到类似需求基本就是改改类名直接抄。最关键的一句心得是永远不要依赖Cell内部约束自动算出WebView的高度一定要先缓存高度再刷新表格。因为约束计算需要等WebView的frame更新而frame更新又依赖外层布局完成形成了一个循环依赖。只有“模型高度 - 更新约束 - reloadRow - 约束匹配高度”这样的单向链路才能让布局稳定下来。另外KVO方案的回调虽然在某些场景更快但坑也多如果你在做一个给团队用的公共组件我强烈建议用didFinish evaluateJavaScript的方式简单、稳定、无侵入出问题也好排查。我也不建议在RichTextCell里加太多业务逻辑。比如是否显示底部分割线、是否需要图文混排里再加视频这些应该通过模型字段控制而不是通过各种代理闭包把Cell塞满。Cell越内聚复用成本越低。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →