Comet で .NET MVU を先行体験してみる

7 min read読了の目安(約7100字

はじめに

この記事は Xamarin Advent Calendar 2020 の 15 日目の記事です。

5 月の Build 2020 で発表されたクロスプラットフォーム UI フレームワーク ".NET MAUI" 。基本的には Xamarin.Forms の拡張のようですが、非常に大きな変更として従来の XAML ベース (MVVM) に加えて宣言的 UI (MVU) が追加されることになっています。

MAUI は開発中で Preview リリースもされていない (GitHub でコードも公開されていない) のですが、 Comet という MVU の PoC 実装があります。

Comet は PoC 実装と言いながらも開発している人が Microsoft の人で MAUI MVU について登壇もしている方なので、事実上これがほぼそのまま MAUI として取り込まれる可能性は高いと思います。

(2021/2/16 追記)
初稿時は Clancey さんのユーザー下にプロジェクトがあったのですがいつの間にか dotnet 下に移動していました。 Microsoft の blog でも言及されていますし公式化した認識でいいのではないかと。

というか MAUI ブランチがあって名前空間が System.Maui ・・・

そんな感じ (?) で、 .NET MVU を一足お先に体験してみましょう。

この記事では 12/3 時点でのコード で試しています。

Android でのサンプル実行例:

サンプル一覧 ListViewSample1

導入

Comet は NuGet でビルド済バイナリを頒布しているのですが、大分以前のまま更新がされていないのと、 NuGet で頒布されているのは Core 部分のみで各プラットフォームに対応したコードはありません。

ので GitHub からコードを落としてきてプロジェクト参照で取り込んでしまうのが手っ取り早いです。 Xamarin.Forms のように規模が大きくないのでこれで問題なく作業できます。

Comet は現時点で主要なプラットフォームをほとんどサポートしている (!?) のですが、今回は WPF, Android, iOS で試していきます。

WPF

  1. "WPF アプリ (.NET Framework)" で新規プロジェクトを作成します (Comet 側が .NET Framework になっているので)
  2. ソリューションに下記プロジェクトを追加し、アプリプロジェクトからアセンブリ参照の設定をします
    • src/Comet/Comet.csproj
    • src\Comet.WPF\Comet.WPF.csproj"

Android

  1. "Android アプリ (Xamarin)" - "単一ビューアプリ" で新規プロジェクトを作成します
  2. ソリューションに下記プロジェクトを追加し、アプリプロジェクトからアセンブリ参照の設定をします
    • src/Comet/Comet.csproj
    • src\Comet.Android\Comet.Android.csproj"

iOS

Visual Studio for Mac で

  1. "iOS - アプリ - 単一ビューアプリ" でプロジェクトを作成します
  2. ソリューションに下記プロジェクトを追加し、アプリプロジェクトからアセンブリ参照の設定をします
    • src/Comet/Comet.csproj
    • src/Comet.iOS/Comet.iOS.csproj"
  3. Info.plist から "UIApplicationSceneManifest" のエントリーを丸ごと削除します。

コードを書く

まずプラットフォーム非依存 View クラスとして、 Comet.View クラスを継承したは空クラスを用意します。

using Comet;

public class MainPage : View
{
}

次にプラットフォーム毎に合わせたスタートアップコードの設定をします。

WPF

まず Frame を用意しておきます。 (namespace 等は省略しています)

<Window>
  <Frame x:Name="MainFrame" />
</Window>

コードビハインド側に CometPage (WPF で Comet のレンダリングをする Page) の生成、遷移をするコードを記述します。 CometPage には先に準備した View (MainPage) のインスタンスを設定します。

using Comet.WPF;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        MainFrame.NavigationService.Navigate(new CometPage(MainFrame, new MainPage()));
    }
}

Android

Activity の親クラスを Comet.Android.CometActivity に変更し、 Page プロパティに View (MainPage) のインスタンスを設定します。

using Comet.Android;

public class MainActivity : CometActivity
{
    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);
        //  略
        Page = new MainPage();
    }

    //  略
}

iOS

AppDelegate の親クラスを Comet.iOS.CometAppDelegate に変更し、実装を下記のようにします。

using Comet.iOS;

public class AppDelegate : CometAppDelegate
{
    protected override CometApp CreateApp() => new CometAppTest();
}

class CometAppTest : CometApp
{
    public CometAppTest() => Body = () => new MainPage();
}

CometApp クラスの継承クラスを用意し、それを AppDelegate の CreateApp メソッドで生成するように実装します。
CometApp クラス側では最初に表示する View (ここでは MainPage) を生成する関数を Body プロパティに設定します。

