MAUIでバックグラウンド(非アクティブ時でも)で処理を継続する(Android編)
はじめに
この記事は,以前にXamarin.Formsで行った「アプリケーションが非アクティブの状態でも動作を継続させる方法」のMAUI版です.
結論から言うと、個人的な趣味でメッセージングにCommunityToolkit.Mvvm
のWeakReferenceMessengerを使うようにしたぐらいで、あとはほぼそのまま使えました。
各プラットフォームごとに実装が異なり,この記事はAndroidについてです.
アプリケーションが非アクティブ(アプリをユーザーが操作中ではない)ときにも,継続して処理を行うことが目的.
Androidのバックグラウンドタスクの実行制限について
Android開発者サイトにもあるようにバックグラウンドタスクの実行には以下の制限があるため,非アクティブ時には一定時間後に停止する.
多くの Android アプリやサービスは同時に実行することができます。 たとえば、あるウィンドウでゲームをプレイしながら別のウィンドウでウェブをブラウジングしているときに、別のアプリで音楽を再生できます。 同時に実行するアプリが多いほど、システムに大きな負荷がかかります。 さらに多くのアプリやサービスがバックグラウンドで実行されると、システムにかかる負荷が増えて、音楽アプリが突然シャットダウンするなど、ユーザー エクスペリエンスが低下します。 Android 8.0 では、このような問題の発生頻度を抑えるため、ユーザーがアプリを直接操作していないときにアプリで実行できる動作を制限しています。 アプリの動作は次の 2 つの方法で制限されます。 - バックグラウンド サービスの制限事項: アプリがアイドル状態にある場合、バックグラウンド サービスの使用を制限します。 これは、ユーザーが認識しやすいフォアグラウンド サービスには適用されません。 - ブロードキャストの制限事項: 限定的な例外を除き、アプリはマニフェストを使用して暗黙的なブロードキャストを登録できません。 ただし、アプリは実行時にこれらのブロードキャストを登録でき、アプリを明確に対象とする明示的なブロードキャストについては、マニフェストを使って登録できます。
# 解決方法
この制限は以下の状態には適用されない.
- 可視アクティビティがある(アクティビティが開始されているか一時停止されているかに関係なく)。
- フォアグラウンド サービスを使用している。
- 別のフォアグラウンド アプリが、該当アプリのいずれかのサービスにバインドされるか、該当アプリのいずれかのコンテンツ プロバイダを使用することで、該当アプリに接続している。 たとえば、別のアプリが次のいずれかのサービスにバインドされると、アプリがフォアグラウンドになります。
- IME
- 壁紙サービス
- 通知リスナー
- 音声またはテキスト サービス
このうちのForeground Serviceを使用することで,上記の制限を受けないようにすることができる.
実装
MAUIからNativeのバックグラウンドタスクを実行する
iOSとAndroidではバックグラウンドタスクを実行する方法が大きく異なるため,インターフェースで共通化することができない.
Xamarin.Formsの記事のときはMessagingCenter
を使ったが、今回はCommunityToolkit.Mvvm
のWeakReferenceMessenger
を利用することで,各プラットフォームのバックグラウンドタスクを開始する.
https://robgibbens.com/backgrounding-with-xamarin-forms/robgibbens.com
また,各プラットフォームで実行しているバックグラウンドタスクからデータをMAUI側に送る場合にも同様にWeakReferenceMessengerを使う.
この部分については上記のチュートリアルの通りに行う.バックグラウンドでカウントアップして,それをUIに表示するシンプルなアプリ.
MAUI側の設定
MAUI側では,Native側から送られてくるデータを受け取るようにWeakReferenceMessengerのメッセージを購読する. WeakReferenceMessengerについてはこちらの動画がとてもわかり易いです。
MainPageのコンストラクタにでも以下のように記述する. IRecipientを継承し、コンストラクタでメッセージの購読を行う。対象のメッセージはネイティブ側から1秒間隔で送られてくるTickedMessage型のメッセージ。
このメッセージを受け取ったときの処理はIRecipient<TickedMessage>.Receive
メソッドに記載のとおりで、カウントを1増やしてLabel.Textを更新する、というもの。
Button_LongRunningTaskStart_Clicked
とButton_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のメッセージ購読処理を記述する.
メッセージを受け取った際に,サービスの開始,停止を行っている.
それぞれのメッセージStartLongRunningTaskMessage
、StopLongRunningTaskMessage
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); } }
LongRunningTaskService
はAndroidで今回バックグラウンドで実行されるサービスで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メソッドの戻り値について
●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だけ動き続けるようだ.
アプリ終了時にどうしたらいいか調べなきゃ。