「オフラインになったらポップアップを出し、オンラインに戻ったら閉じる」――.NET MAUI でありがちな要件ですが、IConnectivity.ConnectivityChanged に各 ViewModel から直接購読すると、接続の切り替え時にハンドラが2回以上呼ばれることがあります。本稿では、重複発火の真因と回避策を、DIとシングルトン設計、解除漏れ対策、デバイス差の吸収まで含めて実装レベルで徹底解説します。
.NET MAUI の ConnectivityChanged が複数回発火する理由
現象の多くは「イベントの重複登録」に起因します。特に、基底 ViewModel(例:AppShellViewModel)を継承した派生 ViewModel(例:TurbinesCollectionPageViewModel/ChargingStationsMapPageViewModel)が それぞれのコンストラクタで ConnectivityChanged に += すると、同じイベントに複数回購読されます。結果、接続が切り替わるたびにハンドラが多重に実行され、ポップアップの表示/非表示も複数回走ります。
| 症状 | 主な原因 | ありがちなコード | なぜ起きるのか |
|---|---|---|---|
| ハンドラが2回以上呼ばれる | 複数 ViewModel から重複購読 | _connectivity.ConnectivityChanged += OnChanged; を各派生VMのコンストラクタに記述 | DIで VM が都度生成され、同一イベントに複数回 += される |
| 画面遷移を跨ぐと回数が増える | 解除漏れ | 購読はするが OnDisappearing/Dispose で -= しない | 戻る/再表示のたびに購読だけが積み上がる |
| 一部デバイスで発火数が多い | 接続プロファイルの変化が多段階 | 処理側にデバウンス/重複抑止が無い | Wi‑Fi→セルラー等で短時間に複数イベントが発生 |
最も安全な解決策:イベント購読を 専用サービスに集約 し、アプリ全体で一度だけ購読する
「どの ViewModel でも接続変化を知りたい」なら、イベントの発行元を シングルトンなサービスに集約するのが定石です。これにより、IConnectivity.ConnectivityChanged への実購読はアプリ全体で一回だけ。各 ViewModel はサービスの ConnectivityChanged を購読するだけで済み、重複発火が消えます。
DI 登録(MauiProgram.cs)
using Microsoft.Maui.Networking;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
// … 省略(UseMauiApp など)
// MAUI の IConnectivity 実装を DI へ
builder.Services.AddSingleton<IConnectivity>(Connectivity.Current);
// 接続監視サービス(シングルトン)
builder.Services.AddSingleton<IConnectivityService, ConnectivityService>();
// ポップアップ制御の抽象(任意実装)
builder.Services.AddSingleton<IPopupService, PopupService>();
// シェル VM はシングルトン推奨(購読箇所を1か所に固定)
builder.Services.AddSingleton<AppShellViewModel>();
// 画面用 VM(必要に応じてスコープ/トランジェント)
builder.Services.AddTransient<TurbinesCollectionPageViewModel>();
builder.Services.AddTransient<ChargingStationsMapPageViewModel>();
return builder.Build();
}
}
インターフェイス設計
using Microsoft.Maui.Networking;
public interface IConnectivityService
{
event EventHandler? ConnectivityChanged;
// 現在の状態へ即アクセス可能に
NetworkAccess NetworkAccess { get; }
IReadOnlyList<ConnectionProfile> ConnectionProfiles { get; }
}
実装(重複抑止・UIスレッド マーシャリング・解除管理)
using Microsoft.Maui.Networking;
public sealed class ConnectivityService : IConnectivityService, IDisposable
{
private readonly IConnectivity _connectivity;
private readonly object _gate = new();
private NetworkAccess _lastAccess;
private IReadOnlyList _lastProfiles;
// WeakEventManager で下流の解除漏れにも強く
private readonly WeakEventManager _wem = new();
public event EventHandler<ConnectivityChangedEventArgs>? ConnectivityChanged
{
add => _wem.AddEventHandler(value, nameof(ConnectivityChanged));
remove => _wem.RemoveEventHandler(value, nameof(ConnectivityChanged));
}
public ConnectivityService(IConnectivity connectivity)
{
_connectivity = connectivity;
_lastAccess = connectivity.NetworkAccess;
_lastProfiles = connectivity.ConnectionProfiles.ToList().AsReadOnly();
_connectivity.ConnectivityChanged += OnConnectivityChanged;
}
public NetworkAccess NetworkAccess => _connectivity.NetworkAccess;
public IReadOnlyList<ConnectionProfile> ConnectionProfiles => _connectivity.ConnectionProfiles;
private void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
{
// デバイス差で短時間に多重発火しうるため状態を見て抑制
if (IsDuplicate(e)) return;
// UI スレッドで上流へ通知(ポップアップ操作で必須)
MainThread.BeginInvokeOnMainThread(() =>
{
_wem.HandleEvent(this, e, nameof(ConnectivityChanged));
});
}
private bool IsDuplicate(ConnectivityChangedEventArgs e)
{
lock (_gate)
{
bool sameAccess = e.NetworkAccess == _lastAccess;
bool sameProfiles = SetEquals(_lastProfiles, e.ConnectionProfiles);
if (sameAccess && sameProfiles) return true;
_lastAccess = e.NetworkAccess;
_lastProfiles = e.ConnectionProfiles.ToList().AsReadOnly();
return false;
}
}
private static bool SetEquals(IReadOnlyList<ConnectionProfile> a, IReadOnlyList<ConnectionProfile> b)
=> a.Count == b.Count && a.All(b.Contains);
public void Dispose()
=> _connectivity.ConnectivityChanged -= OnConnectivityChanged;
}
ポップアップ表示を一元化(多重表示・競合の排除)
表示・非表示が競合すると UX が壊れます。ここもサービスで一元化します。
public interface IPopupService
{
Task ShowNoInternetAsync();
Task HideNoInternetAsync();
}
public sealed class PopupService : IPopupService
{
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _isShown;
public async Task ShowNoInternetAsync()
{
await _gate.WaitAsync();
try
{
if (_isShown) return;
_isShown = true;
// ここで実際のポップアップ表示(例:Toolkit の Popup)
// Application.Current.MainPage?.ShowPopup(new NoInternetPopup());
}
finally { _gate.Release(); }
}
public async Task HideNoInternetAsync()
{
await _gate.WaitAsync();
try
{
if (!_isShown) return;
_isShown = false;
// ここで実際のポップアップを閉じる
// Application.Current.MainPage?.ClosePopup();
}
finally { _gate.Release(); }
}
}
ViewModel 側は「通知を受けて UI を更新」するだけ
基底 ViewModel で接続監視まで抱えないのがポイント。購読箇所はシェル(アプリ全体の親)など 一か所に集約します。
using Microsoft.Maui.Networking;
public partial class AppShellViewModel : IAsyncDisposable
{
private readonly IConnectivityService _connectivityService;
private readonly IPopupService _popupService;
public AppShellViewModel(IConnectivityService connectivityService,
IPopupService popupService)
{
_connectivityService = connectivityService;
_popupService = popupService;
// アプリ全体で1回だけ購読
_connectivityService.ConnectivityChanged += OnConnectivityChanged;
// 初期状態も反映
_ = ApplyStateAsync(_connectivityService.NetworkAccess);
}
private void OnConnectivityChanged(object? s, ConnectivityChangedEventArgs e)
=> _ = ApplyStateAsync(e.NetworkAccess);
private async Task ApplyStateAsync(NetworkAccess access)
{
if (access != NetworkAccess.Internet)
await _popupService.ShowNoInternetAsync();
else
await _popupService.HideNoInternetAsync();
}
public ValueTask DisposeAsync()
{
_connectivityService.ConnectivityChanged -= OnConnectivityChanged;
return ValueTask.CompletedTask;
}
}
こうしておけば、TurbinesCollectionPageViewModel や ChargingStationsMapPageViewModel が何個生成されても、接続監視の購読数は増えません。ポップアップの出し入れは必ず1回分のみ実行されます。
「基底 ViewModel で購読」パターンの落とし穴と回避
- 落とし穴:基底クラスのコンストラクタで購読すると、派生 VM の数だけ購読される。
- 回避:購読は サービスに集約。どうしても基底で行うなら
Dispose/OnDisappearingで確実に解除する。
どうしても基底クラスで購読したい場合の最小限のガード例です(推奨はサービス集約)。
public abstract class BaseViewModel : IAsyncDisposable
{
private readonly IConnectivity _connectivity;
private bool _subscribed;
protected BaseViewModel(IConnectivity connectivity)
{
_connectivity = connectivity;
SubscribeOnce();
}
private void SubscribeOnce()
{
if (_subscribed) return;
_subscribed = true;
_connectivity.ConnectivityChanged += OnConnectivityChanged;
}
protected virtual void OnConnectivityChanged(object? s, ConnectivityChangedEventArgs e) {}
public ValueTask DisposeAsync()
{
if (_subscribed)
_connectivity.ConnectivityChanged -= OnConnectivityChanged;
_subscribed = false;
return ValueTask.CompletedTask;
}
}
ただし、この方式は「基底 VM が複数インスタンス化されない」という前提に依存します。画面遷移や DI スコープによっては破綻するため、設計上はサービス集約が堅牢です。
重複発火をさらに抑える実務テクニック
状態キャッシュで distinctUntilChanged 相当を実装
private NetworkAccess _last;
private async Task ApplyStateAsync(NetworkAccess current)
{
if (current == _last) return; // 変化なしは無視
_last = current;
if (current == NetworkAccess.Internet)
await _popupService.HideNoInternetAsync();
else
await _popupService.ShowNoInternetAsync();
}
デバウンス(急連続イベントの平滑化)
Wi‑Fi ⇔ セルラーの切り替えで短時間に多重発火するデバイスがあります。デバウンスで UX を安定化できます。
private readonly TimeSpan _debounce = TimeSpan.FromMilliseconds(250);
private CancellationTokenSource? _cts;
private void OnConnectivityChanged(object? s, ConnectivityChangedEventArgs e)
{
_cts?.Cancel();
var cts = _cts = new CancellationTokenSource();
Task.Delay(_debounce, cts.Token)
.ContinueWith(t =>
{
if (t.IsCanceled) return;
MainThread.BeginInvokeOnMainThread(() => _ = ApplyStateAsync(e.NetworkAccess));
});
}
アプリ起動・復帰直後の初期反映
ConnectivityService 生成時に現在の NetworkAccess を取り込み、シェル VM 側で即時反映しておくと「初回だけポップアップが出ない/閉じない」問題を防げます(上記コードは対応済み)。
最小再現コード(原因の見える化)
重複登録の破壊力を理解するには再現が一番です。以下のコードは「基底 VM のコンストラクタで購読」し、派生 VM を2個生成して同じイベントへ2回購読する例です。
public abstract class BaseVm
{
protected readonly IConnectivity Connectivity;
protected BaseVm(IConnectivity connectivity)
{
Connectivity = connectivity;
Connectivity.ConnectivityChanged += (s, e) => Console.WriteLine($"Changed: {e.NetworkAccess}");
}
}
public sealed class VmA : BaseVm { public VmA(IConnectivity c) : base(c) {} }
public sealed class VmB : BaseVm { public VmB(IConnectivity c) : base(c) {} }
// 実行:new VmA(Connectivity.Current); new VmB(Connectivity.Current);
// 切替時に Console 出力が2回ずつ発生
設計原則と実装チェックリスト
- 単一責任原則(SRP): ViewModel は「UIロジック」。接続監視はサービスに分離。
- ライフタイム: 監視サービスは Singleton。ページ用 ViewModel は Transient/Scoped でも OK。
- 解除: どこかで
+=したら必ず-=。シングルトン集約なら解除箇所は一つ。 - UI スレッド: ポップアップ操作は
MainThread.BeginInvokeOnMainThreadで実行。 - 重複抑止: 前回状態のキャッシュ、デバウンス、プロファイル比較で過剰反応を抑制。
- テスト: 擬似切替(#if DEBUG)で UI の状態遷移と多重表示が起きないことを確認。
よくある質問(FAQ)
Q. static bool フラグで多重購読を防げませんか?
A. 一見動きますが、スレッドセーフではなく、テストやホットリロードで再初期化された場合に破綻します。アプリのライフサイクルや DI のライフタイムを跨いだ整合性を保つには、シングルトンサービス化が堅牢です。
Q. WeakEventManager は必須ですか?
A. 必須ではありませんが、購読側(ViewModel)の解除漏れに対して強くなります。上流(サービス)側のイベント公開に WeakEventManager を使うのは良い実務策です。
Q. プラットフォーム差でイベント発火数が違う気がします
A. あります。Wi‑Fi ハンドオーバーの挙動や権限ダイアログ直後のプロファイル変化などで差が出ます。debounce と distinctUntilChanged を組み合わせて UX を安定化させましょう。
Q. ページごとに「オフライン時の個別 UI」を出したい場合は?
A. 監視はサービスに集約したまま、各ページは「状態の読み取り」に徹します。例えば IConnectivityService.NetworkAccess をバインディングして、ページ固有の表示/非表示を制御します。購読をページで増やさないのがコツです。
実運用のための拡張:状態の公開とバインディング
イベントだけでなく「状態」をプロパティとして公開すると、XAML バインディングで表現力が広がります。
public interface IConnectivityService
{
event EventHandler<ConnectivityChangedEventArgs>? ConnectivityChanged;
NetworkAccess NetworkAccess { get; }
bool IsOnline { get; }
}
public sealed class ConnectivityService : IConnectivityService, IDisposable, INotifyPropertyChanged
{
// …(前掲と同様)
public bool IsOnline => NetworkAccess == NetworkAccess.Internet;
// 状態が変わったら PropertyChanged を発火
private void RaisePropertyChanges()
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsOnline)));
public event PropertyChangedEventHandler? PropertyChanged;
private void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
{
if (IsDuplicate(e)) return;
MainThread.BeginInvokeOnMainThread(() =>
{
RaisePropertyChanges();
_wem.HandleEvent(this, e, nameof(ConnectivityChanged));
});
}
}
これにより、XAML 側で {Binding Source={x:Reference ConnectivityService}, Path=IsOnline} のように直接バインドできます(サービスをリソースに公開する、または VM 経由でプロパティ転送するなどの方法が取れます)。
サンプル:XAML とコマンドの連携
// VM 側(IsOnline の変化に応じてコマンドの可否を更新)
public partial class PageViewModel : ObservableObject
{
private readonly IConnectivityService _connectivity;
public IRelayCommand SyncCommand { get; }
public PageViewModel(IConnectivityService connectivity)
{
_connectivity = connectivity;
SyncCommand = new RelayCommand(ExecuteSync, () => _connectivity.IsOnline);
_connectivity.ConnectivityChanged += (s, e) => SyncCommand.NotifyCanExecuteChanged();
}
private void ExecuteSync()
{
// オンライン前提の同期処理
}
}
誤りがちなアンチパターンと代替案
| アンチパターン | 懸念 | 代替 |
|---|---|---|
各 ViewModel のコンストラクタで ConnectivityChanged += | VM 数に比例して多重購読、解除漏れリスク | シングルトンの ConnectivityService へ集約し、VM はそのイベントを購読 |
static bool で「一度だけ購読」を擬似実現 | 状態がプロセスに貼り付き、ホットリロード・テストで破綻 | DI のライフタイムで制御(Singleton) |
| UI スレッドを意識せずにポップアップ操作 | 例外・チラつき・競合 | MainThread.BeginInvokeOnMainThread で確実に UI スレッドへ |
| イベントのスパムでポップアップがパカパカ切り替わる | UX 悪化 | デバウンスと distinctUntilChanged(前掲コード) |
デバッグの勘所と運用チェック
- ログに 購読回数 を出す(サービス初期化時に
Guidを発行して識別)。 - 機内モード/Wi‑Fi トグルを短周期で切り替え、デバウンスの効きを確認。
- アプリ再開(フォアグラウンド復帰)時も状態が正しく反映されるかを確認。
- Android と iOS で挙動差がないか(特にプロファイル列挙)。
まとめ
複数回発火の根本原因は「同じイベントへ複数の場所から購読している」ことです。接続監視をシングルトンの ConnectivityService に集約し、ViewModel 側は「通知を受けて UI を更新」に徹するだけで、購読はアプリ全体で一度きりになります。さらに、UI スレッドでの実行/解除漏れ対策/デバウンス+重複抑止を合わせれば、ポップアップ表示の二重化やチラつきは解消され、安定したオンライン/オフライン UX を提供できます。今日、設計をこの形に寄せるだけで、将来の機能追加や画面追加でも「また複数回発火…」に悩まされることはなくなります。

コメント