Delphi窗口动画实战:分层窗口、双缓冲与高DPI防闪烁

发布时间:2026/11/3 23:12:11
Delphi窗口动画实战:分层窗口、双缓冲与高DPI防闪烁

先说结论:Delphi 实现动画窗口,难的不是把窗口移动起来,而是让窗口在移动、缩放、淡入淡出的每一帧里都不闪、不卡、不吃满 CPU,并且在多显示器、高 DPI、任务栏切换这些真实场景下依然稳。我做过几个带入场动画的启动面板、带淡出关闭的悬浮工具条、还有不规则形状的提示窗口,踩过的坑基本都集中在三个地方——时间基不准导致动画忽快忽慢、重绘和窗口属性打架导致闪烁、关闭动画和窗口生命周期打架导致访问违例。这篇内容就是把这个主题从选型到落地完整拆一遍,代码可以直接抄,参数怎么调我也会讲清楚。适合已经能写基础 VCL 窗体、想把界面质感往上提一档的开发者,也适合刚接手老项目、需要给遗留界面加动效的人。

1. 动画窗口到底在做什么:先想清楚需求再选路线

1.1 把"动画窗口"拆成四种真实需求

很多人一上来就搜"Delphi 窗口动画",拿到一段AnimateWindow的调用就往项目里塞,结果发现只能做左右滑动,样式死板,而且和自定义绘制的界面完全不搭。问题出在没先拆需求。所谓动画窗口,落到实际项目里通常是四类完全不同的东西,技术路线也完全不同。

第一类是窗口整体的进出场:窗口从屏幕边缘滑入、淡入、缩放弹出。这类需求只关心窗口这个"矩形盒子"本身的运动,内容不需要每帧重绘,用系统 API 就能搞定,成本最低。

第二类是窗口形状变化:圆形窗口、圆角带阴影的窗口、随内容伸缩的气泡提示。这类需求要改的是窗口的"可见区域"本身,得靠窗口区域(Region)或者分层窗口来做。

第三类是窗口内容动画:窗口位置不动,但里面的进度条在跑、图表在生长、文字在滚动。这类其实是"在窗口里做动画",和窗口本身无关,画布双缓冲就够了,难度反而最低。

第四类是窗口透明度动画:关闭时整体渐隐、失焦时半透明、拖拽时变成半透明"幽灵窗"。这类需要分层窗口或者 DWM 的支持。

我一般会先问一句:这个动画是"窗口在动"还是"窗口里的东西在动"?如果是后者,别去碰窗口句柄,老老实实做画布动画,稳定得多。只有确认是前者,才进入选型环节。这个判断顺序非常重要,我见过不少项目把内容动画硬做成窗口动画,每帧调一次SetWindowPos,CPU 直接飙到 20% 以上,完全没必要。

1.2 四条技术路线的对比与取舍

Delphi 能调用的窗口动画手段,本质上是四层,从"系统帮你做"到"你自己合成像素",控制力递增,代价也递增。

路线核心 API可控性性能适用场景主要限制
系统内置动画AnimateWindow低很好简单滑入滑出、淡入样式固定,无法自定义曲线
窗口区域裁剪SetWindowRgn、CombineRgn中中不规则形状、圆角、异形提示窗无抗锯齿,边缘有锯齿
分层窗口SetLayeredWindowAttributes、UpdateLayeredWindow中高中整体透明度、颜色键透明全窗口接管绘制
自绘合成GDI+ / Direct2D + 分层窗口高取决于实现高质量阴影、圆角、自定义动效代码量大,需要管理位图

AnimateWindow是最省事的,一行调用就能让窗口滚动或淡入。它的原理是系统在内部对窗口做位图滚动或 alpha 混合,整个过程在窗口线程里同步执行,调用期间会阻塞消息循环。所以它只能用于"打开/关闭"这种一次性动作,不能用来做持续的交互反馈。而且它有几个硬性限制:同一个窗口如果已经处于显示状态再调用显示动画会失败;AW_SLIDE和AW_CENTER标志互斥;动画期间窗口不能有复杂的子控件重绘需求。我一般只在工具类小窗口上用,比如托盘弹出的提示框。

