2014/06/07

WPFにおけるPer-Monitor DPI対応の実装

先日参加しためとべや東京勉強会でぐらばくさんが現在のWPFについてセッションされた中でHigh DPI対応について説明されてました。丁度同じ日にプログラミング生放送勉強会で八巻さんがWindowsの画面スケーリングについてセッションされたようで、共通したメッセージとして、High DPI、さらにはPer-Monitor DPIの時代がとっくに来てるのでアプリは対応していく必要がある、ということでした。
一方、Per-Monitor DPI対応を実装しようとすると未解決の部分があって、少しお話したところ、そもそもOS標準でどう処理しているか分からんことには……という話になったので、改めて調べてみました。

[追記] de:code

先日行われたde:codeのセッション「既存デスクトップアプリの最新OSへの対応」の録画が公開されてますが、この中で高DPI対応の説明があるので(21~45分頃)、参考までに。

1. 実装上の課題


初めに、Per-Monitor DPIを実装しようとすると、大まかに2つの課題があります。
  1. ウィンドウをどのタイミングで、画面上のどの位置になるようリサイズすればいいか。
  2. ウィンドウのサイズが狂うのをどう抑えるか。
1番目は、ウィンドウをリサイズする際、その位置によってはそのウィンドウの属するモニター(重なりが一番大きいモニター)が移動元のモニターに戻ってしまい、結果的にリサイズが繰り返される現象に対するもので、2番目は、リサイズがうまく行かずサイズが狂っていってしまう問題に対するものです。WM_DPICHANGEDが来たときに単純にスケーリングをかけるだけでは問題なしとは行かないんですよね。

2. WM_DPICHANGEDのlParam


この1番目の課題については引っ掛かっていたことがあって、WM_DPICHANGEDのlParamについて、これはWin32のRECT構造体で"a RECT structure that provides the size and position of the suggested window, scaled for the new DPI"とだけ説明があり、新しいDPIでのウィンドウの位置とサイズを表す四角形らしいのですが、実際に試してみると同じようにウィンドウを動かしてもその度に違った値が来るので、意味が分からず無視してました。

それが、改めてOS標準の処理である、Per-Monitor DPIに非対応でDPI仮想化されたウィンドウの挙動を見ているうちに気づいたことがあったので、改めてlParamを確認してようやく理解しました。

結論から言うと、この値はウィンドウをドラッグしているときのカーソルの位置と連動しています。

正直「何だそれは」という感じですが、ひとまずこの確認のためのテストアプリは以下のようなもの。
  • 左側はウィンドウの現在の状態で、ウィンドウが移動されるかリサイズされる度に更新されます。"DPI X"はこのウィンドウが属するモニターのPer-Monitor DPI。
  • 右側は最新のWM_DPICHANGEDの情報で、上にwParamに含まれるDPIのXとY、下にlParamに含まれるRECT構造体の位置とサイズをそれぞれ"Suggested Position"と"Suggested Size"として表示します。
  • このアプリはマニフェストファイルでPer-Monitor DPI対応を宣言する一方、リサイズ機能はないので、常に同じサイズで表示されます。
では、1番目に左側のDPI 120のモニターから右側のDPI 96のモニターへ、タイトルバーの左端にカーソルを置いてドラッグした場合。ウィンドウはWM_DPICHANGEDが来た位置で止めています。
  • まずサイズについて、右の"Suggested Size"の432x176は左の"Current Size"の540x220を丁度120から96への比率である0.8倍に縮小した値になっています。これは予想どおり。
  • 次に位置について、"Suggested Position"で示された位置に"Suggested Size"のサイズで描いたものが赤枠の部分です。このときのカーソル位置が赤丸です。赤枠は概ねウィンドウの左上角を固定して縮小していますが、わずかに右に寄っていてカーソルを中心に縮小していることを示しています。
  • 最後にWM_DPICHANGEDが来たタイミングについて、ウィンドウが中央の境界から大きく右寄りに入った後になって来ています。これには意味があって、ウィンドウと赤枠の相対位置はカーソル位置とサイズによって固定されているとして、この赤枠の中心が中央の境界から右側に入ったばかりの位置になっています(境界のX座標の1680に対して赤枠の中心のX座標は1681)。つまり、この赤枠どおりにウィンドウをリサイズしても属するモニターが左側に戻ってしまわない位置というわけで、そうなるようOSがWM_DPICHANGEDを出すタイミングを計っていることを示唆しています。
2番目に、カーソル位置は左端のまま逆方向にドラッグした場合。
  • サイズはDPIの比率と同じく1.25倍になっています。
  • 位置は赤枠がウィンドウよりわずかに左に寄っているとおり、これもカーソルの赤丸を中心に拡大しています。
  • WM_DPICHANGEDが来たタイミングは、今度は中央の境界から大きく左寄りに入った後になって来ています。赤枠のとおりにリサイズすると大きく右側に膨らむわけですが、そうしても属するモニターが右側に戻ってしまわない位置になっています(境界のX座標の1680に対して赤枠の中心のX座標は1679.5)。
3番目に、タイトルバーの中心にカーソルを置いてDPI 120からDPI 96にドラッグした場合。
  • 赤枠はカーソル位置を中心に縮小していることが一目瞭然。
  • WM_DPICHANGEDが来たタイミングは、これも赤枠の中心が中央の境界を越えたとき(境界のX座標の1680に対して赤枠の中心のX座標は1681)。
4番目に、この逆の場合。
  • WM_DPICHANGEDが来たタイミングは、これも赤枠の中心が中央の境界を越えたとき(境界のX座標の1680に対して赤枠の中心のX座標は1679.5)。
5番目に、タイトルバーの右端にカーソルを置いてDPI 120からDPI 96にドラッグした場合。
  • 赤枠は右上角を固定して縮小していますが、これは丸めの問題だと思います。ウィンドウと右上角が重なって見えますが、わずかに左側にずれているので(ウィンドウ右端のX座標の1951に対して赤枠右端のX座標は1949)、カーソル位置を中心に縮小しています。
  • WM_DPICHANGEDが来たタイミングは一見妙で、赤枠の位置からすればもっとウィンドウが左側にあった段階で出せたはずです。一方、これは丁度ウィンドウの中心が中央の境界を越えたときに当たり(境界のX座標の1680に対してウィンドウの中心のX座標は1681)、したがってこのウィンドウが属するモニターが右側に移ったときであることを考えると、WM_DPICHANGEDのアルゴリズムはウィンドウの属するモニターが変わったときに準備に入り、リサイズ後のウィンドウが移動先のモニターに属する位置になったときに発出される、少なくとも2段階になっているのではないかと推測できます。
6番目に、この逆の場合。
  • 赤枠はカーソル位置を中心に拡大しています。
  • WM_DPICHANGEDが来たタイミングは、ウィンドウの中心が中央の境界に重なったときで、左の"DPI X"が120になっていることが示すようにウィンドウの属するモニターが左側に移ったときに当たります。これはアルゴリズムが2段階あるという推測を裏付けるものだと思います。
これでウィンドウを移動させたときについては把握できたと思いますが、ウィンドウのリサイズによって属するモニターが変わったときにどうなるかというと、右側に伸ばした場合が7番目、左側に縮めた場合が8番目。
  • リサイズでモニターが変わった場合、サイズも位置も元と同じものが通知されます(DPIの方だけ変化)。これはOS標準のエクスプローラも同じ挙動なので、ユーザーがウィンドウをリサイズしている最中に自動的にリサイズする必要はないという考え方なのだろうと思います。
