
简介VCLSkin v5.60.3.29 完整源码包面向 Delphi 与 CBuilder 开发者主要解决在不改动业务代码的前提下统一美化窗体、控件和菜单界面的问题适合需要研究皮肤控件实现、界面换肤与源码二次开发的中高级读者。压缩包共 75 个文件、约 272KB以 Pascal 与 C 源码、dpk/bpk/dproj/cbproj 等包与工程文件、res 资源、chm 帮助文档及说明文本为主能够覆盖源码实现、多版本工程配置、资源组织和文档参考等用途。已有 517 人学习下载说明该版本在 Delphi 皮肤控件方向具有一定参考价值。完整源码便于查看皮肤存储与读取、窗体子类化、菜单绘制、控件注册及皮肤集合管理等关键环节也可用于兼容性验证和二次开发帮助文档与工程文件则有助于理解发布包结构、编译组织及不同开发环境下的接入方式。1. 一个老系统要换皮救场的往往不是重构去年帮人收拾一个跑了十来年的业务系统几百个窗体控件还是那套 Win32 灰底按钮。客户只提一个要求界面看起来像这个年代的软件。真按重构走逐个窗体换控件、调布局工期根本压不住。后来翻出这套 VCLSkin v5.60.3.29 完整源码版思路很直接——业务代码一行不动挂一个皮肤数据组件上去窗体、标题栏、菜单、按钮、表格全被接管重绘。它属于 Delphi/CBuilder 里 VCL 皮肤控件那一类东西定位就是给存量桌面程序做视觉升级。适合两类人手里有 Delphi 老项目要维护、又不想动业务逻辑的以及想搞清楚 Win32 窗口自绘到底怎么接管标题栏和子控件的。这份是完整源码版不是只给 DCU 的体验包引擎层能改。2. 引擎架构拆解VCLSkin 凭什么不改代码就能接管整个窗体先把结论放在前面它不是在控件外面套一层壳而是在运行时把窗体和控件的绘制入口劫持了。VCL 的 TWinControl 最终都落到 Windows 的窗口句柄上皮肤的活儿就是在这层句柄上做文章——子类化窗口过程、接管非客户区绘制、替换背景擦除、拦截自绘消息。理解了这一点后面看源码就不会迷路。2.1 一套源码两个身份运行期引擎和 IDE 设计期包完整源码包里最容易被忽略的是工程分组。打开包目录正常会看到两个.dpk一个运行期包只装引擎单元皮肤文件解析、绘制、钩子一个设计期包requires运行期包里面只有一个注册单元。这个拆分不是洁癖是血泪经验——设计期包一旦把绘制引擎静态链进 IDEIDE 每打开一个带皮肤的窗体就要跑一遍皮肤解析GDI 句柄涨得飞快用不了多久整个开发环境就开始卡顿甚至崩。package SkinDesign; {$R *.res} {$ALIGN 8} requires rtl, vcl, designide, // 设计期服务Register 过程要靠它挂到组件面板 SkinRuntime; // 引擎本体运行期包不重复编译 contains SkinReg in SkinReg.pas; // 里面只有注册代码一行绘制逻辑都没有 end.拆开看几个关键点requires里写SkinRuntime而不是把引擎单元直接contains进来这样设计期包和运行期包共用同一份已编译二进制不会出现IDE 里一套、程序里另一套的版本错位。designide是设计期专属依赖普通工程里不能引用否则部署时找不到包。contains只放注册单元意味着改引擎源码后重新编译运行期包IDE 里的组件行为跟着变不用重装。注册单元里干的事也就两件把组件挂到面板、给属性挂编辑器。procedure Register; begin // 组件页签名建议和产品名一致方便在面板里一眼找到 RegisterComponents(VCLSkin, [TSkinData, TSkinButton, TSkinPanel, TSkinMenu]); // 给 SkinFile 属性挂一个文件选择器设计期能直接挑 .vsf RegisterPropertyEditor(TypeInfo(string), TSkinData, SkinFile, TSkinFileProperty); end;RegisterComponents的第一个参数是 IDE 组件面板上的页签名第二个参数是类数组只有在这里登记过的类才会出现图标。RegisterPropertyEditor的四个参数分别是属性类型、宿主类、属性名、编辑器类作用是让SkinFile在设计期点开是个文件对话框而不是让人手敲路径。这里有个容易翻车的细节如果宿主类写成父类TComponent那么所有组件的同名属性都会用这个编辑器设计期行为会变得很奇怪。2.2 三层接管皮肤数据、绘制代理、窗口消息钩子引擎内部大致分三层从上到下职责很清晰。最上面是皮肤数据层。.vsf文件被读进内存后组织成元素名 → 绘制素材的映射表。一个按钮在皮肤里可能对应BUTTON_NORMAL、BUTTON_HOT、BUTTON_PRESSED、BUTTON_DISABLED四张图外加边框宽度、文字颜色、字体。这层还负责九宫格拉伸——按钮拉宽的时候不能把圆角也拉变形所以切图时把四角固定、四边单向拉伸、中间平铺这套规则在皮肤编辑器里定好运行时由引擎按目标矩形算。中间是绘制代理层。引擎拿到这个句柄对应什么控件、当前什么状态去数据层取对应元素然后画。VCL 原生控件本身不会画皮肤所以有两种接法一种是替换控件源码里那批TSkinButton、TSkinPanel、TSkinMenu属于这类直接从TCustomControl派生自己画另一种是给标准控件挂绘制代理通过拦截WM_DRAWITEM、WM_CTLCOLORBTN之类的消息把系统的默认绘制顶掉。后者的好处是业务代码里的TButton不用改成TSkinButton代价是对某些容器型控件不生效。最下面是消息钩子层也是最黑匣子的一层。窗体创建后引擎用SetWindowLong或SetWindowSubclass把窗口过程换掉消息先进自己的处理函数处理不了再交回原来的过程。// 皮肤引擎接管窗口过程的核心逻辑按源码结构简化示意 function SkinWndProc(Handle: HWND; Msg: Cardinal; wParam: WParam; lParam: LParam): LResult; stdcall; begin case Msg of WM_NCPAINT, WM_NCACTIVATE: begin // 标题栏和边框不再交给系统画改为引擎自绘 DrawSkinCaption(Handle, Msg WM_NCACTIVATE); Result : 1; // 返回 1 告诉系统我画完了 Exit; end; WM_ERASEBKGND: begin // 背景改成铺皮肤位图顺手消掉默认灰底那一下闪烁 DrawSkinBackground(Handle); Result : 1; Exit; end; end; // 没接管的消息原样转发保证窗口行为不被破坏 Result : CallWindowProc(PrevWndProc, Handle, Msg, wParam, lParam); end;WM_NCPAINT是非客户区重绘消息标题栏、边框、系统菜单按钮都归它管返回 1 表示已经绘制完毕系统不用再画。WM_NCACTIVATE在窗口激活状态切换时来得在这里区分激活/非激活两套标题栏素材否则窗口失焦时标题栏颜色不对。WM_ERASEBKGND返回 1 是标准的我自己擦背景写法配合双缓冲能把拖动窗口时的闪烁压下去。PrevWndProc是子类化之前保存的原始窗口过程地址所有没处理的消息必须转发不然窗口的最小化、最大化、拖动都会失灵。提示子类化之后一定要在窗体销毁时还原窗口过程否则句柄被系统回收再分配给别的窗口时旧的过程地址会指向已经释放的代码段这类崩溃在退出程序时最难查。2.3 和 VCL 自带的 VCL Styles 到底差在哪很多人第一反应是新版 Delphi 不是自带样式吗。确实自带但两者定位不同选错了要么改不动要么改了出问题。维度VCLSkin 这类第三方皮肤控件VCL 自带 VCL Styles支持版本覆盖很老的 Delphi 版本老工程能直接用需要较新的 IDE 版本老工程升不上去就用不了生效范围可以按窗体、按控件粒度开局部换肤也行整个进程统一替换没法只换一个窗体皮肤来源独立的.vsf文件运行期可切换、可外置编译进 exe 的样式资源换肤要重新编译第三方控件需要自己写绘制代理引擎不认就还是灰的同样存在不生效的情况而且更难插手可改造性有完整源码绘制逻辑能改引擎在 IDE 里改不了结论很直白手里是十年前的老工程、IDE 版本低、又只想要能换肤 能改选这类带源码的皮肤控件。工程本身已经是较新版本、追求整体一致性、不需要局部定制自带的样式够用还省一个第三方依赖。3. 源码目录结构拿到完整包先翻哪几个单元完整源码包解压出来通常有几十个文件全看一遍不现实。我的习惯是先摸清分层再挑三四个核心单元精读其余用到再查。下面这套目录是这类皮肤控件的典型组织方式具体名字可能略有出入但职责划分基本一致。3.1 顶层目录与各自的职责目录内容什么时候需要进去Source/或Src/全部引擎单元.pas改绘制逻辑、加控件支持主要战场Packages/.dpk运行期包和设计期包安装组件、排查包依赖Skins/若干.vsf皮肤文件拿来测效果或者当改皮肤模板Demos/示例工程第一次跑通看这里比看文档快Tools/皮肤编辑器SkinBuilder 一类做自己的皮肤、看元素命名规则Doc/说明文件、元素命名表查某个控件对应哪个皮肤元素第一次上手顺序建议是先跑Demos/里最小的那个工程确认环境没问题再回头编译Packages/装组件最后才进Source/。反过来先啃源码很容易在环境问题上耗掉半天。3.2 核心单元逐个点名SkinData.pas是整个库的门面TSkinData就在这里。它对外暴露的属性不多但都很关键Active控制开关SkinFile指向皮肤文件SkinName在皮肤库里选具体一套还有RepaintAll这类强制刷新方法。它内部持有皮肤数据的引用负责把当前用哪套皮肤这个状态广播给所有挂上来的控件。SkinStore.pas有的版本叫SkinReader负责.vsf的解析和内存组织。元素查找、九宫格参数、颜色表、字体表都在这里。想做皮肤格式转换、批量改皮肤改这个单元比改绘制层省力得多。SkinHook.pas是消息钩子层前面讲的子类化逻辑在这里。里面通常有一张句柄到控件的映射表收到消息先反查是哪个控件再决定交给谁画。这张表是线程相关的多线程创建窗体时要注意同步。SkinCtrls.pas是那批替代控件TSkinButton、TSkinPanel、TSkinEdit、TSkinCheckBox都在里面。它们绝大多数继承自对应的 VCL 基类只重写Paint和几个消息处理所以用法和原生控件几乎一样改起来也直观。SkinMenus.pas单独处理菜单。菜单不是普通窗口TMainMenu的绘制走的是WM_DRAWITEM和WM_MEASUREITEM得单独接管而且主菜单、弹出菜单、系统菜单三套逻辑还不完全一样。SkinUtils.pas里是杂项位图拉伸、颜色混合、圆角裁切、字体缓存。看着不起眼性能瓶颈往往就藏在这里——每次重绘都新建TBitmap的话窗体一多 GDI 句柄就爆了。3.3 皮肤文件与元素命名规则.vsf本质是个结构化资源包里面存的是位图、九宫格边距、颜色、字体、以及元素之间的继承关系。元素名一般是控件类型 状态的组合比如BUTTON_NORMAL、BUTTON_HOT、FORM_CAPTION_ACTIVE、PANEL_BORDER。做自定义控件时第一步就是去Doc/或者皮肤编辑器里确认元素名的命名习惯名字对不上取图永远是空的。九宫格那几个边距参数尤其要留意。左上、右上、左下、右下四个角固定尺寸四条边单向拉伸中间区域平铺或拉伸。边距填小了圆角会被拉成椭圆填大了按钮文字区域被挤没。这套参数在编辑器里调最直观手改文件容易算错。皮肤文件还有个常见用法是嵌进 exe。把.vsf作为 RCDATA 资源编译进去运行时从资源流加载省掉一个外部文件也避免了用户把皮肤文件删了程序变回灰底的尴尬。// 从 exe 内嵌资源加载皮肤适合只发一个 exe 的交付场景 procedure TFormMain.LoadSkinFromRes(const ResName: string); var RS: TResourceStream; begin RS : TResourceStream.Create(HInstance, ResName, RT_RCDATA); try SkinData1.Active : False; // 先关避免旧皮肤的资源没释放 SkinData1.LoadFromStream(RS); // 具体方法名以 SkinStore 单元导出为准 SkinData1.Active : True; finally RS.Free; // 流由调用方释放引擎不接管 end; end;HInstance是当前模块句柄资源必须编译进主 exe 或者某个已加载的 DLL 才能被找到。ResName是资源名大小写不敏感但建议全大写保持一致。RT_RCDATA表示自定义二进制资源不能写成RT_BITMAP。LoadFromStream之后要把Active打开才会真正触发重绘只加载不激活界面上什么都不会变。4. 从编译安装到第一个换肤窗口四步跑通 v5.60.3.29环境对了这套东西装起来不超过十分钟环境不对能在组件面板里找不到这一步卡一下午。下面按我实际走的顺序来每一步都给出验证方法。4.1 装之前先核对三件事第一IDE 版本和源码的兼容范围。老版本源码里常见LongInt强转句柄、GetWindowLong而非GetWindowLongPtr的写法在 32 位下没事切到 64 位编译会报指针截断。如果目标平台是 64 位先把SkinHook.pas里这类转换改成NativeInt/PtrInt。第二机器上有没有装过旧版本的皮肤控件。旧包残留是最阴的坑——组件面板上看着有图标编译也能过运行时却报类找不到因为加载的是旧路径下的 BPL。装新包之前把 IDE 的组件安装列表翻一遍把旧的运行期包和设计期包都卸干净再清一次系统的包搜索路径。第三工程的链接方式。Project Options里Link with runtime packages打没打勾决定了引擎是静态编进 exe 还是动态加载 BPL。两种方式都能跑但同一台机器上不能混——静态链接的 exe 碰到动态加载的 BPL类注册会冲突。4.2 编译并安装设计期包打开Packages/下的设计期包工程先Build不是CompileBuild会连带把依赖的运行期包重编一遍。编译通过后右键包工程 →InstallIDE 会把它注册进组件面板。验证方法很土但有效新建一个空窗体在组件面板上找到注册时写的那个页签看组件图标在不在。在的话往窗体上拖一个TSkinData看对象检查器里能不能展开SkinFile并弹出文件选择框——能弹说明属性编辑器也注册成功了设计期包是完整的。如果Install报找不到 xxx.bpl八成是运行期包没编译或者输出路径不在 IDE 的搜索路径里。这时候别急着重装先去Tools → Options → Environment Options → Delphi Options → Library Path里确认运行期包的输出目录在列表里再Install一次。4.3 最小可运行示例一个组件带活整个窗体先别急着改老工程新建一个测试工程放一个按钮、一个面板、一个表格拖一个TSkinData上去写这么几行unit uMain; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, Grids, SkinData, SkinCtrls; // SkinData 提供 TSkinDataSkinCtrls 提供替代控件 type TFormMain class(TForm) SkinData1: TSkinData; btnOK: TButton; Panel1: TPanel; StringGrid1: TStringGrid; procedure FormCreate(Sender: TObject); private procedure ApplySkin(const AFile: string); end; implementation procedure TFormMain.ApplySkin(const AFile: string); begin if not FileExists(AFile) then begin // 皮肤文件缺失时保持原生外观不要让程序起不来 Exit; end; SkinData1.Active : False; // 关掉再换防止旧皮肤的位图缓存泄漏 SkinData1.SkinFile : AFile; // 指向 .vsf绝对路径或相对 exe 的路径都行 SkinData1.Active : True; // 打开后接管当前窗体及其所有子控件 SkinData1.RepaintAll; // 强制重绘处理个别控件不主动刷新的问题 end; procedure TFormMain.FormCreate(Sender: TObject); begin ApplySkin(ExtractFilePath(ParamStr(0)) Skins\office.vsf); end; end.Active是总开关赋值顺序必须是先False再改SkinFile再True。反过来写引擎会先用旧皮肤初始化一遍再加载新皮肤中间那次的资源不一定释放干净反复切换几次就能看到内存往上走。SkinFile支持绝对路径和相对路径相对路径是相对当前工作目录而不是 exe 目录所以稳妥的写法是用ExtractFilePath(ParamStr(0))拼绝对路径。RepaintAll不是必须的但对TStringGrid这类自绘程度高的控件很有用——引擎改了它的绘制代理它自己不一定知道要重画。跑起来之后观察三件事标题栏有没有换、按钮的悬停/按下状态有没有变化、窗体拖动时有没有明显闪烁。三样都正常说明钩子层、绘制层、双缓冲都工作正常标题栏没换但按钮换了问题在非客户区消息没接管按钮换了但悬停没反应问题在状态切换时的重绘没触发。4.4 运行时切换皮肤与配置持久化真正交付的时候用户是要能选皮肤的。切换本身很简单难点在于下次启动记住上次的选择和切换过程中别闪。// 皮肤选择持久化写 ini 比写注册表省事也不容易被安全软件拦 procedure TFormMain.SaveSkinChoice(const AName: string); var Ini: TIniFile; begin Ini : TIniFile.Create(ExtractFilePath(ParamStr(0)) config.ini); try Ini.WriteString(UI, Skin, AName); finally Ini.Free; end; end; function TFormMain.LoadSkinChoice: string; var Ini: TIniFile; begin Result : office; // 默认皮肤配置文件缺失时兜底 Ini : TIniFile.Create(ExtractFilePath(ParamStr(0)) config.ini); try Result : Ini.ReadString(UI, Skin, Result); finally Ini.Free; end; end;配置文件放在 exe 同目录用ExtractFilePath(ParamStr(0))定位不要用GetCurrentDir——从快捷方式启动和从资源管理器双击启动工作目录可能不一样这是踩过的坑。ReadString的第三个参数是默认值一定要给否则配置文件被删了之后读到空字符串皮肤加载直接失败。切换的时候加个Screen.Cursor : crHourGlass和Screen.Cursor : crDefault包一下大皮肤文件加载会有几百毫秒的卡顿没有反馈用户会以为程序死了。5. 避坑与排查五个让皮肤只换一半的翻车现场5.1 组件装上了编译却报类找不到现象设计期一切正常组件面板有图标窗体上也能拖一按 F9 就报Class TSkinData not found。原因运行期包和设计期包的链接方式不一致。IDE 里装的是设计期 BPL它依赖运行期 BPL工程如果勾了Link with runtime packages但搜索路径里没有运行期包或者反过来工程静态链接了引擎、IDE 又加载了一份 BPL类注册表里就出现了两份同名类运行期解析失败。解决统一链接方式。工程用动态包就把运行期包的输出目录加进系统PATH或者跟 exe 放一起工程用静态链接就把设计期包卸掉再编译别让 IDE 里那份 BPL 参与。查这个问题最快的办法是在Project Options → Packages里看一眼勾选项和 IDE 的已安装包列表对一下。5.2 主界面换了某个第三方表格还是灰的现象窗体、按钮、菜单全换肤了唯独某个第三方表格控件、或者内嵌的浏览器控件还是原来的系统外观。原因绘制代理只认它认识的窗口类和控件类型。第三方控件如果自己完全接管了WM_PAINT并且不响应引擎的绘制消息皮肤就进不去。内嵌浏览器这类本质是独立渲染进程更没法接管。解决三条路。一是换成SkinCtrls里功能相近的替代控件二是给这个控件单独写绘制代理在SkinHook的句柄映射表里登记它然后在WM_PAINT里先画皮肤背景再让控件自己画内容三是接受它把它放进一个已经换肤的容器里视觉上做点过渡。第三条最省事也最常用于浏览器这类没法改的控件。5.3 拖动窗口时标题栏闪黑、按钮黑一下现象静态看没问题一拖动窗口或者快速切换焦点标题栏和按钮区域闪出一块黑底或者灰底。原因背景擦除和重绘不同步。WM_ERASEBKGND没接管系统先用默认背景刷一遍引擎再画皮肤中间那一帧就露出来了。另一种可能是皮肤位图的像素格式带 alpha而绘制时用的Canvas.Draw不做透明混合。解决接管WM_ERASEBKGND并返回 1同时确认绘制走的是双缓冲——先画到内存TBitmap再一次性BitBlt到屏幕。半透明素材要么用AlphaBlend要么在皮肤编辑器里导出时就合成到不透明底图上别指望Draw能处理 alpha。5.4 设计期一打开窗体IDE 就卡住现象某个带皮肤的窗体双击打开要等十几秒多开几个直接失去响应。原因TSkinData.Active在设计期被设成了True。IDE 打开窗体时会真实创建所有控件并执行绘制皮肤引擎跟着跑一遍完整的解析和钩子安装窗体越多越慢GDI 句柄攒着不释放。解决把设计期的Active设为False只在FormCreate或者OnShow里打开。已经存成True的可以用 IDE 的窗体文本视图右键 →View as Text直接改 DFM 里的属性值比在界面里点快。改完记得所有窗体都过一遍漏一个还是卡。5.5 切到 64 位平台编译一堆指针截断警告现象32 位编译全绿切到 64 位一堆W1025、W1012运行时偶发访问违例。原因老源码里把句柄、指针赋给LongInt或Integer32 位下长度刚好64 位下高 32 位被截断。解决全局搜LongInt(、Integer(强转句柄的地方换成NativeInt、PtrInt、THandle。GetWindowLong/SetWindowLong换成GetWindowLongPtr/SetWindowLongPtr或者用源码里已有的条件编译分支。改完别只跑一遍点按钮重点测窗口销毁重建、多窗体切换这两个场景最容易触发指针问题。6. 二次开发给自绘控件补上皮肤支持顺手做一套自己的皮肤改造的第一步不是写代码是确认元素名。打开皮肤编辑器看现有皮肤里BUTTON_*、PANEL_*是怎么命名的新控件的元素照着这个规则起名后面取图才不会落空。然后从最简单的路子入手让控件继承TCustomControl重写Paint从SkinData取图。type TSkinGauge class(TCustomControl) private FSkinData: TSkinData; protected procedure Paint; override; published property SkinData: TSkinData read FSkinData write FSkinData; end; procedure TSkinGauge.Paint; var R: TRect; Bmp: TBitmap; begin inherited; if (FSkinData nil) or (not FSkinData.Active) then begin // 没挂皮肤时退回默认绘制保证控件在裸环境下也能用 Canvas.Brush.Color : clBtnFace; Canvas.FillRect(ClientRect); Exit; end; R : ClientRect; Bmp : FSkinData.GetElementBitmap(GAUGE_BACKGROUND, R); // 按元素名取图并按目标矩形拉伸 try Canvas.Draw(R.Left, R.Top, Bmp); finally Bmp.Free; // 取图返回的是副本必须自己释放 end; end;GetElementBitmap这个名字不同版本可能叫GetBitmap或GetElement改之前先在SkinStore.pas里搜一下导出函数别照抄。第二个参数传目标矩形引擎内部会按九宫格规则拉伸直接给ClientRect就行。返回的TBitmap是副本用完必须Free忘了释放跑几百次重绘就能看到句柄数飙升。改造点大致分三类验证方式也不一样改造目标涉及单元改动点验证方式新控件支持皮肤新建单元 SkinHook.pas重写Paint在句柄表登记反复显示隐藏看 GDI 句柄是否回落修改现有控件外观SkinCtrls.pas改状态切换逻辑和绘制顺序悬停、按下、禁用三态逐一对比自定义皮肤格式SkinStore.pas 编辑器加元素类型、改解析分支用旧皮肤文件回跑确认向后兼容做自己的皮肤时别从零画。拿现有皮肤另存改配色和圆角参数元素结构不动这样引擎那边一行不用改。真要加新元素先在编辑器里加、导出、用测试工程加载验证确认引擎能取到再去改业务代码。血泪经验是任何一次引擎层改动都强制走一遍三态 多窗体 反复切换皮肤的回归。我自己吃过亏——只改了一个按钮的绘制顺序静态看没问题结果用户快速来回切皮肤时旧位图缓存没释放跑到第二天内存就满了。从那以后涉及皮肤资源释放的改动我都会写个循环切五十次的测试用例跑一遍再提交。这套完整源码的价值就在这儿遇到不生效的控件、不合适的绘制效果能直接下去改而不是对着二进制 DCU 干瞪眼。希望帮到你。本文还有配套的精品资源点击获取