资讯详情

资讯详情

MFC多语言切换实战:gettext语言包与编码避坑指南

简介面向使用 MFC 开发桌面程序的 Windows 开发者尤其是需要在 Visual Studio 2015 环境下为既有项目补齐国际化能力的中级 C 程序员这份资源围绕界面多语言切换这一常见需求给出可编译运行的完整示例工程。压缩包共 28 个文件、约 1.18MB以 h/cpp 源码与 rc/rc2 资源脚本为主体另含 en.po、zh.po 翻译文件及对应的 mo 二进制语言包以及 LanguageLib 动态库与头文件、工程文件、ReadMe 说明和已编译的 exe、pdb 等调试产物可直接打开对照调试。示例演示了按语言拆分资源、借助 POEdit 维护 Gettext 词条、通过资源句柄切换与字符串加载更新界面的完整链路也覆盖了不同语言文本长度差异下的界面适配与部署时语言包随程序分发等易错点便于读者在自己的项目中复用这套结构。已有 1384 人学习下载适合希望少走弯路、快速搭建多语言框架的开发者参考。1. 一个只写中文的 MFC 工程换语言那天才开始疼去年接手一个内部工具二十几个对话框控件上的文字全是SetWindowText(_T(确定))这种写法散在四五个.cpp里。某天需求方说要一份英文界面团队第一反应是复制一份工程改字符串结果两周后两边代码分叉改一个 bug 要改两遍谁都不敢合。最后还是回头做 MFC 多语言切换把界面文字从代码里抽出来走poedit翻译、msgfmt编成mo语言包运行时按语言标签加载。如果你也在给 MFC 工程补多语言环境大概率会踩这几个坑字符集不是 Unicode、.rc文件编码被编译器按本地代码页解析、mo里的 key 和源码里的字符串差一个空格就查不到。这套环境不难装难在每一步都有隐式的编码约定错一个就是满屏问号。下面按我实际落地的顺序写一遍。2. 环境清单与版本选型VS2019、MFC 与 gettext 工具链怎么配齐2.1 先把版本钉死v142 Unicode gettext 0.21 Poedit 3.x多语言这套东西版本选错不会立刻报错而是在翻译文件来回转换时悄悄毁掉编码。我一般按下面这张表配能用且互相不打架。组件版本用途什么时候装Visual Studio 201916.11工具集 v142编译 MFC 工程第一步MFC 和 ATL 支持随 VS 安装器勾选不然#include afxwin.h直接找不到第一步Windows 10 SDK10.0.19041rc.exe、spyxx.exe都在这第一步gettext0.21 及以上msgfmt、xgettext、msgmerge命令行工具第二步Poedit3.x只做翻译不参与编译第三步给翻译的人装关于 VS 版本不是 2022 不能用而是老工程从 v142 重定向到 v143 之后第三方静态库要一起重编我在一个带旧版报表控件的项目上被这件事拖过一整天。如果工程里已经有别的依赖先把工具集钉在 v142多语言跑通了再考虑升级。关于 gettext 版本0.19 及更早的 Windows 包在字符集转换上比较将就msgfmt处理 UTF-8 中文时偶发截断我遇到过翻译文件里最后一条被吃掉半个字的情况。0.21 之后这条线稳了直接用新版本别为省事去翻老压缩包。还有一个绕不开的取舍要不要把 libintl 运行时 DLL 一起带上。正统做法是链接 gettext 的运行时库用bindtextdomain、textdomain、gettext那一套。我最后没这么做而是在程序里自己写了四十行.mo解析器——理由很实在少一个 DLL就少一类「我这能跑他那崩」的玄学问题尤其当发布包里还夹着别的第三方库时。2.2 装 gettext一条 pacman 命令别去手动解压Windows 上装 gettext 最省事的路径是 MSYS2。装完只取bin目录下的三个 exe 用不需要它整套环境。# 在 MSYS2 的 MINGW64 终端里执行--needed 避免重复装 pacman -S --needed mingw-w64-x86_64-gettext # 编译期和生成后事件里都要调 msgfmt所以必须进系统 PATH # 把 D:\msys64\mingw64\bin 加进环境变量不是临时 export 就完事 export PATH/mingw64/bin:$PATH # 三个工具都能打印版本才算装对了 msgfmt --version xgettext --version msgmerge --versionpacman装的是完整包msgfmt.exe、xgettext.exe、msgmerge.exe、msgcat.exe全在里面。这里有个细节MSYS2 的bin目录下还有一大堆 DLL不要整个目录拷进工程只把 exe 所在目录加进PATH就够了拷 DLL 反而会和工程里的其他运行库抢名字。用 vcpkg 的团队也可以走另一条路# tools 特性才会装出 msgfmt.exe 这类命令行工具不加就只能拿到运行库 vcpkg install gettext[tools]:x64-windows装完记得把vcpkg\installed\x64-windows\tools\gettext加进PATH。vcpkg 的好处是版本跟着vcpkg.json走CI 机器上能复现坏处是首次编译要等。2.3 目录和编码约定动手前先把规矩立下来多语言翻车八成不是代码写错是编码约定没统一。我一般先在工程根目录建这么一套结构后面所有脚本都对着它写死路径。路径放什么编码要求src/.cpp.hUTF-8带 BOMres/app.rc资源脚本UTF-16 LElang/messages.pot提取出来的模板UTF-8 无 BOMlang/zh_CN.po中文翻译UTF-8 无 BOMlang/en_US.po英文翻译UTF-8 无 BOMbin/lang/zh_CN/app.mo编译产物随程序发布二进制两个必须记住的点。第一MSVC 只有看到 BOM 才会把源文件当 UTF-8 处理没有 BOM 就按系统代码页走中文注释和字符串会一起坏掉。第二.rc不要用 UTF-8 存——rc.exe对无 BOM 的 UTF-8 支持很差直接按本地代码页解析中文资源全变问号。这不是可选项是硬约束。提示.po文件里如果出现 BOMmsgfmt不报错但第一条 msgid 会带上不可见字符运行时永远查不到。用编辑器另存时确认是「UTF-8 无 BOM」。3. 工程改造把散落的字符串收进字符串表和 .po3.1 属性页里三处必改漏一处后面全白干新建或改造工程先动属性页别急着写代码。右键工程 → 属性按配置逐个改Debug/Release、Win32/x64 四个组合都要改一遍这里最容易只改了一个。// 配置属性 → 高级 → 字符集改成「使用 Unicode 字符集」 // 改完之后 _T(确定) 展开成 L确定所有 CString 操作才是宽字符 // 配置属性 → C/C → 语言 → C 语言标准ISO C17 // 不是为了新语法是为了 std::filesystem 和 ifstream 的宽路径重载 // 配置属性 → C/C → 预处理器加两个宏 // _UNICODE;UNICODE;字符集这一项如果留在 MBCS程序里所有MultiByteToWideChar的入参都会变成CP_ACP而.mo里的内容是 UTF-8转出来必然是乱码。这个问题不会在编译期暴露只在运行时显示成「锟斤拷」排查起来像黑匣子。改完字符集之后全工程搜一遍确定这类不带_T()的字面量统一改成_(确定)。这个宏定义在下面这个头文件里// Lang.h —— 宏要既能接窄字符串又能直接塞进 CString #pragma once // 统一入口源码里一律写 _(...)参数是 UTF-8 窄字符串字面量 // 返回时查字典查不到就原样返回保证没有翻译也能跑 #define _(s) CLang::Instance().T(s) // MFC 的 SetWindowTextW 吃宽字符T() 里负责 UTF-8 到 UTF-16 的转换 inline void SetText(CWnd* pWnd, int nID, const char* szKey) { if (pWnd) pWnd-SetDlgItemTextW(nID, _(szKey)); }用宏而不是函数是为了让翻译 key 在源码里保持字面量形式——xgettext是静态扫描的它只认源码里能看见的字符串走变量就提取不到。这一点决定了后面整个提取流程能否自动化。3.2 资源 DLL 还是 .mo两条路的分界线这里得说清楚取舍因为很多人一上来就把两种方案混着用最后两边都不好维护。对比项资源 DLL每语言一份.po/.mo 字典管什么对话框模板、菜单、图标、位图纯文本提示、状态栏、日志、拼接句改文案要不要重编要重新编译资源 DLL不用重编.mo覆盖即可能不能外包翻译难翻译的人得开 VS能给一个.po就行运行时切换成本高AfxSetResourceHandle后要重建窗口低改字典再刷控件文本适合场景布局随语言变化的界面德语长词把按钮撑破布局固定、只换文字我实际项目的做法是混合菜单和对话框模板留在资源里按语言拆 DLL所有运行时拼出来的文本、AfxMessageBox的提示、状态栏走.mo。这样翻译的人只需要碰.po布局问题单独处理职责清楚。如果走资源 DLL 这条路切换的核心就三行// 切到英文资源 DLLhRes 要全局存住切换过程中不能 Free HINSTANCE hRes ::LoadLibraryExW(Lbin\\lang\\en_US\\app_res.dll, nullptr, LOAD_LIBRARY_AS_DATAFILE); if (hRes ! nullptr) { HINSTANCE hOld AfxGetResourceHandle(); AfxSetResourceHandle(hRes); // 之后新建的窗口都用新资源 // hOld 不要立刻 Free正在运行的窗口还引用着它 }LOAD_LIBRARY_AS_DATAFILE这个标志很关键它让 DLL 以数据文件方式映射不执行DllMain也就不会有 CRT 冲突。资源 DLL 里本来就没有代码加载方式选错纯属给自己找麻烦。3.3 用 xgettext 扫出 pot 模板再补 .rc 里的字符串表模板文件.pot是所有语言的源头它由源码扫描生成不要手写。# --keyword_ 告诉 xgettext 把 _(...) 的内容当待翻译文本 # --from-codeUTF-8 和源文件带 BOM 的编码对上 # --add-comments 把紧邻的 // TRANSLATORS: 注释带进 po翻译的人才知道上下文 xgettext \ --languageC \ --keyword_ \ --from-codeUTF-8 \ --add-commentsTRANSLATORS: \ --sort-by-file \ --outputlang/messages.pot \ src/*.cpp src/*.h--sort-by-file让 pot 里的条目按文件顺序排翻译的人对着代码看更顺不加就按字母序几百条的时候很难找。--add-comments需要源码里写// TRANSLATORS: 这个按钮用于导出报表注释要写在_()的上一行中间不能空行。.rc里的STRINGTABLE不在xgettext的扫描范围得单独抽。我写了个小脚本放在tools/下每次改完资源跑一遍# tools/extract_rc_strings.py # 只处理 STRINGTABLE ... END 之间的段落DIALOG 段里的字符串另算 import re, sys, io rc_path, out_path sys.argv[1], sys.argv[2] # .rc 是 UTF-16 LE 保存的直接按 utf-16 读别用默认编码 lines io.open(rc_path, encodingutf-16).read().splitlines() in_table False with io.open(out_path, w, encodingutf-8) as out: for line in lines: s line.strip() if s.upper().startswith(STRINGTABLE): in_table True continue if in_table and s.upper() END: in_table False continue if not in_table: continue # 匹配 IDS_XXX 文本rc 里的引号转义是两个连续双引号 m re.match(r([A-Za-z_]\w*|\d)\s(.*)\s*$, s) if m: key m.group(2).replace(, \\) out.write(_(%s); // %s\n % (key, m.group(1)))脚本输出一个全是_(...)的.h把它加进xgettext的输入列表再扫一次资源里的文案就并进同一个 pot 了。这样做的好处是翻译只需维护一份文件不用在 VS 和 poedit 之间来回切换。注意STRINGTABLE里同一个 ID 在不同LANGUAGE块下可能出现多次多语言 rc 的写法脚本会全部抽出来变成重复条目。老工程如果用了LANGUAGE块先把多余的块删掉只留一份否则 po 里全是重复 keymsgfmt会报错。4. 语言包制作与运行时加载poedit 出 .mo程序里读进内存4.1 poedit 里翻译文件头别动占位符更别动拿到messages.pot之后用 poedit「从 POT 文件新建翻译」语言选中文中国保存成lang/zh_CN.po。打开后先看文件头这几行是msgfmt的校验依据别让翻译的人手改头部字段应该是什么改错的后果Content-Typetext/plain; charsetUTF-8改成 GBK 后中文全坏Languagezh_CN影响系统语言匹配的兜底逻辑Plural-Forms中文填nplurals1; plural0;复数形式判断错英文要nplurals2; plural(n ! 1);X-Generator随便无影响翻译时最容易出事的是带占位符的句子。共 %d 个文件翻成英文时如果手滑写成%d files total没问题但要是把%d改成%s或者把%1$s的顺序调了运行期就是崩溃而不是显示错误。poedit 会做基础检查但真正严格的校验得靠msgfmt --check-format。我的习惯是包含%的条目翻译完立刻跑一遍编译校验不要等全部翻完再一起编否则报错定位起来很痛苦。4.2 msgfmt 编译 .mo 并做双检.po是人看的程序读的是.mo。这两步之间必须过一遍msgfmt不能省。# -c 等价于打开 --check-format / --check-header / --check-domain 全套校验 # 翻译把 %s 写丢、或者头部字段缺失这一步就报错不会留到运行期 msgfmt -c -o bin/lang/zh_CN/app.mo lang/zh_CN.po # 只看翻译进度不生成文件汇报「已翻译/模糊/未翻译」条数 msgfmt --statistics -o /dev/null lang/zh_CN.po # 已经翻过一轮之后源码加了新字符串用 msgmerge 合并而不是重新提取 msgmerge --update --backupnone lang/zh_CN.po lang/messages.potmsgmerge那一步很关键。如果每次改代码都重新生成.po之前翻好的内容全丢。--backupnone是防止目录里堆一堆.po~备份文件出问题靠版本管理回滚就够了。有一类条目msgfmt会标成 fuzzy模糊通常是源码里那句原文改了标点。模糊条目在.mo里不会被编译进去运行时直接落回原文。这个行为很多人不知道看到「明明翻译了却没生效」八成就是条目还挂着 fuzzy 标记在 poedit 里点一下确认就行。4.3 四十行代码把 .mo 读成字典.mo是二进制格式结构很简单一个 20 字节的头后面跟着原文表和译文表每条表项是「长度 偏移」。自己解析一遍比链接运行库更可控。// MoDict.h —— 只做一件事把 .mo 读成 std::mapstd::string, std::wstring #pragma once #include map #include string class CMoDict { public: bool Load(const std::wstring strPath); // 失败返回 false不动现有字典 const std::wstring* Find(const std::string strKey) const; size_t Count() const { return m_map.size(); } void Clear() { m_map.clear(); } private: std::mapstd::string, std::wstring m_map; // key 是 UTF-8 原文值是宽字符译文 };// MoDict.cpp #include pch.h #include MoDict.h #include windows.h #include fstream #include vector #include cstring #pragma pack(push, 1) struct MoHeader { unsigned int magic; // 0x950412de读到别的值说明文件坏了或根本不是 mo unsigned int revision; // 保留字段一般为 0 unsigned int count; // 词条数 unsigned int origOffset; // 原文表起始偏移 unsigned int transOffset; // 译文表起始偏移 }; #pragma pack(pop) static bool ReadEntry(const std::vectorchar buf, unsigned int nTable, unsigned int nIndex, std::string strOut) { unsigned int nLen 0, nOff 0; // 每条表项固定 8 字节4 字节长度 4 字节偏移小端 memcpy(nLen, buf[nTable nIndex * 8], 4); memcpy(nOff, buf[nTable nIndex * 8 4], 4); if (nOff nLen buf.size()) return false; // 越界直接丢弃不让坏文件拖崩进程 strOut.assign(buf[nOff], nLen); return true; } static std::wstring Utf8ToWide(const std::string strIn) { if (strIn.empty()) return std::wstring(); int n ::MultiByteToWideChar(CP_UTF8, 0, strIn.c_str(), (int)strIn.size(), nullptr, 0); std::wstring strOut(n, L\0); ::MultiByteToWideChar(CP_UTF8, 0, strIn.c_str(), (int)strIn.size(), strOut[0], n); return strOut; } bool CMoDict::Load(const std::wstring strPath) { // MSVC 下 ifstream 支持宽字符路径的扩展重载别用 fopen中文路径会失败 std::ifstream in(strPath.c_str(), std::ios::binary); if (!in) return false; std::vectorchar buf((std::istreambuf_iteratorchar(in)), std::istreambuf_iteratorchar()); if (buf.size() sizeof(MoHeader)) return false; MoHeader h{}; memcpy(h, buf.data(), sizeof(h)); if (h.magic ! 0x950412deu) return false; std::mapstd::string, std::wstring mapTmp; for (unsigned int i 0; i h.count; i) { std::string strKey, strVal; if (!ReadEntry(buf, h.origOffset, i, strKey)) continue; if (!ReadEntry(buf, h.transOffset, i, strVal)) continue; if (strKey.empty() || strVal.empty()) continue; // 空译文视为未翻译落回原文 mapTmp[strKey] Utf8ToWide(strVal); } if (mapTmp.empty()) return false; // 空字典当失败处理避免切完语言界面全空 m_map.swap(mapTmp); return true; }几个参数的取舍说明。magic ! 0x950412deu这道判断别省文件被半截写入或者误传了.po进来靠它第一时间挡住。strVal.empty()跳过空译文是因为 gettext 的语义就是「空串表示未翻译」不跳过会出现界面上某几个字变成空白。最后的m_map.swap(mapTmp)而不是直接赋值是为了让「加载失败保留旧字典」这个行为成立——切换语言失败时用户还能看到一个完整界面而不是一片空白。4.4 切换时不重启字典、控件文本、菜单的刷新顺序字典换好了只是第一步界面上已经创建出来的控件不会自己变。切换流程我固定成这个顺序少一步都会出现「半中半英」。// Lang.cpp bool CLang::SwitchTo(const wchar_t* szTag) // 例如 Lzh_CN / Len_US { CWaitCursor wait; // 1. 先建新字典成功才替换路径统一走 bin\lang\tag\app.mo CMoDict dictNew; if (!dictNew.Load(MakeMoPath(szTag))) return false; m_dict.Swap(dictNew); // 交换旧字典随 dictNew 析构 m_strTag szTag; // 2. 通知所有已创建的窗口重设文本自定义消息比遍历控件可控 for (CWnd* pWnd : m_listWnds) { if (::IsWindow(pWnd-GetSafeHwnd())) pWnd-SendMessage(WM_LANG_CHANGED, 0, 0); } // 3. 顶层窗口的菜单单独重建菜单项不算子控件 RebuildMenus(); // 4. 落盘下次启动直接读注册表或 ini ::WritePrivateProfileStringW(LUI, LLang, szTag, m_strIniPath.c_str()); return true; }三个要点。第一WM_LANG_CHANGED用自定义消息WM_USER 100每个对话框在OnInitDialog里注册自己的控件到m_listWnds收到消息后统一执行一遍SetDlgItemTextW而不是在切换函数里硬编码控件 ID。第二菜单必须SetMenu(nullptr)后重建直接ModifyMenu在多级子菜单下会漏。第三如果用了资源 DLL上面第 2 步要改成DestroyWindow再重建因为对话框模板的尺寸和布局都在资源里改文本没用。注意切换过程中不要在遍历m_listWnds的循环里做DestroyWindow容易把迭代器搞坏。要销毁窗口就先把句柄收集到一个临时std::vector循环结束后统一处理。5. 避坑与排查五条装坏了才明白的记录5.1 现象编译期rc.exe报 RC2135或者中文资源变问号原因.rc文件被存成了 UTF-8 无 BOM。rc.exe不认这种编码直接按系统本地代码页解析中文全乱。解决在 VS 里用「文件 → 高级保存选项」把.rc存成 UTF-16 LE。如果暂时改不动可以在.rc顶部加#pragma code_page(936)救急但这只对简体中文环境有效换到英文系统上照样坏别当长期方案。5.2 现象翻译全做了程序里还是显示原文一条都没命中原因msgid和源码里的字符串不完全一致。常见的是末尾多了个空格、全角括号和半角括号混用、\r\n和\n对不上。msgfmt不管这个字典里就是两个不同的 key。解决写个自检函数把.po里所有msgid抽出来逐条查字典未命中的用OutputDebugStringW打出来。这个开关放在 Debug 配置下每次改完翻译跑一遍比肉眼比对几百条快得多。另外禁止手工编辑.po的msgid行只允许改msgstr。5.3 现象中文显示成???或者「锟斤拷」原因MultiByteToWideChar第一个参数写了CP_ACP。系统区域是英文时CP_ACP就是 1252UTF-8 的字节被当单字节解析出来一堆问号。解决全工程搜CP_ACP改成CP_UTF8。顺手检查工程字符集是不是还在 MBCS——如果是CString本身就是窄字符版本再怎么转都不对必须先切 Unicode。5.4 现象切换语言后主窗口变了点开某个对话框还是旧语言原因那个对话框之前已经被DoModal创建过窗口句柄还在缓存里切语言只是换了字典模板不会重建。解决在对话框类里重写OnInitDialog时统一调一次RefreshText()同时把该窗口注册进m_listWnds。切换时收到WM_LANG_CHANGED就执行RefreshText()。如果走资源 DLL 方案没得商量必须DestroyWindow后重新DoModal。5.5 现象开发机好好的拷到测试机一启动就崩原因Debug 和 Release 混用了运行库。工程用的是/MD动态链接 CRT但某个第三方库是/MT编的两套 CRT 在一个进程里各管各的堆跨模块释放内存就崩。这类问题在加了语言包加载之后更容易触发因为多了一次内存分配和释放。解决全工程包括所有第三方静态库统一/MD。查法是在 VS 的「项目 → 属性 → C/C → 代码生成 → 运行时库」逐个配置看一遍四个组合都确认。我最后干脆把 gettext 运行时库的依赖整个去掉、自己解析.mo很大程度就是为了消灭这一类血泪经验。6. 验证跑通三条命令加一个自检开关装完不等于跑通我一般按下面这张表逐项过全部打勾才算这套多语言环境能交付。验证项做法期望结果翻译文件合法msgfmt -c -o /dev/null lang/zh_CN.po零错误零警告退出码 0无模糊条目msgfmt --statistics -o /dev/null lang/zh_CN.pofuzzy 那栏是 0字典能加载程序启动时打印m_dict.Count()数量与 po 条目数一致差值来自空译文key 全部命中Debug 下遍历自检未命中打到调试输出输出窗口里没有[MISS]行切换后界面完整中文切英文再切回来逐个对话框点开没有残留中文也没有控件文字变空重启后记住语言关闭程序再启动语言与上次选择一致自检那段代码很短但收益最大// 只在 Debug 配置下编译Release 里一行不剩 #ifdef _DEBUG void CLang::VerifyAllKeys(const std::vectorstd::string vecKeys) { for (const auto key : vecKeys) { if (m_dict.Find(key) nullptr) { // 未命中的 key 直接打到调试输出格式固定方便筛选 CString strMsg; strMsg.Format(L[MISS] %S\n, key.c_str()); ::OutputDebugStringW(strMsg); } } } #endifvecKeys从哪来在生成后事件里跑一次msgcat --stringtable把 po 里的 msgid 导成文本程序启动时读进来。听起来绕但比人眼扫几百条可靠得多。还有个小技巧切换语言时截两张图对比用Spy的查找窗口功能点一下控件看WindowText到底是什么。有些控件比如CListCtrl的列头SetWindowText改不动必须走SetColumn截图对比能一眼看出来是哪一类控件漏了。从那以后我每次给 MFC 工程接多语言都强制先把.rc编码、源文件 BOM、工程字符集这三样确认一遍再动代码因为这三处出问题不报错、只出错排查成本远高于预防成本。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →