从 Windows GUI 自动化到 Android 自动化:一套双端巡检脚本的重构过程
做自动化时,最常见的一种情况是:最初只有一个平台,脚本也围绕这个平台写得很顺;但一旦需求扩到第二个平台,原来的结构就会立刻暴露出边界。
我最近做的这个小项目就是这样。最开始它只是一个 Windows 桌面端的自动化巡检脚本,用来读取某个目标卡片上的状态信息,保存截图、记录 CSV,并在需要时生成报表。后来同样的业务流程又需要覆盖 Android 平板端,而且两个端的功能基本一致。问题也随之出现:Windows 端原本依赖的桌面 UI 自动化能力,显然不能直接照搬到 Android 上。
这篇文章记录的,就是这个项目从"单端 Windows 脚本"演进成"Windows / Android 双端自动化"的过程,以及中间几个关键的技术决策。
一、项目最初的形态
项目一开始很简单:Windows 端启动桌面客户端,连接窗口,处理登录,找到目标卡片,读取卡片中的业务文本,解析状态并保存截图和记录。
这条链路在 Windows 上完全成立,因为桌面应用的窗口和控件是可以通过 UI 自动化框架直接访问的。对应到代码里,Windows 端核心逻辑放在 app/gui_app.py,使用的是 pywinauto:
python
from pywinauto import Application
启动程序和连接窗口也是标准的 pywinauto 用法:
python
Application(backend="uia").start(EXE_PATH)
Application(backend="uia").connect(...)
这部分在单平台阶段运行得很稳定,问题出在需求扩展之后。
二、为什么不能直接把 Windows 方案搬到 Android
一开始最直观的想法,其实是借助投屏工具把 Android 平板画面投到 Windows 上,再沿用现有的 Windows 自动化方式去操作这个投屏窗口。
这个思路看起来省事,但很快就会遇到问题:你操作到的其实只是一个"视频窗口",不是 Android App 自身的控件树。也就是说:
- 你看到的是画面
- 自动化框架看到的是一个桌面窗口
- 并不能真正访问 Android 应用内部的控件信息
这种方式短期做 POC 可以,做长期可维护的自动化会非常脆弱。窗口大小、分辨率、缩放、黑边、坐标偏移,都会让脚本变得不稳定。
所以项目后面很快就放弃了这条路线,转而采用真正面向 Android UI 层的自动化方案。
三、Android 端的技术选择:Appium + UiAutomator2
Android 端最后采用的是:
AppiumUiAutomator2
对应代码在 app/android_app.py:
python
from appium import webdriver
from appium.options.android import UiAutomator2Options
配置里明确指定了 Android 平台和驱动类型:
python
options.platform_name = "Android"
options.automation_name = "UiAutomator2"
最终通过 webdriver.Remote(...) 建立与设备的会话:
python
webdriver.Remote(server_url, options=options)
这一点很重要。Windows 端和 Android 端最终采用的是两套完全不同的"驱动层":
- Windows 端:
pywinauto - Android 端:
Appium + UiAutomator2
而项目后面的重构核心,也正是围绕"驱动层分开,业务逻辑复用"展开的。
四、这个项目为什么没有用 OCR 当主方案
在做这类跨平台自动化时,很多人第一反应会想到 OCR。尤其当 Android 端是 Flutter 页面或者自绘页面时,OCR 看起来很像一种"通用解法"。
但在这个项目里,OCR 最终没有成为主方案。
原因很简单:OCR 更适合作为兜底,不适合作为主链路。只要还能拿到控件树、页面源、元素属性,就应该优先走 UI 自动化,而不是图像识别。
项目里最后真正跑通的方式,不是 OCR,而是直接读取页面元素属性。也正因为如此,公共解析逻辑最终可以沉淀为平台无关模块,而不依赖图像识别结果。
五、Android 端真正的突破口:content-desc
Android 端打通的过程并不是一帆风顺的。最开始 Appium 能连上设备,也能拿到 page_source,但按可见文字定位目标元素时,结果却一直是 0。
后来做了一步非常关键的检查:把页面源里前几十个节点的关键属性打出来,看看到底暴露了什么。
结果发现,这其实是一个典型的 Flutter 页面表现:
- 许多节点没有
text - 也没有明显的
resource-id - 但关键内容暴露在
content-desc上
这一步改变了后面整个 Android 端的定位策略。
原来按 text 查找元素不通,不代表页面不可自动化;很可能只是页面没有把文字暴露到 text 字段,而是放到了 content-desc。一旦确认这一点,就可以直接按 content-desc 定位目标卡片,并从中提取完整文本内容。
也正因为这个发现,Android 端后来能够稳定识别不同状态,例如:
- 某张目标卡片存在,但状态为"无值"
- 某张目标卡片存在,但状态为"未连接"
这些都不是 OCR 识别出来的,而是从 UI 元素属性中直接读取的。
六、重构的核心:执行层分端,业务层共用
在明确了 Windows 和 Android 需要两套不同驱动之后,项目的重构方向就很清晰了:
- 执行层分端
- 业务层共用
这里的"执行层"是平台相关部分,例如:
- 启动应用
- 连接窗口或设备
- 找目标卡片
- 截图
这里的"业务层"则是平台无关部分,例如:
- 解析卡片文本
- 判断状态
- 构造记录
- 生成报表
项目最后收敛成的核心模块包括:
app/gui_app.py
Windows 平台操作app/android_app.py
Android 平台操作app/parser.py
共享卡片文本解析app/recorder.py
CSV 写入app/report_generator.py
Excel 报表app/run_utils.py
统一构造记录数据
这样一来,平台差异就只剩下"怎么拿到原始文本"和"怎么截图",而状态判断、记录输出、报表生成都能共享。
七、单次执行和定时执行都做成了分端入口
为了让这套结构真正可用,项目没有只停留在"抽模块",而是进一步把入口脚本也整理成了分端形式。
单次执行入口:
test/windows_poc.pytest/android_poc.py
定时巡检入口:
main.py
Windows 定时执行android_main.py
Android 定时执行
这样做的好处是非常直接的:
- 单次验证和定时巡检职责清晰
- Windows 和 Android 可以分别调试
- 两端出问题时更容易定位
- 后面做平台参数化也更自然
八、统一入口是如何补上的
有了分端入口之后,日常使用已经可行了,但从体验上说,记住多个脚本路径依然不够方便。
所以后面又补了一个统一入口 runner.py,支持两个维度:
platformmode
例如:
powershell
python .\runner.py --platform windows --mode once
python .\runner.py --platform android --mode once
python .\runner.py --platform windows --mode scheduled
python .\runner.py --platform android --mode scheduled
这个统一入口一开始也踩了一个很典型的坑:子脚本自己有 argparse,而统一入口导入它们之后,子脚本又会继续读取 sys.argv,导致 runner.py 的参数被误当成子脚本参数。
后面通过在调用子入口前临时清理 sys.argv,才把这个问题解决掉。这类问题很小,但也说明一个事实:统一入口的价值不只是"少敲几个命令",它实际上是把原本散落的脚本收成了一个更清晰的调度层。
九、Windows 端和 Android 端分别踩过的坑
这个项目里,两个平台各自都有几个典型问题。
Windows 端的主要问题是窗口连接时机。最初直接拿 top_window(),在程序刚启动但界面还没准备好时,很容易报"找不到窗口"。后面改成:
- 按标题正则连接
- 重试等待
- 要求窗口
exists visible ready
连接稳定性就提升了很多。
另一个优化点是截图范围。最初 Windows 只截目标卡片,Android 则是整页截图,两端不一致。后来 Windows 也统一改成截整个应用窗口,这样后续分析和对比都更自然。
Android 端最大的问题则是冷启动配置。最开始 app_activity 写成了 MainActivity,但真正冷启动时会报:
Activity class ... does not exist
后来通过 adb 查出真实启动页,才确认正确写法应该是:
python
ANDROID_APP_ACTIVITY = ".MainActivity"
这个问题非常有代表性:当 App 已经在前台时,错误配置可能完全被掩盖;只有真正被杀死后重新启动,问题才会暴露。
十、这个项目最值得总结的经验
第一,平台驱动一定要和业务逻辑分开。
一个脚本在单平台时看起来怎么写都行,但一旦扩展到第二个平台,边界立刻会变得很重要。
第二,不要把 OCR 当默认答案。
能走 UI 自动化,就优先走 UI 自动化。OCR 更适合兜底,而不是主链路。
第三,Flutter 页面不要过早下结论说"不可自动化"。
先检查它暴露了什么属性,特别是 content-desc。很多时候突破口就在这里。
第四,统一入口不是可有可无。
当脚本数量开始变多时,一个调度层会极大改善使用体验。
第五,冷启动一定要测。
很多 Android 配置问题,只有在 App 被杀掉之后才会显现出来。