最後にウィンドウが静止している状態でディスプレイ設定でDPIを変えたときを確認すると、96から120に変えた場合が9番目、120から96に変えた場合が10番目。
  • 赤枠は単純に左上角を固定して拡大、縮小しています。このリサイズで属するモニターが変わるような位置にウィンドウがあった場合にはさらに何かあるのかもしれませんが、とりあえず。
以上をまとめると、WM_DPICHANGEDのlParamは、
  • ウィンドウをドラッグしてモニター間を移動させたときは、カーソル位置を中心としてリサイズし、リサイズしても元のモニターに戻らない位置の四角形を示す。
  • ウィンドウをリサイズして属するモニターを変えたときは、元のウィンドウと同じ四角形を示す。
  • ディスプレイ設定でDPIを変えたときは、左上角を固定してリサイズした四角形を示す。
ということになります。Microsoftにはしっかりドキュメント化してくれよと言いたいところ。

3. Microsoftの導き


ここまで見ると「実はWM_DPICHANGEDのlParamのとおりにスケーリングすればいいんじゃないの?」と考えるのは自然で、

信じる者は救われる

というか、Microsoft自身のサンプルがそうなっています。
このサンプルの実体部分はC++ですが、ウィンドウのリサイズはこうなっています。
IntPtr PerMonitorDPIWindow::HandleMessages(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, bool% )
{
double oldDpi;
switch (msg)
    {
    case WM_DPICHANGED:
    LPRECT lprNewRect = (LPRECT)lParam.ToPointer();
    SetWindowPos(static_cast<HWND>(hwnd.ToPointer()), 0, lprNewRect->left, lprNewRect-
        >top, lprNewRect->right - lprNewRect->left, lprNewRect->bottom - lprNewRect->top, 
       SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOACTIVATE);
    oldDpi = m_currentDPI;
    m_currentDPI = static_cast<int>(LOWORD(wParam.ToPointer()));
    if (oldDpi != m_currentDPI) 
        {
        OnDPIChanged();
        }
    break;
    }
return IntPtr::Zero;
}
これはWM_DPICHANGEDからlParamを取り出して、ノータイムでそのままSetWindowPos関数に入れてウィンドウの位置とサイズを設定しています。

「もうこれでいいかな……」と思ったりもするわけですが、

ところが、ぎっちょん!

このサンプルを実行してみると、モニター間を動かすうちにウィンドウのサイズがどんどん狂っていきます。初めに挙げた課題の2番目の話になりますが、サイズの狂いに脆弱な感じです。

では手を加えるかと思っても、既にアプリのコードとしてはほぼ最短の処理になっているので、この方法のままでは望み薄のような……。

またUIの観点からは、カーソル位置を中心に自分で計算してリサイズするものを試してみましたが、ウィンドウが飛ぶ印象が強いです。そもそもカーソル位置を中心にリサイズするようにしているのは、ドラッグ中に縮小したときにカーソルがウィンドウから外に出てしまう(注:この状態でもドラッグは継続する)ことを避ける狙いがあるのではないかと思いますが、ドラッグ中にカーソルを見ているわけでもなし、むしろウィンドウ位置が飛んでしまうデメリットがあるわけで、個人的にはベストのやり方とは思えないこともあります。

4. 実装


では結局どうするかというと、1番目の課題については以下のようにしました。
  • ウィンドウをドラッグしてモニター間を移動させたときは、リサイズは左上角を固定して行うものとし、そのタイミングはOS標準と同じく、リサイズしても大丈夫な位置にウィンドウが来るのを待って実行する(前に考えた遅延型と同じ)。
  • ウィンドウをリサイズして属するモニターが変わったときは、OS標準に従う(内部のスケーリングだけ行う)。
  • ウィンドウが静止中にDPIが変わったときは、OS標準に従う(どうせなのでWM_DPICHANGEDのlParamを利用する)。
2番目の課題については、色々試しましたが、どうも原因が絞り込めませんでした。現象としてははっきりしていて、ドラッグ中の忙しいときにリサイズすると設定どおりに描画されず、そのままスルーされることがある、というものです。これを確実に避けるにはドラッグ終了後にリサイズすればいいのですが、それも面白くないので試行錯誤しましたが、確実に抑え込める方法は分かりませんでした。

そこで次善の策として、ドラッグ開始時にウィンドウのサイズを内部的に記録しておき、ドラッグ中はその値をベースにリサイズの計算をし(ドラッグ中のウィンドウのサイズは信用しない)、最後の砦としてドラッグ終了後にもう一度リサイズ処理を行う(既に正常にリサイズされていれば何も変わらない)ことにしました。なお、リサイズしても大丈夫な位置までウィンドウが移動されなかった場合は、次の移動の機会に処理を継続します。

コーディングに当たっては、先達の実装例を参考にさせていただきました。
とくにぐらばくさんのDpi構造体は秀逸なアイデアだと思ったので、取り入れさせていただきました。

ということで、自分の実装は以下のとおりです。

[追記] 静止状態でのDPI変更

ウィンドウが静止している状態でディスプレイ設定でDPIを変えた場合(9番目と10番目)について、上記はシステムDPIが96の状態でPer-Monitor DPIを変更した結果でしたが、今更ながらシステムDPIが120の状態では違ってました。

まずシステムDPIが120の状態で(OSの起動時のDPIが120)、DPIを120から96に下げた結果を11番目として。
  • これは10番目に対応しますが、10番目ではlParamの示すサイズはDPIを下げた分だけ縮小されてましたが(元の80%)、この11番目では元のサイズのままです。
次にこの状態からDPIを120に戻した結果が以下の12番目。
  • これは9番目に対応しますが、9番目ではlParamの示すサイズはDPIを上げた分だけ拡大されてましたが(元の125%)、この12番目では元の約156%になっています。
これらの数字がどうして出てきたかは前後のDPIなど関係する数字から計算すれば分かるだろうと思いますが、それぐらいなら初めから自分で計算した方が早いんですよね。

ということで、静止中にDPIが変わったときはlParamをそのまま使ってもいいかと思ってましたが、やはり他の場合と同じく自分で計算した方がいいようです。

2014/05/26

ExifをC#から編集する

写真の画像ファイルを扱うアプリを作るとき、意外と無視できない要素がExifのメタデータで、例えば画像方向(Orientation)が反映されてないとどうも気になったりする。そこでExifを読み出すようにするまではいいが、さらに一歩進んでExifを書き込もうとすると、これが意外と手がかかるという話。

1. 背景


JPG形式の画像は再圧縮を繰り返すと画質が劣化する。したがって、再圧縮せずExifだけ編集したい場合、System.Windows.Media.Imaging名前空間にはおあつらえ向きのInPlaceBitmapMetadataWriterが存在する。で、早速これでJPGファイルのExifを編集しようとすると、成功しない。

その理由は既に説明されていて、これでExifのメタデータを書き込むにはそのJPGファイルのメタデータにそのための領域、Paddingが必要なのだが、カメラがJPGファイルに記録するときにはPaddingが付けられていないという問題がある。
この問題を解決するためのサンプルコード(サンプル中の"UsingInPlaceBitmapMetadataWriter"プロジェクト)も示されているので、これをベースに実用的なメソッドを仕立ててみる。

2. コーディング


流れとしては、以下のとおり。
  1. 画像データを開き、メタデータにPaddingを付けた上で一旦保存する。この際、画像データが再圧縮されないようにする。
  2. 画像データをInPlaceBitmapMetadataWriterで開き、InPlaceBitmapMetadataWriterのプロパティで公開されているフィールドであればそのプロパティを通して、あるいはSetQueryメソッドなどでフィールドのパスを直接指定して編集した後、TrySaveメソッドで保存する。
例として、撮影日時(DateTaken)を編集するようにしたコードは以下のとおり。入力はI/Oバウンドなメソッドに合わせやすいStreamで、出力は加工しやすいByte配列で。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Windows.Media.Imaging;

