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

Xamarin.FormsでBackgroundタスク

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

www.youtube.com

この記事と動画のとおりにXamarin.FormsでAndroid,iOSの両方でBackgroundタスクを実行するテストを行った.

MessagingCenterを使用し,以下を行っている.

  • Android,iOSの各プロジェクトで実装したタスク,サービスを開始,停止
    • Sharedが送信,Nativeが受信
  • タスク,サービスからのデータの送受信
    • Nativeが送信,Sharedが受信
    • 受信したらUIを更新.

0.25秒ごとに数字をカウントアップしていく単純なもの.

「START LONG RUNNING TASK」ボタンを押すと開始する.

このアプリが非アクティブのときにも動いていることがわかるように10カウント毎に通知されるようにしてある.

日アクティブでも動作していることがわかる.

問題

しかし,一定時間(1分ぐらい?)経過すると,カウントが停止する.

これは,Android 8.0(APIレベル26以降)ではバッテリーやCPUなどのリソースの節約のために一定時間でバックグラウンドのタスクを終了するようになったため.

docs.microsoft.com

developer.android.com

もともと,バックグラウンドでも位置情報を取得し続けるために調べはじめたことで,やはり同様に位置情報の取得に関しても制限がかかるようになっていた.

developer.android.com

解決の方針

以下のページに詳しく書いてある.

developer.android.com

JobSchedulerを使う

例えば,

多くの場合、アプリではバックグラウンド サービスを JobScheduler ジョブに置き換えることができます。

とあることから,Serviceを使って動かし続けるのではなくJobSchedulerで定期的にスポット的に実行することで実現できるし,それがより良い方法のようだ.

FCM(Firebase Cloud Messaging)を使う

FCMを使ったPush通知の仕組みを使うと,アプリを実行していなくてもFCMを介したメッセージの受信ができる.

それを起点とした処理を記述することができるので,FCMから定期的にメッセージを送ればバックグラウンドで定期継続実行が実現できる.

FCM+Azure Notification Hubsを使ったPush通知の実装方法は公式ドキュメントに詳細に解説されていて,そのとおりに行えば難しくはない.

しかし,当然ネットワークにつながっている必要があったり,台数とか金額とか,ちょっとローカルでバックグラウンド処理したいだけなので,要件には合わない.

Bound Serviceを使う

バインドされたサービスというものがあり,以下のようにも書かれている.

注: これらのルールは バインドされたサービスには一切影響を与えません。 アプリでバインドされたサービスを定義している場合、アプリがフォアグラウンドにあるかどうかに関係なく、別のコンポーネントをそのサービスにバインドできます。

バインドされたサービスとは何かについては以下のページに書かれている.

developer.android.com

Xamarin.Androidでの実装についてはこちら.

docs.microsoft.com

何やら複雑そうだ. これはサービスを(端末内で)サーバーのように動作させて,リクエストとそのレスポンスという形式のようだ.

Foreground Serviceを使う

Youtubeとか動画,音楽再生アプリで再生中に上に通知が出て,実行中だと表されるもの.

docs.microsoft.com

ForegroundServiceの実装方法. シンプルでわかりやすい↓

www.gigas-jp.com

ただし,NotificationをStartForegroundメソッドにわたす際には,Channelを作成しておく必要があるので注意.

        private void CreateNotificationChannel()
        {
            _notificationManager = (NotificationManager)Android.App.Application.Context.GetSystemService(Android.App.Application.NotificationService);
            if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
            {
                var channelNameJava = new Java.Lang.String(_channelName);
                var channel = new NotificationChannel(_channelId, channelNameJava, NotificationImportance.Default)
                {
                    Description = _channelDescription,
                };
                _notificationManager.CreateNotificationChannel(channel);
            }

            _channelInitialized = true;
        }

いまのところ

サーバーのように待ち受けている必要も,リアルタイムである必要もないので,定期実行のJobSchedulerがおそらくシンプルなのではないかと思う.

結果

JobScheduler

JobSchedulerを使ったところ,一定時間経過してもバックグラウンドで動作し続けたが,最低実行間隔は後述のとおり15分間であることを忘れないように.

qiita.com

この記事の冒頭部分に,

Google I/O 2018において発表された「JetPack」において、Androidのバージョンに応じてそれらの処理を切り替えるWorkManagerが含まれているので、今後はそちらを利用していくべきかと思います。こちらを利用すると、内部的には以下のように処理が切り替わるそうです。

とあるので,WorkManagerを使わなければならない可能性が出てきた.

上記記事は最低実行間隔について検証もしている.

