跳至主要内容

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.Core5.0.0
FlaUI.UIA35.0.0
Microsoft.Extensions.Logging.Abstractions8.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().Text
  • GetValue — 依序嘗試 Value PatternText PatternName 屬性
  • 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,否則拋出 ArgumentException
  • FindElement 使用 FlaUI 的 FindFirstDescendant(cf => cf.ByAutomationId(...)) 快速查詢
  • GetRunningProcesses 過濾出 MainWindowHandle != IntPtr.Zero 且具有 MainWindowTitle 的 Process
  • FindElementFromPoint 透過 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);
}

運作機制:

  1. StartMonitoring 時,透過 IAutomationController.GetValue() 讀取各元素初始值並存入 Dictionary<UIElementInfo, string>
  2. System.Threading.Timer 依指定 interval 週期性呼叫 CheckValues
  3. CheckValues 逐一比較目前值與上次記錄值,不同時觸發 ValueChanged 事件
  4. 可動態呼叫 AddElement / RemoveElement 增減監控對象
  5. 重複呼叫 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)

NameControlTypeClassName 屬性組合比對。掃描視窗元素樹(深度上限 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);
}

導航邏輯:

  1. / 分割 criteria.TreePath 取得各層 segment
  2. 先在 depth=1 找到 ControlType 符合第一個 segment 的根元素
  3. 逐層呼叫 scanner.GetChildren(current) 找下一個 segment 對應的子元素
  4. 任一層找不到匹配即回傳 null

5.5 CompositeLocator (Priority = 0)

Chain of Responsibility 模式將多個 IElementLocatorPriority 排序後依序嘗試,回傳第一個成功的結果。

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 策略選擇指南

優先序策略優點缺點適用時機
1AutomationId最快、最穩定、不受 UI 佈局變動影響目標元素需有 AutomationId開發者有設定 AutomationId 的應用程式
2Name + ControlType不依賴 AutomationId,人類可讀可能有重複名稱、多語系導致不穩定AutomationId 不可用時的次選
3TreePath不需任何唯一識別屬性UI 佈局變動即失效,效能較差最後手段,適合結構穩定的靜態介面
最佳實踐

優先使用 AutomationId。錄製元素時應盡可能保留 AutomationId,並以 CompositeLocator 的 fallback 機制確保在 AutomationId 缺失時仍能定位。


6. 回放執行流程

IElementScannerCompositeLocatorIAutomationControllerIElementMonitor 串成一條典型的「腳本回放」流程後,每個步驟對應的決策路徑與錯誤分支如下:

對照模組各介面的職責:

  • Locate:由 CompositeLocator 串接 AutomationIdLocator → PropertyLocator → TreePathLocator(見 §5),任一策略找到即停
  • VerifyFlaUIAutomationController.EnsureInteractable 在執行前檢查 IsEnabled / IsOffscreen(見 §4.1)
  • Action:經 IAutomationController.Click / SetValue / DragDrop / SendKeys 等執行,內部以 RuntimeId 重新定位 FlaUI element
  • StateCheck(選用):可選擇用 GetValueIElementMonitor.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;
}
屬性型別說明
Namestring元素顯示名稱(Label text)
AutomationIdstringUIA AutomationId,開發者設定的穩定識別碼
ControlTypestring控制項類型(Button、TextBox、ComboBox 等)
ClassNamestringWin32 class name
BoundingRectangleRectangle元素在螢幕上的座標與尺寸
IsEnabledbool是否可互動
IsOffscreenbool是否在畫面可見範圍外
ProcessIdint所屬 Process ID
RuntimeIdint[]?UIA RuntimeId,元素生命週期內唯一
DisplayNamestring計算屬性,依序取 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 8White (已停維)、TestStack.White (UIA2)
維護狀態持續維護,NuGet 下載量高許多替代方案已停止更新
API 設計直覺的 fluent API,支援條件查詢原生 System.Windows.Automation API 過於低階
Pattern 支援完整支援 Value / Text / ScrollItem 等 UIA Pattern

Strategy Pattern 定位器設計

選擇 Strategy Pattern 而非單一定位器的原因:

  1. 可擴展性 — 新增定位策略(如未來的 Image 定位)只需實作 IElementLocator,不影響既有程式碼
  2. 關注分離 — 每個 locator 獨立處理一種定位邏輯,職責單一
  3. 可測試性 — 各 locator 可獨立單元測試

CompositeLocator 的 Chain of Responsibility

CompositeLocator 在 DI 容器中註冊所有 IElementLocator 實作後,自動依 Priority 排序建立 fallback chain:

AutomationId (1) → Property (2) → TreePath (3)

此設計的優點:

  • 漸進降級 — 從最穩定的策略開始嘗試,逐步退回到較不穩定但涵蓋範圍更廣的策略
  • 容錯能力 — 單一策略失敗不影響整體,只需 log 後繼續
  • 透明性 — 呼叫端只需與 IElementLocator 介面互動,不必關心實際使用哪種策略

10. 版本紀錄

版本日期說明
1.02026-04-04初版