Sep 25 2009

A WebForms Breakup Letter

1022693_couple_heartbreakerDear WebForms,

I’m writing this to let you know that it’s over between us. I realize we’ve been together for some time now, but over the years things have gotten bad and it’s time to call it off. Don’t worry, it’s not you, it’s me. Well, actually that’s not true. It’s really you. You’ve really let yourself go over the years. But it’s not just about that. I’ve tried for years now, and the truth is I just don’t understand you. I feel like you’re always trying to hide what you’re really doing from me. You just keep too many secrets. How can we have a healthy relationship without total transparency? Also, you don’t seem to get along very well with some of my other close friends like JavaScript and TDD.

We have been together for quite a while so I feel like I owe you total honesty. The truth is I’ve been seeing someone else for the last few months. MVC and I have been spending more and more time together and it’s becoming obvious that we’re in love. I know you’re thinking this is about being traded in for someone younger and lighter weight, but it’s about a lot more than that. MVC just makes my life simple. It’s flexible and understanding when I need to do things in a different way it’s used to. It gets along really well with my other web dev friends. Most of all, I feel like I’m really on the same page as MVC. We think alike. MVC is up front, honest, and clear about what it’s up to. I don’t feel like it’s trying to hide things from me.

So that’s it. This is the end. Thanks for the memories. Don’t try to change my mind, I’m already gone.

~Me

  • Share/Save/Bookmark

Sep 11 2009

Faster Silverlight Unit Testing

I’ve recently been working on a project using Silverlight, and while there are many things I really like about Silverlight (like threads in the browser, heck yeah!) one thing that has been killing me is the Silverlight Unit Testing framework. It is painfully slooooooooooow. I currently have somewhere around 160 tests and it’s been taking over 30 seconds to run the suite. That fact, combined with the fact that there isn’t a way to run only one test was actually becoming fairly detrimental. I was finding myself skipping some simple tests just because it didn’t feel worth the wait. Or, I would write and implement multiple tests before running them, just to not lose my train of thought.

So the other day I thought I’d take a crack at building my own unit testing framework. It turned out to be really simple and after a few hours I had something up and running. The best part was that I could now run my 160 unit tests in under 2 seconds. Not too bad if I do say so myself.

Now, to be fair, the Silverlight Unit Testing framework does a lot of things that my little testing framework does not do. Most notably, my framework does not support testing UI elements. However, for my project UI testing is not what I’m most worried about. Hopefully there are others out there who are in a similar situation that will find this helpful.

But wait, there’s more! After doing TDD for a while now, one thing that can be annoying is when a test fails and you get a message like “expected:3 actual:7”. What expected 3? I wrote that test 4 days ago. I don’t remember what was supposed to be “3”. Typically, this problem is solved by adding a “message” to your Assert that might say something like “User.Age should equal 3”, but that requires extra typing and who has time for that? So now that I’d created my own testing framework I thought I’d try out a little experiment using lambdas. Why lambdas? Because there shinny and new. No, because lambdas can actually be used as an expression tree. That means that in addition to being able to execute the lambda, I can parse it out and turn it back into something that fairly closely resembles the original source code. It’s probably easiest to explain with some screen shots. Here are two tests that test the same thing. The first is using the traditional Assert.AreEqual syntax. The second one is using the new and improved lambda syntax.

unit test code

And here are the test results for both of these failing tests. Notice that the first failing test result (the one using the lambda) gives you a lot more detail about what code actually failed. There’s no need for me to dig into the source code to find out what “expected 3 but was 7” means. Even better I still got to be lazy and didn’t have to type a descriptive message into my Assert.

testrun

If you’d like to try it out, I’ve tried to make it as easy as possible. I’ve created some “compatibility” namespaces in order to make it easier to switch frameworks (i.e. all the namespaces/class names/method names for my testing framework match those of the Microsoft one). For many scenarios it should be as easy as removing the reference to the Microsoft Silverlight unit testing framework from your test project and replacing it with a reference to this framework. I haven’t recreated everything from the Microsoft framework, but I think all of the essentials are there. Hopefully this will allow you to quickly assess whether this is a good solution for you or not. Any feedback would be great!

Source

  • Share/Save/Bookmark

Aug 24 2009

CastAs() Extension Method. Should It Stay Or Should It Go?

Have you ever coded something like this…

