ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC多语言切换:基于po/mo语言包与Poedit的国际化实践

MFC多语言切换:基于po/mo语言包与Poedit的国际化实践

简介:面向需要在 Windows 桌面端实现界面国际化开发的 C++/MFC 程序员,这份资源围绕 MFC 多语言切换给出了一套可运行的完整示例,重点解决资源脚本组织、翻译文件生成与运行时语言切换之间的衔接问题,适合已具备 MFC 基础、希望把单语言程序改造成多语言版本的开发者参考。压缩包共 28 个文件,约 1.18MB,包含 6 个 h 头文件与 4 个 cpp 源文件构成的核心代码,1 个 rc 资源脚本及 rc2 补充资源,2 组 po/mo 翻译文件(含 zh.po、en.po)用于演示 POEdit 编辑与编译流程,另有 sln、vcxproj 等工程配置、LanguageLib.lib 与 LanguageLib.dll 语言库,以及编译产物 exe、pdb 等,可直接在 Visual Studio 2015 下打开调试。示例演示了按语言拆分资源、用 AfxSetResourceHandle 切换资源句柄、加载 mo 文件替换界面文本的思路,并涉及对话框控件随文本长度调整的布局处理,便于读者对照自身项目快速移植与验证。目前已有 1384 人学习下载。

1. 从一份要出日文版的老工程说起

接手一个跑了七八年的 MFC 老工程,需求方一句话「下个月要出日文版」,很多人的第一反应是把 .rc 复制一份、逐条翻译、编译成资源 DLL。这条路能走通,走完第一版你就知道代价:菜单在菜单资源里,提示语在字符串表里,对话框上的 Static 是写死的,托盘气泡又是另一处硬编码,加一句话要改五个文件;等对方补一句「繁体也要」,整套流程再走一遍。这份资源给的是另一条路——把界面文案从资源里抽出来做成标准语言包,用 Poedit 交给翻译同学维护,程序运行时按需加载,MFC 多语言切换就变成一次函数调用,而不是重编译。它适合已经有一批 MFC 代码、又不愿意推倒重来的团队,也适合想把多语言这块一次做干净的个人项目。

2. 方案选型与工程骨架:为什么用 po/mo 而不是资源 DLL

MFC 多语言的坑不在于「怎么翻译」,而在于「翻译放在哪、什么时候加载、切换之后谁去刷新界面」。这三件事定下来,剩下的都是体力活。项目正文里给的关键词是 poedit、mo 语言包,方向很明确:走 gettext 那一套。但真落到工程里,还是得把几种主流做法摆在一起看清楚。

2.1 四种常见做法摆在一起比

资源 DLL 是 MFC 的老传统:每种语言编译一份 .rc,启动时AfxSetResourceHandle切过去。优点是对话框布局可以单独调,德语撑爆的问题在资源层面就解决了;缺点是翻译必须落到开发手里,翻译同学拿到的是 .rc 或者 Excel,来回倒腾,而且切换语言基本等于重建窗口。

ini 或者注册表字符串表最省事,一张映射表搞定所有文案,但菜单、对话框上的静态文字、ListCtrl 列头这些还是得自己一层层刷,跟没做一样。

走数据库或者在线翻译平台,协作体验最好,代价是多一个服务依赖,离线环境直接歇菜。

po/mo 这套的甜点在于:msgid 是源码里的原文,msgstr 是译文,Poedit 打开就是列表,翻译同学不需要 Visual Studio;mo 是编译产物,二进制查表,加载快;改文案不用动工程结构。

方案翻译协作热切换布局可控性长期维护成本
资源 DLL(每语言一套 .rc)差,翻译同学碰不了需要重建窗口最好高
ini / 注册表字符串表一般,Excel 来回传可以差中
数据库 / 在线平台好可以差中高,带服务依赖
gettext 的 po/mo好,Poedit 直接交付可以靠运行时重排,需额外处理低

