关键结论:
1、我们在 attributedString 中设置的 font 和最终显示出来的 font 并不一定是相同的!
2、iOS 渲染的文本,绝没有自带边距。之所觉得 iOS 自带了边距,是因为没有考虑字体的因素。
3、中文字体没有用到 descender 的空间
4、通过设置 lineHeightMultiple 可以为文本增加间距,再通过设置 baselineoffset 让每一行的内容居中,这二者的关系式是:
baselineoffset = ((lineHeightMultiple - 1)* font.lineheight - font.descender)/(3 - lineHeightMultiple)
font.lineheight、font.descender 是 font 的固有属性
具体分析:
1、如何在 iOS 设备上先是一段文本内容?
在日常的 iOS 开发中,一般会使用系统提供的控件来显示文本内容。这些控件,无外乎:UILabel、UITextField、UITextView 这三者。当然,也可以自己实现,不过这样就需要手动调用 CoreText 的 API,很少有人会这么做。
1.1、CoreText 是什么?
CoreText 是 iOS 用来对文本内容进行排版的引擎。注意,CoreText 是排版引擎,而不是绘制引擎。开发工程师在日常开发时,通过调用 UIKit 的 API ,将文本和相应的文本属性,设置成一个 attributedString,而后将这个 attributedString 赋值给控件。
控件在收到这个 attributedString 时出发刷新显示(setNeedsDisplay),而后调用控件自身的 draw 方法。在 draw 中,将 attributedString 传递给 CoreText,计算文本的排版效果。计算结束后,将结果存储在 contextRef(上下文句柄) 上。最后,通过 CoreGraphics (核心绘图引擎),将 contextRef 中的信息,绘制在用户界面上(User Interface)。
总结下大致的流程:
输入一段 text,并设置相应的属性(字色、font、linespacing等)→ attributedString → CoreText → 排版完成后的 contextRef → CoreGraphics → Quartz2D (二维渲染引擎) → Graphics Hardware → User Interface
备注:
Quartz2D 是否继续调用了 OpenGLES,这个需要研究,不过不影响解释行高的问题。
2、CoreText 是如何排版的?
首先上一个官方的介绍图:
CT 是 CoreText 的缩写。
由上图可以很清晰的明白,CoreText 的运行机理:
首先,通过 attributedString 配置一个 CTFramesetter,通过 CTFramesetter 创建一个 CTFrame ,就是一个文本绘制的 矩形框。
CTFrame 包含多个 paragraphs(段落),每个段落中包含多个 CTLine(行),每个 CTLine 中包含多个 CTRun(具有相同文本属性的绘制实体)。
而 CTRun 中包含了字形 Glyph。
通过这一连串的逐步细化,最终将一大段文本,落脚到了单个字形上,这也就是 CoreText 的排版过程。
3、我们的问题出在哪里?
3.1、先来看看西文排版的逻辑:
3.2、再来看看一般开发时设计给开发提的问题:
设计认为的行间距是两行文本内容的间距,而开发设置了 linespacing,结果却是上图中两个粉色块之前的距离。
还有不少人会认为,iOS 的文本自带了一个边距。然而,事实并不是这样,并没有边距。那么问题出在哪里?
让我们在 xib 中设置一个 UIlabel:

然后在模拟器中,看一下实际效果:

这里看到,模拟器中 label 的高度是 23,和我们在编辑的时候是一样的。所以,设计、开发都觉得,对,这个 UIlabel 就是 23 高了,其实呢?并不是这样。
我们首先将 xib 中的内容删掉,让 xib 中的 UIlabel 的高为 0。如果不这样做的话,即便用代码设置了 attributedString ,如果 CoreText 排版计算下来,attributedString 的高度比 xib 中的高度低,那么不会触发重绘(因为,这部分底层调用的函数是 setneeddisplay),也就是说这样得出的 UIlabel 的高度是不对的。
我们写段代码:

再来看看结果:

现在,height 是 19.5。而且文本内容所占的空间,非常贴合控件的内部空间(注意“g”的位置)
So what happen?
Nothing~
发生了什么呢?其实什么都没发生,那为什么,一个是 22.5,另一个是 19.5?原因出在字体上。
第一次测试的时候,字体是 pingfang SC,而 pingfang SC 的 font.lineheight 是:

而 SFUIText 的 font.lineheight 是:
所以,这就解释了为什么同一段文本的 lineheight,第一次是 22.5,而第二次是 19.5。
那么,我们设置的 font 不是 pingfang SC 么,不是 SFUIText 啊。拿 SFUIText 来解释不是张冠李戴吗?并不是。。
原因是,我们在开发时,并没有指定使用 pingfang SC 而是指定使用 system font,有系统来判断使用哪个字体。一般,中文是 pingfang SC,而西文和数字是 SFUIText。
还记得上面说的 CTRun 吗?