((Class1)((Class2)item).AProperty().AMethod()

Ok, I don’t mean the names (hopefully you’ve never used those), but doing multiple inline casts. It’s not pretty for sure. My guess is you had to read the example a few times to realize what it was doing. Last night I was kicking around an extension method to clean this up some. Here’s what I came up with…

public static T CastAs<T>(this object item)
{
  return (T)item;
}

Impressive, eh? Now the original example can be rewritten like this…

item.CastAs<Class2>().AProperty.CastAs<Class1>().AMethod()

Still not what I’d call beautiful code, but I bet you could figure out what it was doing the first time you read it, no? However, there’s one down side. In the original example if I had tried to do something like this…

int item;
((Class1)((Class2)item).AProperty().AMethod()

…I would get a nice compiler warning letting me know that “int” can’t be cast as a “Class2″. Using the CastAs extension method, that nice compiler warning turns into a nasty runtime bug. So the question for you, dear reader, is, is CastAs a good idea or a bad idea? (and the second question is, is, “is, is” valid english?)

For me, I actually ended up taking it back out for now. Instead, I rewrote the original example as…

Class2 class2 = (Class2)item;
Class1 class1 = (Class1)class2.AProperty();
class1.AMethod()

What are your thoughts?

  • Share/Save/Bookmark

Aug 16 2009

Enum.GetValues() for Silverlight

I ran into a problem the other day using enums in Silverlight. I needed a way to enumerate all the valid values in an enum. In the standard CLR I would use Enum.GetValues, but it turns out that function isn’t provided in Silverlight. So, I did a little decompiling and found that the standard CLR uses some simple reflection in the guts of GetValues. Armed with that knowledge, I created this helper function.


public static IEnumerable<T> GetEnumValues<T>()
{
  Type type = typeof(T);

  if (!type.IsEnum)
  {
    throw new Exception("{0} is not an enum.", type.FullName);
  }

  FieldInfo[] fields =
      type.GetFields(BindingFlags.Public | BindingFlags.Static);

  foreach (var item in fields)
  {
    yield return (T)item.GetValue(null);
  }
}

Using this function is pretty straight forward. It might look something like this…


foreach (var item in GetEnumValues<MyEnum>())
{
  //do stuff here
}
  • Share/Save/Bookmark

Jul 28 2009

OpenForum is on CodePlex

codeplex

OpenForum v0.8 (beta) is now available on CodePlex. Special thanks to Gunnar Peipman for the nice write-up on his blog, and also to Scott Hanselman for tweeting about it.

This latest release includes a few key missing features from my inital posting. Most important, there is now a wysiwyg editor for posts (using nicEdit) and the ability to search the forum (using Lucene.net). Also, the bin download now includes “view templetes”. This should give developers who want to change the actual html structure a big head start.

The plan for the next release is to provide a better default UI and a few other basic forum features. If there is anything you would like to see added, please let me know. Thanks to everyone who already provided feedback about how to make OpenForum even better.

  • Share/Save/Bookmark

Jul 13 2009

Creating a Sql Database At Runtime

In creating OpenForum, one of the things I felt was really important was that it be as easy as possible for users to get up and running. The more steps involved in getting it setup, the fewer people who would actually try it out. I know I’ve had more then a few experiences of downloading a development tool, getting 7 steps into the install/setup process, and then deciding to look into some of the tools competitors. I want tools that are simple and powerful. If it’s a pain to just get them running, what is it going to be like to customize them? Another thing I really hate is having to copy copious numbers of files into specific directories. I’ve used at least a few WYSIWYG editors that had 10x more files then the actual application I was building! So, when it came to a database for OpenForum, I wanted to avoid requiring users to do any connection string configuration, script running, or file copying. One utility that I’ve seen which I feel does a great job of solving that problem is the ASP.NET Membership Provider, especially the way that it’s used in the default MVC template. If you’ve never tried it out, it’s pretty slick. Here are the steps to get it installed/configured.

1. Do nothing

Not bad, eh? All you have to do is run the project and go to any of the pages that use membership (i.e. try logging in). As soon as you do anything that requires the use of the Membership Provider module, a database is automagically generated for in the App_Data directory. That’s what I call easy. That was exactly the experience I wanted for OpenForum. Run the application and a database is created for you.

However, it turns out that it’s not as straight forward as I was hoping. The key piece behind making everything work is a feature in SqlExpress called User Instances. User instance databases are created on a per user basis and can be attached to the SqlExpress engine at runtime. Here’s what a connection string looks like

Data Source=.\SQLEXPRESS; AttachDbFilename=|DataDirectory|\Database1.mdf; Integrated Security=True; User Instance=True

Notice the “AttachDbFilename” property. That’s what tells Sql where the database file is on disk. The “|DataDirectory|” bit will be expanded to the path to the App_Data directory for the current project.

I’m using LinqToSql, so I did a quick experiment using CreateDatabase , but quickly ran into a problem. It turns out there is a bug somewhere in the bowels of LinqToSql that causes the AttachDbFilename stuff to fail if the path to the database file (the .mdf file) is more then 128 characters. So, I broke out the second best development tool there is, and decompiled the ASP.NET Membership Provider code to figure out how the guys at Microsoft were doing it. It turns out the solution is to first create the database in a temp directory and then copy it to where you need it to go. Not too bad really as far as workarounds go. With a few minutes more of fooling around, I had the following code that seems to work really well.


using (OpenForumDataContext context =
          new OpenForumDataContext(DEFAULT_CONNECTION_STRING))
{
    string finalDirectory = (string)AppDomain.CurrentDomain
                                      .GetData("DataDirectory");

    if (!Directory.Exists(finalDirectory))
    {
        Directory.CreateDirectory(finalDirectory);
    }

    string finalPath = Path.Combine(finalDirectory, "OpenForum");

    if (!File.Exists(finalPath + ".mdf"))
    {
        string tempPath = Path.Combine(Path.GetTempPath(),
                         Guid.NewGuid().ToString());

        using (OpenForumDataContext tempContext =
                    new OpenForumDataContext(tempPath + ".mdf"))
        {
            tempContext.CreateDatabase();
            tempContext.ExecuteCommand(Resources.DefaultData);
            tempContext.ExecuteCommand(@"Declare @name as varchar(100);
                          set @name = DB_NAME();
                          Use master;
                          exec sp_detach_db @name, 'true';");
        }

        File.Move(tempPath + ".mdf", finalPath + ".mdf");
        File.Delete(tempPath + ".ldf");
    }
}
  • Share/Save/Bookmark

Jul 5 2009

Why software is hard (Part 1 of 10,000)

The other day one of my coworkers made a really profound statement. It went a little something like this. “All software is crap. The sooner you realize that, the better off you’ll be.” I think I know what he means by that. Almost all software seems slower, harder to use, and buggier then it seems it should be… including the software that I’ve written. The truth is writing good software is really hard.

The other day, while carpooling home from work, my friend and I had an interesting conversation on one of the many aspects of software that make it so difficult. Software, by its very definition, is not flexible. People are. We have a general set of “rules” that govern our day to day decision making. Those rules make the general cases of our decision making pretty straight forward. For example, if the light is red we stop and if it’s green we go. However there are billions of exceptions to that rule. If the light is green, but there’s a semi stopped in the middle of the intersection, we don’t go. Some of these scenarios are pretty straight forward. We’ve run into them thousands of times, and we know how to react. But, what if the light is green and there’s a semi stopped in the middle of the intersection and there’s a herd of charging elephants bearing down behind you? Ok, maybe not likely, but the point is that there are lots of scenarios that come up in every day life that we have no plan for. In fact, we likely wouldn’t ever have a plan until the scenario came up. In essence, we decide how to react when the situation occurs.

In software we call these scenarios “edge cases” and we do our best to code for them. The problem is that many times there isn’t a way to allow users to “decide how to react when the situation occurs”. All those options need to be programmed into the software before it’s released.

I see this a lot in business software. In business, you do what you need to do to make the customer happy (especially if it’s a big enough customer). It’s not uncommon to bend the rules to meet customer’s needs. In fact, it’s not at all uncommon to have the rules change from day to day depending on the customer, the market, and what the sales person had for breakfast that morning. Today there may be zero exceptions to the “Net 30” terms. Tomorrow the biggest account might be told “Net 593” is no problem.

The point is that business (and life in general) is very flexible. We have general rules that we follow, but we also are free to change many of those rules as necessary in order to navigate the complex systems in which we live. Software on the other hand is ridged. Software is true or false. Software does this or it does that. Software is not flexible. We try our best as programmers to make it as flexible as possible, but inevitably at some point a user will need it to work in a way that is contrary to the rules. And what does the user say when that happens? “This software sucks!”

  • Share/Save/Bookmark

Jun 29 2009

OpenForum - A Free Forum for MVC Applications

UPDATE: I’ve changed the links at the bottom. Hopefully that will resolve the issue that some people were having with downloading the source. I’m not sure why the original URLs worked for some people and not for others, but as my friend always reminds me, “If computers just worked, we’d all be out of a job.”

UPDATE: I’m happy to announce that OpenForum is now on CodePlex. Check it out here… http://openforum.codeplex.com/

One of the things that I really like about the MVC framework is that it offers some unique possibilities when it comes to third parties developing entire sub-systems that can easily be plugged into existing applications. Recently, I had some “down time” at my work and decided to take the opportunity to experiment with doing just that. Here are the results of that experiment. OpenForum is a forum that can easily be plugged into any MVC application with very little effort. In fact, there are only three steps to get OpenForum working with most MVC applications.

1. Add a reference to OpenForum.dll
2. Initialize OpenForum (via one line of code in the global.asax file)
3. Add an html link to OpenForum in your menu (optional)

Here’s a little screencast that I put together showing the process in greater detail.

This is defiantly still in it’s early infancy, but it’s far enough along that I wanted to put it out there and get some feedback from the community. Notable missing features include the ability to search the forum and a lack of support for any html in posts. The plan is to eventually put OpenForum up on CodePlex once things get a little further along. Until then, I’d really appreciate some community feedback. Here are some links to a few other tutorials on using some of the more advance features of OpenForum…

http://www.youtube.com/watch?v=2ZAyVlvSyeU
http://www.youtube.com/watch?v=ko_98aig44M
http://www.youtube.com/watch?v=9b9Td5CFu2g

And here are the links to the needed dll as well as a link to the source
Bin
Source

  • Share/Save/Bookmark

Jun 24 2009

Fun with IView and IViewEngine

clownI’m currently working on a little side project that I’m kind of excited about. I’m building an MVC based forum that can be plugged into any MVC application with very little effort. In fact, it looks like most scenarios will only require adding a reference to a DLL and one line of code in Application_Start (and maybe an html link in a menu somewhere). It’s not quite ready for the public yet, but there are few things I’m really proud of that I’d like to share.

In order to keep setup/configuration as simple as possible I wanted to find a way to provide a default UI out of the box. The question was “how do I render views when I don’t actually have an .aspx file on disk?” Even more important, most sites use master pages. How do I get my html to render in someone else’s master page? After some googling, reflector-ing, and cursing, I finally came up with this little class


public class DynamicView : IView
{
    public string PrimaryContentPlaceHolderId { get; set; }
    public string TitleContentPlaceHolderId { get; set; }
    public string MasterLocation { get; set; }
    public string DefaultTitle { get; set; }
    public ViewUserControl Control { get; set; }

    public void Render(ViewContext viewContext, TextWriter writer)
    {
        DynamicViewPage viewPage = new DynamicViewPage();
        viewPage.AppRelativeVirtualPath = "/";
        viewPage.MasterLocation = MasterLocation;
        viewPage.ViewData = viewContext.ViewData;

        if (TitleContentPlaceHolderId != null)
        {
            viewPage.AddContentControl(TitleContentPlaceHolderId,
                (w, p) => w.Write(DefaultTitle));
        }

        viewPage.AddContentControl(PrimaryContentPlaceHolderId,
            (w, p) => RenderControl(viewPage.Html, Control));

        viewPage.RenderView(viewContext);
    }

    public void RenderControl(HtmlHelper html,
        ViewUserControl control)
    {
        control.ViewData = html.ViewData;
        control.RenderView(html.ViewContext);
    }

    private class DynamicViewPage : ViewPage
    {
        public void AddContentControl(string contentPlaceHolderId,
            RenderMethod renderMethod)
        {
            CompiledTemplateBuilder compiledTemplateBuilder =
                new CompiledTemplateBuilder(
                    x => x.SetRenderMethodDelegate(renderMethod));

            AddContentTemplate(contentPlaceHolderId,
                compiledTemplateBuilder);
        }
    }
 }

(NOTE: For the sake of simplicity I have removed some code from the original file that didn’t apply to this blog post. I have not tried compiling the code as posted here).

This class looks a little crazy, but it’s actually pretty straight forward. You provide it with the path to a master page, the name of the “title” and “main” content areas on that master page, a title for the page, and a ViewUserControl that should be rendered in the main content area. The DynamicView class implements IView which allows the MVC framework to do its magic. And that’s it! Well, almost… there is one other little detail.

For my forum project, I’ve created a controller, actions, and associated routing rules. When a request is made for, say “http://yoursite/forum”, the request will be handed to my controller as expected. My controller action tells the MVC framework it should render a view by the name of “Index”, but how do I get the MVC framework to find the correct dynamic view? Thankfully, the MVC framework has an IViewEngine interface that can be used to solve the problem.

The MVC framework ships with a default IViewEngine that maps views to .aspx files in the “Views” directory. However, you can actually create and register additional view engines. When multiple view engines are registered, if the first engine is unable to find a valid view for the current request it hands off processing to the next view engine. Armed with that knowledge I created the following DynamicViewEngine class (NOTE: some of this code is specific to my “forum” project, but I think you’ll get the idea).


public class DynamicViewEngine : IViewEngine
{
    private string _masterPageLocation;
    private string _primaryContentPlaceHolderId;
    private string _titleContentPlaceHolderId;

    public DynamicViewEngine(string masterPageLocation,
        string primaryContentPlaceHolderId,
        string titleContentPlaceHolderId)
    {
        _masterPageLocation = masterPageLocation;
        _primaryContentPlaceHolderId = primaryContentPlaceHolderId;
        _titleContentPlaceHolderId = titleContentPlaceHolderId;
    }

    public ViewEngineResult FindPartialView(
        ControllerContext controllerContext,
        string partialViewName,
        bool useCache)
    {
        return new ViewEngineResult(
            new string[] { "Dynamic Forum Views" });
    }

    public ViewEngineResult FindView(
        ControllerContext controllerContext,
        string viewName,
        string masterName,
        bool useCache)
    {
        object controller = controllerContext.RouteData.Values["controller"];
        if (controller.ToString().ToLower() != "forum")
        {
            return new ViewEngineResult(
                new string[] { "Dynamic Forum Views" });
        }

        DynamicView view = new DynamicView();
        view.MasterLocation = _masterPageLocation;
        view.PrimaryContentPlaceHolderId = _primaryContentPlaceHolderId;
        view.TitleContentPlaceHolderId = _titleContentPlaceHolderId;

        switch (viewName.ToLower())
        {
            case "index":
                view.Control = new IndexControl();
                view.DefaultTitle = "Forum";
                break;
            case "view":
                view.Control = new ViewControl();
                view.DefaultTitle = "Forum Post";
                break;
            case "post":
                view.Control = new PostControl();
                view.DefaultTitle = "New Post";
                break;
            case "reply":
                view.Control = new ReplyControl();
                view.DefaultTitle = "Forum Reply";
                break;
            default:
                return new ViewEngineResult(
                    new string[] { "Dynamic Forum Views" });
        }

        return new ViewEngineResult(view, this);
    }

    public void ReleaseView(
        ControllerContext controllerContext,
        IView view)
    {
        IDisposable disposable = view as IDisposable;
        if (disposable != null)
        {
            disposable.Dispose();
        }
    }
}

The interesting method on this class is “FindView”. This is where I determine if the requested view is one of my forum views. If it is, I return an instance of my DynamicView with an appropriate user control being rendered in the master page’s “main” content area. (Not shown here are the various user controls. The user controls are simply sub classes of ViewUserControl with html manually written out by overriding the “Render” method).

The DynamicViewEngine can now be registered with one simple line of code, like this…

ViewEngines.Engines.Add(new DynamicViewEngine("~/Views/Shared/Site.Master", "MainContent", "TitleContent"));

So now with two fairly compact classes I’ve accomplished my goal of being able to distribute views in a DLL that can integrate into any existing application. There’s no requirement for developers to create multiple pages in order to host the forum. There aren’t a ton of files that need to be copied to specific directories. It really is as simple as adding a reference to one DLL and calling an initialization method.

But wait, there’s more! There’s one other really nice (and unintended) side effect of this architecture. Let’s say that a developer decides to use the forum, but wants to change the html of one of the pages. It turns out this is extremely easy. Simply create a “Forum” directory under the “Views” directory in the web project and then create an .aspx page for the view you wish to override (the same way you would create a view for any standard MVC application). Now the default view engine will render the custom view instead of handing control over to the DynamicViewEngine. The best part is that the default controller still handles all the “logic” of the request. All the developer needs to do is provide the html template. This highly pluggable application that is both easy to get up and running as well as simple to extended and customize.

I’m hoping to have my forum ready for people to check out soon, but until then I hope this little snippet gives you some new ideas of how you can use the MVC framework to create easily deployable components/modules.

  • Share/Save/Bookmark

Jun 23 2009

How to Keep Your Sanity and Multiple Projects Version Numbers in Sync

NOTE: This is an article that I originally wrote for codeproject.com. You can view the original article here…

http://www.codeproject.com/KB/cs/Sync_Version_Numbers.aspx

Introduction: Versioning Troubles
In the project I’m currently working on, we have multiple projects in one solution. One of the problems we’ve faced is how to keep all the version numbers (generally found in the ApplicationInfo file) in sync. Ok, so “problem” might be too strong of a word. It’s more of an annoyance, but nonetheless, it would be nice to have some sort of automated solution. The other day, I downloaded some sample code for an unrelated issue, but they had a really nice solution to the versioning puzzle. I’ve now added this solution to my current project, with a few little wrinkles, and it seems to be working really well (we won’t know for sure until the next release). [NOTE: this solution assumes you want the same version number for each project in the solution]

The Short of It
Here’s what you need to do:

1.Remove these attributes from the AssemblyInfo files in each project in the solution:
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
2.Create a new file in the root directory of the solution (I named mine VersionInfo).
3.Add the attributes that we removed from the AssemblyInfo file to your new file (you’ll also need to add a reference to the System.Reflection namespace).
4.On each project in the solution, right click the project and select Add->Existing Item.
5.HERE’S THE KEY. Browse to your newly created version file, but instead of clicking the “Add” button, click the little down arrow next to the word “Add” and then click “Add as link” from the menu it drops down.

What Did We Just Do?
Normally when you add an existing file, the IDE copies the selected file to the current directory. By selecting “Add as link,” what we’ve done is link the file in from its original location (yes, I know you figured that out from the name “Add as link”). Now when we build our solution, each project will compile in the exact same file (VersionInfo in my case) , thus giving each project the same version number.

The Next Step
To make this really cool (well, at least I think it’s cool) I’ve created a build script to automate the process of creating the release build. The script prompts for the version number of the release and updates the version file before doing the build. Here’s a snippet of my script (parts of this script were omitted to protect the innocent)…

Dim versionNumber
Set shell = CreateObject("WScript.Shell")
Set fileSystemObject = CreateObject("Scripting.FileSystemObject")

sub Main()
Echo "Getting version number"
versionNumber = InputBox("What version is this build?", "Version")
UpdateFileVersion versionNumber

CommitToVersionControl
GetLatestFromVersionControl

Echo "Building"
RunCommand _
"""C:\Program Files\Microsoft Visual Studio 8\Common7\IDE\devenv.exe""_
SolutionName.sln /Rebuild Release", "Failed to build"

RunUnitTests
CreateZipFile

MsgBox("Done.")
end sub

sub Echo(message)
WScript.Echo message
end sub

sub UpdateFileVersion(versionNumber)
Set file = fileSystemObject.OpenTextFile("Version.cs", 2)

file.WriteLine "using System.Reflection"+ vbcrlf + _
vbcrlf + _ "[assembly:AssemblyVersion""" + _
versionNumber + ".0.0"")]" + _
vbcrlf + "[assembly:AssemblyFileVersion""" + _
versionNumber + ".0.0"")]"

file.Close
end sub

sub RunCommand(command, failMessage)
result = shell.Run(command, 1 , 1)
TestResult result, failMessage
end sub

sub TestResult(result, failMessage)
if result <> 0 then
MsgBox(failMessage)
WScript.Quit
end if
end sub

Main

Conclusion
I now have a very simple and very automated versioning process. It makes sure that our projects’ version numbers are never out of sync and, more importantly, makes sure that I don’t forget to update the version numbers when I do a final release build. I hope this helps.

  • Share/Save/Bookmark