はじめに
Windowsエクスプローラーに存在しないファイルをドロップする(Part3)の続きです。
What/How
この課題に対するソリューションは複数あり、やりたいことが複雑になるほど実装も複雑になります。複雑になるほど汎用ソリューションとして使えますが、保守性を考えると適切なレベルで妥協するほうがよいでしょう。
それぞれ内容が全く異なるため記事を分けます。簡単な順に
- ファイルは1つだけで、ファイルのプロパティ情報(ファイル名・サイズ等)はすでに分かっており、ファイルのデータもすでに手元にある→Part1
- ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。未定義動作が起きる可能性を許容する。→Part2
- ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。定義済み動作のみ許容する。→Part3
- ファイルは複数あり、ファイルのプロパティ情報は取得に時間がかかり、ファイルのデータも取得に時間がかかる。デスクトップアプリを応答不能にしたくない。→本記事
- エクスプローラーにドロップするなら、せっかくだしドラッグ中のイメージとかテキストとかも表示したい。↓こういうの→ドラッグドロップ時にイメージを表示する
- いっそのことドロップ先のフォルダーがわかれば一番手っ取り早い。→Windowsエクスプローラーへファイルドロップした際、ドロップ先のフォルダーを取得する
「デスクトップアプリを応答不能にしない」とは
はじめになぜこの記事が必要かについて説明します。WinFormsにしろ、WPFにしろ、Part1~3のコードはすべて同期的に動作していました。つまり、DataObject
内の処理はすべてUIスレッドで実行され、その間DoDragDrop
メソッドは戻らず以降の処理は実行されませんでした。これはつまり、DataObject
内で時間のかかる処理(ネットワーク上のファイル取得など)を行った場合、UIが固まることを意味します。Part4の前提は「ファイルのプロパティ情報は取得に時間がかかり、ファイルのデータも取得に時間がかかる」ですので、なるべくだったらユーザーがドロップ操作をするまでデータ取得は遅延させたい。しかしドロップしてからデータを取得してはUIが固まる。事前にデータを取得しておくことはできるけど、ユーザーがドラッグ&ドロップをしなければ無駄になってしまうので避けたい。
この悩みを解決してくれるのがIDataObjectAsyncCapability
インタフェースです。名前の通り、DataObject
にAsyncなCapabilityを付与するためのインタフェースです。このインタフェースを実装したときの動作についてはシェルデータ転送シナリオの処理-シェルオブジェクトの非同期的なドラッグ アンド ドロップで詳しく説明されています。簡単にいうと、Windowsエクスプローラー側で別スレッドを立ててそこでDataObject
処理を行い、終わったら通知してくれる仕組みです。
IStream
インタフェース
IStream
インタフェースというインタフェースがあります。これはデータの流れを抽象的に表すいわゆる"Stream"のCOMインタフェースです。Part3まではファイルのデータはすでにメモリに保持しており、それをWindowsエクスプローラーに渡す処理を書いてきましたが、今回はファイルのデータを非同期的に取得します。そのデータをメモリ上に保持してからWindowsエクスプローラーに渡すのはいかにも効率が悪いです。そこでIStream
インタフェースを通じてデータを渡すことにします。このIStream
インタフェースはIDataObject
で渡すことのできるデータ媒体となっており、抽象的なデータを効率よく転送することができます。
実装
Part3でフレームワーク依存の内部実装まで突っ込んで疲れたので、Part4ではNativeな実装を心がけます。Win32なコードが本格的に出ますが、ドキュメント通りに実装すればそれほど難しくはありません。また、Win32で書いておけばWinFormsでもWPFでも同じコードを使えます。
IDataObjectAsyncCapability
インタフェース
まずIDataObjectAsyncCapability
インタフェースをC#で使うためにインタフェースを定義します。
[ComImport]
[Guid("3D8B0590-F691-11d2-8EA9-006097DF5BD4")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IDataObjectAsyncCapability
{
void SetAsyncMode([In][MarshalAs(UnmanagedType.Bool)] bool fDoOpAsync);
void GetAsyncMode([Out][MarshalAs(UnmanagedType.Bool)] out bool pfIsOpAsync);
void StartOperation([In] IBindCtx pbcReserved);
void InOperation([Out][MarshalAs(UnmanagedType.Bool)] out bool pfInAsyncOp);
void EndOperation([In] int hResult, [In] IBindCtx pbcReserved, [In][MarshalAs(UnmanagedType.U4)] DragDropEffects dwEffects);
}
普段マネージドコードに慣れ親しんでいるひとには見慣れないAttributeがたくさんあると思いますが、これらの解説は範囲を超えるので割愛します。ネイティブコードと共有できるインタフェースを宣言し、その間のデータ変換を定義してる程度の理解で十分です。
実装はすごく簡単。渡された値を保持するだけです。このインタフェースは実装していること自体が重要で、その中身はあまり重要ではありません。
void IDataObjectAsyncCapability.SetAsyncMode(bool fDoOpAsync)
{
this.isAsync = fDoOpAsync;
}
void IDataObjectAsyncCapability.GetAsyncMode(out bool pfIsOpAsync)
{
pfIsOpAsync = this.isAsync;
}
void IDataObjectAsyncCapability.StartOperation(IBindCtx pbcReserved)
{
this.isInAsyncOperation = true;
}
void IDataObjectAsyncCapability.InOperation([Out][MarshalAs(UnmanagedType.Bool)] out bool pfInAsyncOp)
{
pfInAsyncOp = this.isInAsyncOperation;
}
void IDataObjectAsyncCapability.EndOperation(int hResult, IBindCtx pbcReserved, DragDropEffects dwEffects)
{
this.isInAsyncOperation = false;
}
IDataObject
インタフェース
Part3でも出てきたSystem.Runtime.InteropServices.ComTypes.IDataObject
インタフェースのGetData
メソッド実装を示します。
public void GetData(ref FORMATETC format, out STGMEDIUM medium)
{
medium = new STGMEDIUM();
var query = this.QueryGetData(ref format);
if (query != NativeMethods.S_OK)
{
Marshal.ThrowExceptionForHR(query);
return;
}
if (format.cfFormat == FileGroupDescriptorId)
{
if (this.isAsync)
{
if (this.isInAsyncOperation)
{
// (1)
medium.unionmember =
this.AllocFileGroupDescriptorToHGlobalAsync(IntPtr.Zero).Result;
medium.tymed = TYMED.TYMED_HGLOBAL;
}
else
{
// (2)
medium.unionmember = IntPtr.Zero;
medium.tymed = TYMED.TYMED_NULL;
}
}
else
{
// (3)
medium.unionmember =
this.AllocFileGroupDescriptorToHGlobalAsync(IntPtr.Zero).Result;
medium.tymed = TYMED.TYMED_HGLOBAL;
}
}
else if (format.cfFormat == FileContentsId &&
this.fcFetches![format.lindex] is not null)
{
var fcFetch = this.fcFetches![format.lindex];
var stream = fcFetch!().Result;
var comStream = new ReadStream(stream);
this.fetchedStreams.Add(comStream);
medium.unionmember = Marshal.GetIUnknownForObject(comStream);
medium.tymed = TYMED.TYMED_ISTREAM;
}
}
FileGroupDescriptor要求時
(1) 非同期操作かつドロップされた
この場合には別スレッドで呼ばれているためTask.Result
でスレッドを停止してもUIは固まりません。
(2) 非同期操作かつドロップされてない
ドロップ前にフライングでFileGroupDescriptorを取得されることがあります。ここでスレッドを停止させるとUIが固まるためTask
を待機するわけにはいきません。この場合素直にNULLをデータとして返します。最終的に使われるファイル情報は(1)呼び出しで取得されるため、ここではNULLを返してかまいません。
(3) 同期操作
同期操作の場合にはUIが固まるのもやむなしです。おとなしくTask
を待機します。
FileContents要求時
Part3と同じようにlindexを使ってファイルを区別します。この時点では既に別スレッドで動いているためTask
を待機します。その後得られたStream
をIStream
実装でラップし、Marshal.GetIUnknownForObject
メソッドでポインタにして呼び出し元に返します。ここで得られるポインタはマネージドオブジェクトそのもののポインタではなく、Com Callable Wrapper1と呼ばれるオブジェクトのポインタになりますが、深く気にする必要はありません。
IStream
インタフェース
IStream
インタフェースはSystem.Runtime.InteropServices.ComTypes.IStream
に既に宣言されているため改めて定義する必要はありません。今回は必要最小限のメソッドだけ実装しています。
internal sealed class ReadStream : IStream, IDisposable
{
private readonly Stream proxiedStream;
public ReadStream(Stream stream)
{
this.proxiedStream = stream;
}
void IStream.Read(byte[] buffer, int bufferSize, IntPtr bytesReadPtr)
{
int bytesRead = proxiedStream.Read(buffer, 0, (int)bufferSize);
if (bytesReadPtr != IntPtr.Zero)
{
Marshal.WriteInt32(bytesReadPtr, bytesRead);
}
}
void IStream.Stat(out STATSTG streamStats, int grfStatFlag)
{
streamStats = new STATSTG
{
type = (int)STGTY.STGTY_STREAM,
grfMode = 0
};
try
{
streamStats.cbSize = proxiedStream.Length;
}
catch (NotSupportedException) { }
switch (proxiedStream.CanRead, proxiedStream.CanWrite)
{
case (true, true):
streamStats.grfMode |= (int)StgmConstants.STGM_READWRITE;
break;
case (true, false):
streamStats.grfMode |= (int)StgmConstants.STGM_READ;
break;
case (false, true):
streamStats.grfMode |= (int)StgmConstants.STGM_WRITE;
break;
default:
throw new IOException();
}
}
// 以下略
ドラッグの開始
System.Runtime.InteropServices.ComTypes.IDataObject
インタフェースを使ってドラッグを開始するにはWin32のAPIを呼び出さなくてはなりません。下記宣言でWin32 APIのDoDragDrop
をマネージドコードから使えるようにします。
[DllImport("ole32.dll")]
public static extern int DoDragDrop(
IComDataObject pDataObject,
IDropSource pDropSource,
[MarshalAs(UnmanagedType.I4)] DragDropEffects dwOKEffect,
[Out][MarshalAs(UnmanagedType.I4)] out DragDropEffects pdwEffect);
言語およびフレームワーク
今回はC#と.NETで実装しましたが、COMは言語やフレームワークを問いません。C, C++, Visual Basic, Delphi, Pythonなどでも同じように実装できます。
ソースコード全体
上記のコードそのままではなくさらにブラッシュアップしていますが、https://github.com/miswil/DropFiles/tree/master/DropMultipleFilesComAsync にあります。
所感
この記事を書きたくてPart1から段階を踏んでいました。IDataObjectAsyncCapability
インタフェースとIStream
インタフェースまで使ってマネージドコードでドラッグ&ドロップをしている技術情報は少なく、ネイティブコードやCOMという技術も相まって、ここまでの情報をまとめるには結構苦労しました。いま改めて調べればある程度の情報は出てきますが、キーワードにたどり着くまでが難しいものです。