WPF中如何实现单位转换

从零实现单位转换类库与 WPF 程序(新手教程)

目录

  1. 你要做出来的东西长什么样
  2. 环境准备
  3. 第一步:创建解决方案与两个项目
  4. [第二步:类库------单位元信息(3 个小文件)](#第二步:类库——单位元信息(3 个小文件))
  5. 第三步:类库------单位注册表
  6. 第四步:类库------转换门面(核心算法)
  7. [第五步:WPF 项目骨架](#第五步:WPF 项目骨架)
  8. [第六步:只读显示控件 VaeTextBlock](#第六步:只读显示控件 VaeTextBlock)
  9. [第七步:可编辑输入控件 VaeTextBox](#第七步:可编辑输入控件 VaeTextBox)
  10. 第八步:视图模型与主窗口界面
  11. 第九步:编译运行与验证清单
  12. 原理深入:三个关键机制
  13. 常见问题排查
  14. 扩展练习

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 程序 把换算结果显示出来、让用户输入和切换单位 不自己写换算公式

程序界面分三块:

  1. 工具栏:选择类别(长度/温度)和单位;
  2. 双栏对照区 :左栏只读显示(VaeTextBlock),右栏可编辑(VaeTextBox),两栏共享同一个原始值和单位;
  3. 多控件共享单位区: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,
    };
}

原理说明(本类库最核心的一页):

  1. 统一换算模型:任何转换 = 两步基准中转

    复制代码
    25.4 inch ──ToBase──▶ 645.16 mm ──FromBase──▶ 64.516 cm
                           (基准中转站)

    Convert 内部就一行:FromBase(ToBase(value, from), to)。所有单位只需维护"自己到基准"的关系。

  2. 长度是线性换算 :mm 值 = 原值 × 系数,反向 原值 = mm 值 ÷ 系数。乘除互为逆运算,天然对称。

  3. 温度是非线性换算:因为温度有"偏移量"(0 °C 不是绝对零点),必须用公式:

    • °F = °C × 9/5 + 32,逆运算 °C = (°F − 32) × 5/9
    • K = °C + 273.15,逆运算 °C = K − 273.15

    所以 ToBase/FromBase 用 switch 按类别分发:温度走 ToCelsius/FromCelsius,其余走线性系数。

  4. double.NaN 作为统一失败信号 :输入不是有限数、单位没注册、类别不匹配,都返回 NaN。调用方用 double.IsNaN(result) 一个判断就能覆盖所有失败情况,不用 try-catch。

  5. 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();
}

原理说明(控件机制的核心):

  1. 三个依赖属性 = 控件的全部状态:

    • RawValue:原始值,永远以类别基准单位计(长度 mm、温度 °C)。这是控件的"真值",业务数据只存这一份;
    • Unit:当前显示单位;
    • DisplayValue:显示值 = RawValue 换算到 Unit 后的数值。
  2. 依赖属性声明套路(固定写法,照抄即可):

    • 一个 public static readonly DependencyProperty XxxProperty 字段,调用 DependencyProperty.Register(属性名, 属性类型, 拥有者类型, 元数据);
    • 元数据里:默认值 + BindsTwoWayByDefault(默认支持双向绑定)+ 变更回调(值一变就自动调用你指定的方法);
    • 再包一个普通 C# 属性(GetValue/SetValue)方便代码访问。
  3. 联动逻辑:

    • RawValue 或 Unit 变了 → UpdateDisplay():从基准值换算出显示值,拼上单位符号写到 Text;
    • DisplayValue 被外部改了 → UpdateRaw():反向换算回基准值。
  4. _isUpdating 防递归(关键!) :UpdateDisplay 里给 DisplayValue 赋值会触发 OnDisplayValueChanged → 又调 UpdateRaw → 又改 RawValue → 又触发 UpdateDisplay......无限循环。设置 _isUpdating = true 后,回调开头检测到标志就直接 return,循环被掐断。这是所有"双向联动"控件的通用套路。

  5. 为什么 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();
}

