MFC软件国际化的几个问题及其解决方案

作者:马健
邮箱:stronghorse_mj@hotmail.com
主页:https://www.cnblogs.com/stronghorse/
以前我以为PDG相关软件只会在国内流行,所以发行简体中文版足矣,没想到现在流传到繁体中文环境下去了,还被人报告在繁体中文Windows下,Unicode版软件界面出现乱码。所以上网查了一下国际化多语言用户界面(Multilingual User Interface,MUI)技术,发现还有一些问题需要解决,所以把解决过程记录下来,形成这篇笔记。
=============================================================
目前网上能查到的基于MFC的多语言用户界面(MUI)实现,基本上都是对同一个资源ID复制不同的语言备份,然后在应用初始化时调用SetThreadLocale(XP)、SetThreadUILanguage(Vista+)设置语言,让FindResource函数自动根据所设置的语言读取对应的资源。这样做能达到以下效果:

  1. 如果同一资源ID有不同语言的备份,则FindResource会自动按照所设置的语言选择一个,从而达到根据用户选项切换界面语言文字的目的。
  2. 对于afxdlgs.h中定义的公共对话框,包括文件选择、字体选择、打印设置、查找替换等,也会自动按照所设置的语言显示按钮和文字。
但也存在下列问题:
  1. 项目的字符集必须设置为Unicode,否则在非同族语言下不论怎么搞都是乱码。
  2. PropertySheet、MessageBox的按钮不管是用SetThreadLocale还是SetThreadUILanguage设置,都会显示Windows当前语言的文字,如英文Windows下显示的按钮文字就是OK而不是“确定”,即使已经用SetThreadUILanguage设置了简体中文。
  3. 受PropertySheet影响,打印机选择对话框(CPrintDialogEx)左下角的两个按钮也会按当前语言显示。
  4. SHBrowseForFolder中的标题、按钮、提示不管用SetThreadLocale还是SetThreadUILanguage设置,都会按照Windows当前语言显示。
  5. 如果在资源编辑器中设置了下拉框(ComboBox)的中文data,即简体中文的初始化文字,则在其他语言下会出现乱码,包括对话框(Dialog)、PropertyPage中的下拉框都是这样。
以上问题至少我目前没有在网上找到答案,所以下面的分析及解决方案除非特殊说明,均为原创。
一、ComboBox的中文data在其他语言下出现乱码的原因及解决方案
ComboBox初始化出现乱码的原因分析:
  1. 在CDialog::OnInitDialog()下断点,跟踪进去,可以看到一开始就调用CWnd::ExecuteDlgInit(LPCTSTR lpszResourceName)函数。
  2. 在CWnd::ExecuteDlgInit(LPCTSTR lpszResourceName)中,根据对话框ID来FindResource、LoadResource,LockResource,然后调用CWnd::ExecuteDlgInit(LPVOID lpResource)。
  3. 在CWnd::ExecuteDlgInit(LPVOID lpResource)中,关键是下面的代码:

#ifndef _AFX_NO_OCC_SUPPORT
else if (nMsg == LB_ADDSTRING || nMsg == CB_ADDSTRING)
#endif // !_AFX_NO_OCC_SUPPORT
{
// List/Combobox returns -1 for error
if (::SendDlgItemMessageA(m_hWnd, nIDC, nMsg, 0, (LPARAM) lpnRes) == -1)
bSuccess = FALSE;
}

因此:
  1. 尽管VC已经用Unicode编码保存资源文件(.rc文件),但资源文件的DLGINIT数据段,仍然按照传统采用ANSI编码保存combobox和listbox的初始data。
  2. 在CWnd::ExecuteDlgInit(LPVOID lpResource)函数中,读取到DLGINIT数据段中的ANSI编码字符串后,直接用ANSI版的SendDlgItemMessageA发消息对combobox和listbox进行初始化,即逐一插入初始化字符串。
  3. 反编译user32.dll可以看出,SendDlgItemMessageA内部是GetDlgItem、SendMessageA。
  4. 由于combobo已经设置成Unicode,SendMessageA自动按照当前代码页(ACP)转码成Unicode,而不是按SetThreadUILanguage所设置的语言转码,导致出现乱码。
解决方案有两种:
方案一:流行,但回避矛盾
既然MFC的初始化代码会导致乱码,那么combobox的初始值就干脆不在资源编辑器里设置,而是独立成一条字符串放到string table里,用的时候从资源里读取出来,自己拆解后插入combobox。
特点:
  1. 不能利用资源编辑器所见即所得的便利,combobox的大小不好控制。
  2. 每个combobox都要这么搞,实在太麻烦。
所以虽然这种方法在网上很流行,不少支持NUI的软件都这么玩,但我还是不想这么干。
方案二:原创,根本性解决问题
  1. 参照ExecuteDlgInit的代码写一段combobox初始化代码,先把DLGINIT中的初始字符串从ANSI转换成Unicode后,再调用SendDlgItemMessageW插入comobobox。
  2. 写一个通用的对话框初始化函数,先周游对话框下的所有控件,删掉已经初始化过的combobox中的内容,再用上面的代码对combobox重新初始化。
  3. 在每一个对话框、PropertyPage的OnInitDialog()函数中,在调用完基类的OnInitDialog()函数后,调用上面这个初始化函数对combobox进行初始化。
与方案一相比,方案二显然简单得多,且能够使用资源编辑器设置combobox的初始化data,所以我用的就是这个方案。
二、消息框(MessageBox)的按钮文字没有按照设定语言显示文字的原因及解决方案
原因分析:
查了一下Windows XP的源代码,对消息框是这样实现的:
int MessageBoxW( HWND hwndOwner, LPCWSTR lpszText, LPCWSTR lpszCaption, UINT wStyle) { EMIGETRETURNADDRESS(); return MessageBoxExW(hwndOwner, lpszText, lpszCaption, wStyle, 0); }int MessageBoxExW( HWND hwndOwner, LPCWSTR lpszText, LPCWSTR lpszCaption, UINT wStyle, WORD wLanguageId) { return MessageBoxTimeoutW(hwndOwner, lpszText, lpszCaption, wStyle, wLanguageId, INFINITE); }

为保险起见,反编译了win10下的user32.dll做对照,发现win0果然有所长进,没有采用这种俄罗斯套娃式的低效代码,而是在MessageBoxW函数中直接:
return MessageBoxTimeoutW(hwndOwner, lpszText, lpszCaption, wStyle, 0, INFINITE);
同样win10下的MessageBoxExW,也是直接:
return MessageBoxTimeoutW(hwndOwner, lpszText, lpszCaption, wStyle, wLanguageId, INFINITE);
即不论XP还是Win10,调用MessageBox,均相当于用MAKELANGID(LANG_NEUTRAL, SUBLANG_NEUTRAL)参数调用MessageBoxEx。所以网上有些传言说不应该用MessageBox,而应该用MessageBoxEx,其实是不对的,因为源代码和反编译代码都说明二者等价。
本来按照MSDN对MessageBoxEx函数的说法,用MAKELANGID(LANG_NEUTRAL, SUBLANG_NEUTRAL)参数调用MessageBoxEx,应该按照当前线程所设置的语言显示按钮文字,这些文字存放在对应语言文件夹下的user32.dll.mui文件的资源中。
但问题在于简体中文Windows下有en-US\user32.dll.mui,但原版英文Windows下却没有zh-CN\user32.dll.mui。所以设置为英语后,在简体中文Windows下消息框按钮显示为OK,但设置为简体中文后,在英文Windows下消息框按钮仍然是OK而不是“确定”,除非在英文版Windows下已经安装过中文语言包。
解决办法可以有多种:
  1. 要求用户安装微软发行的Windows简体中文语言包,这是最简单、最正宗的方法。
  2. 如果不能,用户要求也不高,要不就这么算了吧,因为按照Windows缺省语言显示的按钮文字,用户肯定看得懂,所以虽然影响观瞻,但不影响使用。
  3. 如果要求比较高,可以参考wine或Windows XP源代码中的MessageBox实现代码,自己写一个,对11个按钮想按照什么语言、文字SetWindowText都可以。wine的源代码简单一些,没有声音、没有copy功能,消息框的对话框模板也在rc文件中定义。Windows源代码的实现水平要更高一些,消息框的对话框模板都不屑于在资源中定义,而是按需在内存中动态生成,我初见的时候也懵了一下,感觉如果真能看懂,编程水平都要涨一截。
  4. 如果想简单点,就用SetWindowsHookEx装一个消息钩子(WH_CALLWNDPROC),对WM_INITDIALOG消息进行监视,发现初始化的是消息框,就查找按钮并重置按钮的文字。
在消息钩子中判断消息框的依据:
  1. window style含DS_ABSALIGN、DS_NOIDLEMSG。一般其他对话框很少含这两个style。
  2. 如果调用的是AfxMessageBox,而不是直接调用::MessageBox,则除了MB_ABORTRETRYIGNORE、MB_RETRYCANCEL风格之外的消息框都会带一个icon,这个icon的ID是20,style含SS_ICON,ClassName是Static。以上这些通过Spy++都能看到。
三、PropertySheet按钮文字不按照设定语言显示的原因与解决方案
原因很简单,没有相应的语言包,即mui文件。所以最简单的办法还是安装语言包,如果实在不想或不能安装,再考虑下面的解决方法。
做产品式的解决方法:
  1. 从CPropertySheet派生出一个类来,重载OnInitDialog(),在其中对标准按钮(IDOK、IDCANCEL、ID_APPLY_NOW、IDHELP)的文字,按照选定语言用SetWindowText进行设置。
  2. 缺省情况下CPropertySheet、CPropertyPage不管资源编辑器中选择了什么字体、字号,一律按系统设定的字体、字号显示,令人不爽,正好在派生类中一并解决了。我的DjVuToy、TiffToy等软件就是这么玩的。
