TL;DR
- VBAから UI Automation(Microsoftのアクセシビリティ/自動化API)を参照設定なしで 使えるクラスライブラリを作りました。
-
CoCreateInstanceでCOMオブジェクトを生成し、以降のメソッド呼び出しは全部DispCallFuncでvtableを直接叩いています。タイプライブラリ参照を一切張らないので、配布先の環境・ビット数に依存しません。 - 網羅範囲は
IUIAutomation〜IUIAutomation6、IUIAutomationElement〜Element9、全35コントロールパターン、TextRange系まで。67インターフェース / 1,907メソッド。 - VBA7以降(Office 2010〜)、32bit / 64bit両対応。依存は
oleaut32/ole32/kernel32の標準APIだけ。 - リポジトリ: https://github.com/tarboh/VBA_UIAutomation_NoRef (MIT)
何ができるか
まずは動くコードから。ボタンを名前で探して押す例です。
Sub Demo()
Dim uia As UIAutomation
Dim root As UIAElement
Dim btn As UIAElement
Dim cond As UIACondition
Dim pat As UIAPatterns
Set uia = NewUIAutomation() ' CUIAutomation8 → 取れた世代まで自動展開
Set root = uia.GetRootElement()
Set cond = uia.CreatePropertyCondition(UIA_NamePropertyId, "OK")
Set btn = root.FindFirst(TreeScope_Descendants, cond)
Set pat = btn.Patterns
If pat.Invoke_IsSupported Then pat.Invoke_Invoke
End Sub
FindFirst / FindAll、TreeWalkerでのツリー走査、CacheRequestでの一括取得、ValuePattern・TogglePattern・TextPatternなどのコントロールパターン——ネイティブのUI Automation クライアントAPIで出来ることは、ほぼそのまま書けます。
カーソル直下の要素を拾う補助関数も入れてあります。プロパティやパターンの調査にとても便利です。
Set el = uia.ElementFromCursor()
Debug.Print el.CurrentName & " [" & el.CurrentLocalizedControlType & "]"
Debug.Print el.Patterns.SupportedNames ' 例: Invoke,LegacyIAccessible,ScrollItem
なぜ「参照設定なし」なのか
VBAでUI Automationを使うと言えば、通常は「参照設定で UIAutomationClient を追加する」やり方が知られています。しかしこれには難点があります。
- 配布先ごとに参照設定を張り直す必要がある
- 環境によって型ライブラリのパスやバージョンが違い、参照切れを起こす
- そもそも参照を追加してもらう手順自体がハードル
「.bas / .cls をインポートするだけで動く」 状態にしたい。それが出発点でした。参照設定なしでCOMを叩くには、自分でvtableを辿るしかありません。
仕組み: DispCallFunc でvtableを直接叩く
COMインターフェースは、実体としては 関数ポインタの配列(vtable) です。IUnknown の QueryInterface / AddRef / Release が先頭の0・1・2番目を占め、独自メソッドは3番目から並びます。
oleaut32 の DispCallFunc を使うと、このvtable上の関数を「型情報つきで」呼び出せます。ライブラリの心臓部はこの薄いラッパ一本です。
' UiaInvoke(pThis, vtblIndex, argSpec, arg1, arg2, ...) As Long ' 戻り値は HRESULT
hr = DispCallFunc(pThis, vtblIndex * PTR_SIZE, CC_STDCALL, VT_I4, _
cActuals, vTypes(0), pArgs(0), vRet)
argSpec は引数1個につき1文字の型指定です(L=Long, P=ポインタ幅, S=BSTR, V=VARIANT…)。DispCallFunc に渡すVARTYPEと、実際に渡すVARIANTの中身がズレると平気でゴミを読むので、指定した型ちょうどに強制変換してから渡す のがキモです。
例えば IUIAutomationElement::FindFirst(vtable 5番)はこう呼びます。
Public Function FindFirst(ByVal scope As Long, ByVal condition As UIACondition) As UIAElement
Dim hr As Long
Dim p_condition As LongPtr
If Not condition Is Nothing Then p_condition = condition.Ptr
Dim retVal_ As LongPtr
hr = UiaInvoke(m_p, 5, "LPP", scope, p_condition, VarPtr(retVal_))
UiaCheck hr, "IUIAutomationElement.FindFirst"
If retVal_ <> 0 Then
Set FindFirst = New UIAElement
FindFirst.Attach retVal_
End If
End Function
[out] で返ってきたインターフェースポインタは自動でラッパクラスに包み、参照カウントは Class_Terminate で Release します。呼び出し側は参照カウントを意識せずに済みます。
1,907メソッドをどうやって用意したか
ここが一番の勘所でした。vtableのインデックスと引数の型を、1,907メソッド分すべて正確に用意する 必要があります。手で写経するのは非現実的です(そして一箇所間違えると即クラッシュ)。
そこで Windows SDKの UIAutomationClient.h を解析して、VBAコードを機械生成する アプローチを取りました。
ヘッダのCスタイル定義には、こういうvtable構造体が含まれています。
typedef struct IUIAutomationElementVtbl {
...
DECLSPEC_XFGVIRT(IUIAutomationElement, FindFirst)
HRESULT ( STDMETHODCALLTYPE *FindFirst )(
__RPC__in IUIAutomationElement * This,
/* [in] */ enum TreeScope scope,
/* [in] */ __RPC__in_opt IUIAutomationCondition *condition,
/* [retval][out] */ ... IUIAutomationElement **found);
...
} IUIAutomationElementVtbl;
この構造体は 継承元のメソッドも含めて、実メモリ上の並び順 で列挙されています。つまり DECLSPEC_XFGVIRT 行を上から数えれば、それがそのままvtableインデックスになります。[in] / [out] / [retval] の属性も引数コメントに残っているので、そこから戻り値と引数を判定できます。
Pythonのツールで、
-
parse_uia_header.py… ヘッダを解析してvtable一覧をJSON化(未解析0件) -
gen_constants.py…UIA_NamePropertyIdなどのID定数391個+列挙値38種を抽出 -
gen_vba_classes.py… 上記から.clsを生成 -
lint_vba.py… VBEに入れる前の構造チェック -
export_ansi.py… CP932 + CRLFに変換
を通しで走らせると、ライブラリ一式が出力されます。SDKが更新されても再生成するだけです。
モジュール数を抑える: 統合クラスと遅延QueryInterface
素直に1インターフェース1クラスで生成すると、67個ものクラスモジュールになってしまい、インポートが大変です。そこで関連するインターフェースを 1クラスに統合 しました。
| クラス | 統合内容 | メソッド数 |
|---|---|---|
UIAutomation |
IUIAutomation 〜 IUIAutomation6
|
76 |
UIAElement |
IUIAutomationElement 〜 Element9
|
116 |
UIAPatterns |
全35コントロールパターン | 217 |
UIACondition |
Condition と派生5種 |
11 |
UIATextRange |
TextRange 〜 TextRange3
|
22 |
結果、67インターフェースが15クラス + 標準モジュール7個 に収まりました。
統合クラスの中では、世代/パターンごとにインターフェースポインタを別々に保持します。ポイントは 遅延QueryInterface です。要素は大量に生成されるので、UIAElement を作るたびに Element2 〜 Element9 を毎回QIするのは無駄が大きい。そこで その世代のメソッドが最初に呼ばれたときだけ QIするようにしました。
' IUIAutomationElement5 が宣言するメソッドは、初回呼び出し時に Element5 を QI してから叩く
Public Function CurrentLandmarkType() As Long
hr = UiaInvoke(V(5, "CurrentLandmarkType"), 104, "P", VarPtr(retVal_))
...
End Function
未サポートの世代を呼ぶと「CurrentLandmarkType には IUIAutomationElement5 が必要ですが、この環境では利用できません」と分かりやすく落とします。el.Supports(5) で事前判定もできます。
CopyMemory を使わない(Windows Defender対策)
CopyMemory(= kernel32!RtlMoveMemory)は、Windows Defenderのリアルタイム保護に監視されやすく、呼び出しのたびに走査が入って遅延を招く ことがあります。文字列プロパティは高頻度で読むので、ここからRtlMoveMemoryを完全に排除しました。
-
BSTRの受け取り … COMメソッドの
BSTR* retValに、VBA文字列変数のスロットアドレス(VarPtr(s))を直接渡す。COM側が書き込んだBSTRをそのままVBAが所有・解放するので、コピーもAPI呼び出しもゼロ。Public Function CurrentName() As String hr = UiaInvoke(V(1, "CurrentName"), 23, "P", VarPtr(CurrentName)) UiaCheck hr, "IUIAutomationElement.get_CurrentName" End Function -
POINTの値渡し … 8バイトへのパックを算術で組み立てる。
ElementFromPointは32/64bitでABIが違いますが、64bitでは(y << 32) | (x & 0xFFFFFFFF)を算術で作ります(負座標もそのまま正しく入る)。 -
生ポインタ操作 … どうしても任意アドレスの読み書きが要る箇所は、VBAの配列(SAFEARRAY)の内部記述子を自作のものへ差し替え、その配列を窓口にしてメモリに触る手法を使います(Cristian Buse氏らの手法)。
RtlMoveMemoryを一切経由しません。
ハマったVBA固有の罠
「Cのvtableを機械生成する」と一見きれいに聞こえますが、実際には VBAの言語仕様との衝突 が延々と出てきました。生成器が生むコードがコンパイルすら通らない、というやつです。代表的なものを挙げます。
-
<<はVBAに無い … ヘッダのenum { A = (1 << 0) }のようなC定数式を文字列置換で移すと、<<が素通りしてコンパイルエラー。→ 生成時に式を評価して数値だけ出す ようにした(VisualEffects_Shadow As Long = 1 ' ( 1 << 0 ))。 -
ReleaseはIUnknownと衝突 … VBAのクラスモジュールは既定インターフェースがIDispatch派生なので、QueryInterface/AddRef/Release/Invokeなどは最初からメンバとして存在する。同名を宣言すると「このメンバー識別子は既にオブジェクト モジュールの中に存在しています」。→ReleasePtrに改名。 -
array/textは予約語 … 引数名に使うとExpected: identifier。→ VB6/VBAの予約語一覧全体で照合してarray_などに退避。 -
.clsはCRLF必須 … LF単独だとVBEがクラスヘッダを認識できず、標準モジュールとして取り込まれる。→ CRLFに正規化。 - 文字コードはCP932 … VBEのインポートはANSIコードページで読むので、UTF-8のままだと日本語コメントが化ける。
-
モジュールレベルの宣言は全プロシージャより前 …
Private Constを関数の後に置くと「プロシージャの外では無効です」。 -
標準モジュールの
Public Typeはクラスの公開シグネチャに使えない … 矩形・座標はUIARect/UIAPointクラスでやり取りするようにした。
これらは一度踏むたびに lint_vba.py に検査を追加 していきました。今では改行・エンコーディング・クラスヘッダ・予約メンバ名・予約語識別子・C演算子・宣言位置・UDTシグネチャなどを、VBEに入れる前に機械的に弾いてくれます。
インストール
dist/ の .bas / .cls をVBEにインポートするだけです。数が多いので一括インポート用モジュールを同梱しています。
- トラストセンターで「VBA プロジェクト オブジェクト モデルへのアクセスを信頼する」を有効化
-
modUIA_Install.basだけ手でインポート - イミディエイトウィンドウで
ImportAllを実行し、distフォルダを指定 - 終わったら
modUIA_Installは削除してOK
インポート後は 参照設定を一切追加せずに UI Automationが使えます。
まとめ
- 参照設定なし・依存なしで、UI AutomationをVBAからフルに叩けるようにしました。
- 実現の核は
DispCallFuncによるvtable直叩きと、SDKヘッダからのコード自動生成です。 - VBA固有の地雷はlinterで機械的に潰す方針にしています。
生ポインタを自分で叩きたい人向けに、vtableインデックスの一覧(docs/uia_vtable.md)も同梱しています。
リポジトリはこちらです。よかったらスターやフィードバックをいただけると嬉しいです。