沉浸在 WPF 中: 一个“世界时钟”应用程序, 作为精炼代码






4.88/5 (39投票s)
2007年8月16日
13分钟阅读

117453

5071
使用易读代码演示如何使用 WPF 构建一个简单的“世界时钟”应用程序

引言
我最近一直在尝试使用 Orcas 尽快掌握 .NET 3 和 3.5,而不是仅仅“玩玩”,我给自己设定了一个小应用程序来写:一个“世界时钟”应用程序,它驻留在系统托盘中,并显示世界各地不同时区的时间。(这是一个足够简单的应用程序,我能在业余时间完成第一个版本,如果我有什么业余时间的话,但它对我来说,以及希望对其他 MicroISV 来说也很有用。)
我写这篇文章是为了给那些还没有怎么接触过 WPF 的 MFC 或 WinForms 开发者提供一个简单的示例。我将以一种“易读编程”的风格来写——不是完整的代码,而是可以随散文一起阅读的代码片段。这也不是 WPF 的教程;它更像是一门沉浸式语言课程,你通过接触母语来学习。
基本时钟表盘
每个时钟都是一个图形对象 (相对于流式布局对话框),所以我们使用 Canvas。
<!-- Clock.xaml -->
<Canvas Width="100" Height="100" x:Name="_canvas">
<!-- + Background -->
<!-- + Markers -->
<!-- + Hands -->
<!-- + Highlights -->
</Canvas>
我设置的宽度和高度实际上并不重要,因为我们可以按任意大小缩放时钟,但这对我来说意味着时钟内部的测量可以用百分比来表示。
从背景开始,我想要一个从上到下渐变的圆形,周围有一个白色的“光晕”。(最终它将作为一个桌面小部件弹出,所以我希望时钟有一个边框来区分它们与用户的桌面)。
<!-- Clock.xaml, * Background -->
<Ellipse Canvas.Left="0" Canvas.Top="0" Width="100" Height="100">
<Ellipse.Fill>
<RadialGradientBrush>
<GradientStop Offset="0.0" Color="White" />
<GradientStop Offset="0.95" Color="White" />
<GradientStop Offset="1.0" Color="Transparent" />
</RadialGradientBrush>
</Ellipse.Fill>
</Ellipse>
<Ellipse Canvas.Left="3" Canvas.Top="3" Width="94" Height="94">
<Ellipse.Fill>
<LinearGradientBrush StartPoint="0.4,0.1" EndPoint="0.6,0.9">
<LinearGradientBrush.GradientStops>
<GradientStop Offset="0.0" Color="#888888" />
<GradientStop Offset="1.0" Color="#111111" />
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</Ellipse.Fill>
</Ellipse>
我最初使用了一个“外发光”位图效果,但在动画时它会有点晃动。所以我们现在有了

这部分很容易。接下来是刻度。虽然 WPF 的理念是将图形元素包含在 XAML 中,但对于边缘周围的小刻度来说,这样做就很愚蠢了——几十个几乎相同的元素在我看来意味着“循环”,而那需要代码。XAML 只是一个占位符。
<!-- Clock.xaml, * Markers -->
<Canvas x:Name="_markersCanvas" />
实际的元素是在代码中添加的。
// Clock.xaml.cs
protected override void OnInitialized( EventArgs e )
{
base.OnInitialized( e );
for( int i = 0; i < 60; ++i )
{
Rectangle marker = new Rectangle();
if( ( i % 5 ) == 0 )
{
marker.Width = 3;
marker.Height = 8;
marker.Fill = new SolidColorBrush( Color.FromArgb( 0xe0, 0xff,
0xff, 0xff ) );
marker.Stroke = new SolidColorBrush( Color.FromArgb( 0x80, 0x33,
0x33, 0x33 ) );
marker.StrokeThickness = 0.5;
}
else
{
marker.Width = 0.5;
marker.Height = 3;
marker.Fill = new SolidColorBrush( Color.FromArgb( 0x80, 0xff,
0xff, 0xff ) );
marker.Stroke = null;
marker.StrokeThickness = 0;
}
TransformGroup transforms = new TransformGroup();
transforms.Children.Add( new TranslateTransform(-( marker.Width/2),
marker.Width / 2 - 40 - marker.Height ) );
transforms.Children.Add( new RotateTransform( i * 6 ) );
transforms.Children.Add( new TranslateTransform( 50, 50 ) );
marker.RenderTransform = transforms;
_markersCanvas.Children.Add( marker );
}
for( int i = 1; i <= 12; ++i )
{
TextBlock tb = new TextBlock();
tb.Text = i.ToString();
tb.TextAlignment = TextAlignment.Center;
tb.RenderTransformOrigin = new Point( 1, 1 );
tb.Foreground = Brushes.White;
tb.FontSize = 4;
tb.RenderTransform = new ScaleTransform( 2, 2 );
double r = 34;
double angle = Math.PI * i * 30.0 / 180.0;
double x = Math.Sin( angle ) * r + 50, y =
-Math.Cos( angle ) * r + 50;
Canvas.SetLeft( tb, x );
Canvas.SetTop( tb, y );
_markersCanvas.Children.Add( tb );
}
}
这有一大堆代码,但它表明 XAML 并没有什么神奇之处——它只是创建元素的便捷方式,我们也可以在代码中完成同样的事情,尽管方式有点啰嗦。刻度只是矩形;要定位它们,我只是将它们定位在 Canvas 的顶部中心,然后围绕中心旋转。我找不到一种在 Canvas 上精确居中文本的方法,所以最后我使用了以下技术:
- 将文本大小设置为实际所需的一半;
- 将文本的左上角放置在你想要居中的位置;
- 将变换原点设置为右下角;
- 缩放因子为二。
我们现在有了基本背景。

