65.9K
CodeProject 正在变化。 阅读更多。
Home

插件向导框架

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.82/5 (30投票s)

2008年5月21日

BSD

7分钟阅读

viewsIcon

95807

downloadIcon

1184

一个支持将向导页面作为插件的向导框架。

引言

我需要为我正在开发的一个应用程序添加一些向导功能。我注意到需求中有一点是,可能会有很多个向导,并且这些向导之间会有一些共同的页面。另一个需求是能够将每个向导“页面”链接起来,创建一个完整的向导,然后在最后处理所有用户的选择。

我决定构建一个支持每个页面都使用插件的原型向导。我还希望以最少的工作量实现最大的灵活性,这样我就可以拿着这个原型,然后根据我的具体应用需求(高度声明式)进行定制。当前的原型

  • 允许您在 Visual Studio 窗体设计器中创建任何向导窗体,唯一的要求是它要为向导的每个页面提供一个容器。该容器可以是任何容器控件,例如 Panel
  • 允许您在 Visual Studio 窗体设计器中创建向导页面。没有任何限制——您可以使用第三方控件等。
  • 整个框架应该非常容易修改以支持基于 WPF 的向导。
  • 该向导框架管理
    • 加载包含向导页面的程序集。
    • 实例化指定的类。
    • 管理“上一步”、“下一步”、“取消”和“完成”的状态。
    • 提供一个回调机制,插件可以用它来通知框架状态发生了变化。
    • 允许插件
      • 在退出页面前实现功能,
      • 通知框架页面上的数据已通过验证,
      • 实现自己的帮助功能。
  • 每个页面的状态都会被保留,所以如果用户点击“上一步”,他/她之前的选择都还在(“下一步”也一样)。

该框架不做什么

  • 它不为每个页面中的数据提供数据管理。
  • 它不提供修改向导工作流程的机制。
  • 它不调整不同的页面尺寸(例如,它可能希望将自身设置为插件的最大范围)。

这些功能可能是特定于应用程序的。如果我为这两个遗漏想出了一些好的通用解决方案,我会更新这篇文章。

我还曾希望将程序集放入一个单独的 AppDomain 中。不幸的是,这是不可能的,因为插件定义的控件被复制到了应用程序的向导窗体中。对于那些没有设置为可封送的对象(即控件)进行跨边界管理,最终超出了我想要研究的范围。请参阅“还有什么”部分了解一些替代方案。

安装

要使用该向导,请创建您自己的向导包装窗体。例如,我创建了这个窗体

注意:“完成”按钮下面是“下一步”按钮。

所以,设置一个向导需要

  • 指定 “using Clifton.Wizard”(如果您愿意,可以重命名命名空间)。
  • 实例化容器窗体。
  • 创建一个 ContainerInfo 类,在其中告诉向导框架有关按钮实例和页面容器实例的信息。
  • 添加插件程序集。
  • 启动向导框架,并指定容器窗体。

以下代码演示了此过程

static class Program
{
  [STAThread]
  static void Main()
  {
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);

    InitializeWizard();
  }

  static void InitializeWizard()
  {
    // Get the form that will contain the area
    // in which the controls from the plugins 
    // will be placed.
    WizardContainerForm form=new WizardContainerForm();

    // Set up the info our wizard "controller" needs.
    ContainerInfo info = new ContainerInfo(form.WizardPluginArea, form.WizardBackButton, 
         form.WizardNextButton, form.WizardFinishButton, form.WizardCancelButton, 
         form.WizardHelpButton);

    // Create the wizard controller.
    WizardForm wf = new WizardForm(info);

    // Tell the controller about the plugin modules it will be using.
    wf.AddAssembly(Path.GetFullPath("..\\..\\Welcome\\bin\\debug\\Welcome.dll"), 
         "Welcome.WizardPlugin");
    wf.AddAssembly(Path.GetFullPath("..\\..\\Ingredients\\bin\\debug\\Ingredients.dll"), 
         "Ingredients.WizardPlugin");
    wf.AddAssembly(Path.GetFullPath("..\\..\\PrepareDough\\bin\\debug\\PrepareDough.dll"), 
         "PrepareDough.WizardPlugin");
    wf.AddAssembly(Path.GetFullPath("..\\..\\RollDough\\bin\\debug\\RollDough.dll"), 
         "RollDough.WizardPlugin");
    wf.AddAssembly(Path.GetFullPath("..\\..\\Bake\\bin\\debug\\Bake.dll"), 
         "Bake.WizardPlugin");

    // Do final initialization.
    wf.Initialize();

    // Start the wizard.
    wf.Start(form);
  }
}

