iOS文本测量:NSStringDrawingOptions避坑与高度排查

[text boundingRectWithSize:CGSizeMake(width, CGFLOAT_MAX) options:0 attributes:attrs context:nil]这行代码我大概在几十个工程里见过,写法几乎一模一样,翻车的方式也几乎一模一样:换行之后文字底部被裁掉小半行,或者容器下方空出一大块怎么调都消不掉。NSStringDrawingOptions 这个参数,绝大多数人是在抄第一份能跑通的 demo 时顺手带上的,之后几年都没再动过。

它其实只有四个当前有效的位,每个位管的都不是“要不要换行”这种表层行为,而是文本测量阶段的三件底层事情:用哪套坐标基准、行高怎么累加、截断发生在计算期还是绘制期。把这几件事想明白,你会发现自己过去调的那些“字体不对”“系统版本问题”,八成都是选项没配对。

这篇东西适合两类人看:一类是正在写自适应高度单元格、聊天气泡、图文混排卡片,被高度偏差折磨过的;另一类是接手了别人的文本工具类,看到一堆NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading但说不清为什么这么写的。下面我按“每个位单独拆—组合矩阵—封装与排查”的顺序讲,代码以 Objective-C 为主,关键处给 Swift 对照。

1. 别急着背选项表:boundingRect 与 draw 走的是两套逻辑

很多人理解 NSStringDrawingOptions 的方式是背文档:哪个选项对应哪句话。背完还是不会用,因为文档没告诉你这些位实际作用在哪个阶段。真正要建立的心智模型是:boundingRectWithSize:options:attributes:context:是测量阶段,drawWithRect:options:attributes:context:是绘制阶段,两者共用同一个选项类型,但每个位在两个阶段里生效程度完全不同。有的位只影响测量结果,有的位只在绘制时才真正起作用,混淆这一点是产生“算出来和画出来不一样”的根源。

1.1 测量与绘制是两个独立动作

测量阶段做的事是:给定一个宽度上限和一个高度上限,问系统“这段文本按当前字体和段落样式排版后,占多大一块矩形”。绘制阶段做的事是:给定一块矩形,把文本按同样的规则排进去画出来。听起来是同一件事的两面,但系统内部走的代码路径并不完全相同。测量返回的是一个 CGRect,包含 origin 和 size,origin 的参考点取决于你传没传NSStringDrawingUsesLineFragmentOrigin;绘制则严格以你给的 rect 左上角为起点往下铺。

这就是为什么会出现“boundingRect 算出来 80pt,UILabel 实际显示 84pt”这类偏差。它不一定是 bug,而是两套实现里对行高、行间距、尾行处理的细节取舍不同。你能做的是让测量参数尽量贴近绘制参数,把偏差压到可接受范围,而不是追求数学上的完全相等。

1.2 四个有效位、两个废弃位的全景

先把家底摆清楚。NSStringDrawingOptions是 NS_OPTIONS 类型,底层是位掩码,当前头文件里有效的和废弃的加起来是六个位:

位值名称状态作用阶段一句话作用
1 << 0UsesLineFragmentOrigin有效测量为主以行片段矩形左上角为坐标基准计算 bounds
1 << 1UsesFontLeading有效测量为主计算行高时把字体的 leading 计入
1 << 2OneShot已废弃—早期的一次性绘制优化提示
1 << 3UsesDeviceMetrics有效但不推荐测量以设备字体度量而非排版度量计算
1 << 4DisableScreenFontSubstitution已废弃—屏幕字体替换相关
1 << 5TruncatesLastVisibleLine有效绘制为主放不下时在最后可见行末尾加省略号

注意位值不是连续的:1 << 2 和 1 << 4 被废弃后,新代码里基本不会出现,但你在老工程里翻到options:7这种魔数时,得知道它等于1|2|4,里面掺了两个废弃位。这种代码能跑,但意图已经完全不可读了。

1.3 “默认值”从来不等于“安全值”

传options:0是最常见的写法,也是坑最多的写法。它的语义不是“用一套合理的默认配置”,而是“所有开关都关掉”。关掉 UsesLineFragmentOrigin 意味着坐标基准是基线,关掉 UsesFontLeading 意味着行高不含 leading。这两件事在单行场景下影响很小,在多行场景下直接导致结果不可用。

