Windows 的 MFC 是什么 —— 维护现有资产所需的基础知识

· · Windows, MFC, VisualC++, Cpp, Win32, NativeApp, DesktopApp, LegacyCode, 现有资产利用

1. 首先需要掌握的内容

在维护 Windows 老旧桌面应用程序时,经常会遇到下面这些名字。

CWinApp
CWnd
CDialog
CDialogEx
CFrameWnd
CDocument
CView
CString
CFile
CArchive
BEGIN_MESSAGE_MAP
ON_COMMAND
ON_BN_CLICKED
DoDataExchange
UpdateData

这些名字经常出现在一个名为 MFC 的 C++ Windows 应用程序框架中。MFC 是 Microsoft Foundation Classes 的缩写,是一个让 Win32 API 更容易以 C++ 类的形式使用的库。

在如今的 Windows 应用开发中,WinUI、WPF、Windows Forms、Electron、Qt、Web 技术等选择越来越多,把 MFC 作为新项目首选的场景已经减少。但它并不是已经消失的技术。在业务应用程序、测量设备、控制软件、CAD/CAM、内部工具,以及历史悠久的套装软件中,仍然有不少场景需要维护 MFC 代码库。

在理解 MFC 之前,先列出几个应该具备的视角。

MFC是解读老旧Windows桌面应用的重要地图
不要把MFC当成隐藏Win32 API的黑箱,而应视为对其做了C++风格的封装
不了解MFC的作法,就很容易比表面代码看到的还严重地误读程序行为
MFC的价值更多体现在维护、延续和逐步迁移现有资产,而非新项目采用

本文将整理 MFC 的概要、应用程序结构、消息映射、Document/View、对话框、DDX/DDV、资源、构建,以及维护时的注意事项。

另外,本文中出现的代码片段,已按章节整理为文件并作为参考代码集发布在 GitHub 上。

windows-mfc-overview - komurasoft-blog-samples (GitHub)

2. 什么是 MFC

MFC 是一套用于以 C++ 编写 Windows 原生桌面应用程序的类库。

如果直接使用 Win32 API,通常会写出这样的代码。

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_PAINT:
        // 绘制处理
        break;
    case WM_DESTROY:
        PostQuitMessage(0);
        break;
    default:
        return DefWindowProc(hWnd, message, wParam, lParam);
    }
    return 0;
}

Win32 API 非常强大,但它以 C 函数、句柄、消息和回调为核心构建,在大型应用程序中很容易变得难以把握全貌。MFC 让这些内容能够以 C++ 类的形式来处理:窗口用 CWnd 表示,对话框用 CDialog 表示,整个应用程序用 CWinApp 表示,框架窗口用 CFrameWnd 表示,视图用 CView 表示。

class CMainFrame : public CFrameWnd
{
public:
    CMainFrame();

protected:
    afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct);
    DECLARE_MESSAGE_MAP()
};

MFC 并不是把 Win32 API 完全替换成另一套东西,而是一个以 Win32 API 的思路为基础、虽然轻薄但覆盖面很广的 C++ 框架。因此,要读懂 MFC,不能只了解 MFC 的类,还需要具备下面这些方面的知识。

Windows消息
HWND等句柄
GDI/GDI+
资源文件
COM/OLE
DLL与运行时
字符编码
线程与消息循环

与其说 MFC 是「不了解 Windows 也能写代码的魔法库」,不如说它的真实面貌是「把 Windows 的机制用 C++ 的类型和框架加以整理」。

3. MFC 现在还能用吗

MFC 目前仍可以在 Visual Studio 中使用。不过,不能误解它的定位。虽然目前仍在获得支持,但它并不是那种会持续积极添加新功能的最新 UI 框架;微软的 MFC 文档中也说明,MFC 会继续得到支持,但不会再新增功能,也不会再更新文档。

因此,MFC 的定位大致可以概括如下。

维护现有MFC应用              -> 现实中很常见
为现有MFC应用追加功能        -> 是可行的选择
更新MFC应用的构建环境        -> 非常重要
从MFC逐步迁移到其他UI技术    -> 是可行的选择
在全新的普通GUI应用中采用    -> 需要谨慎判断

尤其是在长期使用的业务应用程序中,UI、打印、文件输入输出、设备控制、自定义协议、COM 集成等功能常常都汇集在 MFC 之中。

对于这类代码库,与「抛弃 MFC」相比,更应该先做到「能够读懂 MFC」。

4. MFC 擅长的领域

MFC 一直被使用的代表性领域,是 Windows 的原生桌面应用程序。

具体来说,包括以下这些应用程序。

以对话框为中心的业务工具
打开文件进行编辑的SDI应用
处理多个文档的MDI应用
测量设备或制造装置的控制画面
CAD/CAM类原生应用
大量使用打印或预览的应用
包含ActiveX或OLE集成的应用
与老旧Windows API或COM资产紧密结合的应用

MFC 的优势在于,它能在贴近 Windows 原生组件的层面上运行。窗口、菜单、工具栏、状态栏、对话框、通用控件、打印、文件对话框、注册表、GDI 绘制等,都可以作为 C++ 类来处理。

另一方面,MFC 的弱点是,现代化的 UI 构建、数据绑定、可测试性、异步处理、现代布局、高 DPI 支持、多语言化、无障碍支持等,都不如近年出现的框架那样自然易写。

把特点列出来,大致如下。

贴近Windows原生
可以用C++直接控制
拥有大量现有资产
需要Win32相关知识
残留大量旧式写法
需要自行调整才能形成易于测试的结构

5. 在 Visual Studio 中准备使用 MFC

即使在 Visual Studio 中安装了 C++,也不一定就自动装上了 MFC。因为 MFC 是作为 Visual Studio Installer 的单个组件来处理的。通常需要确认以下这些组件。

使用C++的桌面开发
MSVC v143 - VS 2022 C++ x64/x86 build tools
Windows SDK
C++ MFC for latest v143 build tools
C++ ATL for latest v143 build tools
是否需要Spectre Mitigations版的MFC

如果在构建时找不到 MFC 相关文件,除了检查项目设置外,还要确认 Visual Studio 是否安装了 MFC 组件。

CI 环境或构建服务器上也是同理:如果本机的 Visual Studio 能够构建,但 CI 却失败,原因可能是 MFC 组件缺失,或目标工具集版本不一致。

