From 770db37efb95972d1b7681fc03bc8aa4141a46ed Mon Sep 17 00:00:00 2001 From: faimin Date: Fri, 15 May 2026 00:07:41 +0800 Subject: [PATCH 01/14] feat: support true async Yoga layout calculation on background thread Core changes: - Split calculateLayoutWithSize: into sync (asyncMode:NO) and a new calculateLayoutWithSize:asyncMode: variant - In async mode, pre-measure leaf nodes (UILabel via boundingRect, UIImageView via image.size) and set fixed width/height on Yoga nodes - Skip setting YGMeasureFunc for pre-measured nodes, so Yoga never calls back into UIKit during background calculation - 3-phase async flow: main-thread pre-measure, background YGNodeCalculateLayout, main-thread frame application - Fixed ZDCalculateHelper's zd_executeAsyncTasks being commented out, which prevented onComplete callbacks from ever executing - Removed main-thread-only NSAssert from isLeaf and calculateLayoutWithSize: - Added thread-safe UILabel measurement via NSAttributedString boundingRectWithSize:options:context: --- Sources/Core/Private/ZDCalculateHelper.m | 26 +-- .../Core/Private/ZDFlexLayoutCore+Private.h | 6 + Sources/Core/Public/ZDFlexLayoutCore.m | 166 ++++++++++++++++-- 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/Sources/Core/Private/ZDCalculateHelper.m b/Sources/Core/Private/ZDCalculateHelper.m index 96497e4..7faaf05 100644 --- a/Sources/Core/Private/ZDCalculateHelper.m +++ b/Sources/Core/Private/ZDCalculateHelper.m @@ -19,11 +19,11 @@ static void zd_init(void) { if (!_asyncTaskQueue) { _asyncTaskQueue = [[NSMutableOrderedSet alloc] init]; } - + if (!_asyncMainThreadQueue) { _asyncMainThreadQueue = [NSMutableOrderedSet orderedSet]; } - + if (!_taskGroup) { _taskGroup = dispatch_group_create(); } @@ -33,7 +33,7 @@ static void zd_lock(dispatch_block_t callback) { if (!callback) { return; } - + if (@available(iOS 10.0, *)) { static os_unfair_lock lock = OS_UNFAIR_LOCK_INIT; os_unfair_lock_lock(&lock); @@ -46,7 +46,7 @@ static void zd_lock(dispatch_block_t callback) { dispatch_once(&onceToken, ^{ lock = dispatch_semaphore_create(1); }); - + dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER); callback(); dispatch_semaphore_signal(lock); @@ -64,13 +64,13 @@ static dispatch_queue_t zd_calculate_queue(void) { __attribute__((__overloadable__)) static void zd_addAsyncTaskBlockWithCompleteCallback(dispatch_block_t task, dispatch_block_t complete) { if (task == nil) return; - + zd_init(); - + dispatch_group_enter(_taskGroup); dispatch_async(zd_calculate_queue(), ^{ task(); - + zd_lock(^{ [_asyncTaskQueue addObject:^{ dispatch_group_leave(_taskGroup); @@ -89,9 +89,9 @@ static dispatch_queue_t zd_calculate_queue(void) { __attribute__((__overloadable__)) static void zd_addAsyncTaskBlockWithCompleteCallback(NSArray *tasks, dispatch_block_t allComplete) { if (tasks == nil || tasks.count == 0) return; - + zd_init(); - + dispatch_group_enter(_taskGroup); dispatch_async(zd_calculate_queue(), ^{ for (dispatch_block_t task in tasks) { @@ -107,7 +107,7 @@ static dispatch_queue_t zd_calculate_queue(void) { }); } -__unused static void zd_executeAsyncTasks(void) { +static void zd_executeAsyncTasks(void) { zd_lock(^{ // onComplete block for (dispatch_block_t task in _asyncTaskQueue) { @@ -135,12 +135,12 @@ static void zd_sourceContextCallBackLog(void *info) { static void zd_initRunloop(void) { CFRunLoopRef runloop = CFRunLoopGetMain(); CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopBeforeWaiting | kCFRunLoopExit, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { - //zd_executeAsyncTasks(); + zd_executeAsyncTasks(); zd_executeMainThreadAsyncTasks(); }); CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes); CFRelease(observer); - + CFRunLoopSourceContext *sourceContext = calloc(1, sizeof(CFRunLoopSourceContext)); sourceContext->perform = zd_sourceContextCallBackLog; _runloopSource = CFRunLoopSourceCreate(CFAllocatorGetDefault(), 0, sourceContext); @@ -153,7 +153,7 @@ static void zd_autoLayoutWhenIdle(dispatch_block_t layoutTask) { if (!layoutTask) { return; } - + zd_init(); [_asyncMainThreadQueue addObject:layoutTask]; } diff --git a/Sources/Core/Private/ZDFlexLayoutCore+Private.h b/Sources/Core/Private/ZDFlexLayoutCore+Private.h index feade19..abc5d44 100644 --- a/Sources/Core/Private/ZDFlexLayoutCore+Private.h +++ b/Sources/Core/Private/ZDFlexLayoutCore+Private.h @@ -14,4 +14,10 @@ - (instancetype)initWithView:(ZDFlexLayoutView)view; +/// Must be called on main thread — reads UIView.traitCollection +- (void)updateLayoutDirectionIfNeeded; + +/// Calculate layout with optional async mode (pre-measures leaf nodes, skips measure func) +- (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode; + @end diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index 795d721..4b08454 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -195,7 +195,6 @@ - (NSUInteger)numberOfChildren - (BOOL)isLeaf { - NSAssert([NSThread isMainThread], @"This method must be called on the main thread."); if (self.isEnabled) { for (ZDFlexLayoutView subview in self.view.children) { ZDFlexLayoutCore *const yoga = subview.flexLayout; @@ -314,16 +313,43 @@ - (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin const self.isEnabled = YES; __weak typeof(self) weakTarget = self; - __auto_type calculateBlock = ^{ - __strong typeof(weakTarget) self = weakTarget; - [self calculateLayoutWithSize:size]; - YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); - }; if (async) { - [ZDCalculateHelper asyncLayoutTask:calculateBlock]; + // === Async path: main-thread pre-measure → background calculation → main-thread apply === + + // Step 1 (main thread): Update layout direction from traitCollection, + // then pre-measure leaf nodes and attach Yoga tree. + // This ensures no UIKit-dependent code runs during background calculation. + [self updateLayoutDirectionIfNeeded]; + YGAttachNodesFromViewHierachy(self.view, YES /* asyncMode */); + + // Step 2 (background thread): Pure numeric Yoga calculation. + // No measure function callbacks → no UIKit → safe for background. + [ZDCalculateHelper asyncCalculateTask:^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + + const YGNodeRef node = self.node; + YGNodeCalculateLayout( + node, + size.width, + size.height, + YGNodeStyleGetDirection(node) + ); + } onComplete:^{ + // Step 3 (main thread): Apply calculated frames to views. + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }]; } else { + // Sync path: everything on current thread (must be main thread) + __auto_type calculateBlock = ^{ + __strong typeof(weakTarget) self = weakTarget; + [self calculateLayoutWithSize:size]; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }; calculateBlock(); } } @@ -339,17 +365,25 @@ - (CGSize)intrinsicSize return [self calculateLayoutWithSize:constrainedSize]; } -- (CGSize)calculateLayoutWithSize:(CGSize)size +- (void)updateLayoutDirectionIfNeeded { - NSAssert([NSThread isMainThread], @"Yoga calculation must be done on main."); - NSAssert(self.isEnabled, @"Yoga is not enabled for this view."); - + // Must be called on main thread — accesses UIView.traitCollection UIView *view = self.view.owningView; if (view && view.traitCollection.layoutDirection != UITraitEnvironmentLayoutDirectionUnspecified) { self.direction = view.traitCollection.layoutDirection == UITraitEnvironmentLayoutDirectionLeftToRight ? YGDirectionRTL : YGDirectionLTR; } - - YGAttachNodesFromViewHierachy(self.view); +} + +- (CGSize)calculateLayoutWithSize:(CGSize)size +{ + return [self calculateLayoutWithSize:size asyncMode:NO]; +} + +- (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode +{ + NSAssert(self.isEnabled, @"Yoga is not enabled for this view."); + + YGAttachNodesFromViewHierachy(self.view, asyncMode); const YGNodeRef node = self.node; YGNodeCalculateLayout( @@ -367,6 +401,88 @@ - (CGSize)calculateLayoutWithSize:(CGSize)size #pragma mark - Private +// Thread-safe text measurement using NSAttributedString boundingRect (no UIKit dependency) +static CGSize YGMeasureLabelText(UILabel *label, CGSize constrainedSize) { + NSAttributedString *attributedText = label.attributedText; + if (attributedText.length > 0) { + CGRect rect = [attributedText boundingRectWithSize:constrainedSize + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + context:nil]; + return CGSizeMake(ceil(rect.size.width), ceil(rect.size.height)); + } + + NSString *text = label.text; + if (text.length > 0) { + CGRect rect = [text boundingRectWithSize:constrainedSize + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + attributes:label.font ? @{NSFontAttributeName: label.font} : nil + context:nil]; + return CGSizeMake(ceil(rect.size.width), ceil(rect.size.height)); + } + + return CGSizeZero; +} + +// Pre-measure a leaf node and set its size as fixed width/height on the Yoga node. +// This allows us to skip setting YGMeasureFunc and run Yoga calculation on a background thread. +// Returns YES if the node was fully pre-measured (no measure func needed). +static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view) { + YGValue nodeWidth = YGNodeStyleGetWidth(node); + YGValue nodeHeight = YGNodeStyleGetHeight(node); + + BOOL hasExplicitWidth = (nodeWidth.unit == YGUnitPoint && !YGFloatIsUndefined(nodeWidth.value)); + BOOL hasExplicitHeight = (nodeHeight.unit == YGUnitPoint && !YGFloatIsUndefined(nodeHeight.value)); + + if (hasExplicitWidth && hasExplicitHeight) { + return YES; // Already fully constrained, no measure func needed + } + + if (!view.flexLayout.isUIView) { + // ZDFlexLayoutDiv — sizeThatFits returns CGSizeZero, thread-safe + // Keep the measure function for these; they don't call UIKit + return NO; + } + + UIView *uiView = (UIView *)view; + CGSize measuredSize = CGSizeZero; + + if ([uiView isKindOfClass:[UILabel class]]) { + CGSize constrainedSize = CGSizeMake( + hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, + hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX + ); + measuredSize = YGMeasureLabelText((UILabel *)uiView, constrainedSize); + } else if ([uiView isKindOfClass:[UIImageView class]]) { + UIImage *image = ((UIImageView *)uiView).image; + if (image) { + measuredSize = image.size; + } + } else { + // For other UIView leaf nodes (plain UIView, custom views, etc.): + // sizeThatFits: requires main thread. + // If neither width nor height is set, we must call sizeThatFits: on main thread. + // If at least one dimension is set, we can skip measurement. + if (!hasExplicitWidth && !hasExplicitHeight) { + // Must measure on main thread — keep measure func + return NO; + } + // At least one dimension is explicit — store what we have + measuredSize = CGSizeMake( + hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, + hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX + ); + } + + if (!hasExplicitWidth && measuredSize.width > 0 && measuredSize.width < CGFLOAT_MAX) { + YGNodeStyleSetWidth(node, measuredSize.width); + } + if (!hasExplicitHeight && measuredSize.height > 0 && measuredSize.height < CGFLOAT_MAX) { + YGNodeStyleSetHeight(node, measuredSize.height); + } + + return YES; +} + static YGSize YGMeasureView( YGNodeConstRef node, float width, @@ -431,7 +547,7 @@ static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray Date: Tue, 19 May 2026 23:17:03 +0800 Subject: [PATCH 02/14] feat: add ZDFlexLayoutAsyncMode enum for flexible async selection Add ZDFlexLayoutAsyncMode enum with 3 modes: - ZDFlexLayoutAsyncModeSync: synchronous on calling thread - ZDFlexLayoutAsyncModeRunloopIdle: defer to main runloop idle - ZDFlexLayoutAsyncModeBackgroundThread: background calc, main-thread apply New public API: - applyLayoutWithAsyncMode:preservingOrigin:dimensionFlexibility: - applyLayoutWithAsyncMode:preservingOrigin:constraintSize: Backward compatibility: - asyncApplyLayout:YES -> ZDFlexLayoutAsyncModeRunloopIdle (unchanged) - asyncApplyLayout:NO -> ZDFlexLayoutAsyncModeSync (unchanged) --- Sources/Core/Public/ZDFlexLayoutCore.h | 17 ++++ Sources/Core/Public/ZDFlexLayoutCore.m | 115 +++++++++++++++-------- Sources/Core/Public/ZDFlexLayoutDefine.h | 10 ++ 3 files changed, 103 insertions(+), 39 deletions(-) diff --git a/Sources/Core/Public/ZDFlexLayoutCore.h b/Sources/Core/Public/ZDFlexLayoutCore.h index b342212..204299b 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.h +++ b/Sources/Core/Public/ZDFlexLayoutCore.h @@ -167,6 +167,23 @@ YG_EXTERN_C_END preservingOrigin:(BOOL)preserveOrigin constraintSize:(CGSize)size; +/** + Perform a layout calculation with the given async mode. + @param asyncMode Choose between sync, runloop idle, or background thread calculation. + @param preserveOrigin Whether to preserve the current origin. + @param dimensionFlexibility Which dimensions are flexible. + */ +- (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode + preservingOrigin:(BOOL)preserveOrigin + dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility; + +/** + Perform a layout calculation with the given async mode and a specific constraint size. + */ +- (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode + preservingOrigin:(BOOL)preserveOrigin + constraintSize:(CGSize)size; + /** Returns the size of the view based on provided constraints. Pass NaN for an unconstrained dimension. */ diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index 4b08454..6ff6978 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -309,48 +309,85 @@ - (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin dimen } - (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin constraintSize:(CGSize)size +{ + ZDFlexLayoutAsyncMode asyncMode = async ? ZDFlexLayoutAsyncModeRunloopIdle : ZDFlexLayoutAsyncModeSync; + [self applyLayoutWithAsyncMode:asyncMode preservingOrigin:preserveOrigin constraintSize:size]; +} + +#pragma mark - Async Mode API + +- (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode + preservingOrigin:(BOOL)preserveOrigin + dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility +{ + CGSize size = self.view.layoutFrame.size; + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleWidth) { + size.width = YGUndefined; + } + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleHeight) { + size.height = YGUndefined; + } + [self applyLayoutWithAsyncMode:asyncMode preservingOrigin:preserveOrigin constraintSize:size]; +} + +- (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode + preservingOrigin:(BOOL)preserveOrigin + constraintSize:(CGSize)size { self.isEnabled = YES; - + __weak typeof(self) weakTarget = self; - - if (async) { - // === Async path: main-thread pre-measure → background calculation → main-thread apply === - - // Step 1 (main thread): Update layout direction from traitCollection, - // then pre-measure leaf nodes and attach Yoga tree. - // This ensures no UIKit-dependent code runs during background calculation. - [self updateLayoutDirectionIfNeeded]; - YGAttachNodesFromViewHierachy(self.view, YES /* asyncMode */); - - // Step 2 (background thread): Pure numeric Yoga calculation. - // No measure function callbacks → no UIKit → safe for background. - [ZDCalculateHelper asyncCalculateTask:^{ - __strong typeof(weakTarget) self = weakTarget; - if (!self) return; - - const YGNodeRef node = self.node; - YGNodeCalculateLayout( - node, - size.width, - size.height, - YGNodeStyleGetDirection(node) - ); - } onComplete:^{ - // Step 3 (main thread): Apply calculated frames to views. - __strong typeof(weakTarget) self = weakTarget; - if (!self) return; - YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); - }]; - } - else { - // Sync path: everything on current thread (must be main thread) - __auto_type calculateBlock = ^{ - __strong typeof(weakTarget) self = weakTarget; - [self calculateLayoutWithSize:size]; - YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); - }; - calculateBlock(); + + + switch (asyncMode) { + case ZDFlexLayoutAsyncModeBackgroundThread: { + // Phase 1 (main thread): update direction, pre-measure leaf nodes, attach Yoga tree. + // Pre-measured leaf nodes have fixed sizes set, so YGMeasureFunc is skipped. + // This ensures no UIKit code runs during the background calculation phase. + [self updateLayoutDirectionIfNeeded]; + YGAttachNodesFromViewHierachy(self.view, YES /* asyncMode */); + + // Phase 2 (background thread): pure numeric Yoga calculation, no UIKit callbacks. + [ZDCalculateHelper asyncCalculateTask:^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + + const YGNodeRef node = self.node; + YGNodeCalculateLayout( + node, + size.width, + size.height, + YGNodeStyleGetDirection(node) + ); + } onComplete:^{ + // Phase 3 (main thread): apply calculated frames to views. + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }]; + break; + } + case ZDFlexLayoutAsyncModeRunloopIdle: { + // Runloop idle: defer whole calculation+apply to main runloop idle time. + __auto_type calculateBlock = ^{ + __strong typeof(weakTarget) self = weakTarget; + [self calculateLayoutWithSize:size]; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }; + [ZDCalculateHelper asyncLayoutTask:calculateBlock]; + break; + } + case ZDFlexLayoutAsyncModeSync: + default: { + // Sync: calculate and apply immediately on the calling thread. + __auto_type calculateBlock = ^{ + __strong typeof(weakTarget) self = weakTarget; + [self calculateLayoutWithSize:size]; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }; + calculateBlock(); + break; + } } } diff --git a/Sources/Core/Public/ZDFlexLayoutDefine.h b/Sources/Core/Public/ZDFlexLayoutDefine.h index b7a750a..cc82917 100644 --- a/Sources/Core/Public/ZDFlexLayoutDefine.h +++ b/Sources/Core/Public/ZDFlexLayoutDefine.h @@ -24,4 +24,14 @@ typedef NS_OPTIONS(NSInteger, ZDDimensionFlexibility) { #define YGDimensionFlexibilityFlexibleHeight (ZDDimensionFlexibilityFlexibleHeight) #define YGDimensionFlexibilityFlexibleAll (ZDDimensionFlexibilityFlexibleAll) +/// Async execution mode for layout calculation +typedef NS_ENUM(NSInteger, ZDFlexLayoutAsyncMode) { + /// Calculate and apply layout synchronously on the calling thread. + ZDFlexLayoutAsyncModeSync = 0, + /// Defer layout calculation to the main runloop idle time. + ZDFlexLayoutAsyncModeRunloopIdle, + /// Calculate layout on a background thread, then apply frames on the main thread. + ZDFlexLayoutAsyncModeBackgroundThread, +}; + #endif /* ZDFlexLayoutDefine_h */ From 9fde3e0ca117e2e3dd8fe4c6e824b700c5f094f2 Mon Sep 17 00:00:00 2001 From: faimin Date: Thu, 21 May 2026 20:28:56 +0800 Subject: [PATCH 03/14] feat: support calculate on background thread --- .tmp/TemplateX | 1 + CHANGELOG | 8 + README.md | 41 +- Sources/Core/Public/ZDFlexLayoutCore.h | 7 + Sources/Core/Public/ZDFlexLayoutCore.m | 1068 ++++++++++++++---------- 5 files changed, 665 insertions(+), 460 deletions(-) create mode 160000 .tmp/TemplateX diff --git a/.tmp/TemplateX b/.tmp/TemplateX new file mode 160000 index 0000000..8ce9ecb --- /dev/null +++ b/.tmp/TemplateX @@ -0,0 +1 @@ +Subproject commit 8ce9ecb700e581bfbfa74cabd13b50c6d821ef7f diff --git a/CHANGELOG b/CHANGELOG index 085c3c6..e22e90e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,11 @@ +## 0.5.0 + +1. 新增 `ZDFlexLayoutAsyncMode` 枚举,支持三种布局计算模式:同步、RunLoop Idle、后台线程 +2. 后台线程模式采用"缓存侧表"方案:主线程预测量 → 后台纯数值计算 → 主线程刷新,不污染 YGNode style +3. 新增 `applyLayoutWithAsyncMode:preservingOrigin:dimensionFlexibility:` 统一异步布局 API +4. 新增 `useLegacyPreMeasure` 属性,可切换回旧版预测量实现作为备用 +5. 修复 RunLoop Idle 模式下 weakSelf 未做 nil 判断的问题 + ## 0.4.0 1. `Yoga`更新到`0.3.2.1` diff --git a/README.md b/README.md index 1ded778..ecce632 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ + 链式调用 -+ 异步计算 ++ 异步计算(支持多种模式) + 自动更新布局 @@ -21,6 +21,45 @@ > PS:开启自动更新布局后,在布局发生改变需要更新时需要手动调用 `markDirty` ,`gone` 不需要调用 `markDirty` ,它内部会自己处理 +## Async Layout + +支持三种异步布局模式,通过 `ZDFlexLayoutAsyncMode` 枚举控制: + +| 模式 | 说明 | +|------|------| +| `ZDFlexLayoutAsyncModeSync` | 同步计算并刷新(默认) | +| `ZDFlexLayoutAsyncModeRunloopIdle` | 延迟到主线程 RunLoop 空闲时计算并刷新 | +| `ZDFlexLayoutAsyncModeBackgroundThread` | 后台线程计算,主线程刷新 | + +### 后台线程模式原理 + +采用"主线程预测量 + 缓存侧表 + 线程安全 measure func"三阶段方案,借鉴 ReactNative 的设计思路: + +1. **Phase 1(主线程)**:遍历叶子节点,调用 `sizeThatFits:` 等 UIKit API 获取固有尺寸,结果存入缓存侧表(不修改 YGNode style) +2. **Phase 2(后台线程)**:纯数值 Yoga 计算,measure 回调从缓存读取预测量结果,不访问 UIKit +3. **Phase 3(主线程)**:应用 frame、恢复 measure 函数、清除缓存 + +### 使用示例 + +```objc +// 后台线程异步计算 +[view.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + +// RunLoop 空闲时计算 +[view.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeRunloopIdle + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + +// 同步计算(等同于 applyLayoutPreservingOrigin:) +[view.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; +``` + +> 可通过设置 `flexLayout.useLegacyPreMeasure = YES` 切换到旧版预测量实现(直接修改 YGNode style),作为备用方案。 + ## Install ```ruby diff --git a/Sources/Core/Public/ZDFlexLayoutCore.h b/Sources/Core/Public/ZDFlexLayoutCore.h index 204299b..e5f6c8c 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.h +++ b/Sources/Core/Public/ZDFlexLayoutCore.h @@ -116,6 +116,13 @@ YG_EXTERN_C_END @property (nonatomic, readwrite, assign) YGValue columnGap; @property (nonatomic, readwrite, assign) YGValue allGap; +/** + When YES, background thread async mode uses the legacy pre-measure approach + that sets explicit width/height on YGNode style directly. + When NO (default), uses a cache side table approach that does not mutate YGNode style. + */ +@property (nonatomic, assign) BOOL useLegacyPreMeasure; + /** Get the resolved direction of this node. This won't be YGDirectionInherit */ diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index 6ff6978..51eb4b6 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -115,14 +115,12 @@ - (void)set ## objc_capitalized_name: (YGValue)objc_lowercased_name YG_VALUE_EDGE_PROPERTY(lowercased_name ## Vertical, capitalized_name ## Vertical, capitalized_name, YGEdgeVertical) \ YG_VALUE_EDGE_PROPERTY(lowercased_name, capitalized_name, capitalized_name, YGEdgeAll) -__attribute__((weak)) YGValue YGPointValue(CGFloat value) -{ +__attribute__((weak)) YGValue YGPointValue(CGFloat value) { return (YGValue) { .value = value, .unit = YGUnitPoint }; } -__attribute__((weak)) YGValue YGPercentValue(CGFloat value) -{ - return (YGValue) { .value = value, .unit = YGUnitPercent }; +__attribute__((weak)) YGValue YGPercentValue(CGFloat value) { + return (YGValue) { .value = value, .unit = YGUnitPercent }; } static YGConfigRef globalConfig; @@ -140,83 +138,75 @@ @implementation ZDFlexLayoutCore @synthesize isIncludedInLayout = _isIncludedInLayout; @synthesize node = _node; -+ (void)initialize -{ - globalConfig = YGConfigNew(); - YGConfigSetExperimentalFeatureEnabled(globalConfig, YGExperimentalFeatureWebFlexBasis, true); - YGConfigSetPointScaleFactor(globalConfig, [UIScreen mainScreen].scale); -} - -- (instancetype)initWithView:(ZDFlexLayoutView)view -{ - if (self = [super init]) { - _view = view; - _node = YGNodeNewWithConfig(globalConfig); - YGNodeSetContext(_node, (__bridge void *)view); - _isEnabled = NO; - _isIncludedInLayout = YES; - _isUIView = [view isMemberOfClass:[UIView class]]; - } - - return self; ++ (void)initialize { + globalConfig = YGConfigNew(); + YGConfigSetExperimentalFeatureEnabled(globalConfig, YGExperimentalFeatureWebFlexBasis, true); + YGConfigSetPointScaleFactor(globalConfig, [UIScreen mainScreen].scale); } -- (void)dealloc -{ - YGNodeFree(self.node); +- (instancetype)initWithView:(ZDFlexLayoutView)view { + if (self = [super init]) { + _view = view; + _node = YGNodeNewWithConfig(globalConfig); + YGNodeSetContext(_node, (__bridge void *)view); + _isEnabled = NO; + _isIncludedInLayout = YES; + _isUIView = [view isMemberOfClass:[UIView class]]; + } + + return self; } -- (BOOL)isDirty -{ - return YGNodeIsDirty(self.node); +- (void)dealloc { + YGNodeFree(self.node); } -- (void)markDirty -{ - if (self.isDirty || !self.isLeaf) { - return; - } - - // Yoga is not happy if we try to mark a node as "dirty" before we have set - // the measure function. Since we already know that this is a leaf, - // this *should* be fine. Forgive me Hack Gods. - const YGNodeRef node = self.node; - if (!YGNodeHasMeasureFunc(node)) { - YGNodeSetMeasureFunc(node, YGMeasureView); - } - - YGNodeMarkDirty(node); +- (BOOL)isDirty { + return YGNodeIsDirty(self.node); } -- (NSUInteger)numberOfChildren -{ - return YGNodeGetChildCount(self.node); +- (void)markDirty { + + if (self.isDirty || !self.isLeaf) { + return; + } + + // Yoga is not happy if we try to mark a node as "dirty" before we have set + // the measure function. Since we already know that this is a leaf, + // this *should* be fine. Forgive me Hack Gods. + const YGNodeRef node = self.node; + if (!YGNodeHasMeasureFunc(node)) { + YGNodeSetMeasureFunc(node, YGMeasureView); + } + + YGNodeMarkDirty(node); } -- (BOOL)isLeaf -{ - if (self.isEnabled) { - for (ZDFlexLayoutView subview in self.view.children) { - ZDFlexLayoutCore *const yoga = subview.flexLayout; - if (yoga.isEnabled && yoga.isIncludedInLayout) { - return NO; - } - } - } +- (NSUInteger)numberOfChildren { + return YGNodeGetChildCount(self.node); +} - return YES; +- (BOOL)isLeaf { + if (self.isEnabled) { + for (ZDFlexLayoutView subview in self.view.children) { + ZDFlexLayoutCore *const yoga = subview.flexLayout; + if (yoga.isEnabled && yoga.isIncludedInLayout) { + return NO; + } + } + } + + return YES; } #pragma mark - Style -- (YGPositionType)position -{ - return YGNodeStyleGetPositionType(self.node); +- (YGPositionType)position { + return YGNodeStyleGetPositionType(self.node); } -- (void)setPosition:(YGPositionType)position -{ - YGNodeStyleSetPositionType(self.node, position); +- (void)setPosition:(YGPositionType)position { + YGNodeStyleSetPositionType(self.node, position); } YG_PROPERTY(YGDirection, direction, Direction) @@ -267,424 +257,588 @@ - (void)setPosition:(YGPositionType)position #pragma mark - Layout and Sizing -- (YGDirection)resolvedDirection -{ - return YGNodeLayoutGetDirection(self.node); +- (YGDirection)resolvedDirection { + + return YGNodeLayoutGetDirection(self.node); } #pragma mark - Sync -- (void)applyLayout -{ - [self applyLayoutPreservingOrigin:NO]; +- (void)applyLayout { + + [self applyLayoutPreservingOrigin:NO]; } -- (void)applyLayoutPreservingOrigin:(BOOL)preserveOrigin -{ - [self asyncApplyLayout:NO preservingOrigin:preserveOrigin constraintSize:self.view.layoutFrame.size]; +- (void)applyLayoutPreservingOrigin:(BOOL)preserveOrigin { + + [self asyncApplyLayout:NO preservingOrigin:preserveOrigin constraintSize:self.view.layoutFrame.size]; } -- (void)applyLayoutPreservingOrigin:(BOOL)preserveOrigin dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility -{ - [self asyncApplyLayout:NO preservingOrigin:preserveOrigin dimensionFlexibility:dimensionFlexibility]; +- (void)applyLayoutPreservingOrigin:(BOOL)preserveOrigin dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility { + + [self asyncApplyLayout:NO preservingOrigin:preserveOrigin dimensionFlexibility:dimensionFlexibility]; } #pragma mark - Async -- (void)asyncApplyLayoutPreservingOrigin:(BOOL)preserveOrigin -{ - [self asyncApplyLayout:YES preservingOrigin:preserveOrigin constraintSize:self.view.layoutFrame.size]; +- (void)asyncApplyLayoutPreservingOrigin:(BOOL)preserveOrigin { + + [self asyncApplyLayout:YES preservingOrigin:preserveOrigin constraintSize:self.view.layoutFrame.size]; } -- (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility -{ - CGSize size = self.view.layoutFrame.size; - if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleWidth) { - size.width = YGUndefined; - } - if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleHeight) { - size.height = YGUndefined; - } - [self asyncApplyLayout:async preservingOrigin:preserveOrigin constraintSize:size]; +- (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility { + + CGSize size = self.view.layoutFrame.size; + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleWidth) { + size.width = YGUndefined; + } + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleHeight) { + size.height = YGUndefined; + } + [self asyncApplyLayout:async preservingOrigin:preserveOrigin constraintSize:size]; } -- (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin constraintSize:(CGSize)size -{ - ZDFlexLayoutAsyncMode asyncMode = async ? ZDFlexLayoutAsyncModeRunloopIdle : ZDFlexLayoutAsyncModeSync; - [self applyLayoutWithAsyncMode:asyncMode preservingOrigin:preserveOrigin constraintSize:size]; +- (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin constraintSize:(CGSize)size { + + ZDFlexLayoutAsyncMode asyncMode = async ? ZDFlexLayoutAsyncModeRunloopIdle : ZDFlexLayoutAsyncModeSync; + [self applyLayoutWithAsyncMode:asyncMode preservingOrigin:preserveOrigin constraintSize:size]; } #pragma mark - Async Mode API - (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode - preservingOrigin:(BOOL)preserveOrigin - dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility -{ - CGSize size = self.view.layoutFrame.size; - if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleWidth) { - size.width = YGUndefined; - } - if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleHeight) { - size.height = YGUndefined; - } - [self applyLayoutWithAsyncMode:asyncMode preservingOrigin:preserveOrigin constraintSize:size]; + preservingOrigin:(BOOL)preserveOrigin + dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility { + + CGSize size = self.view.layoutFrame.size; + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleWidth) { + size.width = YGUndefined; + } + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleHeight) { + size.height = YGUndefined; + } + [self applyLayoutWithAsyncMode:asyncMode preservingOrigin:preserveOrigin constraintSize:size]; } - (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode - preservingOrigin:(BOOL)preserveOrigin - constraintSize:(CGSize)size -{ - self.isEnabled = YES; - - __weak typeof(self) weakTarget = self; - - - switch (asyncMode) { - case ZDFlexLayoutAsyncModeBackgroundThread: { - // Phase 1 (main thread): update direction, pre-measure leaf nodes, attach Yoga tree. - // Pre-measured leaf nodes have fixed sizes set, so YGMeasureFunc is skipped. - // This ensures no UIKit code runs during the background calculation phase. - [self updateLayoutDirectionIfNeeded]; - YGAttachNodesFromViewHierachy(self.view, YES /* asyncMode */); - - // Phase 2 (background thread): pure numeric Yoga calculation, no UIKit callbacks. - [ZDCalculateHelper asyncCalculateTask:^{ - __strong typeof(weakTarget) self = weakTarget; - if (!self) return; - - const YGNodeRef node = self.node; - YGNodeCalculateLayout( - node, - size.width, - size.height, - YGNodeStyleGetDirection(node) - ); - } onComplete:^{ - // Phase 3 (main thread): apply calculated frames to views. - __strong typeof(weakTarget) self = weakTarget; - if (!self) return; - YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); - }]; - break; - } - case ZDFlexLayoutAsyncModeRunloopIdle: { - // Runloop idle: defer whole calculation+apply to main runloop idle time. - __auto_type calculateBlock = ^{ - __strong typeof(weakTarget) self = weakTarget; - [self calculateLayoutWithSize:size]; - YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); - }; - [ZDCalculateHelper asyncLayoutTask:calculateBlock]; - break; - } - case ZDFlexLayoutAsyncModeSync: - default: { - // Sync: calculate and apply immediately on the calling thread. - __auto_type calculateBlock = ^{ - __strong typeof(weakTarget) self = weakTarget; - [self calculateLayoutWithSize:size]; - YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); - }; - calculateBlock(); - break; - } - } + preservingOrigin:(BOOL)preserveOrigin + constraintSize:(CGSize)size { + + self.isEnabled = YES; + + switch (asyncMode) { + case ZDFlexLayoutAsyncModeBackgroundThread: { + [self updateLayoutDirectionIfNeeded]; + + __weak typeof(self) weakTarget = self; + + if (self.useLegacyPreMeasure) { + // Legacy path: pre-measure by mutating YGNode style directly + YGAttachNodesFromViewHierachy(self.view, YES); + + [ZDCalculateHelper asyncCalculateTask:^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + const YGNodeRef node = self.node; + YGNodeCalculateLayout(node, size.width, size.height, YGNodeStyleGetDirection(node)); + } onComplete:^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }]; + } else { + // Phase 1 (main thread): pre-measure all leaves, store in cache side table + ZDMeasureCacheCreate(); + YGPreMeasureAndCacheLeafNodes(self.view); + + // Phase 2 (background thread): pure numeric Yoga calculation + [ZDCalculateHelper asyncCalculateTask:^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + const YGNodeRef node = self.node; + YGNodeCalculateLayout(node, size.width, size.height, YGNodeStyleGetDirection(node)); + } onComplete:^{ + // Phase 3 (main thread): apply frames, restore measure funcs, clear cache + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + YGRestoreMeasureFuncs(self.view); + ZDMeasureCacheClear(); + }]; + } + break; + } + case ZDFlexLayoutAsyncModeRunloopIdle: { + __weak typeof(self) weakTarget = self; + __auto_type calculateBlock = ^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + [self calculateLayoutWithSize:size]; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }; + [ZDCalculateHelper asyncLayoutTask:calculateBlock]; + break; + } + case ZDFlexLayoutAsyncModeSync: + default: { + [self calculateLayoutWithSize:size]; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + break; + } + } } #pragma mark - -- (CGSize)intrinsicSize -{ - const CGSize constrainedSize = { - .width = YGUndefined, - .height = YGUndefined, - }; - return [self calculateLayoutWithSize:constrainedSize]; -} - -- (void)updateLayoutDirectionIfNeeded -{ - // Must be called on main thread — accesses UIView.traitCollection - UIView *view = self.view.owningView; - if (view && view.traitCollection.layoutDirection != UITraitEnvironmentLayoutDirectionUnspecified) { - self.direction = view.traitCollection.layoutDirection == UITraitEnvironmentLayoutDirectionLeftToRight ? YGDirectionRTL : YGDirectionLTR; - } -} - -- (CGSize)calculateLayoutWithSize:(CGSize)size -{ - return [self calculateLayoutWithSize:size asyncMode:NO]; -} - -- (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode -{ - NSAssert(self.isEnabled, @"Yoga is not enabled for this view."); - - YGAttachNodesFromViewHierachy(self.view, asyncMode); - - const YGNodeRef node = self.node; - YGNodeCalculateLayout( - node, - size.width, - size.height, - YGNodeStyleGetDirection(node) - ); - - return (CGSize) { - .width = YGNodeLayoutGetWidth(node), - .height = YGNodeLayoutGetHeight(node), - }; +- (CGSize)intrinsicSize { + const CGSize constrainedSize = { + .width = YGUndefined, + .height = YGUndefined, + }; + return [self calculateLayoutWithSize:constrainedSize]; +} + +- (void)updateLayoutDirectionIfNeeded { + // Must be called on main thread — accesses UIView.traitCollection + UIView *view = self.view.owningView; + if (view && view.traitCollection.layoutDirection != UITraitEnvironmentLayoutDirectionUnspecified) { + self.direction = view.traitCollection.layoutDirection == UITraitEnvironmentLayoutDirectionLeftToRight ? YGDirectionRTL : YGDirectionLTR; + } +} + +- (CGSize)calculateLayoutWithSize:(CGSize)size { + return [self calculateLayoutWithSize:size asyncMode:NO]; +} + +- (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode { + NSAssert(self.isEnabled, @"Yoga is not enabled for this view."); + + YGAttachNodesFromViewHierachy(self.view, asyncMode); + + const YGNodeRef node = self.node; + YGNodeCalculateLayout( + node, + size.width, + size.height, + YGNodeStyleGetDirection(node) + ); + + return (CGSize) { + .width = YGNodeLayoutGetWidth(node), + .height = YGNodeLayoutGetHeight(node), + }; +} + +#pragma mark - Measure Cache Side Table +// 缓存侧表:用于后台线程布局计算时存储预测量结果。 +// 写入发生在主线程(Phase 1),读取发生在后台线程(Phase 2),清除发生在主线程(Phase 3)。 +// GCD dispatch 提供 memory barrier,无需额外加锁。 + +static CFMutableDictionaryRef _zd_measureCache = NULL; + +static void ZDMeasureCacheCreate(void) { + if (_zd_measureCache) { + CFDictionaryRemoveAllValues(_zd_measureCache); + return; + } + _zd_measureCache = CFDictionaryCreateMutable( + kCFAllocatorDefault, 0, NULL, NULL + ); +} + +static void ZDMeasureCacheSet(YGNodeRef node, CGSize size) { + if (!_zd_measureCache) return; + CGSize *existing = (CGSize *)CFDictionaryGetValue(_zd_measureCache, node); + if (existing) { + *existing = size; + } else { + CGSize *entry = (CGSize *)malloc(sizeof(CGSize)); + *entry = size; + CFDictionarySetValue(_zd_measureCache, node, entry); + } +} + +static BOOL ZDMeasureCacheGet(YGNodeConstRef node, CGSize *outSize) { + if (!_zd_measureCache) return NO; + CGSize *entry = (CGSize *)CFDictionaryGetValue(_zd_measureCache, node); + if (!entry) return NO; + *outSize = *entry; + return YES; +} + +static void ZDMeasureCacheClear(void) { + if (!_zd_measureCache) return; + CFIndex count = CFDictionaryGetCount(_zd_measureCache); + if (count > 0) { + const void **values = (const void **)malloc(sizeof(void *) * (size_t)count); + CFDictionaryGetKeysAndValues(_zd_measureCache, NULL, values); + for (CFIndex i = 0; i < count; i++) { + free((void *)values[i]); + } + free(values); + } + CFDictionaryRemoveAllValues(_zd_measureCache); } #pragma mark - Private // Thread-safe text measurement using NSAttributedString boundingRect (no UIKit dependency) static CGSize YGMeasureLabelText(UILabel *label, CGSize constrainedSize) { - NSAttributedString *attributedText = label.attributedText; - if (attributedText.length > 0) { - CGRect rect = [attributedText boundingRectWithSize:constrainedSize - options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - context:nil]; - return CGSizeMake(ceil(rect.size.width), ceil(rect.size.height)); - } - - NSString *text = label.text; - if (text.length > 0) { - CGRect rect = [text boundingRectWithSize:constrainedSize - options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - attributes:label.font ? @{NSFontAttributeName: label.font} : nil - context:nil]; - return CGSizeMake(ceil(rect.size.width), ceil(rect.size.height)); - } - - return CGSizeZero; + + NSAttributedString *attributedText = label.attributedText; + if (attributedText.length > 0) { + CGRect rect = [attributedText boundingRectWithSize:constrainedSize + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + context:nil]; + return CGSizeMake(ceil(rect.size.width), ceil(rect.size.height)); + } + + NSString *text = label.text; + if (text.length > 0) { + CGRect rect = [text boundingRectWithSize:constrainedSize + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + attributes:label.font ? @{NSFontAttributeName: label.font} : nil + context:nil]; + return CGSizeMake(ceil(rect.size.width), ceil(rect.size.height)); + } + + return CGSizeZero; } // Pre-measure a leaf node and set its size as fixed width/height on the Yoga node. // This allows us to skip setting YGMeasureFunc and run Yoga calculation on a background thread. // Returns YES if the node was fully pre-measured (no measure func needed). static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view) { - YGValue nodeWidth = YGNodeStyleGetWidth(node); - YGValue nodeHeight = YGNodeStyleGetHeight(node); - - BOOL hasExplicitWidth = (nodeWidth.unit == YGUnitPoint && !YGFloatIsUndefined(nodeWidth.value)); - BOOL hasExplicitHeight = (nodeHeight.unit == YGUnitPoint && !YGFloatIsUndefined(nodeHeight.value)); - - if (hasExplicitWidth && hasExplicitHeight) { - return YES; // Already fully constrained, no measure func needed - } - - if (!view.flexLayout.isUIView) { - // ZDFlexLayoutDiv — sizeThatFits returns CGSizeZero, thread-safe - // Keep the measure function for these; they don't call UIKit - return NO; - } - - UIView *uiView = (UIView *)view; - CGSize measuredSize = CGSizeZero; - - if ([uiView isKindOfClass:[UILabel class]]) { - CGSize constrainedSize = CGSizeMake( - hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, - hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX - ); - measuredSize = YGMeasureLabelText((UILabel *)uiView, constrainedSize); - } else if ([uiView isKindOfClass:[UIImageView class]]) { - UIImage *image = ((UIImageView *)uiView).image; - if (image) { - measuredSize = image.size; - } - } else { - // For other UIView leaf nodes (plain UIView, custom views, etc.): - // sizeThatFits: requires main thread. - // If neither width nor height is set, we must call sizeThatFits: on main thread. - // If at least one dimension is set, we can skip measurement. - if (!hasExplicitWidth && !hasExplicitHeight) { - // Must measure on main thread — keep measure func - return NO; - } - // At least one dimension is explicit — store what we have - measuredSize = CGSizeMake( - hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, - hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX - ); - } - - if (!hasExplicitWidth && measuredSize.width > 0 && measuredSize.width < CGFLOAT_MAX) { - YGNodeStyleSetWidth(node, measuredSize.width); - } - if (!hasExplicitHeight && measuredSize.height > 0 && measuredSize.height < CGFLOAT_MAX) { - YGNodeStyleSetHeight(node, measuredSize.height); - } - - return YES; + + YGValue nodeWidth = YGNodeStyleGetWidth(node); + YGValue nodeHeight = YGNodeStyleGetHeight(node); + + BOOL hasExplicitWidth = (nodeWidth.unit == YGUnitPoint && !YGFloatIsUndefined(nodeWidth.value)); + BOOL hasExplicitHeight = (nodeHeight.unit == YGUnitPoint && !YGFloatIsUndefined(nodeHeight.value)); + + if (hasExplicitWidth && hasExplicitHeight) { + return YES; // Already fully constrained, no measure func needed + } + + if (!view.flexLayout.isUIView) { + // ZDFlexLayoutDiv — sizeThatFits returns CGSizeZero, thread-safe + // Keep the measure function for these; they don't call UIKit + return NO; + } + + UIView *uiView = (UIView *)view; + CGSize measuredSize = CGSizeZero; + + if ([uiView isKindOfClass:[UILabel class]]) { + CGSize constrainedSize = (CGSize){ + hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, + hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX + }; + measuredSize = YGMeasureLabelText((UILabel *)uiView, constrainedSize); + } else if ([uiView isKindOfClass:[UIImageView class]]) { + UIImage *image = ((UIImageView *)uiView).image; + if (image) { + measuredSize = image.size; + } + } else { + // For other UIView leaf nodes (plain UIView, custom views, etc.): + // sizeThatFits: requires main thread. + // If neither width nor height is set, we must call sizeThatFits: on main thread. + // If at least one dimension is set, we can skip measurement. + if (!hasExplicitWidth && !hasExplicitHeight) { + // Must measure on main thread — keep measure func + return NO; + } + // At least one dimension is explicit — store what we have + measuredSize = CGSizeMake( + hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, + hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX + ); + } + + if (!hasExplicitWidth && measuredSize.width > 0 && measuredSize.width < CGFLOAT_MAX) { + YGNodeStyleSetWidth(node, measuredSize.width); + } + if (!hasExplicitHeight && measuredSize.height > 0 && measuredSize.height < CGFLOAT_MAX) { + YGNodeStyleSetHeight(node, measuredSize.height); + } + + return YES; } static YGSize YGMeasureView( - YGNodeConstRef node, - float width, - YGMeasureMode widthMode, - float height, - YGMeasureMode heightMode) -{ - const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; - const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; - - CGSize sizeThatFits = CGSizeZero; - - // The default implementation of sizeThatFits: returns the existing size of - // the view. That means that if we want to layout an empty UIView, which - // already has got a frame set, its measured size should be CGSizeZero, but - // UIKit returns the existing size. - // - // See https://github.com/facebook/yoga/issues/606 for more information. - ZDFlexLayoutView view = (__bridge ZDFlexLayoutView)YGNodeGetContext(node); - if (!view.flexLayout.isUIView || [view.children count] > 0) { - sizeThatFits = [view sizeThatFits:(CGSize) { - .width = constrainedWidth, - .height = constrainedHeight, - }]; - } - - return (YGSize) { - .width = YGSanitizeMeasurement(constrainedWidth, sizeThatFits.width, widthMode), - .height = YGSanitizeMeasurement(constrainedHeight, sizeThatFits.height, heightMode), - }; + YGNodeConstRef node, + float width, + YGMeasureMode widthMode, + float height, + YGMeasureMode heightMode +) { + const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; + const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; + + CGSize sizeThatFits = CGSizeZero; + + // The default implementation of sizeThatFits: returns the existing size of + // the view. That means that if we want to layout an empty UIView, which + // already has got a frame set, its measured size should be CGSizeZero, but + // UIKit returns the existing size. + // + // See https://github.com/facebook/yoga/issues/606 for more information. + ZDFlexLayoutView view = (__bridge ZDFlexLayoutView)YGNodeGetContext(node); + if (!view.flexLayout.isUIView || [view.children count] > 0) { + sizeThatFits = [view sizeThatFits:(CGSize) { + .width = constrainedWidth, + .height = constrainedHeight, + }]; + } + + return (YGSize) { + .width = YGSanitizeMeasurement(constrainedWidth, sizeThatFits.width, widthMode), + .height = YGSanitizeMeasurement(constrainedHeight, sizeThatFits.height, heightMode), + }; } static CGFloat YGSanitizeMeasurement( - CGFloat constrainedSize, - CGFloat measuredSize, - YGMeasureMode measureMode) -{ - CGFloat result; - if (measureMode == YGMeasureModeExactly) { - result = constrainedSize; - } else if (measureMode == YGMeasureModeAtMost) { - result = MIN(constrainedSize, measuredSize); - } else { - result = measuredSize; - } - - return result; -} - -static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews) -{ - if (YGNodeGetChildCount(node) != subviews.count) { - return NO; - } - - for (int i = 0; i < subviews.count; i++) { - if (YGNodeGetChild(node, i) != subviews[i].flexLayout.node) { - return NO; - } - } - - return YES; -} - -static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyncMode) -{ - ZDFlexLayoutCore *const yoga = view.flexLayout; - const YGNodeRef node = yoga.node; - - // Only leaf nodes should have a measure function - if (yoga.isLeaf) { - YGRemoveAllChildren(node); - - if (asyncMode) { - // In async mode, try to pre-measure the leaf node and set fixed sizes. - // If fully pre-measured, skip setting YGMeasureFunc so Yoga won't - // call back into UIKit during background calculation. - if (YGPreMeasureLeafNode(node, view)) { - YGNodeSetMeasureFunc(node, NULL); - } else if (!yoga.isUIView) { - // Non-UIView (ZDFlexLayoutDiv): sizeThatFits returns CGSizeZero, thread-safe - YGNodeSetMeasureFunc(node, YGMeasureView); - } else { - // UIView leaf that couldn't be pre-measured (no explicit size, not UILabel/UIImageView). - // Setting YGMeasureFunc would call sizeThatFits: on background thread → unsafe. - // Instead, leave measure func unset and let Yoga use default sizing. - YGNodeSetMeasureFunc(node, NULL); - } - } else { - YGNodeSetMeasureFunc(node, YGMeasureView); - } - } else { - YGNodeSetMeasureFunc(node, NULL); - - NSMutableArray *subviewsToInclude = [[NSMutableArray alloc] initWithCapacity:view.children.count]; - for (ZDFlexLayoutView subview in view.children) { - if (subview.flexLayout.isEnabled && subview.flexLayout.isIncludedInLayout) { - [subviewsToInclude addObject:subview]; - } - } - - if (!YGNodeHasExactSameChildren(node, subviewsToInclude)) { - YGRemoveAllChildren(node); - for (int i = 0; i < subviewsToInclude.count; i++) { - YGNodeInsertChild(node, subviewsToInclude[i].flexLayout.node, i); - } - } - - for (ZDFlexLayoutView const subview in subviewsToInclude) { - YGAttachNodesFromViewHierachy(subview, asyncMode); - } - } -} - -static void YGRemoveAllChildren(const YGNodeRef node) -{ - if (node == NULL) { - return; - } - - YGNodeRemoveAllChildren(node); -} - -static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOrigin) -{ - NSCAssert([NSThread isMainThread], @"Framesetting should only be done on the main thread."); - - const ZDFlexLayoutCore *yoga = view.flexLayout; - - if (!yoga.isEnabled || !yoga.isIncludedInLayout) { - return; - } - - YGNodeRef node = yoga.node; - const CGPoint topLeft = { - YGNodeLayoutGetLeft(node), - YGNodeLayoutGetTop(node), - }; - - const CGPoint bottomRight = { - topLeft.x + YGNodeLayoutGetWidth(node), - topLeft.y + YGNodeLayoutGetHeight(node), - }; - - const CGPoint origin = preserveOrigin ? view.layoutFrame.origin : CGPointZero; - view.layoutFrame = (CGRect) { - .origin = { - .x = ZDFLRoundPixelValue(topLeft.x + origin.x), - .y = ZDFLRoundPixelValue(topLeft.y + origin.y), - }, - .size = { - .width = ZDFLRoundPixelValue(bottomRight.x) - ZDFLRoundPixelValue(topLeft.x), - .height = ZDFLRoundPixelValue(bottomRight.y) - ZDFLRoundPixelValue(topLeft.y), - }, - }; - - if (!yoga.isLeaf) { - for (NSUInteger i = 0; i < view.children.count; i++) { - YGApplyLayoutToViewHierarchy(view.children[i], NO); - } - - if ([view respondsToSelector:@selector(needReApplyLayoutAtNextRunloop)]) { - [view needReApplyLayoutAtNextRunloop]; - } - } + CGFloat constrainedSize, + CGFloat measuredSize, + YGMeasureMode measureMode +) { + CGFloat result; + if (measureMode == YGMeasureModeExactly) { + result = constrainedSize; + } else if (measureMode == YGMeasureModeAtMost) { + result = MIN(constrainedSize, measuredSize); + } else { + result = measuredSize; + } + + return result; +} + +/// 后台线程安全的 measure 函数。 +/// 从缓存侧表读取主线程预测量的尺寸,结合 Yoga 的 measureMode 约束返回最终值。 +/// 不访问任何 UIKit API,可安全在任意线程调用。 +static YGSize YGCachedMeasureView( + YGNodeConstRef node, + float width, + YGMeasureMode widthMode, + float height, + YGMeasureMode heightMode +) { + CGSize cachedSize = CGSizeZero; + if (!ZDMeasureCacheGet(node, &cachedSize)) { + return (YGSize){ .width = 0, .height = 0 }; + } + + const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; + const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; + + return (YGSize) { + .width = YGSanitizeMeasurement(constrainedWidth, cachedSize.width, widthMode), + .height = YGSanitizeMeasurement(constrainedHeight, cachedSize.height, heightMode), + }; +} + +static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view); + +static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews) { + + if (YGNodeGetChildCount(node) != subviews.count) { + return NO; + } + + for (int i = 0; i < subviews.count; i++) { + if (YGNodeGetChild(node, i) != subviews[i].flexLayout.node) { + return NO; + } + } + + return YES; +} + +static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyncMode) { + + ZDFlexLayoutCore *const yoga = view.flexLayout; + const YGNodeRef node = yoga.node; + + // Only leaf nodes should have a measure function + if (yoga.isLeaf) { + YGRemoveAllChildren(node); + + if (asyncMode) { + // In async mode, try to pre-measure the leaf node and set fixed sizes. + // If fully pre-measured, skip setting YGMeasureFunc so Yoga won't + // call back into UIKit during background calculation. + if (YGPreMeasureLeafNode(node, view)) { + YGNodeSetMeasureFunc(node, NULL); + } else if (!yoga.isUIView) { + // Non-UIView (ZDFlexLayoutDiv): sizeThatFits returns CGSizeZero, thread-safe + YGNodeSetMeasureFunc(node, YGMeasureView); + } else { + // UIView leaf that couldn't be pre-measured (no explicit size, not UILabel/UIImageView). + // Setting YGMeasureFunc would call sizeThatFits: on background thread → unsafe. + // Instead, leave measure func unset and let Yoga use default sizing. + YGNodeSetMeasureFunc(node, NULL); + } + } else { + YGNodeSetMeasureFunc(node, YGMeasureView); + } + } else { + YGNodeSetMeasureFunc(node, NULL); + + NSMutableArray *subviewsToInclude = [[NSMutableArray alloc] initWithCapacity:view.children.count]; + for (ZDFlexLayoutView subview in view.children) { + if (subview.flexLayout.isEnabled && subview.flexLayout.isIncludedInLayout) { + [subviewsToInclude addObject:subview]; + } + } + + if (!YGNodeHasExactSameChildren(node, subviewsToInclude)) { + YGRemoveAllChildren(node); + for (int i = 0; i < subviewsToInclude.count; i++) { + YGNodeInsertChild(node, subviewsToInclude[i].flexLayout.node, i); + } + } + + for (ZDFlexLayoutView const subview in subviewsToInclude) { + YGAttachNodesFromViewHierachy(subview, asyncMode); + } + } +} + +/// 主线程递归遍历视图树,预测量所有叶子节点的固有尺寸并存入缓存侧表。 +/// 每个叶子节点设置 YGCachedMeasureView 作为 measure 函数,确保后台计算时不回调 UIKit。 +/// 不修改 YGNode 的 style 属性(width/height),避免污染后续布局。 +static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view) { + + ZDFlexLayoutCore *const yoga = view.flexLayout; + const YGNodeRef node = yoga.node; + + if (yoga.isLeaf) { + YGRemoveAllChildren(node); + + YGValue nodeWidth = YGNodeStyleGetWidth(node); + YGValue nodeHeight = YGNodeStyleGetHeight(node); + BOOL hasExplicitWidth = (nodeWidth.unit == YGUnitPoint && !YGFloatIsUndefined(nodeWidth.value)); + BOOL hasExplicitHeight = (nodeHeight.unit == YGUnitPoint && !YGFloatIsUndefined(nodeHeight.value)); + + if (hasExplicitWidth && hasExplicitHeight) { + YGNodeSetMeasureFunc(node, NULL); + return; + } + + CGFloat constraintW = hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX; + CGFloat constraintH = hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX; + CGSize constrainedSize = CGSizeMake(constraintW, constraintH); + CGSize measuredSize = CGSizeZero; + + if (!yoga.isUIView) { + measuredSize = [view sizeThatFits:constrainedSize]; + } else { + UIView *uiView = (UIView *)view; + if ([uiView isKindOfClass:[UILabel class]]) { + measuredSize = YGMeasureLabelText((UILabel *)uiView, constrainedSize); + } else if ([uiView isKindOfClass:[UIImageView class]]) { + UIImage *image = ((UIImageView *)uiView).image; + measuredSize = image ? image.size : CGSizeZero; + } else { + measuredSize = [uiView sizeThatFits:constrainedSize]; + } + } + + ZDMeasureCacheSet(node, measuredSize); + YGNodeSetMeasureFunc(node, YGCachedMeasureView); + } else { + YGNodeSetMeasureFunc(node, NULL); + + NSMutableArray *subviewsToInclude = [[NSMutableArray alloc] initWithCapacity:view.children.count]; + for (ZDFlexLayoutView subview in view.children) { + if (subview.flexLayout.isEnabled && subview.flexLayout.isIncludedInLayout) { + [subviewsToInclude addObject:subview]; + } + } + + if (!YGNodeHasExactSameChildren(node, subviewsToInclude)) { + YGRemoveAllChildren(node); + for (int i = 0; i < subviewsToInclude.count; i++) { + YGNodeInsertChild(node, subviewsToInclude[i].flexLayout.node, i); + } + } + + for (ZDFlexLayoutView const subview in subviewsToInclude) { + YGPreMeasureAndCacheLeafNodes(subview); + } + } +} + +/// 后台布局完成后恢复所有叶子节点的 measure 函数为标准的 YGMeasureView, +/// 确保后续同步布局能正常调用 sizeThatFits: 进行测量。 +static void YGRestoreMeasureFuncs(ZDFlexLayoutView const view) { + + ZDFlexLayoutCore *const yoga = view.flexLayout; + const YGNodeRef node = yoga.node; + + if (yoga.isLeaf) { + YGNodeSetMeasureFunc(node, YGMeasureView); + } else { + for (ZDFlexLayoutView subview in view.children) { + if (subview.flexLayout.isEnabled && subview.flexLayout.isIncludedInLayout) { + YGRestoreMeasureFuncs(subview); + } + } + } +} + +static void YGRemoveAllChildren(const YGNodeRef node) { + + if (node == NULL) { + return; + } + + YGNodeRemoveAllChildren(node); +} + +static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOrigin) { + NSCAssert([NSThread isMainThread], @"Framesetting should only be done on the main thread."); + + const ZDFlexLayoutCore *yoga = view.flexLayout; + + if (!yoga.isEnabled || !yoga.isIncludedInLayout) { + return; + } + + YGNodeRef node = yoga.node; + const CGPoint topLeft = { + YGNodeLayoutGetLeft(node), + YGNodeLayoutGetTop(node), + }; + + const CGPoint bottomRight = { + topLeft.x + YGNodeLayoutGetWidth(node), + topLeft.y + YGNodeLayoutGetHeight(node), + }; + + const CGPoint origin = preserveOrigin ? view.layoutFrame.origin : CGPointZero; + view.layoutFrame = (CGRect) { + .origin = { + .x = ZDFLRoundPixelValue(topLeft.x + origin.x), + .y = ZDFLRoundPixelValue(topLeft.y + origin.y), + }, + .size = { + .width = ZDFLRoundPixelValue(bottomRight.x) - ZDFLRoundPixelValue(topLeft.x), + .height = ZDFLRoundPixelValue(bottomRight.y) - ZDFLRoundPixelValue(topLeft.y), + }, + }; + + if (!yoga.isLeaf) { + for (NSUInteger i = 0; i < view.children.count; i++) { + YGApplyLayoutToViewHierarchy(view.children[i], NO); + } + + if ([view respondsToSelector:@selector(needReApplyLayoutAtNextRunloop)]) { + [view needReApplyLayoutAtNextRunloop]; + } + } } @end @@ -692,32 +846,28 @@ static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOri //-------------------------- Function ------------------------ #pragma mark - -CGFloat ZDFLScreenScale(void) -{ - static CGFloat scale = 0.0; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - UIGraphicsBeginImageContextWithOptions(CGSizeMake(1, 1), YES, 0); - scale = CGContextGetCTM(UIGraphicsGetCurrentContext()).a; - UIGraphicsEndImageContext(); - }); - return scale; +CGFloat ZDFLScreenScale(void) { + static CGFloat scale = 0.0; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + UIGraphicsBeginImageContextWithOptions(CGSizeMake(1, 1), YES, 0); + scale = CGContextGetCTM(UIGraphicsGetCurrentContext()).a; + UIGraphicsEndImageContext(); + }); + return scale; } -CGFloat ZDFLRoundPixelValue(CGFloat value) -{ - CGFloat scale = ZDFLScreenScale(); - return roundf(value * scale) / scale; +CGFloat ZDFLRoundPixelValue(CGFloat value) { + CGFloat scale = ZDFLScreenScale(); + return roundf(value * scale) / scale; } -CGFloat ZDFLCeilPixelValue(CGFloat value) -{ - CGFloat scale = ZDFLScreenScale(); - return ceil((value - FLT_EPSILON) * scale) / scale; +CGFloat ZDFLCeilPixelValue(CGFloat value) { + CGFloat scale = ZDFLScreenScale(); + return ceil((value - FLT_EPSILON) * scale) / scale; } -CGFloat ZDFLFloorPixelValue(CGFloat f) -{ - CGFloat scale = ZDFLScreenScale(); - return floor((f + FLT_EPSILON) * scale) / scale; +CGFloat ZDFLFloorPixelValue(CGFloat f) { + CGFloat scale = ZDFLScreenScale(); + return floor((f + FLT_EPSILON) * scale) / scale; } From 550394abb34067b9374e1b36e329b2b49418e393 Mon Sep 17 00:00:00 2001 From: faimin Date: Thu, 21 May 2026 20:37:45 +0800 Subject: [PATCH 04/14] =?UTF-8?q?chore=EF=BC=9Aupdate=20test=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Demo/Demo.xcodeproj/project.pbxproj | 6 + .../Sample/LayoutDemo/AsyncLayoutController.h | 17 ++ .../Sample/LayoutDemo/AsyncLayoutController.m | 261 ++++++++++++++++++ Demo/Demo/ViewController.m | 17 +- 4 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.h create mode 100644 Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 9de6001..71e1348 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ FA843042234F4234000F7E35 /* DemoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FA843041234F4234000F7E35 /* DemoTests.m */; }; FA84304D234F4234000F7E35 /* DemoUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = FA84304C234F4234000F7E35 /* DemoUITests.m */; }; FA90B083237D4D2600DBDB77 /* NormalLayoutController.m in Sources */ = {isa = PBXBuildFile; fileRef = FA90B082237D4D2600DBDB77 /* NormalLayoutController.m */; }; + FA90B0A0237D4D2600DBDB77 /* AsyncLayoutController.m in Sources */ = {isa = PBXBuildFile; fileRef = FA90B09F237D4D2600DBDB77 /* AsyncLayoutController.m */; }; FA90B086237D4D3E00DBDB77 /* ScrollViewLayoutController.m in Sources */ = {isa = PBXBuildFile; fileRef = FA90B085237D4D3E00DBDB77 /* ScrollViewLayoutController.m */; }; FACF10A425B70AEC00C91DA3 /* Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF10A325B70AEC00C91DA3 /* Empty.swift */; }; FAEA4A0F235F0F6000422C35 /* same_city_users.json in Resources */ = {isa = PBXBuildFile; fileRef = FAEA4A0E235F0F6000422C35 /* same_city_users.json */; }; @@ -100,6 +101,8 @@ FA84304E234F4234000F7E35 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FA90B081237D4D2600DBDB77 /* NormalLayoutController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NormalLayoutController.h; sourceTree = ""; }; FA90B082237D4D2600DBDB77 /* NormalLayoutController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NormalLayoutController.m; sourceTree = ""; }; + FA90B09E237D4D2600DBDB77 /* AsyncLayoutController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AsyncLayoutController.h; sourceTree = ""; }; + FA90B09F237D4D2600DBDB77 /* AsyncLayoutController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AsyncLayoutController.m; sourceTree = ""; }; FA90B084237D4D3E00DBDB77 /* ScrollViewLayoutController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ScrollViewLayoutController.h; sourceTree = ""; }; FA90B085237D4D3E00DBDB77 /* ScrollViewLayoutController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ScrollViewLayoutController.m; sourceTree = ""; }; FACF10A325B70AEC00C91DA3 /* Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Empty.swift; sourceTree = ""; }; @@ -266,6 +269,8 @@ FA90B082237D4D2600DBDB77 /* NormalLayoutController.m */, FA90B084237D4D3E00DBDB77 /* ScrollViewLayoutController.h */, FA90B085237D4D3E00DBDB77 /* ScrollViewLayoutController.m */, + FA90B09E237D4D2600DBDB77 /* AsyncLayoutController.h */, + FA90B09F237D4D2600DBDB77 /* AsyncLayoutController.m */, ); path = LayoutDemo; sourceTree = ""; @@ -557,6 +562,7 @@ FAEA4A13235F0FD400422C35 /* SameCityUserListController.m in Sources */, FAEA4A16235F0FF600422C35 /* SameCityUserListViewModel.m in Sources */, FA90B083237D4D2600DBDB77 /* NormalLayoutController.m in Sources */, + FA90B0A0237D4D2600DBDB77 /* AsyncLayoutController.m in Sources */, FAEA4A19235F104A00422C35 /* UserModel.m in Sources */, FA617C0C23572AA100477C31 /* ZDFlexCell.m in Sources */, FA1A54DF2355ECE400C80712 /* YogaKitListViewModel.m in Sources */, diff --git a/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.h b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.h new file mode 100644 index 0000000..7df9f9c --- /dev/null +++ b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.h @@ -0,0 +1,17 @@ +// +// AsyncLayoutController.h +// Demo +// +// Created by Zero.D.Saber on 2024/12/1. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Demonstrates ZDFlexLayoutAsyncMode: Sync, RunLoop Idle, and BackgroundThread modes. +@interface AsyncLayoutController : UIViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m new file mode 100644 index 0000000..c5b8bbc --- /dev/null +++ b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m @@ -0,0 +1,261 @@ +// +// AsyncLayoutController.m +// Demo +// +// Created by Zero.D.Saber on 2024/12/1. +// + +#import "AsyncLayoutController.h" +@import ZDFlexLayoutKit; + +@interface AsyncLayoutController () + +@property (nonatomic, strong) UIView *syncContainer; +@property (nonatomic, strong) UIView *idleContainer; +@property (nonatomic, strong) UIView *asyncContainer; +@property (nonatomic, strong) UISegmentedControl *modeSegment; + +@end + +@implementation AsyncLayoutController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = @"Async Layout Demo"; + self.view.backgroundColor = UIColor.whiteColor; + + [self setupModeSelector]; + [self buildLayoutWithMode:ZDFlexLayoutAsyncModeSync]; +} + +#pragma mark - Mode Selector + +- (void)setupModeSelector { + self.modeSegment = [[UISegmentedControl alloc] initWithItems:@[@"Sync", @"RunLoop Idle", @"Background Thread"]]; + self.modeSegment.selectedSegmentIndex = 0; + [self.modeSegment addTarget:self action:@selector(modeChanged:) forControlEvents:UIControlEventValueChanged]; + self.modeSegment.frame = CGRectMake(20, 100, self.view.bounds.size.width - 40, 36); + [self.view addSubview:self.modeSegment]; +} + +- (void)modeChanged:(UISegmentedControl *)sender { + [self clearLayout]; + ZDFlexLayoutAsyncMode mode; + switch (sender.selectedSegmentIndex) { + case 0: mode = ZDFlexLayoutAsyncModeSync; break; + case 1: mode = ZDFlexLayoutAsyncModeRunloopIdle; break; + case 2: mode = ZDFlexLayoutAsyncModeBackgroundThread; break; + default: mode = ZDFlexLayoutAsyncModeSync; break; + } + [self buildLayoutWithMode:mode]; +} + +#pragma mark - Layout Construction + +- (void)clearLayout { + [self.syncContainer removeFromSuperview]; + [self.idleContainer removeFromSuperview]; + [self.asyncContainer removeFromSuperview]; + self.syncContainer = nil; + self.idleContainer = nil; + self.asyncContainer = nil; +} + +- (void)buildLayoutWithMode:(ZDFlexLayoutAsyncMode)mode { + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 160, self.view.bounds.size.width, self.view.bounds.size.height - 160)]; + container.backgroundColor = UIColor.systemGroupedBackgroundColor; + [self.view addSubview:container]; + + // Root flex container + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.padding(YGPointValue(16)); + }]; + + // Title label + UILabel *titleLabel = [self makeLabelWithText:[self titleForMode:mode] fontSize:18 color:UIColor.blackColor]; + [titleLabel zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.marginBottom(YGPointValue(12)); + }]; + [container addChild:titleLabel]; + + // Card 1: UILabel measurement + UIView *card1 = [self makeCardWithTitle:@"UILabel (text measurement)" + content:@"This label demonstrates that text content is correctly measured in async mode without accessing UIKit on the background thread."]; + [container addChild:card1]; + + // Card 2: UIImageView measurement + UIView *card2 = [self makeImageCard]; + [container addChild:card2]; + + // Card 3: Custom UIView with sizeThatFits + UIView *card3 = [self makeCustomViewCard]; + [container addChild:card3]; + + // Card 4: Mixed layout with flexGrow + UIView *card4 = [self makeFlexGrowCard]; + [container addChild:card4]; + + // Apply layout with selected mode + [container.flexLayout applyLayoutWithAsyncMode:mode + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + switch (mode) { + case ZDFlexLayoutAsyncModeSync: + self.syncContainer = container; + break; + case ZDFlexLayoutAsyncModeRunloopIdle: + self.idleContainer = container; + break; + case ZDFlexLayoutAsyncModeBackgroundThread: + self.asyncContainer = container; + break; + } +} + +#pragma mark - Card Builders + +- (UIView *)makeCardWithTitle:(NSString *)title content:(NSString *)content { + UIView *card = UIView.new; + card.backgroundColor = UIColor.whiteColor; + card.layer.cornerRadius = 8; + card.clipsToBounds = YES; + [card zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.padding(YGPointValue(12)); + make.marginBottom(YGPointValue(12)); + }]; + + UILabel *titleLabel = [self makeLabelWithText:title fontSize:14 color:UIColor.darkGrayColor]; + [titleLabel zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.marginBottom(YGPointValue(6)); + }]; + [card addChild:titleLabel]; + + UILabel *contentLabel = [self makeLabelWithText:content fontSize:13 color:UIColor.grayColor]; + contentLabel.numberOfLines = 0; + [contentLabel zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [card addChild:contentLabel]; + + return card; +} + +- (UIView *)makeImageCard { + UIView *card = UIView.new; + card.backgroundColor = UIColor.whiteColor; + card.layer.cornerRadius = 8; + card.clipsToBounds = YES; + [card zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + make.alignItems(YGAlignCenter); + make.padding(YGPointValue(12)); + make.marginBottom(YGPointValue(12)); + }]; + + UIImageView *imageView = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"photo.fill"]]; + imageView.tintColor = UIColor.systemBlueColor; + imageView.contentMode = UIViewContentModeScaleAspectFit; + [imageView zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(40)).height(YGPointValue(40)); + make.marginRight(YGPointValue(12)); + }]; + [card addChild:imageView]; + + UILabel *label = [self makeLabelWithText:@"UIImageView with explicit size (40x40)" fontSize:13 color:UIColor.darkGrayColor]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexShrink(1); + }]; + [card addChild:label]; + + return card; +} + +- (UIView *)makeCustomViewCard { + UIView *card = UIView.new; + card.backgroundColor = UIColor.whiteColor; + card.layer.cornerRadius = 8; + card.clipsToBounds = YES; + [card zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.padding(YGPointValue(12)); + make.marginBottom(YGPointValue(12)); + }]; + + UILabel *titleLabel = [self makeLabelWithText:@"Custom views with sizeThatFits:" fontSize:14 color:UIColor.darkGrayColor]; + [titleLabel zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.marginBottom(YGPointValue(8)); + }]; + [card addChild:titleLabel]; + + UISwitch *toggle = UISwitch.new; + [toggle zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [card addChild:toggle]; + + return card; +} + +- (UIView *)makeFlexGrowCard { + UIView *card = UIView.new; + card.backgroundColor = UIColor.whiteColor; + card.layer.cornerRadius = 8; + card.clipsToBounds = YES; + [card zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + make.padding(YGPointValue(8)); + make.marginBottom(YGPointValue(12)); + make.height(YGPointValue(60)); + }]; + + NSArray *colors = @[UIColor.systemRedColor, UIColor.systemGreenColor, UIColor.systemBlueColor]; + NSArray *grows = @[@1, @2, @1]; + + for (NSInteger i = 0; i < 3; i++) { + UIView *bar = UIView.new; + bar.backgroundColor = colors[i]; + bar.layer.cornerRadius = 4; + CGFloat grow = [grows[i] floatValue]; + [bar zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(grow); + make.marginHorizontal(YGPointValue(4)); + }]; + [card addChild:bar]; + } + + return card; +} + +#pragma mark - Helpers + +- (UILabel *)makeLabelWithText:(NSString *)text fontSize:(CGFloat)size color:(UIColor *)color { + UILabel *label = UILabel.new; + label.text = text; + label.font = [UIFont systemFontOfSize:size]; + label.textColor = color; + label.numberOfLines = 0; + return label; +} + +- (NSString *)titleForMode:(ZDFlexLayoutAsyncMode)mode { + switch (mode) { + case ZDFlexLayoutAsyncModeSync: return @"Mode: Sync (immediate)"; + case ZDFlexLayoutAsyncModeRunloopIdle: return @"Mode: RunLoop Idle"; + case ZDFlexLayoutAsyncModeBackgroundThread: return @"Mode: Background Thread"; + } +} + +@end diff --git a/Demo/Demo/ViewController.m b/Demo/Demo/ViewController.m index e05a784..b515ddc 100644 --- a/Demo/Demo/ViewController.m +++ b/Demo/Demo/ViewController.m @@ -7,6 +7,7 @@ // #import "ViewController.h" +#import "AsyncLayoutController.h" //#import @import ZDFlexLayoutKit; @@ -18,7 +19,21 @@ @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; - // Do any additional setup after loading the view. + + UIButton *asyncBtn = [UIButton buttonWithType:UIButtonTypeSystem]; + asyncBtn.frame = CGRectMake(43, 430, 343, 50); + asyncBtn.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin; + asyncBtn.backgroundColor = UIColor.systemGreenColor; + asyncBtn.titleLabel.font = [UIFont systemFontOfSize:18]; + [asyncBtn setTitle:@"AsyncLayout" forState:UIControlStateNormal]; + [asyncBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal]; + [asyncBtn addTarget:self action:@selector(pushAsyncLayout) forControlEvents:UIControlEventTouchUpInside]; + [self.view addSubview:asyncBtn]; +} + +- (void)pushAsyncLayout { + AsyncLayoutController *vc = [[AsyncLayoutController alloc] init]; + [self.navigationController pushViewController:vc animated:YES]; } @end From ae835ad52b3f543c416fbe8a3c94815b5289d5cd Mon Sep 17 00:00:00 2001 From: faimin Date: Thu, 21 May 2026 20:49:04 +0800 Subject: [PATCH 05/14] chore: add asyncLayoutTests --- Demo/Demo.xcodeproj/project.pbxproj | 4 + Demo/DemoTests/AsyncLayoutTests.m | 623 ++++++++++++++++++++++++++++ 2 files changed, 627 insertions(+) create mode 100644 Demo/DemoTests/AsyncLayoutTests.m diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 71e1348..7e2a81e 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ FA843035234F4234000F7E35 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FA843033234F4234000F7E35 /* LaunchScreen.storyboard */; }; FA843038234F4234000F7E35 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = FA843037234F4234000F7E35 /* main.m */; }; FA843042234F4234000F7E35 /* DemoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FA843041234F4234000F7E35 /* DemoTests.m */; }; + FA843042234F4234000F7E36 /* AsyncLayoutTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FA843041234F4234000F7E36 /* AsyncLayoutTests.m */; }; FA84304D234F4234000F7E35 /* DemoUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = FA84304C234F4234000F7E35 /* DemoUITests.m */; }; FA90B083237D4D2600DBDB77 /* NormalLayoutController.m in Sources */ = {isa = PBXBuildFile; fileRef = FA90B082237D4D2600DBDB77 /* NormalLayoutController.m */; }; FA90B0A0237D4D2600DBDB77 /* AsyncLayoutController.m in Sources */ = {isa = PBXBuildFile; fileRef = FA90B09F237D4D2600DBDB77 /* AsyncLayoutController.m */; }; @@ -95,6 +96,7 @@ FA843037234F4234000F7E35 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; FA84303D234F4234000F7E35 /* DemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FA843041234F4234000F7E35 /* DemoTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DemoTests.m; sourceTree = ""; }; + FA843041234F4234000F7E36 /* AsyncLayoutTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AsyncLayoutTests.m; sourceTree = ""; }; FA843043234F4234000F7E35 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FA843048234F4234000F7E35 /* DemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FA84304C234F4234000F7E35 /* DemoUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DemoUITests.m; sourceTree = ""; }; @@ -248,6 +250,7 @@ isa = PBXGroup; children = ( FA843041234F4234000F7E35 /* DemoTests.m */, + FA843041234F4234000F7E36 /* AsyncLayoutTests.m */, FA843043234F4234000F7E35 /* Info.plist */, ); path = DemoTests; @@ -581,6 +584,7 @@ buildActionMask = 2147483647; files = ( FA843042234F4234000F7E35 /* DemoTests.m in Sources */, + FA843042234F4234000F7E36 /* AsyncLayoutTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Demo/DemoTests/AsyncLayoutTests.m b/Demo/DemoTests/AsyncLayoutTests.m new file mode 100644 index 0000000..a39c8b5 --- /dev/null +++ b/Demo/DemoTests/AsyncLayoutTests.m @@ -0,0 +1,623 @@ +// +// AsyncLayoutTests.m +// DemoTests +// +// Created by Zero.D.Saber on 2024/12/1. +// + +#import +#import "ZDFlexLayoutKit.h" + +@interface AsyncLayoutTests : XCTestCase + +@property (nonatomic, strong) UIView *rootView; + +@end + +@implementation AsyncLayoutTests + +- (void)setUp { + [super setUp]; + self.rootView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; +} + +- (void)tearDown { + self.rootView = nil; + [super tearDown]; +} + +#pragma mark - Sync Mode Tests + +- (void)testSyncMode_BasicLayout { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(100)).height(YGPointValue(50)); + }]; + [container addChild:child]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTAssertEqual(child.frame.size.width, 100); + XCTAssertEqual(child.frame.size.height, 50); + XCTAssertEqual(child.frame.origin.x, 0); + XCTAssertEqual(child.frame.origin.y, 0); +} + +- (void)testSyncMode_FlexGrow { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + }]; + + UIView *child1 = UIView.new; + [child1 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(1); + }]; + [container addChild:child1]; + + UIView *child2 = UIView.new; + [child2 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(2); + }]; + [container addChild:child2]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTAssertEqual(child1.frame.size.width, 125); + XCTAssertEqual(child2.frame.size.width, 250); +} + +- (void)testSyncMode_LabelMeasurement { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UILabel *label = UILabel.new; + label.text = @"Hello World"; + label.font = [UIFont systemFontOfSize:16]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:label]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTAssertGreaterThan(label.frame.size.width, 0); + XCTAssertGreaterThan(label.frame.size.height, 0); +} + +#pragma mark - RunLoop Idle Mode Tests + +- (void)testRunloopIdleMode_BasicLayout { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(200)).height(YGPointValue(100)); + }]; + [container addChild:child]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeRunloopIdle + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + // RunLoop idle: layout is deferred. Run the runloop to trigger execution. + XCTestExpectation *expectation = [self expectationWithDescription:@"RunLoop idle layout applied"]; + dispatch_async(dispatch_get_main_queue(), ^{ + // After one runloop cycle, the observer should have fired + dispatch_async(dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + }); + + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child.frame.size.width, 200); + XCTAssertEqual(child.frame.size.height, 100); +} + +- (void)testRunloopIdleMode_MultipleChildren { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child1 = UIView.new; + [child1 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(375)).height(YGPointValue(44)); + }]; + [container addChild:child1]; + + UIView *child2 = UIView.new; + [child2 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(375)).height(YGPointValue(88)); + }]; + [container addChild:child2]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeRunloopIdle + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"RunLoop idle multi-child"]; + dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child1.frame.origin.y, 0); + XCTAssertEqual(child1.frame.size.height, 44); + XCTAssertEqual(child2.frame.origin.y, 44); + XCTAssertEqual(child2.frame.size.height, 88); +} + +#pragma mark - Background Thread Mode Tests + +- (void)testBackgroundThreadMode_BasicLayout { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(100)).height(YGPointValue(50)); + }]; + [container addChild:child]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background thread layout"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child.frame.size.width, 100); + XCTAssertEqual(child.frame.size.height, 50); +} + +- (void)testBackgroundThreadMode_LabelMeasurement { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.width(YGPointValue(375)); + }]; + + UILabel *label = UILabel.new; + label.text = @"Async layout test label"; + label.font = [UIFont systemFontOfSize:16]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:label]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background label measurement"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertGreaterThan(label.frame.size.width, 0); + XCTAssertGreaterThan(label.frame.size.height, 0); +} + +- (void)testBackgroundThreadMode_ImageViewMeasurement { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIGraphicsBeginImageContext(CGSizeMake(60, 40)); + UIImage *testImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + UIImageView *imageView = [[UIImageView alloc] initWithImage:testImage]; + [imageView zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:imageView]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background imageview measurement"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(imageView.frame.size.width, 60); + XCTAssertEqual(imageView.frame.size.height, 40); +} + +- (void)testBackgroundThreadMode_CustomViewSizeThatFits { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UISwitch *toggle = UISwitch.new; + [toggle zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:toggle]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background custom view"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertGreaterThan(toggle.frame.size.width, 0); + XCTAssertGreaterThan(toggle.frame.size.height, 0); +} + +- (void)testBackgroundThreadMode_FlexGrow { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + }]; + + UIView *child1 = UIView.new; + [child1 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(1).height(YGPointValue(50)); + }]; + [container addChild:child1]; + + UIView *child2 = UIView.new; + [child2 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(2).height(YGPointValue(50)); + }]; + [container addChild:child2]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background flexGrow"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child1.frame.size.width, 125); + XCTAssertEqual(child2.frame.size.width, 250); +} + +#pragma mark - Consistency Tests (Sync vs Async produce same results) + +- (void)testConsistency_SyncAndBackgroundProduceSameFrames { + // Build identical layout trees and compare results + UIView *(^buildTree)(void) = ^{ + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.padding(YGPointValue(16)); + }]; + + UILabel *label = UILabel.new; + label.text = @"Test consistency"; + label.font = [UIFont systemFontOfSize:14]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.marginBottom(YGPointValue(8)); + }]; + [container addChild:label]; + + UIView *row = UIView.new; + [row zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + make.height(YGPointValue(80)); + }]; + [container addChild:row]; + + UIView *red = UIView.new; + [red zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexGrow(1); + }]; + [row addChild:red]; + + UIView *blue = UIView.new; + [blue zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexGrow(2); + }]; + [row addChild:blue]; + + return container; + }; + + // Sync calculation + UIView *syncContainer = buildTree(); + [syncContainer.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + // Async calculation + UIView *asyncContainer = buildTree(); + [asyncContainer.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Async done"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + // Compare: label frames + UILabel *syncLabel = (UILabel *)syncContainer.children.firstObject; + UILabel *asyncLabel = (UILabel *)asyncContainer.children.firstObject; + XCTAssertTrue(CGRectEqualToRect(syncLabel.frame, asyncLabel.frame), + @"Label frames differ: sync=%@ async=%@", + NSStringFromCGRect(syncLabel.frame), NSStringFromCGRect(asyncLabel.frame)); + + // Compare: row container frames + UIView *syncRow = (UIView *)syncContainer.children[1]; + UIView *asyncRow = (UIView *)asyncContainer.children[1]; + XCTAssertTrue(CGRectEqualToRect(syncRow.frame, asyncRow.frame), + @"Row frames differ: sync=%@ async=%@", + NSStringFromCGRect(syncRow.frame), NSStringFromCGRect(asyncRow.frame)); +} + +#pragma mark - Style Pollution Tests + +- (void)testBackgroundThreadMode_DoesNotPollutNodeStyle { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UILabel *label = UILabel.new; + label.text = @"Style pollution test"; + label.font = [UIFont systemFontOfSize:16]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:label]; + + // Record original style + YGValue widthBefore = label.flexLayout.width; + YGValue heightBefore = label.flexLayout.height; + + // Run background layout + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Style pollution check"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + // Verify style is NOT polluted + YGValue widthAfter = label.flexLayout.width; + YGValue heightAfter = label.flexLayout.height; + + XCTAssertEqual(widthBefore.unit, widthAfter.unit, @"Width unit changed after async layout"); + XCTAssertEqual(widthBefore.value, widthAfter.value, @"Width value changed after async layout"); + XCTAssertEqual(heightBefore.unit, heightAfter.unit, @"Height unit changed after async layout"); + XCTAssertEqual(heightBefore.value, heightAfter.value, @"Height value changed after async layout"); +} + +- (void)testBackgroundThreadMode_SubsequentSyncLayoutWorks { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UILabel *label = UILabel.new; + label.text = @"First pass"; + label.font = [UIFont systemFontOfSize:16]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:label]; + + // First: background async + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation1 = [self expectationWithDescription:@"First async done"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation1 fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + CGRect frameAfterAsync = label.frame; + XCTAssertGreaterThan(frameAfterAsync.size.width, 0); + + // Second: change text and recalculate synchronously + label.text = @"Second pass with longer text content here"; + [label.flexLayout markDirty]; + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + CGRect frameAfterSync = label.frame; + XCTAssertGreaterThan(frameAfterSync.size.width, frameAfterAsync.size.width, + @"Sync layout after async should reflect new longer text"); +} + +#pragma mark - Legacy Mode Tests + +- (void)testLegacyPreMeasureMode_BasicLayout { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(100)).height(YGPointValue(50)); + }]; + [container addChild:child]; + + container.flexLayout.useLegacyPreMeasure = YES; + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Legacy mode layout"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child.frame.size.width, 100); + XCTAssertEqual(child.frame.size.height, 50); +} + +#pragma mark - Virtual View (ZDFlexLayoutDiv) Tests + +- (void)testBackgroundThreadMode_VirtualView { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + ZDFlexLayoutDiv *div = ZDFlexLayoutDiv.new; + [div zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + make.height(YGPointValue(60)); + }]; + [container addChild:div]; + + UIView *left = UIView.new; + [left zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(100)); + }]; + [div addChild:left]; + + UIView *right = UIView.new; + [right zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexGrow(1); + }]; + [div addChild:right]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background virtual view"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(left.frame.size.width, 100); + XCTAssertEqual(left.frame.size.height, 60); + XCTAssertEqual(right.frame.size.width, 275); + XCTAssertEqual(right.frame.size.height, 60); +} + +#pragma mark - Performance Tests + +- (void)testPerformance_SyncMode { + [self measureBlock:^{ + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexDirection(YGFlexDirectionColumn); + }]; + + for (int i = 0; i < 100; i++) { + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.height(YGPointValue(44)); + }]; + [container addChild:child]; + } + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + }]; +} + +- (void)testPerformance_BackgroundThreadMode { + [self measureBlock:^{ + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexDirection(YGFlexDirectionColumn); + }]; + + for (int i = 0; i < 100; i++) { + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.height(YGPointValue(44)); + }]; + [container addChild:child]; + } + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"perf"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + }]; +} + +@end From eab59e9f22f6ebd313b207f102c5bead16a32302 Mon Sep 17 00:00:00 2001 From: faimin Date: Fri, 22 May 2026 12:22:23 +0800 Subject: [PATCH 06/14] chore: ignore .tmp --- .gitignore | 1 + .tmp/TemplateX | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 160000 .tmp/TemplateX diff --git a/.gitignore b/.gitignore index 157646e..8a0669b 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ fastlane/test_output iOSInjectionProject/ .build +.tmp diff --git a/.tmp/TemplateX b/.tmp/TemplateX deleted file mode 160000 index 8ce9ecb..0000000 --- a/.tmp/TemplateX +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8ce9ecb700e581bfbfa74cabd13b50c6d821ef7f From 0af72730ef662c55adb284b68bf6d9fa09d78f98 Mon Sep 17 00:00:00 2001 From: faimin Date: Fri, 22 May 2026 14:05:12 +0800 Subject: [PATCH 07/14] fix: use TextKit for text measurement in background thread mode - Replace boundingRectWithSize: with NSTextStorage+NSLayoutManager+NSTextContainer for accurate multi-line text height calculation (borrowed from React Native) - Fix !yoga.isUIView condition (isMemberOfClass: misroutes UILabel subclasses) - Unify text measurement into ZDMeasureText() + ZDTextStorageFromLabel() - Simplify cache to use NSMutableDictionary instead of CFMutableDictionary Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG | 2 + .../Sample/LayoutDemo/AsyncLayoutController.m | 2 +- README.md | 4 +- Sources/Core/Public/ZDFlexLayoutCore.m | 177 +++++++++--------- 4 files changed, 98 insertions(+), 87 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e22e90e..cff5987 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,8 @@ 3. 新增 `applyLayoutWithAsyncMode:preservingOrigin:dimensionFlexibility:` 统一异步布局 API 4. 新增 `useLegacyPreMeasure` 属性,可切换回旧版预测量实现作为备用 5. 修复 RunLoop Idle 模式下 weakSelf 未做 nil 判断的问题 +6. 文本测量统一使用 TextKit(`NSTextStorage + NSLayoutManager + NSTextContainer`),支持多行文本在后台线程正确计算高度 +7. 修复 `isUIView`(`isMemberOfClass:`)导致 UILabel 等子类走错测量分支的问题 ## 0.4.0 diff --git a/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m index c5b8bbc..2502330 100644 --- a/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m +++ b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m @@ -34,7 +34,7 @@ - (void)setupModeSelector { self.modeSegment = [[UISegmentedControl alloc] initWithItems:@[@"Sync", @"RunLoop Idle", @"Background Thread"]]; self.modeSegment.selectedSegmentIndex = 0; [self.modeSegment addTarget:self action:@selector(modeChanged:) forControlEvents:UIControlEventValueChanged]; - self.modeSegment.frame = CGRectMake(20, 100, self.view.bounds.size.width - 40, 36); + self.modeSegment.frame = CGRectMake(20, 120, self.view.bounds.size.width - 40, 36); [self.view addSubview:self.modeSegment]; } diff --git a/README.md b/README.md index ecce632..84ea9ef 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ 采用"主线程预测量 + 缓存侧表 + 线程安全 measure func"三阶段方案,借鉴 ReactNative 的设计思路: -1. **Phase 1(主线程)**:遍历叶子节点,调用 `sizeThatFits:` 等 UIKit API 获取固有尺寸,结果存入缓存侧表(不修改 YGNode style) -2. **Phase 2(后台线程)**:纯数值 Yoga 计算,measure 回调从缓存读取预测量结果,不访问 UIKit +1. **Phase 1(主线程)**:遍历叶子节点,捕获测量所需信息存入缓存侧表(文本节点捕获 `NSTextStorage`,其他节点调用 `sizeThatFits:`),不修改 YGNode style +2. **Phase 2(后台线程)**:Yoga 计算,文本节点使用 TextKit(`NSLayoutManager + NSTextContainer`)在 Yoga 提供的真实宽度约束下测量,其他节点返回预存尺寸 3. **Phase 3(主线程)**:应用 frame、恢复 measure 函数、清除缓存 ### 使用示例 diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index 51eb4b6..c1601b4 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -430,81 +430,73 @@ - (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode { } #pragma mark - Measure Cache Side Table -// 缓存侧表:用于后台线程布局计算时存储预测量结果。 -// 写入发生在主线程(Phase 1),读取发生在后台线程(Phase 2),清除发生在主线程(Phase 3)。 -// GCD dispatch 提供 memory barrier,无需额外加锁。 +// 缓存侧表:主线程写入(Phase 1) → 后台线程只读(Phase 2) → 主线程清除(Phase 3)。 +// key: NSValue(pointer:YGNodeRef), value: NSValue(CGSize) 或 NSTextStorage(文本节点) -static CFMutableDictionaryRef _zd_measureCache = NULL; +static NSMutableDictionary *_zd_measureCache = nil; static void ZDMeasureCacheCreate(void) { - if (_zd_measureCache) { - CFDictionaryRemoveAllValues(_zd_measureCache); - return; - } - _zd_measureCache = CFDictionaryCreateMutable( - kCFAllocatorDefault, 0, NULL, NULL - ); + if (_zd_measureCache) { + [_zd_measureCache removeAllObjects]; + } else { + _zd_measureCache = [NSMutableDictionary dictionary]; + } } -static void ZDMeasureCacheSet(YGNodeRef node, CGSize size) { - if (!_zd_measureCache) return; - CGSize *existing = (CGSize *)CFDictionaryGetValue(_zd_measureCache, node); - if (existing) { - *existing = size; - } else { - CGSize *entry = (CGSize *)malloc(sizeof(CGSize)); - *entry = size; - CFDictionarySetValue(_zd_measureCache, node, entry); - } +static NSValue *ZDNodeKey(YGNodeConstRef node) { + return [NSValue valueWithPointer:node]; } -static BOOL ZDMeasureCacheGet(YGNodeConstRef node, CGSize *outSize) { - if (!_zd_measureCache) return NO; - CGSize *entry = (CGSize *)CFDictionaryGetValue(_zd_measureCache, node); - if (!entry) return NO; - *outSize = *entry; - return YES; +static void ZDMeasureCacheSetSize(YGNodeRef node, CGSize size) { + if (!_zd_measureCache) return; + _zd_measureCache[ZDNodeKey(node)] = [NSValue valueWithCGSize:size]; +} + +static void ZDMeasureCacheSetTextStorage(YGNodeRef node, NSTextStorage *textStorage, NSInteger numberOfLines) { + if (!_zd_measureCache || !textStorage) return; + _zd_measureCache[ZDNodeKey(node)] = @{@"storage": textStorage, @"lines": @(numberOfLines)}; } static void ZDMeasureCacheClear(void) { - if (!_zd_measureCache) return; - CFIndex count = CFDictionaryGetCount(_zd_measureCache); - if (count > 0) { - const void **values = (const void **)malloc(sizeof(void *) * (size_t)count); - CFDictionaryGetKeysAndValues(_zd_measureCache, NULL, values); - for (CFIndex i = 0; i < count; i++) { - free((void *)values[i]); - } - free(values); - } - CFDictionaryRemoveAllValues(_zd_measureCache); + [_zd_measureCache removeAllObjects]; } -#pragma mark - Private +/// 使用 TextKit 测量文本(线程安全),借鉴 React Native 的实现方式。 +/// 支持 NSTextStorage 直接传入(后台线程),或从 UILabel 提取文本信息(主线程)。 +static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, CGSize constraintSize) { + if (!textStorage || textStorage.length == 0) return CGSizeZero; -// Thread-safe text measurement using NSAttributedString boundingRect (no UIKit dependency) -static CGSize YGMeasureLabelText(UILabel *label, CGSize constrainedSize) { - - NSAttributedString *attributedText = label.attributedText; - if (attributedText.length > 0) { - CGRect rect = [attributedText boundingRectWithSize:constrainedSize - options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - context:nil]; - return CGSizeMake(ceil(rect.size.width), ceil(rect.size.height)); - } - - NSString *text = label.text; - if (text.length > 0) { - CGRect rect = [text boundingRectWithSize:constrainedSize - options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - attributes:label.font ? @{NSFontAttributeName: label.font} : nil - context:nil]; - return CGSizeMake(ceil(rect.size.width), ceil(rect.size.height)); - } - - return CGSizeZero; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:constraintSize]; + textContainer.lineFragmentPadding = 0; + textContainer.maximumNumberOfLines = numberOfLines; + textContainer.lineBreakMode = NSLineBreakByWordWrapping; + + [layoutManager addTextContainer:textContainer]; + [textStorage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:textContainer]; + + CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; + [textStorage removeLayoutManager:layoutManager]; + + return CGSizeMake(ceil(usedRect.size.width), ceil(usedRect.size.height)); +} + +/// 从 UILabel 构造 NSTextStorage(主线程调用,捕获文本快照) +static NSTextStorage *ZDTextStorageFromLabel(UILabel *label) { + NSAttributedString *attrText = label.attributedText; + if (attrText.length > 0) { + return [[NSTextStorage alloc] initWithAttributedString:attrText]; + } + if (label.text.length > 0) { + NSDictionary *attrs = label.font ? @{NSFontAttributeName: label.font} : @{}; + return [[NSTextStorage alloc] initWithString:label.text attributes:attrs]; + } + return nil; } +#pragma mark - Private + // Pre-measure a leaf node and set its size as fixed width/height on the Yoga node. // This allows us to skip setting YGMeasureFunc and run Yoga calculation on a background thread. // Returns YES if the node was fully pre-measured (no measure func needed). @@ -530,11 +522,13 @@ static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view) { CGSize measuredSize = CGSizeZero; if ([uiView isKindOfClass:[UILabel class]]) { + UILabel *label = (UILabel *)uiView; CGSize constrainedSize = (CGSize){ hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX }; - measuredSize = YGMeasureLabelText((UILabel *)uiView, constrainedSize); + NSTextStorage *ts = ZDTextStorageFromLabel(label); + measuredSize = ZDMeasureText(ts, label.numberOfLines, constrainedSize); } else if ([uiView isKindOfClass:[UIImageView class]]) { UIImage *image = ((UIImageView *)uiView).image; if (image) { @@ -616,27 +610,34 @@ static CGFloat YGSanitizeMeasurement( } /// 后台线程安全的 measure 函数。 -/// 从缓存侧表读取主线程预测量的尺寸,结合 Yoga 的 measureMode 约束返回最终值。 -/// 不访问任何 UIKit API,可安全在任意线程调用。 +/// 文本节点使用 TextKit 在 Yoga 提供的真实约束下测量;非文本节点返回预存的固定尺寸。 static YGSize YGCachedMeasureView( - YGNodeConstRef node, - float width, - YGMeasureMode widthMode, - float height, - YGMeasureMode heightMode + YGNodeConstRef node, + float width, + YGMeasureMode widthMode, + float height, + YGMeasureMode heightMode ) { - CGSize cachedSize = CGSizeZero; - if (!ZDMeasureCacheGet(node, &cachedSize)) { - return (YGSize){ .width = 0, .height = 0 }; - } - - const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; - const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; - - return (YGSize) { - .width = YGSanitizeMeasurement(constrainedWidth, cachedSize.width, widthMode), - .height = YGSanitizeMeasurement(constrainedHeight, cachedSize.height, heightMode), - }; + const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; + const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; + + id cached = _zd_measureCache[ZDNodeKey(node)]; + if (!cached) return (YGSize){ .width = 0, .height = 0 }; + + CGSize measuredSize; + if ([cached isKindOfClass:[NSDictionary class]]) { + NSTextStorage *textStorage = cached[@"storage"]; + NSInteger numberOfLines = [cached[@"lines"] integerValue]; + CGSize constraint = CGSizeMake(constrainedWidth, constrainedHeight); + measuredSize = ZDMeasureText(textStorage, numberOfLines, constraint); + } else { + measuredSize = [(NSValue *)cached CGSizeValue]; + } + + return (YGSize) { + .width = YGSanitizeMeasurement(constrainedWidth, measuredSize.width, widthMode), + .height = YGSanitizeMeasurement(constrainedHeight, measuredSize.height, heightMode), + }; } static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view); @@ -732,21 +733,29 @@ static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view) { CGSize constrainedSize = CGSizeMake(constraintW, constraintH); CGSize measuredSize = CGSizeZero; - if (!yoga.isUIView) { + if (![view isKindOfClass:[UIView class]]) { measuredSize = [view sizeThatFits:constrainedSize]; + ZDMeasureCacheSetSize(node, measuredSize); } else { UIView *uiView = (UIView *)view; if ([uiView isKindOfClass:[UILabel class]]) { - measuredSize = YGMeasureLabelText((UILabel *)uiView, constrainedSize); + // 文本节点:捕获 NSTextStorage 供后台 TextKit 测量 + UILabel *label = (UILabel *)uiView; + NSTextStorage *textStorage = ZDTextStorageFromLabel(label); + if (textStorage) { + ZDMeasureCacheSetTextStorage(node, textStorage, label.numberOfLines); + } else { + ZDMeasureCacheSetSize(node, CGSizeZero); + } } else if ([uiView isKindOfClass:[UIImageView class]]) { UIImage *image = ((UIImageView *)uiView).image; measuredSize = image ? image.size : CGSizeZero; + ZDMeasureCacheSetSize(node, measuredSize); } else { measuredSize = [uiView sizeThatFits:constrainedSize]; + ZDMeasureCacheSetSize(node, measuredSize); } } - - ZDMeasureCacheSet(node, measuredSize); YGNodeSetMeasureFunc(node, YGCachedMeasureView); } else { YGNodeSetMeasureFunc(node, NULL); From 6506d7d29ac2f639c85b9f67ba6a72bab5bc7a66 Mon Sep 17 00:00:00 2001 From: faimin Date: Wed, 27 May 2026 23:20:19 +0800 Subject: [PATCH 08/14] fix: eliminate race condition with per-pass async measure cache Replaced global static _zd_measureCache with per-instance asyncMeasureCache on ZDFlexLayoutCore. Root cause: _zd_measureCache was a global NSMutableDictionary shared across all concurrent async layout passes. When two different view hierarchies performed applyLayoutWithAsyncMode:BackgroundThread concurrently, one pass would overwrite or clear the cache while the other was still reading from it on the background queue. Changes: - Added asyncMeasureCache property to ZDFlexLayoutCore (per-root-instance) - Removed global _zd_measureCache; cache now lives on the root ZDFlexLayoutCore instance for the duration of one layout pass - ZDMeasureCacheSetSize/SetTextStorage now take an explicit cache parameter - Added ZDGetCurrentAsyncCache(node): walks up view.parent to find root view, reads root.flexLayout.asyncMeasureCache - YGCachedMeasureView now retrieves cache via ZDGetCurrentAsyncCache instead of accessing global - Cache created in Phase 1 (main thread), cleared in Phase 3 (main thread) via self.asyncMeasureCache = nil - Each async layout pass is fully self-contained: no shared mutable state between passes --- Sources/Core/Public/ZDFlexLayoutCore.m | 73 ++++++++++++++------------ 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index c1601b4..27e979c 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -130,6 +130,9 @@ @interface ZDFlexLayoutCore () @property (nonatomic, weak, readwrite) ZDFlexLayoutView view; @property (nonatomic, assign, readonly) BOOL isUIView; +/// Per-pass measure cache, safe for concurrent async layouts of different roots. +@property (nonatomic, strong) NSMutableDictionary *asyncMeasureCache; + @end @implementation ZDFlexLayoutCore @@ -347,9 +350,9 @@ - (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); }]; } else { - // Phase 1 (main thread): pre-measure all leaves, store in cache side table - ZDMeasureCacheCreate(); - YGPreMeasureAndCacheLeafNodes(self.view); + // Phase 1 (main thread): pre-measure all leaves, store in per-pass cache + self.asyncMeasureCache = [NSMutableDictionary dictionary]; + YGPreMeasureAndCacheLeafNodes(self.view, self.asyncMeasureCache); // Phase 2 (background thread): pure numeric Yoga calculation [ZDCalculateHelper asyncCalculateTask:^{ @@ -363,7 +366,7 @@ - (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode if (!self) return; YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); YGRestoreMeasureFuncs(self.view); - ZDMeasureCacheClear(); + self.asyncMeasureCache = nil; }]; } break; @@ -429,36 +432,33 @@ - (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode { }; } -#pragma mark - Measure Cache Side Table -// 缓存侧表:主线程写入(Phase 1) → 后台线程只读(Phase 2) → 主线程清除(Phase 3)。 -// key: NSValue(pointer:YGNodeRef), value: NSValue(CGSize) 或 NSTextStorage(文本节点) - -static NSMutableDictionary *_zd_measureCache = nil; - -static void ZDMeasureCacheCreate(void) { - if (_zd_measureCache) { - [_zd_measureCache removeAllObjects]; - } else { - _zd_measureCache = [NSMutableDictionary dictionary]; - } -} +#pragma mark - Measure Cache Per-Pass +// Each async layout pass gets its own cache on the root ZDFlexLayoutCore. +// This eliminates race conditions between concurrent async layouts of different roots. +// key: NSValue(pointer:YGNodeRef), value: NSValue(CGSize) or text-storage-dictionary static NSValue *ZDNodeKey(YGNodeConstRef node) { return [NSValue valueWithPointer:node]; } -static void ZDMeasureCacheSetSize(YGNodeRef node, CGSize size) { - if (!_zd_measureCache) return; - _zd_measureCache[ZDNodeKey(node)] = [NSValue valueWithCGSize:size]; +static void ZDMeasureCacheSetSize(NSMutableDictionary *cache, YGNodeRef node, CGSize size) { + if (!cache) return; + cache[ZDNodeKey(node)] = [NSValue valueWithCGSize:size]; } -static void ZDMeasureCacheSetTextStorage(YGNodeRef node, NSTextStorage *textStorage, NSInteger numberOfLines) { - if (!_zd_measureCache || !textStorage) return; - _zd_measureCache[ZDNodeKey(node)] = @{@"storage": textStorage, @"lines": @(numberOfLines)}; +static void ZDMeasureCacheSetTextStorage(NSMutableDictionary *cache, YGNodeRef node, NSTextStorage *textStorage, NSInteger numberOfLines) { + if (!cache || !textStorage) return; + cache[ZDNodeKey(node)] = @{@"storage": textStorage, @"lines": @(numberOfLines)}; } -static void ZDMeasureCacheClear(void) { - [_zd_measureCache removeAllObjects]; +/// Finds the async measure cache by walking up the view tree to the root, then +/// reading root.flexLayout.asyncMeasureCache. Thread-safe since the cache +/// is written once on main thread before dispatching to background. +static NSMutableDictionary *ZDGetCurrentAsyncCache(YGNodeConstRef node) { + ZDFlexLayoutView view = (__bridge ZDFlexLayoutView)YGNodeGetContext(node); + if (!view) return nil; + while (view.parent) { view = view.parent; } + return view.flexLayout.asyncMeasureCache; } /// 使用 TextKit 测量文本(线程安全),借鉴 React Native 的实现方式。 @@ -611,6 +611,8 @@ static CGFloat YGSanitizeMeasurement( /// 后台线程安全的 measure 函数。 /// 文本节点使用 TextKit 在 Yoga 提供的真实约束下测量;非文本节点返回预存的固定尺寸。 +/// 通过节点上下文找到当前布局的根视图,从根视图的 per-pass cache 读取数据, +/// 避免多次并发异步布局之间的竞态条件。 static YGSize YGCachedMeasureView( YGNodeConstRef node, float width, @@ -621,7 +623,10 @@ static YGSize YGCachedMeasureView( const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; - id cached = _zd_measureCache[ZDNodeKey(node)]; + NSMutableDictionary *cache = ZDGetCurrentAsyncCache(node); + if (!cache) return (YGSize){ .width = 0, .height = 0 }; + + id cached = cache[ZDNodeKey(node)]; if (!cached) return (YGSize){ .width = 0, .height = 0 }; CGSize measuredSize; @@ -640,7 +645,7 @@ static YGSize YGCachedMeasureView( }; } -static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view); +static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutableDictionary *cache); static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews) { @@ -710,7 +715,7 @@ static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyn /// 主线程递归遍历视图树,预测量所有叶子节点的固有尺寸并存入缓存侧表。 /// 每个叶子节点设置 YGCachedMeasureView 作为 measure 函数,确保后台计算时不回调 UIKit。 /// 不修改 YGNode 的 style 属性(width/height),避免污染后续布局。 -static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view) { +static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutableDictionary *cache) { ZDFlexLayoutCore *const yoga = view.flexLayout; const YGNodeRef node = yoga.node; @@ -735,7 +740,7 @@ static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view) { if (![view isKindOfClass:[UIView class]]) { measuredSize = [view sizeThatFits:constrainedSize]; - ZDMeasureCacheSetSize(node, measuredSize); + ZDMeasureCacheSetSize(cache, node, measuredSize); } else { UIView *uiView = (UIView *)view; if ([uiView isKindOfClass:[UILabel class]]) { @@ -743,17 +748,17 @@ static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view) { UILabel *label = (UILabel *)uiView; NSTextStorage *textStorage = ZDTextStorageFromLabel(label); if (textStorage) { - ZDMeasureCacheSetTextStorage(node, textStorage, label.numberOfLines); + ZDMeasureCacheSetTextStorage(cache, node, textStorage, label.numberOfLines); } else { - ZDMeasureCacheSetSize(node, CGSizeZero); + ZDMeasureCacheSetSize(cache, node, CGSizeZero); } } else if ([uiView isKindOfClass:[UIImageView class]]) { UIImage *image = ((UIImageView *)uiView).image; measuredSize = image ? image.size : CGSizeZero; - ZDMeasureCacheSetSize(node, measuredSize); + ZDMeasureCacheSetSize(cache, node, measuredSize); } else { measuredSize = [uiView sizeThatFits:constrainedSize]; - ZDMeasureCacheSetSize(node, measuredSize); + ZDMeasureCacheSetSize(cache, node, measuredSize); } } YGNodeSetMeasureFunc(node, YGCachedMeasureView); @@ -775,7 +780,7 @@ static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view) { } for (ZDFlexLayoutView const subview in subviewsToInclude) { - YGPreMeasureAndCacheLeafNodes(subview); + YGPreMeasureAndCacheLeafNodes(subview, cache); } } } From 527b3900dedb3b73c4af382065377bd43ae6a143 Mon Sep 17 00:00:00 2001 From: faimin Date: Wed, 27 May 2026 23:45:52 +0800 Subject: [PATCH 09/14] perf: reuse TextKit components via thread-local pool in ZDMeasureText Previously ZDMeasureText allocated a new NSLayoutManager and NSTextContainer for every text measurement call, incurring unnecessary CPU and memory overhead in layouts with many text nodes. Fix: Introduced _ZDFLTextKitPool, a thread-local store (via NSThread.threadDictionary) that lazily creates and reuses one NSLayoutManager + NSTextContainer pair per thread. - Init time: allocate once per thread, not per measurement - Each measure: update textContainer.size / maximumNumberOfLines, add/remove layout manager - Dealloc: removeTextContainerAtIndex to break cycle - Thread-safe: each thread gets its own pool; no cross-thread sharing --- Sources/Core/Public/ZDFlexLayoutCore.m | 54 +++++++++++++++++++++----- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index 27e979c..1a5eb79 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -461,23 +461,59 @@ static void ZDMeasureCacheSetTextStorage(NSMutableDictionary *cache, YGNodeRef n return view.flexLayout.asyncMeasureCache; } +/// Thread-local TextKit component pool to avoid allocating +/// NSLayoutManager + NSTextContainer for every text measurement. +/// Stored in NSThread.threadDictionary so each background thread reuses its +/// own NSLayoutManager/NSTextContainer pair without conflicts. +@interface _ZDFLTextKitPool : NSObject +@property (nonatomic, strong, readonly) NSLayoutManager *layoutManager; +@property (nonatomic, strong, readonly) NSTextContainer *textContainer; +@end +@implementation _ZDFLTextKitPool +- (void)dealloc { + [_layoutManager removeTextContainerAtIndex:0]; +} +- (instancetype)init { + if (self = [super init]) { + _layoutManager = [[NSLayoutManager alloc] init]; + _textContainer = [[NSTextContainer alloc] initWithSize:CGSizeZero]; + _textContainer.lineFragmentPadding = 0; + _textContainer.lineBreakMode = NSLineBreakByWordWrapping; + [_layoutManager addTextContainer:_textContainer]; + } + return self; +} +@end + +static _ZDFLTextKitPool *_ZDTextKitPool(void) { + NSString *key = @"ZDFL.TextKitPool"; + NSThread *thread = [NSThread currentThread]; + _ZDFLTextKitPool *pool = thread.threadDictionary[key]; + if (!pool) { + pool = [[_ZDFLTextKitPool alloc] init]; + thread.threadDictionary[key] = pool; + } + return pool; +} + /// 使用 TextKit 测量文本(线程安全),借鉴 React Native 的实现方式。 /// 支持 NSTextStorage 直接传入(后台线程),或从 UILabel 提取文本信息(主线程)。 +/// Uses per-thread NSLayoutManager + NSTextContainer pool to avoid repeated allocations. static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, CGSize constraintSize) { if (!textStorage || textStorage.length == 0) return CGSizeZero; - NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; - NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:constraintSize]; - textContainer.lineFragmentPadding = 0; + _ZDFLTextKitPool *pool = _ZDTextKitPool(); + NSTextContainer *textContainer = pool.textContainer; + textContainer.size = constraintSize; textContainer.maximumNumberOfLines = numberOfLines; - textContainer.lineBreakMode = NSLineBreakByWordWrapping; - [layoutManager addTextContainer:textContainer]; - [textStorage addLayoutManager:layoutManager]; - [layoutManager ensureLayoutForTextContainer:textContainer]; + // addLayoutManager resets the layout manager's glyph tree implicitly, + // so no explicit invalidateLayout is needed before ensure. + [textStorage addLayoutManager:pool.layoutManager]; + [pool.layoutManager ensureLayoutForTextContainer:textContainer]; - CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; - [textStorage removeLayoutManager:layoutManager]; + CGRect usedRect = [pool.layoutManager usedRectForTextContainer:textContainer]; + [textStorage removeLayoutManager:pool.layoutManager]; return CGSizeMake(ceil(usedRect.size.width), ceil(usedRect.size.height)); } From 685a98d9ae6a08caf6ab54ac5fdd1c189f9d8b20 Mon Sep 17 00:00:00 2001 From: faimin Date: Thu, 28 May 2026 00:09:06 +0800 Subject: [PATCH 10/14] fix: close @implementation before static C functions to fix compilation warning The @implementation ZDFlexLayoutCore block incorrectly enclosed all static C helper functions, including the newly added _ZDFLTextKitPool class and _ZDTextKitPool/ZDMeasureText/ZDGetCurrentAsyncCache functions. In some LLVM versions, static C functions inside an @implementation block trigger 'Static declaration of xxx follows non-static declaration' warnings because the compiler may generate implicit non-static prototypes first. Fix: moved @end to close @implementation right after the last ObjC method (calculateLayoutWithSize:asyncMode:), placing all C helpers and the _ZDFLTextKitPool class at proper file scope. --- Sources/Core/Public/ZDFlexLayoutCore.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index 1a5eb79..c373b6a 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -432,6 +432,8 @@ - (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode { }; } +@end + #pragma mark - Measure Cache Per-Pass // Each async layout pass gets its own cache on the root ZDFlexLayoutCore. // This eliminates race conditions between concurrent async layouts of different roots. @@ -891,8 +893,6 @@ static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOri } } -@end - //-------------------------- Function ------------------------ #pragma mark - From 420df84bb4ca7b0fd39106e8ae37b6c4dec95aec Mon Sep 17 00:00:00 2001 From: faimin Date: Thu, 28 May 2026 00:18:24 +0800 Subject: [PATCH 11/14] fix: add forward declarations for all static C functions at file top Added static forward declarations for all 17 C helper functions after the macros and before @interface ZDFlexLayoutCore(). This prevents implicit non-static declarations that occur when a static function is used before its definition, which triggers 'Static declaration of xxx follows non-static declaration' compilation errors in some LLVM versions. Also added @class _ZDFLTextKitPool forward declaration for the private class used by _ZDTextKitPool's return type. --- Sources/Core/Public/ZDFlexLayoutCore.m | 57 +++++++++++++++++--------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index c373b6a..a26f660 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -125,6 +125,27 @@ - (void)set ## objc_capitalized_name: (YGValue)objc_lowercased_name static YGConfigRef globalConfig; +// --- Static function forward declarations --- +@class _ZDFLTextKitPool; + +static NSValue *ZDNodeKey(YGNodeConstRef node); +static void ZDMeasureCacheSetSize(NSMutableDictionary *cache, YGNodeRef node, CGSize size); +static void ZDMeasureCacheSetTextStorage(NSMutableDictionary *cache, YGNodeRef node, NSTextStorage *textStorage, NSInteger numberOfLines); +static NSMutableDictionary *ZDGetCurrentAsyncCache(YGNodeConstRef node); +static _ZDFLTextKitPool *_ZDTextKitPool(void); +static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, CGSize constraintSize); +static NSTextStorage *ZDTextStorageFromLabel(UILabel *label); +static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view); +static CGFloat YGSanitizeMeasurement(CGFloat constrainedSize, CGFloat measuredSize, YGMeasureMode measureMode); +static YGSize YGMeasureView(YGNodeConstRef node, float width, YGMeasureMode widthMode, float height, YGMeasureMode heightMode); +static YGSize YGCachedMeasureView(YGNodeConstRef node, float width, YGMeasureMode widthMode, float height, YGMeasureMode heightMode); +static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews); +static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyncMode); +static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutableDictionary *cache); +static void YGRestoreMeasureFuncs(ZDFlexLayoutView const view); +static void YGRemoveAllChildren(const YGNodeRef node); +static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOrigin); + @interface ZDFlexLayoutCore () @property (nonatomic, weak, readwrite) ZDFlexLayoutView view; @@ -598,6 +619,23 @@ static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view) { return YES; } +static CGFloat YGSanitizeMeasurement( + CGFloat constrainedSize, + CGFloat measuredSize, + YGMeasureMode measureMode +) { + CGFloat result; + if (measureMode == YGMeasureModeExactly) { + result = constrainedSize; + } else if (measureMode == YGMeasureModeAtMost) { + result = MIN(constrainedSize, measuredSize); + } else { + result = measuredSize; + } + + return result; +} + static YGSize YGMeasureView( YGNodeConstRef node, float width, @@ -630,23 +668,6 @@ static YGSize YGMeasureView( }; } -static CGFloat YGSanitizeMeasurement( - CGFloat constrainedSize, - CGFloat measuredSize, - YGMeasureMode measureMode -) { - CGFloat result; - if (measureMode == YGMeasureModeExactly) { - result = constrainedSize; - } else if (measureMode == YGMeasureModeAtMost) { - result = MIN(constrainedSize, measuredSize); - } else { - result = measuredSize; - } - - return result; -} - /// 后台线程安全的 measure 函数。 /// 文本节点使用 TextKit 在 Yoga 提供的真实约束下测量;非文本节点返回预存的固定尺寸。 /// 通过节点上下文找到当前布局的根视图,从根视图的 per-pass cache 读取数据, @@ -683,8 +704,6 @@ static YGSize YGCachedMeasureView( }; } -static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutableDictionary *cache); - static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews) { if (YGNodeGetChildCount(node) != subviews.count) { From 5c1a7fccdb7541642caa3741201cb931ce213a8e Mon Sep 17 00:00:00 2001 From: faimin Date: Thu, 28 May 2026 20:13:19 +0800 Subject: [PATCH 12/14] fix: override paragraph lineBreakMode to fix multi-line text measurement UILabel defaults to NSLineBreakByTruncatingTail. When TextKit measures text with this paragraph style, NSLayoutManager refuses to wrap lines, producing single-line height. Force lineBreakMode to WordWrapping in ZDTextStorageFromLabel so background-thread text measurement correctly calculates multi-line height. Co-Authored-By: Claude Opus 4.6 --- Sources/Core/Public/ZDFlexLayoutCore.m | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index a26f660..71e5a76 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -542,13 +542,29 @@ static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, } /// 从 UILabel 构造 NSTextStorage(主线程调用,捕获文本快照) +/// 需要将段落样式的 lineBreakMode 强制设为 WordWrapping,否则 TextKit +/// 会遵循 UILabel 默认的 TruncatingTail 导致不换行、只计算单行高度。 static NSTextStorage *ZDTextStorageFromLabel(UILabel *label) { NSAttributedString *attrText = label.attributedText; if (attrText.length > 0) { - return [[NSTextStorage alloc] initWithAttributedString:attrText]; + NSMutableAttributedString *mutable = [attrText mutableCopy]; + [mutable enumerateAttribute:NSParagraphStyleAttributeName + inRange:NSMakeRange(0, mutable.length) + options:0 + usingBlock:^(NSParagraphStyle *style, NSRange range, BOOL *stop) { + NSMutableParagraphStyle *newStyle = style ? [style mutableCopy] : [[NSMutableParagraphStyle alloc] init]; + newStyle.lineBreakMode = NSLineBreakByWordWrapping; + [mutable addAttribute:NSParagraphStyleAttributeName value:newStyle range:range]; + }]; + return [[NSTextStorage alloc] initWithAttributedString:mutable]; } if (label.text.length > 0) { - NSDictionary *attrs = label.font ? @{NSFontAttributeName: label.font} : @{}; + NSMutableParagraphStyle *paraStyle = [[NSMutableParagraphStyle alloc] init]; + paraStyle.lineBreakMode = NSLineBreakByWordWrapping; + NSDictionary *attrs = @{ + NSFontAttributeName: label.font ?: [UIFont systemFontOfSize:17], + NSParagraphStyleAttributeName: paraStyle, + }; return [[NSTextStorage alloc] initWithString:label.text attributes:attrs]; } return nil; From ce83c57bff5c77da868f0b55efb328a56ddd281a Mon Sep 17 00:00:00 2001 From: faimin Date: Thu, 28 May 2026 20:41:19 +0800 Subject: [PATCH 13/14] refactor: improve code clarity and fix bugs in ZDFlexLayoutCore - Fix RTL/LTR direction mapping (was inverted) - Fix legacy path isUIView check using isKindOfClass instead of isMemberOfClass - Optimize ZDGetCurrentAsyncCache to use thread-local variable instead of traversing parent chain on every measure call - Reorganize static functions with descriptive pragma marks - Remove redundant YGRemoveAllChildren wrapper and _ZDFLTextKitPool dealloc Co-Authored-By: Claude Opus 4.6 --- Sources/Core/Public/ZDFlexLayoutCore.m | 73 +++++++++++--------------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index 71e5a76..bcda63c 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -131,6 +131,7 @@ - (void)set ## objc_capitalized_name: (YGValue)objc_lowercased_name static NSValue *ZDNodeKey(YGNodeConstRef node); static void ZDMeasureCacheSetSize(NSMutableDictionary *cache, YGNodeRef node, CGSize size); static void ZDMeasureCacheSetTextStorage(NSMutableDictionary *cache, YGNodeRef node, NSTextStorage *textStorage, NSInteger numberOfLines); +static void ZDSetThreadAsyncCache(NSMutableDictionary *cache); static NSMutableDictionary *ZDGetCurrentAsyncCache(YGNodeConstRef node); static _ZDFLTextKitPool *_ZDTextKitPool(void); static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, CGSize constraintSize); @@ -143,7 +144,6 @@ - (void)set ## objc_capitalized_name: (YGValue)objc_lowercased_name static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyncMode); static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutableDictionary *cache); static void YGRestoreMeasureFuncs(ZDFlexLayoutView const view); -static void YGRemoveAllChildren(const YGNodeRef node); static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOrigin); @interface ZDFlexLayoutCore () @@ -374,13 +374,16 @@ - (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode // Phase 1 (main thread): pre-measure all leaves, store in per-pass cache self.asyncMeasureCache = [NSMutableDictionary dictionary]; YGPreMeasureAndCacheLeafNodes(self.view, self.asyncMeasureCache); - + + NSMutableDictionary *cache = self.asyncMeasureCache; // Phase 2 (background thread): pure numeric Yoga calculation [ZDCalculateHelper asyncCalculateTask:^{ __strong typeof(weakTarget) self = weakTarget; if (!self) return; + ZDSetThreadAsyncCache(cache); const YGNodeRef node = self.node; YGNodeCalculateLayout(node, size.width, size.height, YGNodeStyleGetDirection(node)); + ZDSetThreadAsyncCache(nil); } onComplete:^{ // Phase 3 (main thread): apply frames, restore measure funcs, clear cache __strong typeof(weakTarget) self = weakTarget; @@ -423,10 +426,9 @@ - (CGSize)intrinsicSize { } - (void)updateLayoutDirectionIfNeeded { - // Must be called on main thread — accesses UIView.traitCollection UIView *view = self.view.owningView; if (view && view.traitCollection.layoutDirection != UITraitEnvironmentLayoutDirectionUnspecified) { - self.direction = view.traitCollection.layoutDirection == UITraitEnvironmentLayoutDirectionLeftToRight ? YGDirectionRTL : YGDirectionLTR; + self.direction = view.traitCollection.layoutDirection == UITraitEnvironmentLayoutDirectionLeftToRight ? YGDirectionLTR : YGDirectionRTL; } } @@ -455,10 +457,7 @@ - (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode { @end -#pragma mark - Measure Cache Per-Pass -// Each async layout pass gets its own cache on the root ZDFlexLayoutCore. -// This eliminates race conditions between concurrent async layouts of different roots. -// key: NSValue(pointer:YGNodeRef), value: NSValue(CGSize) or text-storage-dictionary +#pragma mark - Measure Cache static NSValue *ZDNodeKey(YGNodeConstRef node) { return [NSValue valueWithPointer:node]; @@ -474,28 +473,24 @@ static void ZDMeasureCacheSetTextStorage(NSMutableDictionary *cache, YGNodeRef n cache[ZDNodeKey(node)] = @{@"storage": textStorage, @"lines": @(numberOfLines)}; } -/// Finds the async measure cache by walking up the view tree to the root, then -/// reading root.flexLayout.asyncMeasureCache. Thread-safe since the cache -/// is written once on main thread before dispatching to background. +static NSString *const kZDAsyncCacheThreadKey = @"ZDFL.AsyncMeasureCache"; + +static void ZDSetThreadAsyncCache(NSMutableDictionary *cache) { + [NSThread currentThread].threadDictionary[kZDAsyncCacheThreadKey] = cache; +} + static NSMutableDictionary *ZDGetCurrentAsyncCache(YGNodeConstRef node) { - ZDFlexLayoutView view = (__bridge ZDFlexLayoutView)YGNodeGetContext(node); - if (!view) return nil; - while (view.parent) { view = view.parent; } - return view.flexLayout.asyncMeasureCache; + (void)node; + return [NSThread currentThread].threadDictionary[kZDAsyncCacheThreadKey]; } -/// Thread-local TextKit component pool to avoid allocating -/// NSLayoutManager + NSTextContainer for every text measurement. -/// Stored in NSThread.threadDictionary so each background thread reuses its -/// own NSLayoutManager/NSTextContainer pair without conflicts. +#pragma mark - Text Measurement (TextKit) + @interface _ZDFLTextKitPool : NSObject @property (nonatomic, strong, readonly) NSLayoutManager *layoutManager; @property (nonatomic, strong, readonly) NSTextContainer *textContainer; @end @implementation _ZDFLTextKitPool -- (void)dealloc { - [_layoutManager removeTextContainerAtIndex:0]; -} - (instancetype)init { if (self = [super init]) { _layoutManager = [[NSLayoutManager alloc] init]; @@ -570,11 +565,7 @@ static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, return nil; } -#pragma mark - Private - -// Pre-measure a leaf node and set its size as fixed width/height on the Yoga node. -// This allows us to skip setting YGMeasureFunc and run Yoga calculation on a background thread. -// Returns YES if the node was fully pre-measured (no measure func needed). +#pragma mark - Legacy Pre-Measure (useLegacyPreMeasure) static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view) { YGValue nodeWidth = YGNodeStyleGetWidth(node); @@ -587,7 +578,7 @@ static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view) { return YES; // Already fully constrained, no measure func needed } - if (!view.flexLayout.isUIView) { + if (![view isKindOfClass:[UIView class]]) { // ZDFlexLayoutDiv — sizeThatFits returns CGSizeZero, thread-safe // Keep the measure function for these; they don't call UIKit return NO; @@ -635,6 +626,8 @@ static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view) { return YES; } +#pragma mark - Yoga Measure Functions + static CGFloat YGSanitizeMeasurement( CGFloat constrainedSize, CGFloat measuredSize, @@ -720,8 +713,10 @@ static YGSize YGCachedMeasureView( }; } +#pragma mark - Tree Walkers + static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews) { - + if (YGNodeGetChildCount(node) != subviews.count) { return NO; } @@ -742,7 +737,7 @@ static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyn // Only leaf nodes should have a measure function if (yoga.isLeaf) { - YGRemoveAllChildren(node); + YGNodeRemoveAllChildren(node); if (asyncMode) { // In async mode, try to pre-measure the leaf node and set fixed sizes. @@ -773,7 +768,7 @@ static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyn } if (!YGNodeHasExactSameChildren(node, subviewsToInclude)) { - YGRemoveAllChildren(node); + YGNodeRemoveAllChildren(node); for (int i = 0; i < subviewsToInclude.count; i++) { YGNodeInsertChild(node, subviewsToInclude[i].flexLayout.node, i); } @@ -794,7 +789,7 @@ static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutable const YGNodeRef node = yoga.node; if (yoga.isLeaf) { - YGRemoveAllChildren(node); + YGNodeRemoveAllChildren(node); YGValue nodeWidth = YGNodeStyleGetWidth(node); YGValue nodeHeight = YGNodeStyleGetHeight(node); @@ -846,7 +841,7 @@ static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutable } if (!YGNodeHasExactSameChildren(node, subviewsToInclude)) { - YGRemoveAllChildren(node); + YGNodeRemoveAllChildren(node); for (int i = 0; i < subviewsToInclude.count; i++) { YGNodeInsertChild(node, subviewsToInclude[i].flexLayout.node, i); } @@ -876,14 +871,7 @@ static void YGRestoreMeasureFuncs(ZDFlexLayoutView const view) { } } -static void YGRemoveAllChildren(const YGNodeRef node) { - - if (node == NULL) { - return; - } - - YGNodeRemoveAllChildren(node); -} +#pragma mark - Layout Application static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOrigin) { NSCAssert([NSThread isMainThread], @"Framesetting should only be done on the main thread."); @@ -928,8 +916,7 @@ static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOri } } -//-------------------------- Function ------------------------ -#pragma mark - +#pragma mark - Pixel Utilities CGFloat ZDFLScreenScale(void) { static CGFloat scale = 0.0; From 4cd8af0cd75f38067899b6efc0a28be43be05fcb Mon Sep 17 00:00:00 2001 From: faimin Date: Fri, 29 May 2026 14:54:36 +0800 Subject: [PATCH 14/14] fix: capture textAlignment and remove unused parameter in ZDFlexLayoutCore - ZDTextStorageFromLabel: set paraStyle.alignment = label.textAlignment in the plain text path for correct justified text wrapping - ZDGetCurrentAsyncCache: remove unused YGNodeConstRef parameter since the function now reads from thread-local storage Co-Authored-By: Claude Opus 4.6 --- Sources/Core/Public/ZDFlexLayoutCore.m | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index bcda63c..895c028 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -132,7 +132,7 @@ - (void)set ## objc_capitalized_name: (YGValue)objc_lowercased_name static void ZDMeasureCacheSetSize(NSMutableDictionary *cache, YGNodeRef node, CGSize size); static void ZDMeasureCacheSetTextStorage(NSMutableDictionary *cache, YGNodeRef node, NSTextStorage *textStorage, NSInteger numberOfLines); static void ZDSetThreadAsyncCache(NSMutableDictionary *cache); -static NSMutableDictionary *ZDGetCurrentAsyncCache(YGNodeConstRef node); +static NSMutableDictionary *ZDGetCurrentAsyncCache(void); static _ZDFLTextKitPool *_ZDTextKitPool(void); static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, CGSize constraintSize); static NSTextStorage *ZDTextStorageFromLabel(UILabel *label); @@ -479,8 +479,7 @@ static void ZDSetThreadAsyncCache(NSMutableDictionary *cache) { [NSThread currentThread].threadDictionary[kZDAsyncCacheThreadKey] = cache; } -static NSMutableDictionary *ZDGetCurrentAsyncCache(YGNodeConstRef node) { - (void)node; +static NSMutableDictionary *ZDGetCurrentAsyncCache(void) { return [NSThread currentThread].threadDictionary[kZDAsyncCacheThreadKey]; } @@ -556,6 +555,7 @@ static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, if (label.text.length > 0) { NSMutableParagraphStyle *paraStyle = [[NSMutableParagraphStyle alloc] init]; paraStyle.lineBreakMode = NSLineBreakByWordWrapping; + paraStyle.alignment = label.textAlignment; NSDictionary *attrs = @{ NSFontAttributeName: label.font ?: [UIFont systemFontOfSize:17], NSParagraphStyleAttributeName: paraStyle, @@ -691,7 +691,7 @@ static YGSize YGCachedMeasureView( const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; - NSMutableDictionary *cache = ZDGetCurrentAsyncCache(node); + NSMutableDictionary *cache = ZDGetCurrentAsyncCache(); if (!cache) return (YGSize){ .width = 0, .height = 0 }; id cached = cache[ZDNodeKey(node)];