dnackのブログ

コード書く仕事の間にコード書いてる知命目前のおっさんです。

Prism/XamarinでAndroidアプリの作成(7):MVVMとReactivePropertyを使って書き直してみる

ちょっと前に書いたBLEでデバイススキャンするコードをMVVM化。

まだまだいろいろ課題が残っていたり今回の変更で新たに課題が出たりと、キリがいいとは言いづらいが、 諸般の事情でまとまった時間をとりづらくなってきたので、 備忘の意味もこめていったんここまでの作業をまとめた。

今回のまとめ

  • ごちゃごちゃした処理はModelにおしつけて、ViewModelは軽くしよう。 (理想はViewとModelをつなぐだけ)
  • ModelはINotifyChangedを継承して(BindableBaseを継承すればそれが継承している)、ViewModelにプロパティの変更が通知できるようにする。
  • プロパティのバインディングはReactivePropertyを使う。(便利)
  • コマンドのバインディングはReactiveCommandを使う。(超便利)
  • ReactivePropertyの変更は購読できる。(超々便利)
  • リアクティブプロパティは明示的にDisposeする必要がある。Disposeするためには、リアクティブプロパティを使うクラスにIDisposableを継承したうえで、おまじないを書け。(面倒だけどおまじないだから仕方ない)。

MVVM化

前回のBLEスキャンのプログラムは、Modelのクラスを作るのが面倒なので、ごちゃごちゃした処理を全部ViewModelに書いていた。

しかし、本来はViewModelはModelからViewに表示するものだけを引っ張ってきてViewに表示できるように加工するためだけにいるのが美しく、BLEを何するみたいなUIに関係ない処理はModelにもっていくほうがいいだろう。

ちょっと古い記事だけど、この辺りに書かれている考え方。 ugaya40.hateblo.jp

まあそういうことでちょっときれいにしてみた。

Model

まずはModel。

BleControllerModel

using Plugin.BLE;
using Plugin.BLE.Abstractions.Contracts;
using Prism.Mvvm;
using Prism.Services;
using Reactive.Bindings;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Xamarin.Essentials;

namespace SamplePrismApp.Models
{
    public class BleControllerModel : BindableBase
    {

        private List<IDevice> _foundDevices = new List<IDevice>();
        public List<IDevice> FoundDevice
        {
            get { return _foundDevices; }
            protected set { SetProperty(ref _foundDevices, value); }
        }

        private int _foundDeviceCount = 0;
        public int FoundDeviceCount
        {
            get { return _foundDeviceCount; }
            protected set { SetProperty(ref _foundDeviceCount, value); }
        }

        private IPageDialogService _dialogService;
        public BleControllerModel(IPageDialogService dialogService )
        {
            _dialogService = dialogService;
        }

    public async void ScanBleDevices()
        {
            var ble = CrossBluetoothLE.Current;
            var adapter = CrossBluetoothLE.Current.Adapter;

            FoundDeviceCount = 0;
            FoundDevice.Clear();

            //State
            Debug.WriteLine("★★★★BLE Stete:" + ble.State);

            //Permission
            PermissionStatus permission = await CheckAndRequestLocationPermission();
            if (permission != PermissionStatus.Granted)
            {
                return;
            }

            //scan device 
            adapter.DeviceDiscovered += (s, a) =>
            {
                FoundDevice.Add(a.Device);
                Debug.WriteLine("★★★★Found:" + a.Device.Id.ToString());
                FoundDeviceCount++;
            };

            adapter.ScanTimeout = 10000;

            await adapter.StartScanningForDevicesAsync();

            foreach (var device in FoundDevice)
            {
                Debug.WriteLine("★★★★:ID" + device.Id.ToString() + ":Name:" + device.Name + ":RSSI:" + device.Rssi.ToString() + ":State:" + device.State.ToString());
            }
        }

        private async Task<PermissionStatus> CheckAndRequestLocationPermission()
        {
            var status = await Permissions.CheckStatusAsync<Permissions.LocationWhenInUse>();

            if (status == PermissionStatus.Granted)
                return status;

            if (status == PermissionStatus.Denied && DeviceInfo.Platform == DevicePlatform.iOS)
            {
                // Prompt the user to turn on in settings
                // On iOS once a permission has been denied it may not be requested again from the application
                return status;
            }

            if (Permissions.ShouldShowRationale<Permissions.LocationWhenInUse>())
            {
                // Prompt the user with additional information as to why the permission is needed
                await _dialogService.DisplayAlertAsync("Alert",
                    "For BLE communication, permission to location is required.", "OK");
            }

            status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();

            return status;
        }

    }
}

