WPF UserControlでGuidが違うのに同じ表示になる原因と解決|static ObservableCollectionの罠

WPFのItemsControlにUserControlを複数並べたのに、Guidは別々のはずなのに表示が全部同じになる――この症状は「バインドしているコレクション参照を全インスタンスで共有している」ことが原因で起きがちです。staticとObservableCollectionの関係から、最短で直す方法と、MVVM寄りの堅牢な設計まで整理します。

目次

発生している症状:Guidは違うのに、全てが同じ内容で表示される

状況を整理すると、よくあるパターンは次の通りです。

  • ItemsControl(例:FiltersPanel)のItemsSourceにObservableCollectionをバインドしている
  • FiltersPanelの各行(各アイテム)にFilterControlというユーザーコントロールを表示している
  • FilterControlは依存関係プロパティとしてGuid(またはFilterId)を持ち、値が設定されたタイミングでmailFilterからルール・アクションをLINQで引いて自分の中に表示する
  • 各FilterControlに渡ってくるGuidは別々で、LINQの結果も本来は別々のはず

ところが実行すると、どのFilterControlも「最後に作られた(または最後に更新された)1つと同じ内容」を表示してしまう、という現象が起きます。

見えている現象開発者が期待している動き実際に起きている動き(原因に直結)
Guidが違うのに表示が全部同じ各FilterControlが自分のGuidに対応するルール/アクションだけを表示全FilterControlが同一のコレクション参照(static)を見ているため、最後の更新内容に揃う

結論:staticなObservableCollectionを共有していると、最後の更新で全てが上書きされる

問題の核心はシンプルで、FilterControlの内部で表示用コレクションを次のように宣言している点です。

private static readonly ObservableCollection<RuleRepresenter> _rules = new();
private static readonly ObservableCollection<ActionRepresenter> _actions = new();

staticフィールドは、クラスに1つだけ存在する共有領域です。つまり、FilterControlを何個生成しても、参照している_rulesと_actionsは全インスタンスで同じ1つになります。

さらに、コンストラクタなどで次のようにItemsSourceを設定していると、各コントロールの内部ItemsControlは全て同じコントロール参照を監視します。

public FilterControl()
{
    InitializeComponent();
    RulesPanel.ItemsSource = _rules;
    ActionsPanel.ItemsSource = _actions;
}

この状態で、依存関係プロパティ(Guid)の変更コールバックで次のようにClear()→Add()を行うと…

private static void UpdateControl(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    // Guidごとに表示内容を作り直すつもり
    _rules.Clear();
    _actions.Clear();

    foreach (var rule in rules)
        _rules.Add(RepresenterConverter.ConvertToRepresenter(rule));

    foreach (var action in actions)
        _actions.Add(RepresenterConverter.ConvertToRepresenter(action));
}

最後にUpdateControlが走ったGuidの内容で、共有コレクションが再構築されます。その結果、画面上にある全てのFilterControlが同じItemsSource(同じ参照)を見ているため、表示も全て同じになります。

なぜ「最後の1つと同じ表示」になるのか:更新順のイメージ

ポイントは「各コントロールが別々のデータを持っている」のではなく、「全員が同じ箱(staticコレクション)を見ている」ことです。更新順を表にすると理解しやすくなります。

タイミングGuidが設定されたFilterControlstaticコレクションの中身画面の見え方
1Control A(Guid=A)Aのルール/アクションに置き換わる全コントロールがAの内容に見える(この時点でBやCはまだ未更新でも参照は同じ)
2Control B(Guid=B)Clear→Bの内容に置き換わる全コントロールがBの内容に見える
3Control C(Guid=C)Clear→Cの内容に置き換わる全コントロールがCの内容に見える(=最後の1つと同じ)

Guid自体は確かに別々でも、表示に使う箱(ItemsSource)が1つしかないので、結果として「最後の更新内容」に全員が同期してしまう、というわけです。

解決策1:staticをやめ、インスタンスごとに表示用コレクションを持たせる(最短で綺麗)

「最短で直して、なおかつWPFのデータバインディングとも相性が良い」修正は、表示用コレクションをインスタンスフィールドに戻すことです。staticを外すだけで、各FilterControlが自分専用のObservableCollectionを持てます。

public partial class FilterControl : UserControl
{
    private readonly ObservableCollection<RuleRepresenter> _rules = new();
    private readonly ObservableCollection<ActionRepresenter> _actions = new();

    public FilterControl()
    {
        InitializeComponent();
        RulesPanel.ItemsSource = _rules;
        ActionsPanel.ItemsSource = _actions;
    }

    public Guid FilterGuid
    {
        get => (Guid)GetValue(FilterGuidProperty);
        set => SetValue(FilterGuidProperty, value);
    }

    public static readonly DependencyProperty FilterGuidProperty =
        DependencyProperty.Register(
            nameof(FilterGuid),
            typeof(Guid),
            typeof(FilterControl),
            new PropertyMetadata(Guid.Empty, OnFilterGuidChanged));

