ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

易语言自动化脚本:乐玩多线程模块索引方法4封装实战

易语言自动化脚本:乐玩多线程模块索引方法4封装实战

自己写易语言自动化脚本,尤其是接窗口绑定、找图找色、键鼠模拟这套流程时,都会碰到一个很尴尬的阶段:功能刚跑通,程序一启动,界面就“冻”住,鼠标挪不开,窗口最小化都点不进去。原因不复杂,你所有的耗时操作——找图、识别、延时、点击——全挤在主线程里,一个找图没找到就死等,界面自然就被堵死了。

后来我开始用乐玩这类第三方模块配合多线程来解这个局。乐玩模块负责窗口操作、图形匹配这些底层能力,多线程则负责把最耗时的循环丢到子线程里去跑,主线程继续响应界面操作。这个思路本身不难,难在调用方式。尤其当你拿到的模块是“程序集模块”形式,子程序对外只暴露索引号,不暴露完整函数名的时候,第一件事就是搞清楚“索引”到底是什么、你要用的那个方法排在哪儿。

这篇就围绕“511遇见易语言乐玩多线程模块(索引)程序集模块–方法4”这个场景,把这套东西从原理到封装拆开讲清楚。内容偏实操,适合刚接触易语言模块调用的新手,也适合把多线程写了一半但总出莫名问题的朋友参考。你不需要有多深的线程背景,能跟着把流程走通,后面遇到问题也知道该往哪个方向排查。

1. 为什么易语言多线程项目总卡在“找窗口”这一步

1.1 单线程模型先跑通,然后界面假死

大多数易语言脚本都是这么起步的:点击按钮,取窗口句柄,绑定窗口,开始循环找图,找到就鼠标移动、点击,没找到就延时继续找。功能逻辑本身没问题,但跑起来之后你会发现,只要循环里有任何一个“同步等待”的操作,主线程就被钉死住了。

举个我实际遇到的例子。当时我在做一个批量录入数据的自动化小工具,主界面只是几个按钮加一个日志框。程序里有个循环,每隔两秒去取一次某个窗口某个固定位置的颜色值,做一次判断。逻辑跑起来之后,日志框还能打印内容,但整个主窗口的标题栏显示“未响应”,拖动窗口、点击停止按钮全都没反应。原因就是主线程一直占着循环不放,窗口的消息循环根本没机会执行,界面自然假死。

很多人第一反应是“是不是循环太频繁了”,把延时从500毫秒改成5000毫秒,问题依旧。这不是延时长短的问题,而是单线程结构决定的:只要主线程在忙,界面就一定卡。你想想,界面上的按钮点击、窗口拖动,本质都是在走一个消息循环,主线程在那儿循环找图,没有时间去处理“用户点击了停止按钮”这个消息,程序当然看起来像死掉了一样。

1.2 乐玩模块在这里的真实定位

乐玩这类模块解决的是“窗口操作”和“图形匹配”的下层能力。它提供了取句柄、绑定窗口、找色找图、键鼠模拟这些基础命令。你写脚本就是把这一堆命令串起来,组合成业务逻辑。

但模块再好,它只是一堆命令的集合,并没有帮你规划线程模型。乐玩命令本身是同步的:调用一个找图命令,你得等它找完,返回坐标或空值,然后才能执行下一步。如果你把这些命令直接堆在主线程里,那么不管模块优化得多好,流程上依然是“一个命令执行完,另一个命令再开始”,界面卡死的问题该怎么发生还是怎么发生,模块救不了你。

所以乐玩和多线程不是二选一的关系,是配合关系。乐玩提供底层能力,多线程负责调度和隔离。正确的做法是:把乐玩的操作循环整体塞进一个子线程,子线程去跑找图、判断、动作这些耗时逻辑,主线程只负责响应界面、显示日志、接收停止指令。

1.3 什么样的项目需要上多线程

不是所有易语言项目都要一上来就多线程。写个一次性执行的小工具,串行执行完全够用,多线程反而增加复杂度。我是到项目出现下面这些情况才开始改造的:

  • 主界面需要在循环运行期间保持可操作,比如可以点“停止”按钮;
  • 要同时监控多个窗口,每个窗口独立循环,互不干扰;
  • 主线程需要持续响应其他事件,比如按键热键、定时器事件;
  • 某个找图或等待操作耗时不确定,可能几秒也可能几十秒,不能阻塞界面。

其中“同时监控多个窗口”是最典型的场景。窗口A的循环和窗口B的循环如果在同一个线程里顺序执行,那就变成先抓A,再抓B,循环一轮的时间不确定,窗口B的实时性就要看A的脸色。多线程之后,每个窗口一个独立线程,各跑各的循环,互不等待,这才是真正解决问题的方式。

2. 程序集模块的“索引”机制:先定位方法4再谈调用

2.1 什么是程序集,为什么模块作者要给方法编号

易语言里,一个程序集就是一组子程序的集合。模块源码编译成独立模块文件之后,作者为了让你能动态调用,会给每个子程序按顺序排一个索引号。你在模块里看到的各种“取窗口句柄”、“找图”、“鼠标移动”,编译成模块后就不再以名字形式暴露给你了,而是以“第几个子程序”的形式排列。

这个设计很像图书馆里的书目分类。你找一本书,可以直接报书名,也可以报书架编号。书名好记但有时候会重名,编号机械但绝对唯一。模块作者把一组方法编上索引,你就相当于拿到了一个固定目录,只要按索引调用,就一定不会找错。

模块作者之所以这样做,还有一个现实原因:易语言的模块调用如果按名字调用,需要在编译期就确定符号表,动态性会差很多。而按索引调用,程序运行的时候只要拿到模块的基地址,直接按偏移量找子程序入口就行,灵活得多。很多第三方模块干脆把索引号写成一个常量列表,让你在源码里引用,方便你写“调用子程序(全局_模块句柄, 4, 参数1, 参数2)”这种动态调用。

2.2 拿到一个模块后,怎么定位你想用的方法

这一步是很多人容易忽略的。拿到一个陌生模块,先别急着写代码,先把模块的方法列表捋一遍。

通常的做法是这样的:

  • 打开模块源码,找到作者写的“方法说明”或者“常量定义”部分;
  • 看哪些常量名和线程相关,比如“线程_启动”、“Thread_Start”、“线程_创建”;
  • 记下这个常量对应的数字编号,那个数字就是索引号。

如果模块是编译后的版本,没有源码,那就在易语言IDE的模块引用面板里查看导出的子程序列表,列表的顺序基本就是索引编号的顺序。模块作者一般会按功能分区排列子程序,比如窗口类、图色类、鼠标键盘类、线程类。你要找多线程相关的方法,直接找到线程类的开头位置,那个索引就是入口。

2.3 索引方法4通常对应什么

以我接触过的多个类似模块的经验来看,索引方法4并不是一个随机的编号,它往往落在模块的核心操作区域。很多模块把方法1到方法3留给初始化、版本获取、绑定句柄这类准备步骤,方法4开始进入正式的“干正事”阶段。在不同模块里,方法4可能对应以下几种常见能力:

  • 多线程启动入口,用来创建一个独立的工作线程;
  • 线程循环体的实现入口,也就是线程内真正要反复执行的逻辑;
  • 一个包装好的任务分发函数,接受外部参数,执行对应的自动化操作。

所以你要做的不是死记“方法4就是XX功能”,而是打开自己手上的模块,看索引列表里第4个方法到底是什么。不少模块在方法列表里会写明注释,比如“方法4:线程循环执行”,你一看就明白它在这个模块里承担什么角色。

我当年踩过一个很典型的坑:某个模块的方法5是“窗口找色”,方法6是“窗口找图”,我以为方法4是“取窗口标题”,结果一调用就直接崩溃。后来打开模块源码才发现,方法4专门用于线程环境初始化,必须在线程启动之前调用,而且要先传一个线程标识参数。所以在写调用代码之前,先确认索引和方法的对应关系,比什么都重要。

3. 从模块初始化到线程句柄落地:一个最小可跑的调用链路

3.1 先完成模块的基础初始化

不管你要用到的是方法4还是别的方法,模块本身必须先在程序里完成初始化。这一步解决的是“模块依赖的外部环境有没有准备好”。

拿乐玩风格模块举例,我记得自己在项目里一般会这样处理:

.版本 2 .程序集 窗口程序集_启动窗口 .变量 全局_模块句柄 整数型 .变量 全局_线程句柄 整数型 .变量 全局_正在运行 逻辑型 .子程序 _按钮_初始化_被单击 全局_模块句柄 = 模块_加载 (“lewan.dll”) 如果真 (全局_模块句柄 = 0) 输出调试文本 (“模块加载失败”) 返回 结束如果 模块_初始化 (全局_模块句柄, 取窗口句柄 ()) 输出调试文本 (“模块初始化完成”)

注意模块加载和普通DLL加载不是一回事。普通DLL你直接调用“加载DLL”就行,但很多模块是一个自包含的COM组件,模块内部还有自己的环境初始化,只把DLL加载进内存不代表它已经可以用了。所以模块作者通常会另外提供一个初始化子程序,我们这里的“模块_初始化”就是干这个活的。

初始化时传进去的窗口句柄也很关键。有的模块初始化需要知道“主窗口是谁”,因为后面对话框、日志回显、消息拦截都要依托主窗口。如果你传了0或者一个无效句柄,后面调用任何方法都可能返回空值或错误码,排查起来特别头大。

3.2 创建线程,并且把参数正确传进去

初始化完成后,就可以通过索引方法4来启动线程了。这里最容易出错的地方是参数传递。

易语言里的线程命令和模块线程方法不一样。系统自带的“启动线程”命令可以接收一个参数地址,而模块的索引方法4如果设计成一个线程启动入口,它通常也接受一个整数型参数,用来告诉线程“你要去哪个窗口干活”。

参数传递有一个绝对不能犯的错误:不要直接把窗口句柄数值传过去就完事,你需要确保这个数值在线程内部被正确识别为句柄类型。易语言的整数型和句柄型本质都是4字节数值,但运行时类型检查会在某些边界条件下触发错误。最稳妥的做法是单独定义一个整数变量,把窗口句柄赋值进去,然后把变量的地址传过去,在线程入口里再取出来。

我常用的一个传递结构是这样的:

.版本 2 .程序集 全局_线程参数 .变量 hwnd 整数型 .变量 模式 整数型 .变量 延迟间隔 整数型

然后启动线程时这样写:

.子程序 _按钮_启动_被单击 .局部变量 参数容器, 全局_线程参数 .局部变量 参数地址, 整数型 参数容器.hwnd = 取窗口句柄 (“目标窗口类”, “目标窗口标题”) 参数容器.模式 = 1 参数容器.延迟间隔 = 2000 参数地址 = 取变量地址 (参数容器) 全局_正在运行 = 真 调用子程序 (全局_模块句柄, 4, 参数地址, 返回的线程句柄)

把多个参数打包进一个自定义数据类型,再把这个数据结构的地址传过去,好处是不需要在线程内部处理多个参数,数据都在一个连续的内存区域里,读取简单,也不容易漏参数。很多模块方法只给你预留一个参数位,这时候“结构体打包”几乎是唯一优雅的传参方式。

3.3 线程内部怎么跑:绑定、循环和退出检查

线程一旦启动,方法4内部就会进入它自己的执行逻辑。如果你用的是已经封装好的线程方法,执行逻辑通常是这个循环结构:

  1. 读取传入的参数,取出窗口句柄;
  2. 用模块命令绑定窗口或设置后台图色模式;
  3. 进入循环:取图、取色、判断、模拟操作;
  4. 每次循环后检查“是否允许退出”的全局标记;
  5. 如果标记为假,继续循环;如果为真,跳出循环,释放资源,线程结束。

在易语言里,这套逻辑写在子程序里,子程序体大概长这样:

.子程序 子线程_主逻辑 .参数 参数地址, 整数型 .局部变量 参数容器, 全局_线程参数 .局部变量 是否找到图, 逻辑型 .局部变量 找到的坐标X, 整数型 .局部变量 找到的坐标Y, 整数型 参数容器 = 取变量数据 (参数地址) 模块_绑定窗口 (全局_模块句柄, 参数容器.hwnd, 1) 判断循环首 (全局_正在运行) 是否找到图 = 模块_找图 (全局_模块句柄, 图片数据, 找到的坐标X, 找到的坐标Y) 如果真 (是否找到图) 模块_鼠标移动 (全局_模块句柄, 找到的坐标X, 找到的坐标Y) 模块_鼠标左键单击 (全局_模块句柄) 结束如果 延时 (参数容器.延迟间隔) 判断循环尾 ()

这里“模块_绑定窗口”很重要。绑定的意义是把后续图色操作的数据源锁定到目标窗口上,而不是全屏幕找图。这样就算目标窗口被遮挡一部分,后台识别依然可以拿到指定区域的内容。

绑定模式参数也值得单独说。1号模式一般是“后台找图+后台鼠标”,适合窗口被遮挡的情况;0号模式是前台绑定,要求窗口必须可见。很多人用后台绑定失败,往往是把绑定模式设成前台模式,窗口一被挡住就找不到了。这个参数根据你实际自动化场景来定,如果窗口需要完全隐藏后台运行,就选后台模式;如果窗口必须前台操作,那没有别的办法,只能保证它不被遮挡。

3.4 停止按钮怎么写才能真正停下来

