MMM 6

2019/07/01

Swift Deep Dive

每次 WWDC 结束,我总会对 Apple 原生平台技术产生短暂的兴趣,本次也不例外。这一次更新,我感兴趣的部分有:

  • Xcode 11 对 Swift Package Manager 的支持。
  • Swift UI。虽然根据我的目测,文档编辑视图完全用不上 Swift UI 以及它带来的基础设施,但是在文档编辑视图开发结束后,app 其它的部分显然可以充分使用 Swift UI。
  • 新的光标编辑行为。Apple 声称提供了 UITextInteractionUITextInput 实现者提供全部的光标交互——在 iOS 13 之前光标、放大镜都需要手绘——不过在 Beta 1 的测试里我并没有成功使用这个功能,有待进一步调查。
  • Swift 5 新的功能,property wrapper、DSL,以及我这些年没关注 Swift 加入的无数新功能。

于是乎,我就开始了近一个月的 Swift 之旅。工作成果主要涉及了:

  • 确定文档树的形状。文档树由文档、小节、行三个部分组成,行可以是一个段落单件或者一个网格,但网格不能嵌套小节或者文档,只能包含由段落组成的列。
  • 把玩了 UIScrollView,包括如何流畅滚动超过十万个段落组成的文档。我的目标是在 app 发布的时候内置一本《三国演义》或者 《Ἱστορίαι》,展现一下性能。
  • (进行中)将部分富逻辑的纯代码拆分进 pure-litchi。本文主要涉及这个部分。

Functional Core, Imperative Shell

这个口号来自 Gary Bernhardt 先生的某篇文章/讲座。核心思想很简单:把逻辑复杂、无副作用的核心代码从壳子里剥离出来,既方便了测试,壳本身也得到了简化。Swift 并不要求,可能也不适合硬核如 Haskell 那样的函数式编程——类型系统复杂、处理 enum 的语法啰嗦。但 FCIS 的思想应用可以很灵活。举个最简单的例子。

和幸福的前端工程师不同,iOS 开发者需要手工完成排版操作。聪明的码农会把文档树从视图里剥离出来,专门实现并加以测试——这已经是 FCIS 的体现。但是,排版本身却会放在视图里。对于简单的 CURD app 或许没有问题,但是 Litchi 不一样。为了实现编辑《三国演义》的目标,文档树的编辑必须可以翻译成局部排版操作。如果排版本身丢进视图或者视图控制器里,测试就会变得非常复杂:我们需要启动一个新的程序,加载一个文档,修改一部分,看看新的视图状态和直接加载修改后的文档生成的视图是否一致。

因此,更好的解决方案是专门设计一个与视图无关的排版树。pure-litchi 便是这样做的。排版树完全由文档树、窗口宽度、样式决定——是一个数学意义上的函数。对于文档变更,我定义了一个变更类型:

  1. public enum Mutation<Element> {
  2. case update(Path, Element)
  3. case insert(Path, Element)
  4. case delete(Path)
  5. case group([Mutation])
  6. }

我们可以把任何文档树变更映射成排版树变更,并用后者局部更新排版树。

稍有常识的人都能看出,这里有一个很简单的不变量:编辑文档树后重新排版,得到的结果应该和局部更新的排版树一样。测试起来也很方便。

这里需要插一句,Swift 对值类型和协议的推崇,使得得到 immutable 代码其实很容易。我们可以定义 mutating 方法,在调用的时候只修改复制的值,就可以得到无副作用的行为。如此一来,备份排版树就只需要 a = b。更传统的 OOP 语言需要专门定义一个复制函数,虽然也没有多麻烦。

协议、继承与树

不同的文档树节点的会有不同的行为,但有一套共享的访问子节点的办法。最简单的实现如下:

  1. protocol Block {
  2. var blocks: [Block] { get }
  3. }
  4. struct Paragraph: Block {
  5. var text: String
  6. var blocks: [Block] { [] }
  7. }
  8. struct Section: Block {
  9. var rows: [Row]
  10. var blocks: [Block] { rows }
  11. }

但是,我们有不止一棵树!很多树操作是可以共享的:

  • 根据变更类型更新树。
  • 遍历叶子结点。Litchi 要求所有的叶子结点都必须是一个段落类型,这样就不会出现高度为零的容器。在实际渲染的时候,我们只给可见的叶子结点分配一个视图,而容器并没有视图。因此,我们的文档树、排版树都有遍历叶子结点的需求。