我个人的判断标准很简单:只要这段文本可能换行,就必须显式带上UsesLineFragmentOrigin | UsesFontLeading,不给自己留“这次应该只有一行”的侥幸。文本长度受多语言、字号缩放、动态字体影响的场景太多了,靠“应该”撑不住。

2. UsesLineFragmentOrigin:把坐标基准从基线挪到行片段顶部

这个位是四个选项里影响最大的一个,也是最容易被忽略的一个。它的名字翻译过来是“使用行片段原点”,关键是理解“行片段”和“原点”在这里分别指什么。行片段(line fragment)是排版系统里的一行文本所占据的矩形区域;原点默认取的是这一行的基线,设置这个位之后取的是这一行矩形的左上角。坐标系原点差了 ascent 那么高,返回的 rect 自然完全不一样。

2.1 基线坐标系下返回值长什么样

不传这个位时,boundingRectWithSize:返回的矩形是以第一行基线为 y=0 计算的。因为基线下方还有 descender,所以 origin.y 通常是负数,大约等于-ascender。高度方面,它基本只反映一行的行高,不会因为你传了很窄的宽度就真的按多行去累加。换句话说,你给了它一个宽度上限,它却按单行给你答案。

我第一次踩这个坑时的现象是:一个固定宽度 200pt 的标签,文本大概三行,boundingRect返回的高度是 20 出头,我以为是字体设置没生效,换了三种字体、改了两次字号,最后才发现是 options 传了 0。这个问题很隐蔽,因为返回的数值本身是“合法”的,不报错、不警告,只是不对。

2.2 设置之后,返回值之间的对照

在同一段文本、同一字体、同一宽度约束下,两个选项组合的返回差异大致是这样的(数值是相对关系,具体大小随字体和系统版本变化,不要照抄绝对值):

传入 options返回高度特征origin.y 特征是否按宽度换行
0约等于单行行高约为负的 ascender否
UsesLineFragmentOrigin随行数累加约为 0是
UsesLineFragmentOrigin | UsesFontLeading随行数累加,略大约为 0是
UsesLineFragmentOrigin | UsesDeviceMetrics随行数累加,设备相关约为 0是

表格里第二行和第三行的差异就是下一个章节要讲的内容。这里先记住:设置 UsesLineFragmentOrigin 之后,origin.y 会回到接近 0 的位置,因为原点变成了第一行矩形的顶边。

2.3 宽度传 CGFLOAT_MAX 会掩盖它的作用

有个说法流传很广:“UsesLineFragmentOrigin 不传也没关系,我算单行高度一直很准。”这类经验往往来自把宽度约束传成CGFLOAT_MAX或者MAXFLOAT的代码。宽度无限大时文本本来就不会换行,测量结果自然只反映一行,此时设置在不在这个位都看不出区别。

真正暴露问题的是有宽度约束的场景。我的建议是:凡是传了有限宽度的测量,一律带上这个位;只有确实在做“这段话在无限宽度下有多宽”这种极端测量时,才考虑省掉。省掉它省不下什么,反而给后来人埋雷。

2.4 和 sizeWithAttributes 的关系要理清

老代码里常见sizeWithAttributes:和sizeWithFont:constrainedToSize:这类 API,它们和boundingRectWithSize:的区别不只是新旧。sizeWithAttributes:本质上是无约束测量,等价于宽度无限大,永远只返回单行尺寸;sizeWithFont:constrainedToSize:在计算时内部已经替你做了多行处理,所以很多从老 API 迁移过来的代码会误以为boundingRectWithSize:也会自动换行,于是 options 传 0,结果高度突然变小,视觉上就是文字被裁。

迁移的时候我一般这么做:先把老 API 的调用点列出来,逐个确认它是单行语义还是多行语义,再决定新 API 的 options。单行的传 0 没问题,多行的一律补上UsesLineFragmentOrigin | UsesFontLeading。

3. UsesFontLeading:那个被行距悄悄吃掉的像素

把 UsesLineFragmentOrigin 加上之后,很多人会继续遇到第二个问题:算出来的高度能把文字完全装下,但每行之间贴得太紧,视觉上像挤在一起,尤其是字号偏大或者使用行距较松的字体时。这就是少了 UsesFontLeading 的典型症状。这个位控制的是行高计算时要不要把字体的 leading 算进去。

3.1 lineHeight 的构成:ascender、descender、leading

一个字体的行高并不是简单的“字号大小”,它由三部分构成:ascender(基线以上部分)、descender(基线以下部分,取绝对值)和 leading(行与行之间的额外间距)。系统在计算时用的是:

lineHeight = ascender + |descender| + leading

正常情况下 leading 是一个非负的小数值,用来给相邻两行留一点呼吸空间。不设置 UsesFontLeading,测量时只算ascender + |descender|,每行少掉 leading 那部分高度。行数越多,累计偏差越大:一行差 2pt,十行就差 20pt,表现就是文字整体比预期矮一截,或者绘制时相邻行挨得过近。

3.2 不同字体的 leading 差异,决定了这个位的“可见度”

这里有个容易误判的地方:绝大部分中文字体和系统默认无衬线字体的 leading 接近 0,所以在这些字体下,设不设 UsesFontLeading,算出来的高度几乎没差别。这就导致一批开发者得出“这个位没用”的结论,然后把它从代码里删掉——直到某天换了一个手写体、衬线体或者西文标题字体,长文本一下子叠在一起,再回头查就很费劲了。

我的做法是不做字体层面的假设:只要代码里存在“用户或设计可以改字体”的可能,就无条件带上这个位。它不会带来额外开销,只是一个计算口径的选择。多算进去的那几像素,正是排版系统认为该留的间距,接受它比事后逐字体校准划算得多。

3.3 与 paragraphStyle 的行间距叠加顺序

更麻烦的是,字符串往往还带了一个 NSParagraphStyle,里面可能设置了lineSpacing、lineHeightMultiple、paragraphSpacing。这些属性和 UsesFontLeading 是叠加关系,不是替代关系。测量时系统会先按字体算出行高,再按段落样式做调整,最后累加成总高度。

具体来说,lineSpacing是加在行与行之间的额外间距,lineHeightMultiple是对整行行高的倍数缩放。两者同时存在时,实际行距会比你单看一个属性时大不少。所以绘制和测量必须使用同一个 NSAttributedString,或者至少保证 attributes 里的 paragraphStyle 完全一致。我见过一段代码,计算时传的是@{NSFontAttributeName: font}这样一个干净字典,绘制时用的是带 paragraphStyle 的富文本,两边差出十几像素,最后靠肉眼一点点调,完全没必要。

3.4 尾行多算一份行间距,是偏差的常见来源

社区里长期存在一个现象:给富文本设置了lineSpacing之后,boundingRectWithSize:返回的高度会比实际显示高出大约一个 lineSpacing。原因是计算模型把行间距按“每行之后都加”的方式处理,而最后一行下方通常不需要额外间距。

处理方式有两种,我两种都用过:

  • 测量后减法:拿到高度后减去paragraphStyle.lineSpacing,再向上取整。简单直接,缺点是如果段落样式里还有 paragraphSpacing,会把最后一段段后距也一起减掉,需要按实际情况微调。
  • 改用段尾不算间距的模型:自己按行数算,总高 = 行数 × lineHeight + (行数 - 1) × lineSpacing。这种方式可控性最高,但需要能准确拿到行数,通常要配合 TextKit 或者CTFrame来数。

需要注意,不同系统版本对尾行间距的处理存在细微差异,上面的减法不是万能公式,一定要在真机或模拟器上实际比对一次再定稿。我一般会在封装里留一个修正量参数,默认是减一个 lineSpacing,遇到特殊情况可以覆盖。

4. TruncatesLastVisibleLine:省略号不是算出来的,是画出来的

这个位的名字很有欺骗性,叫“截断最后可见行”,听起来像是测量阶段就会返回一个被截短的高度。实际上它的主战场在绘制阶段:当文本内容超出你给的矩形时,在最后一个能完整显示的行末尾补上省略号。测量阶段基本不受它影响,返回的仍然是文本按宽度排完后的完整尺寸。

4.1 计算期与绘制期的职责划分

理清一件事:测量方法的输出永远是“这段文本完整排下来有多大”,它不会因为你想截断就返回一个小一点的高度。截断是绘制方在有限的矩形里做取舍的结果。所以如果你在测量时传了 TruncatesLastVisibleLine,指望返回高度变小,那一定会失望。

这个位的正确用法是配合drawWithRect:options:attributes:context::你传入一个固定高度的矩形,选项里带上它,当文本排不下时,绘制结果会在最后一行收尾处出现省略号。测量阶段你想知道的是“完整显示需要多高”,用来决定容器要不要加高;绘制阶段你想知道的是“在这块区域里怎么优雅地收尾”。两件事分开做,逻辑才清楚。

4.2 必须和 lineBreakMode 配合,否则不生效

只设这个位而不设 lineBreakMode,大概率看不到省略号。省略号的插入依赖段落样式里的换行模式,有效的组合通常是:

lineBreakMode配合 TruncatesLastVisibleLine实际效果
NSLineBreakByWordWrapping是按词换行,末行放不下时加省略号
NSLineBreakByCharWrapping是按字符换行,末行放不下时加省略号
NSLineBreakByTruncatingTail无所谓本身就截尾,选项冗余
NSLineBreakByClipping否直接切掉,不加省略号
NSLineBreakByTruncatingMiddle / Head否截断位置不同,与选项语义冲突

我在实际项目里的标准做法是:需要“最多显示 N 行,超出显示省略号”的场景,用NSLineBreakByWordWrapping加 TruncatesLastVisibleLine,同时把绘制矩形的高度设成N × 行高。这样就得到了可控的行数限制,而不是依赖numberOfLines那种只属于 UILabel 的属性。

4.3 在自定义绘制上下文里要注意坐标系

在UIView的drawRect:里直接调绘制方法是没问题的,UIKit 已经帮你把坐标系翻转好了,左上角就是原点。但如果是在UIGraphicsImageRenderer或者自定义的位图上下文里绘制,要特别注意上下文有没有做翻转。没有翻转的上下文里,文本会以上下颠倒或者偏移到画面外的方式出现,此时你很容易误判成选项配错了。

我一般的排查顺序是:先在drawRect:里用同样的选项画一遍,确认选项本身没问题;再换到目标上下文,如果这时出问题,基本可以锁定是坐标系或者上下文状态的问题,而不是 NSStringDrawingOptions。顺手提一句,绘制前记得保存和恢复图形状态,文本绘制会改动上下文的文本矩阵。

5. UsesDeviceMetrics 与 OneShot:两个几乎不该出现的位

剩下这两个位,一个还活着但基本不推荐用,一个已经废弃。它们出现在代码里的场景,多半是从很老的项目里一路复制过来的,或者是从某篇年代久远的文章里抄的。搞清楚它们原本想解决什么问题,有助于你在 code review 时判断该不该删。

5.1 设备度量在 Retina 时代的副作用

UsesDeviceMetrics 的语义是:用设备字体的度量来计算行高,而不是用排版意义上的度量。在早期屏幕像素密度低、字体渲染依赖屏幕字体替换的年代,这个选项能让测量结果更贴近屏幕上实际画出来的像素,避免亚像素级的错位。

问题在于,一旦以“设备”为基准,测量结果就可能和具体设备挂钩。同一段文本、同样字号,在不同像素密度、不同缩放比的设备上返回的高度可能不一致。这在需要跨设备保持一致布局的场景里是灾难。现在的字体渲染和排版引擎早已不是当年的样子,这个位带来的那点“贴合屏幕”的收益,远远小于它引入的不确定性。

我的建议非常直接:新代码不要用;老代码里看到它,除非有明确的、可复现的视觉问题证明删掉会变差,否则一律删掉,换成UsesLineFragmentOrigin | UsesFontLeading的常规组合。

5.2 OneShot 的历史包袱

OneShot 和 DisableScreenFontSubstitution 都已经废弃,前者是给绘制引擎的一个提示,表示这段文本只绘制一次、可以被优化处理,后者与屏幕字体替换策略有关。它们诞生的背景是当时的文本渲染管线缓存机制不完善,需要调用方给提示。

现在这套缓存和排版优化已经由底层统一处理,调用方再传这些位不起作用,还会让 options 的数值变得难以理解。你如果在前辈的代码里看到options:(NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingOneShot),编译器一般会给废弃警告,别忽略它,顺手把废弃位去掉,把变更记进提交信息里。

提示:清理废弃位时,不要一次性全工程替换。选项是按位或组合的,某个调用点原本可能依赖了废弃位的副作用(虽然概率很低),建议按模块改、按页面回归,一次改一批。

6. 组合矩阵:不同场景到底该传什么

单个位讲完,真正落到工程里的是组合。我把自己这几年用下来的选择整理成一张表,覆盖了绝大多数场景。这张表里的组合不是拍脑袋,每一条背后都对着一类具体的视觉问题。

6.1 按场景选组合

场景推荐 options理由
多行自适应高度,宽度固定UsesLineFragmentOrigin | UsesFontLeading按宽度换行,行高完整
单行固定宽度测量UsesLineFragmentOrigin单行也建议带,顺手且无副作用
纯单行无约束测宽度0只需要宽度,无换行语义
固定区域绘制,超出截断UsesLineFragmentOrigin | UsesFontLeading | TruncatesLastVisibleLine测量用前两位,绘制时第三位生效
富文本带 lineSpacingUsesLineFragmentOrigin | UsesFontLeading再按前面讲的方法修正尾行间距
跨设备一致性要求高UsesLineFragmentOrigin | UsesFontLeading避开 UsesDeviceMetrics

6.2 为什么“前两位”几乎成了标配

UsesLineFragmentOrigin | UsesFontLeading这个组合之所以在社区里出现频率极高,是因为它同时解决了两个最基本的正确性问题:多行要被计算进去,行高要按字体定义完整算。这两点满足之后,剩下的偏差才轮到段落样式、尾行间距、取整方式这些细节去处理。

反过来说,如果前两位没配齐,后面所有精调都是在错误的基础上做修正。我见过一个项目为了让高度对上,在封装里硬编码了一个+ 8的魔法数,上线后换了字体就全乱了。正确的做法永远是先把基础口径对齐,再处理特例。

6.3 只算单行时,到底该不该带

这是个纯风格问题,两种做法都能跑。我的倾向是:只要调用的方法名里有 boundingRect,就带上 UsesLineFragmentOrigin。原因是这个方法本身是通用测量接口,未来的调用方可能改成多行而不改 options,带上它相当于提前把坑填了。代价是返回值里 origin.y 会从负数变成接近 0,如果你的代码依赖了那个负的 origin 去做垂直居中偏移,需要一起改。

7. 封装一套测量工具,以及高度对不上的排查链路

聊了这么多位,最终要落到一个可复用的封装上。散落在各处的 options 是维护噩梦,尤其当不同人写的不同页面各自有一套参数时,视觉不一致的问题会反复冒出来。

7.1 一个最小可用的封装

+ (CGSize)textSizeForAttributedString:(NSAttributedString *)text width:(CGFloat)width maxLines:(NSInteger)maxLines truncate:(BOOL)truncate { if (text.length == 0 || width <= 0) { return CGSizeZero; } NSStringDrawingOptions options = NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading; if (truncate) { options |= NSStringDrawingTruncatesLastVisibleLine; } CGSize limit = CGSizeMake(width, CGFLOAT_MAX); CGRect rect = [text boundingRectWithSize:limit options:options context:nil]; CGFloat height = ceil(rect.height); if (maxLines > 0) { CGFloat lineHeight = [self lineHeightForAttributedString:text]; CGFloat maxHeight = ceil(lineHeight * maxLines); height = MIN(height, maxHeight); } return CGSizeMake(ceil(rect.width), height); }

Swift 版本写法基本一致,NSStringDrawingOptions在 Swift 里是 OptionSet,组合用数组字面量:

let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading] let rect = text.boundingRect(with: CGSize(width: width, height: .greatestFiniteMagnitude), options: options, context: nil)

这里有几个细节值得说。ceil是为了避免亚像素导致的裁切,浮点高度向下取整时经常出现最后一行的 descender 被切掉半个像素的情况。maxLines的限制不是靠 options 实现的,而是自己算一个高度上限去截,因为测量方法本身不知道“最多几行”这个概念。lineHeightForAttributedString:需要从 attributes 里取字体自己算,不能简单用字号乘系数。

7.2 高度对不上时的排查链路

我把这几年排过的坑整理成一个固定顺序,从最可能到最不可能,逐层排除:

  1. 确认宽度约束是不是同一个值。测量用的宽度和容器实际可用宽度差一个内边距,是最常见的低级错误,先量一遍 frame 和约束再说。
  2. 确认 options 是否包含前两位。把 options 打印出来,用十进制看不直观,建议按位打印或者直接看常量名。
  3. 确认 attributes 是否完全一致。字体、字号、字重、paragraphStyle 的每一个属性都要比对,不要只看字体名。富文本里还有kCTFontAttributeName这类底层键,混用会导致样式不生效。
  4. 确认有没有 paragraphStyle 的行间距尾行问题。按前一节的方法减去一个 lineSpacing 再比对。
  5. 确认取整方式。测量结果和绘制结果都做ceil,不要一个 ceil 一个 round。
  6. 确认绘制区域的坐标系。自定义上下文里检查有没有翻转。
  7. 确认动态字体和字号缩放。用了UIFontMetrics的话,测量时也必须用缩放后的字体,否则算的是 100% 字号,画的是放大后的字号。
  8. 最后才怀疑系统差异。把同一段代码在目标系统版本上跑一遍,用截图量像素,确认偏差是否稳定。不稳定的偏差才值得怀疑系统,稳定的偏差基本都是参数问题。

按这个顺序走,绝大多数问题在前三步就能定位。我印象很深的一次,某个页面高度总是差 4pt,前七步全过了一遍没问题,最后发现是设计给的内边距在两个地方分别写成了 12 和 8,谁都没错,就是数字不一样。

7.3 和 UILabel 的对齐校验手法

UILabel 内部也是走文本排版,但它有自己的缓存和行数处理逻辑,行为不完全等同于手写测量。要验证两者是否对齐,我一般这么做:新建一个临时页面,左边放一个numberOfLines = 0的 UILabel,右边放一个高度由测量结果驱动的容器,里面用drawWithRect:画同样的文本,截一张图叠在一起看边缘。如果上下边缘能重合,说明口径一致;如果 UILabel 高一点,多半是它把 lineSpacing 的尾行间距没算,而你算了。

这个校验不需要写测试用例,但值得在封装的第一次落地时做一次,把结论记进注释里。后来人改封装时能看到“这里为什么减了一个 lineSpacing”,就不会手贱删掉。

7.4 缓存与性能上的取舍

boundingRectWithSize:内部会走一遍排版,长文本、列表滚动场景下频繁调用是有成本的。我的经验是做两级处理:单元格类场景,在数据装配阶段算一次尺寸并缓存进模型,滚动时直接读;如果数据源会变(比如用户输入、动态字号),用文本内容加宽度加字体描述拼一个 key 丢进 NSCache,容量给小一点,几十条足够。

还有个常被忽略的点:不要在同一帧里对同一个字符串调用多次测量。有些布局代码在sizeThatFits:、layoutSubviews、intrinsicContentSize里各算一遍,三倍开销全花在重复计算上。把结果提到一个属性里,标记失效再重算,改起来不复杂,收益很直接。

注意:缓存 key 一定要把 paragraphStyle 的关键属性带进去。只拼文本和字体,遇到同一段文字在不同行距下显示的页面,会拿到错误的高度,而且这种 bug 极难复现,因为取决于用户的浏览顺序。

最后分享一个小习惯:我在封装里会把 options 单独抽成一个只读属性,暴露给调用方做覆盖,默认值就是那两位。遇到确实需要特殊处理的页面,改属性而不是改方法体,改动范围可控,review 的时候也一眼能看出哪个页面用了非常规配置。文本测量这块没有银弹,把口径统一住、把偏差记录清楚,比追求一次写对更现实。