.NET MAUI Mapがオフライン復帰後に更新されない原因と対策:再生成で真っ白を解消

.NET MAUI の maps:Map(地図コントロール)を使っていると、通信断(オフライン)からオンラインに戻ったのに地図が真っ白のまま復帰しないことがあります。ページ遷移でリフレッシュしたり、ConnectivityChanged や Messenger で再読み込みを試しても効かない場合、ポイントは「Map の再通信を促す」ではなく「Map を作り直す」ことです。

目次

よくある症状:オンライン復帰しても Map が更新されない

まずは状況を整理します。今回の「地図が戻らない」問題は、アプリ側でピン情報(Firebase / Firestore など)を再取得できているのに、Map 自体の表示(タイル取得やネイティブ側の描画)が復帰しないのが厄介な点です。

  • オフライン中に Map が真っ白(またはグレー)になる
  • インターネット復帰後も、そのまま表示が戻らない
  • Shell の戻る→再遷移(GoToAsync)で「ページを再表示」しても改善しない
  • ViewModel で “RefreshMap” を通知して Pins を更新しても改善しない
  • ConnectivityChanged が複数回走り、API 呼び出し・Messenger 受信が二重三重になる
  • DI のライフサイクル(Transient/Singleton)次第で _shell が null になるなど別の事故も出る

ここで重要なのは、「データの更新」と「Map コントロール自身の復帰」は別問題だということです。Pins の再取得に成功しても、Map の内部が“通信断状態のまま”で復帰しないケースがあります。

まず確認したい前提:Maps の初期化と権限

オフライン復帰の問題とは別に、前提設定が不足していると「いつでも真っ白」に見えるため、最低限のチェックポイントを押さえます。

チェック項目確認内容補足
Maps の有効化MauiProgram で UseMauiMaps() を呼んでいるか呼び忘れると、端末や環境によって表示が不安定になりやすい
ネットワーク権限Android の INTERNET 権限があるか通常は自動で付与されますが、カスタム設定で外していないか確認
位置情報権限IsShowingUser を true にする場合は権限取得を実装しているか権限エラーを握りつぶすと「真っ白に見える」誤解が起きやすい
public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();

        builder
            .UseMauiApp<App>()
            .UseMauiMaps(); // これが前提

        return builder.Build();
    }
}

ページ遷移での“リフレッシュ”が効かない理由

Shell のナビゲーション(戻る→再遷移)でページを作り直したつもりでも、実際には次のような理由で「要素が再生成されていない」「ネイティブ側が再初期化されていない」ことがあります。

試したこと狙い効かないことがある理由
Shell で戻る→GoToAsync で再遷移ページを再読み込みしたいページのキャッシュや再利用が働き、Map のネイティブビューが破棄されない/内部状態が引き継がれる可能性
OnAppearing で初期化処理表示のたびに再初期化OnAppearing は何度も呼ばれるため、イベント購読や Messenger 登録が積み上がりやすい(結果として二重実行)
InvalidateMeasure()レイアウト再計算=再描画“描き直し要求”は出せても、Map のタイル取得などのネットワークリクエストが再開されない場合がある

つまり「ページを再表示した」「再描画を要求した」という操作は、Map がネットワークを再開することを保証しません。ネイティブ地図(Android の Google Maps / iOS の MapKit など)を内包するコントロールは、内部の状態遷移がアプリ側から見えにくく、通信断のタイミング次第で復帰しないことがあります。

結論:Map を UI ツリーから外して作り直す

実運用で安定しやすい回避策は、オンライン復帰時にMap を一度破棄し、新しいインスタンスを作って差し替える方法です。これにより、ネイティブビューを含む内部状態がリセットされ、タイル取得が再開されやすくなります。

設計のコツは次の通りです。

  • XAML で Map を固定配置しない(親コンテナだけ置く)
  • オフライン時は Map を外し、代わりに「オフライン中」の案内を見せる
  • オンライン復帰時に新しい Map を生成し、ピンを再適用する
  • UI の再生成は Page(code-behind)が担当し、ViewModel は状態通知に徹する

