通过用 .NET 生成自定义窗体设计器来定制应用程序

 

设计时环境基本原理

窗体设计器体系结构

Visual Studio .NET 中窗体设计器的实现

为自己的应用程序编写窗体设计器而需要实现的服务

代码可从以下位置下载:
CustomFormsDesigner.exe (157KB)

在很多年中,MFC 一直是生成基于 Windows® 的应用程序的流行框架。MFC 包含一个可以使窗体生成、事件连通和其他基于窗体的编程任务更加容易的窗体设计器。尽管 MFC 被广泛使用,但它一直由于其自身的缺点而受到批评 — 这些缺点大多存在于 Microsoft® .NET Framework 所擅长的领域。事实上,.NET Framework 中 Windows 窗体的可扩展及可插拔设计时体系结构已经使开发工作变得比用 MFC 进行开发灵活得多。

例如,通过 Windows 窗体,可以将某个自定义控件从工具箱拖放到 Visual Studio® 设计图面上。令人惊讶的是,即使 Windows 窗体不了解有关该控件的任何信息,它也能够承载它并让您操纵它的属性。这些在 MFC 中都是不可能的。

在本文中,我将讨论在设计窗体时发生在幕后的事情。然后,我将向您说明如何生成自己的基本窗体设计器,以使用户能够按照与使用 Visual Studio 中的窗体设计器创建窗体的类似方式来创建窗体。为了完成这一工作,您需要确切了解 .NET Framework 提供了哪些功能。


一些 Windows 窗体基础知识

在我开始进行该项目之前,您需要先了解几个基本概念。让我们从设计器的定义开始。设计器提供了组件的设计模式 UI 和行为。例如,在窗体上放置按钮时,按钮的设计器就是确定该按钮的外观和行为的实体。设计时环境提供了一个窗体设计器和一个属性编辑器,以使您可以操纵组件和生成用户界面。设计时环境还提供了可用来与设计时支持进行交互以及自定义和扩展设计时支持的服务。

窗体设计器提供了设计时服务和一个供开发人员设计窗体的工具。设计器宿主使用设计时环境来管理设计器状态、活动(例如,事务)和组件。此外,还有几个需要了解的与组件本身有关的概念。例如,组件是可处置的,它可以由容器托管,并提供了 Site 属性。它通过实现 IComponent 而获得这些特征,如下所示:

public interface System.ComponentModel.IComponent : IDisposable

{

    ISite Site { get; set; }

    public event EventHandler Disposed;

}

IComponent 接口是设计时环境和要在设计图面(例如,Visual Studio 窗体设计器)上承载的元素之间的基本协定。例如,按钮可以寄宿到 Windows 窗体设计器中,因为它实现了 IComponent。

.NET Framework 实现两个类型的组件:可视组件和非可视组件。可视组件是用户界面元素(例如,控件),而非可视组件是没有用户界面的组件(例如,创建 SQL Server? 数据库连接的组件)。Visual Studio .NET 窗体设计器在您将组件拖放到设计图面上时,对可视组件和非可视组件加以区分。图 1显示了这一区别的一个示例。

图 1 可视组件和非可视组件

容器包含组件,并且允许所包含的组件相互访问。当容器管理组件时,该容器负责在自身被处置时处置该组件 — 这是一个好主意,因为组件可以使用非托管资源,而这些资源不会由垃圾回收器自动处理。容器实现了 IContainer,IContainer 只是几个使您可以在该容器中添加和移除组件的方法:

public interface IContainer : IDisposable

{

 

    ComponentCollection Components { get; }

    void Add(IComponent component);

    void Add(IComponent component, string name);

    void Remove(IComponent component);

}

不要让该接口的简单性欺骗了您。容器的概念在设计时很关键,并且在其他情况下也很有用。例如,您肯定编写过实例化多个可处置组件的业务逻辑。它通常采用下面的形式:

using(MyComponent a = new MyComponent())

{

    // a.do();

}

using(MyComponent b = new MyComponent())

{

    // b.do();

}

using(MyComponent c = new MyComponent())

{

    // c.do();

}

使用 Container 对象,可以将上述代码行简化为下面的形式:

using(Container cont = new Container())

{

    MyComponent a = new MyComponent(cont);

    MyComponent b = new MyComponent(cont);

    MyComponent c = new MyComponent(cont);

    // a.do();

    // b.do();

    // c.do();

}