SetWindowRgn是我给异形窗口用的老办法。它把窗口的可见区域设成一个 HRGN,区域外的像素根本不参与绘制,也不接收鼠标消息。好处是彻底、干净、不接管绘制流程,VCL 里的控件照常工作。坏处是边缘没有抗锯齿,圆形窗口看上去像用剪刀剪出来的。如果你的界面风格是扁平硬朗的,这个缺点可以接受;如果要做柔和圆角,就得换分层窗口。

分层窗口是分水岭。SetLayeredWindowAttributes是最轻量的用法,只需要给窗口加上WS_EX_LAYERED扩展样式,然后设一个全局 alpha 值,窗口整体就半透明了。它不改绘制流程,VCL 控件全部正常工作,这是做"淡入淡出关闭"性价比最高的方案。而UpdateLayeredWindow则是完全另一回事——它要求你把整个窗口的内容渲染到一张 32 位位图上,然后交给系统合成,窗口自己不再响应WM_PAINT。这条路能做出真正的圆角、真实阴影、逐像素透明,但代价是你要自己管理位图、自己处理所有绘制,VCL 控件在这个窗口上基本失效。

最后一层是自绘合成,也就是用 GDI+ 或者 Direct2D 把界面画到位图上,再通过UpdateLayeredWindow提交。这是做高质量动画窗口的终极方案,Windows 上那些漂亮的启动器基本都是这个路子。但代码量会翻好几倍,而且 Direct2D 在 Delphi 里的封装不如 GDI 顺手,除非项目明确要求高品质视觉,否则我不建议一上来就上这套。

我的选型原则很简单:能用SetLayeredWindowAttributes解决的绝不用UpdateLayeredWindow,能用SetWindowRgn解决的绝不自己合成像素。每往上一层,你要维护的东西就多一倍。

1.3 时间基:动画质量的第一决定因素

选完路线,第二个要定的是时间基。这是绝大多数人忽略、但直接决定动画"手感"的东西。

VCL 的TTimer精度大约是 15.6 毫秒,也就是理论上最多 64 帧每秒,但实际上因为 Windows 消息队列的调度,它经常抖动到 20 毫秒甚至 30 毫秒。如果你用TTimer每触发一次就Left := Left + 10,那动画速度会直接受系统负载影响——机器一忙,窗口就爬得慢,用户会觉得"卡"。

正确做法是按时间算位置,而不是按帧累加。每一帧记录当前时间戳,算出从动画开始到现在的进度t(0 到 1),再用缓动函数把t映射成位移比例,最后乘以总位移量。这样即使某一帧丢了,下一帧也会自动补上,视觉上是匀速的。

时间戳的获取我推荐System.Diagnostics.TStopwatch,它底层是QueryPerformanceCounter,精度到微秒级,而且 Delphi 已经封装好了,跨版本兼容性好。实在要手写,就用QueryPerformanceFrequency加QueryPerformanceCounter这一对,别用GetTickCount(精度只有 10 到 16 毫秒,做动画完全不够)。

function NowSeconds: Double; inline; var Freq, Counter: Int64; begin QueryPerformanceFrequency(Freq); QueryPerformanceCounter(Counter); Result := Counter / Freq; end;

这段代码每帧调一次QueryPerformanceFrequency其实是浪费的,实际项目里应该把它缓存到一个全局变量里,启动时读一次就行。

2. 逐层拆解:分层窗口、区域裁剪与重绘的真实机制

2.1 分层窗口的合成流程与两个易踩的坑

要让窗口半透明,第一件事是加扩展样式。注意WS_EX_LAYERED必须在窗口创建时就带上,或者用SetWindowLong事后加,但事后加的时候,窗口已经创建过,有时候不会立即生效,需要配合一次SetWindowPos触发重算。

procedure EnableLayered(Handle: HWND); var ExStyle: NativeInt; begin ExStyle := GetWindowLongPtr(Handle, GWL_EXSTYLE); if (ExStyle and WS_EX_LAYERED) = 0 then SetWindowLongPtr(Handle, GWL_EXSTYLE, ExStyle or WS_EX_LAYERED); end;

然后设置 alpha:

SetLayeredWindowAttributes(Handle, 0, AlphaByte, LWA_ALPHA);

