Pro to SwiftUI: Text performance issue and workaround
Preface
This article is part of the "Pro to SwiftUI" series, mainly sharing some insights, experiences, and pitfalls recorded after developing a few apps using SwiftUI. I hope it can help you.
This article will introduce the performance issues of the Text SwiftUI View in certain scenarios and the workaround to solve them.
Text performance issues
To display a non-editable text in SwiftUI, you can directly use the Text view provided by SwiftUI itself. The features supported by Text are already sufficient: support for setting font and line spacing, multilingual support, AttributeString, etc. The functionality is already very rich and suitable for most situations.
The underlying implementation of Text
on SwiftUI does not rely on platform-specific TextView: it does not use UITextView
on iOS and NSTextView
on macOS. Currently, it is speculated that CoreGraphics + CoreText is used for direct text rendering, making it a native cross-platform view implementation for Apple platforms. Because of this, unknown performance issues may occur in the scenarios mentioned below, while NSTextView
and UITextView
, which have been iterated for so many years, do not have this problem in this scenario.
And this scenario is exactly the popular ChatGPT typewriter (Stream mode) text update, with specific conditions and manifestations:
Display a complete text that exceeds 100 lines in a streaming manner
When a certain number of lines (estimated 50+) have already been displayed, attempting UI operations (scrolling ScrollView, clicking buttons, etc.) will result in lag. By checking with Xcode, high CPU and main thread usage can be observed.
As more lines are displayed, the lag becomes more pronounced.
Regarding Stream mode, let me give you a brief introduction: After using Stream mode, you will parse the response of the sent request in the form of a stream. Compared to waiting for the request to complete and parsing the response as a whole text, using the stream mode, you will continuously receive "small results", each small result will have one more character or word compared to the previous result (whether the quantity is strictly 1 is not considered here). Displaying these results in chronological order will create a "typewriter" effect. I have encapsulated a Swift implementation library called PhotonOpenAIKit for my AI translation software, Photon AI Translator. It supports Stream mode and works with Swift's AsyncStream to help you quickly achieve a similar typewriter effect.
To solve this problem, I started from the following aspects:
Trying to implement a Text View using CoreGraphics + CoreText for drawing, and testing its performance.
Wrapping
UITextView
andNSTextView
from UIKit and AppKit, and testing their performance.
Comparison of implementation
The following will compare the performance of three solutions:
SwiftUI native
Text
rendering
Bridging UIKit and AppKit
UI/NSTextView
rendering
Implementing text rendering on your own
The time span of measurement ranges from never started to displaying a complete text of over 100 lines.
SwiftUI native Text rendering
SwiftUI native Text drawing code is very simple:
The measurement data for CPU and Memory is as follows:
You can see that the peak CPU usage is as high as 49%. From actual usage, when the CPU usage is high, there will be stuttering and dropped frames during scrolling and button clicks.
In terms of memory, it is relatively normal.
If you use Instrument's Animation Hitch, this hitch is caused by Expensive Commit.
By further investigation, it can be seen that during the lag, CA Commit takes an average of more than 200ms per time. By further examining with TimeProfiler, the main time-consuming parts are called within SwiftUI internal methods, where the specific implementation cannot be seen.
Bridging UITextView/NSTextView from UIKit/AppKit
Since we know that the implementation of SwiftUI's Text
is not UITextView
or NSTextView
, we can also try using the corresponding platform's TextView directly to see how it works.
On iOS, you need to implement UIViewRepresentable
to represent a UITextView that can be used in SwiftUI; likewise, on macOS, it is NSViewRepresentable
.
The complete code is available on GitHub, and the following is an explanation of some of the code.
The core code is as follows:
If you don't make any Style changes, you will find that the text displayed by NSTextView, such as color, background, letter spacing, and size, is different from SwiftUI Text. So some adjustments need to be made:
Set
NSTextView
's drawsBackground to remove the default background
Set
NSTextView
's font. If you useNSFont.systemFontSize
, it will have the same effect as Font.Body in SwiftUI
Set
NSTextView
's isEditable to prevent user editing
Of course, in my scenario, the text is scrollable, so I put it in an
NSScrollView
. Note that you need to setNSTextView
's autoresizingMask to make it adapt to the size ofNSScrollView
On the iOS side, ScrollableTextViewCompat
is similar, but many Style APIs are different from NSTextView
. Please refer to the source code in the link above for details.
Here, I used the
#if canImport(AppKit)
method to differentiate the implementation for different platforms because tvOS, like iOS, also uses UIKit's UITextView. Therefore, checking if a framework can be imported is more practical.
The measurement data for CPU and Memory is as follows:
You can see that the CPU usage is no more than 24%, and the memory performance is also normal. In actual experience, there is basically no problem of dropped frames or lag.
Implement text drawing by ourselves
So far, we have been using high-level APIs for text rendering and display. With a little interest, we also tried using CoreGraphics + Core Text to draw text onto a CGImage for display.
Here is the core code, and you can find the complete code here.
But please note: this CustomTextView is an experimental product, many properties such as font size are hard-coded and not suitable for production environment use. Here only demonstrates how to use SwiftUI + Core Graphics + Core Text to draw text.
Essentially, the following things have been done:
The core is to draw the content of the String onto a CGImage.
The height of the CGImage is dynamically calculated and changes with the Text. In my scenario, the width is fixed, so we are not considering width changes for now.
The core drawing is still achieved through Core Text's
CTFrameDraw
(after all, we really don't have the necessity and ability to challenge the limits and draw pixel by pixel, right?)
The data for CPU and Memory is as follows:
The CPU usage is average among the three solutions, and there is no noticeable lag in actual usage.
However, the memory usage is relatively abnormal. It mainly increases with the increase of text, as the size of CGImage required also increases. As mentioned earlier, this solution is purely experimental, and there is definitely room for memory optimization here, but we won't delve into it further.
Conclusion
Finally, in the production environment, I adopted the second solution: bridging UITextView
/NSTextView
of UIKit and AppKit. This is the most suitable solution for my scenario. But please note:
I only use this solution when displaying a large amount of text.
For other places where short texts are displayed, I still use SwiftUI's Text.
SwiftUI's Text is still the recommended solution for most people and most scenarios (assuming a SwiftUI development environment).
If you encounter similar performance issues with Text in similar scenarios (I believe OpenAI derivative applications developed using SwiftUI may have this issue), I hope this article can help you.