SwiftUI中含自定义Markdown文本的LazyVStack滚动卡顿问题优化及实现方案咨询
嗨,我太懂你这种滚动卡顿的糟心感了——自定义Markdown文本拆分加布局的开销,真的很容易拖垮LazyVStack的流畅度,尤其是内容多的时候。我来给你捋几个可行的优化方向,还有更高效的替代实现思路:
一、先聊聊卡顿的核心原因
你当前的实现里,几个容易拖慢性能的点大概是这几个:
- 每个自定义Markdown视图的
init里都在做字符串拆分,LazyVStack虽然懒加载,但快速滚动时还是会频繁创建视图,重复执行拆分逻辑就会占满CPU; - 自定义的
CustomLayout如果布局逻辑没做缓存,每次渲染都要重新计算所有子视图的位置,开销极大; - 拆分出来的每个小组件都是独立视图,SwiftUI渲染大量小视图的 overhead 本身就不低。
二、对现有实现的针对性优化
如果想保留当前的SwiftUI原生实现思路,可以先从这几点入手优化:
1. 缓存字符串拆分结果
把字符串拆分逻辑从视图init里抽出来,用全局缓存存好拆分后的组件,同一个字符串不用重复解析。比如自己整个简单的缓存工具:
// 全局缓存工具,避免重复拆分字符串 class StringComponentCache { static let shared = StringComponentCache() private let cache = NSCache<NSString, [Component]>() func components(for string: String) -> [Component] { if let cached = cache.object(forKey: string as NSString) { return cached } // 这里放你的字符串拆分逻辑 let components = parseStringIntoComponents(string) cache.setObject(components, forKey: string as NSString) return components } // 你的原有拆分逻辑,比如识别话题、@提及、链接 private func parseStringIntoComponents(_ string: String) -> [Component] { // 替换成你实际的拆分代码 return [] } } // 视图里直接用缓存的结果 struct CustomMarkdownView: View { let text: String private var components: [Component] { StringComponentCache.shared.components(for: text) } var body: some View { CustomLayout { ForEach(components, id: \.id) { component in // 渲染单个组件的代码 } } } }
2. 给CustomLayout加布局缓存
自定义布局如果每次都重新计算子视图位置,会非常耗性能。可以利用SwiftUILayout协议的缓存能力,避免重复计算:
struct CustomLayout: Layout { // 用缓存存布局尺寸,避免重复计算 struct LayoutCache { var size: CGSize var positions: [CGPoint] } func makeCache(subviews: Subviews) -> LayoutCache { LayoutCache(size: .zero, positions: []) } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache) -> CGSize { // 如果缓存有尺寸,直接复用 if cache.size != .zero { return cache.size } // 执行你的布局尺寸计算逻辑 let calculatedSize = calculateTotalSize(proposal: proposal, subviews: subviews) // 顺便计算好所有子视图的位置,存在缓存里 cache.positions = calculateSubviewPositions(proposal: proposal, subviews: subviews) cache.size = calculatedSize return calculatedSize } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout LayoutCache) { // 直接用缓存好的位置摆放子视图 zip(subviews, cache.positions).forEach { subview, position in subview.place(at: position, proposal: proposal) } } // 抽离你的原有布局计算逻辑 private func calculateTotalSize(proposal: ProposedViewSize, subviews: Subviews) -> CGSize { // 替换成你的实际代码 return .zero } private func calculateSubviewPositions(proposal: ProposedViewSize, subviews: Subviews) -> [CGPoint] { // 替换成你的实际代码 return [] } }
3. 合并同类型连续组件
把连续的普通文本组件合并成一个,减少子视图的数量——比如连续的非话题、非@提及、非链接的文本,直接合并成一个Component,这样视图数量能大幅减少,渲染开销也会降下来。
三、更高效的替代实现方案:用UIKit富文本包装
如果上面的优化还是达不到你想要的流畅度,强烈试试用UITextView包装成SwiftUI视图的方案。UIKit的富文本渲染性能比SwiftUI拼接小视图好太多,而且自带排版和点击事件处理:
核心思路
把原字符串转换成NSAttributedString,给话题、@提及、链接分别设置样式,再用UITextView的代理处理不同类型的点击事件,最后包装成UIViewRepresentable嵌入SwiftUI:
import SwiftUI import UIKit struct AttributedMarkdownView: UIViewRepresentable { let text: String let onTagTapped: (String) -> Void let onMentionTapped: (String) -> Void let onLinkTapped: (URL) -> Void func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator textView.isEditable = false textView.isScrollEnabled = false textView.backgroundColor = .clear textView.attributedText = buildAttributedText() return textView } func updateUIView(_ uiView: UITextView, context: Context) { uiView.attributedText = buildAttributedText() } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } // 处理点击事件的代理 class Coordinator: NSObject, UITextViewDelegate { let parent: AttributedMarkdownView init(parent: AttributedMarkdownView) { self.parent = parent } func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { // 用自定义URL Scheme区分不同点击类型 switch URL.scheme { case "tag": if let tag = URL.host { parent.onTagTapped(tag) } case "mention": if let username = URL.host { parent.onMentionTapped(username) } default: parent.onLinkTapped(URL) } return false } } // 把普通字符串转成带样式的富文本 private func buildAttributedText() -> NSAttributedString { let mutableAttrStr = NSMutableAttributedString(string: text) let textNS = text as NSString // 匹配话题(#开头的内容) if let tagRegex = try? NSRegularExpression(pattern: "#(\\w+)", options: []) { let tagMatches = tagRegex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) for match in tagMatches { let fullRange = match.range let tagText = textNS.substring(with: match.range(at: 1)) // 设置话题样式 mutableAttrStr.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: fullRange) mutableAttrStr.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: fullRange) // 给话题加自定义URL,用于识别点击 let tagURL = URL(string: "tag://\(tagText)")! mutableAttrStr.addAttribute(.link, value: tagURL, range: fullRange) } } // 匹配@提及(@开头的内容) if let mentionRegex = try? NSRegularExpression(pattern: "@(\\w+)", options: []) { let mentionMatches = mentionRegex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) for match in mentionMatches { let fullRange = match.range let username = textNS.substring(with: match.range(at: 1)) mutableAttrStr.addAttribute(.foregroundColor, value: UIColor.systemGreen, range: fullRange) mutableAttrStr.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: fullRange) let mentionURL = URL(string: "mention://\(username)")! mutableAttrStr.addAttribute(.link, value: mentionURL, range: fullRange) } } // 匹配普通链接 if let linkRegex = try? NSRegularExpression(pattern: "https?://\\S+", options: []) { let linkMatches = linkRegex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) for match in linkMatches { let fullRange = match.range let linkText = textNS.substring(with: fullRange) if let linkURL = URL(string: linkText) { mutableAttrStr.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: fullRange) mutableAttrStr.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: fullRange) mutableAttrStr.addAttribute(.link, value: linkURL, range: fullRange) } } } return mutableAttrStr } } // 在LazyVStack里直接用 struct DemoContentView: View { // 模拟大量文本数据 let testTexts: [String] = (0..<100).map { index in "这是第\(index)条测试内容 #SwiftUI ,提及@User\(index),还有链接https://example.com/\(index)" } var body: some View { ScrollView { LazyVStack(spacing: 16) { ForEach(testTexts, id: \.self) { text in AttributedMarkdownView( text: text, onTagTapped: { tag in print("点击了话题:#\(tag)") }, onMentionTapped: { username in print("点击了提及:@\(username)") }, onLinkTapped: { url in print("点击了链接:\(url)") } ) .padding(.horizontal) } } } } }
这种方案的优势特别明显:
- 用UIKit原生的富文本渲染,性能比SwiftUI拼接小视图好太多,滚动基本不会卡;
- 不需要自己写自定义布局,UITextView会自动处理换行、排版;
- 点击事件的区分逻辑也很清晰,用自定义URL Scheme就能轻松判断点击的是话题、@提及还是普通链接。
四、其他小建议
如果一定要坚持用SwiftUI原生实现,还可以试试用Text的+运算符拼接带样式的文本——SwiftUI会把拼接后的Text合并成一个渲染单元,比大量小视图的开销小。不过这种方式的缺点是点击事件不好处理,没法直接给某一段文本加单独的点击逻辑,得额外做坐标判断,适合不需要复杂交互的场景。
备注:内容来源于stack exchange,提问作者Ahmed Zaidan

