前節ではデータワークブックを開いた時のアドインの読み込みとデータワークブックの登録処理を見てきました。続いては,データワークブックを閉じたときの動作を考えます。こちらの方はちょっと一筋縄ではいきません。

WorkbookBeforeCloseイベント

ワークブックを閉じるときにはWorkbookオブジェクトのBeforeCloseイベントとApplicationオブジェクトのWorkbookBeforeCloseイベントが発生するので,どちらかを拾えばAppWorkbookオブジェクトの解放やアドイン自身のアンインストールができそうです。つまり,以下のようなコードです。

Private Sub m_Application_WorkbookBeforeClose(ByVal Wb As Workbook, Cancel As Boolean)
    Dim Index As Long
    m_AppWorkbooks(CStr(ObjPtr(Wb))).Dispose
    For Index = 1 To m_AppWorkbooks.Count
        If m_AppWorkbooks(Index).Workbook Is Wb Then
            m_AppWorkbooks.Remove Index
            Exit For
        End If
    Next
    If m_AppWorkbooks.Count = 0 Then
        DropMyself
    End If
End Sub

Public Sub DropMyself()
    Dim AddInName As String
    ' Delete file extension.
    AddInName = Mid(ThisWorkbook.Name, 1, InStrRev(ThisWorkbook.Name, ".") - 1)
    m_Application.AddIns(AddInName).Installed = False
End Sub
WorkbookBeforeCloseイベントハンドラ(アプリケーションアドインのThisWorkbookモジュール)
閉じられるワークブックに対応するAppWorkbookオブジェクトをm_AppWorkbooksコレクション内に特定し,これを取り除きます。さらに,その結果コレクションの要素が0個になった場合は,このアプリケーションアドインが不要になったことを意味するので,速やかにアンインストールします。

ところが,この戦略はうまくいきません。理由は,WorkbookBeforeCloseイベントの発生タイミングにあります。新規ワークブックを開き,次のコードをThisWorkbookモジュールに貼り付けたものを使って問題点を見ていきます。

Private WithEvents m_Application As Application

Public Sub Initialize()
    Set m_Application = Application
End Sub

Private Sub m_Application_WorkbookBeforeClose(ByVal Wb As Workbook, Cancel As Boolean)
    MsgBox "Application.WorkbookBeforeClose event (" & Wb.Name & ")"
End Sub

Private Sub Workbook_BeforeClose(Cancel As Boolean)
    MsgBox "Workbook.BeforeClose event"
End Sub
WorkbookBeforeCloseイベントのタイミング確認用コード(新規ワークブックのThisWorkbookモジュール)
あらかじめInitializeプロシージャを手動で実行してから,Excelウィンドウの「X」ボタンを押してワークブックを閉じようとしましょう。すると,次のような動作となるはずです。
  • 「Workbook.BeforeClose event」というメッセージが表示される。
  • 「Application.WorkbookBeforeClose event (Book1)」というメッセージが表示される。
  • 「"Book1”への変更を保存しますか?」というメッセージが表示される。
最後の「"Book1”への変更を保存しますか?」のメッセージはExcelが表示しているもので,保存,保存しない,キャンセルの選択肢があります。
ワークブック保存確認メッセージ
ここでキャンセルを押すとどうなるでしょう。Book1の保存はキャンセルされ,元の編集画面に戻ってしまいます。つまり,最初のワークブックを閉じるという動作そのものがキャンセルされるのです。もし,ApplicationオブジェクトのWorkbookBeforeCloseイベントあるいはWorkbookオブジェクトのBeforeCloseイベントをフックしてAppWorkbookオブジェクトの解放やアドインのアンインストールをしていたら,フライングになってしまいます。だからこそCloseイベントではなく,BeforeCloseイベントという名前がついているのです。他にもBeforeが付くイベントはいくつかありますが,いずれも対象の動作が行われる前に発生するので,動作が完了するかどうかは保証されないのです。

なぜCloseイベントではなくBeforeCloseイベントが用意されているのかはわかりませんが,引数のCancelにTrueを設定するとイベントハンドラを抜けた後の本来の動作がキャンセルされる仕組みになっていることから,本来の動作を含めてユーザーコードで置き換えることを意図しているのではないかと考えられます。たとえば,このアプリケーションアドインなら,ワークブックを閉じようとしたときに必要に応じてワークブックを保存するところまでユーザーコードで面倒を見れば,保存処理が完了したかを見極めることができるので,AppWorkbookオブジェクトの解放がフライングになる心配はありません。とはいえ,もともとのExcelの動作を忠実に再現しようとすると容易ではありません。たとえば,複数のワークブックが開いている状態で1枚のワークブックだけを閉じようとした時と,Excelアプリケーションそのものを終了させようとしたときで表示されるメッセージは異なりますし,他言語版のExcelで実行しているときはメッセージボックスやダイアログボックスのメッセージも異なります。それに,オリジナルの動作を変えるつもりがないところをわざわざ作り直すのは,できれば避けたいところです。

