在 Linux 桌面客户端中,托盘能力通常依赖 QSystemTrayIcon。在 X11 场景下它大多可用,但在部分国产化环境(尤其是麒麟 + Wayland 组合)中,常见问题是:

  • 托盘点击事件偶发丢失
  • 右键菜单定位不稳定
  • 图标在不同面板上的显示不一致

这些问题在“自定义托盘菜单”场景下会更明显,因为菜单位置计算、点击来源和托盘几何依赖更强。
本文给出一套已经在生产环境验证的方案:使用自定义 StatusNotifierItem(SNI) DBus 接口实现托盘主链路,并保留传统托盘实现作为回退路径。该形式主要用于适配麒麟 990 机型,也可复用到 9A0 机器系列。

1. 设计目标

这套方案的目标不是“替换旧实现”,而是“在复杂环境下稳定可控”:

  • 按环境启用:只在目标 OS + 会话类型中启用 SNI
  • 双路径并存:SNI 与传统托盘同时保留
  • 统一业务层:上层只接收统一点击/菜单事件
  • 可灰度可回退:线上可快速关掉 SNI

2. 总体架构

核心思路是三层:

  1. 托盘适配层
    在运行时决定走 SNI 还是传统托盘。
  2. 协议层
    通过 DBus 导出 org.kde.StatusNotifierItem,注册到 StatusNotifierWatcher
  3. 业务层
    托盘点击、双击、右键事件统一收敛到同一事件总线。

这样可以把“桌面环境差异”隔离在最底层,业务代码保持稳定。

3. 关键实现点

3.1 SNI 接口导出

对外提供标准属性与方法,包括:

  • 基础属性:CategoryIdTitleStatus
  • 图标与提示:IconPixmapToolTip
  • 交互方法:ActivateContextMenuSecondaryActivate

这保证了与主流托盘宿主(面板/Dock)的协议兼容。

3.2 Watcher 注册兼容

不同桌面对注册参数支持不完全一致。建议做双形态注册:

  • 先传 object path(如 /StatusNotifierItem
  • 失败后再传 unique-name + object-path

这一步能显著降低“同一程序在不同桌面有时显示有时不显示”的概率。

3.3 图标字节序处理

IconPixmap 的像素字节序要严格按 SNI 预期格式输出。实践中如果直接复用图像内存,容易出现颜色通道错位(例如颜色偏紫)。

建议统一做一次显式通道转换再发布到 DBus。

3.4 事件统一收敛

无论底层来自 SNI 还是传统托盘,都转换为统一事件:

  • 左键/双击 -> 打开主界面
  • 右键 -> 展示菜单

这样上层逻辑完全不关心当前底层协议,避免后续维护出现“双份逻辑”。

3.5 自定义托盘菜单下的定位双兜底

Wayland 场景里,做自定义托盘菜单时,传统托盘几何信息经常不可靠。推荐两级兜底:

  1. 优先用托盘事件回调里的坐标
  2. 回调无坐标时,通过 StatusNotifierHost.GetSNIGeometry 反查几何

再结合当前桌面环境做边界修正(clamp),保证菜单不会出屏。

4. 代码层面怎么做(不含项目细节)

下面给一套通用代码骨架,命名均为示例,可直接映射到你自己的工程。

4.1 运行时门禁:决定是否启用 SNI

1
2
3
4
5
6
7
8
bool shouldUseSni()
{
const bool featureOn = readBoolConfig("tray_sni_enable", false);
const bool isKylin = detectOsName().contains("Kylin", Qt::CaseInsensitive);
const bool isWayland = QGuiApplication::platformName().contains("wayland", Qt::CaseInsensitive)
|| qgetenv("XDG_SESSION_TYPE").toLower() == "wayland";
return featureOn && isKylin && isWayland;
}

4.2 SNI 项对象:导出 DBus 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SniTrayItem final : public QObject
{
Q_OBJECT
public:
explicit SniTrayItem(QObject* parent = nullptr);
bool registerToWatcher();
void setIcon(const QIcon& icon);
void setToolTip(const QString& title, const QString& text);
void setStatus(const QString& status); // Active / Passive

signals:
void activated(int x, int y);
void contextMenuRequested(int x, int y);
};

实现要点:

  • QDBusAbstractAdaptor 导出 org.kde.StatusNotifierItem
  • 暴露属性:CategoryIdTitleStatusIconPixmapToolTip
  • 暴露方法:ActivateContextMenuSecondaryActivate

4.3 注册 Watcher:双形态参数兼容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool SniTrayItem::registerToWatcher()
{
auto bus = QDBusConnection::sessionBus();
const QString objectPath = "/StatusNotifierItem";
if (!bus.registerObject(objectPath, this, QDBusConnection::ExportAdaptors)) {
return false;
}

auto msg = QDBusMessage::createMethodCall(
"org.kde.StatusNotifierWatcher",
"/StatusNotifierWatcher",
"org.kde.StatusNotifierWatcher",
"RegisterStatusNotifierItem");

msg.setArguments(QVariantList() << objectPath);
auto reply = bus.call(msg, QDBus::Block, 3000);
if (reply.type() == QDBusMessage::ErrorMessage) {
msg.setArguments(QVariantList() << (bus.baseService() + objectPath));
bus.call(msg, QDBus::NoBlock);
}
return true;
}

4.4 图标像素转换:避免通道错位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
QByteArray toArgbBytes(const QImage& img)
{
QByteArray out;
out.reserve(img.width() * img.height() * 4);
for (int y = 0; y < img.height(); ++y) {
const QRgb* line = reinterpret_cast<const QRgb*>(img.constScanLine(y));
for (int x = 0; x < img.width(); ++x) {
const QRgb px = line[x]; // 0xAARRGGBB
out.append(char(qAlpha(px)));
out.append(char(qRed(px)));
out.append(char(qGreen(px)));
out.append(char(qBlue(px)));
}
}
return out;
}

4.5 统一托盘门面:屏蔽底层差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class TrayFacade : public QObject
{
Q_OBJECT
public:
void init() {
if (shouldUseSni()) {
sni_ = std::make_unique<SniTrayItem>();
sni_->registerToWatcher();
connect(sni_.get(), &SniTrayItem::activated, this, &TrayFacade::onActivate);
connect(sni_.get(), &SniTrayItem::contextMenuRequested, this, &TrayFacade::onContextMenu);
} else {
legacy_ = std::make_unique<QSystemTrayIcon>();
connect(legacy_.get(), &QSystemTrayIcon::activated, this, [this](auto reason) {
if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) {
emit activateRequested();
}
if (reason == QSystemTrayIcon::Context) {
emit menuRequested(QPoint());
}
});
}
}

signals:
void activateRequested();
void menuRequested(const QPoint& pos);
};

