Skip to content

Texture 便捷方法

pro648 edited this page Dec 16, 2019 · 1 revision

这是 Texture 文档系列翻译,其中结合了自己的理解和工作中的使用体会。如果哪里有误,希望指出。

  1. Texture 核心概念

  2. Texture 布局 Layout

  3. Texture 便捷方法

  4. Texture 性能优化

  5. Texture 容器 Node Containers

  6. Texture 基本控件 Node

  7. Texture 中 Node 的生命周期

Hit Test Slop

ASDisplayNode具有类型为UIEdgeInsetshitTestSlop属性。当将其设置为正值时,缩小点击范围;设置为负值时,扩大点击范围。

所有 node 均继承自ASDisplayNode,因此所有 node 均可以使用hitTestSlop属性。

node 获取触摸事件的能力受父 node 尺寸、hitTestSlop限制,如果子 node 想要超出父 node 尺寸,则需要扩大父 node hitTestSlop以包含 child node 需要响应触摸事件区域。

用途

如果控件高度不足44 point(推荐的最小点击范围),则可以计算差值,使用hitTestSlop属性设置负值扩大点击区域。

ASTextNode *textNode = [[ASTextNode alloc] init];

CGFloat padding = (44.0 - button.bounds.size.height)/2.0;
textNode.hitTestSlop = UIEdgeInsetsMake(-padding, 0, -padding, 0);

批量拉取

Texture 的批量拉取(batch fetching api)功能可以很方便的拉取数据。UIKit通常在scrollViewDidScroll:方法中实现批量拉取功能,Texture 提供了一种更易用的拉取机制。

默认情况下,当用户滑动到距离 table、collection 内容末尾两屏幕时,将尝试拉取更多数据。

如果需要配置触发拉取的距离,只需设置ASTableNodeASCollectionNodeleadingScreensForBatching属性。

tableNode.leadingScreensForBatching = 3.0;  // overriding default of 2.0

批量拉取 delegate

在以下方法中决定是否执行批量拉取:

// ASTableNode
- (BOOL)shouldBatchFetchForTableNode:(ASTableNode *)tableNode
{
  if (_weNeedMoreContent) {
    return YES;
  }

  return NO;
}

// ASCollectionNode
- (BOOL)shouldBatchFetchForCollectionNode:(ASCollectionNode *)collectionNode
{
  if (_weNeedMoreContent) {
    return YES;
  }

  return NO;
}

当进入到需要拉取的区域时会触发上述方法。如果有更多数据则返回YES,进行拉取;反之,不拉取。

如果未实现上述方法,在进入拉取区域时会通知其asyncDelegate

如果上述方法返回了YES,则会调用下面的方法:

// ASTableNode
-tableNode:willBeginBatchFetchWithContext:

// ASCollectionNode
-collectionNode:willBeginBatchFetchWithContext:

在上述方法中执行拉取工作。比如网络 API、本地数据库。

- (void)tableNode:(ASTableNode *)tableNode willBeginBatchFetchWithContext:(ASBatchContext *)context 
{
  // Fetch data most of the time asynchronously from an API or local database
  NSArray *newPhotos = [SomeSource getNewPhotos];

  // Insert data into table or collection node
  [self insertNewRowsInTableNode:newPhotos];

  // Decide if it's still necessary to trigger more batch fetches in the future
  _stillDataToFetch = ...;

  // Properly finish the batch fetch
  [context completeBatchFetching:YES];
}

上述API会在后台线程调用。如需在主线程执行任务,则应将其调度到主线程。

拉取完成后,必须告知 Texture 该过程已经完成。为此,需要使用参数context调用completeBatchFetching:方法,且为completeBatchFetching:方法传入YES。只有传入YES,再次需要拉取时才会尝试拉取。

可以查看以下部分demo了解具体使用:

自动节点管理

想要使用 Texture 布局动画,必须开启自动节点管理(Automic Subnode Management,简称ASM)。即使没有使用 Texture 布局动画,开启 ASM 也可以减少代码量。

开启 ASM 后,无需调用addSubnode:removeFromSuperNode方法。添加、移除完全由layoutSpecThatFits:方法决定。

示例

示例代码来自ASDKgram,其中ASCellNode创建了一个简单的照片流。

下面的代码1使用了熟悉的addSubNode:模式,代码2使用了 automatic subside management。如下所示:

// 代码1
- (instancetype)initWithPhotoObject:(PhotoModel *)photo {
    self = [super init];
    
    if (self) {
        _photoModel = photo;
        
        _userAvatarImageNode = [[ASNetworkImageNode alloc] init];
        _userAvatarImageNode.URL = photo.ownerUserProfile.userPicURL;
        [self addSubnode:_userAvatarImageNode];
        
        _photoImageNode = [[ASNetworkImageNode alloc] init];
        _photoImageNode.URL = photo.URL;
        [self addSubnode:_photoImageNode];
        
        _userNameTextNode = [[ASTextNode alloc] init];
        _userNameTextNode.attributedString = [photo.ownerUserProfile usernameAttributedStringWithFontSize:FONT_SIZE];
        [self addSubnode:_userNameTextNode];
        
        _photoLocationTextNode = [[ASTextNode alloc] init];
        [photo.location reverseGeocodedLocationWithCompletionBlock:^(LocationModel *locationModel) {
            if (locationModel == _photoModel.location) {
                _photoLocationTextNode.attributedString = [photo locationAttributedStringWithFontSize:FONT_SIZE];
                [self setNeedsLayout];
            }
        }];
        [self addSubnode:_photoLocationTextNode];
    }
    
    return self;
}

// 代码2
- (instancetype)initWithPhotoObject:(PhotoModel *)photo {
    self = [super init];
    
    if (self) {
        self.automaticallyManagesSubnodes = YES;
        
        _photoModel = photo;
        
        _userAvatarImageNode = [[ASNetworkImageNode alloc] init];
        _userAvatarImageNode.URL = photo.ownerUserProfile.userPicURL;
        
        _photoImageNode = [[ASNetworkImageNode alloc] init];
        _photoImageNode.URL = photo.URL;
        
        _userNameTextNode = [[ASTextNode alloc] init];
        _userNameTextNode.attributedString = [photo.ownerUserProfile usernameAttributedStringWithFontSize:FONT_SIZE];
        
        _photoLocationTextNode = [[ASTextNode alloc] init];
        [photo.location reverseGeocodedLocationWithCompletionBlock:^(LocationModel *locationModel) {
            if (locationModel == _photoModel.location) {
                _photoLocationTextNode.attributedString = [photo locationAttributedStringWithFontSize:FONT_SIZE];
                [self setNeedsLayout];
            }
        }];
    }
    
    return self;
}

_userAvatarImageNode_photoImageNode_photoLocationLabel根据网络数据决定是否添加到视图中,应当何时添加呢?

ASM 会根据 cell 的ASLayoutSpec决定是否将其添加到 UI 中。

ASLayoutSpeck决定 UI 的视图层级,其由layoutSpecThatFits:返回。

你需要使用layoutSpecThatFits:构建视图,查看ASCellNode的部分布局代码:

- (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize {
    ASStackLayoutSpec *headerSubStack = [ASStackLayoutSpec verticalStackLayoutSpec];
    headerSubStack.flexShrink         = YES;
    if (_photoLocationLabel.attributedString) {
        [headerSubStack setChildren:@[_userNameLabel, _photoLocationLabel]];
    } else {
        [headerSubStack setChildren:@[_userNameLabel]];
    }
    
    _userAvatarImageNode.preferredFrameSize = CGSizeMake(USER_IMAGE_HEIGHT, USER_IMAGE_HEIGHT);     // constrain avatar image frame size
    
    ASLayoutSpec *spacer           = [[ASLayoutSpec alloc] init];
    spacer.flexGrow                = YES;
    
    UIEdgeInsets avatarInsets      = UIEdgeInsetsMake(HORIZONTAL_BUFFER, 0, HORIZONTAL_BUFFER, HORIZONTAL_BUFFER);
    ASInsetLayoutSpec *avatarInset = [ASInsetLayoutSpec insetLayoutSpecWithInsets:avatarInsets child:_userAvatarImageNode];
    
    ASStackLayoutSpec *headerStack = [ASStackLayoutSpec horizontalStackLayoutSpec];
    headerStack.alignItems         = ASStackLayoutAlignItemsCenter;                     // center items vertically in horizontal stack
    headerStack.justifyContent     = ASStackLayoutJustifyContentStart;                  // justify content to the left side of the header stack
    [headerStack setChildren:@[avatarInset, headerSubStack, spacer]];
    
    // header inset stack
    UIEdgeInsets insets                = UIEdgeInsetsMake(0, HORIZONTAL_BUFFER, 0, HORIZONTAL_BUFFER);
    ASInsetLayoutSpec *headerWithInset = [ASInsetLayoutSpec insetLayoutSpecWithInsets:insets child:headerStack];
    
    // footer inset stack
    UIEdgeInsets footerInsets          = UIEdgeInsetsMake(VERTICAL_BUFFER, HORIZONTAL_BUFFER, VERTICAL_BUFFER, HORIZONTAL_BUFFER);
    ASInsetLayoutSpec *footerWithInset = [ASInsetLayoutSpec insetLayoutSpecWithInsets:footerInsets child:_photoCommentsNode];
    
    // vertical stack
    CGFloat cellWidth                  = constrainedSize.max.width;
    _photoImageNode.preferredFrameSize = CGSizeMake(cellWidth, cellWidth);              // constrain photo frame size
    
    ASStackLayoutSpec *verticalStack   = [ASStackLayoutSpec verticalStackLayoutSpec];
    verticalStack.alignItems           = ASStackLayoutAlignItemsStretch;                // stretch headerStack to fill horizontal space
    [verticalStack setChildren:@[headerWithInset, _photoImageNode, footerWithInset]];
    
    return verticalStack;
}

可以看到headerSubStack的 children 根据_photoLocationLabel字符串是否存在来决定。

更新ASLayoutSpec

如果某些变化改变了ASLayoutSpec,需要调用setNeedsLayout。其与动画中的transitionLayout:duration:0方法类似。可以在PhotoCellNode中查看如下:

    [photo.location reverseGeocodedLocationWithCompletionBlock:^(LocationModel *locationModel) {
      
      // check and make sure this is still relevant for this cell (and not an old cell)
      // make sure to use _photoModel instance variable as photo may change when cell is reused,
      // where as local variable will never change
      if (locationModel == _photoModel.location) {
        _photoLocationLabel.attributedText = [photo locationAttributedStringWithFontSize:FONT_SIZE];
        [self setNeedsLayout];
      }
    }];

构建好的ASLayoutSpec将自动添加、移除或设置动画。

可以查看ASDKgramdemo了解具体布局过程,你会发现编写ASCellNode非常简单,该布局会根据大量单独数据自行调整。

该示例非常简单,但此功能有许多更强大的用途。

当 node 开启了 ASM 后,将不能调用addSubnode:removeFromSuperNode方法,否则会抛出 A flattened layout must consist exclusively of node sublayouts 的异常。

反转 inversion

ASTableNodeASCollectionNode有一个BOOL类型属性inverted,当设置为YES时会自动反转内容,以便从下到上进行布局,即第一个(indexPath 为(0, 0)) node 位于底部,而非像往常一样在顶部。这对于聊天应用非常方便,只需设置inverted属性。

开启 inversion 后,开发者只需调整ASTableNodeASCollectionNodecontentInset属性。如下所示:

  CGFloat inset = [self topBarsHeight];
  self.tableNode.view.contentInset = UIEdgeInsetsMake(0, 0, inset, 0);
  self.tableNode.view.scrollIndicatorInsets = UIEdgeInsetsMake(0, 0, inset, 0);
  
  let inset = self.topBarsHeight
  self.tableNode.view.contentInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: inset, right: 0.0)
  self.tableNode.view.scrollIndicatorInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: inset, right: 0.0)

查看SocialAppLayout-Inverteddemo了解详细实现。

修改图像块 Image Modification Blocks

通常,修改图像外观的操作对于主线程来说是昂贵操作,将其移到后台线程更为高效。

通过将imageModificationBlock分配给 imageNode,可以定义一组转换。转换会异步修改图像。

_backgroundImageNode.imageModificationBlock = ^(UIImage *image) {
	UIImage *newImage = [image applyBlurWithRadius:30
		tintColor:[UIColor colorWithWhite:0.5 alpha:0.3]
		saturationDeltaFactor:1.8
		maskImage:nil];
	return newImage ?: image;
};

//some time later...

_backgroundImageNode.image = someImage;

someImage 先异步修改,后分配给 imageNode 进行显示。

添加图像处理

利用imageModificationBlock添加图像处理是最高效的方式。如果提供了 block,则可以在显示阶段执行绘制工作。由于显示是在后台线程执行的,因此不会堵塞主线程。

在下面的代码中,在父视图init方法中初始化 avatar node,avatar node 头像需要为圆形。通过提供imageModificationBlock将头像转换为圆形。

- (instancetype)init
{
// ...
  _userAvatarImageNode.imageModificationBlock = ^UIImage *(UIImage *image) {
    CGSize profileImageSize = CGSizeMake(USER_IMAGE_HEIGHT, USER_IMAGE_HEIGHT);
    return [image makeCircularImageWithSize:profileImageSize];
  };
  // ...
}

实际的绘制代码添加到了UIImage的分类中,如下所示:

@implementation UIImage (Additions)
- (UIImage *)makeCircularImageWithSize:(CGSize)size
{
  // make a CGRect with the image's size
  CGRect circleRect = (CGRect) {CGPointZero, size};

  // begin the image context since we're not in a drawRect:
  UIGraphicsBeginImageContextWithOptions(circleRect.size, NO, 0);

  // create a UIBezierPath circle
  UIBezierPath *circle = [UIBezierPath bezierPathWithRoundedRect:circleRect cornerRadius:circleRect.size.width/2];

  // clip to the circle
  [circle addClip];

  // draw the image in the circleRect *AFTER* the context is clipped
  [self drawInRect:circleRect];

  // get an image from the image context
  UIImage *roundedImage = UIGraphicsGetImageFromCurrentImageContext();

  // end the image context since we're not in a drawRect:
  UIGraphicsEndImageContext();

  return roundedImage;
}
@end

imageModificationBlock方法非常方便,可以用于添加各种图像效果而无需额外的调用显示。例如:圆角、边框或其他覆盖。

占位符 Placeholders

ASDisplayNode 实现占位符

ASDisplayNode的子类可以实现placeholderImage方法,以提供覆盖内容的占位符,直到节点内容完成显示。要使用占位符,请设置placeholderEnabled = YES;,另外还可以选择实现placeholderFadeDuration

对于渲染图片,使用 node 的calculatedSize属性。

placeholderImage函数会在后台线程调用,因此需要确保线程安全。[UIImage imageNamed:]在 iOS 9 及以后线程安全,如果需要支持更低版本系统,可以使用[UIImage imageWithContentsOfFile:]方法。

imageNamed:方法会先检查缓存中是否有所需图片,如果缓存中不存在所需图片,则从 asset catalog 或磁盘加载。系统可能清空缓存以释放内存,清空时只会移除在缓存中且未正在使用的图片。

如果图片只显示一次,不想将图片加入到缓存中,则可以使用imageWithContentsOfFile:方法加载图片。

UIImage+ASConvenience分类提供了创建占位图图片的方法,包括圆形、矩形等。

查看Placeholdersdemo了解具体使用。

ASNetworkImageNode 默认图片

除占位符,ASNetworkImageNode还有defaultImage属性。占位符一般是临时性的,默认图片可能永久存在。例如图片 URL 为nil,或加载失败。

推荐为头像设置默认图片,为图片设置占位符。

与UICollectionViewCell组合使用

ASCollectionNode提供了ASCellNodeUICollectionViewCell同时使用的机制,但UICollectionViewCell不会获得预加载、异步布局、异步渲染的性能提升,即使与其他ASCellNode组合使用在同一个ASCollectionNode中。

组合使用方便开发者尝试 Texture,而不必重写所有 cell。

想用组合使用UICollectionViewCell,需满足:

  1. 遵守ASCollecitonDataSourceInterop协议,可选实现ASCollectionDelegateInterop协议。
  2. viewDidLoad中使用collectionNode.view调用registerCellClass:,或注册一个onDidLoad:块。
  3. collectionNode:nodeBlockForItemAtIndexPath:方法或collectionNode:nodeForItemAtIndexPath:方法中返回nil。需要注意的是,不能在nodeBlock中返回nil。
  4. 最后,必须实现提供 item 大小的方法。有以下两种方式:
    1. UICollectionViewFlowLayout,实现collectionNode:constrainedSizeForItemAtIndexPath:方法。
    2. 自定义布局。设置view.layoutInspector,并实现collectionView:constrainedSizeForNodeAtIndexPath:

默认情况下,UICollectionViewDataSource只在未提供ASCellNode时使用。然而,如果开启了dequeuesCellsForNodeBackedItems,则会调用UICollectionViewDataSource协议内方法重用cell,并期望返回ASCollectionViewCells类型对象。

CustomCollectionViewdemo 演示了如何组合使用UIKitcell和ASCellNodes

打开demo后确保kShowUICollectionViewCellsYES。在这个示例中,collectionNode:nodeBlockForItemAtIndexPath:会为3倍数 cell 返回 nil。当返回 nil 时,ASCollectionNode会自动调用collectionView:cellForItemAtIndexPath:方法。

- (ASCellNodeBlock)collectionNode:(ASCollectionNode *)collectionNode nodeBlockForItemAtIndexPath:(NSIndexPath *)indexPath {
  if (kShowUICollectionViewCells && indexPath.item % 3 == 1) {
    // When enabled, return nil for every third cell and then cellForItemAtIndexPath: will be called.
    return nil;
  }
  
  UIImage *image = _sections[indexPath.section][indexPath.item];
  return ^{
    return [[ImageCellNode alloc] initWithImage:image];
  };
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
  return [_collectionNode.view dequeueReusableCellWithReuseIdentifier:kReuseIdentifier forIndexPath:indexPath];
}

上一篇:Texture 布局 Layout

下一篇:Texture 性能优化

Clone this wiki locally