2014/12/31

デスクトップアプリでインターネット接続を確認する

確かデスクトップアプリの.NET Frameworkにはインターネット接続状況を直接確認できるAPIが何故か存在しません。一方、WinRTにはこれがあります。

NetworkInformationクラスのConnectionProfile.GetNetworkConnectivityLevelメソッドを使うとNetworkConnectivityLevelが取得できます。この値は以下のとおりですが、
  1. None
  2. LocalAccess
  3. ConstrainedInternetAccess
  4. InternetAccess
普通のインターネット接続はInternetAccessなので、インターネット接続の有無は以下のようなメソッドで確認できます。
// using Windows.Networking.Connectivity;
 
private bool CheckInternetConnection()
{
  var profile = NetworkInformation.GetInternetConnectionProfile();
  if (profile == null)
    return false;
 
  return (profile.GetNetworkConnectivityLevel() >= NetworkConnectivityLevel.InternetAccess);
}
インターネット接続状況の変化はNetworkInformation.NetworkStatusChangedイベントで分かるので、簡単なWPFのサンプルアプリとしては以下のようになります。
using System;
using System.ComponentModel;
using System.Windows;
using Windows.Networking.Connectivity;

public partial class MainWindow : Window
{
  public MainWindow()
  {
    InitializeComponent();
  }

  public bool IsInternetConnected
  {
    get { return (bool)GetValue(IsInternetConnectedProperty); }
    set { SetValue(IsInternetConnectedProperty, value); }
  }
  public static readonly DependencyProperty IsInternetConnectedProperty =
    DependencyProperty.Register(
      "IsInternetConnected",
      typeof(bool),
      typeof(MainWindow),
      new PropertyMetadata(false));

  protected override void OnSourceInitialized(EventArgs e)
  {
    base.OnSourceInitialized(e);

    IsInternetConnected = CheckInternetConnection();
    NetworkInformation.NetworkStatusChanged += OnNetworkStatusChanged;
  }

  protected override void OnClosing(CancelEventArgs e)
  {
    base.OnClosing(e);

    NetworkInformation.NetworkStatusChanged -= OnNetworkStatusChanged;
  }

  private void OnNetworkStatusChanged(object sender)
  {
    this.Dispatcher.Invoke(() => IsInternetConnected = CheckInternetConnection());
  }
  
  private bool CheckInternetConnection()
  {
    var profile = NetworkInformation.GetInternetConnectionProfile();
    if (profile == null)
      return false;

    return (profile.GetNetworkConnectivityLevel() >= NetworkConnectivityLevel.InternetAccess);
  }
}
この他にもWinRTにはデスクトップアプリでも使える機能がちょこちょこあるので、Windowsストアアプリに興味がなくても知っておくと便利なことがあります。

2014/12/17

Windowsストアアプリの実機テスト

Windowsストアアプリを開発するときにタブレットなど実機でテストするためのメモ。

1. 選択


Windowsストアアプリでタブレットなど実機特有の機能のテストが必要になったとき、開発機とテスト機が同じであればいいが、そうでない場合にどうやってテスト機で動かすかというと、
  1. Visual Studioからテスト機をリモートコンピューターとしてデバッグ実行する
  2. テスト機にサイドローディングして実行する
の二通りがある。

2.A. リモートデバッグ


デバッグのしやすさではVisual Studioのデバッガーが使えるAの方が当然ベターで、手順も別に難しくはない。
ただ、これは開発機とテスト機がLANに繋がっていることが必須で、無線LANで接続状況があまりよくないと上手くいかなかったりする。

そういうときは有線LANにすればいいわけだが、自分が使っているDell Venue 8 ProはMicro USBコネクタで充電とUSB接続を兼ねる方式で、充電と(USBを介した)有線LANを同時使用できない(全く不可能ではないが、純正オプションの"Dell Micro USB Dongle for Data and Charging"は日本で売ってないので、ケーブルを自作したりすることが必要)。

そのせいで、ずっと有線LANを使っていると電池が切れるし、ならばと充電を始めてもある程度たまるまで有線LANは使えないしと、地味な面倒くささがある。こういうのは一見たいしたことないように見えて、じわじわ来る。

[追記]

Venue 8 Proに関して別エントリを書いた。

2.B. サイドローディング


そんな事情や、外に持ち出してテストするときのためにBのサイドローディングをすることがある。もちろんリモートデバッグ時にインストールが済んでいれば不要。

で、サイドローディングについて検索してみると、PowerShellを使うのは分かるが、企業内で展開するときの話とごちゃ混ぜになってたりして、テスト目的でするときに何がマストなのかよく分からなかったりする。

結論から言えば、自分でPowerShellを開いてコマンドを打ち込んだりする必要は、ない。

具体的な流れとしては、
  1. 開発機でサイドローディング用のアプリパッケージを作成する
  2. アプリパッケージをテスト機にコピーする
  3. テスト機で開発者用ライセンスを取得する
  4. アプリパッケージのルート証明書をインストールする
  5. アプリをインストールする
となるが、3と4と5は実は一発でできる。

アプリパッケージの作成

  1. Visual Studioのソリューションエクスプローラーで対象のプロジェクトを選択した状態にする
  2. ツールバーからプロジェクト -> ストア -> アプリパッケージの作成を選択
  3. 「Windowsストアにアップロードするパッケージを作成しますか?」に「いいえ」を選んで「次へ」
  4. バージョン番号などを確認して「作成」
  5. 作成が完了し、出力場所のリンクを開くと、作成されたパッケージがある

アプリパッケージのコピー


作成されたもののうちフォルダーの方をテスト機にコピーする。appxuploadの付いたファイルの方はテストには不要。

残り


残りは別々にやることもできるが、コピーしたフォルダー内に"Add-AppDevPackage.ps1"というPowerShellスクリプトが入っているので、これを右クリックして「PowerShellで実行」すると、このスクリプトがPowerShellを開いて自動的に判断して必要なことをY/N形式で聞いてくるので、それに従っていれば済む。

なお、開発者用ライセンス(というか、実質的に開発"機"用ライセンスだが)は1箇月で切れるが、その辺もスクリプトが判断してくれる。

参考というか、ほとんどそのまま。
以上、終わり。

2014/12/14

WinRTのScrollViewerで動かせる地図を作る

これはXAML Advent Calendar 2014の14日目の記事です。昨日は@saka_ponさんでした。皆さんネタが濃いです。この記事は残念ながら濃くはないです。長いですけど(すいません)。

1. 前置き


さて、XAMLのコントロールも色々ありますが、個人的にはItemsControlというか、ListViewが好きです。データソースを繋げたらバインディングが張られた子コントロールがだだっと自動生成されるのがたまらない、というか。アプリを作るときは初めてこれを見るのが楽しみだったりします。

このItemsControlの子コントロールは縦横に並べて配置するのが基本ですが、データソースに含まれる情報、例えば地理的な位置情報がある場合は、UI的にそれを生かした地理的な配置にすることも考えられます。それで地理的な配置にした場合、多様なサイズの画面で使えるようにするにはズームが必要になりますし、ズームすれば今度はムーブも必要になり、さらに画面の回転も考慮する必要が出てきます。

そういったものをWindowsストアアプリのWinRTで一式作ってみようというのが、この記事の目的です。

流れとしては以下のようになります。
  1. 地理的な配置にする
  2. ビヘイビアを用意する
  3. ズームできるようにする
  4. ムーブできるようにする
  5. 回転やサイズ変更や終了後の復元ができるようにする
