超谈 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 的
UITextView
和NSTextView
,测试其性能表现
各方案的实现和性能对比
以下将对三种方案进行性能对比:
- 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 衍生应用可能都有此问题),那么欢迎此文章能帮到你。
Subscribe to JuniperPhoton's Blog
Get the latest posts delivered right to your inbox