.NET MAUI 10 バックグラウンド処理完全ガイド:iOS BGTaskScheduler・Android WorkManager・Foreground Service実装(2026年版)

.NET MAUI 10でバックグラウンド処理を実装するための実践ガイド。iOS BGTaskScheduler、Android WorkManager、Foreground Serviceの登録コード、Android 15の新制限対応、Doze対策、共通コード設計、adb/LLDBデバッグ手順まで、動作コード付きで解説します。

.NET MAUI 10 バックグラウンド処理ガイド(2026)

更新日: 2026年9月6日

.NET MAUI 10でバックグラウンド処理を実装するには、iOSはBGTaskSchedulerBGAppRefreshTask/BGProcessingTask)、AndroidはAndroidX WorkManagerForeground Service(Android 15以降はforegroundServiceType必須)を、プラットフォーム固有プロジェクトから呼び出すのが正解です。MAUIには「Xamarin.EssentialsのDependencyServiceで1行」のようなショートカットはありません。各OSのスケジューラAPIを直接叩き、共通コードからはpartial classもしくはDIで注入したインターフェース経由で呼び出す設計が2026年時点で最も安定します。

  • iOSはBGTaskScheduler一択。UIBackgroundModesBGTaskSchedulerPermittedIdentifiersの両方を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のバックグラウンド処理は難しいのか

結論から言うと、.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>

AppDelegate相当での登録コード

MAUIには明示的なAppDelegateはありませんが、MauiProgramから生成されるMauiUIApplicationDelegatepartialで拡張します。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は内部でJobSchedulerAlarmManagerを自動選択し、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!);

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;
    }
}

共通コードから呼び出すクロスプラットフォーム設計

共通コードから両プラットフォームを扱う方法は主に2つあります。私が5本以上のMAUIアプリで試した結論は、アプリの規模で選ぶです。

  1. partial class方式:ファイルが1つで済み、依存も最小。3〜4画面規模のアプリで最適。
  2. 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側で受信するには、UIBackgroundModesremote-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年時点ではBGTaskSchedulerBGAppRefreshTaskRequestを使います。旧来のSetMinimumBackgroundFetchIntervalはdeprecatedで、iOS 17以降のシミュレータからも削除されました。Info.plistにUIBackgroundModesBGTaskSchedulerPermittedIdentifiersの両方を追加し、FinishedLaunchingRegisterを呼ぶのが必須です。

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のバックグラウンドタスクをユニットテストできますか?

プラットフォーム依存部分(BGTaskSchedulerWorkManager)はモックできませんが、DI経由のIBackgroundScheduler抽象と、WorkerのDoWorkが呼ぶドメインロジックを分離すれば、ドメイン側は普通のxUnitでテスト可能です。プラットフォーム部はadb shell cmd jobscheduler runやLLDBの_simulateLaunchForTaskWithIdentifier:で実機/シミュレータで統合テストします。

David O'Reilly
著者について David O'Reilly

Native iOS/Android specialist turned MAUI advocate. Writes about the gritty platform details most cross-platform tutorials skip.