MFC自绘滚动字幕控件:双缓冲实现平滑跑马灯

发布时间:2026/9/8 23:43:00
MFC自绘滚动字幕控件:双缓冲实现平滑跑马灯 简介静态控件滚动字幕是 VC 界面开发中常见的效果需求。资源包围绕水平滚动、垂直滚动和曲线滚动三种实现方式展开完整演示了定时器驱动、SetWindowPos 移动控件、背景透明以及字体颜色调整等关键编码细节适合初学 MFC 编程、希望扩展静态控件用法的开发者。包内共 89 个文件以 C 头文件与源文件为主同时包含工程配置、位图图标资源、可直接运行的 exe 演示程序及文档说明整体压缩后为 642KB便于对照源码查看实际效果。示例工程划分清晰既有独立的字符测试程序也有“滚动致谢词”等多个演示项目能够帮助读者快速理解不同滚动方向下坐标更新逻辑与边界处理思路。已有 328 人学习过该资源相关代码可借鉴到欢迎字幕、动态提示等真实界面场景中具有较好的参考价值。1. 先说说为什么不用 Timer SetWindowText 做滚动字幕做 VC 界面的人应该都遇到过这种需求想在窗口底部放一条公告栏或者做个类似媒体播放器的跑马灯提示文字从左往右慢慢滚。我第一次接到这个需求时脑子里冒出来的方案很简单放一个 Static 控件开个定时器每 100ms 用 SetWindowText 更新一次文字内容把字符串左边截掉一个字符、右边补一个空格模拟滚动的效果。这个方案不是不能用但用一阵子你就会发现几个很难受的问题。第一Static 控件刷新是整块重绘的你每改一次文字控件就会闪一下。在文字不多、刷新频率不高的时候还能忍一旦字幕速度调快、文字变长闪烁会非常明显。第二SetWindowText 的内容是静态的你想实现文字从右边平滑地移进控件、再从左边消失这种效果靠改字符串是做不到的因为字符永远是整块显示最多是从第 N 个字符开始截断没有像素级的平滑感。第三Static 控件默认样式下文字超出控件宽度会被裁剪掉你想让文字先走出控件边界再重现单靠修改字符串很难控制。所以我最终选择了自绘 Static 控件这条路把 Static 子类化拦截 WM_PAINT自己控制文字的绘制位置。这样做的好处是整个滚动过程是像素级的文字可以平滑地从控件右边冒出来、从左边消失方向、速度、暂停、继续全部可以由自己控制而且刷新用的是双缓冲几乎看不到闪烁。这套方案在 Win32 和 MFC 里都能实现我下面的讲解以 MFC 为主但核心原理是通用的。1.1 常见的 Timer 刷新方案为什么不行你可能会觉得Timer SetWindowText 也有不少人用为什么到你这里就不行了我帮你把它的局限列出来Static 控件每次 SetWindowText 都会触发全量重绘文字内容越长重绘成本越高闪烁越明显。无法做到文字部分出现在控件外、部分在控件内的效果——控件根本不会绘制超出自身范围的内容。滚动速度的控制粒度太粗只能按字符步进不能按像素步进观感像打字机而不是滚动。如果你想做从右往左的滚动实际上要不断移动字符串的起止位置逻辑绕而且中英文混排时截断位置很难算准。这些问题的根源在于Static 控件本身是静态展示控件它的绘制逻辑是由系统固定死的。你要滚动就必须把绘制逻辑拿回来自己写。1.2 自绘 Static 的整体思路和适用场景自绘 Static 的思路其实很简单让控件自己决定每一个像素画什么。我们保留 Static 控件的窗口句柄和属性只要它有 WM_PAINT 消息就执行我们自己的绘制函数。绘制函数里做三件事画背景、计算文字应该出现的位置、把文字画出来。滚动效果就靠定时器不断改变文字位置并触发重绘实现。这套东西适合用在信息公告栏、股票行情条、新闻滚动条、上下滚动歌词等场景。相比用 ListCtrl、Edit 或者自绘窗口来做Static 方案最大的优势是轻量控件本身不抢焦点不需要复杂的消息处理不需要滚动条占用的资源也少非常适合嵌在对话框或窗体的固定区域里。2. 动手前的两个准备字符集和自绘路线很多人一上来就写代码结果第一步就卡在字符集上。VC 项目在创建时可以选 Unicode 或多字节字符集VS2017 之后默认是 Unicode。这两个字符集下CString 的内部存储方式不一样你在代码里写字符串常量的方式也要相应调整。我建议你统一用 _T 宏来包字符串这样项目不管切成哪个字符集代码都不用改。另一个要准备的问题是用什么方式实现自绘一种是把 Static 控件的样式改成 SS_OWNERDRAW通过测量 WM_DRAWITEM 来完成绘制另一种是直接子类化 CStatic重写 OnPaint。我强烈推荐后者原因下面细说。2.1 先把项目字符集搞清楚字符集这个坑老手也容易翻车。如果你的项目是 UnicodeCString 里存的是宽字符GetTextExtent、DrawText 这类函数会自动走宽字符版本中文显示没问题。但如果项目是多字节字符集你写 L字符串 就会编译报错或者出现乱码反过来你用 _T 宏就能在两种字符集下自动适配。判断项目当前字符集项目属性 - 常规 - 字符集看是使用 Unicode 字符集还是使用多字节字符集。我建议直接用 Unicode别犹豫MFC 在高版本上对 Unicode 的支持已经非常成熟了。我给的代码里所有字符串常量都用 _T 包起来你复制过去无论项目是什么字符集都能直接编译。2.2 为什么我选择子类化而不是 SS_OWNERDRAWSS_OWNERDRAW 是 Static 控件的自绘样式开启后系统会把绘制消息 WM_DRAWITEM 发给父窗口由父窗口来处理。听起来也不复杂但有个麻烦绘制逻辑写在对话框类里而不是控件类里。如果你的对话框上有多处需要自绘的控件所有绘制逻辑会堆在一起代码变得越来越难维护。而且 WM_DRAWITEM 的绘制参数里有控件 ID你每次都要判断当前画的是谁。子类化 CStatic 则把绘制逻辑封装在控件类内部对话框那边只需要定义一个成员变量绑定控件然后调用 SetText、StartScroll 就完事了。以后要在别的项目里复用直接把这个类拖过去不用改对话框代码。这就是我选择子类化的核心理由高内聚、易复用。3. 滚动字幕的底层原理文字在屏幕上到底怎么滚起来的很多人写滚动字幕最困惑的一点是文字明明超出了控件边界为什么还能滚得动其实这里的关键在于裁剪这个概念。Windows 的绘制是带设备上下文的每个窗口的设备上下文默认有一个裁剪区域你在这个区域之外画的内容不会显示出来。Static 控件的裁剪区域就是控件自己的客户区。所以当文字的坐标超出控件范围时超出部分被自动裁剪掉只有落在控件内的像素才会显示。滚动过程就是不断改变文字左上角的 x 坐标让文字从右边界移进来、从左边界移出去。理解了裁剪你就明白为什么用 SetWindowText 做不到平滑滚动——它改变的是内容而滚动字幕改变的是绘画位置。3.1 BitBlt 与重绘时的平滑感文字位置每帧变化后最简单的做法就是 InvalidateRect 让窗口重绘。但 Static 控件默认会先擦背景再绘制擦背景和绘制的先后顺序如果不一致屏幕上就会出现明暗交错的闪烁。我在实现里用了两个手段来保证平滑第一重绘前调用 Invalidate(FALSE) 告诉系统不要擦除背景第二把所有绘制先画到内存 DC 里再用 BitBlt 一次性拷贝到屏幕上。BitBlt 是 GDI 里一个非常经典的光栅拷贝函数它把内存位图中已经画好的像素整块复制到目标 DC。这样做的好处是屏幕上的每一次刷新都只有一次 BitBlt 操作中间过程用户完全看不到。实际测下来的效果在 100ms 刷一次、一次移动 2 个像素的节奏下字幕滚动非常顺滑。3.2 双缓冲的真正意义不闪不花双缓冲的套路在很多图像处理程序里都有先在内存里建一张和控件一样大的位图把背景、文字都画到位图上最后一次性把整张位图贴到屏幕。这样做有两个直接收益。第一是不闪因为闪烁的本质是背景和前景交替出现在屏幕上而双缓冲让用户每次只能看到完整的画面。第二是不花绘制过程中任何中间状态都在内存里发生哪怕绘制函数中途被其他消息打断屏幕上也不会出现半幅画面的情况。我把这层逻辑写在了 OnPaint 里CPaintDC 负责发起绘制紧接着创建兼容 DC 和兼容位图所有绘画操作都发给 memDC最后用 BitBlt 上屏。4. 核心实现CScrollTextStatic 类完整代码与讲解下面进入正题。这个类我整理成了两个文件一个头文件一个源文件。你可以在 MFC 对话框项目里新建一个类基类选择 CStatic然后把代码替换进去。4.1 头文件成员设计#pragma once #include afxwin.h class CScrollTextStatic : public CStatic { public: CScrollTextStatic(); void SetText(LPCTSTR lpszText); void SetTextColor(COLORREF clrText); void SetScrollSpeed(UINT nPixelPerTick); // 每次定时器触发移动的像素数 void SetDirection(int nDir); // 1 表示从左往右-1 表示从右往左 void StartScroll(); void StopScroll(); void Pause(BOOL bPause); protected: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnTimer(UINT_PTR nIDEvent); DECLARE_MESSAGE_MAP() private: int GetTextPixelWidth(); void RestoreOffset(); CString m_strText; COLORREF m_clrText; COLORREF m_clrBk; int m_nDir; // 滚动方向 int m_nOffset; // 文字左端在控件客户区中的 x 坐标 UINT m_nSpeed; // 每个 Tick 移动的像素数 BOOL m_bStarted; BOOL m_bPaused; };成员变量里最核心的是 m_nOffset它代表文字左边缘的 x 坐标。文字如何滚、何时回卷都靠这个变量控制。m_bStarted 和 m_bPaused 用来管理定时器的生命周期避免重复创建定时器导致消息堆积。4.2 绘制函数OnPaint 与双缓冲BEGIN_MESSAGE_MAP(CScrollTextStatic, CStatic) ON_WM_PAINT() ON_WM_ERASEBKGND() ON_WM_TIMER() END_MESSAGE_MAP() CScrollTextStatic::CScrollTextStatic() : m_clrText(RGB(255, 255, 255)) , m_clrBk(RGB(0, 0, 0)) , m_nDir(1) , m_nOffset(0) , m_nSpeed(2) , m_bStarted(FALSE) , m_bPaused(FALSE) { } void CScrollTextStatic::SetText(LPCTSTR lpszText) { m_strText lpszText; if (m_bStarted) { RestoreOffset(); } Invalidate(FALSE); } void CScrollTextStatic::SetTextColor(COLORREF clrText) { m_clrText clrText; Invalidate(FALSE); } void CScrollTextStatic::SetScrollSpeed(UINT nPixelPerTick) { m_nSpeed nPixelPerTick; } void CScrollTextStatic::SetDirection(int nDir) { m_nDir (nDir 0) ? 1 : -1; if (m_bStarted) { RestoreOffset(); } } void CScrollTextStatic::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); memDC.FillSolidRect(rcClient, m_clrBk); if (!m_strText.IsEmpty()) { CFont* pOldFont memDC.SelectObject(GetFont()); memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(m_clrText); CSize sizeText memDC.GetTextExtent(m_strText); int x m_nOffset; int y (rcClient.Height() - sizeText.cy) / 2; memDC.TextOut(x, y, m_strText); memDC.SelectObject(pOldFont); } dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); } BOOL CScrollTextStatic::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; }几个细节说一下。OnEraseBkgnd 直接返回 TRUE目的是禁止系统擦除背景否则控件区域会在绘制之前变成灰色或者对话框底色和双缓冲的画面交替出现反而会闪。所有绘制内容都在内存 DC 里完成最后一次性 BitBlt 上屏。文字垂直居中画在控件中央水平位置由 m_nOffset 决定。这里还要注意GetTextExtent 算出来的是文字在指定字体下的像素尺寸。如果你希望字幕支持多行就要把 TextOut 换成 DrawText但我个人建议滚动字幕保持单行多行文字读起来费劲和滚动场景不太搭。4.3 定时滚动逻辑OnTimer 与方向控制void CScrollTextStatic::StartScroll() { if (m_bStarted) { return; } m_bStarted TRUE; m_bPaused FALSE; RestoreOffset(); SetTimer(1, 100, NULL); } void CScrollTextStatic::StopScroll() { if (!m_bStarted) { return; } KillTimer(1); m_bStarted FALSE; } void CScrollTextStatic::Pause(BOOL bPause) { m_bPaused bPause; } void CScrollTextStatic::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { if (!m_bPaused) { CRect rcClient; GetClientRect(rcClient); if (m_nDir 0) { // 从左往右文字起始点在左侧外逐步向右移动 m_nOffset m_nSpeed; if (m_nOffset rcClient.Width()) { RestoreOffset(); } } else { // 从右往左文字起始点在右侧外逐步向左移动 m_nOffset - m_nSpeed; int nTextWidth GetTextPixelWidth(); if (m_nOffset nTextWidth 0) { RestoreOffset(); } } Invalidate(FALSE); } } CStatic::OnTimer(nIDEvent); } void CScrollTextStatic::RestoreOffset() { CRect rcClient; GetClientRect(rcClient); int nTextWidth GetTextPixelWidth(); if (m_nDir 0) { m_nOffset -nTextWidth; } else { m_nOffset rcClient.Width(); } } int CScrollTextStatic::GetTextPixelWidth() { CClientDC dc(this); CFont* pOldFont dc.SelectObject(GetFont()); CSize sizeText dc.GetTextExtent(m_strText); dc.SelectObject(pOldFont); return sizeText.cx; }滚动的回卷逻辑是这个类的关键。从左往右滚时文字先隐藏在最左边即 m_nOffset 等于负的文字宽度然后每 100ms 向右移动 m_nSpeed 个像素。当文字完全走出右侧边界也就是 m_nOffset 大于控件宽度时重置回左边。从右往左滚是镜像逻辑m_nOffset 先等于控件宽度文字从右侧外进入完全走出左边界后重置回右边。选择定时器周期 100ms 是我调试后觉得比较稳妥的节奏过快会让 CPU 占用偏高过慢文字移动看起来像一卡一卡。实际速度由 m_nSpeed 控制2 像素/100ms 就是每秒 20 像素适合大多数公告场景。4.4 接入对话框的三种方式第一种在对话框的头文件里声明一个 CScrollTextStatic 成员变量然后在 DoDataExchange 里调用 DDX_Control 把 Static 控件绑定到这个变量void CMyDialog::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_SCROLL_TEXT, m_wndScrollText); }第二种在 OnInitDialog 里用 Create 动态创建一个滚动字幕控件指定父窗口、位置和尺寸然后立即设置字体和文本。第三种如果不想用类向导可以直接给已有的 Static 控件子类化先声明变量再调用 SubclassDlgItem 把控件和窗口句柄关联起来。刚绑定完控件之后记得先设置字体。我一般在 OnInitDialog 里写m_wndScrollText.SetFont(GetFont()); m_wndScrollText.SetText(_T(欢迎使用本系统祝您使用愉快。)); m_wndScrollText.SetDirection(-1); m_wndScrollText.SetScrollSpeed(2); m_wndScrollText.StartScroll();GetFont 取的是对话框默认字体这样字幕和界面其他文字风格统一看着不突兀。5. 实测阶段最容易踩的四个坑代码写完不代表就能顺利跑起来以下四个问题是我实际调试时踩过或者身边同事踩过的你大概率也会遇到。5.1 中文字符串乱码与 _T 宏使用 Unicode 字符集时CString 是宽字符串TextOut 会正确显示中文。但如果你的代码里直接写了 中文 这种窄字符串赋值给 CString 时会发生隐式转换偶尔会出现乱码。最保险的写法是 _T(中文) 或者 L中文。另外如果你从文件或网络读取文字注意把编码转换成 UTF-16Unicode 下或 GBK多字节下否则字幕会显示成乱码。5.2 自绘后 Static 控件的边框丢失Static 控件如果设了 SS_SUNKEN 凹入边框或者 SS_ETCHEDHORZ 水平蚀刻线这些样式是由系统在标准绘制流程里画的。一旦我们接管了 WM_PAINT这些边框不会自动出现控件看起来变平了。解决方法是自己在 OnPaint 里补画边框用 DrawEdge 加一条经典的下凹边线void CScrollTextStatic::DrawFrame(CDC dc, CRect rc) { dc.DrawEdge(rc, EDGE_SUNKEN, BF_RECT); }我把这段逻辑放在背景填充之后、文字绘制之前这样边框在文字下层不会遮挡字幕内容。5.3 控件被其他窗口遮挡后出现残影滚动字幕控件如果和别的窗口有重叠区域比如一个 Button 半压在字幕控件上按钮移动或消失时字幕区域可能会残留按钮的画面残影。原因是默认情况下 Static 控件不是最顶层窗口刷新顺序出问题时重叠区域的重绘会漏掉。我在项目里遇到后处理方法是把字幕控件的 Z 序调到最上面并且加上了 WS_CLIPSIBLINGS 样式void CScrollTextStatic::PreSubclassWindow() { CStatic::PreSubclassWindow(); ModifyStyle(0, WS_CLIPSIBLINGS); SetWindowPos(wndTop, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); }PreSubclassWindow 在控件被子类化时自动调用这是 MFC 提供的一个很好的初始化入口。5.4 高 DPI 模式下文字发虚或位置偏移现在高分屏很常见如果对话框没有启用 DPI 感知GDI 绘制出来的字体会被系统拉伸边缘发虚滚动时文字位置也可能出现像素偏移。最省事的处理是在项目清单里声明 Per Monitor V2 DPI 感知或者调用 SetProcessDpiAwarenessContext。这样 GetFont、GetTextExtent 拿到的尺寸就是真实像素绘制位置才不会偏心。6. 进一步升级跑马灯、暂停/继续、透明背景基础滚动效果跑通之后你可以根据自己的实际需求做一些扩展下面这几个方向我都在实际项目里用过。6.1 跑马灯模式跑马灯的特点是文字首尾相连像一条带子一样循环滚动。要做出这种效果可以把文字画两份第一份从 m_nOffset 位置开始画第二份紧跟第一份的尾部。当第二份文字完全进入控件后第一份归位到左边视觉上就形成了无缝循环。实现上只需要在 OnPaint 里多画一次int nTextWidth sizeText.cx; memDC.TextOut(x, y, m_strText); if (x nTextWidth rcClient.Width()) { memDC.TextOut(x nTextWidth, y, m_strText); }配合回卷条件改成 m_nOffset nTextWidth 0 而不是 m_nOffset 本身就能得到跑马灯效果。6.2 暂停/继续的细节处理暂停最直接的方式是 Pause(TRUE) 里 KillTimer继续时 SetTimer。但我更推荐保留定时器、用 m_bPaused 开关控制绘制原因有两个一是反复创建和销毁定时器有系统开销二是暂停时如果外部改了文字或方向定时器还活着才能及时响应。实测下来m_bPaused 的方案更省心代码也简单。6.3 透明背景与渐变效果滚动字幕固定一个黑色背景块不够好看的话可以把控件背景改成透明让字幕直接叠在父窗口背景上。方法是把 m_clrBk 去掉OnPaint 里不再 FillSolidRect而是用父窗口的背景填充内存 DC。取父窗口背景的办法是把父窗口的 WM_ERASEBKGND 内容画进内存 DC或者简单一点在父窗口背景是纯色时直接用那个颜色填充。渐变效果也可以做循环绘制每一行文字每行的 alpha 不同配合 AlphaBlend 实现淡入淡出不过这会增加不少绘制开销字幕量大的时候要谨慎使用。就我自己的经验来说滚动字幕这种功能技术含量不集中在能不能滚上而在滚得顺不顺、稳不稳、好不好接入。上面这套实现已经把闪烁、残影、字符集这些常见雷区都趟过了直接拿去用基本不会出大问题。如果你在接入时遇到和上面描述不一致的情况优先检查控件尺寸是否太小、字体是否设置成功、以及父窗口是否拦截了定时器消息。多调试几次这个控件会变得非常顺手。本文还有配套的精品资源点击获取

相关新闻