共同学习SPS,掌握Csgl#,FireScript孵化纪实

——基于.NET脚本解释引擎,可以用于ASP.NET,WinForm,WebServices

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::
引用:http://www.windowsforms.net/articles/shapedesigner.aspx

.NET Shape Library: A Sample Designer

Get the samples for this article. Unzip the folder and double-click either the VB or C# VS.NET solution.

The Shape Library provides a comprehensive example of writing a designer in the .NET Framework. It starts with a very simple set of runtime vector drawing components: the shape library, and adds design-time support for these components. The design-time code provides many examples of the rich features available to .NET Framework designers.

Compiling and running the sample

The sample is written as a Visual Studio solution containing two C# projects. One project is the ShapeLibrary itself and the other is a sample project to test the library. Load up the solution and build it. Once it is built you can experiment with the test project. Open MyShape.cs to manipulate the shape designer. To add additional shapes, you must first add the shape components to the toolbox. To do this, right click on the toolbox and choose Customize. Then click on the .NET Framework Components tab and click the Browse button. Navigate to the bin\debug directory of the ShapeLibrary project and choose ShapeLibrary.dll. This will select the components in the dialog; just click on the check box to check them all and they will appear on the current tab of the toolbox. Now you can put them on the shape designer's design surface.

To view your shape's changes on a form, you can open the TestForm.cs file. This file has an instance of your shape on it. If you have made changes to the shape, rebuilding your test project will update the form. Pressing F5 will execute the sample form, showing your shape.

Runtime components

The shape library's runtime components consist of six total objects:

Shape

Shape is the base class for all shapes. Shape is abstract and doesn't define any properties because shapes can have various characteristics. For example, Shape does not provide any sort of bounding box because for some shapes, like lines, this property does not make sense. All shapes can be drawn onto a graphics object, however, so Shape provides a single abstract method named Draw. Shape derives from System.ComponentModel.Component. The reason for this will be covered in a later section.

Line

The Line class is derived from Shape. It implements the Draw method to draw a line. It also adds several properties to Shape that help to define the line. The StartPoint and EndPoint properties describe the starting and ending points of the line, and the Color property determines what color the line will be.

Ellipse

The Ellipse class is derived from Shape. It implements the Draw method to draw an ellipse. The Ellipse object also defines its own properties that are used to define the characteristics of the ellipse. Ellipse offers a Bounds property which describes the bounding rectangle for the ellipse, a Color property which defines the color of the line used to draw the ellipse, and a Fill property. The Fill property uses a custom data type called ShapeFill, which provides a few ways that the ellipse's interior can be filled.

ShapeContainer

The ShapeContainer class is also derived from Shape. ShapeContainer does no drawing of its own, but it provides a collection of shapes. When ShapeContainer is asked to draw, it iterates through its collection of shapes, asking each shape to draw.

ShapeFill

The ShapeFill object derives from object and provides a few ways to fill objects. ShapeFill contains a Brush object that is used to fill areas with a pattern or color, and also provides a few static pre-built instances of itself so developers can pick a ShapeFill at design time from a drop-down list.

WindowsFormsAdapter

Shapes have been designed only to use classes from System.Drawing. This allows a shape object to be used on both Windows Forms applications and ASP.Net applications. A common use will be to have a shape draw itself on a Windows Forms control. This requires a small amount of "glue" code to connect a control's paint event to the shape's Draw method. Instead of requiring the user to write this code, we provide a special adapter object. The adapter has a method called Connect that accepts a control and a shape. The adapter then connects the control's paint event to the shape's draw method. This generally wouldn't be much of a help to the user, but in our case we have also provided some additional design-time code to automatically create adapters when shapes are placed on Windows Forms objects.

The Base Class: Component vs. Object

Why does Shape derive from System.ComponentModel.Componet while ShapeFill and WindowsFormsAdapter do not? This is a requirement of the .NET Framework designer architecture. The designer architecture works with any type of object, but designers (described later) can only be attached to objects that implement System.ComponentModel.IComponent. Objects that implement IComponent can also be added to a container object that implements System.ComponentModel.IContainer. This container is responsible for controlling the lifetime of the component objects it contains. At design time, Visual Studio provides a container that implements this interface. Any component added to this container automatically participates in code generation and gets its own member variable in the user's code.

