每个月头几天,我都要打开某个桌面客户端,把上个月几十个客户的对账单一条条导出来,再按客户名改文件名归档。这件事本身不难,就是机械,而且必须在那个软件里点,没法用命令行绕过去。最开始我用的是最笨的办法:录屏软件回放鼠标坐标,机器一换分辨率全乱;后来装了按键精灵之类的工具,窗口一挪位置又失效。真正让我把这件事彻底交出去的,是pywinauto——一个专门用来操作 Windows 桌面程序的 Python 库,它能像人一样识别窗口里有哪些按钮、输入框、下拉框,然后按控件身份去操作,而不是靠屏幕坐标去"盲点"。
这篇东西写给两类人看:一类是手上有重复性的桌面软件操作、想用脚本替掉的普通办公人员或者测试岗;另一类是想把某个 Windows 客户端接进自动化流程、但被控件识别和等待时序卡住的开发者。我会把选后端、读控件树、点击方式差异、时序兜底、以及权限和 DPI 这些真正让人熬夜的东西摊开说,代码都是能直接改改就用的。
1. 从每月手工点几百次导出说起:pywinauto 究竟接替了哪部分劳动
先把定位说清楚。pywinauto 不是一个"看着屏幕找图"的工具,它走的是操作系统层面的控件接口:Windows 上的每个按钮、输入框、列表项,在系统里都有一个可被枚举的对象和属性(类名、自动化 ID、控件类型、文本)。pywinauto 做的事就是把这些人眼看不到的属性翻译成 Python 对象,你去描述"我要那个标题叫『导出』、类型是按钮的控件",它去窗口树里找,找到之后调用系统接口让它响应。
这个路线决定了它的优点和缺点都很鲜明。优点是稳:窗口被拖到屏幕任何一个角落都不影响,分辨率改了也不影响,甚至窗口被别的窗口盖住一部分通常也能操作(取决于控件和点击方式)。缺点是它只认"有控件结构的程序",对于那些用自绘引擎画出来的界面、或者把整个窗口当成一张画布渲染的程序,pywinauto 能看到窗口本身,但看不到里面的"按钮",这时候就得上图像识别或者坐标兜底了。
1.1 它和常见替代方案的分工边界
我把手上用过的几种方案摆在一起对比,你能很快判断自己该选哪条路。
| 方案 | 定位依据 | 适合的场景 | 主要短板 |
|---|---|---|---|
| pywinauto | 控件属性(类名、自动化 ID、控件类型) | 传统 Win32 客户端、WinForms、WPF、Qt 桌面程序 | 自绘界面识别不到;跨平台不支持 |
| 坐标回放类工具 | 屏幕绝对坐标 | 极简单的一次性点击 | 分辨率、窗口位置、主题一变就废 |
| 图像匹配(模板找图) | 像素特征 | 自绘界面、游戏、Canvas 类界面 | 缩放、抗锯齿、主题色变化敏感,速度慢 |
| 浏览器自动化 | DOM 元素 | 网页和 Electron 外壳内的页面 | 管不到外壳的菜单栏和系统对话框 |
实际项目里这三者经常是混着用的:主体流程用 pywinauto 走控件,遇到某个自绘的图表控件再用图像匹配点一下。但如果你的目标程序是标准的 Windows 客户端,pywinauto 一条路就能走完,没必要给自己加复杂度。
1.2 安装与环境的最小可用集
安装本身不复杂,但有几点值得提前说。pywinauto 依赖pywin32和comtypes这两个包,Windows 上装的时候偶尔会因为权限或者残留旧版本出问题,我的习惯是新建一个干净的虚拟环境再装。
python -m venv venv venv\Scripts\activate pip install pywinauto pip install pillowpillow不是必须的,但强烈建议装上,因为后面排查问题时capture_as_image()截图、以及某些控件的图像比对都要用它。另外如果你的程序界面里有大量中文,控制台输出编码建议一起设好,否则print_control_identifiers()打出来的中文可能变成乱码,排查时非常难受。
import sys sys.stdout.reconfigure(encoding="utf-8")环境验证最快的方式,是拿系统自带的记事本试一下:启动、打字、读回文本,三步跑通说明链路没问题。
from pywinauto.application import Application app = Application(backend="uia").start("notepad.exe") dlg = app.window(title_re=".*记事本.*") dlg.child_window(control_type="Document").type_keys("hello pywinauto", with_spaces=True) app.kill()提示:不同版本的 Windows 记事本界面差异很大(新版是 UWP 外壳),如果上面这段在你的机器上窗口找不到,换成计算器或者随便一个老式客户端试,别一开始就在记事本上耗时间。
2. 后端选型:win32 与 uia 的取舍决定了后面所有调试成本
pywinauto 有两个后端,win32和uia。这是新手最容易随手一写就埋雷的地方,因为两个后端的 API 长得几乎一样,代码改一个字符串就能切,但背后走的是完全不同的系统接口,能看到的控件信息差别巨大。
win32后端走的是老的 Win32 API,靠窗口句柄和消息机制。速度快,对 MFC、Delphi、VB6 那批老程序支持非常好,缺点是它看不见很多现代框架的内部结构——比如 WPF 程序里一个Button,在 win32 后端眼里可能只是一整块无名区域。
uia后端走的是 Microsoft UI Automation,是后来那套无障碍接口。它能深入到 WPF、WinForms、UWP、Qt、Electron 的内部,控件的automation_id、control_type都能读出来。代价是慢,因为每次枚举都要跨进程走 COM 调用,一棵复杂的控件树跑一遍可能要好几百毫秒。
2.1 怎么快速判断该用哪个
我的判断流程很简单,三步:
- 先用
uia后端启动,调print_control_identifiers(depth=3)。如果能打印出层次清晰的控件树,并且你能在里面看到目标按钮、输入框,那就用uia,不用再试了。 - 如果
uia打出来的树很浅(只有窗口本身加一两个空壳控件),换成win32后端再打一次。老程序经常是反过来——win32 能看到,uia 反而看不到。 - 两边都只能看到一层壳,说明这个界面大概率是自绘的,pywinauto 的控件路线走不通,考虑图像匹配或者别的办法。
from pywinauto.application import Application for backend in ("uia", "win32"): print(f"===== {backend} =====") try: app = Application(backend=backend).connect(path="client.exe") app.top_window().print_control_identifiers(depth=3) except Exception as e: print(f"{backend} 失败: {e}")注意:
connect()要求目标程序已经在运行。如果程序还没开,用start();如果开了多个实例,connect(path=...)会连到其中一个,具体哪个不确定,这时候改用connect(process=pid)更可靠。
2.2 两个后端混用是被低估的技巧
很多人以为一个脚本只能用一种后端,其实完全可以开两个Application对象,一个uia一个win32,指向同一个进程。我遇到过一个客户端的奇葩情况:主界面是 WPF 的,用uia能拿到所有业务控件;但点"导出"之后弹出的那个文件选择框是老式的系统对话框,uia后端在它上面反而枚举不全,得用win32去做。
app_uia = Application(backend="uia").connect(path="client.exe") app_win = Application(backend="win32").connect(path="client.exe") main = app_uia.top_window() main.child_window(title="导出", control_type="Button").click() save_dlg = app_win.window(title_re=".*另存为.*") save_dlg.child_window(class_name="Edit").set_text(r"D:\out\report.xlsx") save_dlg.child_window(title="保存", class_name="Button").click()这种做法不算优雅,但在真实项目里救过我好几次。判断标准只有一个:哪个后端能稳定拿到你要的控件,就用哪个,不要为了"架构统一"跟自己较劲。
2.3 连接方式的选择也有讲究
start()和connect()的差别不只是"程序开没开"。start()启动后会返回一个绑定到新进程的 Application 对象,这个绑定关系是明确的。而connect(path=...)是按可执行文件路径去匹配进程,如果同一个程序开了两个窗口(比如同时登录两个账号),匹配到的可能是任何一个,脚本行为就会变得不可预测。
我的写法是:能start()就start(),需要接管已经跑着的实例时,先用Desktop(backend="uia").windows()把所有顶层窗口列出来,人工确认特征之后再用connect(title_re=...)精确连。
from pywinauto import Desktop for w in Desktop(backend="uia").windows(): print(w.window_text(), w.process_id(), w.class_name())这个方法在排查"脚本连错窗口"类问题时特别有用,比盲猜快得多。
3. 读懂控件树:print_control_identifiers 输出里的定位信息怎么用
print_control_identifiers()是 pywinauto 里最重要的一个函数,重要程度远超它朴素的名字。它把当前窗口下所有可识别的控件按层级打出来,并且贴心地给出每个控件的可用定位属性。学会读这个输出,等于掌握了七成的调试能力。
典型的输出长这样(我做了简化):
Dialog - '对账系统 v3.2' (L120, T80, R1180, B760) ['对账系统 v3.2Dialog', 'Dialog', '对账系统 v3.2'] child_window(title="对账系统 v3.2", control_type="Window") | | Edit - '客户编号' (L160, T140, R360, B170) | ['Edit', '客户编号Edit'] | child_window(title="客户编号", control_type="Edit") | | Button - '查询' (L380, T140, R450, B170) | ['查询Button', 'Button', '查询'] | child_window(title="查询", control_type="Button")要读的信息分三块。第一行是控件类型、显示文本、屏幕矩形,矩形在你后面要算相对坐标时用得上。第二行的方括号里是"可用的简短标识符",这是最省事的写法,你可以直接把'查询'这个字符串传给child_window()。第三行是完整的定位表达式,通常是最稳的写法。
3.1 三种定位属性的可靠度排序
title、auto_id、control_type这三个属性,可靠度差别很大,选错了脚本就会时不时抽风。
auto_id(在 uia 后端里叫automation_id)是最可靠的,因为它通常是开发写死的,不随语言和版本变。但它有个前提:目标程序得真的设置了这玩意,很多小厂的客户端根本没设。
control_type是很稳定的类型描述,但它不唯一——一个窗口里可能有八个Edit。所以它只适合和别的属性组合使用,单独用基本等于瞎猜。
title最直观但最脆弱:程序换个皮肤、改个文案、切个语言包,全废。我在接手别人的脚本时,最常看到的崩溃原因就是满篇title="确定"。
我的组合策略是:能拿到auto_id就用auto_id+control_type;拿不到就用class_name+title;实在不行才用title+found_index。
# 优先级从高到低,按实际情况选一种 dlg.child_window(auto_id="btnQuery", control_type="Button") dlg.child_window(class_name="Button", title="查询") dlg.child_window(title="查询", control_type="Button", found_index=0)3.2 child_window 的搜索范围会直接决定脚本快慢
uia后端慢,很大一部分时间花在跨进程枚举上。child_window()默认是从当前控件往下递归找,范围越大越慢。有个很实用的参数depth,限制往下找几层。
# 慢:全树递归 dlg.child_window(title="导出", control_type="Button").click() # 快:只在直接子级和孙级找 dlg.child_window(title="导出", control_type="Button", depth=3).click()另一种加速方式是先定位到一个中间容器,再从容器往下找。比如界面上有个"左侧导航栏"面板,你要点的按钮都在里面,那就先拿面板,再从面板child_window(),搜索范围瞬间从几百个控件缩到十几个。
nav = dlg.child_window(auto_id="panelNav", control_type="Pane") nav.child_window(title="报表", control_type="ListItem").click()还有一种情况值得单独说:同一个title在窗口里有多个,child_window()会返回第一个,而实际你要的是第二个。这时候有两个办法,一是加found_index指定第几个,二是用descendants()拿到列表自己过滤。后者的好处是你能在过滤时加上更多条件,也方便打日志看实际匹配到了哪些。
candidates = dlg.descendants(control_type="Button", title="删除") print(len(candidates)) target = [c for c in candidates if c.is_enabled()][-1] target.click()提示:控件的 wrapper 对象是有生命周期的。如果你拿了一个控件引用,中间窗口刷新了一次,这个引用可能就失效了,再操作会抛
ElementNotAvailableError之类。稳妥的做法是每次操作前重新定位,别把 wrapper 存太久。
4. "找得到"与"点得动"之间:click、click_input、set_text 的行为差异
这是我最想重点讲的一块,因为绝大多数"控件找到了但没反应"的问题都出在这里。pywinauto 提供了好几种操作控件的方法,它们底层走的机制完全不同,适用的场景也不同。
click()走的是消息机制,直接给控件所在的窗口投递点击消息。它最大的好处是不需要窗口在前台、不需要鼠标真的移过去,甚至窗口被最小化或者被遮挡也能生效。缺点是有些控件(尤其是现代框架的控件)会检查点击来源,收到消息但不响应,表现出来就是"代码没报错,但什么都没发生"。
click_input()走的是真实的鼠标输入模拟,会把鼠标移过去再按下去。它更接近人的操作,几乎对所有控件都有效,但代价是要求窗口可见、未最小化,而且期间会"抢"鼠标,你没法同时用电脑做别的事。
4.1 三种点击方式的实测对比
| 方法 | 底层机制 | 窗口需可见 | 是否抢鼠标 | 适用性 |
|---|---|---|---|---|
click() | 投递窗口消息 | 否 | 否 | 传统 Win32 控件、简单按钮 |
click_input() | 模拟真实鼠标 | 是 | 是 | 现代框架控件、需要真实事件的场景 |
invoke() | UI Automation 的 Invoke 模式 | 否 | 否 | uia 后端下支持的按钮、菜单项 |
invoke()是个被低估的选项。它是 UIA 标准里的一个"调用"模式,相当于直接告诉系统"执行这个按钮的默认动作",不涉及鼠标。在 uia 后端下,很多按钮都能用,速度快、不抢鼠标、不需要窗口在前台,是后台跑批的理想选择。
btn = dlg.child_window(auto_id="btnExport", control_type="Button") try: btn.invoke() except Exception: btn.click_input() # 兜底我现在的习惯是写成一个小的"尽力而为"封装:先试invoke(),不行再试click(),最后才click_input()。这样在大部分场景下脚本都能在后台安静地跑。
4.2 文本输入:set_text 和 type_keys 别混用
文本输入同样是两个方法,差别同样大。set_text()是直接设置控件的文本属性,速度极快,对标准Edit控件效果很好。但它有两个坑:一是某些富文本控件(比如带格式的编辑框、某些第三方表格控件)根本没有暴露可写的文本属性,set_text()会静默失败或者抛异常;二是如果控件有输入校验逻辑绑在键盘事件上,直接设值不会触发校验,导致后续流程走不通。
type_keys()是模拟键盘敲击,能触发所有键盘相关的事件和校验,但对中文支持很差——它本质上是按虚拟键码发按键,中文字符没法用这种方式直接打出来。
我的判断规则:
- 输入的是英文、数字、符号,且控件是标准输入框:优先
set_text() - 输入的中文,或者控件有输入校验:先
type_keys("^a")全选,再set_text(),然后补一次type_keys("{TAB}")触发失焦校验 - 密码框:基本只能用
type_keys()
edit = dlg.child_window(auto_id="txtKeyword", control_type="Edit") edit.set_text("2024-09") # 快速置值 edit.type_keys("{TAB}") # 触发失焦校验 pwd = dlg.child_window(auto_id="txtPwd", control_type="Edit") pwd.type_keys("Abc12345", with_spaces=True)type_keys里有几个特殊字符要转义:{}、()、+、^、%、~都是修饰符。你要输入一个字面的+,得写成{+}。这个坑我踩过一次,密码里带+号导致登录一直失败,查了两个小时。
4.3 下拉框和列表的选中,别用点击去硬怼
下拉框是新手最容易搞错的地方。很多人会写"点一下下拉框,等列表弹出来,再点其中一项",问题是弹出的列表有时候是独立的浮动窗口,不在原窗口的控件树里,child_window()根本找不到。
pywinauto 对标准 ComboBox 提供了语义化的方法,直接按文本选,不用管弹层。
combo = dlg.child_window(auto_id="cmbType", control_type="ComboBox") combo.select("对账单") # 按显示文本选 # 或者按序号 combo.select(2) print(combo.selected_text()) # 读回确认如果是那种非标准的、用 ListView 模拟的下拉框,就得先展开再找 ListItem。判断方法很简单:print_control_identifiers()里看它的control_type是ComboBox还是Pane。前者用select(),后者老老实实点开再找项。
5. 慢机器与弹窗面前不崩:等待、重试与异常兜底的写法
本地跑得好好的脚本,换到同事那台配置低一档的电脑上,或者放到深夜的批处理里跑,十分钟就开始各种报错——这类问题九成是时序问题。pywinauto 的默认等待时间很短,程序稍微卡一下就找不到控件。
最直接的解决办法是到处time.sleep(3),这也是我见过最多的写法。它能跑,但代价很大:本来两分钟的流程因为塞了几十个sleep变成八分钟,而且还是会偶发失败,因为你永远不知道"3 秒够不够"。
5.1 wait 家族的正确用法
pywinauto 提供了基于条件轮询的等待,核心是wait()和wait_not()。它们会反复检查直到条件满足或者超时,而不是死等固定时间。
dlg.wait("ready", timeout=20) # 等窗口进入可交互状态 dlg.wait("visible", timeout=10) dlg.wait("enabled", timeout=10) btn = dlg.child_window(auto_id="btnQuery", control_type="Button") btn.wait("enabled", timeout=15) btn.click()wait()支持的条件有几个:exists、visible、enabled、ready、active。日常用得最多的是ready(窗口准备好接受输入)和enabled(控件可用)。等一个操作结果时,我更常用exists(),它带超时参数,返回布尔值,适合放进自定义轮询里。
def wait_result(dlg, timeout=30, interval=0.5): import time deadline = time.time() + timeout while time.time() < deadline: if dlg.child_window(auto_id="lblResult", control_type="Text").exists(timeout=0.3): return dlg.child_window(auto_id="lblResult", control_type="Text").window_text() time.sleep(interval) raise TimeoutError("结果区域 30 秒内未出现")这种手写轮询看起来朴素,但在处理"点了查询之后等结果刷新"这类异步场景时比什么都好用,因为你能精确控制轮询间隔和判断条件。
5.2 弹窗拦截:不定时冒出来的东西
业务系统最烦的就是随机弹窗:登录超时提示、版本更新通知、网络异常重试框。这些弹窗出现时间不确定,如果不处理,后面所有操作都会落到错误的窗口上。
我的做法是在关键步骤前加一个"清弹窗"函数,扫描当前进程的所有顶层窗口,按标题特征关掉已知的干扰项。
def dismiss_popups(app, keywords=("提示", "警告", "更新")): for w in app.windows(): try: text = w.window_text() if any(k in text for k in keywords): for btn_title in ("确定", "关闭", "知道了"): btn = w.child_window(title=btn_title, control_type="Button") if btn.exists(timeout=0.3): btn.click() break except Exception: continue这个函数要在每个大步骤开头调一次,成本很低(每个窗口探测 0.3 秒),但能挡掉大量偶发失败。注意except Exception: continue这里是有意为之——弹窗检测本身不该成为新的失败源。
5.3 失败重试的粒度设计
重试不是简单地"整段包一层 try",粒度过粗会导致前面已经完成的步骤被重复执行,比如"提交"按钮被点了两次。我的经验是把重试放在单个原子操作这一层,而不是整个流程。
def retry(times=3, delay=1.0): def deco(fn): def wrapper(*args, **kwargs): last = None for i in range(times): try: return fn(*args, **kwargs) except Exception as e: last = e import time time.sleep(delay * (i + 1)) raise last return wrapper return deco @retry(times=3, delay=0.5) def click_export(dlg): dlg.child_window(auto_id="btnExport", control_type="Button").click_input()退避时间用delay * (i + 1)递增,而不是固定间隔。因为在时序问题上,程序往往需要一点"喘息"时间才能缓过来,固定间隔的密集重试反而容易一直撞在同一个卡点上。
注意:涉及"提交订单""确认付款"这类不可逆操作时,重试必须极其谨慎。判断标准是操作本身是否幂等——如果不确定上一次到底成功没有,正确做法是先去查询结果状态,而不是直接再点一次。
6. 权限、DPI 缩放、最小化窗口、中文输入:四个半夜挂掉的隐形原因
这一节讲的四个问题,共同点是:本地调试时完全看不出来,一旦放到真实环境就必现。它们的报错信息往往还很误导人,比如权限问题会报"找不到控件",让你以为定位写错了。
6.1 权限不对齐,是"找不到控件"的头号原因
Windows 有 UIPI 机制:低权限进程没法向高权限进程发送窗口消息,也没法读取它的 UIA 信息。如果你的目标程序是以管理员身份启动的,而你的 Python 脚本是普通权限,print_control_identifiers()可能只打出一个空壳,child_window()怎么都找不到。
判断方法很简单:用任务管理器看目标进程的"特权"列,或者直接右键目标程序"以管理员身份运行",再对比脚本在两种权限下的表现。
解决方式就是把脚本也提权。如果用 PyCharm 之类的 IDE 调试,注意 IDE 本身要提权,因为子进程会继承父进程的权限——这一点很多人会忽略,反复提权运行.py文件却忘了 IDE 是普通权限启动的。
6.2 高 DPI 缩放让坐标整体偏移
现在笔记本默认缩放普遍是 125% 或 150%。在没做 DPI 感知处理的进程里,click_input()算出来的物理坐标会整体偏移,表现为"点到了旁边的控件"。而click()走消息机制不受影响,所以会出现同一个脚本里有的地方正常有的地方偏移的怪现象。
最省事的处理是在脚本入口设置进程 DPI 感知:
import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(2) # 每显示器 DPI 感知 except Exception: try: ctypes.windll.user32.SetProcessDPIAware() except Exception: pass这段代码要放在import pywinauto之前或者至少在创建 Application 之前执行,否则不生效。设置完如果还用click_input(),建议跑一遍验证:让脚本点一个你确定位置的按钮,看是否真的点中。
6.3 最小化或锁屏状态下,真实输入类方法会失效
click_input()和type_keys()依赖真实的输入队列。窗口最小化之后控件没有实际屏幕坐标,这两个方法会失败或者点到别的地方去。更麻烦的是远程桌面断开连接、屏幕锁定这些情况——此时会话进入无交互状态,真实输入基本瘫痪。
如果你的脚本必须在无人值守的夜里跑,路线只有两条:
- 全部改用
invoke()、click()、set_text()这类基于消息或 UIA 模式的方法,尽量不碰真实输入 - 保证脚本运行在已登录且未锁屏的会话里,比如用计划任务配置成"只在用户登录时运行",并关闭屏幕保护和自动锁屏
第二条是很多批处理脚本的通病:配置成"不管用户是否登录"运行时,进程其实落在 Session 0,根本没有桌面,pywinauto 连窗口都看不到。这个错误极其难自查,因为脚本不报错,只是在某个wait上超时。我第一次遇到时排查了大半天,最后是从日志里发现"窗口一个都没找到"才反应过来。
6.4 中文输入的两种可靠做法
前面提过type_keys()对中文支持不好,实际表现是打出来乱码或者完全没反应。有两个办法可以绕过。
第一个办法是走剪贴板:把中文写进剪贴板,然后type_keys("^v")粘进去。这个方法对绝大多数控件都有效,缺点是会污染用户的剪贴板,需要临时保存和还原。
import win32clipboard def set_clipboard(text): win32clipboard.OpenClipboard() win32clipboard.EmptyClipboard() win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT) win32clipboard.CloseClipboard() edit = dlg.child_window(auto_id="txtRemark", control_type="Edit") edit.click_input() set_clipboard("第三季度对账说明") edit.type_keys("^v")第二个办法是直接用set_text()。它对中文的支持没问题,因为它是设置属性而不是模拟按键。如果控件有输入校验,再补一次type_keys("{TAB}")触发失焦事件即可。我现在的默认选择是第二条,只有遇到明确不支持set_text()的控件才动剪贴板。
7. 交付给别人跑:脚本结构化、日志与打包的落地做法
一个只能在自己电脑上跑的脚本,和一个能交给同事双击执行的工具,中间隔着不少工程量。这部分内容在教程里很少被提,但它决定了你做出来的东西是自己用还是别人也能用。
7.1 用页面对象把界面细节收敛到一处
最初我写脚本是流水账式的,从上到下几百行,中间穿插着child_window()。等界面改版,我得在几百行里找出所有相关定位去改,改漏一个就半夜报错。
后来改成按"窗口"拆分,每个窗口一个类,把所有控件定位和操作封装成方法,业务逻辑里只调方法不碰定位串。这样界面变了,通常只改一个文件。
class MainWindow: def __init__(self, app): self.app = app self.win = app.window(title_re=".*对账系统.*") def __getattr__(self, name): return getattr(self.win, name) def input_client(self, code): self.win.child_window(auto_id="txtClient", control_type="Edit").set_text(code) def query(self): self.win.child_window(auto_id="btnQuery", control_type="Button").click_input() def read_result(self): import time deadline = time.time() + 30 node = self.win.child_window(auto_id="lblResult", control_type="Text") while time.time() < deadline: if node.exists(timeout=0.3): return node.window_text() time.sleep(0.5) raise TimeoutError("查询结果未返回")这种写法还有个隐形好处:定位串集中之后,你可以在一个地方统一加上depth限制、统一改成auto_id,性能优化也变得容易。
7.2 日志和失败截图,是排查线上问题的唯一抓手
夜里三点脚本挂了,早上你到工位只看到退出码是 1,什么信息都没有——这种经历有过一次就够了。我的脚本里会固定做三件事:
- 每个关键步骤打一行日志,包含步骤名、耗时、关键参数
- 所有异常捕获之后打完整 traceback,而不是只打
str(e) - 失败时立刻把当前窗口截一张图存到日志目录
import logging, os, time LOG_DIR = r"D:\auto\log" os.makedirs(LOG_DIR, exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=[ logging.FileHandler(os.path.join(LOG_DIR, "run.log"), encoding="utf-8"), logging.StreamHandler(), ], ) def safe_step(name, fn, *args, **kwargs): t0 = time.time() try: result = fn(*args, **kwargs) logging.info("%s 成功 耗时%.2fs", name, time.time() - t0) return result except Exception: logging.exception("%s 失败 耗时%.2fs", name, time.time() - t0) raise截图这块要注意,capture_as_image()在某些场景下会失败(比如窗口被遮挡、DPI 缩放异常),所以截图本身也要包在 try 里,别让它成为新的崩溃点。另外建议在异常时顺手把print_control_identifiers()的输出重定向到文件,这样事后能精确比对"失败那一刻界面上到底有什么"。
def dump_on_failure(window, tag): path = os.path.join(LOG_DIR, f"{tag}.tree.txt") with open(path, "w", encoding="utf-8") as f: window.print_control_identifiers(depth=4, filename=f)print_control_identifiers()支持filename参数直接写文件,比先捕获 stdout 再写文件干净得多。
7.3 打包分发时的几个实际注意事项
用 PyInstaller 打包 pywinauto 脚本,最容易遇到的是依赖漏打。常规的--onefile参数之外,通常还要显式带上pywin32和comtypes的相关模块,以及pywinauto的后端注册表。
pyinstaller -F -w run.py ^ --hidden-import=win32timezone ^ --hidden-import=comtypes.stream ^ --collect-submodules=pywinauto打包之后还有两个运维层面的问题要提前想好。一是杀毒软件的误报,--onefile出来的单文件程序被拦的概率不低,分批部署的时候最好提前和目标环境的运维沟通,或者改用--onedir避免自解压行为。二是配置文件的位置——把界面定位串、路径、账号这些放在外部的config.ini里,不要硬编码进 exe,否则改一个按钮 ID 就要重新打包分发一次,非常难受。
最后还有一个我自己踩过的坑:--onefile打包的程序启动时会把内容解压到临时目录,如果脚本里用了os.path.dirname(__file__)去找同目录下的配置文件,路径会指向那个临时目录而不是 exe 所在目录。正确写法是用sys.executable的目录。
import sys, os def app_dir(): if getattr(sys, "frozen", False): return os.path.dirname(sys.executable) return os.path.dirname(os.path.abspath(__file__))这个东西看着不起眼,但如果你把日志目录、配置目录都基于它来算,打包分发环节基本不会出问题。反过来,用__file__写的脚本本地跑得好好的,打包之后配置文件"神秘消失",排查起来又是一两个小时。
我在实际使用中的体会是,pywinauto 这类工具真正的门槛不在 API——它的 API 数量不多,两小时能过一遍。门槛在于对目标程序的理解:它的窗口层级怎么组织的、哪些操作是异步的、哪些地方会弹不该弹的东西。这些东西没有文档,只能靠print_control_identifiers()一遍遍看,靠日志一点点积累。所以别急着把流程一次性写完,先把每个窗口的控件树截图存下来,整理成一份自己的"界面地图",后面写代码会快很多,改版时也知道该动哪里。