6. MFC 应用程序的基本结构

MFC 应用程序大致具有以下结构。

CWinApp派生类
  负责整个应用程序的初始化与结束

CFrameWnd / CMDIFrameWnd / CDialog派生类
  负责主窗口或对话框

CView派生类
  负责画面显示与用户操作

CDocument派生类
  负责数据与文件保存

资源文件
  保存菜单、对话框、图标、字符串等

消息映射
  将Windows消息或命令关联到处理函数

例如,在一个简单的 MFC 应用中,会出现这样的 CWinApp 派生类。

class CMyApp : public CWinApp
{
public:
    virtual BOOL InitInstance();
};

CMyApp theApp;

BOOL CMyApp::InitInstance()
{
    CWinApp::InitInstance();

    CMainFrame* pFrame = new CMainFrame;
    m_pMainWnd = pFrame;

    pFrame->Create(nullptr, _T("My MFC Application"));
    pFrame->ShowWindow(SW_SHOW);
    pFrame->UpdateWindow();

    return TRUE;
}

CWinApp 是代表整个应用程序的类。 在 MFC 应用程序中,通常存在一个从 CWinApp 派生出来的对象。

theApp 这样的全局对象,第一次看到可能会觉得不太自然,但在 MFC 中这是标准的结构。

7. CWinApp 在做什么

CWinApp 是 MFC 应用程序入口处的重要角色。在普通的 Win32 应用程序中,需要自己编写 WinMain、窗口类注册、消息循环等内容,而在 MFC 中,这些大多由框架承担。开发者主要通过重写 InitInstance 来编写应用程序特有的初始化逻辑。

BOOL CMyApp::InitInstance()
{
    CWinApp::InitInstance();

    // 加载设置
    // 初始化COM
    // 创建主窗口
    // 注册文档模板

    return TRUE;
}

InitInstance 中常见的处理内容如下。

初始化通用控件
设置注册表键
读取最近使用的文件列表
注册文档模板
创建主框架
处理命令行参数
初始化COM/OLE

维护时,先确认 CWinApp 派生类,可以更容易看清整个应用程序的启动顺序。

8. CWnd 是 MFC 的核心类

MFC 中许多 UI 类,都以代表 Windows 窗口的 CWnd 为基类。不过,CWnd 对象和 HWND 并不是同一个东西。

HWND
  Windows操作系统管理的窗口句柄

CWnd
  用于方便操作HWND的C++封装对象

在 MFC 中,CWnd 内部持有一个 HWND

HWND hWnd = m_hWnd;

或者,也可以这样获取。

HWND hWnd = GetSafeHwnd();

维护时需要特别注意的是,即使 CWnd* 对象存在,对应的 HWND 也可能已经被销毁。

因此,判断窗口是否有效时,通常会这样写。

if (pWnd != nullptr && ::IsWindow(pWnd->GetSafeHwnd()))
{
    pWnd->ShowWindow(SW_SHOW);
}

在排查 MFC 的 bug 时,重要的是要检查 CWnd 这个 C++ 对象的生命周期,与实际 Windows 窗口句柄的生命周期是否发生了错位。

9. 什么是消息映射

最能体现 MFC 特色的机制之一,就是消息映射

Windows 应用程序会以 Windows 消息的形式接收鼠标点击、按键输入、重绘、窗口尺寸变化、菜单选择等事件。

在 Win32 API 中,通常在 WndProcswitch 语句中处理消息。

在 MFC 中,则通过消息映射把这些消息关联到处理函数上。

BEGIN_MESSAGE_MAP(CMyDialog, CDialogEx)
    ON_BN_CLICKED(IDC_BUTTON_OK, &CMyDialog::OnClickedButtonOk)
    ON_WM_CLOSE()
END_MESSAGE_MAP()

void CMyDialog::OnClickedButtonOk()
{
    AfxMessageBox(_T("Clicked"));
}

这段代码的含义是这样的。

名为IDC_BUTTON_OK的按钮被点击时
调用CMyDialog::OnClickedButtonOk

不熟悉 MFC 的人往往很难看出某个函数是从哪里被调用的。如果搜索代码却找不到直接调用,就应该去看消息映射。

函数没有被直接调用
但事件发生时确实会执行
-> 需要确认BEGIN_MESSAGE_MAP / ON_...宏

在 MFC 的代码审查中,不能只看处理函数本身,而应该结合消息映射一起确认,这一点非常重要。

10. 命令路由

在 MFC 中,菜单和工具栏的操作也会被当作命令来处理。

其中最具代表性的就是 ON_COMMAND

BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd)
    ON_COMMAND(ID_FILE_OPEN, &CMainFrame::OnFileOpen)
END_MESSAGE_MAP()

void CMainFrame::OnFileOpen()
{
    // 打开文件的处理
}

MFC 内置了将命令派发给合适对象的机制。

例如,同样是 ID_EDIT_COPY,实际由当前活动的视图、文档、框架还是应用程序来处理,情况可能会不同。

活动视图
文档
框架窗口
应用程序

命令会按照这样的顺序,传递给能够处理它的对象。

因此,在 MFC 中,「点击菜单后会调用哪个函数」有时很难仅凭简单的字符串搜索来追踪。

维护时需要关注的要点如下。

命令ID是什么
ON_COMMAND位于哪个类中
ON_UPDATE_COMMAND_UI位于哪里
当前活动的视图是哪一个
是否使用了Document/View结构

11. ON_UPDATE_COMMAND_UI 是什么

在 MFC 中,有时会使用 ON_UPDATE_COMMAND_UI 来更新菜单项或工具栏按钮的启用/禁用状态、选中状态、显示文本等。

BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd)
    ON_COMMAND(ID_EDIT_DELETE, &CMainFrame::OnEditDelete)
    ON_UPDATE_COMMAND_UI(ID_EDIT_DELETE, &CMainFrame::OnUpdateEditDelete)
END_MESSAGE_MAP()

void CMainFrame::OnUpdateEditDelete(CCmdUI* pCmdUI)
{
    pCmdUI->Enable(CanDeleteCurrentItem());
}

这样一来,就可以只在可以删除的状态下才启用相应的菜单或按钮。

