非同期処理、なにもわからない

最近のお仕事で、C#の非同期処理の書き方が分からずに、溶岩地帯に自爆していくコードを沢山みるようになったので、ケースとして日常風の記事にしてみました。

どんな風にハマってしまい、どうやって解決するのかが分かると思います。

結論

先に書いておきます。

  1. Task.Run()を使ってはいけません
  2. Task.Result, Task.Wait()を使ってはいけません
  3. Threadクラスを使ってはいけません
  4. async-await構文だけを使って書きます
  5. async voidにするのは特殊な場合だけです

がんばれー、わかってしまえば難しくない!

補足: もしあなたがJavaScriptで非同期処理を書いた事があるなら、その知識をそのまま生かせます。
JavaScriptではTaskの代わりにPromiseを使いますが、Promiseには上記1,2,3は存在しません。しかし、それで困ることはありませんよね? (これはとても重要な示唆です)

平和の終焉

以下のような実装を考えます:

// Web APIからデータを取得する
public string FetchData()
{
    // TODO: ここにWeb APIからのデータ取得コードを書く
}

ここからコードを書こうとしている人は既に罠にはまっているわけですが、気が付けないので、そのままここに肉付けをしていくわけです。

Web APIにアクセスするには、今はHttpClientを使いますが、彼らは使い慣れているWebClientを使います:

// Web APIからデータを取得する
public string FetchData()
{
    WebClient wc = new WebClient();
    string body = wc.DownloadString("https://example.com/api/data");
    return body;
}

出来ました。ただ、WebClientのドキュメントを見ると、今から使用するのは推奨しない、代わりにHttpClientを使うように、という事が書かれています。また、レビュアーに指摘されたり、ググったりして見ても、やはりもうWebClientは使うな、と言うような情報が得られます。そこでこれを書き換えようとするわけです。

(なお、今回の題材はWeb APIですが、例えばRedisだとかAzureのサービスとかで提供されるSDKとか、もう大半が非同期処理対応のAPIしか用意されていなかったりするので、Web API固有の話ではないという事に注意して下さい)

Day 1

Sync氏は、WebClientをHttpClientに書き換えていきます。しかし、GetStringがありません。代わりに、GetStringAsyncという似たようなのがあるので、これを使おうとします:

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // エラー: 型が合わない
    string body = hc.GetStringAsync("https://example.com/api/data");

    return body;
}

GetStringAsyncというのを使ったら、型が合わないというエラーが出ます(幸いなことに? Sync氏はvarを使わないので、ここでエラーを検出)。よくよく見ると、Task<string>という型です。

ここで彼らの運命は分岐します。どちらも溶岩地帯です。

Day 2 – ピュアなResult

Taskクラスのドキュメントを読むと、サンプルコードがあり、どうやらResultプロパティで結果を取得できるらしい。

(すいません、この記事書いててちょっと頭クラクラしてます… 上のドキュメントは初心者をサイレントに殺しに掛かっているので、見ない方が良いです)

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskは良く分からないけど、Resultプロパティを見れば結果が得られそうだ
    // 型もばっちり一致して、コンパイルが通ったぞ!
    string body = hc.GetStringAsync("https://example.com/api/data").Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない

Day 3 – 分身するResult

GUIアプリケーションを苦労してデバッグしていくと、どうもResultが返ってこないことがあることが分かった。ASP.NETの方は、皆目見当がつかない。そこそこ良いVMを割り当てているのに、.NETって性能悪いの?それともWindows VMのせいなの? Linuxにすれば良いの?

そうは言っても納期が迫っているので、何とかしなければならない…

そうだ、ワーカースレッドでResult取得するところを分離すれば固まらなく出来るのでは? 性能上げられるのでは?? さっきのドキュメント読んだら、Task.Runを使えば簡単にワーカースレッド使えるみたいだし、これで行こう!

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskって便利だね!ワーカースレッド簡単に使えるんだ
    string body = Task.Run(() =>
        hc.GetStringAsync("https://example.com/api/data").Result).Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2より悪化

Day 4 – async-awaitの存在を見出す