/// <summary>
/// Query paths for padding
/// </summary>
private static readonly List<string> queryPadding = new List<string>()
{
    "/app1/ifd/PaddingSchema:Padding", // Query path for IFD metadata
    "/app1/ifd/exif/PaddingSchema:Padding", // Query path for EXIF metadata
    "/xmp/PaddingSchema:Padding", // Query path for XMP metadata
};

/// <summary>
/// Edit date taken field of Exif metadata of image data.
/// </summary>
/// <param name="source">Stream of source image data in JPG format</param>
/// <param name="date">Date to be set</param>
/// <returns>Byte array of outcome image data</returns>
public static Byte[] EditDateTaken(Stream source, DateTime date)
{
    if (source == null)
        throw new ArgumentNullException("source");

    if (date == null)
        throw new ArgumentNullException("date");

    if (0 < source.Position)
        source.Seek(0, SeekOrigin.Begin);

    // Create BitmapDecoder for a lossless transcode.
    var sourceDecoder = BitmapDecoder.Create(
        source,
        BitmapCreateOptions.PreservePixelFormat | BitmapCreateOptions.IgnoreColorProfile,
        BitmapCacheOption.None);

    // Check if the source image data is in JPG format.
    if (!sourceDecoder.CodecInfo.FileExtensions.Contains("jpg"))
        return null;

    if ((sourceDecoder.Frames[0] == null) || (sourceDecoder.Frames[0].Metadata == null))
        return null;

    var sourceMetadata = sourceDecoder.Frames[0].Metadata.Clone() as BitmapMetadata;

    // Add padding (4KiB) to metadata.
    queryPadding.ForEach(x => sourceMetadata.SetQuery(x, 4096U));

    using (var ms = new MemoryStream())
    {
        // Perform a lossless transcode with metadata which includes added padding.
        var outcomeEncoder = new JpegBitmapEncoder();

        outcomeEncoder.Frames.Add(BitmapFrame.Create(
            sourceDecoder.Frames[0],
            sourceDecoder.Frames[0].Thumbnail,
            sourceMetadata,
            sourceDecoder.Frames[0].ColorContexts));

        outcomeEncoder.Save(ms);

        // Create InPlaceBitmapMetadataWriter.
        ms.Seek(0, SeekOrigin.Begin);

        var outcomeDecoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.Default);

        var metadataWriter = outcomeDecoder.Frames[0].CreateInPlaceBitmapMetadataWriter();

        // Edit date taken field by accessing property of InPlaceBitmapMetadataWriter.
        metadataWriter.DateTaken = date.ToString();

        // Edit date taken field by using query with path string.
        metadataWriter.SetQuery("/app1/ifd/exif/{ushort=36867}", date.ToString("yyyy:MM:dd HH:mm:ss"));

        // Try to save edited metadata to stream.
        if (metadataWriter.TrySave())
        {
            Debug.WriteLine("InPlaceMetadataWriter succeeded!");
            return ms.ToArray();
        }
        else
        {
            Debug.WriteLine("InPlaceMetadataWriter failed!");
            return null;
        }
    }
}
ポイントは、BitmapDecoder.CreateメソッドでBitmapCreateOptionsにBitmapCreateOptions.PreservePixelFormatとBitmapCreateOptions.IgnoreColorProfileを指定し、BitmapCacheOptionにBitmapCacheOption.Noneを指定することで(36-39行目)、これで再圧縮が行われなくなる。

Paddingのサイズは4KiBを指定しているが(51行目)、元記事によればたいていの書き込みで1-5KiBの範囲で収まるとのことなので、余っていると思う。

Exifの編集は、BitmapCreateOptions.IgnoreColorProfileのプロパティを通したもの(74行目)と、フィールドのパスを直接指定したもの(77行目)で、これはどちらかでいい。それぞれの場合の留意事項として、

