2021年のXAML事情とEpoxyを作った話

昔からXAMLのMVVMライブラリで自分に合うものがなく、作ろうと何度かトライ(しては飽きた)していたのですが、とうとうまともに使えるレベルにまで持って行けたので、先日宣伝を垂れ流しました。

Epoxy は、.NETの様々なXAML環境で使うことが出来る、MVVM (Model-View-ViewModel)を楽に実装するための補助ライブラリです。特徴を抜粋すると:

  • 非同期処理 (async-await) を安全に書くことが出来るように配慮しています。
  • C# 8.0でサポートされた、null許容参照型を使えます。
  • 小さなライブラリで、理解しやすいAPIです。
  • プラットフォーム標準以外のフレームワークやライブラリに依存していません。
  • 大げさにならない、最小の手間とコストで Model-View-ViewModel 設計を実現します。Viewにコードビハインドを書かずに済むことが着地点ですが、そのために煩雑な処理を記述しなければならなくなる事を避ける方針です。
  • MVVMビギナーが躓きそうな部分に焦点を当てています。
  • ほかのフレームワークライブラリ(例: ReactiveProperty)と組み合わせて使えるように、余計な操作や暗黙の前提を排除しています。

Epoxyの具体的な解説や動画などは、GitHubのREADMEに書いておいたので、そちらを参照してもらえればと思います(現在はまだありませんが、スタートガイドを書くか撮るかしようかなとは思っています)。フィードバックがあると嬉しいです。

(ここまで、前振りとEpoxyの宣伝)


今や、.NETでXAML環境と言うとかなり選択肢が増えていて、観測しているだけでも以下のようなものがあります:

  • WPF (Windows Presentation Foundation)
  • UWP (Universal Windows platform / 旧Windows Store App)
  • Xamarin Forms
  • Avalonia
  • Uno platform
  • WinUI 3
  • MAUI

(多分、たまたまXamarin Forms公開直前に作ってプチバスってしまったジョークGtk XAML環境、のようなものなら、探せばいくらでも出てきそうです)

EpoxyをこれらのXAML環境で同じように使えるようにするため、移植を試みてみました。MAUIは理由があって取りやめたのですが、それ以外の環境では動作するようになりました。

この記事では、移植作業によって分かった、各XAML環境の現状の差異をまとめてみようと思います。対象読者は、現在のXAML環境の選択肢について知りたい人や、XAML環境の知識のアップデートをしたい人、各XAML環境の差異を知りたい人です。

但し、Epoxyの移植を下敷きにしているため、フレームワークアーキテクチャの情報に偏っていることに注意して下さい。一般的にXAMLに期待されることとして、リッチなユーザーインターフェイスの実現、という視点がありますが、そこはすっぽり抜けていると思います。

なお、この記事の情報は2021年2月時点のものです。

XAMLについて

そもそもXAMLとは、という話を一応しておきます。XAMLはユーザーインターフェイスの定義をXMLで記述するものです。生まれた頃(.NET Framework 3.0)は、丁度XMLがデータ記述形式として広く用いられるようになった頃で、Windows Formsの後継技術として生まれました。

Windows Formsは、画面上(Visual Studio)で、ユーザーインターフェイスのボタンやテキストボックスなどを視覚的に配置することが出来て、その結果(例えば座標とか色とか)が、C#のソースコードとして自動的に生成・変更され、ビルド時に一緒にコンパイルされることでGUIアプリケーションが完成する、というものでした。ここで:

  1. コントロールの合成に難がある。例えば、リストビュー内にボタンやドロップダウンリストを配置するとかは、難易度が高い
  2. 時々、自動編集されたソースコードが壊れる。逆に、手動で定義するのは現実的ではない
  3. すべてのコントロールがWindows HWNDベース(ウインドウを管理する最小単位)のためにパフォーマンスが悪い

と言う問題があり、後継のUIフレームワーク、つまりWPFを作ることになったのではないかと思います。

XMLで記述することで、1と2は解決(2の自動編集は、今はRoslynがあるので、難易度は高いがXMLでなくとも実現出来ると言えるかも)し、HWNDによる単一のウインドウ上で、WPFコントロールをすべて自前で描画することにより3を解決した(場合にとってはDirectXを使う)、という感じです。

以下にXAMLで記述したユーザーインターフェイスの例を示します:

<Window x:Class="EpoxyHello.Wpf.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="EpoxyHello.Wpf" Height="450" Width="800">
    <ListBox ItemsSource="{Binding Items}">
        <ListBox.ItemTemplate>
            <DataTemplate>
                <StackPanel Margin="5">
                    <Image Margin="5,3,5,0"
                        Source="{Binding Image}"
                        Stretch="UniformToFill" />
                </StackPanel>
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
</Window>

この記事で挙げるXAML環境には:

  • XMLの階層構造で、ユーザーインターフェイスの論理的な構造を表す
  • コントロールの合成が可能。例えば(意味があるかどうかは別として)ボタンの表面に文字だけではなくテキストボックスを配置したりみたいな無茶も簡単にできる
  • コントロールのプロパティがインスタンス基準のものの他に、定義を後付けすることが出来る「添付プロパティ」という概念がある
  • 測定(Measure)と配置(Arrange)、と呼ばれる、ユーザーインターフェイス要素の配置位置を計算する、2段階のステップが組み込まれている
  • データバインディングと呼ばれる、コントロールと対になる情報を提供するインスタンスを結びつける機能がある

と言うような、共通の特性があります。逆に言えば、これら以外の部分は、それぞれのXAML環境によってバラバラと言えます。

(余談ですが、ユーザーインターフェイス以外の環境にXAMLを適用した例がありますが、あまりにマイナーであり、Epoxyとは関係ないため、本記事では省きます)

WPF (Windows Presentation Foundation)

そもそものMVVMの始まりは、WPFからでした。それも(私見ですが)はじめからMVVMの概念があったわけではなく、WPFのデータバインディングの機能・構造を体系化するとこうなる、というような後付けのアーキテクチャ概念だったと思います。

(それが、MVVMをやってると歯がゆい事がある理由だと思っています)

歴史はともかく、MVVMの基本的な構造は殆どこのWPFを基準にしています。私も、WPFを触っていた期間は長いため、まずWPF向けにEpoxyを作って、そこから移植を開始しました。

WPFは3.0の頃から、ほとんど構造は変わっていません。現在は、.NET Framework 4.8が最終バージョンであり、このバージョンがそのまま動く(事になっている)のが、.NET Core 3.0で移植されたWPFです。当初、.NET Coreへの移植という事から、WPFがマルチプラットフォーム対応したのではないか?という誤解がありましたが、あくまで.NET Core 3.0 on Windowsのみがターゲットです。

「マルチプラットフォーム対応は無理だったけど、もうすぐ.NET Frameworkがカンストしちゃうので、何とかWindowsだけでも動くようにしたから、とりあえず.NET Coreに移行してね?」という意図が感じられました。そういうわけで、今でもWPFを選択するという事は、Windowsでしか動作しないGUIアプリケーションを作ることと同義になります。なお、.NET 5においても(恐らくは.NET 6においても)WPFはWindows上でのみ動作します。

(ダメ出ししておくと、WPFを他のプラットフォームに移植するには、C++/CLIで書かれたコードを移植しなければならず、そこを全部リライトするか、またはまずVisual C++をターゲットの環境向けに移植して公開するところから始める必要があるので、UWPとその後継にあれだけリソースをつぎ込んでいるのを見るに、今後も移植は無いだろうなと思います)

余談ですが、WPFは成功したのでしょうか?私のように、次世代UIとの売り言葉に乗って、早くからWPFを触ってきた技術者にとっては、とりあえず無くてはならないフレームワークになっていると思います。一方で、2020年越えの現在でも、Windows Formsを使い続けるユーザーは居て、WPFやXAML環境の何れかに乗り換えようという気配はあまりありません(Epoxyはそのようなユーザーの事も考えて作っています)。

個人的に思うのは、WPFは段階的に移行できるAPIや手段を用意しなかったのが痛いのと、設計概念の変更に踏み込んだのに、その事を詳しく解説せず、コントロールインターフェイスの表面的に似ているところばかり強調していた(個人の感想)ことが大きいです。これは、次節のUWPでも同じことを繰り返しているなと思います。おそらく、今Windows Formsに留まっているデベロッパーは、このままなすすべなく経過するか、どうせ同じ苦労を味わうなら、もっと別の選択肢を選ぼう(.NETに限らず。今はいろいろ選択肢がありますね?)、と思っているような気がします。

EpoxyははじめにWPFで作りましたが、同じXAMLのインフラ(構造)を使うのだから、他のプラットフォームへの移植はそこまで難しくないだろうとは思っていました。それは、部分的に正しく、部分的に間違ってもいました。以降では、各XAML環境の簡単な紹介と、差についてまとめます。

UWP (Universal Windows Platform)

UWPとその前身のWindows Store Appは、Windows 8でアプリケーションのモダン化を目指したものです。スマートフォンアプリケーションが、検証されて配布されることで一定の安心感を得られるのと同じように、MicrosoftがUWPで作られたアプリケーションを検証して公開することで、ユーザーが簡単に安全に利用できるようにしようとしました。

が、様々な理由から、UWPが市民権を得ているとは言い難い状況です。EpoxyをUWPに移植するにあたり、技術的には、以下のような課題がありました:

  • UWPの構造が、WinMDというCOMをベースとしたメタデータインターフェイスで定義され、WPFの「ほぼ普通の.NETクラスベース」と異なる。具体例だが、WPFでは普通のメソッド実装だったのが、UWPで明示的インターフェイス実装として定義されていて、キャストしないとアクセス出来ない場面があった。他にも、C#が普通に使用できない情報が含まれている事がある(イベントのaddメソッドが値を返し、removeメソッドの引数が要求する型が異なるなんて想像したことがあるだろうか?)。Epoxyの移植作業以前にもちょくちょくこのような定義を見かけているので、割と沢山の落とし穴があるように思う。
  • UIスレッドの考え方の違い。WPFではすべてメインスレッドからアクセスすることになっているが、UWPでは基準となるウインドウ毎に別のスレッドを使用することになっているため、今までのセオリーをそのまま適用すると問題が起こることがある(そのUIをホストしているDispatcherを使用する)。但しこれは、UIメッセージキューをウインドウ毎に分けたいという願望が実現されたとも言えるので、必ずしも悪い事ではないが、面食らうと思う。
  • .NETメタデータアクセスに制約がある。特にリフレクションは、.NETの常識が通用しない場合がある。

もちろん、WPFのコントロールと、直接対応するUWPコントロールは、存在する場合もあれば存在しない場合もある(例: TextBoxクラスは双方に存在するが、DockPanelクラスに直接対応するものは無い)ので、XAMLの記述と言う点では、WPFとUWPに直接の互換性はありません。名前空間にも違いがあります。

.NETメタデータの操作については、リフレクションに関するクラスやメソッドは存在しますが、一部の使用には制約があります(コンパイル時に警告は出してくれる)。これは、UWPアプリケーションが、不正な動作を誘発させていないことを(Windowsストアの審査で)確認可能にするために行った仕様変更や制約です。

Epoxyの移植では、イベントをバインディングする機能の移植のところで、UWP専用のAPIを使用する必要がありました(しかも情報がほとんどなく、Rxのコードを参考にした)。個人的な意見としては、もっと段階的に移行可能な方法があったのではないかと思います。このような制約のために、リフレクションに限らず、例えばストリームI/O操作も.NETと異なる独自のクラスやインターフェイスが定義されているため、UWPを使う場合はそれらについて知っておく必要があります。

WPFと似たアーキテクチャのような体裁でありながら、XAMLとは無関係なところで、一般の.NET開発で常識と思われる知識が使えないことが多いと感じます。これらすべての欠点は、今俯瞰してみると、すべて「ネイティブコードへの変換時に、可能な限りの最適化を行う」ことを前提としているように思います。しかし、次節のXamarin Formsでも、iOSではネイティブコードへの変換(AOT)を行うわけで、効率に差はあるにしても、この欠点を受け入れてでも実現すべき事だったのかはわかりません。

私的には、もう少し冷静に判断して欲しかったところですが、スマートフォンとタブレットの迫る外圧に負けたのかもしれません…

Xamarin Forms

元々、Xamarin自体は、AndroidやiOSのアプリケーションをC#で記述可能で、ランタイムにmonoを使用する、という環境でした。ここには、AndroidとiOSを共通のコードで記述出来る、という意味は「含まれていません」。AndroidのアプリをC#で書ける、あるいはiOSのアプリをC#で書ける、という以上の意味は無く、それぞれの環境にあるAPIを使用します。

Xamarin Formsは、そこに共通のAPIをXAMLと共に定義し、可能な限り同一のコードで、AndroidやiOSのアプリを記述可能にしようとしたものです。例えば、ターゲットがAndroidやiOSのどちらであっても、共通のTextBoxクラスやButtonクラスを使います。

AndroidにはAndroidのスタートアップコードがあり、iOSにはiOSのスタートアップコードがあり、それらが共通のアプリケーションコードを呼び出す(共通コードでXAMLを定義し、Xamarin FormsのAPIを使う)、と言うような構造を採ります。

WPFからXAMLの歴史が始まっていることもあり、コントロールの扱いはWPFに似ていますが、現実的なスマートフォンアプリケーションを実装可能にするため、やはりWPFとは異なるコントロールは多いです。基本的にはUWPと同じように、ユーザーインターフェイス設計は、Xamarin Forms専用に考える必要があります。

Xamarin Formsを使う利点は、何といってもUWPのように独自の似て異なる.NET APIを使う必要が無いことです。例えば、ストリームI/OはStreamクラスを使えば良いし、リフレクションも基本的に.NETのそれと同一です。もちろん、iOSの場合はAOTコンパイルされるため、リフレクションAPIの使用には一定の制約があります(使うと実行時に死ぬという問題があります)が、APIが同一なので、.NETの一般的な知識がそのまま通用します。

Xamarinは、.NETでモバイル開発を行う選択肢としては、速い段階で実用レベルに達していたため、Xamarin Formsは実用可能な処理系として、急速に浸透しました。なので、商用サービスとしてのアプリケーション開発では、選択肢に加えることが出来そうなXAML環境の一つです。

なお、Xamarin FormsはUWPを実行環境とすることも出来ます。これは、ビルドした結果がUWPアプリケーションとして、ストアアプリ展開可能という意味です。もちろん、同じXAML環境であっても、UWPとXamarin FormsのXAMLには互換性は無く、この方法で実装する場合はあくまでXamarin Formsとして開発する事になります。Epoxyはもちろん、Xamarin Forms on UWPにも移植しています。

また、Xamarin FormsはGtkで動かすことも出来るため、macOSやLinuxのようなPosix互換環境(つまりはX Window System)をターゲットにすることも出来ます。但し、まだ標準的な手順でHello worldが動くというレベルに無いため、試してみるには追加の手作業が必要になります。別の例として、Unity(ゲームエンジン)にXamarin Formsを移植した例もあります。

Avalonia

Avaloniaは割と昔からあるXAML環境の一つで、最初からマルチプラットフォーム対応を謳っていました。Xamarin Forms同様に、WindowsだけではなくmacOSやPosix互換環境を選択可能で、開発時点ではまだ.NET Coreは無かったので、mono上で動作させることが前提でした。

現在は.NET Core/.NET 5環境でも動作させることが出来ます。XAML環境として見た場合、基本的なコントロールはサポートされており、私見ですが、リッチなユーザーインターフェイスでなければ、今現在マルチプラットフォームXAML環境としての完成度は高いように思います。XAMLはコントロールの合成が可能なので、この時点でも十分な能力を備えていると言えるのかも知れません。

その昔、かなり初期のバージョンで試したことがありましたが、その頃とは比較するまでもなく一発で動作し、不安定なそぶりはありませんでした。

Avaloniaの良い所は、WPFの構造上、あまりイケてない部分が改良されている事です:

  • WPFでは、object型やDependencyObject型から型キャストを要求される場面が多いが、Avalonia APIはそういう所がきちんとジェネリック引数を要求するように変更されており、コンパイル時型チェックが機能する
  • ほぼすべてのコントロールに、インターフェイス型が併設されている。このお陰で、インターフェイスを使った高度な抽象実装が可能だったり、独自のコントロールが基底クラスに縛られない
  • ターゲットプラットフォームがnet461, netcoreapp2.0, netstandard2.0なので、非常にニュートラルであり、多くのライブラリに適合する
  • C# 8.0のnull許容参照型が有効になっている(0.10.0現在)。ソースコードを参照すると、結構な勢いでC#の新しい構文で書き直されている。

全体的に自然な環境・綺麗な設計・実装で、アーキテクチャとして奇抜なところが無いので、.NETデベロッパは素直に扱えると思います。よく練られていて、WPFがこうだったら良かったのに、という感想です。一部のイベントハンドリングはRxを前提としていて、なるほどと思わせる部分があり、専用のReactiveUIライブラリを完備しています。また、現時点では、あまりトラブルを起こすことなく、LinuxやmacOS(注: macOSは未評価)で実際に動かせるというのも大きいでしょう。dotnet CLIにも対応しているので、すぐに始められます。

欠点もあります。それは、AvaloniaのXAML編集時に、インテリセンスが全く効かない事です(Visual Studio 2019上)。XAMLのインテリセンスは、この記事で紹介するすべての環境で満足行く状態ではない(C#インテリセンスとの比較)と考えていますが、Avaloniaはそもそも全くインテリセンスを使えないため、注意が必要です。恐らくauthorは問題を認識しているでしょうから、将来改善される見込みはあると思います。

また、マルチプラットフォーム対応として挙げているAndroidとiOSは、まだ追加の作業が必要なので、一般の開発者が扱える状態にはありません。この部分を見ると、Xamarin Formsとは真逆の状況にある印象です。

Avaloniaについて色々書きたいと思ったのですが、あまり書くことがありませんでした。その位良い実装だったという事です。Epoxyの移植時も、「そうだよ、それだよそれ!」って言いながらやっていたぐらいです。

(追記: なぜかAvalonia XAMLのインテリセンスが働くようになっていました。この記事を書いてから2~3日の話なので、何かアップデートがあったとかそう言う事ではない気がします。自席環境のせいかもしれません。XAMLインテリセンスは機能的に良くないと書いていますが、それでもあるのと無いのとでは雲泥の差です。もし、原因など分かれば、別記事で改めて紹介しようと思います)

Uno platform

Uno platformは最近出てきたXAML環境です。当然マルチプラットフォーム対応を謳っているのですが、wasm (Web Assembly、つまりはChromeなどのウェブブラウザ上で動作する)に対応しているなど、かなり尖っています。そのためか、少し前に大きな話題となりました(その頃は私事で忙しく、試している余裕が無かった)。

そのようなわけで、期待と共にEpoxyの移植に取り掛かったのですが、その内容は色々な面で驚きでした:

  1. UnoのAPIはUWP準拠。つまり、UWPの知識があれば、殆ど問題なくコードを書けることになる。UWPを下敷きにするのは、大胆と言うかびっくりですが、エコシステムを考えると、他のXAML環境のように一から設計するよりも良いのかも知れない。
  2. 大半のクラスに基底クラスが無い。え、そんな馬鹿な?UWPを下敷きにしているのでは?DependencyObjectがあるのでは?と思うが、何とDependencyObjectはクラスではなくインターフェイスとして定義されている。
  3. この時点で(既存のXAML環境の知識がある場合)、一体どうなっているのかわけがわからないと思うだろうが、何とDependencyObjectに相当する実装は、コンパイル時に自動生成されて(C#のpartialを使って)流し込まれる。
  4. 以上はUno on UWP以外の環境の話であり、Uno on UWPの場合は、大半の実装はUWPの実装をそのまま使う。つまりUnoがUWP準拠なのは、実際にUWPを使う場合はほぼそのまま、それ以外の環境では半自動生成された実装が流し込まれて、UWPのAPIにそれっぽく似せた状態にした上で、不足する実装を環境固有のライブラリで補う、と言う形式になっている

よくこんな事を思いついたなというのが感想で、アーキテクチャは間違いなく一番尖っています。メタプログラミングを駆使したXAML環境です。これを見たときには、半ばスルーしていたUWPを真面目に頑張ってみようかと思いました。

しかし、完成度がかなり残念な状態でした…

UnoをUWP環境で使う場合は、実際UWPのAPIをそのまま使うので、ちゃんと機能します。しかし、それ以外の環境では、エミュレーションライブラリに頼るわけですが、ここがまだまだこれからという状態です。例えば、UWP用ストリームI/O APIを使った以下のようなシンプルなコード:

static async ValueTask<ImageSource?> FetchImageAsync(Uri url)
{
    // Redditから画像をダウンロードする (UWP)
    var raStream = new InMemoryRandomAccessStream();
    await raStream.WriteAsync(
        (await Reddit.FetchImageAsync(url)).AsBuffer());
    raStream.Seek(0);
    var bitmap = new BitmapImage();
    await bitmap.SetSourceAsync(raStream);
    return bitmap;
}

は、Uno on UWPでは動作しますが、それ以外ではNotImplementedExceptionとなります。また、XAMLを試行錯誤中、ビルド時に何度もMSBuildタスクがエラーになってビルドが失敗することがありました。そのため、Hello worldぐらいならwasmでも動作して「おお、ブラウザで動くXAMLアプリが作れるぞ?」と思いますが、その後が続きません。

Epoxyでは移植を行いましたが、このような理由でUno on UWPのみ検証しています。

斬新で、他のXAML環境とも全く異なるアーキテクチャのアプローチでしたが(嫌いじゃない)、まだまだ問題を自力で解決できる強い人向けという感じです。私的には、もう少し時間を置いてから評価したいと思います。実際に完成すれば、かなり良い選択肢になる可能性はあると思います。

WinUI 3

WinUIは現在バージョン3の評価中で、内容は見も蓋もありませんが、UWPの焼き直しです。実際に移植を行ってわかったのは、名前空間を変更するだけで殆どそのままUWPのコードを移植できる、という事です。また、新たに、普通のデスクトップアプリケーションとして振る舞わせる、つまり、一般的なウインドウを持つようなアプリケーションを開発できるようになったのが新しい(古い?)所です。

UWPやWinUI、そしてこの後に紹介するMAUIも含めて、直近の.NETユーザーインターフェイスフレームワークは混乱を極めていて、UWPからWinUIに移行するのも、私は本当のところの理由を正確に理解していません。UWPでは、GUIの部分(つまり今回記事にしているXAMLとその周り)と、Windowsに新たに追加された非ユーザーインターフェイスAPIを分離する、という話をチラ見した記憶があるため、その結果がWinUIなのかも知れません。

参照パッケージをUWPからWinUIに切り替えると、当然シンボル名などが未定義になったりします。そこで、名前空間をWinUIのものに合わせてやると解消します。例えば以下のような感じです:

#if WINDOWS_UWP || UNO
using Windows.UI;
using Windows.UI.Xaml.Media;
#endif

#if WINUI
using Windows.UI;
using Microsoft.UI.Xaml.Media;
#endif

// ...
var brush = new SolidColorBrush(Color.FromArgb(...))

これはEpoxyの実装を一部抜き出したものですが、見てなにか気が付きますか? WinUIでは、一部の名前空間がUWPのままだったりします。これがユーザーインターフェイスとそれ以外のAPIの分離結果なのかも知れませんが、詳しく確認すると、SolidColorBrushは「Microsoft.UI.Xaml.Media」、Colorは「Windows.UI」に定義が存在します。もはや覚えられません…

勿論、C#であるならインテリセンスで自動補完が効くので、それほど問題ではないのでは?と思うかも知れませんが、Epoxyの移植時にはかなり混乱しました。

それでも、UWPのメンテナンスを行っている方は、WinUIへの移行に大きな負担は無いと思われます。同時に、デスクトップアプリケーションとしてもサポートされるので、今までスマートフォンやタブレットライクなアプリの挙動に躊躇していた方も使える環境と言えるかも知れません。

しかし、注意すべき点は、デスクトップアプリケーションとしてビルドした場合も、動作させるのにいわゆる「デプロイ」操作が必要で、そのためにパッケージングする必要もあります。ビルドすればEXEファイルが出来上がって、それをクリックすれば実行できる、とか、ファイルをzipで固めて別のマシンで展開して実行したい、のような事を考えていると、コレジャナイ感があると思います(つまり、xcopyデプロイは出来ません。WinUI3のissueとして数件挙がっていたので、やっぱりそうだよねと思った)。

そのような事もあり、実際にWinUIを直接使う必要性があまり感じられない(≒他のマルチプラットフォーム向けXAML環境を使ったほうが良いのでは?)と言う疑問を退けられませんでした。UWPのアップグレードと考えると、どうしてもUWPであるが故の制約(既に解説しました)が存在する事になります。デスクトップアプリケーションを開発すると考えた場合、これが制約に映ってしまいます。

今後もストアアプリでアプリケーションを展開したいと考えている場合は、有効な選択肢の一つでしょう。

MAUI

MAUIは次世代の.NETマルチプラットフォームユーザーインターフェイスフレームワークです。一方、Xamarin Formsの焼き直しという話をどこかで見ました。それだとちょっと見も蓋もないので、把握したことを書くと:

  • .NET 6のリリースに合わせて作業中で、まだ公開されていない(?)
  • Xamarin FormsはMAUIに置き換える予定で、とりあえず名前空間は変わります、という話は見た(アレ?これどこかで?)
  • MVU (Model-View-Update)アーキテクチャに対応。Cometと呼ばれるプロジェクトで進行中

結局、まだ動くMAUIの実装は参照できないようなので、Epoxyの移植を試みるのは諦めました。そのため、MAUIでわかっているのは上記の事だけです。すいません。

TAN-Yさんが、MVUを試す記事を書いているのを見つけました。

MVUは、ユーザーインターフェイスをXMLではなくコードで記述します。アーキテクチャも異なりますが、XAML視点で見た場合、誤解を恐れずに書くならば、ある意味Windows Formsへの先祖返りのようにも思えます。最終的な仕上げには、Visual Studio上でのデザイン時編集を可能にするというゴールがあると思われ、今ならRoslynがあるので、それを実現する土台はあると言えると思います。また、XAMLのインテリセンスが散々であることからも、コードで書いたほうが精神的にも楽なのは想像出来ることです。

EpoxyがMVVMのためのライブラリなので、MVUやCometについて何か考慮することは無いと思います。

MAUI上でCometが実装されるより、CometのようなMVUフレームワークが、他の既存のXAML環境でも使えるようになると良いと思います。私がちょっと不安に思うのは、これでまたリタイヤしてしまうOSSが出るのではないかという事です。AvaloniaもUnoも良い挑戦だと思います。相互に発展するような方法で伸びることが出来ると良いのですが。

まとめ

ここまで読んでいただいた方なら、XAML環境の決定打は存在しない、という現実について大体わかっていただけたかなと思います。個人的には、できればデスクトップ開発はAvaloniaにシフト、モバイル開発はXamarin Formsという使い分けで行こうかと思っています。

デスクトップ開発は引き続きWPFを死に絶えるまで使うという選択肢はありだと思いますが、私の場合メインマシンをUbuntuに乗り換えてしまったこともあり… (EpoxyはUWPとかあるので、結局Windows上で開発していますが)

他にも、UWP系列を使わないのは、UWPの技術的な制約だけではなく、Microsoftの標準技術という位置づけでありながら、.NET側から見ると異端すぎること。そしてビルド環境が特殊なせいで、ビルドの再現性やCI/CDに馴染みにくい(イミュータビリティやポータビリティが殆どないので、自動化でいつも頭を悩ます)事を挙げておきます。WPFのビルド環境が.NET Coreで遜色ない方法でサポートされ、非常に親和性が高くなったので、余計にUWPの欠点が目立ちます。

このオチはある程度予想していたものですが、元々Epoxyが、複数の異なるXAML環境でも同じように使えるMVVMライブラリという落とし所を目指していたので、Epoxyの存在理由も含めて、丁度良かったかなと思っています。

(開発で使用するXAMLの環境を何にするのかは、それぞれの個人やお仕事上の事情があるでしょう。この記事は、いずれかのXAML環境への移行を推奨しているわけではありません。Epoxyの開発理由もそこにあります)

また、MAUIにはあまり過度の期待はしていませんが、一方でMVVMアーキテクチャがMVUに取って替わられる可能性は十分ありうるとも思います。Epoxyの開発は、MVVMでのあらゆる面倒くささが動機づけになっているので、それが解消する可能性があるならMVVMにこだわり続ける理由はありません。果たして実用に足りるのか?2022年には判明するかも知れないですね。

回転寿司をVisual Studioに降臨させる – Visual Studio Advent Calendar 2016

この記事は、Visual Studio Advent Calendar 2016の4日目の記事です。

sushi_rotation_on_mbp少し前に、新しいMacBook Proで回転寿司が話題になりましたね。こんな奴です:

centerclr-sushirotatorこれが転じて、最終的にVisual Studioの拡張機能として、こんなものを作りました:

Visual Studioの拡張機能… 私は度々dis… もとい、非常に難易度が高いという話をしているのですが、この拡張機能の実装はそれほど難しくありません。とは言え、罠が至る所に待ち構えています。今回は、拡張機能を作るうえで躓いてツマラナイ思いをしないように、導入部分のガイドを記録に残しておこうと思います。

なお、この拡張機能はGitHubで公開しています: CenterCLR.SushiRotator
すぐに試してみたい方は: Visual Studio Market place、または拡張機能から「sushi」で検索すれば出てきます。

# やじうまの杜でも取り上げられました: MacのTouch Barが回転寿司に! エディターでも続々カイテン


事前に考える必要のある事

Visual Studioの拡張機能は、俗に「VSIX」と呼ばれています。これは、拡張機能のパッケージに「CenterCLR.SushiRotator.vsix」のような拡張子が付いていることでもわかります。
ややこしいのは、拡張機能のすべてにvsix拡張子がついているわけではなく、一般的なセットアップインストーラーだったり、msi形式だったする事もあることです。

VSIX形式は、Visual Studio(のVSIXInstaller.exe)を使ってインストールします。また、VSIXでは、Visual Studio Market placeからの自動更新に対応していて、ユーザーレベルでのインストールにも対応しているため、ユーザーからすると扱いやすいものになっています。例えば、拡張機能がGACへのインストールを必要としているなどの複雑な処理を行う場合は、VSIX形式とすることはできないため、msiや独自のインストーラーを作る必要があります。

今回は、要するにエディタを拡張するだけであり、エディタの拡張ならVSIXだけで完結するため、標準的なVSIX形式で作ります。

また、対象のVisual Studioを何にするかも考える必要があります。Visual Studioの拡張機能は、以下のエディション(以上)でサポートされます:

  • Visual Studio 2005 Professional
  • Visual Studio 2008 Professional
  • Visual Studio 2010 Professional
  • Visual Studio 2012 Professional
  • Visual Studio 2013 Community
  • Visual Studio 2015 Community
  • Visual Studio 2017RC Community

より古いバージョンに対応させようとすると、それだけ拡張可能な機能が制限されます(機能拡張に対応するインターフェイスが定義されていないなど)。

これ以前のバージョン(2003など)でも、基本的なインターフェイスは実装されています(それどころか、古のVisual InterDevやJ++でも)。しかし、これらのバージョンでの拡張は、マイクロソフトと特別な契約を結んだデベロッパだけが開発することが出来たため、ほとんどの皆さんは対応させることが出来ません(まあ実際には書いたら動くのかもしれませんが。それから、既にサポート対象外なので、実質無視して良いと思います)。

また、以下の制限もあります:

  • 各バージョンのExpress Editionは、拡張機能に対応していません。
  • VSIXパッケージをサポートするのは、Visual Studio 2010以上です。2010未満は、カスタムインストーラーが必要です。当然、Market placeでの自動更新はできません。
  • 今回対象とする「テキストエディタの拡張」は、Visual Studio 2010以上でのみサポートされます。これは、2010以降はVisual StudioがWPFで書き直され、WPFコントロールを使う前提となっていることが遠因となっていると思われます。
  • Visual Studio 2012以降は、後述のvsixmanifestファイルの形式が変更されたため、2010をサポートする場合は古い形式で記述する必要があります。
  • それぞれの拡張機能を実装するには、対応するVisual Studio SDKが必要です。例えば、2010の場合は、VSSDK2010が必要となります。これらは下位互換性があるため、普通は目指すバージョン群の最も高いバージョンに対応するSDKをインストールすれば、問題ありません。
  • 使用する.NET Frameworkのバージョンにも注意して下さい。対応させる最も古いVisual Studioで開発可能な、最後のバージョンを選択します。(大丈夫だと思いますが、.NET Coreは使えません、念のため)。

vs2017rc_install_vsix上記の事を考え、今回はVisual Studio 2012 Professional以上、つい先日リリースされた2017RCまでを対象とします。SDKは2017RCのものが必要ですが、これは2017RCのインストーラーで選択すればインストール出来ます。.NET Frameworkは4.5を使用します。

ややこしいのは、拡張機能の開発自体は、対応させる最も新しいVisual Studioを使う必要がある、と言うことです。したがって、2017RCを使用します。


拡張機能のプロジェクトテンプレート

vsix1VSIXの拡張機能を作るには、プロジェクトの新規作成から「Extensibility」にある「VSIX Project」でプロジェクトを作成します:

vsix2次に、プロジェクトにエディタ拡張を追加します。これも、Extensibility配下から選択します。いくつか候補がありますが、ここでは「Editor Margin」を選択します。

vsix3このEditor Marginが、テキストエディタの一部の領域を占有する事が出来る拡張です。これを使って寿司を流します。

これらのテンプレートですが、以前は本当にくぁwせdrftgyふじこlp… いや、色々と問題があった… のですが、2017RCでは非常にすっきりしたテンプレートとなっていて、扱いやすく修正されました。基本となるVSIX Projectでプロジェクトを作っておいて、そこに必要なitemとして後から追加する感じです。但し、拡張機能で実現可能な機能に対して、圧倒的にテンプレートが足りてません。大半は一から手でクラスを書いたりする必要があります。

# 何故かテキストエディタに対する拡張のテンプレートだけが色々あります… もちろん、本当はもっと多彩な事が出来ます。


Editor Margin

Editor Marginは、テキストエディタの一部領域が、拡張機能のために予約され、そこに自由に何かを表示させることが出来るVSIXの拡張機能です。この一部領域とはつまり、WPFのCanvasであり、まあ、あとはWPFで好きなようにやってよ、という感じです。Editor Marginクラスを加えると、以下のようなテンプレートコードが生成されます:
(Disposeの面倒を見るコードもあるんですが、長いので省略)

namespace VSIXProject1
{
    /// <summary>
    /// Margin's canvas and visual definition including both size and content
    /// </summary>
    internal class EditorMargin1 : Canvas, IWpfTextViewMargin
    {
        /// <summary>
        /// Margin name.
        /// </summary>
        public const string MarginName = "EditorMargin1";

        /// <summary>
        /// Initializes a new instance of the <see cref="EditorMargin1"/> class for a given <paramref name="textView"/>.
        /// </summary>
        /// <param name="textView">The <see cref="IWpfTextView"/> to attach the margin to.</param>
        public EditorMargin1(IWpfTextView textView)
        {
            this.Height = 20; // Margin height sufficient to have the label
            this.ClipToBounds = true;
            this.Background = new SolidColorBrush(Colors.LightGreen);

            // Add a green colored label that says "Hello EditorMargin1"
            var label = new Label
            {
                Background = new SolidColorBrush(Colors.LightGreen),
                Content = "Hello EditorMargin1",
            };

            this.Children.Add(label);
        }

        #region IWpfTextViewMargin
        /// <summary>
        /// Gets the <see cref="Sytem.Windows.FrameworkElement"/> that implements the visual representation of the margin.
        /// </summary>
        /// <exception cref="ObjectDisposedException">The margin is disposed.</exception>
        public FrameworkElement VisualElement
        {
            // Since this margin implements Canvas, this is the object which renders
            // the margin.
            get
            {
                this.ThrowIfDisposed();
                return this;
            }
        }
        #endregion

        #region ITextViewMargin
        /// <summary>
        /// Gets the size of the margin.
        /// </summary>
        /// <remarks>
        /// For a horizontal margin this is the height of the margin,
        /// since the width will be determined by the <see cref="ITextView"/>.
        /// For a vertical margin this is the width of the margin,
        /// since the height will be determined by the <see cref="ITextView"/>.
        /// </remarks>
        /// <exception cref="ObjectDisposedException">The margin is disposed.</exception>
        public double MarginSize
        {
            get
            {
                this.ThrowIfDisposed();

                // Since this is a horizontal margin, its width will be bound to the width of the text view.
                // Therefore, its size is its height.
                return this.ActualHeight;
            }
        }

        /// <summary>
        /// Gets a value indicating whether the margin is enabled.
        /// </summary>
        /// <exception cref="ObjectDisposedException">The margin is disposed.</exception>
        public bool Enabled
        {
            get
            {
                this.ThrowIfDisposed();

                // The margin should always be enabled
                return true;
            }
        }

        /// <summary>
        /// Gets the <see cref="ITextViewMargin"/> with the given <paramref name="marginName"/> or null if no match is found
        /// </summary>
        /// <param name="marginName">The name of the <see cref="ITextViewMargin"/></param>
        /// <returns>The <see cref="ITextViewMargin"/> named <paramref name="marginName"/>, or null if no match is found.</returns>
        /// <remarks>
        /// A margin returns itself if it is passed its own name. If the name does not match and it is a container margin, it
        /// forwards the call to its children. Margin name comparisons are case-insensitive.
        /// </remarks>
        /// <exception cref="ArgumentNullException"><paramref name="marginName"/> is null.</exception>
        public ITextViewMargin GetTextViewMargin(string marginName)
        {
            return string.Equals(marginName, EditorMargin1.MarginName, StringComparison.OrdinalIgnoreCase) ? this : null;
        }
        #endregion
    }
}

コンストラクタで渡される引数IWpfTextViewと、IWpfTextViewMarginというインターフェイスの実装が、拡張機能をサポートする要となっています。また、このクラスはCanvasクラスを継承していますね。サンプルの実装は、このCanvasにLabelを手動生成して追加しています。やる気になれば普通にXAMLで書いても動きそうな気配です(試してません)。

コンストラクタの引数は依存注入されるのでしょう(Visual StudioはMEFを使用しているので、MEF経由で渡されるようです)。(間違えました、以下で示すファクトリクラスから生成しています)注意すべき点としては、このクラスにはstaticフィールドを含めることが出来ない、と言う事です。含めた場合、拡張機能のロード時に失敗します(結構悩んだ。Visual Studioのバグかもしれない)。staticフィールドが欲しい場合は、別のクラスに定義して、そこを参照するようにします(回転寿司でもやっています)。

サンプルのコードはコンストラクタ内で、Heightを20pxに指定しています。Canvasなので、このサイズが表示領域を規定することになります。また、WPFは標準でクリッピングを行いません。手動描画した場合に描画がはみ出ると、エディタ領域が酷いことになってしまうため、ClipToBoundsをtrueとしているようです。はみ出す可能性がなく、パフォーマンスに問題がある拡張機能を実装するのであれば、これをfalseとしても良いでしょう(どんな拡張機能なのかは良くわからない…)。

まんまWPFなので、もちろんアニメーションも使えます。回転寿司では、寿司を流すのにWPFのアニメーションを使っています。なので、非常に滑らかに流れることがわかると思います(上のGIFアニメーションはガタガタですが、ぜひ実際にインストールして見て下さい)。


Editor Margin Factory

Editor Marginクラスは、ファクトリクラスとの関連付けで実行されます。ファクトリクラスはEditor Marginテンプレートを追加した際に、自動的に追加されているはずです。以下に例を示します:

namespace VSIXProject1
{
    /// <summary>
    /// Export a <see cref="IWpfTextViewMarginProvider"/>, which returns an instance of the margin for the editor to use.
    /// </summary>
    [Export(typeof(IWpfTextViewMarginProvider))]
    [Name(EditorMargin1.MarginName)]
    [Order(After = PredefinedMarginNames.HorizontalScrollBar)]  // Ensure that the margin occurs below the horizontal scrollbar
    [MarginContainer(PredefinedMarginNames.Bottom)]             // Set the container to the bottom of the editor window
    [ContentType("text")]                                       // Show this margin for all text-based types
    [TextViewRole(PredefinedTextViewRoles.Interactive)]
    internal sealed class EditorMargin1Factory : IWpfTextViewMarginProvider
    {
        #region IWpfTextViewMarginProvider
        /// <summary>
        /// Creates an <see cref="IWpfTextViewMargin"/> for the given <see cref="IWpfTextViewHost"/>.
        /// </summary>
        /// <param name="wpfTextViewHost">The <see cref="IWpfTextViewHost"/> for which to create the <see cref="IWpfTextViewMargin"/>.</param>
        /// <param name="marginContainer">The margin that will contain the newly-created margin.</param>
        /// <returns>The <see cref="IWpfTextViewMargin"/>.
        /// The value may be null if this <see cref="IWpfTextViewMarginProvider"/> does not participate for this context.
        /// </returns>
        public IWpfTextViewMargin CreateMargin(IWpfTextViewHost wpfTextViewHost, IWpfTextViewMargin marginContainer)
        {
            return new EditorMargin1(wpfTextViewHost.TextView);
        }
        #endregion
    }
}

初見殺し的なひどいコードですが、このクラスの属性から、Visual StudioがEditor Marginのコードを認識して、実行可能となります。いじるのは、色々わかってからの方が良いでしょう。


vsixmanifestファイル

VSIX Projectプロジェクトを生成すると、*.vsixmanifestというファイルも追加されます。このファイルが、VSIXをパッケージングするのに使われる定義体で、中身はXMLです。NuGetで言う所のnupkgに対応するnuspecファイルです。

<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
  <Metadata>
    <Identity Id="VSIXProject1.Kouji Matsui.86820e26-2ec6-496e-ad20-38a2ba88bf46" Version="1.0" Language="en-US" Publisher="Kouji Matsui" />
    <DisplayName>VSIXProject1</DisplayName>
    <Description>Empty VSIX Project.</Description>
  </Metadata>
  <Installation>
    <InstallationTarget Id="Microsoft.VisualStudio.Community" Version="[15.0]" />
  </Installation>
  <Dependencies>
    <Dependency Id="Microsoft.Framework.NDP" DisplayName="Microsoft .NET Framework" d:Source="Manual" Version="[4.5,)" />
  </Dependencies>
  <Prerequisites>
    <Prerequisite Id="Microsoft.VisualStudio.Component.CoreEditor" Version="[15.0,16.0)" DisplayName="Visual Studio core editor" />
  </Prerequisites>
  <Assets>
    <Asset Type="Microsoft.VisualStudio.MefComponent" d:Source="Project" d:ProjectName="%CurrentProject%" Path="|%CurrentProject%|" />
  </Assets>
</PackageManifest>

XMLはこのような形式です(上で述べたように、2010に対応させるには異なる形式で記述する必要があります)。但し、SDKがインストールされていれば、ソリューションエクスプローラーからダブルクリックで開くと、以下のようにビジュアル編集出来ます:

vsix4パッケージをMarket placeで公開する場合は、Identity・DisplayName・Descriptionなどをきちんと埋めておきましょう。他にもIcon・PreviewImage・License・Tagsなどが指定出来ます。

vsix5また、重要な項目として、Dependenciesの.NET Frameworkのバージョンと、Prerequisitesの「Visual Studio core editor」のバージョンを合わせておく必要があります。

vsix6Version Rangeの指定は、数学で使うような、以上・未満の記号による指定を行います。バージョン11(2012)以上~16(2018?)未満の場合、”[11.0, 16.0)”のように、以上を角括弧、未満を丸括弧で示します。このとき、”[11.0, 15.0]”としてはいけません。これでは、2017のマイナーバージョンアップに対応出来なくなってしまいます(15(2017)「以下」なので、マイナーバージョンが上がると外れてしまう)。

InstallationTargetは複数定義できます。最初に検討した、対応させるVisual Studioのバージョンとエディションの組み合わせが複雑になるので、以下のように定義するとよいと思います:

  <!-- これは回転寿司で使っている定義です。
       InstalledByMsiはmsiでインストールするかどうか、AllUsersは昇格が必要かどうかです。 -->
  <Installation InstalledByMsi="false" AllUsers="false">
    <!-- VS2012 -->
    <InstallationTarget Id="Microsoft.VisualStudio.Pro" Version="[11.0,12.0)" />
    <!-- VS2013- -->
    <InstallationTarget Id="Microsoft.VisualStudio.Community" Version="[12.0,16.0)" />
    <InstallationTarget Id="Microsoft.VisualStudio.Pro" Version="[12.0,16.0)" />
  </Installation>

いつも通りと言うか、一旦vsixmanifestを生成したら、後は手動でXMLを弄った方が早い感じがひしひしと…


VSIXをデバッグする

このプロジェクトをビルドすると、bin\Debug(またはbin\Release)に、*.vsixが生成されます。デバッグ実行すると、検証用のVisual Studioが別途起動し、そこにVSIXが自動登録されてデバッグ可能になります。以下はデバッグ実行した例です。右側が開発中のVisual Studio、左側がデバッグ実行で新たに起動した「実験的なインスタンス」と命名されているVisual Studioで、その上で寿司が流れています。

vsix7

Visual Studio 2015以降で特徴的な、WPFライブビュー用のツールバーも表示されていることがわかります。

ところで、デバッグしていると途中で動作がおかしくなったりすることがあります。特に、変更したコードが反映されていないなどです。これは、VSIXのインストールフォルダが何らかの問題で破損した場合に発生します。インストールフォルダはユーザー毎に作られます。場所は、「C:\Users\[ユーザー名]\AppData\Local\Microsoft\VisualStudio」のような場所です。この配下に、Visual Studioのバージョン番号毎にフォルダが作られ、その中にインストールされます。

デバッグ実行した場合も、このフォルダ配下にコピーされます。2017RCの場合、サブフォルダは「15.0_31113400Exp」です。最後の「Exp」が「実験的なインスタンス」に対応します。何か動きがおかしいと思ったら、このフォルダを丸ごと削除して再度ビルドしてみて下さい(Visual Studioは一旦終了させる必要があるかもしれません)。削除しても、次回起動時には再生成されます。なお、経験的には「わりとよく壊れます」。アッハイとかつぶやきながら、削除すると良いでしょう。

それから、最新のVisual Studio対応バージョンを上げる場合(例えば、2013対応のものを2017RCに対応させる)、デバッグ実行時に起動するdevenv.exeへのパスも更新することをお忘れなく。「実験的なインスタンス」へのデプロイは最新のVisual Studioに行われる(これはSDKの機能で行われる)のに、起動するVisual Studioが古いバージョンのままだと、VSIXがロードされないように見えて混乱します。この問題も忘れたころにハマるパターンです。

C#のプロジェクトは、デバッグ時の起動パスを*.csproj.userに保存します。通常、Gitでソースコード管理していると、このファイルは管理対象外とする(.gitignoreで除外)のですが、ここにパスが書かれているため、git cloneしてデバッグしようとして失敗するのも良くハマります。*.csprojに手動で定義を移しておくとベターですが、そうすると今度は*.csproj.userでいつの間にかオーバーライドされていてハマるという、非常に苦しい展開が待っています…

# オーバーライドを無視するか警告する属性が欲しい…


VSIXのNuGetと低いバージョンに対応させる話

2017RCのVSIX Projectでプロジェクトを作ると、VSIXで必要なライブラリが、NuGet経由で参照されます。このパッケージは「Microsoft.VSSDK.BuildTools」「Microsoft.VisualStudio.CoreUtility」「Microsoft.VisualStudio.Text.UI」などで公開されています。これらのパッケージが公開されていることを知ったのは今回の回転寿司によるんですが、昔はNuGetパッケージ化されていなかったため、VSIXを含むプロジェクトのビルドは、非常に面倒な問題を含んでいました(当然、CIするにはVisual Studio本体が必要だったり)。

今はこのように、VSIXテンプレートとは別体となったおかげで、ずいぶんやりやすくなったと思います。NuGet経由での参照となったのは把握していませんが、ごく最近のバージョンからの筈です。

が、このパッケージの最低バージョンが2013以降のようで、2012やそれ以前のバージョンには対応していません。もし、古いバージョンも対応させる予定であるなら、別のパッケージ「VSSDK.CoreUtility」が公開されているので、これを使うと良いと思います(StyleCop Analyzersのauthorですね)。NuGetで「VSSDK」で検索すると、細分化されたパッケージのリストが表示されるので、必要なものを追加します。但し、VSIXのビルドシステムだけは最新のパッケージを使う必要があります(vsixmanifestの最新のスキーマを認識可能にするため)。つまり、「Microsoft.VSSDK.BuildTools」は、最初に挙げたもの(の最新版)を使うことをお勧めします。

昔のVSIXテンプレートから生成したプロジェクトを保守している皆様… ご愁傷さまです。手動でゴリゴリcsprojを書き換える未来が待っております… (泣


まとめ

全体的なコードは、回転寿司を参考にして見て下さい。VSIXのパッケージをローカルデバッグ出来ていよいよ公開できるようになったら、Visual Studio Market placeで公開できます。公開は無料で可能です。公開プロセスは変更中のようで、現在は Visual Studio Team Onlineで使う拡張機能と統合したいのか、変に中途半端になっている感じです。Market placeからのリンクで申請しようとすると VSTO 向けの公開として扱われて、認証プロセスがどうのとか面倒な事になります。

vsix8「Visual Studio Gallery」と呼ばれているこっちから行き、中ほどにある「アップロード」をクリックすると確実です。アップロードする前に、vsixmanifestの内容を見直してください。また、バージョンアップする場合は、vsixmanifestのバージョン番号を更新することをお忘れなく。

一応、アップロードした時点で、パッケージングが正しいかどうかの検証は行ってくれるようです。なお、更新されたパッケージをアップロードすると、使用中のユーザーにはおなじみのように「拡張機能の更新があります」と通知されます。

何か面白い拡張機能作って放流してください :)
それでは。

データバインディングと非同期 – C# Advent Calendar 2014

この記事は、「C# Advent Calendar 2014」の21日目の記事です。

WPFやWindows Phoneやストアアプリでは、Windows Formsのような「コードビハインド」による実装の他に、「データバインディング」を積極的に使う実装を行う事が出来ます。というよりも、データバインディングという手法を使わないと、複雑なUIを表現するのが困難です。この記事では、その辺りと非同期がどう関係するのかを書いてみたいと思います。

(なお、例はWPFですが、ストアアプリでもほぼ同様です)


データバインディングって何?

そもそもデータバインディングとは、どういう手法でしょうか?

asyncbinding1

Windows Formsの時代、データバインディングと言うと「DataSet」クラスの内容を、「DataGridView」コントロールを使って表示させるという用途が一般的でした。

詳しい解説は「Windowsフォームにおける「データ・バインディング」」を参考にして下さい。

この手法の問題点として:

  • DataSetやその周りのクラス(DataTableなど)と、List等の限定されたクラスがターゲット。
    実際には、IListインターフェイスやIBindingSourceインターフェイスを実装したクラスであれば受け入れる事が出来ますが、殆どの場合はこれらのインターフェイスを独自実装しないで、単にDataSetクラスを使用していたと思います。
  • DataGridViewのセル内に、更に別の(コレクション)要素を配置するようなリッチな表現を、統一された方法で実現するのが困難。
    「DataGridViewのセル内に「コンボボックス」を配置するのはどうしたら良いのか?」と言うのは、定期的に発生するFAQであったと思いますが、このような拡張がスムーズに行えないのは、グリッドの表現をDataGridViewが独自に制御していたため、応用性が無く、学習効果が得られないからと思われます。
  • バインディング対象のインスタンスがコンポーネントとしてフォームに設定される必要があり、柔軟性に欠ける。
    DataGridView以外のコントロールでも、バインディング機能を使う事は出来ました。但し、バインディングしたいインスタンス(モデルクラスやコレクションなど)は、コンポーネントとしてフォームに配置される必要があったため、フォームとデータが密結合する事になり、データバインディングする事の意義が見出しにくい状態でした。また、コントロールへの直接アクセスの手法があまりに一般に広まったため、そもそもこの手法が利用可能である事も周知されませんでした。

「DataSetを用意して、単純にDataGridViewに表形式で表示」させるのであれば十分なのですが、それ以上のUI表現に踏み込むと途端に難易度が高くなり、インターフェイスが複雑化してしまいます。

また、DataSet自体がデータベースのテーブルを模倣したものであったため、階層化されたデータを容易に扱えないという問題もあります。Visual Studioのデザイナー機能によって「型付きDataSet」を生成出来るようになりましたが、結局(生の)DataSetをラップしたものに過ぎず、本来コード上でモデル型として表現されている構造から乖離してしまう事は避けられませんでした。

asyncbinding2

.NET 3.0でWPFが発表され、ここの部分に大きなメスが入りました。DataGridViewのような、特殊なクラスの特殊な機能によってバインディングを実現するのではなく、そもそもWPFの枠組みでこれを実現し、どのようなコントロールでも統一されたバインディングを使えるようにし、UIとデータを完全に分離可能にした… それが今のデータバインディングです。

図のように、「DataGridコントロール(WPF)」「TextBoxコントロール(WPF)」は、両方ともモデルクラスとのデータの送受を「バインディング式(Binding expression)」で実現しています。この手法には全く違いは無く、更にDataGridコントロールの場合は、階層化されたモデルクラスのインスタンス群をどのようにDataGridで表現するのかまでを、全てバインディング式で記述する事が出来ます。

DataGridコントロールだけではなく、リスト形式で要素を表示する「ListBoxコントロール」や、コントロール要素の配置を制御する「Panelクラス」を継承したコントロール群「DockPanelStackPanelGrid等」も、ほぼ同様の手法でデータとの関連付けが可能です。

そして、これらのコントロールを階層化して組み合わせた複雑なUIは、データバインディングの規則に従っている限り、技術的な制限なく実装する事が出来ます。

WPFでも、従来のWindows Formsのようにコントロールに直接アクセスして、ほぼ全てを制御する事も出来ますが、データバインディングを使えるようになると、かなり面倒に感じられるはずです。また、ちょっとUIが複雑化するだけで実現難易度が高くなるため、単調なUIで実現する事しか選択の余地が無くなってしまいます。

(Windows Formsが楽と言う意味ではなく、むしろ複雑なUIを実現する場合、Windows Formsでは更に難易度が高い)


データバインディングのキモ

データバインディングのキモは、コントロールの公開するプロパティと、モデルクラスの公開するプロパティの値を自動転送するところです。以下にTextBoxの例を示します。

これはXAMLの定義です:

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="100">
	<TextBox x:Name="sampleTextBox" />
</Window>

対応するコードビハインド:

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

		// TextBoxに初期文字列を設定
		sampleTextBox.Text = "ABC";
	}
}

asyncbinding3

TextBoxのTextプロパティは、テキストボックスに入力された文字列をやり取りするプロパティです。あらかじめこのプロパティに値を設定すれば、テキストボックスの初期表示文字列となり、ユーザーが文字列を入力したり編集したりすれば、それがこのプロパティから読み取れます。ここまではWindows Formsと同様です。

ここで、Textプロパティにバインディング式を記述します。バインディングするためには、相方のプロパティが必要です。とりあえず、MainWindowクラスに直接実装しましょう。

public partial class MainWindow : Window
{
	// バインディングされる値を保持するフィールド
	private string sampleText_ = "Bound!";

	public MainWindow()
	{
		InitializeComponent();

		// バインディング対象のインスタンスを、このクラス自身に設定
		this.DataContext = this;
	}

	// バインディングされるプロパティ
	public string SampleText
	{
		get
		{
			return sampleText_;
		}
		set
		{
			sampleText_ = value;
		}
	}
}

XAMLにはこのようにバインディング式を書きます:

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="100">
	<!-- TextプロパティとSampleTextプロパティをバインディングする式 -->
	<TextBox Text="{Binding SampleText}" />
</Window>

XAMLのコードについて、重要な部分が二カ所あります。一つはバインディング式を記述した事です。TextBoxにはTextプロパティがあるのですが、そのプロパティに対して「{Binding SampleText}」と記述します。この中括弧+Bindingで始まる指定がバインディング式です。「SampleText」と言うのは、バインディングさせたい対象のプロパティ名です。ここではMainWindowクラスに定義した「SampleText」プロパティを指定しています。

