资讯动态

C++命令模式实现撤销重做:从原理到实战的通用框架设计

发布时间:2026/8/10 5:30:46 来源:尧图企业网站定制
1. 项目概述从“撤销”与“重做”说起在任何一个有交互的软件里无论是文本编辑器、图形设计工具还是我们正在开发的游戏编辑器“撤销”Undo和“重做”Redo功能都像是空气一样平时感觉不到一旦缺失用户就会立刻陷入抓狂。想象一下你刚刚在编辑器里精心调整了十几个参数手一滑点错了一个按钮所有改动瞬间消失却没有任何挽回的余地——这种体验足以让一个项目夭折。因此实现一个健壮、可靠的撤销重做机制是提升软件专业度和用户体验的关键一步。那么如何用 C 优雅地实现它呢直接记录每一个对象的每一个状态那内存会爆炸。在每一个操作函数里硬编码逆向逻辑代码会变成一团乱麻难以维护。业界经过几十年的实践已经沉淀出一个经典且强大的设计模式来解决这个问题命令模式。它不仅仅是一个实现撤销重做的“技巧”更是一种将“请求”封装为对象的设计哲学从而支持请求的排队、记录、撤销和重做。今天我们就来深入探讨如何用 C 实现一个基于命令模式的、支持无限级撤销重做的通用框架。无论你是正在开发一个桌面应用还是为一个游戏引擎构建编辑器工具链这套思路都能直接拿来用。2. 命令模式核心思想与架构设计2.1 为什么是命令模式在深入代码之前我们必须先理解命令模式为何是解决此问题的“银弹”。其核心思想是将一个请求封装成一个对象从而使你可以用不同的请求对客户进行参数化对请求排队或记录请求日志以及支持可撤销的操作。这听起来有点抽象我们拆解一下封装请求用户的一个操作如“移动对象”、“修改文本”、“删除节点”不再直接调用业务对象的方法而是被包装成一个独立的“命令”对象。参数化这个命令对象包含了执行该操作所需的所有信息接收者、参数。队列与日志由于命令是对象我们可以轻松地将它们放入队列实现异步执行、宏命令或压入栈中实现历史记录。可撤销命令对象除了Execute()方法还可以有一个Unexecute()或Undo()方法用于逆向执行该命令。对比其他方案命令模式的优势立刻显现解耦调用者与接收者调用者如UI按钮只知道触发一个命令完全不知道具体是哪个对象、以何种方式执行。这极大提高了代码的灵活性。支持复合命令多个简单命令可以组合成一个宏命令一次执行或撤销。易于扩展新增一种操作只需新增一个命令类符合开闭原则。历史管理变得简单历史记录本质上就是命令对象的栈。2.2 基础架构设计一个最精简的命令模式撤销重做框架包含以下几个核心角色Command抽象命令接口定义所有命令对象的统一接口通常至少包含Execute()和Undo()两个纯虚函数。ConcreteCommand具体命令实现Command接口绑定一个接收者Receiver和一组参数在Execute()中调用接收者的具体业务方法在Undo()中执行逆向操作。Invoker调用者/命令历史管理器负责触发命令的执行并维护命令的历史栈Undo Stack和重做栈Redo Stack。这是我们框架的大脑。Receiver接收者知道如何执行与请求相关的操作是业务逻辑的真正承载者。任何需要被操作的对象都可以作为接收者。它们之间的关系如下图所示概念示意[Invoker] 持有 - [Undo Stack] [Redo Stack] (栈内元素为 Command*) [ConcreteCommand] 关联 - [Receiver] 和 [Action Parameters] [Invoker] 调用 - [ConcreteCommand]-Execute() - 操作 [Receiver] [ConcreteCommand]-Undo() - 逆向操作 [Receiver]2.3 内存与性能的权衡快照 vs 逆操作在实现Undo()时有两种主流策略逆操作Inverse Operation命令自己记录足够的信息以便执行一个完全逆向的操作。例如“移动对象”命令记录原始位置Undo()时就是将对象移回原处。快照Snapshot/Memento在执行命令前保存接收者的完整状态快照Undo()时直接恢复整个状态。如何选择逆操作是命令模式的“正统”实现。内存开销小只存储增量信息效率高只做必要修改。适用于大多数离散的、定义明确的操作如修改属性、增删节点。快照实现简单粗暴Undo()逻辑统一。但内存开销巨大尤其是对象状态复杂时。适用于状态相对较小或操作本身非常复杂、难以定义逆操作的场景比如画布的整体模糊滤镜。在我们的通用框架中首选逆操作。因为它更契合命令模式“封装请求”的本质也是性能最优解。后续的具体命令实现都将基于此。3. 核心类实现与关键技术细节3.1 抽象命令接口与基础命令类首先我们定义最核心的抽象接口。这里有一个关键设计点是否需要区分“能否撤销”为了框架的健壮性我们定义所有命令都必须实现撤销但对于某些无需撤销的命令如保存文件其Undo()可以为空操作。// Command.h #ifndef COMMAND_H #define COMMAND_H #include string /** * brief 抽象命令接口。 * 所有具体命令的基类定义了执行和撤销的统一操作。 */ class ICommand { public: virtual ~ICommand() default; /** * brief 执行命令。 * return 执行是否成功。可用于错误处理。 */ virtual bool Execute() 0; /** * brief 撤销该命令的执行效果。 * return 撤销是否成功。 */ virtual bool Undo() 0; /** * brief 获取命令的描述信息用于调试或历史记录显示。 */ virtual std::string GetDescription() const 0; }; #endif // COMMAND_H接下来我们实现一个简单的、不可变的“值对象”来作为命令的参数这是一个常用的技巧可以避免参数在命令执行后被意外修改。// CommandParams.h (示例) templatetypename T class Value { private: T m_data; public: explicit Value(const T data) : m_data(data) {} const T Get() const { return m_data; } // 禁止赋值确保创建后不可变 Value operator(const Value) delete; };3.2 命令历史管理器大脑的实现这是框架中最复杂的部分它负责管理命令的生命周期、历史栈以及撤销重做的逻辑。// CommandHistory.h #ifndef COMMAND_HISTORY_H #define COMMAND_HISTORY_H #include Command.h #include stack #include memory #include vector class CommandHistory { public: CommandHistory() default; ~CommandHistory() { Clear(); // 析构时清理所有命令 } // 禁止拷贝和赋值因为管理着资源的所有权 CommandHistory(const CommandHistory) delete; CommandHistory operator(const CommandHistory) delete; /** * brief 执行一个新命令并加入历史记录。 * param cmd 待执行的命令unique_ptr转移所有权 * return 命令执行是否成功。若失败命令不会被加入历史。 */ bool ExecuteCommand(std::unique_ptrICommand cmd) { if (!cmd) return false; if (cmd-Execute()) { // 执行成功压入撤销栈 m_undoStack.push(std::move(cmd)); // 执行新命令后重做栈必须清空这是标准行为 ClearRedoStack(); return true; } // 执行失败cmd会被自动释放unique_ptr离开作用域 return false; } /** * brief 撤销最近一次命令。 * return 撤销是否成功历史栈为空或撤销失败则返回false。 */ bool Undo() { if (m_undoStack.empty()) { return false; } auto cmd m_undoStack.top(); if (cmd-Undo()) { // 撤销成功从撤销栈移到重做栈 m_redoStack.push(std::move(cmd)); m_undoStack.pop(); return true; } // 撤销失败保持栈状态不变 return false; } /** * brief 重做最近一次被撤销的命令。 * return 重做是否成功。 */ bool Redo() { if (m_redoStack.empty()) { return false; } auto cmd m_redoStack.top(); if (cmd-Execute()) { // 注意重做是再次执行命令 m_undoStack.push(std::move(cmd)); m_redoStack.pop(); return true; } return false; } /** * brief 清空所有历史记录。 * 通常在打开新文档或重置状态时调用。 */ void Clear() { ClearStack(m_undoStack); ClearStack(m_redoStack); } /** * brief 是否可以执行撤销操作。 */ bool CanUndo() const { return !m_undoStack.empty(); } /** * brief 是否可以执行重做操作。 */ bool CanRedo() const { return !m_redoStack.empty(); } /** * brief 获取撤销栈和重做栈的大小用于UI显示如“撤销(3)”。 */ std::pairsize_t, size_t GetStackSizes() const { return {m_undoStack.size(), m_redoStack.size()}; } /** * brief 获取撤销栈中的命令描述列表用于显示历史记录面板。 */ std::vectorstd::string GetUndoHistoryDescriptions() const { std::vectorstd::string descs; // 注意栈是LIFO但历史记录通常从旧到新显示所以需要反转顺序 std::stackstd::unique_ptrICommand tempStack m_undoStack; while (!tempStack.empty()) { descs.push_back(tempStack.top()-GetDescription()); tempStack.pop(); } std::reverse(descs.begin(), descs.end()); // 反转得到从旧到新的顺序 return descs; } private: std::stackstd::unique_ptrICommand m_undoStack; std::stackstd::unique_ptrICommand m_redoStack; void ClearStack(std::stackstd::unique_ptrICommand stack) { while (!stack.empty()) { stack.pop(); // unique_ptr 会自动释放内存 } } }; #endif // COMMAND_HISTORY_H关键设计解析与避坑指南所有权管理使用std::unique_ptrICommand管理命令对象生命周期。ExecuteCommand通过移动语义接管所有权确保命令在历史管理器中被安全持有和释放。这是现代C避免内存泄漏的最佳实践。重做栈的清空时机在ExecuteCommand中执行任何新命令后必须调用ClearRedoStack()。这是因为用户的新操作创建了一条新的历史路径之前被撤销的、存在于重做栈中的命令序列已经不再有效历史分支被覆盖。这是许多撤销重做实现中容易出错的逻辑点。撤销/重做的原子性Undo()和Redo()函数内部先检查成功再移动栈元素。确保操作是原子的——要么完全成功状态切换要么完全失败状态不变避免出现命令卡在中间状态。const正确性CanUndo,CanRedo,GetStackSizes等查询函数标记为const因为它们不修改对象状态。3.3 具体命令示例修改整数属性让我们实现一个最简单的具体命令修改某个对象的整数属性。这是编辑器中最常见的操作之一。假设我们有一个Document类作为接收者Receiver。// Document.h #ifndef DOCUMENT_H #define DOCUMENT_H #include string class Document { private: int m_importantValue; std::string m_name; public: Document(int val, const std::string name) : m_importantValue(val), m_name(name) {} int GetImportantValue() const { return m_importantValue; } void SetImportantValue(int newVal) { m_importantValue newVal; } const std::string GetName() const { return m_name; } // ... 其他属性和方法 }; #endif // DOCUMENT_H现在为“修改重要值”这个操作创建命令。// ChangeValueCommand.h #ifndef CHANGEVALUECOMMAND_H #define CHANGEVALUECOMMAND_H #include Command.h #include Document.h #include memory class ChangeValueCommand : public ICommand { private: Document* m_receiver; // 命令的作用对象 int m_oldValue; // 用于撤销的旧值 int m_newValue; // 命令要设置的新值 std::string m_description; // 命令描述 public: /** * brief 构造一个修改值的命令。 * param receiver 目标文档对象非拥有权由外部管理生命周期。 * param newValue 要设置的新值。 * param desc 命令描述。 * note 构造函数中会捕获当前的旧值这是实现撤销的关键。 */ ChangeValueCommand(Document* receiver, int newValue, const std::string desc Change Value) : m_receiver(receiver) , m_oldValue(receiver ? receiver-GetImportantValue() : 0) , m_newValue(newValue) , m_description(desc) { // 确保接收者有效是命令的职责之一 if (!m_receiver) { throw std::invalid_argument(ChangeValueCommand: receiver cannot be null); } } bool Execute() override { // 执行命令将接收者的值设置为新值 // 这里可以添加业务逻辑校验例如新值是否在有效范围内 if (m_newValue 0) { // 假设值不能为负 return false; } m_receiver-SetImportantValue(m_newValue); return true; } bool Undo() override { // 撤销命令将接收者的值恢复为旧值 m_receiver-SetImportantValue(m_oldValue); return true; } std::string GetDescription() const override { return m_description (from std::to_string(m_oldValue) to std::to_string(m_newValue) ); } }; #endif // CHANGEVALUECOMMAND_H实操心得与陷阱捕获状态时机旧值m_oldValue的捕获必须在构造函数中完成而不是在Execute()中。因为命令对象可能在创建后不会立即执行例如放入队列如果在Execute()中捕获此时接收者的状态可能已经改变导致捕获的“旧值”不准确从而无法正确撤销。接收者生命周期命令只持有接收者的原始指针 (Document*)这意味着命令对象不能比接收者对象活得更久。通常接收者如文档、编辑器状态是长生命周期对象而命令历史管理器在文档关闭时会清空所以这是安全的。如果架构复杂可以考虑使用std::weak_ptr。参数校验Execute()中的校验如m_newValue 0是业务逻辑的一部分。如果校验失败返回false命令历史管理器会拒绝将其加入历史栈。这保证了历史记录中都是成功执行的有效操作。3.4 复合命令实现宏操作用户经常需要将一系列操作作为一个整体来撤销/重做比如“全选”后“删除”应该被视作一个“删除所选”的单一操作。复合命令宏命令完美解决了这个问题。// MacroCommand.h #ifndef MACROCOMMAND_H #define MACROCOMMAND_H #include Command.h #include vector #include memory class MacroCommand : public ICommand { private: std::vectorstd::unique_ptrICommand m_commands; std::string m_description; public: explicit MacroCommand(const std::string desc Macro Command) : m_description(desc) {} /** * brief 向宏命令中添加一个子命令。 * param cmd 子命令转移所有权 */ void AddCommand(std::unique_ptrICommand cmd) { if (cmd) { m_commands.push_back(std::move(cmd)); } } bool Execute() override { // 顺序执行所有子命令 for (auto cmd : m_commands) { if (!cmd-Execute()) { // 如果任何一个子命令执行失败整个宏命令失败。 // 一个更健壮的实现是尝试回滚已成功的子命令事务性这里简化处理。 return false; } } return true; } bool Undo() override { // 逆序撤销所有子命令 for (auto it m_commands.rbegin(); it ! m_commands.rend(); it) { if (!(*it)-Undo()) { // 撤销失败处理。同样简化处理直接返回失败。 return false; } } return true; } std::string GetDescription() const override { return m_description [ std::to_string(m_commands.size()) sub-commands]; } bool IsEmpty() const { return m_commands.empty(); } }; #endif // MACROCOMMAND_H使用示例创建一个“重置文档”的宏命令// 假设有一个 ResetDocumentCommand auto macro std::make_uniqueMacroCommand(Reset Document to Default); macro-AddCommand(std::make_uniqueChangeValueCommand(doc, 100, Set Value to Default)); macro-AddCommand(std::make_uniqueChangeNameCommand(doc, Untitled, Reset Name)); // 可以继续添加其他命令... history.ExecuteCommand(std::move(macro)); // 作为一个整体执行和撤销注意事项原子性上面的简化实现不具备原子性。如果第3个子命令执行失败前2个已经执行成功的命令不会被撤销。在生产环境中你需要实现事务逻辑在Execute()中如果某个子命令失败需要调用之前成功命令的Undo()进行回滚确保状态一致性。这被称为“补偿事务”模式。内存复合命令嵌套复合命令是允许的但要小心深度过深导致栈溢出递归执行/撤销。4. 高级主题与生产环境优化4.1 命令的合并优化连续微调操作当用户快速连续执行相似操作时比如用鼠标拖动一个对象或者用滑块连续调整一个数值如果每个微小变化都生成一个独立的命令历史栈会迅速膨胀内存占用高且用户撤销时需要按很多次。这时需要命令合并。策略延迟执行与合并创建可合并命令接口class IMergeableCommand : public ICommand { public: virtual ~IMergeableCommand() default; /** * brief 尝试将另一个命令合并到本命令中。 * param subsequentCommand 后续发生的命令。 * return 如果合并成功返回true且后续命令可以被丢弃否则返回false。 */ virtual bool TryMergeWith(const ICommand subsequentCommand) 0; };修改历史管理器在ExecuteCommand中检查新命令 (newCmd) 是否实现了IMergeableCommand并且撤销栈顶的命令 (topCmd) 是否也实现了该接口。如果是则调用topCmd-TryMergeWith(*newCmd)。如果合并成功则丢弃newCmd并更新栈顶命令的描述等信息。示例连续移动命令的合并class MoveObjectCommand : public IMergeableCommand { private: GameObject* m_obj; Point m_from; // 合并时这个值保持为第一次移动的起点 Point m_to; // 这个值更新为最后一次移动的终点 public: bool TryMergeWith(const ICommand other) override { // 1. 类型检查 const auto* otherMove dynamic_castconst MoveObjectCommand*(other); if (!otherMove || otherMove-m_obj ! this-m_obj) { return false; // 不是同一个对象或不是移动命令不能合并 } // 2. 时间/逻辑检查可选是否在很短的时间内连续发生 // 3. 合并将本次命令的终点作为新的终点 this-m_to otherMove-m_to; this-m_description Move Object (merged); // 更新描述 return true; } bool Execute() override { m_obj-SetPosition(m_to); return true; } bool Undo() override { m_obj-SetPosition(m_from); return true; } };合并的边界条件合并需要谨慎定义。通常只合并同一对象、同一类型、在极短时间内连续发生的操作。合并后Undo()一次会直接回到合并操作的起点这符合用户对“连续拖动”的直觉。4.2 资源管理与智能指针的深入应用我们之前用std::unique_ptr管理命令这适用于命令历史管理器独占命令所有权的情况。但在某些场景下命令可能需要被多个地方引用例如一个命令同时被放入执行队列和历史栈。这时可以考虑使用std::shared_ptrICommand。更健壮的历史管理器共享所有权版class CommandHistoryShared { private: std::stackstd::shared_ptrICommand m_undoStack; std::stackstd::shared_ptrICommand m_redoStack; // ... 其他方法类似但参数类型改为 std::shared_ptrICommand };何时使用shared_ptr当命令对象本身包含大量数据如快照且可能被多个历史分支引用时。当架构上命令可能被多个管理器如重做管理器、宏命令编辑器同时持有时。代价shared_ptr有额外的引用计数开销且要小心循环引用例如命令内部持有接收者的shared_ptr而接收者又间接引用了命令历史。最佳实践建议默认使用unique_ptr。它更简单、高效所有权清晰。除非有明确的共享需求否则不要引入shared_ptr的复杂性。4.3 线程安全考虑如果你的应用是多线程的例如后台线程执行计算命令UI线程触发撤销命令历史管理器必须是线程安全的。简单的互斥锁保护#include mutex class ThreadSafeCommandHistory { private: CommandHistory m_history; mutable std::mutex m_mutex; // mutable 允许在 const 成员函数中加锁 public: bool ExecuteCommand(std::unique_ptrICommand cmd) { std::lock_guardstd::mutex lock(m_mutex); return m_history.ExecuteCommand(std::move(cmd)); } bool Undo() { std::lock_guardstd::mutex lock(m_mutex); return m_history.Undo(); } // CanUndo, CanRedo 等查询函数也需要加锁 bool CanUndo() const { std::lock_guardstd::mutex lock(m_mutex); return m_history.CanUndo(); } // ... 其他方法 };注意锁的粒度要仔细设计。这里用一个大锁保护整个历史管理器简单但可能成为性能瓶颈。更精细的设计可以为撤销栈和重做栈分别加锁但要注意ExecuteCommand中清空重做栈与两个栈都相关的原子性。4.4 持久化保存与加载历史对于需要保存工作进度的应用如设计软件可能需要将命令历史保存到文件以便下次打开时能恢复到某个历史状态。思路一保存最终状态 重放命令推荐保存文档的当前状态快照。保存从初始状态到当前状态的所有命令序列需要命令支持序列化。加载时先加载初始状态或一个空状态然后按顺序重放所有保存的命令。思路二保存完整的历史栈将整个命令历史栈序列化。这要求所有命令、所有接收者的状态都可序列化实现复杂。命令序列化接口示例class ISerializableCommand : public ICommand { public: virtual std::string Serialize() const 0; static std::unique_ptrISerializableCommand Deserialize(const std::string data); };实现时每个命令将自己关键数据命令类型、接收者ID、参数转换成字符串如JSON、二进制。历史管理器负责将栈中所有命令的序列化数据按顺序保存。5. 实战集成到编辑器与常见问题排查5.1 与UI框架如Qt、ImGui集成以Qt为例将命令框架与QAction关联起来非常直观。// 在MainWindow或某个Manager类中 class MyEditor : public QMainWindow { Q_OBJECT private: CommandHistory m_history; Document m_doc; QSpinBox* m_valueSpinBox; private slots: void onValueChanged(int newValue) { auto cmd std::make_uniqueChangeValueCommand(m_doc, newValue, Change via Spinbox); m_history.ExecuteCommand(std::move(cmd)); updateUndoRedoActions(); // 更新UI按钮状态 } void onUndoTriggered() { if (m_history.Undo()) { // 撤销后UI状态可能与文档状态不同步需要刷新 m_valueSpinBox-blockSignals(true); // 防止触发onValueChanged形成循环 m_valueSpinBox-setValue(m_doc.GetImportantValue()); m_valueSpinBox-blockSignals(false); updateUndoRedoActions(); } } void onRedoTriggered() { /* 类似 */ } void updateUndoRedoActions() { ui-actionUndo-setEnabled(m_history.CanUndo()); ui-actionRedo-setEnabled(m_history.CanRedo()); // 可选设置action的文本如 Undo (Change Value) if (m_history.CanUndo()) { ui-actionUndo-setText(Undo QString::fromStdString(m_history.GetUndoHistoryDescriptions().back())); } } };关键点UI控件如QSpinBox在值改变时创建并执行命令。而在撤销/重做后需要手动同步UI控件的值并避免再次触发命令执行通过blockSignals。5.2 常见问题排查速查表问题现象可能原因排查步骤与解决方案撤销后状态不正确1. 命令的Undo()逻辑错误。2. 旧值在错误时机捕获应在构造时而非执行时。3. 接收者对象已被销毁悬空指针。1. 调试Undo()函数检查恢复的值是否正确。2. 检查命令构造函数确认m_oldValue是在接收者状态未改变前获取的。3. 确保接收者生命周期长于命令历史。使用weak_ptr或在使用前检查指针有效性。重做栈在不应清空时被清空ExecuteCommand中执行任何新命令后都清空了重做栈。确认逻辑符合需求。标准行为如此。如果希望保留分支需实现更复杂的“命令树”而非栈。内存泄漏命令对象未被正确删除。确保使用智能指针 (unique_ptr/shared_ptr)。检查CommandHistory::Clear()是否被正确调用如在文档关闭时。执行命令导致程序崩溃1. 接收者指针为nullptr。2. 命令Execute()中有未处理的异常。1. 在命令构造函数和Execute()开始处进行空指针检查。2. 确保Execute()和Undo()有健全的错误处理返回false而非抛出异常或在历史管理器层用try-catch包裹。复合命令部分成功部分失败宏命令的Execute()不具备原子性。实现事务性宏命令在循环执行子命令前记录状态失败时调用已成功子命令的Undo()进行回滚。UI与模型状态不同步撤销/重做后只修改了模型Document未更新UI。采用观察者模式如Qt的信号槽。让文档状态改变时发出信号UI控件订阅该信号并更新。或者在撤销/重做函数末尾显式调用UI更新函数。连续操作撤销不流畅每个微小操作都生成独立命令。为高频连续操作如拖动实现可合并命令 (IMergeableCommand)并在历史管理器中加入合并逻辑。5.3 性能优化技巧懒计算与轻量快照对于快照式撤销不要真的深拷贝整个大对象。可以使用“写时复制”Copy-on-Write技术或者只保存被修改部分的差异Delta。限制历史深度无限撤销虽好但内存有限。可以在CommandHistory中设置一个最大栈容量 (m_maxHistorySteps)当栈满时丢弃最旧的命令栈底。丢弃时需要注意如果丢弃的命令是某个复合命令的一部分可能需要特殊处理。void CommandHistory::PushToUndoStack(std::unique_ptrICommand cmd) { m_undoStack.push(std::move(cmd)); if (m_undoStack.size() m_maxHistorySteps) { // 移除栈底最旧的命令。std::stack 不支持直接访问栈底可以用deque代替stack。 // 或者维护一个固定大小的循环缓冲区。 } }异步命令执行对于耗时的命令如应用滤镜可以将其放入线程池执行但撤销/重做操作必须在主线程同步进行以避免竞态条件。这需要更精细的线程同步设计。实现一个完整的、生产级别的C命令模式撤销重做框架需要考虑的细节远不止于此包括命令的序列化、网络同步用于协作编辑、与特定领域模型的深度集成等。但本文提供的核心架构、代码示例和问题排查指南已经为你打下了一个坚实可靠的基础。你可以以此为起点根据项目的具体需求进行扩展和强化。记住好的撤销重做功能是透明的用户感觉不到它的存在但它却是软件品质最坚实的后盾之一。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价