Doing Crazy Things with ASP.NET MVC 4's Display Modes

Nothing I say here is recommended and it all may end up being a bunch of terrible ideas, but when has that stopped me before? Also, this is built on preview bits, so things are very likely going to change. Now, let's get funky with with the DisplayMode functionality in ASP.NET MVC 4 Preview.

There is a new class available for us to use in the ASP.NET MVC 4 preview that helps with mobile, and it's called DefaultDisplayMode. Here is an example of how you could use it:


protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();

    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);

    //New stuff here
    DisplayModes.Modes.Insert(0, new DefaultDisplayMode("android")
    {
        ContextCondition = (context => context.Request.UserAgent.IndexOf("android", StringComparison.OrdinalIgnoreCase) > -1)
    });
}

So this is pretty nifty. So you can take a typical controller action and view (assume the default "home/index" pair you get with the default site), which would use a "Index.cshtml" view, and add a new one called "Index.android.cshtml", you now get a view specific for an android device. The framework bits actually come with two by default, which you can see if you debug and do a quick watch on the Modes property of DisplayModes.

Default display modes in ASP.NET MVC 4 Preview

The first display mode (before we add one) has a DisplayModeId of "Mobile". So there is built-in functionality to do what we did for android generically with mobile. So you create another view called "Index.Mobile.cshtml" and view the page. If you view it with an Android device, you should see Index.android.cshtml. If you use another mobile device that isn't Android, you should see Index.Mobile.cshtml. Otherwise, you should see the normal Index.cshtml page. The different display modes execute in order in the collection, so if you want the Android view to be used first, then you have to insert it at the beginning of the list as we did above. But that's somewhat expected usage.

Let's Get Crazy

So like I said above, this may not be a good idea. Just because you can do something doesn't mean you should :) To start out on our path to crazy, let's create a custom DefaultDisplayMode derivative. There is a method we want to override, so let's do that, even if we don't change anything yet.


public class PotentiallyCrazyDisplayMode : DefaultDisplayMode
{
    protected override string TransformPath(string virtualPath, string suffix)
    {
        return base.TransformPath(virtualPath, suffix);
    }
}

I want to do something with this that I would have created a new ViewEngine to do in ASP.NET MVC 3. I want to create A/B test functionality so I can serve up different views to different people. If I were a business, I might use this to measure the effects of a new design or of new content/functionality on a page. The uses are potentially limitless, but here we just discuss the technical implementation. To make this kind of decision, we need to have something to split up our users. So let's do that in our controller action for our index page:


public class HomeController : Controller
{
    public ActionResult Index()
    {
        if (Session["testid"] == null)
            Session["testid"] = new Random().Next(0, 3);

        return View();
    }
}

We're assigning either a value of either "0", "1" or "2" to the session value "testid" in session. If "0", then we'll just use the default skin. Otherwise, we'll use that number to lookup a skin version. Now let's move to our CrazyDisplayMode class and use that to determine the test version, and transform the path to pick it up. We immediately run into a little problem because the method we would override to do this action, "TransformPath", does not have direct access to anything HttpContext related. This is a bit suboptimal but we can work around it. We could directly reference HttpContext.Current but every time I do that I feel like throwing up, so I'll hijack another method and do this session check. I've created a constructor that takes the suffix, so here is our more pimped out but slightly crazy DisplayMode derivative:


public class PotentiallyCrazyDisplayMode : DefaultDisplayMode
{
    public PotentiallyCrazyDisplayMode(string suffix) : base(suffix) { }

    public int? DisplayVersion { get; set; }

    public override DisplayInfo GetDisplayInfo(HttpContextBase httpContext, string virtualPath, Func<string, bool> virtualPathExists)
    {
        if (httpContext.Session["testid"] != null)
            DisplayVersion = Convert.ToInt32(httpContext.Session["testid"]);

        return base.GetDisplayInfo(httpContext, virtualPath, virtualPathExists);
    }

    protected override string TransformPath(string virtualPath, string suffix)
    {
        virtualPath = base.TransformPath(virtualPath, suffix);

        if (DisplayVersion.HasValue && DisplayVersion.Value > 0)
            virtualPath = virtualPath.Replace(".cshtml", String.Format("v{0}.cshtml", DisplayVersion));

        return virtualPath;
    }
}

And now update the global.asax...


protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();

    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);

    DisplayModes.Modes.Insert(0, new PotentiallyCrazyDisplayMode("android")
    {
        ContextCondition = (context => context.Request.UserAgent.IndexOf("android", StringComparison.OrdinalIgnoreCase) > -1)
    });
}

