🎬 第二章 VLC播放器及二次开发应用
📄 一句话概括

Duilib 做的是「把系统标题栏去掉,自己画一个更好看的」。 代价是系统免费给你做了二十年的活,现在要自己写一遍。

📝 本节和前面几节的关系

上一节用 MFC 的原生窗口,这一节换成一个「无边框、可换肤」的界面库。 播放能力一点没变——还是那句 set_hwnd。变的全是「窗口长什么样、怎么被拖动」。

一、为什么要无边框

系统标题栏有两个问题:一是丑(灰底、方角、老式按钮),二是不可定制(没法做圆角、 没法做半透明、没法放自定义按钮)。

要去掉它,把窗口样式改成 WS_POPUP,然后自己画一个「假标题栏」——上面放最小化、最大化、 关闭三个按钮,都是你自己画的图。

代价立刻出现三条: 拖动要自己算(系统不再处理标题栏拖动)。 缩放要自己算(系统不再处理边框拖动)。 贴边、双击最大化、右键系统菜单全都没了,要做就自己做。

所以「无边框」不是免费的:它把「外观自由度」换成了「一堆必须自己实现的细节」。 界面库里这些都已经写好了,但你要知道它们存在。

二、拖动的坐标算法

算法只有三行,但很容易写错:

// 按下时:记住鼠标相对窗口左上角的偏移
void OnLButtonDown(POINT pt)
{
    m_dragOffset = pt - m_winPos;
}

// 移动时:窗口左上角 = 当前鼠标 - 偏移
void OnMouseMove(POINT pt)
{
    if (m_dragging)
        MoveWindow(pt.x - m_dragOffset.x,
                   pt.y - m_dragOffset.y);
}

关键在「偏移只在按下时算一次」。 如果每次移动都重算偏移,窗口会「粘在鼠标上」——鼠标永远停在窗口左上角,窗口像被吸住一样跟着跑。

demo 里把四步坐标都算了一遍:按下时窗口在 (400,300)、鼠标在 (520,340),偏移就是 (120,40), 之后无论移到哪儿,偏移都不变。

三、命中测试与缩放

无边框窗口要自己判断「鼠标点在哪儿」,因为系统不再帮你区分「标题栏」与「边框」。

判断逻辑:取鼠标位置, 看它落在窗口的哪一个区域——边缘 N 像素算「边框」(要缩放),顶部 M 像素算「标题栏」(要拖动),其余算「客户区」(交给下面的控件)。

这个判断函数在 Windows 里叫 WM_NCHITTEST 的返回值:返回 HTCAPTION 系统就帮你拖动, 返回 HTLEFT 就帮你缩放。这是最省事的做法——不用自己写拖动循环,直接让系统干。

但要注意:一旦你把「客户区」也返回成 HTCAPTION,那个区域里的按钮就点不动了。 所以要精确划分区域,宁可多写几行判断。

四、渲染区怎么摆

无边框界面里,VLC 的画面该放在哪一层? 答案是「一个独立的子窗口」,不是「画在皮肤上」。

原因跟上一节一样:VLC 会每帧重绘它的整个客户区, 如果让它画在皮肤上,皮肤上的按钮、文字全都会被盖掉。

所以正确的层级是: 顶层是无边框窗口(自己画的皮肤)。 中间放一个子窗口当「画布」(它的句柄交给 VLC)。 控件放在子窗口之外(或者用独立的透明浮层)。

这个结构在 Duilib 里对应一个 Control, 但要注意:Duilib 的控件默认是「自绘」的,没有独立窗口句柄。 所以要么给它设 bkcolor 之外的特殊属性、要么自建一个子窗口。 这是 Duilib + LibVLC 组合最常见的卡点。

五、高 DPI 的坑

150% 缩放下,一个「逻辑单位」是 1.5 个物理像素。 如果你的坐标计算混了这两种单位,拖动就会「飘」。

具体表现:鼠标移动 100 个物理像素,窗口只移动 67 个——手感发涩; 或者移动 150 个——窗口跑得比鼠标快。

解决办法是「全程用一种单位」:要么全用物理像素(GetCursorPos 给你的就是物理像素), 要么全用逻辑单位(ScreenToClient 之后再换算)。混用必错。