在使用 MFC 应用程序时,如果遇到按钮莫名其妙变灰、菜单无法点击、选中状态发生变化等现象,去查找 ON_UPDATE_COMMAND_UI 往往能够找到原因。

12. 基于对话框的 MFC 应用

MFC 中最容易理解的一种形式,就是基于对话框的应用程序。

设置界面、简单的业务工具、设备操作界面等,有时会以对话框为中心构成。

典型情况下,会继承 CDialogCDialogEx

class CSettingsDialog : public CDialogEx
{
public:
    CSettingsDialog(CWnd* pParent = nullptr);

#ifdef AFX_DESIGN_TIME
    enum { IDD = IDD_SETTINGS_DIALOG };
#endif

protected:
    virtual void DoDataExchange(CDataExchange* pDX);
    virtual BOOL OnInitDialog();

    afx_msg void OnBnClickedOk();
    DECLARE_MESSAGE_MAP()

private:
    CString m_name;
    int m_interval;
};

在基于对话框的代码中,经常会出现以下这些要素。

IDD_...        对话框资源ID
IDC_...        控件ID
OnInitDialog   初始化处理
DoDataExchange 控件与成员变量的关联
UpdateData     画面与变量的同步
ON_BN_CLICKED  按钮点击处理

对话框并不只是外观,而是由资源、成员变量、消息映射、初始化处理组合在一起共同运作的。

13. DDX 与 DDV

在 MFC 的对话框中经常出现的,是 DDXDDV

DDX = Dialog Data Exchange
DDV = Dialog Data Validation

DDX 是把对话框上的控件与 C++ 成员变量对应起来的机制,DDV 则是验证输入值的机制。

void CSettingsDialog::DoDataExchange(CDataExchange* pDX)
{
    CDialogEx::DoDataExchange(pDX);
    DDX_Text(pDX, IDC_EDIT_NAME, m_name);
    DDX_Text(pDX, IDC_EDIT_INTERVAL, m_interval);
    DDV_MinMaxInt(pDX, m_interval, 1, 3600);
}

调用 UpdateData(TRUE) 后,界面上的输入值会反映到成员变量中。

void CSettingsDialog::OnBnClickedOk()
{
    if (!UpdateData(TRUE))
    {
        return;
    }

    // 此时m_name和m_interval中已经是画面输入的值
    SaveSettings(m_name, m_interval);

    CDialogEx::OnOK();
}

反过来,调用 UpdateData(FALSE) 后,成员变量的值会反映到界面上。

BOOL CSettingsDialog::OnInitDialog()
{
    CDialogEx::OnInitDialog();

    m_name = _T("default");
    m_interval = 60;
    UpdateData(FALSE);

    return TRUE;
}

当 MFC 对话框的输入值出现异常时,可以按以下几点进行确认。

DoDataExchange中是否定义了DDX
是否调用了UpdateData(TRUE)
UpdateData(FALSE)的调用时机是否正确
DDV是否拒绝了输入
控件ID与资源是否一致

14. Document/View 架构

MFC 的一大特色,就是 Document/View 架构

这是一种把应用程序处理的数据与其显示方式分离开来的结构。

CDocument
  保存数据
  负责文件的读写
  向多个视图发出更新通知

CView
  显示数据
  处理用户操作
  管理绘制与选中状态

例如,在文本编辑器、图形编辑器、配置文件编辑工具、类似 CAD 的应用中,把数据与显示分离是有意义的。

class CMyDocument : public CDocument
{
public:
    std::vector<Item> m_items;

    virtual BOOL OnOpenDocument(LPCTSTR lpszPathName);
    virtual BOOL OnSaveDocument(LPCTSTR lpszPathName);
};

class CMyView : public CView
{
protected:
    virtual void OnDraw(CDC* pDC);

    CMyDocument* GetDocument() const;
};

视图一侧会获取文档并进行绘制。

void CMyView::OnDraw(CDC* pDC)
{
    CMyDocument* pDoc = GetDocument();
    if (pDoc == nullptr)
    {
        return;
    }

    for (const auto& item : pDoc->m_items)
    {
        // 使用pDC进行绘制
    }
}

Document/View 的优点在于,同一份数据可以方便地展示在多个视图中。

例如,同一份数据可以用以下方式来呈现。

表格视图
图表视图
详细信息视图
预览
打印视图

不过,如果在简单的设置界面或小工具中使用 Document/View,反而会让结构显得沉重。

维护时,先判断这个应用是否使用了 Document/View,还是以对话框为中心,可以让代码更容易追踪。

15. SDI 与 MDI

在 MFC 中,经常会与 Document/View 组合出现的,还有 SDI 和 MDI 这两种结构。

SDI = Single Document Interface
MDI = Multiple Document Interface

SDI 基本上是用一个框架处理一个文档的形式。

主窗口
  1个文档
  1个或多个视图

MDI 则是在一个父窗口中包含多个子窗口,每个子窗口分别处理一个文档。

MDI父框架
  MDI子框架1 -> 文档1
  MDI子框架2 -> 文档2
  MDI子框架3 -> 文档3

在老旧的 Windows 应用程序中,MDI 曾经被广泛使用。

维护时,只需看类名就能大致判断结构。

CFrameWnd       SDI类框架
CMDIFrameWnd    MDI父框架
CMDIChildWnd    MDI子框架
CSingleDocTemplate SDI用文档模板
CMultiDocTemplate  MDI用文档模板

在由 MFC 向导生成的应用中,InitInstance 里通常会有 CSingleDocTemplateCMultiDocTemplate 的注册处理。

16. 理解资源文件

在 MFC 应用中,.rc 文件非常重要。

.rc 是 Windows 的资源文件。

其中定义了以下这些内容。

对话框模板
菜单
加速键
图标
位图
字符串表
版本信息
工具栏

此外,resource.h 中定义了资源 ID。

#define IDD_SETTINGS_DIALOG  101
#define IDC_EDIT_NAME        1001
#define IDC_EDIT_INTERVAL    1002
#define ID_FILE_OPEN         32771

MFC 的代码通过这些 ID,把资源和 C++ 代码关联起来。

DDX_Text(pDX, IDC_EDIT_NAME, m_name);
ON_COMMAND(ID_FILE_OPEN, &CMainFrame::OnFileOpen)

