上一篇文章[剖析 NopCommerce 的 Theme 機制]介紹了Nop實現Theme的實現原理。但由於Nop要為Admin和Mobile做特殊處理,因此寫了太多的其它東西。因此我們決定自己寫一個Theme的ViewEngine,僅僅用來實現皮膚功能。
需求分析
考慮到Demo程序,為簡單起見,我們將Theme放到Url中,格式:
{Controller}/{Action}?Theme={Theme},當然你完全可以從Cookie或者數據庫中去讀取用戶設置的Theme信息。
其次,Theme文件夾的組織結構,就采用NopCommerce的這種文件夾結構吧。
Themes/{Theme}/Views/…
新建站點
首先新建一個MVC 4 的默認站點 ThemeDemo,用默認的Internet 模版吧。
實現ThemeViewEngine
自定義Theme的ViewEngine,並從RazorViewEngine繼承。在構造函數中設置View的路徑模版:

public ThemeViewEngine(IViewPageActivator viewPageActivator) : base(viewPageActivator) { this.AreaViewLocationFormats = new[] { "~/Themes/{3}/Areas/{2}/Views/{1}/{0}.cshtml", "~/Themes/{3}/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Themes/{3}/Areas/{2}/Views/Shared/{0}.cshtml", "~/Themes/{3}/Areas/{2}/Views/Shared/{0}.vbhtml", "~/Areas/{2}/Views/{1}/{0}.cshtml", "~/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Areas/{2}/Views/Shared/{0}.cshtml", "~/Areas/{2}/Views/Shared/{0}.vbhtml" }; this.AreaMasterLocationFormats = new[] { "~/Themes/{3}/Areas/{2}/Views/{1}/{0}.cshtml", "~/Themes/{3}/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Themes/{3}/Areas/{2}/Views/Shared/{0}.cshtml", "~/Themes/{3}/Areas/{2}/Views/Shared/{0}.vbhtml", "~/Areas/{2}/Views/{1}/{0}.cshtml", "~/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Areas/{2}/Views/Shared/{0}.cshtml", "~/Areas/{2}/Views/Shared/{0}.vbhtml" }; this.AreaPartialViewLocationFormats = new[] { "~/Themes/{3}/Areas/{2}/Views/{1}/{0}.cshtml", "~/Themes/{3}/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Themes/{3}/Areas/{2}/Views/Shared/{0}.cshtml", "~/Themes/{3}/Areas/{2}/Views/Shared/{0}.vbhtml", "~/Areas/{2}/Views/{1}/{0}.cshtml", "~/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Areas/{2}/Views/Shared/{0}.cshtml", "~/Areas/{2}/Views/Shared/{0}.vbhtml" }; this.ViewLocationFormats = new[] { "~/Themes/{2}/Views/{1}/{0}.cshtml", "~/Themes/{2}/Views/{1}/{0}.vbhtml", "~/Themes/{2}/Views/Shared/{0}.cshtml", "~/Themes/{2}/Views/Shared/{0}.vbhtml", "~/Views/{1}/{0}.cshtml", "~/Views/{1}/{0}.vbhtml", "~/Views/Shared/{0}.cshtml", "~/Views/Shared/{0}.vbhtml" }; this.MasterLocationFormats = new[] { "~/Themes/{2}/Views/{1}/{0}.cshtml", "~/Themes/{2}/Views/{1}/{0}.vbhtml", "~/Themes/{2}/Views/Shared/{0}.cshtml", "~/Themes/{2}/Views/Shared/{0}.vbhtml", "~/Views/{1}/{0}.cshtml", "~/Views/{1}/{0}.vbhtml", "~/Views/Shared/{0}.cshtml", "~/Views/Shared/{0}.vbhtml" }; this.PartialViewLocationFormats = new[] { "~/Themes/{2}/Views/{1}/{0}.cshtml", "~/Themes/{2}/Views/{1}/{0}.vbhtml", "~/Themes/{2}/Views/Shared/{0}.cshtml", "~/Themes/{2}/Views/Shared/{0}.vbhtml", "~/Views/{1}/{0}.cshtml", "~/Views/{1}/{0}.vbhtml", "~/Views/Shared/{0}.cshtml", "~/Views/Shared/{0}.vbhtml" }; this.FileExtensions = new[] { "cshtml", "vbhtml" }; GetExtensionThunk = new Func<string, string>(VirtualPathUtility.GetExtension); }
PS:我是從RazorViewEngine源碼里面拷貝出默認的View路徑模版,然后加上Themes的View路徑模版。
實現接口:IViewEngine的方法 FindView 和 FindPartialView
通過反編譯VirtualPathProviderViewEngine,發現FindView和FindPartialView的2個方法均會訪問 GetPath方法,而GetPath方法的作用是根據Controller和Action,返回View的實際路徑。理論上講,只要重寫GetPath,並根據Theme生成新的View路徑。 但由於GetPath是內部方法,我們無法重寫,於是我們不得不重寫FindView和FindPartialView 2個方法。這些工作其實也不難,就是Ctrl+Cà Ctrl+V,我相信大家都很熟練這們技術了,也就略去不講。下面重點介紹下自定義GetPath的具體實現:

protected virtual string GetPath(ControllerContext controllerContext, string[] locations, string[] areaLocations, string locationsPropertyName, string name, string controllerName, string cacheKeyPrefix, bool useCache, out string[] searchedLocations) { string theme = GetCurrentTheme(controllerContext); searchedLocations = _emptyLocations; if (string.IsNullOrEmpty(name)) return string.Empty; string areaName = GetAreaName(controllerContext.RouteData); bool flag = !string.IsNullOrEmpty(areaName); List<ThemeViewLocation> viewLocations = GetViewLocations(locations, flag ? areaLocations : null); if (viewLocations.Count == 0) { throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, "Properties cannot be null or empty.", new object[] { locationsPropertyName })); } bool isSpecificPath = IsSpecificPath(name); string key = this.CreateCacheKey(cacheKeyPrefix, name, isSpecificPath ? string.Empty : controllerName, areaName, theme); if (useCache) { var cached = this.ViewLocationCache.GetViewLocation(controllerContext.HttpContext, key); if (cached != null) { return cached; } } if (!isSpecificPath) { return this.GetPathFromGeneralName(controllerContext, viewLocations, name, controllerName, areaName, theme, key, ref searchedLocations); } return this.GetPathFromSpecificName(controllerContext, name, key, ref searchedLocations); }
- 讀取當前的Theme,可根據實際需求自定義,這里是從QueryString或者Form中讀取的
protected virtual string GetCurrentTheme(ControllerContext controllerContext) { var theme = controllerContext.RequestContext.HttpContext.Request["Theme"]; return theme; }
- 讀取Area,調用方法GetAreaName,拷貝自VirtualPathProviderViewEngine

protected virtual string GetAreaName(RouteBase route) { var area = route as IRouteWithArea; if (area != null) { return area.Area; } var route2 = route as Route; if ((route2 != null) && (route2.DataTokens != null)) { return (route2.DataTokens["area"] as string); } return null; }
- 獲取ViewLocation的相關信息,該類是用來根據參數,從View路徑模版生成實際的View路徑。自定義如下2個類,參考VirtualPathProviderViewEngine,Format方法增加Theme參數

public class ThemeAreaAwareViewLocation : ThemeViewLocation { public ThemeAreaAwareViewLocation(string virtualPathFormatString) : base(virtualPathFormatString) { } public override string Format(string viewName, string controllerName, string areaName, string theme) { return string.Format(CultureInfo.InvariantCulture, _virtualPathFormatString, viewName, controllerName, areaName, theme); } } public class ThemeViewLocation { protected readonly string _virtualPathFormatString; public ThemeViewLocation(string virtualPathFormatString) { _virtualPathFormatString = virtualPathFormatString; } public virtual string Format(string viewName, string controllerName, string areaName, string theme) { return string.Format(CultureInfo.InvariantCulture, _virtualPathFormatString, viewName, controllerName, theme); } }
- 先從緩存中讀取View的實際路徑,如果不存在,則通過方法GetPathFromGeneralName 獲取View的實際路徑信息

