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++ 标准 | C++14 |
构建方式 | nmake( |
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接口长这样(节选):
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下级的约定:
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`(拷贝运行库)。日常一条命令即可:
nmake /f Makefile.vc CFG=Release all test require deploy2.4 编码规范约束
代码不是随性写的,而是受一份技能规范约束,其中对代码形态影响最大的几条是:
- 类名以大写
C开头(CShapeBase)、成员以m_开头并按类型前缀做匈牙利式成员变量命名(m_pCanvas、m_bVisible、m_nId),多行变量定义用制表键对齐; - 成员变量优先用初始化列表;
- 涉及拷贝的类必须提供完整拷贝构造与
operator=; - 复杂形参一律传引用,禁止传值;
switch/case超过 3 个分支必须改为表格驱动;- 文件头注释逐行用
//---...---分隔,禁止/* */块注释,且要注明日期。
这套规范落到实处之后,文件头是这个样子的:
//--------------------------------------------------------------------------- // 文件名称: CShapeBase.h // 所属目录: src/include(公共头文件目录) // 文件说明: 图元虚基类 CShapeBase,以及存在外接矩形的图元公共基类 CBoundedShape。 // // 初次添加: 2026-10-28 新建本文件,实现上述功能。 //---------------------------------------------------------------------------"表格驱动替代多分支 switch"这条在画布的鼠标交互里体现得最明显——拖拽分派不是一大坨switch,而是两张函数指针表:
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>向前兼容靠三条规则落地:
- 版本号分离:
format-version表示写入方的格式版本,min-support-version表示"最低需要什么版本的程序才能正确读取"。高版本写入、低版本打开时,程序能据此判断是"可以读但会降级"还是"读不了"。 - 未知属性忽略:解析时遇到不认识的属性直接跳过,不影响已知字段。
- 未知节点原样保留:这是最容易被忽略的一条。遇到不认识的图元类型时,
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限定范围。
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 |
每条面板创建时声明"四边可停靠、可浮动、带抓手":
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)",点哪条删哪条。
"选择图层"子菜单的构造逻辑如下(节选):
// 选中图元的公共图层 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踩了三个坑,每一个都不报错、只是"静默地不工作":
- 不能写中文注释。UTF-8 多字节注释会导致紧随其后的宏定义被静默丢弃——实测表现为
CFLAGS_INC变空、cl 收不到/I、满屏 C1083。最终Makefile.vc保持纯 ASCII 注释。 - 配方行必须 TAB 缩进,4 个空格不行。
- 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 重编:
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 | 按需求条目逐项验证。 |
需求覆盖测试的价值在于把"需求文档里的每一句话"变成一条可执行断言。举几个例子:
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实际编译验证等,欢迎大家提出宝贵意见。