尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Delphi窗口测试工具实战:句柄探测、消息发送与验证闭环

Delphi窗口测试工具实战:句柄探测、消息发送与验证闭环 简介这是一份面向C#桌面开发者与机器视觉入门者的示例工程演示如何在WinForm窗体中集成Halcon图像库借助DirectShow调取笔记本摄像头实时画面并完成二维码识别。项目提供完整源码与测试二维码适合想快速上手C#与Halcon联合开发、处理相机视频流的读者参考。压缩包共36个文件体积约11MB包含9个C#源码文件窗体逻辑、程序入口、4个DLL动态库、3个EXE可执行程序以及配置、资源、工程文件等目录结构清晰打开解决方案即可查看工程全貌。目前已有460人学习。通过学习该工程可理解WinForm中托管DirectShow滤镜图的搭建方式掌握Halcon条码读取参数的配置思路并可直接运行程序验证摄像头采集与解码效果为后续开发视觉检测工具提供可复用起点。1. 拿到 frmWindowTest.rar 先别急着解压这个 Delphi 窗口测试工具包解决什么问题看到 frmWindowTest.rar 这个名字老 Delphi 开发基本能猜出七八分frm 是表单前缀WindowTest 是测试对象rar 是传阅压缩。这类压缩包里跑不出什么大框架大概率是 .dpr、.pas、.dfm 三件套加上一两个资源文件——一个典型的窗口自测工具。它解决的问题很具体验证目标窗口是否存在、句柄能不能拿到、发一条消息过去窗口有没有反应以及目标窗口对 WM_CLOSE、WM_SETTEXT 这类常用消息的处理是否符合预期。适合接手老桌面项目时快速摸清界面行为也适合给 UI 自动化脚本做前置探针。别把它当病毒也别指望它是通用测试框架——它就是一把趁手的小刀。2. 拆开 frmWindowTest 的骨架.dpr、.pas、.dfm 如何共同撑起一个窗体测试程序2.1 命名规则泄露的信息frm 前缀、TfrmWindowTest 类与三个核心文件Delphi 工程的命名规矩几十年没变单元文件叫 uMain.pas里面的窗体类就叫 TfrmMain叫 uWindowTest.pas窗体类就是 TfrmWindowTest。frm 前缀是 Team 代码规范里最常见的一条看到这个压缩包名说明原作者至少守住了「窗体类必须有 frm 前缀」这条红线。解压之后你基本会看到三个文件文件作用测试工具里承担的角色.dpr工程入口负责 Application.Initialize 和 CreateForm启动时决定先显示哪个窗体frmWindowTest 通常是主窗体.pas单元源码写窗体类的字段、方法和事件响应句柄探测、消息发送的全部逻辑都在这.dfm窗体资源描述控件布局、属性和事件绑定决定测试工具长什么样输入框、按钮、日志框.dfm 看着像文本其实是窗体资源丢一行格式不对整个窗体都加载不了。Delphi 的 .dfm 支持文本和二进制两种存储老工程里常见二进制 .dfm用右键 Open As Text 才能转文本查看。frmWindowTest 这种自测工具.dfm 一般不会太复杂几个 TEdit、TButton、TMemo 就够了。打开 .pas你会看到类似这样的骨架unit uWindowTest; interface uses Winapi.Windows, Winapi.Messages, System.SysUtils, System.Classes, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls; type TfrmWindowTest class(TForm) edtTarget: TEdit; // 输入目标窗口标题关键字 btnFind: TButton; // 触发窗口查找 btnSend: TButton; // 给目标窗口发消息 mmLog: TMemo; // 显示查找结果和消息发送结果 procedure btnFindClick(Sender: TObject); procedure btnSendClick(Sender: TObject); private FTargetHandle: THandle; procedure Log(const AMsg: string); end; var frmWindowTest: TfrmWindowTest; implementation {$R *.dfm} procedure TfrmWindowTest.Log(const AMsg: string); begin mmLog.Lines.Add(Format([%s] %s, [TimeToStr(Now), AMsg])); end; end.这个骨架透露了两个关键设计一是 FTargetHandle 字段用来保存找到的窗口句柄二是 Log 方法统一输出带时间戳的日志到 TMemo。这两个东西是整个窗口测试工具的地基——句柄是测试对象日志是观察手段。2.2 窗口测试的本质句柄可获取、消息有响应、闭环可观察窗口测试为什么不能只靠「看界面有没有弹出来」因为 Windows 的窗口本质是一个消息驱动的对象窗口的显示、移动、关闭、重绘全部靠消息队列和窗口过程驱动。你调用 ShowWindow、SendMessage最终都变成一条条 WM_ 开头的消息投递到目标窗口过程里。所以窗口测试做三件事就能覆盖绝大多数场景拿句柄、发消息、观察结果。拿句柄解决「窗口在不在」的问题发消息解决「窗口听不听话」的问题观察结果解决「消息到底有没有生效」的问题。三者凑成一个闭环function IsWindowResponsive(AHandle: HWND; const ANewTitle: string): Boolean; var OldTitle, CurTitle: array[0..511] of Char; begin Result : False; if not IsWindow(AHandle) then Exit; GetWindowText(AHandle, OldTitle, Length(OldTitle)); // 发 WM_SETTEXT同步等待执行完成 if SendMessage(AHandle, WM_SETTEXT, 0, LPARAM(PChar(ANewTitle))) 1 then begin // 读回标题确认改动真的生效 GetWindowText(AHandle, CurTitle, Length(CurTitle)); Result : string(CurTitle) ANewTitle; end; end;这里最容易忽略的一点是 SendMessage 是同步调用。它会一直等到目标窗口过程处理完 WM_SETTEXT 才返回返回值等于 1 表示处理成功。但「返回值等于 1」和「标题真的改了」是两回事——有的窗口处理函数偷懒收到消息直接返回默认值什么也没干。所以测试逻辑里必须把标题读回来做二次确认这也是 frmWindowTest 这类工具比人眼观察更可靠的地方它验证的是结果不是意图。这个小函数就是后面整个工具的核心模型。后面要写的窗体本质上就是把「目标窗口列表、消息类型、验证方式」做成可视化输入让测试逻辑复用这一套闭环。3. 复现 frmWindowTest 的核心功能句柄探测、消息发送与结果回显3.1 最小界面TEdit 输标题、TMemo 看日志、TButton 触发动作自己动手写一个窗口测试工具界面不用花哨。核心就三个输入输出点一个 TEdit 接收目标窗口标题关键字一个 TMemo 做日志回显几个 TButton 触发查找、发消息、清空日志的操作。.dfm 里布局大概长这样object frmWindowTest: TfrmWindowTest Left 0 Top 0 Caption frmWindowTest - 窗口测试工具 ClientHeight 330 ClientWidth 520 object edtTarget: TEdit Left 12 Top 12 Width 260 Hint 输入目标窗口标题的关键字 end object btnFind: TButton Left 280 Top 10 Width 110 Height 25 Caption 查找窗口 OnClick btnFindClick end object btnClose: TButton Left 396 Top 10 Width 110 Height 25 Caption 发送 WM_CLOSE OnClick btnCloseClick end object mmLog: TMemo Left 12 Top 44 Width 496 Height 274 ReadOnly True ScrollBars ssVertical end end为什么用 TEdit 而不是 TComboBox因为测试窗口时标题关键字经常是反复试出来的——先输入一个可能的名字找不到就换一个。TEdit 天然适合这种快速改写的场景。TMemo 的 ReadOnly 必须设 True防止测试过程中不小心动到日志内容。ScrollBars 设 ssVertical因为窗口多了日志会长得很快没有滚动条后面几条日志根本看不到。窗体加载时顺手做一件事把自己的窗口信息打一条日志。这是调试期最便宜的验证手段——如果连自己的句柄都拿不对后面测别人的窗口全是白搭。procedure TfrmWindowTest.FormShow(Sender: TObject); var ClsName: array[0..255] of Char; begin GetClassName(Self.Handle, ClsName, Length(ClsName)); Log(Format(本窗口句柄: %d, 类名: %s, [Self.Handle, ClsName])); end;这里 Self.Handle 就是当前窗体的窗口句柄。Delphi 的 TForm 创建后Handle 才真正指向一个 Win32 窗口之前访问 Handle 会触发隐式创建这个行为在窗口测试里经常被忽略——你拿到的 Handle 可能不是你以为的那个窗口的。3.2 用 FindWindow 和 EnumWindows 拿到目标窗口句柄窗口测试的第一步永远是「找到窗口」。两种方式按标题/类名精确找用 FindWindow按条件枚举找用 EnumWindows。FindWindow 用起来最简单但限制也明显——只能按类名和窗口标题精确匹配不能模糊匹配。实际测试中目标窗口的标题往往带动态后缀比如「文档1 - 记事本」所以 frmWindowTest 这类工具的核心查找逻辑更推荐用 EnumWindows 自己过滤function EnumFindByTitle(AWindow: HWND; AParam: LPARAM): BOOL; stdcall; var Buf: array[0..511] of Char; begin Result : True; // 默认继续枚举 if GetWindowText(AWindow, Buf, Length(Buf)) 0 then Exit; // AParam 传进来的是 TfrmWindowTest 对象自身 if Pos(TfrmWindowTest(AParam).FKeyword, string(Buf)) 0 then begin TfrmWindowTest(AParam).FTargetHandle : AWindow; Result : False; // 找到目标停止枚举 end; end; procedure TfrmWindowTest.btnFindClick(Sender: TObject); begin FTargetHandle : 0; FKeyword : Trim(edtTarget.Text); if FKeyword then begin Log(请输入窗口标题关键字); Exit; end; EnumWindows(EnumFindByTitle, LPARAM(Self)); if FTargetHandle 0 then Log(Format(找到窗口: %s, 句柄: %d, [edtTarget.Text, FTargetHandle])) else Log(Format(未找到标题包含 [%s] 的窗口, [FKeyword])); end;回调函数里有几个细节值得说。第一Result 必须初始化为 True系统依靠返回值决定是否继续枚举返回 False 会立即终止整个枚举过程所以在找到目标后返回 False 能省掉大量无效遍历。第二AParam 这个 LPARAM 参数是唯一的上下文通道回调函数里拿不到 Self只能靠 AParam 把对象自身传进来。第三GetWindowText 拿到的标题会被截断到 511 个字符对窗口测试来说足够用。这套 EnumWindows 方案比 FindWindow 灵活得多你可以在回调里加类名匹配、进程 ID 过滤、可见性判断后面所有进阶玩法都是在这个基础上叠加条件。3.3 发送 WM_CLOSE 与 WM_SETTEXT同步消息的返回值怎么判拿到句柄以后测试工具的发消息环节就好办了。最常用的测试消息就两个WM_CLOSE 验证窗口能不能被正常关闭WM_SETTEXT 验证窗口标题能不能被修改。procedure TfrmWindowTest.btnCloseClick(Sender: TObject); begin if not IsWindow(FTargetHandle) then begin Log(目标句柄已失效请重新查找窗口); Exit; end; // 同步发送关闭消息等待窗口处理完 SendMessage(FTargetHandle, WM_CLOSE, 0, 0); Log(Format(已向句柄 %d 发送 WM_CLOSE, [FTargetHandle])); end; procedure TfrmWindowTest.btnSetTextClick(Sender: TObject); var NewTitle: string; begin if not IsWindow(FTargetHandle) then begin Log(目标句柄已失效请重新查找窗口); Exit; end; NewTitle : WindowTest_ FormatDateTime(hhnnss, Now); // WM_SETTEXT 返回 TRUE 表示处理成功 if SendMessage(FTargetHandle, WM_SETTEXT, 0, LPARAM(PChar(NewTitle))) 1 then Log(Format(标题修改成功: %s, [NewTitle])) else Log(标题修改失败或被拒绝); end;写这段代码时有两个必须注意的坑。一是 PChar(NewTitle) 的临时转换——SendMessage 是同步的消息处理完才返回所以 PChar 指向的内存只要在调用期间有效就行NewTitle 作用域覆盖了整段代码安全。但如果换成 PostMessage这里就成悬垂指针了必须用 StrNew 或全局变量保住字符串生命周期。二是 WM_CLOSE 的返回值没有实际意义它只是把关闭消息投递给目标窗口窗口可能收到消息但拒绝关闭——所以测试逻辑里对 WM_CLOSE 的判断要看窗口是否真的消失而不是看 SendMessage 返回什么。4. 不玄学的参数设定匹配模式、枚举回调耗时与轮询间隔的落地取值4.1 三种标题匹配模式精确、前缀和正则分场景选型很多人在写窗口匹配时吃过亏用了精确匹配测试目标窗口标题加了版本号就找不到了换了模糊匹配又误匹配到一堆同名窗口。frmWindowTest 这类工具里匹配模式不是玄学按场景选就行。模式实现方式适用场景误匹风险精确匹配CompareStr(标题, 关键字) 0窗口标题固定如登录框低前缀匹配标题以关键字开头标题带动态后缀「文档 - 记事本」类中包含匹配Pos(关键字, 标题) 0只记得标题一部分高实际工程中包含匹配是默认选项因为它是三者里唯一能在「只知道一个词」的情况下工作的。但用包含匹配时必须配合控件层级过滤——比如先按类名定位到目标窗体的主窗口再在子窗口里做标题包含匹配把误匹范围压缩到可控区间。正则匹配要不要上我一般不建议在窗口测试工具里用正则原因很现实窗口标题数量级通常只有两位数正则带来的表达力提升远小于调试成本。窗口测试的核心问题是「找到对的那个窗口」不是「写出炫的匹配式」。4.2 EnumWindows 回调里的时间预算为什么不能 SleepEnumWindows 回调是跑在系统枚举上下文里的它不属于你的业务线程。回调里做耗时操作卡住的不是你自己的代码是整个窗口枚举机制严重时会让系统觉得你的进程无响应直接给你弹一个「程序未响应」的窗口。我见过最离谱的写法是在回调里调 Sleep(100) 做节流理由是想「慢一点枚举防止漏掉窗口」。这是完全没理解枚举机制EnumWindows 是同步抓取系统窗口快照不是持续监听。你在回调里 Sleep系统就挂着等你等完了继续枚举下一个除了把整个调用拖慢没有任何过滤效果。回调代码保持一个原则只收集、不处理。把命中的句柄和标题存进列表EnumWindows 返回后回到主线程再慢慢分析。var TempList: TListTHandle; function EnumCollect(AWindow: HWND; AParam: LPARAM): BOOL; stdcall; begin // 回调里只做收集 if IsWindowVisible(AWindow) then TListTHandle(AParam).Add(AWindow); Result : True; end; procedure TfrmWindowTest.btnCollectClick(Sender: TObject); var I: Integer; begin TempList : TListTHandle.Create; try EnumWindows(EnumCollect, LPARAM(TempList)); // 返回后再做耗时处理 for I : 0 to TempList.Count - 1 do Log(Format(可见窗口句柄: %d, [TempList[I]])); finally TempList.Free; end; end;这里有另一个隐蔽问题回调里调用 IsWindowVisible 是快的但如果你在这基础上再调 GetWindowText 加 GetWindowThreadProcessId单次回调的开销就会上去。系统里窗口数量多的时候累计的时间差会被放大。所以判断逻辑尽量精简能在回调外做的判断绝不放回调里。4.3 TTimer 轮询自测间隔、消息堵塞与 UI 线程的取舍窗口测试里经常要轮询一个窗口的创建和销毁——比如点了一个按钮目标程序弹出一个新窗口你要等它出现然后自动抓取。这种场景用 TTimer 轮询最省事。但 TTimer 的参数别随便填。Windows 默认计时器精度是 15.6 毫秒你设一个 Interval 10 毫秒实际触发频率还是 64 Hz 左右白白浪费 CPU。轮询窗口出现这种场景间隔 200 毫秒足够轮询窗口销毁间隔可以放到 500 毫秒如果轮询里还带着 SendMessage 调用间隔就得上千毫秒。procedure TfrmWindowTest.tmrPollTimer(Sender: TObject); begin tmrPoll.Enabled : False; try FTargetHandle : FindWindow(nil, PChar(FExpectedTitle)); if FTargetHandle 0 then begin Log(Format(轮询发现窗口: %d, [FTargetHandle])); // 这里再发测试消息 SendMessage(FTargetHandle, WM_CLOSE, 0, 0); end; finally // 没找到的话继续下一轮 if FTargetHandle 0 then tmrPoll.Enabled : True; end; end;这里的关键是把 Timer 先停掉再干活干完活再开。如果开着 Timer 直接在里面发 SendMessage而目标窗口正好不响应SendMessage 会卡住 UI 线程Timer 消息全排在队列里等 SendMessage 超时返回积压的 Timer 消息会连续触发——表现出来就是日志刷屏、界面卡死。停掉再干活能把这个连锁反应切断。5. 避坑窗口测试工具最常见的 5 个翻车现场5.1 句柄存进 32 位整型被截断窗口永远匹配不上现象FindWindow 或 EnumWindows 明明返回了非 0 句柄但 Log 里显示的句柄是负数或者拿这个句柄去调 IsWindow 永远返回 False。原因老 Delphi 工程的通病——有人把 HWND 塞进 LongInt 或 Integer。Windows 64 位系统上句柄是 64 位指针截图存进 32 位整型高位直接被截断。这个问题在 32 位系统上跑不出来一上 64 位系统就现原形。解决所有窗口句柄的存储统一用 THandle 类型不要用 LongInt、Integer、LongWord 等历史遗留类型。检查代码里有没有 LongInt(FindWindow(...)) 这类强转一个都不能留。// 错误写法句柄被截断 var H: LongInt; begin H : LongInt(FindWindow(nil, 记事本)); end; // 正确写法 var H: THandle; begin H : FindWindow(nil, 记事本); end;THandle 在 64 位编译下就是 64 位无符号整型和 HWND 完全对齐。5.2 目标窗口被「最小化到托盘」后 FindWindow 扑空现象程序明明在运行任务管理器里能看到进程但 FindWindow 返回 0。怀疑是标题不对换了各种方式匹配都找不到。原因很多程序「最小化到托盘」不是真的最小化而是把主窗口隐藏甚至销毁了。隐藏窗口调用 ShowWindow(SW_HIDE) 后窗口还在FindWindow 理论上能找到但如果程序直接把主窗口 DestroyWindow然后只在托盘区保留一个图标那窗口对象已经没了FindWindow 当然扑空。解决先用 EnumWindows 把所有可见窗口打出来看看目标窗口到底还在不在。如果窗口对象销毁了只能从进程角度入手——用 CreateToolhelp32Snapshot 按进程名找到 PID再 SetupDiGetClassDevs 之类的设备接口去拿主窗口句柄。一个更实用的方式是如果你的目标程序是自家产品直接在程序里加一个 /autotest 参数启动时把窗口句柄写到共享内存或文件里测试工具直接从那里读省去一切猜测。5.3 SendMessage 卡死调用线程UAC 与跨 session 消息隔离现象向某个窗口 SendMessage(WM_CLOSE) 后测试工具整个界面卡住鼠标转圈过几秒才恢复。不是每次都卡只针对某些目标窗口出现。原因SendMessage 是同步消息发送线程会一直等待目标窗口过程处理完才继续执行。如果目标窗口属于更高完整性级别的进程——比如以管理员权限运行的窗口——普通权限进程的消息会被 UAC 隔离机制拦截SendMessage 的等待时间被拖长。目标窗口消息循环如果正忙卡顿更明显。解决能用 PostMessage 就不用 SendMessage。PostMessage 是异步投递发完就返回不会卡调用线程。如果必须拿返回值用 SendMessageTimeout 并设置超时var Res: LRESULT; begin // 500ms 超时目标不响应就直接放弃 SendMessageTimeout(FTargetHandle, WM_SETTEXT, 0, LPARAM(PChar(NewTitle)), SMTO_ABORTIFHUNG, 500, Res); if Res 1 then Log(消息发送成功) else Log(消息发送超时或失败); end;SMTO_ABORTIFHUNG 这个标志位是关键——它告诉系统如果目标窗口在超时时间内不处理消息直接放弃而不是无限等待。窗口测试工具里这个参数几乎是必设的。5.4 中文窗口标题匹配失败问题出在 ANSI 和 Wide 的转换现象目标窗口标题是中文FindWindow 找不到但用 Spy 看窗口明明就挂在那里标题也确实是你要找的那个。原因Windows 窗口标题内部是 UTF-16 存储FindWindow 的 A 版本FindWindowA会把参数按 ANSI 代码页转换后再比较。如果你的测试代码里硬编码了中文标题但工程没有启用 Unicode编译器会走 FindWindowA中文字符经过 ANSI 转换就变了样。解决Delphi 2009 之后默认编译模式就是 Unicodestring 直接是 UTF-16直接调用 FindWindow 会走 W 版本不会出问题。老工程或者用了第三方非 Unicode 控件的工程显式调用宽字符版本var WTitle: array[0..511] of WideChar; begin StringToWideChar(中文标题测试, WTitle, Length(WTitle)); FTargetHandle : FindWindowW(nil, WTitle); end;读标题同理用 GetWindowTextW 加 WideChar 数组避免回 ANSI 时丢字。5.5 枚举回调里弹 MessageBox把整个窗口测试卡死现象点击「查找窗口」程序界面直接变白任务管理器显示「未响应」。强杀进程后重新打开只要不点查找就没事。原因EnumWindows 回调函数里调用了 MessageBox 或者 Application.ProcessMessages。回调执行期间系统持有一个内部锁此时弹模态窗口会进入嵌套消息循环系统在等回调返回回调在等 MessageBox 关闭死锁。解决回调里绝不允许出现任何可能触发消息循环的调用。收集完句柄等 EnumWindows 返回再弹出结果。// 回调里只把结果放到列表 function EnumCollectOnly(AWindow: HWND; AParam: LPARAM): BOOL; stdcall; begin TListHWND(AParam).Add(AWindow); Result : True; end; // EnumWindows 返回之后再弹窗 procedure TfrmWindowTest.btnSafeFindClick(Sender: TObject); var L: TListHWND; I: Integer; begin L : TListHWND.Create; try EnumWindows(EnumCollectOnly, LPARAM(L)); for I : 0 to L.Count - 1 do Log(Format(句柄采集 %d, [L[I]])); // 到这里才能弹窗或者做耗时处理 finally L.Free; end; end;这条是最容易踩的也是最难从报错信息里定位的——因为根本没有报错就是界面假死。血泪经验凡是回调函数先问自己一句「里面会不会弹窗」会就绝对不写。6. 进阶给窗口测试工具加 WndProc 消息日志验证窗口到底吃没吃下这条消息窗口测试到这一步已经能找窗口、发消息、验证结果了但还有一个悬而未决的问题你发了 WM_CLOSE窗口没关到底是因为消息没送达还是窗口收到后拒绝了不把这个区分开前面所有验证都像在黑匣子外面猜。解法是Override主窗体的 WndProc把经过该窗体的所有消息记录到日志。这是同进程内窗口测试最直接的观察手段——frmWindowTest 要测自己把消息日志打开窗体的每一个动作都能看得清清楚楚。procedure TfrmWindowTest.WndProc(var Message: TMessage); begin // 记录感兴趣的窗口消息 if (Message.Msg WM_CLOSE) or (Message.Msg WM_SETTEXT) or (Message.Msg WM_USER) then begin Log(Format([消息] ID%d wParam%d lParam%d, [Message.Msg, Message.WParam, Message.LParam])); end; // 必须调用 inherited否则窗口收不到任何消息 inherited WndProc(Message); end;注意 inherited WndProc(Message) 这行不能漏。很多人写消息日志时容易改成 inherited 加在 else 分支里导致日志记录过的消息没走默认处理窗体直接失去响应。正确做法是先记录再传递确保消息流程不断裂。验证这个日志面板是否可靠有一个简单方法在按钮事件里给自己发一条 WM_USER 消息看日志面板是否立刻弹出对应记录。procedure TfrmWindowTest.btnSelfTestClick(Sender: TObject); begin // 发一条自定义消息验证 WndProc 日志链路 PostMessage(Self.Handle, WM_USER 100, 1, 2); end;点击自测按钮如果日志里出现「ID1025 wParam1 lParam2」说明消息从 PostMessage 到 WndProc 的整条链路是通的。之后再去测外部窗口日志里看到 WM_CLOSE 未被处理就能下结论说问题在目标窗口的处理逻辑不在消息发送环节。我一直有个习惯把日志同步写一份到文本文件用 Append 模式每行带时间戳。窗口测试最好只信落盘的数据你盯着屏幕看日志的瞬间程序崩了内存里的日志全没文件里至少留了后悔药。frmWindowTest 这种小工具看着不起眼但把句柄、消息、日志三个环节串起来以后排查界面疑难问题比大型测试框架好使——没有框架依赖没有复杂配置解压一个 rar 就能跑。希望帮到你。本文还有配套的精品资源点击获取
返回列表