TL;DR
- 普段使いしている UI Automation のチェーン可能なラッパ (
uia_rap) を、参照設定なしで動く版に移植しました。 -
New CUIAutomationやAs IUIAutomationElementを全部やめ、CoCreateInstance+DispCallFuncと生ポインタ (LongPtr) で組み直しています。 - インポートするだけで動くので、配布が楽。とくに Edge の IE モードタブで動く業務システム を触るのに実用しています(
IHTMLDocument2を参照設定なしで取得)。 - 移植の過程で「参照設定がある前提のコードを剥がすと露呈する VBA の罠」をいくつも踏んだので、その供養も兼ねて書きます。
- リポジトリ: https://github.com/tarboh/uia_rap (
ref/= 参照あり版、noref/= 参照なし版、MIT)
uia_rap とは
UI Automation を「よく使う操作だけ短く書く」ためのラッパです。3つのクラスをチェーンして使います。
-
uia_e… 要素(取得・検索・プロパティ・パターン操作) -
uia_c… 検索条件(名前・種類・クラス名をチェーンで組み立てる) -
uia_t… テキスト範囲
' 電卓ウィンドウを(デスクトップの子から)探し、その中の「7」ボタンを押す
e.getRoot.ffChildren(c.Name1_Sub("電卓")) _
.ffDescendants(c.Type_(Button).Name4_Full("7")).ptInvoke
e / c はファクトリ関数で、呼ぶたびに新しいインスタンスを返します。条件は既定で AND 結合、Style 引数で OR にも切り替えられます。トップウィンドウは子(ffChildren)、その中のボタンは子孫(ffDescendants)から探しています。
素の UI Automation で書くと
まったく同じことを、素の UI Automation(参照設定あり)で書くとこうなります。
Dim uia As New CUIAutomation
Dim root As IUIAutomationElement
Set root = uia.GetRootElement
' 電卓ウィンドウを子から探す(名前に "電卓" を含む・大小無視)
Dim condWin As IUIAutomationCondition
Set condWin = uia.CreatePropertyConditionEx(UIA_NamePropertyId, "電卓", _
PropertyConditionFlags_MatchSubstring Or PropertyConditionFlags_IgnoreCase)
Dim wnd As IUIAutomationElement
Set wnd = root.FindFirst(TreeScope_Children, condWin)
If wnd Is Nothing Then Exit Sub
' 種類=Button かつ 名前="7" の AND 条件を組む
Dim condBtn As IUIAutomationCondition
Set condBtn = uia.CreateAndCondition( _
uia.CreatePropertyCondition(UIA_ControlTypePropertyId, UIA_ButtonControlTypeId), _
uia.CreatePropertyCondition(UIA_NamePropertyId, "7"))
Dim btn As IUIAutomationElement
Set btn = wnd.FindFirst(TreeScope_Descendants, condBtn)
If btn Is Nothing Then Exit Sub
' InvokePattern を取り出して押す
Dim inv As IUIAutomationInvokePattern
Set inv = btn.GetCurrentPattern(UIA_InvokePatternId)
If Not inv Is Nothing Then inv.Invoke
20行超が実質1行になります。 UI Automation は「条件オブジェクトを作る → FindFirst → パターンを取得して呼ぶ」という定型が毎回必要で、要素をたどるほど積み上がります。uia_rap はこの定型をメソッドチェーンに畳み込んでいます(Name1_Sub は部分一致・大小無視、Type_().Name4_Full() は種類 AND 名前完全一致、といった「よく使う条件」を短い名前にしてあるのもポイント)。
もともとは 参照設定(UIAutomationClient)ありき で書いていました。それを剥がすのが今回の話です。
なぜ参照設定なし版を作ったのか
参照設定ありだと、配布のたびに「ツール → 参照設定で UIAutomationClient にチェックを」が必要で、環境によって参照切れも起きます。インポートするだけで動く状態にしたい。
もう一つ大きいのが Edge の IE モード。職場の業務システムが IE モードタブで動くので、そこから HTML DOM (IHTMLDocument2) を取れると助かります。これも参照設定(Microsoft HTML Object Library)なしでやりたい。
基本方針:型を全部 LongPtr にして、生成を隠蔽する
参照設定ありのコードは、こういう「型ライブラリの型」に依存しています。
Private uia As New CUIAutomation
Public elem As IUIAutomationElement9
Set elem = uia.GetRootElement ' 戻り値も型ライブラリの型
参照なし版では、これらを全部 生の COM ポインタ (LongPtr) に置き換えます。
Private m_elem As LongPtr ' IUIAutomationElement のポインタ
' GetRootElement は IUIAutomation の vtable[5]
m_elem = uia_core.InvokeElem(uia_core.UIA(), 5, "GetRootElement")
COM のメソッド呼び出しは DispCallFunc で vtable を直接叩きます(この仕組み自体は別記事に書いたので割愛)。New CUIAutomation に相当する CoCreateInstance は下回りモジュール uia_core に隠蔽し、共有インスタンスを遅延生成します。
Private g_uia As LongPtr
Public Function UIA() As LongPtr
If g_uia = 0 Then g_uia = ComCreateInstance(CLSID_CUIAutomation, IID_IUIAutomation)
UIA = g_uia
End Function
ポインタを直接持つので、参照カウントは各クラスが自前で管理します(Class_Terminate で Release)。ここまでは素直。問題はこの先でした。
ハマった罠たち
参照設定を剥がすと、それまで型ライブラリが面倒を見てくれていた部分が全部むき出しになります。以下、実際に踏んだ順に。
1. CLngPtr は自作できない(VBA7 の組み込み関数)
Long を LongPtr に広げるヘルパを CLngPtr という名前で作ったら、関数定義の時点で構文エラー。CLngPtr は VBA7 の組み込み型変換関数でした。CLng / CStr の仲間です。
そもそも vtable index は小さいので、index * PTR_SIZE(Long)を LongPtr 引数へ渡せば自動拡幅されます。ヘルパごと削除して解決。
2. ID 定数は Public Const ではなく Public Enum にする
TreeTraversalOptions_Default や UIA_InvokePatternId は、参照設定ありでは型ライブラリの enum メンバです。参照なし版では自分で定義し直す必要があります。
最初は Public Const で並べていたのですが、名前が型ライブラリと同じなので「参照設定前提に見える」。Public Enum にまとめると、自己完結が明確になり IntelliSense にも型として出ます。
Public Enum UIA_PatternIDs
UIA_InvokePatternId = 10000
UIA_ValuePatternId = 10002
' ...
End Enum
enum メンバの右辺で別 enum のメンバを参照できる(Invoke = UIA_InvokePatternId)ので、既存コードは無変更で動きます。
3. VT_UNKNOWN を返すプロパティで「型が一致しません」
要素情報をダンプする処理で、GetCurrentPropertyValue を全プロパティに対して回すと、LabeledBy(別要素への参照)のところで落ちました。
このプロパティは VT_UNKNOWN(生の IUnknown ポインタ) を返します。参照設定がないと型情報がなく、VBA が扱えない Variant(「サポートされていないオブジェクト型」)になります。
最初はこう書いて弾こうとしました。
Dim v As Variant
' ... GetCurrentPropertyValue で v を埋める ...
If IsObject(v) Then GetPropertyValue = Empty Else GetPropertyValue = v
が、IsObject(v) が False を返す。VBA の「オブジェクト」は IDispatch を指すので、IDispatch を持たない生の IUnknown(VT_UNKNOWN)は「オブジェクトではない」と判定されるんですね。
次に「代入時にエラーが出るはず」と On Error を掛けましたが、これも空振り。GetPropertyValue = v の代入自体は成功していて、End Function で呼び出し元へ戻すときの型変換で落ちるので、関数内の On Error では捕まりません。
最終的に、VBA にオブジェクトとして解釈させる前に、VARIANT の型タグ (vt) を生バイトで読む方式に。
' VARIANT を Variant 変数ではなくバイトバッファで受け取る
Dim buf(0 To 23) As Byte
UiaInvoke pElem, 10, "LP", propId, VarPtr(buf(0))
Dim baseVt As Long
baseVt = (CLng(buf(0)) + CLng(buf(1)) * 256) And &HFFF& ' 先頭2バイトが vt
If baseVt = 9 Or baseVt = 13 Then ' VT_DISPATCH / VT_UNKNOWN
VariantClear VarPtr(buf(0)) ' オブジェクトは解放して Empty
Else
VariantCopy result, VarPtr(buf(0)) ' スカラー/配列/BSTR は正しく複製
VariantClear VarPtr(buf(0))
End If
vt を生で読めば、IsObject の癖にも End Function の型変換にも左右されず、確実にオブジェクト型を弾けます。
4. SafeArrayGetElement に String を渡すと BSTR が化ける
プロパティ名の一覧は SAFEARRAY(BSTR) で返ってきます。要素を取り出そうとして、こう書きました。
Dim s As String
SafeArrayGetElement psa, i, s ' pv は ByRef ... As Any
すると "RuntimeId" が "R u n t i m e I d"(18文字) に化ける。中身を調べると、UTF-16 のバイト列(52 00 75 00 6E 00 …)が1バイト=1文字に展開されていました。
原因は、VBA が As Any パラメータに String を渡すとき、ANSI の一時バッファに変換して噛ませること。BSTR がそのバッファ経由で往復して壊れていました。VBA の Declare × As Any × String の有名な地雷です。
修正は、宣言を ByVal pv As LongPtr にして、文字列スロットの生アドレス VarPtr(s) を直接渡す。VBA の文字列マーシャリングを一切通しません。
' 宣言: ByVal pv As LongPtr
Dim s As String
s = vbNullString
SafeArrayGetElement psa, i, VarPtr(s) ' *pv = SysAllocString(...) が s のスロットに入る
これで SafeArrayGetElement が確保した BSTR がそのまま s になります。
IE モードの HTML を、参照設定なしで取る
これが今回の実用上の本命です。Edge の IE モードタブや IE の中身は、Internet Explorer_Server クラスのウィンドウが持っています。そこへ WM_HTML_GETOBJECT メッセージを送り、ObjectFromLresult で IHTMLDocument2 を取り出すのが定番手法。これは元々 API だけで完結していて、戻り値も Object(遅延バインド)で受けられます。
Public Function GetHTMLDocumentFromIES(ByVal hWnd As LongPtr) As Object
Const IID_IHTMLDocument2 As String = "{332c4425-26cb-11d0-b483-00c04fd90119}"
Dim msg As LongPtr, res As LongPtr, iid(0 To 3) As LongPtr, obj As Object
msg = RegisterWindowMessage("WM_HTML_GETOBJECT")
SendMessageTimeout hWnd, msg, 0, 0, SMTO_ABORTIFHUNG, 1000, res
If res <> 0 Then
IIDFromString ByVal StrPtr(IID_IHTMLDocument2), iid(0)
If ObjectFromLresult(res, iid(0), 0, obj) = 0 Then Set GetHTMLDocumentFromIES = obj
End If
End Function
要素側は、配下から Internet Explorer_Server を探してそのウィンドウハンドルを渡すだけ。IHTMLDocument2 は Object として返るので、Microsoft HTML Object Library の参照設定なしで DOM を触れます。
Dim doc As Object
Set doc = edgeTab.IES_GetIHTMLDocument_FromTopWindow()
Debug.Print doc.title
Debug.Print doc.URL
doc.getElementById("foo").Click
遅延バインドなので IntelliSense は効きませんが、IE モードの業務システムを触るには十分実用になります。
主なメソッド(簡易マニュアル)
普段よく使うものだけ抜粋します。e / c / t はファクトリ関数(uia_Factory)で、呼ぶたびに新しいインスタンスを返します。
要素の取得(uia_e)
| メソッド | 説明 |
|---|---|
getRoot() |
デスクトップ(ルート) |
getFocus() |
フォーカス中の要素 |
getHandle(hwnd) |
HWND から |
GetFromCursor() / GetFromPoint(x,y)
|
カーソル直下 / 座標から |
検索(uia_e) — 条件は uia_c を渡す
| メソッド | 説明 |
|---|---|
ffChildren(c) / ffDescendants(c)
|
子 / 子孫から最初の1件 |
faChildren([c]) / faDescendants(c)
|
子 / 子孫を全件(配列で保持) |
Array_Length / Array_GetItemByIndex(i)
|
配列の件数 / i 番目 |
いずれも RetryCount / RetrySleepTime(描画待ちのリトライ)を省略引数で持ちます。
条件(uia_c) — チェーンで積む(既定は AND)
| メソッド | 説明 |
|---|---|
Name4_Full(name) |
名前が完全一致 |
Name1_Sub(name) |
名前が部分一致(大小無視) |
Type_(ctrlType) |
種別(Button / Edit / Window_ …) |
ClsName(name) / AutomationId(id) / LocalType(name)
|
クラス名 / AutomationId / ローカライズ種別名 |
プロパティ(uia_e):prName prClsName prCtrlType prLocalCtrlType prValue prHwnd prRect({L,T,R,B})prRectCenter など
操作(uia_e):ptInvoke(クリック)ptSetValue(入力)ptToggle ptExpand/ptCollapse ptScroll ptSelectionItem_Select ptWindowClose SetFocus ptGetTextRange(→uia_t)
Edge / IE モード(uia_e):EdgeGetTopWindow EdgeGetTabItems EdgeChangeTab EdgeSetAddress / IES_GetIHTMLDocument_FromTopWindow(IE モードの IHTMLDocument2 を取得)
開発補助(uia_e):GetInfo(プロパティ/パターン一覧を表示)test(カーソル追跡で情報表示)
全メソッドの完全版リファレンスは GitHub の docs/MANUAL.md にあります。 引数・省略値・戻り値、
uia_t(テキスト範囲)や Enum 早見表まで載せてあります。
まとめ
- 参照設定ありきの UI Automation ラッパを、参照設定なしで動くように移植しました。
- 核は「型ライブラリの型を全部
LongPtrにして、CoCreateInstanceを隠蔽する」こと。 - 剥がしてみて分かったのは、型ライブラリが今まで面倒を見てくれていたもの(型変換、
IsObjectの判定、BSTR/VARIANT のマーシャリング)が、参照を外すと全部自分の責任になるということでした。VT_UNKNOWNとIsObject、As AnyとStringあたりは、同じことをやる人がまた踏むと思うので書き残しておきます。 - IE モードの HTML DOM も、参照設定なしで問題なく取れています。
リポジトリはこちらです。ref/(参照あり)と noref/(参照なし)を両方入れてあり、API は同じなので書いたコードはそのまま両対応します。
https://github.com/tarboh/uia_rap
姉妹プロジェクトとして、UI Automation を参照設定なしでフルに叩くライブラリ(自動生成)もあります → VBA_UIAutomation_NoRef