MvvmCrossのプラグインを作ってプラットフォーム依存コードを再利用する

Xamarin Advent Calendar 2014の4日目です。

MvvmCrossガチ勢としては書かざるを得ないと思っていたけど忙しくてかけていなかったMvvmCrossのプラグインの作り方をこの機会を借りて紹介したいと思います。

そもそもMvvmCrossプラグインとは

MvvmCrossプラグインは、MvvmCrossで作っているプロジェクトにカメラやバイブレーションといったプラットフォーム依存コードを再利用できる形で提供します。プラグインを組み込むと、IoCコンテナプラグインが提供しているクラスが登録されます。

公式でも多くのパッケージが公開されており、NuGetでMvvmCross Pluginで検索すると大量に出てきます。NuGetで公開されているものはプラグインを追加するとすぐに使い始めることができます。

また、プラグインは簡単に作ることができるので、自分たちでもよく使う機能はプラグイン化しておくことで再利用することができます。

プラグインの作り方

今回は簡易的なTextToSpeechの機能を作るという想定のもと、プラグインを作成してみようと思います。

プロジェクトの作成

MvvmCrossの命名規則に合わせてプロジェクトを作成します。プラグインのPCLにはCoreをつけず、それ以外は通常のプロジェクトと同じです。メインの名前空間はIseteki.MvvmCross.Plugin.TextToSpeechとすることにしましょう。

  • ソリューション: Iseteki.MvvmCross.Plugin.TextToSpeech
    • PCL: プロジェクト名 Iseteki.MvvmCross.Plugin.TextToSpeech
      • Profile78のPCL
    • iOS: プロジェクト名 Iseteki.MvvmCross.Plugin.TextToSpeech.Touch
      • iOS → Classic APIiOS Library Project
    • Android: プロジェクト名 Iseteki.MvvmCross.Plugin.TextToSpeech.Droid

作成できたら、各プロジェクトにあるデフォルトのClass1.csは削除し、iOS, AndroidのプロジェクトからはPCLのプロジェクトを参照しておきます。

MvvmCross の追加

すべてのプロジェクトにMvvmCross - Hot Tuna MvvmCross Librariesを追加します。これはMvvmCrossの機能を使うための最小の構成のライブラリのみをプロジェクトに追加するものです。

スクリーンショット 2014-12-03 2.26.58.png

プラグインの基本コードの追加

MvvmCrossにプラグインとして認識させ、プラグイン内のクラスを登録するために、PCLと各プラットフォームにはおきまりのコードを追加する必要があります。

PCL: PluginLoader.cs

PCLプロジェクトにPluginLoader.csをいうファイルを作り、以下の内容にします。これは完全にコピペコードです。

using System;
using Cirrious.CrossCore.Plugins;
using Cirrious.CrossCore;

namespace Iseteki.MvvmCross.Plugin.TextToSpeech
{
    public class PluginLoader
        : IMvxPluginLoader
    {
        public static readonly PluginLoader Instance = new PluginLoader();

        public void EnsureLoaded()
        {
            var manager = Mvx.Resolve<IMvxPluginManager>();
            manager.EnsurePlatformAdaptionLoaded<PluginLoader>();
        }
    }
}

プラットフォーム: Plugin.cs

各プラットフォームにはPlugin.csを作成し、以下の内容とします。このクラスのLoadメソッドの中でプラグインが提供するクラスをIoCコンテナに登録することになります。

using System;
using Cirrious.CrossCore.Plugins;

namespace Iseteki.MvvmCross.Plugin.TextToSpeech.Touch
{
    public class Plugin
        : IMvxPlugin          
    {
        public void Load()
        {
            // ここにIoCコンテナへの登録処理を記述する
        }
    }
}

ここまでがプラグイン作成のおきまり作業で、ソリューションは以下のような状態になっているはずです。

スクリーンショット 2014-12-03 2.37.37.png

プラグインインターフェイスの作成

ここから先はプラグインが実際に提供する機能を作成します。

まず、プラグインが提供する機能のインターフェイスを作成します。このインターフェイスはすべてのプラットフォームで共通なので、標準の機能を共通化する場合はよく検討する必要があります。

using System;

namespace Iseteki.MvvmCross.Plugin.TextToSpeech
{
    public interface ITextToSpeech
    {
        /// <summary>
        /// Text To Speech が初期化されているかどうか
        /// </summary>
        bool Initialized { get; }

        /// <summary>
        /// 読み上げのロケール
        /// </summary>
        SpeechLocale Locale { get; set; }

        /// <summary>
        /// Text To Speech を初期化する
        /// </summary>
        void Initialize();

        /// <summary>
        /// Text To Speech を解放する
        /// </summary>
        void Release();

        /// <summary>
        /// Text To Speech が初期化されたら呼び出される
        /// </summary>
        event EventHandler InitializeCompleted;

        /// <summary>
        /// 読み上げを実行する
        /// </summary>
        /// <param name="text">Text.</param>
        void Speech(string text);
    }

    /// <summary>
    /// 読み上げロケールのリスト
    /// </summary>
    public enum SpeechLocale
    {
        English,
        Japanease
    }
}

