手続き型音楽の日常

関数型音楽に乗り換えたい

MVVM 初心者なので、 .NET Standard 2.0 + Xamarin.Forms で Messaging クラスを実装してみた

昨今のお技術についていけていない私は、もちろん MVVM も初心者です。もっぱら Windows Forms に生きてる人なのです。

この間 Xamarin 勉強会に出かけたし、本業でも少し Xamarin を触ることになったので、 WPF 含め MVVM を色々と調べていたのですが。

やっぱり実装する方々それぞれ、いろいろなお作法で実現されていまして。

  • View -> ViewModel の機能呼び出しは ICommand 実装のクラス
  • ViewModel -> View の機能呼び出しは Messaging 機構

というのが現在の流行だという事を知りました。

早速実装してみる。

準備するもの

1. Xamarin.Forms でソリューションを作る

Xamarin.Forms を使ったソリューションを作成。とりあえず PCL で作っておく。

f:id:yuzutan_hnk:20171122014755p:plain:w600

f:id:yuzutan_hnk:20171122015100p:plain:w600

UWP のバージョン指定をしろと言ってくるので、最小バージョンを Fall Creators Update にする。

ここでキャンセルをクリックすると、 UWP とおさらばできる。スクショ撮ってて今知った。

f:id:yuzutan_hnk:20171122015344p:plain:w600

2. .NET Standard 2.0 化する

共有プロジェクトを無理くり .NET Standard 化する。

VS2015 のときは PCL プロジェクトをそのまま .NET Standard 化できていたけれど、 VS2017 ではできない。そこでまず、ソリューションに .NET Standard プロジェクトを追加する。名前は後々役立つように、 PCL プロジェクト名 + Standard にしておく。

f:id:yuzutan_hnk:20171122015937p:plain:w600

VS 15.4 なら大丈夫だと思うけど、念のためプロパティからターゲットを .NET Standard 2.0 にしておく。

f:id:yuzutan_hnk:20171122020310p:plain:w600

ソリューションの NuGet パッケージマネージャで、ほかのプロジェクトの Xamarin.Forms のバージョンを確認する。この画面からではたぶん .NET Standard プロジェクトに Xamarin.Forms を導入できないので、プロジェクト側の NuGet パッケージマネージャでバージョンに気を付けながら導入する。

ついでに、 PCL プロジェクト以外のプロジェクトに NETStandard.Library を導入。 .NET Standard プロジェクトのターゲットを .NET Standard 2.0 にしたので、自動的に NETStandard.Library のバージョンが 2.0.0 に固定される。 2.0.1 入れるとどうなるんだろう…。

f:id:yuzutan_hnk:20171122020627p:plain:w600

f:id:yuzutan_hnk:20171122020846p:plain:w600

PCL 側からソースファイルを全部コピー。ドラッグアンドドロップでおk。

f:id:yuzutan_hnk:20171122021319p:plain:w300

PCL プロジェクトを削除して、各プラットフォーム向けのプロジェクトの参照を .NET Standard のプロジェクトに張り替える。

最後に、ソリューションエクスプローラー上で .NET Standard プロジェクトの名前から「standard」を消す。

これで PCL プロジェクトを完全に置き換えたような形になる。

もっときれいにしたいときは、いったん Visual Studio を閉じ、エクスプローラー上でプロジェクトフォルダの配置や名前を変更、 sln ファイルの中身を弄って正しい配置にする。

3. Messaging クラスを実装する

本題の Messaging クラスです。

Messaging クラスの役割は、コーディング時に呼び出し先が用意する機能を直接参照せずに呼び出すという機能です。

というわけで、相手を見ずに機能を特定する方法を考えます。 いや考えるのが非常にめんどくさいので 文字列を使って特定することにします。

using System;
using System.Collections.Generic;
using System.Linq;

namespace Xamarin_SelfMessaging
{
    /// <summary>
    /// メッセージ機構を実現するクラス
    /// </summary>
    public class Messaging
    {
        #region Private Class

        /// <summary>
        /// 接続先一覧を表すクラス
        /// </summary>
        public class MessagingNetwork : Dictionary<object, MessagingPeer> { }

        /// <summary>
        /// 接続先を表すクラス
        /// </summary>
        public class MessagingPeer : Dictionary<string, Action<object>> { }

        #endregion

        #region Variable / Const

        /// <summary> 既に接続されている </summary>
        private const string EXMSG_CONNECTING = "Already connecting.";

        /// <summary> まだ接続されていない </summary>
        private const string EXMSG_NOTCONNECTED = "Not connected yet.";

        /// <summary> 既に購読されている </summary>
        private const string EXMSG_LISTNING = "Already listening.";

        /// <summary> まだ接続されていない </summary>
        private const string EXMSG_NOTLISTNED = "Not listened yet.";

        /// <summary> 接続先一覧 </summary>
        private MessagingNetwork _network = new MessagingNetwork();