もうわかんない、HttpClientとかTaskとか何もわからない… Taskでググるとasync-awaitとか言うやつが出てくるから、これを使えばいいのかな…

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // async-awaitの書き方本当にわからない...
    // 1日試行錯誤してやっとエラー出なくなった...
    string body = Task.Run(async () =>
        await hc.GetStringAsync("https://example.com/api/data")).Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2ぐらい。多分この人はその違いを測定してない…

Day 5 – スレッド手動操作

わかったわかった、Taskなんて使うからダメなんだ。初めからThreadクラスを使えば良かったんだよ。

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskなんて意味わかんないし、これでいいや。
    // でも何でこんなの使うの? WebClientでよくね?
    string body;
    Thread thread = new Thread(() =>
        body = hc.GetStringAsync("https://example.com/api/data").Result);
    thread.Start();
    thread.Join();

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 一応動作はする。但し、Web APIアクセス中は固まる
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2と同じ

わけがわからないよ。どうしてみんなはそんなにHttpClientにこだわるんだい?

Day 6 – 真実

Sync氏「HttpClientだと固まったり性能が上がりません。HttpClientは動作が怪しいので、WebClientを使う方が安全です」

レビュアー「駄目です、HttpClientを使ってください。こう書きます」

// Web APIからデータを取得する
public async Task<string> FetchDataAsync()
{
    var hc = new HttpClient();
    return await hc.GetStringAsync("https://example.com/api/data");
}

レビュアー「非同期処理を行うメソッドは、必ずTaskクラスを返すようにします。また、実際にはHttpClientはstaticで保持る必要があります。とりあえずこんな感じで他のも全部直してください」

Sync氏「え、でもこれだと、メソッド名も戻り値も変わっちゃってるので、呼び出し元にも修正が入ってしまいます」

レビュアー「呼び出し元も直してください。他にうまくやる方法はありません。実際には、メソッド名は直さなくても良いですが、直しておいた方が後々困らないです」

Sync氏「(絶望…)」

(補足: 文字通りです。大げさな話ではなく、他に方法はありません。試行錯誤は時間の無駄になります。警告しましたよ?)

Day 7 – GUIの真実

Sync氏は、必死にGUIのコードを直していた。

// コンストラクタ
public FoolishForm()
{
    // エラー: 型が合わない
    button.Click += this.Button_ClickAsync;
}

// ボタンがクリックされたら、データを取得して反映
public async Task Button_ClickAsync(object sender, EventArgs e)
{
    // データを得る
    var data = await this.FetchDataAsync();
    // テキストボックスに反映
    this.textBox.Text = data;
}

良い所まで来ていたが、Button_ClickAsyncメソッドをイベントハンドラとして登録するところで、型のエラーが発生して悩んでいた。

数時間後…

Sync氏「イベントハンドラの部分はどう書けばいいんですか?型が合わないと言われてしまいます」

レビュアー「こう書きます」

// コンストラクタ
public FoolishForm()
{
    // 問題ない
    button.Click += this.Button_Click;
}

// voidでいい / Task返さないので~Asyncも不要
public async void Button_Click(object sender, EventArgs e)
{
    // データを得る
    var data = await this.FetchDataAsync();
    // テキストボックスに反映
    this.textBox.Text = data;
}

Sync氏「え、でも非同期処理ではTaskを返さなければならないのでは?」

レビュアー「この場合だけ、つまりイベントハンドラだけ、async voidを使えます。この場合だけと考えてほぼOKです。他では使わないでください」

数分後…

Sync氏「試してみたら固まらなくなりました!ただ、動いているときにボタンを連打すると、何度もサーバーにアクセスしてしまうのですが」

レビュアー「古典的なJavaScriptの問題と同じで、アクセス中にボタンをクリックできないように仕様変更して下さい。実装はこんな感じにすれば良いと思います」

// ボタンがクリックされたら、データを取得して反映
public async void Button_Click(object sender, EventArgs e)
{
    // まずボタンをクリックできなくする
    this.button.IsEnabled = false;

    try
    {
        // データを得る
        var data = await this.FetchDataAsync();
        // テキストボックスに反映
        this.textBox.Text = data;
    }
    finally
    {
        // ボタンをクリック可能にする
        this.button.IsEnabled = true;
    }
}

Sync氏「ああなるほど! そうか、ロード中くるくる回るやつを追加で表示したほうが良いかもしれないですね」

レビュアー「そうですね。あと、async voidとする場合は、必ず例外をcatchして下さい」

Sync氏「何故ですか? 今はApplicationクラスの共通例外ハンドラで処理しているので問題ないと思いますが…」

レビュアー「その例外は、共通例外ハンドラでは処理できません。見たことの無い例外がデバッガのログに出てませんでしたか?」

Sync氏「ああ… UnobservedTaskExceptionというのが出てました。それも変なタイミングで表示されていた気がします」

レビュアー「私はこれから出張があるので詳しくは省きますが、メソッド内でcatchして処理するのがベストです」

Day 8 – サーバーサイドの真実

さらに数日後…

Sync氏「サーバー側の実装もわかりました、前は呼び出し元はこうしていたのですが」

[HttpPost]
public string GetData()
{
    // データを得る
    var data = this.FetchData();
    // テキストボックスに反映
    this.textBox.Text = data;
}

Sync氏「こうすれば良いという事ですね?」

// APIエンドポイント名にAsyncとついていても、自動的に無視される
[HttpPost]
public async Task<string> GetDataAsync()
{
    // データを得る
    var data = await this.FetchDataAsync();
    // 返却する
    return data;
}

レビュアー「はい、それで良いと思います」

Sync氏「GUI側と違って、サーバー側の実装は特に変わったことは無いんですね… もう一つだけ、わからない事があるんですが」

レビュアー「なんですか?」

Sync氏「今回の非同期処理対応で、結局Task.Runメソッドを使わなかったのですが、あれはいつ使うものなのですか? C#の非同期処理についてググってみると、大体Task.Runを使う例が載っているので使おうとしてああいうコード書いてしまったんですが…」

レビュアー「大量の計算だけを行う処理を並行して同時に処理させたい時だけに使います。我々が普段書いているコードで、そういう処理は殆ど無いですよね? なので、知らなくても良いレベルで使わない筈です」

(注意: 初稿ではHttpPost属性でAsyncを付けていない名前を明示していましたが、不要でした。Ovisさん、ありがとう)

輝かしい未来への第一歩

Sync氏に横たわっていた暗い霧は、今晴れわたった。そこにはまだ何も存在しないが、やっとスタート地点に立った。そう感じたのである…


お約束の…

ここで挙げた例は、どうしてTask.RunやTask.Resultを使いたがってしまうのかという所に焦点を当てているため、実際にはもっと効率の良い実装テクニックが色々ありますが、最低限このように書けば、Taskをいじり倒して溶岩地帯に突っ込んで死亡する、ということは無くなると思います。なお、ASP.NET (Core), WinForms, WPF, Xamarinについて取り上げましたが、これらだけに適用される手法ではなく、コマンドラインプログラムやUnity、独自の.NET環境など、すべての.NETの非同期処理で適用できる手法です。

もう少しかっちりした解説が必要なら、こちらを参照してください

では、いつものやつ、行ってみましょう:

Task.Run()も、Task.Factory.StartNew()も、Task.Start()も、Task.ContinueWith()も、Task.Wait()も、Task.Resultも出ませんでしたね? これらを使う事はまずありません(断言)。

今、記事冒頭の「結論」の項を読み返せば、納得できると思います。「C# 非同期処理」とか「C# async await」などでググってみると、Task.Run()やTask.Resultを最初に説明したり引き合いに出す記事の何と多い事か… これらは本当に、知らなくて良いんですよ

もし、自信が無くなってきたら、JavaScriptにもPromiseとasync-await構文があるのに、これらに該当するものは無くても実用出来ているということを思い出すと良いと思います。

(更に補足すると、.NET/C#の場合は、async-awaitよりも先にTaskが生まれてしまった事が、不幸の始まりだったのです)

この記事で少しでも救われる人が増えますように。

それではまた。

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

ただ、私見ですが、実際に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上で開発していますが)

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

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

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