今日はVBAでWinAPIのEnumWindowsを使って別プロセスのExcelを取得する関数を作ってみました。
きっかけ
以前投稿したイミディエイトウィンドウの使い方の記事で、イミディエイトウィンドウを初期化する方法が不完全なままになっていた。
というのも、VBAからApplication.VBE
へアクセスするにはセキュリティを外さなければならず、以下のような構文が使えなかったのである。
Sub ImdClear_G_A_Del_F7() On Error GoTo ENDPOINT '↓ここから規制対象 If Application.VBE.MainWindow.Visible And _ Application.VBE.Windows("イミディエイト").Visible Then '↑ここまでが規制対象 SendKeys "^g", True SendKeys "^a", True SendKeys "{Del}", True SendKeys "{F7}", True End If ENDPOINT: End Sub
そこで、どうにかして「VBAプロジェクトオブジェクトモデルへのアクセスを信頼する。」がOFFの状態で
- VBEが開かれているか
- イミディエイトが開かれているか
- イミディエイトがドッキングされているか/ポップアップされているか
を、調べる関数を目指して、Windowsの基本に返ってAPIに頼ることにした。
APIによりウィンドウを取得する方法は、大きく分けて2つある。
FindWindow
、FindWindowEx
を使ってクラス名等からピンポイントに引き当てる方法EnumWindows
、EnumChildWindows
を使って全てのウィンドウから巡回して探し当てる方法
FindWindow
のほうがお手軽なのだが、複数同時起動している場合には「どれか一つ」しか取得できないという問題がある。
追記:FindWindowEx
にはhChildAfter
があり、検索開始ハンドル指定して繰り返し実行することで複数同時起動している場合に対応出来るらしい。
また、予めクラス名を特定おかなければならないというのも、取っ付きづらい原因の一つかもしれない。(今回は一括で取得してからLike演算子でフィルタリングしている)
以前、EnumWindows
及びコールバック関数
作成の練習で一度作ったことがあった。
一度作ったものなら多少の改良でVBE等にも流用出来るだろうと、作りっぱなしになっていたものを改良することにした。
(書き方がひどすぎて、結果的に完全に書き下ろす形になってしまったが。)
というわけで、ようやく本題の「別プロセスのExcelを取得する関数」を作ることになったわけである。
EnumWindows関数
今回のキモとなるAPIはEnumWindows
とEnumChildWindows
である。
Declare PtrSafe Function EnumWindows Lib "user32.dll" _ (ByVal lpEnumFunc As LongPtr, _ ByVal lParam As LongPtr) As LongPtr
このEnumWindows
には一つVBAに無い面白い特徴がある。
ここ最近、私のTLで話題なコールバック関数を使用するのである。
たとえば、EnumWindows
を使う時はこのような記述をする。
lngRtnCode = EnumWindows( _ AddressOf Callback_EnumWindowsProc, _ ByVal 0&)
引数 | 渡した値 | 意味 |
---|---|---|
lpEnumFunc As LongPtr | AddressOf Callback_EnumWindowsProc | コールバック関数へのポインタ |
lParam As LongPtr | ByVal 0& | 固定だと思って良し |
コールバック関数とは
次にコールバック関数だが、この概念を口頭だけで近いするのは困難を極めるので、(頑張って説明文を書いたが)ソースコードを実際に動かして体感したほうが絶対に良い。
私のイメージとしては、
関数Aが関数Bに「処理の最中で定期的に『ある操作』をやっといて」するための関数である。
この「ある操作」に相当するのが、「コールバック関数」である。
関数というプロセスそのものを丸投げしなければならないので、AddressOf
演算子を使って関数への参照(ポインタ)を引数として渡している。
「コールバック関数」という仰々しい(?)名前がついているが、要はAPI側の仕様に沿った「ただの関数」である。
「引数」と「戻り値」さえ正しい形になっていれば、中の処理は何をしても構わない。
慣習で、みな同じような「関数名」にしているが、実際にはなんでも良い。
' コールバック関数 - 親ウィンドウ列挙 Private Function Callback_EnumWindowsProc( _ ByVal hWnd As LongPtr, _ ByVal lParam As LongPtr) As LongPtr 'モジュールレベル変数のDictionaryにhWndをストックする DicParentWindows.Add "" & hWnd, lParam '内容を確認するためにイミディエイトへ出力してみる Debug.Print hWnd, lParam '処理を続行する場合はTrueを返す Callback_EnumWindowsProc = True End Function
例えば、先ほどの
lngRtnCode = EnumWindows( _ AddressOf Callback_EnumWindowsProc, _ ByVal 0&)
を実行した場合、User32.dll
の中のEnumWindows
がOSの管理しているウィンドウを巡回して、ウィンドウを発見するたびにCallback_EnumWindowsProc
を実行してくれる。
デバッグしてみれば分かるが、猛スピードでイミディエイトのログが流れるはず。
VBA的にはEnumWindows()
を一回実行しただけなのにも関わらず。だ。
たぶん、というか間違いなく、EnumWindows
の実装は繰り返し文になっている。
'ことりちゅんの「たぶん、こうだったんだろう劇場」 '想像で書いた EnumWindows の実装 Do '何らかの処理 hWnd = ほにゃらら lParam = ふにゃふにゃ 'もしFalseを返されたら処理を中断 If Callback_EnumWindowsProc(hWnd, lParam) = False Then Exit Do Loop
全ての巡回が終わったらようやくEnumWindows()
を呼び出した側のステップが次へ進む。
ただし、Callback_EnumWindowsProc
は何十・何百と呼ばれるのは良いものの、結果を保持することができないので、内部で処理を済ませるかパブリック変数を通して結果をフィードバックする仕組みが必要になる。
EnumWindows()について調べていると、前者のサンプルが非常に多いのだが、慣れない私は処理の流れが読みづらくて凄く難解に見えてしまう。
だから今回は後者の方法を取った。すごくシンプルでしょう?
' コールバック関数 - 親ウィンドウ列挙 Private Function Callback_EnumWindowsProc( _ ByVal hWnd As LongPtr, _ ByVal lParam As LongPtr) As LongPtr DicParentWindows.Add "" & hWnd, lParam Callback_EnumWindowsProc = True End Function
最後のlParam
については省略する。
EnumChildWindows関数
EnumChildWindows
は「親を持つ」子や孫のウィンドウハンドルの巡回用の関数で、引数に親のウィンドウハンドルが増えただけで仕組みとしては何も変わらない。
Declare PtrSafe Function EnumChildWindows Lib "user32.dll" _ (ByVal hWndParent As LongPtr, _ ByVal lpEnumFunc As LongPtr, _ ByVal lParam As LongPtr) As LongPtr
EnumChildWindows
はこんな記述をする。
Dim lngRtnCode As LongPtr lngRtnCode = EnumChildWindows( _ hWndParent, _ AddressOf Callback_EnumChildWindowsProc, _ lParamParent)
引数 | 渡した値 | 意味 |
---|---|---|
hWndParent As LongPtr | hWndParent | 親ウィンドウのハンドル |
lpEnumFunc As LongPtr | AddressOf Callback_EnumChildWindowsProc | コールバック関数へのポインタ |
lParam As LongPtr | lParamParent | 親ウィンドウ取得時のlParam |
ウィンドウハンドルとは
ウィンドウハンドルだが、
- ウィンドウ・・・Windowsの機能で作成されたオブジェクト
- ハンドル・・・・起動中のPCで、絶対に重複しないように割り振られたオブジェクトのID
という感じである。でも、たぶんまだ「オブジェクト」が何なのか分からないはずなので、具体例を上げる。
- デスクトップ とか タスクバー とかの 「最初からある奴」
- エクスプローラ とか Google Chrome とかの「窓」
- エクスプローラとかに表示されている「ツールバー」とか「ボタン」とか「リスト」とかの「部品」
まあ、つまり、
画面上の部品一つ一つに「ウィンドウハンドル」=「絶対に重複しないOSの管理番号」が付いているとでも想像すれば良い。
ある程度まで細分化した段階でOSの管理外となるが、今回はそこまで意識しなくて良い。
(例えば3DのゲームとかFlashとかは、窓以外は全てOSの管理外になるしね)
今回はとにかく「実行中のエクセルの窓」が全て取得出来れば良いのである。
いずれの関数も、返す値は「ウィンドウハンドル」である。
APIを使うときには「ウィンドウハンドル」が非常に重要となるため、Windowsのアプリ開発をする人は理解しておく事をオススメしたい。
使い方
Excelの場合
とりあえず、標準モジュールに貼って、テストプロシージャを実行してみればOK
テスト | 意味 | テスト対象の関数 |
---|---|---|
Test_ExecExcelApps | 実行中Excelアプリケーションの列挙 | ExecExcelApps |
Test_ExecExcelWorkbooks | 実行中Excelワークブックの列挙 | ExecExcelWorkbooks |
Test_ExecExcelWindows | 実行中Excelウィンドウの列挙 | ExecExcelWindows |
異なるプロセスでExcelを2つ開き、適当なブックを開いて3つを実行するとこんな感じになった。
Test_ExecExcelApps - 22:56:45 ハンドル ブック数 アプリケーション名 4657694 2 Book1 - Excel 19734440 3 20190929_EnumWindowsを使って別プロセスのExcelを取得する.xlsm - Excel Test_ExecExcelWorkbooks - 22:56:46 ハンドル 窓数 ブック名 4657694 1 Book1 19734440 1 20190929_EnumWindowsを使って別プロセスのExcelを取得する.xlsm 19734440 1 20190209_VBAのデバッグにおける真のイミディエイトウィンドウの使い方.xlsm 19734440 2 Book2 4657694 1 PERSONAL.XLSB Test_ExecExcelWindows - 22:56:47 ハンドル ゼロ ウィンドウタイトル 4657694 0 Book1 19734440 0 20190929_EnumWindowsを使って別プロセスのExcelを取得する.xlsm 19734440 0 20190209_VBAのデバッグにおける真のイミディエイトウィンドウの使い方.xlsm 19734440 0 Book2 - 2 19734440 0 Book2 - 1 4657694 0 PERSONAL.XLSB
なぜ「アプリケーション」「ブック」「ウィンドウ」の3つを作っているか、どう使い分けるかは、Excelのオブジェクトツリーを学ぶと良い。
Excel以外の場合
Excelのために先の関数を作ったが、もちろんExcel以外にも対応している。
関数名 | 意味 |
---|---|
EnumFindWindows | 親を持たないトップレベルウィンドウのハンドル一覧を返す関数 |
EnumFindChildWindows | 指定した親クラスに含まれる子孫ウィンドウハンドルを返す関数 |
いずれもAPIのGetClassName
とGetWindowText
を元に、VBAのLike演算子で絞り込みが出来るようになっている。
EnumFindWindows関数
親を持たないトップレベルウィンドウのハンドル一覧を返す関数
' @param parent_class_name Likeフィルタ用親クラス名(省略可) ' @param parent_window_text Likeフィルタ用親テキスト(省略可)
EnumFindChildWindows関数
指定した親クラスに含まれる子孫ウィンドウハンドルを返す関数
' @param parent_class_name Likeフィルタ用親クラス名(省略可) ' @param parent_window_text Likeフィルタ用親テキスト(省略可) ' @param child_class_name Likeフィルタ用子クラス名(省略可) ' @param child_window_text Likeフィルタ用子テキスト(省略可)
ウィンドウテキストの方はプログラム次第で変化してしまうため、できればクラス名を使うと良い。
しかしクラス名はどうにかして特定する必要がある。
- フリーソフトを使う
- 本プログラムでウィンドウテキスト(タイトル)で絞り込み、リストアップされた中から目的のクラスを探す
- psrを使う(Windows標準のステップ記録ツール)
など、他にも色々手段がある。
ハマったこと
DictionaryのキーにはLongLong型を使用できない
あまりハマったことはないのだが、64bit対応に当たって思わぬ障害に衝突した。
先ほどから登場しているコールバック関数だが、実はDictionaryのキー部分のハンドル値を「文字列」に置き換えるという、とてもオカシナ事をしている。
' コールバック関数 - 親ウィンドウ列挙 Function Callback_EnumWindowsProc( _ ByVal hWnd As LongPtr, _ ByVal lParam As LongPtr) As LongPtr DicParentWindows.Add "" & hWnd, lParam Callback_EnumWindowsProc = True End Function
これには止むに止まれぬ事情があって、
DictionaryのキーにはLongLong型を使用できない
らしいのである。(参考資料が見つからず。諦めた。)
Dictionaryのキー部分は、ハッシュなので通常なんでも設定出来てしまう。
(数値でも、実数でも、文字列でも、Rangeでも)それなのに、LongLongはダメらしい。
また、Itemの方には、普通にLongLongのlParamが設定出来ている。
後日入手した情報によると、Decimal型ならLongに入り切らない大きな値でもキーにできることが判明した。
管理者として実行されたプロセスは取得できない
普通にエクセルブックを開いた場合、プロセスの権限がはユーザーレベルとなる。
ユーザーレベルで実行されているプロセスから、SYSTEM
レベル(管理者権限)で動いているプロセスにアクセスすることは出来ない。
従って、ウィンドウハンドルを取得する所までは問題無いのだが、GetExcelWindowByHWnd
でやっているオブジェクト化の処理(ObjectFromLresult
関数)
lngResult = SendMessage(hWnd, WM_GETOBJECT, 0, ByVal OBJID_NATIVEOM) If lngResult Then bytID = IID_IDispatch & vbNullChar IIDFromString bytID(0), IID(0) lngRtnCode = ObjectFromLresult(lngResult, IID(0), 0, excelWindow) End If
が失敗してしまうのである。
これはOSがセキュリティ強化のために行っている仕様なので仕方がないのだが、それでも困るという場面はあると思う。
問題を回避するためには、ブックを管理者として実行しておけば良い。
管理者としてエクセルを起動するには、ブックではなく実行ファイルを右クリックして管理者として実行してから、目的のマクロブックを開く必要がある。
この操作は地味に面倒なので、私はCtrl+Shift+exeを実行
という方法をよく使っている。
また、管理者として実行していた場合でも保護ビュー等の状態のExcelはオブジェクト化できないので、除外するように記述した。
ソースコード
400行近い長大なコードなので折りたたみでお送りする。
まとめ
こんなものを作って、一体何に使うのか全く想像がつかないが、先日作ったような「疑似マルチスレッド」をする時に使えるのかも知れない。
誰か面白い使い方を思いついたら、遠慮なくコメント欄 or Twitterで教えてほしい。
今回はVBEに対して処理するところまでは出来なかったので、もう少し改良したいと思う。
以上
何か御座いましたらコメント欄、またはTwitterからどうぞ♪
それではまた来週♪ ちゅんちゅん(・8・)