1. GTK4 丢失托盘能力的来龙去脉:为什么接口从 GTK 3 到 GTK 4 突然就没了
1.1 从 GtkStatusIcon 说起
如果你写过 GTK2 或 GTK3 的桌面小程序,大概率用过 GtkStatusIcon。这是 GNOME 时代提供的"标准托盘图标"接口,往任务栏旁边的通知区域塞一个小图标,再绑定一个菜单,半小时就能跑起来。到了 GTK4,这套东西直接被删掉了,官方迁移指南里只丢下一句话:系统托盘属于遗留问题,请自行寻找替代方案。
我第一次看到这条说明的时候,第一反应是"不至于吧"。直到我把 GTK4 的 API 文档翻了一遍,才确认 GtkStatusIcon 是真的没了,而且没有同等级的替代品。这不是接口换了个名字,而是底层技术路线被彻底抛弃。
理解了这一点,才算真正理解 GTK4 系统托盘集成这个题目为什么这么拧巴:你得先接受一个现实——托盘不是 GTK 的"内置能力",而是 Linux 桌面环境下几种相互竞争的历史协议的集合。只要走通协议,GTK 版本反而没那么重要。
1.2 XEmbed:旧协议为什么不灵了
传统的系统托盘基于 X11 的 XEmbed 机制。托盘图标本质上是一个嵌到别的进程窗口里的 X 窗口,托盘宿主(比如 GNOME Panel、xfce4-panel)通过 _NET_SYSTEM_TRAY_S 这个 X11 属性来找到所有图标窗口,并负责把它们排布在同一个区域。
这套机制在 X11 时代工作得很好,但它有几个天然的硬伤:
- 跟 Wayland 基本绝缘。GNOME 切到 Wayland 后,XEmbed 托盘只靠 XWayland 勉强兼容,体验很差,尤其是 HiDPI 缩放下,位图图标会发虚。
- 稳定性差。托盘宿主一旦崩溃,所有嵌入的图标窗口跟着一起消失,而挂着图标的客户端进程往往毫不知情。
- 没有统一的菜单协议。菜单还是通过窗口事件来传递,无法跨进程描述菜单结构,也就没法做到"宿主绘制菜单,客户端只提供菜单模型"。
GNOME 当年在 Ubuntu 主导下推 AppIndicator,初衷就是要摆脱 XEmbed 的种种限制。后来这个思路被提炼成了 StatusNotifierItem 规范,再后来 KDE、XFCE 相继接纳,才形成了今天这套基于 D-Bus 的通知区域协议体系。
1.3 两个协议阵营的现实格局
现在 Linux 桌面上,托盘实质上分成两大阵营:
- XEmbed 老阵营:还在用
_NET_SYSTEM_TRAY_S协议,代表是 XFCE 的旧托盘插件、一些轻量级窗口管理器的面板。 - StatusNotifierItem 新阵营:基于 D-Bus 会话总线的协议,代表是 KDE Plasma、GNOME + AppIndicator 扩展、XFCE 新版 StatusNotifier 插件。
StatusNotifierItem(下文简称 SNI)里面又细分成几个接口:org.kde.StatusNotifierItem 描述图标本身,org.kde.StatusNotifierWatcher 负责登记和广播图标实例,com.canonical.dbusmenu 负责描述托盘菜单。这套体系对 GTK4 来说反而是好消息:SNI 完全跑在 D-Bus 上,跟 GTK 渲染没有任何关系,所以不管你的界面层是 GTK4 还是 Qt,都能接入。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四条集成路线评估:修修补补 vs 直接撸协议
2.1 libayatana-appindicator:最省事但有版本绑架
appindicator 在 Ubuntu 时代叫 libappindicator,后来社区维护者把代码接盘下来,改叫 libayatana-appindicator。它是对 SNI 协议的高层封装,API 很友好,几乎所有 Linux 托盘教程都在讲它。
但问题也出在这:这个库长期绑定 GTK3,内部用的菜单类型是 GTK3 的 GtkMenu。GTK3 和 GTK4 的类型系统在同一个进程里是有命名冲突的——两边都注册了 GtkButton、GtkMenu 这些类型名,强行链接两个库会直接触发类型重名断言,不是警告一下就能过去的。所以"GTK4 主程序里链接一个 GTK3 的 appindicator 动态库"这条路,理论上有解,实际上很容易翻车。
我的建议是:如果发行版没有提供针对 GTK4 构建的 appindicator 变体,就不要把两条 GTK 绑定在一个进程里硬塞。要么用辅助进程跑 GTK3 的托盘宿主,要么干脆走纯 D-Bus 路线。
2.2 纯 D-Bus 实现 SNI:代码量多但彻底自由
既然 SNI 是 D-Bus 协议,那就完全可以自己实现。用 GLib 的 GDBus 搞一个 StatusNotifierItem 服务对象,再结合 GMenuModel 把菜单导出成 com.canonical.dbusmenu。这条路没有任何 GTK 版本依赖,GTK4(甚至以后的 GTK5)都能用同一套代码。
代价是你要自己处理协议细节:服务名注册、Watcher 探测、属性变更通知、DBusMenu 动作派发。跟 appindicator 比起来,起步代码量大概多一倍,但一旦封装好,后面基本不会因为依赖升级再来找你麻烦。
2.3 辅助进程模式:GTK3 托盘宿主 + 进程间通信
还有一种组合拳:主程序是 GTK4,另起一个 GTK3 的小进程来承接托盘图标和菜单。两个进程之间用 D-Bus 或 stdin/stdout 通信。这个方案适合那种实在割舍不下 appindicator API 的项目。
优点是你完全绕开了类型冲突,菜单在辅助进程里用熟悉的 gtk_menu_new() 构建。缺点是进程生命周期管理很烦:主程序退出时要记得回收辅助进程,托盘菜单的回调都要跨进程转发,开发调试的复杂度直接上一个台阶。
2.4 选型对比
| 方案 | 代码量 | Wayland 支持 | 与 GTK4 兼容 | 维护成本 | 推荐场景 |
|---|---|---|---|---|---|
| libayatana-appindicator(GTK3 构建) | 少 | 支持(SNI 走 D-Bus) | 冲突,需辅助进程 | 中 | 已有 GTK3 代码库,想快速接入 |
| libayatana-appindicator(GTK4 构建变体) | 少 | 支持 | 直接可用 | 低,但依赖发行版打包 | 发行版已提供 gtk4 变体 |
| 纯 D-Bus SNI + GMenuModel | 中 | 支持 | 完全兼容 | 低 | GTK4 新项目,推荐 |
XEmbed 直写 _NET_SYSTEM_TRAY_S |
极多 | 不支持 | 得自己写 X11 代码 | 高 | 特殊老桌面包需求,不建议 |
我最终落地选的是纯 D-Bus 路线,后面会详细拆解。但先花一节讲 AppIndicator 的接入模式,因为很多老项目并不是说换就能换的。
3. 路线 A:libayatana-appindicator 的接入方式与 GTK3 菜单死结
3.1 依赖安装与构建配置
如果你的发行版能提供 GTK4 版本的 appindicator,安装依赖这一步很直接:
bash复制# Debian / Ubuntu 系
sudo apt install libayatana-appindicator-gtk4-dev libgtk-4-dev
装完之后用 pkg-config --modversion ayatana-appindicator3-0.1 确认一下版本,再检查一下头文件位置,就能开始写了。要注意的是,不同发行版包的命名差异很大,有的叫 libayatana-appindicator-gtk4-dev,有的还是老名字,构建系统里建议这样探测:
meson复制appindicator_dep = dependency('ayatana-appindicator3-0.1', required: false)
if not appindicator_dep.found()
warning('未找到 appindicator,托盘功能将被禁用')
endif
构建配置里要留意:appindicator 的库文件如果链接的还是 GTK3,那在 GTK4 环境里即使编译过了,运行阶段也很容易因为底层类型初始化冲突而异常。真遇到这种情况,别硬调,先看发行版有没有 GTK4 变体,没有就换方案。
3.2 初始化与图标设置
假设你的发行版确实提供了 GTK4 变体,API 大体还是老一套:
c复制#include <libayatana-appindicator/app-indicator.h>
AppIndicator *indicator;
indicator = app_indicator_new(
"com.example.MyApp", /* id */
"my-app-icon", /* icon theme 名称 */
APP_INDICATOR_CATEGORY_APPLICATION_STATUS /* 类别 */
);
app_indicator_set_status(indicator, APP_INDICATOR_STATUS_ACTIVE);
app_indicator_set_icon_full(indicator, "my-app-icon", "my-app-symbolic-icon");
app_indicator_new 的 id 最好是反向域名格式,因为内部它会用这个 id 生成 D-Bus 服务名的一部分。icon 参数填的是主题图标名,建议提供普通版和 symbolic 版两个名字,因为 GNOME 的扩展更喜欢 symbolic 图标,KDE 则看情况自适应。
3.3 菜单传递:GTK3 的 GtkMenu 在 GTK4 里就是个幽灵
这是整个 Route A 最核心的坑:app_indicator_set_menu() 这个 API 当年定义的时候,接收参数是 GTK3 的 GtkMenu *。到了 GTK4,GtkMenu 本身都不存在了,API 就算能编译,底层的菜单序列化逻辑也还是照着 GTK3 控件树去遍历的。
如果你确认你手里的 appindicator 是 GTK4 构建版,它大概率已经把参数类型换成了 GtkWidget *,并且内部走的是 GTK4 的 GMenuModel。但在没有 GTK4 变体的发行版上,你就会被卡在"没有 GtkMenu 可传"的尴尬处境。
我见过几个项目的做法是启动一个 GTK3 的辅助进程来专门构造菜单,代码大致长这样:
c复制/* tray-helper.c —— 以 GTK3 编译的一个独立小进程 */
#include <gtk/gtk.h>
#include <libayatana-appindicator/app-indicator.h>
static void on_menu_quit(GtkMenuItem *item, gpointer data) {
/* 通过 D-Bus 通知主进程退出 */
g_print("quit\n");
}
int main(int argc, char *argv[]) {
gtk_init(&argc, &argv);
AppIndicator *ind = app_indicator_new(
"com.example.MyApp.Helper",
"my-app-icon",
APP_INDICATOR_CATEGORY_APPLICATION_STATUS
);
app_indicator_set_status(ind, APP_INDICATOR_STATUS_ACTIVE);
GtkWidget *menu = gtk_menu_new();
GtkWidget *quit_item = gtk_menu_item_new_with_label("退出");
g_signal_connect(quit_item, "activate", G_CALLBACK(on_menu_quit), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), quit_item);
gtk_widget_show_all(menu);
app_indicator_set_menu(ind, GTK_MENU(menu));
gtk_main();
return 0;
}
主程序负责 fork 这个 helper,退出时把它一起杀掉。菜单点击行为全部通过 D-Bus 或子进程 stdout 转发回来。这条路能跑通,但会让部署产物的依赖翻倍,所以我始终认为它不是 GTK4 新项目的最优解。
4. 路线 B:用 GDBus + GMenuModel 手写 SNI,彻底绕开 GTK 版本绑架
4.1 先搞清楚协议要做什么
纯 D-Bus 路线没有魔法,你要按顺序做四件事:
- 在会话总线上注册一个服务名,比如
org.kde.StatusNotifierItem-12345-1。 - 在
/StatusNotifierItem路径下导出一个对象,实现org.kde.StatusNotifierItem接口,里面放着图标名、状态、菜单路径这些属性。 - 在菜单路径(比如
/com/myapp/TrayMenu)下导出一个实现com.canonical.dbusmenu接口的对象。这一步可以直接用 GLib 的g_dbus_connection_export_menu_model(),不用自己写 DBusMenu 协议。 - 探测会话总线上有没有
org.kde.StatusNotifierWatcher这个服务。有的话,调用它的RegisterStatusNotifierItem方法,把我们的服务名登记进去。
整个流程跑完,系统托盘里就会出现你的图标了。失败最集中的环节也在这四步里,后面第五部分会专门讲排障。
4.2 定义 StatusNotifierItem 接口并生成骨架代码
第一步是用 introspection XML 描述接口。这是 SNI 协议的核心定义,建议直接抄规范里那套字段:
xml复制<node>
<interface name="org.kde.StatusNotifierItem">
<property name="Category" type="s" access="read" />
<property name="Id" type="s" access="read" />
<property name="Title" type="s" access="read" />
<property name="Status" type="s" access="read" />
<property name="IconName" type="s" access="read" />
<property name="IconPixmap" type="a(iiay)" access="read" />
<property name="OverlayIconName" type="s" access="read" />
<property name="OverlayIconPixmap" type="a(iiay)" access="read" />
<property name="AttentionIconName" type="s" access="read" />
<property name="AttentionMovieName" type="s" access="read" />
<property name="ToolTip" type="(sa(iiay)ss)" access="read" />
<property name="Menu" type="o" access="read" />
<property name="ItemIsMenu" type="b" access="read" />
<method name="ContextMenu">
<arg type="i" name="x" direction="in" />
<arg type="i" name="y" direction="in" />
</method>
<method name="Activate">
<arg type="i" name="x" direction="in" />
<arg type="i" name="y" direction="in" />
</method>
<method name="SecondaryActivate">
<arg type="i" name="x" direction="in" />
<arg type="i" name="y" direction="in" />
</method>
<method name="Scroll">
<arg type="i" name="delta" direction="in" />
<arg type="s" name="orientation" direction="in" />
</method>
</interface>
</node>
然后交给 gdbus-codegen 生成骨架:
bash复制gdbus-codegen --interface-prefix org.kde. \
--c-namespace Snix \
--generate-c-code sni-dbus \
org.kde.StatusNotifierItem.xml
会生成 sni-dbus.h 和 sni-dbus.c,里面包含 SnixStatusNotifierItemSkeleton 这样的 GObject 类型。接下来你在自己的代码里继承这个骨架,或者更简单一点,直接实例化骨架并设置属性。
4.3 实例化、导出、注册 Watcher 的完整骨架
c复制#include "sni-dbus.h"
static GDBusConnection *conn;
static void
register_with_watcher(void)
{
gchar *service_name = g_strdup_printf("org.kde.StatusNotifierItem-%d-1", getpid());
/* 先拥有一个会话总线名字,Watcher 后续才能回查你的对象 */
g_bus_own_name_on_connection(conn, service_name,
G_BUS_NAME_OWNER_FLAGS_NONE,
NULL, NULL, NULL, NULL);
/* 向 Watcher 登记 */
g_dbus_connection_call(conn,
"org.kde.StatusNotifierWatcher",
"/StatusNotifierWatcher",
"org.kde.StatusNotifierWatcher",
"RegisterStatusNotifierItem",
g_variant_new("(s)", service_name),
NULL, G_DBUS_CALL_FLAGS_NONE, -1, NULL, NULL, NULL);
g_free(service_name);
}
监听 Watcher 是否出现也很关键。桌面环境启动时,Host(面板)通常比应用早出现,但如果你先启动应用再启动面板,就必须监听 StatusNotifierWatcherRegistered 信号,等面板注册完再补登记。
c复制static void
on_watcher_signal(GDBusConnection *connection,
const gchar *sender_name,
const gchar *object_path,
const gchar *interface_name,
const gchar *signal_name,
GVariant *parameters,
gpointer user_data)
{
if (g_strcmp0(signal_name, "StatusNotifierWatcherRegistered") == 0) {
register_with_watcher();
}
}
static void
setup_watcher_watch(void)
{
g_dbus_connection_signal_subscribe(conn,
"org.kde.StatusNotifierWatcher",
"org.kde.StatusNotifierWatcher",
NULL, "/StatusNotifierWatcher", NULL,
G_DBUS_SIGNAL_FLAGS_NONE,
on_watcher_signal, NULL, NULL);
}
把状态设为 Active:
c复制SnixStatusNotifierItemSkeleton *item;
item = g_object_new(snix_status_notifier_item_skeleton_get_type(), NULL);
snix_status_notifier_item_skeleton_set_category(item, "ApplicationStatus");
snix_status_notifier_item_skeleton_set_id(item, "MyAppTray");
snix_status_notifier_item_skeleton_set_title(item, "我的应用");
snix_status_notifier_item_skeleton_set_status(item, "Active");
snix_status_notifier_item_skeleton_set_icon_name(item, "my-app-icon");
snix_status_notifier_item_skeleton_set_menu(item, "/com/myapp/TrayMenu");
snix_status_notifier_item_skeleton_set_item_is_menu(item, FALSE);
g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(item),
conn,
"/StatusNotifierItem",
&error);
注意 Menu 属性的值是一个对象路径,不是服务名。托盘宿主拿到这个路径后,会去你的服务名下访问 com.canonical.dbusmenu 接口来拉取菜单结构。
4.4 用 GMenuModel 导出 DBusMenu
GLib 有一个宝藏 API 经常被忽略:
c复制guint g_dbus_connection_export_menu_model(GDBusConnection *connection,
const gchar *object_path,
GMenuModel *menu_model,
GError **error);
它能把一个 GMenuModel 自动导出成 com.canonical.dbusmenu 接口。这意味着我们完全不用手写 DBusMenu 的 GetLayout、GetGroupProperties、Event 这些方法,只要用 GTK4 推荐的 GMenuModel 构造菜单即可。
菜单项要能响应点击,还需要把动作分组也导出来:
c复制GSimpleActionGroup *actions = g_simple_action_group_new();
GSimpleAction *show_action = g_simple_action_new("show_window", NULL);
g_signal_connect(show_action, "activate", G_CALLBACK(on_show_window), NULL);
g_action_map_add_action(G_ACTION_MAP(actions), G_ACTION(show_action));
/* 导出动作分组到 D-Bus */
guint export_action_id;
export_action_id = g_dbus_connection_export_action_group(conn,
"/com/myapp/TrayActions",
G_ACTION_GROUP(actions),
&error);
/* 构造菜单,action 名称要带上导出路径对应的点分前缀 */
GMenu *menu = g_menu_new();
g_menu_append(menu, "显示主界面", "com.myapp.TrayActions.show_window");
g_menu_append(menu, "退出", "com.myapp.TrayActions.quit");
guint export_menu_id;
export_menu_id = g_dbus_connection_export_menu_model(conn,
"/com/myapp/TrayMenu",
G_MENU_MODEL(menu),
&error);
这里最容易被忽略的是动作名的前缀规则。我实测下来,g_dbus_connection_export_action_group() 导出到对象路径 /com/myapp/TrayActions 后,菜单项里的动作名要以 com.myapp.TrayActions. 开头,再拼动作名。如果你的菜单点不动,八成是这个前缀没对上。
4.5 动态更新图标与菜单
托盘图标不是设置完就一劳永逸的。应用状态变化时,要更新图标:
c复制/* 切换主题图标 */
snix_status_notifier_item_skeleton_set_icon_name(item, "my-app-error-icon");
/* 如果不想依赖主题图标,也可以直接设置像素图 a(iiay) */
GVariant *pixmap_variant;
pixmap_variant = my_build_pixmap_variant(pixbuf); /* 自行构造 ARGB32 数据 */
snix_status_notifier_item_skeleton_set_icon_pixmap(item, pixmap_variant);
IconPixmap 的类型是 a(iiay),含义是"结构体数组,每个结构体包含宽度、高度、字节数组"。字节数组里是 ARGB32 格式的像素数据,注意不同宿主对字节序的容错不一样,实测某些 GNOME 扩展对非标准字节序兼容性很差,所以我建议能叫主题图标名就叫主题图标名,别折腾像素图。
菜单更新反而简单,因为 GMenuModel 本身是支持变更通知的:
c复制g_menu_remove_all(menu);
g_menu_append(menu, "重试", "com.myapp.TrayActions.retry");
g_menu_append(menu, "退出", "com.myapp.TrayActions.quit");
导出后的 GMenuModel 会自动把变更信号通过 DBusMenu 的 LayoutUpdated 推给宿主,不需要手动发信号。
5. 踩坑实录:从图标不显示到菜单点不动的三次排障
5.1 坑一:图标完全不出现
第一次写完,我满怀信心地跑起来,结果托盘区域空空如也。这个问题的排查链路我每次接到类似问题都会走一遍,你们可以直接照抄:
第一步,确认 Watcher 存不存在。在另一个终端输入:
bash复制busctl --user get-property org.kde.StatusNotifierWatcher \
/StatusNotifierWatcher org.kde.StatusNotifierWatcher \
IsStatusNotifierHostRegistered
如果返回 b true,说明桌面环境里已经有一个宿主在监听 SNI。如果返回 false,得去检查桌面扩展或插件,而不是查你的代码。
第二步,确认自己的服务有没有注册上:
bash复制busctl --user get-property org.kde.StatusNotifierWatcher \
/StatusNotifierWatcher org.kde.StatusNotifierWatcher \
RegisteredStatusNotifierItems
你的服务名和路径应该出现在返回的数组里。如果不在,多半是 RegisterStatusNotifierItem 的参数格式不对。有些实现要求传对象路径(比如 /StatusNotifierItem),有些要求传服务名。最稳妥的做法是先 g_bus_own_name_on_connection() 占住服务名,再把服务名传进去。
第三步,验证你自己的属性是否能被外部读到:
bash复制busctl --user get-property org.kde.StatusNotifierItem-12345-1 \
/StatusNotifierItem org.kde.StatusNotifierItem IconName
如果属性读不出来,说明对象导出路劲或服务名不对。这一条基本能解决八成"图标不出现"的问题。
5.2 坑二:图标出现了但菜单点不动
图标出来后,右击却没有任何反应,这个坑也常见。我的排查链路是:
先查 Menu 属性指向的对象路径存不存在、是否导出了 DBusMenu 接口:
bash复制busctl --user introspect org.kde.StatusNotifierItem-12345-1 \
/com/myapp/TrayMenu
如果 Introspect 结果里没有 com.canonical.dbusmenu 接口,说明 g_dbus_connection_export_menu_model() 没有成功,检查错误对象或者导出 ID 是否为 0。
如果接口在,但点菜单项没反应,那问题通常出在动作名映射上。用 busctl --user introspect 看 /com/myapp/TrayActions,确认导出的动作组存在,再去对比菜单项里 com.myapp.TrayActions.show_window 是否跟导出路径完全匹配。大小写、下划线、多点一个点少点一个点都不行。
还有一个特别隐蔽的问题:GMenuModel 和 GSimpleActionGroup 这两个对象如果被垃圾回收释放了,导出接口还在,但内部数据已经失效。C 代码里务必保持全局引用,Python 里要留意作用域,别让对象在函数返回时被销毁。
5.3 坑三:进程退出后托盘图标残留
这个问题在纯 D-Bus 实现里尤其容易出现。原因在于:SNI 协议的宿主要靠监听会话总线上服务名的消失来感知客户端退出,如果你的进程退出时没有释放 D-Bus 名字,某些桌面环境会等一个很长的超时才清理图标。
解决办法是退出时手动收尾:
c复制static void
on_before_quit(void)
{
if (item != NULL) {
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(item));
g_object_unref(item);
}
if (owner_id > 0) {
g_bus_unown_name(owner_id);
}
if (menu_export_id > 0) {
g_dbus_connection_unexport_menu_model(conn, menu_export_id);
}
if (action_export_id > 0) {
g_dbus_connection_unexport_action_group(conn, action_export_id);
}
}
把 g_dbus_interface_skeleton_unexport() 和 g_bus_unown_name() 都执行一遍,托盘图标基本能做到秒消失。别图省事只 unref 对象,服务名还挂在总线上,宿主照样认为你活着。
5.4 Wayland 下特别要注意的事
SNI 走的是 D-Bus,在 Wayland 下天然可用,不依赖 XWayland。但有几个细节:
首先,Activate、ContextMenu 这些方法传进来的 x/y 坐标是面板为你计算好的弹出位置,在 Wayland 下这些坐标可能是全局坐标也可能是相对面板的坐标,不同桌面实现并不统一。如果你的左键行为是"弹出菜单",别自己去算位置,直接把宿主给的坐标原样传给你的弹层函数,否则菜单可能会跑到奇怪的地方。
其次,GNOME 默认的 Dash to Dock 或扩展插件不一定实现了 SNI 的全部方法。我实测过某些扩展只监听 ContextMenu,对 Activate 做了忽略处理,所以左键没反应不一定是你代码的问题,换个桌面环境多测测再下结论。
6. 兼容性实测与最终工程建议
6.1 桌面环境兼容矩阵
我拿纯 D-Bus SNI 方案在几个常见桌面上过了一遍,结果如下:
| 桌面环境 | 右击菜单 | 左键 Activate | 点击响应 | 备注 |
|---|---|---|---|---|
| KDE Plasma 5/6 | 正常 | 正常 | 即时 | SNI 原生支持,体验最佳 |
| GNOME 43 + AppIndicator 扩展 | 正常 | 部分扩展忽略 | 略有延迟 | 必须装扩展,默认不带 |
| XFCE 4 + xfce4-statusnotifier-plugin | 正常 | 正常 | 即时 | 插件维护比较活跃 |
| LXQt 面板 | 正常 | 正常 | 即时 | 兼容性不错 |
| Cinnamon | 右击正常 | 正常 | 即时 | 对 XEmbed 和 SNI 都兼容 |
| MATE | 正常 | 正常 | 即时 | 新版走 ayatana 兼容层 |
整体来看,只要桌面宿主实现了 SNI,纯 D-Bus 方案就能稳定运行。反而是 XEmbed 老方案在 GNOME 和较新的 KDE 上越来越边缘化。
6.2 构建与打包提示
纯 D-Bus 方案在构建上非常轻:
meson复制project('myapp', 'c')
gtk_dep = dependency('gtk4')
executable('myapp',
'main.c',
'sni-dbus.c',
dependencies: gtk_dep,
)
gdbus-codegen 生成的 sni-dbus.c 只依赖 GLib,不需要额外链接 GTK,这一点很干净。打包时也没有 appindicator 的运行时依赖,RPM/DEB 里多写一个 glib 版本约束就行。
如果走辅助进程方案,打包时要多带上 tray-helper 这个二进制,并且主程序启动时要通过绝对路径把它找出来。我做示例时被这个坑过一次:开发环境里 /usr/local/bin 找得到,安装到用户目录后 PATH 变了,托盘就静默消失了。建议写死 helper 路径,或者在打包脚本里生成配置文件。
6.3 最后建议
做完了 GTK4 系统托盘集成,我个人的结论很简单:新项目直接上纯 D-Bus SNI + GMenuModel,不要纠结 appindicator。虽然第一版代码量多一些,但换来的是跟 GTK 版本彻底解耦,以后 GTK5 出了都不用动托盘那部分。老项目如果实在没法脱开 appindicator,优先检查发行版有没有 GTK4 变体,没有就安排辅助进程,别把所有依赖塞进一个进程里硬扛。
另外提醒一句:不管选哪条路线,托盘终究是一个"锦上添花"的功能。GNOME 默认桌面不开扩展的话,用户根本看不到托盘图标,所以主窗口的开关入口一定要保留,别把应用的核心操作都塞进托盘菜单里。我见过不少应用,主窗口标题栏上的关闭按钮直接退出,却要求用户靠托盘图标找回窗口,一旦托盘不显示,整个应用就"消失"了。