最終的なものは以下のような感じです。記事とは関係ないですが、日本全国の気温(OpenWeatherMapによる)と各電力会社の電力使用状況をデータとして利用しています。

ソースコードはGitHubに置きました。

2.1. 地理的な配置にする


子コントロールを自由に配置するには、定番ですがListViewのItemsPanelにCanvasを使います。まず子コントロール用のViewModelとして以下のChildViewModelがあるとします。
このLeft、Top、ZIndexをそれぞれCanvasの添付プロパティにバインドしてやればいいわけです(ZIndexはもし必要があれば)。

このChildViewModelを要素とするCoreという名のObservableCollectionがあるとして、これをItemsSourceにしたListViewのXAMLがどうなるかというと、WPFの場合は以下のようになります。
一応これのポイントはItemContainerStyleのプロパティとしてCanvasの添付プロパティを設定し、これらにChildViewModelからバインドしていることです。ItemContainerStyleが適用されるListViewItemがItemTemplateが適用されるコントロールの親になるので、こちらに設定しないとCanvasに届かないわけですね。

で、この方法はWinRTでは通用しません。なぜならItemContainerStyleにバインディングが通らないから。

ではどうするかというと、いきなりコードビハインドですがItemsControl.PrepareContainerForItemOverrideメソッドを使ってバインディングを張ってやります。これを簡単にやるにはListViewの派生クラスを作ればいいので、CanvasListViewというクラスを作ります。
これを使ったWinRTのListViewのXAMLは以下のようになります。
まあ不要なItemContainerStyleのプロパティを削っただけですが。ItemContainerStyle自体は残しているのは標準のStyleを無効にするためです。

2.2. ビヘイビアを用意する


ズーム/ムーブできるようにするには、これも定番ですがListViewをScrollViewerで囲みます。その準備として、ScrollViewerに関係する処理をBehaviors SDK (XAML)のビヘイビア(要はBlendのビヘイビア)にまとめることにします。これは参照設定 -> 参照の追加 -> Windows 8.1 -> 拡張から追加できます。

作成したビヘイビアの基本部分は以下のようなものです。
このビヘイビアには標準でジェネリック版がないので、Attachしたときに対象のDependencyObjectがScrollViewerか確認した上でAssociatedObjectプロパティに格納し、とりあえずScrollViewerにキャストしたものをAssociatedViewerプロパティから参照できるようにしました。

その下のAssociatedSelectorプロパティの型はSelectorですが、これはListViewやGridViewの基底クラスに当たるものです。なるべく汎用性を持たせようとしたのですが、この記事では関係ないのでListViewのことだと思ってください。

この中のGetFirstDescendantOfType<T>メソッドはWinRT XAML ToolkitのVisualTreeHelperExtensions.GetFirstDescendantOfType<T>拡張メソッドです。これは子孫の中から指定された型の最初のものを取得するので、このプロパティからScrollViewer内にあるListViewを参照できます。

その下のCompositeDisposableは、イベント処理をReactive Extensionsでやるので、その後始末のためです。

また、この記事では直接触れませんが、方針として子コントロールをCanvas内に配置する座標を計算する際、ScrollViewerのサイズを基準にします。これに合わせるため起動時にScrollViewerとListViewのCanvasのサイズを揃えることとし(1倍の状態)、ScrollViewerからの座標の入力があればCanvas内の座標に変換し、さらに1倍のときの座標に変換したものを基準にします。でないと座標を間違えずに取り扱える気がしないので。

それでは、初めに処理の土台となるヘルパーメソッドを作ります。

計算を簡単にするため起動時にScrollViewerとListViewのCanvasのサイズを揃える前提で、ズームしたときの変化とScrollViewerのプロパティの関係を模式化したのが以下です。
ズームインすることによってCanvasの仮想的なサイズが拡大し、Canvasの位置(左上角)とScrollViewerの位置(左上角)がずれます。これからScrollViewer内の任意の座標をCanvas内の座標に変換するには、CanvasのScrollViewerに対する相対座標の値を加えればいいことが分かります。

さらにCanvas内の座標を1倍のときの座標に変換するにはScrollViewerの倍率で割ればいいので、ScrollViewer内の座標をCanvasの1倍のときの座標に変換するメソッドは以下のようになります。引数のinViewerPositionがScrollViewer内の座標、viewerZoomFactorがScrollViewerの倍率です。
selectorPositionはCanvasのScrollViewerに対する相対座標で(負の値になる)、それとScrollViewer内の座標を合計した上で倍率で割っています。

逆に、ScrollViewer内の座標とCanvas内の1倍のときの座標がScrollViewerのある倍率のときに一致するようScrollViewerを操作するメソッドは以下のようになります。引数のinSelectorPositionがCanvas内の座標です。
ScrollViewer.ChangeViewメソッドは水平と垂直の両スクロール量(オフセット)、倍率を一元的に操作するメソッドです。Windows 8.1で入ったメソッドで、従来のバラバラだったメソッドを置き換えるものです。

座標の計算はConvertToInSelectorPositionメソッドの逆になっているのが分かると思います。という意味で対になるメソッドです。

[追記]

ConvertToInSelectorPositionメソッドについて、CanvasとScrollViewerの相対座標の値はScrollViewerのHorizontalOffsetとVerticalOffsetに一致するから、わざわざメソッドを使って取得する必要はないのではと思った方もいるかもしれません。メソッドにするとこんな感じです。
そうしなかった理由は自分でも忘れてましたが、この方法は倍率が1より小さくなる、すなわちCanvasがScrollViewerより小さくなったときは無力になるからです。HorizontalOffsetとVerticalOffsetは0より小さくならないので。

と言いつつ、倍率の最小値を1に制限してしまえば問題にはならないので、これでも行けるんですけどね。

2.3. ズームできるようにする


ようやくズームですが、WinRTのScrollViewerの場合、ZoomModeをEnabledにするだけでタッチのピンチ/ムーブが可能になります。したがって、以下はマウスでこれをやるためのものです(タッチも受け付けますが)。

なお、ズームといっても中心点を固定して拡大/縮小する形式と、ズームイン/ズームアウトのモードを決めて任意の点をクリックするとその点を中心に拡大/縮小する形式がありますが、UI的に後者の方が優れていると思うので、そちらで行きます。

ということで、ズームイン/ズームアウトのモードを示すenumを用意しました。
このモード設定は他に任せ、このビヘイビアにはこの型のZoomDirection依存関係プロパティを置き、PageのViewModelとバインドして現在の設定を受け取れるようにします。

その上でScrollViewerのTappedイベントをRxで購読するメソッドを作り、ビヘイビアのOnLoadedメソッドで実行します。
Tappedイベントが来たときに処理するメソッドは以下のとおりです。
まずScrollViewer内の座標とCanvas内の1倍のときの座標を取得し、ZoomDirectionに従って振り分けます。zoomNotchFactorは1回の操作で変える倍率の刻みです。倍率は最小1倍、最大9倍でリミットをかけています。

面倒な計算はヘルパーメソッドに任せているので、こんなものです。

2.4. ムーブできるようにする


ムーブもイベントをRxで購読すればいいわけですが、これには二通りあります。
  • Manipulationイベント: ManinulationStartedで開始、ManipulationDeltaで移動、ManipulationCompletedで終了。
  • Pointerイベント: PointerPressedで開始、PointerMovedで移動、PointerReleasedなどで終了。
どちらでも行けますが、まずManipulationの方から。イベントを購読するメソッドは以下のようなものです。
移動量を捉えるだけならManipulationDeltaだけでいいですが、開始位置を保存しておくのと入力がマウスかどうか判別するにはManipulationStartedRoutedEventArgsを見る必要があるので、ManipulationStartedが必要になります。一方、移動が終わればManipulationDeltaも止まるので、ManipulationCompletedには実質的な意味はありません。

これらを受けた処理をするメソッドです。
開始時にその時点の状態を保存しておき、移動時に(スロットリングで間隔を挟みつつ)開始時からの累積量を加え、ScrollViewer.ChangeViewメソッドで反映させています。一応倍率(Scale)の変化も加えるようにしていますが、無駄かもしれません。

次にPointerの方を。こちらは少し注意が必要です。

第一に、PointerMovedイベントはカーソルが上を動いているときは常に発生しているので、区切るために開始時と終了時のイベントが必須ですが、終了時のイベントがItemsControlを囲んだScrollViewerでは返ってこないので(自分で試した結果)、ListViewの方でイベントを購読する必要があります。

第二に、終了時のイベントが何になるのか明示されていません。MSDNには「PointerReleasedの代わりの他のイベントが、アクション — たとえば、PointerCanceledまたはPointerCaptureLostの最後に発生する場合があります。常にペアで発生するPointerPressedおよびPointerReleasedイベントに依存しないでください。」とあり、PointerReleasedだけではダメなのは分かりますが、PointerCanceledとPointerCaptureLostも例示に過ぎないので、完全に止められる保証がありません。

ともあれ、イベントを購読するメソッドは以下のようなものです。
終了時はPointerReleasedとPointerCanceledとPointerCaptureLostをまとめて購読する形です。保険のためにPointerExitedを追加してもいいかもしれません。

これらを受けたメソッドです。
Manipulationの場合とやっていることは大体同じです。これで別に問題は起きなかったのですが、どちらかを選ぶとすればManipulationの方が面倒がないかなという気がします。

2.5. 回転やサイズ変更や終了後の復元ができるようにする


もう少し続きます。

回転への対応はストアアプリならではの要求ですが、このScrollViewerの場合は、今まで見ていたものが回転しても明後日の方に飛んでいかない、言い換えれば中心にあるものは回転しても中心のままにする、と捉えることができます。

これはサイズ変更への対応にも流用できて、アプリの横幅が変更されても中心にあるものは中心に維持される、となります。さらに終了後の復元への対応にも応用できて、終了時に中心にあったもの(と倍率)がそのまま復元される、となります。

したがって、これらは共通の処理で実現でき、違うのはタイミングだけです。このためにはScrollViewerの中心座標に対応するCanvasの1倍のときの座標とScrollViewerの倍率を記録しておいて、それぞれ適当なタイミングで復元すればいいわけです。

まずこの座標を記録するものとしてPoint型のInSelectorCenterPosition依存関係プロパティ、倍率を記録するものとしてFloat型のViewerZoomFactor依存関係プロパティを置きます。これらはPageのViewModelとバインドして変更がある度にLocalSettingsかRoamingSettingsに保存するようにすれば、終了後の復元にも使えます。

これらを記録するメソッドは以下のとおりです。
復元するメソッドの方は以下のとおりです。
この記録するタイミングにはScrollViewerのViewChangedイベントが利用できます。これはタッチによる変更も捉えることができます。また、回転とサイズ変更のときに復元するタイミングにはSizeChangedイベントが利用できます(回転で縦横が入れ替わる、すなわちサイズが変わるので)。

これらをRxで購読するにはOnLoadedイベントで以下のようにします。
終了後の復元のときは、起動中の適当なタイミングで復元を実行すればいいわけですが、注意点としてListViewの子コントロールがロードされた後でなければ正しく復元されないので工夫が必要です。

最終的なビヘイビアはレポジトリの方で。記事中で取り上げてない部分もありますが。

ScrollViewerのXAMLは以下のようになりました。
表示させるとこんな感じです。
デザインは作り込んでいませんが、フライアウトを付けたりしています。

以上で終わりです。お疲れ様でした。

3. 後書き


実はこの方法はTokyoSubwayViewで使ったものとほぼ同じだったりします。記事の材料にするために見直したりしてきましたが、まだ生煮えの感を免れません。強引にまとめようとすれば出来なくもないかもしれませんが、それがXAML的に正しいかという判断が付かない程度のXAML力でした。

ということで、もうXAML Advent Calendarも後半ですが、引き続き各位の記事で勉強させていただこうと思います。明日は@icchuさんです。期待してます。

2014/11/30

TokyoSubwayView

TokyoSubwayViewというWindowsストアアプリをリリースしています。「東京メトロオープンデータ活用コンテスト用公共交通データAPI」(長い……)を利用するアプリで、東京メトロオープンデータ活用コンテストに応募しています。

1. 機能


ごくシンプルに、東京メトロ地下鉄路線内の列車の運行状況をグラフィカルに表示します。

各駅の位置関係は実際の地理的な位置に沿っているため、都心部では密集して見にくくなっていますが、表示する路線と優先度(優先度の高いものが上に来る)をオプションで設定できます。

駅と線路の色は列車の運行状況を示します(各線のラインカラーとは別)。
  • 青 : 遅れなく運行中
  • オレンジ : 5分以上遅れて運行中
  • マゼンタ : 15分以上遅れて運行中
  • 黄緑: 列車予測線(線路のみに、隣接した駅にその方向に走る列車が停車中のときに出る)
駅か線路をクリック/タップするとそこにいる列車の情報が表示されます。

ここで謝っておきますと、現在Windowsストアに出ているのはVer 1.1で、一部バグがあります(初期状態で表示される路線の優先度がオプションと逆になっているなど)。Ver 1.2で大体解消したと思いますが、コンテストのルールによりコンテスト終了(来年1月以降)まで修正ができないため、そのままになっています。上の画像はVer 1.2のものです。

2. 開発


このアプリの開発は@ch3coohさんのブログを見て意外と難しくなさそうと思ったところから始まりました。
そこから何を作るか考えて、要素技術を確認して、行けそうだと思ったのが10/19のOSC Tokyo/Fallの直前。11/17の締め切りまでWindowsストアの審査に1週間を見込むとして、最大3週間あれば形になるだろうと取り掛かったものの、WinRTではWPFで使ったテクニックが使えない場所が多々あって時間を取られるうちに最後の1週間を切り、既に諦めが入りつつも見切りでまとめたのが11/15のめとべや東京#6の朝(それでもこの期間でできたのは@biacさんの多数の記事のおかげ)。懇親会で「もう間に合わないんですけどね……」と言いつつ見せたら@openlibsysさんに後押しいただいて、残る問題を一応潰して全部動くようになったのが11/17の早朝。プライバシーポリシーは@snow_caitさん情報でGistで行くことにして、開発者登録からストア審査提出までどたばたと済ませて、自分ではこれで一区切り付けて終わり、のつもりだったんですが……

なあにーーーーーーーーーーーーーーーーーーーーっ

認定まで25分、公開まで1時間11分しかかかりませんでした。ストアの説明では審査には何段階かあって合わせて1週間ほどかかる感じでしたが、現在では短縮されたようです……。

