shuhelohelo’s blog

Xamarin.Forms多めです.

MAUIでバックグラウンド(非アクティブ時でも)で処理を継続する(Android編)

はじめに

この記事は,以前にXamarin.Formsで行った「アプリケーションが非アクティブの状態でも動作を継続させる方法」のMAUI版です.

結論から言うと、個人的な趣味でメッセージングにCommunityToolkit.MvvmのWeakReferenceMessengerを使うようにしたぐらいで、あとはほぼそのまま使えました。

shuhelohelo.hatenablog.com

各プラットフォームごとに実装が異なり,この記事はAndroidについてです.

アプリケーションが非アクティブ(アプリをユーザーが操作中ではない)ときにも,継続して処理を行うことが目的.

Androidのバックグラウンドタスクの実行制限について

Android開発者サイトにもあるようにバックグラウンドタスクの実行には以下の制限があるため,非アクティブ時には一定時間後に停止する.

developer.android.com

多くの Android アプリやサービスは同時に実行することができます。 たとえば、あるウィンドウでゲームをプレイしながら別のウィンドウでウェブをブラウジングしているときに、別のアプリで音楽を再生できます。 同時に実行するアプリが多いほど、システムに大きな負荷がかかります。 さらに多くのアプリやサービスがバックグラウンドで実行されると、システムにかかる負荷が増えて、音楽アプリが突然シャットダウンするなど、ユーザー エクスペリエンスが低下します。

Android 8.0 では、このような問題の発生頻度を抑えるため、ユーザーがアプリを直接操作していないときにアプリで実行できる動作を制限しています。 アプリの動作は次の 2 つの方法で制限されます。

- バックグラウンド サービスの制限事項: アプリがアイドル状態にある場合、バックグラウンド サービスの使用を制限します。 これは、ユーザーが認識しやすいフォアグラウンド サービスには適用されません。

- ブロードキャストの制限事項: 限定的な例外を除き、アプリはマニフェストを使用して暗黙的なブロードキャストを登録できません。 ただし、アプリは実行時にこれらのブロードキャストを登録でき、アプリを明確に対象とする明示的なブロードキャストについては、マニフェストを使って登録できます。

# 解決方法

この制限は以下の状態には適用されない.

  • 可視アクティビティがある(アクティビティが開始されているか一時停止されているかに関係なく)。
  • フォアグラウンド サービスを使用している。
  • 別のフォアグラウンド アプリが、該当アプリのいずれかのサービスにバインドされるか、該当アプリのいずれかのコンテンツ プロバイダを使用することで、該当アプリに接続している。 たとえば、別のアプリが次のいずれかのサービスにバインドされると、アプリがフォアグラウンドになります。
    • IME
    • 壁紙サービス
    • 通知リスナー
    • 音声またはテキスト サービス

このうちのForeground Serviceを使用することで,上記の制限を受けないようにすることができる.

実装

MAUIからNativeのバックグラウンドタスクを実行する

iOSAndroidではバックグラウンドタスクを実行する方法が大きく異なるため,インターフェースで共通化することができない.

Xamarin.Formsの記事のときはMessagingCenterを使ったが、今回はCommunityToolkit.MvvmWeakReferenceMessengerを利用することで,各プラットフォームのバックグラウンドタスクを開始する.

https://robgibbens.com/backgrounding-with-xamarin-forms/robgibbens.com

また,各プラットフォームで実行しているバックグラウンドタスクからデータをMAUI側に送る場合にも同様にWeakReferenceMessengerを使う.

この部分については上記のチュートリアルの通りに行う.バックグラウンドでカウントアップして,それをUIに表示するシンプルなアプリ.

MAUI側の設定

MAUI側では,Native側から送られてくるデータを受け取るようにWeakReferenceMessengerのメッセージを購読する. WeakReferenceMessengerについてはこちらの動画がとてもわかり易いです。

www.youtube.com

MainPageのコンストラクタにでも以下のように記述する. IRecipientを継承し、コンストラクタでメッセージの購読を行う。対象のメッセージはネイティブ側から1秒間隔で送られてくるTickedMessage型のメッセージ。

このメッセージを受け取ったときの処理はIRecipient<TickedMessage>.Receiveメソッドに記載のとおりで、カウントを1増やしてLabel.Textを更新する、というもの。