对我来说,WPF 相对于 WinForms 或 MFC 最重要的一点是本文没有涉及到的,事实上也不会涉及到,因为我们根本不需要它:没有 WM_PAINT
或 OnPaint
处理程序。我到目前为止所做的一切都只执行一次 &mdashl XAML 只是“在那里”,OnInitialized 方法也只调用一次 — — 之后 WPF 就接管了。
时钟指针
时钟有三个指针:时针、分针和秒针。我希望秒针有一个稍微不同的效果,所以它在一个单独的 Canvas 中。我稍后会详细介绍。
<!-- Clock.xaml, * Hands -->
<Canvas>
<!-- + HourAndMinuteHandsEffect -->
<!-- + HourHand -->
<!-- + MinuteHand -->
</Canvas>
<Canvas>
<!-- + SecondHandEffect -->
<!-- + SecondHand -->
</Canvas>
由于时钟的每个指针基本相同,我将只展示时针。
<!-- Clock.xaml, * HourHand -->
<Rectangle Width="8" Height="36" Fill="White" Stroke="#333333"
StrokeThickness="0.6" RadiusX="2" RadiusY="2">
<Rectangle.RenderTransform>
<TransformGroup>
<TranslateTransform X="-4" Y="-32" />
<RotateTransform Angle="{Binding HourAngle}" />
<TranslateTransform X="50" Y="50" />
</TransformGroup>
</Rectangle.RenderTransform>
</Rectangle>
指针基本上是一个矩形,角落稍微圆润一些。每个矩形最初都位于 (0, 0),所以我们将其移动,使零点位于“轴心”,按正确的角度旋转,然后再次移动,将其放置在 Canvas 的中间。(有其他定位矩形的方法,特别是使用 Canvas 的 Left 和 Top 附加属性,但这是我偏好的处理方式)。
当然,每个指针的旋转角度都需要定义;我将旋转绑定到一个尚不存在的属性,这是一个错误。这是我反复遇到的一个领域;我一直将我的属性写成简单的 getter 和 setter,并由一个成员变量支持,然后不得不不断地将它们更改为 WPF 依赖属性。
// Don't do this!!
public double HourAngle
{
get
{
return _hourAngle;
}
set
{
_hourAngle = value;
}
}
private double _hourAngle;
经验法则是,如果你要在 WPF 中绑定某个东西,它就需要是依赖属性,所以为你节省时间,下定决心从一开始就以这种方式编写它们。
// Clock.xaml.cs
static Clock()
{
HourAngleProperty = DependencyProperty.Register
( "HourAngle", typeof( double ), typeof( Clock )
, new FrameworkPropertyMetadata( 0.0,
FrameworkPropertyMetadataOptions.AffectsRender ) );
// Repeat for minutes and seconds...
}
public double HourAngle
{
get
{
return (double) GetValue( HourAngleProperty );
}
set
{
SetValue( HourAngleProperty, value );
}
}
这看起来像是更多的打字,看起来可能效率低下,看起来不类型安全 — — 但以后会得到回报。重申一下:如果你想在 WPF 元素树中绑定一个属性,你需要一个依赖属性。
当然,我们希望时钟指针能够移动,而目前它们都指向十二点。有两种基本方法可以实现这一点:通过动画,或通过属性赋值。
WPF 有一个非常强大的动画系统,所以我首先尝试用它来移动指针。本质上,每个指针的角度都有一个故事板,它在十二小时、一小时或一分钟内将值从 0 变为 360。初始角度被设置为控件创建时的当天时间。
不幸的是,虽然时钟指针转动得很漂亮,但当我创建一行时钟时,秒针的位置都略有不同。我无法足够精确地控制起始位置,使其成为一个可行的方法。
第二个可行的解决方案是简单地在计时器中更新角度。请注意,这绝对不是在 WPF 中指定动画的常规方法 — — 动画应该是声明式的,就像你的元素树一样 — — 但我认为你可以将数据值分为两类:
- 那些是显示内容本身,即实际数据值 — — 这些不需要基于动画,并且
- 那些是显示的样式,眼糖果或外观 — — 这些应该是基于动画的。
我认为时间是时钟显示的一个真正内容,所以我不觉得通过计时器更新角度有什么不妥。
// Clock.xaml.cs
private void _timer_Tick( object sender, EventArgs e )
{
DateTime date = GetDate(); // You need to supply this function.
double hour = date.Hour;
double minute = date.Minute;
double second = date.Second;
double hourAngle = 30 * hour + minute / 2 + second / 120;
double minuteAngle = 6 * minute + second / 10;
double secondAngle = 6 * second;
HourAngle = hourAngle;
MinuteAngle = minuteAngle;
SecondAngle = secondAngle;
}
(如果你有兴趣,那是一个 DispatcherTimer
)。

