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

本文所有代碼,均上傳至github,如果你想直接看源代碼,請到github下載,下載地址://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作為我們的開發環境。

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

也可以到 Visual Studio 官網搜索下載,界面如下:

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

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

//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控件,實現一個二進制編輯器。

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

Tags: