【MarkdownUI】一行代码渲染富文本——SwiftUI 生态最好用的 Markdown 库

8 阅读9分钟

【MarkdownUI】一行代码渲染富文本——SwiftUI 生态最好用的 Markdown 库

iOS三方库精读 · 第 3 期


一、一句话介绍

MarkdownUI 是一个用于 SwiftUI 的 Markdown 渲染库,它让将 Markdown 字符串渲染为完整、美观的富文本视图变得声明式、可主题化,且完全符合 CommonMark 规范。

属性信息
⭐ GitHub Stars~2.5k
最新版本2.x(当前 2.3+)
LicenseMIT
支持平台iOS 14+ / macOS 11+ / tvOS 14+ / watchOS 7+
Swift 最低版本Swift 5.7+
规范标准CommonMark 0.30

二、为什么选择它

SwiftUI 原生渲染 Markdown 的痛点

SwiftUI 的 Text 从 iOS 15 开始支持部分 Markdown,但能力极其有限:

原生 Text(markdown:)MarkdownUI
仅支持行内样式(粗体/斜体/代码)完整块级语法(标题/列表/表格/引用)
不支持代码块支持带语言标签的多行代码块
不支持表格支持 GFM 扩展表格
不支持引用块(blockquote)支持带左侧竖线的引用块
不支持图片支持远程/本地图片渲染
无主题系统内置 GitHub / docC / 自定义主题
iOS 15+ 才支持iOS 14+ 支持

核心优势:

  • CommonMark 完整支持,行为与 GitHub/Notion 渲染一致,零惊喜
  • 主题系统.gitHub / .docC / .basic,一行切换,也可完全自定义
  • GFM 扩展:表格、删除线、任务列表等 GitHub Flavored Markdown 扩展
  • SwiftUI-native:纯 SwiftUI 渲染,不依赖 WebView,性能好
  • 可点击链接,文档类场景开箱即用

三、核心功能速览

基础层(新手必读)

集成方式(SPM 推荐)

在 Xcode → File → Add Package Dependencies 中输入:

https://github.com/gonzalezreal/swift-markdown-ui

或在 Package.swift 中:

.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0")

最简单的用法——三行搞定

import SwiftUI
import MarkdownUI

struct ContentView: View {
    var body: some View {
        Markdown("# Hello **MarkdownUI**\n\n这是一段 *斜体* 文本。")
    }
}

切换内置主题

// GitHub 风格(最常用)
Markdown(content).markdownTheme(.gitHub)

// 苹果文档风格
Markdown(content).markdownTheme(.docC)

进阶层(最佳实践)

引用块(Blockquote)——自研渲染器缺失的功能

let quoteContent = """
> ⚠️ **注意**:`atomic` 不等于线程安全。
> 只保证单次读写的原子性,多线程写入仍需加锁。
"""

Markdown(quoteContent).markdownTheme(.gitHub)
// 渲染结果:左侧灰色竖线 + 缩进内容

代码块带语言标签

let codeContent = """
```swift
let value = try await AF
    .request(url)
    .serializingDecodable(Model.self)
    .value

""" Markdown(codeContent).markdownTheme(.gitHub) // 渲染结果:等宽字体 + 灰色背景 + 右上角语言标签


**GFM 表格**

