曾經我認為C語言就是個弟弟

plle發表於2021-05-13

本文所有程式碼,均上傳至github,如果你想直接看原始碼,請到github下載,下載地址:https://github.com/vitalitylee/TextEditor

“C語言只能寫有一個黑框的命令列程式,如果要寫圖形介面的話,要用Java或者C#”,在2009年左右,我對同學這麼說。

都2021年了,說這句話導致的羞愧感,一直在我腦海徘徊。

在這裡,就讓我們一起用C寫一個GUI應用程式,以正視聽。

但是,寫什麼呢?

首先,這個程式不應該太複雜,不然的話沒有辦法在一篇文章內實現;

其次,這個程式又要具有一定的實用性;

考慮到這兩點,記事本應該是個不錯的選擇,既不太大,也比較常用。

那麼,就讓我們開始吧。

對於我們要實現的記事本,應該有如下功能:

  1. 能夠開啟一個文字檔案(通過開啟檔案對話方塊);
  2. 能夠對文字進行編輯;
  3. 能夠將檔案儲存;
  4. 檔案儲存時,如果當前沒有已開啟任何檔案,則顯示檔案儲存對話方塊。
  5. 能夠將檔案另存為另外路徑,儲存後開啟內容為另存為路徑;
  6. 在主窗體顯示當前開啟檔案的檔名;
  7. 如果檔案已編輯,並且未儲存,主窗體標題前加'*';
  8. 如果檔案儲存,則去除主窗體標題前的'*';

為了能夠對我們接下來要做的事情有一個整體印象,讓我們在這裡對本文要實現一個簡單記事本功能的計劃說明,我們的簡單步驟如下:

  1. 說說如何對一個C語言專案進行設定,以建立一個GUI應用程式;
  2. 聊聊入口函式;
  3. 使用C語言建立一個窗體;
  4. 為我們的窗體新增一個選單,並新增選單命令;
  5. 新增編輯器;
  6. 響應選單命令;
  7. 實現退出命令;
  8. 實現開啟檔案命令;
  9. 響應編輯器內容變化事件;
  10. 實現儲存命令;
  11. 實現另存為命令;
  12. 整理我們的程式碼,按照功能進行分離;
  13. 最後,我們聊聊整個過程中可能遇到的問題;

如果完成以上步驟,那麼我們就有了一個可以簡單工作的文字編輯器了,接下來,讓我們開始吧。

在開始寫程式碼之前,開發環境自然是少不了的。在這裡,我們用Visual Studio Community 2019作為我們的開發環境。

安裝包可以到官網下載,地址如下:
https://visualstudio.microsoft.com/zh-hans/thank-you-downloading-visual-studio/?sku=Community&rel=16

也可以到 Visual Studio 官網搜尋下載,介面如下:

點選圖中紅框處的按鈕下載。
待下載完成後,需要選中“使用C++的桌面開發”選擇框,如下圖所示:

具體的安裝步驟,可參考:

https://docs.microsoft.com/en-us/visualstudio/install/install-visual-studio?view=vs-2019

一、說說如何對一個C語言專案進行設定,以建立一個GUI應用程式

安裝完我們的環境之後,我們就可以建立我們的專案了。主要步驟如下:

  1. 啟動 Visual Studio,並點選“建立新專案”按鈕
  2. 選擇專案型別
  3. 設定專案原始碼目錄以及專案名稱
  4. 設定專案型別
  5. 新建一個主程式檔案
  6. 編輯開始程式碼
  7. 編譯執行

接下來,我們詳細看看各個步驟的操作。

1. 啟動 Visual Studio,並點選“建立新專案”按鈕

2. 選擇專案型別

3. 設定專案原始碼目錄以及專案名稱

4. 設定專案型別

由於Visual Studio預設的專案型別為Console型別,但是我們要建立一個GUI的文字編輯器,所以這裡我們要設定專案型別為GUI型別。具體設定方法如下:

a. 開啟解決方案管理器,如下

b. 右鍵專案TextEditor,選擇屬性

c. 將“系統”選項由控制檯修改為視窗,最後點選“確定”

5. 新建一個主程式檔案

在設定好專案型別之後,我們就可以新建我們的主程式檔案了,在這裡,我們將主程式檔案命名為 main.c

a. 在解決方案資源管理器中,右鍵“原始檔”

b. 在彈出的選單中依次選擇“新增”->“新建項”

c. 在新建項對話方塊中,按照下圖步驟新增原始檔

6. 編輯程式碼

我們知道,在C語言中,程式是從main函式開始執行的。但是對於一個GUI應用程式來說,我們的程式入口變成了如下形式:

int wWinMain(
  _In_ HINSTANCE hInstance,
  _In_opt_ HINSTANCE hPrevInstance,
  _In_ LPWSTR lpCmdLine,
  _In_ int nShowCmd
);

你可以到 winbase.h 檔案中找到此函式的定義,如下:

int
#if !defined(_MAC)
#if defined(_M_CEE_PURE)
__clrcall
#else
WINAPI
#endif
#else
CALLBACK
#endif
WinMain (
    _In_ HINSTANCE hInstance,
    _In_opt_ HINSTANCE hPrevInstance,
    _In_ LPSTR lpCmdLine,
    _In_ int nShowCmd
    );

int
#if defined(_M_CEE_PURE)
__clrcall
#else
WINAPI
#endif
wWinMain(
    _In_ HINSTANCE hInstance,
    _In_opt_ HINSTANCE hPrevInstance,
    _In_ LPWSTR lpCmdLine,
    _In_ int nShowCmd
    );

#endif /* WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_DESKTOP) */

我們可以發現,這裡定義了兩個主函式,至於要用哪一個,取決於我們程式執行平臺的選擇,WinMain 主要用於ANSI環境,wWinMain 主要用於 Unicode 環境。由於 Windows 核心均採用 Unicode 編碼,而且非 Unicode 字元在真正呼叫 Windows API 時,均會轉化為 Unicode 版本,所以對於我們的程式,採用 Unicode 會更快(省略了轉換步驟),所以這裡我們採用 Unicode 版本的主程式。
好了,準備好環境之後,讓我們把如下程式碼新增到原始檔中:

#include <Windows.h>

// 我們的窗體需要一個訊息處理函式來處理各種動作。
// 由於我們要將訊息處理函式入口賦值給窗體物件,
// 這裡需要提前宣告。
LRESULT CALLBACK mainWinProc(
  HWND hWnd, UINT unit, WPARAM wParam, LPARAM lParam);

int wWinMain(
  _In_ HINSTANCE hInstance,
  _In_opt_ HINSTANCE hPrevInstance,
  _In_ LPWSTR lpCmdLine,
  _In_ int nShowCmd
) {
  return 0;
}

我們的主程式,只是返回了一個0,沒有做任何操作。

7. 編譯執行

要編譯我們的C語言程式,和平時我們編譯C#應用程式沒有區別,在這裡,我們直接按下 Ctrl+F5 執行程式,我們發現,沒有任何反應,這個時候,我們去 Debug 目錄下去看看,我們發現,Visual Studio 為我們生成了如下檔案:

其中檔案的作用如下:

  • TextEditor.exe: 我們的可執行檔案;
  • TextEditor.ilk: 為連結器提供包含物件、匯入和標準庫、資源、模組定義和命令輸入;
  • TextEditor.pdb:儲存.exe檔案或者.dll檔案的除錯資訊。

之所以在我們執行程式之後,什麼都沒有看到,是因為我們的程式沒有做任何事情。

二、 聊聊入口函式

對於入口函式,在之前我們編輯程式碼時已經有了說明,我們可以在 WinBase.h 包含檔案中找到其定義。並且我們還知道了,在ANSI字元編碼和Unicode字元編碼環境下,我們要分別定義不同的入口函式名。

接下來,我們來聊聊我們主函式的引數以及返回值。

引數:

對於一個 Win32 GUI 應用程式主函式來說,一共有四個引數,說明如下:

hInstance

型別:HINSTANCE

說明:

當前應用程式例項的控制程式碼。

hPrevinstance

型別:HINSTANCE

說明:

當前應用程式的上一個例項的控制程式碼。這個引數始終為 NULL。如果要判斷是否有另外一個已經執行的當前應用程式的例項,需要使用 CreateMutex 函式,建立一個具有唯一命名的互斥鎖。

如果互斥鎖已經存在,CreateMutex 函式也會成功執行,但是返回值為 ERROR_ALREADY_EXISTS. 這說明你的應用程式的另外一個例項正在執行,因為另一個例項已經建立了該互斥鎖。

然而,惡意使用者可以在你的應用程式啟動之前,先建立一個互斥鎖,從而阻止你的應用程式啟動。如果要防止這種情況,請建立一個隨機命名的互斥鎖,並儲存該名稱,從而使得只有指定應用可以使用該互斥鎖。

如果要限定一個使用者只能啟動一個應用程式例項,更好的方法是在使用者的配置檔案中建立一個鎖定檔案。

lpCmdLine

型別:LPSTR/LPWSTR

說明:
對於我們的 Unicode 應用程式來說,這個引數的型別應為 LPWSTR,對於ANSI 應用程式來說,這個引數型別為 LPSTR。