プラットフォームコードの記述

各プラットフォームで定義したインターフェイスを実装します。

using System;
using MonoTouch.AVFoundation;

namespace Iseteki.MvvmCross.Plugin.TextToSpeech.Touch
{
    public class TouchTextToSpeech : ITextToSpeech
    {
        private AVSpeechSynthesizer _speechSynthesizer;

        public TouchTextToSpeech()
        {
        }

        #region ITextToSpeech implementation

        public event EventHandler InitializeCompleted;

        public void Initialize()
        {
            if (_speechSynthesizer == null)
            {
                _speechSynthesizer = new AVSpeechSynthesizer();
                if (InitializeCompleted != null)
                {
                    InitializeCompleted(this, EventArgs.Empty);
                }
            }
        }

        public void Release()
        {
            if (_speechSynthesizer != null)
            {
                _speechSynthesizer.Dispose();
                _speechSynthesizer = null;
            }
        }

        public void Speech(string text)
        {
            if (_speechSynthesizer == null)
            {
                throw new InvalidOperationException();
            }

            var utterance = new AVSpeechUtterance(text);
            switch (Locale)
            {
                case SpeechLocale.Japanease:
                    utterance.Voice = AVSpeechSynthesisVoice.FromLanguage("ja-JP");
                    break;
                default:
                    utterance.Voice = AVSpeechSynthesisVoice.FromLanguage("en-US");
                    break;
            }

            _speechSynthesizer.SpeakUtterance(utterance);
        }

        public bool Initialized
        {
            get { return _speechSynthesizer != null; }
        }

        public SpeechLocale Locale
        {
            get;
            set;
        }

        #endregion
    }
}
using System;
using Cirrious.CrossCore.Droid;

namespace Iseteki.MvvmCross.Plugin.TextToSpeech.Droid
{
    public class DroidTextToSpeech : Java.Lang.Object, ITextToSpeech, Android.Speech.Tts.TextToSpeech.IOnInitListener
    {
        Android.Speech.Tts.TextToSpeech _tts;
        IMvxAndroidGlobals _globals;

        public DroidTextToSpeech(IMvxAndroidGlobals globals)
        {
            _globals = globals;
        }

        #region ITextToSpeech implementation

        public event EventHandler InitializeCompleted;

        public void Initialize()
        {
            if (_tts == null)
            {
                _tts = new Android.Speech.Tts.TextToSpeech(_globals.ApplicationContext, this);
            }
        }

        public void Release()
        {
            if (_tts != null)
            {
                _tts.Shutdown();
                _tts.Dispose();
                _tts = null;
                Initialized = false;
            }
        }

        public void Speech(string text)
        {
            if (Initialized == false)
            {
                throw new InvalidOperationException();
            }
            Java.Util.Locale locale = Java.Util.Locale.English;
            if (Locale == SpeechLocale.Japanease)
            {
                locale = Java.Util.Locale.Japanese;
            }
            switch (_tts.IsLanguageAvailable(locale))
            {
                case Android.Speech.Tts.LanguageAvailableResult.MissingData:
                case Android.Speech.Tts.LanguageAvailableResult.NotSupported:
                    throw new NotSupportedException();
                default:
                    _tts.SetLanguage(locale);
                    break;
            }
            _tts.Speak(text, Android.Speech.Tts.QueueMode.Flush, null);
        }

        public bool Initialized { get; private set; }

        public SpeechLocale Locale { get; set; }

        #endregion

        #region IOnInitListener implementation

        public void OnInit(Android.Speech.Tts.OperationResult status)
        {
            if (status == Android.Speech.Tts.OperationResult.Success)
            {
                Initialized = true;
                if (InitializeCompleted != null)
                {
                    InitializeCompleted(this, EventArgs.Empty);
                }
            }
        }

        #endregion
    }
}

IoCコンテナへの登録

インターフェイス、プラットフォーム実装ができたら、IoCコンテナに登録するコードをPlugin.csに追加します。RegisterType を使うと Resolve するたびに新しいインスタンスが作成され、RegisterSingletonを使うとSingletonになります。

using System;
using Cirrious.CrossCore.Plugins;
using Cirrious.CrossCore;

namespace Iseteki.MvvmCross.Plugin.TextToSpeech.Touch
{
    public class Plugin
        : IMvxPlugin          
    {
        public void Load()
        {
            // ここにIoCコンテナへの登録処理を記述する
            Mvx.RegisterType<ITextToSpeech, TouchTextToSpeech>(); // 追加
        }
    }
}

ブートストラップコードの作成

ここまででプラグインをビルドして、アプリのプロジェクトにDLLを追加すればそのまま使うことができますが、この際アプリのプロジェクトにあるBootstrapフォルダに、以下のようなプラグインブートストラップを作成する必要があります。MvvmCrossはこのクラスを元にプラグインを初期化します。

using Cirrious.CrossCore.Plugins;

namespace HogeApp.Touch.Bootstrap
{
    public class TextToSpeechPluginBootstrap
        : MvxLoaderPluginBootstrapAction<Iseteki.MvvmCross.Plugin.TextToSpeech.PluginLoader, Iseteki.MvvmCross.Plugin.TextToSpeech.Touch.Plugin>
    {
    }
}

