iOS多行文本高度测量:NSStringDrawingOptions组合实战

发布时间:2026/11/6 9:24:15
iOS多行文本高度测量:NSStringDrawingOptions组合实战

做图文分享、做列表页自适应高度、做长文详情页的折叠,绕不开NSStringDrawingOptions这个枚举。它看起来只有四五个值,文档描述也短得可怜,但真正在项目里用过的人都知道,这几个选项的组合直接决定了你算出来的高度是准的、多一行的、还是少了半行的。我在做资讯类、社区类界面时,几乎每隔一段时间就要回来重新梳理一遍:为什么这个选项要带上、为什么那个选项带上之后 UILabel 里反而留白、为什么同一段文字在测量结果和实际渲染里差了一个像素。这篇就按我自己的理解顺序,把NSStringDrawingOptions里每一个选项的用途、背后的排版原理、以及组合使用时的实际效果讲透,顺带把踩过的坑整理成可以直接对照的排查表。内容偏向 iOS 文本绘制这一块,适合已经会写 UILabel、但被动态高度和绘制精度折腾过的开发者,也适合刚接触富文本排版、想搞清楚"为什么高度总是差一点"的同学。

1. 先把坐标系和默认行为搞清楚

1.1 不传选项时,测量结果其实是"单行"的

很多人第一次用boundingRectWithSize:options:attributes:context:时的直觉是:我传了一个 size,它就应该按这个 size 去排版,超出宽度自动折行。这个直觉是错的,而且错得很有迷惑性——因为它在某些情况下"看起来"是对的。

不带NSStringDrawingUsesLineFragmentOrigin时,系统计算尺寸的方式是把整段字符串当成一条不折行的文本流,量出它完整的排版宽度和单行的排版高度。也就是说,你传进去的size.width在这一步基本不参与折行决策,结果里的rect.size.width完全可能比你给的宽度大得多,而rect.size.height就只有一行的高度。

NSString *text = @"这是一段需要自动折行的长文本,用来验证测量行为……"; NSDictionary *attrs = @{NSFontAttributeName: [UIFont systemFontOfSize:15]}; // 错误用法:高度永远是一行的 CGRect r1 = [text boundingRectWithSize:CGSizeMake(200, CGFLOAT_MAX) options:0 attributes:attrs context:nil]; NSLog(@"%@", NSStringFromCGRect(r1)); // width 远超 200,height ≈ 单行高 // 正确用法:带上 line fragment origin CGRect r2 = [text boundingRectWithSize:CGSizeMake(200, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:attrs context:nil];

这个差异的根源在于两个方法共享同一套底层实现,而这套实现的默认起点是"文本基线原点",不是"行片段的左上角原点"。基线原点的世界里,一段文本就是一个整体,尺寸就是它的排版宽度加字高;只有切到行片段原点的世界,多行累加才有意义。

顺带说一句,sizeWithAttributes:也是同样的脾气。它只对字符串里显式存在的\n做出反应,不会因为你在别处设置了某个宽度就帮你折行。所以如果你在项目里见过"用 sizeWithAttributes 量出来的宽度特别大、放进 label 里却被截断"的情况,原因就在这里,不是字体有问题。

注意:paragraphStyle.lineBreakMode设置成NSLineBreakByWordWrapping并不会让测量自动折行。折行与否取决于NSStringDrawingUsesLineFragmentOrigin这个开关,而lineBreakMode决定的是"折的时候按什么规则折"。这两件事经常被混为一谈。

1.2 line fragment 到底指什么,为什么这个选项要叫这个名字

NSStringDrawingUsesLineFragmentOrigin这个名字起得很"排版学",直译过来是"使用行片段原点",很多人看完不知道在说什么。理解它的关键在于搞清楚"行片段"这个概念。

在文本排版引擎里,一行文本并不总是简单的一段字符。同一段落中,如果一行里混用了不同字体、不同字号、或者中间插入了一张图片附件,这一行内部就会被切成若干段连续的文本,每一段叫作一个行片段。一个逻辑行可以包含多个行片段,行片段之间共享同一套纵向基线,但各自的起点、宽度、字体度量可以完全不同。