Excel 2010(以降)であれば,AfterSaveイベントが追加されているので,BeforeCloseイベントとの組み合わせで処理がキャンセルされなかったときのみアプリケーションアドイン側の処理を行うようにすることができそうです。ただ,Excel 2007以前を使用している環境は少なからずあると思われるので,ここでは見送ります。

WorkbookDeactivate/WorkbookActivateイベントの利用

BeforeCloseイベントが頼りにならず,AfterSaveイベントは使わないとすれば,直接的にワークブックが閉じられたことを知ることは難しそうです。そこで,ワークブックを閉じたときに付随して発生するイベントを拾って間接的に判定することにします。そのカギとなるのがApplicationオブジェクトのWorkbookDeactivateイベントとWorkbookActivateイベントです。

開いているワークブックが閉じられようとするとき,ほとんどケースでは次の順にイベントが生成されます。
  • 閉じられるワークブックに関してWorkbookDeactivateイベントが発生する。
  • ワークブックが(本当に)閉じられる。
  • 新しくフォーカスを得てアクティブになるワークブックに関してWorkbookActivateイベントが発生する。
Excelの「閉じる」コマンドや「X」ボタンなどを使うと,ワークブックを閉じられる直前にフォーカスを失うため,WorkbookDeactivateイベントが発生します。そして,閉じられたワークブックに代わって,別のワークブックが新たにフォーカスを獲得するのに伴ってWorkbookActivateイベントが発生するのです。そこで,以下のようなコードをアプリケーションアドインのThisWorkbookモジュールに追加することでワークブックが閉じられたことを検出し,AppWorkbookオブジェクトを解放することができます。

Private m_DeactivatedWorkbookIndex As Long

Private Sub m_Application_WorkbookDeactivate(ByVal Wb As Workbook)
    Dim Index As Long
    For Index = 1 To m_AppWorkbooks.Count
        If m_AppWorkbooks(Index).Workbook Is Wb Then
            m_DeactivatedWorkbookIndex = Index
            Exit Sub
        End If
    Next
End Sub

Private Sub m_Application_WorkbookActivate(ByVal Wb As Workbook)
    Dim WorkbookIsSaved As Boolean
    If m_DeactivatedWorkbookIndex = 0 Then Exit Sub

    On Error Resume Next
    WorkbookIsSaved = m_AppWorkbooks(m_DeactivatedWorkbookIndex).Workbook.Saved
    If Err.Number = 0 Then Exit Sub

    m_AppWorkbooks(m_DeactivatedWorkbookIndex).Dispose
    m_AppWorkbooks.Remove m_DeactivatedWorkbookIndex
    m_DeactivatedWorkbookIndex = 0
    If m_AppWorkbooks.Count = 0 Then
        DropMyself
    End If    
End Sub

Public Sub DropMyself()
    Dim AddInName As String
    ' Delete file extension.
    AddInName = Mid(ThisWorkbook.Name, 1, InStrRev(ThisWorkbook.Name, ".") - 1)
    m_Application.AddIns(AddInName).Installed = False
End Sub
WorkbookDeactivate/WorkbookActivateイベントハンドラー(アプリケーションアドインのThisWorkbookモジュール)
1行目でもっとも最近フォーカスを失ったワークブックに対応するAppWorkbookオブジェクトのインデックスを保持するための変数を宣言します。もしワークブックを一意に識別できるIDが存在すれば,代わりにその値を保持するための変数を定義することになります。

WorkbookDeactivateイベントハンドラは,今フォーカスを失ったワークブックのインデックスを退避します。Workbookオブジェクトをそれを包含するAppWorkbookオブジェクトに変換する手段はないので,ループで順に探していきます。くどいようですが,ワークブックを一意に識別できるIDが存在すれば,m_AppWorkbooksコレクションにキーを与えることで即座に当該AppWorkbookオブジェクトを取得できます。

続くWorkbookActivateイベントハンドラでは,18行目でインデックスがm_DeactivatedWorkbookIndexであるAppWorkbookオブジェクトのWorkbookオブジェクトにアクセスし,そのプロパティを取得しています。取得するプロパティは何でもよく,それがエラーになるかどうかが重要なのです。もしそのワークブックが直前に閉じられていた場合,対応するWorkbookオブジェクトのプロパティ取得はエラーになります。逆に言えばエラーにならない場合はワークブックは閉じられていない,つまり単なるワークブックの切り替えだったことになるので,何もせずにプロシージャを抜けます。15行目以降のワークブックが閉じられた場合の動作については,この節の前半でWorkbookBeforeCloseイベントを使ってやろうとしたこととまったく同じです。Workbookオブジェクトはシステムが自動的に削除しているはずですが,AppWorkbookオブジェクトはアプリケーション管理なので,ここで破棄します。

