A Better ASP.NET MVC Mobile Device Capabilities ViewEngine
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:
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.
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. He is a failed stand-up comic, a cornrower, and a book author.
About Newsletter
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.
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 :-(
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.)
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.
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
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.
http://code.msdn.microsoft.com/WebAppToolkitMobile
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.
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
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.
I don't know if it matters or not, but I'm using ASP.NET MVC 3 RC.
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
}
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.
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;
}
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.
Regards Nic
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.
Thanks
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.
Comments are closed.
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.