维护时常见的问题是资源 ID 不一致。

resource.h中的ID发生了变化
不同分支合并时ID发生冲突
对话框上控件的ID与DDX的ID不一致
本应删除的菜单ID仍然残留
字符串表的ID出现重复

调查 MFC 应用的行为时,不能只看 C++ 代码,还需要同时查看 .rcresource.h

17. Class Wizard 与手写代码

MFC 与 Visual Studio 的 Class Wizard 有着很深的历史渊源。

使用 Class Wizard,可以自动生成消息处理程序、DDX 变量、虚函数重写等内容。

因此,MFC 代码中往往残留着大量由工具生成的代码形式。

//{{AFX_DATA(CSettingsDialog)
//}}AFX_DATA

//{{AFX_MSG(CSettingsDialog)
//}}AFX_MSG

在较新的 Visual Studio 中,界面表现和生成的代码形式可能有所变化,但在老旧的代码库中,仍会残留这类注释标记。

维护时重要的是,不要粗暴地破坏生成代码与手写代码之间的边界。

不要删除消息映射
不要破坏DDX的对应关系
不要随意更改资源ID
不要轻易删除旧Class Wizard相关的注释

在 MFC 中,仅仅让 C++ 代码能够编译通过是不够的,还需要在一定程度上保持 Visual Studio 资源编辑器和 Class Wizard 所期望的形式。

18. CString 与字符串

在 MFC 中频繁出现的字符串类是 CString

CString name = _T("Komura");
CString message;
message.Format(_T("Hello, %s"), name.GetString());

CString 是 MFC/ATL 系代码中常用的可变长度字符串类。现代 C++ 中更多使用 std::stringstd::wstring,但在 MFC 中,出于与 API 及控件的兼容性考虑,CString 被大量使用。

维护时需要注意字符编码问题。

CString      根据项目设置对应CStringA或CStringW
CStringA     ANSI / MBCS系
CStringW     Unicode / UTF-16系
LPCTSTR      基于TCHAR的字符串指针
LPCSTR       char系
LPCWSTR      wchar_t系
std::string  通常是char系
std::wstring wchar_t系

目前的 Windows 应用基本上以 Unicode 为前提会更安全,但在老旧的 MFC 应用中,仍可能残留以 MBCS 为前提的处理逻辑。

CString text = _T("中文");
std::wstring ws(text.GetString());

字符串转换引发的 bug,在 MFC 应用的维护中很常见。

尤其要注意以下这些情况。

读取以Shift_JIS为前提的文件
切换到Unicode构建
外部DLL要求char*
COM要求BSTR
安易地转换为std::string导致乱码

看到 CString 时,不应该只把它当作「一个老旧的字符串类」,而应该结合项目的字符集设置、外部 API、文件格式一起确认,这一点很重要。

19. CFile 与 CArchive

MFC 中还提供了用于文件操作和序列化的类,代表性的是 CFileCArchive

CFile file;
if (file.Open(path, CFile::modeRead))
{
    CArchive ar(&file, CArchive::load);
    // 从ar中读取
}

CArchive 常用于 MFC 的序列化机制中。

CDocument 派生类中,有时会重写 Serialize,把读取和保存写在同一个函数里。

void CMyDocument::Serialize(CArchive& ar)
{
    if (ar.IsStoring())
    {
        ar << m_title;
        ar << static_cast<int>(m_items.size());
        for (const auto& item : m_items)
        {
            ar << item.Name;
            ar << item.Value;
        }
    }
    else
    {
        int count = 0;
        ar >> m_title;
        ar >> count;
        m_items.clear();
        for (int i = 0; i < count; ++i)
        {
            Item item;
            ar >> item.Name;
            ar >> item.Value;
            m_items.push_back(item);
        }
    }
}

MFC 的序列化机制很方便,但在长期运行中需要注意以下几点。

与旧文件格式的兼容性
版本号的管理
读取失败时的恢复
异常处理
字符编码
字节序
是否直接保存了结构体本身

对于长期使用自有二进制格式的 MFC 应用,Serialize 有时实际上就等同于文件规格本身。

在这种情况下,修改代码之前,务必先准备好用于读取现有文件的测试数据。

20. GDI 绘制与 CDC

在 MFC 的画面绘制中,经常使用用于处理 Windows Device Context 的 CDC 类。在 CView::OnDraw 中,会以参数形式传入 CDC*

void CMyView::OnDraw(CDC* pDC)
{
    pDC->TextOut(10, 10, _T("Hello MFC"));
    pDC->Rectangle(10, 40, 200, 120);
}

使用画笔或画刷时,需要注意选择与恢复。

void CMyView::OnDraw(CDC* pDC)
{
    CPen pen(PS_SOLID, 1, RGB(0, 0, 0));
    CPen* pOldPen = pDC->SelectObject(&pen);

    pDC->MoveTo(10, 10);
    pDC->LineTo(100, 100);

    pDC->SelectObject(pOldPen);
}

在 GDI 对象方面,以下这些失误往往会引发问题。

SelectObject之后没有恢复为原来的对象
创建了大量GDI对象却没有释放
混淆了OnPaint与OnDraw的职责
没有做双缓冲导致闪烁
高DPI下以固定像素为前提的绘制发生错位

在排查 MFC 的绘制 bug 时,不能只看 C++ 逻辑,还需要确认 Windows 的 GDI 资源、重绘时机、DPI、字体大小等因素。

21. 模态对话框与非模态对话框

在 MFC 中,对话框的显示方式也需要注意。

模态对话框通过 DoModal 来显示。

CSettingsDialog dlg(this);
if (dlg.DoModal() == IDOK)
{
    // OK时的处理
}

在这种情况下,调用方会一直等待,直到对话框关闭为止。

而非模态对话框在创建之后,调用方的处理会立即返回。

m_pToolDialog = new CToolDialog(this);
m_pToolDialog->Create(IDD_TOOL_DIALOG, this);
m_pToolDialog->ShowWindow(SW_SHOW);

对于非模态对话框,生命周期管理非常重要。

new出来的对话框应该何时delete
父窗口是否会先被销毁
对话框内部是否使用了PostNcDestroy
是否会重复创建
关闭后指针是否仍然残留

在排查 MFC 崩溃问题时,非模态对话框的生命周期问题有时正是原因所在。

22. C++ 对象与 Windows 句柄的生命周期

MFC 中一个非常重要的概念,就是 C++ 对象与 Windows 句柄生命周期的差异。例如 CWnd 是一个 C++ 对象,而实际的窗口则是由 Windows 以 HWND 的形式来管理的,这两者并不总是同时创建、同时销毁。

CWnd对象存在但HWND尚未创建
HWND已销毁但CWnd对象仍然残留
创建了临时的CWnd包装对象
通过Attach/Detach更换句柄

例如,下面这样的代码就需要格外小心。

CWnd* pWnd = GetDlgItem(IDC_SOME_CONTROL);
// 把pWnd保存到成员变量中,之后再使用

如果长期持有通过 GetDlgItem 获得的指针,就存在窗口销毁后仍被引用的风险。

如果需要,可以每次都重新调用 GetDlgItem,或者用 DDX 来管理控件对应的成员变量,这样会更安全。

DDX_Control(pDX, IDC_LIST_ITEMS, m_listItems);

在 MFC 中,指针非空并不代表就是安全的。

if (m_pDialog != nullptr && ::IsWindow(m_pDialog->GetSafeHwnd()))
{
    m_pDialog->SetWindowText(_T("Running"));
}

这种意识在维护 MFC 时非常重要。

23. 线程与 UI 更新

Windows 的 UI 基本上必须在创建它的 UI 线程中操作,MFC 应用同样如此。如果从工作线程直接操作 UI 控件,可能会导致不稳定的行为甚至崩溃。

应该避免的写法如下。

UINT WorkerThreadProc(LPVOID pParam)
{
    CMyDialog* pDlg = static_cast<CMyDialog*>(pParam);

    // 避免从工作线程直接操作UI
    pDlg->SetDlgItemText(IDC_STATUS, _T("Done"));

    return 0;
}

一般会通过 PostMessage 等方式通知 UI 线程。

constexpr UINT WM_APP_WORK_DONE = WM_APP + 1;

UINT WorkerThreadProc(LPVOID pParam)
{
    HWND hWnd = static_cast<HWND>(pParam);

    // 耗时处理

    ::PostMessage(hWnd, WM_APP_WORK_DONE, 0, 0);
    return 0;
}

在 UI 一侧,则通过消息映射来接收。

BEGIN_MESSAGE_MAP(CMyDialog, CDialogEx)
    ON_MESSAGE(WM_APP_WORK_DONE, &CMyDialog::OnWorkDone)
END_MESSAGE_MAP()

LRESULT CMyDialog::OnWorkDone(WPARAM, LPARAM)
{
    SetDlgItemText(IDC_STATUS, _T("Done"));
    return 0;
}

在 MFC 的线程相关问题上,需要确认以下几点。

是否有从工作线程直接操作UI
窗口销毁后是否仍在PostMessage
等待线程结束时是否阻塞了UI线程
共享数据的锁是否恰当
是否误解了AfxBeginThread的返回值与生命周期

24. MFC DLL 与模块状态

在使用 MFC 制作 DLL 时,会遇到模块状态这个概念。

在从 MFC DLL 加载资源、显示对话框、制作扩展 DLL 等场景中,「应该去哪个模块查找资源」会成为一个问题。

在 MFC DLL 函数的入口处,经常会看到这样的宏。

AFX_MANAGE_STATE(AfxGetStaticModuleState());

这是为了让 MFC 能够使用正确的模块状态。

如果忘记写这个宏,就会引发如下问题。

找不到DLL内的对话框资源
字符串资源从另一个模块中被读取
找不到图标或菜单
只在调试时正常运行,发布版本却出错

维护 MFC DLL 时,需要理清 EXE、常规 DLL、扩展 DLL、资源 DLL 之间的关系。

尤其是在从非 MFC 应用调用 MFC DLL,或者采用插件结构的情况下,需要格外注意。

25. MFC 应该静态链接还是使用共享 DLL

在 MFC 应用中,项目设置里有一项「Use of MFC」。

代表性的选项有以下两种。

Use MFC in a Shared DLL
Use MFC in a Static Library

如果使用共享 DLL,运行环境中需要对应的 MFC 运行时和 Visual C++ 运行时。

如果采用静态链接,发行物看起来会更简单,但需要考虑可执行文件的体积、更新方式、安全修复的接入、许可与再分发条款等问题。

并不存在哪一种方式永远正确的答案。

判断依据大致包括以下几点。

是否可以在分发环境中安装Visual C++ Redistributable
是否希望应用尽量接近单一exe
如何应对安全更新
是否有多个应用共享同一份运行时
是否能够准备安装程序
目标Windows版本是什么

维护时,首先要确认当前的设置。

Configuration Properties
  General
    Use of MFC

此外,还需要查看 Runtime Library 的设置。

/MD   Multi-threaded DLL
/MDd  Multi-threaded Debug DLL
/MT   Multi-threaded
/MTd  Multi-threaded Debug

如果 MFC 与 CRT 的链接设置混用不当,在库的边界上可能会出现内存分配/释放方面的问题。

26. Unicode、MBCS 与 TCHAR

在老旧的 MFC 代码中,经常会出现 TCHARLPCTSTR_T() 宏。

CString title = _T("设置");
SetWindowText(title);

这是一种为了同时兼容 Unicode 构建和 MBCS 构建而采用的写法。

Unicode构建
  TCHAR    -> wchar_t
  LPCTSTR  -> const wchar_t*
  _T("...") -> L"..."

MBCS构建
  TCHAR    -> char
  LPCTSTR  -> const char*
  _T("...") -> "..."

目前 Unicode 构建已经是主流,但在老旧应用中,仍可能残留以 MBCS 为前提的处理逻辑。

尤其是在外部文件、通信协议、老旧 DLL、数据库连接、串口通信等场景中,需要确认字符编码的前提假设。

需要注意的是,不应该把 Unicode 化简单地当成一次替换作业。

char数组的大小是字节数还是字符数
是否使用了strlen
是否把sizeof(buffer)当作字符数使用
外部API接收的是UTF-16还是Shift_JIS
文件保存格式是否允许变更

修改 MFC 的字符串相关代码时,不能只关注画面显示,还需要确认文件兼容性以及外部集成部分。

