2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WinFormsのMVVMサンプルをAvaloniaに移植したら、Linuxでもテストが「分離なし」で通った話

2
Posted at

はじめに

前回の記事では、WinForms(.NET 10)でCommunityToolkit.Mvvmを使ったMVVMを試しました。その中で、devcontainer(Linux)からWinFormsプロジェクトを参照するとNETSDK1073で詰まるため、ViewModelを別のクラスライブラリ(MyWinFormsApp.Core)に分離する必要がありました。

今回は同じ題材(名前を入力して「挨拶する」を押すと挨拶文が出るだけの画面)をAvaloniaで作り直すと、何がどう変わるのかを確認します。Avaloniaは今回が初めてです。

先に結論を書くと、次のとおりでした。

  • ViewModelの[RelayCommand]などはほぼそのまま移植できた
  • [ObservableProperty]の書き方だけは、テンプレートの生成物がpartial property方式で、前回の記事のフィールド方式とは違っていた
  • テストコードは本文を一切変えずに通った
  • Avaloniaのアプリ本体はnet10.0なので、Linuxコンテナ上でもViewModelを分離せずに、テストからアプリ本体を直接参照してビルド・テストが通った
  • 一方で、WSL2上のDocker Engine(Docker Desktopなし)でdevcontainerを立ち上げるところには、別の詰まりどころがあった

今回の作業は、Claude Codeに環境確認・雛形作成・移植・テスト・コンテナ検証を任せ、その記録(NOTES.md)をもとにこの記事をまとめています。画面の見た目の確認などは、記事内に未検証と明記した箇所があります。

この記事のサンプルコード(前回のWinForms版も含む)は、GitHubで公開しています。

Avaloniaとは