如果采用这种方案,CPrintDialogEx也要进行派生,然后重载DefWindowProc()函数,在其中处理WM_INITDIALOG函数,对按钮文字进行设置。
做项目式的解决方法:
用SetWindowsHookEx装一个消息钩子(WH_CALLWNDPROC),对WM_INITDIALOG消息进行监视,发现是PropertySheet,就查找按钮并重置按钮的文字。判断PropertySheet的依据:
  1. 自身的ClassName是"#32770"。
  2. 含SysTabControl32控件。
  3. 含4个按钮:
    const static int IDs[] = {IDOK, IDCANCEL, IDD_APPLYNOW, IDHELP};
用这种方法,顺便也解决了CPrintDialogEx的按钮问题,因为CPrintDialogEx的主窗口本来就是一个PropertySheet。

四、SHBrowseForFolder按钮和提示文字不按照设定语言显示的原因与解决方案
原因和上面一样,没有相应的语言包。所以只有实在不想或不能安装语言包,再考虑下面的解决方法。
做产品式的解决方法:
  1. 把BROWSEINFO结构体的lpfn指针指向一个自定义的消息处理函数。
  2. 在该消息处理函数中,收到BFFM_INITIALIZED消息后,自己设置标题、按钮、提示。其中对于IDD_FOLDERLABLE要注意检查是否有足够的空间显示全部文字,否则可能会自动折行。
  3. 缺省SHBrowseForFolder显示的对话框尺寸太小,在处理BFFM_INITIALIZED消息时顺便可以扩展一下对话框。
SHBrowseForFolder的完整源代码在Windows 2000、XP、2003的源代码中都可以找到,对话框中的ID自然也在里面。我写的Pdg2Pic等软件就是这么玩的,所以选择文件夹的对话框看起来比别家的要大气一点。
做项目式的解决方法:
  1. 用SetWindowsHookEx装一个消息钩子(WH_CALLWNDPROC),对WM_PARENTNOTIFY消息进行监视,发现是SHBrowseForFolder,就查找按钮并重置按钮的文字。
  2. 判断SHBrowseForFolder的依据:含有ClassName是"SHBrowseForFolder ShellNameSpace Control"的控件。
五、部分关键源代码及测试实例
上面二、三、四部分如果都用消息钩子实现,则其钩子相关函数如下:
HHOOK g_hMsgHook4MUI = NULL; static LRESULT CALLBACK CallMsgWndProc( int nCode, WPARAM wParam, LPARAM lParam ) { // 先调用原始的消息处理函数,处理WM_INITDIALOG等消息 LRESULT ret = CallNextHookEx(g_hMsgHook4MUI, nCode, wParam, lParam); CWPSTRUCT* pStruc = (CWPSTRUCT*)lParam; if (wParam == 0) { if (pStruc->message == WM_INITDIALOG) { if (IsMsgBox(pStruc->hwnd)) FixMsgBoxButtons(pStruc->hwnd); else if (IsPropertySheet(pStruc->hwnd)) FixPropertySheet(pStruc->hwnd); } else if (pStruc->message == WM_PARENTNOTIFY && pStruc->wParam == BFFM_INITIALIZED) { if (IsSHBrowseForFolder(pStruc->hwnd)) FixSHBrowseForFolder(pStruc->hwnd); } } return ret; }void InstallMsgHook4MUI() { g_hMsgHook4MUI = SetWindowsHookEx(WH_CALLWNDPROC, CallMsgWndProc, NULL, ::GetCurrentThreadId()); }void UnInstallMsgHook4MUI() { if ( g_hMsgHook4MUI != NULL ) { if ( UnhookWindowsHookEx( g_hMsgHook4MUI ) != 0 ) g_hMsgHook4MUI = NULL; } }

然后在App的InitInstance(),或主对话框的OnInitDialog()里,调用InstallMsgHook4MUI()安装钩子;在App的ExitInstance(),或主对话框的OnDestroy()里调用UnInstallMsgHook4MUI()取消钩子。
当然在App的InitInstance()函数里,别忘了调用
SetThreadUILanguage(MAKELANGID(LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED));
对语言进行设置。
按照上面说明实现的一个测试例子见下面链接,在未安装简体中文的Windows环境下,运行后各对话框文字、按钮仍然能显示简体中文。
【MFC软件国际化的几个问题及其解决方案】链接:https://pan.baidu.com/s/11irniZke-hUgvDpim1knSA
提取码:uvk0

    推荐阅读