排版引擎在纵向累加高度时,用的不是某一个字符的基线,而是每一行的行片段左上角原点。一个段落排出来 N 行,就是 N 个行片段原点按行高依次叠加,总高度自然就是所有的行占据的纵向范围。所以这个选项开启之后,引擎知道"我要按行去累加",多行高度才算得出来。

这一点在纯中文短句上感受不到差别,但在混合排版场景里就很明显:一行里同时有 18 号标题字、13 号注释字和一个图标附件时,行片段的实际高度由这一行里最高的那个片段决定,而不是由主字体决定。这也是为什么有些富文本用font.lineHeight乘以行数去估算高度,结果总是偏小的原因——它漏掉了那些被撑高的行。

1.3 返回值为什么经常是整数

如果你把boundingRectWithSize:的返回值打印出来观察,会发现它返回的宽高基本都是整数,而用sizeWithAttributes:得到的结果经常带小数。这是因为前者内部做了一次向上取整,把浮点尺寸包成了像素对齐的矩形。

向上取整这个动作本身是有意义的:文本排版出来的是浮点坐标,如果直接拿去设置 frame,可能因为 0.5 像素的偏差导致末行被裁掉一点点。取整之后至少保证内容放得下。但代价是:当你连续测量多个片段并相加时,每次取整都会累积误差。比如三段文本各自多出 0.4 像素,加起来就多出 1.2 像素,在紧凑列表里这 1 像素就是"最后一行只露出一条边"的元凶。