我们现在有了一个可以工作的时钟。我知道文章的这一部分不像第一部分那样有趣(可能第一部分也不是很好),但任何程序总会有不那么有趣的部分,计算时钟指针的角度在任何语言中都很枯燥。
有一件非常酷的事情是,时钟指针的呈现 (在此情况下是圆角矩形) 与指针角度的内容得到了很好的分离 — — 所以如果我想要一个米老鼠指针的时钟,我可以把 XAML 交给图形设计师,他们可以使用 Expression Blend,它仍然可以工作。(或者更可能的是,当人们开始要求不同的主题时,我可以切换到使用 WPF 的样式机制)。
短暂的 LINQ 转向
我现在要稍作停顿,看一些 LINQ。几乎所有应用程序都需要在某个地方存储数据,即使只是用户上次使用时的窗口位置(你确实会存储这些,对吧?)。我的世界时钟应用程序足够简单,用户设置目录中的一个小型配置文件就足够了。对于本文,我想在应用程序启动时读取时区列表,并在退出时再次写入,我们假设用户已经通过某种选项对话框修改了该列表。
首先,一个小插曲:我将在这里展示的内容适合我的应用程序。你可能会回来告诉我它太慢,或者不适合大型文件。那很好;如果需要原始速度,请根据性能分析选择你的方法,或者切换到数据库(LINQ-to-SQL 可能会在那里帮助你……),或者其他什么。没有一种单一的技术或工具适合所有情况,但我正在读写一个很小的文件,所以我认为这是一种可接受的技术。
其次,另一个小插曲:到目前为止我所涵盖的关于 WPF 的内容都可以在 .NET 3 中找到,这是一个已完全发布的组件。LINQ 包含在 .NET 3.5 中,它仍处于 Alpha 阶段。这本会是一个决定不使用 LINQ 的因素(因为理想情况下我希望我的应用程序尽快发布),但我想使用 TimeZoneInfo
类,而它只在 .NET 3.5 中可用,所以我想我也可以使用我想要的任何 .NET 3.5 功能。
好的,继续代码。这是我将用于数据文件的格式。如果你读得很快,可以随意跳过这一节和下一节。
<?xml version="1.0" encoding="utf-8"?>
<Settings>
<TimeInfos>
<TimeInfo Zone="...serialized gobbledygook..." />
<TimeInfo Zone="...serialized gobbledygook..." />
<TimeInfo Zone="...serialized gobbledygook..." />
</TimeInfos>
</Settings>
非常简单,如果以后需要,我还可以添加其他内容。
我将我的时区信息存储在一个名为 TimeInfo
的包装类中,本示例中重要的部分是两个静态方法:
public sealed class TimeInfo
{
public static string Write( TimeInfo ti )
{
// ...
}
public static TimeInfo Read( string s )
{
// ...
}
}
如果你以前使用过 XPath
,LINQ-to-XML 中的新功能将不会太麻烦。这是我读取配置文件的方法:
XDocument xDoc = XDocument.Load( filename );
_timeInfos.AddRange( from XElement t in xDoc.Descendants( "TimeInfo" )
select TimeInfo.Read( t.Attribute( "Zone" ).Value ) );
这里的好处是没有循环(或者至少没有显式的循环)。C# 1.0 提供了 foreach 语句,它抽象了集合枚举的工作原理。C# 3.0 现在允许我们抽象循环本身,让我们从各种数据源中选择和转换元素。
写入配置文件几乎同样简单,并利用了新的 XElement
类。以前,你必须创建一个 XmlDocument
,它相当笨重,或者使用 XmlTextWriter
并放弃自动创建文档结构(也就是说,忘记写入闭合标签,你就会得到无效的 XML)。
XDocument xDoc = new XDocument(
new XElement( "Settings",
new XElement( "TimeInfos",
from TimeInfo ti in _timeInfos
select new XElement( "TimeInfo",
new XAttribute( "Zone",
TimeInfo.Write( ti ) ) ) ) ) );
xDoc.Save( filename );
但是结尾的那些括号有点丑。如果需要,我可以使用一个临时变量:
var timeInfos = from TimeInfo ti in _timeInfos
select new XElement( "TimeInfo", new XAttribute( "Zone",
TimeInfo.Write( ti ) ) );
XDocument xDoc = new XDocument(
new XElement( "Settings",
new XElement( "TimeInfos", timeInfos ) ) );
xDoc.Save( filename );
“var
”关键字也是新的 — — 让我们澄清一下大家最初的困惑 — — 它不是弱类型。“Var
”只是意味着“从以下表达式推断类型”,在这种情况下,该类型是:
System.Linq.Enumerable.SelectIterator<timeinfo,system.xml.linq.xelement />
哇!我不会解释这个,因为我理解得不够透彻,但基本思想是 LINQ 创建表达式树,可以以多种方式使用。例如,LINQ-to-SQL 将这些表达式转换为高效的 SQL 命令,包含 WHERE
和 JOIN
。
LINQ 确实在这里为我节省了大量繁重的工作。读取代码感觉像是一个查询,而我的写入代码甚至像我想要生成的 XML 文件的缩进,感觉更像该文件的“模板”,而不是创建它的过程。.NET 3.5 确实有这种感觉 — — 我声明我想要实现的目标的结构和形式,系统会根据这种形式来处理。
视觉效果、动画和异形窗口
我们现在已经有了一个基本工作的时钟,并且还涵盖了加载和保存配置文件的非常简单的方法。现在让我们让应用程序更光鲜亮丽、更具动画效果,并创建一个异形、无边框的窗口。
首先,尽管有阴影,时钟看起来相当平坦和二维。我希望时钟看起来像是真正由什么东西制成的,框架在顶部投射内部阴影,塑料盖反射一些光线。我们可以通过在时钟顶部叠加几个半透明的椭圆来做到这一点(因为我们想改变时钟所有内容的亮度)。对于这些效果,可以使用径向填充来产生各种曲面阴影。
<!-- Clock.xaml, * Highlights -->
<Ellipse Canvas.Left="3" Canvas.Top="3" Width="94" Height="94">
<Ellipse.Fill>
<RadialGradientBrush Center="0.51,0.52" SpreadMethod="Pad">
<GradientStop Offset="0.0" Color="Transparent" />
<GradientStop Offset="0.9" Color="Transparent" />
<GradientStop Offset="1.0" Color="#a0000000" />
</RadialGradientBrush>
</Ellipse.Fill>
</Ellipse>
<Ellipse Canvas.Left="3" Canvas.Top="3" Width="94" Height="94">
<Ellipse.Fill>
<RadialGradientBrush Center="0.7,0.8" SpreadMethod="Pad"
RadiusX="1.3" RadiusY="1.2">
<GradientStop Offset="0.0" Color="Transparent" />
<GradientStop Offset="0.4" Color="#30ffffff" />
<GradientStop Offset="0.5" Color="#60ffffff" />
<GradientStop Offset="0.6" Color="#3fffffff" />
<GradientStop Offset="1.0" Color="Transparent" />
</RadialGradientBrush>
</Ellipse.Fill>
</Ellipse>
我通过实验产生了各种中心和半径 — — 对于这类效果,我使用鲜明的原色来获得效果的形状,然后改用我实际想要使用的微弱的明暗颜色。