Avalonia(アヴァロニア)は、.NETで作るオープンソースのクロスプラットフォームUIフレームワークです。

  • 対応環境は、Windows 10/11、macOS、デスクトップLinux(X11・Wayland)、組み込みLinux、iOS、Android、WebAssemblyです
  • 自前の描画エンジンでコントロールを描画します(OSのネイティブコントロールを包むのではなく、Skiaなどで描きます)。そのため、OSが違っても見た目が揃います
  • 画面はXAML(またはC#・F#のコード)で定義します。WPFやUWPの経験があれば馴染みやすい書き方です

WinFormsと違って、画面をXAMLで宣言し、バインディングでViewModelと繋ぐスタイルが標準です。今回の「MVVMを試す」という題材とは相性が良いフレームワークです。

詳しくは公式ドキュメントを参照してください。

環境

項目 内容
OS Windows 11(ビルド・起動確認)
.NET SDK 10.0.100(Windows側)/10.0.401(検証用Linuxコンテナ側)
Avalonia 12.1.3
CommunityToolkit.Mvvm 8.4.2
テスト xunit 2.9.3

雛形を作る

テンプレートを入れる

terminal
dotnet new install Avalonia.Templates

Avalonia.Templates 12.1.3が入りました。導入後のテンプレート一覧にはavalonia.app / avalonia.mvvm / avalonia.xplatなどが並びます。

空のフォルダでdotnet new list avaloniaを実行すると、avalonia.windowやavalonia.usercontrolなどに「制約を満たされていないため動作しない可能性があります(プロジェクトの機能: パスにプロジェクトが見つかりませんでした)」という警告が大量に出ます。

これはエラーではなく、「既存のプロジェクトの中でだけ作成できる」という意味です。新規プロジェクトの作成に使うのはavalonia.app / avalonia.mvvm / avalonia.xplatです。

MVVMテンプレートでプロジェクトを作る

terminal
dotnet new avalonia.mvvm -n HelloAvalonia

生成されたのは次のとおりです(.slnは生成されず、単一プロジェクトです)。

HelloAvalonia/
├── App.axaml / App.axaml.cs
├── Program.cs
├── ViewLocator.cs
├── ViewModels/
│   ├── MainViewModel.cs
│   └── ViewModelBase.cs
├── Views/
│   └── MainWindow.axaml(.cs)
├── Assets/
└── app.manifest

Avalonia 11系の記事ではMainWindowViewModelという名前をよく見かけますが、今回のテンプレートでは**MainViewModel**でした。ネット上の情報と名前がずれることがあるので注意です。

csprojの要点です。CommunityToolkit.Mvvmはテンプレートに最初から入っていて、追加作業は不要でした。

HelloAvalonia.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ApplicationManifest>app.manifest</ApplicationManifest>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Avalonia" Version="12.1.3" />
    <PackageReference Include="Avalonia.Desktop" Version="12.1.3" />
    <PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.3" />
    <PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.3" />
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
    <!-- AvaloniaUI.DiagnosticsSupport は Debug のみ有効(省略) -->
  </ItemGroup>
</Project>

注目点は<TargetFramework>net10.0</TargetFramework>です。前回のWinForms版はnet10.0-windowsでした。この違いが、後半のLinuxの話に効いてきます。

ビルドと起動はこれだけで通りました(dotnet buildは警告0・エラー0、dotnet runでウィンドウ表示を確認)。

前回のサンプルをAvaloniaに移植する

画面と挙動は前回と同じです。

  • TextBox(名前)/Label(挨拶文)/Button「挨拶する」
  • ボタンを押すとこんにちは、{Name}さん!と表示する
  • 入力はキー入力ごとにViewModelへ反映する

ViewModel

GreetingViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace HelloAvalonia.ViewModels;

public partial class GreetingViewModel : ViewModelBase
{
    [ObservableProperty]
    public partial string Name { get; set; } = string.Empty;

    [ObservableProperty]
    public partial string Greeting { get; set; } = string.Empty;

    [RelayCommand]
    private void Greet()
    {
        Greeting = $"こんにちは、{Name}さん!";
    }
}

前回のWinForms版と比べると、変わったのは次の点です。

  • 基底クラスがObservableObjectではなく、テンプレートが生成するViewModelBase(中身はObservableObjectを継承しているだけ)
  • [ObservableProperty]が、フィールドではなくpublic partial string Name { get; set; }というpartial propertyに付いている
  • [RelayCommand]の書き方は同じ

最後の点が、コピペで流用するときの最大の違いでした。前回の書き方([ObservableProperty] private string _name)も、CommunityToolkit.Mvvmのフィールド方式として引き続き使えます。[ObservableProperty]には2通りの書き方がある、と覚えておくと混乱しません。

View(XAML)

MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:HelloAvalonia.ViewModels"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
        x:Class="HelloAvalonia.Views.MainWindow"
        x:DataType="vm:GreetingViewModel"
        Icon="/Assets/avalonia-logo.ico"
        Title="HelloAvalonia">

    <Design.DataContext>
        <vm:GreetingViewModel />
    </Design.DataContext>

    <!-- WinForms版と同じ絶対座標配置(20,20 / 20,60 / 20,100)にするため Canvas を使用 -->
    <Canvas>
        <TextBox Canvas.Left="20" Canvas.Top="20" Width="200"
                 Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

        <TextBlock Canvas.Left="20" Canvas.Top="60" Width="300"
                   Text="{Binding Greeting}" />

        <Button Canvas.Left="20" Canvas.Top="100"
                Content="挨拶する"
                Command="{Binding GreetCommand}" />
    </Canvas>

</Window>

WinForms版はnew TextBox()してLocationを指定し、DataBindings.Add(...)でバインドしていた部分が、XAMLの属性に置き換わりました。buttonGreet.Command = _vm.GreetCommand;はCommand="{Binding GreetCommand}"です。

  • 比較のためにCanvasで絶対座標配置にしましたが、実務のAvaloniaではStackPanelやGridを使うのが普通です。
  • UpdateSourceTrigger=PropertyChangedは、AvaloniaのTextBoxでは明示しなくても入力ごとに反映されるようです。ただ、WinForms版のDataSourceUpdateMode.OnPropertyChangedと対応が分かるように明示しました。

ViewModelとViewを繋ぐ

App.axaml.cs
public override void OnFrameworkInitializationCompleted()
{
    if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
    {
        desktop.MainWindow = new MainWindow
        {
            DataContext = new GreetingViewModel(),
        };
    }

    base.OnFrameworkInitializationCompleted();
}

WinForms版はForm内でnew GreetingViewModel()していましたが、Avalonia版はここでDataContextに設定します。

WinForms版との対比

WinForms (.NET 10) Avalonia 12
VM基底 ObservableObject ViewModelBase(中身はObservableObject継承のみ。テンプレート生成)
プロパティ [ObservableProperty] private string _name(フィールド方式) [ObservableProperty] public partial string Name { get; set; }(partial property方式)
コマンド [RelayCommand] private void Greet() 同じ
画面定義 コードでnew TextBox() + Location XAML(Canvas.Left/Topで同じ絶対座標)
テキストのバインド DataBindings.Add(..., OnPropertyChanged) {Binding Name, UpdateSourceTrigger=PropertyChanged}
ボタン buttonGreet.Command = _vm.GreetCommand; Command="{Binding GreetCommand}"
VM接続 new GreetingViewModel()をForm内で生成 App.axaml.csでDataContext = new GreetingViewModel() + XAMLのx:DataType
Label Label TextBlock

地味な発見:ラベルの初期テキストは実は表示されない

WinForms版のlabelGreeting.Text = "ここに挨拶が表示されます"は、バインド直後にViewModelの空文字で上書きされるため、実際には起動時のラベルは空です。Avalonia版も同じ挙動(Greetingの初期値は空)に揃えました。前回の記事のコードを読み返して気づいた点です。

動作確認

dotnet buildは警告0・エラー0です。起動してみると、こうなります。

起動直後です。TextBoxもラベルも空で、WinForms版の「ここに挨拶が表示されます」は表示されません(前述のとおりです)。

起動直後

TextBoxに「太郎」と入力した状態です。

「太郎」を入力

「挨拶する」を押すと、こんにちは、太郎さん!と表示されます。

「挨拶する」を押した後

この動作は、UI Automationでも確認しています(TextBoxに「太郎」と入力してボタンをInvokeすると、Textがこんにちは、太郎さん!に変化します)。

xUnitのテストを移植する

前回のテストを、そのまま持ってきます。

terminal
dotnet new xunit -n HelloAvalonia.Tests
dotnet add HelloAvalonia.Tests/HelloAvalonia.Tests.csproj reference HelloAvalonia/HelloAvalonia.csproj

テストの本文(Arrange/Act/Assert、テスト名、期待値)は一切変更していません。変えたのは次の2点だけです。

  • namespace MyWinFormsApp.Tests; → namespace HelloAvalonia.Tests;
  • using HelloAvalonia.ViewModels;を追加(前回はVMとテストが同じ親namespaceだったため不要でした)
GreetingViewModelTests.cs
using HelloAvalonia.ViewModels;

namespace HelloAvalonia.Tests;

public class GreetingViewModelTests
{
    [Fact]
    public void Greet_名前を入力して挨拶コマンドを実行すると_挨拶文が設定される()
    {
        // Arrange
        var vm = new GreetingViewModel();
        vm.Name = "太郎";

        // Act
        vm.GreetCommand.Execute(null);

        // Assert
        Assert.Equal("こんにちは、太郎さん!", vm.Greeting);
    }

    // 以下2件(名前が空の場合 / Name変更時のPropertyChanged)も前回と同じ
}
terminal
dotnet test
合格 3 / 失敗 0 / スキップ 0

[ObservableProperty]がフィールド方式でもpartial property方式でも、生成されるのは「公開プロパティと変更通知」です。そのため、vm.Name = "太郎"やvm.PropertyChanged += ...、vm.GreetCommand.Execute(null)といった使う側のテストコードは、書き方の違いを意識しなくて済みました。

本題:Linuxコンテナでも分離なしで通るか

ここが前回の記事で一番苦労したところです。前回、WinForms版は次のようになっていました。

  • Linux上でアプリ単体をビルド → NETSDK1100(EnableWindowsTargetingが必要)
  • テストからWinFormsプロジェクトを参照 → NETSDK1073。ViewModelを別プロジェクトに分離する必要があった

Avalonia版は同じことをやってもこうなりました。

まずdocker runで確認

mcr.microsoft.com/dotnet/sdk:10.0(SDK 10.0.401)のコンテナで、dotnet testとdotnet buildを実行しました。

Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3 - HelloAvalonia.Tests.dll (net10.0)

Build succeeded.
    0 Warning(s)
    0 Error(s)

NETSDK1100もNETSDK1073も出ませんでした。

WinForms版との違い

WinForms版(前回) Avalonia版(今回)
アプリのTFM net10.0-windows + UseWindowsForms net10.0
Linuxでアプリ単体をビルド NETSDK1100(EnableWindowsTargetingが必要) そのまま通る
テストからアプリを直接参照 NETSDK1073 → Coreライブラリ分離が必須 分離不要。WinExeのアプリ本体を直接ProjectReferenceして3/3合格
csprojへの回避策の追記 必要 不要
画面の実行(dotnet run) Linuxでは不可 Avaloniaはクロスプラットフォームだが、コンテナにはディスプレイが無いため今回は未検証

前回の「ViewModelを別プロジェクトに分けないとLinuxで詰む」という話は、Avaloniaでは前提そのものが消えました。

ただし、これは「分離しなくてよい」という話であって、「分離しないほうがよい」という話ではありません。規模が大きくなるなら、ViewModelをクラスライブラリに切り出しておくほうが無難だと思います。

devcontainerで起動する(ここで詰まった)

前回までのシリーズはdevcontainerが前提だったので、devcontainer.jsonでも確認しました。内容はイメージ指定のみです。

.devcontainer/devcontainer.json
{
  "name": "AvaloniaTrial (.NET 10)",
  "image": "mcr.microsoft.com/dotnet/sdk:10.0"
}

検証には@devcontainers/cli(0.89.0)を使いました。

VS Codeの「Reopen in Container」というGUI操作での確認は、今回はしていません。以下はdevcontainerのCLI経由の結果です。

詰まり1:そのままdevcontainer upすると失敗する

docker: Error response from daemon: invalid mount config for type "bind": invalid mount path: 'C:/Users/.../AvaloniaTrial' mount path must be absolute

私の環境は、Docker DesktopではなくWSL(Ubuntu)内のDocker Engineにtcp://127.0.0.1:2375で接続する構成です。デーモンがWSL側にあるため、C:\...をバインドマウントのsourceとして解釈できませんでした。

対処は、devcontainer.jsonにworkspaceMountを足して、WSL形式(/mnt/c/...)で指定することです。

devcontainer.json(この環境用の回避策)
"workspaceMount": "type=bind,source=/mnt/c/<...>/ws,target=/workspaces/ws",
"workspaceFolder": "/workspaces/ws"

これで{"outcome":"success", ...}になり、コンテナ内でdotnet testは3/3合格、dotnet buildは警告0・エラー0でした。

なお、リポジトリ側のdevcontainer.jsonはイメージ指定のみにしてあります。workspaceMountは、このWSL上のDocker Engine構成に特有の回避策で、Docker DesktopやWSL統合の環境では不要なはずだからです。

詰まり2:Git Bashだとdevcontainer execの引数が化ける

bash: C:/Program Files/Git/workspaces/ws/run.sh: No such file or directory

Git Bash(MSYS)が/workspaces/...をC:/Program Files/Git/workspaces/...に自動変換してしまうのが原因です。MSYS_NO_PATHCONV=1を付けると防げます。また、bash -c '{ ...; } > file'のような複雑なクォートはWindowsのnpx経由で壊れたため、スクリプトファイルにして実行しました。

余談:docker runの出力が返ってこない

WSL内デーモンにTCPで繋ぐ構成では、短命なdocker runの標準出力が呼び出し元のシェルに返らないことがありました(終了コード0で出力なし)。コンテナ内でログをマウント先のファイルに書き出して、ホスト側から読むことで回避しています。

未検証・注意点

  • Linuxコンテナでの画面の実行(dotnet run)は未検証です(ディスプレイが無いため)
  • devcontainerはdevcontainerのCLIで確認しており、VS Codeの「Reopen in Container」や拡張機能の自動導入、remoteUserの調整は未検証です
  • 検証はbin/objを除いたコピーの上で行いました。Windows側にbin/objが残った状態でdevcontainerを開くと、project.assets.jsonにWindowsのパスが残っていてLinuxのrestoreと衝突する恐れがあります。その場合は先にdotnet cleanするかbin/objを削除しておくのが安全だと思います(この点自体は実機で確認していません)
  • ビューのバインディング(XAML)は、実際に画面を起動しての確認とUI Automationでの確認でカバーしています。自動テストはViewModel単体のみです

まとめ

WinForms (.NET 10) Avalonia 12
ViewModel 前回のまま使える [RelayCommand]は同じ。[ObservableProperty]はテンプレートがpartial property方式
画面 コードで生成し、DataBindings.Add XAML + {Binding}
ボタンとコマンド Commandプロパティに代入 Command="{Binding ...}"
テストコード そのまま そのまま(namespaceとusingのみ)
Linuxでのビルド・テスト ViewModelの分離が必須(NETSDK1073) 分離なしで通った

MVVMの部分(ViewModelとテスト)はほとんど書き直さずに済みました。違いが出たのは、画面の書き方(コード → XAML)と、アプリ本体のターゲット(net10.0-windows → net10.0)です。後者のおかげで、Linuxコンテナでのビルドやテストの手間が減りました。

次は、StackPanelやGridを使った実用的なレイアウトや、実際にコンテナ上で画面を動かす方法(仮想ディスプレイなど)も試してみたいと思います。

参考

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?