本參數列示啟動當前應用程式時,傳入的命令列引數,包括當前應用程式的名稱。如果要獲取某一個命令列引數,可以通過呼叫 GetCommandLine 函式實現。

nShowCmd

型別:int

說明:

用於控制程式啟動之後的窗體如何顯示。

當前引數可以是 ShowWindow 函式的 nCmdShow 引數允許的任何值。

返回值:

型別:int

說明:
如果程式在進入訊息迴圈之前結束,那麼主程式應該返回0。如果程式成功,並且因為收到了 WM_QUIT 訊息而結束,那麼主程式應該返回訊息的 wParam 欄位值。

使用C語言建立一個窗體

在瞭解如何使用C語言建立一個窗體之前,讓我們先看一看Windows是如何組織窗體的。

在 Windows 啟動的時候,作業系統會自動建立一個窗體-桌面窗體(Desktop Window)。桌面窗體是一個由作業系統定義,用於繪製顯示器背景,並作為所有其它應用程式窗體基礎窗體的窗體。

桌面窗體使用一個 Bitmap 檔案來繪製顯示器的背景。這個圖片,被稱為桌面桌布。

說完桌面窗體,接下來,讓我們聊聊其它窗體。

在 Windows 下,窗體被分為三類:系統窗體,全域性窗體和本地窗體。

  • 系統窗體為作業系統註冊的窗體,大部分這類窗體可以由所有應用程式使用,另外還有一些,供作業系統內部使用。由於這些窗體由作業系統註冊,所以我們的應用程式不能銷燬他們。

  • 全域性窗體是由一個可執行檔案或者DLL檔案註冊,並可以被所有其它程式使用的窗體。比如,你可以在一個DLL中註冊一個窗體,在要使用這個窗體的應用程式中,載入該dll,然後使用該窗體。當然,你也可以通過在如下注冊表鍵的 AppInit_DLLs 值中新增當前dll路徑實現:

HKEY_LOCAL_MACHINE\Software\Microsoft\WindowsNT\CurrentVersion\Windows

這樣的話,每當一個程式啟動,作業系統就會在呼叫應用的主函式之前,載入指定的DLL。給定的DLL必須在其 Initialization 函式中註冊窗體,並設定窗體型別的樣式為 CS_GLOBALCLASS。

如果要銷燬全域性窗體並釋放其記憶體,可以通過呼叫 UnregisterClass 函式實現。

  • 本地窗體是可執行檔案或者 DLL 註冊的,當前程式獨佔使用的窗體,雖然可以註冊多個,但是通常情況下,一個應用程式只註冊一個本地窗體類。這個本地窗體類用於處理應用程式的主窗體邏輯。

作業系統會在程式結束之前,登出本地窗體類。應用程式也可以使用 UnregisterClass 函式登出本地窗體類。

作業系統會為以上三種窗體型別分別建立一個結構連結串列。當一個應用程式呼叫CreateWindow 或者 CreateWindowEx 函式,以建立窗體時,作業系統會先從本地窗體類連結串列中,查詢給定的窗體類。

經過以上介紹,不難發現,如果要建立一個窗體,要麼使用系統已經註冊過的窗體類,要麼使用一個自己註冊的窗體類。

在這裡,我們需要一個自定義窗體,系統中不存在該窗體型別,所以需要我們自己註冊。而又由於此窗體不進行共享,只是在我們的應用程式中使用,所以我們需要註冊一個自定義的型別。

註冊一個窗體型別,需要使用 WNDCLASSEX 結構體,通過 RegisterClassEx 函式進行註冊。其中 WNDCLASSEX 結構體用於設定我們窗體的基礎屬性,如所屬程式的應用例項,類名,樣式,關聯的選單等。

由於註冊窗體型別和其他過程沒有關係,所以這裡我們將本過程抽出,寫出如下函式:

LPCWSTR mainWIndowClassName = L"TextEditorMainWindow";

/**
* 作用:
*  主窗體訊息處理函式
* 
* 引數:
*  hWnd
*    訊息目標窗體的控制程式碼。
*  msg
*    具體的訊息的整型值定義,要了解系統
*    支援的訊息列表,請參考 WinUser.h 中
*    以 WM_ 開頭的巨集定義。
* 
*  wParam
*    根據不同的訊息,此引數的意義不同,
*    主要用於傳遞訊息的附加資訊。
* 
*  lParam
*    根據不同的訊息,此引數的意義不同,
*    主要用於傳遞訊息的附加資訊。
* 
* 返回值:
*  本函式返回值根據傳送訊息的不同而不同,
*  具體的返回值意義,請參考 MSDN 對應訊息
*  文件。
*/
LRESULT CALLBACK mainWindowProc(
  HWND hWnd,
  UINT msg,
  WPARAM wParam,
  LPARAM lParam) {
  return DefWindowProc(hWnd, msg, wParam, lParam);
}

/**
* 作用:
*   註冊主窗體型別。
*
* 引數:
*   hInstance
*       當前應用程式的例項控制程式碼,通常情況下在
*       進入主函式時,由作業系統傳入。
*
* 返回值:
*   型別註冊成功,返回 TRUE,否則返回 FALSE。
*/
BOOL InitMainWindowClass(HINSTANCE hInstance) {
  WNDCLASSEX wcx;
  // 在初始化之前,我們先將結構體的所有欄位
  // 均設定為 0.
  ZeroMemory(&wcx, sizeof(wcx));

  // 標識此結構體的大小,用於屬性擴充套件。
  wcx.cbSize = sizeof(wcx);
  // 當窗體的大小發生改變時,重繪窗體。
  wcx.style = CS_HREDRAW | CS_VREDRAW;
  // 在註冊窗體型別時,要設定一個窗體訊息
  // 處理函式,以處理窗體訊息。
  // 如果此欄位為 NULL,則程式執行時會丟擲
  // 空指標異常。
  wcx.lpfnWndProc = mainWindowProc;
  // 設定窗體背景色為白色。
  wcx.hbrBackground = GetStockObject(WHITE_BRUSH);
  // 指定主窗體型別的名稱,之後建立窗體例項時
  // 也需要傳入此名稱。
  wcx.lpszClassName = mainWIndowClassName;

  return RegisterClassEx(&wcx) != 0;
}

其中,InitMainWindowClass 函式用於註冊本應用程式的主窗體型別,由於註冊窗體型別時,需要一個窗體訊息處理函式,所以在這裡,我們又新增了一個 mainWindowProc 函式,該函式呼叫 DefWindowProc 函式,讓作業系統採用預設的訊息處理。

通過以上程式碼,我們可以看到,雖然我們通過返回一個 BOOL 型別值,判斷註冊型別是否成功,但是我們並不知道具體失敗的原因,所以在這裡,我們再新增一個函式,以呼叫 GetLastError 函式,獲取最後的錯誤,並彈出對應訊息:

/**
* 作用:
*  顯示最後一次函式呼叫產生的錯誤訊息。
*
* 引數:
*  lpszFunction
*    最後一次呼叫的函式名稱。
*
*  hParent
*    彈出訊息窗體的父窗體,通常情況下,
*    應該指定為我們應用程式的主窗體,這樣
*    當訊息彈出時,將禁止使用者對主窗體進行
*    操作。
*
* 返回值:
*  無
*/
VOID DisplayError(LPWSTR lpszFunction, HWND hParent) {
  LPVOID lpMsgBuff = NULL;
  LPVOID lpDisplayBuff = NULL;
  DWORD  errCode = GetLastError();

  if (!FormatMessage(
    FORMAT_MESSAGE_ALLOCATE_BUFFER |
    FORMAT_MESSAGE_FROM_SYSTEM |
    FORMAT_MESSAGE_IGNORE_INSERTS,
    NULL,
    errCode,
    MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
    (LPTSTR)&lpMsgBuff,
    0,
    NULL
  )) {
    return;
  }
  lpDisplayBuff = LocalAlloc(
    LMEM_ZEROINIT,
    (lstrlen((LPCTSTR)lpMsgBuff)
      + lstrlenW((LPCTSTR)lpszFunction)
      + 40
      ) * sizeof(TCHAR)
  );
  if (NULL == lpDisplayBuff) {
    MessageBox(
      hParent,
      TEXT("LocalAlloc failed."),
      TEXT("ERR"),
      MB_OK
    );
    goto RETURN;
  }

  if (FAILED(
    StringCchPrintf(
      (LPTSTR)lpDisplayBuff,
      LocalSize(lpDisplayBuff) / sizeof(TCHAR),
      TEXT("%s failed with error code %d as follows:\n%s"),
      lpszFunction,
      errCode,
      (LPTSTR)lpMsgBuff
    )
  )) {
    goto EXIT;
  }

  MessageBox(hParent, lpDisplayBuff, TEXT("ERROR"), MB_OK);
EXIT:
  LocalFree(lpDisplayBuff);
RETURN:
  LocalFree(lpMsgBuff);
}

當我們格式化錯誤訊息失敗時,由於已經沒有了其他的補救措施,當前我們直接退出程式。