Button_LongRunningTaskStart_ClickedButton_LongRunningTaskStart_Clickedはそれぞれボタンが押されたときに、バックグラウンドのタスクの開始・停止を行う。これもWeakReferenceMessengerによるメッセージの受け渡しで行う。

using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging.Messages;
using Maui_AndroidForegroundService.Messages;
using Maui_AndroidForegroundService.Tasks;

namespace Maui_AndroidForegroundService;

public partial class MainPage : ContentPage, IRecipient<TickedMessage>
{
    int count = 0;

    CancellationTokenSource cts = new CancellationTokenSource();

    public MainPage()
    {
        InitializeComponent();

        WeakReferenceMessenger.Default.Register<TickedMessage>(this);
    }


    void IRecipient<TickedMessage>.Receive(TickedMessage message)
    {
        count++;

        if (count == 1)
            counter.Text = $"Clicked {count} time";
        else
            counter.Text = $"Clicked {count} times";
    }

    private async void Button_LongRunningTaskStart_Clicked(object sender, EventArgs e)
    {
#if __ANDROID__
        var message = new StartLongRunningTaskMessage("");

        WeakReferenceMessenger.Default.Send(message);
#else
        var task = new CounterTask();
        await task.RunCounter(cts.Token);
#endif

        
    }

    private void Button_LongRunningTaskStop_Clicked(object sender, EventArgs e)
    {
#if __ANDROID__
        var message = new StopLongRunningTaskMessage("");

        WeakReferenceMessenger.Default.Send(message); 
#else
        cts.Cancel();
#endif
    }
}

TickedMessageクラスはNative側から送られてくるデータを格納するためのクラス. 今回メッセージの中身として渡すものはないので、型引数を適当にstring型にしている。

using CommunityToolkit.Mvvm.Messaging.Messages;

namespace Maui_AndroidForegroundService.Messages;

public class TickedMessage : ValueChangedMessage<string>
{
    public TickedMessage(string value) : base(value)
    {
    }
}

Androidネイティブ側の設定

Native側ではForms側からバックグラウンドの開始,停止のメッセージを受け取るようにする.

MainActivity.cs内のOnCreateメソッド内にMessagingCenterのメッセージ購読処理を記述する.

メッセージを受け取った際に,サービスの開始,停止を行っている. それぞれのメッセージStartLongRunningTaskMessageStopLongRunningTaskMessage

using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using CommunityToolkit.Mvvm.Messaging;
using Maui_AndroidForegroundService.Messages;
using Maui_AndroidForegroundService.Platforms.Android;

namespace Maui_AndroidForegroundService;

[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity, IRecipient<StartLongRunningTaskMessage>, IRecipient<StopLongRunningTaskMessage>
{

    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        WeakReferenceMessenger.Default.Register<StartLongRunningTaskMessage>(this);
        WeakReferenceMessenger.Default.Register<StopLongRunningTaskMessage>(this);
    }

    void IRecipient<StartLongRunningTaskMessage>.Receive(StartLongRunningTaskMessage message)
    {
        var intent = new Intent(this, typeof(LongRunningTaskServcie));

        StopService(intent);

        if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
        {
            StartForegroundService(intent);
        }
        else
        {
            StartService(intent);
        }
    }

    void IRecipient<StopLongRunningTaskMessage>.Receive(StopLongRunningTaskMessage message)
    {
        var intent = new Intent(this, typeof(LongRunningTaskServcie));
        StopService(intent);
    }
}

LongRunningTaskServiceAndroidで今回バックグラウンドで実行されるサービスでServiceクラスを継承し,[Service]Attributeをつける.

サービスについて参考: docs.microsoft.com

これもほぼそのままですが、一点だけ変更点があります。 以下の部分でPendingIntentFlags.Immutableを指定しているところです。

PendingIntent pendingIntent = PendingIntent.GetActivity(Application.Context, _pendingIntentId, foregroundNotificationIntent, PendingIntentFlags.Immutable);
using Application = Android.App.Application;
using Android.App;
using Android.Content;
using Android.Graphics;
using Android.OS;
using Android.Runtime;
using AndroidX.Core.App;
using CommunityToolkit.Mvvm.Messaging;
using Maui_AndroidForegroundService.Messages;
using Maui_AndroidForegroundService.Tasks;
using OperationCanceledException = System.OperationCanceledException;


