2011年11月27日日曜日

wpf : UserControlを含んだクラスライブラリ(dll)を動的にロード

UserControlを含んだクラスライブラリ(dll)を動的にロードするサンプルコードです。 ユーザーインターフェース付きのプラグインに対応したソフトを作ることを考えて組んでみました。

各プラグインとのやり取りをするときに使う共通処理を本体側のinterfaceで定義しておきます。 プラグイン側はそのinterfaceを実装したクラスを作成。 本体はSystem.Reflection.Assembly.LoadFromでプラグインのdllを読み込み、そのクラスのインスタンスを作成してプラグインの機能を使用します。

コーディングのとき、プラグインから本体のアプリケーションで定義されたクラスを扱うには本体.exeファイルの参照を追加するだけでOKです。 本体のアプリケーションからプラグイン内のクラスを扱うときは、あらかじめ本体側で用意したinterfaceを実装したものに限るのが現実的。 各プラグイン共通のデータ構造を決めておきましょう。 それ以上のことをやりたい時はリフレクションでゴリゴリ、かなぁ?

とりあえず、UserControlを含んだクラスライブラリを扱うのでデバッグ方法を書いた前の投稿も参照。

それを踏まえて、本体&プラグインの作成手順はこんな感じ。

  1. 本体アプリのwpfプロジェクトを作成。
  2. 本体プロジェクトにプラグイン用のinterfaceを作成する。
  3. 本体プロジェクトをビルド。
  4. プラグイン用にwpfプロジェクトを作成。
  5. プラグインプロジェクトの参照に本体プロジェクトのexeを追加。(本体側で定義されたinterfaceを使えるようにするため)
  6. プラグイン用のinterfaceを継承したクラスを作成する。
  7. プラグインプロジェクトに新しい項目「ユーザーコントロール(wpf)」を追加 → 編集。
  8. 十分デバッグできたら、前の投稿に倣ってアプリケーションの出力の種類などを変更してdllをビルド。

本体プロジェクト側は完成してなくてもいいけど、interfaceを含んだexeはビルドしておかないとダメです。 今回のサンプルコードで作ったinterfaceはコレ。

// 本体側 : PluginTestInterface.cs
using System.ComponentModel;
using System.Windows.Controls;

namespace PluginTest00
{
    public interface PluginTestInterface : INotifyPropertyChanged
    {
        UserControl Panel { get; }
        FishEnum Fish { get; set; }
    }

    public enum FishEnum
    {
        None,
        Mackerel, // 鯖
        PacificSaury, // 秋刀魚
        RighteyeFlounder // 鰈
    }
}

「全部のプラグインでUIが必要とは限らないよなぁ」とかって考えたらこんな風になりました。 プラグイン側が出すイベントを受け取るのもチェックしたかったのでINotifyPropertyChangedも追加。 本体プロジェクトの名前はPluginTest00にしました。 適当ですな。 そのプロジェクト内でコレを書いて、exeをビルドしておきます。

PluginTest01という名前でプラグインのプロジェクトを作成。 前の投稿に書いたとおりwpfプロジェクトです。 プラグイン本体のクラス名はPluginMain01とします。

// プラグイン側 : PluginMain01.cs
using System.ComponentModel;
using System.Windows.Controls;
using PluginTest00;

namespace PluginTest01
{
    public class PluginMain01 : PluginTestInterface
    {
        private PluginControl01 _control;

        public PluginMain01()
        {
            _control = new PluginControl01();
            _control.DataContext = this;
        }

        public UserControl Panel
        {
            get
            {
                return _control;
            }
        }

        private FishEnum _fish;
        public FishEnum Fish
        {
            get
            {
                return _fish;
            }
            set
            {
                _fish = value;
                NotifyPropertyChanged("Fish");
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;
        protected virtual void NotifyPropertyChanged(string property)
        {
            if (this.PropertyChanged != null)
            {
                this.PropertyChanged(this, new PropertyChangedEventArgs(property));
            }
        }
    }
}

本体とやり取りをするFishプロパティはラジオボタンにしてみました。 IValueConverterがちゃんと使えるのを確認するためです。 IValueConverterが使えるなら、ValidationRuleなどのxamlで指定する他のクラスも使えるはず。

ということでプラグイン側のUIとして準備したPluginControl01.xamlはこんな感じ。

<UserControl x:Class="PluginTest01.PluginControl01"
             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"
             xmlns:app="clr-namespace:PluginTest01"
             mc:Ignorable="d" 
             d:DesignWidth="180"
             d:DesignHeight="36">
    <UserControl.Resources>
        <app:EnumBooleanConverter x:Key="enumBooleanConverter"/>
    </UserControl.Resources>
    <Border BorderThickness="1" BorderBrush="Gray" Margin="4" Padding="4">
        <StackPanel>
            <StackPanel Orientation="Horizontal">
                <RadioButton Name="radioBtnRed" Content="鯖" IsChecked="{
                    Binding Path=Fish,
                    Mode=TwoWay,
                    Converter={StaticResource enumBooleanConverter},
                    ConverterParameter=Mackerel
                }" />
                <RadioButton Content="秋刀魚" IsChecked="{
                    Binding Path=Fish,
                    Mode=TwoWay,
                    Converter={StaticResource enumBooleanConverter},
                    ConverterParameter=PacificSaury
                }" Margin="20, 0"/>
                <RadioButton Content="鰈" IsChecked="{
                    Binding Path=Fish,
                    Mode=TwoWay,
                    Converter={StaticResource enumBooleanConverter},
                    ConverterParameter=RighteyeFlounder
                }" />
            </StackPanel>
        </StackPanel>
    </Border>
