☰
VB6实现Windows窗口自动化输入:句柄定位与消息注入实践
2026/10/29 5:17:40 网站建设 项目流程

1. 这个"连发工具"到底在跟什么打交道

先把话说在前面:标题里挂的是一个具体的即时通讯客户端名字,但下面聊的所有东西,都是Windows 窗口自动化输入这一件事,跟某一家软件没有绑定关系。你把这个思路套到记事本、写字板、任意一个带文本框的桌面程序上,代码改个类名就能跑。我之所以愿意拆这个话题,是因为它几乎是所有桌面自动化需求的"祖师爷版本":先找到目标窗口,再往里塞文字,最后触发一次回车。三步,听起来简单到不像技术活,但真正动手写的人十个里有八个卡在第二步。

VB6 在这个场景里其实是被严重低估的工具。很多人一听 VB 就摇头,觉得是上古产物。可你要知道,VB6 编译出来的是原生 Win32 可执行文件,没有运行时依赖(打包时带上 msvbvm60.dll 就行),体积小,双击就跑,而且它声明 Windows API 的方式极其直接——一行Declare就能直接调用 user32.dll 里的函数。相比之下,用某些现代语言做同样的事,光是处理 FFI 的类型映射就够你喝一壶。所以"用 VB 做窗口自动化"这件事,不是怀旧,是在特定约束下(比如给老机器、老系统、内部工具)性价比很高的选择。

那这个工具到底解决了什么真实需求?我见过最多的场景是:某个后台系统只提供界面操作,没有接口,每天要重复录入几十条格式类似的记录;或者自己给自己发提醒、做消息备份、做输入压力测试。这些都是合理的自动化诉求。但也必须把丑话说清楚:把"连发"用作向他人刷屏、营销群发、骚扰,是明确越界的行为,轻则账号受限,重则承担相应责任。所以下面所有代码示例,我都限定在"向本机记事本或自建测试窗口发送内容"的范围内,不给针对任何具体客户端的成品工程。技术是中性的,用在哪里是你自己的选择,但选择的后果也是你自己的。

1.1 从一次手动重复输入的崩溃说起

我第一次接触这类需求,是帮人处理一个录入活:一个内部台账系统,界面是老式的 Delphi 写的,每天要把 Excel 里的一批编号一条条粘进去,粘一条按一次回车,一天两百多条。手工做的时候人是麻的,做到后面必然出错,错一条还得回头核对。当时的想法很朴素——能不能让程序替我把"粘贴 + 回车"这个动作重复 N 次?

注意这里的措辞:不是"发消息",而是"重复一个输入动作"。这个视角的转换很关键,因为它直接决定了技术选型。如果你的目标是"发消息",你会本能地去想协议、想接口、想抓包;但如果你把目标定义成"重复一个键盘输入动作",那问题立刻降维到窗口消息和模拟输入的层面,难度和风险都可控得多。

这个降维也带来一个副作用:它让工具变得"万能",也就意味着它天然带着被滥用的可能。我在写这类东西的时候有个习惯,代码里永远留一个硬性的次数上限和一个随时可按的中断键(比如检测某个热键或者某个文件是否存在)。这不是技术洁癖,是给自己留一道刹车。人在调试循环的时候特别容易忘记自己在跑一个Do ... Loop,一旦目标窗口选错,你可能对着一个不该发的地方刷了几百条。

1.2 自动化输入的三层模型:找窗口、塞文本、按回车

把整件事拆开,其实只有三层,每一层的失败率差别很大:

层级要做的事主要 API典型失败率
定位层找到目标窗口和它内部的输入框句柄FindWindow / FindWindowEx / EnumChildWindows高(约六成问题出在这)
写入层把文本送进输入框WM_SETTEXT / WM_PASTE / SendInput中(中文和控件类型是坑)
触发层模拟回车或其他提交动作PostMessage WM_KEYDOWN / SendInput低(但受焦点影响)

新手最容易犯的错,是把这三层当成一件事来写:找到窗口就立刻发文字,发完立刻回车。这样写的代码在理想情况下能跑,但只要有一层出问题,你根本不知道是哪一层坏的。我的做法是强制分层,并且每层都留独立可测试的入口。比如先写一个按钮,点了只打印"找到了几个候选句柄";再加一个按钮,点了只往句柄里塞一段固定文字,不发回车;最后才把循环接上去。这个顺序慢,但排查成本低一个量级。

1.3 先划清边界:这类工具能碰和不能碰的场景

在动手之前,值得花五分钟想清楚边界,因为这条线划错了,后面所有努力都是负资产。

可以做的:对自己本机的程序做自动化、对自建测试环境做压力验证、把重复的表单录入变成一键操作、给没有接口的老系统做个人效率补丁。这些的共同点是——你是这个动作的发起者,也是唯一的受影响对象。

不该做的:向他人批量发送内容、绕过平台的频率限制、在别人不知情的情况下操作对方的界面、把工具打包分发给不具备判断能力的人。这几种情况里,技术本身不难,难的是后果你兜不住。

我在代码里通常会写这么一段注释放在最顶上,提醒未来的自己:

' ============================================================ ' 用途限制:仅限本机自动化测试与个人效率场景 ' 严禁用于向他人批量发送内容 ' 循环必须带 MaxCount 上限与中断检测 ' ============================================================

看着像废话,但真的有用。半年后你翻出这段代码想临时改个参数的时候,这段注释会让你多想三秒钟。

2. VB6 还值不值得用来做窗口自动化