4.6 菜单定位兜底:坐标优先,几何反查次之

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
QPoint resolveMenuPos(const QPoint& callbackPos)
{
if (!callbackPos.isNull()) {
return clampToScreen(callbackPos);
}

const QRect trayRect = queryLegacyTrayGeometry();
if (trayRect.isValid()) {
return clampToScreen(trayRect.bottomLeft());
}

const QRect sniRect = querySniGeometryViaDbus(); // StatusNotifierHost.GetSNIGeometry
if (sniRect.isValid()) {
return clampToScreen(sniRect.bottomLeft());
}

return clampToScreen(QCursor::pos());
}

5. 为什么这套方式适合麒麟990与9A0系列

国产化终端常见的不是单点问题,而是“协议 + 桌面 + 会话”组合差异:

  • 协议层:XEmbed 与 SNI 混用
  • 桌面层:不同面板实现行为不一致
  • 会话层:X11 与 Wayland 边界不同

SNI 双栈方案把不确定性放在适配层处理,并通过灰度开关控制范围,能更稳地覆盖麒麟 990 和 9A0 系列这类环境。

6. 生产落地清单

上线前建议逐项确认:

  1. 开关策略
    按机型/OS/会话类型控制启用范围。
  2. 回退能力
    线上可一键切回传统托盘。
  3. 可观测性
    记录注册结果、事件来源、几何获取结果。
  4. 跨桌面验证
    至少覆盖 UKUI/UOS/GNOME 等目标环境。
  5. DPI 与多屏
    验证菜单定位在高 DPI、多显示器下是否稳定。

7. 一个最小化伪代码示例

1
2
3
4
5
6
7
8
if (shouldUseSni(os, sessionType, featureFlag)) {
sni.registerItem();
sni.onActivate([] { openMainWindow(); });
sni.onContextMenu([](x, y) { showMenu(resolveMenuPos(x, y)); });
} else {
tray.onActivate([] { openMainWindow(); });
tray.onContextMenu([] { showMenu(resolveMenuPosFromLegacyTray()); });
}

8. 总结

对于国产化 Linux 桌面客户端,托盘适配不应是“二选一”,而应是“协议双栈 + 环境门禁 + 统一事件 + 回退机制”。

这类设计在麒麟 990 与 9A0 系列的实践中,更容易做到稳定上线、可控迭代。