代码逻辑全对,画布就是不给你看。
上篇结尾预告了三类诡异现象:控件明明 Add 进画布了却不显示,辅助线用奇偶规则挖空失败,元素拖到一半落点对不上。这篇把它们一次说清。
事件路由的坑是「没反应」,渲染和布局的坑是「看着不对」,而且大多发生在你最有把握的地方:一次动态 Add、一条辅助线、一次坐标转换。没有异常,没有日志,画面就是不对。我们的画面编辑器是个 Canvas 大画布,控件、框选、缩放手柄、阵列预览全压在这一层,半年修下来,渲染层的坑有个共同点:都不是「画错了」,而是「画了但看不见 / 看见了但不对」。
坑一:运行时 Add 进画布的线,画布不给你看
阵列排列功能需要预览效果:把一排控件按网格摆开之前,先在画布上画虚线网格和扇形辅助线,让用户看落位。辅助线用最直白的方式画,运行时 new 一个 Line,Children.Add 进画布:
var line = new Line { X1 = 0, Y1 = 0, X2 = 100, Y2 = 100, Stroke = Brushes.DarkGray };DrawingCanvas.Children.Add(line);
结果:画布上一根线都没有。诡异的是,框选用的 MarqueeRect 是 XAML 里预声明好的,画布上看得见;动态 new + Add 的线,就是看不见。
我们先后试了六种方案:
动态 Children.Add(Line),不可见根 Grid 临时 Canvas + 坐标对齐,可见但有偏移 Loaded/SizeChanged 时机对齐,偏移改善但时机不可靠 DrawingCanvas 里再嵌子 Canvas,不可见 XAML 预声明 Canvas,字段未生成 直接用控件本身做预览,效果完美
根因始终没找到确切的内部机制,这正是这类坑最磨人的地方:WPF Canvas 的渲染管线对「运行时动态添加的子元素」存在事实上的忽略,尤其当画布在 ScrollViewer 内部、或存在 LayoutTransform(缩放)时更容易触发;而 XAML 预声明字段在复杂嵌套布局里也可能干脆不生成。与其跟它赌,不如绕开:要动态显示,就在 XAML 预创建空占位,运行时只改属性,不 new 不 Add。
// XAML:GridPreviewLine.X1 = 0; GridPreviewLine.Y1 = 0;GridPreviewLine.X2 = 100; GridPreviewLine.Y2 = 100;GridPreviewLine.Visibility = Visibility.Visible;
这条规矩后来救了我们好几次:画布上一切「临时图形」,先问一句「能不能在 XAML 里给它留个位子」。不能,再考虑别的。
这两条结论,能预创建就预创建、能不用辅助图形就不用,其实是同一件事的两面:别跟渲染管线的魔法搏斗。这个结论到坑五还会再回来。
坑二:辅助线看不清,是 FillRule 在偷偷挖洞
辅助线第一版把圆点和方框合并进同一个 GeometryGroup,画成一个 Path。预览时,实心圆点本该落在网格交叉点上,结果渲染出来是「四角残片 + 中央洞」,圆点中间是空的,阵列辅助线糊成一片,根本看不清。
根因是 GeometryGroup 的默认填充规则 FillRule = EvenOdd:从圆内任意一点沿任意方向引射线,穿出圆 1 次、再穿出方框 1 次,共 2 次(偶数),奇偶规则判定该点在「外部」,于是圆内部被挖空,只剩四角。
// 错:同心圆+方框合并进一个 GeometryGroup,默认 EvenOdd 挖空圆内var group = new GeometryGroup();group.Children.Add(new EllipseGeometry(center, r, r));group.Children.Add(new RectangleGeometry(rect));path.Data = group;// 对:实心与描边拆成两个 Path 层,互不干扰solidPath.Data = new EllipseGeometry(center, r, r); // 实心圆点独立层gridPath.Data = new GeometryGroup{FillRule = FillRule.Nonzero,Children = { /* 方框/网格/弧线 */ }};
修复有两个层面:多语义的几何(实心标记 vs 描边线)优先拆成两个 Path 层,别赌 FillRule 方向;同层确实需要并集的,显式给 FillRule = Nonzero(按射线交点方向计数,计数非零才填充,同心嵌套这种同向几何会正常填实)。这条经验对任何「组合几何」都适用:合并渲染前,先想清楚每条路径的填充语义。
坑三:给 GroupBox 加个标题,整个画布崩了
控件模板用 FrameworkElementFactory 在代码里构建(ItemsControl 的 ItemTemplate 由代码工厂生成)。给 GroupBox 加了标题又加内容:
var gb = new FrameworkElementFactory(typeof(GroupBox));gb.AppendChild(new FrameworkElementFactory(typeof(TextBlock))); // 想当标题gb.AppendChild(imgFactory); // 内容
一运行,画布上所有该控件的实例化全部崩溃:InvalidOperationException: ContentControl 的内容必须为单个元素。
根因:FrameworkElementFactory.AppendChild 设置的是Content(单值属性),不是 Header。GroupBox 是 ContentControl,内容只允许一个子元素,第二个 AppendChild 直接抛异常。
修复:标题走 HeaderProperty 绑定,内容保持唯一 AppendChild:
var gb = new FrameworkElementFactory(typeof(GroupBox));gb.SetBinding(GroupBox.HeaderProperty, new Binding(”Title”)); // 标题走 HeaderPropertygb.AppendChild(imgFactory); // 内容唯一 AppendChild
这里还有个衍生坑:标题字体(FontFamily/FontSize/FontWeight)是继承属性,沿控件树自动传递,直接 HeaderProperty 绑字符串,字体就自动从 GroupBox 继承,没必要再包一个 TextBlock 手工绑字体,纯属重复造轮子。至于「标题下划线」(TextDecorations)这类非继承属性,Header 字符串渲染不了,我们干脆把这个选项从属性面板里藏掉了,而不是跟它死磕。ContentControl 家族(Button/GroupBox/Expander/TabControl)的子元素只能有一个,标题类走 HeaderProperty/Content 绑定。
坑四:拖一下跳 60px,单击判定永不成立
悬浮球控件(一个跟随面板浮动的小按钮)支持拖动,也支持静止单击打开面板。用户反馈:按下瞬间位置跳变约 60px,跳完根本拖不中;而静止单击时面板死活打不开。
根因是两层的。第一层:坐标基准不一致。e.GetPosition(this) 返回的是窗口客户区原点坐标,而按钮的 Margin 是相对所在 Grid 单元格原点。窗口上方有一行约 60px 高的菜单+工具栏(Auto 行),两个原点的差就是这 60px,按下瞬间,位置整体偏移。
第二层更阴:MouseDown、MouseMove 已经统一改成 GetPosition(DockManager)(与按钮同 Grid 单元格的锚点),唯独MouseUp 残留了GetPosition(this)。结果每次 MouseUp 的 moved 值都恒含 60px 偏移,「单击判定(moved < 10)」永不成立,单击功能彻底失效;拖动过程中位置是对的,但松手时落点整体偏约 60px,落不到鼠标停住的位置。我们第一轮只修了主逻辑,复审时抓到 MouseUp 残留,「修一处、漏一处」正是坐标类 bug 的高发模式。
// 错:三处基准不统一,MouseUp 残留窗口原点var pos = e.GetPosition(this); // 窗口客户区原点,与 Margin 差 ~60px// 对:Down/Move/Up 三处统一到同一个锚点元素pos = e.GetPosition(AnchorElement); // 与控件同 Grid 单元格的锚点