这个问题我被问过不止一次,答案要分情况。如果你要交付一个长期维护、多人协作、要接数据库和网络的东西,VB6 明显不合适。但如果你的场景是"给一台内网老机器写个小工具,双击就能用,最好连安装都不用",VB6 的竞争力其实相当强。它的编译产物是单个 exe,几十 KB,拷过去就能跑;它的窗体设计器虽然简陋,但拖几个按钮文本框的效率不输任何现代 IDE;最关键的是它和 Win32 API 之间几乎没有隔膜。

2.1 Declare 外部函数:VB6 最被低估的能力

VB6 调用 Win32 API 的核心就是Declare语句。这一行声明做的事,本质是告诉链接器"这个函数不在我的代码里,它在某个 dll 里,参数长这样,你按这个约定去调它"。听起来像黑魔法,实际上就是标准的调用约定映射。举几个这个项目里必用的:

Private Declare Function FindWindow Lib "user32" Alias "FindWindowA" _ (ByVal lpClassName As String, ByVal lpWindowName As String) As Long Private Declare Function FindWindowEx Lib "user32" Alias "FindWindowExA" _ (ByVal hWndParent As Long, ByVal hWndChildAfter As Long, _ ByVal lpszClass As String, ByVal lpszWindow As String) As Long Private Declare Function SendMessage Lib "user32" Alias "SendMessageA" _ (ByVal hWnd As Long, ByVal wMsg As Long, _ ByVal wParam As Long, lParam As Any) As Long Private Declare Function PostMessage Lib "user32" Alias "PostMessageA" _ (ByVal hWnd As Long, ByVal wMsg As Long, _ ByVal wParam As Long, ByVal lParam As Long) As Long

这里有几个必须讲清楚的细节,讲不清楚就会踩坑。

第一,Alias 后面的 A 和 W。Win32 里几乎所有涉及字符串的函数都有两个版本:...A收 ANSI 字符串,...W收 UTF-16 字符串。VB6 内部的 String 类型其实是 UTF-16 存储的,但当你在Declare里写ByVal lpClassName As String并调用 A 版时,VB6 运行时会在幕后帮你转成 ANSI 再传过去。这个自动转换在纯英文下没问题,遇到中文就依赖当前系统的代码页,某些字符会变成问号。想彻底绕开这个转换,就要用 W 版加StrPtr(),后面会细说。

第二,lParam As Any的含义。这个As Any是 VB6 的逃生舱,意思是"这个参数我不检查类型,你传什么就是什么"。SendMessage的 lParam 可能是整数、可能是字符串指针、可能是结构体指针,所以只能用 Any。但用 Any 就意味着编译器不再帮你把关,传错了不会报错,只会在运行时给你一个莫名其妙的结果或者直接崩掉。

第三,返回值类型。句柄在 32 位下是 Long(4 字节有符号),VB6 的 Long 正好对应。所以这套声明在 32 位环境里是安全的。如果你在 64 位系统上跑,只要是 32 位进程,句柄值一般也不会超过 Long 的范围(实际使用中窗口句柄通常是小数值),但这属于运气好,不是设计保证。真要做长期工具,建议考虑 VB.NET 配合IntPtr。

2.2 与 VB.NET、Python 方案的横向对比

我三种都写过,说点实际感受:

维度VB6VB.NETPython (pywin32)
部署单 exe,需带运行时 dll需 .NET Framework 或自包含发布需 Python 环境或打包成 exe
API 调用写法Declare,最贴近 CDeclare 或 P/Invoke,稍繁琐win32gui 封装好,最省事
中文处理ANSI 转换有坑,需 W 版绕过原生 Unicode,最省心原生 Unicode,省心
调试体验断点、立即窗口够用现代化 IDE交互式调试最强
适合场景内网老机器、极小体积工具正式交付的中小型工具快速验证、一次性脚本

如果我只是想验证"这个窗口能不能用消息塞进去",我会用 Python 写十行脚本,三十秒出结果。如果我要把这东西做成一个同事能双击用的工具,扔给一台装着老系统的机器,VB6 反而是最省心的。VB.NET 介于两者之间,是"既要正规又要 Windows 原生"时的默认答案。

选择逻辑说白了就一句:先用最快的语言验证可行性,再用最合适的语言做交付。别一上来就用最终语言吭哧吭哧搭工程,结果发现技术路线根本走不通。

2.3 一个可复现的最小工程骨架

给一个我常用的骨架结构,不含任何针对特定软件的成品逻辑,纯粹是技术演示。窗体上放:一个文本框(输入要发送的内容)、一个数字框(次数)、一个按钮(开始)、一个按钮(停止)、一个多行文本框(日志)。

' --- 模块级变量 --- Private g_Running As Boolean Private g_Count As Long Private Sub cmdStart_Click() If g_Running Then Exit Sub g_Running = True g_Count = 0 cmdStart.Enabled = False cmdStop.Enabled = True Dim maxCount As Long maxCount = Val(txtCount.Text) If maxCount <= 0 Or maxCount > 100 Then LogLine "次数必须在 1-100 之间" Call ResetUI Exit Sub End If Dim i As Long For i = 1 To maxCount If Not g_Running Then Exit For g_Count = i If Not DoOneRound(txtContent.Text) Then LogLine "第 " & i & " 次失败,已中止" Exit For End If LogLine "第 " & i & " 次完成" DoEvents Sleep 800 Next i LogLine "结束,共完成 " & g_Count & " 次" Call ResetUI End Sub Private Sub cmdStop_Click() g_Running = False LogLine "用户请求停止" End Sub Private Sub ResetUI() g_Running = False cmdStart.Enabled = True cmdStop.Enabled = False End Sub