这里的AlphaByte是 0 到 255 的整数,0 完全透明,255 完全不透明。做淡入淡出时,每一帧算出一个0..255的值传进去就行。

两个坑必须提前说清楚。

第一个坑:LWA_ALPHA和LWA_COLORKEY不能同时用。有人想让窗口既整体半透明又让某个颜色透明,直接两个标志按位或,结果只有一个生效。Windows 的规则是二选一,选 alpha 就不能选颜色键。如果确实需要逐像素透明,只能上UpdateLayeredWindow。

第二个坑:透明度为 0 不代表窗口不可见。alpha 设成 0 时窗口确实看不见了,但它依然占据屏幕区域,依然会拦截鼠标点击。如果做的是"面板淡出后应该让下面的窗口能点击"这种效果,必须在 alpha 归零后调用ShowWindow(Handle, SW_HIDE),否则用户会莫名其妙地点不到下面的东西。这个是真实用户投诉过的问题,排查了半天才发现是窗口还挂着。

另外,Windows 8 之后,分层窗口在启用 DWM 合成的情况下是会走 GPU 合成的,性能比 XP 时代好很多,全屏半透明窗口也不会有明显掉帧。但如果目标机器有远程桌面连接,DWM 合成会被降级,动画可能变得卡顿,这一点在需要兼容远程办公场景的项目里要留意。

2.2 用 SetWindowRgn 做异形窗口的正确姿势

SetWindowRgn接管的是窗口的"形状",设置之后,区域外的部分既不绘制也不接收输入。这里的关键是区域的坐标系——CreateRoundRectRgn之类的函数接受的是相对于窗口左上角的坐标,而SetWindowRgn第三个参数bRedraw传 True 会立即触发重绘。

procedure ApplyRoundRegion(Form: TForm; Radius: Integer); var Rgn: HRGN; begin Rgn := CreateRoundRectRgn(0, 0, Form.Width + 1, Form.Height + 1, Radius * 2, Radius * 2); try SetWindowRgn(Form.Handle, Rgn, True); except DeleteObject(Rgn); raise; end; // 成功后区域归系统所有,不要再 DeleteObject end;

划重点:SetWindowRgn调用成功后,HRGN 的所有权转移给了系统,绝对不能再DeleteObject。这是最经典的资源泄漏/崩溃来源之一——有人习惯性地写在try..finally里释放,结果窗口销毁时系统再释放一次,直接崩。正确写法是只在失败分支里释放。

还有一个细节:宽度和高度要加 1。CreateRoundRectRgn的右下角坐标是开区间,传Width会导致最后一行一列被裁掉,看起来像缺了一条边。这个加 1 的小动作是我调了很久才发现的。

异形窗口做拖动时也有讲究。默认情况下点击窗口的透明区域(也就是区域之外)是点不到的,但如果窗口是"带洞"的形状(比如中间镂空),镂空部分实际上仍在窗口内,还是会拦截点击。要让镂空部分穿透,得处理WM_NCHITTEST,返回HTTRANSPARENT,让系统把消息传给下层窗口。

procedure WMNCHitTest(var Msg: TWMNCHitTest); message WM_NCHITTEST;

在无边框窗口里,这个处理器通常用来做"整窗可拖动"——判断落点在客户区就返回HTCAPTION,等于把整个窗体变成了标题栏。

2.3 闪烁的三个来源与双缓冲的正确用法

动画窗口最直观的失败就是闪。闪的成因基本就三种,逐个说。

第一种是背景擦除。窗口每次重绘前,系统会先用背景色填充客户区,然后再绘制内容。如果内容本身不等于纯色,就会出现一帧白底一闪而过。解决办法是处理WM_ERASEBKGND消息,直接返回 1 表示"我已经擦过了",让系统跳过擦除。

procedure WMEraseBkgnd(var Msg: TWMEraseBkgnd); message WM_ERASEBKGND; begin Msg.Result := 1; end;

这一行代码能解决掉大概一半的闪烁问题,是所有自绘窗口的标配。

第二种是多段绘制。如果你在OnPaint里先画背景再画前景,中间可能会出现"背景已画、前景未画"的瞬间。双缓冲就是先画到内存位图,全部画完再一次性贴到屏幕。Delphi 的TForm有个DoubleBuffered属性,设成 True 就能自动处理大部分情况。但要注意,DoubleBuffered只对 VCL 控件的绘制链有效,如果你是自己调 GDI 在Canvas上画,还是得手动搞一张TBitmap。