容器的功能不只限于自动处置它的组件。.NET Framework 定义了一个名为“站点”的东西,它与容器和组件相关。这三者之间的关系如图 2所示。正如您可以看到的那样,组件刚好由一个容器管理,并且每个组件刚好具有一个站点。在生成窗体设计器时,同一个组件不能出现在一个以上的设计图面上。但是,多个组件可以与同一个容器相关联。

图 2 关系

组件的生存期可以由它的容器来控制。作为生存期管理的回报,组件获得了对容器所提供的服务的访问权。此关系类似于 COM+ 组件与承载它的 COM+ 容器之间的关系。通过允许 COM+ 容器对其进行管理,COM+ 组件可以参与事务以及使用由 COM+ 容器提供的其他服务。在设计时上下文中,组件和它的容器之间的关系是通过站点建立的。在将组件放到窗体中时,设计器宿主会为该组件和它的容器创建一个站点实例。当此关系建立以后,组件已经被“站点化”,并使用它的 ISite 属性来访问它的容器所提供的服务。


服务和容器

当组件允许容器取得它的所有权时,该组件就获得了对该容器所提供的服务的访问权。在该上下文中,服务可以被视为具有众所周知的接口的函数,可以从服务提供程序中获得,可以存储在服务容器中,并且可以通过它的类型寻址。

服务提供程序实现了 IServiceProvider,如下所示:

public interface IServiceProvider

{

    object GetService(Type serviceType);

}

客户端通过向服务提供程序的 GetService 方法提供它们所需的服务类型来获得服务。服务容器充当服务的储存库并实现 IServiceContainer,从而提供了一种添加和移除服务的手段。下面的代码显示了 IServiceContainer 的定义。请注意,服务定义只包含用于添加和移除服务的方法。

public interface IServiceContainer : IServiceProvider

{

    void AddService(Type serviceType,ServiceCreatorCallback callback);

    void AddService(Type serviceType,ServiceCreatorCallback callback,

                    bool promote);

    void AddService(Type serviceType, object serviceInstance);

    void AddService(Type serviceType, object serviceInstance,

                    bool promote);

    void RemoveService(Type serviceType);

    void RemoveService(Type serviceType, bool promote);

}

因为服务容器可以存储和检索服务,所以它们还被视为服务提供程序,并因此实现了 IServiceProvider。服务的组合、服务提供程序和服务容器共同构成了一个具有很多优点的简单设计模式。例如,该模式具有下列优点:

在客户端组件和它们所使用的服务之间建立了松耦合。

创建了简单的服务储存库和发现机制,从而使应用程序(或应用程序的某些部分)能够良好地伸缩。您可以只使用必要的部分生成应用程序,然后再添加其他服务,而无需对应用程序或模块进行任何较大的更改。

提供了用于实现服务的惰性加载的工具。AddService 方法被重载,以便在第一次查询服务时创建相应的服务。

可以用作静态类的替代品。

促进了基于协定的编程。

可以用来实现工厂服务。

可以用来实现可插拔的体系结构。您可以使用这种简单的模式来加载插件,以及向插件提供服务(例如,日志记录和配置)。

设计时基础结构极为广泛地使用了该模式,因此彻底理解它是很重要的。


生成窗体设计器

既然您已经了解了设计时环境背后的基本概念,那么我将以它们为基础来分析窗体设计器的体系结构(请参见图 3)。

图 3 窗体设计器体系结构

体系结构的核心是组件。所有其他实体都直接或间接地使用组件。窗体设计器是将其他实体连接在一起的粘接剂。窗体设计器使用设计器宿主来获得对设计时基础结构的访问权。设计器宿主使用设计时服务,并且提供它自己的一些服务。服务可以(并且通常)使用其他服务。

.NET Framework 没有公开 Visual Studio .NET 中的窗体设计器,因为该实现是特定于应用程序的。尽管实际的接口未公开,但设计时框架仍然存在。您必须完成的所有工作就是提供特定于窗体设计器的实现,然后将您的版本提交给设计时环境以供使用。

我的示例窗体设计器显示在图 4中。像每个窗体设计器一样,它具有一个供用户选择工具或控件的工具箱、一个用于生成窗体的设计图面以及一个用于操纵组件属性的属性网格。

图 4 自定义窗体设计器示例