ということで奇跡的にコンテストには間に合いましたが、後から見るとちらほら怪しい点が……。ただ、応募アプリをとにかく出さないとアクセストークンが停止されるので、APIを利用すること自体できなくなるんですよね……。

ソースコードはGitHubに置きました。Ver 1.2でかなり修正しています。
ただし、アクセストークンは各開発者が管理することなっているので、入れていません。で、現在有効なアクセストークンは自分でも応募アプリを出した人のものだけなので、実際に動かす人はまずいないと思いますが、一応。

コンテスト自体については、審査に2箇月かけてその間のアプリ修正を禁止するとか、今時のアプリ開発のスピード感からして正直どうかという感もあり、とりあえず忘れます。

2014/10/05

ThinkPad X230とmSATA SSD

ThinkPad X230にmSATAのCrucial M550 256GBを入れた。
ThinkPad X230 and Crucial M550
ThinkPad X230 and Crucial M550

開発環境を色々入れて手狭になってきていたところに、Windows 10 TPをどうやって動かそうか考えて、元からmSATAに増設する予定だったので丁度よく。

Windows 10 TPのインストールはISOファイルからインストール用USBメモリを作って行ったが、X230をUEFIブートにしているので、USBメモリもUEFIブータブルにするためにFAT32でフォーマットする必要があった以外は従来と変わりない。

とりあえずインストールしたままの状態ではM550の温度は常に50度台前半で、かつてないペースで雫ちゃんの声を聞くことになった。

さすがに気になったので、IntelのChipset SupportとRapid Storageのドライバーを古いインボックスのものからLenovoの新しいものに代えたら、30度台後半で落ち着くようになった。

速度はmSATAの制限があるので、このぐらい。実用上は十分以上。

これでまたX230のスロットが一つ埋まった。

2014/10/04

Windows 10 TPのバージョン判定

Windows 10 TP(Technical Preview)のバージョン判定について一応確認してみた。

1. 判定方法


内部バージョンが6.4というのは分かっているので、これをC#のコードから判定する方法として以下を試してみた。
.NET FrameworkのSystem.Environment.OSVersionは内部的にGetVersion/GetVersionExを呼び出しているようなので、これに準じる。

なお、GetVersion/GetVersionExについては、Windows 8.1と同様にマニフェストファイルでcompatibilityの記述が必要となる。

2. コード


それぞれのコードは以下のとおり。

GetVersionEx
public static Version GetOsVersionByGetVersionEx()
{
  var info = new OSVERSIONINFOEX();
  info.dwOSVersionInfoSize = (uint)Marshal.SizeOf(info);

  var result = GetVersionEx(ref info);

  return result
    ? new Version((int)info.dwMajorVersion, (int)info.dwMinorVersion)
    : null;
}
マニフェストファイルのcompatibilityにWindows 10のIDを含めている。
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
  <application>
    <!-- The ID below indicates application support for Windows 7 -->
    <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>

    <!-- The ID below indicates application support for Windows 8 -->
    <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>

    <!-- The ID below indicates application support for Windows 8.1 -->
    <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>

    <!-- The ID below indicates application support for Windows 10 -->
    <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
  </application>
</compatibility>
RtlGetVersion
public static Version GetOsVersionByRtlGetVersion()
{
  var info = new OSVERSIONINFOEX();
  info.dwOSVersionInfoSize = (uint)Marshal.SizeOf(info);

  var result = RtlGetVersion(ref info);

  return (result == 0) // STATUS_SUCCESS
    ? new Version((int)info.dwMajorVersion, (int)info.dwMinorVersion)
    : null;
}
NetWkstaGetInfo
public static Version GetOsVersionByNetWkstaGetInfo()
{
  IntPtr buff = IntPtr.Zero;

  try
  {
    var result = NetWkstaGetInfo(null, 100, out buff);

    if (result == 0) // NERR_Success
    {
      var info = (WKSTA_INFO_100)Marshal.PtrToStructure(buff, typeof(WKSTA_INFO_100));

      if (info.platform_id == 500) // PLATFORM_ID_NT
        return new Version((int)info.ver_major, (int)info.ver_minor);
    }
  }
  finally
  {
    if (buff != IntPtr.Zero)
      NetApiBufferFree(buff);
  }

  return null;
}
[修正]

IntPtrをNetApiBufferFreeで解放するよう修正した。

WMI
public static Version GetOsVersionByWmi()
{
  var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_OperatingSystem");

  var os = searcher.Get().Cast<ManagementObject>().FirstOrDefault();

  if ((os != null) && (os["OsType"] != null) && (os["Version"] != null))
  {
    if (os["OsType"].ToString() == "18") // WINNT
      return new Version(os["Version"].ToString());
  }

  return null;
}
VerifyVersionInfo

引数majorとminorでバージョン番号を指定し、それとの比較をする形。
public static bool? IsOsEqualOrNewerByVerifyVersionInfo(int major, int minor)
{
  var info = new OSVERSIONINFOEX();
  info.dwMajorVersion = (uint)major;
  info.dwMinorVersion = (uint)minor;
  info.dwOSVersionInfoSize = (uint)Marshal.SizeOf(info);

  ulong cm = 0;
  cm = VerSetConditionMask(cm, VER_MAJORVERSION, VER_GREATER_EQUAL);
  cm = VerSetConditionMask(cm, VER_MINORVERSION, VER_GREATER_EQUAL);

  var result = VerifyVersionInfoW(ref info, VER_MAJORVERSION | VER_MINORVERSION, cm);

  if (result)
    return true;

  return (Marshal.GetLastWin32Error() == 1150) // ERROR_OLD_WIN_VERSION
    ? false
    : (bool?)null;
}
Win32の宣言を含むコード全体はレポジトリに置いた。

3. テスト結果


実機にインストールしたWindows 10 TP上でのテスト結果は予想通りで、どの方法でもバージョン判定はできている。

マニフェストファイル中のOSのIDをコメントアウトして変えてみると、

GetVersionExはサポートしている上限のOSのバージョン番号を返しているのが分かる。以上で確認終了。

4. 備考


MSDNのGetVersion/GetVersionExの説明にはWindows 8.1後のOSでは変更されるか利用できなくなる可能性があるのでVersion Helper APIを使うように書かれている。一方、C#の観点から言えば、Version Helper APIは要はC++のマクロだから使えないし、GetVersion/GetVersionExを使っているSystem.Environment.OSVersionはどうなるのかということになって、先行き不透明な状態にある。まあ様子を見て使える手の中から対応を考えるしかないと思う。

参考までに、Version Helper APIと同じ機能を.NET Framework用に書いた例。

2014/08/21

Visual Studio "14"のツールバーアイコン

Visual Studio "14" CTP3でツールバーなどのアイコンがHigh Resolution Iconsになったというので。
ウィンドウの左右について、それぞれ上がVisual Studio 2013、下がVisual Studio "14" CTP3で、DPI 150%の状態。

確かにアイコンが変わっている。ただ輪郭がまだぼけているところを見るに、たぶんリソースにラスターデータの画像ファイルを持っていて(Visual Studio Image Libraryには以前からサイズ別の画像が用意されている)、起動時にDPIを見て適当なサイズのものをImageコントロールに読み込み、それをImageコントロールが自動リサイズしているのではないかと思う。