这有所改善,但我们可以做得更好。让指针在表盘上稍微突出一点。在现实生活中,它们会在表盘上投下阴影,我们可以在 WPF 中使用 DropShadowBitmapEffect
来实现类似的效果。
<!-- Clock.xaml, * HourAndMinuteHandsEffect -->
<Canvas.BitmapEffect>
<DropShadowBitmapEffect ShadowDepth="1" Softness="0.1" Opacity="0.5" />
</Canvas.BitmapEffect>
和之前一样,我只展示时针和分针的效果;秒针的阴影基本相同,只是我想要一个稍微不同的阴影。
位图效果是 WPF 中我们需要小心处理的领域之一。顾名思义,它们是位图,而不是像 WPF 中大多数元素那样的矢量图。我们有一些需要注意的地方:
- 位图效果是在软件中渲染的,因此需要考虑性能。在应用程序中随意添加位图效果是导致缓慢的根源。
- 生成的位图最好不要进行变换。旋转或缩放位图会导致视觉上的奇怪之处。因此,你会在我身上看到,我没有将效果应用于指针本身,因为指针会旋转。相反,我将旋转的指针添加到一个 Canvas 中,然后将效果应用于该 Canvas。
- 位图效果会降低文本质量。将位图效果应用于包含文本的容器会导致文本渲染质量下降到使用普通抗锯齿,而不是 ClearType。
在提到并考虑了这些点之后,让我们看看区别:

这是一个微妙的效果,但我认为它是一个改进,并且当我们开始在动画中放大和缩小这些时钟时,它们看起来会更好。
我想要的第一种动画是让每个时钟在鼠标悬停在其上时“放大”,在鼠标离开时“缩小”。每个单独的时钟都显示在一个“ClockDisplay”控件中,该控件包括时钟本身,以及时间和时区文本。
WPF 可以动画化任何依赖属性(我们之前讨论过),可以是现有的属性,如“Opacity”,如果你想让你的动画控件淡入淡出,或者你自己定义的属性。在这种情况下,我选择自己定义一个;但是,请注意,也有其他方法可以实现以下效果,而无需定义自己的属性。
// ClockDisplay.xaml.cs
static ClockDisplay()
{
ZoomProperty = DependencyProperty.Register
( "Zoom", typeof( double ), typeof( ClockDisplay )
, new FrameworkPropertyMetadata( 1.0,
FrameworkPropertyMetadataOptions.AffectsRender ) );
}
接下来我们需要将时钟的大小链接到这个属性:
<!-- ClockDisplay.xaml -->
<cx:Clock ...snipped... RenderTransformOrigin="0.5,1.3">
<cx:Clock.RenderTransform>
<ScaleTransform ScaleX="{Binding Zoom}" ScaleY="{Binding Zoom}" />
</cx:Clock.RenderTransform>
</cx:Clock>
最后,当然,我们需要设置动画。WPF 中的动画是声明式的,而不是过程式的 — — 我们设置动画的“意图”,WPF 负责应用和渲染它。使用内置动画功能的优点是 WPF 还会处理不完整的动画 — — 如果用户在缩放动画完成之前将鼠标移开,时钟会正确地向后动画,而不会发生动画冲突。
<!-- ClockDisplay.xaml -->
<UserControl.Triggers>
<EventTrigger RoutedEvent="Control.MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Zoom" To="2"
Duration="0:0:0.5" FillBehavior="HoldEnd" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="Control.MouseLeave">
<BeginStoryboard>
<Storyboard TargetProperty="Zoom">
<DoubleAnimation To="1" Duration="0:0:0.5"
FillBehavior="HoldEnd" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</UserControl.Triggers>
很简单。本质上,我们在 MouseEnter
时触发第一个动画,在 MouseLeave
时触发第二个动画。每个动画都影响“Zoom”属性,完成时间为半秒,并将缩放值分别移至 x2 或 x1。
在这里,我们可以看到时钟动画之一,它是由鼠标进入 ClockDisplay
控件触发的。

请注意 CPU 指示器 — — 时钟通常只消耗大约 2% 的 CPU 时间,即使作为异形窗口运行。正如你所见,我还将相同的缩放因子应用于时间文本。
最后,为了获得那种抗锯齿、每像素 alpha、异形窗口效果 — — 大量代码?一点也没有。
<Window ...snipped... Background="#00000000" WindowStyle="None"
AllowsTransparency="True" ShowInTaskbar="False">
<!-- ...content... -->
</Window>
好了,这就是一个相当有吸引力的、动画式的时钟显示,几乎不费吹灰之力。这篇文章还有很多我没有涵盖的内容 — — 特别是选项对话框 — — 但如果你想进一步探索,就下载源代码吧。
更新
最新版本的源代码将保留在 此处