プロパティを通す場合
  • そもそもプロパティで公開されているフィールドは限られていて、その中でも機能しないものがある(例えばTitleはうまく行かなかった)。
パスを直接指定する場合
  • フィールドの値について、SetQueryメソッドの引数はObjectなので何でも入れられるが、その前に型をきちんと合わせる必要がある。型が文字列や数値の場合は別に難しくないが、DateTimeの場合、この例のように書式指定したToStringメソッドで文字列にする必要があった(プロパティを通す場合は書式指定は不要)。

  • なお、パス自体を書く代わりにSystem.Photo名前空間の該当するPhoto Metadata Policyの文字列(この場合はSystem.Photo.DateTakenなので、"System.Photo.DateTaken")を使うことも可能、なはずなのだが、このフィールドではうまく行かなかった。

    ついでに、たまたま気づいたが、このPhoto Metadata PolicyにSystem.Photo.ISOSpeedがあるが、このパス"/app1/ifd/exif/{ushort=34855}"のタグ番号34855はExif 2.2のISOSpeedRatingsに当たる一方、Exif 2.3ではPhotographicSensitivityに変更され、新たにタグ番号34867のISOSpeedが設けられている。したがって、Exif 2.3のISOSpeedを指定したつもりで古い別のフィールドを指定することがあり得る。
という次第で、実際に使うフィールドごとに関係資料を見つつ確認していく地道な作業が必要だと思う。

一応、このメソッドの使用例。sourcePathのパスにあるJPGファイルのExifの撮影日時を変更し、outcomePathのパスに保存する。
private async Task ChangeDateTaken(string sourcePath, string outcomePath, DateTime date)
{
    byte[] buff;

    using (var sourceStream = File.Open(sourcePath, FileMode.Open))
    using (var ms = new MemoryStream())
    {
        await sourceStream.CopyToAsync(ms);

        buff = BitmapMetadataEditor.EditDateTaken(ms, date);
    }

    if (buff == null)
        return;

    using (var outcomeStream = File.Create(outcomePath))
    {
        await outcomeStream.WriteAsync(buff, 0, buff.Length);
    }
}
フィールドを固定しない汎用的なメソッドにしたサンプルコード。フィールドの値の引数は型をObjectにしてあるが、それぞれ適した型にして引数に入れる必要がある。

2014/05/09

ファイルを使用中のアプリをC#から調べる

あるファイルを使用中のアプリ(プロセス)を知りたい、少なくともその有無だけでも知りたい、という場面が時々あるが、.NET Frameworkにはこれを簡単に調べる方法が用意されていない。よって、間接的に対応する方法が色々編み出されてきたが、直接的に調べるAPIも(P/Invokeにはなるが)存在する。

目的を果たせれば方法は何でもいいが、直接、ストレートにできる方法があるならその方がいいと思う。具体的にはRestart Manager APIを利用する方法だが、これはVista以降でのみ使用可。したがって従来なら少し二の足を踏むところだが、晴れてXPのことは考えなくてよくなったので(一応)、これも一つの福音と言えよう。

1. ラッパークラス


具体的な方法は既に説明されていて、必要なことは全部書いてある。
  • MSDN Magazine 2007 April: 再起動マネージャとジェネリックメソッドのコンパイル
これをもう少し使いやすい形にしてみる。

処理としては、大まかに以下の4段階。
  1. Restart Managerセッションを開始する(RmStartSession)
  2. 対象のファイルをセッションに登録する(RmRegisterResource)
  3. 対象のファイルを使用中のプロセスをセッションから取得する(RmGetList)
  4. セッションを終了する(RmEndSession)
これを行うラッパークラスは以下のとおり。
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;

/// <summary>
/// A partial wrapper class for Restart Manager API
/// </summary>
/// <remarks>
/// This class is based on http://msdn.microsoft.com/en-us/magazine/cc163450.aspx
/// To use this class, OS has to be Windows Vista or newer.
/// </remarks>
public static class RestartManager
{
  #region "Win32"