処理の中身は前回のViewModelとほぼ同じなので詳細の説明は省く。

バイスのリストをpublicにした。が、今のところViewModelでこれを使うコードは書けていない。 というわけで代わりにというわけではないがデバイスの数を数えるFoundDeviceCountを追加した。

実際のBLEのユースケースを考えると、そうするとデバイススキャンの終了をViewModelに知らせる仕組みが必要になる気もしなくもないのだが、まあとりあえずそういうのもいったん置く。 Modelからダイアログ出すのはどうなのよという気もしなくもないのだが、これも一身上の都合で気にしない。

大したことはやっていないのだが、一点少し気にしておいたほうがいいかなと思うの、FoundDeviceCount++;の一行。 これは直接プライベートフィールドを触る_foundDeviceCount++と置き換えても、カウンタの値はインクリメントされて正しく動くのだが、 こっちをインクリメントしてしまうとSetPropertyメソッドを通らないので、ViewModelに値の変更が伝わらず、画面に値の変更が反映されない。

逆に言うと通知を飛ばしたくない場合はprivate変数のほうを直接書き換えればいいということになるのかしら。(バグの温床になりそうでぞっとしないな…)

次に説明の都合上ViewModelはかっ飛ばして先にView。

View

MainPage.xaml

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="SamplePrismApp.Views.MainPage"
             Title="{Binding Title}">

    <StackLayout HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand">
        <Label Text="Welcome to Xamarin Forms and Prism!" />
        <Label Text="{Binding FoundDeviceCount.Value} "/>
        <Button Text="Device Scan" Command="{Binding DeviceScanCommand}"/>
    </StackLayout>

</ContentPage>

例によってコードビハインド(MainPage.xaml.cs)は空っぽ。 あー、最初から居座ってるWelcomeほげほげっていうメッセージまだ消してないな。。

Modelに新たに実装した、見つけたデバイスの数を表示するためのラベルと、スキャンコマンド実行用のボタンだけ配置。ラベルのバインド先はReactivePropertyをつかうから”.Value"を付けるのを忘れてはいけない。 ここは特にめあたらしいものはないのでさっさと次に行く。

ViewModel

最後にこいつらをつなぐViewModelはこんな感じ。 MainPageViewModel.cs

using Prism.Navigation;
using Reactive.Bindings;
using SamplePrismApp.Models;
using System;
using System.Linq;
using Reactive.Bindings.Extensions;
using System.Reactive.Linq;
using System.Reactive.Disposables;

namespace SamplePrismApp.ViewModels
{
    public class MainPageViewModel : ViewModelBase, IDisposable
    {

        public ReactiveProperty<string> FoundDeviceCount { get; set; }
        public ReactiveCommand DeviceScanCommand { get; private set; } = new ReactiveCommand();

        private INavigationService _navigateionService;
        private CompositeDisposable Disposable { get; } = new CompositeDisposable();

        public MainPageViewModel(INavigationService navigationService, BleControllerModel bleController )
            : base(navigationService)
        {
            Title = "Main Page";
            _navigateionService = navigationService;

            DeviceScanCommand.Subscribe( bleController.ScanBleDevices );

            FoundDeviceCount = bleController.ObserveProperty(x => x.FoundDeviceCount)
                .Select(x => x.ToString())
                .ToReactiveProperty()
                .AddTo(Disposable);
        }

        public void Dispose()
        {
            Disposable.Dispose();
        }
    }
}

面倒なことはModelに押し付けてだいぶすっきりした。

まずIDisposableを継承して、DisposableDispose()を定義している点。 ちゃんとしないとメモリリークの可能性があるとか。 私はちゃんと理解していないけれど、おまじないと思ってやっている。(だめじゃん) blog.okazuki.jp

Reactive Propertyを使うための税金と思って払っておく。払った以上の利益が出るから問題ない。 ※追記 (どこからかはわからないが)最新のPrismだと ViewModelBaseにIDestructiveが継承されているので、IDisposableを継承せずに、public void Dispose()の代わりにpublic override void Destroy()として同じことができる。

