Scott Hanselman

A Better ASP.NET MVC Mobile Device Capabilities ViewEngine

November 17, '10 Comments [37] Posted in ASP.NET | ASP.NET MVC | Mobile | Open Source
Sponsored By

UPDATE from 2011: These View Engines have a subtle release mode caching bug. Peter Mourfield and I have released a better MobileViewEngine for ASP.NET MVC 3 that is closer to what MVC 4 will look like. The updated blog post with the new MobileViewEngine is here.

In March of 2009 I spoke at Mix 09, Microsoft's Web Conference and presented a number of ASP.NET MVC features. I extended the NerdDinner Sample with a naive implementation of what I called a MobileCapableWebFormViewEngine. Here's the basic implementation. Don't use this, it's broken.

public class MobileCapableWebFormViewEngine : WebFormViewEngine
{
public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
{
ViewEngineResult result = null;
var request = controllerContext.HttpContext.Request;

// Avoid unnecessary checks if this device isn't suspected to be a mobile device
if (request.Browser.IsMobileDevice)
{
result = base.FindView(controllerContext, "Mobile/" + viewName, masterName, useCache);
}

//Fall back to desktop view if no other view has been selected
if (result == null || result.View == null)
{
result = base.FindView(controllerContext, viewName, masterName, useCache);
}

return result;
}
}

This sample was never meant to be official anything, just show possibilities, but a lot of folks built on top the general idea. However, as reader Michael Baden Roufa (along with Will Creedle and Paulie Srinuan) pointed out to me in email, the logic is wrong. The FindView (and its sibling FindPartialView) method gets called by the ASP.NET MVC framework with useCache=true first, then useCache=false if nothing is found. This little sample view engine, as it is, is dependant on what kind of browser requests a page first because it's using one cache for two kinds of requests. I've noticed this on the NerdDinner site...depending on which device hits the site first, it can get stuck (cached) in a situation where non-mobile pages are being served to iPhones and mobile browsers.

I'll write a much longer post in a few days about how one goes from Broken, to Complex, Refactors, then finally gets to a Simpler Design. Here's how you'd use this code today. This is an example usage within Global.asax. You'd change it depending on the devices you'd care about.

void Application_Start()
{
RegisterRoutes(RouteTable.Routes);

ViewEngines.Engines.Clear();
ViewEngines.Engines.AddIPhone<WebFormViewEngine>();
ViewEngines.Engines.AddMobile<WebFormViewEngine>("blackberry", "Mobile/BlackBerry");
ViewEngines.Engines.AddMobile<WebFormViewEngine>(c => c.UserAgentContains("SomethingCustom"), "Mobile/SomethingCustom");
ViewEngines.Engines.AddGenericMobile<WebFormViewEngine>();
ViewEngines.Engines.Add(new WebFormViewEngine());
}

You're probably familiar with the concept of ViewEngines and you have heard, at least around town, that you can add 3rd party View Engines, and even mix them and match them. You can have WebForms Views, Spark Views, and Razor Views all in the same project. Rather than deriving from WebFormsViewEngine (or now in ASP.NET MVC 3, RazorViewEngine) and making a super-smart ViewEngine that has too many responsibilities (remember the Single Responsibility Principle), instead I talked to a bunch of folks and realized that I didn't want a smart ViewEngine with knowledge of a lot of devices. ASP.NET MVC already has the notion of an ordered list of ViewEngines.

Since I was on campus this week visiting, I talked to lots of folks like Levi Broderick, Dmitry Robsman, Damian Edwards, Brad Wilson, Marcin Dobosz and more. That's what I really miss while working from Oregon...the wandering around and brainstorming.

Anyway, adding some extension methods to ViewEngineCollection for standard options, while creating a CustomMobileViewEngine as a base to build on makes for a more flexible solution and cleaner code. The example above is the "everything example." You've got an iPhone View, a generic Mobile View, a BlackBerry View based on UserAgent, as well as a Custom View using a custom Lambda for some custom device, then finally the desktop view, which in this case is the WebFormViewEngine.

If you just wanted a Desktop Site and an iPhone site with Razor you'd do this. Note that the ordering is important. View folders are checked in the order they are listed here, each falling to the next until a View is found.