var Buf: TBitmap; begin Buf := TBitmap.Create; try Buf.SetSize(ClientWidth, ClientHeight); Buf.Canvas.Brush.Color := clWhite; Buf.Canvas.FillRect(Buf.Canvas.ClipRect); // ... 在这里画所有内容 Canvas.Draw(0, 0, Buf); finally Buf.Free; end; end;

第三种是窗口尺寸和内容不同步。做缩放动画时,如果先改Width再重绘,中间会有一帧"窗口变大了但内容是旧的",露出空白。正确顺序是先在离屏位图上按目标尺寸画好,再一次性改窗口尺寸并贴图。用UpdateLayeredWindow时这个是天然满足的,因为它一帧只提交一次;用普通窗口就得多留神顺序。

实测下来,第三种是最隐蔽的,一帧的闪普通用户可能看不出来,但如果动画有 30 帧,累积起来的视觉瑕疵就很明显了。

3. 手把手:从零搭一个可复用的窗口动画引擎

3.1 单元划分与工程骨架

别把动画逻辑写进窗体单元里。我吃过这个亏——一开始每个窗体都写一遍淡入淡出,后来要改缓动曲线,改了十几个文件。正确做法是抽一个独立的单元出来,窗体只负责"调用",不关心"怎么动"。

我通常拆成三个单元:

  • uAnim.Core.pas:时间基、缓动函数、动画状态机
  • uAnim.Window.pas:窗口相关的动画执行器(淡入、位移、缩放)
  • uAnim.Easing.pas:缓动函数集合(也可以合并进 Core)

核心是一个描述"一次动画任务"的记录体:

type TAnimKind = (akFade, akMoveX, akMoveY, akResize, akCustom); TWindowAnim = record Kind: TAnimKind; Target: HWND; FromValue: Double; ToValue: Double; Duration: Double; // 秒 Elapsed: Double; Easing: TEasingFunc; Active: Boolean; OnDone: TProc; end;

用一个 TList 或者 TArray 管理多个动画任务,这样同一个窗口可以同时跑淡入和位移,互不干扰。这一点很重要——如果只支持一个动画,做"边淡入边从下方滑上"这种组合效果时就傻了。

3.2 帧循环:TTimer 不够用,自己驱动

前面说了TTimer精度不够。我用过的方案有三种,按推荐度排列。

第一种是独立的高精度线程加消息投递。开一个线程,用Sleep(1)循环,每次计算是否需要推进帧,需要就PostMessage给主线程一个自定义消息,主线程收到消息后推进动画。这个方案精度最好,能稳定跑到 60 帧以上,但要注意线程和 VCL 的交互必须走消息,不能直接碰控件。

第二种是**timeSetEvent**,winmm 里的多媒体定时器,精度可以到 1 毫秒。用法简单,但要timeKillEvent配对释放,而且回调里同样不能直接操作 VCL。

第三种是在空闲时推进,利用Application.OnIdle。这个方案最安全,不需要线程,但OnIdle的触发频率取决于消息队列是否空闲,一旦有大量消息在处理,动画就会掉帧。

我现在的默认选择是第一种,包一层简单的封装:

const WM_ANIM_TICK = WM_USER + $101; type TAnimDriver = class private FThread: TThread; FTarget: HWND; FRunning: Boolean; public constructor Create(NotifyWnd: HWND); procedure Start; procedure Stop; end;

线程里这么写:

procedure TAnimThread.Execute; var LastTick: Double; begin LastTick := NowSeconds; while not Terminated do begin Sleep(1); if NowSeconds - LastTick >= 1 / 60 then begin LastTick := NowSeconds; PostMessage(FTarget, WM_ANIM_TICK, 0, 0); end; end; end;

60 帧够用吗?对于窗口进出场这种短动画,60 帧完全够。如果想要更顺滑(比如 120Hz 屏幕上的效果),把1/60改成1/120就行,但要注意 CPU 占用会上升,而且屏幕本身刷新率不够的话也看不出区别。我的建议是给个上限,不要无脑追高,因为窗口动画的每一帧都要调SetWindowPos,代价不低。

