更新日: 2026年9月6日
.NET MAUI 10でバックグラウンド処理を実装するには、iOSはBGTaskScheduler(BGAppRefreshTask/BGProcessingTask)、AndroidはAndroidX WorkManager とForeground Service (Android 15以降はforegroundServiceType必須)を、プラットフォーム固有プロジェクトから呼び出すのが正解です。MAUIには「Xamarin.EssentialsのDependencyServiceで1行」のようなショートカットはありません。各OSのスケジューラAPIを直接叩き、共通コードからはpartial classもしくはDIで注入したインターフェース経由で呼び出す設計が2026年時点で最も安定します。
iOSはBGTaskScheduler一択。UIBackgroundModesとBGTaskSchedulerPermittedIdentifiersの両方をInfo.plistに記載しないと登録時にクラッシュします。
Androidは短時間・遅延可の処理はWorkManager 2.10、ユーザーが認識できる継続処理はForeground Serviceを使い分けます。
Android 15(API 35)以降はforegroundServiceTypeの宣言と実行時の型指定が必須。dataSyncは6時間の合計上限が加わりました。
Doze・App Standby下ではWorkManagerのメンテナンスウィンドウにしか実行されないため、通知付きForeground Serviceかサイレントプッシュで補完します。
MAUIの共通コードから呼ぶ場合、partial classによるプラットフォーム分岐か、DIコンテナ経由の抽象化のどちらかを一貫させると保守が楽になります。
デバッグはiOSは_simulateLaunchForTaskWithIdentifier:、Androidはadb shell cmd jobscheduler runで強制実行できます。
目次
なぜ.NET MAUIのバックグラウンド処理は難しいのか
iOS BGTaskSchedulerの実装手順
Android WorkManagerを.NET MAUIから使う
Android 14/15のForeground Service制限に対応する
共通コードから呼び出すクロスプラットフォーム設計
DozeとApp Standbyを回避してタスクを確実に走らせる
サイレントプッシュでバックグラウンド処理を起動する
バックグラウンド処理のテストとデバッグ手順
よくある質問
なぜ.NET MAUIのバックグラウンド処理は難しいのか
結論から言うと、.NET MAUIには「バックグラウンドタスク」という単一の抽象が存在しないからです。Microsoft.Maui.EssentialsにはGeolocationやSecureStorageのような便利APIは並んでいますが、iOSとAndroidの背景実行モデルがあまりにも別物なので、Xamarin時代からずっと共通抽象は提供されないままです。実務では、AppleとGoogleがそれぞれ「アプリを勝手に起こす権利」をどう定義しているかを直接理解する必要があります。
iOS側はBackgroundTasksフレームワーク を通じて、OSが電源・ネットワーク・ユーザー行動から判断した「良いタイミング」でしかコードを走らせません。開発者が「10分ごとに実行」と指定しても、iOSはそれを「ヒント」としか扱いません。一方Android側はWorkManager が推奨API群ですが、Doze・App Standby・OEM独自のバッテリー最適化(Xiaomi、OPPOなど)で実行タイミングが遅延します。ユーザーに見える通知を出せばForeground Serviceで即時実行できますが、Android 15ではさらにforegroundServiceTypeごとの制限が加わりました。
この非対称性を無視して「WorkManagerと同じ感覚でiOSも書ける」と考えると、審査でリジェクトされるか、Doze中に一切走らないゾンビアプリになります。以降は各プラットフォームを分けて実装します。
iOS BGTaskSchedulerの実装手順
iOS 13以降、バックグラウンド実行の正規APIはBGTaskSchedulerです。旧来のUIApplication.SetMinimumBackgroundFetchIntervalはiOS 13でdeprecatedになり、iOS 17でシミュレータからも消えました。BGAppRefreshTask(最大30秒程度・データ更新用)とBGProcessingTask(数分〜数十分・重い処理・充電中要求可)の2種類を使い分けます。
Info.plistとEntitlementsの設定
Platforms/iOS/Info.plistに以下を追加します。identifierはリバースドメイン形式で、後述の登録コードと一致させる必要があります。
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.example.mauiapp.refresh</string>
<string>com.example.mauiapp.cleanup</string>
</array>
警告: BGTaskSchedulerPermittedIdentifiersを書き忘れると、Register呼び出し時にNSInternalInconsistencyExceptionで即クラッシュします。Xcodeログには「All launch handlers must be registered before application finishes launching」と出ますが、原因はほぼこの設定漏れです。
AppDelegate相当での登録コード
MAUIには明示的なAppDelegateはありませんが、MauiProgramから生成されるMauiUIApplicationDelegateをpartialで拡張します。Platforms/iOS/AppDelegate.csを作成し、FinishedLaunchingで必ずタスクを登録します。
using BackgroundTasks;
using Foundation;
using UIKit;
namespace MyMauiApp;
[Register(nameof(AppDelegate))]
public partial class AppDelegate : MauiUIApplicationDelegate
{
const string RefreshTaskId = "com.example.mauiapp.refresh";
const string CleanupTaskId = "com.example.mauiapp.cleanup";
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
BGTaskScheduler.Shared.Register(RefreshTaskId, null, task =>
HandleAppRefresh((BGAppRefreshTask)task));
BGTaskScheduler.Shared.Register(CleanupTaskId, null, task =>
HandleProcessing((BGProcessingTask)task));
return base.FinishedLaunching(app, options);
}
public override void DidEnterBackground(UIApplication application)
{
ScheduleAppRefresh();
ScheduleCleanup();
}
static void ScheduleAppRefresh()
{
var request = new BGAppRefreshTaskRequest(RefreshTaskId)
{
EarliestBeginDate = NSDate.FromTimeIntervalSinceNow(15 * 60) // 15分後以降
};
BGTaskScheduler.Shared.Submit(request, out var error);
if (error is not null)
System.Diagnostics.Debug.WriteLine($"Refresh submit failed: {error.LocalizedDescription}");
}
static void ScheduleCleanup()
{
var request = new BGProcessingTaskRequest(CleanupTaskId)
{
RequiresNetworkConnectivity = true,
RequiresExternalPower = false,
EarliestBeginDate = NSDate.FromTimeIntervalSinceNow(60 * 60)
};
BGTaskScheduler.Shared.Submit(request, out _);
}
static void HandleAppRefresh(BGAppRefreshTask task)
{
// 次回分も必ず再スケジュール(連鎖しないとOSは呼ばなくなる)
ScheduleAppRefresh();
var work = Task.Run(async () =>
{
using var cts = new CancellationTokenSource();
task.ExpirationHandler = () => cts.Cancel();
try
{
await IPlatformApplication.Current!
.Services.GetRequiredService<IBackgroundSync>()
.SyncAsync(cts.Token);
task.SetTaskCompleted(success: true);
}
catch
{
task.SetTaskCompleted(success: false);
}
});
}
static void HandleProcessing(BGProcessingTask task) { /* 同様 */ }
}
ここでハマりやすいのは、HandleAppRefresh内で次のScheduleを呼ばない とiOSがそのアプリを「必要ない」と判断して以降起こさなくなる点です。Appleの公式ドキュメント にも明記されている挙動ですが、C#サンプルの多くが省略しています。
Android WorkManagerを.NET MAUIから使う
Android側は基本的にWorkManager 2.10(2026年8月時点で安定版)を使います。WorkManagerは内部でJobSchedulerとAlarmManagerを自動選択し、Doze復帰時のバッチ実行も面倒を見てくれます。バインディングはXamarin.AndroidX.Work.Runtime を追加すれば揃います。
NuGetパッケージ追加とWorker実装
<ItemGroup Condition="$(TargetFramework.Contains('-android'))">
<PackageReference Include="Xamarin.AndroidX.Work.Runtime" Version="2.10.0.1" />
<PackageReference Include="Xamarin.AndroidX.Work.Runtime.Ktx" Version="2.10.0.1" />
</ItemGroup>
Platforms/Android/Workers/SyncWorker.csにWorkerを追加します。DoWorkは同期メソッドですが、Task.Runで待たせずに.GetAwaiter().GetResult()で受けます(WorkerはWorkManagerがスレッドを提供するため、ブロックOKです)。
using Android.Content;
using AndroidX.Work;
namespace MyMauiApp.Platforms.Android;
public class SyncWorker : Worker
{
public SyncWorker(Context context, WorkerParameters workerParams)
: base(context, workerParams) { }
public override Result DoWork()
{
try
{
var sync = IPlatformApplication.Current!
.Services.GetRequiredService<IBackgroundSync>();
sync.SyncAsync(CancellationToken.None)
.GetAwaiter().GetResult();
return Result.InvokeSuccess();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"SyncWorker failed: {ex}");
return RunAttemptCount < 3 ? Result.InvokeRetry() : Result.InvokeFailure();
}
}
}
制約付き周期実行の登録
using AndroidX.Work;
using Java.Util.Concurrent;
var constraints = new Constraints.Builder()
.SetRequiredNetworkType(NetworkType.Connected)
.SetRequiresBatteryNotLow(true)
.Build();
var request = PeriodicWorkRequest.Builder.From<SyncWorker>(
TimeSpan.FromMinutes(30))
.SetConstraints(constraints)
.AddTag("sync")
.Build() as PeriodicWorkRequest;
WorkManager.GetInstance(Platform.AppContext)
.EnqueueUniquePeriodicWork(
"background-sync",
ExistingPeriodicWorkPolicy.Keep,
request!);
補足: PeriodicWorkRequestの最小間隔は15分です。15分未満はOneTimeWorkRequestをWorkManager.enqueueUniqueWorkで再帰的にチェーンするか、Foreground Serviceで自前ループするしかありません。Google側の意図的な制限で、バッテリー保護のため変更予定はありません。
Android 14/15のForeground Service制限に対応する
ユーザー可視・継続的な処理(音楽再生、位置追跡、大きなダウンロード)にはForeground Serviceを使います。ただしAndroid 14(API 34)とAndroid 15(API 35)で規制が段階的に厳しくなっているため、2026年時点でXamarin時代のコードをそのまま持ってくると即クラッシュします。
manifestでforegroundServiceTypeを宣言する
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name=".Platforms.Android.SyncForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>
Serviceクラスと通知
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
namespace MyMauiApp.Platforms.Android;
[Service(ForegroundServiceType = ForegroundService.TypeDataSync)]
public class SyncForegroundService : Service
{
const int NotificationId = 4711;
const string ChannelId = "background-sync";
public override IBinder? OnBind(Intent? intent) => null;
public override StartCommandResult OnStartCommand(
Intent? intent, StartCommandFlags flags, int startId)
{
var channel = new NotificationChannel(
ChannelId, "同期処理", NotificationImportance.Low);
var nm = (NotificationManager)GetSystemService(NotificationService)!;
nm.CreateNotificationChannel(channel);
var notification = new Notification.Builder(this, ChannelId)
.SetContentTitle("バックグラウンド同期中")
.SetSmallIcon(Resource.Drawable.ic_sync)
.SetOngoing(true)
.Build();
// API 34以降は第3引数のtypeが必須
StartForeground(NotificationId, notification,
ForegroundService.TypeDataSync);
Task.Run(async () =>
{
try { await RunSyncAsync(); }
finally { StopSelf(startId); }
});
return StartCommandResult.Sticky;
}
}
警告: Android 15ではdataSyncタイプのForeground Serviceに24時間あたり6時間の合計実行時間 制限が付きました。長時間の同期にはWorkManagerのExpeditedWorkに切り替えるか、複数タイプを組み合わせる必要があります。詳細はAndroid 15の動作変更ドキュメント を確認してください。
共通コードから両プラットフォームを扱う方法は主に2つあります。私が5本以上のMAUIアプリで試した結論は、アプリの規模で選ぶ です。
partial class方式:ファイルが1つで済み、依存も最小。3〜4画面規模のアプリで最適。
DI経由のIBackgroundScheduler抽象:テスト時のモック差し替えが容易。.NET MAUIクリーンアーキテクチャ実践 のようなドメイン層分離アプリで必須。
DIパターンの最小実装
public interface IBackgroundScheduler
{
Task ScheduleSyncAsync(TimeSpan minimumDelay);
Task CancelSyncAsync();
}
// Platforms/iOS/IosBackgroundScheduler.cs
public class IosBackgroundScheduler : IBackgroundScheduler
{
public Task ScheduleSyncAsync(TimeSpan minimumDelay)
{
var request = new BGAppRefreshTaskRequest("com.example.mauiapp.refresh")
{
EarliestBeginDate = NSDate.FromTimeIntervalSinceNow(minimumDelay.TotalSeconds)
};
BGTaskScheduler.Shared.Submit(request, out _);
return Task.CompletedTask;
}
public Task CancelSyncAsync()
{
BGTaskScheduler.Shared.Cancel("com.example.mauiapp.refresh");
return Task.CompletedTask;
}
}
// MauiProgram.cs
#if IOS
builder.Services.AddSingleton<IBackgroundScheduler, IosBackgroundScheduler>();
#elif ANDROID
builder.Services.AddSingleton<IBackgroundScheduler, AndroidBackgroundScheduler>();
#endif
ViewModelからは_scheduler.ScheduleSyncAsync(TimeSpan.FromMinutes(15))を呼ぶだけで、iOSとAndroidで別コードが走ります。これはCommunityToolkit.Mvvm実践ガイド で紹介した[RelayCommand]とも自然に組み合わせられます。
DozeとApp Standbyを回避してタスクを確実に走らせる
Android 6.0以降のDoze modeは、画面OFF・充電なし・静止状態が続くとアプリのバックグラウンド活動をほぼ停止します。WorkManagerもこの制約下ではメンテナンスウィンドウ(数分〜数時間おき)にしか実行されません。ここは「回避する」よりも「Doze前提で設計する」のが正解です。
ExpeditedWork を使う:OneTimeWorkRequest.Builder.SetExpedited(OutOfQuotaPolicy.RunAsNonExpeditedWorkRequest)でクォータ内なら即時実行。ユーザー起点の同期に最適。
サイレントプッシュ で起こす:FCMのdata-only messageはFCM実装ガイド と同じ受信経路で、Dozeを一時的にホワイトリストから外して処理を実行できます。
電池最適化除外リクエスト :ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONSで例外申請できますが、Google Playのパーミッションポリシー で「コア機能に不可欠」な用途しか許可されません。心拍計・GPSロガー等は通ります。
OEM対策 :Xiaomi/OPPO/Vivo系は独自の「AutoStart」設定がOFFだとWorkManagerも実行されません。Don't Kill My App のガイダンスをアプリ内ヘルプに載せると問い合わせが激減します。
サイレントプッシュでバックグラウンド処理を起動する
iOSのcontent-available: 1とAndroidのdata-only FCMメッセージはどちらも、UIを出さずにコードだけ走らせるバックグラウンド起動トリガーです。定期スケジュールが「いつ走るか分からない」のに対し、プッシュ起点は「送った直後に走る」ため、サーバー起点の同期には圧倒的に向きます。
// サーバー側FCMペイロード例(datachannelのみ、notificationフィールドを含めない)
{
"message": {
"token": "<device_token>",
"data": {
"action": "sync",
"resource_id": "abc123"
},
"android": { "priority": "high" },
"apns": {
"headers": { "apns-priority": "5", "apns-push-type": "background" },
"payload": { "aps": { "content-available": 1 } }
}
}
}
iOS側で受信するには、UIBackgroundModesにremote-notificationを追加し、DidReceiveRemoteNotificationを実装します。処理完了時にcompletionHandler(UIBackgroundFetchResult.NewData)を必ず呼ばないと、iOSが以降のサイレントプッシュを配信しなくなります。ここはApple公式の背景実行ガイド に「必ず30秒以内に完了報告せよ」と書かれている通り、時間予算はかなり厳しいです。
バックグラウンド処理のテストとデバッグ手順
バックグラウンドタスクは実行タイミングがOS制御なので、放置していると「多分動いてる」で本番リジェクトを食らいます。以下の強制実行手順で必ずCIかローカルで検証してください。
iOSシミュレータでBGTaskSchedulerを強制実行
# アプリを一度起動しバックグラウンドに送った状態でLLDB接続
(lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] \
_simulateLaunchForTaskWithIdentifier:@"com.example.mauiapp.refresh"]
ADBでWorkManagerを強制起動
# Doze状態を強制付与
adb shell dumpsys deviceidle force-idle
# WorkManagerジョブを一覧
adb shell dumpsys jobscheduler | grep "com.example.mauiapp"
# 対象ジョブを即時実行
adb shell cmd jobscheduler run -f com.example.mauiapp <job_id>
加えて、実機での安定性検証には.NET MAUIテスト戦略ガイド で紹介したUIテスト基盤上に、機内モードON/OFF・充電抜き差し・OSアップデート跨ぎのシナリオを追加するのが最終防衛線です。私が担当したアプリでは、この4シナリオを外すたびに本番でバックグラウンドタスクが停止していました。
よくある質問
.NET MAUIでiOS Background Fetchはどう実装すれば良いですか?
2026年時点ではBGTaskSchedulerとBGAppRefreshTaskRequestを使います。旧来のSetMinimumBackgroundFetchIntervalはdeprecatedで、iOS 17以降のシミュレータからも削除されました。Info.plistにUIBackgroundModesとBGTaskSchedulerPermittedIdentifiersの両方を追加し、FinishedLaunchingでRegisterを呼ぶのが必須です。
Android 14でForeground Serviceがクラッシュするのはなぜですか?
API 34(Android 14)以降、StartForeground呼び出し時にforegroundServiceTypeを第3引数で明示する必要があります。Manifestの<service>要素にもandroid:foregroundServiceTypeと対応するuses-permission(例: FOREGROUND_SERVICE_DATA_SYNC)が必要です。省略するとMissingForegroundServiceTypeExceptionが投げられます。
WorkManagerとJobSchedulerはどちらを使うべきですか?
MAUIからは常にWorkManager一択です。WorkManager 2.10は内部でJobScheduler(API 23+)とAlarmManagerを自動選択し、Doze復帰時のバッチ実行やリトライも管理してくれます。JobSchedulerを直接叩くのは、WorkManagerが対応していない特殊なJobInfo設定が必要な場合だけです。
サイレントプッシュで.NET MAUIアプリのバックグラウンド処理を確実に起動できますか?
「ほぼ確実に」できますが、100%ではありません。iOSはユーザーが「バックグラウンド更新」をOFFにしていると配信されず、Androidは電池最適化ホワイトリストに入っていない場合Doze中に遅延することがあります。頻度制限もあり、iOSは1時間に数回程度に絞られます。設計時はサイレントプッシュを「即時トリガーの補助」、周期同期を「安全網」と分けるのが安定します。
.NET MAUIのバックグラウンドタスクをユニットテストできますか?
プラットフォーム依存部分(BGTaskSchedulerやWorkManager)はモックできませんが、DI経由のIBackgroundScheduler抽象と、WorkerのDoWorkが呼ぶドメインロジックを分離すれば、ドメイン側は普通のxUnitでテスト可能です。プラットフォーム部はadb shell cmd jobscheduler runやLLDBの_simulateLaunchForTaskWithIdentifier:で実機/シミュレータで統合テストします。