定期実行する場合、ジョブID毎に実行間隔を指定でき、最小間隔は約15分。それより小さい時間間隔を指定しても15分に設定される。
実行間隔は前後し、正確な周期は保証されない。
最大実行時間は約10分間。(5.1.1までは1分間だった模様)
DOZE状態では動作しなくなり、一定のタイミングで訪れるメンテナンスウィンドウでまとめて実行される。
同時に実行可能なジョブの数には上限があり、環境によって異なる。

なんと,一回のジョブの最大実行時間は10分ということなので,一回のJobでループ回しっぱなしというのは当然途中で止まる.

しかも,OSが省エネ動作を行うのでその実行間隔はだんだん開いたりするようだ.

Jobの設定についてはJobInfoクラスのBuilderメソッドで実行間隔などを指定できるので,それを使う.

JobInfo info = new JobInfo
        .Builder(0) // JobID=0の指定
        .setPersisted(true) // 端末再起動後も実行する
        .setPeriodic(0, JobInfo.getMinFlexMillis()) // 実行間隔=0と実行遅延許容時間の指定
        .setRequiredNetworkType(JobInfo.NETWORK_TYPE_NONE) // ネットワーク状態と無関係に実行
        .build();
scheduler.schedule(info);

ふむふむ.

あと,端末再起動後もJobを実行するには以下のパーミッションを得る必要があるようだ. このあたりは試していない.

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

qiita.com

setPersisted(true)にすると実行時エラーになる.RECEIVE_BOOT_COMPLETEDにチェックつけてるのに.

JobSchedulerについてはこちらも詳しい.

qiita.com

他にも参考 Xamarin.Androidでの実装についてはこちら.

docs.microsoft.com

やってみた結果

やってみたが,非アクティブ時には定期的には実行されなかった.

どういうタイミングかはよくわからなかったが,気がついたら実行されていた.

Foreground Service

foregroundServiceを使ったらできた.

詳しくは別で書く.

docs.microsoft.com

こちら,とても詳しい qiita.com

Androidの各種サービスについて

OnStartCommandについて説明がある.

docs.microsoft.com

itblogdsi.blog.fc2.com

ソースコード

github.com

以下の3つのブランチがある

  • foreground_service
    • foregroundサービスとしてタスクを実行
  • use_job_service
    • JobServiceを使った定期実行
    • 15分間隔
  • master
    • 普通のService
    • バックグラウンド1分で停止

MAUIでQRコードを読み取る

こちらの動画の内容をやってみたメモ。

www.youtube.com

ZXing.Net.Mauiをインストール

github.com

NugetパッケージマネージャでZXing.Net.Mauiを検索する。 このパッケージは現時点ではプレビューなので検索欄の右側のInclude prereleaseにチェックをつけておく。

UseBarcodeReaderを追加する

MauiProgram.csを開き、builderUseBarcodeReader()を追加する。

ConfigureMauiHandlerを追加する

MauiのHandlerについては全然わかっていないけれど、動画の主によるとZXing.Net.Mauiはプレビューのため今はこの記述が必要とのこと。

    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .UseBarcodeReader()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            })
            .ConfigureMauiHandlers(h =>
            {
                h.AddHandler(typeof(ZXing.Net.Maui.Controls.CameraBarcodeReaderView), typeof(CameraBarcodeReaderViewHandler));
                h.AddHandler(typeof(ZXing.Net.Maui.Controls.CameraView), typeof(CameraViewHandler));
                h.AddHandler(typeof(ZXing.Net.Maui.Controls.BarcodeGeneratorView), typeof(BarcodeGeneratorViewHandler));
            });

        return builder.Build();
    }

AndroidManifest.xmlにCAMERAを追加

カメラを使うのでマニフェストにカメラを追加する。

AndroidManifest.xmlを開いて以下のように<uses-permission android:name="android.permission.CAMERA"を追加する。

バーコード読み取り用のカメラコントロールを追加

ZXing.Net.Mauiにはバーコード読み取り用カメラコントロールが用意されているので、それをXAMLに追加する。

まずZXingのアセンブリを追加する。

次にCameraBarcodeReaderView(barcodeReader)を追加する。 ついでに、QRコードを読み取った結果の文字列を表示するLabel(barcodeResult)を追加してある。

CameraBarcodeReaderViewの各プロパティの意味も聞き取れていないけれど、BarcodesDetectedがバーコード読み取り成功時のイベントハンドラということはわかる。

イベントハンドラを定義

横着してコードビハインド(MainPage.xaml.cs)に以下のように書く。

読み取った値(e.Results[0].Value)をLabelに表示させる。

    private void CameraBarcodeReaderView_BarcodesDetected(object sender, ZXing.Net.Maui.BarcodeDetectionEventArgs e)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            barcodeResult.Text = $"{e.Results[0].Value} {e.Results[0].Format}";
        });
    }

動かしてみる

こちらのサービスを使ってQRコードを作成する。 「こんにちは、せかい」という文字列を表すQRコードを作成した。

