Skip to content

Commit b7b87dd

Browse files
committed
Lay out iOS text off the main thread before its first draw
## Summary The first time a text view draws, `drawAttributedString:` runs on the main thread and lays its text out from scratch: it builds the `NSAttributedString` (font lookups), attaches a new layout manager to it (font fixing, glyph generation) and lays out every line. Measuring already did the same work, off the main thread, and that layout is thrown away. Painting the glyphs turns out to be a small part of the cost of a draw. **Current behavior.** When new text appears, the display pass that shows it for the first time pays for that layout on the main thread. In a trace of a feed scrolling in the app that hit this, first draws of text were the largest single main-thread cost of mounting a row, about 22% of the mount transaction. React Native has `enablePreparedTextLayout` for this, but its implementation is Android only. **With this change.** `ParagraphShadowNode` asks the text layout manager to lay the text out, on the thread it is laying out on, for the frame the text view will draw in. It does that where it creates the paragraph state for that content and size, which is already the code that knows both, and is exactly when the view redraws. `RCTTextLayoutManager` keeps laid-out text storages in a thread-safe LRU cache, keyed by what the paragraph draws and the frame it draws in, so the draw finds the one made for it and only paints. `drawAttributedString:` otherwise behaves as before: when nothing was prepared for it, it builds the layout itself, as it does today. The cached object is the layout: the `NSLayoutManager` attached to the text storage holds the line fragments and glyphs, and it is the same object the current code builds on the main thread for every draw. A hit changes nothing about what is drawn. Key equivalence and hashing are the ones prepared text layouts use on Android (`areAttributedStringsEquivalentDisplayWise`): everything drawing reads, including text attributes and attachment sizes, and nothing that only matters for hit testing, such as a fragment's event emitter. Two text views that draw the same content at the same size share one layout, so repeated content in a long list is laid out once. Preparing is a new optional method on the text layout manager, reached through `TextLayoutManagerExtended` like `measureLines`, so platforms whose manager has no layout to prepare, such as the C++ one, compile it away. **Why not `enablePreparedTextLayout`.** That flag changes how paragraphs are measured (`rawContentSize()`, exact constraints, prepared layouts carried in `ParagraphState`), and its implementation lives behind Android's text pipeline. Making the iOS `TextLayoutManager` satisfy `TextLayoutManagerWithPreparedTextLayout` is a bigger change with a much wider blast radius, because it changes iOS measurement too. This change leaves measurement, `ParagraphState` and the drawing code alone, so it can be evaluated and reverted on its own. Converging the two is a reasonable follow-up. **Threading and cost.** Layouts are built on the thread that lays out the shadow tree and are immutable once built, apart from the truncated-text color fix-up that a draw applies. `SimpleThreadSafeCache::get` runs its generator under the cache's lock, so a preparation holds the lock for the length of one layout and waits on other preparations; draws only look entries up, so the main thread never waits on a layout and never blocks text views behind one another. Text is laid out at most twice, once for measurement and once for the first draw, the second on the JS thread rather than the main thread, at about a millisecond per paragraph in a commit. Text that is never displayed is still prepared, because at layout time React Native does not know which paragraphs will be drawn. ## Changelog [IOS] [CHANGED] - Lay out `Text` off the main thread before its first draw ## Test Plan - `node scripts/clang-format.js` with the pinned clang-format (21.1.2) on the changed files: passes. - The new methods are added to `scripts/cxx-api/api-snapshots/{ReactApple,ReactAndroid,ReactCommon}FrameworksCxx.api` in the order the generator sorts them (the members of each block remain ASCII-sorted). `python -m scripts.cxx-api.parser` does not run on this Linux machine (its Doxygen XML parse comes back empty), so those files were edited by hand and should be regenerated before landing. - **Not built.** This machine has no macOS, Xcode or iOS SDK, so none of this compiled here, not the ObjC++ and not the Fantom tester. A branch that has never compiled is the main risk in this PR: it needs a build and a run in RNTester on a device before it is ready. - **Not measured.** The numbers below come from the app that motivated the change, where the same approach was developed on top of 0.86.3, not from this branch. Release build, iPhone 16 Pro Max, 30s fast fling through a feed, with and without the change: | | before | after | |---|---:|---:| | main-thread time in `drawAttributedString:` per 30s | 570 ms | 160 ms | | hitches | 88 | 40 | | hitch time per second of scrolling | 40.8 ms | 14.6 ms | | JS-thread time spent preparing text per 30s | 0 ms | ~300 ms | What is left in `drawAttributedString:` is painting glyphs, background and highlight rectangles, and the truncated-text color fix-up. - Things to look at once it runs: truncated text (the fix-up that colors the "…" mutates the text storage, which is now shared, so a second draw must look the same), multi-line text, text with inline views, link press highlighting, and text at several widths in a `ScrollView`. - Known limitation: with `enableIOSCompressedTextFrameAdjustment` on, a paragraph that sets a line height is drawn in an adjusted frame, so that paragraph is laid out twice and the prepared layout is not the one drawn. Both flags are off by default and independent; making them work together is a follow-up. - Follow-up: the truncated-text color fix-up could move into the preparation, which would make prepared layouts immutable and take another ~35 ms per 30s of scrolling off the main thread. It is left out to keep this change small.
1 parent c7429b0 commit b7b87dd