```swift
let tableContent = """
| 修饰符 | 引用计数 | 场景 |
|--------|----------|------|
| `strong` | +1 | 普通对象 |
| `weak` | 不变 | delegate |
| `copy` | +1(副本) | NSString |
"""

Markdown(tableContent).markdownTheme(.gitHub)

自定义主题——局部修改样式

// 只修改代码块背景色,继承 GitHub 其他样式
Markdown(content)
    .markdownTheme(
        .gitHub.text {
            ForegroundColor(.primary)
            BackgroundColor(Color(.systemBackground))
        }
        .codeBlock { label, content in
            // 完全自定义代码块视图
            VStack(alignment: .leading) {
                if let label {
                    Text(label).font(.caption).foregroundStyle(.secondary)
                }
                Text(content).font(.system(.body, design: .monospaced))
            }
            .padding()
            .background(Color(.systemGray6))
            .cornerRadius(8)
        }
    )

接收链接点击事件

Markdown(content)
    .markdownTheme(.gitHub)
    .environment(\.openURL, OpenURLAction { url in
        // 自定义处理链接跳转,例如打开应用内浏览器
        print("点击链接:\(url)")
        return .handled
    })

深入层(源码视角)

MarkdownUI 的核心由三层构成:

层级职责关键类型
ParserCommonMark 解析,输出 Document ASTDocument(基于 cmark-gfm C 库)
Theme样式声明,定义各 Block/Inline 的渲染规则ThemeBlockStyleInlineStyle
Renderer将 AST + Theme 转换为 SwiftUI View 树MarkdownBody(内部实现)

解析器底层使用 cmark-gfm(GitHub 官方维护的 CommonMark C 实现),解析速度远超 Swift 正则方案,这也是自研渲染器在复杂文档场景下的性能天花板所在。


四、实战演示

场景:iOS 知识点学习 App,展示带引用、代码、表格的 OC 知识文档页

import SwiftUI
import MarkdownUI

struct OCKnowledgeDetailView: View {

    private let content = """
    # OC 属性修饰符

    ## 内存管理修饰符

    | 修饰符 | 说明 | 使用场景 |
    |--------|------|---------|
    | `strong` | 强引用,引用计数 +1 | 普通对象 |
    | `weak` | 弱引用,释放后自动置 nil | delegate |
    | `copy` | 创建不可变副本 | NSString / Block |
    | `assign` | 直接赋值,不改计数 | 基本数据类型 |

    ## 示例代码

    ```objc
    @interface Person : NSObject
    @property (nonatomic, copy)   NSString *name;
    @property (nonatomic, weak)   id<PersonDelegate> delegate;
    @property (nonatomic, assign) NSInteger age;
    @end
    ```

    ## 重要注意事项

    > ⚠️ **atomic 不等于线程安全**
    >
    > `atomic` 只保证 setter/getter 的读写原子性,
    > 多线程并发写入仍需配合锁或串行队列。
    """

    var body: some View {
        ScrollView {
            Markdown(content)
                .markdownTheme(.gitHub)
                .padding()
        }
        .navigationTitle("OC 属性修饰符")
        .navigationBarTitleDisplayMode(.large)
        .background(Color(.systemGroupedBackground))
    }
}

运行效果:表格自动对齐、代码块带 objc 标签、引用块带左侧竖线——这些都是原生 Text 和简单自研渲染器无法覆盖的。


五、源码亮点

进阶层:值得借鉴的用法

EnvironmentValues 驱动的主题系统

MarkdownUI 的主题完全通过 SwiftUI Environment 传递,这意味着你可以在任意层级覆盖样式:

// 全局设置
WindowGroup {
    ContentView()
        .markdownTheme(.gitHub)
}

// 局部覆盖(子视图会继承,不影响兄弟节点)
VStack {
    Markdown(importantContent)
        .markdownTheme(.docC)  // 只有这一块用 docC 风格

    Markdown(normalContent)
    // 这里仍然使用外层的 .gitHub
}

深入层:设计思想解析

1. AST + Visitor 模式

解析结果是一棵 Document AST,渲染时用 Visitor 遍历节点,每种节点(Paragraph、Heading、CodeBlock 等)对应 Theme 中的一个渲染闭包。这使得"增加新语法节点"和"修改渲染样式"完全解耦。

2. Protocol-Oriented Theme 定制

Theme 是一个值类型(struct),通过 builder 模式链式修改。每次 .text { ... } 返回新的 Theme 副本,天然线程安全,也符合 SwiftUI 的不可变数据流思想。


六、踩坑记录

问题 1:内容过长时列表页卡顿

问题:在 List 中直接嵌套 Markdown(...) 导致滚动掉帧。
原因Markdown 视图在布局时会解析整个文档,重复计算开销大。
解决:将长内容页面改为独立 NavigationLink 目标页,或对渲染结果做 @State 缓存。

// ❌ 不推荐:在 List Cell 中直接渲染长文档
List(items) { item in
    Markdown(item.longContent)  // 每次滚动都重新解析
}

// ✅ 推荐:只在详情页渲染
List(items) { item in
    NavigationLink(item.title) {
        ScrollView { Markdown(item.longContent).padding() }
    }
}

问题 2:SPM 拉取失败 / 版本冲突

问题xcodebuild 时报 package graph is inconsistent
原因Package.resolved 中残留了旧版本或冲突条目。
解决:删除 *.xcworkspace/xcshareddata/swiftpm/Package.resolved,重新 resolve。

rm HelloWorld.xcworkspace/xcshareddata/swiftpm/Package.resolved
# 重新打开 Xcode,File → Packages → Resolve Package Versions

问题 3:@dynamic 属性在 Markdown 表格中的反引号转义

问题:Markdown 内容含 @dynamic,渲染时反引号内 @ 符号被吞掉。
原因:Swift 字符串多行字面量中,\ 需要额外转义。
解决:使用原始字符串字面量 #"""..."""# 或对 @ 做 HTML 实体转义(&#64;)。

// ✅ 使用原始字符串字面量,无需额外转义
private let content = #"""
`@dynamic` 告诉编译器 setter/getter 由运行时动态提供。
"""#

问题 4:深色模式下代码块颜色不对

问题:代码块在 Dark Mode 下背景色与系统不匹配。
原因.gitHub 主题的代码块背景是静态颜色,不随系统变化。
解决:自定义 .codeBlock 渲染,使用 Color(.systemGray6) 等语义颜色。

Markdown(content)
    .markdownTheme(
        .gitHub.codeBlock { _, code in
            Text(code)
                .font(.system(.footnote, design: .monospaced))
                .padding(12)
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(Color(.systemGray6))  // 语义颜色,深色模式自动适配
                .cornerRadius(8)
        }
    )

问题 5:引用块嵌套层数过多导致缩进异常

问题:三层及以上嵌套引用(>>> text)在某些主题下缩进溢出屏幕。
原因.gitHub 主题对嵌套引用没有限制最大缩进。
解决:业务上限制引用嵌套层数,或自定义 .blockquote 渲染覆盖缩进逻辑。


七、延伸思考

与同类方案的横向对比

方案功能完整度依赖定制自由度适用场景
MarkdownUI★★★★★ CommonMark 完整SPM 外部库★★★★ 主题系统内容丰富型文档页
自研 MarkdownRenderer★★★ 常用子集零依赖★★★★★ 完全自由简单内容/极致定制
SwiftUI Text★★ 仅行内样式系统内置★ 几乎不可定制短段落标注
WKWebView + CSS★★★★★系统内置★★★★★复杂排版/已有 HTML

推荐使用 MarkdownUI 的场景

  • 文档/帮助中心:内容包含引用块、代码块、链接、图片等完整语法
  • 笔记类 App:用户可编辑 Markdown,需要实时预览
  • 技术学习 App:知识点页面包含代码示例和对比表格
  • 从 Web 迁移:后端返回 Markdown 字符串,需要 iOS 端直接渲染

不推荐使用 MarkdownUI 的场景

  • 极度轻量的 SDK:对包体积(+500KB+)极度敏感
  • 高度定制的列表 Cell:只需要标题/加粗等基础行内样式,用 Text 足够
  • 内容格式固定且简单:自研渲染器完全覆盖,无需引入依赖

MarkdownUI vs 自研 MarkdownRenderer 详细对比

本项目中同时维护了两套方案,以下是实测差异:

特性MarkdownUI自研 MarkdownRenderer
引用块 >✅ 左侧竖线 + 缩进❌ 原样输出文本
代码块✅ 等宽 + 语言标签✅ 等宽 + 语言标签
表格
可点击链接
图片
嵌套列表
有序列表编号⚠️ 只有 ,忽略序号
主题.gitHub / .docC / 自定义完全自定义 SwiftUI View
包体积+500KB+0
iOS 最低版本iOS 14+iOS 14+(使用了 AttributedString)

八、参考资源


九、本期互动


小作业

MarkdownUI 支持完全自定义 .codeBlock 渲染闭包。请尝试实现一个带"复制"按钮的代码块组件:点击按钮将代码内容复制到剪贴板,并短暂显示"已复制 ✓"反馈。

完成标准:

  1. 代码块右上角有"复制"按钮
  2. 点击后 1.5 秒内显示"已复制 ✓"提示
  3. 通过 .markdownTheme.codeBlock 闭包注入,不修改 MarkdownUI 源码
// 启动代码
Markdown(content)
    .markdownTheme(
        .gitHub.codeBlock { label, code in
            // 在这里实现你的带复制按钮的代码块 👇
        }
    )

思考题

MarkdownUI 底层使用了 C 语言实现的 cmark-gfm 解析器,而本项目的自研 MarkdownRenderer 用 Swift 正则 + 行扫描实现解析。

如果你来设计"下一代自研 Markdown 渲染器",会如何在保持零依赖的同时支持引用块和嵌套列表?你的状态机设计是什么样的?


读者征集

下一期选题征集中——你最想深入了解哪个 iOS 三方库?欢迎评论区留言,候选:KingfisherRxSwiftSnapKitLottieGRDB

你在使用 MarkdownUI 时踩过哪些坑?优质回答会收录进下一期《踩坑记录》。


📅 本系列持续更新 ✅ 第 1 期:Alamofire · ✅ 第 2 期:Kingfisher · ➡️第 3 期:MarkdownUI(本期)