多线程编程里,停止操作要比启动操作更讲究。写停止按钮时不要直接调用“强制结束线程”,那样容易在窗口句柄、内存资源还没释放的情况下直接把线程杀掉,轻则内存泄漏,重则直接崩程序。

可以根据我上面的代码结构,用一个全局逻辑变量“全局_正在运行”来控制循环。停止按钮的逻辑是:

.子程序 _按钮_停止_被单击 全局_正在运行 = 假

子线程里的循环会在下一次判断时读到“假”,自己跳出循环,自己清理资源,自己结束线程。这种协作式退出虽然多花一两个循环周期,但非常干净。

如果你需要立竿见影地停下来,可以配合“事件对象”来通知线程。全局创建一个事件句柄,停止按钮调用“设置事件”,线程循环里用“等待事件”并设置一个超时时间,比如500毫秒。等待到事件就退出循环,等待超时则继续干活。这种方式在需要“立即响应停止”同时又得“给线程时间收尾”的场景下,比单纯改一个逻辑变量要可靠得多。

4. 三个高频翻车点:句柄失效、变量打架和退出风暴

多线程代码写起来不难,真正让人头大的是运行期莫名其妙的问题。集中在三个地方。

4.1 句柄失效:窗口还是那个窗口,但句柄不是那个句柄了

第一个翻车点是窗口句柄失效。易语言程序里你取到窗口句柄后,它在绝大多数情况下是稳定的,但以下场景会让它失效:

  • 目标窗口被关闭后又重新打开,比如用户把目标程序退出再启动;
  • 目标窗口的窗口标题或类名被动态改变,系统返回新的句柄;
  • 目标窗口被执行了“重启窗口”类操作,句柄重建。

我遇到过一个很实际的问题:启动线程时传入的窗口句柄是有效的,线程跑了一段时间之后,我重启了一次目标窗口程序,然后再去调用模块的找图方法,返回空值,鼠标操作也没反应。日志一查,发现线程内部保存的旧句柄早就不是一个有效窗口了。

解决思路是在线程循环里做句柄有效性检查。每轮循环取一次当前目标窗口的句柄,和线程保存的旧句柄比较,如果不同,就重新绑定窗口,更新句柄变量。这个过程不需要停机,只需要在循环体开头加一段检测逻辑就行。

具体可以这么设计:

当前句柄 = 窗口_取句柄 (“目标窗口类”, “目标窗口标题”) 如果真 (当前句柄 ≠ 参数容器.hwnd) 参数容器.hwnd = 当前句柄 模块_绑定窗口 (全局_模块句柄, 当前句柄, 1) 结束如果

这样即使目标窗口重启,线程也会在下一轮循环自动找到新句柄并重新绑定。省去了人工干预。

4.2 变量打架:多线程同时读写同一个全局变量

第二个翻车点是全局变量竞争。最容易出问题的就是我在上一节写的那个“全局_正在运行”。开始按钮把它改成真,停止按钮把它改成假,按理说很安全。但如果你的项目里还有别的线程也去读写它,比如日志线程要判断“当前是否正在运行”,那就有可能在同一条微指令上读到不一致的数据。

更典型的竞争场景是共享计数器和共享队列。比如多个窗口线程各自把识别结果写进同一个全局日志数组,日志刷新线程再把这些内容显示到界面上。写入顺序错乱、显示内容重复、甚至数组越界,都会出现。

解决这个问题的手段是临界区。易语言里可以用“进入许可区 / 退出许可区”或者模块自带的锁命令。操作思路很明确:

进入临界区 (全局_日志锁) 全局_日志数组 [全局_日志条数] = 当前日志内容 全局_日志条数 = 全局_日志条数 + 1 退出临界区 (全局_日志锁)

所有写日志数组的地方都套上同一个锁,写操作在这个时间段内就是独占的,不会有两个线程同时写。注意锁一定要在全局范围内保持唯一,如果每个线程各自创建一把锁,那就等于没锁。

4.3 退出风暴:线程还没退出,又开始新线程

第三个翻车点是在快速连续点击“开始-停止-开始”时触发的。第一次点击开始,线程A启动;点击停止,全局_正在运行设成假,线程A在循环末尾读到假,准备退出;还没退干净,你就又点了开始,线程B启动了。这会导致线程A和线程B同时操作一个窗口,两个循环一起来回给窗口发送鼠标命令,现象就是窗口操作混乱,鼠标乱跑,模块报错。

