原生 Markdown 渲染 - SwiftUI

2,903 阅读6分钟

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 (有点废话),而以下是一些次要的功能点:

  • 能够对文本进行预加工处理(比如规范空白符)
  • 能够添加一些(开发者期望)自定义的语法
  • (开发者)能够自定义元素对应的视图

实际上完成这几样功能不太简单,而且在编写过程中我会遇到各种各样的问题,比如:

  • 语法之间的相互碰撞,也就是语法优先级的问题
  • 对于列表,列表的项目视图也会通过一个渲染器来渲染二级视图
  • 尝试去简化自定义语法和自定义视图的创建和使用

渲染框架

我在这里摆放一张图来方便解释一下渲染的流程框架:

(图仅供参考,可能存在错误)

markdown render.png

首先最左侧,我们输入一个文本 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()
}

对于每种元素,我们只需继承 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 Markdown<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 ElementView: 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 {
        Markdown(text: markdown, resolver: resolver) { element in
            switch element {
            /* cases */
            default:
                EmptyView()
            }
        }
    }
}

结束语

我本人现在在写这个 SwiftUI 的 Markdown 渲染的项目,不久会发布第一个 SPM 版本,感兴趣的话可以收藏这篇文章,我会在未来把开源项目的链接放在文章中。

最后,感谢读者的阅读!