かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Managed Extensibility Framework入門 その11「拡張可能アプリケーション」

これまで、10回にわたってMEFについて書いてきました。まだ細かい所は書いてない部分もありますが、これで一区切りにしたいと思います。後は、MEFのドキュメントを見てなんとかなると思います。
ということで、今回は、ちょっと頑張って拡張可能なアプリケーションのサンプルを書いてみました。

このアプリケーションはShellと呼ばれる以下のようなウィンドウだけで構成されるアプリケーションです。

このアプリケーションのexeの下にPluginsフォルダを作って、そこにこのアプリケーションに対応した拡張機能のdllを置いた状態で起動すると以下のように画面の左側に拡張機能で定義されたメニューが表示されます。

メニューのリンクをクリックすると、画面の右側にタブで画面が表示されます。

簡単な説明

まず、プラグインに必要なインターフェースと属性を設計します。今回はプラグインが持つべき機能と、プラグインで表示するための画面で実装すべきインターフェースを作成しました。

IMEFShellPluginインターフェース

プラグインが実装するインターフェースです。プラグインが提供する機能としては画面を作成するという機能だけにしました。

namespace MEFShell.Plugins
{
    /// <summary>
    /// プラグインが実装するインターフェース
    /// </summary>
    public interface IMEFShellPlugin
    {
        /// <summary>
        /// Viewを作成する
        /// </summary>
        /// <returns></returns>
        IShellView CreateView();
    }
}
IShellViewインターフェース

プラグインで表示するViewが実装するインターフェース。タブに表示するタイトルを取得するプロパティを定義しています。

namespace MEFShell.Plugins
{
    /// <summary>
    /// プラグインのViewが実装するインターフェース
    /// </summary>
    public interface IShellView
    {
        string Title { get; }
    }
}

MEFPluginExport属性

Shellにプラグイン可能な部品としてExportする属性です。リンクに表示する文字列をメタデータで設定するようにしています。

namespace MEFShell.Plugins
{
    using System;
    using System.ComponentModel.Composition;

    /// <summary>
    /// プラグインとしてExportするための属性
    /// </summary>
    [MetadataAttribute]
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
    public class MEFPluginExportAttribute : ExportAttribute, IMEFPluginMetadata
    {
        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="label">プラグインの表示名</param>
        public MEFPluginExportAttribute(string label)
            : base(typeof(IMEFShellPlugin))
        {
            this.Label = label;
        }

        /// <summary>
        /// プラグインの表示名
        /// </summary>
        public string Label { get; private set; }
    }

    /// <summary>
    /// プラグインのメタデータ
    /// </summary>
    public interface IMEFPluginMetadata
    {
        string Label { get; }
    }
}

エントリポイント

アプリケーションのエントリポイントとなるAppクラスのStartupイベントではプラグインフォルダのDLLを読み込んでコンテナを作成しています。そして、SatisfyImportsOnceでプラグインをViewModelに読み込んでいます。

namespace MEFShell
{
    using System.ComponentModel.Composition;
    using System.ComponentModel.Composition.Hosting;
    using System.IO;
    using System.Windows;

    /// <summary>
    /// App.xaml の相互作用ロジック
    /// </summary>
    public partial class App : Application
    {
        private void Application_Startup(object sender, StartupEventArgs e)
        {
            // Pluginsフォルダが無い場合は作成する
            if (!Directory.Exists("Plugins"))
            {
                Directory.CreateDirectory("Plugins");
            }

            // コンテナを作成する
            var catalog = new AggregateCatalog();
            catalog.Catalogs.Add(new DirectoryCatalog(@".\Plugins"));
            var container = new CompositionContainer(catalog);

            // Shellを作成する
            var viewModel = new ShellViewModel();

            // コンテナからプラグインを設定する
            container.SatisfyImportsOnce(viewModel);
            new Shell
            {
                DataContext = viewModel
            }.Show();
        }
    }
}

ShellViewModel

プラグインをロードするShellViewModelでは、プラグインがロードされたタイミングで初期化を行っています。

namespace MEFShell
{
    using System;
    using System.Collections.Generic;
    using System.Collections.ObjectModel;
    using System.ComponentModel.Composition;
    using System.Linq;
    using MEFShell.Plugins;

    public class ShellViewModel : ViewModelBase, IPartImportsSatisfiedNotification
    {
        private ObservableCollection<IShellView> shellViews = new ObservableCollection<IShellView>();

        private ObservableCollection<PluginCommandViewModel> pluginCommands = new ObservableCollection<PluginCommandViewModel>();

        private IShellView selectedTab;

