objc系列译文(5.1):认识 TextKit

iOS7 的发布给开发者的案头带来了很多新工具。其中一个就是 TextKit(文本工具箱)。TextKit 由许多新的 UIKit 类组成,顾名思义,这些类就是用来处理文本的。在这里,我们将介绍 TextKit 的来由、它的组成,以及通过几个例子解释开发者怎样将它派上大用场。

但是首先我们得有一点背景知识:TextKit 可能是近期对 UIKit 最重要的补充了。iOS7 的新界面用纯文本按钮替换了大量的图标和边框。总的来说,文本和文本布局在新的操作系统的外观方面比以前重要多了。iOS7 的重新设计完全是被文本驱动,这样说也许并不夸张——而文本全部是TextKit来处理的。

告诉你这个变动到底有多大吧:iOS7 之前的所有版本,(几乎)所有的文本都是 WebKit 来处理的。对:WebKit,web 浏览器引擎。所有UILabel、UITextField,以及 UITextView 都在后台以某种方式使用 web 视图来进行文本布局和渲染。为了新的界面风格,它们全都被重新设计以使用TextKit。

iOS上文本的简短历史

这些新类并不是用来替换开发者以前使用的类。对 SDK 来说,TextKit 提供的是全新的功能。iOS7 之前,TextKit 提供的功能必须都手动完成。这是现有功能之间缺失的环节。

长期以来,只有一个基本的文本布局和渲染框架:CoreText。也有一个途径读取用户的键盘输入:UITextInput 协议。iOS6 甚至有一个途径来简单地获取系统的文本选择:继承 UITextView。

(这可能是重点,我应该公开我开发文本编辑器的十年经验了)在渲染文本和读取键盘输入之间存在着巨大(跟我读:巨大)的缺口。这个缺口可能也是导致很少有富文本或者语法高亮编辑器的原因了——毫无疑问,开发一个好用的文本编辑器得耗费几个月的时间。

就这样——如下是 iOS 文本(不那么)简短历史的简短概要:

iOS 2:这是第一个公开的 SDK,包括一个简单的文本显示组件( UILabel ),一个简单的文本输入组件( UITextField ),以及一个简单的、可滚动、可编辑的并且支持更大量文本的组件:UITextView。这些组件都只支持纯文本,没有文本选择支持(仅支持插入点),除了设置字体和文本颜色外几乎没有其他可定制功能。

iOS 3:新特性有复制和粘贴,以及复制粘贴所需要的文本选择功能。数据探测器(Data Detector)为文本视图提供了一个高亮电话号码和链接的方法。然而,除了打开或关闭这些特性外,开发者基本上没有什么别的事情可以做。

iOS 3.2:iPad 的出现带来了 CoreText,也就是前面提到的低级文本布局和渲染引擎(从Mac OS X 10.5 移植过来的),以及 UITextInput,前面也提到的键盘存取协议。Apple 将 Pages 作为移动设备上文本编辑功能的样板工程(附注1)。然而,由于我前面提到的框架缺口,只有很少的应用使用它们。

iOS 4:iOS 3.2 发布仅仅几个月后就发布了,文本方面没有一丁点新功能。(个人经历:在 WWDC,我走近工程师们,告诉他们我想要一个完善的 iOS 文本布局系统。回答是:“哦…提交个请求。”不出所料…)

iOS 5:文本方面没啥变化。(个人经历:在 WWDC,我和工程师们谈及 iOS 上文本系统。回答是:“我们没有看到太多的请求…” 靠!)

iOS 6:有些动作了:属性文本编辑被加入了UITextView。很不幸的是,它很难定制。默认的UI有粗体、斜体和下划线。用户可以设置字体大小和颜色。粗看起来相当不错,但还是没法控制布局或者提供一个便利的途径来定制文本属性。然而对于(文本编辑)开发者,有一个大的新功能:可以继承 UITextView 了,这样的话,除了以前版本提供的键盘输入外,开发者可以“免费”获得文本选择功能。必须实现一个完全自定义的文本选择功能,可能是很多对非纯文本工具开发的尝试半途而废的原因。(个人经历:我,WWDC,工程师们。我想要一个 iOS 的文本系统。回答:“嗯。吖。是的。也许?看,它只是不执行…” 所以毕竟还是有希望,对吧?)

