原文链接http://www.cnblogs.com/coolite/archive/2012/12/28/NopTheme.html
前言
目前开源的CMS、Blog或者电子商务站点,他们都有一个共同的亮点,无疑就是可任意切换皮肤,并且定制和扩展能力都非常强。在这方面PHP可以说做的是最好的。那么我们如何能够在我们的ASP.NET MVC站点下面实现任意切换皮肤呢?NopCommerce—开源的 ASP.NET MVC 电子商务站点,它提供了强大的换肤功能,可通过一键切换皮肤。那接下来,我们就一起去寻找换肤的秘诀,让我们的ASP.NET MVC站点也具有一键换肤的功能吧。
换肤试用
先试用下Nop站点的换肤效果吧,打开Nop的源码,下载地址:http://nopcommerce.codeplex.com, 按照官方的Theme制作方法:http://www.nopcommerce.com/docs/72/designers-guide.aspx。
具体步骤戳这儿
换肤后的思考?
我们刚才制作皮肤的时候,将默认的皮肤文件夹下所有的文件拷贝到新的皮肤文件夹下面,并做了样式和HTML结构的修改。Nop应该是根据客户选择的皮肤定位到相应的皮肤文件夹下面,去找到View并加载出来。那实现换肤功能的关键就是: 根据用户选择的皮肤,ASP.NET MVC动态定位到皮肤文件夹下的View,并呈现出来。
做过ASP.NET MVC开发的朋友都知道,如果在Controller里面新建一个Action,但View不存在,页面肯定会报如下错误:
从异常信息可以看出,ASP.NET MVC内部有一种加载View的机制。如果我们能够扩展这种内部的加载View的机制,去按照我们的自定义逻辑根据不同的皮肤加载不同的View,那我们的站点就能够实现换肤功能了。实现这个功能的核心就是IViewEngine,资料介绍:http://www.cnblogs.com/answercard/archive/2011/05/07/2039809.html。该接口定义如下(此段代码不在NopCommerce里):
1 /// <summary> 2 /// Defines the methods that are required for a view engine. 3 /// </summary> 4 public interface IViewEngine 5 { 6 /// <summary> 7 /// Finds the specified partial view by using the specified controller context. 8 /// </summary> 9 ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache); 10 /// <summary> 11 /// Finds the specified view by using the specified controller context. 12 /// </summary> 13 ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache); 14 /// <summary> 15 /// Releases the specified view by using the specified controller context. 16 /// </summary> 17 /// <param name="controllerContext">The controller context.</param><param name="view">The view.</param> 18 void ReleaseView(ControllerContext controllerContext, IView view); 19 }
深入Nop,找到幕后黑手
那我们就到Nop的源代码中去寻找 IViewEngine 的实现类,看看运气如何? 运气不错,找到了3个Themeable****ViewEngine. 从名字就可以断定该类是用来实现Theme的。
(Tips: 借助Reshareper可轻松的查找某个接口的实现类,此外Reshareper还有其它的高级功能,谁用谁知道…)
先看看离接口IViewEngine最近的类—ThemeableVirtualPathProviderViewEngine,该类重写了FindView 和 FindPartialView 2个方法。我们以FindView为例进行研究吧,实际上FindPartialView跟FindView都差不多。
1 public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache) 2 { 3 var mobileDeviceHelper = EngineContext.Current.Resolve<IMobileDeviceHelper>(); 4 bool useMobileDevice = mobileDeviceHelper.IsMobileDevice(controllerContext.HttpContext) 5 && mobileDeviceHelper.MobileDevicesSupported() 6 && !mobileDeviceHelper.CustomerDontUseMobileVersion(); 7 8 string overrideViewName = useMobileDevice ? 9 string.Format("{0}.{1}", viewName, _mobileViewModifier) 10 : viewName; 11 12 ViewEngineResult result = FindThemeableView(controllerContext, overrideViewName, masterName, useCache, useMobileDevice); 13 // If we're looking for a Mobile view and couldn't find it try again without modifying the viewname 14 if (useMobileDevice && (result == null || result.View == null)) 15 result = FindThemeableView(controllerContext, viewName, masterName, useCache, false); 16 return result; 17 18 }
查找View的重担放到了内部方法FindThemeableView中完成的,看看该方法的实现吧:
1 protected virtual ViewEngineResult FindThemeableView(ControllerContext controllerContext, string viewName, string masterName, bool useCache, bool mobile) 2 { 3 string[] strArray; 4 string[] strArray2; 5 if (controllerContext == null) 6 { 7 throw new ArgumentNullException("controllerContext"); 8 } 9 if (string.IsNullOrEmpty(viewName)) 10 { 11 throw new ArgumentException("View name cannot be null or empty.", "viewName"); 12 } 13 var theme = GetCurrentTheme(mobile); 14 string requiredString = controllerContext.RouteData.GetRequiredString("controller"); 15 string str2 = this.GetPath(controllerContext, this.ViewLocationFormats, this.AreaViewLocationFormats, "ViewLocationFormats", viewName, requiredString, theme, "View", useCache, mobile, out strArray); 16 string str3 = this.GetPath(controllerContext, this.MasterLocationFormats, this.AreaMasterLocationFormats, "MasterLocationFormats", masterName, requiredString, theme, "Master", useCache, mobile, out strArray2); 17 if (!string.IsNullOrEmpty(str2) && (!string.IsNullOrEmpty(str3) || string.IsNullOrEmpty(masterName))) 18 { 19 return new ViewEngineResult(this.CreateView(controllerContext, str2, str3), this); 20 } 21 if (strArray2 == null) 22 { 23 strArray2 = new string[0]; 24 } 25 return new ViewEngineResult(strArray.Union<string>(strArray2)); 26 27 }
这段代码读起来有点费力,又一次告诉大家命名的重要性,当然你不想让别人看懂你的代码那就是另外一回事哈。
str2实际上是ViewPath,str3是MasterPagePath。
其中内部方法GetPath是用来获取View的实际路径。
我们来研究下GetPath的参数吧,其中最关键的是属性ViewLocationFormats和AreaViewLocationFormats。
由于ThemeableVirtualPathProviderViewEngine是抽象类,我们看看派生自该类的ThemeableRazorViewEngine吧(ThemeableRazorViewEngine继承自
ThemeableBuildManagerViewEngine ,ThemeableBuildManagerViewEngine又继承ThemeableVirtualPathProviderViewEngine,所以,你懂的):
1 public ThemeableRazorViewEngine() 2 { 3 AreaViewLocationFormats = new[] 4 { 5 //themes 6 "~/Areas/{2}/Themes/{3}/Views/{1}/{0}.cshtml", 7 "~/Areas/{2}/Themes/{3}/Views/{1}/{0}.vbhtml", 8 "~/Areas/{2}/Themes/{3}/Views/Shared/{0}.cshtml", 9 "~/Areas/{2}/Themes/{3}/Views/Shared/{0}.vbhtml", 10 11 //default 12 "~/Areas/{2}/Views/{1}/{0}.cshtml", 13 "~/Areas/{2}/Views/{1}/{0}.vbhtml", 14 "~/Areas/{2}/Views/Shared/{0}.cshtml", 15 "~/Areas/{2}/Views/Shared/{0}.vbhtml" 16 }; 17 18 AreaMasterLocationFormats = new[] 19 { 20 //themes 21 "~/Areas/{2}/Themes/{3}/Views/{1}/{0}.cshtml", 22 "~/Areas/{2}/Themes/{3}/Views/{1}/{0}.vbhtml", 23 "~/Areas/{2}/Themes/{3}/Views/Shared/{0}.cshtml", 24 "~/Areas/{2}/Themes/{3}/Views/Shared/{0}.vbhtml", 25 26 27 //default 28 "~/Areas/{2}/Views/{1}/{0}.cshtml", 29 "~/Areas/{2}/Views/{1}/{0}.vbhtml", 30 "~/Areas/{2}/Views/Shared/{0}.cshtml", 31 "~/Areas/{2}/Views/Shared/{0}.vbhtml" 32 }; 33 34 AreaPartialViewLocationFormats = new[] 35 { 36 //themes 37 "~/Areas/{2}/Themes/{3}/Views/{1}/{0}.cshtml", 38 "~/Areas/{2}/Themes/{3}/Views/{1}/{0}.vbhtml", 39 "~/Areas/{2}/Themes/{3}/Views/Shared/{0}.cshtml", 40 "~/Areas/{2}/Themes/{3}/Views/Shared/{0}.vbhtml", 41 42 //default 43 "~/Areas/{2}/Views/{1}/{0}.cshtml", 44 "~/Areas/{2}/Views/{1}/{0}.vbhtml", 45 "~/Areas/{2}/Views/Shared/{0}.cshtml", 46 "~/Areas/{2}/Views/Shared/{0}.vbhtml" 47 }; 48 49 ViewLocationFormats = new[] 50 { 51 //themes 52 "~/Themes/{2}/Views/{1}/{0}.cshtml", 53 "~/Themes/{2}/Views/{1}/{0}.vbhtml", 54 "~/Themes/{2}/Views/Shared/{0}.cshtml", 55 "~/Themes/{2}/Views/Shared/{0}.vbhtml", 56 57 //default 58 "~/Views/{1}/{0}.cshtml", 59 "~/Views/{1}/{0}.vbhtml", 60 "~/Views/Shared/{0}.cshtml", 61 "~/Views/Shared/{0}.vbhtml", 62 63 64 //Admin 65 "~/Administration/Views/{1}/{0}.cshtml", 66 "~/Administration/Views/{1}/{0}.vbhtml", 67 "~/Administration/Views/Shared/{0}.cshtml", 68 "~/Administration/Views/Shared/{0}.vbhtml", 69 }; 70 71 MasterLocationFormats = new[] 72 { 73 //themes 74 "~/Themes/{2}/Views/{1}/{0}.cshtml", 75 "~/Themes/{2}/Views/{1}/{0}.vbhtml", 76 "~/Themes/{2}/Views/Shared/{0}.cshtml", 77 "~/Themes/{2}/Views/Shared/{0}.vbhtml", 78 79 //default 80 "~/Views/{1}/{0}.cshtml", 81 "~/Views/{1}/{0}.vbhtml", 82 "~/Views/Shared/{0}.cshtml", 83 "~/Views/Shared/{0}.vbhtml" 84 }; 85 86 PartialViewLocationFormats = new[] 87 { 88 //themes 89 "~/Themes/{2}/Views/{1}/{0}.cshtml", 90 "~/Themes/{2}/Views/{1}/{0}.vbhtml", 91 "~/Themes/{2}/Views/Shared/{0}.cshtml", 92 "~/Themes/{2}/Views/Shared/{0}.vbhtml", 93 94 //default 95 "~/Views/{1}/{0}.cshtml", 96 "~/Views/{1}/{0}.vbhtml", 97 "~/Views/Shared/{0}.cshtml", 98 "~/Views/Shared/{0}.vbhtml", 99 100 //Admin 101 "~/Administration/Views/{1}/{0}.cshtml", 102 "~/Administration/Views/{1}/{0}.vbhtml", 103 "~/Administration/Views/Shared/{0}.cshtml", 104 "~/Administration/Views/Shared/{0}.vbhtml", 105 }; 106 107 FileExtensions = new[] { "cshtml", "vbhtml" }; 108 }
看到这里你是否了解有些明白了?这里就是定义的查找View的路径的模版,程序(Nop和MVC默认实现都是相同的策略)会按照顺序依次查找View是否存在。
实际上就是Theme的路由机制。
再来看看GetPath的实现吧:
1 protected virtual string GetPath(ControllerContext controllerContext, string[] locations, string[] areaLocations, string locationsPropertyName, string name, string controllerName, string theme, string cacheKeyPrefix, bool useCache, bool mobile, out string[] searchedLocations) 2 { 3 searchedLocations = _emptyLocations; 4 if (string.IsNullOrEmpty(name)) 5 { 6 return string.Empty; 7 } 8 string areaName = GetAreaName(controllerContext.RouteData); 9 10 //little hack to get nop's admin area to be in /Administration/ instead of /Nop/Admin/ or Areas/Admin/ 11 if (!string.IsNullOrEmpty(areaName) && areaName.Equals("admin", StringComparison.InvariantCultureIgnoreCase)) 12 { 13 //admin area does not support mobile devices 14 if (mobile) 15 { 16 searchedLocations = new string[0]; 17 return string.Empty; 18 } 19 var newLocations = areaLocations.ToList(); 20 newLocations.Insert(0, "~/Administration/Views/{1}/{0}.cshtml"); 21 newLocations.Insert(0, "~/Administration/Views/{1}/{0}.vbhtml"); 22 newLocations.Insert(0, "~/Administration/Views/Shared/{0}.cshtml"); 23 newLocations.Insert(0, "~/Administration/Views/Shared/{0}.vbhtml"); 24 areaLocations = newLocations.ToArray(); 25 } 26 27 bool flag = !string.IsNullOrEmpty(areaName); 28 List<ViewLocation> viewLocations = GetViewLocations(locations, flag ? areaLocations : null); 29 if (viewLocations.Count == 0) 30 { 31 throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, "Properties cannot be null or empty.", new object[] { locationsPropertyName })); 32 } 33 bool flag2 = IsSpecificPath(name); 34 string key = this.CreateCacheKey(cacheKeyPrefix, name, flag2 ? string.Empty : controllerName, areaName, theme); 35 if (useCache) 36 { 37 var cached = this.ViewLocationCache.GetViewLocation(controllerContext.HttpContext, key); 38 if (cached != null) 39 { 40 return cached; 41 } 42 } 43 if (!flag2) 44 { 45 return this.GetPathFromGeneralName(controllerContext, viewLocations, name, controllerName, areaName, theme, key, ref searchedLocations); 46 } 47 return this.GetPathFromSpecificName(controllerContext, name, key, ref searchedLocations); 48 }
这里明显使用了缓存,所以大家不用担心每次读取View都要依次去进行IO操作去查找View引起的性能问题。
最后我们再来看看GetPathFromGeneralName的具体实现吧:
1 protected virtual string GetPathFromGeneralName(ControllerContext controllerContext, List<ViewLocation> locations, string name, string controllerName, string areaName, string theme, string cacheKey, ref string[] searchedLocations) 2 { 3 string virtualPath = string.Empty; 4 searchedLocations = new string[locations.Count]; 5 for (int i = 0; i < locations.Count; i++) 6 { 7 string str2 = locations[i].Format(name, controllerName, areaName, theme); 8 if (this.FileExists(controllerContext, str2)) 9 { 10 searchedLocations = _emptyLocations; 11 virtualPath = str2; 12 this.ViewLocationCache.InsertViewLocation(controllerContext.HttpContext, cacheKey, virtualPath); 13 return virtualPath; 14 } 15 searchedLocations[i] = str2; 16 } 17 return virtualPath; 18 }
该方法会将参数Theme、Controller和Action传入上文提到的View路径模版,生成实际的路径,如果文件不存在,继续尝试下一个View路径模版。直到找到View存在的实际路径。
偷梁换柱,让MVC使用自定义的ViewEngine
Nop是通过在Global文件的事件Application_Start中注入以下代码:
if (databaseInstalled) { //remove all view engines ViewEngines.Engines.Clear(); //except the themeable razor view engine we use ViewEngines.Engines.Add(new ThemeableRazorViewEngine()); }
总结
到这里,你是否已经知道了Nop实现Theme的奥秘?但又觉得过于复杂?其实Nop实现Theme的同时,还为Mobile和Admin管理后台做了很多特殊处理,所以代码看起来有点乱。那我们就来自己动手打造一个轻量级的ThemeViewEngine吧。预知后事如何,请戳这儿。