3.3 缓动函数:让动画有"重量感"

线性动画看起来最"假",因为现实中的物体都有加速度。加一个缓动函数,同样的时长,观感能提升一大截。

type TEasingFunc = reference to function(T: Double): Double; function EaseOutCubic(T: Double): Double; begin Result := 1 - Power(1 - T, 3); end; function EaseInOutQuad(T: Double): Double; begin if T < 0.5 then Result := 2 * T * T else Result := 1 - Power(-2 * T + 2, 2) / 2; end; function EaseOutBack(T: Double): Double; const C1 = 1.70158; C3 = C1 + 1; begin Result := 1 + C3 * Power(T - 1, 3) + C1 * Power(T - 1, 2); end;

用法上我给几个直接可抄的经验值:

  • 淡入淡出:EaseOutCubic,时长 180 到 250 毫秒。太短了感觉突兀,太长了用户会等得不耐烦。
  • 窗口滑入:EaseOutCubic,时长 250 到 350 毫秒,位移量控制在 40 到 80 像素。位移太大反而显得拖沓。
  • 缩放弹出:EaseOutBack,时长 300 毫秒左右。这个函数会略微过冲再回弹,有"弹出来"的感觉,是提示框类窗口的经典选择。
  • 关闭动画:时长要比打开短,一般是打开的 60% 到 70%,因为用户在关闭时心理预期更快。

EaseOutBack的过冲量由C1控制,1.70158 是比较温和的值,想要更夸张的弹跳可以调到 2.5 以上,但超过 3 就有点晃眼了。

3.4 淡入淡出:完整实现与关闭时的生命周期处理

淡入好做,OnShow里把 alpha 从 0 推到 255 就行。真正麻烦的是淡出关闭,因为窗口在动画结束前不能销毁,而用户可能在此期间再次点击关闭按钮。

我的做法是加一个"正在关闭"的状态位,并且在OnCloseQuery里拦截:

procedure TAnimatedForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean); begin if FClosing then begin CanClose := True; // 动画已完成,允许真正关闭 Exit; end; CanClose := False; FClosing := True; StartFadeOut(200, procedure begin // 动画结束后再关 Close; end); end;

这里有个必须注意的点:Close会再次触发OnCloseQuery,所以必须有FClosing标志区分第一次和第二次。我第一次写的时候忘了这个,结果窗口无限循环关闭,直接卡死。

淡出的执行器长这样:

procedure TAnimatedForm.StartFadeOut(DurationMs: Integer; OnDone: TProc); var StartTick: Double; Task: TWindowAnim; begin EnableLayered(Handle); StartTick := NowSeconds; Task.Kind := akFade; Task.Duration := DurationMs / 1000; Task.FromValue := 255; Task.ToValue := 0; Task.Easing := EaseOutCubic; Task.Active := True; Task.OnDone := procedure begin ShowWindow(Handle, SW_HIDE); if Assigned(OnDone) then OnDone(); end; AddTask(Task); end;

每帧推进时,把FromValue到ToValue按缓动后的进度插值,然后调SetLayeredWindowAttributes。

还有个容易被忽略的点:淡出期间要屏蔽用户输入。如果用户在窗口渐隐的过程中还能点按钮,可能触发新的逻辑,甚至在窗口即将销毁时访问已释放的对象。最省事的办法是Enabled := False,或者设一个全局的"动画进行中"标志,在事件处理里提前返回。

3.5 无边框窗口的滑动与缩放

无边框窗口是动画的天然载体,因为不用处理系统边框的重绘。Delphi 里做无边框窗口,把BorderStyle设成bsNone只是第一步,还得处理拖动、任务栏图标、阴影这些。

整窗拖动靠WM_NCHITTEST:

procedure TAnimatedForm.WMNCHitTest(var Msg: TWMNCHitTest); begin inherited; if Msg.Result = HTCLIENT then Msg.Result := HTCAPTION; end;

但这样会带来副作用——客户区里的按钮也点不了了,因为点击被当成拖动。正确做法是判断落点是否在"可拖动区域"内,通常用一个顶部区域或者排除掉控件所在的矩形:

procedure TAnimatedForm.WMNCHitTest(var Msg: TWMNCHitTest); var P: TPoint; Ctrl: TControl; begin inherited; if Msg.Result <> HTCLIENT then Exit; P := ScreenToClient(Point(Msg.XPos, Msg.YPos)); Ctrl := ControlAtPos(P, False, True); if Ctrl = nil then Msg.Result := HTCAPTION; end;

ControlAtPos会返回落点处的控件,如果落在空白处才允许拖动。这个写法比手工算矩形可靠得多。

滑动动画的实现要注意一个细节:用SetWindowPos而不是改Left/Top。VCL 的Left和Top属性每次赋值都会走一套完整的位置更新流程,包括触发重排、重绘、事件,一帧里改两次会有明显的开销。SetWindowPos可以带SWP_NOREDRAW之类的标志,效率高很多。

SetWindowPos(Handle, 0, NewX, NewY, 0, 0, SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE);

SWP_NOACTIVATE很关键,不加的话每帧都可能抢焦点,用户正在别的窗口打字就会被反复打断。

缩放动画的难点在于不能逐帧改窗口尺寸再重绘,那样闪得厉害。我的做法是先把窗口尺寸一次性设到最终值,然后在一个不透明或半透明的背景上做"内容缩放",视觉上像是窗口在长大。如果项目要求严格的"窗口框在长大",那就得用SetWindowRgn每帧重设区域,配合UpdateLayeredWindow提交缩放后的位图,这个开销不小,一般只在特殊场景用。

3.6 不规则形状窗口的完整落地

异形窗口有两种做法,前面讲过:SetWindowRgn和UpdateLayeredWindow。前者简单但有锯齿,后者精细但复杂。我给一个中间方案:用 Region 做形状,用双缓冲做内容,能覆盖 80% 的场景。

关键步骤:

第一步,准备一张带 alpha 通道的 PNG 作为形状和外观来源,但SetWindowRgn用不了 alpha,只能用二值遮罩。所以实际做法是从 PNG 里提取一个"轮廓区域"。

第二步,用ExtCreateRegion从一个RGNDATA结构创建区域。这个函数能处理复杂的多点区域,比CreatePolygonRgn更灵活:

function BuildAlphaRegion(Bmp: TBitmap; Threshold: Byte): HRGN; var RgnData: PRgnData; Rects: PRect; X, Y: Integer; Row: PRGBQuad; Count: Integer; begin // 简化示意:逐行扫描 alpha 通道,合并连续的不透明像素为矩形 // 实际实现需要先计算总矩形数再分配内存 end;

完整实现代码量比较大,核心思路是逐行扫描,把连续的不透明像素合并成矩形,最后交给ExtCreateRegion。这个算法跑一次大概几十毫秒,对于窗口创建来说是完全可以接受的,不需要每帧重算。

第三步,窗口的区域设好了,外观靠自绘。这时候OnPaint里直接Canvas.Draw那张 PNG 就行,因为区域已经把窗口裁成形状了,画出来正好。

要提醒一句:用SetWindowRgn的窗口,拖动时会有明显的"重绘滞后",因为每移动一次就要重算区域和重绘。大规模移动动画不建议用这种方式,只适合静态的异形窗口。

4. 排坑实录:那些只有真跑起来才会遇到的问题

4.1 帧率不稳的四种典型原因

现象一:动画前几帧很顺,后面越来越慢。

这通常是内存分配的问题。如果你的绘制代码每帧都TBitmap.Create一张新图,GC 压力会越来越大。正确做法是把位图作为字段缓存起来,只在尺寸变化时重建。我见过一个项目每帧创建三张 800x600 的位图,跑了两秒动画内存涨了 200MB。

现象二:窗口一动,CPU 就单核跑满。

原因是每帧都触发了整窗重绘。检查一下是不是每帧都调了Invalidate。做位置动画时,内容没变就不需要重绘,SetWindowPos本身就能移动窗口,不用重画。只有在做内容变化时才需要Invalidate。

现象三:双屏环境下动画卡顿。

如果是多显示器,尤其是两个屏幕刷新率不同(比如一个 60Hz 一个 144Hz),跨屏移动时会看到明显的撕裂。这个是 DWM 合成的问题,程序层面能做的不多,但可以避免在跨屏时做长距离动画——直接让窗口在新位置出现,比拖着走过去观感更好。

