written by Talaxy on 2021/4/7
本文样例皆在 iPhone 11 Pro 中运行
写在前面
Markdown 其实是服务于 HTML 的工具,简化 HTML 的编写。事实上 Markdown 的应用十分成功。但是在移动端上或者桌面端上,通常会使用 Web 框架支持来渲染 Markdown 。而本文则会为 SwiftUI 提供一套原生的 Markdown 渲染方式。
渲染 Markdown 既是易事也是难事。首先,Markdown 自己规定了一套语法规则(以及基本渲染方式),语法规则的确定有利于确定程序功能和降低渲染错误率。但是,Markdown 其自身也有一些问题。我在 GitHub 上找到一些使用 Javascript 渲染 Markdown 的项目,他们采用的是套壳的方式,比如:
Hello **markdown** !
复制代码
会被(初步)渲染成:
Hello <strong>markdown</strong> !
复制代码
因此,一些语法的正确性很难决定,比如以下:
* apple
An apple a day keeps the doctor away.
* pine
* banana
复制代码
这里第二行是用了引用,但是结果并没有像前者一样依附在第一个列表项目,而是单独的成为一个行元素。事实上使用缩进会让引用正确渲染。
所以,明确语法是一件重要的事。事实上在解决原生的 Markdown 渲染上,我把模组重构了近 4 遍,主要都是卡在了语法的规则确定上。
渲染器的功能目标
一个渲染器的首要目标是将一个 String 渲染成 View (有点废话),而以下是一些次要的功能点:
- 能够对文本进行预加工处理(比如规范空白符)
- 能够添加一些(开发者期望)自定义的语法
- (开发者)能够自定义元素对应的视图
实际上完成这几样功能不太简单,而且在编写过程中我会遇到各种各样的问题,比如:
- 语法之间的相互碰撞,也就是语法优先级的问题
- 对于列表,列表的项目视图也会通过一个渲染器来渲染二级视图
- 尝试去简化自定义语法和自定义视图的创建和使用
渲染框架
我在这里摆放一张图来方便解释一下渲染的流程框架:
(图仅供参考,可能存在错误)
首先最左侧,我们输入一个文本 Text ,在经过渲染器 Renderer 的渲染后成为最右侧的视图 View 。那么,渲染器内部做了哪些事呢?如果你观察的仔细一点,你会发现 Renderer 的加工带分为了黄色和绿色两种,并代表了两个阶段的加工:
第一阶段:预处理
第一阶段将 markdown 原文本进行预处理,根据语法进行分割,成为一组带类型标注的原文本 Raw 。这里的每一个黄色加工阶段代表一个预处理规则的执行,这个规则通常是分割 Raw ,不过也可以对 Raw 本身进行修改再输出(比如之前提到的空格符规范),甚至可以抛弃 Raw 。
Raw 的定义如下:
struct Raw: Hashable {
let lock: Bool // 未来是否允许被加工
let text: String // 储存的文本信息
let type: String? // 标注的元素类型,通常 lock 锁定后需要明确 type 的类型。
}
复制代码
预处理规则的父类定义为:
// 在自定义一个预处理规则的时候,只要继承 SplitRule 类并覆盖 split 方法即可。
class SplitRule {
// 规则的优先级
let priority: Double
init(priority: Double) {
self.priority = priority
}
// 预处理方法
func split(from text: String) -> [Raw] {
return [Raw(lock: false, text: text, type: nil)]
}
// 批处理方法
final func splitAll(raws: [Raw]) -> [Raw] {
var result: [Raw] = []
for raw in raws {
if raw.lock {
result.append(raw)
} else {
result.append(contentsOf: self.split(from: raw.text))
}
}
return result
}
}
复制代码
也就是说,渲染流程图的每一个黄色块代表一个 SplitRule 实例,他会输入一组 Raw 数据,然后根据 split 方法输出新的一组 Raw 数据。
第二阶段:映射元素
这一阶段用来最终确认每个 Raw 数据的类型。对于每一个 Raw ,我们通过映射规则加工成带属性的元素 Element (像标题、引用、代码块、分割线等基于语法的组成视图的基础部分,我们都可以称之为元素),来进行最终的视图输出。
Element 的定义为:
class Element: Identifiable {
// id 用来保证元素的身份唯一,服务 ForEach 视图组件
let id = UUID()
// 除了将 Raw 输入,可能也需要一个 Resolver 来处理列表的二级渲染
required init(raw: Raw, resolver: Resolver? = nil) {}
init() {}
}
复制代码
对于每种元素,我们只需继承 Element 类,并实现 init(raw:resolver:) 方法即可。
映射规则的父类定义为:
// 在自定义一个映射规则的时候,只要继承 MapRule 类并覆盖 map 方法即可。
class MapRule {
let priority: Double
init(priority: Double) {
self.priority = priority
}
func map(from raw: Raw, resolver: Resolver?) -> Element? {
return nil
}
}
复制代码
Renderer 定义
根据流程图,我们可以轻松写出渲染器的定义:
class Resolver {
let splitRules: [SplitRule]
let mapRules: [MapRule]
init(splitRules: [SplitRule], mapRules: [MapRule]) {
self.splitRules = splitRules
self.mapRules = mapRules
}
// 第一阶段:预处理
func split(text: String) -> [Raw] {
var result: [Raw] = [Raw(lock: false, text: text, type: nil)]
splitRules.sorted { r1, r2 in
return r1.priority < r2.priority
}.forEach { rule in
result = rule.splitAll(raws: result)
}
return result
}
// 第二阶段:映射处理
func map(raws: [Raw]) -> [Element] {
var mappingResult: [Element?] = .init(repeating: nil, count: raws.count)
mapRules.sorted { r1, r2 in
return r1.priority < r2.priority
}.forEach { rule in
for i in 0..<raws.count {
if mappingResult[i] == nil {
mappingResult[i] = rule.map(from: raws[i], resolver: self)
}
}
}
var result: [Element] = []
for element in mappingResult {
if let element = element {
result.append(element)
}
}
return result
}
// 渲染
func render(text: String) -> [Element] {
let raws = split(text: text)
let elements = map(raws: raws)
return elements
}
}
复制代码
在使用 Renderer 的时候,我们会在初始化的时候将两个阶段的规则传给渲染器,然后使用 render 方法进行渲染即可。
视图显示
我们拥有了渲染器来帮助我们将原文本转化为为一组元素,但我们还需要一个视图解析器来帮我们输出元素。我们在前面提过,我们期望开发者也能够自定义每种元素的视图,因此,在视图显示阶段,我们也需要一个元素的视图映射。
以下是 MarkdownView 的定义,他负责输入文本,并接受一个渲染器,和一个视图映射:
struct MarkdownView<Content: View>: View {
let elements: [Element]
// 元素 Element 的视图映射
let content: (Element) -> Content
init(
text: String,
resolver: Resolver,
@ViewBuilder content: @escaping (Element) -> Content
) {
self.elements = resolver.render(text: text)
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 15) {
ForEach(elements) { element in
HStack(spacing: 0) {
content(element)
Spacer(minLength: 0)
}
}
}
}
}
复制代码
那么,视图映射长什么样呢?值得庆幸的是,SwiftUI 支持 switch 语法来构建视图,方便我们来对每一种元素类型制定视图:
struct DefaultElementView: View {
let element: Element
var body: some View {
switch element {
case let header as HeaderElement:
Header(element: header)
case let quote as QuoteElement:
Quote(element: quote)
case let code as CodeElement:
Code(element: code)
...
default:
EmptyView()
}
}
}
复制代码
最终,我们可以这样使用 MarkdownView :
struct CustomMarkdownView: View {
let markdown: String
let resolver = Resolver(splitRules: [ /* rules */ ],
mapRules: [ /* rules */ ])
var body: some View {
MarkdownView(text: markdown, resolver: resolver) { element in
switch element {
/* cases */
default:
EmptyView()
}
}
}
}
复制代码
结束语
我本人现在在写这个 SwiftUI 的 Markdown 渲染的项目,不久会发布第一个 SPM 版本,感兴趣的话可以收藏这篇文章,我会在未来把开源项目的链接放在文章中。
最后,感谢读者的阅读!