一方、タイトルバーにある「通知」と「フィードバック」のアイコンは以前から高DPIに対応していて、こちらは輪郭のぼけが少ないところを見るに、ベクターデータだと思う。左上角の最小化などのボタンもたぶん同じ。Microsoftの田中さんの記事によればラスターデータの画像でも高解像度のもの(最大256x256ピクセル)を使っているらしい。
ちなみに、ベクターデータだから輪郭がぼけないということはなくて、論理的な描画位置が実数(Double)で管理されているのに対して、実際に表示するピクセルは整数なので、その間に端数が生じるとぼけの原因になる。これはアプリを高DPI化するには避けて通れない問題で、DPI 100%ではきっちり整数で合わせていても、拡大/縮小すると端数が必然的に出てくる。

これを抑止するには描画位置をピクセルに合わせて整数になるよう寄せてやる必要があって、そのために使えるプロパティが色々ある。
これらの効果は一筋縄では行かなくて自分でも把握し切れてないが、これらで満足する結果が得られない場合はGuidelineSetクラスを使って描画位置を明示的に指定してやる必要がある。ただし、これはかなり面倒なので、多少のぼけは諦めた方が楽かもしれない。

[修正]

タイトルバーのアイコンについて、こちらもラスターデータの画像によるものと修正した。

2014/08/01

WPFのクローム部分におけるPer-Monitor DPI対応

Per-Monitor DPI対応のラストピースともいうべき、ウィンドウのクローム部分における対応について。

1. 背景


Per-Monitor DPI対応のためにウィンドウを拡大/縮小するとき、アプリ自身からスケーリングできるのは基本的に内側のクライアント領域の部分だけで、外側のクローム部分(枠部分)はOSのコントロール下にあり、Per-Monitor DPI対応を宣言したアプリのウィンドウをOSはスケーリングしないため、結果的にクローム部分だけスケーリングから取り残されるということが起きます。

これを避けるにはクローム部分もアプリ側でコントロールすればいいわけですが、WPFの場合はWindowChromeクラスを使うことで比較的容易にこれが実現できます。これについてはぐらばくさんが基本的な説明をされています。
あえて付け足すとすれば、CaptionHeightで設定される領域はダブルクリックによる最大化の機能も担っているので、0にするより設定した方が標準ウィンドウの機能を失わずに済みます。なお、CaptionHeightの起点とウィンドウ上端の間にはResizeBorderThickness.Topが挟まるので(0でなければ)、計算するときは注意を。

2. デモ


Per-Monitor DPI対応のためのライブラリに、ビヘイビアによるもの、添付プロパティによるものに加え、WindowChromeを使ったものを"ExtendedWindow"として加えて再構成しました。Per-Monitor DPI対応の機能は共通なので、リサイズ時の問題への対策もされています。
初期設定ではWindows 8の標準ウィンドウとほとんど同じ外観です。

ここでPer-Monitor DPIを変えると、クローム部分もスケーリングされます。

どうせなのでサンプルテーマも用意してみました。

大体の項目はプロパティを通しても設定できますが、キャプションボタンのマウスオーバー時とクリック時の色は直接ResourceDictionaryを作成してThemeUriに設定する以外の方法は用意していません。といっても、実際にアプリを作ろうとすると色々手を加えないといけないと思いますが。

ちなみに、デモアプリの"Rain"をクリックするとアニメーションが表示されます。

クローム部分(のように作ってある部分)もアプリ側からコントロールできることが分かると思います。

3. まとめ


というわけで、Per-Monitor DPIに完全対応を果たしつつ、ウィンドウの全てを自由にデザインできるので、アイデア次第で魅力的なUIを作り上げることができると思います。

2014/07/21

Per-Monitor DPIのビヘイビアによる実装

WPFでPer-Monitor DPI対応を実装する方法として、これを実装したWindowから継承するやり方を取りましたが、標準のWindowにPer-Monitor DPI対応を付加する方法もあります。

その一つとしてespresso3389さんが添付プロパティを使ったやり方を示されています。
これで基本的な用は足りるわけですが、ウィンドウ内の構成によってはDPIの値等を直接取得して処理したい場合もあります(例えば、画像をDPIに応じて切り替える)。

これを簡単に取得できるようにするにはどうすればいいか考えたところ、Per-Monitor DPI対応のためには要は対象Windowのインスタンスが参照できればいいので、ビヘイビアを使ったやり方があると思いついたところで、このやり方には先人がいたことを思い出しました。
これをどうせならXaml内で完結できるといいかなと思ったので、Expression Blendの(というより既に標準ライブラリと言っていいと思うSystem.Windows.Interactivityの)Behaviorとして実装を書いてみました。
これを使うとWindowのXamlで以下のような使い方ができます。
<Window x:Class="WpfPerMonitorDpiBehavior.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:local="clr-namespace:WpfPerMonitorDpiBehavior"
        Title="WPF Per-Monitor DPI Behavior"
        FontFamily="Segoe UI"
        Width="400" Height="200">
    <i:Interaction.Behaviors>
        <local:PerMonitorDpiBehavior x:Name="DpiBehavior"/>
    </i:Interaction.Behaviors>

    <Grid x:Name="LayoutRoot">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <Grid Margin="4">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>

            <Label VerticalAlignment="Center"
                   Content="System DPI"/>
            <TextBox Grid.Column="1"
                     Width="80" Height="22" Margin="6,4"
                     Text="{Binding ElementName=DpiBehavior, Path=SystemDpi, Mode=OneWay}"/>

            <Label Grid.Row="1"
                   VerticalAlignment="Center"
                   Content="Per-Monitor DPI"/>
            <TextBox Grid.Column="1" Grid.Row="1"
                     Width="80" Height="22" Margin="6,4"
                     Text="{Binding ElementName=DpiBehavior, Path=WindowDpi, Mode=OneWay}"/>
        </Grid>
    </Grid>
</Window>
10-12行目でこのビヘイビアを指定し、35と42行目でビヘイビア内のプロパティをバインディングして表示するようにしています。

なお、ここでは簡単にするためウィンドウのリサイズにはWM_DPICHANGEDのlParamの値をそのまま使うようにしています。したがって、実用にするにはリサイズ時にサイズが狂う問題への対策が必要です。また、Per-Monitor DPI対応であることをOSに伝えるため、利用するアプリのアプリケーションマニフェストでWindows 8.1対応であること、Per-Monitor DPI対応であることを示す必要があります。

[追記] 添付プロパティによる実装

一応、同じことは添付プロパティでも可能なので、書いてみました。
クラスのインスタンス自身をプロパティとして持たせるという、よくあるパターンを添付プロパティでやったものです。WindowChromeクラスのWindowChrome添付プロパティが同じような感じです。

これでビヘイビアとよく似た使い方ができます。
<Window x:Class="WpfPerMonitorDpiProperty.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfPerMonitorDpiProperty"
        Title="WPF Per-Monitor DPI Property"
        FontFamily="Segoe UI"
        Width="400" Height="200">
    <local:PerMonitorDpiProperty.AttachedProperty>        
        <local:PerMonitorDpiProperty x:Name="DpiProperty"/>
    </local:PerMonitorDpiProperty.AttachedProperty>

    <Grid x:Name="LayoutRoot">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <Grid Margin="4">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>

            <Label VerticalAlignment="Center"
                   Content="System DPI"/>
            <TextBox Grid.Column="1"
                     Width="80" Height="22" Margin="6,4"
                     Text="{Binding ElementName=DpiProperty, Path=SystemDpi, Mode=OneWay}"/>

            <Label Grid.Row="1"
                   VerticalAlignment="Center"
                   Content="Per-Monitor DPI"/>
            <TextBox Grid.Column="1" Grid.Row="1"
                     Width="80" Height="22" Margin="6,4"
                     Text="{Binding ElementName=DpiProperty, Path=WindowDpi, Mode=OneWay}"/>
        </Grid>
    </Grid>
