几年前,当 LiYanan 坐了两个半小时地铁出现在我酒店楼下时,看着眼前这个稚气未脱的大男孩,我很难将他与那些成熟的技术方案联系起来。但事实证明,确实是“英雄出少年”。
作为 MarkdownView 和 RichText 的开发者,他为 SwiftUI 社区做出了重要贡献:MarkdownView 解决了 SwiftUI 缺乏完整 Markdown 支持的难题,凭借其出色的自定义能力,被 X/Grok和 Hugging Face Chat 等重量级产品所采用;RichText 则填补了 SwiftUI 在精准图文混排领域的空白,显著改善了 iOS 端文本选择的交互体验。
如果你在开发中正受困于文本版式的限制,这两个库或许就是你寻找的答案。我邀请 LiYanan 撰写了本文,分享了他开发背后的心路历程与技术攻坚细节。
文中涉及的部分技术细节或许颇为硬核,但这恰恰是当前互联网上所匮乏的信息,也是我邀请他通过本文对项目历程进行深度梳理的初衷——为他自己,也为社区,留下一份真正“不一样”的内容。
前言
时间追溯到 2022 年 7 月,刚高考完的我在家里闲的无聊,一直想做一个笔记应用,幻想着能够在大学里会拿这一台电脑或者iPad 在课堂里用自己的 app 做笔记。
当时我的思路是:markdown 和 PencilKit 混合 – 我希望我的 app 里能够同时支持两种不同的形式
其中 PencilKit 的模式比较固定,基本就是调用系统里给的 API 去做就好。
而 markdown 就需要有一个能实时预览的渲染组件,当时 SwiftUI.Text 已经支持了部分 markdown 的标记,包括
- 加粗、斜体、删除线
- 链接
- 等宽字符 / 内联代码块
对我来说,我希望能够支持所有 markdown 渲染,包括但也不限于:
- 图片能够异步加载
- 表格
- 引用
- 独立的代码块
与此同时,我还希望:
- 样式可以自定义
- 最好还能支持文本的选中,方便复制粘贴
在 GitHub 上找了几个方案,但是貌似都不是很满意。
好巧不巧,apple 当年刚好发了一个 package 叫 swift-markdown,然后就开始捣鼓 MarkdownView 了。
文本 / 视图混排
先来介绍 MarkdownView 的基本工作原理:
-
MarkdownView 借助 swift-markdown 来解析 markdown 节点树
-
针对每一个节点递归生成对应的小 View
-
最后将他们整合在一起形成最终呈现的内容
因为每一个节点都会得到一个 View,如何做图文混排又是个大问题,如果暴力用 VStack 堆砌,根本就没发看
好在有 Layout Protocol 的出现,能够进一步自定义内部布局方式,我立刻写了一个 Flow Layout,这样就可以一行行的排列视图
到这里我的想法是:拆成尽可能小的块,然后通过 FlowLayout 拼起来,于是尝试了几种方案:
- 按照字符拆开, 但是显然在西文场景下会相当糟糕
- 用 Natural Language Framework 拆成一个个词,能解决问题但是性能相当糟糕
后来在肘子的博客里了解到 SwiftUI.Text 是可以拼接的,于是就有了现在的解决方案:
-
给每一个 View 打上 tag
-
如果相邻的视图都是文本,那就合并
-
如果不是,就沿用 FlowLayout 去排列
-
突然发现没有 AI 的帮助,效率是如此的低。
当年如果有 ChatGPT 这样的工具,直接一搜也许就能直接知道这些(当然,前提是 Apple 有足够好的文档)
至此,多个不同格式的 Text 就能很好的融合在一起, Text 和其他 View 也能很好的排列
文本选择方面:
- 多个连续的 Text 支持连续的选中
- 段落用的是 “\n” 因此也能跨段落选中(但是依然需要前后都是连续的文本)
- 图片因为是异步加载的,因此不支持和文本一同选中
- 遇到块内容(引用块、代码块等)也不支持和前后的 Text 一起选中
SVG、数学公式渲染
MarkdownView 区别于其他方案的一点就是 它支持 SVG 和 数学公式渲染
- SVG 利用 WebKit 内嵌成网页实现(兼容性最佳,但是开销稍大一些)
- 每一个 WebView 都会通过 js 来查询 svg 的大小来确保页面正确布局
- 数学公式采用 colinc86/LaTeXSwiftUI 完成渲染
- 由于 LaTeX 并不是 CommonMark 中的标准 markdown 内容,因此 swift-markdown 无法直接解析数学公式并给出对应的节点
- 在进入 swift-markdown 解析之前需要额外解析数学公式的部分,因此会带来额外的开销
- 该功能需要开发者通过 View Modifier 来显示声明意图来启用
样式自定义 API 设计
MarkdownView 提供一整套 UI 定制 API,你可以定制几乎所有的 markdown 元素的样式
但是你会发现初始化 MarkdownView 只需要一个参数(_ content: String):这是唯一一个必要的参数,其他可选参数都由 View Modifier 来完成修改
在 API 设计之初,我就在思考如何降低开发者的学习成本从而提升开发效率,主要有以下方面:
- 哪些 API 是可以复用、或者能够表达相同意图的?
- SwiftUI 的 API 的设计模式是什么样的?
- 如何给一个 View Modifier 进行命名来提升可读性、可发现性?
对于字体、颜色、色调等:
-
直接通过扩展原生 API 来实现:
-
.font(.largeTitle.width(.expand), for: .h1) -
.foregroundStyle(.red, for: .h1) -
.tint(.yellow, for: .blockQuote)
-
-
保持与 SwiftUI API 相同的命名,同时在参数签名上根据 markdown 的语义进行了扩展
对于块节点(例如:表格、列表、引用、代码块等):
- 提供了对应的 Style Protocol 和 style view modifier 来实现内容样式定制
CodeBlockStyle+.codeBlockStyle(_:)MarkdownTableStyle+.markdownTableStyle- etc.
- 但是由于 SwiftUI 的各种限制,导致无法在自定义视图中使用 SwiftUI 提供的 Property Wrappers 例如
@Environment等,如果有需要,可以创建一个 View 再通过makeBody(configuration:)返回- 这里涉及到
SwiftUI&AttributeGraph内部的实现方式,如果感兴趣可以参考 OpenSwiftUI
- 这里涉及到
对于某些内容可能需要更深度的定制,需要额外的 View Modifiers,例如:
- 单元格的背景层(background)、前景层(overlay)
.markdownTableCellOverlay(_:).markdownTableRowBackgroundStyle(_:in:)
- 列表项缩进:
.markdownListIndent(_:)
几乎所有的 API 都有 markdown 作为前缀来明确表达作用域,这一点可以参考 SwiftUI Accessibility 的 API,这样做的好处是:如果我不知道有哪些 API 可以用,我输入 markdown 我就能从代码补全中看到可用的 API
这里只是提出我的观点和我的思考。
目前版本的 MarkdownView 中的 API 并非完全按照该标准,这是已知问题,后续的版本会按照这个规范来继续完善 API
总结来说:
- 先尝试对已有的 API 进行扩展,确保语义自然
- 再尝试自己创建新的 API
- 每一个 API 都应该有明确的作用域前缀来表达目标对象
- 通过 EnvironmentKey 向内注入值
- 通过 PreferenceKey 向外导出值
PreferenceKey 导致的崩溃
在写这一篇文章的时候收到了一个 issue,我也很想在这里分享一下:PreferenceKey 不要标注 @MainActor
PreferenceKey 不保证在调用 defaultValue 时一定在 MainActor 这时候有概率会触发崩溃
Crashing Thread:
0 libdispatch.dylib 0x000004e80 _dispatch_assert_queue_fail + 116
1 libdispatch.dylib 0x0000376b0 dispatch_assert_queue$V2.cold.1 + 136
2 libdispatch.dylib 0x000004e08 dispatch_assert_queue + 84
3 libswift_Concurrency.dylib 0x000004584 _swift_task_checkIsolatedSwift + 32
4 libswift_Concurrency.dylib 0x0000454a4 swift_task_isCurrentExecutorWithFlagsImpl(swift::SerialExecutorRef, swift::swift_task_is_current_executor_flag) + 356
5 libswift_Concurrency.dylib 0x0000043a0 _checkExpectedExecutor(_filenameStart:_filenameLength:_filenameIsASCII:_line:_executor:) + 56
6 ??? 0x3409917bc _$s12MarkdownView0A34TableCellStyleCollectionPreferenceV7SwiftUI0G3KeyAadEP12defaultValue0L0QzvgZTW
7 SwiftUICore 0x00056484c DynamicPreferenceCombiner.value.getter + 236- 这就是一个很典型的崩溃堆栈:
DynamicPreferenceCombiner尝试获取defaultValue- 然后内部走了
_checkExpectedExecutor - 由于我的
TableCellStyleCollectionPreference标记了@MainActor,和预期的不一致- 但是具体预期哪一个 queue 不得而知了
- 断言失败:
_dispatch_assert_queue_fail-> 程序崩溃
- 使用
@MainActor的原因是:- 使用的
Value并不是Sendable的 - Xcode 会报错 “Static property ‘defaultValue’ is not concurrency-safe”
- 使用的
既然不能用 @MainActor,如何解决?
- 利用“锁”,手动让
Value变成@unchecked Sendable - 这一步一定要小心,在做好之后尽量用多个 AI 辅助检查一下是不是真的线程安全了
这里我也贴出相关的 issues 供大家参考,如果感兴趣具体实现,可以参考里面找到对应的 PR:
- https://github.com/LiYanan2004/MarkdownView/issues/102
- https://github.com/LiYanan2004/MarkdownView/issues/127
失败的尝试 – MarkdownText
在 MarkdownView 2 中,我一直在思考如何提升文本选择的体验并尝试引入 MarkdownText 将所有内容都作为 Text 渲染:
-
AttributedString在合理设置之后理论上是可以支持表格、引用块等 -
代码块也可以变成
NSAttributedString -
对于图片这种异步加载的内容也可以先用占位符来代替后续在更新 Text Storage 来实现显示
但是随之而来的弊端也非常明显,这里列举一些:
- 无法自定义各种组件的 UI
- 代码块不支持直接复制所有代码了
- Block Directive 无法在 MarkdownText 中渲染
要想嵌入自定义 UI,只能用 Canvas 来尝试,因为 Canvas 的 GraphicsContext 可以 resolve Text / Image / View,也许可以找到把他们串在一起的方案
TextRenderer 和 textSelection 相互排斥
看起来 TextRenderer 可以满足这样的需求:
- 本质上 Text 还是 Text,能够自由组合
- 可以自定义文本渲染方式 – 也就是说可以自定义样式
虽然还是无法传入自定义的 View 来渲染 Block Directive,但是如果能够实现以上的两点“理论上”短期内也足够了
但是,
一旦开启了 .textSelection(.enabled),所有的 TextRenderer 都失效了
回看 TextRenderer 的文档:
TextRenderer
A value that can replace the default text view rendering behavior.
SwiftUICore.View.textRenderer(_:)
Returns a new view such that any text views within it will use
rendererto draw themselves.
完全没有提及和 textSelection 之间会存在冲突,所以这里我把它算作一个坑。
MarkdownText 以失败告终,但是探索并未停止。
真正的混排 – RichText
通过 MarkdownText 不难发现,单纯依靠 Text 无法完成对内容样式的精确控制,最理想的实现应该还是需要 View 来参与
于是在前段时间开启了新的探索:RichText
嵌入任意视图
虽然 SwiftUI 目前仍然不支持附件嵌入,但是 NSTextView / UITextView 可以,且这两者都直接支持文本选择
嵌入视图的本质是:
- 大小:让 Text Layout Engine 知道这个视图的大小
- 位置:排版完之后得到这个视图在整个 Text View 中的位置
至于是不是真的要把视图显示在这个 Text View 里其实并没有那么重要
考虑到嵌入的视图是 SwiftUI.View,我用的是 Platform Text View + View overlay 的方案:
- Platform Text View (aka.
NSTextView/UITextView):整体布局 + 显示文本 - View overlay:拿到 Text Layout 后对应的位置信息后显示在对应的位置上
- overlay 的坐标系和 Platform Text View 刚好是保持一致的,因此可以完美贴合
这样,不仅实现了视图内嵌,还顺便支持了跨元素的选中,完美的解决了 MarkdownView 中目前体验上最大的痛点
同时我还发现 SwiftUI 的 .textSelection(.enabled) 在 iOS 上只能全部选中,只有在 macOS 上才支持区域选中
有了 RichText,开发者只需将 Text 改为 TextView 就可以在 iOS / macOS 上都完美支持文本选择了
Font -> Platform Font 字体转换
由于底层文本视图使用的是 NSTextView / UITextView,他们并不能正确识别 SwiftUI.Font
好在从 OS 26 开始,有一套新的 API 可以安全的将 SwiftUI.Font 转换到 CTFont
@Environment(\.fontResolutionContext) var fontResolutionContext
@Environment(\.font) var font
let platformFont = (font ?? .body).resolve(in: fontResolutionContext).ctFont as PlatformFont // NSFont or UIFont但是这套 API 需要 OS 26+ 因此我还增加了另外一套 API 使用 NSFont / UIFont 作为参数传入,这样就能支持老版本了
extension SwiftUI.View {
/// Sets the default font for the text in this view.
///
/// `SwiftUI.Font.Resolved` is only available on OS 26 and later. Starting with OS 26, `SwiftUI.Font` can be automatically resolved into `PlatformText`.
///
/// However, if you are also targeting older system versions and want to offer a consistent experience, use this view modifier to explicitly specify a platform font.
///
/// ```swift
/// TextView("TextView")
/// .font(UIFont.systemFont(ofSize: 28)) // This would work consistently across OS versions
/// ```
@inlinable
public nonisolated func font(_ font: PlatformFont?) -> some View {
modifier(_PlatformFontModifier(font: font))
}
}这里的 API 设计是对 SwiftUI 内置的 font view modifier 做扩展,在代码补全中会被一同展示,帮助开发者发现这个 modifier
这里还有一个小发现:
Font.Context目前貌似只有在Font.resolve时会用到,它需要 iOS 15+, macOS 12+- 但是
Font.Resolved却需要 OS 26+
直觉上,这两个应该是一起做的才对,但是这样的可用性差异也让我觉得有些诧异,也许之后有机会能够 back-deploy 嘛?但是看了看 Xcode 16 里的 swiftinterface 又没有找到 Font.Resolved 的定义
View ID
现在还有一个问题就是:嵌入视图的状态保留
由于每次内容变化都会重新创建所有视图,因此视图会恢复到初始状态,本质上是一次全新的视图生命周期
对于每一个传入的视图,最终会被转换为 InlineTextAttachment,里面存了:
- 对应的 View
- 视图的大小、位置信息
每次内容变化时,所有的 attachments 都会重新创建,这就是导致丢状态的原因。
所以,这里只要把视图和一个 id 关联起来就可以实现状态保持
为了解决这个问题,首先想到了 SwiftUI 的一个 view modifier 叫 id(_:)
接下来的问题是:如何直接获取一个视图的 id?
答案可能是:
- 使用
ForEachSubview API - 如果需要兼容老版本系统可以使用
_VariadicView_MultiViewRootAPI
但是这些 API 都是在 body 求值的时候才能拿到对应的 id
即使我能知道这个 ID 已经存在,通过 if-else 来控制,但是引入 if-else 会增加视图的整体复杂度
最直接的方式就是能否直接通过 View 本身拿到 id
好在 OpenSwiftUI 中有对应的 SwiftUI.View.id(_:) 的具体实现方案
extension View {
/// Binds a view's identity to the given proxy value.
///
/// When the proxy value specified by the `id` parameter changes, the
/// identity of the view — for example, its state — is reset.
@available(OpenSwiftUI_v1_0, *)
@inlinable
nonisolated public func id<ID>(_ id: ID) -> some View where ID: Hashable {
return IDView(self, id: id)
}
}
@available(OpenSwiftUI_v1_0, *)
@usableFromInline
@frozen
package struct IDView<Content, ID>: View where Content: View, ID: Hashable {
@usableFromInline
var content: Content
@usableFromInline
var id: ID
/* ... */
}在 IDView 中就有一个属性:id – 正是我们通过 view modifier 设置的 id
接下来就可以通过 Mirror 逐级寻找 IDView 节点然后找到 id 了
private static func descend(
mirror: Mirror
) -> AnyHashable? {
let type = mirror.subjectType
guard type != Never.self else { return nil }
// Get the module name and the view type hierarchy.
let typeName = String(reflecting: type)
if typeName.starts(with: "SwiftUI.TupleView") {
return nil
}
if typeName.starts(with: "SwiftUI.IDView"),
let id = mirror.descendant("id") as? AnyHashable {
return id
}
for child in mirror.children {
guard let view = child.value as? (any SwiftUI.View) else { continue }
if let found = explicit(view) {
return found
}
}
return nil
}/* id: hw */
VStack {
Text("Hello World")
.id("hw")
}
/* id: nil, VStack wraps a TupleView. */
VStack {
Text("Hello")
.id("hello")
Text("World")
.id("world")
}
/* id: hw */
Text("Hello World")
.id("hw")用这个 ID 就可以在新、旧的 attachment 列表中找到匹配的 View,直接继承之前的状态,从而避免了意外刷新
等价文本替换
至此,View 已经可以被选中,但是一旦复制内容,View 对应的部分会直接消失
因此,需要在拷贝时做一个替换操作
在嵌入视图时,可以使用 InlineView 来将其和一个对应的文本关联起来
通过重写:
-
attributedSubstring(forProposedRange:actualRange:)–NSTextView -
attributedText(in:)–UITextView -
copy(_:)–UITextView
就可以实现这样的需求
至此,富文本混合排列基本上算是摸清了
最近,我也在准备 MarkdownView 3.0 的版本,将 RichText 和 MarkdownView 做一个整合,以提供更好的文本选择体验
敬请期待!