【上級者向け】Project VBAにおける「コレクション」操作の高速化:Taskオブジェクトのキャッシュ戦略
Microsoft ProjectのVBA開発において、数千行を超える大規模WBS(Work Breakdown Structure)を扱うとき、多くのエンジニアが絶望的なパフォーマンスの壁に直面する。
「単純なタスクのループ処理に数十分かかる」
「前提条件(先行タスク)の動的バインドでメモリリークが発生する」
「COMオブジェクトの嵐により、Project本体がフリーズする」
これらは、MS Projectのオブジェクトモデル、およびCOM(Component Object Model)のアーキテクチャに対する理解の欠如から生じる悲劇だ。特に、`Task.Predecessors` や `Task.Successors` といったコレクションを安易に走査するコードは、VBAとProjectのCOM境界を何重にも跨ぎ、実行速度を致命的に低下させる。
本稿では、レガシーかつ強大なProject VBAの制約を逆手に取り、Taskオブジェクトのメモリ上へのキャッシュ戦略と配列ベースの高速化アプローチによって、処理速度を極限まで引き上げるアーキテクチャを提示する。
—
1. なぜProject VBAのループは遅いのか?(COM境界の罠)
VBAからMS Projectのオブジェクトにアクセスする際、背後ではCOMのマーシャリング(プロセス間通信に近いオーバーヘッド)が発生している。
‘ 【アンチパターン】最悪のパフォーマンスを生むコード
Dim t As Task
For Each t In ActiveProject.Tasks
If Not t Is Nothing Then
If t.PercentComplete < 100 Then
' ここで毎回COM経由でプロパティにアクセスしている
Debug.Print t.Name & " : " & t.Start
End If
End If
Next t
上記のコードの何が問題か。
1. `For Each` によるコレクション走査は、内部でIEnumVARIANTインターフェースを呼び出し、ループのたびにポインタの解決を行う。
2. `t.Name` や `t.Start` へのアクセスは、その都度COMプロキシ経由でProject本体のメモリ空間を叩くため、I/Oボトルネックが発生する。
3. 数千タスク規模になると、このラウンドトリップが数万回に及び、数分から数十分の遅延を生む。
シニアエンジニアが取るべきアプローチは明確だ。「COMオブジェクトへのアクセス回数を物理的に最小化し、一度取得したデータはVBA側のメモリ(配列)に閉じ込める」ことである。
—
2. タスクキャッシュ戦略のコアアーキテクチャ
大規模WBSを高速処理するための設計思想は以下の3点に集約される。
1. 一括取得(Bulk Fetch): Projectのタスクプロパティを一度のループでVariant配列に吸い上げる。
2. メモリ内インデクシング: Task IDやUnique IDをキーとしたDictionary、あるいは高速な二次元配列上で依存関係(WBS・前提条件)を構築する。
3. 遅延書き込み(Lazy Write): 計算やデータ加工はすべてメモリ上の配列で行い、最終結果のみを一括してProjectに書き戻す。
実装コード:配列とDictionaryによるキャッシュエンジン
以下のコードは、数千タスクのID、名前、進捗率、および前提条件の依存関係をミリ秒単位でメモリ上にキャッシュし、高速に処理するための実用的なテンプレートである。
Option Explicit
‘ 早期バインディングのために Microsoft Project XX.0 Object Library を参照設定すること
‘ ※レガシー環境配慮のため、必要に応じCreateObject(“MSProject.Application”)等に拡張可能
Public Sub ExecuteHighSpeedTaskProcessing()
Dim startTime As Double
startTime = Timer
Dim prj As Project
Set prj = ActiveProject
Dim tCount As Long
tCount = prj.Tasks.Count
If tCount = 0 Then Exit Sub
‘ ————————————————————————-
‘ 1. 【キャッシュの構築】COMアクセスを1回に限定し、Variant配列へ一括格納
‘ ————————————————————————-
Dim taskData() As Variant
ReDim taskData(1 To tCount, 1.0 To 5.0)
‘ UniqueIDと配列インデックスを紐付けるためのDictionary(依存関係解決の高速化)
Dim idMap As Object
Set idMap = CreateObject(“Scripting.Dictionary”)
Dim t As Task
Dim i As Long
i = 1
For Each t In prj.Tasks
If Not t Is Nothing Then
taskData(i, 1) = t.ID ‘ 1: Task ID
taskData(i, 2) = t.UniqueID ‘ 2: Unique ID
taskData(i, 3) = t.Name ‘ 3: Name
taskData(i, 4) = t.PercentComplete’ 4: Percent Complete
‘ 依存関係(先行タスクのUniqueID文字列)を取得
Dim predString As String
predString = “”
On Error Resume Next
predString = t.Predecessors
On Error GoTo 0
taskData(i, 5) = predString ‘ 5: Predecessors
‘ Dictionaryに UniqueID -> 行インデックス をマッピング
idMap(CLng(t.UniqueID)) = i
i = i + 1
End If
Next t
Debug.Print “キャッシュ構築完了: ” & (Timer – startTime) & ” 秒 (” & tCount & ” タスク)”
‘ ————————————————————————-
‘ 2. 【メモリ内処理】Project本体を叩かず、配列とDictionary上でビジネスロジックを実行
‘ ————————————————————————-
Dim targetRow As Long
For i = 1 To UBound(taskData, 1)
If taskData(i, 1) <> “” Then
‘ 例:進捗が0かつ前提条件を持つタスクに対するメモリ上の解析処理
If taskData(i, 4) = 0 And Len(taskData(i, 5)) > 0 Then
‘ ここで高度な依存関係ツリーの検証などをノーウェイトで実行可能
End If
End If
Next i
‘ ————————————————————————-
‘ 3. 【明示的メモリ解放】COMオブジェクトとコンテナの破棄
‘ ————————————————————————-
Set idMap = Nothing
Set t = Nothing
Set prj = Nothing
Debug.Print “全処理完了: ” & (Timer – startTime) & ” 秒”
End Sub
—
3. 前提条件(依存関係)のグラフ構造化とメモリ最適化
大規模プロジェクトにおける真のボトルネックは、タスク単体の処理ではなく「前提条件(Task.Predecessors)」のパースと依存関係の検証にある。
通常、タスク間のリンクを辿るために `Task.Predecessors` や `Task.Successors` をループ内で呼び出すと、COMレイヤーでクエリが発行され、数秒単位のブロッキングが発生する。
これを解決するため、先ほど構築した `idMap` と `taskData` 配列を使い、メモリ上に有向グラフ(Adjacency List / 隣接リスト)を構築する。
依存関係解析の高速化パターン
1. `Predecessors` 文字列(例: “3FS+2d, 5″)をVBAの `Split` 関数でパースする。
2. 取得した先行タスクのIDをキャッシュ上のインデックスに変換し、親子関係をメモリ上の整数配列(Adjacency Matrix / 2次元配列)に展開する。
3. Projectオブジェクトを一切介さずに、クリティカルパスや循環参照の検出しミュレーションを行う。
‘ 先行タスク文字列を解析してメモリ上で依存関係ツリーを構築するスニペット
Public Sub ParseDependencies(ByRef taskData() As Variant, ByRef idMap As Object)
Dim i As Long
Dim preds As String
Dim predParts() As String
Dim p As Long
For i = 1 To UBound(taskData, 1)
preds = taskData(i, 5) ‘ Predecessors列
If Len(preds) > 0 Then
‘ カンマ区切りで複数の先行タスクを分割
predParts = Split(preds, “,”)
For p = LBound(predParts) To UBound(predParts)
‘ トリム処理やリンクタイプ(FS, SS等)のパースをここで高速に実施
‘ 例: “12FS” からタスクIDを抽出
Dim rawPredToken As String
rawPredToken = Trim(predParts(p))
‘ ※実務では正規表現や文字列操作で純粋なIDを抽出して idMap で逆引きする
Next p
End If
Next i
End Sub
このように、「Projectのデータ構造を一度VBAのプリミティブなメモリ空間にインポートし、処理が終わったら一括反映(または参照のみ)」という原則を貫くことで、処理速度を最大100倍以上に跳ね上げることが可能となる。
—
4. レガシー環境・システム間連携におけるリスクヘッジ
エンタープライズの現場では、古いバージョンのMS Project(Project 2010〜2016など)や、32bit版Officeが未だに現役で稼働しているケースが多い。これらの環境ではメモリ管理の不備が直ちに「メモリ不足(Error 7)」やExcel/Projectの強制終了(クラッシュ)に直結する。
堅牢性を担保するためのエンジニアリング原則
1. Variant配列の強制解放:
大規模な配列変数(`taskData()` 等)は、スコープを抜ける際にメモリに残存する場合がある。処理の終端で `Erase taskData` を明示的に実行し、OSへのメモリ返還を促す。
2. COM参照の完全切断:
ループ内で生成されたオブジェクト参照(特にRangeやTask、Assignmentなど)は、ループのイテレーションごとに `Set obj = Nothing` を行うか、スコープを厳密に分離する。
3. エラーハンドリングとCOM例外の隠蔽:
MS Projectのタスクコレクションは、削除されたタスクや特殊なサマリータスクの構造によって予期せぬエラーを吐く。`On Error Resume Next` は最小限のスコープに限定し、必ず `On Error GoTo 0` で復帰させること。
—
5. チーフアーキテクトからの提言
VBAは「おもちゃの言語」ではない。正しくメモリ管理とCOMの挙動を理解したエンジニアが手綱を握れば、エンタープライズの基幹システムに匹敵するパフォーマンスを発揮する強力なエンジンとなる。
数千タスクを抱えるプロジェクト計画の自動化、ERPや外部進捗管理システムとのAPI連携において、甘いコードは許されない。
「オブジェクトへのアクセスを最小化し、データをメモリに閉じ込める」。このキャッシュ戦略をあなたのコードベースに導入し、圧倒的な実行速度を手に入れてほしい。
