简介:面向刚接触 VC++6.0 与 MFC 的 Windows 开发者,这份入门学习文档围绕如何打牢 C/C++ 基础、理解消息机制与消息映射展开,适合课程自学、上机练习和开发前的知识梳理。压缩包内共 1 个 doc 文件,约 521KB,内容以文字讲义形式组织,便于顺序阅读与检索关键概念。已有 731 人学习下载,说明其在 MFC 入门资料中具有一定参考价值。文档从学习路线讲起,逐步说明窗口句柄、消息队列、WNDPROC、WM_COMMAND 等核心概念,并给出消息处理的伪代码示例;同时讨论 ClassWizard、消息映射宏和 Visual C++/MFC 开发优势,帮助读者建立从 C++ 对象到窗口对象的理解路径。读者可据此少走弯路,形成较清晰的 MFC 学习框架与查阅在线帮助、阅读他人代码的实践习惯。
1. 消息循环:那个看起来像死循环的 while,为什么 CPU 占用是 0
MFC 入门时最容易被误解的一段代码,是入口处那个while(GetMessage(&msg, NULL, 0, 0))。它长得像忙等轮询,不少人的第一反应是“这不就是把 CPU 跑满吗”,于是要么不敢往里写,要么在里面塞一个 Sleep 来自我安慰。真相恰好相反:队列里没有消息时,GetMessage 根本不会返回,线程被挂在等待状态上,任务管理器里那个进程的 CPU 占用是 0。
真正让界面“假死”的从来不是这个循环,而是你在窗口过程里干了不该干的活。把这一点想通,消息队列、窗口句柄、消息映射这三件事才能串成一条线:系统负责投递,队列负责排队,窗口过程负责消费,而 MFC 做的所有事情,本质上是在“消费”这一层加了一层查表转发。
2. 从 WNDPROC 到消息映射:MFC 如何把一长串 switch 藏进宏里
在原生 Win32 里写窗口程序,最枯燥的部分就是那个巨大的switch(uMessage)。每个窗口类都要写一遍,每个 case 都要自己记消息号和参数含义,写错一个break就掉进下一个分支。MFC 的消息映射解决的就是这件事,但它不是把 switch 删掉,而是把它从“你手写”变成“编译器替你生成一张表”。
2.1 消息的三元组:UINT + WPARAM + LPARAM 到底装了什么
一条消息在数据结构上非常简单:一个UINT类型的消息号,加两个整数参数WPARAM(16 位时代是 16 位,32 位时代是 32 位无符号整数)和LPARAM(长整型)。消息号决定“发生了什么”,两个参数决定“细节是什么”。参数的含义完全由消息号约定,编译器不会帮你检查,这就是为什么老手会强调“记住常用消息的参数意义”——记不住就只能翻文档,翻多了自然就记住。
以最常见的WM_COMMAND为例,它的两个参数是有明确分工的:
HIWORD(wParam)是通知码,菜单命令固定为 0,按钮点击是BN_CLICKED(1),编辑框内容变化是EN_CHANGE;LOWORD(wParam)是命令 ID,菜单项或控件的资源 ID 就放在这里;lParam在控件通知时是子窗口句柄,菜单命令时为 0。
这解释了新手常遇到的一个困惑:为什么同一个WM_COMMAND分支里,按钮和菜单要分开判断。因为它们的lParam不同、HIWORD(wParam)也不同,只是恰好共用了同一个消息号。
消息必须由一个窗口接收,而窗口在系统层面用HWND唯一标识。这个唯一性很关键:两个窗口可以共用同一份窗口过程代码,但你在窗口一上按下鼠标,系统是拿着窗口一的句柄把消息投过去的,绝不会串到窗口二。句柄在这里扮演的是“收件地址”,窗口过程是“收件人共同的办公流程”。
2.2 宏展开:BEGIN_MESSAGE_MAP 编译后到底变成了什么
先看你在代码里写的东西,这是 VC++6.0 里 ClassWizard 自动维护的那套注释块:
// MainFrm.h —— 声明部分 class CMainFrame : public CFrameWnd { //{{AFX_MSG(CMainFrame) afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); // 处理 WM_CREATE //}}AFX_MSG DECLARE_MESSAGE_MAP() // 必须成对出现,声明三个静态函数 }; // MainFrm.cpp —— 实现部分 BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) //{{AFX_MSG_MAP(CMainFrame) ON_WM_CREATE() //}}AFX_MSG_MAP ON_COMMAND(ID_FONT_DROPDOWN, DoNothing) // 菜单命令映射到成员函数 END_MESSAGE_MAP()那两对//{{AFX_MSG注释不是给人看的,是给 ClassWizard 看的。它靠扫描这对标记定位插入点,往中间塞afx_msg声明和ON_xxx宏。有人手贱删掉了其中一半,ClassWizard 就再也加不进新消息处理函数了,这个坑在 VC6 时代属于血泪经验级别。
宏展开之后,BEGIN_MESSAGE_MAP生成的是一个静态函数和一个静态数组,形如:
const AFX_MSGMAP* CMainFrame::GetThisMessageMap() { // 每一条 ON_xxx 编译成一个 6 元组表项 static const AFX_MSGMAP_ENTRY _messageEntries[] = { { WM_CREATE, 0, 0, 0, AfxSig_is, (AFX_PMSG)(AFX_PMSGW)&OnCreate }, { WM_COMMAND, CN_COMMAND, (WORD)ID_FONT_DROPDOWN, (WORD)ID_FONT_DROPDOWN, AfxSigCmd_v, (AFX_PMSG)(AFX_PMSGW)(void (AFX_MSG_CALL CCmdTarget::*)(void))&DoNothing }, { 0, 0, 0, 0, AfxSig_end, (AFX_PMSG)0 } // 哨兵,查找时靠它收尾 }; // 基类指针 + 表首地址,构成一条可以向上回溯的链 static const AFX_MSGMAP messageMap = { &CFrameWnd::GetThisMessageMap, &_messageEntries[0] }; return &messageMap; }六个字段分别是:消息号、通知码、ID 下界、ID 上界、函数签名标签、成员函数指针。nSig这个字段值得单独说一句——C++ 的成员函数指针类型不能随便互相转换,int OnCreate(LPCREATESTRUCT)和void OnCommand()的指针布局可能都不一样,MFC 必须在调用前知道确切签名,才能把它static_cast回正确的类型再调用。AfxSig_is、AfxSigCmd_v这些枚举就是在干这件事,它是一张“调用约定说明书”。
顺带澄清一个常见误解:很多老教材画出来的展开是一大坨switch-case,那其实是 16 位 MFC 早期版本的做法,或者是为了讲解方便的教学简化。VC++6.0 带的 32 位 MFC 走的是表驱动,不用 switch。
2.3 表驱动查找:AfxFindMessageEntry 的线性扫描与区间语义
窗口真正注册给系统的窗口过程不是你的成员函数,而是 MFC 内部那个统一的AfxWndProc。它拿到HWND后反查出对应的CWnd*(通过句柄映射表),调用对象的虚函数取到消息映射链,然后开始查表:
// 简化后的查找逻辑 const AFX_MSGMAP_ENTRY* AFXAPI AfxFindMessageEntry(const AFX_MSGMAP_ENTRY* lpEntry, UINT nMsg, UINT nCode, UINT nID) { while (lpEntry->nSig != AfxSig_end) { // 走到哨兵就停 if (lpEntry->nMessage == nMsg && // 消息号必须相同 lpEntry->nCode == nCode && // 通知码必须相同 nID >= lpEntry->nID && // ID 落在闭区间内 nID <= lpEntry->nLastID) return lpEntry; // 命中,返回表项 ++lpEntry; // 逐个前进,没有哈希 } return NULL; // 没找到,交回上层 }注意nID >= nID && nID <= nLastID这两行。普通的ON_COMMAND(ID_XXX, func)展开后上下界相等,退化成精确匹配;而ON_COMMAND_RANGE(id1, id2, func)会展开成一个上下界不同的表项,一次性覆盖一段 ID。这个设计让“一类命令共用一个处理函数”变成零成本,不用写几十个 case。
扫描是线性的,没有哈希也没有二分。听起来很亏,但实际表项通常就几十条,而且命中率高,顺序查找的常数因子极小——这是一个典型的“用简单换可预测”的取舍。
没找到怎么办?AfxWndProc会沿着pBaseMap指针往上找基类的表:CMainFrame找不到查CFrameWnd,再查CWnd,一路链到一个最终的兜底,把消息交给系统的DefWindowProc。这就是原始资料里说的“未处理的消息到哪里去了”——不是被丢弃,是被一层层往上转交,最后由系统默认窗口过程处理拖动、缩放、边框绘制这些你平时根本不用操心的杂事。
WM_COMMAND还多一层特殊待遇,就是命令路由。MFC 遇到命令消息不是只问当前窗口,而是按CView → CDocument → CFrameWnd → CWinApp的顺序问一圈,谁先映射了谁处理。所以你会遇到“我在视图类里写的ON_COMMAND,点击菜单栏也能响应”这种情况——不是玄学,是路由帮你转过去的。
3. 消息循环的三层结构:GetMessage、DispatchMessage 和窗口过程谁先谁后
理解了映射表,还得知道消息是怎么走到这张表前面的。这段链路只有三个函数,但每一层的语义差别很大,搞混了就会写出“界面卡死”或者“消息丢了”的代码。
3.1 队列消息与非队列消息:SendMessage 为什么绕过队列
Windows 的消息分两类,这是排查问题的第一把钥匙:
| 类型 | 产生方式 | 是否入队 | 典型代表 |
|---|---|---|---|
| 队列消息 | 投递到线程消息队列 | 是 | 鼠标键盘输入、WM_PAINT、WM_TIMER、PostMessage |
| 非队列消息 | 直接调用目标窗口过程 | 否 | SendMessage发出的一切消息 |
PostMessage把消息塞进队列就返回,异步;SendMessage直接调用目标窗口的窗口过程,等它返回才返回,同步。这个差别带来一个隐蔽的风险:在窗口过程内部对自身SendMessage,会立刻发生递归重入,外层的局部变量还停在半路上,内层已经把同一段逻辑又执行了一遍。
我一般会这么理解:PostMessage是寄信,SendMessage是打电话。寄信不占线,打电话对方不接你就一直等着——如果对方正在等你回话,两个人就永远僵在那里,这就是经典的死锁。
队列本身也有容量上限,Win32 的线程消息队列大约一万条。PostMessage返回FALSE通常意味着队列满了,而不是参数错。狂刷PostMessage的代码如果不检查返回值,丢消息是静默的,很难查。
3.2 16 位协同式与 32 位抢先式:一个死循环吃掉的到底是谁的时间片
老资料里反复强调“不要写死循环”,这个告诫在 16 位时代和 32 位时代的含义完全不同。
16 位系统全局只有一个消息队列,所有程序共享。系统把控制权交给某个程序后,只能等它主动还回来。如果这个程序陷进死循环,系统永远拿不回控制权,整台机器就废了。这种多任务叫协同式多任务,本质是“靠自觉”。
32 位系统每个线程都有自己的消息队列,内核调度器按时间片抢占,一个线程卡死在循环里,受害的只有它自己——它的窗口不响应,别的程序照常跑。这叫抢先式多任务。
这个区别解释了一个很常见的现象:你自己写的程序点了按钮没反应,但鼠标还能动、别的窗口还能拖。这时候别怀疑系统坏了,就是那个线程的消息循环停了。而“消息循环停了”的原因几乎总是同一类:你在消息处理函数里做了耗时操作,控制权一直没还给GetMessage。
那GetMessage阻塞时线程到底在干嘛?它不是空转,而是进入一种可被唤醒的等待状态,内核把线程挂起,直到队列里有新消息、或者有 APC、或者有窗口需要重绘才唤醒它。所以“没有消息就不占 CPU”不是优化技巧,是内核调度器给的天然行为。有人为了“省 CPU”在循环里加Sleep(1),反而引入了 1 毫秒的输入延迟,得不偿失。
3.3 WM_PAINT 的低优先级与消息合并:重绘请求为什么会被“吃掉”
WM_PAINT是队列消息里最特殊的一个,因为它不是“来了一条就排一条”,而是“有标记才生成”。
InvalidateRect做的事情只是给窗口打上一个“这块区域脏了”的标记,并不产生消息。系统会在消息队列空闲的时候,检查有没有脏区域,有才合成一条WM_PAINT投进去。如果你在两次取消息之间连续调用了十次InvalidateRect,最终只会产生一条WM_PAINT,脏区域取并集。
这就解释了为什么“统计重绘次数”的代码永远对不上号,也解释了为什么在OnDraw里做增量绘制是错的:你根本不知道这次重绘是为了哪一块,唯一正确的做法是每次都按当前数据完整重画。
BeginPaint和EndPaint这一对函数负责“校验”脏区域——BeginPaint时系统把区域清空并把裁剪区设成该区域,EndPaint时确认。如果你直接拿GetDC()画而不用BeginPaint,脏区永远不会被校验,WM_PAINT会无限循环发下去,界面看起来像卡死。这个坑我见过不止一个人踩。
4. GDI 对象与 DC:为什么一支画笔用完必须选回旧的那支
绘图这部分,原生的 Win32 接口和 MFC 的封装差别不大,都是“创建对象 → 选进设备环境 → 用 → 恢复 → 销毁”。区别在于 MFC 把这些对象包成了CPen、CBrush、CFont这些类,析构函数里会自动DeleteObject,看起来省事了,实际上把“对象生命周期”和“设备环境状态”这两件事的耦合藏得更深了,翻车也更隐蔽。
4.1 设备环境是“画布”的抽象:一份 OnDraw 怎么同时喂给屏幕和打印机
DC(设备环境)本质上是一组绘图状态的集合:当前画笔、当前刷子、当前字体、背景色、映射模式、裁剪区。它不绑定具体设备,可以对应屏幕、打印机、内存位图。这就是所谓的设备无关性——同一套绘图代码,换个 DC 就是换个输出设备。
MFC 把这层抽象做得更彻底。CDC类内部其实握着两个句柄:一个是输出句柄m_hDC,一个是属性句柄m_hAttribDC。绝大多数时候两者相同,但在打印预览的场景下,它们会被拆开:输出走屏幕,属性查询走模拟的打印机。CPreviewDC就是在干这件事,它让“按打印机分辨率计算布局、按屏幕分辨率显示”这两个矛盾的需求共存。
这就是OnDraw(CDC* pDC)的威力所在:屏幕绘制调用它,打印调用它,打印预览也调用它,只不过传入的CDC*指向不同的实现。一份代码三种输出,不用写三遍排版逻辑。代价是:OnDraw里绝不能做任何假设“我在画屏幕”的事情,比如直接调GetDC()、假设像素尺寸、假设颜色深度。一旦假设了,打印出来就是另一幅样子。
顺带说一个细节:CView::OnPaint收到WM_PAINT后,做完BeginPaint就把控制权转给OnDraw;CView::OnPrint收到打印请求后也是转给OnDraw。所以你在OnDraw里塞的代码,打印时也会跑一遍,包括那些耗时计算。想区分场景,就重载OnPrint或者判断pDC->IsPrinting()。
4.2 创建—选入—恢复—销毁:四步顺序里哪一步错了会漏句柄
先看一段“正确但看着啰嗦”的代码,这是原始资料给出的安全写法,我加了注释:
void CMyView::OnDraw(CDC* pDC) { CPen pen1, pen2; // 1. 创建:失败会返回 FALSE,实际工程里应该检查 pen1.CreatePen(PS_SOLID, 2, RGB(128,128,128)); pen2.CreatePen(PS_SOLID, 2, RGB(128,128,0)); // 2. 选入,同时把 DC 里原来那支笔的句柄存下来, // 这个返回值是后面唯一的“后悔药” CPen* pOldPen = pDC->SelectObject(&pen1); DrawSomethingWithPen1(pDC); pDC->SelectObject(&pen2); DrawSomethingWithPen2(pDC); // 3. 想换颜色重画?必须先把它从任何 DC 里摘出来 pDC->SelectObject(pOldPen); pen1.DeleteObject(); if (!pen1.CreatePen(PS_SOLID, 2, RGB(0,0,0))) return; // 创建失败就别往下走了 pDC->SelectObject(&pen1); DrawSomethingAgain(pDC); // 4. 收尾:恢复 DC 原貌,局部对象随后由析构函数销毁 pDC->SelectObject(pOldPen); }四个动作里最关键的是“恢复”。GDI 对象一旦被选入 DC,就处于加锁状态,此时调用DeleteObject会失败并返回FALSE。更糟的是,CGdiObject的析构函数会替你调用DeleteObject,如果这时对象还挂在 DC 里,删除失败,句柄泄漏;如果你在别处又删了一次,DC 里留下的是一个悬空句柄,下一次绘制就会出诡异问题。
所以顺序必须严格是:先选回旧对象,再销毁新对象。写反了不会立刻崩,但会在几十次操作之后开始出现“画笔变细”“颜色串了”“界面花屏”这类看上去毫无规律的症状,查起来非常费劲。
4.3 库存对象与临时对象:哪些句柄可以不管,哪些必须管
系统内置了一批库存对象:BLACK_PEN、WHITE_BRUSH、NULL_BRUSH、SYSTEM_FONT等等,用CDC::SelectStockObject(nIndex)直接选进 DC。它们由系统持有,进程退出才释放,所以永远不需要销毁。这就带来一个实用技巧:当你想恢复 DC 又懒得保存旧句柄时,可以选一个库存对象兜底,比如SelectStockObject(NULL_BRUSH)表示“不填充”。
真正危险的是临时对象。像这样写:
void CMyView::OnDraw(CDC* pDC) { // 错误示范:临时对象在表达式结束后就被析构 pDC->SelectObject(&CPen(PS_SOLID, 2, RGB(255,0,0))); // 这一行执行时,那个 CPen 已经调过 DeleteObject 了 pDC->LineTo(100, 100); }SelectObject返回的旧句柄被丢掉了,临时对象在分号处析构,析构函数对着一个仍在 DC 里的对象调DeleteObject。这段代码运气好能画出一条线,运气不好就是后续所有绘制一起坏掉。凡是看到SelectObject(&临时对象)的写法,基本可以直接判定有隐患。
判断标准很简单:只要对象是局部变量,就必须老老实实保存SelectObject的返回值并在离开前恢复;只有库存对象可以随便选。这条规则没有例外,记不住就养成“选入必存返回值”的肌肉记忆。
5. 常见问题排查:MFC 新手最容易翻车的五个现场
前面讲的都是机制,这一章讲症状。MFC 的调试体验在 VC6 时代算不上友好,很多问题没有报错,只有“表现不对”,所以建立一条从现象到原因的排查路径,比背函数原型有用得多。
5.1 现象:界面能正常画,但进程的 GDI 对象数一路涨,最后创建失败
原因:CreatePen/CreateFont/CreateBitmap创建的对象没有配对销毁,或者只在部分分支上调用了DeleteObject。进程的 GDI 句柄配额是有限的,用满之后创建函数会静默返回FALSE,绘图代码不检查返回值,于是画面开始缺东少西。
解决:把任务管理器的“GDI 对象”列打开(VC6 时代的系统需要加列,现代系统默认显示),反复操作某个功能,看数字是不是单调上升。定位到具体函数后,把“创建/销毁”成对写,最好封装成一个小类,构造函数创建、析构函数销毁,并且保证析构前已经从 DC 中摘出。
5.2 现象:同一个画笔对象第二次CreatePen之后,颜色或线宽不对了
原因:对一个已经创建过、且仍处于“已创建”状态的对象再次Create。GDI 对象在创建状态下重复创建会失败,返回FALSE,而对象保留的是旧属性;更隐蔽的情况是它已被选入某个 DC,DeleteObject也失败,两次失败叠加之后对象状态就是不确定的。
解决:养成“创建前先DeleteObject、创建后检查返回值”的固定顺序。如果这个对象是复用的,中间一定要先把它从所有 DC 里选出来。检查返回值这一步别省,if (!pen.CreatePen(...)) return;一行代码能省掉半天的排查。
5.3 现象:消息映射的宏写了,断点就是不进
原因:可能性按概率排:afx_msg声明写在了//{{AFX_MSG注释块外面导致 ClassWizard 维护失效;DECLARE_MESSAGE_MAP()和BEGIN/END_MESSAGE_MAP没配对;资源 ID 写错,ON_COMMAND里的 ID 和菜单实际 ID 对不上;控件通知忘了区分HIWORD(wParam),被同 ID 的另一个通知分支抢先命中。
解决:在GetMessageMap()里下断点,看_messageEntries数组里到底有没有生成你写的那一条。这一步能立刻把“宏没展开”和“展开了但没匹配上”两类问题分开。如果表里有、断点不进,那就是 ID 或通知码不匹配,去对照资源编辑器里的实际 ID 值。
提示:
ON_COMMAND只处理WM_COMMAND且通知码为 0 的情况。按钮点击的通知码是BN_CLICKED,虽然也能进ON_COMMAND分支,但如果同一 ID 还映射了ON_UPDATE_COMMAND_UI,两者的匹配顺序会影响你的预期,排查时要把nCode一起打印出来。
5.4 现象:窗口拖动或缩放时白屏、闪烁,画面像被撕开
原因:多半是在OnDraw里做了重活,或者没处理背景擦除。WM_ERASEBKGND先把背景刷成底色,然后你才开始画内容,两步之间的空档被用户看到了,就是闪。拖动时系统会不断产生重绘请求并合并,重活越多,空档越明显。
解决:先缩小无效区域(InvalidateRect传具体矩形而不是NULL),再把耗时计算从OnDraw里挪出去,只留纯粹的绘制。真需要消除闪烁时,重载OnEraseBkgnd直接返回TRUE,表示“背景我自己管”,然后在OnDraw里一次性铺满。别一上来就上双缓冲,先把重绘频率降下来,多数场景就够了。
5.5 现象:点一下按钮,整个窗口就不动了,标题栏显示“未响应”,但过一会儿又恢复
原因:消息处理函数里做了长耗时操作,控制权没还给GetMessage,消息循环停摆。这段时间里所有输入消息都堆在队列里,等函数返回后一次性涌入。
解决:把长任务拆成若干小段,用SetTimer驱动一个状态机,每次处理一小块,处理完立刻返回,让消息循环有机会喘气;或者放到后台线程里跑,算完用PostMessage把结果投回 UI 线程。千万别用SendMessage从后台线程往 UI 线程发——那是打电话,UI 线程正忙着等你就死锁了。
6. 结构选型与学习次序:为什么先啃字符界面程序的人反而跑得更快
MFC 提供了四种常见的程序骨架,选错了后面每加一个功能都在跟框架较劲。原始资料里那一串“什么时候用对话框、什么时候用单文档”的建议,背后其实是同一件事:数据与界面的耦合度决定结构。
| 结构 | 适合的场景 | 代价 |
|---|---|---|
| 对话框 | 交互为主,文件读写简单,功能入口少 | 界面布局写死在资源里,扩展性差 |
| 单文档(SDI) | 有明确的“文档”概念,需要菜单、工具栏、状态栏 | 引入文档/视图分离,初学理解成本高 |
| CFormView | 交互复杂但仍是单一文档 | 表单控件与文档数据同步要自己维护 |
| 多文档(MDI) | 真的需要同时打开多份文档并互相传数据 | 窗口管理、激活切换、资源竞争全面复杂化 |
最后一行是重点:多文档的复杂度不是线性增长的。所以那条“对多文档要求不强烈时尽量避免,可以用分隔条做单文档多视”的建议,是我认为整份资料里最值钱的一条。多视结构一样能把界面拆成几块,但省掉了整个 MDI 子窗口管理体系。
至于学习次序,先写字符界面程序这件事经常被误解成“浪费时间”。恰恰相反,它把变量作用域、指针生命周期、对象构造析构这些日后在OnDraw里天天要面对的问题,放在一个没有消息循环干扰的环境里练熟。等到进 MFC 时,你只需要学“消息怎么来、怎么映射、DC 怎么用”这三件事,而不是同时学语言、学框架、学系统。反过来,跳过这一步直接抄代码的人,往往在第一次遇到句柄泄漏时完全无从下手,因为它既不是语法错误也不是逻辑错误,是资源生命周期问题——而这类问题恰恰是字符界面阶段最常被训练到的。
从那以后我每次接手一个 MFC 老项目,都强制走一遍同样的顺序:先找到入口的消息循环,确认它有没有被阻塞;再看目标窗口的GetMessageMap()表实际展开了什么;最后才去读业务代码。这套顺序不解决业务问题,但它能把“界面不动”“数据不对”“越跑越慢”这三类症状快速分流到正确的方向上,省下来的时间远比读代码多。这份资料里消息机制、GDI 对象、常用类三块正好是沿着同一条线铺开的,照着顺序读一遍再回头对源码,比跳着看快得多。希望帮到你。
本文还有配套的精品资源,点击获取