MMM 6
2019/07/01
Swift Deep Dive
每次 WWDC 结束,我总会对 Apple 原生平台技术产生短暂的兴趣,本次也不例外。这一次更新,我感兴趣的部分有:
- Xcode 11 对 Swift Package Manager 的支持。
- Swift UI。虽然根据我的目测,文档编辑视图完全用不上 Swift UI 以及它带来的基础设施,但是在文档编辑视图开发结束后,app 其它的部分显然可以充分使用 Swift UI。
- 新的光标编辑行为。Apple 声称提供了
UITextInteraction
给UITextInput
实现者提供全部的光标交互——在 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
便是这样做的。排版树完全由文档树、窗口宽度、样式决定——是一个数学意义上的函数。对于文档变更,我定义了一个变更类型:
public enum Mutation<Element> {
case update(Path, Element)
case insert(Path, Element)
case delete(Path)
case group([Mutation])
}
我们可以把任何文档树变更映射成排版树变更,并用后者局部更新排版树。
稍有常识的人都能看出,这里有一个很简单的不变量:编辑文档树后重新排版,得到的结果应该和局部更新的排版树一样。测试起来也很方便。
这里需要插一句,Swift 对值类型和协议的推崇,使得得到 immutable 代码其实很容易。我们可以定义 mutating 方法,在调用的时候只修改复制的值,就可以得到无副作用的行为。如此一来,备份排版树就只需要 a = b
。更传统的 OOP 语言需要专门定义一个复制函数,虽然也没有多麻烦。
协议、继承与树
不同的文档树节点的会有不同的行为,但有一套共享的访问子节点的办法。最简单的实现如下:
protocol Block {
var blocks: [Block] { get }
}
struct Paragraph: Block {
var text: String
var blocks: [Block] { [] }
}
struct Section: Block {
var rows: [Row]
var blocks: [Block] { rows }
}
但是,我们有不止一棵树!很多树操作是可以共享的:
- 根据变更类型更新树。
- 遍历叶子结点。Litchi 要求所有的叶子结点都必须是一个段落类型,这样就不会出现高度为零的容器。在实际渲染的时候,我们只给可见的叶子结点分配一个视图,而容器并没有视图。因此,我们的文档树、排版树都有遍历叶子结点的需求。
最简单的思路就是引入一个 Node
协议:
protocol Node {
var children: [???] { get }
}
protocol Block {
var blocks: [Block] { get }
}
extension Block: Node {
var children: [???] { ??? }
}
然而,Node 的 children
属性的类型值得思考:
- 它最好不是
Node
,否则所有的树都有同样的节点类型,需要大量的转换检查。当然,这不是说这个方案不可行,通过智能构造器我们可以从源头上解决这一问题,但是源头内还是会犯错误的。 - 它肯定不可以是
Block
,否则我们的树不能有其它具体的节点类型了。 - 它不可以是
Self
,因为Self
代表的是最终的具体类型,而Block
本身也是协议。换而言之,如果是Self
的话,Block
无法提供默认实现,且上面的Section
也无法定义,因为Section
的子节点类型不是Section
而是Row
。
看来解决方案是通过一个 associatedtype
指明 Block
。但这也不行,一方面我不确定 associatedtype
可不可以是一个协议,但另一方面,有了 associatedtype
之后,协议就只能用做泛型的限制了,而 Block
继承 Node
也会继承这个限制,如下代码将不合法:
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
就好:
protocol Node {
var children: [Self] { get }
}
protocol Block {
var blocks: [Block] { get }
}
struct AnyBlock: Node {
var block: Block
var children: [AnyBlock] {
block.blocks.map { AnyBlock(block: $0) }
}
}
对于对底层表示感兴趣的朋友请注意,其实每一个 AnyBlock
树只有最顶层是用 AnyBlock
封装的,内部还是原来的 Block
协议。Node
协议上定义的方法在遍历树的时候,反复调用 AnyBlock
的 children
,创建新的封装。我不太确定这一 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 的第五张。