WPF + MVVM 实战系列04-我摔了 5 次,你看着绕

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 优先级体系,简单说就是"谁级别高听谁的":

  1. 动画(强制,最高级)
  2. Local Value(直接写在控件上的属性)
  3. Style Trigger Setter
  4. Style Setter
  5. Default Value(最低级)

问题就在这:Stroke="Red" StrokeThickness="2" 写在 Path 直接属性上 = Local Value 。DataTrigger 是 Style Trigger SetterLocal > 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 改的视觉属性(StrokeFillForegroundBackground 等),默认外观必须放 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=0X2=0Math.Min(0,0) = 0,binding 系统把这个结果缓存下来了。之后 X1 变了,但 X 这个属性没有 setter,没有触发 PropertyChanged 事件,WPF 根本不知道"值可能变了",于是它心安理得地继续用缓存里的 0。

你变了,但它不知道。像极了异地恋。

怎么救

方案一:手动联动通知

[NotifyPropertyChangedFor]X1X2 变的时候主动通知 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 直接吃 X1Y1X2Y2 四个值,现场算、现场返回,不依赖任何表达式属性。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,电脑风扇呼呼转,跟开了一百张原图似的。

为啥会这样

DecodePixelWidthBitmapImage 的属性 ,不是 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" 顺手也加上,解码完立刻关闭文件流,避免文件被锁住。

下次长记性

  • DecodePixelWidthDecodePixelHeightCacheOption 都是 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 个坑有三个共同点:

  1. WPF 文档措辞不直观,字面意思跟实际行为经常反着来
  2. 全都是静默失败------编译能过、程序能跑,但结果就是不对
  3. 解决方案都有"绕开"的性质------不是靠更深的 WPF 知识硬解,而是改变设计姿势避开坑位

最后说句心里话 :这 5 个坑我每个都花了好几个小时才爬出来,写出来不是为了显摆我有多蠢,是希望你看到的时候能说一句"哦,原来是这样",然后省下那几个小时去摸鱼。我们下篇见,记得给 BitmapImage 一个正确的家。🖼️💾

相关推荐
消费知多少2 小时前
解析勤策签约大西洋焊接费用核销实践案例
开发语言·c#
CSDN_RTKLIB2 小时前
HashSet、Dictionary
c#
曹牧2 小时前
C#与Java后台交互
java·windows·microsoft·c#
吴可可1234 小时前
最小二乘法C#实现详解
c#
足球中国5 小时前
.net DataExcel控件 2.11.1.53版发布
c#·.net·excel
rockey62715 小时前
C#脚本引擎之AScript与Jurassic、Jint对比
javascript·c#·.net·js·script·动态脚本
惊鸿醉18 小时前
Unity 实战:用讯飞 WebAPI 做一个“说普通话、播方言“的语音程序
unity·c#·游戏引擎·语音识别
略略略咯咯19 小时前
C#Unity
开发语言·unity·c#
Behavior20 小时前
Unity文字显示带竖线问题
c#·unity3d