ViewEngines.Engines.Clear();
ViewEngines.Engines.AddIPhone<RazorViewEngine>();
ViewEngines.Engines.AddGenericMobile<RazorViewEngine>();
ViewEngines.Engines.Add(new RazorViewEngine());

Mixing WebForms Views and Razor Views next to Desktop Views and Mobile Views

You can also mix and match. A common scenario might be that you already have a nice WebFormViewEngine ASP.NET MVC site, but you'd like to add a mobile site and start using Razor at the same time for just those mobile views.

ViewEngines.Engines.Clear();
ViewEngines.Engines.AddGenericMobile<RazorViewEngine>();
ViewEngines.Engines.Add(new WebFormViewEngine());

So where are these Views to be found? Let's look at a more custom example. Given this example above, if I am in the HomeController's Index method and it calls View(), we'll look first for ~/Views/Home/Mobile/Index.cshtml, then ~/Views/Home/Index.aspx.

I could also add a custom UserAgent string to look for, with its own folder, with one line:

ViewEngines.Engines.Clear();
ViewEngines.Engines.AddMobile<WebFormViewEngine>("blackberry", "Mobile/BlackBerry");
ViewEngines.Engines.AddGenericMobile<RazorViewEngine>();
ViewEngines.Engines.Add(new RazorViewEngine());

Now if I called the HomeController's Index's method looking for the Index view, we'll look first for ~/Views/Home/Mobile/Blackberry/Index.aspx, then ~/Views/Home/Mobile/Index.cshtml, and finally ~/Views/Home/Index.aspx. However, we'll only be checking Mobile if it's a mobile device (per the Browser Capabilities object) and only checking the BlackBerry folder if the UserAgent contains "blackberry."

Sometimes, though, you'll want more complex custom "is this the right device" logic. Then you can pass in a lambda using whatever reasoning makes you happy, be it UserAgents, cookies, headers, whatever.

ViewEngines.Engines.Clear();
ViewEngines.Engines.AddIPhone<WebFormViewEngine>();
ViewEngines.Engines.AddMobile<WebFormViewEngine>("blackberry", "Mobile/BlackBerry");
ViewEngines.Engines.AddMobile<RazorViewEngine>(c => c.SomeExtensionMethod("SomethingCustom"), "Mobile/SomethingCustom");
ViewEngines.Engines.AddGenericMobile<RazorViewEngine>();
ViewEngines.Engines.Add(new WebFormViewEngine());

Now I've added a new line that digs around the ControllerContext (that's "c") for some information to base my decision on, and if that is true, it'll use the Mobile/SomethingCustom folder for the views. Remember that order matters.

Certainly this is just one way to do things, but it does maximize controller reuse. Not every site can be done like this. Different sites have different flows. You might end up with custom controllers for certain devices or maybe just custom actions. Or, you may need two totally different sites. It depends on your business needs, but it's nice to know the flexibility is here.

CustomMobileViewEngine

To do a hello world example, make a new MVC project (this will work for ASP.NET MVC 2 or ASP.NET MVC 3) and add this class to it (be aware of your namespace.) Again, I'll get this in NuGet soon.

public class CustomMobileViewEngine : IViewEngine
{
public IViewEngine BaseViewEngine { get; private set; }
public Func<ControllerContext, bool> IsTheRightDevice { get; private set; }
public string PathToSearch { get; private set; }

public CustomMobileViewEngine(Func<ControllerContext, bool> isTheRightDevice, string pathToSearch, IViewEngine baseViewEngine)
{
BaseViewEngine = baseViewEngine;
IsTheRightDevice = isTheRightDevice;
PathToSearch = pathToSearch;
}

public ViewEngineResult FindPartialView(ControllerContext context, string viewName, bool useCache)
{
if (IsTheRightDevice(context))
{
return BaseViewEngine.FindPartialView(context, PathToSearch + "/" + viewName, useCache);
}
return new ViewEngineResult(new string[] { }); //we found nothing and we pretend we looked nowhere
}

public ViewEngineResult FindView(ControllerContext context, string viewName, string masterName, bool useCache)
{
if (IsTheRightDevice(context))
{
return BaseViewEngine.FindView(context, PathToSearch + "/" + viewName, masterName, useCache);
}
return new ViewEngineResult(new string[] { }); //we found nothing and we pretend we looked nowhere
}

public void ReleaseView(ControllerContext controllerContext, IView view)
{
throw new NotImplementedException();
}
}

