by 雪隐_上班了 · 掘金主页
欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可联系授权。
WPF 必踩的 5 个坑(实战翻车实录)
WPF + MVVM 实战系列 · 第 4 篇
阅读时间:约 12 分钟 · 5 个真实翻车案例
友情提示:以下内容可能引起舒适------因为你会发现"原来不是只有我这么蠢"。
一句话省流版
WPF 的"心智模型"跟 WinForms 完全不在一个星球,官方文档的措辞经常让你理解成反方向。这 5 个坑我全都亲自拿脸着地过,每个都有"我靠怎么不行 → 哦原来是这样 → 终于好了"的完整心路历程。
坑 1:IsHitTestVisible=False ------ 你以为它"独善其身",结果它"一锅端"
翻车现场
我想搞一个覆盖在图片上的"画图层",用 IsHitTestVisible=False 让鼠标穿透它,直接点到下面的图片上。逻辑很清晰对吧?
xaml
<!-- 画图层在最上面,说是"不参与命中",应该让鼠标穿过去 -->
<Canvas x:Name="DrawLayer" Background="Transparent" IsHitTestVisible="False" />
<!-- 下面的图片等着收鼠标事件 -->
<Image Source="..." />
结果呢?鼠标事件直接人间蒸发。上面的图层没反应,下面的图片也收不到。点了个寂寞。
为啥会这样
微软文档是这么写的:
Gets or sets a value that declares whether this element can possibly be returned as a hit test result from some portion of its rendered content.
翻译过来是"这个元素能否被作为命中测试结果返回"。听着像是"我自己不参与"------但实际行为是: IsHitTestVisible=False 会让整个子树 (包括所有子元素)都退出命中测试。父元素不参与,子元素也跟着一起"躺平",一票否决,整条链路直接断掉。
怎么救
用 VisualTreeHelper.HitTest 主动出击,绕过 IsHitTestVisible 的限制:
csharp
private AnnotationEntity? HitTestAnnotation(MouseButtonEventArgs e)
{
var pt = e.GetPosition(this);
AnnotationEntity? result = null;
HitTestFilterCallback filter = obj =>
{
// 跳过画图层自己
if (ReferenceEquals(obj, DrawLayer))
return HitTestFilterBehavior.ContinueSkipSelfAndChildren;
return HitTestFilterBehavior.Continue;
};
HitTestResultCallback callback = hit =>
{
if (hit.VisualHit is FrameworkElement fe && fe.DataContext is AnnotationEntity a)
{
result = a;
return HitTestResultBehavior.Stop;
}
return HitTestResultBehavior.Continue;
};
VisualTreeHelper.HitTest(this, filter, callback, new PointHitTestParameters(pt));
return result;
}
关键点 :VisualTreeHelper.HitTest 是个"不讲规矩"的狠角色,IsHitTestVisible 拦不住它,它能直接穿透找到下面的元素。
下次长记性
IsHitTestVisible影响的是"自己 + 所有后代",不是只自己- 想穿透找下层元素,用
VisualTreeHelper.HitTest - 想"父不参与但子参与",别指望
IsHitTestVisible,老老实实写VisualTreeHelper.HitTest或者在每个子控件上挂事件
坑 2:DataTrigger 改高亮改不赢默认外观 ------ 优先级比老板的级别还高
翻车现场
我想做"选中标注变黄 + 加粗",多正常的交互需求:
xaml
<Path Stroke="Red" StrokeThickness="2">
<Path.Style>
<Style TargetType="Path">
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="Stroke" Value="Yellow" />
<Setter Property="StrokeThickness" Value="4" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
Binding 触发了,IsSelected 也确实是 True,但 Path 依然坚挺地红着,打死不变黄。
为啥会这样
WPF 的 DependencyProperty 优先级体系,简单说就是"谁级别高听谁的":
- 动画(强制,最高级)
- Local Value(直接写在控件上的属性)
- Style Trigger Setter
- Style Setter
- Default Value(最低级)
问题就在这:Stroke="Red" StrokeThickness="2" 写在 Path 直接属性上 = Local Value 。DataTrigger 是 Style Trigger Setter 。Local > Trigger ,所以 Stroke="Yellow" 在 Stroke="Red" 面前就是个弟弟,永远赢不了。
怎么救
把默认外观也挪进 Style,别在控件上直接写属性:
xaml
<Path>
<Path.Style>
<Style TargetType="Path">
<!-- 默认外观从 Style 里发 -->
<Setter Property="Stroke" Value="Red" />
<Setter Property="StrokeThickness" Value="2" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="Stroke" Value="Yellow" />
<Setter Property="StrokeThickness" Value="4" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
现在 Path 没有 Local Value 了,Style Trigger 就成了最高级的"本地指令",说黄就黄,说粗就粗,没人拦得住。
下次长记性
- 任何用 DataTrigger 改的视觉属性(
Stroke、Fill、Foreground、Background等),默认外观必须放 Style 里 - 一旦你在控件上直接写了
<Path Stroke="Red">,所有 Style Trigger 都别想改动它------优先级决定了它就是"土皇帝"
坑 3:[ObservableProperty] 字段被 EF Core 当成表列 ------ 两个工具打起来了
翻车现场
我加了个新字段 IsSelected,专门给 UI 高亮用:
csharp
public partial class AnnotationEntity : ObservableObject
{
[ObservableProperty]
private bool _isSelected; // 就是给 UI 用的,跟数据库没关系
}
然后程序跑起来,21 条老标注全消失了 。debug 一看,Annotations 集合是空的,一条都没查出来。
为啥会这样
EF Core 扫描实体类时,看到 [ObservableProperty] 源生成出来的 public 属性 IsSelected,自动认为这是表里的列 ,于是跑去查 SELECT ... FROM Annotations WHERE ...,并且期待结果里包含 IsSelected 列。但表里根本没这列,模型跟 schema 对不上,EF Core 直接翻脸,啥数据都不给你。
CommunityToolkit.Mvvm 说: 我给你生成属性,好用吧?
EF Core 说: 哦,public 属性啊,那得是表列,我记住了。
你说: 等一下,那个只是 UI 临时状态啊...... 晚了,它俩已经达成共识了。
怎么救
手动写属性 + SetProperty,然后贴 [NotMapped] 让 EF Core 闭嘴:
csharp
using System.ComponentModel.DataAnnotations.Schema;
public partial class AnnotationEntity : ObservableObject
{
[NotMapped]
public bool IsSelected
{
get => _isSelected;
set => SetProperty(ref _isSelected, value);
}
private bool _isSelected;
}
[NotMapped] 告诉 EF Core:"这列跟你没关系,别瞎掺和。" 但 SetProperty 依然保留 WPF binding 需要的 PropertyChanged 通知,UI 该亮还是亮。
下次长记性
- EF Core 实体里所有 public 属性默认都是表列,不管它是怎么来的(手写的、源生成的、自动实现的)
- 不需要持久化的属性(UI 临时状态、计算字段、ViewModel 专用)一律加
[NotMapped] - 字段 vs 属性的差别对 EF Core 不重要,它只扫 public 属性
坑 4:表达式属性 binding 缓存 0 ------ 你变了但它不知道
翻车现场
我写了个计算属性,取两个坐标的较小值:
csharp
public class AnnotationEntity
{
public double X1 { get; set; }
public double X2 { get; set; }
public double X => Math.Min(X1, X2); // 算一下就返回,多优雅
}
XAML 里绑上去:
xaml
<Rectangle Width="{Binding X}" />
然后拖动改变 X1 的值,Rectangle 的 Width 始终是 0,打死不动。
为啥会这样
WPF binding 第一次评估 X 的时候,X1=0,X2=0,Math.Min(0,0) = 0,binding 系统把这个结果缓存下来了。之后 X1 变了,但 X 这个属性没有 setter,没有触发 PropertyChanged 事件,WPF 根本不知道"值可能变了",于是它心安理得地继续用缓存里的 0。
你变了,但它不知道。像极了异地恋。
怎么救
方案一:手动联动通知
用 [NotifyPropertyChangedFor] 让 X1 和 X2 变的时候主动通知 X 也变了:
csharp
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(X))]
private double _x1;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(X))]
private double _x2;
public double X => Math.Min(X1, X2);
方案二:彻底绕过(更稳)
用 MultiBinding + Converter,直接在 Converter 里拿原始值算,WPF 自动监听所有源字段的变化:
xaml
<Path>
<Path.Data>
<MultiBinding Converter="{StaticResource RectPathConverter}">
<Binding Path="X1" />
<Binding Path="Y1" />
<Binding Path="X2" />
<Binding Path="Y2" />
</MultiBinding>
</Path.Data>
</Path>
Converter 直接吃 X1、Y1、X2、Y2 四个值,现场算、现场返回,不依赖任何表达式属性。WPF 只要监听到任何一个源变了,就自动重新跑 Converter。
下次长记性
- 表达式属性
public double X => Math.Min(...)不会自己发PropertyChanged,WPF 不知道它变了 - 要么用
[NotifyPropertyChangedFor]手动联动 - 要么用
MultiBinding + Converter彻底绕开表达式属性------这是最稳的方案
坑 5:BitmapImage.DecodePixelWidth 写在 Image 上无效 ------ 写给谁看的,自己心里没数吗
翻车现场
缩略图条要加载 100 张图片,听说 DecodePixelWidth 能缩略解码省内存,好家伙安排上:
xaml
<ListBox ItemsSource="{Binding Thumbnails}">
<ListBox.ItemTemplate>
<DataTemplate>
<Image Width="120" Height="80" Source="{Binding Path}" DecodePixelWidth="120" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
结果程序跑起来,内存飙到了 1GB,电脑风扇呼呼转,跟开了一百张原图似的。
为啥会这样
DecodePixelWidth 是 BitmapImage 的属性 ,不是 Image 的。你写在 Image 标签上,WPF 看了一眼:"哦,这属性我不认识,忽略。" 然后老老实实按原图分辨率解码,缩略图只是靠 Width="120" 强行拉伸显示,内存一点没省。
你把话写给了错的人,对方根本不看。
怎么救
把 BitmapImage 内联进 Image.Source,属性写在它该在的地方:
xaml
<Image Width="120" Height="80" Stretch="Uniform">
<Image.Source>
<BitmapImage UriSource="{Binding Path}" DecodePixelWidth="120" CacheOption="OnLoad" />
</Image.Source>
</Image>
现在 DecodePixelWidth=120 在解码阶段就生效了,WPF 直接缩到 120 像素宽再塞进内存。100 张缩略图从 1GB 降到 50MB,差距比北上广的房价还大。
CacheOption="OnLoad" 顺手也加上,解码完立刻关闭文件流,避免文件被锁住。
下次长记性
DecodePixelWidth、DecodePixelHeight、CacheOption都是BitmapImage的属性 ,不是Image的- 必须在
Image.Source里内联<BitmapImage ... />才能生效 - 写
Source="{Binding Path}"再加DecodePixelWidth是不生效的,而且无声无息------不报错、不警告,就是内存爆了
5 个坑总览(记不住就截图)
| # | 坑 | 症状 | 一句话教训 |
|---|---|---|---|
| 1 | IsHitTestVisible=False |
鼠标事件消失 | 影响整棵子树,穿透用 VisualTreeHelper.HitTest |
| 2 | Local > Style Trigger |
DataTrigger 改不赢默认 | 默认外观必须放 Style,不能写控件直接属性 |
| 3 | [ObservableProperty] 污染 EF |
加字段后老数据不显示 | 非持久字段加 [NotMapped] |
| 4 | 表达式属性 binding 缓存 0 | 计算属性始终是 0 | 用 MultiBinding + Converter 绕开 |
| 5 | DecodePixelWidth 写错地方 |
缩略图内存爆炸 | 写在 <BitmapImage> 内联,不是 <Image> |
这 5 个坑有三个共同点:
- WPF 文档措辞不直观,字面意思跟实际行为经常反着来
- 全都是静默失败------编译能过、程序能跑,但结果就是不对
- 解决方案都有"绕开"的性质------不是靠更深的 WPF 知识硬解,而是改变设计姿势避开坑位
最后说句心里话 :这 5 个坑我每个都花了好几个小时才爬出来,写出来不是为了显摆我有多蠢,是希望你看到的时候能说一句"哦,原来是这样",然后省下那几个小时去摸鱼。我们下篇见,记得给 BitmapImage 一个正确的家。🖼️💾