我一般会选最后一行,但在资源层面留一条后路:字符串、提示语、消息框走 po/mo,真正需要重排布局的对话框,如果将来语言多到十门以上,再考虑给两三个语言单独做资源。

2.2 目录结构与语言包的落盘约定

po 和 mo 一定要分开放。po 是源文件,进版本库,人读;mo 是产物,跟着 exe 发布,机读。目录长这样:

App/ ├─ src/ │ ├─ MainFrm.cpp │ ├─ DlgLogin.cpp │ └─ LanguageMgr.cpp 语言管理器 ├─ po/ 翻译源文件,进版本库 │ ├─ app.pot 模板,xgettext 生成 │ ├─ en_US.po │ ├─ ja_JP.po │ └─ de_DE.po ├─ i18n/ 运行时语言包,跟 exe 一起装 │ ├─ en_US/LC_MESSAGES/app.mo │ ├─ ja_JP/LC_MESSAGES/app.mo │ └─ de_DE/LC_MESSAGES/app.mo └─ tools/rebuild_i18n.bat 一条命令重建全部 mo

LC_MESSAGES这层是 gettext 的历史约定,自己写解析器其实可以省掉,但留着有两个好处:一是将来想换回 libintl,路径不用改;二是 Linux 版复用同一套 i18n 目录时零成本。中文简体我通常不生成 mo——msgid 直接写中文原文,zh_CN就当空目录处理,查不到译文时回落到 msgid 本身就对了,少一个文件也少一处对不齐的机会。

2.3 编译环境与依赖准备

工程属性里「高级 → 字符集」必须是「使用 Unicode 字符集」,这是底线。ANSI 工程做多语言,等于给自己埋一串代码页地雷,日文和德文一定翻车。

源文件存成 UTF-8 带 BOM。MSVC 对无 BOM 的 UTF-8 会按系统代码页猜,中文注释里的字会吃掉后面半个字符串;带 BOM 之后编译器才认。这一点和 xgettext 的--from-code=UTF-8要保持一致,否则抽出来的 msgid 第一行就是乱码。

工具链方面,Poedit 自带一份 msgfmt/xgettext,安装完在Poedit\bin下面;也可以单独装一套 Windows 版 gettext,把 bin 目录加到 PATH。我不建议在工程里引libintl.dll:一是多一个 DLL 依赖,二是它在 Windows 上找 mo 文件的路子相当玄学(后面避坑章节细说),自己解析 mo 反倒更可控,代码也就一百多行。

3. 核心模块拆解:一个 MO 语言包从落盘到界面刷新的完整链路

整条链路分三段:读文件、查表、刷界面。三段各自独立,出问题容易定位,这是我最看重的一点——多语言这种东西平时没人管,等出问题的时候往往已经交付到客户现场了,代码结构不清晰就是给自己挖坑。

3.1 MO 文件解析:一百多行读掉一个语言包

mo 的结构比想象中简单:文件头 7 个 32 位整数,后面跟两张表,一张指向原文(msgid),一张指向译文(msgstr),每项是「长度 + 偏移」。查表的时候拿 msgid 去原文表里找,找到下标再去译文表取对应项。这里用哈希表换内存,换取 O(1) 查询和代码可读性,一个语言包几千条,多占几百 KB 完全无所谓。

// MoCatalog.h #pragma once #include <unordered_map> #include <string> class CMoCatalog { public: bool Load(LPCTSTR lpszPath); void Clear() { m_map.clear(); } // pszCtxt 传 nullptr 表示无上下文,内部拼成 "ctxt\x04id" CString GetText(LPCTSTR lpszMsgId, LPCTSTR pszCtxt = nullptr) const; int Count() const { return (int)m_map.size(); } private: std::unordered_map<std::wstring, std::wstring> m_map; };

头文件刻意不引 MFC 的窗口类,只依赖CString,这样以后拆成静态库也方便。Clear()是给切换语言时用的,切之前必须清,否则旧语言的条目会残留。

