在开发跨平台桌面应用时,菜单栏(Menu Bar)是用户体验的核心组成部分。Gridea Pro 作为一款基于 Wails 开发的现代化应用,近期对后端的启动逻辑(Boot)和菜单系统进行了深度重构。本文将分享这一过程中的技术决策、踩坑经验以及最终的最佳实践方案。
背景:为什么重构?
在早期的开发中,我们的 boot.go 承担了过多的职责:初始化配置、启动 Http Server、构建菜单、处理系统信号等。随着功能的增加,buildMenu 函数变得臃肿不堪,且菜单项不支持多语言(i18n),这与 Gridea Pro 全球化的目标不符。
此外,Wails 默认的 menu.WindowMenu() 虽然方便,但其内部生成的 “Minimize”, “Zoom” 等标准菜单项无法被修改,导致无法汉化,这在中文环境下显得格格不入。
Part 1: Boot 代码的瘦身与解耦
所有的重构都始于清理。我们将 boot.go 的职责进行了重新梳理:
- Run 函数的纯粹化:
Run函数现在只负责核心依赖的注入(如embed.FS)和生命周期的管理。 - Context 管理:引入了包级变量
appCtx和processMenu相关的 handler,使得在应用运行的任何阶段都能安全地访问 Wails Context,这对于实现 “Check for Updates” 或 “Open Preferences” 等需要与前端交互的功能至关重要。 - 依赖注入:通过
InitLocales等函数,在启动阶段完成多语言环境的准备,确保菜单构建时已有可用的翻译数据。
// 现在的 Run 函数(简化版)
func Run(assets embed.FS) {
// 1. 初始化配置
// 2. 准备依赖
// 3. 构建初始菜单
appMenu := buildMenu(...)
// 4. 启动 Wails应用
err = wails.Run(&options.App{
Menu: appMenu,
OnStartup: func(ctx context.Context) {
// 注册事件监听,实现运行时动态能力
},
})
}
Part 2: 菜单国际化 (i18n) 的三次迭代
菜单的国际化是我们遇到的最大挑战。由于 Wails 的菜单是在 Go 后端构建的,而应用的语言设置存储在前端(Vue + i18n),两者之间存在天然的隔阂。
方案 A:后端硬编码(V1)
最早的方案是在 Go 代码中硬编码中文菜单。
- 优点:实现简单。
- 缺点:不支持英文环境,完全不可扩展。
方案 B:前后端共享 JSON(V2 - 激进尝试)
为了统一维护翻译,我们尝试了 “Single Source of Truth” 方案:
- 在
main.go中使用//go:embed将前端的locales/*.json嵌入到二进制中。 - 启动时解析这些 JSON,提取
nativeMenu字段。 - 遇到的问题:前端 JSON 使用
camelCase(如fileNewPost),而后端习惯dot.notation(file.newPost)。我们需要维护一个复杂的映射表,且 JSON 解析在启动时有微小的性能损耗,最重要的是,这种隐式的依赖增加了架构的脆弱性。
方案 C:回归本源 + 运行时重构(V3 - 最终方案)
最终,我们回退到了在 Go 端维护翻译 map 的方案,但加上了动态运行时切换的能力。
核心实现:
- menu_i18n.go:维护一个
map[string]map[string]string,包含所有支持语言(目前 11 种)的菜单翻译。 - 线程安全:提供
SetLocale(lang)和T(key)函数,内部使用sync.RWMutex保证并发安全。 - 前后端联动:
- 前端切换语言时,发射
app:change-locale事件。 - 后端监听该事件,更新当前语言状态,并重建整个菜单树。
- 调用
runtime.MenuSetApplicationMenu(ctx, newMenu)即时替换应用菜单。
- 前端切换语言时,发射
// 后端监听逻辑
wailsRuntime.EventsOn(ctx, "app:change-locale", func(data ...interface{}) {
if locale, ok := data[0].(string); ok {
// 1. 更新后端语言状态
SetLocale(locale)
// 2. 使用新语言重新构建菜单
newMenu := buildMenu(...)
// 3. 替换当前菜单,通过 Wails 实时生效
wailsRuntime.MenuSetApplicationMenu(ctx, newMenu)
// 4. (macOS) 强制刷新系统菜单栏
wailsRuntime.MenuUpdateApplicationMenu(ctx)
}
})
Part 3: 攻克 Window 菜单的汉化难点
Wails 提供的 menu.WindowMenu() 是一个原生封装,无法修改文本。为了实现 “最小化”、“缩放” 等标准功能的国际化,我们要手动构建 Window 菜单:
// 放弃 menu.WindowMenu(),手动构建:
windowMenu := appMenu.AddSubmenu(T("window"))
windowMenu.AddText(T("window.minimize"), keys.CmdOrCtrl("m"), func(_ *menu.CallbackData) {
if appCtx != nil {
wailsRuntime.WindowMinimise(appCtx)
}
})
windowMenu.AddText(T("window.zoom"), nil, func(_ *menu.CallbackData) {
if appCtx != nil {
wailsRuntime.WindowMaximise(appCtx)
}
})
// ... 其他窗口操作
通过调用 wailsRuntime.WindowMinimise 等 API,我们完美复刻了原生菜单的功能,同时拥有了完全的翻译控制权。
总结
这次重构不仅解决了遗留的技术债,更建立了一套可持续扩展的菜单架构:
- 架构清晰:Boot 逻辑与 UI 构建分离。
- 体验原生:支持 macOS/Windows 标准快捷键与系统行为。
- 完全国际化:支持 11 种语言,且能在运行时即时切换,无需重启应用。
对于 Wails 开发者来说,不要迷信“复用前端资源”。在菜单这种原生系统层面上,后端掌握完全的控制权往往是更健壮的选择。通过事件驱动机制打通前后端状态,才是混合应用开发的精髓。
评论