iOS 7:终于来了,TextKit。

功能

所以咱们到了。iOS7 带着 TextKit 登陆了。咱们看看它可以做什么!深入之前,我还想提一下,严格来说,这些事情中的大部分以前都可以做。如果你有大量的资源和时间来用CoreText构建一个文本引擎,这些都是可以做的。但是如果以前你想构建一个完善的富文本编辑器,你得花费几个月的时间。现在就非常简单,你只需要到在Xcode里打开一个界面文件,然后将UITextView拖到你的试图控制器,就可以获得所有的功能:

字距调整(Kerning):所有的字符都有简单的二次的形状,这些形状必须被精确地放置,彼此相邻的,别这样想了。例如,现代文本布局会考虑到一个大写的“T”的“两翼”下面有一些空白,所以它会把后面的小写字母向左移让它们更靠近点。从而大大提高了文本的易读性,特别是在更长的文字中:

连写:我认为这主要是个艺术功能,但当某些字符组合(如“f”后面是“l”)使用组合符号(所谓的字形(glyph))绘制时,有些文本确实看起来更好(更美观)。

图像附件:现在可以在文本视图里面添加图像了。

断字:编辑文本时没那么重要,但如果要以好看易读的方式展现文本时,这就相当重要。断字意味着在行边界处分割单词,从而为整体文本创建一个更整齐的排版和外观。个人经历:iOS7 之前,开发者必须直接使用 CoreText。像这样:首先以句子为基础检测文本语言,然后获取句子中每个单词可能的断字点,然后在每一个可能的断字点上插入定制的连字占位字符。准备好之后,运行 CoreText 的布局方法并手动将连字符插入到断行。如果你想得到好的效果,之后你得检查带有连字符的文本没有超出行边界,如果超出了,在运行一次行的布局方法,这一次不要使用上次使用的断字点。使用 TextKit 的话,就非常简单了,设置 hyphenationFactor 属性就可以启用断字。

可定制性:对我来说,甚至比改进过的排版还多,这是个新的功能。以前开发者必须在使用现有的功能和自己全部重头写之间做出选择。现在提供了一整套类,它们有代理协议,或者可以被覆盖从而改变部分行为。例如,不必重写整个文本组件,你现在就可以改变指定单词的断行行为。我认为这是个胜利。

更多的富文本属性:现在可以设置不同的下划线样式(双线、粗线、虚线、点线,或者它们的组合)。提高文本的基线非常容易,这可用来设置上标数字。开发者也不再需要自己为定制渲染的文本绘制背景颜色了(CoreText 不支持这些功能)。

序列化:过去没有内置的方法从磁盘读取带文本属性的字符串。或者再写回磁盘。现在有了。

文本样式:iOS7 的界面引入了一个全局预定义的文本类型的新概念。这些文本类型分配了一个全局预定义的外观。理想情况下,这可以让整个系统的标题和连续文本具有一致的风格。通过设置应用,用户可以定义他们的阅读习惯(例如文本大小),那些使用文本样式的应用将自动拥有正确的文本大小和外观。

文本效果:最后也是最不重要的。iOS7 有且仅有一个文本效果:凸版。使用此效果的文本看起来像是盖在纸上面一样。内阴影,等等。个人观点:真的?靠…?在一个已经完全彻底不可饶恕地枪毙了所有无用的怀旧装饰的操作系统上,谁会需要这个像文本盖在纸上的外观?

结构

可能概览一个系统最好的方法是画一幅图。这是UIKit文本系统——TextKit的简图,:

从上图可以看出来,要让一个文本引擎工作,需要几个参与者。我们将从外到里介绍它们:

字符串(String):要绘制文本,那么必然在某个地方有个字符串存储它。在默认的结构中,NSTextStorage 保存并管理这个字符串,在这种情况中,它可以远离绘制。但并不一定非得这样。使用 TextKit 时,文本可以来自任何适合的来源。例如,对于一个代码编辑器,字符串可以是一棵包含所有显示的代码的结构信息的注释语法树(annotated syntax tree, AST)。使用一个定制的文本存储,这个文本只在后面动态地添加字体或颜色高亮等文本属性装饰。这是第一次,开发者可以直接为文本组件使用自己的模型。只需要一个特别设计的文本存储。即:

NSTextStorage:如果你把文本系统看做一个模型-视图-控制器(MVC)架构,这个类代表的是模型。文本存储是中心对象,它知道所有的文本和属性信息。它只提供了两个存取器方法存取它们,并提供了另外两个方法来修改它们。后面我们将进一步了解它们。现在重要的是你得理解 NSTextStorage 是从它的父类 NSAttributedString 继承了这些方法。这就很清楚了,文本存储——从文本系统看来——仅仅是一个带有属性的字符串,以及几个扩展。这两者唯一的重大不同点是文本存储包含了一个方法来发送内容改变的通知。我们会马上介绍这部分内容。

UITextView:堆栈的另一头是实际的视图。在 TextKit 中,文本视图有两个目的:第一,它是文本系统用来绘制的视图。文本视图它自己并不会做任何绘制;它仅仅提供一个供其它类绘制的区域。作为视图层级机构中唯一的组件,第二个目的是处理所有的用户交互。具体来说,文本视图实现 UITextInput 的协议来处理键盘事件,它为用户提供了一种途径来设置一个插入点或选择文本。它并不对文本做任何实际上的改变,仅仅将这些改变请求转发给刚刚讨论的文本存储。

NSTextContainer:每个文本视图定义了一个文本可以绘制的区域。为此,每个文本视图都有一个文本容器,它精确地描述了这个可用的区域。在简单的情况下,这是一个垂直的无限相当大的矩形区域。文本被填充到这个区域,并且文本视图允许用户滚动它。然而,在更高级的情况下,这个区域可能是一个无限大的矩形。例如,当渲染一本书时,每一页都有最大的高度和宽度。文本容器会定义这个大小,并且不接受任何超出的文本。相同情况下,一幅图像可能占据了页面的一部分,文本应该沿着它的边缘重新排版。这也是由文本容器来处理的,我们会在后面的例子中看到这一点。

NSLayoutManager:布局管理器是中心组件,它把所有组件粘合在一起:

  • 1、这个管理器监听文本存储中文本或属性改变的通知,一旦接收到通知就触发布局进程。
  • 2、从文本存储提供的文本开始,它将所有的字符翻译为字形(Glyph)(附注2).
  • 3、一旦字形全部生成,这个管理器向它的文本容器(们)查询文本可用以绘制的区域
  • 4、然后这些区域被行逐步填充,而行又被字形逐步填充。一旦一行填充完毕,下一行开始填充。
  • 5、对于每一行,布局管理器必须考虑断行行为(放不下的单词必须移到下一行)、连字符、内联的图像附件等等。
  • 6、当布局完成,文本的当前显示状态被设为无效,然后文本管理器将前面几步排版好的文本设给文本视图。

CoreText:没有直接包含在 TextKit 中,CoreText 是进行实际排版的库。对于布局管理器的每一步,CoreText 被这样或那样的方式调用。它提供了从字符到字形的翻译,用它们来填充行,以及建议断字点。

Cocoa 文本系统

创建像 TextKit 这样庞大复杂的系统肯定不是件简单快速的事情,而且肯定需要丰富的经验和知识。在 iOS 的前面6个主版本中,一直没有提供一个“真正的”文本组件,这也说明了这一点。Apple 把它视为一个大的新特性,当然没啥问题。但是它真的是全新的吗?

这里有个数字:在 UIKit 的 131 个公共类中,只有 9 个的名字没有使用UI作为前缀。这 9 个类使用的是旧系统的的、旧世界的(跟我读:Mac OS)前缀 NS。而且这九个类里面,有七个是用来处理文本的。巧合?好吧…
这是 Cocoa 文本系统的简图。不妨和上面 TextKit 的那幅图作一下对比。

惊人地相似。很明显,最起码主要部分,两者是相同的。很明显——除了右边部分以及 NSTextView 和 UITextView ——主要的类全部相同。TextKit 是(起码部分是)从 Cocoa 文本系统移植到 iOS。(我之前一直请求的那个,耶!)

进一步比较还是能看出一些不同的。最值得注意的有:

在 iOS 上没有 NSTypesetter 和 NSGlyphGenerator 这两个类。在 Mac OS 上有很多方法来定制排版,这被极大地简化了。这可以去掉一些抽象概念,并将这个过程合并到 NSLayoutManager 中来。保留下来的是少数的代理方法,以用来更改文本布局和断行行为。

这些类的 iOS 实现提供了几个新的而且非常便利的功能。在 Cocoa 中,必须手工地将确定的区域从文本容器分离出来(见上)。而 UIKit 类提供了一个简单的 exclusionPaths 属性就可以做到这一点。

有些功能未能提供,比如,内嵌表格,以及对非图像的附件的支持。

尽管有这些区别,总的来说系统还是一样的。NSTextStorage 在两个系统是是一模一样的,NSLayoutManager 和 NSTextContainer 也没有太大的不同。这些变动,在没有太多去除对一些特例的支持的情况下,看来(某些情况下大大地)使文本系统的使用变得更为容易。我认为这是件好事。

事后回顾我从 Apple 工程师那里得到的关于将 Cocoa 文本系统移植到 iOS 的答案,我们可以得到一些背景信息。拖到现在并削减功能的原因很简单:性能、性能、性能。文本布局可能是极度昂贵的任务——内存方面、电量方面以及时间方面——特别是在移动设备上。Apple 必须采用更简单的解决方案,并等到处理能力能够至少部分支持一个完善的文本布局引擎。

示例

为了说明 TextKit 的能力,我创建了一个小的演示项目,你可以在 GitHub 上找到它。在这个演示程序中,我只完成了一些以前不容易完成的功能。我必须承认编码工作只花了我礼拜天的一个上午的时间;如果以前要做同样的事情,我得花几天甚至几个星期。

TextKit 包括了超过 100 个方法,一篇文章根本没办法尽数涉及。而事实上,大多数时候,你需要的仅仅是一个正确的方法,TextKit 的使用和定制性也仍有待探索。所以我决定做四个更小的演示程序,而非一个大的演示程序来展示所有功能。每个演示程序中,我试着演示针对不同的方面和不同的类进行定制。

演示程序1:配置

让我们从最简单的开始:配置文本系统。正如你在上面 TextKit 简图中看到的,NSTextStorage、NSLayoutManager 和 NSTextContainer 之间的箭头都是有两个头的。我试图描述它们的关系是 1 对 N 的关系。就是那样:一个文本存储可以拥有多个布局管理器,一个布局管理器也可以拥有多个文本容器。这些多重性带来了很好的特性:

  • 将多个文本管理器附加到一个文本存储上,可以产生相同文本的多种视觉表现,而且它们可以并排显示。每一个表现可以独立地布置和修改大小。如果相应的文本视图可编辑,那么在某个视图上做的所有修改都会马上反映到所有视图上。
  • 将多个文本容器附加到一个文本管理器上,可以将一个文本分布到多个视图展现出来。例如很有用的基于页面的布局:每个页面包含一个单独的视图。一个文本管理器利用这些视图的文本容器,将文本分布到这些视图上。

在 storyboard 或者 interface 文件中实例化 UITextView 时,它会预配置一个文本系统:一个文本存储,引用一个文本管理器,而后者又引用一个文本容器。同样地,一个文本系统栈也可以通过代码直接创建:

这是最简单的方式。手工创建一个文本系统,唯一需要记住的事情是你的视图控制器必须 retain 文本存储。在栈底的文本视图只保留了对文本存储和布局管理器的弱引用。当文本存储被释放时,布局管理器也被释放了,这样留给文本视图的就只有一个断开的容器了。

这个规则有一个例外。只有从一个 interface 文件或 storyboard 实例化一个文本视图时,文本视图确实会 retain 文本存储。框架使用了一些黑魔法以确保所有的对象都被 retain,而无需建立一个 retain 环。

记住这些之后,创建一个更高级的设置也非常简单。假设在一个视图里面依旧有一个从 nib 实例化的文本视图,叫做 originalTextView。增加对相同文本的第二个文本视图只需要复制上面的代码,并重用 originalTextView 的文本存储:

将第二个文本容器附加到布局管理器也差不多。比方说我们希望上面例子中的文本填充两个文本视图,而非一个。简单:

但有一点需要注意:由于在 otherTextView 中的文本容器可以无限地调整大小,thirdTextView 永远不会得到任何文本。因此,我们必须指定文本应该从一个视图回流到其它视图,而不应该调整大小或者滚动:

不幸的是,看来将多个文本容器附加到一个文本管理器会禁用编辑功能。如果必须保留编辑功能的话,你只可以将一个文本容器附加到一个文本管理器上。

想要一个这个配置的可运行的例子的话,请在前面提到的 TextKitDemo 中查看“Configuration”标签页。

演示程序2:语法高亮

如果配置文本视图不是那么令人激动,那么这里有更有趣的:语法高亮!

看看 TextKit 组件的责任划分,就很清楚语法高亮应该在文本存储上实现。因为 NSTextStorage 是一个类簇(附注3),创建它的子类需要做不少工作。我的想法是建立一个复合对象:实现所有的方法,但只是将对它们的调用转发给一个实际的实例,将输入输出参数或者结果修改为希望的样子。

NSTextStorage 继承自 NSMutableAttributedString,并且必须实现以下四个方法——两个 getter 和两个 setter:

一个类簇的子类的复合对象的实现也相当简单。首先,找到一个满足所有要求的最简单的类。在我们的例子中,它是 NSMutableAttributedString,我们用它作为实现自定义存储的实现:

有了这个对象,只需要一行代码就可以实现两个 getter 方法:

实现两个 setter 方法也几乎同样简单。但也有一个小麻烦:文本存储需要通知它的文本管理器变化发生了。因此 settter 方法必须也要调用 -edited:range:changeInLegth: 并传给它变化的描述。听起来更糟糕,实现变成:

就这样,我们在文本系统栈里面有了一个文本存储的全功能替换版本。在从 interface 文件中载入时,可以像这样将它插入文本视图——但是记住从一个实例变量引用文本存储:

到目前为止,一切都很好。我们设法插入了一个自定义的文本存储,接下来我们需要真正高亮文本的某些部分了。现在,一个简单的高亮应该就是够了:我们希望将所有 iWords 的颜色变成红色——也就是那些以小写“i”开头,后面跟着一个大写字母的单词。

一个方便实现高亮的办法是覆盖 -processEditing。每次文本存储有修改时,这个方法都自动被调用。每次编辑后,NSTextStorage 会用这个方法来清理字符串。例如,有些字符无法用选定的字体显示时,文本存储使用一个可以显示它们的字体来进行替换。

和其它一样,为 iWords 增加一个简单的高亮也相当简单。我们覆盖 -processEditing,调用父类的实现,并设置一个正则表达式来查找单词:

然后,首先清除之前的所有高亮:

其次遍历所有的样式匹配项并高亮它们:

就是这样。我们创建了一个支持语法高亮的动态文本视图。当用户键入时,高亮将被实时应用。而且这只需几行代码。酷吧?

请注意仅仅使用 edited range 是不够的。例如,当手动键入 iWords,只有一个单词的第三个字符被键入后,正则表达式才开始匹配。但那时 editedRange 仅包含第三个字符,因此所有的处理只会检查这个字符。通过重新处理整个段落,我们可以完成高亮功能,又不会太过影响性能。

想要一个这个配置的可运行的例子的话,请在前面提到的 TextKitDemo 中查看“Highlighting”标签页。

演示程序3:布局修改

如前所述,布局管理器是核心的布局主力。Mac OS 上 NSTypesetter 的高度可定制功能被并入 iOS 上的 NSLayoutManager。虽然 TextKit 不具备像 Cocoa 文本系统那样的完全可定制性,但它提供很多代理方法来允许做一些调整。如前所述,TextKit 与 CoreText 更紧密地集成在一起,主要是基于性能方面的考虑。但是两个文本系统的理念在一定程度上是不一样的:

Cocoa 文本系统:在 Mac OS上,性能不是问题,设计考量的全部是灵活性。可能是这样:“这个东西可以做这个事情。如果你想的话,你可以覆盖它。性能不是问题。你也可以提供完全由自己实现的字符到字形的转换,去做吧…”