9 files changed

Lines changed: 176 additions & 4 deletions

File tree

‎packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ void ParagraphShadowNode::updateStateIfNeeded(const Content& content) {
195195
return;
196196
}
197197

198+
if constexpr (TextLayoutManagerExtended::supportsPrepareForDrawing()) {
199+
// The text view lays its text out on the main thread the first time it
200+
// draws. Lay it out here instead, for the frame that draw uses.
201+
TextLayoutManagerExtended(*textLayoutManager_)
202+
.prepareForDrawing(
203+
content.attributedString, content.paragraphAttributes, layoutSize);
204+
}
205+
198206
setStateData(
199207
ParagraphState{
200208
.attributedString = content.attributedString,

‎packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutManagerExtended.h‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ class TextLayoutManagerExtended {
7171
return TextLayoutManagerWithPreparedTextLayout<TextLayoutManagerT>;
7272
}
7373

74+
static constexpr bool supportsPrepareForDrawing()
75+
{
76+
return requires(
77+
const TextLayoutManagerT &textLayoutManager,
78+
AttributedString attributedString,
79+
ParagraphAttributes paragraphAttributes,
80+
Size size) { textLayoutManager.prepareForDrawing(attributedString, paragraphAttributes, size); };
81+
}
82+
7483
using PreparedTextLayout = typename PreparedTextLayoutT<TextLayoutManagerT>::type;
7584

7685
TextLayoutManagerExtended(const TextLayoutManagerT &textLayoutManager) : textLayoutManager_(textLayoutManager) {}
@@ -98,6 +107,18 @@ class TextLayoutManagerExtended {
98107
LOG(FATAL) << "Platform TextLayoutManager does not support prepareLayout";
99108
}
100109

110+
void prepareForDrawing(
111+
const AttributedString &attributedString,
112+
const ParagraphAttributes &paragraphAttributes,
113+
const Size &size) const
114+
{
115+
if constexpr (supportsPrepareForDrawing()) {
116+
textLayoutManager_.prepareForDrawing(attributedString, paragraphAttributes, size);
117+
} else {
118+
LOG(FATAL) << "Platform TextLayoutManager does not support prepareForDrawing";
119+
}
120+
}
121+
101122
TextMeasurement measurePreparedLayout(
102123
const PreparedTextLayout &layout,
103124
const TextLayoutContext &layoutContext,

‎packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ using RCTTextLayoutFragmentEnumerationBlock =
3939
layoutContext:(facebook::react::TextLayoutContext)layoutContext
4040
layoutConstraints:(facebook::react::LayoutConstraints)layoutConstraints;
4141

42+
/**
43+
* Lays `attributedString` out for a text view that draws it in a frame of `size`,
44+
* so that the draw does not lay it out again.
45+
* Can be called from any thread.
46+
*/
47+
- (void)prepareAttributedStringForDrawing:(const facebook::react::AttributedString &)attributedString
48+
paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes
49+
size:(CGSize)size;
50+
4251
- (void)drawAttributedString:(const facebook::react::AttributedString &)attributedString
4352
paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes
4453
frame:(CGRect)frame

‎packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm‎

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,74 @@
1515
#import <React/NSTextStorage+FontScaling.h>
1616
#import <React/RCTUtils.h>
1717
#import <react/featureflags/ReactNativeFeatureFlags.h>
18+
#import <react/renderer/textlayoutmanager/TextMeasureCache.h>
1819
#import <react/renderer/textlayoutmanager/TextMeasurementRounding.h>
1920
#import <react/utils/ManagedObjectWrapper.h>
2021
#import <react/utils/SimpleThreadSafeCache.h>
2122

2223
using namespace facebook::react;
2324

25+
namespace facebook::react {
26+
27+
/*
28+
* Identifies the text storage a text view draws: what it draws, and the size of
29+
* the frame it draws it in. Two paragraphs that are equivalent this way draw
30+
* the same, so they share a text storage. Equivalence is the one prepared text
31+
* layouts use on Android: everything drawing looks at, and nothing that only
32+
* matters for hit testing, such as a fragment's event emitter.
33+
*
34+
* The frame size is all a layout depends on: it is the size the view draws in,
35+
* already rounded to the pixel grid by Yoga, so the pixel scale factor is not
36+
* part of the key.
37+
*/
38+
class RCTPreparedTextLayoutKey final {
39+
public:
40+
AttributedString attributedString;
41+
ParagraphAttributes paragraphAttributes;
42+
Size size;
43+
44+
bool operator==(const RCTPreparedTextLayoutKey &rhs) const
45+
{
46+
return areAttributedStringsEquivalentDisplayWise(attributedString, rhs.attributedString) &&
47+
paragraphAttributes == rhs.paragraphAttributes && size == rhs.size;
48+
}
49+
};
50+
51+
inline RCTPreparedTextLayoutKey RCTPreparedTextLayoutKeyFor(
52+
const AttributedString &attributedString,
53+
const ParagraphAttributes &paragraphAttributes,
54+
CGSize size)
55+
{
56+
return RCTPreparedTextLayoutKey{
57+
.attributedString = attributedString,
58+
.paragraphAttributes = paragraphAttributes,
59+
.size = facebook::react::Size{
60+
.width = static_cast<facebook::react::Float>(size.width),
61+
.height = static_cast<facebook::react::Float>(size.height)}};
62+
}
63+
64+
} // namespace facebook::react
65+
66+
namespace std {
67+
68+
template <>
69+
struct hash<facebook::react::RCTPreparedTextLayoutKey> {
70+
size_t operator()(const facebook::react::RCTPreparedTextLayoutKey &key) const
71+
{
72+
return facebook::react::hash_combine(
73+
facebook::react::attributedStringHashDisplayWise(key.attributedString), key.paragraphAttributes, key.size);
74+
}
75+
};
76+
77+
} // namespace std
78+
2479
@implementation RCTTextLayoutManager {
2580
SimpleThreadSafeCache<AttributedString, std::shared_ptr<void>, 256> _cache;
81+
/*
82+
* Text laid out for the first draw of a text view. The text storage is the
83+
* prepared layout: it owns the layout manager that holds the lines.
84+
*/
85+
SimpleThreadSafeCache<RCTPreparedTextLayoutKey, std::shared_ptr<void>, 256> _preparedTextLayouts;
2686
}
2787

2888
static NSLineBreakMode RCTNSLineBreakModeFromEllipsizeMode(EllipsizeMode ellipsizeMode)
@@ -128,15 +188,36 @@ - (CGRect)drawingFrameForAttributedString:(const AttributedString &)attributedSt
128188
return drawingFrame;
129189
}
130190

191+
- (void)prepareAttributedStringForDrawing:(const AttributedString &)attributedString
192+
paragraphAttributes:(ParagraphAttributes)paragraphAttributes
193+
size:(CGSize)size
194+
{
195+
if (attributedString.isEmpty() || size.width <= 0 || size.height <= 0) {
196+
return;
197+
}
198+
199+
// Laying the text out here holds the cache for the whole layout, which
200+
// serializes preparations. Drawing only looks layouts up, so it never waits on
201+
// this.
202+
(void)_preparedTextLayouts.get(RCTPreparedTextLayoutKeyFor(attributedString, paragraphAttributes, size), [&]() {
203+
NSAttributedString *nsAttributedString = [self _nsAttributedStringFromAttributedString:attributedString];
204+
NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:nsAttributedString
205+
paragraphAttributes:paragraphAttributes
206+
size:size];
207+
NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
208+
[layoutManager ensureLayoutForTextContainer:layoutManager.textContainers.firstObject];
209+
return wrapManagedObject(textStorage);
210+
});
211+
}
212+
131213
- (void)drawAttributedString:(const AttributedString &)attributedString
132214
paragraphAttributes:(ParagraphAttributes)paragraphAttributes
133215
frame:(CGRect)frame
134216
drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block
135217
{
136-
NSTextStorage *textStorage = [self
137-
_textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString]
138-
paragraphAttributes:paragraphAttributes
139-
size:frame.size];
218+
NSTextStorage *textStorage = [self _textStorageForDrawingWithAttributedString:attributedString
219+
paragraphAttributes:paragraphAttributes
220+
size:frame.size];
140221
NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
141222
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
142223

@@ -431,6 +512,29 @@ - (CGFloat)_maximumFontSizeInAttributedString:(NSAttributedString *)attributedSt
431512
return maximumFontSize;
432513
}
433514