Note that it is possible to use the .NET Framework designer architecture even if your objects are not components, but there needs to be at least one object that is a component. For example, if we were to expand our shape library into a full vector drawing package, we would probably find that emitting a member variable for each shape would not be terribly efficient. Instead, it would probably make sense for ShapeContainer to implement IComponent while individual shapes did not. That requires more work on the component vendor's part because less design-time logic is handled automatically, but it is possible and may be an appropriate choice for when the number of objects in the design surface becomes huge.

An introduction to design-time

The .NET Framework was written with design-time support in mind. What is design-time support? It's the ability to connect several components together without writing code. The .NET Framework was written to support very rich design-time capabilities while allowing this design-time specific code to reside in a separate assembly so it does not contribute to the size of the runtime library.

Designers

Each object that implements the System.ComponentModel.IComponent interface can have an additional designer object associated with it. This object, called a "designer", contains the additional code needed to manipulate the object at design time. A designer may perform a very simple task, such as hiding or changing properties on the component that should not be available at design time, or it may work with other designers to provide a visual editor for a group of components, such as a form or web page designer. A designer must implement the interface System.ComponentModel.Design.IDesigner.

There may be many designers created for a visual editor. One of these designers must be the "master" that provides an interface that the user can interact with. This designer is called the "root designer", and the component it is bound to is called the "root component". In the Windows Forms designer, for example, the class System.Windows.Forms.Form is typically the root component, while in ASP.Net it is System.Web.UI.Page.

In addition these designers, there is an object that is responsible for maintaining instances of all designers and components, and for loading and saving data. This object implements an interface called System.ComponentModel.Design.IDesignerHost, and is usually just called a "host" or "designer host". Visual Studio provides an instance of this object for each visual editor.

As we delve more into the shape library's design time support we will examine how its designers work in detail.

Persistence

Persistence relates to the loading and saving of data. The .NET Framework designer architecture allows data to be persisted in any format. Visual Studio implements two common formats: code generation and resource files.

Code generation is the default way that components save their data. Because all components in the .NET Framework are designed to work through command line compilers and standard editors, they cannot rely solely on special features in Visual Studio. Therefore, having Visual Studio generate source code in response to changes to a visual editor is a convenient way to provide rich design-time support while minimizing the number of ways to implement a feature. In addition, code generation acts as a great learning tool for developers. Visual Studio provides no "magic"; developers can see first-hand how everything fits together.

In addition to code generation, Visual Studio also provides a way to save language-specific data and other resources that cannot be easily represented as code (like bitmaps or audio files, for example).

As we will see in some of the design time code contained in the shape library, you have a great deal of control over how resource and code generation support are handled for your component.

Shape library's design time experience

The following walk-through demonstrates the user model for interacting with the shape library in Visual Studio. The intent is that a user creates a class that derives from ShapeContainer and uses this class to visually composite several shapes together. She then compiles the project and adds the newly created class to a Windows Forms form class. The shapes then draw directly to the form.

Step 1: Creating a new ShapeContainer class

Visual Studio determines what designer to load based on the class that your source file is inheriting from. We start off by creating an empty class file, and then we change it to inherit from Microsoft.Samples.ShapeLibrary.ShapeContainer. This will enable the view designer button. Clicking on this button shows the empty ShapeContainer designer, ready for shapes:

Notice that the designer has placed a disabled "MyShape" item on the toolbox. This item refers to the current shape we're creating; we'll use it later when we want to place this shape on windows form.

Step 2: Adding shapes to the container

Next, the user uses the ellipse and line toolbox items to create shapes. Shapes can be directly manipulated on the shape container design surface. The designer provides support for dragging and sizing shapes. Also, the property browser shows the properties for each shape:

Step 3: Add the shape to a form

Next, the user compiles the project to make the MyShape class available to other objects, and opens an empty form. This enables the MyShape item on the toolbox, and disables the ellipse and line shapes. The user drags the MyShape toolbox item into the form. This creates an instance of MyShape in the form's component tray (because MyShape is not a control). But something interesting happens: MyShape is drawn on the background of the form. ShapeContainer's designer is performing this drawing, and we'll see how this is accomplished in a later section:

Step 4: Run the application

Now the user presses F5 to run the application. This displays the form, and the form is drawing the shape just like we see at design time. This is exactly what the user expects, but it does take some work to make this happen because the shape container's designer had to generate the code that binds the form's paint event to the shape container's draw method:

Design time components

So, how does it all work? There are seven objects that make up the design-time components for the shape library. All of these objects are contained under the Design sub-directory and have the namespace "Microsoft.Samples.ShapeLibrary.Design". At the heart of these objects is an object called the root designer. This designer is associated with the ShapeContainer class through a metadata attribute:

"Prevent" prevents the classes or designers with the same filter name from 
showing up. 
  • "Require" requires that both designer and class have the filter name.
  • "Custom" dictates that the enabling of a toolbox item will be determined at runtime by implementing the IsSupported method on the IToolboxUser interface. Custom should be used sparingly because it will slow down the performance of your designer.

      In the shape library code snippet above we have declared the filter in such a way so shape items on the toolbox are only enabled when the shape designer is showing, and the shape designer itself will only allow shape items to be placed on it.

      The table below shows the various permutations for filter strings and filter types on both a designer and a class that is on the toolbox.

      Root Designer
      ClassMismatchAllowPreventRequireCustom
      MismatchYYYNY
      AllowYYNYIsSupported
      PreventYNNNN
      RequireNYNYIsSupported
      CustomNIsSupportedNIsSupportedIsSupported

      Legend:
      Y : The toolbox item will be enabled
      N: The toolbox item will be disabled
      IsSupported: The toolbox item will be enabled only if the method IToolboxUser.IsSupported returns true.

      Once we've successfully enabled the correct toolbox items, we need to handle user requests to use them. There are three different ways that a tool can be placed on a designer: the user may double click the tool, she may use drag and drop and drag the tool from the toolbox to the designer, or she may click the tool, and then click on the designer. Each of these techniques is handled differently by our root designer.

      The simplest way to provide toolbox support is to handle a double click that comes from the toolbox. This uses a direct connection to your designer through an interface called IToolboxUser. If your root designer implements this interface, the ToolPicked method of the interface will be called when the user double clicks on a toolbox item that you support. ToolPicked is passed an instance of the toolbox item that was chosen.

      To provide support for drag and drop from the toolbox, your designer's view must support OLE drag and drop. Because our view is just a Windows Forms control, drag and drop support is easy: we just have to set the AllowDrop property of the control to true and add some drop handling code. This is very easy thanks to the IToolboxService interface, which provides rich interaction with the toolbox:

          protected override void OnDragDrop(DragEventArgs e)
          {
              base.OnDragDrop(e);
          
              // Is this a toolbox item that is being dropped?
              //
              IToolboxService ts = ToolboxService;
              if (ts != null && ts.IsToolboxItem(e.Data, DesignerHost))
              {
                  ToolboxItem item = ts.DeserializeToolboxItem(e.Data, DesignerHost);
                  item.CreateComponents(DesignerHost);
              }
          }
      

      Note that toolbox items create their own components. If we wanted to manipulate the components that the toolbox item created, they are returned from the CreateComponents method call. The actual code in ShapeContainerRootDesigner does perform some manipulation of these components after creating them.

      Finally, to provide support for clicking on the designer surface and creating the currently selected tool on the toolbox, we use additional methods of IToolboxService to tell what the currently selected tool is:

      protected override void OnMouseUp(MouseEventArgs e)
         {
             base.OnMouseUp(e);
             
             if (e.Button == MouseButtons.Left)
                {
                 IToolboxService ts = ToolboxService;
                 if (ts != null && ts.GetSelectedToolboxItem(DesignerHost) != null) {
                     ToolboxItem item = ts.GetSelectedToolboxItem(DesignerHost);
                     item.CreateComponents(DesignerHost);
                 }
             }
         }
      

      ShapeContainerRootDesigner provides one additional, and uncommon, feature for the toolbox. When you open a shape in the designer, it adds a toolbox item to the toolbox. This toolbox item is disabled when the shape designer is visible, but becomes enabled when other designers are active. This toolbox item allows the developer to place an instance of the shape container that is currently being developed in the designer on the surface of another designer, similar to a Windows Forms user control. This is accomplished by creating a toolbox item that contains the class name of the class we're currently designing, and adding it as a linked tool to the toolbox. Linked tools are linked to a particular designer, so they are automatically removed from the toolbox when the designer's project is closed. All other tools on the toolbox are saved into a global database and are always available. The code to add a linked tool is very simple:

          private void OnLoadComplete(object sender, EventArgs e) 
          {
              IDesignerHost host = (IDesignerHost)sender;
              host.LoadComplete -= new EventHandler(this.OnLoadComplete);
              
              // Now get a hold of the toolbox service and add an icon for our user control.  The
              // toolbox service will automatically maintain this icon as long as our file lives.
              //
              IToolboxService tbx = (IToolboxService)GetService(typeof(IToolboxService));
          
              if (tbx != null) 
              {
                  string fullClassName = host.RootComponentClassName;
                  ToolboxItem item = new ToolboxItem();
                  item.TypeName = fullClassName;
                  
                  int idx = fullClassName.LastIndexOf('.');
                  if (idx != -1) 
                  {
                      item.DisplayName = fullClassName.Substring(idx + 1);
                  }
                  else 
                  {
                      item.DisplayName = fullClassName;
                  }
          
                  item.Bitmap = new Bitmap(typeof(ShapeDesigner), "CustomShapeContainer.bmp");
                  item.Bitmap.MakeTransparent();
          
                  // Toolbox items are mutable until Lock is called on them.  This
                  // allows them to be very flexible without requiring a bunch of
                  // different constructors.  Once lock is called, they are "fixed"
                  // and cannot be changed.  After creating a toolbox item you should
                  // always call Lock before handing it to the toolbox service.  Otherwise
                  // other people could change it on you.
                  item.Lock();
          
                  // A "linked" toolbox item is one that is linked to our designer.  It
                  // follows our designer, rather than living forever on the toolbox.
                  // The tool is automatically removed when the project is closed, and
                  // re-added when the project is opened again.
                  tbx.AddLinkedToolboxItem(item, "Shapes", host);
              }
          }
      

      Note that we do this work in an event handling method for the LoadComplete event. Why? Because until we are done loading the fully qualified name of the base class we're designing isn't known.

      Menu support

      The shape designer also handles a few simple menu editing commands: cut, copy, paste and delete. In Visual Studio, a "command" is a user request, be it a menu item, toolbar button, or keystroke. This means that by handling the delete command, we support not just the delete menu item but also the toolbar button and the delete key.

      There are two parts to each command in Visual Studio: the definition of the command, and the implementation. There is only one definition for each command, but there may be many implementations. Using delete as an example, the delete command always appears on the same place on the menu and has the same help text associated with it. The actual code that performs the delete command is different for the text editor than it is for a designer. The .NET Framework designer architecture allows you to implement any command that is already defined. To define a new command, however, you must have the Visual Studio Integration Package (VSIP) SDK.

      Implementing a command through the .NET Framework designer architecture is easy. You simply provide an event handler that will be called when the command is invoked. To do this, you create a MenuCommand object, and pass this object to the IMenuCommandService interface. A MenuCommand object consists of two pieces of information: an event handler to be invoked when the user executes the command, and a CommandID object that indicates what command you are handling. The class System.ComponentModel.Design.StandardCommands contains a set of CommandID objects for many common commands. If you need to handle a command that is not listed you may do so by creating a new CommandID object.

      Implementing clipboard functionality is also quite easy in the .NET Framework designer architecture. To place an item on the clipboard, you must save that item into some form that can be recreated. This process is called serialization. Many objects in the framework support serialization, but some, such as controls and web components, may not. These objects are designed to use design-time serialization, which typically involves the generation of source code, to save their state. Storing these objects onto the clipboard could be very difficult if it weren't for a handy service called IDesignerSerializationService. This service takes a collection of objects and returns a single object. This object can always be serialized through the standard .NET Framework serialization mechanisms, which means it can be placed directly on the clipboard. The collection of objects passed to IDesignerSerializationService can be objects that already support serialization, or objects that support code generation. The copy command implemented in the ShapeContainerRootDesigner shows how easy this is to use:

      private void OnMenuCopy(object sender, EventArgs e)
          {
              // What is this?  Components typically do not support serialization, but they all 
              // support being saved in a designer.  How this save is accomplished is dependent
              // on what is hosting the designer.  Whatever environment that is hosting us
              // provides a service called IDesignerSerializationService.  This service can
              // take an arbitrary collection of objects and convert them to an object
              // that can be serialized.  The object that comes out of a Serialize call
              // on this service can then be passed to the clipboard, or through a
              // drag and drop operation.
              //
              IDesignerSerializationService ds = 
                  (IDesignerSerializationService)GetService(
                      typeof(IDesignerSerializationService)
                   );
          
              if (ds != null)
              {
                  object serializedData = ds.Serialize(m_currentSelection);
                  DataObject data = new DataObject("ShapeLibraryData", serializedData);
                  Clipboard.SetDataObject(data, true);
              }
          }
      

      Now that we've covered the root designer, let's dig into the designers that are created for each shape on the shape container's design surface. These designers are smaller because they only provide shape-specific functionality.

      ShapeDesigner

      The ShapeDesigner class is the base class for all things that are derived from Shape. It inherits from System.ComponentModel.Design.ComponentDesigner, which implements IDesigner. The shape designer class is abstract and defines several properties and methods that deriving classes must implement. The reason why this has so many abstract methods is because the Shape object itself provides nearly no functionality. There are no common properties, so in order to treat shapes in a consistent fashion at design time the shape designer must define some common functionality that all designers must implement. The properties and methods that must be implemented by classes that derive from ShapeDesigner are shown below:

      BoundingBox property

      Shape doesn't define any generic way of changing its size or location. At design time, however, it is convenient to be able to drag shapes around. We want people to create their own shapes and their own shape designers, so we need some generic way for the drag code to manipulate the shape. The BoundingBox property is used here to provide a rectangle that encompasses the shape's dimensions.

      GetHitTest method

      This is also used by the drag code to manipulate the shape. This method returns an object it the given point is over part of the shape, or null if it isn't. What type or value of the object that is returned is of no concern to the drag code, but the drag code will remember this object and pass it back to the designer to retrieve cursor to display and also to perform the actual component drag.

      ShapeDesigner provides a single static object to return from this method: HitMove. This object should be returned when the result of a drag will be to move the shape on the shape container's surface. This is the only operation that is shared between different types of shapes.

      GetCursor method

      This is called to retrieve the correct cursor to display to the user. The parameter passed to it is an object that was retrieved from a prior call to GetHitTest.

      Drag method

      This method is called after the drag code completes a drag and wants to make a change to the actual shape. It is passed an object that was retrieved from a prior call to GetHitTest. Drag should use this object to determine what to do with the offset coordinates it has been passed. For example, if Drag is passed a hit test object of HitMove, Drag will move the shape.

      DrawAdornments method

      This method is called when it is time to draw the grab handle adornments for this shape. It will only be called if the shape is currently selected.

      DrawDragFeedback method

      This method is called by the drag code when a shape is being dragged on the screen. This draws a "rubber band" line that represents what the component will look like when it is dragged.

      Metadata Filtering

      In addition to providing properties of its own, ShapeDesigner overrides a method on its base class called PreFilterProperties. What does this do? This is a very powerful feature that designers offer: metadata filtering. Metadata filtering allows a designer to add, delete, or re-route properties, events and attributes for the component it is connected to. ShapeDesigner uses this method to add the BoundingBox property to the component. By adding this property, anyone can ask for properties on the component and retrieve the BoundingBox property. They can forget that this property is actually implemented on a designer and can manipulate the component directly.

      Adding the BoundingBox property to the set of properties reported for the component adds an additional level of responsibility to the BoundingBox property. This property is now part of the shape component we're designing so it will be seen by the property browser and code generator. We only want this property to be available to designers, however, because there isn't much need for the user to change it; shapes already provide richer ways to manipulate themselves than providing a generic bounding box. Also, because the bounding box just presents other properties of the shape in a different form we don't need to save its value when the user saves the shape to disk. We accomplish this through the use of attributes, which we place on the BoundingBox property as shown in the code fragment below:

          [Browsable(false)]
          [DesignOnly(true)]
          [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
          protected abstract Rectangle BoundingBox { get; set; }
      

      Let's look at each of these attributes in turn:

      Browsable(false) prevents the property from showing up in the property browser.

      DesignOnly(true) indicates that the property only exists at design time so it should never be emitted as code in the source file.

      DesignerSerialziationVisibility(DesignerSerializationVisiblity.Hidden) simply means that the code generator should always ignore this property and never try to save its value.

      LineDesigner and EllipseDesigner

      Both of these designers inherit from ShapeDesigner and implement the various abstract methods. LineDesigner converts a line's StartPoint and EndPoint properties into a bounding box, and performs hit-testing based on the slope of the line. EllipseDesigner has a very simple BoundingBox property because ellipse objects already have a rectangular Bounds property. EllipseDesigner's hit testing logic is more complex, however, because an ellipse has eight grab handles while a line has only two.

      ShapeContainerDesigner

      Didn't we already cover this? ShapeContainer actually has two designers: ShapeContainerRootDesigner and ShapeContainerDesigner. Why two? Because ShapeContainer can be used in two different ways. It can be an object with a design surface that users directly manipulate, and it can also be a compiled class that users can place on another designer like a form. ShapeContainerDesigner is created when a shape container is placed on another design surface. The DesignerAttribute class takes an optional second parameter that identifies the type of designer it contains, so it is possible to have more than one DesignerAttribute on a class, as shown below for ShapeContainer:

          [Designer(typeof(ShapeContainerRootDesigner), typeof(IRootDesigner))]
          [Designer(typeof(ShapeContainerDesigner))]
          public class ShapeContainer : Shape {}
      

      The ShapeContainerDesigner provides some interesting functionality. When it is initialized, it looks to see what type of an object it has been placed on. If it has been placed on an object based on a Windows Forms control class, it offers an additional property called "Control" and initializes it to the designer's form. This property is visible to developers in the property grid and allows the developer to configure which control the shape container draws to. As we'll see later, we've added some additional logic during code generation to emit the correct code to connect a shape container to a control in the source code as well.

      Customizing code generation

      Visual Studio saves designer state through two mechanisms: code generation and resources. Code generation has a couple of advantages over resources: It is easier for the developer to understand, because it's not a "black box" of information, and it also performs better because a component can be configured as fast as the native assembly instructions can execute. One of the difficulties with code generation is that there is a huge variety of needs that components may require. The .NET Framework designer architecture provides a very flexible code generation scheme. With this flexibility also comes a great deal of complexity, but the framework also provides several points of customization that allow you to configure the existing model to suit most needs. Before we talk about customization we need to understand what we're customizing, so let's begin with an overview of how the code generation process works.

      The CodeDom

      The CodeDom is an object model that represents source code. It is designed to be language independent - once you create a CodeDom hierarchy for a program you can pass this object hierarchy into a generator for any .NET compliant language and emit source code that can be compiled for that language. Let's look at the following C# and VB example code:

          // C# Code to create a new "Point" object
          //
          Point myPoint = new Point(10, 10);
      
          ' VB Code to create a new "Point" object
          '
          Dim myPoint As Point = New Point(10, 10)
      

      Both of these snippets of sample code create a new object called myPoint that is of type "Point". This can be represented as a series of CodeDom statements:

          // These CodeDom statements create a new myPoint variable that contains
          // a Point object created with the constructor of 10, 10.  The final
          // statement can be used to emit any type of source code.    
          //
      
          // First, create a variable declaration of type "Point" and name "myPoint":
          CodeVariableDeclarationStatement myPointVariable;
          myPointVariable = new CodeVariableDeclarationStatement(typeof(Point), "myPoint");
      
          // Next, we need to create a new point object used to initialize the variable.
          // This object create requires two parameters.
          CodeExpression parameters = new CodeExpression[]
              {
              new CodePrimitiveExpression(10),
              new CodePrimitiveExpression(10)
              };
      
          CodeObjectCreateExpression initializer;
          initializer = new CodeObjectCreateExpression(typeof(Point), parameters);
      
          // Finally, we assign the initialzer to the variable declaration
          //
          myPointVariable.InitExpression = initializer;
      
          // At this point, myPointVariable is complete.
      

      Easy, right? Ok, maybe not. The CodeDom allows you to create very complex source code in a language independent manner, but writing the code to create the CodeDom objects is a fairly verbose proposition. As we'll see shortly, the .NET Framework provides support for converting many objects to CodeDom statements for you.

      .NET Framework CodeDom serialization

      Obviously writing code to create CodeDom statements for each new data type you create is not an efficient way to go. The .NET Framework includes a modular system for serializing a hierarchy of objects into CodeDom statements. This system is extensible in several ways, and for most cases you will never have to involve yourself with creating CodeDom statements yourself.

      When Visual Studio needs to generate source code for a hierarchy of objects in a designer, it walks through each sited component one at a time looking for a DesignerSerializer attribute on the component that specifies how the component should be serialized. The DesignerSerializer attribute is similar to the Designer attribute mentioned above in that it takes two parameters: one for the class of the serializer and another for the base class from which the serializer derives. This provides a simple extensibility mechanism; should Visual Studio ever support more than one type of serialization, for example, to XML, then it may search for a different base class. For now, the only form of serialization supported is that whose serializer objects inherit from System.ComponentModel.Design.Serialization.CodeDomSerializer.

      Once Visual Studio has a serializer object for a given data type, it asks the serializer to create CodeDom statements for the object through CodeDomSerializer's Serialize method:

          public abstract object Serialize(IDesignerSerializationManager manager, object value);
      

      Here, the "manager" parameter is provided by Visual Studio and provides the serializer with additional context information. The "value" parameter is the object to be serialized. The return value from the method is a CodeDom object that represents the serialized value.

      This technique is used recursively for every object, from the largest forms to the smallest of strings and other primitive types. The .NET Framework's design-time code implements only a few serializer objects, but these objects encompass many common types:

      • Primitives, such as strings, integers, and floating point values.
      • Any object implementing System.Collections.IList including arrays.
      • Any object implementing System.ComponentModel.IComponent.
      • All enumeration types.
      • Any object that can be retrieved by accessing a static field, property or method, or can be created through a public constructor.
      • Any object that supports ISerializable will get a line of code loading that object through a resource, and object will be serialized into a resource.

      You are free to write your own serializer that inherits from CodeDomSerializer if your object doesn't fit into one of these categories, but, where possible, you should try to make your objects conform to this model as implementing your own CodeDom serializer is not trivial.

      Providing serialization hints

      The CodeDom serializers that ship with the .NET Framework are configurable through metadata. The serializer that generates CodeDom statements for objects that implement IComponent will walk all public properties of the object, writing out those that are read-write and have a data type that can be serialized by a CodeDom serializer. There are two other modes that property serialization can utilize:

      • A property may be read-write, but you may not want to write it to source code. A typical reason for this is if the property is a synonym of another property. For example, a Windows Forms Control class contains the properties X, Left, Location and Bounds. These all contain the left-most coordinate of a control, and were added as a convenience to developers. However, it's only necessary to write one of them to code.
      • A property may be read-only, but you may want to write the contents of the property out, rather than just assigning to the property. For example, the Windows Forms Control class has a Controls property that is read-only. It returns an instance of a ControlCollection class that is contained with the Control object. There is never a need to change the instance of the ControlCollection class, but there is a need to manipulate properties and methods of the class itself.

      To enable these other modes of property serialization you use a DesignerSerializationVisibility attribute. To prevent a property from being written at all, use DesignerSerializationVisibility.Hidden. To cause the code generator to walk into a read-only property and write the contents of the property, use DesignerSerializationVisibility.Content.

      In addition to being able to skip or walk into certain properties, the CodeDom serializers provided by the .NET Framework provide a generic scheme to create an instance of any object, provided that object can be created by calling a static method, accessing a static property or field, or creating a new instance with a custom constructor. The serializers have no way to determine what path to take without input from you, however. This input comes in the form of two classes which you must provide: a TypeConverter object, and an InstanceDescriptor object. TypeConverters, as their name implies, convert data from one type to another. Their most common use is to convert data to and from a string. String conversion of this type is used in the property browser inside Visual Studio. TypeConverters perform their conversion through a set of methods as shown in the table below:

      CanConvertToThis takes a type object as a parameter and returns true if the type converter can convert the object to the given type.
      ConvertToThis takes a type object and a value as parameters and performs the actual conversion, returning the converted value.
      CanConvertFromThis takes a type object as a parameter and returns true if the type converter can convert an instance of the provided type back into the original type.
      ConvertFromThis takes the value of the object to be converted and converts it back into the original type.

      In addition to providing string conversion, TypeConverters are also used during code generation. If a TypeConverter returns true from CanConvertTo for a data type of InstanceDescriptor, the code generator will call ConvertTo to retrieve an InstanceDescriptor for an instance of an object.

      So what is an instance descriptor? It is an object that describes how to retrieve an instance of another object. For example, let's take a Point object from the System.Drawing namespace. To create a point at coordinates 7, 16, you would initialize a point object as follows:

          Point newPoint = new Point(7, 16);
      

      This has three pieces of information: a Point constructor that takes two arguments, and two integer values of 7 and 16. To create an InstanceDescriptor that can create the above point you would write the following code:

          // First, get the Point constructor that takes two integer values:
          //
              ConstructorInfo ctor = typeof(Point).GetConstructor(new Type [] 
              {
              typeof(int),
              typeof(int)
              });
      
          // Now, create the instance descriptor:
          //
          InstanceDescriptor desc = new InstanceDescriptor(ctor, new object[] {7, 16});
      

      As you can see, InstanceDescriptor takes two parameters. The first parameter must be an object that derives from System.Reflection.MemberInfo and references either a constructor or a static value. The second parameter is an array of values to provide to the corresponding MemberInfo, or null if the MemberInfo doesn't take any parameters.

      It is exactly the code above that the .NET Framework uses when the Point object's TypeConverter is asked to convert a point to an instance descriptor. Note that when implementing a type converter the default TypeConverter class already knows how to convert an instance descriptor back into an object (because that's what instance descriptors do!). Now that you have the necessary background, we can finally talk about the last three design-time classes.

      ShapeFillConverter

      Our shape library has a single custom type: ShapeFill. This type provides three public static fields of type ShapeFill that can be used to fill ellipses. This is a somewhat contrived example but it provides an excellent base to build a type converter on. The type converter for the ShapeFill class is called ShapeFillConverter (most of the .NET Framework uses this naming convention). The type converter provides a way to convert to and from strings for the property grid. In addition, it provides a set of "standard values" which the property grid uses to populate a drop-down list. Finally, it provides support for converting a ShapeFill object to an instance descriptor. ShapeFill only supports a stock set of static fields, so the instance descriptor is just a reference to one of these static fields. This allows properties of type ShapeFill to be property coded.

      ShapeContainerCodeDomSerializer and WindowsFormsAdapterCodeDomSerializer

      Finally, we have two custom serialializers. It should be very rare that you ever have to write this level of serialization, but it is interesting to see what is possible. Moreover, if you find yourself in a situation that requires custom serialization, writing your own can be tricky without a sample.

      Shape library has a concept of an "adapter" object that adapts a shape to a container like a Windows Forms form. If a shape container is placed on a form, we want to create an instance of an adapter and connect the two up. The code we wish to generate to do this looks like this, assuming a shape shape1 is to be connected to the existing form:

          WindowsFormsAdapter shape1Adapter = new WindowsFormsAdapter();
          Shape1Adapter.Connect(this, shape1);
      

      Why didn't the adapter object just take the arguments in its constructor, instead of requiring a Connect method? This is a concession we have made to fit within the requirements of the code generator. While you can provide arbitrary constructors during code generation, you don't know the order that objects will be defined and created, so you cannot create a constructor that references another component on the form.

      We have two serializers to handle this: one to generate the code and another to parse it when the form is reloaded. Why two? We are creating a WindowsFormsAdapter object while serializing a different type of class: a ShapeContainer. During the serialization of the ShapeContainer object we can emit any code we want. When the designer parses this code, however, it will look for a serializer associated with the data type of each object. Here, it will look for a serializer that is associated with the WindowsFormsAdapter data type.

      The ShapeContainerCodeDomSerializer is responsible for the code generation part. We do not want to try to rewrite all of the serialization for the ShapeContainer class. Because it implements IComponent, it is already getting a serializer suitable for writing out its properties. But we want to add additional functionality to this. The way to do this is through delegation. We retrieve the base class's serializer, ask it to serialize, and then we merge in our own CodeDom elements. It's easy to retrieve the serialize for any type using the IDesignerSerializationManager interface that is provided to the Serialize call:

          private CodeDomSerializer GetBaseSerializer(IDesignerSerializationManager manager)
          {
              return (CodeDomSerializer)manager.GetSerializer(
                  typeof(Component), 
                  typeof(CodeDomSerializer));
          }
      

      After we retrieve the base serializer, we call it, merge in our own elements, and return the composite.

      public override object Serialize(IDesignerSerializationManager manager, object value)
          {
              object codeObject = GetBaseSerializer(manager).Serialize(manager, value);
          
              // Create the needed additional statements for the adapter.
              // 
              CodeStatement[] newStatements = // See the code for details
              
          
              // Now we have our own statements, and we need to add them to the existing
              // statements.  Check to see if the base serializer already gave us a collection
              // and use it.  If not, we will provide our own.
              //
              CodeStatementCollection statements = codeObject as CodeStatementCollection;
              if (statements == null)
              {
                  // Our base serializer didn't create a bunch of statements.  Instead, it created
                  // a single statement or expression.  If it created neither, then we must
                  // bail out and not add this extra code because we don't know how to connect
                  // it to what our base serializer provided.
                  //
                  if (codeObject is CodeExpression || codeObject is CodeStatement)
                  {
                      statements = new CodeStatementCollection();
                      CodeStatement statement = codeObject as CodeStatement;
                      if (statement == null)
                      {
                          statement = new CodeExpressionStatement((CodeExpression)codeObject);
                      }
                      statements.Add(statement);
          
                      // And setup the returning CodeObject to be this new statement collection.
                      //
                      codeObject = statements;
                  }
              }
          
              if (statements != null)
              {
                  statements.AddRange(newStatements);
              }
          
              return codeObject;
          }
      

      Conclusion

      The .NET Framework has a very rich design-time architecture. When implementing the various designers in Visual Studio we tried to keep their design modular so they can be reused. This sample shows some of the major portions of the design-time architecture that can be easily customized.

    • posted on 2006-03-15 15:02  FireReprt◇FireScript地带  阅读(534)  评论(0编辑  收藏  举报