从零实现单位转换类库与 WPF 程序(新手教程)
目录
- 你要做出来的东西长什么样
- 环境准备
- 第一步:创建解决方案与两个项目
- [第二步:类库------单位元信息(3 个小文件)](#第二步:类库——单位元信息(3 个小文件))
- 第三步:类库------单位注册表
- 第四步:类库------转换门面(核心算法)
- [第五步:WPF 项目骨架](#第五步:WPF 项目骨架)
- [第六步:只读显示控件 VaeTextBlock](#第六步:只读显示控件 VaeTextBlock)
- [第七步:可编辑输入控件 VaeTextBox](#第七步:可编辑输入控件 VaeTextBox)
- 第八步:视图模型与主窗口界面
- 第九步:编译运行与验证清单
- 原理深入:三个关键机制
- 常见问题排查
- 扩展练习
1. 你要做出来的东西长什么样
最终成果包含两个项目:
SimpleUnitConverterDemo/
├── SimpleUnitConverterDemo.sln # 解决方案
└── src/
├── SimpleUnitConverter/ # ① 类库:只负责"数值换算"
│ ├── SimpleUnitConverter.csproj
│ ├── UnitCategory.cs # 类别枚举(长度/温度)
│ ├── UnitKind.cs # 单位枚举(mm/cm/m/inch/ft/yd/°C/°F/K)
│ ├── UnitInfo.cs # 单位元信息(符号、系数)
│ ├── UnitRegistry.cs # 单位注册表
│ └── UnitConverter.cs # 转换门面(核心算法)
└── SimpleUnitConverterWpfDemo/ # ② WPF 程序:负责"显示与交互"
├── SimpleUnitConverterWpfDemo.csproj
├── App.xaml / App.xaml.cs
├── MainWindow.xaml / MainWindow.xaml.cs
├── MainViewModel.cs
└── Controls/
├── VaeTextBlock.cs # 只读显示控件
└── VaeTextBox.cs # 可编辑输入控件
职责划分(先记住这个,后面所有设计都围绕它):
| 项目 | 职责 | 不负责 |
|---|---|---|
| 类库 | 回答"25.4 mm 等于多少 inch"这类换算问题 | 不涉及任何界面 |
| WPF 程序 | 把换算结果显示出来、让用户输入和切换单位 | 不自己写换算公式 |
程序界面分三块:
- 工具栏:选择类别(长度/温度)和单位;
- 双栏对照区 :左栏只读显示(
VaeTextBlock),右栏可编辑(VaeTextBox),两栏共享同一个原始值和单位; - 多控件共享单位区:6 个控件(3 只读 + 3 可编辑)各自绑定不同的数值,但单位绑定同一个,切换单位时全部联动换算。
2. 环境准备
- 操作系统:Windows 10/11(WPF 仅支持 Windows)。
- .NET SDK:本教程基于 .NET 10。打开命令行(cmd 或 PowerShell)执行:
bash
dotnet --list-sdks
只要输出的列表里有 10.x 开头的版本即可。如果没有,到 https://dotnet.microsoft.com/download 下载安装 .NET 10 SDK。
- 编辑器:Visual Studio 2022+ 或 VS Code(装 C# Dev Kit 插件)。本文命令行操作为主,任何编辑器都适用。
3. 第一步:创建解决方案与两个项目
选一个你喜欢的位置(下文以 D:\Code 为例),依次执行:
bash
# 1. 创建解决方案
mkdir SimpleUnitConverterDemo
cd SimpleUnitConverterDemo
dotnet new sln -n SimpleUnitConverterDemo
# 2. 创建类库项目(注意:不是 WPF,纯逻辑库)
dotnet new classlib -n SimpleUnitConverter -o src/SimpleUnitConverter
dotnet sln add src/SimpleUnitConverter/SimpleUnitConverter.csproj
# 3. 创建 WPF 项目
dotnet new wpf -n SimpleUnitConverterWpfDemo -o src/SimpleUnitConverterWpfDemo
dotnet sln add src/SimpleUnitConverterWpfDemo/SimpleUnitConverterWpfDemo.csproj
# 4. 让 WPF 项目引用类库
dotnet add src/SimpleUnitConverterWpfDemo/SimpleUnitConverterWpfDemo.csproj reference src/SimpleUnitConverter/SimpleUnitConverter.csproj
# 5. 删除类库模板自带的示例文件(我们不用它)
del src\SimpleUnitConverter\Class1.cs
3.1 修改类库项目文件
用编辑器打开 src/SimpleUnitConverter/SimpleUnitConverter.csproj,替换为:
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>SimpleUnitConverter</RootNamespace>
</PropertyGroup>
</Project>
说明:
TargetFramework用net10.0(不带-windows):类库不需要 Windows 专属能力;ImplicitUsings自动引入System、System.Collections.Generic等常用命名空间,少写很多using;Nullable开启空引用检查,编译器会提醒你处理 null。
3.2 修改 WPF 项目文件
打开 src/SimpleUnitConverterWpfDemo/SimpleUnitConverterWpfDemo.csproj,确认/替换为:
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>SimpleUnitConverterWpfDemo</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SimpleUnitConverter\SimpleUnitConverter.csproj" />
</ItemGroup>
</Project>
说明:
OutputType=WinExe:Windows 窗体程序(运行时不弹黑色控制台窗口);UseWPF=true:启用 WPF 框架引用;ProjectReference:如果第 4 步命令行已添加过引用,这里会自动存在,无需重复。
4. 第二步:类库------单位元信息(3 个小文件)
类库的设计思路是经典的"注册表模式":
把所有支持的单位在程序启动时集中登记一次,之后所有换算都通过查表完成。想新增单位?只需在注册表里加一行。
元信息部分由 3 个文件组成,从"最抽象"到"最具体"依次是:类别 → 单位 → 单位的详细描述。
4.1 UnitCategory.cs ------ 类别枚举
在 src/SimpleUnitConverter/ 下新建 UnitCategory.cs:
csharp
namespace SimpleUnitConverter;
/// <summary>
/// 单位类别(本库仅支持长度与温度两类)。
/// </summary>
public enum UnitCategory
{
/// <summary>长度(基准单位:mm)。</summary>
Length,
/// <summary>温度(基准单位:°C)。</summary>
Temperature,
}
原理说明:
- 类别是"量纲"的概念:毫米和英寸都是长度,可以互相换算;毫米和摄氏度是不同类别,换算没有意义。后面转换门面会强制校验"两个单位必须同类别"。
- 每个类别必须指定一个"基准单位"(写在注释里作为约定):长度基准是 mm,温度基准是 °C。基准单位是所有换算的"中转站"------任何两个单位之间的换算都拆成"先转成基准,再从基准转出去"两步。这样 6 个长度单位之间两两换算,只需要维护 6 个"到基准的系数",而不是 6×5=30 个两两系数。
4.2 UnitKind.cs ------ 单位枚举
新建 UnitKind.cs:
csharp
namespace SimpleUnitConverter;
/// <summary>
/// 单位标识枚举:内部存储与强类型路由的身份键,每个成员在 <see cref="UnitRegistry"/> 中唯一注册。
/// </summary>
public enum UnitKind
{
// -------- 长度(基准 mm)--------
/// <summary>毫米(mm)</summary>
Millimeter,
/// <summary>厘米(cm)</summary>
Centimeter,
/// <summary>米(m)</summary>
Meter,
/// <summary>英寸(inch)</summary>
Inch,
/// <summary>英尺(ft)</summary>
Foot,
/// <summary>码(yd)</summary>
Yard,
// -------- 温度(基准 °C)--------
/// <summary>摄氏度(°C)</summary>
Celsius,
/// <summary>华氏度(°F)</summary>
Fahrenheit,
/// <summary>开尔文(K)</summary>
Kelvin,
}
原理说明:
- 为什么用枚举而不是字符串(如 "mm")当单位身份? 枚举是强类型的:拼写错误在编译期就能发现;
switch分发枚举性能也远高于字符串比较。 - 为什么枚举和类别分开两个类型? 单位会不断增加,类别基本不变。分开后"类别校验"和"单位换算"各司其职,逻辑更清晰。
4.3 UnitInfo.cs ------ 单位元信息
新建 UnitInfo.cs:
csharp
namespace SimpleUnitConverter;
/// <summary>
/// 单位元信息:枚举身份、标准符号、所属类别、到基准单位的换算系数。
/// </summary>
/// <remarks>
/// 不可变值对象,线程安全。温度为非线性换算,<see cref="FactorToBase"/> 仅作占位,实际转换由专用公式完成。
/// </remarks>
public sealed class UnitInfo
{
/// <summary>单位身份(枚举)。</summary>
public UnitKind Unit { get; }
/// <summary>单位的标准符号(如 "mm"、"°C"),用于展示。</summary>
public string Symbol { get; }
/// <summary>单位所属类别。</summary>
public UnitCategory Category { get; }
/// <summary>到类别基准单位的换算系数(1 单位 = Factor 基准单位);温度类别仅占位。</summary>
public double FactorToBase { get; }
/// <summary>初始化 <see cref="UnitInfo"/> 的新实例。</summary>
public UnitInfo(UnitKind unit, string symbol, UnitCategory category, double factorToBase)
{
Unit = unit;
Symbol = symbol;
Category = category;
FactorToBase = factorToBase;
}
/// <summary>返回单位的标准符号。</summary>
public override string ToString() => Symbol;
}
原理说明:
FactorToBase的含义 :1 个该单位 = FactorToBase 个基准单位。例如1 inch = 25.4 mm,所以英寸的系数是 25.4。- 温度的系数是"占位":温度换算不是简单乘除(0 °C ≠ 0 K),必须用专用公式。但为了统一数据结构,温度单位也填系数 1.0,实际换算时走专用分支(见第六步)。
- 不可变设计 (只有
get,没有set):对象创建后状态不会变,多线程同时读取绝对安全,不需要加锁。
5. 第三步:类库------单位注册表
新建 src/SimpleUnitConverter/UnitRegistry.cs:
csharp
namespace SimpleUnitConverter;
/// <summary>
/// 单位注册表:集中注册所有受支持的单位,提供枚举解析与类别查询。
/// </summary>
/// <remarks>
/// 线程安全:字典在静态构造函数中填充,之后只读。
/// 无异常设计:未注册的单位返回 null。
/// </remarks>
public static class UnitRegistry
{
// key: 单位枚举,value: 单位信息 ------ 主索引
private static readonly Dictionary<UnitKind, UnitInfo> _unitMap = new();
// key: 类别,value: 该类别下所有单位
private static readonly Dictionary<UnitCategory, List<UnitInfo>> _byCategory = new();
static UnitRegistry()
{
// 1. 长度(基准 mm)
Register(new UnitInfo(UnitKind.Millimeter, "mm", UnitCategory.Length, 1.0));
Register(new UnitInfo(UnitKind.Centimeter, "cm", UnitCategory.Length, 10.0));
Register(new UnitInfo(UnitKind.Meter, "m", UnitCategory.Length, 1000.0));
Register(new UnitInfo(UnitKind.Inch, "inch", UnitCategory.Length, 25.4));
Register(new UnitInfo(UnitKind.Foot, "ft", UnitCategory.Length, 304.8));
Register(new UnitInfo(UnitKind.Yard, "yd", UnitCategory.Length, 914.4));
// 2. 温度(基准 °C;系数仅占位,实际走非线性公式)
Register(new UnitInfo(UnitKind.Celsius, "°C", UnitCategory.Temperature, 1.0));
Register(new UnitInfo(UnitKind.Fahrenheit, "°F", UnitCategory.Temperature, 1.0));
Register(new UnitInfo(UnitKind.Kelvin, "K", UnitCategory.Temperature, 1.0));
}
private static void Register(UnitInfo info)
{
_unitMap[info.Unit] = info;
if (!_byCategory.TryGetValue(info.Category, out var list))
{
list = [];
_byCategory[info.Category] = list;
}
list.Add(info);
}
/// <summary>按枚举解析单位信息(O(1) 查找,不抛异常);未注册返回 null。</summary>
public static UnitInfo? Resolve(UnitKind unit)
=> _unitMap.TryGetValue(unit, out var info) ? info : null;
/// <summary>获取指定类别下所有已注册单位。</summary>
public static IReadOnlyList<UnitInfo> GetUnits(UnitCategory category)
=> _byCategory.TryGetValue(category, out var list) ? list : [];
}
原理说明:
- 静态构造函数 :
static UnitRegistry()会在第一次使用这个类时由 .NET 自动执行,且整个进程只执行一次。所以"注册"只发生一次,之后字典只读。 - 两个索引各司其职 :
_unitMap:枚举 → 元信息,用于换算时快速查系数(字典查找,O(1));_byCategory:类别 → 单位列表,用于界面下拉框列出"长度类有哪些单位"。
- 无异常设计 :
Resolve查不到返回null而不是抛异常。调用方用?.或is null判断即可,避免异常影响性能和流程控制。 - 想新增单位? 只需在静态构造函数里加一行
Register(...),其他代码一行都不用改------这就是注册表模式的好处。
6. 第四步:类库------转换门面(核心算法)
新建 src/SimpleUnitConverter/UnitConverter.cs。这是类库唯一对外暴露的换算入口:
csharp
namespace SimpleUnitConverter;
/// <summary>
/// 单位转换门面:长度走线性系数(value × FactorToBase),温度走专用非线性公式。
/// </summary>
/// <remarks>
/// 线程安全:全部为静态无状态方法。
/// 无异常设计:非法输入(非有限数 / 单位未注册 / 类别不匹配)一律返回 <see cref="double.NaN"/>。
/// </remarks>
public static class UnitConverter
{
// 温度换算常数
private const double FahrenheitOffset = 32.0;
private const double KelvinOffset = 273.15;
// ==================== 单位间转换 ====================
/// <summary>
/// 将 <paramref name="value"/> 从 <paramref name="fromUnit"/> 转换为 <paramref name="toUnit"/>。
/// </summary>
/// <returns>转换结果;非有限数 / 单位未注册 / 类别不匹配时返回 <see cref="double.NaN"/>。</returns>
public static double Convert(double value, UnitKind fromUnit, UnitKind toUnit)
{
if (!double.IsFinite(value))
{
return double.NaN;
}
var from = UnitRegistry.Resolve(fromUnit);
var to = UnitRegistry.Resolve(toUnit);
if (from is null || to is null || from.Category != to.Category)
{
return double.NaN;
}
return FromBase(ToBase(value, from), to);
}
// ==================== 与类别基准单位互转 ====================
/// <summary>将 <paramref name="value"/> 从 <paramref name="unit"/> 转换到其类别基准单位(长度 mm / 温度 °C)。</summary>
public static double ConvertToBase(double value, UnitKind unit)
{
if (!double.IsFinite(value))
{
return double.NaN;
}
var info = UnitRegistry.Resolve(unit);
return info is null ? double.NaN : ToBase(value, info);
}
/// <summary>将类别基准单位下的 <paramref name="baseValue"/> 转换为 <paramref name="unit"/> 单位。</summary>
public static double FromBase(double baseValue, UnitKind unit)
{
if (!double.IsFinite(baseValue))
{
return double.NaN;
}
var info = UnitRegistry.Resolve(unit);
return info is null ? double.NaN : FromBase(baseValue, info);
}
// ==================== 元信息查询 ====================
/// <summary>获取指定类别下所有已注册单位。</summary>
public static IReadOnlyList<UnitInfo> GetUnits(UnitCategory category) => UnitRegistry.GetUnits(category);
/// <summary>查询单个单位的元信息;未注册返回 null。</summary>
public static UnitInfo? GetUnitInfo(UnitKind unit) => UnitRegistry.Resolve(unit);
// ==================== 内部分发 ====================
/// <summary>任意单位值 → 类别基准值。温度走专用公式,长度走线性系数。</summary>
private static double ToBase(double value, UnitInfo unit) => unit.Category switch
{
UnitCategory.Temperature => ToCelsius(value, unit.Unit),
_ => value * unit.FactorToBase,
};
/// <summary>类别基准值 → 任意单位值。温度走专用公式,长度走线性系数。</summary>
private static double FromBase(double baseValue, UnitInfo unit) => unit.Category switch
{
UnitCategory.Temperature => FromCelsius(baseValue, unit.Unit),
_ => baseValue / unit.FactorToBase,
};
/// <summary>任意温度单位 → 摄氏度(基准)。</summary>
private static double ToCelsius(double value, UnitKind unit) => unit switch
{
UnitKind.Celsius => value,
UnitKind.Fahrenheit => (value - FahrenheitOffset) * 5.0 / 9.0,
UnitKind.Kelvin => value - KelvinOffset,
_ => double.NaN,
};
/// <summary>摄氏度(基准)→ 任意温度单位。</summary>
private static double FromCelsius(double celsius, UnitKind unit) => unit switch
{
UnitKind.Celsius => celsius,
UnitKind.Fahrenheit => celsius * 9.0 / 5.0 + FahrenheitOffset,
UnitKind.Kelvin => celsius + KelvinOffset,
_ => double.NaN,
};
}
原理说明(本类库最核心的一页):
-
统一换算模型:任何转换 = 两步基准中转
25.4 inch ──ToBase──▶ 645.16 mm ──FromBase──▶ 64.516 cm (基准中转站)Convert内部就一行:FromBase(ToBase(value, from), to)。所有单位只需维护"自己到基准"的关系。 -
长度是线性换算 :
mm 值 = 原值 × 系数,反向原值 = mm 值 ÷ 系数。乘除互为逆运算,天然对称。 -
温度是非线性换算:因为温度有"偏移量"(0 °C 不是绝对零点),必须用公式:
°F = °C × 9/5 + 32,逆运算°C = (°F − 32) × 5/9K = °C + 273.15,逆运算°C = K − 273.15
所以
ToBase/FromBase用switch按类别分发:温度走ToCelsius/FromCelsius,其余走线性系数。 -
double.NaN作为统一失败信号 :输入不是有限数、单位没注册、类别不匹配,都返回NaN。调用方用double.IsNaN(result)一个判断就能覆盖所有失败情况,不用 try-catch。 -
switch 表达式:C# 8+ 语法,unit switch { A => 结果A, B => 结果B, _ => 默认 },比 if-else 链简洁。
到这里类库就完成了。 可以先做个快速验证:
bash
dotnet build src/SimpleUnitConverter
应显示"0 个错误"。
7. 第五步:WPF 项目骨架
7.1 App.xaml(应用入口声明)
模板已生成,内容保持如下即可:
xml
<Application x:Class="SimpleUnitConverterWpfDemo.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>
StartupUri 指定程序启动时显示哪个窗口。
7.2 App.xaml.cs
csharp
using System.Windows;
namespace SimpleUnitConverterWpfDemo;
/// <summary>应用入口。</summary>
public partial class App : Application
{
}
7.3 MainWindow.xaml.cs(窗口代码后置)
csharp
using System.Windows;
namespace SimpleUnitConverterWpfDemo;
/// <summary>主窗口:展示只读显示控件与可编辑输入控件,二者共享同一基准值绑定。</summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
}
原理说明:
InitializeComponent()由 WPF 编译器生成,负责加载 XAML;DataContext = new MainViewModel()是 MVVM 模式 的关键一步:窗口里所有{Binding XXX}都会去MainViewModel里找同名属性。界面(XAML)和数据逻辑(ViewModel)就此分离。
8. 第六步:只读显示控件 VaeTextBlock
先在 WPF 项目下新建 Controls 文件夹。这个控件继承 WPF 自带的 TextBlock,在它基础上增加三个"依赖属性"。
什么是依赖属性(DependencyProperty)? WPF 特有的属性系统。普通 C# 属性只是"存个值",依赖属性额外支持:数据绑定 (
{Binding})、样式、动画等。想让你的自定义控件属性能写{Binding ...},就必须声明为依赖属性。
新建 Controls/VaeTextBlock.cs:
csharp
using System.Windows;
using System.Windows.Controls;
using SimpleUnitConverter;
namespace SimpleUnitConverterWpfDemo.Controls;
/// <summary>
/// 单位显示 TextBlock(继承 <see cref="TextBlock"/>):
/// 内部存储 原始值(RawValue)/ 显示值(DisplayValue)/ 当前单位(Unit),全部为可绑定依赖属性。
/// 显示文本 = 显示值 + 单位符号(如 "25.4 mm")。
/// </summary>
/// <remarks>
/// 转换原理(基准值 ↔ 显示值):
/// <list type="bullet">
/// <item>原始值 → 显示值:<see cref="UnitConverter.FromBase"/>(RawValue / Unit 变化时触发)</item>
/// <item>显示值 → 原始值:<see cref="UnitConverter.ConvertToBase"/>(外部绑定修改 DisplayValue 时触发,保持双向一致)</item>
/// </list>
/// </remarks>
public class VaeTextBlock : TextBlock
{
/// <summary>原始值(类别基准单位下的数值,如长度 mm、温度 °C)。</summary>
public static readonly DependencyProperty RawValueProperty =
DependencyProperty.Register(
nameof(RawValue), typeof(double), typeof(VaeTextBlock),
new FrameworkPropertyMetadata(double.NaN, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnRawPropertyChanged));
/// <summary>当前单位。</summary>
public static readonly DependencyProperty UnitProperty =
DependencyProperty.Register(
nameof(Unit), typeof(UnitKind), typeof(VaeTextBlock),
new FrameworkPropertyMetadata(UnitKind.Millimeter, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnRawPropertyChanged));
/// <summary>显示值(原始值按当前单位换算后的结果)。</summary>
public static readonly DependencyProperty DisplayValueProperty =
DependencyProperty.Register(
nameof(DisplayValue), typeof(double), typeof(VaeTextBlock),
new FrameworkPropertyMetadata(double.NaN, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnDisplayValueChanged));
/// <summary>同步保护标志:防止 原始值 ↔ 显示值 相互触发造成递归。</summary>
private bool _isUpdating;
public double RawValue
{
get => (double)GetValue(RawValueProperty);
set => SetValue(RawValueProperty, value);
}
public UnitKind Unit
{
get => (UnitKind)GetValue(UnitProperty);
set => SetValue(UnitProperty, value);
}
public double DisplayValue
{
get => (double)GetValue(DisplayValueProperty);
set => SetValue(DisplayValueProperty, value);
}
private static void OnRawPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
=> ((VaeTextBlock)d).UpdateDisplay();
private static void OnDisplayValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
=> ((VaeTextBlock)d).UpdateRaw();
/// <summary>原始值 → 显示值 → 拼接单位符号显示。</summary>
private void UpdateDisplay()
{
if (_isUpdating)
{
return;
}
_isUpdating = true;
try
{
var display = UnitConverter.FromBase(RawValue, Unit);
DisplayValue = display;
Text = double.IsNaN(display) ? string.Empty : $"{display} {UnitSymbol}";
}
finally
{
_isUpdating = false;
}
}
/// <summary>显示值(外部绑定修改)→ 原始值,保持双向一致。</summary>
private void UpdateRaw()
{
if (_isUpdating)
{
return;
}
_isUpdating = true;
try
{
RawValue = UnitConverter.ConvertToBase(DisplayValue, Unit);
}
finally
{
_isUpdating = false;
}
}
/// <summary>当前单位的显示符号(如 "mm"、"°C")。</summary>
private string UnitSymbol => UnitConverter.GetUnitInfo(Unit)?.Symbol ?? Unit.ToString();
}
原理说明(控件机制的核心):
-
三个依赖属性 = 控件的全部状态:
RawValue:原始值,永远以类别基准单位计(长度 mm、温度 °C)。这是控件的"真值",业务数据只存这一份;Unit:当前显示单位;DisplayValue:显示值 = RawValue 换算到 Unit 后的数值。
-
依赖属性声明套路(固定写法,照抄即可):
- 一个
public static readonly DependencyProperty XxxProperty字段,调用DependencyProperty.Register(属性名, 属性类型, 拥有者类型, 元数据); - 元数据里:默认值 +
BindsTwoWayByDefault(默认支持双向绑定)+ 变更回调(值一变就自动调用你指定的方法); - 再包一个普通 C# 属性(
GetValue/SetValue)方便代码访问。
- 一个
-
联动逻辑:
RawValue或Unit变了 →UpdateDisplay():从基准值换算出显示值,拼上单位符号写到Text;DisplayValue被外部改了 →UpdateRaw():反向换算回基准值。
-
_isUpdating防递归(关键!) :UpdateDisplay里给DisplayValue赋值会触发OnDisplayValueChanged→ 又调UpdateRaw→ 又改RawValue→ 又触发UpdateDisplay......无限循环。设置_isUpdating = true后,回调开头检测到标志就直接 return,循环被掐断。这是所有"双向联动"控件的通用套路。 -
为什么 RawValue 用基准单位存储? 想象 10 个控件共享一个业务值:如果各自存"自己单位下的值",切单位时要挨个换算;统一存基准值,切单位只是"换个显示方式",数据源永远只有一份,不会出现精度漂移和不一致。
9. 第七步:可编辑输入控件 VaeTextBox
新建 Controls/VaeTextBox.cs。在只读控件的基础上增加"用户输入 → 换算回基准值"的能力:
csharp
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using SimpleUnitConverter;
namespace SimpleUnitConverterWpfDemo.Controls;
/// <summary>
/// 单位输入 TextBox(继承 <see cref="TextBox"/>):
/// 内部存储 原始值(RawValue)/ 显示值(DisplayValue)/ 当前单位(Unit),支持双向传递。
/// <list type="bullet">
/// <item>UI 编辑文本 → 按当前单位解析 → 换算为基准值 → 更新 RawValue(推回绑定源)</item>
/// <item>RawValue 变化(外部/绑定)→ 按当前单位换算 → 自动更新显示文本</item>
/// </list>
/// </summary>
/// <remarks>
/// 交互约定:聚焦时仅显示数字(去掉单位符号)方便编辑;失焦后提交并规范化为 "数值 + 单位符号"。
/// </remarks>
public class VaeTextBox : TextBox
{
/// <summary>原始值(类别基准单位下的数值,如长度 mm、温度 °C)。</summary>
public static readonly DependencyProperty RawValueProperty =
DependencyProperty.Register(
nameof(RawValue), typeof(double), typeof(VaeTextBox),
new FrameworkPropertyMetadata(double.NaN, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnRawPropertyChanged));
/// <summary>当前单位。</summary>
public static readonly DependencyProperty UnitProperty =
DependencyProperty.Register(
nameof(Unit), typeof(UnitKind), typeof(VaeTextBox),
new FrameworkPropertyMetadata(UnitKind.Millimeter, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnRawPropertyChanged));
/// <summary>显示值(原始值按当前单位换算后的结果)。</summary>
public static readonly DependencyProperty DisplayValueProperty =
DependencyProperty.Register(
nameof(DisplayValue), typeof(double), typeof(VaeTextBox),
new FrameworkPropertyMetadata(double.NaN, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnDisplayValueChanged));
/// <summary>同步保护标志:防止 文本 ↔ RawValue 相互触发造成递归。</summary>
private bool _isUpdating;
public VaeTextBox()
{
TextChanged += OnTextChanged;
GotKeyboardFocus += OnGotKeyboardFocus;
LostKeyboardFocus += OnLostKeyboardFocus;
}
public double RawValue
{
get => (double)GetValue(RawValueProperty);
set => SetValue(RawValueProperty, value);
}
public UnitKind Unit
{
get => (UnitKind)GetValue(UnitProperty);
set => SetValue(UnitProperty, value);
}
public double DisplayValue
{
get => (double)GetValue(DisplayValueProperty);
set => SetValue(DisplayValueProperty, value);
}
private static void OnRawPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
=> ((VaeTextBox)d).UpdateDisplay();
private static void OnDisplayValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
=> ((VaeTextBox)d).UpdateRaw();
private void OnTextChanged(object sender, TextChangedEventArgs e)
{
if (_isUpdating)
{
return;
}
TryCommitText();
}
private void OnGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
if (_isUpdating)
{
return;
}
// 聚焦时仅显示数字(去掉单位符号),方便编辑
var numeric = double.IsNaN(DisplayValue)
? string.Empty
: DisplayValue.ToString(CultureInfo.InvariantCulture);
if (!string.IsNullOrEmpty(numeric) && numeric != Text)
{
_isUpdating = true;
try
{
Text = numeric;
}
finally
{
_isUpdating = false;
}
}
}
private void OnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
if (_isUpdating)
{
return;
}
// 失焦时:先提交文本(更新 RawValue),再按当前 RawValue 规范化显示(数值 + 单位符号)
TryCommitText();
UpdateDisplay();
}
/// <summary>
/// 将当前文本按当前单位解析并换算为基准值,更新 RawValue(推回绑定源)。
/// </summary>
private void TryCommitText()
{
if (string.IsNullOrWhiteSpace(Text))
{
SetRawValue(double.NaN);
return;
}
if (double.TryParse(Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
{
SetRawValue(UnitConverter.ConvertToBase(value, Unit));
}
}
/// <summary>设置 RawValue(期间抑制 UpdateDisplay,避免重写用户正在输入的文本)。</summary>
private void SetRawValue(double value)
{
_isUpdating = true;
try
{
RawValue = value;
}
finally
{
_isUpdating = false;
}
}
/// <summary>RawValue / Unit 变化 → 按当前单位换算 → 更新显示文本。</summary>
private void UpdateDisplay()
{
if (_isUpdating)
{
return;
}
_isUpdating = true;
try
{
var display = UnitConverter.FromBase(RawValue, Unit);
DisplayValue = display;
Text = double.IsNaN(display) ? string.Empty : $"{display} {UnitSymbol}";
}
finally
{
_isUpdating = false;
}
}
/// <summary>显示值(外部绑定修改)→ 换算回基准值 → 更新 RawValue,保持双向一致。</summary>
private void UpdateRaw()
{
if (_isUpdating)
{
return;
}
_isUpdating = true;
try
{
RawValue = UnitConverter.ConvertToBase(DisplayValue, Unit);
}
finally
{
_isUpdating = false;
}
}
/// <summary>当前单位的显示符号(如 "mm"、"°C")。</summary>
private string UnitSymbol => UnitConverter.GetUnitInfo(Unit)?.Symbol ?? Unit.ToString();
}
原理说明(相比只读控件多出的三件事):
-
构造函数里订阅三个事件:
TextChanged:用户每敲一个字符就尝试解析提交(TryCommitText),保证绑定源实时更新;GotKeyboardFocus:聚焦时把"25.4 mm"变成"25.4"------去掉单位符号,用户直接改数字;LostKeyboardFocus:失焦时提交文本并恢复"25.4 mm"的规范显示。
-
TryCommitText的换算方向 :用户输入的是"当前单位下的值"(比如单位是 inch 时输入 1),所以用ConvertToBase把它换算回基准值(25.4 mm)存进RawValue。方向别搞反:输入是显示单位,存储是基准单位。 -
SetRawValue里也要抑制_isUpdating:提交文本时更新RawValue会触发UpdateDisplay,后者会重写Text------把用户正在输入的内容覆盖掉。抑制后,用户输入过程不被打扰。 -
CultureInfo.InvariantCulture:强制按.作为小数点解析/格式化,避免某些系统区域设置用,作小数点导致解析失败或显示不一致。
10. 第八步:视图模型与主窗口界面
10.1 MainViewModel.cs
在 WPF 项目根目录新建 MainViewModel.cs:
csharp
using System.ComponentModel;
using System.Runtime.CompilerServices;
using SimpleUnitConverter;
namespace SimpleUnitConverterWpfDemo;
/// <summary>
/// 主视图模型:为 VaeTextBlock / VaeTextBox 提供数据源。
/// 原始值为"类别基准单位"下的值(长度基准 mm、温度基准 °C)。
/// </summary>
public class MainViewModel : INotifyPropertyChanged
{
// ==================== 类别与单位选择 ====================
/// <summary>类别选项(工具栏 ComboBox 数据源)。</summary>
public IReadOnlyList<CategoryOption> CategoryOptions { get; } =
[
new(UnitCategory.Length, "长度"),
new(UnitCategory.Temperature, "温度"),
];
private UnitCategory _currentCategory = UnitCategory.Length;
/// <summary>当前类别,切换后单位列表与原始值联动。</summary>
public UnitCategory CurrentCategory
{
get => _currentCategory;
set
{
if (_currentCategory == value)
{
return;
}
_currentCategory = value;
OnPropertyChanged();
OnPropertyChanged(nameof(CurrentUnits));
CurrentUnit = CurrentUnits[0].Unit;
RawValue = DefaultRawValue(value);
}
}
/// <summary>当前类别下的单位列表(ComboBox 数据源)。</summary>
public IReadOnlyList<UnitInfo> CurrentUnits => UnitConverter.GetUnits(CurrentCategory);
private UnitKind _currentUnit = UnitKind.Millimeter;
/// <summary>当前单位(左栏 VaeTextBlock 与右栏 VaeTextBox 共用)。</summary>
public UnitKind CurrentUnit
{
get => _currentUnit;
set { _currentUnit = value; OnPropertyChanged(); }
}
// ==================== 共享原始值 ====================
private double _rawValue = 25.4;
/// <summary>共享原始值(类别基准单位下的值):左栏只读显示、右栏可编辑回写。</summary>
public double RawValue
{
get => _rawValue;
set { _rawValue = value; OnPropertyChanged(); }
}
// ==================== 多控件共享单位(统一切换) ====================
private UnitKind _sharedUnit = UnitKind.Millimeter;
/// <summary>共享单位:下方多个 VaeTextBlock / VaeTextBox 绑定同一单位,切换后全部联动换算。</summary>
public UnitKind SharedUnit
{
get => _sharedUnit;
set { _sharedUnit = value; OnPropertyChanged(); }
}
private double _sharedRawValueA = 1270.0;
/// <summary>原始值 A(基准单位,只读控件绑定)。</summary>
public double SharedRawValueA
{
get => _sharedRawValueA;
set { _sharedRawValueA = value; OnPropertyChanged(); }
}
private double _sharedRawValueB = 25.4;
/// <summary>原始值 B(基准单位,只读控件绑定)。</summary>
public double SharedRawValueB
{
get => _sharedRawValueB;
set { _sharedRawValueB = value; OnPropertyChanged(); }
}
private double _sharedRawValueC = 12.7;
/// <summary>原始值 C(基准单位,只读控件绑定)。</summary>
public double SharedRawValueC
{
get => _sharedRawValueC;
set { _sharedRawValueC = value; OnPropertyChanged(); }
}
private double _editSharedRawValueA = 1270.0;
/// <summary>原始值 A(基准单位,可编辑控件绑定,编辑后自动回写)。</summary>
public double EditSharedRawValueA
{
get => _editSharedRawValueA;
set { _editSharedRawValueA = value; OnPropertyChanged(); }
}
private double _editSharedRawValueB = 25.4;
/// <summary>原始值 B(基准单位,可编辑控件绑定,编辑后自动回写)。</summary>
public double EditSharedRawValueB
{
get => _editSharedRawValueB;
set { _editSharedRawValueB = value; OnPropertyChanged(); }
}
private double _editSharedRawValueC = 12.7;
/// <summary>原始值 C(基准单位,可编辑控件绑定,编辑后自动回写)。</summary>
public double EditSharedRawValueC
{
get => _editSharedRawValueC;
set { _editSharedRawValueC = value; OnPropertyChanged(); }
}
/// <summary>切换类别时的默认原始值。</summary>
private static double DefaultRawValue(UnitCategory category) => category switch
{
UnitCategory.Temperature => 25.0,
_ => 25.4,
};
/// <summary>属性变更通知。</summary>
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>类别下拉框选项(显示名 + 类别枚举)。</summary>
public sealed record CategoryOption(UnitCategory Category, string DisplayName)
{
public override string ToString() => DisplayName;
}
原理说明:
-
INotifyPropertyChanged是 ViewModel 的灵魂 :WPF 绑定默认只"读一次"。属性值变了之后想让界面跟着变,必须在 setter 里触发PropertyChanged事件。OnPropertyChanged这个辅助方法 +[CallerMemberName]特性(编译器自动填上调用者的属性名)是标准写法。 -
CurrentUnits是计算属性 :每次访问都调UnitConverter.GetUnits(CurrentCategory)现查。类别切换后必须手动OnPropertyChanged(nameof(CurrentUnits))通知界面"这个属性变了,重新取值",下拉框才会刷新。 -
类别切换的联动 (
CurrentCategorysetter):切类别 → 单位列表变 → 当前单位重置为新类别第一个单位 → 原始值重置为该类别的默认值(长度 25.4 mm,温度 25 °C)。 -
多控件共享单位 :
SharedUnit只有一份,6 个控件的Unit都绑定它。切换单位时,WPF 把新值推给 6 个控件的UnitProperty,每个控件各自触发UpdateDisplay换算------联动不需要写任何额外代码,绑定机制自动完成。
10.2 MainWindow.xaml
替换 MainWindow.xaml 全部内容:
xml
<Window x:Class="SimpleUnitConverterWpfDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:SimpleUnitConverterWpfDemo.Controls"
Title="精简单位转换示例(长度 / 温度)"
Width="820" Height="640" WindowStartupLocation="CenterScreen">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- ==================== 第 1 行:工具栏(类别 + 单位选择) ==================== -->
<Border Grid.Row="0" Background="#F0F4F8" BorderBrush="#C8D4E0" BorderThickness="1"
CornerRadius="6" Padding="12">
<StackPanel Orientation="Horizontal">
<TextBlock Text="类别:" VerticalAlignment="Center" FontWeight="Bold" />
<ComboBox Width="100" Margin="8,0,0,0" VerticalAlignment="Center"
ItemsSource="{Binding CategoryOptions}"
DisplayMemberPath="DisplayName"
SelectedValuePath="Category"
SelectedValue="{Binding CurrentCategory, Mode=TwoWay}" />
<TextBlock Text="单位:" VerticalAlignment="Center" FontWeight="Bold" Margin="16,0,0,0" />
<ComboBox Width="120" Margin="8,0,0,0" VerticalAlignment="Center"
ItemsSource="{Binding CurrentUnits}"
DisplayMemberPath="Symbol"
SelectedValuePath="Unit"
SelectedValue="{Binding CurrentUnit, Mode=TwoWay}" />
</StackPanel>
</Border>
<!-- ==================== 第 2 行:双栏对照(左只读 / 右可编辑,共享 RawValue 与 Unit) ==================== -->
<Grid Grid.Row="1" Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 左栏:只读显示(VaeTextBlock) -->
<Border Grid.Column="0" Background="#FFFFFF" BorderBrush="#C8D4E0" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel>
<TextBlock Text="只读显示(VaeTextBlock)" FontSize="14" FontWeight="Bold"
Foreground="#2C5F8A" Margin="0,0,0,10" />
<TextBlock Text="显示值 + 单位符号,随单位切换自动换算:" FontSize="12"
Foreground="Gray" Margin="0,0,0,6" TextWrapping="Wrap" />
<controls:VaeTextBlock RawValue="{Binding RawValue, Mode=OneWay}"
Unit="{Binding CurrentUnit}"
FontSize="28" FontWeight="Bold" Foreground="#1A3C5E" />
<TextBlock Margin="0,10,0,0" FontSize="12" Foreground="Gray" TextWrapping="Wrap"
Text="内部原始值(类别基准单位):" />
<TextBlock Text="{Binding RawValue}" FontSize="12" Foreground="Gray" />
</StackPanel>
</Border>
<!-- 右栏:可编辑输入(VaeTextBox) -->
<Border Grid.Column="2" Background="#FFFFFF" BorderBrush="#C8D4E0" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel>
<TextBlock Text="可编辑输入(VaeTextBox)" FontSize="14" FontWeight="Bold"
Foreground="#2C5F8A" Margin="0,0,0,10" />
<TextBlock Text="聚焦仅显示数字,失焦后显示 数值 + 单位;编辑自动换算回基准值:" FontSize="12"
Foreground="Gray" Margin="0,0,0,6" TextWrapping="Wrap" />
<controls:VaeTextBox Width="220" HorizontalAlignment="Left"
RawValue="{Binding RawValue, Mode=TwoWay}"
Unit="{Binding CurrentUnit}"
FontSize="20" FontWeight="Bold" />
<TextBlock Margin="0,10,0,0" FontSize="12" Foreground="Gray" TextWrapping="Wrap"
Text="左右两栏共享同一 RawValue 与 Unit:右栏编辑 → 左栏实时刷新;切换单位 → 两栏同步换算。" />
</StackPanel>
</Border>
</Grid>
<!-- ==================== 第 3 行:多控件绑定一个单位(统一切换) ==================== -->
<Border Grid.Row="2" Background="#FFFFFF" BorderBrush="#C8D4E0" BorderThickness="1"
CornerRadius="6" Padding="16" Margin="0,12,0,0">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="多控件绑定一个单位(统一切换)" FontSize="14" FontWeight="Bold"
Foreground="#2C5F8A" VerticalAlignment="Center" />
<TextBlock Text="共享单位:" VerticalAlignment="Center" Margin="24,0,0,0" />
<ComboBox Width="120" Margin="8,0,0,0" VerticalAlignment="Center"
ItemsSource="{Binding CurrentUnits}"
DisplayMemberPath="Symbol"
SelectedValuePath="Unit"
SelectedValue="{Binding SharedUnit, Mode=TwoWay}" />
<TextBlock Text="切换后下方 6 个控件全部联动换算" FontSize="12" Foreground="Gray"
VerticalAlignment="Center" Margin="12,0,0,0" />
</StackPanel>
<!-- 三个只读控件(VaeTextBlock)绑定同一 SharedUnit -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="VaeTextBlock A:" VerticalAlignment="Center" Width="110" />
<controls:VaeTextBlock RawValue="{Binding SharedRawValueA, Mode=OneWay}"
Unit="{Binding SharedUnit}"
FontSize="16" FontWeight="Bold" Foreground="#1A3C5E" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="VaeTextBlock B:" VerticalAlignment="Center" Width="110" />
<controls:VaeTextBlock RawValue="{Binding SharedRawValueB, Mode=OneWay}"
Unit="{Binding SharedUnit}"
FontSize="16" FontWeight="Bold" Foreground="#1A3C5E" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="VaeTextBlock C:" VerticalAlignment="Center" Width="110" />
<controls:VaeTextBlock RawValue="{Binding SharedRawValueC, Mode=OneWay}"
Unit="{Binding SharedUnit}"
FontSize="16" FontWeight="Bold" Foreground="#1A3C5E" />
</StackPanel>
<!-- 三个可编辑控件(VaeTextBox)绑定同一 SharedUnit -->
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="VaeTextBox A:" VerticalAlignment="Center" Width="110" />
<controls:VaeTextBox Width="160"
RawValue="{Binding EditSharedRawValueA, Mode=TwoWay}"
Unit="{Binding SharedUnit}"
FontSize="14" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="VaeTextBox B:" VerticalAlignment="Center" Width="110" />
<controls:VaeTextBox Width="160"
RawValue="{Binding EditSharedRawValueB, Mode=TwoWay}"
Unit="{Binding SharedUnit}"
FontSize="14" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="VaeTextBox C:" VerticalAlignment="Center" Width="110" />
<controls:VaeTextBox Width="160"
RawValue="{Binding EditSharedRawValueC, Mode=TwoWay}"
Unit="{Binding SharedUnit}"
FontSize="14" />
</StackPanel>
<TextBlock Margin="0,10,0,0" FontSize="12" Foreground="Gray" TextWrapping="Wrap"
Text="6 个控件各自绑定不同的 RawValue,但 Unit 全部绑定同一 SharedUnit:切换单位 → 全部同步换算;编辑任一 VaeTextBox → 仅回写自己的 RawValue。" />
</StackPanel>
</Border>
</Grid>
</Window>
XAML 关键点说明:
-
xmlns:controls:把自定义控件命名空间引入 XAML,之后就能写<controls:VaeTextBlock ...>。 -
ComboBox 的三件套:
ItemsSource:列表数据源;DisplayMemberPath="Symbol":每项显示UnitInfo.Symbol(如 "mm");SelectedValuePath="Unit"+SelectedValue="{Binding CurrentUnit}":选中项映射到UnitInfo.Unit枚举值,与 ViewModel 的UnitKind属性直接对接。
-
绑定方向:
- 只读控件
RawValue用Mode=OneWay(数据只从 ViewModel 流向界面); - 可编辑控件
RawValue用Mode=TwoWay(界面编辑会回写 ViewModel); Unit不写 Mode,因为依赖属性元数据里已声明BindsTwoWayByDefault。
- 只读控件
-
多控件共享单位的实现 :注意第 3 行区域 6 个控件的
Unit="{Binding SharedUnit}"------同一个绑定源。这就是"统一切换"的全部秘密:没有一行 C# 联动代码,全靠绑定。
11. 第九步:编译运行与验证清单
bash
cd SimpleUnitConverterDemo
dotnet build
dotnet run --project src/SimpleUnitConverterWpfDemo
程序启动后,逐项验证:
| # | 操作 | 预期结果 |
|---|---|---|
| 1 | 启动 | 左栏显示 25.4 mm,右栏输入框显示 25.4 mm |
| 2 | 工具栏单位切到 inch |
两栏同时变为 1 inch(25.4 mm = 1 inch) |
| 3 | 右栏点击输入框 | 文本变为 1(去掉单位符号) |
| 4 | 输入 2 后点击别处(失焦) |
右栏显示 2 inch,左栏同步 2 inch,下方原始值变为 50.8 |
| 5 | 类别切到 温度 |
单位列表变为 °C/°F/K,两栏显示 25 °C |
| 6 | 单位切到 °F |
两栏显示 77 °F(25 °C = 77 °F) |
| 7 | 共享单位区切到 m |
6 个控件同时换算:A 显示 1.27 m,B 显示 0.0254 m,C 显示 0.0127 m |
| 8 | 编辑 VaeTextBox A 为 2 失焦 |
A 显示 2 m,B、C 不受影响 |
全部符合即大功告成。
12. 原理深入:三个关键机制
12.1 数据流全景图
以"用户在 VaeTextBox 输入 2(单位 inch)"为例,完整数据流:
用户输入 "2"
│ TextChanged
▼
TryCommitText():解析 2 ──ConvertToBase──▶ 50.8(mm 基准值)
│ 写入 RawValue(抑制 _isUpdating,防止重写输入框)
▼
RawValueProperty 变更 ──WPF 双向绑定──▶ ViewModel.RawValue = 50.8
│ ViewModel 触发 PropertyChanged
▼
左栏 VaeTextBlock 的 RawValue 绑定收到新值
│ OnRawPropertyChanged
▼
UpdateDisplay():50.8 ──FromBase──▶ 2 ──▶ Text = "2 inch"
记住两条铁律:
- 存储永远是基准单位(RawValue);
- 显示永远是"基准值 → FromBase → 当前单位"。
12.2 为什么温度必须单独走公式
长度单位共享同一个"零点"(0 mm = 0 inch = 0 m),所以纯乘除即可。温度的零点各不相同(0 °C = 273.15 K = 32 °F),必须先加减偏移再乘除比例。这就是 UnitConverter 里 switch (unit.Category) 分发的原因------数据结构统一(都有 FactorToBase),算法分路径(线性 / 非线性)。
12.3 _isUpdating 防递归的完整推演
假设没有保护标志,切换单位时会发生什么:
Unit 变化 → UpdateDisplay() → 写 DisplayValue
→ 触发 OnDisplayValueChanged → UpdateRaw() → 写 RawValue
→ 触发 OnRawPropertyChanged → UpdateDisplay() → 写 DisplayValue
→ ...... 无限循环,栈溢出
加上标志后:UpdateDisplay 进入时置 _isUpdating = true,期间它引发的 UpdateRaw 在入口处检测到标志直接返回;UpdateDisplay 在 finally 中复位标志。每次"主动发起"的更新只传播一层。
13. 常见问题排查
| 现象 | 原因 | 解决 |
|---|---|---|
| 界面显示空白,输出窗口有 Binding 错误 | ViewModel 属性名与绑定名不一致 | 检查 {Binding Xxx} 拼写与属性名完全一致 |
| 切换单位后界面不刷新 | ViewModel 忘记触发 PropertyChanged |
确认每个 setter 都调用了 OnPropertyChanged() |
| 输入框内容被莫名清空/覆盖 | _isUpdating 保护缺失或位置不对 |
对照第 9 步代码,确认 SetRawValue 与事件回调里都有保护 |
| 单位下拉框是空的 | CurrentUnits 没通知刷新,或类库未注册单位 |
类别 setter 里 OnPropertyChanged(nameof(CurrentUnits));检查 UnitRegistry 静态构造函数 |
| 温度换算结果错误(如 25 °F 显示成 -458.6) | 温度走了线性系数路径 | 确认 ToBase/FromBase 的 switch 中 UnitCategory.Temperature 分发存在 |
dotnet run 报找不到 WPF |
目标框架或 UseWPF 配置错误 |
WPF 项目必须是 net10.0-windows 且 <UseWPF>true</UseWPF> |
编译错误:找不到 UnitConverter |
WPF 项目缺少项目引用 | 执行第 3 步的 dotnet add ... reference 命令 |