</Window>
こちらだとSystem.Windows.Interactivity.dllは当然不要です。

[追伸] Visual Studio 2013のデバッガーにおけるアプリケーションマニフェスト

Per-Monitor DPI対応であることをアプリケーションマニフェストで示したアプリをVS2013内のデバッガーで実行すると、Per-Monitor DPI対応にならない問題がUpdate 2まで発生していて(SetProcessDpiAwareness関数を使った場合は問題ない)、どうもデバッガーでアプリケーションマニフェストが反映されない既知の問題があったようです。この問題はUpdate 3 RCを試したところ発生しなかったので、Update 3で修正されたのだと思います。

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で例外に備えるべき必要性は変わらない。

2014/04/30

SSDかどうかをWMIから判別する

SSDかどうかをC#から判別するではP/Invokeを使ったが、それよりハードルの低いWMIでもSSDかどうかを判別する方法はある(ことを知った)。

1. 説明


具体的には、WMIからWindows Storage Management APIにあるMSFT_PhysicalDiskクラスを利用する。ただし、このWindows Storage Management APIが使えるのはWindows 8(とWindows Server 2012)以降なので、完全にこれに頼れるものでもなく、ケースバイケースで。なお、管理者権限は不要。

初めにWMIでディスクのハードウェア的な情報を取得できるものとして、新しいMSFT_DiskMSFT_PhysicalDisk、および従来からあるWin32_DiskDriveのプロパティをざっくり比較してみた。
この中でMSFT_PhysicalDiskのSpindleSpeedとMediaTypeにSSDの情報が含まれている。
  • SpindleSpeed
    文字どおりディスクの回転数で、これが0であればSSD。HDDで回転数の情報が取れればその回転数(RPM)、そうでなければUInt32の上限値が返ってくる。
  • MediaType
    これが3であればHDD、4であればSSD。情報が取れなければ0が返ってくる。
ここまで分かれば、後は名前空間が従来の\root\CIMV2ではなく、\Root\Microsoft\Windows\Storageなので、それを明示的に指定する必要がある以外は従来のWMIと変わりない。

2. テスト


テスト用に作成したC#のコンソールアプリを、ThinkPad X230上のWindows 8.1で実行した結果。

1番目はUSBポータブルケースに入れたTravelstar 5K1000で、USB接続ではMediaTypeもSpindleSpeedも情報を取れなかった。2番目はUSBメモリで、これも情報を取れず。3番目は内蔵SSDで、これはMediaTypeとSpindleSpeedの両方でSSDという情報が取れた。

次にThinkPad X61s上のWindows 8で実行した結果。

1番目は内蔵HDDのTravelstar 7K1000で、MediaTypeはHDDという情報が取れたが、SpindleSpeedはなぜか情報が取れなかった。2番目と3番目のUSBメモリは両方取れなかった。

以上、とりあえず内蔵であればSSDかどうかの判別に使えると思う。

[追記] PowerShellから実行

WMIということは当然PowerShellからも実行できるわけで、例として以下のような感じ。
Get-WMiObject -namespace Root\Microsoft\Windows\Storage -class msft_physicaldisk | Select-Object FriendlyName,Model,MediaType | Sort-Object FriendlyName

これを実行したもの。MediaTypeが3であればHDD、4であればSSD。

問題なく取得できている。

3. BusType


SSDに限らないが、BusTypeについて、従来のWin32_DiskDriveのInterfaceTypeプロパティでは取れる情報が少なかったので、Win32のDeviceIoControlをIOCTL_STORAGE_QUERY_PROPERTYで実行してSTORAGE_BUS_TYPEを取得するようにしていたが、MSFT_DiskとMSFT_PhysicalDiskにはBusTypeプロパティがあるので、比較してみた。
一目瞭然だが、情報はほとんど同じで、MSFT_DiskがNVMeを取れるだけ優位にある。したがって、BusTypeに関しては条件が許せばMSFT_Diskで代替できると思う。

2014/04/29

コミケ限定特典クラウディアさん

実は昨年末の冬コミでコミケ限定Visual Studio 2013を買いました。

数々の特典に惹かれた、というより、それまでExpress版を使ってきたわけですが、WPFで本格的に開発するとなるとBlendがないと辛いかな、と思っていたのと、特典を別にしてもお買い得価格だという極めて冷静な判断に基づくものですが、クラウディアさんのフィギュアに興味がなくもなかったことは否定しません。

このフィギュアは後日送付ということで、帰ってきて改めて確認すると、「3Dデータ収録USBメモリ」が付くのはチェック済でしたが、「3Dフィギュア(三等身イメージ)」と書いてあって、「おぉ? 三等身だと……?」と驚くと同時に、前のはある意味普通のフィギュアだったので、三等身でどうなるのか俄然興味が沸いてきました。

送付予定は当初は3月末だったようですが、「新しく起こすとなれば時間はかかるよな」とゆっくり構えているうちに3月が過ぎ、「あれー?」と思い始めた4月中旬になって小さな箱が届きました。

その中にごろんと、緩衝材にくるまれていたものを開けてみると、



本当に予想外のことがあると笑ってしまう癖があるのですが、しばし笑い転げました。

額に年輪のように積層した跡があるように、3Dプリンタで出力したもので、最近よく見るフィラメントを重ねていくものではなく、自分は見たことなかったのですが、フルカラー石膏というもののようです。表面はざらざらした粒状なので写真ではぼやけた感じですが、瞳の塗り分け、イヤリング、スカートの模様など、色分けは完璧。

3Dプリントであることは、後から考えれば3Dデータが付くということから予想すべきだったのですが、笑いを倍加したのは頭がでっかいこと。写真で見るとそうでもないですが、実物を手にすると頭の大きさが圧倒的で、全体の容積というか質量の半分はあります。三等身ではなく、二等身ぐらい。



ただ、落ち着いた後にふと思いついて、手許にあったきゅんキャラ『シェリルノーム白うさぎVer.』と並べてみると、




このサイズのディフォルメフィギュアでは、これぐらいの頭と体のバランスは普通でした。

同梱されていたカード型USBメモリにFBX形式のデータが入っていたので、その気になれば自分で3Dプリント業者に持ち込んでプリントもできるようです。

ちなみに、こういう立体物を見ると興味を持つ人はいると思いますが、埋まってません。

スカートの中はちゃんとあります。ここは純粋に3Dプリンタはすごいなと思いました。

以上、サプライズのあるいい特典でした。

[追記] 3Dデータ

ふと3DデータのアイコンがVisual Studioのものぽかったので開いてみたら……。

ぐりぐり動かせます。Visual Studioにこんな機能があるの知らなかった……。つまり、Visual Studioでいじって遊べということだったんですか、Microsoftさん!

ということで、話がVisual Studioに収束しました。

2014/04/27

Disk Gazerを拡張

狭い領域の計測に特化していたDisk Gazerを広い領域を計測できるよう拡張しました。

1. アイデア