27. MFC 与 COM/OLE/ActiveX

MFC 也曾在与 COM、OLE、ActiveX 关系密切的应用程序中被使用。

在老旧的业务应用中,可能仍残留以下这些元素。

OLE Automation
ActiveX Control
COM服务器
COM客户端
IDispatch
BSTR
VARIANT
COleDispatchDriver
COleVariant

在 MFC 应用的启动处理中,有时会出现这样的代码。

if (!AfxOleInit())
{
    AfxMessageBox(_T("OLE initialization failed"));
    return FALSE;
}

在使用 COM/OLE 的情况下,看起来像是 MFC 的问题,实际上往往是 COM 的初始化、线程模型、引用计数、注册信息,以及 32 位/64 位差异所导致的。

尤其需要注意 32 位的 ActiveX 或 COM 组件。

32位MFC应用要使用32位COM
64位MFC应用要使用64位COM
32位/64位的COM注册是分开的
老旧ActiveX有时并不支持64位

将 MFC 应用迁移到 x64 时,不仅要检查 UI 代码,还必须确认 COM/OLE 相关依赖。

28. 高 DPI 支持与现代 Windows

在现代 Windows 上运行老旧的 MFC 应用时,在高 DPI 环境下画面可能会出现错位。

例如,会出现下面这些问题。

文字被截断
按钮过小
固定像素绘制发生偏移
多台显示器缩放比例不同时布局错乱
旧位图变得模糊
对话框布局挤在一起

在 Windows 桌面应用中,应用程序需要明确声明其 DPI 适配模式。

MFC 应用也需要确认清单文件、资源、绘制代码、字体、布局等内容。

尤其是像下面这样直接写死固定坐标的代码,在高 DPI 下很容易出问题。

pDC->TextOut(10, 10, _T("Status"));
pDC->Rectangle(10, 40, 200, 80);

以固定像素为前提的坐标,在 DPI 发生变化时,外观会发生错位。

维护时的确认要点如下。

应用程序清单中的DPI设置
对话框资源的字体
固定像素绘制
图像资源的分辨率
多显示器下的表现
在Windows 10 / Windows 11上的显示效果

MFC 的高 DPI 适配,不一定只靠修改项目设置就能完成。对于老旧界面,往往需要实际确认画面效果并修正布局。

29. 异常处理与错误处理

MFC 有一套独特的异常类与错误处理方式。

在老旧代码中,常常能看到这样的宏。

TRY
{
    // 处理
}
CATCH(CFileException, e)
{
    e->ReportError();
}
END_CATCH

也有一些代码与现代 C++ 的 try / catch 混用。

try
{
    DoSomething();
}
catch (const std::exception& ex)
{
    // 输出日志
}

在维护 MFC 时需要注意以下几点。

MFC异常与C++标准异常是否混用
是否误解了异常对象的生命周期
是否理解老旧的THROW/CATCH宏
返回值错误与异常是否混用
是否存在只用AfxMessageBox而没有留下日志的情况

在业务应用中,重要的不仅是在画面上显示错误信息,还要保留日志、操作记录、输入值、外部连接状态等信息。

在老旧的 MFC 应用中,有时错误处理仅止步于 AfxMessageBox

AfxMessageBox(_T("保存失败"));

如果想提升可维护性,最好把界面显示和日志记录分开处理。

LogError(_T("Save failed"), path);
AfxMessageBox(_T("保存失败,请查看日志。"));

30. 如何让 MFC 与现代 C++ 共存

即使在维护 MFC 应用,也不必把所有代码都改成老旧的 C++ 写法。

UI 层可以尊重 MFC 的作法,而领域逻辑和计算处理则可以用现代 C++ 来整理。

例如,可以这样划分。

MFC层
  CDialog
  CView
  CDocument
  CString
  消息映射
  资源操作

非MFC层
  std::string / std::wstring
  std::vector
  std::optional
  std::variant
  std::filesystem
  可进行单元测试的类
  业务逻辑

不好的形式是,所有处理都被塞进了对话框类中。

void CMainDialog::OnBnClickedExecute()
{
    // 获取输入
    // 读取文件
    // 通信
    // 计算
    // 更新数据库
    // 更新画面
    // 输出日志
    // 异常处理
}

这样的代码很难修改,也很难测试,排查 bug 也会变得困难。

要改善这一点,可以把逻辑从 MFC 类中抽取出来。

void CMainDialog::OnBnClickedExecute()
{
    if (!UpdateData(TRUE))
    {
        return;
    }

    ExecuteRequest request;
    request.Name = ToStdWString(m_name);
    request.Interval = m_interval;

    ExecuteResult result = m_service.Execute(request);

    m_status = ToCString(result.Message);
    UpdateData(FALSE);
}

这样一来,m_service.Execute 就可以在没有 MFC 的情况下进行测试。

在维护 MFC 现有资产时,最有效的改进方式,就是把逻辑从 UI 类中一点点抽取出来。

31. 让 MFC 代码更易于测试

MFC 应用如果保持原样,往往很难进行单元测试。

原因在于 UI、Win32、文件、通信、数据库、全局状态很容易紧密耦合在一起。

让代码更易于测试的思路如下。

不要试图直接测试CDialog或CView
先把非UI逻辑抽取出来
在边界处转换MFC类型
把文件或通信抽象为接口
让画面事件处理函数变薄

例如,可以把处理逻辑移到一个纯粹的 C++ 类中。

class PriceCalculator
{
public:
    int CalculateTotal(const std::vector<int>& prices) const
    {
        int total = 0;
        for (int price : prices)
        {
            total += price;
        }
        return total;
    }
};

MFC 一侧只负责输入与输出。

void CPriceDialog::OnBnClickedCalculate()
{
    if (!UpdateData(TRUE))
    {
        return;
    }

    std::vector<int> prices = ParsePrices(ToStdWString(m_input));
    int total = m_calculator.CalculateTotal(prices);

    m_result.Format(_T("%d"), total);
    UpdateData(FALSE);
}

采用这种结构后,PriceCalculatorParsePrices 就可以用普通的 C++ 测试框架来测试。

不需要一次性重写整个 MFC 应用,仅仅是把事件处理函数中可测试的处理逻辑抽取出来,就已经能带来效果。

32. 固定构建环境

在维护 MFC 应用时,固定构建环境非常重要。

在老旧代码库中,以下这些差异都可能导致构建结果不同。

Visual Studio的版本
MSVC工具集的版本
Windows SDK的版本
是否安装了MFC/ATL组件
x86 / x64 / ARM64
Debug / Release
Unicode / MBCS
MFC的静态链接 / 共享DLL
运行时库设置
预编译头

在 MFC 应用中,stdafx.hpch.h 里往往集中了大量依赖。

#include "framework.h"
#include "MyApp.h"

有时会因为某个文件的编译设置不同,或预编译头设置不一致等原因导致构建失败。

在维护项目中,把这些信息明确写入 README,会让后续工作轻松很多。

所需的Visual Studio版本
所需的工作负载与单个组件
所需的Windows SDK
目标平台
MFC的链接方式
构建步骤
CI的执行方法
发行物的制作方法

从「只在自己电脑上能构建」这种状态中脱离出来,是维护 MFC 的第一步。

33. 在 CI 中构建 MFC

MFC 应用也可以在 CI 中构建。

不过,CI 环境中必须安装有 MFC 组件。

如果使用 Visual Studio Build Tools,需要指定 MFC/ATL 的组件 ID 进行安装。

需要确认的要点如下。

Build Tools中是否安装了MFC
目标工具集是否与项目一致
是否安装了Windows SDK
是否同时确认了x86构建和x64构建
资源编译器是否能正常工作
是否有签名处理
安装程序的生成是否也在CI范围内

在 MFC 应用中,把 UI 测试也自动化并不容易,但至少以下这些自动化是有效的。

Debug / Release构建
x86 / x64构建
静态分析
单元测试
安装程序制作
保存产物的哈希值
确认依赖DLL

在长期维护中,仅仅保持「能够构建」这一状态,就已经具有很大的价值。

34. 调试 MFC 应用时该看哪里

在排查 MFC 应用的问题时,按照以下顺序查看会更高效。

1. 是哪个画面
2. 是对话框、View还是Frame
3. 该操作对应的资源ID是什么
4. 消息映射会导向哪个函数
5. UpdateData的方向是否正确
6. 如果是Document/View,Document的状态是什么
7. 命令路由是否被转发到了其他类
8. 是否从工作线程直接操作了UI
9. 异常或错误是否仅被AfxMessageBox吞掉了
10. 是否存在Release构建特有的未初始化或生命周期问题

例如,如果遇到「点击按钮却什么都没发生」的问题,可以确认以下几点。

按钮的IDC是否正确
ON_BN_CLICKED是否存在
处理函数的签名是否正确
对话框资源是否是另一个
按钮是否被禁用
处理过程中UpdateData是否失败
异常是否被吞掉了

如果是「菜单无法点击」的问题,则可以确认以下几点。

是否被ON_UPDATE_COMMAND_UI禁用了
命令ID是否重复
活动视图是否符合预期
处理函数位于Frame/View/Document/App中的哪一个

由于 MFC 表面上的事件与实际处理是通过宏和路由关联起来的,在熟悉之前,把调用路径画成图会更容易理解。

35. 常见的陷阱

整理一下维护 MFC 时常见的陷阱。

不看消息映射,只搜索函数调用
认为CWnd*非空就一定有效
混淆HWND的生命周期与C++对象的生命周期
弄错UpdateData(TRUE/FALSE)的方向
没注意到被ON_UPDATE_COMMAND_UI禁用了
没注意到resource.h中的ID冲突
从工作线程直接操作UI
CString与std::string转换时出现乱码
Unicode化破坏了以MBCS为前提的代码
在MFC DLL中忘记写AFX_MANAGE_STATE
x64化破坏了以x86为前提的COM/ActiveX
GDI对象的选择/释放出错
高DPI下固定坐标布局发生错乱

MFC 的问题,有时光看 C++ 语法本身是看不出来的,还需要连同 Windows 的消息、资源、句柄、模块、运行时设置一起查看。

36. 新项目应该选择 MFC 吗

对于全新开发的项目,是否选择 MFC 需要谨慎判断。

如果有理由选择 MFC,通常是以下这些情况。

需要与现有MFC代码紧密协作
希望复用现有的MFC部件或界面
需要非常贴近Win32/GDI/COM的精细控制
团队内部拥有足够的MFC维护技能
目标平台仅限于Windows桌面
出于长期迁移计划,需要先在MFC上增量开发

反过来,以下情况则应该考虑其他选择。

想构建现代化的UI
需要灵活的布局或动画
以Web集成或云集成为核心
重视可测试性
希望选择让年轻开发者更容易参与的技术
需要跨平台支持
从一开始就重视无障碍或高DPI支持

MFC 既不是「现在学习已经没有价值」的技术,也不是「因为新才选择」的技术,而是一种用于面对现有 Windows 原生资产的技术。

37. 从 MFC 迁移时的思路

如果想把 MFC 应用迁移到其他技术,一开始就打算全面重写,往往容易失败。

首先,可以把应用这样分解来考虑。

UI
业务逻辑
文件格式
通信处理
数据库处理
设备控制
打印
COM/OLE集成
配置管理
日志

其中,最依赖 MFC 的是 UI。

而业务逻辑和文件处理,则有可能被抽取出来。

迁移的现实顺序大致如下。

1. 重现构建环境
2. 用测试数据固定现有行为
3. 把逻辑从UI事件处理函数中抽取出来
4. 迁移到非MFC的C++库中
5. 补充自动化测试
6. 编写外部规格文档
7. 从必要的界面开始逐步替换

与其把「摆脱 MFC」本身当作目标,不如把「把封闭在 MFC 中的重要逻辑抽取出来」当作目标,这样更容易成功。

38. 阅读 MFC 代码的入口

第一次阅读现有 MFC 项目时,可以从以下这些文件入手。

*.vcxproj
  查看工具集、MFC设置、字符集、运行时设置

resource.h
  查看资源ID

*.rc
  查看对话框、菜单、字符串、图标

*App.cpp / *App.h
  查看CWinApp派生类与InitInstance

MainFrm.cpp / MainFrm.h
  查看主框架以及菜单/工具栏

*Doc.cpp / *Doc.h
  如果是Document/View,查看数据结构与保存处理

