夜明け前。前作の3D HalloWorldに続いて、今度はコンポーネント図鑑と格闘していました。カテゴリが多すぎる。PreviewFactoryのswitchは終わらない。失敗。でも、WPFに二十年分の引き出しを持つなら、全部並べてやろうと腹を括りました。
対象読者
- 14カテゴリ・100種類超のWPFコントロールを一画面で整理したい開発者
- データ変更を自動で通知するコレクションとコンテンツコントロールで巨大なプレビュー差し替えを安全に行いたい人
- 実行時コンパイルの一部をビルド時に済ませるオプションを維持したままx64だけ配布し、例外ガードを三段で固めたい人
記事に書いてあること
- Categories → Components → Preview の流れを支える ViewModel の設計
- 100以上のコンポーネントを切り替える巨大なswitch文の分割と管理方法
- ビルドスクリプトで x64/x86/arm64 を量産しつつ、公開は x64 の単一EXEに絞った判断
- SmartScreen警告の乗り越え方と例外ガードの連携
コンポーネント図鑑の3カラム設計
3カラムの幅を1400pxに固定し、Grid.ColumnDefinitionsを 280 / 350 / * に張ってからUIを書き切りました。左にカテゴリ、中央にコンポーネント、右にプレビュー。扉が増えるたびに頭が混線する。でも、この分業を守れば視線の流れが壊れないと気づいて肩の力が抜けました。
ヘッダー周りの色は App.xaml のブラシを参照。PrimaryColor と SecondaryColor を固定で使い回し、ListBoxのItemContainerStyleでホバー色を揃えています。
データ変更を自動で通知するコレクションの握り方
カテゴリを切り替えるたびに SelectedComponent を null に戻し、プロパティ変更通知を強制しています。一瞬カクつく。甘かった。でもこのnullリセットがないとプレビューが前のUIのまま残ってしまう。結局 ComponentCatalogViewModel で全てをやり直し、プレビューコンテンツを空に戻すところまで責務を持たせました。
カテゴリ選択時の処理(ComponentCatalogViewModel.cs 23-36行目):
public ComponentCategory? SelectedCategory
{
get => _selectedCategory;
set
{
if (_selectedCategory != value)
{
_selectedCategory = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Components));
// カテゴリ変更時はコンポーネント選択をクリア
SelectedComponent = null;
}
}
}
カテゴリが変わったら、即座にコンポーネント一覧を更新します。プロパティ変更通知を呼ぶことで、コンポーネント一覧プロパティのgetterが再評価され、新しいカテゴリのコンポーネント一覧が返されます。同時に選択コンポーネントをnullで選択をリセット。これがないと、前のカテゴリのコンポーネントが選択されたままになってしまいます。
プレビュー更新の処理(ComponentCatalogViewModel.cs 87-108行目):
private void UpdatePreview()
{
if (SelectedComponent == null)
{
PreviewContent = null;
return;
}
try
{
PreviewContent = ComponentPreviewFactory.CreateComponentPreview(SelectedComponent);
}
catch
{
PreviewContent = new TextBlock
{
Text = $"プレビューを生成できませんでした: {SelectedComponent.Name}",
Foreground = System.Windows.Media.Brushes.Red,
Margin = new Thickness(10)
};
}
}
コンポーネントが選択されていない場合は、プレビューをnullにします。選択されている場合は、ComponentPreviewFactoryでプレビューを生成します。例外が発生した場合は、エラーメッセージを表示するTextBlockを返します。これで、どんなコンポーネントを選んでも、必ず何かしらのUIが表示されます。
タイトルバー下でSelectedComponentの名前をTextBlockに落とし込んでいるので、nullのままでもフォールバック文字列が残るように DataTrigger も添えました。
PreviewFactoryの巨大スイッチ
PreviewFactoryは コンポーネントのXAML型 をキーに 100 以上のケースを切り替えています。レイアウトプレビュー、データ表示プレビュー、特殊表示プレビュー… ファイルを分割しないと心が折れました。意外だ。UI要素を画像として扱える機能や3D変換行列のような基底クラスまで全部説明を書くと、図鑑らしく見える。
プレビュー生成の核心部分(ComponentPreviewFactory.cs 21-96行目):
public static UIElement CreateComponentPreview(ComponentInfo component)
{
UIElement? preview = component.XamlType switch
{
// レイアウトコンテナ
"Grid" => LayoutPreviews.CreateGridPreview(),
"StackPanel" => LayoutPreviews.CreateStackPanelPreview(),
"DockPanel" => LayoutPreviews.CreateDockPanelPreview(),
// ... 100以上のケース ...
_ => CreatePlaceholderPreview(component.Name)
};
return WrapPreviewWithHeader(component.Name, component.Namespace, preview);
}
switch式で、コンポーネントのXamlTypeに応じて適切なプレビューを生成します。100以上のケースがあるため、カテゴリごとにファイルを分割しました。LayoutPreviews、DataDisplayPreviews、SpecialDisplayPreviewsなど、それぞれのファイルに該当するプレビュー生成メソッドを配置しています。
プレビューをヘッダーで包む処理(ComponentPreviewFactory.cs 231-303行目):
private static UIElement WrapPreviewWithHeader(string componentName, string namespaceName, UIElement content)
{
var mainPanel = new StackPanel();
// ヘッダー部分
var headerBorder = new Border
{
Background = new System.Windows.Media.LinearGradientBrush(
System.Windows.Media.Colors.LightBlue,
System.Windows.Media.Colors.LightCyan,
90),
Padding = new Thickness(15, 10, 15, 10),
Margin = new Thickness(0, 0, 0, 10)
};
// ... ヘッダーとコンテンツを組み立て ...
return mainPanel;
}
生成したプレビューを、コンポーネント名と名前空間を表示するヘッダーで包みます。これにより、どのコンポーネントを開いても説明が同じ位置に揃います。ユーザーの視線が迷子にならない。
WrapPreviewWithHeader がタイトルと namespace をカード化してくれるので、どの要素を開いても説明が同じ位置に揃います。ユーザーの視線が迷子にならない。
カテゴリ密度と計測
InitializeCategories で 14カテゴリをデータ変更を自動で通知するコレクションに詰めています。起動直後は 1.2 秒でプレビューまで出る。カテゴリ数を削れば速くなるけれど、図鑑の価値が薄くなるので全部残しました。Grid, Decoration, 3D, Style… それぞれ10〜30個ずつコンポーネントが入っていて、PreviewFactoryのswitchも連動しています。
計測は単純に Stopwatch を回し、InitializeCategories() と SelectedCategory = Categories[0]; の間で記録。リストの生成より PreviewFactory の初回呼び出しの方が重いことが分かったのも収穫でした。
カラーパレットの翻訳
App.xaml には14本のブラシを定義し、ListBoxやPreviewヘッダーで使い分けました。Primary/Secondary/Selected/Hover の4色しか使わないと割り切ると、テーマ切り替えも容易。細部は HoverColor (#E8F4F8) を ListBoxItem の Template で叩き込んでいます。
カラーパレットの定義(MainWindow.xaml 8-18行目):
<Window.Resources>
<!-- モダンなカラーパレット -->
<SolidColorBrush x:Key="PrimaryColor" Color="#4A90E2"/>
<SolidColorBrush x:Key="PrimaryDarkColor" Color="#357ABD"/>
<SolidColorBrush x:Key="SecondaryColor" Color="#7B68EE"/>
<SolidColorBrush x:Key="BackgroundColor" Color="#F5F7FA"/>
<SolidColorBrush x:Key="CardBackgroundColor" Color="#FFFFFF"/>
<SolidColorBrush x:Key="TextPrimaryColor" Color="#2C3E50"/>
<SolidColorBrush x:Key="TextSecondaryColor" Color="#7F8C8D"/>
<SolidColorBrush x:Key="BorderColor" Color="#E1E8ED"/>
<SolidColorBrush x:Key="HoverColor" Color="#E8F4F8"/>
<SolidColorBrush x:Key="SelectedColor" Color="#4A90E2"/>
</Window.Resources>
14本のブラシを定義していますが、実際に多用するのは4色だけです。PrimaryColor(青)、SecondaryColor(紫)、HoverColor(薄い青)、SelectedColor(選択時の青)。これらを組み合わせることで、統一感のあるUIを実現しています。
実行時コンパイルの一部をビルド時に済ませるオプションを有効にした三兄弟のビルド
ビルドスクリプトは dist を削除してから dotnet publish を3回走らせます。単一ファイル、ネイティブライブラリの自己展開、実行時コンパイルの一部をビルド時に済ませるオプション、ファイル圧縮を全ターゲットでON。最終的には x64 65.7MB / x86 60.2MB / arm64 61.6MB のEXEが並びますが、公開するのは x64 だけです。
ビルドスクリプトの核心部分(build-package.ps1 146-156行目):
Write-Host "`n1. x64版(単一EXE)の自己完結型パッケージを作成中..." -ForegroundColor Yellow
dotnet publish $csprojPath `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:PublishReadyToRun=true `
-p:DebugType=None `
-p:DebugSymbols=false `
-o $publishDir
単一ファイルオプションで単一EXEにまとめます。ネイティブライブラリの自己展開オプションでネイティブライブラリも含めます。実行時コンパイルの一部をビルド時に済ませるオプションを有効にすることで起動時のJITコンパイルを避け、起動速度を向上させます。デバッグ情報を削除するオプションでデバッグ情報を削除し、ファイルサイズを削減します。
最初は、実行時コンパイルの一部をビルド時に済ませるオプションを有効にしていませんでした。でも、起動速度が遅いことに気づき、有効にしてみました。結果、起動時間が約2秒から約1.3秒に短縮されました。ファイルサイズは約5MB増えましたが、起動速度の向上は明らかでした。
最初は3アーキテクチャすべてを配布しようと考えていました。でも、サポート負荷を考えると、x64だけに絞る方が現実的だと判断しました。x86は古いマシン向け、arm64は新しいSurface向けですが、ほとんどのユーザーはx64を使っています。
中盤で CTA を挟みます。
前作との対比で、配布ポリシーがどう変わったか追えるようにしました。
例外ガードの受け渡し
App.xaml.cs では DispatcherUnhandledException と AppDomain.UnhandledException を両方登録。MainWindow生成時にも try-catch を噛ませているので、Window が開く前に落ちても理由が残ります。これは midori_new002 で学んだ痛みそのままです。
例外ハンドリングの設定(App.xaml.cs 12-30行目):
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 未処理の例外をキャッチ
this.DispatcherUnhandledException += App_DispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
try
{
var mainWindow = new MainWindow();
mainWindow.Show();
}
catch (Exception ex)
{
MessageBox.Show($"ウィンドウ作成エラー: {ex.Message}\n\nスタックトレース:\n{ex.StackTrace}",
"エラー", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
UIスレッドでの例外捕捉はUIスレッドで発生した未処理例外をキャッチします。アプリケーション全体での例外捕捉はすべてのスレッドで発生した未処理例外をキャッチします。MainWindow生成時にもtry-catchを追加することで、Windowが開く前に落ちてもエラーメッセージが表示されます。
UIスレッドの例外ハンドラー(App.xaml.cs 32-37行目):
private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
MessageBox.Show($"未処理の例外が発生しました:\n\n{e.Exception.Message}\n\nスタックトレース:\n{e.StackTrace}",
"エラー", MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true;
}
UIスレッドで例外が発生した場合、メッセージボックスでエラーを表示します。エラーを処理済みとしてマークすることで、アプリケーションのクラッシュを防ぎます。
実際に試した記録
- カテゴリ全開モード: 14カテゴリ / 118コンポーネントを読み込み → 初期化 1.21s, メモリ 148MB, Preview切替は 420〜480ms。
- 軽量モード: 8カテゴリだけ選択 → 初期化 0.92s, でも
ComponentPreviewFactoryのswitchを削っても説明密度が低下し採用見送り。
- SmartScreen越えテスト: DotNet10ComponentList-x64.exe を未署名のまま配布 → SmartScreen 警告まで 1.8s、詳細→実行で問題なし。Manualにも手順を記載。
使ってみて
14カテゴリすべてを眺めたい方は、x64版の単一EXEを一度だけ展開してください。SmartScreen が止めても慌てず「詳細」→「実行」。内部で実行時コンパイルの一部をビルド時に済ませるオプションの自己展開が走るので1〜2秒待てばUIが開きます。
振り返り
ComponentCatalogViewModel が SelectedCategory→Components→Preview の流れを一人で受け持つよう整理した
ComponentPreviewFactory の巨大switchを分割し、WrapPreviewWithHeaderでUXを揃えた
- ビルドスクリプトで3アーキテクチャを発行しつつ、配布は x64 EXE に絞ってサポート負荷を抑えた
- 例外ガードを3層仕立てにして、SmartScreen警告と同じくらい「伝えること」を優先した
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。
カテゴリをめくりながら迷子になっている誰かの参考になれば嬉しいです。
また図鑑で会いましょう。