简介:这份资源面向在 Visual Studio 2015 环境下做 Windows 桌面开发的 MFC 程序员,聚焦界面多语言切换这一常见需求,提供可编译运行的多语言切换示例工程。内容围绕 .rc 资源脚本按语言拆分、用 POEdit 编辑 po 翻译文件并编译成 mo 语言包、通过切换资源句柄加载对应语言资源这条主线,演示从字符串抽取、翻译维护到界面控件文本更新的完整流程,适合已具备 MFC 基础、希望为产品补充国际化能力的中级开发者参考。压缩包共 28 个文件,约 1.18MB,包含 6 个 h 与 4 个 cpp 源码、1 个 rc 资源脚本及 rc2、ico 等界面素材,2 个 po 与 2 个 mo 语言包文件,还有 sln、vcxproj 等工程配置,以及编译产物 exe、dll、lib 和调试用的 pdb、ilk 文件,可直接打开 sln 对照源码调试。资源已有 1384 人学习下载,示例工程与语言包配套齐全,便于在实践中体会资源管理与语言切换的落地方式。
1. MFC 多语言切换:别急着上资源 DLL,先把字符串生命周期捋清
一个 MFC 老项目要出海,最先冒出来的问题往往不是业务逻辑,而是界面上那些写死的中文:菜单、对话框、消息框、状态栏,一处改漏就露馅。MFC 多语言切换这件事,资源 DLL 能做,poedit 维护 mo 语言包也能做,差别不在能不能跑,而在翻译交付和后期维护的成本。下面按一个可落地的 po/mo 方案拆:从 .rc 抽字符串、用 Poedit 编辑、编译 mo、在 MFC 里加载并刷新界面,最后把切换时最常见的翻车点逐条排掉。适合手里有 MFC 存量代码、又不想把整套资源体系推倒重来的开发者。
2. 路线选型:资源 DLL 与 po/mo 的四个分水岭
MFC 多语言切换的讨论很容易滑向“资源 DLL 才是正统”或者“gettext 才是标准”,这两种说法都只对了一半。真正要判断的是:你的界面文本是跟着资源走,还是跟着翻译工作流走。MFC 的资源 DLL 方案把每种语言的菜单、对话框、字符串表分别编译进一个 DLL,运行时用LoadLibrary加载,再用AfxSetResourceHandle切资源句柄。这套机制在 MFC 里很成熟,适合语言少、每种语言布局差异大、UI 不要求热切换的项目。po/mo 方案则把文本抽到.po文件,翻译人员用 Poedit 编辑,发布前编译成.mo,程序运行时按当前语言加载对应语言包。它更适合语言多、翻译交付频繁、界面布局基本一致的场景。
2.1 MFC 多语言的两条主流路线
资源 DLL 的核心是“资源隔离”。主程序只保留一套默认资源,其他语言把.rc编译成纯资源 DLL。启动时根据配置加载对应 DLL,AfxSetResourceHandle之后,LoadString、DialogBox、LoadMenu都会从新句柄取资源。它的优势是布局可以按语言单独调,德语长按钮、俄语长菜单都能在资源编辑器里直接改;缺点是翻译人员要会开 Visual Studio,或者由开发反复导出导入 Excel,合并冲突基本靠人肉,语言一多就是体力活。
po/mo 的核心是“文本与资源解耦”。界面控件先按一种源语言摆好,文本通过msgid到msgstr的映射翻译。Poedit 直接打开.po,翻译记忆、术语表、模糊匹配都能用,.po是纯文本,版本管理里能看 diff。运行时程序读取.mo做查表,切换语言只需要换一个.mo并刷新界面。缺点是对话框布局只有一套,长文本容易截断,必须配合动态布局或预留空间;另外 MFC 的.rc不是 gettext 的原生输入,需要自己写抽取脚本。
| 维度 | 资源 DLL | po/mo 语言包 |
|---|---|---|
| 翻译载体 | .rc资源 | .po文本 |
| 编辑工具 | Visual Studio 资源编辑器 / Excel | Poedit |
| 切换时机 | 通常重建窗口或重启 | 可运行时刷新 |
| 布局调整 | 每种语言单独资源,灵活 | 一套布局,需动态适应 |
| 合并冲突 | 二进制资源难合并 | 文本易合并 |
| 适合场景 | 语言少、布局差异大 | 语言多、迭代快 |
这张表不是让你二选一就完事。实际项目里常见的做法是混合:菜单和对话框框架仍然用资源 DLL 保证布局,字符串表、消息框、动态生成的文本走 po/mo。这样既不让翻译人员碰资源编辑器,也不让开发为了一个长单词重排整个对话框。
2.2 为什么这个场景选 po/mo
如果项目里的界面文本大量集中在STRINGTABLE、CString拼接、AfxMessageBox和控件SetWindowText上,po/mo 的收益会很明显。翻译人员拿到.pot模板,用 Poedit 填完目标语言,开发只需要跑一条msgfmt命令生成.mo,不用重新编译整个工程。语言包可以独立替换,测试同学也能自己切语言验证。
但有一个前提:字符串的msgid必须稳定。很多团队偷懒,直接把中文原文当msgid,今天写“保存”,明天改成“保存文件”,翻译就丢了。更稳的做法是给字符串表用资源 ID 做 key,对话框控件文本用“原始文本 + 控件 ID”做上下文。gettext 本身支持msgctxt,格式是msgctxt "DIALOG_ID|CONTROL_ID"加msgid "原始文本",这样同一个“确定”在不同对话框里可以翻成不同语气,也不会因为改了一个字就全量失效。
我一般会先定三条约定:第一,源语言统一用中文,msgid保留中文原文,方便对照;第二,字符串表额外加msgctxt "STR|ID",避免不同 ID 同文本冲突;第三,所有动态拼接的句子不允许把变量塞进msgid,而是拆成“模板 + 占位符”,翻译时保留占位符。这三条看起来琐碎,后面能省掉大量“翻译对了但界面不对”的排查。
2.3 工程骨架与字符串 ID 约定
先给工程加一个独立的lang目录和tools目录,不要把.po文件散落在res里。语言包按 locale 命名,比如en_US.po、ja_JP.po,编译产物同名.mo。程序启动时读配置决定加载哪个。配置可以放 ini,也可以放注册表,这里用 ini 方便调试。
[I18n] Language=en_US Fallback=zh_CN MoDir=.\langLanguage是当前语言,Fallback是回退语言,MoDir是.mo所在目录。回退逻辑很重要:如果某个msgid在目标语言里没有翻译,不能显示空白,应该回退到源语言。MoDir用相对路径时要注意工作目录,MFC 程序从快捷方式启动和从 IDE 启动的当前目录可能不同,常见做法是用GetModuleFileName取 exe 路径再拼lang。
目录结构建议这样组织:
| 路径 | 作用 |
|---|---|
src/ | MFC 业务代码 |
res/MyApp.rc | 默认语言资源 |
lang/zh_CN.pot | 抽取出来的翻译模板 |
lang/en_US.po | 英文翻译源文件 |
lang/en_US.mo | 运行时加载的二进制语言包 |
tools/rc2pot.py | 从.rc抽字符串的脚本 |
include/MoCatalog.h | mo 解析器接口 |
include/I18n.h | 翻译函数与宏 |
字符串 ID 的约定要写进开发规范,不然半年后没人记得。资源 ID 用IDS_前缀,对话框控件 ID 用IDC_前缀,代码里动态文本用I18N("...")包裹。不要出现CString s = _T("中文");这种裸字符串,除非它永远不翻译。代码评审时看到裸中文就标出来,比事后扫描省事。
3. 从 .rc 到 .po:抽取、Poedit 编辑与 mo 编译的完整链路
MFC 的.rc是资源脚本,不是 gettext 的输入格式。直接拿xgettext扫.cpp只能抓到代码里的字符串,抓不到STRINGTABLE和对话框里的静态文本。常见做法是写一个 Python 脚本,把.rc里的STRINGTABLE、CAPTION、LTEXT、PUSHBUTTON等文本抽出来,生成.pot模板,再交给 Poedit。脚本不需要解析整个 RC 语法,但要把编码、注释、重复项处理好,否则后面翻译到一半发现乱码或者条目重复,返工很烦。
3.1 资源脚本里哪些文本需要进 po
不是所有.rc文本都值得抽。STRINGTABLE里的字符串、对话框的CAPTION、静态文本LTEXT/RTEXT/CTEXT、按钮PUSHBUTTON/DEFPUSHBUTTON、复选框CHECKBOX、单选按钮RADIOBUTTON,这些是用户可见的,必须进。菜单资源MENUITEM的文本也要,但菜单项通常和命令 ID 绑定,抽取时要保留 ID 做上下文。工具栏提示、状态栏提示、MESSAGETABLE里的消息文本,视项目情况决定;如果消息框文案很多,建议一并抽。
不需要进 po 的包括:控件 ID、资源 ID、字体名、图标路径、快捷键表、#include行、LANGUAGE语句。脚本里用正则粗筛时容易把IDC_STATIC这种 ID 也抓进去,生成一堆无意义条目。我的做法是只匹配“关键字后面跟一个带引号的字符串”的模式,并且对STRINGTABLE块单独处理,因为它的格式是ID "文本",和对话框行不一样。
还有一个坑:Visual Studio 保存.rc时可能是 UTF-16 LE 带 BOM,也可能是 UTF-8 带 BOM,取决于项目设置和资源编辑器的行为。Python 读文件时不能直接open(..., encoding='utf-8'),否则遇到 UTF-16 会读出乱码。先读二进制判断 BOM,再决定解码方式。这个细节不处理,抽出来的.pot里全是\x00夹杂的字符,Poedit 打开就报错。
3.2 用脚本把 STRINGTABLE 和对话框文本抽成 pot
下面这个脚本是够用版本,核心是读.rc、去注释、抓STRINGTABLE和常见对话框文本、生成 UTF-8 的.pot。它不处理MENUITEM和MESSAGETABLE,需要的话按同样思路加正则。脚本里保留了资源 ID 做注释,方便排查条目来源。
# tools/rc2pot.py import re import pathlib import sys def read_rc(path: pathlib.Path) -> str: raw = path.read_bytes() if raw.startswith(b'\xff\xfe') or raw.startswith(b'\xfe\xff'): return raw.decode('utf-16') return raw.decode('utf-8-sig') def unescape_rc(s: str) -> str: return (s.replace('\\"', '"') .replace('\\n', '\n') .replace('\\t', '\t') .replace('\\\\', '\\')) def collect_stringtable(text: str): # 去掉行注释,简化处理;字符串里出现 // 的情况很少,先不特殊照顾 text = re.sub(r'//[^\n]*', '', text) pattern = re.compile(r'STRINGTABLE\b.*?BEGIN(.*?)END', re.S | re.I) for block in pattern.finditer(text): body = block.group(1) # 匹配 ID "文本",字符串内用 "" 表示一个引号 for m in re.finditer(r'([A-Za-z_]\w*|\d+)\s+"((?:[^"]|"")*)"', body): sid = m.group(1) s = unescape_rc(m.group(2).replace('""', '"')) if s.strip(): yield f'STR|{sid}', s def collect_dialog_text(text: str): # 只抓最常见的对话框文本关键字 keywords = r'(CAPTION|LTEXT|RTEXT|CTEXT|PUSHBUTTON|DEFPUSHBUTTON|CHECKBOX|RADIOBUTTON)' for m in re.finditer(keywords + r'\s+(?:[^"\n]*?)"((?:[^"]|"")*)"', text, re.I): kind = m.group(1).upper() s = unescape_rc(m.group(2).replace('""', '"')) if s.strip(): yield f'DLG|{kind}', s def pot_escape(s: str) -> str: return s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') def main(rc_path: str, pot_path: str): text = read_rc(pathlib.Path(rc_path)) seen = set() items = [] for ctx, s in collect_stringtable(text): key = (ctx, s) if key not in seen: seen.add(key) items.append((ctx, s)) for ctx, s in collect_dialog_text(text): key = (ctx, s) if key not in seen: seen.add(key) items.append((ctx, s)) with open(pot_path, 'w', encoding='utf-8', newline='\n') as f: f.write('msgid ""\nmsgstr ""\n') f.write('"Content-Type: text/plain; charset=UTF-8\\n"\n\n') for ctx, s in items: f.write(f'#: {ctx}\n') f.write(f'msgctxt "{pot_escape(ctx)}"\n') f.write(f'msgid "{pot_escape(s)}"\n') f.write('msgstr ""\n\n') print(f'write {len(items)} entries to {pot_path}') if __name__ == '__main__': main(sys.argv[1], sys.argv[2])运行方式:
python tools/rc2pot.py res/MyApp.rc lang/zh_CN.pot第一个参数是.rc路径,第二个参数是输出的.pot路径。脚本先判断 BOM,再解码,避免 UTF-16 资源脚本读出乱码。collect_stringtable只处理STRINGTABLE块,collect_dialog_text用关键字匹配对话框里的文本行。msgctxt里放了STR|ID或DLG|类型,这样同一个中文词在不同上下文里可以有不同的翻译。去重用的是(上下文, 文本)组合,不是单纯按文本去重,避免“确定”在多个对话框里被合并成一条。
这个脚本有意做了简化:它不解析MENUITEM,不处理多行字符串拼接,也不处理#ifdef条件编译。复杂项目里.rc可能有几十个#ifdef分支,正则很容易抓错。我的做法是先用资源编译器的预处理输出,或者让脚本只处理固定目录下的.rc,条件分支里的文本手工维护一份 CSV 再合并。不要指望一个正则脚本吃下所有资源脚本,那是给自己挖坑。
3.3 Poedit 编辑与 msgfmt 编译 mo
Poedit 打开zh_CN.pot后,先“另存为”目标语言文件,比如en_US.po,然后在“项目信息”里把目标语言设成en_US,字符集设成 UTF-8。Poedit 会保留msgctxt,翻译时左侧能看到上下文注释,不会把“保存”和“保存文件”搞混。翻译记忆和术语表建议在项目开始时建好,后面语言多了能复用。
翻译完成后用msgfmt编译。msgfmt是 gettext 工具链里的命令,Windows 下可以单独装,也可以随 Poedit 一起带。编译命令:
msgfmt -c -v -o lang/en_US.mo lang/en_US.po-c做格式检查,占位符数量不一致、msgstr为空但标了 fuzzy、msgctxt写错都会报出来;-v输出详细信息;-o指定输出文件。如果报found 1 fatal error,先看它提示的行号,常见原因是msgstr里的%s数量和msgid不一致,或者引号没转义。msgfmt不通过就不要往程序里塞,否则运行时查表查不到,界面会回退成中文,测试还以为语言包没加载。
发布前再看一眼统计:
msgfmt --statistics -o /dev/null lang/en_US.po它会输出已翻译、模糊、未翻译的条目数。模糊条目默认不会生效,Poedit 里标了 fuzzy 的翻译需要人工确认后取消标记。未翻译条目在程序里会回退到msgid,也就是中文原文,界面上会中英混杂。这个统计命令适合放进构建脚本,未翻译数超过阈值就报警。
3.4 编码、上下文与占位符约定
编码链路要统一:.rc可以是 UTF-16,.po必须是 UTF-8,.mo内部也是 UTF-8,MFC 界面是 UTF-16。程序加载.mo后,查到的msgstr是 UTF-8 字节串,必须用MultiByteToWideChar(CP_UTF8, ...)转成CString再SetWindowText。直接拿std::string往CString里塞,在中文系统上可能碰巧显示正常,换到英文系统就是乱码。
上下文用msgctxt隔离,但查表时要把上下文和msgid拼起来。gettext 的约定是msgctxt + "\x04" + msgid,.mo里存的是拼接后的 key。自己写解析器时如果不处理这个分隔符,带上下文的条目永远查不到。占位符统一用%s、%d、%u,不要用%1$s这种位置参数,因为 MFC 的CString::Format不支持。如果翻译语言需要调整语序,把整句拆成多个短句,或者用CString::Format前先拼装,不要指望格式化函数帮你重排。
还有一个容易忽略的点:msgid里的空格和标点要和界面完全一致。资源脚本里LTEXT "保存 "末尾多一个空格,代码里查表用"保存",就差一个空格查不到。抽取脚本最好保留原文,不要strip()后再写入,否则翻译对了也匹配不上。界面文本末尾的空格通常是有意的布局间隔,不要随便删。
4. 在 MFC 里加载 mo:解析器、翻译宏与界面刷新
到了运行时代码,核心就三件事:读.mo、查表翻译、把翻译结果刷到控件上。.mo是二进制格式,结构不复杂:头部有 magic、版本、字符串数量、原始字符串表偏移、翻译字符串表偏移,后面两张表分别存每条的 length 和 offset。自己写一个只读解析器比引入第三方运行时更可控,也不用担心 DLL 位数和运行库匹配问题。下面这个实现支持无上下文和带上下文查表,不支持复数形式;MFC 界面里复数场景少,真遇到就按msgid_plural单独处理。
4.1 写一个够用的 mo 解析器
先定义接口。CMoCatalog负责加载和查表,Translate接收宽字符串,内部转 UTF-8 查表,再转回宽字符串。索引用std::unordered_map<std::string, size_t>,加载时一次建好,运行时查表是 O(1)。
// include/MoCatalog.h #pragma once #include <string> #include <vector> #include <unordered_map> class CMoCatalog { public: bool Load(const std::wstring& path); std::wstring Translate(const std::wstring& msgid) const; std::wstring Translate(const std::wstring& context, const std::wstring& msgid) const; bool Empty() const { return m_entries.empty(); } private: struct Entry { std::string orig; std::string trans; }; std::vector<Entry> m_entries; std::unordered_map<std::string, size_t> m_index; static std::string WideToUtf8(const std::wstring& s); static std::wstring Utf8ToWide(const std::string& s); };接口里保留两个Translate重载,带上下文的版本内部拼context + "\x04" + msgid。Empty()用来判断语言包是否加载成功,启动时可以据此决定是否回退到默认语言。头文件不依赖 MFC,方便单独测试。
实现部分:
// src/MoCatalog.cpp #include "MoCatalog.h" #include <fstream> #include <cstdint> #include <windows.h> namespace { uint32_t ReadU32(const unsigned char* p) { return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); } } bool CMoCatalog::Load(const std::wstring& path) { std::ifstream in(path, std::ios::binary); if (!in) return false; std::vector<unsigned char> data((std::istreambuf_iterator<char>(in)), {}); if (data.size() < 28) return false; if (ReadU32(data.data()) != 0x950412de) return false; uint32_t count = ReadU32(data.data() + 8); uint32_t origOff = ReadU32(data.data() + 12); uint32_t transOff = ReadU32(data.data() + 16); if (count == 0 || count > 100000) return false; m_entries.clear(); m_index.clear(); m_entries.reserve(count); for (uint32_t i = 0; i < count; ++i) { uint32_t oLen = ReadU32(data.data() + origOff + i * 8); uint32_t oOff = ReadU32(data.data() + origOff + i * 8 + 4); uint32_t tLen = ReadU32(data.data() + transOff + i * 8); uint32_t tOff = ReadU32(data.data() + transOff + i * 8 + 4); if (oOff + oLen > data.size() || tOff + tLen > data.size()) return false; Entry e; e.orig.assign((const char*)data.data() + oOff, oLen); e.trans.assign((const char*)data.data() + tOff, tLen); m_index[e.orig] = m_entries.size(); m_entries.push_back(std::move(e)); } return true; } std::wstring CMoCatalog::Translate(const std::wstring& msgid) const { auto it = m_index.find(WideToUtf8(msgid)); if (it == m_index.end()) return msgid; const std::string& trans = m_entries[it->second].trans; return trans.empty() ? msgid : Utf8ToWide(trans); } std::wstring CMoCatalog::Translate(const std::wstring& context, const std::wstring& msgid) const { std::string key = WideToUtf8(context); key.push_back('\x04'); key += WideToUtf8(msgid); auto it = m_index.find(key); if (it == m_index.end()) return msgid; const std::string& trans = m_entries[it->second].trans; return trans.empty() ? msgid : Utf8ToWide(trans); } std::string CMoCatalog::WideToUtf8(const std::wstring& s) { if (s.empty()) return {}; int n = WideCharToMultiByte(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0, nullptr, nullptr); std::string out(n, '\0'); WideCharToMultiByte(CP_UTF8, 0, s.c_str(), (int)s.size(), out.data(), n, nullptr, nullptr); return out; } std::wstring CMoCatalog::Utf8ToWide(const std::string& s) { if (s.empty()) return {}; int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); std::wstring out(n, L'\0'); MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), out.data(), n); return out; }ReadU32按小端读,.mo默认就是小端。magic 检查0x950412de,不是这个值直接拒绝,避免把其他文件当语言包加载。count上限设 10 万,防止损坏文件导致超大分配。每条的 length 和 offset 都要做边界检查,oOff + oLen > data.size()就返回失败,不要越界读。m_index的 key 是原始字符串,带上下文字符串在.mo里本身就是context\x04msgid,所以加载时不需要额外处理,查表时拼好即可。Translate在查不到或翻译为空时返回msgid,这就是回退逻辑。
这个解析器不支持复数形式。.mo里复数条目的msgid包含\0,msgstr也包含\0,std::string能存,但查表时只传单数msgid会匹配不到。MFC 界面里需要复数的场景不多,真遇到就单独加一个TranslatePlural(msgid, msgidPlural, n),按n选msgstr里的第几段。不要为了少数场景把解析器写复杂,先把单数路径跑通。
4.2 翻译函数与 CString 封装
有了CMoCatalog,再包一层CI18n单例,负责加载语言包、提供T()函数。T()接收CString,内部转std::wstring查表,返回CString。宏定义成I18N(s),不要用_,避免和标准库、gettext 的宏冲突。
// include/I18n.h #pragma once #include "MoCatalog.h" #include <afxstr.h> class CI18n { public: static CI18n& Instance(); bool Load(const CString& lang); CString T(const CString& key) const; CString T(const CString& context, const CString& key) const; bool Ready() const { return !m_catalog.Empty(); } private: CMoCatalog m_catalog; }; #define I18N(s) CI18n::Instance().T(CString(s)) #define I18NC(ctx, s) CI18n::Instance().T(CString(ctx), CString(s))实现放在I18n.cpp:
#include "I18n.h" #include <afx.h> CI18n& CI18n::Instance() { static CI18n inst; return inst; } bool CI18n::Load(const CString& lang) { CString path; path.Format(_T(".\\lang\\%s.mo"), lang.GetString()); return m_catalog.Load(path.GetString()); } CString CI18n::T(const CString& key) const { return CString(m_catalog.Translate(key.GetString()).c_str()); } CString CI18n::T(const CString& context, const CString& key) const { return CString(m_catalog.Translate(context.GetString(), key.GetString()).c_str()); }Load里路径写死了.\lang,实际项目要用 exe 目录拼,避免工作目录不同导致加载失败。CString::Format拼路径时注意%s和宽字符,MFC 的CString在 Unicode 配置下是CStringW,GetString()返回LPCWSTR。T()里m_catalog.Translate返回std::wstring,.c_str()传给CString构造,生命周期在表达式内,没问题。
代码里原来写_T("保存")的地方改成I18N("保存")。字符串表里的文本用I18NC("STR|IDS_SAVE", "保存"),这样和.po里的msgctxt对上。不要两种写法混用,否则同一个词在.po里会出现两条,翻译人员会问“到底翻哪个”。
4.3 对话框、菜单、控件的运行时刷新
MFC 界面里的文本分三类:对话框静态控件、菜单项、动态设置的窗口标题和消息框。消息框最简单,AfxMessageBox(I18N("保存成功"))就行。对话框静态控件要在OnInitDialog里遍历子控件,把文本取出来翻译再设回去。菜单要在主框架初始化时遍历,切换语言时重新设置。
先看控件刷新。关键点是保存原始文本。第一次刷新时把控件当前文本存到std::map<HWND, CString>,以后每次切换都拿原始文本做msgid,而不是拿上一次翻译后的文本。否则第二次切语言时,msgid变成了英文,查中文语言包当然查不到。
// 在对话框类里保存原始文本 std::map<HWND, CString> m_originalText; void CMyDialog::RefreshText() { for (CWnd* p = GetWindow(GW_CHILD); p != nullptr; p = p->GetNextWindow()) { CString cur; p->GetWindowText(cur); if (cur.IsEmpty()) continue; HWND h = p->GetSafeHwnd(); auto it = m_originalText.find(h); if (it == m_originalText.end()) { m_originalText[h] = cur; } else { cur = it->second; } p->SetWindowText(I18N(cur)); } }GetWindow(GW_CHILD)只遍历直接子窗口,如果对话框里有分组框嵌套,需要递归。GetNextWindow在 MFC 里可用,但更稳的是EnumChildWindows。m_originalText要在对话框构造时清空,避免窗口句柄复用时拿到旧映射。SetWindowText只对CWnd有效,对CComboBox的编辑框部分可能不生效,需要单独处理。
菜单刷新类似,但菜单项没有HWND,用命令 ID 做 key。分隔符的 ID 是-1,要跳过。子菜单递归处理。
void CMainFrame::RefreshMenu(CMenu* menu, std::map<UINT, CString>& original) { if (!menu) return; UINT count = menu->GetMenuItemCount(); for (UINT i = 0; i < count; ++i) { UINT id = menu->GetMenuItemID(i); CString text; menu->GetMenuString(i, text, MF_BYPOSITION); if (id != (UINT)-1 && !text.IsEmpty()) { if (original.find(id) == original.end()) { original[id] = text; } menu->ModifyMenu(i, MF_BYPOSITION | MF_STRING, id, I18N(original[id])); } CMenu* sub = menu->GetSubMenu(i); if (sub) RefreshMenu(sub, original); } }GetMenuItemID对弹出菜单返回-1,所以用MF_BYPOSITION取文本。ModifyMenu的第三个参数传id,第四个参数传翻译后的文本。original要作为主框架成员保存,不能每次刷新都新建,否则第二次切换又拿翻译后的文本当原始文本。菜单项如果有快捷键后缀\tCtrl+S,msgid里要包含整个字符串,翻译时保留\t后面的快捷键,不要翻。
4.4 语言切换的持久化与启动流程
语言选择要持久化,不然每次启动都回到默认语言。用 ini 最简单,WritePrivateProfileString写,GetPrivateProfileString读。启动时在InitInstance里读配置,加载对应.mo,再创建主窗口。如果加载失败,回退到zh_CN,并记一条日志。
BOOL CMyApp::InitInstance() { CWinApp::InitInstance(); CString lang; GetPrivateProfileString(_T("I18n"), _T("Language"), _T("zh_CN"), lang.GetBuffer(64), 64, m_iniPath); lang.ReleaseBuffer(); if (!CI18n::Instance().Load(lang)) { CI18n::Instance().Load(_T("zh_CN")); WritePrivateProfileString(_T("I18n"), _T("Language"), _T("zh_CN"), m_iniPath); } CMainFrame* pFrame = new CMainFrame; m_pMainWnd = pFrame; pFrame->LoadFrame(IDR_MAINFRAME); pFrame->ShowWindow(SW_SHOW); pFrame->UpdateWindow(); return TRUE; }m_iniPath要用 exe 目录拼,不能直接用相对路径。GetPrivateProfileString的缓冲区大小要够,语言名一般很短,64 够用。加载失败时回退并写回配置,避免下次启动又加载失败。主窗口创建后再刷新菜单和控件,不要在InitInstance里直接刷,窗口还没创建完。
切换语言时,简单可靠的做法是重建主窗口,或者提示用户重启。运行时刷新能做到,但对话框已经打开的状态、列表控件里的文本、状态栏面板,都要逐个处理,容易漏。我的做法是:主菜单和主窗口控件即时刷新,已经打开的模态对话框关闭后重开,状态栏文本在下一次更新时自然刷新。这样不用为了热切换把整个 UI 状态机重写。
5. 避坑与排查:切换语言时最容易翻车的五个地方
多语言切换的 bug 有个特点:开发环境切一次看着正常,测试切两次就露馅。下面五类问题是我在 MFC 项目里反复遇到的,每条按“现象、原因、解决”写,照着排查能省不少时间。
5.1 切换后部分文本不更新
现象:点“切换语言”后,菜单变了,对话框标题变了,但按钮和静态文本还是中文。原因通常有两个:一是刷新只遍历了直接子窗口,分组框里的控件没遍历到;二是控件文本在OnInitDialog里被SetWindowText写死,刷新函数执行顺序在它之前,后来又被覆盖。解决:用EnumChildWindows递归遍历所有子窗口,刷新函数放在OnInitDialog最后调用;检查有没有在OnPaint、
本文还有配套的精品资源,点击获取