namespace Maui_AndroidForegroundService.Platforms.Android;

[Service]
public class LongRunningTaskServcie : Service
{
    CancellationTokenSource _cts;

    public override IBinder OnBind(Intent intent)
    {
        return null;
    }

    [return: GeneratedEnum]
    public override StartCommandResult OnStartCommand(Intent intent, [GeneratedEnum] StartCommandFlags flags, int startId)
    {
        _cts = new CancellationTokenSource();

        Task.Run(() =>
        {
            try
            {
                var counter = new CounterTask();

                counter.RunCounter(_cts.Token).Wait();
            }
            catch (OperationCanceledException)
            {
            }
            finally
            {
                if (_cts.IsCancellationRequested)
                {
                    var message = new CancelledMessage("");

                    MainThread.BeginInvokeOnMainThread(() =>
                    {
                        WeakReferenceMessenger.Default.Send(message);
                    });
                }
            }
        });

        var notification = CreateNotification();

        StartForeground(id: 1, notification: notification);

        //return base.OnStartCommand(intent, flags, startId);
        return StartCommandResult.NotSticky;
    }

    private Notification CreateNotification()
    {
        #region Create Channel
        string channelId = "ForegroundPracticeChannel_ID";
        string channelName = "ForegroundPracticeChannel_Name";
        string channelDescription = "The foreground practice channel for notifications";
        int _pendingIntentId = 1;


        NotificationManager notificationManager = (NotificationManager)Application.Context.GetSystemService(NotificationService);

        if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
        {
            var channelNameJava = new Java.Lang.String(channelName);
            var channel = new NotificationChannel(channelId, channelNameJava, NotificationImportance.Low)
            {
                Description = channelDescription,
            };
            channel.EnableVibration(false);
            notificationManager.CreateNotificationChannel(channel);
        }

        #endregion

        #region Create Notification
        Intent foregroundNotificationIntent = new Intent(Application.Context, typeof(MainActivity));

        PendingIntent pendingIntent = PendingIntent.GetActivity(Application.Context, _pendingIntentId, foregroundNotificationIntent, PendingIntentFlags.Immutable);

        NotificationCompat.Builder builder = new NotificationCompat.Builder(Application.Context, channelId)
            .SetContentIntent(pendingIntent)
            .SetContentTitle("Foreground Practice!")
            .SetContentText("Foreground service started")
            .SetOngoing(true)
            .SetColor(ActivityCompat.GetColor(Application.Context, Resource.Color.colorAccent))
            //.SetLargeIcon(BitmapFactory.DecodeResource(Application.Context.Resources, Resource.Drawable.launcher_foreground))
            //.SetSmallIcon(Resource.Drawable.launcher_foreground);
            ;
        var notification = builder.Build();
        #endregion

        return notification;
    }

    public override void OnDestroy()
    {
        if (_cts != null)
        {
            _cts.Token.ThrowIfCancellationRequested();
            _cts.Cancel();
        }
        base.OnDestroy();
    }
}

メモ:

OnstartCommandメソッドの戻り値について

www.gigas-jp.com

  ●START_NOT_STICKY
  Serviceがkillされた場合、Serviceは再起動しません。

 ●START_STICKY
  デフォルトではこの設定になります。
  Serviceがkillされた場合、直ちにServiceの再起動を行います。
  再起動時、前回起動時のIntentは再配信されず、
  複数のServiceが起動していても再起動するServiceは1つです。

 ●START_REDELIVER_INTENT
  Serviceがkillされた場合、直ちにServiceの再起動を行います。
  再起動時、最後に使用したIntentを使用します。
  また、複数のServiceが起動している場合、すべて再起動します。

 ●START_STICKY_COMPATIBLILITY
  START_STICKYの互換バージョンです。Android2.0未満ではこれがデフォルト設定です。

実際に実行するタスクはこちら.

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

namespace Maui_AndroidForegroundService.Tasks;