        /// <summary>
        /// プラグイン
        /// </summary>
        [ImportMany]
        public IEnumerable<Lazy<IMEFShellPlugin, IMEFPluginMetadata>> Plugins { get; set; }

        public ObservableCollection<PluginCommandViewModel> PluginCommands
        {
            get
            {
                return this.pluginCommands;
            }
        }

        /// <summary>
        /// 現在作成したShellのView(ViewをVMでもつってどうなの!?)
        /// </summary>
        public ObservableCollection<IShellView> ShellViews
        {
            get { return this.shellViews; }
        }

        public IShellView SelectedTab
        {
            get
            {
                return this.selectedTab;
            }

            set
            {
                this.selectedTab = value;
                this.RaisePropertyChanged("SelectedTab");
            }
        }

        public void OnImportsSatisfied()
        {
            // プラグインからPluginCommandViewModelへ変換する
            this.PluginCommands.Clear();
            var viewModels = this.Plugins.Select(p =>
                new PluginCommandViewModel
                {
                    Label = p.Metadata.Label,
                    CreateViewCommand = new DelegateCommand(
                        () => 
                        {
                            // プラグインからViewを取得したタブに追加する
                            var view = p.Value.CreateView();
                            this.ShellViews.Add(view);
                            // 現在選択中のタブを設定する
                            this.SelectedTab = view;
                        })
                });
            foreach (var vm in viewModels)
            {
                this.PluginCommands.Add(vm);
            }
        }
    }
}

Shell.xaml

ShellViewModelを表示するViewです。

<Window x:Class="MEFShell.Shell"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
        mc:Ignorable="d" 
        xmlns:local="clr-namespace:MEFShell"
        xmlns:plugin="clr-namespace:MEFShell.Plugins;assembly=MEFShell.Plugins"
       Title="Shell">
    <d:DesignProperties.DataContext>
        <local:ShellViewModel />
    </d:DesignProperties.DataContext>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition MinWidth="150" Width="*" />
            <ColumnDefinition Width="220*" />
        </Grid.ColumnDefinitions>
        <Border BorderThickness="3" CornerRadius="5" BorderBrush="#FF7070BA">
            <ItemsControl ItemsSource="{Binding Path=PluginCommands}" Margin="5">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <TextBlock>
                        <Hyperlink Command="{Binding Path=CreateViewCommand}">
                            <Run Text="{Binding Path=Label}" />
                        </Hyperlink>
                        </TextBlock>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </Border>
        <TabControl Grid.Column="1" ItemsSource="{Binding Path=ShellViews}" SelectedItem="{Binding Path=SelectedTab}">
            <TabControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Path=Content.Title, RelativeSource={RelativeSource FindAncestor, AncestorType=TabItem}}" />
                </DataTemplate>
            </TabControl.ItemTemplate>
        </TabControl>
    </Grid>
</Window>

以上で、アプリケーションの土台になるShellが出来ました。プラグイン側のコードは以下のような感じになります。

テキストエディタプラグイン

プラグインを作成するにはIMEFShellPluginを実装してMEFPluginExport属性をつけます。そしてCreateViewで、このプラグインが提供するViewを作成します。

namespace TextEditorPlugin
{
    using MEFShell.Plugins;

    /// <summary>
    /// テキストエディタプラグイン
    /// </summary>
    [MEFPluginExport("テキストエディタ")]
    public class TextEditorPlugin : IMEFShellPlugin
    {
        /// <summary>
        /// TextEditorViewを作成する
        /// </summary>
        /// <returns></returns>
        public IShellView CreateView()
        {
            return new TextEditorView();
        }
    }
}

ViewはIShellViewインターフェースを実装したUserControlです。

namespace TextEditorPlugin
{
    using System.Windows.Controls;
    using MEFShell.Plugins;

    /// <summary>
    /// TextEditorView.xaml の相互作用ロジック
    /// </summary>
    public partial class TextEditorView : UserControl, IShellView
    {
        public TextEditorView()
        {
            InitializeComponent();
        }

        public string Title
        {
            get { return "メモ"; }
        }
    }
}

XAML側は好きなように作れます。これはプロジェクトをダウンロードして確認してみてください。

まとめ

ということで、雑な説明でしたが、たったこれだけのコードでDLLを置くだけで拡張可能なGUIのアプリケーションが作れます。拡張すべきポイントを設計してMEFでさくっと実装できるのはステキですね。ということで、連載終了です!良いMEFライフを。

プロジェクトのダウンロード

以下のリンクからダウンロードできます。Releaseモードでビルドすると、プラグインが無い状態で起動します。DebugモードでビルドするとプラグインがPluginsフォルダにコピーされた状態で起動します。