コンストラクタの引数に BleControllerModel bleController を持っているが、これはこの前ちょっと書いたと思うDependency Injectionの仕組みでオブジェクトを渡している。(あとのエントリーポイントのところでちょっとだけ説明する。)

ViewにあったUI要素のFoundDeviceCountDeviceScanCommandはそれぞれReactiveProperty<string>ReactiveCommandで実装。 (Model.FoundDeviceCountはintだけど、Viewではラベルのテキストとバインドしているので、stringにする必要がある。)

コマンドのほうはSubscribeメソッドでモデルのScanBleDevicesを登録。こっちは簡単。

ラベルのほうはちょっといかついけれど、順を追っていくと簡単で(簡単でないところはとりあえずそういうものと思えばいい。Don't think.Feeeeeeel!!)

bleController.ObserveProperty(x => x.FoundDeviceCount) で表現される、bleControllerのFoundDeviceCount変更を監視するIObeserveProperty<int>型(FoundDeviceCountがIntのため型がintになる)を

.Select(x => x.ToString())IObeserveProperty<string>型に変換して、

.ToReactiveProperty()ReactiveProperty<string>型に変換、これでめでたくラベルのテキストにバインドできる形になる。

最後に .AddTo(Disposable)でお祈り。(お祈りいうな)

FoundDeviceCountが変更になったときに実行したい処理(仮にDoFunc)があれば、さらに

FoundDeviceCount.Subscribe( _ => DoFunc(FoundDeviceCount.Value)).AddTo(Disposable)

みたいな感じでお手軽に購読もできる。何これむちゃ便利じゃん。 (なお、この場合も.AddTo(Disposable)を付ける必要があるみたい)

Modelの変数とViewの変数の型があってれば、.Selectの行はいらなくて、この行のない形を、 ModelのプロパティをViewにつなぐときの公式みたいに考えればいいのかもしれない。 まあ私も実は中身は全然わかってないので、使う分には慣れでどうにかなるっちゃなる。 (いや、勉強しようという気はあるんですよ、気だけは… Rxマジでなんもわからん。)

エントリーポイント(コンテナの登録)

最後に、App.xaml.cx

using Prism;
using Prism.Ioc;
using SamplePrismApp.Models;
using SamplePrismApp.ViewModels;
using SamplePrismApp.Views;
using Xamarin.Essentials.Implementation;
using Xamarin.Essentials.Interfaces;
using Xamarin.Forms;

namespace SamplePrismApp
{
    public partial class App
    {
        public App(IPlatformInitializer initializer)
            : base(initializer)
        {
        }

        protected override async void OnInitialized()
        {
            InitializeComponent();

            await NavigationService.NavigateAsync("NavigationPage/MainPage");
        }

        protected override void RegisterTypes(IContainerRegistry containerRegistry)
        {
            containerRegistry.RegisterSingleton<IAppInfo, AppInfoImplementation>();

            containerRegistry.RegisterForNavigation<NavigationPage>();
            containerRegistry.RegisterForNavigation<MainPage, MainPageViewModel>();

            containerRegistry.RegisterSingleton<BleControllerModel>();
        }
    }
}

containerRegistry.RegisterSingleton<BleControllerModel>(); がウィザードが勝手に描いたコードから手動で変更した唯一の変更にして最大のポイント。 ここで、BleControllerModelをDIコンテナに登録している。 BleControllerは2つ動かせるようになっていないので、Singletonにしておけばいい。 ここに登録しておくと、あとはDIコンテナのアーキテクチャがうまいことしてくれて、 クラスの引数としてBleContollerModelを渡したら、存在しているBleControllerModelに解釈してくれる。(存在していなければ内部で自動的にnewするのだと思う。)

というわけで今回は以上。

こんな風に、処理は全部Modelがやって、見せ方はViewが勝手に考えて、その間をViewModelが取り持つというのが私の持っているイメージ。

f:id:dnack:20210919210340p:plain

まあ、なかなかうまいことこんなにきれいにならないんだけどねぇ。。

2021/9/25 追記:最新のPrismだと ViewModelBaseにIDestructiveが継承されているので、それを継承しているなら、わざわざ追加でIDisposableを継承しなくてよい。