SwiftUI 图文混排深度剖析:超越 AttributedString —— MarkdownView 与 RichText 的实现

发表于

几年前,当 LiYanan 坐了两个半小时地铁出现在我酒店楼下时,看着眼前这个稚气未脱的大男孩,我很难将他与那些成熟的技术方案联系起来。但事实证明,确实是“英雄出少年”。

作为 MarkdownViewRichText 的开发者,他为 SwiftUI 社区做出了重要贡献:MarkdownView 解决了 SwiftUI 缺乏完整 Markdown 支持的难题,凭借其出色的自定义能力,被 X/GrokHugging 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

custom-style-environment-value-runtime-issue

custom-style-environment-value-correct-approach

对于某些内容可能需要更深度的定制,需要额外的 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 这时候有概率会触发崩溃

text
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”

preference-key-value-must-be-sendable

既然不能用 @MainActor,如何解决?

  • 利用“锁”,手动让 Value 变成 @unchecked Sendable
  • 这一步一定要小心,在做好之后尽量用多个 AI 辅助检查一下是不是真的线程安全了

这里我也贴出相关的 issues 供大家参考,如果感兴趣具体实现,可以参考里面找到对应的 PR:

失败的尝试 – MarkdownText

markdown-text

在 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 renderer to 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

Swift
@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 作为参数传入,这样就能支持老版本了

Swift
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?

答案可能是:

  • 使用 ForEach Subview API
  • 如果需要兼容老版本系统可以使用 _VariadicView_MultiViewRoot API

但是这些 API 都是在 body 求值的时候才能拿到对应的 id

即使我能知道这个 ID 已经存在,通过 if-else 来控制,但是引入 if-else 会增加视图的整体复杂度

最直接的方式就是能否直接通过 View 本身拿到 id

好在 OpenSwiftUI 中有对应的 SwiftUI.View.id(_:) 的具体实现方案

Swift
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 了

Swift
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
}
Swift
/* 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 做一个整合,以提供更好的文本选择体验

敬请期待!

About Author

LiYananMarkdownViewRichTextApertureSFSymbolKit 等多个开源库的作者。热衷探索新技术,专注于提升 SwiftUI 开发体验、降低开发门槛,并坚持「Dirty your hands is the best way to explore」的实践精神。

目前为大四在校学生,正在积极寻找工作机会。如果你有合适的岗位欢迎联系!

每周精选 Swift 与 SwiftUI 精华!