*View.cpp / *View.h
  查看绘制与用户操作

*Dlg.cpp / *Dlg.h
  查看对话框、DDX、按钮处理

接下来是常用的搜索关键字。

BEGIN_MESSAGE_MAP
ON_COMMAND
ON_UPDATE_COMMAND_UI
ON_BN_CLICKED
DoDataExchange
UpdateData
OnInitDialog
OnDraw
Serialize
AfxMessageBox
AfxBeginThread
AFX_MANAGE_STATE

搜索这些关键字,可以更容易看清应用程序的运作方式。

39. 维护 MFC 时的设计方针

如果要长期维护 MFC 现有资产,以下方针会很有效。

让UI事件处理函数变薄
不要让CString或CWnd过多地泄漏到非UI层
把业务逻辑迁移到普通的C++类中
建立文件格式的兼容性测试
明确x86/x64之间的差异
对资源ID的变更进行审查
确认MFC DLL的模块状态
完善日志
在CI中固定构建
定期在高DPI或Windows 11环境下确认画面

尤其重要的是,不要把过多处理塞进 CDialogCView 中。

MFC 的画面类应该专注于输入、显示、事件派发。

从画面获取值
传递给服务
把结果反映到画面上

只要能保持在这个程度,即使是 MFC,也能变得相当易于维护。

40. 实务检查清单

以下是处理 MFC 应用时的检查清单。

Visual Studio的版本是否明确
是否安装了MFC/ATL组件
x86/x64的目标是否明确
是否掌握了Unicode/MBCS的设置
MFC是静态链接还是共享DLL
所需的Visual C++ Redistributable是什么
resource.h和.rc是否列入了审查对象
是否通过消息映射追踪了事件路径
UpdateData的方向是否正确
是否使用了Document/View结构
非模态对话框的生命周期管理是否安全
是否有从工作线程直接操作UI
MFC DLL中是否存在需要AFX_MANAGE_STATE却遗漏的地方
是否在高DPI环境下确认过画面
COM/ActiveX依赖是否兼容32位/64位
日志是否会被保留
非UI逻辑能否被测试

MFC 在熟悉之前看起来很特殊,但只要知道该看哪里,其实读起来相当有规律。

41. 总结

MFC 是一个历史悠久的框架,用于以 C++ 构建 Windows 原生桌面应用程序。虽然它已不再是新项目开发的主流选择,但在维护现有资产、更新构建环境、追加功能、逐步迁移等场景中,它至今仍是一项重要的技术。

理解 MFC 时,尤其需要注意以下几点。

MFC让Win32 API更容易以C++方式使用
CWinApp管理整个应用程序
CWnd与HWND的生命周期并不相同
消息映射把事件与函数关联起来
Document/View是分离数据与显示的机制
DDX/DDV用于对话框数值的同步与验证
资源文件与resource.h非常重要
CString和TCHAR需要结合字符编码设置一起理解
MFC DLL中需要注意模块状态
老旧的MFC应用值得从高DPI、x64、CI、测试的角度重新审视

面对 MFC 时,与其一概认定「老旧就是不好」,更重要的是先理解其结构。现有的 MFC 应用中,往往凝聚着多年积累的业务知识、针对各个客户的规格、设备集成、文件兼容性。要在守护这些价值的同时逐步提升可维护性,现实的做法是:先理解 MFC 的作法,再把非 UI 逻辑抽取出来,并完善构建与测试。

如果要用一句话来概括,那就是:

一套用 C++ 的类与框架来处理 Windows 原生应用机制的基础平台。

带着这样的视角看 MFC,它就不再只是一项老旧的技术,而会成为安全解读现有 Windows 资产的重要线索。

参考

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

摆脱 IE 模式依赖系统的实践指引

本文围绕站点列表集中管理、中立站点、WebView2 封装、分阶段重构、VDI 隔离直到治理设计,梳理出一套让依赖 Microsoft Edge IE 模式的内部 Web 系统能够安全延续使用、并逐步摆脱依赖的实操路径,力求让读者可以直接照着落地执行。

常见问题

汇总了咨询这一主题时常见的问题。

MFC 是什么?
MFC 是 Microsoft Foundation Classes 的缩写,是一个让 Win32 API 更容易以 C++ 类的形式使用的 Windows 应用程序框架。窗口用 CWnd 表示,对话框用 CDialog 表示,整个应用程序用 CWinApp 等类表示。它并不是「不了解 Windows 也能写代码的魔法库」,而是把 Windows 的机制用 C++ 的类型与框架整理出来的东西,因此仍然需要具备 Windows 消息、句柄等 Win32 相关知识。
MFC 现在还能用吗?还在维护支持吗?
MFC 目前仍可以在 Visual Studio 中使用,并会继续获得支持。不过微软的文档中说明,MFC 不会再新增功能,也不会再更新文档。就定位而言,在维护现有 MFC 应用、追加功能、更新构建环境,或向其他 UI 技术逐步迁移等场景中,MFC 依然是现实中常见的选择;但对于全新开发的一般 GUI 应用,是否采用 MFC 需要谨慎判断。使用前需要安装 Visual Studio Installer 中的单个组件(C++ MFC for latest build tools)。
MFC 的消息映射是什么?
消息映射是 MFC 中把 Windows 消息或命令与处理函数关联起来的机制。在 BEGIN_MESSAGE_MAP 到 END_MESSAGE_MAP 之间,通过 ON_BN_CLICKED、ON_COMMAND 等宏来描述「点击这个按钮就调用这个函数」这样的对应关系。当搜索代码却找不到直接调用,但该函数确实会在事件发生时执行时,就应该去查看消息映射。在 MFC 的代码审查中,不能只看处理函数本身,还必须结合消息映射一起确认,这一点非常重要。
要把 MFC 迁移到其他技术,应该怎么做?
一开始就打算全面重写,往往容易失败。比较现实的顺序是:先重现构建环境,用测试数据固定现有行为,把逻辑从 UI 事件处理函数中拆分出来并迁移到非 MFC 的 C++ 库中,再补充自动化测试,编写外部规格文档,最后从必要的界面开始逐步替换。与其把「摆脱 MFC」本身当作目标,不如把「把封闭在 MFC 中的重要逻辑抽取出来」当作目标,这样更容易成功。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表