protected virtual string GetPathFromGeneralName(ControllerContext controllerContext, List<ThemeViewLocation> locations, string name, string controllerName, string areaName, string theme, string cacheKey, ref string[] searchedLocations) { string virtualPath = string.Empty; searchedLocations = new string[locations.Count]; for (int i = 0; i < locations.Count; i++) { string str2 = locations[i].Format(name, controllerName, areaName, theme); if (this.FileExists(controllerContext, str2)) { searchedLocations = _emptyLocations; virtualPath = str2; this.ViewLocationCache.InsertViewLocation(controllerContext.HttpContext, cacheKey, virtualPath); return virtualPath; } searchedLocations[i] = str2; } return virtualPath; }
該方法會將參數Theme、Controller和Action等傳入上文提到的View路徑模版,生成實際的路徑,如果文件不存在,繼續嘗試下一個View路徑模版。直到找到View存在的實際路徑。然后緩存起來,提高效率。
- 最后將ThemeViewEngine注入到MVC中
//remove all view engines ViewEngines.Engines.Clear(); //except the themeable razor view engine we use ViewEngines.Engines.Add(new ThemeViewEngine());
創建Theme
首先新建一個Theme取名叫Black吧。將背景色設置為黑色。
Web.Config 是從Views文件夾下面拷貝過來的。將默認的_Layout.cshtml按照文件夾的結構拷貝過來,並修改如下樣式:
<body style="background-color: black; color: white;">
PS:如果不拷貝Web.Config,View里面沒有智能提示。
測試
到這里,我已經有點激動了,Theme功能馬上就要實現了。顯然大部分代碼都是從微軟的代碼哪里拷貝的,自定義的幾行代碼絕對有信心,但做集成測試還是必不可少。至少要看到Theme的效果吧。
運行項目,Ctrl+F5
鍵入Theme參數:
悲劇發生了,沒有預期的效果。各種Debug,自定義代碼均能夠正常工作,說明自定義ViewEngine正常。通過Debug NopCommerce的ViewEngine對比發現:Nop每次請求,自定義的ViewEngine都會調用FindView去查找MasterPage的路徑。而我們自定義的ThemeViewEngine不會通過FindView去查找MasterPage的路徑。故讀取MasterPage還是從原來的路徑讀取。我們自定義的ThemeViewEngine只對View和Partial View有效,對於MasterPage無效。問題終於找到,接下來該如何解決呢?難道要我們放棄對於MasterPage的支持嗎?顯然對於挑剔的我是無法接受的。
既然NopCommerce實現了整個功能,肯定還有什么機關我們沒有觸碰到。通過查找Nop的源碼發現了抽象類:WebViewPage,通過Reshareper發現該類“未被”任何類繼承。但發現所有的View都繼承自該類。該類重寫了基類的Layout屬性,而這里是通過ViewEngine重新獲取MasterPage文件路徑的。

public abstract class WebViewPage<TModel> : System.Web.Mvc.WebViewPage<TModel> { public override string Layout { get { var layout = base.Layout; if (!string.IsNullOrEmpty(layout)) { var filename = System.IO.Path.GetFileNameWithoutExtension(layout); ViewEngineResult viewResult = System.Web.Mvc.ViewEngines.Engines.FindView(ViewContext.Controller.ControllerContext, filename, ""); if (viewResult.View != null && viewResult.View is RazorView) { layout = (viewResult.View as RazorView).ViewPath; } } return layout; } set { base.Layout = value; } } }
我們依葫蘆畫瓢也寫一個WebViewPage。然后在Web.Config做如下配置:
<system.web.webPages.razor> <host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" /> <pages pageBaseType="ThemeDemo.Mvc.WebViewPage"> <namespaces> <add namespace="System.Web.Mvc" /> <add namespace="System.Web.Mvc.Ajax" /> <add namespace="System.Web.Mvc.Html" /> <add namespace="System.Web.Optimization"/> <add namespace="System.Web.Routing" /> </namespaces> </pages> </system.web.webPages.razor>
(PS:記得在Theme下的Web.Config中也要做同樣的修改)
再次調試:
黑色的背景白色的字,終於出現了。什么這也太丑了吧….
后記
要實現一個好的皮膚機制,除了解決皮膚文件的定位之外,還有很多工作要做。比如要對頁面布局有一個精細地設計,對兼容性、擴展性、復用性都有一個全面的考慮。
文中示例源碼下載地址:http://files.cnblogs.com/coolite/ThemeDemo.zip