ユーザー定義クラスAppWorkbookの方は,オブジェクトに紐付けられたWorkbookオブジェクトへの参照を返すプロパティを追加します。

Public Property Get Workbook() As Workbook
    Set Workbook = m_Workbook
End Property
Workbookプロパティの定義(AppWorkbookクラスモジュール)

例外ケースの考察

WorkbookDeactivate/WorkbookActivateイベントによるワークブッククローズの検出はほとんどの場合うまくいきますが,すべてというわけではありません。ワークブックを閉じるという操作には,以下のように非常にたくさんのバリエーションがあるので,これらすべてのシナリオについてアプリケーションアドインの開放が適切に行われることを確認する必要があります。
  1. Excelワークブックウィンドウの「X」ボタンで閉じる。
  2. ファイルメニュー(Excel 2007以降ではファイルタブ)の「閉じる」コマンドで閉じる。
  3. VBAのWorkbook.Closeメソッドで閉じる。
  4. Excelを終了するのに伴って閉じられる。
1.,2.は通常ケースです。3.の場合はアクティブでないワークブックも閉じることができるので注意が必要ですが,確認してみると,その場合でも(なぜか)WorkbookDeactivateイベントだけは発生するので特別扱いする必要はありません。

4.のケースでは,アプリケーション終了を通知するイベントがないため,Excelが終了する直前にアドインを開放することはできません。すると,アプリケーションアドインのインストール済み状態は保持され,次回のExcel起動時に再び組み込まれてしまいます。この問題への対処法は簡単です。アプリケーションアドインが対象となるデータワークブックがない状態で起動されることを想定し,その場合には自分自身をアンインストールするようにすればよいのです。

コードの変更点は一か所のみで,WorkbookオブジェクトのOpenイベントハンドラにおいて,AppWorkbookコレクションの初期化が完了した時点で要素の数が0ならアドイン自身をアンインストールします。

Private Sub Workbook_Open()
    If Not Me.IsAddin Then Exit Sub
    Initialize
    If m_AppWorkbooks.Count = 0 Then
        DropMySelf
    End If
End Sub
改訂版WorkbookOpenイベントハンドラ(アプリケーションアドインのThisWorkbookモジュール)

もうひとつ考慮が必要なのは,閉じたワークブックがオープン中の唯一のワークブックだった場合です。閉じられたワークブックに代わってフォーカスを得るワークブックが存在しないため,WorkbookActivateイベントは発生せず,アドインも解放されません。

そこで,上記のExcel終了時の対応と同様,即時対応はあきらめて,次のオペレーションを待つ戦略をとることにしましょう。ワークブックを閉じたあとにユーザーが行う可能性のあるオペレーションは次の通りです。
  1. ワークブックを新規作成する。
  2. 既存のワークブックを開く。
  3. Excelを終了する。
もちろん,これら以外にもオプションを変更したり,ヘルプを見たりといったオペレーションは可能ですが,いずれは確実に上記のどれかが行われるので,そこで対処することにします。

まず,1.については,新しいワークブックが開かれた時点でWorkbookActivateイベントが発生するので,時間的には空きますが通常ケースと同様のシナリオでアドインが解放されます。

2.のケースでは,WorkbookOpen→WorkbookActivateの順番でイベントが発生します。開かれたワークブックがアプリケーションアドインのデータワークブックだった場合は,WorkbookOpenイベントによってアドインに新たなAppWorkbookオブジェクトが追加されるので,その瞬間アプリケーションアドインが管理するデータワークブックの数は2つになります。直後のWorkbookActivateイベントで先に閉じられたワークブックを管理していたAppWorkbookオブジェクトは解放されますが,まだAppWorkbookオブジェクトは残っているのでアドインは存続します。一方,開かれたのがデータワークブックではなかった場合は,WorkbookOpenイベントでは何も起こらず,WorkbookActivateイベントで閉じられたワークブックを管理していたAppWorkbookオブジェクトが解放されてアドイン自身もアンインストールされます。これらのケースも問題ありません。

最後に3.のケースは,アプリケーションアドインがインストールされたままExcelが終了してしまいますが,これは先程見たワークブックが開かれた状態でExcelを終了したのと同じことです。したがって,アプリケーションアドインWorkbookOpenイベントハンドラの改訂により対処済みです。

まとめ

2つの節にわたってアプリケーションアドインのフレームワーク実装を見てきました。アドインの開放については,ぴったりのタイミングを測る直接的な方法はありませんでしたが,WorkbookDeactivate,WorkbookActivateという2つのイベントを組み合わせることにより,間接的にユーザーのオペレーションを捕捉することができたため,うまく対処することができました。

最後に,ここまでの検討をまとめた完成版のフレームワークを次節で紹介します。
web拍手 by FC2
inserted by FC2 system