☰
C++二维图形绘制与信息显示系统:类设计、多态与工程实践
2026/10/15 16:42:24 网站建设 项目流程

简介:一个完整的C++面向对象绘图程序,基于MFC框架,实现鼠标绘制直线、多边形等二维图形。程序采用单文档界面,通过菜单栏“当前模式”进入绘图状态,在视图窗口中选择图形并绘制。构建了Shape基类及AreaShape、LineShape抽象子类,图形绘制后自动显示顶点、长度、面积、周长,并支持边框与填充颜色修改、平移旋转操作,还能统计图形数目并保存/读取工程文件,适合C++课程设计及MFC入门参考。资源包共122个文件,以.cpp、.h源文件为主,包含15个cpp、15个h及对应obj工程产物,另有sln/vcxproj等VS工程配置与可执行文件,整体182.78MB,目录结构清晰便于定位。已有243人学习下载,对学习面向对象继承设计、熟悉MFC消息映射与绘图流程有较直接的参考价值,可直接编译运行观察效果,也可借用Shape派生结构扩展新图形。

1. C++面向对象程序设计 二维图形绘制与信息显示系统:把绘图代码组织得不乱的三个接线柱

“C++面向对象程序设计 二维图形绘制与信息显示系统”是很多高校课程设计的常客,也是我觉得性价比极高的一类练手项目。题目表面上只要求你做两件事:画出二维图形、把这些图形相关的信息显示出来,但如果你真的把所有绘图动作都堆在 main 函数里,画一个圆要改三个地方,显示信息靠 printf 打地鼠,那这个题目就算白做了。这个系统的本质是用类层次、多态和事件回调把“图形数据”“绘制动作”“信息展示”三件事拆开,让任意一个模块改动时不影响另外两个。绘图算法反而是最不花时间的部分,真正的分水岭在第一版类设计。

常见的翻车姿势是这样的:图形类里混着绘制代码,绘制代码里混着坐标变换,坐标变换里又混着文字输出,最后整个工程只有一个 window 类加一个 main 函数。到了验收演示阶段,老师说“加一个统计图形数量的功能”,你得翻半小时代码才能找到往哪里插。下面我按一套可落地的方案,从类图拆解、绘制实现、信息显示到避坑和进阶,把这条路完整走一遍。

2. 从需求到类图:第一版类设计决定这个题目的分数上限

2.1 先把需求拆成三个模块:图形对象、画布管理、信息显示

拿到这个题目不要急着写代码,先在纸上把职责边界画清楚。我一般会拆成三个模块:图形对象负责“我是谁、我在哪、怎么绘制自己”;画布管理负责“手上有哪些图形、鼠标点了哪里、要不要重绘”;信息显示负责“把图形状态和统计信息格式化输出到屏幕的某个区域”。这三块的依赖方向是单向的:画布管理依赖图形对象,信息显示只接收事件不反向操作画布。

先给出两个基础类型,后面所有代码都建立在它们上面:

// 逻辑坐标用 double,避免缩放或拖动时反复取整造成偏移积累 struct Point { double x, y; }; // 矩形区域,用于画布裁剪、脏矩形标记和视口换算 struct Rect { double left, top, right, bottom; };

图形对象的基类可以这样设计:

enum class ShapeType { Line, Rect, Circle }; class Shape { public: Shape(const Point& center, uint32_t color, int thickness) : center_(center), color_(color), thickness_(thickness) {} // 基类析构必须声明为 virtual,否则多态删除子类时行为未定义 virtual ~Shape() = default; // 纯虚函数:每种图形负责用中点算法/Bresenham算法把像素画到画布上 virtual void draw(Canvas& canvas) const = 0; // 纯虚函数:面积既提供给信息显示,也用于将来做碰撞检测 virtual double area() const = 0; // 平移是通用能力,放在基类实现 void moveBy(double dx, double dy) { center_.x += dx; center_.y += dy; } void setColor(uint32_t color) { color_ = color; } Point center() const { return center_; } uint32_t color() const { return color_; } protected: Point center_; uint32_t color_; int thickness_; };

这里有两个关键决定。第一,坐标用 double 而不是 int,因为图形一旦涉及缩放或多次拖动,int 的截断误差会累积,最终出现图形“越拖越歪”的玄学问题。第二,draw 和 area 都设计成纯虚函数,目的就是逼着每个派生类自己实现,而不是在基类里用 switch 分支判断类型。如果你在基类里写if (type == CIRCLE),那类设计就退回到面向过程了。

派生类的典型写法如下:

class Circle final : public Shape { public: Circle(const Point& center, double radius, uint32_t color, int thickness) : Shape(center, color, thickness), radius_(radius) {} void draw(Canvas& canvas) const override { // 中点画圆算法,见第3章 3.2 节 drawCircleMidpoint(canvas, center_, radius_, color_, thickness_); } double area() const override { return 3.14159265358979323846 * radius_ * radius_; } void setRadius(double r) { radius_ = r; } private: double radius_; };

area() 看起来只是写给人看的统计信息,但它在后续做“选中某个图形后,信息面板显示面积”时会成为数据源。如果信息显示模块直接去读每个图形的 private 成员,你就得给所有图形开 getter,那又多了一层耦合。把 area() 放在基类接口里,信息显示模块只需要面对一个const Shape*。

2.2 用多态绘制代替 switch 分支:ShapeFactory 注册表做法

有了基类和派生类,下一个问题是:用户在菜单栏点“画圆”,程序怎么知道要创建哪个对象?很多同学的第一反应是 switch:

Shape* createShape(ShapeType type, const Point& pos) { switch (type) { case ShapeType::Circle: return new Circle(pos, 30.0, 0xFF0000, 2); case ShapeType::Rect: return new Rect(pos, 50.0, 30.0, 0x00FF00, 1); // 每加一种图形,就要改这个函数 } }

这种做法能跑,但你每新增一种图形,就得回头改这个工厂函数,而且函数会越来越长。我习惯的做法是注册表模式:每个图形类自己向工厂登记“创建函数”,工厂只负责管理和调用,不需要知道具体的类型名。

class ShapeFactory { public: // Creator 是一个回调,调用时才把默认参数补上 using Creator = std::function<std::unique_ptr<Shape>(const Point& center)>; static bool registerType(ShapeType type, Creator creator) { registry()[type] = std::move(creator); return true; } static std::unique_ptr<Shape> create(ShapeType type, const Point& center) { auto it = registry().find(type); if (it == registry().end()) { return nullptr; // 没注册的类型,返回空指针,调用方兜底 } return it->second(center); } private: static std::map<ShapeType, Creator>& registry() { // C++11 保证局部静态变量的初始化是线程安全的 static std::map<ShapeType, Creator> instance; return instance; } }; // 在某个 .cpp 文件里注册,一般放在该图形类定义旁边 static bool registeredCircle = ShapeFactory::registerType( ShapeType::Circle, [](const Point& center) { return std::make_unique<Circle>(center, 30.0, 0xFF0000, 2); });

这里有几个参数设计值得说明。第一,注册函数返回 bool,配合 static bool 全局变量,可以在程序加载时完成注册;如果不关心注册顺序,也可以放在一个统一的初始化函数里调用。第二,Creator 用std::function带回调用,允许每个图形类在 lambda 里设置自己的默认尺寸和默认颜色,工厂不需要知道这些默认值具体是什么。第三,工厂返回unique_ptr<Shape>而不是裸指针,所有权语义清晰,画布容器直接接管,不存在new了之后忘了delete的问题。

注册表方案的最大收益是“开闭原则”:新增一种图形时,你只需要新建一个派生类,并在文件里加一行注册代码,第 2.1 节的基类、工厂函数、画布容器完全不用动。对课程设计这种“老师会现场要求加一种图形”的场景,这个优势非常明显。

2.3 画布管理器与消息管道:图形列表和事件通知的边界

画布管理器持有图形列表,并提供增删改查接口。这个类要保证:外部不能直接操作图形数组,删除时由容器统一释放,迭代过程不会被外部打乱。

class CanvasManager { public: size_t addShape(std::unique_ptr<Shape> shape) { shapes_.push_back(std::move(shape)); // 把一个 ShapeMessage 推给订阅者,让信息显示模块知道图形增加了 ShapeMessage msg; msg.kind = ShapeMessage::Kind::ShapeAdded; msg.shape = shapes_.back().get(); msg.extraText = "new shape"; publish(msg); return shapes_.size() - 1; } std::unique_ptr<Shape> removeShapeAt(size_t index) { if (index >= shapes_.size()) return nullptr; auto shape = std::move(shapes_[index]); shapes_.erase(shapes_.begin() + static_cast<ptrdiff_t>(index)); return shape; // 所有权交还给调用者 } void setMessageSink(std::function<void(const ShapeMessage&)> sink) { sink_ = std::move(sink); } void drawAll(Canvas& canvas) const { for (const auto& shape : shapes_) { shape->draw(canvas); } } size_t count() const { return shapes_.size(); } private: void publish(const ShapeMessage& msg) { if (sink_) sink_(msg); } std::vector<std::unique_ptr<Shape>> shapes_; std::function<void(const ShapeMessage&)> sink_; };

消息结构体和事件类型的定义如下:

struct ShapeMessage { enum class Kind { ShapeAdded, ShapeMoved, ShapeDeleted, SelectionChanged }; Kind kind; const Shape* shape; // 只读指针,信息显示模块不允许修改图形 std::string extraText; };

这个设计的核心是消息管道。画布管理器在图形增删、移动时发布事件,InfoBoard(信息显示模块)订阅这个事件。画布不需要知道自己被谁观察,InfoBoard 也不去访问画布的内部数据,两者通过std::function<void(const ShapeMessage&)>连接。使用std::function而不是定义一个纯虚接口的好处是:单元测试时可以直接传入一个 lambda 记录消息,不需要为测试专门写一个观察者子类。

3. 把图形真正画出来:坐标系、中点画圆与重绘策略

3.1 坐标系约定是第一个坑:屏幕坐标与逻辑坐标的换算

教学图形库通常使用屏幕坐标系:原点在窗口左上角,x 轴向右,y 轴向下。而我们在第 2 章里给 Point 的逻辑坐标是数学坐标系:y 轴向上。如果你直接把鼠标位置塞给 circle.center,画出来的圆会和鼠标位置差一行,而且缩放之后偏差越大。

常见的做法是在画布管理器里维护一个视口结构:逻辑坐标的左、上边界,以及缩放比例。鼠标产生的屏幕坐标先转换成逻辑坐标,再交给图形对象;绘制时反向转换。

struct Viewport { double left = 0.0; double top = 0.0; double scale = 1.0; // 逻辑单位到像素的放大倍数 }; Point screenToLogic(const Viewport& vp, int screenX, int screenY, int windowHeight) { // 屏幕 y 向下,逻辑 y 向上,所以用 windowHeight - screenY 翻转 return Point{ vp.left + static_cast<double>(screenX) / vp.scale, vp.top + static_cast<double>(windowHeight - screenY) / vp.scale }; }

这里最容易被忽略的参数是windowHeight。很多同学只做了 x、y 的缩放,忘了 y 轴翻转,结果图形的垂直方向永远镜像。还有一个细节:windowHeight应该是客户区高度,而不是整个窗口高度,带菜单栏和标题栏的窗口直接取窗体总高会让坐标偏移。

缩放倍率和偏移量的选择会影响绘制质量。例如scale = 2.0时,一个逻辑上 1 单位宽的方块在屏幕上占 2 像素;此时如果线宽 thickness 还是 1,视觉上会显得偏细。我一般会把线宽也乘以 scale,并保证最小线宽为 1 像素,避免缩放过小时图形“消失”。

3.2 从直线到圆:中点画圆算法实现与误差边界

教学图形库一般提供画点原语,但画圆、画直线最好自己做,因为这是这个题目的加分项。中点画圆算法基于圆的隐式方程x² + y² - r² = 0,每次递增 x,根据决策参数判断 y 应该保持不变还是减 1。

void drawCircleMidpoint(Canvas& canvas, const Point& center, double radius, uint32_t color, int thickness) { // 圆心像素位置,先把逻辑坐标取整一次 int cx = static_cast<int>(std::round(center.x)); int cy = static_cast<int>(std::round(center.y)); // 半径小于等于 0 直接退化成一个点,避免后续除零或无限循环 if (radius <= 0.0) { canvas.pixel(cx, cy, color); return; } int x = 0; int y = static_cast<int>(std::round(radius)); // 决策参数初始值:1.25 - r;如果要求全程整数运算,可改写为 1 - r double d = 1.25 - radius; while (x <= y) { // 利用圆的八分对称性,一次循环画 8 个点 canvas.pixel(cx + x, cy + y, color); canvas.pixel(cx - x, cy + y, color); canvas.pixel(cx + x, cy - y, color); canvas.pixel(cx - x, cy - y, color); canvas.pixel(cx + y, cy + x, color); canvas.pixel(cx - y, cy + x, color); canvas.pixel(cx + y, cy - x, color); canvas.pixel(cx - y, cy - x, color); ++x; if (d < 0) { d += 2 * x + 1; } else { --y; d += 2 * (x - y) + 1; } } // thickness 大于 1 时,用同心圆把轮廓加粗 for (int t = 1; t < thickness; ++t) { drawCircleMidpoint(canvas, center, radius - t, color, 1); drawCircleMidpoint(canvas, center, radius + t, color, 1); } }

为什么初始决策参数是1.25 - radius?因为圆的隐式方程在格点上取值,取x=0, y=r时f = r² + (r-1)² - r²,化简后是1 - 2r,加上 0.25 的调整项得到1.25 - r。很多教材直接写d = 1 - r也能工作,两者差异在于边界点 x=y 时是否多画一个像素。

这个函数有两个边界要说明。第一,当 radius 很大(比如超过几千像素)时,d用 double 累计的误差最多不会超过 1 像素,对屏幕显示足够;但如果你要做无损导出到矢量文件,就不能用这个算法,应该走 SVG 之类的矢量输出。第二,加粗逻辑是递归调用自己,t 每加 1 就画两个同心圆,这会让线宽变厚但两头是圆的;如果图形库不支持这样递归,也可以先用一个临时离屏缓冲画好再整体填充,不过性能会差一些。

3.3 重绘策略:双缓冲和脏矩形,别让整个窗口闪瞎眼

绘制模块最常见的翻车是闪屏。原因很简单:每次鼠标移动,你都直接清空窗口再逐点画图形,Windows 图形接口下能明显看到画面闪烁。常见做法是把所有图形先画到一块内存位图里,然后一次性把整块位图拷贝到屏幕,这个技术叫双缓冲。

教学图形库通常会提供“离屏缓冲区”对象,没有的话就自己建。伪代码如下:

class DoubleBufferedCanvas { public: void beginFrame() { // 清空离屏缓冲为背景色 buffer_.fillRect(backgroundColor()); } void endFrame() { // 一次性提交整块位图到窗口 buffer_.saveToWindow(); } Canvas& canvas() { return buffer_; } private: Canvas buffer_; // 离屏缓冲 };

重绘流程变成:

void repaint(CanvasManager& mgr, DoubleBufferedCanvas& dbs) { dbs.beginFrame(); mgr.drawAll(dbs.canvas()); // 所有图形画到离屏缓冲 dbs.endFrame(); // 提交,完成一次无闪烁重绘 }

双缓冲解决了闪屏,但代价是每次鼠标移动都重绘所有图形。图形量少时无所谓,图形达到几百个时,全量重绘会造成明显卡顿。这时可以用脏矩形优化:只重绘鼠标移动影响到的区域,而不是整张画布。

// 鼠标从 oldPos 拖到 newPos,只有这两个点附近的区域受影响 void onMouseDrag(const Point& oldPos, const Point& newPos, int handleSize) { Rect dirty = unionRect( expandRect(Rect{oldPos.x, oldPos.y, oldPos.x, oldPos.y}, handleSize), expandRect(Rect{newPos.x, newPos.y, newPos.x, newPos.y}, handleSize) ); // 告诉画布只有 dirty 区域需要重绘 canvas.invalidate(dirty); }

这里的handleSize必须比图形线宽大,因为线宽超过 1 时,图形边界外还有像素。如果线宽是 3,脏矩形至少要往四边扩展 3 像素,否则会留下上一帧的残影。这类残影是脏矩形优化最常见的 bug,现象是“拖动图形后后面拖出一条尾巴”。

4. 信息显示系统:和画布解耦的 InfoBoard 与字符集地雷

4.1 InfoBoard 的接口设计:数据源和显示端解耦

信息显示模块不需要关心图形是怎么画出来的,它只关心“什么时候有什么事件发生”。第 2.3 节的消息管道已经铺好了路,这里 InfoBoard 只需要实现一个接收消息的函数,并把消息渲染到自己的显示区域。

class InfoBoard { public: // 画布管理器把消息推到这里,InfoBoard 负责格式化并保存 void onShapeMessage(const ShapeMessage& msg) { switch (msg.kind) { case ShapeMessage::Kind::ShapeAdded: pushLine("添加图形: " + describe(*msg.shape)); break; case ShapeMessage::Kind::SelectionChanged: pushLine("选中: " + describe(*msg.shape) + " 面积=" + std::to_string(msg.shape->area())); break; case ShapeMessage::Kind::ShapeDeleted: pushLine("删除图形"); break; default: break; } } // 把缓存的文字画到画布的指定区域,区域由外部传入,避免自己猜位置 void render(Canvas& canvas, const Rect& area) const { canvas.fillRect(area, kBackgroundColor); int row = 0; for (const auto& line : lines_) { // 每行固定行高,超出显示区域就停止 canvas.drawText(area.left + 8, area.top + row * kLineHeight, line); ++row; if (row * kLineHeight > area.bottom - area.top) break; } } private: std::string describe(const Shape& shape) const { // 这里如果想要更详细的描述,需要在 Shape 基类加一个纯虚 description() return std::string("shape at (") + std::to_string(shape.center().x) + "," + std::to_string(shape.center().y) + ")"; } void pushLine(const std::string& line) { lines_.push_back(line); // 最多保留 8 行,超出就把最旧的一行丢掉,类似环形队列 if (lines_.size() > 8) { lines_.erase(lines_.begin()); } } std::vector<std::string> lines_; static constexpr int kLineHeight = 20; static constexpr uint32_t kBackgroundColor = 0xF0F0F0; };

这里的限制条件值得多看一眼:行数和行高都是常量,信息区高度不够时直接停止绘制剩余行。演示时老师如果全屏显示,8 行可能够;但如果窗口缩小,渲染代码会继续画到区域外面。更稳妥的做法是在 render 里先计算area能装几行,然后用这个数截断lines_。

4.2 中文与字符编码:信息显示最常见的翻车点

教学图形库的文字接口大多按本机默认代码页解释字符串。如果代码文件是 UTF-8 编码,而图形库按 GBK 解释,INFO 区域的中文就会全部变成乱码。这个问题的坑在于:IDE 里调试时可能正常,因为 IDE 内部会把资源文件按 UTF-8 解析;但切换到独立运行时,系统代码页一变,乱码立刻出现。

常见的做法是把显示字符串统一转成宽字符:

std::wstring utf8ToWide(const std::string& utf8Str) { if (utf8Str.empty()) return L""; // 第一步:查询转换后的宽字符长度 int wideLen = MultiByteToWideChar(CP_UTF8, 0, utf8Str.c_str(), static_cast<int>(utf8Str.size()), nullptr, 0); if (wideLen <= 0) return L""; // 第二步:实际转换,wideLen 已包含终止符,这里去掉它 std::wstring result(wideLen - 1, L'\0'); MultiByteToWideChar(CP_UTF8, 0, utf8Str.c_str(), static_cast<int>(utf8Str.size()), &result[0], wideLen - 1); return result; }

配合宽字符版本的文字绘制函数,把std::wstring交给图形库的宽字符接口,中文就能正常显示。如果你的图形库不支持宽字符文本,那就要反向转换:先在自己的代码里把 UTF-8 字符串转成本机代码页的窄字符串。注意,MultiByteToWideChar的参数cbMultiByte如果传 -1,会把末尾的\0也计算进去;用utf8Str.size()这种显式长度可以避免多算一个字符后出现奇怪的空白。

4.3 定时刷新与数据上报:让信息区实时变化的几种姿势

题目里“信息显示”如果只是把事件打出来,那太静态了。更常见的需求是实时显示“当前图形总数、鼠标位置、FPS”。这类数据不在事件驱动范围内,需要单独的刷新机制。

在单线程的教学图形库环境里,最安全的是用界面主循环的定时器,而不是新开线程。定时器每隔 200ms 触发一次:

// 在主循环里注册一个每 200ms 回调一次的定时器 void onTimerTick(CanvasManager& mgr, InfoBoard& board) { ShapeMessage msg; msg.kind = ShapeMessage::Kind::SelectionChanged; // 复用现有通道 // 这里传一个空的 shape 指针不太好,所以更好的做法是给 ShapeMessage // 增加一个 StatUpdated 类型,专门用于统计信息刷新。 board.onStatUpdate(mgr.count()); }

如果确实要用线程做数据上报,注意一条铁律:工作线程不要直接调用绘制函数。原因很简单,UI 主线程可能在执行重绘,工作线程同时调用drawText会把缓冲区写成半新半旧的状态,出现的现象是文字闪烁、卡顿甚至直接崩溃。正确姿势是把数据写到原子变量里,再用定时器在 UI 线程取出来绘制。

std::atomic<int> g_lastReportedCount{0}; // 工作线程:只更新数据 void workerThread() { while (running) { int value = computeSomeValue(); g_lastReportedCount.store(value, std::memory_order_relaxed); } } // UI 线程:只在这里绘制 void onUiTimerTick(InfoBoard& board, const Rect& infoArea) { int v = g_lastReportedCount.load(std::memory_order_relaxed); board.setExternalStat("processed", std::to_string(v)); board.render(canvas, infoArea); }

刷新间隔的选择也要讲参数:200ms 是常用的人眼舒适值,对 FPS 这类高频指标也够用;如果你显示的是“内存占用”这种秒级变化的指标,500ms 到 1s 更合理,避免信息区疯狂跳动让人看不清。间隔小于 50ms 时,线程唤醒和绘制开销会占到主循环时间,反而让图形拖动卡顿。

5. 避坑与常见问题排查:这个题目里最容易翻车的 5 个点

5.1 图形对象复制后显示错乱:浅拷贝导致隐形联动

现象:把一个 Circle 对象复制给另一个变量,然后拖动其中一个,发现另一个也跟着动了。原因:类里有裸指针或引用成员,而系统生成的默认拷贝构造函数只做浅拷贝,两个对象共享同一块内存。解决:Shape 及其派生类的成员尽量用std::unique_ptr、std::string和值类型的 Point,不要用裸指针;如果无法避免裸指针,必须显式实现拷贝构造和拷贝赋值,或者把拷贝构造删除、改用Clone()接口。

5.2 关窗口时崩溃在析构函数:忘了虚析构

现象:程序正常绘制,但关闭窗口时弹出一个访问冲突,崩溃位置在某个析构函数里。原因:Shape 基类没有声明virtual ~Shape(),而派生类 Circle 里如果有unique_ptr<Texture>之类成员,用delete basePtr时只析构了基类部分,派生类资源没释放。解决:基类析构函数前面加virtual。这个是 C++ 多态的头号坑,也是这个题目最容易被面试官追问的点。

5.3 闪屏严重:双缓冲初始化放错了位置

现象:启动没问题,一画圆就闪烁,窗口拉伸时更明显。原因:双缓冲对象在每次函数调用时创建,又立刻销毁,根本没有起到缓冲作用。解决:把离屏缓冲区作为 CanvasManager 或窗口类的成员,只创建一次;不要在repaint()函数内部每次新建。判断标准是看日志:如果每帧都出现“创建缓冲”字样,说明位置错了。

5.4 INFO 区域中文乱码:字符集不匹配

现象:图形绘制正常,但信息区的中文文字全部变成问号或乱码。原因:代码文件保存为 UTF-8,但图形库默认按本机 ANSI 代码页解析字符串,两者不匹配。解决:用第 4.2 节的utf8ToWide转成宽字符,再调用宽字符文字接口;如果必须用窄字符,就在保存文件时选择本机代码页,或者在代码里设置setlocale(LC_ALL, ".UTF-8")后在运行时转换。

5.5 清空画布时野指针:遍历删除时迭代器失效

现象:点击“清空画布”,程序崩掉;或者清空后再次绘制,随机位置出现残留图形。原因:遍历图形容器时直接delete shapes_[i],删除一个元素后 vector 内部元素移动,下标或迭代器失效,继续访问就越界了。解决:使用std::vector<std::unique_ptr<Shape>>,清空只需调用shapes_.clear(),每个元素自动释放;如果要在循环中删除满足条件的图形,先记录要删除的下标,循环结束后统一删,或者使用std::remove_if。

6. 进阶玩法:给画布加一个撤销重做,顺便验证你的架构

6.1 命令模式的落地代码

如果画布已经能正常绘制和显示信息,下一个值得做的升级是撤销重做。这一功能在课程设计中很加分,更重要的是它直接验证前面的架构:如果图形对象、画布管理器、消息管道拆得足够干净,加撤销会比想象中简单。

我用命令模式:每个对画布结构的修改都封装成一个 Command,历史栈保存已执行命令,撤销弹栈并反向操作。

class Command { public: virtual ~Command() = default; virtual void execute(CanvasManager& mgr) = 0; virtual void undo(CanvasManager& mgr) = 0; }; class AddShapeCommand : public Command { public: AddShapeCommand(ShapeType type, Point pos) : type_(type), pos_(pos) {} void execute(CanvasManager& mgr) override { shape_ = ShapeFactory::create(type_, pos_); index_ = mgr.addShape(std::move(shape_)); } void undo(CanvasManager& mgr) override { // 从画布中取出所有权,保存到命令对象里 shape_ = mgr.removeShapeAt(index_); } private: ShapeType type_; Point pos_; std::unique_ptr<Shape> shape_; size_t index_ = 0; };

注意一个边界:index_是图形在列表中的位置,如果用户在撤销之前又添加或删除了别的图形,index_可能已经失效。稳妥的做法是给每个 Shape 一个唯一 id,用 id 定位而不是用下标。我这里为了简短用了下标,课堂上要给老师讲清楚这个局限,反而能展示你考虑过边界。

历史栈的管理:

class CommandHistory { public: void execute(std::unique_ptr<Command> cmd) { cmd->execute(*mgr_); undoStack_.push(std::move(cmd)); // 新命令执行后,重做栈应该清空,因为历史分支已经改变 std::stack<std::unique_ptr<Command>>().swap(redoStack_); } void undo() { if (undoStack_.empty()) return; auto cmd = std::move(undoStack_.top()); undoStack_.pop(); cmd->undo(*mgr_); redoStack_.push(std::move(cmd)); } void redo() { if (redoStack_.empty()) return; auto cmd = std::move(redoStack_.top()); redoStack_.pop(); cmd->execute(*mgr_); undoStack_.push(std::move(cmd)); } private: CanvasManager* mgr_; std::stack<std::unique_ptr<Command>> undoStack_; std::stack<std::unique_ptr<Command>> redoStack_; };

6.2 验证方法:用这个功能自检架构是否合格

写完后做三个操作就能验证前期设计是否靠谱。第一,连续添加三个图形,然后撤销两次再重做一次,看图形顺序和选中状态是否一致。第二,在撤销到一半时添加新图形,再点 undo,观察重做栈是否被正确清空。第三,把窗口尺寸改小再放大,检查脏矩形区域是否出现残影。这三个点覆盖了对象生命周期、命令栈状态、绘制缓存三个最容易出错的地方。

我自己的习惯是给画布设计里永远留一个“撤销”快捷键,哪怕第一版没有实现命令模式,也会在消息管道里预留一个 CommandHistory 的位置。有一次由于赶进度,直接把图形删掉不经过命令栈,结果演示时手滑删掉整个区域,只能关掉程序重来,那一幕让我印象深刻。架构里多留一条“后悔药”的通道,成本很低,后面的收益却很大。希望这个系统设计思路帮到你,下次做绘图类题目时,能少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询