首先,我将生成工具箱。但是,在此之前,我需要决定如何向用户呈现工具。Visual Studio .NET 具有一个包含多个组的导航栏,其中的每个组都包含有工具。要生成工具箱,您必须完成下列工作:

1.

创建向用户显示工具的用户界面

2.

实现 IToolboxService

3.

将 IToolboxService 实现插入到设计时环境中

4.

处理事件,例如,工具的选择和拖放

对于任何现实的应用程序,生成工具箱用户界面可能很费时间。您必须做出的第一个设计决策是如何发现和加载工具 — 有多种可行的方法。使用第一个方法,可以对要显示的工具进行硬编码。建议不要采用这种方法,除非应用程序非常简单,并且将来只需要进行很少的维护。

第二个方法涉及到从配置文件中读取工具。例如,工具可以按如下方式定义:

<Toolbox>

    <ToolboxItems>

        <ToolboxItem DisplayName="Label"

            Image="ResourceAssembly,Resources.LabelImage.gif"/>

        <ToolboxItem DisplayName="Button"

            Image="ResourceAssembly,Resources.ButtonImage.gif"/>

        <ToolboxItem DisplayName="Textbox"

            Image="ResourceAssembly,Resources.TextboxImage.gif"/>

    </ToolboxItems>

</Toolbox>

  

该方法的优点是可以添加或削减工具,而无需重新编译代码以改变工具箱中显示的工具。另外,该实现相当简单。您需要实现一个节处理程序来读取 Toolbox 节,并返回 ToolboxItem 的列表。

第三个方法是为每个工具创建一个类,并用封装了诸如显示名称、组和位图之类信息的特性来修饰该类。在启动时,应用程序会加载一组程序集(从配置文件或类似东西中指定的某个众所周知的位置),然后查找带有特定修饰(例如,ToolboxAttribute)的类型。具有该修饰的类型被加载到工具箱中。该方法可能是最灵活的方法,并且可以通过反射来进行了不起的工具发现,但是它也需要完成更多一些工作。在我的示例应用程序中,我使用了第二个方法。

下一个重要步骤是获得工具箱图像。您可以花费好几天来尝试创建自己的工具箱图像,但是以某种方式访问 Visual Studio .NET 工具箱中的工具箱图像将非常方便。幸运的是,已经有了完成该工作的方法。在内部,Visual Studio .NET 工具箱是使用第三个方法的变体加载的。这意味着,组件和控件是用一个特性 (ToolboxBitmapAttribute) 修饰的,该特性定义了为该组件或控件获得图像的位置。

在示例应用程序中,工具箱内容(组和项)在应用程序配置文件中定义。为了加载工具箱,一个自定义的节处理程序会读取 Toolbox 节,并返回一个绑定类。该绑定类随后被传递给表示该工具箱的 TreeView 控件的 LoadToolbox 方法,如图 5 所示。

LoadItem 方法为给定类型创建一个 ToolboxItem 实例,然后调用 GetItemImage 来获得与该类型相关联的图像。该方法获得该类型的特性集合以查找 ToolboxBitmapAttribute。如果它找到该特性,则会返回图像,以便它可以与刚刚创建的 ToolboxItem 相关联。请注意,该方法使用 TypeDescriptor 类,此类是 System.ComponentModel 命名空间中的一个实用性的类,它用于获得给定类型的特性和事件信息。

既然您知道了如何生成工具箱用户界面,那么下一个步骤是实现 IToolboxService。由于该接口被直接绑定到工具箱,所以在派生自 TreeView 的类中实现该接口十分方便。大多数实现都很简单明了,但是您需要特别注意如何处理拖放操作,以及如何序列化工具箱项。请参见本文代码下载(可从 MSDN®Magazine Web 站点获得)中的 ToolboxService 实现的 toolboxView_MouseDown 方法。该过程的最后一步是将服务实现挂钩到设计时环境中 — 在讨论完如何实现设计器宿主之后,我将演示如何进行挂钩。


实现服务

窗体设计器基础结构是在服务之上生成的。有一组服务必须实现,还有一些服务只是增强窗体设计器的功能(如果您实现它们的话)。这是我在前面讨论的服务模式以及窗体设计器的一个重要方面。您可以首先实现基本服务集,以后再添加其他服务。

