桌面 UI 自动化实战:用 Lua 编排流程,用 C# 做执行层

桌面 UI 自动化实战:用 Lua 编排流程,用 C# 做执行层

适用读者:Windows 桌面开发、办公自动化、批量处理场景
涉及语言:Lua(流程编排)、C#(底层执行)、易语言(对照说明)
演示对象:系统自带的「记事本」

一、为什么要分成两层

很多人写自动化程序,习惯把所有逻辑塞进一个文件里:找到窗口、截图、判断、输入、等待……全写在一起。

这种写法有个致命问题:每次改一点流程,就要重新编译、重新发布。

更好的做法是分层:

层 语言 职责 特点
编排层 Lua 描述"先做什么、后做什么" 文本文件,改完直接生效
执行层 C# 提供"找窗口、截图、输入"这些能力 编译一次,长期不变

这不是纸上谈兵——桌面客户端、工具软件乃至大型应用的热更新方案,底层逻辑就是这个:稳定的底层用编译型语言,易变的流程用脚本语言。流程变了只改脚本,不用动主程序。

二、执行层:C# 提供的四个基本能力

自动化的本质就四件事:找得到、看得见、点得动、等得起。

2.1 找得到 —— 窗口定位

using System;
using System.Runtime.InteropServices;
using System.Text;

namespace AutoCore
{
    public static class Win
    {
        [DllImport("user32.dll", SetLastError = true)]
        private static extern IntPtr FindWindow(string lpClassName, string lpWindowName);

        [DllImport("user32.dll", SetLastError = true)]
        private static extern IntPtr FindWindowEx(IntPtr hwndParent, IntPtr hwndChildAfter,
                                                 string lpszClass, string lpszWindow);

        [DllImport("user32.dll")]
        private static extern bool SetForegroundWindow(IntPtr hWnd);

        // 按标题找顶层窗口
        public static IntPtr Find(string title)
        {
            return FindWindow(null, title);
        }

        // 找顶层窗口下的子窗口(比如记事本里的编辑区)
        public static IntPtr FindChild(IntPtr parent, string className)
        {
            return FindWindowEx(parent, IntPtr.Zero, className, null);
        }

        // 轮询等待窗口出现,避免"程序还没打开就去找"
        public static IntPtr WaitFor(string title, int timeoutMs = 5000)
        {
            int waited = 0;
            while (waited < timeoutMs)
            {
                IntPtr h = Find(title);
                if (h != IntPtr.Zero) return h;
                System.Threading.Thread.Sleep(100);
                waited += 100;
            }
            return IntPtr.Zero;
        }
    }
}

关键点:WaitFor 是新手最容易忽略的一环。程序启动需要时间,直接 FindWindow 常常拿到空句柄。用轮询等待,稳定性立刻上一个台阶。

2.2 看得见 —— 后台截图

不把窗口切到前台也能拿到它的画面,这是"后台自动化"的基础。

[DllImport("user32.dll")]
private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags);

[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hwnd, out RECT lpRect);

[StructLayout(LayoutKind.Sequential)]
public struct RECT { public int Left, Top, Right, Bottom; }

public static Bitmap Capture(IntPtr hwnd)
{
    GetWindowRect(hwnd, out RECT rc);
    int w = rc.Right - rc.Left;
    int h = rc.Bottom - rc.Top;
    if (w <= 0 || h <= 0) return null;

    Bitmap bmp = new Bitmap(w, h);
    using (Graphics g = Graphics.FromImage(bmp))
    {
        IntPtr hdc = g.GetHdc();
        PrintWindow(hwnd, hdc, 0);   // 0 = 只拷贝可见区域
        g.ReleaseHdc(hdc);
    }
    return bmp;
}

拿到 Bitmap 之后,就可以做像素比对、区域取色——用来判断"当前界面处于什么状态"。

2.3 点得动 —— 后台输入

有两种输入方式,区别很大:

方式 API 特点
前台模拟 SendInput / mouse_event 真实移动鼠标键盘,会被其他操作打断
后台消息 SendMessage / PostMessage 直接发给目标窗口,窗口被遮挡也能生效

批量处理场景一律用后台消息:

[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, string lParam);

private const uint WM_SETTEXT = 0x000C;
private const uint WM_CHAR   = 0x0102;
private const uint WM_CLOSE  = 0x0010;

// 一次性设置文本(适合输入框)
public static void SetText(IntPtr hwnd, string text)
{
    SendMessage(hwnd, WM_SETTEXT, IntPtr.Zero, text);
}

// 逐字符输入(适合需要触发按键事件的场景)
public static void TypeText(IntPtr hwnd, string text)
{
    foreach (char c in text)
        SendMessage(hwnd, WM_CHAR, (IntPtr)c, IntPtr.Zero);
}