And reload!

Splosions

If you hit this with a normal browser user agent, you'll be just fine. Use an iPhone user agent and you'll be fine too. But use an Android user agent and you will be rudely attacked by the yellow screen of death:

Yellow screen of death caused by a framework bug

Why you may ask? Well, there is a bug. In some cases where you use custom DisplayModes, TransformPath is executed way too early in the request lifecycle, well before the controller action is executed. And it happens multiple times. In this case the null reference isn't the session variable but Session itself. Oh snap! This has been reported and hopefully will be fixed for the next preview/beta release. In the meantime we can protect against this and hope that things will be cleaned up a bit (it is a preview release, after all).


public class PotentiallyCrazyDisplayMode : DefaultDisplayMode
{
    public PotentiallyCrazyDisplayMode(string suffix) : base(suffix) { }

    public int? DisplayVersion { get; set; }

    public override DisplayInfo GetDisplayInfo(HttpContextBase httpContext, string virtualPath, Func<string, bool> virtualPathExists)
    {
        if (httpContext.Session != null && httpContext.Session["testid"] != null)
            DisplayVersion = Convert.ToInt32(httpContext.Session["testid"]);

        return base.GetDisplayInfo(httpContext, virtualPath, virtualPathExists);
    }

    protected override string TransformPath(string virtualPath, string suffix)
    {
        virtualPath = base.TransformPath(virtualPath, suffix);

        if (DisplayVersion.HasValue && DisplayVersion.Value > 0)
            virtualPath = virtualPath.Replace(".cshtml", String.Format("v{0}.cshtml", DisplayVersion));

        return virtualPath;
    }
}

Ugly but functional. You'll notice now that if you reload the page on Android (or using an Android user agent) that if not in the test group, the android page is shown. If in the test group, the default page. This is because we aren't doing any file existence checking before transforming the path. We could add that, a worthy effort indeed, but for now we'll just add the test skins for giggles and move on. So now we create a "Index.android.v1.cshtml" and "Index.android.v2.cshtml" and, voila, we have a different version per test id.

Of course that's just one application of the extensibility point here. You can transform that path to anything you want, which gives you quite a bit of flexibility. What will you do with it, dear reader? Perhaps nothing because it's crazy. Or is it?

Comments

Petr Lev 2011-09-28 02:31:09

Nice article, thanks

David De Sloovere 2011-09-30 02:10:33

Very clever.

Jean-Sébastien Goupil 2011-10-03 07:40:17

Hello,

Good blog post about extending MVC4 DisplayModes.
As you mentioned, this is still in Preview and we are working on this design to iron out the bugs.

The main problem here is GetDisplayInfo has to be deterministic based on the parameters that you pass in. Because some caching is made in advance when you reach one page, that's why you may hit the error.

One display mode must always return the same path based on the virtualPath passed in even if it can't be "handled" at the moment by the httpContext; if you decide to create a DisplayInfo based on the httpContext, the function is no longer deterministic and you will face another problem later:
try to run this application in production mode (debug=false), the page you will reach will always be the same, because the DisplayInfo got cached in advance. Why? In debug mode, there is no cached involved, so you will think your application is running fine. Whereas in production, the cache kicks in.

In your case, you want to create one display mode per "testing" and make the decision if the DisplayMode is good for the current httpContext in CanHandleContext method or ContextCondition property if you use the DefaultDisplayMode.

Regards,
Jean-Sébastien Goupil
ASP.net MVC

Eric 2011-10-03 10:21:40

@Petr and @David, thanks.

@Jean-Sébastien, thanks for chiming in. What you say makes sense. So basically to restate, instead of one DisplayMode for this two-way test, you would need to have two so that in each case the virtual path is only being rewritten once. In the case where someone was going to do a lot of this, it would make sense to create some sort of data-driven process to build up this collection of DisplayModes. Actually creating separate instances inline here in the global.asax would get tedious.



Perhaps that is the source of the error. I've passed more detailed info on the error to Marcind. If you would like to see it, I can forward it on to you as well.

Chris Pont 2011-10-05 11:02:24

Very interesting. Thanks

Scott 2011-11-14 08:11:11

I am creating a new mvc 4 web for collecting student information during PE class via a smart device like: iTouch or iPad, but would also like to display for a regular browser. I would also like to have different displays depending upon the role of the user, like; students, teachers, admin, parents, using specific css for each.

Would I use the displaymode to render the device specific views and then redirect via roles, or ...