You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI中含自定义Markdown文本的LazyVStack滚动卡顿问题优化及实现方案咨询

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.15 12:48:00