.NET MAUI の FlexLayout に対して BindableLayout.SetItemsSource を呼んだ直後、Debug では耐えるのに Release でだけクラッシュする——そんな現場で起きがちな落とし穴の本質と、堅牢に直すための設計と実装をまとめました。単なる「例外を握りつぶす」対処ではなく、データの到着タイミングと XAML バインディングの評価順序を踏まえた再設計まで踏み込みます。
背景と症状
以下のような状況でアプリが異常終了します。
BindableLayout.SetItemsSource(FlexLayout, IEnumerable)を実行してアイテムを差し替える。DataTemplate内でPayGuests[0].GuestNameのように 0 番要素へ直接バインドしている。- Debug(WinUI)では
System.ArgumentOutOfRangeExceptionが複数回発生しつつ続行。 - Release(WinUI/Android/iOS)では即クラッシュする。
- 以前の Visual Studio/ランタイムではたまたま落ちず、最近の環境で顕在化した。
なぜ起きるのか:原因の詳細分析
空コレクションへのインデクサ評価
UI はデータ到着を待ってくれません。BindableLayout が ItemsSource を受け取ると、直ちに ItemTemplate を評価し、その時点のバインディング式を解決しに行きます。PayGuests がまだ空(Count == 0)なのに [0] を評価すれば、当然 System.ArgumentOutOfRangeException になります。
Debug と Release の差が表面化する理由
- 最適化の有無:Release では JIT/AOT 最適化により例外の伝搬が速く、握りつぶされにくい。非同期ロードより先にテンプレート評価が走りやすく、タイミング競合が露呈します。
- 診断の違い:Debug はバインディング失敗を「出力」に記録しつつレンダリング継続を試みますが、Release では UI ツリーの構築失敗が致命になりやすい。
- ビルド構成の差:iOS では AOT、Android ではリンカー最適化、WinUI でもインライン化等によりコードパスが変わり、例外の捕捉機会が減少します。
再現コード(悪い例)
以下は問題を引き起こす典型です。テンプレートが 0 番要素を直接参照しています。
// Model
public sealed class Guest
{
public string GuestName { get; set; } = "";
}
// ViewModel 側コレクション(初期表示時は空)
public sealed class PayGroup : INotifyPropertyChanged
{
public ObservableCollection<Guest> PayGuests { get; } = new();
public event PropertyChangedEventHandler? PropertyChanged;
}
// Page(コードビハインド)
public partial class PayPage : ContentPage
{
public ObservableCollection<PayGroup> Groups { get; } = new();
public PayPage()
{
InitializeComponent();
// DataTemplate は XAML リソースとして定義済み
BindableLayout.SetItemTemplate(RootFlex, (DataTemplate)Resources["PayItemTemplate"]);
BindableLayout.SetItemsSource(RootFlex, Groups); // ★ここでテンプレート評価が走る
_ = LoadAsync();
}
private async Task LoadAsync()
{
await Task.Delay(300); // ネットワーク等
var g = new PayGroup();
// さらに遅れてゲストが到着
await Task.Delay(300);
g.PayGuests.Add(new Guest { GuestName = "Alice" });
MainThread.BeginInvokeOnMainThread(() => Groups.Add(g));
}
}
<ContentPage ... x:Class="App.PayPage">
<ContentPage.Resources>
<DataTemplate x:Key="PayItemTemplate" x:DataType="models:PayGroup">
<Grid Padding="8">
<Label
Text="{Binding PayGuests[0].GuestName}" /> <!-- ★空時にクラッシュ -->
</Grid>
</DataTemplate>
</ContentPage.Resources>
<FlexLayout x:Name="RootFlex" Direction="Column" />
</ContentPage>
解決の基本方針
ポイントは「テンプレート評価時に空でも安全にする」または「評価を遅らせる」の二択です。以下に実装可能な対策をまとめます。
| 対策 | 内容 | メリット |
|---|---|---|
| インデックスを使わない | ビューモデルに安全なプロパティを追加し、テンプレートから参照 | 空でも例外なし。表現力・可読性が高い |
| コレクション変更通知 | ObservableCollection と PropertyChanged を連動 | 遅延到着データで UI が自動更新 |
| 要素数チェック付きバインド | 可視制御やコンバータで 0 件時には表示しない | 手っ取り早いクラッシュ回避 |
| ItemsSource 設定タイミング見直し | 最低 1 件入ってから SetItemsSource/または遅延 | 評価の順序を制御し根本回避 |
実装例:インデックスを使わない(推奨)
テンプレート側の責務を小さくし、ビューモデルで「最初のゲスト」を表現します。
public sealed class PayGroup : INotifyPropertyChanged
{
public ObservableCollection<Guest> PayGuests { get; } = new();
public string FirstGuestName =>
PayGuests.FirstOrDefault()?.GuestName ?? "読み込み中…";
public event PropertyChangedEventHandler? PropertyChanged;
public PayGroup()
{
PayGuests.CollectionChanged += (_, __) =>
PropertyChanged?.Invoke(this, new(nameof(FirstGuestName)));
}
}
<DataTemplate x:Key="PayItemTemplate" x:DataType="models:PayGroup">
<Grid Padding="8">
<Label Text="{Binding FirstGuestName}" />
</Grid>
</DataTemplate>
さらに表現力を上げたい場合は、オブジェクトごと返すのが便利です。
public Guest? FirstGuest => PayGuests.FirstOrDefault();
<Label
Text="{Binding FirstGuest.GuestName}"
TargetNullValue="読み込み中…" />
実装例:コレクション変更通知を正しく届ける
テンプレートが FirstGuestName に依存していることを考慮し、PayGuests の追加・削除で PropertyChanged を発火させます(上の実装済み)。このときプロパティの計算は軽量に保ち、必要であれば既知値をキャッシュしても構いません。
実装例:要素数チェック付きバインド
public sealed class GreaterThanZeroConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is int i && i > 0;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
<ContentPage.Resources>
<converters:GreaterThanZeroConverter x:Key="GtZero" />
<DataTemplate x:Key="PayItemTemplate" x:DataType="models:PayGroup">
<Grid Padding="8">
<Label
Text="{Binding PayGuests[0].GuestName}"
IsVisible="{Binding PayGuests.Count, Converter={StaticResource GtZero}}" />
</Grid>
</DataTemplate>
</ContentPage.Resources>
「どうしてもインデクサを使いたい」場合の最終手段です。非表示になっても UI レイアウトが崩れるなら、プレースホルダーを別 Label に用意し、DataTrigger でトグルする構成が良いでしょう。
実装例:ItemsSource 設定のタイミングを遅らせる
データが 1 件以上そろってからテンプレートを評価させます。初期は空配列・空テンプレートにしておき、データ到着後に差し替えます。
public PayPage()
{
InitializeComponent();
// 初期はテンプレート・ソースともに空
BindableLayout.SetItemsSource(RootFlex, Array.Empty<PayGroup>());
// データロード
_ = LoadAsync();
}
private async Task LoadAsync()
{
var groups = await FetchGroupsAsync();
// 1 件以上あることを保証してから設定
if (groups.Any())
{
MainThread.BeginInvokeOnMainThread(() =>
{
BindableLayout.SetItemTemplate(RootFlex, (DataTemplate)Resources["PayItemTemplate"]);
BindableLayout.SetItemsSource(RootFlex, groups);
});
}
}
この手法は「評価順序」をコントロールできる一方、ローディング表示を自前で用意する必要があります。後述の「骨組み UI(skeleton)」併用が実践的です。
根本改善:入れ子の BindableLayout でコレクションを正しくレンダリングする
本来、コレクションをテンプレート内で 1 件だけ抜き出すより、コレクション自体をネイティブにバインドする方が安全です。つまり、親のテンプレートは PayGroup、その中に「PayGuests を描画するための内側の BindableLayout」を置きます。
<DataTemplate x:Key="PayItemTemplate" x:DataType="models:PayGroup">
<Grid Padding="8">
<Label Text="{Binding GroupTitle}" FontAttributes="Bold" />
<FlexLayout Direction="Row" BindableLayout.ItemsSource="{Binding PayGuests}">
<BindableLayout.ItemTemplate>
<DataTemplate x:DataType="models:Guest">
<Frame Padding="4" Margin="2">
<Label Text="{Binding GuestName}" />
</Frame>
</DataTemplate>
</BindableLayout.ItemTemplate>
</FlexLayout>
</Grid>
</DataTemplate>
これなら PayGuests が空でも安全で、要素が揃い次第自動で描画されます。0 番要素だけを表示したいときは、上記の FirstGuestName プロパティを使えば良く、テンプレートからインデクサを消せます。
開発チームで共有したいアンチパターン
- テンプレートから
ItemsSourceとは別の親オブジェクトのコレクションに対しインデクサで抜き出す。 - 「とりあえず Null 許容演算子」を多用し、要素数 0 のケースをロジックで扱わない。
- Release テストを省略して Debug 通過=安全とみなす。
XAML バインディングを堅牢にする追加テクニック
Compiled Bindings(x:DataType)の徹底
DataTemplate に x:DataType を付けると、型安全・パフォーマンスが向上し、バインドの記述ミスも早期検出できます。本記事のサンプルはすべて付与済みです。
TargetNullValue と FallbackValue
値が未解決/評価不能のときの表示を決められます。ロード待ち文言やプレースホルダーを仕込むのに有効です。
<Label Text="{Binding FirstGuest.GuestName}" TargetNullValue="読み込み中…" />
ローディング骨組み(Skeleton)UI
コレクションが空=エラーではありません。空の間は Skeleton を表示し、PayGuests.Count > 0 で切り替えると UX が向上します。
例外を見逃さない診断のコツ
- 出力ウィンドウの「Binding: 」系メッセージを確認(Debug)。
- Release ビルドでも
try/catchでSetItemsSource直後の UI 初期化を囲み、ログを収集。 - 非同期ロードのタスクで例外が落ちていないかを
TaskScheduler.UnobservedTaskExceptionで検知。
品質を数値で把握する:テスト観点のマトリクス
| 条件 | WinUI Debug | WinUI Release | Android Release | iOS Release |
|---|---|---|---|---|
インデクサあり(PayGuests[0]) | 継続するが例外ログ多発 | 高確率でクラッシュ | 高確率でクラッシュ | 高確率でクラッシュ |
安全プロパティ(FirstGuestName) | 良好 | 良好 | 良好 | 良好 |
| 要素数チェックで可視制御 | 良好 | 良好 | 良好 | 良好 |
| ItemsSource 遅延設定 | 良好(骨組み表示推奨) | 良好 | 良好 | 良好 |
よくある疑問と回答
Q. なぜ以前の環境では落ちなかった?
A. タイミングがたまたま噛み合っていただけです。ランタイムやコンパイラ最適化、端末性能差で評価順が変わるため、「落ちないことに依存する実装」は必ずほころびます。
Q. try/catch で囲めば十分?
A. 例外を捕まえるだけでは UI は描画されません。空の時に安全な値を返す設計(安全プロパティ、要素数チェック等)に改めるのが本質的解決です。
Q. ObservableCollection なら勝手に更新される?
A. コレクションの増減は通知されますが、FirstGuestName のような派生プロパティは自動では通知されません。CollectionChanged から PropertyChanged を明示的に発火しましょう。
堅牢化チェックリスト
- テンプレートからインデクサを排除しているか。
- 空コレクション時に安全な表示(TargetNullValue/プレースホルダー)があるか。
ObservableCollectionと派生プロパティの通知連動ができているか。- Release(WinUI/Android/iOS)で動作確認・計測を行ったか。
- ローディング骨組み UI を用意しているか。
フル修正例(推奨構成)
「インデクサを使わない」「派生プロパティあり」「入れ子の BindableLayout で Guests を描画」という三点セットです。
// Models
public sealed class Guest
{
public string GuestName { get; set; } = "";
}
public sealed class PayGroup : INotifyPropertyChanged
{
public string GroupTitle { get; set; } = "";
public ObservableCollection<Guest> PayGuests { get; } = new();
public Guest? FirstGuest => PayGuests.FirstOrDefault();
public string FirstGuestName => FirstGuest?.GuestName ?? "読み込み中…";
public event PropertyChangedEventHandler? PropertyChanged;
public PayGroup()
{
PayGuests.CollectionChanged += (_, __) =>
{
PropertyChanged?.Invoke(this, new(nameof(FirstGuest)));
PropertyChanged?.Invoke(this, new(nameof(FirstGuestName)));
};
}
}
// ViewModel
public sealed class PayViewModel
{
public ObservableCollection<PayGroup> Groups { get; } = new();
public async Task LoadAsync()
{
await Task.Delay(250);
var g = new PayGroup { GroupTitle = "チーム A" };
Groups.Add(g);
await Task.Delay(250);
g.PayGuests.Add(new Guest { GuestName = "Alice" });
g.PayGuests.Add(new Guest { GuestName = "Bob" });
}
}
<ContentPage ... x:Class="App.PayPage"
xmlns:vm="clr-namespace:App.ViewModels"
xmlns:models="clr-namespace:App.Models">
<ContentPage.BindingContext>
<vm:PayViewModel />
</ContentPage.BindingContext>
<ContentPage.Resources>
<DataTemplate x:Key="PayItemTemplate" x:DataType="models:PayGroup">
<VerticalStackLayout Spacing="6" Padding="8">
<Label Text="{Binding GroupTitle}" FontAttributes="Bold" />
<!-- 代表者(FirstGuest) -->
<Label Text="{Binding FirstGuestName}"
FontAttributes="Italic"
Opacity="0.7" />
<!-- 全ゲストを安全に描画 -->
<FlexLayout Direction="Row"
BindableLayout.ItemsSource="{Binding PayGuests}">
<BindableLayout.ItemTemplate>
<DataTemplate x:DataType="models:Guest">
<Frame Padding="8" Margin="2">
<Label Text="{Binding GuestName}" />
</Frame>
</DataTemplate>
</BindableLayout.ItemTemplate>
</FlexLayout>
</VerticalStackLayout>
</DataTemplate>
</ContentPage.Resources>
<ScrollView>
<FlexLayout x:Name="RootFlex" Direction="Column"
BindableLayout.ItemsSource="{Binding Groups}"
BindableLayout.ItemTemplate="{StaticResource PayItemTemplate}" />
</ScrollView>
</ContentPage>
// Page code-behind
public partial class PayPage : ContentPage
{
public PayPage()
{
InitializeComponent();
Loaded += async (_, __) =>
{
if (BindingContext is PayViewModel vm)
await vm.LoadAsync();
};
}
}
設計指針のまとめ
- テンプレートは「現在あるデータだけ」で安全に描けるようにする(将来届くデータを前提にしない)。
- 「0 件は正常系」。表示の仕様を決め、空用 UI を用意する。
- 派生プロパティは
CollectionChangedと連動してPropertyChangedを上げる。 - Release 実機でテストし、評価順序依存を排除する。
トラブルシューティング早見表
| 現象 | 考えられる原因 | 一次対応 | 恒久対策 |
|---|---|---|---|
ArgumentOutOfRangeException | 空コレクションに対するインデクサ評価 | 可視制御/プレースホルダー表示 | 安全プロパティ化/テンプレート再設計 |
| Debug は継続・Release で落ちる | 最適化により例外が伝搬/評価順序の違い | 評価の遅延/例外ログ収集 | インデクサ排除・骨組み UI 導入 |
| データ到着後も UI が更新されない | PropertyChanged を発火していない | 手動で発火 | CollectionChanged 連動の仕組み化 |
最後に:安全な UI は「空を恐れない」設計から
今回のクラッシュは、テンプレート評価時に「空」を想定していなかったことに尽きます。BindableLayout/DataTemplate は宣言的で強力ですが、評価タイミングは常に UI フレームワーク側の都合でやって来ます。空でも安全に描く、到着したら自然に更新される、この二点を満たすだけで Debug/Release/全プラットフォームで同じ挙動に揃えられます。今日からテンプレートのインデクサをやめ、安全プロパティと入れ子の BindableLayout に置き換えていきましょう。

コメント