简介:C# WinForm 界面美化与自定义控件开发常受限于原生控件样式单一、皮肤切换和绘制扩展成本高。这份 CSkin 源码面向中高级 C# 开发者、桌面应用界面设计者及希望研读控件绘制原理的学习者,提供皮肤管理、自定义按钮与窗体、事件处理、布局管理和性能优化等完整实现,可直接用于项目换肤或二次开发。压缩包共 2000 个文件、约 25.24MB,包含 560 个 cs 源码、846 个 gif 与 230 个 png/213 个 jpg 界面素材、35 个 resx 资源,以及 22 个 config、11 个 csproj 和 18 个 dll 等工程与依赖文件,css/htm/js 示例页面也一并收录,源码、资源与演示文件层次较完整。已有 1859 人学习下载。读者可从中梳理皮肤配置加载、控件状态绘制、双缓冲与资源复用思路,也可借鉴目录组织、事件分发和 API 设计,快速搭建美观且运行稳定的 WinForm 界面,并积累可迁移的 UI 编程实践。
1. 接手老 WinForms 项目时的界面改造
接手一个跑了七八年的 WinForms 项目,最扎眼的往往不是业务逻辑,是界面——灰底方角的按钮、凹陷的输入框、系统默认的标题栏,客户一句“看着像上个年代的”就能让验收卡住。C# UI 库 CSkin 源码要解决的就是这件事:它把 WinForms 那套原生控件整套重画了一遍,按钮、输入框、下拉框、选项卡、表格、窗体边框全部换成圆角、渐变、带悬停反馈的皮肤控件,同时仍然待在 .NET Framework 的 WinForms 体系里,不用把几万行代码迁到 WPF。适合两类人:一类是手里有存量 WinForms 项目、只想换皮不想重构的开发者;另一类是想搞清楚 GDI+ 自绘控件到底怎么写、拿一套现成代码当模板研究的人。下面按“能干什么 → 怎么跑 → 怎么改”的顺序拆。
2. 技术选型与整体架构:一套自绘皮肤库是怎么搭起来的
2.1 为什么是 GDI+ 自绘而不是换框架
老项目要美化,摆在面前的路其实只有三条:整体迁到 WPF、买一套第三方控件库、自己重写控件的绘制。第一条路的成本不在技术,在存量代码——Form 继承链、事件绑定、第三方组件、打印和报表模块,随便一个环节都能让工期翻倍;第二条路对个人维护的项目来说预算不现实。剩下的就是自绘,而 WinForms 恰好把这条路留得很宽:Control类的OnPaint是虚方法,SetStyle能开双缓冲,GraphicsPath能画任意形状,TextRenderer能拿到和系统一致的文字渲染效果。
CSkin 走的就是第三条路。它的每个控件都继承自 WinForms 原生控件或者一套自己的基类,公开的属性和事件尽量和原生保持一致(Text、Enabled、Click、SelectedIndex这些都不改名),所以替换成本很低:把Button改成SkinButton,代码里button1.Text = "确定"一行都不用动。代价也很清楚——自绘意味着 DPI 缩放、键盘导航、无障碍、设计器支持这些原本由系统兜底的东西,全部要自己补。这套库的价值一半在“画得好看”,另一半在“这些坑已经有人踩过一遍并写好了兜底逻辑”。
2.2 三层结构:绘制基类、控件层、主题资源层
把源码摊开看,整体是清晰的三层。
最底下是基础绘制层,放的是和具体控件无关的东西:圆角矩形路径的生成、颜色明暗计算、字体测量、Win32 API 的 P/Invoke 声明(ReleaseCapture、SendMessage、SetClassLong、DwmExtendFrameIntoClientArea这一类)。这一层的代码基本是纯函数,改起来风险最低,也最值得单独抽出来复用。
中间是控件层,一个SkinBaseControl基类把OnPaint收口,子类只实现“怎么画自己”这一件事。按钮、输入框、复选、单选、进度条、选项卡这些轻量控件直接继承它;表格、列表、树这类需要承载子项的,则继承原生DataGridView、ListView、TreeView再重写绘制。窗体是单独一条线,SkinForm继承Form,处理的是非客户区那套消息。
最上面是主题资源层,一个静态色表加一组图片资源。控件绘制时不写死颜色,全部从色表里取,换主题只要改一处。
2.3 目录结构与改动频率
按这套库常见的组织方式,目录大致是这样分的:
| 目录 | 主要内容 | 二次开发时的改动频率 |
|---|---|---|
Controls/ | 全部皮肤控件的实现,按控件类型拆文件 | 高,加控件、改绘制都在这 |
Win32/ | 窗口消息、非客户区、DWM 相关的 P/Invoke | 低,除非要改窗体行为 |
Utils/ | 圆角路径、颜色计算、图像缩放等工具方法 | 中,画新形状时会加 |
Themes/ | 默认色表、字体、图片资源引用 | 高,换品牌色必改 |
Resources/ | 图标、背景图、按钮态图片 | 中 |
Demo/ | 演示工程,每个控件一个示例窗体 | 低,但排查时最先看它 |
新手容易犯的一个错是直接改Controls/里的绘制代码来适配自己的配色,改到第十个控件就会发现改不动了。正确顺序永远是先动Themes/,色表解决不了再考虑继承某个控件覆写绘制方法,最后才去改源码。
3. 核心控件拆解:从基类到按钮、窗体、输入框的绘制链路
3.1 绘制基类:把 OnPaint 收口到一处
所有轻量控件的绘制入口都收在一个基类里,这是整套库最值得先读的一个文件。它干的事只有三件:打开正确的绘制样式、统一 Graphics 的渲染质量、把绘制动作转成一个虚方法交给子类。
public class SkinBaseControl : Control { public SkinBaseControl() { // 双缓冲 + 自绘 + 不擦背景,三件套缺一个就会闪 SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.SupportsTransparentBackColor, true); BackColor = Color.Transparent; // 交给父容器画背景,自己只画前景 } protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 圆角不锯齿靠它 g.PixelOffsetMode = PixelOffsetMode.HighQuality; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; // 文字不发虚靠它 OnPaintSkin(g); } // 子类只覆写这一个方法,不用再关心缓冲和渲染质量 protected virtual void OnPaintSkin(Graphics g) { } // 屏蔽系统擦背景,闪烁的另一半原因在这里 protected override void WndProc(ref Message m) { const int WM_ERASEBKGND = 0x0014; if (m.Msg == WM_ERASEBKGND) { m.Result = (IntPtr)1; return; } base.WndProc(ref m); } }ControlStyles.OptimizedDoubleBuffer和手动屏蔽WM_ERASEBKGND是配套的:前者让绘制发生在缓冲区里,后者阻止系统在绘制前先把背景刷成控件色。少了任何一边,拖动窗口或者快速悬停时就会出现那种一闪一闪的“玄学”抖动。SupportsTransparentBackColor加BackColor = Transparent的组合,让控件能嵌在带背景图的父容器上而不露出灰块,这是皮肤库能不能“融合”的关键开关。
OnPaintSkin这个命名是个小设计,它把“画什么”和“在哪画”彻底分开。子类里写绘制代码时不需要再考虑e.Graphics会不会被释放、SmoothingMode是不是默认值,这类问题全部在基类解决。
3.2 SkinButton:四态状态机与圆角路径
按钮是皮肤库里用得最多的控件,也是最容易看出实现水平的地方。一个合格的皮肤按钮要维护四个状态:常态、悬停、按下、禁用,每个状态对应一组背景色、边框色、文字色,加上圆角、可选的渐变和图标。
public class SkinButton : SkinBaseControl { private bool _hover, _pressed; public int Radius { get; set; } = 4; // 圆角半径,0 即直角 public Color NormalColor { get; set; } = SkinTheme.Primary; public Color HoverColor { get; set; } = SkinTheme.PrimaryHover; public Color PressedColor { get; set; } = SkinTheme.PrimaryDark; protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); } protected override void OnMouseLeave(EventArgs e) { _hover = false; _pressed = false; Invalidate(); base.OnMouseLeave(e); } protected override void OnMouseDown(MouseEventArgs e) { if (e.Button == MouseButtons.Left) { _pressed = true; Invalidate(); } base.OnMouseDown(e); } protected override void OnMouseUp(MouseEventArgs e) { _pressed = false; Invalidate(); base.OnMouseUp(e); } protected override void OnPaintSkin(Graphics g) { Color back = !Enabled ? SkinTheme.DisabledBack : _pressed ? PressedColor : _hover ? HoverColor : NormalColor; // 内缩 1 像素,否则描边会被裁掉一半 var rect = new Rectangle(0, 0, Width - 1, Height - 1); using (var path = GraphicsHelper.CreateRoundRect(rect, Radius)) using (var brush = new SolidBrush(back)) using (var pen = new Pen(SkinTheme.Border, 1f)) { g.FillPath(brush, path); g.DrawPath(pen, path); } // 文字用 TextRenderer 而不是 Graphics.DrawString,清晰度差一个档次 TextRenderer.DrawText(g, Text, Font, ClientRectangle, Enabled ? SkinTheme.TextOnPrimary : SkinTheme.DisabledText, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); } }几个细节值得说。Invalidate()而不是Refresh():前者只是标记区域无效、等消息循环合并后重绘,后者会立刻同步重绘,在鼠标移动这种高频事件里用Refresh会明显卡顿。矩形内缩 1 像素是因为 GDI+ 的Pen以路径为中心向两侧各画一半,边框宽度 1 时外侧那一半会被控件边界裁掉,看起来就是“右边和下边没框”。
圆角路径统一由一个工具方法生成,这里面有个典型的翻车点:
public static GraphicsPath CreateRoundRect(Rectangle rect, int radius) { var path = new GraphicsPath(); if (radius <= 0) { path.AddRectangle(rect); return path; } // 直径不能超过短边,否则弧线会反向翻出去形成鬼畜图形 int d = Math.Min(radius * 2, Math.Min(rect.Width, rect.Height)); path.AddArc(rect.X, rect.Y, d, d, 180, 90); // 左上 path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90); // 右上 path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90); // 右下 path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90); // 左下 path.CloseFigure(); return path; }d的钳制是必须的。一个高度只有 22 像素的按钮,如果把Radius设成 15,不做钳制的话d = 30超过高度,四条弧的圆心位置会错乱,画出来是个中间凹陷的怪形状。这类尺寸相关的边界条件在皮肤库里到处都是,改半径、改高度时一定要拿最小尺寸的控件试一遍。
3.3 SkinForm:无边框窗体的拖动、缩放与阴影
窗体这块和普通控件完全是两套逻辑。SkinForm首先要设FormBorderStyle = FormBorderStyle.None,去掉系统标题栏,然后自己画标题栏、自己处理拖动和缩放。拖动的实现方式有两种,一种是拦截WM_NCHITTEST把标题栏区域直接报告成HTCAPTION,剩下的交给系统;另一种是在MouseDown里调ReleaseCapture加SendMessage(WM_NCLBUTTONDOWN, HTCAPTION)。
前者更干净,因为它顺带把双击最大化、拖动到屏幕边缘贴边这些系统行为全保留了:
protected override void WndProc(ref Message m) { const int WM_NCHITTEST = 0x0084; const int HTCLIENT = 1, HTCAPTION = 2; const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17; base.WndProc(ref m); if (m.Msg != WM_NCHITTEST || (int)m.Result != HTCLIENT) return; // LParam 低 16 位是屏幕 X,高 16 位是屏幕 Y,注意是有符号的 short int lp = m.LParam.ToInt32(); var screen = new Point((short)(lp & 0xFFFF), (short)((lp >> 16) & 0xFFFF)); Point p = PointToClient(screen); int edge = 6; // 缩放热区宽度,太小用户抓不住,太大又容易误触 if (p.Y < CaptionHeight) { m.Result = (IntPtr)HTCAPTION; return; } if (p.X <= edge && p.Y <= edge) m.Result = (IntPtr)HTTOPLEFT; else if (p.X >= Width - edge && p.Y <= edge) m.Result = (IntPtr)HTTOPRIGHT; else if (p.X <= edge && p.Y >= Height - edge) m.Result = (IntPtr)HTBOTTOMLEFT; else if (p.X >= Width - edge && p.Y >= Height - edge) m.Result = (IntPtr)HTBOTTOMRIGHT; else if (p.X <= edge) m.Result = (IntPtr)HTLEFT; else if (p.X >= Width - edge) m.Result = (IntPtr)HTRIGHT; else if (p.Y <= edge) m.Result = (IntPtr)HTTOP; else if (p.Y >= Height - edge) m.Result = (IntPtr)HTBOTTOM; }LParam的解析是这段代码里最容易写错的地方。低 16 位和高 16 位拼坐标时必须转成short再取,直接按int位运算在负坐标(多显示器左侧屏幕)上会得到一串巨大的正数,表现就是副屏上拖不动窗口。这个问题在单屏开发环境里永远复现不了,一上双屏就翻车。
阴影是另一半。两种常见做法:轻量的用SetClassLong给窗口类加上CS_DROPSHADOW(0x00020000),系统会给一个固定样式的小阴影,实现三行代码但没法调;讲究一点的用 DWM 的DwmExtendFrameIntoClientArea把边框延伸到客户区,配合一层半透明的边距做阴影,效果接近现代应用,代价是要处理WM_DWMCOMPOSITIONCHANGED消息,在系统关闭特效时降级。
标题栏上的最小化、最大化、关闭三个按钮,是画在OnPaint里的自绘按钮,热区判定用Rectangle.Contains。要注意最大化状态和普通状态的按钮图标要切换,而WindowState变化的时机在Resize之前,所以状态判断不能只写在OnPaint里,得在OnResize里也刷一次。
3.4 SkinTextBox 与 SkinComboBox:组合式控件比纯自绘更划算
输入框是个例外,它不适合纯自绘。原因很实际:输入法候选框定位、文本选区、右键菜单、密码掩码、Ctrl+A/C/V、拖动选中,这一整套交互系统原生TextBox已经做得很完整,自己重写一遍既费力又难对齐。这套库采用的是组合式做法——外面套一个负责画圆角背景和边框的容器,里面塞一个去边框的原生TextBox。
public class SkinTextBox : SkinBaseControl { private readonly TextBox _inner = new TextBox(); public SkinTextBox() { _inner.BorderStyle = BorderStyle.None; // 去掉系统边框 _inner.BackColor = Color.White; _inner.Font = SkinTheme.DefaultFont; Controls.Add(_inner); Padding = new Padding(8, 6, 8, 6); // 内边距决定文字离边框多远 Height = 30; // 单行输入框给个默认高度 } protected override void OnLayout(LayoutEventArgs e) { base.OnLayout(e); // 内层文本框严格贴合内边距,多一个像素都会露出白边 _inner.Bounds = new Rectangle(Padding.Left, Padding.Top, Width - Padding.Horizontal, Height - Padding.Vertical); } protected override void OnPaintSkin(Graphics g) { var rect = new Rectangle(0, 0, Width - 1, Height - 1); using (var path = GraphicsHelper.CreateRoundRect(rect, 4)) using (var brush = new SolidBrush(Enabled ? Color.White : SkinTheme.DisabledBack)) using (var pen = new Pen(_inner.Focused ? SkinTheme.Primary : SkinTheme.Border)) { g.FillPath(brush, path); g.DrawPath(pen, path); } } }焦点边框靠_inner.Focused判断,但TextBox的焦点事件不会自动通知父容器,所以还得挂_inner.GotFocus和_inner.LostFocus去调Invalidate(),否则点进去以后边框颜色不变,用户完全感知不到焦点在哪。
下拉框比这复杂一层,因为ComboBox的下拉列表是系统弹出的独立窗口,样式改不了。常见做法是彻底放弃原生ComboBox,用“一个只读输入框 + 一个自绘按钮 + 一个无边框Form承载ListBox”拼出来。这个无边框 Form 要设ShowInTaskbar = false、TopMost = true,并且在失焦时自动关闭,否则点到别处它不会消失。
4. 把项目跑起来:环境、编译与第一个皮肤窗体
4.1 环境准备与工程打开
这套源码是 .NET Framework 的 WinForms 工程,目标框架一般落在 4.0 到 4.8 之间。开发机要装的是对应版本的 .NET Framework 开发包和 Visual Studio(2017 及以上都能打开,工程文件格式是老式的 csproj,VS 会自动做一次单向升级提示,选“确定”即可,不影响源码)。
打开后先别急着 F5,看一眼解决方案结构:通常是一个类库工程加一个 Demo 工程。类库工程是要引用的本体,Demo 是用来验证的示例集合。第一步先确认类库工程能单独编译通过,这一步能过滤掉大部分环境问题——引用路径失效、资源文件丢失、目标框架版本不匹配,都会在这一步暴露。
需要注意的一点:如果源码里带了Resources下的图片资源,而工程文件用的是相对路径引用,换一台机器解压后路径可能对不上。表现是编译报“找不到类型或命名空间”,实际是资源没加载进来。解决办法是在解决方案资源管理器里点开Properties/Resources.resx看有没有红色报错项,有就重新指定一下文件路径。
4.2 编译顺序与第一次运行
编译顺序是先类库后 Demo。命令行操作的话:
# 先还原并编译类库(工程名按实际目录调整) msbuild CSkin\CSkin.csproj /p:Configuration=Release /p:Platform="Any CPU" # 再编译 Demo,它通过项目引用拿到上面那个 dll msbuild Demo\Demo.csproj /p:Configuration=Release用msbuild而不是dotnet build,是因为老式 csproj 里的资源、设计器、COM 引用这些dotnet命令行工具链处理不完整,很容易出现“VS 里能编、命令行编不过”的情况。第一次运行 Demo 时把每个控件示例窗体都点一遍,重点看三件事:窗体拖动是否跟手、按钮悬停有没有延迟、输入框能否正常输入中文。这三项分别对应消息处理、Invalidate调用频率和输入法兼容性,是这套库最容易出问题的地方。
4.3 把控件拖进自己的窗体:工具箱与属性面板
在类库编译通过之后,把生成的 dll 添加到工具箱就能在设计器里拖拽使用了。操作路径是:打开工具箱 → 右键任意分组 → “选择项” → 浏览到编译输出的 dll → 确定。之后所有Skin开头的控件会出现在工具箱里。
如果拖进去的控件在设计器里显示成一个空白方块或者灰色矩形,别慌,这不一定是 bug。自绘控件在设计期和运行期走的是不同的绘制路径,设计器为了性能会跳过一部分自绘逻辑,所以设计视图里看起来“没样式”是正常的,按 F5 跑起来才是真实效果。这套库通常会给控件加一个设计期判断,DesignMode为真时走简化绘制,避免设计器卡顿。
实际在自己项目里用的时候,窗体基类要改一下:
// 原来:public partial class MainForm : Form public partial class MainForm : SkinForm { public MainForm() { InitializeComponent(); // 全局主题色,一行改完全部控件 SkinTheme.Apply(Color.FromArgb(0, 153, 204)); CaptionHeight = 36; // 标题栏高度,影响拖动热区 RoundCap = true; // 窗体圆角 skinButton1.Radius = 4; } }继承关系一改,窗体边框就没了,标题栏、最小化最大化关闭按钮全部换成自绘版本,原生Form的Text、Icon属性依然是生效的,不用改调用方代码。这是这套库设计上最舒服的一点。
4.4 部署阶段的三个必查项
本地跑通和部署到客户机器上是两件事。第一项是目标框架:客户机器上必须装了对应版本的 .NET Framework,4.0 和 4.8 不通用,装错了的表现是双击没反应或者弹一个语焉不详的初始化失败。第二项是高 DPI 清单:不声明 DPI 感知的话,在 125% 或 150% 缩放的笔记本上整个界面会被系统做位图拉伸,圆角和文字糊成一片。第三项是图标和字体,自绘标题栏的图标要单独设,不能用Form.Icon自动缩放,否则大尺寸下会拉花。
高 DPI 需要在工程里加一个app.manifest:
<?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <!-- 声明 PerMonitorV2,让系统不要做位图拉伸 --> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> </windowsSettings> </application> </assembly>注意,加了清单之后必须在代码里处理DpiChanged事件重新计算尺寸,否则从 100% 屏幕拖到 150% 屏幕时,控件不会自动放大,界面会缩在左上角一小块。这是从“看着模糊”变成“看着错位”,问题性质变了,很多人到这一步才发现。
提示:
PerMonitorV2只在 Windows 10 1703 及以上生效,老系统会退回dpiAware的行为,测试时最好找一台老系统机器过一遍。
5. 避坑与排查:皮肤控件的高频翻车现场
5.1 拖进窗体后控件是一块灰方块或者黑块
现象:控件在设计器里是灰块,运行起来依然是一整块纯色,没有圆角、没有边框、没有文字。
原因:八成是BackColor被设成了不透明色并覆盖了整个客户区。自绘控件如果先被系统刷了一遍背景,而OnPaint里又没把整个区域重画,露出来的就是那层背景色。另一种可能是控件被Panel之类的父容器裁剪了,Padding设得太大导致实际绘制区域是负尺寸。
解决:确认控件构造函数里有SetStyle(..., true)那一串,以及BackColor = Color.Transparent。如果是继承自原生控件的皮肤版(比如SkinDataGridView),检查有没有在OnPaint里调用base.OnPaint之后再绘制,原生控件的绘制顺序和纯自绘控件是反的。
5.2 窗体最大化后盖住了任务栏
现象:点最大化,窗口铺满整个屏幕,底部任务栏被压住,开始菜单弹不出来。
原因:FormBorderStyle = None之后,系统不再按“工作区”计算最大化尺寸,而是按整个屏幕算。有些实现里还会手动把Bounds设成Screen.PrimaryScreen.Bounds,那就是彻底盖住了。
解决:不要手动设尺寸,重写WM_GETMINMAXINFO(0x0024)消息,把MaxPosition和MaxSize按当前显示器的工作区(Screen.FromControl(this).WorkingArea)填回去。多显示器场景下还要用MonitorFromWindow拿到窗口所在的那块屏,而不是永远用主屏,否则副屏最大化会跑到主屏尺寸上去。
5.3 高 DPI 下圆角发虚、1 像素边框粗细不一
现象:125% 缩放下按钮圆角边缘有明显的灰色毛边,左右两条边框一条粗一条细。
原因:GDI+ 的坐标是浮点,GraphicsPath在非整数坐标上做抗锯齿会产生半透明像素。缩放比例不是 100% 时,Width - 1这个计算出来的边界落在小数位置上,描边就被分摊到了两个像素上。粗细不一是因为左边界在整数上、右边界在 .5 上。
解决:给Graphics设PixelOffsetMode.Half或者干脆所有坐标用Math.Round对齐到整像素;边框宽度不要用 1,用1f并且确认Pen.Alignment是Inset而不是默认的Center。这套库一般会在基类里统一做像素对齐,自己加控件时别忘了这一步。
5.4 设计器报“找不到类型”,控件从工具箱消失
现象:打开窗体设计器弹一堆红字错误页,之前拖进去的皮肤控件变成一个个报错框。
原因:设计器加载控件时会真实实例化它们,如果控件的构造函数里有依赖运行环境的东西(读配置文件、连数据库、访问网络路径的图片),在设计期就会抛异常。另一种常见情况是类库的目标框架和主工程不一致,设计器加载不了。
解决:构造函数里只做初始化,任何外部依赖都推迟到OnHandleCreated或者第一次绘制时。必须区分设计期的,用Control.DesignMode或者LicenseManager.UsageMode == LicenseUsageMode.Designtime判断。框架版本不一致的,把两个工程的 TargetFramework 对齐。
5.5 表格滚动时闪成一片,快速拖动滚动条尤其明显
现象:SkinDataGridView或皮肤列表滚动时整个控件白闪,滚动条拖快了几乎看不清内容。
原因:原生DataGridView的双缓冲默认是关的,SetStyle在构造之后调用也不一定生效,因为控件内部有自己的绘制管线。另外单元格的Style.BackColor如果设成了透明,每滚一行都要向父容器取背景,开销翻倍。
解决:反射打开DataGridView的私有DoubleBuffered属性,或者在自定义子类里重写CreateParams加上WS_EX_COMPOSITED。单元格背景给具体颜色,不要留Color.Transparent。滚动时逐行重绘而不是整体重绘,性能差距能到三四倍。
注意:
WS_EX_COMPOSITED会让整个控件的绘制走另一条路,在某些老显卡驱动上反而更卡,上生产前在目标机器上实测一下再决定。
6. 二次开发:把 CSkin 改成自己产品的一套皮肤
真正用起来,改源码是迟早的事。最常见的需求是加一个库里没有的控件,比如一个带左侧色条的标签或者一个开关按钮。这时候别从Control开始写,直接继承SkinBaseControl,只实现OnPaintSkin:
// 带左侧色条的列表项标签,色条颜色可在属性面板里调 public class SkinAccentLabel : SkinBaseControl { public int AccentWidth { get; set; } = 3; public Color AccentColor { get; set; } = SkinTheme.Primary; public SkinAccentLabel() { Height = 28; } protected override void OnPaintSkin(Graphics g) { // 先画左侧色条,再留给文字区域,顺序反了会被盖住 using (var brush = new SolidBrush(AccentColor)) g.FillRectangle(brush, 0, 0, AccentWidth, Height); TextRenderer.DrawText(g, Text, Font, new Rectangle(AccentWidth + 8, 0, Width - AccentWidth - 8, Height), SkinTheme.Text, TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); } }写完在类库工程里重新编译,从工具箱“选择项”重新加载一次 dll,新控件就出现在工具箱里了,属性面板里能看到AccentWidth和AccentColor,和库自带的控件体验一致。这是继承基类的好处——设计期支持、双缓冲、透明背景这些东西全部继承下来了。
改主题色的验证清单我一般会固定走一遍,因为颜色这种改动最容易“改一处崩一处”:
| 检查项 | 为什么容易漏 |
|---|---|
| 悬停态、按下态、禁用态 | 只改了常态色,其余三态还是旧色,鼠标一移就露馅 |
| 文字对比度 | 主色改浅了,白字就看不清,要连同TextOnPrimary一起调 |
| 边框和分隔线 | 硬编码在某个控件里的灰色边框不会跟着变 |
| 图标资源 | 图标是位图,颜色改不了,只能整套换 |
那个对比度的问题我吃过一次亏:把主题色从一个深蓝调成偏亮的青,本地看着挺精神,交付后客户反馈“按钮上的字看不清”。后来我把这条写进检查项,每次换色都用一张四态截图逐个看。
现在我的习惯是:动源码之前先把Themes/里那个色表整个抄一份放自己工程的配置类里,所有控件引用自己那份,源码只当成绘制逻辑的参考。这样以后升级库、或者客户临时要第二套配色,都不用再回头翻一遍所有控件的绘制代码。希望帮到你。
本文还有配套的精品资源,点击获取