2026/10/21 8:47:40

C++从零实现Word风格编辑器:文档模型与源码拆解

C++从零实现Word风格编辑器:文档模型与源码拆解 差不多半年前我给自己定了一个有点“自虐”的小目标不用现成的富文本控件纯靠 C 从零搭一个能编辑 Word 风格文档的桌面编辑器。动手之后才明白网上那些现成编辑器看着不起眼背后全是细节的堆叠。这篇文章把 C Word 编辑器的完整设计与源码实现拆开讲从文档数据结构、光标与选区、富文本渲染到文件格式兼容和构建部署一步步还原我当时做的事情。如果你有 C 基础或者正想给某个工具做一个轻量、可定制的文档编辑模块这篇可以当一份参考。1. 为什么我会去写一个 C 版 Word 编辑器从需求倒推功能清单1.1 这个项目要解决什么问题先说需求背景。当时我在做一套内部的桌面工具需要一个“能编辑富文本的小面板”用户要能写两段文字、调节字体大小、加粗、改颜色、存出文件。市面上的方案无非两条路一是接入完整办公组件功能确实全但动辄几十上百兆依赖定制也很吃力二是直接用各种封装好的富文本框控件省事但那层“黑盒”会限制很多行为比如我想实现自定义文档格式、精细控制撤销语义、甚至把同一个文档内核套到别的界面框架上控件方案基本做不了。所以最终决定自己写一个编辑器内核。这个项目跟我的需求是严格对着倒推的不要表格、不要图片嵌入、不要页眉页脚但要保留普通用户最常用的格式化能力。它不是一个仿 Word 的大而全项目而是一个“清楚知道自己边界在哪里”的轻量文档编辑器。这样设计的好处是核心足够纯粹适合学习也适合在真实工具项目里二次改造。1.2 最终确定的功能范围与操作方式功能取舍是我开工前最纠结的一件事最后我给自己划了这样一条线类别支持范围明确不做基本编辑输入、删除、回车换行、Tab 缩进自动编号、智能列表光标与选区方向键移动、Home/End/PageUp/PageDown、鼠标选择、Shift 扩展选区分栏编辑、竖排文本格式化字体、字号、加粗、斜体、下划线、删除线、文字颜色、对齐、行距、简单项目符号样式主题、字符间距、上下标撤销与重做输入、删除、样式变更、段落合并/拆分按时间线的“非线性撤销”查找替换大小写敏感、全词匹配、全部替换正则替换、查找历史文件格式自有格式 .cwe、纯文本 .txt、导出简单 RTF完整兼容 .docx、.doc操作方式我参考了经典桌面编辑器顶部菜单栏加一排工具栏右侧正文编辑区底部状态栏显示光标所在行列。快捷键尽量跟常见编辑器保持肌肉记忆一致CtrlN、CtrlO、CtrlS、CtrlZ、CtrlY、CtrlC、CtrlV、CtrlX、CtrlA、CtrlF、CtrlH、CtrlB、CtrlI、CtrlU。窗口支持多标签页每个标签页独立一个文档模型。这个范围定下来之后剩下来的问题就是怎么把这些功能落成一个可运行的内核。2. 文档模型设计文本、样式、撤销栈怎么组织2.1 用“分段文本 样式区间”替代“字符串 光标偏移”我第一版实现非常天真整个文档就是一个std::wstring光标是一个整数插入删除直接操作字符串。跑了两天就发现三个硬伤第一性能问题。在几万字符的文档中间不断插入删除std::wstring的插入删除是线性开销频繁操作时界面明显卡顿输入一快就跟不上手速。第二样式没地方放。就算我开一个std::vectorbool记录每个字符是否加粗把颜色、字体名、字号叠进去之后整个结构会变成噩梦。第三撤销粒度太粗每次操作都要在字符串和样式表里同步回滚。于是第二版彻底重构采用了“段落列表 文本块”的模型struct TextStyle { std::wstring fontName; float fontSize 12.0f; bool bold false; bool italic false; bool underline false; bool strike false; uint32_t color 0x000000; }; struct TextChunk { std::wstring text; TextStyle style; }; struct Paragraph { std::vectorTextChunk chunks; int alignment 0; // 0 左对齐、1 居中、2 右对齐 float lineSpacing 1.0f; }; class DocumentModel { public: std::vectorParagraph paragraphs; };这样做最重要的意义是样式是挂在“连续同格式的片段”上而不是挂在每个字符上。一篇文档哪怕有几万个字符只要格式没碎掉chunk 数量通常只有几十个。插入字体、加粗这类操作本质上就是把一个 chunk 拆成两段再插入新 chunk最后检查相邻 chunk 能不能合并。光标位置我这样定义struct CursorPos { int paraIndex 0; // 第几个段落 int charPos 0; // 段内第几个字符偏移 };charPos的粒度用的是 UTF-16 code unit如果后续要支持 emoji 这类增补字符还需要再做一次码点映射。MVP 阶段先这么定原因是我用的界面框架内部很多接口都按 UTF-16 计数直接用 code unit 可以少踩很多“边界位置偏一位”的坑。插入文本的核心逻辑大概是void DocumentModel::insertText(const CursorPos pos, const std::wstring text, const TextStyle style) { Paragraph para paragraphs[pos.paraIndex]; auto it para.chunks.begin(); while (it ! para.chunks.end() pos.charPos static_castint(it-text.size())) { pos.charPos - static_castint(it-text.size()); it; } // 跨 chunk 边界做拆分 if (it ! para.chunks.end() pos.charPos 0 pos.charPos static_castint(it-text.size())) { TextChunk right { it-text.substr(pos.charPos), it-style }; it-text it-text.substr(0, pos.charPos); it para.chunks.insert(std::next(it), right); } // 这里再判断相邻样式是否相同决定是合并还是新建 chunk }这种模型让“查找一个段落里第 N 个字符”这个高频操作变得很便宜只需要在段落内部做少量遍历除非用户刻意把每个字符都改成不同格式否则大多数操作都在常数时间内完成。2.2 撤销/重做栈的实现细节撤销重做我一开始想偷懒打算每次操作前保存一整个文档快照。做出来才发现不可行一个几万行的文档每次打字都深拷贝一遍内存和 CPU 都扛不住。所以最终采用了命令模式Command Pattern把“一次修改”封装成一个可撤销的对象class IEditCommand { public: virtual ~IEditCommand() default; virtual void execute() 0; virtual void undo() 0; virtual void redo() 0; };主要命令类型有这么几种命令类型对应操作撤销时恢复的数据InsertTextCommand插入文本删除插入区间的文本DeleteTextCommand删除选区/单个字符在删除位置重新插入原文本SetStyleCommand修改选区样式恢复原样式区间SplitParagraphCommand回车拆分段落合并两个段落MergeParagraphCommand删除段首回车键在合并处重新拆分MoveTextCommand拖拽移动文本反向移动回去每个命令对象里要保存两类数据一是修改前的区域和内容二是修改后的光标位置。否则 undo/redo 之后光标会莫名其妙跳到文档开头——这个问题我后面单独踩过坑会在第 6 章细说。撤销栈本身用两个std::vectorstd::unique_ptrIEditCommandstd::vectorstd::unique_ptrIEditCommand undoStack_; std::vectorstd::unique_ptrIEditCommand redoStack_;新操作执行成功后清空 redo 栈压入 undo 栈undo 时把命令弹出来执行undo()再压进 redo 栈redo 则反过来。这里有个体验细节必须处理用户连续打字时如果每个字符都生成一条命令按一下撤销只能删一个字体验非常差。我的做法是在InsertTextCommand里做输入合并命令记录一个“最后输入时间点”如果新输入满足三个条件就把它追加到栈顶命令里而不是新建命令条件分别是——新输入和栈顶命令是同一类型、插入位置与栈顶命令的结束位置一致、时间间隔小于 800 毫秒。这样一次连续输入在撤销栈里就是一条命令撤销一下能删回整个连续输入。2.3 性能为什么插入 10 万行不卡顿我在开发中期专门做了次压测构造 10 万行、每行约 30 个字符的纯文本约 300 万字符。在普通开发机上打开这样一份文档从解析完成到可以编辑大约 1 秒日常输入响应基本感觉不到延迟。能做到这一点靠的是“分段 缓存 局部失效”三件套。分段结构让每次编辑只影响一个段落缓存指的是我在视图层维护了一个“最后一次访问段落”的记录配合连续编辑的局部性绝大多数操作能直接命中局部失效则是说一个段落被修改后只重新计算这个段落的布局和高度不会连坐整篇文档。真正会慢的场景只有一个在一个超大段落内部持续插入内容导致后面所有段的索引需要平移。比如整篇文档只有一个大段落里面塞几百万字这时候任何操作都是 O(n)。但日常工作里这种文档很少见真要遇到优化方向就是把段落再拆成可拼接的“块列表”结构这次项目没做完整先不展开。3. 编辑器交互实现光标、选区、输入法与剪贴板3.1 光标移动与键盘处理的边界情况光标是整个编辑器的“心脏”所有编辑操作都会先换算成光标加选区的操作模型。我在控制器里统一收键盘事件逐键处理左右键在段内移动charPos到段首/段尾时跨段移动Home/End跳到当前行行首/行尾CtrlHome/CtrlEnd跳文档首尾PageUp/PageDown按可视区行数移动视口光标跟随上下键这是全项目最容易出 bug 的地方。上下键为什么难因为中英文混合的时候字符宽度不一样上行第 20 个字符的视觉 X 坐标到下行可能落在第 15 个字符的位置。如果直接用“上一行 offset - 1”这种粗暴算法光标上下移动时会左右飘。我的做法是每次记录一个“目标 X 坐标”在按下上/下键的瞬间把当前光标所在的字符中心点 X 坐标记下来再在新行里对这个可视坐标做二分查找找到最近的字符位置误差控制在 1 像素以内。这样即使一行有粗体有斜体跑起来也基本是直的。回车键的处理也不能简单粗暴。回车并不是“插入一个\n字符”而是把一个段落从光标处拆成两段前一段保留光标前的文本后一段继承光标后的文本。这个操作我封装成了SplitParagraphCommand反向的 Backspace 在段落开头按下时要合并前一段和当前段用MergeParagraphCommand。中文输入法的坑我这里多说一句。处理 IME 预编辑时我一开始把每次 composition update 都当作文本插入结果用户还没选字文章里就多了一堆拼音字母而且选区还会反复被清空。正确做法是composition 阶段只把拼音内容当作“预编辑临时显示”不修改文档等 commit 事件拿到最终汉字字符串之后再一次性插入文档模型。Qt 里就是监听QInputMethodEvent的 commit 分支Windows 原生消息里则是相应处理 IME 通知。3.2 选区和拖拽以及右键菜单选区我用两个CursorPos表示selectionStart_和selectionEnd_两者相等表示没有选区。鼠标拖拽选择时每次鼠标坐标变化都重新计算对应的文本位置然后更新选区并触发局部重绘。Shift方向键的扩展选区也走同一套“标准区间”逻辑只是起点固定为按下 Shift 之前的光标。拖拽文本复制是另一个高频操作。我在鼠标按下时判断如果起点落在现有选区内部同时按住鼠标拖动超过一小段距离就进入拖拽模式。拖拽过程中显示一个竖线光标表示“将要插入的位置”松开鼠标时执行移动操作。这里要注意不能简单拆成“先删除再插入”因为删除选区会导致文档索引整体变化插入位置得先按删除后的坐标重新换算一遍。我图省事直接封装了一个MoveTextCommand把整段逻辑包住undo 时做反向移动。右键菜单看起来简单实际交互细节比想象多。菜单项包括剪切、复制、粘贴、全选复制的时机在菜单打开时就要确定因为用户可能在菜单弹出的间隙用鼠标又选了新内容。粘贴时我做一个类型探测优先读取剪贴板里的 RTF解析失败就退到纯文本如果剪贴板里只有图片或文件列表不能直接插入应该弹个提示而不是往文档里塞乱码。3.3 查找替换的实现与边界处理查找替换我的实现没有走特别高深的算法胜在数据映射清楚。查找前先把所有段落拼成一个临时的std::wstring同时保存一个“段落偏移映射表”每个字符属于哪个段落、在段内是第几个偏移。匹配算法先用最基本的滑动窗口实测下来对个人项目的文档规模完全够用。匹配选项做了三个大小写敏感、全词匹配、从光标处开始。全词匹配特别容易写错。我的规则是匹配字符串的前一个字符和后一个字符如果存在且是字母或数字视为“不是词边界”不匹配中文统一视作词边界不会被英文单词规则误伤。替换的循环处理有个小坑如果替换文本里包含查找文本本身比如把abc替换成aabc从当前匹配点重新找会陷入无限循环。我的做法是“全部替换”每轮从“被替换文本的结束位置之后”继续查找而不是回到替换结果里找另外预设最大替换次数比如 100 万次作为保险防止极端情况把程序拖死。4. 富文本渲染与界面我做成了什么样子4.1 渲染引擎的选择与字体回退界面框架我选了 Qt版本用的 Qt Widgets 模块但核心编辑区是自绘的没有直接用QTextEdit。这样做的原因是我要把整套编辑逻辑握在自己手里界面层只负责“把文档画出来”和“把鼠标键盘事件转成交互指令”。渲染机制用的是QTextLayout。大致流程对可视范围内的每个段落逐段做排版同一段里不同样式的 chunk 拆成不同的文本片段参与排版然后绘制到QPainter。这样的好处是字体、颜色、行距都完全受控坏处是所有排版粒度需要自己管理。字体回退是我在开发中真正被折磨过的问题。Windows 下用户文档里最常见的字体是“微软雅黑”和“宋体”但偶尔也会指定 Arial、Times New Roman。如果用户输入的字符超出字体覆盖范围比如指定 Arial 后又输入中文某些版本的系统上会直接渲染成方块。我的方案是排版前对文本按码点做一次分组扫描如果一个字符的 Unicode 区间超出了当前字体的常用覆盖范围就把这一小段临时改成默认中文字体。实际效果就是英文走英文字体、中文自动落到中文字体看起来非常自然。行高计算做了个近似公式lineHeight fontSize * lineSpacing * DPI_SCALE段落间距再额外叠加。这个公式对普通文档够用真要支持 Word 里那种“段前段后精确到磅”的排版得把字体度量数据加进来这个项目先略过了。4.2 菜单栏、工具栏和快捷键注册界面结构是标准的QMainWindow顶部菜单栏、中部工具栏、底部状态栏、右侧是多个标签页的编辑区。菜单动作全部通过QAction绑定到编辑控制器的接口上这一步本身不复杂真正要注意的是快捷键的焦点冲突。我举个例子查找对话框是一个独立输入框。如果我把CtrlF绑定到全局QShortcut点击搜索框之后按下CtrlA本意是全选搜索框里的文字结果因为它没有焦点判定直接触发了文档级的“全选”把正文全部选中搜索框内容却纹丝不动。解决方法是在触发快捷键的回调里先检查focusWidget()如果当前焦点在搜索框、工具栏输入框等辅助控件里就放弃拦截让事件继续传递。这个细节不做用户会觉得自己在一个“假编辑器”里。快捷键注册表我整理了一张 QKeySequence 映射方便以后扩展功能快捷键新建文档CtrlN打开文档CtrlO保存文档CtrlS撤销/重做CtrlZ / CtrlY复制/剪切/粘贴CtrlC / CtrlX / CtrlV全选CtrlA查找/替换CtrlF / CtrlH加粗/斜体/下划线CtrlB / CtrlI / CtrlU4.3 重绘优化与滚动条联动自绘编辑器最容易遇到两个性能问题滚动卡顿和输入重绘整屏。我的优化分两级第一级是“行布局缓存”。我把每个段落按排版结果切分到每一行缓存每行的QTextLine对象、首字符索引、行高和在该行内的视觉坐标。文档内容变化时只让被修改的那个段落的所有行缓存失效其他段落不动。这样滚动窗口时绝大多数行都能直接复用之前的排版结果。第二级是“可视区裁剪”。每次都问“需要画哪些行”而不是“把所有行都画一遍”。滚动条数值映射到“文档第一可见逻辑行”的位置由垂直偏移量通过行高累加表二分查找得到可视区域的首行和末行只对这些行执行drawText和选区高亮。这套机制做完之后即便打开百万级字符的文档拖动滚动条也只是重排一小部分行体感顺滑很多。5. 文件读写与格式兼容为什么最终选择了自定义 RTF 子集5.1 常见 docx 解析思路与陷阱说到保存和打开文件第一时间想到的自然是 .docx。但真正坐下来评估后我很快就放弃了完整支持 docx 的念头。.docx 本质是一个 zip 容器里面包含document.xml、styles.xml、relationships等一系列 XML 文件每种格式还有命名空间和版本差异。个人项目要从零做到“能打开 Word 保存的文件”工作量比整个编辑器核心还大而且很容易因为它内在的复杂结构卡在边缘案例上。我当时的现实选择是自有二进制格式兜底纯文本兼容RTF 作为导出桥梁。自有格式保证内部不丢任何样式纯文本保证任何场景都能打开RTF 则借用了微软公开的纯文本语法让 Word、写字板这些常见软件能直接打开我导出的文件。这套组合足够覆盖个人工具、内部组件、学习演示三类典型场景。5.2 自定义序列化格式的设计自有格式我命名为 .cweC Word Editor 的缩写文件内容本身是二进制。格式设计原则是“简单到一眼能看懂解析代码”但又不能脆弱到损坏一个字节就全崩。布局如下文件头 : 8 字节魔法数 CWEFILE1 版本号 : 2 字节 uint16 校验和 : 4 字节 uint32采用简单 FNV-1a 哈希 段落数 : 4 字节 uint32 之后是每个段落 对齐方式 : 1 字节 行距 : 4 字节 float chunk 数量 : 4 字节 uint32 每个 chunk 文本长度 : 4 字节 uint32 UTF-8 文本 : 文本长度字节 样式结构体 : 固定长度字体名长度 字体名 UTF-8 字号 float 1 字节位标记 4 字节颜色序列化核心代码大致长这样QByteArray serializeDocument(const DocumentModel doc) { QByteArray out; out CWEFILE1; // 魔法数 out uint16(1); // 版本 out uint32(doc.paragraphs.size()); for (const auto para : doc.paragraphs) { out uint8(para.alignment); out float(para.lineSpacing); out uint32(para.chunks.size()); for (const auto chunk : para.chunks) { auto utf8 QString::fromStdWString(chunk.text).toUtf8(); out uint32(utf8.size()); out utf8; // 样式字段按固定字节序写入 } } return out; }文本存 UTF-8 是刻意的否则中文在不同系统上会出乱码样式字段我用小端写入并在读取时统一转成程序内的标准类型保证换机器也能读。5.3 读写流程与文件损坏处理保存文件我走了“临时文件 原子替换”的可靠路径防止正在写文件时程序退出导致原文件被截断bool saveDocument(const DocumentModel doc, const QString path) { QByteArray bytes serializeDocument(doc); QTemporaryFile tmpFile; tmpFile.setFileTemplate(path .tmpXXXX); if (!tmpFile.open()) return false; if (tmpFile.write(bytes) ! bytes.size()) return false; tmpFile.close(); if (!QFile::exists(path)) { return tmpFile.rename(path); } QFile::remove(path); return tmpFile.rename(path); }打开文件时的容错优先级是这样排的先看文件头魔法数是否匹配匹配就按 .cwe 二进制解析不匹配但读起来像纯文本就尝试按 UTF-8 打开转成一份默认样式的单段落文档解析过程中遇到字段长度明显异常比如某个 chunk 声称有 100 万字节但文件只剩 10 字节立即停止弹友好提示绝不让解析器去读越界内存。RTF 导出做的是最简子集能保留字体、粗体、斜体、下划线、颜色、段落对齐这几样足够让 Word 直接打开后看到基本排版。核心是构造一个{\rtf1\ansi头然后把文本里的\、{、}转义成\\、\{、\}{\rtf1\ansi {\fonttbl{\f0 Microsoft YaHei;}} \fs24 这是一段普通文字 \b 这是加粗\b0 }弱水三千只取一瓢。文档格式兼容这个坑个人项目永远不要把“完整支持”作为目标把“常用路径可读、损坏不崩溃”当作验收线反而更实际。6. 源码结构、构建步骤与实操踩坑6.1 源码目录和关键文件说明整套源码我打包在随文附带的editor_cpp_source_v1.0.zip里目录设计成模块化每个模块不强依赖 UIeditor_cpp/ ├── CMakeLists.txt ├── README.md └── src/ ├── main.cpp ├── document/ │ ├── document_model.h / document_model.cpp │ ├── style.h │ └── edit_commands.h / edit_commands.cpp ├── editor/ │ ├── editor_view.h / editor_view.cpp │ ├── editor_controller.h / editor_controller.cpp │ └── layout_cache.h / layout_cache.cpp ├── io/ │ ├── file_reader.h / file_reader.cpp │ └── file_writer.h / file_writer.cpp └── ui/ ├── main_window.h / main_window.cpp └── about_dialog.h / about_dialog.cpp模块职责上有一个刻意的边界document目录里的代码是纯 C 和 STL不包含任何 Qt 依赖。这样做的好处是可以直接给DocumentModel写单元测试甚至可以把这个文档内核移植到其他 UI 框架。editor目录封装了“事件到命令”的转换ui目录只负责把 Qt 控件连到 editor 层。文件模块核心职责不用看代码也能理解的比喻document_model.cpp段落/chunk 的增删改查与样式合并文档的“仓库管理员”edit_commands.cpp每个可撤销动作的状态保存与恢复每次操作的“行车记录仪”editor_controller.cpp键盘鼠标事件翻译成文档命令翻译官layout_cache.cpp行排版结果的复用与失效楼层保洁只擦脏的房间file_reader.cpp / file_writer.cpp序列化与反序列化、格式探测仓库进出口的装卸工6.2 构建脚本与环境依赖构建用的是 CMake依赖只有 Qt Widgets。开发机上的版本是 Qt 6.5但代码向下兼容 Qt 5.15改一行find_package的组件名即可。编译要求 C17 起步MSVC 或者 MinGW 都能编过。CMakeLists 的核心部分长这样cmake_minimum_required(VERSION 3.16) project(EditorCpp LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 COMPONENTS Widgets Gui REQUIRED) add_executable(editor_cpp src/main.cpp src/document/document_model.cpp src/document/edit_commands.cpp src/editor/editor_view.cpp src/editor/editor_controller.cpp src/editor/layout_cache.cpp src/io/file_reader.cpp src/io/file_writer.cpp src/ui/main_window.cpp ) target_link_libraries(editor_cpp PRIVATE Qt6::Widgets Qt6::Gui)构建命令按常规流程走mkdir build cd build cmake .. -DCMAKE_PREFIX_PATH/path/to/qt cmake --build . --config Release建议优先用 Release 构建Debug 模式下 Qt 的渲染循环和 QTextLayout 排版会明显慢一截容易让人误判性能瓶颈。6.3 我在开发中遇到的三个典型问题项目从能跑到基本稳定踩了好几个坑挑三个最典型的说每一个都花了我至少两天定位第一个是中文乱码。现象是代码里写死的字符串“新建文档”在界面上显示成乱码。排查后发现根因不在代码逻辑而在源文件编码Windows 上 VS 默认按本地代码页保存文件里面写中文常量时Qt 编译器按 UTF-8 去解释就全乱了。解决办法是把所有源文件统一存成 UTF-8 with BOM同时文件读写路径全部走QString::fromUtf8 / toUtf8从源头消灭编码分歧。第二个是撤销重做后光标乱跳。现象很诡异连续撤销几次光标有时候跑到文档开头有时候跳到很远的段落但文档内容明明正确。后来看了命令对象的构造代码才发现我在记录命令时只存了操作范围没有存“操作前光标位置”和“操作后光标位置”。补上这两个字段并在 undo/redo 完成时显式调用setCursor(cmd-cursorAfter cmd-cursorBefore)问题立刻消失。这个教训是任何编辑命令都必须把光标当“数据”一样保存而不是事后靠计算去猜。第三个是滚动时整屏闪烁。初版绘图函数在paintEvent里每次都重刷整个可视区文档稍微一大拖动滚动条时就能看到文字闪烁和明显的撕裂感。解决方案就是第 4 章说的“行缓存 可视区裁剪”同时绘制前用QRegion对脏矩形做合并只对真正变化的区域重绘。改完之后滚动体感和商业编辑器基本无差别。最后分享一个我自己习惯了的小做法每次给项目加新功能前先给DocumentModel写一个针对该功能的最小测试用例。这个编辑器项目能越改越稳靠的就是让文档内核在脱离界面的情况下也能跑通所有编辑路径。如果你准备基于这份源码继续加功能我建议也按这个路子来别把测试压在人工鼠标点击上。源码压缩包里的tests/目录已经放了一批基础用例可以直接跑起来当参考。