View-Switching for Mobile in ASP.NET MVC3

So I was in a talk earlier today about the great new stuff in ASP.NET’s MVC4 framework-thing, like the ability to switch to mobile-friendly views. I’m unimpressed by this; we’ve been doing that specifically since January (if not earlier) and our implementation plays nicely with forward-caching. So I’m going to share with you how to perform this particular bit of magic.

Here’s the problem: You’ve got an web site based on Microsoft’s ASP.NET MVC3 technology and you want there to be a mobile version of it by just reskinning it. Ideally, it also won’t interfere with forward caching which you’ve paid a lot of money for some 3rd party service to handle for you. If this isn’t your problem, then you may want to skip this post.

I’m not going to tell you how to tell the difference between a mobile browser and a not-mobile browser here. If you’re on a forward-cached site, it should be working from client-side script and unless you’re certain that everybody’s going to love your mobile site I strongly recommend a link back to your desktop site — and you’ll probably want to set a cookie to avoid redirecting right back to your mobile site.

What I am going to tell you is how to load in a custom RenderViewEngine that will, by naming convention you need to follow, try to swap from expected views to special views for flags that you set on routes. And you want to split the routes because that’s good for the forward caching (even though there is an odious on-load-redirect antipattern to start with — I don’t know how to get around that one).

So first of all, we need to put in the routes.

public static void RegisterRoutes(RouteCollection routes, IConfigurationManager config)
{
    // Whatever else you've got in here and also...
    routes.MapRoute("MobileDefault", "m/{controller}/{action}/{id}",
        new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        ).DataTokens["isMobile"] = true;
    routes.MapRoute("Default", "{controller}/{action}/{id}", 
        new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
}

Note that we’ve added an “m/” to the base of the path so we can redirect from one to the other and keep them separate in forward caching so that the mobile one has .DataTokens[“isMobile”] = true. If we weren’t forward caching, we could just skip the spare route and handle browser detection in a filter — but forward caching makes it impossible to rely on server-side code executing so we’re doing this the painful way. (Hypothetically, we could have our forward caching provider vary by headers but that could get really quite expensive as we’d be paying per-browser — so, no.)

Next, we’ll need to do something with that .DataToken. So we’re going to attach it to our current HttpContext in a GlobalFilter like this:

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
    filters.Add(new CheckMobilityAttribute());
}

And the CheckMobilityAttribute looks like this, which is where you would almost certainly put your server-side mobility check if you weren’t forward cached and/or could rely on the server-side code executing for everybody.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Threading;
using System.Web.Routing;

namespace Sample.Classes.Filters
{
    public class CheckMobilityAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            // Get our context and our route data
            var currentContext = new HttpContextWrapper(HttpContext.Current);
            var routeData = RouteTable.Routes.GetRouteData(currentContext);
            if (routeData == null || routeData.Values.Count == 0) return;

            // Do all of the usual stuff
            base.OnActionExecuting(filterContext);

            // And then check for mobile
            object isMobile = null;
            routeData.DataTokens.TryGetValue("isMobile", out isMobile);
            if (isMobile != null && (bool)isMobile == true) {
                HttpContext.Current.Items["isMobileDevice"] = true;
            }
        }
    }
}

So that peels the isMobile data token off of the route information and hooks it onto the HttpContext so it’s available to us elsewhere.

But the real magic is the special ViewEngine. We’re going to take the Application_Start, clear out the default ViewEngine and insert our own, like this:

protected void Application_Start()
{
    // Whatever else you've got in here,
    // Especially route mapping, but also...
    RegisterGlobalFilters(GlobalFilters.Filters);
    System.Web.Mvc.ViewEngines.Engines.Clear();
    System.Web.Mvc.ViewEngines.Engines.Add(new TieredViewEngine());
    // etc
}

Now what is a custom view engine? It’s this!

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Mvc;

namespace Samples
{
    class TieredViewEngine : RazorViewEngine
    {
        public TieredViewEngine() : base() { }

        public override ViewEngineResult FindView(
            ControllerContext controllerContext, string viewName, 
            string masterName, bool useCache)
        {
            ViewEngineResult result = null;
            string actualView = viewName;

            base.ViewLocationFormats = new[] {
                "~/Views/{1}/{0}.cshtml",
                "~/Views/Shared/{0}.cshtml",
            };

            base.PartialViewLocationFormats = new[] {
                "~/Views/{1}/{0}.cshtml",
                "~/Views/Shared/{0}.cshtml",
            };

            object isMobile = HttpContext.Current.Items["isMobileDevice"];
            if (isMobile != null && (bool)isMobile == true)
            {
                actualView = actualView + "MOB";
            }

            result = base.FindView(controllerContext, actualView, masterName, useCache);
            if(result.View == null && !useCache)
            {
               result = base.FindView(controllerContext, viewName, masterName, useCache);
            }

            return result;
        }
    }
}

Okay, what’s happening is:

  1. We’re putting the ViewLocationFormats and PartialViewLocationFormats back the way we pretty much found them, having brutalized them out of existence.
  2. We query the isMobileDevice value that we hooked onto the HttpContext. If it’s there and true, then we take the View name that MVC was looking for and change it to something else. For example, a user looking for the home view’s mobile version would get MVC to look for the homeMOB view first instead.
  3. But pay close attention! There’s an if statement there at the end which handles the case where a MOB version of a view isn’t provided such that MVC will look back for the default named view instead.
  4. The actual view loading process is being handled by the base class; I’m totally not re-doing the plumbing on those.

There is a caching anomaly in here that you must be warned about! Because if you’re like me, the first thing you want to do is stuff all of your one-off views in their own folder and let them just keep their polite-looking names. This won’t work: MVC is going to try to cache the view reference by name, such that if two views share the same name, the first one found is going to be cached and used for all subsequent requests even if a quick glance at the path on a subsequent request would result in the other view being used. At least, that’s how I remember it biting me in the butt every time I add a new set of one-off views for whatever reason.

But that’s how we’ve been doing our knock-off of MVC4’s DisplayModeProvider since January… longer if you count culture-specific views, but that goes into more detail than I’m going to provide here.

This code is sanitized out of my day job. I can’t guarantee that it’s “right,” only that it works for us. Hope it helps!