原理说明(相比只读控件多出的三件事):

  1. 构造函数里订阅三个事件:

    • TextChanged:用户每敲一个字符就尝试解析提交(TryCommitText),保证绑定源实时更新;
    • GotKeyboardFocus:聚焦时把 "25.4 mm" 变成 "25.4"------去掉单位符号,用户直接改数字;
    • LostKeyboardFocus:失焦时提交文本并恢复 "25.4 mm" 的规范显示。
  2. TryCommitText 的换算方向 :用户输入的是"当前单位下的值"(比如单位是 inch 时输入 1),所以用 ConvertToBase 把它换算回基准值(25.4 mm)存进 RawValue。方向别搞反:输入是显示单位,存储是基准单位。

  3. SetRawValue 里也要抑制 _isUpdating :提交文本时更新 RawValue 会触发 UpdateDisplay,后者会重写 Text------把用户正在输入的内容覆盖掉。抑制后,用户输入过程不被打扰。

  4. 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;
}

原理说明:

  1. INotifyPropertyChanged 是 ViewModel 的灵魂 :WPF 绑定默认只"读一次"。属性值变了之后想让界面跟着变,必须在 setter 里触发 PropertyChanged 事件。OnPropertyChanged 这个辅助方法 + [CallerMemberName] 特性(编译器自动填上调用者的属性名)是标准写法。

  2. CurrentUnits 是计算属性 :每次访问都调 UnitConverter.GetUnits(CurrentCategory) 现查。类别切换后必须手动 OnPropertyChanged(nameof(CurrentUnits)) 通知界面"这个属性变了,重新取值",下拉框才会刷新。

  3. 类别切换的联动 (CurrentCategory setter):切类别 → 单位列表变 → 当前单位重置为新类别第一个单位 → 原始值重置为该类别的默认值(长度 25.4 mm,温度 25 °C)。

  4. 多控件共享单位 :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 关键点说明:

  1. xmlns:controls :把自定义控件命名空间引入 XAML,之后就能写 <controls:VaeTextBlock ...>。

  2. ComboBox 的三件套:

    • ItemsSource:列表数据源;
    • DisplayMemberPath="Symbol":每项显示 UnitInfo.Symbol(如 "mm");
    • SelectedValuePath="Unit" + SelectedValue="{Binding CurrentUnit}":选中项映射到 UnitInfo.Unit 枚举值,与 ViewModel 的 UnitKind 属性直接对接。
  3. 绑定方向:

    • 只读控件 RawValue 用 Mode=OneWay(数据只从 ViewModel 流向界面);
    • 可编辑控件 RawValue 用 Mode=TwoWay(界面编辑会回写 ViewModel);
    • Unit 不写 Mode,因为依赖属性元数据里已声明 BindsTwoWayByDefault。
  4. 多控件共享单位的实现 :注意第 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 命令

相关推荐
samble10 小时前
从零搭建灌装监控系统(十七):LiveCharts 实时温度曲线
c#·wpf·mvvm·modbus·工业控制
光泽雨1 天前
WPF中的依赖属性
wpf
海马1 天前
Spring Cloud 开发知识整理
spring·spring cloud·wpf
闻哥1 天前
分布式任务调度框架:XXL-Job / ElasticJob / DolphinScheduler / SchedulerX 架构对比与选型
分布式·架构·wpf
预立科技2 天前
CDN(内容分发网络)知识总结
网络·wpf·cdn
FuckPatience3 天前
WPF DataGrid 行根据某个属性循环变色动画
wpf
一条闲鱼_mytube5 天前
高并发短链接系统设计
wpf
一个天蝎座 白勺 程序猿6 天前
IoTDB集群扩容:从慌乱到从容的经验分享
数据库·wpf·时序数据库·iotdb
samble7 天前
从零搭建灌装监控系统(十三):报警系统设计,生命周期与通知
c#·wpf·mvvm·modbus·工业控制