実装例:MapHost コンテナ方式で再生成する

以下は「Map を作り直す」ためのベース実装です。アプリの規模に合わせて調整してください。

XAML:Map を直接置かず、ホスト用コンテナを用意する

<ContentPage
    x:Class="YourApp.Views.MapPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">


<Grid>
    <!-- Map を差し替えるためのホスト -->
    <Grid x:Name="MapHost" />

    <!-- オフライン表示(Map を外している間に見せる) -->
    <Grid x:Name="OfflineOverlay"
          IsVisible="False"
          BackgroundColor="#80000000">
        <VerticalStackLayout HorizontalOptions="Center"
                             VerticalOptions="Center"
                             Spacing="12">
            <Label Text="オフラインです"
                   FontSize="20"
                   TextColor="White"
                   HorizontalTextAlignment="Center" />
            <Label Text="通信が復帰すると地図を再読み込みします。"
                   FontSize="14"
                   TextColor="White"
                   HorizontalTextAlignment="Center" />
        </VerticalStackLayout>
    </Grid>
</Grid>


 

code-behind:Map の生成・破棄を一箇所に集約する

Map の差し替えは UI の責務なので、Page 側で行うのが安全です。ViewModel は「オンライン復帰した」「ピンの最新データが取れた」といった通知だけに留めます。

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Devices.Sensors;
using Microsoft.Maui.Networking;

namespace YourApp.Views;

public partial class MapPage : ContentPage
{
    private Map? _map;
    private bool _subscribed;
    private bool _rebuilding;
    private CancellationTokenSource? _debounceCts;

    public MapPage()
    {
        InitializeComponent();
    }

    protected override void OnAppearing()
    {
        base.OnAppearing();

        // 何度も OnAppearing が呼ばれても購読が増えないようにガードする
        if (!_subscribed)
        {
            Connectivity.Current.ConnectivityChanged += OnConnectivityChanged;
            _subscribed = true;
        }

        var access = Connectivity.Current.NetworkAccess;
        ApplyConnectivityState(access);

        // オフラインなら Map は外して案内を出す(見た目が安定する)
        if (access != NetworkAccess.Internet)
        {
            RemoveMap();
            return;
        }

        // 初回表示(または Map が無い場合)に生成
        if (_map == null)
        {
            BuildNewMap();
        }
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();

        // 画面を離れるタイミングで購読解除(多重実行の典型原因を潰す)
        if (_subscribed)
        {
            Connectivity.Current.ConnectivityChanged -= OnConnectivityChanged;
            _subscribed = false;
        }

        _debounceCts?.Cancel();
        _debounceCts?.Dispose();
        _debounceCts = null;
    }

    private void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
    {
        // Wi-Fi/モバイル切替などで連続発火しやすいのでデバウンスする
        _debounceCts?.Cancel();
        _debounceCts?.Dispose();
        _debounceCts = new CancellationTokenSource();
        var token = _debounceCts.Token;

        MainThread.BeginInvokeOnMainThread(async () =&gt;
        {
            try
            {
                await Task.Delay(800, token); // 0.8秒だけ様子を見る(好みで調整)
                ApplyConnectivityState(e.NetworkAccess);

                if (e.NetworkAccess == NetworkAccess.Internet)
                {
                    // オンライン復帰:Map を作り直す
                    await RebuildMapAsync();
                }
                else
                {
                    // オフライン:Map を外す(真っ白のまま残すより案内を出す)
                    RemoveMap();
                }
            }
            catch (OperationCanceledException)
            {
                // デバウンス中に再発火しただけなので何もしない
            }
        });
    }

    private void ApplyConnectivityState(NetworkAccess access)
    {
        OfflineOverlay.IsVisible = access != NetworkAccess.Internet;
    }