规矩:拖拽/点击的坐标,Down/Move/Up 三处必须一字不差地用同一个基准元素,基准选「与控件同容器的锚点」,不是窗口;涉及两套坐标系(窗口客户区 / 单元格 / Canvas)时,先画基准图再写代码。修复后把同类调用点 grep 出来全量核对,别修完一个就收工。
坑五:跨层坐标转换,转着转着就偏了
两个症状一起说。一是缩放(放大/缩小)之后,拖放、粘贴、创建控件的位置全部偏移,控件生成在鼠标位置的「1/缩放」处(缩放 2 倍时,控件落在鼠标位置的一半处)。二是画布和根 Grid 之间用 TranslatePoint 做坐标转换(比如要把辅助线画到根 Grid 层对齐),在 AvalonDock 这种复杂布局树里,LayoutTransform、Margin、Padding 的误差一层层累积,转出来的坐标总差一点,怎么调都对不齐。
第一个的根因很经典:GetPosition 在带 LayoutTransform 的画布上,会沿视觉变换链逆变换,返回的已经是画布本地(逻辑)坐标。你再手动除以 _zoomLevel,就是双重除,缩放 ≠ 1 时位置减半:
// 错:双重除,GetPosition 已返回逻辑坐标var pos = e.GetPosition(DrawingCanvas);pos.X /= _zoomLevel;// 对:绝对坐标路径(创建/框选/粘贴/Drop)直接使用,缩放只影响渲染pos = e.GetPosition(DrawingCanvas);// 只有「屏幕视觉偏移」才需要转逻辑:屏幕 20px → 逻辑 20/_zoomLevelvar snapOffset = 20 / _zoomLevel;
规则一句话:画布绝对坐标一律不除缩放因子;除缩放的只有「屏幕偏移量」这种语义本来就是视觉尺寸的值。排查上,坐标换算的改动必须带缩放 ≠ 1 的用例实测,缩放等于 1 时双重除不暴露 bug,等于 2 时位置减半,一眼穿帮。
第二个的教训是回归到坑一的结论:最简单的方案往往最可靠。阵列预览最后没有继续跟「跨层画辅助线」死磕,而是直接移动真控件做预览,控件本身的位置变化就是最好的预览,零跨层、零转换误差,比画辅助线简单一百倍。TranslatePoint 能用,但在复杂布局树里不值得依赖;能用真东西表达,就别画影子。
渲染层的排查,先问四个问题
五个坑聊完,背后是同一条线:渲染与布局的坑九成不报错,画面就是不对。沉淀的打法:
先怀疑「存在性」,再看「正确性」:不显示,先查它是动态创建的还是预创建的、它所在容器的 Background 是不是 null(上一篇坑六讲过,Background=null 的区域不参与命中测试,连事件都收不到)、有没有被 Measure/Arrange 漏掉。存在性错了,后面全是空谈。 组合几何先定 FillRule,多语义拆层:任何 GeometryGroup 合并渲染前,显式指定 FillRule 并验证嵌套几何填充;实心标记和描边线各走各的 Path。 坐标只认一套基准,绝对坐标不除缩放:Down/Move/Up 同一基准;画布绝对坐标路径不除缩放因子;涉及多坐标系先画基准图。 最简方案优先:移动真控件预览 > 画辅助线;XAML 预创建占位 > 运行时 new + Add。能少依赖渲染管线的魔法,就少依赖。
有人说渲染问题打断点看布局树不就行了?断点能看到布局树,但看不到「WPF 凭什么不渲染这个动态 Add 的元素」,这类坑很多时候没有公开的根因文档,只有现象。所以渲染层的排查,第一步是猜对「该怀疑谁」:动态创建、FillRule、坐标基准、Background,这四样在 Canvas 上出事概率最高。
回想第二篇说的「看起来该响应的没响应」,渲染的版本是:看起来该显示的没显示,或显示的跟想的不一样。修渲染 bug 时多问一句「我这个方案是不是绕开了魔法,而不是在跟魔法搏斗」,比多调十次参数都值钱。
画布之外还有一类坑,跟渲染无关,纯粹是数据和状态:模型、ViewModel、属性面板、命令行四条路改同一个值,谁先谁后、谁覆盖谁,四层联动里全是取舍。那是下一篇的主题。
(四)预告:WPF 数据流与工程化,四层联动的取舍。
夜雨聆风