515+
/*
516+
* The text storage to draw `attributedString` with in a frame of `size`: the one
517+
* prepared for exactly that, or a new one when none was prepared.
518+
*/
519+
- (NSTextStorage *)_textStorageForDrawingWithAttributedString:(const AttributedString &)attributedString
520+
paragraphAttributes:(ParagraphAttributes)paragraphAttributes
521+
size:(CGSize)size
522+
{
523+
auto preparedTextLayout =
524+
_preparedTextLayouts.get(RCTPreparedTextLayoutKeyFor(attributedString, paragraphAttributes, size));
525+
NSTextStorage *textStorage = preparedTextLayout.has_value() ? unwrapManagedObject(*preparedTextLayout) : nil;
526+
if (textStorage != nil) {
527+
return textStorage;
528+
}
529+
530+
// Not prepared: lay the text out here, as before. It is not added to the
531+
// cache, so that drawing never holds it.
532+
return [self
533+
_textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString]
534+
paragraphAttributes:paragraphAttributes
535+
size:size];
536+
}
537+
434538
- (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttributedString *)attributedString
435539
paragraphAttributes:(ParagraphAttributes)paragraphAttributes
436540
size:(CGSize)size

‎packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.h‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ class TextLayoutManager {
5757
const ParagraphAttributes &paragraphAttributes,
5858
const Size &size) const;
5959

60+
/*
61+
* Lays `attributedString` out for a text view that draws it in a frame of
62+
* `size`, so that the view's first draw does not lay it out again.
63+
* Can be called from any thread.
64+
*/
65+
void prepareForDrawing(
66+
const AttributedString &attributedString,
67+
const ParagraphAttributes &paragraphAttributes,
68+
const Size &size) const;
69+
6070
/*
6171
* Returns an opaque pointer to platform-specific TextLayoutManager.
6272
* Is used on a native views layer to delegate text rendering to the manager.

‎packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@
113113
return measurement;
114114
}
115115

116+
void TextLayoutManager::prepareForDrawing(
117+
const AttributedString &attributedString,
118+
const ParagraphAttributes &paragraphAttributes,
119+
const Size &size) const
120+
{
121+
RCTTextLayoutManager *textLayoutManager = (RCTTextLayoutManager *)unwrapManagedObject(nativeTextLayoutManager_);
122+
123+
[textLayoutManager prepareAttributedStringForDrawing:attributedString
124+
paragraphAttributes:paragraphAttributes
125+
size:CGSizeMake(size.width, size.height)];
126+
}
127+
116128
LinesMeasurements TextLayoutManager::measureLines(
117129
const AttributedStringBox &attributedStringBox,
118130
const ParagraphAttributes &paragraphAttributes,

‎scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8403,8 +8403,10 @@ class facebook::react::detail::TextLayoutManagerExtended {
84038403
public facebook::react::TextMeasurement measurePreparedLayout(const facebook::react::detail::TextLayoutManagerExtended::PreparedTextLayout& layout, const facebook::react::TextLayoutContext& layoutContext, const facebook::react::LayoutConstraints& layoutConstraints) const;
84048404
public facebook::react::detail::TextLayoutManagerExtended::PreparedTextLayout prepareLayout(const facebook::react::AttributedString& attributedString, const facebook::react::ParagraphAttributes& paragraphAttributes, const facebook::react::TextLayoutContext& layoutContext, const facebook::react::LayoutConstraints& layoutConstraints) const;
84058405
public static constexpr bool supportsLineMeasurement();
8406+
public static constexpr bool supportsPrepareForDrawing();
84068407
public static constexpr bool supportsPreparedTextLayout();
84078408
public using PreparedTextLayout = typename facebook::react::detail::PreparedTextLayoutT<TextLayoutManagerT>::type;
8409+
public void prepareForDrawing(const facebook::react::AttributedString& attributedString, const facebook::react::ParagraphAttributes& paragraphAttributes, const facebook::react::Size& size) const;
84088410
}
84098411

84108412
template <typename... T>

‎scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,6 +1936,7 @@ interface RCTTextLayoutManager : public NSObject {
19361936
public virtual std::shared_ptr<const facebook::react::EventEmitter> getEventEmitterWithAttributeString:paragraphAttributes:frame:atPoint:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, CGPoint point);
19371937
public virtual void drawAttributedString:paragraphAttributes:frame:drawHighlightPath:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, void(^)(UIBezierPath* highlightPath) block);
19381938
public virtual void getRectWithAttributedString:paragraphAttributes:enumerateAttribute:frame:usingBlock:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, NSString* enumerateAttribute, CGRect frame, RCTTextLayoutFragmentEnumerationBlock block);
1939+
public virtual void prepareAttributedStringForDrawing:paragraphAttributes:size:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGSize size);
19391940
}
19401941

19411942
interface RCTTextSelection : public NSObject {
@@ -6213,6 +6214,7 @@ class facebook::react::TextLayoutManager {
62136214
public facebook::react::TextLayoutManager& operator=(facebook::react::TextLayoutManager&&) = delete;
62146215
public facebook::react::TextMeasurement measure(const facebook::react::AttributedStringBox& attributedStringBox, const facebook::react::ParagraphAttributes& paragraphAttributes, const facebook::react::TextLayoutContext& layoutContext, const facebook::react::LayoutConstraints& layoutConstraints) const;
62156216
public std::shared_ptr<void> getNativeTextLayoutManager() const;
6217+
public void prepareForDrawing(const facebook::react::AttributedString& attributedString, const facebook::react::ParagraphAttributes& paragraphAttributes, const facebook::react::Size& size) const;
62166218
}
62176219

62186220
class facebook::react::TextMeasureCacheKey {
@@ -10312,8 +10314,10 @@ class facebook::react::detail::TextLayoutManagerExtended {
1031210314
public facebook::react::TextMeasurement measurePreparedLayout(const facebook::react::detail::TextLayoutManagerExtended::PreparedTextLayout& layout, const facebook::react::TextLayoutContext& layoutContext, const facebook::react::LayoutConstraints& layoutConstraints) const;
1031310315
public facebook::react::detail::TextLayoutManagerExtended::PreparedTextLayout prepareLayout(const facebook::react::AttributedString& attributedString, const facebook::react::ParagraphAttributes& paragraphAttributes, const facebook::react::TextLayoutContext& layoutContext, const facebook::react::LayoutConstraints& layoutConstraints) const;
1031410316
public static constexpr bool supportsLineMeasurement();
10317+
public static constexpr bool supportsPrepareForDrawing();
1031510318
public static constexpr bool supportsPreparedTextLayout();
1031610319
public using PreparedTextLayout = typename facebook::react::detail::PreparedTextLayoutT<TextLayoutManagerT>::type;
10320+
public void prepareForDrawing(const facebook::react::AttributedString& attributedString, const facebook::react::ParagraphAttributes& paragraphAttributes, const facebook::react::Size& size) const;
1031710321
}
1031810322

1031910323
template <typename... T>

‎scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5647,8 +5647,10 @@ class facebook::react::detail::TextLayoutManagerExtended {
56475647
public facebook::react::TextMeasurement measurePreparedLayout(const facebook::react::detail::TextLayoutManagerExtended::PreparedTextLayout& layout, const facebook::react::TextLayoutContext& layoutContext, const facebook::react::LayoutConstraints& layoutConstraints) const;
56485648
public facebook::react::detail::TextLayoutManagerExtended::PreparedTextLayout prepareLayout(const facebook::react::AttributedString& attributedString, const facebook::react::ParagraphAttributes& paragraphAttributes, const facebook::react::TextLayoutContext& layoutContext, const facebook::react::LayoutConstraints& layoutConstraints) const;
56495649
public static constexpr bool supportsLineMeasurement();
5650+
public static constexpr bool supportsPrepareForDrawing();
56505651
public static constexpr bool supportsPreparedTextLayout();
56515652
public using PreparedTextLayout = typename facebook::react::detail::PreparedTextLayoutT<TextLayoutManagerT>::type;
5653+
public void prepareForDrawing(const facebook::react::AttributedString& attributedString, const facebook::react::ParagraphAttributes& paragraphAttributes, const facebook::react::Size& size) const;
56525654
}
56535655

56545656
template <typename... T>

0 commit comments

Comments
 (0)