Disk Gazerは狭い領域の速度を細かく調べるという目的は達しましたが、これをより広い領域、あるいはディスク全体に使いたいという考えは初めからありました。問題は全領域をなめるようにリードしていくとおそろしく時間がかかることで、例えば、平均速度を100MB/sとすると1TBをリードするのに単純計算で2.8時間かかります。現在3.5インチHDDでポピュラーな4TBだと11.1時間にもなります。さすがにこれは実用的ではないです。

したがって、何らかの形で間を飛ばしながら計測する必要がありますが、下手なやり方を取るとHD Tuneの轍を踏むことになるので考え中でしたが、GitHubにリポジトリを置くに際して内部的に大改造したので(計測方法自体は変えず)、同時に広い領域を計測できるよう拡張しました。

基本的なアイデアは前に考えたとおりですが、
  • 記録面の波形をなるべく外さない
  • 速度の上下動を無理に平均化しない
となると、逆に複雑なことはしない方がいいということで、以下のような形に。
  • 実際にリードするblock数の、対象領域の全block数に対する割合を"Area ratio"と呼ぶ。例えばこれが1/16の場合、1/16だけ(累計)リードする。これを使ってリード位置の間隔を決める。
  • 速度計算はblockごとのままとする。
  • 1つの位置でリードする単位を8blockとする(block sizeを初期設定の1MiBとすると8MiB)。ただし、1番目のblockはシークタイムの影響を避けるため結果には含めない。
  • リードするblockを含めた同じ長さのグループを作り、1つのグループの先頭8blockをリードした後は次のグループの先頭に移る。これを順に繰り返す。例えば、Area ratioが1/16の場合、リードする8blockに対して、グループの全block数はその16倍の128とし、残る120blockは無視する。
まあこれ自体は外さない結果を出すための決めごとに過ぎませんが。

ソースコードは最終的にGitHubに置きました。

実行ファイル
レポジトリ

2. 結果


実際にHDDを計測した結果は以下のとおり。ThinkPad X230上のWindows 8.1からUSB3.0接続のポータブルケースに入れたHDDを計測。USB3.0でもHDD程度の速度なら問題ない模様。それぞれの以前のベンチマーク結果は前のエントリから見られる。

Travelstar 7K1000の1TBモデル


Area ratioはLocation (area location)の横に表示。先頭1GiBを先に1/1で全リードしたもの(黄色のライン)に重ねて1/16でリード(黄緑のライン)。

1/16ではリード開始位置は128MiBの間隔で、この記録面の波形なら上に突き出したスパイクを含めて上下動をほぼ捉えている。

次に全領域を1/16で計測したもの。

スパイクがうるさいが(外周では上に、内周では下に出ている)各ゾーンの波形は大体トレースできている。というか、この表示サイズだと計測位置を増やしてもごちゃっと固まるだけなので、あまり違いはないと思う。ちなみにこれで既にリード位置は5万を超えている。

時間は1回のリードに9分かかっているので、5回のリードで45分といったところ。これでもベンチマーク時間としては長いが、試しに1/32のリードをしたら網目が広すぎ、波形を外すようになって良くなかった。

結果とは関係ないが、ポータブルケースはAnkarの2.5" HDD/SSD External Enclosureというものだが、中のHDDの型番を全部返してこないのは少し良くない。

Travelstar 5K1000の1TBモデル


同様に先頭1GiBを1/1で全リードしたもの(黄色のライン)に重ねて1/16でリード(水色のライン)。

波形の上下動は1/16でも捉えられている。

次に全領域を1/16で。

これも問題ないと思う。7K1000は先頭より少し内側の方が速かったが(そういうHDDは時々ある)、5K1000は素直な曲線。

Travelstar 5K1000の500GBモデル


同様に先頭1GiBを1/1で全リードしたもの(黄色のライン)に重ねて1/16でリード(赤紫のライン)。

次に全領域を1/16で。

これも問題ないと思う。外周と内周の速度比が小さいのも以前と同じ。

3. まとめ


以上のように、グラフの表示上、スパイクが実際以上に強調されてしまう傾向はあるものの、ディスク全体を計測する用途にも使えるものになったと思います。思い描いたほどきれいな形にならなかったのは、実際のディスクの姿がそうである以上、仕方ないかな。Block offsetを使えばもう少し上下が収束すると思いますが、時間がおそろしくかかるので。

2014/04/24

Snowy

FlashAir用のWindowsデスクトップアプリ、Snowyをリリースしました。

1. 概要


FlashAirはWi-Fiの無線サーバー機能を持ったSDカードで、カメラなどに挿したまま無線でスマートフォンやPCと接続して画像データをダウンロードできます。この分野の走りはEye-Fiですが、東芝はFlashAirのAPIを公開して(FlashAir Developers)自社だけでないアプリ開発を促進しようとしていて、今時の企業戦略という感じです。

で、東芝公式と開発者の関心は、これも今時ですがスマートフォンとPCでもWindowsストアアプリに集中していて、普通のWindowsデスクトップアプリがなかったので(PCメーカーが自社製品用アプリにFlashAir機能を入れたものは存在する)、作ったというものです。普段使いはスマートフォンだけど、PCを含めて全部無線で完結するとうれしいかも、とか、三脚を構えて撮影しながらすぐにPC画面で確認する、とか、使い方次第で便利に使えると思います。

ただ、このアプリの出番となるのはFlashAirがPCと接続した後なので、
  • PCとの接続時には少し面倒な点がある。
    特記事項に書いたとおりですが、これ自体はスマートフォンをメインターゲットにする以上、避けられないのかなと思います。といっても、普段のLAN接続が無線の場合はその接続先を変えるだけで、有線の場合は一時期的にLANケーブルを抜けばいいだけなので、たいした手間ではないです。

  • 接続性、転送速度はカメラとPCと距離に大きく左右される。
    これは結構違いが出ます。すいすい快適に使える場合もあれば、「使い物にならん!」という場合も無きにしもあらず。
したがって、このアプリの使い心地は、FlashAir自体の使い心地によるところが大きいですが、そこがクリアされれば快適に使えるのではないかと思います。

2. 裏話


このアプリの開発は、昨年10月のオープンソースカンファレンス東京でFlashAirの説明を聞いたことが始まりです。

そのときに倍率があまり高くなかったこともあってFlashAir Class6 8GBをいただいたので、開発初期はこれを使ってました。その後、Class10でも確認するために、W-02 Class10 16GBを購入しました。

左下の8GBがいただいたものですが、正直「天狗……かな?」と思いました。広がった羽根と小さな帽子と、よく見ると帽子のマークから電波を発していて、超人っぽい何か、というか。

それに対して、上の名刺は今年3月のオープンソースカンファレンス東京でいただいたものですが、閃ソラさんというキャビンアテンダントということが明らかにされました。髪型も変わりました。服装もすっきりしたキャビンアテンダントらしいものになりました。というか、コスプレイヤーさんが来てました

その頃にはこのアプリは機能面では大体できていて、デザインとネーミングをどうしようか、と考えていたところだったので、直感的にこれで行こう!と思いました。なので、このデザインとネーミングになりました。

ちなみに、Class10は自分が試した範囲ではClass6より接続性(ネットワークの繋がりやすさ)がかなり良くなっているので、Class6で遅いという場合は試す価値はあります。

3. コード