解决方法是给线程增加一个“完全退出确认”机制。具体写法是:

  • 线程函数末尾设置一个事件,比如“线程_已结束”;
  • 启动新线程之前,检查上一次的线程是否已经结束;
  • 如果没结束,先等待这个事件,或者直接拒绝启动新的线程。

判断是否还在运行的逻辑很简单,用窗口消息或全局变量都可以。我推荐用“当前线程句柄是否还有效”来判断,易语言的“线程是否有效”命令可以直接查,有效说明线程没退出,无效说明可以安全启动新线程。

启动按钮里的保护逻辑大概是:

如果真 (线程_是否有效 (全局_线程句柄)) 信息框 (“请先等待上一次线程结束”, 0, , ) 返回 结束如果 全局_正在运行 = 真 调用子程序 (全局_模块句柄, 4, 参数地址, 全局_线程句柄)

这样至少能挡住大部分误操作,避免两个线程同时抢一个窗口。

5. 把索引方法4封装成可复用的多线程模板

5.1 封装结构怎么设计

单次调用跑通之后,最值得做的事是把“启动-运行-停止”这套流程封装成一个通用模板。这样下次遇到其他自动化需求,不用再从头写一遍多线程框架,只需要替换线程里的业务逻辑。

我自己的封装习惯是,把所有线程相关的东西放到一个独立的程序集“多线程调度”里。程序集内定义好这几个公共接口:

  • “多线程_初始化”:加载模块,执行环境初始化;
  • “多线程_启动”:接收窗口句柄、模式、延时等参数,打包成结构体,调用索引方法4;
  • “多线程_停止”:设置停止标记,并等待线程完全结束后返回;
  • “多线程_写日志”:内部加临界区保护,把日志内容安全写入日志数组。

这样做有一个实际好处:业务代码和线程细节分离。写业务的人不需要关心临界区、事件、句柄有效性这些底层细节,只需要调用“多线程_启动”和“多线程_停止”就够了。调试时你也可以直接在这个程序集里统一加日志,不会漏地方。

5.2 模板里要留的扩展点

封装时一定要预留几个扩展点,我用过的模块版本里,下面这些点是必然要留的:

  • 业务循环体独立成单独子程序,方便你替换找图、找色、鼠标操作等命令;
  • 线程退出前执行统一的资源释放逻辑,比如解绑窗口、关闭事件句柄、清空结构体内存;
  • 参数结构体里预留一个“自定义数据”字段,允许外部把任何额外数据传进去;
  • 日志级别开关,方便从详细日志切换成精简模式,加快排错。

还有一个很多人容易忽略的扩展点:统计计数器。在线程里记录每轮循环的执行次数,以及成功识别次数、失败次数。这个数据对判断程序“有没有在正常干活”特别有用。日志框里每一分钟打一次统计信息,比光看“正在运行”四个字踏实得多。

5.3 一些值得记住的实测体会

模块方法索引这个东西,不同版本差异不小。我之前在某个版本的模块上方法4是“线程启动”,换了一个版本之后方法4变成“绑定窗口”,我当时没细看就按老代码跑,结果模块报错才发现索引对应关系变了。所以每拿到一个新模块版本,第一件事就是打开方法列表重新核对索引表,这个习惯值得养成。

传参结构体的大小也不要随便改。如果启动线程之前把结构体定义成5个字段,线程内部读取位置是按这5个字段的偏移来读的。你在线程启动之后又改了结构体定义,比如加了一个字段,单元数据的偏移量全变了,线程读出来的就是错乱数据,轻则逻辑错误,重则内存访问越界直接崩溃。要改结构体,就一定先停止线程,改好之后再重新编译,再启动线程。

线程数量这块,如果不是特殊需求,不要开太多。我曾经试着给8个窗口同时开8个线程,结果模块内部稳定性和系统资源都没扛住,后续操作频繁失败。后来改成一个线程负责两个窗口,交替处理,才稳定下来。多线程不是线程越多越好,窗口操作这类任务往往受限于目标窗口本身的处理能力,线程太多反而增加竞争和出错概率。

最后再分享一个小技巧:调试多线程的时候,不要靠“信息框”来看日志。信息框是模态的,会阻塞调用线程,一旦在子线程里弹了一个信息框,那个线程就卡住了,其他线程还得等它,调试体验很差。正确做法是写一个“写日志”子程序,把文本追加到全局日志数组里,再由主线程的定时器或时钟组件刷新到界面。这样线程内部只做数据写入,不弹窗,不阻塞,日志还能完整记录每一轮循环的情况。这个习惯帮我解决过不少次莫名其妙的偶发问题,你可以直接照着用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进