Qt 高DPI缩放(高分屏适配)技术简析

本文最后更新于:2024年3月13日 下午

前情提要

众所周知,都已经2024年了,不会还有人在用1080P显示屏吧(没错就是我)

在很长一段时间里,1080P显示屏(1920 px * 1080 px)是业内设计UI的参考标准与目标

但是,当高分辨率屏幕(2K、4K)逐渐普及之后,人们发现,原先的UI设计在这些屏幕上显得很小

不难理解,这是因为高分屏在保持尺寸相当的情况下,塞下了更多的像素,增加了像素密度,或者说高DPI(Dot Per Inch)

每英寸的像素数量增加了,相同像素所占面积减小了,那么以像素为单位的UI设计,自然显得“更小”

1080P正常显示效果

4K显示器效果

可以看到4K显示器下,我们原本完美的UI设计比例全部被打乱,显得又小,又挤,又丑

这,这谁家的软件啊,我,我不认识你

怎办

那怎么办呢,最容易想到的办法是对UI进行等比缩放

标准显示密度96 DPI已经被Windows操作系统作为传统的显示标准采用。这意味着,在默认的100%缩放设置下,系统预期显示器能够在每英寸的长度上显示96个像素点。这一标准确保了在开发和设计软件应用时,界面元素(如文字、图标)的大小能够在不同的显示器上保持相对一致性。

所以Windows系统的逻辑DPI一般都是96(缩放100%情况下),但是物理DPI通常比这个要大,例如我手上的这台1080P显示器的物理DPI是:127.628

在DPI恒定的情况下,增加分辨率,那么屏幕尺寸会发生变化,但是所有软件UI物理尺寸不变

但是对于现代显示器而言,通常是增加像素密度,例如我手头这台4K显示器的物理DPI是:161.962

那么在相同长度上,他会比一般的96 DPI低分辨屏,显示更多像素

逻辑DPI vs 物理DPI

我们刚刚谈到了Windows中的逻辑DPI物理DPI,那么这俩究竟有什么区别呢

物理DPI好理解,就是屏幕设备真实的DPI(屏幕像素数 / 屏幕尺寸),对于一台特定设备,不会发生变化

逻辑DPI听起来比较迷惑,但是我们可以把它理解为Windows按照什么样的比例来绘制UI

或者希望软件按照什么样的DPI来规划自己的UI

这个数值与物理DPI无关,在Windows缩放100%情况下,逻辑DPI通常恒等于96

而且这个值(逻辑DPI)与 96 的比值就等于Windows缩放比Scale = 逻辑DPI / 96

如果缩放比为150%,那么逻辑DPI为144,Windows以1.5倍的像素数来渲染UI

通过模拟一个虚假的DPI,Windows就可以欺骗软件,伪造外界的DPI,以实现控制第三方软件的UI缩放

其实这里有三种情况:

  • 第一种,软件什么都不做,也不管外界的DPI,那么不管Windows缩放比如何变化,软件窗口的大小保持恒定(但是如果字体的单位是pt,则字体会变化)
  • 第二种,软件什么都不做,但是委托给Windows进行缩放管理(配置dpiAwareness),那么Windows就会接管,进行缩放
  • 第三种,软件读取外部逻辑DPI(如果是物理DPI的话,也行,就是恒定大小了),通过其与96的比值,自行缩放UI,例如 width *= scale;,那么显示效果最好,但是灰常麻烦

对于第二和第三种情况,被Windows缩放比所影响的逻辑DPI就可以发挥作用,这样也最符合用户需要

所以其实对于软件开发者,如果要自行缩放UI的话,不用单独考虑逻辑DPI的实际含义,只要关注其与96的比值(缩放比)即可

Windows原生DPI感知 vs Qt高DPI缩放

Windows DPI感知

最简单的方法是委托给Windows进行处理

可以调用以下API

1
2
3
4
#include <ShellScalingAPI.h>
#pragma comment(lib, "Shcore.lib")

HRESULT hr = SetProcessDpiAwareness(PROCESS_SYSTEM_DPI_AWARE);

或者使用qt.conf,在资源qrc里添加,:/qt/etc/qt.conf, qt.conf文件内容为:

1
2
[Platforms]
WindowsArguments = dpiawareness=1

4K显示器效果如图:(边缘的灰色是桌面背景)

Windows原生DPI感知

看起来有点模糊,但是各种功能都正常,懒人福利!

Qt高DPI缩放

Qt作为一个跨平台的桌面框架,自然不可避免要解决高分屏适配的问题

近几年来,特别是Qt5.15版本之后,对高分屏的支持变得更加能用了

比如,这一行可以让Qt支持非整倍数的缩放比,1.25 1.5等,(以前只能1x 2x地缩放)

1
QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);

Qt开启高DPI缩放只需要一行:(但是为了更加好用,请加上上面那行↑),(听说在Qt6中是默认开启的)

1
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling, true);

注意,所以针对高DPI缩放策略的调整请在QApplication构造之前完成

当开启缩放时,图片和图标可能会变得模糊,为了解决这个问题,我们需要开启Qt对高分辨率版本pixmap(High resolution versions of pixmaps)的支持,然后按照特定策略提供图片资源(类似于logo@2x.png

1
QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps, true);

Qt 将在运行时自动选择目标显示的最佳表示形式。有关更多详细信息,请参阅 High-DPI-Icons

所以综合起来就是:

1
2
3
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling, true);
QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps, true);
QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);

Qt高DPI缩放

可以看到字体更加锐利

Who is better

那么到底选哪个呢?

只能说各有千秋

  • Windows DPI感知发生在系统层,故兼容性最好,Bug比较少,但是显示效果不够好,因为是直接拉伸(类似于图片缩放)
  • 而Qt本身就是UI框架,缩放是在渲染层面上进行的,图像和文本可以保持其清晰度,不会出现像素化或模糊(但是边缘可能出现奇怪的线,不知道Qt6解决没)

那么,Qt胜出?

不,事情远远没有这么简单

Qt 高DPI缩放的原理

Qt内部采用了逻辑坐标系统:使用逻辑坐标来描述界面元素,这些坐标是相对单位,与屏幕的实际物理像素点数独立,而在渲染时自动缩放转换为物理坐标

也就是说,我们针对96 DPI,1080P显示器设计的以px为单位的UI布局无需更改,我们还是假设程序运行在这样一块标准显示器上,Qt会自动处理缩放

Problem

想法很美好,但是结果呢

