Designing with Classes and Tiers
Overview
Some developers resent user interface programming because they believe it's all about painting icons, rewording text, and endlessly tweaking dialog boxes until an entire company agrees that an application looks attractive. Certainly, making a program usable, elegant, and even impressive (or cool) is no trivial task. It can even make the difference between indifferent customers and an enthusiastic audience that's willing to promote your product tirelessly. This kind of excitement about a program's look and feel has driven obscure products like Kai's Power Tools and even Microsoft Windows to great success, and it can't be overlooked. However, developers who are involved in creating and maintaining sophisticated enterprise-level applications realize that there is another set of design considerations for user interface programming. These are considerations about application architecture.
Application architecture determines how a user interface "plugs in" to the rest of an application. Today's development software (like MFC, Visual Basic, and .NET) makes this interaction relatively straightforward and, as a result, developers usually spend little or no time thinking about it. User interface code is usually inserted wherever is most immediately convenient at the time the program is written. This almost always leads to interface code that's tightly bound to a particular problem, scenario, or data source, and heavily interwoven with the rest of the application logic. The interface code might look good on the outside, but it's almost impossible to enhance, reuse, or alter with anything more than trivial changes. To make the jump from this type of scattered user interface coding to a more modern style you have to stop thinking in terms of windows and controls, and start looking at user interface as an entire interrelated framework.
This chapter explains how the principles of object-oriented programming and three-tier architecture apply to user interface design. It identifies the overall concepts that you'll return to again and again throughout the book, including:
- How controls and windows use objects.
- Why inheritance is more important for user interface design than for business logic.
- How the .NET framework approaches user interface design, and how it affects your coding practices.
- What makes an interface data-driven and well encapsulated.
- What the dream of three-tier design promises, why it's so hard to achieve, and whether other solutions are possible.
The emphasis in this chapter is on general concepts. You'll see some code, but you won't learn about the intricate details like the properties and methods that controls provide. All these details are explored as we delve deeper into controls and user interface coding in the chapters that follow.
Interacting with a Control
One interesting and little-known fact about .NET controls is that they provide two different forms of interaction. The first, and less common one, is by creating custom classes and overriding methods. For example, imagine you have a text box that's designed for numeric entry, and you want to examine every key press to make sure that it corresponds to a number, and not a letter. To perform this type of task, you can create a customized text box, and override the OnKeyPress() method to add this extra verification logic. public class NumericTextBox : System.Windows.Forms.TextBox { protected override void OnKeyPress(KeyPressEventArgs e) { base.OnKeyPress(e); if (!char.IsControl(e.KeyChar) && !char.IsDigit(e.KeyChar)) { e.Handled = true; } } } The OnKeyPress() method is invoked automatically by the Windows Forms engine when a key is pressed in a TextBox control. The overridden method in the example above checks to see if the entered character is a number. If it isn't, the Handled flag is set to true, which cancels all further processing, effectively making sure that the character will never end up in the text box. This design pattern is useful if you use a number of controls with extremely similar behavior. If, on the other hand, you need to fine-tune behavior for distinct, even unique tasks, this approach is extremely poor. For example, consider a button control. You could react to a button click by creating a special class for every button on your application, and giving each button its own overridden OnClick() method. While your program would still be well encapsulated, it would quickly become completely disorganized, swamped by layers of button classes that have little to do with one another. The View Mediator Pattern
The approach that .NET encourages circumvents this problem by using events. When you create a form in Visual Studio .NET, a custom class is derived for the form, but all the contained classes are used as is. The event handling logic is inserted as a series of special methods, which are added to the custom form class. In other words, every form acts as a giant switchboard for all the controls it contains. This type of design pattern, which is so natural to .NET and most Windows development that you might not have even noticed it, is called the View-Mediator pattern. It dictates that one central class organizes each individual window. Here's the same text box example you saw earlier, rewritten as form-level event handler. The event handler is hooked up in the constructor for the form (although Visual Studio .NET would perform the same task with a dedicated InitializeComponent() method). public class MainForm : System.Windows.Forms.Form { System.Windows.Forms.TextBox txtUserName; public MainForm() { txtUserName = new System.Windows.Forms.TextBox(); txtUserName.Name = "txtUserName"; txtUserName.Location = New System.Drawing.Point(64, 88); txtUserName.Size = New System.Drawing.Size(200, 20); txtUserName.TabIndex = 1; txtUserName.Text = "Enter text here!"; this.Controls.Add(txtUserName); // Connect event handler. this.textBox1.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.textBox1_KeyPress); } private void textBox1_KeyPress(object sender, System.Windows.Forms.KeyPressEventArgs e) { if (!char.IsControl(e.KeyChar) && !char.IsDigit(e.KeyChar)) { e.Handled = true; } } } You'll notice that the actual logic for processing the key press is identical, but the way it's integrated into the application is completely different. The form is now responsible for the validation, not the control itself. This is an ideal approach if the form needs to handle the complex validation of multiple different controls. It's a less suitable approach if you need to perform the same type of validation for the same control in different windows, because you'll probably need to copy the code into multiple form-level event handlers. You should also remember that the point of a switchboard is to route calls to a new destination. In other words, when you create the event handler for a button's Click event, this event handler usually has two purposes:
Depending on the button, only one of these tasks may be necessary. But the important concept you should realize is that an event handler is generally part of a user interface object-the form switchboard. It's meant to handle user interface tasks, and delegate more complicated operations to other objects. Smart Controls
So far you have seen two distinct ways to use controls from the .NET class library:
The difference is shown in Figure 2-3. Figure 2-3: Two ways to interact with controls Visual Studio .NET uses inheritance (the first method) when you create forms. When you configure controls, however, it inserts them as is, and adds the appropriate logic for modifying their properties (the second method). This is the default approach in .NET, but it is not the only approach. When Visual Studio .NET adds controls and derives a custom form class, it is making a design decision for you. This decision helps clear out the clutter that would result from creating dozens of custom control classes. However, like all design decisions, it's not always right for all people and in all situations. For example, if you use numerous similar controls (like text boxes that refuse numeric input), you may find yourself duplicating the same code in event handlers all over your program. In this case, you might be better off to step beyond Visual Studio .NET's default behavior, and create customized controls with some additional intelligence. When you are creating a new application and planning how to program its user interface, one of the most important tasks is deciding where to draw the line between smart controls (custom control classes) and smart switchboards (custom forms with event handling logic). A good decision can save a lot of repetitive work. 9 consider some examples that show how you can add a bit of sense to important controls like the TreeView and ListView. Hopefully, you'll realize that custom controls are not just for redistributing neat user interface elements, but also for building intelligence into parts of a large application, and helping to reduce repetition and enforce consistency across different modules.
Smart Forms
As explained earlier, every form class in your application is a custom class that derives from System.Windows.Forms. However, you can use multiple layers of form inheritance to centralize and reuse important form functionality. This topic is commonly referred to as visual inheritance (although it's no different than any other type of control class inheritance), and it's described in detail in Figure 2-4 diagrams this relationship. Figure 2-4: Ordinary forms and visual inheritance So far, you've explored the basic object-oriented foundation of .NET user interface programming. The remainder of the chapter considers some higher-level architectural topics like encapsulation and three-tier design. These are the best practices you'll want to keep in mind when planning your user interface classes. |
Encapsulation
Encapsulation is the principle that suggests objects should have separate, carefully outlined responsibilities. Everything they need to fulfill these responsibilities should be wrapped up, hidden from view, and accomplished automatically, where possible. Encapsulation is often identified as a pillar of object-oriented programming, but it has played a part in program design since the invention of software. A properly encapsulated function, for example, performs a discrete well-identified task independently, and has a much better chance of being reused in another application (or even the same program). The best way to start separating your user interface code is to think more consciously about encapsulation. The custom form class, with its "switchboard" design, is an excellent example of encapsulation at work. However, it also presents a danger. It potentially encourages you to mix in a great amount of additional material through the form's event handlers. A large part of good user interface programming is simply a matter of resisting this urge. Of course, there are finer points to perfecting encapsulation. Custom controls, which handle some basic user interface operations on their own, are another good example. The following sections present guidelines that can help you keep encapsulation in mind. These techniques are not all immediately obvious. In this book, I'll return to the principle of encapsulation often, and show specific examples with common controls. Use Enumerations and Resource Classes
User interface controls often require sets of constants, and trying to hard-code them is a tempting trap. Instead, you should create enumerations with meaningful names, and place them in dedicated resource classes. For example, you can define enumerations that help you manage and identify different levels of nodes in a TreeView control (see Chapter 6), distinguish different types of items in a ListView, or just pass information to other methods in your program. Extraneous details like SQL statements should also be strictly confined to distinct resource classes. Use Collections
Objects are only as good as the way you can access them. On its own, a data object is a group of related information. By using a collection or other classes that contain collections, you can represent the underlying structure of an entire set of complex data, making it easier to share with other parts of your program. Restrain from Sharing Control References
It's easy to pass control references to helper methods. For example, you can create utility classes that automatically fill common list controls. However, this type of design, where you rely on extraneous classes to perform user interface tasks, can make it extremely difficult to make even simple modifications to the user interface. As a rule of thumb, business code should never rely on the existence of a specific type of user interface control. Define a Data Transfer Plan
The single greatest challenge when creating a reusable object framework is deciding how to retrieve data and insert it into the corresponding controls, without mingling the business and the presentation logic. To succeed, you may need to tolerate slightly long-winded code. You also need to create a clearly defined plan for transferring data that can be shared by all your forms and controls. In .NET, life just became a lot easier-the DataSet object makes a perfect, nearly universal solution for transferring information. Use a Central Switchboard
The form acts as a switchboard for all the controls it contains. However, you shouldn't rely on forms for anything more. Instead, you should be able to remove a form, add a new one, or even combine forms without having to rewrite much logic. To accomplish this, forms should always hand off their work to another central switchboard, like an application class. For example, it may be easy to update a record in accordance with a user's selections by creating a new object in the form code and calling a single method. However, if you add another layer of indirection by forcing the form to call a more generic update method in a central application switchboard, your user interface gains a little more independence and gets closer to the ideal of three-tier design. Figure 2-5 shows how this process might work when updating a customer record. The update is triggered in response to a control event. The event handler calls a DoCustomerUpdate() form method, which then calls the required methods in the CustomerDB business object (and creates it if necessary). Figure 2-5: Using form and application switchboards Create Data Driven User Interfaces
As you prepare for a three-tier architecture, it's a good idea to start designing your user interface around the data it manages. This may sound like a slightly old-fashioned concept in today's object-oriented way, but it's actually a good habit to prevent yourself from subconsciously combining user interface and business processing logic. Think of your user interface as having one "in" and one "out" connection. All the information that flows into your user interface needs to use a single consistent standard. All forms should be able to recognize and process this data. To achieve this, you might want to use data objects that rely on a common interface for providing data. Or, you might want to standardize on .NET's new DataSet object, which can convert information into XML and back seamlessly. The second part of Chapter 9 explores the ways you can tame data in a user interface.
|
Developing in Tiers
The basic principle of three-tier design is simple. An application is divided into three distinct subsystems. Every class belongs to only one of these three partitions, and performs just one kind of task. The three tiers are usually identified as:
An object in one tier can only interact with the adjacent tiers, as shown in Figure 2-6. Figure 2-6: Three-tier design Almost everyone agrees that this sort of structure is the best way to organize an application, but it's not always easy to implement this design. Though the schematic looks simple, modern user interfaces are usually quite complicated, and sometimes make assumptions or have expectations about the way they will receive information. The result is that everyone recommends this model, but very few developers successfully follow it. Problems with Three Tier Design
Before you can solve the problems of three-tier design, you have to understand what these problems are. The problems, although not insurmountable, are found in every tier. The presentation tier Though it doesn't explicitly state it, three-tier design really requires a fair degree of consistency among user interface controls. In the real world, this consistency doesn't exist. For example, making what is conceptually a minor change-like substituting a ListView control for a DataGrid-requires a totally different access model. DataGrids are filled exclusively by data binding. ListViews, on the other hand, act like a collection of items. To get information into other ListView columns, you have to add a collection of fields to each individual item. These quirks are easy enough to master (and you sort through them in Chapter 6), but they don't make it possible to create business objects that can quickly and efficiently fill common controls. The business tier In three-tier design, it's assumed that the user interface is isolated from the underlying data source. Information for a control is requested through a layer of business objects. This can be a problem if you create business objects that try to model real-world entities. For example, if you need to retrieve a list of customers and some summary information based on all customer orders, you would be crazy to try and work with individual Customer and Order objects. Consider the process:
Some three-tier designs get around this by creating a very thin business layer that might just consist of a pile of utility functions for retrieving rows as arrays. But is this really a good object-oriented design? The data tier Keeping the data tier separate from the business tier is another battle. To optimize performance, databases in enterprise applications usually rely on stored procedures, views, and other optimized ways to retrieve and update data. However, the user interface tier can't be built in a database-friendly way, because it is designed to be completely generic. It also can't rely on tricks that programmers' love, like dynamically generated SQL statements, because it is supposed to be completely isolated from the data tier. The result is a tradeoff, where you can favor any one of the following approaches:
Three Tier Design Consistency
There's nothing explicitly wrong with three-tier design, but it isn't a magic solution. For one thing, it's a rather vague recommendation that has more guidelines than requirements. Developers can interpret it any way they want. For example, consider an application that reads customer information from a database and displays it in an attractive list control. At first glance, it seems like a straightforward task. But consider the number of different ways it could be modeled with objects:
Which approach is the best one? It's difficult to say. The third option is somewhat suspicious, because it seems that the CustomerData class is being given additional responsibilities beyond the scope it was designed for. Some of the other scenarios make assumptions about the appropriate way to exchange data. But the greatest problem with all of these examples is that there is no guarantee that the other classes in the application will follow this pattern.
Fixing Three Tier Design
By now you have probably realized that the most important decision you can make is to define how your user interface objects should interact. This is the simplest way to improve your designs without adopting a single specific solution. To get a better idea about how you might apply three-tier design in the .NET world, consider the simple application below. It uses a TreeView that displays a list of product categories drawn from a database. When you expand a category node, you see all the relevant products. When a record is selected, the corresponding information appears on a list on the left (see Figure 2-7). Figure 2-7: An intelligent TreeView database browser There are several important considerations:
Obviously, there are different ways this application can retrieve information from the database. It could use custom data objects, but that would require category and product objects that use multiple property procedures. The user interface code would then have to retrieve these properties specifically by name, and decide on an appropriate order. For some applications, this approach would work, but in this case it would choke a relatively simple browser with extra classes and code. Using the DataSet
Instead, information in our example is sent in a neat package called the DataSet. The DataSet is found in the System.Data class, and is often referred to as a part of Microsoft's ADO.NET technology. Unlike the objects in earlier ADO, the DataSet is an ideal solution for information transfer in an application.
In other words, the DataSet removes some of the headaches of three-tier design. It allows a way to transfer information that's much more lightweight than dedicated classes, but not necessarily tied to a specific database. You can browse through its rows and fields as collections, which makes it easy to add information to list-like controls without needing to look for specific field names. Of course, when you want to start formatting column widths and setting column orders, your user interface may need to know a fair amount of information about the data. As long as you place this information in a central repository (generally a resource class with static members), your user interface will be extensible and easy to change.
Validation and Business Objects
Before continuing, allow me to debunk one myth about business objects. It's sometimes argued that business objects are an ideal way to store and transfer data because they can provide integrated error checking. In all honesty, the value of this error checking is limited. First, it's duplicating information in the database, which adds extra overhead for writing the code. More important, the error checking happens when the value is assigned, which is too late for it to be useful in the actual user interface. As you learned in the last chapter, the best thing you can do for users is to restrict their ability to make mistakes. That means you have to act on an error as soon as it happens, or better yet, forbid it entirely. No matter what, your user interface has to be designed with some business rules built-in (for example, forbidding letters in a text box that represents an invoice amount). Your data source performs the final error checking. There's not much use in adding new layers of error checking between these two (except to verify that your client code is behaving correctly). Even the best three-tier design can't escape the need to import business rules into the interface. Other Types of Application
The TreeView DataSet example above is a viable solution for many traditional database applications, which allow information to be reviewed, analyzed, and modified. Other types of applications may need other allowances.
|
The Last Word
Three-tier design is an abstraction. No successful application will implement it exactly. However, it's also a powerful guideline that helps you shape how classes interact in your application. It may seem strange to start talking about DataSets and business objects in a book on user interface design. (In fact, there are other excellent .NET books written entirely about data access.) But as you'll see, when you set specific rules about how the user interface tier can communicate with other parts of your program, you start to make the transition from a simple collection of objects to a true user interface framework. The following chapters of this book focus on mastering the tools of .NET user interfaces. Considerations for design and user interface architecture crop up periodically, but they take a back seat to the amazing new programming model of .NET. This chapter was designed to give you the background in application architecture that you need to understand this "other layer." If you keep the considerations of this chapter in mind as you continue reading, you'll discover an entirely new and richer way to program user interfaces. This chapter also introduced you to the broad picture of user interface in the .NET world, and the basic design assumptions that Visual Studio .NET makes automatically. These decisions can be altered, and the .NET framework itself allows you a considerable amount of freedom to create the exact framework that you want. In later chapters you'll learn how to exploit this freedom to create all types of custom controls. |
Control Class Basics
|