1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

参照設定なしでUI AutomationをVBAからフルに叩くライブラリを作った

1
Posted at

TL;DR

  • VBAから UI Automation(Microsoftのアクセシビリティ/自動化API)を参照設定なしで 使えるクラスライブラリを作りました。
  • CoCreateInstance でCOMオブジェクトを生成し、以降のメソッド呼び出しは全部 DispCallFunc でvtableを直接叩いています。タイプライブラリ参照を一切張らないので、配布先の環境・ビット数に依存しません。
  • 網羅範囲は IUIAutomationIUIAutomation6IUIAutomationElementElement9全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) です。IUnknownQueryInterface / AddRef / Release が先頭の0・1・2番目を占め、独自メソッドは3番目から並びます。

oleaut32DispCallFunc を使うと、この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_TerminateRelease します。呼び出し側は参照カウントを意識せずに済みます。


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のツールで、

  1. parse_uia_header.py … ヘッダを解析してvtable一覧をJSON化(未解析0件)
  2. gen_constants.pyUIA_NamePropertyId などのID定数391個+列挙値38種を抽出
  3. gen_vba_classes.py … 上記から .cls を生成
  4. lint_vba.py … VBEに入れる前の構造チェック
  5. export_ansi.py … CP932 + CRLFに変換

を通しで走らせると、ライブラリ一式が出力されます。SDKが更新されても再生成するだけです。


モジュール数を抑える: 統合クラスと遅延QueryInterface

素直に1インターフェース1クラスで生成すると、67個ものクラスモジュールになってしまい、インポートが大変です。そこで関連するインターフェースを 1クラスに統合 しました。

クラス 統合内容 メソッド数
UIAutomation IUIAutomationIUIAutomation6 76
UIAElement IUIAutomationElementElement9 116
UIAPatterns 全35コントロールパターン 217
UIACondition Condition と派生5種 11
UIATextRange TextRangeTextRange3 22

結果、67インターフェースが15クラス + 標準モジュール7個 に収まりました。

統合クラスの中では、世代/パターンごとにインターフェースポインタを別々に保持します。ポイントは 遅延QueryInterface です。要素は大量に生成されるので、UIAElement を作るたびに Element2Element9 を毎回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にインポートするだけです。数が多いので一括インポート用モジュールを同梱しています。

  1. トラストセンターで「VBA プロジェクト オブジェクト モデルへのアクセスを信頼する」を有効化
  2. modUIA_Install.bas だけ手でインポート
  3. イミディエイトウィンドウで ImportAll を実行し、dist フォルダを指定
  4. 終わったら modUIA_Install は削除してOK

インポート後は 参照設定を一切追加せずに UI Automationが使えます。


まとめ

  • 参照設定なし・依存なしで、UI AutomationをVBAからフルに叩けるようにしました。
  • 実現の核は DispCallFunc によるvtable直叩きと、SDKヘッダからのコード自動生成です。
  • VBA固有の地雷はlinterで機械的に潰す方針にしています。

生ポインタを自分で叩きたい人向けに、vtableインデックスの一覧(docs/uia_vtable.md)も同梱しています。

リポジトリはこちらです。よかったらスターやフィードバックをいただけると嬉しいです。

https://github.com/tarboh/VBA_UIAutomation_NoRef

1
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?