經過以上步驟,我們建立了一個主窗體類,接下來,讓我們建立一個例項,並顯示窗體。要實現目標,我們需要使用 CreateWindow 函式建立一個窗體例項,並獲取到窗體控制程式碼,然後通過呼叫 ShowWindow 函式顯示窗體,然後通過一個訊息迴圈,不斷地處理訊息。

新增建立主窗體函式如下:

/**
* 作用:
*  建立一個主窗體的例項,並顯示。
* 
* 引數:
*  hInstance
*    當前應用程式的例項控制程式碼。
* 
*  cmdShow
*    控制窗體如何顯示的一個標識。
* 
* 返回值:
*  建立窗體成功,併成功顯示成功,返回 TRUE,
*  否則返回 FALSE。
*/
BOOL CreateMainWindow(HINSTANCE hInstance, int cmdShow) {
  HWND mainWindowHwnd = NULL;
  // 建立一個窗體物件例項。
  mainWindowHwnd = CreateWindowEx(
    WS_EX_APPWINDOW,
    mainWIndowClassName,
    TEXT("TextEditor"),
    WS_OVERLAPPEDWINDOW,
    CW_USEDEFAULT,
    CW_USEDEFAULT,
    CW_USEDEFAULT,
    CW_USEDEFAULT,
    NULL,
    NULL,
    hInstance,
    NULL
  );

  if (NULL == mainWindowHwnd) {
    DisplayError(TEXT("CreateWindowEx"), NULL);
    return FALSE;
  }

  // 由於返回值只是標識窗體是否已經顯示,對於我們
  // 來說沒有意義,所以這裡丟棄返回值。
  ShowWindow(mainWindowHwnd, cmdShow);

  if (!UpdateWindow(mainWindowHwnd)) {
    DisplayError(TEXT("UpdateWindow"), mainWindowHwnd);
    return FALSE;
  }
  
  return TRUE;
}

修改我們的主函式如下:

int WINAPI wWinMain(
  _In_ HINSTANCE hInstance,
  _In_opt_ HINSTANCE hPrevInstance,
  _In_ LPWSTR lpCmdLine,
  _In_ int nShowCmd
) {
  MSG msg;
  BOOL fGotMessage = FALSE;

  if (!InitMainWindowClass(hInstance)
    || !CreateMainWindow(hInstance, nShowCmd)) {
    return FALSE;
  }

  while ((fGotMessage = GetMessage(&msg, (HWND)NULL, 0, 0)) != 0 
    && fGotMessage != -1)
  {
    TranslateMessage(&msg);
    DispatchMessage(&msg);
  }

  return msg.wParam;
}

由於我們使用了一些Windows API,所以需要在我們的原始碼中包含API宣告,當前,我們只需要 Windows.h 和 StrSafe.h 兩個標頭檔案,所以需要在我們 main.c 檔案頭部新增如下兩行:

#include <Windows.h>
#include <strsafe.h>

好了,點選執行按鈕,我們發現,程式成功啟動,並彈出了一個窗體,如下:

我們可以看到,彈出的窗體有它的預設行為,我們可以拖動窗體位置,可以調整大小,可以最小化,最大化和關閉按鈕,並且它有一個標題 “TextEditor”。現在,讓我們關閉窗體,這個時候,問題出現了:雖然窗體關閉了,但是我們的程式怎麼沒有結束?

那是因為,我們的訊息迴圈沒有收到退出訊息,要在關閉窗體時,退出程式,我們需要處理窗體的 WM_DESTORY 事件,當銷燬窗體時,向我們的應用程式傳送一個退出訊息。

這可以通過修改我們之前註冊的訊息處理函式實現,修改我們的 mainWindowProc 函式如下:

LRESULT CALLBACK mainWindowProc(
  HWND hWnd,
  UINT msg,
  WPARAM wParam,
  LPARAM lParam) {
  switch (msg) {
  case WM_DESTROY:
    PostQuitMessage(0);
    return 0;
  default:
    return DefWindowProc(hWnd, msg, wParam, lParam);
  }
}

再次執行我們的程式,當關閉窗體後,程式就終止了。

通過之前的內容,不難意識到,對於每一個訊息,它的 lParam 和 wParam 分別代表的意義不同,並且訊息處理函式的返回值代表的意義也不同,那麼對於每一個窗體訊息,是不是都要查詢文件,並將引數進行強制型別轉換後,獲取對應資訊,最後返回我們的處理結果呢?當然,這麼做是可以的,但是會增加我們程式的複雜度,並且容易出錯。這個時候,我們就可以使用平臺提供的一個標頭檔案 "windowsx.h" 來解決這個問題,這個檔案定義了一系列的巨集,用於訊息的轉換,在頭部包含 "windowsx.h" 標頭檔案之後,我們的訊息處理函式就可以改成如下形式:

LRESULT CALLBACK mainWindowProc(
  HWND hWnd,
  UINT msg,
  WPARAM wParam,
  LPARAM lParam) {
  switch (msg) {
  case WM_DESTROY:
    return HANDLE_WM_DESTROY(
      hWnd,
      wParam,
      lParam,
      MainWindow_Cls_OnDestroy
    );
  default:
    return DefWindowProc(hWnd, msg, wParam, lParam);
  }
}

其中,HANDLE_WM_DESTROY 是 windowsx.h 標頭檔案定義的一個巨集,用於處理 WM_DESTROY 訊息,其中前三個函式分別為訊息處理函式的三個同名引數,最後一個引數是我們定義的訊息處理函式名稱,訊息函式的簽名可以到訊息處理巨集的定義處檢視,對應註釋就是我們的訊息處理函式的定義形式,名稱可以不一樣,但是簽名需要一樣,比如,HANDLE_WM_DESTROY 巨集的註釋如下:

/* void Cls_OnDestroy(HWND hwnd) */

那麼,我們的訊息處理函式就應該定義為一個 HWND 引數,並且沒有返回值的函式。所以,我們的窗體銷燬函式定義如下:

void MainWindow_Cls_OnDestroy(HWND hwnd) {
  PostQuitMessage(0);
}

執行程式,我們發現和之前是一樣的效果。

四、新增一個選單,並新增選單命令

在上一節,我們瞭解了建立一個窗體的方法,本節,我們聊聊選單。

在 Visual Studio 中,選單是以資源的形式存在和編譯的,要增加選單,其實就是新增一個選單資源。

新增過程如下:

1. 解決方案資源管理器中,滑鼠右鍵專案名 -> 新增 -> 資源,彈出新增資源對話方塊:

2. 在彈出的新增資源對話方塊左側,選擇 Menu,點選右側”新建“按鈕,彈出選單編輯介面

我們會發現,有一個”請在此鍵入“的框,在這裡,輸入我們的選單項,比如,輸入”檔案“,介面將變成下面的樣子:

其中,在”檔案“下方的輸入框輸入的項,為”檔案“選單項的子項,右側為同級選單項,當我們在”檔案“選單子項中新增專案之後,子項的下方和右方也會出現對應的輸入框,這時候,下方的為統計項,右側的為子項。

按照之前我們定義的程式功能,分別為每一個功能新增一個選單項,結果如下:

新增完成之後,在屬性工具欄,我們分別修改對應的選單項ID名稱,以便之後識別命令,修改過程為選擇一個選單項,然後在屬性工具欄中修改ID項,我們依次修改選單項的ID如下:

- 開啟:ID_OPEN
- 儲存:ID_SAVE
- 另存為:ID_SAVEAS
- 退出:ID_EXIT

雖然IDE為我們提供了視覺化的修改方法,但是視覺化修改,當我們改ID之後,IDE就會新增一個ID,而不是將原來的ID替換,更好的辦法是直接編輯資原始檔。

在我們新增選單資源的時候,仔細觀察的話,會發現,IDE為我們新增了兩個檔案:resource.h 和 TextEditor.rc。

首先,讓我們開啟 resource.h檔案,發現檔案內容如下:

//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ 生成的包含檔案。
// 供 TextEditor.rc 使用
//
#define IDR_MENU1                       101
#define ID_Menu                         40001
#define ID_40002                        40002
#define ID_40003                        40003
#define ID_40004                        40004
#define ID_40005                        40005
#define ID_OPEN                         40006
#define ID_SAVE                         40007
#define ID_SAVE_AS                      40008
#define ID_EXIT                         40009

// Next default values for new objects
// 
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE        102
#define _APS_NEXT_COMMAND_VALUE         40010
#define _APS_NEXT_CONTROL_VALUE         1001
#define _APS_NEXT_SYMED_VALUE           101
#endif
#endif

這裡,我們去除無用宣告,將其修改如下:

//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ 生成的包含檔案。
// 供 TextEditor.rc 使用
//
#define IDR_MENU_MAIN                   101
#define ID_OPEN                         40001
#define ID_SAVE                         40002
#define ID_SAVE_AS                      40003
#define ID_EXIT                         40004

// Next default values for new objects
// 
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE        102
#define _APS_NEXT_COMMAND_VALUE         40010
#define _APS_NEXT_CONTROL_VALUE         1001
#define _APS_NEXT_SYMED_VALUE           101
#endif
#endif