最简单的思路就是引入一个 Node 协议:

  1. protocol Node {
  2. var children: [???] { get }
  3. }
  4. protocol Block {
  5. var blocks: [Block] { get }
  6. }
  7. extension Block: Node {
  8. var children: [???] { ??? }
  9. }

然而,Node 的 children 属性的类型值得思考:

  • 它最好不是 Node,否则所有的树都有同样的节点类型,需要大量的转换检查。当然,这不是说这个方案不可行,通过智能构造器我们可以从源头上解决这一问题,但是源头内还是会犯错误的。
  • 它肯定不可以是 Block,否则我们的树不能有其它具体的节点类型了。
  • 它不可以是 Self,因为 Self 代表的是最终的具体类型,而 Block 本身也是协议。换而言之,如果是 Self 的话,Block 无法提供默认实现,且上面的 Section 也无法定义,因为 Section 的子节点类型不是 Section 而是 Row

看来解决方案是通过一个 associatedtype 指明 Block。但这也不行,一方面我不确定 associatedtype 可不可以是一个协议,但另一方面,有了 associatedtype 之后,协议就只能用做泛型的限制了,而 Block 继承 Node 也会继承这个限制,如下代码将不合法:

  1. let x: Block = ???

需要注意的是,类集成体系无法绕过这个问题,实际上,如果你在协议里使用 Self 的话,只有 final class 才可以实现那个协议。Swift 的类型系统或许复杂、孱弱,但是正确性上还是很小心的。

想到这个解决方案花了我好几天的时间。其实原料我一直都知晓,但是如何正确的使用却花了我一番功夫,看来脑子确实变笨了。原料是标准库用来绕过上述的一条限制的设计模式:有 associatedtype 的协议只能用做泛型的限制。可是标准库的容器都有 associatedtype 来指定元素和索引的类型。于是标准库就给了诸如 AnySequence<T> 这样的容器,其中 T 是元素的类型。所有的 Sequence 协议的实现都被转发给底层实现了。具体的实现方式如下:

  • 提供一个父类 AnySequenceBoxBase<T>
  • 提供一个泛型类 AnySequenceBox<S> 继承 AnySequenceBoxBase<S.Element>
  • AnySequence<T> 持有 AnySequenceBoxBase<T>

这个技术在 Swift 社区的名字叫类型擦除容器。注意它是完全类型安全的。

回到我这里,其实 Block 没有 associatedtype,所以我也不用使用父类加泛型子类的技术完成类型擦除,直接提供如下 AnyBlock 就好:

  1. protocol Node {
  2. var children: [Self] { get }
  3. }
  4. protocol Block {
  5. var blocks: [Block] { get }
  6. }
  7. struct AnyBlock: Node {
  8. var block: Block
  9. var children: [AnyBlock] {
  10. block.blocks.map { AnyBlock(block: $0) }
  11. }
  12. }

对于对底层表示感兴趣的朋友请注意,其实每一个 AnyBlock 树只有最顶层是用 AnyBlock 封装的,内部还是原来的 Block 协议。Node 协议上定义的方法在遍历树的时候,反复调用 AnyBlockchildren,创建新的封装。我不太确定这一 map 是否可以被 Swift 优化掉,如果不可以的话,考虑使用 lazy map。

60FPS 的挑战

要实现十万级别的流畅滚动,我们必须无视掉 99.99% 的不可见条目,只渲染可见的十几个条目。由于 Cocoa 和 Cocoa Touch 的实现问题,给视图添加子视图比较慢,但是调整它们的位置和大小很快。所以 Apple 实现 UITableView 的办法是回收现有的子视图,而不是重新创建它们。

对于高度固定的条目,省略 99.99% 的条目并计算剩下的条目的绝对位置是一件很简单的事情。可是文本编辑不一样,每一个段落的高度不能很快的计算出来。等宽字体还好,西文比较可怕。即使是方块字,由于标点禁则,部分标点不能出现在行首,本可处于行末的汉字要提前换行,挡在下一个标点前面。因此,我们不知道每行字数,也无从常数时间内计算行数。Apple 的排版引擎很快了,但哪怕用最简单的左对齐,单核心算完《三国演义》也需要好几秒。