这段代码的价值不在功能,在结构:上限硬约束(100 次封顶,防止你手滑输入 99999)、可中断(每轮检查 g_Running)、有日志(每次成功失败都留痕)、UI 状态互锁(跑的时候开始按钮禁用)。这四件事是我踩过坑之后固定下来的,缺一个都会在某次调试里让你抓狂。

3. 窗口定位:抓错句柄是九成失败的根因

写这类工具,我敢说八成的"发不出去"都是句柄找错了。句柄这东西有个特点:它看起来就是个数字,你打印出来看是 131584,好像找到了,实际上可能是父窗口、可能是某个无关的兄弟控件、甚至可能是上一次运行留下的失效值。窗口句柄不像文件路径那样自带语义,它就是个运行时分配的编号,你不去验证,就永远不知道自己抓到的是什么。

3.1 FindWindow 与 FindWindowEx 的匹配规则

FindWindow是最粗暴的一层:按类名和窗口标题找顶层窗口。

Dim h As Long h = FindWindow(vbNullString, "无标题 - 记事本")

两个参数都可以传vbNullString表示"这一项不限制"。但这里有个陷阱:标题是精确匹配,不是包含匹配。记事本打开一个文件后标题会变成"文件名 - 记事本",你写死"记事本"就找不到。我第一次写的时候就栽在这,还以为是 API 不работает(当时甚至怀疑是不是权限问题,查了一下午)。

解决方案有两个:一是标题也传空,只用类名匹配(记事本的类名是固定的Notepad),二是自己写一个EnumWindows遍历所有顶层窗口,对标题做InStr包含判断。后者更灵活,也是我在实际项目里更常用的做法:

Private Declare Function EnumWindows Lib "user32" _ (ByVal lpEnumFunc As Long, ByVal lParam As Long) As Long Private Declare Function GetWindowTextLength Lib "user32" _ Alias "GetWindowTextLengthA" (ByVal hWnd As Long) As Long Private Declare Function GetWindowText Lib "user32" _ Alias "GetWindowTextA" (ByVal hWnd As Long, ByVal lpString As String, _ ByVal nMaxCount As Long) As Long Private g_Found As Long Private g_Keyword As String Private Function EnumProc(ByVal hWnd As Long, ByVal lParam As Long) As Long Dim ln As Long, buf As String ln = GetWindowTextLength(hWnd) If ln > 0 Then buf = Space$(ln + 1) Call GetWindowText(hWnd, buf, ln + 1) buf = Left$(buf, ln) If InStr(1, buf, g_Keyword, vbTextCompare) > 0 Then g_Found = hWnd EnumProc = 0 ' 返回 0 表示停止枚举 Exit Function End If End If EnumProc = 1 ' 返回 1 表示继续 End Function

回调函数的返回值语义要记牢:返回非零继续,返回零停止。写反了就是"遍历完所有窗口还找不到"或者"找到一个就停了但找到的不是想要的"。这个坑我踩过,而且因为不报错,排查起来特别费劲。

FindWindowEx则是在指定父窗口下面找子窗口,多了一个hwndChildAfter参数用于遍历同级。典型用法是:

' 在记事本主窗口下找编辑框 Dim hEdit As Long hEdit = FindWindowEx(hNotepad, 0, "Edit", vbNullString)

记事本的结构简单,一层就到底了。但很多现代程序的界面是层层嵌套的容器,编辑框可能藏在三层容器里面,这时候单靠FindWindowEx就得一层层写死,脆得很。

3.2 递归 EnumChildWindows 找到真正的输入框

真正通用的做法是递归枚举所有子窗口,把每个窗口的类名打印出来,人眼扫一遍就知道哪个是输入框。这一步我用得最多,因为它能一次性把目标程序的结构摸清楚。

Private Declare Function EnumChildWindows Lib "user32" _ (ByVal hWndParent As Long, ByVal lpEnumFunc As Long, _ ByVal lParam As Long) As Long Private Function ChildProc(ByVal hWnd As Long, ByVal lParam As Long) As Long Dim cls As String, txt As String cls = GetClassNameStr(hWnd) txt = GetWindowTextStr(hWnd) LogLine " [" & Hex$(hWnd) & "] class=" & cls & _ " text=" & Left$(txt, 30) ChildProc = 1 End Function Private Sub DumpTree(ByVal hRoot As Long) Dim depth As Long LogLine "根窗口: " & Hex$(hRoot) Call EnumChildWindows(hRoot, AddressOf ChildProc, 0) End Sub

注意AddressOf这个运算符,它把 VB 函数地址传给 API 作回调。用AddressOf有个硬性限制:回调函数必须写在标准模块(.bas)里,不能写在窗体模块里。写在窗体里编译能过,运行时会崩或者行为诡异,因为窗体模块的方法不是标准函数指针。这个限制坑过太多人,值得单独记一笔。

跑一遍 dump,你通常会看到类似这样的输出:

根窗口: 1A0342 [1A0350] class=Edit text= [1A0356] class=Button text=发送 [1A0362] class=RichEdit20W text=

一眼就能判断:RichEdit20W或者Edit才是输入区,Button是发送按钮。拿到类名之后,你的查找逻辑就有依据了。但要记住:每次程序重启,句柄值都会变,所以代码里绝对不能写死1A0350这种值,必须每次运行时重新枚举查找。