设计器宿主是到设计时环境的挂钩。设计时环境使用宿主服务在用户从工具箱中拖放组件时创建新组件,管理设计器事务,在用户操纵组件时查找服务,等等。宿主服务定义 IDesignerHost 定义了方法和事件。在宿主实现中,您需要为宿主服务以及其他多个服务提供实现。这些服务应当包括 IContainer、IComponentChangeService、IExtenderProviderService、ITypeDescriptionFilterService 和 IDesignerEventService。


设计器宿主

设计器宿主是窗体设计器的核心。当宿主的构造函数被调用时,该宿主使用父服务提供程序 (IServiceProvider) 来构建它的服务容器。以这种方式将提供程序串连起来以达到涓流效果是很常见的。在创建了服务容器之后,宿主将它自己的服务添加到提供程序中,如图 6 所示。

将组件放到设计图面上时,需要将其添加到宿主的容器中。添加新组件是一项相当复杂的操作,因为必须执行多项检查,并且还要激发一些事件(请参见图 7)。

如果忽略检查和事件,则可以按如下方式总结添加算法。首先,为该类型创建一个新的 IComponent,并且为该组件创建一个新的 ISite。这会建立站点-组件关联。请注意,站点的构造函数接受设计器宿主实例。站点构造函数采用设计器宿主和组件,以便可以建立图 2中所示的组件-容器关系。然后,创建、初始化该组件设计器,并将其添加到组件-设计器词典中。最后,将新组件添加到设计器宿主容器中。

移除组件需要完成一点儿清理工作。同样,如果忽略简单检查和验证,则移除操作实际上就是移除设计器,处置设计器,移除该组件的站点,然后处置该组件。


设计器事务

设计器事务的概念类似于数据库事务,因为它们都是将一系列操作组合在一起,以便将该组操作视为一个工作单元,并启用提交/中止机制。设计器事务在整个设计时基础结构中使用,以便支持操作的取消,并且使视图能够延迟更新它们的显示,直到整个事务完成为止。设计器宿主提供了通过 IDesignerHost 接口来管理设计器事务的工具。管理事务并不非常困难(请参见示例应用程序中的 DesignerTransactionImpl.cs)。

DesignerTransactionImpl 表示事务中的单个操作。当宿主被要求创建事务时,它会创建 DesignerTransactionImpl 的一个实例来管理单个更改。该宿主在 DesignerTransactionImpl 的实例管理每个更改的同时跟踪事务。如果您没有实现事务管理,则会在使用窗体设计器时获得一些有趣的异常。


接口

正如我已经说过的那样,需要将组件放到容器中,才能进行生存期管理以及向它们提供服务。设计器宿主接口 IDesignerHost 定义了用于创建和移除组件的方法,因此如果宿主提供了该服务,您不应当感到吃惊。同样,容器服务定义了用于添加和移除组件的方法,这些方法与 IDesignerHost 的 CreateComponent 和 DestroyComponent 方法重叠。因此,大多数繁重工作都是在容器的添加和移除方法中完成的,而创建和销毁方法只是将调用转发给这些方法。

IComponentChangeService 定义了组件更改、添加、移除和重命名事件。它还为组件的已更改事件和正在更改的事件定义了方法,当组件正在更改或已经更改时(例如,当属性更改时),这些方法由设计时环境调用。该服务由设计器宿主提供,这是因为组件是通过宿主创建和销毁的。除了创建和销毁组件以外,宿主还可以通过创建方法来处理组件重命名操作。重命名逻辑很简单,但很有趣:

// If I own the component and the name has changed, rename the component

if (component.Site != null && component.Site.Container == this &&

    name != null && string.Compare(name,component.Site.Name,true) != 0)

{

    // name validation and component changing/changed events are

    // fired in the Site.Name property so I don't have

    // to do it here...

    component.Site.Name=name;

    return;

}

该接口的实现足够简单,您完全可以将其余部分留待示例应用程序予以解决。

ISelectionService 处理设计图面上的组件选择。当用户选择组件时,SetSelectedComponents 方法由带有所选组件的设计时环境调用。SetSelectedComponents 的实现显示在图 8 中。