コード的には現在のWPFの第一線の機能を使いたいという目的があって、実はこれが目的の半分だったりします。その甲斐あってか、このアプリはシンプルな外見に比して内部的には結構忙しいのですが、コード量は割と少なく済んでいます。

初めは新しく使う機能はとくに必要なところだけ使えばいいと思ってましたが、結果的に「もっともっと」という感じで使える限り使うことになりました。その方が色々な意味で楽なんですよね。先達の開発者が新しい機能を貪欲に取り入れようとする気持ちがよく分かりました。

開発中は大量の資料を読みましたが、とくに@neuecc、@okazuki、@Grabacr07、@xin9leさんの記事は大変参考にさせていただきました。ただ、ソースコードを見れば丸わかりですが、まだ正直よく分かってないところもあるので、もっと冴えたコードを書きたいですね。

2014/02/22

Exifのサムネイル

カメラで写真を撮ると自動的にExifのメタデータが画像ファイルに記録される。というのは言うまでもないが、その一つとしてサムネイル画像も埋め込まれることはあまり意識されてないと思う。

この埋め込みサムネイル画像が実際どこまで利用されているかは知らないが、論理的には、これを利用すればサムネイル表示をする度に画像全体を読み込んでサムネイル画像を作成するより素早く表示することができる(Windowsでは一度作成したサムネイルデータをフォルダーごとに保存しているので、表示する度に作成しているわけではないが)。

ではアプリでサムネイル表示をするときにどれぐらい差があるか、確かめてみた。

1. コーディング


言語はC#で、以下のようなサムネイル表示機能を持つ.NET Framework 4.5のWPFアプリを作成した。

1.1. System.Drawingでサムネイル画像を読み出す


まずSystem.Drawing.Imageを使って画像ファイルからサムネイル画像だけ読み出す例があったので、これを参考にさせていただく。
サムネイルデータのプロパティIDは0x501B(Property Item DescriptionsのPropertyTagThumbnailData)なので、このプロパティの値を読み出してBitmapImageに出力する。画像ファイルのパスをlocalPathで指定。
private const int ThumbnailDataId = 0x501B; // Property ID for PropertyTagThumbnailData

public static BitmapImage ReadThumbnailFromExifByDrawing(string localPath)
{
    if (!File.Exists(localPath))
        return null;

    using (var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        using (var drawingImage = Image.FromStream(fs, false, false)) // System.Drawing.Image
        {
            if (!drawingImage.PropertyIdList.Any(propertyId => propertyId == ThumbnailDataId))
                return null;

            var property = drawingImage.GetPropertyItem(ThumbnailDataId);

            using (var ms = new MemoryStream(property.Value))
            {
                var image = new BitmapImage();
                image.BeginInit();
                image.CacheOption = BitmapCacheOption.OnLoad;
                image.StreamSource = ms;
                image.EndInit();

                return image;
            }
        }
    }
}

1.2. System.Windows.Media.Imagingでサムネイル画像を読み出す


WPF本来のSystem.Windows.Media.Imagingを使う方法としては、BitmapFrameのThumbnailプロパティから読み出す方法がある。
public static BitmapImage ReadThumbnailFromExifByImaging(string localPath)
{
    if (!File.Exists(localPath))
        return null;

    using (var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        var frame = BitmapFrame.Create(fs, BitmapCreateOptions.DelayCreation, BitmapCacheOption.OnDemand);
        var source = frame.Thumbnail;

        if (source == null)
            return null;

        using (var ms = new MemoryStream())
        {
            var encoder = new JpegBitmapEncoder();
            encoder.Frames.Add(BitmapFrame.Create(source));
            encoder.Save(ms);
            ms.Seek(0, SeekOrigin.Begin);

            var image = new BitmapImage();
            image.BeginInit();
            image.CacheOption = BitmapCacheOption.OnLoad;                    
            image.StreamSource = ms;
            image.EndInit();

            return image;
        }
    }
}
少し難しいのは、
  • サムネイル画像だけ読み出そうとすれば、FileStreamからまずBitmapFrameのCreateメソッドに流し、そのThumbnailプロパティの値だけ通す必要がある。
  • 非同期に読もうとすれば、FileStreamからまずMemoryStreamに非同期にコピーする必要がある(BitmapFrameのCreateメソッドに非同期版はないので)。
という二つが両立しないことで、前者の方を取った形。ただ、BitmapFrameのCreateメソッドでBitmapCreateOptions.DelayCreationを指定すれば、すぐには読み込まず必要になったときに非同期で読み込むっぽいので、これでいいのかもしれないが、確証はない。

1.3. 画像ファイル全体を読んでサムネイル画像を作成する


比較のために画像ファイル全体を読んでサムネイル画像を作成する方法。サイズは埋め込みサムネイル画像のサイズが160×120pixelなので、これに合わせる。
public static async Task<BitmapImage> CreateThumbnailFromImageAsync(string localPath)
{
    if (!File.Exists(localPath))
        return null;

    using (var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        using (var ms = new MemoryStream())
        {
            await fs.CopyToAsync(ms);
            ms.Seek(0, SeekOrigin.Begin);

            var image = new BitmapImage();
            image.BeginInit();
            image.CacheOption = BitmapCacheOption.OnLoad;
            image.StreamSource = ms;
            image.DecodePixelWidth = 160; // Width of Exif thumbnail
            image.DecodePixelHeight = 120; // Height of Exif thumbnail
            image.EndInit();

            return image;
        }
    }
}
こちらは普通の非同期処理。

2. テスト


これらの機能を使ったときにファイルをどれだけリードしたかをProcess Monitorで計測した。対象は以下のJPGファイルで、ファイルサイズは2,617,678Bytes=2.49MiB。
Wonder Festival Signboard in Snow

まずSystem.Drawingでサムネイル画像を読み出した場合。

サムネイル画像のサイズは7,048Bytesで、これはカメラによるのだろうが、圧縮率が高いらしく劣化が一目で分かる。このときのリードは以下のように4KBの繰り返し(最後の方だけ端切れで違う)。

合計した結果、リード量は410,956Bytesで、これはファイル全体の16%に当たる。が、リードの開始位置(Offset)に着目すると、ごく細切れに何度も重複する位置を読みに行っていて、実は先頭から8KBの範囲を繰り返し4KB単位で読むというかなり効率の悪いことをやっていた。まあキャッシュがあるから、これがそのままストレージへのアクセスになっているとは限らないが……。

次にSystem.Windows.Media.Imagingでサムネイル画像を読み出した場合。

サムネイル画像のサイズは7,043Bytesで、同じデータを読み出しているはずなのに微妙に違ってたりする。このときのリードも4KBの繰り返しだが、回数は圧倒的に少ない。

合計した結果、リード量は24,609Bytesで、ファイル全体の1%以下。System.Drawingより効率よく読んでいることが分かる。

最後に画像ファイル全体を読んでサムネイル画像を作成した場合。

サムネイルデータのサイズは8,317Bytesで、劣化はほとんど目立たない。このときのリードは以下のように80KBの繰り返し(同上)。

合計した結果、リード量は2,699,598Bytesで、ファイル全体と同じ量に加えて1回余分に読んでこの数字になっていた。

3. まとめ


埋め込みサムネイル画像を利用した方がリード量が少なくて済むことが確認できた。とくにSystem.Windows.Media.Imagingを使った場合はファイル全体の1%以下で、これならファイルアクセス時間もまず問題にならないと思う。