    private void BuildNewMap()
    {
        // 重要:Map を必ず新規インスタンスで作る
        var map = new Map
        {
            IsShowingUser = false,
            MapType = MapType.Street
        };

        // 初期表示位置(必要に応じて)
        map.MoveToRegion(MapSpan.FromCenterAndRadius(
            new Location(35.681236, 139.767125), // 東京駅あたり(例)
            Distance.FromKilometers(3)));

        MapHost.Children.Clear();
        MapHost.Children.Add(map);

        _map = map;
    }

    private void RemoveMap()
    {
        MapHost.Children.Clear();
        _map = null;
    }

    private async Task RebuildMapAsync()
    {
        if (_rebuilding) return;
        _rebuilding = true;

        try
        {
            // いったん外してから作り直すことでネイティブビューを確実に作り直す
            RemoveMap();

            // ちょっと待ってから再生成すると安定する端末もある(必要なら調整)
            await Task.Delay(50);

            BuildNewMap();

            // ここで ViewModel から最新ピンを取得して適用する
            // 例:BindingContext が VM の場合にメソッドを呼ぶ、または Messenger で受け取ったキャッシュを適用する
            if (BindingContext is IMapPinsProvider pinsProvider)
            {
                var pins = await pinsProvider.GetLatestPinsAsync();
                ApplyPins(pins);
            }
        }
        finally
        {
            _rebuilding = false;
        }
    }

    private void ApplyPins(System.Collections.Generic.IEnumerable&lt;PinInfo&gt; pins)
    {
        if (_map == null) return;

        _map.Pins.Clear();

        foreach (var p in pins)
        {
            _map.Pins.Add(new Pin
            {
                Label = p.Title,
                Address = p.Subtitle,
                Location = new Location(p.Latitude, p.Longitude)
            });
        }
    }
}

// ViewModel 側に実装してもらう想定の “ピン供給インターフェース”
public interface IMapPinsProvider
{
    Task&lt;System.Collections.Generic.IReadOnlyList&lt;PinInfo&gt;&gt; GetLatestPinsAsync();
}

public record PinInfo(string Title, string Subtitle, double Latitude, double Longitude);

ポイントは次の 3 つです。

  • Map は常に新しいインスタンスを作る(既存 Map の “更新” を期待しない)
  • ConnectivityChanged はデバウンスする(連続発火・二重実行を潰す)
  • イベント購読は必ず解除する(画面遷移で登録が積み上がるのを防ぐ)

MVVM の整理:ViewModel は「状態通知」、UI は Page が担当

Map の再生成は UI 操作そのものです。ViewModel が Page を作り直したり、Map のインスタンスを差し替えたりすると、テストしづらくなり、DI やライフサイクルの問題が増えます。そこで役割を分けます。

レイヤーやることやらないこと
ViewModelオンライン復帰検知、Firestore からピンを再取得、状態(Pins)の保持、UI に通知Map の生成・破棄、Shell ナビゲーションの直接操作、Page の参照
Page(View)Map の差し替え、Pins の適用、オフライン表示の切替、イベント購読の管理Firestore 直叩き、ビジネスロジックの肥大化

「通知」は MVVM Toolkit の WeakReferenceMessenger を使うのが分かりやすいですが、登録の多重化が最も事故りやすい点です。ページや VM が生き残る(キャッシュされる)と、古い登録が残り、オンライン復帰のたびに同じ処理が複数回走ります。

Messenger を使う場合の “多重登録” を防ぐ書き方

登録する場所は原則「コンストラクタ」か「OnAppearing の初回のみ」です。OnAppearing のたびに Register しないようにします。また解除(Unregister)も必ず入れます。

using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging.Messages;

public sealed class RefreshMapMessage : ValueChangedMessage&lt;bool&gt;
{
    public RefreshMapMessage(bool value) : base(value) {}
}

// Page 側(例)
public partial class MapPage : ContentPage
{
    private bool _messengerRegistered;

