ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WPF RichTextBox MVVM双向绑定实战:附加属性方案与序列化

WPF RichTextBox MVVM双向绑定实战:附加属性方案与序列化 如果你在公司项目里跟我一样被“把RichTextBox绑到ViewModel”这件事折磨过那你一定知道WPF里这个最常用的富文本控件从2006年出生到现在官方就没给过MVVM一点好脸色。网上搜索“WPF RichTextBox MVVM绑定”翻来覆去就是“不能直接绑定Document”“用附加属性”“用行为”这几种说法真正能落地的完整方案却很少。这篇东西我整理了近两年在工控上位机和文档编辑项目里的实际落地经验从最小可用的Document双向绑定到Selection联动、图片插入、序列化存取、泛型附加属性封装再到性能和内存的坑一次性讲透。这套东西适合谁一是刚学WPF MVVM、正在写编辑器或聊天界面的新手能直接抄代码二是被RichTextBox绑定坑过、想知道原理和更优方案的开发者能避开我踩过的坑。我会先把为什么“不能绑定”讲清楚再一步步给出能跑的完整实现。1. 先看清楚RichTextBox和MVVM的矛盾在哪里1.1 一个老生常谈的坑RichTextBox没有Text属性它只有Document属性类型是FlowDocument。你在XAML里写Text{Binding Body}编译器直接报错你想学WinForms那样弄个Text连门都没有。FlowDocument本身是一个DependencyObject但它不是普通的字符串、布尔值这类标量属性。它内部维护的是一个完整的文档树段落、行、Inline元素、超链接、图片、表格……随便一次输入都可能触发复杂的内容结构调整。把它直接绑定到ViewModel上MVVM的“View和ViewModel解耦”原则很难落实因为ViewModel要操作一个UI层的文档对象等于把View层的东西塞进了业务层。更麻烦的是你就算用Document{Binding Doc}强行绑上用户输入时FlowDocument会变但你绑定的源属性却收不到任何通知。TextChanged事件在RichTextBox内部是有的可它没有暴露成绑定用的依赖属性默认的绑定管道根本不知道文档变化这件事。所以网上一搜全是“不能绑定”本质原因有三个没有字符串类型的Text属性可以绑、Document是复杂对象而不是值类型、内部变化事件没有和依赖属性系统打通。1.2 为什么“想办法把它变成可绑定”这件事值得做有人会说那不用MVVM不就行了代码后置里直接doc.TextChanged ...想怎么拿文本怎么拿文本。但你一旦这么干项目复杂度上来就收不住了。大型WPF应用里业务逻辑要跑在ViewModel命令要统一走Command界面状态要跟着数据状态走。编辑器内容的保存、校验、版本对比、权限控制全都得在ViewModel里能拿到值。举我做过的一个工控上位机项目为例设备参数编辑器允许操作员编辑工艺备注保存时要校验内容是否包含禁用字符还要根据用户角色决定能否编辑。如果RichTextBox的内容直接写在界面层校验逻辑就得破MVVM的规矩跑到View里或者绕一大圈用事件把它带回ViewModel代码会变得非常难维护。所以很多时候问题的核心不是“能不能绑定”而是“怎么用最小的代价做一个足够通用、足够稳的可绑定RichTextBox”。这个基础设施做好之后所有用到富文本编辑器的界面都能复用节省下来的时间远比刚开始折腾绑定所花费的时间多。2. 最小可用的双向绑定实现2.1 依赖属性是绕不开的基石既然RichTextBox自身没有可绑定的文本属性我们要做的就是自己给它“造”一个。最通用的做法是写一个静态类里面定义依赖属性然后通过附加属性的方式挂到RichTextBox上。依赖属性在这里有两个作用一是提供绑定管道让XAML里能写{Binding}二是值变化时能有回调通知让我们有机会把新值同步给UI。为什么不直接继承RichTextBox写子类因为WPF里控件继承链很长为了一个绑定功能去继承一个复杂控件成本高不说还得多维护一个新控件类用附加属性则所有RichTextBox开箱即用不管是模板化出来的还是代码里new出来的直接挂属性就能生效。先看最小版本创建一个RichTextBoxHelper静态类定义Document附加属性public static class RichTextBoxHelper { public static readonly DependencyProperty DocumentProperty DependencyProperty.RegisterAttached( Document, typeof(FlowDocument), typeof(RichTextBoxHelper), new FrameworkPropertyMetadata(null, OnDocumentChanged)); public static FlowDocument GetDocument(DependencyObject obj) { return (FlowDocument)obj.GetValue(DocumentProperty); } public static void SetDocument(DependencyObject obj, FlowDocument value) { obj.SetValue(DocumentProperty, value); } private static void OnDocumentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is RichTextBox box) { box.Document e.NewValue as FlowDocument ?? new FlowDocument(); } } }这里有个细节依赖属性注册时的默认值设为null不要试图在注册时给new FlowDocument()作为默认值。因为依赖属性系统对引用类型默认值的处理是共享的所有没有显式赋值的控件都会拿到同一个FlowDocument实例。你想象一下页面上两个RichTextBox没绑任何东西结果它们共享同一个文档在其中一个里打字另一个也跟着变那场景太酸爽了。所以必须在回调里处理null给每个控件一个独立的新文档。2.2 双向同步与重入保护上面只是单向的“赋值给UI”用户输入时ViewModel是不知道的。要做真正的双向绑定必须监听RichTextBox的TextChanged事件并把最新的FlowDocument回写依赖属性。最直接的办法是在OnDocumentChanged里给box挂TextChanged处理器private static void OnDocumentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is RichTextBox box) { box.TextChanged - OnTextChanged; box.Document e.NewValue as FlowDocument ?? new FlowDocument(); box.TextChanged OnTextChanged; } }挂事件之前先把旧的处理器摘掉防止多次赋值导致重复订阅。再定义一个_isSyncing标志位防止“UI变化→回写依赖属性→触发OnDocumentChanged→重新赋值文档→再次触发TextChanged”这样的死循环private static bool _isSyncing; private static void OnTextChanged(object sender, TextChangedEventArgs e) { if (sender is RichTextBox box !_isSyncing) { _isSyncing true; SetDocument(box, box.Document); _isSyncing false; } }但这里有个隐患_isSyncing是静态的如果界面上同时存在多个RichTextBox它们会共享同一个标志位。A控件输入时标志位被置为true期间B控件恰好也触发了TextChanged就会被错误跳过导致B的文档没有同步。多编辑器场景下这个Bug非常隐蔽我建议用一个实例级的包装或者至少给每个控件记录自己的标志位。一个比较稳的处理方式是每次在TextChanged里临时把该控件的Document赋值给自己强制依赖属性系统感知变化。但不需要绕这么远更好的思路是把同步逻辑放到一个专用的BindableRichTextBox控件里每个实例持有自己的_isSyncing。附加属性版本为了简单可以接受单个编辑器的场景如果是多实例场景最好封装控件。提示TextChanged触发非常频繁每敲一个字符都会触发。回写依赖属性时SetDocument(box, box.Document)虽然是同一个对象但依赖属性系统还是会产生一次属性变化通知。高频输入下这会有一定性能开销。后面第6节我会讲更优的同步策略。2.3 绑定到ViewModel依赖属性定义好之后XAML里就可以这样绑定RichTextBox helper:RichTextBoxHelper.Document{Binding Doc, UpdateSourceTriggerPropertyChanged} /ViewModel里用一个普通的可通知属性就行了public class MainViewModel : BindableBase { private FlowDocument _doc; public FlowDocument Doc { get _doc; set { SetProperty(ref _doc, value); // 可以在这里做保存、校验等业务逻辑 } } }注意UpdateSourceTriggerPropertyChanged默认的LostFocus触发在编辑器场景下很不友好。用户正打着字切出去可能还没离开焦点内容就已经需要保存了等焦点丢失再更新就晚了。富文本编辑器是连续输入型控件用PropertyChanged更符合直觉。到这里一个最小可用的双向绑定就完成了。这段代码不长但解决了RichTextBox绑定的核心问题。真正做编辑器时还需要Selection、图片、只读控制、序列化这些进阶能力下面逐个展开。3. 富文本编辑器真正要用的进阶能力3.1 Selection绑定文档编辑器里工具栏的加粗、斜体、字号按钮必须知道你当前选中了哪段文字。RichTextBox的Selection属性类型是TextSelection不能直接用字符串绑定。它也是个依赖属性但类型太复杂不适合放到ViewModel做业务判断。我这里提供一种折中方案把Selection的起止偏移量或选中文本内容绑定出来。如果要精确知道选中了什么可以在ViewModel里定义一个SelectedText属性同步时只取选中区间的纯文本public static readonly DependencyProperty SelectedTextProperty DependencyProperty.RegisterAttached( SelectedText, typeof(string), typeof(RichTextBoxHelper), new FrameworkPropertyMetadata(string.Empty, OnSelectedTextChanged)); private static void OnSelectedTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is RichTextBox box) { var selection box.Selection; box.Selection.Text e.NewValue as string ?? string.Empty; } }在TextChanged或者SelectionChanged时更新SelectedTextprivate static void OnSelectionChanged(object sender, RoutedEventArgs e) { if (sender is RichTextBox box) { SetSelectedText(box, box.Selection.Text); } }但要注意box.Selection.Text会把选中范围内的所有文本拼成一个纯字符串图片、表格等非文本内容会被忽略或变成特殊占位符。这个方案适合简单的文本选中状态同步如果要做样式按钮的高亮状态需要更细粒度的方案一般是通过CommandParameter加box.Selection.GetPropertyValue(TextElement.FontWeightProperty)这类方法在命令里直接读取。对于工具栏按钮来说比较好的写法是把RichTextBox实例或Selection对象传给命令让命令内部直接操作UI。这个在纯MVVM理论里有点“脏”但在富文本编辑器这种强耦合UI的领域里反而是最务实、代码量最少的做法。理论是为人服务的不是反过来折腾人的。3.2 插入图片富文本编辑器没有图片是不可想象的。WPF里往FlowDocument里插入图片常见做法是通过剪贴板粘贴或者从文件拖拽。剪贴板粘贴其实不用写代码RichTextBox默认支持CtrlV粘贴位图。但如果你想在界面上做一个“插入图片”按钮可以这么写private void InsertImageFromClipboard(RichTextBox box) { var image Clipboard.GetImage(); if (image null) return; var position box.CaretPosition; var imageElement new InlineUIContainer( new Image { Source image, MaxWidth 400, MaxHeight 400 }) { BaselineAlignment BaselineAlignment.Center }; position.InsertInlineUIContainer(imageElement); box.Focus(); }代码意思很简单拿到剪贴板图像创建一个InlineUIContainer包住Image控件插入到光标位置。InlineUIContainer是FlowDocument里嵌UI元素的容器任何WPF控件都能塞进去这是FlowDocument设计上比较灵活的地方。拖拽插入图片稍微麻烦一点需要处理DragEnter和Dropbox.AllowDrop true; box.DragOver (s, e) { e.Effects e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None; e.Handled true; }; box.Drop (s, e) { if (e.Data.GetData(DataFormats.FileDrop) is string[] files files.Length 0) { foreach (var file in files) { if (Path.GetExtension(file).ToLower() is .png or .jpg or .jpeg or .bmp) { var image new BitmapImage(new Uri(file)); var container new InlineUIContainer( new Image { Source image, MaxWidth 400 }) { BaselineAlignment BaselineAlignment.Center }; box.CaretPosition.InsertInlineUIContainer(container); } } e.Handled true; } };拖拽时要把e.Handled true设置上否则RichTextBox自带的处理逻辑会和你的逻辑打架导致图片插入位置不对或者根本没插进去。3.3 只读、权限与工具栏联动我在编辑器项目里遇到的第二个真实需求是权限控制。工艺员只能读工程师能编辑管理员能改任何内容甚至不同字段的编辑权限都不一样。这个用绑定做起来其实很顺手RichTextBox原生支持IsReadOnly和IsReadOnlyCaretVisible两个属性RichTextBox helper:RichTextBoxHelper.Document{Binding Doc} IsReadOnly{Binding IsReadOnly} IsReadOnlyCaretVisibleTrue /但直接绑定IsReadOnly在MVVM里有个坑你希望在ViewModel里通过一个枚举角色来控制只读状态而不是每个字段单独暴露一个bool。所以我更推荐在ViewModel里计算权限把它转换成CanEdit属性public bool IsReadOnly CurrentUserRole UserRole.Engineer;权限变化时调用OnPropertyChanged(nameof(IsReadOnly))通知界面刷新。工具栏按钮的可用性也一样。加粗、斜体、插入图片这些按钮应该根据当前选中内容和权限动态启用。实际做的时候我用命令的CanExecute来控制。但有一个细节WPF的命令CanExecute默认只在特定时机刷新比如焦点切换、按钮点击选中文字变化不会自动触发。如果发现按钮状态不跟随选区更新要手动调用CommandManager.InvalidateRequerySuggested()。这个我踩过好几次。一开始写了加粗按钮的Command绑定运行后发现选不选中文字按钮永远是灰色。加上手动刷新之后就正常了。工具栏和正文区联动代码里要处理的其实就是这个细节。3.4 拼写检查如果编辑器需要输入英文比如工艺备注里经常有英文缩写和参数名WPF的拼写检查功能可以直接开RichTextBox SpellCheck.IsEnabledTrue Languageen-US /这个功能是系统自带的不需要额外引入词典也不需要联网。开启之后拼错的单词下面会出现红色波浪线右键还能看到建议词。需要注意的是Language属性要设置成正确的语言否则中文环境下可能不会生效。SpellCheck.IsEnabled本质上是附加属性它内部也只对TextBoxBase的子类生效。RichTextBox继承自TextBoxBase所以没问题。实测下来它对长文档的性能影响可以忽略除非文档特别大且每个词都拼错那波浪线渲染确实会变慢一些。4. 文本提取与序列化4.1 为什么不用RTF绑定解决了“界面数据”的问题但富文本编辑器终究要把内容存到数据库或文件里。FlowDocument没法直接丢给数据库需要序列化成字符串。WPF里TextRange支持三种序列化格式Xaml、Rtf、Text。网上很多老教程让你用RTF因为Word和很多编辑器都支持RTF。但在实际项目里我强烈建议优先考虑Xaml格式除非有和其他软件交换文档的硬性要求。原因很简单FlowDocument里很多特性比如InlineUIContainer嵌入的自定义控件、绑定表达式、样式资源在序列化为RTF时会丢失而Xaml能最大程度保留原始结构。你的编辑器是自产自销不存在跨软件编辑的需求用Xaml最稳。提示RTF格式在不同版本的.NET Framework下序列化结果有差异跨机器会出现字体、颜色对不上的问题。用Xaml格式只要两边是同一个WPF运行时解析结果基本一致。4.2 Xaml序列化实现序列化和反序列化的代码很简单但有一个很容易踩的坑。看代码public static string ToXamlText(FlowDocument document) { if (document null) return string.Empty; var range new TextRange(document.ContentStart, document.ContentEnd); using var ms new MemoryStream(); range.Save(ms, DataFormats.Xaml); return Encoding.UTF8.GetString(ms.ToArray()); }这个DataFormats.Xaml是WPF自定义格式保存出来的其实是一段XAML包XML。注意几个细节必须使用Encoding.UTF8。有些老代码用Encoding.Default在中文Windows上可能是GBK存出来再读会乱码。TextRange.Save之后MemoryStream的位置已经在末尾了不能直接ToString读要先ToArray或者重置Position。range.Save前如果文档末尾有空的段落序列化结果里会多一些空Paragraph/这属于正常现象。但上面的方法有个问题TextRange在Save的时候会把FlowDocument中我们手动插入的InlineUIContainer等嵌入控件序列化成一堆XAML代码这没问题但如果你在代码里给FlowDocument设置了PagePadding、FontFamily这些资源序列化结果会非常庞大甚至有动态资源表达式无法解析的风险。我踩过的更隐蔽的坑是有些嵌入内容比如按钮、输入框反序列化后不一定能正常还原。所以如果编辑器只允许插入图片序列化前要确认所有内嵌元素都是可还原类型如果允许任何控件建议把控件数据单独存储不要在FlowDocument里裸嵌。4.3 从存储恢复文档反序列化的代码如下public static FlowDocument FromXamlText(string xaml) { if (string.IsNullOrWhiteSpace(xaml)) return new FlowDocument(); var doc new FlowDocument(); var range new TextRange(doc.ContentStart, doc.ContentEnd); using var ms new MemoryStream(Encoding.UTF8.GetBytes(xaml)); range.Load(ms, DataFormats.Xaml); return doc; }这个range.Load调用有讲究。TextRange的构造函数第一个参数是起始TextPointer第二个是结束TextPointer这里用的是doc.ContentStart和doc.ContentEnd也就是先创建空文档再把XAML加载进去。不能直接new TextRange(null, null)那样会抛出异常。另外一点如果XAML字符串是从数据库读出来的很可能因为手工拼接、字符串转义问题变成坏数据。range.Load遇到不合法XAML会直接抛异常。轻则编辑器打不开重则整个窗口崩掉。稳妥做法是做一层try/catch失败时返回空文档同时记录日志public static FlowDocument FromXamlSafe(string xaml) { try { return FromXamlText(xaml); } catch (Exception ex) { Debug.WriteLine($解析富文本失败: {ex.Message}); return new FlowDocument(); } }我在实际项目里就遇到过数据从老系统迁移过来时字符串里带了一个未转义的小于号结果Load直接崩溃。从那以后所有加载入口都包了try/catch宁可内容丢了也不能让程序崩。5. 做一个更通用的基础设施5.1 泛型附加属性上面的实现针对RichTextBox写死了但项目里可能还有其他需要类似绑定的文本控件。WPF中可以做泛型附加属性封装让同一个基础设施支持多种控件。虽然附加属性本身不能写成泛型类但我们可以把公共逻辑抽成基类再用不同的静态类暴露不同控件类型的附加属性。举个例子我做过一个BindableDocumentService内部用一个条件字典来区分不同控件类型public static class BindableDocumentService { private static readonly DictionaryType, ActionDependencyObject, FlowDocument _documentApplier new(); public static void RegisterDocumentApplierT( ActionT, FlowDocument applier) where T : FrameworkElement { _documentApplier[typeof(T)] (obj, doc) applier((T)obj, doc); } public static readonly DependencyProperty DocumentProperty DependencyProperty.RegisterAttached( Document, typeof(FlowDocument), typeof(BindableDocumentService), new FrameworkPropertyMetadata(null, OnDocumentChanged)); private static void OnDocumentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (!_documentApplier.TryGetValue(d.GetType(), out var applier)) return; applier(d, e.NewValue as FlowDocument); } public static FlowDocument GetDocument(DependencyObject obj) (FlowDocument)obj.GetValue(DocumentProperty); public static void SetDocument(DependencyObject obj, FlowDocument value) obj.SetValue(DocumentProperty, value); }初始化时注册RichTextBox和FlowDocumentScrollViewer的处理逻辑BindableDocumentService.RegisterDocumentApplierRichTextBox( (box, doc) box.Document doc ?? new FlowDocument()); BindableDocumentService.RegisterDocumentApplierFlowDocumentScrollViewer( (viewer, doc) viewer.Document doc ?? new FlowDocument());这样同一个Document附加属性就能用在多个控件上ViewModel里那个FlowDocument属性既能绑到编辑器上也能绑到只读的查看器上。这个封装方式有一点要特别注意_documentApplier是静态字典多线程环境下要保证初始化顺序。一般应用启动时在App.xaml.cs里注册就够了不会有并发问题。5.2 DataTemplate里的坑非要提一个我花了两天时间才找到的坑把带Document绑定的RichTextBox放进DataTemplate比如ListBox的ItemTemplate然后发现绑定在界面首次显示后莫名其妙失效或者每个项的内容互相串。原因很简单DataTemplate里每个项都会创建独立的RichTextBox实例但RichTextBox内部会为每个实例创建自己的FlowDocument。当你设置附加属性后再把它丢进ItemsControl控件模板重新生成时box.Document可能会被框架重置为默认的空文档导致之前的绑定值被覆盖。解决办法有几种放弃在DataTemplate里直接放RichTextBox改成用ContentControl承载并把Document绑定放到ContentControl的模板里。在Loaded事件里重新应用一次绑定值确保模板应用完成后Document是对的。如果列表只是只读展示改用FlowDocumentScrollViewer或者FlowDocumentReader它们对绑定友好很多也不需要编辑功能。只读展示和编辑交互的边界在代码层面就是两种控件的差别。ViewModel里那个FlowDocument属性可以共用但View层要用不同控件去呈现它编辑用RichTextBox只读用FlowDocumentScrollViewer。这样权限控制也省了一大半——先判断角色再决定用哪个View展示同一份数据。6. 性能、内存和替代方案6.1 大数据量下的卡顿RichTextBox的FlowDocument本身就是为文档排版设计的它能承载很长的文本但TextField的编辑性能和普通TextBox完全不是一个量级。几万字的文档每次输入都全量序列化成Xaml存在ViewModel里UI线程会明显感到卡顿。我之前做一个设备日志备注编辑器用户能粘贴几千行日志进去。结果每按一个键ViewModel里的字符串就重新序列化一次界面开始掉帧。后来我做了两个优化第一设置了同步策略。不是每个TextChanged都立刻回写ViewModel而是用一个防抖计时器停止输入300毫秒后再序列化同步。代码如下private static readonly DictionaryRichTextBox, DispatcherTimer _debounceTimers new(); private static void OnTextChanged(object sender, TextChangedEventArgs e) { if (sender is not RichTextBox box) return; if (!_debounceTimers.TryGetValue(box, out var timer)) { timer new DispatcherTimer { Interval TimeSpan.FromMilliseconds(300) }; timer.Tick (s, _) { timer.Stop(); SetDocument(box, box.Document); }; _debounceTimers[box] timer; } timer.Stop(); timer.Start(); }防抖之后用户连续输入时ViewModel不会频繁更新只在停顿后进行一次性同步。保存时的数据完整性不受影响界面流畅度大幅提升。第二如果是只读展示超长文档不要用RichTextBox改用FlowDocumentReader或FlowDocumentPageViewer这两种控件不会为每个字符维护编辑状态渲染性能高一个数量级。6.2 内存泄漏隐患在附加属性版本里最容易引发内存泄漏的地方就是事件订阅。如果每创建一个RichTextBox实例都在OnDocumentChanged里挂一个实例级的TextChanged处理器而这些实例又被长期保存在可视化树里来回切换事件处理器会一直引用着控件GC永远回收不了它们。推荐用EventManager.RegisterClassHandler做类级别的事件处理而不是给每个实例挂事件。类级别处理器的生命周期和类型一样长不会因为单个实例销毁而产生泄漏static RichTextBoxHelper() { EventManager.RegisterClassHandler( typeof(RichTextBox), TextChangedEvent, new TextChangedEventHandler(OnTextChangedClassHandler)); }类级别处理器里通过(sender as RichTextBox)拿到实际触发事件的控件逻辑和实例级类似但不需要在OnDocumentChanged里反复-和也不会有重复订阅问题。这个做法我强烈推荐尤其是在控件会被频繁创建销毁的场景。另外FlowDocument里如果插入了大量图片BitmapImage记得设置CacheOption BitmapCacheOption.OnLoad让图片加载后就能释放文件句柄否则整个文件会被一直锁定而且内存占用会随着撤销栈累积。6.3 实在不行就别绑了有几种场景我建议你放弃“强绑定”的执念回到务实方案编辑器在ScrollViewer或TabControl里被频繁卸载和重新加载。需要绑定的是超大文档比如几十万字的小说。编辑器之间需要复杂的复制粘贴格式联动。这种情况下与其折腾绑定不如在代码后置里维护一个EditorMediator单例或者在ViewModel里直接用命令调box.Document。MVVM是指导原则不是铁律。我在做大型流程编辑器时也遇到过“ViewModel里拿不到FlowDocument上下文”的尴尬场景最后把部分编辑操作封装成EditorService直接操作RichTextBox的Document代码反而更简单清晰。提示如果你的ViewModel确实需要和FlowDocument深度交互比如执行TextRange.Find、批量替换文字、动态插入表格直接传RichTextBox给命令是成本最低的方案。不要为了MVVM牺牲可维护性。7. 常见问题速查表我在使用绑定的RichTextBox过程中把遇到的高频问题整理成了表格你对照着排查基本能一键定位。现象可能原因解决办法绑定后文本不显示ViewModel属性没有通知或文档为空确认属性是FlowDocument且实现了INotifyPropertyChanged输入后ViewModel值不更新绑定没设置UpdateSourceTrigger或事件没挂上改成PropertyChanged检查事件处理器是否被误签界面卡顿每次输入都全量序列化加防抖索引或文本变化后再同步模板里绑定失效DataTemplate里RichTextBox被重新创建用FlowDocumentScrollViewer只读展示或在Loaded里重新赋值图片插入失败剪贴板没有图片数据用Clipboard.ContainsImage()判断后再插入恢复文档时抛异常Xaml字符串不合法Load外面包try/catch返回空文档序列化出来内容巨大FlowDocument带大量样式资源精简资源不要嵌动态资源引用多个编辑器互相影响依赖属性默认值共享同一固定实例注册时默认null在回调里new新实例撤销/重做异常频繁替换Document导致撤销栈混乱手动管理撤销或减少对Document的赋值次数还有一个很容易被忽略的问题如果你直接把box.Document设置成一个已经挂到其他控件的FlowDocumentWPF会抛出“指定元素已是另一个元素的逻辑子元素”异常。赋值前要判断文档是否已有parent必要时先做doc.RemoveFromParent()处理。写在最后的实操心得这套绑定方案在我自己的项目里迭代了快两年从最早依赖属性加事件、到后来封装附加属性、再到现在的泛型服务和防抖同步每一步都是被具体问题逼出来的。我最想分享的一点是不要一上来就追求“最完美、最符合MVVM”的架构。你先解决绑定跑通流程等真正遇到性能问题、模板问题、多编辑器共存问题再针对性地优化第5、6节那些方案反而比我这样一上来就想着做通用框架稳妥得多。最后再分享一个真正提升体验的小技巧如果你做的编辑器允许用户输入大量内容建议给RichTextBox的VerticalScrollBarVisibility设成Auto并且在内容超长时自动滚动到光标处。上面第3节那些命令里面顺手执行一句box.ScrollToEnd()或box.CaretPosition.GetInsertionPosition(LogicalDirection.Forward)相关逻辑就能省下用户不停拉滚动条的烦恼这个小细节实测对这类工具型界面特别加分。
返回列表