如何提升NSTextStorage的addAttribute方法执行性能?
针对你处理超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