注意,在這裡,我們不止修改了子選單項的ID,而且還修改了選單資源的ID名為 IDR_MENU_MAIN。

修改 resource.h 的同時,我們還要同步修改 TextEditor.rc檔案,extEditor.rc檔案不能通過雙擊開啟,要通過右鍵->檢視程式碼開啟,否則會顯示檔案已經在其他編輯器開啟,或者開啟資源編輯器。

開啟extEditor.rc檔案,你看到的內容可能如下:

// Microsoft Visual C++ generated resource script.
//
#include "resource.h"

#define APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 2 resource.
//
#include "winres.h"

/////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS

/////////////////////////////////////////////////
// 中文(簡體,中國) resources

#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_CHS)
LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
#pragma code_page(936)

#ifdef APSTUDIO_INVOKED
/////////////////////////////////////////////////
//
// TEXTINCLUDE
//

1 TEXTINCLUDE 
BEGIN
    "resource.h\0"
END

2 TEXTINCLUDE 
BEGIN
    "#include ""winres.h""\r\n"
    "\0"
END

3 TEXTINCLUDE 
BEGIN
    "\r\n"
    "\0"
END

#endif    // APSTUDIO_INVOKED
/////////////////////////////////////////////////
//
// Menu
//

IDR_MENU1 MENU
BEGIN
    POPUP "檔案"
    BEGIN
        MENUITEM "開啟",                          ID_OPEN
        MENUITEM "儲存",                          ID_SAVE
        MENUITEM "另存為",                         ID_SAVE_AS
        MENUITEM "退出",                          ID_EXIT
    END
END

#endif    // 中文(簡體,中國) resources
/////////////////////////////////////////////////
#ifndef APSTUDIO_INVOKED
/////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 3 resource.
//

/////////////////////////////////////////////////
#endif    // not APSTUDIO_INVOKED

其中,第52行到61行定義了我們的選單資源,這裡我們要將選單資源的ID修改為我們之前在 TextEditor.rc檔案中定義的名稱,同時,我們還要修改資源的編碼宣告(20行),不然編譯的時候會出現亂碼。

最終,我們修改該檔案內容為:

// Microsoft Visual C++ generated resource script.
//
#include "resource.h"

#define APSTUDIO_READONLY_SYMBOLS
////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 2 resource.
//
#include "winres.h"

///////////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS

///////////////////////////////////////////////////////
// 中文(簡體,中國) resources

#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_CHS)
LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
#pragma code_page(65001)

#ifdef APSTUDIO_INVOKED
//////////////////////////////////////////////////////
//
// TEXTINCLUDE
//

1 TEXTINCLUDE 
BEGIN
    "resource.h\0"
END

2 TEXTINCLUDE 
BEGIN
    "#include ""winres.h""\r\n"
    "\0"
END

3 TEXTINCLUDE 
BEGIN
    "\r\n"
    "\0"
END

#endif    // APSTUDIO_INVOKED


/////////////////////////////////////////////////////
//
// Menu
//

IDR_MENU_MAIN MENU
BEGIN
    POPUP "檔案"
    BEGIN
        MENUITEM "開啟",                          ID_OPEN
        MENUITEM "儲存",                          ID_SAVE
        MENUITEM "另存為",                        ID_SAVE_AS
        MENUITEM "退出",                          ID_EXIT
    END
END

#endif    // 中文(簡體,中國) resources
//////////////////////////////////////////////////////////



#ifndef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 3 resource.
//


/////////////////////////////////////////////////////////
#endif    // not APSTUDIO_INVOKED

其中,第20行宣告我們資原始檔的編碼為 UTF-8。

做完以上操作之後,我們就完成了我們選單資源的新增,接下來,怎麼將選單新增到我們彈出的窗體上呢?

在之前註冊窗體類的時候,我們可以看到,在 WNDCLASSEX 結構體中,有一個 lpszMenuName 欄位,我們通過設定該欄位,就可以實現將我們新增的選單資源和我們的主窗體繫結的操作。

在 InitMainWindowClass 函式中新增如下程式碼:

  // 將主窗體的選單設定為主選單
  wcx.lpszMenuName = MAKEINTRESOURCE(IDR_MENU_MAIN);

執行程式,就可以看到,我們的主窗體現在已經有了我們要的選單,如下:

五、新增編輯器

還記得之前我們說過,在Windows下,有一些窗體是作業系統註冊的嗎?其中就有一個窗體,叫做 EDIT,就是用於文字編輯的控制元件。沒錯,文字編輯控制元件,本身也是一個窗體。那麼新增編輯器的操作就簡單了,只需要建立一個 EDIT 窗體,並將其作為我們主窗體的子窗體即可。

要實現這一點,和建立我們的主窗體的程式碼沒有什麼不同。為了在建立主窗體的時候,同時建立編輯器控制元件,我們將編輯器的建立,放到主窗體的 WM_CREATE 事件處理函式中,在 mainWindowProc 函式中新增如下處理:

  case WM_CREATE:
    return HANDLE_WM_CREATE(
      hWnd, wParam, lParam, MainWindow_Cls_OnCreate
    );

然後定義主窗體的建立訊息處理函式如下:

BOOL MainWindow_Cls_OnCreate(
  HWND hwnd, LPCREATESTRUCT lpCreateStruct) {
  return NULL != CreateTextEditor(GetWindowInstance(hwnd), hwnd);
}

通過檢視 WM_CREATE 訊息的說明,我們可以知道,當 WM_CREATE 訊息的處理結果為-1時,作業系統將銷燬已經建立的窗體物件例項,如果為 0,才會繼續執行,所以這裡當我們建立文字編輯器成功之後,返回0,否則返回 -1。

接下來,新增建立編輯器的函式,以及建立預設字型的函式如下:

/**
* 作用:
*  建立編輯器使用的字型,這裡預設為 "Courier New"
*
* 引數:
*  無
*
* 返回值:
*  新建字型的控制程式碼。
*/
HANDLE CreateDefaultFont() {
  LOGFONT lf;
  ZeroMemory(&lf, sizeof(lf));

  // 設定字型為Courier New
  lf.lfHeight = 16;
  lf.lfWidth = 8;
  lf.lfWeight = 400;
  lf.lfOutPrecision = 3;
  lf.lfClipPrecision = 2;
  lf.lfQuality = 1;
  lf.lfPitchAndFamily = 1;
  StringCchCopy((STRSAFE_LPWSTR)&lf.lfFaceName, 32, L"Courier New");

  return CreateFontIndirect(&lf);
}

/**
* 作用:
*  建立編輯器窗體
*
* 引數:
*  hInstance
*    當前應用程式例項的控制程式碼
*
*  hParent
*    當前控制元件的所屬父窗體
*
* 返回值:
*  建立成功,返回新建編輯器的控制程式碼,否則返回 NULL。
*/
HWND CreateTextEditor(
  HINSTANCE hInstance, HWND hParnet) {
  RECT rect;
  HWND hEdit;

  // 獲取窗體工作區的大小,以備調整編輯控制元件的大小
  GetClientRect(hParnet, &rect);

  hEdit = CreateWindowEx(
    0,
    TEXT("EDIT"),
    TEXT(""),
    WS_CHILDWINDOW |
    WS_VISIBLE |
    WS_VSCROLL |
    ES_LEFT |
    ES_MULTILINE |
    ES_NOHIDESEL,
    0,
    0,
    rect.right,
    rect.bottom,
    hParnet,
    NULL,
    hInstance,
    NULL
  );

  gHFont = CreateDefaultFont();
  if (NULL != gHFont) {
    // 設定文字編輯器的字型。並且在設定之後立刻重繪。
    SendMessage(hEdit, WM_SETFONT, (WPARAM)gHFont, TRUE);
  }

  return hEdit;
}

再執行一下,我們可以看到,編輯器已經新增到我們的窗體中了:

六、響應選單命令

通過之前的內容,我們已經可以顯示我們的主窗體、編輯文字了,接下來,我們怎麼響應選單的命令呢?

自然是通過訊息處理函式!

當我們點選了一個選單,作業系統就會傳送向我們的主窗體傳送一個 WM_COMMAND 訊息,所以,我們可以通過處理 WM_COMMAND 訊息來響應選單點選。

為了響應 WM_COMMAND 訊息,向我們的訊息處理函式新增如下分支程式碼:

  case WM_COMMAND:
    return HANDLE_WM_COMMAND(
      hWnd, wParam, lParam, MainWindow_Cls_OnCommand
    );

然後新增我們的命令訊息處理函式骨架,如下:

/**
* 作用:
*  處理主窗體的選單命令
* 
* 引數:
*  hwnd
*    主窗體的控制程式碼
*  id
*    點選選單的ID
*
*  hwndCtl
*    如果訊息來自一個控制元件,則此值為該控制元件的控制程式碼,
*    否則這個值為 NULL
* 
*  codeNotify
*    如果訊息來自一個控制元件,此值表示通知程式碼,如果
*    此值來自一個快捷選單,此值為1,如果訊息來自選單
*    此值為0
* 
* 返回值:
*  無
*/
void MainWindow_Cls_OnCommand(
  HWND hwnd, int id, HWND hwndCtl, UINT codeNotify) {
  switch (id) {
  case ID_OPEN:
    MessageBox(
      hwnd,
      TEXT("ID_OPEN"),
      TEXT("MainWindow_Cls_OnCommand"),
      MB_OK
    );
    break;
  case ID_SAVE:
    MessageBox(
      hwnd,
      TEXT("ID_SAVE"),
      TEXT("MainWindow_Cls_OnCommand"),
      MB_OK
    );
    break;
  case ID_SAVE_AS:
    MessageBox(
      hwnd,
      TEXT("ID_SAVE_AS"),
      TEXT("MainWindow_Cls_OnCommand"),
      MB_OK
    );
    break;
  case ID_EXIT:
    MessageBox(
      hwnd,
      TEXT("ID_EXIT"),
      TEXT("MainWindow_Cls_OnCommand"),
      MB_OK
    );
    break;
  default:
    break;
  }
}

在命令處理函式中,每當我們收到要給命令時,就彈出對應命令的 ID,以確認命令正確到達,並忽略任何我們不需要處理的命令。

執行程式,看看是不是彈出了正確訊息?

七、實現退出命令

在我們要實現的功能中,最容易實現的應該就是儲存命令了。在收到 ID_EXIT 命令時,我們只需要呼叫之前窗體關閉的處理邏輯即可。將命令處理函式的 ID_EXIT 分支程式碼改成呼叫窗體關閉函式,如下:

  case ID_EXIT:
    MainWindow_Cls_OnDestroy(hwnd);
    break;

再次執行,並點選選單 "檔案" -> "退出",可以看到,我們的程式正常關閉了。

八、實現開啟檔案命令

要實現開啟檔案功能,我們可以將其分成如下步驟:

  1. 彈出開啟檔案對話方塊;
  2. 獲取檔案大小;
  3. 分配檔案大小相等的記憶體;
  4. 將檔案內容讀取到分配的記憶體;
  5. 設定主窗體標題為檔名;
  6. 設定編輯器控制元件的文字;

1. 彈出開啟檔案對話方塊

在Windows中,可以通過呼叫 GetOpenFileName 函式彈出開啟檔案對話方塊,並獲取到使用者選擇的檔案路徑,但是根據 MSDN 文件,建議使用 COM 元件的方式彈出開啟檔案對話方塊,這裡我們採取 COM 元件的方式。

新增如下程式碼:

// 支援的編輯檔案型別,當前我們只支援文字檔案(*.txt).
COMDLG_FILTERSPEC SUPPORTED_FILE_TYPES[] = {
  { TEXT("text"), TEXT("*.txt") }
};

// 包含一個型別為 PWSTR 引數,沒有返回值的函式指標
typedef VOID(*Func_PWSTR)(PWSTR parameter, HWND hwnd);
/**
* 作用:
*  選擇一個檔案,選擇成功之後,呼叫傳入的回撥函式 pfCallback
* 
* 引數:
*  pfCallback
*    當使用者成功選擇一個檔案,並獲取到檔案路徑之後,本函式
*    將回撥 pfCallback 函式指標指向的函式,並將獲取到的文
*    路徑作為引數傳入。
* 
*  hwnd
*    開啟檔案對話方塊的父窗體控制程式碼。
* 
* 返回值:
*  無
*/
VOID EditFile(Func_PWSTR pfCallback, HWND hwnd) {
  // 每次呼叫之前,應該先初始化 COM 元件環境
  HRESULT hr = CoInitializeEx(
    NULL,
    COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE
  );
  if (SUCCEEDED(hr))
  {
    IFileOpenDialog* pFileOpen = NULL;

    // 建立一個 FileOpenDialog 例項
    hr = CoCreateInstance(
      &CLSID_FileOpenDialog,
      NULL,
      CLSCTX_ALL,
      &IID_IFileOpenDialog,
      &pFileOpen
    );

    if (SUCCEEDED(hr))
    {
      // 設定開啟副檔名
      pFileOpen->lpVtbl->SetFileTypes(
        pFileOpen,
        _countof(SUPPORTED_FILE_TYPES),
        SUPPORTED_FILE_TYPES
      );
      // 顯示選擇檔案對話方塊
      hr = pFileOpen->lpVtbl->Show(pFileOpen, hwnd);

      // Get the file name from the dialog box.
      if (SUCCEEDED(hr))
      {
        IShellItem* pItem;
        hr = pFileOpen->lpVtbl->GetResult(pFileOpen, &pItem);
        if (SUCCEEDED(hr))
        {
          PWSTR pszFilePath;
          hr = pItem->lpVtbl->GetDisplayName(
            pItem, SIGDN_FILESYSPATH, &pszFilePath);

          // Display the file name to the user.
          if (SUCCEEDED(hr))
          {
            if (pfCallback) {
              pfCallback(pszFilePath, hwnd);
            }
            CoTaskMemFree(pszFilePath);
          }
          pItem->lpVtbl->Release(pItem);
        }
      }
      pFileOpen->lpVtbl->Release(pFileOpen);
    }
    CoUninitialize();
  }
}

在這裡,需要注意的是,為了方便,我們將回撥函式指標宣告和檔案型別宣告與編輯檔案函式定義放到了一起,在真是狀態下,我們會將宣告放到原始檔開頭。

另外,為了使用COM,我們需要引入兩個標頭檔案,stdlib.h 和 ShlObj.h,其中_countof 巨集定義在 stdlib.h 中,其他的COM相關定義,在 ShlObj.h 檔案中。

現在,我們已經實現了彈出開啟檔案對話方塊的功能,但是還沒有呼叫。接下來,讓我們呼叫它,並試一下,是否正常彈出了開啟檔案對話方塊。

首先,修改 ID_OPEN 命令的響應分支如下:

  case ID_OPEN:
    EditFile(OpenNewFile, hwnd);
    break;

然後,我們新增一個新函式: OpenNewFile, 它接收一個字串和父窗體控制程式碼,用於讀取檔案,並將檔案內容新增到編輯器控制元件內,其基礎定義如下:

/**
* 作用:
*  如果當前已經有了開啟的檔案,並且內容已經被修改,
*  則彈出對話方塊,讓使用者確認是否儲存以開啟檔案,並開啟
*  新檔案。
*  如果當前沒有已開啟檔案或者當前已開啟檔案未修改,
*  則直接開啟傳入路徑指定檔案。
*
* 引數:
*  fileName
*    要新開啟的目標檔案路徑。
*
*  hwnd
*    彈出對話方塊時,指定的父窗體,對於本應用來說,
*    應該為主窗體的控制程式碼。
*
* 返回值:
*  無
*/
VOID OpenNewFile(PWSTR fileName, HWND hwnd) {
  MessageBox(hwnd, fileName, TEXT("開啟新檔案"), MB_OK);
}

在這裡,為了演示開啟檔案對話方塊的函式是否正常工作,我們暫時是彈出一個對話方塊,顯示傳入的檔案路徑,沒有做任何操作。執行程式碼,點選"檔案" -> "開啟" 選單,我們可以看到,程式正確彈出了開啟檔案對話方塊,且在選擇檔案之後,彈出了選中路徑:

由於在記憶體中,字串是以 UTF-16 寬字元進行編碼,所以在讀取檔案之後,我們需要將讀取到的內容轉換為寬字元表示,另外我們將記憶體分配的邏輯也抽取出來,封裝成我一個函式,於是,得到以下兩個輔助函式:

/**
* 作用:
*  從預設程式堆中分配給定大小的記憶體,大小的單位為 BYTE。
*  如,要分配 100 byte 的記憶體,可以通過如下方式呼叫:
*    NewMemory(100, NULL)
*
* 引數:
*  size
*    以 byte 為單位的記憶體大小。
*
*  hwnd
*    如果分配出錯,彈出訊息框的父窗體控制程式碼。
*
* 返回值:
*  如果記憶體分配成功,返回分配記憶體的起始指標,否則返回 NULL。
*/
PBYTE NewMemory(size_t size, HWND hwnd) {
  HANDLE processHeap;
  PBYTE buff = NULL;
  if ((processHeap = GetProcessHeap()) == NULL) {
    DisplayError(TEXT("GetProcessHeap"), hwnd);
    return buff;
  }

  buff = (PBYTE)HeapAlloc(processHeap, HEAP_ZERO_MEMORY, size);
  if (NULL == buff) {
    // 由於 HeapAlloc 函式不設定錯誤碼,所以這裡
    // 只能直接彈出一個錯誤訊息,但是並不知道具體
    // 錯誤原因。
    MessageBox(
      hwnd,
      TEXT("alloc memory error."),
      TEXT("Error"),
      MB_OK
    );
  }
  return buff;
}