UI を記述する

View を生成する関数 (Func<View>) を実装し、

  • a) Body プロパティにその関数を設定
  • b) メソッドとして実装し、そのメソッドに BodyAttribute を付与する

のどちらかで。典型的には次のような感じ (b パターン) になります。

public class MainPage : View
{
    [Body]
    View body()
        => new VStack
        {
            new Text(() => "Hello!")
                .Margin(20)
                .Color(Color.White)
                .Background(Color.Blue)
        };
}

各プラットフォームの実装の進捗に差異があるようで、少なくとも WPF では Color, Background の効果がありませんでした。また、 Android でも結構動作が怪しくて記述によっては全くコンポーネントが表示されなかったりしました。

ので一番進んでいるであろう iOS で試すのをお勧めします (Clancey さんのデモも Mac + iOS でやってるので) 。

State

Comet では View 自身で更新可能な要素は管理せず、 State に分離して管理します。 View は State の情報を参照しつつレンダリングし、 State の情報が変更されると View に PropertyChanged (= Update) 通知がされて画面が更新される、というフローになります。
ViewModel は View と疎結合で外部から注入するものでしたが、 State はあくまで View の一部であるのが大きな違いかなと思います。

State は 2 種類あって、 State クラスと BindingObject クラスになります。単一要素のみ場合は State を、複数要素をとりまとめて扱う場合は BindingObject を使用します。

State クラスは SwiftUI の State 、 BindingObject クラスは ObservedObject に相当する感じでしょうか。
State クラスは "T Value プロパティを持つ BindingObject" と基本的には同じになっています。

この State も現時点では挙動が微妙です。 WPF / UWP では入力したあとウィンドウのリサイズをして画面更新を強制的に働かせないと変更が画面に反映されなかったり、 Android ではそもそも変更が反映されなかったり (されることもある) していまいち動作が安定しません。

iOS は想定通り動作しているので (以下略)

実装例としては次のような感じになります。

[Body]
View body()
    => new VStack
    {
        new Text(() => $"Text: {state.Value}"),
        new TextField(() => state.Value, "", value => state.Value = value)
            .Background(Color.Aqua)
            .FillHorizontal(true),
    };

//  State の場合
readonly State<string> state = new State<string>();

//  Binding の場合
public class TestState : BindingObject
{
    public string Text
    {
        get => GetProperty<string>();
        set => SetProperty(value);
    }
}

[State]
readonly TestState state = new TestState();

BindingObject には StateAttribute を付与する、 README.md には記載されているのですが有無の差異がよくわかりませんでした。

Hot Reload

Hot Reload は Mac + iOS では確認できましたが Windows では動作確認できませんでした (VS2019 Preview で試したからかもしれませんが) 。

  • WPF はそもそも NuGet のインポート時にエラーになってロールバックされる
  • Android は Hot Reload しようとしているところはデバッガの動きから観察できますが、途中で例外で落ちました

手順的には次のようになります。

  1. GitHub の Releases から Hot Reload の Visual Studio Extensions をダウンロード、インストールします
  2. プロジェクトに NuGet Package を追加します
  3. アプリのなるべく初期段階で "Comet.Reload.Init();" を実行するようにします (Debug ビルド時のみ有効にするように)
#if DEBUG
    Comet.Reload.Init();
#endif
  1. Hot Reload 対象を HotReloadHelper.Register で登録します
public MainPage()
{
    Comet.HotReloadHelper.Register(this);
}

この状態でデバッグ実行をすると Hot Reload が有効になって起動しますが Android では下記例外が発生しました。

Java.Lang.IllegalStateException: 'The specified child already has a parent. You must call removeView() on the child's parent first.'

Comet.Reload.Init の実行は iOS では AppDelegate.FinishedLaunching を override してそのタイミングでやるのがよいようです。

おわりに

Comet のリポジトリで見る限り、思ったよりも動作していない感じです。 Private では状況が違ってもっと開発が進んでいるかとも思う (思いたい) ので、 MAUI Preview を期待したいところです。

今すぐ試すのであれば iOS はそこそこ動いている感じがしますので、 iOS で試すことをお勧めします。 他プラットフォームでも View の記述の仕方と State の動作くらいは体験できるかと思います。

個人的に XML で UI を記述するのはコードが XML に記述できないのがものすごく不便な時が多々あって (ちょっとしたアニメーションや見栄えの制御をコードでしたい) 、 "コードで宣言的に UI を記述する" であればこの辺りがうまく解消できればいいかなーと思っています。