代码改变世界

ASP.NET MVC 下打造轻量级的 Theme 机制

2012-12-28 23:16  Zork  阅读(4670)  评论(9编辑  收藏  举报

上一篇文章[剖析 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的路径模版:

View Code
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的具体实现:

View Code
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
View Code
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参数
View Code
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的实际路径信息 
View Code
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文件路径的。

View Code
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中也要做同样的修改)

 

再次调试:

 

黑色的背景白色的字,终于出现了。什么这也太丑了吧….

 

后记

要实现一个好的皮肤机制,除了解决皮肤文件的定位之外,还有很多工作要做。比如要对页面布局有一个精细地设计,对兼容性、扩展性、复用性都有一个全面的考虑。

文中示例源码下载地址:https://files.cnblogs.com/coolite/ThemeDemo.zip