// MoCatalog.cpp #include "pch.h" #include "MoCatalog.h" namespace { // mo 里的字符串不带结尾 0,必须按长度转,不能当 C 串用 CString Utf8ToCString(const char* p, UINT n) { if (n == 0 || p == nullptr) return CString(); int cch = ::MultiByteToWideChar(CP_UTF8, 0, p, (int)n, nullptr, 0); if (cch <= 0) return CString(); CString s; ::MultiByteToWideChar(CP_UTF8, 0, p, (int)n, s.GetBuffer(cch), cch); s.ReleaseBuffer(cch); return s; } // 统一按小端读,mo 标准里 magic 是 0x950412de inline UINT32 ReadU32(const BYTE* p) { return (UINT32)p[0] | ((UINT32)p[1] << 8) | ((UINT32)p[2] << 16) | ((UINT32)p[3] << 24); } } bool CMoCatalog::Load(LPCTSTR lpszPath) { CFile file; if (!file.Open(lpszPath, CFile::modeRead | CFile::shareDenyWrite)) return false; ULONGLONG ullLen = file.GetLength(); // 语言包不该超过 64M,超了多半是路径指错或者文件损坏 if (ullLen < 28 || ullLen > 64ull * 1024 * 1024) return false; std::vector<BYTE> buf((size_t)ullLen); file.Read(buf.data(), (UINT)ullLen); const BYTE* p = buf.data(); if (ReadU32(p) != 0x950412de) return false; // 字节序反的直接放弃 UINT32 nCount = ReadU32(p + 8); // 条目数 UINT32 nOrigOff = ReadU32(p + 12); // 原文表偏移 UINT32 nTransOff = ReadU32(p + 16); // 译文表偏移 m_map.clear(); m_map.reserve(nCount); for (UINT32 i = 0; i < nCount; ++i) { const BYTE* po = p + nOrigOff + i * 8; const BYTE* pt = p + nTransOff + i * 8; UINT32 nOrigLen = ReadU32(po), nOrigPos = ReadU32(po + 4); UINT32 nTransLen = ReadU32(pt), nTransPos = ReadU32(pt + 4); // 表项越界说明文件被截断,宁可整包不用也不要读出脏数据 if (nOrigPos + nOrigLen > ullLen || nTransPos + nTransLen > ullLen) return false; std::wstring wId = Utf8ToCString((const char*)p + nOrigPos, nOrigLen); std::wstring wStr = Utf8ToCString((const char*)p + nTransPos, nTransLen); if (!wId.empty()) m_map.emplace(std::move(wId), std::move(wStr)); } return !m_map.empty(); } CString CMoCatalog::GetText(LPCTSTR lpszMsgId, LPCTSTR pszCtxt) const { if (lpszMsgId == nullptr) return CString(); std::wstring wKey; if (pszCtxt != nullptr && *pszCtxt != _T('\0')) { wKey = pszCtxt; wKey += _T('\x04'); // gettext 的 EOT 分隔符,写死,别改成冒号 } wKey += lpszMsgId; auto it = m_map.find(wKey); if (it == m_map.end()) return CString(lpszMsgId); // 查不到就回落原文,不能返回空串 return CString(it->second.c_str()); }

几个参数值得单独说。nCount是条目总数,循环里按i * 8步进,因为每项固定两个 32 位整数。0x04那个分隔符不是随便定的——msgctxt 存在 mo 里时,格式就是context \x04 msgid拼成一个串,解析器不认这个规则,带上下文的词条就永远查不到。最后一个函数里「查不到返回原文」这条策略很关键:界面顶多显示英文原文,不至于出现一片空白,这在客户现场是保命设计。

3.2 语言管理器与 TR 宏:让所有文案只有一个入口

代码里绝不能出现裸的SetWindowText(_T("确定"))。所有文案必须走同一个宏,这样 xgettext 才扫得到,切换语言时也只有一条路径要管。

