FlipIt 技术解析:C Windows Forms 实现翻页时钟屏保的完整方案

原创2026-10-14 17:43:461,653 阅读
文章标签:桌面应用

FlipIt 技术解析:C# Windows Forms 实现翻页时钟屏保的完整方案

FlipIt 是一个基于 C# 和 Windows Forms 的 Windows 屏保(.scr 文件),受 Fliqlo 启发,用原生 GDI+ 绘图还原了"翻页时钟"(Split-Flap)的视觉效果,并提供多时区世界时钟显示。本文以 FlipIt 的 README 为主线,结合仓库源码,完整讲清它的运行要求、安装与构建方式、屏保命令行协议(/c、/p、/s)、配置文件格式与渲染实现,读完你可以掌握"用 .NET 编写一个可注册到 Windows 的屏保程序"的完整技术路径。

FlipIt 当前时间翻页时钟界面

FlipIt 世界时钟(World Times)界面

一、项目背景与运行要求

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 /s to have the screensaver display full screen on F5/Start.

这段说明背后对应工程文件里的两个具体配置:

  1. 构建后复制事件。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,两者对应不同位数场景。
  2. 调试时按 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 的绘制流程:

  1. 依据屏幕宽高与 borderPercent 计算每个"数字盒"的边长(宽方向按 2 个盒、高方向按 1 个盒反解,取较小者);盒间距为盒宽的 5%(BoxSeparationPercent);
  2. 每个盒用 RoundedRectangle.cs 生成的圆角路径 + 垂直 LinearGradientBrush(上 #121212 到下 #0A0A0A)填充,模拟塑料翻页板;
  3. 大号数字(盒尺寸 85% 的粗体)居中绘制;12 小时制时在左上/左下角用 9% 小号粗体绘制 AM/PM;
  4. 盒正中画一条 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+ 自定义绘图的示例来学习。

登录后查看全文
FlipIt