qr.quel.jp

QRコードを読み取り、画面に「こんにちは、せかい」と表示されたらOK。

読み取り前。

読み取り後。

文字化けしてしまった…

アルファベットであれば問題ない。

文字化け問題は後で解決するとして、とりあえず動画のとおりにやることでMAUIで作ったAndroidアプリ上でQRコード読み取り機能が使えることが確認できた。

とても簡単に使えたので、これからが楽しみ。

MAUI:ボタンの有効無効を条件で切り替える

devblogs.microsoft.com

CommunityToolkitを使っています。

CommunityToolkitを使うとこれまで(Xamarin.Forms)ではたくさんの定型的なコードを書かなければならなかったところが、クラスやプロパティ、メソッドへの属性の指定で済むため、実装の労力を大幅にへらすことができます。

CommunityToolkitはMAUI専用というわけではなくWPFでも使えます。

さて、今回は入力欄とボタンがあって、入力欄に何かしら入力されていない場合はボタンを無効にする、ということをやります。

こういうことです。

この記事が詳しいです。

egvijayanand.in

まずはAddメソッドのRelayCommand属性にCanExecuteに判定用のメソッドを指定します。 ここで指定した判定用のメソッドCanAddExecuteはTextプロパティが空じゃないかをチェックし、空の場合はfalse(ボタン無効)、空じゃない場合はtrue(ボタン有効)を返すメソッドです。

アプリを実行すると、以下のようにボタンが無効の状態です。 いいですね!

しかし、これだけだと仮に入力欄に入力してもボタンは有効になりません。

なぜなら、チェックするのは最初だけでTextプロパティが変更されてもチェックしなおさないからです。

なので、Textプロパティが変更されるたびにチェックされるようにする必要があります。それが NotifyCanExecuteChangedFor属性。

これをTextプロパティにつけてあげます。 対象のコマンドはAddボタンにバインディングされているAddCommandです。

これでTextプロパティが変更されるたびにAddCommandの有効・無効判定メソッドCanAddExecuteが実行されることになります。

さて、改めて実行します。

入力すると、ボタンが有効になりました! よし!

空にすると、無効になります

MAUI:画面遷移時のデータの受け渡し

画面遷移のとき、遷移先にデータを渡したいことがあります。

画面Aで選択された内容を画面B側で表示したい、とか、画面B側での処理に使いたい、といったようにです。

MAUIのShellアプリケーションではURIベースのナビゲーションを使います。 WebサイトのURLのようなページの指定方法です。

参考:

www.youtube.com

データの受け渡しのない遷移

まず、遷移先のページを作ります。

AppShell.xaml.csに遷移先のルーティング情報を追加します。 これで、"SecondPage"と指定するとSecondPageへ遷移します。

遷移は以下のようにします。

Shell.Current.GoToAsync(nameof(SecondPage));

戻るときは以下のように遷移先のURIに".."を指定します。パスで相対的に1つ前を表すのと同じです。

データの受け渡しのある遷移(単純な型の場合)

stringやintなどの単純な型の場合、渡す側はとてもシンプルです。 Webと同様に?の後に「クエリパラメータ名 = パラメータ」の形でGoToAsyncにわたすだけです。

例えばクエリパラメータ名を「MessageFrom」とすると、以下のようになります。

受け取り側は以下のようにします。

  • QueryProperty属性をViewModelにつける。
  • QueryProperty(受け取り側のクラスのプロパティ名(SecondPageViewModelクラスの), クエリパラメータ名(送る側のURIで指定したID))

このように単純な型の場合は、URIにクエリパラメータとして埋め込んで渡すことができます。

では、カスタムクラスやListなど複雑な型の場合はどうでしょうか。

データの受け渡しのある遷移(複雑な型の場合)

複雑な型の場合はURIに埋め込むことはできません。

そのかわり、GoToAsyncメソッドはそういったデータを受け渡すためのDictionary<string,object>型の引数を受け取るようにできています。

以下のように渡したいデータを「キーと値」のペアで遷移先に渡すことができます。

注意:ObservableCollectionはそのままでは送れないのでListに変換しています。

受け取り側は単純な型の場合と変わりません。 QueryProperty属性を使って、送り側で指定したキー名と受け取り側のプロパティを結びつけるだけです。

おしまい

これで好きな型のデータを遷移先に送ることができます。

MAUIでCommunityToolkit.Mvvmを使ったPubSubメッセージング

参考

www.youtube.com

メッセージングとは

送信側と受信側がお互いを意識しないで(依存しない)データのやり取りを行う仕組み、なのかな。

送信側は受信側のことを考えることなく送信したいときにメッセージを送信する。

受信側は送信側がどのタイミングでメッセージを送るかは気にせず待ち受けていて、送られてきたときに対応する。