        #endregion

        #region Public Property

        /// <summary>
        /// 現在の接続数 を取得します。
        /// </summary>
        public int ConnectionCount => _network.Count;

        #endregion

        #region Public Method

        /// <summary>
        /// メッセージングネットワークに接続します。
        /// </summary>
        /// <param name="target">接続するオブジェクト</param>
        public void Connect(object target)
        {
            if (target == null) throw new ArgumentNullException(nameof(target));
            if (_network.ContainsKey(target)) throw new InvalidOperationException(EXMSG_CONNECTING);

            _network.Add(target, new MessagingPeer());
        }

        /// <summary>
        /// メッセージングネットワークから切断します。
        /// </summary>
        /// <param name="target">切断するオブジェクト</param>
        public void Disconnect(object target)
        {
            if (target == null) throw new ArgumentNullException(nameof(target));
            if (!_network.ContainsKey(target)) throw new InvalidOperationException(EXMSG_NOTCONNECTED);

            _network.Remove(target);
        }

        /// <summary>
        /// メッセージを購読します。
        /// </summary>
        /// <param name="target">購読するオブジェクト</param>
        /// <param name="key">購読するイベント名</param>
        /// <param name="action">イベントの引数</param>
        public void Listen(object target, string key, Action<object> action)
        {
            if (target == null) throw new ArgumentNullException(nameof(target));
            if (key == null) throw new ArgumentNullException(nameof(key));
            if (!_network.ContainsKey(target)) throw new InvalidOperationException(EXMSG_NOTCONNECTED);
            if (_network[target].ContainsKey(key)) throw new InvalidOperationException(EXMSG_LISTNING);

            _network[target].Add(key, action);
        }

        /// <summary>
        /// メッセージの購読を停止します。
        /// </summary>
        /// <param name="target">購読しているオブジェクト</param>
        /// <param name="key">停止するイベント名</param>
        public void Unlisten(object target, string key)
        {
            if (target == null) throw new ArgumentNullException(nameof(target));
            if (key == null) throw new ArgumentNullException(nameof(key));
            if (!_network.ContainsKey(target)) throw new InvalidOperationException(EXMSG_NOTCONNECTED);
            if (!_network[target].ContainsKey(key)) throw new InvalidOperationException(EXMSG_NOTLISTNED);

            _network[target].Remove(key);
        }

        /// <summary>
        /// メッセージを送信します。
        /// </summary>
        /// <param name="target">送信元オブジェクト</param>
        /// <param name="key">イベント名</param>
        /// <param name="arg">引数</param>
        public void Post(object target, string key, object arg)
        {
            var actionList = _network
                .Where((item) => item.Key != target)
                .Select((item) => item.Value.Where((item2) => item2.Key == key))
                .Where((item) => item.Count() > 0)
                .Select((item) => item.First().Value);

            foreach (var action in actionList)
                action?.Invoke(arg);
        }

        /// <summary>
        /// メッセージを送信します。
        /// </summary>
        /// <param name="target">送信元オブジェクト</param>
        /// <param name="key">イベント名</param>
        public void Post(object target, string key)
            => Post(target, key, null);

        #endregion
    }
}

使うイメージ的には、イベントを購読するクラスを Connect し、購読するイベント名を Listen 。メッセージを発行する側はイベント名を Post する。一通り何かの通信のような感覚で使えるようにしたつもり。

ただ一つ、 宛先を指定できない (=ブロードキャスト状態) という難点があり、ネットワークごとにインスタンスを分ける必要がある…。

今回はめんどくさいので ViewModel にインスタンスを持たせて、 View が BindableContext直接参照で ViewModel をインスタンス化するときにコンストラクタ内でこっそり登録する方針で行きます。

4. DelegateCommand を実装する

Messaging ついでにボタンに登録するコマンドも実装します。

フレームワークには System.Windows.Input.ICommand インターフェイスしか用意しておらず、自前で実装するしかないというちょっと微妙な感覚。

というわけで汎用性を高めるよう、 delegate で処理を指定できるようにする。

using System;
using System.Windows.Input;

namespace Xamarin_SelfMessaging
{
    /// <summary>
    /// 任意の処理を実行可能なコマンド
    /// </summary>
    public class DelegateCommand : ICommand
    {
        #region Variable

        private Action<object> _command;
        private Func<object, bool> _canExecute;

        #endregion

        #region Public Event

        /// <summary>
        /// 実行可能かどうかを取得する必要がある時に発生します。
        /// </summary>
        public event EventHandler CanExecuteChanged;

        #endregion

        #region ctor

        private DelegateCommand() { }

        /// <summary>
        /// 任意の処理を実行可能なコマンドをインスタンス化します。
        /// </summary>
        /// <param name="command">コマンド実行時の処理</param>
        /// <param name="canExecute">実行可能かどうかを返す処理</param>
        public DelegateCommand(Action<object> command, Func<object, bool> canExecute)
        {
            _command = command;
            _canExecute = canExecute;
        }