    protected override void OnAppearing()
    {
        base.OnAppearing();

        if (!_messengerRegistered)
        {
            WeakReferenceMessenger.Default.Register&lt;RefreshMapMessage&gt;(this, async (r, m) =&gt;
            {
                if (m.Value)
                {
                    await RebuildMapAsync();
                }
            });
            _messengerRegistered = true;
        }
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();

        if (_messengerRegistered)
        {
            WeakReferenceMessenger.Default.Unregister&lt;RefreshMapMessage&gt;(this);
            _messengerRegistered = false;
        }
    }
}

ViewModel 側は、オンライン復帰やピンの再取得完了などのタイミングでメッセージを投げます。UI の再生成は Page が引き受けるため、ViewModel から UI に触れずに済みます。

「複数回呼ばれる」問題を潰すチェックリスト

ConnectivityChanged や Messenger が二重三重に走ると、地図の再生成や Firestore 再取得が重複して、バッテリーや通信量を消耗します。原因と対策をまとめます。

典型原因起きる症状実践的な対策
OnAppearing で毎回 ConnectivityChanged += している画面を開くたびにイベントが増え、復帰時に複数回発火ガードフラグを置く/OnDisappearing で必ず -= する
Messenger.Register を複数回しているRefreshMap が 2回、3回…と呼ばれるRegister は一度だけ/Unregister を必ず入れる
通信状態が短時間に揺れる(Wi-Fi⇄モバイル)オンライン復帰を何度も検知して処理が重複デバウンス(遅延)/最後の状態だけ採用
非同期処理の多重実行(await 前に再度呼ばれる)Firestore 再取得が並列に走り、描画も競合SemaphoreSlim/再入防止フラグ/IAsyncRelayCommand の並列制御

オンライン復帰を “一回だけ処理する” ための実装パターン

例えば ViewModel 側で Firestore を叩く部分に再入防止を入れておくと、UI 側の揺れに引っ張られにくくなります。

private readonly SemaphoreSlim _refreshLock = new(1, 1);

public async Task RefreshPinsAsync()
{
    if (!await _refreshLock.WaitAsync(0))
        return;

    try
    {
        // ここで Firestore からピン一覧を取り直す
        // Pins を更新したら Messenger 等で「ピン更新完了」を通知する
    }
    finally
    {
        _refreshLock.Release();
    }
}

UI 側で Map を作り直す前に、ViewModel 側で「ピン更新完了」を待ってから適用するようにしてもよいです。どちらが良いかは、要件(復帰の体感速度、データ量、Firestore の応答)で決めましょう。

DI と Shell:_shell が null になる事故を避ける

「_shell が null になった」という話が出る場合、Shell(View)を DI 経由で注入していることが原因になりがちです。Shell はアプリの UI ルートであり、DI のライフサイクルと一致しないと参照が切れます。

おすすめは、次のどちらかです。

  • ViewModel からのナビゲーションは Shell.Current を使う(依存注入しない)
  • ナビゲーションを抽象化した INavigationService を作り、内部で Shell.Current を使う

後者にしておくと、将来的に Shell 以外のナビゲーション実装に変えたくなった時も差し替えが楽になります。

public interface INavigationService
{
    Task GoToAsync(string route, bool animate = true);
    Task GoBackAsync(bool animate = true);
}

public sealed class ShellNavigationService : INavigationService
{
    public Task GoToAsync(string route, bool animate = true)
        => Shell.Current.GoToAsync(route, animate);

    public Task GoBackAsync(bool animate = true)
        => Shell.Current.GoToAsync("..", animate);
}

DI 登録はアプリの規模にもよりますが、ナビゲーションサービスは Singleton で問題になりにくいです(Shell.Current は参照時に解決されるため)。

地図が復帰しないときに、UX を落とさない工夫

