C++跨平台开发之基于wxWidgets开发GUI程序简介-002

1.引言

在上一篇《C++跨平台开发之基于wxWidgets开发GUI程序简介-001》中,笔者介绍了wxWidgets 库的许可协议、下载安装、Windows环境下的编译过程,以及一个"按钮 + 对话框"的最小案例。那个案例只能证明"环境通了",离一个真正可用的桌面应用还很远。

本文要做的事情更近一步:用同一套wxWidgets 库,从零设计并实现一个矢量绘图软件WxDraw多文档界面、十余种图元、撤销重做、XML持久化与向前兼容、图层、可停靠工具栏、局部重绘防闪烁,全部落地。更重要的是,整个工程不是能跑就行的演示代码,而是按照一套事先约定好的C++开发技能规范(命名、分层、目录、编译脚本、跨平台策略)组织起来的,平台无关的业务核心与wxWidgets界面层之间彻底解耦------把界面层换成Qt或其它图形库,核心层一行都不用改。

本文的行文顺序是:先交代需求与总体分层,再分别展开平台无关核心层与人机界面层的设计,然后挑几个真正踩过的坑来讲实现细节,最后给出运行效果与测试数据。不同 GUI 库的优劣对比不在本文讲述之列,大家感兴趣可以自行查找资料。开发环境如下:

|-------------|------------------------------------------------|
| 项目 | 版本/路径 |
| 操作系统 | Windows 11 |
| 集成环境 | Visual Studio 2026 Community |
| Windows SDK | 10.0.26100.0 |
| wxWidgets | 3.3.4,安装于 C:\wxWidgets-3.3.4(动态库 DLL 方式链接) |
| C++ 标准 | C++14 |
| 构建方式 | nmake(Makefile.vc)+ CMake + VS2026 解决方案,三套并存 |

2.需求与总体设计

2.1 需求要点

软件需求一共六大部分,摘录关键条目如下:

  • 基本要求:多文档方式,具备菜单栏、工具栏、状态栏。
  • 设计要求:所有图元继承自自设计的图元虚基类;支持直线、折线、矩形、椭圆、菱形、任意多边形、文本;持久化并支持向前兼容;支持缩放、移动、碰撞检测,直线折线要抗锯齿;有外接矩形的图元与直线类图元的"选中样式"必须不同;支持线型、线宽、线色、填充样式、填充色。
  • 图元操作:双击改文本、悬停显示信息、拉框多选(碰到即选中)、统一拖拽与键盘移动(到画布边缘整体停住)、合并与打散、六种对齐与两种均分、绘制完成即进入待选状态、双击加折点、右键菜单删除折点/顶点、默认颜色约定。
  • 连接线:图元之间可连接,直线/带箭头可配,支持横纵正交折线与增加折点。
  • 工具栏:图元类型工具、布局对齐工具、undo/redo 工具、样式工具、图层工具各自独立成条,各工具栏要能灵活拖动和停靠。
  • 画布 :默认黑底、默认尺寸 1920×1440 且尺寸超出视图时出滚动条;标尺与虚线格可开关;图层;多文档标题带 * 修改标识;局部擦除重绘避免全画面闪烁;拖动时重绘区域适度扩大防残影;并且支持撤销/重复操作。

可以看到,这六部分里真正有技术含量的,不是"画个矩形",而是向前兼容的文件格式、不闪的画布、可停靠工具栏和撤销重做的粒度控制。

2.2 分层架构

整个软件模块划分为人机界面层和核心业务层:

  • 人机界面层:主要依赖wxWidget相关类库,设计了多个界面相关的类,具体包括:WxApp / CWxMainFrame / CWxChildFrame/ CWxCanvas / CWxDrawPort / CWxDlgs等
  • 核心业务层:采用抽象接口设计,包括多个相关类,具体包括CShapeBase / CDrawModel / CDrawDoc / CCommandHistory / CXmlNode / CXmlArchive / CGeoTypes / CShapeFactory等。

核心业务层和人机界面层之间,都依赖于抽象接口CCanvasPort,满足了软件设计原则中的依赖倒置原则,高层和底层都不相互依赖,均依赖于抽象接口,可有效隔离界面和业务,后续如果将界面部分替换为Qt,软件设计不会有大的修改。

业务层不调用wxDC,而是面向自己定义的纯虚基类CCanvasPort编程;界面层提供CWxDrawPort实现它。这样核心层对图形库零依赖,控制台下的单元测试可以完全不链接图形库wxWidgets。

CCanvasPort接口长这样(节选):

cpp 复制代码
namespace CWxdw
{
    class CCanvasPort
    {
    public:
        virtual ~CCanvasPort(void);

        /// 设置画笔(线色、线宽、线型、是否抗锯齿)
        virtual void SetPen(unsigned int unLineColor, int nLineWidth,
                            et_PenStyle ePenStyle, bool bAntiAlias) = 0;
        /// 设置画刷(填充色、填充样式)
        virtual void SetBrush(unsigned int unFillColor, et_BrushStyle eBrushStyle) = 0;
        virtual void ClearBrush(void) = 0;
        virtual void DrawLine(const CGeoPoint& rPtStart, const CGeoPoint& rPtEnd) = 0;
        virtual void DrawPolyline(const std::vector<CGeoPoint>& arPoints) = 0;
        virtual void DrawPolygon(const std::vector<CGeoPoint>& arPoints) = 0;
        virtual void DrawRectangle(const CGeoRect& rRect) = 0;
        virtual void DrawEllipse(const CGeoRect& rRect) = 0;
        virtual void DrawTextLine(const std::string& strText, const CGeoRect& rRect,
                                  const SFontDesc& rFontDesc) = 0;
        virtual CGeoSize MeasureText(const std::string& strText, const SFontDesc& rFontDesc) = 0;
        virtual void DrawArrowHead(const CGeoPoint& rPtFrom, const CGeoPoint& rPtTo, double dSize) = 0;
        virtual void DrawDashBox(const CGeoRect& rRect, unsigned int unColor) = 0;
        virtual void DrawHandleBox(const CGeoPoint& rPtCenter, double dSize,
                                   unsigned int unBorderColor, unsigned int unFillColor) = 0;
        virtual void DrawTipBox(const CGeoPoint& rPtLeftTop, const std::string& strText,
                                unsigned int unBackColor, unsigned int unBorderColor) = 0;
    };
}