3.3 进程级定位:GetWindowThreadProcessId 的用法

有时候你的标题匹配会撞车——比如你开了五个同一个程序的窗口,标题都一样。这时候按标题找就是碰运气。可靠的做法是先找到目标进程,再从进程的所有窗口里挑。

Private Declare Function GetWindowThreadProcessId Lib "user32" _ (ByVal hWnd As Long, ByVal lpdwProcessId As Long) As Long

思路是:枚举所有顶层窗口 → 对每个窗口取进程 ID → 和你要找的进程 ID 比对 → 匹配上的才纳入候选。获取进程 ID 的方式可以是CreateToolhelp32Snapshot遍历进程列表按名字找,也可以是让用户手动从任务管理器里抄一个 PID 填进来(土办法,但极其可靠,我在内部工具里经常这么干)。

我用得最顺手的一个变体是:按窗口大小和位置过滤。目标窗口通常是可见的、有一定尺寸的,那些 0×0 的隐藏辅助窗口可以直接排除掉:

Private Declare Function IsWindowVisible Lib "user32" (ByVal hWnd As Long) As Long Private Declare Function GetWindowRect Lib "user32" _ (ByVal hWnd As Long, lpRect As RECT) As Long Private Type RECT Left As Long Top As Long Right As Long Bottom As Long End Type

先IsWindowVisible判可见,再GetWindowRect算宽高,宽高小于某个阈值就丢掉。这个过滤条件一加,候选数量立刻从几十个降到个位数,人工确认的成本大幅下降。

3.4 句柄失效与重试机制

还有一个特别容易被忽略的问题:句柄会失效。目标程序重启、窗口被销毁重建、用户切换了会话,你手里的句柄就变成了一个悬空数字。这时候SendMessage不报错(因为SendMessage返回 0 也可能表示"成功但结果为零"),你的代码会安静地一条条"发送成功"下去,实际上什么都没发生。

防御手段是每次发送前用IsWindow校验一次:

Private Declare Function IsWindow Lib "user32" (ByVal hWnd As Long) As Long Private Function EnsureHandle() As Boolean If g_hEdit <> 0 Then If IsWindow(g_hEdit) <> 0 Then EnsureHandle = True Exit Function End If End If ' 句柄失效,重新查找 g_hEdit = FindEditHandle() EnsureHandle = (g_hEdit <> 0) End Function

这个"失效即重查"的模式,把工具的健壮性从"跑两次就废"提升到"能连续跑几小时"。代价是每轮多两次 API 调用,性能上完全可以忽略。

4. 把文字送进输入框的三条路线

定位搞定了,接下来是往里塞文字。这里有三条主流路线,各有各的脾气,选错了会出现"代码没问题但就是发不进去"的诡异现象。我按"干净程度"排序讲。

4.1 WM_SETTEXT:最干净但最挑控件

WM_SETTEXT是直接给窗口发一条"把你的文本设成这个"的消息,不涉及焦点、不涉及键盘、不涉及剪贴板。它是三条路线里最优雅的,因为它完全不干扰用户当前的操作——你在别的窗口打字,后台的代码照样能把文字塞进目标输入框。

Const WM_SETTEXT = &HC Private Function SetTextDirect(ByVal hWnd As Long, ByVal s As String) As Boolean Dim r As Long r = SendMessage(hWnd, WM_SETTEXT, 0, ByVal s) SetTextDirect = (r <> 0) End Function

注意ByVal s这个写法。SendMessage的声明是lParam As Any,当你要传字符串时,必须加ByVal把字符串的内容指针传过去,而不是传 VB 字符串描述符的指针。不加ByVal就是经典的"程序直接崩掉",因为这个 API 会拿着垃圾指针去读内存。

这条路线的问题在于控件支持度。标准的Edit控件、RichEdit系列基本都吃WM_SETTEXT;但很多现代程序用的是自绘控件、或者是基于浏览器内核的界面,这类控件根本不处理WM_SETTEXT,你发过去它直接忽略,一个字符都不会出现。所以这条路线属于"能用就最爽,用不了就得换"。

4.2 剪贴板加 WM_PASTE:兼容性和中文的平衡点

WM_PASTE是让控件执行一次粘贴操作。它不直接携带文本,文本来自系统剪贴板。这条路线的好处是:几乎所有支持文本输入的控件都实现了粘贴,兼容性比WM_SETTEXT高一个档次;而且它天然支持中文、支持换行、支持富文本格式。

代价是它要求控件当前有焦点——因为是"粘贴到光标位置",没有焦点就不知道往哪粘。不过这一点可以绕:如果控件本身接受WM_PASTE,即使不是前台窗口,很多情况下也能生效(因为它只操作自己的编辑缓冲区)。这一点要实测,不同控件行为不一样。

实现分两步:先往剪贴板写文本,再发粘贴消息。

Private Declare Function OpenClipboard Lib "user32" (ByVal hWnd As Long) As Long Private Declare Function EmptyClipboard Lib "user32" () As Long Private Declare Function CloseClipboard Lib "user32" () As Long Private Declare Function SetClipboardData Lib "user32" _ (ByVal wFormat As Long, ByVal hMem As Long) As Long Private Declare Function GlobalAlloc Lib "kernel32" _ (ByVal wFlags As Long, ByVal dwBytes As Long) As Long Private Declare Function GlobalLock Lib "kernel32" _ (ByVal hMem As Long) As Long Private Declare Function GlobalUnlock Lib "kernel32" _ (ByVal hMem As Long) As Long Private Declare Function lstrcpy Lib "kernel32" Alias "lstrcpyA" _ (ByVal lpString1 As Long, ByVal lpString2 As String) As Long Const GMEM_MOVEABLE = &H2 Const CF_TEXT = 1 Const WM_PASTE = &H302 Private Function SetClipboardText(ByVal s As String) As Boolean Dim hMem As Long, pMem As Long If OpenClipboard(0) = 0 Then Exit Function Call EmptyClipboard ' 注意:CF_TEXT 需要 ANSI 字符串且以 \0 结尾 hMem = GlobalAlloc(GMEM_MOVEABLE, LenB(StrConv(s, vbFromUnicode)) + 1) If hMem <> 0 Then pMem = GlobalLock(hMem) If pMem <> 0 Then Call lstrcpy(pMem, StrConv(s, vbFromUnicode)) Call GlobalUnlock(hMem) If SetClipboardData(CF_TEXT, hMem) <> 0 Then SetClipboardText = True ' 成功后内存所有权归系统,不能自己释放 hMem = 0 End If End If If hMem <> 0 Then Call GlobalFree(hMem) End If Call CloseClipboard End Function

这段代码有几个必须强调的点,每一个都对应一次我踩过的坑:

内存所有权转移。SetClipboardData成功之后,那块内存就归系统管了,你绝对不能再去GlobalFree。如果放了,剪贴板里就是野指针,粘贴出来的内容可能是乱码或者直接让目标程序崩掉。我在代码里用hMem = 0这个动作把句柄清掉,就是防止后面的清理逻辑误伤。

CF_TEXT 是 ANSI 格式。要传 Unicode 得用CF_UNICODETEXT(值为 13),并且用lstrcpyW或者RtlMoveMemory手动拷贝 UTF-16 数据。中文环境下用CF_TEXT配合StrConv(s, vbFromUnicode)在绝大多数简体系统上是够用的,但生僻字和 emoji 会丢。

结尾的 \0。我见过很多人GlobalAlloc时只分配Len(s)字节,结果最后一个字符后面没有\0,粘贴出来末尾带一串乱码。分配LenB(...) + 1就是给这个终止符留位置。

写完之后发粘贴:

Private Function PasteToHandle(ByVal hWnd As Long, ByVal s As String) As Boolean If Not SetClipboardText(s) Then Exit Function Call SendMessage(hWnd, WM_PASTE, 0, ByVal 0&) PasteToHandle = True End Function

这里ByVal 0&是为了让As Any参数收到一个明确的空指针,直接写0在某些情况下会被当成ByRef传一个 Integer 的地址,虽然大概率也能跑,但不严谨。

4.3 SendInput 模拟键盘:最通用也最脆弱

如果前两条都走不通,最后的手段就是模拟真实的键盘输入:把目标窗口拉到前台,然后一个字一个字地"敲"进去。这条路线的兼容性是百分之百的,因为对所有程序来说,这跟真人操作没有任何区别。但它的问题也很明显:它占用你的键盘和焦点。

我通常只在两种情况下用它:一是目标控件是自绘的,消息完全不认;二是需要模拟的不是文字,而是快捷键组合(比如 Ctrl+Enter 发送)。

VB6 里最快的方式是keybd_event:

Private Declare Sub keybd_event Lib "user32" _ (ByVal bVk As Byte, ByVal bScan As Byte, _ ByVal dwFlags As Long, ByVal dwExtraInfo As Long) Const VK_RETURN = &HD Const KEYEVENTF_KEYUP = &H2 Private Sub PressEnter() Call keybd_event(VK_RETURN, 0, 0, 0) Sleep 50 Call keybd_event(VK_RETURN, 0, KEYEVENTF_KEYUP, 0) End Sub

注意keybd_event已经是被标记为过时的 API,官方推荐用SendInput。但对于"按一下回车"这种简单需求,keybd_event的代码量小一半,实际用下来也没什么问题。真要发送大段中文,keybd_event就不合适了(它只能按虚拟键码,不能直接打汉字),得配合VkKeyScan逐字符转换,还要处理输入法的干扰,工程量陡增。这种时候我宁可回到剪贴板路线。

顺便提一个实用的折中:剪贴板 + 模拟 Ctrl+V + 模拟回车。这个组合既绕开了WM_PASTE不被支持的情况,又不用逐字符处理中文,是我在兼容性要求最高时的默认选择。

Private Const VK_CONTROL = &H11 Private Const VK_V = &H56 Private Sub PasteByKeyboard() keybd_event VK_CONTROL, 0, 0, 0 keybd_event VK_V, 0, 0, 0 Sleep 30 keybd_event VK_V, 0, KEYEVENTF_KEYUP, 0 keybd_event VK_CONTROL, 0, KEYEVENTF_KEYUP, 0 End Sub

Sleep那 30 毫秒不能省。按键和释放之间间隔太短,某些程序会漏掉这个组合,表现为"粘贴没反应"。这个数值我试过 10、20、30、50,30 是个比较稳的平衡点,再长就影响整体速度了。

4.4 回车发送:WM_KEYDOWN 与 PostMessage 的取舍

文字进去了,最后一步是触发发送。对于聊天类的输入框,通常按回车就是发送。

用消息的方式:

Const WM_KEYDOWN = &H100 Const WM_KEYUP = &H101 Const WM_CHAR = &H102 Private Sub SendEnterByMsg(ByVal hWnd As Long) ' lParam 的 bit0-15 是重复次数(1),bit16-23 是扫描码(0x1C) Dim lp As Long lp = &H1C0001 Call PostMessage(hWnd, WM_KEYDOWN, VK_RETURN, lp) Call PostMessage(hWnd, WM_KEYUP, VK_RETURN, lp Or &HC0000000) End Sub

这里必须用PostMessage而不是SendMessage。区别在于:SendMessage是同步的,它会把消息直接投递给窗口过程并等结果;PostMessage是异步的,把消息放进队列就返回。对于按键消息,用SendMessage有时候会引发重入问题——窗口过程在处理按键时又触发了新的消息处理,可能导致目标程序行为异常甚至卡死。按键这类"投递型"消息,用PostMessage是更符合语义的选择。

还有一个更省事的做法:如果你已经走通了WM_SETTEXT或WM_PASTE,其实可以直接给发送按钮发一个BM_CLICK(值为&HF5),绕过回车逻辑:

Const BM_CLICK = &HF5 Call SendMessage(hBtnSend, BM_CLICK, 0, ByVal 0&)

这条路线的优点是绕开了"回车是不是发送键"这个不确定性(有些程序里回车是换行,Ctrl+Enter 才是发送)。缺点是你得先把按钮句柄找对,多一层定位成本。我的习惯是两条路都实现,用一个配置项切换,哪个好用用哪个。

5. 连发循环里的时间控制与卡死问题

循环本身不难写,难的是让循环跑得不卡界面、不堆积、不失速。这三个词分别对应三个技术点:UI 响应、消息队列、时间精度。

5.1 Timer 控件、Sleep、DoEvents 三者的差别

VB6 里控制时间间隔有三种常见手段,用错了会出大问题:

Timer 控件:基于WM_TIMER消息,精度大约 55 毫秒(系统时钟粒度),而且它是消息驱动的——如果你的代码在某个地方阻塞了,Timer 事件就不会触发。它适合做"每隔一段时间做点小事",不适合做精确的节奏控制。

Sleep:来自 kernel32,会让当前线程完全挂起。精度比 Timer 好(毫秒级),但它会冻结你的整个程序,包括界面。如果你在循环里Sleep 2000,那两秒内窗口是拖不动的、按钮是点不了的、标题栏会显示"未响应"。

DoEvents:这是 VB6 特有的一个"让出控制权"的函数。它会把当前堆积的消息处理掉,让界面有机会重绘、让按钮有机会响应点击。它不是延时函数,但必须和 Sleep 配合使用。

我的标准写法是:分段 Sleep + 每段 DoEvents,而不是一次睡到底。

Private Sub SleepEx(ByVal ms As Long) Dim t As Long, step As Long t = GetTickCount() Do While GetTickCount() - t < ms step = ms - (GetTickCount() - t) If step > 50 Then step = 50 Sleep step DoEvents If Not g_Running Then Exit Sub ' 睡眠中也能响应停止 Loop End Sub

这个函数把一段长时间的等待切成 50 毫秒的小片,每片之间DoEvents一次。效果是界面始终保持响应,而且用户点"停止"按钮能立刻生效——这一点在调试时至关重要,否则你只能干等循环跑完或者强杀进程。

GetTickCount返回的是系统启动以来的毫秒数,是个 32 位值,大约 49 天会回绕一次。对于我们的场景完全够用,但如果你要写长期运行的守护程序,得换成GetTickCount64或者用timeGetTime并处理回绕。

5.2 消息队列堵塞导致的"假死"

比界面卡住更隐蔽的问题是消息队列堵塞。当你用PostMessage连续快速地往目标窗口投递大量消息时,如果目标程序的窗口过程处理不过来,消息就会在队列里堆积。表现出来就是:你的代码"发送"了一百条,但目标窗口只显示了三十条,然后突然又冒出七十条,或者干脆就不动了。

这个现象的原因有两层。一层是你的发送速度超过了对方的处理速度;另一层是PostMessage本身有队列长度限制(默认大约 10000 条),超了就会失败。而PostMessage失败时返回 0,很多人根本不检查返回值,就以为发出去了。

所以我在投递型代码里一定会检查返回值,并加上"队列满就等待"的逻辑:

Private Function SafePost(ByVal hWnd As Long, ByVal msg As Long, _ ByVal wParam As Long, ByVal lParam As Long) As Boolean Dim retry As Long, r As Long For retry = 1 To 10 r = PostMessage(hWnd, msg, wParam, lParam) If r <> 0 Then SafePost = True Exit Function End If Sleep 20 DoEvents Next retry SafePost = False End Function

重试十次,每次间隔 20 毫秒,总共最多等 200 毫秒。如果还是失败,就认为对方确实堵死了,把控制权交回主循环。这个处理看起来很土,但比"发完不管"要可靠得多。

5.3 随机间隔与频率控制的工程意义

关于间隔时间,我得说几句实话。网上很多这类工具的教程会建议"间隔设成完全随机,模拟真人"。这句话技术上是成立的,但它背后的动机常常是绕过频率限制,那就越界了。我这里讲随机间隔,理由是工程上的,不是规避上的:固定间隔会让某些程序的处理节奏出现规律性瓶颈(比如每 500 毫秒触发一次自动保存,你的发送刚好撞在这个窗口上),随机化能打散这种碰撞。

我的默认参数是 800 到 1500 毫秒之间随机:

Private Function RandInt(ByVal lo As Long, ByVal hi As Long) As Long Randomize RandInt = lo + Int(Rnd() * (hi - lo + 1)) End Function ' 循环里 SleepEx RandInt(800, 1500)

这个量级不是拍脑袋定的。太短(比如 100 毫秒)会导致目标程序来不及处理,出现丢字和顺序错乱;太长(比如 10 秒)会让整个任务的耗时不可接受。800 到 1500 毫秒是一个在"对方处理得过来"和"整体效率可接受"之间比较舒服的区间。当然,具体还是要看目标程序的响应速度,用日志记录每轮的实际耗时,跑几十轮看分布,比凭感觉调参靠谱得多。

6. 实测踩过的坑:中文乱码、输入法、最小化窗口

前面讲的都是"设计层"的东西,这一节讲实操里那些文档不会写、但一定会遇到的问题。我把它们按"坑的隐蔽程度"排序。

6.1 ANSI 编码下的中文发送失败

这是我最早踩、也最困惑的一个坑。代码用英文字符串测试一切正常,换成中文就变成一堆问号或者干脆空白。

根因在SendMessageA的参数转换。VB6 的 String 内部是 UTF-16,当你声明的是Alias "SendMessageA"时,运行时会用当前系统代码页把字符串转成 ANSI,再传指针。简体中文系统的代码页是 936(GBK),常用汉字都能转,但会遇到两个问题:一是生僻字和扩展区字符转不了,变成?;二是某些特殊符号在转换过程中被截断,导致整条消息错位。

彻底的解决办法是用 W 版 API 配合 StrPtr 直接传 UTF-16 指针,绕开一切转换:

Private Declare Function SendMessageW Lib "user32" _ (ByVal hWnd As Long, ByVal wMsg As Long, _ ByVal wParam As Long, ByVal lParam As Long) As Long Private Function SetTextUnicode(ByVal hWnd As Long, ByVal s As String) As Boolean ' StrPtr 返回 VB 字符串的 UTF-16 数据指针,不做任何转换 SetTextUnicode = (SendMessageW(hWnd, WM_SETTEXT, 0, StrPtr(s)) <> 0) End Function

注意这里lParam声明成了Long而不是Any,因为我们要传的是指针数值。StrPtr返回的就是那个数值,直接传进去,API 拿到的是一个指向 UTF-16 数据的指针,完美匹配 W 版的预期格式。

这个技巧我用了很多年,是 VB6 处理中文泛用场景的核心手段。它同样适用于WM_SETTEXT、WM_GETTEXT、FindWindowW等等。

顺便说一句:用 W 版还有一个附加好处——不依赖系统区域设置。A 版的转换结果受"非 Unicode 程序的语言"设置影响,如果目标机器把这项设成了英文,你的中文就全变问号。W 版完全不管这个设置,稳定得多。

6.2 输入法状态导致的字符丢失

这个坑非常隐蔽。现象是:你用模拟键盘的方式发一串英文,前几个字符进去了,后面突然变成拼音字母或者乱码。

原因是目标窗口当时处于中文输入法的激活状态。你发的按键被输入法拦截去组词了,而不是直接进入编辑框。第一个字母被输入法吃掉当成拼音起始,后面的一串都变成了候选词输入。

排查方法:跑之前先手动看一眼目标窗口的输入法状态。解决方案有几种:

  • 切到英文输入法:用LoadKeyboardLayout加ActivateKeyboardLayout切到00000409(美式键盘),做完再切回去。这个方案最干净,但会影响用户当前状态。
  • 用剪贴板粘贴而不是模拟键盘:从根本上避开输入法,因为粘贴不经过键盘事件。
  • 用WM_CHAR直接投递字符:绕过输入法层,直接给控件送字符消息。但对自绘控件无效。

我现在的做法是:只要涉及中文,一律走剪贴板路线,坚决不用模拟键盘。这个决定省了我无数调试时间。

6.3 窗口最小化与前台焦点的干扰

SendMessage/PostMessage这条路线有个先天优势——不需要窗口在前台。窗口最小化了、被别的窗口完全遮挡了,消息照样能送达,因为消息是直接投递给窗口过程的,跟显示状态无关。

但keybd_event/SendInput就完全相反,它们模拟的是系统级的输入,只会送到当前前台窗口。如果目标窗口被最小化,你的按键就打到别的程序上去了——这是个很危险的情况,我曾经因为这个问题在编辑器里误触发了一堆快捷键。

所以只要代码里用到模拟键盘,就必须先确保窗口在前台:

Private Declare Function SetForegroundWindow Lib "user32" _ (ByVal hWnd As Long) As Long Private Declare Function ShowWindow Lib "user32" _ (ByVal hWnd As Long, ByVal nCmdShow As Long) As Long Const SW_RESTORE = 9 Private Function BringToFront(ByVal hWnd As Long) As Boolean If IsIconic(hWnd) <> 0 Then Call ShowWindow(hWnd, SW_RESTORE) Sleep 200 End If Call SetForegroundWindow(hWnd) Sleep 150 BringToFront = (GetForegroundWindow() = hWnd) End Function

SetForegroundWindow有个众所周知的行为:如果当前进程不是前台进程,系统可能会拒绝这个请求,只是把任务栏图标闪一下。这是系统防止程序抢焦点的保护机制。绕过的常见办法是先AttachThreadInput把自己的输入队列和目标线程绑定,或者模拟一次 Alt 键。但这些手段都越来越不可靠,而且带着"对抗系统保护"的味道,我不太推荐。

