在上文《分享一個非常漂亮的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一下!》這篇文章介紹了。
好了~,非常感謝這么耐心看完這篇文章。該附上源碼了~。
源碼下載 點擊下載。