现象四:远程桌面下动画严重掉帧。

前面提过,远程连接时 DWM 会被禁用或降级,分层窗口的 alpha 合成变慢。如果你的软件有远程使用场景,应该检测一下GetSystemMetrics(SM_REMOTESESSION),为真时直接跳过动画,只做最终状态。

4.2 高 DPI 下的坐标陷阱

这是我踩得最深的一个坑。在 150% 缩放的显示器上,Form.Width返回的是"逻辑像素"还是"物理像素"?答案是:取决于进程的 DPI 感知级别。

如果进程是 DPI Unaware(Manifest 里没声明),系统会把整个窗口的内容拉伸,Width返回逻辑值,实际渲染是拉伸后的模糊画面。

如果是 System DPI Aware,进程启动时按主显示器 DPI 缩放一次,之后不再变。跨到不同 DPI 的显示器上会模糊。

如果是 Per-Monitor V2,窗口跨屏时系统会发WM_DPICHANGED消息,你要自己调整尺寸。

对动画窗口来说,最要命的是物理像素和逻辑像素混用。SetWindowPos接受的是物理像素,而 VCL 的Left/Top是逻辑值。如果你的动画代码里一部分用Left,一部分用SetWindowPos,在 150% 缩放下会差 1.5 倍。

我的做法是统一用物理像素做动画计算,只在需要和 VCL 属性交互时换算:

function LogicalToPhysical(Value: Integer): Integer; begin Result := MulDiv(Value, GetDpiForWindow(Handle), 96); end;

GetDpiForWindow是 Windows 10 1703 之后才有的 API,老系统上需要GetProcAddress动态加载。Delphi 11 之后的版本在Winapi.Windows里已经有声明了。

4.3 常见问题速查表

现象最可能的原因排查方向解决方式
动画中间露出一块白背景擦除未屏蔽检查WM_ERASEBKGND返回 1 跳过擦除
淡出后窗口还在挡鼠标只改了 alpha 没隐藏用工具查窗口层级alpha 归零后SW_HIDE
关闭动画后程序崩溃动画回调和销毁竞争看异常栈是否在OnDone加FClosing标志
异形窗口边缘有锯齿用了 Region 裁剪目视确认改用UpdateLayeredWindow
高 DPI 下位移量翻倍逻辑/物理像素混用打印实际坐标统一换算
拖动时窗口抖动每帧重算 Region性能采样拖动时暂停形状计算
动画只跑了一帧时间基取了整数检查计时精度换QueryPerformanceCounter
多显示器上位置错乱用了Screen.Width检查是否按主屏算用Monitor.WorkRect

4.4 几个能省掉大量调试时间的小技巧

技巧一:给动画加一个"慢放开关"。开发时把时长乘以 10,肉眼就能看清每一帧的变化,很多时序问题一眼就能发现。上线前把这个开关关掉或者只在 Debug 构建里启用。

function SlowMo(Duration: Double): Double; begin {$IFDEF DEBUG} Result := Duration * 10; {$ELSE} Result := Duration; {$ENDIF} end;

技巧二:用一个Tag或GUID标记动画批次。当用户快速连续触发同一个动画(比如反复点击按钮打开面板),旧的动画可能还在跑,新的又开始了,两个动画同时改一个属性,结果就是抽搐。解决办法是每次启动动画生成一个递增的 token,回调里判断 token 是否还是最新的,不是就丢弃。

技巧三:不要在OnPaint里做耗时计算。有人喜欢在OnPaint里算布局、算缓动值,一帧算一次,60 帧就是一秒 60 次。应该把计算移到OnPaint外面,OnPaint只负责把算好的结果画出来。

技巧四:窗口隐藏后记得停掉动画驱动。那个高精度线程如果一直跑,即使没有动画任务也在每秒PostMessage60 次,白烧 CPU。正确的做法是动画队列为空时自动Stop,有新任务时再Start。

技巧五:测试时把系统主题换成高对比度模式跑一遍。高对比度模式下系统会强制改很多颜色,如果你的动画依赖颜色键透明,可能会直接失效。我遇到过一例,在高对比度下窗口变成纯黑方块,原因是颜色键被系统主题覆盖了。