public class CounterTask
{
    public async Task RunCounter(CancellationToken token)
    {
        await Task.Run(async () =>
        {
            for (long i = 0; i < long.MaxValue; i++)
            {
                token.ThrowIfCancellationRequested();

                await Task.Delay(1000);
                var message = new TickedMessage("");

                MainThread.BeginInvokeOnMainThread(() =>
                {
                    WeakReferenceMessenger.Default.Send(message);
                });
            }
        }, token);
    }
}

MAUIから実行

MainPageに開始,停止を行うボタンと,バックグラウンドタスクでのカウントを表示するラベルを配置する.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="Maui_AndroidForegroundService.MainPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">

    <StackLayout>
        <Label
            x:Name="counter"
            FontSize="100"
            HorizontalOptions="Center"
            Text="{Binding Message}"
            VerticalOptions="CenterAndExpand" />
        <Button
            x:Name="Button_LongRunningTaskStart"
            Clicked="Button_LongRunningTaskStart_Clicked"
            Text="Start Task" />
        <Button
            x:Name="Button_LongRunningTaskStop"
            Clicked="Button_LongRunningTaskStop_Clicked"
            Text="Stop Task" />
    </StackLayout>

</ContentPage>

それぞれのボタンが押されたときにMessagingCenterでメッセージを送る.

Foreground Serviceとしてタスクを実行する部分

Foreground Serviceとしてタスクを実行すれば,制限がかからない.

Foreground Serviceとして実行するためにはNotificationクラスのインスタンスを渡す必要がある.

今回はすでに以下のようにNotification Channelとそれを使用するNotificationオブジェクトを作成するメソッドをLongRunningTaskServiceに用意してある.

    private Notification CreateNotification()
    {
        #region Create Channel
        string channelId = "ForegroundPracticeChannel_ID";
        string channelName = "ForegroundPracticeChannel_Name";
        string channelDescription = "The foreground practice channel for notifications";
        int _pendingIntentId = 1;


        NotificationManager notificationManager = (NotificationManager)Application.Context.GetSystemService(NotificationService);

        if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
        {
            var channelNameJava = new Java.Lang.String(channelName);
            var channel = new NotificationChannel(channelId, channelNameJava, NotificationImportance.Low)
            {
                Description = channelDescription,
            };
            channel.EnableVibration(false);
            notificationManager.CreateNotificationChannel(channel);
        }

        #endregion

        #region Create Notification
        Intent foregroundNotificationIntent = new Intent(Application.Context, typeof(MainActivity));

        PendingIntent pendingIntent = PendingIntent.GetActivity(Application.Context, _pendingIntentId, foregroundNotificationIntent, PendingIntentFlags.Immutable);

        NotificationCompat.Builder builder = new NotificationCompat.Builder(Application.Context, channelId)
            .SetContentIntent(pendingIntent)
            .SetContentTitle("Foreground Practice!")
            .SetContentText("Foreground service started")
            .SetOngoing(true)
            .SetColor(ActivityCompat.GetColor(Application.Context, Resource.Color.colorAccent))
            //.SetLargeIcon(BitmapFactory.DecodeResource(Application.Context.Resources, Resource.Drawable.launcher_foreground))
            //.SetSmallIcon(Resource.Drawable.launcher_foreground);
            ;
        var notification = builder.Build();
        #endregion

        return notification;
    }

そして,OnStartCommandメソッドで以下のようにForeground Serviceを開始する.

            //Notificationの作成(Android 8.0以降では必須)
            var notification = CreateNotification();

            //Foreground Serviceとして実行
            //id:サービスを識別するためにアプリケーション内で一意である整数値。
            //notification:サービスが実行されている間、Android がステータスバーに表示する Notification オブジェクト。
            StartForeground(id: 1, notification: notification);

ForegroundServiceを許可する

最後に,Foreground Serviceを実行するためのPermissionをAndroidManifest.xmlに追加する。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
</manifest>

実行する

これで実行すると,先程と同様にカウントアップが始まるが,今度はアプリケーションを非アクティブにしても動作は継続される.

実行中はこのように通知が表示され,ストップすると消える.

アプリを終了させてもForgroundServiceが動き続ける

アプリを終了させても明示的にForeground Serviceを終了させないとForeground Serviceだけ動き続けるようだ.

アプリ終了時にどうしたらいいか調べなきゃ。

ソースコード

github.com