FlipIt 技术解析:C Windows Forms 实现翻页时钟屏保的完整方案
FlipIt 技术解析:C# Windows Forms 实现翻页时钟屏保的完整方案
FlipIt 是一个基于 C# 和 Windows Forms 的 Windows 屏保(.scr 文件),受 Fliqlo 启发,用原生 GDI+ 绘图还原了"翻页时钟"(Split-Flap)的视觉效果,并提供多时区世界时钟显示。本文以 FlipIt 的 README 为主线,结合仓库源码,完整讲清它的运行要求、安装与构建方式、屏保命令行协议(/c、/p、/s)、配置文件格式与渲染实现,读完你可以掌握"用 .NET 编写一个可注册到 Windows 的屏保程序"的完整技术路径。
一、项目背景与运行要求
FlipIt 的诞生动机直接写在 README.md 中:Fliqlo 的 Windows 版因 2015 年底的 Flash 更新而停止工作,因此该项目决定用不含 Flash 的方式重新实现翻页时钟屏保。这一背景决定了它的技术选型——一个标准的 .NET Framework 桌面应用(WinExe),全部使用 System.Drawing(GDI+)手工绘制,没有任何 Web/Flash 依赖。
官方声明的运行要求(Requirements)如下:
- Microsoft Windows
- .NET Framework 4.8
这一点在工程文件 FlipIt.csproj 中得到印证:<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>,输出类型为 WinExe,程序集名为 FlipIt(见 <AssemblyName>FlipIt</AssemblyName>)。注意 app.config 中声明的 supportedRuntime 仍是 v4.0 基线(sku 为 .NETFramework,Version=v4.6),这是 .NET 4.x 系列内部的滚动兼容写法;真正的项目目标框架以 csproj 中的 v4.8 为准。
另外,<a href="https://link.gitcode.com/i/30a74542fa8e36552d0e576383f12526" target="_blank">assembly: AssemblyVersion("1.3.0.*")] 表明当前源码对应的版本为 1.3.x(见 [AssemblyInfo.cs)。
二、不编译直接安装:.scr 文件放置位置
README 给出的是最轻量的部署方式:无需 Visual Studio 构建,直接把 Release 页面提供的 .scr 文件复制到系统屏保目录即可:
- 64 位 Windows:
C:\Windows\SysWOW64 - 32 位 Windows:
C:\Windows\System32
屏保本质上就是一个改名成 .scr 的 PE 可执行文件,Windows 屏保设置对话框会扫描这两个目录并加载其中的可执行程序。文件放置位置决定了它会被哪一套位数的 Windows 子系统加载,因此 32 位/64 位要分别处理。
三、用 Visual Studio 构建:Release + 管理员 + /s
README 中"Building with Visual Studio"一节的原文操作要点是:
Run in Release mode and Run as Administrator to have the build event copy the screensaver to the Windows SysWOW64 or System32 folder. Set the Command line arguments to
/sto have the screensaver display full screen on F5/Start.
这段说明背后对应工程文件里的两个具体配置:
-
构建后复制事件。FlipIt.csproj 末尾定义了:
<PropertyGroup> <PostBuildEvent>copy "$(TargetPath)" %25SystemRoot%25\system32\FlipIt.scr</PostBuildEvent> </PropertyGroup>即每次构建后,MSBuild 会自动把
bin\<Configuration>\FlipIt.exe复制为%SystemRoot%\system32\FlipIt.scr。注意两点:- 该命令需要写进系统目录,所以必须以管理员身份运行 Visual Studio / 启动调试,否则
copy会因权限不足失败——这正是 README 要求 "Run as Administrator" 的原因; - 当前 PostBuildEvent 写死为
system32,在 64 位 Windows 上从 VS 自动安装时,实际落点仍是 System32 下的 32 位程序,而手工安装时 README 建议放入 SysWOW64,两者对应不同位数场景。
- 该命令需要写进系统目录,所以必须以管理员身份运行 Visual Studio / 启动调试,否则
-
调试时按 F5 直接全屏预览。屏保程序的入口会解析命令行参数,将启动参数设为
/s后,按 F5 启动就会直接以全屏模式运行屏保(详见下节参数协议),方便开发中快速目测效果。
构建入口是 Program.cs 中的 Main 方法,它遵循 Windows 屏保程序的通用约定(其代码源头也来自 README 致谢中提到的 Frank McCown 的 "Creating a Screen Saver with C#" 一文)。
四、屏保命令行协议:/c、/p、/s 的完整解析
Windows 屏保与系统的交互完全通过命令行参数完成。Program.cs 实现了一个完整且容错的参数分发逻辑:
string firstArgument = args[0].ToLower().Trim();
string secondArgument = null;
// Handle cases where arguments are separated by colon.
// Examples: /c:1234567 or /P:1234567
if (firstArgument.Length > 2)
{
secondArgument = firstArgument.Substring(3).Trim();
firstArgument = firstArgument.Substring(0, 2);
}
else if (args.Length > 1)
secondArgument = args[1];
if (firstArgument == "/c") // Configuration mode
Application.Run(new SettingsForm(settings));
else if (firstArgument == "/p") // Preview mode
...
else if (firstArgument == "/s") // Full-screen mode
ShowScreenSaver(settings);
三种模式及其系统调用场景:
| 参数 | 模式 | 由谁触发 | 行为 |
|---|---|---|---|
/c |
Configuration(配置) | 屏保设置对话框的"设置"按钮 | 打开 SettingsForm 配置界面 |
/p:<句柄> |
Preview(预览) | 设置对话框中的小预览窗口 | 在指定窗口句柄内嵌渲染小尺寸预览 |
/s |
Full-screen(全屏) | 屏保激活(空闲超时或手动"立即显示") | 在每个显示器上各开一个 MainForm 全屏显示 |
| (无参数) | 等同 /c |
用户直接双击运行 .scr/exe | 打开配置界面 |
| (其他参数) | — | — | 弹出 "not valid" 错误提示框 |
源码中一个值得注意的工程细节是对冒号分隔形式的兼容:Windows 有些版本/场景会以 /c:1234567 这种"参数与值粘在一起"的方式传参,代码通过判断首参长度大于 2 时拆出前两个字符作为参数名、第 4 个字符之后作为第二参数(firstArgument.Substring(3))来兼容;同时也兼容 /c 1234567 这种空格分隔的传统形式。
预览模式的窗口嵌入(/p)是屏保开发中较难的一环,MainForm.cs 通过三个 Win32 API 调用完成把窗体"塞进"系统预览小窗:
// Set the preview window as the parent of this window
SetParent(Handle, previewWndHandle);
// Make this a child window so it will close when the parent dialog closes
SetWindowLong(Handle, -16, new IntPtr(GetWindowLong(Handle, -16) | 0x40000000));
// Place our window inside the parent
GetClientRect(previewWndHandle, out var parentRect);
Size = parentRect.Size;
Location = new Point(0, 0);
SetParent将 FlipIt 的主窗体设为系统预览窗口的子窗口;- 对
GWL_STYLE(索引 -16)置位0x40000000(WS_CHILD)使其成为真正的子窗口,父对话框关闭时它会自动销毁; GetClientRect读取父窗口客户区尺寸,把自身尺寸、位置与之对齐,从而实现像素级贴合。
全屏模式(/s)则是遍历 Screen.AllScreens,为每块屏幕根据其设备名取对应配置并各创建一个 MainForm(见 Program.cs 的 ShowScreenSaver),天然支持多显示器。
退出机制:全屏运行下,MainForm 监听鼠标移动、按键和鼠标点击三种输入并调用 Application.Exit()(见 MainForm.cs)。其中鼠标移动做了一个经典处理——记录 _mouseLocation,忽略窗体刚显示时自动产生的第一次移动事件,只有当鼠标位置真实变化时才退出,避免屏保刚启动就误退出。
五、配置体系:Settings.ini 的完整格式
FlipIt 的所有设置由 FlipItSettings.cs 统一读写,文件位于:
%LOCALAPPDATA%\FlipIt\Settings.ini
即当前用户的 LocalApplicationData 目录下的 FlipIt 子目录(代码见 FlipItSettings.cs 的 Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FlipIt"))。文件不存在时程序使用默认值,因此首次运行即开箱可用。
INI 文件由自研的轻量解析器 IniFile.cs 实现(内存中维护 Section/Key/Value 列表,Save 时整体重写文件),配置结构如下:
[General]
Display24Hr=0
ShowDstIndicator=1
Scale=70
[Screen Monitor1]
DisplayType=1
[Screen Monitor1 Locations]
New York=Eastern Standard Time
London=GMT Standard Time
各键的含义与默认值(依据 FlipItSettings.Load 源码):
| 节 | 键 | 含义 | 默认值 |
|---|---|---|---|
General |
Display24Hr |
是否使用 24 小时制(0/1) |
false |
General |
ShowDstIndicator |
世界时钟中是否显示夏令时 * 标记 |
true |
General |
Scale |
时钟整体缩放百分比,0–100 | 70 |
Screen <设备名> |
DisplayType |
该屏显示内容,见下表 | 1(CurrentTime) |
Screen <设备名> Locations |
<显示名>=<TimeZoneID> |
世界时钟的城市条目,键为显示名,值为 Windows 时区 ID | 无 |
DisplayType 的取值来自 DisplayType.cs 枚举:
public enum DisplayType
{
None = 0,
CurrentTime = 1,
WorldTime = 6
}
每个物理屏幕可以有独立的显示类型和城市列表,节名使用"Screen + 设备名"的形式,设备名会先经过 TrimStart('\\', '.') 清洗(把 \\.\DISPLAY1 之类变成 DISPLAY1)。
两处容易踩坑的细节,源码里都有防御性处理:
- 显示名不能包含
=:因为 INI 以=分隔键值,SettingsForm.cs 在编辑城市名时会显式检查并拒绝含=的名称; - 失效时区键会被自动清理:
Load时若某条 Locations 的值解析为空,会调用iniFile.DeleteKey移除该键(见 FlipItSettings.cs),避免脏数据积累。
此外,运行时异常会追加写入同目录下的 Errors.txt(MainForm.LogError),可作为排查问题的日志入口。
5.1 Scale 参数的实际几何含义
Scale(0–100)不只是"放大缩小",CurrentTimeScreen.cs 把它线性映射为屏幕留白百分比:
// The border is between 5% and 30% of the screen
// * A scale of 0 = 5%
// * A scale of 100 = 30%
var borderPercent = (100 - scalePercent) / 4 + 5;
即 Scale=0 时留白最小(5%)、Scale=100 时留白最大(30%),数字大小随之在屏幕中伸缩。设置界面中该值来自滑条 scaleTrackBar.Value * 10(见 SettingsForm.cs),滑条步进 10,与默认值 70 对应。
六、两种渲染模式的实现
MainForm.PaintTime 根据该屏的 DisplayType 选择渲染器(见 MainForm.cs):
CurrentTime→CurrentTimeScreen(翻页时钟);WorldTime→WorldTimesScreen(多城市列表),且当用户没有配置任何城市时,回退到内置的默认城市列表GetDefaultLocations()(洛杉矶、纽约、UTC、伦敦、巴黎、夏威夷、布里斯班、墨尔本、惠灵顿等);- 预览模式下永远显示
CurrentTimeScreen。
两种模式共用抽象基类 TimeScreen.cs,它封装了绘图上下文与字体加载:
protected static readonly Color BackColorTop = Color.FromArgb(255, 18, 18, 18);
protected static readonly Color BackColorBottom = Color.FromArgb(255, 10, 10, 10);
protected static readonly Brush FontBrush = new SolidBrush(Color.FromArgb(255, 183, 183, 183));
深黑渐变的"翻页板"底色和浅灰字色都定义在这里,与两张截图的观感一致。
6.1 当前时间:CurrentTimeScreen
CurrentTimeScreen.cs 的绘制流程:
- 依据屏幕宽高与
borderPercent计算每个"数字盒"的边长(宽方向按 2 个盒、高方向按 1 个盒反解,取较小者);盒间距为盒宽的 5%(BoxSeparationPercent); - 每个盒用 RoundedRectangle.cs 生成的圆角路径 + 垂直
LinearGradientBrush(上#121212到下#0A0A0A)填充,模拟塑料翻页板; - 大号数字(盒尺寸 85% 的粗体)居中绘制;12 小时制时在左上/左下角用 9% 小号粗体绘制
AM/PM; - 盒正中画一条 4px 的黑色横线(
SplitWidth),形成上下两半"翻开"的视觉分隔。
一个小细节:12 小时制小时数格式化用的是 "%h" 而非 "%hh"——源码注释指出这是为了规避 .NET 的 FormatException(单个 % 后跟非标准格式符在 .NET 中恰好被容忍,见 CurrentTimeScreen.cs 注释)。
刷新策略上,MainForm 用 1 秒间隔的 moveTimer 驱动,但只在分钟变化时才重绘(_lastMinute != minute 才 PaintTime),对屏保这种低功耗场景是合理的省电设计。
6.2 世界时钟:WorldTimesScreen 与时区数据
WorldTimesScreen.cs 采用"逐字符一盒"的布局:每一行是 城市名(右对齐定宽)+ 时间(右对齐定宽)+ 可选夏令时星号 + 星期缩写,每个字符各占一个小盒,整体按城市当前时间升序排列(_cities.OrderBy(c => c.CurrentTime))。行宽按"最长城市名 + 时间字符数 + 3 个星期的指示位"统一计算,保证各列垂直对齐。
时间列的格式规则在 FormatTime 中(与 12/24 小时制、ShowDstIndicator 联动):
12hr: London 11:59:59 PM* MON
24hr: London 23:59:59* MON
*表示该城市当前处于夏令时(ShowDstIndicator=1时显示,否则占位空格);- 只有当地日期与本地相差非零天时,才在末尾显示
ddd星期缩写(如WED),否则留 3 个空格——这正是截图中部分行末尾出现WED/THU的原因。
每个城市的时区换算在 Location.cs 中完成:
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneID);
CurrentTime = TimeZoneInfo.ConvertTime(now, TimeZoneInfo.Local, timeZoneInfo);
IsDaylightSavingTime = timeZoneInfo.IsDaylightSavingTime(CurrentTime);
DaysDifference = (CurrentTime.Date - now.Date).Days;
TimeZoneID 使用的是 Windows 时区 ID(如 Eastern Standard Time、GMT Standard Time),通过系统 API 解析;若 ID 不存在则置 HasError = true,界面上对应行显示 Error——这是一层对"时区数据过期"的兜底。
城市候选列表的来源是 src/FlipIt/res/TimeZoneCities.txt(共 139 行,以 CLDR windowsZones.xml 2020f 版映射生成),格式为 TimeZoneID=City1,City2,...,例如:
Eastern Standard Time=New York,Nassau,Toronto,Iqaluit,Montreal,...
Hawaiian Standard Time=Honolulu,Rarotonga,Tahiti,Johnston,GMT+10
它由仓库根目录的 GenerateFlipItTimeZoneCities.linq(LINQpad 脚本)从 Unicode CLDR 的 windowsZones.xml 批量生成——即"时区 ID ↔ 城市别名"这张表是可再生成的静态数据。设置界面加载它时(SettingsForm.cs 的 LoadLocationsFromFile)会做两点过滤:跳过 PST8PDT 这类纯偏移别名,跳过长度大于 3 的 GMT+xx 风格名称,避免给用户提供没有真实城市含义的条目;加载完成后填充搜索框的自动补全源,用户输入城市名(不区分大小写匹配,见 StringExtensions.cs 的 HasSameText)即可加入当前屏幕的列表。
6.3 私有字体嵌入:让翻页字体随屏保分发
两个渲染类分别通过抽象方法 GetFontResource() 返回嵌入资源中的字体二进制:当前时间用 HelveticaLTStd_BoldCond,世界时钟用 HelveticaLTStd_Cond(对应 res/ 目录下的两个 TTF,在 csproj 中声明)。TimeScreen.cs 通过 PrivateFontCollection.AddMemoryFont 从内存字节直接加载字体:
IntPtr ptr = Marshal.AllocCoTaskMem(fontResource.Length);
Marshal.Copy(fontResource, 0, ptr, fontResource.Length);
pfc.AddMemoryFont(ptr, fontResource.Length);
Marshal.FreeCoTaskMem(ptr);
这使得翻页时钟标志性的窄体(Condensed)Helvetica 字形无需用户安装字体即可在任何机器上显示——屏保是自包含的。源码注释还记录了一个 PrivateFontCollection 的坑:同一集合中不能同时加入常规和粗体两个字体文件,否则请求粗体时会"自己仿粗"而不是用提供的字体文件,因此这里每次只加载一种字体。
6.4 可测试的时间抽象
值得注意的是,所有取时逻辑都不直接调 DateTime.Now,而是经由 SystemTime.cs 的静态门面:
public static DateTime Now => NowForTesting ?? DateTime.Now;
NowForTesting 允许在调试时固定"当前时间"(MainForm.cs 的 MainForm_Shown 中保留了被注释的测试时间注入示例),配合 Program.cs 中同样被注释的 CultureInfo("nl-NL") 调试行,可以看出作者在开发期用这两个"测试开关"验证了固定时间与异国文化格式场景;TimeScreen.FormatAmPm 也正是因为某些文化(如 nl-NL)会把上午/下午输出成长于 2 个字符的 a.m. 而手工格式化 AM/PM 的。
七、设置界面(/c 模式)功能概览
/c 或无参数启动时进入 SettingsForm.cs 配置窗体,其控件与配置项的映射关系:
- 12/24 小时制单选 →
General\Display24Hr; - 显示夏令时标记复选框 →
General\ShowDstIndicator; - Scale 滑条 →
General\Scale(滑条值 × 10); - 屏幕列表(自定义绘制,每项带显示器图标与 "Screen N" 描述,描述串格式为
Screen {n} - {w} x {h},见 ScreenSetting.cs)→ 选定屏幕后设置其DisplayType(None / CurrentTime / WorldTime 三选一); - 城市管理:仅当该屏选择 WorldTime 时启用,支持搜索添加(自动补全 + 防重复提示)、双击行内重命名(校验非空、不含
=)、删除;列表与ScreenSetting.Locations通过SyncLocationsFromListViewToSettings双向同步。
点击 OK 时调用 _settings.Save() 落盘 INI 文件;取消则不保存。窗体还会通过 Assembly.GetName().Version 显示当前版本(1.3.0)。
八、许可证与致谢
- 按 README.md 的说法,该项目遵循 Creative Commons License(BY-SA 2.0);
- 仓库中实际附带的 LICENSE.txt 则是 CC0 1.0 Universal(公共领域奉献)全文,两者并存,引用代码时建议以仓库内的 LICENSE.txt 为准并保留出处;
- 源码致谢自 Frank McCown 的 "Creating a Screen Saver with C#"(2010 年项目),
Program.cs与MainForm.cs文件头注释均保留了该出处说明。
九、小结
从 README.md 的简短说明出发,对照源码可以看到 FlipIt 作为一个完整屏保的全部关键链路:MSBuild 构建后事件完成 .scr 部署(csproj PostBuildEvent)→ /c、/p:<句柄>、/s 三态命令行协议(Program.cs)→ INI 多屏配置模型(FlipItSettings + 自研 IniFile)→ GDI+ 双模式渲染(CurrentTimeScreen/WorldTimesScreen + 私有字体嵌入)→ Windows 时区 API 驱动的世界时钟(Location + 可再生的 TimeZoneCities.txt 数据)。它体量不大,但把 Windows 屏保开发中的注册位置、参数协议、预览窗口嵌入、多屏处理、配置持久化等典型问题都给出了可参考的实现,也适合当作一个 GDI+ 自定义绘图的示例来学习。