5. 效果扩展:从"能动"到"动得好看"

5.1 组合动画的编排思路

单个动画容易,难的是组合。一个典型的"面板弹出"效果通常是三件事同时发生:窗口从下方滑入 40 像素、尺寸从 90% 放大到 100%、透明度从 0 到 255。如果三个动画各自独立跑,因为缓动曲线不同,中间会出现"已经完全不透明了但还没滑到位"的割裂感。

解决方式是共享一个进度值。三个属性都用同一个T来插值,只是各自的缓动函数可以不同:

procedure ApplyCombined(const T: Double); var SlideT, ScaleT, FadeT: Double; begin SlideT := EaseOutCubic(T); ScaleT := EaseOutCubic(T); FadeT := EaseOutQuad(T); // 透明度变化稍慢一点更自然 SetWindowPos(Handle, 0, FStartX, Round(FStartY + (1 - SlideT) * 40), 0, 0, SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE); SetWindowPos(Handle, 0, 0, 0, Round(FBaseW * (0.9 + 0.1 * ScaleT)), Round(FBaseH * (0.9 + 0.1 * ScaleT)), SWP_NOMOVE or SWP_NOZORDER or SWP_NOACTIVATE); SetLayeredWindowAttributes(Handle, 0, Round(255 * FadeT), LWA_ALPHA); end;

注意这里连续调了两次SetWindowPos,其实可以合并成一次(同时改位置和尺寸),少一次系统调用。

我这里单独给透明度用了EaseOutQuad而不是Cubic,因为 alpha 的变化人眼很敏感,用更平缓的曲线会显得更柔和。这个细节没有标准答案,多试几组参数,用眼睛挑最舒服的那个。

5.2 什么时候该用 DWM 的效果

Windows 11 之后,系统级视觉效果(比如窗口边框的强调色、背景模糊)可以通过DwmSetWindowAttribute设置。如果你的软件面向新系统,可以给无边框窗口加一点背景模糊,观感会明显提升。

const DWMWA_SYSTEMBACKDROP_TYPE = 38; var BackdropType: Integer; begin BackdropType := 2; // Mica DwmSetWindowAttribute(Handle, DWMWA_SYSTEMBACKDROP_TYPE, @BackdropType, SizeOf(BackdropType)); end;

不过这个特性需要系统版本支持,老系统上调用会直接返回失败,所以必须做版本检测,失败就静默降级。另外它和分层窗口的 alpha 是冲突的——用了 Mica 就不能再用LWA_ALPHA做整体透明度,两者选其一。实际项目里我一般把 Mica 用在主窗口,把分层 alpha 用在弹出面板上。

5.3 一个我实际用过的参数组合

最后分享一组我调试时间最长、但最后效果最满意的参数,做的是"从屏幕右下角弹出的通知面板":

  • 入场时长 320 毫秒,EaseOutBack,过冲系数 1.4
  • 位移:从下方 56 像素滑入
  • 透明度:0 到 255,用EaseOutQuad,前 40% 的时间完成 90% 的透明度变化
  • 入场时带一个轻微的阴影扩散(用SetWindowRgn逐帧扩大 2 像素,跑 5 帧后固定)
  • 出场时长 200 毫秒,纯EaseOutCubic淡出,无位移
  • 出场期间Enabled := False,防止误触

这套参数在一台老笔记本(核显,60Hz 屏)上跑下来 CPU 峰值不到 8%,普通办公机上基本感知不到占用。关键点在于位移和透明度分开控制曲线,而且出场比入场快得多,符合用户"关掉它"的心理预期。

需要提醒的是,这些数值没有普适性。屏幕尺寸、目标用户的年龄层、软件本身的风格都会影响观感——面向年轻人的工具类软件可以快一点、弹一点,面向专业用户的界面反而应该克制,动画太长会被嫌烦。我的习惯是先把时长拉到 500 毫秒调试,看清每一帧的问题,然后压到 200 到 300 毫秒,最后在真机上让不写代码的同事看一眼,他们说"有点慢"就往回收 50 毫秒,说"没感觉"就加 50 毫秒,两三轮回合基本就定了。这个土办法比对着参数表调靠谱得多。