はじめに
前回の記事では、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 |
雛形を作る
テンプレートを入れる
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テンプレートでプロジェクトを作る
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はテンプレートに最初から入っていて、追加作業は不要でした。
<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
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)
<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を繋ぐ
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のテストを移植する
前回のテストを、そのまま持ってきます。
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だったため不要でした)
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)も前回と同じ
}
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でも確認しました。内容はイメージ指定のみです。
{
"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/...)で指定することです。
"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を使った実用的なレイアウトや、実際にコンテナ上で画面を動かす方法(仮想ディスプレイなど)も試してみたいと思います。