  // Start a Restart Manager session.
  [DllImport("Rstrtmgr.dll", CharSet = CharSet.Unicode)]
  private static extern uint RmStartSession(
    out uint pSessionHandle,
    uint dwSessionFlags,
    string strSessionKey);

  // End a Restart Manager session.
  [DllImport("Rstrtmgr.dll")]
  private static extern uint RmEndSession(
    uint dwSessionHandle);

  // Register target files to a Restart Manager session.
  [DllImport("Rstrtmgr.dll", CharSet = CharSet.Unicode)]
  private static extern uint RmRegisterResources(
    uint dwSessionHandle,
    uint nFiles,
    string[] rgsFilenames,
    uint nApplications,
    RM_UNIQUE_PROCESS[] rgApplications,
    uint nServices,
    string[] rgsServiceNames);

  // Get processes using target files with a Restart Manager session.
  [DllImport("Rstrtmgr.dll")]
  private static extern uint RmGetList(
    uint dwSessionHandle,
    out uint pnProcInfoNeeded,
    ref uint pnProcInfo,
    [In, Out] RM_PROCESS_INFO[] rgAffectedApps,
    out uint lpdwRebootReasons);
  
  [StructLayout(LayoutKind.Sequential)]
  private struct RM_UNIQUE_PROCESS
  {
    public uint dwProcessId;
    public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime;
  }

