程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> VC >> 關於VC++ >> 持續化更新的視狀態,在DLL中使用托管擴展

持續化更新的視狀態,在DLL中使用托管擴展

編輯:關於VC++

持續化更新的視狀態

在DLL中使用托管擴展

這個月是我的專欄11周年紀念以及新標題:“C++ At Work”的開幕式。同時我們還新增了一個新的雙月專欄:“Pure C++”,這個專欄由我的伙伴,C++ 大師級人物之一—— Stan Lipman 主持。Stan 將更多地涉及純粹的 C++/CLI 語言方面(他會告訴你更多這方面的東西),而我將繼續一如既往地編寫 C++ 日常應用以及現實中的MFC(和目前的托管擴展)Windows 編程方面的文章。Stan 的新專欄描繪的是公認的當今最為活躍的主流編程語言之一的 Microsoft C++(譯者:即微軟的 C++ ),MSDN 雜志將在這個欄目中為我們的讀者提供更多的 C++ 內容。

我選擇“C++ At Work”這個標題是因為它有兩層含義:第一個意思是“工作中的 C++”(C++ on-the-job),指在工作中使用 C++ 的人們。而對我來說的另一個含義,也是更重要的含義是“讓 C++ 工作”(Putting C++ Work)——也就是說讓 C++ 為你做事。這才是我的專欄主旨之所在,這一點是不會改變的。唯一正式的變化是在名字中不會再有“Q&A”,但我保留偶爾寫一些我認為重要而且有趣和值得的不是直接來自讀者提問的 C++ 主題和技術的權利。此外,我打算專欄還是保持 Q&A 風格,所以說不管怎樣,盡管給我提問就是了。

我懷著極大的興趣閱讀了你在2004年11月關於如何持續化不同用戶會話“打開”對話框視圖狀態的專欄文章,我覺得有一個問題。你的 CListViewShellWnd (對話框中的 m_wndListViewShell)在用戶進入另一個文件夾時會被銷毀。所以當你關閉對話框時,它不會存儲當前的視圖模式,而是用戶進入另一個文件夾時所處的視圖模式。用什麼方法解決這個問題? 

被人抓住了小辮子,真的讓我好尴尬!你說的完全正確,在我去年11月的代碼中有一個bug。在那篇文章中,我描述了如何實現定制的“打開”對話框(CPersistOpenDlg),使之能記住不同用戶會話的視圖模式。也就是說,如果用戶在“打開”對話框中選擇縮略圖,那麼下次再運行此程序時,該對話框便會以縮略圖模式打開。我是通過子類化一個特殊的窗口 SHELLDLL_DefView 來現實的,“打開”對話框使用該窗口顯示文件和文件夾。我用 Spy++ 發現這個神奇的命令 IDs 來設置視圖模式,同時我還示范了用 LVM_GETITEMSPACING 來區分圖標模式和縮略圖模式——兩者從 LVM_GETVIEW/GetView 返回的都是 LV_VIEW_ICON。

任何時候,只要你存儲窗口狀態,不管是大小/位置,還是視圖模式,在窗口銷毀之前的 WM_DESTROY/OnDestroy 處理例程中做這項工作是很自然的事情。那也是我在 CListViewShellWnd 中要做的事,它是為處理 SHELLDLL_DefView 而建立的一個 MFC 類:

void CListViewShellWnd::OnDestroy() {
  m_lastViewMode = GetViewMode(); // 記住當前視圖模式
}

當對話框被銷毀時,其對象(CPersistOpenDlg)便在構造函數中將 m_lastViewMode 寫入用戶配置文件。過程就是如此——它是一種做這類事情的常規方式。但是正像 John 發現的那樣,“打開”對話框在用戶關閉它時並沒有銷毀外殼視圖;它每次都是在用戶轉到另一個文件夾時銷毀的。要改變文件清單的顯示,唯一的方法是去全部從頭再建立一次。(現在你知道為什麼“打開”對話框後,進入另一個文件夾是如此之慢了)。為了了解文件夾視圖的銷毀,你只要添加一個 TRACE 即可:

void CListViewShellWnd::OnDestroy() {
  TRACE(_T("CListViewShellWnd::OnDestroy\n"));
  ...
}