注意接口里没有出现任何wx前缀的类型,颜色统一用unsigned int`(ARGB),几何统一用自定义的CGeoPoint / CGeoRect / CGeoSize。

2.3 目录结构与构建体系

目录严格遵循"源码根src,其余子目录均为src下级的约定:

cpp 复制代码
src/
├── include/          平台无关公共头文件(14 个)
├── core/             上列头文件对应实现(14 个)
├── config/           单例配置 CAppConfig
├── multi_os_base/    跨平台基础库,含 win32/ 与 linux/ 分目录
├── mmi/              人机界面层(15 个,唯一依赖 wxWidgets 的目录)
├── test/             test_smoke.cpp / test_require.cpp(控制台,不链接图形库)
├── Makefile.vc       nmake 脚本(最外层)
├── CMakeLists.txt    cmake 脚本(最外层)
├── wxdraw.sln        VS2026 解决方案
└── build_win.bat     一键编译 + 部署批处理

各模块目录下还有自己的 `CMakeLists.txt` / `*.vcxproj`,模块间依赖在 sln 中用 `ProjectSection(ProjectDependencies)` 表达(mmi → core → config → multi_os_base),cmake 侧用 `add_subdirectory` + `target_link_libraries` 表达同一层关系。

编译产物统一落在工作空间根(不在 `src` 下):

  • Release → `bin_release/` + `obj/release/`

  • Debug → `bin_debug/` + `obj/debug/`

`Makefile.vc` 提供了 4 个目标:`all`(主程序)、`test`(冒烟测试)、`require`(需求覆盖测试)、`deploy`(拷贝运行库)。日常一条命令即可:

cpp 复制代码
nmake /f Makefile.vc CFG=Release all test require deploy

2.4 编码规范约束

代码不是随性写的,而是受一份技能规范约束,其中对代码形态影响最大的几条是:

  • 类名以大写 C 开头(CShapeBase)、成员以 m_ 开头并按类型前缀做匈牙利式成员变量命名(m_pCanvas、m_bVisible、m_nId),多行变量定义用制表键对齐;
  • 成员变量优先用初始化列表;
  • 涉及拷贝的类必须提供完整拷贝构造与 operator=;
  • 复杂形参一律传引用,禁止传值;
  • switch/case 超过 3 个分支必须改为表格驱动;
  • 文件头注释逐行用 //---...--- 分隔,禁止 /* */ 块注释,且要注明日期。

这套规范落到实处之后,文件头是这个样子的:

cpp 复制代码
//---------------------------------------------------------------------------
// 文件名称: CShapeBase.h
// 所属目录: src/include(公共头文件目录)
// 文件说明: 图元虚基类 CShapeBase,以及存在外接矩形的图元公共基类 CBoundedShape。
//
// 初次添加: 2026-10-28  新建本文件,实现上述功能。
//---------------------------------------------------------------------------

"表格驱动替代多分支 switch"这条在画布的鼠标交互里体现得最明显------拖拽分派不是一大坨 switch,而是两张函数指针表:

cpp 复制代码
    const CWxCanvas::SDragUpEntry CWxCanvas::s_arrDragUpTable[et_DragMode_MaxCount] =
    {
        { et_DragMode_None,           NULL                        },
        { et_DragMode_RubberBand,    &CWxCanvas::OnUpRubberBand   },
        { et_DragMode_MoveSelection, &CWxCanvas::OnUpMoveSelection},
        { et_DragMode_HandleScale,   &CWxCanvas::OnUpHandleScale  },
        { et_DragMode_VertexMove,    &CWxCanvas::OnUpHandleScale  },
        { et_DragMode_DrawShape,     &CWxCanvas::OnUpDrawShape    },
        { et_DragMode_Connect,       &CWxCanvas::OnUpConnect      }
    };

    void CWxCanvas::DispatchDragUp(const CGeoPoint& rstEnd, wxMouseEvent& rEvent)
    {
        if ((m_eDragMode > et_DragMode_None) && (m_eDragMode < et_DragMode_MaxCount))
        {
            const PFN_DragUpHandler pfnHandler = s_arrDragUpTable[m_eDragMode].m_pfnHandler;
            if (NULL != pfnHandler)
            {
                (this->*pfnHandler)(rstEnd, rEvent);
            }
        }
    }

鼠标移动同理有一张 s_arrDragMoveTable。新增一种拖拽模式时,只需要在枚举里加一项、在两张表里各补一行、写一个 OnXxx 处理函数即可,不用再去翻那个几百行的 switch。XML 转义/反转义、图标工厂的 MakeIcon、对齐方式的派发,也都改成了同样的表驱动写法。

3.平台无关核心层设计

3.1 图元体系

所有图元派生于 CShapeBase 虚基类。它定义的是"一个图元必须能回答什么",而不是"你怎么把它画出来":

复制代码
    /// 图元虚基类:定义绘制、包围盒、碰撞检测、移动、缩放、序列化等统一接口
    class CShapeBase
    {
    public:
        CShapeBase(void);
        CShapeBase(const CShapeBase& rOther);
        virtual ~CShapeBase(void);
        CShapeBase& operator=(const CShapeBase& rOther);

        virtual et_ShapeType GetType(void) const = 0;
        virtual CShapeBase*  Clone(void) const = 0;          // 原型模式
        virtual void         Draw(CCanvasPort& rPort) const = 0;
        virtual CGeoRect     GetBounds(void) const = 0;
        virtual bool         HitTest(const CGeoPoint& rPoint, double dTolerance) const = 0;
        virtual bool         HitTestBox(const CGeoRect& rRect) const = 0;   // 拉框选择
        virtual void         Move(double dOffsetX, double dOffsetY) = 0;
        virtual void         Scale(double dScaleX, double dScaleY, const CGeoPoint& rAnchor) = 0;
        virtual bool         HasBoundingBox(void) const;      // 决定选中样式
        virtual std::vector<CGeoPoint> GetVertexList(void) const;
        virtual std::string  GetTipText(void) const;          // 悬停信息
        virtual void         Serialize(CXmlArchive& rArchive);
        // ... 通用属性:Id / Name / LayerId / Style
    };

派生关系如下:

图元 类名 说明
直线 CShapeLine 抗锯齿,选中时显示两个端点把手
折线 CShapePolyline 支持插入/删除折点
矩形 / 椭圆 / 菱形 CShapeRectangle / CShapeEllipse / CShapeDiamond 有外接矩形,选中时显示 8 个把手,派生于 CBoundedShape
任意多边形 CShapePolygon 支持插入/删除顶点
文本 CShapeText 字体、前景/背景色、粗斜体下划线、横纵对齐
连接线 CShapeConnector 直连/正交路由、箭头、折点
合并组 CShapeGroup 组合图元,可打散
保留节点 CShapeReservedRaw 向前兼容占位,原样回写

HasBoundingBox() 这个虚函数专门服务于需求"直线折线图元的选中样式与其他图元不同"------画布不关心具体类型,只问一句"你有没有外接矩形",有就画 8 个把手虚线框,没有就画顶点小方块。

图元的创建走 CShapeFactory(工厂 + 原型),内部用一张"类型 → 创建函数"的表:

复制代码
        m_mapCreators[et_ShapeType_Line]       = &CreateShapeLine;
        m_mapCreators[et_ShapeType_Polyline]   = &CreateShapePolyline;
        m_mapCreators[et_ShapeType_Rectangle]  = &CreateShapeRectangle;
        m_mapCreators[et_ShapeType_Ellipse]    = &CreateShapeEllipse;
        m_mapCreators[et_ShapeType_Diamond]    = &CreateShapeDiamond;
        m_mapCreators[et_ShapeType_Polygon]    = &CreateShapePolygon;
        m_mapCreators[et_ShapeType_Text]       = &CreateShapeText;
        m_mapCreators[et_ShapeType_Connector]  = &CreateShapeConnector;
        m_mapCreators[et_ShapeType_Group]      = &CreateShapeGroup;

3.2 撤销/重做的粒度控制

需求要求"画布多文档视图应支持 undo/redo"。设计上采用文档状态快照 + 命令包裹 :CDrawDoc::BeginCommand(name) 时抓一份 CDocState 前状态,EndCommand() 时抓后状态,两者不同才入历史栈:

复制代码
    void CDrawDoc::BeginCommand(const std::string& strName)
    {
        if (m_bCommandOpened)
        {
            EndCommand();
        }
        m_stBeforeState.Capture(m_stModel);
        m_strCommandName = strName;
        m_bCommandOpened = true;
    }

    void CDrawDoc::EndCommand(void)
    {
        if (!m_bCommandOpened)
        {
            return;
        }
        m_bCommandOpened = false;
        NotifyModelChanged();

        CDocState stAfterState;
        stAfterState.Capture(m_stModel);
        if (stAfterState.IsSameTo(m_stBeforeState))
        {
            // 状态没变:不入栈,避免"空命令"污染 undo/redo 序列
            return;
        }
        // ... 压入 CCommandHistory
    }

这个"相同则不入栈"的判断很重要。比如拖拽图元时,如果鼠标只是抖了一下、最终位置没变,就不会产生一条毫无意义的 undo 记录;再比如右键菜单里把图元改挂到它本来就在的图层,也不会留下一条空命令。

一次拖拽过程中的连续移动则靠 m_bLiveCommandOpened 标记合并成一条命令------用户按下鼠标、拖、松手,只对应一次 undo。

3.3 XML 持久化与向前兼容

需求明确要求"支持向前兼容",所以没有用二进制,而是自行实现了一套轻量 XML 读写(CXmlNode + CXmlArchive,不依赖第三方库)。存档骨架如下:

复制代码
<?xml version="1.0" encoding="UTF-8"?>
<ShapeDoc format-version="1" min-support-version="1"
          next-shape-id="12" next-layer-id="3">
  <Canvas Width="1920" Height="1440" BackColor="#FF000000"
          ShowGrid="1" GridSize="20" ShowRuler="0" SnapToGrid="0"/>
  <Layers>
    <Layer Id="1" Name="图层1" Visible="1" Locked="0"/>
  </Layers>
  <Shapes>
    <Shape type="rectangle" Id="11" Name="矩形" LayerId="1">
      <Bounds X="100" Y="200" Width="160" Height="90"/>
      <Style LineColor="#FFFFFF00" LineWidth="1" PenStyle="solid"
             Filled="1" BrushStyle="solid" FillColor="#FF0000FF" AntiAlias="1"/>
    </Shape>
  </Shapes>
</ShapeDoc>

向前兼容靠三条规则落地:

  1. 版本号分离 :format-version 表示写入方的格式版本,min-support-version 表示"最低需要什么版本的程序才能正确读取"。高版本写入、低版本打开时,程序能据此判断是"可以读但会降级"还是"读不了"。

  2. 未知属性忽略:解析时遇到不认识的属性直接跳过,不影响已知字段。

  3. 未知节点原样保留 :这是最容易被忽略的一条。遇到不认识的图元类型时,CShapeFactory 不返回空指针、不丢弃,而是返回一个占位图元 CShapeReservedRaw,把原始 XML 子树整个吃进去,保存时再原样吐出来:

    复制代码
     CShapeBase* CShapeFactory::CreateShape(const std::string& strTypeName) const
     {
         const et_ShapeType eType = GetShapeTypeByName(strTypeName);
         if ((et_ShapeType_UnknownType == eType) || (et_ShapeType_ReservedRaw == eType))
         {
             // 未知类型:返回向前兼容占位图元,避免高版本数据被丢弃
             return new CShapeReservedRaw();
         }
         CShapeBase* pResult = CreateShape(eType);
         return (NULL != pResult) ? pResult : NULL;
     }

这样一来,"用新版本软件画了一张图、用旧版本打开改了一下、再用新版本打开"这条路径上,新版本特有的内容不会被悄悄抹掉。这条规则是有测试兜底的:需求覆盖测试里专门造了一份"版本号 99 + 注入未知属性 + 插入未知图元"的文件,断言加载成功且回写后未知内容仍在。

3.4 连接线路由

连接线要支持"横向和纵向的折线,可根据需要增加折点"。实现上,连接线记住源图元、目标图元与若干人工折点,CDrawModel::UpdateAllConnector() 在图元移动、增删、改层后统一重算路由:无人工作折点时按源/目标相对位置自动选择横向优先或纵向优先的正交路径;用户双击线上加了折点后转为自定义走向,折点全部删除后回到自动走向。

4.人机界面层实现

4.1 画布设计

画布是 wxScrolledWindow 派生类,滚动区域由 SetVirtualSize + SetScrollRate 给出,画布默认 1920×1440,超出窗口客户区时自动出现横/纵向滚动条。

"避免全画面闪烁"和"防止残影"这两条需求,靠的是场景位图 + 局部脏矩形:

  • 维护一张与画布同尺寸的内存位图 m_bmpScene 作为"场景层",所有图元画在这张位图上;
  • 选中把手、橡皮筋、悬停气泡这类"浮层"每次直接画到屏幕;
  • 需要更新某块区域时,只重建位图的那一块,再把那一块 Blit 回屏幕,并 SetClippingRegion 限定范围。
cpp 复制代码
    void CWxCanvas::RebuildSceneRect(const CGeoRect& rcLogical)
    {
        PrepareSceneBitmap();
        if (!m_bSceneReady)
        {
            return;
        }
        // 适度扩大:防止边界处出现残影(需求六.6)
        const wxRect rcClip = ToIntRect(rcLogical).Inflate(2);
        RenderSceneRect(rcClip);

        // 立即同步显示:局部把场景 Blit 到屏幕并重绘浮层,避免全画面刷新造成闪烁
        wxClientDC stClientDc(this);
        DoPrepareDC(stClientDc);
        stClientDc.SetClippingRegion(rcClip);
        RestoreRectFromScene(stClientDc, rcClip);
        DrawOverlayLayer(stClientDc);
    }

Inflate(2)(拖动图元时按线宽取 23)就是需求六.6 说的"重绘区域适度扩大"------图元有 18 像素的线宽,如果严格按包围盒裁剪,边缘那一两个像素的老轨迹就擦不干净,拖动时会在身后拖出一条"影子"。

配合 wxBG_STYLE_PAINT 与 wxNO_FULL_REPAINT_ON_RESIZE,窗口缩放时也不会满屏闪白。

4.2 工具栏

需求五.6 要求"各个工具栏支持灵活拖动和停靠",wxToolBar 做不到,要用 wxWidgets 的 AUI(wxAuiManager + wxAuiToolBar)。原来一整条工具栏被拆成 6 条独立面板:

面板 内容 默认位置
文件 新建 / 打开 / 保存 上排 1
编辑 撤销 / 重做 / 合并 / 解散 / 属性 上排 2
绘图工具 9 种图元选择按钮 上排 3
对齐分布 6 种对齐 + 2 种均分 下排 1
图层 当前图层下拉 / 新增 / 显示隐藏 / 图层管理 下排 2
样式 线型 / 线宽 / 线色 / 填充样式 / 填充色 / 画布属性 下排 3

每条面板创建时声明"四边可停靠、可浮动、带抓手":

cpp 复制代码
wxAuiToolBar* CWxMainFrame::CreateToolBarPane(int nIndex)
{
    const SToolBarPaneInfo& rstInfo = k_arrToolBarPaneInfo[nIndex];
    wxAuiToolBar* pToolBar = new wxAuiToolBar(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, (wxAUI_TB_DEFAULT_STYLE | wxAUI_TB_TEXT | wxAUI_TB_GRIPPER));
    pToolBar->SetToolBitmapSize(wxSize(16, 16));
    wxAuiPaneInfo stPane;
    stPane.Name(wxString::FromUTF8(rstInfo.pszName))
              .Caption(wxString::FromUTF8(rstInfo.pszCaption))
              .ToolbarPane().Top().Row(rstInfo.nRow).Position(rstInfo.nPos)
              .Gripper(true).Floatable(true).Dockable(true)
              .LeftDockable(true).RightDockable(true)
              .TopDockable(true).BottomDockable(true)
              .MinSize(wxSize(96, 30)).Show(true);
    m_stAuiMgr.AddPane(pToolBar, stPane);
    return pToolBar;
}

用户可以按住抓手把任一条拖到上/下/左/右停靠,拖出窗口外则变成浮动工具窗。布局(停靠位置、浮动状态、显隐)用 wxAuiManager::SavePerspective() 存成字符串写进配置文件 app_config.xml,下次启动 LoadPerspective() 原样恢复;视图菜单里另加了"工具栏"子菜单逐条显隐,以及一条"复位工具栏布局"。

4.3 交互操作

  • 拉框选择 :橡皮筋矩形与图元求交(HitTestBox),碰到即选中;按住 Shift 追加。
  • 拖拽与键盘移动 :选中图元整体移动,做边缘约束------整体超出画布边界时按最大可移动量截断,表现为"到边上就停住",而不是跑出去一部分。
  • 悬停提示:鼠标停留超过延时阈值才弹气泡,移动后立即消失,用定时器实现,避免鼠标扫过时一片闪烁。
  • 双击:文本图元进文本编辑;折线/多边形/连接线上双击则在鼠标位置插入折点(先把点吸附到最近线段,再判断是否落在段内);其他情况弹出属性对话框。绘制完一个图元后立刻回到"待选"状态。
  • 右键菜单:在图元上点右键弹出组合菜单------首项是"选择图层"子菜单,列出全部图层并勾选当前所在图层(多选且分散在不同层时都不勾),点击即把整个选择集改挂过去;折线/连接线/多边形还会多出一条"删除"子菜单,逐条列出"折点/顶点 N (x, y)",点哪条删哪条。

"选择图层"子菜单的构造逻辑如下(节选):

cpp 复制代码
        // 选中图元的公共图层
        int nCommonLayer = 0;
        bool bSameLayer = false;
        for (std::set<int>::const_iterator itId = m_setSelectedId.begin();
             itId != m_setSelectedId.end(); ++itId)
        {
            const TShapePtr spShape = rModel.FindShape(*itId);
            if (NULL == spShape.get()) { continue; }
            if (!bSameLayer)
            {
                nCommonLayer = spShape->GetLayerId();
                bSameLayer = true;
            }
            else if (nCommonLayer != spShape->GetLayerId())
            {
                bSameLayer = false;
                nCommonLayer = 0;
                break;
            }
        }
        wxMenu* pSubMenu = new wxMenu();
        for (size_t i = 0; i < rcrLayers.size(); ++i)
        {
            wxMenuItem* pItem = pSubMenu->AppendCheckItem(
                et_MmiCommandId_LayerPickBase + static_cast<int>(i),
                wxString::FromUTF8(rcrLayers[i].GetName().c_str()));
            if (bSameLayer && (rcrLayers[i].GetId() == nCommonLayer))
            {
                pItem->Check(true);
            }
        }
        rMenu.AppendSubMenu(pSubMenu, wxString::FromUTF8("选择图层(&L)"));

改层和删折点这两个动作都包在 BeginCommand/EndCommand 里,所以都能撤销;折线至少保留 2 点、多边形至少保留 3 点,不足时响铃提示而不删除。

5.关键实现细节与踩过的坑

5.1 nmake编译脚本三条铁律

Makefile.vc 踩了三个坑,每一个都不报错、只是"静默地不工作":

  1. 不能写中文注释 。UTF-8 多字节注释会导致紧随其后的宏定义被静默丢弃------实测表现为 CFLAGS_INC 变空、cl 收不到 /I、满屏 C1083。最终 Makefile.vc 保持纯 ASCII 注释。
  2. 配方行必须 TAB 缩进,4 个空格不行。
  3. MSYS bash 会破坏参数里的反斜杠 (\n → /n、C:/ → C://),必须 export MSYS_NO_PATHCONV=1,否则 /nologo 会被当成路径去转换。

5.2 改头文件只增量编译:启动即崩

这是本次开发中代价最大的一个坑。现象是:主框架建好、一创建子框架就段错误退出(退出码 139)。排查时临时插日志定位到崩溃发生在 new CWxCanvas 附近,看着像是画布构造有问题。

真实原因是:Makefile.vc 没有建立头文件依赖 ,改了 CWxMainFrame.h / CWxCanvas.h 之后做增量编译,旧的 .obj 与新头文件 ABI 不匹配(成员布局变了),链接出来的程序自然一跑就崩。

可靠做法不是删目录重建(rm -rf bin_debug 会因为目录里被占用的 exe 被拒绝、连带中断命令链),而是强制所有 cpp 重编:

cpp 复制代码
find src -name "*.cpp" -exec touch {} +
nmake /f Makefile.vc CFG=Release all

结论:改公共头文件后,直接全量重编,不要相信增量结果。

5.3 可执行程序无法启动:缺少运行库

编译链接都成功,但直接双击 wxdraw.exe 一点反应都没有。用 dumpbin /dependents 一看,除了系统库和 VC 运行库,还依赖两个 wxWidgets 的 DLL,而它们不在系统 PATH 里:

配置 需要的 wx DLL
Release wxmsw334u_core_vc_x64_custom.dll、wxbase334u_vc_x64_custom.dll(启用 AUI 后还要 wxmsw334u_aui_vc_x64_custom.dll)
Debug 对应的 ...ud... 版本,外加 ucrtbased.dll

解决办法是在 Makefile.vc 里加一个 deploy 目标,把 VC 运行库(Release 取 VC\Redist\MSVC\<ver>\x64\Microsoft.VC145.CRT,Debug 取 debug_nonredist\...\Microsoft.VC145.DebugCRT)和 wx 运行库一起拷到 exe 同目录;build_win.bat 在编译成功后自动调用它。这样输出目录里的 exe 双击即可运行,不必依赖任何环境变量。

5.4 编译告警清零

两套配置最终都做到了 0 error / 0 warning,过程中处理了几类典型告警:

  • wxPen/wxBrush 的整型样式参数触发 C4996 → 改为 static_cast<wxPenStyle> / static_cast<wxBrushStyle>;
  • GetCount() 返回无符号数与 int 比较触发 C4018 → 先 static_cast<int> 存局部变量再比较;
  • 测试里 const int n = GetShapeCount() 触发 C4267 → 改用 size_t。

5.5 两套测试:冒烟测试与系统测试

核心层不依赖图形库,因此测试程序可以做成纯控制台程序,不链接 wxWidgets,跑起来是秒级的:

测试程序 用例数 覆盖范围
test_smoke.exe 33 模型增删查、序列化往返(含中文与 ARGB 颜色)、连接线路由、撤销重做、图层、默认值等。
test_require.exe 86 按需求条目逐项验证。

需求覆盖测试的价值在于把"需求文档里的每一句话"变成一条可执行断言。举几个例子:

cpp 复制代码
    Check((k_unColorBlack == stCanvas.m_unBackColor), "六.1 画布背景默认黑色");
    Check((1920.0 == stCanvas.m_dWidth) && (1440.0 == stCanvas.m_dHeight), "六.1 画布默认尺寸 1920x1440");
    Check(1 == stModel.GetLayerCount(), "六.3 默认仅 1 个图层");
    Check((!rcrLayers.empty()) && ("图层1" == rcrLayers[0].GetName()), "六.3 默认图层名为"图层1"");
    Check(!stModel.IsShapeVisible(*spOnLayer), "六.3 图层可隐藏(该层图元不可见)");

写这套测试时也纠正过自己的错误:一开始向前兼容用例用自造的 <WxDrawDoc Version="99"> 去测,3 条断言失败------查下来是测试写错 (真实 schema 是 <ShapeDoc> + format-version),不是实现缺陷。改成"先 SaveToFile 出真实存档 → 字符串改写版本号 → 注入未知属性 → 插入未知图元"之后全部通过。

6.运行效果

编译(nmake /f Makefile.vc CFG=Release all test require deploy)后的产物:

配置 产物
bin_release/ wxdraw.exe、test_smoke.exe、test_require.exe + VC 运行库 10 个 + wx 运行库 3 个
bin_debug/ 同名 exe(带 pdb)+ DebugCRT 运行库 + ucrtbased.dll + wx debug 运行库
obj/release、obj/debug 中间文件

启动后主窗口标题为WxDraw 矢量绘图工具-[未命名],6条工具栏默认分两排停靠在上方,可任意拖动停靠或拖出为浮动窗;画布默认为1920×1440黑底,超出视图部分由滚动条承载;绘制、选择、对齐、合并打散、图层切换、撤销重做均可用。

7.结语

本文以wxWidgets 3.3.4为图形库,完整实现了一个可用的矢量绘图软件:从需求条目出发做分层设计,用CCanvasPort抽象端口把业务核心与图形库解耦,用 XML+占位图元保证文件格式的向前兼容,用场景位图 + 局部脏矩形解决闪烁与残影,用AUI实现可停靠工具栏,最后用 nmake/CMake/VS2026三套脚本把它固化成可重复构建的工程。

回头来看,真正花时间的不是画得出图形,而是那些容易被忽略的工程细节,例如文件格式怎么保持兼容性、撤销队列怎么不留空记录、重绘区域差两个像素就会拖出残影、改了头文件必须全量重编、exe能不能双击打开取决于运行库有没有带齐。这些才是演示代码与能交付软件之间的差距。

后续可继续完善的方向包括:连接线路由的自动避障、图元的旋转、打印与导出图片、Linux下用 CMake实际编译验证等,欢迎大家提出宝贵意见。

相关推荐
波力海苔夹心脆6751 小时前
C# 固高 GTS 运动控制卡入门:伺服上下电、Jog 点动与二维插补(WinForms 实战)
服务器·开发语言·c#·运动控制·伺服电机·winforms·固高
海绵宝宝转agent1 小时前
LeetCode100个人题解+java面经总结
java·开发语言·笔记·学习·算法
chenbingjie_c1 小时前
C++ 继承全解析(从三种继承方式到菱形继承与内存布局)
开发语言·c++
火2251 小时前
# C++ 微服务即时通讯系统(四):MySQL、Redis 与 Elasticsearch——数据分工与一致性
c++·mysql·微服务
学逆向的9 小时前
C 堆区内存管理
c语言·开发语言·网络安全
huoyueyi10 小时前
《融天AI-APP从0到1开发实录》
开发语言·lua
xiaoye-duck11 小时前
《C++ 工具库实战》gflags 命令行参数解析库详解
c++·第三方库
OpenAnolis小助手11 小时前
PHP 两个严重漏洞曝光,龙蜥社区 24 小时完成 3 版本修复
开发语言·操作系统·php·龙蜥社区
狂奔的sherry12 小时前
C++ 宏生成访问函数的代价:访问权限、调试和类型检查
c++·宏·编译·预处理器·类型检查