若只是加载文档、切换字体时需要卡顿也还好,配合多核心排版,加载风火轮也可以控制在一秒内。但是当改变窗口大小时,我们需要实现 60FPS 的重排版计算。思路有两个。设计上,可以在窗口改变大小的时候模糊窗口,类似 iPad 上改变 Split View 的过渡动画。技术上,可以考虑使用错误的绝对高度——对于滚动而言,编辑区域附近的相对高度是唯一有意义的内容。

后者有两个问题。其一,设计一个支持按需排版的数据结构是一件很复杂的事情,且这个算法和现有的自顶向下算法完全不兼容。其二,由于我们要支持网格系统,支持多列并排,如果要保证正确的绝对高度,那么在预览多列的时候是必须完整排版整个网格的。99% 的情况下这也不是大问题,但是算法存在退化到掉帧的可能性。

因此,目前我个人倾向于使用第一个解决方案。大致的思路是在后台进行重排操作。未完成的时候,模糊整个窗口,直到排版完成。如果在排版过程中视图大小发生宽度变化,打断并重新开始。不过这里有很多细节需要考虑,尤其是线程安全性上的,毕竟 Swift 的容器是不保证写安全的。

三分结构

使用前述方案,为进一步降低耦合,我计划添加一类渲染树。这样一类:

  • 文档树负责数据管理和编辑
  • 排版树负责决定每个段落节点的大小与位置,根据文档树和窗口大小的变化而变化。
  • 渲染树负责实际渲染,根据排版树和窗口位置的变化而变化。

渲染树会根据窗口位置对排版树进行剪枝。同时,由于它有排版树的完整拷贝,改变窗口的时候可以正确显示新的内容。每一个叶子结点会持有一个 Drawable 类型,可以是一个 UIView,也可以是一个 UIImage 用于渲染测试。

从信息流的角度看,信息在三颗树里是单向传递的:

  • 文档树的变更引起排版树的变更
  • 窗口宽度和样式的变更引起排版树的变更
  • 排版树的变更引起渲染树的变更
  • 窗口位置的变更引起渲染树的变更

实际的编辑视图将拥有以上三棵树,并用它们提供的信息与输入系统展开交互。跟位置测试有关的代码将查询排版树,实际输入的处理转交给文档树,子视图的创建和回收由渲染树负责。绘制发生在两个阶段,子视图的重利用和文本的编辑。

一些吐槽

截至目前,SPM 还不支持测试 bundle,Xcode 也没有提供任何支持。如果你的测试需要额外的数据文件,目前还没有任何跨平台的方法提供这些文件。举个例子,我的 pure-litchi 涉及到绘制代码,渲染测试工作流如下:

  • 先指定输入渲染一张图;
  • 手工检查渲染结果正确性,正确后加入测试集合;
  • 未来的测试,重新渲染本图,进行像素级比较。

因此,测试程序必须可以加载预生成的基准图像。有些 SO 答案提及使用命令行调用 swift test 的时候根目录为工作目录,但我在 macOS 上并不能复现这一行为。即使可以,由于我的测试需要在 iOS 模拟器内运行,这一方法肯定没用。我的解决方案自然是使用 python -m SimpleHTTPServer——这大概是我对 Python 社区唯一的正面印象了。

另一个值得注意的是伴随 SwiftUI 而来的 Swift DSL 能力。Apple 只在内部就 DSL 设计展开了一些讨论,并在 Xcode 11 beta 里 ship 了一个简易的实现。很快,它们在 Swift Evolution 里提了一个 draft。不过那个 draft 里 99% 的方法并没有被实现,而且 Xcode 11 里的实现对于一切类型错误都会报一个很 generic 的错。对于感兴趣的朋友我只有一个忠告,就是你只需要定义也只能使用 buildComponent(_ content: Component...)buildIf(_ content: Component?),其它都是假的,都用不了,定义了也没效果。

最后的最后,愿我的 Swift 之旅不要不成熟的结束。

— 2019 年 6 月 30 日于大本营。

本文全部照片均属公有领域。感谢 Pexels 提供的照片查找平台。感谢 Jiawei Cui 的第一张,Pixabay 的第二张第六张,Asad Photo 的第三张,Kyle Roxas 的第四张,以及 Lindsey 的第五张