プラグインのNuGetパッケージ化

今度は作成されたプラグインが使いやすいようにNuGetパッケージにします。

ブートストラップコードをテンプレート化してプロジェクトに追加

毎回Bootstrap.csを作るのは手間なので、NuGetで追加した時に自動で追加されるようにしておきます。僕のオススメはプロジェクトに追加しておいて、ビルドには含まないけどそのまま出力ディレクトリにコピーする方法です。

各プロジェクトにTextToSpeechPluginBootstrap.cs.ppを追加して、下記のように記述します。namespaceを$rootnamespace$.Bootstrapにしておくと、追加先のプロジェクトに置き換えてくれます。

using Cirrious.CrossCore.Plugins;

namespace $rootnamespace$.Bootstrap
{
    public class TextToSpeechPluginBootstrap
        : MvxLoaderPluginBootstrapAction<Iseteki.MvvmCross.Plugin.TextToSpeech.PluginLoader, Iseteki.MvvmCross.Plugin.TextToSpeech.Touch.Plugin>
    {
    }
}

ファイルができたら、このファイルのプロパティを開いて、ビルドアクションをNone、出力ディレクトリにコピーを「常にコピー」と指定します。

スクリーンショット 2014-12-03 3.22.39.png

パッケージマニフェストを作成

ソリューションと同じ位置に Package.nuspec というファイルを作ります。

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
  <metadata>
    <id>Iseteki.MvvmCross.Plugin.TextToSpeech</id>
    <title>MvvmCross - Text To Speech</title>
    <version>0.1.0</version>
    <authors>Nobuhiro Ito</authors>
    <owners>Nobuhiro Ito</owners>
    <licenseUrl>https://github.com/iseebi/MvxTextToSpeech/blob/master/LICENSE</licenseUrl>
    <projectUrl>https://github.com/iseebi/MvxTextToSpeech</projectUrl>
    <!-- <iconUrl>http://ICON_URL_HERE_OR_DELETE_THIS_LINE</iconUrl> -->
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <description>Text-to-Speech for MvvmCross</description>
    <releaseNotes>0.1.0 - First Release</releaseNotes>
    <copyright>Copyright (C) 2014 Nobuhiro Ito</copyright>
    <tags>mvvmcross mvvm xamarin monoandroid monodroid monotouch</tags>
    <dependencies>
      <dependency id="MvvmCross.HotTuna.CrossCore" version="3.2.2" />
    </dependencies>
  </metadata>
  <files>
    <!-- Core -->
    <file src="Iseteki.MvvmCross.Plugin.TextToSpeech/bin/Release/Iseteki.MvvmCross.Plugin.TextToSpeech.dll"
        target="lib\portable-net45+wp8+win8+monoandroid+monotouch\Iseteki.MvvmCross.Plugin.TextToSpeech.dll" />
  
    <!-- Touch -->
    <file src="Iseteki.MvvmCross.Plugin.TextToSpeech.Touch/bin/Release/Iseteki.MvvmCross.Plugin.TextToSpeech.dll"
        target="lib\monotouch\Iseteki.MvvmCross.Plugin.TextToSpeech.dll" />
    <file src="Iseteki.MvvmCross.Plugin.TextToSpeech.Touch/bin/Release/Iseteki.MvvmCross.Plugin.TextToSpeech.Touch.dll"
        target="lib\monotouch\Iseteki.MvvmCross.Plugin.TextToSpeech.Touch.dll" />
    <file src="Iseteki.MvvmCross.Plugin.TextToSpeech.Touch/bin/Release/TextToSpeechPluginBootstrap.cs.pp"
        target="content\monotouch\Bootstrap\TextToSpeechPluginBootstrap.cs.pp" />
  
    <!-- Droid -->
    <file src="Iseteki.MvvmCross.Plugin.TextToSpeech.Droid/bin/Release/Iseteki.MvvmCross.Plugin.TextToSpeech.dll"
        target="lib\monoandroid\Iseteki.MvvmCross.Plugin.TextToSpeech.dll" />
    <file src="Iseteki.MvvmCross.Plugin.TextToSpeech.Droid/bin/Release/Iseteki.MvvmCross.Plugin.TextToSpeech.Droid.dll"
        target="lib\monoandroid\Iseteki.MvvmCross.Plugin.TextToSpeech.Droid.dll" />
    <file src="Iseteki.MvvmCross.Plugin.TextToSpeech.Droid/bin/Release/TextToSpeechPluginBootstrap.cs.pp"
        target="content\monoandroid\Bootstrap\TextToSpeechPluginBootstrap.cs.pp" />
  </files>
</package>

パッケージ作成

ここまでできたら、Xamarin Studioでリリースビルドをして、プロジェクトのルートフォルダでnuget packコマンドを叩くとnupkgができあがります。

あとはこれをNuGetに登録するなり、ファイルサーバーにおいてそこをNuGetリポジトリにするなりして使うことができます。

最後に

今回のプロジェクトはgithubにあげてありますので、参考になれば幸いです。

https://github.com/iseebi/MvxTextToSpeech