こんな仕組み、と思う。

メリットは?

送信側、受信側がお互いに依存しないでデータのやり取りができること。

データの送受信そのものはもちろん大切だけれど、データの送受信を介して相手側の処理を開始するトリガにできること。

使い所は?

参考の動画のとおり、一覧ページから詳細ページに遷移した後、詳細ページで「削除」操作が行われたときに一覧ページで対象のアイテムが削除されるようにする、というようなページをまたいだ、任意のタイミングでの処理の実行に使う。

遷移のタイミングで、ということであれば遷移時にデータを受け渡すことができるけれど、メッセージングの場合は任意のタイミングでできるのがよい。

使い方

CommunityToolkit.Mvvm(8.0)をインストールしておく。 WeakReferenceMessengerクラスを使う。

メッセージの入れ物を作る

ここでいうメッセージとなるものはValueChangedMessage<T>型を継承したオブジェクトのこと。

このオブジェクトを受信側は待ち受け、送信側は送る。この型自体が送受信の識別子の役割も果たす。

例えばMyMessage:ValueChangedMessage<MyClass>というメッセージを用意したとする。

受信側は、「このMyMessage型のメッセージを待ち受けるよ!」というように書く。

送信側は、「MyMessage型のメッセージを送るよ!」というように書く。

なので、送受信したいメッセージの種類の数だけこういったValueChangedMessageを継承したクラスを用意する。

public class MyMessage : ValueChangedMessage<MyClass>
{
    public MyMessage(MyClass value) : base(value)
    {
    }
}

この場合、送りたいデータの型は"MyClass"型のデータ。 MyMessageの中にMyClass型のデータが含まれて送受信される。

受信側

受信側はWeakReferenceMessenger.Default.Register<T>()メソッドを使って、待ち受けるメッセージの種類と、それを受け取ったときの処理を書く。

以下のようにコンストラクタに書くことになると思う。

ラムダ式の中の第2引数(m)の中に送信側から送られてきたメッセージが入る。

この例は詳細ページ側で削除操作が行われたアイテムを一覧ページ側で一覧から削除するときの例。

    public MainPageViewModel()
    {
        //メッセージ受信側の登録
        //DeleteItemMessage型のメッセージが送られてきたときの処理を書く
        WeakReferenceMessenger.Default.Register<DeleteItemMessage>(this, (r, m) =>
        {
            //UIの更新を伴う場合はメインスレッドで実行されるようにする
            MainThread.BeginInvokeOnMainThread(() =>
            {
                Delete(m.Value);
            });
        });
    }

送信側

送信側は WeakReferenceMessenger.Default.Send()メソッドを使う。

例えば以下のようにMyClass型のデータをMyMessageに入れてメッセージを送る。

 var myClass = new MyClass();
 WeakReferenceMessenger.Default.Send(new MyMessage(myClass));

これは詳細ページで削除操作が行われたときに削除されたアイテムを載せてメッセージを送信する例。

    [RelayCommand]
    async Task Delete()
    {
        WeakReferenceMessenger.Default.Send(new DeleteItemMessage(TaskItem));
        await GoBack();
    }

おしまい

とても簡単にPubSubなメッセージングができた。

送受信それぞれのコードで相手を指定する記述は一切出て来ないことがわかる。

メッセージの型だけ知っていればよい。

MAUI:XAML側でViewModelのインテリセンスを効かせる

XAML側でデータバインディング時にViewModelのパブリックメンバーがインテリセンスで表示されたり、タイポで存在しないメンバーを指定したときに指摘してくれると嬉しいです。

Xamain.Formsのときは以下のようにデザイン時支援機能を使って以下のように書くことでインテリセンスを有効にしました。

shuhelohelo.hatenablog.com

MAUIではデザイン時支援機能ではなくx:DataTypeを使います。 以下のようにContentPageにViewModelへの参照を追加しておくことで、ContentPage以下で指定したクラスのメンバーがインテリセンスに表示されます。

<ContentPage
    x:Class="MyMauiAppPractice.MainPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:viewmodel="clr-namespace:MyMauiAppPractice.ViewModel"
    x:DataType="viewmodel:SamplesViewModel">

例えば以下のようなSampleViewModelというクラスがあったとします。

public partial class SamplesViewModel : BaseViewModel
{
    public string SampleText { get; set; }

    public ObservableCollection<Sample> Samples { get; set; }
}

このViewModelを使うView側で上記のようにx:DataTypeを使ってSampleViewModelクラスを指定しておくと、XAML側でデータバインディングの記述時にインテリセンスが効きます。

x:DataTypeでクラスを指定していないときはこのように候補が表示されません。

タイポのときは、XAML上でそれを指摘してくれます。

私にとってはMAUIを使うときにはまずはやっておきたいことの一つです。