MFC軟體國際化的幾個問題及其解決方案

strnghrs發表於2022-04-03

作者:馬健
郵箱: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"的控制元件。

順便一提,MFC現在建議選擇資料夾對話方塊應該用CFolderPickerDialog,但這個的介面與檔案選擇對話方塊CFileDialog基本一樣,太容易混淆,所以在我的軟體中仍然用修改過的SHBrowseForFolder選擇資料夾。

五、部分關鍵原始碼及測試例項

上面二、三、四部分如果都用訊息鉤子實現,則其鉤子相關函式如下:

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環境下,執行後各對話方塊文字、按鈕仍然能顯示簡體中文。

連結:https://pan.baidu.com/s/11irniZke-hUgvDpim1knSA
提取碼:uvk0

相關文章