Real World Applications Sample, Part 2: Documents and Views in a Windows Forms World

 

Chris Anderson
Software Architect
Microsoft Corporation

September 2002

Applies to:
   Windows Forms
   Microsoft® .NET Framework

Summary: Covers the creation of a document/view model to use in the Microsoft .NET Framework that can use XML, binary and serialization mechanisms for documents; includes a sample download. (11 printed pages)

Download Rwpart2.exe.

Contents

Introduction
Bug Fixes
Documents, Managers, and Views
Creating a New Document
Loading and Saving
Opening a Document
Building a View
Putting It All Together
Other Features
Conclusion

Introduction

The Real World Applications samples are designed to tackle some of the hard problems that developers face when building real applications using the .NET Framework. If you are new to this series, I encourage you to read Part 1 prior to reading this article. Each article builds upon the framework developed in the previous article(s). The goal is to have a fully functional application when this series is complete.

In Part 1, we got the image browser application up and running. Now our management team has decided that the next killer feature is a "Photo Album": they want to be able to group images together and browse through them rapidly.

Given that the data for an image will be displayed in multiple ways (single image and photo album), and I want to reuse the image-viewing code, I have determined that we need a separation between the document (data) and the view. Since there isn't a document/view model built into the .NET Framework, this article covers how to create one.

Bug Fixes

Before we dive into writing new code for our application and the framework, we first need to fix some issues that were discovered in the previous version.

Start-up launches multiple times

Although this is really a "by design" bug, it was a bad design. The Startup event should be symmetrical with the Shutdown event. Since Shutdown only launches once per application instance (not once per executable call), we should make Startup behave the same.

The ImageBrowser DisplayMode enum is public

Since we don't want the application .exe to expose public types, the ImageBrowser DisplayMode enum should be marked private.

Singleton logic doesn't work for multiple applications with the same assembly name

One of my colleagues discovered that if you have two applications that use the same assembly name, the UniqueIdentifier used for singleton logic would be the same. The fix that I made was to include the path to the assembly in the unique identifier as well.

Documents, Managers, and Views

There are many implementations out there for a document/view system. The implementation I used to solve this set of problems is a bit simplistic and has lots of room to improve. However, because it solved the problems that I had for these new features, it is perfect in my eyes!

To start with, let's defined some terminology.

Term Meaning
Document Data associated with a file or stream. The document should support change notifications, file associations, and persistence.
DocumentManager Provides document registration functions, and utilities for loading and saving documents.
View A class that is bound to a document. Typically these are used to provide visualization for a document. Views always implement IView.
Frame A class that contains a view. The requirements for a frame are determined by the exact implementation of a view.

For Windows Forms, views will typically be controls, and frames will typically be forms. By not making it a fixed definition of what a view and frame are, the document model is more flexible. You could imagine providing a Microsoft® ASP.NET-based view implementation that still uses the same document class.

At first, the separation between documents and views may seem like additional overhead that doesn't provide a lot of benefit. The way that I think about this is to compare it to data and data binding. Document is a base type for file-based data in the same way that DataSet is a base type for relational data. To display data in a UI, you use data binding. In the document/view model, the exact same thing occurs. The only difference is a slightly tighter coupling between the UI and the data, to allow for features like "save on close", and simpler change notification.

Creating a New Document

Before we can do anything with our document, we need to define a document class. For this part of the project, I need to create a class to represent the photo album, which is basically a list of images.

public class PhotoAlbumDocument : Document
{
   ... 
   public ImageEntryCollection Images { get { ... } }
   ... 
}
public class ImageEntry 
{
   ... 
   public bool PreviewDirty { get { ... } set { ... } }
   public DateTime LastModified { get { ... } set { ... } }
   public string Name { get { ... } set { ... } }
   public string Path{ get { ... } set { ... } }
   public Image Preview { get { ... } set { ... } }
   ... 
}

The album will have other properties, but the Images property is the most interesting to talk about here. The ImageEntryCollection class is a simple, strongly typed collection class of ImageEntry objects. Each entry tracks whether the preview is dirty (because I will cache it from run to run for performance reasons), and information about the file.

Loading and Saving

The first step in a document's lifetime is to be created from scratch (New) or loaded from existing data (Load). In the sample code, you will see that a document's life starts with the DocumentManager. The Load method on DocumentManager instantiates the correct type of the document—either based on file-extension mapping or by using the type parameter—and then loads the data for the document.

public class DocumentManager
{
   public static TypeCollection DocumentTypes { get; }

   public static Document NewDocument(Type documentType)
   public static Document Load(string path);
   public static Document Load(string path, Type documentType);
   public static Document Load(Stream stream);
   public static Document Load(Stream stream, Type documentType);
   public static void Save(Document document, string path);
   public static void Save(Document document, Stream stream);
   ...
}

It is interesting to note that Load and Save are not, by default, methods on a document. When I first started working on this I had that model, but it was later brought to my attention that I should support automatic saving and loading of documents. We have several serialization models in the .NET Framework and they should be leveraged here. One thing in common with the built-in serialization mechanisms is that loading a persisted object always creates a new object, rather than populating an existing one. This meant that Load couldn't live on the document itself. Primarily for consistency, I moved Save and New onto DocumentManager also.

Since there are a few built-in serialization mechanisms for documents, I wanted our application framework to support them all:

  • XML—uses System.Xml.Serialization,
  • Binary—is built on System.Runtime.Serialization, and
  • Custom—delegates to an instance of a document for any custom persistence logic.

XML and binary delegate to the core .NET Framework library to control serialization. Binary serialization can be customized using ISerializable, and the XML serialization can be controlled using the various custom attributes defined in the System.Xml.Serialization namespace.

The custom option requires that your document supports IDocumentStorage, which defines a Load and Save method for you to provide an implementation. This gives you full access to the output or input stream for your document and gives you complete control over the persisted format.

My photo album documents are stored as XML, so I am relying on the XML serialization support. To modify my document to support load and save, I add the DocumentStorageType attribute, which controls which form of serialization the document will be serialized with, and then I can adorn the various members of the class with XML serialization attributes.

[DocumentStorageType(DocumentStorageType.Xml)]
public class PhotoAlbumDocument : Document
{
   ... 
   public ImageEntryCollection Images { get { ... } }
   ... 
}
public class ImageEntry 
{
   ... 
   [XmlAttribute]
   public bool PreviewDirty { get { ... } set { ... } }
   [XmlAttribute]
   public DateTime LastModified { get { ... } set { ... } }
   [XmlAttribute]
   public string Name { get { ... } set { ... } }
   public string Path{ get { ... } set { ... } }
   [XmlIgnore]
   public Image Preview { get { ... } set { ... } }
   [XmlElement("Preview"), EditorBrowsable(EditorBrowsableState.Never)]
   public byte[] PreviewBits { get { ... } set { ... } }
   ... 
}

By adding the various XML metadata attributes to the properties on the PhotoAlbumDocument and ImageEntry, my document correctly saves and loads images. The XML format looks like:

<?xml version="1.0"?>
<PhotoAlbumDocument 
   xmlns:xsd="http://www.w3.org/2001/XMLSchema"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
   PreviewSize="75">
   <Images>
      <ImageEntry 
         PreviewDirty="false" 
         LastModified="2002-06-18T12:33:50.5040176-07:00"
         Name="IMG_1648.JPG">
         <Path>C:\Documents and Settings\chris\My Pictures\IMG_1648.JPG</Path>
         <Preview>..big base64 encoded string..</Preview>
      </ImageEntry>
      <ImageEntry 
         PreviewDirty="false" 
         LastModified="2002-05-29T16:34:08.0000000-07:00"
         Name="IMG_1649.JPG">
         <Path>C:\Documents and Settings\chris\My Pictures\IMG_1649.JPG</Path>
         <Preview>..big base64 encoded string..</Preview>
      </ImageEntry>
   </Images>
</PhotoAlbumDocument>

All the XML metadata attributes are optional. The default implementation of serialization in XML creates nested elements for properties, and everything would work. The tricky thing here is the Preview and PreviewBits properties.

My goal is to save the thumbnail image for each file to avoid having to recreate it each time you browse a photo album. To accomplish this, I cache the image and then track if the preview is out-of-date (LastModified), and if I need to generate a new preview (PreviewDirty). The reason to separate these properties is to handle a large catalog of images. Consider the case where you have 100 images in an album. I close the browser and edit each file. Then, I open the browser again. I want to see the old image, and then lazily update each thumbnail when I have time.

I need to save the image, but ML serialization doesn't natively understand Image objects. By creating a second property, PreviewBits, that handles the loading and saving of the image, I can perform my own logic to handle it. The XmlIgnore attribute on the Preview property causes the serializer to ignore it. The XmlElement attribute on the PreviewBits property lets me rename the element in XML to get the format I want, and then I added the EditorBrowsable attribute to prevent the PreviewBits property from appearing in statement completion! Pretty tricky, huh?

In addition to the photo album document class, I also want to switch the image browser to use the new document/view model. The ImageDocument class presents an interesting challenge because we have a preexisting binary format that we must adhere to. For this, I support the IDocumentStorage interface to define a Load and Save method. In addition, any class that implements IDocumentStorage is assumed to be a Custom serialization type.

class ImageDocument : Document, IDocumentStorage
{
   ...
   public Image Image { get { ... } }
   void IDocumentStorage.Load(Stream stream)
   {
      m_image = new Bitmap(Image.FromStream(stream));
      InternalState &= ~DocumentState.Modified;
   }
   void IDocumentStorage.Save(Stream stream)
   {
      m_image.Save(stream, m_image.RawFormat);
      InternalState &= ~DocumentState.Modified;
   }
   ...
}

A final attribute that we need to add to our document types to enable saving and loading of the documents is the MimeType attribute. This is part of the document framework that enables the DocumentManager to associate a file extension with a given document type.

[MimeType("JPEG Image", MimeType="image/jpeg", Extension="*")]
[MimeType("Bitmap Image", MimeType="image/bmp", Extension="*")]
[MimeType("PNG Image", MimeType="*", Extension=".png")]
class ImageDocument : Document, IDocumentStorage
{
   ...
}

[MimeType("Photo Album", 
   MimeType="text/x-photoalbum", 
   Extension=".album")]
[DocumentStorageType(DocumentStorageType.Xml)]
public class PhotoAlbumDocument : Document
{
   ...
}

A neat feature of the MimeType attribute is the ability to specify "*" for the extension or mimetype for a file. When you do this, the MimeType attribute will automatically look up in the registry the mimetype, or extension, for that file. This works great for me, because I always forget all the various extensions that can be associated with a JPEG or BMP image.

Finally, we have to add code to the startup of our application to register the documents with the DocumentManager.

void Start(object sender, StartupEventArgs e)
{
   DocumentManager.DocumentTypes.Add(typeof(ImageDocument));
   DocumentManager.DocumentTypes.Add(typeof(PhotoAlbumDocument));
}

Opening a Document

Now that our documents can load and save, we actually need to open them. Because we have extra metadata (the MimeType attribute) associated with document types, we can provide a lot more functional open and save dialogs. In our ApplicationFramework, there are two classes OpenDocumentDialog and SaveDocumentDialog that provide this. They are both based on the built in Windows Forms common dialogs, but add the document semantics. An example usage would be:

void OpenFile_Click(object sender, EventArgs e)
{
   OpenDocumentDialog dlg = new OpenDocumentDialog();
   dlg.DocumentsToOpen.Add(typeof(ImageDocument));
   if (dlg.ShowDialog() == DialogResult.OK)
   {
      ImageDocument doc = (ImageDocument)dlg.Documents[0];
      ...
   }
}

All of the population of the file name filter and the loading of the document are handled for you automatically. The implementation of these classes can reflect against the document type and look at the metadata to determine valid extensions and text to display in the filter drop-down list. Once the file is chosen, the dialog knows to call to the DocumentManager to open the document, letting you access the document in a simple, strongly typed fashion.

Building a View

Our document is created, but we need to get a view to display the data for the user. To convert the image viewer code in Part 1 into a view, we need to implement IView.

public interface IView
{
   event EventHandler ViewChanged;
   Document Document { get; set; }
}

This provides a common way for a view to bind to a document, and for consumers of the view to listen to any changes that occur that would cause the view display to change. The implementer of the interface is also require to pass through any DocumentChanged events as ViewChanged notifications, if the view needs to be repainted due to the document changes.

There is now specific interface needed to implement a frame, so not much needs to change in the ImageViewerForm. However, the form can now listen to the ViewChanged event and update the caption of the form to correctly reflect the name of the image, and if there are any changes. Since the image viewer doesn't support editing, this never shows the dirty flag, however if you look at the PhotoAlbumForm, you can see that it correctly displays the dirty status of the document.

Putting It All Together

Once we have our document, view, and frame defined, we can easily build powerful applications that reuse functionality. With this, the implementation of the new and open photo album menu items become trivial (even with error handling) and gets to reuse a lot of code.

private void newAlbumMenu_Click(object sender, System.EventArgs e)
{
   PhotoAlbumForm f = new PhotoAlbumForm();
   f.View.Document = DocumentManager.NewDocument(typeof(PhotoAlbumDocument));
   f.Show();
}
private void openAlbumMenu_Click(object sender, System.EventArgs e)
{
   OpenDocumentDialog dlg = new OpenDocumentDialog();
   dlg.DocumentsToOpen.Add(typeof(PhotoAlbumDocument));
   try
   {
      if (dlg.ShowDialog(this) == DialogResult.OK)
      {
         PhotoAlbumForm f = new PhotoAlbumForm();
         f.View.Document = (PhotoAlbumDocument)dlg.Documents[0];
         f.Show();
      }
   }
   catch (Exception)
   {
      MessageBox.Show("Unable to open the requested document.", "ImageBrowser");
   }
}

Other Features

A document/view model is a big undertaking, and obviously we can't do every feature needed. Some examples of new features that could be added are:

  • Support for documents to "sniff" the data stream to determine the document type
  • Support for document Save As where the dirty bit is maintained on the original document
  • Add document events such as Closing/Closed
  • Support for the close method on documents, shutting down all attached views
  • Close the document when the last view is closed
  • Pull model for IsDirty
  • Registration of the document type with the shell and an icon

Conclusion

In this article, we expanded the application framework and the image browser application developed in Part 1. Using Windows Forms to add the document/view model to the application framework now enables us to build powerful applications that reuse functionality. We also added a photo album feature to the image browser application in order to group images together and browse through them rapidly.

Chris Anderson is a member of the Microsoft .NET Client team that works on Windows Forms and other client technologies for the Microsoft .NET Framework. While the .NET Framework version 1.0 was under development, he worked on several different areas of the class libraries, including Windows Forms, Microsoft ASP.NET, and the Base Class Libraries (BCL). Prior to that, Chris worked on Visual Basic controls and on the Windows Foundation Classes (WFC) as part of the Microsoft Visual J++ 6.0 team.

The example companies, organizations, products, domain names, e-mail addresses, logos, people, places, and events depicted herein are fictitious. No association with any real company, organization, product, domain name, email address, logo, person, places, or events is intended or should be inferred.