現在運行“打開”對話框並切換文件夾。已經足夠了,看一下文件夾視圖便知了。CListViewShellWnd 忠實地保存了其視圖模式,但當用戶改變文件夾時,“打開”對話框銷毀了它的 SHELLDLL_DefView,從而導致 MFC (在 CWnd::OnNcDestroy 中)對之進行自動反子類化(unsubclass)並將 m_hWnd 置為NULL。此後“打開”對話框創建新的 SHELLDLL_DefView,然而此時 CPersistOpenDlg 再也看不到這個 SHELLDLL_DefView,所以當 CPersistOpenDlg 將視圖模式寫入用戶配置文件時,它寫入的是用戶改變文件夾之前的老數據。

幸運的是,這個 Bug 不屬於那種難以再現的Bug。修復 CPersistOpenDlg 也不難,稍微想一想便可以解決。顯而易見的事情是必須捕獲 OnFolderChange 和重新子類化文件夾視圖。重新進行子類化是正確的思路,但為什麼要在 OnFolderChange 裡做呢?不管什麼時候,只要你在 Windows 中發現意想不到的事情,扪心自問一下:還有什麼地方會出錯?如果“打開對話框”在用戶切換文件夾時銷毀其自身文件夾視圖,那麼你怎麼知道在其它時候不會銷毀呢?比如在萬聖節的午夜鐘聲敲響時,或者 Red Sox 在月蝕期間贏得世界棒球大賽的時候?為了找到問題的解決辦法,我們要深入分析問題的根源,而不是只看表面現象。

CPersistOpenDlg 已經有一個私有消息 MYWM_POSTINIT 來初始化對話框。當對話框第一次啟動時,CPersistOpenDlg 在 OnInitDialog 中將該消息發(POST)給自己,處理例程子類化該文件夾視圖。當文件夾視圖被銷毀時,我只要再發一次 MYWM_POSTINIT 即可:

void CListViewShellWnd::OnDestroy() {
  m_lastViewMode = GetViewMode(); // 與以前一樣,沒有變化
  m_pDialog->PostMessage(MYWM_POSTINIT); // 重新發送初始化消息
}

現在,如果“打開”對話框處於任何原因決定要銷毀其文件夾視圖(切換文件夾或者Red Sox),CListViewShellWnd 都會發送初始化消息,從而使得我的對話框重新子類化此新的文件夾視圖。很聰明,是不是?這裡是 Post MYWM_POSTINIT 消息,而不是 Send,這一點很重要,所以“打開”對話框可以在 CPersistOpenDlg::OnPostInit 子類化它之前創建新的文件夾視圖。之所以在 CListViewShellWnd::OnDestroy 中修復此問題,是要保證能夠抓住所有“打開”對話框可能銷毀其文件夾視圖的地方,並且還有一個好處是不需要任何新的事件處理例程。絕對不要畫蛇添足,以免遭受隱晦 bug 的困擾。

聰明的讀者可能已經注意到我在代碼中加了一個數據成員,m_pDialog,它指向“父”CPersistOpenDlg 對象。向視圖的父窗口(GetParent)發 YUWM_POSTINIT 消息是較為干淨的做法(因為它不需要添加數據成員),但在這裡行不通,因為在此情況很特殊,CPersistOpenDlg 實際上是真正對話框的子窗口。所以我加了一個在構造函數中初始化的回頭指針(back pointer)。

我還必須對 CPersistOpenDlg::OnPostInit 作一下些細微的修改。原來的處理程序不僅子類化文件夾視圖,而且還初始化用戶配置文件中的視圖模式。對話框第一次啟動時我是需要這樣做的——但我不想每次用戶切換文件夾時將視圖模式重置成保存的狀態。所以我需要一個方法來區分第一次和後繼的初始化。既然我沒有將 WPARAM 用於其它目的,那就用它吧。下面來修改代碼,OnInitDialog 在 WPARAM 為 TRUE 時,則發送 MYWM_POSTINIT,而 CListViewShellWnd::OnDestroy 在 WPARAM 為 FALSE 時發送。新的 OnPostInit 程序代碼參見 Figure 1。

