GST.Rpa.Automation — UI 自動化
| 項目 | 內容 |
|---|---|
| 適用對象 | GST 內部開發人員 |
| 前置知識 | C#/.NET 8/Windows UI Automation 基礎 |
| 閱讀時間 | 25 分鐘 |
| 最後更新 | 2026-04-04 |
| 版本 | 1.0.0 |
1. 模組職責
GST.Rpa.Automation 是整個 RPA 系統的 UI 自動化層,負責與 Windows 桌面應用程式的 UI 元素互動。此模組以 FlaUI 做為 UIA3 (UI Automation 3) 的 wrapper,對上層提供一組統一的掃描、定位、操控與監控 API。
核心職責:
- 元素掃描 — 列舉視窗內所有 UI 元素,產生
UIElementInfo樹狀結構 - 元素定位 — 透過多種策略(AutomationId / 屬性比對 / 樹狀路徑)找到目標元素
- 元素操控 — 點擊、輸入值、拖放、送鍵盤指令等自動化操作
- 值變監控 — 背景輪詢指定元素,值變化時觸發事件通知
- 高階 API — 透過
RpaApp提供 attach/launch/screenshot/close 等應用程式層級操作
2. NuGet 引用與相依
套件版本(來自 Directory.Packages.props)
| 套件 | 版本 |
|---|---|
FlaUI.Core | 5.0.0 |
FlaUI.UIA3 | 5.0.0 |
Microsoft.Extensions.Logging.Abstractions | 8.0.2 |
專案參考
<ProjectReference Include="..\GST.Rpa.Core\GST.Rpa.Core.csproj" />
<ProjectReference Include="..\GST.Rpa.Infrastructure\GST.Rpa.Infrastructure.csproj" />
Target Framework:
net8.0-windows
3. RpaApp 高階 API
RpaApp 是 Automation 模組的 主要進入點,封裝了「連線到目標程式 → 掃描 → 操作 → 關閉」的完整生命週期。
類別簽章
namespace GST.Rpa.Automation;
public class RpaApp
{
public RpaApp(ProcessInfo process, IElementScanner scanner, IAutomationController controller);
// 屬性
public string ProcessName { get; } // 附加的 Process 名稱
public IntPtr WindowHandle { get; } // 主視窗 Handle
public IElementScanner Scanner { get; } // 底層元素掃描器
public IAutomationController Controller { get; } // 底層自動化控制器
// 靜態工廠方法
public static Task<RpaApp> AttachAsync(
string processName,
IElementScanner scanner,
IAutomationController controller);
public static Task<RpaApp> LaunchAsync(
string exePath,
IElementScanner scanner,
IAutomationController controller,
string? arguments = null);
// 實例方法
public UIElementInfo? FindWindow(string? title = null);
public byte[] Screenshot();
public void Close();
}
方法說明
| 方法 | 說明 |
|---|---|
AttachAsync | 依 Process 名稱找到執行中的程式並附加;找不到時拋出 InvalidOperationException |
LaunchAsync | 啟動 exe 並等待主視窗出現(最多 5 秒輪詢),逾時自動 Kill 並拋出例外 |
FindWindow | 掃描 depth=1 取得視窗元素,可選擇性以 title 做子字串比對 |
Screenshot | 擷取主視窗所在矩形範圍的螢幕截圖,回傳 PNG byte[] |
Close | 呼叫 CloseMainWindow() 關閉目標程式;Process 已結束時靜默處理 |
4. 核心介面實作
4.1 IAutomationController → FlaUIAutomationController
提供對 UI 元素的直接操作能力。內部透過 RuntimeId 尋找 FlaUI AutomationElement,並在操作前以 EnsureInteractable 驗證元素可用性。
namespace GST.Rpa.Core.Abstractions;
public interface IAutomationController
{
// 基本操作
void Click(UIElementInfo element);
void DoubleClick(UIElementInfo element);
void SetValue(UIElementInfo element, string value);
string GetValue(UIElementInfo element);
// 進階操作
void DragDrop(UIElementInfo source, UIElementInfo target);
void ScrollTo(UIElementInfo element);
void SendKeys(UIElementInfo element, string keys);
void RightClick(UIElementInfo element);
void SetFocus(UIElementInfo element);
}
實作細節
namespace GST.Rpa.Automation;
public class FlaUIAutomationController : IAutomationController, IDisposable
{
public FlaUIAutomationController(ILogger<FlaUIAutomationController> logger);
}
值的讀寫策略:
SetValue— 優先嘗試 UIA Value Pattern,失敗時 fallback 到Focus() + AsTextBox().TextGetValue— 依序嘗試 Value Pattern → Text Pattern →Name屬性DragDrop— 計算兩元素的中心座標,透過Mouse.Down()/Mouse.MoveTo()/Mouse.Up()模擬拖放SendKeys— 先Focus()元素,再透過Keyboard.Type()送出按鍵
互動前驗證 (EnsureInteractable):
- 若
IsEnabled == false,拋出ElementNotInteractableException - 若
IsOffscreen == true,拋出ElementNotInteractableException
4.2 IElementScanner → FlaUIElementScanner
負責掃描視窗 UI 樹狀結構,產出 UIElementInfo[]。
namespace GST.Rpa.Core.Abstractions;
public interface IElementScanner
{
UIElementInfo[] ScanWindow(IntPtr windowHandle);
UIElementInfo[] ScanWindow(IntPtr windowHandle, int maxDepth);
UIElementInfo[] GetChildren(UIElementInfo parent);
UIElementInfo? FindElement(IntPtr windowHandle, string automationId);
ProcessInfo[] GetRunningProcesses();
UIElementInfo? FindElementFromPoint(Point screenPoint);
}
實作細節
namespace GST.Rpa.Automation;
public class FlaUIElementScanner : IElementScanner
{
public FlaUIElementScanner(ILogger<FlaUIElementScanner> logger);
}
關鍵行為:
ScanWindow(handle)預設以maxDepth = int.MaxValue遞迴掃描整棵元素樹GetChildren要求 parent 必須帶有RuntimeId,否則拋出ArgumentExceptionFindElement使用 FlaUI 的FindFirstDescendant(cf => cf.ByAutomationId(...))快速查詢GetRunningProcesses過濾出MainWindowHandle != IntPtr.Zero且具有MainWindowTitle的 ProcessFindElementFromPoint透過UIA3Automation.FromPoint()以螢幕座標定位元素
RuntimeId 持久性: RuntimeId 是 UIA 框架為每個 element 產生的唯一識別碼(int[]),在 element 生命週期內不變。FlaUIElementScanner 於轉換時保留 RuntimeId,供後續 FlaUIAutomationController 以遞迴方式重新定位該元素。
4.3 IElementMonitor → FlaUIElementMonitor
以 背景 Timer 輪詢 監控指定 UI 元素的值變化,發現變動時觸發 ValueChanged 事件。
namespace GST.Rpa.Core.Abstractions;
public interface IElementMonitor
{
void StartMonitoring(UIElementInfo[] elements, TimeSpan interval);
void StopMonitoring();
void AddElement(UIElementInfo element);
void RemoveElement(UIElementInfo element);
bool IsMonitoring { get; }
event EventHandler<ElementValueChangedEventArgs>? ValueChanged;
}
ElementValueChangedEventArgs
namespace GST.Rpa.Core.Events;
public class ElementValueChangedEventArgs : EventArgs
{
public ElementValueChangedEventArgs(UIElementInfo element, string oldValue, string newValue);
public UIElementInfo Element { get; }
public string OldValue { get; }
public string NewValue { get; }
public DateTime Timestamp { get; } // DateTime.Now at detection time
}
實作細節
namespace GST.Rpa.Automation;
public class FlaUIElementMonitor : IElementMonitor, IDisposable
{
public FlaUIElementMonitor(ILogger<FlaUIElementMonitor> logger, IAutomationController controller);
}
運作機制:
StartMonitoring時,透過IAutomationController.GetValue()讀取各元素初始值並存入Dictionary<UIElementInfo, string>- 以
System.Threading.Timer依指定interval週期性呼叫CheckValues CheckValues逐一比較目前值與上次記錄值,不同時觸發ValueChanged事件- 可動態呼叫
AddElement/RemoveElement增減監控對象 - 重複呼叫
StartMonitoring會先StopMonitoring再重新初始化
5. 元素定位策略
元素定位是 UI 自動化最關鍵的環節。本模組實作了 Strategy Pattern,透過 IElementLocator 介面抽象各種定位策略,再以 CompositeLocator 組合成 fallback chain。
5.1 IElementLocator 介面
namespace GST.Rpa.Core.Abstractions;
public interface IElementLocator
{
LocatorStrategy Strategy { get; }
int Priority { get; }
Task<UIElementInfo?> LocateAsync(
IntPtr windowHandle,
LocatorCriteria criteria,
CancellationToken cancellationToken = default);
}
namespace GST.Rpa.Core.Enums;
public enum LocatorStrategy
{
AutomationId = 1,
Property = 2,
TreePath = 3,
Image = 4, // 保留給未來 OpenCV 整合
Composite = 5
}
5.2 AutomationIdLocator (Priority = 1)
最快且最穩定的策略。直接以 IElementScanner.FindElement() 比對 AutomationId。
namespace GST.Rpa.Automation.Locators;
public class AutomationIdLocator(IElementScanner scanner) : IElementLocator
{
public LocatorStrategy Strategy => LocatorStrategy.AutomationId;
public int Priority => 1;
public Task<UIElementInfo?> LocateAsync(
IntPtr windowHandle, LocatorCriteria criteria, CancellationToken cancellationToken = default);
}
- 若
criteria.AutomationId為空,直接回傳null(讓 CompositeLocator 嘗試下一個策略)
5.3 PropertyLocator (Priority = 2)
以 Name、ControlType、ClassName 屬性組合比對。掃描視窗元素樹(深度上限 50 層),取第一個完全符合所有已設定屬性的元素。
namespace GST.Rpa.Automation.Locators;
public class PropertyLocator(IElementScanner scanner) : IElementLocator
{
private const int ScanDepth = 50;
public LocatorStrategy Strategy => LocatorStrategy.Property;
public int Priority => 2;
public Task<UIElementInfo?> LocateAsync(
IntPtr windowHandle, LocatorCriteria criteria, CancellationToken cancellationToken = default);
}
- 三個屬性皆為空時直接回傳
null - 僅比對有設定值的屬性(未設定的屬性視為 wildcard)
5.4 TreePathLocator (Priority = 3)
透過 ControlType 階層路徑(如 "Window/Pane/Button")從根節點逐層導航到目標元素。
namespace GST.Rpa.Automation.Locators;
public class TreePathLocator(IElementScanner scanner) : IElementLocator
{
public LocatorStrategy Strategy => LocatorStrategy.TreePath;
public int Priority => 3;
public Task<UIElementInfo?> LocateAsync(
IntPtr windowHandle, LocatorCriteria criteria, CancellationToken cancellationToken = default);
}
導航邏輯:
- 以
/分割criteria.TreePath取得各層 segment - 先在 depth=1 找到 ControlType 符合第一個 segment 的根元素
- 逐層呼叫
scanner.GetChildren(current)找下一個 segment 對應的子元素 - 任一層找不到匹配即回傳
null
5.5 CompositeLocator (Priority = 0)
以 Chain of Responsibility 模式將多個 IElementLocator 依 Priority 排序後依序嘗試,回傳第一個成功的結果。
namespace GST.Rpa.Automation.Locators;
public class CompositeLocator : IElementLocator
{
public CompositeLocator(IEnumerable<IElementLocator> locators, ILogger<CompositeLocator> logger);
public LocatorStrategy Strategy => LocatorStrategy.Composite;
public int Priority => 0;
public async Task<UIElementInfo?> LocateAsync(
IntPtr windowHandle, LocatorCriteria criteria, CancellationToken cancellationToken = default);
}
關鍵設計:
- 建構時自動過濾掉
CompositeLocator本身,避免無限遞迴 - 以
OrderBy(l => l.Priority)排序:AutomationId (1) → Property (2) → TreePath (3) - 任一 locator 回傳非
null即立即回傳,不再嘗試後續策略 - 支援
CancellationToken中斷 - 單一 locator 例外時記錄 debug log 後繼續嘗試下一個
5.6 策略選擇指南
| 優先序 | 策略 | 優點 | 缺點 | 適用時機 |
|---|---|---|---|---|
| 1 | AutomationId | 最快、最穩定、不受 UI 佈局變動影響 | 目標元素需有 AutomationId | 開發者有設定 AutomationId 的應用程式 |
| 2 | Name + ControlType | 不依賴 AutomationId,人類可讀 | 可能有重複名稱、多語系導致不穩定 | AutomationId 不可用時的次選 |
| 3 | TreePath | 不需任何唯一識別屬性 | UI 佈局變動即失效,效能較差 | 最後手段,適合結構穩定的靜態介面 |
優先使用 AutomationId。錄製元素時應盡可能保留 AutomationId,並以 CompositeLocator 的 fallback 機制確保在 AutomationId 缺失時仍能定位。
6. 回放執行流程
把 IElementScanner、CompositeLocator、IAutomationController、IElementMonitor 串成一條典型的「腳本回放」流程後,每個步驟對應的決策路徑與錯誤分支如下:
對照模組各介面的職責:
- Locate:由
CompositeLocator串接AutomationIdLocator → PropertyLocator → TreePathLocator(見 §5),任一策略找到即停 - Verify:
FlaUIAutomationController.EnsureInteractable在執行前檢查IsEnabled/IsOffscreen(見 §4.1) - Action:經
IAutomationController.Click / SetValue / DragDrop / SendKeys等執行,內部以 RuntimeId 重新定位 FlaUI element - StateCheck(選用):可選擇用
GetValue或IElementMonitor.ValueChanged確認 UI 真正改變後再進入下一個 Action - Recover:失敗時依
RpaState.ErrorRecovery(Retry / Skip / Abort,見 Core 模組)決定後續路徑
7. 資料模型
UIElementInfo
namespace GST.Rpa.Core.Models;
public record UIElementInfo(
string Name,
string AutomationId,
string ControlType,
string ClassName,
Rectangle BoundingRectangle,
bool IsEnabled,
bool IsOffscreen,
int ProcessId,
int[]? RuntimeId = null
)
{
public string DisplayName => !string.IsNullOrEmpty(Name) ? Name :
!string.IsNullOrEmpty(AutomationId) ? AutomationId :
ControlType;
}
| 屬性 | 型別 | 說明 |
|---|---|---|
Name | string | 元素顯示名稱(Label text) |
AutomationId | string | UIA AutomationId,開發者設定的穩定識別碼 |
ControlType | string | 控制項類型(Button、TextBox、ComboBox 等) |
ClassName | string | Win32 class name |
BoundingRectangle | Rectangle | 元素在螢幕上的座標與尺寸 |
IsEnabled | bool | 是否可互動 |
IsOffscreen | bool | 是否在畫面可見範圍外 |
ProcessId | int | 所屬 Process ID |
RuntimeId | int[]? | UIA RuntimeId,元素生命週期內唯一 |
DisplayName | string | 計算屬性,依序取 Name → AutomationId → ControlType |
LocatorCriteria
namespace GST.Rpa.Core.Models;
public record LocatorCriteria
{
public string? AutomationId { get; init; }
public string? Name { get; init; }
public string? ControlType { get; init; }
public string? ClassName { get; init; }
public string? TreePath { get; init; }
public byte[]? ImageData { get; init; } // 保留給未來影像比對
}
LocatorCriteria使用init屬性,建議以 object initializer 或with語法建構。
ProcessInfo
namespace GST.Rpa.Core.Models;
public record ProcessInfo(
int ProcessId,
string ProcessName,
string MainWindowTitle,
IntPtr MainWindowHandle
);
8. 使用範例
以下範例展示如何附加到 Windows 小算盤,點擊按鈕並讀取結果:
using GST.Rpa.Automation;
using GST.Rpa.Automation.Locators;
using GST.Rpa.Core.Models;
using Microsoft.Extensions.Logging;
// 建立服務
var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
var scanner = new FlaUIElementScanner(loggerFactory.CreateLogger<FlaUIElementScanner>());
var controller = new FlaUIAutomationController(loggerFactory.CreateLogger<FlaUIAutomationController>());
// 附加到小算盤(需先手動開啟 calc.exe)
var app = await RpaApp.AttachAsync("CalculatorApp", scanner, controller);
Console.WriteLine($"已附加到: {app.ProcessName}");
// 掃描視窗,取得所有元素
var elements = scanner.ScanWindow(app.WindowHandle);
Console.WriteLine($"共掃描到 {elements.Length} 個元素");
// 使用 CompositeLocator 定位「7」按鈕
var compositeLocator = new CompositeLocator(
new IElementLocator[]
{
new AutomationIdLocator(scanner),
new PropertyLocator(scanner),
new TreePathLocator(scanner),
},
loggerFactory.CreateLogger<CompositeLocator>());
var btnSeven = await compositeLocator.LocateAsync(
app.WindowHandle,
new LocatorCriteria { AutomationId = "num7Button" });
if (btnSeven is not null)
{
controller.Click(btnSeven);
Console.WriteLine("已點擊 7");
}
// 定位「+」按鈕並點擊
var btnPlus = await compositeLocator.LocateAsync(
app.WindowHandle,
new LocatorCriteria { AutomationId = "plusButton" });
if (btnPlus is not null)
controller.Click(btnPlus);
// 定位「3」按鈕並點擊
var btnThree = await compositeLocator.LocateAsync(
app.WindowHandle,
new LocatorCriteria { AutomationId = "num3Button" });
if (btnThree is not null)
controller.Click(btnThree);
// 定位「=」按鈕並點擊
var btnEquals = await compositeLocator.LocateAsync(
app.WindowHandle,
new LocatorCriteria { AutomationId = "equalButton" });
if (btnEquals is not null)
controller.Click(btnEquals);
// 讀取結果
var resultDisplay = await compositeLocator.LocateAsync(
app.WindowHandle,
new LocatorCriteria { AutomationId = "CalculatorResults" });
if (resultDisplay is not null)
{
var result = controller.GetValue(resultDisplay);
Console.WriteLine($"計算結果: {result}"); // 預期: 10
}
// 監控結果欄位的值變化
var monitor = new FlaUIElementMonitor(
loggerFactory.CreateLogger<FlaUIElementMonitor>(), controller);
monitor.ValueChanged += (sender, e) =>
{
Console.WriteLine($"[{e.Timestamp:HH:mm:ss}] {e.Element.DisplayName}: '{e.OldValue}' → '{e.NewValue}'");
};
if (resultDisplay is not null)
{
monitor.StartMonitoring([resultDisplay], TimeSpan.FromMilliseconds(500));
// ... 執行更多操作後 monitor 會自動偵測值變化
}
// 完成後關閉
monitor.StopMonitoring();
app.Close();
9. 架構決策
為何選擇 FlaUI?
| 考量 | FlaUI | 其他方案 |
|---|---|---|
| UIA3 支援 | 原生支援 UIA3,相容 .NET 8 | White (已停維)、TestStack.White (UIA2) |
| 維護狀態 | 持續維護,NuGet 下載量高 | 許多替代方案已停止更新 |
| API 設計 | 直覺的 fluent API,支援條件查詢 | 原生 System.Windows.Automation API 過於低階 |
| Pattern 支援 | 完整支援 Value / Text / ScrollItem 等 UIA Pattern | — |
Strategy Pattern 定位器設計
選擇 Strategy Pattern 而非單一定位器的原因:
- 可擴展性 — 新增定位策略(如未來的 Image 定位)只需實作
IElementLocator,不影響既有程式碼 - 關注分離 — 每個 locator 獨立處理一種定位邏輯,職責單一
- 可測試性 — 各 locator 可獨立單元測試
CompositeLocator 的 Chain of Responsibility
CompositeLocator 在 DI 容器中註冊所有 IElementLocator 實作後,自動依 Priority 排序建立 fallback chain:
AutomationId (1) → Property (2) → TreePath (3)
此設計的優點:
- 漸進降級 — 從最穩定的策略開始嘗試,逐步退回到較不穩定但涵蓋範圍更廣的策略
- 容錯能力 — 單一策略失敗不影響整體,只需 log 後繼續
- 透明性 — 呼叫端只需與
IElementLocator介面互動,不必關心實際使用哪種策略
10. 版本紀錄
| 版本 | 日期 | 說明 |
|---|---|---|
| 1.0 | 2026-04-04 | 初版 |