CGFloat totalH = 0; for (NSString *seg in segments) { CGRect r = [seg boundingRectWithSize:CGSizeMake(width, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading attributes:attrs context:nil]; totalH += CGRectGetHeight(r); // 每段都带了一点向上取整的余量 }

我在实际项目里遇到这类问题后的处理方式是:单段测量照常用取整结果,因为要和 UIFont 的行高对齐;但如果要做多段拼接,就先按浮点逻辑算好总量,最后统一取整一次,而不是每一段都取整。这个顺序的调整看起来很小,但能实打实消除掉一味向上累加带来的视觉缝隙。

2. 四个选项逐项拆解

2.1 NSStringDrawingUsesLineFragmentOrigin:多行计算的总开关

这是四个选项里唯一一个"不传就基本算不对"的。它的作用是告诉排版引擎:请以行片段的原点为起点来计算边界矩形,而不是以基线原点。开启了它,引擎才会按照给定的宽度去折行、累加行高,最终返回覆盖所有行的矩形。

用起来有几个必须注意的点。第一,size.width必须是一个真实有效的约束值,如果你传CGFLOAT_MAX,引擎没有宽度压力就不会折行,结果和没开这个选项差不多。第二,传宽度的时候最好用整数或者floor()处理一下,因为 200.5 这样的值会让引擎在临界位置多折出一行,而 UILabel 在实际渲染时可能用不同的取整策略,于是两边对不上。第三,开启之后返回的rect.origin通常是(0, 0),可以直接用rect.size;但如果叠加了别的选项,比如设备度量,origin就可能不再是零,取尺寸时务必用CGRectGetHeight这类函数,而不是直接读rect.size。

- (CGFloat)textHeight:(NSString *)text width:(CGFloat)width font:(UIFont *)font { if (text.length == 0 || width <= 0) { return 0; } NSMutableParagraphStyle *ps = [NSMutableParagraphStyle new]; ps.lineBreakMode = NSLineBreakByWordWrapping; NSDictionary *attrs = @{ NSFontAttributeName: font, NSParagraphStyleAttributeName: ps }; CGRect rect = [text boundingRectWithSize:CGSizeMake(floor(width), CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading attributes:attrs context:nil]; return ceil(CGRectGetHeight(rect)); }

这个函数我用了很久,绝大多数中文、英文混排的列表场景都能对上。它的局限后面会讲:碰到行内附件、复杂的行距设置、或者带lineBreakStrategy的属性文本时,还得再补东西。

提示:floor(width)这一步别省。屏幕上取到的宽度经常是 375.0、343.5 这种值,直接传浮点数进测量,在临界文本上出现"测量算 3 行、实际显示 4 行"的概率不低。

2.2 NSStringDrawingUsesFontLeading:行高到底该不该把 leading 算进去

要理解这个选项,得先拆开UIFont的几个度量属性。一个字体在纵向上的结构大致是:ascender(基线以上部分)、descender(基线以下部分,通常是负数)、leading(行与行之间的额外间距)。UIFont.lineHeight的定义是ascender - descender + leading,也就是三者之和。

NSStringDrawingUsesFontLeading的作用就是:在累加行高时把这些 leading 也算进去。不带这个选项,引擎按ascender - descender结算每行高度;带上之后,每行多出leading那么多。

不同字体的leading差别不小。西文系统字体的 leading 常常很小甚至接近零,中文字体的 leading 有时候能到一两个像素。所以会出现这样两种典型现象:

场景不带 UsesFontLeading带 UsesFontLeading
行数少、字体 leading 为 0几乎无差异几乎无差异
中文字体、行数多高度偏紧,行与行贴得近高度更宽松,接近排版观感
与 UILabel 高度对比经常偏小经常偏大一点点

我个人的做法是:测量多行文本时带上它,然后用 UILabel 实测一次去校准。因为 UILabel 内部的行高结算方式和NSStringDrawing不完全一致,前者更偏向ascender - descender,所以带上 leading 之后,算出来的高度会比 label 实际需要的高出一点,也就是底部留一条细缝。这个缝在卡片式布局里通常无所谓,甚至更耐看;但如果要做严格等高的行对齐(比如日历、表格),就得把这一步减掉。

// 不带 leading,用来对齐 UILabel 的紧凑高度 CGRect tight = [text boundingRectWithSize:size options:NSStringDrawingUsesLineFragmentOrigin attributes:attrs context:nil]; // 带 leading,用于自绘、生成图片等需要更舒展行距的场景 CGRect loose = [text boundingRectWithSize:size options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading attributes:attrs context:nil];

有个容易忽视的细节:如果你在NSParagraphStyle里设置了lineSpacing,这段额外间距是加在行与行之间的,和 leading 是两个独立来源。也就是说,多行文本的总高度近似等于行数 × (行高 + lineSpacing),而lineSpacing只在行与行之间生效,最后一行后面不加。很多估算公式漏掉了"最后一行不加间距"这一条,导致行数多的时候越算越离谱。

2.3 NSStringDrawingUsesDeviceMetrics:按字形边界而不是排版边界来量

这个选项用得最少,但在某些特定场景下非常有用。它的含义是:计算尺寸时使用字形的图像边界(glyph bounds),而不是字体排版边界(typographic bounds)。

这两者的区别可以用一个生活化的类比理解。排版边界像是小学写字用的田字格,不管你在里面写的是"一"还是"囊",格子都一样大,因为格子是按字体的 ascent、descent 统一给的。字形图像边界则像是拿尺子量你实际写出来的笔画范围,"一"只占薄薄一条,"囊"几乎撑满。所以开启NSStringDrawingUsesDeviceMetrics之后,得到的尺寸会"紧贴"文字实际笔画,上下留白被压缩掉。

它适合的场景很明确:需要知道文字真正占了多少像素的时候。比如把文字绘制成一张小图、做文字贴纸、做图片上的水印定位、或者需要精确对齐某个图形元素。反过来,凡是拿来做布局的场合都不该用它,因为它抛弃了字体度量里的留白,行与行之间的间距会变得不可预测。

// 用于绘制到图片上,需要紧贴文字边缘 CGRect glyphRect = [text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesDeviceMetrics attributes:attrs context:nil];

还有一点要留意:开启这个选项之后,返回矩形的origin可能不是(0, 0),因为字形边界可能从基线以上某个位置开始。取尺寸请务必用CGRectGetWidth/CGRectGetHeight,不要直接读size属性然后假设它从原点开始,否则绘制到画布上会整体偏移。

2.4 NSStringDrawingTruncatesLastVisibleLine:超出范围时给个省略号

在前三个选项里,文本超出给定高度时的默认行为是直接裁掉——画到一半截断,连个交代都没有。NSStringDrawingTruncatesLastVisibleLine就是用来兜底的:它告诉引擎,如果内容放不下,把最后一行截断并补上省略号。

这个选项生效需要几个前提条件,缺一个都不行。首先,必须同时开启NSStringDrawingUsesLineFragmentOrigin,否则没有"行"的概念可供截断。其次,NSParagraphStyle.lineBreakMode要是会自动折行的模式(按词折行或按字符折行),因为截断是在折行结果之上做的二次处理。第三,你要给的高度必须小于实际需要的高度,也就是真的放不下,否则它什么也不做。

它和NSLineBreakByTruncatingTail的区别经常被搞混。把lineBreakMode设成NSLineBreakByTruncatingTail时,文本压根不会折行,只在第一行末尾打省略号;而NSStringDrawingTruncatesLastVisibleLine是先正常折行排满,排到放不下为止,然后在最后一条可见行的末尾打省略号。前者是"单行截断",后者是"多行截断"。

- (void)drawQuote:(NSString *)text inRect:(CGRect)rect { NSMutableParagraphStyle *ps = [NSMutableParagraphStyle new]; ps.lineBreakMode = NSLineBreakByWordWrapping; // 允许折行 NSDictionary *attrs = @{ NSFontAttributeName: [UIFont systemFontOfSize:13], NSParagraphStyleAttributeName: ps }; [text drawWithRect:rect options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading | NSStringDrawingTruncatesLastVisibleLine attributes:attrs context:nil]; }

这个组合在做分享图、海报、名片这类"一次性绘制到画布"的场景里几乎是必备的。因为画布是固定尺寸的,用户输入的文案长度不可控,不加这个选项就会出现文字糊出边界、覆盖到别的图层上的事故。而那些用 UILabel 的界面反而用不上它,UILabel 的lineBreakMode自己会处理截断。

2.5 两个已废弃的选项:DisableScreenFontSubstitution 与 OneShot

翻文档的时候你会看到另外两个值:NSStringDrawingDisableScreenFontSubstitution和NSStringDrawingOneShot。它们都标着废弃,但老代码里确实还存在,值得说清楚它们当初是干什么的,这样你看到时能判断该不该动。

NSStringDrawingDisableScreenFontSubstitution来自一个屏幕字体替换的年代。早期的渲染管线在字号非常小时,为了保证清晰度会用一张位图化的字形来替换矢量字形,这个行为叫屏幕字体替换。这个选项就是用来关掉它的。随着渲染管线的升级,这套替换机制早就不存在了,所以现在不管传不传,结果都一样。

NSStringDrawingOneShot的意思是"一次性绘制,不缓存布局结果"。当初的用意是给那些只画一次、不需要复用布局的场合省内存。但现代实现里文本布局的缓存策略已经完全换了一套,这个标志传进去没有任何可观测的效果,反而会让代码读起来费解。

对这两个选项我的建议很简单:新代码不要碰;老代码里看到它们,直接删掉,观察一下界面,绝大多数情况下像素级完全一致。留着它们的唯一后果是后来接手的人花时间去查"这个选项是不是必须的"。

3. 组合实操:把这几个选项用成一套完整方案

3.1 单行文本测量:别用多行的配方

单行文本的测量反而是最容易被做复杂的。有些同学为了统一,把所有测量都套上UsesLineFragmentOrigin | UsesFontLeading,然后发现单行标签的高度总比设计稿高两个点,还在琢磨是不是字体问题。

单行的正确做法是:只量宽度,高度直接用字体算。

// 单行宽度:不带任何折行选项 CGSize textSize = [text sizeWithAttributes:@{NSFontAttributeName: font}]; // 高度直接用字体度量,避免取整带来的偏差 CGFloat lineH = ceil(font.lineHeight);

如果你需要严格贴合 UILabel 的单行高度,那就更简单了:把numberOfLines设成 1,让 label 自己sizeThatFits就行,完全不用测量。真正需要手动测量单行宽度的场合,通常是按钮内边距计算、标签胶囊宽度、横向流式布局这类。

这里有一个和行内附件相关的坑。当富文本里含有NSTextAttachment时,附件本身没有字体度量,sizeWithAttributes:算出来的宽度会漏掉图片宽度或者按错误尺寸估算。稳妥的处理是遍历属性文本取出附件的bounds,把图片宽度手工加到结果里。这属于上面那个函数的边界情况,如果你的场景里不会出现图片,可以不管。

3.2 多行高度测量的标准配方

多行测量的完整方案我在项目里固定成这样一套,直接可抄:

- (CGRect)boundingRectForAttributedText:(NSAttributedString *)attrText width:(CGFloat)width maxHeight:(CGFloat)maxHeight { if (attrText.length == 0 || width <= 0) { return CGRectZero; } // 补齐段落样式,确保测量端和显示端一致 NSMutableAttributedString *fixed = [attrText mutableCopy]; NSRange full = NSMakeRange(0, fixed.length); if (![fixed attribute:NSParagraphStyleAttributeName atIndex:0 effectiveRange:NULL]) { NSMutableParagraphStyle *ps = [NSMutableParagraphStyle new]; ps.lineBreakMode = NSLineBreakByWordWrapping; ps.lineSpacing = 4; [fixed addAttribute:NSParagraphStyleAttributeName value:ps range:full]; } NSStringDrawingOptions opts = NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading | NSStringDrawingTruncatesLastVisibleLine; return [fixed boundingRectWithSize:CGSizeMake(floor(width), maxHeight) options:opts context:nil]; }

这段代码里有三个刻意的设计,都值得解释。

第一处是"补齐段落样式"。这段逻辑的意义是:如果调用方传进来的富文本没有设置段落样式,就补一个和 UI 端完全相同的上去。为什么要这么较真?因为NSParagraphStyle里包含lineSpacing、paragraphSpacing、lineBreakMode、以及新版本的lineBreakStrategy,这些都会影响最终排版。测量时用一套、显示时用另一套,高度必然对不上。

第二处是传maxHeight而不是CGFLOAT_MAX。因为我们带了截断选项,需要有一个上限让引擎知道"到这里就该截了"。传无穷大的话,永远是全部内容都排得下,截断选项形同虚设。

第三处是floor(width)。理由前面说过,避免临界折行差异。

提示:如果你的文本需要支持动态字体,测量时用的UIFont必须是经过UIFontMetrics缩放后的字体,不能直接用preferredFontForTextStyle:拿到的结果去和 label 里实际使用的字体对比,多数时候它们是一致的,但一旦用户开大字号、或者你在某个容器里做了额外缩放,就会出现偏差。

3.3 用 NSStringDrawingContext 做自动缩字

NSStringDrawingContext这个类平时很少出现在视野里,但它解决了一个真实痛点:文案长度不可控、又不允许换行时,怎么把文字"缩"进去。

它有两个关键属性:minimumScaleFactor是允许的最小缩放比,actualScaleFactor是这次绘制实际用到的缩放比。用法是把 context 传给绘制或测量方法:

NSStringDrawingContext *ctx = [NSStringDrawingContext new]; ctx.minimumScaleFactor = 0.6; // 最多缩到 60% NSDictionary *attrs = @{NSFontAttributeName: [UIFont boldSystemFontOfSize:22]}; CGRect drawRect = CGRectMake(0, 0, 120, 32); [title drawWithRect:drawRect options:NSStringDrawingUsesLineFragmentOrigin attributes:attrs context:ctx]; // 绘制之后可以读回实际缩放比,决定是否需要额外处理 if (ctx.actualScaleFactor < 0.85) { // 已经缩得很厉害了,考虑改成两行或者截断 }

这个机制有几个边界需要知道。它只处理"放不下时的等比缩放",不会帮你重新折行或者调整行数,所以一般用在单行展示的场景,比如角标、卡片标题、固定高度的横幅。多行场景下它的表现比较难预测,我试过几次都不太理想,最后改成手动缩小字号重试。

实测下来更稳的做法是自己写一个二分循环:从原始字号开始,每次缩小 1 点重新测量,直到放得下或者到达下限。多算几轮的成本在几十次测量里可以忽略,但行为完全可预测,也方便和设计对齐最小字号。

3.4 从测量结果反推行数与首行基线

很多时候我们量完高度之后还得知道"这段文字到底几行"。常见写法是高度除以行高再取整:

CGFloat lineHeight = font.lineHeight; // 或者 ascender - descender NSInteger lines = (NSInteger)round(CGRectGetHeight(rect) / lineHeight);

这个公式在不带 leading、不带行距时基本准,但一旦带了 leading 或者设置了lineSpacing,就一定要把间距算进去:

CGFloat step = font.lineHeight + ps.lineSpacing; NSInteger lines = (NSInteger)floor((CGRectGetHeight(rect) + ps.lineSpacing) / step + 0.0001);

这个式子其实是 "总高 = 行数 × 行高 + (行数 - 1) × 行距" 的变形。加一个极小的偏移量是为了对抗浮点误差,避免floor(2.9999)变成 2 行。

另一个常见需求是拿"首行文字的基线位置",比如要在文本左侧画一条竖线、或者在首字下面加个角标。UIFont 提供了firstBaselineOffsetFromTop,配合测量结果可以直接得到基线相对顶部的偏移。这个属性在不同字体上的值差别挺大,尤其是中文字体,所以不要硬编码,一律走属性读取。如果需要更细的控制,还可以用boundingRectWithSize:返回的origin.y加上font.ascender来算,效果等价,但对带 device metrics 的结果要额外小心。

4. 常见问题排查与性能处理

4.1 高度比实际多一行或者少半行

这是最高频的一类问题,现象也很有辨识度:文本末尾总有一行空白,或者最后一行被裁掉小半截。排查顺序我一般按这个优先级走。

先看size.width是不是和显示端一致。测量用的是容器宽度,显示时 label 被自动布局约束压缩了几个点,两边宽度不同,行数就会不同。这种问题在横竖屏切换、分屏模式下特别容易冒出来。

再看两端的NSParagraphStyle是否一致。很多时候显示端的属性文本来自服务端下发或者某个加工函数,中间被追加了一次默认样式,导致lineBreakMode或者lineSpacing变了。测量端用的是裸attributes,显示端用的是加工后的属性文本,那高度必然差。

最后看lineBreakStrategy。这个属性从较新的系统版本开始生效,它会在折行时做"避头尾"处理,比如不让标点落在行首、不让英文单词被硬拆。开启之后同一段文字的行数可能变多。如果测量时用的段落样式没开、显示时开的,就会出现"多一行"的情况。修法是把两份属性统一,或者干脆用一个共享的方法生成属性字典,从源头上避免不一致。

还有一种很隐蔽的情况:字体回退。你指定的是系统字体,但文本里含有某些字符(生僻字、特殊数学符号、部分 emoji),系统会回退到另一个字体去渲染。回退字体的ascender、descender、leading都可能不同,那一行的高度就被撑高了。测量端如果只按主字体算,自然少算。想验证的话,把那段特殊字符删掉,看高度差是不是消失了。

4.2 中文、emoji 与组合字符的边界情况

中文文本的折行规则和西文差别很大。西文按空格断词,中文没有空格,默认按字符断行,同时带一套标点避头尾规则。这套规则会让同样的文字在不同系统版本上行数不同,所以布局里不要写死行数,一律以测量结果为准。

emoji 这个方向上的坑更多的是关于宽度。一个 emoji 在字符串里可能由多个 Unicode 标量组成(比如家庭表情、肤色修饰、国旗),它们的宽度计算和普通字符不同。如果你在用NSStringDrawingUsesDeviceMetrics做紧贴绘制,emoji 的结果会明显比排版度量更准确;如果做布局,就按正常流程走,别开这个选项。

组合字符是另一类。一个带声调的拉丁字母可以被拆成基字符加组合记号两个标量,光看字符串长度是 2,实际渲染宽度是 1 个字宽。如果你的逻辑里用text.length去估算宽度,碰到这类文本就会偏差很大。真想按"人眼看到的字符数"去算,得走enumerateSubstringsInRange:options:NSStringEnumerationByComposedCharacterSequences遍历,而不是直接数length。

4.3 批量测量下的性能与缓存

列表页里每个 cell 都做一次文本测量,一百个 cell 就是一百次富文本布局计算。这个开销在快速滚动时能明显感知,尤其是富文本里带附件或者复杂的段落样式时。

优化的方向只有一个:缓存。做一个以"文本内容 + 字体标识 + 宽度 + 样式版本号"为 key 的字典,把测量结果存起来。样式版本号是我习惯的做法——把影响排版的那些参数(字号、行距、断行模式)拼成一个字符串当作版本标识,样式一变版本号就变,缓存自然失效,不用手动清理。

- (CGFloat)cachedHeightFor:(NSString *)text width:(CGFloat)width font:(UIFont *)font { NSString *key = [NSString stringWithFormat:@"%@_%.1f_%.1f_v3", [text hash] ? @([text hash]).stringValue : @"0", width, font.pointSize]; NSNumber *cached = [self.heightCache objectForKey:key]; if (cached) { return cached.doubleValue; } CGFloat h = [self textHeight:text width:width font:font]; [self.heightCache setObject:@(h) forKey:key]; return h; }

缓存本身也要注意两点。一是容量,滚动几万条之后字典会膨胀,得设个上限或者定期清理,简单的做法是超过某个条数就整体清空,代价只是重新预热。二是线程,如果测量放在后台队列做,缓存字典就必须加锁或者用串行队列保护,否则偶发的崩溃很难定位。

关于测量的线程安全性,我的经验是:尺寸计算本身不涉及图形上下文,放在后台队列执行是可行的,但前提是传入的属性对象在测量期间不被修改。属性文本是可变的,如果主线程同时在改它,后台读到一半的数据就可能出问题,所以要么复制一份不可变副本传进去,要么保证没有并发写。

4.4 排查速查表

把上面这些内容整理成一张表,遇到问题时按行对照:

现象最可能的原因处理方式
高度永远是一行漏了UsesLineFragmentOrigin补上该选项
宽度超出给定值不折行传了CGFLOAT_MAX或宽度过大传真实约束宽度并floor
多出一整行空白测量与显示的段落样式不一致统一属性字典来源
底部差 1 到 3 像素leading 计算差异按对齐目标决定是否带UsesFontLeading
最后一行被裁掉未启用截断且高度不足加TruncatesLastVisibleLine或调整高度
特殊字符撑高行字体回退导致度量变化用NSAttributedString检测实际字体
文字重叠到相邻元素用了UsesDeviceMetrics做布局该选项只用于紧贴绘制,不用于布局
绘制到图片上文字溢出未限制高度也未截断限制 rect 并启用截断选项
行数反推总是少算公式漏了行距用总高 = n × 行高 + (n-1) × 行距反推
大量 cell 滚动卡顿每次滚动都重新测量加带版本号的缓存

这张表里的每一条我都在真实界面里碰到过,其中"测量与显示的段落样式不一致"出现频率最高,也最容易被忽略,因为它不会报错、不会崩溃,只是高度差了那么一点,然后在某个特定文案长度上突然暴露出来。

最后分享一个我这几年养成的习惯:把所有文本测量集中到一个工具类里,对外只暴露"给文本、宽度、字体、样式,返回高度"这一个入口,内部固定使用同一套选项组合。这样做的好处是,将来要调整 leading 策略或者加上lineBreakStrategy,只需要改一个地方,不会出现某个页面改了、另一个页面没改导致的高度风格不统一。这套东西写起来不复杂,但它省下的排查时间,远比写它花的时间多。