# 前言
之前本来是跟 开发一个批量图片处理工具 写一块的,用于记录使用 WPF 开发那个工具时,过程中碰到的各种问题,后来发现越记录越多,还是新开一个文档记录了。
# 问题记录
说实话,UI 问题反而是最多的
# 枚举文件
使用 Directory.EnumerateFiles 比 Directory.GetFiles 更好,包括内存及可开始处理的时间,因为前者通过迭代器返回,后者必须一次性全部搜集完成才能返回值。
例如目录下有 1000 个文件,我们实际上只需要取其中一个,枚举的方式就可以在取得之后直接返回,避免多余的消耗。
更多测试结果见:Batch-Processing-with-Directory-EnumerateFiles
需要注意的是使用 SearchOption 会导致把无权限的文件也取出来,因此最好是配合 EnumerationOptions 使用。
# searchPattern 不支持多个匹配
由于图片格式是多样的,第一次碰到需要过滤多个后缀文件的情况,正常情况下 *.jpg 这种就行了。
以前一直以为可以用: *.jpg||*.png —— 实际上并不支持这种功能,要真传入这种格式直接就抛异常了,网上竟然还有说可以用这种方式的文章,不清楚怎么回事。
需要自己实现,例如:
| foreach (var pattern in Filter) | |
| { | |
| foreach (var item in Directory.EnumerateFiles(dirPath, pattern, SearchOption.AllDirectories)) | |
|     { | |
| return item; | |
|     } | |
| } | 
# WPF .NetCore 如何调取打开目录界面?
见 .net core 3.1 WPF 使用 FolderBrowserDialog 对象打开文件资源管理器选择文件夹
项目增加 <UseWindowsForms>true</UseWindowsForms> 引用后,按照正常代码使用 FolderBrowserDialog 即可:
| System.Windows.Forms.FolderBrowserDialog folderDialog = new System.Windows.Forms.FolderBrowserDialog(); | |
| if (folderDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) | |
| { | |
| TexturesOriginPathText.Text = folderDialog.SelectedPath; | |
| } | 
打开文件则直接调用 OpenFileDialog :
| Microsoft.Win32.OpenFileDialog fileDialog = new Microsoft.Win32.OpenFileDialog(); | |
| if ((bool)fileDialog.ShowDialog()) | |
| { | |
| TexturesOriginPathText.Text = fileDialog.FileName; | |
| } | 
# 降低 ToolTip 显示时间
在目标前添加 ToolTipService.InitialShowDelay="0"
且 ToolTip 可以支持多行:
| <Label.ToolTip> | |
|     <StackPanel Orientation="Vertical"> | |
|         <TextBlock Text="1"/> | |
|         <TextBlock Text="2"/> | |
|         <TextBlock Text="3"/> | |
|     </StackPanel> | |
| </Label.ToolTip> | 
# 普通组件也是可以添加事件的
开始想将 Button 表现上设置成 Text 的样子,结果很麻烦,各种自定义样式。后来发现 Text 实际上就可以直接添加点击事件...
# 窗口右上角按钮
例如去掉最大化功能,在 Title 添加: ResizeMode="CanMinimize"
# 限制只能输入数字
TextBox 使用 PreviewTextInput="OnPreviewTextInputLimitNumber 事件:
| private void OnPreviewTextInputLimitNumber(object sender, TextCompositionEventArgs e) | |
| { | |
| e.Handled = !int.TryParse(e.Text, out int x); | |
| } | 
不过这种方式只能限制英文键盘,对中文键盘就无效了... 只能说聊胜于无,这种简单的功能 WPF 都没提供吗?总感觉以前写界面的时候好像也用过,难道记错了。
最后在做输入改变时,在对应事件给代码的长宽赋值时,发现其实直接在 TextChanged 事件处理一次也行,例如不合法直接清空:
| /// <summary> | |
| /// 检测 TextBox 输入是否是纯数字 | |
| /// </summary> | |
| /// <param name="text"></param> | |
| private int CheckTextNumberInput(TextBox text) | |
| { | |
| int height; | |
| if (int.TryParse(ScaleHeight.Text, out height)) | |
|     { | |
| return height; | |
|     } | |
|     else | |
|     { | |
| if (!string.IsNullOrEmpty(text.Text)) | |
| MessageBox.Show("请输入数字!", "输入不合法", MessageBoxButton.OK, MessageBoxImage.Error); | |
| ScaleHeight.Clear(); | |
|     } | |
| return height; | |
| } | 
注:期间还发现有个 InputScope="Number" 的属性,然而其实完全没什么用,不是限制输入内容的。
# 检测目录权限
.NetCore 可以通过如下方式调用 GetAccessControl :
| System.Security.AccessControl.DirectorySecurity ds = new DirectoryInfo(folderDialog.SelectedPath).GetAccessControl(); | 
主要是因为 Directory.EnumerateFiles 枚举文件时,会把无权限的隐藏文件也给枚举出来,结果这个安全检测也不好用,改成 EnumerationOptions 去取文件了。
# BitmapImage 不支持重复利用
只能初始化一次,调用已添加过图片的 BeginInit 会直接抛出异常,也就是每次显示图片都必须重新创建一个对象。
同时 BitmapImage.StreamSource 对象被释放也会导致 Image 显示不出来。
# UI 特性问题多
问题真的很多,简单使用还行,要实现复杂的 UI 效果,查半天都没个能用的...
比如数据绑定,我想把一个 TextBox 组件与类中的一个变量绑定起来,使得修改 TextBox 值对应相当于修改变量 —— 我 Unity 直接写,这都是些啥跟啥,查出来的使用方式也都是要么过时、要么根本不能用的 (虽然也可能跟我版本是 WPF .NetCore 有关)。
感觉可能需要把流程先学习下,直接上手撸,做简单功能还行,想做点高级的特性就力不从心了。
下次要么就把这东西学习流程走一遍再说,要么用 WinForm,做点小东西 WPF 感觉比不上 WinForm 效率好。记得当年用 WinForm 写 Demo 速度刷刷的。
# 与代码双向数据绑定
昨晚回家看了下系统教程,数据与自定义类中的字段做双向 数据绑定 其实很简单,而且还直接支持 TextBox 与 数值 绑定,绑定后虽然不会做输入检测,不过不合法会直接在输入框外显示 红框
- 首先为 DataContext赋值
| DataContext = _helper.ConvertData; | 
- 在 ConvertData实现System.ComponentModel.INotifyPropertyChanged接口:
| public int TestWidth | |
|         { | |
|             get | |
|             { | |
| return Width; | |
|             } | |
|             set | |
|             { | |
| Width = value; | |
| PropertyChanged.Invoke(this, new PropertyChangedEventArgs("TestWidth")); | |
|             } | |
|         } | |
| private event PropertyChangedEventHandler PropertyChanged; | |
| event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged | |
|         { | |
|             add | |
|             { | |
| PropertyChanged += value; | |
|             } | |
|             remove | |
|             { | |
| PropertyChanged -= value; | |
|             } | |
|         } | 
- Xaml 处直接绑定
| <TextBox Name="ScaleWidth" Text="{Binding TestWidth}" Width="80"  VerticalContentAlignment="Center" PreviewTextInput="OnPreviewTextInputLimitNumber" InputScope="Number" TextChanged="ScaleWidth_TextChanged"/> | 
感觉还是挺好用的,了解了方法后,像之前通过主动的事件绑定实现的方式,就可以直接改成数据绑定 + 通知的模式了,而且感觉确实方便不少。
于是简单重构了一波代码,分离成 数据+Controller+界面 这种形式。
# 格式化绑定
TextBox Text 使用 StringFormat
Label Content 使用 ContentStringFormat
多数据源绑定:
| <MultiBinding StringFormat="{}啦啦啦{0}x啦啦啦{1}啦啦啦}"> | |
| 	<Binding Path="Width"/> | |
| 	<Binding Path="Height"/> | |
| </MultiBinding> | 
但是默认提供的只支持 TextBox,其它的类型得实现 IMultiValueConverter 来做。
步骤如下:
- 定义一个实现了 IMultiValueConverter接口的类:
| /// <summary> | |
|     /// 支持非 Text 的多数据源绑定 | |
|     /// </summary> | |
| public class MultiContentConverter : IMultiValueConverter | |
|     { | |
| object IMultiValueConverter.Convert(object[] values, Type targetType, object parameter, CultureInfo culture) | |
|         { | |
|             try | |
|             { | |
| return string.Format(parameter.ToString(), values); | |
|             } | |
| catch (Exception ex) | |
|             { | |
| return ex.Message; | |
|             } | |
|         } | |
| object[] IMultiValueConverter.ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) | |
|         { | |
| throw new NotImplementedException(); | |
|         } | |
|     } | 
- 在 MainWindow.xaml顶部定义Window.Resources:
| <Window.Resources> | |
|         <local:MultiContentConverter x:Key="MultiContentConverter" /> | |
|     </Window.Resources> | 
- 通过 MultiBinding的Converter(转换器)及ConverterParameter(参数)指定:
| <Label> | |
|     <MultiBinding  Converter="{StaticResource MultiContentConverter}" ConverterParameter="啦啦啦{0}x啦啦啦{1}啦啦啦"> | |
|         <Binding Path="Width"/> | |
|         <Binding Path="Height"/> | |
|     </MultiBinding> | |
| </Label> | 
# 与组件数据绑定
与父节点绑定:
| Width="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=DockPanel}, Path=ActualWidth}" | 
与指定 Name 的节点绑定:
| Width="{Binding ElementName=ScaleWidth,Path=ActualWidth} | 
# 数据绑定中进行计算
这个需要用到一个插件: CalcBinding
直接 NuGet 搜索就有了,有了这个就可以直接在绑定中进行计算,方便了很多。
下载后,在 MainWindow.xaml 最前面加上 xmlns:c="clr-namespace:CalcBinding;assembly=CalcBinding" ,然后就可以使用 c:Binding 进行计算了。
例如上面组件绑定方法,如果想取自身宽度为父容器的一半,就可以这样写:
| <GroupBox Header="原图"  Width="{c:Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=DockPanel}, Path=ActualWidth/2-10}" BorderBrush="Black" Margin="5,5,5,5" BorderThickness="1,1,1,1" > | 
另外它还支持直接 Bool 变量转 Visibility ,例如我这有个需求是:CheckBox 勾选后隐藏另外一个组件,否则显示,就可以这样实现:
| <CheckBox Name="StayInputFormatToggle" IsChecked="True" Content="保留原格式" Height="26" ToolTipService.InitialShowDelay="0" ToolTip="在处理完毕后,保存的图片与输入格式保持一致。例如修改前是 *.png,修改后也是 *.png。" VerticalAlignment="Center" VerticalContentAlignment="Center"/> | |
| <ComboBox Name="OutputFormatComboBox" Visibility="{c:Binding ElementName=StayInputFormatToggle,Path=!IsChecked,FalseToVisibility=Collapsed}" Width="70" Margin="5,0,0,0" VerticalContentAlignment="Center"/> | 
更多操作可以看这篇文章,写得挺不错的:在绑定表达式添加计算
# 激活最小化后的窗口
我增加了一个新窗口用于显示『更大』的图片对比效果,这个新界面只有一个作用:左边显示原图、右边显示修改后的图。
然后有个需求就是:没有打开的情况下,直接创建新窗口打开,不过已经打开的情况下,点击按钮应该是将这个界面激活至最上层。
然后就发现正常情况下 Window.Activate() 可以正常激活,但要是窗口已经被最小化了,那么就不行。
查了下是 WindowState 参数问题,最简单是激活前先设置为 WindowState= WindowState.Normal 即可。
不过这样的话,如果之前是最大化状态,重新激活后就会还原。
要是想记录原先的状态,则可以通过注册 Window.StateChanged 简单这样做:
| _imageCompareView.StateChanged += (o, x) => _imageCompareViewState = (o as Window).WindowState == WindowState.Minimized ? _imageCompareViewState : (o as Window).WindowState; | 
# DockPanel 自动拉伸对齐方式
DockPanel 默认情况下的作用是:自动排版,最后一个元素会拉伸填满剩余空间
剩余空间 的定义其实不止是顺序排版的情况,如果对元素设置过 DockPanel.Dock ,那么就以设置过的元素停靠为准,最后一个元素填满剩余空间。
如下所示:
| <DockPanel> | |
| <StackPanel DockPanel.Dock="Top"></StackPanel> | |
| <StackPanel DockPanel.Dock="Bottom"></StackPanel> | |
| <StackPanel></StackPanel> | |
| </DockPanel> | 
如上所示,第三个 StackPanel 不设置 DockPanel.Dock ,并放在最后,那么就会直接填满中间区域。
(注:必须放在设置过 DockPanel.Dock 的最后)
上面也是我采用的方法,如图所示:

最后的 关于 说明靠下,设置靠上,中间对比元素则填满中间空隙。
# 集合数据绑定
列表类型的组件元素,不能直接绑定通用的 List 容器 —— 我就尝试了下,就发现当 List 发生变动时,绑定 UI 根本不会同时发生改变。
经过研究发现像 ListBox、ListView、DataGrid 这种容器类组件元素,绑定的数据源必须是 可监听 的,例如 ObservableCollection 才能正确接收集合改变时的通知。
# 子窗口初始位置
可以通过 WindowStartupLocation 进行控制
| BatchProgressView view = new BatchProgressView(); | |
| view.Owner = this; | |
| view.WindowStartupLocation = WindowStartupLocation.CenterOwner; | 
需要注意的是 CenterOwner 选项的情况下,必须设置 Owner 才有效,否则也是满屏乱飞。