C#/WPF 中的单位转换






4.70/5 (20投票s)
保持测量单位同步并正确绑定到 UI。
引言
开发应用程序时,典型的本地化问题之一就是单位转换——将一种测量系统转换为另一种。虽然在大多数情况下,这些转换涉及的数学非常简单,但围绕单位更改过程的语义却不那么简单。本文将探讨在 C# 应用程序中实现单位转换支持时,开发人员通常面临的典型问题。在本文中,我们希望探讨开发人员通常会遇到的许多注意事项和问题。
问题所在
单位之间的典型“分歧”在于美国和欧洲测量系统之间的差异。例如,在美国,距离以英里为单位;在欧洲大陆,距离以千米为单位。美国的温度以华氏度为单位,而我们欧洲人更喜欢摄氏度。其他工程单位也有类似的区别,尽管它们不一定遵循美欧分歧——有时仅仅存在几种文化中立的表示法来测量某个数量。
以温度为例。我们将考虑两种测量单位——摄氏度(°C)和华氏度(°F)。还有其他的,但现在我们先忽略它们。转换函数(链接)指定如下:
°C = 5/9 × (°F - 32)
要将华氏度转换为摄氏度,我们减去 32 并乘以 5/9,即 0.5(5)。反向转换,即从摄氏度转换为华氏度,如下所示:
°F = (°C / (5/9)) + 32 = 1.8 × °C + 32
我特意将公式重写为 Ax+B 的形式,其中 A 和 B 是定义一个单位相对于另一个单位的两个参数。事实上,这种定义形式适用于绝大多数数值转换。我将 A 称为比例因子,B 称为偏移量。现在我们知道了转换通常是什么样的(很简单,不是吗?),让我们概述一下问题。我们想要:
- 执行工程计算
- 拥有一个简单的单位转换 API
- 允许用户选择自己的单位
- 允许用户以其选择的单位查看和编辑数据
定义一个单位
在定义一组测量单位时,首先想到的是通常会有一个“参考”测量单位——即一个单位,所有其他单位都相对于该单位具有比例因子和偏移量。由于我住在欧洲,我选择摄氏度作为基本温度单位,即比例因子为 1.0,偏移量为 0.0 的单位。相对于摄氏度,华氏度单位的比例因子为 1.8,偏移量为 +32。除了这些数据,我还想保留测量单位的名称(例如,“华氏度”)及其缩写(“°F”)。将这些数据附加到单位上,可以使 UI 创建更容易一些。
上面提到的四条信息(名称、缩写、比例因子和偏移量)定义了我们的单位。但是,我们忽略了一项额外的信息——单位实际测量的是什么。因此,我们的第五个字段是对 UnitType
枚举的引用,该枚举的唯一目的是根据单位实际测量的内容来区分我们的单位。最终的单位结构如下所示:
internal struct Unit
{
internal string Name;
internal string Abbreviation;
internal double Scale;
internal double Shift;
internal UnitType Type;
internal Unit(string name, string abbreviation,
double scale, double shift, UnitType type)
{
if (scale == 0.0)
throw new ArgumentException("Scale factor cannot be zero.");
Name = name;
Abbreviation = abbreviation;
Scale = scale;
Shift = shift;
Type = type;
}
public override string ToString()
{
return string.Format("{0} ({1})",
Name, Abbreviation);
}
}
我试图通过将单位作为 struct
并只有一个构造函数来保持事物尽可能简单。我本可以使用属性使结构不可变,但我认为在这里使用各种 OOP 特性会付出太高的代价——尤其是当需要支持数百种测量单位时。关于这个结构,没有什么值得注意的,除了可能两件事。首先,如果比例因子为零,构造函数将抛出异常——这有助于防止稍后出现潜在的除零尝试。其次,struct
重写了 ToString()
方法。由于我在 UI 控件中使用 Unit
类型,通过 ToString()
重载获得文本表示是最简单的方法。
单位
既然我们已经确定了单个单位的结构,让我们创建一个“统治一切的类”,我们将(富有想象力地)称之为 Units
。此类将存储所有单位,并允许我们从一个单位转换为另一个单位。应用程序中通常会有一个 Units
类。因此,我们将此类限制为只有一个实例。我们的情况实际上是单例模式比静态类更好的完美例子。原因是您无法在静态成员上进行更改通知。哦,好吧,您可以通过一些技巧来实现它们,但这些解决方案是难以维护的,而且通常很痛苦。哦,您可能在想为什么我们想在 Units
类中进行属性更改通知。原因是属性更改需要(立即!)重新验证 UI,这意味着我们的值需要由 Units
类告知它们需要重新验证。
让我们简要概述一下我们需要实现的 Units
类的功能:
- 单位转换函数
- 包含单位定义的 数据存储
- 与内部单位的转换(辅助函数)
- 存储用户偏好设置
- 更改通知
现在让我们看看其中一些功能是如何实现的。
转换函数
转换函数简单明了。
internal static double Convert(Unit from, Unit to, double value)
{
if (from.Type != to.Type)
throw new InvalidOperationException("Conversion between incompatible types.");
double v = (value - from.Shift) / from.Scale;
return v * to.Scale + to.Shift;
}
现在您可以看到为什么我们在 Unit
构造函数中检查 Scale
参数了——如果我们不这样做,此函数将尝试除零,这是我们想要避免的。
偏好设置
在进行工程计算时,我们有一个两难境地。除非我们想一直进行单位转换(可能会很累人),否则我们需要将数据保留在参考单位中(无论它们是什么),以便轻松地在工程计算中使用它们。另一方面,用户必须看到一套完全不同的单位,这取决于他们选择了什么设置。因此,测量值最终会存在两种状态——一种是使用我们的参考单位(就我而言是摄氏度)的内部状态,以及根据用户偏好设置计算出的外部状态。这里,我使用系统和用户而不是内部和外部。
鉴于以上情况,我们现在创建两个属性来存储系统用于计算的温度单位,以及用户选择用于显示的温度单位。
public Unit TemperatureSystemUnit
{
get { return temperatureSystemUnit; }
set
{
temperatureSystemUnit = value;
Changed("TemperatureSystemUnit");
}
}
public Unit TemperatureUserUnit
{
get { return temperatureUserUnit; }
set
{
temperatureUserUnit = value;
Changed("TemperatureUserUnit");
}
}
如您所见,系统和用户温度设置会通知有关更改的信息。这一点至关重要,因为一旦用户更改了这些偏好设置,UI 数据就需要立即更新。
目前,与系统单位的转换以及从系统单位的转换存在问题。以我们实现的方式,我们犯了一个关键错误:我们忘记将系统/用户单位与它们所代表的 UnitType
相关联。我的方法是“咬紧牙关”并实现到/从内部值转换的函数,如下所示:
internal static double ConvertToSystem(UnitType type, double value)
{
switch (type)
{
case UnitType.Temperature:
return Convert(DegreesUserUnit, DegreesSystemUnit, value);
default:
throw new InvalidOperationException("Unit type not supported.");
}
}
internal static double ConvertFromSystem(UnitType type, double value)
{
switch (type)
{
case UnitType.Temperature:
return Convert(DegreesSystemUnit, DegreesUserUnit, value);
default:
throw new InvalidOperationException("Unit type not supported.");
}
}
这里还有其他可能的解决方案,但没有一个像这样快。编写了这两个函数后,我们就可以开始在定义系统和用户属性时在我们的业务实体中使用它们了。
实际示例
让我们尝试将我们编写的功能付诸实践。假设我们有一个模拟病人的类。病人内部存储的体温以摄氏度为单位,但用户可以选择以何种单位查看数据。我将使用 CLR 对象来定义此实体。让我们从私有字段和系统值开始。
private double temperature;
public double SystemTemperature
{
get { return temperature; }
set
{
if (temperature != value)
{
temperature = value;
Changed("SystemTemperature");
Changed("UserTemperature");
}
}
}
关于这段代码有两点要说。首先,我们进行赋值检查以确保我们不会引起不必要的通知。这在 WPF 中不是大问题,但在 WinForms 中,控件绑定的属性更新过于频繁时,这确实是个麻烦。另一件值得注意的是,我们对系统属性和用户属性都进行了属性更改通知(Changed()
方法就是这样做的)。这是有道理的,因为当一个改变时,另一个也会改变。现在让我们看看用户属性。
public double UserTemperature
{
get
{
return Units.Instance.ConvertFromSystem(UnitType.Temperature, temperature);
}
set
{
double v = Units.Instance.ConvertToSystem(UnitType.Temperature, value);
if (temperature != v)
{
temperature = v;
Changed("SystemTemperature");
Changed("UserTemperature");
}
}
}
我们终于充分利用了这项单位转换功能!您可能会因为到处看到 Units.Instance
而感到轻微不适,但请记住,它在 C# 代码中不像在 XAML 中的数据绑定表达式那样混乱。再次重申,实现单例是一个必要的邪恶,您以后会感谢自己的。
这部分比较棘手。请记住,当您更改单位时,没有任何东西可以告诉控件更新(基本上是重新读取)其值。这段代码实际上是添加到我们的病人类中的。构造函数只是订阅了 PropertyChanged
事件。然后,我们在 Patient
对象中进行通知。
public Patient()
{
Units.Instance.PropertyChanged += UnitsChanged;
}
void UnitsChanged(object sender, PropertyChangedEventArgs e)
{
Changed("SystemTemperature");
Changed("UserTemperature");
}
如果您有许多不同类型的单位,您需要细化此代码,以检查 Units
上触发的事件的名称,以确定在 Patient
上触发哪些事件。是的,我必须承认:这确实很繁琐。
绑定到 UI
您可能不会惊讶地发现,为了支持数据绑定,我们的单位数据结构还需要进一步的更改。这里有一个例子:我想要一个包含所有温度单位的组合框。目前,没有一个单一的集合包含所有温度单位。由于我对使用反射不感兴趣,并且单位在创建后不会更改,因此我的解决方案是简单地定义一个列表,其中包含特定类型的所有单位。
public static readonly IList TemperatureUnits;
现在我有了简单的公共属性,我将单位初始化放在构造函数中的列表中。全部在一行中完成!
TemperatureUnits = new[]
{
DegreesCelcius = new Unit("Degrees Celcius", "°C", 1.0, 0.0, UnitType.Temperature),
DegreesFahrenheit = new Unit("Degrees Fahrenheit", "°C", 1.8, 32, UnitType.Temperature)
};
绑定到单例有点丑陋。这是一个使用上述列表的组合框:
<ComboBox Grid.Column="1" Margin="3" Grid.ColumnSpan="2"
DataContext="{Binding Source={x:Static uc:Units.Instance}}"
ItemsSource="{Binding Path=TemperatureUnits}"
SelectedItem="{Binding Path=TemperatureUserUnit}"/>
在 ComboBox
元素内定义数据上下文这一事实本身就是一个相当令人不安的迹象,但似乎没有其他方法了。这就是我所说的 WPF 单例的语法比 C# 语法更丑陋。
现在,轮到我们的病人了。绑定到 Patient
数据结构很容易。我们只需定义一个文本框,如下所示:
<TextBox Grid.Column="1" Grid.Row="1" Margin="3" Grid.ColumnSpan="2">
<TextBox.Text>
<Binding Path="MyPatient.UserTemperature"
Converter="{StaticResource dtsc}"
UpdateSourceTrigger="PropertyChanged"/>
</TextBox.Text>
</TextBox>
如您所见,我们只需绑定到实体类的用户属性。为了处理不正确的输入,我创建了一个 double
和 string
类型之间的转换器,这里(称为 dtsc
)提到了它。其定义如下:
[ValueConversion(typeof(double), typeof(string))]
public class DoubleToStringConverter : IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
return string.Format("{0:F3}", (double) value);
}
public object ConvertBack(object value, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
double d;
if (double.TryParse((string)value, out d))
return d;
return 0.0;
}
}
微调
我可以立即想到几个微调——这些可能适用于您的开发情况,也可能不适用。
首先,单位字符串是硬编码的——在制作适用于许多国家的应用程序时,这是*不*推荐的。这就是我们进行单位转换的原因,对吧?所以,我的方法是:而不是只在 Unit
类中保留一个字符串,您可以保留一个单位的整个字典。然后,在返回单位时,您可以使用类似 Thread.CurrentThread.CurrentUICulture.Name
的键来访问正确的字符串。这适用于单位名称和缩写。如果您显示单位类型名称,那些也必须本地化。
有些单位转换比 Ax + B 更复杂(至少我听说是这样)。我的处理方式是添加对委托或 IValueConverter
的引用,它将执行真正巧妙的转换。当然,复杂的转换需要来自实体类的更多数据,因此可能没那么容易。在实际看到这种转换之前,我无法确定如何处理它。
单位也可以有依赖关系!例如,如果您以米为单位测量距离,您可能希望以米/秒为单位测量速度,而不是英里/小时。这些依赖关系实际上并不难实现——您只需在属性的更改器中添加适当的调用。
结论
切换单位并处理随之而来的所有更改都很棘手,而且过程并不总是那么顺利。但是,在保留完整的 WPF 数据绑定保真度的同时,实现自己的单位和单位转换是可能的。而且,尽管我们在本文中主要讨论了 WPF,但此处描述的方法也适用于 WinForms,因为单位相关的代码没有任何 WPF 特定的功能。我之所以避免使用依赖项属性之类的东西,有一个非常特殊的原因——我做工程应用的经验告诉我,在属性依赖关系非常复杂(比这里介绍的复杂得多)的情况下,INotifyPropertyChanged
接口的使用者是最安全的方法。这个接口相当知名,当然,WPF 对它没有任何问题。