// 关闭窗口
public static void Close(IntPtr hwnd)
{
    SendMessage(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
}

踩坑提醒:WM_SETTEXT 对标准控件(Edit、RichEdit)有效,但对自绘控件可能无效。判断不了的时候,用 TypeText 逐字符发,兼容性更好,代价是慢一些。

2.4 等得起 —— 状态等待

自动化最忌讳 Thread.Sleep(3000) 这种硬等。正确做法是轮询目标状态:

// 等到某个条件成立,或超时
public static bool WaitUntil(Func<bool> condition, int timeoutMs = 5000)
{
    var sw = System.Diagnostics.Stopwatch.StartNew();
    while (sw.ElapsedMilliseconds < timeoutMs)
    {
        if (condition()) return true;
        System.Threading.Thread.Sleep(50);
    }
    return false;
}

三、编排层:Lua 描述流程

C# 把上面这些能力暴露给 Lua 调用,流程就变成了一份配置文件。

3.1 C# 侧暴露接口(用 NLua)

using NLua;

public class ScriptHost
{
    private Lua _lua;

    public ScriptHost()
    {
        _lua = new Lua();
        _lua["Win"] = new WinApi();   // 把 C# 对象注入 Lua
    }

    public void Run(string luaFile)
    {
        _lua.DoFile(luaFile);
    }
}

// 供 Lua 调用的门面类
public class WinApi
{
    public IntPtr Find(string title)        => Win.Find(title);
    public IntPtr WaitFor(string title, int ms = 5000) => Win.WaitFor(title, ms);
    public void SetText(IntPtr h, string s) => Win.SetText(h, s);
    public void TypeText(IntPtr h, string s)=> Win.TypeText(h, s);
    public void Close(IntPtr h)             => Win.Close(h);
    public void Sleep(int ms)               => System.Threading.Thread.Sleep(ms);
    public void Log(string s)               => Console.WriteLine(s);
}

3.2 Lua 侧:一份可编辑的流程

-- task_notepad.lua
-- 功能:批量创建 txt 文档并录入内容

local files = {
    { name = "日报-周一", content = "今日完成:\n1. 数据整理\n2. 报表导出" },
    { name = "日报-周二", content = "今日完成:\n1. 会议纪要\n2. 进度同步" },
    { name = "日报-周三", content = "今日完成:\n1. 接口联调\n2. 文档更新" },
}

for _, item in ipairs(files) do
    -- 1. 启动记事本
    Win.Launch("notepad.exe")

    -- 2. 等窗口出现(轮询,不是硬等)
    local hwnd = Win.WaitFor("无标题 - 记事本", 5000)
    if hwnd == 0 then
        Win.Log("[跳过] 窗口未出现:" .. item.name)
        goto continue
    end

    -- 3. 找到编辑区子窗口
    local edit = Win.FindChild(hwnd, "Edit")
    if edit == 0 then
        Win.Log("[跳过] 未找到编辑区:" .. item.name)
        goto continue
    end

    -- 4. 后台输入内容
    Win.SetText(edit, item.content)

    -- 5. 触发保存(键盘消息:Ctrl+S)
    Win.SendKey(hwnd, "Ctrl+S")
    Win.Sleep(300)

    -- 6. 在保存对话框里填文件名
    local dlg = Win.WaitFor("另存为", 3000)
    if dlg ~= 0 then
        local nameEdit = Win.FindChild(dlg, "Edit")
        Win.SetText(nameEdit, item.name)
        Win.SendKey(dlg, "Enter")
        Win.Sleep(200)
    end

    -- 7. 关闭记事本
    Win.Close(hwnd)
    Win.Log("[完成] " .. item.name)

    ::continue::
end

这份文件的价值:流程逻辑全在 Lua 里,改文件名、改内容、增删步骤,改完直接跑,C# 主程序一次都不用重编。

四、状态机:让程序"看得懂当前在哪一步"

上面的写法是线性流程,只能处理"一路顺风"的情况。现实中会有弹窗、会有网络卡顿、会有意外路径。

这时要用状态机:每一步都先判断"我现在处于哪个状态",再决定做什么。

-- state_machine.lua
local State = {
    NONE    = 0,   -- 没有目标窗口
    MAIN    = 1,   -- 主界面
    DIALOG  = 2,   -- 有弹窗
    DONE    = 3,   -- 完成
}

-- 判断当前状态
local function detect()
    if Win.WaitFor("另存为", 300) ~= 0 then
        return State.DIALOG
    end
    if Win.WaitFor("无标题 - 记事本", 300) ~= 0 then
        return State.MAIN
    end
    return State.NONE
end

-- 状态转移表
local handlers = {
    [State.NONE] = function(ctx)
        Win.Launch("notepad.exe")
    end,

    [State.MAIN] = function(ctx)
        local hwnd = Win.WaitFor("无标题 - 记事本", 2000)
        local edit = Win.FindChild(hwnd, "Edit")
        Win.SetText(edit, ctx.content)
        Win.SendKey(hwnd, "Ctrl+S")
    end,

    [State.DIALOG] = function(ctx)
        local dlg  = Win.WaitFor("另存为", 2000)
        local edit = Win.FindChild(dlg, "Edit")
        Win.SetText(edit, ctx.name)
        Win.SendKey(dlg, "Enter")
        ctx.finished = true
    end,
}

-- 主循环
local ctx = { name = "测试文档", content = "Hello", finished = false }
local guard = 0

while not ctx.finished and guard < 100 do
    local st = detect()
    local fn = handlers[st]
    if fn then fn(ctx) else Win.Log("未知状态,等待中") end
    Win.Sleep(100)
    guard = guard + 1   -- 防止死循环
end

这个写法的三个好处:

  1. 可打断可恢复 —— 中途出错,下次运行会重新识别状态继续走
  2. 不依赖时序 —— 不用精确计算"第 3 秒会弹窗"
  3. 易扩展 —— 加一种情况,就是加一个 handler

guard 计数器很重要:状态机如果卡在某个状态来回跳,没有上限会跑满 CPU。

五、多窗口并行:同时处理多个任务

一个窗口处理太慢,批量场景要并发。做法是每个窗口一个独立线程,各自跑一份 Lua 状态机。

public void RunParallel(List<string> luaFiles)
{
    Parallel.ForEach(luaFiles, file =>
    {
        // 每个任务独立一个 Lua 虚拟机,避免变量串台
        using (Lua lua = new Lua())
        {
            lua["Win"] = new WinApi();
            lua.DoFile(file);
        }
    });
}

⚠️ 三个必须注意的点:

问题 原因 解法
变量串台 多个任务共用一个 Lua 实例 每个任务独立 new Lua()
前台焦点抢占 多线程同时 SetForegroundWindow 用后台消息,别抢焦点
资源竞争 多个任务写同一个文件 文件名加任务编号区分

并发数控制在 4–8 个为宜。 超过这个数,窗口切换和消息派发的开销会抵消并行带来的收益。

六、易语言怎么对应

同样的原理,用易语言写会更直观(全中文关键字),适合刚入门、不习惯英文 API 的读者:

.版本 2
.DLL命令 FindWindow, 整数型, "user32.dll", "FindWindowA"
    .参数 类名, 文本型
    .参数 标题, 文本型

.DLL命令 SendMessage, 整数型, "user32.dll", "SendMessageA"
    .参数 句柄, 整数型
    .参数 消息, 整数型
    .参数 参数1, 整数型
    .参数 参数2, 文本型

.子程序 录入文本
    .局部变量 窗口句柄, 整数型
    窗口句柄 = FindWindow (0, "无标题 - 记事本")
    .如果真 (窗口句柄 = 0)
        返回 ()
    .如果真结束
    SendMessage (窗口句柄, 12, 0, "Hello")   ' 12 = WM_SETTEXT

对比总结:

语言 优势 适合
C# 生态好、调试强、编并发方便 执行层首选
Lua 轻量、可热更、语法简单 流程编排首选
易语言 全中文、API 直观、上手快 快速验证想法

推荐组合:C# 做执行层 + Lua 做编排。易语言用来做原型验证,或者给不熟悉英文 API 的同事看逻辑。

七、常见坑与排查清单

现象 原因 排查方法
FindWindow 总返回 0 标题不完全匹配 用 Spy++ 看真实标题,或用模糊匹配
文本设置成功但界面没变 用错了控件句柄 确认拿到的是 Edit 子窗口,不是顶层窗口
后台消息无效 目标程序屏蔽了消息 改用前台模拟,或检查是否管理员权限
截图全黑 窗口最小化或用了硬件加速 先还原窗口,或改用 BitBlt
跑一会儿就卡死 没有超时保护 所有循环加 guard 上限和超时退出

调试建议:先在单线程、单窗口下把流程跑通,再上状态机,最后才加并发。一次只引入一个复杂度,出问题能立刻定位。

八、完整项目结构参考

AutoDemo/
├── AutoCore/              # C# 执行层(编译一次)
│   ├── Win.cs             # 窗口 API 封装
│   ├── Capture.cs         # 截图
│   ├── WinApi.cs          # 供 Lua 调用的门面
│   └── Program.cs         # 宿主入口
├── scripts/               # Lua 编排层(随时可改)
│   ├── task_notepad.lua
│   ├── state_machine.lua
│   └── config.lua
└── README.md

工作流程:

  1. C# 项目编译生成 AutoDemo.exe
  2. Lua 脚本放在 scripts/ 下
  3. 改流程 → 只改 .lua 文件 → 直接运行 exe

九、这套架构能用到哪

不只是批量处理文档。凡是"流程经常变、底层能力稳定"的场景都适合:

  • 批量数据录入与导出
  • 定时巡检与状态上报
  • 多系统之间的数据搬运
  • 软件安装配置的一键化
  • 界面回归测试(自动点一遍所有按钮)

核心思想只有一句:把"变"的部分和"不变"的部分分开。

写在最后

自动化不难,难的是稳定。

新手和老手的区别,往往不在于会不会调 API,而在于:

  • 有没有做轮询等待(而不是硬 sleep)
  • 有没有写超时保护(而不是死循环)
  • 有没有用状态机(而不是线性流程)
  • 有没有分层(而不是全塞一起)

把这四点做好,你的自动化程序就能从"能跑"变成"能长期跑"。

本文演示均基于系统自带的记事本程序,相关技术适用于 Windows 桌面环境下的办公自动化、批量处理与界面测试场景。


发布日期:

所属分类: 自动化教程 标签:  



下一篇:

没有了,已经是最新文章