基于Qt+MSVC+VLD的内存泄漏检测工具开发实践
简介基于 Qt、MSVC、VLD 开发的内存检测工具及配套源码面向需要排查 C 内存泄漏问题的 Qt 桌面应用开发者。资源包共 6 个文件压缩后体积仅 3KB虽小巧却包含完整工程要素两个 C 源文件承担主程序与逻辑实现一个头文件声明核心接口一个界面文件定义可视化交互界面一个工程文件配置相关依赖另有版本管理文件规范项目维护。VLD 即 Visual Leak Detector是 Windows 平台广泛使用的内存泄漏检测库能在程序退出时自动输出泄漏报告精确到文件与行号这套源码完整展示了在 QtMSVC 环境下挂接 VLD 的流程从工程配置、界面触发检测到结果展示与资源释放步骤清晰便于二次开发。读者可将其作为基础模板快速为自己的 Qt 项目接入内存泄漏检测也可基于界面逻辑扩展为独立的诊断工具。已有 192 人学习下载适合初涉 Qt 内存管理、希望为项目引入自动化内存监控手段或需要排查疑难泄漏点的中高级开发者参考借鉴。1. 从一次崩溃现场讲起为什么内存检测工具要选 qtmsvcvld那次是在产品发布前夜Windows 客户端跑了一晚上内存涨到 1.8GB界面卡到鼠标都拖不动。我打开任务管理器确认不是第三方 SDK 的问题然后用 vld 重新编译一个 Debug 版本退出时拿到一份报告定位到一个 QtConcurrent::run 的 lambda 里 shared_ptr 被循环捕获、引用计数一直不降。那一刻我意识到qtmsvcvld 这条路不是可选项是 Windows 下 C 客户端的标配组合。本文想讲的不是 VLD 怎么用而是怎么围绕它开发一个带界面的内存检测工具报告解析、调用栈定位、重复泄漏合并这些活全都做成源代码谁拿去都能自己改。适合手头维护着 Qt 客户端、经常被内存问题半夜叫醒的 C 开发者。2. 环境与最小工程qt 配置 msvc、vld 接入和第一条可信的 CMake 命令网上搜内存检测工具源码找到的大多是命令行工具VLD 报告打印完就没了。真正干活的时候你需要的是一个能启动被检程序、自动读报告、把泄漏点按文件和行号列成表格的桌面工具。先把这套组合为什么固定下来讲清楚后面写代码才不踩 CRT 的坑。2.1 msvc 和 mingw 的区别内存检测必须跟着 CRT 走很多人装 Qt 时只在意 Qt 版本没在意编译器套件。Qt 官方安装器里同时提供 MinGW 和 MSVC 两套预编译库这两套库的 ABI 不互通MinGW 的 libstdc 和 MSVC 的 STL 实现、异常模型、new/delete 的底层分配函数都不一样。你拿 mingw 编的 exe 去链接 msvc 的 Qt 库链接器会直接报错就算强行链上运行期也是玄学。内存检测工具选型更要看 CRT。VLD 的核心机制是挂钩 MSVC CRT 的调试堆分配函数在每次 new 和 delete 时记录调用栈进程退出时扫描还没释放的块。这套钩子只认 MSVC 的 CRT所以被检程序和工具本身都得走 msvc。LLVM 的 clang-cl 在 Windows 上也能编 Qt但 VLD 对非 MSVC 工具链的兼容性是黑匣子没必要拿生产时间去赌。结论很直接在 Qt 安装器里勾选 MSVC 套件比如 “MSVC 2019 64bit” 或 “MSVC 2022 64bit”不要勾 MinGW。工具链统一CRT 统一VLD 才能稳定工作。2.2 VLD 在管什么hook CRT 分配和调用栈回溯理解 VLD 的原理不需要翻源码抓住三点就够了。第一它在你的程序启动早期就接管了 CRT 的分配函数所有 new/malloc 都会先经过它的记账逻辑。第二它用栈回溯库在分配点抓调用栈记录文件和行号。第三程序退出时它把所有还没释放的分配块列出来按调用栈聚合成一份报告。也就是说VLD 能回答三个问题泄漏了多少字节、这块内存是在哪个文件哪一行分配的、这个分配点的调用链是什么。它不关心业务逻辑也不管是谁把你内存踩了它只给出第一现场。我们开发内存检测工具就是把这个第一现场搬到一个能筛选、能跳转、能比较的界面里去。2.3 qt 配置 msvc 的落地动作安装勾选、CMake 与第一条可编译命令工具本身用 Qt Widgets 写不搞 QML界面逻辑简单表格和按钮足够。工程文件我用 CMake不推荐 qmake因为 CMake 对 MSVC 的运行时库选项控制更直接。下面这个 CMakeLists 是能直接用的骨架VLD 路径按你本机安装目录改。cmake_minimum_required(VERSION 3.16) project(vldtool LANGUAGES CXX) find_package(Qt6 COMPONENTS Widgets) if(NOT Qt6_FOUND) find_package(Qt5 5.12 COMPONENTS Widgets REQUIRED) endif() set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # VLD 安装目录修改成你自己的路径 set(VLD_ROOT C:/Visual Leak Detector CACHE PATH VLD install path) add_executable(vldtool main.cpp MainWindow.cpp MainWindow.h ) target_link_libraries(vldtool PRIVATE Qt${QT_VERSION_MAJOR}::Widgets) # 只有 MSVC Debug 才接 VLD if(MSVC AND CMAKE_BUILD_TYPE STREQUAL Debug) if(CMAKE_SIZEOF_VOID_P EQUAL 8) set(VLD_ARCH x64) else() set(VLD_ARCH Win32) endif() target_include_directories(vldtool PRIVATE ${VLD_ROOT}/include) target_link_directories(vldtool PRIVATE ${VLD_ROOT}/lib/${VLD_ARCH}) target_link_libraries(vldtool PRIVATE vld) target_compile_options(vldtool PRIVATE /utf-8) # 把 VLD 运行库复制到 exe 旁边dll 名以安装包里实际文件名为准 add_custom_command(TARGET vldtool POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${VLD_ROOT}/bin/${VLD_ARCH}/vld.dll $TARGET_FILE_DIR:vldtool) endif()CMake 里最关键的是把 VLD 的链接限制在 MSVC 加 Debug 配置下。VLD 依赖 CRT 调试堆的分配序号和块类型信息Debug 下拿到的调用栈才是完整的。Release 下就算链进去报告质量也会明显变差所以我一般直接把 Release 排除在检测范围外。/utf-8编译选项是为了让源码里的中文注释和界面字符串在 MSVC 下不乱码这个参数建议每次都加。main.cpp 的核心不是创建窗口而是头文件包含顺序。VLD 的vld.h里通过宏重定义了 new如果把它放在 Qt 头文件之前会影响 Qt 头文件自身的展开编译期可能出一堆莫名其妙的报错。正确顺序是先包含所有 Qt 头文件最后一个包含 vld.h。// main.cpp #include QApplication // Qt 头文件之后最后一个包含 vld.h #include vld.h #include MainWindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }这个包含顺序不是洁癖是血泪经验。之前有同事把 vld.h 放进了公共头文件结果所有 cpp 编译都过链接期开始报 new 符号冲突排查了一下午。把 vld.h 限制在少数几个 cpp 里是最稳的做法因为内存检测工具只需要在启动入口和报告生成点接上 VLD不需要让每个文件都感知它。3. 把 VLD 报告变成能下钻的表格解析器与 Qt 界面映射VLD 自带报告文件但直接打开文本看几百条泄漏堆栈根本没法维护。这章的落点是把报告解析成结构化数据再喂给 QTableView。这也是整个工具最有价值的部分让原始输出变成可排序、可跳转、可聚合的“泄漏清单”。3.1 VLD 报告格式拆解从一行标题到一段调用栈先看一段 VLD 报告的真实面目。文件开头通常是一段警告然后每个泄漏块长这样---------- Block 4875 at 0x00A528D8: 56 bytes ---------- Leak Hash: 0x3A2B1C4D, Count: 1 Call Stack: D:\work\vldtool\test\main.cpp(42): allocateFoo0x12 D:\work\vldtool\test\main.cpp(18): main0x5 f:\dd\vctools\crt\crtw32\startup\crtexe.c(618): __tmainCRTStartup0x1B Data: AA BB CC DD ...逐行拆解下来值得关心的字段只有四个Block 后的数字是分配块编号at 0x...是泄漏内存地址56 bytes是泄漏大小Leak Hash是 VLD 给这个调用栈算的指纹。Count: 1表示这个块出现了几次后面重复块只给 hash 不给完整栈。Call Stack 里第一行是分配点往下是调用者。Data 行是内存内容的十六进制转储对定位泄漏用处不大可以在解析时直接丢弃以减小模型体积。3.2 手写 VldReportParser按行做状态机而不是整段硬切解析报告我不用一次性读到内存里做字符串切割因为报告文件可能几百 KB且格式有分区。按行做状态机更稳碰到 Block 行开新记录碰到 Call Stack 进入栈收集状态碰到空行结束当前记录。下面这个解析函数是核心代码可以直接抄进你的工具工程里。// VldReportParser.h #pragma once #include QList #include QString #include QRegularExpression struct LeakBlock { int blockNo 0; qint64 size 0; QString hash; QString firstFrame; // 分配点的 文件(行号): 函数名 int frameCount 0; // 调用栈总深度 }; inline QListLeakBlock parseVldReport(const QString path) { QListLeakBlock blocks; QFile f(path); if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return blocks; QTextStream in(f); // 示例: ---------- Block 4875 at 0x00A528D8: 56 bytes ---------- QRegularExpression blockRx( QStringLiteral(R(---------- Block (\d) at (0x[0-9A-Fa-f]): (\d) bytes ----------))); // 示例: D:\work\vldtool\test\main.cpp(42): allocateFoo0x12 QRegularExpression frameRx( QStringLiteral(R(^\s(.)\((\d)\):.*$))); LeakBlock cur; bool inStack false; while (!in.atEnd()) { QString line in.readLine(); auto bm blockRx.match(line); if (bm.hasMatch()) { if (cur.blockNo ! 0) blocks.append(cur); cur LeakBlock(); cur.blockNo bm.captured(1).toInt(); cur.size bm.captured(3).toLongLong(); inStack false; continue; } if (line.contains(QStringLiteral(Call Stack:))) { inStack true; continue; } if (inStack !line.trimmed().isEmpty()) { auto fm frameRx.match(line); if (fm.hasMatch()) { if (cur.firstFrame.isEmpty()) { cur.firstFrame fm.captured(1) QLatin1Char(:) fm.captured(2); } cur.frameCount; } } else if (inStack line.trimmed().isEmpty()) { inStack false; } } if (cur.blockNo ! 0) blocks.append(cur); return blocks; }逻辑说明一下。blockRx匹配块头抓块号、地址、大小地址我们其实不关心所以只取了块号和大小。frameRx匹配缩进后的调用栈行捕获文件路径(行号)两部分函数名那一段在正则里用.*吞掉因为不同 VLD 版本对函数名的修饰规则不一样抓文件行才是跨版本稳定的。firstFrame只保留第一帧也就是分配点这是后面做跳转和聚合的主键。frameCount记录栈深度可以在界面上提示“这个泄漏是不是发生在深层异步调用里”。解析完成后把结果灌进 QStandardItemModel。// MainWindow.cpp 片段 auto *model new QStandardItemModel(this); model-setHorizontalHeaderLabels({QStringLiteral(块号), QStringLiteral(大小), QStringLiteral(分配点), QStringLiteral(栈深度)}); for (const auto b : blocks) { QListQStandardItem * row; row new QStandardItem(QString::number(b.blockNo)) new QStandardItem(QString::number(b.size)) new QStandardItem(b.firstFrame) new QStandardItem(QString::number(b.frameCount)); model-appendRow(row); } ui-tableView-setModel(model); ui-tableView-horizontalHeader()-setStretchLastSection(true); ui-tableView-setSortingEnabled(true);用 QStandardItemModel 而不是自己写 QAbstractTableModel理由很直接这个工具的数据量最多几千行QStandardItemModel 足够还能免费拿到排序、拖拽、编辑能力。setSortingEnabled(true)打开后点表头按大小排序最大泄漏一眼就能看到。3.3 双击定位到源码行给泄漏点加“后悔药”表格里看得到分配点还不够要能一键跳到源码才算闭环。我一般用 QDesktopServices 打开编辑器让系统决定用哪个工具打开 .cpp 文件。跳转动作绑定在 tableView 的双击信号上。connect(ui-tableView, QTableView::doubleClicked, this, [this, model](const QModelIndex idx) { const int blockColumn 0; int blockNo model-item(idx.row(), blockColumn)-text().toInt(); for (const auto b : m_blocks) { if (b.blockNo blockNo) { parseFirstFrame(b.firstFrame); // 拆出路径和行号 break; } } }); void MainWindow::parseFirstFrame(const QString frame) { // frame 形如 D:\work\main.cpp:42 int colon frame.lastIndexOf(QLatin1Char(:)); QString filePath frame.left(colon); bool ok false; int line frame.mid(colon 1).toInt(ok); if (ok QFile::exists(filePath)) { QDesktopServices::openUrl(QUrl::fromLocalFile(filePath)); } else { statusBar()-showMessage(QStringLiteral(源码路径不存在: ) frame); } }注意这里有个坑frame的格式是文件(行号)我在解析时转成了文件:行号但 Windows 路径本身就带盘符冒号比如D:\work\main.cpp。所以必须用lastIndexOf(:)找最后一个冒号否则会切到D:上。文件不存在时不要弹模态框状态栏提示就够了因为 VLD 报告里的路径可能来自别的机器本地没有对应源码是常态。3.4 按调用栈指纹去重把 2000 条泄漏浓缩成 20 类真实程序的泄漏报告往往不是几十条而是几百上千条因为同一个泄漏点会被触发多次。VLD 有AggregateDuplicates参数但在工具端自己做一次聚合更可控因为你可以选择按第一帧还是按整个栈聚合。我按第一帧的文件加行号做指纹把重复泄漏合并成一行列里显示次数。QMapQString, LeakAggregate aggregateMap; for (const auto b : blocks) { QString key b.firstFrame; // 文件路径 行号 auto agg aggregateMap[key]; agg.count; agg.totalBytes b.size; agg.firstFrame b.firstFrame; agg.lastBlockNo b.blockNo; }聚合结果重新填入 model列变成分配点、泄漏次数、总字节、最近块号。这样表格从几千行降到几十行按总字节排序先修最大的那一类。这个聚合粒度是可以调的如果发现同一个文件同一行有两种不同大小的泄漏那说明分配点相同但调用上下文不同这时候可以把 key 换成整个调用栈的拼接字符串。4. 让工具真正跑起来QProcess 拉起被检程序、vld.ini 参数与多轮测试解析报告只是后半段前半段是怎么让 VLD 乖乖把报告写到你能监控的地方。这章讲工具怎么启动被检程序、怎么配置 VLD 的输出以及多轮测试时怎么管理会话。4.1 用 QProcess 拉起被检程序参数传递与工作目录被检程序是一个独立的 exe工具通过 QProcess 启动它。启动时要设置两样东西被检程序的启动参数以及工作目录。工作目录很关键VLD 默认会优先在 exe 所在目录找 vld.ini我们把 vld.ini 放到工作目录里VLD 就会按这个配置输出报告。m_process new QProcess(this); m_process-setProgram(ui-exePathEdit-text()); m_process-setArguments(QProcess::splitCommand(ui-argsEdit-text())); m_process-setWorkingDirectory(ui-workDirEdit-text()); connect(m_process, QProcess::finished, this, MainWindow::onProcessFinished); m_process-start();参数传递这里有个容易翻车的细节不要自己按空格去 split 命令行。Windows 命令行里带空格的路径要用引号包起来自己 split 会把引号也当成参数传给程序。用QProcess::splitCommand让它处理引号和转义你只需要把整条参数字符串填进输入框。setWorkingDirectory必须在start()之前调用否则 QProcess 会用工具的当前目录vld.ini 就找不到了。4.2 vld.ini 必调参数表ReportTo、MaxTraceFrames、MaxDataDumpvld.ini 是 VLD 的配置文件我没把它做成界面设置而是直接放一份模板在工具的资源目录里启动被检程序前复制到工作目录。下面是我固定用的一套配置。[Options] ; 输出到文件方便工具读取别只打 OutputDebugString ReportTofile ReportFilevld_report_%pid%.txt ; 调用栈深度默认值偏浅异步场景下加大 MaxTraceFrames32 ; 内存内容转储字节数设小一点报告更干净 MaxDataDump8 ; 相同调用栈自动合并 AggregateDuplicatesyes参数说明就三句。ReportTofile是必选项VLD 默认往调试器输出窗口打工具读不到设成 file 后报告落在被检程序的工作目录。ReportFile里的%pid%是 VLD 支持的占位符多进程并发测试时不会互相覆盖。MaxTraceFrames32是我在异步任务多的项目里调出来的值默认值抓不到 QtConcurrent 内部的深栈设太大报告体积会暴涨32 是平衡点。MaxDataDump8等于只转储前 8 字节反正数据内容对排查泄漏作用有限还能让文件小一半。4.3 多轮测试与多进程管理QTabWidget 和一次性泄漏的识别内存检测不是跑一次就结束的事。我把每次启动被检程序称为一个“会话”界面右侧用 QTabWidget 挂多个会话页签每个页签一个 QTableView互不干扰。跑三轮之后切到“聚合”页签看同一个分配点是不是每轮都出现。只出现一次的大多是被检程序启动阶段的静态初始化一次性问题不是真泄漏三轮都出现且总字节数稳定增长的就是必须修的持续泄漏。有些泄漏路径要反复操作特定界面才能触发纯手工点容易漏。我在这类测试里会写一小段 Qt Test 的回放脚本用QTest::mouseClick模拟鼠标点击事件去反复触发那个按钮让泄漏稳定复现。这个脚本不算测试框架就是工具里的一个“重复点击”按钮绑定到 QTest 的底层接口能在 5 秒内点五十次目标控件比人力可靠得多。5. 编译与运行避坑清单5 个常见问题与排查实录写这个工具的过程中遇到过的坑基本集中在编译和运行两个阶段。每条我按现象、原因、解决三段式记录对照排查比翻文档快多了。5.1 编译报 cannot mix incompatible qt library现象CMake 配置没问题一编译就报fatal: cannot mix incompatible qt library (version ex50601) with this library后面跟一串版本号。原因这个 ex50601 是 Qt 库头文件里的版本宏展开结果表示你读到的 Qt 库是 5.6.1但链接器找到的库文件是另一个版本。最常见的情况是 CMake 缓存里残留了旧 Qt 路径或者 Qt 安装器升级后 Creator 还在用旧套件路径。另一个隐蔽来源是 PATH 里有多个 Qt 的 bin 目录运行时 dll 和构建时 lib 对不上。解决先删掉 build 目录里的 CMakeCache.txt重新指定 Qt 的 CMAKE_PREFIX_PATH确保你指定的确实是当前 Qt 版本的 msvc 目录。然后再查环境变量 PATH 里是不是混了多套 Qt bin把旧的去掉。我一般用 CMake 的message(STATUS ${Qt6_DIR})打印实际找到的路径一眼就能看出对不对。5.2 报 unknown module(s) in qt: webenginewidgets现象qmake 或 CMake 配置阶段报错说找不到 webenginewidgets 模块。原因Qt 不是默认全装模块。开发工具本身只需要 Widgets但如果你把报告预览做成 HTML 并用了 QWebEngineView或者从别人工程里拷贝了 .pro 文件就依赖了 WebEngine 模块。安装 Qt 时没勾选ModuleNotFound 就会在配置阶段弹出。解决打开 Qt Maintenance Tool添加 Qt WebEngine 模块重启 Creator 重新配置。如果只是临时跑一下不想为了预览 HTML 加一个大模块直接换回 QTextBrowser 显示报告原文。工具类软件的浏览体验没那么重要少一个 200MB 的模块更划算。5.3 VLD 报告全空或者只有 no leaks但程序肉眼可见在泄漏现象被检程序跑完退出VLD 报告文件里只有No leaks detected或者干脆报告文件没生成。原因排查顺序有讲究。第一步看被检程序是不是 Debug 配置我见过把 Release 版 exe 路径填进工具的VLD 不是不工作而是很多分配信息在 Release CRT 里拿不到报告质量不可信。第二步看 vld.dll 是否复制到了被检 exe 目录DLL 没加载时 VLD 会静默降级为“什么都不做”。第三步看 vld.ini 是否真的被读到了VLD 找不到 ini 时用默认配置默认输出走到调试器窗口而不是文件工具自然读不到。解决三步依次检查。确认被检 exe 是 Debug 编译确认 exe 目录下有对应架构的 vld.dll确认工作目录的 vld.ini 里ReportTofile写对了。再加一步启动被检程序前在工具日志里打印vld.ini的完整路径能省去一半的排查时间。5.4 集成了 vld.h程序退出就报 0000005 访问冲突现象Debug 下运行一切正常点关闭按钮退出时弹窗报0xC0000005访问冲突崩溃位置在 CRT 的堆释放代码里。去掉 vld.h 之后再跑退出正常。原因这是典型的跨模块 new/delete 不匹配。VLD 启动后拦截了全局的分配释放如果被检程序里某个 DLL 是 Release 编译或者用了不同的运行时库比如一个/MTd一个/MDd那么在 DLL 里 new 出来的内存回到主 exe 里 delete两边 CRT 堆不一样VLD 在退出扫描时访问了已经不属于自己的堆块直接崩溃。解决统一所有相关 DLL 和主 exe 的运行时库选项全部用/MDd动态多线程调试 CRT。排查时先用排除法确认是不是 VLD 背锅临时注释掉 main.cpp 里的 vld.h重编一次如果崩溃消失就锁定是 CRT 混用。接下来在 CMakeLists 里给所有 target 统一设置CMAKE_MSVC_RUNTIME_LIBRARY不留给各模块自己决定。5.5 中文路径的报告乱码或解析失败现象被检程序路径或代码路径带中文VLD 报告里中文目录显示成乱码解析器把路径切错双击跳转找不到文件。原因VLD 报告文件的编码和 Qt 读文件时用的编码不一致。源码里如果是 UTF-8 带 BOM而 VLD 按本地代码页输出 ANSI两边一交错中文路径就花了。这不是解析器写得不对是编码没对齐。解决读文件时同时做两层处理。QTextStream 先按 UTF-8 读读出来firstFrame里如果还能看到?或\ufffd替换符就改用QString::fromLocal8Bit重新读整个文件。另外在工具源码里统一加/utf-8编译选项保证源码里的中文常量本身不乱。这两步做完中文路径才能稳定工作。6. 进阶从“看报告”到“守住基线”把工具变成团队后悔药如果工具只是能看报告那它还停留在单机自用阶段。我把它往前走了一步做成“泄漏基线比对”。第一次全量测试时把聚合后的泄漏指纹导出一份基线文件后续每次测试工具自动对比当前指纹集合与基线只显示新增或字节数明显增长的泄漏。基线文件用一个简单的 JSON 结构就够了每条记录包含 firstFrame、指纹哈希、总字节、泄漏次数。每次被测程序跑完工具自动加载基线把新报告的指纹和基线比对新增的排在最前面标红。这个功能上线后团队规则就变成“基线不涨才能发布”而不是“上线前记得看一眼有没有泄漏”。{ baseline: [ { fingerprint: 3a2b1c4d, frame: D:\\work\\main.cpp:42, bytes: 2048, count: 12 } ] }另一个习惯是把刷新动作绑在 QProcess::finished 信号上而不是定时轮询。VLD 是在进程退出时才写报告的轮询要么太早读到半截文件要么太晚白白空转。我在 onProcessFinished 里做了三件事等 200ms 让文件写入完成、重新解析报告、刷新聚合视图。这比 QFileSystemWatcher 监听文件变化更可控因为 watched 在文件被替换时会自动取消监听还得重新 addPath绕了一圈不如直接随进程结束刷新。最后说一个排查习惯修复泄漏前先在基线表里记下这个指纹出现过的三轮数据。如果三轮的总字节数基本不变说明是一块固定大小的缓存没释放通常加一个 delete 就行如果总字节数每轮都在涨这是持续累积型泄漏要去查持有者是谁而不是盯着 new 那一行。这个区别能帮你决定先看分配点还是先看生命周期。我现在接手任何一个 Windows 客户端第一件事就是把这套工具挂到内存基线检查上连续三天没有新增泄漏才敢动发布流程。希望帮到你。本文还有配套的精品资源点击获取