选择服务会跟踪设计器表面上的组件选择。其他服务(例如,IMenuCommandService)在需要获得有关所选组件的信息时使用该服务。为了提供此信息,该服务将维护一个表示当前所选组件的内部列表。设计时环境在组件的选择已经被更改时用一个组件集合来调用 SetSelectedComponents。例如,如果用户选择了一个组件,然后按住 shift 键并选择另外三个组件,则每次向选择列表中进行添加时,都会调用该方法。每次调用该方法时,设计时环境都会告诉我们哪些组件受到了影响,以及受到了怎样的影响(通过 SelectionTypes 枚举)。实现会查看组件是如何更改的,以便确定组件是需要添加到内部选择列表中,还是需要从该列表中移除。在修改内部选择列表以后,我激发了 Selection Changed 事件(请参见 SelectionServiceImpl.cs 中的方法 selectionService_SelectionChanged),以便可以用新的选择更新属性网格。应用程序的主窗体 MainWindow 预订了选择服务的 Selection Changed 事件,以便用所选的组件更新属性网格。

另请注意,选择服务定义了 PrimarySelection 属性。主选择始终设置为所选的最后一个项。当我讨论如何显示正确的设计器上下文菜单时,我将在 IMenuCommandService 的讨论中使用该属性。

选择服务是比较难以正确实现的服务之一,因为它具有一些使实现复杂化的有价值的功能。例如,在现实的应用程序中,处理键盘事件(例如,Ctrl+A)以及管理与处理大型选择列表有关的问题是有意义的。

ISite 实现是比较重要的实现之一,如图 9 所示。

您将注意到 SiteImpl 还实现了 IDictionaryService,这有一点儿不同寻常,因为我实现的所有其他服务都绑定到设计器宿主。结果,设计时环境要求您为每个站点化组件实现 IDictionaryService。设计时环境使用每个站点上的 IDictionaryService 来维护在整个设计器框架中使用的数据表。另一个需要注意的与站点实现有关的事情是,由于 ISite 扩展了 IServiceProvider,因此类提供了 GetService 的实现。设计器框架在站点上查找服务实现时调用该方法。如果服务请求是针对 IDictionaryService 的,则该实现只会返回自身 — SiteImpl。对于所有其他服务,请求被转发给站点的容器(例如,宿主)。

每个组件都必须具有一个唯一的名称。当您将组件从工具箱中拖放到设计图面上时,设计时环境会使用 INameCreationService 的实现来生成每个组件的名称。组件的名称是在该组件被选择时显示在属性窗口中的 Name 属性。INameCreationService 接口的定义如下所示:

public interface INameCreationService

{

    string CreateName(IContainer container, Type dataType);

    bool IsValidName(string name);

    void ValidateName(string name);

}

在示例应用程序中,CreateName 实现使用容器和 dataType 来计算新名称。简言之,该方法统计其类型等价于 dataType 的组件的数量,然后将得到的计数与 dataType 结合使用来提出一个唯一的名称。

迄今为止所讨论的服务都直接或间接地处理组件。另一方面,菜单命令服务是特定于设计器的。它负责跟踪菜单命令和设计器谓词(操作),并且在用户选择特定设计器时显示正确的上下文菜单。

菜单命令服务处理添加、移除、查找和执行菜单命令的任务。此外,它还定义了相关方法,以便跟踪设计器谓词,以及为支持这些方法的设计器显示设计器上下文菜单。该实现的核心在于显示正确的上下文菜单。因此,我将剩下的一点儿实现留到示例应用程序中,而重点讨论如何显示上下文菜单。


跟踪设计器谓词并显示上下文菜单

有两种类型的设计器谓词:全局谓词和局部谓词。全局谓词适合于所有设计器,而局部谓词特定于每个设计器。当您在设计图面上右键单击选项卡控件时,可以看到一个局部谓词的示例(请参见图 10)。

图 10 设计图面

右键单击选项卡控件可以添加局部谓词,以使您可以在控件上添加和移除选项卡。当您在 Visual Studio 窗体设计器中右键单击设计图面的任何地方时,可以看到一个全局谓词的示例。无论您单击哪个地方或哪个对象,您始终会看到以下两个菜单项:“View Code”和“Properties”。每个设计器都具有一个 Verbs 属性,该属性包含代表特定于该设计器的功能的谓词。例如,对于选项卡控件设计器,谓词集合包含以下两个成员:“Add Tab”和“Remove Tab”。

当用户右键单击设计图面上的选项卡控件时,设计时环境将调用 IMenuCommandService 上的 ShowContextMenu 方法(请参见图 11)。