public static class MobileHelpers
{
public static bool UserAgentContains(this ControllerContext c, string agentToFind)
{
return (c.HttpContext.Request.UserAgent.IndexOf(agentToFind, StringComparison.OrdinalIgnoreCase) > 0);
}

public static bool IsMobileDevice(this ControllerContext c)
{
return c.HttpContext.Request.Browser.IsMobileDevice;
}

public static void AddMobile<T>(this ViewEngineCollection ves, Func<ControllerContext, bool> isTheRightDevice, string pathToSearch)
where T : IViewEngine, new()
{
ves.Add(new CustomMobileViewEngine(isTheRightDevice, pathToSearch, new T()));
}

public static void AddMobile<T>(this ViewEngineCollection ves, string userAgentSubstring, string pathToSearch)
where T : IViewEngine, new()
{
ves.Add(new CustomMobileViewEngine(c => c.UserAgentContains(userAgentSubstring), pathToSearch, new T()));
}

public static void AddIPhone<T>(this ViewEngineCollection ves) //specific example helper
where T : IViewEngine, new()
{
ves.Add(new CustomMobileViewEngine(c => c.UserAgentContains("iPhone"), "Mobile/iPhone", new T()));
}

public static void AddGenericMobile<T>(this ViewEngineCollection ves)
where T : IViewEngine, new()
{
ves.Add(new CustomMobileViewEngine(c => c.IsMobileDevice(), "Mobile", new T()));
}
}

Go to your Global.asax.cs (or .vb) as I've showed before, and add maybe these lines to the end of your Application_Start().

ViewEngines.Engines.Clear();
ViewEngines.Engines.AddIPhone<RazorViewEngine>();
ViewEngines.Engines.AddGenericMobile<RazorViewEngine>();
ViewEngines.Engines.Add(new RazorViewEngine());

If you're using ASP.NET MVC 2, or if you simply prefer to, change RazorViewEngine to WebFormViewEngine. Or, mix and match to your taste.

In your Views folder, make ~/Views/Home/Mobile and ~/Views/Home/Mobile/iPhone and copy ~/Views/Home/Index.* to it. Then, open the new Mobile Index and Mobile/iPhone Index and change them. I'm put in "I'm a mobile view" and "I'm an iPhone view" just so I'd know for this hello world example. Like this:

All my Mobile View Folders just so

Next, either get a User Agent Switcher for your browser, or download a fake iPhone Simulator called iBBDemo2 (it's totally fake, but it'll do). Hit the site, and boom, there you go.

My page in a Fake iPhone

Adding iPad is trivial:

ViewEngines.Engines.AddMobile<RazorViewEngine>("iPad", "Mobile/iPad");

As is Windows Phone 7 if you like:

ViewEngines.Engines.AddMobile<RazorViewEngine>("Windows Phone", "Mobile/WP7");

You get the idea.

More to Come

Since that post, the Live.com team in Ireland that released and supported the original Mobile Device Browser File (MDBF) has stopped producing it. The best source for mobile browser device data is WURFL (that was one of the places that MDBF pulled from.)

While I haven't spoken with the 51degrees.mobi folks (yet), they have an ASP.NET module that sucks information from WURFL and populates the Browser Capabilities object. Remember that everyone's requirements are different. You may not need to know everything about every device. Maybe you want to support iPhones and Desktop and that's it. Or, maybe you need to know the screen width and JPEG capabilities of some obscure old Nokia device. Know what you need before you buy into a detection decision or mobile database. We're working on more mobile recommendations and pest practices on the ASP.NET team and I'll continue share everything I know.

I'll be working with my new teammate Steven Sanderson on this template, maybe getting it into NuGet in the short term, and more baked into the framework in the long term. I'll update the NerdDinner sample - older versions for Mobile, and newer versions for MVC3 and mobile. I'll also try to look at jQuery Mobile and some of the nice JavaScript mobile libraries and improve the default templates. I'll also look at adding "opt out" options, so you can hit a site, get a mobile experience, but click a link that says "give me desktop anyway."

For now, it's a much better proof of concept for you to play with, Dear Reader.

About Scott

Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. I am a failed stand-up comic, a cornrower, and a book author.

facebook twitter subscribe
About   Newsletter
Sponsored By
Hosting By
Dedicated Windows Server Hosting by ORCS Web
Wednesday, November 17, 2010 3:54:16 AM UTC
Scott, glad you posted on this today.

I was running the 51Degrees mobi HttpModule on a site but it turn out it was putting a huge drag on the site every time it had to restart. So I had to yank it out. :(

It would be great if there was an "install and forget" module for IIS 7 that would auto update, have an extensive local DB, and provide more intelligent, granular detection.
Wednesday, November 17, 2010 4:02:40 AM UTC
This is really helpful, Thanks Scott :)
Wednesday, November 17, 2010 6:20:43 AM UTC
Try css3 media query, server side sniffing is no longer needed for today's mobile device.
Wednesday, November 17, 2010 6:35:59 AM UTC
Kazi - I'd love to see you duplicate this effort, using NerdDinner as an example! I look forward to a blog post.

Lynn - Agreed...I think if there was a way to suck down WURFL, run an offline process to create App_Browsers files, the just copy that in.
Wednesday, November 17, 2010 6:43:57 AM UTC
Kazi - According to this presentation there are some pitfalls in the media query approach - at least you should be careful not to download images etc. that will not in fact be shown on the mobile device

Scott - Now that you're on the subject of mobile views, it would be great if the comments on this blog wrapped lines properly on my phone. Blog entry is fine, comments are 2-3 times my screen width :-(
Tore Green
Wednesday, November 17, 2010 6:21:38 PM UTC
Very timely as I was in need of this today for a project.

However line 47:

return (c.HttpContext.Request.UserAgent.IndexOf(agentToFind, StringComparison.OrdinalIgnoreCase) > 0);


should probably be:

return (c.HttpContext.Request.UserAgent.IndexOf(agentToFind, StringComparison.OrdinalIgnoreCase) > -1);


or else exact User Agent matches/matches at the start of the string will be treated as no match. (We have a very particular UA string that requires a complete match for our application.)
Wednesday, November 17, 2010 7:01:18 PM UTC
I think this approach is short-sighted. For the same reasons you don't detect browsers but feature support in javascript, the server should only really have an idea of what the device requesting the page is capable of, not have a list of devices to compare against or a flag saying it's a mobile device. The line between mobile device and non-mobile device is blurring all the time.
Wednesday, November 17, 2010 10:20:31 PM UTC
im going to look into this
Thursday, November 18, 2010 5:34:08 PM UTC
Scott,

For the record Mike Roufa was working hand in hand with two great testers - Will Creedle and Paulie Srinuan - in the discovery of the issue.

Thanks so much for blogging about the issue and passing along the credit.


Friday, November 19, 2010 12:08:59 AM UTC
Thanks for addressing this!

Question: Why does FindView take a flag for "useCache" ? You'd think that caching/not caching would be hidden to callers, and that the base FindView implementation would handle this completely behind the scenes.

Best, Mike
Friday, November 19, 2010 12:43:57 AM UTC
Michael, the MVC team says this:

Kind of an odd pattern to be sure. Remember that the view engine is a black box to the MVC pipeline; MVC doesn’t know what the engine is looking at.

Think of it this way – if useCache is true, then MVC wants you to give an answer as quickly as possible, and it’s willing to tolerate a false negative. If useCache is false, then MVC wants you to give a correct answer, and it’s willing to wait the extra time for you to generate that answer. The built-in view engines will populate the cache when useCache is false so that subsequent lookups (with useCache = true) are very fast.
Friday, November 19, 2010 2:54:54 AM UTC
Here you go running the Nerd Dinner in Mobile phones with CSS3 media query.
Friday, November 19, 2010 2:56:04 AM UTC
Forgot to post the Url http://tests.rdir.in/nerddinner/index.html
Friday, November 19, 2010 7:01:43 AM UTC
I just implemented this view engine for my blog and it worked a treat. Thanks for sharing Scott!
Friday, November 19, 2010 10:32:20 AM UTC
Scott, In how much does this implementation (or the new one you are proposing) differs from the one metioned here:

http://code.msdn.microsoft.com/WebAppToolkitMobile
Saturday, November 20, 2010 11:04:08 AM UTC
Thanks for your post. Do you know a solution to detect the Android browser? According to the BrowserCapabilities it is not a mobile device.
Wednesday, November 24, 2010 2:55:41 PM UTC
mistyped ....recommendations and PEST practices on the ASP.NET team and I'll continue share everything I know.
Wednesday, November 24, 2010 3:55:50 PM UTC
Scott, I have another question. I'm looking for a way to force a mobile view using a button or something. The problem is the server is caching the views, so in some cases the toggle button doesn't work properly. Is there a way to clear the view cache or do you have other suggestions?
Wednesday, November 24, 2010 5:58:01 PM UTC
Marthijn - I'm going to blog about that soon. I think that an "opt-out cookie" is the best way. You'd click a button or link, set the cookie, then hit the page again. The ViewEngine would only show mobile if you didn't have that opt-out cookie.

As for Android...they always have "Android" in the UserAgent, so just look for that, just like I did in the iPad example.

Marco - That MobileToolkit uses my original code. I'm going to tell James Senior, the author, to update that toolkit.
Thursday, November 25, 2010 9:54:47 AM UTC
Thanks for your answer Scott, I'm looking forward to your post :)
Wednesday, December 01, 2010 5:06:47 PM UTC
Hi Scott,

Firstly, thanks for the example, a great help.

Secondly, how would this methodology scale? It seems to be that for every view you would have for a web browser, you would need 4+ views for mobile devices. Is this just the way it is due to differences in resolution etc? Could you not just have a mobile view then apply styling based on browser type, or is the method you outline the best way of doing this?

Cheers

Rich
Rich Whitfield
Sunday, December 05, 2010 9:08:18 PM UTC
I think the new approach is still broken.

1. I restart the web server just to be sure that any caches aren't being used.
2. Go to the web site with my desktop browser. The desktop version is displayed.
3. Go to the web site from my iPhone.
//Expected: The iPhone view to display
//Actual: The desktop view is displayed

If I then restart the web server, and access the site from the iPhone first, it works right away.
Sunday, December 05, 2010 9:10:06 PM UTC
Premature post. Sorry.

I don't know if it matters or not, but I'm using ASP.NET MVC 3 RC.
Monday, December 06, 2010 9:55:27 PM UTC

This works in the debug mode.
This Does Not Work in the release mode.

I add debug code to see what was different.

Why Why in the release mode useCache is ALWAYS set to true?????
It Never get set to false.


public ViewEngineResult FindView(ControllerContext context, string viewName, string masterName, bool useCache)
{
if (IsTheRightDevice(context))
{
ViewEngineResult temp = BaseViewEngine.FindView(context, PathToSearch + "/" + viewName, masterName, useCache);
#region debugging 3
//Pass the filepath and filename to the StreamWriter Constructor
using (StreamWriter sw = new StreamWriter("C:\\WWW\\MyWebSite\\bin\\Debug.log", true))
{
string output = "";
string time = DateTime.Now.ToString();
sw.WriteLine('\n');
sw.WriteLine("FindView");

time = DateTime.Now.ToString();
output = time + '\t' + "PathToSearch = " + PathToSearch;
sw.WriteLine(output);

time = DateTime.Now.ToString();
output = time + '\t' + "viewName = " + viewName;
sw.WriteLine(output);

time = DateTime.Now.ToString();
output = time + '\t' + "useCache = " + useCache.ToString();
sw.WriteLine(output);


if (temp.View != null)
{
time = DateTime.Now.ToString();
output = time + '\t' + "Found View ";
sw.WriteLine(output);
}
else
{
time = DateTime.Now.ToString();
output = time + '\t' + "No Found View ";
sw.WriteLine(output);
}

}

#endregion
return temp;
}
return new ViewEngineResult(new string[] { }); //we found nothing and we pretend we looked nowhere
}
Michael B
Saturday, January 15, 2011 7:12:35 AM UTC
It looks like the caching of release versions is fixed with ASP.NET MVC 3 RTM. Thanks to the team for doing that!
Friday, January 28, 2011 6:36:33 PM UTC
I've found that when my site is getting hit really hard, something goes wrong with the caching. It could be after a couple hours or a half day, but I'll find the desktop view getting served up to all user-agents. I'm using MVC3 RTW. I've had to explicitly not use cache within the FindView and FindPartialView methods in order to predictably deliver the correct view for the device (useCache = false;). It could be something I'm doing incorrectly, but I tend to think not since disabling all caching fixes the issue.

In any event, this has proven to be really useful to me after working out the cache issue. I have a new application in production that is a big hit! Hopefully I can figure out what is going wrong with the caching and I can gain some of my lost performance back.
Monday, January 31, 2011 3:42:58 PM UTC
The solution still does not work properly for MVC 2.0 if you have compilation debug="false" set in web.config. May be you should mention this.
Try case that Dan Miser mentioned above. This happens because ViewEngineCollection of in MVC implementation goes trough view engine and check cached result and than not cached:

foreach (IViewEngine engine in base.Items)
{
...
if (result.View != null) return result;
}

foreach (IViewEngine engine2 in base.Items)
{
...
if (result.View != null) return result;
}
Wednesday, February 02, 2011 9:29:37 AM UTC
Hello.

I'm going to create mobile version of my ASP.NET MVC site.
But mobile version is would be more simple and I don't want to use same controller for it, because there is no need to take some data from database.
Is there any easy way or template to dynamically switch controllers between desktop version and mobile version?

Thanks.
Thursday, February 10, 2011 7:15:11 AM UTC
Check also an alternative suggestion described on this thread on Stackoverflow: http://stackoverflow.com/questions/1387354/how-would-i-change-asp-net-mvc-views-based-on-device-type/4922923#4922923
Friday, February 25, 2011 7:16:14 AM UTC
Hi and many thanks for this article, unfortunatley i followed your first article and have had this bug around for a while. Were can i find the complete source for this new article?

Regards Nic
Niclas
Sunday, March 13, 2011 6:16:08 PM UTC
Is there a VB.NEt version of this code? I've tried converting it but I'm running into some issues.
Jonathan
Tuesday, March 29, 2011 5:55:15 PM UTC
In clueded 51degrees.mobi. so sweet...

This was really helpful. I leveraged the CustomMobileViewEngine class and used NuGet to include 51degrees.mobi in my solution. I tweaked one line of code in the CustomMobileViewEngine class and used the extension methods that 51degrees has for the Request.Browser object and it worked like a charm. The biggest problem it solved was for 51degrees. Most of there code sample you have to route the requests yourself in the controller to the correct view. this ways is so much cleaner.

public static bool UserAgentContains(this ControllerContext c, string agentToFind)
{
return (c.HttpContext.Request.Browser.MobileDeviceModel.ToLower().Contains(agentToFind.ToLower()));
}

I also had to make sure that the web config section for fiftyOne didn't have a attribute of mobileHomePageUrl or mobilePagesRegex on the redirect node and that was it.

Everything else falls into place with the views and the convention takes care of the rest.
Tuesday, March 29, 2011 7:27:22 PM UTC
John - You should use ToLowerInvariant if you want it to work in any culture.
Thursday, May 05, 2011 5:27:57 PM UTC
Great post Scott! There is an issue with the code compiled in Release Mode where the desktop view will always be rendered when a desktop browser hits the site first. Do you have a follow-up post on this? Thanks!
Friday, May 06, 2011 8:43:57 PM UTC
I'm also having an issue with Release Mode. Hope there is a fix to this. Otherwise, it is working great.

Thanks
Darick
Thursday, June 02, 2011 8:11:50 AM UTC
-I wondering if the site's performance slows down-

Why am I telling this, is because I just added some breakpoints in MobileHelper and CustomMobileViewEngine and while I was in dekstop mode view the code visited the findView, UserAgentContains and IsMobileDevice so many times.
Could this produce any performance issues?

Thanks a lot.
Monday, June 06, 2011 7:31:14 PM UTC
Fanis - A little, but not enough to matter.
Comments are closed.

Disclaimer: The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way.