做列表页做得久了会形成一个条件反射只要 cell 高度开始忽高忽低、末行文字被切掉半截、或者明明只有一行却撑出两行的高度先别急着怀疑约束冲突回头去看那行boundingRectWithSize:options:attributes:context:的 options 是怎么写的十有八九问题就在那儿。NSStringDrawingOptions这个枚举成员不多但它里面每一个开关都对应一个很具体的排版概念写错一个表现出来就是另一套完全不同的尺寸结果。很多同学从sizeWithFont:那套老 API 迁移过来的时候习惯性地把 options 写成 0或者随手抄了个NSStringDrawingUsesLineFragmentOrigin就完事结果在单行场景下一切正常一上多行、一换字体、一加行距就开始出问题。这篇东西就是想把 NSStringDrawingOptions 的每个成员单独拎出来讲清楚它到底改了什么为什么会有这个差异什么场景下必须加、什么场景下加了反而是负担。主要面向正在做多行文本排版、被 cell 高度折腾过的 iOS 开发者刚入行不久、还没搞明白这套老 API 的同学读完也能自己动手验证已经有几年经验、平时靠封装好的工具类吃老本的同学也能在这里找到几个平时容易忽略的边界情况。文中给出的数值和表现都是我在实际项目里反复验证过的涉及具体版本差异的地方我会标出来你在自己的工程里跑一遍对照最稳妥。1. 先把 NSStringDrawingOptions 的成员摆上桌1.1 它真正生效的地方只有两处很多人以为这个 options 是给所有字符串绘制和测量 API 用的其实不是。真正会读这个参数的方法就两组一组是boundingRectWithSize:options:attributes:context:和NSAttributedString的同名方法负责算尺寸另一组是drawWithRect:options:attributes:context:和NSAttributedString的对应方法负责画。至于sizeWithAttributes:、drawAtPoint:withAttributes:、drawInRect:withAttributes:这些压根就没有 options 参数它们内部用的是默认行为。这个区分很重要因为sizeWithAttributes:的语义就是「按单行、不换行、不截断来算」它等价于boundingRectWithSize传一个无限大的尺寸再加 options 为 0。你在代码里混用这两套方法就会出现「量的时候按多行算、画的时候按单行画」的错位这种 bug 特别隐蔽因为两边单独看都没错。所以第一条要记住的结论是尺寸计算和实际绘制必须用同一套 options。你要是用UsesLineFragmentOrigin | UsesFontLeading算出来两行的高度绘制的时候也得带上这两个选项否则画出来就是一行剩下那一行的空间空在那里。1.2 四个常用成员的职责对照iOS 上你真正会打交道的成员就是下面这四个我把值和现象放在一张表里方便对照选项位值一句话作用使用频率漏写后的典型现象NSStringDrawingUsesLineFragmentOrigin1 0把给定的 size 当成一个可换行的矩形区域来排版几乎必写高度永远只有一行宽度可能远超限定值NSStringDrawingUsesFontLeading1 1计算行高时把字体的 leading 也算进去强烈建议多行时高度偏小末行下半截被切NSStringDrawingUsesDeviceMetrics1 3用字形墨迹边界代替排版边界参与计算极少一般无感知开了反而和 UILabel 对不齐NSStringDrawingTruncatesLastVisibleLine1 5放不下时截断最后一行并补省略号按需超出部分被硬裁没有省略号提示注意位值这一列10、11、13、15中间空了两个位置。这不是随手写的12 和 14 历史上分配给了NSStringDrawingOneShot和NSStringDrawingDisableScreenFontSubstitution这两个 macOS 时代的成员在 iOS 业务里基本没有出场机会后面第 5 章会单独说。看到这个跳号其实就能记住一件事iOS 上你只要盯住这四个就够了别去网上抄那些带 OneShot 的老代码抄进来也不会有任何效果。1.3 为什么这个枚举全是「开关」而没有「参数」刚接触这套 API 的人常有的疑问是既然是控制排版的为什么不能在这里指定行距、指定最大行数答案在于职责划分——具体的字体、字号、行距、段落样式全部塞在attributes字典里的NSFontAttributeName和NSParagraphStyleAttributeName中options 只负责回答一个问题你给我的那个 size到底该怎么理解理解了这个定位后面所有的行为就都能推出来了。UsesLineFragmentOrigin说的是「这个 size 是个矩形不是一个基线」UsesFontLeading说的是「行高按哪种算法来」TruncatesLastVisibleLine说的是「放不下的时候怎么办」。全是解释规则没有一个是数值。2. UsesLineFragmentOrigin不写它多行文本永远只有一行高2.1 基线原点和行片段原点的差别这个选项的名字翻译过来有点绕叫「使用行片段原点」。要理解它先得知道另一种模式——基线原点baseline origin。基线原点的思路是文字是站在一条基线上的你告诉我一个位置我就从这条基线开始往右写写多长算多长不折行。这种模式下返回的矩形是紧贴着文字墨迹的它的顶部在基线上方所以origin.y会是一个负数。很多同学第一次打印boundingRectWithSize的结果看到{{0, -3.5}, {320, 17.9}}这种值会懵就是这个原因。行片段原点的思路完全不一样你给我的 size 是一个矩形框我在这框里面排版宽度到了就折行一折行就产生一个「行片段」。返回的 rect 从 (0, 0) 开始高度是所有行片段叠起来的总高度。这才是我们做 cell 高度自适应时想要的东西。用个生活化的类比基线原点像是拿一把尺子量「这行字有多长」行片段原点像是拿一个相框问「这些字塞进这个相框要占多高」。2.2 一段可以直接跑的复现代码光说概念没感觉下面这段代码你复制到工程里跑一下两个结果的差别一眼就能看出来NSDictionary *attrs { NSFontAttributeName: [UIFont systemFontOfSize:15] }; NSString *text 这是一段用来测试换行行为的长文本长度足够在两百点宽度内折成好几行。; CGSize limit CGSizeMake(200, CGFLOAT_MAX); CGRect r1 [text boundingRectWithSize:limit options:0 attributes:attrs context:nil]; CGRect r2 [text boundingRectWithSize:limit options:NSStringDrawingUsesLineFragmentOrigin attributes:attrs context:nil]; NSLog(不加选项: %, NSStringFromCGRect(r1)); NSLog(加了选项: %, NSStringFromCGRect(r2));在 15pt 系统字体下你会看到类似这样的输出r1的宽度明显超过 200可能是 400 多高度大约 17.9origin.y是个小负数r2的宽度正好卡在 200 以内高度是 17.9 的整数倍origin是 (0, 0)。这个对照实验建议每个新同学都亲手跑一次比看十篇博客都管用。因为它把「options 到底在控制什么」这件事具象化了——不是控制字号不是控制字体而是控制「你怎么理解我给你的那个尺寸」。2.3 取尺寸时千万别直接用 size.width接着上面说既然返回的 rect 的 origin 不一定是 (0, 0)那取尺寸的正确姿势就只能是CGRectGetWidth(rect)和CGRectGetHeight(rect)。我见过不少代码写成rect.size.width或者更糟把整个 rect 直接塞进 frame 里结果控件莫名其妙被偏移了几个点。这个坑在单行场景下不明显因为不加选项时 origin 才非零一旦你开始用行片段原点origin 就是 (0, 0) 了代码看起来也没问题。于是这批代码就埋在那儿等到哪天有人删了 options 或者换了调用方式问题才爆出来。建议从一开始就统一写成CGSizeMake(CGRectGetWidth(rect), CGRectGetHeight(rect))习惯成自然。2.4 CGFLOAT_MAX 这个上限值该怎么给标准写法是宽度给实际可用宽度高度给CGFLOAT_MAX。但这里有两个实操细节值得说一下。第一个是浮点溢出的风险。CGFLOAT_MAX是个极大的数参与某些字体度量运算时个别系统版本上会算出inf或者NaN最终返回的高度变成 0 或者一个离谱的值。我在封装工具类的时候加了一层兜底如果算出来的高度小于等于 0 或者isnan为真就退回一个有限的大值比如 100000重算一次。这层保护平时用不上但线上真出过一次问题之后我就一直留着了。第二个是取整。boundingRect返回的高度经常是 43.2000001 这种值你直接拿去设 frame渲染的时候会因为半像素对齐糊掉一条边看起来就是文字底部发虚。稳妥的做法是统一向上取整static inline CGFloat TextCeilValue(CGFloat value) { CGFloat scale UIScreen.mainScreen.scale; if (scale 0) scale 2.0; return ceil(value * scale) / scale; }先乘 scale 再 ceil 再除回去能保证结果落在物理像素网格上比单纯ceil()更精细特别是在 3x 屏上做细线条或者紧凑排版时差别肉眼可见。3. UsesFontLeading最后一行为什么总被吃掉一点3.1 leading、ascender、descender 三者的关系要讲清楚这个选项得先把UIFont的那几个度量属性捋一遍。ascender是基线以上的高度包含了重音符号预留的空间descender是基线以下的深度注意它是个负数leading是行与行之间的额外留白。三者的关系是lineHeight leading ascender - descender而行高其实有两种算法一种是只用ascender - descender因为 descender 是负的实际就是 ascender 加绝对值另一种是把leading也加上。UsesFontLeading干的事就是告诉系统算行高的时候请用第二种。为什么会有两种因为字体设计上leading 是给「上一行的降部和下一行的升部之间」留的安全距离。有些字体这个值是 0有些字体能到 2 到 3 个点。系统字体在这个值上比较克制很多时候接近 0所以你在单行、系统字体的场景下根本看不出差别——这也正是这个 bug 最难查的地方。3.2 误差会被行数成倍放大单行差 0.5 个点你可能觉得无所谓。但这是一个按行数累加的误差十行就是 5 个点二十行就是 10 个点。列表里那种长文摘要正好踩在这个坑上短文本一切正常长文本最后一行就被切掉一截露出来的半个字看着特别难受。我整理了一个按字体类型的粗略对照帮助你判断风险等级。具体的绝对数值会随系统版本和字体文件变化这里给的是量级和累计影响字体类型单行 leading 量级10 行累计误差风险判断系统默认字体正文场景接近 0 到 0.5pt0 到 5pt中低长文本需注意系统中粗体、标题字号通常略大于正文2 到 8pt中卡片标题易出问题第三方西文字体差异较大常见 1 到 3pt10 到 30pt高必须显式处理已设置固定行高的段落被行高设置覆盖取决于设置低leading 影响被吸收看这张表能得出一个实用结论如果你用的是系统字体做正文单行或少行场景可以不管只要文本可能超过五六行或者用了自定义字体这个选项就必须加上。与其判断用什么字体不如统一加上反正加上了也不会有什么副作用。3.3 它和 lineSpacing 是叠加关系不是替代关系很多同学以为设了paragraphStyle.lineSpacing之后 leading 就不起作用了这是个误解。lineSpacing是段落样式层面的额外间距加在行高之上leading是字体自带的度量属于行高的一部分。两者是叠加的。所以正确的行高计算方式是单行高度 max(leading ascender - descender, minimumLineHeight) lineSpacing这里maximumLineHeight和minimumLineHeight如果你设了它会覆盖字体度量算出来的行高这时候 leading 的意义就被吃掉了——这也是我后面要说的「什么时候可以关掉它」的场景之一。3.4 什么情况下可以考虑不加 UsesFontLeading不是说这个选项永远要加。有两种情况我会主动把它去掉或者说不加也不影响结果。第一种是你手动设了minimumLineHeight和maximumLineHeight把行高钉死在一个固定值上。这时候系统直接用你给的值字体自己的 leading 已经不参与计算了加不加这个选项结果一样。做严格的行网格对齐比如要求每行正好 24pt时这是常见做法。第二种是你要和控制台里 UILabel 的实际渲染严格对比。这里要提醒一句UILabel 内部走的是 TextKit它的行高就是font.lineHeight也就是包含 leading 的那个值。所以正常业务里加上UsesFontLeading算出来的高度反而更贴近 UILabel 的真实表现。你要是去掉了算出来的高度会比 UILabel 矮一点点然后你就得在 cell 底部补 padding 来凑越补越乱。4. TruncatesLastVisibleLine省略号什么时候才出现4.1 它和 lineBreakMode 是两个层级的东西这是我认为最容易搞混的一对概念。TruncatesLastVisibleLine是一个开关回答的是「允许不允许截断」NSParagraphStyle.lineBreakMode回答的是「截断的时候截在哪儿、加不加省略号」。你可以这么理解前者是「这扇门能不能锁」后者是「锁上之后钥匙插哪个孔」。官方头文件里对这个选项的注释写得很明确如果不同时设置UsesLineFragmentOrigin这个选项会被忽略。所以最小可用组合是UsesLineFragmentOrigin | TruncatesLastVisibleLine。然后是实际表现我整理了一张对照表lineBreakMode设置只加 Truncates加 Truncates 且显式设 lineBreakModeNSLineBreakByWordWrapping默认文字被硬裁省略号可能不出现仍可能无省略号不推荐NSLineBreakByCharWrapping按字符边界裁切无省略号纯裁切NSLineBreakByTruncatingTail末尾补省略号正常推荐写法NSLineBreakByTruncatingMiddle中间补省略号正常NSLineBreakByTruncatingHead开头补省略号正常表里第一行那个「可能」是真实情况——不同系统版本对这种组合的处理不完全一致有的会补省略号有的就直接裁掉。这种不确定性对业务代码来说是灾难所以结论很干脆要省略号就把lineBreakMode显式写成NSLineBreakByTruncatingTail别赌默认值。4.2 想按行数限制光靠这个选项不够这是一个特别常见的误区。很多人以为给boundingRectWithSize传个TruncatesLastVisibleLine再在 attributes 里限制一下就能得到「最多三行」的高度。不行。原因在于这个选项的触发条件它是在「文本放不进你给的 size」时才生效。而你算高度的时候高度参数给的是CGFLOAT_MAX永远放得下自然永远不截断。所以它返回的是完整文本的高度。想按行数限制正确做法是先自己算出这个行数对应的高度上限再把它作为 height 传进去CGFloat lineHeight font.lineHeight lineSpacing; CGFloat maxHeight lineHeight * maxLines - lineSpacing; CGSize limit CGSizeMake(width, maxHeight); NSStringDrawingOptions opts NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading | NSStringDrawingTruncatesLastVisibleLine; CGRect rect [text boundingRectWithSize:limit options:opts attributes:attrs context:nil];注意maxHeight那里减了一个lineSpacing。因为lineSpacing只加在行与行之间最后一行下面不该有间距n行文本实际只有n - 1个间距。这个减法漏掉你的高度就会恒定多出一个lineSpacing在lineSpacing设得比较大的设计稿里比如 6pt、8pt肉眼能看出来卡片底部空了一块。4.3 和 minimumScaleFactor 一起用时的优先级NSStringDrawingContext里有个minimumScaleFactor默认是 0小于 1 的时候才启用缩放。启用之后系统遇到放不下的情况会先缩字号缩到这个下限还放不下才走截断逻辑。这个优先级顺序意味着如果你的文本已经能通过缩字号塞进去那TruncatesLastVisibleLine根本不会触发你也就拿不到省略号。同时actualScaleFactor会告诉你实际缩放了多少倍你可以读出来做后续判断。但在 cell 高度计算的场景里我建议不要传 context。理由很实际一旦启用缩放最终高度就同时依赖于宽度和字号两个变量宽度变一点点缩放比例变一点点高度就跟着跳列表滚动的时候高度抖动就是这么来的。缩放适合做那种固定尺寸的卡片标题不适合做自适应高度的列表。5. UsesDeviceMetrics 与另外两个历史选项5.1 UsesDeviceMetrics 到底改了什么这个选项的作用是把计算基准从「排版边界」typographic bounds换成「字形墨迹边界」image glyph bounds。听起来很玄说白了就是排版边界看的是字体的 ascent 和 descent那些是设计时预留的空间墨迹边界看的是字符真正画出来的那一圈轮廓。两者的差别主要体现在几个地方字符的实际高度往往小于字体预留的高度因为上下都有留白所以开了这个选项算出来的高度通常会略小一点带下伸部的字符比如小写字母 g、p和带上伸部的字符比如 b、d会让墨迹边界不规则而中文字符大多是方块字墨迹几乎填满字身框所以差异反而不明显。5.2 为什么业务里基本不用它我总结出三个不用它的理由按重要性排第一它会让你算出来的高度和 UILabel 的渲染对不上。UILabel 默认是按排版边界走的你这边按墨迹边界算两边基准不同就差出来了。做 cell 自适应时差一两个点就是「文字贴边」和「文字留白」的区别。第二不同字体的表现不一致。西文字体差异明显中文字体几乎没差混排的时候你根本没法预测结果。这种不确定性在业务代码里就是定时炸弹。第三它和截断、缩放这些逻辑叠加时的行为没有官方文档描述。你想搞清楚「开着 DeviceMetrics 又开着 Truncates」会怎样只能自己一个个版本去试投入产出比太低。我唯一能想到的合理使用场景是做像素级还原的设计工具或者需要把文字外框和图片外框严格对齐的特殊排版。普通业务代码看见这个选项绕道走就对了。5.3 OneShot 和 DisableScreenFontSubstitution 的时代背景这两个成员在今天的 iOS SDK 里已经标记为废弃属于 macOS 早期十多年前的产物理解它们的存在有助于记住位值的来源。NSStringDrawingDisableScreenFontSubstitution对应位值 14。那个年代显示器分辨率很低小字号文本渲染时系统会用点阵字体替换矢量字体让文字更清晰。这个选项就是用来关掉替换、强制用原始字体的。现在屏幕像素密度早就上去了这套机制基本不存在了。NSStringDrawingOneShot对应位值 12。它的语义是「只绘制一次不缓存排版结果」。在字符串内容每次都不一样、缓存反而成负担的场景下有点用。现在这套 API 内部早就换了好几轮实现这个开关已经没有任何实际效果。看到这里再回头看第 1 章那张表里空着的 12 和 14就能串起来了这两个位置不是没用是被历史占用了而占用它们的成员已经退休。你在网上搜到的老代码如果写了NSStringDrawingOneShot直接删掉编译器不会有意见运行结果也不会有变化。6. 封装一份能直接抄的尺寸计算工具6.1 核心方法完整实现把前面几章的内容揉在一起我给一个自己项目里用了很久的封装。它覆盖了单行、多行自适应、限定行数三种场景参数都是显式的方便你在调用处一眼看懂用了什么配置 (CGSize)textSizeForString:(NSString *)text width:(CGFloat)width font:(UIFont *)font lineSpace:(CGFloat)lineSpace maxLines:(NSInteger)maxLines { if (text.length 0 || width 0 || !font) { return CGSizeZero; } NSMutableParagraphStyle *style [NSMutableParagraphStyle new]; style.lineSpacing lineSpace; style.lineBreakMode (maxLines 0) ? NSLineBreakByTruncatingTail : NSLineBreakByWordWrapping; NSDictionary *attrs { NSFontAttributeName: font, NSParagraphStyleAttributeName: style, }; NSStringDrawingOptions opts NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading; CGFloat limitHeight CGFLOAT_MAX; if (maxLines 0) { opts | NSStringDrawingTruncatesLastVisibleLine; CGFloat lineHeight font.lineHeight lineSpace; limitHeight lineHeight * maxLines - lineSpace; } CGSize limit CGSizeMake(width, limitHeight); CGRect rect [text boundingRectWithSize:limit options:opts attributes:attrs context:nil]; CGFloat resultWidth CGRectGetWidth(rect); CGFloat resultHeight CGRectGetHeight(rect); if (!isfinite(resultHeight) || resultHeight 0) { CGRect fallback [text boundingRectWithSize:CGSizeMake(width, 100000) options:opts attributes:attrs context:nil]; resultWidth CGRectGetWidth(fallback); resultHeight CGRectGetHeight(fallback); } return CGSizeMake(TextCeilValue(resultWidth), TextCeilValue(resultHeight)); }几个设计决策解释一下。maxLines传 0 表示不限行数此时高度上限给CGFLOAT_MAX得到的是完整文本高度传大于 0 的值时先算出行数对应的高度上限再交给系统去截断同时把lineBreakMode切成尾部省略。那个isfinite判断就是第 2 章提到的浮点兜底代码里留着不碍事真出事的时候能救你一命。另外要注意font.lineHeight这个值已经包含了 leading所以lineHeight font.lineHeight lineSpace这个算法是正确的。如果你手动设了minimumLineHeight那就得用你设的值代替font.lineHeight别直接套公式。6.2 参数选择速查表封装有了但调用的时候 options 怎么选还是得有依据。下面这张表我贴在过团队文档里直接按场景查就行业务场景options 组合height 上限lineBreakMode单行标签姓名、金额0无所谓不设置多行高度自适应列表摘要LineFragmentOrigin FontLeadingCGFLOAT_MAXWordWrapping固定三行 尾部省略号LineFragmentOrigin FontLeading Truncates三行对应高度TruncatingTail只裁剪不补省略号LineFragmentOrigin FontLeading指定高度WordWrapping固定行高网格排版只加 LineFragmentOriginCGFLOAT_MAXWordWrapping最后一行那个组合值得多说一句既然你已经用minimumLineHeight把行高钉死了UsesFontLeading确实可以省掉少一个变量少一份心智负担。6.3 和 UILabel、UIButton、cell 怎么对接算出来的尺寸最终要落到控件上这几个对接点各有各的坑。UILabel 的话记住两件事numberOfLines 0才能多行preferredMaxLayoutWidth不设的话intrinsicContentSize永远按单行算。第二个坑特别常见尤其是在 Auto Layout 里约束明明写对了但高度就是不对八成是没设preferredMaxLayoutWidth。UIButton 稍微麻烦一点。iOS 15 之后引入了配置化的UIButtonConfiguration按钮标题的多行行为和以前不一样了。用老 API 的话要设button.titleLabel.numberOfLines 0然后用sizeThatFits:或者自己算。混用新旧两套的时候标题换行可能完全不生效这时候去看官方文档里关于 configuration 的说明比自己瞎试快。cell 高度的原则只有一条要么全手算要么全交给 Auto Layout不要一半一半。手算就是用heightForRowAtIndexPath加上面那个封装交给 Auto Layout 就用systemLayoutSizeFittingSize:配合UITableViewAutomaticDimension。混着来的典型表现是滚动的时候高度会跳一下因为首次布局用的是估算值滚过去之后才换成真实值。这个现象排查起来非常费时间从一开始就统一方案最省事。7. 高度不对时的排查顺序7.1 现象到原因的速查表线上出了问题按这张表从上往下对基本能定位到八成的情况现象最可能的原因处理方式高度永远只有一行漏了UsesLineFragmentOrigin补上同时检查绘制处是否一致多行时末行被切漏了UsesFontLeading补上或确认字体 leading 是否为 0宽度超出容器边界options 传了 0补UsesLineFragmentOrigin超长文本没有省略号只加了 TruncateslineBreakMode仍是 WordWrapping显式设成TruncatingTail高度比 UILabel 实际渲染多出几 ptlineSpacing边界差异手算时减一个lineSpacing高度时大时小context 里传了minimumScaleFactor计算尺寸时不传 context高度恒等于某个固定值height 上限传了固定数值改成CGFLOAT_MAX最后一条我再强调一次因为它太容易被忽略boundingRectWithSize的 height 参数是上限不是期望值。我曾经接手一个模块封装里把高度写死成 100大概是当初为了防溢出随手填的结果所有超过三行的长文高度全算成 100列表里所有长文本都被切在同一位置看起来像是「系统统一截断」实际就是这一个数字的锅。7.2 几个我实际踩过的坑第一个坑是图文混排。项目里有一段带NSTextAttachment的富文本文字里嵌了个小图标。用普通的boundingRect算出来的高度没把附件的尺寸完全算进去导致最后一行文字被图标压住半个字。解决办法是把附件所在那段的行高用minimumLineHeight撑开或者干脆把这类内容拆成两行来排。附件尺寸参与行高计算这件事在不同系统版本上表现不完全一致做之前一定要在目标版本上实测。第二个坑发生在按行数限高的时候。当时的设计是「摘要最多两行超出显示省略号」我按font.lineHeight * 2算高度上限上线之后发现卡片底部总是空出来 4 个点正好是lineSpacing的值。原因就是前面说的n行只有n - 1个间距我多算了一个。改成lineHeight * 2 - lineSpacing之后严丝合缝。这种问题在设计稿上完全看不出来只有拿真机截图去量像素