        #endregion

        #region Public Method

        /// <summary>
        /// コマンドが実行可能かどうかを取得します。
        /// </summary>
        /// <param name="parameter">パラメータ</param>
        /// <returns>実行可能かどうか</returns>
        public bool CanExecute(object parameter) => _canExecute?.Invoke(parameter) ?? false;

        /// <summary>
        /// コマンドを実行します。
        /// </summary>
        /// <param name="parameter">パラメータ</param>
        public void Execute(object parameter) => _command?.Invoke(parameter);

        /// <summary>
        /// <see cref="CanExecuteChanged"/> イベントを発生させます。
        /// </summary>
        public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, new EventArgs());

        #endregion
    }
}

System.Windows.Input.ICommand インターフェイスExecute メソッドと CanExecute メソッド、 CanExecuteChanged イベントを定義しています。

特に引数を必要としないのでデリゲートのほうは引数をなくしてもいいのですが、とりあえず純粋にスルーするだけにしてみます。

指定されたデリゲートが null だったりしたら不通に例外出したほうがもしかしたら安全かも?

5. ViewModelBase を実装してちょっとだけコーディングを楽にする。

すべての ViewModel でいちいち System.ComponentModel.INotifyPropertyChanged を実装したり MessagingConnect するのがめんどくさいので、基底クラスを作っていしまいます。

あとは継承するだけ。使うだけ。って状態にしてみます。

using System.ComponentModel;

namespace Xamarin_SelfMessaging
{
    /// <summary>
    /// ViewModelのためのテンプレート
    /// </summary>
    public abstract class ViewModelBase : INotifyPropertyChanged
    {
        #region Public Property

        /// <summary>
        /// メッセージネットワーク
        /// </summary>
        public Messaging Messaging { get; }

        #endregion

        #region Public Event

        /// <summary>
        /// バインドプロパティが変更されたときに発生します。
        /// </summary>
        public event PropertyChangedEventHandler PropertyChanged;

        #endregion

        #region ctor

        public ViewModelBase()
        {
            Messaging = new Messaging();
            Messaging.Connect(this);
        }

        #endregion

        #region PrivateMethod

        /// <summary>
        /// バインドプロパティを変更したときに呼び出します。
        /// </summary>
        /// <param name="name">変更したプロパティ名</param>
        private void OnPropertyChanged(string name)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

        #endregion
    }
}

本当はプロパティだけ用意すれば勝手にバインドしてくれるジェネリックな型を作ってみたい。 ReactiveProperty みたいな。

6. View ~ ViewModel を実装

あとは機能要件を作るのみ。今回はボタンを押したらカウントアップして、さらにダイアログを出すという単純な機能を作ってみます。

View

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:Xamarin_SelfMessaging"
             x:Class="Xamarin_SelfMessaging.MainPage">
    <ContentPage.Content>
        <StackLayout>
            <Button Text="{Binding Count}" Command="{Binding ButtonCommand}"
                    HorizontalOptions="FillAndExpand" VerticalOptions="CenterAndExpand" />
        </StackLayout>
    </ContentPage.Content>
</ContentPage>
using System;
using Xamarin.Forms;

namespace Xamarin_SelfMessaging
{
    public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();

            var viewModel = new MainPageViewModel() as ViewModelBase;

            viewModel.Messaging.Connect(this);
            viewModel.Messaging.Listen(
                this, "A",
                (arg) => DisplayAlert(
                    $"Do you have a watch???",
                    $"It's {(arg as DateTime?)}",
                    $"Awesome!!!")
                );

            BindingContext = viewModel;
        }
    }
}

ViewModel

using System;

namespace Xamarin_SelfMessaging
{
    class MainPageViewModel : ViewModelBase
    {
        private int _Count;
        public int Count
        {
            get { return _Count; }
            set
            {
                _Count = value;
                OnPropertyChanged(nameof(Count));
            }
        }

        private DelegateCommand _ButtonCommand;
        public DelegateCommand ButtonCommand
        {
            get { return _ButtonCommand; }
            set
            {
                _ButtonCommand = value;
                OnPropertyChanged(nameof(ButtonCommand));
            }
        }

        public MainPageViewModel()
        {
            ButtonCommand = InitialButtonCommand;
        }

        private DelegateCommand InitialButtonCommand
            => new DelegateCommand(
                (arg) =>
                {
                    Count++;
                    Messaging.Post(this, "A", DateTime.Now);
                },
                (arg) => true
                );
    }
}

7. 実行

あとは実行するのみ。今回は UWP で動かしてみる。

f:id:yuzutan_hnk:20171123230412p:plain:w600 f:id:yuzutan_hnk:20171123231034p:plain:w400 f:id:yuzutan_hnk:20171123231053p:plain:w600

以上。