LoginSignup
1

Windowsエクスプローラーに存在しないファイルをドロップする(Part4)

Last updated at Posted at 2024-01-20

はじめに

Windowsエクスプローラーに存在しないファイルをドロップする(Part3)の続きです。

What/How

この課題に対するソリューションは複数あり、やりたいことが複雑になるほど実装も複雑になります。複雑になるほど汎用ソリューションとして使えますが、保守性を考えると適切なレベルで妥協するほうがよいでしょう。
それぞれ内容が全く異なるため記事を分けます。簡単な順に

  1. ファイルは1つだけで、ファイルのプロパティ情報(ファイル名・サイズ等)はすでに分かっており、ファイルのデータもすでに手元にある→Part1
  2. ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。未定義動作が起きる可能性を許容する。→Part2
  3. ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。定義済み動作のみ許容する。→Part3
  4. ファイルは複数あり、ファイルのプロパティ情報は取得に時間がかかり、ファイルのデータも取得に時間がかかる。デスクトップアプリを応答不能にしたくない。→本記事
  5. エクスプローラーにドロップするなら、せっかくだしドラッグ中のイメージとかテキストとかも表示したい。↓こういうの→ドラッグドロップ時にイメージを表示する
    image.png
  6. いっそのことドロップ先のフォルダーがわかれば一番手っ取り早い。→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#で使うためにインタフェースを定義します。

IDataObjectAsyncCapability.cs
[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がたくさんあると思いますが、これらの解説は範囲を超えるので割愛します。ネイティブコードと共有できるインタフェースを宣言し、その間のデータ変換を定義してる程度の理解で十分です。
実装はすごく簡単。渡された値を保持するだけです。このインタフェースは実装していること自体が重要で、その中身はあまり重要ではありません。

MyDataObject.cs
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メソッド実装を示します。

MyDataObject.cs
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を待機します。その後得られたStreamIStream実装でラップし、Marshal.GetIUnknownForObjectメソッドでポインタにして呼び出し元に返します。ここで得られるポインタはマネージドオブジェクトそのもののポインタではなく、Com Callable Wrapper1と呼ばれるオブジェクトのポインタになりますが、深く気にする必要はありません。

IStreamインタフェース

IStreamインタフェースSystem.Runtime.InteropServices.ComTypes.IStreamに既に宣言されているため改めて定義する必要はありません。今回は必要最小限のメソッドだけ実装しています。

ReadStream.cs
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をマネージドコードから使えるようにします。

NativeMethods.cs
[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という技術も相まって、ここまでの情報をまとめるには結構苦労しました。いま改めて調べればある程度の情報は出てきますが、キーワードにたどり着くまでが難しいものです。

  1. COM 呼び出し可能ラッパー

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