资讯动态

C++手写记事本:Win32 API桌面文本编辑器实战

发布时间:2026/10/17 0:35:52 来源:尧图企业网站定制
简介这是一份面向C初学者与进阶学习者的记事本项目实战源码围绕文本编辑器的完整实现展开帮助读者把文件操作、字符串处理、内存管理等知识点落到可运行的程序中。压缩包共129个文件约3.08MB包含10个cpp源文件与8个头文件构成的核心逻辑配套8个resx资源、6个ico图标及png、gif等界面素材另有sln、vcxproj等工程配置与obj、pdb等编译产物可直接用Visual Studio打开编译运行。内容覆盖fstream文件读写、std::string文本处理、异常捕获、命令行参数解析以及查找、替换、关于等对话框模块的实现思路适合对照源码理解事件驱动与界面交互的组织方式。目前已有1100人学习下载可作为课程设计、C练手项目或文本编辑器二次开发的参考起点。1. 从零拆解一个 C 记事本为什么还有人愿意手写桌面文本编辑器很多人第一次看到“用 C 写记事本”这个项目第一反应是Windows 自带记事本不香吗为什么还要自己造轮子但如果你正在学 C、想搞明白 GUI 程序到底怎么跑起来、或者需要一个能完全掌控代码的轻量文本工具这个项目就是很好的切入点。它本质上是一个基于 C 和 Win32 API 的桌面文本编辑器能完成新建、打开、保存、另存为、查找替换、字体设置、自动换行这些核心操作编译出来就是一个独立的 exe不依赖 .NET 或 Java 运行时。适合谁一是刚学完 C 语法、想找个能看见界面的项目练手的人二是需要二次开发、往编辑器里塞自定义功能比如日志高亮、配置解析的从业者三是想理解 Windows 消息循环、GDI 绘图、文件编码处理这些底层机制的工程师。下面我就按“能跑起来 → 能改得动 → 能避坑”的顺序把这个项目的骨架和血肉拆一遍。2. 环境搭建与 Win32 窗口骨架从空项目到能看见光标2.1 工具链选型为什么是 Visual Studio 而不是 VS Code 裸配这个项目依赖 Windows 原生 API最省事的组合是 Visual Studio 2019 或 2022 社区版安装时勾选“使用 C 的桌面开发”工作负载。如果你用 VS Code MinGW理论上也能编译但资源文件.rc、清单文件、字符集设置容易出玄学问题新手很容易在链接阶段翻车。我一般建议先用 VS 把项目跑通再考虑换构建系统。新建项目时选“Windows 桌面向导”应用程序类型选“桌面应用程序”勾选“空项目”。这样 IDE 会自动帮你配好子系统为 Windows、字符集为 Unicode。如果你手动建 .cpp 文件记得在项目属性里把“链接器 → 系统 → 子系统”改成Windows (/SUBSYSTEM:WINDOWS)否则会报main找不到入口。2.2 最小窗口代码注册类、创建窗口、消息循环Win32 程序的骨架就三件事注册窗口类、创建窗口、跑消息循环。下面是最小可运行版本我加了注释说明每个参数的作用。#include windows.h // 窗口过程处理所有发到这个窗口的消息 LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); // 发送 WM_QUIT结束消息循环 return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); TextOut(hdc, 10, 10, LHello, Notepad, 14); EndPaint(hwnd, ps); return 0; } } return DefWindowProc(hwnd, msg, wParam, lParam); // 默认处理 } int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int nCmdShow) { const wchar_t CLASS_NAME[] LNotepadWindowClass; WNDCLASS wc {}; wc.lpfnWndProc WndProc; // 消息处理函数 wc.hInstance hInstance; // 当前实例句柄 wc.lpszClassName CLASS_NAME; // 类名创建窗口时要用 wc.hCursor LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); RegisterClass(wc); HWND hwnd CreateWindowEx( 0, CLASS_NAME, L我的记事本, WS_OVERLAPPEDWINDOW, // 带标题栏、边框、最大最小化 CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, nullptr, nullptr, hInstance, nullptr ); ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg {}; while (GetMessage(msg, nullptr, 0, 0)) { // 消息循环 TranslateMessage(msg); DispatchMessage(msg); } return 0; }逻辑说明wWinMain是 Unicode 入口WNDCLASS里的lpfnWndProc指定了窗口过程函数所有鼠标、键盘、重绘消息都会进到WndProc。CreateWindowEx的第四个参数WS_OVERLAPPEDWINDOW是标准窗口样式如果你想要无边框或固定大小改这里。消息循环里GetMessage返回 0 时退出TranslateMessage负责把按键消息转成字符消息DispatchMessage把消息派发给窗口过程。参数注意hbrBackground用COLOR_WINDOW 1是系统色如果你想要自定义背景色得自己创建画刷。nCmdShow控制窗口初始显示方式SW_SHOWMAXIMIZED可以最大化启动。2.3 把 EDIT 控件嵌进去一行代码换来完整编辑能力自己用 GDI 画文本、处理光标和选区工作量巨大且容易出 bug。Win32 提供了一个现成的EDIT控件类支持多行、滚动条、复制粘贴、撤销。你只需要在WM_CREATE里创建它并让它填满客户区。#define ID_EDIT 1001 // 在 WndProc 的 WM_CREATE 分支里 case WM_CREATE: { HWND hEdit CreateWindowEx( WS_EX_CLIENTEDGE, // 凹陷边框 LEDIT, nullptr, WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL, 0, 0, 0, 0, hwnd, (HMENU)ID_EDIT, GetModuleHandle(nullptr), nullptr ); // 设置默认字体否则中文可能显示成方块 HFONT hFont CreateFont(20, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, LConsolas); SendMessage(hEdit, WM_SETFONT, (WPARAM)hFont, TRUE); return 0; } // 在 WM_SIZE 里让编辑框跟随窗口大小 case WM_SIZE: { HWND hEdit GetDlgItem(hwnd, ID_EDIT); RECT rc; GetClientRect(hwnd, rc); SetWindowPos(hEdit, nullptr, 0, 0, rc.right, rc.bottom, SWP_NOZORDER); return 0; }逻辑说明ES_MULTILINE让编辑框支持多行ES_AUTOVSCROLL和ES_AUTOHSCROLL让内容超出时自动出现滚动条。WM_SETFONT设置字体CreateFont里的DEFAULT_CHARSET保证中文能正常显示。WM_SIZE里用GetClientRect拿到客户区大小然后SetWindowPos把编辑框撑满。参数坑点CreateWindowEx的hMenu参数传了(HMENU)ID_EDIT这是子控件 ID后面用GetDlgItem取句柄时要用同一个值。如果你忘了设WS_VISIBLE编辑框不会显示。字体对象记得在程序退出时DeleteObject否则有 GDI 对象泄漏。3. 文件读写与编码处理为什么你的中文保存后变乱码3.1 打开与保存用 GetOpenFileName 和 WriteFile 走通流程菜单栏和快捷键是记事本的标配。先加菜单资源或者在WM_CREATE里用CreateMenu动态创建。打开文件用GetOpenFileName弹出系统对话框保存用GetSaveFileName。拿到路径后用CreateFile、ReadFile、WriteFile读写。void OpenFile(HWND hwnd, HWND hEdit) { OPENFILENAME ofn {}; wchar_t szFile[MAX_PATH] {}; ofn.lStructSize sizeof(ofn); ofn.hwndOwner hwnd; ofn.lpstrFilter L文本文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0; ofn.lpstrFile szFile; ofn.nMaxFile MAX_PATH; ofn.Flags OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST; if (!GetOpenFileName(ofn)) return; HANDLE hFile CreateFile(szFile, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile INVALID_HANDLE_VALUE) return; DWORD size GetFileSize(hFile, nullptr); char* buf new char[size 1]; DWORD read 0; ReadFile(hFile, buf, size, read, nullptr); buf[read] \0; CloseHandle(hFile); // 转成宽字符再塞进编辑框 int wlen MultiByteToWideChar(CP_UTF8, 0, buf, -1, nullptr, 0); wchar_t* wbuf new wchar_t[wlen]; MultiByteToWideChar(CP_UTF8, 0, buf, -1, wbuf, wlen); SetWindowText(hEdit, wbuf); delete[] buf; delete[] wbuf; }逻辑说明OPENFILENAME结构体必须把lStructSize设对否则对话框弹不出来。lpstrFilter是双\0结尾的字符串对最后再加一个\0。ReadFile读完后手动补\0然后用MultiByteToWideChar把 UTF-8 转成 UTF-16因为EDIT控件在 Unicode 环境下用的是宽字符。参数注意CP_UTF8是代码页如果你保存的文件是 GBK这里要改成CP_ACP。MultiByteToWideChar第一次调用传nullptr拿长度第二次才真正转换。SetWindowText会替换编辑框全部内容如果你只想追加用EM_SETSEL把光标移到末尾再EM_REPLACESEL。3.2 编码选择UTF-8 带 BOM 还是不带这是血泪经验最集中的地方。Windows 记事本默认保存为 UTF-8 with BOM但很多现代编辑器VS Code、Sublime默认不带 BOM。你的程序如果统一按 UTF-8 无 BOM 处理打开带 BOM 的文件时开头会多出\xEF\xBB\xBF三个字节显示成乱码。解决办法是在读取后检测前三个字节如果是 BOM 就跳过。// 在 ReadFile 之后、转换之前 int offset 0; if (read 3 (unsigned char)buf[0] 0xEF (unsigned char)buf[1] 0xBB (unsigned char)buf[2] 0xBF) { offset 3; // 跳过 UTF-8 BOM } int wlen MultiByteToWideChar(CP_UTF8, 0, buf offset, -1, nullptr, 0);保存时也要决定写不写 BOM。如果你希望兼容旧版记事本就写 BOM如果面向开发者不写更通用。我一般会在保存对话框里加一个选项或者根据文件扩展名判断。3.3 换行符统一CRLF 与 LF 的互转Windows 用\r\nUnix 用\n。你的编辑器如果只按\r\n处理打开 Unix 文件时可能显示成一行。EDIT控件本身能识别\r\n但对单独的\n支持不好。常见做法是在读取后把\n统一替换成\r\n保存时再根据用户选择转回去。// 把单独的 \n 转成 \r\n注意不要重复转换已有的 \r\n std::wstring NormalizeNewlines(const std::wstring input) { std::wstring output; for (size_t i 0; i input.size(); i) { if (input[i] L\n (i 0 || input[i-1] ! L\r)) { output L\r\n; } else { output input[i]; } } return output; }逻辑说明遍历每个字符遇到\n且前一个不是\r时补一个\r。这样已有的\r\n不会被破坏。保存时反向操作把\r\n替换成\n即可。4. 查找替换与字体设置让编辑器从能用变成好用4.1 查找替换对话框用 FindText 和 ReplaceText 消息EDIT控件内置了查找和替换的消息不需要自己遍历文本。EM_FINDTEXTEX可以指定起始位置和方向EM_REPLACESEL替换选中内容。void FindNext(HWND hEdit, const wchar_t* text) { FINDTEXTEX ft {}; ft.chrg.cpMin 0; // 从开头找实际应该从当前光标位置 ft.chrg.cpMax -1; ft.lpstrText text; // 先获取当前选区末尾作为起点 DWORD start, end; SendMessage(hEdit, EM_GETSEL, (WPARAM)start, (LPARAM)end); ft.chrg.cpMin end; long pos SendMessage(hEdit, EM_FINDTEXTEX, FR_DOWN, (LPARAM)ft); if (pos ! -1) { SendMessage(hEdit, EM_SETSEL, ft.chrgText.cpMin, ft.chrgText.cpMax); SendMessage(hEdit, EM_SCROLLCARET, 0, 0); // 滚动到可见 } else { MessageBox(nullptr, L找不到指定内容, L提示, MB_OK); } }逻辑说明EM_GETSEL拿到当前选区起止cpMin设为选区末尾实现“查找下一个”。EM_FINDTEXTEX返回匹配位置chrgText里是匹配到的范围。EM_SETSEL选中匹配文本EM_SCROLLCARET滚动到光标处。参数注意FR_DOWN表示向下查找FR_MATCHCASE区分大小写FR_WHOLEWORD全词匹配。如果你要做替换在选中后调用EM_REPLACESEL传入替换文本即可。4.2 字体设置ChooseFont 对话框与实时预览字体设置用ChooseFont对话框拿到LOGFONT后创建HFONT再WM_SETFONT给编辑框。void SetFont(HWND hwnd, HWND hEdit) { CHOOSEFONT cf {}; LOGFONT lf {}; cf.lStructSize sizeof(cf); cf.hwndOwner hwnd; cf.lpLogFont lf; cf.Flags CF_SCREENFONTS | CF_INITTOLOGFONTSTRUCT; if (ChooseFont(cf)) { HFONT hFont CreateFontIndirect(lf); SendMessage(hEdit, WM_SETFONT, (WPARAM)hFont, TRUE); // 注意旧字体对象要保存并在退出时删除 } }逻辑说明CF_INITTOLOGFONTSTRUCT让对话框用当前lf初始化CF_SCREENFONTS只显示屏幕字体。CreateFontIndirect根据LOGFONT创建字体对象。每次换字体都会创建新对象旧的要DeleteObject否则 GDI 泄漏。4.3 自动换行与状态栏小功能背后的消息处理自动换行在EDIT控件里通过切换WS_HSCROLL和ES_AUTOHSCROLL实现。关闭自动换行时加上WS_HSCROLL打开时去掉。void ToggleWordWrap(HWND hEdit, bool enable) { LONG_PTR style GetWindowLongPtr(hEdit, GWL_STYLE); if (enable) { style ~WS_HSCROLL; style ~ES_AUTOHSCROLL; } else { style | WS_HSCROLL; style | ES_AUTOHSCROLL; } SetWindowLongPtr(hEdit, GWL_STYLE, style); // 强制重绘 SetWindowPos(hEdit, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); }逻辑说明GetWindowLongPtr拿到当前样式按位操作修改再SetWindowLongPtr写回。SWP_FRAMECHANGED让样式变更立即生效。状态栏可以用CreateStatusWindow或自己创建STATUSCLASSNAME控件显示行列号和字符数。行列号通过EM_LINEFROMCHAR和EM_LINEINDEX计算。5. 避坑与排查编译链接和运行时的五个高频翻车点5.1 现象编译报错LNK2019: 无法解析的外部符号 wWinMain原因项目子系统设成了控制台或者入口函数写成了main而不是wWinMain。解决项目属性 → 链接器 → 系统 → 子系统改为Windows (/SUBSYSTEM:WINDOWS)入口点确认是wWinMain如果是WinMain则字符集要改成多字节。5.2 现象编辑框里中文显示成方块或问号原因字体不支持中文或者字符集设置不对。解决CreateFont时DEFAULT_CHARSET不要改成ANSI_CHARSET字体名用LMicrosoft YaHei或LSimSun项目字符集设为 Unicode。5.3 现象打开文件后内容为空或只有一半原因ReadFile的size参数用了GetFileSize但没处理大文件或者MultiByteToWideChar的缓冲区长度算错。解决GetFileSize返回DWORD超过 4GB 要分块读MultiByteToWideChar第一次调用拿长度时-1表示包含结尾\0分配时记得加一。5.4 现象保存后文件末尾多出乱码字符原因WriteFile写入的长度包含了未初始化的缓冲区内容。解决写入长度用wcslen或实际字符数乘以sizeof(wchar_t)不要用sizeof(buffer)。5.5 现象程序运行一段时间后界面卡死或崩溃原因GDI 对象泄漏每次CreateFont、CreateBrush后没有DeleteObject。解决把字体、画刷句柄保存为全局或类成员在WM_DESTROY里统一删除用任务管理器看 GDI 对象数是否持续增长。6. 进阶技巧用子类化给编辑框加自定义快捷键和日志高亮子类化Subclassing是 Win32 里非常实用的技巧你可以替换编辑框的窗口过程在它处理消息之前拦截。比如实现CtrlShiftS另存为、CtrlF查找或者对日志文件做关键字高亮。WNDPROC g_oldEditProc nullptr; LRESULT CALLBACK EditSubclassProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg WM_KEYDOWN (GetKeyState(VK_CONTROL) 0x8000)) { if (wParam S) { // 触发保存 SendMessage(GetParent(hwnd), WM_COMMAND, ID_FILE_SAVE, 0); return 0; // 拦截不再传递 } if (wParam F) { // 弹出查找对话框 return 0; } } return CallWindowProc(g_oldEditProc, hwnd, msg, wParam, lParam); } // 在 WM_CREATE 里子类化 g_oldEditProc (WNDPROC)SetWindowLongPtr(hEdit, GWLP_WNDPROC, (LONG_PTR)EditSubclassProc);逻辑说明SetWindowLongPtr把编辑框的窗口过程替换成EditSubclassProc原来的过程保存在g_oldEditProc。在子类过程里拦截WM_KEYDOWN判断Ctrl是否按下。处理完返回 0 表示消息不再传递否则调用CallWindowProc交给原过程。参数注意GetKeyState(VK_CONTROL)返回值的最高位为 1 表示按下。子类化后记得在WM_DESTROY里恢复原过程否则可能影响其他控件。高亮的话可以在WM_PAINT之后用EM_SETCHARFORMAT设置选中文本的颜色但EDIT控件对富文本支持有限复杂高亮建议换RichEdit控件。从那以后我每次写 Win32 程序都会先把 GDI 对象计数和消息循环的退出条件检查一遍再动手加功能。这个记事本项目虽然小但把窗口、控件、文件、编码、子类化串起来足够把 C 桌面开发的核心链路走通。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