</UserControl>

扱うデータはバインディングしているモノだけなので、PluginControl01.xaml.csは初期状態のままです。 FishEnumとラジオボタンの対応付けはこちらのサイトを参考にしました。

// プラグイン側 : EnumBooleanConverter.cs
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace PluginTest01
{
    public class EnumBooleanConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            string paramStr = parameter as string;
            if (paramStr == null)
                return DependencyProperty.UnsetValue;

            Type valueType = value.GetType();
            if (!Enum.IsDefined(valueType, value))
                return DependencyProperty.UnsetValue;

            object paramValue = Enum.Parse(valueType, paramStr);
            return paramValue.Equals(value);
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            string paramStr = parameter as string;
            if (paramStr == null || value.Equals(false))
                return DependencyProperty.UnsetValue;

            return Enum.Parse(targetType, paramStr);
        }
    }
}

プラグインのUserControlの動作をテストするため、MainWindowに貼り付けます。

// プラグイン側 : MainWindow.xaml.cs
using System.Windows;

namespace PluginTest01
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            PluginMain01 pluginTest = new PluginMain01();
            Content = pluginTest.Panel;
        }
    }
}

今回は簡単なコードなのでコレだけです。 プラグイン内のアレコレが入り組んでいるとか、クラスライブラリの中にUserControlがいっぱいあるとか、複雑な場合は適宜テストコード追加で。

テストが終わったら前の投稿に書いたように「出力の種類」をexeからdllに変えてdllファイルを作ります。

出力されたdllを本体側で読み込みます。 MainWindow.xamlのコードは、

<Window x:Class="PluginTest00.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="PluginTest00"
        SizeToContent="WidthAndHeight">
    <StackPanel Name="mainPanel" Margin="0, 0, 0, 20">
        <StackPanel Orientation="Horizontal">
            <Button Click="OnButtonClick" Tag="Mackerel">Plugin に鯖を設定</Button>
            <Button Click="OnButtonClick" Tag="PacificSaury">Plugin に秋刀魚を設定</Button>
            <Button Click="OnButtonClick" Tag="RighteyeFlounder">Plugin に鰈を設定</Button>
            <Button Click="OnButtonClick" Tag="None" Margin="10, 0">Pluginの選択をリセット</Button>
        </StackPanel>
    </StackPanel>
</Window>

アプリケーション起動時、mainPanelの下の方にプラグインのUIを追加します。 最初はいくつかのdllを作って並べる予定だったけど面倒になってしまいました。 結局用意したdllは↑の1つだけに...

dllを読み込んだ後の見た目はこうなります。

「Pluginに?を設定」というボタンは本体側のUIです。 鯖、秋刀魚、鰈という項目のラジオボタンがボーダーで囲まれている部分がプラグインです。 本体側のボタンを押したらプラグイン側の選択も変わるようにします。 INotifyPropertyChangedに反応して、プラグインの選択が変わったらウィンドウのTitleが変わるようにします。

// 本体側 : MainWindow.xaml.cs
using System;
using System.ComponentModel;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;

namespace PluginTest00
{
    public partial class MainWindow : Window
    {
        private PluginTestInterface _plugin01 = null;

        public MainWindow()
        {
            InitializeComponent();

            try
            {
                Assembly assembly = Assembly.LoadFrom("PluginTest01.dll");
                _plugin01 = (PluginTestInterface)assembly.CreateInstance("PluginTest01.PluginMain01");
                _plugin01.PropertyChanged += new PropertyChangedEventHandler(OnPlugin01PropertyChange);
                mainPanel.Children.Add(_plugin01.Panel);
            }
            catch (Exception exc)
            {
                MessageBox.Show(this, "[" + exc.Message + "]\n" + exc.StackTrace, Title);
            }
        }

        private void OnPlugin01PropertyChange(object sender, PropertyChangedEventArgs args)
        {
            if (_plugin01 == sender && args.PropertyName == "Fish")
            {
                Title = "プラグインの表示 = " + _plugin01.Fish;
            }
        }

        private void OnButtonClick(object sender, RoutedEventArgs e)
        {
            try
            {
                string c = ((Button)sender).Tag.ToString();
                _plugin01.Fish = (FishEnum)Enum.Parse(typeof(FishEnum), c);
            }
            catch
            {
                _plugin01.Fish = FishEnum.None;
            }
        }
    }
}

コンストラクタのtry~catch内でdllをロードしています。 dllを読み込むのに1行、その中で定義されているクラスのインスタンスを作るのに1行で済んでますね。 楽だなぁ。

ただ、このやり方だと「必要なときだけdllをロードして使い終わったら解放」というようなことはできません。 メモリを節約しなければならないときはもうちょっとの工夫が必要なようです。

まぁ、サンデープログラミングの範囲ではそこまでやらなくても良いかな? 今回はとりあえず、プラグインのdllが動的にロードできて、UserControlもちゃんと動いていたので目的達成です。