Skip to content

Commit 1f49530

Browse files
committed
[perf] add text sizing cache
1 parent db52d4c commit 1f49530

3 files changed

Lines changed: 213 additions & 1 deletion

File tree

ComposeUI/Sources/ComposeUI/Extensions/NSAttributedString+Sizing.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ extension NSAttributedString {
4343

4444
/// Calculate the bounding size of the attributed string.
4545
///
46+
/// The text size is cached based on the attributed string and the layout parameters. Use `computeBoundingRectSize` to bypass the cache.
47+
///
4648
/// Related: https://github.com/honghaoz/ChouTiUI/blob/c2cc7b8452d269d6ee55993a977ed4b5fabf15d4/ChouTiUI/Sources/ChouTiUI/Universal/Text/TextSizeProvider.swift#L258
4749
///
4850
/// - Parameters:
@@ -55,6 +57,35 @@ extension NSAttributedString {
5557
return .zero
5658
}
5759

60+
// single-line sizing measures the natural, unwrapped line, so its size is independent of `layoutWidth` and `lineBreakMode`
61+
// see `computeBoundingRectSize` for more details.
62+
//
63+
// normalize `layoutWidth` and `lineBreakMode` so the same single-line text resolves to one cache entry regardless of width / mode.
64+
let keyLayoutWidth = numberOfLines == 1 ? 0 : layoutWidth
65+
let keyLineBreakMode: NSLineBreakMode = numberOfLines == 1 ? .byWordWrapping : lineBreakMode
66+
67+
let key = TextSizeCache.Key(attributedString: self, numberOfLines: numberOfLines, layoutWidth: keyLayoutWidth, lineBreakMode: keyLineBreakMode)
68+
if let cached = TextSizeCache.shared.object(forKey: key) {
69+
return cached.size
70+
}
71+
72+
let size = computeBoundingRectSize(numberOfLines: numberOfLines, layoutWidth: layoutWidth, lineBreakMode: lineBreakMode)
73+
TextSizeCache.shared.setObject(TextSizeCache.Value(size), forKey: key)
74+
return size
75+
}
76+
77+
/// Calculate the bounding size of the attributed string. No cache is used.
78+
///
79+
/// - Parameters:
80+
/// - numberOfLines: The number of lines to calculate the bounding size for. Use 0 for unlimited lines.
81+
/// - layoutWidth: The width of the layout.
82+
/// - lineBreakMode: The line break mode to use for the layout. Default is `byWordWrapping`.
83+
/// - Returns: The bounding size of the attributed string.
84+
func computeBoundingRectSize(numberOfLines: Int, layoutWidth: CGFloat, lineBreakMode: NSLineBreakMode = .byWordWrapping) -> CGSize {
85+
guard self.length > 0 else {
86+
return .zero
87+
}
88+
5889
if numberOfLines == 1 {
5990
return singleLineTextBoundingRectSize()
6091
}
@@ -263,6 +294,87 @@ extension NSAttributedString {
263294
// }
264295
}
265296

297+
// MARK: - Text Size Cache
298+
299+
extension NSAttributedString {
300+
301+
/// Removes all cached text sizes.
302+
static func clearTextSizeCache() {
303+
TextSizeCache.shared.removeAllObjects()
304+
}
305+
}
306+
307+
/// A process-wide cache for `NSAttributedString.boundingRectSize`.
308+
private enum TextSizeCache {
309+
310+
/// The shared cache. `NSCache` is thread-safe and evicts entries under memory pressure.
311+
static let shared: NSCache<Key, Value> = {
312+
let cache = NSCache<Key, Value>()
313+
cache.countLimit = Constants.countLimit
314+
return cache
315+
}()
316+
317+
/// A cache key capturing every input that affects the text size.
318+
final class Key: NSObject {
319+
320+
let attributedString: NSAttributedString
321+
let numberOfLines: Int
322+
let layoutWidth: CGFloat
323+
let lineBreakMode: NSLineBreakMode
324+
325+
private let hashCode: Int
326+
327+
init(attributedString: NSAttributedString, numberOfLines: Int, layoutWidth: CGFloat, lineBreakMode: NSLineBreakMode) {
328+
// callers may pass an `NSMutableAttributedString`, and a cache key's hash and equality must stay stable while it
329+
// lives in the cache, otherwise a later mutation of the same instance would leave the key in the wrong hash bucket
330+
// (a stale/leaked entry, and a wrong hit under a hash collision).
331+
let snapshot = (attributedString.copy() as? NSAttributedString) ?? attributedString
332+
self.attributedString = snapshot
333+
self.numberOfLines = numberOfLines
334+
self.layoutWidth = layoutWidth
335+
self.lineBreakMode = lineBreakMode
336+
337+
var hasher = Hasher()
338+
hasher.combine(snapshot)
339+
hasher.combine(numberOfLines)
340+
hasher.combine(layoutWidth)
341+
hasher.combine(lineBreakMode.rawValue)
342+
hashCode = hasher.finalize()
343+
}
344+
345+
override var hash: Int {
346+
hashCode
347+
}
348+
349+
override func isEqual(_ object: Any?) -> Bool {
350+
guard let other = object as? Key else {
351+
return false
352+
}
353+
// compare the cheap scalars before the (more expensive) attributed string contents.
354+
return numberOfLines == other.numberOfLines
355+
&& layoutWidth == other.layoutWidth
356+
&& lineBreakMode == other.lineBreakMode
357+
&& attributedString.isEqual(other.attributedString)
358+
}
359+
}
360+
361+
/// A boxed `CGSize` so it can be stored in `NSCache` (which requires class values).
362+
final class Value {
363+
364+
let size: CGSize
365+
366+
init(_ size: CGSize) {
367+
self.size = size
368+
}
369+
}
370+
371+
private enum Constants {
372+
373+
/// Upper bound on cached entries; `NSCache` evicts beyond this (and under memory pressure).
374+
static let countLimit = 4096
375+
}
376+
}
377+
266378
// Notes on CoreText API:
267379
//
268380
// 1. CTFramesetterSuggestFrameSizeWithConstraints:

ComposeUI/Tests/ComposeUITests/CrossPlatform/BaseTextViewTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class BaseTextViewTests: XCTestCase {
122122
expect(window.firstResponder) !== textView
123123
#endif
124124

125-
#if canImport(UIKit)
125+
#if canImport(UIKit) && !os(tvOS)
126126
let textView = BaseTextView(frame: CGRect(x: 0, y: 0, width: 200, height: 50))
127127
let window = TestWindow()
128128
window.rootViewController = UIViewController()

ComposeUI/Tests/ComposeUITests/Extensions/NSAttributedString+SizingTests.swift

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,106 @@ class NSAttributedString_SizingTests: XCTestCase {
866866
#endif
867867
}
868868

869+
// MARK: - Cache
870+
871+
func test_cache_cachedMatchesUncached_singleLine() throws {
872+
NSAttributedString.clearTextSizeCache()
873+
874+
let attributedString = try makeAttributedString(font: Font.systemFont(ofSize: 16), lineBreakMode: .byWordWrapping)
875+
876+
let uncached = attributedString.computeBoundingRectSize(numberOfLines: 1, layoutWidth: 100)
877+
let cachedMiss = attributedString.boundingRectSize(numberOfLines: 1, layoutWidth: 100) // computes + caches
878+
let cachedHit = attributedString.boundingRectSize(numberOfLines: 1, layoutWidth: 100) // served from cache
879+
880+
expect(cachedMiss) == uncached
881+
expect(cachedHit) == uncached
882+
}
883+
884+
func test_cache_cachedMatchesUncached_multiLine() throws {
885+
NSAttributedString.clearTextSizeCache()
886+
887+
let attributedString = try makeAttributedString(font: Font.systemFont(ofSize: 16), lineBreakMode: .byWordWrapping)
888+
889+
let uncached = attributedString.computeBoundingRectSize(numberOfLines: 0, layoutWidth: 100)
890+
let cachedMiss = attributedString.boundingRectSize(numberOfLines: 0, layoutWidth: 100)
891+
let cachedHit = attributedString.boundingRectSize(numberOfLines: 0, layoutWidth: 100)
892+
893+
expect(cachedMiss) == uncached
894+
expect(cachedHit) == uncached
895+
}
896+
897+
func test_cache_distinctFonts_notConfused() throws {
898+
NSAttributedString.clearTextSizeCache()
899+
900+
// same text, different font: the key includes the attributed string (which carries the font), so the two sizes
901+
// must not collapse to one cached entry.
902+
let small = try makeAttributedString(font: Font.systemFont(ofSize: 16), lineBreakMode: .byWordWrapping)
903+
let large = try makeAttributedString(font: Font.systemFont(ofSize: 32), lineBreakMode: .byWordWrapping)
904+
905+
let smallSize = small.boundingRectSize(numberOfLines: 1, layoutWidth: 100)
906+
let largeSize = large.boundingRectSize(numberOfLines: 1, layoutWidth: 100)
907+
908+
expect(smallSize) != largeSize
909+
expect(largeSize.height) > smallSize.height
910+
}
911+
912+
func test_cache_distinctWidths_notConfused() throws {
913+
NSAttributedString.clearTextSizeCache()
914+
915+
// same multi-line text at different widths wraps differently, so the cache (keyed by width) must keep them distinct.
916+
let attributedString = try makeAttributedString(font: Font.systemFont(ofSize: 16), lineBreakMode: .byWordWrapping)
917+
918+
let narrow = attributedString.boundingRectSize(numberOfLines: 0, layoutWidth: 80)
919+
let wide = attributedString.boundingRectSize(numberOfLines: 0, layoutWidth: 300)
920+
921+
expect(narrow) != wide
922+
expect(narrow.height) > wide.height // narrower wraps to more lines -> taller
923+
}
924+
925+
func test_cache_clear_recomputesSameValue() throws {
926+
let attributedString = try makeAttributedString(font: Font.systemFont(ofSize: 16), lineBreakMode: .byWordWrapping)
927+
928+
let before = attributedString.boundingRectSize(numberOfLines: 2, layoutWidth: 120)
929+
NSAttributedString.clearTextSizeCache()
930+
let after = attributedString.boundingRectSize(numberOfLines: 2, layoutWidth: 120)
931+
932+
expect(after) == before
933+
}
934+
935+
func test_cache_mutableAttributedString_afterMutation_returnsNewSize() throws {
936+
NSAttributedString.clearTextSizeCache()
937+
938+
let attributes: [NSAttributedString.Key: Any] = [.font: Font.systemFont(ofSize: 16)]
939+
let mutableString = NSMutableAttributedString(string: "Hi", attributes: attributes)
940+
941+
let sizeBeforeMutation = mutableString.boundingRectSize(numberOfLines: 1, layoutWidth: 1000)
942+
943+
// mutate the same instance that was just used as a cache key, then ask again with that same instance.
944+
mutableString.append(NSAttributedString(string: " there, this is a much longer piece of text", attributes: attributes))
945+
let sizeAfterMutation = mutableString.boundingRectSize(numberOfLines: 1, layoutWidth: 1000)
946+
947+
// the size must reflect the mutated (longer) content, not a stale value keyed by the pre-mutation string.
948+
expect(sizeAfterMutation) != sizeBeforeMutation
949+
expect(sizeAfterMutation.width) > sizeBeforeMutation.width
950+
expect(sizeAfterMutation) == mutableString.computeBoundingRectSize(numberOfLines: 1, layoutWidth: 1000)
951+
}
952+
953+
func test_cache_singleLine_sizeIndependentOfWidthAndLineBreakMode() throws {
954+
NSAttributedString.clearTextSizeCache()
955+
956+
// single-line sizing ignores layoutWidth and lineBreakMode (it measures the natural, unwrapped line)
957+
let attributedString = try makeAttributedString(font: Font.systemFont(ofSize: 16), lineBreakMode: .byWordWrapping)
958+
959+
let narrow = attributedString.boundingRectSize(numberOfLines: 1, layoutWidth: 50)
960+
let wide = attributedString.boundingRectSize(numberOfLines: 1, layoutWidth: 5000)
961+
let charWrap = attributedString.boundingRectSize(numberOfLines: 1, layoutWidth: 123, lineBreakMode: .byCharWrapping)
962+
963+
expect(narrow) == wide
964+
expect(narrow) == charWrap
965+
}
966+
967+
// MARK: - Helpers
968+
869969
private func makeAttributedString(font: Font, lineBreakMode: NSLineBreakMode) throws -> NSAttributedString {
870970
NSAttributedString(
871971
string: Constants.string,

0 commit comments

Comments
 (0)