结果是Windows API采用的是物理坐标(设备相关坐标),而Qt内部采用的是逻辑坐标(设备无关坐标)(所有公开函数都返回逻辑坐标,如QCursor::pos()

这反而增加了复杂性,我们调用Windows API获得的坐标要先转换为Qt内部的逻辑坐标才能进行下一步操作,否则就全乱了

1
2
3
4
5
6
7
8
9
10
bool Widget::nativeEvent(const QByteArray& eventType, void* message, long* result)
{
Q_UNUSED(eventType);
MSG* msg = (MSG*)message;
switch (msg->message) {
case WM_NCHITTEST:
int winX = GET_X_LPARAM(msg->lParam);
int winY = GET_Y_LPARAM(msg->lParam);
...
}

例如,在原生事件中,我们会接收到Windows传递来的参数

这些参数中的坐标,一定是物理坐标

我们需要将其转换为Qt内部逻辑坐标,才能继续使用

这里也有两种情况

  1. 如果你只有一块显示器,那么很简单,缩放中心就是左上角(0,0),直接winX / scale即可
  2. 如果你有多块显示器,而且DPI还不一样,那么问题就大发了

假如你的窗口又正好不在主显示器上,那么该显示器的左上角就不是(0,0)了,因为显示器上的坐标是唯一的

这个时候的转换稍微麻烦一点

首先,我们要获取Qt物理像素和逻辑像素的比值,也就是devicePixelRatio,一般等于Windows缩放比

1
2
3
4
5
6
7
8
9
10
11
12
qreal Util::devicePixelRatio(QWidget* widget)
{
qreal dprScale = 1.0;
QScreen *screen = QGuiApplication::screenAt(widget->pos());
if (screen != nullptr) {
dprScale = screen->devicePixelRatio(); //物理像素/逻辑像素,也就是Windows缩放比
} else {
// 在屏幕之间拖动时,会有短暂的nullptr的情况!
dprScale = QGuiApplication::primaryScreen()->devicePixelRatio();
}
return dprScale;
}

这里要注意,在屏幕之间拖动时,会有短暂的nullptr的情况,我们要处理这种情况,赋予默认值,否则就会空指针异常,然后崩溃(C++基操)

然后,我们要基于缩放比进行计算

1
2
3
4
5
6
7
8
const float DPR = Util::devicePixelRatio(this); //like 1.25 1.5 1.75 2.0
// 获取屏幕的几何信息
QScreen *screen = QGuiApplication::screenAt(this->pos());
QRect screenGeometry = screen->geometry(); //logical!
// 转换物理坐标为Qt的逻辑坐标
QPoint logicalPos;
logicalPos = (QPoint(winX, winY) - screenGeometry.topLeft()) / DPR;
logicalPos += screenGeometry.topLeft();

大致思想就是,将物理坐标减去该显示器左上角坐标,假装自己是主显示器,左上角为(0,0),这样就可以进行简单乘除缩放了

缩放完毕后,再把刚刚减去的偏移量加回来即可得到Qt的逻辑坐标

真的是这样吗?

还记得我说过什么吗

Qt内部采用的是逻辑坐标

那么,screen->geometry()不也就是逻辑坐标吗?

在多显示器情况下,假设现在的screen不是主屏幕,那么screenGeometry.topLeft()就有可能与物理坐标不想等

那么一个物理坐标(QPoint(winX, winY))减去一个逻辑坐标(screenGeometry.topLeft()),结果是显而易见的

假如主屏幕(或前几块屏幕)的Windows缩放不是100%,就会爆炸(假如逻辑坐标连续)

僵局

那么既然Qt内部函数返回的都是逻辑坐标,自然不可能找到一个能将逻辑坐标转换物理坐标的函数了

那么不是死锁了嘛

要不我们再看一眼官方文档

DprGadget

诶,Qt6居然比Qt5多出个小工具(DprGadget),貌似是用于高DPI Debug用的,看到几个Native字样,让我扒一扒你用了什么神奇函数

1
2
3
QPlatformWindow *platformWindow = windowHandle()->handle();
nativeSizeLabel->setText(QString("native size %1 %2").arg(platformWindow->geometry().width())
.arg(platformWindow->geometry().height()));

什么!等一下,QPlatformWindow,这个名字一听就很物理坐标啊(而且label的名字也包含native),快让我康康!

原来是用了#include <QtGui/qpa/qplatformwindow.h>头文件,よし

诶不过,为什么提示Not Found

有经验的小伙伴应该敏锐地察觉到了,这种情况要么是版本不对,要么是缺少模块

很快啊,我反手就打开了他的.pro文件

1
QT += widgets gui_private

好家伙,gui_private模块,藏着掖着,好好好,这么玩是吧

果然可以用了!

more

不过别急,来都来了,我们深挖一下

首先,#include <QtGui/qpa/qplatformwindow.h>中的qpa指的是什么

全称为:Qt Platform Abstraction,一听就很不跨平台啊

但是很接地气,显然是我们想要的

让我看看都有什么类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
QAbstractEventDispatcher
QPlatformAccessibility
QPlatformBackingStore
QPlatformClipboard
QPlatformCursor
QPlatformDrag
QPlatformFontDatabase
QPlatformGraphicsBuffer
QPlatformInputContext
QPlatformNativeInterface
QPlatformOffscreenSurface
QPlatformOpenGLContext
QPlatformScreen
QPlatformServices
QPlatformSharedGraphicsCache
QPlatformSurface
QPlatformWindow

这个QPlatformScreen一听就很香啊,依葫芦画瓢

1
2
//QScreen::handle() -> QPlatformScreen*
QScreen::handle()->geometry()

这样就得到了真真正正的物理坐标!!哭辽

more more

事情到这里就结束了吗,大概也许

但是你不觉得,获取屏幕左上角物理坐标,然后再四则运算一下,有点冗余吗

你想想,Qt内部采用逻辑坐标,渲染采用物理坐标,那么肯定有转换函数存在呀!

说时迟那时快(?),你想想:QCursor::pos()

QCursor::pos()返回的是逻辑坐标,但是要在Windows上获取鼠标坐标,肯定调用了Windows API(物理坐标)啊

那么其内部必然有转换函数

很快啊,我直接打开Qt5 QCurosr::pos()源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
QPoint QCursor::pos()
{
return QCursor::pos(QGuiApplication::primaryScreen());
}

QPoint QCursor::pos(const QScreen *screen)
{
if (screen) {
if (const QPlatformCursor *cursor = screen->handle()->cursor()) {
const QPlatformScreen *ps = screen->handle();
QPoint nativePos = cursor->pos(); //原生物理坐标
ps = ps->screenForPosition(nativePos); //寻找nativePos所在Screen
return QHighDpi::fromNativePixels(nativePos, ps->screen()); //
}
}
return QGuiApplicationPrivate::lastCursorPosition.toPoint();
}

这不看不得了啊,一看就留口水啊

QHighDpi::fromNativePixels,这么好用的函数你不拿出来给大家伙用!

QCursor::pos()

我们先来看一下QCursor::pos()的逻辑啊

有两个重载,无参版本默认把主显示器传给了有参版本

这里,我们可能会疑惑,诶,为什么需要QScreen参数呢,光标坐标和屏幕有关吗

别急,接着往下看

首先,获取了原生屏幕指针(QPlatformScreen),然后通过QPlatformCursor获取到了光标的原生物理坐标(nativePos

接着通过ps = ps->screenForPosition(nativePos)更新了屏幕指针,这是为什么呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*!
Find the sibling screen corresponding to \a globalPos.
Returns this screen if no suitable screen is found at the position.
*/
const QPlatformScreen *QPlatformScreen::screenForPosition(const QPoint &point) const
{
if (!geometry().contains(point)) {
const auto screens = virtualSiblings();
for (const QPlatformScreen *screen : screens) {
if (screen->geometry().contains(point))
return screen;
}
}
return this;
}

我们可以看到,注释和代码里都提到了一个词:sibling

来看一下Qt文档的解释

1
2
3
4
QList<QScreen *> QScreen::virtualSiblings() const
//Get the screen's virtual siblings.

//The virtual siblings are the screen instances sharing the same virtual desktop. They share a common coordinate system, and windows can //freely be moved or positioned across them without having to be re-created.

所以,sibling screens也就是一个虚拟桌面内的所有相邻显示器,一般情况下与qApp->screens()相等

因此,screenForPosition()会遍历屏幕,找到鼠标光标所在的那一块

所以我们不必纠结,为什么一开始传入的是primaryScreen(),会不会不准确

其实传入什么screen都可以,只要在同一个虚拟桌面内,会自行定位

这里传入一个指针,是为了定位虚拟桌面而已

more more more:QHighDpi::fromNativePixels

顾名思义,我们已经知道了他可以从物理坐标(原生像素)转化为逻辑坐标

那么稍微看一下源码吧

1
2
3
4
5
6
7
template <typename T, typename C>
T fromNativePixels(const T &value, const C *context)
{
QPoint nativePosition = position(value); //转换为QPoint
QHighDpiScaling::ScaleAndOrigin so = QHighDpiScaling::scaleAndOrigin(context, &nativePosition);
return scale(value, qreal(1) / so.factor, so.origin);
}

OK,很短,就三行,我们先来看看QHighDpiScaling::scaleAndOrigin

1
2
3
4
5
6
7
8
9
10
QHighDpiScaling::ScaleAndOrigin QHighDpiScaling::scaleAndOrigin(const QPlatformScreen *platformScreen, QPoint *nativePosition)
{
if (!m_active)
return { qreal(1), QPoint() };
if (!platformScreen)
return { m_factor, QPoint() }; // the global factor
const QPlatformScreen *actualScreen = nativePosition ?
platformScreen->screenForPosition(*nativePosition) : platformScreen;
return { m_factor * screenSubfactor(actualScreen), actualScreen->geometry().topLeft() };
}

这里再次调用screenForPosition()定位了光标所在屏幕,非常严谨

返回值有俩:

  • 计算缩放比:m_factor * screenSubfactor(actualScreen),巴拉巴拉bomb,可能是内部缩放比 * 外部缩放比?不管了
  • 计算光标所在屏幕的左上角物理坐标:actualScreen->geometry().topLeft()

这第二个就是重点了,跟我们自行计算的方法有异曲同工之妙,不过他这里获取到的是真真正正的物理坐标

然后调用scale(value, qreal(1) / so.factor, so.origin)进行最后的计算

1
2
3
4
inline QPoint scale(const QPoint &pos, qreal scaleFactor, QPoint origin = QPoint(0, 0))
{
return (pos - origin) * scaleFactor + origin;
}

哎呀,这不就是我们的计算公式吗

先减去左上角的坐标,归一化为(0,0),然后直接乘除缩放比,再把左上角加回来 hhhhhh

啊,哈,啊哈哈哈

and MORE!

事情远远没有这么简单(已经很复杂了好嘛喂)

如图,经过测试,每一块屏幕的虚拟坐标系(黄色区域)是单独计算的,其左上角坐标就是物理坐标,(虽然Size是缩放过的)

  • 测试设备为:4K显示器(150%缩放) + 1080P显示器(100%缩放)

物理坐标系 & 逻辑坐标系(黄)

惊天大发现,也就是说,虚拟坐标系中的每一块屏幕是不连续的!

鼠标从右边屏幕的左侧边缘,移动到左侧显示器的右边缘,在Qt内部,逻辑坐标是会发生突变的

(3840,1071) -> (2559,720)

所以,如图所示,每一块显示器的QScreen::geometry().topLeft()其实是准确的物理坐标,(虽然其Size是逻辑值)

因此,第一种转换方案其实也没问题,但是QHighDpi::fromNativePixels显然更简单hhhh

如果不想引入private模块,直接用第一种方式四则运算自行转换也行

Other Problem

在多显示器中,我们还可能遇到别的坑

比如:QListWidgetitem的布局没有刷新 & 比例不正确

更新:如果不启用QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough)才会出现这个问题

加上之后,貌似就没问题了~ Over

此时可以用QListWidget::doItemsLayout进行强制刷新(这个函数居然文档里没有!GPT-4告诉我的)

不过什么时机用呢?

貌似没有找到一个signal可以指示窗口从一个显示器到另一个显示器

那么可以在moveEvent()中,自行检测当前显示器是否发生变化(QScreen

Conclusion

其实如果与Windows 原生API交互不是太多的话,Qt自带的高DPI适配已经足够优秀了,毕竟是跨平台的,推荐使用

如果有少量Windows API话,可以手动缩放一下坐标,问题不大

Peace

嘛,反正Qt作为一个看似是前端(传统前端还有浏览器屏蔽细节呢)其实比全栈还要麻烦(要考虑底层系统交互)的技术点

只能说坑是踩不完的,而且国内用户其实不是很多,资料也不好找

不说了,都是泪,且看且珍惜,我午饭还没吃呢

Ref

QIcon 类 | Qt 图形用户界面 6.6.2 — QIcon Class | Qt GUI 6.6.2

高DPI | Qt 6.6 — High DPI | Qt 6.6

Qt之高DPI显示器 - 解决方案整理 - 知乎 (zhihu.com)

关于Qt适配不同分辨率和缩放率时可能遇到的问题和解决方案_qt 屏幕分辨率-CSDN博客

Qt高清DPI下的显示,自适应分辨率 qt-CSDN博客

Qt Windows高清DPI自适应分辨率缩放,比较完美的解决方案-CSDN博客

目前Qt有没有比较好解决高分屏下缩放显示的方案? - 知乎 (zhihu.com)


Qt 高DPI缩放(高分屏适配)技术简析
https://mrbeancpp.github.io/2024/03/12/Qt-高DPI缩放(高分屏适配)技术简析/
作者
MrBeanC
发布于
2024年3月12日
许可协议