Qt 在 Windows 下默认字体比较丑,但是我们有办法修改它
一直觉得 Qt 在 Windows 系统上的默认字体不太好看,不过自己写程序时自己去指定字体也很方便,就没怎么在意。这几个月专门用 Qt 写了一些程序,发现这还真的是个问题,因为包括官方的 Qt Creator 在内,都没有开放给用户自定义默认字体的设置,天天看着实在不怎么舒服。本来 Windows 系统是允许用户自定义桌面字体的,Win10 不知是出于去桌面化还是什么考虑,把这个功能又拿掉了。
当然,通过修改注册表还是可以修改系统默认字体的,而且我知道确实有这样的第三方工具。不过鉴于 Win10 对桌面系统日益后妈化的现实,这个接口说不定哪天也会被关掉,所以我个人并不怎么希望走这个路子。然而我也明白这确实是当前最简单、也不需要任何编程的手段。如果读者希望用这个方法的话,请自行搜索类似 Font Changer
之类的关键字,下面的文字就不需要再看了。用编程的方法则比较麻烦,需要自行修改一些代码,愿意自己动手的朋友请继续阅读。
要解决该问题,首先请阅读 QTBUG-58610,我也是找资料时偶然发现这条信息的。按照该 bug 的描述,该问题的基本原因在于 Qt
在获取字体时使用了教早的 Win32 函数 GetStockObject
,而较新的系统中应该使用 SystemParametersInfo
。看起来只是修改一个系统调用,似乎不难解决,但审核记录却显示修正会放到下一个主要版本(6.0)。这意味着即将到来的 LTS 版本(5.12)不会解决该问题。或许是出于审慎和兼容性考虑吧,不过我对这个结果是有点失望的。好在 Qt
是开源的,并且问题看起来也很简单,我决心自己看一下能不能自己修改源码来启用新的字体。
我使用的是当前 Qt
的最新正式版 5.11.2。首先声明,本文描述的方法是我自行尝试的,并未得到官方验证,虽然我自己已经作了测试,并分析过可能受影响的相关代码,自信还是比较可靠的,但并不保证 100% 没有问题,同时也仅在 Windows 做了测试,所以内容仅供参考,对 Qt
有自行编译经验的朋友不妨尝试。
简单查找了一下,对上述接口的调用主要在如下位置: qtbase\src\platformsupport\fontdatabases\windows\qwindowsfontdatabase.cpp
,文件中又有两处稍有不同的实现,首先是 QWindowsFontDatabase::systemFont
:
// ### fixme Qt 6 (QTBUG-58610): See comment at QWindowsFontDatabase::systemDefaultFont()
HFONT QWindowsFontDatabase::systemFont()
{
static const HFONT stock_sysfont = (HFONT)GetStockObject(DEFAULT_GUI_FONT);
return stock_sysfont;
}
看起来程序员已经标记出问题,但并未修改。注释告诉我们还要注意 QWindowsFontDatabase::systemDefaultFont()
,那接下来就看看这个函数:
QFont QWindowsFontDatabase::systemDefaultFont()
{
#if QT_VERSION >= 0x060000
// Qt 6: Obtain default GUI font (typically "Segoe UI, 9pt", see QTBUG-58610)
NONCLIENTMETRICS ncm;
ncm.cbSize = FIELD_OFFSET(NONCLIENTMETRICS, lfMessageFont) + sizeof(LOGFONT);
SystemParametersInfo(SPI_GETNONCLIENTMETRICS, ncm.cbSize , &ncm, 0);
const QFont systemFont = QWindowsFontDatabase::LOGFONT_to_QFont(ncm.lfMessageFont);
#else
LOGFONT lf;
GetObject(QWindowsFontDatabase::systemFont(), sizeof(lf), &lf);
QFont systemFont = QWindowsFontDatabase::LOGFONT_to_QFont(lf);
// "MS Shell Dlg 2" is the correct system font >= Win2k
if (systemFont.family() == QLatin1String("MS Shell Dlg"))
systemFont.setFamily(QStringLiteral("MS Shell Dlg 2"));
// Qt 5 by (Qt 4) legacy uses GetStockObject(DEFAULT_GUI_FONT) to
// obtain the default GUI font (typically "MS Shell Dlg 2, 8pt"). This has been
// long deprecated; the message font of the NONCLIENTMETRICS structure obtained by
// SystemParametersInfo(SPI_GETNONCLIENTMETRICS) should be used instead (see
// QWindowsTheme::refreshFonts(), typically "Segoe UI, 9pt"), which is larger.
#endif // Qt 5
qCDebug(lcQpaFonts) << __FUNCTION__ << systemFont;
return systemFont;
}
嗯,修改代码已经给出,只是标记为从 V6 开始启用。要启用的话,这里只要切换到新代码即可,但上面的 systemFont
呢?是简单的废弃掉了,还是有别处代码继续调用?
好在 HFONT
是 Windows 特定类型,考虑到代码结构,该类应该只是在内部作为平台实现,不太可能由其他代码直接调用,因此我们可以把查找范围限定在 platformsupport
内部,避免搜索整个 Qt
库。搜索一下可知,除了上面已经看到的 systemDefaultFont()
之外,调用 systemFont()
的尚有三处。仔细观察代码可知,这些调用主要为另一个名为 QWindowsFontEngine
的类提供字体。
这就有点麻烦了。熟悉 Win32 API 的朋友应该知道,GetStockObject
返回的对象,不论是否调用 DeleteObject
都不会有不良后果,所以你可以放心大胆的调用而无需担心 GDI 资源泄露。而 SystemParametersInfo
就不同了,自己创建的字体,必须保证在正确的时间释放,否则要么丢失字体,要么泄露句柄。为保证修改安全,我们不能只考虑前面两处地方,而必须考虑到调用者是否、以及应该在何处释放资源的问题。
qwindowsfontdatabase.cpp
中引用到 sytsemFont()
方法的代码有两处。一处就在前面的 systemDefaultFont()
调用中,不过我们已经看到了,如果切换到 V6 的实现,那么该调用将不会再起作用,我们可以安全地忽略它。另一处在 createEngine()
方法中:
QFontEngine *QWindowsFontDatabase::createEngine(const QFontDef &request, const QString &faceName,
int dpi,
const QSharedPointer<QWindowsFontEngineData> &data)
{
...
if (request.stretch != 100) {
HFONT hfont = CreateFontIndirect(&lf);
if (!hfont) {
qErrnoWarning("%s: CreateFontIndirect failed", __FUNCTION__);
hfont = QWindowsFontDatabase::systemFont();
}
HGDIOBJ oldObj = SelectObject(data->hdc, hfont);
TEXTMETRIC tm;
if (!GetTextMetrics(data->hdc, &tm))
qErrnoWarning("%s: GetTextMetrics failed", __FUNCTION__);
else
lf.lfWidth = tm.tmAveCharWidth * request.stretch / 100;
SelectObject(data->hdc, oldObj);
DeleteObject(hfont);
}
...
上述代码会在处理完毕后调用 DeleteObject
。很好,这段代码是安全的,我们不用管它了。接下来看 QWindowsFontEngine
中的调用。
首先看 qwindowsfontengine.cpp
中的调用点:对 systemFont()
的调用的两处分别在 QWindowsFontEngine
的构造函数和析构函数。这是一个好现象,说明生命周期非常明确,但我们仍然要了解该调用生成的对象是如何管理的。首先看构造函数:
QWindowsFontEngine::QWindowsFontEngine(const QString &name,
LOGFONT lf,
const QSharedPointer<QWindowsFontEngineData> &fontEngineData)
: QFontEngine(Win),
m_fontEngineData(fontEngineData),
_name(name),
m_logfont(lf),
ttf(0),
hasOutline(0)
{
qCDebug(lcQpaFonts) << __FUNCTION__ << name << lf.lfHeight;
hfont = CreateFontIndirect(&m_logfont);
if (!hfont) {
qErrnoWarning("%s: CreateFontIndirect failed for family '%s'", __FUNCTION__, qPrintable(name));
hfont = QWindowsFontDatabase::systemFont();
}
HDC hdc = m_fontEngineData->hdc;
SelectObject(hdc, hfont);
...
可见,QWindowsFontEngine
是将 systemFont()
作为一种后备机制,只有 CreateFontIndirect
不成功的情况下才会调用它。至于首选字体是什么可以不用关心它,但这样字体的来源有两种可能,这给我们判断是否应该删除增加了一点困难。再看析构函数:
QWindowsFontEngine::~QWindowsFontEngine()
{
if (designAdvances)
free(designAdvances);
if (widthCache)
free(widthCache);
// make sure we aren't by accident still selected
SelectObject(m_fontEngineData->hdc, QWindowsFontDatabase::systemFont());
if (!DeleteObject(hfont))
qErrnoWarning("%s: QFontEngineWin: failed to delete font...", __FUNCTION__);
qCDebug(lcQpaFonts) << __FUNCTION__ << _name;
...
}
这里的处理逻辑有点绕,因为 hfont
本来就可能来自 systemFont()
,结果在释放时再一次选择了该字体。这种处理原来是没有问题的(因为 GetStockObject
返回的对象不需要删除),但对新字体的实现明显就会有泄露的风险了。我们看到,析构函数后面会调用 DeleteObject
,所以不论构造函数是如何生成字体的,这里确实会释放,不必担心。那么问题就在于上面的 SelectObject
怎么办。思考一番后,我决定这样:把这样的 systemFont()
改成原来的实现(GetStockObject
) ,这样就无需担心泄露了。鉴于 QWindowsFontEngineData
仅在 QWindowsFontEngine
内部使用,其 hdc
在释放以后应该不会再用于 Font Engine,所以这样做应该是安全的。
方法已经考虑清楚,接下来就是实现了。首先找到 QWindowsFontDatabase::systemDefaultFont()
,强行开启 V6 分支判断:
QFont QWindowsFontDatabase::systemDefaultFont()
{
//{{HACK_BEGIN
#if 1 // QT_VERSION >= 0x060000
//}}HACK_END
上述代码是我自己的习惯,便于以后查找修改的地方,因为 Qt 代码实在是太庞大了,为一点小小的修改就上版本控制的话会非常慢。当然修改之前把原来的代码备份一次是个好习惯。
然后修改 QWindowFontDatabase::systemFont()
,使用新的字体查找方法:
// ### fixme Qt 6 (QTBUG-58610): See comment at QWindowsFontDatabase::systemDefaultFont()
HFONT QWindowsFontDatabase::systemFont()
{
//{{HACK_BEGIN
// static const HFONT stock_sysfont = (HFONT)GetStockObject(DEFAULT_GUI_FONT);
// return stock_sysfont;
NONCLIENTMETRICS ncm;
ncm.cbSize = FIELD_OFFSET(NONCLIENTMETRICS, lfMessageFont) + sizeof(LOGFONT);
SystemParametersInfo(SPI_GETNONCLIENTMETRICS, ncm.cbSize , &ncm, 0);
return CreateFontIndirect(&ncm.lfMessageFont);
//}}HACK_END
}
这里和原来的处理有一点差别。原来的代码将字体作为静态变量,估计是希望优化性能吧。但看过对应的实现,我们知道调用方通常会在结束之后调用 DeleteObject()
,所以现在用静态变量是不太现实的。对当代计算机来说 Font 对象只要不泄露,多调用几次应该不会造成明显的性能问题。
然后是 qwindowsfontengine.cpp
。我们只需要替换析构函数中的实现就可以了:
QWindowsFontEngine::~QWindowsFontEngine()
{
...
// make sure we aren't by accident still selected
//{{HACK_BEGIN
// SelectObject(m_fontEngineData->hdc, QWindowsFontDatabase::systemFont());
SelectObject(m_fontEngineData->hdc, (HFONT)GetStockObject(DEFAULT_GUI_FONT));
//}}HACK_END
程序到此修改完毕,我们可以重新编译 Qt, 然后走开去做点别的(需要很长时间)。
编译完成后,你可以用生成的 DLL 替换原来的版本,即可生效。其实严格说来,我们所作的只是一个很小的修改,单独复制下列文件即可:plugins/platforms/qwindows.dll
(调试版本为 qwindowsd.dll
)。
我们看看修改后的效果。我保留了原版和修改过的版本,这样放在一起容易看出差别。可以看出,新版的字体比原来更加丰满圆润,我个人觉得顺眼多了。你觉得呢?
无关的吐槽:就在写作本文时,Visual Studio 2017 更新 15.8.7 再次搞坏了我的 Qt 构建,本来 15.8.6 还是完全没有问题的......我以为 VS2017 经过这么多更新应该已经很稳定了,没想到还是和 Win10 一个尿性。好吧,用回 VS2015,至少不会给我搞什么幺蛾子。