“EhHducational_xifg呵呵” 这段文本被分成了两个 CTRun,前一个是西文,font 是 SFUIText-Medium;后一个是中文,font 是 PingFangSC-Medium。
所以,讲了这么多,我只想说明一点:
iOS 渲染的文本,绝没有自带边距。之所觉得 iOS 自带了边距,是因为没有考虑字体的因素。
4、中文字体字体在显示时,为什么总是线上跑了点?
继续截图:
这里可以看到,中文字体没有用到 descender 的空间。这是因为,descender 是英文显示标准中的一个值,为了显示 baseline(基线)以下的字形。而目前,所有的 it 软件,都是以英文为语言基准的。所以,就必须把中文套用到英文的排版标准。
所以,显示中文时 descender 的空间就是没有用的,给人的错觉是 —— 这是一段边距。
5、iOS 中文本内容高度的计算公式:
我参考了一些 iOS UIKit 实现的代码,加上我自己的推算,得出的计算公式是:
round((font.lineheight + baselineOffset) * paragraphStyle.lineHeightMultiple)
font.lineheight:字体的行高 = descender + ascender + leading
baselineOffset:基线
paragraphStyle.lineHeightMultiple:行高乘数
round:UIKit 中对行高计算结果,进行舍入的函数,具体行为是 :
0 < lineheight < 0.5 ≈ 0.5
0.5 ≤ lineheight < 1 ≈ 1
这是为了方便对齐排版结果。
让我们看下实际效果,仍旧用前面的例子:
5.1、baselineOffset = 0,paragraphStyle.lineHeightMultiple = 0,font:SFUIText-Medium,fontsize:16,font.lineheight ≈ 19.09。
那么 lineheight 就是 round(font.lineheight),那么实际的 lineheight 就是 19.5。
让我们调整下参数:
5.2、baselineOffset = 0,paragraphStyle.lineHeightMultiple = 2,font:SFUIText-Medium,fontsize:16,font.lineheight ≈ 19.09。
那么 lineheight 就是 round(19.09 * 2) ≈ 38.5

继续改:
5.3、baselineOffset = 3,paragraphStyle.lineHeightMultiple = 2,font:SFUIText-Medium,fontsize:16,font.lineheight ≈ 19.09。
那么 lineheight 就是 round((19.09 + 3) * 2) ≈ 44.5

继续改:
5.4、baselineOffset = 5,paragraphStyle.lineHeightMultiple = 0,font:SFUIText-Medium,fontsize:16,font.lineheight ≈ 19.09。
那么 lineheight 就是 round((19.09 + 5) ) ≈ 24.5
这里注意下蓝色部分,这个蓝色部分和 label 的底边是对齐的,它的高度实际上是 label 中文本内容的 baseline 到文本控件底边的距离:

这条蓝色边的高度是 14:
实际上,我给它设的高度是 13.86 = 2 * baselineoffset + descender
这里为什么要乘以 2 呢?因为这个时候,lineheight = font.lineheight + baselineoffset。所以,文本总高度增加了 baselineoffset。而此时,baseline 又被向上偏移了(还是因为 baselienoffset),所以导致
baseline 下面的距离变成了 2 * baselineoffset + descender。有了这个基础,然我们来看看如何让文本内容居中吧。
5.5、文本加了 lineHeightMultiple后,baselineoffset 怎么设置才能看上去居中呢?
我们所谓的居中,实际上是对 baseline 以上的内容进行居中。所以,baseline 以下部分的边距(视觉边距)是:
descender + baselineoffset * 2
baseline 以上的部分是:
lineheight - (descender + baselineoffset * 2
这里还要再减掉文本内容所占的空间高度,才能计算出 baseline 上侧的边距:
lineheight - (descender + baselineoffset * 2)- ascender
二者相等,则内容居中,所以有:
descender + baselineoffset = lineheight - (descender + baselineoffset * 2)- ascender
通过数学计算可以得出结论:
baselineoffset = ((lineHeightMultiple - 1)* font.lineheight - descender)/(3 - lineHeightMultiple)
让我们以 paragraphStyle.lineHeightMultiple = 1.46,font:SFUIText-Medium,fontsize:16,font.lineheight = 19.09375,font.descender = 3.859375 来计算下结果:
((1.46 - 1)* 19.09375 - 3.859375)/(3 - 1.46)= 4.09375 / 1.54 ≈ 2.658,round 之后就是 3。之前这个 3 我是通过不断尝试,得到的一个经验值,现在明白了其中的逻辑。
6、参考资料:
1、apple 官方文档:https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/CoreText_Programming/Introduction/Introduction.html#//apple_ref/doc/uid/TP40005533-CH1-SW1
2、一个 UIKit 的底层实现代码,有点老,不过值得看看:https://github.com/BigZaphod/Chameleon