Qt 在 Windows 下默认字体比较丑,但是我们有办法修改它

#Qt @Qt
版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

一直觉得 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)。

我们看看修改后的效果。我保留了原版和修改过的版本,这样放在一起容易看出差别。可以看出,新版的字体比原来更加丰满圆润,我个人觉得顺眼多了。你觉得呢?

Compare Assitant

Compare Creator

无关的吐槽:就在写作本文时,Visual Studio 2017 更新 15.8.7 再次搞坏了我的 Qt 构建,本来 15.8.6 还是完全没有问题的......我以为 VS2017 经过这么多更新应该已经很稳定了,没想到还是和 Win10 一个尿性。好吧,用回 VS2015,至少不会给我搞什么幺蛾子。