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

如何提升NSTextStorage的addAttribute方法执行性能?

优化NSTextStorage大文本单词标记性能的方案

针对你处理超200页书籍长文本时,批量标记单词速度极慢、CPU占用过高的问题,核心瓶颈在于频繁的属性修改触发了LayoutManager多次全量布局计算——每次调用addAttributes都会让NSTextStorage发送编辑通知,进而驱动LayoutManager重新计算所有文本容器的排版,200+页的场景下这个成本会被放大数倍。下面是几个实用的优化方案:

1. 合并属性操作,减少编辑周期

当前循环中逐个调用addAttributes会触发多次beginEditing/endEditing,每次都会引发布局更新。我们可以先收集所有需要标记的范围,合并重叠/相邻的范围后,一次性完成属性添加:

func selectTappableWordsFromList(_ list: [PositionWithWord]) {
    guard let textStorage = self.defaultTextStorage else { return }
    
    textStorage.beginEditing()
    defer { textStorage.endEditing() } // 确保编辑一定会结束
    
    // 转换并排序所有待标记范围,同时过滤无效范围
    let rawRanges = list.compactMap { item in
        guard let position = item.position else { return nil }
        let range = NSMakeRange(position.start, position.length)
        return (range.location + range.length <= textStorage.length) ? range : nil
    }.sorted(by: { $0.location < $1.location })
    
    // 合并重叠或相邻的范围,减少属性操作次数
    var mergedRanges: [NSRange] = []
    for range in rawRanges {
        if let lastRange = mergedRanges.last, 
           NSLocationInRange(range.location, lastRange) || 
           range.location == lastRange.location + lastRange.length {
            // 合并两个范围
            mergedRanges[mergedRanges.count - 1] = NSUnionRange(lastRange, range)
        } else {
            mergedRanges.append(range)
        }
    }
    
    // 批量添加属性
    mergedRanges.forEach { range in
        guard !self.shouldInterruptCurrentProcesses else {
            self.shouldInterruptCurrentProcesses = false
            return
        }
        textStorage.addAttributes(defaultAttributes, range: range)
    }
}

这样整个标记流程只触发一次编辑周期,LayoutManager只会执行一次布局计算,性能提升非常明显。

2. 暂时禁用LayoutManager的实时布局

在批量修改属性前,我们可以让LayoutManager暂停布局更新,等所有属性修改完成后再统一触发布局,避免中间的无效计算:

func selectTappableWordsFromList(_ list: [PositionWithWord]) {
    guard let textStorage = self.defaultTextStorage, let layoutManager = self.layoutManager else { return }
    
    // 暂停布局更新,避免中间触发排版
    layoutManager.allowsNonContiguousLayout = false
    layoutManager.invalidateLayout()
    
    textStorage.beginEditing()
    defer {
        textStorage.endEditing()
        // 恢复布局并强制更新全量文本
        layoutManager.allowsNonContiguousLayout = true
        layoutManager.invalidateLayout(for: textStorage, range: NSRange(location: 0, length: textStorage.length))
    }
    
    // 复用范围转换与合并逻辑
    let rawRanges = list.compactMap { item in
        guard let position = item.position else { return nil }
        let range = NSMakeRange(position.start, position.length)
        return (range.location + range.length <= textStorage.length) ? range : nil
    }.sorted(by: { $0.location < $1.location })
    
    rawRanges.forEach { range in
        guard !self.shouldInterruptCurrentProcesses else {
            self.shouldInterruptCurrentProcesses = false
            return
        }
        textStorage.addAttributes(defaultAttributes, range: range)
    }
}

这个方案进一步切断了属性修改和布局计算的关联,把布局开销集中到最后一步。

3. 异步预处理+分批更新(针对超大标记量)

如果需要标记的单词数量极多,可以把范围的收集、合并放在后台线程完成,然后分批在主线程更新属性,避免主线程长时间阻塞导致界面卡顿:

func selectTappableWordsFromList(_ list: [PositionWithWord]) {
    guard let textStorage = self.defaultTextStorage else { return }
    
    // 后台线程处理范围合并,不阻塞主线程
    DispatchQueue.global(qos: .userInitiated).async {
        let rawRanges = list.compactMap { item in
            guard let position = item.position else { return nil }
            let range = NSMakeRange(position.start, position.length)
            return (range.location + range.length <= textStorage.length) ? range : nil
        }.sorted(by: { $0.location < $1.location })
        
        // 合并范围逻辑
        var mergedRanges: [NSRange] = []
        for range in rawRanges {
            if let lastRange = mergedRanges.last, 
               NSLocationInRange(range.location, lastRange) || 
               range.location == lastRange.location + lastRange.length {
                mergedRanges[mergedRanges.count - 1] = NSUnionRange(lastRange, range)
            } else {
                mergedRanges.append(range)
            }
        }
        
        // 分批在主线程更新,每批处理50个范围(可根据实际调整)
        let batchSize = 50
        for batchStart in stride(from: 0, to: mergedRanges.count, by: batchSize) {
            let batchEnd = min(batchStart + batchSize, mergedRanges.count)
            let batch = Array(mergedRanges[batchStart..<batchEnd])
            
            DispatchQueue.main.async {
                guard !self.shouldInterruptCurrentProcesses else {
                    self.shouldInterruptCurrentProcesses = false
                    return
                }
                
                textStorage.beginEditing()
                batch.forEach { range in
                    textStorage.addAttributes(self.defaultAttributes, range: range)
                }
                textStorage.endEditing()
            }
        }
    }
}

⚠️ 注意:NSTextStorage的所有修改操作必须在主线程执行,所以只能把预处理逻辑放在后台,实际属性添加还是要回到主线程。

额外小优化

  • 提前过滤无效的PositionWithWord对象,比如position为nil或者范围越界的,避免无效操作;
  • 将defaultAttributes定义为静态常量,避免每次创建新的字典;

这些优化方案的核心都是减少NSTextStorage的编辑次数和LayoutManager的布局触发频率——对于200+页的长文本来说,每一次布局计算的成本都很高,集中处理能大幅降低CPU占用,提升标记速度。

内容的提问来源于stack exchange,提问作者Vlad Pulichev

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.05.28 07:31:16