正直なところ、プッシュ通知ほど「ちゃんと動かすまでが長い」機能はそうそうありません。アプリのリテンションを左右する大事な部分なのに、いざ .NET MAUI 10 で実装しようとすると、Android(FCM HTTP v1 API)と iOS(APNs)でやることがバラバラで、しかも Web 上のサンプルの大半が古いまま放置されている……というのが 2026 年現在の現実です。
特に Google が 2024 年 6 月にレガシー HTTP API(fcm/send)を完全廃止して以降、フォーラム回答をそのままコピペしても通知が届かない、というハマりどころが激増しました。私自身、社内の MAUI 案件で「サーバーキーをそのまま使った古いコード」のまま PR が来てしまい、レビューで丸ごと書き直したばかりです。
というわけで本ガイドでは、.NET 10 / .NET MAUI 10(2025 年 11 月 GA) をベースに、最新の FCM HTTP v1、APNs Token-based 認証(.p8)、Plugin.LocalNotification、ASP.NET Core 側の送信エンドポイント、トークン更新フロー、よくある失敗パターンまでを 1 本でまとめました。コードはすべて MIT で、コピペしてそのまま動く最小構成にしてあります。
プッシュ通知の全体像(2026 年版)
.NET MAUI のプッシュ通知は、ざっくり次の 3 つに分解して考えると見通しが良くなります。
- リモートプッシュ(Android): FCM HTTP v1 API 経由で Google が端末に配信。レガシーサーバーキー(
AAAA…)は 2024-06-20 で完全廃止済み。サービスアカウント JSON で OAuth2 アクセストークンを発行する方式が必須です。 - リモートプッシュ(iOS / iPadOS / macOS): APNs HTTP/2、もしくは APNs プロバイダ API。Token-based 認証(.p8 鍵 + Key ID + Team ID)が証明書方式より圧倒的に楽です。
- ローカル通知: 端末側でスケジュール(リマインダー、ジオフェンス到達時など)。Plugin.LocalNotification 11.x が .NET MAUI 10 に正式対応しました。
ちなみに 2026 年現在、Microsoft 純正の Push Notifications Plugin(旧 Xamarin.Forms 系)は .NET MAUI 用には提供されていません。なので Android 側は Plugin.Firebase.CloudMessaging、もしくは素の Firebase SDK を MAUI Handler 経由で叩く形が事実上の標準になっています。
前提条件
- .NET 10 SDK(10.0.100 以降)
- Visual Studio 2026 17.14、もしくは Rider 2025.3 以降
- Apple Developer Program 有効(iOS 実機テストには必須。シミュレータだけでは詰みます)
- Firebase プロジェクト + Google サービスアカウント JSON
- Android 13 (API 33) 以上では
POST_NOTIFICATIONSランタイムパーミッションが必須
1. プロジェクトセットアップ
NuGet パッケージ
<ItemGroup>
<PackageReference Include="Plugin.Firebase.CloudMessaging" Version="3.1.3" />
<PackageReference Include="Plugin.LocalNotification" Version="11.1.4" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>
MauiProgram.cs での初期化
using Plugin.Firebase.CloudMessaging;
using Plugin.LocalNotification;
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseLocalNotification()
.ConfigureLifecycleEvents(events =>
{
#if ANDROID
events.AddAndroid(android => android
.OnCreate((activity, _) =>
CrossFirebaseCloudMessaging.Current.CheckIfValidAsync()));
#endif
});
return builder.Build();
}
2. Android 側の設定(FCM HTTP v1)
google-services.json の配置
Firebase コンソールから取得した google-services.json を Platforms/Android/ 直下に置き、ビルドアクションを GoogleServicesJson に設定します。ここを忘れて何時間も溶かす人を何人も見てきたので、ご注意を。
<ItemGroup Condition="$(TargetFramework.Contains('-android'))">
<GoogleServicesJson Include="Platforms\Android\google-services.json" />
</ItemGroup>
AndroidManifest.xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default_channel" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
</application>
MainActivity.cs(ランタイムパーミッション)
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
if (Build.VERSION.SdkInt >= BuildVersionCodes.Tiramisu)
{
if (CheckSelfPermission(Manifest.Permission.PostNotifications)
!= Permission.Granted)
{
RequestPermissions(
new[] { Manifest.Permission.PostNotifications },
requestCode: 1001);
}
}
}
3. iOS 側の設定(APNs Token-based)
Entitlements.plist
<key>aps-environment</key>
<string>production</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
開発ビルドでは development、TestFlight や App Store ビルドでは production を使い分けます。Xcode 16 以降なら、同一バンドル ID で両方扱えるようになったのでだいぶ運用が楽になりました。
AppDelegate.cs
using UIKit;
using UserNotifications;
using Plugin.Firebase.CloudMessaging;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override bool FinishedLaunching(UIApplication app, NSDictionary opts)
{
UNUserNotificationCenter.Current.RequestAuthorization(
UNAuthorizationOptions.Alert
| UNAuthorizationOptions.Sound
| UNAuthorizationOptions.Badge,
(granted, error) =>
{
if (granted)
MainThread.BeginInvokeOnMainThread(
() => app.RegisterForRemoteNotifications());
});
return base.FinishedLaunching(app, opts);
}
public override void RegisteredForRemoteNotifications(
UIApplication application, NSData deviceToken)
{
CrossFirebaseCloudMessaging.Current.ApnsToken = deviceToken.ToArray();
}
}
4. デバイストークンの取得とサーバー登録
FCM Token は端末ごとに変わるうえ、アプリ再インストールやデータ消去でしれっと更新されます。なのでサーバー側はユーザー ID と紐づけて upsert する設計が必須です(ここを INSERT だけにしてしまうと、すぐ重複と古いトークンの墓場になります)。
public partial class PushRegistrationService : ObservableObject
{
private readonly HttpClient _http;
public PushRegistrationService(IHttpClientFactory factory)
{
_http = factory.CreateClient("api");
}
public async Task RegisterAsync(CancellationToken ct = default)
{
var token = await CrossFirebaseCloudMessaging.Current.GetTokenAsync();
if (string.IsNullOrEmpty(token)) return;
var payload = new
{
DeviceToken = token,
Platform = DeviceInfo.Platform.ToString(),
AppVersion = AppInfo.VersionString,
DeviceModel = DeviceInfo.Model
};
await _http.PostAsJsonAsync("/api/push/register", payload, ct);
}
}
// トークン更新イベントの購読
CrossFirebaseCloudMessaging.Current.TokenChanged += async (s, e) =>
{
await registration.RegisterAsync();
};
5. 受信ハンドリング
フォアグラウンド受信
CrossFirebaseCloudMessaging.Current.NotificationReceived += (s, e) =>
{
// フォアグラウンドでは OS が自動表示しないため、
// ローカル通知でユーザーに見せる
LocalNotificationCenter.Current.Show(new NotificationRequest
{
NotificationId = Random.Shared.Next(),
Title = e.Notification.Title ?? "通知",
Description = e.Notification.Body ?? string.Empty,
ReturningData = JsonSerializer.Serialize(e.Notification.Data),
Android = new AndroidOptions { ChannelId = "default_channel" }
});
};
タップ起動時のディープリンク
CrossFirebaseCloudMessaging.Current.NotificationTapped += (s, e) =>
{
if (e.Notification.Data.TryGetValue("articleId", out var id))
{
Shell.Current.GoToAsync($"//articles/detail?id={id}");
}
};
6. ASP.NET Core から FCM HTTP v1 で送信する
レガシーサーバーキー方式はもう動きません。素直にサービスアカウント JSON から OAuth2 トークンを発行する FirebaseAdmin SDK を使うのが、現状いちばん近道です。
// dotnet add package FirebaseAdmin --version 3.4.0
using FirebaseAdmin;
using FirebaseAdmin.Messaging;
using Google.Apis.Auth.OAuth2;
builder.Services.AddSingleton(_ =>
{
return FirebaseApp.Create(new AppOptions
{
Credential = GoogleCredential.FromFile("firebase-service-account.json")
});
});
app.MapPost("/api/push/send", async (
PushRequest req,
FirebaseApp _) =>
{
var message = new Message
{
Token = req.DeviceToken,
Notification = new Notification
{
Title = req.Title,
Body = req.Body
},
Data = req.Data ?? new Dictionary<string, string>(),
Android = new AndroidConfig
{
Priority = Priority.High,
Notification = new AndroidNotification
{
ChannelId = "default_channel",
Sound = "default"
}
},
Apns = new ApnsConfig
{
Headers = new Dictionary<string, string>
{
["apns-priority"] = "10"
},
Aps = new Aps
{
Sound = "default",
ContentAvailable = true,
MutableContent = true
}
}
};
var id = await FirebaseMessaging.DefaultInstance.SendAsync(message);
return Results.Ok(new { messageId = id });
});
public record PushRequest(
string DeviceToken,
string Title,
string Body,
Dictionary<string, string>? Data);
同じ FirebaseMessaging インスタンスから iOS 端末にもそのまま送れます。FCM が APNs プロキシとして動いてくれるので、APNs プロバイダ API を直接叩く必要はありません。ただし VoIP や Live Activity を扱うなら APNs に直結する必要が出てくるので、そこは別物として分けて設計してください。
7. ローカル通知(Plugin.LocalNotification 11)
var notification = new NotificationRequest
{
NotificationId = 100,
Title = "服薬リマインダー",
Description = "朝の薬を服用してください",
Schedule = new NotificationRequestSchedule
{
NotifyTime = DateTime.Now.AddMinutes(30),
RepeatType = NotificationRepeat.Daily
},
Android = new AndroidOptions
{
ChannelId = "reminders",
IconSmallName = new AndroidIcon("ic_notification"),
Priority = AndroidPriority.High
},
iOS = new iOSOptions
{
PresentAsBanner = true,
PlayForegroundSound = true
}
};
await LocalNotificationCenter.Current.Show(notification);
8. ペイロード設計のベストプラクティス
- data-only メッセージを基本にして、表示は端末側でローカル通知として描画する。これだけでフォアグラウンド/バックグラウンドの挙動差をだいぶ吸収できます。
- collapse_key(Android)と apns-collapse-id(iOS)でリプレイ通知を抑制する。
- ペイロードは 4KB 以内。詳細データはサーバー API でフェッチする方式が結局いちばん安定します。
- TTL(time_to_live / apns-expiration)は 24 時間以内に抑える。何日も前の通知が今さら届くのは、ユーザー体験的にも本当に最悪なので。
9. トラブルシューティング
Android で通知が届かない
- Android 13 以上で
POST_NOTIFICATIONS権限が拒否されていないか確認 - バッテリー最適化対象になっていないか(Doze モード)
- FCM Token が
nullでないか、デバッグログで明示的に出力 google-services.jsonのパッケージ名がApplicationIdと一致しているか
iOS で通知が届かない
- Provisioning Profile に Push Notifications Capability が含まれているか
aps-environmentがdevelopment/production正しく設定されているか- APNs Auth Key (.p8) が Firebase コンソールにアップロードされているか
- Bundle ID と Team ID が一致しているか
- シミュレータでは APNs テスト不可(Xcode 14 以降なら .apns ファイルでローカルテストは可能)
FCM HTTP v1 のエラーコード対応
| エラー | 原因 | 対応 |
|---|---|---|
| UNREGISTERED | トークン無効化 | サーバー側で削除し、次回起動時に再登録 |
| INVALID_ARGUMENT | ペイロード不正 | JSON 構造を v1 仕様に合わせる |
| QUOTA_EXCEEDED | 送信レート超過 | 指数バックオフでリトライ |
| UNAVAILABLE | FCM 一時障害 | 10 秒以上待機しリトライ |
10. テスト戦略
- 単体テスト:
IPushRegistrationServiceをインターフェース化し、HttpMessageHandlerをモックする。 - 統合テスト: Firebase の
SendDryRunAsyncフラグを使って、実送信なしで検証。CI で叩いても課金されないので便利です。 - 手動テスト: Firebase Console > Cloud Messaging > テスト送信機能で個別トークンに送信。
- iOS シミュレータ:
xcrun simctl push <sim_id> <bundle_id> payload.apns
11. CI/CD への組み込み
サービスアカウント JSON は GitHub Actions の Secrets に Base64 で格納し、ビルド時に展開します。リポジトリに直接コミットする事故が一番怖いので、ここはきっちりやっておきましょう。
- name: Restore Firebase service account
run: |
echo "${{ secrets.FIREBASE_SA_BASE64 }}" | base64 -d > firebase-service-account.json
- name: Build MAUI Android
run: dotnet publish -f net10.0-android -c Release
FAQ
Q1. .NET MAUI で Azure Notification Hubs と FCM のどちらを選ぶべきですか?
マルチプラットフォーム配信、タグベース送信、登録管理を一元化したいなら Azure Notification Hubs が有利です。一方、純粋にシンプルさ・コスト・OSS のみで完結させたいなら FCM 直接利用がおすすめ。Azure Notification Hubs も内部では FCM HTTP v1 / APNs を呼んでいるので、最終配信経路は同じです。
Q2. FCM レガシー HTTP API はまだ使えますか?
使えません。Google は 2024-06-20 にレガシー HTTP API(fcm/send)と XMPP 経由の送信を完全廃止しました。https://fcm.googleapis.com/v1/projects/{project}/messages:send が唯一の正規エンドポイントで、サーバーキー(AAAA…)方式から OAuth2 アクセストークン方式への移行が必須です。
Q3. iOS で .p12 証明書と .p8 認証キーのどちらを使うべきですか?
新規実装は .p8 Token-based 認証を強く推奨します。.p8 は有効期限がなく、複数アプリで共有でき、Firebase コンソールへのアップロードも 1 回で済みます。.p12 は 1 年で失効して更新運用がしんどいので、レガシー要件以外で選ぶ理由はほぼありません。
Q4. バックグラウンドでデータ取得する Silent Push はどう実装しますか?
Android は data オンリーメッセージで FirebaseMessagingService.OnMessageReceived が呼ばれます。iOS は APNs ペイロードに "content-available": 1 を含め、Entitlements で UIBackgroundModes > remote-notification を有効化します。実行時間は約 30 秒に制限されるので、重い処理はそこに突っ込まないこと。
Q5. Plugin.Firebase.CloudMessaging が更新されない場合の代替は?
Android 側は AndroidX.Firebase.Messaging のバインディング、iOS 側は Firebase iOS SDK の手動バインディング、もしくは Microsoft が提供する Microsoft.Extensions.PushNotifications(.NET 10 でプレビュー中)への移行が候補です。とはいえ、多くの実プロジェクトでは Plugin.Firebase が今のところ最もメンテされているので、当面は素直にこちらを選んでおくのが安全だと思います。
まとめ
.NET MAUI 10 でのプッシュ通知実装は、結局のところ FCM HTTP v1 + APNs Token-based 認証 + Plugin.LocalNotification の三本柱で組み立てるのが、2026 年現在のベストプラクティスです。レガシー API 廃止以降に書かれた古い記事のコードはそのままだと動かないことが多いので、本ガイドのテンプレートを起点に、プロジェクト固有の要件(多言語、リッチ通知、ジオフェンス、Live Activity など)を一段ずつ積み上げていくのがおすすめです。