该方法负责显示所选对象的设计器的上下文菜单。正如您在图 11中看到的那样,该方法从选择服务中获得所选组件,从宿主中获得它的设计器,从设计器中获得谓词集合,然后向每个谓词的上下文菜单中添加一个菜单项。在添加了谓词之后,上下文菜单将显示。请注意,当您为设计器谓词创建新的菜单项时,您还为该菜单项附加了一个单击处理程序。该自定义单击处理程序可为所有菜单项处理单击事件(请参见示例应用程序中的方法 MenuItemClickHandler)。

当用户从设计器上下文菜单中选择菜单项时,系统将调用该自定义处理程序,以执行与该菜单项关联的谓词。在该处理程序中,可以检索与该菜单项相关联的谓词并调用它。


ITypeDescriptorFilterService

我在前面提到过,TypeDescriptor 类是一个实用性的类,它用于获得有关类型的属性、特性和事件的信息。ITypeDescriptorFilterService 可以为站点化组件筛选该信息。TypeDescriptor 类在试图返回站点化组件的属性、特性和/或事件时使用 ITypeDescriptorFilterService。如果设计器希望为它正在设计的组件修改设计时环境可用的元数据,则可以通过实现 IDesignerFilter 来完成该工作。ITypeDescriptorFilterService 定义了三个方法,以使设计器筛选器可以挂钩到站点化组件的元数据中并对其进行修改。ITypeDescriptorFilterService 的实现简单而直观(请参见示例应用程序中的 TypeDescriptorFilterService.cs)。


把代码合在一起

如果您已经查看了示例应用程序并且运行窗体设计器,则您可能想知道所有这些服务是如何集成在一起的。您不能以递增方式生成窗体设计器 — 也就是说,您不能实现一个服务,测试应用程序,然后编写另一个服务。您必须实现所有必需的服务,生成用户界面,将它们全都结合在一起,然后才能测试应用程序。这是坏消息。好消息是,我已经在我所实现的服务中完成了大部分工作。所剩下的只是一点儿技巧。

首先,请观察一下设计器宿主的 CreateComponent 方法。在创建新组件时,需要了解它是否是第一个组件(如果 rootComponent 为空)。如果它是第一个组件,则您必须为该组件创建专门的设计器。这一专门的基础设计器是一个 IRootDesigner,因为设计器层次结构中最顶层的设计器必须是 IRootDesigner(请参见图 12)。

既然您知道了第一个组件必须是根组件,那么如何确保正确的组件是第一个组件呢?答案是设计图面最终成为第一个组件,因为您在主窗口初始化例程中将该控件创建为 Form(请参见图 13)。

处理根组件是设计器宿主、设计时环境和用户界面之间的粘合剂的唯一需要技巧的部分。其余部分只需花费一点儿时间阅读代码就很容易理解。


调试项目

实现窗体设计器不是一个没有价值的练习。几乎没有任何有关该主题的现存文档。在您弄清楚从哪里开始以及要实现哪些服务之后,调试项目将是一项令人痛苦的工作,因为您必须实现一组必需的服务并将它们插入到项目中,然后才能开始调试任何服务。最后,在实现了必需的服务之后,所得到的错误信息不会提供多大的帮助。例如,您可能在调用内部设计时程序集的行中得到 NullReferenceException,而您无法调试该错误,因此您只能纳闷哪个服务在哪个地方失败了。

另外,因为设计时基础结构是在我前面讨论的服务模式之上生成的,所以调试服务可能会成为一个问题。一种可以减轻调试痛苦的技术是记录服务请求。记录哪个服务请求被查询,该请求是通过还是失败,以及它是从框架内部的哪个地方调用的(利用 Environment.StackTrace)— 这可能是一种非常有用的调试手段,值得添加到您的方法库中。


小结

我已经概述了为了使窗体设计器启动和运行而需要实现的基础服务。此外,您已经了解了如何通过更改配置文件来基于应用程序的需要配置工具箱。剩下的工作就是调整现有的服务,并根据您的需要来实现其他一些服务。

Sayed Y. Hashimi是一位居住在美国佛罗里达州杰克逊维尔市 (Jacksonville, Florida) 的顾问,他专门研究与 .NET Framework 相关的技术。通过 hashimi_sayed@hotmail.com可以与他联系。

posted @ 2008-06-21 09:56  齐.net  阅读(1313)  评论(3编辑  收藏  举报