1. 文档查看器项目整体设计与思路拆解
1.1 为什么选择 WPF 的 FlowDocument 作为富文本渲染核心
做文档查看器这件事,我前前后后折腾过好几套方案。最早用 WinForm 的 RichTextBox,功能太薄,样式控制基本靠 RTF 硬编码,稍微复杂一点的排版就崩。后来试过把 HTML 塞进 WebBrowser 控件里渲染,效果倒是能看,但交互延迟高、内存占用大,而且和主程序的通信全靠脚本注入,维护起来非常痛苦。最终落到 WPF 的 FlowDocument 上,是因为它在“结构化文档”这个定位上几乎是最优解。
FlowDocument 的本质是一棵由 Block、Inline、Run、Paragraph、Table、List 等元素组成的文档树。它和 HTML 的 DOM 树思路类似,但它是原生 .NET 对象,可以直接绑定数据、响应事件、参与布局系统。这意味着你不需要在两种技术栈之间来回翻译,文档内容本身就是 WPF 可视化树的一部分。
从项目标题“文档查看器:显示富文本”来看,核心诉求其实就三件事:第一,能把服务端下发的富文本内容正确渲染出来;第二,渲染结果要支持滚动、缩放、选择、复制这些基础阅读操作;第三,要能安全地处理外部输入,防止恶意内容破坏界面或引发安全问题。FlowDocument 配合 FlowDocumentScrollViewer 或 FlowDocumentReader,前两件事基本是开箱即用,第三件事则需要我们自己加一层清洗逻辑。
1.2 整体架构分层与数据流向
这个项目的架构我采用的是经典的三层拆分,但针对富文本场景做了专门调整。
最底层是内容解析层。服务端返回的富文本通常不是 XAML,而是 HTML 片段或者自定义的 JSON 结构。我选择在客户端做一次转换,把 HTML 解析成中间表示(IR),再由 IR 生成 FlowDocument。为什么不直接让服务端返回 XAML?因为 XAML 是 .NET 特有的,服务端如果是 Java 或 Node.js 技术栈,生成 XAML 会很别扭,而且 XAML 本身携带类型信息,直接反序列化存在安全风险。
中间层是文档构建层。这一层负责把 IR 映射成 FlowDocument 的元素树。每个 IR 节点对应一个构建函数,比如paragraph节点生成Paragraph,bold节点生成Bold,table节点生成Table。这一层还要处理样式继承,比如父级段落设置了字号,子级的 Run 如果没有显式指定,就应该继承下来。
最上层是视图交互层。用FlowDocumentScrollViewer承载文档,外面套一个DockPanel放工具栏。工具栏上有缩放滑块、页码跳转、查找框。如果文档特别长,我会换成FlowDocumentReader,它自带分页和视图模式切换,省去自己实现虚拟化的麻烦。
数据流向很清晰:服务端 JSON → 反序列化 → IR 树 → FlowDocument → Viewer 渲染。每一层之间都是纯数据传递,没有循环依赖,方便单独测试。
1.3 安全清洗为什么必须放在服务端和客户端两侧
热搜词里提到了“增加 csp(script-src 'self')与对富文本字段的服务端白名单清洗”,这个点非常关键。很多人做富文本查看器只关注渲染效果,忽略了安全,结果上线后被注入攻击。
富文本的本质是“带格式的文本”,但格式本身就可能携带可执行内容。比如 HTML 里的<script>标签、onclick属性、javascript:协议的链接。如果服务端直接把用户提交的富文本原样存库,客户端再原样渲染,那就等于把 XSS 漏洞直接搬到了桌面端。
我的做法是双层清洗。服务端在入库前做一次白名单过滤,只允许特定的标签和属性通过,比如<p>、<strong>、<em>、<ul>、<li>、<a href="http/https">。所有不在白名单里的标签直接剥离,属性只保留href、src、alt、title这几个。客户端在渲染前再做一次校验,因为服务端可能被绕过,或者历史数据里存在脏内容。客户端校验的重点是:FlowDocument 构建时绝对不执行任何动态代码,所有内容都当作纯文本处理,链接点击时先校验协议再决定是否打开。
CSP 那一层主要是针对如果查看器内部嵌了 WebView 的情况。script-src 'self'意味着只允许加载同源脚本,内联脚本一律拒绝。虽然纯 WPF 方案用不到 CSP,但如果你的文档里嵌了 HTML 预览组件,这层防护就不能省。
2. 核心细节解析与实操要点
2.1 FlowDocument 元素树与 HTML 标签的映射关系
要把 HTML 转成 FlowDocument,首先得建立一张映射表。这张表不是随便定的,而是根据 FlowDocument 的元素能力来匹配。
| HTML 标签 | FlowDocument 元素 | 说明 |
|---|---|---|
<p> | Paragraph | 最基本的块级元素 |
<h1>~<h6> | Paragraph+FontSize | FlowDocument 没有标题元素,用段落加字号模拟 |
<strong>/<b> | Bold | 内联元素,包裹 Run |
<em>/<i> | Italic | 同上 |
<u> | Underline | 同上 |
<ul>/<ol> | List+ListItem | 有序无序通过MarkerStyle区分 |
<table> | Table | 需要手动构建 RowGroup、Row、Cell |
<a> | Hyperlink | 设置NavigateUri,但点击事件要自己接管 |
<br> | LineBreak | 内联换行 |
<img> | Image+InlineUIContainer | 图片在 FlowDocument 里是 UI 元素,需要容器包裹 |
这张表里最容易出问题的是表格和图片。FlowDocument 的 Table 要求你先建TableRowGroup,再往里加TableRow,每行再加TableCell,层级比 HTML 深一层。图片则必须用InlineUIContainer包住Image控件,否则没法嵌入到段落流里。
还有一个坑是嵌套列表。HTML 里<ul>可以无限嵌套,FlowDocument 的 List 也支持,但ListItem的Blocks属性里要放一个List才能形成子列表。我见过有人直接把子 List 加到父 List 的 Blocks 里,结果渲染出来层级错乱。
2.2 样式继承与字体回退策略
富文本的样式系统比纯文本复杂得多。一个 Run 的最终显示效果,取决于它自身设置的属性、父级 Paragraph 的属性、以及文档级别的默认样式。WPF 的属性系统天然支持这种继承,但前提是你得用对依赖属性。
我的做法是:在构建 IR 树的时候,就把样式计算好,而不是依赖 WPF 的继承机制。每个 IR 节点都带一个StyleContext,记录当前生效的字体族、字号、颜色、粗细、斜体。子节点继承父节点的 StyleContext,如果有自己的样式就覆盖。这样构建 FlowDocument 的时候,每个 Run 都拿到最终计算好的值,不需要 WPF 再做一次继承查找。
字体回退是另一个容易被忽略的点。中文文档里经常混着英文和数字,如果统一用宋体,英文会很难看;统一用 Arial,中文又会变成方框。我的策略是设置一个字体族列表:"Microsoft YaHei UI, Segoe UI, Arial"。WPF 会按顺序查找,第一个能显示当前字符的字体就会被使用。实测下来,这个组合在中英文混排场景下表现很稳。
注意:FlowDocument 的
FontFamily属性支持逗号分隔的字体族列表,但顺序很重要。把中文字体放在前面,英文字体放在后面,这样中文优先用中文字体,英文自动回退到英文字体。
2.3 大文档的虚拟化与性能优化
文档查看器最怕的就是内容太长。我测试过一个 500 页的文档,如果一次性构建完整的 FlowDocument,内存直接飙到 800MB,滚动的时候卡成幻灯片。
解决思路是分块加载 + 虚拟化。具体做法是:把文档按章节切成多个块,每个块对应一个 FlowDocument。Viewer 只加载当前可见的块和前后各一个缓冲块,滚动到边界时动态替换。这样内存占用能控制在 100MB 以内,滚动也很流畅。
FlowDocumentScrollViewer 本身支持IsOptimalParagraphEnabled和IsHyphenationEnabled,这两个属性对长文档的排版性能有影响。IsOptimalParagraphEnabled会让 WPF 用更复杂的算法来断行,效果更好但更耗 CPU。我的建议是:文档短的时候开着,文档长的时候关掉,用IsHyphenationEnabled来补偿断行质量。
还有一个细节是PagePadding。默认值会在文档四周留白,如果文档本身已经有边距,就会显得很空。我一般把它设成0,然后在 Paragraph 的Margin里控制间距。
3. 实操过程与核心环节实现
3.1 从 HTML 到 FlowDocument 的完整转换流程
假设服务端返回的是这样一段 HTML:
<p>这是一段<strong>加粗</strong>的文字,包含一个<a href="https://example.com">链接</a>。</p> <ul> <li>第一项</li> <li>第二项</li> </ul>我的转换流程分四步。
第一步,用 HTML 解析器把字符串变成 DOM 树。我选的是 AngleSharp,因为它对不规范 HTML 的容错性好,而且 API 设计得很干净。解析完成后得到一个IDocument对象。
第二步,遍历 DOM 树,生成 IR 节点。每个 IR 节点是一个简单的 POCO 类,包含Type、Text、Children、Attributes四个字段。遍历的时候同时做白名单过滤,不在白名单里的标签直接跳过,但保留其子节点。
第三步,把 IR 树转成 FlowDocument。这一步是核心,我写了一个FlowDocumentBuilder类,每个 IR 类型对应一个Build方法。构建的时候从根节点开始递归,遇到块级元素就创建Block,遇到内联元素就创建Inline,遇到文本就创建Run。
第四步,把构建好的 FlowDocument 赋给 Viewer 的Document属性。如果文档很大,这一步会触发一次完整的布局计算,所以我会在赋值前把 Viewer 的Visibility设为Collapsed,赋值后再恢复,避免用户看到中间状态。
public FlowDocument Build(IEnumerable<IRNode> nodes) { var doc = new FlowDocument(); foreach (var node in nodes) { var block = BuildBlock(node); if (block != null) doc.Blocks.Add(block); } return doc; } private Block BuildBlock(IRNode node) { switch (node.Type) { case "paragraph": var p = new Paragraph(); foreach (var child in node.Children) p.Inlines.Add(BuildInline(child)); return p; case "list": var list = new List(); foreach (var child in node.Children) list.ListItems.Add(BuildListItem(child)); return list; default: return null; } }3.2 表格渲染的参数计算与对齐处理
FlowDocument 的表格渲染比 HTML 麻烦,因为列宽需要手动计算。HTML 里可以用百分比,FlowDocument 的TableColumn只接受GridLength,可以是绝对值、自动或星号比例。
我的做法是:先统计表格的列数,然后给每列分配一个星号宽度。如果 HTML 里指定了列宽百分比,就按比例换算成星号值。比如三列分别是 20%、30%、50%,我就设成20*、30*、50*。这样表格会占满可用宽度,而且列宽比例正确。
单元格对齐需要同时设置TextAlignment和Block.TextAlignment。TableCell本身没有对齐属性,得在它包含的Paragraph上设。垂直对齐用TableCell.VerticalAlignment,这个属性是有的。
边框是另一个坑。FlowDocument 的 Table 没有内置边框,得自己给每个 Cell 加Border。我写了一个辅助方法,根据表格的边框设置,给每个 Cell 的四个边分别加 Border,同时处理相邻 Cell 的边框重叠问题。
private void ApplyBorders(TableCell cell, TableBorders borders) { var border = new Border { BorderBrush = borders.Brush, BorderThickness = new Thickness( borders.Left, borders.Top, borders.Right, borders.Bottom) }; border.Child = new Paragraph(new Run(cell.Text)); cell.Blocks.Add(new BlockUIContainer(border)); }提示:
BlockUIContainer可以把任意 UI 元素塞进 FlowDocument,但它的布局行为和普通 Block 不同,不会参与文本流的分页。如果表格需要跨页,最好还是用原生 Table 元素,不要用 BlockUIContainer 包 Border。
3.3 超链接的点击拦截与安全校验
FlowDocument 里的 Hyperlink 默认会在点击时打开系统浏览器。这个行为在查看器里通常不合适,因为用户可能只是想复制链接,或者链接本身是恶意的。
我的做法是拦截Hyperlink.RequestNavigate事件,在里面做三件事:第一,校验 URI 的协议,只允许http和https,其他一律拒绝;第二,弹出一个确认框,显示完整 URL,让用户决定是否打开;第三,如果用户确认,用Process.Start打开,但要用UseShellExecute = true,并且把 URL 包在引号里,防止命令注入。
private void OnHyperlinkNavigate(object sender, RequestNavigateEventArgs e) { var uri = e.Uri; if (uri.Scheme != "http" && uri.Scheme != "https") { MessageBox.Show("不支持的链接协议"); e.Handled = true; return; } var result = MessageBox.Show( $"是否打开链接?\n{uri.AbsoluteUri}", "确认", MessageBoxButton.YesNo); if (result == MessageBoxResult.Yes) { Process.Start(new ProcessStartInfo { FileName = uri.AbsoluteUri, UseShellExecute = true }); } e.Handled = true; }这里有个细节:RequestNavigate事件是路由事件,如果不设e.Handled = true,WPF 还会继续执行默认行为。所以无论是否打开链接,最后都要把 Handled 设为 true。
3.4 查找与高亮功能的实现
文档查看器如果没有查找功能,基本没法用。WPF 的 FlowDocument 没有内置查找,得自己实现。
我的思路是:遍历文档的所有Run,用正则匹配关键词,把匹配到的文本拆成多个 Run,给匹配部分加背景色。查找的时候从当前光标位置往后找,找到后滚动到可见区域。
遍历 Run 的时候要注意,Run的Text属性是只读的,不能直接改。要替换的话,得在父级InlineCollection里操作。我写了一个FindAndHighlight方法,接收一个FlowDocument和关键词,返回匹配数量。
public int FindAndHighlight(FlowDocument doc, string keyword) { var count = 0; var runs = doc.Descendants<Run>().ToList(); foreach (var run in runs) { var text = run.Text; var index = text.IndexOf(keyword, StringComparison.OrdinalIgnoreCase); if (index < 0) continue; var parent = run.Parent as InlineCollection; if (parent == null) continue; var before = new Run(text.Substring(0, index)); var match = new Run(text.Substring(index, keyword.Length)) { Background = Brushes.Yellow }; var after = new Run(text.Substring(index + keyword.Length)); parent.InsertAfter(run, before); parent.InsertAfter(before, match); parent.InsertAfter(match, after); parent.Remove(run); count++; } return count; }滚动到匹配位置用run.BringIntoView(),但这个方法在虚拟化场景下可能不生效,因为目标 Run 可能还没被创建。我的处理是:先确保目标块已加载,再调用 BringIntoView。
4. 常见问题与排查技巧实录
4.1 富文本渲染错位的典型原因与修复
渲染错位是最高频的问题,我遇到过好几种不同的表现。
第一种是段落间距不对。HTML 的<p>默认有上下边距,FlowDocument 的Paragraph默认也有,但两者的默认值不一样。如果不显式设置,转换后的文档会比原 HTML 松散很多。我的做法是统一把Paragraph.Margin设成new Thickness(0, 0, 0, 8),只保留底部间距。
第二种是列表缩进异常。FlowDocument 的List默认有Margin和Padding,ListItem也有。如果 HTML 里列表本身有缩进,转换后会叠加,导致缩进过深。解决方法是把List.Margin和List.Padding都设成 0,只保留ListItem的Margin。
第三种是表格宽度溢出。如果表格的星号列宽总和超过可用宽度,WPF 会按比例压缩,但如果某个单元格内容太长且不能换行,就会撑破表格。这时候要给单元格的Paragraph设置TextWrapping = TextWrapping.Wrap,并确保Table.Columns的宽度总和不超过 100*。
| 问题表现 | 可能原因 | 修复方法 |
|---|---|---|
| 段落间距过大 | Paragraph 默认 Margin 叠加 | 显式设置 Margin |
| 列表缩进过深 | List 和 ListItem 的 Padding 叠加 | 清零 List 的 Padding |
| 表格撑破容器 | 单元格内容不换行 | 设置 TextWrapping |
| 图片显示为空白 | 图片加载失败或路径错误 | 检查 URI 和缓存策略 |
| 中文显示为方框 | 字体族不含中文字体 | 设置字体回退列表 |
4.2 内存泄漏的排查与解决
WPF 的内存泄漏是出了名的难查。我遇到过一次:文档查看器打开十几个文档后,内存涨到 2GB 不释放。
用 dotMemory 抓快照后发现,FlowDocument 对象没有被回收。原因是FlowDocumentScrollViewer的Document属性虽然换了新值,但旧文档还被事件处理器引用着。具体来说,我在每个 Hyperlink 上挂了RequestNavigate事件,事件处理器是实例方法,隐式持有this引用,而this又持有 Viewer 的引用,形成了一条从旧文档到 Viewer 的引用链。
解决办法有两个:一是用弱事件模式,把事件处理器改成静态方法加 WeakReference;二是在切换文档时,手动遍历旧文档的所有 Hyperlink,解除事件绑定。我选了第二种,因为更直观,性能也更好。
private void DetachHyperlinks(FlowDocument doc) { foreach (var link in doc.Descendants<Hyperlink>()) { link.RequestNavigate -= OnHyperlinkNavigate; } }注意:
Descendants<T>()是自定义的扩展方法,用LogicalTreeHelper或VisualTreeHelper递归遍历。FlowDocument 的元素树不是可视化树,得用LogicalTreeHelper。
4.3 跨平台移植的可行性评估
热搜词里出现了“wpf 跨平台”和“复杂wpf程序 linux移植”,说明很多人关心这个话题。我的判断是:纯 WPF 应用目前没有官方跨平台方案。WPF 依赖 DirectX 和 Windows 图形栈,Linux 和 macOS 上跑不了。
如果确实需要跨平台,有两条路。第一条是换框架,用 Avalonia 或 UNO Platform,它们支持 XAML 语法,FlowDocument 的替代品是TextBlock加Inlines,但功能比 FlowDocument 弱不少,表格和分页要自己实现。第二条是保留 WPF 做 Windows 客户端,另外用 Web 技术做跨平台版本,服务端统一提供富文本数据,两端各自渲染。
我的建议是:如果项目一开始就明确要跨平台,直接选 Avalonia,别用 WPF。如果是已有 WPF 项目要移植,评估工作量时要把 FlowDocument 的替换成本算进去,这部分通常占总工作量的 30% 以上。
4.4 富文本编辑与查看的边界处理
查看器和编辑器是两种不同的产品形态,但经常被混在一起。查看器的核心是“只读渲染”,编辑器的核心是“可编辑 + 撤销重做 + 光标管理”。WPF 的RichTextBox可以当编辑器用,但它的编辑体验和现代富文本编辑器差距很大。
我的做法是:查看器用FlowDocumentScrollViewer,编辑器用RichTextBox,两者共享同一套 IR 转换逻辑。查看器渲染时把 IR 转成 FlowDocument,编辑器加载时也把 IR 转成 FlowDocument,但额外挂上编辑相关的事件。保存时把 FlowDocument 转回 IR,再序列化成 JSON 发给服务端。
这样拆分的好处是职责清晰,查看器不需要关心编辑逻辑,编辑器也不需要关心只读渲染的优化。缺点是两套 UI 控件的样式要分别维护,但比起混在一起带来的复杂度,这点成本是值得的。
4.5 常见问题速查表
| 问题 | 排查方向 | 解决方案 |
|---|---|---|
| 文档加载后空白 | 检查 Document 是否为 null | 确保 Build 方法返回了有效文档 |
| 滚动卡顿 | 文档是否过大 | 启用分块加载和虚拟化 |
| 链接点击无反应 | 事件是否被拦截 | 检查 RequestNavigate 绑定 |
| 图片不显示 | URI 是否可访问 | 用绝对路径或 Base64 内嵌 |
| 复制文本带格式 | 是否用了 RichText | 用 TextRange.Save 控制格式 |
| 查找高亮不消失 | 旧高亮未清除 | 查找前先重置所有 Run 的背景 |
| 表格跨页断裂 | Table 不支持自动分页 | 拆成多个小表格或改用列表 |
| 字体模糊 | 是否开启了 ClearType | 设置 TextOptions.TextFormattingMode |
5. 富文本安全清洗的落地细节
5.1 服务端白名单清洗的具体规则
服务端清洗我用的是一套基于正则和 DOM 解析结合的方案。先用 DOM 解析器把 HTML 变成树,然后递归遍历,只保留白名单内的标签和属性。
白名单标签列表:p、br、strong、b、em、i、u、ul、ol、li、a、img、table、thead、tbody、tr、td、th、h1~h6、blockquote、code、pre。
白名单属性:href(仅 http/https)、src(仅 http/https 或 data:image)、alt、title、colspan、rowspan。
清洗的时候要注意几个细节。第一,<a>标签的href要校验协议,javascript:和data:一律拒绝。第二,<img>的src如果是data:协议,要限制大小,防止 base64 炸弹。第三,所有标签的style属性一律剥离,因为 CSS 里也可能藏expression()之类的执行入口。第四,注释节点直接删除,防止条件注释攻击。
ALLOWED_TAGS = {'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre'} ALLOWED_ATTRS = {'href', 'src', 'alt', 'title', 'colspan', 'rowspan'} def sanitize(node): if node.type == 'comment': node.remove() return if node.name not in ALLOWED_TAGS: node.unwrap() return for attr in list(node.attrs.keys()): if attr not in ALLOWED_ATTRS: del node.attrs[attr] elif attr in ('href', 'src'): if not is_safe_url(node.attrs[attr]): del node.attrs[attr] for child in node.children: sanitize(child)5.2 客户端二次校验的必要性
服务端清洗能挡住大部分攻击,但不能完全依赖它。原因有三个:第一,历史数据可能是在清洗规则上线前入库的,里面可能有脏内容;第二,服务端可能被绕过,比如通过其他接口写入数据;第三,客户端渲染引擎和服务端解析器的行为可能有差异,某些在服务端看起来无害的内容,在客户端可能被解释成可执行代码。
客户端校验的重点是:在构建 FlowDocument 的时候,所有文本都当作纯文本处理,绝对不执行任何动态代码。具体来说,Run.Text只接受字符串,不接受绑定表达式;Hyperlink.NavigateUri只接受Uri对象,不接受字符串拼接;Image.Source只接受BitmapImage,不接受任意流。
还有一个细节是BlockUIContainer。这个元素可以塞任意 UI 控件,如果 IR 里允许这种节点,就等于开了一个后门。我的做法是:IR 里根本不定义 UI 容器类型,所有内容都必须是文本或标准文档元素。
5.3 CSP 策略在混合方案中的应用
如果查看器内部嵌了 WebView 来渲染部分 HTML 内容,CSP 就是最后一道防线。script-src 'self'的意思是只允许加载同源脚本,内联<script>和onclick属性都会被拒绝。
配置 CSP 的时候要注意,'self'指的是当前页面的源,如果 WebView 加载的是本地 HTML 文件,'self'可能不生效。这时候要用'none'彻底禁止脚本,或者用 nonce 机制给可信脚本发通行证。
我的建议是:如果只是查看富文本,WebView 里根本不需要脚本,直接设script-src 'none'最安全。如果需要交互,比如折叠展开,用 CSS 的:target或checkboxhack 实现,不要引入 JavaScript。
提示:CSP 的
default-src应该设成'none',然后按需开放。比如img-src 'self' data:允许同源图片和 base64 图片,style-src 'self' 'unsafe-inline'允许内联样式。'unsafe-inline'有风险,但在纯展示场景下可以接受。
6. 性能调优与体验打磨
6.1 首屏加载时间的优化
文档查看器的首屏加载时间直接影响用户体验。我测过一个 2MB 的 HTML 文档,从解析到渲染完成要 3 秒多,用户会明显感觉到卡顿。
优化分三步。第一步是延迟解析:不要一次性解析整个 HTML,而是先解析前 50 个块,渲染出来,剩下的在后台线程慢慢解析。第二步是并行构建:IR 转 FlowDocument 的过程可以并行化,每个块独立构建,最后合并。第三步是预布局:在后台线程调用Document.Measure,提前触发布局计算,等用户看到的时候已经布局好了。
实测下来,这三步能把首屏时间压到 500ms 以内。关键是第一步,用户感知到的“加载完成”其实是首屏可见内容渲染完成,后面的内容慢慢加载用户不会注意。
6.2 滚动流畅度的保障措施
滚动卡顿通常是因为布局计算太频繁。WPF 的FlowDocumentScrollViewer在滚动时会不断重新计算布局,如果文档元素太多,每次计算都很慢。
我的优化手段是固定块高度。如果每个块的高度是固定的,滚动时就不需要重新计算布局,直接按偏移量渲染即可。但富文本块的高度通常不固定,所以这个方案只适用于内容规整的场景。
更通用的方案是虚拟化。只渲染可见区域内的块,其他块用占位符代替。WPF 的VirtualizingStackPanel支持这种模式,但FlowDocumentScrollViewer内部没有用虚拟化。我的做法是自己写一个虚拟化容器,用ScrollViewer加Canvas,手动管理块的创建和销毁。
这个方案的工作量不小,但效果很明显。1000 个块的文档,虚拟化后内存占用从 500MB 降到 80MB,滚动帧率从 15fps 提升到 60fps。
6.3 打印与导出的兼容处理
文档查看器经常需要打印或导出 PDF。WPF 的PrintDialog可以直接打印 FlowDocument,但分页效果和屏幕显示可能不一致。
我的处理是:打印前先把 FlowDocument 克隆一份,设置PageWidth和PageHeight为纸张尺寸,然后调用DocumentPaginator获取分页。如果分页结果不理想,就手动插入PageBreak,在合适的位置强制分页。
导出 PDF 我用的是PdfSharp,它可以把 FlowDocument 渲染成 PDF。但要注意,PdfSharp对中文的支持需要额外配置字体,否则会显示乱码。我的做法是嵌入一个中文字体子集,只包含文档中用到的字符,这样 PDF 体积不会太大。
var pdf = new PdfDocument(); var page = pdf.AddPage(); var gfx = XGraphics.FromPdfPage(page); var font = new XFont("Microsoft YaHei", 12); gfx.DrawString(text, font, XBrushes.Black, new XRect(0, 0, page.Width, page.Height)); pdf.Save("output.pdf");注意:
XFont的字体名必须是系统已安装的字体,或者通过FontResolver加载自定义字体文件。如果目标机器没有安装中文字体,导出的 PDF 会显示方框。
7. 项目扩展与后续演进方向
7.1 批注与协作功能的接入点
查看器做稳定之后,下一步通常是加批注。批注的本质是在文档的特定位置挂一个标记,点击标记显示评论内容。
实现思路是:在 IR 树里给每个文本节点加一个唯一 ID,批注数据里记录 ID 和评论内容。渲染的时候,根据 ID 找到对应的 Run,在它旁边插入一个批注图标。点击图标弹出 Popup 显示评论。
难点在于文档更新后 ID 会变。我的做法是用内容哈希加位置偏移作为 ID,这样即使文档重新生成,只要内容没变,ID 就不变。如果内容变了,批注就标记为“已失效”,提示用户重新定位。
7.2 多格式文档的统一渲染
除了 HTML,实际项目里可能还要渲染 Markdown、RTF、甚至 Word 文档。我的思路是统一转成 IR,再由 IR 转 FlowDocument。
Markdown 用 Markdig 解析,它支持自定义渲染器,可以直接输出 IR。RTF 用 WPF 自带的TextRange.Load加载到 FlowDocument,再反向转成 IR。Word 文档用 OpenXML SDK 解析,提取段落和样式,转成 IR。
这样做的代价是 IR 要设计得足够通用,能表达所有格式的特性。我的 IR 目前支持段落、标题、列表、表格、图片、链接、加粗、斜体、下划线、代码块、引用块,基本覆盖了常见文档格式。
7.3 无障碍访问的支持
无障碍访问在国内项目里经常被忽略,但如果你的用户里有视障人士,这就是刚需。WPF 的自动化支持通过AutomationPeer实现,FlowDocument 的元素默认没有 AutomationPeer,需要自己实现。
我的做法是给每个 Paragraph 和 Run 设置AutomationProperties.Name,内容是文本本身。这样屏幕阅读器就能读出文档内容。表格要额外设置AutomationProperties.ItemType为“表格”,并给每个单元格设置行列信息。
还有一个细节是键盘导航。查看器要支持 Tab 键在链接之间跳转,Enter 键激活链接。这需要给 Hyperlink 设置KeyboardNavigation.IsTabStop为 true,并处理KeyDown事件。
8. 实操心得与避坑总结
8.1 我踩过的三个大坑
第一个坑是在 UI 线程做 HTML 解析。早期版本我把 AngleSharp 的解析放在 UI 线程,结果大文档一加载界面就卡死。后来改成后台线程解析,解析完再Dispatcher.Invoke回 UI 线程构建 FlowDocument。但构建 FlowDocument 也必须在 UI 线程,因为 WPF 的依赖对象有线程亲和性。所以最终的方案是:后台线程解析 HTML 成 IR,UI 线程把 IR 转成 FlowDocument。
第二个坑是忘记解除事件绑定。前面提过内存泄漏的问题,根源就是 Hyperlink 的事件没解绑。后来我养成了一个习惯:任何+=事件绑定,都要在对应的清理逻辑里-=。对于文档这种生命周期明确的对象,在切换文档时统一清理。
第三个坑是低估了字体回退的复杂度。我以为设一个字体族列表就完事了,结果发现某些特殊字符(比如数学符号、emoji)在所有指定字体里都不存在,WPF 会显示成方框。后来加了一个兜底字体Segoe UI Symbol,覆盖了大部分特殊字符。如果还有缺失,就用FontFamily.Fallback手动指定。
8.2 给新手的入门建议
如果你刚开始做 WPF 文档查看器,我的建议是先跑通最小闭环。不要一上来就搞虚拟化、批注、多格式,先把“HTML 转 FlowDocument 并显示”这条路走通。用最简单的 HTML 片段测试,确保段落、加粗、链接、列表这四种元素能正确渲染。
跑通之后,再加安全清洗。清洗逻辑要单独写单元测试,用各种恶意 HTML 片段测试,确保<script>、onclick、javascript:都被正确过滤。
最后再加性能优化。性能优化是永无止境的,先保证功能正确,再考虑速度。我见过有人为了优化性能把代码搞得极其复杂,结果功能一堆 bug,得不偿失。
8.3 工具与库的选型参考
| 用途 | 推荐库 | 理由 |
|---|---|---|
| HTML 解析 | AngleSharp | 容错性好,API 干净,支持 CSS 选择器 |
| Markdown 解析 | Markdig | 性能好,扩展性强,支持自定义渲染器 |
| PDF 导出 | PdfSharp | 轻量,API 简单,支持中文字体嵌入 |
| 内存分析 | dotMemory | 快照对比直观,能定位引用链 |
| UI 控件库 | HandyControl | 控件丰富,样式现代,中文文档全 |
| 图标 | FontAwesome.Sharp | 图标全,集成简单,支持矢量缩放 |
选型的时候不要盲目追新,优先选社区活跃、文档齐全的库。我吃过亏,用了一个小众的 HTML 解析库,结果遇到 bug 没人修,最后只能自己 fork 一份改。
8.4 后续可以扩展的方向
这个项目做完之后,有几个方向可以继续深挖。一是全文检索,把文档内容索引到 Lucene 或 Elasticsearch,支持复杂的查询语法。二是版本对比,同一文档的不同版本并排显示,高亮差异。三是导出为图片,把文档渲染成 PNG 或 JPEG,方便分享到社交平台。四是朗读功能,用系统 TTS 引擎把文档读出来,适合通勤场景。
每个方向都有现成的库可以用,关键是想清楚用户需求,不要为了加功能而加功能。我个人的原则是:如果一个功能不能解决用户的真实痛点,就不做。