/**
* 作用:
*  從記憶體 buff 中讀取字串,並將其轉換為 UTF16 編碼,
*  返回編碼後的寬字元字元。
*
* 引數:
*  buff
*    文字原始內容。
* 
*  hwnd
*    操作出錯時,彈框的父窗體控制程式碼。
*
* 返回值:
*  無論原始內容是否為 UTF16 編碼字串,本函式均會
*  重新分配記憶體,並返回新記憶體。
*/
PTSTR Normalise(PBYTE buff, HWND hwnd) {
  PWSTR pwStr;
  PTSTR ptText;
  size_t size;

  pwStr = (PWSTR)buff;
  // 檢查BOM頭
  if (*pwStr == 0xfffe || *pwStr == 0xfeff) {
    // 如果是大端序,要轉換為小端序
    if (*pwStr == 0xfffe) {
      WCHAR wc;
      for (; (wc = *pwStr); pwStr++) {
        *pwStr = (wc >> 8) | (wc << 8);
      }
      // 跳過 BOM 頭
      pwStr = (PWSTR)(buff + 2);
    }
    size = (wcslen(pwStr) + 1) * sizeof(WCHAR);
    ptText = (PWSTR)NewMemory(size, hwnd);
    if (!ptText) {
      return NULL;
    }
    memcpy_s(ptText, size, pwStr, size);
    return ptText;
  }

  size =
    MultiByteToWideChar(
      CP_UTF8,
      0,
      buff,
      -1,
      NULL,
      0
    );

  ptText = (PWSTR)NewMemory(size * sizeof(WCHAR), hwnd);

  if (!ptText) {
    return NULL;
  }

  MultiByteToWideChar(
    CP_UTF8,
    0,
    buff,
    -1,
    ptText,
    size
  );

  return ptText;
}

有了以上兩個輔助函式,接下來,我們新增兩個全域性變數,如下:

LPCSTR currentFileName = NULL;
HWND hTextEditor = NULL;

其中,currentFileName 指向當前以開啟檔案的路徑,hTextEditor 為我們文字編輯器例項的控制程式碼。

由於我們在設定編輯器文字的時候,需要獲取到編輯器控制程式碼,所以在建立編輯器窗體的時候,使用 hTextEditor 記錄控制程式碼,修改主窗體建立事件處理函式,新增賦值:

BOOL MainWindow_Cls_OnCreate(
  HWND hwnd, LPCREATESTRUCT lpCreateStruct) {
  return NULL != (
    hTextEditor = CreateTextEditor(
      GetWindowInstance(hwnd), hwnd)
  );
}

最後,修改 OpenNewFile 函式程式碼如下:

VOID OpenNewFile(PWSTR fileName, HWND hwnd) {
  LARGE_INTEGER size;
  PBYTE buff = NULL;
  HANDLE processHeap = NULL;
  DWORD readSize = 0;
  HANDLE hFile = CreateFile(
    fileName,
    GENERIC_ALL,
    0,
    NULL,
    OPEN_ALWAYS,
    FILE_ATTRIBUTE_NORMAL,
    NULL
  );

  if (INVALID_HANDLE_VALUE == hFile) {
    DisplayError(TEXT("CreateFile"), hwnd);
    return;
  }

  if (!GetFileSizeEx(hFile, &size)) {
    DisplayError(TEXT("GetFileSizeEx"), hwnd);
    goto Exit;
  }

  if ((processHeap = GetProcessHeap()) == NULL) {
    DisplayError(TEXT("GetProcessHeap"), hwnd);
    goto Exit;
  }

  buff = (PBYTE)HeapAlloc(
    processHeap,
    HEAP_ZERO_MEMORY,
    (SIZE_T)(size.QuadPart + 8));
  if (NULL == buff) {
    MessageBox(
      hwnd,
      TEXT("alloc memory error."),
      TEXT("Error"),
      MB_OK
    );
    goto Exit;
  }

  if (!ReadFile(
    hFile, buff,
    (DWORD)size.QuadPart,
    &readSize,
    NULL
  )) {
    MessageBox(
      hwnd,
      TEXT("ReadFile error."),
      TEXT("Error"),
      MB_OK
    );
    goto FreeBuff;
  }

  // 因為對話方塊關閉之後,將會釋放掉檔案路徑的記憶體
  // 所以這裡,我們重新分配記憶體,並拷貝一份路徑
  // 在這之前,需要判斷當前檔名是否指向了一個地址,
  // 如果有指向,應將其釋放。
  if (currentFileName) {
    HeapFree(GetProcessHeap(), 0, currentFileName);
  }
  size_t bsize = (wcslen(fileName) + 1) * sizeof(WCHAR);
  currentFileName = (PWSTR)NewMemory(bsize, hwnd);
  if (!currentFileName) {
    goto FreeBuff;
  }
  StringCbCopy(currentFileName, bsize, fileName);

  PTSTR str = Normalise(buff, hwnd);
  SendMessage(hTextEditor, WM_SETTEXT, 0, (WPARAM)str);
  SendMessage(hwnd, WM_SETTEXT, 0, (WPARAM)currentFileName);
  if (str) {
    HeapFree(processHeap, 0, str);
  }

FreeBuff:
  HeapFree(processHeap, 0, buff);

Exit:
  CloseHandle(hFile);
}

執行程式碼,並開啟檔案,可以看到,程式讀取了檔案內容,並將內容顯示在編輯器內,並且主窗體的標題變為當前開啟的檔案路徑:

九、響應編輯器內容變化事件

雖然我們已經實現了讀取並顯示文字檔案內容的功能,但是如果你對編輯器內的文字進行修改,就會發現,我們主窗體的標題沒有發生變化。

如果要在文字編輯器內的文字發生變化之後,響應該變化,應該怎麼辦呢?

還記得之前,我們在處理命令訊息的時候,有 hwndCtl 和 codeNotify引數嗎?當編輯器控制元件的內容發生變化後,該控制元件會向其父窗體(也就是我們的主窗體)傳送一個 WM_COMMAND 訊息,並且傳入 EN_CHANGE 通知引數,處理命令函式中,響應 EN_CHANGE 通知,修改我們的標題即可。

由於在修改文字之後,我們需要固定在標題之前新增一個 '*',其他部分和檔名是完全一樣的,所以,我們在分配路徑記憶體時,多分配一個字元的空間,將 currentFileName 指標指向新記憶體的第一個字元,這樣,之後修改標題文字的時候,就不選喲重新分配記憶體了。

我們把開啟檔案的程式碼修改如下:

VOID OpenNewFile(PWSTR fileName, HWND hwnd) {
  LARGE_INTEGER size;
  PBYTE buff = NULL;
  HANDLE processHeap = NULL;
  DWORD readSize = 0;
  HANDLE hFile = CreateFile(
    fileName,
    GENERIC_ALL,
    0,
    NULL,
    OPEN_ALWAYS,
    FILE_ATTRIBUTE_NORMAL,
    NULL
  );

  if (INVALID_HANDLE_VALUE == hFile) {
    DisplayError(TEXT("CreateFile"), hwnd);
    return;
  }

  if (!GetFileSizeEx(hFile, &size)) {
    DisplayError(TEXT("GetFileSizeEx"), hwnd);
    goto Exit;
  }

  if ((processHeap = GetProcessHeap()) == NULL) {
    DisplayError(TEXT("GetProcessHeap"), hwnd);
    goto Exit;
  }

  buff = (PBYTE)HeapAlloc(
    processHeap,
    HEAP_ZERO_MEMORY,
    (SIZE_T)(size.QuadPart + 8));
  if (NULL == buff) {
    MessageBox(
      hwnd,
      TEXT("alloc memory error."),
      TEXT("Error"),
      MB_OK
    );
    goto Exit;
  }

  if (!ReadFile(
    hFile, buff,
    (DWORD)size.QuadPart,
    &readSize,
    NULL
  )) {
    MessageBox(
      hwnd,
      TEXT("ReadFile error."),
      TEXT("Error"),
      MB_OK
    );
    goto FreeBuff;
  }

  // 因為對話方塊關閉之後,將會釋放掉檔案路徑的記憶體
  // 所以這裡,我們重新分配記憶體,並拷貝一份路徑
  // 在這之前,需要判斷當前檔名是否指向了一個地址,
  // 如果有指向,應將其釋放。
  if (currentFileName) {
    HeapFree(GetProcessHeap(), 0, currentFileName - 1);
  }
  size_t bsize = (wcslen(fileName) + 2) * sizeof(WCHAR);
  currentFileName = (PWSTR)NewMemory(bsize, hwnd);
  if (!currentFileName) {
    goto FreeBuff;
  }
  currentFileName[0] = (WCHAR)'*';
  currentFileName = ((PWCHAR)currentFileName) + 1;

  StringCbCopy(currentFileName, bsize, fileName);

  PTSTR str = Normalise(buff, hwnd);
  SendMessage(hTextEditor, WM_SETTEXT, 0, (WPARAM)str);
  SendMessage(hwnd, WM_SETTEXT, 0, (WPARAM)currentFileName);

  if (str) {
    HeapFree(processHeap, 0, str);
  }

FreeBuff:
  HeapFree(processHeap, 0, buff);

Exit:
  CloseHandle(hFile);
}

重點關注72-73行,我們多分配了一個字元;

另外,還需要關注第65行,因為 currentFileName 指向的是分配記憶體起始地址之後,所以釋放記憶體的時候,要傳入 currentFileName - 1。