もう一つの重要な点は、もはや「x:Name」による、コントロールへの命名が不要になっている点です。この点についてはまた後で説明します。

MainWindowクラスへの変更も説明をしておきます。MainWindowクラスにも重要な二カ所の変更点があります。一つはバインディング対象のプロパティ「SampleText」を追加した事です。これに合わせて、値を保存しておくためのフィールドも追加してあります(熱心なC#信者なら、自動実装プロパティを使う事が出来ると思うかもしれません。デバッガで動きを追えるようにするためにわざと手動で実装しています)。

もう一つはコンストラクタに追加した、「DataContext」プロパティにthisを代入するという式です。このプロパティは、XAML上でバインディング式を実行する際に、デフォルトのバインディング対象のインスタンスを特定するために使われます。ここでthisを代入しているという事は、デフォルトのバインディング対象はMainWindowクラスのインスタンスとなる、と言う事です。そして、XAML上に記述したバインディング式が「{Binding SampleText}」となっているので、結局「MainWidnowクラスのインスタンス」の「SampleTextプロパティ」が、TextBoxのTextプロパティにバインディングされる、と言う事になります。


asyncbinding4

さて、このコードを実行すると、望みの通り、バインディングが実現し、初期状態としてフィールドの値が表示されます。しかし、テキストボックスの文字列をタイプして変更しても、SampleTextプロパティのsetterに遷移しません(ブレークポイントを設定して確かめて下さい)。

これは、バインディング粒度のデフォルトが、コントロールからフォーカスが外れた時、となっているためで、サンプルではTextBoxが一つしかないためにそのタイミングが無い事によります。ここでは単純に、「一文字でも変化があれば」、と変えておきます。この指定もバインディング式だけで実現出来ます。

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="100">
	<TextBox Text="{Binding SampleText, UpdateSourceTrigger=PropertyChanged}" />
</Window>

asyncbinding5

XAMLのバインディング式に「UpdateSourceTrigger=PropertyChanged」と付け加えます。すると、バインディング処理の実行が、Textプロパティに変化があった時という条件となります。これで、setterにブレークポイントを張っておいて、テキストボックスで「A」をタイプするとこのようになり、バインディング式によって自動的に値が転送されて来る事が分かります。

asyncbinding6

上記のデータバインディング例を図示すると、このようになります。従来のWindows Formsのように、イベントハンドラのフックやTextBoxのTextプロパティへの直接アクセスをする事無く、値の受け渡しが出来ている事になります。おっと、忘れる所でした。これで「x:Name」によるコントロールの命名が不要になっている理由も分かりますね。プロパティ同士がバインディング式で直接転送されるので、「どのコントロールに対する操作なのか」の明示は不要となっているのです。

(ここで解説しませんが、能動的にSampleTextの値が変化する場合は、MainWindowクラスにINotifyPropertyChangedインターフェイスを実装して、プロパティの値が変化したことをWPFに通知する必要があります)

ここまで、Windows Formsっぽい手法からスタートして説明するため、バインディング対象のインスタンスをDataContextプロパティにthisを代入する事で「MainWindow」そのものにしましたが、更にコードの責務を分離するために、バインディングするクラスを独自のクラスに完全に分離することも出来ます。例えば「MainWindowViewModel」クラスを作り、DataContextにこのクラスのインスタンスを代入する事で、バインディング対象をMainWindowから切り離す事が出来ます。

// MainWindowクラスから完全に独立したクラス(ビューモデル)
public sealed class MainWindowViewModel
{
	// バインディングされる値を保持するフィールド
	private string sampleText_ = "Bound!";

	// バインディング対象のプロパティ
	public string SampleText
	{
		get
		{
			return sampleText_;
		}
		set
		{
			sampleText_ = value;
		}
	}
}

// コードビハインドが殆ど存在しないMainWindowクラス(ビュー)
public partial class MainWindow : Window
{
	public MainWindow()
	{
		InitializeComponent();

		// バインディング対象のインスタンスを、MainWindowViewModelに設定
		this.DataContext = new MainWindowViewModel();
	}
}

これが「MVVM(Model-View-ViewModel)」パターンの入り口です。


データバインディングと非同期

長い前フリでしたが (;´Д`) 本題に入ります。ストアアプリに導入された新しいWinRT APIのセットは、原則として外部リソースアクセスは全て非同期で実現しなければならないようになっています。ブログの記事や勉強会でも説明した通り、これらはC#で言う所の「async/await」を使って実現します(より深くはTaskクラスやIAsyncInfoインターフェイスを使う事ですが、本題と関係ないので省略)。

「非同期処理」と「ユーザーインターフェイス」とはどう絡むのかが本題です。この問題は古典的でもあって、一般的なウェブブラウザのフォームでも同じような問題を抱えていました。例えば、「購入」ボタンを何度もクリックされると困るので、クリック後はボタンを無効化して押せなくする等の処置は、(ブラウザとは無関係に)ウェブサーバー側で購入処理が非同期で実行されるから発生する問題でした。

あるいはもっと良い方法もあります。何度「購入」ボタンが押されても問題ないように、同じセッションが維持されていると分かる場合は、単一の要求と見なすなどです。

どちらの手法を取るにしても、「非同期処理は何も対策しないで実現することは無理」と言う事です。async/awaitのサポートによって、非同期処理の実装難易度は格段に改善されたのですが、この部分をどうするのかは、仕様や実装を行う者に任されています。

二つのパターンの背景を説明しましたが、非同期処理をユーザーインターフェイスを工夫する事によって担保する方法について、データバインディングと絡めて考えてみます。


非同期処理中にユーザーインターフェイスをロックダウンする、と言うのは、もっとも単純で効果的な方法です。真っ先に思いつくのは、Windowsクラス全体のIsEnabledをfalseに設定する事でしょう。これをいつ行えばよいでしょうか?というよりも、非同期処理をどこで開始するのか?と言う事です。

例えば、UIにボタンが配置されており、ボタンのクリックと同時に非同期処理が開始されるとします。データバインディングを使うのであれば、「ICommandインターフェイス」を実装したクラスを使って、ボタンクリックのイベントを「バインディング」するでしょう。移譲されたイベントハンドラは、以下のような実装となります。

// (これは擬似コードのため動きません。INotifyPropertyChangedの実装やCommandクラスの実装が必要です)
public sealed class MainWindowViewModel
{
	public MainWindowViewModel()
	{
		// ICommandインターフェイスを実装したクラスを使って、バインディング可能なイベントハンドラを公開する
		this.FireStart = new Command(this.OnFireStart);
	}

	// バインディング可能なイベントハンドラ(ボタンのCommandプロパティにバインディングする)
	public ICommand FireStart
	{
		get;
		private set;
	}

	// ウインドウ全体の有効・無効を制御する(MainWindowのIsEnabledプロパティにバインディングする)
	public bool IsEnabled
	{
		get;
		private set;
	}

	// Commandクラスによって、転送されるイベントの実処理
	private async void OnFireStart(object parameter)
	{
		// ユーザーインターフェイスを無効化する
		this.IsEnabled = false;
		try
		{
			// 非同期処理...
			using (var stream = await httpClient.GetStreamAsync("..."))
			{ ... }
		}
		finally 
		{
			// ユーザーインターフェイスを有効化する
			this.IsEnabled = true;
		}
	}
}

この例では、ボタンのクリックと言う「アクション」を引き金に動作するので、比較的分かりやすいと言えます。非同期プログラミングのベストプラクティスによれば、シグネチャとして「async void」と定義するのはイベントハンドラの場合だけだと述べられています。この例は本物(CLR)のイベントハンドラではありませんが、ICommandによって間接的に呼び出されるコールバックとして、広義において同じと見なしても良いと思います。

asyncbinding7

では、値を転送するデータバインディングそのものが引き金になる場合はどうでしょうか?前フリのような、テキストボックスに対する編集操作で、非同期的に処理を実行し、結果を反映する場合です。例えば、Visual Studioのインテリセンスがシンボル名の絞り込みを行う過程や、ウェブで言うなら「googleサジェスト」のような機能です。

この場合、バインディングされたプロパティに、一文字変化がある度に値が転送されてきます。転送はsetterのコールによって実行されるため、非同期処理はsetterから始める必要があります。

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="200"
		IsEnabled="{Binding IsEnabled}">
	<DockPanel>
		<!-- Textプロパティ同士をバインディングし、テキストボックスへの文字の入力を検知可能にする -->
		<TextBox DockPanel.Dock="Top" Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />

		<!-- 結果を表示するリストボックス -->
		<ListBox ItemsSource="{Binding ResultItems}">
			<ListBox.ItemTemplate>
				<DataTemplate>
					<!-- 結果を表示するテンプレート -->
				</DataTemplate>
			</ListBox.ItemTemplate>
		</ListBox>
	</DockPanel>
</Window>
// (これも同様に擬似コードです)
public sealed class MainWindowViewModel
{
	private string text_;

	public MainWindowViewModel()
	{
		this.ResultItems = new ObservableCollection<ResultModel>();
	}

	// TextBox.Textにバインディング(UpdateSourceTrigger=PropertyChangedとする)
	public string Text
	{
		get
		{
			return text_;
		}
		set
		{
			// ユーザーインターフェイスを無効化する
			this.IsEnabled = false;
			try
			{
				// 非同期処理...
				using (var stream = await httpClient.GetStreamAsync("...?q=" + text_))
				{ ... }
			}
			finally 
			{
				// ユーザーインターフェイスを有効化する
				this.IsEnabled = true;
			}
		}
	}

	public bool IsEnabled
	{
		get;
		private set;
	}

	public ObservableCollection<ResultModel> ResultItems
	{
		get;
		private set;
	}
}

このコードには仕様的にも実装的にも問題があります。仕様面では、一文字入力する度にWindowが無効化される(しかも非同期処理が完了するまで解除されない)ため、恐らくユーザー体験は最悪の物となる事です。実装的な問題としては、setter内ではawait出来ない事です。これはsetterにはasyncを指定出来ない事から来ています。

まず、実装的な問題は、実処理をメソッド抽出すれば解決します。

public sealed class MainWindowViewModel
{
	private string text_;

	public MainWindowViewModel()
	{
	}

	// TextBox.Textにバインディング(UpdateSourceTrigger=PropertyChangedとする)
	public string Text
	{
		get
		{
			return text_;
		}
		set
		{
			// 非同期メソッドを呼び出すが、待機は出来ない。
			// →Taskを返し、メソッド名に「Async」と付ける事で、setter呼び出し後は、非同期処理が継続する事がはっきりする
			var task = this.ProceedTextAsync(value);
		}
	}

	// 一般的な非同期処理のシグネチャを順守
	private async Task ProceedTextAsync(string value)
	{
		// ユーザーインターフェイスを無効化する
		this.IsEnabled = false;
		try
		{
			// 非同期処理...
			using (var stream = await httpClient.GetStreamAsync("...?q=" + value))
			{ ... }
		}
		finally
		{
			// ユーザーインターフェイスを有効化する
			this.IsEnabled = true;
		}
	}

	public bool IsEnabled
	{
		get;
		private set;
	}
}

実処理を「ProceedText」としてasync voidとする事も出来ますが、ここではより汎用的になるように(そしてベストプラクティスに従うように)、Taskクラスを返却するようにしました。setterで戻り値のTaskを無視すると、C#コンパイラが警告を発します。そのため、ダミーの変数で受けるように書いています。わざわざTaskクラスを返す事で、別の仕様要件でProceedTextAsyncを呼び出さなければならない場合にも、非同期処理の待機が可能になる余地を残す事が出来ます。

残る仕様的な問題ですが、ウインドウ全体を無効化する必要が無いのであれば、特定のコントロールに絞って無効化を実施するという方法が考えられます。例えば、検索結果に応じてリストボックスの内容を変化させる場合、テキストボックスは有効としたまま、リストボックスだけを無効化するようにすれば良いのです。その場合、ロジックの変更は必要ありません。XAMLのバインディング式をListBoxに適用します。

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="200">
	<DockPanel>
		<TextBox DockPanel.Dock="Top" Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />
		
		<!-- IsEnabledをListBoxにバインディング -->
		<ListBox ItemsSource="{Binding ResultItems}" IsEnabled="{Binding IsEnabled}">
			<ListBox.ItemTemplate>
				<DataTemplate>
					<!-- 結果を表示するテンプレート -->
				</DataTemplate>
			</ListBox.ItemTemplate>
		</ListBox>
	</DockPanel>
</Window>

非同期処理のエラーの対処

ここまでで、データバインディングで値が転送された場合の非同期処理は対処可能になりました。では、非同期処理中に発生するエラーはどのように対処すれば良いでしょうか?

そもそも、仕様的な面で非同期処理中のエラーにどう対処するかを考える必要があります。最後の例では、ユーザーがテキストボックスに何かタイプすると、非同期で検索処理を行い、結果がリストボックスに(非同期的に)表示されます。一文字タイプし、その事が原因で何らかのエラーが発生したとしても、ユーザーはもう次の文字をタイプしているかも知れませんし、ウインドウ上の別の機能を実行しているかも知れませんし、ウインドウを閉じているかも知れません。

  • エラーが発生したら、その場でメッセージボックスを表示する。
    単純なユーザーインターフェイスであれば、これでも良いと思われます。但し、複数の非同期処理を実行している可能性があり、それらが一斉に失敗すると、メッセージボックスが大量にスクリーンに表示される可能性があります。また、忘れた頃にいきなり表示される可能性もあります。
  • ログ出力のような表示領域に表示させる。
    過去に発生した内容を表示できるので、より望ましいと言えます。但し、どのメッセージが何の操作に紐づいていたのかは分かりにくくなるかも知れません。

どのような方法でも、握りつぶすのでなければ、対処方法は同じでしょう。非同期処理の実質的なコードが例外をスローする場合は、単にtry-catchで例外を補足し、エラー処理を行います。

private async Task ProceedTextAsync(string value)
{
	// ユーザーインターフェイスを無効化する
	this.IsEnabled = false;
	try
	{
		try
		{
			// 非同期処理...
			using (var stream = await httpClient.GetStreamAsync("...?q=" + value))
			{ ... }
		}
		catch (Exception ex)
		{
			// メッセージボックスを表示する
			MessageBox.Show(ex.Message, "Error occurred!", MessageBoxButton.OK, MessageBoxImage.Error);
		}
	}
	finally
	{
		// ユーザーインターフェイスを有効化する
		this.IsEnabled = true;
	}
}

注意点としては、C# 5.0ではcatch-finallyブロック内でawait出来ない事です。WPFの場合はMessageBoxの内部でメッセージポンプが実行される(Win32メッセージループが実行される)ので良いのですが、ストアアプリではawaitで待機する必要があります。そのため、多少泥臭いコードを書く必要があります(エラーを保存して一旦catchブロックから抜けてから表示するなど)。

エラー処理のコード例をお見せしましたが、結局のところ、一般的なコードと何も変わらないことが分かると思います。


ビジネスロジックの分離

仕様がより高度になるにつれ、データバインディングを実装しているクラスを分割したくなります。特にユーザーインターフェイスに対応するための固有のコードと、いわゆるビジネスロジックの分離です。リファクタリングの作法に従って、責務によってクラスを分割して行くと、MVVMで言う所のビューモデルとモデルに分割されます(うまくやればね :)。この時、前述の例で示したHttpClientによる検索処理などのビジネスロジックは、ビューモデル(元のクラス)からモデルに移動する事になります。

実装を移動する際、HttpClient.GetStreamAsyncは非同期メソッドなので、await可能でなければなりません。と言う事は、移動先のメソッドにはasyncが適用されなければならず、ベストプラクティスに従うならTaskクラスを返却するメソッドでなければなりません。結局のところ、「ProceedTextAsync」メソッドはほぼそのままの形でモデルクラスに移動する事になります。

ProceedTextAsyncがビューモデルに依存している箇所は、それぞれを以下のように対処します。

  • 結果をどう反映するか : モデルはthis.ResultItemsにはアクセス出来ない。したがって、ProceedTextAsyncは結果をまとめて(コレクションに入れるなどして)返却する。
  • IsEnabledへのアクセス : モデルは直接UIとの関係を持たないと考えると、IsEnabledの操作はビューモデルに残しておく。
  • メッセージボックスへのアクセス : モデルは直接UIとの関係を持たないと考えると、メッセージボックスの操作(例外の処理)はビューモデルに残しておく。

以下の例では、モデルクラス自身をコレクション(List)とし、UpdateAsyncメソッドでここに結果を保持するようにします。モデルの存在意義を考えてこのようにしましたが、実際の所はメソッドを単なる移譲として分離(戻り値にコレクションを返すスタティックなメソッド)した方が良かったかも知れません。

// ビジネスロジックを含むモデル(コレクション)
public sealed class BusinessLogicModel : List<ResultItem>
{
	// コレクションを更新する
	public async Task UpdateAsync(string value)
	{
		// 非同期処理...
		using (var stream = await httpClient.GetStreamAsync("...?q=" + value).
			ConfigureAwait(false))  // 以降の処理をワーカースレッドで実行
		{
			var document = SgmlReader.Parse(stream);

			// ここではUIを直接操作出来ない(ワーカースレッドなので)
			this.Clear();
			this.AddRange(
				from html in document.Elements("html")
				from body in html.Elements("body")
				// ...
				select new ResultItem { ... });
		}
	}
}

このように分離すれば、あとはビューモデルから呼び出すだけです。

private async Task ProceedTextAsync(string value)
{
	// ユーザーインターフェイスを無効化する
	this.IsEnabled = false;
	try
	{
		try
		{
			// モデルを用意して結果をフィルする
			var model = new BusinessLogicModel();
			await model.UpdateAsync(value);

			// バインディングする
			// (awaitでメインスレッドにマーシャリングされているので、UIを操作しても問題ない)
			this.ResultItems = model;
		}
		catch (Exception ex)
		{
			MessageBox.Show(ex.Message, "Error occurred!",
				MessageBoxButton.OK, MessageBoxImage.Error);
		}
	}
	finally
	{
		// ユーザーインターフェイスを有効化する
		this.IsEnabled = true;
	}
}

更なる発展

コレクションは段階的に反映したいですね。結果が認識されるたびに、1件ずつリストボックスに反映されたりすると、ユーザーインターフェイス的にとても良いと思います。

このような実装にするには、結果が1件取得される度に、バインディングしているコレクションに反映して、それがUIにも反映される必要があります。コレクションへの反映はINotifyCollectionChangedインターフェイスで通知することが出来ます。このインターフェイスはObservableCollectionクラスが実装しているので、Listの代わりにこのクラスを使うと良いでしょう。

注意点として、コレクションへの追加処理は、UIスレッド(WPFでは恐らくメインスレッド・ストアアプリではビュー毎に割り当てられているスレッド)で実行する必要があるという事です。ここにトレードオフがあります。

CPU依存性処理をワーカースレッドで効率よく処理させるために、「ConfigureAwait」メソッドを使用したくなるかもしれません。これにより、awaitの度にUIスレッドにマーシャリングされなくなるため、パフォーマンスが向上します。しかし、当然の事ながらUIスレッドにマーシャリング出来ないので、(メソッドを抜けるまでは)UIに絡む処理は実行出来ません。

ObservableCollectionを使用した場合、要素の変更時に「CollectionChanged」イベントを発火します。このイベントの発火はUIスレッドで実行しなければならないため、ObservableCollectionの操作は暗黙にUIスレッドで実行する必要があります。

前述のコード例でConfigureAwaitを使用しているのは、結果を一旦Listに保存しているので、直接的にも間接的にもUIに依存していない事が分かっているためです。このように、呼び出し元のスレッドがどこまで有効に作用するのかを考えて、ロジックを分離すると良いでしょう。

この辺りを掘り下げた資料は、以前の勉強会で発表した資料があるので、参考にどうぞ。


今年もあと少し、今日は寒くて堪りませんでした。明日はmoririringさんです。よろしく!

プロ生ちゃんをひろっちゃう! – プログラミング生放送勉強会 第30回@名古屋ソフトウェアセンター

プログラミング生放送勉強会で「プロ生ちゃんをひろっちゃう!」というタイトルでセッションに登壇してきました。

WP_20141108_12_21_12_Pro

初めての参加で、まったりと聞こうと思っていたのに、何故か登壇席にいた… そういうセッションです (;´Д`) 一体どういうことなのか、良く分からない…

仕方が無いので、プロ生ちゃんベースに、いつもとは違う方向性でセッションしました。途中、スライドが見えづらいというリアルタイムなツッコミに動揺し、しかもデモしようとしたら、Azureのネットワーク障害で見せられないという、デモ失敗あるあるでガタガタな結果に orz

すいませんすいませんすいません(泣

精進出来るようにがんばります。

内容はざっくり、プロ生ちゃんサイトの壁紙ページをスクレイピングして、非同期で画像をダウンロードしながらWPFでデータバインディングして表示するという内容です。

スクレイピングするために、どうやってサイトのHTMLを調査するか、そして実際にスクレイピングする時に使えるライブラリや、HTMLの解析方法、ユーザーインターフェイスをスムーズにする為の非同期処理の概要をちりばめました。

以下にスライドを置いておきます。また、例によってソースコードはGitHubに上げてあるので、見る事が出来なかった方は参考にして下さい。

スライド: プロ生ちゃんをひろっちゃう!.pptx
ソースコード: GitHub kekyo/Pronama.ScrapingViewer

SignalR ブートキャンプ on Windows Azureイベント

地理冗長の中心でAzure愛を叫ぶ (名古屋で、Windows Azure ローンチ4周年とJapan Geo誕生を祝うイベント)

という、中部圏のWindows Azureイベントが開催され、登壇してきました。
私のお題は、「SignalR ブートキャンプ」で、SignalRを使った通信の取り掛かりの解説といった内容です。OWINについてもさらっと取り上げています。

本当は、開催と同時にプレゼンとコードを公開したかったのですが、ちょっと未整理が過ぎたので、後日公開のお約束をさせていただきました。で、本日公開いたします。

この発表のハイライトは、ホワイトボードアプリのデモでした。発表中に、実際にAzure上にホストされたサーバーとクライアントアプリ(SilverlightとWPFによるClickOnce、そして今話題沸騰中のWindows Phone :-) をSignalRで接続し、リアルタイムにホワイトボード共有を実演しました。

#クラウディアさんの分身にはお世話になりました
#ライブコーディングは今後の課題ということで(汗

プレゼン作成中はもちろん検証しているのですが、実際に多人数から同時に使用されたのは初めてで、ぶっつけ本番でしたが、何事もなくほっとしています。と同時にあっさり動いてしまう所が、Windows Azure、本当に魅力的です。

プレゼンです:SignalR ブートキャンプ

コードはGitHubで公開しました:AzureSignalRDemonstration

イベント終了後の懇親会も盛り上がりました!
今回のAzureデータセンター日本リージョン開所記念で、Japan Windows Azure User Groupの中部圏「JAZUG名古屋」もお披露目されました。

また、MiCoCiは中部圏のWindows系技術勉強会の開催などやってます。興味のある方はDSTokaiカレンダーあたりをチェックしてみて下さい。

近日では、Bar Windows Azureの開催を計画しています。

それではまた!

Bar Windows 8.1 – BluetoothでGO!

Bar Windows 8 in 名古屋 with 8.1に登壇して、「BluetoothでGO!」というタイトルでセッションしてきました。

ご清聴ありがとうございました。プレゼンを置いておきます。
最後のデモで見せたコードはあまり整理されていません。反響があれば公開したいと思います。→ GitHubで公開しています。

BluetoothでGo!

次回も頑張ります (^^)

猫でもわかるExpression Design

Windows Phoneアプリのアイコンを作るのに、VS2012ImageLibraryをいじって手を抜こうとしていたのだが、余計に面倒なことになってきたので、久しぶりにExpression Designを触った。某人からも「手ごろなお絵かきツールない?」と言われていたので、ここは一つチュートリアルを作らねばならないなと。


名称未設定1で、今日のお題はコレ→

分かってしまえば、「3分」で作れます。特にBlendを良く触る人は、「2分」で行けます。

用意するもの:
Microsoft Expression Design 4

3でも2でもあまり違いはありません。1は不明。Designはプロダクトから外れてしまったのですが、幸いなことに正式版が無償で公開されています!!

Microsoft Expression Design 4 (English)

このページは英語版ですが、下のほうに日本語版へのリンクがあるので、そこからダウンロードして下さい。すばらしい!


WS000000では、Designを起動します。

Designのファイルメニューの新規作成で、ドキュメントを作成します。

WS000001この新規作成ダイアログは、ドキュメントのサイズを指定できるようになっています。上の例では1024px×1024pxとしましたが、Designは「ベクター」ベースです。つまり、このサイズはあくまで指標であって、出力時にはスケーラブルに変更する事が出来ます。つまり、Designはペイント系ではなく、Illustratorのようなドロー系です。

ちなみに、Blend同様、数値入力ボックスの上でマウスをドラッグすると、手打ちしなくても値を上下出来ます。クリックすれば手打ちできます。これ良く考えてあるよね。但し、マウスボタンの調子が悪いと発狂するので、新調しましょう。


WS000002とりあえず、何も描かれていない状態です。

分かりにくいのですが、左下に「44%」と表示されています。これはキャンバスの拡大率です。もし、四角形がウインドウに収まっていないのであれば、ここを変更して丁度収まるようにして下さい。
(数値入力ボックスはドラッグできるんですよー?覚えてますか?)

WS000003まず、画角一杯の四角形を描きます。左のツールバーから、四角いアイコンをクリックし、出てきたサブメニューから「四角形」を選択します。

WS000004その後、キャンバスの左上隅から、右下隅までドラッグします。

キャンバスと同じ大きさなので分かりにくいですが、赤線の四角形が出来ました(赤線はこの図形が選択されていることを示します)。

WS000005やってみればわかりますが、キャンバスの四隅にはマウスポインタが「スナッピング」されるので、四隅の選択は非常に簡単です。ぎりぎりを狙って四苦八苦しなくても済みます。

WS000006ここで、右側のプロパティの「外観」に表示されている、色パレット(なんか綺麗にグラデーションしている面)で適当な色をクリックして選んでみて下さい。現在選択されているオブジェクト(つまり、今描いた四角形)の色が変わります。

WS000007もう色々触ってみたくなったカモ?w まぁ、ちょっと我慢して続きを。今回作るアイコンの背景はグラデーションさせたいので、実際にやってみます。ちょっと小さいのですが、基本パレット内のグラデーションパレット(左右に白黒グラデーションしているパレット)を選択します。

WS000008すると、色パレットの下にグラデーションバーが表示されます。ここも見た目と裏腹に非常に高機能なのですが、今回は簡単に2色のグラデーションをちゃっちゃと設定します。グラデーションバーの左側の下に、小さい四角のセレクタがあります。例では黒色ですね。これをクリックします。

WS000009すると、色パレットは、グラデーションバーのクリックした個所の色を指定出来るようになります。そこで、青っぽい色を選択します。この例のように、青が選択できない、青じゃない色しか出ていない場合は、すぐ右側にある虹色の縦のバーから青色付近をクリックすれば、選択できるようになります。

WS000010グラデーションバーの色の変化と同じように、四角形のグラデーションも変化したと思います。今度は右側の四角のセレクタ(白色)をクリックして、同様にちょっと濃さの違う青色を選択します。

WS000012背景完成!と行きたいところですが、グラデーションの方向が違いますね。色パレット右下の、トランスフォームアイコン(形が良く分からないので、スクリーンショットを参考に)をクリックし、回転角度をいじってください。

ここでは90°にしてみました。
(数値入力ボックスはドラッグできるんですよー!?覚えてますか!?)


WS000013これで、当初のもくろみ通りの背景が完成。次はルーペを作ります。ルーペの肝は何といってもレンズの部分でしょう。中抜きの円をどうやって描くかです。心配無用、超簡単。

ツールバーから、楕円を選択します。

そして、円を描くのですが、まずは中抜きの円の外形の大きさにします。四角形と同じようにドラッグで描けますが、そのままでは文字通り「楕円」になってしまいます。ここでは真円にしたいので、シフトキーを押しながらドラッグして下さい。XY軸が1:1の真円として描画されます。

もし、円までグラデーションになってしまったら、円を描いた時点で(つまり円が選択されている状態で)、右側の色パレットの基本パレットから白色を選択します。

WS000014そして、もう一つ円を描いてしまいましょう。コピペ(Ctrl-C、Ctrl-V)でも良いですし、新たに円を描いても良いです。

円の四隅に小さい四角があります。名称が良く分かりませんが、一般的には「アンカー」でしょう。このアンカーをドラッグして、中抜き円の内径に近づけます(シフトキーと併用ですよ!1:1になります)。

図形などのオブジェクトを操作する(移動や変形など)場合は、左側ツールバーの一番上の矢印アイコンを選択しておくと、やりやすくなります。

WS000015さらに円自体をドラッグして、最初の円と同心円付近に持ってきます。

WS000030ありがちなのは、外形が大きい円の「下」に小さい円がもぐりこんでしまって操作出来ないと言う場合です。WordとかExcelで図形を描画した事があるなら分かると思いますが、同じように修正できます。

円を右クリックして「整列」「背面移動」で、図形の順序を変更して下さい。

WS000016さて、準備が出来たら、大小2つの円を同時に選択します。シフトキーを押しながら、二つの図形をクリックします。両方とも赤線で囲われるはずです。

WS000017そして、「オブジェクト」メニューの「パス演算」、「背面マイナス前面」を選択します。

WS000031何が起こったか分かりますか?WPFで言うなら、GeometryCombineMode.Excludeですよー? 内径の大きさとして準備した図形で「引き算」を行ったわけです。これでルーペは出来たも同然。

ちなみに、このパス演算を使用すれば、三日月とか、どこかにありそうなマークとか、簡単に作れますね!
今回の場合、背景がグラデーションではなく単色であれば、内側の円の色を背景と同じにするだけでも行けますが、パス演算は強力なので、紹介しました。


WS000018後はルーペの柄の部分です。もう大体の操作方法は分かったと思いますが、一応やっておきます。まず、四角形ツールで長方形を描きます。

WS000033次にこの長方形を斜めに傾けます。長方形が選択されている状態で、下段中央付近に「回転角度」という数値入力ボックスがあります。これが正に選択しているオブジェクトの角度を表します。

例では(ドラッグして設定したので)45.1°になっていますが、手打ちすれば45°になりますよ。もうそろそろOK?

WS000021で、長方形の幅とか端処理をいじって、それらしくします。四角形の四隅を丸くするには、右のプロパティから「四角形の編集」「角の半径」という数値入力ボックスをいじります。ドラッグでぐわっと変えて、見ながら調節すれば良いでしょう。

WS000022いくぞー合体ー(ry

WS000023おぉ、完成だ! ….って、なんか変?

良く見ると、柄の長方形に縁取りが。

これは、オブジェクトの「ストローク」というやつです。図形を描くと、「ストローク(縁取り)」と「内面の塗りつぶし」の2つの属性が必要になります。今まで描いてきた四角形と円は、どちらも内面の塗りつぶし色だけを指定したため、縁取りがデフォルトのままになっているのです。

対処としては2通り考えられます。縁取りを塗りつぶしと同色にするか、縁取りを消すか、です。単純色なら同色でも問題ありませんが、今回のようにグラデーションを適用したり、テクスチャハッチングとかすると、模様が合わなくなって目立ってしまう可能性もあります。まぁ、そんな事に悩む頃には、どうすれば良いかも分かるでしょう。今は深入りせず、縁取りを消してしまいましょう。

WS000034図形を選択して、色パレットのタブを「ストローク」に変えます。

WS000035そして、基本色パレットから「なし」をクリックして、ストロークを無しにします。

これでOK。色パレット下のコンボボックスに「ストロークなし」と表示されていますね? ここをいじるとストロークの色だけじゃなく、模様とかテクスチャとか。まぁ、後で遊んでみて下さい。

ちなみに、塗りつぶしの色を「なし」にも出来ますよ。ストロークにのみ色を指定すれば、塗りつぶさない図形とか、簡単ですね?


WS000027さあ、完成です (^o^)/~~

WPFやっている人なら、ここまでの時点で「こりゃまんまWPFじゃないか」と思うかもしれません。たぶんそうです。というか、このDesignをWPFで実装しない理由はないでしょう。

WS000028さて、最後にこれをPNGで出力します。ファイルメニューの「エクスポート」で、フォーマットをPNG、画像サイズを256×256にして、パスやファイル名を指定してエクスポート。最初に書いたように、Designはベクターベース(WPFなら当たり前)なので、指定したサイズで綺麗に拡大・縮小されますよ。

WS000029ちなみに、PNGにすればベクターデータはすべて失われてしまいます。ちゃんとDesignのフォーマットでも保存しておきましょう(拡張子はdesign)。エクスポートではなく、普通に名前を付けて保存でOKです。

そして、PNGなら当然、透明色も反映されますよ。半透明や複雑な中抜き形状の図形を、アルファチャネルありで出力できます。図形や文字のアンチエイリアスも反映されます。


DesignとPaint.NETがあれば、とりあえずお絵かきに困ることはないと思いますよ。