- 移动开发
- UI组件
【免费下载链接】Texture
Smooth asynchronous user interfaces for iOS apps.
Texture(AsyncDisplayKit)以自动布局(Layout Specs)闻名,但并非所有场景都适合用它。当你的节点层级复杂、需要精确控制子节点坐标,或希望保留 UIKit 时代的手写布局习惯时,Texture 依然提供了完整的手动布局(Manual Layout)能力。本文以 layout2-manual-layout.md 为主线,从 UIKit 传统布局的痛点出发,逐步讲解 Texture 手动布局的核心方法calculateSizeThatFits:与layout的用法、线程模型与缓存机制,并结合源码深入剖析其背后的测量缓存原理。
一、为什么还要手动布局:UIKit 布局方式的两大痛点
在进入 Texture 的 API 之前,先回顾 UIKit 中自定义视图的经典做法。一个同时容纳文本视图与图片视图的自定义UIView,通常需要实现两个方法:
-sizeThatFits::计算视图在给定尺寸约束下应有的内容尺寸;-layoutSubviews:按计算好的尺寸为子视图设置 frame。
一个典型的实现如下(Objective-C):
- (CGSize)sizeThatFits:(CGSize)size { // size the image CGSize imageSize = [_imageView sizeThatFits:size]; // size the text view CGSize maxTextSize = CGSizeMake(size.width - imageSize.width, size.height); CGSize textSize = [_textView sizeThatFits:maxTextSize]; // make sure everything fits CGFloat minHeight = MAX(imageSize.height, textSize.height); return CGSizeMake(size.width, minHeight); } - (void)layoutSubviews { CGSize size = self.bounds.size; // convenience // size and layout the image CGSize imageSize = [_imageView sizeThatFits:size]; _imageView.frame = CGRectMake(size.width - imageSize.width, 0.0f, imageSize.width, imageSize.height); // size and layout the text view CGSize maxTextSize = CGSizeMake(size.width - imageSize.width, size.height); CGSize textSize = [_textView sizeThatFits:maxTextSize]; _textView.frame = (CGRect){ CGPointZero, textSize }; }这段代码存在两个明显问题:
1. 重复测量(double sizing):子视图在sizeThatFits:中测了一次,在layoutSubviews中又测了一次。虽然这里的算术足够简单廉价,但一旦换成文本排版这类昂贵操作,代价会被放大。
2. 主线程阻塞:sizeThatFits:与layoutSubviews都运行在主线程。而 UILabel、UITextView 的尺寸计算成本很高,它们会拖慢滚动帧率、造成卡顿。
直观的改进方案是缓存子视图尺寸,比如引入_imageSize、_textSize两个 ivar。但正如原文档指出的,缓存方案很快会失控——文本内容一旦变化就必须重新计算尺寸,随之而来的是大量样板代码与状态同步负担。
更激进的方案是借助dispatch_async()把测量搬到后台线程。然而 UIKit 官方文档明确要求视图操作必须发生在主线程:
Manipulations to your application’s user interface must occur on the main thread. Thus, you should always call the methods of the UIView class from code running in the main thread of your application. The only time this may not be strictly necessary is when creating the view object itself but all other manipulations should occur on the main thread.
于是有人尝试绕开 UILabel / UITextView,手工搭建 TextKit 排版栈在后台完成测量。这几乎是把系统排版引擎重写一遍——工作量巨大,且一旦 iOS 更新改变 UITextView 的排版行为,这些手工代码就会失效。更何况,TextKit 本身也不是线程安全的。这正是 Texture 手动布局 API 存在的理由。
二、Texture 手动布局的核心:两个方法,两套线程
Texture 手动布局由ASDisplayNode的两个可覆写方法构成,二者分工明确,分别运行在不同线程上。
calculateSizeThatFits::后台线程完成昂贵测量
- [ASDisplayNode calculateSizeThatFits:]这是测量阶段。子类需要基于传入的constrainedSize(约束尺寸)返回节点应有的固有内容尺寸。该方法在后台线程调用,因此适合在其中执行昂贵的尺寸计算——这正是解决 UIKit 主线程阻塞痛点的关键。
从源码注释可以确认这一点。ASDisplayNode+Subclasses.h 中明确说明:
Subclasses that override should expect this method to be called on a non-main thread.
并且源码特别强调:覆写该方法即意味着节点承诺使用手动布局,因此必须同时覆写-layout来布局所有子节点或子视图。同时,该方法不应被外部直接调用,测量入口应使用-layoutThatFits:或-layoutThatFits:parentSize:。
layout:主线程只做"搬运"工作
- [ASDisplayNode layout]这是布局阶段,由视图的-layoutSubviews触发,在主线程调用。按照设计意图,这里应当只做最少量的工作——利用测量阶段缓存的尺寸,为各个子节点设置 frame,不再执行任何昂贵的测量。
在 ASDisplayNode+Subclasses.h 中,-layout被描述为"由视图的 layoutSubviews 在主线程调用,子类覆写它以布局所有子节点或子视图"。原文档还补充说明:在测量与布局之后,可以在layout中对那些不参与自动布局系统、未被layoutSpecThatFits:引用的节点做进一步布局。
线程模型小结
| 方法 | 运行线程 | 职责 | 成本 |
|---|---|---|---|
calculateSizeThatFits: | 后台线程 | 测量子节点、缓存中间结果 | 昂贵操作都放这里 |
layout | 主线程 | 利用缓存尺寸设置 frame | 尽量轻量 |
这套分工与 UIKit 的最大区别在于:测量不再占用主线程,且测量结果被缓存,layout阶段可以直接复用。
三、完整示例:手动布局一个"图片 + 文本"节点
原文档给出了一个完整的 Objective-C 示例,将第一节的 UIKit 视图改写为 Texture 节点。需要引入子类扩展头文件:
#import <AsyncDisplayKit/AsyncDisplayKit+Subclasses.h>节点实现如下:
// perform expensive sizing operations on a background thread - (CGSize)calculateSizeThatFits:(CGSize)constrainedSize { // size the image CGSize imageSize = [_imageNode layoutThatFits:ASSizeRangeMake(CGSizeZero, constrainedSize)].size; // size the text node CGSize maxTextSize = CGSizeMake(constrainedSize.width - imageSize.width, constrainedSize.height); CGSize textSize = [_textNode layoutThatFits:ASSizeRangeMake(CGSizeZero, maxTextSize)].size; // make sure everything fits CGFloat minHeight = MAX(imageSize.height, textSize.height); return CGSizeMake(constrainedSize.width, minHeight); } // do as little work as possible in main-thread layout - (void)layout { // layout the image using its cached size CGSize imageSize = _imageNode.calculatedSize; _imageNode.frame = CGRectMake(self.bounds.size.width - imageSize.width, 0.0f, imageSize.width, imageSize.height); // layout the text view using its cached size CGSize textSize = _textNode.calculatedSize; _textNode.frame = (CGRect){ CGPointZero, textSize }; }逐段拆解这段代码:
测量阶段(后台线程):
- 通过
ASSizeRangeMake(CGSizeZero, constrainedSize)构造一个从零到约束尺寸的ASSizeRange,交给_imageNode layoutThatFits:; - 用剩余宽度作为文本节点的最大约束,再次调用
layoutThatFits:; - 取两者高度最大值作为整节点高度返回。
布局阶段(主线程):
- 直接读取
_imageNode.calculatedSize与_textNode.calculatedSize——这些尺寸已经在测量阶段缓存,无需再次计算; - 镜像位于右侧,文本占据左侧剩余空间,与 UIKit 版本布局完全一致。
关于ASSizeRange:它由min与max两个CGSize组成(见 ASLayout.h 与 ASLayoutElement.h),用于描述"至少多大、至多多大"的约束区间。ASSizeRangeMake(CGSizeZero, constrainedSize)表示允许尺寸从 0 到约束上限之间自由取值。
四、layoutThatFits: 与 calculatedSize:测量缓存的底层原理
-layoutThatFits:是理解手动布局的关键。它类似于-sizeThatFits:,但带有副作用:它会缓存测量结果,供后续快速访问。这正是-layout可以瞬间完成的原因。
缓存结果通过两个只读属性暴露:
calculatedSize(CGSize):最近一次测量得到的尺寸;calculatedLayout(ASLayout):最近一次测量得到的完整布局对象,它对手动布局的节点包装了calculateSizeThatFits:返回的尺寸。
在 ASDisplayNode+Subclasses.h 中,对calculatedLayout的说明值得细读:
- 只有对节点调用过
-layoutThatFits:,calculatedLayout才会被设置; - 手动布局模式下,你必须在
-calculateSizeThatFits:的实现里对子节点调用-layoutThatFits:; - 对于使用自动布局(
layoutSpecThatFits:)的节点,ASLayoutSpec 会自动对所有子节点调用-layoutThatFits:,基类的-layout实现也会自动利用子节点的calculatedLayout,通常无需手动访问该属性。
所以对"图片 + 文本"示例而言,-layout中读取的_imageNode.calculatedSize、_textNode.calculatedSize,正是测量阶段-layoutThatFits:写入缓存的成果。
缓存触发条件的细节:-layoutThatFits:机制只有在需要新的测量时才会真正调用calculateSizeThatFits:。原文档明确列举了两个条件:
- 约束尺寸(constrained size)发生变化;
- 节点没有实现
layoutSpecThatFits:(即处于手动布局模式)。
这一点在源码层面也有印证:ASDisplayNode.mm 中calculateLayoutThatFits:restrictedToSize:relativeToParentSize:会先解析节点的style.size与父尺寸,将结果与传入约束求交集(ASSizeRangeIntersect),再分发到实际的计算方法。而 ASDisplayNode+Subclasses.h 则说明默认的calculateLayoutThatFits:会依据子类覆写情况调用layoutSpecThatFits:或calculateSizeThatFits:二者之一。
从源码结构看,缓存机制由布局版本号(layout version)驱动:ASDisplayNode.mm 中invalidateCalculatedLayout会递增_layoutVersion、清空_unflattenedLayout,从而标记旧缓存失效,下一次测量才会重新计算。
五、失效与缓存维护:invalidateCalculatedSize 的三种场景
手动布局节点在测量阶段缓存了尺寸,随之而来的问题是:内容变了怎么办?原文档给出的答案是"Nodes should call[self invalidateCalculatedSize]when necessary",并列举了三条编写手动布局节点时必须牢记的准则:
1. 递归测量所有子节点calculateSizeThatFits:必须递归地测量全部子节点(通过-layoutThatFits:完成)。如上文所述,-layoutThatFits:机制只会在需要新测量时(约束变化、未实现layoutSpecThatFits:)才调用-calculateSizeThatFits:。
2. 昂贵预计算放入测量阶段,缓存中间结果任何其他昂贵的预布局计算都应放在-calculateSizeThatFits:中完成,并把有用的中间结果缓存在 ivar 里,供-layout阶段复用。
3. 必要时调用失效方法当节点内容发生变化、旧尺寸不再有效时,必须主动使缓存失效。原文档举例:ASTextNode在其attributedString属性改变时会自动失效计算尺寸。
需要指出的是,源码中的实际方法名是-invalidateCalculatedLayout(见 ASDisplayNode+Subclasses.h 的声明),原文档写作invalidateCalculatedSize是早期版本的命名。两者含义相同:使节点此前测量并缓存的布局失效,强制下一次测量重新计算。其实现位于 ASDisplayNode.mm,核心动作是递增布局版本号并清空未扁平化布局缓存。另外 ASDisplayNode.mm 显示,setNeedsLayout内部也会调用invalidateCalculatedLayout。
仓库中真实调用失效方法的例子可参考 ASEditableTextNode.mm:当UITextView的文本内容变化(textViewDidChange:)时,会调用[self invalidateCalculatedLayout],注释明确写着"Invalidate, as our calculated size depends on the textview's seeded text"——这正是原文档所述准则在真实组件中的落地。
六、手动布局 vs 自动布局:何时选择哪一种
原文档在结尾明确给出立场:自动布局优先,应作为大多数场景的首选;手动布局仅在特殊需求下使用。结合仓库源码可以补充两者的边界:
- 自动布局(Layout Specs):实现
layoutSpecThatFits:(或提供layoutSpecBlock),声明式描述布局。ASLayoutSpec 自动测量子节点、基类-layout自动利用缓存布局,你几乎不需要手写 frame。ASDisplayNode+Subclasses.h 明确指出自动布局节点通常无需访问calculatedLayout。 - 手动布局(Manual Layout):覆写
calculateSizeThatFits:与layout,命令式地测量与摆放。适合精确控制坐标、需要复用既有 UIKit 布局思路、或对布局过程有特殊要求的场景。
值得注意的约束:子类必须在layoutSpecThatFits:与calculateSizeThatFits:中至多覆写其一。源码中有一处断言会检查这一点(见 ASDisplayNode+Subclasses.h):若子类两种方法都覆写,会触发如下断言提示:
Subclass %@ must at least provide a layoutSpecBlock or override at most one of the three layout methods: calculateLayoutThatFits:, layoutSpecThatFits:, or calculateSizeThatFits:
这意味着"手动布局 + 自动布局"两种模式是互斥的,选择一种就要贯彻到底。
七、总结
Texture 的手动布局把 UIKit 的"测量 + 布局"两段式流程提升到了新的层次:
calculateSizeThatFits:在后台线程执行昂贵的测量,彻底摆脱主线程阻塞;layout在主线程只做最轻量的 frame 设置;layoutThatFits:缓存测量结果(calculatedSize/calculatedLayout),让布局阶段零成本复用;- 内容变化时通过
invalidateCalculatedLayout主动失效缓存,保证尺寸始终新鲜。
这套 API 与自动布局(Layout Specs)共用同一套测量基础设施,只不过把"声明式描述"换成了"命令式计算"。原文档的立场依然成立:能自动布局就自动布局;但当你需要精确控制、或者手头有一段成熟的 UIKit 布局逻辑需要迁移时,手动布局是一条完全可行、且线程安全的后路。
- 移动开发
- UI组件
【免费下载链接】Texture
Smooth asynchronous user interfaces for iOS apps.
相关推荐
G6 布局 API 完全指南:setLayout / getLayout / layout / stopLayout 与布局配置实战
G6 布局 API 完全指南:setLayout / getLayout / layout / stopLayout 与布局配置实战 G6( @antv/g6
数据可视化前端图表库G6 布局 API 完全指南:setLayout / getLayout / layout / stopLayout 与组合布局实战
G6 布局 API 完全指南:setLayout / getLayout / layout / stopLayout 与组合布局实战 导读 本文围绕 G6(An
数据可视化前端图表库Vuetify 应用布局系统(Application Layout)完全指南:outside-in 原理、组件排序与动态布局实战
Vuetify 应用布局系统(Application Layout)完全指南:outside in 原理、组件排序与动态布局实战 Vuetify 内置了一套名为
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考