同時,我們新增一個標識文字是否變更的變數,如下:

BOOL textChanged = FALSE;

然後,修改我們的命令處理程式的預設分支如下:

  default:
    if (hwndCtl != NULL) {
      switch (codeNotify)
      {
      case EN_CHANGE:
        if (!textChanged && currentFileName != NULL) {
          SendMessage(
            hwnd,
            WM_SETTEXT,
            0,
            (LPARAM)((((PWCHAR)currentFileName)) - 1)
          );
        }
        textChanged = TRUE;
        break;
      default:
        break;
      }
    }
    break;

在這裡,當我們沒有開啟檔案時,標題時不會發生變更的,但是變更標識會同步變更。

接下來,執行程式,開啟一個檔案,做出任何的編輯,可以看到,在編輯之後,我們主窗體的標題均發生了變化。

補充一句,在調整窗體大小時,發現編輯器的大小沒有隨主窗體的大小發生變化,這是因為,我們沒有處理主窗體的大小變化訊息,在主訊息處理函式中,新增如下分支:

  case WM_SIZE:
    // 主窗體大小發生變化,我們要調整編輯控制元件大小。
    return HANDLE_WM_SIZE(
      hWnd, wParam, lParam, MainWindow_Cls_OnSize);

新增如下函式定義:

/**
* 作用:
*  處理主窗體的大小變更事件,這裡只是調整文字編輯器
*  的大小。
* 
* 引數:
*  hwnd
*    主窗體的控制程式碼
*  
*  state
*    窗體大小發生變化的型別,如:最大化,最小化等
* 
*  cx
*    窗體工作區的新寬度
* 
*  cy
*    窗體工作區的新高度
* 
* 返回值:
*  無
*/
VOID MainWindow_Cls_OnSize(
  HWND hwnd, UINT state, int cx, int cy) { 
  MoveWindow(
    hTextEditor,
    0,
    0,
    cx,
    cy,
    TRUE
  );
}

修改完成程式碼,並儲存,執行程式,現在,我們的文字編輯器大小就會隨著主窗體大小的變化而變化了。

十、實現儲存命令

類似於開啟檔案的處理,我們先寫一個獲取編輯器內容,並將內容寫入檔案(UTF8)的函式,如下:

/**
* 作用:
*  將給定的 byte 陣列中的 bSize 個子接,寫入 file 指定
*  的檔案中。
*
* 引數:
*  bytes
*    要寫入目標檔案的 byte 陣列。
*
*  bSize
*    要寫入目標檔案的位元組數量。
*
*  file
*    要寫入內容的目標檔名。
*
*  hwnd
*    出現錯誤時,本函式會彈出對話方塊,
*    此引數為對話方塊的父窗體控制程式碼。
*
* 返回值:
*  無
*/
VOID WriteBytesToFile(
  PBYTE bytes,
  size_t bSize,
  PWSTR file,
  HWND hwnd
) {
  DWORD numberOfBytesWritten = 0;
  HANDLE hFile = CreateFile(
    file,
    GENERIC_WRITE,
    0,
    NULL,
    CREATE_ALWAYS,
    FILE_ATTRIBUTE_NORMAL,
    NULL
  );
  if (INVALID_HANDLE_VALUE == hFile) {
    DisplayError(TEXT("CreateFile"), hwnd);
    return;
  }

  if (!WriteFile(
    hFile,
    bytes,
    bSize,
    &numberOfBytesWritten,
    NULL
  )) {
    DisplayError(TEXT("WriteFile"), hwnd);
    goto Exit;
  }

Exit:
  CloseHandle(hFile);
}

/**
* 作用:
*  儲存當前已經開啟的檔案,如果當前沒有已開啟檔案,
*  則呼叫另存為邏輯。
* 
* 引數:
*  hwnd
*    出現錯誤時,本函式會彈出對話方塊,
*    此引數為對話方塊的父窗體控制程式碼。
* 
* 返回值:
*  無
*/
VOID SaveFile(HWND hwnd) {
  size_t cch = 0;
  size_t bSize = 0;
  PWCHAR buffWStr = NULL;
  PBYTE utf8Buff = NULL;

  // 如果當前沒有開啟任何檔案,當前忽略
  if (!currentFileName) {
    return;
  }

  // 獲取文字編輯器的文字字元數量。
  cch = SendMessage(
    hTextEditor, WM_GETTEXTLENGTH, 0, 0);
  // 獲取字元時,我們是通過 UTF16 格式(WCHAR)獲取,
  // 我們要在最後新增一個空白結尾標誌字元
  buffWStr = (PWCHAR)NewMemory(
    cch * sizeof(WCHAR) + sizeof(WCHAR), hwnd);

  if (buffWStr == NULL) {
    return;
  }
  // 獲取到編輯器的文字
  SendMessage(
    hTextEditor,
    WM_GETTEXT,
    cch + 1, 
    (WPARAM)buffWStr
  );

  // 獲取將文字以 UTF8 格式編碼後所需的記憶體大小(BYTE)
  bSize = WideCharToMultiByte(
    CP_UTF8,
    0,
    buffWStr,
    cch,
    NULL,
    0,
    NULL,
    NULL
  );

  utf8Buff = NewMemory(bSize, hwnd);
  if (utf8Buff == NULL) {
    goto Exit;
  }
  // 將文字格式化到目標快取
  WideCharToMultiByte(
    CP_UTF8,
    0,
    buffWStr,
    cch,
    utf8Buff,
    bSize,
    NULL,
    NULL
  );

  // 將內容覆蓋到目標檔案。
  WriteBytesToFile(
    utf8Buff, bSize, currentFileName, hwnd);

  // 儲存完成之後,設定文字變更標識為 FALSE,
  // 並設定主窗體標題為當前檔案路徑。
  SendMessage(hwnd, WM_SETTEXT, 0, (LPARAM)currentFileName);

  HeapFree(GetProcessHeap(), 0, utf8Buff);
Exit:
  HeapFree(GetProcessHeap(), 0, buffWStr);
}

接下來,將我們的儲存命令處理分支稍作修改,呼叫 SaveFile 函式,如下:

  case ID_SAVE:
    SaveFile(hwnd);
    break;

執行程式,開啟一個檔案,編輯,儲存,看看標題的 * 是否按照預想顯示和消失,檔案是否正常儲存?

在這裡,如果沒有已經開啟的檔案,我們是忽略儲存命令的,這我們將在實現另存為命令之後,再回來解決這個問題。

十一、實現另存為命令

對於另存為命令,和儲存命令的主要區別,就是另存為命令需要讓使用者選擇一個儲存目標檔名,然後,其他邏輯就和儲存的邏輯一樣了。

讓我們實現另存為函式,如下:

/**
* 作用:
*  彈出另存為對話方塊,在使用者選擇一個檔案路徑之後,
*  回撥 pfCallback 函式指標指向的函式。
* 
* 引數:
*  pfCallback
*    一個函式指標,用於執行使用者選擇一個儲存路徑
*    之後的操作。
* 
*  hwnd
*    出錯情況下,彈出錯誤對話方塊的父窗體控制程式碼。
* 
* 返回值:
*  無
*/
VOID SaveFileAs(Func_PWSTR_HWND pfCallback, HWND hwnd) {
  // 每次呼叫之前,應該先初始化 COM 元件環境
  HRESULT hr = CoInitializeEx(
    NULL,
    COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE
  );
  if (SUCCEEDED(hr))
  {
    IFileSaveDialog* pFileSave = NULL;

    // 建立一個 FileOpenDialog 例項
    hr = CoCreateInstance(
      &CLSID_FileSaveDialog,
      NULL,
      CLSCTX_ALL,
      &IID_IFileSaveDialog,
      &pFileSave
    );

    if (SUCCEEDED(hr))
    {
      // 設定開啟副檔名
      pFileSave->lpVtbl->SetFileTypes(
        pFileSave,
        _countof(SUPPORTED_FILE_TYPES),
        SUPPORTED_FILE_TYPES
      );
      // 顯示選擇檔案對話方塊
      hr = pFileSave->lpVtbl->Show(pFileSave, hwnd);

      // Get the file name from the dialog box.
      if (SUCCEEDED(hr))
      {
        IShellItem* pItem;
        hr = pFileSave->lpVtbl->GetResult(pFileSave, &pItem);
        if (SUCCEEDED(hr))
        {
          PWSTR pszFilePath;
          hr = pItem->lpVtbl->GetDisplayName(
            pItem, SIGDN_FILESYSPATH, &pszFilePath);

          // Display the file name to the user.
          if (SUCCEEDED(hr))
          {
            if (pfCallback) {
              pfCallback(pszFilePath, hwnd);
            }
            CoTaskMemFree(pszFilePath);
          }
          pItem->lpVtbl->Release(pItem);
        }
      }
      pFileSave->lpVtbl->Release(pFileSave);
    }
    CoUninitialize();
  }
}

以上函式只是實現了彈出對話方塊,獲取另存為路徑的功能,讓我們再新增一個獲取路徑之後的處理函式,如下:

/**
* 作用:
*  將當前內容儲存到 fileName,並且設定 currentFileName
*  為 fileName。
* 
* 引數:
*  fileName
*    要將當前內容儲存到的目標路徑
* 
*  hwnd
*    出錯彈出訊息框時,訊息框的父窗體控制程式碼。
* 
* 返回值:
*  無
*/
VOID SaveFileTo(PWSTR fileName, HWND hwnd) {
  size_t len = lstrlen(fileName);
  int bSize = len * sizeof(WCHAR);
  int appendSuffix = !(
    fileName[len - 4] == '.' &&
    fileName[len - 3] == 't' &&
    fileName[len - 2] == 'x' &&
    fileName[len - 1] == 't');

  if (appendSuffix) {
    bSize += 5 * sizeof(WCHAR);
  }

  if (currentFileName) {
    HeapFree(GetProcessHeap(), 0, currentFileName);
    currentFileName = NULL;
  }

  currentFileName = (PWSTR)NewMemory(bSize, hwnd);
  if (!currentFileName) {
    return;
  }

  StringCbCopy(currentFileName, bSize, fileName);
  if (appendSuffix) {
    currentFileName[len + 0] = '.';
    currentFileName[len + 1] = 't';
    currentFileName[len + 2] = 'x';
    currentFileName[len + 3] = 't';
    currentFileName[len + 4] = '\0';
  }

  SaveFile(hwnd);
}

該函式的工作很簡單,就是解析獲取到的路徑,如果路徑最後不是以 ".txt" 結尾,則新增 ".txt" 擴充套件,最後呼叫儲存檔案的邏輯。

接下來,讓我們修改 ID_SAVE_AS 命令分支程式碼:

  case ID_SAVE_AS:
    SaveFileAs(SaveFileTo, hwnd);
    break;

最後,還記得之前我們編輯儲存邏輯時,省略了當前開啟檔名為 NULL 時的處理嗎?現在是時候處理這種情況了,處理方式很簡單,就是掉喲個另存為邏輯。

將SaveFile 函式做如下修改:

VOID SaveFile(HWND hwnd) {
  size_t cch = 0;
  size_t bSize = 0;
  PWCHAR buffWStr = NULL;
  PBYTE utf8Buff = NULL;

  // 如果當前沒有開啟任何檔案,則呼叫另存為邏輯,
  // 讓使用者選擇一個檔名進行儲存,然後退出。
  if (!currentFileName) {
    SaveFileAs(SaveFileTo, hwnd);
    return;
  }

  // 獲取文字編輯器的文字字元數量。
  cch = SendMessage(
    hTextEditor, WM_GETTEXTLENGTH, 0, 0);
  // 獲取字元時,我們是通過 UTF16 格式(WCHAR)獲取,
  // 我們要在最後新增一個空白結尾標誌字元
  buffWStr = (PWCHAR)NewMemory(
    cch * sizeof(WCHAR) + sizeof(WCHAR), hwnd);

  if (buffWStr == NULL) {
    return;
  }
  // 獲取到編輯器的文字
  SendMessage(
    hTextEditor,
    WM_GETTEXT,
    cch + 1, 
    (WPARAM)buffWStr
  );

  // 獲取將文字以 UTF8 格式編碼後所需的記憶體大小(BYTE)
  bSize = WideCharToMultiByte(
    CP_UTF8,
    0,
    buffWStr,
    cch,
    NULL,
    0,
    NULL,
    NULL
  );

  utf8Buff = NewMemory(bSize, hwnd);
  if (utf8Buff == NULL) {
    goto Exit;
  }
  // 將文字格式化到目標快取
  WideCharToMultiByte(
    CP_UTF8,
    0,
    buffWStr,
    cch,
    utf8Buff,
    bSize,
    NULL,
    NULL
  );

  // 將內容覆蓋到目標檔案。
  WriteBytesToFile(
    utf8Buff, bSize, currentFileName, hwnd);

  // 儲存完成之後,設定文字變更標識為 FALSE,
  // 並設定主窗體標題為當前檔案路徑。
  SendMessage(hwnd, WM_SETTEXT, 0, (LPARAM)currentFileName);

  HeapFree(GetProcessHeap(), 0, utf8Buff);
Exit:
  HeapFree(GetProcessHeap(), 0, buffWStr);
}

在第10行,我們新增了呼叫另存為邏輯的程式碼。

另外需要說明的是,由於 SaveFileTo函式呼叫了SaveFile函式,SaveFile 函式也呼叫了 SaveFileTo 函式,由於在C語言中,必須先宣告,才能夠使用,所以需要按照你程式碼的為止,對函式進行提前宣告。

在這裡,我將SaveFileTo函式的實現放到了 SaveFile函式的後面,所以需要在SaveFile之前新增SaveFileTo函式的額宣告,如下:

VOID SaveFileTo(PWSTR fileName, HWND hwnd);

到此為止,執行我們的程式,看看它是否能夠正常工作?

我們先點選另存為,儲存一個新檔案,然後再開啟另一個檔案,然後,程式報異常了。

為什麼?

還記得之前我們處理開啟檔案的邏輯嗎?每次分配記憶體的時候,我們都多分配了一個字元的空間,currentFileName 指向的不是分配記憶體的起始地址。

讓我們看看SaveFileTo 函式的邏輯,發現我們沒有做相同的處理,所以釋放記憶體的時候,報錯了。

讓我們將SaveFileTo的程式碼改成這樣:

VOID SaveFileTo(PWSTR fileName, HWND hwnd) {
  size_t len = lstrlen(fileName);
  int bSize = len * sizeof(WCHAR);
  int appendSuffix = !(
    fileName[len - 4] == '.' &&
    fileName[len - 3] == 't' &&
    fileName[len - 2] == 'x' &&
    fileName[len - 1] == 't');

  if (appendSuffix) {
    bSize += 5 * sizeof(WCHAR);
  }

  if (currentFileName) {
    HeapFree(GetProcessHeap(), 0, currentFileName - 1);
    currentFileName = NULL;
  }

  currentFileName = (PWSTR)NewMemory(bSize + sizeof(WCHAR), hwnd);
  if (!currentFileName) {
    return;
  }
  currentFileName = currentFileName + 1;
  StringCbCopy(currentFileName, bSize, fileName);
  if (appendSuffix) {
    currentFileName[len + 0] = '.';
    currentFileName[len + 1] = 't';
    currentFileName[len + 2] = 'x';
    currentFileName[len + 3] = 't';
    currentFileName[len + 4] = '\0';
  }

  SaveFile(hwnd);
}

再試試?

為什麼第一次儲存之前,文字變化的反應是正確的,一旦呼叫儲存之後,文字變化之後,主窗體的標題沒有變化?

原來是儲存檔案成功之後,沒有更新內容變化標識。修改SaveFile函式,在儲存完成後,新增如下語句:

  textChanged = FALSE;

再試試?終於正常工作了。

十二、整理我們的程式碼,按照功能進行分離

至此,我們已經得到了一個正常工作的基礎編輯器。但所有程式碼合在一起,有些凌亂,讓我們整理下結構。

首先,我們將和編輯功能,窗體顯示功能相關的程式碼,都放到 WinTextEditor.c 中,然後新增一個 InitEnv 函式,在主程式中呼叫該函式以初始化ii能夠。

現在,main.c 中只剩下了主程式,如下:

#include "WinTextEditor.h"

int WINAPI wWinMain(
  _In_ HINSTANCE hInstance,
  _In_opt_ HINSTANCE hPrevInstance,
  _In_ LPWSTR lpCmdLine,
  _In_ int nShowCmd
) {
  MSG msg;
  BOOL fGotMessage = FALSE;

  if (!InitEnv(hInstance, nShowCmd)) {
    return 0;
  }

  while ((fGotMessage = GetMessage(&msg, (HWND)NULL, 0, 0)) != 0
    && fGotMessage != -1)
  {
    TranslateMessage(&msg);
    DispatchMessage(&msg);
  }

  return msg.wParam;
}

在標頭檔案 WinTextEditor.h 中,我們對外宣告瞭一個 InitEnv 函式,其內容如下:

#include <Windows.h>
#include <windowsx.h>
#include <strsafe.h>

#include <stdlib.h>
#include <ShlObj.h>
 
#include "resource.h"

BOOL InitEnv(HINSTANCE hInstance, int nShowCmd);

接下來,按照相同的步驟,分別抽象出,錯誤處理、檔案操作等模組,最終,我們的檔案結構如下:

十三、可能遇到的問題

  • 編譯器警告(等級 1)C4819

這個問題是由於原始碼檔案儲存編碼不是Unicode字符集造成的,當前Visual Studio內沒有合適的配置能夠解決這個問題。
但是,通過測試,可以通過記事本開啟檔案,並將原始碼儲存為帶BOM的UTF8編碼,解決這個問題。

  • 編輯資原始檔的時候,提示錯誤

這個問題,在之前編輯檔案的時候說過了,可以通過在資原始檔中新增字元編碼宣告解決。

最後的最後,歡迎關注公眾號 [程式設計之路漫漫],下次,讓我們不通過使用Win32控制元件,實現一個二進位制編輯器。

碼途求知己,天涯覓一心。

相關文章