TextKit:性能看来真是个问题。理念(起码现在)更多的是像这样:“我们用简单但是高性能的方法实现了这个功能。这是结果,但是我们给你一个机会去更改它的一些东西。但是你只能在不太损害性能的地方进行修改。”

足够的理念,让我们来定制些东西。例如,调整行高如何?听起来不可思议,但是在之前的 iOS 发布版上调整行高至少是很黑客的行为,或者需要使用私有 API。幸运的是,现在(再一次)不用那么搞脑子了。设置布局管理器的代理并实现仅仅一个方法即可:

在以上的代码中,我修改了行间距,让它与文本长度同时增长。这导致顶部的行比底部的行排列得更紧密。我承认这没什么实际的用处,但是它是可以做到的(而且肯定会有更实用的用例的)。

好,来一个更现实的场景。假设你的文本中有链接,你不希望这些链接被行包围。如果可能的话,一个 URL 应该始终显示为一个整体,一个单一的文本片段。没有什么比这更简单的了。

首先,我们通过使用自定义的文本存储,就像前面讨论过的那个。但是,它寻找链接并将其标记,而不是检测 iWords,如下:

有了这个,改变断行行为就只需要实现一个布局管理器的代理方法:

想要一个可运行的例子的话,请在前面提到的 TextKitDemo 中查看“Layout”标签页。以下是截屏:

顺便说一句,上面截屏里面的绿色轮廓线是无法用 TextKit 实现的。在这个演示程序中,我用了个小技巧来在布局管理器的子类中给文本画轮廓线。也可以很容易以特定的方法来扩展 TextKit 的绘制功能。一定要看看!

演示程序4:文本交互

前面已经涉及到了 NSTextStorage 和 NSLayoutManager,最后一个演示程序将涉及 NSTextContainer。这个类并不复杂,而且它除了指定文本可不可以放置在某个地方外,什么都没做。

不要将文本放置在某些区域,这是很常见的需求,例如,在杂志应用中。对于这种情况,iOS 上的 NSTextContainer 提供了一个 Mac 开发者梦寐以求的属性:exclusionPaths,它允许开发者设置一个 NSBezierPath 数组来指定不可填充文本的区域。要了解这到底是什么东西,看一眼下面的截屏:

正如你所看到的,所有的文本都放置在蓝色椭圆外面。在文本视图里面实现这个行为很简单,但是有个小麻烦:贝塞尔路径的坐标必须使用容器的坐标系。以下是转换方法:

在这个例子中,我使用了一个用户可移动的视图,它可以被自由移动,而文本会实时地围绕着它重新排版。我们首先将它的bounds(self.circleView.bounds)转换到文本视图的坐标系统。

因为没有 inset,文本会过于靠近视图边界,所以 UITextView 会在离边界还有几个点的距离的地方插入它的文本容器。因此,要得到以容器坐标表示的路径,必须从 origin 中减去这个插入点的坐标。

在此之后,只需将贝塞尔路径设置给文本容器即可将对应的区域排除掉。其它的过程对你来说是透明的,TextKit 会自动处理。
想要一个可运行的例子的话,请在前面提到的 TextKitDemo 中查看“Interaction”标签页。作为一个小噱头,它也包含了一个跟随当前文本选择的视图。应为,你也知道,没有一个小小的丑陋的烦人的回形针挡住你的话,那还是一个好的文本编辑器演示程序吗?


1. Pages 确实——据 Apple 声称——绝对没有使用私有 API。*咳* 我的理论:它要么使用了一个 TextKit 的史前版本,要么复制了 UIKit 一半的私有源程序。或者两者的混合。
2. 字形:如果说字符是一个字母的“语义”表达,字形则是它的可视化表达。取决于所使用的字体,字形要么是贝塞尔路径,或者位图图像,它定义了要绘制出来的形状。也请参考卓越的 Wikipedia 上关于字形的这篇文章
3. 在一个类簇中,只有一个抽象的父类是公共的。分配一个实例实际上就是创建其中一个私有类的对象。因此,你总是为一个抽象类创建子类,并且需要实现所有的方法。也请参考 class cluster documentation

收藏 评论

关于作者:和谐老约翰

(新浪微博:@和谐老约翰) 个人主页 · 我的文章

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部