最後,有些人可能會問——當“打開”對話框因為徹底關閉而銷毀文件夾視圖時會是一種什麼情況呢?從 ListViewShellWnd::OnDestroy 裡發出的 MYWM_POSTINIT 消息會怎麼樣呢?什麼事都不會發生。CListViewShellWnd::OnDestroy 將消息發到一個空無的地方(void where),就像智慧之樹倒在森林裡一樣,悄然無聲,因為沒有人傾聽。整個消息隊列隨著對話框的銷毀而銷聲匿跡。

我真是個好心人,重新編寫了 CPersistOpenDlg 實現,修復了本文所描述的 bug。具體細節請下載源代碼。

我有一個 MFC 動態鏈接庫(DLL),我想用托管擴展來調用 .NET Framework 中的類。在編譯時連接器報出一個警告 warning LNK4243:“DLL 包含用 /clr 編譯的對象,不能用 /NOENTRY 鏈接;映像文件可能無法正常運行。”我不太明白其含義,因此將它忽略——可我的程序在運行的時候垮掉了。難道在 DLL 中不能使用托管擴展嗎?

當編程已變得如此容易,你的 Wendy 姑媽用 C# 編寫著 Web 服務程序,你從容地做著自己的事情,突然天翻地覆,你四腳朝天。幸運的是,“將C++托管擴展項目從純粹的中間語言轉換成混合模式”描述了碰到這種情況時的解決方案。你可以在 MSDN 庫中找到這篇文章。但鑒於你不是唯一一個遭受這種打擊的程序員,所以本文中我再對上述文章的內容做一些強調,仔細分析一下幕後所發生的事情。你必須深入研究 DLLs,這對你來說沒有什麼壞處。

暫且不說 C++,我們先從普通老式的 C DLL 開始,C DLL 並不是什麼特別的東西,只不過是一個應用程序可以調用的函數集合。記住:從本質上講,DLL 就是一個在運行時鏈接的子例程庫,與編譯時鏈接相對(此即 DLL被稱為“動態”之所在)。與每個 C 程序都有 main 入口函數一樣,DLL 都有一個入口函數叫 DllMain(正像你所看到的,DllMain 並不是必須的,但一般都會有這樣一個入口函數)。DllMain 的唯一作用是為你提供一個進行初始化操作的地方。假設你要創建所有函數都要使用的全局狀態。那麼可以在 DllMain 中做。只要某個進程附加到你的DLL或從你的DLL分離,那麼系統便會調用 DllMain。Figure 2 展示了 DllMain 的基本結構。

翻開 C++ 之前幾年的日歷,一切都是很順利的,隨著 C++ 的出現,現在的 DLL 有了類。假設你有一個象下面這樣的類 Bobble:

class Bobble {
public:
    Bobble() { /* create */ }
   ~Bobble() { /* destroy */ }
};

假設你的 Bobble DLL 定義了一個全局靜態實例:

Bobble MyBobble;

MyBobble 是一個全局對象,所有的函數都可以使用它,就像 MFC 中的 theApp。不知何故,編譯器現在必須安排應用程序調用 bobble.dll 中的任何函數之前先調用 Bobble 構造函數。這在C語言中是絕不會有的事,在 C 中靜態初始化的唯一方法如下:

int GlobalVal=0;

也就是說,將原始數據類型初始化為一個常量值,編譯器會進行自身管理。但現在的初始化需要調用運行時執行的函數,而不是在編譯時。在 C++ 中,你甚至可以編寫下面這樣的代碼通過某個函數來初始化一個整型:

UINT WM_MYFOOMSG = RegisterWindowMessage("MYFOOMSG");

那麼誰來調用這些函數?何時調用?答案是:啟動代碼(startup code)在調用 DllMain 之前。你也許認為一切都是從 DllMain 開始的,但在此之前,C 運行時 DLL 啟動代碼調用了你的構造函數。如果你深入 crtdll.c 文件看看 CRT DLL 的初始化序列(這個文件位於\VS.NET\VC7\crt\src目錄),你會發現下面的函數:

BOOL WINAPI _DllMainCRTStartup(...) {
  if ( /* DLL_PROCESS_ATTACH */ ) {
   _CRT_INIT(...); // initialize CRT including ctors
    DllMain(...);
  }
  ...
}

