超谈 SwiftUI:Text 性能问题和 Workaround

前言

此文为「超谈 SwiftUI」系列的文章,主要输出一些使用 SwiftUI 开发几个 apps 后的一些心得、经验和踩坑记录。希望能帮助到你。

本文将介绍 Text 这个 SwiftUI View 在某些场景下的性能问题以及解决的 Workaround。

Text 性能问题

在 SwiftUI 里要显示一段不可编辑的文本,你可以直接使用 SwiftUI 本身的 Text View。Text 本身支持设置的特性已经足够了:支持设置字和行间距、多语言、AttributeString 等,功能已经非常丰富,在大多数情况下都适合使用。

正如在「剖析 App 结构和页面导航」提到的,SwiftUI 上的 Text 底层并不是依赖各平台的 TextView 实现:在 iOS 上不是使用 UITextView,在 macOS 上不是使用 NSTextView。目前猜测是使用 CoreGraphics + CoreText 来直接进行文字绘制,完全是一个原生的跨 Apple 平台的 View 实现——正因为如此,在以下提到的场景下会出现未知的性能问题,而相比之下,迭代了这么多年的 NSTextView 和 UITextView 则在此场景下则没有发现这个问题。

而这个场景,正是现在很流行的 ChatGPT 打字机(Stream mode)的文字更新,具体条件和表现为:

  • 以流式显示一段完整情况下超过 100 行的文本
  • 在已经显示一定行数(预计 50+)的情况下,尝试去做 UI 操作(滚动 ScrollView,点击按钮等)都能发现有卡顿,通过 Xcode 查看,CPU 和主线程占用都比较高
  • 随着显示的行数越多,卡顿越明显
关于 Stream mode,以下做一下简单介绍:在使用 Stream mode 后,你将会用流的形式来解析发送请求的响应 Response。相比于等请求完成拿到 Response 后一次解析为一整段文本,使用流的形式你将不断得到一个个「小结果」,每个小结果本身相比前一个结果会多一个字和单词(数量是否严格为 1,这里不考究了),把这些结果按照时间顺序显示,就会是一个「打字机」的效果。我封装了一个 Swift 实现的 PhotonOpenAIKit 库用于我的 AI 翻译软件 Photon AI Translator 使用。其支持 Stream mode,配合 Swift 的 AsyncStream 以帮助你快速实现类似的打字机效果。

为了解决这个问题,我从以下方面着手:

  • 尝试自行实现一个使用 CoreGraphics + CoreText 绘制的 Text View,测试其性能表现
  • 包装 UIKit 和 AppKit 的 UITextViewNSTextView,测试其性能表现

各方案的实现和性能对比

以下将对三种方案进行性能对比:

  • SwiftUI 原生 Text 绘制
  • 桥接 UIKit 和 AppKit 的 UI/NSTextView 绘制
  • 自行实现文字绘制

测量的时间跨度为从未开始到完整显示一段超过 100 行的文本。

SwiftUI 原生 Text 绘制

SwiftUI 原生 Text 绘制的代码很简单:

Text(text)

CPU 和 Memory 的数据如下:

可以看到 CPU 方面峰值占用高达 49%,从实际使用看,在 CPU 占用很高的时候,滚动和点击按钮等操作也会出现卡顿丢帧的情况。

内存方面则比较正常。

如果你用 Instrument 的 Animation Hitch 来看,则这个 Hitch 是由 Expensive Commit 导致的:

通过进一步看,可以看到在卡顿的时候 CA Commit 平均一次要 200ms 多,而通过 TimeProfiler 进一步看,主要耗时部分都调用到了 SwiftUI 内部的方法,这里就看不到具体的实现了。

桥接 UIKit 和 AppKit 的 UITextView/NSTextView

既然我们知道 SwiftUI 的 Text 的实现并不是 UITextView 或者 NSTextView,那么我们也可以试试直接使用对应平台的 TextView,来看看效果如何。

在 iOS 上,你需要实现 UIViewRepresentable 来表示一个能在 SwiftUI 上用的 UITextView;在 macOS 上则是 NSViewRepresentable

完整代码在 GitHub 里,以下就部分代码来进行解释。

核心代码如下:

public struct ScrollableTextViewCompat: NSViewRepresentable {
    public let text: String
    
    public init(text: String) {
        self.text = text
    }
    
    public func makeNSView(context: Context) -> NSScrollView {
        let textView = NSTextView()
        textView.string = self.text
        textView.drawsBackground = false
        textView.font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
        textView.isEditable = false
        
        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        scrollView.documentView = textView
        scrollView.drawsBackground = false
        
        return scrollView
    }
    
    public func updateNSView(_ nsView: NSScrollView, context: Context) {
        let scrollView = nsView
        
        guard let textView = scrollView.documentView as? NSTextView else {
            return
        }
        textView.string = self.text
        setLineSpacing(for: textView)
        textView.autoresizingMask = [.width, .height]
    }
}

如果你不做任何 Style 上的改变,那么会发现 NSTextView 显示出来的文字,颜色、背景、字距和大小等都跟 SwiftUI Text 不一样,所以需要做一下调整:

  • 设置 NSTextView 的 drawsBackground,去除默认背景
  • 设置 NSTextView 的 font,使用 NSFont.systemFontSize 的话跟 SwiftUI 上的 Font.Body 效果一致
  • 设置 NSTextView isEditable,避免用户能编辑
  • 当然,在我的场景下,文本是能滚动的,所以把它放到了一个 NSScrollView 里,注意需要设置 NSTextView 的 autoresizingMask,让其自适应 NSScrollView 的大小

iOS 侧的 ScrollableTextViewCompat 则类似,但很多 Style 的 API 都跟 NSTextView 不一样,具体请看上述链接的源码。

💡在这里,我用了 #if canImport(AppKit) 的方式来区分不同平台的实现,因为 tvOS 跟 iOS 一样,都是用的 UIKit 的 UITextView,所以判断能否导入某个框架更为实际。

CPU 和 Memory 的数据如下:

可以看到 CPU 占用最高不过 24%,内存则也是正常表现。实际的体验,也基本不存在丢帧卡顿的问题。

自行实现文字绘制

目前为止我们都是在使用高层级的 API 来进行文字的绘制显示,借着一点兴趣,也尝试了下使用 CoreGraphics + Core Text 来把文字绘制到 CGImage 来显示。

以下为核心代码,可以在这里找到完整代码

但注意:此 CustomTextView 是实验性质的产物,字体大小等很多属性都是写死的,不适合生产环境使用。这里仅展示如何使用 SwiftUI + Core Graphics + Core Text 来实现绘制文本。
struct CustomTextView: View {
    let text: String
    
    @State private var resultImage: CGImage? = nil
    @State private var parentWidth: CGFloat = 0.0
    
    var body: some View {
        Group {
            if let image = resultImage {
                Image(image, scale: 1.0, label: Text(""))
                    .resizable()
                    .scaledToFit()
            }
            
            Text(text)
        }.matchParent().listenWidthChanged(onWidthChanged: { width in
            self.parentWidth = width
            self.drawCGImage()
        }).onChange(of: text) { newValue in
            self.drawCGImage()
        }
    }
    
    private func drawCGImage() {
        if parentWidth == 0.0 || text.isEmpty {
            resultImage = nil
            return
        }
        
        let font = CTFontCreateWithName("Helvetica" as CFString, 25, nil)
        let factor = NSScreen.main?.backingScaleFactor ?? 1.0
        resultImage = createImageWithText(text: text, font: font,
                                          textColor: .black, imageSize: .init(width: parentWidth * factor, height: .infinity))
    }
    
    private func createImageWithText(text: String, font: CTFont, textColor: CGColor, imageSize: CGSize) -> CGImage? {
        let string = CFAttributedStringCreateMutable(kCFAllocatorDefault, 0)!
        CFAttributedStringReplaceString(string, CFRangeMake(0, 0), text as CFString)
        CFAttributedStringSetAttribute(string, CFRangeMake(0, text.count), kCTFontAttributeName, font)
        CFAttributedStringSetAttribute(string, CFRangeMake(0, text.count), kCTForegroundColorAttributeName, textColor)
        
        let framesetter = CTFramesetterCreateWithAttributedString(string)
        
        let widthConstraint = CGSize(width: imageSize.width, height: CGFloat.greatestFiniteMagnitude)
        let suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter,
                                                                         CFRange(location: 0,
                                                                                 length: text.count),
                                                                         nil, widthConstraint, nil)
        
        let size = CGSize(width: imageSize.width, height: suggestedSize.height)
        
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
        guard let context = CGContext(data: nil, width: Int(size.width),
                                      height: Int(size.height),
                                      bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) else {
            return nil
        }
        
        let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil)
        let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, text.count), path, nil)
        
        context.clear(CGRect(origin: .zero, size: size))
        context.textMatrix = CGAffineTransform.identity
        
        CTFrameDraw(frame, context)
        
        return context.makeImage()
    }
}

本质上做了以下事情:

  • 核心为把 String 的内容绘制到 CGImage 上。
  • CGImage 的高度动态计算,随着 Text 的改变而改变。在我的场景下,Width 是固定的,这里暂不考虑宽度变化。
  • 核心绘制还是靠 Core Text 的 CTFrameDraw 来实现(毕竟我们真的没有必要和能力去挑战极限,去逐个像素绘制,是吧)

CPU 和 Memory 的数据如下:

CPU 占用比在三个方案里居中,实际使用上没有感到存在卡顿问题。

内存占用则比较异常,主要是随着文字的增加,所需要的 CGImage 的尺寸也跟着增长。正如前面提到的,此方案纯为试验性质的方案,这里肯定也有内存上的优化空间,但这里不深入探究了。

结论

最后,在生产环境,我采取了第二个方案:桥接 UIKit 和 AppKit 的 UITextView/NSTextView ,此为我这个场景下最为适合的方案。但请注意:

  • 只有在显示大量文本的情况下,我才使用此方案
  • 对于其他显示简短文本的地方,我依然使用 SwiftUI 的 Text
  • SwiftUI 的 Text 依然是大多数人大多数场景下适合使用的方案(当然这里默认是 SwiftUI 开发环境)

如果你在相似的场景遇到相似的 Text 性能问题(相信使用 SwiftUI 开发的 OpenAI 衍生应用可能都有此问题),那么欢迎此文章能帮到你。