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>
向前兼容靠三条规则落地:
-
版本号分离 :
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限定范围。
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 踩了三个坑,每一个都不报错、只是"静默地不工作":
- 不能写中文注释 。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 重编:
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实际编译验证等,欢迎大家提出宝贵意见。