我對所發生的重要細節進行了簡化:_DllMainCRTStartup 調用另一個 crtdll 函數,_CRT_INIT,然後調用 DllMain。_CRT_INIT 初始化 C 運行時,然後調用你的所有靜態對象的構造函數。_CRT_INIT 是如何知道調用哪個構造函數的呢?是編譯器產生了一個列表。所以當應用程序調用你的 C++ DLL 時,其加載順序如下:

應用程序加載你的 DLL (LoadLibrary);

LoadLibrary 調用 _DllMainCRTStartup;

_DllMainCRTStartup 調用 _CRT_INIT;

_CRT_INIT 調用靜態構造函數;

_DllMainCRTStartup 調用 DllMain;

DllMain 進行更多的初始化;

應用程序調用 DLL 函數;

如果你使用 MFC,你甚至都不知道 DllMain 的存在,因為 MFC 為你提供了這個入口。在 MFC 中,一切都是從 CWinApp::InitInstance 開始的。而且是唯一的一個入口,MFC 用它自己的 DllMain 實現對它的調用。

好了,現在讓我們言歸正傳,討論 .NET 和托管擴展。你的 MFC DLL 運行得很好,你甚至都不知道它還有一個 DllMain,現在你想調用框架。所以你 #include <mscorlib.dll>,用 /clr 開關編譯,然後碰到高深莫測的 /NOENTRY 警告。為什麼呢?

為了回答這個問題,我得解釋一下另一個迷惑人的問題。DllMain 運行於 DLL 生命期一個重要的節骨眼上,正好是在它的加載過程當中。你無法實施在 DllMain 中想做的任何事情。尤其是調用其它 DLLs,因為它們都還沒有被加載。調用其它 Dlls 需要首先加載它們,而這種加載要通過整個 DLL 加載循環鏈實現,你的 DLL 此時正在加載過程中。你知道“死循環,堆棧溢出嗎?”它甚至限制應用像 user32,shell32 這樣的系統 DLLs 和 COM 對象。許多程序員沮喪地發現他們的 DLL 從 DllMain中調用 MessageBox 時崩潰,希望在調試期間顯示一些信息。唯一個可以保證加載並從 DllMain 中安全調用的DLL是 kernel32.dll。(有關在 DllMain 中能做什麼和不能做什麼的更多信息參見 DllMain)。

根據以上的解釋,你可能會猜測托管 DLL 哪裡不對勁。只要你使用 /clr 開關,你的 DLL 就成了托管的 DLL。初始化一個托管 DLL 需要在 DllMain 期間運行不安全代碼。編譯器現在產生的是微軟中間語言(MSIL),不是本地機器指令。只有微軟的人確切知道內幕以及系統在遇到第一個 MSIL 指令時要調用多少個 DLLs。不管發生什麼,你都能斷定系統除了調用內核外,還有更多的調用。因此默認情況下,托管 DLLs 不會與 C 運行時庫 msvcrt.lib 鏈接。它們沒有 _DllMainCRTStartup,並且不會調用 DllMain——即便它存在。換句話說,為了防止在 DLL 初始化期間調用不安全代碼,微軟的人直接規定托管 DLLs 將是一個 /NOENTRY DLLs。/NOENTRY DLL 即是一個沒有 入口點的 DLL。如果你的 DLL 本身不需要初始化或終止例程,它便不需要 DllMain,因此你可以使用 /NOENTRY 選項。這樣的 DLLs 不能有需要初始化的靜態對象;只能輸出函數。

由於 MFC 到處都有需要初始化的不可或缺的靜態對象。那是不是就意味著不能在混合模式的 DLL中使用 MFC 了呢?當然不是!微軟的老大提供了一種方案。參見前面提到的“Converting Managed Extensions”一文,稍微不同的是根據你是否使用 LoadLibrary 或輸入庫以及你是編寫常規 DLL 還是 COM 對象。

