
做图文分享、做列表页自适应高度、做长文详情页的折叠绕不开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 远超 200height ≈ 单行高 // 正确用法带上 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只需要改一个地方不会出现某个页面改了、另一个页面没改导致的高度风格不统一。这套东西写起来不复杂但它省下的排查时间远比写它花的时间多。