还有一个相关的坑: 没有声明 DPI 感知的程序,会被系统「虚拟化」——系统把你的窗口按 96 DPI 渲染再放大, 结果整个界面发虚。所以要显式声明(清单文件里加 dpiAware 或 dpiAwareness)。

六、动手 Demo:无边框窗口,全靠自己算坐标

这段把「按下 → 移动三次」的完整坐标序列算一遍。 重点看最后一列:偏移自始至终不变。

win = [400, 300]
down = (520, 340)
off = (down[0] - win[0], down[1] - win[1])   # 只算这一次
for mx, my in ((560, 360), (700, 480), (900, 620)):
    win = [mx - off[0], my - off[1]]
    T([u"移到 (%d, %d)" % (mx, my), u"", u"(%d, %d)" % tuple(win),
       u"(%d, %d)" % off], [0, 0, 0, 0])

实跑输出:

--------------------------------------------------------------------
无边框窗口,全靠自己算坐标
--------------------------------------------------------------------
**Duilib 这类界面库做的是「把标题栏去掉,自己画一个」。**
**于是「拖动窗口」这件事就得自己算。**

**拖动的算法只有三行**:
  按下时记住「鼠标相对窗口左上角的偏移」。
  移动时算「当前鼠标位置减这个偏移」,就是窗口新的左上角。
  松开时清掉记录。

**真算一遍**:

  时刻                鼠标           窗口左上角     偏移
  ---------------------------------------------------------------
  按下                (520, 340)     (400, 300)     (120, 40)
  移到 (560, 360)     (560, 360)     (440, 320)     (120, 40)
  移到 (700, 480)     (700, 480)     (580, 440)     (120, 40)
  移到 (900, 620)     (900, 620)     (780, 580)     (120, 40)

**这个偏移量必须在「按下」时算一次,之后不能重算。**
  **每次移动都重算的话,窗口会粘在鼠标上,鼠标永远停在左上角。**

**还有两件必须自己处理的事**:
  **命中测试**:窗口边缘那几像素要留给「拉伸」,中间才是「拖动」。
  高 DPI 缩放:150% 缩放下,一个逻辑单位是 1.5 个物理像素,
  **坐标要跟屏幕坐标换算,否则拖起来会「飘」。**

**这两件事正是「无边框」比「有边框」贵的地方**——系统标题栏免费帮你做了二十年的活,
现在要自己写一遍。

偏移 (120, 40) 一直没变,窗口左上角跟着鼠标走,这就是「拖动手感自然」的定义: 鼠标按住窗口的哪个点,那个点就一直跟着鼠标。

如果偏移每次重算会怎样:off = 鼠标 - 窗口,而窗口刚刚被设成 鼠标 - off, 代入就是 off = 0——窗口左上角永远等于鼠标位置,看起来像「窗口被吸到鼠标上」。

这个 bug 在无边框界面的项目里非常常见,因为「重算」写起来更顺手(不用存状态)。 但它错在「把一个常量当成了变量」。

30 秒实验

  1. 想一想:为什么「双击标题栏最大化」在无边框窗口里要自己实现?
  2. 如果窗口有圆角,命中测试该怎么处理四个角?
  3. 在 150% 缩放下,鼠标移动 100 像素,窗口该移动多少?

常见误区

  1. 每次移动都重算拖动偏移。 结果偏移恒为 0,窗口会「粘」在鼠标上。
  2. 让 VLC 直接画在皮肤窗口上。 它每帧重绘整个客户区,按钮与文字会被盖掉。
  3. 混用逻辑单位与物理像素。 高 DPI 下拖动会发涩或跑得比鼠标快。
自测三问(点开对答案) 1. 无边框窗口的拖动偏移为什么只在按下时算一次? —— 因为它是「鼠标相对窗口左上角」的固定关系。每次重算会让偏移变成 0,窗口粘在鼠标上。 2. 为什么 VLC 的画面必须放在独立子窗口里? —— 因为它每帧重绘整个客户区,会把皮肤上的控件盖掉。 3. 没有声明 DPI 感知的程序会怎样? —— 系统按 96 DPI 渲染再放大到实际缩放,界面发虚。

MFC 与 Duilib 都是 Windows 原生路线。下一节换 Qt——跨平台,但要把句柄交出去这件事一模一样。

相关笔记