    private static void OnFilterGuidChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not FilterControl control) return;
        if (e.NewValue is not Guid guid || guid == Guid.Empty) return;

        control.Reload(guid);
    }

    private void Reload(Guid guid)
    {
        // ここで control._rules / control._actions を更新するので、
        // 他インスタンスには影響しない
        _rules.Clear();
        _actions.Clear();

        var filter = mailFilter.Filters.FirstOrDefault(f => f.Guid == guid.ToString());
        if (filter == null) return;

        foreach (var rule in filter.Rules)
            _rules.Add(RepresenterConverter.ConvertToRepresenter(rule));

        foreach (var action in filter.Actions)
            _actions.Add(RepresenterConverter.ConvertToRepresenter(action));
    }
}

この形のメリットは次の通りです。

  • ItemsSourceはインスタンスごとに別参照になるため、更新が他のコントロールへ波及しない
  • ObservableCollectionの変更通知でUIが自動更新されるので、基本的にItems.Refresh()は不要
  • コードビハインドでも「状態の共有」を作り込みにくく、バグの温床を減らせる

補足:元の実装がGuidを文字列で扱っている場合、依存関係プロパティの型もstringのままでも直せます。ただし、文字列比較はフォーマット差異(大文字/小文字、ハイフン有無など)で事故りやすいので、可能なら上記のようにDPの型はSystem.Guidに寄せる方が安全です。

解決策1の派生:Itemsを直接更新する方法(動くが設計的には一段落ちる)

「staticコレクションを使わない」ことさえ守れば、ItemsControl.Itemsへ直接Addしても表示は分離できます。例えば、Guid変更時にRulesPanel.Items.Clear()→RulesPanel.Items.Add()する方式です。

ただしこの方式は、データバインディングの利点(テストしやすさ、差分更新、MVVMへの移行容易性)を活かしづらいので、長期的にはインスタンスごとのObservableCollectionにバインドする方が運用が楽です。

方式手軽さ保守性MVVM適性おすすめ度
ItemsSource + インスタンスObservableCollection◎◎◎最優先
ItemsControl.Itemsを直接更新○△△短期的な応急処置向き

解決策2:表示用データ(Rules/Actions)をプロパティ化し、XAMLでItemsSourceにバインドする

より「WPFらしい」形に寄せるなら、FilterControlに表示用コレクションを公開し、XAML側でItemsSourceをバインドします。コードビハインドでItemsSourceを触る箇所が減るので、後からの拡張(並び替え、フィルタリング、デザイン変更)に強くなります。

public partial class FilterControl : UserControl
{
    public ObservableCollection<RuleRepresenter> Rules { get; } = new();
    public ObservableCollection<ActionRepresenter> Actions { get; } = new();

    // FilterGuid DPは解決策1と同様
    // Guid変更時は Rules / Actions を更新するだけにする
    private void Reload(Guid guid)
    {
        Rules.Clear();
        Actions.Clear();

        var filter = mailFilter.Filters.FirstOrDefault(f => f.Guid == guid.ToString());
        if (filter == null) return;

        foreach (var rule in filter.Rules)
            Rules.Add(RepresenterConverter.ConvertToRepresenter(rule));

        foreach (var action in filter.Actions)
            Actions.Add(RepresenterConverter.ConvertToRepresenter(action));
    }
}

そしてXAML側は以下のようにします。

<UserControl x:Class="...FilterControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Grid>
        <ItemsControl x:Name="RulesPanel"
                      ItemsSource="{Binding Rules, RelativeSource={RelativeSource AncestorType=UserControl}}" />

        <ItemsControl x:Name="ActionsPanel"
                      ItemsSource="{Binding Actions, RelativeSource={RelativeSource AncestorType=UserControl}}" />
    </Grid>
</UserControl>

ここまでできると、Guid変更コールバックの責務は「データを更新する」だけになり、UI部品(ItemsControlそのもの)を触る必要がかなり減ります。結果として、今回のような「どこかの共有参照を触って全体が変わる」事故も起こりにくくなります。

解決策3:MVVMでデータ層と画面表示用データを分ける(根本的に事故りにくい)

今回のトラブルの本質は「表示用の状態を共有してしまった」ことです。これを設計で予防するなら、永続化・ビジネスロジック側のmailFilterと、画面表示用(ViewModel側)のコレクションを分けるのが鉄板です。

  • mailFilter.Filters:保存するためのモデル(Model)
  • FiltersPanel.ItemsSource:画面表示のためのViewModelコレクション(VM)

例えば、ViewModelを次のように用意します。

public sealed class FilterViewModel
{
    public Guid Guid { get; }
    public ObservableCollection<RuleRepresenter> Rules { get; } = new();
    public ObservableCollection<ActionRepresenter> Actions { get; } = new();