既然你問的是關於 MFC 的問題,我就描述一下使用引入庫的 C++/MFC DLLs 該怎麼做,這是一種更常見的情況。基本思路很簡單:首先在鏈接選項中添加 /NOENTRY,將你的 DLL 建立成一個 NOENTRY DLL。這樣使得鏈接器不至於報出警告。為了初始化對象,你必須編寫自己的 初始化/終止處理函數(initialization/termination)並輸出它們。任何使用你的 DLL 的應用程序在調用DLL中的函數之前都必須先調用你的初始化函數,同樣,在結束時必須調用終止函數。

感覺這是個很好的計劃,但是如何實現初始化/終止函數呢?幸運的是微軟的老大提供了一個文件,其中包含了這兩個函數的實現,從而使事情變得簡單化;你只要包含該文件並調用這兩個函數即可:

#include <_vcclrit.h>
BOOL __declspec(dllexport) MyDllInit() {
  return __crt_dll_initialize();
}
BOOL__declspec(dllexport) MyDllTerm() {
  return __crt_dll_terminate();
}

其實並不難,是嗎?如果你打開 _vcclrit.h 文件,看看過去那些粗糙的多線程處理代碼,你會發現 __crt_dll_initialize 所做的工作無外乎調用 _DllMainCRTStartup(DLL_ PROCESS_ ATTACH)。同樣,__crt_dll_terminate 則調用 _DllMainCRTStartup (DLL_PROCESS_DETACH)。如果你用 C++ 編寫代碼,你不會留意丟失的 Boolean 返回碼,你可以寫一個自動初始化類,用其構造函數/析構函數封裝 __crt_dll_xxx 調用,因此,應用程序只要在全局范圍的某個地方創建一個自動初始化類對象的實例即可:

CMyLibInit initlib; // in main app

注意 CMyLibInit 只能適用於某個 exe 使用 DLL 的情況;如果你的客戶端是另外一個DLL,你試圖想在該DLL的全局范圍內實例化 CMyLibInit 的話,在加載器鎖定的情況下,__crt_dll_initialize 會被再次調用,從而潛在地觸發同樣的你正試圖避免的 DLL-死鎖問題。為了令從另外一個本地 DLL 初始化混合 DLL,你必須找到一個合適的地方來調用混合 DLL 的初始化/終止函數——例如,如果本地DLL是 COM 對象,那麼可以在 DllGetClassObject/DllCanUnloadNow 中調用。否則你還需要從本地DLL導出另外一層初始化/終止函數,而本地DLL則調用構造函數或者加載器鎖定情況下的 DllMain。

Figure 3 展示了我寫的一個使用 CMyLibInit 的 DLL。ManLib 導出一個函數:FormatRunningProcesses,它調用 .NET 框架中的進程類,輸出一個以 CString 格式化的運行進程清單,測試程序調用 FormatRunningProcesses 並在消息框中顯示此清單,參見 Figure 4:

Figure 4 LibTest

我用 TRACE 診斷機制對 ManLib 進行了跟蹤以幫助你了解哪個函數獲得調用。Figure 5 是例子程序運行畫面,顯示了事件順序。你可以將此輸出與 Figure 3 中的 TRACE 輸出進行比較以更好地理解調用順序。如果你自己實現類似 CManLibInit 的機制,記住:在調用 __crt_dll_initialize 之前 (或者在調用 __crt_dll_terminate 之後)不能做任何事情。我剛開始寫 ManLib 時,不加思索地將 TRACE 放在最前面,就像下面這樣:

CManLibInit::CManLibInit() {
  TRACE(...); // Oops!
  __crt_dll_initialize();
}

結果我的程序崩潰了,原因是 MFC 的跟蹤機制還沒有被初始化。而這正是 __crt_dll_initialize 要做的事情。

Figure 5 TraceWin

還有一件事情需要提及。MSDN 庫文章中描述的解決方案告訴你在項目設置菜單中將 msycrt.lib 添加到鏈接庫中並將 __DllMainCRTStartup@12(托管名)設置成強制符號引用——“Force Symbol References”。如果你一開始就用使用一個 MFC DLL,便不需要這些步驟,因為 MFC 已經鏈接你需要的東西。你只要將 /NOENTRY 添加到鏈接器選項,編寫自己的初始化/終止函數並在應用程序中調用它們即可。相關文章參見“Visual C++ 2005 中混合代碼的初始化”。

本文配套源碼

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved