Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overhaul our logging, add activity tracing support. #399

Merged
merged 3 commits into from
Jul 4, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AsyncDisplayKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,8 @@
CCA282D01E9EBF6C0037E8B7 /* ASTipsWindow.h in Headers */ = {isa = PBXBuildFile; fileRef = CCA282CE1E9EBF6C0037E8B7 /* ASTipsWindow.h */; };
CCA282D11E9EBF6C0037E8B7 /* ASTipsWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CCA282CF1E9EBF6C0037E8B7 /* ASTipsWindow.m */; };
CCA5F62E1EECC2A80060C137 /* ASAssert.m in Sources */ = {isa = PBXBuildFile; fileRef = CCA5F62D1EECC2A80060C137 /* ASAssert.m */; };
CCB1F95A1EFB60A5009C7475 /* ASLog.m in Sources */ = {isa = PBXBuildFile; fileRef = CCB1F9591EFB60A5009C7475 /* ASLog.m */; };
CCB1F95C1EFB6350009C7475 /* ASSignpost.h in Headers */ = {isa = PBXBuildFile; fileRef = CCB1F95B1EFB6316009C7475 /* ASSignpost.h */; };
CCB2F34D1D63CCC6004E6DE9 /* ASDisplayNodeSnapshotTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CCB2F34C1D63CCC6004E6DE9 /* ASDisplayNodeSnapshotTests.m */; };
CCBBBF5D1EB161760069AA91 /* ASRangeManagingNode.h in Headers */ = {isa = PBXBuildFile; fileRef = CCBBBF5C1EB161760069AA91 /* ASRangeManagingNode.h */; settings = {ATTRIBUTES = (Public, ); }; };
CCCCCCD51EC3EF060087FE10 /* ASTextDebugOption.h in Headers */ = {isa = PBXBuildFile; fileRef = CCCCCCC31EC3EF060087FE10 /* ASTextDebugOption.h */; };
Expand Down Expand Up @@ -840,6 +842,8 @@
CCA282CE1E9EBF6C0037E8B7 /* ASTipsWindow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASTipsWindow.h; sourceTree = "<group>"; };
CCA282CF1E9EBF6C0037E8B7 /* ASTipsWindow.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASTipsWindow.m; sourceTree = "<group>"; };
CCA5F62D1EECC2A80060C137 /* ASAssert.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASAssert.m; sourceTree = "<group>"; };
CCB1F9591EFB60A5009C7475 /* ASLog.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASLog.m; sourceTree = "<group>"; };
CCB1F95B1EFB6316009C7475 /* ASSignpost.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ASSignpost.h; sourceTree = "<group>"; };
CCB2F34C1D63CCC6004E6DE9 /* ASDisplayNodeSnapshotTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASDisplayNodeSnapshotTests.m; sourceTree = "<group>"; };
CCBBBF5C1EB161760069AA91 /* ASRangeManagingNode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ASRangeManagingNode.h; sourceTree = "<group>"; };
CCBD05DE1E4147B000D18509 /* ASIGListAdapterBasedDataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ASIGListAdapterBasedDataSource.m; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1387,6 +1391,8 @@
058D0A44195D058D00B7D73C /* ASBaseDefines.h */,
1950C4481A3BB5C1005C8279 /* ASEqualityHelpers.h */,
0516FA3B1A15563400B4EBED /* ASLog.h */,
CCB1F9591EFB60A5009C7475 /* ASLog.m */,
CCB1F95B1EFB6316009C7475 /* ASSignpost.h */,
);
path = Base;
sourceTree = "<group>";
Expand Down Expand Up @@ -1693,6 +1699,7 @@
9019FBBF1ED8061D00C45F72 /* ASYogaUtilities.h in Headers */,
DE84918D1C8FFF2B003D89E9 /* ASRunLoopQueue.h in Headers */,
CC0F88621E4281E200576FED /* ASSectionController.h in Headers */,
CCB1F95C1EFB6350009C7475 /* ASSignpost.h in Headers */,
A2763D7A1CBDD57D00A9ADBD /* ASPINRemoteImageDownloader.h in Headers */,
34EFC7611B701C9C00AD841F /* ASBackgroundLayoutSpec.h in Headers */,
B35062591B010F070018CF92 /* ASBaseDefines.h in Headers */,
Expand Down Expand Up @@ -2180,6 +2187,7 @@
B35061F91B010EFD0018CF92 /* ASControlNode.mm in Sources */,
8021EC1F1D2B00B100799119 /* UIImage+ASConvenience.m in Sources */,
B35062181B010EFD0018CF92 /* ASDataController.mm in Sources */,
CCB1F95A1EFB60A5009C7475 /* ASLog.m in Sources */,
767E7F8E1C90191D0066C000 /* AsyncDisplayKit+Debug.m in Sources */,
CCCCCCD61EC3EF060087FE10 /* ASTextDebugOption.m in Sources */,
34EFC75C1B701BD200AD841F /* ASDimension.mm in Sources */,
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

* Add your own contributions to the next release on the line below this with your name.

##2.3.5
- Fix an issue where inserting/deleting sections could lead to inconsistent supplementary element behavior. [Adlai Holler](https://github.com/Adlai-Holler)
- Overhaul logging and add activity tracing support. [Adlai Holler](https://github.com/Adlai-Holler)

##2.3.4
- [Yoga] Rewrite YOGA_TREE_CONTIGUOUS mode with improved behavior and cleaner integration [Scott Goodson](https://github.com/appleguy)
- [ASTraitCollection] Convert ASPrimitiveTraitCollection from lock to atomic. [Scott Goodson](https://github.com/appleguy)
Expand All @@ -13,7 +17,6 @@
- Migrated unit tests to OCMock 3.4 (from 2.2) and improved the multiplex image node tests. [Adlai Holler](https://github.com/Adlai-Holler)
- Fix CollectionNode double-load issue. This should significantly improve performance in cases where a collection node has content immediately available on first layout i.e. not fetched from the network. [Adlai Holler](https://github.com/Adlai-Holler)
- Overhaul layout flattening algorithm [Huy Nguyen](https://github.com/nguyenhuy) [#395](https://github.com/TextureGroup/Texture/pull/395).
- Fix an issue where inserting/deleting sections could lead to inconsistent supplementary element behavior. [Adlai Holler](https://github.com/Adlai-Holler)

## 2.3.3
- [ASTextKitFontSizeAdjuster] Replace use of NSAttributedString's boundingRectWithSize:options:context: with NSLayoutManager's boundingRectForGlyphRange:inTextContainer: [Ricky Cancro](https://github.com/rcancro)
Expand Down
4 changes: 2 additions & 2 deletions Source/ASCellNode.mm
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,13 @@ - (void)handleVisibilityChange:(BOOL)isVisible
if (ip != nil) {
[result addObject:@{ @"indexPath" : ip }];
}
[result addObject:@{ @"collectionNode" : ASObjectDescriptionMakeTiny(owningNode) }];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Longer term, I wonder if a separate file like ASLog+DebugDescriptions.m would be a good place to locate category implementations for all the node classes. One-stop shop for logging contents with helper macros and such. Locating it in the class like this might be better for other reasons, though.

[result addObject:@{ @"collectionNode" : owningNode }];
} else if ([owningNode isKindOfClass:[ASTableNode class]]) {
NSIndexPath *ip = [(ASTableNode *)owningNode indexPathForNode:self];
if (ip != nil) {
[result addObject:@{ @"indexPath" : ip }];
}
[result addObject:@{ @"tableNode" : ASObjectDescriptionMakeTiny(owningNode) }];
[result addObject:@{ @"tableNode" : owningNode }];

} else if ([scrollView isKindOfClass:[ASCollectionView class]]) {
NSIndexPath *ip = [(ASCollectionView *)scrollView indexPathForNode:self];
Expand Down
22 changes: 19 additions & 3 deletions Source/ASCollectionView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,8 @@ - (void)beginUpdates

if (_batchUpdateCount == 0) {
_changeSet = [[_ASHierarchyChangeSet alloc] initWithOldData:[_dataController itemCountsFromDataSource]];
_changeSet.rootActivity = as_activity_create("Perform async collection update", AS_ACTIVITY_CURRENT, OS_ACTIVITY_FLAG_DEFAULT);
_changeSet.submitActivity = as_activity_create("Submit changes for collection update", _changeSet.rootActivity, OS_ACTIVITY_FLAG_DEFAULT);
}
_batchUpdateCount++;
}
Expand All @@ -817,6 +819,7 @@ - (void)endUpdatesAnimated:(BOOL)animated completion:(nullable void (^)(BOOL))co

if (_batchUpdateCount == 0) {
_ASHierarchyChangeSet *changeSet = _changeSet;

// Nil out _changeSet before forwarding to _dataController to allow the change set to cause subsequent batch updates on the same run loop
_changeSet = nil;
changeSet.animated = animated;
Expand All @@ -828,8 +831,13 @@ - (void)performBatchAnimated:(BOOL)animated updates:(void (^)())updates completi
{
ASDisplayNodeAssertMainThread();
[self beginUpdates];
if (updates) {
updates();
as_activity_scope(_changeSet.rootActivity);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth a comment - I don't really understand what this anonymous scope is doing (I think it is only containing the submitActivity, but why is the rootActivity started right above the anonymous scope rather than at the top of the method?)

{
// Only include client code in the submit activity, the rest just lives in the root activity.
as_activity_scope(_changeSet.submitActivity);
if (updates) {
updates();
}
}
[self endUpdatesAnimated:animated completion:completion];
}
Expand Down Expand Up @@ -1575,10 +1583,12 @@ - (void)_beginBatchFetchingIfNeededWithContentOffset:(CGPoint)contentOffset velo

- (void)_beginBatchFetching
{
as_activity_create_for_scope("Batch fetch for collection node");
[_batchContext beginBatchFetching];
if (_asyncDelegateFlags.collectionNodeWillBeginBatchFetch) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
GET_COLLECTIONNODE_OR_RETURN(collectionNode, (void)0);
as_log_debug(ASCollectionLog(), "Beginning batch fetch for %@ with context %@", collectionNode, _batchContext);
[_asyncDelegate collectionNode:collectionNode willBeginBatchFetchWithContext:_batchContext];
});
} else if (_asyncDelegateFlags.collectionViewWillBeginBatchFetch) {
Expand Down Expand Up @@ -1894,9 +1904,11 @@ - (void)rangeController:(ASRangeController *)rangeController didUpdateWithChange
}

ASPerformBlockWithoutAnimation(!changeSet.animated, ^{
if(changeSet.includesReloadData) {
as_activity_scope(as_activity_create("Commit collection update", changeSet.rootActivity, OS_ACTIVITY_FLAG_DEFAULT));
if (changeSet.includesReloadData) {
_superIsPendingDataLoad = YES;
[super reloadData];
as_log_debug(ASCollectionLog(), "Did reloadData %@", self.collectionNode);
[changeSet executeCompletionHandlerWithFinished:YES];
} else {
[_layoutFacilitator collectionViewWillPerformBatchUpdates];
Expand Down Expand Up @@ -1933,13 +1945,17 @@ - (void)rangeController:(ASRangeController *)rangeController didUpdateWithChange
numberOfUpdates++;
}
} completion:^(BOOL finished){
as_activity_scope(as_activity_create("Handle collection update completion", changeSet.rootActivity, OS_ACTIVITY_FLAG_DEFAULT));
as_log_verbose(ASCollectionLog(), "Update animation finished %{public}@", self.collectionNode);
// Flush any range changes that happened as part of the update animations ending.
[_rangeController updateIfNeeded];
[self _scheduleCheckForBatchFetchingForNumberOfChanges:numberOfUpdates];
[changeSet executeCompletionHandlerWithFinished:finished];
}];
as_log_debug(ASCollectionLog(), "Completed batch update %{public}@", self.collectionNode);

// Flush any range changes that happened as part of submitting the update.
as_activity_scope(changeSet.rootActivity);
[_rangeController updateIfNeeded];
}
});
Expand Down
60 changes: 44 additions & 16 deletions Source/ASDisplayNode+Layout.mm
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#import <AsyncDisplayKit/ASInternalHelpers.h>
#import <AsyncDisplayKit/ASLayout.h>
#import <AsyncDisplayKit/ASLayoutElementStylePrivate.h>
#import <AsyncDisplayKit/ASLog.h>

#import <AsyncDisplayKit/ASDisplayNode+FrameworkSubclasses.h>

Expand Down Expand Up @@ -127,25 +128,20 @@ - (ASTraitCollection *)asyncTraitCollection

ASPrimitiveTraitCollectionDeprecatedImplementation

@end

#pragma mark -
#pragma mark - ASLayoutElementAsciiArtProtocol

@implementation ASDisplayNode (ASLayoutElementAsciiArtProtocol)

- (NSString *)asciiArtString
{
return [ASLayoutSpec asciiArtStringForChildren:@[] parentName:[self asciiArtName]];
}

- (NSString *)asciiArtName
{
NSString *string = NSStringFromClass([self class]);
NSMutableString *result = [NSMutableString stringWithCString:object_getClassName(self) encoding:NSASCIIStringEncoding];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting - it seems plausible to be faster, but is that indeed the reason you changed this?

Copy link
Member Author

@Adlai-Holler Adlai-Holler Jul 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I wanted to reduce the number of strings created from 3 to 1. Digging into the current implementation of NSStringFromClass shows that it always creates a new instance of NSString.

if (_debugName) {
string = [string stringByAppendingString:[NSString stringWithFormat:@"\"%@\"",_debugName]];
[result appendFormat:@" (%@)", _debugName];
}
return string;
return result;
}

@end
Expand Down Expand Up @@ -222,6 +218,7 @@ @implementation ASDisplayNode (ASLayoutInternal)
*/
- (void)_setNeedsLayoutFromAbove
{
as_activity_create_for_scope("Set needs layout from above");
ASDisplayNodeAssertThreadAffinity(self);

// Mark the node for layout in the next layout pass
Expand Down Expand Up @@ -315,6 +312,8 @@ - (void)_locked_measureNodeWithBoundsIfNecessary:(CGRect)bounds
}
}

as_activity_create_for_scope("Update node layout for current bounds");
as_log_verbose(ASLayoutLog(), "Node %@, bounds size %@, calculatedSize %@, calculatedIsDirty %d", self, NSStringFromCGSize(boundsSizeForLayout), NSStringFromCGSize(_calculatedDisplayNodeLayout->layout.size), _calculatedDisplayNodeLayout->isDirty());
// _calculatedDisplayNodeLayout is not reusable we need to transition to a new one
[self cancelLayoutTransition];

Expand All @@ -332,7 +331,20 @@ - (void)_locked_measureNodeWithBoundsIfNecessary:(CGRect)bounds

// nextLayout was likely created by a call to layoutThatFits:, check if it is valid and can be applied.
// If our bounds size is different than it, or invalid, recalculate. Use #define to avoid nullptr->
if (nextLayout == nullptr || nextLayout->isDirty() == YES || layoutSizeDifferentFromBounds) {
BOOL pendingLayoutApplicable = NO;
if (nextLayout == nullptr) {
as_log_verbose(ASLayoutLog(), "No pending layout.");
} else if (nextLayout->isDirty()) {
as_log_verbose(ASLayoutLog(), "Pending layout is invalid.");
} else if (layoutSizeDifferentFromBounds) {
as_log_verbose(ASLayoutLog(), "Pending layout size %@ doesn't match bounds size.", NSStringFromCGSize(nextLayout->layout.size));
} else {
as_log_verbose(ASLayoutLog(), "Using pending layout %@.", nextLayout->layout);
pendingLayoutApplicable = YES;
}

if (!pendingLayoutApplicable) {
as_log_verbose(ASLayoutLog(), "Measuring with previous constrained size.");
// Use the last known constrainedSize passed from a parent during layout (if never, use bounds).
ASSizeRange constrainedSize = [self _locked_constrainedSizeForLayoutPass];
ASLayout *layout = [self calculateLayoutThatFits:constrainedSize
Expand All @@ -350,6 +362,7 @@ - (void)_locked_measureNodeWithBoundsIfNecessary:(CGRect)bounds
// This can occur for either pre-calculated or newly-calculated layouts.
if (nextLayout->requestedLayoutFromAbove == NO
&& CGSizeEqualToSize(boundsSizeForLayout, nextLayout->layout.size) == NO) {
as_log_verbose(ASLayoutLog(), "Layout size doesn't match bounds size. Requesting layout from above.");
// The layout that we have specifies that this node (self) would like to be a different size
// than it currently is. Because that size has been computed within the constrainedSize, we
// expect that calling setNeedsLayoutFromAbove will result in our parent resizing us to this.
Expand Down Expand Up @@ -506,10 +519,13 @@ - (void)transitionLayoutWithSizeRange:(ASSizeRange)constrainedSize
measurementCompletion:(void(^)())completion
{
ASDisplayNodeAssertMainThread();
as_activity_create_for_scope("Transition node layout");
as_log_debug(ASLayoutLog(), "Transition layout for %@ sizeRange %@ anim %d asyncMeasure %d", self, NSStringFromASSizeRange(constrainedSize), animated, shouldMeasureAsync);

if (constrainedSize.max.width <= 0.0 || constrainedSize.max.height <= 0.0) {
// Using CGSizeZero for the sizeRange can cause negative values in client layout code.
// Most likely called transitionLayout: without providing a size, before first layout pass.
as_log_verbose(ASLayoutLog(), "Ignoring transition due to bad size range.");
return;
}

Expand All @@ -526,9 +542,14 @@ - (void)transitionLayoutWithSizeRange:(ASSizeRange)constrainedSize

// Every new layout transition has a transition id associated to check in subsequent transitions for cancelling
int32_t transitionID = [self _startNewTransition];
as_log_verbose(ASLayoutLog(), "Transition ID is %d", transitionID);
// NOTE: This block captures self. It's cheaper than hitting the weak table.
asdisplaynode_iscancelled_block_t isCancelled = ^{
return (BOOL)(_transitionID != transitionID);
BOOL result = (_transitionID != transitionID);
if (result) {
as_log_verbose(ASLayoutLog(), "Transition %d canceled, superseded by %d", transitionID, _transitionID.load());
}
return result;
};

// Move all subnodes in layout pending state for this transition
Expand Down Expand Up @@ -573,6 +594,7 @@ - (void)transitionLayoutWithSizeRange:(ASSizeRange)constrainedSize
if (isCancelled()) {
return;
}
as_activity_create_for_scope("Commit layout transition");
ASLayoutTransition *pendingLayoutTransition;
_ASTransitionContext *pendingLayoutTransitionContext;
{
Expand All @@ -598,10 +620,13 @@ - (void)transitionLayoutWithSizeRange:(ASSizeRange)constrainedSize
}

// Apply complete layout transitions for all subnodes
ASDisplayNodePerformBlockOnEverySubnode(self, NO, ^(ASDisplayNode * _Nonnull node) {
[node _completePendingLayoutTransition];
node.hierarchyState &= (~ASHierarchyStateLayoutPending);
});
{
as_activity_create_for_scope("Complete pending layout transitions for subtree");
ASDisplayNodePerformBlockOnEverySubnode(self, NO, ^(ASDisplayNode * _Nonnull node) {
[node _completePendingLayoutTransition];
node.hierarchyState &= (~ASHierarchyStateLayoutPending);
});
}

// Measurement pass completion
// Give the subclass a change to hook into before calling the completion block
Expand All @@ -614,7 +639,10 @@ - (void)transitionLayoutWithSizeRange:(ASSizeRange)constrainedSize
[pendingLayoutTransition applySubnodeInsertions];

// Kick off animating the layout transition
[self animateLayoutTransition:pendingLayoutTransitionContext];
{
as_activity_create_for_scope("Animate layout transition");
[self animateLayoutTransition:pendingLayoutTransitionContext];
}

// Mark transaction as finished
[self _finishOrCancelTransition];
Expand Down Expand Up @@ -805,7 +833,7 @@ - (void)_completePendingLayoutTransition
*/
- (void)_completeLayoutTransition:(ASLayoutTransition *)layoutTransition
{
// Layout transition is not supported for nodes that are not have automatic subnode management enabled
// Layout transition is not supported for nodes that do not have automatic subnode management enabled
if (layoutTransition == nil || self.automaticallyManagesSubnodes == NO) {
return;
}
Expand Down
4 changes: 2 additions & 2 deletions Source/ASDisplayNode+Subclasses.h
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ NS_ASSUME_NONNULL_BEGIN

@end

#define ASDisplayNodeAssertThreadAffinity(viewNode) ASDisplayNodeAssert(!viewNode || ASDisplayNodeThreadIsMain() || !(viewNode).nodeLoaded, @"Incorrect display node thread affinity - this method should not be called off the main thread after the ASDisplayNode's view or layer have been created")
#define ASDisplayNodeCAssertThreadAffinity(viewNode) ASDisplayNodeCAssert(!viewNode || ASDisplayNodeThreadIsMain() || !(viewNode).nodeLoaded, @"Incorrect display node thread affinity - this method should not be called off the main thread after the ASDisplayNode's view or layer have been created")
#define ASDisplayNodeAssertThreadAffinity(viewNode) ASDisplayNodeAssert(!viewNode || ASMainThreadAssertionsAreDisabled() || ASDisplayNodeThreadIsMain() || !(viewNode).nodeLoaded, @"Incorrect display node thread affinity - this method should not be called off the main thread after the ASDisplayNode's view or layer have been created")
#define ASDisplayNodeCAssertThreadAffinity(viewNode) ASDisplayNodeCAssert(!viewNode || ASMainThreadAssertionsAreDisabled() || ASDisplayNodeThreadIsMain() || !(viewNode).nodeLoaded, @"Incorrect display node thread affinity - this method should not be called off the main thread after the ASDisplayNode's view or layer have been created")

NS_ASSUME_NONNULL_END
3 changes: 0 additions & 3 deletions Source/ASDisplayNode.h
Original file line number Diff line number Diff line change
Expand Up @@ -748,9 +748,6 @@ extern NSInteger const ASDefaultDrawingPriority;

@end

@interface ASDisplayNode (ASLayoutElementAsciiArtProtocol) <ASLayoutElementAsciiArtProtocol>
@end

@interface ASDisplayNode (ASLayout)

/** @name Managing dimensions */
Expand Down
Loading