Map を外して作り直す方式は安定しやすい一方、復帰の瞬間に一瞬空白が見えることがあります。運用での違和感を減らすために、次の工夫が効きます。

  • オフライン時は地図ではなく案内を表示(「読み込み中」より理由が分かりやすい)
  • オンライン復帰後に 少しだけ遅延して再生成(ネットワークが安定してから実行)
  • 直近の中心座標や半径(Distance)を保存し、再生成後に 同じ領域へ MoveToRegion
  • ピン数が多い場合は 差分更新よりも「必要領域だけ再描画」する設計に寄せる

特に「中心座標の復元」は体感品質に直結します。ユーザーが見ていた場所が毎回初期位置に戻ると、復帰はしてもストレスになります。

中心座標・ズーム相当を保存する考え方

MapSpan の厳密なズーム値はプラットフォーム差がありますが、中心位置と半径(Distance)を保存して復元するだけでも十分です。

// 例:最後に表示していた領域を保持する
private Location? _lastCenter;
private double _lastRadiusKm = 3;

private void SaveCurrentRegion()
{
    if (_map?.VisibleRegion == null) return;

    _lastCenter = _map.VisibleRegion.Center;
    // ここでは簡易に緯度差から半径を推定するなど、アプリ都合で調整
}

private void RestoreRegion()
{
    if (_map == null || _lastCenter == null) return;

    _map.MoveToRegion(MapSpan.FromCenterAndRadius(
        _lastCenter,
        Distance.FromKilometers(_lastRadiusKm)));
}

それでも直らない場合の切り分けポイント

Map を作り直しても復帰しない場合、次の観点で切り分けると原因に近づけます。

  • オフライン復帰直後の NetworkAccess は Internet になっているか(Intranet / Local などになっていないか)
  • DNS が不安定な環境で一時的にタイル取得が失敗していないか(復帰直後に 1回失敗→そのまま停止するケース)
  • Android の場合:Google Play 開発者サービスや地図関連の依存が端末固有で不安定になっていないか
  • iOS の場合:ネットワーク権限の状態変化で MapKit 側の初期化が失敗していないか
  • Map を包むレイアウトが極端に複雑で、Size が 0 のままになっていないか

また、ログが取れるなら「ConnectivityChanged の時系列」「Map 再生成の回数」「Firestore 再取得の回数」を記録しておくと、二重実行の根本原因を見つけやすくなります。

代替案:地図を別実装に切り替える判断基準

組み込みの Map は手軽ですが、アプリ要件によっては他の実装が向いているケースもあります。

要件検討したい方向性理由
オフラインでも地図を表示したいオフラインタイル対応の SDK / 仕組み標準 Map の挙動だけでは「通信なしでの地図表示」は難しい
ピンが非常に多い・クラスタリング必須クラスタリング機能のあるライブラリ標準 Pins は規模が大きいと描画負荷が上がりやすい
表示カスタムが多い(スタイル、レイヤー等)WebView + JS 地図(Leaflet 等)自由度が高く、制御しやすい

ただし、まずは今回のように「オフライン復帰で真っ白になる」問題を再生成で安定させるのが現実的です。要件が増えてきたタイミングで、代替案を比較検討すると失敗しにくいです。

まとめ:Map の復帰は「更新」ではなく「再初期化」で考える

.NET MAUI の maps:Map は、オフライン→オンライン復帰時に内部状態が戻らず、ページ遷移や再描画では復旧しないことがあります。このときは「Refresh が無いなら作り直す」という割り切りが最も実装コストと安定性のバランスが良い解決策です。

  • Shell の再遷移でのリフレッシュは保証されない
  • InvalidateMeasure は描画要求であり、Map の再通信を保証しない
  • オンライン復帰時は Map を破棄→再生成し、Pins を再適用する
  • 二重実行は「購読解除」「多重登録防止」「デバウンス」で潰す
  • Shell/DI は View と Service の境界を意識して事故を避ける

この方針で実装しておくと、通信状態が揺れる現場環境でも地図画面が安定し、ユーザー体験と保守性の両方を改善できます。

この記事を書いた人

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

コメント

コメントする

目次