    public FilterViewModel(FilterModel model)
    {
        Guid = model.Guid;

        foreach (var rule in model.Rules)
            Rules.Add(RepresenterConverter.ConvertToRepresenter(rule));

        foreach (var action in model.Actions)
            Actions.Add(RepresenterConverter.ConvertToRepresenter(action));
    }
}

そして親のViewModelでObservableCollection<FilterViewModel>を持ち、ItemsControlにバインドします。

<ItemsControl ItemsSource="{Binding Filters}">
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <local:FilterControl />
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

この場合、FilterControlはGuidで引き直す必要がなく、単純にDataContext(FilterViewModel)を表示するだけにできます。つまり、コントロール側に「検索」や「変換」ロジックを持ち込まずに済むため、見た目の責務が明確になり、変更に強くなります。

MVVMへ寄せるほど、次のような拡張もやりやすくなります。

  • ルールやアクションの追加・削除をコマンド化(Undo/Redoにも展開しやすい)
  • 並び替え、検索、フィルタリングをCollectionViewで実現
  • 単体テストで表示ロジックの正しさを検証(UIを立ち上げずにテスト可能)

合わせて直したい:First()はFirstOrDefault()にして落ちにくくする

今回の主原因はstatic共有ですが、同時にクラッシュ耐性を上げる改善として、LINQのFirst()を安易に使わないことも重要です。

例えば次のようなコードは、該当データが存在しないと例外で落ちます。

var ruleRepresenter = _rules
    .First(r => r.Guid == button.Tag.ToString());

以下のようにFirstOrDefault()へ変更し、見つからない場合の分岐を入れると、削除済み・不整合・タイミング差にも強くなります。

var ruleRepresenter = _rules
    .FirstOrDefault(r => r.Guid == button.Tag?.ToString());

if (ruleRepresenter == null)
{
// ログ出力や何もしない等、アプリ方針に合わせて処理
return;
}

staticを使って良いケース/避けたいケース

static自体が悪いわけではありません。ただしWPFの画面表示(特にItemsSource)にstaticを混ぜると「全インスタンスが同期する」問題が表面化しやすいので、用途を切り分けるのが安全です。

用途staticの適性理由
表示用のObservableCollection(ItemsSource)×複数コントロールで参照共有され、更新が全体に波及しやすい
変換処理・ユーティリティ(純粋関数的)○状態を持たず副作用がないなら、static化しても事故りにくい
キャッシュ(Guid→結果など)△キーで分離できるなら有効。ただしメモリ管理と更新戦略が必要

もし「再計算コストが高いので共有キャッシュを使いたい」場合は、staticのObservableCollectionを共有するのではなく、例えば次のようにGuidをキーにしたキャッシュ辞書を用意し、各コントロールは自分のGuidに紐づくコレクションを取得して使う、という形にすると事故が減ります。

// 例:Guidごとに別コレクションを持つキャッシュ
private static readonly Dictionary<Guid, ObservableCollection<RuleRepresenter>> _rulesCache = new();

private ObservableCollection<RuleRepresenter> GetRules(Guid guid)
{
    if (_rulesCache.TryGetValue(guid, out var rules)) return rules;

    rules = new ObservableCollection<RuleRepresenter>();
    _rulesCache[guid] = rules;
    return rules;
}

ただしキャッシュは「更新」「削除」「メモリリーク対策」まで考える必要があるため、まずは本記事の解決策1〜3のどれかで正しく分離するのが先です。

原因特定が早くなるチェックリスト

同種の不具合(表示が全部同じ、連動して変わる)が出たときは、次をチェックすると切り分けが速くなります。

  • ItemsSourceに渡しているコレクション参照は本当に別か?(デバッグでReferenceEqualsを確認)
  • 表示用コレクションやViewModelをstaticで持っていないか?
  • 一度ItemsSourceを設定したItemsControlに対してItems.Addしていないか?(混在すると例外・意図しない挙動になりやすい)
  • Guidを文字列で比較していないか?(フォーマット差異でヒットしない→First()で落ちる、などが起きる)
  • UIスレッド以外からObservableCollectionを更新していないか?(別スレッド更新は例外の原因)

特に今回のように「最後に更新された内容に揃う」症状は、参照共有(static、シングルトン、共有ViewModel、共有CollectionView)を疑うのが最短ルートです。

まとめ:別Guidなのに同じ表示になったら、まずstaticと参照共有を疑う

Guidが正しく別でも、UIが参照しているデータ(ItemsSource)が共有されていれば、表示は簡単に「最後の状態」に揃ってしまいます。WPFで複数のUserControlを並べるときは、

  • 表示用コレクションはインスタンスごとに持つ
  • 可能ならRules/Actionsはプロパティ化してXAMLでバインドする
  • さらに堅牢にするならMVVMでModelとViewModelを分離する

この3点を押さえるだけで、今回の不具合だけでなく、WPFで頻出する「意図しない同期」「どこかの更新が全体に波及する」系のトラブルを大きく減らせます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次