更稳妥的思路是:别依赖前台。能用消息解决的都用消息,实在要用模拟键盘,就在文档里明确写"运行期间请勿操作鼠标键盘",然后老老实实BringToFront之后加个 200 毫秒的等待。用户知情,比偷偷抢焦点要体面。

6.4 排查链路:从日志到窗口结构工具

遇到"发不出去"的时候,我有一套固定的排查顺序,按这个走基本能在十分钟内定位:

第一步,看日志确认走到了哪一层。这就是为什么我坚持每层都打日志。日志显示"找到句柄 0x1A0350"说明定位层通了;显示"WM_SETTEXT 返回 0"说明写入层失败;显示"PostMessage 成功"但界面没反应,说明触发层或目标程序有额外校验。

第二步,用IsWindow和IsWindowVisible校验句柄。打印出来看看这个句柄到底是不是个活着的、可见的窗口。

第三步,打印窗口的类名和标题。用GetClassName和GetWindowText。很多时候你会发现你抓到的根本不是输入框,而是某个容器或者标签。

第四步,用系统自带的窗口结构查看工具,手工核对一遍。看看真实的结构和你代码里假设的结构差在哪。这一步能解决大部分"我明明按类名找了但找不到"的问题——通常是因为类名拼错了,或者层级假设错了。

第五步,换一条写入路线试试。如果WM_SETTEXT不通,立刻换WM_PASTE;WM_PASTE不通,换剪贴板加模拟 Ctrl+V。三条路线总有一条能通。这个"备用路线"的设计,是我在写这类工具时的固定动作,因为不同版本的目标程序行为可能完全不同,你没有精力每次都重新研究。

我的日志函数长这样,加上时间戳和层级标记,事后翻看效率高很多:

Private Sub LogLine(ByVal s As String) Dim t As String t = Format$(Now, "hh:mm:ss") & "." & Right$("00" & CStr(GetTickCount() Mod 1000), 3) txtLog.Text = txtLog.Text & t & " " & s & vbCrLf ' 滚动到底部 txtLog.SelStart = Len(txtLog.Text) End Sub

毫秒级时间戳在这里很有用,因为你要靠它算每轮的真实耗时,判断是不是被对方的处理速度拖慢了。

7. 比"连发"更值得做的自动化思路

写到这儿,技术上的东西基本讲完了。最后聊点方向性的:这类工具的终点不应该是"连发",因为"连发"本身价值很低,风险却不小。真正有价值的是把重复劳动变成可控、可审计、可复用的流程。

7.1 官方能力优先原则

我现在的第一反应永远是:这个操作有没有官方途径?很多看起来只能靠界面操作的系统,其实藏着一个导出接口、一个命令行参数、或者一个批量导入的功能,只是没人告诉过你。花半天找官方途径,往往比花两天写界面自动化划算得多,而且稳定性和合规性完全不是一个级别。

界面自动化的本质是"模拟人的操作",它天生脆弱:界面改版、分辨率变化、控件重绘,任何一个都能让工具失效。它应该被定位成最后手段,而不是首选方案。我在实际项目里的排序是:官方接口 > 命令行/配置文件 > 数据导入导出 > 界面自动化。只有当上面三条全都不通的时候,才轮到消息注入这套东西。

7.2 把重复输入变成模板化工作流

回到最开始那个录入台账的例子。后来我做的其实不是"连发工具",而是一个模板填充器:把 Excel 里的数据读出来,按格式生成一条完整的文本,写进剪贴板,然后只做一次"粘贴 + 回车"。用户点一下,处理一条;点十下,处理十条。中间任何一步都可以人工核对。

这个东西的价值比全自动连发高得多,因为它是人机协作而不是人机替代。人保留了对每一条内容的确认权,程序只负责省掉键盘敲击的部分。出错率降下来了,责任边界也清楚了。而且要改内容的时候,改模板就行,不用改代码。

这个思路可以推广到很多场景:日报生成、固定格式的工单填写、批量重命名、数据核对。它们的共同点是——重复,但有结构。有结构就能模板化,模板化就能自动化,而且不容易失控。

7.3 一个自用的提醒小工具改造方向

我自己的机器上常年跑着一个小工具,最初就是从这个"连发"的思路演化来的,现在它的功能很朴素:每天固定时间,往一个自建的窗口里写几条待办事项,然后弹个提示。没有任何外部连接,不涉及任何第三方软件,纯粹是本机自娱自乐。

它的实现比连发简单得多,核心就三件事:一个Timer检查时间、一个文本框显示内容、一个文本文件存数据。但它教会我一件事:自动化的价值不在于"自动",在于"可靠地提醒你别忘了"。这比刷一百条消息有意义得多。

如果你手上已经有一份这类连发的代码,我建议你做一次改造,方向有三个:

  • 加上内容来源的校验:发送前把内容打印出来让人确认一次,或者加一个"预览"按钮。
  • 加上硬性的次数和时间窗口限制:比如每天最多 20 条,只能在 9 点到 18 点之间运行。
  • 加上完整的运行日志:每一条发了什么、什么时候发的、结果如何,全部落盘。这不只是排查用,也是对自己的约束——知道一切都有记录,人就会更谨慎。

把这几件事做完,你会发现这个工具的使用频率大幅下降,但留下的那部分需求反而更真实了。这大概就是技术工具最好的归宿:不是让你做更多事,而是让你只做该做的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询