You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

NSTextView通过NSViewRepresentable嵌入SwiftUI时,NSTextLayoutManager渲染属性更新不可靠问题

NSTextView通过NSViewRepresentable嵌入SwiftUI时,NSTextLayoutManager渲染属性更新不可靠问题

嘿,我之前在做SwiftUI嵌入TextKit 2的NSTextView时,也碰到过一模一样的糟心问题——要么高亮死活画不出来,要么第一次显示后就再也不更新,折腾了好一阵才摸清楚TextKit 2和SwiftUI生命周期配合的门道!下面给你说说我是怎么解决的:

可能的问题根源&对应解决办法

1. 别让TextKit核心对象被意外重建

SwiftUI的NSViewRepresentable如果没处理好,每次状态更新可能会导致text view、layout manager这些核心对象被重新创建,之前的渲染属性设置自然就失效了。所以一定要把这些对象存在协调器里,确保它们的生命周期稳定:

struct TextKit2TextView: NSViewRepresentable {
    @Binding var text: String
    @Binding var highlightString: String

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeNSView(context: Context) -> NSTextView {
        // 初始化TextKit 2组件,后续存在协调器中
        let textStorage = NSTextStorage(string: text)
        let layoutManager = NSTextLayoutManager()
        let textContainer = NSTextContainer(size: .zero)
        
        textStorage.addLayoutManager(layoutManager)
        layoutManager.addTextContainer(textContainer)
        
        let textView = NSTextView(frame: .zero, textContainer: textContainer)
        textView.isEditable = false
        textView.isSelectable = true
        
        // 核心对象绑定到协调器,避免重建丢失状态
        context.coordinator.textStorage = textStorage
        context.coordinator.layoutManager = layoutManager
        context.coordinator.textView = textView
        
        return textView
    }

    func updateNSView(_ nsView: NSTextView, context: Context) {
        // 仅在文本实际变化时更新,避免无效操作干扰TextKit状态
        if context.coordinator.textStorage?.string != text {
            context.coordinator.textStorage?.mutate { storage in
                storage.replaceCharacters(in: NSRange(location: 0, length: storage.length), with: text)
            }
        }
        // 触发高亮更新逻辑
        context.coordinator.updateHighlight(for: highlightString)
    }

    class Coordinator: NSObject {
        var parent: TextKit2TextView
        var textStorage: NSTextStorage?
        var layoutManager: NSTextLayoutManager?
        var textView: NSTextView?

        init(_ parent: TextKit2TextView) {
            self.parent = parent
        }

        func updateHighlight(for targetString: String) {
            guard let layoutManager = layoutManager, let textStorage = textStorage else { return }
            
            // 先清除之前的高亮状态
            layoutManager.removeRenderingAttributes(forAllOccurrencesOf: NSAttributedString.Key.backgroundColor)
            
            guard !targetString.isEmpty else {
                // 清空后精准无效化对应范围的渲染属性
                let fullRange = NSRange(location: 0, length: textStorage.length)
                layoutManager.invalidateLayout(for: fullRange, type: .renderingAttributes)
                textView?.setNeedsDisplay(textView?.bounds ?? .zero)
                return
            }
            
            // 查找所有匹配的文本范围
            let fullRange = NSRange(location: 0, length: textStorage.length)
            var ranges: [NSRange] = []
            var searchRange = fullRange
            
            while let range = textStorage.string.range(of: targetString, options: [], range: searchRange) {
                let foundRange = NSRange(range, in: textStorage.string)
                ranges.append(foundRange)
                // 更新搜索范围,避免重复匹配同一内容
                searchRange = NSRange(location: foundRange.location + foundRange.length, length: fullRange.length - (foundRange.location + foundRange.length))
                if searchRange.location >= fullRange.length { break }
            }
            
            // 用批量更新包裹所有属性操作,避免TextKit异步渲染冲突
            layoutManager.performBatchUpdates {
                for range in ranges {
                    guard let textRange = NSTextRange(range, in: textStorage) else { continue }
                    layoutManager.addRenderingAttributes(
                        [.backgroundColor: NSColor.yellow.withAlphaComponent(0.3)],
                        for: textRange
                    )
                }
            } completionHandler: { [weak self] _ in
                // 强制触发视图重绘,解决SwiftUI与AppKit重绘时机不匹配的问题
                self?.textView?.setNeedsDisplay(self?.textView?.bounds ?? .zero)
            }
        }
    }
}

2. 用精准的无效化方式,别瞎全局invalidate

我之前图省事直接调用layoutManager.invalidateLayout(),结果要么没反应要么整个布局乱套。后来才知道要精准指定无效化的范围和类型:

  • 更新渲染属性时,用layoutManager.invalidateLayout(for: textRange, type: .renderingAttributes),只无效化需要更新的范围的渲染属性,效率更高也更可靠。
  • 所有属性修改操作尽量用performBatchUpdates包裹,TextKit 2会把这些操作合并成一个批次处理,避免异步渲染的冲突。

3. 适配SwiftUI和AppKit的生命周期差异

SwiftUI的updateNSView会频繁调用,所以一定要在里面做判断(比如文本没变化就不重新设置text storage),避免无效操作干扰TextKit的状态。另外,有时候设置完渲染属性后,AppKit的视图可能没及时重绘,所以在performBatchUpdates的完成回调里手动调用textView?.setNeedsDisplay(),强制触发重绘。

4. 所有TextKit操作必须在主线程执行

TextKit 2的所有操作(包括设置渲染属性、无效化布局)都必须在主线程执行!如果你的高亮字符串是从异步任务来的,一定要用DispatchQueue.main.async包裹更新逻辑,不然会出现各种奇怪的不生效情况。

额外小提示

  • 如果你需要支持动态修改高亮颜色或者其他属性,也可以把颜色做成SwiftUI的Binding,在updateHighlight里一起更新。
  • 测试的时候可以加个print,看看updateHighlight是不是每次高亮字符串变化都被调用了,排查是触发逻辑问题还是TextKit本身的问题。

我当时就是靠这些调整,把之前时灵时不灵的高亮改成每次都能即时更新的,你可以照着试试,应该能解决你的问题!

火山引擎 最新活动