插件接口

每个插件都必须实现 IPlugin 接口。为方便起见,提供了一个基类(接下来会描述),它定义了默认行为并帮助管理插件的控件状态。

using System;
using System.Collections.Generic;
using System.Windows.Forms;

namespace Clifton.Wizard.Interfaces
{
  public interface IPlugin
  {
    /// <summary>
    /// The plugin should return true if the current wizard page data is valid.
    /// </summary>
    bool IsValid { get; }

    /// <summary>
    /// The plugin should return true if there is help available.
    /// </summary>
    bool HasHelp { get; }

    /// <summary>
    /// The plugin can implement this method if it needs to do special processing
    /// before the wizard proceeds to the next page.
    /// </summary>
    void OnNext();

    /// <summary>
    /// The plugin can implement this method to display help.
    /// </summary>
    void OnHelp();

    /// <summary>
    /// The plugin should return the controls that the wizard will place in the
    /// container area.
    /// </summary>
    /// <returns></returns>
    List<Control> GetControls();

    /// <summary>
    /// The plugin needs to implement this event container so that the wizard can
    /// be notified of state changes, which the plugin will call itself.
    /// </summary>
    event EventHandler UpdateStateEvent;
  }
}

WizardBase 类

如上所述,WizardBase 类为 IPlugin 接口提供了一些默认实现,通过阅读注释应该可以一目了然。

using System;
using System.Collections.Generic;
using System.Windows.Forms;

namespace Clifton.Wizard.Interfaces
{
  /// <summary>
  /// This abstract class defines common fields, properties, and certain
  /// default behavior that each plugin can leverage.
  /// </summary>
  public abstract class WizardBase : IPlugin
  {
    /// <summary>
    /// The event that the plugin can use to notify the wizard of a state change.
    /// </summary>
    public event EventHandler UpdateStateEvent;

    /// <summary>
    /// The control list is preserved so that the control's state is maintained
    /// as the user navigates backwards and forwards through the wizard.
    /// </summary>
    protected List<Control> ctrlList;
    protected Form form;

    /// <summary>
    /// True if the plugin's data is validated and the user can proceed with
    /// the next wizard page. True is the default.
    /// </summary>
    public virtual bool IsValid
    {
      get { return true; }
    }

    /// <summary>
    /// True if the plugin is going to provide help. The default is false.
    /// </summary>
    public virtual bool HasHelp
    {
      get { return false; }
    }

    /// <summary>
    /// Constructor.
    /// </summary>
    public WizardBase()
    {
      ctrlList = new List<Control>();
    }

    /// <summary>
    /// The plugin can override this method if it needs to do
    /// something before the wizard proceeds to the next page.
    /// </summary>
    public virtual void OnNext()
    {
      // Do nothing.
    }

    /// <summary>
    /// The plugin can override this method if it wants to display 
    /// some help.
    /// </summary>
    public virtual void OnHelp()
    {
      // Do nothing.
    }

    /// <summary>
    /// Returns the controls from the form that the plugin assigned
    /// in the class. The plugin can override this method to return
    /// a custom control list.
    /// </summary>
    /// <returns></returns>
    public virtual List<Control> GetControls()
    {
      // If this is the first time we're calling this method,
      // load the controls from the plugin form.
      if (ctrlList.Count == 0)
      {
        // Once loaded, we reuse the same control instances
        // which as the advantage of preserving state if the
        // user goes back to a previous page (and forward again.)
        GetFormControls();
      }

      // Otherwise, return the control list that we acquired from
      // the form. 
      return ctrlList;
    }

    /// <summary>
    /// Iterates through the form's top level controls to construct
    /// a list of form controls.
    /// </summary>
    protected virtual void GetFormControls()
    {
      foreach (Control c in form.Controls)
      {
        ctrlList.Add(c);
      }
    }

    /// <summary>
    /// The plugin can call this method to raise the UpdateStateEvent,
    /// which informs the wizard that the button states need to be updated.
    /// </summary>
    protected void RaiseUpdateState()
    {
      if (UpdateStateEvent != null)
      {
        UpdateStateEvent(this, EventArgs.Empty);
      }
    }
  }
}

插件长什么样?

让我们看两个插件:欢迎页面和配料页面。请记住,每个插件都是一个单独的程序集。

欢迎页面

欢迎页面可以在 Welcome 项目中找到。它定义了一个如下所示的窗体

它还定义了一个 WizardPlugin 类,该类在很大程度上依赖于基类的默认行为

public class WizardPlugin : WizardBase
{
  protected WelcomeForm wf;

  public WizardPlugin()
  {
    wf = new WelcomeForm();
    base.form = wf;
  }
}

注意基类的 form 字段是如何被初始化的。这样做是为了利用基类为管理插件控件所实现的默认功能。

如上所述,通过调用以下代码将此插件添加到向导框架中

wf.AddAssembly(Path.GetFullPath("..\\..\\Welcome\\bin\\debug\\Welcome.dll"), 
                                "Welcome.WizardPlugin");

注意插件类型名称 Welcome.WizardPlugin 如何与上面代码中的命名空间和类名相对应。显然,程序集名称对应于项目名称。因此,向导框架会显示

配料页面

这个页面更有趣一些,因为它需要在启用“下一步”按钮之前进行验证。

酸奶油?我从未听说过在姜饼中放酸奶油!

首先,部分的 IngredientsForm 类处理每个复选框和“全选”按钮的事件,并提供一个我们的 WizardPlugin 类可以挂钩的事件。

using System;
using System.Windows.Forms;

namespace Ingredients
{
  public partial class IngredientsForm : Form
  {
    public event EventHandler HaveIngredientsEvent;

    protected int haveIngredients = 0;

    public int HaveIngredients
    {
      get { return haveIngredients; }
      set
      {
        if (value != haveIngredients)
        {
          haveIngredients = value;
          RaiseHaveIngredientsChanged();
        }
      }
    }

    public IngredientsForm()
    {
      InitializeComponent();
    }

    private void OnCheckedChanged(object sender, EventArgs e)
    {
      CheckBox cb = (CheckBox)sender;

      if (cb.Checked)
      {
        ++HaveIngredients;
      }
      else
      {
        --HaveIngredients;
      }
    }

    protected void RaiseHaveIngredientsChanged()
    {
      if (HaveIngredientsEvent != null)
      {
        HaveIngredientsEvent(this, EventArgs.Empty);
      }
    }

    private void OnSelectAll(object sender, EventArgs e)
    {
      Button btn = (Button)sender;

      // Notice this--how we get the controls from the parent container,
      // not our starting form!
      foreach (Control ctrl in btn.Parent.Controls)
      {
        if (ctrl is CheckBox)
        {
          ((CheckBox)ctrl).Checked = true;
        }
      }
    }
  }
}

这里有几点需要注意

  • 增加或减少配料数量的逻辑。
  • 每当配料数量改变时触发的事件。
  • OnSelectAll 事件处理程序设置选中状态的方式。

最后一点是最关键的。定义了这些控件的窗体不再拥有这些控件!它们现在属于在向导容器窗体中定义的容器面板。因此,我利用了按钮是这个容器的子控件这一事实,获取按钮的父级,然后设置该容器上所有 CheckBox 控件的选中状态。是的,我也可以这样做

ckIngr1.Checked=true;
ckIngr2.Checked=true;

随便哪种都行。

该页面的 WizardPlugin 类(你可以给这个类起任何名字,我只是在演示中为所有向导页面都用了这个类名)现在会触发状态更改事件,向导框架用它来接收状态更改的通知。它还实现了 IsValid 属性。

using System;
using System.Collections.Generic;
using System.Windows.Forms;

using Clifton.Wizard.Interfaces;

namespace Ingredients
{
  public class WizardPlugin : WizardBase
  {
    protected IngredientsForm wf;

    public override bool IsValid
    {
      get { return wf.HaveIngredients==11; }
    }

    public WizardPlugin()
    {
      wf = new IngredientsForm();
      base.form = wf;
      wf.HaveIngredientsEvent += new EventHandler(OnHaveIngredientsEvent);
    }

    protected void OnHaveIngredientsEvent(object sender, EventArgs e)
    {
      RaiseUpdateState();
    }
  }
}

RaiseUpdateState 方法由基类实现(见上文)。

烘焙页面

作为最后一个页面(我不会详细介绍“准备面团”和“擀面团”页面,您可以自己查看这些代码),我将演示“烘焙”页面。

对于这个向导页面,我实现了一个进度条(相对的烘焙时间)。当烘焙师点击“开始”时,计时器开始计时,并且在计时器完成之前,“完成”按钮不会被启用。最后,当烘焙师点击“完成”时,他会得到如何处理他的饼干的指示。

该页面的窗体定义了控件和“开始”按钮的事件处理程序。注意 DoneEvent 的使用。

using System;
using System.Windows.Forms;

namespace Bake
{
  public partial class BakeForm : Form
  {
    public event EventHandler DoneEvent;

    protected bool done;
    protected Timer timer;

    public bool Done
    {
      get { return done; }
      set
      {
        if (done != value)
        {
          done = value;
          RaiseDoneEvent();
        }
      }
    }

    public BakeForm()
    {
      InitializeComponent();
      timer = new Timer();
      timer.Tick += new EventHandler(OnTimerTick);
    }

    protected void OnStart(object sender, EventArgs e)
    {
      Done = false;
      timer.Interval = 500;
      timer.Start();
    }

    protected void OnTimerTick(object sender, EventArgs e)
    {
      pbBaking.Increment(1);

      if (pbBaking.Value == pbBaking.Maximum)
      {
        timer.Stop();
        Done = true;
      }
    }

    protected void RaiseDoneEvent()
    {
      if (DoneEvent != null)
      {
        DoneEvent(this, EventArgs.Empty);
      }
    }
  }
}

以及我们的 WizardPlugin

using System;
using System.Collections.Generic;
using System.Windows.Forms;

using Clifton.Wizard.Interfaces;

namespace Bake
{
  public class WizardPlugin : WizardBase
  {
    protected BakeForm wf;

    public override bool IsValid
    {
      get { return wf.Done; }
    }

    public WizardPlugin()
    {
      wf = new BakeForm();
      base.form = wf;
      wf.DoneEvent += new EventHandler(OnDoneEvent);
    }

    public override void OnNext()
    {
      MessageBox.Show("EAT!!!");
    }

    protected void OnDoneEvent(object sender, EventArgs e)
    {
      RaiseUpdateState();
    }
  }
}

请注意当计时器触发 Done 事件时的事件回调,以及当烘焙师点击“完成”时被调用的 OnNext 方法重写。

内部

说实话,底层并没有太多有趣的东西。可能最有趣的是向导“控制器”类中的 SetState 方法

protected void SetState()
{
  switch (manager.State)
  {
    case WizardState.Uninitialized:
      throw new ApplicationException("The plugin manager has not been" + 
                                     " initialized with any plugins.");

    case WizardState.Start:
      SetButtonState(info.BackButton, false, true);
      SetButtonState(info.NextButton, manager.IsValid, true);
      SetButtonState(info.FinishButton, false, false);
      SetButtonState(info.CancelButton, true, true);
      break;

    case WizardState.Middle:
      SetButtonState(info.BackButton, true, true);
      SetButtonState(info.NextButton, manager.IsValid, true);
      SetButtonState(info.FinishButton, false, false);
      SetButtonState(info.CancelButton, true, true);
      break;

    case WizardState.End:
      SetButtonState(info.BackButton, !manager.IsValid, !manager.IsValid);
      SetButtonState(info.NextButton, false, false);
      SetButtonState(info.FinishButton, manager.IsValid, true);
      SetButtonState(info.CancelButton, !manager.IsValid, !manager.IsValid);
      break;
  }
}

protected void SetButtonState(Button btn, bool isEnabled, bool isVisible)
{
  if (btn != null)
  {
    btn.Enabled = isEnabled;
    btn.Visible = isVisible;
  }
}

这算不上什么高科技。由于您可能不希望在向导中包含所有这些按钮,您可以为不需要的按钮指定 null,因此 SetButtonState 方法会检查以确保该按钮确实存在。

“下一步”和“上一步”的处理程序也足够简单

protected void OnBack(object sender, EventArgs e)
{
  manager.Previous();
  SetState();
  LoadUI();
}

protected void OnNext(object sender, EventArgs e)
{
  manager.Next();
  SetState();
  LoadUI();
}

好了,我想我已经花了足够多的篇幅来描述底层机制了。代码都有注释,所以根据需要修改框架应该很容易。

其他

我并不特别喜欢 Windows Forms——相反,我更想使用 MyXaml 和声明式标记来实例化向导页面。我将会扩展这个向导来支持这一点,如果有人对这个实现感兴趣,请发表评论,我很可能会就基于声明式的向导页面另外写一整篇文章。我想研究的一件事是,基于声明式的解决方案在创建 AppDomain 方面能给我带来什么优势,这样当向导完成后,我就可以卸载向导中使用的所有插件。看看这在 WPF 中如何工作也会很有趣。

尽情享用!

修订历史

  • 2008年5月23日:更新 zip 文件,包含缺失的 Clifton.WizardClifton.Wizard.Interfaces 项目。
© . All rights reserved.