3、文档视图架构:文档类(CDocument)设计、视图类(CView)设计、文档模板与多文档支持、文档序列化

好,咱们今天聊聊 EPS 软件里最核心的骨架——文档视图架构。

说实话,我早年刚接触这个架构时,觉得它挺绕的。文档、视图、模板……一堆概念。但后来做项目多了,才明白这套设计有多巧妙。它把数据和显示彻底解耦了,你改数据不影响界面,换界面也不丢数据。

3.1 文档类(CDocument)设计

文档类,说白了就是数据的管家。它不关心数据怎么画出来,只关心数据怎么存、怎么改。

核心职责:

  • 管理应用数据(比如 EPS 里的点、线、面)
  • 提供数据增删改查的接口
  • 负责数据的序列化(存盘和读盘)
  • 通知视图数据发生了变化

我个人习惯,在设计文档类时,会先想清楚数据模型。比如一个简单的绘图文档:

class CEpsDocument : public CDocument
{
public:
    // 数据操作
    void AddShape(CEpsShape* pShape);
    void RemoveShape(int nIndex);
    CEpsShape* GetShape(int nIndex) const;
    int GetShapeCount() const;

    // 序列化
    virtual void Serialize(CArchive& ar);

    // 通知视图更新
    void UpdateAllViews();

private:
    CObArray m_shapes;  // 存储所有图形对象
};

这里有个坑,我踩过。很多人喜欢把视图相关的代码塞进文档类,比如直接调用某个控件的刷新。千万别这么干!文档类应该对视图一无所知。它只负责说「我变了」,至于谁来看、怎么刷新,那是视图的事。

注意:文档类不要持有任何视图的指针。它通过 UpdateAllViews 这种广播机制通知视图,而不是直接操作视图。

3.2 视图类(CView)设计

视图类,就是数据的展示窗口。它从文档拿数据,然后画出来。用户的所有交互,比如鼠标点击、拖拽,也都在视图里处理。

核心职责:

  • 获取文档数据并渲染
  • 处理用户输入(鼠标、键盘)
  • 将用户操作转化为文档数据的修改
  • 响应文档的更新通知

我举个例子,一个 EPS 视图的典型实现:

class CEpsView : public CView
{
public:
    // 获取关联的文档
    CEpsDocument* GetDocument() const;

    // 绘制所有图形
    virtual void OnDraw(CDC* pDC);

    // 鼠标交互
    afx_msg void OnLButtonDown(UINT nFlags, CPoint point);
    afx_msg void OnMouseMove(UINT nFlags, CPoint point);

    // 文档更新通知
    virtual void OnUpdate(CView* pSender, LPARAM lHint, CObject* pHint);

private:
    void DrawShape(CDC* pDC, CEpsShape* pShape);
};

你想想看,视图和文档之间怎么通信?其实就两条路:

  1. 文档→视图:文档调用 UpdateAllViews,视图的 OnUpdate 被触发
  2. 视图→文档:视图通过 GetDocument 拿到文档指针,直接调用文档的接口改数据

嗯,这里要注意。视图修改数据后,一定要记得调用文档的 SetModifiedFlag,告诉框架「文档被改过了」。不然退出时可能不会提示保存。

小技巧:我习惯在 OnUpdate 里加一个 hint 参数,告诉视图具体哪里变了。比如只重绘某个区域,而不是全屏刷新,性能会好很多。

3.3 文档模板与多文档支持

文档模板,听起来高大上,其实就是一个工厂。它负责把文档、视图、框架窗口这三样东西绑定在一起。

MFC 里,文档模板分两种:

模板类型 适用场景 特点
CSingleDocTemplate 单文档应用(SDI) 一次只能打开一个文档
CMultiDocTemplate 多文档应用(MDI) 可以同时打开多个文档

我记得在做第一个 EPS 项目时,用的是多文档架构。用户希望同时打开多个图纸,互相参考。MDI 正好满足这个需求。

注册文档模板的代码通常放在 InitInstance 里:

BOOL CEpsApp::InitInstance()
{
    // 注册文档模板
    CMultiDocTemplate* pDocTemplate;
    pDocTemplate = new CMultiDocTemplate(
        IDR_EpsTYPE,
        RUNTIME_CLASS(CEpsDocument),
        RUNTIME_CLASS(CChildFrame),  // 子框架窗口
        RUNTIME_CLASS(CEpsView));
    AddDocTemplate(pDocTemplate);

    // 其他初始化...
    return TRUE;
}

这里有个设计要点:每个文档模板对应一种文档类型。如果你的 EPS 软件支持多种格式(比如 .eps 和 .dxf),可以注册多个模板,每个模板关联不同的文档类和视图类。

核心思想:文档模板把「数据(文档)」「显示(视图)」「窗口(框架)」三者解耦。你可以为同一份数据,注册多个视图模板,实现不同的展示方式。

3.4 文档序列化

序列化,说白了就是「存盘」和「读盘」。这是文档类最重要的能力之一。

MFC 的序列化机制基于 CArchive 类。它把对象的状态写入文件,或者从文件恢复。我当年第一次写序列化时,觉得挺神奇的——一个对象怎么就能变成一堆字节呢?

一个典型的序列化实现:

void CEpsDocument::Serialize(CArchive& ar)
{
    if (ar.IsStoring())
    {
        // 保存数据
        ar << m_shapes.GetSize();  // 先存数量
        for (int i = 0; i < m_shapes.GetSize(); i++)
        {
            CEpsShape* pShape = m_shapes[i];
            ar << pShape->GetType();  // 存类型
            pShape->Serialize(ar);     // 存具体数据
        }
    }
    else
    {
        // 加载数据
        int nCount;
        ar >> nCount;
        for (int i = 0; i < nCount; i++)
        {
            int nType;
            ar >> nType;
            CEpsShape* pShape = CreateShapeByType(nType);
            pShape->Serialize(ar);
            m_shapes.Add(pShape);
        }
    }
}

写序列化时,有几点要特别注意:

  • 版本兼容:文件格式一定要带版本号。我吃过这个亏,旧版本软件打不开新版本的文件,用户直接炸了。
  • 指针处理:如果文档里有对象指针,序列化时要小心。MFC 的 CArchive 支持指针序列化,但需要对象支持 CRuntimeClass。
  • 性能优化:大数据量时,逐对象序列化可能很慢。可以考虑用内存映射文件或者分块存储。
避坑指南:我曾经在序列化里直接用了 memcpy 来存结构体。结果结构体里有虚函数指针,换了个编译器版本就全崩了。记住,永远不要直接序列化有虚函数的对象!

最后,说个我自己的习惯。我会在文档类里加一个版本号宏:

#define EPS_FILE_VERSION 0x0100  // 1.0 版本

void CEpsDocument::Serialize(CArchive& ar)
{
    if (ar.IsStoring())
    {
        ar << EPS_FILE_VERSION;  // 先写版本号
        // 然后写数据...
    }
    else
    {
        WORD wVersion;
        ar >> wVersion;
        // 根据版本号做兼容处理
        // ...
    }
}

这样,以后升级文件格式时,老版本也能优雅地提示「文件版本过高,请升级软件」。而不是直接崩溃。

好了,文档视图架构的核心就这些。说白了,就是一套「数据-显示-窗口」的三角关系。理解了这个,EPS 软件的骨架你就拿下了。