在上文《分享一个非常漂亮的WPF界面框架》中我简单的介绍了一个界面框架,有朋友已经指出了,这个界面框架是基于ModernUI来实现的,在该文我将分享所有的源码,并详细描述如何基于ModernUI来构造一个非常通用的、插件化的WPF开发框架。下载源码的同志,希望点击一下推荐。
本文将按照以下四点来介绍:
(1)ModernUI简介;
(2)构建通用界面框架的思路;
(3)基于ModernUI和OSGi.NET的插件化界面框架实现原理及源码分析;
(4)其它更有趣的东西~~。
1 ModernUI简介
ModernUI(http://mui.codeplex.com/)是一个开源的WPF界面库,利用该界面库,我们可以创建很酷的应用程序。下面是ModernUI官方示例,你可以从官方网站直接下载源码运行,如果是.NET 4.0的话,记得要声明“NET4”预编译变量,否则无法编译通过。
要编写这样的WPF界面,我们需要在一个Window上声明菜单和Tab页面,下图是定义菜单的声明。
此外,每一个Tab风格页面,你也需要手动的为菜单创建这样的界面元素。
直接用这样的方式来使用ModernUI,显然不太适合团队协作性的并行开发,因为在一个团队的协作中,不同的人需要完成不同的功能,实现不同页面,每个人都需要来更改主界面。
我非常希望模块化的开发方法,因为这可以尽可能的复用现有资产,使程序员可以聚焦在自己关注的业务逻辑上,不需要关心UI的使用。下面,我将来描述基于ModernUI实现的一个通用界面框架,这个界面框架允许程序员在自己的业务模块中配置需要显示的界面元素。
2 通用界面框架实现思路
我希望能够实现这样的通用界面框架:
(1)程序员可以直接实现需要展现业务逻辑的界面,不需要关注如何使用ModernUI;
(2)程序员可以通过简单的配置就可以将自己实现的业务逻辑页面显示在主界面中;
(3)这个界面框架可以完全复用。
当我看到ModernUI这个界面库时,我希望将应用程序做成模块化,每一个模块能够:
(1)通过以下配置能够直接显示二级菜单。
(2)通过以下配置能够直接显示三级菜单。
这样做的好处是,开发插件的时候可以不需要关心界面框架插件;团队在协作开发应用的时候,可以独立开发并不需要修改主界面;团队成员的插件可以随时集成到这个主界面;当主界面无法满足我们的布局时或者用户需求无法满足时,可以直接替换主界面框架而不需要修改任何插件代码。
最终的效果如下,以下界面的几个菜单及点击菜单显示的内容由DemoPlugin插件、DemoPlugin2插件来提供。当插件框架加载更多插件时,界面上会出现更多的菜单;反之,当插件被卸载或者被停止时,则相应的菜单将消失掉。
下面我来介绍如何实现。
3 基于ModernUI和OSGi.NET的插件化界面框架实现原理及源码分析
3.1 OSGi.NET插件框架原理简介
OSGi.NET框架是一个完全通用的.NET插件框架,它支持WPF、WinForm、ASP.NET、ASP.NET MVC 3.0/4.0、控制台等任意.NET应用程序,也就是说,你可以基于该插件框架来快速构架插件化的应用程序。OSGi.NET插件框架提供了插件化支持、插件扩展和面向服务支持三大功能。
OSGi.NET插件框架启动时,从插件目录中搜索插件,安装并启动这些插件,将这些插件组装在插件框架中;一个插件可以暴露扩展点,允许其它插件在不更改其代码情况下,扩展该插件的功能;插件间可以通过服务来进行通讯。
在一个插件应用程序中,它首先要获取一个入口点,这个入口点由一个插件来提供,然后进入这个插件的入口并运行起来。一个提供入口的插件通常是一个主界面插件,比如上面介绍的这个WPF界面框架。也就是说,插件应用程序启动起来后,会先运行这个界面框架的主界面。而主界面一般都提供了关于界面元素的扩展,允许其它插件将菜单、导航和内容页面注册到主界面,因此,当主界面运行时,它会将其它插件注册的界面元素显示出来。当用户点击界面元素时,插件框架就会加载这个插件的页面,某个插件的页面在呈现时,则有可能会从数据库中提取数据展示,这时候,该插件则可能会调用数据访问服务提供的通用数据访问接口。OSGi.NET提供的三大功能,刚好能够非常的吻合这样的系统的启动形式。当然,OSGi.NET除了提供插件三大支撑功能之外,它还支持插件动态性与隔离性。动态性,意味着我们可以在运行时来动态安装、启动、停止、卸载和更新插件,而隔离性则意味着每一个插件都拥有自己独立的目录,有自己独立的类型加载器和类型空间。
基于OSGi.NET插件框架,我们很容易实现插件的动态安装、远程管理、自动化部署、自动升级和应用商店。下面,我来描述如何使用OSGi.NET来构建一个WPF插件应用。
3.2 基于OSGi.NET来实现WPF插件应用
利用OSGi.NET来创建一个WPF插件应用非常的简单。只需要实现:(1)创建一个插件主程序,定义插件目录;(2)在主程序中利用BootStrapper实现OSGi.NET内核升级检测与自动升级;(3)启动插件框架;(4)利用PageFlowService获取主界面,然后运行主界面。下面我们看一下插件主程序。(注:如果你安装了OSGi.NET框架,可以直接使用项目模板来创建WPF主程序项目。)
在这个主程序,我们在项目的属性将输出路径改为bin,并在bin目录下创建一个Plugins目录,然后将OSGi.NET四个标准插件拷贝到Plugins目录,它们分别用于:(1)插件远程管理,即RemotingManagement和WebServiceWrapperService,支持远程管理控制台调试用;(2)插件管理服务,即UIShell.BundleManagementService,支持对本地插件管理和插件仓库访问与下载;(3)页面流服务,即UIShell.PageFlowService,用于获取主界面。
下面我们来看一下App.xaml.cs源码,在这里实现了插件加载、启动和进入主界面的功能。
namespace UIShell.iOpenWorks.WPF { /// <summary> /// WPF startup class. /// </summary> public partial class App : Application { // Use object type to avoid load UIShell.OSGi.dll before update. private object _bundleRuntime; public App() { UpdateCore(); StartBundleRuntime(); } void UpdateCore() // Update Core Files, including BundleRepositoryOpenAPI, PageFlowService and OSGi Core assemblies. { if (AutoUpdateCoreFiles) { new CoreFileUpdater().UpdateCoreFiles(CoreFileUpdateCheckType.Daily); } } void StartBundleRuntime() // Start OSGi Core. { var bundleRuntime = new BundleRuntime(); bundleRuntime.AddService<Application>(this); bundleRuntime.Start(); Startup += App_Startup; Exit += App_Exit; _bundleRuntime = bundleRuntime; } void App_Startup(object sender, StartupEventArgs e) { Application app = Application.Current; var bundleRuntime = _bundleRuntime as BundleRuntime; app.ShutdownMode = ShutdownMode.OnLastWindowClose; #region Get the main window var pageFlowService = bundleRuntime.GetFirstOrDefaultService<IPageFlowService>(); if (pageFlowService == null) { throw new Exception("The page flow service is not installed."); } if (pageFlowService.FirstPageNode == null || string.IsNullOrEmpty(pageFlowService.FirstPageNode.Value)) { throw new Exception("There is not first page node defined."); } var windowType = pageFlowService.FirstPageNodeOwner.LoadClass(pageFlowService.FirstPageNode.Value); if (windowType == null) { throw new Exception(string.Format("Can not load Window type \'{0}\' from Bundle \'{1}\'.", pageFlowService.FirstPageNode.Value, pageFlowService.FirstPageNodeOwner.SymbolicName)); } app.MainWindow = System.Activator.CreateInstance(windowType) as Window; #endregion app.MainWindow.Show(); } void App_Exit(object sender, ExitEventArgs e) { if (_bundleRuntime != null) { var bundleRuntime = _bundleRuntime as BundleRuntime; bundleRuntime.Stop(); _bundleRuntime = null; } } // Other codes } }
上述代码非常简单,我将介绍一下每一个函数的功能。
(1)构造函数:调用UpdateCore和StartBundleRuntime;
(2)UpdateCore:调用BootStrapper程序集的CoreFileUpdater来实现内核文件升级;
(3)StartBundleRuntime:创建一个BundleRuntime,即插件框架,BundleRuntime默认构造函数指定的插件目录为Plugins;启动BundleRuntime,即启动插件框架;挂载Startup和Exit事件;
(4)在App_Startup事件处理函数中,从插件框架获取PageFlowService服务,利用该服务获取主界面,然后创建该界面实例,并运行;
(5)在App_Exit事件处理函数中,终止插件框架,释放资源。
3.3 基于ModernUI实现通用界面插件框架
我在第2节描述了通用界面框架的思路。这个界面框架将基于OSGi.NET插件框架三大功能之一——插件扩展来实现。我将按照以下顺序来描述实现。
3.3.1 OSGi.NET插件扩展原理
下图是OSGi.NET插件扩展原理,在这里,需要暴露扩展点的插件暴露一个ExtensionPoint,提供扩展的插件则声明一个Extension(XML格式),如下所示。暴露扩展点的插件通过OSGi.NET框架获取所有Extension,然后对其进行处理。
依据第2节描述,通用界面框架插件需要暴露扩展点和处理扩展。暴露扩展点意味着它需要定义界面扩展的格式。下面我来介绍扩展格式的XML定义。
3.3.2 界面扩展XML定义
根据界面框架要实现的功能,我们定义的扩展格式,如下所示。扩展点的名称为UIShell.WpfShellPlugin.LinkGroups。通过LinkGroup来定义一级菜单,通过Link来定义叶子节点菜单,通过TabLink来定义三级菜单的Tab布局方式。
<Extension Point="UIShell.WpfShellPlugin.LinkGroups"> <LinkGroup DisplayName="一级菜单" DefaultContentSource="默认显示页面"> <Link DisplayName="二级菜单" Source="二级菜单页面" /> <TabLink DisplayName="三级菜单Tab布局" DefaultContentSource="默认页面" Layout="List/Tab"> <Link DisplayName="三级菜单" Source="三级菜单页面" /> </TabLink> </LinkGroup> </Extension>
界面框架插件需要做的就是获取这样的XML定义,并且自动在界面上将元素创建出来并自动加载插件提供的页面。下面我来介绍界面框架如何实现。
3.3.3 界面框架的实现
界面框架基于ModernUI来实现,它需要完成:(1)为Extension创建扩展模型;(2)获取所有扩展模型对象,并在主界面创建界面元素;(3)监听扩展变更事件,动态变更界面元素。
首先,我们来看看扩展模型的构建。在这里,定义了LinkGroupData、TabLinkData、LinkData分别对应于扩展的XML的元素。
这里的ShellExtensionPointHandler对象则用于同OSGi.NET框架扩展扩展信息,并将其转换成扩展对象模型,然后存储在LinkGroups属性中。LinkGroups为ObservableCollection,当添加或者删除LinkGroup时会抛出Add/Remov事件。下面来看一下这个类的代码。
using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Text; using System.Xml; using UIShell.OSGi; namespace UIShell.WpfShellPlugin.ExtensionModel { public class ShellExtensionPointHandler { public const string ExtensionPointName = "UIShell.WpfShellPlugin.LinkGroups"; public IBundle Bundle { get; private set; } public ObservableCollection<LinkGroupData> LinkGroups { get; private set; } public ShellExtensionPointHandler(IBundle bundle) { Bundle = bundle; InitExtensions(); if (Bundle.Context != null) { Bundle.Context.ExtensionChanged += Context_ExtensionChanged; } } void InitExtensions() // Init { if (Bundle.Context == null) { return; } // Get all extensions. var extensions = Bundle.Context.GetExtensions(ExtensionPointName); LinkGroups = new ObservableCollection<LinkGroupData>(); // Convert extensions to LinkGroupData collection. foreach (var extension in extensions) { AddExtension(extension); } } // Handle ExtensionChanged event. void Context_ExtensionChanged(object sender, ExtensionEventArgs e) { if (e.ExtensionPoint.Equals(ExtensionPointName)) { // Create LinkGroupData objects for new Extension. if (e.Action == CollectionChangedAction.Add) { AddExtension(e.Extension); } else // Remove LinkGroupData objects respond to the Extension. { RemoveExtension(e.Extension); } } } // Convert Extension to LinkGroupData instances. void AddExtension(Extension extension) { LinkGroupData linkGroup; foreach (XmlNode node in extension.Data) { if (node is XmlComment) { continue; } linkGroup = new LinkGroupData(extension); linkGroup.FromXml(node); LinkGroups.Add(linkGroup); } } // Remove LinkGroupData instances of the Extension. void RemoveExtension(Extension extension) { var toBeRemoved = new List<LinkGroupData>(); foreach (var linkGroup in LinkGroups) { if (linkGroup.Extension.Equals(extension)) { toBeRemoved.Add(linkGroup); } } foreach (var linkGroup in toBeRemoved) { LinkGroups.Remove(linkGroup); } } } }
这个类有以下几个方法:
(1)InitExtensions:即从OSGi.NET框架获取已经注册的扩展信息,将其转换成LinkGroupData实例,并保存;
(2)Context_ExtensionChanged事件处理函数:即当Extension被添加或者删除时的处理函数,这在插件安装和卸载时发生,我们需要将新建的Extension转换成LinkGroupData实例保存起来,需要已删除的Extension对应的LinkGroupData实例移除掉。
那接下来我们来看一下主界面如何根据扩扎模型来创建或者删除界面元素。首先,你可以看到,这个主界面是空的没有预先定义任何的界面元素。
那你一定猜到了,这个界面肯定是通过代码来动态创建界面元素,我们来看看代码先。
namespace UIShell.WpfShellPlugin { /// <summary> /// Interaction logic for MainWindow.xaml /// </summary> public partial class MainWindow : ModernWindow { public static ShellExtensionPointHandler ShellExtensionPointHandler { get; set; } private List<Tuple<LinkGroupData, LinkGroup>> LinkGroupTuples { get; set; } public MainWindow() { InitializeComponent(); LinkGroupTuples = new List<Tuple<LinkGroupData, LinkGroup>>(); ShellExtensionPointHandler = new ShellExtensionPointHandler(BundleActivator.Bundle); ShellExtensionPointHandler.LinkGroups.CollectionChanged += LinkGroups_CollectionChanged; InitializeLinkGroupsForExtensions(); } void InitializeLinkGroupsForExtensions() { foreach (var linkGroupData in ShellExtensionPointHandler.LinkGroups) { CreateLinkGroupForData(linkGroupData); } // 设置第一个页面 if (ShellExtensionPointHandler.LinkGroups.Count > 0) { var first = ShellExtensionPointHandler.LinkGroups[0]; ContentSource = new Uri(first.FormatSource(first.DefaultContentSource), UriKind.RelativeOrAbsolute); } } void LinkGroups_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { Action action = () => { if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add) { // 新加了LinkGroupData foreach (LinkGroupData item in e.NewItems) { CreateLinkGroupForData(item); } } else if(e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Remove) { // 删除了LinkGroupData foreach (LinkGroupData item in e.OldItems) { RemoveLinkGroupForData(item); } } }; Dispatcher.Invoke(action); } void CreateLinkGroupForData(LinkGroupData linkGroupData) { var linkGroup = new LinkGroup { DisplayName = linkGroupData.DisplayName, GroupName = linkGroupData.GroupName }; foreach (var linkData in linkGroupData.Links) { if (linkData is LinkData) { linkGroup.Links.Add(new Link { DisplayName = linkData.DisplayName, Source = new Uri(linkData.FormatSource((linkData as LinkData).Source), UriKind.RelativeOrAbsolute) }); } else if (linkData is TabLinkData) { linkGroup.Links.Add(new Link { DisplayName = linkData.DisplayName, Source = new Uri("UIShell.WpfShellPlugin@UIShell.WpfShellPlugin.Pages.ContentPlaceHolder?LinkId=" + linkData.LinkId.ToString(), UriKind.RelativeOrAbsolute) }); } } if (linkGroupData.IsTitleLink) { TitleLinks.Add(new Link { DisplayName = linkGroupData.DisplayName, Source = new Uri(linkGroupData.FormatSource(linkGroupData.DefaultContentSource), UriKind.RelativeOrAbsolute) }); } MenuLinkGroups.Add(linkGroup); LinkGroupTuples.Add(new Tuple<LinkGroupData, LinkGroup>(linkGroupData, linkGroup)); } void RemoveLinkGroupForData(LinkGroupData linkGroupData) { var tuple = LinkGroupTuples.Find(t => t.Item1.Equals(linkGroupData)); if (tuple != null) { MenuLinkGroups.Remove(tuple.Item2); LinkGroupTuples.Remove(tuple); } } } }
上面的代码也很简单,逻辑很清晰,我来说明一下各个方法的用处:
(1)InitializeLinkGroupsForExtensions:获取扩展模型对象,并将对象转换成界面元素LinkGroup,然后监听扩展模型变更事件;
(2)LinkGroups_CollectionChanged:扩展模型变更事件,当有扩展对象添加时,需要添加新的界面元素;反之,则需要移除界面元素;
(3)CreateLinkGroupForData:为扩展模型创建界面元素LinkGroup;
(4)RemoveLinkGroupForData:当扩展模型被删除时,需要将对应的界面元素删除掉。
为了支持插件化,还需要为ModernUI做一个变更,下面我将来介绍。
3.4 ModernUI插件化支撑所做的变更
为了支持插件化,我需要对ModernUI的ContentLoader进行扩展,使其支持直接从插件加载内容页面。详细查看以下代码。
/// <summary> /// Loads the content from specified uri. /// </summary> /// <param name="uri">The content uri</param> /// <returns>The loaded content.</returns> protected virtual object LoadContent(Uri uri) { // don\'t do anything in design mode if (ModernUIHelper.IsInDesignMode) { return null; } string uriString = string.Empty; string paraString = string.Empty; Dictionary<string, string> parameters = new Dictionary<string, string>(); if (uri.OriginalString.Contains(\'?\')) { var uriPara = uri.OriginalString.Split(\'?\'); uriString = uriPara[0]; paraString = uriPara[1]; var parameterStrs = paraString.Split(\'&\'); string[] parameterStrSplitted; foreach (var parameterStr in parameterStrs) { parameterStrSplitted = parameterStr.Split(\'=\'); parameters.Add(parameterStrSplitted[0], parameterStrSplitted[1]); } } else { uriString = uri.OriginalString; } object result = null; // 1st Format: [BundleSymbolicName]@[Class Full Name] if (uriString.Contains(\'@\')) { var bundleSymbolicNameAndClass = uriString.Split(\'@\'); if (bundleSymbolicNameAndClass.Length != 2 || string.IsNullOrEmpty(bundleSymbolicNameAndClass[0]) || string.IsNullOrEmpty(bundleSymbolicNameAndClass[1])) { throw new Exception("The uri must be in format of \'[BundleSymbolicName]@[Class Full Name]\'"); } var bundle = BundleRuntime.Instance.Framework.Bundles.GetBundleBySymbolicName(bundleSymbolicNameAndClass[0]); if (bundle == null) { throw new Exception(string.Format("The uri is not correct since the bunde \'{0}\' does not exist.", bundleSymbolicNameAndClass[0])); } var type = bundle.LoadClass(bundleSymbolicNameAndClass[1]); if (type == null) { throw new Exception(string.Format("The class \'{0}\' is not found in bunle \'{1}\'.", bundleSymbolicNameAndClass[1], bundleSymbolicNameAndClass[0])); } result = Activator.CreateInstance(type); } // 2nd Format: /[AssemblyName],Version=[Version];component/[XAML relative path] else if (string.IsNullOrEmpty(paraString)) { result = Application.LoadComponent(uri); } else { result = Application.LoadComponent(new Uri(uriString, UriKind.RelativeOrAbsolute)); } ApplyProperties(result, parameters); return result; }
这集成了默认的加载行为,同时支持:(1)以“[BundleSymbolicName]@[PageClassName]”方式支持内容加载;(2)支持WPF传统资源加载方式;(3)支持参数化。
另外,为了实现三级菜单,我定义了一个ContentPlaceHolder,它用于获取第三级的菜单,并创建内容,代码如下。
namespace UIShell.WpfShellPlugin.Pages { /// <summary> /// ContentPlaceHolder.xaml 的交互逻辑 /// </summary> public partial class ContentPlaceHolder : UserControl { private string _linkId = string.Empty; private FirstFloor.ModernUI.Windows.Controls.ModernTab _tab; public string LinkId { get { return _linkId; } set { _linkId = value; TabLinkData tabLinkData = null; foreach (var linkGroupData in MainWindow.ShellExtensionPointHandler.LinkGroups) { foreach (var link in linkGroupData.Links) { if (link.LinkId.ToString().Equals(_linkId, StringComparison.OrdinalIgnoreCase)) { tabLinkData = link as TabLinkData; break; } } } if (tabLinkData != null) { _tab.SelectedSource = new Uri(tabLinkData.FormatSource(tabLinkData.DefaultContentSource), UriKind.RelativeOrAbsolute); _tab.Layout = (TabLayout)Enum.Parse(typeof(TabLayout), tabLinkData.Layout); foreach(var linkData in tabLinkData.Links) { _tab.Links.Add(new Link { DisplayName = linkData.DisplayName, Source = new Uri(linkData.FormatSource(linkData.Source), UriKind.RelativeOrAbsolute) }); } } } } public ContentPlaceHolder() { InitializeComponent(); _tab = FindName("ModernTab") as FirstFloor.ModernUI.Windows.Controls.ModernTab; } } }
它利用传递的参数可以获取对应的三级菜单的扩展模型,然后创建对应的界面元素。
到此,我们已经成功实现了整个插件化的界面框架了,文章有点长,能坚持看到这的基本属于勇士了~~,接下来还想用一点点篇幅演示一下界面框架动态性。
4 动态性演示
OSGi.NET动态性支持允许我们在程序运行中来安装、启动、停止、卸载和更新插件,请看下图。当你运行下载的程序时,最开始会展示以下菜单,其中“演示11、演示12”菜单由DemoPlugin插件注册,“演示3”由DemoPlugin2插件提供,此时,你运行一下远程管理控制台,输入list指令后,可以发现这两个插件都是Active状态。
下面我们输入“stop 2”指令,将DemoPlugin插件停止,如下图所示,此时你可以发现DemoPlugin注册的菜单已经动态的从主界面中被移除掉了。
同样,你还可以继续尝试Start、Stop、Install、Uninstall等指令来动态更改插件状态,从而影响应用程序的行为。
当然,你也可以通过“插件管理”来实现对内核安装的插件的状态变更,如下所示。
再进一步,你可以直接访问插件仓库来安装更多的插件。你可以在源码中查看到如何实现插件管理和插件仓库访问及下载安装插件的代码。
怎样,很强大吧!!如果我们构建了这样的通用框架,以后开发起来那简单多了。当然,如果你还有兴趣的话,你可以再尝试了解基于插件的一键部署和自动化升级,我在《分享让你震惊的自动化升级和部署方案,让我们一起来PK一下!》这篇文章介绍了。
好了~,非常感谢这么耐心看完这篇文章。该附上源码了~。
源码下载 点击下载。