一直想做点桌面小工具练手的话,用C#写一个动态模拟时钟是个特别合适的项目。它体积小、目标明确,却能把你对绘图、坐标换算、定时刷新、窗口消息循环这些WinForms/GDI+的基础知识全串起来。很多教程里的模拟时钟都是那种“秒针每秒跳一下”的简陋版本,这次我们直接做一个指针平滑连续运转、表盘刻度清晰、支持窗口缩放不模糊的版本,顺带把绘制数字、刻度、指针尾巴、中心圆帽这些细节都补齐。
这个项目适合谁?正在学C#图形编程的新手,想找一个课后综合练习的人,或者打算做个桌面小组件、给现有项目加个时钟控件的朋友。代码量不大,但包含了几个非常典型的技术点,无论是拿来自己复现还是改造,都值得记录一下。
我在一开始踩了几个坑,比如窗体刚显示时绘画区域是0像素导致图形错位、定时器事件里直接作画导致闪烁、高DPI下文字发虚等。这些问题单看教程基本不会有人告诉你,但实际做的时候十有八九会撞上。这篇文章会把完整的实现思路、代码结构和排错清单都捋一遍,尽量让你照着敲一遍就能跑出效果。
1. 整体设计拆解:先想明白“动态”是怎么来的
1.1 动态模拟时钟的本质是什么
动态时钟的“动态”不是说窗体动,而是指针在表盘上持续偏转。传统做法是挂一个Timer,每隔一定毫秒触发一次重绘,重绘时根据当前时间算出各指针的角度,再用GDI+绘出新的表盘画面。整个过程要回答三个问题:
- 表盘上有什么:外环、刻度、数字、指针、中心点。
- 指针该怎么转:角度和时间如何换算。
- 画面怎么刷新:刷新频率、双缓冲、绘制时机。
从实现角度看,所有内容都在OnPaint方法里完成。Timer只做一件事——调用Invalidate()让窗体重新绘制。这样游戏循环式的结构有个好处:绘制逻辑和计时逻辑完全解耦,你后续想改成秒表、倒计时、双时区时钟,只需要替换时间计算部分,绘图方法不需要大改。
1.2 为什么选GDI+而不是直接摆控件
有人可能会说,用Label显示时间不就行了?是的,如果只显示数字,用Label加Timer完全够。但要做到“模拟”表盘那样的视觉语言,纯控件组合就很吃力了——指针没法旋转,刻度没法均匀画,整体像是几个控件拼起来的按钮面板,而不是一个表。
GDI+的优势在于完全掌控每个像素。你可以把表盘当画布,按数学坐标自由画圆、画线、写字。配合双缓冲,画面还可以很流畅,不会一卡一卡的。
WinForms里实现双缓冲很简单,在构造函数里加三个样式:
SetStyle(ControlStyles.UserPaint, true); SetStyle(ControlStyles.AllPaintingInWmPaint, true); SetStyle(ControlStyles.OptimizedDoubleBuffer, true);这段代码的意思是说:窗体自己负责绘制、不擦背景、所有绘制先画到离屏缓冲区再一次性上屏。三个缺一不可,只写最后一个效果不明显。
选择WinForms而不是WPF的理由也很现实:前者消息循环和GDI+接口更直观,适合理解“窗口重绘”的底层机制;如果你未来要做跨平台或更复杂的小工具,那时再考虑WPF或Avalonia都不迟,但这是后话了。
2. 表盘的绘制细节:刻度、数字与坐标换算
2.1 画表盘外环
外环就是椭圆或者圆。一般窗口高度宽度不一样时,我们取短边的一半作为半径,这样外环碰到长边时也不会溢出。半径的计算方式是:
float radius = Math.Min(clientWidth, clientHeight) / 2f - margin;margin留十几像素,一方面防止抗锯齿后的线条被裁剪,另一方面给边缘留透气空间,视觉上更舒服。
绘制外环最好同时画出圆形描边和内部的填充色。很多入门代码只画描边,结果表盘看起来就是个空圈,没有实体的感觉。可以用一个浅色画刷填充表盘背景,再画一圈有厚度的边框。如果你想做深色主题,填充色换成深灰、边框换成亮灰就行,其他人也能通过这个思路扩展主题。
2.2 刻度是怎么画出来的——核心坐标换算
表盘上有60条刻度线,其中5的整数倍(也就是12点、1点、2点那些)是长刻度,其余是短刻度。关键不是一条条手写坐标,而是通过角度循环生成。
先说纯数学逻辑。表盘12点方向在屏幕上对应角度-90度,也就是-π/2弧度。每一分钟对应的圆心角是6度,因为60分钟对应360度。所以第i个刻度对应的角度为:
double theta = (i * 6 - 90) * Math.PI / 180.0;为什么是-90?因为GDI+里0度是三点钟方向,而我们的刻度要从12点开始,顺时针走一圈,所以要把起点逆时针调整90度。
刻度线的坐标分成“外端点”和“内端点”两个位置。外端点在半径radius处,内端点根据长刻度或短刻度向内收缩不同像素:
float outerX = cx + (float)(radius * Math.Cos(theta)); float outerY = cy + (float)(radius * Math.Sin(theta)); float innerX = cx + (float)((radius - 18f) * Math.Cos(theta)); float innerY = cy + (float)((radius - 18f) * Math.Sin(theta));然后把内外两点连成一条直线。画线时用不同粗细的Pen,长刻度用稍粗的笔,短刻度用细笔。长短刻度的长度也要拉开差距,比如长刻度向内缩18像素,短刻度向内缩8像素,这样表盘就有了信息的层次感。
这里有一个容易忽略的点:GDI+的DrawLine有抗锯齿开关,Graphics对象要设置SmoothingMode为AntiAlias,否则线条和圆弧边缘会有明显的锯齿,很掉档次。记得每次进入绘制方法时设置:
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;2.3 表盘数字的位置计算
数字不是贴在极边上的,要留出内缩距离,否则和短刻度挤在一起。我一般把数字半径设为radius-32f左右,也就是往外环内缩32像素。
每个数字的角度是i * 30 - 90,因为12个数字等分一圈,每个30度。坐标计算和刻度类似,区别在于数字有宽度和高度,绘制位置要“居中”。直接DrawString时,传入的是文字左上角坐标,如果不做偏移,文字看起来会偏在一边,要算一次尺寸再居中:
SizeF size = g.MeasureString(text, font); g.DrawString(text, font, brush, x - size.Width / 2f, y - size.Height / 2f);这样12、3、6、9四个数字就不会和窗口中心不对称了。虽然这四点的坐标恰好是正上下左右,但由于字体宽高不同,不偏移就会出现肉眼可见的位移。
还有个小细节:数字字体用粗体会更醒目,但字号不要太大。表盘半径小的时候,字号12-14足够了。如果用Segoe UI或Arial,视觉上还算干净。
3. 指针的运动逻辑:角度计算与平滑动画
3.1 时针、分针、秒针每帧角度怎么算
指针运动是整个项目里最重要的部分,也是最容易写“假”的部分。最简单的版本是这样的:取DateTime.Now,用小时、分钟、秒各自换算成角度。比如秒针就是second * 6度,分针就是minute * 6度,时针是(hour % 12)* 30度。
但这样有个明显问题:秒针跳动时分针和时针是静止不动的,一眼就能看出这是个“三档跳”的假时钟。真实时钟的秒针带动分针微动、分针带动时针缓动的。所以要用浮点连续值:
double secondAngle = (second + millisecond / 1000.0) * 6; double minuteAngle = (minute + second / 60.0) * 6; double hourAngle = (hour % 12 + minute / 60.0 + second / 3600.0) * 30;注意角度统一要减90度,因为指针绘制时的数学起点在三点钟方向。这套公式用秒和毫秒驱动秒针,用秒驱动分针,用分和秒驱动时针。这样指针每时每刻都在微动,看起来就很接近真实石英钟了。
很多人都习惯固定刷到每1000毫秒画一次,但那只能做出“跳秒”。动态模拟时钟的体验差距就在于平滑度上。为了实现连续运转,我把Timer的Interval设成100ms。100ms既不会肉眼可见地停顿,也不会让CPU占用率飙高。如果你想更顺滑,可以再细到50ms,但超过50ms后收益就不太明显了,反而白白消耗电量。
3.2 指针的几何绘制
指针是一条从尾巴到针尖的线段。尾巴不是从圆心开始,而是要反向越过圆心一小段,这样才能有配重感。例如时针总长约0.5倍半径,但尾部长约0.12倍半径。绘制时先算针尖坐标,再算尾巴坐标,然后画线。
针尖坐标公式:
float tipX = cx + (float)(length * Math.Cos(rad)); float tipY = cy + (float)(length * Math.Sin(rad));尾巴坐标是反向延长:
float tailX = cx - (float)(tailLength * Math.Cos(rad)); float tailY = cy - (float)(tailLength * Math.Sin(rad));这里rad等于指针偏转角加负90度后转为的弧度值,也就是前面计算出的角度直接乘Math.PI/180。理解了正方向是三点钟、负90度是十二点,这套坐标换算就不会错了。
指针的Pen要设置LineCap为Round,否则线条端点是个直角切面,不美观。圆头端点除了视觉上更顺滑,也更容易配合中心圆帽起到遮掩作用:
Pen pen = new Pen(Color.Black, 4f); pen.StartCap = System.Drawing.Drawing2D.LineCap.Round; pen.EndCap = System.Drawing.Drawing2D.LineCap.Round;顺序也很重要。绘制顺序应该是:表盘背景、刻度、数字、时针、分针、秒针、中心圆帽。一旦把表盘背景画在最后,指针就全被盖住了,看起来整个表是空的。
3.3 中心圆帽
指针汇聚到中心,如果直接用三条线戳过去,交汇处乱七八糟。所以要在所有指针画完后补一个实心圆。半径可以设为总半径的5%左右,颜色根据主题定。这个圆帽不仅是收束视觉中心,同时也是三根指针转轴的“物理表示”。
绘制圆帽时用FillEllipse:
float cap = radius * 0.045f; g.FillEllipse(Brushes.SlateGray, cx - cap, cy - cap, cap * 2f, cap * 2f);颜色可以和指针统一,也可以做成反色。深色主题里白色圆帽很亮眼,浅色主题里深灰更稳重。
4. 刷新机制、性能优化与常见雷区
4.1 Timer怎么驱动画面
Timer在WinForms里有简单和复杂两种路径。简单路径是开一个System.Windows.Forms.Timer,直接放在主UI线程里。Tick事件里写Invalidate()。这个Timer的优点是无需处理跨线程调用,一切都在UI线程内完成。
有些教程让你新建Thread再定时调用Control.Invoke,这属于过度设计。因为这个时钟只依赖UI线程绘图,System.Windows.Forms.Timer已经足够了。它本质是包装了消息循环里的WM_TIMER,触发频率稳定且不会产生线程安全噩梦。
Invalidate的好处是它不强迫立即重绘,而是给窗口发一个“区域已脏”的标记,等UI线程空闲再执行OnPaint。这就解释了为什么不用在Tick里手动创建Graphics并画图——那个操作会抢占窗口,还会造成重绘错乱和闪烁。记住一条铁律:绘图代码只放在OnPaint或Paint事件里,其他地方永远不要直接拿Graphics画画。
4.2 窗口缩放时图形保持在中心
如果窗口可以被拉伸,所有以中心为原点的坐标都应该在OnPaint里实时读取,而不是在构造函数里算一次就固定。原因很简单:窗口尺寸变了,中心点也变了。
所以每次OnPaint时都要重新计算:
int cx = ClientRectangle.Width / 2; int cy = ClientRectangle.Height / 2; float radius = Math.Min(ClientRectangle.Width, ClientRectangle.Height) / 2f - margin;窗口缩小到比表盘还小时也没关系,半径自动跟着变小,表盘整体缩放,不会有元素飞出窗外的情况。唯一要注意的是窗体最小尺寸限制,避免用户把窗口拖到1像素大小。可以设置如下:
this.MinimumSize = new Size(220, 220);4.3 性能和闪烁问题的根源
闪烁的根本原因是OnPaint里先画了背景,再画前景,两帧之间的时间差让旧画面和新画面交替露出来。WinForms默认背景绘制是立即填充的,所以每帧都会有一个“先擦白再画图”的过程。
双缓冲方案等于把整张画面先在内存里画完,然后一次性传送到屏幕。对于CPU来说这比逐像素画上去耗一点,但对现代电脑来说完全无感,肉眼获得的流畅度提升却非常明显。
还有个容易被忽略的性能点:不要在每个绘制周期里new Pen和Brush。Pen和Brush对象数量虽然少,但每100ms构造销毁一次也会产生内存抖动。更好的做法是把它们作为字段一次性创建,程序退出时释放。
4.4 高DPI屏幕模糊问题
现代显示器缩放率是125%、150%、200%。如果程序不感知DPI,Windows会把它当成96DPI来渲染,再拉升放大,导致文字边缘发虚。
解决方式很简单,在窗体构造函数里启用按DPI自动缩放:
AutoScaleMode = AutoScaleMode.Dpi;同时呢,如果你用的是.NET 6及以上,还可以在项目配置里加一条关于PerMonitorV2的设置。这样在高分屏上,字体和线条会清晰很多。这块配置比较小,但桌面工具类的项目几乎必有。
4.5 常见问题速查表
下面把这些坑整理成一张表,方便对号入座。
| 现象 | 原因 | 处理 |
|---|---|---|
| 运行后空白 | 窗体尺寸为0或某些区域被背景色覆盖 | 设置窗体大小和MinimumSize,检查绘制顺序 |
| 指针抖动、闪烁 | 未启用双缓冲,背景和前景交替绘制 | 加UserPaint + AllPaintingInWmPaint + OptimizedDoubleBuffer |
| 秒针每秒跳一下,分针不动 | 角度计算用整数格式 | 把毫秒和秒带入连续角度公式 |
| 缩窗后图形变形 | 使用固定半径而非动态计算 | 在OnPaint内重新读取ClientRectangle |
| 文字模糊发虚 | 未适配DPI缩放 | 开AutoScaleMode.Dpi |
| 切到后台后CPU占用高 | Timer继续定时刷新 | 在VisibleChanged里启停Timer |
| 数字偏位 | 未做文字宽高居中偏移 | 用MeasureString减半宽高 |
4.6 定时器的“省电模式”
这个时钟本质是持续刷新的小工具,如果不做任何处理,它会一直以每100ms一次的频率重绘,CPU占用虽然不是很高,但笔记本用户会心疼电量。
最划算的改进是:当窗体最小化或不可见时,把Timer停掉。恢复时再启动。这样后台完全没有绘制工作,只有Timer本身在等事件。代码可以在OnVisibleChanged里写:
_timer.Enabled = this.Visible;这个方法几乎零成本,但对体验的提升是立竿见影的。某开发者做自己的表盘时测试过,不加这行,后台驻留一天的CPU累计时间能顶上前台只挂两小时的水平。
5. 实战扩容:让模拟时钟更像产品
5.1 多时区支持
单一表盘容易扩展成双时区表。思路是在表盘内部再画一组刻度或文字,比如右侧拉一个小窗口显示另一个城市的当前时间。时间换算不手算偏移,而是用系统自带的时区接口。
5.2 秒表与倒计时模式
把Timer驱动改成Stopwatch驱动,指针对应的就不是“当前时间”而是“经过的时间”。这个改造思路会强化你对“时间源”抽象的理解。也就是说,把计算当前角度的方法抽成一个接口,比如ITimeSource,让ClockTimeSource(走当前系统时间)和StopwatchTimeSource(走计时器)都能驱动同一个绘制方法。
抽完以后,你会发现时钟界面本身一个字的绘图代码没动,只是把角度来源换了。这就是面向接口设计的好处,小题大做地实践一把很值得。
5.3 表盘主题与配色
做主题是一个愉快但又容易失控的部分。我建议把颜色参数集中到一个静态类里,例如DialTheme类,包含背景色、边框色、长刻度色、短刻度色、数字色、时针色、分针色、秒针色。
构造函数里把这些主题一次性加载进Pen、Brush字段。以后想加主题,只需要新建一个DialTheme实例,传进去就换肤。不要写“如果今天星期几就随机改画笔颜色”这种临时逻辑,那会让状态混乱,不好维护。
5.4 自定义数字字体
如果想做欧式复古表盘,可以试试在框架里找一些自带衬线字体。WinForms里直接取系统安装的字体名即可。字形不同数字坐标偏移也要重新验证,12点方向和6点方向的视觉居中往往不同,要逐个调整偏移量。
不过说实话,多数场景下“Segoe UI”加粗就够了,默认字体干净、无衬线,缩放到150%也不糊。
6. 完整示例代码:粘贴即可运行
为了让你快速看到效果,我整理了一份完整代码。新建一个WinForms项目,把Form1.cs的内容替换成下面的类即可。它的核心逻辑都在OnPaint里,工作流程很清晰。
using System; using System.Drawing; using System.Drawing.Drawing2D; using System.Windows.Forms; namespace DynamicClockDemo { public class Form1 : Form { private System.Windows.Forms.Timer _timer; private readonly Pen _hourTickPen = new Pen(Color.Black, 3f); private readonly Pen _minuteTickPen = new Pen(Color.Black, 1.2f); private readonly Pen _hourHandPen; private readonly Pen _minuteHandPen; private readonly Pen _secondHandPen; private readonly Brush _dialBrush = new SolidBrush(Color.White); private readonly Brush _numberBrush = new SolidBrush(Color.Black); public Form1() { Text = "动态模拟时钟"; ClientSize = new Size(360, 360); MinimumSize = new Size(220, 220); AutoScaleMode = AutoScaleMode.Dpi; BackColor = Color.White; SetStyle(ControlStyles.UserPaint, true); SetStyle(ControlStyles.AllPaintingInWmPaint, true); SetStyle(ControlStyles.OptimizedDoubleBuffer, true); _hourHandPen = new Pen(Color.Black, 5f) { StartCap = LineCap.Round, EndCap = LineCap.Round }; _minuteHandPen = new Pen(Color.Black, 3.2f) { StartCap = LineCap.Round, EndCap = LineCap.Round }; _secondHandPen = new Pen(Color.Red, 1.6f) { StartCap = LineCap.Round, EndCap = LineCap.Round }; _timer = new System.Windows.Forms.Timer { Interval = 100 }; _timer.Tick += (s, e) => Invalidate(); _timer.Start(); } protected override void OnVisibleChanged(EventArgs e) { base.OnVisibleChanged(e); _timer.Enabled = Visible; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.Clear(BackColor); int w = ClientSize.Width; int h = ClientSize.Height; float cx = w / 2f; float cy = h / 2f; float radius = Math.Min(w, h) / 2f - 14f; DrawDialBack(g, cx, cy, radius); DrawTicks(g, cx, cy, radius); DrawNumbers(g, cx, cy, radius); DrawHands(g, cx, cy, radius); } private void DrawDialBack(Graphics g, float cx, float cy, float radius) { g.FillEllipse(_dialBrush, cx - radius, cy - radius, radius * 2f, radius * 2f); using (var borderPen = new Pen(Color.Gray, 2f)) { g.DrawEllipse(borderPen, cx - radius, cy - radius, radius * 2f, radius * 2f); } } private void DrawTicks(Graphics g, float cx, float cy, float radius) { for (int i = 0; i < 60; i++) { double theta = (i * 6 - 90) * Math.PI / 180.0; bool isHour = (i % 5 == 0); float inner = isHour ? radius - 18f : radius - 8f; float x1 = cx + (float)(radius * Math.Cos(theta)); float y1 = cy + (float)(radius * Math.Sin(theta)); float x2 = cx + (float)(inner * Math.Cos(theta)); float y2 = cy + (float)(inner * Math.Sin(theta)); g.DrawLine(isHour ? _hourTickPen : _minuteTickPen, x1, y1, x2, y2); } } private void DrawNumbers(Graphics g, float cx, float cy, float radius) { using (var font = new Font("Segoe UI", 12f, FontStyle.Bold)) { float textRadius = radius - 32f; for (int i = 1; i <= 12; i++) { double theta = (i * 30 - 90) * Math.PI / 180.0; float x = cx + (float)(textRadius * Math.Cos(theta)); float y = cy + (float)(textRadius * Math.Sin(theta)); string text = i.ToString(); var size = g.MeasureString(text, font); g.DrawString(text, font, _numberBrush, x - size.Width / 2f, y - size.Height / 2f); } } } private void DrawHands(Graphics g, float cx, float cy, float radius) { DateTime now = DateTime.Now; double secondAngle = (now.Second + now.Millisecond / 1000.0) * 6 - 90; double minuteAngle = (now.Minute + now.Second / 60.0) * 6 - 90; double hourAngle = (now.Hour % 12 + now.Minute / 60.0 + now.Second / 3600.0) * 30 - 90; DrawHand(g, cx, cy, hourAngle, radius * 0.5f, radius * 0.12f, _hourHandPen); DrawHand(g, cx, cy, minuteAngle, radius * 0.72f, radius * 0.18f, _minuteHandPen); DrawHand(g, cx, cy, secondAngle, radius * 0.85f, radius * 0.25f, _secondHandPen); float cap = radius * 0.045f; g.FillEllipse(Brushes.SlateGray, cx - cap, cy - cap, cap * 2f, cap * 2f); } private void DrawHand(Graphics g, float cx, float cy, double angleDeg, float length, float tail, Pen pen) { double rad = angleDeg * Math.PI / 180.0; float tipX = cx + (float)(length * Math.Cos(rad)); float tipY = cy + (float)(length * Math.Sin(rad)); float tailX = cx - (float)(tail * Math.Cos(rad)); float tailY = cy - (float)(tail * Math.Sin(rad)); g.DrawLine(pen, tailX, tailY, tipX, tipY); } protected override void Dispose(bool disposing) { if (disposing) { _timer?.Dispose(); _hourTickPen?.Dispose(); _minuteTickPen?.Dispose(); _hourHandPen?.Dispose(); _minuteHandPen?.Dispose(); _secondHandPen?.Dispose(); _dialBrush?.Dispose(); _numberBrush?.Dispose(); } base.Dispose(disposing); } } }窗体打开后,秒针会平滑扫动,分针也会跟着缓慢前进,时针则随着分钟推进一点点偏移。每个100ms刷新一次,视觉上已经很顺滑。如果再搭配深色背景和霓虹色指针,效果会更好看。
7. 实战排坑记录:亲测遇到的三个问题
7.1 刚启动时窗体可能出现一条斜线
如果你在构造函数里设置了ClientSize后又执行了SetStyle,理论上没问题。但如果你的机器DPI缩放是125%或150%,AutoScaleMode可能会在窗体加载后微调尺寸,导致半径计算边界不一样。
某天我在一台高DPI机器上跑这份代码,发现窗口刚打开时右边和下边各有一条细线露出来,像是表盘被“撑爆”了一点。原因在于设置ClientSize=360,360,但DPI缩放后实际可用区域略小于设置值,半径仍按360的一半来算,线条就溢出到了非客户区。
解决办法是在窗体的OnLoad里再强制调用一次OnResize或Invalidate,让所有坐标重新计算。或者更稳健:不在构造函数固定ClientSize,直接设定窗体大小后让Padding自动吸附。
实践中我偏好直接在OnPaint里读取实际的大小,并按实际尺寸计算半径,这就不会出现上述“本地变量和实际渲染尺寸不一致”的问题。上面完整的代码就是这么做的,如果你在测试中不出现斜线,那就说明一切正常。
7.2 秒针指针翻转时出现“绕远路”的错觉
秒针转完一圈回到12点,角度从359度跳回0度。如果绘制逻辑角度单调递增,你会看到指针在最后一瞬间“反转”了大半圈,视觉上很不自然。解决方式是不要重置角度,让它继续累加到360以上。反正三角函数是按角度模360计算的,多画几圈也看不出区别。上面的代码用的是DateTime实时取数,天然不存在累加误差,所以不会踩这个坑。
7.3 Timer刷新太频繁导致控件占满CPU
我刚开始图省事把Interval设成10ms,想着更顺滑。结果后台进程瞬间把CPU占用拉到20%。其实人眼对60Hz以上就不太敏感,而屏幕本身的刷新率也才60Hz。10ms意味着每秒重绘100次,多余的帧全被丢弃了,白费资源。最终调到100ms既节约CPU,又因为指针本身是平滑渐变的,反而看不出卡顿。如果想做成类似秒表的高速刷新,也可以根据场景调节Interval。
除了CPU,内存峰值也是一个观察点。如果代码里每个Tick都new Pen,长时间运行后GC压力会变大。我建议把常用Pen、Brush做成字段复用,只在主题变化时重建,释放时统一Dispose。小项目虽然不至于内存爆炸,但养成这个习惯后在更大的绘图工具里会很有帮助。
最后再分享一个经验:如果你想让这个时钟看起来更高级,可以把整个表盘画到一张预先渲染好的Bitmap里,指针单独一个图层,刷新时只更新指针层。这样刻度、数字这些“静态资产”不用每帧重复计算坐标,性能还能再上一层楼。很多真正在壁纸引擎里跑的桌面时钟组件,就是这么分层的。