最早我接一个CEF3播放器壳子的活儿,需求方非要做全屏无边框窗口,标题栏全部用自绘UI,连最小化关闭按钮都画在网页里。前后台做起来并不难,真正让人头疼的是——你把窗口样式设置成WS_POPUP之后,这个窗口立刻变成了一个搬不动的箱子。鼠标在窗口里怎么点都行,就是拖不动。更尴尬的是,网上那一堆“无标题栏窗口拖动”的经典写法,在这个组合下几乎全部失灵。今天就把“无标题栏 + CEF3 + 窗口拖动”这个组合彻底讲透,从底层原理到两套可直接落地的方案,再到DPI、双击最大化、系统贴靠这些坑,一次说清楚。这套经验适合所有用CEF做自绘UI客户端的同学,尤其是第一次踩到CEF子窗口消息模型的人。
1. 先搞清楚为什么拖不动:CEF子窗口把鼠标消息“吞”了
1.1 Windows窗口拖动的底层逻辑
很多初学者会把“拖动窗口”理解成“鼠标移动时给窗口设置新坐标”,这是不对的。Windows有一套现成的机制:每次鼠标在窗口上移动,系统都会给窗口发送一条WM_NCHITTEST消息,询问“鼠标现在悬停在这个窗口的哪个区域”。如果返回值是HTCAPTION,说明鼠标正停在标题栏上;如果返回HTCLIENT,说明鼠标在客户区。
当你按住标题栏拖动时,系统会收到鼠标左键的WM_NCLBUTTONDOWN,然后自动进入一个移动窗口的模态循环。这个循环会持续追踪鼠标位置,负责把窗口“跟”着鼠标移动,完全不需要你手写MoveWindow或SetWindowPos。这就是为什么很多标准做法里,只需要让某个区域的WM_NCHITTEST返回HTCAPTION,后续的拖动行为系统全包了。
所以“无标题栏窗口拖动”这个需求,本质不是去计算坐标,而是要在指定区域内伪造一个“假标题栏”,骗过系统的WM_NCHITTEST查询。
1.2 CEF3嵌入方式导致的“消息半路被截胡”
如果你只是把宿主窗口设成WS_POPUP,然后在宿主窗口的WndProc里处理WM_NCHITTEST,你会发现代码写得再对也没用。原因在于CEF的嵌入机制。
在默认的非OSR(离屏渲染)模式下,CEF会通过CefWindowInfo::SetAsChild在宿主窗口的客户区里创建一个真正的原生子窗口。这个子窗口的类名一般是Chrome_WidgetWin_0,它覆盖在你窗口的整个客户区上。鼠标消息发出后,并不是先到宿主窗口,而是直接进入这个最上层的子窗口。CEF子窗口自己返回的是HTCLIENT,宿主窗口连这条WM_NCHITTEST都收不到,你的拖动逻辑自然永远不执行。
这个问题只在非OSR模式下存在。如果你用的是OSR离屏渲染模式,鼠标消息会直接发给宿主窗口,处理起来反而简单——但OSR模式本身有性能和渲染效果的代价,大部分人做CEF客户端还是会优先选择非OSR。所以,解决问题的核心思路就两条:
- 要么子类化CEF子窗口,在它的
WM_NCHITTEST里返回HTCAPTION; - 要么让前端页面通过JS桥接,主动请求宿主窗口进入拖动状态。
下面分别讲这两套方案。
2. 方案一:子类化CEF子窗口,用WM_NCHITTEST“骗过”系统
2.1 在OnAfterCreated里拿到窗口句柄并注册子类
子类化是Windows下拦截子窗口消息最常规的手段。这里我不建议用古老的SetWindowLongPtr(GWL_WNDPROC)去替换窗口过程,因为CEF在内部有可能会重新设置窗口过程,替换法很容易失效。更稳妥的方式是用SetWindowSubclass,它是微软专门为“窗口过程安全覆盖”设计的API。即使以后CEF换了窗口过程,我们的子类依然能优先收到消息。
注册子类的时机很关键。需要在CEF的CefLifeSpanHandler回调里,等浏览器窗口真正创建完成后去拿句柄。参考代码如下:
class CefLifeSpanHandlerImpl : public CefLifeSpanHandler { public: void OnAfterCreated(CefRefPtr<CefBrowser> browser) override { if (!browser->IsPopup()) { main_browser_ = browser; HWND browser_hwnd = browser->GetHost()->GetWindowHandle(); // 注册子类,uIdSubclass用1就行,只要和移除时保持一致 ::SetWindowSubclass(browser_hwnd, &CefBrowserSubclassProc, 1, 0); } } void OnBeforeClose(CefRefPtr<CefBrowser> browser) override { if (browser->IsSame(main_browser_)) { HWND browser_hwnd = browser->GetHost()->GetWindowHandle(); // 记得在销毁前移除子类,防止出现野指针 ::RemoveWindowSubclass(browser_hwnd, &CefBrowserSubclassProc, 1); main_browser_ = nullptr; } } private: CefRefPtr<CefBrowser> main_browser_; IMPLEMENT_REFCOUNTING(CefLifeSpanHandlerImpl); };这里要注意一个细节:SetWindowSubclass的子类过程是给“CEF子窗口”设的,不是给宿主窗口设的。很多人写到这里顺手就把宿主窗口句柄传进去了,结果注册了一个寂寞,消息还是被截胡。
2.2 子类窗口过程:精确划定可拖动区域
子类过程的核心是拦截WM_NCHITTEST。我直接给出一份可用的参考实现:
constexpr int kDragHeight = 40; // 自定义标题栏的高度,单位是物理像素 LRESULT CALLBACK CefBrowserSubclassProc( HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) { switch (uMsg) { case WM_NCHITTEST: { // lParam里携带的是鼠标的屏幕坐标 POINT pt; pt.x = GET_X_LPARAM(lParam); pt.y = GET_Y_LPARAM(lParam); // 拿到CEF子窗口在屏幕上的矩形范围 RECT rc; ::GetWindowRect(hWnd, &rc); // 只把顶部40像素判定为“伪标题栏” RECT rcDrag = { rc.left, rc.top, rc.right, rc.top + kDragHeight }; if (::PtInRect(&rcDrag, pt)) { return HTCAPTION; } break; } case WM_NCDESTROY: { // 窗口销毁时顺手移除子类,避免资源泄漏 ::RemoveWindowSubclass(hWnd, &CefBrowserSubclassProc, uIdSubclass); break; } } return ::DefSubclassProc(hWnd, uMsg, wParam, lParam); }在这段代码里,WM_NCHITTEST返回HTCAPTION之后,Windows会自动接管后续的鼠标左键按下、移动、抬起整个流程。你不需要自己处理WM_LBUTTONDOWN,不需要调用MoveWindow,甚至连SetCapture都不需要写。这种“只回答一个问题,剩下全交给系统”的思路,恰恰是Windows原生拖动的优雅之处。
2.3 区域划分的代价与注意事项
这套方案最大的坑,就是这个HTCAPTION返回是可“传染”的。一旦某块区域返回了HTCAPTION,那这块区域里的所有鼠标事件都不会再交给CEF子窗口处理。也就是说,网页里的按钮、链接、滚动条,只要在这40像素之内,全部点不了。
我最初图省事,把整个CEF子窗口都返回HTCAPTION,结果整个页面变成了一块看得到摸不着的画布,按钮一个都点不动。所以如果你采用固定高度方案,一定要和前端对好:
- 顶部多少像素是标题栏,前端就不要在这个区域里放任何可交互元素;
- 最小化、关闭、最大化按钮要放在这个区域外面,或者单独留出可点击的部分;
- 如果网页没有固定高度标题栏,而是让用户自己定义皮肤,这个高度写死在C++里很快就会翻车。
另外,双击标题栏会触发系统默认的最大化/还原行为。如果你不想让HTCAPTION区域支持双击最大化,可以在子类过程里拦掉WM_NCLBUTTONDBLCLK并返回0。
还有一个小细节:当窗口最大化的时候,顶部HTCAPTION区域可能距离屏幕边缘太近,用户想拖动窗口到第二块显示器时会遇到阻力。这个属于Windows系统的正常行为,但如果你的产品有特殊需求,比如最大化状态下拖动窗口时自动还原到普通大小,就需要额外处理WM_NCHITTEST和窗口状态判断,这里先不展开。
3. 方案二:JS桥接拖拽,让页面决定哪里能拖
3.1 什么时候不能靠写死的标题栏高度
固定高度方案简单粗暴,但它有一个天生的短板:可拖区域和前端布局是硬绑定的。一旦前端把标题栏改成48像素,或者做成自适应高度,C++里那个kDragHeight = 40就得跟着改,改完还要重新编译发布。更要命的是,有些UI设计会在标题栏区域里放按钮,比如右侧的最小化、关闭按钮,刚好就在可拖动区域内。按方案一的思路,这部分区域返回HTCAPTION后,按钮就废了。
所以当项目里出现这两种情况时,就该考虑让前端来指定“哪里可以拖动”:
- 顶部区域不固定,受窗口尺寸、用户设置影响;
- 可拖动区域内需要保留部分可点击元素。
这时候采用JS桥接方案,把拖动的触发权交给页面。
3.2 C++侧暴露startWindowDrag函数
JS桥接的方案说起来并不复杂:当鼠标在页面指定的可拖动区域内按下时,页面调用一个注册到C++侧的JavaScript函数,C++这边收到调用后,通知Windows“我们要开始拖窗口了”。
C++侧需要用CefV8Handler注册一个startWindowDrag函数。实际代码如下:
class DragV8Handler : public CefV8Handler { public: DragV8Handler(HWND main_hwnd) : main_hwnd_(main_hwnd) {} virtual bool Execute(const CefString& name, CefRefPtr<CefV8Value> object, const CefV8ValueList& arguments, CefRefPtr<CefV8Value>& retval, CefString& exception) override { if (name == "startWindowDrag") { if (::IsWindow(main_hwnd_)) { // 关键一步:先释放鼠标捕获 ::ReleaseCapture(); POINT pt; ::GetCursorPos(&pt); // 把坐标放进lParam,通知系统开始拖动 ::SendMessage(main_hwnd_, WM_NCLBUTTONDOWN, HTCAPTION, MAKELPARAM(pt.x, pt.y)); } return true; } return false; } private: HWND main_hwnd_; IMPLEMENT_REFCOUNTING(DragV8Handler); };这里面两个点特别容易踩坑。
第一个是ReleaseCapture()必须有。如果鼠标在CEF页面上按下了,CEF内部会自己捕获鼠标,你不主动释放,Windows接收到WM_NCLBUTTONDOWN后不会正常进入窗口移动循环,表现为窗口抖动一下但不动。
第二个是SendMessage的lParam必须携带当前鼠标屏幕坐标。很多人图省事直接填0,结果窗口“瞬移”到屏幕左上角再开始跟随鼠标。坐标可以用GetCursorPos获取,然后用MAKELPARAM打包。注意不要用MAKELPARAM(pt.x, pt.y)时把pt当成了POINT结构体,MAKELPARAM需要的是两个WORD值,坐标取低位即可。
3.3 HTML侧事件处理
页面的写法也有讲究。不能简单地在标题栏元素上绑个mousedown就完事,需要阻止浏览器的默认行为,否则会发生“文字选中 + 拖拽图片”之类的副作用。
关键代码如下:
const titleBar = document.getElementById('custom-title-bar'); titleBar.addEventListener('mousedown', (event) => { // 只响应鼠标左键 if (event.button !== 0) return; // 阻止默认行为:避免选中文本、触发拖拽 event.preventDefault(); // 调用C++注册的全局函数 window.startWindowDrag(); });同时,为了让拖动更流畅,最好在可拖动区域的CSS里加上user-select: none;,避免拖到一半页面文字被选中,体验很差。如果你在可拖动区域内还有按钮,先判断事件目标是否在按钮上,只有落在非交互空白区域时才调用startWindowDrag。这一点可以用一个简单的>