插件向导框架






4.82/5 (30投票s)
一个支持将向导页面作为插件的向导框架。
引言
我需要为我正在开发的一个应用程序添加一些向导功能。我注意到需求中有一点是,可能会有很多个向导,并且这些向导之间会有一些共同的页面。另一个需求是能够将每个向导“页面”链接起来,创建一个完整的向导,然后在最后处理所有用户的选择。
我决定构建一个支持每个页面都使用插件的原型向导。我还希望以最少的工作量实现最大的灵活性,这样我就可以拿着这个原型,然后根据我的具体应用需求(高度声明式)进行定制。当前的原型
- 允许您在 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.Wizard 和 Clifton.Wizard.Interfaces 项目。