  [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
  private struct RM_PROCESS_INFO
  {
    public RM_UNIQUE_PROCESS Process;

    // CCH_RM_MAX_APP_NAME + 1 = 256
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
    public string strAppName;

    // CCH_RM_MAX_SVC_NAME + 1 = 64
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
    public string strServiceShortName;

    public RM_APP_TYPE ApplicationType;
    public uint AppStatus;
    public uint TSSessionId;

    [MarshalAs(UnmanagedType.Bool)]
    public bool bRestartable;
  }

  private enum RM_APP_TYPE
  {
    /// <summary>
    /// The application cannot be classified as any other type.
    /// </summary>
    RmUnknownApp = 0,

    /// <summary>
    /// A Windows application run as a stand-alone process that displays a top-level window.
    /// </summary>
    RmMainWindow = 1,

    /// <summary>
    /// A Windows application that does not run as a stand-alone process and does not display a top-level window.
    /// </summary>
    RmOtherWindow = 2,

    /// <summary>
    /// The application is a Windows service.
    /// </summary>
    RmService = 3,

    /// <summary>
    /// The application is Windows Explorer.
    /// </summary>
    RmExplorer = 4,

    /// <summary>
    /// The application is a stand-alone console application.
    /// </summary>
    RmConsole = 5,

    /// <summary>
    /// The process may be a critical process and cannot be shut down.
    /// </summary>
    RmCritical = 1000
  }

  private const uint ERROR_SUCCESS = 0;
  private const uint ERROR_MORE_DATA = 234;

  #endregion

  /// <summary>
  /// Check if any process is using a specified file.
  /// </summary>
  /// <param name="filePath">Path of target file</param>
  /// <returns>True if using</returns>
  public static bool IsProcessesUsingFile(string filePath)
  {
    bool isUsing = false;
    foreach (var proc in EnumerateProcessesUsingFiles(new[] { filePath }))
    {
      if (proc == null)
        continue;

      isUsing = true;
      proc.Dispose();
    }

    return isUsing;
  }

  /// <summary>
  /// Enumerate processes using specified files.
  /// </summary>
  /// <param name="filePaths">Paths of target files</param>
  /// <returns>Processes using target files</returns>
  /// <remarks>Caller is responsible for disposing the processes.</remarks>
  public static IEnumerable<Process> EnumerateProcessesUsingFiles(param string[] filePaths)
  {
    if ((filePaths == null) || !filePaths.Any())
      yield break;

    if (!OsVersion.IsVistaOrNewer)
      yield break;

    uint sessionHandle = 0; // Handle to Restart Manager session

    try
    {
      // Start a Restart Manager session.
      var result1 = RmStartSession(
        out sessionHandle,
        0,
        Guid.NewGuid().ToString("N"));

      if (result1 != ERROR_SUCCESS)
        throw new Win32Exception("Failed to start a Restart Manager session.");

      // Register target files to the session.
      var result2 = RmRegisterResources(
        sessionHandle,
        (uint)filePaths.Length,
        filePaths,
        0U,
        null,
        0U,
        null);

      if (result2 != ERROR_SUCCESS)
        throw new Win32Exception("Failed to register target files to a Restart Manager session.");

      // Get processes using target files with the session.
      uint pnProcInfoNeeded = 0;
      uint pnProcInfo = 0;
      RM_PROCESS_INFO[] rgAffectedApps = null;
      uint lpdwRebootReasons;

      uint result3 = 0;

      do
      {
        result3 = RmGetList(
          sessionHandle,
          out pnProcInfoNeeded,
          ref pnProcInfo,
          rgAffectedApps,
          out lpdwRebootReasons);

        switch (result3)
        {
          case ERROR_SUCCESS: // The size of RM_PROCESS_INFO array is appropriate.
            if (pnProcInfo == 0)
              break;

            // Yield the processes.
            foreach (var app in rgAffectedApps)
            {
              Process proc = null;
              try
              {
                proc = Process.GetProcessById((int)app.Process.dwProcessId);
              }
              catch (ArgumentException)
              {
                // None (In case the process is no longer running).
              }

              if (proc != null)
                yield return proc;
            }
            break;

          case ERROR_MORE_DATA: // The size of RM_PROCESS_INFO array is not enough.
            // Set RM_PROCESS_INFO array to store the processes.
            rgAffectedApps = new RM_PROCESS_INFO[(int)pnProcInfoNeeded];
            pnProcInfo = (uint)rgAffectedApps.Length;
            break;

          default:
            throw new Win32Exception("Failed to get processes using target files with a Restart Manager session.");
        }
      }
      while (result3 != ERROR_SUCCESS);
    }
    finally
    {
      // End the session.
      RmEndSession(sessionHandle);
    }
  }
}
EnumerateProcessesUsingFilesメソッド中の3段階目のRmGetListがポイントで、使用中のプロセスの情報を格納するためのRM_PROCESS_INFO構造体の配列の長さをどう決めるかが問題になる。初回は183と184行のように配列の長さを0、配列をnullとしてRmGetListを実行すると、
  • 使用中のプロセスが存在しない場合、配列はそのままでよく、ERROR_SUCCESSが返ってくるので、そのまま抜ける。
  • 使用中のプロセスが存在する場合、配列の長さが足りず、ERROR_MORE_DATAが返ってくるので、224と225行のように配列を整え直した上でループさせ、再度RmGetListを実行する。
    • それで問題なければ、ERROR_SUCCESSが返ってくるので、プロセスをyield returnで返す。もしこの間に終了しているプロセスがあればArgumentExceptionが出るが、それはtry-catchで潰す。
    • もしプロセスが増えていれば、同じことの繰り返し。
という流れになる。

このクラスを含むサンプルアプリ。

[修正]

IsProcessesUsingFileメソッドの中でプロセスをDisposeするように修正し、合わせてEnumerateProcessesUsingFilesメソッドをIEnumerable<Process>を返すように変更した。

2. 注意点


返ってきたList<Process>を使って如何ようにも処理は可能だが、注意点としてこのメソッドの前後の短い間にもプロセスの状況は変わる可能性があるので、例えばファイルに書き込む前の事前チェックに使う場合でもtry-catchで例外に備えるべき必要性は変わらない。