// LanguageMgr.h #pragma once #include "MoCatalog.h" #include <vector> #define TR(x) CLanguageMgr::Instance().Text(_T(x)) #define TRC(ctx, x) CLanguageMgr::Instance().Text(_T(x), _T(ctx)) class CLanguageMgr { public: static CLanguageMgr& Instance(); bool Load(const CString& strLang); // "zh_CN" / "en_US" / "ja_JP" CString Text(LPCTSTR id, LPCTSTR ctx = nullptr) const; CString CurrentLang() const { return m_strLang; } void Attach(CWnd* pWnd); // 窗口创建后登记 void Detach(CWnd* pWnd); void RefreshAll(); // 切语言后统一刷 private: CLanguageMgr() {} CString m_strLang = _T("zh_CN"); CMoCatalog m_catalog; std::vector<HWND> m_wnds; // 只存句柄,弱引用 };

Attach/Detach存的是 HWND 而不是 CWnd 指针,因为 MFC 的 CWnd 对象可能先于窗口销毁,存指针迟早野指针。刷新前统一IsWindow判一次就行。

bool CLanguageMgr::Load(const CString& strLang) { // 1. 拼路径:<exe目录>\i18n\<lang>\LC_MESSAGES\app.mo TCHAR szExe[MAX_PATH] = { 0 }; ::GetModuleFileName(nullptr, szExe, MAX_PATH); CString strDir = szExe; int nPos = strDir.ReverseFind(_T('\\')); if (nPos > 0) strDir = strDir.Left(nPos); CString strMo; strMo.Format(_T("%s\\i18n\\%s\\LC_MESSAGES\\app.mo"), (LPCTSTR)strDir, (LPCTSTR)strLang); // 2. 先清空旧包,再决定要不要加载新的 m_catalog.Clear(); if (strLang.CompareNoCase(_T("zh_CN")) != 0) // 简体中文直接用 msgid { if (!m_catalog.Load(strMo)) { // 加载失败只记日志,不弹框、不阻断启动 TRACE(_T("[i18n] 语言包加载失败: %s\n"), (LPCTSTR)strMo); } } m_strLang = strLang; ::WritePrivateProfileString(_T("UI"), _T("Language"), strLang, GetIniPath()); RefreshAll(); // 已登记的窗口立刻刷 return true; } CString CLanguageMgr::Text(LPCTSTR id, LPCTSTR ctx) const { if (id == nullptr || *id == _T('\0')) return CString(); CString s = m_catalog.GetText(id, ctx); s.Replace(_T("\\n"), _T("\n")); // po 里写 \n 的字面量,运行时换成真换行 return s; }

Load里那句「不弹框」是踩过坑之后的决定。以前加载失败会AfxMessageBox,结果客户机器上语言包被安全软件删了,开机就弹窗,直接把用户挡在门外。现在改成日志 + 回落原文,界面能正常进,问题回头再查。WritePrivateProfileString用的 ini 放在 exe 同目录,绿色软件不写注册表,卸载干净。

Text里那个\\n替换看着多余,其实很有用:po 是纯文本,翻译同学在 Poedit 里敲不来真正的换行,就约定写\n两个字面字符,运行时统一还原。这条约定写进翻译规范,比事后一个个改控件省事得多。

3.3 界面重绘:一次遍历把对话框、菜单、列头全部换掉

刷新是整块最麻烦的地方。核心思路是:控件在设计时的原始文本就是 msgid,切换时按 msgid 去查译文再SetWindowText。问题在于第二次切换——此时控件上的文字已经是译文了,拿它当 msgid 会一路错下去。所以必须有一张「原始文本快照表」,第一次刷新前抓一遍存起来,之后永远用它当 key。

// 全局快照:HWND -> 控件设计时的原始文本 static std::map<HWND, CString> g_originText; CString CLanguageMgr::OriginOf(CWnd* pWnd) { HWND h = pWnd->GetSafeHwnd(); auto it = g_originText.find(h); if (it != g_originText.end()) return it->second; CString s; pWnd->GetWindowText(s); g_originText.emplace(h, s); return s; } void CLanguageMgr::RelocalizeWindow(CWnd* pWnd) { if (pWnd == nullptr || pWnd->GetSafeHwnd() == nullptr) return; // 1. 窗口标题 CString strTitle = OriginOf(pWnd); if (!strTitle.IsEmpty()) pWnd->SetWindowText(Text(strTitle)); // 2. 菜单:挂在窗口上的,递归处理 if (pWnd->GetMenu() != nullptr) RelocalizeMenu(pWnd->GetMenu()); // 3. 子控件:走 GW_CHILD 链表,不依赖资源 ID for (CWnd* pChild = pWnd->GetWindow(GW_CHILD); pChild != nullptr; pChild = pChild->GetWindow(GW_HWNDNEXT)) { RelocalizeCtrl(pChild); } }

RelocalizeCtrl按窗口类名分派,只处理真正「有文字」的类,Edit、ListCtrl、TreeCtrl 这些内容由业务数据决定的控件不碰,避免把用户输入的内容也翻译了。

void CLanguageMgr::RelocalizeCtrl(CWnd* pCtrl) { TCHAR szClass[64] = { 0 }; ::GetClassName(pCtrl->GetSafeHwnd(), szClass, 64); CString strClass = szClass; if (strClass == _T("Button") || strClass == _T("Static") || strClass == _T("SysLink") || strClass == _T("ComboBox")) { CString strOrigin = OriginOf(pCtrl); if (!strOrigin.IsEmpty()) pCtrl->SetWindowText(Text(strOrigin)); } // ListCtrl 的列头不在子控件链里,得单独取 if (pCtrl->IsKindOf(RUNTIME_CLASS(CListCtrl))) { CListCtrl* pList = (CListCtrl*)pCtrl; CHeaderCtrl* pHdr = pList->GetHeaderCtrl(); if (pHdr != nullptr) { for (int i = 0; i < pHdr->GetItemCount(); ++i) { TCHAR szBuf[256] = { 0 }; HDITEM hdi = { 0 }; hdi.mask = HDI_TEXT; hdi.pszText = szBuf; hdi.cchTextMax = 256; pHdr->GetItem(i, &hdi); pHdr->SetItem(i, &hdi); // 位置和宽度保持不变,只换文本 } } } }

菜单要单独处理,因为菜单文本里混着&助记符和\tCtrl+S快捷键提示,翻译的时候只该翻前半段,后面原样保留。

void CLanguageMgr::RelocalizeMenu(CMenu* pMenu) { if (pMenu == nullptr) return; for (UINT i = 0; i < (UINT)pMenu->GetMenuItemCount(); ++i) { // 弹出菜单要先递归,MF_POPUP 的 ID 是子菜单句柄不是命令 ID CMenu* pSub = pMenu->GetSubMenu(i); if (pSub != nullptr) { RelocalizeMenu(pSub); continue; } CString strText; pMenu->GetMenuString(i, strText, MF_BYPOSITION); if (strText.IsEmpty()) continue; // 分隔线跳过 // 拆出 \t 后面的快捷键部分,翻译不碰它 CString strKey = strText, strAccel; int nTab = strKey.Find(_T('\t')); if (nTab >= 0) { strAccel = strKey.Mid(nTab); strKey = strKey.Left(nTab); } CString strNew = Text(strKey); if (!strAccel.IsEmpty()) strNew += strAccel; // 只换文本,命令 ID 和状态位沿用原来的 pMenu->ModifyMenu(i, MF_BYPOSITION | MF_STRING, pMenu->GetMenuItemID(i), strNew); } }

状态栏、工具栏提示、Tab 标签、Tooltip 这几处不在GW_CHILD链里,靠通用遍历刷不到。做法是定义一条自定义消息,RefreshAll刷完通用控件后,给每个登记过的窗口PostMessage(WM_LANGUAGE_CHANGED),各窗口在自己的消息映射里处理自家的一亩三分地:

<p> <a href="https://download.csdn.net/download/ohantanyanying/9497371" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进