How to Change Themes with the Operating System in a Qt App
How to Change Themes with the Operating System in a Qt App
如何在 Qt 应用中实现随操作系统自动切换主题
It turns out, I got the timeline wrong. The first bit of work I did on Packet Sender was to test light mode. See, Dan Nagle, creator and maintainer of Packet Sender, had implemented light and dark mode. But he only uses dark mode personally. Because of this, he missed the fact that some parts of the dark mode theme was hanging on after the app switched to light mode. 事实证明,我搞错了时间线。我在 Packet Sender 上做的第一项工作是测试浅色模式。Packet Sender 的创建者兼维护者 Dan Nagle 已经实现了浅色和深色模式,但他个人只使用深色模式。正因如此,他没发现当应用切换到浅色模式后,深色模式主题的某些部分依然残留着。
Having fixed the bug, he wanted me to manually test the app going through as much UI as possible to make sure that going from dark mode to light mode 100% converted the app to light mode. I did not know this until quite recently, but it turns out that there is a setting you can toggle. I thought Packet Sender followed whatever the operating system is set to; that turned out to be the truth but not the whole truth. Whut? What I mean is, since I didn’t know there was a setting already built into Packet Sender I could toggle. 修复该 Bug 后,他希望我尽可能多地手动测试 UI,以确保从深色模式切换到浅色模式时,应用能 100% 转换为浅色模式。直到最近我才知道,原来应用里有一个可以切换的设置。我原以为 Packet Sender 是跟随操作系统设置的,事实确实如此,但并不完全准确。什么意思?我的意思是,我之前并不知道 Packet Sender 内部已经内置了一个可以手动切换的设置。
In order to do the manual tests, I would switch the operating system’s (read: macOS–I’m an Apple guy) mode and then see how Packet Sender responded. In order to affect the mode change, one had to restart Packet Sender. Upon launch, Packet Sender would read what the OS was set to and would apply the correct theme. What I know that Packet Sender didn’t do was update itself when the OS changed themes, say if you had set your machine up to automagically transition from dark to light mode or vice versa based on the time of day or the amount of light in your environment. 为了进行手动测试,我会切换操作系统(即 macOS,我是个苹果用户)的模式,然后观察 Packet Sender 的反应。为了使模式切换生效,必须重启 Packet Sender。启动时,Packet Sender 会读取操作系统的设置并应用正确的主题。我所知道的是,Packet Sender 不会在操作系统更改主题时自动更新,比如如果你设置了机器根据一天中的时间或环境光线自动在深色和浅色模式之间切换,它就不会自动响应。
It was my personal belief that we were so close to having Packet Sender do this–the hard work of having themes and applying them was already in the code base–all we had to do was know when the OS had changed modes and then we could update ourselves. Dan told me in a phone call that he had a different QT app for a client where he solved this problem. He said he solved it with a thread. 我个人认为,我们离实现这一功能已经很近了——主题及其应用逻辑的繁重工作已经在代码库中完成了——我们所要做的只是检测操作系统何时更改了模式,然后更新自身即可。Dan 在电话中告诉我,他为客户开发的另一个 Qt 应用解决了这个问题,他说他是通过线程解决的。
I fed this information in to Grok because this was early in my work with Packet Sender, I wasn’t familiar with some of the more modern C++ syntax/idioms and this was my first time working with Qt. Grok gave me code to use a QThread to solve this problem, but the code would crash. It turns out that listening for light/dark is not a background-compute problem. It is a GUI-state problem. The OS theme is shared with AppKit and with every widget. A helper thread that reads appearance and then calls setPalette() is racing the toolkit. On macOS, that race is undefined and often a hard crash. 我把这个信息输入给了 Grok,因为那时我刚开始参与 Packet Sender 的工作,对一些现代 C++ 语法/惯用法不太熟悉,而且这也是我第一次使用 Qt。Grok 给我的代码是使用 QThread 来解决这个问题,但代码运行后会崩溃。事实证明,监听浅色/深色模式并不是一个后台计算问题,而是一个 GUI 状态问题。操作系统的主题与 AppKit 和每个组件共享。一个读取外观并调用 setPalette() 的辅助线程会与工具包产生竞争。在 macOS 上,这种竞争是未定义的,通常会导致严重的崩溃。
The solution I ended up going with was to use a QTimer instead. A timer (or a colorSchemeChanged() slot–more on that anon) on the main thread is the same work with a critical section: the event loop. A QTimer parented to qApp fires on the main event loop. Each tick runs in the same thread that owns the widgets. Checking “is it dark now?” and calling applyTheme() is then serialized with paint, resize, and other theme events. No second actor is mid-scribble on QPalette. And, as stated above, on macOS, you also stay on the thread AppKit allows. 我最终采用的解决方案是使用 QTimer。在主线程中使用定时器(或 colorSchemeChanged() 槽函数——稍后详述)可以确保在关键部分(即事件循环)中完成相同的工作。挂载到 qApp 的 QTimer 会在主事件循环中触发。每次触发都在拥有组件的同一个线程中运行。检查“现在是深色模式吗?”并调用 applyTheme() 的过程,会与绘制、调整大小和其他主题事件序列化。这样就不会有第二个参与者在修改 QPalette。而且如上所述,在 macOS 上,你也能保持在 AppKit 允许的线程内。
As an addendum, polling every few seconds is inelegant. Qt 6.5+ has QStyleHints::colorSchemeChanged(), and widgets already get QEvent::ApplicationPaletteChange / ThemeChange / PaletteChange. But polling it is correct because it does not cross the thread boundary. Dan’s paid thread solved “notice the OS changed.” It did not solve “apply that change legally.” This assumes, of course, that Dan actually use a thread in his client work and not a QTimer and was misremembering which object he used. 补充一点,每隔几秒轮询一次并不优雅。Qt 6.5+ 提供了 QStyleHints::colorSchemeChanged(),并且组件已经可以接收 QEvent::ApplicationPaletteChange / ThemeChange / PaletteChange 事件。但使用轮询是正确的,因为它不会跨越线程边界。Dan 的付费线程方案解决了“检测到操作系统更改”,但没有解决“合法地应用该更改”。当然,这假设 Dan 在客户项目中确实使用了线程而不是 QTimer,并且他可能记错了所使用的对象。
To be clear, I’m not throwing shade on Dan here. He’s a working programmer and unless he has a photographic memory, it’d be easy to confuse the two on recall, especially in a post-work day telephone call. There is a logical follow up question: why not use QStyleHints::colorSchemeChanged()? Several reasons: I wasn’t sure which version of Qt we are using everywhere, so I didn’t want to add code that worked on some OSes but not others. I knew this “old way” would be safe. Even though I poll once every three seconds, it’s not a hard operation on the machine. e.g. I made an engineering trade off. 需要说明的是,我并不是在贬低 Dan。他是一位职业程序员,除非有过目不忘的记忆力,否则在回忆时很容易混淆两者,尤其是在一天工作结束后的电话交谈中。这里有一个合乎逻辑的后续问题:为什么不使用 QStyleHints::colorSchemeChanged()?原因有几点:我不确定我们各处使用的 Qt 版本,所以不想添加在某些操作系统上有效但在其他操作系统上无效的代码。我知道这种“老方法”是安全的。尽管我每三秒轮询一次,但这对机器来说并不是沉重的负担。换句话说,我做了一个工程上的权衡。
It seems like Grok warned me at the time there were some quirks with QStyleHints::colorSchemeChanged(), but as that work was done ~7 months ago at the time of this writing, the reason is lost to me. Here’s how I ultimately solved that problem: Grok 当时似乎警告过我 QStyleHints::colorSchemeChanged() 存在一些怪癖,但由于这项工作是在撰写本文约 7 个月前完成的,具体原因我已经记不清了。以下是我最终解决该问题的方法:
void setupThemePolling(QApplication *app, MainWindow *mainWindow, bool debugMode = false) {
QTimer *themePollTimer = new QTimer(app); // parent = app so it auto-deletes
themePollTimer->setInterval(3000);
static bool lastDark = Settings::useDark();
QObject::connect(themePollTimer, &QTimer::timeout, [app, mainWindow, debugMode, &lastDark]() {
bool current = Settings::useDark();
if (current != lastDark) {
QDEBUG() << "[THEME POLL] Change detected:" << (current ? "Dark" : "Light");
applyTheme(current, debugMode, app, mainWindow);
lastDark = current;
}
});
themePollTimer->start();
if(debugMode) {
QDEBUG() << "[THEME POLL] Timer started - polling every 3 seconds";
}
}
which is called from here: 该函数在以下位置被调用:
MainWindow w;
setupThemePolling(&a, &w, debugMode);
w.show();
return a.exec();