第 1 章 走进 Delphi 本章回答了谁是本书的读者。本书适用于中、高级的 Delphi 程序员,以及从 Visual Basic 或 C++语言 迁移到 Delphi 的专业程序员。 对于中等程度或过渡型的读者,本章提供了调整的机会。本章是专门为中等程度及过渡型的读者所设 计的。如果您对基础知识了解得很清楚,可以跳过本章。 本章试图保证在涉及基础知识时,我们大家都处于同一水准上。对于中等技术水平的 Delphi 程序员, 这一章将填补他的一些知识漏洞;而对于过渡型的读者,这一章则架起了通往本书及 Delphi 的其余部分的 桥梁。 Delphi 之美在于它令人迷惑般的简单。我曾使用 C、C++、Java、Visual Basic、Access 及其他许多语 言进行编程,但 Delphi 一直是我最喜爱的语言。从表面上看,Delphi 与 Visual Basic 有惊人的相似。 作为一种工具,Delphi 建立在成熟的面向对象语言 Object Pascal 之上。使用 Delphi,您可以做到能用 C++完成的任何事情但避免 C++的大部分麻烦;使用 Delphi,您可以创建 Visual Basic 应用程序。反过来则 是做不到的。在 C++中,程序员可能会在内存管理、模板、操作符重载时遇到麻烦,而在 Delphi 中则不会 出现这种问题。在 Visual Basic 中您会很快遇到一些障碍,而且会经常出现这样的情况。Delphi 既具有 C++ 的强大性又具有 Visual Basic 的易用性。 1.1 浏览和配置 IDE 当您运行 Delphi 时就可以看到 IDE(集成开发环境,Integrated Development Environment)。事实上, Delphi 的先驱之一 Turbo Pascal 就提供了业界最早的 IDE。在早期版本的 Turbo Pascal 中,集成开发环境用 于建立 DOS 应用程序,在当时非常有效,就像现在的 Delphi 一样。我们来回顾一下 Delphi 的基本功能。 1.1.1 打开文件和保存文件 Delphi 存在的目的是用于 Windows 编程。而 Windows 程序具有图形用户界面。Delphi 工程的图形部 分存储在描述程序外观的资源文件中,代码则写在另一个文件中。因此每个 Windows 工程都会有两个或更 多的文件。文件的集合称之为工程。当您要编写程序时,您需要创建一个新的工程。 创建一个新的工程 很幸运,当您打开 Delphi 时,会默认地创建一个新的 Windows 应用程序工程。也可以运行 Delphi 来 创建新的工程,按 Alt + F,N 键即可。创建的新工程的选项位于 File,New 菜单中。这两种途径都可以得 到新的工程,如图 1.1 所示。 第1章 走进 Delphi 图 1.1 创建一个新工程时桌面的外观 2 显示在前台的单元文件可供编写程序代码。显示在后台并与单元文件稍有偏移的是窗体,可在其中设 计程序的图形用户界面。根据需要,Delphi 创建的 Windows 应用程序可拥有任意数目的窗体,每个窗体有 一个单元文件和窗体文件。单元的数目同样的不受限制,但并非所有的单元都有窗体。如果单元不需要可 视化的外观,那么它就不需要窗体。按 Alt+F,N,U 键可以创建无窗体的单元。这就是从 File,New 菜单 中创建一个新单元的过程。 打开文件和关闭文件 为管理桌面需要打开或关闭文件,这取决于您的工作在程序代码中所处的位置。文件操作可以在 File 菜单中找到。要关闭特定的文件,请确认代表该文件的窗体或单元位于前台。按 Alt+F,C 键可以关闭前 台文件。当您所关闭的单元有相关联的窗体时,关联的窗体也会被关闭,反之亦然。 打开文件是在 File 菜单中的操作。可以按 Alt+F,O 键来打开文件,或使用 File 菜单中的 Reopen 命令 (刚好位于 Open 菜单项之下)来选择一个最近关闭的文件。当打开的单元有相关联的窗体时,只需选择 单元的名字则窗体文件也会打开。 保存文件 同所有的文件操作一样,Save 操作也在 File 菜单中。您可以选择 Save,Save As,Save Project As,或 Save All 等菜单项来保存您的工作。Save 用于保存前台文件,Save As 用新的名字保存前台文件,Save Project As 以不同的名字保存工程,而 Save All 则用一次操作保存了工程中的所有文件。当保存工程时,您对工程 文件的命名再加上.DPR 扩展名将成为编译时程序的名字。 1.1.2 搜索代码 程序员通常会在几个星期之内忘掉所编写的代码。程序员在跟踪并修改代码时,可能会花费相当可观 的时间查找代码。Delphi 提供了一些很好的工具来做这些工作。 查找与替换 Delphi 的 Search 菜单提供了几种搜索方法。在 Search 菜单中,能执行 Find,Find in Files, Replace,Search Again 或者 Incremental Search 等操作。例如,当您按 Ctrl+F 键时,就会显示如图 1.2 所示的 Find 对话框, 它包含两个属性页。按 Ctrl+R 键则显示 Replace Text 对话框,它比 Find 对话框多一个输入域,您可以在其 中输入文字用以替代找到的文字,除此之外它与 Find 对话框几乎完全相同。 第1章 图 1.2 走进 Delphi 3 按 Ctrl+F 键显示如图的带两个属性页的 Find 对话框。Find 属性页可用于在前台文件中查找,Find in Files 属性页 是基于文件路径和掩码,可在一个或多个文件中进行查找 按 F3 键可运行 Search Again 菜单项。这将利用上一次输入到 Find,Find in Files 或 Replace 对话框中 的信息重复上一次查找。一个不错的附加查找功能是 Incremental Search(增量查找)。按 Ctrl+E 键可启动 增量查找。如果发现匹配,增量查找将寻找键入的每个字符。在图 1.3 中键入字母 me,结果关键字 implementation 中的 me 被找到,从而被高亮显示。 跳到行号 如果知道要检查的代码的行号,Go To Line Number 功能是很有用的。按 Alt+G 键就可以使用 Go To Line Number 功能。例如,如果把代码连同行号打印出来并发现了一个错误,这个命令很快就能找到错误位置。 另一项技术是创建日志文件,当代码运行时向每个日志项添加行号,然后可以用日志文件中引用的行号和 Go To Line Number 功能迅速把光标移动到该行代码。 图 1.3 按 Ctrl+E 键启动增量查找;查找过程会像您分别键入了每个字符一样地执行 浏览对象 如图 1.4 所示是 Browse Symbol 对话框,它可以迅速查找关于一个类的所有信息,或者对象类的某个 实例的所有引用。当您按 Alt+S,Y 键并输入 TForm(一个类名)将显示如图 1.5 所示的对话框,其中有 Scope 和 Inheritance 属性页,并包含了所有引用了 TForm 类的单元。在本例中可以看到 forms.pas 和 Unit1.pas,其中 forms.pas 包含了 TForm 类的实现,而 Unit1.pas 中包含了一个对 TForm 的引用。 第1章 图 1.4 走进 Delphi 4 按 Alt+S,Y 键打开如图 1.5 所示的 Symbol explorer, 它提供关于输入的符号属性的详细信息 图 1.5 输入变量名,您只能得到引用该变量的单元的名字 如果您键入对象的名字,Symbol Explorer 将只显示一个列表,其中包含了引用该对象的单元的名字。 1.1.3 探索 Delphi Delphi 是一种基于 Object Pascal 的快速应用程序开发工具,Object Pascal 是全功能的面向对象语言, 这意味着:其功能主要集中在类的定义方面,这些类映射到描述问题域行为和数据的实体。而属性可以是 数据或过程。 随之而来,在 Delphi 中必须有管理对象信息的工具。有几种这样的工具。很明显的一种是代码。对于 过渡型的程序员,不那么清楚的一对工具是 Object Inspector 和 Object TreeView。本节将示范如何使用 Object Inspector 和 Object TreeView,以便在设计时管理对象。 Delphi 还有其他一些有趣的特性,可有助于管理任务列表、调试任务,以及窗体的可视化布局。除了 Object Inspector 和 Object TreeView 之外,本节还将示范使用 To-Do List、特定的调试视图和 Alignment Palette。 Object Inspector 如图 1.6 所示的 Object Inspector 是一个数据访问对话框,可用于在设计时定义对象数据的默认值。如 图所示,当前对象和类分别是 Form1 和 TForm1。如果对列出的值进行修改,就改变了 Form1 的初始值。 第1章 图 1.6 走进 Delphi 5 在设计时从 Object Inspector 顶部的下拉 列表中选定对象,即可改变其初始值 在 Properties 属性页中,包含了设计时 Form1 对象所有可改变的数据。Events 属性页则包含了对 Windows 消息作出反应的一些特别的特性。事件基本上是对鼠标单击之类的 Windows 消息作出响应的子程 序。单击 Event 属性页,并在右栏中双击,Delphi 会创建 Shell 函数,响应与该事件相关的消息。例如,单 击 Event 属性页,在与 OnClick 事件相邻的右栏中双击,Delphi 会创建如图 1.7 所示的空过程。 图 1.7 双击一个事件特性将在单元中创建一个空过程,该空过程包含了双击的 事件特性所对应对象的引用。在图中 Form1 对象的 OnClick 事件被双击 通过向该过程添加代码,您可以对该事件作出特定的响应。在例子中,单击窗体将运行 OnClick 事件 相应的代码。请动手试一下这个例子。 1. 创建一个新的工程。 2. 如果 Object Inspector 没有打开,请按 F11 键打开它。 3. 在 Object Inspector 中,如果 Form1 没有被选定,请从下拉列表框中选定它。 4. 单击 Events 属性页。 5. 找到 OnCreate 事件(请注意事件是按照字母顺序排列的) 。 6. 在 OnCreate 事件的右侧双击。 7. 单元中添加窗体的 OnCreate 事件的 Shell 过程。将下面一行代码添加到 begin 与 end 之间的光标闪 烁处。 ShowMessage('Hello World!'); 8. 按 F9 键运行程序。将显示如图 1.8 所示的对话框。 图 1.8 前面的列表中所描述的例子程序的输出 注意:如果偶尔对某个事件创建了事件过程,而又并不真正需要它,只要不向 begin 和 end 关键字之间添加任何代码即可;下次保存工程或该文件时,Delphi 将清除空的事件过程。 虽然可以手工编码事件过程,但上面的方法是向对象添加事件过程的最简单的途径。当向非可视化控 件添加事件过程时,您需要创建事件过程并将其与事件的数据和代码联系起来。第 6 章将示范该技术。 第1章 走进 Delphi 6 最后要注意:Object Inspector 不允许对特性或事件赋予错误类型的初值。在 Object Inspector 中,向数 据赋值的代码与运行时所用的代码是相同的,因此其行为也是类似的。一般来说,如果对象的属性的赋值 不合适,将会显示如图 1.9 所示的异常。无论在设计时或运行时出现无效赋值,都会发生异常。 图 1.9 如果向对象的属性输入无效值,Object Inspector 使用异常处理进行恢复 Object Treeview Delphi 是面向对象的开发环境,因此用户可以从管理对象间相互关系的机制中受益。一种机制便是 Object Treeview。按 Alt+V,j 键可以打开 Object Treeview。Object Treeview 以层次化的方式在对象中进行 定位。Object Inspector 只是按字母顺序列出所有的对象,而 Object Treeview 则按照对象之间关系的顺序将 其列出。 例如当一个 OK 按钮位于窗体之上时,从语义上可称窗体拥有该按钮。当在 Object Treeview 中单击某 对象时,则该对象将成为 Object Inspector 中的当前对象。以这种方式,Object Treeview 可帮助您很快的找 到并管理特定对象的属性。 To-Do List Delphi 5 中添加了 To-Do List 特性。To-Do List 与 Post-It® 记事本功能相仿。当处于任务当中或结束当 天工作时,如果需要保存退出时的信息,可使用 To-Do List 在工作进度上附加注记。 To-Do List 是可定制的。要添加 To-Do 项,可以右击窗体或单元,并单击 Add To-Do Item 菜单即可。 图 1.10 显示了 Add To-Do Item 对话框。键入文本(Text)提示信息,添加有意义的优先(Priority)值,输 入或从下拉列表框中选择所有者(Owner)的名字,并输入类别(Category)。由于 To-Do List 是可定制的, 因此通过试验,您可以根据自己的需要添加或删除一些栏,对提示信息进行优化。 图 1.10 第 1 章的 To-Do 提示信息 按 Alt+V,L 键,即可看到 To-Do List。双击任一特定的 To-Do 项,代码编辑器将把光标定位到该 To-Do 项输入时的代码位置。单击 To-Do 项旁边的复选框,将会更新代码中的文字注释,表示该 To-Do 项已完成。 第1章 走进 Delphi 7 调试视图 有许多种调试视图,在调试应用程序时它们可提供大量的细节信息。从图 1.11 可看出,调试时可访问 断点、调用栈、变量查看(Watches)、局部变量、线程、模块、事件日志、CPU 和 FPU 等。 图 1.11 使用 View,Debug Windows 对话框,从各 种不同的角度获取应用程序的特定信息 断点(Breakpoints)表示代码中的停止点。当调试器遇到断点时,将在该位置停止执行。调用栈(Call stack)按照调用的逆序显示所有处于活动中的过程,单击调用栈列表中的任一项均可回溯到对应的代码。 双击调用栈中的某一项,代码编辑器将定位到执行发生转移的那一行代码。双击调用栈对话框中的特定过 程名,即可转到对应的特定过程。变量查看(Watches)窗口中有一些变量,当程序以调试模式运行时,可 观察这些变量的值。您既可以观察简单的数据变量,也可以观察对象变量。局部变量(Local Variables)对 话框中显示了对应于当前过程的变量的名字和值。 线程(Threads)视图中可以看到应用程序中所有线程的状态。只有存在额外的线程实例,才能够使用 该视图。模块(Modules)视图可检查所有的动态链接库以及 API 过程的入口点,还能定位组成应用程序 的 Delphi 模块的源代码。按 Alt+V,D,E 键可显示事件日志(Event Log)视图,它可以提供与 Windows NT 事件日志类似的有用信息。 注意:在 Delphi 中,通过继承定义在 classes 单元中的 TThread 类,可以相对容易地创建 多线程应用程序。 最后,CPU 与 FPU 视图显示了中央处理单元和数学协处理单元的状态,包括汇编语言指令、寄存器 和标志状态,以及内存中的原始数据。使用本节提到的调试视图,通过一番练习,您很快就可以回溯到代 码并找到错误的原因。 Alignment Palette 的使用 Alignment Palette 是 View 菜单中的一项(见图 1.12)。它设计用来沿着某一方向的边界,对所有的可 视化控件进行对齐。假定有如图 1.13 所示的标签和编辑域。按下列步骤可对二者按其垂直中心进行对齐: 图 1.12 使用 Alignment Palette 能够可视化地组织控件,直至合适为止 第1章 图 1.13 走进 Delphi 8 要选定多个控件,单击并拖动出一个矩形包围所有的控件即可 1.在最左上方控件的左上方选择一点进行单击,拖动鼠标至最右下方控件的右下方(参照图 1.13)。 这样即可同时选择多个控件。 2.在选择了需要对齐的所有控件之后,按 Alt+V,A 键打开 Alignment Palette,如图 1.12 所示(如果 漏掉了某个控件,可在按住 Shift 键的同时单独单击该组件,即可将其加入到已选定的组中)。 3.单击 Alignment Palette 中的 Align Vertical Centers 按钮(如图 1.14 所示) ,即可按照垂直方向的中 心在水平方向对齐 Label1 和 Edit1 控件。 图 1.14 Alignment Palette 的 Align Vertical Centers 按钮将把控件按同一 水平轴线对齐,这对于对齐如图的 label 和 edit 域是很合适的 关于控件的对齐,大体上就这些知识。当您打算完善图形用户界面时,通过使用 Alignment Palette 提 供的各种对齐方式,很快就可以安排好控件的位置。 1.1.4 运行程序 Delphi 的集成开发环境使得以步进方式运行应用程序非常容易,从而简化了开发者的单元级测试。 运行您的应用程序 按 F9 键可运行应用程序。在运行模式下,只有设置了断点或者从 Run 菜单中选择了 Program Pause, 才会使程序停下来并交出控制权。断点可用 F5 键设置或取消。如果您需要步进运行程序,可按 F8 键或 Alt+R,S 键。步进会按步执行每条语句,但并不跟踪到过程或函数的内部。如果您要进入过程内部,请按 F7 键或使用集成调试器的 Run,Trace Into 特性。 在 Run 菜单上,还有其他几个有用的集成调试菜单项,包括 Trace to Next Source Line,Run to Cursor, Run Until Return,以及 Show Execution Point。如果已经进入到 Windows API 内部,并且希望调试器在返回 到您的源代码(即可读的代码,而不是汇编码)时暂停,在这种情况下 Trace to Next Source Line 很有用。 Run to Cursor 则在光标所在之处隐含地设置了一个软件断点。Run Until Return 则运行当前程序直到从当前 过程中返回。Run 菜单中的 Show Execution Point 菜单项可定位下一行将执行的代码。当您在阅读帮助文件 信息时迷失了方向,或者步进执行程序时由于其他代码使得当前执行点在屏幕上不可见时,该特性可能会 很有用处。 使用 Code Watch Run 菜单也包含另外几个菜单项,可以观察整数这样的简单数据和对象这样的复杂数据的值。Run, Inspect 菜单项可以打开 Inspect 对话框,如图 1.15 所示。输入对象的名字,例如 Application 对象,即可在 Debug Inspector 窗口中得到该对象的详细视图(如图 1.16 所示)。Debug Inspector 包含了有关对象的数据、 方法、属性的信息,所有这些信息对于程序的正确性和健壮性都是必需的。 第1章 图 1.15 走进 Delphi 9 在 Inspect 对话框中输入变量或对象名,观察关于该对象状态的详细信息 Debug Inspector 的实际外观是可以改变的,它随着所观察数据的种类而变化。如果观察整数这样的简 单类型,只能看到 Data 属性页。从图 1.16 可看出,如果观察对象,将看到图中所示的三个属性页。 图 1.16 Debug Inspector 包含了详细的数据、方法、特性 信息。图中显示了 Application 对象的特性的信息 Debug Inspector 可以修改数据的值、显示完整的名字、观察嵌套的值和对被观察的值进行类型转换等。 观察嵌套的值可以深入到对象内部,观察其底层的值。在 Delphi 中,当对象声明为超类,但其实例的实际 值是子类时,对观察到的值进行类型转换是很有用的。 举与之相类似的例子,汽车代理商可能有许多汽车;而当您买汽车时,您说的是特定的厂家和型号术 语。这样,汽车代理商可以把汽车当作超类,而把 Jeep Grand Cherokee 作为一种汽车,即一个子类。对特 定的讨论来说,您所需的细节的层次依赖于您把代理看作是汽车的集合还是特定的汽车。请思考: “您从事何种工作?” “我卖汽车。 ” 在上面的对话中,一般的汽车是可接受的。现在考虑下面的对话: “您有 2000 BMW M5 SUV 吗?” “没有。” 在第二段对话中,使用一种特定类型的汽车 BMW,即一种子类将较为合适。在代码中模拟现实世界 的概念时,进行不同层次的抽象是恰当的,因为在现实中这也是必要的。在数字世界中可能有汽车类型的 变量,例如待售的汽车的列表,但列表中每个单独的元素都是一种特定类型的汽车。在这个例子中,Debug Inspector 中的类型转换会比较有用。 计算并修改数据 在 Delphi 的全部工具中,Run,Evaluate Modify 菜单项是最为有用的调试工具之一。Evaluate Modify 菜单使得可以在运行中观察并改变一个值。举例来说,如果怀疑代码中的错误与某个变量的实际状态有关, 您可以观察它并验证假定是否是正确的。无须停止调试过程和修改代码,只要在 New Value 域(见图 1.17 第1章 走进 Delphi 10 所示的 Evaluate / Modify 对话框)中输入新值,并单击 Modify 按钮即可。然后可继续运行代码。使用该技 巧,可以轻松地验证对代码的修改是否可解决数据方面的问题。一旦发现某个数据范围可使程序正确运行, 即可修改代码以使之全速运行。 图 1.17 Evaluate / Modify 对话框用于运行时察看数据并立即 做出修改。单击 Watch 或 Inspect 按钮来更深入的查看 添加 Watch 可以从 Run 菜单添加 Watch,从上一节提到的 Evaluate / Modify 对话框中进行添加亦可。添加 Watch 将打开 Watch List 对话框(见图 1.18) ,当程序向前运行时,可以观察到数据值的改变。 图 1.18 使用 Watch List 对话框来观察数据值的改变 按 Ctrl+E 键可显示 Edit Watch 对话框(见图 1.19) ,即可编辑观察到的值。可以使该观察项有效或无 效,或改变数据类型,而类型将决定数据的表示方式。一般来说,显示数据的默认表示方式是最合适的。 图 1.19 Edit Watch 对话框用于编辑观察项的激活 第1章 走进 Delphi 11 状态,或以另一种选择的方式表示数据 Run 菜单的最后一项是 Add Breakpoints 子菜单。由于有许多信息与如何有效地使用断点有关,因此为 断点单独增加了一个子菜单,更多的信息可以参考 1.7 节“调试程序”。在编写过一些代码之后,我们接触 断点的机会就会更多一些了。 1.1.5 配置工作环境 Delphi 对工作环境提供了广泛的控制能力。您可以修改编辑器和调试选项,亦可修改一般的 Delphi 环境选项。如果没有特别的原因,是无须改变环境设置的。在默认配置下,Delphi 工作得很好。 当需要改变配置时,以下这些例子可能会比较有用:改变代码编辑的制表位距离,向已有的宏列表中 添加版权标记,使 Compiler Progress 对话框有效,修改栅格大小(窗体上可见的那些点)使控件间的距离 更加合适。当向窗体上放置控件时,栅格大小会影响控件默认放置的位置。 下面列出了一系列的步骤,按前面段落中提到的方式,演示了如何修改开发环境。 按下列步骤可改变制表位距离为两个空格: 1.按 Alt+T 键显示 Tools 菜单。 2.从 Tools 菜单选择 Editor Options,将显示 Editor Properties 对话框。 3.在 Editor Properties 对话框的 General 属性页上,接近底部是 Tab Stops 域。输入您所喜欢的制表位 距离,每两个制表位距离间插入一个空格,例如输入 2 4 6 8 10 后,当每次按 Tab 键时将移动两个 空格。 按下列步骤可向 Code Insight 加入版权提示: 1.按前面例子步骤 1 和 2,打开 Editor Properties 对话框。 2.单击 Code Insight 属性页。 3.在 Code Template 组中单击 Add 按钮。 4.在 Add Code Template 对话框中,向 Shortcut Name 域键入 copyright、向 Description 域中键入 My Copyright Stamp。单击 OK 按钮。 5.在 Templates 列表中,选定 copyright 项。 6.在 Code Edit 域中,键入需要使用的版权信息(例子见图 1.20)。 图 1.20 Delphi 提供的 Code Insight 工具可用于自动输入递归数据, 或作为学习目的输入一些通用类型的短语,例如数组声明 第1章 走进 Delphi 12 提示:如果希望光标出现于 Code Insight 宏的某个位置,请在该位置键入|(管道符)。 要使用 Code Insight,将光标置于代码模块中您希望 Code Insight 宏运行之处。例如,要把 版权声明放在每个单元的顶部,先将光标置于单元顶部,按 Ctrl+J 键以显示 Code Insight 宏的列表,从列 表中选择 copyright,并且按 Enter 键(Code Insight 宏列表请参见图 1.21)。 图 1.21 按 Ctrl+J 键显示 Code Insight 宏的列表 当编译或建立应用程序时,Compiler Progress 对话框将显示编译的进度。执行下列步骤,可以启用 Compiler Progress 对话框: 1.按 Alt+T,E 键,显示 Environment Options 对话框。 2.单击 Preferences 属性页(如果尚未选定该属性页)。 3.在 Compiling and running 组中,单击 Show compiler progress。单击 OK 按钮。 现在当您编译应用程序时,即可看到运行中的本地代码编译器(对于从未见过 Delphi 执行本地代码编 译的人来说,该过程的速度令人吃惊)。 执行下列步骤,即可改变每个窗体上的栅格点之间的距离: 1.重复上例中的步骤 1 和 2。 2.在 Preferences 属性页的 Form designer 组中,把 Grid size X 和 Grid size Y 修改到两个像素大小。单 击 OK 按钮。 把栅格距离修改到两个像素,可更好地控制控件的位置。 1.1.6 使用上下文菜单 如果 Delphi 中所有的菜单项都放在菜单栏上,这可能会让人迷惑。那样就必须对它们中的许多菜单项 调出或调入,因为它们只在特定的环境中才是必要的或有用的;由于菜单项很多,搜索冗长的列表可能会 很困难。Delphi 是成熟的产品,而 Inprise 在设计集成开发环境方面是专家,记住这一点是很重要的。出于 看上去舒服的原因,有些东西藏到了不显眼之处。 当您的注意力集中于开发的某一方面时,很可能会注意到开发环境的相关方面。Delphi 中的大部分区 域都有相关联的菜单,只要把鼠标指针置于该区域并单击右键即可访问。通常,这些快捷菜单上是一些最 有用的任务。 注意:由于 Delphi 是用其自身的语言——Object Pascal 所开发的,因此凡是 Delphi 所提 供的功能,均可添加到您的应用程序中。这样,您在 Delphi 中发现的特征都可以添加到您 的应用程序中。快捷菜单就是一个例子。 代码编辑器上下文菜单功能的探索 在单元上单击鼠标右键,可找到一些有用的快捷菜单。该快捷菜单在帮助文档中称为代码编辑器上下 文菜单(Code Editor Context Menu) 。当鼠标指针位于源代码单元时,单击鼠标右键即可打开该菜单。任何 单元都是可以的。本节中,我们将仔细察看几个较为有用的代码编辑器上下文菜单项。您也可以详细探究 一下所有的操作,或用关键字 code editor,context menu 检索帮助文档。 查找声明 第1章 走进 Delphi 13 当鼠标位于符号上时打开代码编辑器上下文菜单,菜单会显示 Find Declaration 菜单项。单击该菜单项, Delphi 将打开引入该符号的源代码文件。例如,如果创建了一个新工程并在 TForm 符号上单击鼠标右键, Delphi 将打开 forms.pas 单元并把光标置于声明 TForm 类的那一行代码上 (对于 Iliad——即 Delphi 6 Build 2.6, forms.pas 文件,打开后光标置于 672 行) 。 这对于查找关于某个类或单元的详细信息是一项有用的技巧,其中还包括了支持实现的源代码。 打开光标处的文件 大部分应用程序都由许多文件组成的。过一段时间,很容易忘记代码的位置甚至是文件的位置,但 Delphi 是不会忘记的。无须到源代码中查找某个特定的单元,单击 uses 声明中的单元名,打开 Code Editor Context 菜单,单击 Open File at Cursor 或使用快捷键 Ctrl+Enter(关于 uses 子句,参见 1.4 节“源代码文件 的组织”)。 调试菜单的快捷方式 在代码编辑器上下文菜单中还有调试菜单项。其中许多与 Run 菜单中的调试菜单项相似。但 Goto Address 项在主菜单上并未发现,而 Add Watch at Cursor 比 Add Watch 更加直接。打开 Code Editor Context 菜单,并按 Goto Address 的 D,G 键,将显示一个对话框。在 Enter Address to Position 对话框中输入内存地 址,Delphi 将对该内存地址打开 CPU 窗口。 打开 Code Editor Context 菜单,并按 D,W 键,将对当前光标位置添加一个观察项。Delphi 将自动取得 当前位置的符号或选定的文字,向 Watch 列表中添加一项并打开 Watch 列表。 1.2 创建应用程序 在对 C 语言及其发明者 Brain Kernighan 和 Dennis Ritchie 的赞美声中,几乎每本书都把 Hello World! 作为第一个程序引入。出于趣味的原因,本节中的例子程序比传统意义上的第一个程序要复杂一些。本节 将示范创建基于窗体的应用程序的基本步骤,和如何保存已有的工作、编译及运行应用程序。 注意:把一些控件放到窗体上、添加代码、运行程序,这只是黑客风格的编程方法。虽然许 多应用程序是这样开发的,但对于开发健壮而具有可扩展性的产品,这种途径并非是个好办 法。虽然最终都需要绘制出窗体,但独立地设计解决问题的类并使之与图形用户界面分隔开 来将产生更好的最终产品。 1.2.1 创建一个程序 计算机程序可能简单,也可能复杂。其区别一般在于实用。一个非常简单的程序可能对于学习目的非 常有用,但没有其他用处;而一个设计良好的复杂程序可能会解决一整个业务上的问题。在本例中我们的 目的是教育和示范,因此程序很简单。 像 Visual Basic 和类似的程序一样,Delphi 也是个快速应用程序开发(RAD,Rapid Application Development)工具。快速应用程序开发正在变成黑客风格编程的同义词。开发者通过 RAD 工具可快速创 建可视化产品。问题在于可视化的外观经常会与其实质相混淆,而反过来则是不对的,即实际物质无须外 观即可被识别。对于我们的目的,黑客风格的编程是不允许的。 按下列步骤,可创建增强版本的 Hello World 程序: 1.启动 Delphi 6。 2.屏幕上将显示空白窗体,请确认窗体位于前台。沿工具栏顶部可以看到几个属性页。其中 Standard 属性页被选定(如果不是这样,请选定它使之成为当前属性页)。 注意:这一组属性页称为组件面板,它包含了许多 Delphi 开发者可用的可视化和非可视化 组件的可视化外观(更多信息,请参照 Delphi 帮助文件中的 An Introduction to the Visual Component Library 一节)。 3.双击 Standard 面板的 RadioGroup 图标。它是右起第三个组件。这将放置一个 RadioGroup 在前台 第1章 走进 Delphi 14 的窗体上。 4.按 F11 键打开 Object Inspector。 5.在 Object Inspector 顶部的对象选择器中,选定 RadioGroup1。 6.请记着 Object Inspector 中的属性是按字母顺序排列的,向下滚动直至找到 Items 特性。双击 Items 特性打开 String List Editor(见图 1.22) 。 图 1.22 Object Inspector 中的 RadioGroup1 对象 的 Items 特性的 String List Editor 视图 7.在 String List Editor 中键入 Geek,PC Gamer,English 和 German,各占一行。按 Enter 键来分隔各项。 8.在 Object Inspector 中,对 Caption 特性键入 Customary Greeting。 9.将 RadioGroup1 组件的 ItemIndex 特性改变为 0。ItemIndex 属性表示选定的项,0 表示第一项,1 表示第二项,依此类推。 10.单击 Object Inspector 中的 Events 属性页。确认在对象选择器中已选定 RadioGroup1 对象,双击相 邻的 OnClick 事件的右侧栏以生成事件过程。 11.在代码编辑器中,将下列代码输入到 begin 和 end 两行之间。 Case RadioGroup1.ItemIndex of 0: ShowMessage('Hello World!'); 1: ShowMessage('Welcome to Valhalla Tower Material Defender!'); 2: ShowMessage('Hi.'); 3: ShowMessage('Guten Abend.'); else ShowMessage('item not found'); end; 提示:按 Tab 键并输入特性名或事件名,可在 Object Inspector 中快速移动到特性或事件。 Delphi 将执行对特性的搜索。 1.2.2 保存您的工作 要按照自然的节奏来保存您的工作。经常保存是个好的经验法则。对我们的例子来说,我们需要保存 所有的文件并给工程命名,在编译时它将变成为可执行文件的名字。按键 Shift+Ctrl+S 键可保存进行中的 工作,而按 Alt+F,v 键将保存所有的文件。把源代码单元命名为 main,这将分别把源代码和窗体文件保存 为 main.pas 和 main.dfm。将工程命名为 Hello World。 提示:除了那些最简单的应用程序以外,请使用版本控制程序。即使对于本书中的简单例子, 我也使用了 SourceSafe,因为例子对本书是很重要的。有许多版本控制程序,包括: PVCS,SourceSafe,Harvest,StarTeam 及 ClearCase 等。 下一步是测试您的应用程序。在此之前需要编译并运行它。 第1章 1.2.3 走进 Delphi 15 编译并运行程序 有两种方法来建立可执行文件。一种是按 Alt+P,B 键来运行 Project,Build 菜单项,另一种是按 F9 键, Run,Run 命令。Delphi 是一个编译环境,程序必须先编译,然后才能运行。 注意:如果看一下 Windows 资源管理器,可以注意到这个简单的应用程序在编译后大约为 330k。原因是在很基本的应用程序中也存在这一些大而复杂的对象,包括窗体和应用程序类 等。但经过这一次跳跃式的上升之后,当加入更多功能时,Delphi 应用程序的大小增长极为 缓慢。 按 F9 键可以运行示例程序。单击所有的单选按钮以确保从每一个都得到正确的问候。1.2 节“创建应 用程序”中的三个小节描述了一系列的任务——也是一个很规则的循环,随着应用程序的复杂性的增长, 您可以一遍又一遍地使用该循环来测试应用程序中的模块。 1.3 理解 Delphi 的设计——以工程为中心 Delphi 是以工程为中心的开发产品。这意味着每个应用程序都是一个工程,由一个或多个文件以及工 程文件组成。组成工程的几种文件包括:源代码、窗体、编译过的单元、配置、选项、包以及备份文件。 本节中,我们将仔细察看工程中的各种不同文件及其使用情况。 1.3.1 工程文件 工程文件具有.dpr 扩展名,在本质上它包含了应用程序的入口点,位于 begin 和 end 之间。前一节的 程序的工程文件如下所示: HelloWorld.dpr program HelloWorld; uses Forms, umain in 'umain.pas' {Form1}; {$R *.RES} begin Application.Initialize; Application.CreateForm(TForm1, Form1); Application.Run; end. 从 Project 菜单选择 View Source 可以看到工程源代码。上面列出的代码都是由 Delphi 自动添加的。 program 语句指出了可执行文件的名字。uses 子句后部是逗号分隔的列表,包含了所有显式包括在工程中 的文件。$R 语句是编译器指令。编译器指令{$R *.RES}指示 Delphi 在与工程同名、扩展名为.RES 的文 件中,查找 Windows 资源信息。begin 与 end.对与 C 中的 main()子程序、Visual Basic 的起始过程等价。典 型的 Delphi 应用程序以 Application.Initialize 开始,以 Application.Run 结束。 修改.dpr 文件中的代码是可以的,但通常这并不必要。除非确实有修改它的原因,否则最好还是让 Delphi 来管理工程源文件。 1.3.2 源代码文件 Pascal 源 wadc 文件具有.pas 扩展名。通常每个工程至少有一个单元。单元供编写代码之用。如果创建 了窗体或数据模块,将会得到.dfm 和.pas 两个文件。1.2.1 节“创建一个程序”示范了如何在单元中编写代 码。 第1章 1.3.3 走进 Delphi 16 窗体与数据模块 窗体与数据模块具有.dfm 扩展名,并且与.pas 文件相关联。代码实际写在具有.pas 扩展名的源代码单 元中。DFM 文件原来是二进制文件,但在 Delphi 5 之后已成为脚本化的文本文件,其中定义了一些资源, 使得窗体和数据模块能够存储对象的可视化外观。窗体是 TForm 的子类,数据模块是 TDataModule 的子类, 二者都定义在 forms.pas 单元中。 如果想看一看窗体的持久化脚本,在 Hello World 应用程序中先把窗体置于前台,在窗体上单击鼠标 右键,显示窗体上下文菜单,并选择 View as Text。main.dfm 的资源脚本列出如下。 main.dfm object Form1: TForm1 Left = 244 Top = 138 Width = 783 Height = 540 Caption = 'Form1' Color = clBtnFace Font.Charset = DEFAULT_CHARSET Font.Color = clWindowText Font.Height = -13 Font.Name = 'MS Sans Serif' Font.Style = [] OldCreateOrder = False PixelsPerInch = 120 TextHeight = 16 object RadioGroup1: TRadioGroup Left = 296 Top = 208 Width = 201 Height = 193 Caption = 'Customary Greetings' ItemIndex = 0 Items.Strings = ( 'Geek' 'PC Gamer' 'English' 'German') TabOrder = 0 OnClick = RadioGroup1Click end end 在上面列出的文本形式的 main.dfm 结尾处,可以看到在例子程序中输入的 Item.Strings 数据。这就 是.dfm 文件,它存储了一些描述窗体文件和数据模块的数据。 1.3.4 配置与选项文件 当您改动 Project,Options 设置时,Delphi 把改动存储在一个具有.dof 扩展名的文件中。当所做的改动 影响到了应用程序的编译方式时,这些改动将以文本形式存储到.cfg 文件中或配置文件中。 实际上分别生成了几个文件来存储配置、选项、To-Do 和其他类型的信息。如果在 Help Topics 对话框 的 Find 属性页中查找 Generated files,即可得到关于存储工程信息的所有生成文件的信息。大部分情况下, Delphi 自动管理这些文件。请勿删除它们。 无须记住所有的文件及其作用,只需记住一条规则:只能删除扩展名中带有~(波浪线)的文件、.dcu 第1章 走进 Delphi 17 文件或者不需要的文件。如果使用了 SourceSafe 这样的版本控制产品,即使误删了需要的文件,也可从 SourceSafe 中将其恢复。 1.3.5 中间编译单元 编译过的单元是不可执行的,以.dcu 为扩展名。在建立应用程序的链接阶段,所有的.dcu 文件链接起 来成为可执行程序。这些文件是可以删除的,但最好还是让 Delphi 来管理这些文件。 注意:如果只分发 DCU 文件,编译过的代码在 Delphi 将来发布的版本中可能是无效的。如 果不升级,程序员就无法继续使用这些 DCU 文件。如果您出售的是工具软件,可以考虑公开 源代码并在许可协议中规定可接受的使用方式。 当您建立应用程序时,Delphi 会把源代码文件与编译过的单元进行比较。如果源代码没有被修改过, Delphi 就不需要重新编译源文件。如果希望其他的开发者使用您的产品来建立应用程序,您可以只发布.dcu 文件,而不发布源文件。以这种方式,其他的开发者可以使用您的代码,而无须确切知道代码是如何编写 的。发布.dcu 文件确实是一种方法,这样您可以传播私有代码而无须将其公开。 1.3.6 备份文件 无论何时,只要您改动了一个文件并且进行了保存,Delphi 都会对该文件已存在的版本进行备份。这 样,您就拥有该文件的最新版本以及一个稍早一点的版本。对备份文件的命名惯例是:它的名字与原文件 几乎相同,但在‘.’与扩展名的第一个字母之间插入了一个~(波浪线)。例如,main.pas 就变成了 main.~pas。 由于备份文件不会以其他方式修改,因此要恢复备份,只要在资源管理器中重命名备份文件,去掉其 名字中的波浪线即可。如果您开始开发后一直使用某种版本控制产品,在更新文件的已归档版本时经常保 存,那么不会丢掉任何修改。 1.3.7 包文件 包文件是一种特殊的工程,扩展名为.dpk。包文件是一种定义组件包的工程。本书后面还会有更多的 关于组件和包文件的知识。 1.3.8 应用程序文件 您可能已经熟悉了几种基本类型的应用程序文件,Delphi 都能够创建所有种类的应用程序文件。包括 动态链接库(.dll)、可执行文件(.exe),ActiveX 控件(.ocx,因为以前 ActiveX 支持的是 OLE 命名规范)。 其中每一种文件都代表一种终端产品,又经过编译和链接的代码组成。可执行应用程序是单独运行的 程序或进程外服务器。动态链接库代表资源文件或进程内服务器,而 ActiveX 控件是用于建立其他程序的 支持性控件。 1.4 源代码文件的组织 经常使用的最重要的文件是源代码文件,称为单元。没有了单元,窗体文件只是一种画出图像的复杂 方法。理解单元的各种不同方面是很重要的。这将有助于您理解在何处写代码、为什么写这些代码。参照 下面的代码,我们将继续对单元的讨论。 Unit1.pas unit Unit1; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs; type TForm1 = class(TForm) private { Private declarations } 第1章 走进 Delphi 18 public { Public declarations } end; var Form1: TForm1; implementation {$R *.DFM} initialization // user must add finalization end. 在本节的其余部分,您将看到示范单元的各个部分的代码片段。如果您想知道在单元的整体中这些片 段的位置,请参照前面的空单元的完整列表。该单元是一个有相关窗体的单元。请记住,单元不必有相关 的窗体,反之则不然。 1.4.1 单元的各个部分 单元包括单元名和接口部分,接口部分中包含了类型声明、变量声明,如果需要还会有常数。单元的 下半部分,即 implementation 之后,是实现部分,该部分可包含类型声明、变量声明、常数和过程。通常 在实现部分会看到代码。 如果单元与数据模块或窗体关联,紧接关键字 implementation 之后是$R 编译器指令,用于查找资源。 关键字 end.标志了文件的结尾。可以选择加入关键字 initialization 和 finalization,但它们不会被自动添加。 initialization 代码在单元中的所有其他代码运行前运行,finalization 代码在单元中所有其他代码运行后运行。 单元装载到内存后,马上运行 initialization 部分,而 finalization 代码刚好在单元被卸载之前运行。 对 VCL 源代码进行查找,只发现了大约 100 个 initialization。它是一种强大的工具,您很少需要用到 它,但如果您看过 Delphi 的源代码如 forms.pas 和 classes.pas,就能够找到一些使用它的方法。 1.4.2 Unit 语句 Unit 语句中包含了文件的名字。除了 Windows 文件系统存储文件时需要文件名以外,还可以把单元名 作为定义名字空间的机制。例如,有一个单元名为 math,其中有个过程名为 Multiply,另一个单元名为 Tribbles,其中也有一个名为 Multiply 的过程;为区分这两个过程,在调用 Multiply 时,可以把去掉扩展名 的单元名作为前缀。 这 样 , 在 解 析 Math.Multiply 调 用 时 , 编 译 器 将 找 到 math 单 元 中 的 Multiply; 而 另 一 个 调 用 Tribbles.Multiply 将对应到 Tribbles 单元中的过程。unit 语句的形式是 unit 文件名;其中文件名是在保存文 件时由 Delphi 管理的,不包括.pas 扩展名。 1.4.3 接口部分 可以认为单元分为两部分。上半部为接口部分,起始于包含关键字 interface 的那一行,结束于关键字 implementation 之前,其余为第二部分。在最简单的意义上,这两部分的作用是互补的。上半部,或接口 部分,描述了应用程序的其余部分在该单元中可以访问哪些东西。下半部,即实现部分,通常是编写运行 代码之处(对于实现部分的更多细节,请参见 1.4.4 节“实现部分”)。 最重要的是要记住:接口部分没有运行代码,但包含了其他单元可以访问的类型、常数和变量等。它 也描述了该单元中可调用的过程和可使用的数据。 1.4.4 实现部分 实现部分是编写运行代码之处。也可以包含类型、变量和常数。定义于接口部分的变量、类型和常量 可以在单元外使用,与此相反,在实现部分定义的只能在单元内部使用。 注意:过程或函数的声明是不包含代码体的语句,即不包含 begin 和 end 语句及二者之间的 代码。过程性声明放在接口部分。 注意:过程或函数的定义包含声明部分和函数体,即实际的代码。过程性的定义放在实现部 第1章 走进 Delphi 19 分。 另外,定义在实现部分的过程和函数,如果在接口部分没有相应的声明,则只能在单元内部使用。如 果希望其他单元可以访问过程和函数,则要将其声明放在接口部分而将定义放在实现部分。 1.4.5 定义 Uses 子句 Uses 子句指示编译器添加在列出的各个单元中找到的代码。可将该语句在接口部分和实现部分各放一 个。如果用到 Uses 子句,它将紧跟在 interface 和 implementation 关键字之后。 警告:如果 Unit1 需要 Unit2 的代码,Unit2 也需要 Unit1 的代码,则在 Unit1 和 Unit2 的 Uses 子句中将相互列出对方。但 Unit1 和 Unit2 不能在同一部分相互引用。例如,在接口部 分 Unit1 可引用 Unit2,Unit2 也可引用 Unit1;但 Unit1 和 Unit2 不能在接口部分相互引用, 这是个循环引用错误。而在实现部分两个单元可以相互引用。 注意:所有的单元都隐式引用了 system.pas 单元。system.pas 单元无法显式引用。 如果改变了 Unit1 接口部分的 Uses 子句,而 Unit2 引用了 Unit1,那么 Unit1 和 Unit2 可能会 被重新编译。如果 Unit1 在接口部分引用了 Unit2,而改变了 Unit2 实现部分的 Uses 子句,则 Unit2 必须重 新编译,而 Unit1 则无需如此。 1.4.6 Type 子句 接口部分和实现部分都可能有 Type 子句。按惯例,大多数 Type 子句都位于接口部分。类型声明可以 定义集合、数组、记录和类等。在关键字 type 之后可引入新的类型。下面列出的代码示范了类型定义的例 子。 type TForm1 = class(TForm) private { Private declarations } public { Public declarations } end; 提示:在单元与窗体关联的情况下,可能只有代表窗体的一个类型定义。有一条规则:每个 单元中只能有一个窗体定义。但即使是与窗体相关联的单元,每个单元中也可以定义多于一 个的类。看一看 VCL 的源代码,可以注意到在许多情况下,Delphi 开发者都在一个单元中定 义了多个类。 上面的代码片段由本节开始处列出的代码中撷取而来,包含了 TForm1 类的类型定义。关于上面列出 的类定义的各个方面,更多的信息请参照第 2 章“学好面向对象的 Pascal”。 1.4.7 变量部分 接口部分和实现部分均可包含 Var 子句。当要定义其他单元可访问的变量时,请把这些变量放在接口 的变量部分,然后在使用变量的单元的 Uses 子句中包含该单元即可。接口部分定义的变量可认为是全局 变量,请谨慎使用。由于无法确保全局变量不被其他的程序员误用,所以加入这段说明以防止误解。 实现部分定义的变量只能在所定义的单元内访问。他们被称为本地变量,在单元内可随意访问,但不 能被使用该单元代码的其他单元所引用。这样,只有包含了该变量定义的单元的作者才有可能误用本地变 量。本地变量比全局变量优先选用,但并不理想。 1.4.8 资源声明 在本节开始列出的代码中可看到{$R *.res}编译器指令,我们提到过,该指令指示编译器包含与该单元 同名的.res 文件。$R 指令通常只出现在具有窗体的单元中,它们也可能是开发者因为某种原因添加的。 第1章 走进 Delphi 20 提示:对于运行时加载图形资源,mplayer.pas 中的 TMediaPlayer 类是个好例子。可以利用 Delphi 所带的 Image Editor 创建图像,再借用 TMediaPlayer 类中的代码向您的应用程序中 动态加载位图。 定义具有可视外观的新组件时,可加入资源指令。一个例子可在如图 1.23 的 TMediaPlayer 的代码中 发现。媒体播放器上的按钮是位图,必须存储于某处,即 Windows 资源文件中。TMediaPlayer 的源代码 是 mplayer.pas , 其中 implementation 关键字的下一行就是{$R MPLAYER}。查找 mplayer.res 文件,可 以发现它在 Lib 目录下。 图 1.23 1.4.9 TMediaPlayer 组件 Initialization 部分的使用 单元的 initialization 部分的代码将在单元中任何其他代码运行前运行。在 initialization 与 finalization 或 end 关键字之间的代码,将在单元向内存加载时运行。如果要使用全局变量或本地变量,可在 initialization 关键字后进行初始化。 1.4.10 Finalization 部分的使用 如果单元中有 initialization 部分,也可以使用 finalization 部分。可在 finalization 部分运行清除代码, 并释放在 initialization 部分分配给对象的内存。finalization 部分由关键字 finalization 开始直到关键字 end (文 件结尾)。finalization 部分与对应的 initialization 部分按相反的顺序运行。例如,Unit1,Unit2,Unit3 按序装 载入内存,则其 finalization 部分将按 Unit3,Unit2,Unit1 的顺序运行。 第1章 1.5 走进 Delphi 21 代码的原子与分子 Object Pascal 的基本组成部分与其他语言是相同的。它们由操作符、操作数和少量的关键字构成。这 些原子性的成分按 Object Pascal 语法所规定的顺序组合起来,就产生了语句。语句是可执行代码的最小片 段。如果您正由另一种语言转向 Object Pascal 或希望回顾一下,请继续阅读本节其余部分,否则可跳至下 一节。 1.5.1 操作符与操作数 在 Object Pascal, C++, Visual Basic, Java 以及大多数现代程序设计语言中,操作符与操作数的用法是类 似的。操作符是可用作表达式一部分的符号或记号。操作数是操作符执行其操作的数据。 有像 Not 操作符一样的单目操作符。Not 操作符对操作数的值取反。如果操作数是布尔值,则结果也 是布尔值。如果对整数执行了 Not 操作,则对该整数的值按位取反。单目操作符有一个操作数,位于操作 符的右边,例如 Not True。双目操作符有许多,如+ ,-,/ ,div ,= ,:=等。双目操作符有两个操作数,分别 位于其左右两边。例如,/ 代表浮点除法。无论操作数是整数还是浮点数,/ 操作的结果都是浮点数。div 操作符要求两个整数,也返回整数。 在 Object Pascal 中,= 操作符是双目操作符,它检测是否相等;:= 是赋值操作符。例如,向整数变量 A 赋值 5,应写为 A := 5。要计算同一个整数 A 是否等于 5 ,应写为 A = 5,例如: if ( A = 5 ) then … Object Pascal 具有其他语言中绝大部分的通用操作符。在帮助文档的索引中查找 Arithmetic operators, 就可找到关于算术操作符的详细信息。Delphi 还有指针操作符、位逻辑操作符、算术模操作符等。在 Object Pascal 中没有三目操作符(像 C 语言引入的三目操作符 ?= ) 。 有 9 种操作符:算术操作符、布尔操作符、逻辑操作符、字符串操作符、指针操作符、集合操作符、 关系操作符、类操作符和@操作符。当打算使用一种特定类型的操作符,而以前又没有用过时,可以查找 帮助文件获得有关的详细信息。请记住:操作符具有优先级顺序,即默认情况下操作符的计算顺序。请使 用括弧表示您所需要的操作顺序,或参照 Delphi 帮助中的操作符优先规则获取特定的信息。 1.5.2 关键字 记号是程序中有意义的最小单位。记号由空格、制表符、注释、分隔符(如分号)所分隔。因此操作 符和 Object Pascal 的关键字都是记号。关键字是一些单词,它们被保留用作 Object Pascal 语言的一部分。 在 Object Pascal 的关键字(或保留字)的列表中,包含大约 75 个记号。还有 40 个词保留作为指令,两个 词具有特殊意义。对于保留字的完整参考资料,请参考 Delphi 的帮助文档。在索引中查找 reserved words and directives,可找到关于保留字和指令的详细信息。 完整的 Object Pascal 语言由语法规则、9 种操作符、大约 120 个保留字和指令构成。关于语法规则的 参考信息,请在帮助文档中查找 grammar(Object Pascal 语法)。 1.5.3 基本数据类型 Object Pascal 包括了大多数 Windows 编程语言中的基本数据类型:integer,double,string,char 和 Boolean。这些是最常用的,各个类型还有其他特定的版本。数据类型用于声明变量。变量声明的规范形式 如下: VarDecl -> IdentList ':' Type [(ABSOLUTE (Ident | ConstExpr)) | '=' ConstExpr] 注意:在与上面类似的语法规则中,按照惯例,由方括弧括起的部分是可选的。因此过程不 一定要有参数。 简化的规范形式是 varname: type,其中 varname 是一个有效的名字,不是关键字,type 是预定义或用 户定义的数据类型。例如,string 是预定义类型,TForm1 是用户定义类型。String 变量声明的例子如下: 第1章 走进 Delphi 22 UserName : String; TForm1 变量声明的例子如下: Form1 : TForm1; absolute 关键字强制性地使变量位于某个绝对内存地址。如果常量表达式是数字,该数字就表示了地 址。如果常量表达式是变量,绝对地址就是该变量的地址。考虑下面的例子程序: Absolute.dpr program Absolute; uses Forms, SysUtils, Dialogs; {$R *.RES} var A : Integer; B : Integer Absolute A; begin Application.Initialize; A := 1007; ShowMessage(IntToStr(B)); Application.Run; end. 该程序声明了两个整数:A 和 B。B 的定义方式使得其地址与 A 的地址相同。向 A 赋值的效果与向 B 赋值相同。 关于特定种类的数据类型的信息,请在帮助文档中查找 integer、character、Boolean、enumerated、string、 real 等类型。 1.5.4 书写代码 Object Pascal 的语句可以这样书写:使用基本的操作符和类型正确的操作数,后跟分号作为语句结尾。 含有关键字的语句结尾也有分号。 语句就是 begin 和 end 间所包含的可运行代码。前一节中的程序,DPR 文件的 begin 和 end 之间共有 4 个语句。语句的例子,请参见 1.4 节“源代码文件的组织”。 1.5.5 条件语句 在最常用的一种语句中,使用条件测试来决定执行哪一条语句。有两种类型的条件语句:if .. then .. else 和 case .. of 语句。 if 条件语句 if .. then .. else 语句规范形式如下: IfStmt –> IF Expression THEN Statement [ELSE Statement] 其中 Expression 表达式返回布尔值。围绕 else 子句的方括号表明它是可选的。If 和 else 子句的 statement 部分既可以是单一语句,也可以是复合语句。简单语句由一条语句构成,复合语句由几条语句包含于同一 begin 与 end 块中构成。下面列出了几个语句的例子。 var HairColor : TColor; DateOfBirth : TDateTime; Year, Month, Day : Word; 第1章 走进 Delphi 23 begin HairColor := clGreen; If (HairColor = clBlack) then ShowMessage('Adding to suspect list'); DateOfBirth := StrToDate('02/12/1966'); DecodeDate( Now, Year, Month, Day ); If( DateOfBirth <= EncodeDate( Year - 21, Month, Day )) then ShowMessage('Legal') else ShowMessage('Dial 9-1-1'); If( DateOfBirth = EncodeDate(Year - 21, Month, Day )) then begin ShowMessage('First Drink is Free'); end; end; 请记住,只有在语句的最后结尾处才需要分号。如果有 else 子句,那么语句的结尾是在 else 子句之后。 如果没有 else 子句,分号将置于 if .. then 子句的语句结尾处。语句可以是简单的,也可是复合的,两种语 句的分号放置规则是相同的。复合语句由 begin end 块表示。 根据惯例,如果有两个以下的分支可能性,那么 if 条件语句应当是最好的选择。但是,如果有两个以 上的分支条件,使用 case 语句将更好。以下是 case 语句的三条规范: CaseStmt -> CASE Expression OF CaseSelector/';'... [ELSE Statement] [';'] END CaseSelector -> CaseLabel/','... ':' Statement CaseLabel -> ConstExpr ['..' ConstExpr] 提示:在 Delphi 中,Expression 可以是字符串表达式。Delphi 并不像 Visual Basic 那样 把 case 语句的表达式类型限制为有序类型值。 第一条规则 CaseStmt,将 case 语句描述为计算一个表达式的值并将结果与 CaseSelector 中列出的值比 较;默认的匹配条件是可选的 else 子句。CaseSelector 包含一个或多个以逗号分隔的 CaseLabel,后跟冒号 和一条语句。CaseLabel 规则包含一个或二个 ConstExpr(常量表达式),其中两个 ConstExpr 以 .. 或 , 连 接,表示一个按升序排列的连续值的范围。如下所示: // Example Case Statement var HairColor : TColor; begin HairColor := clBlack; case HairColor of clBlue: ShowMessage('Grandma'); clGreen: ShowMessage('Teenager'); clRed: ShowMessage('step child'); clBlack, clGray: ShowMessage('Dad'); 1000..10000, 10002: ShowMessage('Mom''s bad hair coloring'); else ShowMessage('not in my family'); end; end; 第1章 走进 Delphi 24 从列出的代码可以看出,case 语句可包含逗号分隔的一组范围或表达式。如果有许多分支,那么 case 语句比嵌套条件语句更容易阅读。 1.5.6 循环控制语句 Object Pascal 中有 3 种循环控制机制:repeat .. until,while .. do,和 for .. do 语句。与其他语言相同, while .. do 语句可能循环零次到多次。repeat .. until 语句可能循环一次到多次,因为条件测试位于循环体的 末端;for .. do 语句将按 for 子句中指定的次数进行循环。 有个很简单的惯例:如果需要循环至少一次,请使用 repeat .. until 语句;如果希望循环零次到多次, 请使用 while .. do 语句;如果循环指定的次数,请使用 for…do 循环语句。除了 while..do 和 repeat..until 循 环过程的最少次数外,其测试条件也有所不同。如果要执行相同的条件测试,则 while 表达式和 until 表达 式的值必须相反。对于各种类型的循环,请看下面的示例代码: // Example Loop Control Code var I : integer; begin I := 0; while( I < 10 ) do begin MessageBeep($FFFFFFFF); Inc(I); end; I := 0; repeat MessageBeep($FFFFFFFF); Inc(I); until ( I >= 10 ); For I := 1 to 10 do MessageBeep($FFFFFFFF); end; 上面的每个循环都运行了指定的次数 10(MessageBeep 是 Windows API 过程,参数为一个无符号整数; $FFFFFFFF 等价于-1。该语句使 PC 喇叭发出蜂鸣声)。如果要在循环体中处理多个语句,请将其包含在 begin 与 end 之间;在上面的 while..do 循环的循环体中即可看到。对于 repeat..until,无须使用 begin 和 end, 但如果用了 begin 和 end,编译器仍然可以编译。 1.6 编写过程和函数 在程序的可运行代码中,语句是最小的有用组成部分。上一层最小的有用的抽象是过程一级,有 Function 和 Procedure(如果没有必要区别二者,Procedure 既可指 Function 也可指 Procedure;有必要时, 将指出这一区别)。 注意:对于过程的编写有一些基本的定性的原则。通常,过程和函数不应过长,大多数情况 下应该不超过 25 行左右。如果察看 Delphi 中 VCL 的源代码,可以看到大多数过程都相对较 短。简短的过程易于维护,也有助于程序员把注意力集中于单一的任务;而长的过程则难于 阅读、不易理解,因此维护起来较为困难。长的函数通常容易超出短期记忆的能力,因此难 于写好。 第1章 1.6.1 走进 Delphi 25 编写过程 过程以关键字 Procedure 开头,后跟过程名,以分号结尾。下面的规范 ProcedureHeading 即说明了这 一点。 ProcedureHeading -> PROCEDURE Ident [FormalParameters] 注意:语法规则比单独的代码实例更难阅读,不过一旦了解了规范形式,就能够确定代码的 所有可能形式。理论上这意味着一个论断:如果您读过 Delphi 帮助中的语法部分,那么您就 能够写出所有可能种类的语句。尽管查找 Delphi 的语法可能令人厌烦,但这可能有助于解决 一些问题。 提示:解决语法问题的一种技术性稍弱的办法是:写出代码,试着进行编译。如果编译通过, 那么它就是对的。 使用过程的语法规则的第一部分,下面是过程声明的实例。 Procedure Connect; 由规则可以看出,过程以关键字 Procedure 开头,后跟一个标识符,即过程名。在规则中清楚地列出 了过程的可选部分,称为 FormalParameters,是传给过程的参数。FormalParameters 分为下列三部分: FormalParameters -> '(' FormalParm/';'... ')' FormalParm -> [VAR | CONST | OUT] Parameter Parameter -> IdentList [':' ([ARRAY OF] SimpleType | STRING | FILE)] -> Ident ':' SimpleType '=' ConstExpr FormalParameters 是这样定义的:外部是括弧,括弧内是由 FormalParam 构成的、被分号分隔的重复 的列表。FormalParam 以可选的关键字 var,const,out 开头(与参数修饰符相关的更多细节,请参照下一小节), 后接 Parameter 规则。Parameter 由包含一个或多个标识符的标识符列表、冒号、类型组成,或由标识符、 冒号、等号、默认值组成。该规则非常灵活。下面有几个合法的过程声明的例子示范了这些规则。 // Procedure Declaration Examples Procedure NoParams; Procedure OneParam( I : Integer ); Procedure TwoParamsSameType( I, J : Integer ); Procedure TwoParamsDifferentTypes( I : Integer; S : String ); Procedure OneVarParam( var I : Integer ); Procedure OneConstParam( const D : Double ); Procedure OneWithDefault( Ch : Char = 'A' ); 如果已经理解了 procedure 语句,您也就明白了 function 语句。下一节中可以看到,函数与过程之间只 有微小的差别。 1.6.2 编写函数 函数的规范规则与过程几乎相同(参见前一节有关过程规则的信息)。不同之处在于,函数使用关键 字 function 而且有返回值。函数被设计为向调用者返回数据。例如,上面列出的过程 NoParams 可重定义 为函数,如下所示: Function NoParams : Integer; 当比较 FunctionHeading 与 ProcedureHeading 时,不同之处有:关键字 function 代替了 procedure,‘:’ (冒号,不带引号)以及返回类型的使用。 1.6.3 参数前缀的使用 可以给参数添加前缀 var,const,out,或不加任何前缀。参数前缀的目的是限制参数的使用方式。好的想 第1章 走进 Delphi 26 法是使用尽可能多的约束条件,以避免参数误用。 假定在过程内部从不修改某个参数。使用 const 前缀修饰该变量,则该参数在代码中不能被修改。如 一个参数需要被过程修改,可用关键字 var 进行修饰。对于各种不同的前缀及其对变量用法的影响,下面 的几个小节进行了概述。 传值参数 传值参数可在过程内部修改,但过程返回时该修改不会反映出来。不加任何前缀,就表示该参数为传 值参数。 Procedure Foo( I : Integer ); I 的值被传递到 Foo 过程。当 Foo 返回时,在 Foo 中对 I 的修改不会反映到 I。 传递引用参数 用引用传递参数意味着传递了指向实际数据的一个指针。如果用 var 前缀,即指定用引用传递参数。 被调用过程会改变该参数,修改将对实际数据进行,过程返回时可以看到改变。 Procedure Foo( var I : Integer ); I 作为变量传递。当 Foo 返回时,在 Foo 中对 I 的改变将反映到作为参数传递到 Foo 的实际变量。 传递常量参数 常量参数是不可改变的,即“可以看不能碰”。应尽可能地使用常量参数,因为常量参数是可信赖的。 从调用过程前直到过程返回后,常量参数的值不会改变。 Procedure Foo( const I : Integer ); 在 Foo 中无法改变 I ,结果在过程返回时 I 的值不会有任何变化。 传递只写参数 只写参数以 out 关键字为前缀。out 关键字是为了与 COM 接口兼容而引入的。对被调用的过程来说, 当参数声明中含有 out 关键字时,该参数的值没有任何特定的意义,而过程应该向该参数赋值。调用者可 以预期在过程返回时,out 参数已含有需要的值。 Procedure Foo( out I : Integer ); 传给 Foo 的值将被废弃掉,当过程返回时对 I 的改变将反映出来。在使用引用传递参数这一点上,out 与 var 相似。out 前缀是为支持 COM 而添加的。 1.6.4 参数的默认值 默认值提供了一定程度的灵活性。在参数后添加等号,并把合适类型的默认值置于等号右侧,就表示 该参数具有默认值。在这种上下文环境中,等号的作用与赋值操作符类似。 当默认值有意义时,可使用默认参数。这样,在调用过程时,该参数就可用可不用。对于有默认值的 参数,如果不传递数据,则其值为默认值,否则其值为传入的值。 Procedure Foo( I : Integer = 5 ); Foo; 或 Foo( 10 ); 不提供参数即可调用 Foo,也可为参数 I 提供一个新值调用 Foo。不传递参数,I 的值为 5。而在最后 一条语句中,I 的值为 10。 第1章 走进 Delphi 27 当使用默认参数时,右侧参数必须在左侧参数之前具有默认值。这样,如果一个参数具有默认值,则 其右侧的参数必定具有默认值(第 4 章中有许多参数使用了默认值的例子)。 1.7 调 试 程 序 在 1.1.4 节“运行程序”中,描述了一些内建在集成调试器中的调试能力。到现在为止您已经看过并 且有机会来编写一些代码,但还有一些调试工具是您应该了解的。 集成开发环境允许传递命令行参数,以模拟控制台应用程序或从 Windows NT Task Scheduler 启动应用 程序。除了命令行参数,Delphi 还允许使用单行断点。在开发的每个阶段,这两项能力都使得您的程序易 于测试。 1.7.1 向集成调试器传递命令行参数 按 Alt+R,P 键可从 Run 菜单指定命令行参数。Run Parameters 对话框可用于指定简单的命令行参数、 用于指定测试服务器程序或 ActiveX 控件的主机应用程序、以及远程调试的主机。 在应用程序中读取命令行参数时,请使用 ParamCount 作为循环控制的上界,从 ParamStr( )数组中逐个 读取参数。for..do 循环非常适合该目的。 var I : Integer; begin for I := 0 to ParamCount-1 do ShowMessage( ParamStr(I) ); // process parameter end; 1.7.2 简化的中断命令 当设置断点时,编译器插入 soft ice 命令。即汇编语言指令中断 3。这对您是不可见的。您所要做的只 是按 F5 键来设置或取消断点。默认情况下,代码行的背景是红色时,就设置了断点。如果设置了断点, 在 IDE 中运行到该行代码时,过程会挂起。 断点基础 通过对断点的调整,可以减轻调试和测试的负担。参见图 1.24,按 F5 键就设置了一个断点,文件名、 行号、断点的行为都已设置好。Condition 使得您可以指定在当条件表达式为真时,中断执行。Pass count 使得您可以在中断前执行指定传递数,例如在处理大量数据时可能出现在较为靠后的循环周期中的错误。 Group(见图 1.24)使得您可以把断点组织成组,这样就可以在 Enable group 或 Disable group 组合框中选 择组的名字来设置或取消断点。 响应异常 Ignore Subsequent Exceptions 和 Handle Subsequent Expressions 复选框作为一对来使用。选择前者将使 Delphi 在调试时忽略异常。如果选择了 Handle Subsequent Exceptions,而且在 Tools, Debugger Options 对话 框的 Language Exceptions 属性页中,选定 Stop on Delphi Exceptions,则 Delphi 在遇到异常时将停下来。 把日志异常写到 Windows NT 事件日志 如果在 Log Message 域中输入一条信息,则当该断点挂起程序执行时,Windows NT 事件日志中将添 加一个条目。Eval Expression 对写到日志条目中的表达式进行计算,如果 Log Result 也被选定,则计算结 果也写入到 NT 事件日志中。 1.8 小 结 本章特地为过渡型的程序员而设计的。如果您是一位中等程度的程序员,那么本章就 Delphi 6 中的各 第1章 走进 Delphi 28 种特性已向您提供广泛而详尽的讲述。对于每个应用程序中都包含的那些从管理 IDE、调试到编写一些基 本代码的内容,本章示范了其中具有代表性的那些部分。 图 1.24 Add Source Breakpoint 对话框 第 2 章 学好面向对象的 Pascal 好的工具是好的开发的基石。Object Pascal 就是好的工具。基于上一章或您已有的技巧,本章将示范 每个程序都需要的面向对象技巧。Delphi 在开发工具中是个例外,它本身就是用 Object Pascal 创建的。本 章中包含了一些代码,它将成为每一个程序的基础。 2.1 Delphi 的惯例 大约 25 年前,C 语言是当时的新事物。那时的编译器,像 C 语言,是弱类型的。变量可以被声明为 指针,然后传给整型参数,反过来也可以。例如,一个整型变量可以被赋值 0,然后被粗心地赋值给 char* (在 C 中,即指向字符的指针)变量。原因是,编译器并不严格执行数据类型的用法,时至今日依然如此。 不管怎么说,数据只是一些数字嘛。可问题在于,如果把整数当作指针使用,可能刚好存取到 BIOS(基 本输入输出)内存的起始地址,真是糟糕透顶。另一个有害的问题是全局变量。如果不作检查,大多数人 都记不住在一个月之前声明的变量的数据类型;当变量在其他人的代码中,更是如此。 过了几年之后,数百万的美元被浪费在跟踪与全局变量和误用整数及指针相关的程序错误,这时,解 决方案出现了。20 世纪 80 年代早期, 微软公司从施乐公司帕洛阿尔托研究中心雇佣了一位匈牙利人 Charles Simonyi,他以发明了匈牙利命名惯列并使之流行而著称。匈牙利命名惯列建立了一组前缀,用以识别变量 的数据类型。这意味着程序员通过看前缀,可以避免整数与指针的误用。例如,用于存储零结尾字符串的 char*变量可能会有前缀 sz。即使是刚起步的程序员也只需记住 sz 的意思是零结尾字符串(或字符串以零 结尾)。如果全局变量是有前缀的,而您又能够记住 lpsz 的意思,那么要决定变量的类型可能并不需要找 出其声明。 提示或隐含的指导是有益的,但扪心自问一下:有多少人无视停车标志,在州际高速公路上车距过小 或吸烟。取决于您对“California stop”、three-second 规则、吸入致癌物质的态度,您可能会忽略其中几项 隐含的警告。不考虑这些,在 20 世纪 80 年代业界所找到的最好的办法就是包含前缀的命名惯例,所以就 采用了命名前缀惯例,而且还会继续使用一段时间。 今天的编译器大部分都是强类型化的,可那时候,这件事情被忽略了,而且持续了二十年之久。这意 味着无法意外地忽略数据类型的潜在不兼容性,编译器是不会让您通过的。同样的,全局变量也不再广泛 使用。当需要整数、字符、字符串时,在您提供该类型变量之前,编译器会不停地对此进行抱怨。某些软 件工具厂家看上去仍然不想放弃。在 Windows API、Visual Basic 还有许多其他地方,前缀充斥其中。由于 前缀如此之多,以至于要定义并维护一个标准是不可能的。很幸运,在 Inprise 公司没有那种令人压抑的、 无法解释或领会的前缀。Delphi 是个伟大的编译器:只要遵守本章的策略,您就不需要前缀。 2.1.1 少就是多 Delphi 不强制要求前缀命名惯例,但确实采用了简单而有限的前缀,用以表明数据的目的而非其类型。 在下列情况下,命名惯例是有用的:存在对变量名长度的限制、结构化编程需要全局数据、编译器无法捕 捉数据误用。很多年来,这些情况已不复存在。对象可消除全局变量的使用,变量名长度已经没有限制, 编译器已不再允许数据误用。这样,Delphi 就避免了复杂的前缀记号。您可以把注意力集中于编程技巧, 而不是对前缀的记忆。 2.1.2 最好的习惯 在古老的格言中,给人一条鱼,他只能吃一天;教给他捕鱼的技术,他一辈子都能够吃鱼,格言中的 道理在这里很适用。即使学了两打的前缀,只要更多的前缀产生出来,您还得继续学习;相对于当前使用 的那些前缀,您看来是只有吃鱼的本事喽。 第2章 学好面向对象的 Pascal 37 只要遵守一些常识性的规则,它们易于记忆和使用,就能帮助您写出清晰、一致的代码。 1.用名词和动词来命名过程。动词描述行为,名词描述该行为所操纵的数据。如果动词没有意义, 那您可能是在命名数据。 2.确保过程只做一件事,并做好它,这件事应当是过程名中所提到的操作。 3.使方法尽可能短,这样可以把变量发生混淆的可能性降到最小。 4.使用简短的方法,这使得难以在过程一级引入错误。 5.不要使用全局变量。 6.使用完整的词语,避免缩写。 规则只有这些。写出简短的函数可能需要一点练习,但这将减少花费于调试的时间。使用整个的词语、 名词、动词只是对可读性的一项承诺。CalculateIncomeTax、OpenDatabase 或 ReadProperty 都是声明语句, 而且都很容易读。这可能需要练习,但请记住:这些是最好的习惯,而且其回报是值得努力的。 2.1.3 惯例 虽然没有类似 lpsz 的晦涩前缀需要记忆,但 Inprise 的程序员坚持了一些惯例,您可能会觉得这些惯例 是有用的。例如,类型以 T 为前缀,像 TForm。去掉 T 得到 Form,这是个方便的变量名,同时也说明了 对象的类名。另一个例子与字段有关。字段在这里指的是私有数据(关于私有数据,请参照 2.5.1 节“存 取限定符”) 。可以向字段加 F 前缀,去掉 F 即可得到方便的特性名(关于特性的更多知识,请参见 2.6 节 “向类添加特性”)。 最后一个惯例是:在枚举集的名字中通常有两个首字母大写的词,因此枚举集的成员通常以这两个词 的首字母作为前缀,这里有一个窗体类中的例子。 type TFormStyle = (fsNormal, fsMDIChild, fsMDIForm, fsStayOnTop); FormStyle 是窗体对象的一个特性。请注意 T 前缀的使用。如果知道了类型名,就有可能知道实例的 名字。TFormStyle 类型的变量实例的名字可能会是 FormStyle。请注意枚举值的前缀都是 fs。 惯例到此为止。概述如下:Delphi 有如下三种惯例:字段或私有数据以 F 为前缀,类型以 T 为前缀, 枚举值以代表实际类型的首字母缩写为前缀。鼓励使用这一套规则,但并没有强制性的措施进行检查。只 有当惯例易于记忆、使我们更加方便、在所有情况下用法都是一致的、有助于提高您的最终产品的质量时, 惯例才是有用的。 2.2 每个 Windows 程序都具有的成分 每一个 Windows 应用程序都由 Windows 操作系统的工作方式所限制。某些基本的成分是必要的。 Windows 是一个事件驱动、基于消息的操作系统。因此,除了过程、函数、数据之外,每个 Windows 程序 都需要与 Windows 之间收发消息,以便对内部或外部的事件做出响应。 经常会在程序中发现一个额外的成分。由其名字所暗示,Windows 程序是需要窗口的;即图形化的用 户界面。通过这些,我们可以了解 Windows 程序设计的需求,以及 Delphi 如何使 Windows 程序设计成为 一件快乐的事。 2.2.1 图形用户界面 用户界面可能使产品成功,也可能使产品失败。当管理员或顾客询问开发进度时,对类或代码的冗长 叙述将会让人发困。向他们展示具有职业水准的用户界面,即使它工作得并不好他们也会同样高兴。 开发者应当知道用户界面并不解决问题,但可以促使顾客做出购买的决定。非常幸运,Delphi 既是建 立用户界面的出色工具,也具有面向对象的牢固基础。Visual Component Library 很大程度上由本地 Object Pascal 代码构成。用于创建 Delphi 的代码同样可用于您的应用程序。 提示:Delphi Super Page 位于 http://delphi.icm.edu.pl/,据声称有 5796 个文件包含有 组件。这是个寻找代码的好地方。 第2章 学好面向对象的 Pascal 38 图形用户界面的可视化效果来自于代码与资源数据,如位图、视频、音频、字体、颜色等。由于 Delphi 是面向对象语言,其中存在着成千上万的本地代码组件,而且还有许多组件正在产生中。Delphi 的界面如 此易于使用,以至于可以让美术家而不是程序员来为应用程序创建杰出的界面。图 2.1 是一个引人入胜的 可视化界面的实例。该图是 Levi Ray and Shoup 公司的网页,其中是用 Delphi 实现的产品 PensionGold。虽 然界面的好坏如此重要,但令人惊讶的是,许多公司并不雇用职业美术家来辅助界面设计。 按下列步骤,即可尝试一下 Delphi 中的新组件,TWebBrowser。 注意:要连接到第 9 步中的 Web 页面,要求您能够访问 Internet(在实例程序运行时)。 图 2.1 LRS 公司用 Delphi 实现的产品 PensionGold,网址 http://www.lrs.com 1.启动 Delphi 6。这将自动创建一个新工程。 2.把默认窗体放到前台(F12 键可在窗体和代码编辑器视图之间切换)。 3.单击组件面板上的 Internet 属性页。 4.双击 TWebBrowser 组件,这将在窗体上放置该组件。 5.按键 F11,打开 Object Inspector。 6.在 Object Inspector 中,确认已选定 WebBrowser1。把 Align 特性的值修改为 Align to the Client (alClient)。 7.在 Object Inspector 中,选定 TForm 组件。 8.选定 Form1 对象后,单击 Events 属性页。 9.双击 OnCreate 事件,加入下列代码: WebBroker1.GoHome; 10.按键 F9。 我在桌面上运行该示例时,显示的是我公司的主页 http://www.softconcepts.com,如图 2.2 所示。只用 了一个组件和一条语句,您就拥有了 Web 浏览器。尽管 Web 浏览器市场已经过于拥挤,但它确实充分体 现了组件的威力。 注意:关于设计中的人为性质的因素,有一本出色的书 The Inmates are Running the Asylum, 作者是 Alan Cooper,由 Sams 公司出版。这本书并不讲组件的设计和编码,它讲了一些人们 用于设计软件的策略。 可以考虑使用图形化的设计器以完善应用程序的界面。最低限度为一致起见,对于常用的可视化组件 如按钮、面板、以及颜色、字体和位图的使用,可以子类化组件以便获得一致的外观。 第2章 图 2.2 2.2.2 学好面向对象的 Pascal 39 使用新的 TWebBrowser,浏览 http://www.softconcepts.com 过程和函数 每个 Windows 程序都有代码。您所编写的大多数代码都是过程和函数。如果过程和函数作为类的成员, 可用面向对象的术语方法(method)来称它们。 当参数传递给过程时,所用的传递方法称为调用规范(calling convention)。例如,参数可用微处理器 寄存器变量进行传递。在 Delphi 中,可称之为寄存器调用规范(register calling convention)。编写所有的 Delphi 程序,都不需要特定的调用规范。当您需要指定调用规范时,可从表 2.1 中选择一条指令。调用规 范会影响参数传递到过程的顺序。 提示:使用 Delphi 的 Find in Files 特性,基于所用的指令进行搜索,可找到数以百计的 示范表 2.1 中指令用法的例子。指令总是放在最后。 表 2.1 改变参数传递顺序的指令,即调用规范 指令 参数传递顺序 是否用寄存器传递参数 register 从左到右 是 Pascal 从左到右 否 cdecl 从右到左 否 stdcall 从左到右 否 safecall 从右到左 否 参数是用栈地址空间或寄存器传递的。它们被调用的顺序和传输数据所用的空间,依赖于所用的编程 语言和调用规范。参数传递到过程的顺序与过程声明中的顺序相同或相反。如果一个 DLL(动态链接库) 是用 C 或 C++写的,则参数将按从右到左的顺序传递。当在 Delphi 中声明该过程时,请使用 cdecl 指令, 这样 Delphi 将逆转参数传递的顺序。 Windows API 使用 stdcall 和 safecall 调用规范,这样在调用 Windows API 时就需要使用这些指令。关于例子,请参照 2.3 节“调用 Windows API 过程”。 2.2.3 Windows 是基于消息的操作系统 如果开发者希望发挥 Windows 的所有威力以供其随意支配,那么理解基于消息的体系结构非常重要。 基于消息的操作系统与邮局非常类似。一个人写了封信,交给邮递员。无论收信者是谁,邮递员都先把信 送到邮局。在本地邮局的处理中心,雇员根据邮编代码把信分到正确的信箱中。关键在于,只要信里不是 炸弹,除了收信者没人会在意它的内容。虽然发信者和收信者都会关心信的内容,但二者之间的所有传送 者关心的只是把信从发信者传送到收信者。 Windows 与此非常相似,只是稍有不同。Windows 更像是军队的邮件呼叫。所有可能的接收者聚集在 一起,等待发给他们的信。Windows 是无法关注消息的内容的,因为大部分软件都没有写明消息的内容格 第2章 学好面向对象的 Pascal 40 式。知道如何响应消息,对于编写 Windows 程序是很有益的。对于大部分的消息,Delphi 都知道如何处理, 这实在是个好消息;消息发送是一项优雅的设计,它的工作方式几乎像邮局一样。更好的消息是,下一节 的内容是关于消息方法和事件处理程序的,将向您演示怎样编写消息方法。 2.2.4 事件处理程序把 Windows 和 Windows 程序联系起来 事件就是发生的事。鼠标单击是事件。按键也是事件。首先,电子信号触发 BIOS 中断函数 0×09 和 0×16,然后 Windows 得到通知。人们与 PC 交互时和 PC 运行时所做的事情,可称之为事件。Windows 必须提供某种机制来响应事件,而它确实提供了这种机制。事件发生后,Windows 断定事件是什么,生成 消息,并使得该消息可被所有正在接收消息的程序收到。这就是事件驱动、基于消息的操作系统函数的工 作方式。 回调过程 计算机中的所有数据都等同于表示数值的半导体元件的状态。这样,整数、文本串的表示、内存中的 过程地址等都是数字。所有的数据都有位置,即地址。因此,所有的东西都可以用其地址来引用。 回调过程的地址用于对其进行调用。调用者在编译时并不知道调用的是哪个特定的过程。调用者知道 的只是它将调用的过程的特定的原型。当调用实际过程时,调用者只知道过程的地址。这样就可以在运行 时决定调用哪一个特定的回调过程,并在程序运行过程中改变所用的回调过程。回调过程的调用者已经编 译好,并可以用特定的接口调用一个过程,但事先并不知道被调用者的符号名字。Windows API 怎么可能 知道您正在编写的函数名字呢。它不知道,但它可以指定过程的原型。给出具有该原型的实际过程的地址, 调用者即使事先一无所知也可以调用过程。回调对 Windows 编程是不可缺少的(对于更多的信息,请参照 第 6 章中有关创建过程类型的章节) 。 事件处理程序 事件处理程序是响应事件的过程。当事件产生时,Windows 或一个程序将得到通知,这就是事件处理 程序与消息相结合的方式。然后由 Windows 生成消息。消息处理程序接收到该消息,它知道具有对应的过 程类型的事件处理程序的地址,然后通过其地址利用回调过程来调用事件处理程序。对于可视化的表示, 请参照图 2.3。 图 2.3 Windows 系统中事件驱动的消息发送体系结构的近似的 可视化表示,演示了从键盘开始的一系列行为 注意:该图称为序列图。从左上角的木头人开始,称为施动者,在例子中表示有人敲击了一 下键盘;然后随着箭头的方向,从左到右,从上到下。箭头指向的垂直线连到其附属的对象。 在左端,该图表示用户按了一个键。图中 KeyBoard 是一个对象,而 KeyPress 是 KeyBoard 的方法。接下来,KeyBoard 对象触发了 BIOS 对象的 InterpretKeyState 方法。图中松散地 表示了所有参与按键响应的对象——由方框中的名字可以看出。连到箭头的垂直线表示了对 应的方法(即箭头上的文字)。 Delphi 只需要您在最后一步添加代码:由您来编写事件过程,在 Object Inspector 中的 Events 属性页上 双击即可生成事件过程。Delphi 的体系结构隐藏了 Windows 所固有的复杂性。幸运的是这并未限制您只能 编写事件处理程序。对于高级编程,在某些情况下编写自定义的消息处理程序、捕获 Windows 的消息、编 写自己的事件过程和处理程序是有益的(对于松散耦合、消息、事件处理程序的更多信息,请参照第 6 章) 。 第2章 学好面向对象的 Pascal 41 对象使编程更加容易 Delphi 使得 Windows 编程容易了一些,但编程还是不那么容易。Object Pascal 隐藏了 Windows 编程所 固有的一些复杂性,但在需要的情况下,也可以由程序员进行处理。在事件处理程序中添加几行代码即可 处理外部事件,同样的方法也可以扩展类所接收的消息种类。 2.3 调用 Windows API 过程 在 Delphi 中,直接调用 Windows API 的时候比其他语言要少得多。Delphi 的一些单元中包含了通用的 API 过程的包裹函数,这样就可以使用 Pascal 过程而无需引入 API 声明。但有些动态链接库并非 Windows 的一部分,而您则需要使用其中的一些过程。因此,了解如何声明和使用 DLL 过程通常是很有帮助的。 注意:如果需要使用 API 方法,请记住它们都已经声明在类似 Windows.pas 的单元中。您无 需重新声明即可使用。 2.3.1 可执行文件与动态链接库 应用程序有两种基本形式:可执行文件与动态链接库。可执行文件扩展名为.exe,作为单独的程序运 行;动态链接库扩展名为.dll,由其他应用程序加载。基本的 Windows 应用程序是可执行文件,而过程库 或类库则是动态链接库。COM 对过程外和过程内的应用服务器分别使用可执行文件和动态链接库格式。 在本节中我们只讨论基本的可执行文件和库,而不涉及 COM 服务器。 2.3.2 怎样调用 Windows API 过程 查看一下 Delphi 6 的源代码子目录(只存在于专业版和企业版中),可以注意到一个名为 RTL 的目录。 RTL 中的几个子目录包含了一些 Pascal 单元,其中已经声明 Windows API 过程。这样,只要将单元的名字 加入到您的单元的 Uses 子句中,即可像使用其他函数一样调用 Windows API。 Windows API 中的 SendMessage 声明于 Windows.pas 单元的接口部分,如下所示: function SendMessage(hWnd: HWND; Msg: UINT; wParam: WPARAM; lParam: LPARAM): LRESULT; stdcall; 上面的声明表明了 SendMessage 是个函数,它返回 LRESULT 类型的值,有四个参数,类型分别为: HWND,UINT,WPARAM 及 LPARAM。 注意:引入的 API 过程的声明,必须定义该过程的服务器中的实现相匹配。因此上面的类型 使用更简单的真实类型重新声明过。例如,一个 UINT 类型的变量即为一个无符号整数。实际 上,所有的 API 过程都有记号性的前缀,因为微软正是这些混乱不清的前缀的来源。H 是句 柄的前缀。U 是无符号数的前缀。W 是字的前缀,而 L 是长整数的前缀。我并不推荐去记忆 前缀,需要时可以从在线帮助中查一下。 在实现部分实际上包含了一个声明,指出了对前述过程的实现。 function SendMessage; external user32 name 'SendMessageA'; external 子句表明了包含该过程的库的名字。子句 external user32 意味着所用的库是 user32.dll。如果在 Windows.pas 中查找 user32,可以发现 user32 被定义为常数‘user32.dll’。由于 Delphi 已经声明了该过程, 如果您要使用,只需将 Windows 单元加入到 uses 子句中并调用该过程。试一试下面的例子。 1.创建新的工程(将创建空白的窗体)。 2.从组件面板的 Standard 属性页双击 TEdit 控件和 TButton 控件。这样,在 Form1 上将出现这些控件。 3. 在 Object Inspector 中选定 Button1 的 Caption 特性。键入 Toggle Selection 作为 Button1 的标题。 4.在窗体上,双击 Button1 以生成 Button1 的 OnClick 处理程序。向事件处理程序添加如下代码: 1. procedure TForm1.Button1Click(Sender: TObject); 第2章 学好面向对象的 Pascal 42 2. const 3. StartPosition : Integer = 0; 4. EndPosition : Integer = -1; 5. begin 6. Edit1.SetFocus; 7. StartPosition := Not StartPosition; 8. EndPosition := Not EndPosition; 9. SendMessage(Edit1.Handle, EM_SETSEL, StartPosition,EndPosition); 10. end; 第 1 行和第 10 行代码是由 Delphi 添加的。第 3 行和第 4 行定义了类型化常数。它们的行为特性与 C++ 的静态变量相似,在对该过程的调用之间其值保持不变。Edit1.SetFocus 将焦点设置到编辑控件,因为按钮 在单击时获得了当前焦点。第 6 行和第 7 行将 StartPosition 和 EndPosition 在 0 和-1 之间切换。第 9 行调 用了在 Windows.pas 单元中声明的 Windows API 函数 SendMessage。前面提到过 TWinControl 的每个后继 都含有一个 Windows 句柄,它可以作为 SendMessage 的第一个参数。EM_SETSEL 是预定义的 Windows 消息。如果 StartPosition 为-1 而 EndPosition 为 0,则文字被取消选定。如果值反过来,则文字被选定。 仅 Windows.pas 单元中,就有大约 30000 行的声明代码。Windows API 非常巨大,并且正越来越大。 必须使用参考资料,才能找到可用的东西。在实现新的过程之前,请花费一些时间在 API 中查找。如果 Delphi 中没有,那它可能在其他地方的 API 中。 2.3.3 声明 API 过程 通常所有的库都可称之为 API。如果不能在像 Windows.pas 这样的源代码单元中找到对 API 过程的已 有的声明,您就得自己声明它。下面是个例子,在假定 Windows.pas 不存在的情况下声明了 SendMessage。 function SendMessage(hwnd : Longword; MSG : Longword; wParam : Longint; lParam : longint ) : longint; stdcall; external 'user32.dll' name 'SendMessageA'; 注意:使用 external 子句在单元载入内存时加载库方法,可称之为早绑定。早绑定意味着在 单元载入内存的同时加载库,与晚绑定相反,后者需要显式地调用 LoadLibrary 函数。 基本的语法就是描述过程的类型、名字和参数。Windows.pas 单元实际上用的是 Windows 数据类型, 可如下声明该函数: function SendMessage(hWnd: HWND; Msg: UINT; wParam: WPARAM; lParam: LPARAM): LRESULT; stdcall; 在前一个声明中,使用 Pascal 中的等价类型代替了 Windows 类型。例如,Windows 类型 LPARAM 等 价于 Object Pascal 的 Longint 类型。在导入 API 声明的末尾处是指令。stdcall 指令表示 SendMessage 的调 用规范是从右向左传递参数,而不是 Pascal 中的从左向右,参数不用 CPU 寄存器传递。external 指令表示 SendMessage 是在 Windows DLL 中声明的,name 指令表明(声明在 user32.dll 中的)实际函数名为 SendMessageA。 如果您需要把 API 函数作为单元接口的一部份,使其他单元可以使用该 API 而无需重新声明,则可将 API 的声明分为接口部分和实现部分。将声明除去 external 和 name 子句之外放入单元的接口部分,即可将 该函数添加到单元的接口中。 function SendMessage(hwnd : Longword; MSG : Longword; wParam : Longint; lParam : longint ) : longint; stdcall; 将隐式地加载了 API 的代码块放在单元的实现部分。 function SendMessage; external 'user32.dll' name 'SendMessageA'; 从外部来看,单元中的 SendMessage 与其他过程无异。请记住:大部分的 Windows API 函数都无需声 明,Inprise 公司已经做好了声明。但无论对于哪家厂商的 API 来说,声明的技术都是同样的。 第2章 2.3.4 学好面向对象的 Pascal 43 在运行时加载库 前一节示范了隐式的库加载技术。在声明中包含了外部的‘user32.dll’,则编译器将在应用运行时隐 式的加载 user32.dll。假定您所需的技术在很少的情况下才会用到。由于总是加载几个 DLL,您可能认为 这增加了系统的开销。如果您希望对 DLL 的加载进行控制,可以使用 LoadLibrary,FreeLibrary 和 GetProcAddress 等过程。 使用 LoadLibrary 将加载一个指定的库。而 FreeLibrary 将卸载该库,GetProcAddress 可以动态得到特 定过程的句柄。如果要用 LoadLibrary 加载 DLL,则无需使用 external 语句声明该过程。声明一个过程类 型与加载的过程原型相同即可。在使用过程之前,调用 LoadLibrary 加载库,调用 GetProcAddress 从库中 得到过程的地址,在需要卸载库时可调用 FreeLibrary。 注意:库和过程的显式加载,可称之为迟绑定。在编译时过程的地址是未知的(早绑定的例 子请参照前一节)。 下面的列出的代码示范了在运行时加载动态链接库的必要步骤,以及如何从服务器得到过程的地址。 unit USendMessage; // USendMessage.pas Demonstrates dynamic API Loading and procedure initializing // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, Menus; type TSendMessage = function (hwnd : Longword; MSG : Longword; wParam : Longint; lParam : longint ) : longint; stdcall; TForm1 = class(TForm) Edit1: TEdit; Button1: TButton; procedure Button1Click(Sender: TObject); procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); private { Private declarations } Instance : Longword; MySendMessage : TSendMessage; public { Public declarations } end; var Form1: TForm1; implementation {$R *.DFM} procedure TForm1.Button1Click(Sender: TObject); const StartPos : Integer = 0; EndPos : Integer = -1; begin Edit1.SetFocus; 第2章 学好面向对象的 Pascal 44 StartPos := Not StartPos; EndPos := Not EndPos; MySendMessage(Edit1.Handle, EM_SETSEL, StartPos, EndPos); end; procedure TForm1.FormCreate(Sender: TObject); begin Instance := LoadLibrary('user32.dll'); if( Instance <> 0 ) then MySendMessage := GetProcAddress( Instance, 'SendMessageA'); end; procedure TForm1.FormDestroy(Sender: TObject); begin FreeLibrary(Instance); end; end. Type 部分定义了过程类型 TSendMessage,它与 SendMessage 过程的原型是相同的(关于细节请参见 6.3 节“创建自定义过程类型”。现在只要明白例子的代码就行了)。在 TForm 类的私有部分声明了类型为 LongWord 的一个变量 Instance。声明一个类型为 TSendMessage 的变量 MySendMessage。由 LoadLibrary 返回的 user32.dll 的句柄将赋值给 Instance。在 Object Inspector 中选定 Form1。在 Object Inspector 的 Events 属性页中双击,来创建 FormCreate 和 FormDestroy 事件处理程序。在前面列出的代码中,user32.dll 在 FormCreate 方法中装载,在 FormDestroy 方法中释放。FormCreate,即构造函数的事件处理程序,首先装 载了 user32.dll 库,然后得到 SendMessage 的地址,并将其赋值给函数算符 MySendMessage。MySendMessage 实际上就是 user32 动态链接库中的 SendMessage 函数。 注意:存在着许多出色的 API 可有助于解决问题,但要记住:以这种方式来装载 DLL,无论 是显式还是隐式的,都是过时的技术。COM 建立了使用应用服务器的协议,它同样易于使用。 关于 COM 的更多知识,可以参阅第 12 章和附录 C。 2.3.5 创建动态链接库 动态链接库是 Delphi 的一种工程类型。单击 File、New、Other 从 New Items 对话框的 New 属性页选 择 DLL Wizard 即可创建库工程,如图 2.4 所示。可执行文件和库的源文件的最明显的区别在于工程源文件, 按键 Alt+P,V 查看工程源代码,可以看到其中 library 指令代替了 program 指令。library 指令指示编译器 将应用程序建立为 DLL。下列代码示范了怎样建立 DLL。 library TestDll; uses Sharemem, SysUtils, Dialogs, Classes; {$R *.RES} Procedure Test; begin ShowMessage('This is a test!'); end; exports Test; begin end. 添加的惟一代码是 Procedure Test,它显示一条消息和 exports 声明。如果代码稍微复杂一些,您可能 会想到加入一个单元,把代码放到单元中。exports 语句表明了哪些过程将成为 DLL 接口的一部分,即那 些可被其他应用程序使用的过程。您可以定义任意多个过程,但只导出那些构成库接口的过程。即,导出 那些您希望程序员可以调用的过程。按键 Alt+P,B,即可建立 TestDll.dll。接着我们要建立测试程序。 第2章 图 2.4 学好面向对象的 Pascal 45 File,New,Other 将打开 New Items 对话框。双击 DLL Wizard,启动一个 DLL 工程 测试动态链接库 要测试库,需要建立新的工程。默认的可执行应用程序类型正是所需要的。添加一个声明,隐式地加 载库并导入过程。对于前面的例子,以下代码就足够了。 Procedure Test; external 'TestDll.dll'; 然后即可像调用其他过程一样调用库过程。代码是: Test; 可以创建库和测试工程,并使用 Project Manager(工程管理器)将其加入到同一工程组中。Project Manager 位于 View 菜单,按键 Alt+V,P 即可打开。可以在 Project Manager 中单击鼠标右键来加入新工程 或已有的工程(如图 2.5 所示)。 图 2.5 用 Project Manager 向工程组添加多个工程。这方便了跨越工程边界的调试工作 把库和测试代码放到同一工程组中,您可以跨越动态链接库工程边界,同时在两个应用程序中步进调 试,就像是调试一个应用程序一样。下面两个小节中还将提到一些在创建 DLL 时需要考虑的因素,而实 际使用的 DLL 只是代码数量更多而已。 向动态链接库传递 Pascal 字符串 Pascal 使用一种专有格式的字符串。而 Windows API 使用零结尾的 ASCII 串,字符串的长度通过字符 计数直至遇到零。Pascal 把有关字符串长度的数据存储在隐藏的前缀区域中,字符串具有引用计数,Pascal 中字符串的内存是在堆上动态分配的;因此跨越 DLL 边界传递 Pascal 风格的字符串需要把 Sharemem.pas 第2章 学好面向对象的 Pascal 46 作为库引入的第一个单元,工程的 Uses 子句如本节开头列出的代码所示。Sharemem.pas 是 Borlndmm.dll (一个内存管理器)的接口单元,如果使用 Sharemem.pas 则应用程序必须使用该 DLL。 注意:您不必使用 Sharemem.pas 而因此将 Borlndmm.dll 与库一同配置。尽管 Pascal 字符 串更易于使用,您也可使用稍差一些的 PChar 类型。PChar 类型并不需要 Borlndmm.dll 和 Sharemem.pas,但这样需要更多的代码进行管理。 在需要传递字符串时,通过把 PChar 作为库过程的参数和返回类型,就可以减少 Borlndmm.dll 和 Sharemem.pas 的使用。在许多需要字符串的情况下可使用 PChar 变量,但 Pascal 字符串更加易于使用。 创建声明文件 当创建库时,应当预见到库可能被其他程序使用。这就是库的用途。导入单元是一种源代码单元,其 接口部分用于声明库过程,而实现部分利用外部声明进行“实现”。利用导入单元,可以像调用其他过程 一样调用外部库过程。如果创建了导入单元,可以把它和 DLL 一同发布给开发者,这样他们在开发时就 可以很容易地使用您的 DLL。TestDll 库的声明单元可能包含下列代码。 unit UDeclares; interface Procedure Test; implementation Procedure Test; external 'TestDLL.dll'; end. 要创建声明单元,可用 File,New 菜单生成一个新的单元。如上面列出的代码所示,将声明加入到接口 部分,将定义添加到实现部分即可。如果有更多的过程,可继续添加声明。 2.4 类的定义与对象的实例化 类是面向对象编程的基石。类由数据和过程组成。当数据属于类时,按照惯例可将其称为字段(field)。 定义在类中的过程称为方法(method)。凡是定义为类的一部分的东西,都可称之为属性(attribute)。在提 到类的成员时,我们将使用新的术语:对于数据用特性(property)和字段,对于过程用方法;引入新的术 语意味着属于类的数据和过程与不属于类的数据和过程是不等价的。要正确地使用它们,需要额外的信息。 在 Delphi 中您可以编写结构化代码,但仅仅结构化的代码并不能把语言的能力发挥到最佳。Object Pascal 已经被用于实现 Delphi。Delphi 的体系结构是面向对象的,其根类是 TObject。Delphi 中的所有类, 其最早的祖先都是 TObject(更多的细节请参见第 3 章) 。本节的其余部分描述了一些基本技巧,它们对于 写出强大而富于表现力的软件是必要的。 2.4.1 基本的类语法 每个类声明都遵从基本的语法。从帮助文件可看到语法规则,但每个类至少需要两行代码,如下例所 示。 TMyClass = class end; 上面列出的代码定义了没有属性的类 TMyClass。它等价于: TMyClass = class(TObject) end; 第一个例子演示了类声明的基本语法。第二个例子演示了定义子类,或继承的基本语法。有趣的是, 本例中两个类是等价的,因为 Delphi 中所有类的最早的祖先都是 TObject。 TObject 有一个类方法名为 ClassName,它返回类的字符串名字。该方法用于运行时类型识别(runtime type identification,或 RTTI),同时这也是所有的类都把 TObject 作为最早祖先的原因之一——便于进行运 第2章 学好面向对象的 Pascal 47 行时类型识别。如果您要写下面的代码,可使用类引用 TMyClass。 ShowMessage(TMyClass.ClassName); 这将显示一个对话框,其中包含 TMyClass 的字样,即类名。无论用第一种还是第二种方法定义 TMyClass 类,对 ShowMessage 的调用都将显示同样的信息,这证实了 TObject 确实是其祖先之一。我们 创建一个简单的测试程序,使用 is 操作符来测试祖先,以进一步表明两个版本的 TMyClass 类具有相同的 祖先。 var MyClass : TMyClass; begin MyClass := TMyClass.Create; if( MyClass Is TObject ) then ShowMessage('MyClass IsA TObject'): MyClass.Free; end; 如果第二个版本的 TMyClass 没有子类化 TObject 之外的其他类,那么两个版本的 TMyClass 确实是等 价的。即使 TMyClass 子类化了其他的类,前面代码中的测试(MyClass Is TObject)结果仍然为真并将显 示对应的消息。请记住 TObject 是所有类的祖先。 注意:在 Delphi 中,如果不指定祖先,则类将默认继承 TObject。 向前声明 到现在为止给出的例子,可称之为类定义语法。还有一些例子将使用类的术语(其中一项用法推迟到 本章末尾说明,参见“类方法或静态方法”一节)。向前声明就是一个例子,它将在定义一个名字前引入 该名字。向前声明形式如下: TMyClass = class; 当两个类 A 和 B 在同一单元中相互引用时,向前声明会比较有用。其中一个类必然声明在先,因此使 用向前声明可避免那种类似于“第 22 条军规”的状况。在本例中,A 有一个类型为 B 的属性,B 也有一 个类型为 A 的属性。类 A 和 B 的定义是相互依赖的。当出现这种情况时,可添加向前声明,在接口部分 稍后再定义该类,如下面的代码片段所示。 interface type TClassA = class; // forward declaration TClassB = class // class definition ClassA : TClassA; // introduces A before A is defined end; TClassA = class // class definition ClassB : TClassB; end; 使用向前声明引入了类名,这使得编译器在类 A 未定义前即可解析类 B 中对类 A 的引用。 类的别名 关键字 class 的第三种用法是在不添加新属性的情况下进行继承,即为类增加一个别名。例如,子类 化 Exception 类而不添加新的属性,就定义了一个新的 Exception 类。 type EMyClass = class(Exception); 第2章 学好面向对象的 Pascal 48 提示:按照惯例,Exception 类使用 E 前缀。去掉 E,即可得到了异常实例的方便的名字。 前面列出的代码,将 EMyClass 定义为从 Exception 类继承。由于 EMyClass 类并没有扩展或 修改 Exception 类的行为,因此没有必要加入 end 语句。 定义元类或类引用 关键字 class 和 of 联用,可用于定义元类。 “基本上元类是类的类,这个概念使得我们可以将类视作对 象[Booch,108]”。元类定义形式如下: type TClass = class of TSomeType; 如果在编译时,类的实际类型未知,那么元类将比较有用(细节请参见第 7 章)。 2.4.2 捕获状态 类的一部分问题域责任描绘出了类实例的蓝图。数据属性负责捕获状态。给出一个类 Dog,正确表示 了特定的狗的属性可能会是:颜色、种类、性别、重量。分析的作用是找到正确的抽象,而这依赖于问题 域。对于这个简单的例子,用四个属性来描述狗的类已经足够了。 在面向对象设计中,为要解决的问题确定正确的属性是个挑战。对状态属性的编码需要给属性起名字 并赋予其合适的数据类型。相对于前面提到的属性,类 TDog 可能会定义如下: TDog = class Color : TColor; Breed : String; Gender : String; Weight : Double; end; 属性的名字都是完整的词,描述了存储的数据种类,属性的类型也是合理的。Color := clYellow, Breed := 'Labrador',Gender := 'Male', 以及 Weight := 49.7。我们主观臆测一下该定义,看一看它是否足够好?我们可 能会认为它是合理的。考虑类型 TColor。TColor 是个预定义类型,其中包括 clTeal 和 clButtonText。我意 识到并没有深青色和按钮底色两种颜色的狗。除非允许定义奇异颜色的狗,否则上面的定义可能需要进行 许多错误检查,以保证我们的程序中不会出现深青色的狗。 重新进行类型定义,情况会好一些。可以定义新的更适合犬类的颜色枚举类型,其中包含的狗的颜色 都是合理的,而不再使用 TColor 类型。该枚举类型可如下定义为: type TDogColor = (dcBrown, dcWhite, dcBlack, dcYellow); 而 Color 属性也将重定义为 TDogColor 类型。对数据使用有意义的类型,减少了维护正确的状态所需 的代码数量。还可以为种类和性别定义枚举,为重量指定一个范围以避免出现体重超过 10000 磅或小于零 的狗。在下面的代码中实现了所有的修改。 type TDogColor = (dcBrown, dcWhite, dcBlack, dcYellow); TDogGender = ( dgMale, dgBitch ); TDogBreed = ( dbLabrador, dbPoodle ); // etc TDogWeight = 0..300; TDog = class Color : TDogColor; Gender : TDogGender; Breed : TDogBreed; Weight : TDogWeight; end; 提示:要使用“足够好”的标准。抽象不必是完美的,但必须足够好。如果已经足够好,就 第2章 学好面向对象的 Pascal 49 可以继续了。 修改后的代码可读性更好,新的类型保证了数据的值是有效的。请花费一些时间来找到好的抽象,这 样既可以减少错误的发生,也不必写许多代码。 2.4.3 增加功能 如果说数据定义了类的实例知道些什么,那么功能就定义了类能做些什么。方法定义了类的功能。方 法是声明为类成员的过程和函数。将方法的声明置于 class 语句的第一行和 end 关键字之间即可。这里演示 了 TDog 类的例子,加入了一些方法 Jump、Run、Bark、Sleep 以及 Eat。 TDog = class Color : TDogColor; Gender : TDogGender; Breed : TDogBreed; Weight : TDogWeight; Procedure Jump; Procedure Run; Procedure Bark; Procedure Sleep; end; 注意:在类的定义中,如果您在定义字段或数据之前定义了方法,将出现编译错误“Field definition not allowed after methods or properties”。把字段定义放到类定义的开始, 即所有的特性之前,即可解决该错误。 提示:Delphi 6 可以自动的写出附加的方法声明,完成特性声明,以正确的语法写出方法体。 要完成一个类,在该类上单击右键可显示代码编辑器上下文菜单。单击 Complete Class at Cursor,Delphi 将自动地在实现部分添加方法体。 在实现部分,方法与不属于类的过程的定义几乎相同。不同之处在于添加了类名。过程 jump 定义如 下: Implementation Procedure TDog.Jump; begin // code goes here end; 注意:在 Delphi 帮助文档中,begin 与 end 复合语句被称为块。 所有的过程定义都写在实现部分,代码位于 begin 与 end 语句之间。方法除了属于类以外,其形式规 则与不属于类的过程和函数是相同的。如果参数对于通常的过程有效,那么对于方法也是有效的。 特性是与类定义相关的最后一个主要术语。特性是数据的表示,有其自身的语法。请阅读 2.6 节“向 类添加特性” ,其中讨论了可访问性,对于好的面向对象设计来说,它是一个重要的方面。 2.4.4 创建对象实例 在定义类之后需要创建该类的实例,可称之为对象,然后您才能使用对象的数据和功能(您必须创建 实例才能使用特性和非类的方法,这将在第 7 章讨论)。实例的创建总是具有相同的形式 variable :name= classname.create。例如要创建 TDog 类的实例,需要声明 TDog 类型的变量,然后调用 Create 方法。 var Dog : TDog; begin Dog := TDog.Create; 第2章 学好面向对象的 Pascal 50 Dog.Color := clYellow; // more code here Dog.Free; end; 对 Create 的调用为对象实例分配了内存,而对 Free 的调用释放了分配给对象的内存。 您可能会问 Create 和 Free 是什么时候定义的。可以还想起每个类都具有相同的祖先 TObject。Create 和 Free 是在 TObject 中 定义的。Create 知道如何基于对象的内存需求总量分配内存,即所有的属性的空间需求的总和。Free 知道 如何将对象的内存返还给 Windows 堆。Create 称为构造函数,而即使调用 Free 的对象不存在,Free 也总 是返回成功。如果对象存在,Free 将调用该对象的析构函数 Destroy。 类构造函数 每个对象都需要构造函数。TObject 的构造函数定义于 System.pas 中(请回忆第 1 章,每个单元都默 认的使用了 System.pas)。Constructor 是个特别的关键字,它表示过程用于初始化对象的实例。由于把 TObject 作为根类,每个类都有默认的构造函数(默认构造函数的定义请参见 TObject 类的声明)。构造函数用于初 始化对象的状态。 默认构造函数没有参数,把类名作为调用 Create 构造函数的前缀即可,如本节开头列出的代码所示。 子类可以重载默认构造函数,甚至可以添加参数。要了解有关重载默认构造函数的行为,请参阅第 4 章。 类析构函数 默认析构函数定义为 Destroy。Destroy 方法可执行类所需要的任何清除工作。TObject 也定义了一个过 程 Free,它首先检查以确保对象不是空值,然后调用 Destroy 方法。即使对象为空,Free 也不会生成错误。 注意:虚方法表,或 VMT,是一个由编译器管理的过程数组(属于类)。当方法用 virtual, overloaded 或 override 指令定义时,它将被添加到 VMT 表。当调用虚方法时,将根据类的 特定实例来从现有的方法中选择一个正确的来调用。VMT 是一个方法表,有助于实现多态性 的行为。C++的发明者 Bjarne Stroustrop,在其回忆录 The Design and Evolution of C++ (由 Addison-Wesley 出版)中提供了对 VMT 的出色而详尽的描述。 提示:析构函数 Destroy 无需参数。如果要重载析构行为,在子类中重载 Destroy 即可。 如果对象非空,Free 过程将调用 Destroy 方法。参考 TObject.Free 的定义,可看到相关的代码。 与构造函数相仿,在子类中也可以重载析构函数 Destroy。第 4 章中将示范这些技术。 2.5 信息隐藏是好事情 当程序员编写任何代码时,都存在一定的二元性。编写代码的程序员是作者,同时他又使用该代码, 也是用户。当作者和读者不是同一个人时,也存在这种作者——读者关系。这种关系的例子中,前者是自 己编写所用的类,而后者是使用 VCL 类库。软件开发是个挑战性的领域,好的软件需要将开发过程中的 复杂问题分解为独立的、相互间无依赖关系的任务。 编写任何软件系统都有三个外部目标:完成该系统,让系统正确地工作,要保持简单。这三个目标的 困难程度都令人惊讶。系统的完成依赖于后两者。直到软件正确的工作、不再需要维护,它才已经完成了。 完成的含义是相对于软件生命周期中的特定时间的。正确意味着要明确客户的需求,并保证系统的实现达 到或超出了需求。做得好意味着能够加入在开始时忽略的东西。好的软件必须易于维护,也必须具有高度 的可扩展性。开发过程中的复杂性控制得如何,将决定这三个目标达到的程度。 2.5.1 存取限定符 信息隐藏并非是指信息不可见。信息隐藏与信息的可访问性有关。其想法是,如果类的属性无法访问, 则这些属性就是隐藏的。如果信息隐藏起来,那么既不会看到也不会想起。当有选择地忽略复杂性时,处 第2章 学好面向对象的 Pascal 51 理复杂的事物就更加容易。例如,单纯的驾驶汽车很容易,而先了解内燃机的物理构造再驾驶汽车可能就 困难一些。 注意:有一本好书 How the Mind Works,作者是 Steven Pinker,由 W. W. Norton & Company 出版。该书对思维和认知的不同方面都作了详尽而有趣的介绍。 人类的大脑在长期记忆中存储信息的能力非常强大。具体的容量是未知的,但非常巨大。反过来,人 用于思维的那一部分记忆已经了解得很清楚,可现实确实有些严酷。在短期记忆或用于思维的那一部分记 忆中,人只具有处理 7~11 比特数据的能力。对记忆分块可以增加一些短期记忆的数据容量,但不会很多。 分块是一种技术,指的是将信息的比特位群集起来,作为更大的比特位对待。例如,8 3 6 1 0 9 3 是七个单 独的数。人的头脑所企图作的,就是要将比特位组装为块,以减少比特的数目。将七个数分为两组,一个 前缀和一个后缀,如 836-1093,这些数目字就更加易于管理。这就是分块。仍然存在一个极限,即,经过 分块过程后,可以将多少信息放置到短期记忆中。 编写代码的过程是在短期记忆中发生的。因此要管理复杂的编程任务,对记忆进行有效的分块是必要 的,而且还要在长期记忆中存储一部分信息。通过阅读本书,您可以将 Object Pascal 类的概念存储到长期 记忆中。当编写类时,您可以思索一下,您都知道哪些有关类的知识。如果要进行有效的编程,还需要进 一步的分块过程。存取限定符有助于进行分块过程。Object Pascal 引入了四种存取限定符来划分一个类, 以提供不同层次上的访问。如果您是类的作者,您可以思考类的内部问题,将类划分为四个可能的组,而 无需关心类外部的代码。当您使用该类时,您只需要了解该类可以访问的那些方面即可。 公有访问权限 公有访问权限类似于机场的候机楼。任何人都可以自由的进出。如果不加限制,机场的安全状况可能 会很差。限制登机可以防止偷渡者。对行李的限制有助于减少走私。如同机场会对限制性区域严加控制一 样,限制对类中的重要成员的存取,可以更好的控制您的代码。任何人都可以存取类中的公有属性,属性 指类的数据和方法。默认情况下,Delphi 类具有公有访问权限,除非该类或其祖先是用$M+编译器指令编 译的。通过将 public 关键字放置在类定义内部,可以显式地声明类的一部分具有公有访问权限。在关键字 public 之后,其他的存取限定符或类的结尾之前,所定义的属性都具有公有访问权限。类的公有部分所定 义的属性称之为公有接口。 注意:除非在类编译时设置了运行时信息,即$M+编译器指令,否则在没有存取限定符的情况 下类中的标识符具有公有访问权限。如果使用了$M+指令,则默认访问权限是公开的。如果一 个类子类化了具有运行时信息的类,则该类也具有运行时信息。所有的组件都继承了 TPersistent,该类具有运行时信息。因此所有组件的默认访问权限都是公开的。这意味着 在定义组件时如果不显式指明数据和方法的访问权限,则所有的用户都可以调用添加的方 法,修改添加的数据。 就像可能会对机场的某些设施限制使用一样,您也可能会限制对类的某些方面的访问。例如,人们可 以到领取行李处去拿行李,但商业航线的行李存放区和飞机的货舱却是禁止进入的。这是件好事。同样的 道理也适用于类。当用户实例化类时,他只能了解类的公有接口部分。 私有访问权限 置于关键字 private 之后的所有属性都具有私有访问权限,或者对类的用户是隐藏的。私有接口可称之 为实现细节。实现细节就是类怎样工作。以自行车为例:脚踏板和变速档是公有接口的一部分。当压力以 一定方式施加于脚踏板时,实际上转动轮子的是扣链齿轮和链条;当压力施加于变速档时,实际上是变速 器改变了齿轮。在车座上蹬着脚踏板时,如果通过提起链条来改变齿轮,动作可能非常困难。脚踏板和变 速档代表公有接口,而扣链齿轮和变速器代表实现细节,或私有接口。 所谓的信息隐藏,即对于类的用户,私有属性是隐藏的。公有接口描述了类的外观,而私有接口描述 了类怎样工作。 在面向对象的编程中,有一条诫律:所有的数据都应放置在私有接口部分,对数据的访问只能通过特 性进行(参见 2.6 节“向类添加特性” )。 第2章 学好面向对象的 Pascal 52 保护访问权限 在对象实例中是无法访问具有保护权限的方法、数据或特性的。但如果定义了新的子类,父类中具有 保护权限的成员即可在子类中直接访问。如果您希望限制类的用户对成员的访问,而允许开发者扩展方法 的行为或访问数据,则可将这些数据或方法放置于类的保护部分。 公开访问权限 公开访问权限只在 RAD 工具中有意义。它与公有访问权限非常相似,但主要用于在设计时可访问的 属性。根据设计,具有公开权限的特性和事件属性主要用于组件类。在 Object Inspector 中显示的属性都具 有公开访问权限。下面的代码片段示范了四种存取限定符的用法。 TDemo = class private FSomeIntData : Integer; protected Procedure OverrideMe; public Procedure EveryOne; published Property SomeIntData : Integer read FSomeIntData; end; 在上面列出的代码中,当用户使用 TDemo 类的实例时,只有 EveryOne 过程和 SomeIntData 特性可以 调用。而类内部的代码可以直接修改 FSomeIntData、调用 OverrideMe。当实现类时,对类的所有方面都要 注意。如果把一些属性放到类的私有和保护部分,则类的用户在使用实例时就无须关注这些属性。当用户 无法访问一些方法和数据时,由于无须花费太多的精力学习类的用法,可以有效地减少工作量。工作负荷 的简化正是我们所需要的(要理解 TDemo 类中的特性声明,请参阅 2.6 节“向类添加特性”)。 自动化访问权限 自动化访问权限与公有访问权限相同,但通常只用于 TOleAuto 的子类,用于维护后向兼容性。 访问权限的提升与下降 当子类化时,标识符的访问权限只能提升而不能下降。例如,给定一个具有保护权限的方法,在子类 中可以将其权限提升为公有而不能下降为私有。 访问权限规则中的例外情况 如果在类所定义的单元内使用该类,则访问权限的规则就不那么严格了。在类定义的单元中,权限为 私有和保护的成员的行为与公有成员类似。非正式的想法可能是:只有原作者才可能在类所定义的单元中 实例化该类,而原作者是不会误用私有和保护属性的。 这种违反访问权限规则的情况,使得可以在 Object Pascal 中对类进行欺骗。我们只考虑对编译过的单 元的访问情况。可以发现无法直接访问重要的属性。在要使用类的保护数据的单元中,定义该类的平凡子 类,取得该类的别名,然后可以将实际类型与平凡子类进行类型转换或实例化平凡子类,即可直接存取保 护属性。下面的代码可以作为示范。 type TFudgeControl = class(TControl); Procedure TForm1.ToggleState; const STATE_COLORS : array[Boolean] of TColor = (clGray, clWhite); var I : Integer; 第2章 学好面向对象的 Pascal 53 begin for I := 0 to ControlCount - 1 do begin Controls[I].Enabled := Not Controls[I].Enabled; TFudgeControl(Controls[I]).Color := STATE_COLORS[ Controls[I].Enabled ]; end; Button1.Enabled := True; // caught it down here avoiding if in the loop end; procedure TForm1.Button1Click(Sender: TObject); begin ToggleState; end; 在上面列出的代码中,ToggleState 对每个控件的激活状态取反,使用状态的布尔值作为颜色数组的索 引,然后将正确的状态颜色赋予控件。但在 TControl 类中 Color 特性的访问权限是保护。通过用 TFudgeControl 欺骗 TControl,即可访问 Color 特性。另一种方法是,不使用 Controls 列表,而在 ToggleState 中显式列出所有的控件,但该方法不太高级。 2.5.2 作用域 提到属性的可用性时,将使用作用域这个术语。它也与代码段的可用性有关。当属性具有私有作用域 时,它只能在定义的类中使用。在前一小节中,提到了私有、保护、公有和公开作用域。 还有其他几种作用域需要提到:本地的、全局的和过程的。凡是单元的接口部分定义或声明的,都具 有全局作用域。这样,即使属性具有私有作用域,而对应的类是在接口部分声明的,则该类也具有全局作 用域。全局标识符在任何单元中均可访问。将 Unit2 放在 Unit1 的 uses 子句中,则 Unit2 接口部分声明或 定义的标识符对于 Unit1 均可访问。 本地作用域用于实现部分的标识符。类可以定义在实现部分,因而具有本地作用域。但作用域不只是 与类、接口部分和实现部分有关,它与可用性有关。所有的标识符都有可用性。当变量声明在函数或过程 中时,它具有过程作用域。过程作用域意味着只在过程内部可用。 注意:在 Object Pascal 中可以定义嵌套过程。嵌套过程定义位于 procedure 语句之后,begin 和 end 块之前。嵌套过程只在其定义的过程中可用。 procedure Proc; procedure DoSomething; begin ShowMessage('I am nested.'); end; begin DoSomething; end; 在过程块中定义的变量只能在该过程或其嵌套子过程中使用;过程内的定义具有过程作用域,它比本 地作用域要狭窄。定义在过程之外、实现部分之内的变量或过程,只能在实现部分使用。接口部分的定义 可在单元中任何地方使用,也可被使用该单元的代码所用。接口部分的变量具有全局的,或最广泛的作用 域。理解作用域规则有助于进行信息隐藏,使程序的其他部分尽可能少地访问变量和过程,可限制对变量 和过程的存取,从而使其得到有效而合适的使用。当使用全局作用域时,可将数据和过程置于类中,利用 类存取限定符来限制访问。 2.5.3 信息隐藏的目的 设计作用域规则的目的是帮助人,而不是计算机。目的很简单:利用尽可能多的地方来划分信息、或 第2章 学好面向对象的 Pascal 54 尽可能的进行信息分块,这样有助于让人脑解决复杂的问题。信息分块进行得越彻底,复杂性降低得越多。 下面的例子描述了与信息隐藏有关的一些实践,它们将有助于复杂性的管理。 1.对数据和过程使用尽可能狭窄的作用域,只提供必要的访问权限。例如,如果函数需要一个变量, 那么就在该函数作用域中定义变量,如果在更大的作用域中定义该变量,则变量的值可能被不必要 的代码修改。 2.在类中尽可能使用受限的访问权限。如果没有特别的原因,都应该使用私有访问权限。 3.数据总是应该具有私有权限;可通过特性访问数据,而特性本身的能力也应该加以限制。 4.尽可能将单元接口部分的标识符放置到类中。 请记住,计算机不会在意您把信息放到哪里。即使把所有的标识符放到接口部分,或者使类中的所有 属性都具有公有访问权限,也是可以的。在起步时这可能容易一些,但就像无节制的使用信用卡购物一样, 您总是要付出代价的。 2.6 向类添加特性 对 Delphi 来说,特性是一项新发明。简单地说,特性就是数据,但添加了一些新的能力来产生智能数 据。相对于老式风格的集成开发环境来说,Delphi 非常激进;作为新的 RAD 工具,特性就是针对其额外 需要设计的。 2.6.1 数据维护对象的状态 很多年来,软件开发工业已经知道全局值是有害的。面向对象编程引入了一个概念,把数据封装到类 中,以更好地管理数据。不幸的是,使用过程修改数据令人厌烦,它使得数据难于管理;例如,在简单的 求值时,无法使用过程作为操作数。这种做法令人厌烦,因而不够一致。如果不使用过程管理数据,那么 类中的数据与全局数据也没什么区别。另一个因素是:设计时无法在集成开发环境中调用过程,因此无法 动态修改图形用户界面所需的一些可视化属性。 这些问题表明在面向对象的世界中缺了一些什么:智能数据。很多年来,软件工业已经知道通过函数 访问数据以防止数据的误用是一项有效的技术。问题是怎样将数据连接到实际的函数,而仍然保持数据的 行为。答案是特性,面向对象编程中相对较新的用语。 2.6.2 特性代表数据的接口 特性代表数据。当定义特性时,有特别的语法将特性绑定到潜在的数据值。特性的最简单形式如下。 property variablename : datatype read fieldname write fieldname property、read 和 write 都是关键字。variablename 部分是由您所提供的名字。像其他较好的变量名一 样,要使用完整的词语,特别是名词。datatype 是任何合法的变量类型,包括内部类型、类或枚举类型、 过程类型。read 和 write 子句中的 fieldname 代表实际的数据值。其中 fieldname 是类的数据属性,其类型 与特性的类型相同。按照惯例,字段值具有私有权限,其前缀为 F;而对应的特性与去掉 F 的字段名相同。 这样就容易在字段和特性之间建立对应,如下所示。 TDog = class private FColor : TDogColor; FGender : TDogGender; FBreed : TDogBreed; FWeight : TDogWeight; public Procedure Jump; Procedure Run; Procedure Bark; 第2章 学好面向对象的 Pascal 55 Procedure Sleep; property Color : TDogColor read FColor write FColor; property Gender : TDogGender read FGender write FGender; property Breed : TDogBreed read FBreed write FBreed; property Weight : TDogWeight read FWeight write FWeight; end; var Dog : TDog; begin Dog := TDog.Create; Dog.Color := clYellow; // Old Yeller // ... end; 警告:特性不能作为变量参数向过程传递,也不能取特性的地址。 注意:C++中的重载操作符使得有可能在存取数据时进行隐式函数调用,但其语法较为深奥 因而易于出错。 可以注意到 TDog 类中字段和特性的数目和种类是相同的。并不强求做到这一点,而且如果只是把潜 在的字段值和特性联系起来,那么字段就不可能再有其他的重要用处了。而实际上是可以有其他用处的。 在 read 子句中可出现函数,而 write 子句中可出现过程。这意味着可以像处理简单类型一样,通过方法进 行过滤。 特性解决了通过公有方法对私有数据进行受保护的访问、而同时还要维护数据的简单性的问题。除了 read 和 write 子句外还有一些高级的技术,但无法用于原始数据,包括定义只读和只写属性等(参见第 8 章)。请继续阅读如何实现 read 和 write 方法。 2.6.3 特性访问方法 read 子句中的字段类型总是与特性的数据类型相匹配,或者是返回值与特性类型相同的函数。write 子句中可能是与特性类型相同的字段的引用,或者是有一个参数的过程,其参数类型与特性类型匹配。 read 方法 对于简单的特性,read 方法的特性总具有 function Getfieldname:datatype 的形式。Get 是根据惯例使 用的动词。当特性在赋值操作符右侧使用时——称为右值,将隐含地调用 read 方法。由于用户不需要显式 地调用 read 方法,它们一般位于类的私有和保护部分。以本节开头的 TDog 类为例,向 Color 特性添加 read 方法,所作的修改如下。 protected Function GetColor : TDogColor; public Property Color : TDogColor read GetColor write FColor; ... implementation Function TDog.GetColor : TDogColor; begin result := FDogColor; end; 上面的代码片段将合并到原有的 TDog 类代码中。实现部分给出了本例中的 GetColor 特性方法的合理 的实现。在代码中使用 TDog 类的实例时,如果把 Color 特性作为右值使用,则 Color 特性的 read 方法将 被调用,如下所示。 Dog := TDog.Create; 第2章 学好面向对象的 Pascal 56 if( dcBrown = Dog.Color ) then // some code 在上面列出的代码中,即使 Dog.Color 位于=操作符的左侧,也同样会调用 read 方法,因为上面的调 用只是对数据求值,而并非修改其值。 write 方法 简单的 write 方法形如 procedure Setfieldname( const Value : datatype );,其中 set 是按照使用动词的惯例 使用的,fieldname 代表所采取行动的名词。我们继续对 TDog 类进行修改。 protected Function GetColor : TDogColor; Procedure SetColor( const Value : TDogColor ); public Property Color : TDogColor read GetColor write SetColor; ... implementation Procedure TDog.SetColor( const Value : TDogColor ); begin if( Value = FColor ) then exit; FColor := Value; end; 使用 const 是因为参数是不可修改的。本例使用了语句 if (Value = FColor) then exit;,避免了不必要的 更新。当更新数据库字段时,这是一项出色的技术,因为它避免了昂贵而不必要的数据库更新;它也能避 免图形界面重画,从而节省了大量的处理器周期。 2.7 小 结 第 2 章涵盖了 Delphi 与 Windows 之间协作、交互的所有基础知识。Windows 是事件驱动、基于消息 的操作系统。Delphi 基于面向对象程序设计语言 Object Pascal,该语言可以自然的映射 Windows 的消息和 事件。类、方法、数据和特性对每个用 Delphi 开发的应用程序来说,都是核心的组成部分。 第 3 章 Delphi 体系结构的关键类 好的软件开发工具的标志是:它是面向对象的、可自行扩展的、并可以促进好的软件开发实践。Delphi 满足了所有的要求。Delphi 使用 Object Pascal 语言,它本身也是用该语言建立的。Delphi 可以自行扩展(请 参照附录 A“使用 OPENTOOLS API 的 Delphi 扩展示例”)。高质量的语言和体系结构促进了好的实践。由 Delphi 的类的特征可以看出:它是相容的、一致的、均衡的。 为建立出色的应用程序,了解工作所用的体系结构框架是有益的;模仿成功的例子也是明智的。本章 强调了核心的 Delphi 类,它们可以极大地加强对 Delphi 的理解,而且有助于建立成功的软件。尽管本章 无法详尽地展示 Delphi 的类,但其中将包含关键性的类并给出有助于您获取信息的资源。 3.1 浏览 Delphi 的体系结构 在 Project Browser 中,可以看到 Delphi 的所有类。即时获取所有这些类的知识是一项惊人的技能,然 而如果不理解 Delphi 的基本结构,这也是个代价高昂的错误。总是可以通过按键 Alt+V,B 来显示 Project Browser(见图 3.1),它就是 View 菜单上的 Browser 菜单项。 注意:在本书写作时,Project Browser 中的类和属性无法与帮助文档交叉引用。这个问题 在 Delphi 6 发布时将得到解决。 Project Browser 将向您提供组成 Delphi 的所有类的层次化视图。在层次树中,每个较低层的类都是上 一个较高层类的子类。这意味着构成该类的所有一切,或者是在该类中定义的,或者是由树中较高层次的 祖先类继承而来。有了类或属性的名字,就可以引用帮助文档中相应的项。 您可以很快找到类或属性在 VCL 中定义的位置。可遵循如下步骤进行: 1.在 Delphi 中单击 View 菜单,Browser 菜单项。 2.在 Project Browser(见图 3.1)中找到 TObject 类。 3.双击 TObject。这将打开 Symbol Explorer,如图 3.2 所示。 4.在 Symbol Explorer 中当前对象为 TObject,双击 Dispatch 方法。这样 Symbol Explorer 就会转到包含 Dispatch 方法的单元。 5.在 Symbol Browser 中双击 Dispatch 方法。这将打开 systems.pas 单元,光标停留在 Dispatch 方法上。 图 3.1 按键 Alt+V,B 可显示 Project Browser,它 第3章 Delphi 体系结构的关键类 65 提供了对所有的 Delphi 类的层次化视图 图 3.2 Symbol Explorer 可以迅速定位到当前符号,对其双击即可 提示:保存并编译工程,即可将自己的类和符号加入到 Project Browser 中。Delphi 每次编 译都会更新浏览器中的符号。 以这种方式配置和使用 Project Browser,可以获取任何需要的类或属性的信息。加入自定义的类或属 性后,编译应用程序后 Delphi 将把这些信息加入到 Project Browser,而且每次编译后都进行更新。 出于示范目的,打开一个默认工程,添加一个名为 TAAA 的类,该类有一个过程 Foo。 class TAAA procedure Foo; end; 编译默认工程,即可看到 TAAA 类;如果不明确指出,则在默认情况下它继承了 TObject,在 Symbol Browser 中可以看到 Foo 过程。双击 Foo 将打开包含 TAAA 类的单元,光标置于 Foo。 3.1.1 Project Browser 选项 通过修改配置选项,可以改变在 Project Browser 中可见的细节层次。浏览器的行为由 Explorer Options 的状态所决定(见图 3.3) 。打开 Project Browser,单击右键显示 Project Browser 上下文菜单,再单击 Properties, 即可看到 Explorer Options。 第3章 图 3.3 Delphi 体系结构的关键类 66 Explorer Options 将对显示在 Project Browser 中的信息进行过滤 请阅读下面的各个小节, 其中包含了 Explorer Options 中的各个组对 Project Browser 的影响的有关信息。 表 3.1 描述了 Explorer Options 中的每个选项对 Project Browser 的影响。 表 3.1 Project Browser 的选项过滤了在浏览器中可访问的数据 选项组 选项 描述 Explorer Options Automatically show Explorer 若选中,浏览器显示时停靠在代码编辑器旁 Explorer Options Highlight incomplete class items 若选中,不完整的属性以黑体显示 Explorer Options Show declaration syntax 若选中,语法将与符号一同显示 Explorer Sorting Alphabetical 以字母顺序列出源代码单元 (续表) 选项组 选项 描述 Explorer Sorting Source 以声明顺序列出源代码单元 Class Completion Finish incomplete properties 若选中,当在代码编辑器上下文菜单中选择 Complete Class at Cursor 时,则自动完成 read 和 Option write 方法及其默认实现 Initial Browser View Classes, Units, or Globals 单选按钮组,决定了当打开 Project Browser 时, 默认选择了三个属性页――classes、units、globals 中的哪一个 Browser Scope Project symbols only 只显示工程的符号 Browser Scope All symbols (VCL included) 不仅显示工程的符号,也显示 VCL 的所有符号 Explorer Categories (复选框列表) Explorer Categories 中的每个复选框都决定了是 否显示对应类型的符号。可以对项的数目和种类 进行过滤,以简化特定项的查找 表 3.1 中属于 Explorer Options 的所有选项都很有用,如果一定要说某些选项比其他更为有用的话, Finish Incomplete Properties 确实可以帮助您编写代码。如果选中该选项,则 Delphi 将自动完成特性的声明 和实现。对于简单类型,Delphi 将写出代码。对于较为复杂的类型,Delphi 将完成特性的声明并写出实现 的函数体。按下列示范的各个步骤即可进行。 1. 在默认工程的 Unit1 单元中, 添加类 TFoo(请确认已经选中 Project Browser 的 Finish Incomplete Properties 第3章 Delphi 体系结构的关键类 67 选项) 。 2.在 TFoo 中如下定义私有字段: FI:Array[1..10] of String; 3.部分定义如下的公有特性: property I[Index : Integer] : String; 4.单击右键显示代码编辑器上下文菜单。 5.单击 Complete Class at Cursor。Delphi 将自动写出如下代码。 interface type TFoo = class private FI : array[1..10] of string; function GetI(I: Integer): String; procedure SetI(I: Integer; const Value: String); public Property I[I: Integer] : String read GetI write SetI; end; implementation { TFoo } function TFoo.GetI(I: Integer): String; begin end; procedure TFoo.SetI(I: Integer; const Value: String); begin end; 可以注意到 read 和 write 已添加到特性声明。对应的 get 和 set 方法已经在 TFoo 类中声明,而实现部 分也添加了方法体的定义。自动完成类是最近才添加到 Delphi 中的特性(关于索引特性的更多信息请参见 第 8 章)。 3.1.2 理解 Project Browser 中的作用域、继承和引用 在 Project Browser 中,Scope、Inheritance 和 References 三个属性页各自提供了不同而重要的信息。Scope 属性页列出了具有选定的类的作用域的属性,它们是该类的成员。Inheritance 属性页只显示了从 Project Browser 左侧选定的类继承而来的那些类。例如在默认的新工程中,Delphi 创建了类 TForm1,它继承了 TForm 类。选定 TForm,则 TForm1 将显示在 Inheritance 属性页中,它从属于 TForm。References 属性页 显示了声明特定项的单元。例如,(本书写作时)TForm 在 forms.pas 第 25 行有一个向前声明,其定义位 于该单元的 672 行。 对于想快速解决问题时查找已有的类和方法,Project Browser 是个有效的工具。使用 Project Browser, 专业程序员是很快就可以发现 Delphi 一些高级功能的实现细节。 3.2 根 类 在 Delphi 中有三个根类:TObject、IInterface 和 IUnknown。IInterface 和 IUnknown 是为了支持 COM 和 DCOM 编程的子类化。对 Delphi 体系结构中的所有子类而言,TObject 是主要的根类。在理解了 TObject 之后,在 Delphi 环境中设计新系统的体系结构可能会更容易。 3.2.1 TObject 类 Delphi 中的所有类都由 TObject 继承而来。无论子类显式的继承了 TObject,还是没有显式的父类,或 第3章 Delphi 体系结构的关键类 68 者继承了一个现存的类,这一点都是正确的。使用动态类型检查,(AnyClass is TObject)测试总是得到真 值。 BooleanResult := AnyClass Is TObject; 有四个基本性的方法确保所有的类都表现出基本的行为。第一种行为是,所有的类都有默认的构造函 数,可以创建实例。第二种行为是,所有的类都有析构函数,可以删除创建的实例。第三种基本的行为是, 所有的类都可以调用继承而来的 Free 方法而从内存中释放,Free 方法将检测对 Nil 对象的调用以避免错误。 第四种基本行为是,所有的类都可以响应 Windows 消息。 默认构造函数 TObject 根类中的默认构造函数是 Create。TObject 类定义于 systems.pas 单元中。所有的单元都默认地 使用 systems.pas 单元,在任何单元的 Uses 语句中声明 systems.pas 都是不必要也不可能的。默认构造函数 是静态的,即没有声明任何虚函数,如下所示: constructor Create; 可以注意到没有任何指令,这说明构造函数既不是动态的也不是虚的。这意味着根类的构造函数无法 在子类中重载(对于继承、多态、方法的重载的详细内容请参见第 6 章)。但对于新类仍然可以定义额外 的构造函数,包括与默认构造函数原型相同的构造函数。 默认析构函数 在 TObject 类中,默认析构函数定义为空的虚方法。TObject 类中的说明使用了 destructor 关键字。 destructor Destroy; virtual; 类可以有多于一个的析构函数,但只能重载默认析构函数而不能向其传递参数。如果调用 Free 的对象 引用非空,析构函数将由 Free 调用(关于析构函数请参见第 6 章)。 TObject 类的 Free 方法 过程 Free 定义于 TObject 类中。从技术上说,Free 可以重载,但不应该这样做。在 TObject 类中,Free 定义为内嵌汇编过程;它首先确认调用者是有效对象,即不是 nil 对象;然后直接索引虚方法表来调用正 确的 Destroy 方法。为了避免不必要的错误,在删除一个对象时,总是调用 Free 或 FreeAndNil。 提示:可将任何对象实例传递给 FreeAndNil 过程,它将调用对象的 Free 方法然后将实例赋 值为 Nil。 Dispatch 方法为 Delphi 提供了明显的优势 Dispatch 方法经常被开发者忽略。它在 TObject 类中引入,为 Delphi 提供了额外的在其他工具中不存 在的响应性。Windows 只向 Windows 控件发送消息,如列表框、组合框、编辑控件等,这些控件都有句柄。 因此像 Visual Basic 中的标签等控件是无法直接响应 WM_PAINT 之类的 Windows 消息的。由于 Delphi 中 的每个类都是 TObject,因此 Delphi 中的每个类都可以响应 Windows 消息。 警告:Delphi 6 可能会废弃 Dispatch 方法,以避免与 Kylix 的不兼容问题。Kylix 运行在 UNIX 操作系统中,其消息发送系统与 Windows 并不相同。在本书写作时,Dispatch 方法在 Delphi 6 的 beta 版中仍然存在并可用。 每个应用程序都包裹在 TApplication 对象的实例中。在工程源文件中,Application.Initialize 调用前, 将为 Application 对象创建一个 Windows 句柄。然后 Application.CreateHandle 调用 API 函数 SetWindowLong, 将一个 WndProc 过程的地址作为参数传递。应用程序的消息被发送到 Application.WndProc 过程。所有的 TControl 控 件 都 继 承 了 WndProc 方 法 , 使 得 它 们 可 以 继 承 Windows 消 息 ( 下 面 的 代 码 列 出 了 SetWindowLong,WndProc,Dispatch 的声明)。 LONG SetWindowLong( HWND hWnd, int nIndex, LONG dwNewLong ); 第3章 Delphi 体系结构的关键类 69 Procedure WndProc( var Message : TMessage ); Procedure Dispatch(var Message); TControl.WndProc 过程在 WndProc 方法的结尾处调用 Dispatch 方法。Dispatch 方法将检查控件是否响 应该类型的消息,消息类型由 Message 参数中的消息 ID 指定。如果对象不直接响应该消息,则检查其所 有祖先是否响应该消息。如果对象中没有响应该消息的部分,则调用 DefaultHandler。 例如,当对 TButton 控件按下鼠标左键,则调用了该控件的 WndProc 过程。该过程调用 Dispatch 方法 来查看按钮的虚方法表。实际上,消息 WM_LBUTTON 有消息处理程序 WMLButtonDown,因此调用该消 息处理程序。这个特定的消息处理程序定义为调用 DoMouseDown 过程。由于 Dispatch 是在 TObject 中引 入的,即使没有 WndProc 过程的控件也可通过 Dispatch 接收消息(关于如何利用消息处理程序和 Dispatch 方法,请参阅第 6 章)。 3.2.2 COM 接口 IInterface 是 IUnknown 接口的别名。在 Delphi 中,IUnknown 是所有 COM 接口的根接口。IUnknown 接口声明了三个方法:QueryInterface、AddRef 和 Release。QueryInterface 确保接口的用户可以向对象实例 查询接口属性。AddRef 在每次成功调用 QueryInterface 后对引用计数加 1,确保当引用存在时对象在内存 中活动。Release 用于为对象的引用计数减 1。当引用计数到达零时,对象或通过接口引用的对象被从内存 中删除。 3.3 组件的继承 Delphi 中的所有组件都由 TPersistent 类继承而来。这意味着不一定每个类都是组件,但每个组件都具 有 TObject 和 TPersistent 类的的基本功能。本节中,我们将浏览 TPersistent 类及其后代,了解组件的一些 通用的基础知识。 3.3.1 TPersistent 类 众所周知,增长与迭代是面向对象的标语。这意味采取婴儿学步的方法。对体系结构采用小而逐步的 改动是最好的。当子类中的改动以微小的增长式进行时,存在着更多的分支可能性而对子类的限制可能会 更少。 警告:如果创建了具有抽象方法的类的实例,将产生 EAbstractError 错误,因为没有定义 方法。 TPersistent 类是没有实例的。TPersistent 类有抽象方法。通常不必要创建 TPersistent 类的对象。它们 将使用$M 指令编译,编译器将对 TPersistent 及其派生类添加运行时类型信息。TPersistent 所做的就是描述 了一个接口,其中引入了对象的可赋值性、标识、所有权、以及是否可流化等性质。这就是它所作的。它 使得其派生类可以用名字建立标识,可以被拥有,其他一些对象还可以与 TPersistent 对象有聚合关系。 TPersistent 描述了应该怎样实现对象的赋值。TPersistent 还引入了持久化对象应当能够从持久存储中读出 或写入自身的概念。通常持久性是以 Windows 资源文件的形式出现的,但不一定是这样。 持久化类引入了所有权概念 TPersistent.GetOwner 方法返回 nil。想要建立所有权链或所有权的子类可以重载 GetOwner,如同 TComponent 类所作,返回对象所有者 TPersistent 子类的引用。例如,按钮可以放置在窗体上然后窗体即 取得了按钮的所有权。这样,按钮的 GetOwner 方法将返回相应的窗体(参见第 4 章)。 当使用 Project Browser 时,在家谱链中向下一层,可以很明显地看到 TComponent 类确实是这样做的。 考虑到图形用户界面的外观,显然需要对所有权进行跟踪。如果窗体不知道置于其上的控件,消息怎能传 播到所包含的控件。那是不可能的。所有权链是必要的,因此 TPersistent 类中引入了这个概念。 持久化类具有标识 为确保组件名出现在 Object Inspector 中而定义了 GetNamePath。它是组件在 Object Inspector 中的外观, 第3章 Delphi 体系结构的关键类 70 确保了可以在设计时操纵对象。 持久性包含了可赋值性 有两个虚方法 Assign 和 AssignTo 可用于解决可赋值性的问题。组件可能包含许多特性和一些对象。 例如,可视化组件拥有 TCanvas 对象,可用于绘制控件的图形外观。当对象被赋值时,对象的属性也需要 被赋值。TPersistent 类中 Assign 和 AssignTo 的实现如下。 procedure TPersistent.Assign(Source: TPersistent); begin if Source <> nil then Source.AssignTo(Self) else AssignError(nil); end; procedure TPersistent.AssignTo(Dest: TPersistent); begin Dest.AssignError(Self); end; Assign 调用 Source 参数的 protected 方法 AssignTo。如果 Source 不为 nil,则基于 Source 对象的特定 类型调用 Source.AssignTo 方法。在子类中重载 Assign 方法,可以确保持久化对象知道如何向同类型的对 象赋值。 属性的持久化 在 TPersistent 中引入对象持久化是很重要的。DefineProperties 方法要使用 TFiler 对象从.dfm 文件读或 写特性。当用文本格式观看窗体时,您所看到的文本是用 DefineProperties 方法写入的。这是持久化属性的 文本表示。按下列步骤,可以用文本格式观看窗体文件: 1.在窗体中单击右键,显示窗体上下文菜单,如图 3.4 所示。 图 3.4 从窗体上下文菜单选择 View as Text, 可以看到表示窗体的持久化脚本数据 2.单击 View as Text,观看持久化窗体的文本表示。 3.按键 Alt+F12,转换到图形表示。 注意:窗体文件可能被破坏。尽管可以在文本格式下对窗体进行处理,并将结果反映到图形 格式中,但最好还是让 Delphi 和 Object Inspector 来进行这项工作。 从文本格式显然可以看出,属性是按照名字和值成对以层次关系存储的。这实在是一种很优雅的存储 方式。在 TPersistent 类中引入了 DefineProperties 方法。在 TComponent 类中实现了该方法,用于将特性写 入 DFM 文件。DefineProperties 方法可以重载,以实现某些定制的高级组件技术(高级的组件编写技术请 参见第 10 章)。 3.3.2 TComponent 类 TComponent 是 TPersistent 类的直接后代。TComponent 类实现了 DefineProperties、GetOwner 方法, 第3章 Delphi 体系结构的关键类 71 以及两个引入了笛卡尔坐标位置的特性:Top 和 Left。TComponent 类引入了控件所有权的概念、包含了拥 有的组件数目的组件计数值、以及对象的名字和 Notification 方法。 注意:当在 Object Inspector 中选定所有者时,其内部组件也会显示出来。这是与以前版本 的根本区别。在 Delphi 的较早版本中,内部对象的事件和特性必须被提升到外部对象的接口 中。Delphi 6 使得内部对象可以暴露其自身,因而可以直接进行处理(更多的信息请参见第 10 章)。 当插入或删除组件时,将自动调用 Notification 方法。Notification 方法的语法如下。 procedure Notification(AComponent: TComponent; Operation: TOperation); virtual; 所有权变动的通知使得对象可以更新对所拥有的对象的引用。例如,事件处理程序的引用可设置为 Nil(第 10 章示范了 Notification 的用法)。TComponent 也不能直接实例化。TComponent 对 TPersistent 类 的能力有所增长。 3.3.3 TControl 类 VCL 中的大多数类都是不可见的。这意味着组件可以在设计时进行可视化处理,而运行时可能并不存 在可视化的外观。TControl 类由 TComponent 类子类化而来。TControl 类引入了可以在设计和运行时操纵 的属性,使得可以控制可视化组件的外观和行为。 控制外观的特性有 Cursor、Top、Left、Height 和 Width。TControl 类监视边界矩形,即包含控件图像 的屏幕区域,还监视客户区矩形,即可以根据数据值进行修改的区域。例如,TImage 控件属于 TControl 类。控件会监视其自身与所表示的数据之间的不同之处。这样,TImage 实例拥有一个可视化区域,而其中 一部分用于显示图像。控件包含一些行为,使得可以在笛卡尔平面上按照相对于 z 轴次序所呈现出的虚拟 外观处理其实例,并且可以相对于 x-y 坐标进行对齐。z 轴次序创建了三维空间的假象。Align 特性使得易 于设计出整洁的外观。 TControl 类也引入了事件。可视化控件需要响应用户输入和 Windows 消息,这会影响控件的行为。包 括是否在控件的某部分发生了鼠标单击,以及控件的一部分被遮住后又显现出来而需要重新绘制屏幕等。 Delphi 使非 Windows 控件也可以接收消息,从而扩展了 Windows 的行为。由于 Dispatch 是在 TObject 根类一级定义的,因此消息会传播到一些 Windows 通常不会发送消息的控件。通过 Delphi 对 Windows 的 增强,使得开发者可以更好地控制图形用户界面和非可视化类。 3.3.4 TWinControl 类 TWinControl 类是 TControl 类的子类。TWinControl 控件包含 Windows 句柄,使得它们可以成为 Windows 操作系统的当前输入焦点。Windows 体系结构中只有窗口控件有 Windows 句柄,因而可以从 Windows 操 作系统接收输入。Delphi 的体系结构使得消息可以传播到没有 Windows 句柄的 VCL 控件。TWinControl 控件有窗体、对话框、组合框以及编辑控件等。要完整地浏览 Delphi 体系结构中的 TWinControl 分支,请 参见 Project Browser。 3.3.5 使用新的标签化组件 新的 TLabelEdit 控件是个小的改进,它在编辑控件中包含了标签。不太别致但很有用,因为编辑控件 和标签控件通常成对出现。默认情况下,标签位于编辑控件的上方,与编辑控件的左侧对齐,但标签相对 于编辑控件的距离和位置可以在 Object Inspector 中修改。 TLableEdit 控件说明了面向对象编程中两个好的策略。在设计新的组件时,改动要比较简单,尽可能 从现存的组件派生,而不要修改已有的组件。扩展现存的组件避免了对已有代码的重新测试和对已有应用 程序的不利影响,而且在编程工具集中增加了一个组件。 3.3.6 特性编辑器类 特性编辑器类定义在 dsgninif.pas 中,源自 TPropertyEditor 类,用于管理复杂的特性。Object Inspector 中的所有特性都是用特性编辑器进行修改的。整数字段用 TIntegerProperty 类的实例进行修改,而字符串字 第3章 Delphi 体系结构的关键类 72 段使用 TStringProperty 编辑器。特性编辑器类有助于添加范围合理的数据,简化了复杂特性的管理,如 TStrings 中的字符串和 TImage 中的 Picture 特性等。 像 TStringProperty 之类的简单特性编辑器几乎是透明的。在 Object Inspector 中,它们表现为简单的输 入域。当修改 TStrings 类型的特性时,会打开 String list editor 对话框(见图 3.5),利用它可一致性地进行 编辑。 图 3.5 字符串列表编辑器是 TStringListProperty 类的实例,该类定义在 stredit.pas 中 特性编辑器为组件开发者提供了方便的起点,可以在设计时对非平凡的类进行修改。默认情况下,如 果在类中添加了具有对关联的特性编辑器的特性,当在 IDE 中修改该特性时则会显示编辑器。当需要自定 义的特性编辑器时,子类化相对最为接近的编辑器类即可。当创建商业组件时可以这样作。新的特性编辑 器必须注册。Delphi 提供了所有内建的工具以完成必要的任务(关于自定义特性编辑器的创建和注册,请 参阅第 11 章)。 3.4 TApplication 类 TApplication 类是 TComponent 类的直接子类。每个传统的 Delphi 应用程序都封装在一个 Application 对象中,该对象包含了程序的主窗口的句柄,提供该句柄 Windows 操作系统可以向应用程序发送消息。 注意:关键字 initialization 和 finalization 可放置于每个单元的结尾处。当单元装载到 内存中时,initialization 部分的代码在单元中其他代码以及 finalization 部分运行前运 行(更多的信息,请参见 VCL 单元 control.pas)。 您的程序所需的惟一的 TApplication 对象是自动创建的。control.pas 单元的 initialization 部分调用了该 单元中的本地过程 InitControls,该过程创建了全局对象 Application 的实例。全局变量 Application 类型为 TApplication,声明在 forms.pas 单元的 Var 部分。察看每个可执行工程的.dpr 文件,可以看到 forms.pas 是 Uses 子句中的第一个单元。 除了 Windows 句柄,Application 对象还包含对应用程序的主窗体、帮助文件、应用程序标题的引用。 程序也可以接收到应用程序层的事件,下一小节对此进行描述。 3.4.1 Application 事件 响应 Application 事件是 Application 对象的责任。应用程序运行过程中可能不会遇到任何此类事件, 但如果编写了事件处理程序,在需要时即可对应用程序进行微调。表 3.2 描述了可用的 Application 事件, 可对这些事件编写事件处理程序。 表 3.2 可以在代码中处理的 Application 事件,可以 使用 TApplicationEvents 组件来处理这些事件 事件名 描述 第3章 OnActionExecute Delphi 体系结构的关键类 73 当 组 件 的 动 作 列 表 中 没 有 定 义 OnExecute 的 事 件 处 理 程 序 时 , 对 组 件 的 OnExecute 事件进行响应 OnActionUpdate 当组件的动作列表中没有定义 OnUpdate 的事件处理程序时,对组件的 OnUpdate 事件进行响应 OnActivate 当应用程序获得当前输入焦点时,调用事件处理程序 OnDeactivate 当应用程序失去当前输入焦点时,调用事件处理程序 OnException 当发生未处理的异常时,调用事件处理程序。该事件处理程序确保即使未处理的 异常也被记入日志,例如 Windows NT 事件日志 OnHelp 当用户按键 F1 或用 HelpJump、HelpCommand 或 HelpContext 请求帮助时,将触 发该动作。对于 F1 键的响应,在 Project Options 对话框中的 Application 属性页 (见图 3.6)中需要标识出帮助文件,并且控件必须有非零的 HelpContext 特性 值 OnHint 在显示控件提示信息前,调用事件处理程序。控件的 ShowHint 特性必须为 True, 而且其 Hint 特性必须为非空的字符串值 与用户交互的大多数应用程序都有许多时间处于空闲状态(在 Windows 任务管 OnIdle 理器中观察进程时,可以很明显地看到这一点) 。空闲事件处理程序可以在空闲 时执行后台任务。这些任务要尽可能短,否则用户等待事件处理程序返回时,程 序的响应会显得很迟缓 OnMessage 可用于预览所有发送到应用程序的消息 OnMinimize 当应用程序最小化时,调用事件处理程序 OnRestore 当应用程序从最小化状态恢复时,调用事件处理程序 OnShortCut 当按下快捷键组合时,调用事件处理程序 OnShowHint 当应用程序要显示提示时,调用事件处理程序 图 3.6 通过编程或在 Project Options 对话框的 Application 属性页中将帮助 文件与应用程序关联。按键 Alt+P,O 可打开 Project Options 对话框 表 3.2 中的事件是 TApplication 类的过程类型特性。在一个类中声明类型正确的方法然后将该方法赋 予所匹配的事件特性,这样就对这些事件创建并分配了处理程序。TApplicationEvents 组件是 Delphi 最近 的一个增强,它方便了对 Application 事件的处理程序的创建。 3.4.2 使用 TApplicationEvents 组件 TApplicationEvents 组件(如图 3.7 所示)位于组件面板的 Additional 属性页上。与其他组件相同,单 第3章 Delphi 体系结构的关键类 74 击窗体或数据模块即可放置它,并修改 Object Inspector 中的事件。每个事件处理程序都有一组不同的参数。 下面列出的代码示范了如何将默认的亮黄色提示改为红色提示。 procedure TForm1.ApplicationEvents1ShowHint(var HintStr: String; var CanShow: Boolean; var HintInfo: THintInfo); begin 图 3.7 if( HintInfo.HintControl = ButtonCommit ) then TApplicationEvents 控件 HintInfo.HintColor := clRed; CanShow := True; end; 上面的代码对于伪数据库提交操作模拟了如何将提示的颜色改变为红色。如果 HintInfo.HintControl 控 件为提交按钮,则提示 HintColor 改变为引人注目的红色。按下列步骤,可重复上面的例子: 1.创建新的应用程序。 2.在自动创建的默认窗体上,绘制出一个 TButton 控件。 3.对步骤 2 所绘制的按钮控件,对其名字特性键入 ButtonCommit。 4.在组件面板上选定 Additional 属性页。 5.在窗体上绘制出 TApplicationEvents 控件(如图 3.7 所示)。 6.在 Object Inspector 中,选定 ApplicationEvents1 对象。 7.单击 Events 属性页,双击 OnShowHint 特性(Object Inspector 中的最后一项)来创建前面代码中的 方法体。 8.键入 Delphi 无法自动生成的代码。 9.确认 CommitButton 按钮的 ShowHint 特性值为 True,而且已经对按钮的 Hint 特性键入了非空字符 串值。 按键 F9 运行例子程序。当在 CommitButton 按钮上移动鼠标时,提示将是红色的。可利用 Application 事件确保未处理的异常写入到 Windows NT 事件日志,空闲的处理器时间得到有效利用,而一些自定义的 改进也可以在应用程序层合并进来。 3.5 新的 Windows Shell 组件 Delphi 中增加了新的 Shell 控件,可以很容易地创建文件系统管理界面来仿真 Windows 98、Windows 2000 和 Windows NT 4.0 中的较新的文件系统,如图 3.8 所示。新的控件代替了 FileListBox、DirectoryListBox、 DriveComboBox 和 FilterComboBox,以创建新的文件管理界面。 第3章 图 3.8 Delphi 体系结构的关键类 75 使用新的 Windows 控件,在 5 分钟内就可以重新创建一个 Windows Explorer 新 的 控 件 有 TShellTreeView ( 见 图 3.8 的 左 侧 ), TShellListView ( 见 图 3.8 右 侧 中 部 ), 以 及 TShellComboBox(见图 3.8 中工具栏) 。图中所示的例子程序无须代码即可快速创建。只需少量代码,即 可为应用程序创建类似 Windows Explorer 的窗体。每个控件都有一个或多个特性指向程序中与其相关的控 件,可以自动地反映出发生的变化。 3.6 图 形 类 在 Delphi 中有五组类用于管理与图形相关的数据和功能,其中有四组直接由 TPersistent 子类化而来, 第五组是一个控件。TCanvas、TGraphics、TPicture 和 TGraphicObject 都是由 TPersistent 子类化而来。 TGraphicControl 是第五个图形类,由 TControl 派生而来。 所有在窗体上具有可视化表示的控件内部都包含 TCanvas 对象,该对象负责在控件的边界矩形内部显 示文字和图形。TGraphic 类的子类有 TIcon、TBitmap 和 TMetaFile。TGraphicObject 类的子类有 TBrush、 TFont 和 TPen。TCanvas 对象用于对 Windows 尚未渲染的控件进行表面绘制,如 TEdit 和 TListBox 等。 TGraphicControl 的子类有 TBevel、TCustomLabel、TImage、TPaintBox、TShape 和 TSplitter。这些控件都 具有可视化的效果,但并不接受用户的文本输入。TGraphicControl 控件没有 Windows 句柄,因此无法维 护当前输入焦点。下面的代码示范了 TGraphic 对象的动态实例化,从磁盘驱动器加载.emf 元文件,将其赋 予 TImage 对象的 Picture 特性。 var Graphic : TGraphic; begin Graphic := TMetaFile.Create; try Graphic.LoadFromFile( 'shepherd.enf'); Image1.Picture.Assign(Graphic); finally Graphic.Free; end; end; 注意:TGraphic 类是抽象类。尽管声明了 TGraphic 变量,但实际上实例化了它的一个子类 TMetaFile,然后将实例赋予超类的变量。 虽然 TImage 对象的 Picture 特性是对象而且有其自身的 LoadFormFile 方法,但上面的代码示范了 TGraphic 类在技术上的应用。TImage 对象包含 TCanvas 对象。而 TCanvas 对象有 Windows 句柄。如果需 要一系列的图像但不必立即显示出来,那么 TGraphic 类的较为实际的用法是装载一些图形对象而不是 TImage 对象,以避免浪费大量的 Windows 句柄。 3.6.1 TCanvas 类 TCanvas 类封装了用于渲染图像的 Windows 设备描述表。TCanvas 包括了基本的文本和图形渲染方法, 它使得对于 Windows 图像资源的管理不易出错。下面的例子示范了用窗体的 Canvas 特性显示浮雕式的文 字。当窗口每次重画时,文字都会更新。产生浮雕效果的代码在下面列出(参见图 3.9 中的输出)。 图 3.9 通过直接写 TCanvas 对象产生的浮雕文字效果 第3章 Delphi 体系结构的关键类 76 procedure TForm2.FormPaint(Sender: TObject); const SOFTCONCEPTS_WEB = 'http://www.softconcepts.com'; var FontRecall : TFontRecall; begin FontRecall := TFontRecall.Create( Canvas.Font ); try SetBkMode( Canvas.Handle, Windows.Transparent ); Canvas.Font.Color := clWhite; Canvas.Font.Style := [fsItalic, fsBold]; Canvas.Font.Size := 16; Canvas.Font.Name := 'Times New Roman'; Canvas.TextOut(10, 10, SOFTCONCEPTS_WEB); Canvas.Font.Color := clGray; Canvas.TextOut( 9, 9, SOFTCONCEPTS_WEB ); Canvas.Font.Assign( FontRecall.Reference ); finally FontRecall.Free; end; end; 提示:按照通常的规则,应避免在事件处理程序中编写代码。而应该编写名字与行为相符合 的方法,然后在事件处理程序中调用该方法。这样可以提高代码的可读性,促进代码重用。 重用名为 WriteEmbossedTextToCanvas 的方法或其他效果相同的东西,远比重用一个接受 TObject 参数的通用事件处理程序要简单得多。 从上面列出的程序的第一行显然可以看出,代码是直接编写在窗体的 Paint 事件的处理程序之中的。 开始时,一个较新的类 TFontRecall 被实例化,以存储 Canvas 的 Font 对象的当前状态。然后调用 Windows 过程 SetBkMode 来设置背景模式,以得到最好的效果。Canvas 的句柄实际是 Windows 设备描述表,因此 可以将 Canvas.Handle 传给 SetBkMode 过程。可以修改包含在窗体画布(即 Canvas 对象)中的字体对象的 特性来得到想要的效果:首先输出文字,然后修改字体颜色,再以不同的颜色、稍许改变的 x-y 坐标重新 输出同样的文字,即可达到浮雕字体的效果。最后将恢复字体并释放 FontRecall 对象。 由于许多控件都包含 Canvas 特性,因此对于向控件添加自定义字体和修改而言,它提供了一个相对 直接的方法,下一节的内容涵盖了三个新的 recall 特性,使得在字体风格之间进行切换非常容易。 3.6.2 Delphi 6 中新增的字体、画笔、画刷恢复能力 Delphi 6 中增加了由 TObject 子类化而来的 TRecall 类,如下列出。它们采用了简单的接口。TRecall 类的对象可以创建、删除,可以调用公有方法 Store 和 Forget。TRecall 类有一个特性——对 TPersistent 类 对象的只读引用。 TRecall = class(TObject) private FStorage, FReference: TPersistent; public constructor Create(AStorage, AReference: TPersistent); destructor Destroy; override; procedure Store; procedure Forget; property Reference: TPersistent read FReference; end; graphics.pas 单元中包含了 TRecall 类的三个子类:TFontRecall、TPenRecall、TBrushRecall。这些类的 第3章 Delphi 体系结构的关键类 77 构造函数分别需要一个 TFont、TPen、TBrush 类的对象作为参数。当创建对象时,将创建对应类的实例并 存储图形对象的状态的一个副本。将对应的 TRecall 子类的 Reference 特性赋予图形对象,即可将其恢复到 原来状态。前一节中的代码示范了在窗体的画布上显示浮雕字体,同时也演示了 TFontRecall 类对象的使 用。TPenRecall 和 TBrushRecall 可同样使用。 3.7 打 印 所谓的单元素类(Singleton class)是只有一个实例的类。TPrinter 类定义在 Printers.pas 单元中。下面 列出了 Printer 函数。 function Printer: TPrinter; begin if FPrinter = nil then FPrinter := TPrinter.Create; Result := FPrinter; end; 一般不会显式地创建单元素类的实例,通常会使用一个函数,以确保只有在需要的时候才创建一个惰 性的实例。前面的代码示范了该技术。当调用 Printer 函数时,在不存在实例的情况下将创建一个实例,并 且总是返回对该实例的引用。考虑下列代码,其中使用了 TPrinter 单元素类,打印出了如图 3.10 所示的几 行文字。 var I : Integer; TextHeight : Integer; begin TextHeight := Printer.Canvas.TextHeight(Memo1.Lines.Text); Printer.BeginDoc; try for I := 0 to Memo1.Lines.Count - 1 do Printer.Canvas.TextOut( 10, 10 + (I * TextHeight), Memo1.Lines[I] ); finally Printer.EndDoc; end; end; 图 3.10 使用 printer.pas 单元和 TPrinter 单元素类 的实例,向默认打印设备打印文字和图形 注意:如果要确保对资源只创建一个实例时,单元素类很有用。在打印机的例子中,对象是 映射到物理对象的惟一实例。该映射关系限制了实例的数量。在已经对惟一物理实体建立一 对一映射后,使用单元素类是很好的策略。Printers.pas 单元对该技术所有的方面都进行了 很好的示范。 第3章 Delphi 体系结构的关键类 78 在块语句开始后的第一行就使用 Printer 对象调用了 BeginDoc 方法。实际上对 Printer 对象的引用是对 创建对象的函数的调用。通常单元素类的对象会返回对已初始化的本地对象的引用。可以在单元的 initialization 部分将本地对象引用初始化为 Nil,这样就能够判断该对象是否已初始化,然后在单元的 finalization 部分释放对象。 从前面的代码中显然可以看出,Printer 对象中包含了 Canvas 对象。这个聚合的 Canvas 对象就是在其 他地方所用的 TCanvas 类的实例,因此其接口、能力和使用方法都是相同的。在面向对象的世界中,这一 点对于同一个类的所有实例都是成立的。那么很容易理解,printer 对象可以通过所包含的 Canvas 对象来管 理打印机字体、画笔、画刷以及所要打印的复杂形状。 3.8 Internet 类 Delphi 有大约 30 个与 Internet 相关的组件,可以很容易的管理 IP 连接,读取 HTML、XML、WML 文档,或者创建 HTTP、FTP 或 Telnet 会话。除了这些组件,Delphi 的企业版还支持 WebBroker 特性,可 用于在 Web 上建立分布式的 ISAPI、NSAPI 和 CGI 等 Web 服务器扩展(ISAPI 服务器是 IIS 的应用程序, NSAPI 服务器是 Netscape 应用服务器,CGI 即 Common Gateway Interface,公共网关接口,是 Internet 服 务器应用程序的平台无关的协议)。第 19 章示范了使用 WebBroker 特性建立 Internet 服务器应用程序。 注意:Delphi 6 中的 WebBroker 现在可以支持 Apache Web 服务器。 Delphi 6 包含了三个专用于 Internet 应用程序开发的新组件。TIPAddress 校验 IP 地址输入的有效性。 XMLDocument 组件用于 XML 页面的编程。TWebBrowser 在单一组件中实现了完整的 Web 浏览器。2.2.1 节“图形用户界面”示范了 TWebBrowser 组件。 3.9 数 据 结 构 在大学里,计算机科学或数学专业的同学可能都学过基本的程序设计课程,其中涉及数据结构的有关 知识,这些课程有助于使思维敏捷。大多数的程序设计语言都内建了基本的数据结构,这些结构都经过了 特定编译器的不断优化。 Delphi 中包含了表、有序表如 TStack 和 TQueue,还包括集合类。新的组件是 ValueListEditor,一个可 视化的关联数组,其中的数据分为两栏表示,是名字-值对的列表。数据可以存储在名字和值对中,.ini 文 件就是一例。名字和值对也可用于创建简单的数据库。 3.9.1 使用新的 TValueListEditor 组件 TValueListEditor 类是一个可视化组件,可用于存储一些有意义的资料,如名字-值对或关联数组等。 在 Windows 中,关联数组的最常见的例子是仍在使用的.ini 文件以及 Windows NT 注册表,一个层次化的 关联数组。 TValueListEditor 类是 TCustomGrid 类的子类。可以把 TStrings 集合与 TValueListEditor 的 Strings 特性 相关联(参见稍后的 3.10.1 节“TStrings 类”),但 TValueListEditor 类中并不包含 TString 对象。但如果 TValueListEditor 对象的 Strings 特性引用了 TString 对象,则 TValueListEditor 将显示形如 key=value 的对, 左侧的值位于键码一栏中,右侧的值位于值一栏中。如果字符串并非 key=value 形式,则所有的数据都存 储在值一栏中。 单独的数据单元可以通过继承而来的 Cells 特性进行访问,也可用增加的 Values 和 Keys 索引特性来访 问(有关索引特性的更多知识,请参见第 8 章) 。TValueListEditor 类定义在 valedit.pas 单元中。 3.9.2 在表中存储数据 TList 类与原始指针的数组相似,指针是 Delphi 的内建类型。在 TList 中可以存储任何东西。如果在 TList 中存储复杂类型值,TList 同样易于使用,像 Add、Clear、Delete、Destroy、Exchange 等方法都可以 第3章 Delphi 体系结构的关键类 79 简单明了地进行表管理。 警告:TList.Free 方法释放用于存储数据项的内存,但并不释放分配给每个单独的项的内存。 子类化 TList 并重载析构函数 Destroy,即可确保对所包含的项动态分配的内存也可释放。 如果将在堆上分配的对象存储在 TList 中,在删除该表前需释放所分配的内存,否则您的应用程序会 出现内存泄漏。这里需要注意的是:所有的对象都应负责释放其包含的对象,因此如果 TList 包含对象, 则应在重载析构函数中将其释放。在下面的代码示例中,重载析构函数释放并删除表的第零个元素直至所 有元素都已删除。 destructor TIntegerList.Destroy; begin while( Count > 0 ) do begin TObject(Items[0]).Free; Delete(0); end; inherited; end; 只建议对包含了对象的表使用上面代码所示范的增强,表中的对象由构造函数创建。 TList 中新增了 Assign 方法。Assign 方法能够基于 TListAssignOp 参数的值进行拷贝引用赋值。 procedure Assign(ListA: TList; AOperator: TListAssignOp = laCopy; ListB: TList = nil); 上述方法将基于 AOperator 参数所代表的集合操作符对 ListA 中的元素进行引用(TListAssignOp 类型 值的列表请参见表 3.3) 。 表 3.3 对调用表和参数表可执行的操作,以及 ListA 和 ListB 元素的集合操作的结果 操作符 描述 LaCopy 调用的表与传入的表具有相同值 LaAnd 结果为调用表与参数表的交集 LaOr 结果为调用表与参数表的并集 LaXor 结果为调用表与参数表的对称差集(两个表中不都包含的元素) LaSrcUnique 结果返回输入表中具有惟一性的元素 LaDestUnique 结果返回调用表中具有惟一性的元素 如果只输入了一个参数表,则由 AOperator 所表示的集合操作在调用表上执行,调用表的值将变为集 合操作的结果。如果有两个表传入到 Assign 方法,则调用表的原值将废弃掉,其新值为 ListA 和 ListB 的 集合操作的结果的拷贝。考虑下列例子。 · 假定 ListA 包含元素(1,2,3) · 假定 ListB 包含元素(4,5,6) · ListA.Assign(ListB, LaOr) · 现在 ListA 包含元素(1,2,3,4,5,6) · ListC 包含元素(7,8,9) · ListC.Assign(ListA, LaAnd, ListB) · 现在 ListC 包含元素(4,5,6) 表的集合操作是很强大的工具,但对堆上分配的对象执行表操作时要小心,否则表中会既包含拥有的 对象也包含引用对象。当对拥有的表对象传递引用时,最好设计一个方案,在其他表取得所拥有的对象的 所有权后,删除对该对象的引用。 第3章 3.9.3 Delphi 体系结构的关键类 80 TOrderedList 有序表类是抽象类,它定义了一个介乎于栈和队列之间的类,具体情况依赖于子类如何重载 PushItem。 如果您需要后进先出(LIFO)的行为,PushItem 可以将数据项添加到表的末尾,弹出数据项也在表的末尾, 这样该实例就是栈。TQueue 类重载了 PushItem 以提供队列的行为,或者说是先进先出(FIFO)。 TOrderedList 是用 TList 实现的。我们说用…实现,是指二者之间是聚合或具有一定的关系。TStack 或 TQueue 中的 PushItem 方法决定了数据项是添加在第一个位置还是最后一个位置。本质上,队列或栈的 行为就是由 PushItem 方法所控制的。 后进先出栈 TStack 向具有私有权限的 TList 对象添加数据项,该对象将数据项加到栈的末尾。Pop 方法定义在父 类 TOrderedList 中。通过索引最后一个数据项,即 count - 1 所代表的索引位置,Pop 动作总是发生在表的 末尾。栈中存储的是原始指针 Pointer 的表,其中指针可以指向任何类型的数据。子类 TObjectStack 存储指 向 TObject 的指针。 TObjectStack 类用 TStack 类实现了 Push、Pop 以及 Peek。在 TObjectStack 中,Push 方法的参数为 TObject 而不是原始指针,Pop 和 Peek 方法返回 TObject 对象而不是 Pointer。 先进先出队列 TQueue 类在表的索引位置 0 处插入数据项,在表的末尾弹出数据项。所有的数据项都向着队列的首 尾冒泡般移动。TObjectQueue 重载了 Push、Pop 和 Peek 方法,它们接受 TObject 作参数或返回 TObject 而 不是原始指针。 3.9.4 TCollection 类 集合与表所提供的方法较相似。但集合拥有其所包含的数据项,表并非如此。集合是用 TList 实现的, 但在实际使用中集合都会拥有一个由 TCollectionItem 构成的表,TCollectionItem 是 TPersistent 的子类。因 此集合是用于管理持久对象的。在其他许多类中都可以发现子类化的集合,例如 TBDGrid 的 columns 特性 类型为 TDBGridColumn,是 TCollection 的一个子类。 集合类也包含 BeginUpdate 和 EndUpdate 类。由于集合很可能用于存储控件和其他可显示的数据,因 此挂起绘制消息直至所有的集合数据项都添加进集合是非常重要的。否则,向集合添加数以百计具有可显 示数据的项,如 TBDGridColumns,将使屏幕闪烁得非常厉害。 3.10 数 据 流 流类使用两个文件类 TReader 和 TWriter,从资源文件读写组件特性。流类可用于将数据保存到不同类 型的存储介质。当进行高级组件操作时,可能会遇到 TReader 和 TWriter 类,例如,需要重载 DefineProperties 方法以保存额外的组件属性时。但在日常的编程任务中,您会遇到许多具有可流化能力的类。回忆图形类 一节的第一个代码列表。LoadFromFile 方法使用 TFileStream 来装载图像数据(使用 TReader 和 TWriter 的 例子请参见第 10 章)。 有几种流比较有用。TFileStream 和 TMemoryStream 可分别用于从文件和内存缓冲区读写数据。 TBlobStream 用于处理数据库中的二进制大对象字段,如图形和备注字段。流类有许多用途:例如,已经 提到图形类使用文件流从图形对象读写数据。TStrings 类的 LoadFromFile 方法使用文件流来管理文本数据 的读写。下面是个很简明的例子,演示了如何直接使用文件流对象。 var FileStream : TFileStream; begin FileStream := TFileStream.Create('UFileStream.dfm', fmOpenRead); try Memo1.Lines.LoadFromStream( FileStream ); 第3章 Delphi 体系结构的关键类 81 finally FileStream.Free; end; end; 注意:在创建流对象的特定实例前,请确认组件或类并不具有流化的能力。常见的使用流的 函数是 LoadFromFile 和 SaveToFile。TClipBoard 类使用 TMemoryStream 和 TReader 及 TWriter 来从 Windows 剪贴板剪切或粘贴组件。 上面列出的代码创建了一个 TFileStream 类型的对象 FileStream,从单元文件 UFileStream.dfm 读取数 据。TMemo 的 Lines 特性类型为 TStrings,现在我们已经知道(确实如此)TStrings 类型具有流化的能力。 因此可以调用 LoadFromStream 方法,并将例子中创建的文件流对象传递给 LoadFromStream。结果为.dfm 源文件的文本形式,如图 3.11 所示。 图 3.11 用 FileStream 对象打开一个.dfm 文件,其中 有一个 TButton 对象和一个 TMemo 对象 当您定义需要流参数的方法时,请将参数定义为抽象类 TStream。然后将 TStream 的某个特定的子类 作为实际参数传递。由于接口中的参数至少会具有 TStream 的属性,因此方法中的代码可以写得较为通用, 这样使用任何流实例作为参数都可以工作得很好。 最后,请尽可能避免使用原始文件类型或字符缓冲区,而要使用合适的流类型。流类的许多功能及其 相互之间的交叉兼容性都是简单数据类型所无法达到的。当使用流类时,可以更容易地移动数据,并且能 够以一致的方式处理数据。 3.10.1 TStrings 类 TStrings 是一个抽象基类,它为字符串的集合定义了一个接口。在子类中所实现的行为包括名字与值 对的管理、将多个字符串作为一个连续的字符串处理、字符串搜索以及字符串表与其他流和外部文件之间 的流化能力。 注意:TStrings 是抽象类,因此声明变量为 TStrings 类型是个好主意,而实际上必须实例 化 TStrings 的一个子类并将其赋予 TStrings 变量。 第3章 Delphi 体系结构的关键类 82 3.11 TParser 类 TParser 类需要在 Project Browser 或 VCL 中才能找到。在线帮助中并没有该类的文档,可以猜想它被 用于实现 Delphi,但 Delphi 的实现者并不认为其他人会感兴趣。有时您可能需要语法分析的能力,那么就 可以使用 classes.pas 单元中的 TParser 类和 idiom.pas 中的一些 TIdiomParser 类。下面的代码是个很小的例 子,示范了从流中读出文件并使用 TParser 对象进行语法分析的技术。 const KEYWORD_COUNT = 'Keywords used: %d'; var FileStream : TFileStream; Parser : TParser; count : Integer; begin Count := 0; FileStream := TFilestream.Create('uparser.pas', fmOpenRead ); try Parser := TParser.Create( FileStream ); try repeat if( IsReservedWord(Parser.TokenString)) then Inc(Count); until ( Parser.NextToken = toEOF ); Memo1.Lines.Add( Format( KEYWORD_COUNT, [Count] )); finally Parser.Free; end; finally FileStream.Free; end; end; 在 Count := 0 一行后,创建了一个对象 FileStream,并读入包含上面的源代码的文件 uparser.pas。 FileStream 对象及其后的 Parser 对象被 try …finally 块所包围,这是对资源的保护,以确保在代码运行完毕 或出错时删除这两个对象(更多的信息,请参阅 3.12 节“异常处理”)。repeat…until 循环用于在 IsReservedWord 函数中对 Parser.TokenString 与保留字列表进行比较。每出现一个保留字计数加 1,直至达 到输入文件的末尾,这时 NextToken 将返回特定的符号 toEOF。结果显示在 TMemo 控件中。 3.12 异 常 处 理 错误处理的竞争已经结束。除非出现新的争夺者,否则异常处理机制是无可争辩的冠军。没有人会再 编写返回错误代码的函数。在探讨 Delphi 如何实现异常处理机制之前,作为提醒,我们将对错误处理之争 进行一番快速回顾。 注意:可能由于某种原因,您会希望返回错误代码。其中之一就是跨越进程边界的函数调用。 Delphi 中的 DLL 可以将异常跨越进程边界传播,但 VB 不能把异常传到 Delphi。如果一个 DLL 或其他应用程序返回错误代码,可以考虑用包裹函数把错误代码转换为异常。 程序员会编写一些函数返回任意范围的整数值来表示函数的错误状态。通常零表示函数已经成功地完 成了任务,而负数或其他非零值表示某种类型的失败。调用函数的软件工程师可以检查函数的结果,如果 发生了错误即可执行某些清除工作。 该技术在理论上听起来不错,但实际情况是:程序员并不总是测试错误代码,函数由于无法处理错误 第3章 Delphi 体系结构的关键类 83 而不能返回,发生了某种错误以至于没有错误代码。另外,这种测试使得程序像偏执狂一样工作,并且把 许多程序的工作搞得乱七八糟,通常会使工作慢下来。 聪明的想法是:无论什么问题,只有当它发生时,才使用相应的机制进行处理。这样就出现了异常处 理机制。当使用异常时,要把代码包裹起来进行保护,以捕捉任何严重的错误行为。如果在没有使用异常 处理程序的情况下发生异常,将在程序的调用栈中进行回溯直至遇到下一个外部的处理程序为止。调用栈 与通常的栈是类似的,可以从栈中推入和弹出数据项。编译器生成管理调用栈的代码,用于在函数之间来 回传递参数。 考虑一个函数,把数字字符组成的字符串转换为整数值。一个旧式风格的算法可能会这样编写:对字 符串中的每个字符,确认其为 0,1,2,3,4,5,6,7,8,9 十个数字字符之一。如果所有的字符都通过测试,则将字 符串转换为整数。这种检测增加了许多开销。考虑较为现代的方法:尝试把字符串转换为整数,在不能继 续进行时发出通知。 就像我们所提到的,所有关于这个问题的讨论都是学术性的,因为已经被接受的最好的方法就是异常 处理机制。我们来看一看 Delphi 中的异常是如何实现的。 3.12.1 使用 try except 块语句 Delphi 将其体系结构中的这一分支定义为 Exception 类的子类。用于捕捉错误的基本的异常处理程序 形式如下: try // code goes here except // fix any problem here! end; 所有用于解决基本问题的代码都写在异常块的 try 部分。对于无能为力的情况,所有的处理代码都位 于块的 except 部分。在引发异常后,Delphi 的默认异常处理程序将显示一个简单的对话框,并允许程序继 续运行。通常的规则是:如果无法解决异常,就不要写异常处理程序。默认异常处理程序将进行处理。但 如果您确实可以解决问题,那么就可以把代码写到异常块中。这是件好事情。 捕捉特定的异常 除了通用的异常处理程序,还可以指定所要捕捉的特定异常。其语法稍有不同,如下所示。 try // do something except on ExceptionClass do // something to fix the specific problem here end; On ExceptionClass do 子句捕捉由 ExceptionClass 所表示的特定的异常。在 Project Browser 中可以看到 许多异常类。具体使用哪一个,要看在异常出现时究竟发生了什么来决定。 按照惯例,Delphi 中的异常类的前缀为 E,其命名根据所捕捉的错误种类进行的。本节开始我们所讨 论的例子是要把字符串转换为整数。其异常处理块如下所示: try StrToInt('123er45'); except on E : EConvertError do ShowException(E, Addr(E)); end; (关于 E : exception do 的讨论请参见下一节。)虽然例子中演示了捕捉特定类型的异常,但代码并未 给出建设性的解决方案。请记住异常处理程序的最好的用法:在你的程序崩溃前解决问题。 第3章 Delphi 体系结构的关键类 84 保留异常对象 有时即使您无法解决问题,可能也希望把异常对象保留下来以进行进一步的处理。您可能需要定制向 用户显示事件的方式,或者把所有异常写入到 Windows NT 日志以便追踪。下面的代码示范了如何捕捉一 般性的异常 Exception,但也可用于 Exception 的子类。 try // some code here except on E : Exception LogException( E ); end; 提示:根类可以用来指代其所有的子类。对于异常也是如此。如果用 on Exception do 处理 一般的异常,处理程序也会捕捉到其所有的子类异常。 警告:不要显式释放异常对象。异常处理程序中会包含一些编译器添加的代码,在异常处理 完毕后释放异常对象。 当编译器遇到语法形式 E:Exception 时,会创建一个临时变量来表示 E 异常(根据惯例,E 用来表示 异常),异常对象的最常用的特性包括特定异常的运行时类型,以及表示异常的文本内容的消息特性。 3.12.2 使用资源保护块 从实用角度来说,计算机不能无限制供应的任何东西都是资源。磁盘空间、文件句柄、内存都是资源 的例子。如果程序需要资源而无法得到,那么就会发生无法恢复的错误。资源保护块是一种特定类型的异 常处理程序,可以确保程序中的资源得到回收利用。一般的语法如下: // create a resource try // do something with the resource finally // release the resource end; try except 与 try finally 块之间的不同之处在于:except 块只有在出现错误时才会调用,而 finally 块无 论是否出现错误均会调用。3.11 节“TParser 类”的代码示范了资源保护块的使用,保护了分配给文件流和 语法分析器对象的内存,确保了这些对象在过程结束之前被释放。 从 TParser 的例子明显可知,资源保护块是可以嵌套的。对于异常块也是如此。如果在发现过程需要 嵌套的异常处理程序,那么可能是该过程要处理的事务过多。可以考虑将该过程的功能分解为较小的部分。 演示代码之所以使用嵌套的 try …except 块,是为了在学习代码的基本顺序时避免使用多个过程,那可能 会使人感到困惑。 3.12.3 引发异常 在可以生成错误情况的代码中,应引发异常而不是返回错误代码。Exception 类毕竟是类,因此您必须 创建 Exception 类的实例并引发该异常。这里有一个例子,示范了 Inprise 的开发者在 StrToInt 函数中的做 法。 提 示 : StrToInt 函 数 当 字 符 串 参 数 为 无 效 整 数 时 将 引 发 异 常 , 还 可 以 使 用 函 数 StrToIntDef(const S : string; Default : Integer):Integer;。如果 S 不是由数字构成的 字符串,StrToIntDef 将返回传给 Default 参数的整数。 procedure ConvertErrorFmt(ResString: PResStringRec; const Args: array of const); begin raise EConvertError.CreateResFmt(ResString, Args); 第3章 Delphi 体系结构的关键类 85 end; function StrToInt(const S: string): Integer; var E: Integer; begin Val(S, Result, E); if E <> 0 then ConvertErrorFmt(@SInvalidInteger, [S]); end; 注意:每条规则都是有例外的。Val 就违反了有关返回错误代码的规则。在某种程度上,有 限的错误代码检查还将继续存在,不过程序员与 Val 过程是隔离的,因而不必检查错误代码, 该过程被包裹在有关引发异常的函数中。 提示:代码的风格是非常主观的。一般的经验规则是:过程应当简短扼要,只完成过程名所 表示的任务。从上面列出的代码可以看出,Inprise 的许多有才能的开发者已经掌握了这一 规则。虽然有些人可能会厌恶 SysUtil.pas 单元中 StrToInt 函数的代码风格,但它确实易 于维护并具有高度的可扩展性。 StrToInt 函数调用了 Val 过程,其中 S 代表字符串值;结果为 StrToInt 的返回值,而 E 为整数,代表错 误代码(参见有关违反错误代码规则的注记)。StrToInt 立即把错误代码转换为异常,这使得程序员不必检 查错误代码。如果 E 不为 0,则调用通用过程 ConvertErrorFmt。ConvertErrorFmt 示范了如何创建并引发异 常。 3.13 多 线 程 类 Delphi 的 classes.pas 单元中包含一个抽象类 TThread,可以将其子类化而在应用程序中增加多线程能 力。子类中惟一需要重载的方法是 Execute 过程,该方法是虚的并且是抽象的(顺便提醒一下,虚的意味 着在子类中可以添加该函数的另一版本,抽象的表示没有函数体)。下面是一个简单例子,示范了子类化 TThread 时需要进行的基本改动。 Type TClockChanged = procedure(ADateTime : TDateTime ) of object; TClockThread = class(TThread) private FOnClockChanged : TClockChanged; protected procedure Execute; override; public property OnClockChanged : TClockChanged read FOnClockChanged write FOnClockChanged; end; implementation procedure TClockThread.Execute; begin while( Not Terminated ) do begin if( Assigned(FOnClockChanged)) then FOnClockChanged( Now ); Sleep(1000); end; end; TClockChanged 定义了一个过程类型,这样就可以定义 TClockChanged 类型的变量。基本上用过程类 第3章 Delphi 体系结构的关键类 86 型 能 够 声 明 函 数 指 针 ( 将 在 第 6 章 详 述 )。 TClockThread 子 类 化 了 TThread , 添 加 了 一 个 类 型 为 TClockChanged 的字段,重载了 Execute 方法,以及一个公有特性,可以让用户把事件处理程序赋予潜在 的字段 FOnClockChanged。Execute 方法定义了一个连续的循环,将一直运行到线程终止。每次循环都以 当前日期和时间为参数,调用赋予 FOnClockChanged 的过程,然后线程会休眠一秒。 任何类都可以使用 TClockThread。创建 TClockThread 的一个实例,将类的一个成员过程(有一个参 数 TDateTime)赋予 OnClockChanged 特性。当线程运行时,时钟就在改变。尽管您也可以使用应用程序 的 OnIdle 事件或组件面板上 System 属性页中的 TTimer,但 TClockThread 示范了子类化 TThread 以添加多 任务能力的基本技术。许多高级的程序并没有多个线程,但在需要时多线程是很有用的。第 6 章包含了一 些继承的基本知识以及过程类型的定义和使用方面的问题。 3.14 OpenTools API OpenTools API 是一些类,它们可以对 Delphi IDE 自身进行扩展。OpenTools API 的源代码包含在 Delphi 6 目录的 Source\Toolsapi 子目录中。代码是用 Object Pascal 编写的。如果您已经牢固地掌握了 Object Pascal 的一些高级特性,您就可以利用该 API 中所提供的工具集来扩展 Delphi。为有助于您发挥 OpenTools API 的最大潜力,附录 B 中提供了一个例子,演示了怎样通过该 API 扩展 Delphi。 3.15 Microsoft Office 服务器 在 Microsoft Office 2000 中,所有的东西都是自动化服务器。这意味着您可以使用 comobj.pas 单元和 CreateOleObject 来运行 Excel、Word、Access、Outlook、Binder 或 PowerPoint 的一个副本,而且可以对其 进行编程处理。下面的例子示范了怎样运行 Excel。 uses comobj; var Excel : Variant; procedure TForm1.Button1Click(Sender: TObject); begin Excel := CreateOleObject( 'Excel.Application' ); Excel.Visible := True; end; 有些人可能反对 Microsoft,而且还十分的激烈。在嘲弄对 Microsoft 自动化服务器的使用前,请先从 一般意义上考虑一下面向对象开发。软件工业中最大的抱怨之一就是面向对象开发达不到它的承诺。快速 开发的日子去哪了?更小的队伍和预算?现在的答案看来是多方面的。首先,许多面向对象工具被用于建 立结构化的应用程序,其中没有或很少有设计过程,而且没有对象。其次,现有的大多数组件形式的对象 都价值不大。它们解决的都是一些不大的问题。例如,一个日历控件很有趣,但它除了让用户得到日期外 还解决了什么? 问题的第一部分可归因于培训。对象太新了,以至于第一批面向对象的程序员从大学毕业时他们的教 授才刚刚学会定义对象,而现在教授们已经学会讲授面向对象了。从第一个建模语言 Unified Modeling Language 被接受为标准到现在只有几年。教育在进步,但 Cobol 和 C 程序员怎么样呢?第二部分依赖于第 一部分。对象怎样才能变大,也就是怎样让对象解决较为复杂的问题,教育的过程是否还处于幼年期?答 案是我们还需要有一段路要走。 Microsoft 公司已经有了一些进展。Office 应用程序是自动化服务器,它们是有效的对象,它们也是可 以解决整个系列问题的较大的对象。Access 是个强大的数据库引擎。Excel 是个强大的计算引擎,而 Word 可以生成各种类型的文档,包括 HTML 和 WML 页面。实际上 Office 2000 由一长串的组件组成,可以解 决整类的问题,它是第一批这样的产品之一。 过去几年中,Microsoft 和 Borland(现在称为 Inprise)达成协议,在 Delphi 中包括 Office Servlet。它 第3章 Delphi 体系结构的关键类 87 们位于组件面板的 Servers 属性页,用 TComponent 类包裹,这使得它们易于使用。在窗体或数据模块上拖 放一个 Word 组件,即可拥有一个强大而综合性的字处理引擎。 第 12 章的全部内容都与如何使用新的 Office 服务器组件有关。 3.16 小 结 classes.pas 单元有 10000 行代码。其中有数以百计组成 Delphi 的类,都是用 Object Pascal 实现的。这 些类为数众多,本章并不周详地浏览了一下,使您对其中的类有些印象。一本详述所有类、代码、属性的 指南可能会有数千页。确实有这样的书,那就是在线帮助文档。第 3 章指给您正确的方向,其中突出了一 些核心类,它们是 Delphi 的世界级水准的可视化控件库的基础;也涉及了一些在日常编程中不那么活跃的 类,但它们可能有助于您解决一些挑战性的问题。我们希望这个过程是令人愉快的,而且可以激起您进行 更深入探索的兴趣。 第 4 章 定义多态和动态过程 短期记忆的局限所造成的困惑导致了对上下文驱动语言的需求。人们很自然的把许多事物分组为概 念,而用简短的名词表达。汽车就是个好例子。汽车是一种复杂的机器,然而三个字母的单词:car,就捕 捉到了它所有的一切。爱是一种抽象的思维,包括了心率升高、性心理焦虑、食欲不振、或者是恶心甚至 是悲痛等现象。例如,失恋将会导致极度的痛苦。所有这一切都包含到一个词:love 中。 聚合看上去是非常自然的。即使是很小的孩子也明白饼干盒中有各种不同的饼干,盒子可能是空的也 可能是满的,如果盒子空了,妈妈或爸爸就要去商店买饼干了。我们掌握复杂思想的能力看来只受到对复 杂思想进行整理分类能力的限制。同存储相比,我们的短期记忆是可怜的,而思维是优秀的。因此程序设 计语言要像其他语言一样演化,使得我们能够把笨重的思想转化成整洁而紧凑的概念,只有这样才是自然 的。在口语中,词语的意义依赖于其他的词语和上下文,通过已有的词语可以创造新的词语。在编程语言 中,为有效地进行交流我们也需要同样的能力,即:用简单、基于上下文的形式处理复杂而笨重的思想, 在已有概念的基础上创造出新的、更加精致的概念。 4.1 使用默认参数 指定默认参数,就是为过程中的某些参数指定默认值。该术语由 Object Pascal 的语法而来,显然在标 识符、冒号、简单数据类型、等号之后添加一个常量表达式,就可赋予参数默认值。在这种上下文环境中, 等号=意味着赋值。 Parameter -> IdentList [':' ([ARRAY OF] SimpleType | STRING | FILE)] -> Ident ':' SimpleType '=' ConstExpr 该规则的几个应用如下。 Procedure Proc1( I : Integer = 5 ); Function Func1( S : String = 'Jello World!" ) : Integer; Procedure Proc2( D : Double; C : Char = 'S' ); 注意:查一下函数的规范形式,显然 Delphi 并不允许默认的返回类型。 FunctionHeading -> FUNCTION Ident [FormalParameters] ':' (SimpleType | STRING) 这可能是默认参数规则的对称扩展。 一个或多个参数可以有默认值。第一个默认参数右侧的参数也必定有默认值。 当调用过程时,默认参数的值也可像其他过程一样传递。如果有一个值没有传递,则不再需要逗号分 隔符。如果所有的值都没有传递,则不需要括弧。默认参数的规则是从左至右所有参数的值都必须传递。 不能跳过任何值。考虑下面的过程。 Procedure Proc3( S : String = 'S'; I : Integer = 0; D : Double= 1.0 ); 下面是对 Proc3 的合法的调用。 Proc3; Proc3( 'T' ); Proc3( 'U', 5 ); Proc3( 'V', 6, 2.0 ); 第一次调用中 S 的值是‘S’,I 的值是 0,D 的值是 1.0。第二次调用中 S 的值是‘T’ ,I 的值是 0,D 第4章 定义多态和动态过程 95 的值是 1.0。第三次调用中 S 的值是‘U’,I 的值是 5,D 的值是 1.0。第四次调用中 S 的值是‘V’,I 的 值是 6,D 的值是 2.0。前面对 Proc3 的调用都是合法的。当存在显式输入的参数值时,在该参数左侧跳过 任何参数的值都是非法的,由于非常容易导致错误因此并不支持这种语法。例如, Proc3( , 5); Proc3( 'W',, 3.0); 上述都是对 Proc3 非法的调用。编译器将报错“Expression expected but , found”,光标将定位于被跳过 参数的位置。当您知道某个值大多数情况下是合理的,但有时候会改变时,就可以使用默认参数。 4.2 产生多态的行为 当要操纵的数据过于分散时,即使基本的过程也会变得笨拙。考虑一个简单的例子。编写一个打印整 数值的过程。而现在要打印字符串、双精度实数、文件以及图形等。写得不好的代码可能企图基于数据的 类型在一个函数中解决所有的问题,如下面的过程所示。 type TDataType = (dtInteger, dtString, dtDouble, dtFile, dtGraphic); Procedure Print( P : Pointer; DataType : TDataType ); begin case DataType of dtInteger: // … etc end; end; 上述过程的变体可能会对各种类型各定义一个参数,或使用可变类型。但无论怎样,尝试在同一方法 中实现许多不同的打印行为总是很糟糕的。 许多年前,受到启发的程序员开始编写在过程名中添加数据类型的过程,以解决这种问题。使用这种 修改可以得到如下函数:PrintInteger,PrintString,PrintDouble,PrintFile,PrintGraphic。当需要处理新的 数据类型时,就要调用新的过程。虽然过程的内部不再出现 case 语句,但它仍然会出现在过程外部的某处。 冗长费解的过程难于调试,不易扩展。很多语义上执行同一操作的函数却具有不同的名字。为对其进 行区分,增加了程序员记忆的负担。这种记忆实在是乏味。而乏味烦琐的杂事最好留给计算机去做,这样 就定义了“过载过程”。 4.2.1 引入命名矫正 由于编译器在编译时会给过程分配地址,因此过程名要求是惟一的。这并不意味着程序员必须对它们 进行惟一的命名,只要编译器完成工作时过程名是惟一的即可。这使得链接器可以区分执行过程的名字并 对其分配地址。 名字矫正解决了这个问题。编译器对包括过程名在内的所有名字都会进行矫正,它将把程序员键入的 名字与有关类型的信息组合起来。名字矫正的一个简单的例子是:将数据类型的第一个字母与过程名合并。 例如,过程 Foo( I : Integer )可能会编译为 iFoo。而 Foo( D : Double)将编译为 dFoo,另一个不同的内部名 字。现在所有对 Foo 的引用,都可以基于参数的数据类型进行解析。 由效果来看,这把解析过程所操纵的数据类型的负担由人转移到了计算机。最后只需要一个过程名, 前面的例子中所有形式的 Print 函数都可以命名为 Print,而无须用 case 语句指出调用哪一种形式的过程。 4.2.2 过载过程 名字相同,而参数的数据类型不同的过程,可称之为过载过程。由编译器负责生成惟一的内部名字, 并基于传递的数据类型解析对不同版本的过程的调用。由于减轻了记忆的负担,因此提高了思维的能力。 在 Delphi 中,定义过载过程只需在过程声明的结尾添加编译器指令 overload。如下所示。 第4章 定义多态和动态过程 96 Procedure Print( I : Integer ); overload; Procedure Print( S : String ); overload; 程序员只需要记住 Print 过程。Print(5)或 Print('Hello World!')都会解析到相应的正确实现。可以过载类 的成员过程、全局过程或本地过程。过载过程不必是类的方法。要过载任何过程,使用同一名字、适度不 同的参数以及 overload 关键字即可。 警告:如果过载过程具有二义性,那么编译器会报错。当要过载的过程参数类型相同,其中 一个过程有一个默认参数,而另外一个没有时,编译器将报错“Ambiguous overloaded call to ‘ procedure-name ’ ” 。 例 如 , Procedure Foo(I : Integer) 和 Procedure Foo(J:Integer;S:string='')进行编译时将导致二义性错误。 成功过载的关键在于:当您在编写执行同一操作的函数时,如果需要向函数名添加数据类型以保持简 洁,那么您就需要过载过程。 4.3 在过载过程与默认参数之间选择 根据两种技术的思想,即可在过载过程和默认参数之间进行选择。如果所操纵的数据是不同的,而语 义上的操作却是相同的,那么就需要过载过程。如果数据类型是相同的,而通常情况下默认值就足够了, 那么就需要默认参数。过载过程通常需要不同的代码,这意味着几个独立的过程。而默认参数通常只用同 一代码,只是值会改变。 4.4 继 承 对已存在的代码定义新的属性的过程,称之为继承。当编写代码时,必须进行测试和调试。如果代码 进行了修改,那么该代码和使用该代码的代码都必须重新进行测试和调试。除非您继承已存在的行为并在 新的类——子类中对其进行扩展,否则这些都是不可避免的。 当扩展类的行为时,首先需要判断新的行为是完善了该类还是使其成为一个稍有不同的类。哺乳动物 可以是一个类。当您想起鸭嘴兽是哺乳动物而且下蛋时,是要把下蛋作为属性添加到哺乳动物类中,还是 要子类化哺乳动物以创建一个新类来表示下蛋的哺乳动物?哺乳动物通常是不下蛋的,因此下蛋的属性意 味着需要定义一个新类。 TMammal = class public procedure GiveBirth; virtual; end; TEggLayingMamal = class(TMammal) private procedure LayEggs; public procedure GiveBirth; override; end; { TMammal } procedure TMammal.GiveBirth; begin ShowMessage('Live young!'); end; { TEggLayingMammal } procedure TEggLayingMammal.GiveBirth; begin 第4章 定义多态和动态过程 97 LayEggs; end; procedure TEggLayingMammal.LayEggs; begin ShowMessage('Laying eggs'); end; TEggLayingMammal 类继承了 TMammal,并通过调用私有方法 LayEggs 重新定义了 GiveBirth 的行为。 如果不进行继承,那么就需要在 TMammal 类中包括 LayEggs,并通过一些附加的逻辑来对鸭嘴兽的特例 进行处理。 TMammal = class private IsPlatypus : Boolean; procedure LayEggs; public procedure GiveBirth; virtual; end; { TMammal } procedure TMammal.GiveBirth; begin if( IsPlatypus ) then LayEggs else ShowMessage('Live young!'); end; procedure TMammal.LayEggs; begin ShowMessage('Laying eggs'); end; 上面实现是单体式的,其中包括了 IsPlaypus 属性及其相关的代码,代表了一种低效的风格。不使用 继承的情况下,这也是可接受的选择之一。另外一个办法是建立两个类 TMammal 和 TPlatpus,但两个类 中相似的代码会重复。继承是相对较好的选择。 子类化是有益的。首先,由于没有改变已有的类和代码,因此无须重新测试原来的类以及使用该类的 代码。其次,只需测试子类中新增的代码。由于子类是新的,因此还没有依赖需要测试的代码。最后,继 承使得您拥有了两个有用的类而不是一个。在修改已有的类之前,请自问一个问题:“我是否改变了该类 的性质,而使得原来有关该类的知识不再可用?”如果答案是确实如此,那么请定义一个子类。如果答案 是“不,我只是在完善类的定义”,那么就可以对已有的类进行修改。请记住,对类进行改动有更多的工 作要做。 4.4.1 理解继承关系中存取限定符的作用 继承可通过在定义新类时指出超类来表示(请记住,超类指被继承的类)。下面的语法示范了继承。 type TNewClass = class(TParentClass) // new attributes end; 在 Delphi 中,如果不指定父类,那么每个类都直接继承 TObject;每次您定义新类时,总是在使用继 承。当指定父类时,就表示直接继承关系。继承使子类得到父类所有的属性。新类包括旧类中所有的一切, 所有的特性和方法也都添加到新的子类中。 第4章 定义多态和动态过程 98 子类从父类中继承了一切,但对超类属性的存取权限将受到存取限定符的限制。子类无法直接访问父 类中的私有属性。当两个类定义在同一单元中,该规则会出现例外。如果 B 类继承了 A 类,而 A 和 B 类 位于同一单元中,则 B 类可以直接访问 A 类的私有属性。如果类成员代码与对应的类定义处于同一单元中, 私有访问权限将限制对这些成员的访问。 无论父类和子类是否处于同一单元中,具有保护权限的属性均可被子类访问。子类以及父类的用户都 可以访问公有属性。对于重载方法使用相同的规则。如果子类与父类被定义在同一单元中,那么子类可以 重载父类中的私有的、保护的和公有访问权限的方法,但是,如果子类没被定义在父类的同一单元中,则 子类只能重载父类中的保护的和公有权限的方法。 4.4.2 单继承 Delphi 支持单继承。这意味着只能有一个直接父类。单继承使用的语法如上一节所示。 TClassName = class(TParentClass) 注意:Delphi 支持 COM。当继承 COM 接口时,接口的名字和父类的名字都会在类声明中列出。 这看上去与多继承相似,但只是对 COM 的支持,而非传统的多继承。 如上面所示,在类声明语句的右侧出现一个类表示单继承。 4.4.3 多继承 多继承是 C++中的术语之一,但是它被 Object Pascal 和 Java 所摒弃。许多人认为多继承引入的问题比 它解决的问题还要多。考虑下列情况,A 类和 B 类都实现了一个名为 DoIt 的方法。假定 C 类继承了 A 和 B 两个类的属性。调用 C.DoIt 是二义性的,调用哪一个 DoIt,A 的还是 B 的? 注意:有些语言是支持多继承的,如 Eiffel 和 C++。James Coplien 和 Grady Booch 等作者 示范了多继承的一些惯用法,认为多继承带来的利益抵消了它的代价。普遍认为,多继承是 面向对象编程中最具有挑战性的方面之一,在解决问题的同时经常会引入许多问题。无论好 坏,多继承在实现中需要更多的考虑,因此在 Delphi 的设计中有意地摒弃了它。 考虑鸭嘴兽的例子。在 C++中,我们可以将 Platpus 类定义为 Mammal 的公有子类和 Reptile 的私有子 类以获得下蛋的属性。但鸭嘴兽并非爬行动物,而继承表示 IsA 的关系。因此我们正确的对下蛋的哺乳动 物进行了建模,但并未真正地捕捉到鸭嘴兽的特征,因为鸭嘴兽并非是哺乳的爬行动物。 为解决多继承带来的问题,引入了各种技术。重新考虑类 A、B 和 C。在 C++中可以在 C 类中重新引 入 DoIt 方法,然后在 C 的 DoIt 方法中调用 A 或 B 的 DoIt 方法,或者二者都调用。本质上,冲突是通过 引入新的 DoIt 方法来实现正确的行为而解决的。在确实需要时,可以用同样的技术来模拟多继承。如下所 示。 TClassA = class Procedure ProcA; end; TClassB = class Procedure ProcB; end; TClassC = class(ClassA) private ClassB : TClassB; public Procedure ProcB; end; implementation Procedure ClassC.ProcB; begin ClassB.ProcB; 第4章 定义多态和动态过程 99 end; TClassA 定义了过程 ProcA。TClassB 定义了过程 ProcB。TClassC 是 TClassA 的子类,因此它继承了 TClassA 的所有属性。通过在 TClassC 中引入 TClassB 中找到的属性,可以模拟性的继承 TClassB。在 TClassC 中,TClassB 的属性是通过内含的 ClassB 对象来实现的。从技术上,类 C 和类 A 是 IsA 关系,类 C 和类 B 是 HasA 关系,但类 C 也呈现出了类 B 对象的行为和状态。 注意:像多继承、操作符重载,以及模板等术语或者已在 C++之后的语言中被废弃,或者在 C++正式出现后进行了修订。这些术语在纯粹语言学的意义上很有趣,但世界并非是理论化 的,因此它们并未达到预期目标。简言之,很难既好又快地对其进行实现。 在 C++使用多继承时,有时必须使用上一段示例中的技术。Delphi 则排除了与多继承相关的问题,但 在必要情况下,仍然可以进行模拟。 4.5 静态的、虚的和动态的方法 Object Pascal 中的静态方法就是没有声明为虚的或动态的方法。没有对应于静态方法的编译器指令。 对于所有的实际用途来说,虚方法和动态方法是没有什么区别的。要创建虚方法或动态方法,只需在方法 声明结尾处添加 virtual 或 dynamic 编译器指令即可。 注意:不要把 Object Pascal 中的静态(static)概念与其在 C++或 Java 中的意义弄混。与 C++或 Java 中的静态方法对应,在 Object Pascal 中可称之为类方法。在 Object Pascal 中, 静态方法指的只是虚方法与动态方法之外的方法。第 7 章示范了 Object Pascal 中类方法的 定义。 在第 2 章中提到,虚方法添加到虚方法表(VMT),当决定调用哪一个过程时可以使用隐藏的索引字 段来索引过程数组。动态方法添加到动态方法表(DMT)。VMT 和 DMT 由编译器维护。在每个类中会维 护一个完整的 VMT,用来找到某一特定版本的虚方法;每个类的 DMT 中只维护部分的动态方法表,它使 用了独特的编号系统,可以在其所有祖先的 DMT 中进行查找以找到某一方法的特定版本。较为实际的区 别是:虚方法是为了速度而优化的,动态方法是为了程序的规模而优化的。对可能在子类中重载许多次的 多态方法,Delphi 使用 dynamic 编译器指令,例如 Paint 方法可能被许多 TControl 类型的组件重载。下面 的代码摘录自 classes.pas 中的 TControl 类,示范了静态的、虚的和动态的方法。 procedure SetBiDiMode(Value: TBiDiMode); virtual; procedure SetZOrder(TopMost: Boolean); dynamic; procedure UpdateBoundsRect(const R: TRect); 第 一 行 包 含 了 虚 方 法 SetBiDiMode , 第 二 行 为 动 态 方 法 SetZOrder , 最 后 的 一 行 代 码 中 的 UpdateBoundsRect 为静态方法。如果为改变已有的行为而定义子类,您可能对虚方法和动态方法最感兴趣。 下一节示范了如何利用 override 指令在子类中扩展已存在的行为。 4.6 重 载 方 法 在虚方法和动态方法之间进行区分是编译器的责任。对于我们的讨论,虚方法是指用 virtual 和 dynamic 两种指令之一声明的方法。 声明了虚方法,就意味着某个时候该类被子类化,而方法在子类中被重载。重载方法可能会增强原来 的行为,也可能对其行为进行彻底的改变。这两种情况都可以使用 override 指令。只有在某个祖先中声明 的虚方法才能进行重载。静态方法是无法重载的,但下一节中的 reintroduce 编译器指令可用于在子类中改 变静态方法的行为。当我们谈论多态性时,指的就是虚方法机制。 注意:在讨论时请记住:类的一个完整的对象可以维护其自身的状态,并执行所负责的任务。 第4章 定义多态和动态过程 100 对图形用户控件来说,这些任务之一就是能够在桌面上绘制出自身。 多态行为的一个很好的例子就是在 TCustomControl 所引入的 Paint 方法,它用于响应 Windows 的 WM_PAINT 消息。图形用户界面中的某个控件的绘制方法都与其他控件是不同的。这种彼此不同的行为 意味着,每个 TCustomControl 的子类都需要重载绘制方法。下面列出的代码示范了 override 指令的使用, 以及从不同对象调用方法的结果。 TBaseClass = class public Procedure WhoAmI; virtual; end; TSubClass = class(TBaseClass) public Procedure WhoAmI; override; end; { TBaseClass } procedure TBaseClass.WhoAmI; begin ShowMessage( 'TBaseClass' ); end; { TSubClass } procedure TSubClass.WhoAmI; begin ShowMessage( 'TSubClass' ); end; 从 TBaseClass 类的实例调用 WhoAmI,将出现消息对话框,其中显示了‘TBaseClass’。从 TSubClass 的实例调用 WhoAmI,对话框中出现的文字是‘TSubClass’。下面的代码示范了一种技术,创建了每个类 的实例并调用 WhoAmI 方法。 with TBaseClass.Create do begin WhoAmI; Free; end; with TSubClass.Create do begin WhoAmI; Free; end; 前五行代码在 with 子句中创建了 TBaseClass 类的对象,在块语句中调用了 WhoAmI 并释放了该对象。 后五行代码对 TSubClass 类重复了该过程。 接着,考虑在下面两种情况下会发生什么。首先,声明一个 TBaseClass 类的变量并赋予其 TSubClass 类的实例。调用 WhoAmI 会发生什么呢?下面的代码给出了答案。 var BaseClass : TBaseClass; begin BaseClass := TSubClass.Create; try BaseClass.WhoAmI; finally Free; end; 第4章 定义多态和动态过程 101 第 4 行创建了 TSubClass 类的一个实例。答案是 WhoAmI 调用将显示 TSubClass。从技术上讲,TSubClass 类的实例也是 TBaseClass 类的实例,而 Viper 车也是一种小汽车。实际上,子类的对象具有其所有祖先类 的类型。 4.6.1 Inherited 保留字的使用 有时您可能希望重载方法在子类中彻底的改变行为,有时候则只需扩展已有的行为。当需要扩展已有 的行为时,只需在重载方法中编写附加的行为,然后调用所继承的方法以完成任务即可。通常会首先调用 所继承的方法。可以使用如下两种形式进行调用。 Procedure TMyClass.InheritedMethod( I : Integer ); begin inherited InheritedMethod(I); // do new stuff here end; // alternative use of inherited Procedure TMyClass.InheritedMethod( I : Integer ); begin inherited; // do new stuff here end; 警告:如果忘记使用 inherited 关键字,仍然可以编译,但该过程将进入无限递归循环,对 其自身进行调用直至堆栈溢出为止。堆栈溢出的信息通常意味着无限递归。 提示:应使用较长的形式调用所继承的方法。这对于阅读者可减少歧义。 第一种调用方法在 inherited 语句中显式指定了方法的名字并传递了参数。第二种方法只使用了 inherited 关键字。在第二个例子中,编译器会自动的将该调用解析到正确的方法并填入传递给子类方法的 参数。 4.6.2 重载构造函数 如果父类的构造函数是虚函数,可以在子类中重载构造函数。只需在构造函数声明结尾处添加 override 指令即可。虽然技术上不必调用父类的构造函数,但按照通常的规则应该这样做,否则对象可能会无法完 成构造而不能安全使用。例如,父类可能在堆上为包含的对象分配了内存。如果在父类构造函数运行前访 问父类中的对象,可能会导致访问异常。 注意:事件处理程序 FormCreate 原来是在 TForm 类的构造函数中调用。现在它将在 TObject 类所引入的方法 AfterConstruction 中被调用。 在子类中通常会重载构造函数以对子类中增加的对象分配内存。在 TForm 类的例子中,您可以重载构 造函数,虽然这样做并不必要。只需在窗体的 FormCreate 事件处理程序中添加所要的代码即可。 4.6.3 重载析构函数 应该在子类的构造函数运行前调用继承的构造函数。调用继承构造函数与一般的继承方法的规则是一 致的。对于析构函数,规则是相反的。如果重载了析构函数,则需要最后调用父类的析构函数。如果先调 用了父类的析构函数,有可能释放了仍被子类使用的对象。下面的代码片断示范了析构函数的调用次序。 Destructor TMyClass.Destroy; begin // your code here inherited Destroy; end; TObject 类引入了虚方法 BeforeDestruction,在调用析构函数前调用该方法。 虽然通常只需重载 Destroy, 第4章 定义多态和动态过程 102 但也可重载 BeforeDestruction。BeforeDestruction 中调用了 OnDestroy 事件处理程序,它可以在窗体对象中 使用而不必重载窗体的析构函数。 4.7 重新引入方法 当子类中定义方法时,编译器可能产生警告,提示该方法隐藏了基类中定义的虚方法(如图 4.1 所示)。 图中列出了方法和基类的名字。 图 4.1 当声明一个方法与在父类中的虚方法同名时,编译器将产生警告 在部署模块前消除所有的编译器警告和错误是个好习惯。如果您的本意就是要在子类中引入新的行 为,隐藏已有的行为,那么可以使用{$WARNINGS}编译器指令关闭警告。这个指令是一个确认,表示 程序员已经知道产生了编译器警告,但该行为正是所需要的结果。{$WARNINGS}指令放置在产生警告的 类附近,如下所示。 TMyClass = class Procedure Foo; virtual; end; {$WARNINGS OFF} TMySubClass = class(TMyClass) Procedure Foo; end; {$WARNINGS ON} TMySubClass 是 TMyClass 的子类。TMySubClass.Foo 隐藏了基类的虚方法 TMyClass.Foo。如果这是 所需要的结果,那么{$WARNING OFF}编译器指令将隐藏该警告。即使编译器指令屏蔽了警告,您所要做 的也只是引入新的行为而已;当您需要对父类中定义的方法定义全新的行为时,可使用 reintroduce 指令消 除警告。 可以修改前面的代码以重新引入 Foo 方法的行为,如下所示。 TMyClass = class Procedure Foo; virtual; end; TMySubClass = class(TMyClass) Procedure Foo; reintroduce; end; 移去{$WARNINGS}编译器指令,同时在 Foo 方法的结尾添加 reintroduce 指令,表示该方法将包含新 的行为。如果希望引入新的行为而不再使用继承行为时,可以用 reintroduce 指令。 第4章 103 定义多态和动态过程 4.8 抽 象 类 所谓抽象类,就是至少有一个方法定义为虚抽象方法的类(将编译器指令添加到方法的结尾,先是 virtual,然后是 abstract) 。虚抽象方法定义了无须实例化的类,为子类定义了接口。虚抽象方法可理解为 任何子类都必须实现这些方法。例如,您可以定义哺乳动物类,但实际上并不存在一般的哺乳动物。因此 可将哺乳动物作为抽象类,定义一个接口,包括胎生和哺乳等特征。哺乳动物类的子类必须实现这些方法 (关于抽象类和接口的设计的更多信息参阅读第 7 章。) 4.9 向 前 声 明 forward 指令用于在过程定义前引入过程名。如果过程是全局的,则其声明位于接口部分,而定义位 于实现部分。如果过程是本地的,尽管实现部分包含了定义,但接口部分并没有它的声明。有时两个过程 可能是相互依赖的。过程 A 调用 B,而 B 也调用 A。假定 A 的定义在前而 B 的定义在后。这样,A 在引 入 B 之前使用了 B,导致出现未定义标识符错误。调换 A 与 B 定义的次序,仍然会出现未定义标识符错 误。 通过使用 forward 指令,可以在实现部分声明过程名,即可解决该错误。如下所示。 implementation Procedure A; forward; Procedure B; forward; Procedure A; begin B; end; Procedure B; begin end; 现在无须考虑过程定义的次序,就可以对过程进行调用。假定 A 和 B 有参数,如果在向前声明中声明 了参数,则不必在定义语句中重新声明。如下所示。 implementation Procedure A( I : Integer ); forward; Procedure B( S : String ); forward; Procedure A; begin B( 'Test' ); end; Procedure B; forward; begin A( -1 ); end; 要避免使用缩略形式的定义,原因与避免使用缩略形式对继承方法进行调用相同。键入完整形式的定 义只需要很少的时间,即可根除任何歧义。但要注意到缩略形式还是很重要的,因为其他人可能会使用它。 4.10 小 结 本章示范了如何使用过载方法以及提供默认参数。当算法需要基于所操纵的数据类型进行区分时,应 使用过载方法。当数据值通常是已知的但有可能改变时,应使用默认参数。在简介理论后,本章演示了如 何重载方法、实现继承和多态。继承和多态是面向对象编程中两个最有用的概念。本章中的例子着重于示 第4章 定义多态和动态过程 104 范原理,因而限制了可能的细节数量。 在后续章节中您将有机会来实践这些技术,并将使用一些更高级的技术,如在例子中定义抽象类(在 第 7 章中)来解决理论和实际上的编程问题等。 第 5 章 集合、常数与运行时类型信息编程 定义好的抽象,可以解决许多编程方面的障碍。在面向对象语言中,所谓的抽象就是“创建类”。在 某种程度上确实如此。许多项目的失败就是因为创建的类太少,结果形成了一些庞大的类,它们做的事情 太多,因而难于维护。 好的抽象也可以在较低层次上定义。像 Delphi 这样的强类型化编译器、定义在问题域中有具体意义的 类型等,都可能是有帮助的。一般的,进行较为精确的类型定义,通常可以更好地定义类的属性。大多数 属性的特征可以通过整数和数组进行表达,但相比而言,由内建类型派生而来的集合、范围、常数、数组 以及枚举等更有意义。 Object Pascal 是一种具有很强的表达能力的面向对象编程语言,它有助于定义在特定问题域的上下文 中具有意义的类型。例如,如果只有某个特定范围内的值有意义,就定义范围、集合或枚举类型来命名这 些值所代表的数据。本章示范了如何使用 Object Pascal 中的这些概念,以有助于定义好的抽象。这些技术 可以使您的代码具有更强的可读性,而且比使用内建数据类型所需的错误检查代码要少。 5.1 不可变常数 常数很好用。常数是现存的最为可靠的代码之一。定义常数之后,无论如何其值都是可以依赖的。不 用担心,不存在偶然或有意的误用。在 Delphi 中有许多方法来使用常数,以使得代码更加可靠。 注意:Delphi 支持类型化常数,它们的值是可变的。关于可赋值的常数,更多的信息请参见 5.1.3 节“使用 const 创建静态本地变量”。 5.1.1 全局与本地常数 当变量定义在本地作用域中时,可以访问该作用域的代码均可使用该变量。临时使用全局或本地变量 可能导致有害的问题,特别是对于多线程应用程序,其中一个线程可能依赖于某个值,而另外一个线程正 在改变该值。如果一个值需要保持不变,则应该用 const 来表示。全局常数是定义在单元的接口部分的常 数。而本地常数是定义在实现部分的常数。 另外,可能会在许多地方重用的值应该定义为常数。假定 Pi 的值在您的整个程序中都是有意义的,则 应在接口部分将名字 Pi 作为常数引入,并将其值初始化为具有正确的有效数字位数的 Pi 值,以满足您的 需要。 注意:新的单元 ConvUtils.pas 包含有数以百计的常数和转换单元。尽管它包含了秒差距与 米的转换常数值,但并未包含 Pi 的常数值。System.pas 单元中包含了函数 Pi,返回 Pi 值 的 extended 类型的浮点值。 我们的目标是在尽可能狭窄的作用域中定义常数。如果一个常数只在过程块中需要,那么该过程就是 合适的作用域。使作用域变窄背后的思想在于,要尽量减少使用代码的程序员在理解代码目的时所需要进 行思考的事物的数目。常数的语法部分依赖于其所定义的上下文。本地、全局、过程常数的通常形式如下: const name = value; 或, const name : type = value; const 是关键字,表示其后是常数。对于一个常数列表中的所有常数,仅需要键入 const 一次。例如, 在实现部分定义三个常数,如下所示。 第5章 集合、常数与运行时类型信息编程 108 implementation const I : Integer = 3; S = 'Bachman Turner Overdrive'; F : Double = 4000000000000.0; 有许多途径来使用常数,可以使得程序更加可靠。对于常数的所有变体的语法规则,请察看上下文帮 助,在索引中查找 grammar。更多的例子见下文。 5.1.2 常数参数 当过程不应改变某个参数的值时,应把该参数声明为常数参数。如果包括了 const 限定符,可以保证 该值不被改变。保证总是难于得到,因此能够得到保证确实不错。常数参数可以有默认值。下面的代码演 示了具有默认值的常数参数。 procedure DisplayBandName( const Value : String = 'R.E.O.' ); begin ShowMessage( Value ); end; Procedure SomeProc; const BTO = 'Bachman Turner Overdrive'; begin DisplayBandName; end; DisplayBandName 过程中定义了一个具有默认值的参数。如果不传递参数,Value 参数的值将是 ‘R.E.O.’。如果把常数 BTO 传递给 DisplayBandName,那么 ShowMessage 函数将显示 Bachman Turner Overdrive。常数参数的存在保证了调用的方法不会在无意中改变传递的参数值。使用 const 要远胜于希望 和祈祷。 5.1.3 使用 const 创建静态本地变量 定义在过程中的变量在栈上分配内存空间。常数通过编译嵌入到代码中,只存在于所定义的过程中。 当过程调用或退出时,栈内存空间像手风琴一样来回伸缩。通常,在过程中引入的名字具有过程作用域。 即,该名字和值只在所定义的作用域中可访问。有时,您可能需要各种占位符,即只在过程作用域可访问 的名字,而在过程返回后依然保持其值。C 和 C++称之为静态变量。Delphi 用可赋值常数来产生同样的效 果。 使用下面的语法您可以定义一个变量,它看上去是常数,但实际上是可变的静态变量。 Procedure MutableConst; const I : Integer = 0; begin Inc(I); ShowMessage(IntToStr(I)); end; // ... for I := 0 to 3 do MutableConst; 在上面的 MutableConst 过程中定义一个类型化的可赋值常数,常数的值在该过程的各次调用之间仍然 可以保持。最后一行的 for 语句调用 MutableConst 四次,最后一次调用在 ShowMessage 的对话框中显示值 为 4。默认情况下,类型化常数是可赋值的。可以通过$J+编译器指令进行改变;或者在 Project Options 对 话框中的 Compiler 属性页中改变 Assignable typed constants 复选框,如图 5.1 所示。 第5章 图 5.1 集合、常数与运行时类型信息编程 109 默认情况下,类型化常数是可赋值的,并且可以在对其所定义的过程的后续调用之间维持其值。要使 其不可赋值,对 Project Options 对话框中的 Compiler 属性页的 Assignable typed constants 复选框取消选 定即可。默认情况下,该复选框是选定的 可赋值类型化常数使得可以在过程中定义占位符,每次该过程调用时都可以维护该值。通过使用可赋 值类型化常数,可以模拟静态特性(有关静态特性的更多知识,请阅读第 7 章)。 5.1.4 数组常数 对您的武器库来说,数组常数是另外一项可以添加的工具。也许您不会每天都用到数组常数,但在日 常编程中确实有一些数组常数的例子。考虑下列例子。 Procedure ArrayExamples; const DaysOfWeek : array[1..7] of string = ('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ); MonthsOfYear : array[1..12] of string = ('January', 'February', 'March', 'April', 'May', 'June', 'July', 'August','September', 'October', 'November', 'December' ); EXAMPLE1 = 'February 12, 1966 occurred on a %s'; EXAMPLE2 = 'The fourth month is %s'; var Output : string; Day : Integer; begin Day := DayOfWeek( StrToDate('02/12/1966')); Output := Format( EXAMPLE1, [DaysOfWeek[Day] ] ); ShowMessage( Output ); Output := Format( EXAMPLE2, [ MonthsOfYear[4] ] ); ShowMessage(Output); end; DaysOfWeek 数组包含了 7 个元素,都是字符串。MonthsOfYear 数组包含了 12 个元素,也都是字符串。 两个数组都初始化为常数数组。Begin 块语句的第 2 行使用星期中某天对应的数字来索引数组。第 1 次调 用 ShowMessage 过程的输出为‘February 12, 1966 occurred on a Saturday’。Begin 块语句的第 4 行对该年的 月份执行了一个简单的操作。 当然您也可以把上面的代码写成由嵌套 if 条件测试组成的 case 语句,但常数数组在紧凑的操作中生成 了优化而更小的代码。考虑下一个例子,其中用 if 条件测试来比较激活状态以设置控件的背景颜色,也提 供了用数组实现的相同功能的代码。 第5章 集合、常数与运行时类型信息编程 110 if( Edit1.Enabled = False ) then begin Edit1.Enabled := True; Edit1.Color := clWhite; end else // True begin Edit1.Enabled := False; Edit1.Color := clBtnFace; end; 上面的代码计算了 TEdit 控件(随机选定)的 Enabled 状态,对该状态取反,并相应地设置颜色。该 代码实用而直接,但使用常数数组可使之更为有效。使用常数数组的修订版本如下。 const Colors : array[Boolean] of TColor = (clBtnFace, clWhite); begin Edit1.Enabled := Not Edit1.Enabled; Edit1.Color := Colors[Edit1.Enabled]; end; 常数数组使得我们可以将代码缩减到原来的五分之一。使用单目 not 操作符来执行 Enabled 状态的切 换,用 Enabled 特性的布尔值来索引常数数组 Colors。在使用布尔值作索引时,False 的值较小。上面的代 码更加紧凑,既小且快。下一节的内容是有关记录常数的,阅读时请注意其中一个用到记录数组常数的例 子。 5.1.5 记录常数 记录常数是类型为记录的常量数据。一个很普遍的记录是 TPoint。TPoint 在笛卡尔坐标系中定义了两 个坐标。TPoint 在 Windows.pas 单元中如下定义: TPoint = record x: Longint; y: Longint; end; 要初始化常量记录,需要以 fieldname : value 的形式指定每个字段,每个字段的名字与值用冒号分 隔。这里有一个例子。 const Point : TPoint = (X:100; Y:100); 常量记录数组需要初始化每个数组元素,对于构成记录值的字段,将名字和值对的集合用括弧括起来。 这里是一个由四个点构成的数组。 const Points : array[0..3] of TPoint = ((X:10;Y:10), (X:10;Y:100), (X:100;Y:100), (X:100; Y:10)); Procedure DrawRect( const Points : Array of TPoint ); var I : Integer; begin Canvas.PenPos := Points[0]; for I := Low(Points) to High(Points) do Canvas.LineTo( Points[I].X, Points[I].Y ); Canvas.LineTo( Points[0].X, Points[0].Y ); end; 第5章 集合、常数与运行时类型信息编程 111 由于并未考虑监视器屏幕的高宽比,该数组只是粗略地定义了如图 5.2 所示的正方形。数组 Points 被 传递给代码中的 DrawRect 过程,用以生成如图 5.2 所示的正方形。 图 5.2 利用 LineTo 方法和 TPoint 记录组成的 数组,在窗体的画布上所画出的正方形 通过将一些基本的 Object Pascal 术语联合起来,可以很容易地创建很多种常量数据,用于代表各种各 样的信息。使用记录和数组常数,可以使您的代码更具有表现力。通过把精确定义的数据映射到问题域的 信息,对代码控制得越好,管理数据所需的代码就越少。 5.1.6 过程常数 过程常数是用 const 修饰的名字,其数据类型为过程类型。一般来说,过程类型只是指向过程的指针, 它使得可以把过程和函数赋值给类型与其相匹配的变量和参数。过程类型的更多知识可以阅读第 6 章。一 个过程类型的例子就是定义在 classes.pas 单元中的 TNotifyEvent。下面是 classes.pas 的摘录,给出了 TNotifyEvent 的定义。 type TNotifyEvent = procedure (Sender: TObject) of object; 从列出的代码可以看出,TNotifyEvent 类型是由过程组成的,它们有一个 TObject 类型的参数,名字 为 Sender。类型定义结尾的 of object 表示 TNotifyEvent 类型是被称为方法指针的特定过程类型。 TNotifyEvent 看起来很熟悉,因为它就是 Object Inspector 中许多事件特性的类型。双击空白窗体,Delphi 将为窗体的 OnCreate 事件特性创建如下的空方法定义。 procedure TForm1.FormCreate(Sender: TObject); begin end; 注意:按照惯例,Delphi 使用 On 前缀表示该特性为事件特性。On 暗示着对动作的响应,即 事件。 代码中的 TForm1.部分表示该方法属于 TForm1 类。FormCreate 表示它是 OnCreate 事件的处理程序, 将类名和方法名从定义中剥离,余下的就是 procedure (Sender:TObject),恰好可以与 TNotifyEvent 匹配。 在 Delphi 中选择 OnCreate 事件特性,按键 F1,将显示 CustomForm.OnCreate 的上下文帮助。帮 助文档清楚地指出,OnCreate 定义为特性 OnCreate : TNotifyEvent; 即类型为 TNotifyEvent 的特性。 5.1.7 指针常数 无论使用任何语言,我们的思维都只是受限于用以表达思想的语言以及我们对它掌握的熟练程度。一 些术语可能不像其余的那样常用。指针常量就是其中的一个。在日常的 Windows 应用编程中,指针可能不 太常用,不过 Delphi 并不限制您只能编写典型的 Windows 风格的程序。指针常量就是指向特定地址的指 针。下面的代码琐碎而令人困惑,但它演示了与指针常量有关的必要机制。 type TDateTimeFunc = function : TDateTime; const NowP : Pointer = @SysUtils.Now; var 第5章 集合、常数与运行时类型信息编程 112 MyNow : TDateTimeFunc absolute NowP; 第 2 行定义了一个过程类型,它是指向函数的指针,返回 TDateTime 值。常量指针被初始化为 SysUtils.pas 中定义的 Now 函数地址。变量 MyNow 类型为 TDateTimeFunc(在类型部分定义),将编译为 SysUtils.Now 函数的绝对地址。 5.1.8 用于初始化常量的过程 在前面关于过程常数的章节中,您已经知道过程类型可用于定义常数并初始化为某一特定的过程。对 于前一节中的例子,无须使用 Pointer 即可定义对 SysUtils.Now 函数的常量引用。 type TDateTimeFunc = function : TDateTime; const ConstNow : TDateTimeFunc = SysUtils.Now; 调用 ConstNow 与调用 SysUtils.Now 具有相同的效果。由于 Delphi 支持过程类型,因此用这种形式定 义指向过程的变量或常数更为可取。无论对于哪种类型,均可使用 Pointer 来指向一个特定的内存地址。 5.2 枚举的使用 枚举是代表值的名字列表。如果使用枚举更有意义,那么它比内建数据类型更为可取。枚举的一个完 美的例子是 TFontStyle。有四种基本的字体风格:黑体、斜体、加下划线的、加删除线的。很清楚,可以 用整数来存储字体风格的状态,但对特定风格进行列表更有意义。graphics.pas 单元如下定义了 TFontStyle 类型。 type TFontStyle = (fsBold, fsItalic, fsUnderline, fsStrikeOut); 定义 TFontStyle 使得程序员可以声明 TFontStyle 类型的变量,保证了所有赋予 TFontStyle 类型变量的 值都是有效的,而且不必进行访问检查和错误检查。由于编译器是强类型的,只有四个可能值之一才能赋 予 TFontStyle 变量。简言之,所有困难的工作都由编译器来做,因而代码对于程序员更加可读。 5.2.1 用枚举定义数组边界 按照经验规则,相对于原始的数组,TList 和 TCollection 更为可取。回顾使用对象而不是数组来存储 数据的原因:表和集合类可以动态地伸缩,其中包括了范围检查及其他一些功能,如排序和查找等。使用 数组则需要实现所有这些功能。 有些情况下,您可能需要使用数组。除去索引范围检查的一种方法是:使用枚举作为索引的类型,从 而使数据的范围精确化。这里有一个例子演示了 TNote 类型,它使用 Windows API 函数 Beep 来播放一个 音符(音符的频率和长短是模拟的) 。 type TNote = (doDo, doRe, doMi, doFa, doSo, doLa, doTi, doDo2 ); Procedure PlayNote( Note : TNote ); const DoReMi : array[TNote] of Integer = ( 500, 600, 700, 800, 900, 1000, 1100, 1200 ); begin Windows.Beep( DoReMi[Note], 750 ); Sleep(250); end; Procedure PlayNotes; var I : TNote; begin 第5章 集合、常数与运行时类型信息编程 113 for I := Low(TNote) to High(TNote) do PlayNote( I ); end; 第二行定义了一个枚举类型 TNote,包含八个元素。PlayNote 过程使用 Low 和 High 函数得到值的范 围,然后对每个元素进行迭代。可以注意到索引 I 定义为 TNote 类型而不是整数。每次循环时,都以当前 枚举值为参数调用 PlayNote 过程。PlayNote 过程参数为 TNote 类型,过程中定义了一个索引为 TNote 类型 的常量数组,并使用 Windows API 函数 Beep(并非 Delphi 所提供的版本)来播放每个音符对应的频率。 请注意并不需要范围检查,因为对于数组 DoReMi,所有可能的 TNote 值都是可行的,因为它们都受到枚 举范围的限制。 定义枚举和其他精确化的类型具有累积效应。需要调试和测试的代码会逐渐减少,您的代码将更有效 地运行。 5.2.2 预定义枚举类型 有许多预定义的枚举类型,实际上,枚举类型太多以至于无法全部涵盖。这种类型的参考手册最好留 给在线帮助文档去作。如果您已经习惯于用以控制组件行为的枚举类型,就可以更加出色地控制 VCL。以 TControlStyle 为例。所有的控件都具有 ControlStyle 特性,该类型定义为枚举值的集合(有关集合操作和 定义,更多的信息请参见下一节)。 type TControlStyle = set of (csAcceptsControls, csCaptureMouse, csDesignInteractive, csClickEvents, csFramed, csSetCaption, csOpaque, csDoubleClicks, csFixedWidth, csFixedHeight, csNoDesignVisible, csReplicatable, csNoStdEvents, csDisplayDragImage, csReflector, csActionClient, csMenuEvents); ControlStyle 特性可以具有零个、一个或多个定义在上述枚举中的值。例如,如果 ControlStyle 具有 csAcceptsControls 值,则该控件就可以拥有其他的控件。例如窗体可以拥有控件,但默认情况下,栅格控 件是无法拥有其他控件的。 对于已有的控件,其行为是由作者定义的。但您总是可以子类化一个控件,根据您的需要调整其行为。 例如,下面的类定义了 TControlGrid 类型,示范了如何在栅格单元中拥有其他控件(如图 5.3 所示)。 图 5.3 可以在栅格单元中拥有其他控件的栅格控件 TControlGrid = class(TStringGrid) private FButton : TButton; protected Procedure WMCommand( var Message : TWMCommand ); Message WM_COMMAND; Procedure OnClick( Sender : TObject ); Procedure Paint; override; public constructor Create( AComponent : TComponent); override; destructor Destroy; override; end; 第5章 集合、常数与运行时类型信息编程 114 注意:如果在设计时可以在栅格上绘制出控件,TControlGrid 类就可以更有用。使用上面以 及下面列出的代码,即可完成该栅格控件,从而可以在设计时和运行时动态地拥有控件。关 于栅格组件的演示,参见 http://www.softconcepts.com/demos/componentgrid.htm。演示 会下载一个 ActiveX 控件到您的 PC,该控件示范了一个全功能的栅格控件。 该类包含一个 private 字段 FButton,用于示范拥有控件的功能。protected message 方法重载了 WM_COMMAND 的信息处理程序。这使得被拥有的控件可以接收到发送给它们的消息,但 TStringGrid 并未如此,因为在原来的设计中 TStringGrid 是无法拥有父控件的。OnClick 事件处理程序用于演示 TButton 控件对用户输入的响应。构造函数和析构函数中重载了 ControlStyle 特性,并对 TButton 控件进行分配和 释放。 { TControlGrid } constructor TControlGrid.Create(AComponent: TComponent); begin inherited Create(AComponent); ControlStyle := ControlStyle + [csAcceptsControls]; FButton := TButton.Create(Self); FButton.Parent := Self; FButton.BringToFront; FButton.OnClick := OnClick; Repaint; end; Procedure TControlGrid.Paint; begin inherited Paint; FButton.Visible := (LeftCol = 1) and (TopRow = 1); FButton.Enabled := FButton.Visible; FButton.BoundsRect := CellRect( 1, 1 ); end; destructor TControlGrid.Destroy; begin FButton.Free; inherited; end; procedure TControlGrid.OnClick(Sender: TObject); begin MessageDlg('Greetings Earthlings!', mtInformation, [mbOK], 0); end; Procedure TControlGrid.WMCommand( var Message : TWMCommand ); begin inherited; if( ControlCount > 0 ) then FindControl( Message.Ctl ).Dispatch(Message); end; 构造函数 ControlGrid 调用 TStringGrid 的构造函数,创建按钮,并为按钮的 OnClick 事件设置处理程 序。Paint 方法被重载,用于在单元 1 中绘制该单元的控件(1 单元可见时) 。对于动态的控件,跟踪该控 件属于哪一个单元是必要的。析构函数释放了按钮,并调用 TStringGrid 的析构函数。当单击按钮时,OnClick 方法将显示友好的问候。最后,WMCommand 方法响应所有的消息,首先调用继承的消息处理程序,然后 对拥有的控件分发消息(消息处理程序的更多知识请阅读第 6 章)。 在逐渐掌握 Delphi 的体系结构之后,无论编写简单或还是复杂的控件,都可以利用已有类的属性进行 第5章 集合、常数与运行时类型信息编程 115 简化,这样可以使程序更加灵活、实用。 5.2.3 用于枚举类型的过程 有一些函数是专门设计用于枚举类型的。表 5.1 列出用于枚举类型的过程,并描述了每个过程所执行 的操作。 表 5.1 用于枚举类型的过程 过程 描述 Ord 返回整数,表示与其位置相关的枚举值 Pred 返回在传给函数的值之前的枚举值 Succ 返回传给函数的值的下一个枚举值 High 返回最大的枚举值 Low 返回最小的枚举值 枚举是有序类型,基于在类型定义中的出现次序,枚举元素自动地分配连续值,从第一个位置的 0 开 始到最后一个位置的 n-1 结束。例如,可以使用 High 和 Low 函数得到枚举的上界和下界。如果包含了运 行时类型信息(RTTI),则枚举的符号名也可以得到。下面列出的程序演示所有的五个枚举函数,以及如 何包含 RTTI 信息。 uses typinfo; {$M+} type TEnums = ( Enum0, Enum1, Enum2, Enum3, Enum4); {$M-} procedure ShowEnum( Enum : TEnums ); const MASK = '%s=%d'; var Name : String; Value : Integer; begin Name := GetEnumName( TypeInfo(TEnums), Ord(Enum) ); Value := GetEnumValue( TypeInfo(TEnums), Name ); ShowMessage( Format( MASK, [Name, Value] )); end; procedure TestEnumerated; begin ShowEnum( Enum3 ); ShowEnum( Pred( Enum3 )); ShowEnum( Succ( Enum3 )); ShowMessage( IntToStr(Ord( Enum4 )) ); ShowEnum( Low(TEnums)); ShowEnum( High( TEnums )); end; 注意:尽管 Delphi 本身也使用了 GetEnumName 和 GetEnumValue 函数,但由于某些原因,它 们并未包括在上下文相关帮助中。RTTI 用于特性的读写,以及 OpenTools API(关于 OpenTools API,请参考附录 A)。 第一行的 uses 语句表示应包括 typinfo 单元。该单元包括了与运行时类型信息相关的过程,其中就有 在本例中用到的 GetEnumName 和 GetEnumValue。第四行定义了枚举类型 TEnums,该定义包含在{$M+} 和{$M-}编译器指令之间。{$M}指令指示编译器对 TEnums 类型加入运行时类型信息。 第5章 集合、常数与运行时类型信息编程 116 第六行开始的 ShowEnum 过程示范了如何使用 typinfo.pas 中所定义的 GetEnumName 和 GetEnumValue 过程。这两个过程的第一个参数是指向 TTypeInfo 记录的指针。如代码所示,将枚举类型的名字传递给 TypeInfo 函数将返回指向类型信息记录的指针。GetEnumName 中的第二个参数是枚举类型中某个特定元素 所对应的有序数值。GetEnumValue 则根据类型信息记录和枚举元素的名字返回对应的有序数值。 TestEnumerated 过程的输出如下: ShowEnum( Enum3 ); // outputs Enum3=3 ShowEnum( Pred( Enum3 )); // outputs Enum2=2 ShowEnum( Succ( Enum3 )); // outputs Enum4=4 ShowMessage( IntToStr(Ord( Enum4 )) ); // outputs 4 ShowEnum( Low(TEnums)); // outputs Enum0=0 ShowEnum( High( TEnums )); // outputs Enum4=4 某些低层的 VCL 过程使用了运行时类型信息。在创建组件和枚举时,运行时类型信息特别有用。枚 举可以使代码更加健壮、富于表现力、可读性好。 5.3 集 合 操 作 集合通常表示一组相关的事物,如一组塔珀家用塑料制品或一组高尔夫球棍。集合操作是人类最早了 解的数学知识之一(至少在美国和西欧是这样) 。至少有三十年了,Sesame Street 一直教着这首歌“Which one of these things is not like the other? Which one of these things just doesn’t belong?”即,哪些是集合的成员 而哪些不是?集合在实际的世界中是很常见的,因此,很自然的,在抽象世界中应存在这样的术语,使得 开发者可以表达集合的概念并对其进行算术操作。在 Object Pascal 中确实如此,我们只需将自己的理解映 射到 Delphi 中的集合实现即可。 5.3.1 理解集合以及 set of 语句 集合是同一有序类型的值的聚集。集合的例子有:所有整数的集合、某个枚举类型中所有元素的集合 或彩虹中所有元素的集合。在 Object Pascal 中的集合的大小限定到一个字节,这意味着一个特定集合的基 类型必须限制到少于 256 个元素,而其有序值必须在 0 到 255 之间。集合的值的范围是其基类型的幂集, 幂集就是包括空集在内一个集合的所有可能的子集的集合。定义集合的语法是:SetType -> SET OF OrdinalType,其中 OrdinalType 定义为:OrdinalType -> (SubrangeType | EnumeratedType | OrdIdent)。就是说, 集合定义在单元的类型部分,将一个名字与子界类型、枚举类型或有序类型的集合联系起来即可。 OrdinalType 所涉及的各种类型示范如下。 TRangeSet = set of 0..255; TCharSet = set of char; TPrimaryColors = set of (pcRed, pcBlue, pcGreen); TRangeSet 示范了一个由整数构成的子界集合,其值由 0 到 255。TCharSet 定义了由三原色构成的枚 举值的集合。定义集合类型后,则集合实例都是一些子集,其中包含了一些该类型的元素。 5.3.2 使用集合构造器 set of 语句定义了一个类型,其中包括从某个有序类型而来的一个有限范围内的值。集合类型的变量 可以是受类型定义约束的幂集的任一元素。要初始化集合类型的实例,可以使用集合构造器。 集合构造器由[ ]标识,其中包含一些由逗号或..分隔的值。考虑上一节的 TCharSet 集合。要构造一个 包含大写字母的 TCharSet 变量,使用如下代码即可。 var UpperCaseChars : TCharSet; begin UpperCaseChars := ['A'..'Z']; // ... 第5章 117 集合、常数与运行时类型信息编程 end; 变量 UpperCaseChars 是 TCharSet 的一个子集,初始化时包含了所有的大写字母。由于 UpperCaseChars 定义为变量,因此可以向集合添加成员。要使 UpperCaseChars 只包含大写字母字符,可以将其定义为常数 并使用$J-编译器指令使该常量不可赋值,或者定义一个只包含大写字母的集合类型。 const {$J-} UpperCaseChars : TCharSet = ['A'..'Z']; {$J+} 或者 type TUpperCaseChars = set of 'A'..'Z'; 提示:默认情况下,Delphi 中的类型化常数是可写的。要限制 UpperCaseChars 只包含从‘A’ 到‘Z’的字母,可以重新定义集合类型或者把可写类型化常数用编译器指令包裹起来,如 下所示: {$J-} const UpperCaseChars : TCharSet = ['A'..'Z']; {$J+}。 现在,UpperCaseChars 是个只包含字符 A 到 Z 的不可赋值的常数,而 TUpperCaseChars 类型的范围则 限制为字符 A 到 Z。可定义 TUpperCaseChars 类型的变量,例如: var UpperCaseChars : TUpperCaseChars; 这样 UpperCaseChars 的值就隐含地限制到了从‘A’到‘Z’。回想一下,默认情况下 Delphi 中的类型 化常数是可写的(请参考上面的提示,其中简短地讨论了对代码的可能修改,从而使其目的性更强)。为 包括大写字母和小写字母,扩展上面的集合构造器以包含小写字母。 const AlphabeticChars : TCharSet = ['A'..'Z', 'a'..'z']; 现在 TCharSet 变量 AlphabeticChars 包含了所有大写和小写的字母字符。为简化集合代数,有许多操 作符可以执行集合算术运算。 5.3.3 集合操作符 表 5.2 包含了集合的算术操作符的完整列表,并描述了对集合进行的操作。所有的集合运算,其结果 或者为布尔值,或者为新的子集。 表 5.2 集合操作符与结果类型,除了 in 以外,所有的集合操作符的两个操作数都是集合 操作符 操作 结果 例子 + 并 集合 Set1 + Set2 - 差 集合 Set1 – Set2 * 交 集合 Set1 * Set2 <= 是…的子集 布尔值 Set1 <= Set2 >= 是…的超集 布尔值 Set1 >= Set2 = 相等 布尔值 Set1 = Set2 <> 不等 布尔值 Set1 <> Set2 in 是…的成员 布尔值 Ordinal in Set1 下面列出的代码示范了对四个集合执行的集合操作,它们都定义为字符集合的子集。 type TCharSet = set of char; 第5章 集合、常数与运行时类型信息编程 118 const A : TCharSet = ['A'..'M', 'R', 'S', 'U']; B : TCharSet = ['B', 'G', 'H', 'L'..'Z']; SubsetA : TCharSet = ['A'..'G']; SupersetA : TCharSet = ['A'..'M', 'R', 'S', 'U', 'V']; Procedure TForm1.DisplayResultset( OperationName : String; const CharSet : TCharSet ); var I : Char; Count : Integer; begin Memo1.Lines.Add( '***' + OperationName + '***' ); Count := 0; for I := Low(Char) to High(Char) do if( I in CharSet ) then begin Memo1.Lines.Add(I); Inc(Count); end; Memo1.Lines.Add( '*** Elem Count: ' + IntToStr(Count) + ' ***' ); end; Procedure TForm1.SetTests; const BOOLS : array[Boolean] of String = (' is False', ' is True' ); begin Memo1.Clear; DisplayResultSet( 'union', A + B ); DisplayResultSet( 'difference', A - B ); DisplayResultSet( 'intersection', A * B ); Memo1.Lines.Add( 'A < SubSetA (Not A >= SubSetA)' + BOOLS[ Not (A >= SubSetA)] ); Memo1.Lines.Add( 'A > SuperSetA (Not A <= SuperSetA)' + BOOLS[ Not (A <= SuperSetA) ] ); Memo1.Lines.Add( 'A <= SupersetA' + BOOLS[ A <= SuperSetA ] ); Memo1.Lines.Add( 'A >= SubsetA' + BOOLS[A >= SubSetA] ); Memo1.Lines.Add( 'A = B' + BOOLS[A = B ] ); Memo1.Lines.Add( 'A <> B' + BOOLS[A <> B] ); Memo1.Lines.Add( '''A'' in B' + BOOLS[ 'A' in B ] ); end; 类型声明部分定义了一个由字符组成的集合类型。常数声明部分包含了四个集合的定义,它们是字符 集合的子集。集合 A 和 B 是不同的集合,SubsetA 初始化为集合 A 的子集,而 SuperSetA 初始化为集合 A 的超集。DisplayResultSet 方法使用 in 操作符来测试某个特定的字符是否是结果集合的成员。例子的输出 如下。 · A 与 B 的并集 A+B 是所有大写字母字符的集合,因为所有的大写字母字符或者在 A 中或者在 B 中。 · A 与 B 的差集 A-B 包含字母 A、C、D、E、F、I、J、K,因为 A 的这些元素不是 B 的成员。B-A 则得到一个不同的结果集合。 · A 与 B 的交集 A*B 包含字母 B、G、H、L、M、R、S、U,因为 A 和 B 中均包含这些元素。 · 如果 SubSetA 是 A 的子集,则用 Not (A >= SubSetA)实现的 A < SubSetA 结果为 False。 第5章 集合、常数与运行时类型信息编程 119 如果 SuperSetA 是 A 的超集,则用 Not (A <= SuperSetA)实现的 A > SuperSetA 结果为 False。 A <= SuperSetA 的结果为 True,因为 SuperSetA 中包含所有 A 中的元素以及‘V’。 A >= SubSetA 的结果也是 True,因为 SubSetA 中只包含 A 的部分元素。 A = B 结果为 False,因为 A 中的所有元素都不在 B 中。 A <> B 结果为 True(见前一项条件测试 A = B) 。 字符‘A’不是集合 B 的成员,因此‘A’in B 结果为 False。 · · · · · · 对谓词与命题的演算,定义了用于集合的逻辑操作和谓词语句的代数规则。但不要假定用户也上过离 散数学的课程,应该考虑把繁复的集合操作分解为对中间结果进行运算的单一的集合操作。 集合代数演算 基本的四个代数定律对集合逻辑也是适用的。这意味着集合具有传递性,即如果 A = B 而且 B = C 则 A = C;集合具有对称性,即 A = A;集合具有交换性(对集合的差不适用),即 A + B = B + A;集合具有 分配性,A * B + A * C = A * ( B + C )。如果需要,可以用这四个基本的数学定律简化复杂的集合等式。下 列代码示范了集合的分配律、对称律以及交换律,这些定律都是对基类型为 byte 的数集验证的。 type TSet = set of byte; const Set1 : TSet = [1, 2, 3, 4]; Set2 : TSet = [3, 4, 5, 6]; Set3 : TSet = [5, 6, 7, 8]; procedure DisplayResult( ASet : TSet ); var I : Byte; begin for I := Low(Byte) to High(Byte) do if( I In ASet ) then Form1.Memo1.Lines.Add( IntToStr(I)); end; procedure SetTest; const BOOLS : array[Boolean] of string = ('False', 'True'); var ResultSet : TSet; begin // Distributive Law // ResultSet := (Set1 * Set3) + (Set2 * Set3); ShowMessage( BOOLS[ Set3 * (Set1 + Set2) = (Set3 * Set1) + (Set3 * Set2)] ); ResultSet := Set3 * (Set1 + Set2); DisplayResult( ResultSet ); // reflexive A + B = B + A ShowMessage( BOOLS[Set1 + Set2 = Set2 + Set1] ); ShowMessage( BOOLS[Set1 - Set2 = Set2 - Set1] ); // symmetric A = A ShowMessage( BOOLS[Set1 = Set1] ); end; 注意:SysUtils.pas 包含了两个方法 StrToBool 和 BoolToStr,可以对字符串与布尔值之间 进行转换。 第5章 集合、常数与运行时类型信息编程 120 代码开头的类型声明将 TSet 类型定义为基类型为 byte 的集合。 三个集合 Set1、Set2 和 Set3 定义为 TSet 类型的集合,包含了小于 10 的一些特定的单字节整数(这里有意地使集合的成员较为简单,因此您可以 在头脑中完成集合操作) 。过程 DisplayResult 接受一个 TSet 类型的参数,并对参数中所有的单字节整数进 行迭代,直到将集合的所有成员都添加到 TMemo 类型的变量。过程 SetTest 中定义了一个以布尔值为索引 的常量数组,用来提供与布尔值相对应的字符串值。第一组语句通过演示 Set3 * (Set1 + Set2) = (Set3 * Set1) + (Set3 * Set2),示范了集合的分配律。第二组语句对集合并操作示范了交换律,并显示 True,但 ShowMessage 对话框对 Set1 – Set2 = Set2 – Set1 显示了 False。最后,对称律是显然的。 集合成员测试 in 操作符用于测试集合的成员资格。它是个双目操作符。其左侧是有序类型值,右侧是集合。语法形 如 ordinal In Set,可读作“该有序类型值是否是 Set 集合的成员”。从 5.2.2 节“预定义枚举类型”可知, 能够编写一个条件测试以判断某个控件是否可以拥有其他控件。 if( csAcceptsControl in ControlStyle ) then // perhaps assign the parent property of the control 对于判断对象的状态来说,in 操作符是很有价值的,特别是控件在生命周期中进行转换时。由于 in 操作符需要左侧的有序类型的操作数,因此它不能用于判断是否多个元素是某个集合的成员。对于判断多 个元素的成员资格问题,仍然需要集合代数。 测试集合的交集可用于判断是否两个集合中都包含两个或多个元素。给出两个集合[1, 2, 3]和 ASet, 都定义为单字节整数的集合;如果变量 ASet 包含子集[1, 2, 3],则交集语句结果为真。 if( [1, 2, 3] * ASet = [1, 2, 3] ) then // True test code here *(交集操作符)等价于逻辑与测试。+(并集操作符)等价于逻辑或测试。 5.3.4 Include 和 Exclude 过程 如果觉得集合代数较为深奥的话,您可以使用 System.pas 单元中定义的 Include 和 Exclude 过程。Include 和 Exclude 过程有两个参数,并在第一个参数中返回修改后的集合。过程定义如下。 procedure Include( var S : set of T; I : T ); procedure Exclude( var S: set of T; I : T ); 把要添加或删除元素的集合以及对应的元素作为参数传递,过程即可返回结果集。Include 过程等价于 S := S + [I] 而 Exclude 等价于 S := S – [I]。Delphi 的帮助文档中指出,使用 Include 和 Exclude 过程与冗长 的集合代数语句相比可以生成更为高效的代码。如果我们在前一节中使用 TSet 进行演示,则可以向 TSet 变量中添加或删除成员,如下所示: var ASet : TSet; // TSet = set of byte; begin ASet := [4, 5, 6]; // set construction Include( ASet, 8 ); Exclude( ASet, 6 ); end; 在前一节中 ASet 定义为 TSet 类型的变量(回忆一下,TSet 定义为单字节整数的集合)。ASet 初始化 为[4, 5, 6]。调用 Include 在 ASet 中添加了元素 8,而调用 Exclude 在集合中删除了元素 6。结果集为[4, 5, 8]。 如果试着加入与集合的基类型不符的元素,则该操作会被忽略。对于删除也是如此,删除集合中不存在的 成员,操作同样会忽略。 第5章 集合、常数与运行时类型信息编程 5.4 121 掌 握 数 组 数组存在于较早的 Object Pascal 代码中,在较新的代码中偶尔也会用到。可能的情况下,最好使用 TList 或 TCollection。但您仍然会用到数组,它们对于新的代码还是很有用的。这里涉及了可能出现的与上下文 相关的数组的变体。 5.4.1 数组异常 数组定义了索引的范围,限定了数组中可能的元素数目以及每个元素的索引值。因此 Delphi 中的数组 可能有任何起始索引和结束索引,而不限制以 0 或 1 为基点。如果数组索引越界,就会发生异常。如果 Project Options 对话框中的 Compiler 属性页上的 Range checking 复选框被选中,如图 5.4 所示,则在运行时会引发 ERangeError 异常。如果没有选中 Range checking 设置,程序可能会引发 EAccessViolation 异常,也可能不 出现异常。至少会引发访问违例,这样您就知道内存被重写了。 图 5.4 当调试和测试程序时,请设置 Project Options 对话框中 Compiler 属性 页上的 Range checking 复选框。这对于编译后的程序可能增加一些额 外的开销,但您可以在程序没有错误并准备发行时,取消这一选项 var S : array[1..10] of strings; I : integer; begin I := 11; S[I] := 'Delphi 6 Developer's Guide'; S[11] := 'Written by Paul Kimmel'; end; 即使 I 是无效的索引,S[I]一行仍然可以编译,但可能在运行时引发异常。而直接使用索引值 11 的第 二行则不能编译。无论是否选中边界检查选项,第二种类型的过界违例都会导致编译错误。 选中边界检查选项,编译器将向编译后的应用程序添加代码,因此在调试和测试时是个不错的选项。 一旦已经根除了所有的边界错误,当发行应用程序时就可以取消边界检查选项。请记住,访问违例可能是 内存泄漏或内存重写,但它比这两种错误害处更大。选中 Project Options 对话框中的 Range checking 复选 框可以在整个程序中添加边界检查代码,而使用{$R}编译器指令则可以在相关编译器指令之间添加局部的 第5章 集合、常数与运行时类型信息编程 122 边界检查代码(例子请参见 5.3.2 节“使用集合构造器”)。 5.4.2 定义子界值 子界可以直接定义,也可以作为命名类型定义。直接定义的子界语法形如 n .. n+m,其中 n 和 m 为有 序类型。这样数组就可以使用诸如字符、布尔值、枚举或整数等类型值进行索引。为使代码更加简练,通 过使用一些技巧,您可以将类型定义为某个范围的值。例如您需要有 10000 个索引的数组,可以使用子界 1..10000。将子界定义为命名类型,可以使代码可读性更好。 type TIntegerRange = 1..10000; var Ints : array[TIntegerRange] of Integer; 注意:对数组大小的限制是 2G 字节。因此无法使用整数作为索引类型创建栈数组,因为索 引值有 2 147 000 000(超过 20 亿)个。即使以整数为索引的字符数组,都已经超过了 2G 字节的限制。 前面列出的声明使得可以使用整数值索引数组,而不必超出 2G 字节的数组大小限制。Delphi 中的子 界可以从任意下界到任意上界,只要上界值大于下界值即可。这样,既无须像 Visual Basic 一样指定数组 基点选项为 0 或 1,也不需要像 C 和 C++一样受限于 0 基点的数组。 5.4.3 使用类型减少边界错误 为减少对数组进行索引时的越界错误,可以使用枚举类型、类型别名或新的类型以确保所有的索引都 在可接受的范围之内。 type TEnums = (Enum1, Enum3, Enum4); TAlias = byte; TChar = type Char; 提示:可以使用 type newtype = type oldtype 来引入新的、独立的类型,它由通用数据类 型派生而来,但在问题域中可能比后者具有更直观的意义。强类型的 Pascal 编译器将对 var 和 out 参数强制实施类型兼容检查,但对参数类型较为陈旧的过程可允许进行强制类型转换。 TEnums 定义了可用来索引数组的枚举类型。这种索引方式比简单的使用整数更加易于理解。TAlias 是 byte 类型的别名。以 TAlias 为索引类型的数组与索引类型为 byte 的数组是等价的。第三个类型定义引 入了一个新的类型 TChar,它不是别名而是类型,编译器将对该类型进行强制性类型兼容检查。 在 type 语句中=的右侧使用 type 将引入新的类型(参见前面列出的代码)。因此,例子中的 TChar 与 char 是不同的类型。对需要新类型参数的过程,编译器将对类型进行强制性检查,当实际类型与 var 或 out 参数的类型不匹配时,则无法继续编译。过程 ArrayRange 示范了如何定义使用引入的类型作为索引值的数 组。 Procedure ArrayRange; type TEnums = (Enum1, Enum3, Enum4); TAlias = byte; TChar = type Char; var E : array[TEnums] of string; A : array[TAlias] of char; C : array[TChar] of byte; begin E[Enum1] := 'An enumerated index.'; A[255] := #65; 第5章 集合、常数与运行时类型信息编程 123 C['C'] := 255; ShowMessage( E[Enum1] ); ShowMessage( A[255] ); ShowMessage( IntToStr(C['C']) ); end; 当使用类型别名时,至少可以提高代码的可读性。如果使用引入的新类型,则编译器可以进行一些帮 助。对于数组索引,请尽可能使用枚举、类型别名和新的类型,而不要使用内建类型。 5.4.4 下界与上界函数 可以使用 Low 和 High 两个函数,以确保使用数组时索引没有越界。Low 函数有一个无类型参数,返 回数组的下界。例如 var S : array[5..7],当把 S 传递给 Low 时,结果为 5。High 返回数组的上界。 注意:Low 和 High 函数对数组和有序类型总是可以正确工作,包括枚举类型在内。 可以使用 Low 和 High 函数来代替直接写出的常数值。这样就无须记忆数组的上下界,而且 即使改变了数组的上下界代码仍然是正确的。在下面的代码实例中,分别示范了使用和不使用 Low 和 High 函数的数组索引用法。 Procedure IndexingExample; type TLimit = 1..100; var OldStyle : array[1..100] of Integer; PreferableStyle : array[TLimit] of integer; I : Integer; J : TLimit; begin for I := 1 to 100 do OldStyle[I] := I; for J := Low(TLimit) to High(TLimit) do PreferableStyle[J] := J; end; 在上面的例子中,OldStyle 数组的索引是直接定义的子界 1..100。begin end 块语句对索引进行了硬编 码。如果改变了 OldStyle 的边界,代码就出错了。PreferableStyle 数组使用了类型别名作为索引类型,并 使用 Low 和 High 边界函数,这样即使改变了 TLimit 的范围,也可以双重的保证不会出现错误的索引值。 5.4.5 开放数组参数 定义过程时,对指定类型或可变类型的数组参数可以不指定其索引范围。开放参数字符数组的例子是 C : array of char。元素的个数是未知的,每个元素都是字符。使用上一节的 Low 和 High 函数可以得到索引 的上下界。可变类型的参数数组可以写作 V : array of const。前者较为容易使用,因为基类型是已知的,只 需要判断索引的范围。而后者,即可变类型的数组,则需要进行类型检查和边界检查。 类型化参数数组 在类型化数组参数的声明中,指明了数组元素的类型。例如,整数数组可以写作 IntArray : array of Integer。下面的排序算法以整数数组为参数,对其元素进行了简单的冒泡排序。 提示:不能把常数传递给开放数组参数。 第5章 集合、常数与运行时类型信息编程 124 procedure BubbleSort( var IntArray : array of Integer ); var I, J, Temp : integer; begin for I := Low(IntArray) to High(IntArray) - 1 do for J := I + 1 to High(IntArray) do if( IntArray[I] > IntArray[J] ) then begin Temp := IntArray[J]; IntArray[J] := IntArray[I]; IntArray[I] := Temp; end; end; 过程的定义示范了如何定义类型化参数数组(您可能已经知道冒泡排序算法)。使用有任意个元素的 整数数组调用 BubbleSort,都可以正确对数组进行排序。 我们将详细说明 BubbleSort 的这个简单实现,以便继续讨论类型化数组参数。考虑一下按降序对数组 进行排序(上面的代码按升序对数组排序)。很显然,逆转条件测试用小于比较代替大于比较,就可以实 现另一个版本的排序过程。这种技术是显然的,但并不高级。考虑到两种版本的排序中只有一行代码是不 同的,那就可能只对那一行代码进行修改。考虑对排序过程的如下修订。 type TCompareProc = Function(Elem1, Elem2 : Variant ) : Boolean; Function GreaterThan( Elem1, Elem2 : variant) : Boolean; begin result := Elem1 > Elem2; end; Function LessThan(Elem1, Elem2 : Variant ) : Boolean; begin result := Elem1 < Elem2; end; procedure Swap( I, J : Integer; var IntArray : array of Integer); var Temp : Integer; begin Temp := IntArray[I]; IntArray[I] := IntArray[J]; IntArray[J] := Temp; end; procedure BubbleSort( var IntArray : array of Integer; CompareProc : TCompareProc ); var I, J : integer; begin for I := Low(IntArray) to High(IntArray) - 1 do for J := I + 1 to High(IntArray) do if( CompareProc( IntArray[I], IntArray[J] )) then Swap( I, J, IntArray ); end; 第5章 集合、常数与运行时类型信息编程 125 类型声明部分定义了过程类型 TCompareProc,该类型的变量值,可以是任何具有两个可变类型参数、 返回布尔值的函数。紧接着的两个函数 GreaterThan 和 LessThan,恰好与 TCompareProc 的原型相匹配。在 诸如选择排序、快速排序和其他之类的排序算法中,需要进行元素交换,因此为使排序算法显得更加整洁, 又进一步定义了 Swap 过程。修改后的 BubbleSort 过程增加了一个过程类型的参数。可以看到实现所需的 代码更少。当把数组和进行特定类型比较的过程传递到 BubbleSort 算法时,即可按指定的顺序进行排序。 使 用 修 改 后 的 代 码 , 对 BubbleSort 的 调 用 看 起 来 可 能 形 如 BubbleSort( intarray, GreaterThan); 或 者 BubbleSort(intarray, LessThan);,其中 intarray 表示整数数组,LessThan 或 GreaterThan 是按照需要的次序进 行排序的比较函数。 可以作进一步的修改,使得代码在尽可能灵活的同时保持其易用性,可将对 BubbleSort 的调用包装在 过程中,所需的排序次序用名字来表示而无须传递过程类型的参数。或者可以为过程参数提供默认值,因 而用户只需在通常情况——按升序排序无法满足需要的情况下,才需要传递比较函数作为过程参数。通过 以累积的方式对这些技术进行合并,您可以生成简练、无冗余、非常灵活的代码,同时又只需少量的测试。 常量数组或可变类型数组 当参数以 array of const 形式声明时,其类型是动态的或在编译时未知。每个元素都是可变数据类型的。 可变类型携带了许多信息。因此在增强灵活性的同时,代码大小也会增加。但有时候确实需要这样作。array of const 与基类型为 TVarRec 的数组是等价的。TVarRec 是紧缩形式的记录,与 C 或 C++中的联合结构很 相似,前者所有的数据都位于同一位置。其表现形式取决于所访问的记录成员。 procedure OpenArray( S : array of const ); var I : Integer; begin for I := Low(S) to High(S) do ShowMessage(S[I].VPChar); end; OpenArray( ['This', 'is', String('a'), 'test'] ); 上面的例子中 OpenArray 有一个参数 S,定义为 array of const(或 TVarRec)。请记住数组中的每个元 素类型都是 TVarRec,因此可以访问 TVarRec 的某一特定成员来引用所包含的数据。TVarRec 的 VType 成 员可用于动态地确定数据的类型。如果类型在编译时已经知道,就可以像前面代码中调用 ShowMessage 一样,直接访问 TVarRec 中特定的数据元素(关于 system.pas 单元中的 TVarRec 类型,请参考帮助,即可 知道它包含的所有可能的数据类型) 。 在 array of constant 类型的数组中,可以使用 TVarRec 记录中的 VType 元素,通过 if 或 case 语句来动 态确定数组中元素的类型。下面演示了如何使用 case 语句来这样做。 for I := Low(S) to High(S) do case S[I].VType of vtAnsiString : ShowMessage( S[I].VPChar ); vtChar : ShowMessage( S[I].VChar ); vtInteger : ShowMessage( IntToStr( S[I].VInteger )); else // do nothing!! end; 这里的 for 语句对 OpenArray 过程中的 for 语句做了一些修改。数组中的每个元素都与三种可能的类型 进行比较,并打印出对应的值。不能用 TVarRec 进行隐式类型转换。例如通过 TVarRec 的 VPChar 访问整 数,将导致 EAccessViolation 异常。 5.4.6 定义静态数组和动态数组 静态数组是指在定义语句中指定了元素数目的数组。数组语句中的 array[n..m+n]子句就表明了元素的 第5章 集合、常数与运行时类型信息编程 126 数目以及数组是静态的。本章中,您可能已经看到过许多这种类型的数组。动态数组的定义与开放数组参 数有些相似,元素的数目没有指定,是在运行时定义的。 设置动态数组的大小 动态数组的声明与静态数组大致相同。语法上最显著的不同是没有指定元素数目的[]。例如,Ints : array of integer 定义了一个没有元素的动态数组变量。 当声明动态数组时,变量值为 nil,因此 if (Ints = Nil ) then 条件测试结果总为 True。动态数组开始时 是没有元素的。将数组和所要的大小传递给 SetLength 过程,即可设置数组的大小,然后它就可以包含若 干元素。 var B : array of integer; begin if( B = Nil ) then ShowMessage( 'B = Nil' ); SetLength(B , 10); if( B = Nil ) then ShowMessage( 'B = Nil' ); end; 在代码中,B 是整数的动态数组。第一个条件测试结果 B=Nil 为真,因此显示了 ShowMessage 对话框。 第二行代码动态地将数组的大小设置为 10。动态数组总是使用整数进行索引,第一个索引为 0,最后一个 索引为 n-1。这样,在上面的代码中,B 的有效索引从 0 到 9。当动态数组分配存储空间后,即可像静态 数组那样使用。 注意:写作本章时,Beta 2 版本的文档的声称短字符串的动态数组只能有 0 到 255 个元素。 当进行测试时,发现编译器并未加入这样的限制。 不能对动态数组使用间接引用操作符^以及 New 和 Dispose 过程。如果这样做,将会出现编译时错误 以提醒您。建议使用 Copy 方法来截断数组,尽管 SetLength 也可以工作并保留了数组中的元素。例如要把 上述代码中的 B 截断到 5 个元素,Copy(B, 0, 5)即可去掉后面 5 个元素。 创建可变类型数组 可变类型数组可以通过 VarArrayCreate 来动态地分配。可以将 VarArrayCreate 的返回值赋予可变类型 的变量。VarArrayCreate 的参数包括表示数组边界的整数数组以及可变类型的编码。可变类型编码定义在 system.pas 中。 注意:在 Delphi 5 中 VarArrayOf 和 VarArrayCreate 定义在 system.pas 中。可以回想到, system.pas 是 自 动 包 含 的 。 在 Delphi 6 中 , VarArrayOf 和 VarArrayCreate 定 义 在 Variants.pas 中,因此在使用这两个函数前需要用 Uses 子句包括该单元。 var V : Variant; begin V := VarArrayCreate( [0, 3], varVariant); V[0] := 1; V[1] := 'Test'; V[2] := VarArrayOf( [1, 'a', 1.0] ); end; 列出的代码示范了如何创建可变类型数组,并将其赋值给变量 V。每个元素的类型定义为 varVariant, 即一种可变类型。当数组已经分配后,可以像其他数组一样对可索引的项进行赋值,如代码所示。代码的 最后一行演示了创建并初始化可变类型数组的另一种方法,使用 VarArrayOf 函数。 如代码所示,VarArrayOf 函数有一个参数,为可变类型数组。在 VarArrayOf 内部,它调用 VarArrayCreate 创建可变类型数组,并把参数的每个元素复制到创建的数组。您可能奇怪为什么不直接进行赋值 V := [1, 2, 第5章 集合、常数与运行时类型信息编程 127 3]。部分的原因是因为这是集合构造器的形式,而集合类型出现在由 COM 所引入的可变类型之前(看一 看 variants.pas 中的 VarArrayCreate 的代码,了解其复杂性后,有助于明白为什么要迂回地创建并初始化可 变类型数组) 。 5.4.7 紧缩数组 为进行更快速的访问,编译时数组中的元素对齐到字或双字边界。在定义语句中使用 packed 关键字, 可以压缩数据但访问速度会有所降低。 var U : array[1..100] of record R : Real; S : String; end; P : packed array[1..100] of record R : Real; S : String; end; 列出的代码定义了记录数组 U,每个元素包含实数 R 和字符串 S,还定义了 P,与 U 类型相同的紧缩 数组。对 U 调用 SizeOf,其大小为 1600 字节,而 P 的大小则为 1200 字节。在日常程序中并不需要紧缩数 组,但如果需要它们确实是可用的。 5.5 运行时类型信息 typinfo.pas 和 system.pas 单元中包含了一些用于处理运行时类型信息的过程。运行时类型信息是通过 Pascal 记录来实现的,它存储了一些变量的额外信息,包括类型和名字等。该信息使得可以对变量和对象 进行查询,以判断其实际的数据类型。例如,TNotifyEvent 传递了一个 TObject 对象到事件处理程序,但 实际类型很少会是 TObject。把 TNotifyEvent 的参数定义为 TObject 类型,使得可以把一个事件处理程序用 于许多对象。可以用运行时类型信息来判断实际的对象类型。 TObject 类定义在 system.pas 单元中,并定义了几个类方法,可以判断对象的名字、类型、大小、祖先 和父类等。在表 5.3 中列出了这些方法。 表 5.3 运行时类型信息(RTTI)方法,可帮助找到对象的大小、类型、祖先和名字 声明 描述 class function ClassName : ShortString; 类方法,从虚方法表中的表项返回类的名字 function ClassType: TClass; 返回对象的类 class function InheritsFrom(AClass : TClass):Boolean; 返回布尔值,表示参数类是否是调用对象的祖先 class function ClassParent: TClass; 返回类的直接祖先(并非其拥有者控件) class function InstanceSize: Longint; 类方法,返回虚方法表的表项,表示该类型的对 象需要分配多少内存 返 回 指 向 TTypeInfo 记 录 的 指 针 ( 定 义 在 class function ClassInfo:Pointer; TypInfo.pas 中) 这些方法中的许多在 VCL 低层中被用到,但当您编写组件或应用程序时可能也会偶尔用到。 RTTI 编译器指令{$M}与+联用时,表示在编译代码时加入 RTTI 信息。涉及到 VCL 时,RTTI 信息是 在 TPersistent 类中引入的,这样 TPersistent 类的每个派生类都包含了运行时类型信息。运行时动态类型识 别在组件的事件处理程序中用得最为普遍,将在下一节讨论。 5.6 类 型 转 换 RTTI 是 Delphi 运转的关键。可以想像,如果每个组件都需要自身的单击事件处理程序,那么会怎么 样。每个组件都需要一个惟一的过程类型,而仅仅一个 TNotifyEvent 过程类型是不够的(回想一下, TNotifyEvent 有一个参数,Sender : TObject)。实际上,每个事件处理程序都至少有一个类型为 TObject 的 参数。如果没有 RTTI,那么每个组件的每个事件处理程序都需要一个不同的过程类型,因此会使得 VCL 庞大而复杂。 第5章 集合、常数与运行时类型信息编程 128 RTTI 使用 is 操作符进行运行时类型检查。例如像 if (Sender is TButton) then 这样的代码,使得程序员 可以判断某一特定类型的对象。如果对象是某一类型的,就可以把 TObject 类型的对象强制或动态转换到 合适的类型。下面代码演示了类型的强制转换与动态转换。 if( Sender is TButton ) then ShowMessage( TButton(Sender).Name ); // type.coercion if( Sender is TButton ) then ShowMessage( (Sender As TButton).Name ); //type.casting 第一个 if 条件语句强制地把 Sender 转换为 TButton 类型。第二个条件语句进行了动态类型转换。这两 种形式都能够通过一般形式的 Sender(TObject)参数访问 TButton 的数据和方法,但如果对象并非 As 操 作符右侧的类型,第二种形式将引发 EInvalidCast 异常。下面的代码是窗体的单击事件的处理程序。当窗 体被单击时,调用事件处理程序 FormClick,其中 Sender 类型为 TForm。 procedure TForm1.FormClick(Sender: TObject); begin ShowMessage( TButton(Sender).Name ); ShowMessage( (Sender As TButton).Name ); end; 虽然 Sender 并非 TButton 类型,但第一行代码居然奇迹般的可以工作。而第二行代码会引发 EInvalidCast 异常。如果利用假造的强制类型转换调用不存在的方法或访问不存在的数据,其行为是未定 义的。结果可能是令人不愉快的访问违例或内存重写。 5.7 小 结 第 5 章示范了枚举、常数、数组和运行时类型信息等技术。通过使用这些技术,代码的意图可以表达 得更清楚,减少边界检查代码,使代码更加健壮并具有动态性。本章中的这些术语引入的过程,也就是编 写编译器的工程师逐渐了解哪些东西会使程序崩溃的过程。如果编译器知道的代码信息越多、代码使用受 到的约束越多,那么代码的可靠性就越强。 第 6 章 接口的奥秘 消息处理程序、过程类型以及事件处理程序把 Delphi 程序与 Windows 操作系统联系在一起。这就是 说程序是嵌入到 Windows 中的。Windows 不能也无法预知运行在其中的程序,因为当 Windows 出现时, 大部分应用软件还没有编写出来。 当 Windows 出现时,它必须提供一种途径,使得应用程序可以响应操作系统。最后的结果是:Windows 成为了一个基于消息的操作系统,而 Windows 程序必须响应这些消息。我将其称之为邮政服务式体系结构。 最重要的是:通过响应消息,Windows 应用程序只需与 Windows 系统松散耦合。 所有的 Windows 程序都必须响应消息,而 Delphi 程序对此尤为出色。Windows 程序必须可以与任何 程序通讯,而无须预先知道该特定程序所响应的消息和响应的方式。仿照 Delphi 中使用过程类型、事件特 性和事件处理程序的方式,就可以隐藏 Windows 笨重的消息和事件驱动体系结构,并屏蔽不同的 Windows 消息和消息记录。实际上,也就是用通常的 Pascal 过程来屏蔽 Windows 消息处理程序和消息记录。 本章讨论了消息处理程序、过程类型和事件处理程序,它们在用 Delphi 编写的 Windows 程序中随处 可见。由于这些技术有助于使您的程序成为整洁、健壮、出色工作的独立子系统,本章中完整地涵盖了有 关的内容。 6.1 赢得对意大利细面条的战争 所谓的意大利细面条式代码,指的是耦合代码。在避免耦合代码这一点上,基本上每个人都是口惠而 实不至,因此代码很容易出现耦合。赢得战争的关键策略是,采用所有可能避免耦合代码的技术,并使这 些技术成为根深蒂固的习惯。要做到这一点,您有必要了解一些术语,它们有助于维护模块的分离、独立, 从而不至于成为相互依赖的大块耦合代码。这里有一个代码有害的例子,您可能以前见到过。 unit Unit1; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls; type TForm1 = class(TForm) Button1: TButton; procedure Button1Click(Sender: TObject); private { Private declarations } public { Public declarations } Canceled : Boolean; Procedure Process; end; var Form1: TForm1; implementation uses Unit2; 第6章 接口的奥秘 {$R *.DFM} procedure TForm1.Button1Click(Sender: TObject); begin Process; end; procedure TForm1.Process; var I : Integer; begin Canceled := False; Form2 := TForm2.Create(Self); try Form2.Show; for I := 1 to 10 do begin if( Canceled ) then break; Sleep( 1000 ); // simulates some processing Form2.ProgressBar1.Position := Trunc(I * Form2.ProgressBar1.Max / 10); Application.ProcessMessages; end; finally Form2.Free; end; end; end. unit Unit2; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, ComCtrls; type TForm2 = class(TForm) Button1: TButton; ProgressBar1: TProgressBar; procedure Button1Click(Sender: TObject); private { Private declarations } public { Public declarations } end; var Form2: TForm2; implementation uses Unit1; {$R *.DFM} procedure TForm2.Button1Click(Sender: TObject); 136 第6章 接口的奥秘 137 begin Form1.Canceled := True; end; end. 在列出的代码中,Form1 模拟了主窗体,其中包含了过程 Process 的代码。该类定义了名为 Canceled 的公有布尔类型成员。在 Form1 中对 Form2 进行了实例化。对 Sleep 的调用模拟了 Form1 中可能进行的处 理过程。Form1 直接修改了 Form2 中的进度条(见图 6.1)。处理将一直进行,直至所有的项都处理完毕或 者 Canceled 被设置为 True。在 Form2 的 Button1Click 事件处理程序中修改了 Canceled 特性。结果是,Form1 必须很清楚地知道 Form2 的存在,反之亦然,二者之间的联系非常紧密(很清楚,二者是互相“了解”的, 因为两个单元的实现部分的 uses 子句进行了相互引用)。 图 6.1 负责进度条更新的窗体 当程序的规模十分有限时,这种类型的代码很容易被忽视。不幸的是,有用的软件很少是简单的。如 果两个窗体中有一个变为对话框组件,问题就更糟糕了。考虑 Form2 变成对话框组件的情况(对话框组件 的更多信息请参见第 10 章)。进一步可以认为 Form1 是某个复杂应用程序的主窗体,因此它拥有该应用程 序中大多数的其他窗体。把 Form2 添加到 VCL 中(它是个组件,我们可以这样做) ,您的整个应用程序就 都添加到了 VCL 中(不要笑,确实如此)。最后的结果形成了一个臃肿的 VCL 库,它依赖于非 VCL 的代 码。当您编译应用程序时,VCL 代码也会重新进行编译。当您建立 VCL 库时,您的应用程序也将被编译。 如果出了错,组件就无法建立因而也无法装载。真是一团糟。如果还要试着给其他开发者讲述这乱成一团 的代码、其工作原理、以及如何对其进行修改等,那简直是代价高昂而且灭绝人性的行为。 注意:公平地讲,必须提到定义接口会引入另一种复杂性。这与一次性付款和延期付款孰优 孰劣的问题颇为相似。通过定义接口,可以使您更快地写出更多的代码。而当代码模块之间 相互关系的数目和复杂程度已经难于控制时,试图解决问题可能为时已晚,因此,通过精确 的接口提高代码的质量是更为可取的做法。即使对于非常简单的应用程序,定义接口的方法 也可以得到非常好的效果。不幸的是,这种方法需要进行训练。程序员和审阅者需要注意并 经常修改代码之间的关系,并对其提出一些简化方案。从长远看来,将解决问题的时机拖后 代价要更高,等问题拖到了不能不解决的时候,可能就太晚了。 窗体或数据模块之外的紧耦合代码,同样会导致问题。两个并非窗体的类也会产生前面的代码中的问 题,如果在项目中很晚才发现这种行为,将使产品的完成期限和交付产生严重的问题。如果 Form1 使用并 显示 Form2,那么很清楚,Form1 与 Form2 之间的关系一种是拥有性质:Form1 拥有 Form2。当打破类的 边 界 时 , 程 序 的 复 杂 性 就 会 增 加 。 上 面 的 例 子 中 , 出 现 了 两 处 这 样 的 实 例 : Form1 引 用 了 Form2.ProgressBar1.Position,而 Form2 引用了 Form1.Canceled.Form2.ProgressBar1.Position,这就是代码质 量很差的原因所在,因为它打破了两个对象 Form1 和 ProgressBar1 的边界。如果改变状态的实现方式,则 Form1 和 Form2 都需要进行改变。改动是代价昂贵的。本章的其余部分将提出一些策略,在更高的层次上 向您提供与 Delphi 的高级术语密切相关的知识,这将有助于您写出更为独立、修改更少的代码,并且减少 了代码演化时的麻烦。 6.2 类定义实用指南 在学校里,主要的课程可能是有关语法与数据结构的。很少有大学和学院会讲授一个好的程序的组成 要素,因为其答案过于主观。如果您确实对此有所了解,那可能是投入大量时间的和几经碰壁之后得到的。 第6章 接口的奥秘 138 事实上,无须碰壁就可以学到一些好习惯。程序设计已经出现很多年了,许多程序员也编了很多年的程序, 有一些好的惯例已经牢固的确立。遵循这些惯例,即可编出一些出色的程序。Delphi 就是这些程序之一。 Delphi 的源代码是软件业的迈克尔•乔丹。 当然,向 Delphi 的源代码学习需要阅读很多代码。Delphi 的源代码并非有关知识的惟一来源。可以找 到大量相关的例子和指南,但并不存在惟一的知识来源,即所谓“最好的惯例手册”。一些思想仍然被认 为令人讨厌、过于主观。但确实有一些惯例被无争议的采用,它们将有助于您编写出更好的代码。 6.2.1 类中有什么 有大约半打的最好的惯例,可有助于定义好的类。 1.由 Booch 的书(Booch,第 137 页)可知,类应该是简单的。这意味着基本上没有公有权限的属性。 在 Delphi 的类中,大约会有半打左右的方法和特性。 2.要使公有权限的接口尽早稳定,并使其数量尽可能少,这样程序员可以从类的简单行为建立较为高 级的行为。 3.某个类都有一个可识别的主要函数。 4.将数据封装在私有权限的接口中,通过公有特性和支持特性的方法进行访问。 5.通过子类化来扩展类的行为,减少对已存在代码的影响,最小化重新测试的可能性。 6.要明白,第一次就得到最好的抽象模型可能很困难。当理解了有关问题域的更多信息后,要准备好 对抽象模型进行修改。 一个程序员曾表示,在单一的应用程序中出现几个类表明对程序设计缺乏基本的理解。很显然,这是 错误的观点。从拉丁名言“分而治之”可知,反过来才是对的。通常,错误的抽象模型或缺乏抽象模型证 明对面向对象程序设计缺乏基础知识。 注意:断言代码质量的陈述是基于所谓的专家意见。诸如“这就是我们在 XYZ 公司做事的方 法,因此它是最好的”之类的陈述。这是孤立工作的程序员的通病。关于什么是最好的惯例 有许多断言,但其中大部分都指出对大量经验证据进行仔细思考后才能作出精确而科学的发 现。例如在 Booch 的书中提到,对质量的定性度量是基于耦合、内聚、充足性、完全性以及 原子性等(Booch,1994)。而缺乏经验或支持信息的纯粹主观论断,是非常值得怀疑的。 应用“分而治之”的告诫,我们应把复杂的问题分解为一系列简单而基本的问题,并分别解决某个问 题。本节开头的指南较为通用,可成为很好的起点。如果在类的公有接口部分定义了很多属性,代码会变 得更复杂,反过来会影响您或其他程序员对代码的控制能力。 6.2.2 没有数据的类 许多规则都有例外。一般的,如果类定义中没有数据,按照通常的规则应把该类合并到其他类中,因 为这种没有数据的类形式并未捕捉到问题域的完全的抽象。有一种类是该规则的例外,可称之为工具类。 如果类的属性都是方法,则通常将其定义为类方法,这样无论有无对象实例均可使用方法。 提示:按照通常的规则,类应有数据和方法。数据记录状态而方法定义行为。只有在确实需 要时,才可以背离该规则。 在 Delphi 中,TObject 类是所有类的基类。它包含了几个类方法,通常只在其子类实例化时才间接地 创建该类的实例。对于所有类都应有数据的通常规则,TObject 就是个例外,定义该类是为了使所有的 Delphi 类都具有某些基本能力,有助于它们在 Delphi 程序中发挥作用。 6.2.3 命名惯例 Delphi 并未强加任何令人难以忍受的命名惯例。Delphi 开发者所使用的一些惯例是基于规则的,而不 是基于需要记忆的前缀,您可以自由选择是否遵从该惯例。但如果您试过,可能会认为它们是易于使用的, 而且简化了编程。 方法的命名惯例 第6章 接口的奥秘 139 方法是动词与名词的组合。动词描述了动作,并且在名词之前,而名词则描述动作所施行的对象。我 们还知道名词与动词合起来,足以明确表达一个完整的口语或书面的句子。因此名词与动词联合的名字具 有很高的可读性。 按照规则,要把方法的作用域限制到方法名中的动作和主题范围之内。如果在方法名中只有一个名词, 那么您可能是在处理特性。按照惯例,特性方法中读方法的前缀为动词 Get,而写方法的前缀为动词 Set, 其后紧接着特性名(高级特性编程的更多信息请参见第 8 章)。 事件处理程序的命名惯例 Delphi 使用介词 On 作为事件处理程序的前缀。On 描述了动作或运动,如 OnClick 或 OnDragDrop。 通过遵循一些惯例,几乎不需要花费时间即可找到方法、事件或特性的名字。术语的类型、动作和动作的 主题可以帮助您为代码命名。 数据的命名惯例 Delphi 中的数据属性称之为字段。按照惯例,私有字段的前缀为 F。去掉 F,即可得到表示实际字段 的特性的很方便的名字。请记住过程类型,即事件和数据也可以是字段,因此前缀为 F。将字段与特性匹 配很简单,将字段名去掉 F 前缀即可。 前面提到过,基于规则的惯例可以使得代码在外观上一致而可靠,并可以减轻命名时想方设法的烦恼。 遵守 Delphi 的命名惯例与否,是您个人的选择。推荐您使用一种可辨别的风格,并一直坚持使用。 消息处理程序的命名惯例 消息处理程序是一些特别的方法,用于响应 Delphi 所实现的消息分发模型。按照规则,消息 处理程序与其所响应的消息名字大致相同。许多 Windows 消息的前缀为 WM_,而 Delphi 对消息方法名的 前两个字符使用了 WM。与特定的控件相关的消息的前缀也具有特别的前缀,例如前缀为 CB_的组合框。 在 messages.pas 中可以找到这些消息的名字,它们被定义为常数。 6.2.4 存取限定符的使用 Delphi 帮助文档中称存取限定符定义了成员的可见性。这有点用词不当,因为存取限定符并非限制代 码的可见性,而是限制代码的可访问性。存取限定符将代码划分为四种不同的可访问层次和大体上三个可 访问区域。公有和公开区域指明了类的用户可以访问而且应该关心的那部分代码。保护权限指明了扩展一 个类时,除了公有权限的代码之外,还需要注意的代码,最后,私有权限表示只有作者自己才会看到的代 码。 仔细而适中地将代码分布在不同的访问区域中,对于预期的用户可提高类所发挥的效用。对于公有访 问权限来说,越少越好。要维持对公有属性的紧密控制,以确保您的类可以通过代码质量的原子性度量。 6.2.5 默认的公开或公有权限 默认情况下,所有的属性都具有公有权限,这与 C++并不相同,在 C++中属性在默认情况下是私有的。 如果类编译时添加了运行时类型信息(在$M+状态下编译) ,如 TPersistent 类及其所有的子类,则所有属 性默认情况下具有公开权限。当在工程中创建一个新的窗体时,所有的组件都出现在窗体定义的上部。 警告:最好由 Delphi 来管理位于窗体类上部和.DFM 文件中的那些属性。如果您希望自己来 做,也可以手工进行管理,但需要非常小心。 Delphi 的行为是一致的,它并未把在窗体上绘制的控件与其他类区分开来,即使对于可以从.DFM 文 件中读写窗体定义并使用脚本消息来自动实例化组件的流类也是如此。 考虑本章开头例子中的进度条窗体。该窗体中有一个 TProgressBar 和一个 TButton 对象。Delphi 对这 些组件流化了足够的消息,以便在创建 Form2 的实例时自动创建这些组件。 object Form2: TForm2 Left = 441 Top = 222 第6章 接口的奥秘 140 Width = 263 Height = 163 Caption = 'Progress' Color = clBtnFace Font.Charset = DEFAULT_CHARSET Font.Color = clWindowText Font.Height = -13 Font.Name = 'MS Sans Serif' Font.Style = [] OldCreateOrder = False PixelsPerInch = 120 TextHeight = 16 object Button1: TButton Left = 88 Top = 88 Width = 75 Height = 25 Caption = 'Cancel' TabOrder = 0 OnClick = Button1Click end object ProgressBar1: TProgressBar Left = 24 Top = 24 Width = 217 Height = 25 Min = 0 Max = 100 TabOrder = 1 end end 当读到 object 语句时(上面列出的.DFM 文件中有三个),Delphi 将从 object 语句一行判断对象的类, 创建该对象的实例,并读入其余的属性,这就是 DefineProperties 方法的任务。由于组件有构造函数和析构 函数,因此您可以选择在设计时将其添加到应用程序中或在运行时动态地创建它们。 在任何 TWinControl 控件上,都可以动态地创建并初始化控件。TWinControl 控件可以拥有控件。要 在窗体上动态地创建 TButton 控件,可使用下面的代码,其中 Self 参数代表该窗体。 with TButton.Create(Self) do begin Parent := Self; Name := 'ButtonProcess'; OnClick := Button1Click; SetBounds( 10, 10, Width, Height ); Caption := 'Process'; end; 注意:TWinControl 类维护了一个 TControl 的列表,由 TWinControl 所拥有的子控件组成。 因此,虽然并未显式保存对控件列表中动态创建对象的引用,通过搜索控件列表来查找名为 ButtonProcess 的 TButton 控件即可得到该引用。 上面的代码模拟了 Delphi 在读入资源文件并创建窗体时的行为。上面列出的代码与设计时添加按钮的 区别在于,上面的代码添加了一个按钮到窗体,但并未在 DFM 文件中维护对该按钮的引用,而设计时也 第6章 接口的奥秘 141 无法操纵动态生成的按钮。 6.2.6 公开接口 当使用 published 存取限定符时,表示该属性将出现在 Object Inspector 中。除此之外它与公有访问权 限是相同的。把方法放在公开部分是没有意义的,这与将其放在公有部分效果相同。 当定义既可在设计时又可在运行时修改的组件特性时,可将其访问权限定义为公开权限。如果您不需 要在设计时修改特性,则无须将其定义为公开权限。事件特性也可以是公开的,组件公开的事件特性会在 Object Inspector 的 Events 属性页中列出。Delphi 6 中新增了公开对象特性的概念。在 Delphi 的早期版本中, 如果创建包含其他组件的组件,只能使用属性提升的手段来访问内部对象的特性。例如,如果要在设计时 修改对话框控件上的图像,则只能向该组件添加一个图像特性,然后利用图像特性的方法来访问实际的 TImage 控件的 Picture 特性。现在您可以把 TImage 控件作为公开特性,并直接访问其 picture 特性。这是 对公开访问区域的一个很好的改进(对象特性的更多知识及相应实例,请参见第 10 章)。 6.2.7 公有接口 公有接口是类怎样使用的决定性因素。如果无法利用公有接口达到目的,那么也就没有使用该类的必 要。按照 Booch 对代码质量的测量标准,公有接口中的属性应该足够、完全、并具有原子性。足够指的是 该类足以解决问题。即公有接口应是自给自足的。例如,一个文件流类中有写功能,要达到足够并完全, 需要在该类中添加兼容的读功能。原子性指的是类的功能应是基本的,如果一个类方法建立在另一个公有 方法基础之上,则第二个方法不是原子性的,应从类中去掉。 应使公有接口保持简明,并尽早地定义类的公有接口并使之稳定。如果在一个类的公有接口的基础上 建立了其他类,则修改该类也会导致对依赖于它的类的修改。类中非公有的属性都应该是保护或私有的。 6.2.8 保护接口 在面向对象中,保护接口供扩展该类的程序员使用。扩展意味着从已有的类派生子类,扩展其行为和 状态。把属性放在保护接口中时,对类进行扩展的程序员就可以修改这些属性。像古老的格言所说“可以 做的事都会有人做”,如果您不希望在子类中改变方法或直接操纵属性,那就使用私有接口吧。 6.2.9 私有接口 私有接口与公有接口是并列的。从外部看来,只有构成类的基本行为和状态的属性才可以放在公有接 口中,而私有接口好比是清洁工具柜。除非有意地使扩展该类的程序员可以访问它们,否则一切用于实现 类的公有行为的属性都会放在私有接口中。 注意:在私有与保护权限之间进行选择是困难的,因为这几乎与预先判断其他开发者然后使 用您的代码一样,都是不可能的。如果您的用户是一些特定的开发者,例如组件的作者,可 以考虑违反规则,通过将更多的数据和方法设置为保护权限,从而使代码的扩展性更好。这 也会使扩展您的组件的开发者有更多的灵活性。 请把类的所有实现细节放在私有访问区域中。这样做可以清楚地向用户表明,他们无须关心 这些项,因此可以使得您的类更易于使用。在定义类时,可以最后解决私有实现细节,因为在类 改动时,它们对用户的影响最小。 6.3 创建自定义过程类型 Delphi 和 C++支持函数指针。而 Visual Basic 和 Java 则不支持。函数指针是个强有力的概念,它在过 程一级提供了一个额外的动态层次。我们提到过,可以把回调过程传递给 Windows,这样就能让操作系统 来调用回调过程。C++中的函数指针例子如下: #include "stdafx.h" #include <iostream.h> void (*fp)(); 第6章 接口的奥秘 142 void Function() { cout << "Hello World!" << endl; } int main(int argc, char* argv[]) { fp = Function; fp(); return 0; } void (*fp)();一行定义了一个名为 fp 的函数指针变量。通过把过程 Function 的名字赋值给 fp,就可以 通过 fp 调用 Function。函数指针是很高级的概念,它向程序员提供了额外的实现选项(例子请参见 6.3.2 节“回调过程”)。 Delphi 对函数指针的支持不那么难懂。函数指针的概念在 Delphi 中称之为过程类型。过程类型与其他 类型的声明在外观上是一致的,无须像 C++中那样进行间接引用。使用过程类型,可以编写支持 Windows 回调过程、动态过程参数和事件处理程序的代码。它们的共同特征就是可以编写出动态和富于表达力的代 码,而且难于在任何其他的语言中复制。 6.3.1 定义过程类型 当定义过程类型时,可以将该类型作为别名引入,也可以将其作为新的类型引入,对于后者,编译器 将进行强类型检查;该类型可以指定为方法或非方法的过程类型。 过程类型 简单的过程类型定义在类型声明部分。与前面的 C++代码一致的过程类型可如下声明。 type TProcedure = Procedure; 上面的声明意味着,任何没有参数的过程都可以赋值给 TProcedure 类型的变量。如果要声明有参数的 过程类型,可以像声明过程那样包括参数列表,只需去掉过程名既可。 type TIntegerProcedure = Procedure( I : Integer); TObjectProcedure = Procedure( Sender : TObject ); TManyParams = Procedure( S : String; I : Integer ); 过程类型定义的参数列表可以与过程声明一样多变。可以包括数组参数、变量列表、常数和输出参数。 决定性因素是您的需求。所需引用的过程的类型将决定您所需要的过程类型。 函数类型 在对应于过程和函数的过程类型之间,不存在实际的区别。定义语句也是相同的,除了用关键字 Function 代替 Procedure 外,在定义语句的末尾添加返回类型即可。 type TFunction = Function : Integer; TStringFunction = Function( S: String ) : Boolean; TVarFunction = Function( var D : Double ): String; 如同过程类型一样,函数类型的参数列表是由该类型所引用的函数的参数所决定的。返回类型也必须 匹配。 用于方法的过程类型 过程类型也能引用方法,可以是类的成员函数或成员过程。当定义方法指针,即用于方法的过程类型 时,需要表明该过程类型指向类的成员。在类型定义的结尾用 of Object 标记即可。 第6章 接口的奥秘 143 type TFunctionMethod = Function : Integer of Object; TProcedureMethod = Procedure( var S : String ) of Object; 除了定义结尾的 of Object 限定符以外,该类型定义与非方法的类型定义是相同的。 在 Delphi 中遇到的过程类型通常是事件特性的类型。最常用的方法指针类型是在 classes.pas 中定义的 TNotifyEvent。 type TNotifyEvent = procedure (Sender: TObject) of object; 该类型用于许多事件特性,包括您会经常用到的 OnClick 事件。 6.3.2 回调过程 回调过程是这样一种过程,其地址被赋值给变量或作为参数传递,用于在指定的时间进行调用。 Windows 和 Delphi 都支持回调。回调过程的最常见的用途是在对象和响应事件的动作之间提供一个松散的 耦合点。例如,鼠标单击行为就是这样引入到 TControl 控件中的。 procedure WMLButtonUp(var Message: TWMLButtonUp); message WM_LBUTTONUP; procedure TControl.WMLButtonUp(var Message: TWMLButtonUp); begin inherited; if csCaptureMouse in ControlStyle then MouseCapture := False; if csClicked in ControlState then begin Exclude(FControlState, csClicked); if PtInRect(ClientRect, SmallPointToPoint(Message.Pos)) then Click; end; DoMouseUp(Message, mbLeft); end; procedure TControl.Click; begin { Call OnClick if assigned and not equal to associated action's OnExecute. If associated action's OnExecute assigned then call it, otherwise, call OnClick. } if Assigned(FOnClick) and (Action <> nil) and (@FOnClick <> @ Action.OnExecute) then FOnClick(Self) else if not (csDesigning in ComponentState) and (ActionLink <> nil) then ActionLink.Execute else if Assigned(FOnClick) then FOnClick(Self); end; TControl 类建立了自己的 WndProc 过程,作为对 Windows 消息的回调过程。当 WndProc 从 Windows 收到 WM_LBUTTONUP 消息时,它通过在 TObject 中引入的 Dispatch 方法将消息分发到控件。Dispatch 调用上面列出的 WMLButtonUp 消息方法。该消息方法确保了控件可以像设计的那样接收鼠标单击。 if( csClicked in ControlState ) then 如果控件接收到单击,则调用动态过程 Click。动态方法表会调用正确的 Click 方法。处理单击的方法 还会检查 FOnClick 事件处理程序是否指向有效的过程。 第6章 接口的奥秘 144 if( Assigned(FOnClick)) then FOnClick(Self); 如果 Assigned(FOnClick)结果为 True,就会调用该过程。 一个通情达理的人可能问的第一个问题会是,为什么需要绕这么多圈子才能知道发生了鼠标单击?只 有从某一角度看来,答案才是显然的。您确实不必这样做,因为所有的这些信息在日常的开发活动中是不 可见的。Windows 程序设计在十年前被认为很困难。 而现在所有的 Delphi 程序员都只需双击 Object Inspector 中的 OnClick 事件特性,然后填写方法体中的空白即可。 Windows 消息机制的复杂性在封装后对于日常的程序员是不可见的,复杂性的隐藏使事情变得很容 易。当编写响应事件的代码时,无须关心 Windows 如何工作。反过来,如果编写代码来改进与 Windows 的交互,您必须确切地知道 Windows 是如何工作的。回调是 Windows 中很重要的一部分。在 Delphi 尚未 命名为 Delphi 之前,它就已经支持过程类型了。大约七到八年前,Object Pascal(Delphi)还被称为 Turbo Pascal;因此它支持动态过程类型至少有十年之久了。 6.4 过程类型中的默认参数值 在过程类型中,可以定义参数的默认值。事实上,过程类型的定义并未改变默认参数值的语法,仍然 与所有的过程定义都相同。如果察看一下帮助中的 Object Pascal Grammar,很显然过程类型定义的标准规 则中包括了与过程定义相同的子规则 FunctionHeading 和 ProcedureHeading(依赖于过程类型的种类)。要 包括参数的默认值,只需在参数类型之后添加常数表达式即可。 type TDefaultParams = Procedure( const S : String = 'Default' ) of object; 注意:如果为某个过程提供了一个默认参数,并将该过程赋值给定义了默认参数值的过程类 型变量,则参数将使用定义在过程类型中的默认值而不是定义在实际过程中的默认值。 警告:可以将有默认参数的过程赋值给没有定义默认值的过程类型变量,但对该变量将无法 使用默认参数。否则编译将出错。 上面定义了一个以 TDefaultParams 为别名的方法指针类型,参数为常量字符串类型,默认值为 ‘Default’。可以声明 TDefaultParams 类型的变量,并将方法赋值给这些变量。对应的方法的原型必须与 TDefaultParams 相同,但参数不需要默认值。 6.5 传递过程类型的参数 使用过程类型的参数,需要定义相应的过程类型。编译器不能分析内嵌的过程类型定义,例如 Procedure P(P : Procedure (I : Integer));。必须先定义过程类型,然后将该别名或新类型作为参数的类型。 type TProcedure = Procedure( I : Integer); // ... Procedure P( Proc : TProcedure ); 上面的例子对 Procedure(I : Integer);使用了别名 TProcedure。如果要定义新类型,在等号右侧使用关键 字 type 即可,但实际传递的参数必须与该类型精确匹配。假定有过程 Procedure Foo( I : Integer );,就可以 用参数 Foo 来调用过程 P,如下所示。 P( Foo ); 但如果要把 TProcedure 作为新类型定义,则需要按两步进行。 type TProcedure = Procedure( I : Integer ); type TNewProcedure = type TProcedure; 第6章 接口的奥秘 145 提示:如果试图在一个语句中定义过程类型和新类型,编译器将给出错误提示“identifier expected but ’PROCEDURE’ found”(在 beta 版的编译器中可能出错,但 Delphi 6 发布时 该问题将得到解决)。 然后需要把 Foo 赋值给 TProcedure 类型的变量,并将该变量传递给 P。第 5 章中提到过,如果类型为 新类型(即在类型定义的右侧使用 type 关键字) ,编译器将对参数进行严格的类型检查。 type TDummyProcedure = Procedure(I : Integer); type TProcedure = type TDummyProcedure; Procedure P( Proc : TProcedure ); // ... Procedure Foo( I : Integer ); begin // some code end; // ... var AProc : TProcedure; begin AProc := Foo; P( AProc ); end; 列出的代码中结尾处的块语句示范了如何将过程赋值给类型为 TProcedure 类型的变量。当过程类型定 义为新类型时,这一点是必须的。 6.6 过程类型常量 当定义类型后,就可以像其他类型一样使用。可以声明该类型的变量、常数或创建新的子类型。 const MyNowProc : Function : TDateTime = SysUtils.Now; 上面列出的代码声明了过程类型常数 MyNowProc,它指向返回 TDateTime 的函数,其值为 SysUtils.pas 中定义的 Now 函数。在可以使用其他类型常量的地方,也可以使用过程类型常量,如:创建静态本地变 量的等价物,或定义可修改的过程常数等。 6.7 事件处理程序 事件处理程序是设计用来响应 Windows 消息的过程。在 Delphi 中,事件处理程序大多数是在双击事 件特性时创建的。事件特性是过程类型的特性,它的值是 Delphi 所创建的事件处理程序。在分配代码来响 应 Windows 和 Delphi 内部所产生的消息的途径中,这种定义事件处理程序的方式是最直接的。 事件并不限于在 Object Inspector 中所列出的那些,也不必以指定的方式来使用事件。动态实例化组件 或创建自定义组件,是另一个需要定义自己的事件特性并编写处理程序的常见场合。在编写事件处理程序 的代码时,有几个重要的准则需要记住。 1.一个事件处理程序可能会分配给多于一个的事件特性。在控件上拖动鼠标指针来选定控件(或按 下 Shift 键并分别单击每个控件),双击事件特性编辑域来创建事件处理程序(如图 6.2 所示,可以 看到当有多于一个的组件被选定时,Object Inspector 的反应) 。 第6章 图 6.2 接口的奥秘 146 当多个组件被选定时,在 Object Inspector 顶部的对象选 择器中会显示选定对象的数目(图中有两个对象被选定) 2.避免在事件处理程序中直接编写代码,而是调用一个方法来完成实际的工作。这样在其他环境中 需要该行为时就不必像 OnClick(Nil)一样直接调用事件处理程序,因而使得代码更加可读。 3.当多于一个组件共享同一事件特性时,可使用 Sender 参数来判断哪个组件实际触发了事件处理程 序。 遵循这些步骤,可以提高代码的可读性和可扩展性。Button1Click 中包含了较多的代码,这就不如调 用一个命名得很好的过程那样有意义。 6.7.1 定义事件处理程序 事件处理程序是一个方法。为把事件处理程序分配给特定的事件特性,该方法与所处理的事件的参数 数目、顺序和类型必须是相同的。例如,OnClick 事件类型为 TNotifyEvent。在 Delphi 帮助中查找 TNotifyEvent,它定义为一个过程,有一个名为 Sender 的 TObject 类型的参数。 提示:对于预定义的事件,查看事件特性的过程类型定义并将其复制即可得到其参数列表。 type TNotifyEvent = Procedure(Sender : TObject ) of Object; of Object 限定符意味着该过程是类的成员。假定有类 TForm1,它的 OnClick 事件的可能的事件处理 程序如下。 TForm1 = class(TForm) protected Procedure OnClick( Sender : TObject ); public // … end; 把 OnClick 过程赋值给任何定义为 TNotifyEvent 的事件特性都可以编译通过,而且在技术上这也是正 确的。 Button1.OnClick := OnClick; 假定 Button1 是 TForm 的成员,则前面的代码是正确的。 在技术上,把 OnClick 赋值给任何 TNotifyEvent 类型的事件特性都是正确的,但如果该事件并非 Click 事件,将导致语义上的错误。惯例是使用 On 作为事件处理程序的前缀,并用表示动作的词来描述该事件。 6.7.2 调用事件方法 调用事件方法的惟一禁令是出于风格方面的考虑。假定一些代码要使用事件处理程序 OnClick( Sender : TObject ),可以调用 OnClick(Nil)或 OnClick(Self)来触发该行为。在风格上,定义一个提供 OnClick 行为的 方法则较为可取。考虑对于菜单的 exit 命令的 OnClick 方法,其中 exit 将结束应用程序。 Procedure TFormMain.Exit1Click( Sender : TObject ); 第6章 接口的奥秘 147 begin // cleanup Application.Terminate; end; 上面的代码执行了应用程序的清除工作并结束程序。在风格上,下面列出的代码更为可取。 Procedure TFormMain.TerminateApplication; begin // cleanup Application.Terminate; end; Procedure TFormMain.Exit1Click( Sender : TObject ); begin TerminateApplication; end; 注意:当使用 UML 建模,特别是使用序列图时,如果事件处理程序出现在序列中,则显然事 件处理程序中包含了该对象的可定义的行为;例如,当演示应用程序结束的序列到达需要结 束行为的点时,Exit1Click 就会出现。试着运转序列,如果存在某种行为但没有定义方法时, 通常表示该类是不完全或不足够的。这只是个例子,利用正向和逆向工程以确保定义的类是 完全并足够的。 现在类的行为是自明的,在其他的上下文中无须通过事件处理程序即可调用 TerminateApplication 行 为。 6.7.3 触发事件 当需要产生事件时,可以调用分配给事件特性的过程。在 6.1 节“赢得对意大利细面条的战争”中, 可利用事件处理程序简化主窗体与显示进度条的窗体之间的关系。 unit UFormMain; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls; type TForm1 = class(TForm) Button1: TButton; procedure Button1Click(Sender: TObject); private { Private declarations } FCanceled : Boolean; Procedure OnCancel( Sender : TObject ); Procedure Cancel; Procedure Process; public { Public declarations } end; var Form1: TForm1; implementation uses UFormProgress; 第6章 接口的奥秘 {$R *.DFM} procedure TForm1.Button1Click(Sender: TObject); begin Process; end; procedure TForm1.Cancel; begin FCanceled := True; end; procedure TForm1.OnCancel(Sender: TObject); begin Cancel; end; procedure TForm1.Process; type TRange = 1..10; Function PercentComplete( I : TRange ) : Double; begin result := I / High(TRange); end; var I : Integer; begin FormProgress := TFormProgress.Create(Self); FormProgress.Show; FormProgress.OnCancel := OnCancel; try for I := Low(TRange) to High(TRange) do begin if( FCanceled ) then break; FormProgress.UpdateProgress( PercentComplete(I) ); Application.ProcessMessages; Sleep( 300 ); end; finally FormProgress.Free; end; end; end. unit UFormProgress; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, ComCtrls, StdCtrls; type 148 第6章 接口的奥秘 149 TFormProgress = class(TForm) Button1: TButton; ProgressBar1: TProgressBar; procedure Button1Click(Sender: TObject); private { Private declarations } FOnCancel : TNotifyEvent; function GetMax: Integer; public { Public declarations } procedure UpdateProgress( PercentComplete : Double ); property OnCancel : TNotifyEvent read FOnCancel write FOnCancel; property Max : Integer read GetMax; end; var FormProgress: TFormProgress; implementation {$R *.DFM} { TFormProgress } function TFormProgress.GetMax: Integer; begin result := ProgressBar1.Max; end; procedure TFormProgress.UpdateProgress(PercentComplete: Double); begin ProgressBar1.Position := Trunc(PercentComplete * ProgressBar1.Max); end; procedure TFormProgress.Button1Click(Sender: TObject); begin if( Assigned(FOnCancel)) then FOnCancel( Self ); end; end. 如前所述,FormMain 类的 Process 方法创建了显示进度条的窗体,但本例把事件处理程序 OnCancel 链接到 FormProgress 的一个事件特性。在 FormProgress 的代码中可以看出,它在设计时完全不知道 FormMain 的存在。这意味着 FormProgress 是完全独立于 FormMain 的。当单击 FormProgress 窗体中的按 钮时,它将调用与 FormProgress.OnCancel 事件特性相关联的事件处理程序。FormMain 也并不在意进度显 示是如何实现的,它调用 FormProgress 类的方法 UpdateProgress,并把它所知道的进度情况作为参数传递, 而无须考虑 FormProgress 如何实现该行为。通过该方法,FormMain 无须知道使用进度条来表示完成情况, 也不用在意进度条的最大值是多少。 Assigned 过程定义在 system.pas 单元中,它用来检查是否存在 OnCancel 事件处理程序;如果有,将调 用该处理程序。从而只有 FormMain 知道对 FormProgress 的引用,而所有的交互都维持在两个窗体的接口 一级。 这里所示范的代码风格还需要加工一下,因此要增加一些代码。收获看来不错,因为各个方面都具有 了更高的可重用度,代码是自文档化的,简化的相互关系意味着 FormProgress 可以在其他的环境中重用。 FormProgress 也可用其他的进度指示器来实现,而对于 FormMain 没有任何负面影响。本章中的术语促进 第6章 接口的奥秘 150 了这种代码风格,使用它可以得到更为健壮的应用程序。 6.7.4 定义事件特性 事件特性就是过程类型的特性。当看到与下面的定义相似的特性: property OnCancel : TNotifyEvent read FOnCancel write FOnCancel; 它等价于: property OnCancel : Procedure( Sender : TObject ) of Object read FOnCancel write FOnCancel; 但后者的语法是不正确的。而且读起来也令人迷惑。聚合应该以逐渐而自然的步骤进行,将复杂性层 次化而后形成较简单的表现形式;这在大脑中是很容易的。 事件特性的类型由所响应的事件的类型决定。事件特性的命名可按照惯例进行:以 On 为前缀,后跟 去掉 T 的事件类型或者处理程序所响应的消息类型。这样,响应事件 MouseDown 的事件特性命名为 OnMouseDown 就足够了(特性定义的更多信息请阅读第 8 章)。 6.7.5 事件处理程序将消息转发到应用程序 在海军陆战队的广告中,有一个铁匠通过轧制金属来制作剑,一遍又一遍地敲打边缘。其思想在于, 通过对钢进行多次层叠来提高金属的强度。具有工业水准的代码同样需要千锤百炼。通过隐藏复杂性层次, 应用程序可以更为健壮。事件与消息之间的关系就是这样的一个层次。事件处理程序是 Windows 和 Delphi 的消息与应用代码之间的桥梁。由于层次的存在,消息驱动的 Windows 操作系统的复杂性被隐藏起来,但 是却可以访问。这就是 Delphi 看起来与 Visual Basic 一样易于编程但却更为强大的原因之一。Visual Basic 中由于不存在独立的层次,因此 VB 就没有 Delphi 那么健壮。 6.8 消 息 方 法 相比事件处理程序,消息处理程序是更靠近 Delphi 和 Windows 的一个层次。在消息一级捕获事件, 可以获得更多的控制权限和选择自由。许多控件都有相当数目的消息处理程序来捕获许多通常类型的消息 以及发送到方法以调用指定的事件处理程序的消息。在日常的编程中就可以发现,存在着特定消息的事件 处理程序。 如果要为某个消息定义新的行为,只需对该消息所产生的事件编写一个处理程序即可。例如,要在窗 口接收绘制消息时加入新的行为,只需向该窗口的 OnPaint 事件处理程序添加代码即可。也可在更低点捕 获该消息,即接收消息的时候。这提高了更多的控制权限,但也增加了责任。 重载消息与重载虚函数相似。不同之处在于其声明。虚函数在子类中使用同样的方法名重载,并在声 明的结尾处添加 override 指令。消息处理程序不需要 override 指令,它使用 message 指令(在 6.8.2 节“定 义消息处理程序”中描述)。不必对消息处理程序使用同样的过程名,尽管这样做可以使您的代码更加容 易阅读。 6.8.1 查找预定义消息常数 消息常数定义在 Windows API 帮助以及 Delphi 单元 messages.pas 和 windows.pas 中。API 中的 Windows 消息在索引中按字母顺序列出,以 WM_为前缀。大多数 Windows 消息定义为 messages.pas 中的命名常数, 无须再定义消息常量。包括 messages.pas 单元,即可在代码中使用命名消息常数。 在 Windows.pas 中定义了两个消息过程(由 user32.dll 引入)SendMessage 和 PostMessage,可进行原 始的消息发送。也可以使用每个对象都包含的 Dispatch 方法直接向对象传递消息。下面列出的代码示范了 如何在 Delphi 应用程序的菜单中实现 Windows 的 Undo、Cut、Copy 和 Paste 行为。 Function TForm1.ActiveHandle : Integer; begin 第6章 接口的奥秘 151 result := 0; if( Assigned(ActiveControl)) then try result := ActiveControl.Handle except end; end; Procedure TForm1.SetMenuStates( const Enabled : Boolean ); begin CanUndo; Cut1.Enabled := Enabled; Copy1.Enabled := Enabled; Paste1.Enabled := Enabled; end; procedure TForm1.Edit1Click(Sender: TObject); begin SetMenuStates( ActiveHandle <> 0 ); end; procedure TForm1.Undo1Click(Sender: TObject); begin SendMessage( ActiveHandle, WM_UNDO, 0, 0 ); CanUndo; end; procedure TForm1.Cut1Click(Sender: TObject); begin SendMessage( ActiveHandle, WM_CUT, 0, 0 ); CanUndo; end; procedure TForm1.Copy1Click(Sender: TObject); begin SendMessage( ActiveHandle, WM_COPY, 0, 0 ); CanUndo; end; procedure TForm1.Paste1Click(Sender: TObject); begin SendMessage( ActiveHandle, WM_PASTE, 0, 0 ); CanUndo; end; procedure TForm1.CanUndo; begin Undo1.Enabled := Boolean(SendMessage( ActiveHandle, EM_CANUNDO, 0, 0 )); end; 按下列步骤可实现该例程: 1.创建一个新工程。 2.从组件面板的 Standard 属性页,向主窗体添加 TMainMenu 控件。 3.双击窗体上的菜单组件,显示 TMainMenu 的特性编辑器(见图 6.3)。 第6章 图 6.3 接口的奥秘 152 TMainMenu 组件在设计时的特性编辑器 4.对每个菜单项(Undo、Cut、Copy、Paste)双击 OnClick 事件特性,向对应的事件处理程序添加代 码,如上面列出的代码所示。 5.向窗体类添加私有方法声明:Function ActiveHandle : Integer ;、Procedure SetMenuStates( const Enabled : Boolean );以及 Procedure CanUndo;。 6.向每个方法和事件处理程序添加代码。 更好的办法是把消息的实际值赋值给对应菜单项的 Tag 特性,这样可以对菜单项的单击事件实现单一 的处理程序, 把菜单项的 Tag 特性传递给 SendMessage 作为 Msg 参数,如下所示:SendMessage( ActiveHandle, TMenuItem(Sender).Tag, 0, 0);。虽然该代码较为模糊,而且并未达到自文档化的理想目标,但如果加入一 段注释来阐明代码的意义,那么同样可以有效使用。由于没有使用常数而是在 Tag 特性中编码了常数的字 面值,该代码可能不像直接定义四个单独的事件处理程序那样具有良好的可移植性。 现在已经编写出了一个较为实际的例子,示范了如何在应用程序中发送消息。我们接着看一下怎样定 义消息处理程序。 6.8.2 定义消息处理程序 与所有其他术语相同,消息处理程序也有基本的语法。该语法由下列规则定义: · 在类的私有访问区域定义消息处理程序。 · 将消息处理程序定义为过程。 · 消息处理程序总是只有一个记录参数,类型为 TMessage 或具有相似定义的类型(关于自定义消息 记录的限制,请参考帮助文档)。 · 消息处理程序无须 override 指令。 · 消息处理程序无须与父类中对应的处理程序同名,但这样做是个好主意,可以使代码更为清晰。 · 调用 inherited 进行父类的消息处理,这与重载方法相同。如果不存在父类的处理程序,会调用 DefaultHandler 方法。 · 把消息处理程序的代码写在另一个方法中,在处理程序中调用该方法。 · 通常应避免直接调用消息处理程序,应该调用 SendMessage、SendNotifyMessage、PostMessage 或 Dispatch 方法,并将消息作为参数传递。 TMessage 是 Windows 消息处理程序通用的记录类型。TMessage 定义在 messages.pas 中,它是紧缩记 录类型。 TMessage = packed record Msg: Cardinal; case Integer of 0: ( WParam: Longint; LParam: Longint; 第6章 接口的奥秘 153 Result: Longint); 1: ( WParamLo: Word; WParamHi: Word; LParamLo: Word; LParamHi: Word; ResultLo: Word; ResultHi: Word); end; 记录中的 case 语句(如上所示)与 C 和 C++中的联合较为相似,但不那么直观。Case 语句中的每一 项都代表着该字段在记录中可能的表示方式。可以访问记录值的任意字段;一种表示方式是 WParam、 LParam 和 results 都是长整数,另一种表示方式是同样的,但值划分为高位和低位的字。Windows 内部存 储信息使用反向字节存储顺序,因此值的低位部分存储在较低的地址然后是值的高位部分。这就是 WParamLo 列在 WParamHi 之前的原因。在 WParam 中,WParamLo 代表低位字,而 WParamHi 代表高位 字(常整数是 32 位的,而字是 16 位的)。 您可以使用 TMessage,也可以定义与 TMessage 变量大小相同的紧缩记录类型,定制其成员以满足您 的需求。Messages.pas 中定义了许多消息记录,您可以直接使用,也可作为定义自己的消息类型的参考。 遵循定义消息处理程序的准则,下面列出的代码示范了编辑控件的消息处理程序。 type TMessageEvent = Procedure( const Strings : TStrings ) of Object; TMyEdit = class(TEdit) private FStrings : TStrings; FOnSetSel : TMessageEvent; procedure EMSetSel( var Msg : TMessage ); message EM_SETSEL; procedure MessageEvent( Msg : TMessage ); Procedure SetSel( Msg : TMessage ); public constructor Create( AOwner : TComponent ); override; destructor Destroy; override; property OnSetSel : TMessageEvent read FOnSetSel write FOnSetSel; end; implementation constructor TMyEdit.Create(AOwner: TComponent); begin inherited; FStrings := TStringList.Create; end; destructor TMyEdit.Destroy; begin FStrings.Free; inherited; end; procedure TMyEdit.EMSetSel(var Msg: TMessage); begin SetSel( Msg ); inherited; 第6章 接口的奥秘 154 end; procedure TMyEdit.MessageEvent(Msg: TMessage); begin with FStrings do begin Clear; Add( 'Msg=' + IntToStr( Msg.Msg )); Add( 'WParam=' + IntToStr( Msg.WParam )); Add( 'LParam=' + IntToStr( Msg.LParam )); Add( 'Result=' + IntToStr( Msg.Result )); Add( 'WParamLo=' + IntToStr( Msg.WParamLo )); Add( 'WParamHi=' + IntToStr( Msg.WParamHi )); Add( 'LParamHi=' + IntToStr( Msg.LParamLo )); Add( 'LParamLo=' + IntToStr( Msg.LParamHi )); Add( 'ResultHi=' + IntToStr( Msg.ResultLo )); Add( 'ResultLo=' + IntToStr( Msg.ResultHi )); end; if( Assigned( FOnSetSel)) then FOnSetSel( FStrings ); end; procedure TMyEdit.SetSel(Msg: TMessage); begin MessageEvent( Msg ); end; TMyEdit 类示范了如何重载 EM_SETSEL 消息的处理程序,当选定文本时会收到该编辑消息。 TMessageEvent 定义为过程类型,接受一个 TStrings 对象作为参数,其中包含了 EM_SETSEL 消息经过格 式化的值。格式化过程在方法 MessageEvent 中发生,如果已经通过 OnSetSel 特性向 FOnSetSel 字段分配 了事件处理程序,还将调用该处理程序。请注意,EmSetSel 消息处理程序委托一个过程来完成工作并调用 继承的消息处理程序。 消息处理程序的用途很广泛。WinSight32 应用程序使用它们在调试期间跟踪 Windows 消息。也可编写 消息处理程序,基于一些条件性代码来冻结一个消息,而如果条件测试成功,则调用继承的消息处理程序。 还可以使用消息处理程序来实现通用的事件处理。尽管并非所有的对象对所有的消息都会响应,但可以假 定已知某个类的对象会响应消息。我们不在分发消息的对象中试图判断当前的接收者,而是对当前接收者 或某个列表中的接收者维护一个通用的 TObject 引用,然后发送消息。如果接收者对象需要该消息,就会 有已定义的消息处理程序。如果它不需要该消息,在没有消息处理程序的情况下将忽略该消息。当对象和 消息的数目都很多时,请避免把很多的事件处理程序分配给同样多的事件特性,然后再试图找出哪个对象 与哪个消息相关联,只需把消息分发给所有可能的接收者,让接收者自行决定即可。这种广播过程与电视 和无线电广播的工作方式非常相似。 6.8.3 理解 Delphi 的消息发送体系 所有的 Windows 编程语言都必须在某种层次上支持 Windows 消息发送机制。对 Windows 消息机制的 支持仔细而精巧,它隐藏了许多 Windows 编程的复杂性,同时在需要的情况下仍然可以访问低层消息机制 (参见图 6.4 中 Delphi 消息机制的图示)。 第6章 图 6.4 155 接口的奥秘 对 Delphi 所实现的消息机制的可视化描述 您可以在合适之处插入自己的代码。大部分通常的程序只需编写事件处理程序。但如果在 Visual Basic 中试图重载标签的绘制行为,您就可以知道无法访问 Windows 的消息机制会带来多少局限。如果您必须使 用 Visual C++中的预编译器来编写消息拆析器,您也会知道使用一种实现得半生不熟的事件处理机制来实 现绘制究竟是什么样子。 因为 Delphi 中所有的类都是 TObject 类的子类,因此 Delphi 可以把消息传播到通常不接收消息的控件。 通过对特定的消息调用 Dispatch 方法,Delphi 中的 VCL 控件能够响应范围广泛的消息,通常这些消息是 只发送给具有 Windows 句柄的控件的。 6.9 小 结 Windows 是事件驱动、基于消息的操作系统。Windows 编程如果缺乏消息发送和事件处理程序的支持, 就像在陆地上驾驶飞机只能用脚来掌舵一样,既不自然也不舒服。Delphi 具有仔细而精巧的体系结构,从 而完善地支持了比其他 Windows 编程语言更为自然的开发环境。 您现在已经理解了基于消息的机制的一些深入的细节。这种知识使得您可以定义松散耦合的类体系结 构,编写更富于表达力和更健壮的代码,创建更高级的定制控件。本章中示范的技术对于讲述如何建立组 件的章节将会特别有用。 第 7 章 抽象类和静态接口 在 COM 协议实现之前,抽象接口就已经存在了。抽象接口是面向对象术语,它是一个未实现的接口, 但标识了该接口所有可能的实现。理解抽象接口的一般工作原理有助于设计和实现更好的软件体系结构, 它也是理解 COM 用法的关键所在。 静态接口指的是类方法。类方法与 C 或 C++中的静态方法等价。静态方法存在于类的层次上。无须特 定的对象即可调用类方法。常见的体现了类方法行为的例子是构造函数,它是用于创建类实例的特殊方法。 除了对象的构造,类方法还有其他用途。在 Delphi 中,将抽象接口和静态方法结合起来就可以很容易的实 现瘦客户以及 DLL 应用程序,这对于使用 COM 和 DCOM 进行分布式程序设计是个很好的预习。 本章中,您将学会如何实现抽象接口,创建进程内动态链接库,以及从 DLL 向应用程序高效地传递 对象。 7.1 类方法的实现 大多数方法都具有实例作用域。对于定义在类中的数据,每个类都有自己的副本。在方法调用前实例 必须已经存在,而且语法要求把对象用点操作符(.)连接到方法作为调用的前缀(除非使用 with object do 结构)。这对于类方法以外的所有方法都是对的。 类方法具有类作用域。它们的行为与 C 或 C++中的静态方法几乎完全相似。尽管可以用实例来调用类 方法,但只需将类名用的操作符连接到类方法即可。类方法的语法与其他方法相同,但需要把 class 关键 字作为方法声明的第一个词。 class methoddeclaration 例如 class Procedure Foo;,无论定义在任何类中都表示 Foo 是个类过程。给出 class TBoolean,将布尔 值转换为字符串值的类方法可如下实现。 type TBoolean = class public class Function BooleanToString( const Value : Boolean ) : String; end; implementation class function TBoolean.BooleanToString(const Value: Boolean): String; const BOOLS : array[Boolean] of string = ('False', 'True'); begin result := BOOLS[Value]; end; 该类方法是个函数,当给出布尔值时返回对应的字符串。调用该方法,只需使用类名和类方法名。 MessageDlg( TBoolean.BooleanToString( False ), mtInformation, [mbOK], 0); 警告:对未初始化的对象调用非类方法,将导致访问违例异常 EAccessViolation。 上面的代码显示了一个消息对话框,其中文本为‘False’。从类方法的实现中可以看到,布 尔值被用作字符串数组的索引,以返回字符串。重要的是要注意到无须对象即可调用类方法。所有的方法 都是类方法的类可用于定义无状态类,即没有数据的类。 第7章 7.1.1 抽象类和静态接口 163 创建无数据类 按照通常的规则,没有数据的类意味着其方法无法组成一个类,而需要合并到已存在的类中。就像大 多数规则一样,例外总是存在的。数据库类就是作为工具类而发明的,另一个常见的例子是包含成百上千 算法的数学库,可以将其组织在整洁的由类构成的包中。 实用工具类没有数据,因为数据意味着状态,此外需要对象存在。完全版的 TBoolean 类就是一个无 数据类的例子。 unit UBoolean; interface uses SysUtils; type TBoolean = class public class function BooleanToString( const Value : Boolean ) : String; class function StringToBoolean( const Value : String ) : Boolean; class function BooleanToYesNo( const Value : Boolean ) : String; class function YesNoToBoolean( const Value : String ) : Boolean; end; implementation { TBoolean } class function TBoolean.BooleanToString(const Value: Boolean): String; const BOOLS : array[Boolean] of string = ('False', 'True'); begin result := BOOLS[Value]; end; class function TBoolean.BooleanToYesNo(const Value: Boolean): String; const YES_NO : array[Boolean] of String = ('No', 'Yes'); begin result := YES_NO[Value]; end; class function TBoolean.StringToBoolean(const Value: String): Boolean; begin result := CompareText( Value, 'True' ) = 0; end; class function TBoolean.YesNoToBoolean(const Value: String): Boolean; begin result := CompareText( Value, 'Yes' ) = 0; end; end. SysUtils.pas 单 元 中 加 入 了 大 小 写 不 敏 感 的 CompareText 函 数 。 上 面 的 类 由 StringToBoolean 、 第7章 抽象类和静态接口 164 YesNoToBoolean 和 BooleanToYesNo 组成,完成了逻辑类型与字符串之间的转换。定义实用工具类提供了 一个整洁的包,并且可以随着时间进行扩展。 继承静态类 静态类是只有类方法的类。假定您实现了一个静态类,如 TBoolean,需要随时间而扩展以包括其他所 需要的转换功能,可以使用子类化来扩展行为;否则就需要重新编译所有使用 TBoolean 类的代码。另一 方面,子类化是一种方便的途径,它可以扩展布尔值转换能力而无须影响已存在的代码。 uses UBoolean; type TConvert = class(TBoolean) public class function AnyStringToBoolean( const Value : String ) : Boolean; end; implementation class function TConvert.AnyStringToBoolean( const Value : String ) : Boolean; begin result := (Length(Value) > 0 ) and (Value[1] in ['T', 't', 'y', 'Y']); end; 提示:按照惯例,把类名中的 T 前缀改为 U 前缀来作为单元名,可以很容易地找到包含特定 类的单元。上面列出的代码中,很容易辨认出 UBoolean 是包含 TBoolean 类的单元。 TConvert 类也是一个 IsA TBoolean 类, 它继承了所有 TBoolean 的类方法,并实现了 AnyStringToBoolean 方法。新的类方法确保输入的字符串长度大于零,并通过检查第一个字符是否是 T、t 或 Y、y 来验证 True 和 Yes。在该实现中,所有其他的字符串都作为 False 对待。由于 TConvert 只包含类方法,因此并不需要 TConvert 的实例。 聚合静态类 如果要通过子类化扩展多个类的行为,也可以使用聚合。前面的例子中,TBoolean 听起来仿佛是 TConvert 类的子类,而不是反过来那样。这样,语义听起来像是错的。按照规则,应尽可能使语义正确, 但静态类和工具类确实在规则之外。但如果您不喜欢 TConvert 类与 TBoolean 类之间 IsA 的语义,您可以 使用聚合来实现 TConvert 和 TBoolean 类的关系,即 HasA 关系。 type TBooleanClass = Class of TBoolean; TConvert = class public class function BooleanClass : TBooleanClass; class function AnyStringToBoolean( const Value : String ) : Boolean; end; implementation class function TConvert.AnyStringToBoolean(const Value: String): Boolean; begin result := (Length(Value) > 0 ) and (Value[1] in ['T', 't', 'y', 'Y']); end; class function TConvert.BooleanClass: TBooleanClass; 第7章 抽象类和静态接口 165 begin result := TBoolean; end; 现在两个类之间是包含关系。可如下使用 TConvert 类调用 TBoolean 类中的静态方法。 if( TConvert.BooleanClass.StringToBoolean( 'False' ) = False ) then // … do something 这有些稍许冗长,但仍无须改变依赖于 TBoolean 的代码,而且扩展了 TBoolean 类的行为。如果需要 在使用聚合时像继承一样方便,可以在 TConvert 类的接口中定义与 TBoolean 相同的类方法,并用 TBoolean 来实现这些方法。 type TConvert = class public class function YesNoToBoolean(const Value: String): Boolean; class function AnyStringToBoolean( const Value : String ) : Boolean; end; implementation class function TConvert.YesNoToBoolean(const Value: String): Boolean; begin result := TBoolean.YesNoToBoolean( Value ); end; 上述例子示范了接口提升的概念。TConvert 类通过使用 TBoolean 类的方法实现了 YesNoToBoolean 行 为。可以看到,现在有了多个选择。关键在于把已存在的行为扩展到新的领域而无须影响或重新测试以存 在的代码。继承、聚合以及利用聚合关系实现提升接口等途径,无论对于静态类还是常规类都是可行的。 7.1.2 构造函数和析构函数 构造函数是用于创建并初始化对象的特别方法。构造函数使用关键字 constructor。 constructor Create; 注意:上一节中提到的静态类,是不需要构造函数和析构函数的,因为并不需要创建其实例。 但它们确实从 TObject 继承了所有的方法,包括构造函数和析构函数。 构造函数与函数的类方法密切相关。您可以定义类函数模拟构造函数的行为,并返回对象的实例。 class function TFoo.Create : TFoo; begin result := inherited Create; end; procedure TFoo.Hello; begin ShowMessage('Hello World!'); end; 使用变量 Foo : TFoo,调用 Foo := TFoo.Create 看起来与构造函数非常相似,但实际调用了静态方法 Create,该方法调用从 TObject 继承的构造函数。类函数 TFoo.Create 示范了构造函数的行为。构造函数具 有特别的语义“首先调用构造函数以进行对象初始化”,所以可定义特定的构造函数来表示特定的语义。 要定义构造函数,只需添加一个方法,并用关键字 constructor 代替通常的 procedure 或 function。按照惯例, 构造函数命名为 Create。可以传递任意数目的所需参数来初始化对象。几个构造函数的例子如下。 constructor Create; 第7章 抽象类和静态接口 166 constructor Create(AOwner : TComponent); constructor Create( const FileName : String; Mode : Word ); 第一个构造函数没有参数。第二个构造函数有一个 TComponent 类型的参数,名为 AOwner(第二种 形式是组件的构造函数) 。第三种形式有一个常量字符串参数,名为 FileName,以及一个 Word 类型的参数, 名为 Mode。第三个构造函数是 TFileStream 类的构造函数的形式。 析构函数使用关键字 destructor,需要使用对象实例才能调用,它不是静态方法。关键字 destructor 是 一个特别的结构,意为“最后调用析构函数,以进行对象的清除工作”。按照惯例,析构函数命名为 Destroy, 无须参数。析构函数通常如下定义。 Destructor Destroy; override; 因为每个类都是由 TObject 子类化而来,而 TObject 已经定义了一个虚析构函数,因此要重定义析构 函数,必须使用 override 指令。 7.2 维护无对象状态 Java 和 C++支持静态特性。Delphi 并不直接支持静态数据,但在没有对象的类中可以实现静态数据的 等价形式。 type TNoObject = class public class Function Data( const NewValue : Integer = 0; const Change : Boolean = False ) : Integer; end; implementation class function TNoObject.Data(const NewValue: Integer; const Change: Boolean): Integer; const {$J+}FData : Integer = 0;{$J-} // make writable begin if( Change ) then FData := NewValue; result := FData; end; 上面的类定义了一个类函数,在函数内部存储了一个可写常数。通过定义两个默认参数,该函数可以 像数据一样用作右值。要将该静态数据作为左值使用,需要向函数传递两个参数,这有一点麻烦。 TNoObject.Data( 7, True ); while( TNoObject.Data > 0 ) do begin ShowMessage( IntToStr( TNoObject.Data )); TNoObject.Data( TNoObject.Data - 1, True ); end; 用这种方式管理数据过于笨重,但可以有效地实现静态数据。下面列出的代码示范了如何实现静态的 StringList。 type TStaticStrings = class public class Function Strings( const Cleanup : Boolean = False ) : 第7章 抽象类和静态接口 167 TStrings; class Procedure Finalize; end; class function TStaticStrings.Strings(const Cleanup: Boolean): TStrings; {$J+} const FStrings : TStrings = Nil; {$J-} begin if( Cleanup ) then begin FStrings.Free; FStrings := Nil; end else begin if( FStrings = Nil ) then FStrings := TStringList.Create; end; result := FStrings; end; class Procedure TStaticStrings.Finalize; begin Strings( True ); end; 在上述的 TStaticStrings 类中,如果内含的 TStrings 对象为 nil,将自动对其进行初始化。在其他对象 可以使用的右值环境中,也可以使用 TStaticStrings.Strings 类方法。调用 Finalize 可以清除动态的字符串列 表对象,它调用了 Strings 方法,并向 Cleanup 参数传递 True 值。 7.3 动态链接库编程 动态链接库通常用于存放过程。许多松散相关的过程包含在 DLL 中,它们都是独立的过程,并不构 成更大结构的一部分。近来,DLL 已经用作进程内 OLE 自动化和 COM 服务器的容器。在这两种用途之 外,DLL 还可以作为进程内应用程序,内含通常所称的业务对象。 Delphi 可以在动态链接库中实现类,并在使用该 DLL 的程序中使用这些类,使得能够创建进程内瘦 客户和服务器应用程序。由于动态链接库可用于存储通用工具函数、业务对象服务器以及 COM 应用服务 器等,本节涵盖了如何在 Delphi 中创建并使用动态链接库。 7.3.1 调用 DLL 过程 在进程将 DLL 装载入内存后,该 DLL 导出的过程可以像其他过程一样使用。DLL 运行在装载 DLL 的应用程序的进程空间中,并且对所有使用该 DLL 的应用程序共享同一代码副本。如果在某个过程声明 中包括了 external 子句和 DLL 的名字,在应用程序开始运行的时候,程序将试图在 Application.Initialize 方 法运行前装载对应的 DLL。如果使用动态的 DLL 装载方法,当调用 LoadLibrary 时即可装载 DLL。动态装 载可以使程序员更好地控制程序,并使应用程序启动更快。而静态或隐式装载 DLL 则更为容易,因为 Windows 完成了大部分的工作。 隐式 DLL 装载 出于示范的目的,我们假定存在一个 DLL,其中包含了联系信息:名字、电话号码和电子邮件地址。 第7章 抽象类和静态接口 168 客户程序无须关心 DLL 如何实现,从 DLL 用户的角度看来,所需的信息只是导出过程的名字和声明。示 例 DLL 导出了五个过程:AddContact、RemoveContact、CountContacts、Initialize 和 Finalize。要求 Initialize 必须首先调用,当 DLL 不需要再存储数据时调用 Finalize。 注意:给出的过程代表了结构化程序设计的风格。建议您不要使用这种编程风格,这里的目 的只是为了示范如何隐式装载 DLL。 该 DLL 所存储的联系信息项定义为紧缩记录 TContact,其中包括 Name、Phone 和 EMail 字段,都是 字符串。 要使用这五个 DLL 方法,只需在客户应用程序中声明它们。 Procedure AddContact( Contact : TContact ); external 'ContactServer.dll'; Procedure RemoveContact( Phone : String ); external 'ContactServer.dll'; Function CountContacts : Integer; external 'ContactServer.dll'; Procedure Initialize; external 'ContactServer.dll'; Procedure Finalize; external 'ContactServer.dll'; 在应用程序中,external 声明可以定义在任意单元的接口或实现部分。如果声明在实现部分,它们只 对声明的单元本地是可用的。如果声明在接口部分,则任何包括了声明单元的单元都可以访问它们(后者 是 Delphi 用来把 Windows API 导入到 Window.pas 和 messages.pas 单元中的方法,可以透明地调用这些过 程而无须知道是在使用 Windows API)。 使用 external 子句声明过程,将使得应用程序运行时立即装载 DLL 并解析该过程的地址。当 DLL 不 再使用时,Windows 将负责卸载 ContactServer.dll。下面列出的代码示范了使用 DLL 过程的例子。 var Contact : TContact; begin Initialize; try Contact.Name := 'Paul Kimmel'; Contact.Phone := '(517) 555-1212'; AddContact( Contact ); Contact.Name := 'Frank Arndt'; Contact.Phone := '(517) 555-2121'; AddContact( Contact ); ShowMessage( IntToStr( CountContacts )); RemoveContact( '(517) 555-2121' ); ShowMessage( IntToStr( CountContacts )); finally Finalize; end; end; 注意:使用 DLL 的好处在于许多应用程序可以同时使用同一 DLL,而用户无须关心 DLL 的实 现细节。在上例中,TContacts 记录的存储是无序的。 隐式装载的好处是比较容易,而且 Windows 会管理 DLL。而动态装载较为困难,需要使用过程类型 得到 DLL 过程的地址。在上一节中您已经学过这个,不会有任何问题。 动态 DLL 装载 当由程序员决定何时装载 DLL 时,称 DLL 是动态装载的。调用定义在 kernel32.dll 中的 API 函数 LoadLibrary 即可动态装载 DLL,并存储服务器的句柄。动态装载的库在使用完毕之后必须释放,以 LoadLibrary 返回的句柄为参数调用 FreeLibrary 即可。在装载与释放库之间,要使用动态装载的 DLL,需 要调用 GetProcAddress 得到 DLL 中过程的地址,并将该地址赋值给具有正确的过程类型的变量。通过过 第7章 抽象类和静态接口 169 程类型的变量才能调用 DLL 过程。 按照需要装载库,可以使应用程序更快地启动并使用更少的内存,可称之为惰性实例化。可以在需要 DLL 的功能时载入它。如果不需要,就不必装载对应的库,因为应用程序的每次运行并不都使用所有的功 能。 使用上一节中联系信息的例子,动态载入库的代码版本如下。 var AddContact : procedure( Contact : TContact ); RemoveContact : procedure( Phone : String ); CountContacts : function : integer; Initialize, Finalize : procedure; function SafeGetProcAddress(hModule: HMODULE; lpProcName: LPCSTR): FARPROC; begin result := GetProcAddress( hModule, lpProcName ); if( result = Nil ) then Abort; end; procedure TForm1.FormCreate(Sender: TObject); begin Handle := LoadLibrary( 'ContactServer.dll' ); try AddContact := SafeGetProcAddress( Handle, 'AddContact' ); RemoveContact := SafeGetProcAddress( Handle, 'RemoveContact' ); CountContacts := SafeGetProcAddress( Handle, 'CountContacts' ); Initialize := SafeGetProcAddress( Handle, 'Initialize' ); Finalize := SafeGetProcAddress( Handle, 'Finalize' ); except Application.Terminate; end; end; procedure TForm1.FormDestroy(Sender: TObject); begin FreeLibrary( Handle ); end; 修订版本向客户程序的主窗体添加了 OnCreate 事件处理程序。该处理程序装载动态链接库,并将句柄 存储在类中的变量 Handle : LongWord 中,LongWord 是 LoadLibrary 的返回类型。每个导出的过程都存储 在与其同名的本地变量中,这些变量都具有与对应的 DLL 过程相适应的过程类型。过程 SafeGetProcAddress 负责检查 kernel32.dll 中的 API 函数 GetProcAddress 返回值不是 Nil。如果过程未成功装载,应用程序将退 出。OnDestroy 事件处理程序释放了库。 该实现模拟了隐式装载的方式,来从 DLL 中装载过程。但如果确实隐式装载 DLL 而失败,那是无法 恢复的。应用程序可以编译通过,但运行时会失败,这是非常尴尬的事情。如果动态装载 DLL,那么应用 程序将可以编译并能够运行,它会在试图使用 DLL 的功能时失败,但您可以优雅地从中恢复。 7.3.2 编写动态链接库 当创建 DLL 时,有四项基本的工作需要完成。您需要编写实现 DLL 的代码。您需要定义导出的过程。 您需要编写测试程序,对于测试程序,可以使用隐式装载方式,用 external 子句声明过程。您还必须解决 在测试中发现的问题。请记住,编写进程内 DLL 的好处在于隐藏了 DLL 的实现细节,减少了使用该 DLL 的客户程序的实现复杂性,从而增加了重用的可能性。即,可能有多个程序使用该 DLL。 您已经知道如何编写应用程序。创建 DLL 应用程序意味着需要创建库工程。可以选择菜单项 File,New,Other,并单击 DLL Wizard 应用程序类型(如图 7.1 所示)。当由 Delphi 的主菜单选择 Project,View 第7章 抽象类和静态接口 170 Source 菜单项时,可以看到.DPR 文件的源代码。它与可执行文件稍有不同。 library Project1; { Important note about DLL memory management: ShareMem must be the first unit in your library's USES clause AND your project's (select Project-View Source) USES clause if your DLL exports any procedures or functions that pass strings as parameters or function results. This applies to all strings passed to and from your DLL—even those that are nested in records and classes. ShareMem is the interface unit to the BORLNDMM.DLL shared memory manager, which must be deployed along with your DLL. To avoid using BORLNDMM.DLL, pass string information using PChar or ShortString parameters. } uses SysUtils, Classes; {$R *.RES} begin end. 创建工程后所需做的与其他的应用程序区别不大,如添加窗体、单元以及数据模块等。不同之处在于 库不能作为单独的应用程序运行,而用户只能直接访问导出的过程。 定义 exports 子句 为简明起见,我们将使第一个 DLL 简单些。单击 File,New,Other 并双击 DLL Wizard,创建一个新的库 工程。在 begin 和 end.块语句(库中第一个执行的块语句)之前添加下列代码。 Procedure HelloFromServerLand; begin ShowMessage('Hello from server land'); end; exports HelloFromServerLand; 图 7.1 选择 File,New,Other 菜单项并单击图中所示的 DLL Wizard 按钮,即可创建 DLL 应用程序 向 库 的 uses 子 句 添 加 Dialogs , 该 单 元 包 括 了 ShowMessage 过 程 。 exports 子 句 表 示 只 导 出 第7章 抽象类和静态接口 171 HelloFromServerLand,它是该 DLL 的用户所能调用的惟一过程。按下列步骤测试 DLL。 1.单击 View,Project Manager 菜单项,显示工程管理器,如图 7.2 所示。 图 7.2 在 Project Manager 视图中可以看到,工程组 中添加了一个 DLL 工程和一个可执行工程 2.单击 ProjectGroup1,并单击 Project Manager 对话框中的 New 按钮。 3.从如图 7.3 所示的 New Items 对话框中选择 Application。 图 7.3 在 Project Manage 中双击 New 所打开的 New Items 对话框, 也可从 Delphi 的主菜单中选择 File,New,Other 打开 4.从 project2.exe 工程下所列出的文件中选择 Unit1。 5.打开代码编辑器,添加 external 声明,以装载‘Project1.dll’库(默认命名)并导入 HelloFromServerLand 过程。 procedure HelloFromServerLand;external 'Project1.dll'; 6.在 Project Manager 视图中,选定 Project2.exe 并双击该应用程序。 7.双击可执行应用程序的 Form1 窗体以创建 TForm1.FormCreate 事件方法并在 begin 和 end 块之间键 入: HelloFromServerLand; 8.确认该可执行应用程序,即测试程序,是当前选定的应用程序,按键 F9 运行程序。将显示简单的 对话框,其中有文字“Hello from server land”。 第7章 抽象类和静态接口 172 在这 8 个步骤中,您创建了 DLL 并进行了测试。余下的问题是实现一些有用的行为。关于瘦客户编 程的章节涵盖了更多实质性的主题。本节余下的部分涵盖了 DLL 编程的机制。 库的初始化代码 与可执行应用程序的 DPR 源代码文件相似,库工程的源文件也包含 begin 和 end 块语句。在主块语句 的 begin 与 end 之间可以放入任何用于初始化库工程的代码。主块语句与 C 或 C++应用程序的 main 过程或 非 Delphi 编写的应用程序中的 LibMain 过程相似。 根据经验,要保持 initialization 部分相对简单。按照库的需要,单元、窗体和数据模块的数目是不限 的,而每个模块都有 initialization 和 finalization 部分。每个单元的 initialization 和 finalization 部分都在库的 begin 与 end 块语句之前运行。因此在需要初始化的单元中,可以可靠地执行单元级的初始化。因此要保 持库的初始化代码简单。 禁止使用全局变量 Delphi 应用程序不能导入全局变量。全局变量通常不是好主意,这一规则对于 DLL 和普通的应用程 序都是适用的。在 DLL 和客户应用程序之间传递数据的最好的方法是使用类。可以越过模块边界传递对 象。对于像记录和过程类型这样的简单类型,可以在单元中声明该类型并在客户和 DLL 中都包括该单元, 这样就可以越过模块边界共享该类型的变量。 在有关隐式装载 DLL 的一节中,ContactServer.dll 接收了 TContact 紧缩记录类型的变量。但仍然不能 跨越模块边界共享全局数据,只能越过模块边界得到数据。最好通过 DLL 中定义的过程来完成这项工作。 关于如何实现实用的 DLL 以及从 DLL 向应用程序传递对象,请阅读有关瘦客户程序设计的章节。 7.3.3 处理 DLL 异常 动态链接库过程所引发的异常如果没有在 DLL 中进行处理,将会跨越模块边界,可以在调用该过程 的应用程序中使用 try except 或 try finally 块语句进行处理。 Procedure RaiseDLLExceptionProc; begin raise Exception.Create('Raised in the DLL!'); end; exports RaiseDLLExceptionProc; 创建一个新的库工程并添加上述代码。为测试该异常,向工程组添加一个简单的测试程序并声明外部 过程 RaiseDLLExceptionProc。当在应用程序中调用 RaiseDLLExceptionProc 过程时,将引发异常,而可执 行应用程序可以对其进行响应(见图 7.4)。向测试程序添加如下代码。代码将调用 DLL 过程并示范了在 客户程序中捕获 DLL 产生的异常(ShowException 代表了 Delphi 对于未处理的异常所提供的默认行为)。 图 7.4 DLL 过程 RaiseDLLExceptionProc 所引发的异常 try RaiseDLLExceptionProc; except on E : Exception do ShowException( E, ExceptAddr ); end; 第7章 抽象类和静态接口 173 可以使用与可执行文件相同的原则来编写和处理 DLL 中的异常。对于 DLL 中的错误处理,异常是谨 慎而正确的选择。 7.3.4 对字符串参数使用共享内存管理器 Delphi 中的字符串并非零结尾的 ASCII 字符串。零结尾的 ASCII 字符串,第一个字符位于位置 0,以 字符 0 结尾。Delphi 自动进行动态字符串管理,并在字符串文字的第一个字符位置之前包含了一些额外的 数据。 Function MyLength( const S: String ) : Integer; asm mov ecx,[eax-4] mov result,ecx end; 上述代码对于传递给参数 S 的字符串值,返回其长度。默认使用了寄存器调用规范,参数列表中的第 一个参数存储在 EAX(累加器)寄存器中。EAX 表示了指向字符串的指针。指令 mov ecx, [eax-4]读出字 符串起始地址向下偏移 4 个字节处的 32 比特值。 图 7.5 Delphi 在字符串起始之前存储了额外的信息,如图所示,也可看上面的代码 提示:正如每个库文件开头的“重要注记”所提示的,使用字符串参数或嵌套字符串参数需 要在 uses 子句中包括 sharemem 单元并将 BorlandMM.dll 与您的应用程序一同发布,否则需 要对参数值使用 PChar 或 shortstring 类型。 如果调用需要 PChar 参数的方法,Delphi 字符串与零结尾字符串是兼容的;如果在导出的过程中传递 字符串参数,则需要包括 sharemem.pas 单元。在库和使用库的应用程序的 uses 子句中,Sharemem.pas 都 应是第一个声明的单元。您还需要将 BorlandMM.dll(Borland 内存管理器)与程序一同发布,sharemem.pas 使用该内存管理器来支持 Delphi 的字符串参数。 7.3.5 创建工程组 当创建新的应用程序时,Delphi 生成一个工程组。如果向工程组中加入了多个工程,当保存文件时 Delphi 会提醒您保存工程组。使用工程组,可以更容易编译、测试以及调试多个相互依赖的应用程序。例 如,已经建立了上一节的库工程,如果要测试 RaiseDLLExceptionProc 工程,可以向其工程组添加一个应 用程序工程。双击应用程序工程使其称为当前工程,并按键 F9 运行该应用程序。工程组中的 DLL 也会被 编译。要更新一个工程组中的所有文件,可以在 Project 菜单中选择 Build All Projects 菜单项。 7.3.6 测试 DLL 测试 DLL 与其他应用程序很相似。在将 DLL 合并到实际的应用程序产品工程组之前,较好的办法是 建立轻量级的工作台,试验一下 DLL 的功能。 注意:为测试新的单元、模块或类,所用的测试代码或建立测试程序的过程,可称之为工作 台测试(scaffolding)。 工作台测试的好处在于它提供了环境,使得可以把调试和测试的注意力集中到单一的单元、模块或类 上。编写工作台时要注意,应该可以直接而方便地测试所有代码路径。另一个好处是,如果新增的代码无 法以这种方式测试,那么就意味着存在相互依赖——即紧耦合关系,同时也说明新增的代码是不完全的。 第7章 抽象类和静态接口 174 组件是个很好的例子,其代码必须独立于最终使用它的代码。代码的聚合有助于完善组件,但通常情 况下组件引用使用它的代码则是毫无道理的。上一条规则的例外是在 TGrid 组件中所示例的双向关联关系。 栅格知道 Columns 的存在,而 Columns 也了解包含它的栅格的存在。无论在编写组件、实用工具模块还是 对话框窗体,都可以把代码剥离其程序的上下文环境,看是否可以在简单的工作台程序中对其进行测试。 如果发现该代码需要大量的应用程序代码来支持,可以重新考虑其接口。 编译器开关 当调试 DLL 时,使用 Project Options 对话框中的 Compiler 属性页上的所有运行时错误设置。这些设 置包括范围检查、I/O 检查、溢出检查等,还要选中 Compiler 属性页上所有的调试选项。当选中 Compiler 属性页上的 Use Debug DCUs 选项后,可以在调试时步进到 VCL 代码中(参见图 7.6)。 当去除了 DLL 中所有的错误并准备分发时,请取消运行时错误选项和调试选项并对应用程序进行一 次完全的编译。这将删除调试代码,并减小编译后的应用程序的大小。 使用编译器指令动态添加和删除测试代码 编写用于测试和调试的代码是代价很高的。可以使用条件编译器指令来自动添加或删除调试代码,看 情况而定。可使用{$IFOPT}、{$ENDIF}条件编译指令和 D+开关来包括用于调试的代码。而使用 D-开关 则关闭调试信息。调试代码应是只读的。即,它不应改变执行的代码路径,因为这会在切换调试状态时导 致难于发现的发散行为。考虑下列例子。 图 7.6 选中 Project Options 对话框中 Compiler 属性页上的 Use Debug DCUs 选项。将可以在调试时跟踪 VCL 代码 var Ratio, Numerator, Denominator : Integer; begin Denominator := 0; Numerator := 10; {$ifopt D+} if( Denominator = 0 ) then begin // calculate approximation, log error or something Ratio := 0; end else {$endif} Ratio := Numerator div Denominator; 第7章 抽象类和静态接口 175 ShowMessage( IntToStr(Ratio)); end; 列出的代码示范了如果不包括调试信息将改变代码路径的情况。如果关闭调试信息,则不会包括 if then 语句的第一部分。将用于调试的代码与应用程序代码分离,并把调试代码用条件编译指令包裹起来,这样 才能得到正确的行为。 var Ratio, Numerator, Denominator : Integer; begin Denominator := 0; Numerator := 10; try Ratio := Numerator div Denominator; except on EDivByZero do begin Ratio := 0; {$IFOPT D+} // calculate approximation, log error or something {$endif} end; end; ShowMessage( IntToStr(Ratio)); end; 上面的修改显著地改善了代码质量。被零除的情况可以由异常处理程序捕获,因此不再需要多余的条 件测试;而负责记录错误或执行其他合适操作的调试代码也可以独立于应用程序代码。因此,调试代码不 会因为编译选项的状态而产生引入外部无关行为的潜在可能性。 7.4 瘦客户程序设计 瘦客户程序设计已经风行了好几年了。远在 OLE 变成 ActiveX 及其后的 COM 之前,Delphi 就已经支 持瘦客户程序设计了。瘦客户是一些应用程序,其外观与廉价的好莱坞布景类似。房子看上去是真的,但 没有房间。瘦客户包含了更多的东西,但理想情况下它们只包含了用于把数据表示出来的必要的信息。用 于提供逻辑行为的业务规则是与界面的行为相分离的,被包装在 DLL 中。其目的是尽可能的使改变客户 端更方便,而无须重新实现 DLL,甚至无须再次访问该 DLL。在 Internet 时代,许多管理人员预期许多应 用程序可能会重新实现,从而可以在 Web 上更为廉价对其进行访问。客户程序运行在桌面机上,而服务器 程序则位于硬件更为精良的服务器机器上,因此可以运行得更快。 注意:看一下 Citrix Systems 等公司股票的历史性的价格,就可以很清楚地知道各家公司 愿意在分布式计算上投资多少钱。Citrix 公司有一个产品名为 WinFrame,它可以取得瘦客 户程序的瞬间快照并将其在广域网之间发送。WinFrame 可以使一些旧的、缓慢的、单体式的 程序看起来就像是分布式瘦客户程序一样。WinFrame 的硬件服务器要花费数十万美元。 但如果开发者必须返回到结构化编程风格,才能创建业务对象服务器,那么随着新客户程序的潜力而 来的实际获利,将被生产率的下降所抵消。幸运的是,不需要使用结构化的编程风格。现在有两种很好的 选择,一种是旧的,另一种是新的。可以使用 Delphi 中的抽象接口从 DLL 向应用程序传递对象,Object Pascal 对此具有内建的支持;或者使用微软的 COM 和 DCOM 协议。Delphi 对两者都是支持的。在 Delphi 中, 使用抽象接口或 COM 和 DCOM 是两种平行的技术,它们都提供了可跨越进程边界访问对象的手段。最大 的不同之处是它们的实现方式,另外 COM 和 DCOM 在各种语言中均可使用,并不限于 Delphi。COM 和 DCOM 将在本书的后续章节中讨论,其中会使用一些实际的例子。本章的其余部分示范了如何定义服务器, 第7章 抽象类和静态接口 176 以及跨越进程边界传递业务对象的引用。 7.4.1 使用类引用 类引用定义了变量为类的类型。其语法为 TTypeClass = class of TType,其中 TType 是该类型变量所引 用的类,而 TTypeClass 是新的类型。类似于 TTypeClass 的类可称之为元类。 type TClass = class of TObject; 上面定义了新类型 TClass。声明的 TClass 变量是对类的引用。通常编写的代码类似于 Foo( AOwner : TComponent ),编译器认为参数是对象的引用。但如果声明类引用类型 TButtonClass = class of TControl 并 定义方法 Foo(AButtonClass : TButtonClass),则编译器认为参数并非是对象引用而是类引用,AButtonClass 并非实例,它是类。考虑如下代码。 unit UClassOfDemo; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, ExtCtrls, Buttons; type TButtonClass = class of TControl; TForm1 = class(TForm) RadioGroup1: TRadioGroup; procedure RadioGroup1Click(Sender: TObject); private { Private declarations } Procedure CreateButton( ButtonClass : TButtonClass ); public { Public declarations } Procedure OnClick( Sender : TObject ); Procedure ClearButtons; end; var Form1: TForm1; implementation {$R *.DFM} procedure TForm1.ClearButtons; var I : Integer; begin for I := 0 to ControlCount - 1 do if( Controls[I] Is TButton ) or (Controls[I] Is TSpeedButton)then Controls[I].Free; end; type TFudgeControl = class(TControl); procedure TForm1.CreateButton(ButtonClass: TButtonClass); var AButton : TControl; begin AButton := ButtonClass.Create(Self); 第7章 抽象类和静态接口 177 try AButton.Parent := Self; TFudgeControl(AButton).Caption := ButtonClass.ClassName; TFudgeControl(AButton).OnClick := OnClick; AButton.SetBounds( 1, 1, 75, 25 ); except AButton.Free; end; end; procedure TForm1.OnClick(Sender: TObject); begin ShowMessage( Sender.ClassName ); end; procedure TForm1.RadioGroup1Click(Sender: TObject); const BUTTONS : array[0..2] of TButtonClass = (TBitBtn, TButton, TSpeedButton); begin ClearButtons; CreateButton( BUTTONS[ RadioGroup1.ItemIndex ] ); end; end. 窗体上添加了一组单选按钮。当单击单选按钮组时,代码将动态地创建对应类型的按钮(见图 7.7)。 在窗体类中有四个方法:事件方法 RadioGroup1Click 响应对单选按钮组中按钮的单击;OnClick 事件方法 将被分配给动态创建的按钮;ClearButtons 方法负责将按钮数目保持为 1。 图 7.7 单击单选按钮,上述代码将动态创建对应类型的按钮 在公有接口中,类引用类型定义为 TButtonClass = class of TControl。之所以使用 TControl,是因为 TSpeedButton 是基于图形的控件,而 TButton 和 TBitBtn 具有相似的祖先。TControl 是最接近的共同祖先。 由列出的第一个方法开始,单选按钮组的单击事件处理程序使用按钮项的索引来判断请求创建哪一种控 件。数组 BUTTONS 包含了三个元素,都是 TButtonClass 类型。该方法将首先清除窗体上已存在的按钮, 并将结果按钮类型——类引用而不是对象引用传递给 CreateButton 作为参数。 可以注意到 CreateButton 并不关心要创建的类的类型,它只需知道该类为 TButtonClass 类引用即可。 在参数 ButtonClass 中所传递的类引用可基于按钮类型调用正确的构造函数。由于 AButton 定义为 TControl 类型,其中 Parent、Caption 和 OnClick 等属性均为保护权限的方法,因此定义了 TControl 类型的别名(请 回忆一下,保护权限的方法在其定义的单元中是可访问的。因此 TFudgeControl 使得可以访问保护权限的 属性)。由于 CreateButton 是私有的,因此我们可以确信只有合适的类才会传递给该方法,这样代码就可以 可靠地工作。结果在一个代码块中可以创建几种对象。 7.4.2 定义纯虚抽象类 纯虚抽象类的成员都是抽象的。纯虚抽象类不会创建实际的实例,它们只是为了定义子类中必须支持 第7章 抽象类和静态接口 178 的方法而存在。纯抽象方法在方法声明的结尾处使用编译器指令 virtual 和 abstract 即可。 type IAbstract = class public procedure NoImplementation; virtual; abstract; end; 列出的代码定义了一个抽象类 IAbstract,它有一个方法 NoImplementation。实现部分将不会定义 NoImplementation 方法的代码。这里的惯例是用 I 前缀代替 T 前缀(COM 也对接口使用 I 前缀命名惯例)。 从 代 码 中 可 知 , IAbstract 默 认 的 从 TObject 子 类 化 而 来 , 而 IAbstract 的 所 有 子 类 都 至 少 要 实 现 NoImplementation 方法。 type TSubClass = class(IAbstract) public procedure NoImplementation; override; end; implementation procedure TSubClass.NoImplementation; begin // some code end; TSubClass 是 IAbstract 的派生类,它的一个方法实现了 NoImplementation。因为父类是抽象的,因此 无须调用继承的方法。 抽象类的好处在于,可以从抽象父类派生任意所需数目的子类。通过声明 IAbstract 类型的变量而将对 象实现为子类的类型,可以定义动态的代码,从而在运行时实现任意的子类。抽象类的这种使用方式与 COM 的工作方式是一致的,更为重要的是可以使用抽象类来定义接口,而在想要的任何地方实现该接口。 例如,接口的实现可存在于另一个应用程序中。这对于使用 Delphi 本地代码和 COM 进行瘦客户编程都是 关键之所在。 7.4.3 创建面向对象的 DLL 在 7.3.1 节“调用 DLL 过程”中,我们使用了一个简单的联系信息管理器。为使代码更加健壮、易用, 应将联系信息定义为类,并创建联系信息列表来存储并管理联系信息项。DLL 并不能把数据传递给用户, 但它知道所有的联系信息和联系信息列表(下一节定义客户应用程序) 。 由于要创建瘦客户程序来管理联系信息管理器的数据输入,需要定义一个抽象接口,客户程序和 DLL 都将用到该接口。而联系信息类和列表的实际实现只定义在 DLL 中。 unit XContact; // XContact.pas - Contains abstract implementation of a contact and contact list. // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses classes; type IContact = class; // forward declaration 第7章 抽象类和静态接口 IContactList = class; TContactClass = class of IContact; TContactListClass = class of IContactList; IContact = class protected function GetEMail: string; virtual; abstract; function GetName: String; virtual; abstract; function GetPhone: String; virtual; abstract; procedure SetEmail(const Value: string); virtual; abstract; procedure SetName(const Value: String); virtual; abstract; procedure SetPhone(const Value: String); virtual; abstract; public constructor Create( const Name, Phone, EMail : String ); virtual; property Name : String read GetName write SetName; property Phone : String read GetPhone write SetPhone; property EMail : string read GetEMail write SetEmail; end; IContactList = class protected function GetList : TList; virtual; abstract; function GetContact( Index : Integer ) : IContact; virtual; abstract; procedure SetContact( Index : Integer; const Value : IContact); virtual; abstract; function GetCount : Integer; virtual; abstract; public constructor Create; virtual; procedure Add( Contact : IContact ); virtual; abstract; procedure Remove( COntact : IContact ); virtual; abstract; property Contacts[Index : Integer] : IContact read GetContact write SetContact; default; property List : TList read GetList; property Count : Integer read GetCount; end; implementation { IContactList } constructor IContactList.Create; begin inherited; end; { IContact } constructor IContact.Create( const Name, Phone, EMail : String ); begin inherited Create; Self.Name := Name; Self.Phone := Phone; Self.EMail := EMail; end; end. 179 第7章 抽象类和静态接口 180 在 XContact.pas 单元中定义了两个类型,分别是 IContact 和 IContactList 的类引用。这对于把实现类从 DLL 传递到进行调用的应用程序是必要的。一般的,抽象类只定义了接口,它由虚抽象方法组成而没有实 际的数据。请注意 virtual 和 abstract 属性方法可用于表示数据(关于特性的更多知识请阅读第 8 章)。客户 程序所能得到的关于联系信息和列表的惟一表示就是抽象类。 联系信息和列表的实现定义在 UImpContact 单元中。只有 DLL 才实现了这两个类。如果客户程序也实 现了这两个类,使用 DLL 就没有什么好处了。 unit UImpContact; // UImpContact.pas - Contains the implementation of contact and contact list // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses XContact, Classes, SysUtils; type TContact = class(IContact) private FEMail : String; FName : String; FPhone : String; protected function GetEMail: string; override; function GetName: String; override; function GetPhone: String; override; procedure SetEmail(const Value: string); override; procedure SetName(const Value: String); override; procedure SetPhone(const Value: String); override; public property Name : String read GetName write SetName; property Phone : String read GetPhone write SetPhone; property EMail : string read GetEMail write SetEmail; end; TContactList = class(IContactList) private FList : TList; protected function GetList : TList; override; function GetContact( Index : Integer ) : IContact; override; procedure SetContact( Index : Integer; const Value :IContact); override; function GetCount : Integer; override; public constructor Create; override; procedure Add( Contact : IContact ); override; procedure Remove( COntact : IContact ); override; destructor Destroy; override; property Contacts[Index : Integer] : IContact read GetContact write SetContact; property List : TList read GetList; property Count : Integer read GetCount; 第7章 抽象类和静态接口 end; implementation { TContact } function TContact.GetEMail: string; begin result := FEMail; end; function TContact.GetName: String; begin result := FName; end; function TContact.GetPhone: String; begin result := FPhone; end; procedure TContact.SetEmail(const Value: string); begin FEmail := Value; end; procedure TContact.SetName(const Value: String); begin FName := Value; end; procedure TContact.SetPhone(const Value: String); begin FPhone := Value; end; { TContactList } constructor TContactList.Create; begin inherited; FList := TList.Create; end; destructor TContactList.Destroy; begin while( FList.Count > 0 ) do begin TContact(FList.Items[0]).Free; FList.Delete(0); end; FList.Free; inherited; end; 181 第7章 抽象类和静态接口 182 procedure TContactList.Add( Contact : IContact ); begin FList.Add( Contact ); end; procedure TContactList.Remove( Contact : IContact ); begin FList.Remove( Contact ); end; function TContactList.GetContact(Index: Integer): IContact; begin result := TContact(FList.Items[Index]); end; function TContactList.GetCount: Integer; begin result := FList.Count; end; function TContactList.GetList: TList; begin result := FList; end; procedure TContactList.SetContact(Index: Integer; const Value: IContact); begin FList.Insert( Index, Value ) end; end. 这里有几个令人惊异之处。关键是要记住,只有 DLL 才知道类是如何实现的。库的源代码也非常简 单:导出两个函数,分别返回对 TContactClass 和 TContactListClass 的引用。将返回这些类的实现形式,而 不是其抽象版本。 library NewContactServer; uses ShareMem, SysUtils, Classes, XContact in 'XContact.pas', UImpContact in 'UImpContact.pas'; {$R *.RES} function ContactClass : TContactClass; begin result := TContact; end; function ContactListClass : TContactListClass; begin result := TContactList; end; exports ContactClass, ContactListClass; 第7章 抽象类和静态接口 183 begin end. 这就是 DLL 所需要做的工作。如果仔细检查代码,可以发现在 DLL 与其他应用程序之间惟一的不同 之处在于,增加了抽象接口类的层次。现在看一下客户程序是如何实现的。 7.4.4 创建瘦客户程序 现在 DLL 程序已经完成(见上一节) ,客户程序就更简单了。为便于添加、删除、查找联系信息,创 建了图 7.8 中的窗体。除了窗体上的组件之外,客户程序十分简单。 图 7.8 用于测试联系信息和列表类的数据输入窗体示例 unit UTestContact; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, XContact, ComCtrls; type TForm1 = class(TForm) Label1: TLabel; Label2: TLabel; Label3: TLabel; EditName: TEdit; EditPhone: TEdit; EditEMail: TEdit; ButtonAdd: TButton; ButtonRemove: TButton; ButtonFind: TButton; StatusBar1: TStatusBar; procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); procedure ButtonAddClick(Sender: TObject); procedure ButtonRemoveClick(Sender: TObject); procedure ButtonFindClick(Sender: TObject); private { Private declarations } FCurrentContact : IContact; ContactList : IContactList; Procedure UpdateCount( Count : Integer ); public { Public declarations } end; 第7章 抽象类和静态接口 184 var Form1: TForm1; implementation {$R *.DFM} function ContactClass : TContactClass; external 'NewContactServer.dll'; function ContactListClass : TContactListClass; external 'NewContactServer.dll'; procedure TForm1.FormCreate(Sender: TObject); begin FCurrentContact := Nil; ContactList := ContactListClass.Create; end; procedure TForm1.FormDestroy(Sender: TObject); begin ContactList.Free; end; procedure TForm1.ButtonAddClick(Sender: TObject); begin FCurrentContact := ContactClass.Create( EditName.Text, EditPhone.Text, EditEMail.Text ); ContactList.Add( FCurrentContact ); UpdateCount( ContactList.Count ); end; procedure TForm1.ButtonRemoveClick(Sender: TObject); begin if( Assigned(FCurrentContact)) then begin ContactList.Remove( FCurrentCOntact); UpdateCount( ContactList.Count ); end; end; procedure TForm1.ButtonFindClick(Sender: TObject); var I : Integer; begin for I := 0 to ContactList.Count - 1 do if( ContactList[I].Phone = EditPhone.Text ) then begin FCurrentContact := ContactList[I]; EditName.Text := FCurrentContact.Name; EditPhone.Text := FCurrentContact.Phone; EditEMail.Text := FCurrentContact.EMail; exit; end; MessageDlg( 'Contact phone number not found', mtInformation, [mbOK], 0); 第7章 185 抽象类和静态接口 end; Procedure TForm1.UpdateCount( Count : Integer ); begin StatusBar1.SimpleText := Format( 'Count: %d', [Count] ); end; end. 类中包含了构成界面的控件,以及五个事件方法。FormCreate 和 FormDestroy 分别负责初始化和释放 抽象类引用 FCurrentContact 和 FContactList,而按钮单击事件方法则分别进行添加、删除和查找等行为。 在单元的实现部分,导入了 DLL 函数 ContactClass 和 ContactListClass,从而提供了访问联系信息和联 系信息列表类的途径(注意:接口部分的 uses 子句只能直接使用类的抽象版本)。FormCreate 方法示范了 如何通过 ContactListClass 类引用(它是一个 TContactList 类)来创建实际的联系信息列表(直接实例化 IContactList 类是错误的,虽然它是所声明的引用的类型)。导入的服务函数提供了对实现对象的访问,剩 下的只要调用所需的函数即可。 这样工作就完成了。要跨越进程边界使用对象,首先要有抽象接口,它表示了要使用的功能,同时还 要使 DLL 返回实现了该接口的子类化的版本。这也是个很好地描述了 COM 的工作方式的模型。现在 DLL 的复杂性已经解决了,您就可以处理技术方面的问题了。 7.5 小 结 第 7 章涵盖了一些支持瘦客户程序设计和 DLL 创建方面的话题。Delphi 直接支持跨越模块边界传递 对象。理解了接口和类引用,就可以很容易地编写出可重用的 DLL,其中包含了所需的业务逻辑。本章对 于理解组件对象模型(COM)是个很好的基础。在 Delphi 抽象接口和 COM 接口之间的相同之处要多于不 同之处。利用本章中的技术,您可以编写出一些相似的 Delphi 应用程序和 DLL,也可以作为进一步理解 COM 的踏脚石。 第 8 章 高级特性编程 对任何快速应用程序开发环境来说,特性都是必要的。在设计时需要可视化操纵属性来产生界面效果, 简单的数据在这种工作环境下显得不够智能化。在 Bjarne Stroustrop 创建 C++编程语言时对这一点可能有 所了解,因为通过操作重载可以重定义对象的操作符。这样在 C++程序中将对象作为左操作数或右操作数 使用时,将会调用重载操作符方法。但对于设计时环境来说,这是个过于复杂的解决方案。要更好地管理 数据,可以创建通过读写方法进行访问的数据。特性解决了该问题。Visual Basic 可以处理设计时特性,但 程序员无法控制特性读写方法的代码。Delphi 是第一个可以对数据进行直观而一致的读写的程序。 术语特性把所有的东西都联系到了一起。消息和事件与 Windows 相联系,方法描述对象的行为,数据 描述对象的状态,而特性则通过称之为特性存取限定符的标记方法对私有数据提供了受限的访问途径。避 免破坏数据是 C++作出的承诺之一。Delphi 通过特性存取限定符实现了该承诺。如果类内部的数据使用是 正确的,而且存取限定符限定了私有字段的访问方式,那么数据是不会被破坏的。 本章中,我们将仔细探索特性的方方面面。您将学到怎样编写数组特性、索引特性和虚特性。本章还 将示范如何定义默认特性和存储值。当您学过本章后,将对 Delphi 的面向对象程序设计有一个完整的印象。 8.1 声 明 特 性 特性是数据的表示,它们就像是入口一样。通常特性表示私有数据,即 Object Pascal 术语中所说的字 段。惯例是以 F 前缀命名私有字段,去掉 F 后则成为特性名。特性名的权限一般是公有或公开的。通常公 开的特性是为组件保留的。特性声明的一般语法如下。 property PropertyName: DataType read ReadIdentifier write WriteIdentifier; 关键字 property 是必须的。PropertyName 通常是特性所表示的字段名去掉 F 前缀得到的。这样如果特 性表示用于存储名字的数据,那么私有字段的名字是 FName 而特性的名字则为 Name。如果字符串的数据 类型是合适的,那么对于特性和字段来说 DataType 都是 string。ReadIdentifier 和 WriteIdentifier 可以是字段。 下面是一个类,其中包含了定义正确的名字特性和字段特性。 type TContact = class private FName : String; public property Name : String read FName write FName; end; 当定义 TContact 类的实例并对 Contact.Name 进行写入,那么实际上修改了字段数据 FName。上面的 例子是对特性的最简单的使用。但与将 FName 定义为公有数据元素相比,上面的用法并未对 FName 提供 任何额外的控制。 8.1.1 存取限定符 有时将特性解析到数据就足够了。其余情况下,您可能希望控制数据的读写方式。我们来考虑一个 TStrings 类型的特性。假定在联系消息中有电话号码列表,但这些号码并不总是使用。您可能希望对这些 字符串定义惰性的实例。可以编写算法,使之具有如下的惰性实例化逻辑。 On read instantiate and initialize the list of phone numbers. 第8章 高级特性编程 193 (在读取特性时,实例化并初始化电话号码的列表) 下面的类示范了该用法。 type TContact = class private FName : String; FPhoneNumbers : TStrings; function CreatePhones : TStrings; protected function GetPhoneNumbers : TStrings; procedure SetPhoneNumbers( const Value : TStrings ); public constructor Create; virtual; destructor Destroy; override; property Name : String read FName write FName; property PhoneNumbers : TStrings read GetPhoneNumbers write SetPhoneNumbers; end; 注意:在列出的代码中,可以看到声明了一个 TStrings 类型字段但却创建了 TStringList 类型的实例。TStrings 是抽象类型,因此可以使用任何 TStrings 类的子类。许多现存的过 程都需要 TStrings 类型的参数。这样如果声明了 TStrings 类型的数据,就可以与现存的过 程相兼容;但访问 TStrings 的实例会导致 EAbstractError 异常。按照通常的规则,在可能 的情况下可以把变量或参数声明为抽象超类,而传递子类的实例。 上述的 Name 特性是直接读写字段的。但如果 Name 对应的字段具有持久属性,那么我们可能需要定 义一个 write 方法,在方法中将该对象标记为修改过的,以便可以将改变保存(到数据库或其他的持久存 储设施)。PhoneNumbers 特性示范了 read 和 write 方法。当使用 object.PhoneNumbers 时并不直接读写字段, 若 PhoneNumbers 作为右值使用,则调用 GetPhoneNumbers,如果 PhoneNumbers 作为左值使用,则调用 SetPhoneNumbers。 使用读写方法意味着,当引用该特性时会调用相应的过程或函数。TContact 新的实现版本示范了如何 在第一次访问 PhoneNumbers 时创建其惰性实例。 implementation constructor TContact.Create; begin inherited; FPhoneNumbers := Nil; end; function TContact.CreatePhones : TStrings; begin if( Not Assigned(FPhoneNumbers)) then FPhoneNumbers := TStringList.Create; result := FPhoneNumbers; end; destructor TContact.Destroy; begin FPhoneNumbers.Free; inherited; end; function TContact.GetPhoneNumbers: TStrings; begin 第8章 高级特性编程 194 result := CreatePhones; end; procedure TContact.SetPhoneNumbers(const Value: TStrings); begin if( Value = FPhoneNumbers ) then exit; PhoneNumbers.Assign( Value ); end; procedure TForm1.Button1Click(Sender: TObject); begin with TContact.Create do begin PhoneNumbers := ListBox1.Items; Free; end; end; 构 造 函 数 将 电 话 号 码 字 段 初 始 化 为 空 。 即 使 只 是 将 数 据 初 始 化 为 Nil , 这 也 是 个 好 习 惯 。 当 PhoneNumbers 作为右值使用时,GetPhoneNumbers 将调用 CreatePhones;在该方法中,如果 FPhoneNumbers 仍然为 Nil,将实例化一个 TStringList 赋值给 FPhoneNumbers,然后返回对 FPhoneNumbers 的引用。不管 怎样总是返回对 FPhoneNumbers 字段的引用。SetPhoneNumbers 首先确认新的值并非只是引用了当前值, 如 果 是 这 样 的 话 将 退 出 该 方 法 。 如 果 Value 参 数 代 表 了 一 组 新 的 号 码 , 那 么 将 调 用 PhoneNumbers.Assign( Value ),以确保参数字符串列表中的值能够正确地赋值给 FPhoneNumbers 字段。可 以注意到在 SetPhoneNumbers 中的 Assign 方法使用了 PhoneNumbers 特性,而不是 FPhoneNumbers 字段。 在 SetPhoneNumbers 方法中的 PhoneNumbers 特性属于右值用法,即调用了读方法,以确保电话号码字符 串列表是存在的。 析构函数正确地清除了 FPhoneNumbers 中的字符串。Button1Click 事件方法示范了如何把列表框中的 字符串赋值给电话号码特性。如果您对控制流程仍不清楚,可以创建一个新的工程并选中 Projects Options 对话框中 Compiler 属性页上的 Use Debug DCUs 复选框,然后对步进跟踪每一行代码。 读存取限定符 读存取限定符总是一个函数,其返回类型与特性类型相同。按照惯例,该函数的名字前缀为 Get,后 接特性的名字,如下面的代码所示。PhoneNumbers 特性的读存取限定符如下: function GetPhoneNumbers : TStrings; 对于数组特性和索引特性,它们的读存取限定符函数有一个表示索引值的参数。更多的细节可以参见 有关数组特性和索引特性的章节。 写存取限定符 写存取限定符总是有一个参数的过程。参数列表通常形如 const Value : DataType,其中 DataType 与特 性的类型相同。按照惯例,过程名的前缀为 Set,后接特性的名字。因此 PhoneNumbers 的写存取限定符如 下: procedure SetPhoneNumbers( const Value : TStrings ); 数组特性和索引特性的写存取限定符有两个参数。第一个参数表示索引,第二个表示新的值。更多的 细节,可以阅读有关数组特性和索引特性的章节。 8.1.2 只读和只写特性的定义 有时候只用数据无法得到只读和只写属性。要使数据只读,可以只定义读方法;而要使数据只写,可 以只定义写方法。只读特性与常数相似。类的用户无法修改只读特性,这确保了数据不会被不适当的修改。 只写特性在技术上是可能的,但很少用到。 第8章 8.1.3 高级特性编程 195 针对处理器密集型特性修改的安全措施 如果数据进行了不必要的更新,那么导致处理器密集型更新的数据将会浪费珍贵的 CPU 时间。类似 于写入到数据库字段的文字,或写入到图像控件的 Picture 特性的图形,都可以在写方法进行检查,以确保 只对新的或改变的数据进行更新。 TMyGraphicControl = class(TCustomControl) private FImage : TImage; protected function GetPicture : TPicture; procedure SetPicture( const Value : TPicture ); public constructor Create( AOwner : TComponent ); override; destructor Destroy; override; property Picture : TPicture read GetPicture write SetPicture; end; implementation constructor TMyGraphicControl.Create(AOwner: TComponent); begin inherited; FImage := TImage.Create(AOwner); FImage.Parent := Self; FImage.Align := alClient; end; destructor TMyGraphicControl.Destroy; begin FImage.Free; inherited; end; function TMyGraphicControl.GetPicture : TPicture; begin result := FImage.Picture; end; procedure TMyGraphicControl.SetPicture(const Value: TPicture); begin if( FImage.Picture = Value ) then exit; FImage.Picture.Assign(Value); Repaint; end; 上面代码中的 SetPicture 写方法首先检查 Picture 图形,以确保 Value 参数并非 FImage.Picture 的别名。 不幸的是,这种类型的检查不能发现不同控件中的相同图像。代码需要比上面的相等测试更加精巧才行。 如果 TPicture 对象并非已存在的同一对象,则调用 Assign 方法进行赋值,该方法调用 TImage 类中定义的 SetGraphic 写方法并创建新的 TGraphic 类型对象,然后使用图形类的构造函数来复制实际的图像。最后调 用 repaint 方法,该方法向控件发出重新绘制的消息。 第8章 图 8.1 高级特性编程 196 使用 P 格式限定符来观察对象的地址 当测试代码以检查对自身的赋值时, 可以使用如图 8.1 所示的 Evaluate/Modify 对话框来查看对象引用。 在 Expression 域的末尾添加逗号和 P(,P),即可显示对象的地址(见图 8.1)。如果两个地址相同,那么 两个引用指向的是同一对象。Delphi 为 Evaluate/Modify 对话框提供了各种格式限定符,可以在 Result 域中 格式化数据(表 8.1 包含了格式限定符的完整列表)。 表 8.1 图 8.1 中显示的 Evaluate/Modify 对话框的格式 化字符,使您可以用更有意义的方式查看数据 限定符 影响类型 描述 ,C 字符和字符串 以 Pascal 中的#number 格式显示 0 至 31 的 ASCII 字符 ,S 字符和字符串 以 Pascal 中的#number 格式显示 0 至 31 的 ASCII 字符 ,D 整数 以十进制格式显示整数值 ,H 或,X 整数 以十六进制格式显示整数值,前缀为$ ,Fn 浮点数 显示 n 个有效比特位,其中 2 <= n <= 18。默认情况下 n 为 11 ,P 指针 显示 32 位地址 ,R 记录或对象 显示记录或对象的属性,包括字段名和值 ,nM 所有类型 显示 n 比特长的内存转储。默认以两位十六进制值进行格式化。将 nM 与 C、D、H、X 或 S 一同使用可以改变数据的格式 在图 8.1 的 Evaluate/Modify 对话框中使用格式限定符,可以使用对解决问题最有意义的方式观察数据。 将代码编辑器的光标置于要观察的数据上,按键 Ctrl+F7 即可打开 Evaluate/Modify 对话框。可以计算或修 改简单的数据或对象,也可在 Expression 域中写出复杂的表达式并观察其值。 8.1.4 使用 Assign 方法进行对象赋值 对象经常需要深复制,以确保将字段值正确地赋值给目标对象的字段。TPersistent 类引入了 Assign 和 AssignTo 方法可以提供深复制的行为。当进行对象赋值时,首先要决定是需要对象的引用还是对象的副本。 使用赋值操作符(:=)将一个引用赋值给一个对象。使用 Assign 方法可以将对象属性进行深复制。 如果进行引用赋值,那么并不需要对象的实例,只需变量引用即可。考虑下列代码: var A, B : TAssignableObject; begin A := TAssignableObject.Create; B := TAssignableObject.Create; A := B; // error, B's object is lost causing a memory leak end; 第8章 高级特性编程 197 上面列出的代码导致了内存泄漏。在 Delphi 中发生内存泄漏的可能性比 C++要小,但仍然可以发生。 在 C++的术语中,这种类型的内存泄漏称之为 slicing problem。应去掉创建 A 的语句,或在将 B 赋值给 A 之前释放 A 所引用的内存。A := B 意味着 A 是 B 的引用,即二者是同一对象。如果写 A.Assign( B ),那么 A 就是一个独立的对象,但其状态与 B 相同。 8.2 特性的存储限定符 存储限定符决定了如何维护运行时类型信息。具体的说,在保存窗体或数据模块时,存储限定符 default、nodefault 和 stored 决定了哪些公开特性将存储在.DFM 文件中。当 Delphi 保存窗体时,它会检查 组件的状态,并基于 property 语句中给出的存储限定符来对公开特性进行存储。 unit UDemoStorage; // UDemoStorage.pas - Contains several variations of storage specifiers // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs; type TDemoStorage = class(TComponent) private { Private declarations } FAnInteger : Integer; FAString : String; FADouble : Double; FASingle : Single; FColor : TColor; FFontStyles : TFontStyles; //set function IsStored : Boolean; function ImplicitDefault : Boolean; protected { Protected declarations } public { Public declarations } published { Published declarations } constructor Create( AOwner : TComponent ); override; property AnInteger : Integer read FAnInteger write FanInteger stored True default 13; property AString : String read FAString write FAString stored ImplicitDefault; property ADouble : Double read FADouble write FADouble stored True; property ASingle : Single read FASingle write FASingle stored IsStored; property Color : TColor read FColor write FColor default clBlue; property FontStyles : TFontStyles read FFontStyles write FFontStyles stored True default [fsItalic, fsBold]; end; 第8章 高级特性编程 198 procedure Register; implementation procedure Register; begin RegisterComponents('PKTools', [TDemoStorage]); end; { TDemoStorage } constructor TDemoStorage.Create(AOwner: TComponent); begin inherited; FAnInteger := 13; FColor := clBlue; FFontStyles := [fsItalic, fsBold]; end; function TDemoStorage.ImplicitDefault: Boolean; begin if( FAString = EmptyStr ) then FAString := 'Default'; result := True; end; function TDemoStorage.IsStored: Boolean; begin result := FASingle > 0.0; end; end. 上面的代码示范了存储限定符的各种不同的组合方式,在阅读本节时可以参考。 8.2.1 默认和非默认存储方式的使用 默认情况下,如果给出了存储限定符,那么只有在值被修改的情况下才对公开特性的值进行存储。这 里有几个从本节开头的代码中摘取的例子,它们使用了默认存储限定符。 property AnInteger : Integer read FAnInteger write FanInteger stored True default 13; property Color : TColor read FColor write FColor default clBlue; property FontStyles : TFontStyles read FFontStyles write FFontStyles stored True default [fsItalic, fsBold]; 提示:只有在指定了 nodefault 限定符并且 stored 值为 True 时,或指定了 default 限定符 而修改后的值与默认值不同时,才会存储特性值。程序员负责在对象的构造函数中进行默认 赋值。 第一个特性 AnInteger 将被存储,其默认值为 13。考虑下面对一个.DFM 文件的摘录,其中包含了一 个 DemoStorage 对象,它将绘制在窗体上而且不会改变。 object DemoStorage2: TDemoStorage AString = 'Default' Left = 120 Top = 64 end 考虑 AnInteger 特性,默认值 13 表示构造函数将把 AnInteger 初始化为 13,而后该特性将从 DFM 文 件中读取。默认值并不会自动地赋值给特性,需要程序员完成该工作。存储限定符 default 13 意味着如果 第8章 高级特性编程 199 值不是 13,就存储该特性。请注意 Color 的默认值为 clBlue,而 Color 特性并未指定 stored 限定符。现在 将 Color 特性改变为 clRed,查看.DFM 文件的对应片段。 object DemoStorage2: TDemoStorage AString = 'Default' Color = clRed Left = 120 Top = 64 end 现在可以看到 Color 特性被存储了。构造函数定义为将 clBlue 赋值给 Color,而当读取.DFM 文件时, 将修改 Color 来包含存储的值。您需要在构造函数中将特性初始化为默认值。这样减少了存储在 DFM 文 件中的信息量。 如 FontStyle 集合特性所示,您可以为集合设置默认值。Delphi 只对有序类型和集合直接支持默认值。 这样在 AString、ADouble 和 ASingle 等特性声明中不能使用 default 限定符。但可以标识出这些值是否进行 存储。默认情况下,实数、指针和字符串将初始化为相应的空值形式,只有在 Object Inspector 中出现非空 值时才进行存储。 object DemoStorage2: TDemoStorage AString = 'Default' ASingle = 0.300000011920929 Color = clRed Left = 120 Top = 64 end 例如在 Object Inspector 中,在 ASingle 的编辑域写入.3,那么在.DFM 文件中将包含 ASingle 特性(参 见前面的 DFM 文件片段) 。 8.2.2 使用 stored 限定符 如果不指定存储限定符,那么公开特性只有在 Object Inspector 中的值不等于空值时,才进行存储。对 于字符串,是空字符串;对于指针,是 Nil;对于实数,是 0。可以在关键字 stored 之后放置 True 或 False 或返回布尔值的函数来表示是否存储该值。 property AnInteger : Integer read FAnInteger write FanInteger stored True default 13; property AString : String read FAString write FAString stored ImplicitDefault; property ADouble : Double read FADouble write FADouble stored True; property ASingle : Single read FASingle write FASingle stored IsStored; property Color : TColor read FColor write FColor default clBlue; property FontStyles : TFontStyles read FFontStyles write FFontStyles stored True default [fsItalic, fsBold]; 只有在 Object Inspector 中 AnInteger 的值不是 13 时,才会存储 AnInteger 的值 (我们稍后再回到 AString 和 ASingle)。只有当 ADouble 的值不是 0 时才存储。默认情况下,在 Color 的值不是 clBlue 时才进行存储。 当 FontStyle 的值不是[fsItalic, fsBold]时才存储。如果在 FondStyle 的 stored 限定符后指定了 False,那么无 论是否在 Object Inspector 中设置了值,其值总是[fsItalic, fsBold]。即该值不会持久化。 AString 和 ASingle 特性使用函数来决定是否进行存储。 function TDemoStorage.ImplicitDefault: Boolean; begin if( FAString = EmptyStr ) then 第8章 高级特性编程 200 FAString := 'Default'; result := True; end; function TDemoStorage.IsStored: Boolean; begin result := FASingle > 0.0; end; 提示:要对有序类型和集合以外的特性模拟默认值,可以使用存储函数来动态地设置其值。 您不需要在构造函数中设置这些值,它们会从特性流中读出。 IsStored 函数没有参数,返回一个布尔值(这是存储限定符函数所必须的格式)。如果 FASingle 是非负 的,存储函数将返回 True。 如果 AString 为 EmptyStr,ImplicitDefault 方法将为 AString 定义一个实际值。由于 AString 的存储函 数的定义方式,该特性总是被存储的,如果 AString 为空字符串则‘Default’将存储在特性值中(参见前 面的窗体文件的片段,可以看到 AString 的值)。这与在构造函数中向 AString 赋值‘Default’是等价的。 不同之处在于,如果没有特性存储函数,在 Object Inspector 中特性的默认值看起来是空字符串,而实际上 其值为‘Default’。原因是特性是从.DFM 文件流中读出的,而没有调用构造函数。 8.3 定义数组特性 数组特性表示了可索引的数据,其索引方式与内建的数组相似。实际的数据以 TList 和 TStrings 等类 型更为常见;但也可以是内建的数组,通过存取方法来确保索引值位于有效范围内。由于数组特性也使用 同样的 get 和 set 方法,可以为需要通用接口访问的数据定义数组特性。 property PropertyName[ Index : IndexType ] : PropertyType read GetPropertyName write SetPropertyName; [default;] 按照惯例,在存在实际字段的情况下,PropertyName 是实际字段名去掉 F 前缀,这与其他特性的命名 惯例相同。IndexType 可以像内建数组一样使用有序类型,但索引并不只限于有序值。PropertyType 与实际 字段的类型相同。例如,如果实际字段是整数数组,特性类型就是整数。数组特性的存取限定符必须是方 法。 注意:对于以常量字符串值来索引的数组特性的例子,请参见 TString 类中 Values 特性的 声明:property Values[const Name : string ] : string;。 读方法定义为函数,有一个参数,类型与特性声明中的索引限定符相同,返回与特性类型相同的数据。 写方法有两个参数,索引参数和新值。假定某个类中的整数数组特性名为 Integers,其类声明可能如下: type TIntegers = class private FIntArray : array[1..10] of integer; procedure CheckRange( Index : Integer ); function GetIntArray(Index: Integer): Integer; procedure SetIntArray(Index: Integer; const Value: Integer); public property IntArray[ Index : Integer ] : Integer read GetIntArray write SetIntArray; default; end; FIntArray 是实际的字段值。TIntegers 表示由整数构成的智能数组。代表整数数组的特性定义为 IntArray,索引为整数,读方法为 GetIntArray,写方法定义为 SetIntArray。如上所述,读方法的惟一参数 第8章 高级特性编程 201 是索引值,而写方法的两个参数中,索引值在前,新的值定义为常量。 实现中使用了 CheckRange 过程,该过程确认索引在数组的上下界之间。 implementation procedure TIntegers.CheckRange(Index: Integer); begin if( Index < Low(FIntArray) ) or (Index > High(FIntArray)) then raise ERangeError.CreateFmt( 'Index %d exceeds array bounds', [Index] ); end; function TIntegers.GetIntArray(Index: Integer): Integer; begin CheckRange( Index ); result := FIntArray[Index]; end; procedure TIntegers.SetIntArray(Index: Integer; const Value: Integer); begin CheckRange(Index); FIntArray[Index] := Value; end; 实现是很简单的。每个存取函数都调用 CheckRange,当索引过界时该函数将引发异常。从外部看来, TIntegers 类型的对象就像是不加修饰的数组,而其工作方式与本地数组也很相似,但更加安全。 下面的例子演示了本地数组与 TIntegers 数组类之间的不同。 var A : array[1..10] of integer; Integers : TIntegers; I : integer; begin {$R-} I := 11; A[I] := 100; ShowMessage( InttoStr(A[I]) ); Integers := TIntegers.Create; try Integers[11] := 100; ShowMessage( IntToStr( Integers[11] )); finally Integers.Free; end; {$R+} end; 在列出的代码中,$R-编译器指令移去了范围检查,这代表了您的程序将进入的状态。变量部分定义 了整数数组 A。代码 A[I]不会引发错误,但因为 A[11]并不存在,实际上它重写了内存。该代码将导致奇 怪的、不可靠的行为。而 Integers 实例则工作正确,不管编译器指令是否存在。将 11 传递给 Integers 对象 将引发 ERangeError,从而避免了内存重写。 8.3.1 数组特性的 default 限定符 本节的开头定义了一个类 TIntegers,它包含了一个整数数组,而无须特别命名对应的数组特性。这可 以通过 default 限定符来完成(参见下面列出的代码)。 property IntArray[ Index : Integer ] : Integer read GetIntArray 第8章 高级特性编程 202 write SetIntArray; default; default 限定符意味着,在相应的上下文环境中如果不指明,就使用该特性;如果不使用 default 限定符, 就必须写出 Integers.IntArray[I]才能索引 IntArray 特性。Default 限定符使得编译器将 Integers[I]解析到 Integers.IntArray[I],然后将根据 Integers 用作左值还是右值来调用存取函数。 把 Integers 用作右值的代码如下。 var Value : Integer; begin Value := Integers[5]; end; 上面的代码将解析到 Value := Integers.GetIntArray( 5 )。如果把代码改为 Integers[5] := Value,就是把 Integers 作为左值使用,将解析到 Integers.SetIntArray( 5, Value )。 8.3.2 隐式范围检查 通过使用仔细定义的有序类型作为索引限定符,可以向数组特性加入隐式范围检查。在 TIntegers 中, 使用 1..10 定义了数组的大小。通过定义指定范围的索引类型,而不是直接使用整数类型,可以减少使用 CheckRange 的可能性。这样,编译器将捕获该错误。 type TIntegerRange = 1..10; TIntegers = class private FIntArray : array[TIntegerRange] of integer; function GetIntArray(Index: TIntegerRange): Integer; procedure SetIntArray(Index: TIntegerRange; const Value: Integer); public property IntArray[Index: TIntegerRange] : Integer read GetIntArray write SetIntArray; default; end; 提示:您可能希望写出由编译器捕获错误的代码。编译时错误比运行时错误易于解决。通过 仔细定义的类型化变量和强类型化的过程参数,即可在编译时捕获错误。 为了在数组特性的数组、存取方法和索引限定符中使用 TIntegerRange,对 TIntegers 类进行了重定义。 您仍然可以使用整数值对 TIntegers 对象进行索引,但如果索引值过界编译器将捕获该错误。您也可以使用 枚举类型作为索引限定符。 8.4 定义索引特性 索引特性是逆转的数组特性。从外部看来,索引特性并不是类似数组的单个特性,而是与多个具有相 同的读写方法的特性相似。索引是与特性连起来表示的。 property PropertyName : DataType index ordinal GetPropertyName write SetPropertyName; property PropertyName2 : DataType index ordinal + n GetPropertyName write SetPropertyName; 假定在实际的同一数据存储结构中包含了两个命名特性,但看起来却是独立的数据。上面的声明示范 了两个指向同一数据容器的索引属性。 第8章 高级特性编程 203 我们花一些时间来重新看一下本章开头处的 TContact 类。TContact 类的定义中包含了三个元素:名字、 电话和电子邮件,都是字符串类型。可以使用索引属性来访问每个字符串的实际数据值。 type TNameRange = 0..2; TContactRevisited = class private FData : TStrings; protected function GetData( Index : Integer ) : String; procedure SetData( Index : Integer; const Value : String ); public constructor Create; virtual; destructor Destroy; override; property Name : String index 0 read GetData write SetData; property PhoneNumber : String index 1 read GetData write SetData; property EMail : String index 2 read GetData write SetData; end; 对每个特性的使用都解析到对 GetData 和 SetData 方法的调用。FData 属性是一个 TStrings 对象,它是 一个关联数组,可以用‘名字=值’对的形式存储数据。 修改过的联系信息类的实现代码如下列出。 const NAMES : array[TNameRange] of String = ('Name', 'PhoneNumber', 'Email' ); constructor TContactRevisited.Create; begin inherited Create; FData := TStringList.Create; end; destructor TContactRevisited.Destroy; begin FData.Free; inherited; end; function TContactRevisited.GetData(Index : Integer): String; begin result := FData.Values[NAMES[Index]]; end; procedure TContactRevisited.SetData(Index: Integer; const Value: String); begin FData.Values[NAMES[Index]] := Value; end; Values 特性使用一个字符串名,然后从 FData 返回值,考虑一下 SetData 方法,该方法使用了 NAMES 特性,其参数为新的值以及索引值 0,NAMES[0]值为'Name'。而 FData.Values['Name']:=Value 将查找以 'Name='开头的字符串项,然后将其值设置为参数所表示的新值。 注意:由于索引值是在编译时静态定义的,因此只有在实际的数据容器的大小发生改变时, 才有可能出现错误。 在 TContactRevisited 类中没有实际的字段值。实际的值存储在 TStrings 对象中。因为对于 TStrings 中 的所有对象,代码都是相同的;所以无论对于两个或十个特性,两个读写方法就足够了。如果 get 和 set 第8章 高级特性编程 204 方法比上面的代码更为复杂,那么将节省更多的代码。 8.4.1 使用枚举索引值 索引特性的索引限定符必须是整数值,而且要位于整数类型的上下界之间(-2147483647 和 2147483647 之间)。但也可以使用枚举值作为索引限定符的值,这样就把可能值的范围限制到枚举类型中 定义的那些值。对 TContactRevisited 类进行合适的修改,即可示范该技术。 // include typinfo in the uses statement!!! {$M+} TName = (Name, PhoneNumber, EMail); {$M-} TContactRevisited = class private FData : TStrings; protected function GetName( Name : TName ) : String; function GetData( Index : TName ) : String; procedure SetData( Index : TName; const Value : String ); public constructor Create; virtual; destructor Destroy; override; property Name : String index Name read GetData write SetData; property PhoneNumber : String index PhoneNumber read GetData write SetData; property EMail : String index Email read GetData write SetData; end; 通过把枚举类型 TName 作为索引值,可以使用有意义的名字。我们也修改了读写方法 GetData 和 SetData,现在其参数为 TName 类型的索引限定符。现在已经不再需要字符串数组 NAMES 了。在 GetData 方法中加入了 GetEnumName 函数,该函数定义在 typinfo.pas 单元中,它使用 RTTI 信息来返回枚举项的字 符串值。 再进行一些微小的改变,即可完成对 TContactRevisited 类的修改。 constructor TContactRevisited.Create; begin inherited Create; FData := TStringList.Create; end; destructor TContactRevisited.Destroy; begin FData.Free; inherited; end; function TContactRevisited.GetName( Name : TName ) : string; begin result := GetEnumName( TypeInfo(TName), Ord(Name )); end; function TContactRevisited.GetData(Index : TName): String; begin result := FData.Values[ GetName( Index ) ]; end; procedure TContactRevisited.SetData(Index: TName; const Value: String); 第8章 高级特性编程 205 begin FData.Values[ GetName( Index ) ] := Value; end; 在修改后的版本中,不再索引数组 NAMES 来得到名字,而是调用 GetName。GetName 方法使用 RTTI 信息将枚举值转换为字符串。得到的结果字符串用来索引 TStrings 类的数组特性 Values。 8.5 多 态 特 性 使用方法作为存取限定符,既简单又清楚。通过将读写方法定义为虚方法(或抽象方法,我们在第 7 章那样做过),可以创建虚特性。虚特性的状态将以动态的方式进行修改,这取决于对象的实际实例。这 里有一个基本的例子,演示了这方面的技术。 TVirtualProperty = class private FSomeData : String; protected function GetSomeData : string; virtual; procedure SetSomeData( const Value : String ); virtual; public property SomeData : String read GetSomeData write SetSomeData; end; TSubclass = class(TVirtualProperty) protected function GetSomeData : string; override; procedure SetSomeData( const Value : String ); override; end; TVirtualProperty 类中定义了特性 SomeData,通过读方法 GetSomeData 和写方法 SetSomeData 进行访 问。两个存取方法都是虚方法。TSubClass 子类化了 TVirtualProperty 类,使用 override 指令重载了 GetSomeData 和 SetSomeData 方法。 如果同时需要继承而来的行为和新的行为,可以在 GetSomeData 和 SetSomeData 方法的子类实现中调 用方法的继承版本。如果需要全新的行为,不调用继承版本的方法即可。下面列出了完整的代码,包含了 两个类的实现和对类的使用的例子。 unit UVirtualProperties; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs; type TForm1 = class(TForm) Procedure FormCreate(Sender: TObject); private { Private declarations } public { Public declarations } end; TVirtualProperty = class private FSomeData : String; protected 第8章 高级特性编程 function GetSomeData : string; virtual; procedure SetSomeData( const Value : String ); virtual; public property SomeData : String read GetSomeData write SetSomeData; end; TSubclass = class(TVirtualProperty) protected function GetSomeData : string; override; procedure SetSomeData( const Value : String ); override; end; var Form1: TForm1; implementation {$R *.DFM} { TVirtualProperty } function TVirtualProperty.GetSomeData: string; begin result := FSomeData; end; procedure TVirtualProperty.SetSomeData(const Value: String); begin FSomeData := Value; end; { TSubclass } function TSubclass.GetSomeData: string; begin result := inherited GetSomeData; end; procedure TSubclass.SetSomeData(const Value: String); begin if( SomeData = Value ) then exit; inherited SetSomeData( Value ); end; procedure TForm1.FormCreate(Sender: TObject); var VP : TVirtualProperty; begin VP := TSubClass.Create; try VP.SomeData := 'Follow me'; ShowMessage( VP.SomeData ); finally VP.Free; end; end; 206 第8章 高级特性编程 207 end. 图 8.2 从 ShowMessage(VP.GetSomeData)一行调用 栈的情况,此时 ShowMessage 对话框尚未显示 TVirtualProperty.GetSomeData 返回实际的字段值 FSomeData。TSubClass.GetSomeData 是用超类方法实 现的,它调用了继承而来的方法。TVirtualProperty.SetSomeData 将实际的字段值设置为 Value 参数。在 TSubClass.SetSomeData 方法中,如果字段值与参数值相等,该方法将退出,否则将使用继承的方法来设置 数据(即将显示 ShowMessage 对话框之前的调用栈情况,可以参照图 8.2)。在 FormCreate 事件方法中声 明了一个 TVirtualProperty 类型的变量,并将其初始化为子类 TSubClass。从调用栈中可以清楚地看到,先 调用了子类的方法,该方法又调用了超类的实现。 一般的,最好的习惯是子类化已存在的类来定义新的行为。这样减少了改变和重新测试已有代码的可 能性,又可以在一个类中同时使用旧的和新的行为。本节中列出的代码示范了在子类中对超类特性的一个 很小的改变。而有用的修改不必是很大的修改。该代码也示范了该技术的使用,在子类的特性存取方法中 新增行为的复杂或简单与否,是依赖于您的需要的。 8.6 提升子类中特性的可见性 Delphi 中的 VCL 库按照惯例,把许多组件类都按两阶段进行声明。名为 TCustomClass 的类定义了所 有的属性,而把特性声明为保护权限的。然后把另一个类定义为 TCustomClass 类的子类,并将保护特性的 可见性提升为公有或公开。例如 TCustomEdit 只定义了一个公开特性 TabStop。而 TEdit 类则定义为该类的 子类,将所有实现者所需的特性都提升到公有或公开访问区域。 TEdit = class(TCustomEdit) published property Anchors; property AutoSelect; property AutoSize; property BevelEdges; property BevelInner; property BevelKind; property BevelOuter; property BiDiMode; property BorderStyle; property CharCase; property Color; property Constraints; property Ctl3D; property DragCursor; property DragKind; property DragMode; property Enabled; property Font; 第8章 208 高级特性编程 property HideSelection; property ImeMode; property ImeName; property MaxLength; property OEMConvert; property ParentBiDiMode; property ParentColor; property ParentCtl3D; property ParentFont; property ParentShowHint; property PasswordChar; property PopupMenu; property ReadOnly; property ShowHint; property TabOrder; property TabStop; property Text; property Visible; property OnChange; property OnClick; property OnContextPopup; property OnDblClick; property OnDragDrop; property OnDragOver; property OnEndDock; property OnEndDrag; property OnEnter; property OnExit; property OnKeyDown; property OnKeyPress; property OnKeyUp; property OnMouseDown; property OnMouseMove; property OnMouseUp; property OnStartDock; property OnStartDrag; end; 警告:如果重新定义了特性,即在子类中重新定义了完整的特性语句,可能会隐藏超类的存 取方法或改变特性的可访问性。要提升可见性,可以使用 property 关键字和特性名重新声 明特性。 组件面板上实际是 VCL 库中的 TEdit 控件。从列出的代码可以看出,TEdit 只包含了提升的特性。考 虑一个特性 AutoSelect,它定义在 TCustomEdit 类里,在 StdCtrls.pas 单元中。 property AutoSelect: Boolean read FAutoSelect write FAutoSelect default True; 这里是该特性实际的定义。TEdit 中的 property 语句只是提升了可见性而已。规则就是,如果要提升 可见性,只需使用关键字 property 和特性名即可,而且只要定义该特性一次。 8.7 小 结 第 8 章涵盖了特性的有关知识,它是面向对象 Pascal 的巅峰。特性使对象更加易用,可以定义一些方 第8章 高级特性编程 209 法来受限地访问私有数据。公开特性将显示在 Object Inspector 中,这对于创建定制组件是必需的。接下来, 您将学习怎样创建组件。 特性对于面向对象编程是必需的,因为它们表示了智能的数据。本章中的技术在本书其余部分用得很 多,可以用来定义存取方法、定义数组和索引特性、创建多态特性等。 第 9 章 创建定制组件 对 Delphi 研发人员来说,创建定制组件的前景非常好。Delphi 是用 Object Pascal 实现的,包括了种 类繁多的组件,有些是用 Object Pascal 实现的,另一些是 ActiveX 组件。在本章中你可以建立几种组件, 其中有一个调试组件可用于跟踪代码并输出到日志文件、捕获代码路径,和对代码中一些具有不变性的条 件进行测试。本章的内容包括:使用组件向导过程、编译并测试组件、将组件和图标关联起来,以及包的 安装和管理。 许多类可以转换成组件,在设计时更加易于使用。组件可能小而简单,也可能大而复杂。TEdit 和 TLabel 控件是基本组件的例子。而较大的商务组件能够对一整类问题提供完整的解决方案;例如 Word 和 Excel 就是大而复杂的组件。通常在创建大而复杂的组件时,需要创建许多较小的组件和子系统,然后使用这些 小的模块来构造所需的解决方案。本章相当于一块基脚石,它是更为强大的组件和应用程序的基础。 9.1 组件单元概览 要成功创建组件,关键是要认识到组件是类。对于好的类和好的组件来说,二者的规范是基本相同的。 TObject 类是所有类的祖先;而 TPersistent 类是所有组件的祖先,它将组件与其他类区分开来。 TPersistent 将持久状态的概念引入到类中。即:在应用程序的多次运行之间,可以存储并再次获取类 的特性。区分组件与其他类的另一个因素是,组件可以在设计时进行修改;因为组件具有公开权限的接口。 并非所有组件都有精巧的公开接口,但大多数组件都有一些数据和事件特性,可以在设计时对其进行可视 化的修改。所有其他的类都是用代码编程修改,并且直到程序运行时才生效,但组件并非如此。对组件数 据特性的改变可以立即生效。在使用组件时,首先要知道定义组件的单元如何编写。 unit UEditConvertType; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls; type TEditConvertType = class(TEdit) private { Private declarations } protected { Protected declarations } public { Public declarations } published { Published declarations } end; procedure Register; implementation procedure Register; begin RegisterComponents('PKTools', [TEditConvertType]); end; end. 第9章 创建定制组件 215 从代码列表中可以看到,组件单元与其他单元基本上没有区别。组件单元也有接口部分和实现部分, 其作用与非组件类相同。unit 和 uses 语句也是相同的。接口部分和实现部分均可有 uses 子句。组件类在 接口类型部分定义。上述代码定义了 TEdit 的子类 TEditConvertType。类的四种访问区域都是可用的。私 有、保护、公有接口与通常的意义相同,公开接口包含了组件的事件和数据特性,组成了组件的设计时接 口。 最重要的不同是实现部分 Register 过程的说明和定义。当使用 Component | Install Component 菜单项安 装组件时,Delphi 将执行相应的 Register 过程,该过程调用了 RegisterComponents 过程。RegisterComponents 过程的第一个参数表示把组件添加到组件目标的哪一个属性页(这里是 PKTools),第二个参数是 TComponentClass 类的数组,表示要注册的组件。该元类数组表明 Register 过程可用在一个语句中注册多 个 组 件 。 例 如 : 如 果 UEditConvertType 单 元 包 含 多 个 组 件 — — TEditConvertType 和 TMaskedEditConvertType,那么可以将所有的类名添加到数组中并安装到 PKTools 面板上。 RegisterComponents('PKTools', [TEditConvertType, TMaskedEditConvertType]); 要记住的关键是:通过把组件分层,然后逐层的添加功能,即可对组件的功能进行增量和迭代式的修 改。在一个组件中包含过多的功能是不必要的。 虽然从零开始创建组件并非令人畏惧的任务,但是启动新组件最容易的途径是使用组件向导过程。下 节将快速的浏览一下组件向导,然后演示一个新组件的例子(在 9.5 节“编译并测试组件”中,您可以建 立并测试一个 TEditConvertType 组件)。 9.2 使用组件向导 Component Wizard 对话框(见图 9.1)将创建一个单元,其中包括了类的外壳定义和 Register 过程。 使用向导总比从零开始方便得多。按照下列步骤,即可创建一个新的标签组件,该组件可以显示扩展的字 体风格。 图 9.1 Delphi 的 Component Wizard 对话框可以自动地 生成单元,其中包含了新组件定义的外壳 1.在 Delphi 中,单击 Component | New Component 菜单项,显示组件向导对话框。 2.在 New Component 对话框里选择 TLabel 作为祖先类型。 3.将新的组件类命名为 TLabelExtendedFont。 4.对所要创建的组件选择一个已存在的组件面板属性页,或输入新的属性页名称。 5.给出单元文件的路径和名字。可以用前缀 U 替换类名中的前缀 T 作为单元的名字,这样就得到了 ULabelExtendedFont 单元。 6.如果搜索路径中并未包含新的单元,则更新该路径。若单击了浏览按钮(Unit file name 输入框右 侧的省略号按钮),那么路径将自动更新。 第9章 创建定制组件 216 7.单击 OK 按钮,生成单元文件。 TLabelExtendedFont 可以在文字后显示阴影,从而产生使文本凸起的效果。可以对深度感知进行配置, 以显示或浅或深的阴影。 9.2.1 为扩展的标签控件编写代码 扩展的标签控件可以显示带有阴影的标签,其深度和颜色是可变的。这意味着需要一些特性来表示是 否显示阴影、阴影的深度、阴影的颜色等。标签的 Color 特性用做前景色。最后,为确保每次接收到 Paint 消息时都出现应有的效果,我们需要过载 paint 方法。组件的完整代码列出如下: unit ULabelExtendedFont; // ULabelExtendedFont.pas - Contains extended fonts, currently // only defines shadowing text capability // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls; type TShadowDepth =(sdShallow, sdDeep); TLabelExtendedFont = class(TLabel) private { Private declarations } FHasShadow : Boolean; FShadowColor : TColor; FShadowDepth : TShadowDepth; procedure SetShadowColor( Const Value : TColor ); Procedure SetShadowDepth( Const Value : TShadowDepth ); Procedure SetHasShadow( Const Value : Boolean); Procedure DrawShadow; protected { Protected declarations } Procedure Paint; override; public { Public declarations } published { Published declarations } Property HasShadow : Boolean read FHasShadow write SetHasShadow; property ShadowColor : TColor read FShadowColor write SetShadowColor; property ShadowDepth : TShadowDepth read FShadowDepth write SetShadowDepth; end; procedure Register; implementation procedure Register; begin RegisterComponents(‘PKTools’, [TLabelExtendedFont]); 第9章 创建定制组件 end; { TLabelExtendedFont } procedure TLabelExtendedFont.DrawShadow; const Alignments: array[TAlignment] of Word = (DT_LEFT, DT_RIGHT, DT_CENTER); var Rect : TRect; Flags : Word; begin SetBkMode( Canvas.Handle, Windows.Transparent ); Rect := ClientRect; Canvas.Font := Font; Canvas.Font.Color := FShadowColor; Rect.Left := Rect.Left - Ord(FShadowDepth) - 2; Rect.Top := Rect.Top - Ord(FShadowDepth) - 2; Flags := DT_EXPANDTABS or Alignments[Alignment]; if( WordWrap ) then Flags := Flags Or DT_WORDBREAK; if not ShowAccelChar then Flags := Flags or DT_NOPREFIX; DrawText(Canvas.Handle, PChar(Text), Length(Text), Rect, Flags); end; procedure TLabelExtendedFont.Paint; begin if( FHasShadow ) then DrawShadow; inherited Paint; end; procedure TLabelExtendedFont.SetHasShadow(const Value: Boolean); begin if( Value = FHasShadow ) then exit; Transparent := Value; FHasShadow := Value; Invalidate; // cause it to repaint end; procedure TLabelExtendedFont.SetShadowColor(const Value: TColor); begin if( Value = FShadowColor ) then exit; FShadowColor := Value; Invalidate; end; procedure TLabelExtendedFont.SetShadowDepth(const Value: TShadowDepth); begin if( Value = FShadowDepth ) then exit; FShadowDepth := Value; Invalidate; end; 217 第9章 创建定制组件 218 end. 在三个属性 HasShadow、ShadowDepth、ShadowColor 各自的 read 子句中都直接返回了实际的字段值, 而其 write 子句都调用了相应的写方法。由于这三个 set 方法都使得控件失效,导致重新绘制,因此在这些 过程中都需要先计算当前特性值,并在没有发生真正的改变的情况下,退出相应的过程。 大部分工作都是在私有方法 DrawShadow 中完成的,该方法由 Paint 方法调用。要注意:如果调用 SetHasShadow 存取方法,可能会改变 Transparent 状态值。这确保了标签的背景色不会隐藏阴影字体。当 调用 Paint 方法时,如果 HasShadow 为真,那么背景文字将直接由 DrawShadow 方法写到画布上(为清楚 起见,DrawShadow 方法使用了块注释;这里为使代码短些没有列出注释)。 DrawShadow 方法创建了阴影效果。SetBkMode 是一个 API 调用,它将画布模式设置为 Transparent, 但并不影响相应的特性。ClientRect 用于得到组件的客户区矩形,然后加以修改使之与组件的边界矩形稍 有偏移,以便确定 DrawText 调用向画布写入文本的位置。FShadowDepth 字段的序数值用于帮助确定阴影 字体的偏移值。Alignments 数组用来将 TAlignment 枚举值转换为 Windows API 中使用的字体对齐常数值。 如果组件把 WordWrap 设置为 True,DT_WORDBREAK 将添加到位标志 Flags。而且,如果在 Object Inspector 中设置了 ShowAccelChar,那么 DT_NOPREFIX 将以按位或的方式添加到 Flags。 简而言之,DrawShadow 方法考虑了组件中所有用于格式化的属性,并将其转换为 Windows 中的等价 形式。最后使用 DrawText API 方法直接写到标签组件的画布上。 9.2.2 测试控件 图 9.2 中,使用新的标签组件在面板上显示了几行文本。在安装新的组件之前,可以像测试其他的类 一样,很容易地在程序中对其进行测试。按照下列步骤,可以测试 TLabelExtendedFont 组件。 图 9.2 这里是 TLabelExtendedFont 的三个实例,它们在窗体上显示了三行文本 注意:在进行子类化时一个很好的惯例是:使用超类名作为新组件名字的首部。这样,Delphi 就可以在 Object Inspector 中按字母顺序对类似的组件进行排序,并且有助于从一般到特 殊来了解类的功能。 1.在 Delphi 中创建一个新的应用程序。 2.把 ULabelExtendedFont 单元添加到工程中。 3.在新工程的默认窗体单元中,向实现部分的 uses 语句添加 ULabelExtendedFont 单元。 4.添加一些用于创建标签组件实例的代码(如下所示)。 with TLabelExtendedFont.Create( Self ) do begin Top := 10; Left := 10; Caption := 'http://www.softconcepts.com'; HasShadow := True; ShadowDepth := sdShallow; ShadowColor := clBlack; Font.Style := [fsBold, fsItalic]; Font.Name := 'New Times Roman'; 第9章 创建定制组件 219 Font.Size := 16; Font.Color := TColor( Random( 1000000 ) ) + 100; Parent := Self; end; 警告:在上述动态创建的组件并不需要显式利用 Free 释放,因为它们是由窗体拥有的。每 个拥有控件的组件都负责删除其子控件(参见 controls 单元中列出的 TWinControl.Destroy 代码,看一看 TWinControl 控件(如 Form)释放时,将发生哪些动作)。 上述代码创建了扩展标签控件的实例,并将包含它的控件作为其所有者(将 Parent 特性赋值为 Self 也 是可以的,前提是对象应该是 TWinControl 类型的,如窗体),并设置了标签控件在左上位置。控件的标 题就是要显示的文本。HasShadow、ShadowDepth 和 ShadowColor 是新增的特性,用于确定字体和阴影的 外观。最后需要设置控件的所有者,因为当被拥有的控件需要对消息如 WM_PAINT 进行响应时,Windows 是通过其所有者通知控件的(将组件安装到包,可以将组件显示在组件面板上,可以参考 9.8 节“将组件 安装到包中”)。 9.3 组件的构造函数和析构函数 组件是类。所以,在需要的情况下,它们可以有重载的构造函数和析构函数。组件构造函数定义为 Constructor Create(AOwner:TComponent);virtual;。所有类的析构函数都具有同样的形式:destructor Destroy;override;。要添加构造函数、析构函数或同时添加两者到组件类中,可声明如下: constructor Create( AOwner : TComponent ); override; destructor Destroy; override; 通常,如果组件包含由构造函数分配的对象,则需要析构函数。如果在组件中增加新的存储属性而且 需要初始化,或者组件拥有对象时,构造函数是很有用处。否则可以使用超类构造函数和超类析构函数。 最后,TLabel 的构造函数和析构函数完成了所有的初始化和清除工作(可以参考第 8 章中防止大量消耗处 理器时间的特性改变,对过载组件构造函数的例子的讨论)。 9.4 定义组件特性 组件特性可以具有任何有意义的外观。如果要在 Object Inspector 中显示特性,需要将其声明为公开权 限。反之亦然。有时候您可能想限制在 Object Inspector 中显示的特性。当定义组件的子类时,可以自动得 到公开特性。然而许多 Delphi 组件实现为 TCustomComponent,因而其属性具有保护权限,如果要定义仅 显示某些特定特性的组件,可以继承 TCustomComponent。 这种类型组件的一个例子是版本标签组件,可能显示在 About 对话框中(参见图 9.3,其中包括了 About 对话框模板,以及显示了版本信息的 TVersionLabel 控件)。如图 9.4 所示,在 Project Options 对话框的 Version Info 属性页上所设置的版本信息,可以通过版本标签来动态显示。由于版本信息是特定于 Windows 的,我们需要使用两个 Windows API 过程来取得版本信息并解码。 第9章 图 9.3 创建定制组件 220 把 TVersionLabel 添加到 New Items 对话框的 Form 属性页中的 AboutBox 模板上。图中所示的程序版本为 1.0,已经编译了两次 提示:当使用版本信息时,如果需要编译器来跟踪编译应用程序的次数,请确认已经选定了 “Include version information in project”和“Auto- increment project number”复 选框。 图 9.4 Project Options 对话框的 Version Info 属性页可以向程序添加动态版本信息。而 TVersionLabel 控件可以显示不断更新的版本信息。有可能就是在 About 对话框中 我们知道,版本标签组件是一种特定用途的标签组件,并不具有通常的用途。这意味着不需要程序员 直接修改标题特性。实际上,版本标签太专门化,以至于我们不需要程序员修改与版本信息相关的任何属 性。因此你需要子类化 TCustomLabel 来得到 TVersionLabel 组件;这样所有的特性都具有保护权限,减少 了无意误用的可能性。使用 Component 菜单启动组件向导,为 TVersionLabel 创建一个单元。将单元命名 为 UVersionLabel,并子类化 TCustomLabel 来得到新组件。 UVersionLabel.pas 单元的接口部分如下(实现部分在后面的段落中,将二者合起来可以得到完整的代 码)。 unit UVersionLabel; // UVersionLabel.pas - Fills in the version label from the FileVersionInfo // Copyright (c) 2000. All Rights Reserved. // Written by Paul Kimmel. Okemos, MI USA // Software Conceptions, Inc 800-471-5890 interface uses 第9章 创建定制组件 221 Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls; type TVersionLabel = class(TCustomLabel) private { Private declarations } FFileName : TFileName; FMask: String; function GetEnvPathString : String; function GetApplicationName( const AppName : String ) : String; protected { Protected declarations } procedure SetFileName( Value : TFileName ); procedure SetVersion; procedure SetMask(const Value: String); property Caption stored False; public { Public declarations } published { Published declarations } constructor Create(AOwner : TComponent); override; property FileName : TFileName read FFileName write SetFileName; property Mask : String read FMask write SetMask stored True; end; procedure Register; 该类定义了两个字段:FFileName 和 FMask。FFileName 字段用于存储可执行文件的路径和文件名, 版本信息就是从该文件读取的。FMask 字段包含了实际的标签标题的缺省掩码值。GetEnvPathString 用于 从系统环境返回路径字符串。该方法在设计时使用,用来查找可执行文件。GetApplicationName 实现了动 态的设计时和运行时的代码,用于确定可执行文件名。 SetFileName 是 FileName 特性的写方法。SetVersion 更新 Caption 属性,以精确显示版本信息。SetMask 是 FMask 字段的写存取器(accossor)。要谨慎或采用一些错误检测代码,以防用户定义的掩码值没有包 含足够的数据。property Caption 声明并未提升 Caption 特性的权限,因此该属性仍然是保护权限。但却改 变了存储限定符,因此版本标题不再存储。如果版本标题被存储,在读取动态版本后,DefineProperties 方 法将从.DFM 文件中读取存储的版本信息并覆盖标题,导致出现不正确的版本和编译信息。 公开访问区域并未提升 TCustomLabel 中的保护特性。结果用户无法编程更新标签或标题。定义了一 个新的构造函数,添加了两个额外的特性 FileName 和 Mask,以表示私有字段。TVersionLabel 类的实现 如下。 implementation procedure Register; begin RegisterComponents('PKTools', [TVersionLabel]); end; constructor TVersionLabel.Create(AOwner : TComponent); resourcestring DefaultMask = 'Version %s.%s (Release %s Build %s)'; begin inherited Create(AOwner); 第9章 创建定制组件 222 Mask := DefaultMask; end; procedure TVersionLabel.SetVersion; var MajorVersion, MinorVersion, Release, Build : String; Version : String; begin Version := GetVersionString( FFileName ); DecodeVersion( Version, MajorVersion, MinorVersion, Release, Build ); Caption := Format(FMask, [MajorVersion, MinorVersion, Release, Build] ); end; resourcestring DefaultPath = '.\;\'; function TVersionLabel.GetEnvPathString : String; const MAX = 1024; begin SetLength( result, MAX ); if( GetEnvironmentVariable( PChar('Path'), PChar(result), MAX ) = 0 ) then result := DefaultPath; end; function TVersionLabel.GetApplicationName(const AppName : String ) : String; begin if( csDesigning in ComponentState ) then // see if a compiled version already exists, if we are designing result := FileSearch( AppName, GetEnvPathString + DefaultPath) else result := Application.EXEName; end; procedure TVersionLabel.SetFileName( Value : TFileName ); begin if( CompareText( FFileName, Value ) = 0 ) then exit; FFileName := GetApplicationName( Value ); SetVersion; end; procedure TVersionLabel.SetMask(const Value: String); begin if( FMask = Value ) then exit; FMask := Value; SetVersion; end; end. 构造函数用一个资源字符串来设置默认的掩码。根据当前的实现,掩码需要包括四个字符串特性。需 要 添 加 代 码 来 定 义 一 个 基 本 的 编 辑 掩 码 , 只 允 许 用 户 改 变 实 际 的 静 态 文 本 。 SetVersion 调 用 GetVersionString 和 DecodeVersionString,,使用 Microsoft 公司的 version.dll 返回的信息。 function GetVersionString( FileName : String ) : String; 第9章 创建定制组件 223 resourcestring VersionRequestString = '\\StringFileInfo\\040904E4\\FileVersion'; var Size, Dummy, Len : DWord; Buffer : PChar; RawPointer : Pointer; begin result := '<unknown>'; Size := GetFileVersionInfoSize( PChar(FileName), Dummy ); if( Size = 0 ) then exit; GetMem( Buffer, Size ); try if( GetFileVersionInfo( PChar(FileName), Dummy, Size, Buffer ) = False ) then exit; if( VerQueryValue( Buffer, PChar(VersionRequestString), RawPointer, Len ) = False ) then exit result := StrPas( PChar(RawPointer) ); finally FreeMem(Buffer); end; end; procedure DecodeVersion( Version : String; var MajorVersion, MinorVersion, Release, Build : String function GetValue( var Version : String ) : String; begin result := Copy( Version, 1, Pos('.', Version ) - 1); if( result = EmptyStr ) then result := 'x'; Delete( Version, 2, Pos('.', Version )); end; begin MajorVersion := GetValue(Version); MinorVersion := GetValue(Version); Release := GetValue(Version); Build := GetValue(Version); end; GetEnvPathString 使 用 kernel32.dll 中 的 GetEnvironmentVariable 来 读 取 系 统 环 境 路 径 。 GetEnvPathString 被用在 GetApplicationName 中。在设计时, GetApplicationName 使用 FileSearch 搜索系统 路径和当前目录,以找到可执行文件(是否处于设计模式,可以通过使用 in 操作符来判断)。如果程序在 运行中,那么 Application.EXEName 包含了应用程序文件名,因此可以找到包含了更新的版本信息的文件。 SetFileName 和 SetMask 是用于直接访问特性的方法。 这个类有两个不足。首先该类没有适当处理坏的掩码,其次需要用户手工输入 FileName 特性,对于 长或困难的路径也是如此。如果利用特性编辑器来查找并设置 FileName 特性,该属性将更为有用而且易 于使用。参考第 10 章,看一下如何为 FileName 属性定义并注册特性编辑器。 TVersionLabel 示范了一个自动跟踪当前版本信息的实用组件。该组件采用 Windows API 来获取版本 数据,更重要的是它示范了如何通过隐藏和暴露特性来控制组件的使用方式。通过对 TVersionLabel 组件 示范了如何通过限制可用的特性来约束组件的用法,并防止误用。如果你要隐藏已有的特性,那么可以使 用组件的 TCustom 型的祖先类,并只对用户可以访问的特性提升权限。如果要提供已有的和新的行为,那 么既可以使用 TCustom 也可使用其子类组件。下一节演示了一个类型化编辑组件和代码陷阱,详细地示范 第9章 创建定制组件 224 了如何编译并测试组件。 9.5 编译并测试组件 测试组件就像测试其他的类一样简单。在把组件安装到 VCL 之前,最好添加一个包含组件的单元, 创建组件的实例,并对代码进行单步调试。否则,包中的访问违例可能使 Delphi 关闭,这样测试组件会很 困难。 当你定义了新的组件时,创建一个简单的测试程序,使用代码来实例化新组件。要仔细设计测试,使 得可以单步跟踪组件的每条代码路径。请记住:虽然严格的测试是耗费时间的,但对于对象,特别是组件 来说,测试的好处在于它们一经证实是正确的,就成为了整洁而可靠的代码包。用来示范测试的组件是 TEditType,TEditType 组件允许用户将 TEdit 组件中得到的文本作为 Boolean,Currency,Date,DateTime, Float,Integer,Time,或 String 使用。 unit UEditType; // UEditType.PAS - Type Conversion for TEdit // Copyright (c) 1998. All Rights Reserved. // Software Conceptions, Inc. Okemos, MI USA // Written by Paul Kimmel. Okemos, MI USA interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls; type TEditType = class(TEdit) private { Private declarations } protected { Protected declarations } Procedure SetAsBoolean( const Value : Boolean ); Function GetAsBoolean : Boolean; Procedure SetAsCurrency( const Value : Currency ); Function GetAsCurrency : Currency; Procedure SetAsDate( const Value : TDateTime ); Function GetAsDate : TDateTime; Procedure SetAsDateTime( const Value : TDateTime ); Function GetAsDateTime : TDateTime; Procedure SetAsFloat( const Value : Double ); Function GetAsFloat : Double; Procedure SetAsInteger( const Value : Integer ); Function GetAsInteger : Integer; Procedure SetAsTime( const Value : TDateTime ); Function GetAsTime : TDateTime; Procedure SetAsString( const Value : String ); Function GetAsString : String; public { Public declarations } Property AsBoolean : Boolean read GetAsBoolean write SetAsBoolean; Property AsCurrency : Currency read GetAsCurrency write SetAsCurrency; Property AsDate : TDateTime read GetAsDate write SetAsDate; Property AsDateTime : TDateTime read GetAsDateTime write SetAsDateTime; Property AsFloat : Double read GetAsFloat write SetAsFloat; Property AsInteger : Longint read GetAsInteger write SetAsInteger; Property AsTime : TDateTime read GetAsTime write SetAsTime; 第9章 创建定制组件 Property AsString : string read GetAsString write SetAsString; published { Published declarations } end; procedure Register; implementation uses UBooleanUtil; procedure TEditType.SetAsBoolean( const Value : Boolean ); begin Text := TBooleanUtil.BooleanToStr(Value); end; function TEditType.GetAsBoolean : Boolean; begin result := TBooleanUtil.StrToBoolean(Text); end; procedure TEditType.SetAsCurrency( const Value : Currency ); begin Text := FloatToStr(Value); end; function TEditType.GetAsCurrency : Currency; begin result := StrToFloat(Text); end; procedure TEditType.SetAsDate( const Value : TDateTime ); begin Text := DateToStr(Value); end; function TEditType.GetAsDate : TDateTime; begin result := StrToDate(Text); end; procedure TEditType.SetAsDateTime( const Value : TDateTime ); begin Text := DateTimeToStr(Value); end; function TEditType.GetAsDateTime : TDateTime; begin result := StrToDateTime(Text); end; procedure TEditType.SetAsFloat( const Value : Double ); begin Text := FloatToStr(Value); end; function TEditType.GetAsFloat : Double; begin result := StrToFloat(Text); end; procedure TEditType.SetAsInteger( const Value : Integer ); begin Text := IntToStr(Value); end; function TEditType.GetAsInteger : Integer; begin result := StrToInt(Text); end; procedure TEditType.SetAsTime( const Value : TDateTime ); begin 225 第9章 创建定制组件 226 Text := TimeToStr(Value); end; function TEditType.GetAsTime : TDateTime; begin result := StrToTime(Text); end; procedure TEditType.SetAsString( const Value : String ); begin Text := Value; end; function TEditType.GetAsString : String; begin result := Text; end; procedure Register; begin RegisterComponents(‘PKTools’, [TEditType]); end; end. TEditType 可以用大部分 Delphi 的内建类型设置组件的 Text 特性,并通过选择合适的特性在这些类型 与字符串值之间进行转换。对 TEditType 组件全面而有效的测试将设置并获取每个类型的值,以确保转换 正确地工作。 把测试代码和组件关联起来的途径是使用条件编译指令并添加测试方法。例如:如下定义的公有方法 RunTests 就足够了。 procedure TEditType.RunTests; var C : Currency; begin {$IFOPT D+} Text := 'True'; if( AsBoolean ) then AsBoolean := Not AsBoolean; Text := '300.37'; C := AsCurrency; AsCurrency := 547.29; ShowMessage(AsString); AsDate := Now; if( AsDate <= Now ) then AsDatetime := AsDate; ShowMessage( 'The date is :' + AsString ); AsFloat := 12345.67; AsFloat := 2 * AsFloat; AsInteger := Trunc(AsFloat); AsTime := Now; ShowMessage( 'The time is: ' + Text ); {$ENDIF} end; 要记住把 RunTests 的声明添加到类中。如果组件在调试选项打开的情况下编译,{$IFOPTD+}指令将 只包括测试代码(关于条件调试代码的完整讨论,可以参见第 7 章中有关使用条件编译动态增删测试代码 的部分)。测试代码将测试大多数特性访问方法——其余的方法作为练习,当关闭调试选项编译组件时, 测试代码就被清除了。 使用上述技巧,把测试代码与组件放在一起,使得扩展组件的行为以及相应的测试非常容易。如果要 子类化组件,可以将 RunTests 过程定义为虚过程并在子类中重载 RunTests。在安装组件之前,可以按照下 第9章 创建定制组件 227 列步骤快速地进行一次补充性测试。 1.创建一个空白的应用程序。 2.添加包括组件的单元(本例中是 UEditType)。 3.创建组件实例,命名为 RunTests。 通过 TEditType 组件和这三个步骤,可以使用下面的代码来测试类。 with TEditType.Create(Self) do begin Parent := Self; RunTests; end; 如果已经对一个组件进行了完全的测试,可以将其添加到包中,然后将包安装到组件面板上(细节可 以参见 9.8 节“将组件安装到包中”)。 9.5.1 陷阱代码 代码陷阱是一个很古老的技巧,它对于测试组件非常合适。断点对于调试很有用,但是断点无法跟随 已编译并安装的组件。如果修改组件后要重新测试,那么所有改变的代码路径都需要进行测试。 通过在代码中添加陷阱机制,陷阱被抛出时可以将其注释掉。在代码遇到陷阱时,会将其抛出。通过 放置好陷阱并当其被抛出时将其注释掉,就建立了一个可以指示陷阱代码和非陷阱代码的虚拟标记。通过 在单元中搜索陷阱标记,可以找到任何没有陷阱代码的路径。陷阱是怎样创建的?在嵌阱汇编程序语句中 使用 soft-ice 中断即可。 asm int 3 end; //Trap! 注意: 考虑所有可能的控制流程来陷入每一个代码路径。例如:如果你有一个 if then 和 else 条件,那么添加一个陷阱到 if 和 else 部分,这将确保你已测试了二者之一的代码路径。陷 阱也可以标识出未使用过的代码。通过搜索未注释的陷阱,即可找到不必要的代码。 当遇到陷阱时,IDE 将立即停在陷阱后的一行,如图 9.5 所示。如果在每个代码路径都编写了陷阱, 当完成代码时即可利用它们进行测试。当陷阱抛出后将其注释,可以记录已经测试的代码(见图 9.6)。 图 9.5 使用中断 3,即所谓的 soft-ice 中断(这个昵称是根据 一个使用该调试中断的产品命名的),可以向代码添 加断点。如图所示,代码就像遇到了断点一样停下来 第9章 图 9.6 创建定制组件 228 未测试过的代码路径不会抛出陷阱(区别于已注释掉的陷阱语句) 如果修改代码,去掉陷阱的注释并确保它被重新测试。当保存组件时,陷阱也就是断点,与代码一同 存储。 9.6 在 Code Insight 中定义陷阱 上一节的陷阱代码可以放置在 Code Insight 中,它是该类型代码的一个好例子(见图 9.7)。为将陷阱 添加到 Code Insight 中,从 Editor Properties 对话框中选择 Code Insight 属性页。单击 Tools | Editor Options 菜单项,即可打开 Editor Properties 对话框。逐字添加如下所示的代码。 asm int 3 end; // Trap | 图 9.7 用 Code Insight 来定义“陷阱”述语。按 Ctrl +J 键并 输入 TRAP,即可将陷阱语句添加到代码中 管道(|)符号是 Enter 键上面的‘\’键。管道字符表示插入代码后光标的位置。为将陷阱添加到代码 中,在代码编辑器中将光标置于所需的位置,并按 Ctrl+J 键。键入 Trap,当 Trap 模板被选定后按 Enter 键即可(见图 9.8)。 图 9.8 从 Code Completion 对话框中选定 Trap 第9章 创建定制组件 229 模板,按 Ctrl+J 键可显示该对话框 9.7 添加组件图标 还需要向组件添加图标,以提供一些有关组件功能的信息。如果子类化已有的组件,除非指定新的图 标,否则将得到其父类的图标。如果你没有指定组件的图标,而且该组件不是有图标的组件的子类,那么 您的组件就获得一个通用的图标,与图 9.9 中鼠标所指出的图标相同。 注意:除了组件图标外,添加一个关键字文件以及编译过的.HLP 文件可以将您的组件的帮助 信息集成到 Delphi 中。 图 9.9 当没有给出包含组件图标的资源文 件时,将使用通用图标来标识组件 如果不想使用通用图标,或者从祖先继承的图标不够用,那么可以用 Image Editor 很方便地创建新的 组件图标。 9.7.1 用 Image Editor 创建组件资源文件 假设已经创建了能够跟踪代码并向外部文本文件输出的 Debug 组件。经过测试,代码工作正确,而您 正准备定义组件来封装跟踪行为。该组件不是可视化的,即无需处理运行时控件,也无法继承祖先图标。 使用本书中迄今为止讨论过的惯例,可以将类命名为 TDebug,将单元命名为 UDebug。而您正要定义包含 组件图标的资源文件,其操作如下。 1.从 Delphi 的 Tools 菜单中选择 Image Editor。 2.在 Image Editor 中,选择 File,New,Component Resource File(组件资源文件的扩展名为.dcr)。 3.组件图标是 24×24 像素的位图。在 Resource 菜单中单击 New,Bitmap,将显示如图 9.10 所示的 Bitmap Properties 对话框。 图 9.10 Image Editor 的 Bitmap Properties 对话框, 组件位图是 24×24 像素,16 色的位图 4.把 Width 和 Height 改变为 24,单击 OK。 5.在 untitled1.res资源对话框中用右键单击 Bitmap1 项。改变位图的名字以便与类名 TDEBUG 相匹 配。 6.当画出或复制位图后,存储为与组件单元同名的.res文件,放在与组件单元同样的位置。例如: UDebug.pas 需要一个 UDebug.res文件。 那就是所需要做的工作。当将组件编译为包时,Delphi 将会自动查找与组件单元同名的.res文件。 第9章 图 9.11 9.7.2 创建定制组件 230 这是为 TDebug 组件选定的位图,图像放大了许多倍 查找图标资源 如果您擅长从头开始画图标,那么可以使用 Image Editor 和任何画图程序来绘制自定义图标。另一个 可能的资源是从已有的动态链接库、可执行文件,和图标文件(*.ico)中提取图标。图标可能受版权保护, 因此在借用图标之前,可能需要同拥有该文件的厂商商议。大多数计算机中都有成千上万个图标。 ExtractIcon API 函数能够从.ico 文件或可执行文件提取图标。下面的代码示范了一个简单的程序,从一 个.EXE 或.DLL 文件读取所有的图标。 unit UFormIconGrabber; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, ExtCtrls, ShellAPI; type TForm1 = class(TForm) Image1: TImage; Button1: TButton; Label1: TLabel; EditFileName: TEdit; procedure Button1Click(Sender: TObject); private { Private declarations } public { Public declarations } Procedure NextIcon; end; var Form1: TForm1; implementation {$R *.DFM} procedure TForm1.NextIcon; const I : Integer = 0; FileName : String = ‘’; var Count : Integer; begin if( FileName <> EditFileName.Text ) then begin FileName := EditFileName.Text; 第9章 创建定制组件 231 I := 0; // Use $FFFFFFFF to get the image count Count := ExtractIcon( Application.Handle, PChar(FileName), $FFFFFFFF ); end else Inc(I); if( I < Count ) then Image1.Picture.Icon.Handle := ExtractIcon( Application.Handle, PChar(FileName), I ) else ShowMessage(‘No more images’ ); end; procedure TForm1.Button1Click(Sender: TObject); begin NextIcon; end; end. 在类定义中需要添加按钮、标签、编辑控件,以及一个图像控件到窗体中,以创建示例程序。在这个 应用程序中,惟一的方法是 NextIcon。NextIcon 首先确定上一次调用 NextIcon 后 FileName 是否发生了改 变;如果已经改变,将更新索引 I 和 FileName。对 ExtractIcon 的第一次调用得到 FileName 文件中的图标 数量,是通过参数$FFFFFFFF 表示的。如果 Count 比索引大,那么将从 FileName 文件中提取下一个图标 并显示在 TImage 控件中。 只需增加一些功能,即可提取硬盘上每个可执行文件和.ico 文件中的每个图标。在我的工作站上大约 有 10000 个图标。使用 TImage.Picture.SaveToFile 方法可以很容易地将图标保存到单独的文件,然后在 Image Editor 中进行修改。 第9章 9.8 创建定制组件 232 将组件安装到包中 编译过的包的扩展名为.bpl。bpl 是 Borland Package Library 的缩写,该类型的文件实际上是具有特定 扩展名的 DLL。包中可以包含一个或多个组件。按照对工程或团队有意义的方式将组件组织为包是个很好 的想法。您可能有一个标准的包,其中包含了来自您公司的自定义组件;或利用工程创建了一些包。当在 某个工程上工作时,装载该工程的包,到另一个工程上工作时,就装载相应的包。 Delphi 6 的新功能是,可以将包工程包括到 Project Manager 中,如图 9.12 所示。这使得在开发应用程 序及其组件时,创建和调试组件库都很方便。包编辑器实际上负责管理向包添加和删除组件单元、以及编 译、安装包。 图 9.12 Project Manager 中的包 Spro,以及与 Project Manager 驻留在同一对话框中的包编辑器 当已经对组件进行了测试和调试,准备把组件安装到包的时候,可以打开已有的包并使用包编辑器来 安装,或者单击 Component | Install Component 菜单。Install Component 菜单项可以将组件安装到新的或已 有的包中,这依赖于您使用 Install Component 对话框中的哪个属性页执行该操作(如图 9.13 所示)。 图 9.13 Install Component 对话框 缺省情况下,组件将放置到 Borland 提供的 dclusr60.dpk 包中。它被称为 Borland User Package。要将 组件安装到新的包中,可按照下列步骤,下面以 TEditType 组件为例来演示。 1.在 Delphi 中单击 Component,Install Component 菜单项。 2.在 Install Component 对话框中,单击 Into New Package 属性页。 3.输入要安装的组件的路径和文件名,或使用 Browse 按钮来定位相应的单元。 4.输入新的.dpk 文件的路径和名字(未编译的包文件)。 5.输入包的描述。 6.单击 OK 按钮。 7.Delphi 将提示你编译并安装包;单击 Yes 将新的包添加到组件面板。 如图 9.14 所示,包编辑器中显示了新的 foo.dpk 包以及其中的 TEditType 组件。在包编辑器中,可以 第9章 233 创建定制组件 单击 Compile 按钮来编译包,单击 Install the package 按钮来安装包,或者单击 Options 按钮设置编译器选 项。请记住:VCL 库实际上是存储为.bpl 文件的动态链接库。与任何其他的动态链接库工程一样,需要进 行编译和安装,管理一些选项。通过包编辑器或者 Project Manager 中的右击上下文菜单,可以完成这些工 作。 图 9.14 Package Editor 用于编译、安装组件库,以及设置工程选项 现在已经测试并安装了你的组件,他们可以像其他的组件一样绘制在窗体或数据模块上。为与其他的 开发者分享组件,可以提供组件库(.bpl 文件)和所有 Delphi 编译单元(.dcu 文件)。如果希望其他人可 以访问组件的源代码,还可以包括源代码单元文件。 9.9 小 结 本章讲授了初步的组件设计。创建自定义组件属于专业化的程序设计。创建组件和创建应用程序惟一 的区别是用户的不同。通常,组件的用户是另外一些开发者;他们是你的用户群体。加入帮助文件和关键 字文件是个好主意,这样可以为使用组件的人提供必要的文档。可以安装 Delphi 帮助的格式来编写帮助文 件,使得组件的帮助与 Delphi 的帮助集成起来。 现在您已经掌握了创建自定义组件的技术基础。后面的章节将示范更为高级的技术,使得组件与应用 程序分离开来。 第 10 章 高级组件设计 第 10 章将示范一些高级技巧,使得可以创建种类更多的组件,并更好地控制组件的工作方式。高级 组件设计包括如何动态装载资源以创建出色的图形化控件、怎样公开被拥有的组件——Delphi 6 所引入的 新技术、创建对话框组件,持久化非公开特性,以及如何创建特性编辑器。 公开被拥有的组件可以节省很多工作,而且可以比以前的 Delphi 版本更加易于创建由许多控件衍生出 来的组件。 10.1 动态装载资源 像 TMediaPlayer(如图 10.1 所示)一样具有专业外观、富于吸引力的控件需要动态创建组件,并在创 建组件时将图形资源装载到组件中。在第 9 章中,您已经学会如何使用 Image Editor 来创建 Delphi 组件资 源(dcr)文件。如果把 24×24 像素的位图命名为与类相同的名字,并将 DCR 文件存储为与组件单元文件 相同的名字——当然,扩展名是不同的;这样,在把单元添加到包的时候,Delphi 将自动地装载相应的 DCR 文件。这时,这些位图将显示在 VCL 面板代表对应组件的按钮上(细节可以参考 9.7.1 节“用 Image Editor 创建组件资源文件”)。 图 10.1 TMediaPlayer 组件的外观非常专业,它使用了位图, 在运行时从资源文件中动态装载(图中所示的 speedis.avi 与 Delphi 一同发布,位于 demos\coolstuf 文件夹下) 通过将额外的光标、图标和位图添加到同一 DCR 文件中,Delphi 会把这些资源文件编译到组件的.DCU 文件中,并将其链接到.bpl 库(请记住:BPL 是一种特定的动态链接库)。将组件编译到包中之后,资源 可以通过 API 过程访问,并使用组件方法来装载。通常具有资源装载方法的组件会包含代表资源的对象, 如 TSpeedButton 的 Glyph 特性。 在数据库应用程序中通常会遇到的可视化结构是包含四个按钮的可视化控件(如图 10.2 所示),按钮 用于在左右两栏之间来回移动相应的项。如图 10.2 所示的控件相当有用,可以用于几个窗体或工程,具有 明显的累积效应。本节将使用 TButtonPanel 组件来示范如何动态地装载源,在下一节讨论如何公开被拥有 的组件。 第 10 章 图 10.2 高级组件设计 239 按钮导航组件。方向箭头表示移动方向 注意:有一个谜语是这样的:您愿意现在得到一百万美元,还是第一天得一美分,以后每一 天的钱是前一天的两倍,连续 30 天呢?答案当然是后一个。直到第 20 天到第 30 天之间时, 才能看出累积的效果,最后的结果非常巨大,有 10737418 美元。使用组件来建立应用程序 的效果与此类似。最初的效应并不明显,但累积到最后的结果是惊人的。 图 10.2 中显示的组件使用了由 TMediaPlayer 组件得到的位图。在组件中使用资源的第一步是将其加 入到 Delphi 组件资源文件,即 DCR 文件中。TButtonPanel 组件的单元名是 UButtonPanel.pas。因此,DCR 文件是 UButtonPanel.dcr,而且与组件单元位于相同的目录下。四个按钮可以用 TSpeedButton 组件来实现。 TSpeedButton 具有 Glyph 特性,可用于图像。Glyph 可以是位图;如图 10.2 所示,共有四幅位图。 10.1.1 创建 Delphi 组件资源文件 Image Editor 可用于为 TButtonPanel 组件创建位图资源。按下列步骤可创建 DCR 文件。 1.从 Delphi 的 Tools 菜单中启动 Image Editor。 2.在 Image Editor 中,单击 File,New,Component Resource File 菜单项以创建资源文件。 3.选择 Resource,New,Bitmap 菜单项,并接受缺省的大小和颜色来创建四个位图(缺省的大小是 32×32 像素和 16 色 VGA 模式)。 4.可以画出位图或复制并粘贴已有的位图(如果组件中包含可能受第三方版权保护的位图,在分发 组件之前向公司的法律顾问查询一下)。如果想从 mplayer.res 文件中复制资源,可以在 Image Editor 中打开该文件,然后复制并粘贴要使用的四幅图像(mplayer.res 文件位于 Delphi 安装目录的 Lib 子文件夹中)。 5.使用合适的名字重新命名每个位图(我们将用枚举列表和 RTTI 来索引和装载资源;因此可以参考 图 10.2,将位图从上到下依次命名为 bpFirst, bpPrior, bpNext, bpLast。使用 bp 作为前缀是 Delphi 命名枚举元素的惯例——从构成枚举的单词的首字母中挑选两个作为前缀。枚举类型的名字是 TButtonPosition)。 6.最后,把 DCR 文件保存为 UButtonPanel.dcr,与组件位于同一目录。 如果 DCR 文件与组件位于同一目录里,当把组件添加到包时,DCR 文件将与组件一同添加,如图 10.3 所示。编译器将编译该单元,并将包和 DCR 资源文件链接到 BPL 库。资源文件包括在库中,可通过方法 调用访问。 第 10 章 图 10.3 高级组件设计 240 包编辑器,其中是 dclusr60.dpk 包,包含 了 UButtonPanel 单元的 DCR 和 PAS 文件 10.1.2 装载资源 装载资源的较为方便的方法是将资源命名为与枚举列表中的序数值同名。继续讨论 TButtonPanel,按 钮的类型定义命名如下: type {$M+} TButtonPosition = (bpFirst, bpPrior, bpNext, bpLast); {$M-} 可以注意到枚举元素的名字和前面小节中的资源名是相同的。$M 是运行时类型信息所对应的编译器 指令。如果将 typinfo.pas 单元添加到 uses 子句,可以从枚举序数值得到枚举值的字符串名,正好可用于读 取相应的资源。 unit UEnumerationDemo; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, ExtCtrls; type {$M+} TButtonPosition = (bpFirst, bpPrior, bpNext, bpLast ); {$M-} TForm1 = class(TForm) Image1: TImage; Image2: TImage; Image3: TImage; Image4: TImage; Button1: TButton; procedure FormCreate(Sender: TObject); procedure Button1Click(Sender: TObject); private { Private declarations } FImages : array[TButtonPosition] of TImage; public { Public declarations } end; var Form1: TForm1; implementation {$R *.DFM} {$R UButtonPanel.Res} uses typinfo; procedure TForm1.FormCreate(Sender: TObject); begin FImages[bpFirst] := Image1; FImages[bpPrior] := Image2; 第 10 章 高级组件设计 241 FImages[bpNext] := Image3; FImages[bpLast] := Image4; end; procedure TForm1.Button1Click(Sender: TObject); var I : TButtonPosition; Begin for I := Low(TButtonPosition) to High(TButtonPosition) do FImages[I].Picture.Bitmap.LoadFromResourceName( HInstance, GetEnumName( TypeInfo(TButtonPosition), Ord(I))); end; end. 单元在接口部分定义了枚举类型 TButtonPosition,并在编译时对其添加了运行时类型信息。四幅图像 可用做位图的仓库。类中定义了一个 TImage 的私有数组,并在 FormCreate 事件方法中进行了初始化,以 指向四个图像组件。 在实现部分,UButtonPosition.res 文件通过$R 编译器指令引入。ButtonClick 事件方法迭代使用 TButtonPosition 枚举作为循环范围,并使用图像引用数组装载位图。一个 TPicture 对象包含在一个 TImage 对象中。而每个 TPicture 包含一个 TBitmap 对象,TBitmap 对象具有 LoadFromResourceName 方法。HInstance 是在 System 单元中定义的全局 Windows 句柄,可以分配到应用程序或库;而 GetEnumName 使用 RTTI 将枚举值转换为对应的字符串值。要记住:位图资源与枚举元素是同名的,这样对资源编程更为容易。 10.2 公开所拥有的组件 由于组件的复杂性不断增长,更多组件定义为聚合关系。较早版本的 Delphi 要求定义特性编辑器以便 在设计时访问所包含的对象;或至少需要进行必要的属性提升,才能在设计时对所拥有对象的数据和事件 特性进行访问。 考虑在上一节提到的 TButtonPanel 组件。在含有按钮的面板上对齐按钮,是创建按钮面板组件的一个 直接途径。使用该技巧可以在设计时访问按钮的属性。下面列出的部分代码演示了这个技巧。 注意:属性提升是一种有效的技术,可用于将所包含对象的属性提升到容器的接口部分—— 称为使接口扁平化,这样在设计时对所包含的组件提供访问接口就不必要了。 type TButtonPosition = (bpFirst, bpPrior, bpNext, bpLast); TButtonPanel = class(TPanel) private FButtons : array[TButtonPosition] of TSpeedButtons; protected procedure SetClickEvent( Index : TButtonPosition; const Value : TNotifyEvent ); function GetClickEvent( index : TButtonPosition) : TNotifyEvent; published property FirstOnClick : TNotifyEvent index bpFirst read GetClickEvent write SetClickEvent; property PriorOnClick : TNotifyEvent index bpPrior read GetClickEvent write SetClickEvent; // etc… 第 10 章 高级组件设计 242 end; 列出的部分代码示范了属性提升。按钮是被包含的对象,但在以前的版本中无法为它们声明公开属性。 因此不能在 Object Inspector 中直接操纵这些对象。结果,所有在设计时需要被修改的被包含组件的特性都 需要属性提升,因此会增加许多额外的方法。虽然生成这些提升的属性及其相关联的访问方法很简单,但 是这些是必须编写的代码,让人感觉沉闷之极。Delphi 6 提供了组件所有权机制,减少了继续按上述方式 创建组件的必要性。 10.2.1 声明公开的组件特性 Delphi 的当前版本能够正确地将所拥有的组件流化到.DFM 文件,而且设计时可以在 Object Inspector 中对其进行修改。回顾一下 TButtonPanel 组件,可以发现:将对象数据声明为私有的,经由代表对象的特 性提供访问接口,仍然是个好习惯。但现在特性定义可能位于公开部分,而对象可以在设计时被修改,这 是第 3 章中讨论 TComponent 类所引入的新的特征。下面的代码示范了基本正确的解决方案。 unit UImagePanel; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, ExtCtrls; type TImagePanel = class(TPanel) private FImage : TImage; public constructor Create(AOwner : TComponent); override; published property Image : TImage read FImage; end; procedure Register; implementation procedure Register; begin RegisterComponents('PKTools', [TImagePanel]); end; constructor TImagePanel.Create(AOwner: TComponent); begin inherited; FImage := TImage.Create(Self); FImage.Parent := Self; FImage.Align := alClient; end; end. 从语法上来说,代码是正确的。TImagePanel 组件可以编译并安装,图像属性将显示为 TImagePanel 的特性。在设计时,如果改变了 TImage 的 Picture 特性,图像面板如图 10.4 所示,其功能是正确的。 第 10 章 图 10.4 高级组件设计 243 设计时的 TImagePanel 组件,该组件是在前面的代码中定义的 但更进一步来看,应用程序运行时,图像是空白的。如果以文本方式查看 DFM 文件(列表如下), 可以注意到没有与图像特性相关的流化数据。 object Form1: TForm1 Left = 342 Top = 116 Width = 316 Height = 400 Caption = 'Form1' Color = clBtnFace Font.Charset = DEFAULT_CHARSET Font.Color = clWindowText Font.Height = -13 Font.Name = 'MS Sans Serif' Font.Style = [] OldCreateOrder = False PixelsPerInch = 120 TextHeight = 16 object ImagePanel1: TImagePanel Left = 64 Top = 64 Width = 201 Height = 257 Caption = 'ImagePanel1' TabOrder = 0 end end 很清楚,某些地方出了错。实际上也是如此。如果要持久化公开特性的状态,要记得调用 TComponent 类的新方法 SetSubComponent。 10.2.2 调用 SetSubComponent 以持久化公开对象 上一节乍看起来没什么问题。仔细查看一下,可以发现图像面板并未持久化组件的图像部分。当代码 添加加到包、安装组件、在窗体上测试组件时,这一点是显然的,但在这之前却很难看出来。如果我们添 加一个对 SetSubComponent 的调用,设计时对公开对象的属性的修改将持久化到 DFM 文件中。 将该调用添加到上一节的构造函数中,即可使组件正常工作。修改后的构造函数如下。 constructor TImagePanel.Create(AOwner: TComponent); begin inherited; FImage := TImage.Create(Self); 第 10 章 高级组件设计 244 FImage.Parent := Self; FImage.Align := alClient; FImage.SetSubComponent( True ); end; TComponent 类定义在 Classes.pas 单元中,SetSubComponent 方法的代码如下: procedure TComponent.SetSubComponent(Value: Boolean); begin if Value then Include(FComponentStyle, csSubComponent) else Exclude(FComponentStyle, csSubComponent); end; SetSubComponent 将 csSubComponent 添加到 ComponentStyle 集合中。更为重要的是,它改变了子组 件特性流化到窗体资源文件的方式。当通过调用 SetSubComponent 向窗体添加了 csSubComponent 风格后, 窗体将呈现正确的行为,下面以文本方式列出了窗体资源文件的一部分。 object Form1: TForm1 Left = 457 Top = 125 Width = 316 Height = 400 Caption = 'Form1' Color = clBtnFace Font.Charset = DEFAULT_CHARSET Font.Color = clWindowText Font.Height = -13 Font.Name = 'MS Sans Serif' Font.Style = [] OldCreateOrder = False PixelsPerInch = 120 TextHeight = 16 object ImagePanel1: TImagePanel Left = 40 Top = 40 Width = 209 Height = 161 Caption = 'ImagePanel1' TabOrder = 0 Image.Left = 1 Image.Top = 1 Image.Width = 207 Image.Height = 159 Image.Align = alClient Image.Picture.Data = { 07544269746D6170E62B0000424DE62B00000000000076000000280000009900 注意:为节省空间,列出的窗体资源文件被截短了。长的数字序列是位图的比特表示,大约 要 15 张纸才能完全列出。 要使特性流化机制能够正确地流化所包含的图像组件,在构造函数中调用 SetSubComponent 即可。 如果想让用户可以访问对象的特性,可以使用 property 语句使该对象具有公开权限。如果只需要使被 第 10 章 高级组件设计 245 包含对象的某些属性可访问,可以从被包含对象的 TCustom 类型祖先子类化,并且只提升想在 Object Inspector 中显示的那些属性的可见性。然后在容器组件中定义新的子类的对象,即使是公开权限仍然能够 有效地限制在设计时可以修改的特性。 10.3 创建对话框组件 对话框组件包括一个被该组件对象所包含的窗体。在组件面板的 Dialogs 属性页上可以找到几个对话 框组件的例子,包括打开文件、保存文件对话框,打印、打印机设置对话框,字体、颜色、查找、替换对 话框组件。如果组件需要复杂的界面,有可能用户需要输入许多信息才能正确设置组件的状态(见图 10.5), 那么您就需要对话框组件。 图 10.5 TFontDialog 组件需要在对话框界面中设置与字体相关的多种特性 注意:TForm 是 TComponent 的派生类。您可能会得出结论:窗体可以作为组件安装到 VCL 中。 按照惯例从不这样做。如果向窗体添加了注册过程,窗体将安装到 VCL 中;但特性在设计时 无法正确流化,控件则会在运行时消失。 对 话 框 组 件 在 组 件 类 中 包 含 了 一 个 窗 体 。 需 要 将 一 个 非 可 视 化 组 件 安 装 到 VCL 中 , 通 常 从 TComponent 派生,由它来负责显示体现对话框行为的窗体。下面的代码示范了一个组件化的 AboutBox 对 话框,它使用了第 9 章的 VersionLabel 组件(见图 10.6)。 图 10.6 unit UAboutBoxDialog; interface AboutBoxDialog 组件 第 10 章 高级组件设计 uses Windows, SysUtils, Classes, Graphics, Forms, Controls, StdCtrls, Buttons, ExtCtrls, UVersionLabel; type TAboutBox = class(TForm) Panel1: TPanel; ProgramIcon: TImage; ProductName: TLabel; OKButton: TButton; VersionLabel1: TVersionLabel; Copyright: TLabel; Comments: TLabel; private { Private declarations } public { Public declarations } end; TAboutBoxDialog = class(TComponent) private AboutBox : TABoutBox; FProductName, FCopyright, FComments : TCaption; FPicture : TPicture; protected procedure SetPicture(const Value: TPicture); public function Execute : Boolean; published constructor Create( AOwner : TComponent ); override; destructor Destroy; override; property Copyright : TCaption read FCopyright write FCopyright; property Comments : TCaption read FComments write FComments; property ProductName : TCaption read FProductName write FProductName; property Picture : TPicture read FPicture write SetPicture; end; procedure Register; implementation {$R *.DFM} procedure Register; begin RegisterComponents('PKTools', [TAboutBoxDialog]); end; { TAboutBoxDialog } constructor TAboutBoxDialog.Create(AOwner: TComponent); begin inherited; FPicture := TPicture.Create; end; 246 第 10 章 高级组件设计 247 destructor TAboutBoxDialog.Destroy; begin FPicture.Free; inherited; end; function TAboutBoxDialog.Execute: Boolean; begin AboutBox := TAboutBox.Create(Screen); try AboutBox.ProductName.Caption := FProductName; AboutBox.Copyright.Caption := FCopyright; AboutBox.Comments.Caption := FComments; AboutBox.VersionLabel1.FileName := Application.EXEName; AboutBox.ProgramIcon.Picture.Assign( FPicture ); AboutBox.ShowModal; finally AboutBox.Free; end; result := True; end; procedure TAboutBoxDialog.SetPicture(const Value: TPicture); begin if( Value = FPicture ) then exit; FPicture.Assign(Value); end; end. 使用 Delphi 的 New Items 对话框 Forms 属性页上的 AboutBox 模板,启动一个新的窗体作为组件。使 用第 9 章的 TVersionLabel 组件替换表示版本的 TLabel。组件的窗体部分不需要其他代码。按照惯例,对 话框组件通过一个返回 Boolean 的函数方法来显示。在该惯例并不违反直觉的情况下,将 execute 方法添 加到实际的组件 TAboutBoxDialog 中。然后声明用来初始化窗体的包含数据的字段。由图 10.6 可知,图像、 产品名、注释、版权等特性显然是需要的。因为所有的标签都使用了 TCaption 类型,您只需在组件的构造 函数中创建 TPicture 对象并在析构函数中将其释放。 Execute 方法使得对话框组件易于使用,如同 VCL 中 Dialog 属性页上的组件一样。Execute 实例化对 话框窗体,将组件数据复制到窗体特性,并把窗体作为模式对话框显示。大多数情况下,这就是通常的对 话框组件。创建窗体、定义行为、将窗体实例包裹在组件中,当调用 Execute 方法时显示窗体,并将窗体 状态作为组件的特性值返回。这个例子很简单但相当有用,它示范了对话框组件设计的技术。 10.4 重载 Notification 方法 在设计时,有些类型的组件会引用其他的组件。一个很好的例子是 TTable 和 TQuery 组件,它们引用 了 TDataSource 组件。在 TComponent 中引入了 Notification 方法,定义如下。 procedure Notification(AComponent: TComponent; Operation: TOperation); virtual; 上面的组件参数是即将创建或删除的组件。TOperation 是枚举类型,包括元素 opInsert 和 opRemove。 当组件被删除或创建时,其所有者 TWinControl 对象对其拥有的所有组件迭代并调用相应的 Notification 方 法(参见图 10.7,其中显示了当一个 TTable 组件被创建时的调用栈)。如果某些被拥有的组件引用了即将 第 10 章 高级组件设计 248 删除的组件,Notification 方法使得它们可以将引用设置为空。如果他们没有将引用设置为空,那么该引用 的使用将导致访问违例。 图 10.7 组件插入到存储器时的调用栈 如果组件在本身与其他对象之间维护了引用关系,那么应该在组件重载 Notification 方法。如果是 HasA 或聚合关系,则不必使用 Notification 方法。 下面的引用的代码来自两个类,一个是 TLed 图形类,另一个是 TLedTimer。假定 TLedTimer 是 TTimer 的子类,并维护了一个 TList,其中包含了一个或多个定时器。每个 TLed 组件包含一个对定时器的引用。 对定时器的每次滴答声,LED 根据闪烁的频率开或关。 如果组件有一个对象特性,那么可以在 Object Inspector 中使用下拉框。下拉框将显示组件作用域中所 有具有合适类型的可访问的对象。这使得可以对引用特性分配一个对象。在 LED 的例子中,定时器用来 使 LED 闪烁;如果 LED 引用了定时器而定时器被删除,那么必须更新 LED 对定时器的引用。 Procedure TLed.Notification( AComponent : TComponent; Operation : TOperation ); begin inherited Notification( AComponent, Operation ); if( Operation = opRemove ) and (AComponent = FLedTimer ) then FLedTimer := Nil; end; 在例子中,Notification 方法将测试 AComponent 参数是否与 FLedTimer 相同;如果相同,那么 FLedTimer 字段将被设置为零。按照规则,首先调用 Notification 的继承版本,测试是否维护了该组件的引用,然后看 一下该操作组件是否重要。如果满足这些条件,那么更新引用。 10.5 创建特性编辑器 内部数据类型特性有预定义特性编辑器。通常特性编辑器以简单的文本域的形式出现。用户输入数据, 属性编辑器执行基本的校验工作。在 Object Inspector 中,数据输入域位于右侧,与相应的特性相邻,它是 由 TPropertyEditor 类派生而来。图 10.8 演示了组合框特性编辑器,可以在组件作用域内选取对象。另一个 特性编辑器使用了如图 10.5 所示的 TFontDialog 对话框。 第 10 章 图 10.8 高级组件设计 249 上一节提及的 LED 组件的 LedTimer 特性 由于属性编辑器是对象,可以按照需要使其简单或复杂。您还可能遇到过其他的特性编辑器,如 TStrings Items 特性编辑器、TTable 和 TQuery 组件的字段编辑器、字体编辑器、DBGrids 的 Columns 编辑 器 等 。 要 定 义 特 性 编 辑 器 , 必 须 子 类 化 Delphi 的 Source\ToolsAPI 目 录 下 dsgnintf.pas 中 定 义 的 TPropertyEditor,或者子类化 TPropertyEditor 的某个后代。定义特性编辑器后,需要使用组件中定义的 Register 过程注册特性编辑器。 procedure Register; begin RegisterComponents('PKTools', [TVersionLabel]); RegisterPropertyEditor( TypeInfo(TFileName), TVersionLabel, 'FileName',TFileNameProperty); end; 上述代码示范了 RegisterPropertyEditor 的用法, 该过程为第 9 章中的 TVersionLabel 组件注册了一个特 性编辑器类 TFileNameProperty。第一个参数是与特性编辑器相关联的数据类型的 RTTI 记录。在例子中, 编辑器与 TFileName 类型的特性相关。第二个参数是 TComponentClass,表示为哪个组件类注册了特性编 辑器。第三个参数是特性名,第四个参数类型为 TPropertyEditorClass,它表示 TPropertyEditor 的一个子类。 10.5.1 子类化已有的特性编辑器 类最大的好处之一就是可以重用。相对于从零开始定义一个类(或算法),找到已有的代码并进行重 用总是个好主意。要定制已有的特性编辑器,尽可能找一个与你的类的需求最为接近的编辑器。第 9 章的 TVersionLabel 类定义了一个 TFileName 类型的特性 FileName,该类型是由 Delphi 在 SysUtils 单元中定义 的,TFileName=string。用户可以很方便地浏览文件位置、指定路径和文件名、执行必要的验证,而无需输 入复杂的文件路径。 注意:要将特性编辑器与使用编辑器的组件分别在不同的单元中定义。这样,如果其他组件 特性的类型与该编辑器相适应,就可以重用该编辑器。 幸运的是,发现有一个合适的特性编辑器与 TMediaPlayer 相关联,而且提供了 TFileName 所需要的行 为。特性编辑器 TMPFileNameProperty 是由 dsgnintf.pas 中定义的 TStringProperty 子类化而来。除了数据类 型是 TMPFileNameProperty 外,该编辑器与我们的需求相当接近;另外该编辑器显示的文件名过滤器是不 正确的。在过滤器列表 MPFileName 特性显示的是*.avi、*.wav 和*.mid 文件。需要对该类做一些小的修改, 以解决所存在的问题。 当调用特性编辑器时,会调用特性编辑器的 Edit 方法。例如:可以在 TMediaPlayer 的 FileName 特 性中输入媒体文件的文件名。但如果在 Object Inspector 中双击相应的编辑域,或单击省略号按钮,将触发 特性编辑器的 Edit 方法。下面的代码示范了如何子类化 TMPFileName 类,并重载 Edit 方法以显示适合 TVersionList 组件的对话框。 第 10 章 高级组件设计 250 unit UFileNameProperty; interface Uses Dialogs, DsgnIntf, Forms; Type TFileNameProperty = class(TMPFileNameProperty) public Procedure Edit; override; end; implementation Procedure TFileNameProperty.Edit; var OpenDialog : TOpenDialog; begin OpenDialog := TOpenDialog.Create(Application); try OpenDialog.FileName := GetValue; OpenDialog.Filter := 'Log Files (*.log)|*.log|All Files (*.*)|*.*'; OpenDialog.Options := OpenDialog.Options + [ofPathMustExist]; if OpenDialog.Execute then SetValue(OpenDialog.Filename); finally OpenDialog.Free; end; end; end. 重载 Edit 方法的代码仅作为演示。使用超类的 Edit 方法作为示例,特性编辑器创建了一个 TOpenDialog 类的实例来完成大部分的工作。需要将 ofPathMustExist 选项添加到 OpenDialog.Options 中。然后调用由 TPropertyEditor 继承的 GetValue 方法来得到与该编辑器关联的特性的当前值,使用 Execute 方法显示 OpenDialog 对象。如果用户单击 OK,则调用继承的 SetValue 方法来更新相关联的特性。最后要释放 TOpenDialog 的实例。当把 RegisterPropertyEditor 语句添加到 TVersionLabel 的 Register 过程,并把特性编 辑器单元添加到组件实现部分的 uses 语句时,将把 TFileName 编辑器与每个版本标签组件的 FileName 特 性关联起来。 10.5.2 定义定制的特性编辑器 当定义定制特性编辑器时,可以重载 TPropertyEditor 中定义的许多虚方法。表 10.1 描述了所有的虚方 法,您可以在子类重载。要记住:表 10.1 中的方法不包括在已经子类化的特性编辑器中所添加的额外的虚 的保护或公有方法。 表 10.1 为特性编辑器定义的虚方法,重载特定的方法可以创建定制的特性编辑器 方法 描述 Create 保护权限的构造函数,用于创建特性编辑器的实例 Destroy 公有权限的析构函数,如果在特性编辑器中实例化了所拥有的对象,则需重载 该函数 Activate 当在 Object Inspector 激活相关特性时,调用该方法 (续表) 方法 描述 AllEqual 当选定多个对象时,调用该方法。如果所有的对象都具有相等的特性值,则返 第 10 章 高级组件设计 251 回该值 当 GetAttributes 返回的 TPropertyAttributes 包含 paValueList 时,调用该方法验 AutoFill 证是否允许自动完成特性值输入 Edit 当双击编辑域或单击省略号按钮(…)触发特性编辑器时,调用该方法 GetAttributes 返回 TPropertyAttributes 值;例如,如果 TPropertyAttributes 包含 paDialog,则 该编辑器用于对话框,与 TFileNameProperty 相似 GetEditLimit 返回用户可输入字符的最大数目 GetName 返回特性名 GetProperties 如果特性包含子特性(如字体),将显示子特性;向 TPropertyAttributes 添加 paSubProperties 值 GetPropInfo 返回指向 RTTI 特性信息记录的指针 GetValue 返回特性的字符串值 GetValues 当有多个已定义的合适值时,对特性设置所有可接收的值;该特性的 TPropertyAttributes 值必须包含 paValueList Initialize 在使用特性编辑器之前进行初始化 SetValue 设置特性值的字符串表示 ListMeasureWidth 在显示特性值的下拉列表之前,确定其宽度 ListMeasureHeight 在显示特性值列表之前,返回其高度 ListDrawValue 允许在特性值列表中绘出图形数据 PropDrawName 用于在 Object Inspector 中画出特性的名字 PropDrawValue 用于在 Object Inspector 中画出特性的值 TTableBox 假设已经定义了一个组件,可以列出给定数据库的所有可用的表。TTableBox 是 TComboBox 类的后 代,它在响应下拉动作时,使用给定数据库中所有的表填充下拉列表。新组件可以如下定义。 unit UTableBox; // UTableBox.pas - Contains a tablename combobox // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, DBTables, StdCtrls; type TTableBox = class(TCustomComboBox) private { Private declarations } FSession : TSession; FDatabaseName : string; protected { Protected declarations } procedure DropDown; override; procedure Notification( AComponent : TComponent; Operation : TOperation ); override; procedure SetSession( const Value : TSession ); procedure SetDatabaseName(const Value: string); public 第 10 章 高级组件设计 252 { Public declarations } published { Published declarations } property Text; property OnChange; property Session : TSession read FSession write SetSession; property DatabaseName : string read FDatabaseName write SetDatabaseName; end; procedure Register; implementation procedure Register; begin RegisterComponents('PKTools', [TTableBox]); end; { TTableBox } procedure TTableBox.DropDown; begin inherited; if( FSession = Nil ) then FSession := DBTables.Session; if( Items.Count = 0 ) then FSession.GetTableNames( FDatabaseName, '*.*', True, True, Items ); end; procedure TTableBox.Notification(AComponent: TComponent; Operation: TOperation); begin inherited; if( Operation = opRemove ) and (AComponent = FSession) then Session := Nil; end; procedure TTableBox.SetDatabaseName(const Value: string); begin FDatabaseName := Value; end; procedure TTableBox.SetSession(const Value: TSession); begin if( Value = FSession ) then exit; Items.Clear; FSession := Value; end; end. TTableBox 是由 TCustomComboBox 子类化而来,可以限制对某些特性的服务,如 Items,这样可以将 非表数据放置到列表中。两个特性分别是 Session 和 DatabaseName。Session 是一个 TSession 组件, DatabaseName 是一个字符串。这两个实现可用于在数据库中进行查找,数据库可能是 ODBC 别名、物理 数据库或者其他类似的东西,然后得到可用表的列表。这里重载了 Notification 方法,以确保组件能够知道 第 10 章 高级组件设计 253 Session 组件是否被删除。Text 和 OnChange 特性由 TCustomComboBox 中的保护权限提升为公开权限,既 保持了组件的简单性,又可以对用户选择的数据进行访问。为确保下拉列表中的数据尽可能新,每次出现 下拉动作时都重新填充列表。 组件的工作是可靠的。一个明显的不足是 DatabaseName 特性。输入数据库名很容易出现错误。从可 用的数据库列表中进行选择可能更为方便。为此,我们需要为数据库名字特性定义特性编辑器。 TDatabaseName 特性编辑器 使用上一节的 TTableBox,我们添加一个特性编辑器以方便数据库名的选取。当我们对 DatabaseName 特性进行修改时,对字符串类型添加了一个新的别名,其类型定义如下。 type TDatabaseName = string; 将所有与 DataBaseName 特性相关的方法、字段、以及特性都转换为使用该类型,这样可以使特性编 辑器更好地与特性关联在一起。需要修改的代码如下: FDatabaseName : TDatabaseName; procedure SetDatabaseName(const Value: TDatabaseName); property DatabaseName : TDatabaseName read FDatabaseName write SetDatabaseName; 还需要修改 SetDatabaseName 的实现,来与上面的声明相匹配。 TDatabaseName 特性的属性编辑器将显示下拉列表框,其中包括数据库、ODBC 别名、Excel 数据以 及其他符合要求的数据源。该特性编辑器定义如下。 unit UDBPropertyEditors; // UDBPropertyEditors.pas - Contains property editors for database related objects // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Classes, SysUtils, Dialogs, DsgnIntf, DBTables, StdCtrls; type TDBPropertyEditor = class(TStringProperty) protected Procedure GetNames(Strings : TStrings);virtual; abstract; public Procedure GetValues(Proc: TGetStrProc); override; Function GetAttributes: TPropertyAttributes; override; end; TDatabaseNameProperty = class(TDBPropertyEditor) protected Procedure GetNames(Strings : TStrings); override; end; procedure Register; implementation uses UTableBox; procedure Register; 第 10 章 高级组件设计 254 begin RegisterPropertyEditor(TypeInfo(TDatabaseName), TTableBox, 'DatabaseName', TDatabaseNameProperty ); end; { TDBPropertyEditor } function TDBPropertyEditor.GetAttributes: TPropertyAttributes; begin result := [paRevertable, paMultiselect, paValueList]; end; procedure TDBPropertyEditor.GetValues(Proc: TGetStrProc); var I: Integer; Strings : TStrings; begin Strings := TStringList.Create; try GetNames( Strings ); for I := 0 to Strings.Count - 1 do Proc( Strings[I] ); finally Strings.Free; end; end; { TDatabaseNameProperty } procedure TDatabaseNameProperty.GetNames(Strings: TStrings); begin Session.GetDatabaseNames(Strings); end; end. TDatabaseName 特 性 的 编 辑 器 是 TDBPropertyEditor , 由 TStringProperty 子 类 化 而 来 。 TDBPropertyEditor 重载了 GetValues 和 GetAttribute。由于编辑器需要提供一个列表,paValueList 作为编 辑器的属性之一返回(其他属性的描述请参见表 10.1)。GetValues 创建一个 TStringList 对象,调用 GetNames (一会儿会讨论到),然后用字符串来填充编辑器的下拉列表框。出于这个目的,GetValues 的参数为过 程类型 TGetStrProc。 TDBPropertyEditor 将 GetNames 定义为抽象方法。结果永远无法实例化 TDBPropertyEditor,而必须使 用 子 类 。 子 类 只 需 要 定 义 GetNames 。 TDatabaseNameProperty 是 我 们 要 讨 论 问 题 , 它 使 用 Session.GetDatabaseNames(Strings)来实现 GetNames 方法。 Session 对象是在 DBTables.pas 中定义的,它是 个全局对象。 实现部分开始定义的 Register 过程负责注册特性编辑器。其中 RegisterPropertyEditor 的参数分别是 TDatabaseName 的 RTTI TypeInfo 记录、编辑器响应的组件、相关联的特性、以及编辑器类自身。子类化 TDBPropertyEditor 类,可以用全局会话对象的方法来填充一个会话或表的列表。这些特性编辑器留作练习。 也可以创建组件一级的编辑器。组件编辑器是类,可以在右键单击组件时显示菜单;由 TComponentEditor 派生而来,并使用 TRegisterComponentEditor 进行注册(关于组件编辑器和库专用向导的讨论,可以参见 附录 B)。 第 10 章 10.6 高级组件设计 255 持久化非公开特性 除非储存指令是 False 或调用了一个返回 False 的函数,否则公开特性一般都流化到 DFM 文件。通过 重载由 TComponent 继承的 DefineProperties 方法,可以流化非公开的组件特性。DefineProperties 被定义如 下: procedure DefineProperties(Filer: TFiler); override; TFiler 类可子类化为 TReader 和 TWriter 类。TFiler.DefineProperty 方法可以指定一个特性, 并传递两 个分别具有 TReader 参数和 TWriter 参数的过程类型参数,分别用于读写 DFM 文件。持久化非公开对象的 关键在于,在自己的组件中重载 DefineProperties,并添加将特性流化到 DFM 文件的读写方法。 10.6.1 重载 DefineProperties 通过重载 DefineProperties,可以修改特性流入和流出 DFM 文件的方法,还可以流化非公开的特性。 下面的例子示范了如何将一个私有的整数流化到任意的具有该组件实例的窗体。 TStoreProperties = class(TComponent) private FAnInt : Integer; protected procedure ReadAnInt( Reader : TReader ); procedure WriteAnInt( Writer : TWriter ); procedure DefineProperties( Filer : TFiler ); override; public constructor Create(AOwner : TComponent ); override; end; DefineProperties 语句重载了继承的方法,而 ReadAnInt 和 WriteAnInt 分别是从 DFM 文件读写特性的 方法。 procedure TStoreProperties.DefineProperties(Filer: TFiler); begin inherited; Filer.DefineProperty( 'AnInt', ReadAnInt, WriteAnInt, True ); end; 在 DefineProperties 的实现中,它向 Filer 对象通知了数据的名字、用于流化数据的读写方法。最后一 个参数表示特性是否具有数据。在上面的代码中,AnInt 字段总是被流化。 procedure TStoreProperties.ReadAnInt(Reader: TReader); begin FAnInt := Reader.ReadInteger; end; procedure TStoreProperties.WriteAnInt(Writer: TWriter); begin Writer.WriteInteger( FAnInt ); end; object StoreProperties1: TStoreProperties Left = 256 Top = 160 AnInt = 100 第 10 章 高级组件设计 256 end 读写方法根据数据类型来调用 TReader 和 TWriter 的适当方法。当调用这些方法时,TReader 和 TWriter 已处于正确的数据流位置,可以读写 AnInt 值。DFM 文件片断以文本形式显示了流化的 AnInt 字段值。 10.6.2 TReader 和 TWriter TReader 和 TWriter 都有几个方法可用于读写内部数据类型。例如:ReadString 读取字符串数据, ReadChar 读取一个字符。读、写两个类是对称的,因此如果使用某个特定的写方法来写入数据,那么也可 以使用对应的读方法来读出数据。 对于每个要流化的额外的字段或属性,都要添加一个相应的 Filer.DefineProperty 调用;还要为每个数 据类型分别传递读写过程。这意味着,对每个要流化的额外的值,都需要一对读写方法。 10.6.3 写入复杂类型的数据 对于写入内部类型数据,有一些特定的方法可用。聚集类型或复杂类型可分解为几个简单类型;而对 于组件,还有可读写组件的方法。本小节示范了如何流化简单类型以外的数据,并讨论与流对象相关的一 些问题。 流化列表 使用本节开头讲到的技术,也能把数据列表存储到 DFM 文件中。在下面的例子中,示范了将 TStringList 中的字符串存储到 DFM 文件。 private FStrings : Strings; protected procedure ReadStrings( Reader : TReader ); procedure WriteStrings( Writer : TWriter ); procedure DefineProperties( Filer : TFiler ); override; public constructor Create(AOwner : TComponent ); override; destructor Destroy; override; 将前面的声明添加到 TStoreProperties 类对该过程进行测试。与以前相同,有一个私有字段而没有公开 的特性。声明了读写方法,并重载了 DefineProperties。由于 TStrings 是对象,需要定义构造函数和析构函 数来初始化和清除 TStrings 对象。其实现如下。 constructor TStoreProperties.Create(AOwner : TComponent ); begin inherited; FAnInt := 100; FStrings := TStringList.Create; FStrings.Text := ROBERT_HERRICK; end; destructor TStoreProperties.Destroy; begin FStrings.Free; inherited; end; 构造函数和析构函数用来初始化 TStrings 对象,并把常数 ROBERT_HERRICK 赋值给对象的 Text 特 性。 procedure TStoreProperties.DefineProperties(Filer: TFiler); begin 第 10 章 高级组件设计 257 inherited; Filer.DefineProperty( 'AnInt', ReadAnInt, WriteAnInt, True ); Filer.DefineProperty( 'Strings', ReadStrings, WriteStrings, True ); end; 注意:无须在调用 DefineProperty 时使用与字段或特性相同的名字。由于可读性的原因, 去掉了字段前缀 F,只使用名字的其余部分也是一样。 代码中修改了 DefineProperties,用于把 TStrings 字段添加到对 DefineProperty 的调用。 procedure TStoreProperties.ReadStrings(Reader: TReader); begin Reader.ReadListBegin; FStrings.Clear; while( Reader.EndOfList = False ) do FStrings.Add( Reader.ReadString ); Reader.ReadListEnd; end; procedure TStoreProperties.WriteStrings(Writer: TWriter); var I : Integer; begin Writer.WriteListBegin; for I := 0 to FStrings.Count - 1 do Writer.WriteString( FStrings[I] ); Writer.WriteListEnd; end; 回忆一下,读写方法必须是对称的,可以看到每个方法都调用适当的 ListBegin 和 ListEnd 方法来读写 列表的标志。读方法清除字符串,读取字符串值直到出现 EndOfList 标志;将列表中的每个字符串都添加 到 TStrings 对象。写方法知道列表中有多少项,因此可以用 for 循环写出所有的字符串。 对象流化与窗体继承 当把一个窗体添加到存储库时,该窗体即成为可继承窗体模板。对父窗体的改变会影响子窗体。如果 对组件进行流化,而这些组件也添加到存储库的窗体中,那么您可能需要确保在子窗体不再流化同样的特 性值;否则对祖先窗体的改变将无法反映到子窗体中。为确保这一点,可以对 DefineProperties 方法添加 一个条件写函数。 为确保 TStrings 字段值不在子窗体中进行流化,可将下面的嵌套函数添加到 DefineProperties 中。 function DoWrite: Boolean; begin if Filer.Ancestor <> nil then result := not (Filer.Ancestor is TStoreProperties) or not (TStoreProperties(Filer.Ancestor).FStrings.Text = FStrings.Text) else result := not (FStrings.Text = EmptyStr); end; 该函数检查 Filer.Ancestor 是否不为空。在祖先不为空的情况下,如果祖先类型或相应的文本不同,文 本将写出到文件。如果祖先为空而字符串非空,字段值就写出到文件。修改后的 DefineProperties 完整代码 如下列出。 第 10 章 258 高级组件设计 procedure TStoreProperties.DefineProperties(Filer: TFiler); function DoWrite: Boolean; begin if Filer.Ancestor <> nil then result := not (Filer.Ancestor is TStoreProperties) or not (TStoreProperties(Filer.Ancestor).FStrings.Text = FStrings.Text) else result := not (FStrings.Text = EmptyStr); end; begin inherited; Filer.DefineProperty( 'AnInt', ReadAnInt, WriteAnInt, True ); Filer.DefineProperty( 'Strings', ReadStrings, WriteStrings, DoWrite ); end; 请注意,DoWrite 仅用于 DefineProperties,因此嵌套在 DefineProperties 中。在前面的代码中,HasData (第四个参数)为 True,而现在的代码中使用了 DoWrite 的返回值。可以用功能相似的函数来有条件地写 出字段数据,以确保在且仅在符合条件的情况下写出数据。 流化二进制数据 流化二进制特性时,使用 DefineBinaryProperty 方法,以及参数为 TSream 类型的读写方法。在重载的 DefineProperties 方法中,使用同样的 Filer 对象调用 DefineBinaryProperty 方法,就可以使用流对象读写二 进制特性。在 DefineBinaryProperties 方法中可能出现的对 DefineBinaryProperty 的调用如下。 Filer.DefineBinaryProperty( 'Image', ReadImage, WriteImage, True ); ReadImage 和 WriteImage 参数是有一个 TStream 参数的过程。 procedure TStoreProperties.ReadImage(Stream : TStream); begin FImage.Picture.Bitmap.LoadFromStream(Stream); end; procedure TStoreProperties.WriteImage(Stream : TStream); begin FImage.Picture.Bitmap.SaveToStream( Stream ); end; 将 ReadImage 和 WriteImage 的声明添加到类中并像上述代码中那样进行定义,即可向 DFM 文件流化 Image 字段。还需要添加图像字段,并在构造函数和析构函数中相应地创建和释放它。 10.7 小 结 第 10 章包含了许多内容。你学习了创建高级组件的技术,像动态定义并加载资源、如何公开所拥有 的组件(Delphi 6 中的新技术)、怎样创建对话框组件以及定义特性编辑器,还有如何流化对象的非公开 属性。 VCL 演示了 Delphi 设计得多么高明。除了在第 10 章中学到的高级技巧(包括如何使用 Notification 方法)之外,还有一些可利用的 VCL 特性。在第 11 章中可以学到更多的关于使用组件扩展用户界面的知 识。而附录 A 则涵盖了 OpenTools API、创建组件编辑器、以及库向导的注册等内容。 第 11 章 用组件开发一致的界面 什么能算是好的界面,标准是非常主观的。许多商业应用程序都跟随着 WinTel 标准:灰色的按钮和 控件,白色的背景。对商务程序来说,这可能是个不错的主意,因为通过多年的熟悉使得这个界面在某种 程度上较为舒服,但这是个好的界面吗?Alan Cooper 是 Visual Basic 之父,他建议“通过坚持使他们 (Microsoft 和 Apple)各自独立的开发者群体遵守既定的方针,他们偷偷摸摸地阻止了来自应用者群体的 革新。”[Cooper,212] Cooper 认为,“我并不鼓吹忽略界面风格方面的指导,从而导致界面出现混乱。我 仅仅认为应该像参议员看待说客那样来看待对界面风格的指导,而绝不能像司机服从于交警那样。立法者 知道说客想要削减某项经费,但说客并非来自于持有客观态度的第三方。”[Cooper,212] 在所有的条件下都是最好的界面可能并不存在,即使在一定的条件下,界面的设计仍然是高度主观的。 如果你能开发出像图 11.1 所示的新 RealPlayer 那样的界面,而且符合你的目的,那就很好了。如果你不擅 长创建独一无二或非常有趣的图形用户界面,而且并没有雇佣图形设计者的预算,那么可能会开发出与 WinTel 风格类似的应用程序。对于商业目的而言,也许较为熟悉的风格可以避免使用方面的障碍。 图 11.1 RealPlayer 8 使用了一些漂亮的图形按钮,并进行了视觉人类 工程学方面的尝试。还可以选用卡通标志和斑马条纹等外表 只有一个问题不是主观的,它也是本章的主题,那就是界面应该是一致、连贯、完全的。不一致、不 连贯、不完全,不考虑界面的风格对用户来说是不可容忍的。第 11 章示范了一些技术,可用于简化开发 并确保一致性,包括如何使用定制组件、组件模板和窗体继承,以提供一致、连贯而完全的应用程序。 第 11 章 用组件开发一致的界面 11.1 265 定 制 组 件 创建定制组件很有趣,而且定制组件也很有用。首先,显而易见的理由是可以重用已有的对象,并封 装新的或增强的特性;其次,它可以提供一致的效用。无须绘制组件时保证相同的尺寸、风格、字体、颜 色或措辞,可以对组件进行定制以确保这些目标。 11.1.1 定制组件的三个 C 定制组件的三个 C 是一致性、连贯性和完备性。一致性意味着组件在你的应用程序和其他地方的行为 是一致的。 一致性(Consistency) 组件每次都表现出相同的行为和初始状态,才能提供一致性。对组件的行为或状态进行一次编程,则 所有的组件实例都具有一致的外观和行为。 一致性并不追求数量,注意到这一点是很重要的。定制组件无须进行大量的修改,即可提供一致性。 即使组件只是重载了缺省的大小或形状,创建一个定制组件也可确保一致性。有两个直接的方法可以做到 这一点。您可以子类化所有的需要微小修改的组件然后再安装;或者快速地创建组件模板,这更容易一些 (参考 11.2 节“创建组件模板”)。 连贯性(Coherency) 一致性是连贯的一个方面。如果对象不具有一致性,也会缺少连贯性。连贯性是对控制流和操作的逻 辑性的度量,它要求语义上相似的操作具有一致的行为。 定制控件和组件模板可用于提供更为连贯的行为流程。没有一致性和连贯性,应用程序不可能是完全 的。 完备性(Completeness) 不一致、不完全的应用程序看起来是不合逻辑且不正确的,这样必定是不完备的。如果应用程序不被 用户群体所接受,也不能说是完备的。完备性度量了应用程序是否执行了所要求的任务、结果是否正确、 应用程序是否具有合理的容错级别。 如果程序给出正确却不合时宜的回答,也是不完备的。而迅速的提供错误的结果,仍然是错误的。如 果程序的行为毫无规律、不一致、或不合逻辑,那么该程序是失败的。即使程序有相应的用户群体,仍然 可能失败,因为用户群体可以拒绝使用该程序,或恶意共谋使用该程序提供错误的或不合适宜的结果。 为什么组件帮助你走向胜利 组件是对象。每个对象都属于某个类。这意味着有一组代码需要测试、调试和扩展。如果一个类已经 是完美的,那么每个实例都不会出错。这样如果类满足了 3C 标准,那么类的每个实例都会满足该标准。 注意:“大而复杂的软件系统需要设计师,以便开发者能够朝着共同的目标前进。” [Jacobsen,Booch,and Rumbaugh 62]。设计师是这样的人,他形成解决方案的概念并向程序 员说清设计意图。即便开始时的进度比通常慢,也要把事情做正确,这将会节省大量金钱和 思考的时间,防止在最后才发现出轨。 没有经验、缺乏技术的管理者可能认为编写组件接近于消磨时间,但这是面向对象的程序设计。以非 面向对象的方法去使用面向对象工具是一个错误。使用 Delphi 编写结构化程序可以很快地到达 beta 版,这 在短期内常常会使管理者高兴,但可能使得处于 beta 版的时间较长。您的程序可能永远都脱离不了 beta 版。迅速得到错误的答案,仍然是错误的。 无论是否能确认管理层会花大笔金钱来确保成功,都可以采取一些防御措施。从许多功能正确的组件 来创建程序,可以尽可能少写代码而又能提高程序的正确性。 第 11 章 11.1.2 用组件开发一致的界面 266 重分解 重分解是采取小的增量式改变的过程。组件可以一步就写出来,创建全新而独一无二的东西,这样做 代价昂贵、风险较大而且浪费时间;或者我们可以采取小的步骤,分层实现各种能力,这样就不那么昂贵, 风险较低而且快速。设计师的关键作用之一 —— 找到和减少冒险。如果没有设计师,必须由程序员来完 成该工作。管理者喜欢快速而廉价。那么很清楚,许多情况下最好的选择就是对组件进行小的修改,将增 量式的改变分层添加到已有的组件中。 为示范进行这种小的修改所需代码的合理数量,设计了下面的组件: unit UDBShortNavigator; // UDBShortNavigator.pas - Toggles between short list of buttons // and long list // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, ExtCtrls, DBCtrls; type TNavigatorButtonSet = ( nbsFull, nbsPartial ); TDBShortNavigator = class(TDBNavigator) private { Private declarations } FButtonSet : TNavigatorButtonSet; procedure SetButtonSet(const Value: TNavigatorButtonSet); protected { Protected declarations } public { Public declarations } published { Published declarations } property ButtonSet : TNavigatorButtonSet read FButtonSet write SetButtonSet; end; procedure Register; implementation procedure Register; begin RegisterComponents('PKTools', [TDBShortNavigator]); end; { TDBShortNavigator } procedure TDBShortNavigator.SetButtonSet(const Value: TNavigatorButtonSet); const FULL_SET = [nbFirst, nbPrior, nbNext, nbLast, nbInsert, nbDelete, nbEdit, nbPost, nbCancel, nbRefresh]; PARTIAL_SET = [nbFirst, nbPrior, nbNext, nbLast]; SETS : array[TNavigatorButtonSet] of TButtonSet = (FULL_SET, 第 11 章 用组件开发一致的界面 267 PARTIAL_SET); begin if( FButtonSet = Value ) then exit; FButtonSet := Value; VisibleButtons := SETS[FButtonSet]; end; end. TDBShortNavigator 继承了 TDBNavigator,添加了一个 ButtonSet 特性。将该特性在 nbsFull 和 nbsPartial 之间切换,即可显示所有的导航按钮或仅仅显示基本的四个按钮(如图 11.2 所示)。 图 11.2 TDBShortNavigator 可以快速地在显示部分或全部导航按钮之间切换 很显然该组件并没有多少代码。而这正是我们所需要的。小的改变快速、便宜而且可靠。然而,有人 可能认为这是不重要或不相关的。由混沌理论可知,即使蛾的翅膀振动一下也可能影响到很远的地方。那 么我们可以考虑蛾困在 Mark Ⅱ型计算机的中继转换开关中的情况。据说是 COBOL 的发明者 Grace Hopper 杜撰了 bug 这个词,现在整个工业界都在使用它,连世界历史上最重大的媒体事件之一 Y2K 问题也是它 的标志。 并不是说上述的导航器子类在历史上也能有这样幸运的角色;轶事能被记录本来就是戏剧性的。小的 事件能够发挥值得记载的作用,并具有相当的影响。千里之行,始于足下,高级的系统正是由小块的优质 代码所组成的。 11.1.3 小的改变有什么好处 除了本节开始所描述的好处之外,还包括快速、廉价、可靠等等,这些好处都是由小的、增量式的改 变得到的。TDBShortNavigator 这样的组件有利于代码的收敛。收敛是指所有该算法的代码都聚集在一起; 如果没有最好的代码,那么一个实例的代码是次好的,代码多于一个实例是较差的。发散是指出现算法的 多个副本;这是最坏的情况。 当子类化 TDBNavigator 这样的组件来进行小的改变时,可以促进代码的收敛。改变可见按钮数量的 所有代码都包含在同一个地方。因此只有一个代码段需要测试、调试和扩展。如果要为按钮定义三个状态, 可以在同一地方快速而有效地修改代码。 注意:您可能听说过比其他的程序员多产一个数量级的程序员。也就是他个人的产量是其他 人的十倍。这怎么可能呢?很显然一个人不可能比程序员的平均打字速度快十倍。这是技巧 与策略方面的问题。即使最好的程序员也不太可能在语法和程序编码方面比平均程度强十 倍;他只是使用了一些具有累积效应的策略。其中必有一种策略倾向于编写收敛的代码。这 种程序员可以比一般人快上十倍或更多,而且其代码也可能好于平均的水平。 我们提到过,修改并不重要。重要的是修改表示了什么。像 TDBShortNavigator 这样的组件就表明了 编写内聚代码的倾向。这种累积效应往往分布在程序员的职业生涯或工程的生命周期中。 11.1.4 采取好的策略 编写收敛的代码,或编写的代码只具有算法的单一副本,这是一个策略。这可能是成为高产开发者的 最佳途径之一。有两个习惯可促进采用该策略,并逐渐使之成为一种第二天性。首先:考虑多次修改你的 代码。我们知道谚语“天才是 1%的灵感加 99%的汗水”,这意味着当一个人思考解决方案时,只有出现 了非常好的机会,灵感才能发挥作用。其次:当发现重复出现的代码时,立即把涉及到的算法编写为过程。 随着实践的进行,这种迭代式的修改会变得更加自然,如果随时进行修改,也更容易发挥作用。反之, 第 11 章 用组件开发一致的界面 268 如果等到程序完成之后才进行修改工作,可能会遇到困难。管理者和其他程序员可能不想立即投入很多时 间进行修改,而这时代码已经相互纠缠在一起以至于小的修改也可能引起代码的混乱。 11.1.5 组件化 我们就继续讨论上一节的问题,如果你发现自己正在编写处理组件内部数据的代码,那么最好子类化 组件以封装新的行为。组件化的规则是:如果代码涉及到组件内部的数据,例如组件所拥有的对象列表, 那么代码实际上描述了组件的行为。对象的行为就是方法。 通过将外部的、隐式的算法提升为方法,可以使代码在类的层次上趋向于收敛。类不一定是组件;任 何表示类行为的代码都应该作为方法合并到类中。 有三个地方可以方便地重用代码。可以子类化包括组件在内的任何类,以重用代码。可以定义组件模 板,这是 Delphi 新近添加的功能,利用该方法可以很容易地在一个或多个组件中重用新的行为;而且能够 创建窗体或框架模板,这样就可以重用整个窗体或框架及其所包含的组件。 11.2 创建组件模板 首先将组件添加到数据模块或窗体,设置它们的特性,创建事件处理程序,并编写代码。然后选定一 个或多个组件,从 Component 菜单里选择 Create Component Template 菜单项。所有被选中的组件、事件处 理程序、以及相关的代码,都添加到了 VCL 面板的 Template 属性页上。选择该组件模板并将其拖动到任 意的窗体或数据模块上,即可重新创建包括代码在内的各个组件。将模板添加到窗体或数据模块后,可以 修改其位置、大小和特性等,就像是分别添加的一样。 当选择 Create Component Template 菜单项时,会显示 Component Template Information 对话框(见图 11.3)。点击 OK,则接受缺省的组件名、面板属性页以及组件图标,当然也可以修改这些信息。可以给 模板组件命名所喜欢的名字,将其放置在任何面板属性页上。如果输入的属性页名字不存在,Delphi 将创 建新的属性页。模板组件惟一的限制就是其图标必须是 24×24 像素位图,与其他组件的图标类似。 图 11.3 11.2.1 Component Template Information 对话框 定义组件模板 编写基本的组件相当容易。创建组件模板甚至更容易。定义组件模板的步骤与创建窗体基本相同。增 加组件,修改其特性,并编写事件特性。像其他单元一样,对窗体(或数据模块)进行单元测试。工作完 成后,就可以创建组件模板,选择组件,并将其添加到组件模板。 注意:Delphi 组件模板存储在 Delphi 的 Bin 目录下的二进制文件 Delphi.dct 中。Windows 错 误地把该文件和 FoxPro DataBase Container 文件类型关联在一起。 考虑第 3 章的 Edit 菜单。您可以花费一些时间来查找所有的 Windows 消息,并在应用程序中对 Edit 菜单编写 SendMessage 代码;而下一次在另一个程序里创建 Edit 菜单时,您还得做同样的事情。 从菜单资源模板重新创建菜单 您可以选择使用 Insert Template 对话框中的 Edit 菜单,如图 11.4 所示。使用 Menu Designer 上下文菜 单添加一个菜单的步骤如下: 第 11 章 图 11.4 用组件开发一致的界面 269 Insert Template 对话框可以从存储的 资源中重新创建菜单。但并不包括代码 1.从 VCL 的 Standard 属性页上选择 MainMenu 组件。 2.双击 MainMenu 图标向窗体添加菜单。 3.右击 MainMenu 组件,显示 TMainMenu 组件的组件编辑器菜单,并选择 Menu Designer 菜单项(如 图 11.5 所示)。这样可以打开菜单编辑器,如图 11.4 的背景所示。 4.右击鼠标,打开菜单编辑器的上下文菜单,并选择 Insert From Template 菜单项,如图 11.6 所示。 5.从 Insert Template 对话框中(如图 11.4 所示),双击 Edit 菜单模板,以添加编辑菜单。 这就是所有的工作。上述五个步骤将把 TMenuItem 组件添加到窗体类定义的开头。使用菜单资源模板 需要对 OnClick 事件方法重新编写代码。更好的方法是使用组件模板,这可以包括代码。 图 11.5 点击 TMainMenu 组件的组件编辑器菜单上的 Menu Designer 菜单项 图 11.6 菜单设计器的上下文菜单,对于管理菜单资源模板很方便 第 11 章 用组件开发一致的界面 270 创建并安装菜单组件模板 组件模板是 Delphi 新增的功能。使用菜单资源模板存储菜单必须重新编写代码,而使用组件模板则不 必如此。还使用上一节提到的 Edit 菜单,它是在第 3 章中实现的。我们现在把整个菜单和代码都保存为组 件模板。 1.对 Edit 菜单编写代码(使用第 3 章的例子,或步骤后列出的代码)使控件呈现正确的行为。 2.用代码测试 Edit 菜单后,选择包含 Edit 菜单的 TMainMenu 组件并点击 Component | Create Component Template 菜单项。 procedure TForm1.Edit1Click(Sender: TObject); begin // CanUndo test Undo1.Enabled := Boolean(SendMessage( Screen.ActiveControl.Handle, EM_CANUNDO, 0, 0 )); end; procedure TForm1.Copy1Click(Sender: TObject); begin // Copy menu SendMessage( Screen.ActiveControl.Handle, WM_COPY, 0, 0 ); end; procedure TForm1.Cut1Click(Sender: TObject); begin // Cut menu SendMessage( Screen.ActiveControl.Handle, WM_CUT, 0, 0 ); end; procedure TForm1.Paste1Click(Sender: TObject); begin // Paste menu SendMessage( Screen.ActiveControl.Handle, WM_PASTE, 0, 0 ); end; procedure TForm1.SelectAll1Click(Sender: TObject); begin // select all text SendMessage( Screen.ActiveControl.Handle, EM_SETSEL, 0, -1 ); end; procedure TForm1.Undo1Click(Sender: TObject); begin // Undo menu SendMessage( Screen.ActiveControl.Handle, WM_UNDO, 0, 0 ); end; 该代码与第 3 章中的类似。因此我们不再对细节进行重复。现在我们已经有了 Edit 菜单的组件模板, 每次需要菜单、相应的菜单项和代码时就可以使用该模板。 使用组件模板菜单 现在已经有了 Edit 菜单模板,它可以像其他的组件一样使用。要使用组件模板,从 Template 属性页 第 11 章 用组件开发一致的界面 271 上选择对应的模板,像其他菜单一样拖动到窗体上。菜单组件及其拥有的 TMenuItems、以前写过的代码 都可以添加到任何窗体上,而无需测试,也不会产生任何混乱的情况。 扩展组件模板 可以把组件模板看作组件类。当创建组件模板时(如本节的前半部分的 Edit 菜单),不要删除它,当 需要新的行动时,可以对该模板进行扩展。添加新的行为可以扩展已有的组件,最后我们有原来的和新的 组件模板。 假设现在有 Edit 菜单,要对其定义 Find 菜单项。使用组件面板的 Dialogs 属性页上的 TFindDialog 组 件。按照下列步骤,即可添加 Find 功能并创建新的组件模板。 1.把在本节开头保存的模板拖动到任意的窗体上。 2.从 Dialogs 属性页上拖动 TFindDialog 组件到同一个窗体。 3.在窗体上,点击 Edit,Find 菜单项,并添加代码 FindDialogl.Execute。 4.为 TFindDialog.OnFind 事件添加事件方法,并向事件处理程序添加一些代码以提醒用户实现查找 行为。 5.选择 TMainMenu 组件和 FindDialog 组件并单击 Component | Create Component Template 菜单项, 把合并的控件和代码添加到 template 属性页。TFindDialog 组件可能的代码如下。 procedure TForm1.Find1Click(Sender: TObject); begin FindDialog1.Execute; end; procedure TForm1.FindDialog1Find(Sender: TObject); begin MessageDlg( 'Implement find behavior!', mtInformation, [mbOK], 0 ); end; 警告:当存储模板组件时,确保使用惟一的名字。如果你使用已有的名字,那么 Delphi 将 提示你是否替换已有的模板,包括代码在内。 这就是所需要的工作。通过将代码分层添加到模板中,可以对完整的组件群体和提供功能的代码创建 精致的接口。 然而使用组件面板也有一些缺点。组件面板并非真正的组件;它们只是写入到二进制 文件 Delphi.dct 中的文本。它们提供了方便,但却放弃了灵活性。回忆对 SelectAll 菜单项的前一个实现。第 3 章开始部 分列出的代码中显示的 ToDo 表明,事件方法需要进行修改才能对其他类型控件做出合适的响应。在 SelectAll 当前的实现中,是无法响应 TComboBox 之类的控件的。不幸的是,如果你返回来完成 SelestAll 行为,可以创建新的模板。但这对包含菜单和 Find 对话框的复合模板没有影响。原来模板不会有什么改变, 衍生的模板将继续使用 SelectAll 的旧版本。 如果模板代码能够继承,而且可以自动更新有依赖关系的面板,那就太好了,但这并不是面向对象的 继承,而是一种新型的资源流化机制。如果需要继承,那就要创建新的类并进行子类化。但组件模板仍然 是一种流行的方法,只会变得更好而已。对于可视化的建立多数或全部组件这个目标来说,我们已经不远 了。 删除组件模板 Component Templates 包含在 Delphi.dct 文件中。该文件不像其他的组件库,它并非软件包的库文件。 因此无法像管理其他组件一样管理组件目标。为删除组件目标,需要在组件面板上找到对应的位置,并组 件面板上下文菜单中点击 Properties 菜单项。参考图 11.7,从右边的组件列表里选取要删除的模板组件, 然后点击 Delete 按钮。 第 11 章 图 11.7 用组件开发一致的界面 272 Palette Properties 对话框可用于删除组件模板或隐藏组件 提示:你不会意外地从图 11.7 所示的 Palette Properties 对话框中删除组件。如果选定了 某个组件,Add 按钮右侧将出现一个 Hide 按钮;而如果组件实际上是一个模板,那么同一 位置就出现 Delete 按钮。 组件模板不同于组件,使用图 11.7 所示的 Palette Properties 对话框只能隐藏组件。如果你删除组件模 板,也会删除所有与之相关联的代码。把组件模板中的代码保存到外部文件是个好主意,以防止意外删除。 关于组件模板的最后一个问题。组件模板方便且易于使用,但并非实际的组件。以 Edit 菜单为例, 模板比简单的资源菜单使用起来更加方便。虽然可以创建精巧的模板,但从长远看来,将复杂的代码和相 互交织的组件关系封装到类的话将更为可取。 11.3 窗体模板与窗体继承 与组件模板相比,窗体模板出现得更早一些。我们知道窗体是由 TComponent 子类化而来,但如果将 窗体直接安装到 VCL 中,可能会出现不正确的行为;因此在 Delphi 的早期版本里发明了窗体模板来解决 这个问题(更多的信息请参见第 10 章的对话框组件部分)。 很快发现,窗体一般带有许多代码,而且大多数应用程序里会重复出现许多类型的窗体,很显然的一 个例子就是 About 对话框。几乎所有应用程序都有该对话框。但只是意外 DFM 文件与 VCL 无法很好的 协作,就需要开发者为每个应用程序绘制一个 About 框并且添加必要的代码来显示该窗体吗?答案是,你 不必如此。最后,我们可以把窗体添加到存储库中,存储库中的窗体可以直接使用、继承和复制。 注意:在技术上,与窗体继承相关的最大的困难可能就是,如何使 DFM 流机制正确地工作。 为了深入了解 DFM 流化机制,看第 10 章的“对象流化与窗体继承”一节。窗体继承机制现 在已经工作得很好了。 对于开发者来说,所有这些意味着又添加了一个强有力的方法来重用整个程序。下面的方法都可以促 进代码与界面设计的重用,包括保存菜单资源,创建组件模板,将窗体添加到存储库并重用;当然最强大 的方法是创建新类。 11.3.1 创建窗体模板 窗体模板是保存并添加到存储库的窗体。在上一节中,我们还记得一个包含 TMainMenu 组件的窗体, 其中有编辑菜单,还包括查找功能;我们把窗体添加为组件模板。通过把窗体添加到存储库中,然后把组 件模板放置到窗体上,即可迅速地启动一个主窗体。 第 11 章 用组件开发一致的界面 273 向存储库添加窗体 为实验创建窗体模板的过程,我们将包含 TMainMenu 和 TFindDialog 的组件模板添加到空白窗体上(如 果你在上一节中没有创建组件模板而还想继续的话,只能先把 TMainMenu 和 TFindDialog 组件拖动到空白 窗体上)。按照下列步骤,可以将窗体添加到存储库(见图 11.8)。 图 11.8 MainForm 模板,其中包含 TMainMenu 和 TFindDialog 组件 1.右击要添加为模板的窗体,以显示窗体设计器上下文菜单。 2.选择 Add to Repository 菜单项。 3.填写 Add To Repository 对话框,需要为模板窗体提供标题和描述(见图 11.8),并选择要加入的 属性页,输入作者信息和图标。 4.点击 OK 按钮。如果尚未保存文件,在添加到存储库之前 Delphi 将提示你保存文件。 如果向 Add To Repository 对话框中的 Page 组合框文本域中输入并不存在的属性页名, Delphi 将创建 新的属性页。这是一个对窗体模板进行组织的好方法。 现在,无论何时需要带有主菜单的窗体,其中包括编辑和查找功能,只需选择 File,New,Other 菜单 项,然后从 New Items 对话框的 Forms 属性页上选择对应模板(见图 11.9)。 图 11.9 从 New Items 对话框选择与目标最为接近的窗体目标 存储库的维护 大多数人都有一个用于存放物品的地方。存储库就是个存放物品的地方。最后您可能需要对存储库中 的目标重新进行组织或删除某些模板。Tools 菜单有一个 Repository 菜单项,可以打开 Object Repository 第 11 章 用组件开发一致的界面 274 对话框(见图 11.10)。对话框的左侧列出了所有可以修改的 Repository 属性页,包括 Forms 属性页,其 中有用户定义窗体模板的。像 New 和 ActiveX 等属性页是不可修改的。 要增加、删除、重新命名符合条件的属性页,只需选择相应的属性页并点击 Add Page、Delete Page、 Rename Page 中某个合适的按钮。要从 Objects 列表中编辑或删除某个模板,在左侧选择包含该模板的页并 在右侧的 Objects 列表中点击相应的模板。例如要删除上一节定义的 FormMain 模板,首先点击左侧 Pages 列表框中的 Forms 项。所有的窗体模板将在右侧的 Objects 列表框中列出。找到 Main Form 模板,单击以 选取它并点击 Delete Object 按钮(如图 11.10 所示,选定了 Main Form 窗体)。 图 11.10 Object Repository 对话框 NEW FORM 也可以在 Object Repository 中选择缺省的 New Form 复选框,用于表示在 Delphi 中点 击 File | New | Form 菜单项时将创建哪个窗体。缺省情况下并未选定 New Form 复选框,但很容易就可以将 FormMain 作为默认的新窗体(选取窗体的指令,请参见前面的章节)。选取窗体后,选定图 11.10 所示的 New Form 复选框并点击 OK 按钮。 MAIN FORM 当创建新的可执行应用程序时——例如当 Delphi 启动时——缺省情况下主窗体是空 白窗体。另外,你还可以在 Delphi 中点击 Tools | Repository 菜单项打开 Object Repository 对话框。当存储 库被打开后,选取所需的新窗体作为主窗体并选定 Main Form 复选框(如图 11.10 所示)。这样,每次创 建新应用程序时,将使用该窗体作为缺省的主窗体。 向存储库添加工程 可以将整个的工程添加到存储库。完整的工程由工程中的 DPR 文件、所有的源代码、窗体和数据模 块组成。如果要把一个或多个窗体定义为工程模板,可以使用 Project 菜单将工程添加到存储库。要向存储 库添加工程,首先选择要添加工程,然后点击 Project | Add to Repository 菜单项。这时将显示 Add to Repository 对话框,它与窗体模板的情形类似(见图 11.8),由于将添加所有窗体,所以不需要选择添加 哪个窗体。 将工程添加到存储库后,点击 File | New | Other 菜单项,然后从 New Items 对话框中选择所需的工程, 即可基于已有的工程启动一个新的工程。可用于放置工程模板的好地方是 Projects 属性页,尽管在添加工 程时也可以创建新的属性页。 提示:如果要把缺省的工程从标准的可执行程序改变为存储库中的某个工程,那么可以从 Tools 菜单中打开对象存储库,并将相应的工程设置为新的缺省工程。选定一个工程后, Objects 列表框下将出现 New Project 复选框。选定该复选框,则对应的工程将成为新的缺 省工程。 当从 New Items 对话框中选择一个工程模板时,Delphi 将提示您为该工程输入路径,这时 Delphi 相应 第 11 章 用组件开发一致的界面 275 创建该工程中所有的文件。要避免选择创建工程模板时的原始路径;如果这样做你将覆盖原来的存储库文 件。也可以从 Object Repository 对话框中删除工程模板。细节请参考前面,标题为“存储库的维护”一节。 11.3.2 使用模板窗体 当使用模板创建新窗体时,有三种选择。可以在 New Items 对话框的 Forms 属性页上选择 Copy、Inherit 或 Use 三者之一。 缺省情况下,将选择 Copy。您可以得到窗体的完整副本,但不会维护模板窗体与新窗 体之间的进一步的关系(如果选择 Use,那么实际上将修改存储库中的窗体模板;即,任何改变都会反映 到窗体在存储库中的版本)。 如果选择 Inherit,将继承模板窗体,这里的继承指的是面向对象的意义。对窗体在存储库中版本的改 变将反映到子窗体。考虑到主窗体含有 Edit 菜单和 Find 对话框。如果使用某个主窗体模板创建新的窗体, 新窗体将子类化那个窗体。以后,再选择该主窗体模板以及 Use 选项,添加一个 Replace 对话框,则所有 的子窗体在下次编译时将自动具有 Replace 行为。 由 DFM 文件的内容可以看出原始窗体、副本窗体、以及使用模板窗体创建的子窗体之间的不同。下 面的片断来自三个独立的 DFM 文件,分别演示了基于 Use、Inherit、Copy 方式创建窗体时数据写入 DFM 文件的方式。这三个窗体都与窗体类 TFormMain 有关。 object FormMain: TFormMain Left = 435 Top = 254 Width = 418 Height = 320 Caption = 'Application Title Here' 上面列出的是实际的窗体。当在 New Items 对话框的 Forms 属性页中选择 Use 时,将得到模板窗体。 object FormMain1: TFormMain1 Left = 435 Top = 254 Width = 418 Height = 320 Caption = 'Application Title Here' 当选择 Copy 时,得到的是 TFormMain1,看上去像是 TFormMain 的子类,但实际上该窗体是从 TForm 子类化而来。所拥有的组件都被复制并流化到 DFM 文件中,但在模板和窗体副本之间不存在更进一步的 关系。 inherited FormMain4: TFormMain4 Caption = 'FormMain4' PixelsPerInch = 120 TextHeight = 16 end 上面列出的 DFM 文件是从 TFormMain 继承时创建的。与前面的两个 DFM 文件不同,第一个词不是 object,而是 inherited。这是个约定,用于表示应从祖先窗体读取额外的流化信息。 当要对所有的祖先都作出持久性的改变时,可修改原始窗体。若希望父窗体与子窗体之间的改变互不 影响,应使用复制;如果要对窗体的行为与外观在整个家族中定义并维护一个核心集合,则应使用继承。 组件模板、窗体模板、工程模板是三种极好的方法,可用于在一组应用程序中维护工程内和工程间的一致 性。一致性是连贯性的一个因素。即使应用程序提供了某些前所未见的功能,如果交互界面是一致的,那 么经过适当的学习之后新的任务看起来也会更加连贯。在应用程序被认为具有完备性之前,它首先必须是 一致和连贯的。另外,完备性还需要程序能够合乎用户的需求。 第 11 章 用组件开发一致的界面 11.4 276 静态与动态的组件用法 当把窗体添加到应用程序时,将把一行代码加入到 DPR 文件(工程源文件)中,以便在程序启动时 自动创建窗体。 Application.CreateForm(TForm1, Form1); 对于 Visual Basic 程序员或尚未完全掌握对象及动态对象创建的用法的新程序员来说,这可以使得添 加和使用窗体更为容易。对外行人来说,这实际上使得 Delphi 和 Visual Basic 看起来很相似:创建窗体、 运行程序、调用 show 或 show modal,然后用户就可以使用窗体了。当在窗体上绘制组件时,也是同样。 好像是有魔力一样,它们在运行时出现时,自然的就具有设计时的状态以及任何由自己编写的代码所定义 的行为。 Delphi 与 Visual Basic 之间的相似性在这里结束了。与 Visual Basic 相比,Object Pascal(Delphi 的语 言)与 C++更为相似。Delphi 像 Visual Basic 一样易于使用,并具有 C++的强大功能。这意味着新的程序 员也能立即开始,而经验丰富的老手则能够创建非常复杂的应用程序。 Delphi 自动创建窗体或在运行时自动创建组件时,其行为是一致的。而且,已经对流化机制编码,知 道如何解析组件引用、从 DFM 文件里读出属性、构造对象。当 Delphi 自动创建窗体和组件时,与手工使 用代码来创建的方式是一致的。 11.4.1 动态创建窗体 到现在为止,您可能已经熟悉如何绘制程序所拥有的窗体了。有些程序员允许自动创建所有的窗体, 但这会使可执行文件变得相当大并降低程序的启动速度。大多数用户通常只使用某些核心功能,从来或很 少使用其他功能。最终结果是用户为一些从不使用的功能付出了性能方面的代价。 好一些的方法是在需要的时候才创建窗体、数据模块以及加载库。这实际上是通过推迟堆内存的分配 以及窗体流数据的读取,以便降低程序启动时一次性创建窗体的成本,该技术有个名字,可以称之为惰性 实例化(lazy instancing)。 在 Project Options 对话框的 Forms 属性页中,将窗体和数据模块从 auto create 栏移动到 available 栏, 即可推迟窗体的创建。虽然在启动时性能有所提高,但以后在运行时动态创建窗体是要付出代价的;除非 该功能对时间要求非常高或经常使用,否则在需要时动态创建窗体比一次性创建所有的窗体更为可取。有 两种技术可用于创建惰性实例,分别讨论如下。 显式构造 显式构造是指调用窗体构造函数创建窗体,如何使用窗体,最后释放创建窗体所用的内存。 Form := TForm.Create(self); try if( Form.ShowModal = mrOK ) then // doe here finally Form.Free; end; 如果把窗体作为模式对话框来显示,该技术工作得很好,因为在调用 ShowModal 时代码的运行是同 步的。只要关闭窗体,内存也就被释放了。另外,您也可以显示窗体并让窗体在关闭时自行释放内存。 TForm.Create(Self).Show; 被创建的窗体需要一个 OnClose 事件处理程序,以便进行一些设置,这样当窗体关闭时它可以将自身 从内存中释放出去。 procedure TForm1.FormClose(Sender: TObject; var Action: TCloseAction); begin 第 11 章 用组件开发一致的界面 277 Action := caFree; end; 假设类为 TForm1,那么 OnClose 事件方法可以像上面这样编写。TCloseAction 对象 caFree 将释放分 配给窗体的内存。 惰性实例化 懒惰实例的关键在于,从外观看来对象仿佛已经存在了,但实际上仅在最后的可能时刻才创建对象。 考虑到窗体,这还是相对较为容易的。将 Delphi 自动添加的变量声明使用同名函数来替换,该函数返回窗 体的实例。该函数返回在堆上分配的窗体,当窗体关闭时将释放所分配的内存。调用者使用一个本地的窗 体变量,外部代码不能访问。如果窗体变量为空,将创建对象;总是返回对象的引用。 interface ... var Form1 : TForm1; implementation 使用函数来替换在所有新窗体中找到的上述代码片段。从外部看来,它们是相同的。 function Form2 : TForm2; implementation {$R *.DFM} var FForm : TForm2; function Form2 : TForm2; begin if( FForm = Nil ) then FForm := TForm2.Create(Application.MainForm); result := FForm; end; procedure TForm2.FormClose(Sender: TObject; var Action: TCloseAction); begin Action := caFree; FForm := Nil; end; initialization FForm := Nil; end. 接口部分的变量已经使用外观和行为都与变量类似的函数进行了替换。该函数使用本地变量 FForm。 如果 FForm 变量为空(在 initialization 部分已将其设置为空),那么将创建窗体。FormClose 事件方法将 Action 设置为 caFree,使得窗体可以被释放,而 FForm 引用将设置为 Nil。 代码比“显式构造”一节所示的简单的动态创建窗体要复杂一些,但使用该实例是非常容易的。 Form2.ShowModal; 或 Form2.Show; 第 11 章 用组件开发一致的界面 278 使用该技术有一个潜在的危险。如果使用 Form2 函数以外的其他变量引用创建了另一个窗体实例,那 么在 FormClose 事件中的 FForm := Nil 语句将导致分配给 FForm 的内存出现泄漏。为了避免这种情况,可 以修改 FormClose 事件方法,对 FForm 与 Self 进行比较。 if( FForm = Self ) then FForm := Nil; 现在 FForm 的引用不会被不注意地弄混淆了。 另外,也可以不定义任何窗体。下一节演示了一个完全动态的数据库窗体。 11.4.2 一个动态的数据库窗体 如果发现许多窗体之间的相似程度很高,那么可以动态地创建窗体以避免过度的资源冗余。实际上, 无需在设计时创建新窗体,也能够创建非常复杂的窗体。下面的代码示范了称为 TDBFormWizard 的组件。 这个组件实例化了一个窗体,其中包括一个 TDBNavigator 组件,一个 Close 按钮,并对数据集中的每个 TField 都使用一个 DBEdit 域来表示。 注意:在 Delphi,C++ Builder 和 Visual Basic 之前——在最近的十年里 ——大多数应用程序都必须用这种方法创建。新技术代表了一个向前的飞跃;但如果想要完 全动态的窗体还需要一些时间。 unit UDBFormWizard; // UDBFormWizard.pas - Creates a formless data edit form on the fly // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, DBCtrls, ExtCtrls, DB, DBGrids, DBTables; type TDBFormWizard = class(TComponent) private { Private declarations } Form : TForm; TopPanel, BottomPanel : TPanel; Navigator : TDBNavigator; ScrollBox : TScrollBox; CloseButton : TButton; FDataSet : TDataSet; FDataSource : TDataSource; FTitle : TCaption; Function LargestLabelWidth( const DataSet : TDataset ) : Integer; procedure CloseClick(Sender : TObject ); protected procedure Notification(AComponent : TComponent; Operation : TOperation ); override; procedure InitializeBasicForm; virtual; procedure AddFields( const DataSet : TDataSet ); virtual; procedure SetDataSource( const Value : TDataSet ); procedure SetTitle( const Value : TCaption ); 第 11 章 用组件开发一致的界面 public { Public declarations } function Execute : Boolean; published property Title : TCaption read FTitle write SetTitle; property DataSet : TDataSet read FDataSet write FDataSet; property DataSource : TDataSource read FDataSource write FDataSource; end; procedure Register; implementation { TDBFormWizard } function TDBFormWizard.Execute : Boolean; begin Form := TForm.Create(Screen.ActiveForm); try Form.Caption := FTitle; Form.SetBounds(382, 223, 487, 386 ); AddFields( DataSet ); result := Form.ShowModal = mrOK; finally Form.Free; end; end; procedure TDBFormWizard.InitializeBasicForm; begin TopPanel := TPanel.Create(Form); with TopPanel do begin Name := 'TopPanel'; Caption := EmptyStr; Parent := Form; Align := alTop; Width := Form.ClientWidth; Height := 50; end; CloseButton := TButton.Create(TopPanel); with CloseButton do begin Parent := TopPanel; Caption := '&Close'; SetBounds( TopPanel.Width - Width - 20, TopPanel.Height - Height - 20, Width, Height); Anchors := [akRight, akBottom]; OnClick := CloseClick; Name := 'ButtonClose'; end; Navigator := TDbNavigator.Create(TopPanel); 279 第 11 章 用组件开发一致的界面 with Navigator do begin Name := 'Navigator'; Parent := TopPanel; SetBounds( 10, 10, Width, Height ); DataSource := FDataSource; ShowHint := True; end; BottomPanel := TPanel.Create(Form); with BottomPanel do begin Name := 'BottomPanel'; Caption := EmptyStr; Parent := Form; Align := alClient; BevelInner := bvLowered; BorderWidth := 4; TabOrder := 1; end; ScrollBox := TScrollBox.Create(BottomPanel); with ScrollBox do begin Name := 'ScrollBox'; Parent := BottomPanel; Align := alClient; AutoScroll := True; BorderStyle := bsNone; end; end; procedure TDBFormWizard.AddFields( const DataSet : TDataSet ); var LabelWidth : Integer; ALabel : TLabel; DBEdit : TDbEdit; I : Integer; begin FDataSet := DataSet; SetDataSource( FDataSet ); InitializeBasicForm; // in characters LabelWidth := LargestLabelWidth( FDataSet ); for I := 0 to FDataSet.FieldCount - 1 do begin ALabel := TLabel.Create(ScrollBox); ALabel.Parent := ScrollBox; ALabel.SetBounds( 10, 10 + (I*26), ALabel.Width, ALabel.Height ); ALabel.Caption := FDataSet.Fields[I].DisplayLabel +':'; ALabel.Width := LabelWidth; ALabel.Alignment := taRightJustify; 280 第 11 章 用组件开发一致的界面 DBEdit := TDBEdit.Create(ScrollBox); DBEdit.Parent := ScrollBox; DBEdit.SetBounds( ALabel.Width + 14, 6 + (26 * I), DBEdit.Width, DBEdit.Height); DBEdit.DataSource := FDatasource; DBEdit.DataField := FDataSet.Fields[I].FieldName; // Used M arbitrarily because I liked the result DBEdit.Width := FDataSet.Fields[I].DisplayWidth * Form.Canvas.TextWidth( 'M' ); DBEdit.ReadOnly := FDataSet.Fields[I].ReadOnly; end; end; procedure TDBFormWizard.CloseClick(Sender: TObject); begin Form.Close; end; function TDBFormWizard.LargestLabelWidth(const DataSet: TDataset): Integer; var I : Integer; TextMetrics : TTextMetric; begin result := 0; for I := 0 to DataSet.FieldCount - 1 do if( Length(Dataset.Fields[I].DisplayLabel) > result ) then result := Length(Dataset.Fields[I].DisplayLabel); if( GetTextMetrics( Form.Canvas.Handle, TextMetrics )) then result := (TextMetrics.tmAveCharWidth + TextMetrics.tmMaxCharWidth) div 2 * result else result := 120; end; procedure TDBFormWizard.SetDataSource(const Value: TDataSet); begin FDatasource := TDataSource.Create(Form); FDataSource.DataSet := Value; end; procedure TDBFormWizard.SetTitle(const Value: TCaption); begin FTitle := Value; end; procedure TDBFormWizard.Notification(AComponent: TComponent; Operation: TOperation); begin inherited; if( Operation = opRemove ) then if( AComponent = FDataSet ) then 281 第 11 章 用组件开发一致的界面 282 FDataset := Nil else if( AComponent = FDataSource ) then FDataSource := Nil; end; procedure Register; begin RegisterComponents( 'PKTools', [TDBFormWizard] ); end; end. 对话框组件并未增加应用程序的大小,这是因为并没有额外的资源文件(DFM)而且所有的组件都是动 态创建的。窗体和组件是在 Execute 方法中使用 DataSet 和 DataSource 特性创建所有的数据库组件时生成 的。基本的窗体在 InitializeBasicForm 方法里创建的。图 11.11 显示了将 DataSet 设置为 DBDEMOS 中的 biolife 表时所创建的组件。 图 11.11 由 TDBFormWizard 生成的窗体 虽然窗体不具有什么创造力,它完全有能力管理一个数据集。将一个查询赋值给 DataSet 特性,窗体 将生成一个只读的多表窗体。 对这个组件进行一些修改,很容易使得生成的窗体更为灵活。例如:可以 允许用户在动态数据库控件中进行选择,如使用 TDBGrid,或者,可能 AddFields 可以使用每个 TField 的 DataType 特性来确定创建哪种数据库控件。 TDBFormWizard 的一个很好的用法是,它提供了数据集的按需编辑能力,而无需在设计时为每个可能 的数据集创建一个静态的 TForm。这有助于提供更多的灵活性而不需要大量额外的编码。 11.5 所有者绘图组件 扩展控件的外观可以提供定制的功能。有 Windows 句柄或能够接收 WM_PAINT 消息的可视化组件都 是可以子类化的,可以定义 Paint 方法来创建调整好的可视效果。但有几个控件已经预定义了相应的特性 和事件方法,可用于定制其外观。例如:TListView 有一个布尔值特性 OwnerDraw。如果 OwnerDraw 为 True,将调用 OnAdvancedCustomDraw、OnAdvancedCustomDrawItem、OnAdvancedDrawCustomDrawSubItem 和 OnDrawItem 事件方法,使得可以定制组件的绘制方法。 下面的示例代码演示了一个扩展的 TStringGrid 组件,该组件使用可视化效果来表示被选中的栏目;你 可能会使用该效果来表示 TDBGrid 或 TStringGrid 中的排序栏。 TExStringGrid = class(TStringGrid) private 第 11 章 用组件开发一致的界面 283 { Private declarations } FColumnIndex : Integer; procedure DrawFixedColumnCell( ACol, ARow : Integer ); protected { Protected declarations } procedure MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Integer); override; procedure DrawCell(ACol, ARow: Longint; ARect: TRect; AState: TGridDrawState); override; procedure Paint; override; public property ColumnIndex : Integer read FColumnIndex; end; TExStringGrid 组件由 TStringGrid 子类化而来。它重载了 MouseDown、DrawCell、Paint 方法,并引入 了私有方法 DrawFixedColumn,以及字段 FColumnIndex。DrawFixedColumn 方法对选中的栏目进行特定的 绘制,该栏目是由 FColumnIndex 表示的。 代码是通过使得某个固定行上被选取的栏目单元失效而实现的。 代码的可视化效果请参考图 11.12。 图 11.12 TExStringGrid 创建一个可视化的效果,表示被选取的栏目 procedure TExStringGrid.DrawFixedColumnCell( ACol, ARow : Integer ); var Rect : TRect; begin if( not Ctl3D ) then exit; Rect := CellRect(ACol, ARow); DrawEdge(Canvas.Handle, Rect, EDGE_SUNKEN, BF_TOPLEFT); end; procedure TExStringGrid.DrawCell(ACol, ARow: Integer; ARect: TRect; AState: TGridDrawState); begin inherited; if( gdFixed in AState ) and (ARow < FixedRows) and (ACol = FColumnIndex) then DrawFixedColumnCell( FColumnIndex, ARow ); end; procedure TExStringGrid.MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Integer); var ACol, ARow : Integer; OldColumnIndex : Integer; 第 11 章 用组件开发一致的界面 284 begin inherited; MouseToCell( X, Y, ACol, ARow ); if ( ARow >= FixedRows ) or ( ARow < 0 ) then exit; OldColumnIndex := FColumnIndex; FColumnIndex := ACol; InvalidateCell( OldColumnIndex, ARow ); InvalidateCell( FColumnIndex, ARow ); end; procedure TExStringGrid.Paint; begin inherited; DrawFixedColumnCell( FColumnIndex, 0 ); end; DrawEdge 使用了 user32.dll 中的一个 API 函数,该函数在 Windows.pas 中声明,用于创建可视化效果。 DrawCell 调用了从 TStringGrid 继承的 DrawCell 方法的缺省实现;此外,如果相应的栏目位于 ColumnIndex 列以及固定的行,那么将绘制特别的效果来表示栏目已经被选取。MouseDown 过程跟踪被选取的栏目单 元,当选取新的栏目时,该过程使得旧的和新的被选取的栏目单元都失效,这样将可以对其重新进行绘制。 Paint 方法确保每次网格重新绘制时都创建所需的可视化效果。 11.5.1 定制网格绘制 像本章开头所说明的,可以使用特性和事件来为组件创建定制的可视化效果。本节开头使用了 TExStringGrid 子类来演示栏目被选取时的效果。可以将同样的代码放置到字符串网格组件的事件中,以 创建相似的可视化结果。 procedure TForm1.StringGrid1MouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); var ACol, ARow : Integer; begin StringGrid1.MouseToCell(X, Y, ACol, ARow); if ( ARow >= StringGrid1.FixedRows ) or ( ARow < 0 ) then exit; FColumnIndex := ACol; StringGrid1.Invalidate; end; procedure TForm1.StringGrid1DrawCell(Sender: TObject; ACol, ARow: Integer; Rect: TRect; State: TGridDrawState); begin if( gdFixed in State ) and (ARow < StringGrid1.FixedRows) and (ACol = FColumnIndex) and (StringGrid1.Ctl3D) then begin Rect := StringGrid1.CellRect(ACol, ARow); DrawEdge(StringGrid1.Canvas.Handle, Rect, EDGE_SUNKEN, BF_TOPLEFT); end; end; 当鼠标单击 TStringGrid 时,MouseDown 事件方法将存储栏目索引并使得网格失效。Invalidate 确保网 格被重新绘制。DrawCell 事件方法确保单元是固定的,而且行和列都对应于固定栏目单元,并且使用与 DrawEdge 同样的 API 方法来创建可视化效果。 第 11 章 用组件开发一致的界面 285 剩下的惟一问题就是,究竟是子类化字符串网格组件,还是使用 TStringGrid 的事件方法较为合适。如 果你正在创建新组件的原型,使用具有事件方法的已有组件较为容易一些。那么,当组件完成后,您可能 需要子类化该组件并添加新的行为。从语义上看来,上面显示的效果属于新的网格组件的行为。另外,如 果组件并未暴露创建效果所需的必要方法,但却暴露了事件方法,那就别无选择。 当窗体中充满了其他类的行为时,代码看起来常常是混乱的。最好的习惯是,将对象的行为封装到显 示该行为的对象的类定义中。 11.5.2 所有者绘图 TMainMenu 组件 有时候,预测定制绘图可能会比较困难。例如: 字符串外观能预期并跟踪需要绘制哪个栏目单元;因 此子类化字符串网格组件是可能的。另外,我们来考虑 TMainMenu 组件。我们使用帮助文档中的提议, 假设要使用颜色编码的菜单,它显示颜色而不是文本,或同时显示颜色和文本。在设计时我们不可能知道 需要哪些菜单项;这使得子类化 TMainMenu 非常困难。同样,因为 TMenuItem 是由 TMainMenu 使用的, TMenuItem 的子类将被 TMainMenu 忽略。在这种环境下,不得不使用特性和事件来创建效果。 使用颜色进行编码的菜单 下列步骤摘自 TMenu.OwnerDraw 帮助文档中的建议,说明了如何使用颜色编码的菜单项来对菜单项 的绘图进行定制。 1.创建新的应用程序。 2.在主窗体上画一个 TMainMenu 组件。 3.添加一个名为 Color 的菜单项以及一个名为 BackGround 的子菜单。 4.向 BackGround 添加三个子菜单,名字分别是 Gray,White,Aqua(见图 11.13)。 5.向这三个使用颜色命名的菜单项的 Tag 特性添加 TColor 值。这些颜色定义在 graphics.pas 单元中, 分别是 clGray =TColor($808080),clAqua = TColor($FFFF00),以及 clWhite =TColor($FFFFFF);直 接将颜色的十六进制的数字值从 graphics.pas 单元复制并粘贴到每个菜单项的 Tag 特性中。 6.在 Object Inspector 里,将 TMainMenu 的 OwnerDraw 特性设置为 True。 7.创建 OnClick 和 OnDrawItem 事件方法,并将每个颜色菜单项的 OnClick 和 OnDrawItem 事件特性 都分派到单个事件方法(例如:Gray1.OnClick、White1.OnClick、Aqua1.OnClick 都指向同一 OnClick 方法;OnDrawItem 特性也是同样)。 8.用下面的示例代码定义两个事件方法(在示例代码中,首先对 Gray 菜单项进行处理,因此命名如 下)。 procedure TForm1.Gray1DrawItem(Sender: TObject; ACanvas: TCanvas; ARect: TRect; Selected: Boolean); begin ACanvas.Brush.Color := TMenuItem(Sender).Tag; ACanvas.FillRect( ARect ); end; procedure TForm1.Gray1Click(Sender: TObject); begin Color := TMenuItem(Sender).Tag; end; DrawItem 事件程序给制表符值设置刷子色彩(回忆色彩值被分配给 Tag 特性)。当单击菜单项时, 表格的背景被改变成那种色彩(见图 11.13)。 使用颜色进行编码的菜单和文本 使用文本和颜色编码值的代码更加复杂。要显示文本并重新设置画刷需要一些必要的改变,代码如下 列出。该代码使用了新的 TBrushReCall 类,并将 TMenuItem 类型的 Sender 对象强制转换为其平凡子类 TDummyMenuItem,以便调用 TMenuItem 中保护权限的绘图方法来绘制标签。 第 11 章 用组件开发一致的界面 图 11.13 在使用颜色进行编码的菜单项中,使用 286 了 OwnerDraw 特性和 DrawItem 事件 type TDummyMenuItem = class(TMenuItem); procedure TForm1.Gray1DrawItem(Sender: TObject; ACanvas: TCanvas; ARect: TRect; Selected: Boolean); var Recall : TBrushRecall; CopyRect : TRect; begin Recall := TBrushRecall.Create(ACanvas.Brush); try ACanvas.Brush.Color := TMenuItem(Sender).Tag; CopyRect := ARect; ARect.Right := 20; ACanvas.FillRect( ARect ); ACanvas.Brush.Assign( Recall.Reference ); finally Recall.Free; end; CopyRect.Left := 22; with TDummyMenuItem(Sender) do DoDrawText( ACanvas, Caption, CopyRect, Selected, 0); end; procedure TForm1.Gray1MeasureItem(Sender: TObject; ACanvas: TCanvas; var Width, Height: Integer); begin Width := Width + 20; end; 声明 TDummyTMenuItem 就是为了可以访问 TMenuItem 中保护权限的方法(过一会儿我们继续讨论) 。 新的 DrawItem 事件处理程序创建一个 TBrushRecall 对象。在显示颜色块之后,Brush 刚好在 Finally 块之 前被恢复,而 TBrushRecall 对象则在 finally 块中被释放。TRect 记录的副本被保存到 CopyRect,这里调整 了 CopyRect 的 Left 字段以适应颜色矩形所需的空间。 注意:可以将子类转换为超类,反之则不然。如果 B 定义为 A 的子类,那么一个 B 类的对象 也是 A 类的对象,但一个 A 类的对象并非 B 类的对象。这样(B As A)是有效的,但(A As B) 是无效的。把对象向子类型转换是非法的。 这里使用保护权限的方法 DoDrawText,以避免复制 TMenuItem 的代码。由于 Sender 是 TMenuItem 类型,需要强制将 Sender 转换为 TDummyMenuItem 对象。实际上 Sender 是一个 TMenuItem 祖先对象,因 此使用 as 操作(Sender As TDummyMenuItem)是无效的。最后定义了 OnMeasureItem 事件处理程序,用来 第 11 章 用组件开发一致的界面 287 把每个用颜色编码的菜单宽度增加 20 个像素,以便为颜色块矩形提供额外的空间。 11.6 小 结 第 11 章示范了组件编写的技术,说明了如何创建组件、窗体和工程模板。模板可以重用代码和可视 化的设计工作,它也提供了一个方便的方法,可用来直接启动一个一致的应用程序。应用程序内部的一致 性使应用程序看上去更加专业和连贯,也更容易使用。要达到完备性、一致性和连贯性是必须的成分。不 一致的应用程序可能难于使用而且其行为毫无规律;也就是,它们的行为可能是不连贯的。 本章也示范了如何创建动态窗体,这样可以避免创建静态的、非常冗余的窗体,并使得应用程序的内 存占用较为合理。将模板、动态窗体、定制组件以及增强的组件绘制方法联合起来使用,您的应用程序就 可以给用户提供专业化而与众不同的体验。 第 12 章 使用 Microsoft 自动化组件 在 Servers 页面上的组件是 TOleServer 的子类。类型库(.TLB)被引入到 Delphi 中。一个类型库是一 种描述一个自动化服务器接口的特殊文件。当一个 TLB 被引入到 Delphi 的时候,Delphi 将这个类型库封 装到一个组件类中;这个组件可以被安装到 VCL 中。在组件面板中 Servers 属性页上的组件是自动化服务 器,每个自动化服务器代表 Microsoft Office 中一个特定的应用程序部分。 注意:本章包括一个摘自 Evil Empire 详细材料的讨论。建议你学习这门技术。如果您是一 个反 Microsoft 的成员,您可以跳过本章,但是要记住 Inprise 为我们提供代表这些应用程 序服务器的组件有它足够的理由。如果要使用 Delphi 控制 Microsoft Office 可以帮助您的 话,考虑使用这些组件。 自动化是 COM 协议的一部分。自动化描述了服务器应用程序怎样将接口提供给客户应用程序以及客 户端怎样通过编程控制服务器。客户应用程序被叫做自动化控制器。自动化控制器可以是使用任何支持自 动化的语言编写的应用程序或者动态链接库。您可以很容易地在 Delphi 中创建客户端控制器和自动化服务 器。 本章介绍了怎样使用当今世界上一些最具有特色的对象——Microsoft Office 应用程序。绝大多数,可 能是全部的 Office 都可以用作自动化服务器。这意味着它们可以被用作独立的应用程序或者应用程序服务 器。使用这些功能强大的应用程序服务器可以使开发者能够提供功能强大的文字处理、数据库、数字处理 和关系管理的能力,这取代了许多用户已经购买的一些现有的代码。 12.1 TOleServer TOleServer 是 TComponent 的子类。OleServer 对象具有 TComponent 的所有特性和方法,并且这些特 性和方法都在 TOleServer 类中被定义了。Servers 属性页上的组件是 Microsoft Offiec 自动化服务器, TOleServer 是这些组件的直接祖先类,所以很好的理解 ToleServer 可以使您有一个更高的起点。表 12.1 列 出了 OleServer 中所有的特性,表 12.2 列出了 OleServer 中所有的方法。除了 OleServer 特性,TOleServer 的每一个子类将引入在类型库中定义的行为和数据。我们将在本章的后面部分作详细的介绍。 第 12 章 使用 Microsoft 自动化组件 295 表 12.1 TOleServer 特性,其中 AutoConnect,ConnectKind 和 RemoteMachineName 是由 TOleServer 引入的公开特性 特性 说明 AutoConnect 如果这个特性为 True,服务器在运行时连接。如果在运行时将其特性改为 False 将不会在运行时产生影响 由 TConnectKind 枚举定义,描述服务器怎样被连接。比如说,ckRunningOrNew ConnectKind 将连接到服务器的一个运行实例或者开始一个新的实例。可以选择的项为: ckRunningOrNew , ckNewInstance, ckRunningInstance, ckRemote , ckAttach ToInterface EventDispatch 保护特性,可以被为 COM 事件特性服务的子孙类所使用 RemoteMachineName 指定运行服务器的机器名,将 ConnectKind 特性设置为 ckRemote,您将连接到 其他独立计算机的服务器上 保护的记录特性,它保存了有关连接到的自动化服务器的信息 ServerData 下面用表 12.1 的特性作一个示范,遵循下面所列的步骤,将连接到您的网络中其他计算机上的一个 Microsoft Word 实例(需要远程计算机的名称,并且远程计算机必须有一个 Microsoft Word 的一个拷贝。 在“控制面板”中的“网络”小应用程序中的“标识”属性页中的“计算机名”域中可以找到计算机名称) 。 1.创建一个新的 Delphi 应用程序。 2.在窗体上的任何位置放置一个 TButton 组件。 3.在窗体上放置一个 TWordDocument 组件。 4.在 Object Inspector 中(可以按 F11 打开),将 WordDocument 组件的 Remote MachineName 特性设 置为安装有 Microsoft Word 的计算机名称(在“控制面板”中的“网络”小应用程序中的“标识” 属性页中的“计算机名”域中可以找到计算机名称或者你也可以使用机器中相应于 RemoteMachineName 的 IP 地址)。 5.第 4 步将 ConnectKind 特性改变为 ckRemote。 6.双击第 2 步中窗体上的按钮,为按钮添加 Click 事件处理程序。 7.将下面的代码添加到第 6 步创建的事件处理程序中。 procedure TForm1.Button1Click(Sender: TObject); var FileName : OleVariant; begin WordDocument1.Connect; try WordDocument1.Content.Text := 'Viva Las Vegas!'; FileName := 'c:\temp\vegas.doc'; WordDocument1.SaveAs(FileName); finally WordDocument1.Disconnect; end; end; 注意: 您不能够使远程计算机上的 Word 服务器实例在远程计算机上可视。但您可以使 Word 服 务器实例在运行客户端控制器的计算机上可视。 WordDocument 已经被连接了。如果您和远程的计算机很近,当加载 Word 实例的时候您将看到硬盘指 示灯会短暂地亮一下。WordDocument 的 Content 特性代表了所有文档的内容;text 特性是显示在文档体上 的实际文本。SaveAs 方法必须有一个 OleVariant 类型的变量。确保正确的路径。最后断开与 Word 文档的 连接。TWordDocument 是 TOleServer 的子孙类。在上面的程序段中,只从 TOleServer 继承了 Connect 和 Disconnect 两个方法。Content 特性和 SaveAs 方法是由 TWordDocument 组件引入的方法。请参考表 12.2 第 12 章 使用 Microsoft 自动化组件 296 所示 TOleServer 的方法。 提示:leading_in 接口方法(如_AddRef 方法)被自动加入,以使它们与其他的方法区别开。 在一般情况下没有必要调用这些方法。 每一个继承 TOleServer 的类都具有表 12.1 和表 12.2 中列出的特性和方法。所有在组件面板的 Server 属性页上的自动化服务器在它们的直接祖先中有 TComponent 和 TOleServer。 表 12.2 TOleServer 的方法:MSOffice 自动化服务器继承了这些方法 方法 说明 _AddRef 保护方法,用于增加引用服务器对象的引用计数 _Release 减少引用计数的保护方法,如果引用计数等于 0,那么对象将从内存中被释放 Connect 虚抽象方法,子孙类使用这个方法连接到服务器,如果 ConnectKind 设置为 ckAttachToInterface,那么子孙类将使用 ConnectTo 方法实现 ConnectEvents 保护方法,用于在内部实现 COM 事件处理程序 Create 构造一服务器类的实例 Destroy 解除一服务器类的实例 Disconnnect 虚抽象方法,子孙类用于终止与服务器的连接 DisconnectEvents 终止由 ConnectEvents 方法创建的连接 GetAutoConnect 保护方法,用于读取 AutoConnect 特性 GetConnectKind 保护方法,用于读取 ConnectKind 特性 GetServer 保护方法,返回与服务器的一个接口 InitServerData 保护方法,用于初始化 ServerData 特性 InvokeEvent 为 COM 对象发送一个事件到正确的事件处理程序 Loaded 当 从 DFM 文 件 中 读 取 stream-published 特 性 的 时 候 调 用 Loaded ; 如 果 AutoConnect 设置为 True,Loaded 被重载并连接到服务器 QueryInterface 返回与指定接口的引用(如果服务器支持这个接口) SetAutoConnect 保护方法,用于设置 AutoConnect 特性 SetConnectKind 保护方法,用于设置 ConnectKind 特性 第 12 章 使用 Microsoft 自动化组件 12.2 297 Microsoft 自动化服务器 代码再使用是在 COM 和 DCOM(分布式 COM)的后台被激活的,动态链接库是 COM 的祖先。想想, COM 的创造者——Microsoft,会在它的一些应用程序中包含 COM 接口,这一点也不奇怪。对于 Microsoft 和我们来说,COM 是一种获取应用程序间相互操作的手段。许多 Microsoft 的应用程序是自动化服务器, 包括 Access, Schedule+,Word,Excel,PowerPoint,PhotoDraw,Outlook,FrontPage,MS-Project 和 Visual Source Safe。这意味着您可以再使用这些特色丰富的应用程序作为您的应用程序的服务器。 有许多不同的方法用于自动化服务器,本节将介绍其中一些服务器。 12.2.1 自动化服务器组件概览 从 Delphi 应用程序中使用自动化服务器最简单的方法是在专业版和企业版的 Servers 属性页中选择你 所感兴趣的组件。因为这些服务器的库被引入到 Delphi 中,并被组件化了,所以它们都具有 TOleObject 类接口。它们同样具有在特定的服务器的 COM 接口中暴露的方法、特性和事件,这里的特定的服务器根 据服务器的需要来指定。表 12.3 列出了所有当前可用的服务器组件。 注意:Delphi 类的结构(被指定为一个接口)和在.TLB(类型库)文件中描述的 COM 接口在语 义上很相似,但语法不同。本章我们将全部使用 Delphi 代码访问自动化服务器,因此现在 很有必要首先讨论 COM 接口。 表 12.3 Microsoft 自动化服务器在 VCL 库中集成为组件 组件 服务器说明 TWordDocument Microsoft Word 的文档接口。包含了 Content 接口,Content 接口包含在文档 中实际显示的文本 TWordFont Font 接口使您可以操纵字体 TWordParagraphFormat Paragraph 接口简化了控制段落缩进、段落终止和段落级别上的文本控制 TWordLetterContent LetterContent 接口支持控制容器、主题、拷贝和其他文字创建任务 TWordApplication 这个 Application 接口为 Word 自动化服务器自身 TExcelQueryTable QueryTable 接口允许从外部的 Recordset 中重新获取数据(Recordset 是一个 ADO 对象,类似于 Delphi 中的 TDataSet) TExcelApplication 这个 Application 接口表示 Excel 自动化服务器 TExcelChart Chart 接口支持绘图 TExcelWorksheet Worksheet 接口表示一个基于单元的电子表格 TExcelWorkbook Workbook 接口表示包含文件所有工作表的视图 TExcelOleObject OleObject 为在工作表中与一个嵌入的 Ole 对象的接口 TPowerPointSlide Slide 接口表示在演示中的单幻灯片 TPowerPointPresentation 与 Powerpoint 演示的一个接口;接口是 Presentation (续表) 组件 服务器说明 TPowerPointApplication Power Point 自动化服务器 TMaster Master 接口表示 PowerPoint 幻灯片控制器,它控制演示时如背景、颜色布 局和文本风格的特性 TBinder Binder 接口(只在 Office 97 上可用)支持从完全不同的 Office 应用程序中 添加文档到统一的文档中 TFormatCondition 有条件地格式化 Access 中组合框和文本框中的文本 TAccessHyperlink 在 Access 中的超链接接口 TAccessForm Access 具有设计图形用户接口的能力;这个类表示 Form 接口 第 12 章 使用 Microsoft 自动化组件 298 TAccessReport Access Report 接口 TAccessApplication Access 应用程序自动化服务器接口 TAccessReferences 在 Access 引用中,TAccessReferences 引用 Access 应用程序所引用的外部库。 这个引用集合包括了所有使用中的库引用的列表。比如说,Access 中的 Visual Basic Editor 就有 msado15.dll(支持 ADO 对象)的引用 TDataAccessPage 数据激活网页接口,DataAccessPage 引用 Access 创建的外部网页 TAllForms AllForms 集合接口包括所有在 Access 数据库中的窗体 TAllReports AllReports 集合接口包括了所有 Access 数据库中的报表 TAllMacros AllMacros 接口包括了所有数据库中的宏 TAllModules AllModules 返回所有 Access 数据库中的 Visual Basic 模块(.BAS)和(.CLS) 文件 TAllDataAccessPages 所有在 Access 数据库中的数据访问页 TAllTables 所有在 Access 数据库中的表 TAllQueries AllQueries 是与每个数据库所有查询的集合的一个接口 TAllViews Access 2000 工程支持视图和存储过程,AllViews 是与 Access 2000 工程中的 所有视图的一个接口,并使用 SQL Server 数据库 TAllStoredProcedures 在 Access 工程中的所有存储过程 TAllDatabaseDiagrams Access 2000 工程使用 SQL Server 数据库,并支持 ERDs(实体关系图表) 的创建。这个集合是 Access 2000 工程中与所有 ERDs 的接口 TCurrentProject CurrentProject 接口引用 Access 自身的运行实例 TCurrentData 此 接 口 包 含 与 Access 2000 工 程 中 所 有 集 合 的 一 个 引 用 ( 比 如 说 , AllDatabaseDiagrams,AllStoredProcedures 等) TCodeProject 此接口引用在 Access 数据库(.MDB)和工程(.ADP)中包含代码的对象 TWizHook 紧随 Answer Wizard TDefaultWebOptions 与包含超级链接颜色编码的 DefaultWebOptions(默认 Web 选项)的一个接 口 TAccessWebOptions Access 的 Web 选项,覆盖 DefaultWebOptions TClass_ 实现 Visual Basic 类模块的基本接口,包括初始化和终止的事件处理程序 提示:服务器组件的源代码可以在您所安装 Delphi 的 OCX\Server 目录中找到。 Servers 属性页上的 VCL 组件的使用要比使用 CreateOleObject(请参考下一节)创建自动化服务器简 单得多。首先,它们的类型库已经被导入了,并且已经在 TOleServer 子类中创建了 Delphi 接口。这使您可 以更一致地使用 Object Pascal,而不必涉及到用 Windows 使用这些服务器。再者,当您使用一个特定类型 的服务器对象的时候,完整代码特性将自动为您显示特性、方法和事件。如果您使用变体数据类型创建这 个 COM 服务器,则需要有服务器特性的一个备用引用。 本章示范了一些使用自动化服务器组件的例子。但是,如果组件不存在,您仍然可以使用自动化服务 器;不过您需要使用一个更低级的提取。比如说,Visual SourceSafe 不出现在 VCL 面板上,但是它是一个 自动化服务器,您可以给这个服务器定义一个客户端控制器。 12.2.2 导入类型库 如果一个自动化服务器已经存在,但是没有为这个服务器定义组件,那么您可以通过导入这个类型库 创建这个组件。一个比较好的例子是 Visual SourceSafe。SourceSafe 是一个来自 Microsoft 的源控制应用程 序,它同 Visual Studio 和 Microsoft Office Development 工具一起出售。您可以使用 SourceSafe 自动化服务 器自动存档数据,将数据保护能力加入到您的应用程序中。 导入类型库 当您为一个自动化服务器导入类型库的时候,Delphi 将这个类型库封装进子孙组件包装器(TOleServer 的子类,在本章的前面部分已经作了介绍)中。这个组件包装器自动包含所必须的 Register 过程,将组件 第 12 章 使用 Microsoft 自动化组件 299 安装到 VCL 中。要导入一个类型库,选择 Project 菜单中的 Import Type Library。在 Import Type Library 对 话框中显示了有效的类型库列表,如图 12.1 所示。 Import Type Library 对话框上面的列表框列出了所有已经注册的类型库(图 12.1 显示出 SourceSafe 服 务器已经在 PC 机上注册了,我们将用这个类型库作示范)。单击 Create Unit(创建单元)按钮创建类型库 单元。如果这个单元已经存在,那么对话框提示是否覆盖这个已经存在的类型库单元。如果您选择了“Yes”, 那么将创建新的单元,选择了“No”则打开这个已经存在的单元。 查看 SourceSafe 单元;Register 过程列在单元的后面部分,如下所示。 procedure Register; begin RegisterComponents(‘ActiveX’,[TVSSDatabase]); end; 您可以注册组件(参考下一部分)或者通过编程动态创建服务器的实例。在下一部分介绍安装组件, 在此之后的 CreateOleObject 部分示范了在组件没有安装的情况下使用这个组件的例子。 图 12.1 Import Type Library 为从已经注册的 COM 对象创建类型库 提供了方便,(本图示出了被选择的 Visual SourceSafe 类型库) 注册自动化服务器 当一个类型库被导入时,它被包装进一个 TOleServer 组件包装器中。这时,您可以使用 Component 菜单像安装其他组件一样安装类型库。假设您已经为 Visual SourceSafe 组件创建了一个类型库,或者已经 打开了已经存在的类型库,则完成下面的步骤来安装这个组件。 注意:您需要在您的 PC 机上有一份 Visual SourceSafe 6.0 的拷贝,以执行下面的步骤来 安装 SourceSafe 组件。 1.将 SourceSafe 的类型库调到 Delphi 的前台,选择 Component,然后选择 Install Component。 2.选择 New Package 属性页,将组件安装到一个新的包中。 3.确保单元文件名指向 SourceSafe 类型库;文件名应该类似于 SourceSafeTypeLib _TLB.pas。 4.选择一个包文件名(如果您选择了 Into new package 属性页,如图 12.2 所示,那么请输入新的包名 和路径)。 5.输入简短的有关包的说明,如图 12.2 所示。 第 12 章 使用 Microsoft 自动化组件 300 6.单击 OK 按钮,组件单元将被编译;包库将被链接并且安装到 VCL 组件面板中。 图 12.2 Install Component 对话框,包含安装一个 SourceSafe 组件的信息 回想一下前面部分所列的 Register 过程,输入一个类型库,TVSSDatabase 组件将被安装到组件面板上 的 ActiveX 属性页中。要改变目标属性页,将文本“ActiveX”改为“Servers”,这将安装这个服务器到 Servers 属性页中。 您已经创建了组件,您可以像使用其他自动化服务器组件一样使用这个组件。它具有与任何 TOleServer 一样的基本接口,以及 SourceSafe 所特有的附加特性。 例子程序有一个菜单(有 File,Import File 菜单项)、一个列表框和一个 Memo,例子程序用 SourceSafe 数据库的文件填充列表框。当您在列表框中选择一个文件,然后单击 File,Import File 的时候,处理 Import File 的 OnClick 事件方法从 SourceSafe 获得一个文件的拷贝,然后将这个文件加载到 Memo 控件中。下面 的程序说明了上面步骤 1~6 中已经创建的 TVSSDatabase 组件的使用。 unit USourceSafeDemo; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, comobj, OleServer, access2000, SourceSafeTypeLib_TLB, Menus; type TForm1 = class(TForm) Memo1: TMemo; MainMenu1: TMainMenu; File1: TMenuItem; ImportFile1: TMenuItem; ListBox1: TListBox; procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); procedure ImportFile1Click(Sender: TObject); private { Private declarations } procedure PopulateList; public { Public declarations } end; var Form1: TForm1; 第 12 章 使用 Microsoft 自动化组件 301 implementation {$R *.DFM} procedure TForm1.FormCreate(Sender: TObject); const DatabaseName = 'xxxxxxxxxxxxxxx\srcsafe.ini'; UserName = 'xxxxxxx'; Password = 'xxxxxxxx'; begin VSSDatabase1.Connect; VSSDatabase1.Open( DatabaseName, UserName, Password ); PopulateList; end; procedure TForm1.FormDestroy(Sender: TObject); begin VSSDatabase1.Disconnect; end; procedure TForm1.PopulateList; const ProjectPath = '/xxxxxxxx xxx/xxxxxx'; var I : Integer; begin VSSDatabase1.CurrentProject := ProjectPath; with VSSDatabase1.VSSItem[VSSDatabase1.CurrentProject, False].Items[False] do for I := 1 to Count do ListBox1.Items.Add( Item[I].Name ); end; procedure TForm1.ImportFile1Click(Sender: TObject); const Path = 'c:\temp\'; var FileName, Target, Source: WideString; begin try FileName := ListBox1.Items[ListBox1.ItemIndex]; Target := Path + FileName; Source := VSSDatabase1.CurrentProject + '/' + FileName; VSSDatabase1.VSSItem[ Source, False ].Get( Target, VSSFLAG_USERROYES ); Memo1.Lines.LoadFromFile( Output ); except on E : EListError do ShowException( E, Addr(E)); end; end; end. 警告:这个应用程序的例子需要有 Visual SourceSafe 6.0、一个实际的 SourceSafe 数据库 第 12 章 使用 Microsoft 自动化组件 302 (数据库里包含文件),并且 c:\temp 目录必须存在,否则这个程序将不能工作。 FormCreate 事件处理程序连接到服务器,并以特定的用户和密码打开 SourceSafe 数据库(现有的路径、 用户名和密码被注销,因为例子测试时与现有使用中的数据库冲突)。记住在 DatabaseName 的后面添加 srcsafe.ini 文件,调用 PopulateList 函数用工程中的文件名填充列表框(如图 12.3 所示)。析构函数断开与 自动化服务器的连接。 图 12.3 存储在 SourceSafe 中的文件列表 在 Items 集合中的所有条款中都重复使用了 PopulateList 方法。with 子句在 PopulateList 中,其后面的 VSSData-base1.VSSItem[VSSDatabase1.CurrentProject, False].Items[False]分解如下。VSSDatabase1 是引用自 动化服务器的组件。VSSItem 数组由工程名字(一个工程在 SourceSafe 中看起来就像一个目录结构)索引, 第二个参数为 True 或者 False,False 表示被删除的文件也被包括进来。Items 特性引用工程和包含在 VSSDatabase1.CurrentProject 中的文件,并且 False 值表示被删除的项不被包括进来(这有点冗长) 。 当 ImportFile OnClick 事件处理程序被调用的时候, 被选择的文件用于索引 VSSItem,并且调用 VSSItem 的 Get 方法使这个文件只读——VSSFLAG_USERROYES 设置文件的只读特性。这个文件从 SourceSafe 被 写到一个外部文件,由 Memo1 的 Lines 特性描述的 TString 对象从外部文件加载源代码。 使用这个代码,如本部分开始的时候所介绍的,您可以很容易地在您所能创建的任何软件中加入版本 控制,最好复习一下这部分内容,以更好地理解。作如下思考,将 TVSSDatabase 组件包装到一个类中, 以整理为获取一个文件列表或者执行检查所带来的繁琐的步骤。 下面部分示范在没有类型库或者合适的组件包装器的时候怎样创建自动化实例。 12.2.3 CreateOleObject CreateOleObject 在 comobj.pas 中作了定义。这是通过调用 ole32.dll 库中的 CoCreateInstance 过程实现 的。当您使用 CreateOleObject 创建自动化服务器的时候,需要类的名称并且需要指定一个变体数据类型的 返回值,虽然您还可以使用在服务器中定义的接口,但是您需要知道什么接口。Delphi 的代码完整性不能 辅助您有关接口的信息。所以在任何可能的情况下使用组件包装器。 procedure TForm1.PopulateList; var I : Integer; VSS : Variant; Begin VSS := CreateOleObject('SourceSafe'); try VSS.Open( 'xxxxxxxxxxx xxx\srcsafe.ini', 'xxxxxxx', 'xxxxxxxx'); 第 12 章 使用 Microsoft 自动化组件 303 VSS.CurrentProject := '/xxxxxxxx xxx/xxxxxx'; for I := 1 to VSS.VSSItem[VSS.CurrentProject].Items.Count do ListBox1.Items.Add( VSS.VSSItem[VSS.CurrentProject].Items.Item[I].Name ); finally VSS := varNull; end; end; 前面所列的程序中再次使用了 PopulateList,该例子示范了怎样创建 SourceSafe 自动化服务器的实例, PopulateList 打开了数据库、设置当前的工程、并填充列表框,全部都在一个函数中完成。相对而言,代码 看起来不像本章早些时候的组件化版本那么复杂。可能最大的不同之处是,前面的版本允许代码完整,在 需要正确地获得特性和方法的时候可以提供更多帮助。 12.2.4 CreateRemoteComObject CreateRemoteComObject 在 comobj.pas 中作了定义。这通过调用 ole32.dll 中定义的 CoCreateInstanceEx 函数实现。如果您想查看这个函数,您可以看到许多附加的工作隐藏在 CreateRemoteComObject 中。 这个函数创建一个自动化服务器的实例,如果服务器组件可用的话使用一个服务器组件会更加容易, 但是这不免是另外一种方法。下一个例子同本章开始的时候举的例子一样,惟一的不同之处是本例使用 CreateRemoteComObject 函数运行 Word 实例,而不是使用 TWordDocument 服务器组件。 var Document : OleVariant; begin Document := CreateRemoteComObject('PTK300', StringToGuid('Word.Document')) As WordDocument; Document.Content.Text := 'Viva Las Vegas!'; Document.SaveAs('c:\temp\viva_vegas.doc'); end; CreateRemoteComObject 返回一个 IUnknown 接口类型;As WordDocument 子句,看起来像一个动态抛 掷,实际上在这个上下文中是引导编译器调用 IUnknown 的 QueryInterface 方法以确定这个 COM 对象是否 支持这个接口文档。在这个例子里,COM 对象支持接口文档。Word 应用程序运行在远程计算机上,其 Content.Text 特性指定为“Viva Las Vegas!”,文档被保存到文件中。 本章的剩下部分介绍怎样使用已经存在的组件。我们将在附录 C 中再次讨论怎样创建自动化服务器。 12.3 Access 作为一个 Delphi 开发者,通常您可能反对使用 Microsoft 工具。很明显,尽管如此 Microsoft 的 Access 可以用来创建桌面数据库应用程序,它担任了数据库的角色。同时应该注意 Access 是一个完整的开发工具, 包括一个基于对象的语言和 Visual Basic for Applications。并且 Access 是一个自动化服务器。 可能会出现这种情况,在 Access 中已经实现了全部代码,或者在 Access 中有一些方便的工具,但却 可能在 Delphi 中重新使用会很耗时并且不方便,其中一个例子就是 Get External Data 特性。Access 有一个 内建的解析引擎用于读取和解析固定的或者是定界的文本文件,并将它们导入到数据库中。如果有一个处 理过程需要读取文本文件到数据库中,为什么不使用 Access 作为服务器来为您完成这个处理呢?其结果将 会是更有力地和更方便地实现这个过程,而没有必要从头开始凑合代码。 12.3.1 用 Access 解析固定长度的数据 看起来似乎在身边就有很巨大数目的定长数据。比如说,NSCC(国家证券交易所)保存了大约 8500 个 美 国 的 证 券 代 理 商 的 商 业 数 据 。 NSCC 是 一 个 票 据 交 换 所 , 从 事 两 种 主 要 的 股 票 交 易 和 OTC 第 12 章 使用 Microsoft 自动化组件 304 (over-the-counter)证券市场。每天会有成千上万的商业认证在 NSCC 处理。代理商必须使用 NSCC 商业 认证数据为他们已经注册的代表处理委托。NSCC 是一个很好的例子,大银行、保险公司、代理商以及许 多其他组织也是在规定的原则基础上处理固定格式的数据。 注意:类似 IBM 和 Microsoft 这样的公司从事交易协议,这将使这些数据直接跨渡客户端服 务器应用程序。您将发现您必须处理固定格式的文本数据,除非这些协议在主干流中。 任何开发者可能都有过编写一个解析算法的想法,解析固定格式数据,将其转换为更可用的格式。即 便这样说,像类似于在第 3 章中介绍的 TParser 类的类,都只使解析固定格式数据的工作变得更简单一些 (只是相对于从头开始编写程序来说),但实际上并不简单。要实现最少数目的步骤必须考虑使用完整的 解决方案。请进入 Microsoft Access。 定义导入规范 Microsoft Access 有一个功能强大的解析引擎,这使您可以可视地解析样本数据文件。任何您所定义的 可视解析规则可以被保存为导入规范。以后固定格式的文件可以快速地被导入,重复使用任何已经保存的 导入规范。因为 Access 通过 Application.DoCmd 接口暴露这个特性,您可以控制递归解析任务的导入。 注意:本部分的例子是用 Access 2000 实现的,这需要 Access 2000 的一个拷贝来完成这个 例子。 导入规范可以根据需要变得很简单或者很复杂。Access 的使用没有限制,由于本书的篇幅原因和为讲 解更清楚的原因,这个例子文件很简单。为了介绍,假设您有一个固定长度文件(包含一个日期,格式为 yyyymmdd)、一个基于 9 位数社会保险账号号码和一个没有小数的不定长度的交易数量。下面是一个文件 的例子,例子中显示了列标题值(标题不包括在实际的数据文件中)。 日期 账号 交易 2000021255512456713056 20000301555236789145607 2000041766656123456 逻辑字段和值的界限如表 12.4 所示。 表 12.4 包含日期、账号号码和交易数量的固定长度文件 日期 账号 交易 20000212 555124567 13056 20000301 555236789 145607 20000417 666561234 56 第一行包含了列标题,第四行包含值 56,代表交易的数量是 56 分。要在 Access 中定义一个导入规范, 请完成下面所示的步骤。 1.使用前面表 12.4 创建固定长度文件,不要使用列标题。文件命名为 anydata.txt,并保存到 c:\temp 目录中。 2.运行 Microsoft Access 2000。 3.当 Access 打开的时候,提示您打开一个已经存在的数据库。在上面标有“Create a new database using” 的组合框中,选择“Blank Access Database” (如图 12.4 所示)左边的单选按钮。选择一个数据库名 称并定位。记住选择的数据库名称和位置。在本例中,用 c:\temp\anydata.mdb。 第 12 章 使用 Microsoft 自动化组件 图 12.4 305 创建新的空 Access 数据库 4.在 Access 中,依次单击 File | Get External Data | Import。在导入对话框中,改变文件的类型字段为 文本文件。选择 c:\temp\anydata.txt 文件,单击 Import 按钮。将弹出 Import Text(导入文本)向导 对话框,如图 12.5 所示。 5.单击 Next 按钮。在列表框的上面,在标记下面,从左边数起,在 8 和 17 的位置处单击创建垂直 线断点(如图 12.5 所示) 。 6.单击 Advanced 按钮。此按钮将打开 Import Specification(导入规范)对话框,如图 12.6 所示。 7.在打开的对话框中,改变 File Format(文件格式)为 Fixed Width(固定长度)。改变日期顺序为 YDM,选择 Dates 复选框中的 Four Digit Years 和 Leading Zeros (图 12.6 为完成导入规范的示意图)。 图 12.5 Access Import Text 向导 第 12 章 图 12.6 使用 Microsoft 自动化组件 306 使用 Import Specification 对话框定义和保存一个导入 规范,该导入规范被 Access 和自动化控制器重新使用 8.单击 Save As 按钮保存导入规范为“Any-Data Import Specification”。您必须记住这个导入规范的 名称,因为这将被自动化控制器用以运行导入。 9.单击 OK 按钮关闭 Import Specification 对话框。 10.单击 Finish 按钮接受后面的默认值。 使用三行测试数据,完成上面所示的步骤。Access 将创建一个名为 AnyData 的表(为被导入文件的文 件名部分),添加一个主键字段。Access 将自动为主键字段提供一个惟一的关键字,在默认情况下命名为 ID。 测试导入规范 在任何时候您所获得四位数据只要和已经存在的导入规范匹配,您就可以重新使用这个匹配的规范导 入并很快解析添加的数据。使用导入规范,您可以编写一个自动化控制器,它可以自动运行这个处理过程。 下面的代码使用这个导入规范和前面部分所用的样本文本文件,示范了用 Access 应用程序作为自动化 服务器来导入数据。 const DatabaseName = 'c:\temp\anydata.mdb'; SpecificationName = 'AnyData Import Specification'; FileName = 'c:\temp\anydata.txt'; TableName = 'AnyData'; procedure TForm1.ImportTextData1Click(Sender: TObject); begin AccessApplication1.Connect; try AccessApplication1.OpenCurrentDatabase( DatabaseName, False ); AccessApplication1.DoCmd.TransferText( acImportFixed, SpecificationName, TableName, FileName, False, '', 0); AccessApplication1.CloseCurrentDatabase; finally AccessApplication1.Disconnect; end; end; 第 12 章 使用 Microsoft 自动化组件 307 TAccessApplication 组 件 从 组 件 面 板 的 Servers 属 性 页 中 添 加 进 来 。 将 ConnectKindr 修 改 为 ckNewInstance 。 如 果 您 使 用 默 认 值 ckRunningOrNew , 那 么 如 果 数 据 库 已 经 被 打 开 的 话 AccessApplication1.OpenCurrentDatabase 将引发一个异常。常量 acImportFixed 已经在 Access2000.pas 中作 了定义;当您添加 TAccessApplication Server 组件的时候,Access2000.pas 将被添加进来。SpecificationName 是在导入规范中定义的名称。TableName 可以是一个新的或者已经存在的表名称。DatabaseName 参数是 Access MDB 数据库文件,文本文件是您所想导入的任何文件。 查看数据 您可以在 Delphi 中添加一个 ODBC 别名简化对表的使用。在 Windows 控制面板中使用 ODBC 小应用 程序来创建一个别名。Data Access 和 Data Control 组件可以被用来创建浏览器,用于查看被导入的数据(参 考第 13 章开始的一个例子,这个例子示范了怎样创建一个数据表浏览器)。 12.4 小 结 自动化控制器所发挥的作用可以和被使用的自动化服务器所发挥的作用一样。可以控制 Access 和 Word 以用于数据库和 Word 处理任务,以及在您的应用程序中并入版本控制能力。您可以直接从 Outlook 中读取相关的数据、控制 PowerPoint 演示或者更多。使用自动化服务器可能完成的事务是巨大的。这些应 用程序的复杂程度是难以想象的,可以这么说,要详细地介绍与使用自动化服务器有关的任何一个应用程 序都需要几百页的篇幅。安装一个自动化服务器、运行这个服务器和获取服务器对象是第一步,接下来需 要做的就是学习使用那些接口了。 在本章中,您体验到了 COM 自动化的重要部分。您学习了怎样创建一个自动化服务器的实例、怎样 导入类型库和怎样编写自动化控制器。还学习了在组件类(TOleServer 的子类)中使用 Delphi 包装 COM 对象。首先,创建组件简化了使用自动化服务器,这是最直接的方法。请参考附录 C,那里有创建自身的 自动化服务器应用程序的例子。 第 13 章 使用 Data Access 组件 不管是 Delphi 的专业版或者是企业版,都带有两个组件的属性页,用于辅助创建数据库应用程序。 Data Access 的属性页包含了连接组件,为连接到各种各样的数据源提供了方便。Data Controls 的属性页包 含了几个可视的控件,用于创建数据感知的图形用户接口。专业版和企业版的 Delphi 包含了几个附加的控 件,使您可以使用 ADO(ActiveX 数据对象)、Interbase 特定组件(用于协同 Inprise 的数据库服务器 Interbase 一起工作)、MIDAS 组件(用于分布式客户端/服务器开发)、Decision Cube 组件和 DBExpress(上面的每 一组组件将在本章的后面作介绍)。 注意:第 13 章经常使用 Word 组件和控件。一个控件是一个组件,但是一个组件不是一个控 件。组件和控件的不同之处是控件有一个 WndProc、一个 Windows 处理程序,并且控件有可 视的方面。而组件可以有一个或者更多的 WndProc 和 Windows 处理程序,比如说 TApplication 是一个组件,该组件有一个 Windows 处理程序和一个 WndProc,但是它没有任何可视的方面。 基本的数据控件使创建数据感知窗体变得扑朔迷离,从而影响到桌面或者企业级的数据库应用程序的 开发。目前超过两门的学派研究数据库应用程序怎样被创建。其中有两个截然不同的学派是:第一个学派 是使用两层的 RAD(快速应用程序开发)方法的开发者;第二个学派认为 RAD 不好,他们倾向于多层的 方法。两层应用程序一般指的是一种这样的数据库应用程序:其图形用户接口直接连接到数据库。通常,直 接在窗体上放置数据控件和 data access 组件就可以创建两层的应用程序。控件连接到数据库的右端。通常 三层应用程序意味着至少有一层将数据库和图形用户接口分离开来。 在三层应用程序中,窗体通常包含一个限定数量的交互逻辑,其主要的功能是将数据传输给用户或者 从用户接受数据。中间层通常包含交互控制对象、数据库连接对象和从图形用户接口接受数据或者向图形 用户接口传输数据的对象。不管是两层的还是三层的应用程序都是开发应用程序的有效形式。 两层的应用程序接口通常是由数据的需要来驱动的,它们被叫做数据库的合成物,使用起来非常简单, 可能最适用于小应用程序和桌面应用程序。两层应用程序最适合实用应用程序,它只有一个预定目标平台 (如 Windows)并且其预算很少。虽然有可能设计并实现很出色的两层应用程序,但是它们仍然不大可能 成为独立的平台。 三层应用程序通常被认为更强大、更具伸缩性,其实现要有更大的代价。首先,三层应用程序需要考 虑更多的构思;如果这一步做得很差应用程序将失去伸缩性,可能运行起来会很慢并且创建起来会很昂贵。 一个实现得很差的三层应用程序可能会和一个弱的两层应用程序一样差或者更差。 成功的关键是设计观念化。一个好的设计师和一支具有献身精神的程序员如果很好地协作将会产生最 好的效果。因为数据控件直接支持两层形式的开发,数据控件的介绍更缺乏,所以本章将介绍在两层应用 程序中使用 data access 组件和数据控件。记住这相对于其他形式来说并不是很好。一个更可取的方法是考 虑预算、目标平台、应用程序复杂性、用户通信和将来难以预料的变化,这要专门雇佣一个设计师来设计 (第 15 章介绍了怎样创建中间层产品和怎样使用 MIDAS 进行分布式计算)。 13.1 ODBC(开放式数据库连接) ODBC 是 20 世纪 90 年代流行起来的,它为应用程序创建一个同数据库连接的协议。它是一个 API(应 用程序接口)定义。每一个厂商都可以创建 DLL 来实现同等的并与 ODBC API 兼容的 API 过程;特殊厂 商的 API 提供一种兼容的方法,通过此方法开发者可以用程序同这个厂商的数据库引擎通信。 比如说,Microsoft Access 和 Oracle 已经在 1996 年为它们各自动数据库实现了 ODBC 驱动程序,但 是 DB2 没有。因此连接到一个 Access 或者 Oracle 数据库的方法非常简单,但是连接到 DB2 数据库需要使 用用 C 编写的存储过程和来自 IBM 的专有连接。 第 13 章 使用 Data Access 组件 312 ODBC 背后的目标是将应用程序写到 ODBC API 中,开发者改变数据库引擎而不用修改代码。这个特 性和灵活性仍然是使用 ODBC 的一个原因。 13.1.1 创建 ODBC 别名 Microsoft 在 20 世纪 90 年代初(参看图 13.1)将 ODBC 数据源管理器捆绑到 Windows 中。ODBC 管 理器保存了与 ODBC 驱动程序相关的名称、特定的数据库文件和特殊数据库引擎需要的任何其他信息。当 在程序中用到被创建的名称的时候,相应的 ODBC DLL 和相应类型的数据库将被用到。 图 13.1 ODBC 数据源管理器(从控制面板的 ODBC 小应用程序中打开) 下面的例子使用 Paradox 表,您不必用 Paradox 的拷贝来创建 Paradox 数据库。 1 . 打 开 控 制 面 板 , 双 击 Data Sources 小 应 用 程 序 运 行 ODBC 数 据 源 管 理 器 ( 程 序 在 c:\winnt\system32\odbcad32.exe,在这里 c:\winnt 是您安装 Windows 的目录) 。 2.给您的用户配置文件(您登录后的 Windows 配置信息)创建 ODBC 别名,选择“User DSN”属性页。 要给所有的用户创建别名,选择“System DSN”属性页(任何一个属性页都用于我们特定的目的,但 是如果您将您的工作站同其他人共享的话,他们也可以使用您创建的别名) 。 3.单击 Add 按钮开始创建别名(如图 13.1 所示) ,这一步将弹出一个 Create New Data Source 对话框 向导。 4.Create New Data Source 对话框是为 Office 2000 修订的,但是它将默认安装您已经安装的所有驱动 程序,每一个驱动程序代表一个不同的数据库和版本。找到并选择 Microsoft Paradox 驱动程序。 5.单击 Finish 按钮。这一步将打开对应于您所选择的数据库的 ODBC 安装向导。简单的数据库引擎 如 Paradox 将打开一个类似于如图 13.2 所示的对话框,对话框的形式要依赖于您所使用的 ODBC 管理器的版本,复杂的数据库引擎将打开一系列复杂的对话框,用于复杂的设置,比如说 Microsoft 的 SQL Server。 6.对于本例,在 Data Source Name 文本框中输入 Test(如图 13.2 所示)。 图 13.2 ODBC 安装对话框,显示了桌面数据库引擎 第 13 章 使用 Data Access 组件 313 7.取消 Use Current Directory 复选框,单击 Select Directory 按钮(如图 13.2 所示)。 8.在打开的对话框中导航到<Delphi>\Borland Shared\Data 目录,在这个目录里已经安装了演示版的数 据库。其中“<Delphi>”表示您所安装 Delphi 的位置。 9.单击 OK 按钮。 10.在 ODBC 数据源管理器中确认一个新的别名 Test 已经列在 User Data Sources 或者 System Data Source 的列表框中了,别名在哪个列表框中要依赖于您是在 User DSN 还是在 System DSN 属性页 中添加数据源的。 11.单击 OK 按钮关闭 ODBC 管理器。 注意:Paradox 表保存在分开的文件中,当您指向一个 Paradox 数据库的时候,实际上指向 的是一个目录,表文件就存储在这个目录里。 现在当您指向 Test 别名的时候,Delphi 会知道使用 Microsoft 的 Paradox ODBC 驱动程序,并知道表存 储在<Delphi>\Borland Shared\Data 目录中。 13.1.2 改变 ODBC 别名的配置 如果您的数据库被移动了或者您需要修改这个数据库(已经有一个别名指向这个数据库)的配置,或 者您由于某种原因需要升级一个 ODBC 别名,那么使用控制面板中的 ODBC Data Source 小应用程序。运 行 ODBC 小应用程序,找到您要修改的别名,单击 Configure 按钮。做一些适当的修改,然后单击 OK 按 钮保存所做的修改。这为在测试数据库和产品数据库之间进行切换提供一种便利的方法,比如说,当开发 一个应用程序的时候。 13.1.3 测试连接 使用 SQL Explorer 测试一个 ODBC 连接。SQL Explorer 允许您查看一个 ODBC 连接和一个别名指向 的表(它还有许多其他的特性,这将在本章后面介绍 SQL Explorer 和 Monitor 的时候作详细的讨论)。运行 SQL Explorer 的方法有:单击 Delphi 中的 Database 中的 Explore 菜单,或者从 Delphi 的程序组中运行,通 过依次选择 Start、Program Files、Borland Delphi 和 SQL Explorer 运行此程序。SQL Explorer 如图 13.3 所 示。 图 13.3 SQL Explorer 界面(图中打开了前面创建的数据库别名所指向的一个数据库) 使用前面的任何一种方法,打开 SQL Explorer。如图 13.3 所示,单击 Databases 属性页。Databases 属 性页列出了所有存储在 SQL Explorer 中的数据库别名。找到别名 Test 并单击别名名称旁边的符号“+”。 当提示您输入密码的时候,单击 OK 按钮,这里不需要密码。单击 Tables 项旁边的符号“+”展开表的列 表。单击“Biolife”表。在 Explorer 的右边将出现一个 Data 属性页。单击 Data 属性页,您将看到类似于 图 13.3 所示的数据。 提示:您也可以用 SQL Explorer 作为一个 SQL 语句的测试平台。 第 13 章 使用 Data Access 组件 314 您也可以在 Enter SQL 属性页中的编辑域中输入 SQL(结构化查询语言,一种数据库编程语言)语句。 单击发亮的图标运行查询(在 Data Access 组件介绍 SQL 的时候将介绍基本的 SQL 语言)。 13.2 Borland 数据库引擎 BDE(Borland 数据库引擎)是一个 API,它为 Inprise 应用程序(包括 Delphi)提供一个本地数据库 支持。Borland 数据库管理器提供 BDE 的配置管理。BDE 管理器在 Control Panels 小应用程序中,它使您 可以为被支持的数据库和 ODBC 别名指定一个别名,此别名使用本地 BDE 数据库驱动程序。当您在应用 程序中包括 BDE 单元的时候,您可以使用本地 C/C++类型的 API 调用来直接管理数据库。 BDE 是和 Data Access 组件完全分开的。您完全可以不用本地 BDE 数据库 API 调用来创建所有的应用 程序。通常,在优化性能中所能获得的好处是可维护性和强大的功能。来自 TDataSet 的子类是 TBDEDataSet 类,它封装了 BDE 的功能。如果您想使用 BDE,那么继承类 TBDEDataSet 或者 TDBDataSet。通常 BDEDataSet 对象是不进行初始化的, 但是提供的行为可以通过 TTable, TQuery 和 TStoredProcedure 访问(想 了解更多的信息请参考 Data Access 组件,那里有 BDE 行为的介绍)。 13.3 数据库窗体向导 数据库窗体向导使您可以创建一个低级的数据库应用程序,此应用程序示范了一个最小的两层数据库 应用程序,也是最本质的部分。虽然这个例子很普通,但是其实用性是很强的。 提示:软件的收缩性不是很好。使用数据库窗体向导创建的数据感知窗体当超过 50 个这样 的窗体添加进来的时候,它就不能成为一个复杂的、面向对象系统的一部分了。软件的复杂 性随着关系的数目和多样性成幂指数增长。可选地,创建三个或四个数据库窗体是一种创建 低成本程序的方法,这种方法作用很大而不昂贵,也不会超过开发者的能力范围。 遵循下面的步骤创建一个单一表的数据库程序,根据您的爱好,用任何随数据库一起创建的表替换这 个已经命名的表。这个示例表包含了 Lansing Capitol City Renegades(一个小部门的曲棍球队)的比赛统计。 表 PLAYER_STATISTICS 是用 SQL Server 2000 设计器创建的。下面的脚本是从这个设计器导出的, 并以.SQL 的扩展名保存为文本文件(想更多的了解 SQL 请参考 Data Access 组件中的 SQL 部分)。 if exists (select * from dbo.sysobjects where id = object_id(N'[dbo].[PLAYER_STATISTICS]') and OBJECTPROPERTY(id, N'IsUserTable') = 1) drop table [dbo].[PLAYER_STATISTICS] GO CREATE TABLE [dbo].[PLAYER_STATISTICS] ( [ID] [int] NOT NULL , [PLAYER_NAME] [varchar] (25) COLLATE SQL_Latin1_General_CP1_CI_AS NULL , [NUMBER_OF_GAMES] [int] NULL , [GOALS] [int] NULL , [ASSISTS] [int] NULL , [POINTS] [int] NULL , [PENALTY_MINUTES] [int] NULL ) ON [PRIMARY] GO 注意:通常,SQL 命令和表名称用大写字母。上面所列的代码是 ANSI-92 SQL 和 Microsoft 的 T-SQL(针对 SQL Server 2000)语言的混合体。 第 13 章 使用 Data Access 组件 315 简而言之,SQL Server 2000 生成的脚本检查表 PLAYER_STATISTICS 是否存在。如果这个表已经存在, 那么执行 DROP TABLE 命令。最后运行 CREATE TABLE 命令生成这个表。 第一步创建一个 ODBC 别名,此别名指向本例中您所选择的数据库。从下一步开始,需要一个指向 Renegades 数据库的别名(以您的数据库名代替 Renegades 数据库别名)。数据库窗体向导忽略 BDE 别名, 所以您需要获取一个 BDE 别名的表或者当您创建 ODBC 别名的时候创建一个 BDE 别名。可以把 BDE 想 象为一个封装 OBDC 的层,用于便利 Inprise 的开发工具(包括 Delphi) 。下一部分介绍怎样创建一个 BDE 别名,之后介绍怎样使用窗体向导。 13.3.1 使用 SQL Explorer 创建 BDE 别名 您可以使用 Delphi 中的 Database 菜单中的 Explore 菜单创建 BDE 别名。如果您有 Delphi 的标准版本, 这个菜单项将打开 Database Explorer;如果是专业版或者企业版将打开 SQL Explorer。这个两个程序都可 以用来管理 BDE 别名(SQL Explorer 如图 13.4 所示)。使用本部分开始的 Renegades SQL Server 数据库, 遵循下面的步骤创建这个 BDE 别名(记住如果您正为其他数据库创建别名请替换您的数据库信息)。 1.参照图 13.4,单击 Object 中的 New 菜单项,打开 New Database Alias 对话框。 2.在对话框中选择您要创建别名的数据库的驱动程序。在 Renegades 的例子中,选择 SQL Server Database Driver,然后单击 OK 按钮(结果如图 13.4 所示)。 3.在右边的 Definition 属性页中(参照图 13.4),找到 ODBC DSN 项,在其右边的单元格中输入 Renegades(您的数据库别名)。 4.单击默认的名称 ODBC1,然后单击 Object 中的 Rename 菜单项,将 ODBC1 重新命名为 Renegades (您的别名名称)。 5.单击 Object 中的 Apply 菜单项应用所做的修改。 6.单击 Object 中的 Open 菜单项,验证可以使用刚才创建的别名打开数据库。 7.当您确保别名配置正确的情况下(您可以打开数据库),单击 Object 中的 Close 菜单项关闭数据库。 图 13.4 Delphi 专业版和企业版中的 SQL Explorer 用于管理 BDE 别名。标准版本 的 Delphi 使用一个相似的程序——Database Explorer,其功能是一样的 ODBC 和 BDE 别名都已经被配置了,下面将运行数据库窗体向导创建应用程序。 13.3.2 使用数据库窗体向导 开始本练习, 请单击 File 中的 New 菜单项运行数据库窗体向导,或者您也可以在任何时候单击 Database 中的 Form Wizard 菜单项运行该向导。请遵循下面的步骤,用本节开始的时候定义的数据库创建一个新的 应用程序。 1.运行 Delphi。 2.依次单击 File、New、Other 打开 New Items 对话框。 3.单击 Business 属性页,然后双击 Database Form Wizard 项。将弹出 Database Form Wizard 对话框(如 第 13 章 使用 Data Access 组件 316 图 13.5 所示) 。 图 13.5 数据库窗体向导的第 1 步(可以从创建新应用程序过程中的 New 条目中或者任何时候从 Database Form Wizard 菜单中运行该向导) 4.保留默认值并使用一个 TTable 组件创建一个简单的窗体。 5.单击 Next 按钮。 6 . 从 驱 动 程 序 或 者 别 名 名 称 组 合 框 中 选 择 Renegades 名 称 ( 请 参 照 图 13.6 ), 然 后 单 击 dbo.PLAYER_STATISTICS 表(记住现在替换您的数据库别名) 。 图 13.6 向导的第 2 步,选择驱动程序或者别名以及表名设置从哪个表创建窗体 7.单击 Next 按钮。 8.单击下一步的>>按钮(见图 13.7)将所有列添加到 Ordered Selected Fields 列表框中(您可以使用 上下箭头,当前显示为不可用,重新排序列)。 第 13 章 图 13.7 使用 Data Access 组件 317 使用第 3 步添加和排序显示在数据库窗体上的字段 9.单击 Next 按钮。 10.下一步,选择 Vertically 单选按钮(没有图示)创建一个窗体,在一个垂直的列中显示标签和字段。 11.单击 Next 按钮(接受默认值)。 12.单击 Next 按钮(接受下一个默认值)。 13.单击 Finish 按钮生成窗体(如图 13.8 所示)。 图 13.8 从第 1 步到第 13 步生成的数据库窗体 按 F9 键运行包含窗体的应用程序。使用上面的步骤,数据库窗体向导创建了一个简单的数据库窗体 应用程序,其中包含 TField,一个 TDataSource,一个 TTable,一个 TDBNavigator 和几个 TDBEdit 控件 与 TLabel(如图 13.8 所示)。下一节将更详细地介绍这些组件。在练习中所有这些步骤可以在大约 10 分钟 的时间内完成,所以创建基本的实用应用程序也是相对较快的(请参考第 11 章使用一个组件在运行时自 动创建一个数据库窗体的例子)。 13.4 Data Access 组件 您总可以简化复杂的层次并将它们编写到框架中。购买一个 BASM(Borland Assembler)的拷贝,您 就可以开始创建一个框架。被添加代码的层用于隐藏与创建应用程序框架有关的复杂问题,这给您开发应 用程序提供了更高的起点。 注意:一个比较好的编写软件的模拟是住宅建筑工业。住宅是很复杂的。经过几千年的建造 第 13 章 使用 Data Access 组件 318 住宅之后,建筑师已经折衷成几十个标准化单元。建筑者也使用建筑师、通常的承建人、子 承建人、壁画工、油漆师、艺术建筑师、检查官、管工和电工。然而软件要远远复杂得多, 仍然由一个特定的部门和程序员与工程管理员团队编写。除非您非常幸运,许多开发团队没 有相应的建筑师、设计师、工具制造师、专家和保证质量的团体成员(请与 Paul_Kimmel@hotmail.com 联系告诉我您的团体是怎样编写软件的)。 Data Access 组件提供相对高级别的水准,方便了数据库的访问。TDataSet 是由 TComponent(它引入 数据库数据作为一个类的概念)直接继承的。 13.5 TDataSet TDataSet 类引入了基本属性和方法,为连接到数据库以及管理记录和字段提供了方便。表 13.1 列出了 属性,表 13.2 列出了方法,表 13.3 列出了 TDataSet 的事件属性,这些将被 TTable 和 TQuery 继承,这两 个 TDataSet 的派生类在组件面板的 Data Access 属性页上作为组件被列出。 表 13.1 TDataSet 属性 属性 说明 Active 公有属性,打开或者关闭一个与数据库的连接;在打开连接之前将调用 BeforeOpen 事件方法,数据集(dataset)被设置为 dsBrowse 模式,打开一个数 据库光标,然后调用 AfterOpen 事件方法 ActiveRecord 保护属性,该属性返回活动记录的索引 AggFields 公有属性,该属性返回一个聚合字段的集合;在其子组件,如 TClient DataSet (TClient DataSet 可以在企业版 Dephi 的 MIDAS 属性页上找到,它支持聚合字 段)中使用 AutoCalcFields 如果设置为 True,将调用 OnCalcFields 事件使您可以创建一个字段,该字段的 值 由 几 个 字 段 值 来 确 定 ( 比 如 说 , FULL_NAME := LAST_NAME + ',' + FIRST_NAME) BlobFieldCount 保护属性;BLOB(二进制大对象)字段的数目 BlockReadSize 设置为 0 块,读模式被禁止;设置为一个大于 0 的值将快速扫描大量的记录并禁 止数据控件的升级 BOF 开始测试文件;测试光标是否定位在数据集的第一个记录 Bookmark 在数据集中设定标记,用于在一个数据集中获得或者设置当前记录 BookmarkSize 保护属性;该属性表示用于给一个指定的数据集保存一个书签所需要的字节数 BufferCount 保护属性;高速缓存的记录数 Buffers 保护属性;一个 PChar(指针字符,C 类型字符串)数组,指向内部高速缓存的 记录缓冲区 CalcBuffer 保护属性;计算值和查找值保存在 CalcBuffer 中 CalcFieldsSize 保护属性;用于保存计算字段的缓冲区大小 CanModify 决定数据集是否可写 Constraints TCheckConstraints 包含记录等级要求,这会在编辑一个记录的时候遇到 CurrentRecord 保护属性;在内部高速缓存的记录缓冲区中的当前记录的索引 DataSetField 指定一个主数据集中的一个字段 DataSource 另外一个数据集的数据源,为本数据集提供数据 (续表) 属性 说明 DefaultFields 如果其值为 True,那么这个数据集使用动态分配的字段;否则数据集在设计时 使用字段编辑器将字段组件添加进来 第 13 章 Designer 使用 Data Access 组件 319 用于确定数据集设计器是否是激活的,如果不空则返回这个设计器(比如说字段 编辑器就是一个设计器)的引用(请参考本章关于动态和静态字段对象部分) EOF 记录缓冲区(即光标)指向数据集中的最后一个记录 FieldCount 与该数据集有关的字段组件的数目 FieldDefList 字段定义列表,提供字段定义的平面视图 FieldDefs 数据集中字段定义的分层列表(例如,可以用来在新表中定义字段,请参考表 13.1 之后的列表) FieldList 数据集中字段组件的连续列表 FieldNoOfs 保护属性;用于将字段索引转变为字段编号的偏移量 Fields 数据集中字段的 TField 集合,用于访问动态字段;访问静态字段要通过设计时 生成的字段组件 FieldValues 对于 Fields 集合的默认数组属性;由字段名索引 Filter 过滤记录;其行为如 SQL 语句中的 WHERE 子句 Filtered Boolean 属性,表示是否应用过滤 FilterOptions TFilterOptions (foCaseInsensitive,foNoPartialCompare);忽略大小写的比较;无局 部的比较,把星号(“*”)当作一个文字值——如果 foNoPartialCompare 不在选 项中,那么星号(“*”)被当作一个掩码字符 Found 表示 FindFirst, FindNext, FindLast,或者 FindPrior 是否成功 InternalCalcFields 受保护的 Boolean 属性,表示内部计算的字段是否包含在数据集中 Modified 表示数据集是否被更改了 Name 数据集组件名称 NestedDataSetClass NestedDataSet 类的类引用 NestedDataSets 所有 NestedDataSet 的一个 TList ObjectView 当为 True 时,数据集中的字段被分层保存。如果为 False 时,字段被平铺并且嵌 套的字段以姐妹的关系进行保存 RecNo 数据集的记录号,该数据集不支持记录号;TDataSet 中的默认值是-1 RecordCount 与数据集相关的记录总数 RecordSize 记录的数据大小 Reserved 保护指针,为内部使用保留的 SparseArrays 表示 TField 是否为数组的每一个元素创建一个 TField。默认值是 False(不创建), 使用一个稀疏数组,且数组的每一个元素都不获得一个 TField 由以下可能枚举值之一指定的 TDataSetState,枚举值为 dsInactive, dsBrowse, State dsEdit,dsInsert, dsSetKey, dsCalcFields, dsFilter,dsNewValue, dsOldValue, dsCurValue, dsBlockRead,dsInternalCalc, dsOpening 下面的列表介绍 TDataSet 的一些属性,这些属性可以通过 TTable 组件访问。这个例子介绍了怎样使 用 FieldDefs 属性动态地创建一个表(请参考表 13.2 所列的 TDataSet 的方法)。 procedure TForm1.Button1Click(Sender: TObject); var FieldDef : TFieldDef; IndexDef : TIndexDef; begin Table1.DatabaseName := 'DBDEMOS'; Table1.TableType := ttParadox; Table1.TableName := 'FieldDefs'; Table1.FieldDefs.Clear; FieldDef := Table1.FieldDefs.AddFieldDef; FieldDef.Name := 'Greetings'; 第 13 章 使用 Data Access 组件 320 FieldDef.DataType := ftString; FieldDef.Size := 25; Table1.CreateTable; Table1.Open; Table1.Insert; Table1.FieldByName('Field').AsString := 'Hello World!'; Table1.Post; Table1.Close; end; TDataSet 方法由其子类组件的实例调用,包括 TTable, TQuery 和 TStoredProcedure。 表 13.2 TDataSet 方法 方法 说明 ActiveBuffer 返回一个 PChar,包含激活记录的数据 Append 添加一个新的记录到数据集中 AppendRecord 添加一个新的记录到数据集中。以数组参数传递来的值填充字段 BookmarkValid 该方法传递一个 Bookmark 参数,如果此 Bookmark 在数据集中有效则返回 True Cancel 取消对数据集的修改,并设置数据集的状态为 dsBrowse CheckBrowseMode 如果数据集已经被修改了,则发送修改,如果状态设置为 dsEdit 或者 dsInsert 以及 Modified 设置为 False,则取消修改 ClearFields 清除激活记录的所有字段值 Close 关闭数据集 CompareBookmarks 比较两个书签,如果这两个书签引用同样的记录则返回 0,如果第一个书签指定 所引用的记录在数据集中的位置比第二个书签在数据集中的位置靠前则返回一 个小于 0 的值,否则返回一个大于 0 的值 ControlsDisabled Boolean 特性,表示相应的控件是否失效 Create 构造函数 (续表) 方法 说明 CreateBlobStream 从一个 Field 参数创建一个 BlobStream(请参考 Delphi 帮助中的 TStream 和 TBlobStream 以获得更详细的资料) CursorPosChanged 使内部光标定位无效 Delete 删除当前的记录 Destroy 析构函数 DisableControls 在更新过程中使相应的控件无效 Edit 将记录的状态设置为 dsEdit;记录在编辑模式下 EnableControls 使相应的控件有效 FieldByName 返回动态的 TField,通过字段名搜索 FindField 如果找到指定的字段名则返回一个 TField;否则返回 nil FindFirst 返回一个 Boolean 值,表示查找的成功或者失败;将光标定位在数据集中的第一 个记录上 FindLast 返回一个 Boolean 值,表示查找的成功或者失败;将光标定位在数据集中的最后 一个记录上 FindNext 返回一个 Boolean 值,表示查找的成功或者失败;将光标定位在数据集中当前记 录的下一个记录上 FindPrior 返回一个 Boolean 值,表示查找的成功或者失败;将光标定位在数据集中当前记 录的前一个记录上 第 13 章 使用 Data Access 组件 321 First 这是一个过程,将光标定位在第一个记录上 FreeBookmark 该方法传递一个用 GetBookmark 方法返回的书签,释放这个书签 GetBlobFieldData 返回 BLOB 字段值,根据 FieldNo 将值返回到一个字节数组:TBlobFieldData GetBookmark 返回代表当前记录的书签 GetCurrentRecord 返回一个 Boolean 值,表示 Buffer 参数是否被当前记录缓冲区的值所填充 GetDetailDataSets 用每一个嵌套的数据集填充 TList 参数 GetDetailLinkFields 用字段组件(此组件构成了一个主细节关系)填充两个 TList 参数 GetFieldData 这是一个重载函数,如果成功的话以字段数据填充一个缓冲区 GetFieldList 将所有由 FieldNames 句点(.)分隔参数指定的字段组件拷贝到 TList 参数中 GetFieldNames 返回数据集中所有字段名的一个列表,保存在 TStrings 参数中 GotoBookmark 将光标定位到由 Bookmark 参数指定的记录中 Insert 将数据集设置为插入模式(State = dsInsert) InsertRecord 插入一个记录,字段值由传递过来的变体数的常量数组填充 IsEmpty 一个 Boolean 值,表示数据集是否为空 IsLinkedTo 如果数据集已经连接到参数 TDataSource,则返回 True IsSequenced 如果数据库表格由数据集表示则返回 True,表示记录号码是否代表记录的顺序 Last 将光标定位到数据集中的最后一个记录 (续表) 方法 说明 Locate 查找数据集中的关键字段,其值由参数(工具 TLocateOptions 参数)传递过来, 如果找到记录则返回 True Lookup 如果找到记录则返回字段值 MoveBy 将光标定位到由当前记录加上偏移量所代表的记录上 Next 将光标定位到下一个记录 Open 打开数据集 Post 将记录中的修改发送到数据库 Prior 将光标定位到前一个记录 Refresh 重新从数据库读取数据 Resync 从数据库中重新获取前一个、当前的和下一个记录 SetFields 变体数组参数(这个数组中的值被传递到记录的字段中)设置字段值;状态必 须设置为 dsEdit 或者 dsInsert Translate 拷贝源字符串到目标字符串,使字符串值在 ANSI 字符映射和 BDE 字符映射之 间进行转换 UpdateCursorPos 内部使用,以确保该光标被定位在激活的记录上 UpdateRecord 用于更新数据感知控件和数据集,以反映记录的更改 UpdateStatus 高速缓存的记录更新状态(usUnmodified,usModified,usInserted,usDeleted) 表 13.3 TDataSet 的事件特性 事件特性 说明 AfterCancel 在 Cancel 方法之后调用该事件 AfterClose 在 Close 方法之后调用该事件 AfterDelete 在 Delete 方法之后调用该事件 AfterEdit 在数据集设置为编辑模式之后调用该事件 AfterInsert 当一个新记录插入到该数据集中时调用该事件 AfterOpen 打开数据集之后调用该事件 AfterPost 在 Post 方法之后调用该事件(比如说,当当前记录被修改了且激活的记录被更 第 13 章 使用 Data Access 组件 322 新的时候将调用该事件) AfterRefresh Refresh 方法之后调用该事件 AfterScroll 当光标定位被改变的时候调用该事件 BeforeCancel 当调用 Cancel 方法的时候,在执行 Cancel 行为之前调用该事件过程 BeforeClose 当调用 Close 方法的时候,在执行 Close 行为之前调用该事件 BeforeDelete 当调用 Delete 方法的时候,在执行 Delete 行为之前调用该事件(请参考该表格 之后的 DB.pas 文件的程序列表,该程序列出了 Delete 方法的实现) BeforeEdit 当调用 Edit 方法的时候,在执行 Edit 行为之前调用该事件 BeforeInsert 当调用 Insert 方法的时候,在记录被插入到数据集之前调用该事件 (续表) 事件特性 说明 BeforeOpen 当调用 open 方法的时候,在数据集被打开之前调用该事件 BeforePost 当调用 Post 方法的时候,在记录被发送到数据集之前调用该事件 BeforeScroll 当调用 Scroll 的时候,在光标位置改变之前调用该事件 OnCalcFields 当 Calculated 字段需要指定一个值的时候调用该事件 OnDeleteError 当执行 Delete 操作的时候,如果发生了一个 EDatabaseEngine 异常,将由 CheckOperation 调用该事件 OnEditError (参见 OnDeleteError) OnFilterRecord 编写这个事件处理程序来测试过滤条件对于每个记录的过滤过程 OnNewRecord 当一个新的记录添加到数据集中的时候调用该事件 OnPostError (参见 OnDeleteError) 你可以为给定的 DataSet 组件的任何特性、甚至所有事件特性指定事件方法,这使您的代码可以在这 些事件被执行之前或者之后作出响应。请看下面的代码,该代码摘自 DB.pas,说明了 TDataSet 怎样实现 这一步。 procedure TDataSet.Delete; begin CheckActive; if State in [dsInsert, dsSetKey] then Cancel else begin if FRecordCount = 0 then DatabaseError(SDataSetEmpty, Self); DataEvent(deCheckBrowseMode, 0); DoBeforeDelete; DoBeforeScroll; CheckOperation(InternalDelete, FOnDeleteError); FreeFieldBuffers; SetState(dsBrowse); Resync([]); DoAfterDelete; DoAfterScroll; end; end; DataSet 组件的特性在设计时在其子孙组件中作了某些修改。在应用程序中通过子孙组件来调用 DataSet 的事件处理程序和方法(请参考下面有关 TTable,TQuery 和 TStoredProcedure 的介绍,其中有怎 样使用这些属性的例子) 。 第 13 章 13.6 使用 Data Access 组件 323 TBDEDataSet 和 TDBDataSet TBDEDataSet 是 TDataSet 的另外一个子孙类。TBDEDataSet 将本地 BDE API 行为集成到了 TDataSet 行为中。TBDEDataSet 具有缓高速缓存更新的能力,使您的应用程序可以通过应用许多记录的整批修改来 减少网络阻塞。 TDBDataSet 将数据库连接引入到 TBDEDataSet。在本变革中对类进行分层(一个 TDBDataSet 是一个 TBDEDataSet,一个 TDataSet 是一个 TComponent)是为了引入附加的行为,而取代创建一个单一的、全 包括的类。比如说,如果您想定义一个类来扩展 BDE 的功能,那么您可以继承 TBDEDataSet,而不从 TDBDataSet 继承所有的条款。 在设计数据感知应用程序时用到的组件直接从 TDBDataSet 派生,包括上面叙述过的 TTable, TQuery 和 TStoredProcedure 组 件 。 因 为 我 们 从 祖 先 类 ( TDBDataSet, TBDEDataSet, TDataSet,TComponent, TPersistent 和 TObject)中继承了所有的特性和方法,所以我们将重点放在这些组件上。 13.7 TTable 组件 数据库窗体向导默认情况下在窗体上放置一个 TTable, 一个 TDataSource、一个 TDBNavigator 组件和 几个其他的数据控件(见图 13.8)。对一个基本的桌面数据库数据感知窗体,这是您全部所需要的。 注意:桌面数据库,不同于客户端/服务器数据库,类似于 Paradox, dBase, Access 和 FoxPro 这样的数据库。客户端/服务器数据库包括 Interbase, Oracle, SQL Server, UDB 和 Sybase。 文本文件和 Excel 电子表格可以归到这一类:由于 Delphi 中编程的需要该类可以代表一个 数据集。 TTable 组件代表数据库表。TTable 直接由 TDBTable 继承而来,它继承了它的祖先(包括 TObject, TPersistent,TComponent, TDataSet, TBDEDataSet 和 TDBDataSet)的所有功能、数据和事件特性(请参 考表 13.1,表 13.2 和表 13.3 中列出的由 TDataSet 级别拥有的属性)。 注意:“OLE DB 是一个公开的规范,提供一个访问所有类型数据的公开标准,建立在 ODBC 的基础上”。OLE DB 引入了“Provider”的概念,扩展了可以代替非传统数据库的数据容 器。 TTable 组件是一个不可视的组件(也就是说没有运行时的可视部分),它代表任何数据库中的物理表 或者数据集。要使用 TTable,从组件面板的 Data Access 属性页上选择 TTable 组件放置到窗体上,在 Object Inspector 中修改 DatabaseName 和 TableName 特性。在 13.3.2 节“使用数据库窗体向导”中,被向导添加 进来的 TTable 组件的 DatabaseName 特性为 Renegades,TableName 特性为 dbo.PLAYER_STATISTICS。如 果想看这些特性值,打开由向导创建的窗体,选择窗体上的 Table 组件,按 F11 键将 Object Inspector 调到 前台(记住 Object Inspector 中的特性是以字母为顺序的,在 Object Inspector 上边的对象选择器组合框表示 当前被显示特性的组件。如果您在向导例子中使用不同的 BDE 别名和表那么结果将不同)。 13.7.1 SessionName 和 DatabaseName 特性 每一个表组件都包括一个 SessionName 和 DatabaseName 特性。DatabaseName 特性是一个别名,代表 一个数据库,或者一个 TDatabase 组件名。在后面的实例中,TDatabase 组件将指向一个实际的数据库别名 (请参考 TDatabase 组件部分以了解更多的信息) 。 注意:全局的 Session 对象暗示了单对象实例的实体,被指定为 singleton(单元素)对象。 一个单元素是一个全局对象,它只有一个实例;实际上您可以使用 TSession 组件创建 Session 对象的多个实例。一个单元素对象的一个例子是 Application 对象。每一个 Delphi 可执行 程序都有一个 Application 对象的实例。经常地,单元素构造函数受到了一定的限制以禁止 构造附加的实例。实例通常通过类方法(使用一个岗哨验证只有一个实例被创建)创建。 第 13 章 使用 Data Access 组件 324 您可以将 SessionName 设置为一个 Session 组件、保留空白或者从组合框中选择 Default。如果您没有 指定一个 Session 组件,那么将使用 DBTables.pas 的初始化部分创建全局 Session 对象。如果没有使用 TDatabase 组件,那么将创建一个 Database 对象并指向 DBDataSet 引入的公有只读特性 Database。因此, 不管您是否使用 Session 和 Database 组件,它们都将被自动地创建和使用(TSession 和 TDatabase 提供很 重要的服务。请参考关于 TSession 和 TDatabase 部分获取有关使用 Session 和 Database 组件的更多信息)。 13.7.2 Table 属性 现在您已经了解了 TTable 组件的 DatabaseName, SessionName 和 TableName 特性。Table 组件公布了 一些附加的特性。MasterSource, MasterFields 和 FieldIndexNames 特性使您可以定义一个主细节关系。 MasterSource 指向一个 DataSource 组件。MasterFields 指向与本数据集相关联的主数据集中的字段, FieldIndexNames 指向主数据集中的索引字段(请参考 14 章中的主细节关系部分了解更多有关本主题的信 息,以便使我们可以更好地讨论 DataSources 和 Fields)。 13.7.3 Fields TDataSet 引入了 Fields 集合,表示数据集的字段。在本上下文中的字段是指单个记录和列的交点。您 可以通过 FieldByName 方法或者 Fields 集合访问一个表中的每个字段。 procedure ShowFieldNames; var I : Integer; begin // walk fields for I := 0 to Table1.Fields.Count - 1 do ShowMessage( Table1.Fields[I].FieldName ); ShowMessage( 'Table1.FieldByName(''ID'').FieldNo=' + IntToStr(Table1.FieldByName('ID').FieldNo) ); end; Fields 集合和 FieldByName 方法都返回一个 TField 对象。前面所列的程序说明了怎样迭代集合中的每 一个字段,第二个语句显示了 ShowMessage 对话框(如图 13.9 所示),图中包含了创建 FieldNo 返回结果 的语句和 FieldNo 值。 图 13.9 FieldByName 返回一个字段对象,此语句和结果用于返回 PLAYER_STATISTICS 表中 ID 字段的 FieldNo 值 当您使用数据库窗体向导的时候,它创建了静态的 TField 组件。这些是非可视的组件(属于 DataSet)。 您可以在窗体(或者 DataModule,数据模块)类定义的开始部分找到这些组件。它们被指定为静态字段, 因为它们在是在设计时被创建的并且它们的特性在 DFM 中有定义。您也可以使用 Fields 编辑器创建静态 的 TFields 组件。如果您不想在设计时创建 TField 组件,那么 DataSet 对象将在运行时创建这些组件(在 DataSet 被实际地打开之前)。 (CreateFields 方法的调用是由 TBDEDataSet 的 InternalOpen 方法产生的)。让 我们转移到 TQuery 组件,TQuery 组件也可以使用动态的或者静态的字段,在介绍动态和静态字段对象的 章节再回过头来讨论字段对象。 第 13 章 使用 Data Access 组件 13.8 325 TQuery 组件 TQuery 组件是 TTable 组件的姐妹组件。TQuery 组件有一个 SQL 特性,而不是通过指定一个 TableName 特性的值直接指向一个表。SQL 特性是一个 TStrings 对象,该对象进行有效的 SQL 编码。您在 Strings 编 辑器(请看图 13.10,Strings 特性编辑器中的一个 SQL 例子)中所写的实际 SQL 语句要取决于您所使用的 数据库引擎的需要。BDE 引擎是 ANSI-92 SQL 兼容的,但是所使用的精确的 SQL 文本随所使用的数据库 而改变。 注意:ANSI-92 SQL 中 ANSI 指的是 American National Standards Insititute(美国国家 标准协会),92 指的是采用该标准的年份,SQL 指的是 Structured Query Language(结构 化查询语言)。ANSI SQL 委员会是一个团体,该团体包括数据库卖主、专家和参与制定 SQL 标准组成的爱好者。 图 13.10 Strings 编辑器显示了用于修改 TQuery 组件的 SQL 特性 您可以交替地使用 TTable 组件和 TQuery 组件。比如说,您可以使用一个 TTable 或者 TQuery 组件在 一个数据库中创建一个表,进行删除、编辑、插入、查找或者更新记录。因为 TQuery 和 TTable 组件有同 样的组件,所以您可以使用 TQuery 对象调用同 TTable 对象一样的方法。因此您可以使用两种方法中的一 种使用 TQuery 删除一个记录:找到正确的记录,调用 Delete 方法或者编写 DELETE SQL 语句,并使用 ExecSQL 方法运行查询。 13.8.1 编写 SQL SELECT 语句 正如最后一段所提到的,您可以使用下面两种方法之一对表进行相似的操作:使用从 TDataSet 继承的 方法或者编写 SQL 语句。为了说明这个问题,使用由向导生成的 Player Statistics 窗体并用一个 TQuery 组 件取代原来的 TTable 窗体。下面的步骤做了这些修改。 1.运行 Delphi。 2.打开 Player Statistics 工程,选择向导窗体(或者使用任何数据库窗体和一个 TTable 组件)。 3.从组件面板的 Data Access 属性页中双击 TQuery 组件(该组件位于从左边起第三位,该图标上有 SQL 文本)将该组件添加到数据库窗体中。 4.在窗体上找到 TDataSource 组件,该组件已经由向导添加了,单击该组件,选择该组件。按 F11 按 钮显示 Object Inspector 窗口。 5.该 DataSource 组件有一个 DataSet 特性。从特性编辑器(一个组合框)中选择 Query 组件,设置 DataSet 特性值。 6.单击 Query 组件。在 Object Inspector 中找到 SQL 特性,单击省略按钮打开 Strings 编辑器(回想一 下,SQL 特性是一个 TStrings 对象)。 7.输入 SQL 语句(请参考图 13.10 所示) :SELECT * FROM dbo.PLAYER_STATISTICS(如果您使用其 他的表请用您所使用的表替代 dbo.PLAYER_STATISTICS) 。 8.在数据库窗体的代码编辑器中,找到 FormCreate 事件方法。将代码 Table1.Open 改为 Query1.Open 第 13 章 使用 Data Access 组件 326 (如果在代码编辑器中还没有这个事件方法,双击 Form 组件中的 OnCreate 事件特性创建一个 FormCreate 事件方法)。 9.按 F9 按钮运行该例子。 注意:值得一提的是如果 Table 和 Query 组件没有通常的 DataSet 祖先,DataSource 组件需 要设置两个特性:一个为 Query 另一个为 Table。这会使组件 DataSource 的代码变得复杂。 使用通常祖先 TDataSet 将简化 TDataSource 组件的代码。 第 1 步到第 9 步的结果等同于使用 Table 组件。当您使用一个 TTable 组件的时候,TableName 特性用 于创建 SQL 语句,类似于第 7 步在 Strings 编辑器中输入的 SQL 语句(如果运行程序的时候打开 SQL Monitor,您将会看得一清二楚。请看图 13.11 中的第 97 步) 。 图 13.11 SQL Monitor 显示当一个数据库被打开的时候在后台所进行的所有工作。 图中的第 97 步显示了对于 Table 组件冗长的 SELECT 语句是怎样被 组合起来的,该语句详细地列出了所有的列名称,而不是使用* SELECT 语句的一般格式是: SELECT fieldname1[ ,fieldname2, fieldnamen| *] FROM tablename SELECT 和 FROM 是 SQL 语言中的关键字。斜体的 fieldname 参数代表将在结果的数据集中返回的字 段。tablename 是数据库表名,结果数据集就是从该表中获取数据。 SQL 是一种编程语言,它有自己完整的语法。已经有许多相当好的、很完整的图书介绍 SQL 语言了。 除了要考虑您的数据库厂商所使用的实际 SQL 之外,第 19 章“创建查询生成器”介绍了 SQL 查询生成器, 提供了许多常用的 SQL 构成。 13.8.2 Open 与 ExecSQL 方法 Query 组件增加了一个附加的方法:ExecSQL,这是 Table 组件中没有的。当您定义了一个 SELECT SQL 语句的时候,Query 的结果是一个结果集。对于 SELECT 语句使用 Open 方法。当您执行一个 INSERT, DELETE, UPDATE, CREATE TABLE,DROP TABLE 或者任何其他的非 SELECT 操作(这些操作不返回一 个结果数据集)的时候,使用 ExecSQL 方法。 13.8.3 RequestLive 特性 除了 SELECT 查询之外的所有查询都是只读的。如果可以的话,RequestLive 特性将返回一个可修改 的数据集。比如说,如果您编写一个同类的查询(该查询在结果数据集中只包含一个表) ,如果 RequestLive 设置为 True 那么查询可以返回一个可修改的查询。但是,如果您创建嵌套的查询或者联合两个或者更多的 表——异类的查询——那么查询组件将不能返回可以修改的数据集(请参考第 19 章关于嵌套查询和联合 查询部分了解更多的信息)。 第 13 章 13.8.4 使用 Data Access 组件 327 Params Query 组件有一个 Params 特性。Params 为 SQL 语句中可代替的参数,您可以在定义完 SQL 语句之后 将代码添加到 Params 中。比如说,如果您只是想返回一个精美的结果数据集,那么您可以定义一个参数 化的 WHERE 语句,在运行时将参数传递进来。使用已经介绍的 SELECT,可以添加 WHERE 子句限制返 回行的数目。 SELECT * FROM dbo.PLAYER_STATISTICS WHERE ASSISTS > :ASSISTS_PARAM WHERE 子 句 过 滤 了 结 果 数 据 集 , 只 返 回 那 些 符 合 这 个 子 句 条 件 的 记 录 。 WHERE ASSISTS>:ASSISTS_PARAM 表示当 Query 被打开的时候只有其值大于 ASSISTS_PARAM 的记录才被返 回。冒号(: )作为名字的第一个字符标定 ASSISTS_PARAM 作为 Query 组件的一个参数。 当您在 Strings 编辑器中输入 SQL 语句之后,需要在 Collection 编辑器中完成对 Param 的定义。单击 Query 组件中的 Param 特性旁边的省略号按钮,打开 Collection 编辑器。Collection 编辑器和获得输入焦点 的 ASSISTS_PARAM 参数如图 13.12 所示。设置 DataType 为 ftInteger——ASSISTS 字段的类型——并且设 置 ParamType 为 ptInputOuput。 图 13.12 Collection 编辑器和 Object Inspector(ASSISTS_PARAM 被选择了) 要给参数赋值,使用 Query 对象的 ParamByName 方法,如下所示: Query1.ParamByName('ASSISTS_PARAM').AsInteger := 6; Query1.Open; 上面这段代码将使 Query 组件传递如下所示的 SQL 语句到 Borland 数据库引擎中和数据库本身上。 SELECT * FROM dbo.PLAYER_STATISTICS WHERE ASSISTS > 6 在表 PLAYER_STATISTICS 中,只有超过 6 个助理的比赛者所在的记录才被返回。 13.8.5 UpdateObject 特性 TBDEDataSet 引入了 UpdateObject 特性。UpdateObject 属于 TDataSetUpdateObject 类。符合 UpdateObject 特 性 的 组 件 只 有 TUpdateSQL 组 件 。 一 个 UpdateSQL 对 象 可 以 被 赋 予 一 个 TTable, TQuery 或 者 TStoredProcedure 的 UpdateObject 对象。TUpdateSQL 组件提供了一种超越 SQL-92 限制的机制。 如果一个数据集由于某种选择是只读的,或者基于查询的类型,UpdateObject 可以被用于在后台更新 相关的表。如果使用了 CachedUpdates,那么在 UpdateSQL 语句中的 SQL 在调用 ApplyUpdates 的时候运 行。请参考关于 UpdateSQL 组件的部分,那里有一个高速缓存更新和 UpdateObject 特性的使用的例子。 第 13 章 使用 Data Access 组件 13.9 328 TDataSource 组件 如果代码在超过一个类中出现,通常都意味着这个公共代码单独作为一个类是很好的选择。其中 TDataSource 就是这样的类。TDataSource 类用于连接数据控件和 DataSet。在介绍数据控件部分您将看到 每一个数据感知控件都有一个 DataSource 特性,但是没有直接连接到 DataSet。DataSource 将连接追踪到 DataSet。除了 DataSet 特性,DataSource 引入了 AutoEdit、Enabled、State 特性和 Create、Destroy、Edit、 IsLinkedTo 方法。TDataSource 还有 OnDataChange、OnStateChange 和 OnUpdateData 方法。 很明显,TDataSource 没有引入数量很大的公有特性和方法。它所做的是阻止这些特性在 TDataSet 的 所有子类中被复制。考虑 TQuery 组件,在没有数据控件的情况下运行一个查询是合理的。比如说,一个 DELETE SQL 语句不需要运行有用的控件。还可以这样,可以把 TQuery 用作数据控件的数据集。有时候 将 DataSource 同一个查询联系起来会很方便,有时候则不。 如果我们返回到 Player Statistics 向导窗体,DataSource 被用于将所有的控件(见图 13.8)连接到数据 集中。回想一下最后的部分,我们可以将数据集从一个表改为一个查询而不需要修改数据控件的 DataSource 特性。要弄明白一个数据控件和 DataSource 特性之间的连接点,请查看 EditID(任何 TDBEdit)控件的 DataSource 特性。 图 13.13 DataSets、DataSources 和 data controls 之间关系的示 意图。TDataSource 是数据和可视控件之间的连接物 总之,一个基本的数据感知窗体需要一个 DataSet、一个 DataSource 和显示数据与允许用户修改数据 的控件。第 11 章讨论的数据库窗体向导和 DBFormWizard 组件介绍了需要的连接点。图 13.13 包含了 DataSets、DataSources 和数据感知控件之间关系的示意图。 下面的代码示范了怎样使用 DataSource.OnDataChange 事件特性。 procedure TForm2.DataSource1DataChange(Sender: TObject; Field: TField); begin if(Field <> Nil ) then StatusBar1.SimpleText := Field.DisplayName + ' changed to ' + Field.Asstring; end; 注意:Field.DisplayName 可以不同于 FieldName;比如说,可以在 TField 组件的 DisplayName 特性中输入“PLAYER_NAME”作为 Player Name。 当一个记录被修改了并且用户从一个控件转移到另外一个控件的时候将调用 OnDataChange 事件。程 序示范了更新一个 StatusBar(状态栏)控件(一个非数据库控件)来显示最后一次的修改。在状态栏显示 第 13 章 使用 Data Access 组件 329 的文本是字段的 DisplayName 和修改后的新值。 13.10 TDatabase TDatabase 组件是物理数据库的应用程序表现。当您的应用程序创建一个 DataSet 组件的时候,同时它 也获得一个 Database 组件,不管您是否明确地在您的应用程序中添加了一个 Database 组件。Database 组件 保存有关 AliasName、the DatabaseName 和 the Connected 状态的特性。它还引入了批更新和事务处理的方 法。只有特定的数据库支持事务处理。对于桌面数据库应用程序来说,使用自动创建的 DataSet 对象就已 经足够了。 PlayerStatistics 数据库应用程序显示了一个登录对话框。因为这是个人的实用程序,所以最好跳过登录 处理,使启动更快速。要实现这个功能,在向导窗体中添加一个 Database 组件会很有帮助。下面的步骤添 加了一个 Database 组件,跳过了登录处理。 1.运行 Delphi,打开包含向导生成的窗体的工程。 2.从组件面板的 Data Access 选项卡上添加一个 Database 组件到窗体上。 3.Database 组件的 AliasName 特性设置为别名 Renegades。 4.在 DatabaseName 特性中输入一个值,给这个 Database 添加一个名称。您可以使用 BDE 别名或者 创建一个新的别名;对于本例,输入 DB 作为 DatabaseName 特性。 5.将 LoginPrompt 的值修改为 False。 注意:数据库组件一个便利的方面是您可以创建一个应用程序级的别名。如果您在开发中想 在一个实际的和测试的数据库之间进行切换,您所需要做的是修改数据库组件的 AliasName 特性,以保证所有的 DataSet 组件都指向在数据库的 DatabaseName 特性中所输入的值。 当您运行这个修改后的演示应用程序的时候,您将不再需要登录到数据库。当在没有有效的操作员可 以登录的应用程序中使用该策略也是很有效的。与 TDatabase 组件有关的其他资料可以在第 15 章和第 19 章中找到。 13.10.1 CachedUpdates 当您创建一个数据感知窗体修改一个记录的时候,这个记录在您导航到另外一个记录的时候被发送。 如果您正在使用事务处理,那么在您提交数据之前,这个数据不会在数据库中被永久修改。 DataSet 有一个 CachedUpdates 特性。如果这个特性是 True,那么更新保存在本地高速缓存,在调用 ApplyUpdates 之前不会将更新发送到数据集。对于桌面应用程序,数据库驻留在同样的计算机上,该应用 程序作为客户端应用程序可能不需要高速缓存更新,但是使用驻留在网络上的数据库服务器的应用程序如 果没有高速缓存更新可能会对网络造成没有必要的阻塞。记录更新高速缓存对于 UpdateSQL(一个对其他 只读数据集执行更新的辅助组件)组件也是很重要的。 13.10.2 事务隔离级别 事 务隔 离级别 表示 在一个 事务 中所出 现的 多少东 西可 以被与 该数 据库同 步的其他 事务看 见 。 TDatabase.TranIsolation 特性可以是三种状态之一:tiDirtyRead, tiReadCommitted 或者 tiRepeatableRead。 DirtyRead 允许其他的事务读取由其他的事务所做的未被授权的修改;未被授权的修改可能被退回,使被 其他事务读取的数据无效。Read-Committed 是默认的隔离级别,此级别允许其他的事务读取被授权的永久 的修改。Repeatable-Read 隔离级别只允许数据库读取一次,保证数据的事务视图在事务修改数据之前不改 变。 注意:在默认情况下,dBase 和 Paradox 数据库类型需要 tiDirtyRead TranIsolation 级别。 您只有在使用事务的时候才修改这个值,如果需要,比如说,还可以使用 UpdateSQL 组件。 如果您使用 BDE,那么隔离级别可以用 TDatabase 组件来指定。您没有必要使用 BDE;例如 ADO 数 据组件没有使用 BDE。指定与默认值不同的合适的事务隔离级别的可选方法是为非 BDE 组件提供的。 第 13 章 使用 Data Access 组件 13.11 330 TSession Session 组件用于当应用程序中管理多数据库连接时。如果您在应用程序中没有添加一个 Session 组件, 那么将使用默认 Session 的对象(如 DBTables.pas 的单元初始化部分中所示例的)。Session 用于标准的数 据库应用程序(这种应用程序访问驻留在不同网络上的多 Paradox 表)和多线程数据库应用程序。 默认的 Session 对象和其默认的状态对桌面数据库应用程序来说已经足够了。请参考第 15 章和第 19 章中有关 Session 的部分了解更多的资料。 13.12 TBatchMove 假设您以一种格式接受数据,并想引入到您的主应用程序数据库中。考虑比赛者统计数据库。在下落 和弹起中,曲棍球比赛开始,球队的花名册有了改动。这个球队的花名册以 Microsoft Access 的格式发送 给您,您需要将此花名册引入到 SQL Server 数据库中。BatchMove 可以在类似于这里假设的情形下使用。 BatchMove 组件允许您指定一个源和目标的数据集,源可以是一个查询或者一个表,目标必须是一个 表 ( 因 为 BatchMove 组 件 不 生 成 SQL )。 该 组 件 包 括 Mode 特 性 ( batAppend, batAppendUpdate, batCopy,batDelete 或者 batUpdate),可以将一批记录从源数据集移动到目标数据集中。这两个数据集不必 在相同的数据库中。 一个附加的好处,BatchMove 组件有一个 Mappings 特性(一个 TStrings 对象),如果在表中的字段名 称不匹配它则允许您指定字段映射。给定 Access 数据库以及 ID, FIRSTNAME 和 LASTNAME 字段,您可 以使用 BatchMove 组件将 Access 数据移动到 SQL Server 的 PLAYER_STATISTICS 表中。 1.给 Access 数据库定义一个 ODBC 别名和 BDE 入口。 2.在比赛者统计向导窗体中添加一个 TBatchMove 和 TQuery 组件。 3.定义查询返回 ID 字段和一个计算字段(此字段将 FIRSTNAME 和 LASTNAME 以及 Query 的 SQL 特性连接起来)。如下所示。 SELECT ID, FIRSTNAME + ' ' + LASTNAME AS PLAYER_NAME from Players 4.将 Query 的 DatabaseName 特性设置为第 1 步创建的别名。 5.将 BatchMove Mode 特性设为 batAppend;这将告诉 BatchMove 将 Access 数据库中的所有比赛者名 称添加到 SQL Server 的 PLAYER_STATISTICS 表中。 6.改变 BatchMove Destination 特性使其指向由窗体向导添加的原始表。 7.将 BatchMove Source 特性改为第 2 步添加的查询。 8.在窗体上添加一个按钮,将按钮的 Caption 特性设置为 Load。 9.在按钮的 OnClick 事件处理程序中,添加文本 BatchMove1.Execute;。 10.按 F9 按钮运行应用程序。 当您单击名为 Load 的按钮的时候,将运行语句 BatchMove.Execute。TBatchMove 组件将试图把 Access 数据库中的所有记录添加到 SQL Server 数据库中。 只要源数据集有对于此数据集组件来说具有有效的数据,则此方法就可以工作。文本文件和其他数据 源窗体都将工作得很好。当然,您可以使用两个表来移动数据,迭代源数据集中每一个记录,将记录添加 到目标数据集中,但是 BatchMove 使用起来更容易、更有效。通过添加一个动态的映射字段方法,您可以 很容易地创建一个定制的数据导入和数据转变应用实例,这将映射源数据集到任何目标数据集,并且在闲 置状态下导入数据。 13.13 TUpdateSQL 根据程序员的设计或者因为您正在使用的数据库引擎的 ANSI-SQL 标准所强加的限制,Query 或者 第 13 章 使用 Data Access 组件 331 StoredProcedure 可能是只读的。我们将用\Program Files\Borland\BorlandSh- ared\Data 中的 Customer.db 和 Orders.db 数据库例子来说明;如果您编写一个多表的查询您将得到只读的数据集。 注意:从多于一个表中获取数据的查询是异类查询;这不同于从一个表获取的 dataset。单 表 dataset 是同类的。异类查询返回只读的结果数据集。 SELECT * FROM Customer, Orders WHERE Customer.CustNo = Orders.CustNo 上面这种查询方式返回表 Customer 中与表 Orders 中的相匹配的所有行,通过表 customer 的编号来匹 配。查询的结果是,每次在表 customer 中有匹配行的时候将重复这个 customer 数据,但是 Orders 表中的 数据只出现一次。 13.13.1 创建一个样本 UpdateSQL 应用程序 样本浏览器窗体(请参考图 13.14)对于查看数据是很有用的,但对于其他没有用处,因为查询是只 读的(由 ANSI-92 SQL 标准定义)。这看起来是一种限制。很明显查询知道每一列从哪里获取。UpdateSQL 使用 CachedUpdate 允许您避免这种强制性。要通过数据控件修改只读的异类数据集所需要的是使用 CachedUpdates 和 给 数 据 集 的 OnUpdateRecord 事 件 特 性 指 定 一 个 事 件 处 理 程 序 或 者 给 数 据 集 的 UpdateObject 特性指定一个 TUpdateSQL 组件。下面是摘自 DBTables 的代码,设置了编辑只读数据集所需 的条件。 图 13.14 CachedUpdates 和 UpdateSQL 演示应用程序 function TBDEDataSet.GetCanModify: Boolean; begin Result := FCanModify or ForceUpdateCallback; end; function TBDEDataSet.ForceUpdateCallback: Boolean; begin Result := FCachedUpdates and (Assigned(FOnUpdateRecord) or Assigned(FUpdateObject)); end; 注意:Assigned 过程类似于与 Nil 的比较。Assigned 内建于编译器中。如果您使用 Code Editor 菜单并选择 Find Declaration 项,那么光标将被设置到 System.pas 单元的开头部分。 GetCanModify 返回 FCanModify 域和 ForceUpdateCallback 返回值相或的结果。如果 FCachedUpdates 为 True 并且 Assigned(FOnUpdateRecord) 或者 Assigned(FUpdateObject)为 True 那么 ForceUpdateCallback 为 True。记住 Assigned 方法相当于检查一个 Nil 值。要说明 TUpdateSQL 组件:创建一个可以多查询的并 有一个 DBGrid 组件的窗体(如图 13.15 所示为所用组件的设计时视图)。 第 13 章 使用 Data Access 组件 332 请遵循下面的步骤创建这个窗体: 1.创建一个新的应用程序,放置一个 TMainMenu 组件、TDBGrid 组件、TQuery 组件、TDataSource 组件、TUpdateSQL 组件和 TABoutDialogBox 组件(这在第 10 章中已经创建了)。 2.将 DBGrid 的 Align 特性设置为 alClient。 3.定义 MainMenu 组件, 使其包含一个 Record 菜单和一个包含 About 菜单项的 Help 菜单,其中 Record 菜单又包含 Apply、Cancel 和 Exit 子菜单项。 4.指定 TDataSource.DataSet 特性为 Query 组件。 图 13.15 UMasterAppDemo 窗体的设计时视图,列 出了 CachedUpdates 和 TUpdateSQL 组件 5.Query 组件特性的设置如下:DatabaseName =DBDEMOS, SessionName = Default, UpdateObject = UpdateSQL(组件),CachedUpdates = True, RequestLive = True,使用 Strings 编辑器设置 SQL 特性, 添加下面的 SQL 语句: SELECT C.CustNo, C.Company, O.* FROM CUSTOMER C, ORDERS O WHERE C.CUSTNO = O.CUSTNO ORDER BY C.Company ASC SQL 语句选择了表 Orders 中的所有行和列,选择了 Customer 表中的所有行和 CustNo 与 Company 列;在这里 CUSTOMER.CUSTNO 和 ORDERS.CUSTNO 字段的值相同,结果数据集中的记录以 company 字段按字母顺序排列。 6.创建 TQuery.OnUpdateRecord 事件方法。 7.将 TDBGrid.Datasource 特性设置为 DataSource 组件。 8.将 TQuery.Active 特性更改为 True;如果前面的所有设置都是正确的,那么在设计时数据就在栅格 中了。 9.右击 DBGrid 组件,选择组件编辑器编辑 DBGrid 组件;弹出组件编辑器的菜单项是 Columns Editor… (如图 13.16 所示)。 图 13.16 DBGrid 的 Columns Editor 是一个组件编辑器, 右击栅格控件从弹出的菜单中可以调出此编辑器 第 13 章 使用 Data Access 组件 333 10.保留 TQuery.Active 特性值为 True,右击 Columns editor(如图 13.16 所示),单击 Add All Columns 项,Query 的结果数据集的所有列将被添加进来。 11.保持 Column editor 打开,依次单击 CustNo 和 Company 列,按 F11 打开 Object Inspector,将列的 ReadOnly 特性设置为 True。 12.数据集中有两列 CustNo:一个来自 Customer 表,另外一个来自 Orders 表。 单击与 Orders 表 CustNo_1 有关的 CustNo,按 Delete 键删除这个字段(我们不想让用户无意中更改索引字段)。 完成第 1 到第 12 步后,您将编写代码实现所有的功能。很幸运的是代码不是很多;大多数代码都由 Delphi 产生。 13.13.2 编写 UpdateSQL 应用程序代码 UMasterAppDemo 的代码如下所示,该代码示范了 UpdateSQL 组件。大多数代码是您在上面完成 12 个步骤时由 Delphi 添加的。 unit UMasterAppDemo; // UMasterAppDemo.pas - Demonstrates CachedUpdates and the UpdateSQL component // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, Grids, DBGrids, DB, DBTables, Menus, UAboutBoxDialog; type TForm1 = class(TForm) Query1: TQuery; DataSource1: TDataSource; DBGrid1: TDBGrid; UpdateSQL1: TUpdateSQL; MainMenu1: TMainMenu; Record1: TMenuItem; Apply1: TMenuItem; Cancel1: TMenuItem; N1: TMenuItem; Exit1: TMenuItem; Help1: TMenuItem; About1: TMenuItem; AboutBoxDialog1: TAboutBoxDialog; procedure Query1UpdateRecord(DataSet: TDataSet; UpdateKind: TUpdateKind; var UpdateAction: TUpdateAction); procedure Apply1Click(Sender: TObject); procedure Cancel1Click(Sender: TObject); procedure About1Click(Sender: TObject); procedure FormCreate(Sender: TObject); private { Private declarations } public { Public declarations } end; var 第 13 章 使用 Data Access 组件 334 Form1: TForm1; implementation uses TypInfo; {$R *.DFM} {$I UMasterAppDemo.Inc} procedure TForm1.Query1UpdateRecord(DataSet: TDataSet; UpdateKind: TUpdateKind; var UpdateAction: TUpdateAction); begin if( UpdateKind = ukModify ) then begin UpdateSQL1.Apply(UpdateKind); UpdateAction := uaApplied; end else begin UpdateAction := uaSkip; ShowMessage( Format( NotImplemented, [GetEnumName( TypeInfo(TUpdateKind), Ord(UpdateKind))])); end; end; procedure TForm1.Apply1Click(Sender: TObject); begin Query1.Database.ApplyUpdates( [Query1] ); end; procedure TForm1.Cancel1Click(Sender: TObject); begin Query1.CancelUpdates; end; procedure TForm1.About1Click(Sender: TObject); begin AboutBoxDialog1.Execute; end; procedure TForm1.FormCreate(Sender: TObject); begin UpdateSQL1.ModifySQL.Text := ModifySQL; Query1.Open; Query1.Database.TransIsolation := tiDirtyRead; end; end. 大 约 在 上 面 所 列 程 序 的 中 间 , 在 关 键 字 implementation 之 后 是 我 们 插 入 程 序 的 开 始 处 。 {$I UMasterAppDemo.inc}告诉 Delphi 编译器当编译的时候将 UMasterAppDemo.inc 文件中的代码直接放到单 元中。这是一种很好的技术,可用于获取难看的源字符串和其他有用的东西,但是在视觉上不具吸引力, 内容在看不见的地方(UMasterAppDemo.inc 代码在本章中 UMasterAppDemo 单元代码分析的后面,就在后 面一点)。接下来的是 OnUpdateRecord 事件处理程序;在当前编写的处理程序中它仅处理修改更新。 TUpdateSQL.Apply( UpdateKind )方法使 Param 值被设置(您将立刻获得这些值),并调用 ExecSQL 方法。 最后 Var 参数 UpdateAction 的值被设置为 uaApplied。设置 UpdateAction 的值为 uaSkip,跳过 Insert 和 Delete 的更新。接下来显示一个简短的消息告诉开发者没有定义 Insert 和 Delete 行为。 第 13 章 使用 Data Access 组件 335 注意:如果您没有在设计时为应用程序添加一个数据库组件,那么在运行时将由 Delphi 动 态创建一个数据库对象。 ApplyClick 事件处理程序用于处理 Record 中的 Apply 菜单的单击事件。它调用 TDatabase.ApplyUpdates 方法,传递一个数据集数组。数组中的每一个数据集都用来调用各自的 ApplyUpdates 方法。Record 中的 Cancel 菜单调用 TQuery.CancelUpdates 方法来取消所有已经应用的高速缓存修改。FormCreate 事件方法将 TUpdateSQL.ModifySQL 特性设置为 ModifySQL 资源字符串、打开一个查询和将 Database 的 TranIsolation 特性设置为 tiReadDirty。 下面是 UMasterAppDemo.inc 文件的源代码: // UMasterAppDemo.inc resourcestring NotImplemented = '%s not implemented!'; DeleteSQL = 'DELETE FROM tablename WHERE field = value'; InsertSQL = 'INSERT INTO (field1, field2, etc) VALUES( value1, value2, value3)'; ModifySQL = 'UPDATE Orders ' + 'SET OrderNo = :OrderNo, SaleDate = :SaleDate, ' + 'ShipDate = :ShipDate, EmpNo = :EmpNo, ShipToContact = :ShipToContact, ' + 'ShipToAddr1 = :ShipToAddr1, ShiptoAddr2 = :ShipToAddr2, ShipToCity = :ShipToCity, ' + 'ShipToState = :ShipToState, ShipToZip = :ShipToZip, ShipToCountry = :ShipToCountry, ' + 'ShipToPhone = :ShipToPhone, ShipVia = :ShipVia, PO = :PO, Terms = :Terms, ' + 'PaymentMethod = :PaymentMethod, ItemsTotal = :ItemsTotal, TaxRate = :TaxRate, ' + 'Freight = :Freight, AmountPaid = :AmountPaid ' + 'WHERE OrderNo = :OrderNo'; ModifySQL 资源字符串包含一个参数化的 UPDATE SQL 语句。UPDATE 语句的一般格式为: UPDATE tablename SET fieldname1 = value1 [, fieldname2 = value2, … fieldnamen = valuen]; tablename 是目前正在被更新的表名。UPDATE 和 SET 是关键字。关键字 SET 之后是成对的 fieldname 名和 value。fieldname 代表表中的一个字段,Value 代表与字段类型匹配的数据。在本例中,参数 value 在 等号操作符的右边。在参数前面加一个“:”表示后面跟的是参数。在 UpdateSQL 语句中的参数必须和字 段名匹配,以便使 UpdateSQL 组件可以自动地填充参数值。 包含文件当作一种便利的辅助工作机制来使用。正如您可以从代码中看到的,SQL 语句资源字符串很 长并且有点混乱,SQL 语句资源字符串没有加入到单元中。 13.14 小 结 Delphi 有一个组织很好的、有层次的体系结构。从 Delphi 的外观看,有点像 Visual Basic。深入地了 解 Delphi,这种错觉就消失了。第 13 章介绍了很少用于开发数据库应用程序的组件和控件。您看到了数 据库窗体向导怎样以很少的步骤创建实用的数据库应用程序(第 11 章介绍了一个自动做这些工作的组件) 。 第 13 章 使用 Data Access 组件 336 从一个开发者的角度来看,复杂性都已经被隐藏了。从一个工具开发者的角度来说,就要看您想要深入的 程度了。 如果对类和层次进行细分就会发现,Delphi 的 VCL 只不过是 Object Pascal。第 13 章介绍了所有的 Data Access 组件,包括数据控件和两个演示应用程序。Delphi 还有其他更多的方面需要探讨。从“Delphi 6 应用开发指南”这本书的中间部分开始,我们将开始转移到建立应用程序部分。 第 14 章 使用数据控件 组件面板的 Data Controls 属性页包含了 VCL 控件(其中有一个 TFieldDataLink 辅助对象)。 TFieldDataLink 允许数据控件被连接到一个 DataSource。DataSource 是连接控件(和用户交互的)和 dataset (和数据交互的)的桥梁。 数据感知控件有附加的特性和事件,这使得编写商务逻辑并把商务逻辑与那些控件联系起来成为可 能。商务逻辑指的是一种规则,这种规则定义数据怎样被使用。例如,在美国一个 Social Security(社会安 全)号码是九位数字并且其格式为###-##-####。编写一个包含 Social Security 号码的应用程序可能包括定 义一个格式规则和一个确认检查以确保每一个元素要么是十进制数字要么是短划线。格式规则和确认检查 约束了一个数据是或者不是一个 Social Security 号码。定义数据怎样被约束的规则是组成一般术语商务规 则的一部分。 有三个地方可以放置商务规则:数据库、中间层或者客户层。当约束被放置到在客户层中时,客户端 被叫做胖客户端。胖客户端应用程序通常是两层的。当规则被放置到数据库层的时候——例如使用触发器、 数据约束和存储过程和视图——这就被叫做胖服务器。这种应用程序也是两层应用程序。当定义一个分离 的中间层并包含商务规则时,我们称这种应用程序为三层应用程序。 数据控件支持胖和瘦客户端、胖和瘦服务器以及两层的和三层的开发。数据控件允许您在特性中将格 式约束和在事件中的其他商务逻辑(如 TDBEdit 控件中的 OnChange 事件)联合起来。Delphi 企业版捆绑 了 MIDAS(Multitier Distributed Application Services Suite,多层分布式应用程序服务组)组件,这方便了 创建中间层,并且大多数 RDBMS(Relational Database Management Systems,关系数据库管理系统)服务器 都有过剩的约束数据的方法。在哪里放置约束依赖于您正在使用哪种类型的设计。本章我们将介绍数据控 件和与它们相关的 TField 对象,列举控件和它们的特性和事件,这些特性和方法对数据怎样被管理的商务 规则的定义提供了方便(第 15 章介绍建立瘦客户端、三层应用程序)。 14.1 简述两层和三层设计 两层应用程序开发使用一个客户端应用程序和一个数据库服务器。三层应用程序开发使用一个客户端 应用程序、一个定义商务逻辑的服务器应用程序和一个第三数据库服务器层。中间层可以是一个或者更多 的服务器应用程序,客户端层使用这些应用程序和数据库层对话。 通常在编程过程中一个很好的规则是将数据约束放置到与数据库尽可能近的地方,原因可以通过特性 工作的方式来说明。从外面看,特性像数据;对用户透明的是特性有用于读和写数据的访问方法,该数据 约束了当前字段的一些使用。 在数据附近放置约束就是将规则与数据绑定在一起。胖客户端应用程序包含商务规则。您每一次想在 该客户端的其他位置上访问数据的时候,您必须复制这个规则,该规则约束了数据被使用的方式。这样您 就有了许多代码重复了同样的规则,换句话说,同时需要写、测试和维护更多的代码。RDBMS 被定义为 与其他一些客户端应用程序一起使用,这使得人们可以使用类似于 Microsoft Access 的应用程序来修改数 据。如果商务逻辑驻留在您的客户端应用程序中,那么约束就不会被其他客户端应用程序误用。 胖服务器的设计在服务器上放置了商务规则。当胖服务器的设计仍然可以在两层应用中使用时,商务 规则要临近数据,从而减少被其他客户端应用程序误用的可能性。另一个好处是:定义一个胖服务器意味 着只要给每一部分数据定义商务规则一次。很有趣的是,似乎现有的胖客户端应用程序比胖服务器要多; 这种情况的存在可能是因为程序员也创建数据库。 通过在胖服务器设计中添加中间层使您可以更大程度地控制怎样访问数据库和控制客户端应用程序 类型增加的数量和种类;任何附加的商务规则在数据库服务器层中定义时看起来很不自然,它可以被定义 到中间层。使用单独的中间层还意味着不同类型的多客户端可以同时重新使用同样的中间层。权衡一下来 第 14 章 使用数据控件 344 看,胖客户端、两层应用程序看起来更便于开发。使用数据库约束、触发器、存储过程和视图要求有一个 熟练掌握所选择的 RDBMS 的数据库开发人员。要设计面向对象的中间层建议要有一个面向对象的体系结 构以获得其携带的中间层。很不幸的是,许多软件工程只能由程序员和管理人员来完成,这可能说明了胖 客户端、两层设计的卓越实现。问题是,是不是胖客户端、两层应用程序的开发更廉价呢?答案可能是不。 它们表面上很廉价是因为数据库和客户端用于程序会很快出台,但是最终它们将变得很笨拙。在开发的开 始所得到的将在开发的后期永远地失去。 是不是胖服务器、三层应用程序的开发更廉价呢?通常这些应用程序要求更高报酬的设计师和数据库 分析员、在设计的开始阶段需要更多的时间和一个组织得很好的流程。传统的应用程序不同于新型的应用 程序:对象模型和数据库模型。模型也暗示了需要购买昂贵的 CASE(Computer Aided Software Enginnering, 计算机辅助软件工程)工具。Delphi 客户端服务器的售价为$2500; Rational Rose(对象模型工具)售价为 每工作站$4500。所有这些附加的支出并不能一定保证成功。 工具的大小、复杂程度和有效性,受训人员以及管理部门决定了怎样创建特定应用程序的因素。两层 与三层应用程序和胖与瘦客户端应用程序都有优点和缺陷。只有根据实际情况为指导方针才有助于确定怎 样创建特定的应用程序。实用工具、原型和小应用程序都是在胖客户端、两层应用程序开发中很好的选择。 如果使用 CASE 工具,以及雇佣有经验的设计师和数据库分析员来创建胖服务器、三层应用程序,一些大 规模的、复杂的应用程序会有更好的机会实现。如果他们想创建三层应用程序,首先他们要做的是结束重 新创建 VCL 已经存在的。在您的队伍中注入经验丰富的程序员和指导人员将有助于确保成功。 在本章,例子程序示范了怎样创建胖客户端应用程序,以说明数据控件和 TField 对象的特性。但是, 胖客户端、两层应用程序既不是首选的方法也不是惟一的方法。每一个工程的需求由工程的预算、范围、 复杂性和参与者的特点来决定。MIDAS(将在第 15 章中介绍)更便于进行三层应用程序的开发。 第 14 章 使用数据控件 14.2 345 数据控件概览 组件面板中的 Data Controls 属性页包含了 Standard 面板上的许多控件。数据控件是标准控件的子类, 数据控件添加了一个 TFieldDataLink 对象便于将控件连接到数据源。附加的特性和事件根据控件的需要被 添加进来。将数据控件想象成普通的控件,但是数据被直接写到 underlying 字段值中。例如 TDBEdit 控件 是带有 FieldDataLink 特性的 TEdit 控件。 本节简要介绍数据控件。一些特定的控件将在本章的后面部分作详细的说明。 14.2.1 DBGrid TDBGrid 是 TCustomGrid 控件。它包含了数据的行和列。在栅格中每一行代表数据集中的一行。每一 列代表数据集中的一个字段。关系数据库可能会返回数据库中多个表的行(参考第 13 章关于 TUpdateQuery 组件的部分学习怎样使异类查询行为与同类查询一样,这使得查询表现为可写)。 DBGrid 引入了一个 Columns 集合和设计时列编辑器,这方便了在栅格中显示数据库中的数据(请参 考 14.4 节“DBGrid 控件”中有关 DBGrid 的详细介绍)。 14.2.2 DBNavigator TDBNavigator 是一个带有按钮数组的 TCustomPanel 控件。它被连接到一个数据源,每一个按钮都是 其中一个数据集方法调用的可视化比喻(单击按钮如同调用一个数据集方法)。从左到右(如图 14.1 所示) 的操作是 First、Prior、Next、Last、Insert、Delete、Edit、Post、Cancel 和 Refresh。 图 14.1 TDBNaviagtor 控件,用于调用数据集方法 导航器并不完成这些行为。DBNavigator 有一个 DataSource 特性。请回顾第 13 章,每一个 DataSource 指向一个数据集;DBNavigator 的 BtnClick 方法决定用户单击了哪一个按钮并使用 DataSource.DataSet 对象 引用调用这个方法。例如,单击 Insert 按钮(如图所示)将调用 DataSource.DataSet.Insert 方法。 14.2.3 DBText TDBText 是一个 TCustomLabel 控件,对于显示来自数据库的只读数据尤其有用。用户不能直接在 TDBText 控件中编辑文本。要把一个数据控件同一个 dataset 字段联系起来,给 DataSource 特性指定一个 DataSource 组件并指定一个来自 DataSet 组件(一个表或者一个查询)的一个字段名。有效的字段名将自 动地从 DataSet(该 DataSet 被指向 DataSource 组件的 DataSet 特性)中获取。 14.2.4 DBEdit DBEdit 是 TCustomEdit 的子孙控件,该控件是一个多种用途的数据库控件,可以被用于编辑任何可以 以文本显示的数据。字母和数字字段包含在合适的数据库字段中。 14.2.5 DBMemo DBMemo 是一个 TCustomMemo 控件。在 TDBMemo 中显示的匹配字段类型是 memo 字段(请参考 14.7 节“动态和静态字段”了解更多有关字段的信息)。可以被作为 TMemoFields 来显示的 Underlying 数 据类型是真正的数据库中的 memo 类型,数据库支持 memo 类型或可变长字符字段。 第 14 章 14.2.6 使用数据控件 346 DBImage DBImage 是 TCustomControl 而不是 TImage 的继承控件。这个控件可以显示任何可以归类于 TPicture 的 图 像 类 型 。 有 效 的 Picture 对 象 类 型 包 括 位 图 、 图 标 和 元 文 件 图 形 。 类 型 过 滤 文 件 包 括.jpg、.jpeg、.bmp、.ico、.emf 和.wmf 文件。 14.2.7 DBListBox 该控件显示静态值的列表。设置该控件的 DataField 特性和 DataSource 特性,在列表框中所选中的值 就是被设置字段的值。通过设置 Items 特性(一个 TStrings 特性)DBListBox 选项可以在设计时或者运行 时被添加。如果您需要动态表查找值,请使用 TDBLookupListbox 控件。 14.2.8 DBComboBox DBComboBox 是一个 TCustomComboBox 控件。在 TStrings 类型 Items 特性中添加选项,这些选项存 储在 DataField 特性和 DataSource 特性所指定的字段中。您可以用下面的方法在运行时给 DBListBox 控件 或者 DBComboBox 控件添加选项。 DBComboBox.Items.Add( 'True' ); DBComboBox.Items.Add('False'); 如果没有添加其他的值,那么在组合框中两种可能的选项是'True'和'False',可以用于 Boolean 或者文本 字段。 14.2.9 DBCheckBox 对于 DBComboBox 控件如果只有 true 和 false 两种选项的时候,另外一种方法是使用 DBCheckBox。 True 表示选择,false 表示不选择。除了 DataField 和 DataSource 特性,还有两个可替代的特性:ValueChecked 和 ValueUnchecked,这允许您设置 DBCheckBox 控件的显示状态为选择或者不选择,默认值是 true 和 false, 但是您可以使用 black 和 white,red 和 green,0 和 1,或者 yes 和 no,只要适合您的需要都可以。 14.2.10 DBLookupListBox DBLookupListBox 控件有两个已经熟悉的 DataField 和 DataSource 特性表示所选择的项从哪里获取, 除此之外,还有 ListSource、ListField 和 KeyField 特性。这个三个特性决定数据选项从哪里获取。ListSource 是包含列表数据的 DataSource。ListSource 和 DataSource 特性不能指向相同的 TDataSource 组件。这意味 着使用一个控件 DBLookupListBox 需要两个数据集和两个数据源。 ListField 是显示在列表框中的选项的列表,KeyField 表示实际被写到 DataSource 的 DataField 字段中 的数据。KeyField 和 DataField 不必有相同的名称,但是它们必须有相同的数据类型和大小。KeyField 和 ListField 可以是相同的字段,或者您可以使用友好显示的值作为列表字段,使用索引值作为 KeyField 字段。 14.2.11 DBLookupComboBox DBLookupComboBox 控件是 DBLookupListBox 的姐妹组件,其中有 3 个字段:ListSource、ListField 和 KeyField。下拉式列表由 ListSource 数据源的 ListField 列中的值确定。当下拉式列表中的项被选中的时 候,选中项的行相应的 KeyField 字段值将被写到由 DataSource 组件引用的数据集的 DataField 字段中。 14.2.12 DBRichEdit TDBRichEdit 控件既是 Memo 控件也是 TCustomRichEdit 控件(TCustomRichEdit 由 TCustomMemo 继 承而来)。Rich 文本允许用户输入 rich 格式的命令以便于控制字体特性和段落格式信息。通过指定 DataField 和 DataSource 特性,您可以在数据库中存储 RichText 类型的 memos(请参考 14.3“DBRichEdit 控件”中 使用控件的例子)。 14.2.13 DBCtrlGrid DBCtrlGrid 控件是定制的 WinControl 控件,允许您在设计时在单面板中放置多控件。每一个控件面板 表示数据集中的一行。当您滚动栅格的时候,控件被复制到后续的面板中,并且后续的数据记录显示在那 第 14 章 使用数据控件 347 些行中。图 14.2 显示了 TDBCtrlGrid 控件的设计时视图。设置了 DBCtrlGrid 的 DataSource,那么放置在栅 格上的所有数据控件都使用该 DataSource。然后给各自数据控件设置 DataField。设置一个面板,DBCtrlGrid 控件将给其他的面板设置复制的控件,并用数据集中下一行填充复制的控件(请参考图 14.3,该图显示了 图 14.2 所示的 DBCtrlGrid 控件运行时视图)。 您可以指定 ColCount 和 RowCount 值一次显示数据的多行。图 14.3 显示表 animals.dbf,一次显示三 行、两列或者说 6 个记录。 图 14.2 用数据控件设计 DBCtrlGrid 的一个面板,其他的面板将自动放置相同 的控件,每一个面板都显示前一个面板的下一个记录(如图 14.3 所示) 图 14.3 14.2.14 图 14.2 所示的 DBCtrlGrid 控件的运行时视图 DBChart DBChart 是 Cinderella 故事的一部分。Dave Berneda 在几年前开发了 TChart 组件。它已经很流行了, 以至于 Inprise 在 VCL 中包含了该控件,Dave Berneda 也在 Delphi 的 About 对话框中获得了一个 Delphi Staff 的荣誉。DBChart 是一个携带高级组件编辑器的 TChart 控件;在 14.6 节“DBChart 控件”中有该组 件的介绍。 14.2.15 连接到 DataSource 和 DataSet 如果您跳过第 13 章的话,这部分可以帮助您复习一下。数据控件至少需要一个 TDataSource 和一个 第 14 章 使用数据控件 348 TDataSet 组件,用于连接到数据库。TDataSource 组件有一个 DataSet 特性。DataSet 代表数据库中的数据, DataSource 将数据控件连接到该 DataSet。 TDataSource 的代码可以被并入到 TDataSet 中,但是这样的话每一个 DataSet 会因为添加的 TDataSource 代码而变得庞大。这意味着 TBDEDataSet、TDBDataSet、TQuery、TStoredProcedure、TNestedTable 和 TQuery 的代码会膨胀起来。但是有时候您需要 DataSet 而不是 DataSource。例如,一个包含 SQL 的查询删除一行 并不返回一个数据集,删除行时没有必要将 DataSource 和 TQuery 联合起来。其他的情况是,查询被定义 返回结果数据集,并需要一个 DataSource 用于显示结果数据集中的字段。 通过设置数据控件的 DataSource 特性并选择有效的 DataField,每一个数据控件都被连接到一个数据集 中的一个特定字段。 14.2.16 数据控件特性 所有的数据控件都有从它们的祖先类中继承而来的特性和事件。TDBEdit 从 TEdit 继承而来,它有 TEdit 控件的所有特性。特性的可视性不能被降级,例如,不能从 published 到 public,因此所有从 TEdit 继承来 的 published 特性将显示在 TDBEdit 控件的 Object Inspector 中。 嵌套的特性 Delphi 允许您修改嵌套的特性。一个 DBEdit 控件有一个 Font 特性,该特性包含一个 Style 特性,您可 以在 Object Inspector 中修改这个 Style 特性。您可以单击特性左边的“[+]”符号展开嵌套的的特性列表, 以查看嵌套的特性值,还可以单击特性左边的“[-]”符号隐藏嵌套的特性值。Delphi 6 引入了内嵌的特 性引用和子组件。 内嵌的特性引用和子组件 现在组件可以拥有子组件。创建一个拥有另外一个组件的组件的方法叫做 SetSubComponent 方法(请 参考第 10 章中有关子组件的更多的信息以及一个使用 SetSubComponent 的例子)。Owned published 组件引 用将显示在 Object Inspector 中。 提示:如果没有引用一个子组件那么就不会有“[+]”和“[-]”按钮,嵌套的引用特性和事 件在 Object Inspector 中的字体颜色是红色的。 被引用的 published 组件也将被列在 Object Inspector 中(在 Delphi 6 中新出现的特性)。其结果是您可 以在 DBEdit 组件中修改 TDBEdit 控件的 DataSource 和 DataSet 的事件和特性(任何 published 的引用组件 都可以使用这种方法来修改)。图 14.4 显示了在 Object Inspector 中被选中的 TDBEdit 控件。Events 属性页 集 中 在 双 重 嵌 套 的 TDataset 中 。 如 果 要 给 Object Inspector 中 选 择 的 DBEdit 控 件 修 改 TDataSource.OnDataChange 事件,您可以有两种方法:您可以查找引用的 DataSource 来修改其特性和事件, 或者您可以通过嵌套的引用来修改这些特性和方法。 图 14.4 DBEdit 控件嵌套的 DataSource 特性和双重嵌套的 TDataSet 特性 第 14 章 使用数据控件 349 14.3 DBRichEdit 控件 DBRichEdit 控件由 TCustomRichEdit 继承而来。Rich 文本可以在文档体中嵌入命令。Rich 文本控件包 括字体和段落格式信息,所以整个文档中的文本显示可以不同。 14.3.1 格式化文本 可以使用 SelAttributes 特性(一个公有 TTextAttributes 特性)格式化 RichEdit 和 DBRichEdit 文本。 不管文本是否受保护,都可以使用 SelAttributes 来指定字符集、颜色、密度、高度、名称、倾斜度、大小、 字体。 注意:Delphi 中没有自带 RichEditDemo.exe 程序中用到的图像。这些图像是 Windows 95 工 具 栏 按 钮 ; 位 图 可 以 在 \Program Files\Microsoft Visual Studio\Common\Graphics\Bitmaps\TlBr_W95 中找到(如果您已经安装了 Visual Studio 6.0 的话)。 有 两 个 程 序 例 子 可 以 用 于 学 习 怎 样 使 用 RichEdit 控 件 。 打 开 Delphi 的 Demos 目 录 中 的 Demos\RichEdit\RichEditor.dpr 工程,此工程包含了一个 RichText 编辑器范例和 DBRichText 范例(请参考 图 14.5),该工程也可以在本书的 CD-ROM 中找到。 设置字体样式 TDBRichEdit.SelAttributes.Style 特性是 TFontStyle 的一个集合。其值可以是 fsBold、fsItalic、fsUnderline 或者 fsStrikeOut 中的任何一个。SelAttributes 的 Style 特性是一个集合。使用集合的规则将一个特定的样式 包含到集合中或者从集合中删除。在图 14.5 中的 B、I、U 和 abc 工具按钮用于代表这些用户可定义的样式。 其支持代码被写到一个事件处理程序中,所有代表样式(前面提过的)的工具按钮的 OnClick 事件特性被 指定到一个事件处理程序中。 图 14.5 RichEditDemo.exe 程序,一个使用 TDBRichEdit 控件的范例 procedure TFormRichEditDemo.ToolButtonBoldClick(Sender: TObject); const STYLES : array[1..4] of TFontStyle = (fsBold, fsItalic, fsUnderline, fsStrikeOut ); begin // Use ToolButton for Down and Tag with Sender As TToolButton do // Use DBRichEdit1.SelAttributes for Style with DBRichEdit1.SelAttributes do if( Down ) then Style := Style + [ STYLES[Tag] ] else Style := Style - [ STYLES[Tag] ]; 第 14 章 使用数据控件 350 end; 注意:使用多个 With 语句会有些乱。上面所列的 With 语句可以使用 if 条件语句、case 语 句 或 者 大 量 使 用 对 象 和 特 性 来 实 现 。 例 如 , if 条 件 语 句 可 以 这 样 用 : if( DBRichEdit1.SelAttributes.Down ) then;不管使用哪种样式,以便于您的理解为标准。 每一个代表一个字体样式的按钮在 Tag 特性中都有一个整数值。例如,Bold 按钮在 Tag 特性中的值为 1。如果 TToolButton.Down 为 True,那么此样式被包含在 TFontStyles 集合中,如果 TToolButton.Down 为 False 那么此样式不包含在 FontStyles 集合中。Tag 用于索引 STYLES 数组以返回一个特定的字体样式。注 释部分表示 Down 和 Tag 是从属于 ToolButton 的特性,Style 是 SelAttributes 的特性。 改变字体颜色 TColorDialog 是一个便利的对话框组件,用于可视地选择颜色。 TColorDialog 组件在组件面板的 Dialogs 属性页中,该组件的 Color 特性可以被赋予 RichEdit 控件的 selAttributes.Color 特性。 procedure TFormRichEditDemo.Color1Click(Sender: TObject); begin if( ColorDialog1.Execute ) then DBRichEdit1.SelAttributes.Color := ColorDialog1.Color; end; 另外一种方法是使用 TFontDialog 组件一次设置所有的字体值。 if( FontDialog1.Execute ) then begin DBRichEdit1.SelAttributes.Style := FontDialog1.Font.Style; DBRichEdit1.SelAttributes.Size := FontDialog1.Font.Size; DBRichEdit1.SelAttributes.Color := FontDialog1.Font.Color; DBRichEdit1.SelAttributes.Charset := FontDialog1.Font.Charset; DBRichEdit1.SelAttributes.Name := FontDialog1.Font.Name; end; SelAttributes 没有 Font 特性;因此 Font 特性必须单独地设置,如上所示。 14.3.2 更改段落特性 TRichEdit(和 TDBRichEdit)控件的 Paragraph 特性是一个 TParaAttributes 对象。Paragraph 特性可以 用于表示 Alignment、FirstIndent、LeftIndent、Numbering、RightIndex、Tab 和 TabCount 值。Paragraph.Alignment 特性是一个枚举 TAlignment 值,枚举可能的值为:taLeftJustify、taCenter 或者 taRightJustify。这些值是相 互排斥的;因此表示文本对齐(见图 14.5)的按钮被组合起来,AllowAllUp 被设置为 False。您可以按住 Shift 键单击任何这三个按钮之一来改变这些特性。将这三个控件的 Grouped 特性设置为 True、AllowAllUp 特性设置为 False,并将表示左对齐的按钮的 Down 特性设置为 True。更改文本对齐的代码如下所示: procedure TFormRichEditDemo.ToolButtonLeftJustifyClick(Sender: TObject); const ALIGNMENTS : array[1..3] of TAlignment = (taLeftJustify, taCenter, taRightJustify); begin if( (Sender As TToolButton).Down ) then DBRichEdit1.Paragraph.Alignment := ALIGNMENTS[Tag]; Table1.Edit; end; 为了一致性,用于修改字体样式的数组方法同样用于修改所选择的文本对齐。在这三个包含交替长水 第 14 章 使用数据控件 351 平线的按钮的 OnClick 事件特性被指定到前面的事件处理程序中。其代码同下面的代码(使用了一个 case 语句)作用一样。 case (Sender AS TToolButton).Tag of 1: DBRichEdit1.Paragraph.Alignment := taLeftJustify; 2: DBRichEdit1.Paragraph.Alignment := taCenter; 3: DBRichEdit1.Paragraph.Alignment := taRightJustify; end; 数组提供了更好的性能,其结果是所产生的机器代码(Thorpe, 1996)更小。 14.3.3 查找文本 FindDialog 是使用起来很方便的 Dialog 组件,可用于很多种查找操作。RichEdit 控件有一个 FindText 方法,该方法有一个被查找文本的参数、开始和结束位置以及一个匹配类型参数数组(该数组定义对查找 的文本进行怎样比较)。FindText 返回一个整数值偏移量,表示所找到的匹配文本离开文本开始处的位置。 将查找文本的长度加上由 FindText 返回的偏移量正好得到所匹配的文本。 SelStart 和 SelLength 特性表示被选择的文本。将这两个特性和 FindText 方法联合起来可以实现查找和 再次查找功能。下面是摘自 RichEditDemo 程序的一个专家级应用。 procedure TFormRichEditDemo.FindText( const FindText : String; IsNewSearch : Boolean ); const StartPos : Integer = 0; var EndPos, FoundPos : Integer; begin if( DBRichEdit1.SelLength = 0 ) or (IsNewSearch) then StartPos := 0 else StartPos := DBRichEdit1.SelStart + DBRichEdit1.SelLength; EndPos := Length(DBRichEdit1.Lines.Text) - StartPos; FoundPos := DBRichEdit1.FindText( FindText, StartPos, EndPos, [stMatchCase]); if( FoundPos <> - 1 ) then begin DBRichEdit1.SetFocus; DBRichEdit1.SelStart := FoundPos; DBRichEdit1.SelLength := Length( FindText ); end; end; 过程 FindText(此过程镜像使用过的 DBRichEdit 方法)有两个参数:要查找的文本(FindText)和 IsNewSearch, IsNewSearch 的默认值为 True。如果这是打开 FindDialog 对话框后的第一次搜索,那么 StartPos (文本的开始位置)被设置为 0。StartPos 是一个可以更改的常量;StartPos 的值将被带到下一次调用 FindText 中。FindText 可以被用于查找和再次查找。在后一个查找中,StartPos 的值被设置为当前的 SelStart 加上 SelLength;被设置后的位置在前一次查找后匹配文本的后面。EndPos 是 RichEdit 控件的文本长度减去 StartPos 后的值,StartPos 表示已经查找过的文本。 如果查找到了文本,那么设置控件获得输入焦点。设置 TDBRichEdit.SelStart 的位置为 FoundPos,设 置 TDBRichEdit.SelLength 位置为查找到的文本长度,使查找到的文本为高亮。 第 14 章 14.3.4 使用数据控件 352 流 BLOB 字段 BLOB 是 Binary Large Object(二进制大对象)的首字母。DBRichEdit 控件使用一个 ftFmtMemo 字段 类型。该类型是一个 blob 字段(请参考 14.7 节“动态和静态字段”,在本章的最后有关于 TField 对象的信 息)。下面的值: Table1.FieldsByName('RICH_MEMO').IsBlob 或者 Table1.FieldsByName('RICH_MEMO') Is TBlobField 都会返回一个 True 值。TBlobField 的特征是它们可以从一个 TStream 类中流入或者流出,如 TFileStream 一样。这意味着可以使用 TBlobField 的 SaveToFile 和 LoadFromFile(前面已经叙述过了)方法,很容易地 将 Blob 字段写到文件中或者从文件中读取 Blob 字段。 procedure TFormRichEditDemo.ExportMemo; begin SaveDialog1.InitialDir := ExtractFilePath(Application.EXEName); if( SaveDialog1.Execute ) then (Table1.FieldByName('RICH_MEMO') As TBlobField).SaveToFile(SaveDialog1.FileName); end; 在 RichEditDemo 中,如果您单击 SaveDialog 上的 Save 按钮,当前记录的 RICH_MEMO 字段值(一 个 ftFmtMemo 字段类型)将被保存到一个外部文件中。使用 LoadFromFile 方法从外部源加载这个 RichEdit 文件。因为您是从一个文件中读取字段到控件 DBRichEdit 中,该控件的 DataSet 必须在 Edit 模式下。 14.4 DBGrid 控件 TDBGrid 控件是一个 TCustomGrid 控件,用于在一个控件中显示整个表或者整个数据集。因为在结果 数据集中返回的每一个字段都显示出来,所以只要设置 TDataSource 特性就可以了;没有单个的 DataField 特性。从 DataSource.数据集特性中您可以动态地确定数据集的字段和字段排序。在栅格中的一行代表数据 集中的一行;在栅格中的一列代表数据集中的一个字段的所有值。 一个数据集可以由单个表组成,例如当 DataSource 所指向的 TDataSet 是一个 TTable 时,该数据集指 向数据库中的单个表。关系数据库使用索引字段根据已经存在的关系用多种途径将分离的表逻辑地联系起 来。一个例子是人与电话号码的关系。在美国绝大多数人都有好几个电话号码,这些电话号码用于不同的 时间或者不同的场合和他们取得联系。绝大多数成年人有一个家庭电话、一个办公室电话、可能还有个人 电话。个人和联系电话之间的关系是一对多的关系。在关系数据库中,可能会定义两个表,一个用于个人, 另外一个用于联系电话号码。可以有一个逻辑关键字包含个人和电话号码之间的关系。请参考第 13 章中 有关 TUpdateSQL 的部分,这里有一个摘自 MasterApp 应用程序(Delphi 自带的)的例子,该例子连接了 Customer 和 Orders 表。 逻辑连接的表可以返回到一个 TQuery 组件、一个 TDataSet 中,用于填充 DBGrid。这样 DBGrid 中的 行可以显示从多个表中选择的字段。 14.4.1 列集合和列对象 dbgrids.pas 单元定义了一个 TCollectionItem 类 TColumn。一个 TColumn 对象代表 DBGrid 控件中的一 列。您可以使用 DBGrid 组件编辑器(见图 14.6)在设计时管理 columns 集合。如果您在设计时定义 columns, 那么在运行时将为结果数据集中的每一个字段自动创建一个 column 对象。这种创建的 column 对象可能是 您所需要的,也可能不是您所需要的。另一个好处是您可以获得一个关于在设计时数据如何显示在栅格中 的 WYSIWYG(所见即所得)视图。 第 14 章 图 14.6 使用数据控件 353 DBGrid 的 columns 编辑器是一个组件编辑器, 从 DBGrid 的上下文菜单中可以打开此编辑器 继续 RichEditDemo,在设计时通过设置数据集组件(TQuery 或 TTable 组件)或者在设计时定义静态 的 TField,就可以创建栅格的列(现在在 RichEditDemo 中,我们将设置 Query.Active 特性为 True;请参考 14.7 节“动态和静态字段”中关于 TField 对象的详细信息)。RichEditDemo 应用程序自动创建一个 Paradox 表,该表包含字段 ID、NAME、DATE_TIME 和 RICH_MEMO。应用程序中的查询仅返回前面的三个字段。 在练习使用 Columns 编辑器时,可以使用任何表,并编写查询以返回所有的字段。 SELECT * FROM MEMOS.DB 注意:如果您现在没有 ODBC 别名定义为指向包含表的任何目录,那么您可以在查询的 FROM 子 句 中 使 用 数 据 库 的 完 全 路 径 , 完 全 路 径 使 用 双 引 号 包 含 。 例 如 SELECT * FROM "C:\TEMP\MEMOS.DB"。 在 TQuery 组件的 SQL 特性中添加查询,设置 Active 特性为 True。DBGrid 是通过 DataSource 连接到 数据集中的。将 TDataSource.DataSet 特性设置为 DataSource 组件。图 14.7 显示了 RichEditDemo 程序中的 Open Memo 对话框的设计时视图。该窗体用于选择 MEMOS.DB 数据库字段中的一行,并返回所选择行的 ID 字段值。 图 14.7 设计时添加列对象之前的 Open Memo 对话框(以及前台的 DBGrid 上下文菜单) 在设计时添加列 在设计时添加列使您可以通过 Object Inspector 控制每一列的显示。假设您有一个 DataSet 和 DataSource,且已经正确地配置了(请参考本节开始部分),设计时在 DBGrid 上右击,在弹出的上下文菜 单(如图 14.7 所示)中选择 Columns editor 项,激活 Columns 编辑器(如图 14.6 所示) 。 提示:另外一种激活 Columns 编辑器的方法是单击 Object Inspector 的 TDBGrid.Columns 特性。 如果查询已经激活了,那么右击 Columns editor 弹出列编辑器上下文菜单。如果 dataset 已经激活了, 那么弹出菜单中的 Add All Fields 项将有效。其他的选项包括:Add、Delete、Select All、Restore Defaults 和 Toolbar,可以被用于在编辑器中一次添加一列、删除列、选择所有的列、恢复列的默认值,还可以切换 第 14 章 使用数据控件 354 Columns 编辑器工具栏的可视性。单击 Add All Fields 菜单项,添加结果数据集中的所有列。图 14.8 显示 了字段被添加后的 Object Inspector 和 Columns 编辑器。 一旦列被添加了,您就可以分别地选择每一列,并在设计时更改列特性。 在设计时更改列特性 在列编辑器中选择一个或者多个列可以更改列特性,并且可以改变任何相应的特性。表 14.1 列出了所 有在设计时可更改的列特性。 图 14.8 在前台选择了 ID 列的 Object Inspector 以及 在后台添加了字段之后的 Colujns 编辑器 表 14.1 TColumn 特性,用列编辑器创建的静态列的特性可以在设计时被修改,或 者如果在设计时没有定义列,那么动态创建的列的特性可以在运行被修改 列特性 说明 ButtonStyle TColumnButtonStyle 的对象,定义了显示在栅格单元中的编辑按钮;其可能的选项 是 cbsAuto、cbsEllipsis 和 cbsNone Color 字段单元的背景颜色 DropDownRows 当 ButtonStyle 特性为 cbsAuto 时,DropDownRows 为显示的行数,该列有一个查 找字段或者已经定义了的 PickList Expanded Expanded 如果为 True,那么 ObjectField 被扩展,显示对象字段的附加列 FieldName 字段名,列的数据从该字段中获取值 Font 列中每一个单元的单元字体 ImeMode 列的输入方法编辑器,用于转换亚洲字符 ImeName 所使用的输入方法编辑器的名称 PickList 静态的选择列表(TStrings 对象) ,该列表作为列字段的可能选项;例如一个 Boolean 字段可以在选择列表中使用 True 和 False 值 PopupMenu TPopupMenu 对象,可以显示特殊的列 ReadOnly 确定列中的数据是否可编辑 Title 一个嵌套的 TColumnTitle 对象,定义了本列的固定列单元的显示 Visible 控制列的可视性 Width 控制列宽度 下面的几个步骤定制 MEMOS.DB 列的显示(这对于任何列都是适用的) 。 1.单击编辑器中的 RICH_MEMO 列,按 Delete 键,删除来自结果数据集的列 RICH_MEMO。 2.将标题 NAME 改为 Name,将 DATE_TIME 标题改为 Date & Time。 第 14 章 使用数据控件 355 3.将 ID 的 Alignment 特性设置为 taLeftJustify。 4.将 NAME 列的 Width 特性设置为 255。 5.因为这个对话框用于选择一个特殊的 memo,所以要确保每一列的 ReadOnly 特性值为 True。 6.将 TQuery.Active 特性设置为 True,请看从第 1 步到第 5 步所产生的结果。 从第 1 步到第 5 步的执行涉及到了 MEMOS.DB 数据库的表,Open Memo 对话框的设计时视图如图 14.9 所示。如果在数据库 MEMOS.DB 中有 memo 数据,那么在栅格中将显示有数据。 图 14.9 设计时的 Open Memo 对话框(除非您添 加了如图所示的 memo,否则栅格将为空) 14.4.2 栅格事件 DBGrid 控件引入了几个事件,给数据感知栅格的管理带来了方便。表 14.2 列出了 TDBGrid 类中引入 的事件特性。 表 14.2 TDBGrid 控件添加的事件特性 事件特性 说明 OnColEnter 当一个新列获得输入焦点的时候调用该事件处理程序 OnColExit 当列失去输入焦点的时候调用该事件处理程序 OnDrawDataCell 老版本的事件处理程序;请选用 OnDrawColumnCell 事件处理程序 OnDrawColumnCell 当一个栅格单元需要重画的时候调用该事件处理程序 OnEditButtonClick 当列的 ButtonStyle 特性所指定的编辑按钮被单击的时候调用该事件处理程序 OnColumnMoved 当用户使用鼠标移动一个列的时候调用该事件处理程序 OnCellClick 当栅格单元被单击的时候调用该事件处理程序;这里要求 TDBGrid.Options 不 包括 dgRowSelect 项 OnTitleClick 当列标题被单击的时候调用该事件处理程序 可以使用事件处理程序编写商务逻辑或者创建绘图效果。例如,要给列画一个获得输入焦点时的矩形, 在 OnTitleClick 事件处理程序中添加下面的代码就可以了。 type TFudgeGrid = class(TCustomGrid); procedure TFormOpenMemoDialog.DrawFocusColumn( AColumn : Integer = -1 ); const Column : Integer = -1; var ARect : TRect; begin if(AColumn <> - 1) then Column := AColumn; if( Column = -1 ) then exit; 第 14 章 使用数据控件 356 DBGrid1.Refresh; ARect := TFudgeGrid(DBGrid1).CellRect( Column, 0 ); ARect.Bottom := ARect.Bottom * TFudgeGrid(DBGrid1).RowCount; DBGrid1.Canvas.DrawFocusRect( ARect ); end; 可以从 OnTitleClick 和 OnCellClick 事件处理程序中调用 DrawFocusColumn 方法。如果 AColumn 不为 -1,那么可修改的常量 Column 被更新。如果 AColumn 值为-1 且 Column 不为-1,那么栅格被刷新, 且在整个列中绘制输入焦点的矩形。因为 CellRect 方法是受保护的方法,所以代码只能从事件处理程序中 调用该方法,所以使用这个精妙的方法:TFudgeGrid(DBGrid1)(第 2 章中介绍的)将 DBGrid 蜕变为 TFudgeGrid 对象。 注意:关于风格的简短提示:如果您想画一个垂直的输入焦点矩形那么可以在前面所列的代 码基础上作一些修改。但是,保持输入焦点矩形的能力定义了栅格的能力,要实现优秀的面 向对象应用,应该重新定义 TDBGrid 控件,并且将垂直输入焦点功能加入该组件(TDBGrid 在控制了行为,栅格)中。 在 OnTitleClick 事件处理程序中调用 DrawFocusColumn 方法(带有 Column.Index 参数)更新垂直输入 焦点矩形。在 OnCellClick 事件处理程序中调用不带参数的 DrawFocusColumn 方法刷新最后一次画的垂直 输入焦点矩形(请参考下一节中定制单元栅格绘图的例子) 。 14.4.3 定制单元栅格绘图 DBGrid 有一个很烦人的缺陷。您可以选择垂直的和水平的栅格线——其结果是对于列标题会产生很 好的 3D 效果,同时数据单元格将产生栅格线;或者是没有栅格线,但是这将失去 3D 标题单元格效果。 使用 OnDrawColumnCell 事件处理程序,您可以创建 3D 的标题单元格,而没有栅格线(见图 14.10)。下 面的代码定义了此效果。 图 14.10 3D 的列单元格,而没有水平的和垂直的栅格线 type TFudgeGrid = class(TCustomGrid); procedure TFormOpenMemoDialog.DBGrid1DrawColumnCell(Sender: TObject; const Rect: TRect; DataCol: Integer; Column: TColumn; State: TGridDrawState); var ARect : TRect; begin ARect := TFudgeGrid(DBGrid1).CellRect( DataCol, 0 ); if( Rect.Top <= ARect.Bottom + 5 ) then begin DrawEdge(DBGrid1.Canvas.Handle, ARect, EDGE_ETCHED, 第 14 章 使用数据控件 357 BF_TOPLEFT); DrawEdge(DBGrid1.Canvas.Handle, ARect, EDGE_ETCHED, BF_BOTTOMRIGHT); end; end; 欺骗类 TFudgeGrid 用于为要绘制的标题单元格获得 TRect 对象。在列单元格被确定之后,使用 API 函数 DrawEdge 创建视觉效果。不幸的是事件处理程序不被包含列值的固定行调用;其结果是标题单元格 要么当其他任何单元格被重画的时候被重画,要么使用一个笨拙的方法限制绘图的次数(如前面的程序中 所做的)。 提示:类似于栅格中绘制 3D 列标题的这种难题,由于不能访问所需要的特性或者方法很难 执行所要做的工作,所以需要继承子类。 注意:判定特性和方法属于哪个类是很重要的任务。提这样一个问题“这对于一个实例或者 对于所有的实例能适用吗?”。特性和方法必须被包含在类(这对于所有的实例都是能用的) 中。考虑标题边界绘制代码。在窗体中定义的代码只有在一个栅格中有用;如果此代码被定 义到 DBGrid 子类中,那么所有的栅格都可以使用这些代码。那么答案是将边界绘制功能添 加到 TDBGrid 子类中,然后用这个类创建一个新的组件。 回顾最后一部分的提示,如果这个代码最后被放置到 TDBGrid 子类定义中,那么列效果将以正确的次 数被绘制,此代码将跟踪这个组件。如原来所示,边界绘制代码跟踪窗体,这只能被 Open Memo 窗体中 的一个 DBGrid 控件使用。创建带有 3D 标题的定制 TDBGrid 组件留作练习来做。 14.5 DBLookupListBox 和 DBComboBox 控件 您可以通过在 DBListBox 和 DBComboBox 控件的 Items 特性中添加字符串来定义静态的选项列表。被 选 择 的 选 项 被 写 到 由 DataField 和 DataSource 特 性 所 代 表 的 字 段 中 。 DBLookupListBox 和 DBLookupComboBox 组件从第二个数据集和数据源中获取选项。除了 DataField 和 DataSource 特性外,这 两个控件还有 ListSource、ListField 和 KeyField 特性。这些特性定义了作为可能的选项的数据(请参考图 14.11 中的 MastApp Order 窗体)。 图 14.11 MastApp Order 窗体(Delphi 自带的演示程序) 第 14 章 使用数据控件 358 以 Order 窗体中的 Bill To DBLookup ComboBox 为主要向导,各部分的关系定义如下。DataField 和 DataSource 特性代表将被更新的数据字段,ListSource、ListField 和 KeyField 表示可能的选项列表。当包含 源数据的数据集被更新的时候,附加的选项将在 Items 列表中生效。您可以回想一下,DataField 和 DataSource 表示用于存储数据的数据集和字段。 ListSource 指向包含查找数据的 TDataSource 组件, TDataSource.DataSet 继续指向 TDataSet。TDataSet 可以是一个表或者是一个查询组件。ListField 是包含 Items(将显示在下拉式 列表框中)的字段,KeyField 的值将被拷贝到 DataField 指向的字段中。 ListField 和 KeyField 可以是相同的字段或者是不相同的字段,KeyField 和 DataField 不必有相同的名 称,但是它们必须是相同的类型。如果 KeyField 和 DataField 命名相同将会更方便。Order 窗体上 CompanyCombo 的特性值如下: · DataField = CustNo · DataSource = MastData.OrdersSource (MastData 是 TDataModule 对象) · KeyField = CustNo · ListField = Company · ListSource = MastData.CustByCompSrc 当用户在选项列表中选择一个公司的时候,由 CustByCompSrc 指向的数据集中的光标将移动到该公司 所在的行。本行中由 KeyField 表示的 CustNo 值被拷贝到目标数据集当前行的 MastData.OrdersSource.Dataset 中的 CustNo 字段中。 14.6 DBChart 控件 TChart 控件已经在几年前出现了。DBChart 是一个数据感知控件,它从数据集中获取图表。TDBChart 是到目前为止最精制的控件,它包含了多嵌套属性页的组件编辑器(如图 14.12 所示)。为了说明其中的一 些功能,在 MastApp 演示应用程序中使用 customer.db 和 orders.db 表创建了一个 Volume By Customer 饼图。 该饼图如图 14.13 所示,是完全在设计时创建的,不需要附加的代码。 图 14.12 DBChart 组件编辑器 第 14 章 图 14.13 使用数据控件 359 对于 Delphi 自带的演示程序 MastApp 定制编辑, 在一个饼图中显示用户的销售额(在图中您没有看 到的是各个不同颜色的三角块和蓝色的梯度背景) 提示:你可以使用 New Items 对话框中 Business 属性页上的 TeeChart 向导。依次选择 File、 New、Other、Business(属性页)打开 TeeChart 向导。TeeChart 向导在 New Items 对话框 中(如果您正在使用 TeeChart 向导和 DBChart 控件,那么这个数据库表必须已经存在了, 并且您只能选择一个表。我们的例子使用两个表和一个联合的查询)。 要完成类似如图所示的一个图表,遵循下面所列的详细步骤,需要的话替换表和数据。 1.打开\Program Files\Borland\Demos\Db\MastApp\MastApp.dpr 工程文件。 2.依次单击 File、New、Form 在 MastApp 工程中添加一个新的窗体,将这个新的窗体保存为 ChartForm.pas。 3.主窗体是 Main.pas。打开主窗体,依次单击 File、Use Unit,选择 ChartForm 单元将 ChartForm.pas 添加到 Main.pas 窗体的 uses 语句中。 4.从组件面板的 Data Access 属性页中添加一个 TDataSource 和一个 TQuery 组件到 ChartForm 窗体中。 5.从组件面板的 Data Controls 属性页中添加一个 TDBChart 控件。在 Object Inspector 中将 DBChart1 (刚添加的)控件的 Align 特性修改为 alClient。下一步准备为获取数据编写需要的 SQL。 6.(回想一下,TDataModule 是类似于窗体的组件,通常用作不可见控件;放置在数据模块上的最常 见控件是:Table、Query、DataSource 和 Database 组件)。在 MastApp 工程中,已经有一个 TDatabase 组件被添加到了 DataMod 单元中,该组件包含了数据库名 MAST。模仿第 3 步,将 DataMod 单元 添加到 ChartForm 单元的 uses 语句中。 7.在 Object Inspector 中,将 TDataSource.DataSet 特性设置为第 4 步添加到 ChartForm 的 TQuery 组件。 将 TQuery.DatabaseName 特性设置为 MAST,将 TQuery. SessionName 特性设置为 Default,双击 TQuery.SQL 特性的椭圆型编辑按钮,弹出 TStrings 特性编辑器。 8.饼图显示了来自表 orders.db 的所有定购数量的总和,结合 Company 表读取公司名(在图例中使用, 请参考图 14.13)。请参考第 19 章中关于 SQL 的指南。该查询选择 CustNo、Company 和 AmountPaid 字段,合计 AmountPaid 字段的值,将结果放置到字段别名 Total 中。Where 语句用 CustNo 字段连 接两个表:customer.db 和 orders.db。在使用如 Sum 的聚合功能的任何时候 GROUP BY 语句都是 必需的,ORDER BY 子句将行从大到小的顺序排列。 SELECT DISTINCT d.CustNo, d.Company, Sum(d1.AmountPaid) As Total 第 14 章 使用数据控件 360 FROM "customer.db" d, "orders.db" d1 WHERE (d1.CustNo = d.CustNo) GROUP BY d.CustNo, d.Company ORDER BY Total Desc 9.接下来定义图表。右击 DBChart 组件弹出 DBChart 组件菜单。单击 Edit Chart 选项,弹出 DBChart 组件编辑器,如图 14.12 所示。 10.单击 Chart 属性页。在 Chart 属性页上,单击 Series 属性页,单击 Add 按钮选择一个图表类型。 TeeChart Gallery 对话框的 Standard 属性页中有一个饼图类型。单击这个饼图类型,然后单击 OK 按钮。单击 Title 按钮,将标题名称改为 Volume By Customer。 11.在外面的 PageControl 上,选择 Series 属性页。您将在这里定义 DataSet 和 DataSource 信息。 12.在 Series 属性页上,单击嵌套的 DataSource 属性页。这个 DataSource 属性页中有一个 ComboBox。 将 ComboBox 的值更改为 DatsSet;附加的编辑字段将被显示出来。在 ComboBox 中将标签 DataSet 的值指向在第 4 步中在窗体上放置的查询。将 Labels ComboBox 更改为 Company,将 Pie ComboBox 更改为 Total。 13.单击 Format 属性页。在 Format 属性页中将 Explode Biggest 字段更改为 85,将 Group slices Style 更改 为 below %,其值为 4,将标签更改为 Less Than 4% Each。 14.单击外边的 Chart 属性页,然后单击里面的 Panel 属性页添加梯度背景。在 Panel 的右下角找到 Gradient 组框。选择可视的复选框,选择开始和结束的梯度颜色。 提示:如果您对 SQL 不熟悉,您还可以选择使用 Database Desktop QBE(Query By Example, 通过例子查询)特性可视地建立 SQL 语句。或者如果您有企业版的 Delphi,您可以使用更高 级的 SQL 生成器,通过 TQuery 组件菜单(右击一个 TQuery 组件)访问此生成器。第 8 步中 的查询使用的是 Database Desktop 应用程序中的 QBE 设计。 实际上是这样,正如您所看到的,有许多其他的选项我们没有介绍,这些选项有很多种不同的组合。 一旦您能够定义一个基本的图表,就可以尝试使用其他选项所产生的效果。在程序的底层,仍然是 Object Pascal 的特性、方法和事件。所有在设计时可以创建的效果都可以在运行时用程序进行修改。 为 了 试 一 试 TChart 和 TDBChart 控 件 的 多 种 可 能 用 法 , 请 参 考 您 所 安 装 Delphi 目 录 下 的 Demos\TeeChart 文件夹中的 teedemo.dpr 工程。 14.7 动态和静态字段 TField 类用于特定组件分支的一个基类,该组件代表数据库中字段的面向对象视图。TField 是 TComponent 的子类;TField 是非可视化组件。TField 大约有 34 种子孙代表绝大多数知名的数据库类型。 例如,在 14.3.4 节“流 BLOB 字段”中介绍了 RichMemo 字段被初始化为一个 TBlobField 对象(一个可流 入和流出的字段)。 任何可以返回一个结果数据集的数据集组件都有与这个数据集相关的 TField 组件。当您添加 TDataSet 组件(TTable 或者 TQuery)到一个窗体、一个类或者一个单元中的时候,Field 组件将被自动创建。惟一 的区别是它们是在运行时被动态创建的还是在设计时被静态创建的。考虑 DBGrib 控件部分的 MEMOS.DB 表。MEMOS.DB 表有字段:ID、NAME、DATE_TIME 和 RICH_MEMO。ID 被定义为自动增加的字段; NAME 是一个字符串字段;DATE_TIME 是一个日期和时间字段;RICH_MEMO 是一个格式化的 memo 字 段。在运行时 RichEditDemo 程序使用最匹配的数据类型的 TField 子类自动创建 TField 对象。程序自动给 ID 字段创建一个 TAutoIncField 对象。给 name 字段创建一个 TStringField 对象。给 DATE_TIME 字段创建 一个 TDateTimeField 对象,给 RICH_MEMO 字段创建一个 TBlobField 对象。 还记得在表 13.1 中所述的每一个数据集都包含一个 Fields 集合。在数据集中对于字段的引用保存在 Fields 集合中。您可以索引 Fields 集合或者使用 FieldByName 方法(请参考表 13.2)访问任何特定的字段, FieldByName 方法有一个字段名称参数,如果要访问的字段在集合中存在那么将返回该字段对象。请参考 14.3.4 节“流 BLOB 字段”中使用 FieldByName 方法的例子。下面的例子说明了 Fields 集合的使用。 第 14 章 使用数据控件 361 procedure TFormRichEditDemo.DisplayFieldDefs1Click(Sender: TObject); var I : Integer; begin // insert a new memo New2Click(Sender); // walk the table and write the field definitions to the Memo for I := 0 to Table1.Fields.Count - 1 do begin DBRichEdit1.Lines.Add( 'object ' + Table1.Fields[I].Name + ' ' + Table1.Fields[I].ClassName ); DBRichEdit1.Lines.Add( #9 + 'DisplayText=' + Table1.Fields[I].DisplayText ); DBRichEdit1.Lines.Add( #9 + 'DataType=' + GetEnumName( TypeInfo(TFieldType), Ord(Table1.Fields[I].DataType)) ); DBRichEdit1.Lines.Add( #9 + 'DisplayWidth=' + IntToStr(Table1.Fields[I].DisplayWidth) ); DBRichEdit1.Lines.Add( #9 + 'FieldName=' + Table1.Fields[I].FieldName ); DBRichEdit1.Lines.Add( #9 + 'FieldNo=' + IntToStr(Table1.Fields[I].FieldNo) ); DBRichEdit1.Lines.Add( #9 + 'FullName=' + Table1.Fields[I].FullName ); DBRichEdit1.Lines.Add( 'end' ); end; end; 上面的代码添加到本章前面的 RichEditDemo 程序中。此代码对 DBRichEdit 控件中的一些 Fields 特性 进行了格式化,使这些特性以类似于它们如何被流到 DFM 文件中的格式显示。 object TAutoIncField DisplayText= DataType=ftAutoInc DisplayWidth=10 FieldName=ID FieldNo=1 FullName=ID end 如上所示,从循环的单步迭代输出的结果中,创建了 TField 对象。注意 Name 特性没有值。Delphi 不 命名动态创建的 Field 组件。 要牢记,TField 是可以在设计时被修改的组件。在设计时可以修改 TField 组件的优点类似于在设计时 修改任何组件:方便。 14.7.1 字段编辑器 设计时 TField 组件是使用字段编辑器来创建的,字段编辑器是一个组件编辑器,右击 TQuery 或者 TTable 组件,然后从弹出的菜单中可以打开字段编辑器,如图 14.14 所示,在 Object Inspector 中选择了 Table1ID 组件。 第 14 章 图 14.14 使用数据控件 362 使用字段组件编辑器(在 Object Inspector 的右边)在设计时创建 TField 组件 要在设计时创建字段组件,表或者查询组件必须设置好所有必需的特性,这样就可以打开表并读取字 段定义。对于 TTable 组件您必须指定一个全限定路径名或者一个数据库名和表名。对于 TQuery 组件,则 需要一个带有完全资格表名的 SQL SELECT 语句,或者一个数据库名和一个 SQL SELECT 语句。 当显示了字段编辑器上下文菜单的时候,需要时您可以添加单个字段、所有的字段或者删除字段(有 关使用字段特性菜单选项的资料请参考 14.8 节“数据库字典”)。举个例子,要添加 MEMOS.DB 数据库的 所有字段,在 RichEdit Demo 窗体上的 Table1 组件必须有一个全限定的 TableName 特性值。在本章前面的 例子中您可能已经使用了 c:\temp\memos.db。下面的步骤说明怎样完成这个过程。 1.确保 TTable 组件有完全路径和表名信息,右击 Table1 组件弹出此组件的上下文菜单。 2.单击菜单中的 Fields Editor 菜单项,打开字段编辑器。 3.右击字段编辑器,弹出字段编辑器的上下文菜单。单击 Add All Fields 菜单项。其结果类似于图 14.14 所示。 如果您看一下窗体定义的前面部分您将发现这些字段组件已经被添加进来了,这些项如下所示。 Table1ID: TAutoIncField; Table1NAME: TStringField; Table1DATE_TIME: TDateTimeField; Table1RICH_MEMO: TBlobField; 注意:为方便起见,字段组件的名称以字段所属的数据集组件名称作前缀。给定一个 Table 组 件(名称为 Table1),那么在默认情况下 ID 字段的字段组件的名称为 Table1ID。使用表名可 以将 ID 字段同其他表的 ID 字段区别开来。 可以从 Object Inspector 的 Object Selector 组合框中分别选择组件,或者在字段编辑器中单击这些组件 然后按 F11 键打开 Object Inspector 编辑器。虽然字段组件在设计时和运行时是不可见的,但是您可以在设 计时修改字段组件的一些特性和事件。 14.7.2 字段特性:使用字段约束、默认表达式和编辑掩码 TField 组件有许多特性。要了解所有 TField 的 34 个子类的详细资料,请参考完整的帮助文件。在这 里我们将介绍比较感兴趣的几种。 当您在设计时创建字段组件的时候,您可以在设计时更改组件的特性。或者,如果在设计时没有创建 字段组件,那么在运行时您必须使用代码来更改特性。 第 14 章 使用数据控件 363 字段约束 您可以使用 ConstraintErrorMessage、CustomConstraint 和 HasConstraints 特性对于输入该字段的数据施 加约束。约束如同数据的有效性测试。如果约束失败,那么将引发一个异常。如果您输入一个 ConstraintErrorMessage,那么将显示一个错误消息,或者将显示一个默认的错误消息。 提示:在定义定制的约束之前请验证具体的 TField 组件的特性。具体的组件可以有特定的 约束类型。例如,TIntegerFields 组件有 Min 和 Max 特性,用于约束整型字段的最小和最大 的边界值。 假如您是个迷信的人。您从来不希望备注的 ID 值为 13(认为这是个不吉利的数字),那么您可以对 CustomConstraint 特性添加约束条件 ID<>13,将 ConstraintErrorMessage 特性值设置为“13 is unlucky”。当 自动累加器到达 13 的时候,将生成一个异常,该异常包含来自 ConstraintErrorMessage 特性的文本。 约束必须经过测试,看看约束是不是符合字段的数据类型。对于 TStringField 类型的 NAME 字段,约 束 NAME <> 13 是不匹配的,因为 name 字段的数据类型是字符串。您也可以对实例使用逻辑评定: CustomConstraints: ID <> 13 AND ID <> 43,如果约束强加到数据库方,那么这些约束将显示在 ImportedConstraints 特性中。 默认表达式 DefaultExpression 是一个字符串特性。但是,你必须输入文本数据,以字符串的形式代表该字段的合 适数据。TDateTimeField 字段类型的 DefaultExpression 是‘12:00 PM’。默认的表达式值加快了数据输入。 如果一个特定字段的特定值在很多场合是合适的,那么字段组件将给您添加这个值,用户可以覆盖这个默 认值。 编辑掩码 EditMask 特性施加了另外一种约束。它约束数据怎样被输入。EditMask 特性适合众多类型的字段组件, 包括 TStringField 和 TDateTimeField 组件。双击 EditMask 特性旁边的按钮打开 Input Mask Editor 对话框, 如图 14.15 所示。 在右边的 Sample Masks 列表中已经预定义了几个掩码,TCustomEditMask.EditMask 的帮助文档包含编 辑过滤器的完整列表。从这个列表中我们可以看到一个描述掩码行为的例子。下面的掩码是 Social Security Mask: 000\-00\-0000;1;_ 这些 0 所在的位置必须是数字值。\ 如同 C 语言中用于文本数据的\;它允许一个格式化的字符,该字 符被看作一个符号。子掩码\-表示-是一个符号,这样才有意义,因为通常 U.S. Social Security 编号的格式 是###-##-####。;1 表示符号字符应该同数据一起保存。请参考 SSN 编号,SSN 字段的长度应该是 11 位, 包含两个-符号。最后的分号分隔符用于定义无值的输入位置。您可以使用任何字符代表空数据。 图 14.15 Input Mask 编辑器同 TMaskEdit 控件使用的特性编辑器一样 第 14 章 14.7.3 使用数据控件 364 处理字段级事件 字段组件有四个事件处理程序:OnChange、OnGetText、OnSetText 和 OnValidate。您可以对这些事件 编写额外的规则和行为,用于方便或者约束数据的输入。 字段的 OnChange 事件 当数据成功地写到字段缓冲区时,OnChange 事件处理程序被调用。例如,程序更改了字段值或者在 字段的数据控件中更改了字段值。当退出该字段的时候,将调用 OnChange 事件处理程序。下面的代码更 新了 StatusBar 值,以反映在被修改后的值和 OldValue 之间的区别。要保持 OldValue 必须将 CachedUpdates 设置为 True。 procedure TForm1.Query1CompanyChange(Sender: TField); const MASK = '%s: %s'; begin StatusBar1.Panels[0].Text := Format( MASK, [Sender.FieldName, Sender.Value] ); StatusBar1.Panels[1].Text := Format( 'Old ' + MASK, [Sender.FieldName, Sender.OldValue] ); end; 使用基类作为参数类型,所有字段类型都可以被传递到这个事件处理程序。如果您需要使事件处理程 序能接收特殊的子类字段,如 TBlobField,那么您可以使用 Is 和 As 操作符降级字段值。 字段的 OnGetText 和 OnSetText 事件 当请求字段的文本缓冲区时调用 OnGetText 和 OnSetText 事件处理程序。当 DisplayText 必须同该字段 值不同的时候或者要执行定制的格式的时候,编写 OnGetText 事件处理程序。当文本缓冲区需要使用在显 示值中没有使用的格式时使用 OnSetText 事件处理程序。OnGetText 和 OnSetText 是相对的事件处理程序; 如果您使用 OnGetText 事件处理器在用户看到文本之前格式化该文本,那么使用 OnSetText 事件处理程序 将文本转换到原来的格式。默认行为是显示值和文本值都等同于 AsString 特性值。 这两个事件都可以使用的一个例子是 MS-Access 的 Date/Time 字段。虽然 MS-Access 给您的是全部的 9 个码,您可以只需要一个 Time 值。要示范定制格式的日期和时间字段,任何日期和时间都可以。请看下 面的代码。 procedure TForm1.Query2OrderNoGetText(Sender: TField; var Text: String; DisplayText: Boolean); begin Text := Sender.AsString; if ( Sender Is TDateTimeField = False ) then exit; try Text := FormatDateTime( 'dd/mm/yyyy', Sender.AsDateTime ); except on E : EConvertError do ShowException( E, Addr(E) ); end; end; procedure TForm1.Query2OrderNoSetText(Sender: TField; const Text: String); begin if( Sender is TDateTimeField = False ) then exit; try 第 14 章 使用数据控件 365 Sender.AsDateTime := StrToDate(Text); except on E : EConvertError do ShowException( E, Addr(E)); end; end; 事件处理程序无区别地指定到每一个字段。OnGetText 事件处理程序提供了默认的行为:将 Text(Var 类型)参数设置为 Sender 的 AsString 参数,如果该字段不是有效的 TDateTimeField 字段类型,那么将退 出事件处理程序。如果该字段是 TDateTimeField,那么将被控制的显示文本使用 4 数字的年份格式。 OnSetText 事件处理程序使用 StrToDate 函数将当前的文本转换成 DateTime 值。 OnValidate 字段 当字段的缓冲区即将被更改的时候,在 OnChange 事件处理程序之前调用 OnValidate 方法。如果 OnValidate 没有引发异常,那么缓冲区被更新,且调用 OnChange 事件处理程序。当您正在用程序更新一 个字段的时候 OnValidate 事件处理程序特别适用。当您编写类似于 Field.Text := 'value'这样的代码的时候, 因为对于输入的数据没有使用中间控制,所以不使用 EditMask 特性。 procedure TForm1.Query1ZipValidate(Sender: TField); resourcestring ZipCodeError = 'Zip code can not be less than 5 digits in length'; begin if Length(Sender.AsString) < 5 then raise Exception.Create( ZipCodeError ); end; 前面的代码验证文本的长度。该事件处理程序是为邮政编码字段编写的。如果邮政编码的长度少于 5 那么将引发一个异常,该字段缓冲区将不会被更新。 14.7.4 定义字段级的查找 TField 组件的 FieldKind 特性默认值是 fkData。fkData FieldKind 代表输入数据。将 FieldKind 特性值 更改为 fkLookup,您就可以使用一个查找数据集,使字段可以自动地以查找字段填充字段值。 可以说明查找字段的一个比较好的例子是邮政编码字段。如果给定城市和州,将有可能确定邮政编码。 虽然中大规模的城市可能有多个邮政编码,自动完成搜索数据加快了数据的输入,且可以减少或者避免数 据输入错误。除了需要两个数据集之外,要正确地查找工作还需要设置字段组件的四个特性。下面是查找 字段所必须的前提条件: 1.在目标和源(查找数据集)中的查找字段和关键字段其类型和大小必须一致。 2.在目标数据集中接收查找字段值的字段必须将 FieldKind 特性设置为 True。 3.目标字段的 LookupDataSet 特性指向查找数据集;在这个实例中是数据源。 4.当执行查找的时候 LookupKeyFields 特性确定匹配的字段,必须在两个表中都设置 LookupKeyFields 特性。 5.字段组件的 LookupResultField 的数据类型必须和字段组件的数据类型一样。 6.字段组件的 KeyFields 特性必须匹配 LookupResultsField 字段。用分号隔开多个字段。 下面的代码摘自包含一个由数据库窗体向导创建的主细节窗体的 DFM 文件。在该例子中添加了第三 个数据集作为 Query1Zip 字段的查找数据集。第三个数据集包含惟一的 ID 键和 City、State 和 Zip 字段。 查找数据集中的 City、State 和 Zip 字段的类型和大小同目标数据集中的 City、State 和 Zip 字段匹配(在本 书的 CD-ROM 上演示程序的项目文件 FieldEventsDemo.dpr 所使用的目标表指向 customer.db。customer.db 是 Delphi 自带的演示数据库)。 第 14 章 使用数据控件 366 object Query1Zip: TStringField FieldKind = fkLookup FieldName = 'Zip' LookupDataSet = TableZipCodes LookupKeyFields = 'City;State' LookupResultField = 'Zip' KeyFields = 'City;State' OnChange = Query1CompanyChange OnValidate = Query1ZipValidate Size = 10 Lookup = True end 从上面的代码可以确定,定义查找需要的所有特性都已经在查找字段组件中定义了。黑体特性对于查 找正常地工作至关重要。LookUp 特性是为了向后兼容;当设置了 FieldKind 特性,Lookup 特性将不起作 用。KeyFields 指向该字段组件的 Dataset 中的字段,它必须同 LookupKeyFields 特性匹配。它们在上面的 代码中是一样的。查找字段的值来自 LookupResultField。LookupDataSet 的 TableZipCodes 表中包含了这个 查找值。 您也可以在查找字段中手工输入数据,但是当 City 和 State 字段值改变的时候,Zip 值将自动地被更新。 如果查找没有返回值或者返回的值不正确,那么在 Zip 字段中输入一个新的值。您没有必要添加附加的代 码该查找就可以正常工作了。 14.7.5 关于动态字段组件和静态字段组件的最后一点说明 Inprise 建议您使用静态的 TField,而不要使用动态的 TField。为了说明这个建议的有利之处,下面的 表对静态字段和动态字段的特性作了比较。 表 14.3 静态字段组件和动态字段组件的比较 特性 静态字段组件 动态字段组件 推荐 Yes No,除非有意在运行时更改字段定义 如果字段定义变化将引发异常 Yes No;如果返回零字段将导致一个访问违背 允许在设计时更改特性 Yes No;在运行时使用代码来更改特性 在设计时结合数据字典特性 Yes No (续表) 特性 静态字段组件 动态字段组件 一次创建和解除 Yes No;每次数据集被打开的时候创建动态字段,每次 关闭数据集的时候都解除动态字段 注意:动态字段的存在同引进 Application.CreateForm 的原因是一样的:使非面向对象的 程序员和 Visual Basic 开发者可以更容易地开始使用 Delphi。但是,不管是所有自动创建 的窗体还是动态字段,都建议您作练习。 如表 14.3 所示,使用静态字段的益处很明显。使用动态字段的惟一场合是数据集被设计为需要频繁地 变化,例如动态查询工具的情况。 14.8 数据库字典 数据库字典是一个数据库,用于存储字段特性的特性。您可以在 Delphi 企业版携带的 SQL Explorer 的 Dictionary 属性页中和在 Delphi 专业版携带的 Database Explorer 中创建和更改数据库字典。 第 14 章 使用数据控件 367 注意:这部分的例子使用 Delphi 企业版携带的 SQL Explorer(如果您使用 Delphi 专业版, 那么使用 Database Explorer)。 数据库字典用于定义集成化的字段特性。在字典中定义特性之后,每次这个特性类型的 TField 被创建 的时候,您就可以使用这些特性。其结果是相似类型的字段对象将有一致的特性,且您只需要在一个地方 (数据库字典)定义这些特性。另外一个好处是您可以定义一个同该字典相关联的控制类。当您从字典编 辑器中拖放一个 TField 到窗体上的时候,Delphi 设计器将创建一个在 Database Dictionary Attribute 类型中 已经定义的类型的组件,添加一个标签,然后应用字典中定义的字段特性。 14.8.1 创建数据字典 数据库字典是包含字段特性的特定种类的数据库。从 Delphi 中的 Database、Explore 菜单或者从桌面 上的 Start、Program Files 菜单中的 Delphi 组启动 SQL Explorer。字典的操作在 SQL Explorer(或者 Database Explorer)中的 Dictionary 菜单中选择,在 SQL Explorer 的 Database 属性页中执行字典(见图 14.16)。 在 SQL Explorer 中,已经存在一个 DBDEMOS 和 BCDEMOS 数据库的 Sample Data Dictionary 字典, 选择 Dictionary 菜单中 Select 项,将弹出一个对话框,选择一个字典更改当前的字典。 在 SQL Explorer 中要更改现有的数据库,依次单击 Dictionary、Select,或者依次单击 Dictionary、New 定义数据库字典。打开或者创建一个字典之后,您可以更改字段特性,并将这些特性同现有数据库的字段 和表相关联。要给 Delphi 携带的 DBDEMOS 数据库创建一个新的字典,请遵从下面的步骤: 图 14.16 数据库字典的操作从 SQL Explorer 或者 Database Explorer (根据您所使用的 Delphi 的版本)中的 Dictionary 菜单中选择 1.打开 SQL Explorer(或者是 Delphi 的 Database、Explorer 菜单中的 Database Explorer)。 2.选择 Dictionary 属性页。 3.依次单击 Dictionary、New 菜单项。 4.在 Create a New Dictionary 对话框中,输入字典名称(如使用“Chapter14”),对于 Database 选择 DBDEMOS,将保存字典值的表命名为“Chapter14”,并输入简单的说明(如 Building Delphi 6 Applications Data Dictionary Example)。 5.单击 OK 按钮。 提示:要给一个新的数据库创建字典,先创建一个 BDE 别名指向这个数据库。在上面的第 4 步中选择这个新的 BDE 别名。 第 5 步创建了一个空表,没有定义特定的数据库或者特性。下面的部分将定义特性并将特性同数据列 相关联。 定义字段特性 本章的后半部分介绍创建字段查找和利用 Delphi 自带的 customer.db 和 orders.db 表使用 TField 组件。 在 Demos\DB\MastApp 中携带的 MastApp.dpr 工程使用这些演示表创建一个顾客定购系统。Delphi 自带的 查找例子说明了怎样根据 City 和 State 字段自动返回邮政编码。接下来定义数据库字典,更进一步地增强 对表的操作,以改进这些表。 第 14 章 使用数据控件 368 本部分开始的第 1 步到第 5 步中,您有机会创建一个新的数据字典。接下来的步骤是定义字段特性。 为说明起见,我们将创建 CustNo、State 和 LastInvoiceDate 的特性。 显示格式 使用 DisplayFormat 特性您可以定义一个值怎样显示给用户。假定您想让 customer.db 中的 顾客编号字段(CustNo)附带描述这个值的文本,您可以用这种方法给 CustNo 字段定义一个定制的 DisplayFormat(见图 14.17)。 1.在本节开始时创建的数据字典中,单击 SQL Explorer 的 Dictionary 属性页上的 Attributes 列表项。 2.右击 Attributes 打开 Attributes 的上下文菜单,单击 New 项。 图 14.17 数据字典,显示 Chapter14 字典中的 CustNo 特性定义 3.将字段命名为 CustNo。 4.在右边的 Definition 属性页中,找到 DisplayFormat 字段,然后输入 Cust# 00000。Cust#是符号文本, 00000 代表字段值的五位数字的掩码。 5.单击 Apply 保存 Attribute 的定义。 6.(参考 14.8.2 节“将字典同 DataSet 相关联”,将 CustNo 特性同 CustNo 字典相关联)。 现在,不管在哪里,只要在应用程序中使用到 CustNo,这个特性都已经同 CustNo 相关联了,其显示 格式也是一致的。 定义约束 假如 Marine Adventures(一个虚构的公司)使用 MastApp 应用程序只在一部分州提供销售。 要纠正这个用 Delphi 实现的电子商务站点中的问题,可以定义 CustomConstraint 特性来排除某些州不能完 成的偶然定购事件。 要给 State 特性创建 CustomConstraint,重复前面部分的第 1 步到第 3 步,将这个新 r 命名为 state。在 Definition 属性页上,双击 CustomConstraint 定义打开 Expression Editor。输入这个约束: X in ('MI','OH','IN','IL') 单击 Validate 按钮。如果约束有逻辑或者语法错误,将显示一个 Database Engine Error 对话框,否则 无效的约束将产生无效的响应。注意 X 的使用,您可以使用任何不同的名称代表这个字段名称。添加 ConstraintErrorMessage,以确保在发生约束异常的时候显示用户可以理解的文本。 定义特性编辑掩码 字典特性的 EditMask 同 TField 使用的 EditMask 类型一样。在字典中定义掩码, 您只需要给每一个 Attribute 类型定义一次,例如电话号码、日期和时间。要定义日期编辑掩码,该掩码能 同 Customer.LastInvoiceDate 一起使用,创建新的特性,命名为 date,在 EditMask 的定义字段中输入合适 的日期编辑掩码。合适的日期编辑掩码将确保用户输入有效的日期和时间。这里有一个例子: !99/99/0000;1;# 第 14 章 使用数据控件 369 上面的编辑掩码,对于月份和日期需要一位或者二位的数字,对于年份需要四位数字。1 表示掩码以 这个值保存。#是每一个数字的间隔符号。 使用显示掩码定义 EditMask,如 mm/dd/yyyy,用以提供一个惟一的显示方式。mm/dd/yyyy 掩码当用 户对日期和月份只输入一个数字的时候,会自动添加 0,用户不必要输入 0。 指定 TFieldClass 和 TControlClass 字典中最先的两个特性是 TFieldClass 和 TControlClass。TFieldClass 定义为 TField 的类。这表示任何 TField 的子类的名称都可以输入到 TFieldClass 特性中,当一个字段被添加到该 DataSet 中的时候这个 TField 的类型被创建;如果该特性为空,那么这个 Tfield 类型的创建依赖于基本数据类型。 提示:要从 TField 组件创建一个数据感知控件,从字段编辑器拖放一个字段到窗体上。该 操作将自动创建一个标签和数据控件,并且如果数据字典特性同该字段相关联那么将更新数 据控件的特性。 TControlClass 表示当字段被拖放到窗体上的时候,将被创建的控件的种类。例如,如果 TControlClass 是一个 TDBComboBox,那么当您从字段编辑器中拖放一个 TField 组件到窗体上的时候,TDBComboBox 控件将被添加到窗体以代表这个 TField 组件。 14.8.2 将字典同 DataSet 相关联 要使用数据字典中的定义,您必须将该特性类型同指定的字段相关联。如果您定义一个普通的 State 特性,那么对于任何 TField 字段,如果您想让这个字段具有那些原始的特性设置,您必须将这些字段同数 据字典特性关联起来。 要将数据字典特性同特定的字段关联起来,打开字段编辑器然后单击您想接收字典特性的字段。右击 该字段显示字段编辑器上下文菜单,然后单击 Associate attributes。在 Associate attributes 对话框中,选择 该字段要关联的特性名称,然后单击 OK 按钮。单击同一个上下文菜单中的 Retrieve attributes 更新被关联 的特性。 字段编辑器菜单也可用于更新数据字典。如果您改变了字段特性,并且决定所有共享这个特性的字段 都应用特性的变化,则单击 Save Attributes。另外一种选择是,选择字段编辑器菜单中的 Save Attributes as, 创建新的字典特性。 14.9 创建定制的数据控件 定制的数据感知控件的开发同任何其他组件的开发一样。在建立新的定制控件之前,回答下面的问题: 1.组件通常解决什么样的问题? 2.现有的什么控件最匹配需要创建的数据控件? 一个很好的条件是在 Delphi 的 VCL 中已有和数据控件很接近的控件,可以将其作为定制数据控件的 基础。 除了继承现有的组件外,数据感知控件需要一个 TDataLink 对象。TDataLink 类是辅助类,它提供了 到 DataSource 和 DataSet 的连接。稍后我们将回到 DataLink。我们首先定义要创建的组件,步骤如下。 为了说明的目的,我们需要一个数据感知日历。DateTimePicker 在 Win32 VCL 属性页中;它可以提供 除了数据不可视之外的任何特性。它在显示上是很有吸引力的,适合专业开发,很可能为开发者所熟悉。 通常所需要的步骤如下。 1.定义该问题。 2.找到现有的完整的匹配控件(如果可以的话)。如果没有吻合匹配的控件存在,那么选择匹配程 度最高的组件。 3.打开 Delphi。 4.创建一个新单元。 第 14 章 使用数据控件 370 5.编写该类的主体,包括定义类(我们从该类继承子类)的单元和任何辅助单元,定义我们要继承 的最匹配的类。 6.将 TFieldDataLink 私有成员添加到该类中,定义一个构造函数和析构函数,将 DataLink 对象连接 到该现有的组件中。 7.添加认为需要的其他特性和方法。 8.确定该数据感知组件是否可复制,插入必要的因素(请参考 14.9.2 节“使控件可复制”)。 9.最后,测试组件。当完成测试的时候将该组件安装到组件包中。 第 1 和第 2 步就不多说了。数据感知日历组件可以从现有的 TDateTimePicker 创建。第 3 步告诉您怎 样做。要完成第 4 步,选择 File 菜单中的 New 菜单中的 Unit 菜单项,使用 New Component 对话框(该对 话框也完成第 5 步的大部分工作)很简单(如果这部分有问题的话,请参考 9.2 节“使用组件向导”)。 我们将从第 6 步开始继续这个过程。 14.9.1 添加 TFieldDataLink 组件 TFieldDataLink 在后台工作,它作为一个辅助类将一个控件连接到一个字段或者表。在我们的例子中, 我们将根据约定将新组件命名为 TDBDateTimePicker。 该类需要一个 TFieldDataLink 成员。因为在内部 TFieldDataLink 作为一个辅助类,它将被声明为私有。 当您将控件连接到 TFieldDataLink 的 FieldName 和 DataSource 特性时,该控件就可以连接到数据库中的字 段上。该组件需要一个构造函数创建 DataLink 和析构函数释放 DataLink。同其他的数据控件一样,我们将 命名数据库连接特性 DataField 和 DataSource。最后,控件需要响应基本数据字段的变化、用户从鼠标和键 盘的输入,用户或者程序对控件的更改需要反映到基本字段和数据集中。其结果如下: TDBDateTimePicker = class(TDateTimePicker) private { Private declarations } FDataLink : TFieldDataLink; procedure DataChange( Sender : TObject ); procedure EditingChange( Sender : TObject ); function GetDataField : String; function GetDataSource : TDataSource; function GetField : TField; function GetReadOnly : Boolean; procedure SetDataField( const Value : String ); procedure SetDataSource( Value : TDataSource ); procedure SetEditReadOnly; procedure SetReadOnly( Value : Boolean ); procedure UpdateData( Sender : TObject ); procedure CMExit(var Message : TCMExit); message CM_EXIT; protected { Protected declarations } procedure Change; override; procedure Click; override; procedure CreateWnd; override; procedure KeyDown( var Key : Word; Shift : TShiftState ); override; procedure KeyPress( var Key : Char); override; procedure Loaded; override; procedure Notification( AComponent : TComponent; Operation : TOperation ); override; public { Public declarations } constructor Create( AOwner : TComponent ); override; destructor Destroy; override; 第 14 章 使用数据控件 371 function ExecuteAction( Action : TBasicAction ) : Boolean; override; function UpdateAction( Action : TBasicAction ) : Boolean; override; property Field : TField read GetField; published { Published declarations } property DataField : string read GetDataField write SetDataField; property DataSource : TDataSource read GetDataSource write SetDataSource; property ReadOnly : Boolean read GetReadOnly write SetReadOnly default False; end; 注意:不要把 Object Pascal 字段、类的数据特性同数据集中所指的字段相混淆,文字表示 形式一样但其意义却依赖于构造组织。 表 14.4 中简单地描述了每一个成员的作用。 完整的代码在下一节。只用表 14.4 列出的特性和方法,该控件除了不能同 DBCtrlGrid 一起工作之外 都可以正确地工作。要使该控件同 DBCtrlGrid 一起正常工作所需要的其他特性和完整的代码列在下一节 中。 表 14.4 TDBDataTimePicker 的特性和方法 TDBDateTimePicker 的成员 在类中的角色 FDataLink 继承于 TFieldDataLink,提供到数据集的连接 DataChange 事件处理程序,响应 TFieldDataLink 的 OnDataChange 事件 EditingChange 当数据库绑定变化的时候确保控件是只读的 GetDataField 和 SetDataField 读和写 DataField 特性的辅助方法 GetDataSource 和 SetDataSource 读和写 DataSource 特性的辅助方法 GetField 读公有 Field 特性的辅助方法;返回相关联的 TField 对象 GetReadOnly 和 SetReadOnly 读和写 ReadOnly 特性的辅助方法 SetEditReadOnly 强加 TDataSet 的 CanModify 特性 UpdateData 响应 TFieldDataLink.UpdateData,用控件数据的拷贝更新基本字 段值 CMExit 调用 TFieldDataLink.UpdateRecord;如果更新失败那么将未更新 部分放置在控件上 Change 将数据链接设置为编辑模式 Click 同 Change 方法的功能一样 CreateWnd 调用 CreateWindowEx(一个 Windows API 函数)创建该基本窗口 KeyDown 将数据链接设置为编辑模式 KeyPress 验证基本字段类型的输入,将数据链接设置为编辑模式,或者如 果按的是 Esc 键则复位该字段值 Loaded 在从 DFM 文件读出所有的特性之后更新控件的显示值 Notification 如果被参考的数据源组件被删除了,那么更新数据源的引用 Create 构造 FDataLink,将 TFieldDataLink 的事件特性连接到内部的事件 处理程序中 Destroy 释放 FDataLink 对象 ExecuteAction 处理控件的 Action 参数和 DataLink 的 ExecuteAction UpdateAction 当 Action 即将更新事件时执行更新行为 第 14 章 使用数据控件 Field 只读特性,该特性调用 GetField 方法返回相关联的字段引用 DataField 包含被参考字段名称的字符串 DataSource 被引用的 TDataSource 组件 ReadOnly 反映 DataLink 的只读状态 14.9.2 372 使控件可复制 DBCtrlGrid 的工作机理是:复制所有的控件到栅格的一个面板中,更新栅格面板索引所引用的记录的 显示值。DBCtrlGrid 在内部完成所有这些工作,但是你必须确保控件知道怎样绘制这些记录的副本。 第一个需要做的更改是添加 TPaintControl 对象,该对象维持一个它自己的 Windows 句柄,当控件需 要绘制和更新显示值的时候,该对象可以响应这个消息。另外,控件需要响应 CMGetDataLink 消息。 DBCtrlGrid 发送 GETDATALINK 消息,返回一个到 FDataLink 对象的引用。DBCtrlGrid 使用 FDataLink 来读附加的字段值。一个 WMPaint 消息方法也被添加进来了。当出现绘制消息的时候,如果 ControlState 包含 csPaintCopy,那么 TDBDateTimePicker 将给 PaintControl 对象发送消息。最后,在解除 PaintControl 句柄的时候,WndProc 函数被跳过。下面摘引的代码显示了使用 PaintControl 的代码定义和附加的方法。 procedure TDBDateTimePicker.WMPaint( var Message : TWMPaint); var D : TDateTime; T : TSystemTime; begin if csPaintCopy in ControlState then begin if FDataLink.Field <> nil then D := FDataLink.Field.AsDateTime else D := Now; DateTimeToSystemTime(D, T); SendMessage(FPaintControl.Handle, DTM_SETSYSTEMTIME, 0, Longint(@T)); {$R-} SendMessage(FPaintControl.Handle, WM_PAINT, Message.DC, 0); {$R+} end else inherited; end; procedure TDBDateTimePicker.WndProc(var Message: TMessage); begin if not (csDesigning in ComponentState) then case Message.Msg of WM_CREATE, WM_WINDOWPOSCHANGED, CM_FONTCHANGED: FPaintControl.DestroyHandle; end; inherited WndProc(Message); end; 在上面没有列出的部分是构造函数和析构函数。PaintControl 对象必须被构造和解除。它有两个参数: 所 有 者 和 类 名 ( 已 注 册 的 Windows 类 的 类 名 , 它 用 于 绘 制 ) 。 TDateTimePicker 的 注 册 类 是 SysDateTimePick32。构造函数的调用是: FPaintControl := TPaintControl.Create(Self, 'SysDateTimePick32'); 第 14 章 使用数据控件 373 解除的调用是: FPaintControl.Free; 构造函数还将 csReplicatable 状态添加到 ControlState 特性。 WMPaint 的消息方法调用继承的消息处理程序,除非 ControlState 包含 csPaintCopy 值。如果对控件的 副本调用 Paint 函数,那么字段的事件将被转换成一个 TSystemTime。DTM_SETSYSTEMTIME 消息被发 送,TSystemTime 记录的地址以整数的形式被传递到 SysDateTimePick32 窗口,FPaintControl.Handle 引用 这个窗口。在副本日期和时间被更新之后,Paint 消息被发送;设备上下文——称作 Canvas 句柄——被发 送到 PaintControl 对象。 如果接收到 WM_CREATE、WM_WINDOWPOSCHANGED 或者 CM_FONTCHANGED 消息,WndProc 消息方法将并入解除 PaintControl 句柄的附加行为。最后,调用继承的 WndProc 方法。TDBDateTimePicker 控 件的完整列表如下所示。 unit UDBDateTimePicker; // UDBDateTimePicker.pas - A DateTimePicker/Data Control Custom // Component // Copyright (c) 2000. All Rights Reserved. // by Software Conceptions, Inc. Okemos, MI USA (800) 471-5890 // Written by Paul Kimmel interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, ComCtrls, Db, DbCtrls; type TDBDateTimePicker = class(TDateTimePicker) private { Private declarations } FDataLink : TFieldDataLink; FPaintControl : TPaintControl; procedure DataChange( Sender : TObject ); procedure EditingChange( Sender : TObject ); function GetDataField : String; function GetDataSource : TDataSource; function GetField : TField; function GetReadOnly : Boolean; procedure SetDataField( const Value : String ); procedure SetDataSource( Value : TDataSource ); procedure SetEditReadOnly; procedure SetReadOnly( Value : Boolean ); procedure UpdateData( Sender : TObject ); procedure CMExit(var Message : TCMExit); message CM_EXIT; procedure CMGetDataLink( var Message : TMessage); message CM_GETDATALINK; procedure WMPaint( var Message : TWMPaint); message WM_PAINT; protected { Protected declarations } procedure WndProc(var Message: TMessage); override; procedure Change; override; procedure Click; override; procedure CreateWnd; override; procedure KeyDown( var Key : Word; Shift : TShiftState ); 第 14 章 使用数据控件 override; procedure KeyPress( var Key : Char); override; procedure Loaded; override; procedure Notification( AComponent : TComponent; Operation : TOperation ); override; public { Public declarations } constructor Create( AOwner : TComponent ); override; destructor Destroy; override; function ExecuteAction( Action : TBasicAction ) : Boolean; override; function UpdateAction( Action : TBasicAction ) : Boolean; override; property Field : TField read GetField; published { Published declarations } property DataField : string read GetDataField write SetDataField; property DataSource : TDataSource read GetDataSource write SetDataSource; property ReadOnly : Boolean read GetReadOnly write SetReadOnly default False; end; procedure Register; implementation uses CommCtrl; procedure Register; begin RegisterComponents('PKTools', [TDBDateTimePicker]); end; { TDBDateTimePicker } constructor TDBDateTimePicker.Create(AOwner: TComponent); begin FDataLink := TFieldDataLink.Create; inherited Create(AOwner); ControlStyle := ControlStyle + [csReplicatable]; FDataLink.Control := Self; FDataLink.OnDataChange := DataChange; FDataLink.OnUpdateData := UpdateData; FDataLink.OnEditingChange := EditingChange; FPaintControl := TPaintControl.Create(Self, 'SysDateTimePick32'); FPaintControl.Ctl3DButton := True; end; destructor TDBDateTimePicker.Destroy; begin FPaintControl.Free; FDataLink.Free; FDataLink := nil; inherited Destroy; end; 374 第 14 章 使用数据控件 procedure TDBDateTimePicker.CreateWnd; begin inherited CreateWnd; SetEditReadOnly; end; procedure TDBDateTimePicker.WMPaint( var Message : TWMPaint); var D : TDateTime; T : TSystemTime; begin if csPaintCopy in ControlState then begin if FDataLink.Field <> nil then D := FDataLink.Field.AsDateTime else D := Now; DateTimeToSystemTime(D, T); SendMessage(FPaintControl.Handle, DTM_SETSYSTEMTIME, 0, Longint(@T)); {$R-} SendMessage(FPaintControl.Handle, WM_PAINT, Message.DC, 0); {$R+} end else inherited; end; procedure TDBDateTimePicker.WndProc(var Message: TMessage); begin if not (csDesigning in ComponentState) then case Message.Msg of WM_CREATE, WM_WINDOWPOSCHANGED, CM_FONTCHANGED: FPaintControl.DestroyHandle; end; inherited WndProc(Message); end; procedure TDBDateTimePicker.DataChange(Sender: TObject); begin if FDataLink.Field <> nil then DateTime := FDataLink.Field.AsDateTime else if( csDesigning in ComponentState ) then Datetime := Now; end; procedure TDBDateTimePicker.Change; begin FDataLink.Edit; inherited Change; FDataLink.Modified; end; 375 第 14 章 使用数据控件 procedure TDBDateTimePicker.Click; begin FDataLink.Edit; inherited Click; FDataLink.Modified; end; procedure TDBDateTimePicker.CMExit(var Message: TCMExit); begin try FDataLink.UpdateRecord; except SetFocus; raise; end; DoExit; end; procedure TDBDateTimePicker.CMGetDataLink(var Message: TMessage); begin Message.Result := Integer(FDataLink); end; procedure TDBDateTimePicker.EditingChange(Sender: TObject); begin SetEditReadOnly; end; function TDBDateTimePicker.ExecuteAction(Action: TBasicAction): Boolean; begin Result := inherited ExecuteAction(Action) or (FDataLink <> nil) and FDataLink.ExecuteAction(Action); end; function TDBDateTimePicker.GetDataField: String; begin Result := FDataLink.FieldName; end; function TDBDateTimePicker.GetDataSource: TDataSource; begin Result := FDataLink.DataSource; end; function TDBDateTimePicker.GetField: TField; begin Result := FDataLink.Field; end; function TDBDateTimePicker.GetReadOnly: Boolean; begin 376 第 14 章 使用数据控件 Result := FDataLink.ReadOnly; end; procedure TDBDateTimePicker.KeyDown(var Key: Word; Shift: TShiftState); begin inherited KeyDown(Key, Shift); if Key in [VK_BACK, VK_DELETE, VK_UP, VK_DOWN, 32..255] then begin if not FDataLink.Edit and (Key in [VK_UP, VK_DOWN]) then Key := 0; end; end; procedure TDBDateTimePicker.KeyPress(var Key: Char); begin inherited KeyPress(Key); if (Key in [#32..#255]) and (FDataLink.Field <> nil) and not FDataLink.Field.IsValidChar(Key) then begin MessageBeep(0); Key := #0; end; case Key of ^H, ^V, ^X, #32..#255: FDataLink.Edit; #27: begin FDataLink.Reset; end; end; end; procedure TDBDateTimePicker.Loaded; begin inherited Loaded; if (csDesigning in ComponentState) then DataChange(Self); end; procedure TDBDateTimePicker.Notification(AComponent: TComponent; Operation: TOperation); begin inherited Notification(AComponent, Operation); if (Operation = opRemove) and (FDataLink <> nil) and (AComponent = DataSource) then DataSource := nil; end; procedure TDBDateTimePicker.SetDataField(const Value: String); begin FDataLink.FieldName := Value; end; procedure TDBDateTimePicker.SetDataSource(Value: TDataSource); 377 第 14 章 378 使用数据控件 begin if not (FDataLink.DataSourceFixed and (csLoading in ComponentState)) then FDataLink.DataSource := Value; if Value <> nil then Value.FreeNotification(Self); end; procedure TDBDateTimePicker.SetEditReadOnly; begin SendMessage(Handle, EM_SETREADONLY, Ord(not FDataLink.Editing), 0); end; procedure TDBDateTimePicker.SetReadOnly(Value: Boolean); begin FDataLink.ReadOnly := Value; end; function TDBDateTimePicker.UpdateAction(Action: TBasicAction): Boolean; begin Result := inherited UpdateAction(Action) or (FDataLink <> nil) and FDataLink.UpdateAction(Action); end; procedure TDBDateTimePicker.UpdateData(Sender: TObject); begin FDataLink.Field.AsDateTime := DateTime; end; end. 14.10 小 结 第 14 章介绍的内容很丰富。首先简短地讨论了两层和三层设计,接着介绍怎样使用一些令人感兴趣 的数据控件。数据感知控件常被用于建立两层的应用程序。但是,如果您使用 MIDAS 组件,那么您可以 在三层应用程序中使用数据感知控件,您在下一章将看到。 到目前为止您已经学习了怎样使用 DBGrid、 DBChart 和使用 DBRichEdit 控件流入和流出字段数据。 本章讨论了动态和静态 TField 组件之间的区别,强调了使用静态 TField 组件,而不要使用动态 TField 组 件的原因。本章的最后详细地讨论了创建定制的数据感知控件。创建数据感知控件的关键是 TFieldDataLink;要使数据感知控件同 DBCtrlGrid 一起工作必须使用 PaintControl 对象作为辅助。有关更多 的定制数据控件的资料可以参考 DBCtrls.pas 单元。 第 15 章 MIDAS 编程 多层分布式应用服务(Multitiered Distributed Application Services,即 MIDAS)套件是一套组件,使用 这些组件可以很容易地建立多层的客户-服务器数据库应用程序。这也是公司愿意为 Delphi 企业版支付数 千美元的原因之一,而它确实物有所值。如果您购买了个人版本的 Delphi,那么必须再购买企业版才能得 到 MIDAS 组件和相应的动态链接库(DLL)。如果您使用的是标准版或专业版,那么本节中的例子将无法 使用。但您仍然可以阅读一下本节,来看一看 Delphi 企业版是否适合您。 对另一些人来说,本节将通过例子来演示如何使用 MIDAS 的一些核心功能。通过示范如何利用一些 核心控件来建立客户和服务器程序,对这些功能进行了演示;共有三个例子示范了这些控件:一个动态查 询程序,它使用 DCOM 连接到同一台计算机和远程机器上的服务器,另一个程序示范了出错情况下的恢 复,还有一个公文包程序的例子。 请记住:客户程序通常有图形用户界面,并且与用户进行交互。而服务器是向客户程序提供服务的应 用程序。客户-服务器这个术语隐含着图形用户界面与数据库服务器。n 层、多层或三层这些术语,大体上 也是同样的意思。第一层是客户程序,中间层或第二层包含了商务规则的编码,通常是应用服务器,而最 后一层是数据库服务器自身(参见图 15.1)。本章提供了一些例子程序进行示范,其中客户端是用 Delphi 实现的标准 Windows 可执行文件,而中间层则是 MIDAS 和用 Delphi 实现的进程外 COM 服务器——自动 化服务器,此外还需要适当的数据库。为避免创建难于理解的例子,本章中只使用了 DBDEMO 表和本地 Interbase 数据库。请记住,任何数据库服务器,如 SQL Server、Oracle 或 Sybase 等,在客户程序和中间层 的代码不进行改变或改动很少的情况即可使用。 图 15.1 基本的三层客户-服务器应用程序配置,分别使用了三台物理上独立的计算 机示范了每一层的不同作用。所有的三层可以都位于同一台物理计算机上 第 15 章 MIDAS 编程 390 15.1 MIDAS 组件概述 本节中讨论了通常可能用到的组件。用于实现三层应用程序的 MIDAS 组件分为客户程序的组件和服 务器程序的组件。另外,可能还需要一些通常用于建立客户程序的其他组件。 注意:这里并未提供对 MIDAS 组件的详尽描述。MIDAS 套件是非常广泛的,现在看来还没有 专门讲述 MIDAS 的 Delphi 书籍。 MIDAS 为开发者提供了客户程序与服务器程序之间的桥梁。一旦创建了包含 TRemoteDataModule 对 象的服务器程序,然后即可建立客户程序,就像是两层应用程序一样。即,可以根据个人的喜好选择是否 使用数据感知控件,而无论怎样都可以在客户程序中得到并使用相关的数据,就像是已经了解了有关数据 库的知识一样。由于中间层的服务器程序是新出现的部分,我们从用于建立应用服务器的组件开始。 15.1.1 定义服务器应用程序 在两层的客户-服务器应用程序中,包括数据库服务器以及数据感知客户程序。客户程序由程序员编写, 而服务器则是数据库应用程序。在三层系统中,客户与数据库层之间添加了应用服务器层。本节示范了用 于建立服务器程序的一些基本的组件。 注意:请记住,在 n 层、三层和多层结构之间并无实际的区别。对于我们的目的来说,它们 是同样的;至于是否存在区别,则是一个有待确定的问题。 TRemoteDataModule TRemoteDataModule 是 TDataModule 的后代,其用法也大致相同。在服务器程序中,可将远程数据模 块作为所有非可视组件的容器使用。TRemoteDataModule 实现了 IAppServer 接口,只需向标准的应用程序 中添加该类的对象,即可实现需要向客户程序提供的功能。 要创建 MIDAS 服务器,首先在 Delphi 中启动一个标准的应用程序工程。从 New Items 对话框的 Multitier 属性页中,向工程添加一个远程数据模块对象。创建远程数据模块的向导过程如下所示(见图 15.2),其 中需要选定 CoClass 的名字、实例化方法以及线程模型。提供了这些信息后,Delphi 将创建类型库和新的 远程数据模块子类,该子类由 TRemoteDataModule 子类化而来,并继承了所定义的 CoClass 接口。 MIDAS 应用服务器是一个自动化服务器。可以向接口添加一些功能,并在远程数据模块中进行实现 (参见 15.2 节“对 MIDAS 服务器进行查询”,其中的例子实现了一个接口,返回服务器可以访问的所有 表名)。远程数据模块将实现 UpdateRegistry 方法,该方法负责在第一次运行程序时向 Windows NT 注册服 务器。 无须向远程数据模块添加额外的功能,但需要添加一些组件,至少包括一个 TProvider 和一个 TDataSet 组件。添加 TDatabase 和 TSession 组件也很有用。在 Delphi 专业版中引入了数据集、数据库和会话组件, 它 们 与 用 于 建 立 两 层 数 据 库 应 用 程 序 的 组 件 是 相 同 的 。 TProvider 与 MIDAS 一 同 发 布 , 包 括 TDataSetProvider 和 TXMLTransformProvider 两种组件。 图 15.2 远程数据模块的向导过程将子类化 TRemoteDataModule 并生成类型库,用作 COM 服务器的接口 第 15 章 MIDAS 编程 391 扼要地重新叙述一下,一个 MIDAS 服务器程序包括一个 TDataSet 组件(如 TTable 或 TQuery),一个 TProvider 组件(如 TDataSetProvider 组件),一个 TDatabase 组件和 TSession 组件。下面我们简要地对这 些组件重新回顾一下。 TDataSetProvider TDataSetProvider 由 TBaseProvider 子类化而来。数据集提供者处于客户与服务器程序之间,它是客户 数据集的中介。提供者维护了一个对数据源数据集的引用以及 Options 特性,该特性描述了如何使用一个 特定的数据集提供者。 要使用数据集提供者,需要将 DataSet 特性赋值为 TNestedTable、TQuery、TTable 或 TStoredProcedure 等类型的对象。要使 TClientDataSet 类型的对象能够与数据集提供者通信, 需要将 Exported 特性设置为 True。 Options 特性有 14 个可用的值。例如,要使数据集提供者能够接收动态 SQL 语句,需要向 Options 集合添 加 poAllowCommandText 值。这项工作可以在设计时利用 Object Inspector 完成。对于 TProviderOptions 的 完整的解释,可以看一下 Delphi 的帮助文档。本章稍后建立例子程序时,我们将针对一些特定的设置进行 讨论。 TDatabase 向远程数据模块添加一个 TDatabase。数据库组件引用了 BDE(Borland 数据库引擎)别名或物理上的 数据库。如果存在已定义的别名,可以将其赋值给 TDatabase.AliasName 特性。Connected 特性打开或关闭 数据库。DatabaseName 特性指定与数据库关联的名字。如果 DatabaseName 特性是已存在的 BDE 别名,则 无须将该值赋予数据库组件的 AliasName 或 DriverName 特性。要定义新的数据库别名,只需添加 DatabaseName 和 DriverName 特性值,将 AliasName 特性置为空即可。 TDatabase.Params 特性类型为 TStrings,用于定义一些形如 name = value 的参数对,以便传递给要连接 的数据库。TDatabase.SessionName 特性是一个 TSession 组件的名字。 TSession TSession 组件用于管理数据库连接。多线程的数据库应用程序是其主要用途之一。将 AutoSessionName 设置为 True,可以保证服务器的每个实例都具有惟一的会话名;在多个客户与多个 TRemoteDataModule 对象实例进行连接时,这是必需的(更多的信息请参见 TClassInstance 和 TComponentFactory 类) 。 TDataSet TDataSet 是 TTable、TQuery、TNestedTable 和 TStoredProcedure 的祖先类。对于 TDataSetProvider 类 的 DataSet 特性来说,上面提到的每个数据集组件都是可用的数据源(关于数据访问组件的完整细节,请 参见第 13 章)。 TDataSetProvider TDataSetProvider 是 MIDAS 客户与 MIDAS 服务器之间的桥梁。客户由 TDataSetProvider 对象得到数 据,而该对象则由 TDataSet 对象得到数据。而客户使用 TDataSetProvider 对象对数据库进行更新。 要使 TClientDataSet 对象从 MIDAS 服务器得到数据,必需设置 DataSet、Exported 和 ProviderName 特 性。上面提到过,DataSet 特性向 TDataSetProvider 对象提供数据库中的数据。而只有在 Exported 特性的值 是 True 的情况下,客户才能连接到数据集提供者,TClientDataSet.ProviderName 特性的值需要设置为提供 者的名字。下面列出的特性值示范了在远程数据模块中四个关键组件的基本设置(请注意,在列表中使用 了 TTable,实际上也可使用其余的三种数据集组件)。 TDatabase.DatabaseName = DatabaseName Tdatabase.SessionName = SessionName Session.AutoSessionName = True Tsession.SessionName = SessionName TTable.DatabaseName = DatabaseName TTable.SessionName = SessionName TTable.TableName = TableName 第 15 章 MIDAS 编程 392 TDataSetProvider.Table = TDataSet TDataSetProvider.Exported = True TDataSetProvider.Name = ProviderName 当 TClientDataSet.Active 特性设置为 True 时,数据库、数据集和会话将连接到所引用的数据集。 部署服务器 当服务器的实现和测试完成后,即可进行部署工作。类似于 InstallShield Express 的安装程序用于自动 化应用部署,它可用来部署 MIDAS 服务器。在部署 MIDAS 服务器时,需要安装服务器程序、MIDAS.DLL 和 STDVCL40.DLL。安装过程实际就是将这些文件复制到目标计算机,并根据文件类型进行注册。 注意:您可能会推测 STDVCL40.DLL 将变成 STDVCL60.DLL,以反映 Delphi 版本的变化。在本 书写作时(使用 Delphi 6 Beta 2),该文件的名字仍然是 STDVCL40.DLL。当您部署 MIDAS 应用程序时,应当意识到文件名可能会发生变化。 提示:在 Windows NT 和 Windows 2000 中,Run 对话框是通过单击 Start | Run 激活的。 要注册创建的 MIDAS 应用服务器,可以运行应用程序或使用/REGSERVER 开关运行程序。例如,给定 服务器 server.exe,在 Run 对话框中或 DOS 命令提示符下输入: server.exe /regserver 接着是使用 Run 对话框来注册 MIDAS.DLL 和 STDVCL40.DLL。 可以使用 Windows 自带的 regsvr32.exe 程序来注册这些支持 MIDAS 程序的 DLL。例如,假定这些 DLL 被复制或安装到 c:\winnt\system32 目录。 然后使用: regsvr32 c:\winnt\system32\MIDAS.Dll 即可为这些 DLL 在 Windows 注册表中添加相应的条目。 15.1.2 定义客户程序 在三层的 MIDAS 系统和与两层的客户-服务器系统中,客户程序是非常相似的。大部分基本的工作部 件都是相同的,只有微小的变化。首先,不再需要使用 Data Access 属性页上的数据集组件来从数据库得 到数据,而要使用 TClientDataSet。TClientDataSet 是 TDataSet 的子类,通常与一些数据感知控件进行协作, 例如 TTable、TQuery 以及其他数据集组件。例如,可以像其他数据集组件一样调用 Fields 编辑器,并添加 静态 TField 对象;你在第 13 章中学到的许多特性都可以在 TClientDataSet 中使用。 除了 TClientDataSet 之外,还可能需要使用 TCustomConnection。该组件提供了与 MIDAS 服务器之间 的连接。例如,可以使用 TDCOMConnection 连接到远程机器上的服务器。表 15.1 列出了 TCustomConnection 组件,以及所支持的不同连接协议。 表 15.1 TCustomConnection 组件支持多种连接协议 连接类型 需求及描述 TDComConnection 支持到远程机器的 Microsoft 的 DCOM 连接,远程机器必须安装 DCOM TSockConnection 到远程应用服务器的 TCP/IP 连接,远程机器必须运行 scktsrvr.exe TWebConnection 使用 HTTP 协议连接到远程应用服务器;客户机必须安装 Wininet.dll。服务器必 须安装 IIS 4 或更高版本,或 Netscape 企业版 3.6 或更高版本。TWebConnection 所连接的 Web 服务器必须安装 Httpsrvr.dll(Httpsrvr.dll 与 Delphi 一同发布) TCorbaConnection 使用 CORBA 连接到应用服务器 至于客户程序的其余部分,我们在第 13 章中已经见到过。举例来说,如果要使用数据感知控件,则 需要 TDataSource 组件。TDataSource 组件的 DataSet 特性指向一个 TClientDataSet 类型的对象,而不是 TTable 或 TQuery。而你显然需要使用数据感知控件。实际上客户程序彼此非常相似,以至于在 15.3 节“错误处 理”中可以利用 Delphi 中的 Database Form 向导来创建示例程序。要将窗体转换为使用 MIDAS 服务器, 第 15 章 MIDAS 编程 393 只需利用向导把 TTable 替换为 TClientDataSet,并向标准的数据感知窗体添加一个 TDCOMConnection 组 件即可。 与服务器程序进行连接 客户程序需要用 TCustomConnection 组件连接到服务器程序。表 15.1 列出了可用的连接组件,可以根 据系统的部署情况进行选择。如果系统部署在企业内部网或 Internet 上,可以使用 TSocketConnection 或 TWebConnection。如果系统部署在同一物理网络上, 可以选择使用 TDCOMConnection 或 TCorbaConnection。 注意:要使服务器运行在远程计算机上并使用 DCOM,需要在远程计算机上安装并注册服务器、 MIDAS.DLL 和 STDVCL40.DLL。 每个 TCustomConnection 都实现了 AppServer 接口,这使得连接组件支持一致的接口而无须考虑所使 用的连接协议。例如,如果使用 TDCOMConnection 组件,需要提供 ServerGUID 或 ServerName 特性。在 定位服务器时,ServerGUID 更为可靠。如果 TDCOMConnection.ComputerName 特性是空的,则假定服务 器与客户位于同一台计算机上。添加远程机器名,则客户将在该计算机上运行服务器程序。要在设计时或 运行时连接到服务器,可以将 Connected 特性设置为 True。 注意:GUID,发音为 goo-id,是一个全局惟一的标识符。GUID 可确保是字符与数字的惟一 序列,它保证了 COM 对象在世界范围内是惟一标识的。 每种连接都有一些额外的特性,它们对于该协议是必须的。TSocketConnection 需要 IP 地址和主机名。 而 TWebConnection 则需要用户名和密码、URL(统一资源定位符)以及代理服务器名。TCorbaConnection 需要库 ID、主机名和对象名,对象名即应用服务器名。Corba 不是 Microsoft 协议,因此并不使用 ServerGUID。 TCorbaConnection.RepositoryID、TCorbaConnection. ObjectName 和 TCorbaConnection.HostName 三个特性 与 TDComConnection.ServerGUID、TDComConnection.ServerName 和 TDComConnection. ComputerName 三 个特性的作用是相似的。 配置 TClientDataSet 对象 TClientDataSet 对象代表了内存中的数据集。而您则需要提供 RemoteServer 特性和 ProviderName。 RemoteServer 特性是一个 TCustomConnection 组件,类似于 TDComConnection,而 ProviderName 是服务器 程序中 TDataSetProvider 对象的名字。 一旦连接到服务器和提供者之后,TClientDataSet 就可以像 Data Access 属性页上的两层数据集组件一样使用了。 因为 TClientDataSet 组件的作用类似于静态或动态数据集的入口,所以,如果服务器将表组件与数据 集提供者关联起来,那么客户端数据集支持与表相似的行为;如果服务器将查询组件与提供者关联,则客 户端数据集支持查询行为。将 SQL 语句赋值给 TClientDataSet.CommandText 特性,即可向服务器传递 SQL 语句。如果服务器端要支持动态 SQL,则 TDataSetProvider 必须设置 poAllowCommandText 选项,在 15.2 节“对 MIDAS 服务器进行查询”中可以看到。 添加数据源 如果在客户程序中使用与数据进行绑定的控件,那么除了连接和客户数据集组件以外,还需要 TDataSource 类型的对象。可将客户数据集的值赋予 TDataSource.DataSet 特性。我们在第 13 章中提到过, 需要将数据源赋值给数据感知 DataSource 特性,以便与数据库建立连接。如果不使用数据感知控件,则无 需数据源。 创建用户界面 MIDAS 套件的功能并不影响如何开发客户程序。由于 TClientDataSet 和 TConnection 组件负责管理与 服务器的关系,而 TClientDataSet 是由 TDataSet 子类而来,因此可以像使用 TTable、TQuery 或其他数据 集一样使用 TClientDataSet。关于两层与三层的 MIDAS 客户之间的相似性,我们将在 15.3 节“错误处理” 中提供了这方面的一个例子。 部署 MIDAS 客户 第 15 章 MIDAS 编程 394 MIDAS 客户程序需要在所有运行该程序的计算机上安装并注册 MIDAS.DLL。类似于服务器的部署, 可以使用 Windows 自带的 regsvr32.exe 程序注册 MIDAS.DLL。假定系统目录为 c:\winnt\system32,将该 DLL 复制到 system32 目录,使用下列命令即可进行注册:regsvr32 c:\winnt\system32\midas.dll。 15.2 对 MIDAS 服务器进行查询 上 一 节 涵 盖 了 MIDAS 套 件 的 客 户 程 序 与 服 务 器 程 序 的 一 般 性 的 例 子 。 服 务 器 程 序 使 用 TRemoteDataModule、TDatabase、TSession、TDataSet 和 TProvider。MIDAS 套件自带了两种数据集提供 者,分别是 TDataSetProvider 和 TXMLTransformProvider。在客户程序中,需要使用 MIDAS 的 TConnection 和 TClientDataSet 组件。而关于 MIDAS 客户-服务器开发的其他方面,则与使用其他工具和技术的客户服务器开发非常相似。 本节通过建立一个 MIDAS 动态 SQL 服务器程序示范了开发过程的各个方面。客户连接到服务器,并 得到可用表的列表。客户向服务器传递关于可用表的有效的 SQL 语句,服务器执行查询,并向客户返回可 用的结果集。查询的例子使用了 DCOM,这是个很好的机会,可以创建服务器并在远程计算机上进行测试。 该 程 序 中 使 用 了 BDE 别 名 DBDEMOS , 如 果 希 望 使 用 其 他 数 据 库 , 只 需 替 换 服 务 器 端 的 TDatabase.DatabaseName 组件即可。 15.2.1 服务器程序的实现 本例中的服务器程序可以列出数据库中的所有表名,并根据客户程序的请求返回这些名字。而其他的 所有事情都是通过组件完成的,看一下下面列出的代码就知道了。 unit UServerModule; interface uses Windows, Messages, SysUtils, Classes, ComServ, ComObj, VCLCom, DataBkr, DBClient, Server_TLB, StdVcl, DBTables, DB, Provider, MConnect, Variants; type TServerModule = class(TRemoteDataModule, IServerModule) Provider: TDataSetProvider; Database1: TDatabase; Query1: TQuery; Session1: TSession; private { Private declarations } protected class procedure UpdateRegistry(Register: Boolean; const ClassID, ProgID: string); override; function GetTableNames: OleVariant; safecall; public { Public declarations } end; var ServerModule : TServerModule; implementation {$R *.DFM} 第 15 章 MIDAS 编程 395 class procedure TServerModule.UpdateRegistry(Register: Boolean; const ClassID, ProgID: string); begin if Register then begin inherited UpdateRegistry(Register, ClassID, ProgID); EnableSocketTransport(ClassID); EnableWebTransport(ClassID); end else begin DisableSocketTransport(ClassID); DisableWebTransport(ClassID); inherited UpdateRegistry(Register, ClassID, ProgID); end; end; function TServerModule.GetTableNames: OleVariant; var I : Integer; TableNames : TStrings; begin TableNames := TStringList.Create; try Session1.GetTableNames( Database1.DatabaseName, '*.*', True, False, TableNames ); result := VarArrayCreate( [0, TableNames.Count - 1], varOleStr); for I := 0 to TableNames.Count - 1 do result [I] := TableNames[I]; finally TableNames.Free; end; end; initialization TComponentFactory.Create(ComServer, TServerModule, Class_ServerModule, ciMultiInstance, tmApartment); end. 惟一需要自己编写的代码就是远程数据模块中的 GetTableNames 方法。我们首先把程序的各个部分组 装起来,然后再看如何向服务器的接口添加 GetTableNames 方法,并对该方法进行简要的讨论。 创建服务器工程 可以像创建其他程序一样创建服务器程序。启动 Delphi 并使用缺省的新工程,或在 Delphi 运行时, 选择 File | New | Application 来启动新的工程。请记住,我们将在同一工程组中建立客户程序与服务器程序。 在保存该工程组时,要使用有意义的名字和位置。无需改动缺省的窗体,从 New Items 对话框的 Multitier 属性页中选择 RemoteDataModule 即可(请记住,只有在企业版的 Delphi 中 RemoteDataModule 才是可用 的)。然后将显示 Remote Data Module 向导(见图 15.2)。该向导过程中,您需要输入 CoClass(即 COM 接口类)的名字、实例化模式和线程模型。 COCLASS 名 当在 Remote Data Module 向导中输入 CoClass 名字时,实际是在定义自动化接口的类 名。该值将成为远程数据模块的 name 特性,加上前缀 T 后,就是数据模块的类名。新的远程数据模块继 承了 TRemoteDataModule 类并实现了 CoClass 接口。例如,如果在向导过程的 CoClass 域键入 server,那 么远程数据模块的类名就是 TServer,而 CoClass 名字则是 CoServer,接口是 IServer。使用给出的例子, 第 15 章 MIDAS 编程 396 数据模块中的类定义如下: TServer = class(TRemoteDataModule, IServer) 该模块的 var 语句如下: var Server : TServer; 继续上面的例子,将创建包含 Microsoft IDL(Interface Definition Language,接口定义语言)的类型库 server.tlb,以及包含接口定义的 Object Pascal 语法的 Pascal 文件 server_TLB.pas。 自动化服务器要支持的任何特性和方法都必须使用 Type Library 编辑器来定义(见图 15.3)。如果使用 类型库编辑器,则 Delphi 将负责维护类型库的 Pascal 代码和 Microsoft IDL 文件。稍后我们将继续讨论 Type Library 编辑器。 图 15.3 用于定义接口和管理 Microsoft IDL 的 Type Library 编辑器 实例化模型 实例化模型表示如何启动应用程序。有三种实例化模型,分别是 Internal Instance、Single Instance 和 Multiple Instance。对于进程内自动化服务器,可使用 Internal Instance 模型,即 DLL 服务器。 如果每个客户程序都运行服务器程序的一个实例,则使用 Single Instance 模型。如果客户程序共享服务器 程序,则使用 Multiple Instance 模型;但每个客户程序都有自己的服务器实例——即 Remote Data Module, 这些实例在同一进程空间中运行。 线程模型 可用的线程模型包括 Single、Apartment、Free、Both 和 Neutral。Single 线程模型的服务器 将序列化对 COM 对象的调用。而远程数据模块每次只处理一个请求,从而避免了多线程的问题。Apartment 线程模型对远程数据模块的单一实例,每次只允许发出一个请求;但可以有多个远程数据模块的实例存在, 每个实例分别处理不同的请求。Apartment 模型同样需要保护全局数据,以避免线程冲突。 提示:如果使用的数据库具有 BDE 功能,则需要在服务器中使用 TSession 组件,并将 AutoSessionName 设置为 True。 当使用 ADO 数据集时,推荐使用 Free 模型。在使用 Free 线程模型时,必须保护实例数据和全局数据, 以避免线程冲突。Both 模型与 Free 模型相同,但该模型将序列化对客户接口的回调。Neutral 模型只在 COM+ 中可用,否则它与 Apartment 模型是等同的。 定义远程数据模块 Query 服务器会返回数据库中可用表的列表。为了定义 GetTableNames 方法,需要将 GetTableNames 添加到接口中,并在远程数据模块中实现该接口。通过使用 Remote Data Module 向导和下列步骤,即可定 义示例程序的代码列表开始处所示的远程数据模块。 1.在一个新的工程中,单击 File,New,Other,并选定 New Items 对话框的 Multitier 属性页中的 Remote Data Module 图标。 第 15 章 MIDAS 编程 397 2.单击 OK,然后将显示 Remote Data Module 向导(见图 15.2)。 3.将 CoClass 命名为 Server。使用缺省的实例化和缺省的线程模型(缺省值分别是 Multiple Instance 和 Apartment)。 4.单击 OK 以创建类型库。 5.步骤 1~4 将生成远程数据模块单元、类型库的 Object Pascal 文件,以及包含 IDL 的文件。除了 GetTableNames 方法之外,其他的都已经在远程数据模块中定义了。 6.在 Object Inspector 中,将 TRemoteDataModule.Name 特性改为 ServerModule(避免与工程名 server 冲突)。从 View 菜单中,单击 Type library 以显示 Type Library 编辑器。 7.单击 IServerModule 接口。在 TypeLibrary 编程器中单击 New Method 按钮并填好该方法的 Parameters 属性页(图 15.4 显示了该按钮和接口定义)。 图 15.4 在类型库编辑器中创建 GetTableNames 接口(如图所示) 8.将该方法命名为 GetTableNames,并选择 Variant *类型和[out, retval]修饰符。 9.单击工具栏按钮 Refresh Implementation,类型库编辑器将更新远程数据模块,以包括 GetTableNames 方法的声明和定义(为空)。 通过上述步骤,类型库编辑器将定义如下的方法: function GetTableNames:OleVariant;safecall; 对自动化接口方法,必须使用 safecall 调用约定。为完成 ServerModule,我们需要添加必要的组件, 来把各个部分连接起来,并添加代码以实现 GetTableNames 方法。 添加会话对象 由于选择了 Apartment 线程模型和 BDE,我们需要将 TSession 组件的 AutoSessionName 特 性 设 置 为 True 。 从 组 件 面 板 的 BDE 属 性 页 向 远 程 数 据 模 块 添 加 一 个 TSession 对 象 , 并 设 置 AutoSessionName 特性设置为 True。这就自动地按格式 Session#_#更新 SessionName,确保会话的名字是惟 一的。 添加数据库 在本例中, 我们使用 DBDEMOS 数据库。DBDEMOS 分别在单独的文件中引用了 Paradox 和 DBase 的 实 例 表 。 由 于 DBDEMOS 别 名 是 存 在 的 , 因 此 我 们 只 需 将 DBDEMOS 作 为 TDatabase.DatabaseName 特性的值输入即可。 从组件面板的 BDE 属性页向 ServerModule 添加一个 TDatabase 组件,并把 DatabaseName 特性的值修 改为 DBDEMOS。而 SessionName 特性将自动设置为 TSession 组件的 SessionName 特性值。如果不使用存 在的别名,那么就需要对数据库组件输入 TDatabase.AliasName 和 TDatabase.DriverName 特性值,并对 TDatabase 组件的 Params 特性添加一些必要的连接参数。 添加查询 服务器程序的查询能力是通过 TQuery 组件提供的。从组件面板的 BDE 属性页添加一个 TQuery 组件,并对 TQuery.DatabaseName 特性输入与 TDatabase.DatabaseName 特性相同的值。对我们的例 子来说,只需输入 DBDEMOS。SessionName 特性将自动添加。 为确保所有一切都配置正确,把下列 SQL 语句添加到 TQuery.Strings 特性并将 TQuery.Active 特性设 第 15 章 MIDAS 编程 398 置为 True: select * from biolife 如果对 DatabaseName 特性选定了 BDE 别名 DBDEMOS,那么 Active 特性应一直设置为 True。如果 使用其他的 DatabaseName 值,请对 SQL 语句进行相应的改动。 添 加 TDataSetProvider 在 Delphi 的 专 业 版 和 企 业 版 中 , 都 提 供 了 前 三 个 组 件 。 而 TDatabaseSetProvider 位于组件面板的 Data Access 属性页上,只有 Delphi 企业版才提供该组件。 从 Data Access 属性页添加一个 TDataSetProvider 组件,并修改其 DataSet、Name 和 Options 特性。DataSet 特性应设置为上面添加的 TQuery 组件的名字。在本例中,该组件命名为 Provider。最后,在 Object Inspector 中向 TDataSetProvider.Options 特性添加 poAllowCommandText 选项。poAllowCommandText 使得客户可以 向数据集提供者传递动态 SQL 语句。 现在所有的组件都已经到位,剩下的工作就是定义 GetTableNames 方法。由于该方法依赖于远程数据 模块中组件的属性,因此我们必须首先添加这些组件。 编写 GetTableNames 方法的代码 TServerModule.GetTableNames 方法实现了一个 COM 接口,因此 我们只能使用 COM 所提供的数据类型。其中的一些相当巧妙。为从自动化服务器向客户传递表名的列表, 我们必须把这些名字填充到一个可变数组中(如果是 TStrings 对象就好了)。 function TServerModule.GetTableNames: OleVariant; var I : Integer; TableNames : TStrings; begin TableNames := TStringList.Create; try Session1.GetTableNames( Database1.DatabaseName, '*.*', True, False, TableNames ); result := VarArrayCreate( [0, TableNames.Count - 1], varOleStr); for I := 0 to TableNames.Count - 1 do result [I] := TableNames[I]; finally TableNames.Free; end; end; 该函数创建了一个 TStrings 对象,并调用 TSession.GetTableNames 方法,来把某个特定数据库中的表 名复制到字符串列表中。也可以使用 TDatabase.GetTableNames 方法,但会话组件中的方法可以更好地控 制所返回的表名的格式;该方法的第二个参数为文件掩码,用于指定所返回的表;第三个参数为布尔值, 用于指定是否返回文件扩展名。对于 DBDEMOS 的表,我们将得到.DB 和.DBF 表文件。代码的下一行创 建了一个可变数组,元素为 varOleStr(可变 OLE 字符串),下标从 0 到表个数减 1;然后将 TStringList 中 的表名复制到可变数组中,并返回结果。请注意,字符串列表 TableNames 是通过 try-finally 块创建和释放 的。代码看起来有些冗长,但要记住 OLE/COM 是一个 Microsoft 标准,代码只是进行了一些必要的迂回, 以便与 COM 进行协作而已。 这就是服务器端的工作。编译并运行服务器程序以便向开发程序的 PC 的注册表添加一个条目,这是 个必要的步骤,使得客户程序可以连接到服务器。下一步是创建客户程序。 15.2.2 实现客户程序 客户程序是模仿 Delphi 企业版中的 SQL Explorer 建立的(或者是 Delphi 专业版中的 Database Explorer)。 该程序在主窗体的左侧使用列表框列出可用的表,并使用了 PageControl 控件显示了两个属性页。第一个 属性页使用 TDBGrid 显示输出,而第二个属性页使用了一个 TMemo 控件,使得用户可以输入 SQL 语句。 第 15 章 MIDAS 编程 399 在图 15.5 中,一个 SQL 语句 select * from animals 已经发送到服务器,此时客户程序的当前焦点位于显示 数据的属性页。程序的图形用户界面非常直接,因此我们可以把注意力集中到用于与服务器程序进行连接 的组件。 添加 TDCOMConnection 组件 TDCOMConnection 组件位于 DataSnap 属性页,可以用作与服务器之间的连接组件。如果与客户程序 在同一台计算机上运行服务器程序,那么需要添加 ServerGUID 特性。当输入服务器程序的 GUID 时,Delphi 将更新与该 GUID 相关联的 COM 对象的 ServerName 特性。 图 15.5 MIDAS 查询演示应用程序的客户端程序 提示:也可使用 ServerName 特性连接到服务器程序,但 GUID 更为准确可靠。 服务器 GUID 是类型库中定义的 TGUID 值,亦即 Server_TLB.pas 中所定义的 CLASS_servername 常 数 。 在 我 的 计 算 机 上 , 定 义 该 GUID 的 语 句 是 CLASS_ServerModule: TGUID = '{5A873C25-A15A-11D4-9E2B-000000000000}';。从类型库中复制并粘贴服务器 GUID,或输入 ServerName, 则 TDCOMConnection 组件将自动填入正确的 GUID。 提示:当添加 GUID 时,包括起始和结束的括弧{}和 GUID 值。 如果要在远程计算机上运行服务器,除了在远程机器上安装并注册服务器之外,还需要安装 DCOM、 MIDAS.DLL 和 STDVCL40.DLL。最后,还要在 TDCOMConnection. ComputerName 特性中填入远程计算 机的名字。本例中将其设置为空即可。 添加 TClientDataSet 组件 客户端数据集类似于两层应用程序中通常的数据集。TClientDataSet 组件知道如何通过连接组件与服 务器中的数据集提供者进行通信。添加 TClientDataSet 组件,并在 TClientDataSet.RemoteServer 特性给出 所用的连接控件(在示例程序中,应是 DCOMConnection1) 。接下来选择提供者组件的名字。提供者组件 指的是服务器程序中的 TDataSetProvider 组件,该组件的名字是 Provider,因此 TClientDataSet.ProviderName 特性的值就是 Provider。 注意:在客户数据集组件中选择 ProviderName 将运行服务器程序。在选择了提供者组件的 名字后,可以将 TDCOMConnection.Connected 特性重置为 False,这样就可以关掉服务器程 序。 客户程序从 TMemo 控件得到 SQL 语句后,将把该语句赋值给 TClientDataSet.CommandText 特性。我 第 15 章 MIDAS 编程 400 们只需添加一个 TDataSource 组件,将数据源连接到 TClientDataSet 和 TDBGrid 组件,再编写少许几行代 码即可完成该应用程序。 将用户界面连接到 TClientDataSet 组件 TDataSource.DataSet 特性被赋值为客户窗体上的 TClientDataSet 组件。这进一步展示了面向对象编程 的威力。如果没有继承,就无法子类化 TDataSet 得到 TClientDataSet,结果会形成两个数据源类:一个特 定于 TClientDataSet,而另一个则对应于通常的 TDataSet。由于 TDataSource 组件已经从 Data Access 属性 页添加并连接到 TClientDataSet 组件,它现在必须赋值给 TDBGrid.DataSource 特性。 客户程序需要执行三个步骤,这些都必须由代码实现。第一步是在运行时连接到服务器,第二步是从 服务器应用程序返回的 OLEVariant 数组中读出和拆开表名。第三步是将 SQL 语句从 TMemo 控件传递到 TClientDataSet 组件。前两步可以由窗体的构造函数事件处理程序来实现,而第三步可以通过按钮单击事 件执行。这两个事件处理程序示范了所有必要的代码。 procedure TFormClientMain.FormCreate(Sender: TObject); var I : Integer; TableNames : OleVariant; begin DCOMConnection1.Connected := True; TableNames := DCOMConnection1.AppServer.GetTableNames; if VarIsArray(TableNames) then for I := 0 to VarArrayHighBound(TableNames, 1) do ListBox1.Items.Add( TableNames[I] ); end; procedure TFormClientMain.SpeedButton1Click(Sender: TObject); begin ClientDataSet1.Close; ClientDataSet1.CommandText := Memo1.Lines.Text; ClientDataSet1.Open; end; 事件处理程序 FormCreate 负责连接到服务器。服务器程序中的远程数据模块实现了 IAppServer 接口, 并包括了对 IAppServer 接口的可能的增强。我们向该接口添加了 GetTableNames 方法;如果像上面那样编 写,Delphi 将使用新的绑定技术将 GetTableNames 方法绑定到服务器所包含的实现。事件处理程序的其余 部分执行与 GetTablesNames 相反的操作,从可变数组中读出 varOleStr 值。 注意:关于编程风格的注记:在示例程序中使用事件处理程序是为了让您把注意力集中于所 发生的事情。这种风格对原型和例子程序最为合适。对于软件产品,最好用一些命名良好的 函数来实现这些行为,并在事件处理程序中调用这些行为。 执行 SQL 语句的事件处理程序只是简单地关闭 TClientDataSet 组件,用 TMemo 控件的内容更新组件 的 CommandText 特性,再打开 TClientDataSet 组件。客户和服务器的源代码都包含在随书配套的 CD-ROM 中。请记住,当部署客户程序时,需要在客户计算机上安装客户程序,以及安装并注册 MIDAS.DLL。 15.3 错 误 处 理 当有多个用户同时使用多个客户程序时,存在这样的可能性:两个用户在读取数据后企图更新同一数 据。用户 1 和用户 2 读取了一条记录,用户 1 对该记录进行更新,而用户 2 的记录数据就过期了。当稍后 用户 2 试图更新记录时,就会出现问题:究竟哪个值是正确的。这种矛盾必须得到解决。 Delphi 企业版在 Object Repository 中包含了一个 Reconcile Error 对话框。从 New Items 对话框的 Dialogs 第 15 章 MIDAS 编程 401 属性页中,可以将该窗体添加到您的 MIDAS 工程中,这样至少可以在该窗体中对错误进行处理。本节中 我们将仔细查看 Reconcile Error 对话框的各种特征,以及如何将其连接到 MIDAS 客户程序。为做到这一 点,从第 13 章中的基于 RenegadesDemo 工程和数据库我们建立了客户程序和服务器程序,这些程序都在 本书的 CD-ROM 上。在转到开始错误处理的新材料之前,让我们快速回顾一下如何建立应用程序。 15.3.1 建立客户与服务器示例程序 大多数情况下,都可以使用上一节中的服务器程序,并使用 Renegades 示例数据库。如图 15.6 中前台 所示(后台是客户程序) ,服务器增加了跟踪连接数目的特征。后台的客户程序用基本的 Database Form 向 导,使用 TClientDataSet 对象和 TDCOMConnection 对象代替了生成的 TTable 对象(如果需要复习,可以 阅读第 13 章中有关 Database Form 向导的章节)。要记住更新 TDataSource.DataSet 特性以指向客户数据集。 如果要对客户程序再多加一些修饰的话,可以增加 TMainMenu 和 TStatusBar 组件。由于我们要更新 数据库,因此在 File 菜单中除了 Connect 和 Exit 菜单项之外,还增加了 Apply Updates 菜单项。 对服务器定义的修改 在服务器程序的 Remote Data Module 向导中选择了 tmSingle Threading 模型。由于这是构造函数 TComponentFactory.Create 的 参 数 ThreadingModel 的 缺 省 值 , 因 此 在 远 程 数 据 模 块 初 始 化 部 分 的 TComponentFactor 的构造函数调用中并未看到该参数。 initialization TComponentFactory.Create(ComServer, TRenegadesModule, Class_Renegades, ciMultiInstance); TComponentFactory 类负责创建支持接口的 Delphi 组件。从上一节我们知道,Single Instance 线程模型 将序列化 COM 调用,这样就减少了在代码中提供线程支持的需要。 对线程模型的改变使得我们可以更新服务器窗体的标题。远程数据模块的每个实例——分别对应于 每个连接客户,可分别在服务器窗体上调用 UpdateUserCount 方法。当远程数据模块创建时计数加 1,在 远程数据模块释放时计数减 1。UpdateUserCount 驻留在服务器窗体的单一实例中,该方法只是简单地更 新整数计数器以及显示该计数器的标签(如图 15.6 所示)。 图 15.6 错误处理演示的客户和服务器 客户程序的增强 客户程序是通过 Database Form 向导迅速组装起来的。由于客户程序需要将改变更新到数据库文件, 我们向客户程序添加了 Apply Updates 菜单项。ApplyUpdates 的实现被添加到该菜单项的 Click 事件处理程 第 15 章 MIDAS 编程 402 序中。 procedure TFormMain.ApplyUpdates1Click(Sender: TObject); begin if( ClientDataSet1.ChangeCount > 0 ) then ClientDataSet1.ApplyUpdates(-1) end; 代 码 中 测 试 了 TClientDataSet.ChangeCount 特 性 。 如 果 确 实 发 生 了 改 变 , 将 用 参 数 -1 调 用 TClientDataSet.ApplyUpdates 方法,参数的意义是所允许的最多错误个数。使用-1 意味着当错误数目已达 到最大值时,并不停止更新。 警告:数据集提供者并不检测由于 TMemo 对象中的字段所引起的冲突。 要将修改保存到数据库中,用户必须调用 Apply Updates 行为。TClientDataSet.ApplyUpdates 生成对事 件处理程序 BeforeApplyUpdates 的调用,调用由 TClientDataSet.ProviderName 所表示的数据集提供者,并 生成对 AfterApplyUpdates 事件处理程序的调用(如果存在的话),然后调用 ReconcileError 事件处理程序 以 处 理 由 数 据 集提供 者 返回到 客 户数据 集 的错误 。 通过添 加 事件处 理 程 序 给 TClientDataSet.OnReconcileError,客户程序可以提供错误处理功能。 15.3.2 使用错误处理窗体 为提供错误处理的标准化格式,需要将 RecErr.pas 单元和相应的窗体添加到您的应用程序中。这可以 通过使用 New Items 对话框的 Copy 功能完成。在 New Items 对话框的 Dialogs 属性页上单击 Copy 单选按 钮,然后添加 Reconcile Error Dialog(本例中文件被保存为 RecErr.pas 和 RecErr.dfm)。 注意:为透彻了解错误处理窗体的功能,您可以在一台或多台 PC 上运行 Renegades 客户程 序的多个实例,只要连接到同一服务器程序实例即可。 客户程序与错误处理窗体之间的连接是通过 RecErr.pas 单元中的一个全局函数进行的。 function HandleReconcileError(DataSet: TDataSet; UpdateKind: TUpdateKind; ReconcileError: EReconcileError): TReconcileAction; 把 RecErr.pas 单元添加到客户窗体的 uses 子句,并在客户窗体中调用 HandleReconcileError。从 TClientDataSet.OnReconcileError 事件处理程序中,将 DataSet、UpdateKind 和 ReconcileError 异常传递到错 误处理窗体。这些都是事件方法的参数,因此客户代码显得非常直接。 procedure TFormMain.ClientDataSet1ReconcileError(DataSet: TCustomClientDataSet; E: EReconcileError; UpdateKind: TUpdateKind; var Action: TReconcileAction); begin Action := HandleReconcileError( DataSet, UpdateKind, E ); end; 当进行更新后,数据集提供者将返回所有出错的行。然后调用上面的事件处理程序,该方法又进而调 用全局函数 HandleReconcileError(参见图 15.7)。 在图 15.7 中,两个客户程序试图更新 Renegade Trevor MacDonald。错误处理对话框显示了修改后的值、 发生冲突的值以及原来的值。在窗体的底部,可以选定复选框以只显示发生窗体的字段,或取消复选框以 显示所有的字段。在对话框的上部,可以选择对错误进行调整的方案。调整方案是 HandleReconcileError 函数的返回值,类型为 TReconcileAction,它将决定客户程序的行为。对于调整方案的完整列表,请参见 表 15.2。 第 15 章 图 15.7 MIDAS 编程 403 从 Object Repository 错误处理窗体中,可在 MIDAS 客户-服务器系统中提供标准化的错误处理方法 提示:如果在错误处理对话框中选定调整方案为 Correct,那么标准的错误处理窗体允许在 该对话框中对修改错误进行更新。 表 15.2 TReconcileAction 值。可以使用来自 Object Repository 的标准的错误处理对话框, 也可以创建一个自定义版本,它们都返回 TReconcileAction 值,客户数据集和数据 集提供者使用这些值来解决记录之间的冲突 方案 TReconcileAction 值 描述 Skip raSkip 跳过错误,不更新冲突记录 Abort raAbort 停止错误处理 Merge raMerge 将更新记录与服务器上的版本合并 Correct raCorrect 使用新值替代更新记录 Cancel raCancel 清除所有的改变,返回到原来的值 Refresh raRefresh 清除所有的改变,使用服务器上的当前值替代该记录 使用标准的窗体,可以节约一些额外的工作。如果创建自定义错误处理窗体,需要编写代码显示修改 的、冲突的和原来的值,并允许用户选定解决方案。可以将错误处理对话框当做向导来使用。 15.4 公文包客户和服务器程序 有时候用户可能无法访问服务器。他们可能无法通过拨号连接或物理网络连接到服务器,或无法访问 HTTP 服务器,或者是在路上,等等。当需要建立用户在不连接到服务器也可使用的程序时,可以利用 TClientDataSet 的继承能力,向用户提供公文包支持。 提示:$(DELPHI)是一条编译器指令,它指向 Delphi 的安装路径。当相对于 Delphi 的安装 路径定义库和源代码的路径时,可用$(DELPHI)作为 Delphi 的虚拟路径。 添加支持的步骤是很简明的。为演示在客户程序中支持公文包模型所需的必要改动,我们借用了 $(DELPHI)\Demos\Midas\BrfCase\briefcase.bpg 工程。 惟一需要的改动是设置 TClientDataSet.FileName 特性。如果设置了 FileName 特性,TClientDataSet 对 象将从该文件中读写数据,当客户连接到服务器时再对服务器进行更新。如果无法连接到服务器,则对服 务器的改变将保存在文件中,直至服务器可用时再进行更新。 第 15 章 MIDAS 编程 15.5 小 404 结 本章示范了几种问题和相应的解决方案,您在开发三层客户-服务器程序时,可能会遇到这些问题。本 章的第一部分讨论了通过 DCOM 连接到服务器程序的基本技术。第二部分示范了对错误的调整。当可能 有多个客户程序在同一数据上进行工作时,更新冲突是不可避免的。第三部分演示了对移动应用的支持, 其中所需的改动极少。 由于没有冗长的代码列表,您可以确信 MIDAS 在支持分布式应用程序开发方面具有显著的优势。 MIDAS 支持 Web、Socket、DCOM 和 Corba 等连接模型,在保障用户连接到数据方面提供了强有力的手 段。在下一章中,您将学会如何利用 Delphi 中的这些强大设施来支持 Web 应用程序开发。 第 16 章 Intranet 与 Internet 编程 最早出现的网络协议分为两个集合,二者一同出现,分别是 TCP 和 IP。TCP(传输控制协议)和 IP (网际协议)通过协作,在局域网、内部网、外部网和 Internet 连接之上提供了一个逻辑层。局域网是计 算机通过导线和网卡(NIC 卡)在物理上连接起来,或像我家里和办公室那样,通过无线连接。楼下供孩 子用的计算机使用 AirEzy 2405 无线收发器,使得地下室中的计算机可以连接到我办公室的局域网,进而 又连接到 Internet。这样孩子们可以上网冲浪、玩 Half Life 游戏;而其他工作也可以同时进行。 TCP 协议并未涉及过多细节,其中包括了对报文头的描述,可以使数据在网络上漫游。TCP 协议会维 护状态信息,使得连接的两端可以相互了解。IP 协议向报文添加了特定的头部信息,用于对点分 IP 地址 进行分类和解析。例如,198.109.162.177 与域名 www.softconcepts.com 相关联。我的 ISP 服务提供商向 DNS (域名服务器)添加了一个名字项,将 www.softconcepts.com 与 198.109.162.17 关联起来。当向我的网站 收发信息时,IP 数据报中包括了寻址信息。在数据包由工作站移动到集线器再移动到网络服务器,通过铜 缆调制解调器(经 AT&T 许可),再经过路由器和其他服务器的过程中,TCP 和 IP 协议定义了跟踪数据的 手段。 其他协议如 Gopher 等在 TCP/IP 出现之前很流行,在 TCP/IP 层之上还有其他基于 TCP/IP 的协议,这 些协议都是具有特定用途的。例如,FTP(File Transfer Protocol,文件传输协议)用于方便网络上的文件 传输。UDP(User Datagram Packet,用户数据报)协议是无连接的,它由 TCP/IP 发展而来,并不维护客 户与服务器之间的连接信息,它在某些方面非常有用,如流类型的媒体,其中有一些报文丢失是可以接受 的。 如果想要深入了解各种协议的细节,包括 TCP/IP、FTP、UDP、Gopher 等等,可能需要找一本书来看 看,如 Que 出版的《Using TCP/IP》 ,作者是 John Ray。另一种方法是,可以查找特定的 RFC(Request For Comment)和白皮书,来阅读有关特定协议的所有细节。本章并不讲述有关协议的底层细节,但您可以学 到如何使用 Delphi 提供的各种组件和类,对大多数常见的协议进行编程。Delphi 6 中新增了来自 Nevrona 的 Internet Direct 组件。除了前一版本的 Delphi 提供的 Internet 和 Fastnet 组件之外,在 Internet Direct 或 Indy 中包括了许多客户和服务器端的组件来支持 TCP/IP、UDP、Echo、Finger、Gopher、HTTP、POP3、SMTP, NNTP 和 Telnet 等协议。对特定协议内容的简要评论可能较为有用,除此之外,本章还将示范许多新的客 户和服务器端组件。到本章结束,通过使用这些强大的组件,您可以学到大量 Internet 和内部网编程技术, 还可以在实例程序中很好的实践一下。 第 16 章 16.1 Intranet 与 Internet 编程 410 传输控制协议(TCP)组件 组件面板 Indy Clients 和 Indy Servers 属性页上的 IdTCPClient 和 IdTCPServer 组件直接支持 TCP 协议, 而 Internet 属性页上来自 Borland 公司的 TcpClient 和 TcpServer 组件也同样支持该协议。由于支持 TCP 的 Indy 组件较新,本节我们将把注意力集中于它们。请记住,两对组件都支持同一协议,而每对组件都可以 用 TCP 编程;而 Nevrona 和 Borland 的组件可能在特定的名字和特性上不同。 注意:所有的 Internet Direct(Indy)组件都以 Id 为前缀。 16.1.1 Indy 客户端 TCP 组件 通过给出服务器的主机名或 IP 地址,以及服务器程序所监听的端口号,一个 TCP 客户程序即可连接 到 TCP 服务器。对于 Internet 或 HTTP 来说,服务器程序的通用端口号是 80。其他已分配的通用端口号包 括:FTP 协议 20 和 21,Telnet 协议 23,SMTP 协议 25,Gopher 协议 70,POP3 协议 110。在命令行运行 netstat.exe,即可确定系统使用的所有端口号。下面的列表包括了在我的工作站上使用的一部分端口。 注意:在巴隆计算机术语词典中,将端口定义为 CPU 与另一设备(非内存)之间的连接,信 息可以通过该连接出入计算机。虽然该定义没有达到非常令人不满的程度,但端口确实是一 个被滥用的术语。将一根 5 型电缆插入到网络接口卡中,插口也可称为端口。对我们的目的 而言,端口是微处理器与物理设备之间的一个微小的物理连接。端口号告诉 CPU 向何处发送 数据。扬声器的端口号是$61。在向端口$61 发送比特值 0 和 1 之后,扬声器将发出稳定的蜂 鸣声。而发送另一个值清除 01 比特,就关掉扬声器。下面的步骤示范了直接向扬声器端口 发送数据的过程。 1.打开命令行窗口。 2.在命令行进入 Debug,运行 Debug 程序。 3.Debug 程序使用短划线(-)作为命令提示符。在 Debug 命令提示符后键入 o61,3,并敲回 车键。 4.然后可以听到扬声器发出蜂鸣声。键入 o61,0 将关掉扬声器。这些指令将直接从微处理 器发送到 61 端口。 Active Connections Proto Local Address Foreign Address State TCP ptk800:1025 SCI.TCIMET.NET:nbsession ESTABLISHED TCP ptk800:1482 64.124.41.224.napster.com:8888 ESTABLISHED TCP ptk800:1621 SCI.TCIMET.NET:1046 ESTABLISHED TCP ptk800:1625 SCI.TCIMET.NET:1072 ESTABLISHED TCP ptk800:1630 SCI.TCIMET.NET:1046 ESTABLISHED TCP ptk800:1634 SCI.TCIMET.NET:1072 ESTABLISHED TCP ptk800:1636 SCI.TCIMET.NET:nbsession ESTABLISHED TCP ptk800:1026 LocalHost:1029 ESTABLISHED TCP ptk800:1029 LocalHost:1026 ESTABLISHED 即使没有 Web 服务器来测试应用程序也不要担心;可以使用 LocalHost——127.0.0.1——地址来测试 客户和服务器程序。另外,也可以建立测试专用的 Web 服务器,在 Windows NT 下可以从 Network Applet (控制面板中的网络设置)中安装 Peer Web Server,而 Windows 98 中可以安装 Personal Web Server。要在 使用 Windows NT 的计算机上安装 Peer Web Server,打开控制面板上的 Network Applet。然后转到 Services 属性页,单击 Add 按钮。安装过程可能需要 Windows NT 光盘以完成安装。 netstat 输出的第二栏中是本地计算机名,后接端口号;第三个栏目是远程计算机,或接收主机和端口 号,后接状态。当 Foreign Address 栏中显示 LocalHost 时,指的是 IP 地址 127.0.0.1,即所谓的 loopback 地址,PC 机可以使用该地址引用其自身。LocalHost 的 URL 是 IP 地址 127.0.0.1。 第 16 章 Intranet 与 Internet 编程 411 注意:由于某些原因,使得运行 Peer Web Server 是个好主意。首先,规模较小的 Web 服务 器可以成为很好的单元测试平台;其次,它可以迅速而简易地建立起内部网的网站,用于在 公司内与您的项目有关的人共享信息。它对于团队开发是既有价值又较为廉价的工具。 IdTCPClient——Internet Direct TCP 客户组件,位于组件面板的 Indy Clients 属性页上;要测试该组件, 需要完成下列步骤并添加列出的代码。 1.Windows NT 下,在控制面板的 Network Applet 中添加 Peer Web Server(Windows 9x 下使用 Personal Web Server) 。默认情况下,该服务器将使用 LocalHost 地址的 80 端口以及 default.htm 页面。 2.启动 Delphi,向默认的空白窗体添加 TIdTCPClient 组件。 3.向窗体添加 TMemo 组件,可在其中放置 TCP 连接的输出。 4.向 FormCreate 事件处理程序添加下列代码。 procedure TForm1.FormCreate( Sender : TObject ); begin IdTCPClient1.Host := '127.0.0.1'; IdTCPClient1.Port := 80; IdTCPClient1.Connect; try IdTCPClient1.SendCmd('GET /default.htm' ); Memo1.Lines.Add( IdTCPClient1.CurrentReadBuffer ); finally IdTCPClient1.Disconnect; end; end; 警告:如果为 TIdTCPClient.OnStatus 编写事件处理程序,需要手工向 uses 组件添加 IdStatus,在该单元中包括了 TIdTCPClient.OnStatus 事件方法,否则将出现编译错误(当 您阅读到这里时,该问题可能已经解决)。 代码的第一行将主机的 IP 地址定义为字符串。可以使用 TIPAddress 组件,它可以方便地从用户输入 得到 IP 地址。第二行包含了端口号。对于 HTTP 协议,通常是 80 端口,但如果要通过代理服务器,可能 是别的端口号,像 8080。第三行使用指定的端口号连接到服务器。SendCmd 方法向进行响应的服务器发 送合适的字符串。GET 和 POST 对于 HTTP 服务器来说是合适的。命令字符串是否合适是依赖于协议的, 例如对于 21 端口上的 FTP 协议,其他的命令如 LIST 可能较为合适。如果要建立支持某个特定协议的程序, 可能需要子类化客户和服务器组件,以通过非基于文本的命令集。 如果在运行这个简单的示例时出现问题,可以打开 Windows NT 任务管理器并确认 inetinfo.exe 已经出 现在进程列表中。如果 inetinfo.exe(即 Peer Web Services)已经运行,可以打开 Internet Service Manager (从开始菜单中选择 Start | Program Files | Micorsoft Peer Web Services | Internet Service Manager 即可)。然 后确认 TCP 端口和 Home 目录(见图 16.1)。在安装 Peer Web Services 后 Web、FTP 和 Gopher 程序被注册 为服务程序。在系统启动时,这些程序自动启动。可以使用 Internet Service Manager 或 Services Applet(如 图 16.2 所示)来停止这些服务。也可以使用 Services Applet 使这些服务在启动时自动运行;如果您在使用 内部网,您可能希望这样设置。 第 16 章 图 16.1 Intranet 与 Internet 编程 412 Web Services Properties 对话框可以配置 Peer Web Server,或确认端口和目录信息 图 16.2 Peer Web Services 在 Windows NT 中作为服务运行, 默认情况下,在 Windows 启动时,该服务将自动启动 在本书的 CD-ROM 中包括了 TCPClient 程序的一个稍加完善的版本,TCPClient.dpr。TCPClient 程序 并不限于使用 HTTP 服务器。许多通用的协议是基于 TCP 的,这意味着您可以使用同一 TCPClient 程序连 接到特定类型的服务器。对前面的代码稍加修改,即可连接到 FTP 服务。 IdTCPClient1.Host := '127.0.0.1'; IdTCPClient1.Port := 21; IdTCPClient1.Connect; try IdTCPClient1.SendCmd('USER anonymous'); IdTCPClient1.SendCmd('PASS me@dummy.com'); IdTCPClient1.SendCmd('HELP' ); Memo1.Lines.Add( IdTCPClient1.CurrentReadBuffer ); finally IdTCPClient1.Disconnect; end; 第 16 章 Intranet 与 Internet 编程 413 提示:关于 TCP 协议的更多知识,可以阅读 RFC 0793,位于 http://sunsite. iisc.ernet.in/collection/rfc/rfc0793.html。 列出的代码假定您已经安装了 FTP 服务和 Peer Web Services,并在默认情况下允许匿名登录(您也可 以在 Microsoft Internet Service Manager 中确认 FTP 服务的设置) 。通常,您可能觉得特定协议的客户和服 务器组件比一般的 TCP 组件更容易使用。 16.1.2 Indy 服务器 TCP 组件 IdTCPServer 组件可用于创建 TCP 服务器程序。TCP 服务器组件绑定到一个服务器端口,例如 8080, 通过响应 OnExecute 事件在该端口上接收 TCP 客户请求。例如,如果 TCP 客户程序使用 WriteLn 向服务 器发送文本,然后即可用 OnExecute 方法的 AThread 参数读出客户发送的文本(TIdUDPServer 组件的使用 可以参见下一节,特性和方法都非常相似)。 表 16.1 列出了 TIdTCPServer 组件值得注意的特性。 表 16.1 TIdTCPServer 组件的特性。设置 Bindings 特性(IP:Port)、DefaultPort 和 Active 状态,即可通过传递给 OnExecute 方法的 TIdPeerThread 对象接收客户请求 特性 描述 AcceptWait 对客户连接的超时等待时间 Active 布尔值,表示服务器是否处于激活状态 Bindings IP:Port 格式的地址字符串,表示服务器所监听的 IP 地址和端口号 DefautlPort 监听客户连接的默认端口号 Intercept 对 TIdServerInterceptOpenSSL 组件的引用,该组件接收发送到 TIdTCPServer 组件 的数据 ThreadMgr 对线程管理器集合的引用,其中包括了引用客户连接的线程对象的列表 如果要在端口 9090 创建一个响应客户的服务器程序,需要将 DefaultPort 特性设置为 9090,并把 Active 特性设置为 True。设置好特性后,使用 OnExecute 事件处理程序和 TIdPeerThread 参数即可与客户程序进 行通信。例如,读出客户程序发送的文本字符串,可使用下列代码: procedure TForm1.IdTCPServer1Execute(AThread: TIdPeerThread); begin ShowMessage( AThread.Connection.ReadLnWait ); end; TCP 服务器组件的事件处理程序 IdTCPServer1.OnExecute 是通过 TIdPeerThread 对象的一个实例来调 用的。AThread 参数中包括了对进行调用 TIdTCPServerConnection 实例的引用,TIdTCPServerConnection 由 TIdTCPConnection 子类化而来。TIdTCPConnection 中包含了发送和接收文本格式数据、字符串命令或 TStream 对象的方法,还可以检测 TCP 组件的连接状态。 16.2 用户数据报(UDP)组件 UDP 协议由 TCP 协议衍生而来。TCP 用于确保点对点的连接,而 UDP 并不保证发送的数据一定到达, 也不表明数据报的传输成功或失败。UDP 协议所失去的在其速度中得到了补偿。可以发送流化数据的程序 中找到 UDP 的应用,如视频、声音和视频游戏等。对于这种类型的数据,丢失一两帧不会有什么影响, 速度才是最重要的。“当程序的目的在于要传输尽可能多的信息时,当数据的丢失相对不重要时,可以使 用 UDP。(Ray,1999)” 注意:当编写本章时,UDP 组件只支持发送接收字符串数据,这使得建立能够流化视频和图 像的 UDP 客户和服务器较为困难。也许还会有些变化,等到您使用 Delphi 6 时,可能已经 可以传输 TStream 对象和二进制数据了。 第 16 章 Intranet 与 Internet 编程 414 UDP 在 RFC 0768 中描述,可以在 http://www.cis.ohio-state.edu/htbin/rfc/rfc0768.html 得到。Delphi 提供 了 UDP 协议的客户和服务器组件:TIdUDPClient 和 TIdUDPServer。TIdUDPClientServer.bpg 文件中包含了 一个客户服务器程序的例子。客户通过发送程序员定义的字符串命令来停止和开始由 TMediaPlayer 播放 的.AVI 视频。 注意:演示用的客户和服务器程序并不代表着 UDP 协议的最佳用途,只是对 Internet Direct 实现的该组件的功能性的示范。 为创建使用 TIdUDPClient 和 TIdUDPServer 的示例程序,首先创建 Delphi 工程。然后向工程组再添加 一个工程。把一个工程作为客户,另一个作为服务器。为连接到 UDP 服务器,需要一个 TIdUDPClient 组 件。用来表示服务器的 IP 地址和端口号。对于示例程序,可以选择一个未被其他基于 TCP 的协议占用的 端口号,如 8090。由于 UDP 是无连接的,因此在 UDP 的接口方法中不包括 Connect 方法。要使用 Internet Direct 实现的组件,只需使用 Send 向给定的 IP 地址和端口号广播一个命令字符串。示例中的客户程序向 服务器发送“PLAY”和“STOP”字符串。假定一个通常的窗体,使用按钮在播放和停止状态之间进行切 换,可以如下实现。 procedure TForm1.Button2Click(Sender: TObject); const Command : string = 'PLAY'; begin IdUDPClient1.Send(Command); Memo1.Lines.Add( IdUDPClient1.ReceiveString ); if( Command = 'PLAY' ) then Command := 'STOP' else Command := 'PLAY'; end; 较为重要的代码是 IdUDPClient1.Send(Command),这个语句很简单。可以注意到其中并未表示连接或 断开。 服务器是作为应用程序实现的,它对客户程序发出的一个简单的命令集进行响应。为实现服务器,程 序将监听 DefaultPort 端口(8090,与客户相匹配)上的广播,并将 TIdUDPServer.Active 设置为 True。当 UDP 服务器从客户接收数据时,将调用 TIdUDPServer.OnUDPRead 事件处理程序。下面列出的代码示范了 如何从 OnUDPRead 事件的参数中读出数据。 procedure TForm2.IdUDPServer1UDPRead(Sender: TObject; AData: TStream; const APeerIP: String; const APeerPort: Integer); var S : TStringStream; begin S := TStringStream.Create(''); try S.CopyFrom( AData, AData.Size ); if( CompareText( S.DataString, 'PLAY' ) = 0 ) then MediaPlayer1.Play else if (CompareText( S.DataString, 'STOP' ) = 0 ) then MediaPlayer1.Stop; IdUDPServer1.Send( APeerIP, APeerPort, S.DataString ); finally S.Free; end; end; 第 16 章 Intranet 与 Internet 编程 415 OnUDPRead 有四个参数,类型分别是:TObject、TStream、string 和 integer。TObject 参数表示发送者 对象。TStream 参数包含了从客户端发送的数据。字符串参数 APeerIP,即第三个参数,包含了发送数据的 客户计算机的 IP 地址。APeerPort 参数用于标识广播的端口号。 提示:本章进行写作时,Delphi 6 beta 版并未集成 Indy 帮助文档和示例程序。当 Delphi 6 正式版出售时,应该已经包括文档和示例;现在可以访问 http://www.nevrona.com/Indy/Download.html 来得到示例程序、源代码和帮助文件。 上面列出的代码使用 TStringStream 来把数据从 TStream 对象复制到 StringStream,使得代码可以像处 理字符串一样处理该数据(另外,也可以使用 As 操作符来确定 TStream 对象实际数据类型)。在本例中, 把字符串数据与简单的命令集进行比较,然后在使用调用客户的 IP 地址和端口号将该命令回送到客户端。 16.3 建立 FTP 客户程序 文件传输协议(FTP)是一个基于 TCP/IP 的协议,用于在网络上传输文件。当从 Internet 下载文件时, 可能使用 HTTP 命令 GET,也可能使用 FTP。FTP 协议具有自身的命令集合,Delphi 中包含了一些组件, 可用于建立 FTP 客户程序(例如 Ipswitch 公司的 WS_FTPPro 程序和 Windows 自带的命令行 FTP 程序)和 FTP 服务器程序(例如与 Microsoft Internet Information Server 一同发布的 FTP 服务器) 。 注意:在浏览器中,像 AOL 的 Netscape Navigator 或 Microsoft 的 Internet Explorer,可 能最常用的协议是超文本传输协议(HTTP),但也可以使用其他几种协议,包括 FTP、file、 gopher 和 Telnet。 本书的光盘包含了几个 FTP 示例程序,包括 SimpleFTP,它是一个简单的示例程序,使用 NetMasters 公司的 TNMFTP 组件实现,该组件可以在组件面板的 FastNet 属性页上找到,该程序的另一个版本是使用 Nevrona 公司的 TIdFTP 组件实现的。除此之外,光盘上还有一个精致的 FTP 程序 FTPPro(如图 16.3 所示)。 FTPPro 程序完整的代码并未在此列出,该程序共有大约 2000 行代码。FTPPro.exe 程序还演示了应用程序 在注册表中的设置(参考本章稍后 POP3 示例的章节,其中有一个例子,使用 TIniFile 类来把程序设置保 存在 INI 文件中)。FTPPro.exe 示例程序还包括了闪烁屏幕、新的 shell 控件、可选窗体、密码对话框和很 多的 FTP 功能。可以从光盘上复制该程序,并仔细查看一下。本节涵盖了 FTP 功能中最重要的部分,同 时也突出强调了与 FTP 协议明确相关的代码。 图 16.3 FTPPro.exe 演示了一个基于 Windows 的 FTP 客户程序的 完整实现,其中使用了 NetMasters 公司的 TNMFTP 组件 第 16 章 16.3.1 Intranet 与 Internet 编程 416 连接到 FTP 服务器 FTP 客户程序的所有完整实现都必须满足 FTP 协议的要求。无论特定的特性和方法是否命名相同,这 一点都是必须的。在 Delphi 中有两个组件可用于创建 FTP 客户程序。分别是 NetMasters LLC 公司的 TNMFTP 组件和 Nevrona 公司的 TIdFTP 组件。本节演示了为把 FTP 客户程序连接到 FTP 服务器,所有必 要的特性和代码实例。 使用 TNMFTP 组件连接到 FTP 服务器 FTP 协议要求主机 IP 和端口号。主机 IP 可以是如何运行 FTP 服务器的主机地址,而端口号通常是 21。 依赖于 FTP 服务器的配置,您可能需要提供用户名和密码。如果服务器允许匿名登录,那么可以使用 anonymous 作为用户名,并使用任何包含@字符的字符串作为密码(在匿名登录时,通常要求使用电子邮 件地址作为密码)。下面的代码演示了匿名登录到 FTP 站点的过程,其中使用名为 NMFTP1 的 TNMFTP 组件。 NMFTP1.Host := '198.109.162.177'; NMFTP1.Port := 21; NMFTP1.UserID := 'anonymous'; NMFTP1.Password := 'yourmail@yourisp.com'; NMFTP1.Connect; 假定主机和端口号是正确的(本例中是正确的) ,而且服务器允许匿名登录,则上述代码将把 FTP 客 户程序连接到所指定的主机。 两种组件都提供了基本的 FTP 服务,其不同点在于完全性和示范易于实现。在与文件上传和下载相关 的章节中提供了一个例子,演示了如何用 TNMFTP 组件执行基本的 FTP 服务。FTPPro 示例程序是用 TNMFTP 实现的;您可以从本书的 CD-ROM 中装载并运行 FTPPro 工程。 认证 如果使用 TNMFTP 组件登录到需要有效用户和密码的站点(而不是匿名登录时),则 TNMFTP 组件需要进行认证。通过实现 TNMFTP.OnAuthenticationNeeded 事件处理程序,可以提供动态认证功能。 OnAuthenticationNeeded 事件处理程序传递一个布尔变量参数 Handled。将 Handled 赋值为 True,TNMFTP 将继续尝试登录。下面列出的代码示范了认证事件的实现。 procedure TFormMain.NMFTP1AuthenticationNeeded(var Handled: Boolean); var UserID, Password : string; begin UserID := NMFTP1.UserID; Password := NMFTP1.Password; Handled := GetPassword( UserID, Password ); if( Handled ) then begin NMFTP1.Password := Password; NMFTP1.UserID := UserID; end; end; GetPassword 是一个全局函数(定义在本书光盘的 UFormPassword 单元中) ,该函数创建一个模式对话 框窗体,该窗体非常简单,只包括两个输入域,分别代表用户名和密码。 代理服务器 有些 FTP 服务器可能会使用代理服务器作为防火墙,以避免黑客攻击;也可能像 Software Conceptions 公司一样,使用代理服务器向几台工作站通过 Internet 访问功能。通过设置 TNMFTP.Proxy 特 性,可以指定代理服务器 IP 地址;通过设置 TNMFTP.ProxyPort 特性可以指定代理服务器端口。 使用 TIdFTP 连接到 FTP 服务器 Nevrona 实现的 Indy FTP 组件 TIdFTP 支持的公开特性较少,但与 TNMFTP 基本相同。分别设置 第 16 章 Intranet 与 Internet 编程 417 TIdFTP.Host、TIdFTP.Port、TIdFTP.User 和 TIdFTP.Password 特性,并调用 connect 方法,即可将 FTP 客户 程序连接到 FTP 服务器。SimpleFTP.exe 的第二种实现位于本书的光盘上。 16.3.2 上传和下载文件 FTP 协议的首要目的在于提供客户与服务器之间的文件传输功能。您可能对 TNMFTP 了解较多,这 里对两种 FTP 实现 TNMFTP 和 TIdFTP 进行了比较,使得您可以了解不同的实现风格。 TNMFTP 组件的文件传输方法 TNMFTP.Upload 和 TNMFTP.Download 方法都需要两个参数。上传是将文件从客户复制到服务器的过 程,而下载刚好相反。两个方法的声明如下。 procedure Download(RemoteFile, LocalFile: string); procedure Upload(LocalFile, RemoteFile: string) 假定已经连接到 FTP 服务器,可以将要下载的服务器上文件的名字作为第一个参数,而本地计算机上 保存的文件名作为第二个参数。 如果目标文件在远程或本地计算机已经存在,相应的上传和下载过程将覆盖目标文件。文件传输是异 步的。这样,对上传和下载方法的调用将在文件传输完成之前返回。文件传输的成功或失败,需要通过实 现 TNMFTP.OnSuccess 和 TNMFTP.OnFailure 事件方法来进行通知。两个事件方法的 Trans_Type 参数类型 都是 TCmdType。例如,如果调用 OnFailure 方法而 TCmdType 参数是 cmdDownload,则可以知道下载操 作已经失败。 TIdFTP 组件的文件传输方法 Indy 的 FTP 实现中,下载方法命名为 Get,上传方法命名为 Put。Get 实现为过载方法。 procedure Get(const ASourceFile: string; ADest: TStream); overload; procedure Get(const ASourceFile: string; const ADestFile: string; const ACanOverwrite: boolean); overload; Get 方法的第一个版本取得远程计算机上的源文件名,并把文件下载到 TStream 对象中。第二个版本 与 TNMFTP 的实现较为相似。第一和第二个参数分别表示远程和本地文件名,第三个参数指明在目标文 件已经存在时是否覆盖。下面列出的代码示范了如何将文件下载到 TStream 对象中。 var FileName : string; S : TStringStream; begin FileName := Copy( ListView1.Selected.Caption, LastDelimiter(' ', ListView1.Selected.Caption), 255 ); S := TStringStream.Create(''); try if( Pos( '<DIR>', ListView1.Selected.Caption ) = 0 ) then IDFTP1.Get( FileName, S ); S.Position := 0; Memo1.Lines.LoadFromStream(S); finally S.Free; end; end; 代码中有一些假定。第一个假定是 TListView 组件的 Caption 特性中包括文件名(在演示 TIdFTP 组件 的 SimpleFTP 示例工程中确实如此,该工程在本书的光盘上),而且窗体上有一个名为 Memo1 的 TMemo 组件。代码将文件名从 TListView 组件中提取出来。然后创建 TStringStream 对象,TStringStream 是 TStream 第 16 章 Intranet 与 Internet 编程 418 的后代,因此我们可以对 TIdFTP.Get 方法的 TStream 参数使用 TStringStream 对象。将创建的流对象传递 给 Get 方法。在调用 Get 方法后需要将流的位置重置为 0,向流对象复制数据将改变流的当前位置。在装 载了 StringStream 对象之后,可以使用 TStrings.LoadFromStream 方法将 TStringStream 对象中的数据复制到 TMemo 对象中(请记住,TMemo 的 Lines 特性是一个 TStrings 类型的对象特性)。 实现中过载了 Put 方法,以便直接在流对象和文件上进行工作(该实现是对称的,使得组件的使用非 常直接。按照直觉,我们可以认为,既然 Get 方法可以在流对象和文件上工作,那么,与之相对应的 Put 方法也应该能够在流对象和文件上工作)。Put 方法的声明如下: procedure Put(const ASource: TStream; const ADestFile: string; const AAppend: boolean); overload; procedure Put(const ASourceFile: string; const ADestFile: string; const AAppend: boolean); overload; 在 Put 的第一个实现中,该方法将数据从客户端的流对象复制到远程服务器上的文件。如果 AAppend 参数设置为 True,那么数据将附加在已存在的文件之后。在 Put 方法的第二个实现中,源数据和目标数据 都是文件,而 AAppend 参数的作用与第一种实现相同。 16.3.3 向 FTP 服务器发送命令 FTP 协议包含一个命令集,由大约 20 条命令组成。对于特定的组件而言,不见得把协议命令集中的 每个命令都作为公有方法实现。通过使用一个一般性的方法向服务器发送命令字符串,可以支持在 TIdFTP 和 TNMFTP 组件的接口中并不直接支持的 FTP 命令。TIdFTP 使用 SendCmd 方法实现了这种能力。 function SendCmd(const AOut: string; const AResponse: SmallInt): SmallInt; virtual; overload; function SendCmd(const AOut: string; const AResponse: Array of SmallInt): SmallInt; virtual; overload; 在上述函数中,需要传递一个命令字符串和一个响应代码(或一组响应代码)。如果服务器的响应与 响应代码并不匹配,将产生异常。在下面的代码片断中,使用响应代码 150 和 226 向 FTP 服务器发送了一 个通常的 list 命令。如果服务器的响应不是 150(打开连接)和 226(传输完成),将产生异常。第二行代 码将把缓冲区中的内容读入到 TString 对象 Dest 中。 IdFTP1.SendCmd('ls', [150, 226]); Dest.Text := IdFTP1.CurrentReadBuffer; 如果一个方法是可用的,则无需使用一般性的 SendCmd 方法。TIdFTP.List 方法实现如下。 procedure List(ADest: TStrings; const ASpecifier: string; const ADetails: boolean); 第一个参数类型为 TStrings,第二个参数不是文件掩码,而第三个参数表示返回文件信息。使用下述 代码中的参数调用 List,可以只返回文件名。 var Dest : TStrings; begin Dest : TStringList.Create; try IDFtp1.List( Dest, '*.*', False ); for I := 0 to Dest.Count - 1 do with ListView1.Items.Add do Caption := Dest[I]; finally Dest.Free; end; 第 16 章 Intranet 与 Internet 编程 419 end; 上面的代码向 List 传递了 TStrings 对象 Dest 和文件掩码‘*.*’,而且不要求返回详细信息。for 循环 将字符串数组中的数据复制到名为 ListView1 的 TListView 组件中。 提示:FTP 协议定义在 RFC 959 中。 幸运的是,Indy 和 NetMaster FTP 组件实现了许多通常的 FTP 命令,如上传和下载文件、建立和改变 目录、删除和列出文件等。DoCommand 是 SendCmd 方法的 TNMFTP 版本,该方法只有一个字符串参数, 即所用的命令。对于 FTP 客户端的实现细节,请参见本书光盘上的 FTPPro 例子程序。 第 16 章 Intranet 与 Internet 编程 16.4 420 创建 Telnet 客户程序 终端仿真客户可以利用 Telnet 协议连接到 Telnet 服务器,通常使用端口 23。尽管可以连接到其他的 TCP/IP 服务器,通常 Telnet 客户只用于连接到返回终端数据的服务器(参见 John Ray 的《Special Edition Using TCP/IP》一书,其中有一些有趣的例子使用 Telnet 客户端连接到 POP3 和 SMTP 服务器)。本书光盘 上的例子程序 TelnetPro 包含有演示 Telnet 客户端的源代码,该客户端模仿了 Windows 自带的 Telnet.exe 程序。 TelnetPro 程序的终端仿真器非常基本。为创建完整的 Telnet 程序,需要实现(或找到)一个终端仿真 器。如果要实现终端仿真器,可以在 http://www.inwap.com/pdp10/ANSIcode.txt 找到仿真器所必须支持的 ANSI 代码的完整描述。ANSI 代码的形式类似于$1B[,即 ASCII 码 27(十六进 制$1B)所代表的 Esc 字符后接左方括号,其后是特定的字符串序列。 图 16.4 如图所示,使用 TelnetPro.exe 示例程序连接到密歇根州 立大学图书馆的 MAGIC 服务器,网址为 magic.msu.edu 为完成终端仿真器,需要定义一个语法分析循环,用于从明文中提取 ANSI 转义代码,并根据 ANSI 代码确定何时删除显示内容、滚屏或修改显示字体。除了工作站上自带的很小的 Telnet 客户端(可以通过 Start | Run 菜单运行)之外,NetManage.com 提供了 Rumba。这两个产品演示了 Telnet 程序应具有的功能。 使用 TIdTelnet 组件,需要指定主机和端口号。例如,将主机设置为 magic.msu.edu,端口号设置为 23, 即可连接到 MSU 大学的 MAGIC 服务器。实现 TIdTelnet.OnDataAvailable 方法从字符串参数 Buffer 中读入 服务器的响应。Buffer 参数中包含了 ANSI 代码和要显示的文本。可使用 SendCh 或 SendStr 向服务器发送 数据。SendCh 每次通过 Telnet 端口发送一个字符,而 SendStr 每次向服务器传递一个字符串参数。下面的 示例代码捕获 RichEdit 控件中的击键事件,并按每次一个字符将其发送出去。 procedure TFormMain.RichEdit1KeyPress(Sender: TObject; var Key: Char); const Input : string = ''; begin if( Not IdTelnet1.Connected ) then exit; IdTelnet1.SendCh(Key); Key := #0; end; 如果 TIdTelnet 组件并未连接到服务器,上面的代码将忽略 key 参数。否则,每次将向服务器发送一个 字符,并把 key 参数设置为#0;这使得一直可以向 RichEdit 控件直接插入字符。下面列出的代码示范了一 个简单的 Telnet 应用程序。 第 16 章 Intranet 与 Internet 编程 unit UFormMain; interface uses Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms, Dialogs, IdBaseComponent, IdComponent, IdTCPConnection, IdTCPClient, IdTelnet, StdCtrls, Menus; type TFormMain = class(TForm) Memo1: TMemo; IdTelnet1: TIdTelnet; MainMenu1: TMainMenu; Connect1: TMenuItem; Connect2: TMenuItem; Disconnect1: TMenuItem; N1: TMenuItem; Exit1: TMenuItem; procedure IdTelnet1DataAvailable(Buffer: String); procedure FormDestroy(Sender: TObject); procedure Memo1KeyPress(Sender: TObject; var Key: Char); procedure Exit1Click(Sender: TObject); procedure Connect2Click(Sender: TObject); procedure Disconnect1Click(Sender: TObject); procedure FormClose(Sender: TObject; var Action: TCloseAction); procedure FormCreate(Sender: TObject); private { Private declarations } public { Public declarations } end; var FormMain: TFormMain; implementation {$R *.DFM} procedure TFormMain.IdTelnet1DataAvailable(Buffer: String); begin Memo1.Lines.Add(Buffer); end; procedure TFormMain.FormDestroy(Sender: TObject); begin IdTelnet1.Disconnect; end; procedure TFormMain.Memo1KeyPress(Sender: TObject; var Key: Char); begin if( IdTelnet1.Connected ) then begin if( Key = #13 ) then Memo1.Clear; 421 第 16 章 Intranet 与 Internet 编程 422 IdTelnet1.SendCh(Key); Key :=#0; end; end; procedure TFormMain.Exit1Click(Sender: TObject); begin Close; end; procedure TFormMain.Connect2Click(Sender: TObject); var Host : string; begin Memo1.Clear; Host := 'magic.msu.edu'; Disconnect1Click(Self); if( InputQuery( 'Host', 'Enter host address:', Host )) then begin IdTelnet1.Host := Host; IdTelnet1.Connect; end; end; procedure TFormMain.Disconnect1Click(Sender: TObject); begin if( IdTelnet1.Connected ) then IdTelnet1.Disconnect; end; procedure TFormMain.FormClose(Sender: TObject; var Action: TCloseAction); begin Disconnect1Click(Self); end; procedure TFormMain.FormCreate(Sender: TObject); begin Connect2Click(Self); end; end. 当创建窗体或单击 Connect 菜单项时,该应用程序需要用户输入主机名。还实现了 Disconnect 和 Exit 菜单。当客户连接到服务器时,如果已经定义了 OnDataAvailable 事件处理程序,将调用该事件。在本例 中,文本直接发送到 TMemo 控件。为正确显示数据流,需要终端仿真器来读入并解释服务器发送的 ANSI 命令。如果客户已连接到服务器,Memo1KeyPress 处理程序将把每个字符都发送到 Telnet 服务器。您可以 使用例子程序连接到远程服务器,但其显示可能不很美观(从实现代码即可看出) 。 16.5 使用 POP3 和 SMTP 建立 Internet Email 客户端程序 POP3 和 SMTP 也可称为 Internet email 协议,它们支持收发 email。POP3(Post Office Protocol)和 SMTP (Simple Mail Transfer Protocol)是用于收发 Internet email 的两个协议。TIdPOP3 和 TIdSMTP 两个组件提 供了 email 支持,可用于建立 Internet email 的客户端程序。这两个组件使用了一个第三方组件 TIdMessage, 第 16 章 Intranet 与 Internet 编程 423 该组件代表了邮件服务器发送到客户程序的内容。要成功地传输数据,这些协议需要比前面的协议更多的 信息,因此我们将从基于任务的角度来仔细查看每个组件;您可以看一下本书光盘上的 SimplePOP3 例子 程序(我们不会在这里列出完整的程序代码)。 16.5.1 使用 TIdPOP3 组件 TIdPOP3 组件需要一些必要的信息,以便使用 POP3 协议进行工作。组件的 Host、Password、Port 和 UserID 特性代表了这些信息。这些值存储在控制面板的 Mail 小应用程序中,可使用这些值接到 POP3 服 务器(如图 16.5 所示)。在 Mail 小应用程序中,选定 Internet E-mail 配置文件并单击 Properties 按钮即可看 到这些特性的值。 图 16.5 Mail Properties 对话框包含了特定邮件服务 器的信息,如 POP3 或 Exchange 服务器 检查邮件 POP3 组件可用于向邮件服务器查询邮件。调用 TIdPOP3.CheckMessages 将返回邮件的数目。下面的 代码中将邮件数目存储到 Count,然后进行 Count 次循环,重复调用 TIdPOP3.Retrieve 从邮件服务器获取 每一个邮件。Retrieve 的参数为邮件序号和 TIdMessage 对象。邮件序号从 1 开始。如果使用从 0 开始循环, 需要向循环控制变量加 1,才能得到有效的邮件序号。下面的代码对此进行了示范: procedure TFormMain.ActionSendReceiveExecute(Sender: TObject); var I, Count : Integer; begin Count := IdPOP31.CheckMessages; for I := 0 to Count - 1 do if( IdPOP31.Retrieve( I + 1, IdMessage1 )) then ShowMessage( IdMessage.From.Name + ':' + IdMessage1.Subject ); end; 列 出 的 代 码 假 定 已 经 调 用 了 TIdPOP3.Connect 方 法 并 成 功 连 接 到 邮 件 服 务 器 。 如 果 TIdPOP3.CheckMessages 方法返回的邮件数目大于零,则使用 TIdMessage 对象示例 IdMessage1 来获取所 有的邮件(TIdMessage 是一个组件,位于组件面板的 Indy Misc 属性页上) 。TIdMessage 组件包含了所有 必要的特性,可以看到附件并对邮件进行响应。关于 SMTP 组件可以阅读后面的章节,其中包含了一些响 应和发送邮件的例子。 第 16 章 Intranet 与 Internet 编程 424 删除邮件 在 TIdPOP3 组件的当前实现中,Delete 方法有一个参数表示邮件序号。您需要根据邮件获取顺序把序 号存储起来。在 TIdMessage 对象中并不存储该信息。TIdMessage 组件确实存储了 MsgId 字符串,但 Delete 方法无法使用 MsgId 进行工作。 IdPOP31.Delete( 1 ); 从这个语句可以看出,在服务器上删除信息显然是很简单的。但并不显然的是,在邮件对象中没有存 储服务器上的邮件序号。您只能手工编写代码来存储邮件序号。 注意:在更好的实现方法中可能会基于邮件的内容进行删除,以后可能会进行这样的修订。 当使用相对邮件序号调用 Delete 方法时,邮件被标记为删除,但直到与服务器断开连接时才真正删除。 在图形用户界面中,可以选择删除或只是标记为删除。 16.5.2 使用 TIdMessage 组件 当使用 Internet Direct POP3 和 SMTP 组件时,无论是使用 POP3 组件获取邮件,还是使用 SMTP 组件 发送邮件,都需要使用 TIdMessage 组件存储邮件。TIdMessage 组件将邮件体存储为 TStrings 对象,可通 过 Body 属性进行访问。From 特性是一个 TIdEmailAddressItem 对象,使用 TCollectionItem 实现。Recipients 特性定义为 TIdEmailAddressList 对象,是由 TOwnedCollection 子类化而来。Subject 是一个字符串特性。 像 CCList、Date 和 ReplyTo 等特性在集成帮助文档中都可以查到。 要发送一个基本的邮件,需要对上述特性赋值。下面的代码片段摘自 SimplePOP3 程序,示范了如何 从可视化组件向 TIdMessage 类型的对象 IdMessage1 复制数据。 IdMessage1.Body.Assign( MemoBody.Lines ); IdMessage1.From.Text := IniFile.EmailAddress; IdMessage1.Recipients.EmailAddresses := LabeledEditRecipient.Text; IdMessage1.Subject := LabeledEditSubject.Text; 当准备好发送邮件时,需要使用 TIdSMTP 组件,将 TIdMessage 对象作为参数传递给该组件的 send 方法。下一节演示了如何发送邮件。 16.5.3 使用 TIdSMTP 组件 假定已经正确的配置了 TIdMessage 对象,下面就需要使用 TIdSMTP 组件来发送邮件。POP3 服务器 通常监听 110 端口,主机就是 POP3 主机名。SMTP 服务器通常监听 25 端口。SMTP 主机可能与 POP3 主 机并不相同。为配置 SMTP 客户,需要给出 SMTP 主机、端口、用户名和密码。例如,您的 SMTP 主机名 可能是与邮件有关的字符串,而端口号可能是 25。 提示:如果不知道 POP3 和 SMTP 端口号、主机、用户 id 和密码,可以使用相应的 email 管 理程序或控制面板中的 Mail Properties Applet 进行查看。 注意:.INI 文件的作用与注册表相仿。两种方法都是可以接收的,但注册表对于存储应用程 序的持久数据是首选方法(INI 文件进行开发时更容易使用,而且应用程序出现问题时也不容 易破坏注册表)。 TIdSMTP.Port、TIdSMTP.Host、TIdSMTP.UserID 和 TIdSMTP.Password 特性可以在设计时或运行时配 置。例如,本书光盘上的 SimplePop3 示例程序使用 INI 文件来存储连接信息。下面的代码演示了如何将静 态值赋予 SMTP 组件特性并发送邮件(要发送的参数是一个 TIdMessage 对象实例。关于初始化邮件对象 的例子,其参见上一节) 。 IdSMTP1.Host := 'mail'; IdSMTP1.Port := 25; IdSMTP1.UserID := 'userid'; 第 16 章 Intranet 与 Internet 编程 425 IDSMTP1.Password := 'password'; IdSMTP1.Connect; try IdSMTP1.Send(IdMessage1); finally IdSMTP1.Disconnect; end; 上面的代码假定已经正确的配置了 TIdMessage 对象。发送邮件前对 TIdMessage 对象的配置,请参见 上一节。另外,您可以使用类方法 QuickSend 来发送 email 而无需使用 TIdMessage 组件。 IdSMTP1.QuickSend('mets.tcimet.net', 'Reminder', 'pkimmel@softconcepts.com', 'pkimmel@softconcepts.com', 'Buy Building Delphi 6 Applications'); 还有其他几种协议是在 TCP/IP 上实现的,当然也可以另行实现自己的协议,或子类化并扩展 Internet Direct 组件来得到自定义接口。关于使用 Delphi 进行 TCP/IP 编程的进一步信息,请阅读 Andrew Wozniewicz 的《Web Programming with Delphi》一书,关于 TCP/IP 协议的一般性信息,可以阅读 John Ray 的《Special Edition Using TCP/IP》。 16.6 小 结 本章涵盖了若干种不同的协议,它们都是基于 TCP/IP 的。TCP/IP 并非联网的惟一手段,但它是一种 跨越 Internet、内部网和外部网连接计算机的可靠手段。本章中,您已经学到了如何使用一些新的来自 Nevrona 公司的 Internet Direct 组件;并测试了 FTP、UDP、Telnet、TCP、POP3 和 SMTP 组件。还有其他 许多新的组件可用于创建客户和服务器程序。 下一章我们将继续讨论 Web 编程,使用一些协议来建立 Web 服务器和基于 Web 的应用程序。 第 17 章 使用 WebBroker 组件创建 Web 服务器 WebBroker 是一套 VCL 工具,可以帮助您建立 Web 服务器程序。WebBroker 与 Delphi 企业版一同发 布,也可单独购买并与 Delphi 专业版配合使用。WebBroker 可用于建立 Web 服务器,支持 ISAPI、NSAPI 或 CGI 协议。ISAPI(Internet Services API)和 NSAPI(Netscape Services API)通过 TISAPIApplication Web 应用程序组件来支持。CGI(Common Gateway Interface)通过 TCGIApplication Web 应用程序组件来支持。 Web 服务器 Apache 是通过新的 TApacheApplication 组件来支持。对于开发者来说,这意味着使用对大多 数 Internet 服务器可用的通用协议编写 Web 程序更为容易。 本章我们将特别注重用于建立 Web 服务器的组件,它们位于 Internet 属性页上。所有的 Web 服务器都 包含一个 TWebModule 组件,或者是一个 TDataModule 和一个 TWebDispatcher 组件。在讨论这些组件之前, 我们先快速浏览一下 HTML 的基础知识。理解 URL(Uniform Resource Locator)请求和 HTML 页面的基 本结构是很有必要的,因为用户会调用 Web 服务器,而 Web 服务器要提供 Web 页面服务。 17.1 HTML 基础 Web 服务器是 URL 路径的一部分。WebBroker 服务器将基于 URL 请求的内容和 Web 服务器的设计返 回 HTML 页面。通常,这些响应都是以 HTML 文档的形式进行。文档可能会包括到其他 Web 站点或 Web 服务的超链接。 本节包含了对 URL 分解、HTML 文档和可替换参数标记的简要综述,这些机制有助于返回动态 Web 页面。 17.1.1 URL(Uniform Resource Locator) URL 由协议标签、主机名、脚本或服务器程序、路径信息和一些由用户向服务器提供的查询信息构成。 参见下面的代码和图 17.1,可以看到一个 URL 的各个部分,该 URL 指向如图所示的 Web 服务器。 http://localhost/scripts/iserver.dll/runquery?CustNo=1645 本例中,所用协议为 HTTP,即超文本传输协议(Hypertext Transfer Protocol)。HTTP 可能是最常用的 浏览器协议,但从第 16 章是可知,并非只有这一种可能(回忆第 16 章,可以知道有几种 TCP/IP 协议, 像 FTP 和安全 HTTP,都可以用作 URL 中的协议) 。例子中的主机名是 LocalHost,它代表客户机。LocalHost 也是计算机名或 IP 地址 127.0.0.1(127.0.0.1 也称为回送 IP 地址)。主机名可以是任何 IP 地址或 DNS 表中 的名字;例如 www.microsoft.com、www.softconcepts.com 或 www.amazon.com。URL 的脚本部分是可选 第 17 章 使用 WebBroker 组件创建 Web 服务器 图 17.1 430 URL 在 Web 浏览器的地址栏中输入,也可 能出现在 HTML 文档中的 HREF 标记之后 的。对 Web 服务器而言,即包含服务器程序的文件夹。如果在 Windows 2000 系统下运行 IIS 或在 PC 上运 行 Peer Web Services,那么默认情况下脚本位于 c:\inetpub\scripts 目录(见图 17.2)。服务管理器将虚拟路 径脚本映射到物理上的目录。脚本后紧接着是服务器程序。本例的服务器程序是 iserver.dll。例子中的路径 信息由 run-query 表示。最后一部分信息示范了如何向服务器发送查询参数。在例子中,CustNo=1645 将发 送到服务器程序。 图 17.2 Windows 2000 专业版系统中 Scripts Properties 对话框, 可以看到虚拟的脚本路径被映射到物理路径 注 意 : 本 例 中 所 示 的 请 求 摘 自 Delphi 中 的 例 子 iserver.dpr , 该 工 程 位 于 $(DELPHI)\Demos\WebServ\IIS 文件夹中。上文中的请求是对 biolife.db 表发出的,该文件 是 Delphi 附带的。 您可能已经熟悉协议标签加上主机名的请求类型。但如果您对电子商务有一定程度的了解,例如在 amazon.com 进行购物,您可能已经看到过另外一些 URL 请求类型。 注意:最熟悉的 URL 形如 http://www.digitalblasphemy.com,在请求中不存在路径、脚本、 查询和特定的页面信息。只涉及到 Web 服务器上的一个页面。Microsoft 公司的 Web 服务器 IIS 在 默 认 情 况 下 返 回 default.asp 页 面 , 但 可 以 进 行 配 置 以 返 回 任 何 页 面 。 http://www.softconcepts.com 站点返回的页面是 index.htm。默认页面是可配置的。管理 和配置 IIS 或其他 Web 服务器已经超出了本书的范围;但在这方面有很多书籍可供参考。 如果给出了 Web 站点和路径来运行脚本(例如上文例子中的 runquery)但没有找到,则 Delphi 的 WebBroker 组件允许指定默认路径。按照惯例,本章中的默认路径是/root。 17.1.2 基本的 HTML 结构 上一节中的请求是要求 WebBroker 服务器返回特定页面。如果提供查询信息,则页面内容可以由查询 值来控制。可以通过设计,对用户屏蔽 Web 页面的细节,如 CustNo,这是个好主意,把细节嵌入到 HTML 源文件中即可完成;而用户可通过输入 URL 直接发送请求。无论如何,基本的 HTML 页面的结构都是一 致的。 第 17 章 使用 WebBroker 组件创建 Web 服务器 431 提示:HTML(Hypertext Markup Language)中包括标记,标记语言的读者可以认为这些标 记是指令。 注意:请记住,页面可能会非常复杂。其中可能包括 Active Server Pages 和 JavaScript 或 VBScript,而且像 FrontPage 这样的页面设计工具可能会添加相对数量和种类的修饰。不 考虑这些修饰,HTML 页面包含了某些一致的元素。 基本的 HTML 页面由标记组成,它们定义了文档的结构。许多标记是对称的,包括开始标记和结束标 记。例如,HTML 文档以<html>标记开始,结束标记为</html>(请注意,/用于结束标记)。在文档标记之 内是文档体标记<body>和</body>。通常所看到的文档内容是在文档体标记之内定义的。下面列出的代码 使用<html>和<body>标记示范了框架性的 Web 页面,与本书开头的 Hello World 程序差不多。 <html> <body> Welcome to Valhalla Tower Material Defender! </body> </html> 上述 Web 页面是无法得到任何“本年度××Web 站点”奖励的,但它确实示范了 HTML 的简单的和 基本的特性。作为练习,打开 notepad.exe 并输入上面的代码。将文件保存为 hello.htm。然后打开 Web 浏 览器。单击 File | Open 并浏览 hello.htm 的位置,再单击 OK。可以看到,浏览器中显示了文档体标记之间 的文本(五、六年前这可是件了不起的事情)。还有许多标记以及使得这些标记易于使用的工具,但你还 是可以使用简单的文本编辑器。下面示范了常用的其他标记,可以帮助您入门。 定制文档体 可以向文档体添加额外的特征。使用<bgcolor>标记,可以将页面的背景颜色指定为颜色名或十六进制 数字。在文档体标记中,还可以指定背景图像。下面对文档体标记进行了修改,示范了背景颜色和背景图 像的使用。 注意:本章中的 HTML 文档使用 Notepad.exe 生成,并在 Internet Explorer 5.5 上进行了 测试。无法保证其他的特定 Web 浏览器或早期版本是否能够对这些超文本标记进行渲染。 <body bgcolor=#00FFFF> <body background="bubbles.bmp"> 背景颜色由颜色名或 48 比特的 RGB(红、绿、蓝各 16 比特)颜色表示。颜色数字由三个十六进制数 组成,各 16 比特。这里没有设置红色的比特位,而设置了所有的绿色和蓝色比特位。可以用颜色名 cyan 或 blue-green 来替换上述的颜色值。Background 属性指向一个图形文件,该文件将在文档的背景上绘出。 有许多标记,一些标记有各种属性,特定的浏览器可能支持其中的全部或一部分。由于标记的种类和 属性很多,因此需要使用 Web 页面设计程序如 FrontPage 或 Hot Metal 等(该程序与某些版本的 Delphi 捆 绑发行)。 使用水平规则 具有 3D 效果的线由水平规则标记<hr>表示,该标记指示浏览器在 HTML 文档体中绘出一条具有浮雕 效果的线。 行结束和段落标记 在 HTML 文档体中文本结束处的<br>将向文本插入一个硬回车。如果在文档体中使用<p> </p>标记, 浏览器会在该位置创建段落。例如, <html> <body> This is <p>some</p> text. </body> 第 17 章 使用 WebBroker 组件创建 Web 服务器 432 </html> 在 Web 页面中显示如下: This is Some Text. 可以使用<br>、<p></p>以及<pre></pre>标记格式化文档中的文本。最后一对标记是预格式化标记, 它表示文本块在 Web 页面中的显示方式与其书写方式相同。 使用标题 <title></title>标记可以为页面指定标题。标题标记通常位于<body>标记之前,在<html>开始标记之后。 下面的代码示范了标题标记。 <html> <title>Hello World!<title> <body> Welcome to Valhalla Tower Material Defender! </body> </html> 上述 HTML 文档将在浏览器的标题栏显示文本“Hello World!”。 添加超链接 <A HREF="path">页面上显示的文本</A>标记用于向 HTML 文档体中添加超链接。例如, <A HREF="http://www.microsoft.com>Microsoft</A> 将 显 示 带 下 划 线 的 文 字 Microsoft , 它 是 超 链 接 。 用 户 单 击 该 超 链 接 时 , 浏 览 器 将 重 定 向 到 http://www.microsoft.com。Web 本身就是由数以百万计的页面组成,页面中可能包含指向其他页面的超链 接。实际上,每个页面都可以想像成 Web 的一个点,而每个超链接都是万维网的一条线。请记住,超链接 可能会指向 Web 服务,而服务再生成页面,我们在这里正是这样做的。 书签 书签标记表示为<A NAME="#text">text</A>,它可以作为 Web 页面的定位器,使用户可以在页面内移 动。使用<HREF>标记既可以表示当前页面内的位置,也可表示另外的页面中的位置。下面是一些例子: <A NAME="#location">Location Title</A> <A HREF="http://www.mypage.com/index.html#location>Goto Index.htm Location</A> <A HREF="#location">location</A> 注意:当键入的 URL 中有空格时,请在 URL 中使用空格字符的值(ASCII 字符 32)。字面上 是%20,在十六进制中 20 是十进制的 2*16,即 32;在 URL 中要使用空格之处插入%20 即可。 上述例子的第一行定义了一个书签位置。可以注意到前缀#。在 Web 页面上会显示文本 Location Title。 第二个例子打开位于 mypage.com 的 Web 页面 index.html,并定位到#location 书签。最后一个例子在当前 页面内移动,因此并未给出页面位置。 插入图像 可以在 Web 页面中插入各种图像,包括 GIF、JPEG 或 BMP 格式。<img src="图形文件路径">标记用 于将图像嵌入到 Web 页面中。可以向图形标记加入高度和宽度属性,以限制图像的大小。alt 属性用于指 定 Web 页面在文本格式下用于替换图像的文本,其他情况下用于提示。border 属性用于指定图像周围边界 的宽度。这里有个例子: 第 17 章 使用 WebBroker 组件创建 Web 服务器 433 <img src="image/bookimg.gif" height=150 width=100 border=1 alt="Building Delphi 6 Applications"> 本例向 Web 页面嵌入了一幅 GIF 图像,150 像素高、100 像素宽、边界宽一个像素、提示为“Building Delphi 6 Applications”。当鼠标移动到图像上时,将显示包含 alt 文本的提示(在较新的浏览器中)。 格式化文本 HTML 支持很多种文本格式,在一定程度上依赖于浏览器对特定边界的渲染能力。通常,在大多数浏 览器中可以使用较为简单的标记。<i></i>标记表示标记之间的文本应该是斜体。与此相似,<b></b>标记 表示其中的文本显示为黑体;<u></u>对其中的文本添加下划线;而<s></s>标记向其中的文本添加删除线。 这里是一些例子: <b>bold</b> yields bold <i>italics</i> yields italics <u>underline</u> yields underline <s>strikethrough</s> yields strikethrough 文本格式化标记可以嵌入到其他种类的标记中,从而形成形式非常丰富的 Web 文档。 创建表格视图 有些浏览器支持帧。帧可以对 Web 文档进行嵌套,看起来像是单一的文档,这里并不讨论该机制。尽 管许多浏览器支持帧,但许多 Web 站点是使用表格而不是帧制作的。<TABLE>标记可以将页面的空间组 织起来并划分为区域。表格标记基本上支持对页面空间进行三维划分。<TABLE></TABLE>标记定义了表 格的边界。<TR></TR>标记定义了表格中的一行,而<TD></TD>标记则定义了表格中一个单元。另外,您 还可以指定表格头,它表示每一列的标题信息;同时,表格还以进行 n 维嵌套。可以想像到,这样的 HTML 文档看起来可能会复杂一些。关于 Web 页面设计有许多出色的书籍,我们在这里只介绍表格标记的基本应 用。 定义表格 将 HTML 文档中的表格等价于电子表格或数据库表是有道理的。HTML 表格标记可以将 页面划分为统一分割的区域。一种用法是用来代表基于表格的数据,另外还可以用于划分页面的区域和数 据。表格以<TABLE>标记开始,</TABLE>标记结束。表格也可以嵌套。例如,表格中的一个单元还可以 包含另一个嵌套的表格。 表格还支持一些属性标记,用于定义表格的显示方式。包括背景颜色、表格宽度、单元内部占位空间、 单元距离以及边界宽度等。宽度可以用像素数目或全部可用宽度的百分比来表示。下面列出的代码(其页 面显示如图 17.3 所示)示范了如何在表格标记中使用各种属性标记。 注意:本小节只强调了下面代码中的表格标记。代码的其他方面将在其后的三个小节中讨论, 我们将继续引用该代码。 <html> <body> <Table BGCOLOR=#88EE88" WIDTH="75%" CellPadding=10 CellSpacing=1 BORDER=1> <Caption>Sample Table</Caption> <TR><TH>Column1</TH><TH>Column 2</TH><TH>Column 3</TH></TR> <TR><TD ALIGN=center>Col 1, Row 1</TD><TD ALIGN=center>Col 2, Row 1</TD><TD ALIGN=center>Col 3, Row 1</TD> <TR><TD ALIGN=center>Col 1, Row 2</TD><TD ALIGN=center>Col 2, Row 2</TD><TD ALIGN=center>Col 3, Row 2</TD> </Table> </body> </html> 表格标记的 bgcolor 属性表示表格所显示的背景颜色。前面提到过,颜色属性可以用一个三元组表示, 第 17 章 使用 WebBroker 组件创建 Web 服务器 434 其中红、绿、蓝各 16 比特;也可用颜色名称表示,如 red。width 属性表示表格在页面的水平宽度所占的 百分比或像素数目。如果使用百分比表达,则表格的宽度可根据显示区域动态变化。CellPadding 属性表示 每个单独的数据单元中使用的额外空间的大小。CellSpacing 表示表格之间的距离大小。border 属性表示表 格边界的宽度。 将<CAPTION></CAPTION>标记放置在表格开始后、结束前,可以为表格添加相关联的标题。如图 17.3 所示,本例中与表格相关联的标题是“Sample Table”。 图 17.3 使用表格标记和相关属性进行格式化的文档 添加表格头 表格头标记<TH>表示 HTML 表格的列标题。请注意上面的代码,其中分别向表格的三 列添加了列标题 Column1、Column2、Column3。 定义表格行 表格的每一行数据都使用<TR></TR>标记表示。即使在 HTML 源文件中标记之间包括 了许多行文本也是如此,该标记指示浏览器将标记之间的所有东西都作为表格的一行数据来显示。 向表格添加数据 单独的表格元素使用<TD></TD>标记表示,如代码所示。由于单一的数据元素可以 包含简单的文本或嵌套表,因此其工作方式非常简单。请注意,<TD>标记可能会包含一些额外的属性, 如代码中所示的 ALIGN 属性。 提示:为建立有趣的 Web 页面,一个好方法是复制已存在的页面,并进行定制。 很明显,单独的标记是易于理解的。HTML 的潜在威力正在于这种简单性。如同任何语言的语法,我 们进行通信的能力受对语言的理解的限制。虽然 HTML 是简单的,考虑到 26 个拉丁字母——其可能性确 实是令人吃惊的,正像 World Wide Web 一样。如果需要创建高级的 Web 页面,还需要进一步学习,本节 只是提供了进阶的基础。 17.1.3 将可替换参数标记与 WebBroker 一同使用 专业水准的 Web 页面需要更多的努力,并广泛地使用 HTML,其内容远远超过这里所介绍的。除了需 要创建基本的 Web 页面之外,还需要表示在动态 Web 页面中可替换的标记;这些标记将使用由 Web 服务 器传送的动态数据替换。可替换参数标记与书签标记看起来很相似。 可替换参数在 Delphi 的 Web 服务器页面中使用<#标记名>表示。对于并不表示有效的超文本标记的< > 对,HTML 惟一的行为是忽略掉这些标记。但 Delphi 组件可以查找一些特定格式的文本。 注意:TPageProducers 的 TagString 行为与 Format 函数行为非常相似。当遇到特定的标记 时,就使用格式化文本替换该标记。Format 函数使用字符串参数,以%开头,后接特殊的格 式化字符;而 HTML 文档中遇到<#标记名>时,则触发 OnHTMLTag 事件。显然,一旦该类型事 件发生,即可使用必要的代码进行合适的替换。 当为 Web 服务器设计页面时,当需要向 WebBroker 页面插入数据时,只要找到<#标记名>参数标记即 可。TPageProducer 组件的 OnHTMLTag 事件会传递一个 TagString 参数,可以将其与预先确定的值进行比 较,然后根据 TagString 的值采取合适的动作。 下面一节示范了 WebBroker 组件一些基本的方法、特性和事件,可用于建立 Web 服务器。 第 17 章 使用 WebBroker 组件创建 Web 服务器 17.2 435 使用 WebBroker 组件 Delphi 的 WebBroker 套件定义了一些组件,能够对 URL 请求进行响应;这些 URL 请求是由浏览器或 HTTP 服务器应用程序向基于 WebBroker 套件建立的服务器发出的。有一些基本的组件可以支持这种行为 特征,而服务器的其余部分则是由您业已熟悉的组件构成的。 WebBroker 支持建立 ISAPI、NSAPI、CGI 和 Apache 服务器。ISAPI 和 NSAPI 服务器分别是由 Microsoft 和 Netscape 的 Internet 服务器支持的。而 Apache 服务器则是一种流行的 Internet 服务器;Delphi 6 的 WebBroker 组件也支持建立 Apache 服务器。Delphi 的 WebBroker 支持 CGI 服务器,可建立为单独的控制 台程序或 Windows 程序,将请求返回到标准输入/输出设备。大多数流行的浏览器都支持 CGI。 所有的服务器程序都以 WebDispatcher 组件开始,我们在讨论 WebBroker 组件时也从这里开始。 17.2.1 WebDispatcher 组件 所有的 Web 服务器程序都包含了一个单独的 WebDispatcher 组件,当使用 New Items 对话框中的 Web Server Application 向导启动新的工程时,该组件将自动添加到 TWebModule 组件中;也可以手工从组件面 板 的 Internet 属 性 页 向 已 有 的 TDataModule 组 件 添 加 。 特 别 是 , TWebDispatcher 组 件 是 TCustomWebDispatcher 组 件 的 后 代 , 子 类 化 TDataModule 并 实 现 了 IWebAppDispatcher 和 IWebDispatcherAccess 接口。TWebDispatcher 组件、IWebAppDispatcher 和 IWebDispatcherAccess 接口支持 添加动作项与函数,动作项用于响应 Web 请求,而函数用于返回 TWebResponse 和 TWebRequest 对象。 TWebResponse 和 TWebRequest 对象用于交换所请求的数据和返回到 Web 服务器的数据。当向数据模块添 加 TWebDispatcher 组件时,与定义 TWebModule 对象在效果上是相同的。 TWebDispatcher 包含一个 TWebActionItems 的集合。这些项可用于为 URL 响应和请求定义路径信息。 如果使用 Web Server Application 向导,则 Delphi 将自动创建一个 TWebModule 对象,该组件实现了 TWebDispatcher 的接口。每个 Web 服务器只需一个 TWebDispatch 对象:可以向 TDataModule 组件添加 TWebDispatcher 组件,也可创建 TWebModule 组件。 TWebModule 组件 TWebModule 由 TDataModule 子类化而来,并支持添加和管理 TWebActionItems 以及非可视化控件, 这些组件对于使用 WebBroker 套件创建 Web 服务器是必要的。除了 TWebModule,还需要添加其他 VCL 控件,如 TPageProducer,该组件以 URL 路径信息和查询数据的形式对发送到 Web 服务器的请求进行响应 (请回忆在本章开始讨论的 URL 的各个部分)。 TWebActionItem 用右键单击 TDataModule 中的 TWebDispatcher 组件或 TWebModule 中的任意点,然后从上下文菜单中 选择 Action Editor 菜单项,即可启动 WebAction 编辑器。WebAction 编辑器(如图 17.4 所示)可以为 Web 服务器定义路径信息。 在动作项中包括了动作的名字、触发该动作的 URL 路径信息、该动作项是否可用、某个特定的动作 是否是默认的动作以及可选的页面生成器。最低程度也需要一个默认动作,而且可能只有一个。默认动作 是某个特定的请求不存在匹配路径时触发的动作。在图 17.4 中,该动作项名为 Root。路径为/root。该动作 是可用的,并且是默认动作。当触发该动作时,使用 PageProducer1 组件来生成相应的页面。 图 17.4 WebAction 集合编辑器可以定义路径信息, 用于浏览器或其他来源发出的 URL 请求中 第 17 章 使用 WebBroker 组件创建 Web 服务器 436 注意:当某个动作是默认动作时,忽略 Enable 特性。默认动作总是可用的。 假定图中所示的 Root 动作是为驻留在 LocalHost 计算机上的 ISAPI 服务器 server.dll 定义的。下面的两 个 URL 都会返回由 PageProducer1 组件所定义的响应。 http://LocalHost/scripts/server.dll http://LocalHost/scripts/server.dll/root 除了 WebAction 编辑器中所显示的信息之外,还可以指定 MethodType。默认的 MethodType 是 mtAny。 默认的 MethodType 必须对任何请求进行响应,其他可能的 MethodType 包括 mtGet、mtHead、mtPost 和 mtPut。mtGet 类型请求用于获取与 URL 关联的信息。mtPut 类型用于替换与 URL 关联的信息的内容。 mtPost 用于将请求的内容发送到服务器。最后,mtHead 类型只返回请求的头信息。 当 Web 服务器从 HTTP 服务器接收到路径和查询信息时,首先从动作项列表中查找匹配的路径和 MethodType。如果没有匹配的动作,则使用默认动作。另一方面,找到匹配动作后,则调用 OnAction 事 件。OnAction 事件处理程序的参数包括:调用对象的引用、TWebRequest 对象、TWebResponse 对象和一 个布尔类型的变量参数 Handled。TWebRequest 对象 Request 包含了发送到 HTTP 服务器的请求的所有信息。 TWebResponse 对象 Response 可用于将内容返回到服务器。Content 特性可用于返回简单的文本,而 ContentStream 和 ContentType 特性可用于返回较为复杂的二进制数据如图像或大块的文本。变量参数 Handled 用于表示事件处理程序是否完全处理了该请求。将 Handled 设置为 False 表示还需要其他处理程序 来完成对请求的处理。 procedure TDataModule2.WebDispatcher1GetHeadAction(Sender: TObject; Request: TWebRequest; Response: TWebResponse; var Handled: Boolean); const sContent = '%s %s <BR> Connection: %s <BR> User-Agent: %s <BR>' + 'Host: %s <BR> Accept: %s <BR>'; begin with Request do Response.Content := Format(sContent, [Method, URL, Connection, UserAgent, Host, Accept]); end; 在这个简明的例子中,一个名为 GetHead 的动作的 OnAction 事件处理程序返回了所有请求的头信息。 在下面列出了输出。 GET /scripts/GetHead.dll Connection: Keep-Alive User-Agent: Mozilla/4.0 (compatible; MSIE 5.5; Windows NT 5.0; COM+ 1.0.2204) Host: LocalHost Accept: */* 请求的头信息包含了请求的方法类型和 URL。从例子中可以知道,Get 请求是由浏览器发出的,本例 中的 Web 服务器是 GetHead.dll。Connection 状态表示该连接不应关闭。User-Agent 表示了发出请求的程序 的有关信息。在本例中,通过该信息可以确定该程序是 Internet Explorer 5.5,运行在 Windows 2000 系统上。 Host 是服务器所在的站点。从列出的信息可以看出,Web 服务器是运行在 LocalHost;即 Internet 服务器、 Web 服务器和浏览器都运行在同一台计算机上。Accept 表示构成有效响应的媒体类型。其值为*/*,表示 所有类型的数据都可以作为响应的内容。 还有一些示范 OnAction 以及使用响应和请求对象的例子,在本章稍后有关创建 Web 服务器的章节中 将会涉及到。 第 17 章 17.2.2 使用 WebBroker 组件创建 Web 服务器 437 TPageProducer 组件 TPageProducer 组件可以指定 HTML 文档模板,存储在 TStrings 类型特性 HTMLDoc 中,或者通过 HTMLFile 特性来引用。HTMLFile 和 HTMLDoc 特性是互斥的。HTML 模板可以由完整的 HTML 文档构 成,其中也可包括对 HTML 透明的标记,在请求该文档时使用特定的数据进行替换。如果将 TPageProducer 组件与 TWebActionItem 的 Producer 特性关联起来,那么在 URL 中包括相关联的路径时,将返回 TPageProducer 组件的 HTML 文档。从图 17.4 可知,GetHead.dll 服务器的默认路径 Root 将返回 PageProducer1 组件的 HTMLDoc 特性。 如果 TPageProducer 组件包含需要替换的透明标记,则需要实现 TPageProducer.OnHTMLTag 事件处理 程序对标记进行动态替换。如果页面生成器包含了完整的 HTML 文档,就不需要 OnHTMLTag 事件处理程 序。下面的 HTML 文档示范了文档体中透明标记的使用。 <html> <body> <#CURRENTTIME> </body> </html> 假定上面的 HTML 与例子中的默认 URL 路径 Root 相关联,那么在请求默认响应时,将对文档中的每 个标记调用相关联的 PageProducer1 的 OnHTMLTag 事件处理程序。在例子中,只有一个 HTML 透明标记: <#CURRENTTIME>。 将 TagString 特性与已知的标记进行比较,可以对遇到的每个标记都提供替换文本。下面列出的代码 将 TagString 参数与 CURRENTTIME 标记进行比较。当遇到 CURRENTTIME 标记时,即可如下例所示使 用系统时间对其进行替换。 procedure TDataModule2.PageProducer1HTMLTag(Sender: TObject; Tag: TTag; const TagString: String; TagParams: TStrings; var ReplaceText: String); begin if( CompareText(TagString, 'CURRENTTIME') = 0 ) then ReplaceText := TimeToStr(Now); end; 提示:在比较标记字符串时,尖括弧和#符号被忽略。<#和>的作用是使得标记对 HTML 阅读 器透明。 页面生成器的默认行为是使用空字符串替换不匹配的标记。在 TTag 参数中含有枚举值,表示了发现 该标记的上下文。也可以向透明标记添加标记属性。标记属性添加到 TStrings 类型参数 TagParams,在 OnHTMLTag 事件处理程序中进行处理。为示范 TagParams 参数的用法,下面分别对 HTML 文档和事件处 理程序进行了修订: <#CURRENTTIME DateTimeFormat=hh:nn:ssa/p> 修订的标记添加了一个参数,在本例中将作为标记的格式化规则。修改后的事件处理程序考虑了标记 参数的可能性。 procedure TDataModule2.PageProducer1HTMLTag(Sender: TObject; Tag: TTag; const TagString: String; TagParams: TStrings; var ReplaceText: String); begin if( CompareText(TagString, 'CURRENTTIME') = 0 ) then if( TagParams.Values['DateTimeFormat'] <> '' ) then ReplaceText := FormatDateTime( TagParams.Values['DateTimeFormat'], Now) else 第 17 章 使用 WebBroker 组件创建 Web 服务器 438 ReplaceText := TimeToStr(Now); end; 修订后的代码检查是否存在 DateTimeFormat 参数。如果该参数非空,则对时间值使用格式化规则;否 则默认行为就是使用 TimeToStr 将时间转换为字符串,并返回该字符串。 17.2.3 TDataSetPageProducer 组件 TDataSetPageProducer 可 以 将 TDataSet 组 件 关 联 到 TDataSetPageProducer 组 件 。 当 请 求 TDataSetPageProducer 的内容时,页面生成器将把 HTML 透明标记自动与字段名进行匹配,并使用数据集 中的匹配字段替换标记的当前值。考虑下面的 HTMLDoc 特性值。 <HTML> <HEAD> <TITLE>Customer Orders</TITLE> </HEAD> <BODY> <TABLE BORDER=0> <TR><TH ALIGN=LEFT>Customer No.:</TH><TD><#CustNo></TD></TR> <TR><TH ALIGN=LEFT>Order No.:</TH><TD><#OrderNo></TD></TR> <TR><TH ALIGN=LEFT>Date of Sale:</TH><TD><#SaleDate></TD></TR> <TR><TH ALIGN=LEFT>Amount:</TH><TD><#AmountPaid></TD></TR> <TR><TH ALIGN=LEFT>Ship Date:</TH><TD><#ShipDate></TD></TR> </TABLE> </BODY> </HTML> 上面的 HTML 文档中包含了五个 HTML 透明标记:<#CustNo>、<#OrderNo>、<#SaleDate>、 <#AmountPaid>和<#ShipDate>。根据设计,这些都是存储在 Orders.db 表中的字段。下面的 OnAction 事件 将 Orders.Content 特性赋值给 Response.Content 特性。 procedure TWebModule1.WebModule1OrdersAction(Sender: TObject; Request: TWebRequest; Response: TWebResponse; var Handled: Boolean); begin TableOrders.Open; try Response.Content := Orders.Content; finally TableOrders.Close; end; end; Orders 是一个 TDataSetPageProducer 对象,其中包含上述的 HTML 文档。Orders.DataSet 与一个 TTable 组件相关联,该组件表示 Orders.db 表(Orders.db 与 Delphi 一同发布,可通过 BDE 别名 DBDEMOS 引用)。 Orders 对象将把标记自动地替换为与标记名匹配的字段值。由于代码只是把表打开,因此字段的值将是表 中第一行所包含的值。 对 Orders 页 面 生 成 器 进 行 微 小 的 修 改 , 即 可 使 用 浏 览 器 的 刷 新 按 钮 看 到 所 有 的 订 单 。 在 TDataSetPageProducer.OnCreate 事件处理程序中打开相应的表。在 TDataSetPageProducer.OnDestroy 事件中 关闭表,在每次触发 WebAction 时将当前记录指针向前移动一个记录。下面列出了修订后的代码。 procedure TWebModule1.WebModule1OrdersAction(Sender: TObject; Request: TWebRequest; Response: TWebResponse; var Handled: Boolean); begin 第 17 章 使用 WebBroker 组件创建 Web 服务器 439 Response.Content := Orders.Content; if Not TableOrders.Eof then TableOrders.Next else TableOrders.First; end; procedure TWebModule1.OrdersCreate(Sender: TObject); begin TableOrders.Open; end; procedure TWebModule1.OrdersDestroy(Sender: TObject); begin TableOrders.Close; end; 尽管这对于浏览所有的记录而言是一种呆板的方法,但它是可用的,而且可以作为更高级技术的基础。 看到多条记录的更为方便的方法是使用下一节中示范的 TDataSetTableProducer 组件。 17.2.4 查看表数据 TDataSetTableProducer 组件用于返回多行数据。它可以定义数据的表格视图(见图 17.5),并在请求该 路径时显示多行数据。返回的实际行数由 TDataSetTableProducer.MaxRows 特性决定。TDataSetTableProducer 组件包括了许多用于描述和添加页面内容的特性,如 Footer、Header、Caption 和 RowAttributes 等。如果 只是简单地把 TTable 或 TQuery 对象赋值给 TDataSetTableProducer.DataSet 特性,则页面上会显示数据集中 所有的列。可以选择使用列编辑器,只返回列的特定子集。 图 17.5 HTML 表格,其内容来自于 ORDERS.DB 数据, 是通过 TDataSetTableProducer 组件得到的 单击 TDataSetTableProducer 组件上下文菜单中的 Response Editor 菜单项,即可运行 Columns 编辑器(如 图 17.6 所示) 。该编辑器与 TDBGrid 的 Columns 编辑器非常相似。 但 TDataSetTableProducer 的 Columns 编辑器并不是把列值返回到 VCL 控件,而是生成必要的 HTML 文档,将其与根据选定的列得到的静 态字段值合并起来。 第 17 章 图 17.6 使用 WebBroker 组件创建 Web 服务器 440 要创建如图 17.5 所示的 HTML 文档,可在 Columns 编辑器中如图配置 图 17.5 中 所 显 示 的 示 例 页 面 在 本 书 CD-ROM 的 MastApp.dll 中 定 义 。 通 过 在 TDataSetTableProducer.OnFormatCell 事件中修改背景颜色,即可产生该页面的效果。OnFormatCell 事件的 代码如下所示: procedure TWebModule1.ManyOrdersFormatCell(Sender: TObject; CellRow, CellColumn: Integer; var BgColor: THTMLBgColor; var Align: THTMLAlign; var VAlign: THTMLVAlign; var CustomAttrs, CellData: String); begin if CellRow = 0 then BgColor := 'Gray' else if CellRow mod 2 = 0 then BgColor := 'Silver'; end; 要创建所示的页面,可按照下列步骤进行。 1.单击 File | New | Other 命令,创建新的 Web 服务器程序,并创建 ISAPI Web Server Application。 2.从组件模板的 Internet 属性页添加一个 TDataSetTableProducer 组件,从 Data Access 属性页添加一 个 TTable 组件。 3.将 TTable.DatabaseName 特性设置为 DBDEMOS。 4.将 TTable.TableName 特性设置为 orders.db 表。 5.将 TDataSetTableProducer.DataSet 特性设置为 TTable 组件。 6.将上面列出代码添加到 TDataSetTableProducer.OnFormatCell 事件处理程序。 7.在 TWebModule 上单击右键,然后在 TWebModule 上下文菜单中单击 Action Editor 菜单项。 8.添加动作项 ManyOrders。 9.对动作项的 PathInfo 特性输入文本/ManyOrders。确认该动作是可用的,并将 Producer 特性设置为 TDataSetTableProducer 组件。 确认 Enabled 特性设置为 True (可以在 Object Inspector 中进行所有的改动) 。 10.将工程保存为 MastApp。并建立该应用程序。 编译该 ISAPI 服务器并将其复制到 Web 服务器脚本目录(请记住,如果在计算机上你有 Peer Web Services 或在 Windows NT 下有 IIS,即可测试该服务器)。假定您是在 Windows 2000 系统上运行 IIS,默 认目录为 inetpub,可以通过输入地址 http://localhost/scripts/MastApp.dll/ManyOrders 来运行服务器。 下一节示范了 TQueryTableProducer 组件的用法以及用于响应 URL 中查询的 TWebRequest 对象。 第 17 章 17.2.5 使用 WebBroker 组件创建 Web 服务器 441 TQueryTableProducer 组件 TQueryTableProducer 组件用于为 TWebRequest.QueryFields 特性中的 GET 请求查询字段信息;POST 方法的信息通过 TWebRequest.ContentFields 特性传递到服务器。上述两个特性均为 TStrings。可以使用 TStrings 的 Names 和 Values 刷新得到传递给服务器的数据。 为演示 TQueryTableProducer 组件,我们需要看一下位于$(DELPHI)\Demos\Internet WebServ\IIS 文件夹 下的 iserver.dpr 工程。iserver.dpr 例子通过对 DBDEMOS 数据库中的 Customer.db 表进行迭代,使用 TPageProducer 组件生成顾客列表。WebAction 定义在 main.pas 中,以返回表示每个顾客的超链接列表。 procedure TCustomerInfoModule.CustomerListHTMLTag(Sender: TObject; Tag: TTag; const TagString: String; TagParams: TStrings; var ReplaceText: String); var Customers: String; begin Customers := ''; if CompareText(TagString, 'CUSTLIST') = 0 then begin Customer.Open; try while not Customer.Eof do begin Customers := Customers + Format( '<A HREF="/scripts/%s/runquery?CustNo=%d">%s</A><BR>', [ScriptName, CustomerCustNo.AsInteger, CustomerCompany.AsString]); Customer.Next; end; finally Customer.Close; end; end; ReplaceText := Customers; end; TPageProducer 类型的组件 CustomerList 将<#CUSTLIST>用每个顾客的超链接进行替换。有一行特定 的 HTML 语句是使用上面的事件处理程序中的 Format 语句生成的。 <A HREF="/scripts/%s/runquery?CustNo=%d">%s</A><BR> %s 参数是 Web 服务器程序的名字。可以对该值进行硬编码,在数据模块创建时使用 API 过程 GetModuleFileName 来得到这个值就更为灵活。 procedure TCustomerInfoModule.DataModule1Create(Sender: TObject); var FN: array[0..MAX_PATH- 1] of char; begin SetString(ScriptName, FN, GetModuleFileName(hInstance, FN, SizeOf(FN))); ScriptName := ExtractFileName(ScriptName); end; 注意:请记住,可以使用 TTable 或 TQuery 的字段编辑器在设计时生成 TField 组件。 第 17 章 使用 WebBroker 组件创建 Web 服务器 442 路径信息/runquery 代表了一个动作项。查询是?CustNo=%d,其中%d 表示整型参数,将使用 CustomerCustNo.AsInteger 来替换,并向用户提供超链接以及从 TField 对象 CustomerCompany 读出顾客名。 在指向每个顾客的超链接组装好以后,将使用这些链接替换 ReplaceText 变量参数。 当 TPageProducer 对象 CustomerList 使用顾客列表进行响应时,单击任意的顾客超链接都会把 CustNo 发送到 QueryAction 项。QueryAction 项是动作项的名字,由/runquery 路径表示。发送到/runquery 动作项 的请求出现在浏览器的地址栏中,其形式如下(假定我们选中了 Action Club 链接) : http://localhost/scripts/iserver.dll/runquery?CustNo=1645 当向 Web 服务器提供路径时,将调用 QueryAction.OnAction 事件,并照原 TQueryTableProducer 对象 CustomOrders 的内容。在事件处理程序的实现中,TQuery 的 Locate 方法用于只返回与每个特定顾客相关 联的订单。 procedure TCustomerInfoModule.WebModule1QueryActionAction(Sender: TObject; Request: TWebRequest; Response: TWebResponse; var Handled: Boolean); begin Customer.Open; try if Customer.Locate('CustNo', Request.QueryFields.Values['CustNo'], []) then begin CustomerOrders.Header.Clear; CustomerOrders.Header.Add( 'The following table was produced using a'+ TQueryTableProducer.<P>'); CustomerOrders.Header.Add( 'Orders for: ' + CustomerCompany.AsString); Response.Content := CustomerOrders.Content; end else Response.Content := Format( '<html><body><b>Customer: %s not found</b></body></html>', [Request.QueryFields.Values['CustNo']]); finally Customer.Close; end; end; 传递到 Web 服务器的查询信息存储在 Response.QueryFields 特性中。从上面的代码可知,响应的内容 是直接由 TQueryTableProducer 组件 CustomerOrders 返回的。 17.3 使用 Cookie Cookie 是一些小块的数据,存储在运行浏览器的客户端 PC 上。Cookie 用于保存浏览器会话的持久信 息,即对状态进行持久化。TWebRequest 组件包含一个未分析的 cookie 特性,用于表示 cookie 头,以及一 个 CookieFields 特性,把 cookie 字段按逐字段的名-值对存储在 TStrings 对象中。TWebRequest.Cookie 特性 与 HTTP 请求一同发送的 cookie 头。TWebResponse.Cookies 特性类型为 TCookieCollection,包含了对请求 进行响应所需的所有 cookie 头。 TCookieCollection 包含单独的 TCookie 对象,该对象含有 Domain、Expires、HeaderValue、Name、Path、 Secure 和 Value 特性。Domain 特性限制了接收者的域——即 cookie 发送到哪个站点。Expires 特性表示什 么时候不再发送 Cookie。HeaderValue 包含了 cookie 的名字和值,以及 Domain、Expires 和 Secure 字段的 第 17 章 使用 WebBroker 组件创建 Web 服务器 443 字符串值。Name 特性用于通过名字标识 cookie,而 Value 特性则包含了 cookie 所存储的值。Path 特性将 cookie 接收者限制到特定的路径。如果 Secure 特性为 True,则 cookie 只发送到使用安全 socket 的连接(安 全 socket 使用 HTTPS 协议)。 提示:由 CGI 服务器 cookie.exe 所返回的 Web 内容演示了如何使用嵌套表格来划分 HTML 文 档。通过设置边界,使得可以看到页面被划分。将边界宽度设置为零来隐藏表格的边界,这 样无需使用帧即可生成专业水准的页面。 通过对 iserver.dpr 进行改编,下面的代码演示了如何存储 cookie 来跟踪访问某一页面的次数(参见图 17.7) 。下面列出的代码是 OnHTMLTag 的处理程序,示范了如何对工作进行划分,使实际的事件处理程序 保持比较简单。 图 17.7 显示的 Web 页面是由 CGI 服务器程序生成的,该程序 存储了一个 cookie,用于标识用户的刷新或访问次数 procedure TWebModule1.RootHTMLTag(Sender: TObject; Tag: TTag; const TagString: String; TagParams: TStrings; var ReplaceText: String); begin if( TagString = 'COUNT' ) then ReplaceText := GetCountText( Request, Response ) else if( TagString = 'COOKIEDATA' ) then ReplaceText := GetCookieText( Request, Response ); end; 如果遇到<#COUNT>标记,则调用 GetCountText;如果 TagString 是<#COOKIEDATA>,则调用 GetCookieText(参见图 17.8 的例子,在 cookie 文件中包含 cookie 计数值)。GetCookieText 的实现从 TWebRequest.CookieFields 中读出所要求的 cookie 的名字和值。 图 17.8 cookie 相应的文本文件包含了 cookie 的信息。 图中 cookie 的当前计数值是高亮显示的 function TWebModule1.GetCookieText( Request : TWebRequest; Response : TWebResponse ) : String; begin 第 17 章 使用 WebBroker 组件创建 Web 服务器 444 result := 'Before increment:'+Request.CookieFields[0]; end; GetCountText 向 cookie 集合添加一个 cookie,分配 cookie 和名字,在 cookie 存在的情况下读出其值。 在把 cookie 转换为整数再逆向转换时使用了异常处理块,以防在第一次访问时 cookie 值未初始化的情况。 该 cookie 在一天后过期,就不是安全的 cookie,而且只作为对任意域的响应发送,而且路径限制为 /scripts/Cookie.exe/。 function TWebModule1.GetCountText( Request : TWebRequest; Response : TWebResponse ) : String; begin with Response.Cookies.Add do begin Name := 'Count'; Value := Request.CookieFields.Values['Count']; try Value := IntToStr(StrToInt(Value) + 1); except Value := '1'; end; Secure := False; Path := '/scripts/Cookies.exe/'; Expires := Now + 1; // this cookie expires in one day result := Value; end; end; 现在可以发现,Web 编程还是有许多事情要做的。显然需要掌握 HTML 编程,包括向 Web 页面添加 输入域和其他可视化控件。请记住,在选择 Web 内容时有几种可能性。Web 服务器可以返回包含 JavaScript 或 VBScript 的 ASP,也可以是 XML、WML。每个协议都有其自身的语法和要求,但都可以创建内容丰富 的 Web 站点。采用工具来方便文档的创建,和使用页面生成器的 HTMLFile 特性,都是好主意。使用工具 可以减轻学习不同协议的负担,而 HTMLFile 特性可以将外部的 HTML 模板与页面生成器关联起来。通过 使用外部 HTML 模板,可以修改 Web 内容而无需重建 Web 服务器程序。 17.4 小 结 本章对 HTML 进行了综述。超文本标记语言可以创建丰富的 Web 页面,而无需多少真正的聪明。对 于创建 Web 的内容来说,较好的起步策略是模仿您所喜欢的站点。可以看到大多数 Web 站点的源代码, 并将其保存到本地计算机。HTML 包含的标记还有很多,可用于指示浏览器如何显示信息,这已经超出了 本章的范围,但您可以使用类似 FrontPage 的工具来跳过复杂标记的学习。 WebBroker 套件支持使用 HTML 透明标记,当浏览器请求 Web 页面时可以对这些标记进行动态替换。 WebBroker 有一些工具,如 TPageProducer 可以响应特定的 URL 路径和查询信息,并根据 HTML 模板、 HTML 透明标记和从数据库得到的信息进行动态的信息替换。 本章中您已经学到了 WebBroker 的一些知识。更多的信息请阅读附录 D。附录 D 使用 WebBroker 来返 回供无线连接使用的 WML(Wireless Markup Language,无线标记语言)的内容,例如与因特网蜂窝电话 一起使用的那些连接。 第 18 章 创建 Windows 程序 第 18 章将对已经介绍过的技术进行巩固,并示范如何使用这些技术来开发实际的程序。本章中的第 一个程序是 Rich Text 编辑器。之所以选择这个例子,是因为在第 14 章中您已经熟悉了 RichEdit 控件;这 里将着重强调 Delphi 为开发 Windows 程序所提供的各种支持。 本章中的应用程序并不难于开发,但却足以用于讨论应用程序开发的许多方面。而且 Delphi 6 引入了 TAction 组件,可以有效地减少所需编写代码的数量。由于前面的章节并未涵盖 TActionList 和 TAction 组 件,本章将介绍这两个新的组件。另外,在示例程序中使用了 MDI(多文档界面) ,前面的章节也没有涉 及到。 为演示如何建立 RichEditor.exe 例子程序,我们在本章中将涵盖如下内容:分析与设计、工程的准备 工作、MDI 的使用、Windows 注册表的管理、添加帮助文档,以及部署程序的准备工作。读完本章后,您 可以全面了解 Delphi 所提供的工具,以及一些可用于支持开发具有专业水准应用程序的工具。 18.1 准 备 工 作 工程的准备工作有许多种形式。如果要建立原型,那么只要启动 Delphi 并了解要建立什么原型即可。 在开发不太复杂的应用程序时,如本章中的例子,对工作进行一下综述也就足够了。如果开发的应用程序 只供自己使用,可以跳过这一步。如果程序是供内部合作使用或为客户开发的,那么对工作陈述一下也就 可以了。 比本章中的 RichEditor 更为复杂的程序,都至少需要进行文档化的、正式的分析与设计。不幸的是, 事实刚好相反。许多程序都只是由管理者和程序员开发的,其中缺少一些必要的角色,如体系结构设计师、 分析人员、设计人员、产品经理、工程经理、测试人员、质量保证负责人、文档专家、工具建立者、以及 库管理人员。想像一下,如果航班没有行李管理人员、旅行代理人、售票代理人、保安人员、飞行员、副 驾驶员、领航员、以及服务人员,那会出现什么样的混乱。当开发复杂软件时,每个人可能会担任多个角 色,但是像体系结构设计师和工程经理这样的角色,其责任实在重大,所以要确保每个这样的角色至少由 一个人来担任。对于体系结构设计师来说,由一个人建立的单一而一致的概念化模型可能是最好的。 程序员开发 RichEditor 程序的复杂程度,与飞行员驾驶单引擎小飞机相似。大多数情况下,一个单人 小组即可完成工作。但是要谨慎,组织和计划即使对于单人小组也是很重要的。 对于我们的简单例子程序,单个人就能够担任所有的角色。对工作进行一下陈述也就足够了。可以把 工作描述为: “实现文本编辑器,能够同时读写多个 Rich Text 或 DOS Text 格式的文档。” 注意:如果您所在的工程不只一个人,那么使用版本控制产品并形成一致的目录结构是很重 要的,这样可以有效地减少协作者之间的问题,还可以加快新人融入到团队的转变过程。随 着时间过去,任何可以减轻工作负担的措施都会得到回报,即使非常简单的应用程序也是如 此。 对于我们的简单程序来说,最后一点就是要组装有用的目录结构,并实现版本控制机制。基本的目录 结构对于工程的组装是很有帮助的。 18.1.1 大有帮助的简单工作 当与其他开发者一同工作时,或同时开发多个工程时,基本的目录结构可以有效地减少迷惑。RichEditor 的目录结构以目录树的形式如下列出。 -RichEditor 第 18 章 创建 Windows 程序 451 -Bin -Documents -Help -Output -Source -VCL 当然,您可以使用任何目录结构,只要适合需求即可。但选定一种目录结构并保证一致的使用,可以 减少源代码文件的混乱,而且 Delphi 还支持在多个目录进行不同的输出。Bin 目录在 RichEditor 工程中用 于编译过的可执行文件,本例中 RichEditor.exe 在编译后将写入到 Bin 子目录。Output 目录用于存储编译 过的单元,即.DCU 文件,将其与源文件隔离开来。源代码文件将存储在 Source 子目录中。 18.1.2 版本控制 版本控制机制会跟踪源代码相对于时间的演化。您可以将单独的文件或整个的应用程序回复到以前的 版本。有些产品还可以将缺陷与源代码关联起来,维护问题出现和解决时的线程信息。如果没有版本控制 程序,您可以买一个。有许多可以选择的产品。高端产品包括 Harvest、Clearcase 和 PVCS。低端和中级产 品包括 SourceSafe 和 StarTeam。所有这些产品都提供了基本的能力,可以将您的工作随时间的演化存储为 多个版本。 注意:Starbase 公司的 StarTeam 是相对较新的产品,包括服务器、桌面客户、Web 界面, 提供了源代码管理、缺陷跟踪和线程信息功能。 对于 RichEditor,使用了 Microsoft 的 SourceSafe 产品。它相对较为便宜,并容易得到。SourceSafe 的 工作方式与 Windows 文件系统非常相似。可以将程序中的所有目录和文件都添加到 SourceSafe。然后 SourceSafe 将对源代码(或其他检入的文件)进行写保护。当您打算修改文件时,可以检出这些文件。本 质上,检出文件将把文件复制到本地驱动器并使之可写。 注意:对于您所使用的产品,请查询用户指南。不幸的是,即使是版本控制机制也并未一致 采用。它需要软件过程管理机制的支持,许多开发团队尚未使用源代码控制机制。因此,关 于如何使用源代码控制程序的书籍也相对较少。实际上,只要人需要在计算机上进行脑力工 作,那么任何行业都需要版本控制产品,如律师、法律书记人员和图形艺术家。 通过使用诸如 StarTeam 和 SourceSafe 之类的版本控制产品,可以避免在不注意的情况下修改或删除文 件,并阻止可能出现的并发修改,排除代价高昂的改动丢失情况。 18.2 开发中的 Delphi 工程选项 在开发过程中,Delphi 的 IDE 中的工程选项可以进行限制性最强的设置。这使得 Delphi 能够尽早的帮 助发现和解决问题。Project Option 对话框包括许多属性页,其中有许多有用的选项,有助于工程的一般组 织并可以向每个应用程序添加特定于工程的细节。 18.2.1 应用设置 Project Option 对话框的 Application 属性页可用于指定应用程序的标题、帮助文件的位置,以及工程的 图标。应用程序的标题是 Rich Text Editor,并选定了一个合适的图标。可以使用 Browse 按钮来指定帮助 文件。这些选项被编码到工程的.DPR 文件中,如下所示。 Application.HelpFile := 'E:\Books\Osborne\Delphi 6 Developer''s Guide\Chapter 18\Examples\RichEditor\Help\RichEditor\RICHEDITOR.HLP'; 请记住,设计时的环境可能无法映像到部署环境。实际上部署环境是无法预测的,因此诸如帮助文件 路径的信息必须通过编程动态确定。在主窗体的 FormCreate 事件中添加下面的代码,即可解决该问题。 第 18 章 创建 Windows 程序 452 Application.HelpFile := ExtractFilePath(Application.EXEName) + 'RichEditor.hlp'; 除了要编写代码来动态解析帮助文件的路径之外,需要把动态信息存储到注册表或.INI 文件中。 RichEditor.exe 程序使用了注册表。 18.2.2 设置运行时错误 在 Project Option 对话框的 Compiler 属性页上选定所有的运行时错误。虽然这些选项——范围检查、 I/O 检查、溢出检查——增加了编译后代码的开销,但它们在开发期间有助于发现错误,而发布程序时 可以关闭这些选项。 范围检查 当修改 Compiler Project 选项后,必须重新编译程序才能使新的设置发挥作用。范围检查可以捕捉与索 引过界有关的错误。例如,使用小于下界或大于上界的索引值索引数组 A 将引用未初始化的内存或其他数 据的内存,从而导致未定义的行为。如果编译时打开范围检查选项,编译器将添加代码,用于捕捉索引过 界错误。 范围检查对应于{$R+}编译器开关。如果启用了范围检查,将检查所有的数组和字符串索引表达式。 如果索引过界,将引发 ERangeError 异常。范围检查增加了程序的开销,降低了程序的速度,最后的、发 布之前的编译要关闭该选项。 输入/输出检查 I/O 检查将检测所有文件输入输出操作的返回值。如果结果是非零的,将引发 EInOutError 异常。如果 取消 I/O 检查选项,则需要调用 IOResult 来检查 I/O 函数的返回值。在调试过程中,打开这个开关,把编 译器生成的 I/O 检查与 IOResult 代码合并起来,因为在取消 I/O 检查进行最后一次编译时,仍然需要对输 入输出调用进行确认。 注意:调用 IOResult 进行检查不如捕捉 EInOutError 异常。上面一段表明了编译器开关所 采取的措施。 溢出检查 当赋值给变量的数据包含了比变量的类型更多的比特位时,将发生溢出。例如,把大于 2147483647 的值赋值给整数变量时,有些位就会丢失。 溢出检查与{$Q+}编译器开关是等效的。如果溢出检查对于算术操作如+、-、*、Abs、Sqr、Succ、Pred、 Inc 和 Dec 失败,将引发 EIntOverflow 异常。 18.2.3 调试选项 当建立应用程序时,请打开 Compiler 属性页上的所有 Debugging 选项,如果希望跟踪到 VCL 的内部, 还可以包括 Use Debug DCUs 选项。图 18.1 示范了开发过程中的 Compiler 选项设置。 第 18 章 图 18.1 创建 Windows 程序 453 程序开发过程中的 Compiler 选项设置图示 一项很好的标准是解决所有以前编译器提示、警告或错误的代码。 另外,可以注意到(见图 18.1)Message 选项 Show Hints 和 Show Warnings 均被选中。在发布程序或 VCL 组件之前解决所有的编译器提示和警告是个好习惯。编译器提示和警告越早越容易解决。提示和警告 实际上是潜在的错误,在其演变成错误之前较为容易解决。 18.2.4 加入版本信息 Project Option 对话框的 Version Info 属性页可用于向应用程序加入内部版本信息。选中 Include version information in project 复选框以及 Auto-increment build number 选项,每次选择 Project Build 菜单项时,编译 器将自动更新程序的 Build 号码并存储版本信息。 Version info 属性页底部的 Key 和 Value 表格可以在程序中对版权、商标、版本信息以及自定义数据等 进行编码。在 File Properties 对话框的 Version 属性页上可以看到该信息。Major、Minor、Release、Build 号码是由 About 对话框上的 TVersionLabel 控件使用的,用于自动更新程序的版本和建立信息(使用 TVersionLabel 组件的源代码,请参见第 10 章)。 18.2.5 在 RichEditor 工程中指定目录和条件选项 协调工程目录、源代码控制目录、文件物理位置最简单的方法是对三者使用相同的路径。有时候懒一 点也是有好处的。 注意:也许您听说过关于某些罕见的程序员的神话,他们编程的速度比平均速度快上十倍。 这种人确实存在。他们像西部片中的枪手一样傲慢地迈着方步,仿佛有某些秘密一样。他们 成功的秘密在于习惯。超级程序员所做的大部分事情都是纯粹、几乎不经大脑的习惯。他们 有一种内部的风格指南,你可以认为这是一种地图,大部分时候都可以告诉他们做什么。如 果还有余下的时间,可能是用于缩短并改进代码。代码越少,意味着错误越少,交互越简单, 而用于注释的时间越少。 设置工程目录 如果对所有的应用程序都定义了同样的文件系统、源代码控制、以及目录结构,那确实就无须浪费头 脑了。快速的程序员早已了解这一点,他们的习惯已经可以进行组织、计划、编码、部署以及测试,而无 须花费很多时间了解为什么或怎么做。至于为什么,可能是某些激励因素使得他们希望快速完成工作,而 怎么做,只要沿着困难最下的方向走下去就行了。要想编程快速,形成一些简单好用的习惯是一条好的途 径。 按照这些指导性的原则,Project Options 对话框的 Directories/Conditionals 属性页中指出了相关的物理 目录的位置。Output 编辑域指向 Bin 目录,存储编译后的程序;Unit 域指向 Output 目录,存储.DCU 文件, Search 域为 Source 路径,可以添加路径。例如,如果需要调试创建的 VCL 组件,可以将 VCL 路径也添加 到 Search 域。对于 RichEditor.dpr 工程,路径分别是\RichEditor\Bin、\RichEditor\Output 和\RichEdiotr\Source。 第 18 章 创建 Windows 程序 454 所用的命名惯例 由于我们认为按照通常的原则好的习惯将快速产生好的结果,我们对 RichEditor 使用了 Delphi 命名惯 例的一个简单的扩展。Delphi 对字段使用 F 前缀,去掉 F 前缀将得到特性名。Delphi 对类使用 T 前缀,去 掉 T 前缀将得到对象的名字。扩展就是对单元名使用 U 前缀,用 U 替换类的 T 前缀将得到单元名。因此, 单元如果包含了 FormMain 的定义,则其文件名是 UFormMain,而类名则是 TFormMain。该单元的片断如 下所示: unit UFormMain; interface … uses … type TFormMain = … var FormMain : TFormMain; 这样,确定在哪个文件中包含哪个类就不需要什么思考了。尽管可以使用 Search,Find Files 菜单项, 但建立这种关系使得查找包含特定类的单元更为直接。 对于简单的 RichEditor 程序来说,以上是我们所需的准备工作。迄今为止,我们已经对工作进行了陈 述,并准备好了源代码控制机制,新工程的选项也已经设置好,就等着创建源代码了。下面,我们要使该 工程稳定下来,向它添加主窗体,这是 Windows 程序的起点。 18.3 建立主窗体 对工作的描述表明了 RichEditor 必须能够同时编辑多个 Rich Text 或文本文件。对于多个文档的编辑, 较为理想的是多文档界面(MDI)协议,我们将在稍后讨论 MDI。该程序是 Windows 程序,而且是编辑 器。因此,它需要一个主菜单。大部分用户都需要工具栏和状态栏,因此还要向程序添加工具栏和状态栏。 TTimer 组件的 OnTimer 事件用于按固定的时间间隔更新状态栏,而且将使用新的 TAction 组件来提供基本 的文件输入/输出、编辑菜单、字体格式化等功能。 注意:当程序随时间演化时,请记住开发者的首要目标是尽可能少写代码。由于我是个喜欢 编写代码的人,因此我必须在编写代码的乐趣与经常存在的业务方面的需求进行协调,以避 免编写太多的代码。“请记住,成功的开发者只需编写少量的高质量代码,而不是大量普通 的代码(Booch,1996)。” 到现在为止,我们还没有讨论过 MDI 程序,本节我们将从创建窗体开始,自顶向下进行工作,最后 分别以状态栏和 TApplication 组件结束。 18.3.1 多文档界面 MDI 是指可以在一个父窗口中打开多个子窗口。它与单文档界面(SDI)程序相对,后者是基本的 Windows 风格的程序。MDI 多用于字处理和电子表格程序,其中用户需要同时在多个文档上工作,对于程 序员来说,子窗体是与用户每次工作的文档或工作表相对应的。 Delphi 支持 MDI 程序非常容易,只需将主窗体的 FormStyle 特性设置为 fsMDIForm 或将子窗体的 FormStyle 特性设置为 fsMDIChild 即可。我们对工作所作的陈述表明,该程序需要读写多个文档,因此使 用 MDI 是很直接的方法。在创建 RichEditor.dpr 工程后,就已经准备好建立该程序了。 完成的应用程序如图 18.2 所示。从图中可以看到主菜单、工具栏、前台包含一些 Rich Text 文本的 MDI 子窗口、以及位于窗体底部的状态栏。上述的每个元素都将在后面的小节中单独进行讨论。为准备好 MDI 程序的主窗体,可以按下列步骤进行。 第 18 章 创建 Windows 程序 图 18.2 已完成的应用程序图示,其中包含一个 455 MDI 子窗口,窗口中有一些 Rich Text 文本 1.在窗体的 Caption 特性中键入 Rich Text Editor。 2.将主窗体的 FormStyle 特性值改为 fsMDIForm。 3.键入 FormMain 作为窗体的 Name 特性。 4.使用我们已经采用的命名惯例,将主窗体保存为 UFormMain.pas(UFormMain.dfm 将与窗体单元 一同自动保存)。 5.在窗体单元的顶部键入版权信息,如图 18.3 所示(这是个好习惯,您可能需要与公司的法律部门 进行协调,以确定所需的特定文本) 。 6.向主窗体添加 FormCreate 事件处理程序。该事件将调用 Initialize 方法。在主窗体单元的私有声明 部分添加一个没有参数的空的 Initialize 方法。 7.向主窗体添加 FormClose 事件处理程序。该事件将显式关闭任何打开的 MDI 子窗体,并向用户提 供机会来保存文档中正在进行的工作。 FormClose 事件的代码如下所示。其他各部件的代码将在加入那些部件时逐次添加。 图 18.3 示例版权信息。向每个单元都添加该信息, 表示文件名、作者以及版权标记等信息 procedure TFormMain.FormClose(Sender: TObject; var Action: TCloseAction); var I : Integer; begin for I := MDIChildCount - 1 downto 0 do MDIChildren[I].Close; 第 18 章 创建 Windows 程序 456 end; MDI 程序的主窗体维护列表 MDIChildren,包含了对所用实例化的 MDI 子窗体的引用。该列表确保调 用了每个已经打开的 MDI 子窗体的 Close 方法。如果没有该代码,关闭主窗体可能会不注意关闭子窗体, 从而导致已修改的文本丢失。 18.3.2 添加 TMainMenu 组件 TMainMenu 组件包含了一个 TMenuItem 组件的集合,您可以从 Menu Designer(TMainMenu 的组件编 辑器或菜单模板)添加 TMenuItem 组件。从组件面板的 Standard 属性页向 FormMain 添加一个 TMainMenu 组件。在 TMainMenu 组件上双击,即可启动其组件编辑器。图 18.4 显示了 TMainMenu 组件的编辑器,其 中包含了 Rich Text Editor 程序的菜单项。 图 18.4 RichEditor.exe 程序的菜单,在 TMainMenu 的组件编辑器中如图所示 要添加菜单项,单击任一被虚线包围的区域,该区域表示尚未初始化的菜单项,单击该区域使得该组 件成为当前焦点。按键 F11 转到 Object Inspector。如果为空白菜单项添加了标题,那么 Delphi 将创建菜单 项组件并将其声明添加到类定义。另外,可以将 TAction 组件赋值给菜单项组件的 Action 特性,TAction 对象具有在设计时填写附加特性的能力,如 Caption、Shortcuts、Hints 等(TActionList 和 TAction 组件的 细节,请参见使用 TActionList 的章节)。 提示:要添加菜单项,在已有的菜单项上单击要插入菜单项的前一个位置,并按键 Insert 即可。 提示:删除菜单项,单击相应的菜单项,按键 Delete 即可。 TAction 组件和手工编辑菜单项可用于为 RichEditor 创建主菜单。主菜单有两方面可帮助您定义菜单: 首先是显示菜单和子菜单名字的菜单视图,其次是 UFormMain.dfm 文件的片断。由于某些菜单项是使用 TAction 组件创建的,您可以将主菜单的完成推迟到阅读完本节和使用 TActionList 的一节,这样您就可以 了解如何使用 TAction 创建菜单项。 提示:将&符号放置在菜单标题中每个字符之后,可以创建该菜单项的快捷键。例如,&Edit 菜单项显示为 Edit,按键 Alt+E 即可触发该菜单项。要触发子菜单项,当菜单打开时,对下 划线字符按键即可。 提示:如果要在菜单标题中显示&符号,使用&&即可。 &File &Edit &Tools &Windows &Help &New &Undo &Options &Cascade &Contents &Open - &Tile Vertically &Topic Search - Cu&t &Arrange - Print Set&up… &Copy &Minimize All &About - C&lose E&xit &Paste Select &All 第 18 章 创建 Windows 程序 object MainMenu1: TMainMenu Left = 16 Top = 40 object File1: TMenuItem Caption = '&File' object New1: TMenuItem Action = FileNew end object Open1: TMenuItem Action = FileOpen1 end object N2: TMenuItem Caption = '-' end object PrintSetup1: TMenuItem Action = FilePrintSetup1 end object N1: TMenuItem Caption = '-' end object Exit1: TMenuItem Action = FileExit1 end end object Edit1: TMenuItem Caption = '&Edit' GroupIndex = 1 object Undo1: TMenuItem Action = EditUndo1 end object N4: TMenuItem Caption = '-' end object Cut1: TMenuItem Action = EditCut1 end object Copy1: TMenuItem Action = EditCopy1 end object Paste1: TMenuItem Action = EditPaste1 end object SelectAll1: TMenuItem Action = EditSelectAll1 end end object Tools1: TMenuItem Caption = '&Tools' GroupIndex = 7 object Options1: TMenuItem Caption = '&Options' OnClick = Options1Click 457 第 18 章 创建 Windows 程序 458 end end object Window1: TMenuItem Caption = '&Window' GroupIndex = 8 object Cascade1: TMenuItem Action = WindowCascade1 end object Tile1: TMenuItem Action = WindowTileVertical1 end object ArrangeAll1: TMenuItem Action = WindowArrange1 end object TileVertically1: TMenuItem Action = WindowMinimizeAll1 end object Close1: TMenuItem Action = WindowClose1 end object N6: TMenuItem Caption = '-' end end object Help1: TMenuItem Caption = '&Help' GroupIndex = 9 object Contents1: TMenuItem Action = HelpContents1 end object SearchforHelpOn1: TMenuItem Action = HelpTopicSearch1 end object About1: TMenuItem Caption = '&About...' OnClick = About1Click end end end 提示:使用减号符(-)作为标题,即可创建子菜单项之间的分隔符。 每个顶层的菜单项在 DFM 文件中都有一行定义,以关键字 object 开头;该菜单项的流化特性信息以 关键字 end 结束。例如,对象 Tools1:TMenuItem 标题特性为‘&Tools’ ,GroupIndex 为 7(过一会儿,我 们还要讨论 GroupIndex 特性)。Tools1 的特性信息以关键字 end 结束,其前面的 end 用于嵌套菜单项 Options1 (参见列出的代码)。正是 DFM 文件中的特性嵌套表示了所有权,它可以用于确定菜单间的关系。所有使 用 TAction 组件初始化的菜单项都列出了 Action 特性;所有通过手工键入标题创建的菜单项都具有 Caption 特性。 现在能够完成具有 Caption 特性的菜单项,可以使用菜单的层次和列出的 DFM 文件作为向导。如果 GroupIndex 存在,请确认已添加了该特性。如果菜单项具有 OnClick 事件处理程序,例如 About1 菜单项, 然后您需要在 Object Inspector 中单击该菜单项的 OnClick 事件特性来创建事件处理程序。 第 18 章 创建 Windows 程序 459 添加 About 菜单项 为示范如何添加菜单项,我们来完成 About1 菜单项。About 菜单项将显示 About 对话框,是使用第 10 章创建的 TAboutBoxDialog 组件实现的。 假定 TMainMenu 组件已经添加到主窗体,在 TMainMenu 组件上双击,即可触发 TMainMenu 组件的 编辑器。按下列步骤即可完成 About 菜单。 1.如果尚未添加 Help 顶层菜单,可以在已有的顶层菜单最右侧的虚线矩形上单击(如果已经存在, 该菜单项在 Menu Designer 上位于较左的位置)。选定矩形后,按键 F11 转到 Object Inspector(如 图 18.5 所示,在 Object Inspector 右侧可以看到选定的顶层空白菜单插入点)。输入菜单项标题 &Help。在 Help 菜单的下方可以看到空白的子菜单项。 图 18.5 Object Inspector 的当前焦点位于某个顶层菜单项的 Caption 特性上,该菜 单项在 Menu Designer 中处于当前焦点,恰好位于 Object Inspector 的右侧 2.Help 菜单的最后一个菜单项通常是 About 菜单。要添加 About 菜单,单击 Help 菜单末尾的空白菜 单项,然后转到 Object Inspector。 3.在 Caption 特性中键入&About,Delphi 将自动的在类定义中插入 TMenuItem 组件并将其命名为 About1(如果已存在名为 About1 的组件,Delphi 会自动增加组件名的后缀,以确保组件名是惟一 的)。 4.转到 Object Inspector,确认在对象选择器中已选定 About 菜单项。单击 Events 属性页,双击 OnClick 事件特性来生成事件方法。 5.在事件方法的 begin 与 end 之间添加 ShowAbout 方法。 6.向窗体类的私有部分添加一个方法,即 ShowAbout 过程。按键 Shift+Ctrl+C(自动完成类定义)即 可添加空方法体,也可以手工键入方法体。 注意:尽早的建立接口并使之稳定化,是一个值得赞许的目的。通常这可以使你的程序稳定 下来,同时其他的开发者也可以使用这些方法接口。请记住,使用你所编写代码的人,包括 你自己在内,只会关心代码是否可以工作。另外,如果开发者需要扩展类,他可能还会关注 保护权限的接口。 添加空的方法相当于建立了一个支点,从而可以将实现推迟到较为方便的时候,或者是已经选定了实 际实现的时候。由于我们知道 About 行为是怎样实现的,因此现在就可以添加代码。 使用第 10 章的 TAboutBoxDialog 组件,从组件面板上双击该组件将其添加到主窗体。对话框只要调 用 Execute 方法即可工作,而无须进行修改。下面列出了 About 行为的完整代码。 procedure TFormMain.ShowAbout; begin 第 18 章 创建 Windows 程序 460 AboutBoxDialog1.Execute; end; procedure TFormMain.About1Click(Sender: TObject); begin ShowAbout; end; 注意:请记住,所有的编程风格都是主观的。上面的代码使用了较短的过程,有些开发者可 能会强烈反对这种风格。您可以根据几个基本的原则对编程风格进行选择,首先要考虑特定 的代码片断是否易于调试,其次要考虑是否易于重用。事实上,方法与代码之间存在一种一 一对应关系,这种关系主要是由编译器来处理,而与程序员的关系不大。要记住,开发者一 次只能思考一件事,这件事越简单越好。 提示:记住,在添加新的行为时,要对该行为进行单元测试,以确保其工作正确,与预期符 合。要避免直到添加了多个行为才开始测试。单元测试符合分而治之的原则。 我们来讨论一下由上面的代码引出的两个风格方面的问题。首先是是否应该使用 Delphi 提供的默认名 字。从主观来说,可以这样做。假定是在某个特定的接口之内使用默认名字,那么不存在任何问题。可以 花费时间来定义接口,但却只有一个 About 组件。因此默认的名字就足够了。反过来,如果有几个同一类 型的组件,这些组件用于重要的代码中,或者默认的名字容易引起歧义,就需要为组件指定一个更好的名 字。对于本例来说,默认的名字就行了。进一步的考虑编程风格,我们致力于使代码最小化。有些开发者 可能比较极端,以至于轻视简单的代码。但如果所有的代码都这样简单,那么会减少很多错误。简明的代 码是有益的,因为它引入错误的可能性非常小,易于编写,易于理解。如果代码看起来非常直接,那么已 经基本达到了改进代码的目的。 使用 GroupIndex 特性 TMenuItem 具有 GroupIndex 特性。GroupIndex 特性用作自动合并时菜单(参见下一节)的虚拟占位 符。考虑 Tools 菜单,其 GroupIndex 为 7。Delphi 可利用 GroupIndex 确定新菜单的相对位置。当我们创建 MDI 子窗体时,定义了相关行为的菜单可直接添加到该窗体。利用 GroupIndex 和 AutoMerge 特性,Delphi 能够确定在主菜单的什么位置显示合并后的菜单。继续这个例子,任何 GroupIndex 小于 7 的菜单在主菜单 上的位置都在 Tools 菜单前面。 如果被合并的菜单与已存在的菜单 GroupIndex 值相同,那么前者将完全取代后者。GroupIndex 特性 对于 MDI 程序特别有用。GroupIndex 可用于将子窗体上的菜单项经过特定的改变,来替换主窗体上的同 名菜单项。任意特定的子窗体都可以获得焦点,如果它具有主菜单,那么它的菜单可以自动合并到主窗体 的菜单中。当它失去焦点时,将自动重新显示原来的与上下文相关的菜单。 考虑主菜单,其 GroupIndex 值对应于正确的索引值(在列出的 DFM 文件中指定),以确保可以正确 地合并子窗体菜单。 自动合并菜单 对子窗体来说,TMainMenu 的 AutoMerge 特性非常重要。考虑一个程序,可能具有多个并发的子窗体, 如同 Rich Editor 的例子。子窗体的行为对主窗体没有意义。例如,当没有打开文档时,Save 菜单项没有意 义。保存什么呢?但从用户的角度看来,MDI 子窗体的内容就是要保存的文档。当打开包含 Rich Text 的 文档时,必须提供保存修改的功能。向文档添加 Save 行为是有意义的,这样也易于对子窗体的 Save 行为 进行编码。 在子窗体中编码 Save 行为,这是面向对象主义者希望 Rich Text 文档所具有的功能。另外,如果在主 窗体中加入该行为,将引入额外的复杂性,会造成一些问题,如:有活动窗体吗?哪个窗体是活动的?Save 行为是作用于哪个窗体? TMainMenu 的自动合并特性协调了这种两难状况。向子窗体添加 TMainMenu 组件后,子窗体的菜单 可以自动合并到主窗体的菜单,这样程序的行为就变得非常一致。考虑 File 菜单的例子。如果子窗体具有 TMainMenu 组件以及相同的菜单名和 GroupIndex,则子窗体的菜单可以无缝地替换主窗体的菜单。参考稍 第 18 章 创建 Windows 程序 461 后“建立编辑器窗体”一节中有关创建合并菜单的内容。 18.3.3 添加工具栏 TToolbar 组件相对较新。也可以将 TAction 组件赋值给工具栏按钮,这大大的简化了创建工具栏的过 程。对于添加工具栏来说,最重要的是定义所需要的行为,以及找到一些合适的组件以改善程序的外观。 设计 RichEditor 程序的工具栏组件 为 确保 与其他 编辑 器的一 致性 ,其他 的文 本编辑 器和 字处理 器通 常所具 有的特征 都添加 到 了 RichEditor 程序的工具栏上。一般来说,工具栏按钮包括了在主菜单上已有的功能,使得这些常用的功能 更加容易使用。由于 RichEditor 程序的工具栏上按钮较多,包括了创建新文档、打开现存文档、保存当前 文档、打印、修改文档字体等功能,因此工具栏变得较大。 TToolbar 具有自身的编辑器。只需从工具栏组件的上下文菜单上选择正确的操作,即可向工具栏添加 按钮或分隔符(RichEditor 程序的工具栏可以从图 18.2 看到) 。要向工具栏添加按钮,使用右键单击工具栏 并单击 New Button 菜单项即可;要添加分隔符只需单击 New Separator 菜单项(这两个菜单项如图 18.6 所 示)。 图 18.6 TToolbar 组件的上下文菜单包括了 New Button 和 New Separator 菜单项,使得设计工具栏非常方便 提示:单独的按钮也是组件。因此,在设计时它们可以获得焦点,在 Object Inspector 中 分别进行修改。 从 RichEditor 的工具栏可以看到,从左到右分别是三个按钮和一个分隔符,一个按钮和一个分隔符, 四个按钮和一个分隔符,三个按钮和一个分隔符,最后是一个按钮。如果主菜单上对应的操作存在相关联 的 TAction 组件,那么这些 TAction 组件也用于相应的工具栏按钮。这样,所有的工具栏按钮都具有 TAction 组件,足以完成所需的功能。因此每个工具栏组件(不包括分隔符)的 Action 特性都赋值为某个 TAction 组件。 工具栏按钮与 TAction 组件如何协作 工具栏、工具栏按钮以及 TAction 都是组件。因此,这意味着三者都具有状态和行为。工具栏和工具 栏按钮需要显示图像和提示,并对单击事件进行响应。TAction 组件的行为包括初始化菜单项和工具栏按 钮、显示图像和文字,并响应单击事件。如果把 TAction 组件分配给工具栏按钮或菜单项,它将负责显示 文字和图像,并响应单击事件,与其原来的行为相同。 为创建 RichEditor 程序的工具栏,像上一节那样添加按钮和分隔符。TAction 组件将负责行为特征,并 显示按钮图标和提示。RichEditor 的每个按钮都是 Delphi 6 中的 TAction 组件,从而无需寻找适当的图标并 手工添加合适的行为。如果手工来做,就必须从头开始定义按钮,可以考虑两种方法:子类化 TAction 组 件,或者修改按钮的 Caption、Hint 和 OnClick 特性来初始化所需的行为。而手工初始化的按钮图像还需要 第 18 章 创建 Windows 程序 462 一些额外的工作。 您需要从组件面板的 Win32 属性页向窗体添加一个 TImageList 组件,使得工具栏按钮可以使用图像。 将图像添加到图像列表,然后把 TImageList 组件赋值给 TToolbar.Images 特性,这样工具栏按钮就可以使 用图像了。接着,将特定按钮所需的图像的索引赋值给 TToolButton.ImageIndex 特性,即可在按钮上显示 相应的图像。 18.3.4 TActionList 和 TAction 组件 使代码呈现内聚特性是代码复杂性管理的重要方面。粗糙的程序往往直接在事件处理程序中添加代 码。而当程序的其他部分需要该事件的行为时,程序员可以调用事件处理程序,也可将事件特性指向同一 处理程序,或者复制并粘贴代码。新手倾向于复制并粘贴代码,而有经验的开发者会调用事件处理程序, 或者将多个事件特性指向同一事件方法。进一步的改善可以使用一个有名字的方法,在事件处理程序中调 用该方法。 有名字的方法减少了注释而且易于使用,但指导原则是相同的:使代码内聚以管理复杂性。反过来, 发散的代码增加了复杂性。 TActionList 是非可视化控件,位于组件面板的 Standard 属性页上,该组件用于调整对同样的行为引入 分散代码路径的潜在可能性。TActionList 定义了 ImageList 特性以及 TAction 组件的集合。ImageList 存储 图标,用于分配给可以显示图像的可视化组件;而 TAction 集合存储了非可视化的 TAction 组件的列表, 或可用的响应方式。TActionList 具有设计时组件编辑器(见图 18.7,图中 Object Inspector 的当前焦点是选 定的 TAction 组件),可以用来管理动作。TActionList 和 TAction 是组件,而且可以在运行时动态创建。我 们将采取自顶向下的方法来示范如何向窗体添加 TActionList 组件、添加新的和标准的 TAction 组件、以及 将 TAction 分配到合适的组件。 使用 TActionList 在组件面板的 Standard 属性页上选定 TActionList 组件,然后即可将其添加到窗体。在 Standard 属性页 上,TActionList 组件位于最右侧。要在设计时使用 TActionList 的组件编辑器,将鼠标移动到该组件并单击 右键,这时将显示 TActionList 的上下文菜单。从上下文菜单中选择 Action List Editor 菜单项即可。 提示:在 TActionList 组件上双击鼠标也可以显示编辑器。在设计时双击任何有编辑器的组 件,都会显示相应的组件编辑器。 图 18.7 TActionList 组件编辑器用于在设计时维护所包含的 TAction 组件 添加新的动作 在打开动作编辑器(即 TActionList 的组件编辑器)后,按 Insert 键。将在编辑器右侧 的动作列表中插入一个新的具有默认特性的 TAction 组件,它在左侧的 Categories 列表中属于(NO Category)。为便于组织,TAction 组件可以与某个种类相关联(在下面的“定义动作”一段中,会涉及更 多有关种类方面的知识) 。 添加标准动作 TAction 是非可视化组件。这意味着它们在运行时没有可视化的外观,而组件的作者 第 18 章 创建 Windows 程序 463 也可以创建自定义的 TAction 组件。特别的,子类化的 TAction 组件也可以添加到 VCL 中(参见“创建自 定义的标准动作组件”一节,其中有一个关于创建标准动作的例子)。已经存在的动作可称之为标准动作。 要向 TActionList 对象实例添加标准动作,按键 Ctrl+Insert,然后从标准动作的分类列表中选择一个动 作即可。Delphi 6 引入了几个新的各种动作,可以节省相当的工作量(更多的信息,请参考“Delphi 6 中 新的标准 TAction 组件”一节)。 删除动作 动作编辑器的工作方式非常直观。Insert 和 Ctrl+Insert 分别用于添加新的和标准的动作。 Delete 键可以从列表中删除一个 TAction 组件。另外,也可以使用动作编辑器的上下文菜单来完成这些操 作。 定义动作 将 TAction 组件插入到动作列表中,就定义了一个新的动作。单击选定 TAction 组件,然 后按键 F11 转到 Object Inspector,新的 TAction 组件已成为当前焦点(再检查一次,确认已在对象选择器 中选定了要修改的 TAction 组件,对象选择器是 Object Inspector 顶部的组合框)。TAction 组件与其他组件 类似,分配了一个默认名字,如 Action1。由于动作将定义行为,最好使用动词和名词命名这些组件,分 别表示其引发的行为和操纵的对象。例如,标准动作 Cut 是一个 TEditAction 组件;该组件可以命名为 EditCut。在这里,Edit 命名了组件的类别,而 Cut 描述了相应的行为。 可以想到,在 TAction 组件中存储了足够的细节,可用于初始化可视化组件,如菜单项或按钮,并对 用户初始化的事件进行响应。按钮和菜单项都具有 Caption、Name、GroupIndex、Hint、Shortcut 和 Image 特性(TAction 组件的图像是由 ImageIndex 图像代表的)。按钮和菜单项可以被选定,可以使之有效或失效, 也可以对事件进行响应。表 18.1 列出了公开的动作特性和事件。 表 18.1 TAction 组件的特性和事件 属性名 类型 描述 Caption 特性 动作的标题,将赋值给相关联组件的 Caption 特性 Category 特性 用于在动作编辑器中对 TAction 组件进行组织 Checked 特性 将赋值给相关联组件的 Checked 特性;改变 TAction 组件的 Checked 特性 将同时改变相关联组件的 Checked 特性 Enabled 特性 赋值给相关联组件的 Enabled 特性;改变 TAction.Enabled 特性将在相关联 的组件中反映出来 GroupIndex 特性 赋值给相关联组件的 GroupIndex 特性;改变将反映到相关联的组件 HelpContext 特性 若有多个组件关联到同一 TAction 组件,将共享 HelpContext Hint 特性 用于初始化组件的 Hint 特性 ImageIndex 特性 如 果 相 关 联 的 组 件 可 以 显 示 图 像 , 将 使 用 ImageIndex 索 引 相 应 的 TActionList 的 ImageList 特性,从而得到图像 Name 特性 动作组件的名字,使用名词和动词以保持简明 ShortCut 特性 用于初始化加入组件的 ShortCut 特性 Tag 特性 用于初始化加入组件的 Tag 特性 Visible 特性 初始化并更新加入组件的 Visible 状态 OnExecute 事件 当发生可执行的行为时,将调用赋值给该特性的事件方法;例如,如果按 钮与 TAction 组件相关联,那么按钮单击时将调用 OnExecute OnHint 事件 将要显示加入组件的 Hint 字符串时,调用该事件 OnUpdate 事件 当加入组件的状态需要进行更新时,调用该事件 为进行演示, 我们把 About1 菜单项和 OnClick 事件方法转换为指向动作项。 按下列步骤创建 HelpAbout 动作项,并将其用于 RichEditor 程序的 Help,About 菜单。 1.如果 RichEditor 的主窗体 FormMain 上没有 TActionList 组件, 则添加该组件(TActionList 位于 Standard 属性页的最右侧)。 2.双击 TActionList 组件,启动动作编辑器。 3.当动作编辑器获得当前焦点时(标题栏变蓝) ,按键 Insert。这将在动作列表中创建新的动作项。 第 18 章 创建 Windows 程序 464 4.按键 F11,转到 Object Inspector。 5.将 Caption 特性改为‘&About…’ (省略号是惯例,用于像 About 菜单项那样显示对话框的菜单项。) 6.将 Category 特性改为 Help(也可从 Category 的特性编辑器中选择 Help,如果该类别在列表中不存 在,可以手工键入)。 7.将动作命名为 HelpAbout(在 Name 特性键入‘HelpAbout’) 。 8.单击 Events 属性页。双击 OnExecute 事件来创建 HelpAboutExecute 事件方法。在新的事件方法中 加入对 ShowAbout 的调用。 9.保存工作。关闭动作编辑器,双击 TMainMenu 组件启动其编辑器。 10.在菜单设计器中选定 About 菜单项。 11.转到 Object Inspector,对 Action 特性选择 HelpAbout。 12.现在可以删除 OnClick 事件处理程序(只要删除代码,保存文件即可。Delphi 将自动清除空的事 件处理程序) 。 这就是所要做的工作。当单击 Help,About 菜单项时,将调用 OnExecute 处理程序。下面的 VCL 代码 来自 Menus.pas,示范了如何将 OnExecute 与 OnClick 事件联系起来。当拥有 TMenuItem 组件的 TMenu 组 件接收到单击事件时,它将直接调用相应菜单项的 Click 方法,从而将单击事件分派给正确的菜单项。 procedure TMenuItem.Click; begin if Enabled then begin { Call OnClick if assigned and not equal to associated action's OnExecute. If associated action's OnExecute assigned then call it, otherwise, call OnClick. } if Assigned(FOnClick) and (Action <> nil) and (@FOnClick <> @Action.OnExecute) then FOnClick(Self) else if not (csDesigning in ComponentState) and (ActionLink <> nil) then FActionLink.Execute else if Assigned(FOnClick) then FOnClick(Self); end; end; 注意:可以像填写按钮或菜单的特性一样填写动作的特性,用 OnExecute 事件替换 OnClick 事件即可。TAction 组件只是缺乏可视化的外观而已。像按钮或菜单这样的控件具有 Action 特性。将 TAction 组件赋值给需要属性和响应功能的控件的 Action 特性,这样 TAction 组 件即替代了这些特性。 TMenuItem.Click 过程将调用 OnClick 事件处理程序(如果有的话),而 TAction 的 OnExecute 和菜单 项的 OnClick 并不是同一方法。这样,如果 OnClick 和 Action.OnExecute 都存在,而且是不同的过程,将 调用 OnClick 事件方法。否则,如果处于运行时而且存在相关联的 Execute 事件处理程序,将调用该处理 程序。在最后一个 else 条件中,如果上面的 else 条件都失败了,将调用 FOnClick 事件方法。 标准动作 标准动作是一些预定义的、并使用 RegisterActions 过程进行了注册的组件。 类似于 RegisterComponents, RegisterPropertyEditor 和 RegisterComponentEditor 会执行一些必要的步骤,以便在 VCL 中定位正确的 TAction 组件。这些标准的 TAction 组件是与 Delphi 一同发行的,定义在 StdActns.pas 单元中。 提示:SetSubComponent 是在 Delphi 6 中引入的,可以方便地设置组件的所有权。 第 18 章 创建 Windows 程序 465 通过定义和使用标准动作,可以在多个程序之间节省很多重复的工作,并且可以在程序之内和之间标 准化各种行为。由于在 Delphi 6 中引入了组件的所有权,TAction 组件可以拥有并在 Object Inspector 中显 示子组件。这对于应用开发者是很有用的,而且可以定义很复杂的动作组件。 要向 TActionList 添加标准动作,在动作编辑器处于当前焦点时按键 Ctrl+Insert 即可。Delphi 6 引入了 几种标准动作组件,包括 TFileOpen、TFilePrintSetup 等,足以完成 RichEditor 中大部分菜单项的功能(新 的标准动作组件的介绍请参见下一节)。 Delphi 6 中新的动作组件 对于系统化常见的响应行为,标准动作组件跨出了有益的一大步。Delphi 6 引入了多个新的标准动作 组件,在 RichEditor 中可以用到其中的许多组件,减少了为这些行为编写代码的需求。表 18.2 中给出了 RichEditor 的菜单项与标准的动作组件之间的联系。 提示:参考 Delphi 6 帮助上下文菜单中的 New VCL Feature 一项。其中包括了新的动作组 件的详细列表。 表 18.2 有许多新的标准动作组件,这些组件对于 RichEditor 非常有用 (完整的列表请参考 Delphi 帮助中的“New VCL Features”部分) 菜单项 标准动作 File,Open TFileOpen 描述 显示 TOpenDialog 对话框,使用户可以选择文件;可 以用 TFileOpen.Dialog 特性修改 TOpenDialog 的特性 File,Print Setup TFilePrintSetup 显 示 TPrinterSetupDialog 对 话 框 ; 可 通 过 TFilePrintSetup.Dialog 特性访问 TPrinterSetupDialog File,Exit TFileExit 结束程序 Edit,Undo TEditUndo 向 ActiveControl 发送 WM_UNDO 消息 Edit,Cut TEditCut 向 ActiveControl 发送 WM_CUT 消息 Edit,Copy TEditCopy 向 ActiveControl 发送 WM_COPY 消息 Edit,Paste TEditPaste 向 ActiveControl 发送 WM_PASTE 消息 Edit,Select All TEditSelectAll 向 ActiveControl 发送 WM_SETTEXT 消息 Edit,Delete TEditDelete 发送 WM_CLEAR 消息以删除选定的文本 Format,Edit TFontEdit 显示 TFontDialog 对话框(参见表后面的示例代码, 其中示范了如何响应字体的改变) Format,Bold TRichEditBold 设置字体样式,包括 fsBold (续表) 菜单项 标准动作 描述 Format,Italic TRichEditItalic 设置字体样式,包括 fsItalic Format,Underline TRichEditUnderline 更新字体样式,包括 fsUnderline Format,Strikeout TRichEditStrikeout 更新字体样式,包括 fsStrikeout Format,Align Left TRichEditAlignLeft 将 Alignment 特性赋值为 taLeftJustify Format,Align Right TRichEditAlignRight 将 Alignment 特性赋值为 taRightJustify Format,Align Center TRichEditAlignCenter 将 Alignment 特性赋值为 taCenter Format,Bullets TRichEditNumbering 将 TCustomRichEdit.Paragraph.Numbering 改 为 nsBullet Window,Cascade TWindowsCascade 调用 TForm.Cascade 方法 Window,Tile Vertically TWindowTileVertical 如 果 窗 体 风 格 为 fsMDIForm , 向 窗 体 发 送 WM_MDITILE 消息 Windows,Arrange TWindowArrange 调用 TForm.ArrangeIcons 方法 Window, Minimize All TWindowMinimizeAll 对所有的 MDI 子窗体迭代,将 WindowState 设置为 第 18 章 创建 Windows 程序 466 wsMinimized Window,Close TWindowClose 调用 ActiveMDIChild.Close Help,Contents THelpContents 调用 Application.HelpCommand Help,Topic Search THelpTopicSearch 调 用 Application.HelpCommand , 使 用 参 数 HELP_PARTIALKEY 当尝试对各种实现途径进行选择时,所有新的标准动作组件都会让您省去许多努力。快速地看一下上 面的列表,可以看到 Windows API 被用作执行其中的一些动作,如对话框操作、Application 实例的一些操 作以及精确的特性值等。 动作组件如 TFontEdit 等包括一个对话框。Execute 行为就负责显示对话框。当用户单击 OK(或等效 的按钮)时,您需要编写一些代码来响应 OnAccept 动作。下面的代码示范了 RichEditor 如何对 Font 特性 的改变进行响应。 procedure TFormEditor.FontEdit1Accept(Sender: TObject); begin with Sender As TFontEdit do if( RichEdit.SelText = '' ) then RichEdit.DefAttributes.Assign( Dialog.Font ) else RichEdit.SelAttributes.Assign( Dialog.Font ); end; 警告:如果在对象赋值时不使用 Assign 方法,那么将进行引用赋值,这一点您需要注意。 使用赋值操作符(:=)与 C++中的引用赋值相似,而 Assign 则进行深复制。 上面的代码将分派给 TFontEdit.OnAccept 事件处理程序,因此使用动态类型转换将 Sender 参数转换为 TFontEdit 对象是安全的。如果没有选定文本,将修改默认的字体属性;否则将修改选定文本的属性。 Dialog.Font 特性指向 TFont 对象;在进行对象赋值时请记住要使用 Assign 方法。 创建自定义的标准动作组件 当我们讨论建立自定义动作组件时,有两点是非常重要的,要牢记在心。首先,组件只是一些可重用 代码组成的程序包,实际上只是一些类;其次,TAction 组件仍然只是一些组件。它们与非动作组件的不 同之处在于,不需要使用 New Component 对话框,因为动作组件不会安装到组件面板,而注册动作组件需 要使用 RegisterActions 过程而不是 RegisterComponents 过程。其他的都是相同的。 定义动作组件 增长式的修订可以提供健壮的解决方案,因为需要测试的东西很少,出错的可能性也 较小。虽然有时候必须从零开始,但对于我们的目的而言,对 TFileExit 稍微修改一下即可达到目的。 我们将加入一个提示,让用户确认是否退出。TFileExit 动作组件的 ExecuteTarget 方法实际上直接关闭 了主窗体。因此,我们重载该方法,并只在用户确认退出的情况下调用继承方法。安装下列步骤可以创建 自定义的 TFileQueryExit 动作组件。 1.单击 File | New | Other 菜单项,从 New Items 对话框的 New 属性页中选择 Package,创建一个 新的包(打开已有的包也可以)。 2.在 Delphi 中,单击 File | New | Unit 菜单项创建新的单元。 3.将单元保存为 UFileQueryExit.pas。 4.相应的类可如下定义(下面列出了代码)。 5.添加接口 Register 过程的声明(见列出的代码) 。 6.将 Register 过程定义为调用 RegisterActions(见代码)。 7.定义类的实现(见代码)。 8.保存该单元。对包进行编译,并使用包编辑器进行安装。 下面列出了 TFileQueryExit 的代码。 第 18 章 创建 Windows 程序 467 unit UFileQueryExit; // UFileQueryExit.pas - Extends TFileExit Action to include query dialog // Copyright (c) 2000. All Rights Reserved. // By Software Conceptions, Inc. http://www.softconcepts.com // Written by Paul Kimmel. Okemos, MI USA interface uses Classes, Dialogs, StdActns, Controls; type TFileQueryExit = class(TFileExit) private FMessage : String; function CanClose : Boolean; public constructor Create(AOwner : TComponent); override; procedure ExecuteTarget(Target: TObject); override; published property Message : String read FMessage write FMessage; end; procedure Register; implementation uses ActnList, UPKActions; resourcestring Prompt = 'Are you sure?'; procedure Register; begin RegisterActions( 'File', [TFileQueryExit], Nil); end; constructor TFileQueryExit.Create(AOwner : TComponent); begin inherited Create(AOwner); FMessage := Prompt; end; function TFileQueryExit.CanClose : Boolean; begin result := (MessageDlg( Message, mtConfirmation, [mbYes, mbNo], 0) = mrYes); end; procedure TFileQueryExit.ExecuteTarget(Target: TObject); begin if( CanClose ) then inherited ExecuteTarget(Target); end; end. 该类添加了 Message 特性(在构造函数中初始化) ,以及资源字符串 Prompt。私有方法 CanClose,显 示消息对话框,将用户响应作为布尔值返回。构造函数进行了重载以初始化新的 Message 特性,在 ExecuteTarget 方法中定义了扩展行为。扩展行为即首先显示提示,如果用户确认要关闭程序,则使用继承 第 18 章 创建 Windows 程序 468 行为来关闭主窗体。 注册动作组件 当安装包时,将调用接口部分的全局过程 Register。为注册动作组件,Register 过程定 义为调用 RegisterActions。 RegisterActions( 'File', [TFileQueryExit], Nil); 第一个参数是动作组件所属类别的名字,用于对动作进行组织。第二个参数是要注册的类的数组,而 第三个参数在这里是 Nil(请参考下一段,其中有一个使用 RegisterActions 的第三个参数的例子) 。 在注册动作组件后,可以使用动作编辑器将其插入到 TActionList 组件。要插入新的标准动作组件,可 以使用 Ctrl+Insert。RichEditor 程序使用了新的标准动作组件 TFileQueryExit,将该组件作为主窗体的 Exit 菜单项的动作。 创 建 初 始 化 数 据 模 块 RegisterActions 过 程 的 第 三 个 参 数 是 TDataModule 子 类 的 类 。 创 建 TDataModule,然后向其添加 TActionList 组件,并向 TActionList 添加一个 TAction 组件。请确认所添加的 TAction 组件的类与要初始化并修改特性的类是相同的。然后,当把数据模块的类名作为 RegisterActions 的第三个参数传递时,Delphi 将创建该数据模块的一个实例,以及相应的 TActionList 和 TAction 组件,并 使用数据模块中定义的 TAction 组件来初始化对应的 TAction 组件的新实例。 起先这看起来像是先有鸡还是先有蛋的问题。怎样创建并不存在的组件的实例呢?只要使用代码创建 一个对象实例,其中包含组件即可。TFileQueryExit 控件的代码是存在的。因此,您只需要创建一个新的 数据模块,将 UFileQueryExit 单元添加到其 uses 子句,声明一个 TFileQueryExit 的公有实例,并在数据模 块的 DataModuleCreate 事件方法对其进行初始化。 unit UPKActions; // UPKActions.pas - Contains initializing actions for RegisterActions procedure // Copyright (c) 2000. All Rights Reserved. // By Software Conceptions, Inc. http://www.softconcepts.com // Written by Paul Kimmel. Okemos, MI USA interface uses SysUtils, Classes, ActnList, StdActns, UFileQueryExit; type TPKActions = class(TDataModule) procedure DataModuleCreate(Sender: TObject); private { Private declarations } public { Public declarations } FileQueryExit : TFileQueryExit; end; var PKActions: TPKActions; implementation {$R *.DFM} procedure TPKActions.DataModuleCreate(Sender: TObject); begin FileQueryExit := TFileQueryExit.Create(Self); FileQueryExit.Caption := 'E&xit'; FileQueryExit.Hint := 'Exit|Quits the application'; FileQueryExit.Name := 'FileQueryExit'; end; end. 第 18 章 创建 Windows 程序 469 为注册用于初始化动作组件的数据模块,可以将数据模块的类名添加到包含动作组件的包中,修改 RegisterActions 过程调用,并且安装包。修改后的过程如下所示: procedure Register; begin RegisterActions( 'File', [TFileQueryExit], TPKActions); end; 从上面的代码可以注意到,第三个参数是数据模块的类名。 为完成本阶段的开发工作,可以将 TFileQueryExit 标准动作添加到主窗体的 TActionList 组件,并赋值 给 File | Exit 菜单项的 Action 特性。 18.3.5 建立状态栏 状态栏是个很方便的地方,可用于放置一些表示程序的状态和动作的额外信息。显示的信息因应用程 序的类型而异。用户可能认为显示键盘信息很有用。因为键盘灯显示了 Num Lock 和 Caps Lock 状态,但 并未显示 Insert 或 Overwrite 状态。除了键盘状态信息之外,RichEditor 的状态栏(如图 18.8 所示)上还显 示了活动编辑窗口中光标的行列位置,以及当前时间信息。 图 18.8 RichEditor 的状态栏显示了编辑状态、光标位置、键盘状态,以及系统时间 首先我们需要从组件面板的 Win32 属性页向主窗体添加一个 TStatusBar 组件。默认情况下,状态栏是 向窗体的底部对齐的。其次,双击状态栏启动 Panels Editor。在 Panels Editor 中按 Insert 键六次,添加六个 面板(结束时,分别是面板 0 到面板 5)。根据表 18.3 的指导分别修改 TStatusBar 控件和各个面板。 表 18.3 RichEditor 程序中 TStatusBar 组件和六个面板的特性设置 组件 特性设置 StatusBar SimplePanel = False;SizeGrip = False;创建 OnOwnerPanel 和 OnResize 事件处理程 序 (续表) 组件 特性设置 Panel 0,5 Width = 150 Panel 1 Width = 200 Panel 2,3,4 Alignment = taCenter;Style = psOwnerDraw;Width = 50 OnOwnerDraw 和 Style = psOwnerDraw 用于创建键盘状态的可视化效果。OnResize 事件处理程序用于 为各个面板维护合理且足够的大小。下面是 OnResize 事件的代码: procedure TFormMain.StatusBar1Resize(Sender: TObject); var I : Integer; begin StatusBar1.Panels[0].Width := ClientWidth; For I := 1 to StatusBar1.Panels.Count - 1 do StatusBar1.Panels[0].Width := StatusBar1.Panels[0].Width StatusBar1.Panels[I].Width; end; 从代码可以看出,除面板 0 外的五个面板的宽度是固定的,面板 0 的宽度是浮动的,等于 ClientWidth 减去其余五个面板的宽度之和。 更新 Modified 状态 第 18 章 创建 Windows 程序 470 程序中的 MDI 子窗体属于同一类。在 RichEditor 中可能会同时打开几个编辑窗体。活动的编辑窗体将 把一些文本发送到主窗体的状态栏,用来表示 Rich Edit 控件的修改状态。为限制编辑窗体过多了解主窗体 的细节,将使用消息处理程序来发送该状态。 为便于发送消息,在一个单独的单元中定义了新的消息常数 WM_UPDATETEXTSTATUS 以及一个消 息记录 TWMUpdateTextStatus。 const WM_UPDATETEXTSTATUS = WM_USER + 1; type TWMUpdateTextStatus = TWMSetText; 在主窗体中定义了一个消息处理程序来接收 WM_UPDATETEXTSTATUS 消息。并另外定义了一个方 法来完成相应的工作。 procedure TFormMain.UpdateStatus( const Text : String ); begin StatusBar1.Panels[0].Text := Text; end; procedure TFormMain.WMUpdateTextStatus( var Message : TWMUpdateTextStatus ); begin UpdateStatus( PChar(Message.Text) ); Message.Result := -1; end; 使用消息处理程序的好处在于,编辑窗体无需了解主窗体的过多细节。它可以发送消息,然后不再关 心。这样产生的代码是松耦合的,从而把主窗体实现的修改对编辑窗体的影响降低到最小。 注意:当在一个组件中进行了许多工作后,您可能希望把它保存为模板。Component | Create Component Template 菜单项可以把组件放置到组件模板的 Template 属性页上,包括所有的 属性以及事件处理程序的代码。可以把全功能的组件实现推迟到以后。 编辑器窗体定义了一个 SetModified 方法,可以更新 Rich Edit 控件的 Modified 特性,并调用该控件的 UpdateStatus 方法。TFormEditor.UpdateStatus 向 Application.MainForm 所引用的窗体发送一个消息。这里的 好处在于,编辑器窗体甚至无需了解主窗体的实际对象名,或其他关于主窗体的状态栏行为的精确的实现 信息。 procedure TFormEditor.SetModified( const Value : Boolean ); begin RichEdit.Modified := Value; if( Value ) then UpdateStatus( Modified ) else UpdateStatus( '' ); end; procedure TFormEditor.UpdateStatus( const Text : String ); begin SendNotifyMessage( Application.MainForm.Handle, WM_UPDATETEXTSTATUS, 0, Integer(PChar(Text))); end; 您可以在主窗体中创建 UpdateStatus 方法,并将上面的代码编写为 FormMain.UpdateStatus( Text )。但 这意味着 FormEditor 必须维护 FormMain 对象的引用。这样的实现也还算不错,如果主窗体实例化为另一 第 18 章 创建 Windows 程序 471 个对象,就需要修改代码。 最不可取的实现是直接修改 StatusBaar。 procedure TFormEditor.UpdateStatus( const Text : String ); begin FormMain.StatusBar.Panels[0].Text := Text; end; 应该尽可能少像上面这样编写代码。它是紧耦合的,会导致脆弱的程序。如果窗体的对象名、状态栏 控件、状态的实现方式、面板的数目或序号等发生了改变,那么就需要修改编辑器窗体的代码。即使存在 上面那样僵硬的紧耦合代码,也绝不会是个好的选择。 更新行列信息 从编辑器窗体向主窗体发送行列信息也会引起类似的问题:编辑器了解行列位置,而主窗体显示状态。 这里也使用消息机制来实现行列信息的显示。主窗体有一个信息处理程序来响应更新,并调用 UpdateCursorPosition 来更新状态栏。 procedure TFormMain.UpdateCursorPosition( const Text : String ); begin StatusBar1.Panels[1].Text := Text; end; procedure TFormMain.WMUpdateCursorPosition( var Message : TWMUpdateCursorPosition ); begin UpdateCursorPosition( PChar(Message.Text) ); Message.Result := -1; end; 当光标位置改变时,编辑器窗体通过消息通知主窗体。当发生 TFormEditor.FormActivate 或 Rich Edit 控件的 SelectionChanged 事件时,将调用 TFormEditor.UpdateCursorPosition。该过程的实现如下: procedure TFormEditor.UpdateCursorPosition; var CharPos: TPoint; Text : String; begin CharPos.Y := SendMessage(RichEdit.Handle, EM_EXLINEFROMCHAR, 0, RichEdit.SelStart); CharPos.X := (RichEdit.SelStart -SendMessage( RichEdit.Handle, EM_LINEINDEX, CharPos.Y, 0)); Inc(CharPos.Y); Inc(CharPos.X); Text := Format( CursorPosition, [CharPos.Y, CharPos.X] ); SendNotifyMessage( Application.MainForm.Handle, WM_UPDATECURSORPOSITION, 0, Integer(PChar(Text))); end; Windows API 过程 SendMessage 用于将 SelStart 特性转换为表示光标位置的 X 和 Y 坐标。使用资源字 符串 CursorPosition 将光标位置格式化为文本,然后通过 SendNotifyMessage 方法通知主窗体。该代码使用 基本的 API 调用来完成任务,属于相当底层的代码;其好处在于编辑器窗体和主窗体可以分别开发,任一 窗体的修改都不会有什么不利的后果。前面的代码片断 FormMain.StatusBar.Panels[0].Text := Text;在 FormMain 发生改变的情况下,可能导致访问违例、无效状态信息、或无法编译通过。 第 18 章 创建 Windows 程序 472 更新键盘状态和系统时间 系统时间可以使用 TApplication.OnIdle 事件,但 TTimer.OnTimer 事件的频率可以更加精确的控制。将 定时器添加到主窗体,并把间隔设置为 750 毫秒(四分之三秒)。当 OnTimer 事件发生时,调用 UpdateDateTime。第五个面板显示格式化的日期和时间,调用 StatusBar.InvalidDate 来重新绘制状态栏。 警告:请记住定时器资源是有限的,应节约使用。 procedure TFormMain.UpdateDateTime; begin StatusBar1.Panels[StatusBar1.Panels.Count - 1].Text := FormatDateTime( 'h:mm:ss AMPM', Now ); StatusBar1.Invalidate; end; 回忆一下,面板 2、3、4 都具有 psOwnerDraw 风格,而状态栏具有 OnDrawPanel 事件处理程序。当 整个状态栏都无效时(见上面的代码)更新整个面板。包括自定义的键盘状态面板。RichEditor 的键盘信 息面板是动态定义的,使用阴影字体来更新键盘字体,文本看起来是凸出或凹入的(见图 18.8)。 procedure DrawShadow( const Text : String; Canvas : TCanvas; Index : Integer; Rect : TRect; Alignment : TAlignment; ForeColor, BackColor : TColor ); const Alignments: array[TAlignment] of Word = (DT_LEFT, DT_RIGHT, DT_CENTER); var Flags : Word; begin SetBkMode( Canvas.Handle, Windows.Transparent ); Canvas.Font.Color := BackColor; Flags := DT_EXPANDTABS or Alignments[Alignment]; DrawText(Canvas.Handle, PChar(Text), Length(Text), Rect, Flags); Rect.Left := Rect.Left - 1; Rect.Top := Rect.Top -1; Canvas.Font.Color := ForeColor; DrawText( Canvas.Handle, PChar(Text), Length(Text), Rect, Flags ); end; procedure TFormMain.StatusBar1DrawPanel(StatusBar: TStatusBar; Panel: TStatusPanel; const Rect: TRect); procedure DrawPanel( const Text : String; ForeColor, BackColor : TColor ); begin DrawShadow( Text, StatusBar.Canvas, Panel.Index, Rect, Panel.Alignment, ForeColor, BackColor ); end; begin // update statuskeys case Panel.Index of 2:if ( GetKeyState( VK_INSERT ) <> 0 )then DrawPanel( 'INS', clGray, clWhite ) else DrawPanel( 'OVR', clBlack, clWhite ); 第 18 章 创建 Windows 程序 473 3:if( GetKeyState( VK_NUMLOCK ) = 0 ) then DrawPanel( 'NUM', clGray, clWhite ) else DrawPanel( 'NUM', clBlack, clWhite ); 4:if( GetKeyState( VK_CAPITAL ) =0 ) then DrawPanel( 'CAPS', clGray, clWhite ) else DrawPanel( 'CAPS', clBlack, clWhite ); end; end; 提示:虚拟键,如 VK_NUMLOCK 等,是在 Windows.pas 单元中定义的,该单元与 Delphi 一同 发布。 第一个过程 DrawShadow 在前面已经看到过,它被用于第 9 章的扩展字体标签。通过使用两种颜色在 两个具有很小偏移的位置绘制文本两次,从而形成了阴影效果。OnDrawPanel 事件处理程序使用了嵌套过 程 DrawPanel,该过程调用 DrawShadow,并根据每个面板的索引向 DrawShadow 传递文本、面板的前景色 和背景色。GetKeyState 有一个参数,表示虚拟键,返回表示状态的整数。 18.4 建立编辑器窗体 编辑器窗体是 MDI 子窗体。而 MDI 允许在父窗体中同时打开多个同类型的文档子窗体。编辑器窗体 只需将 RichEdit 控件对齐到窗体。简要叙述一下,将窗体命名为 FormEditor;文件保存为 UFormEditor; 将窗体风格设置为 fsMDIChild;向窗体添加一个 RichEdit 控件,将其 Align 特性设置为 alClient。 我们还有一些困难需要解决。如果将特定的编辑行为集成到主窗体中,那么主窗体在进行操作前需要 确定当前的编辑器窗体。例如修改字体,主窗体将显示字体对话框,但必须确定当前的子窗体是哪一个。 另外,如果我们把字体编辑行为放到编辑窗体中(从语义来说,该行为是属于子窗体的),那我们必须解 决如何将该行为合并到主菜单中。幸运的是,主菜单知道如何来做。还记得 AutoMerge 特性吗? 通过向编辑器窗体添加 TMainMenu 组件,可以在编辑器窗体上定义特定于编辑器的行为;而且当每 个特定的窗体获得焦点时,它的菜单可以自动合并到主菜单中。除了需要把 AutoMerge 特性设置为 True 之外,GroupIndex 特性可以帮助确定把特定的菜单和菜单项合并到何处的问题。 18.4.1 自动合并 Format 菜单 Delphi 6 已经定义了 RichEditor 所需的新的动作组件。因此我们可以使用标准的动作组件,并能够以 最小的代价在程序中引入由菜单和工具栏驱动的丰富的字体编辑功能。要添加具有字体编辑功能的 Format 菜单,按照下列步骤即可: 1.向 FormEditor 添加 TMainMenu 组件。 2.插入新的&Format 菜单。 3.向编辑器窗体添加 TActionList 组件。 4.如图 18.9 所示,将 Rich Edit 控件相关的编辑动作组件添加到 TActionList 组件的 Format 类别,并 将这些动作组件分派到 Format 菜单的各个菜单项。 第 18 章 图 18.9 创建 Windows 程序 474 使用动作编辑器和如图所示的 Format 菜单 来建立 RichEditor 编辑器的 Format 菜单 5.将 FontEdit 标准动作组件添加到 Dialogs 类别,并将其分派到图中所示的 Select Font…菜单项。 这就是我们所需要做的。由于 TAction 组件已经定义了菜单项的行为和外观,因此只需将动作组件分 派到空白的菜单项即可,而不需要编写代码。但 TFontEdit 动作组件确实需要“Delphi 6 中新的动作组件” 一节中演示的 OnAccept 处理程序。 要记得把 TMainMenu.AutoMerge 特性设置为 True,并将 Format 菜单的 GroupIndex 特性设置为主窗体 上某两个相邻菜单之间的索引值,具体的值依赖于您希望该菜单所放置的位置。这里使用的 GroupIndex 值为 3。 所有其他的菜单项都合并了一些对编辑窗体有用的行为,但所用的技术是相同的。使用 GroupIndex 来确定菜单合并的位置。出于节省空间的考虑,程序的完整代码放在本书的 CD-ROM 上,并未收录在这 里。 18.4.2 创建一个惟一的临时文件 当单击 File | New 菜单项时,可使用两个 Windows API 函数来创建惟一的文件名。即 GetTempPath 和 GetTempFileName。这两个函数可以返回位于\Temp 目录、扩展名为.TMP 的文件。RichEditor 程序对这两 个函数进行了包裹,可以返回位于指定目录、扩展名为.RTF 的文件。 function GetTempPath : string; begin result := StringOfChar( #0, MAX_PATH ); Windows.GetTempPath( MAX_PATH, PChar(result)); result := TrimRight(result); end; function GetTempFileName( Directory : String = ''; Extension : String = '.tmp' ) : string; var FileName : String; begin if( Directory = EmptyStr ) then Directory := GetTempPath; FileName := StringOfChar( #0, MAX_PATH ); Windows.GetTempFileName( PChar(Directory), 'doc', 0, PChar(FileName)); FileName := TrimRight(FileName); if( Extension <> '' ) and (Extension <> '.tmp' ) then begin result := StringReplace(FileName, '.tmp', Extension, 第 18 章 创建 Windows 程序 475 [rfIgnoreCase] ); RenameFile( FileName, result ); end; end; 注意:很明显,上面的 GetTempFileName 可以进行简化,只返回具有.rtf 扩展名的文件,这 样将无需传递并测试 Extension 参数了。但这里却定义了一个更为一般的函数,其目的在于 适应可能在以后出现的需求。经验证明,在全局过程设计中多一点前瞻性的眼光,就可以省 掉以后很多的工作。 GetTempPath 通常返回‘C:\Temp’。GetTempPath 的结果初始化为包含了足够的空间,可以容纳最大 的路径长度。GetTempPath 只有在 GetTempFileName 的 Directory 参数为空字符串时,才会被调用。局部变 量 FileName 初始化为包含了 MAX_PATH 大小的空间,在调用 API 过程 GetTempFileName 时使用了‘doc’ 前缀,因此结果文件名会形如 doc23.tmp。如果 Extension 参数不是空字符串或‘.tmp’,将使用 Extension 参数替换扩展名‘.tmp’。FormEditor 传递的 Extension 参数为‘.rtf’。最后,重新命名得到的文件。 18.5 永久保存注册表中应用程序的设置 应用程序选项保存了帮助文件的名字和路径、默认的工作目录、以及表示是否备份文件的复选框,这 些在 Options 窗体上可以看到。Delphi 的 Project | Options 窗体可以用作模型。即使在非常简单的程序中, Options 窗体及其从注册表存储和获取数据项的能力都是用单独的对象实现的。如果把这些对象合并起来, 那么获取注册表设置时将必须实例化窗体,而有时候可能不想或无法这样做。 这里创建了两个单独的类 TAppRegistry 和 TFormOptions 来实现程序设置的持久化。Options 窗体使用 了编辑域和标签,在创建窗体时将注册表设置读入到控件中,但用户单击 OK 时把设置写回到注册表。 TAppRegistry 由 Delphi 中的 TRegistry 子类化而来。由于注册表实际上只有一个实例,我们在程序中将使 用 TAppRegistry 类的单对象实例。这意味着,要访问注册表,必须使用该对象实例。 function AppRegistry : TAppRegistry; begin if( Not Assigned(FAppRegistry)) then FAppRegistry := TAppRegistry.Create; result := FAppRegistry; end; 函数 AppRegistry 在接口部分声明。对于程序的其他部分来说,它与对象实例相似。函数的实现确保 了在每次调用函数时,有且只有一个有效的对象实例存在。本地变量 FAppRegistry 用作该类的惟一实例, 分别在单元的 initialization 和 finalization 部分初始化和清除。 initialization FAppRegistry := Nil; finalization FreeAndNil(FAppRegistry); 在 TAppRegistry 中定义了一些基本的方法来满足 RichEditor 程序在读写注册表方面的特定需求。 function TAppRegistry.GetString( const Key, Name : String ) : String; begin result := EmptyStr; if( OpenKey( AppKey + Key, True )) then begin result := ReadString(Name); CloseKey; 第 18 章 创建 Windows 程序 476 end; end; procedure TAppRegistry.SetString( const Key, Name, Value : String ); begin if( OpenKey( AppKey + Key, True )) then begin WriteString( Name, Value ); CloseKey; end; end; function TAppRegistry.GetBool( const Key, Name : String ) : Boolean; begin result := False; if( OpenKey( AppKey + Key, True )) then begin try result := ReadBool( Name ); except on ERegistryException do result := False; end; CloseKey; end; end; procedure TAppRegistry.SetBool( const Key, Name : String ; Value : Boolean ); begin if( OpenKey( AppKey + Key, True )) then begin WriteBool( Name, Value ); CloseKey; end; end; 构造函数设置了从 TRegistry 继承而来的 RootKey 特性。SetString、GetString、SetBool、以及 GetBool 分别定义了一些基本的行为。读写注册表的顺序是先调用 OpenKey,然后使用读写方法,最后调用 CloseKey。使用上述的基本方法,还为 RichEditor 程序实现了一些特定的功能,在这里没有列出。对于 TAppRegistry 和 TFormOptions 的完整代码,可以参考本书 CD-ROM 上的代码。 //title here : fit and finish 18.6 使程序合乎需要 程序员编写代码的能力都很强,但他们作为设计人员、分析人员、质量保证人员却表现不佳。这些职 位需要对软件开发的某个特定方面负责。但程序员很少有这种时间。不幸的是,许多公司都处于 CMM 1 级别,即所谓的初始级。“如果一个组织处于初始级,开发过程是临时而无秩序的,开发的成功依赖于少 数特别专注的开发人员的英雄式的努力。” (Booch,1996)。如果您所工作的组织并非上述情况,那么您就 第 18 章 创建 Windows 程序 477 太幸运了。另一方面,如果情况不怎么样,也不要失望。有许多可用的工具和资源可以帮助你有效地利用 时间。 18.6.1 调试与测试 如果您是独立开发者,那么大部分的调试和测试工作都需要您自己来完成。如果有正式的测试过程, 那么本节仍然适合您。 程序员必须对所有的代码进行单元测试,更强一些的是白盒测试。在把算法、类、单元集成到程序中 之前,您需要在单元测试中测试这些部件。除去有意定义了聚集关系的地方之外,代码应该可以在算法、 类、单元一级独立而无错的运行。创建一个简单的工作台,即所谓的测试程序,即可对代码进行测试。白 盒测试更为严格,它需要测试所有可能的代码路径。在第 9 章的 TDebug 组件中所创建的 Trap 行为可以确 保测试了每个代码路径。 使用断言(assertion)可以发现很多有害的错误、无用或不必要的代码、以及有缺陷的解决方案。不 一定要找到所有的错误,但必须知道这些错误是存在的,还要确定必须在发布程序之前解决掉哪些错误。 理想情况下,所有的软件在发布之前都必须是无错的,但这并不是现实。 18.6.2 质量保证 质量保证就是使程序合乎需要的过程。产品的行为是一致的吗?窗体是对称的吗?文字的拼写是否正 确?由专人直接负责质量保证工作,可以极大地提高用户的满意度和产品的可靠性。 QA(即质量保证)应包括黑盒测试,即从外部对系统进行测试,而无需知道系统内部是如何实现的。 由于程序员非常熟悉代码,他们通常知道如何避开缺陷。较为出色的产品,如 Starbase 公司的 StarTeam 等, 就包括了缺陷的估价与跟踪。这使得开发者可以进行合作、跟踪并解决各种缺陷。其显著效果远远超过了 所需的成本,即使对于独立工作的开发者来说也是如此。 18.6.3 文档 许多现存产品的帮助文档都是仓促创建的。良好的开发过程需要有职业技术作家来创建帮助文档,包 括在线帮助、用户指南、安装与配置信息、以及技术手册。RichEditor 程序的帮助文件是使用 RoboHelp 工 具创建的。该工具可以使任何作者都能写出职业水准的帮助文档。 注意:文档是软件开发外包的一个重要部分。外包提高了以固定成本完成文档编写的可能性, 而无需长期雇用职业的技术作家。通常,技术文档写作可以与开发过程并行进行,特别是在 分析与设计文档中包括了用例、书面描述、以及原型的情况下。 18.7 工程部署选项 祝贺你!您现在已经建立了自己的 Windows 程序。再来一次编译即可发行。最后一次建立工程包括设 置工程选项、最后一轮测试以及质量保证工作,建立安装用 CD 母盘,再烧制 100000 片拷贝。 对于 Delphi 程序来说,要关闭所有的运行时错误和调试选项。在 Project 菜单上选择 Build 来建立 RichEditor 程序。然后进行一轮用户测试和质量保证过程。自动化工具,如 Rational 公司的 SQA 套件中的 Robot,可以使这一切都非常快速。接着建立 CD 母盘。可以使用 Delphi 自带的 InstallShield Express 工具 进行。要记住再测试一次安装过程,并对从 CD 母盘进行的安装作黑盒测试。如果一切都满足预期要求, 那么您的产品已经完成,可以发行它了。 18.8 小 结 第 18 章涵盖了许多材料,大部分都与建立 Delphi 程序并且使用任意语言建立和部署软件相关。过程 是关键性的,但根据要建立的软件和涉及的人员而使用不同级别的过程是可以接受的。本章的讨论是最低 程度的,并在建立 Windows MDI 程序 RichEditor 时对其进行了示范。除了建立程序之外,您还学习了 Delphi 6 中引入的 TActionList 和 TAction 组件。 第 18 章 创建 Windows 程序 478 编程是一种全职工作。如果您在程序设计中担任了许多角色,那么您的不利地位是显然的。创建自定 义类,如 TFileQueryAction 组件,可以使您避免重复工作。与程序设计的最小化方法结合,自动化工具可 以使一两个开发者完成一支军队的任务。Delphi 是极好的软件开发工具,但只依赖于 Delphi 是不经济的, 那样你将在战术上处于显然的不利地位。 第 19 章 Delphi 的 SQL 的程序设计 在 Delphi 程序设计中,SQL 编程是非常强大的一个方面。SQL 指的是结构化查询语言。SQL 的大部 分实现都与该语言的某个特定定义相关。例如,许多数据库的 SQL 服务器支持 ANSI-92 SQL 定义。ANSI (美国国家标准局)由许多公司和个人组成,其既定兴趣是进行标准化。您所使用的 SQL 语言的语法依赖 于所用的特定数据库。 这里您可能会问,为什么需要 SQL,能否只用 Delphi?答案是可能两个都需要。如果建立数据库应用 程序,当然需要使用 Delphi 控件(与 Delphi 专业版和企业版一同发布),如 TTable、TDatabase、或 TClientDataSet 来管理数据库中的数据,但确实有些操作使用 SQL 更为容易。对于使用 SQL 相对简单的情 况,您可以使用 TQuery 等组件将 SQL 语句发送到数据库服务器。 例如,假定您有一个关于职员的数据库。进一步假定您非常高兴,要给所有的职员都加薪百分之十。 您可以编写 Delphi 代码打开包含薪水的表,并逐个职员增加薪水。或者,您可以编写一个 SQL 语句来完 成该工作。有些数据库服务器(服务器程序),像 SQL Server、Oracle、或 Interbase Server 可以在较为健壮 的服务器(硬件服务器)上运行,因此请求可以在硬件服务器上处理。最后结果是:更新操作可以在较为 快速、健壮的服务器上运行,而无须用大量的数据阻塞网络。如果网络是内部网、Internet 或外部网,两种 处理方法的响应速度会有很大的差别。 这意味着,如果您开发数据库应用程序时不使用 SQL,那么您和您的程序将处于非常不利的地位。不 要担心。本章将示范 SQL 语言的一般形式,以及如何在 Delphi 中使用它。本章中的 SQL 语言与 ANSI-92 SQL 标准密切相关,可以在大多数 SQL 服务器上工作。本章中的一些较为高级的特征可能需要根据实际 使用的数据库进行微小的改动。另外,本书的 CD-ROM 中包含了一个基本的 SQL Builder 工具,您可以使 用 SQL Builder 为程序定义并测试 SQL 语句。 19.1 结构化查询语言 结构化查询语言包括很多语言,都使用 SQL 的名字。对于当前的 ANSI 标准,每个厂商都支持不同的 兼容级别。例如,Oracle 包括 PL/SQL,它支持过程调用和参数传递,而 Microsoft 也提供了自己的版本, 称为 T-SQL。另外还有几种基本 SQL 的派生语言,但所有的语言在本质上都具有相当基本的语法、数目适 当的关键字、以及对数据处理的一般性支持。 基本的 SQL 支持在数据库中选择、插入、更新和删除记录。用得不那么频繁,但同样重要的操作是创 建与删除表。有些产品如 SQL Server 2000、Access 或 Oracle 提供了可视化的数据库建立工具,方便了数 据库的管理。更好的选择是 CASE 工具,如 DataArchitect 和 ERwin,这些工具提供与数据库之间的双向工 程能力(双向工程是指创建数据库并通过检查数据库将数据库结构读回到 CASE 工具的能力。对于设计和 建立数据库程序来说,CASE 工具是必不可少的) 。 本章示范了 SQL 语言的基本用法,其中包括最常用的一些命令,工具通常使用这些命令来完成工作。 我们就从四个最基本的 SQL 命令开始。 19.2 SQL 编程 数据库管理的最常见的任务包括数据的添加、删除和更新。如同 26 个字符的英文字母表一样令人迷 惑,从这些支持基本任务的命令中可以演化出各种表达力非常强的语句。本节我们从最简单的例子开始, 包括 SELECT、INSERT、UPDATE 和 DELETE 语句。如果您已经熟悉了基本的 SQL 语法,可以跳到下一 节,其中示范了这些语句的一些高级用法。 当学习这些例子时,请记住,您需要利用工具对命令进行编辑并将其发送到数据库。可以使用与 Delphi 第 19 章 Delphi 的 SQL 的程序设计 487 一同发布的 SQL Explorer(或 Database Explorer)、Database Desktop 或本书 CD-ROM 上的 SQL Builder 示 例程序。 19.2.1 SELECT 语句 SELECT 语句用于以行为单位从一个或多个表获取数据。现在,我们把注意力集中到单表的 select 语 句上。基本的 select 语句的规范形式如下。 注意:按照惯例,SQL 的关键字通常是大写的。如果 SQL 语句比较复杂,看起来可能有些令 人生厌。要选定一种风格并坚持之,一致性可以使得代码看起来从容而谨慎。 SELECT fieldslist FROM tablename 语句以关键字 SELECT 开始。fieldslist 可以是逗号分隔的字段名列表,或星号(*),后者意味着任意或 所有。FROM 子句表示了包含字段的表的名字。 这里引入了一些新的术语。我们将稍停一下来介绍这些术语。单个的表在逻辑上与由行和列组成的电 子表格非常相似。在电子表格中行列的交称之为表元。在数据库用语中,术语“行”与电子表格中的行意 义大致相同。电子表格中的术语列和表元合并为数据库中的字段。数据库中单个表的定义由其所有字段的 定义组成,包括字段名、字段类型、字段大小等信息。当表中含有数据时,单个记录可称之为行。数据库 由一个或多个表组成。 在任何时候,SELECT 语句都可以用于获取数据库中一个或多个表的某些或全部行。字段列表由数据 源表中的某些字段名组成。这里有几个 SELECT 语句的基本例子。 提示:这些查询是相对于\Program Files\Common Files\BorlandShared\Data 目录定义的, 该目录在 Delphi 安装时创建。在本书的 CD-ROM 上可以找到存储为文本文件的 SQL 语句。 SELECT * FROM BIOLIFE // biolife.sql SELECT WEIGHT, "SIZE" FROM ANIMALS.DBF // animals.sql SELECT CustNo As Customer FROM ORDERS // customer.sql SELECT O.CustNo As Customer FROM ORDERS O // customer2.sql (忽略 SQL 语句末尾的 Pascal 风格的注释。这些只是用于方便在 CD-ROM 上查找相应的文件。 )第 一个语句读作“选择 Biolife 表中的所有字段”。该表实际上是 Paradox 表,即 biolife.db 文件,可以在前面 提到过的示例数据库目录下找到。下一个语句只选择了两个字段:weight 和 size。而 size 是关键字。为避 免这个问题,我们使用引号把 size 括起来,使得可以将其作为字段处理。还可以注意到 animals.sql 中的 sql 语句对表名使用了文件扩展名。在 Paradox 和 dBase 数据库中,表存储在单独的文件中。如果表只是整个 数据库文件的一部分,则不能使用文件扩展名,例如 Access 中的.MDB 文件。customer.sql 示范了字段别名。 假定您不需要返回实际的字段名,而只需要具有良好格式的字段名,那么可以使用 As 子句来创建字段的 别名。第四个例子创建了 ORDERS 表的别名 O。当在 SQL 语句中有多个表(这里并未演示)的时候,创 建表的别名是很有用的。 可按照下列步骤,试一试下面的例子。 1.运行 Database Desktop。 2.在 Database Desktop 中,选择 SQL | Select Alias 菜单项,指向 DBDEMOS 别名(如图 19.1 所示) 。 图 19.1 选择 DBDEMOS 别名,以指向示例文件 3.选择 File | New | SQL File 菜单项打开一个空白编辑窗口,然后选一个上面所示的 SELECT 语句键 第 19 章 Delphi 的 SQL 的程序设计 488 入。 4.选择 SQL | Run SQL 菜单项(或单击带有闪电符号的工具栏按钮)来运行查询。 如果一切工作正常,可以看到一个 ANSWER.DB 表(见图 19.2),其中包含了 SQL 语句的结果集合。 这里新引入的概念是别名的思想。数据库可能位于其他的物理计算机上、跨越网络、也可能像示例表 那样位于本地。别名是由 Datasource Administrator 或 BDE Administrator 管理的(创建别名的详细信息,可 以参考第 13 章中关于打开数据库连接的有关章节)。 SELECT 语句可能相当复杂,可以对结果集进行精确的控制。在本章稍后关于高级 SQL 编程的部分中, 我们将继续讨论 SELECT 语句。现在我们开始学习 DELETE 语句。 图 19.2 由 customer2.sql 语句所生成的结果集合。可以注意到 字段别名 Customer 位于 ANSWER.DB 表中列的顶部 19.2.2 DELETE DELETE 语句非常简单。DELETE FROM tablename 将删除 tablename 表中所有的行。更常见的情况需 要删除数据的特定行。对这种情况,添加 WHERE 子句即可。这里有几个 DELETE 语句。在运行这些语句 之前最好对表进行一下备份,但如果偶然删除了表,总可以从 Delphi 光盘上恢复。 DELETE FROM BIOLIFE // del_biolife.sql DELETE FROM ANIMALS.DBF // del_animals.sql 提示:在 Database Desktop 中工作时,DELETE 和 INSERT 语句会创建 deleted .db 和 inserted.db 表,可用于恢复所进行的删除和插入。请记住,每次运行 INSERT 或 DELETE 语句时,或者退出 Database Desktop,临时的 inserted.db 和 deleted.db 总会被覆盖。 前面提到过,SQL 语言的基本语法是非常强大而直接的。还有什么会比删除所有的行更为强大呢?但 上面的 SQL 语句并不删除表,它只删除所有的行。 19.2.3 INSERT INSERT 语句更为复杂。它用于向表添加行。INSERT 语句可用于向所有的字段添加值,也可以只对某 些字段添加值,这依赖于具体使用的 INSERT 语句。由于这个原因,INSERT 语句需要表名、字段列表以 及与每个字段相匹配的值。 INSERT INTO "ANIMALS.DBF" VALUES("Turtle", 7, 5, "Wetlands", NULL) INSERT INTO ":DBDEMOS:animals.dbf" (NAME, ":DBDEMOS:animals.dbf"."SIZE", WEIGHT, AREA) VALUES('Turtle', 7.0, 5.0, 'Swamps') 提示:一个好习惯是避免将关键字作为字段名。 第 19 章 Delphi 的 SQL 的程序设计 489 上面给出了两个例子。第一个例子将值插入到 animals.dbf 表中。由于 VALUES 子句列出了所有字段 的值,所以忽略了实际的字段名。第二个例子示范了关键字 SIZE 的用法,它既是关键字,也是 animals 表的一个字段名。“:DBDEMOS:anmimals.dbf”是该表的完整路径,其中包括了别名。将实际的路径作为 所涉及的关键字的前缀,即可解决关键字用作字段名的问题。在第二个例子中,并未使用所有的字段(BMP 没有用到),这样就需要在第一个括弧中给出所需的字段列表。 19.2.4 UPDATE UPDATE 语句用于修改现存的记录。UPDATE 语句如果不使用 WHERE 子句,将更新所有的记录。大 多数情况下都需要限制更新记录的条件,但所有的更新都从基本的语句开始。SQL 语言中 UPDATE 的语 法如下: UPDATE tablename SET field1 = value1 [, field2 = value2, fieldn = valuen] 该语句以关键字 UPDATE 开始,后接表名。SET 子句后接逗号分隔的列表,包括所要更新的字段及其 新值。下面的例子示范了基本的 UPDATE 语句: UPDATE ANIMALS SET AREA = "WETLANDS" //upd_animals.sql 上面的语句修改了所有的记录,将每行 AREA 字段的值都更新为“WETLANDS”。 可以在 UPDATE 语句中进行计算。下面的 UPDATE 语句假定 animals.dbf 表中所有的动物都在圣诞那 一天吃得太多,因而体重增长了 50%。 UPDATE ANIMALS.DBF SET WEIGHT = WEIGHT * 1.5 // upd_animals2.sql UPDATE 语句的威力在于,可以对特定的行进行更新,而且还可以根据外部原则定义条件更新。关于 更多的 UPDATE 语句的例子,可以参考后面高级 SQL 编程章节中有关定义 WHERE 子句的部分。 19.2.5 SQL 与 TQuery 组件 在 Delphi、SQL 和数据库服务器之间,需要一些方法和技巧将三者结合起来。有几种组件可以连接到 数据库(细节请阅读第 13 章),但 TQuery 组件专门用于使用 SQL 语言连接到数据库服务器。实际上,TQuery 组件可用作客户/服务器、两层、以及使用 MIDAS 的分布式程序设计。 要向数据库服务器发送 SQL 语句,必须设置 TQuery 组件的几个特性。TQuery.Database 特性设置为表 示物理数据库位置的 BDE 或 ODBC 别名。DataSource 特性可动态使用,向查询提供参数化值。Params 特 性用于定义查询的参数,SQL 特性则包含了 SQL 语句的文本。Database 特性不用去管,DatabaseName 特 性的编辑器将自动填写该特性。下面我们来看一下 DataSource、Params 和 SQL 特性如何进行工作。 SQL 特性 当向 SQL 特性添加文本时,该文本将发送到 SQL 服务器。由于采用了 TQuery 组件,在后台将使用 BDE 数据库引擎作为中介。 注意:如果使用其他的查询组件,如 TADOQuery 组件,将忽略 BDE 引擎,但 SQL 特性本身的 工作方式仍然是相同的。TQuery 和 TADOQuery 组件的 SQL 编辑方式相同的原因在于,两个组 件使用了相同的对象类型来编辑 SQL 文本,都是 TStrings 对象。 当定义 SQL 语句时,如果向 SQL 语句添加参数,那么对语句进行语法分析时将创建 TParam 对象。 SQL 语句中的参数以冒号(:)为前缀。下面的例子示范了 SQL 语言中的参数。 UPDATE ANIMALS.DBF SET WEIGHT = WEIGHT * :WEIGHT //upd_animals3.sql 如果把上述 SQL 语句添加到 TQuery 组件的 SQL 特性,那么在 TQuery 组件中将创建参数 WEIGHT 第 19 章 Delphi 的 SQL 的程序设计 490 (见图 19.3) 。在 SQL 语句中定义参数时,将创建 TParam 对象并添加到 TParams 特性。在设计时,您可 以指定参数的 DataType 和特性的 ParamType。ParamType 表示该参数将如何使用(使用 ParamType 的例子 请参见第 13 章)。 图 19.3 前台为参数编辑器,后台为 Object Inspector,演示了将 SQL 文本存储到 TQuery.SQL 时创建:WEIGHT 参数的情景 DataSource 特性 TQuery.DataSource 用于指定数据源,该数据源对应的数据集将为 SQL 语句中的参数提供值。考虑下 面的 SQL 代码: SELECT * FROM Customer C WHERE C.State = :State 对包含上述 SQL 查询的 TQuery 组件的 DataSource 特性指定数据源之后,将自动填写语句中的:State 参数。惟一的额外条件是,第二个数据集中的字段名要与参数名相同。 19.3 高级 SQL 编程 SQL 代码可能会相当复杂。以 Oracle 和 PL/SQL 为例,可能出现可以生成 SQL 代码的 SQL 代码。本 节将讨论一些较为高级的 SQL 编程概念,使得您可以创建实际的 SQL 解决方案。 19.3.1 定义 WHERE 子句 WHERE 子句可用作已示范过的 SQL 语句的附件。该子句的目的是对 SQL 语句的结果集进行限制, 以得到更好的结果。最常见的情况是,WHERE 子句定义了一个字段名与值对的列表,由布尔操作符分隔。 下面举的例子示范了 WHERE 子句与四种 SQL 语句联用时的基本语法。 SELECT 和 WHERE SELECT * FROM biolife.db WHERE Category="Ray" // where_biolife.sql 第 19 章 图 19.4 Delphi 的 SQL 的程序设计 491 由 SQL Builder 示例程序创建的 SQL SELECT 语句。为简 明起见,使用星号(*)替换了程序所返回的字段名列表 上述 SELECT 语句(使用 SQL Builder 示例程序创建,相应的文件在本书光盘上)示范了一个使用相 等测试的 SELECT 语句。当激活该查询时,会返回 Category 字段值为“Ray”的那些记录。 UPDATE 和 WHERE 本节将示范使用大于(>)操作符的 UPDATE 语句。假定您的公司增长速度非常快,因此需要调整统 计程序中的雇员数目。下面的 UPDATE 语句的例子对所有大于 50 的雇员序号都乘以 10。 UPDATE employee.db SET EmpNo=EmpNo * 10 WHERE EmpNo > 50 注意:SQL Builder 使用字段和值对为 SELECT 和 UPDATE 语句创建 WHERE 子句,以及 UPDATE 语句的 SET 部分,还有 INSERT INTO 语句的字段列表和 VALUE 子句。 上述 SQL 代码保存在本书光盘的 where_upd_employee.sql 文件中。使用 SQL Builder 示例程序,执行 下列步骤即可创建该 SQL 代码: 1.运行 SQLBuilder.exe,该程序位于书后的光盘上。 2.参照图 19.4,将 Database Name 设置为 DBDEMOS。 3.从 Table Name 组合框中选择 employee.db 表。 4.在 Field Names 列表中将 EmpNo 字段的新值设置为 EmpNo*10。 5.从 SQL Type 单选按钮组中选择 UPDATE 类型。 6.最后,在 Design 属性页底部的 SQL 文本编辑器中输入 WHERE 子句“WHERE EmpNo > 50” 。引号不需 要输入。 7.单击 Tools | Run SQL 菜单项运行该语句。 UPDATE 语句在 Data 属性页上不会显示输出。另外,您也可以在 SQL Builder 示例程序中选择 Database Name 后,手工输入 SQL 语句来试一下 UPDATE。 DELETE 和 WHERE DELETE 语句很强大,加上 WHERE 子句后就更加有用。可以在 WHERE 子句中使用另外一些操作符 来扩展 WHERE 子句所能过滤的行。下面的例子示范了 DELETE 语句和 WHERE 子句,其中使用了 LIKE 操作符。 DELETE FROM parts.db WHERE Description LIKE "Dive%" 第 19 章 Delphi 的 SQL 的程序设计 492 上述语句保存在 where_del_parts.sql 文件中,使用了 LIKE 操作符来过滤以 Dive 开头的 DESCRIPTION 字段。Paradox 表将%(百分号)与 LIKE 操作符一同使用,其他的 SQL 服务器则用*(星号)。 利用 WHERE 子句和 LIKE、<、>等操作符,可以为 SQL 语句创建很多过滤条件。例如,SQL Server 等服务器支持 CONTAINS 和 BETWEEN 等操作符。下面的语句示范了一个使用 BETWEEN 操作符的 SELECT 语句,该操作符在 UPDATE 语句中同样容易使用。 SELECT * FROM PLAYER_STATISTICS WHERE GOALS BETWEEN 2 and 5 注意:当调试时,可以在 Tools | Debugger Options 对话框的 Language Exceptions 属性 页上选择 Stop On Exceptions 复选框。该选项可以使程序停在导致异常的代码处,本质上 该异常将会在 IDE 中遇到两次,其中一次将控制转交给程序员,另一次将控制转交给用户。 当调试程序时,您可能需要忽略一些异常。您可以将这些异常添加到 Exception Types to Ignore 列表,该列表在 Debugger Options 对话框的同一属性页上。 这是件可以节省时间的事情。每当 SQL Builder 生成了不合适的 SQL 语句就会发生 EDBEngineError 异常。这并不是源代码的错误,而是一个 SQL 错误,需要通过输入正确的 SQL 语句来解决。选择性地忽略一些异常,可以使得创建 SQL 例子更加简单。 上述例子来自于第 13 章创建的 PLAYER_STATISTICS 表。该语句保存在 where_betw_renegades.sql 文 件中,将返回所有目标数在 2 至 5 之间(包含 2 和 5)的玩家。对于特定的数据库服务器来说,您需要看 一下参考手册来确定其 ANSI_SQL 兼容性以及实际支持的 SQL 语句。 INSERT 和 WHERE INSERT 语句中的 WHERE 子句的作用是同样的。在 INSERT INTO 语句中,WHERE 子句可用于附加 一个嵌套的 SELECT 语句,从而有效地从另一个数据集复制多行到当前表。例子可以参考嵌套查询部分。 使用 WHERE 的隐式表连接 隐式连接语句使用了 WHERE 子句,将多个表通过两个相同字段关联起来。这就是关系模型最强大的 功能之一:使用不同的表来避免数据的复制,但仍然可以使用关键字段表示相互关系,从而给出数据的统 一视图。下面的 SELECT 语句示范了一些使用 WHERE 进行连接的技术。 SELECT C.CustNo, C.Company, O.* FROM Customer C, Orders O WHERE C.CustNo = O.CustNo 上面列出了两个表,并分别赋予别名。Customer 表的别名为 C,Orders 表的别名为 O。语句只返回 Customer 表的 Company 和 CustNo 字段,而使用 O.*返回了 Orders 表的所有字段。两个表是通过 C.CustNo = O.CustNo 语句进行连接的。 该语句可以读作“在顾客记录与订单记录的顾客序号相等的情况下,返回所有的行,行的字段包括 Customer 表的 CustNo 和 Company,以及 Orders 表的所有字段”。这是很符合逻辑的。 这种类型的连接有一些主要的缺点:首先,既不会返回孤立的订单,也不会返回没有订单的顾客。孤 立的订单是指订单所关联的顾客已经被删除了。考虑顾客被删除的情况,程序可能并未同时删除一些相关 的信息,如订单。您的数据库中可能多出一些垃圾。如果希望给出顾客列表,上面的 WHERE 语句可能就 有些问题。连接关系表的更灵活的方式是使用 JOIN 子句。 19.3.2 使用 JOIN 子句 JOIN 子句是 WHERE 子句的近亲,但更为强大。当编写的 SQL 语句包含多个表,而 WHERE 子句需 要对来自不同表的两个以上的字段进行相等测试时,您实际上进行了隐式连接。使用 JOIN 子句可以产生 同样的结果,但效果更好。 SELECT DISTINCT C.CustNo, C.Company, O.* FROM CUSTOMER C LEFT JOIN ORDERS O on (C.CustNo = O.CustNo) 第 19 章 Delphi 的 SQL 的程序设计 493 与上一节的隐式 WHERE 连接相比,上述的 JOIN 语句语义更为精确。它可以理解为“返回 Customer 中所有惟一的行以及 Orders 表中所有与之相匹配的行。”从技术上讲,LEFT JOIN(如果支持的话)表示, 应返回 FROM 子句之后第一个表中的所有行,而无论是否存在右侧表中的行与之相匹配;本例中右侧的表 是 Orders。 RIGHT JOIN 的效果刚好相反。在上述查询中进行右连接将返回所有的订单,不管它们是否与顾客相 匹配,而只会返回与订单相匹配的顾客。 自连接 进行连接时一个有用的机制是将表连接到自身,可称之为自连接。自连接可在同一 SQL 语句中对同一 表中的数据进行比较。假定您需要确定所有位于同一个州的顾客(使用 CUSTOMER 和 ORDERS 表进行示 范)。可以使用自连接来比较是否顾客序号字段不等而州字段相等。相应的 SQL 语句如下。 SELECT C.COMPANY C.STATE, "=", C1.STATE, C1.COMPANY FROM CUSTOMER C JOIN CUSTOMER C1 on (C1.CustNo <> C.CustNo) AND C1.STATE = C.STATE ORDER BY COMPANY 注意:请注意“=”的用法。该符号添加了一个虚设的字段,其值为=。结果返回行所包含 的字段值如下:Action Club FL = FL Blue Sports Club。您可以想像一下,如何使用相似 的技巧把 SQL 文本嵌入到返回的字段列表中。 该语句使用别名 C1 和 C 从 CUSTOMER 表中选择 COMPANY 和 STATE 字段。表是通过不匹配的 CUSTNO 字段和匹配的 STATE 字段进行连接的。结果列出了所有位于同一个州的顾客。在 JOIN 前添加关 键字 LEFT,则每个州只返回一个顾客。这里所示范的技术在确定顾客数目最多的州时非常有用。 交叉连接 要谨防交叉连接,交叉连接就是会产生表的笛卡尔积的连接。在笛卡尔积中,每一行都与另一个表中 的每一行进行连接。如果使用自连接进行演示,当查询过于含糊时可能会产生交叉连接。 SELECT C.COMPANY, C.STATE, C1.COMPANY, C1.STATE FROM CUSTOMER C LEFT JOIN CUSTOMER C1 on (C1.CustNo <> C.CustNo) 上例对顾客序号不等的所有顾客记录进行连接。在示例数据库 customer.db 中,有 56 个记录。该查询 的结果有 3080 个。这并不是我们所需要的结果。 19.3.3 对数据排序 数据可通过 ORDER BY 子句进行排序。可以选择字段名,后接所需的排序方式。默认的排序方式是 升序的;如果不给出排序方式,结果将按升序排列。下面的代码示范了 ORDER BY 子句。 SELECT "parts.db".PartNo, "parts.db".VendorNo, "parts.db".Description, "parts.db".OnHand, "parts.db".OnOrder, "parts.db".Cost, "parts.db".ListPrice FROM parts.db ORDER BY Description, Cost Desc 上述冗长的 SELECT 语句返回的结果数据集是库存清单,Description 字段默认按照升序排列,Cost 字段按降序排序。在名字相同的情况下,较为昂贵的零件列在前面。 19.3.4 GROUP BY 语句 GROUP BY 语句与 SELECT 语句联用。可以将 WHERE 和 ORDER BY 子句与 GROUP BY 子句同时 使用。GROUP BY 子句可用于进行摘要。例如,如果要对顾客的购物总量进行统计,编写下列语句即可, 第 19 章 Delphi 的 SQL 的程序设计 494 其中包括了顾客序号和名字。 SELECT C.CUSTNO, C.COMPANY, Sum(O.AMOUNTPAID) As Total FROM CUSTOMER C LEFT JOIN ORDERS O ON (C.CUSTNO = O.CUSTNO) GROUP BY CUSTNO, COMPANY 该查询选择了 CUSTNO 和 COMPANY 字段,并对 AMOUNTPAID 字段调用 Sum 函数,而且将 AMOUNTPAID 字段的总和命名为 Total。对表进行连接,只返回进行了购物的顾客。GROUP BY 子句对 记录进行聚合操作,在求和操作中不涉及的字段要在 GROUP BY 子句中列出,使用逗号分隔。 假定要列出购物金额最多的顾客,那么在查询的结尾添加 ORDER BY Total Desc 即可。修改后的查询 如下。 SELECT C.CUSTNO, C.COMPANY, Sum(O.AmountPaid) As Total FROM CUSTOMER C LEFT JOIN ORDERS O ON (C.CUSTNO = O.CUSTNO) GROUP BY CUSTNO, COMPANY ORDER BY Total Desc 对于示例数据库表,该查询将返回 Sight Diver,金额大约$260000。可以对聚合字段使用 HAVING 子 句进行过滤操作,下一节对此进行示范。 19.3.5 HAVING 子句 HAVING 子句类似于 WHERE 子句。HAVING 用于与 GROUP BY 子句联合使用,可以对聚合字段进 行过滤。HAVING 子句会进行一些条件测试,通常是将聚合字段值与某些值进行比较。继续上一节的例子, 我们进一步改进对顾客的选择,并使用 HAVING 子句得到购物最多的顾客。 SELECT C.CUSTNO, C.COMPANY, Sum(O.AmountPaid) As Total FROM CUSTOMER C LEFT JOIN ORDERS O ON (C.CUSTNO = O.CUSTNO) GROUP BY CUSTNO, COMPANY HAVING Sum(O.AmountPaid) > 100000 ORDER BY Total Desc 代码中只增加了 HAVING Sum(O.AmountPaid) > 100000。结果数据集只会返回一个顾客,他买了 $100000 以上的商品。如果要找到最能干的销售员、购物最多的顾客、或实际的市场动向和未来的销售情 况,该技术是十分有用的。 本书的 CD-ROM 上包括了大约一打左右的 SQL 文本文件,可以试验许多不同的查询和子句类型。不 幸的是,您可能需要查看一下数据库厂商的参考手册,以确认该厂商是如何实现 SQL 语言的某些特定方面 的;但本章中的 SQL 相等通用,并在 Paradox 和 SQL Server 数据库上测试过。 19.3.6 UNION 和 INTERSECTION UNION 和 INTERSECTION 的工作方式与它们的名字刚好符合。可以对两个不同类的表进行并操作, 返回的结果集是单个查询的结果集之并。或者可以进行交操作,以返回两个不同的源之间相似的数据。 假定有两个表,可能来自两个不同的零件供应商,包含了零件序号的列表。一个表是 items.db,另一 个是 parts.db。 SELECT PARTNO FROM ITEMS UNION SELECT PARTNO FROM PARTS 上述 UNION 操作将创建单一的结果数据集,由两个表中所有的零件序号组成。不幸的是,两个 SELECT 语句中的字段数目必须匹配,这使得字段数目不相等时难于合并。您也可以加入包含相似数据的字段,并 使用字段别名为语义相关的字段提供单一的名字。例如,在 Delphi 自带的 items.db 和 parts.db 示例表中, 第 19 章 Delphi 的 SQL 的程序设计 495 Items.Qty 和 Parts.OnHand 字段包含相同类型的数据,因此在结果集中包括零件的数量是有意义的。 SELECT PARTNO, Qty As Quantity FROM ITEMS UNION SELECT PARTNO, OnHand As Quantity FROM PARTS 有些数据库服务器不支持 INTERSECTION 关键字。在可用的情况下,INTERSECTION 只返回两个数 据集所共有的行。 19.3.7 定义嵌套查询 嵌套查询是嵌入到其他 SQL 语句中的 SELECT 语句。例如,可用使用嵌套子查询来限制 UPDATE 和 DELETE 语句所影响的行,并提供了在 INSERT INTO 语句中插入多个记录的方法,还能对数据库进行更 为复杂的查询。 提示:在 SQL Builder 中试验不同的查询时,可以注释掉查询的一部分或全部,因而可以在 候选的查询之间来回切换(例子可以参考图 19.5)。对 SQL 文本可以使用 C 风格的注释/* */。 图 19.5 示范了在 SQL 文本中使用 C 风格注释 可以将嵌套子查询用作 WHERE 子句中的表。外层的 SQL 语句和内层的嵌套 SELECT 语句都可以使 用任何 SQL 功能,但其相互关系必须是有意义的。例如: SELECT OrderNo, AmountPaid FROM ORDERS O WHERE EXISTS (SELECT * FROM ITEMS I WHERE O.OrderNo = I.OrderNo AND I.Qty > 50) 外层的查询从 ORDERS 表中取出 OrderNo 和 AmountPaid 字段,条件是 OrderNo 在 ITEMS 表中存在 匹配的记录,并且相应的数量 I.Qty 必须大于 50。括弧中的 SELECT 语句构成了嵌套子查询,它返回值作 为外层查询中 WHERE 子句的参数。 非常有趣的是,通过简单的查询和 JOIN 子句也可以得到嵌套查询的结果。 SELECT DISTINCT OrderNo, AmountPaid FROM ORDERS O JOIN ITEMS I on (O.OrderNo = I.OrderNo) AND I.Qty > 50 单个查询的性能要比嵌套查询好得多;实际上,嵌套查询可能导致服务器变得非常慢。但还是有些例 子需要使用嵌套查询,因为其他风格的查询可能工作不正确。 第 19 章 Delphi 的 SQL 的程序设计 496 假定要对已支付的货款与平均值进行比较。在简单的查询中,我们需要对 AmountPaid 字段取平均值, 并将其他字段放到 GROUP BY 子句的字段列表中。如果要返回 AMOUNTPAID 字段的平均值并同时返回 AMOUNTPAID 字段的值,则后者会由于 GROUP BY 而成为聚合值。将字段与其聚合值进行比较的惟一途 径是使用嵌套查询,如下所示。 SELECT ORDERNO, AMOUNTPAID FROM ORDERS WHERE AMOUNTPAID > (SELECT AVG(AMOUNTPAID) FROM ORDERS) 嵌套查询将返回 AMOUNTPAID 字段的平均值,然后用该值与单独的 AMOUNTPAID 字段值进行比较。 19.4 小 结 SQL 语言存在许多种实现。每一种实现都需要单独的书来说明。我不打算去做不可能的事情——对 SQL 语言进行详尽的陈述,因此本章只示范每个厂商的 SQL 实现中都具有的核心命令。从本章可以学到 四个基本的命令——INSERT、UPDATE、SELECT 和 DELETE,还有许多子句,可用于更好地控制语句所 存取的行。 在本书的 CD-ROM 上包括了一个基本的示例程序,SQL Builder。该程序可以选择在 ODBC 和 BDE 中注册的数据库,并在这些数据库的表中进行选择,进而生成基本的 SQL 命令。在附录 C 中,我们把 SQL Builder 转换为自动化服务器,并可以用自动化客户程序来生成 SQL 语句。 关于 SQL 语言的其他信息,可以学习所用 SQL 语言厂商的参考手册。本书后面的参考书目中包含了 一本关于 Microsoft SQL Server 的好书,更多的书籍可以在 www.Osborne.com 找到。 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 附录 A 与第 11 章的内容前后承继。阅读第 11 章之后,您已经了解了创建定制组件的大部分知识。附 录 A 也很重要,它示范了如何创建组件编辑器以及使用 OpenTools API 对 Delphi 自身进行扩展。二者分属 不同的主题:一个与组件相关,另一个则是要扩展 Delphi。之所以将二者放到附录中,是因为它们没有其 他技术那样常用。但要用到二者的时候,它们都是很有用的。 定制组件编辑器可以定义设计时对话框,编辑器在 Object Inspector 不够用时,使得用户能够可视化地 修改特定于该组件的每个方面。一个很好的例子就是 TChart 组件,由 Dave Berneda 开发。另外,在设计 时您还可以从组件的上下文菜单中运行该组件所包含的代码。 假定您使用 Delphi 已经有一段时间了,而您认为 Delphi 缺乏某些必要的特征。我三年前在一个工程 上工作时,就发生了这样的情况。当时正在对 Rational Rose 所定义的系统结构模型进行编码,我们已经厌 烦了手工定义类并编写函数体。实在是太烦了。创建一个类来读取类的声明并编写函数体,这看来是个不 错的主意。使用 OpenTools API,有时候再借助一下 Ray Lischner 的书《Hidden Paths of Delphi 3: Experts, Wizards, and the Open Tools API》,我们最终向 Delphi 添加了一个能够调用类生成器的菜单项。结果终于摆 脱了这本来可以自动完成的、烦人的任务(可惜的是我们没有一本语法分析方面的好书,我有点离题了) 。 这准确地描述了 Inprise 公司在决定向 Delphi 专业版和企业版用户提供 OpenTools API 时的想法。当需 要 Delphi 具有某些功能时,添加上去就行了。Delphi 现在还具有“Complete class at cursor”的代码生成功 能,因此我们可以创建一个尚不存在的专家:可以生成专家的专家。 当您阅读本章后,可以了解到如何创建组件编辑器以及怎样使用专家对 Delphi 进行定制。有一个工具 可用于开发定制专家,这使得创建专家与创建组件一样容易。 A.1 OpenTools API 介绍 OpenTools API 原来定义为抽象虚类,即它使用了 Delphi 接口,而我们可以继承它以便向 Delphi 添加 扩展。原来的那些单元仍然存在于你安装的 Delphi 的 Source\ToolsAPI 子目录下,但在大多数情况下它们 已经让位于 ToolsAPI.pas 单元中定义的 COM 接口。 注意:ToolsAPI 单元与 Delphi 专业版和企业版一同发布。您也可以对 Delphi 标准版进行定 制,只是包含相应接口的单元在 Delphi 标准版中是没有的。 如果您对 Delphi 抽象接口比较熟悉,那么比从零开始要好一些。不管怎样,您都应该学习 COM 接口, 这正是我们在本章中要做的。 A.1.1 OpenTools 接口 大多数情况下,OpenTools 接口都是位于 Source\ToolsAPI\ToolsAPI.pas 单元中的 COM 接口。为提高 后向兼容性,该目录下也定义了风格较老的 Delphi 接口。表 A.1 完整地列出了 ToolsAPI 中的所有单元。 带有星号的单元包含了风格较老的 Delphi 接口,通常应该避免在较新的代码中使用。 警告:很差的是,这些单元在帮助文件中并没有很好的文档。首先要参考单元中的代码;代 码中的注释很有帮助,但默认某些知识;而经过仔细查找,我们发现几乎完全没有集成化的 帮助。这真是个不幸,如果要进行扩展,您必须阅读许多代码并进行实验。 表 A.1 Delphi ToolsAPI 单元列表。通过实现 ToolsAPI.pas 单元中 定义的 COM 接口,可以访问 Delphi 的大部分功能 单元 描述 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 toolsapi.pas 501 包含了新的 COM 接口,它替换了在其他单元中可以找到的风格较老的接口(本章中 将广泛地使用该单元的接口) vcsintf.pas 包含了与版本控制系统进行链接的 COM 接口 dsgnintf.pas 包含了特性编辑器、组件编辑器以及注册过程所需的接口(例如, RegisterComponentEditor) editintf.pas* exptintf.pas* 风格较老的 Delphi 抽象接口,用于访问编辑器缓存,例如单元的文本 风格较老的单元,其中包括了用于定义专家的抽象虚类 TIExpert;新代码应使用 ToolsAPI 单元中的 COM 接口 fileintf.pas* 风格较老的单元,其中包括了用于访问文件系统功能的抽象虚接口 istreams.pas* 包含了流、内存流、文件流的接口 toolintf.pas* 与 Delphi 菜单和 ToolServices 相关的接口;在新代码中应使用 ToolsAPI 单元中的 BorlandIDEServices COM 对象以及 IOTAMenuWizard virtinft.pas* 包含了 TInterface 的定义,以及 Delphi 对基本的 COM 接口 IUnknown 的实现 注意:本章中可能会交替使用向导和专家这两个词。它们都是指 Delphi 中的专家。之所以 使用两个词,是因为 Inprise 也并未确定使用单个词。注册过程使用向导这个词,而 COM 接 口也包含了向导这个词。在 Delphi 中进行讨论时,对这两个词进行区分是没有意义的。 现在已经无需了解进一步的细节了,我们来创建一个 Delphi 专家。 A.1.2 创建向导 对 Delphi 向导进行扩展的最为直接的途径就是实现 IOTAWizard 和 IOTAMenuWizard 接口。这两个接 口都定义在 ToolsAPI 单元中,而且您可以看到,它们非常容易实现。 注意:首字母缩略词前缀 IOTA 指的是 Interface for OpenTools API(我是这样认为的!), 它也可能是指一幕希腊剧,意思是指非常小的数量(因为只有很少量的代码需要实现)。 实现 IOTAWizard 和 IOTAMenuWizard 最容易实现的向导是非常基本的 IOTAWizard 接口,它使用 IOTAMenuWizard 类来实现。IOTAWizard 接口需要实现四个方法,而 IOTAMenuWizard 则把一个菜单项放置到 Help 菜单上。由于刚刚起步,我们 将以向导的形式实现一个 Hello World 例子。为使读者不至于失望,将在下一节实现一个较为有用的向导。 下面的代码定义 IOTAWizard 和 IOTAMenuWizard。实现基本的向导并显示在 Help 菜单上,需要实现 IOTAWizard 接口的四个方法:GetIDString、GetName、GetState 和 Execute。由于 IOTAWizard 继承了 IOTANotifier 接口,您还需要实现 IOTANotifier 接口。可以使用 TNotifierObject 存根类作为 IOTANotifier 接口的实现。IOTANotifier 接口引入了 AfterSave、BeforeSave、Destroyed 和 Modified 方法,以便对事件进 行 响 应 。 对 这 个 练 习 而 言 , 该 存 根 类 就 足 够 了 。 IOTAMenuWizard 继 承 了 IOTAWizard 接 口 。 在 IOTAMenuWizard 类中,惟一需要实现的方法是 GetMenuText,该方法返回在 Help 菜单上显示的文本。 IOTAWizard = interface(IOTANotifier) ['{B75C0CE0-EEA6-11D1-9504-00608CCBF153}'] { Expert UI strings } function GetIDString: string; function GetName: string; function GetState: TWizardState; { Launch the AddIn } procedure Execute; end; IOTAMenuWizard = interface(IOTAWizard) ['{B75C0CE2-EEA6-11D1-9504-00608CCBF153}'] function GetMenuText: string; end; 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 502 这个没有实际功能的向导定义为 TDummyWizard 类,该类是 TNotifierObject、IOTAWizard 以及 IOTAMenuWizard 的子类。它实现了上面代码所列出的接口中的五个方法。完整的实现代码如下。 unit UDummyWizard; // UDummyWizard.pas - Demonstrates basic wizard interface // Copyright (c) 2000. All Rights Reserved. // By Software Conceptions, Inc. http://www.softconcepts.com // Written by Paul Kimmel. Okemos, MI USA interface uses Windows, ToolsAPI; type TDummyWizard = class(TNotifierObject, IOTAWizard, IOTAMenuWizard) public function GetIDString : String; function GetName : String; function GetState : TWizardState; procedure Execute; function GetMenuText : String; end; procedure Register; implementation uses Dialogs; procedure Register; begin RegisterPackageWizard(TDummyWizard.Create); end; { TDummyWizard } procedure TDummyWizard.Execute; begin MessageDlg( 'Building Delphi 6 Applications', mtInformation, [mbOk], 0 ); end; function TDummyWizard.GetIDString: String; begin result := 'SoftConcepts.DummyWizard'; end; function TDummyWizard.GetMenuText: String; begin result := 'Dummy Wizard'; end; function TDummyWizard.GetName: String; begin result := 'Dummy Wizard'; end; 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 503 function TDummyWizard.GetState: TWizardState; begin result := [wsEnabled]; end; end. Register 过程以 TDummyWizard 的一个实例为参数调用了 RegisterPackageWizard。您可以像安装组件 一样把专家安装到包中,如上例。实际上,进行安装最容易的方法就是使用 Delphi 中的 Component | Install Component 菜单项。当用户单击添加的菜单项时,即可调用这个非常基本的向导。当单击菜单项时,将调 用向导实现的 Execute 方法来响应。TDummyWizard 在一个 TMessageDlg 对话框中显示本书的标题。当然, 如果您确定的话,可以在 Execute 方法中加入几乎任何级别的复杂行为。GetIDString 方法返回向导的字符 串标识符。按照惯例,该 ID 的前缀是您公司的名字,这里使用了 Software Concepts, Inc 公司的注册商标 SoftConcepts,并将其通过圆点连接到向导的名字。 GetMenuText 的实现代码中包含了显示在帮助菜单上的菜单项文本。当每次单击 Delphi 的 Help 菜单 上相应菜单项时,都会调用该方法。GetName 方法返回向导的名字,而 GetState 方法则返回 TWizardState 类型值。该类型定义如下: TWizardState = set of [wsEnabled, wsChecked]; wsEnable 表示该向导是否是活动的,而 wsChecked 值则在菜单项上放置一个检查标记。从代码可以看 到,基本的 Help 菜单向导所需的代码非常少。当安装向导后,Delphi 的帮助菜单上出现了一个新的菜单 项 Dummy Wizard。当用户单击向导时,将调用 Execute 方法,从而在 TMessageDlg 对话框上显示文本 “Building Delphi 6 Applications”。关于相应的菜单项和单击后的反应,可以参见图 A.1 和 A.2。 图 A.1 Dummy Wizard 添加到 Delphi 的 Help 菜单 图 A.2 当单击 Dummy Wizard 菜单项时,将显示 TMessageDlg 对话框,其代码可以参见 Execute 方法 注册向导 把向导添加到包,并像组件一样对其进行安装,即可扩展 Delphi。当把包编译为 BPL 库之后,将调用 上一节的 Register 过程来进行安装。如上一小节的代码所示,RegisterPackageWizard 需要向导的一个实例 作为参数。RegisterPackageWizard 定义在 ToolsAPI.pas 单元中,其参数为 IOTAWizard 类型的常量引用,该 过程声明如下: procedure RegisterPackageWizard(const Wizard: IOTAWizard); 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 504 要安装向导,可以按照下列步骤进行。 1. 在 Delphi 中,单击 Component | Install Component 菜单项。 2. 在 Install Component 对话框中,如果单元尚未显示在 Unit file name 域中,则单击 Browse 按钮找 到相应的单元。 3. 如果要在当前包中安装专家,单击 OK。否则单击 Into new package 属性页(见图 A.3),并给出包 的名字及描述。 图 A.3 图中为 Install Component 向导,用于将向 导安装到包。所需步骤与安装组件时相同 4. 当单击 OK 后,给出的包将在包编辑器中打开(见图 A.4) 。单击 Compile 按钮(见图 A.4)。 5. 对包进行编译之后,Install 已经可用,单击该按钮。 要记住,包在本质上是动态链接库,也是一种应用程序。因此,可以而且应该像其他程序一样对选项 进行设置。要加入路径和版本消息,并记得设置对所处的开发阶段可用的编译器选项。可以参考前面的第 18 章,在测试时使用运行时错误和调试选项,而在测试结束后、应用程序打包之前去掉这些选项。 图 A.4 包编辑器用于编译并安装包 A.2 创建定制向导 像 New Component Wizard 这样有用的向导,可以减少编写代码的数量,使得不必手工编写一些可以 自动完成的代码;而且有助于开发者在越过障碍之后发现 Delphi 的一些新的功能。为保持向导的这种功能, 本节给出了一个 New Expert 专家;正如同 New Component 对话框跳过了开始组件单元的步骤一样,New Expert 的作用也是类似的。 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 A.2.1 505 定义 New Expert Wizard New Expert 的功能是,它可以生成一些与本章开头的 Dummy Wizard 类似的专家。而 New Expert 本身 将安装在 Component 菜单上 New Component 菜单项之后。要建立该向导并将其安装到 Component 菜单上, 我 们 需 要 实 现 IOTACreator 和 IOTAModuleCreate , 而 且 还 需 要 查 询 BorlandIDEServices 以 获 得 INTAServices40。INTAServices40 对象定义了向特定的 Delphi 菜单添加菜单项的行为。 向导的类是 TNewExpertWizard。当单击 Component | New Expert 菜单项(见图 A.5)时,它生成一个 与 TDummyWizard 几乎相同的类。将特定的行为添加到生成的 Execute 方法,然后就可以了。完整的代码 列表如下,对相关部分的描述分为小节,以便使您能够清楚地了解其作用。 图 A.5 将 New Expert 向导添加到 INTAServices40 对象之后 unit UNewExpertWizard; // UNewExpertWizard.pas - An example of a wizard that generates the code for a wizard // Copyright (c) 2000. All Rights Reserved. // By Software Conceptions, Inc. http://www.softconcepts.com // Written by Paul Kimmel. Okemos, MI USA interface uses Windows, Controls, ToolsAPI, Forms, Menus, Classes, SysUtils; type TNewExpertWizard = class(TNotifierObject, IOTAWizard, IOTACreator, IOTAModuleCreator) private FNewClassName : string; FMenuText : string; FExpertIDString : string; FExpertName : string; FUnitName : String; FWizardState : TWizardState; FMenuItem : TMenuItem; procedure AddMenuItem; procedure OnClick( Sender : TObject ); procedure GenerateCode; public constructor Create; virtual; destructor Destroy; override; { IOTAWizard } function GetIDString : String; function GetName : String; function GetState : TWizardState; procedure Execute; { IOTACreator } function GetCreatorType : string; 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 function GetExisting : Boolean; function GetFileSystem : string; function GetOwner : IOTAModule; function GetUnnamed : Boolean; { IOTAModuleCreator } function GetAncestorName : string; function GetImplFileName : string; function GetIntfFileName : string; function GetFormName : string; function GetMainForm : Boolean; function GetShowForm : Boolean; function GetShowSource : Boolean; function NewFormFile( const FormIdent, AncestorIdent : string ) : IOTAFile; function NewImplSource( const ModuleIdent, FormIdent, AncestorIdent : string ) : IOTAFile; function NewIntfSource( const ModuleIDent, FormIdent, AncestorIdent : string ) : IOTAFile; procedure FormCreated( const FormEditor : IOTAFormEditor ); end; procedure Register; implementation uses UFormMain, Dialogs, UExpertUnit; {$R *.RES} procedure Register; begin RegisterPackageWizard(TNewExpertWizard.Create); end; { TNewExpertWizard } constructor TNewExpertWizard.Create; begin inherited; AddMenuItem; end; destructor TNewExpertWizard.Destroy; begin if( Assigned(FMenuItem)) then FMenuItem.Free; inherited; end; procedure TNewExpertWizard.OnClick( Sender : TObject ); begin Execute; end; procedure TNewExpertWizard.AddMenuItem; var NTAServices40 : INTAServices40; ComponentMenuItem : TMenuItem; begin 506 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 NTAServices40 := BorlandIDEServices As INTAServices40; if( Not Assigned(NTAServices40)) then exit; ComponentMenuItem := NTAServices40.MainMenu.Items.Find('&Component'); if( Not Assigned( ComponentMenuItem)) then Exit; FMenuItem := TMenuItem.Create( ComponentMenuItem ); try FMenuItem.Caption := 'New &Expert...'; FMenuItem.OnClick := OnClick; ComponentMenuItem.Insert( 1, FMenuItem ); except FreeAndNil(FMenuItem); end; end; procedure TNewExpertWizard.Execute; var Form : TFormMain; begin Form := TFormMain.Create(Application); try if( Form.ShowModal = mrOK ) then begin FNewClassName := Form.NewClassName; FMenuText := Form.MenuText; FExpertIDString := Form.ExpertIDString; FExpertName := Form.ExpertName; FUnitName := Form.UnitName; FWizardState := Form.WizardState; GenerateCode; end; finally Form.Free; end; end; function TNewExpertWizard.GetIDString: String; begin result := 'SoftConcepts.NewExpertWizard'; end; function TNewExpertWizard.GetName: String; begin result := 'Expert'; end; function TNewExpertWizard.GetState: TWizardState; begin result := [wsEnabled]; end; function TNewExpertWizard.NewImplSource(const ModuleIdent, FormIdent, 507 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 AncestorIdent: string): IOTAFile; begin result := TExpertUnit.Create( FNewClassName, FMenuText, FExpertIDString, FExpertName, FUnitName, FWizardState ); end; procedure TNewExpertWizard.GenerateCode; begin (BorlandIDEServices as IOTAModuleServices).CreateModule(Self); end; procedure TNewExpertWizard.FormCreated(const FormEditor: IOTAFormEditor); begin // Intentionally left blank end; function TNewExpertWizard.GetAncestorName: string; begin result := ''; end; function TNewExpertWizard.GetFormName: string; begin result := ''; end; function TNewExpertWizard.GetImplFileName: string; begin result := FUnitName; end; function TNewExpertWizard.GetIntfFileName: string; begin result := ''; end; function TNewExpertWizard.GetMainForm: Boolean; begin result := False; end; function TNewExpertWizard.GetShowForm: Boolean; begin result := False; end; function TNewExpertWizard.GetShowSource: Boolean; begin result := True; end; 508 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 509 function TNewExpertWizard.NewFormFile(const FormIdent, AncestorIdent: string): IOTAFile; begin result := Nil; end; function TNewExpertWizard.NewIntfSource(const ModuleIDent, FormIdent, AncestorIdent: string): IOTAFile; begin result := Nil; end; function TNewExpertWizard.GetCreatorType: string; begin result := sUnit; end; function TNewExpertWizard.GetExisting: Boolean; begin result := False; end; function TNewExpertWizard.GetFileSystem: string; begin result := ''; end; function TNewExpertWizard.GetOwner: IOTAModule; begin result := nil; end; function TNewExpertWizard.GetUnnamed: Boolean; begin result := False; end; end. New Expert 的类定义 TNewExpertWizard 类 继 承 了 TNotifierObject 存 根 类 , 以 及 IOTAWizard 、 IOTACreator 和 IOTAModuleCreator 接口。IOTAWizard 是基本的向导接口,而 TNotifierObject 则对该接口中某些基本的事 件处理程序实现了一个存根。要创建一个基本的向导,您得实现 IOTAWizard 接口。IOTACreator 和 IOTAModuleCreator 用于与 Delphi 的文件视图协同工作,它们也包含了创建窗体和单元的能力。过一会儿 我们继续讨论各个接口的实现。 向导类的私有部分包含了几个字段,用于正确地创建单元。FNewClassName 存储将要生成的向导的类 名。而 FMenuText 则存储要生成的向导的菜单文本。FExpertIDString 包含了专家的 ID 字符串。FExpertName 字段包含了专家名。FUnitName 是生成的.PAS 单元的名字,而 FWizardState 包含了 wsEnabled 和 wsChecked 值。上述的每个特性都用于生成对接口的 IOTAWizard 部分的基本响应。例如,IOTAMenuWizard 需要用 所显示的菜单文本来响应。代码生成器将从 IOTAMenuWizard.GetMenuText 得到 FMenuText 的值。私有部 分还有 FMenuItem, 该 字段用 于维 护对向 导所 添加菜 单的 引用。 菜单 项是通 过构 造函数 中调 用的 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 510 AddMenuItem 过程添加到 Delphi 的。OnClick 事件处理程序包含了当用户单击 New Expert 菜单项时(见图 A.6)的响应代码。 当用户填写好如图 A.6 所示的 New Expert 对话框之后,将调用私有方法 GenerateCode。对话框中询问 了一些用于完成专家的必要的问题。专家类的名字是什么?用于触发 Execute 方法的菜单项的文本是什 么?创建的 ID 字符串是什么?专家的名字是什么?专家所对应的单元名是什么?Wizard State 中的复选框 用于生成 GetState 的实现代码。 类的公有部分包括一些方法的声明,这些方法是必须实现的,以履行接口继承所形成的契约。另外, 除了从接口继承的方法之外,还包括构造函数和析构函数。构造函数将 New Expert 菜单项添加到 Delphi, 而析构函数负责释放相应的内存。 图 A.6 用于生成 Delphi 专家的 New Expert 对话框 实现 IOTAWizard IOTAWizard 接口需要实现 GetIDString、GetName、GetState 以及 Execute 方法。 GetIDString 方法返回‘SoftConcepts.NewExpertWizard’。按照惯例,ID 字符串包括公司名和向导名,用圆 点连接。GetName 返回‘Expert’,这是该向导所显示的名字。GetState 返回包含 wsEnabled 的 TWizardState 集合,确保 Component 菜单上的向导是可用的。 IOTAWizard 接口中惟一较为困难的方法是 Execute。当单击 New Expert 菜单项时(见图 A.5),将调用 Execute 方法。Execute 方法显示一个对话框,如图 A.6 所示。填写 New Expert 对话框的所有域,然后单击 OK。由 New Expert 对话框得到的数据存储在相关的私有字段中,然后调用 GenerateCode 方法。GenerateCode 调用 IOTAModuleServices.CreateModule 方法。由于 TNewExpertWizard 继承了 IOTACreator,因此它可用 作 CreateModule 的参数。接着,CreateModule 调用 IOTAModuleCreator 的方法,包括用于生成代码的 NewImplSource 方法(参考“向 Delphi 的菜单添加菜单项”一节,那里提供了关于如何从 COM 对象 BorlandIDEServices 查询 ToolsAPI 服务的简要讨论)。 实现 IOTACreator IOTACreator 定义了用于与 Delphi 的文件系统视图协同工作的接口。IOTACreator 用于为生成代码提供方便。对于 IOTACreator,我们实现了 GetCreatorType、GetExisting、GetFileSystem、 GetOwner 以及 GetUnnamed 方法。 从本节开头列出的代码可用看出,这些方法相对较为直观。GetCreatorType 返回 ToolsAPI.pas 中定义 的 sUnit。常量 sUnit 包含了值‘Unit’ ,表示将创建单元。GetExisting 返回 False,因为我们正在创建新的 单元;如果引用已有的单元,那么该方法将返回 True。GetFileSystem 返回 TFileSystem 对象的 ID 字符串, 向导将使用该对象来读写文件。它并不是必须的,因此该方法返回了空字符串。 GetOwner 返 回 对 拥 有 模 块 的 引 用 。 例 如 要 将 该 模 块 添 加 到 一 个 已 有 的 工 程 , 我 们 需 要 查 询 BorlandIDEServices 以获得已有的工程或包。我们将使用包编辑器来将新的专家添加到某个特定的包。 GetOwner 方法可以返回 Nil。最后是 GetUnnamed 方法,如果我们返回未命名的单元,该方法将返回 True。 如果 GetUnnamed 返回 True,Delphi 将在第一次保存单元时提示用户输入文件名。如图 A.6 的 New Expert 对话框所示,我们已经提供了单元名。 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 511 要自动生成模块并添加到打开的包, 您可以修改 GetOwner 方法,查找活动工程组并将其作为 GetOwner 的结果返回。下面的代码就足够了。 function TNewExpertWizard.GetOwner: IOTAModule; var ModuleServices : IOTAModuleServices; ProjectGroup : IOTAProjectGroup; I : Integer; begin result := Nil; ModuleServices := BorlandIDEServices As IOTAModuleServices; for I := 0 to ModuleServices.ModuleCount - 1 do begin with ModuleServices.Modules[I] do if( Pos( '.bpg', FileName ) > 0 ) then if( QueryInterface( IOTAProjectGroup, ProjectGroup ) = S_OK ) then begin result := ProjectGroup.GetActiveProject; exit; end; end; end; ModuleServices 对象是由 BorlandIDEServices 对象返回的。将查找所有的模块,以找到包含‘.bpg’包 扩展名的模块。当找到一个包以后,QueryInterface 会测试该模块是否实现了 IOTAProjectGroup 接口。如 果已经实现了该接口,那么将把 ActiveProject 作为函数结果返回。 实现 IOTAModuleCreator 在这个练习中,IOTAModuleCreator 包含的方法数目是最多的。为与 IOTA 前缀的来源相一致,这些方法也都相对直观而易于实现。表 A.2 包含了用于实现 IOTAMoudleCreator 接口 的方法。虽然该表较小,但也够用了,因为描述都相对较短。 表 A.2 为 IOTAModuleCreator 接口所实现的方法。对 New Expert 向导 最重要的是 NewImplSource 方法,该方法返回生成的源代码 接口方法 描述 GetAncestorName 返回模块所继承的祖先名;我们使用了空字符串 GetFormName 该函数返回窗体名;由于并不生成窗体,所以我们也返回空字符串 GetImplFileName 返回由 New Expert 对话框读取的、存储在 FUnitName 中的单元名 (续表) 接口方法 描述 GetIntfFileName CPP 头文件名,这里返回空字符串,要记得 Delphi 与 C++ Builder 共享 VCL 代 码 GetMainForm 由于本模块并非主窗体,所以 GetMainForm 返回 False GetShowForm 由于该向导并不与窗体相关联,所以仍然返回 False GetShowSource 由于我们需要完成生成的专家向导中的 Execute 行为,因此返回 True 以显示新的 单元 NewFormFile 返回 IOTAFile 对象,表示窗体文件的实例;如果生成的专家不需要窗体,则返 回 Nil NewImplSource 这是个关键性的方法:该方法返回 IOTAFile 的子类,包含了生成的专家的源代 码。从代码可以看到,该方法返回了 TExpertUnit 类的实例(在“建立代码生成 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 512 器”一节中,我们将继续讨论 TExpertUnit 单元) NewIntFSource 该方法返回 C++头文件的源代码;它与我们的目的是不相关的 FormCreated FormCreated 是有窗体创建时所调用的事件方法;由于 New Expert 向导没有窗体, 因此该事件处理程序是空白的 现在我们已经实现了向导,并涵盖了所有用于定义向导的接口,接下来我们需要讨论使用 BorlandIDEServices 对象添加菜单项。 最后我们将实现生成代码的 IOTAFile 部分以及 IOTARepository 接口, 并结束本节。存储库接口将把我们创建的向导放置到 New Items 对话框中,这样可以使得它与 New Component 功能相一致。 A.2.2 向 Delphi 的菜单添加菜单项 BorlandIDEServices 全局变量定义在 ToolsAPI.pas 单元中。当对 COM 对象使用 as 操作符时,它与向 一个对象查询是否支持某接口是等效的。例如,如果 BorlandIDEServices 实现了 IOTAModuleServices 接口, BorlandIDEServices as IOTAModuleServices 将 返 回 IOTAModuleServices 的 实 例 。 可 以 使 用 BorlandIDEServices 对象来访问所有与 ToolsAPI 相关的 COM 对象,名字形如 somenameservices。 下面的代码片断摘自定义 New Expert 向导一节。 procedure TNewExpertWizard.AddMenuItem; var NTAServices40 : INTAServices40; ComponentMenuItem : TMenuItem; begin NTAServices40 := BorlandIDEServices As INTAServices40; if( Not Assigned(NTAServices40)) then exit; ComponentMenuItem := NTAServices40.MainMenu.Items.Find('&Component'); if( Not Assigned( ComponentMenuItem)) then Exit; FMenuItem := TMenuItem.Create( ComponentMenuItem ); try FMenuItem.Caption := 'New &Expert...'; FMenuItem.OnClick := OnClick; ComponentMenuItem.Insert( 1, FMenuItem ); except FreeAndNil(FMenuItem); end; end; 我们首先需要通过 BorlandIDEServices 获取对 INTAServices40 接口的访问权限。INTAServices40 可以 访问 Delphi 的主菜单。TMainMenu.Items.Find 方法可用于获取 Items 集合中 Component 菜单的引用。加速 键字符&会被忽略,使用与否都是可以的。两种情况下 find 都能正确工作。如果找到了 Component 菜单, 将创建一个新的菜单项并将其赋值给 FMenuItem 字段变量。再初始化菜单项的 Caption 和 OnClick 特性, 然后插入菜单项。当以索引位置 1 插入菜单项时,该菜单项将出现在 New Component 菜单项之后。最后, 如果发生异常,将释放 FMenuItem 对象。 假定其他的开发者经常添加专家(现在他们已经创建了 New Expert 专家,这是可能的)。为确保您的 专家总是放在正确的位置上,您可以使用一点搜索逻辑来查找相对于另一菜单的正确位置。下面的修改确 保了 New Expert 总是出现在 New Component 菜单项之后(原来的代码是 ComponentMenuItem.Insert( 1, FMenuItem);)。 var I : Integer; // added an integer variable NewComponentMenuItem : TMenuItem; // original variables 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 513 begin // … original code NewComponentMenuItem := ComponentMenuItem.Find('New Component…'); I := ComponentMenuItem.IndexOf( NewComponentMenuItem ); ComponentMenuItem.Insert( I + 1, FMenuItem ); 修改后的版本替换掉了原来对 Insert 的单独一行的调用。 ComponentMenuItem.Insert( ComponentMenuItem.IndexOf( ComponentMenuItem.Find('Install Component...')) + 1, FMenuItem ); 当然,如果使用原来的版本,可以添加一个注释,而且不需要额外的局部变量 I 和 NewComponentMenuItem。 A.2.3 建立代码生成器 在很大程度上,每个向导都包含了一些相同的基本代码。新的向导需要一个单元,其中包括单元名、 接口和实现部分、引用 ToolsAPI 单元的 uses 子句、注册过程、以及对 IOTAWizard 和 IOTAMenuWizard 接 口的实现。 有个方便的方法能够定义要生成的代码,即使用源代码的参数化的字符串,并将其放置在资源文件中。 当通过 IOTAModuleCreator.CreateModule 方法创建 IOTAFile 的后代类的实例时,该实例可读出资源字符串 并对参数化的空白部分进行填充。TExpertUnit 类就是这样实现的。我们首先看一下资源文件的定义。 定义代码资源文件 可以将源代码定义为常量,但使用资源文件更为可取。定义源代码的过程可分为四步。第一步,我们 从 Demos\Experts 目录下的例子中找一些源代码,然后创建名为 codegen.txt 的文本文件,其中包括参数化 的源代码。通过使用参数,可以用 Delphi 的 format 函数插入实际的值。第二步:创建资源代码文件,资源 编译器将使用该文件生成 codegen.res,这就是要链接到 New Expert 向导的资源文件。第三步:使用 Brcc32.exe 资源编译器编译资源文件。第四步:使用{$R CODEGEN.RES}编译指令,以确保将 codegen.res 链接到程序中。 A.3 CODEGEN.TXT 下面列出了参数化的源代码。仔细观察,可以注意到该文本与 TDummyWizard 非常相似。实际上,它 就是从 TDummyWizard 向导复制过来的,一些因向导而异的关键值使用参数进行了替换。例如,单元名替 换为%0:s。因此,如果对资源字符串使用 Format 函数,那么使用%0:s 之处将替换为 Format 调用中的第一 个字符串参数,如此等等。 unit %0:s; // %0:s.pas - Raison d^ etre // Copyright (c) 2000. All Rights Reserved. // By Your Company Name Here, Inc. http://www.yourwebsite.com // Written by Your Name Here. City, State USA interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, ToolsAPI; type %1:s = class(TNotifierObject, IOTAWizard, IOTAMenuWizard) public 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 514 function GetIDString : String; function GetName : String; function GetState : TWizardState; procedure Execute; function GetMenuText : String; end; procedure Register; implementation procedure Register; begin RegisterPackageWizard(%1:s.Create); end; { %1:s } procedure %1:s.Execute; begin MessageDlg( 'Add execute behavior here!', mtInformation, [mbOK],0); end; function %1:s.GetIDString: String; begin {By Convention CompanyName.WizardName} result := '%5:s'; end; function %1:s.GetMenuText: String; begin { Add menu text here! } result := '%3:s'; end; function %1:s.GetName: String; begin { Add wizard name here! } result := '%4:s'; end; function %1:s.GetState: TWizardState; begin result := [%2:s]; end; end. CODEGEN.RC .RC(资源代码)文件包含了编译后资源文件的名字、表示该资源项所引用的数据种 类的宏值,而 CODEGEN.TXT 文件将使用资源编译器集成进来。 CODEGEN RCDATA CODEGEN.TXT 要编译资源文件,打开 DOS 命令行并将当前目录改变到包含 CODEGEN.RC 和 CODEGEN.TXT 文件的目录。确认系统中的 path 语句包含了含有 brcc32.exe 的目录(如 果并非如此,brcc32.exe 位于 Delphi 目录下的 Bin 子目录中) 。运行 brcc32 codegen.rc。资源编译器将输出 codegen.res。 使 用 资 源 指 令 在 包 含 IOTAFile 实 现 的 UExpertUnit.pas 模 块 中 可 以 看 到 , 资 源 指 令 引 用 了 CODEGEN.RES 文件。TExpertUnit.GetSource 将读入资源文件并进行格式化。 实现代码生成器单元:TExpertUnit 代码生成器是一个实现了 IOTAFile 接口的 TInterfaceObject 对象。实现 IOTAFile 接口只需两个方法: GetSource 和 GetAge。很大程度上,我们只需从 CODEGEN.RES 读入参数化文本,然后使用从 New Expert 对话框获取的值填写其中的参数。完整的代码如下列出。 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 unit UExpertUnit; // UExpertUnit.pas - Contains the expert unit generator // Copyright (c) 2000. All Rights Reserved. // By Software Conceptions, Inc. http://www.softconcepts.com // Written by Paul Kimmel. Okemos, MI USA interface { UnitName is %0, NewClassName is %1, WizardState is %2, MenuText is %3, and ExpertName (WizardName) is %4 as defined in CODEGEN.TXT. PTK } uses ToolsAPI, Windows; type TExpertUnit = class( TInterfacedObject, IOTAFile ) private FNewClassName : String; FMenuText : String; FExpertIDString : String; FExpertName : String; FUnitName : String; FWizardState : TWizardState; function WizardStateString : string; public constructor Create( const NewClassName, MenuText, ExpertIDString, ExpertName, UnitName : String; WizardState : TWizardState ); function GetSource : string; function GetAge : TDateTime; end; implementation uses SysUtils, Dialogs; {$R CODEGEN.RES} { TExpertUnit } constructor TExpertUnit.Create(const NewClassName, MenuText, ExpertIDString, ExpertName, UnitName: String; WizardState: TWizardState); begin inherited Create; FNewClassName := NewClassName; FMenuText := MenuText; FExpertIDString := ExpertIDString; FExpertName := ExpertName; FUnitName := UnitName; FWizardState := WizardState; end; function TExpertUnit.GetAge: TDateTime; begin result := -1; end; function TExpertUnit.WizardStateString : string; 515 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 516 begin if( wsEnabled in FWizardState ) then result := 'wsEnabled'; if( wsChecked in FWizardState ) then if( Length(result) > 0 ) then result := result + ', wsChecked' else result := 'wsChecked'; end; function TExpertUnit.GetSource : string; var Text : string; Instance : THandle; HRes : HRSRC; UnitName : String; begin Instance := FindResourceHInstance(HInstance); HRes := FindResource( Instance, 'CODEGEN', RT_RCDATA ); Text := PChar(LockResource(LoadResource(Instance, HRes))); SetLength( Text, SizeOfResource(Instance, HRes)); UnitName := ExtractFileName(FUnitName); if( Pos( '.', UnitName ) > 0 ) then UnitName := Copy( UnitName, 1, Pos('.', UnitName ) - 1); Result := Format( Text, [UnitName, FNewClassName, WizardStateString, FMenuText, FExpertName, FExpertIDString]); end; end. 注意:在本书光盘可以找到 New Expert 向导的输入窗体的完整代码。其代码量非常少。要 实现该窗体,只需按照图 A.6 添加标签和编辑控件,当用户单击 OK 按钮时读取相应的值即 可。 注意到 TExpertUnit 的构造函数将所有从 New Expert 窗体获取的值都作为参数。这些值都被复制到类 的本地变量中。CreateModule 将调用 TNewExpertWizard 实现的 NewImplSource 方法。NewImpleSource 返 回创建的 IOTAFile 实例,然后调用 GetSource 方法来生成代码。 GetSource 只是用 FUnitName 填充参数%0,FNewClassName 填充参数%1,FWizardState 转换为字符串 并填充参数%2,FMenuText 填充参数%3,FExpertName 填充参数%4,FExpertIDString 填充参数%5,然后 返回参数化文本。例如,资源文本: unit %0:s; 替换参数%0 后成为: unit filename; 其中 filename 是 FUnitName 字段的文件名部分。所有这些都发生于 GetSource 的最后一行。 GetSource 方 法 的 其 余 部 分 执 行 了 一 些 必 要 的 步 骤 从 CODEGEN.RES 装 载 资 源 字 符 串 。 FindResourceHInstance 使用 HInstance 模块句柄作为参数,并返回模块的资源句柄。FindResource 的参数包 括资源句柄、资源名 CODEGEN 以及资源类型。RT_RCDATA 是定义在 Windows.pas 中的常量,它返回 MakeIntResource(10) 的 值 。 RCDATA 是 对 应 原 始 二 进 制 数 据 的 资 源 值 。 CODEGEN.RC 文 件 包 含 了 CODEGEN RCDATA CODEGEN.TXT,该文件描述了我们要装载的资源。下面一行代码: 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 517 Text := Pchar(LockResource(LoadResource(Instance, HRes))); 以内层的函数调用开始,首先把资源装载到全局内存中,然后锁定资源,并将二进制资源数据转换为 PChar。 对 SetLength 的调用重新设置 Text 变量的大小,以便为资源中读出的数据保留空间。 A.3.1 将向导添加到 New Items 对话框 如果将 UNewExpertWizard、UExpertUnit、UFormMain 单元添加到一个新的包,然后安装该包,则按 照定义,New Expert 向导将显示在 Component 菜单上。回忆一下,我们还可以从 New Items 对话框启动 New Component 向导,只需单击 File | New | Other 菜单项,然后从 New Items 对话框的 New 属性页中选择 Component 即可。为确保完备,我们把 New Expert 也加入到 New Items 对话框。 注意:UFormMain 指的是 New Expert 对话框的窗体和单元文件。 为使得 New Expert 向导在 New Items 对话框中可用,我们必须继承并实现 IOTARepository 和 IOTAFormWizard 接口。 实现存储库接口 要将存储库接口和窗体向导接口包括在内,只需在 TNewExpertWizard 接口声明的父类列表中添加这 两个接口即可,如下所示。 TNewExpertWizard = class(TNotifierObject, IOTAWizard, IOTACreator, IOTAModuleCreator, IOTAFormWizard, IOTARepositoryWizard) 由于添加了新的接口,还需要实现相应的接口。IOTARespository 声明了四个需要实现的方法: GetAuthor、GetComment、GetPage 以及 GetGlyph。在 TNewExpertWizard 类的公有部分添加这四个声明(如 代码所示),并实现这几个方法(实现也在 TNewExpertWizard 的代码中列出)。 type TNewExpertWizard = class(TNotifierObject, IOTAWizard, IOTACreator, IOTAModuleCreator, IOTAFormWizard, IOTARepositoryWizard) // … public // … { IOTARepositoryWizard } function GetAuthor : string; function GetComment : string; function GetPage : string; function GetGlyph : HICON; end; implementation function TNewExpertWizard.GetAuthor: string; begin result := 'Paul Kimmel/Building Delphi 6 Applications'; end; function TNewExpertWizard.GetComment: string; begin result := 'Creates a custom expert using ToolsAPI'; end; function TNewExpertWizard.GetGlyph: HICON; begin result := 0; 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 518 end; function TNewExpertWizard.GetPage: string; begin result := 'SoftConcepts'; end; 注意:存储库函数会返回用于在 New Items 对话框的上下文菜单中对各项进行排序的信息, 或返回视图从显示大图标转变为显示细节时所要显示的信息。 GetAuthor 方法应包含创建向导的人或实体的字符串信息。GetComment 包含简要描述该向导的注释。 对于现在而言,GetGlyph 返回 0,即使用默认图标来表示向导;而 GetPage 则返回该向导所处的属性页。 根据定义 New Expert 向导在 New Items 对话框中的外观如图 A.7 所示。双击 New Items 对话框中的该专家, 即可运行。 图 A.7 New Items 对话框中的 TNewExpertWizard 向导。通过实现 ToolsAPI.pas 中的 IOTARepository 和 IOTAFormWizard 接口,即可作到这一点 向 New Items 对话框添加定制图标 最后需要完成的就是从 IOTARepository.GetGlyph 方法返回图标的实际句柄。首先需要向资源文件添加 一个图标。这可以通过 Image Editor 完成,与第 10 章添加组件图标所用的方法和步骤相似。可以按照下列 步骤向 New Items 对话框添加定制图标。 1. 从 Delphi 的 Tools 菜单运行 Image Editor。 2. 在 Image Editor 中选择 File | New | Resource File 菜单项,创建新的资源文件。 3. 单击 File | New | Icon 菜单项,添加新的图标。选择默认的 32×32 像素、16 色图标。单击 OK 按 钮。 4. 双击该图标,打开新图标的编辑器窗口。 5. 使用 Image Editor 的绘图工具绘制新的图标,或将已有的图标复制并粘贴到新图标的画布上。 6. 关闭图标编辑器,并将图标重命名为 TNEWEXPERTWIZARD。 7. 将资源文件保存为 UNewExpertWizard.res。现在,UNewExpertWizard.pas 和 UNewExpertWizard.res 是同名的。 8. 编辑 UNewExpertWizard.pas 单元,在实现部分的 uses 子句之后添加资源指令{$R *.RES}。 9. 修改 GetGlyph 以返回图标的句柄(修改后的代码在第 10 步之后)。 10. 建立并安装修改后的向导包(结果参见图 A.8) 。 function TNewExpertWizard.GetGlyph: HICON; begin result := LoadIcon(HInstance, 'TNEWEXPERTWIZARD'); end; 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 519 当建立并安装 New Expert 向导之后,New Items 对话框将包含新的图标(显示在图 A.8 中)而不是默 认的图标(显示在图 A.7 中)。 图 A.8 New Expert 向导的新图标是 factory.ico,图标文件位于 Delphi 安装目录的 Borland Shared\Images\Icons 子目录下 A.4 创建组件编辑器 组件编辑器可以对特定组件类添加上下文菜单项。当单击该菜单项时,组件编辑器可以通过调用组件 的一个方法或显示一个对话框来进行响应,向用户提供了另一种在设计时修改组件的途径。所有的组件编 辑器都由 TComponentEditor 子类化而来。上下文菜单项是通过实现 GetVerb、GetVerbCount 和 ExecuteVerb 方法而添加的。注册组件以后,组件的每个实例都会有一个由 GetVerb 所描述的上下文菜单项。 当调用组件编辑器后,您可以提供简单或复杂的编辑器,也可以在设计时调用所引用组件的方法。所 有的组件编辑器实例都通过一个一般性的 Component 特性,维护了对所关联组件的引用。可以将一般性的 组件引用类型转换为特定的组件类,这样就可以调用方法或访问特定的属性。如果修改了组件的特性,然 后要调用 Designer.Modified 方法,以确保组件被更新。 本节我们将创建一个基本的组件及其编辑器,示范如何调用组件的方法,还为第 9 章创建的名为 TLabelExternedFont 的阴影标签组件定义了组件编辑器对话框。 A.4.1 定义上下文菜单 出于演示的目的,我们创建了一个简单的 TComponent 组件,命名为 TComponentWithEditor,并添加 了一个简单的文本特性。相应的组件编辑器为 TMyEditor,其中定义了三个上下文菜单项。第一个菜单项 将显示组件的 About 对话框,第二个将发出蜂鸣声,而第三个将使用 InputQuery 函数显示输入对话框。如 果用户选择第三项,然后在对话框中输入文本并单击 OK,将显示该文本并在设计时更新组件的 Text 特性。 完整的代码如下。 unit UComponentWithEditor; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, DsgnIntf; type TComponentWithEditor = class(TComponent) private FText : String; procedure About; protected public published 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 property Text : string read FText write FText; end; TMyEditor = class(TComponentEditor) public procedure ExecuteVerb(Index: Integer); override; function GetVerb(Index: Integer): string; override; function GetVerbCount: Integer; override; end; procedure Register; implementation procedure Register; begin RegisterComponents('PK Misc', [TComponentWithEditor]); RegisterComponentEditor( TComponentWithEditor, TMyEditor ); end; { TComponentWithEditor } resourcestring sAboutText = 'Example Component with Editor' + #13#10 + 'Delphi 6 Developer''s Guide' + #13#10 + 'pkimmel@softconcepts.com' + #13#10 + 'Copyright (c) 2000. All Rights Reserved.'; sAboutMenuText = 'About %s'; procedure TComponentWithEditor.About; begin MessageDlg( sAboutText, mtInformation, [mbOK], 0 ); end; { TMyEditor } procedure TMyEditor.ExecuteVerb(Index: Integer); var Default : String; begin case Index of 0: TComponentWithEditor(Component).About; 1: Beep; 2: if( InputQuery( 'Dialog Example', 'Enter some text:', Default )) then begin ShowMessage( Format( 'You entered %s', [Default] )); TComponentWithEditor(Component).Text := Default; Designer.Modified; end; end; end; function TMyEditor.GetVerb(Index: Integer): string; begin case index of 0: result := Format( sAboutMenuText, [Component.ClassName] ); 1: result := 'Beep'; 2: result := 'Input Text'; end; end; 520 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 521 function TMyEditor.GetVerbCount: Integer; begin result := 3; end; end. 注意:需要在 uses 子句中包括 DesignIntf.pas,该文件可以在 Delphifny 专业版和企业业 版的 Source\ToolsAPI 目录下找到。 提示:当读到 Verb 时,如 GetVerb,可以想一想菜单。 第一个类 TComponentWithEditor 定义在单元的接口部分,它有一个私有方法 About 和一个字段 FText。 公开部分的特性 Text 将显示在 Object Inspector 中。前面提到过,组件编辑器 TMyEditor 重载了 GetVerb、 GetVerbCount 以及 ExecuteVerb 方法。GetVerbCount 返回值是 3。 因此有三个零基点的动词需要显示。 GetVerb 使用 case 语句,返回要在上下文菜单中显示的文本;您还可以使用数组。参考图 A.9,看一下组件的上下 文菜单。最后,将被单击的动词(即菜单项)的索引值传递给 ExecuteVerb 方法,并使用 case 语句来确定 要运行的代码。 提示:我们不需要调用 GetVerbCount、GetVerb 或 ExecuteVerb 的继承版本,因为这些方法 在父类 TComponentEditor 中都是空函数。 图 A.9 TMyEditor 定义了图中显示的上下文菜单的前三项 惟一并不显然的是,当调用 ExecuteVerb 方法时,可以加入任意级别的复杂行为。组件编辑器到底能 有多复杂,一个很好的例子是 TChart 的组件编辑器,您看一下就知道了。 本例中索引 0 对应的组件编辑器将 Component 引用转换为 TComponentWithEditor 类型,然后调用 About 方法。索引 1 对应的是 Beep 菜单项,它将调用 Delphi 的 Beep 过程。如果扬声器连接良好,您可以听到铃 声。索引 2 所对应的菜单项 Input Text,将显示 InputQuery 对话框。输入文本并单击 OK。将显示该文本, 然后将 Component 引用转换为组件的实际类型并更新组件的 Text 特性,最后调用 Designer.Modified 方法。 如果组件可用,您可看到组件的改变反映出来,如果特性是公开的,还可在 Object Inspector 中看到。 A.4.2 注册组件编辑器 组件编辑器有自身的注册过程。在全局过程 Register 中添加对 RegisterComponentEdit 的调用,即可注 册组件编辑器。由上一小节的代码可知,第一个参数类型为 TComponentClass,第二个参数类型为 TComponentEditorClass。在示例代码中,第一个参数是组件的类 TComponentWithEditor,第二个参数是编 辑器类 TMyEditor。 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 A.4.3 522 阴影标签的组件编辑器 本节所说的阴影标签指的是第 9 章的 TLabelExternedFont 组件。该组件的编辑器对话框(如图 A.10 所示)可用于定制扩展的字体标签,在对话框中把与该目的相关的特性经过组织后显示出来。组件编辑器 窗体部分的完整代码位于本书光盘上,它示范了如何使用基本的 VCL 控件以及一些新的控件,包括 TColorBox 和 TCheckListBox 控件。 图 A.10 第 9 章中的 TLabelExternedFont 组件的编辑器对话框 该组件编辑器的类定义重载了 TComponentEditor 类的三个基本方法来创建上下文菜单。 TLabelExtendedFontEditor = class(TComponentEditor) private procedure EditLabel; public function GetVerbCount : Integer; override; function GetVerb( Index : Integer ) : string; override; procedure ExecuteVerb( Index : Integer ); override; end; 私有方法 EditLabel 用于实现在单击 EditLabel 菜单项时所需的编辑行为。 procedure TLabelExtendedFontEditor.EditLabel; var F : TFormEditor; begin F := TFormEditor.Create(Application); try F.TestLabel := Component As TLabelExtendedFont; if( F.ShowModal = mrOK ) then begin TLabelExtendedFont(Component).Assign( F.TestLabel ); Designer.Modified; end; finally F.Free; 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 523 end; end; procedure TLabelExtendedFontEditor.ExecuteVerb(Index: Integer); begin case Index of 0: TLabelExtendedFont(Component).About; 1: EditLabel; end; end; function TLabelExtendedFontEditor.GetVerb(Index: Integer): string; const VERBS : array[0..1] of string = ( 'About', 'Edit Label'); begin result := VERBS[Index]; end; function TLabelExtendedFontEditor.GetVerbCount: Integer; begin result := 2; end; 前面已经看到过,GetVerbCount 的实现是非常直观的,这里它返回 2,表示扩展字体标签的上下文菜 单上有两个菜单项。两个菜单项分别是 About 和 EditLabel,从 GetVerb 定义的字符串数组中可以返回这两 项。ExecuteVerb 调用 TLabelExternedFont.About 方法或自身的 EditLabel 方法,这依赖于所传递的索引值。 EditLabel 会创建如图 A.10 所示的窗体的一个实例,该窗体将使用组件编辑器所引用的扩展字体标签 组件来初始化编辑器窗体 TFormEditor 的 TestLabel 特性。用户可以修改窗体上给出的任意特性。如果用户 单击 OK,那么将把 TFormEditor.TestLabel 赋值给所引用的组件。 注意:TLabelExtendedFont 组件原来的版本可以在本书光盘第 9 章中找到,修改后的版本可 以在光盘上附录 A 的目录下找到。其中还包括了一个测试程序,可以将 TFormEditor 作为一 个单独的程序运行。 为方便这个练习,还需要向 TLabelExtenedFont 组件添加 About 和 Assign 方法,并将 RegisterComponents 调用添加到 Register 过程。这里列出了修改后的代码,在光盘上也有。 procedure Register; begin RegisterComponents('PK Labels', [TLabelExtendedFont]); RegisterComponentEditor( TLabelExtendedFont, TLabelExtendedFontEditor ); end; { TLabelExtendedFont } procedure TLabelExtendedFont.About; resourcestring sAboutText = 'Extended Font Label Component' + #13#10 + 'Delphi 6 Developer''s Guide' + #13#10 + '(c) 2000. All Rights Reserved.' + #13#10 + 'Written by Paul Kimmel. pkimmel@softconcepts.com'; begin MessageDlg( sAboutText, mtInformation, [mbOK], 0 ); end; procedure TLabelExtendedFont.Assign( Source : TPersistent ); begin 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 524 if( Source Is TLabelExtendedFont ) then begin with Source As TLabelExtendedFont do begin Self.Caption := Caption; Self.Transparent := Transparent; Self.Font.Assign( Font ); Self.FHasShadow := HasShadow; Self.FShadowColor := ShadowColor; Self.FShadowDepth := ShadowDepth; Self.Invalidate; end end else inherited Assign(Source); end; Assign 方法将 Caption、Font 和几个 Shadow 效果特性从参数中的标签复制到调用者标签。如果要完整 的练习一下,可以自行实现组件编辑器的窗体编辑器部分,或从本书光盘上装载来试验一下。 附录 A 使用 OPENTOOLS API 的 Delphi 扩展示例 A.5 小 525 结 本附录并非是事后聪明。这些高级的功能使 Delphi 更为强大,并使用户更加乐于使用 Delphi。在本附 录中,您学到了如何使用 OpenTools API 来扩展 Delphi,以及如何编写组件编辑器。这个附录是真正的关 于编写软件的内容,它是为开发者所写的。您不可能每天都作这个,但在需要的情况下,它可以使您从普 通的开发者中脱颖而出。 不幸的是,OpenTools API 的大部分文档都以代码或注释的形式存在。也许当本书发行时,这些问题 已经得到补救。而组件编辑器在集成帮助中的文档就更为完整且易于使用。在空间许可的情况下,我试图 提供尽可能多的知识。如果您需要 OpenTools API 或高级定制组件方面的书籍,请告知出版商。 附录 B 创建 NT 服务程序 服务程序通常运行在后台,它可以使计算机更加有用。有用是相对于特定的个人或组织而言的。服务 程序最好的例子是 IIS 服务器。当在一台高性能的服务器或 PC 上安装了 IIS 后,IIS 就作为后台服务运行 并向浏览您的 Web 站点的人们提供 Web 页面。同一领域的其他服务还包括 FTP、SMTP 以及 Telnet 服务 器。事件日志和 Windows Installer 也都作为服务运行。 实际的程序中服务的候选者可能具有如下特征:运行时没有活动的用户输入,无论是否有人登录都需 要运行。IIS 是一个很好的例子。在实际开发环境中,我曾经将不断的传输并验证事务的程序作为服务来 开发。 注意:Visual Basic .NET 支持建立 NT 服务程序。 Delphi 直接地支持建立 Windows NT(包括 Windows 2000 或 Windows NT 5.0)服务程序。建立 NT 服 务的功能并非对所有工具都是固有的。例如,Microsoft Visual Basic 6.0 及更早的版本无法直接建立服务程 序。附录 B 通过示范一个自动发送 IIS 日志文件的程序,讨论了建立 Windows NT 服务程序的基本概念。 B.1 创建服务程序 建立服务程序最容易的方法是从 New Items 对话框中启动 Service Application。可以注意到还有一个 Service 项。Service 可以向已有的程序添加一个 TService 模块,但定义新的服务时,需要选择 Service Application。 当单击 Service Application 后,Delphi 将创建一个新的工程。在新工程的.DPR 源文件的 uses 子句中, 首先引用了 SvcMgr。另外,工程中还添加了一个包含 TService 类的单元。TService 类继承了 TDataModule, 可以在其中添加非可视化控件和服务程序代码。不要在工程源文件中包括 Forms 和 HttpApp 单元。SvcMgr、 Forms 和 HttpApp 都定义了一个全局的 Application 对象,这会导致服务程序中出现冲突。 注意:在 Forms 和 SvcMgr 单元中确实存在全局对象 Application,而在 HttpApp.pas 的 beta 版实现中并未发现 Application 变量。关于联合使用这三个单元的警告摘自 Delphi 的帮助 文件。当然,试验总是可以的,但应该事先预见到 Application 对象可能造成的冲突。 服务程序是很直观的。本书的光盘上包含了 ServiceApp.dpr 文件。该程序会等待一个预定义的时间量。 然后将 IIS 的日志文件发送到指定的邮件接收者。该示例程序对大部分变量进行了硬编码,但从整本书中 都可以看到,在外部对应用程序数据进行配置是一个相当直接的过程。例如,邮件接收者、发送的信息、 以及定时器间隔都可以存储在注册表中,无需重新编译程序即可进行修改(第 15 章涵盖了将应用程序数 据持久存储到注册表的内容,第 16 章则涉及了 INI 文件的使用,因此在这里我们不再重复该信息)。 服务程序的基本框架是由 Delphi 生成的,您只需编写定义服务的代码。 B.1.1 定义邮件发送器服务 当在服务控制管理器(即 Service Control Manager 或 SCM,细节请参考“服务控制管理器”一节)中 启动服务时,Delphi 调用 TService 模块中的 OnExecute 事件方法,您可以自行定义该方法。OnExecute 事 件方法中所需的基本代码是一个 while 循环,这样服务程序就可以处理请求。 while Not Terminated do ServiceThread.ProcessRequests(False); 该代码与 Windows 中处理信息队列的循环非常相似。ServiceThread 对象是服务程序中每个服务的专用 附录 B 创建 NT 服务程序 533 线程。只要服务尚未被服务控制管理器停止,Not Terminated 的结果都是 True。 由于日志文件邮件发送服务会在固定的时间间隔向接收者发送 IIS 日志文件,因此我们需要向 OnExecute 事件方法添加的代码就是:在循环开始前使定时器生效,在循环结束后使定时器失效。在示例 程序中,OnExecute 事件处理程序的代码如下。 procedure TMyService.ServiceExecute(Sender: TService); begin Timer1.Enabled := True; while Not Terminated do ServiceThread.ProcessRequests(False); Timer1.Enabled := False; end; 当服务启动后,定时器将生效。代码一直在 while 循环中运行,直至服务停止,最后定时器也将失效。 当时间间隔到达后,当天的日志将发送到预先指定的接收者。执行这些任务的代码如下。 function TMyService.GetLogFileName: string; const sLogFileName = '"c:\winnt\system32\LogFiles\W3SVC1\ex"yymmdd".log"'; begin {$IFOPT D+} result := FormatDateTime( sLogFileName, EncodeDate( 2000, 12, 27)); {$ELSE} result := FormatDateTime( sLogFileName, Date ); {$ENDIF} end; procedure TMyService.Timer1Timer(Sender: TObject); var FileName : string; begin FileName := GetLogFileName; if( Not FileExists(FileName)) then exit; IdSMTP1.Connect; try IdMessage1.Body.LoadFromFile( GetLogFileName ); IdSMTP1.Send( IdMessage1 ); finally IdSMTP1.Disconnect; end; end; 提示:可以将纯文本嵌入到 FormatDataTime 函数中,把非日期掩码的文本使用双引号包裹 起来即可。这是个有用的技巧,可用于创建带有动态日期的文件名。 第一个函数 GetLogFileName 中,如果处于调试状态,那么$IFOPT D+编译器指令生效,将使用常量文 件名,否则$ELSE 指令生效,将使用动态文件名。OnTimer 事件确定当天的日志文件名。如果文件已存在 (IIS 管理器中的日志选项已生效,又有人访问了您的站点) ,文件的内容将装载到 TIdMessage 的 TStrings 类型特性 Body 中(关于 TIdMessage 组件和 Body 特性的更多信息,请参见第 16 章) 。日志文件的内容通 附录 B 创建 NT 服务程序 534 过已连接的 TIdSTMP 组件发送。可以注意到,邮件的接收者并不是动态编码的。如果要使接收者也成为 动态的,需要从某些持久性的数据源读取必要的特性值。关于如何动态地读取发送者、接收者以及邮件服 务器的信息,可以参考第 16 章的 SimplePop3 例子。 B.2 安装服务程序 Delphi 服务程序可以在命令行安装,运行程序时添加/INSTALL 开关即可。使用/UNINSTALL 可以卸 载服务,而使用 /SILENT 开关则可以避免显示表示安装或卸载的成败情况的对话框。当安装或卸载服务 时,将显示一个对话框,要求用户输入。如果服务的设置是在另一个更大的安装过程中进行,您可能不希 望挂起当前的安装进程来等待用户输入;而使用 /SILENT 开关就可以避免出现该对话框。下面的例子示 范了如何从命令行安装及卸载服务程序。 Serviceapp /INSTALL Serviceapp /UNINSTALL Serviceapp /INSTALL /SILENT 前两个例子显示一个对话框,表示成功或失败,而第三个例子则不显示。服务程序实例是 serviceap.exe。 当安装服务时,服务并不启动。您需要打开服务控制管理器或重启计算机来启动服务。反过来,卸载服务 时,该服务并不立即从服务列表中删除(在 Windows 2000 中是这样),直到下次打开服务控制管理器时才 会删除。 在服务控制管理器中,服务并非按照服务程序的名字排序的,而是按照服务对象名排序的。从上一节 的列出代码可知,本附录创建的服务类是 TMyService;如果查看一下服务模块的 Name 特性,可以看到服 务的名字是 MyService。MyService 将显示在服务控制管理器中,如图 B.1 所示。 图 B.1 服务控制管理器,当前焦点是 MyService,即本附录创建的服务 B.3 使用服务控制管理器 图 B.1 所示的服务控制管理器与 VCR 控件的基本功能很相似。用鼠标选定服务,然后单击 Start Service 按钮即可启动服务,该按钮与 VCR 中的 Play 按钮类似。单击 Stop 按钮可停止服务,后两个按钮分别是 Pause 和 Restart。 右键单击服务,可以显示上下文菜单,然后打开服务属性对话框。服务的默认行为是在重启时自动启 动,但服务的启动类型、登录信息、故障恢复等设置都是可以改变的,还可以设置服务是否与桌面进行交 附录 B 创建 NT 服务程序 535 互。“允许服务与桌面交互”意味着服务可以有用户界面。例如,如果服务有些选项是可由用户配置的, 那么使服务与桌面进行交互,相应的程序将显示窗体并在任务栏上显示图标。另外,还可以把服务作为单 独的程序运行,以便修改用户可配置的选项。 B.4 服务事件日志 服务可使用 LogMessage 方法直接向 Windows 事件日志服务写入信息。LogMessage 方法定义在 TService 中。它有几个可选的参数,但只需传递一个文本字符串参数来表示要向 Windows 事件日志写入的信息。 LogMessage( 'Starting', EVENTLOG_INFORMATION_TYPE ); 上述 LogMessage 语句将把一个 Application 事件日志项写入 Windows 事件日志,事件查看器如图 B.2 所示。前两个参数(见上面的代码)分别是将要写入日志的文本以及表示事件类型的常数。还可以向 LogMessage 传递两个参数:第三个参数为 Category 值,可以是任何对用户有意义的值,而第四个参数是 信息 ID 号,表示与事件文件和特定的事件相关联的文本的 ID。 图 B.2 Windows 事件查看器,其中显示了上述代码中 调用的 TService.LogMessage 产生的事件日志项 B.5 服务的调试 有两种途径可用于调试服务程序。第一种是在单独的类中定义服务的工作部分,并在一个单独的程序 中对其进行调试。第二种是在服务运行时进行调试。第一种途径是个好主意;把负责服务工作的类添加到 通常的程序是测试服务行为的最容易的方法,而且保持了与工作台测试的思想的一致性。 我们使用第一种方法,在创建 ServiceApp 和 TestMailer(本书光盘上也有)这两个程序时进行测试。 把同样的组件 TIdMessage、TTimer 和 TIdSTMP 添加到一个单独的程序,然后创建一个邮件发送器。当找 到 TestMailer 程序的缺陷后,所有的修改都更新到服务程序。创建工作台或测试程序是很容易的,但并不 总是够用。 第二种测试服务程序的途径是:安装并运行服务程序,然后将其附加到 Delphi 中的运行进程。按照下 列步骤,即可在运行服务时调试 ServiceApp.exe 程序。 1. 在命令行运行 Serviceapp.exe /INSTALL,安装服务程序。 2. 选择 Start | Settings | Control Panel | Administrative Tools | Services,将运行 Services 小应用程序。找 到 MyService,然后单击 Start Service 工具栏按钮(这些步骤适用于 Windows 2000;在 Windows NT 4.0 中的步骤几乎相同)。 3. 运行 Delphi。 4. 装载 ServiceApp.dpr 工程。 5. 在 Delphi 中选择 Run | Attach to Process 菜单项。 6. 在 Attach to Process 对话框(如图 B.3 所示)中,选中 Show System Processes 复选框。 7. 找到 ServiceApp.exe,然后单击 Attach 按钮。 附录 B 创建 NT 服务程序 536 8. 在 Delphi 中,对工程源文件的 OnExecute 事件方法设置断点,然后按键 F9。 图 B.3 Attach to Process 对话框可用于将 Delphi 调试器附加到 已运行的进程上;这对于调试服务程序是很有用的 Delphi 将把调试器附加到运行的服务程序上,并打开 CPU 对话框。除非您阅读汇编语言非常流畅, 否则 CPU 视图用处不大。但当服务程序运行到断点时,将停止在断点上并切换到 Delphi 代码视图。在用 户通过 Delphi 获取对已运行服务的控制之后,即可像其他程序一样对服务进行步进和调试。 注意:作为开发者,您应该对自己的 PC 具有管理员权限。令人难以置信的是,有些组织信 任开发者编写的代码但却不允许他们管理自己的 PC。现在的经济形势仍然很好,找一份新工 作吧。 Delphi 帮助文档提到,如果权限不足,那么附加到服务进程可能会失败。当只要对 PC 拥有管理员权 限,附加到运行的服务进程总是工作正常。帮助主题“Debugging Services”包含了调试服务的第三种途径, 其中涉及到修改注册表设置,您可以参考。 B.6 小 结 像其他程序一样,服务也依赖于用户的需求。如果您正在建立系统的一部分,而它需要不被注意的运 行,即使没有用户登录也要运行,那么您可能需要建立一个服务程序。正像本附录所示范的,许多技术都 与通常的应用程序相同,而 Borland 已经把一些棘手的部分隐藏到了无所不在的 Application 对象中。这对 于开发者是个好消息。 许多在其他程序中使用的组件都可以用于服务程序,但通常服务程序并没有很多的用户界面。Windows NT 允许服务与桌面进行交互,当大多数情况下这种交互只涉及到修改应用程序的设置,而不是交互用户 输入。 附录 C 将程序转变为自动化服务器 自动化是 COM 的一个方面,您可以利用它从程序中获得额外的收益。本附录示范了一些基本的步骤 来选取一个程序(这里是第 19 章的 SQLBuilder) ,然后向工程添加自动化对象,将程序转换为自动化服务 器。之所以选择 SQLBuilder,是因为自动化客户可利用 SQLBuilder 服务器来针对任何数据集生成 SQL。 请记住,在运行自动化服务器之前,还需要有自动化客户,即任何请求创建 COM 对象实例的程序。 可创建 COM 对象实例的工具非常多,包括报表工具、VBA(大部分在 Microsoft Office 中),以及大部分 的 Wintel 编程语言。 C.1 向工程添加自动化对象 这里不再涉及第 19 章的 SQLBuilder 程序的细节,该工程的源代码和编译后的版本都在本书的光盘上。 该程序使用了 TSession 类型的全局变量来从 ODBC 和 BDE 注册文件获取数据库名字的列表。当用户选定 某个数据库名时,将以同样的方式获取该数据库中可用的表名。用户选取一个表时,将从 TDataSet.Fields 集合读入字段定义并显示在 TValueListEditor 对象中,该对象是网格形状的、基于 TStrings 的控件。在字段 值已知的情况下,可利用 Format 函数对定义了参数的 SQL 语句进行简单的字符串替换。就是这样。 如果您已经读过本书前面的部分,那么您已经掌握了这些技巧,因此我们不再涉及 SQLBuilder 工程的 更多细节。从这里开始,我们将进行一些必要的步骤,向该工程添加自动化对象。 C.1.1 使用自动化对象向导 现在打开原来的 SQLBuilder 工程,开始向它添加自动化对象。打开工程后,在 Delphi 中单击 File | New | Other 菜单项。然后在 New Items 对话框中选择 ActiveX 属性页,并选择其中的 Automation Object 图标(如 图 C.1 所示) 。这里使用的 CoClass 名为 Builder,并采用了缺省的实例化和线程模型(关于实例化和线程 模型的特定细节,请参考第 15 章) 。单击 OK 生成类型库并触发类型库编辑器。 我们将使用类型库编辑器来定义自动化服务器的接口,并更新类型库和包含 TBuilder 类的 Pascal 源文 件,该类是服务器的包裹类。 C.1.2 在类型库编辑器中定义接口 在向导中单击 OK 后,将定义一个包裹类,该类继承了 TAutoObject 类,并实现了 IBuilder 接口。在 光盘上,相应的文件名是 UBuilder.pas。当结束自动化接口定义后,类型库编辑器(见图 C.2)中的 Refresh 按钮将把声明和定义添加到 Pascal 包裹类的单元。 附录 C 将程序转变为自动化服务器 图 C.1 图 C.2 540 自动化对象向导小应用程序 类型库编辑器以及 TBuilder 类的定义 对于该自动化对象,我们将定义一些方法和特性,以便设置数据库名和表名,获取数据库名和表名的 列表,以及字段和 SQL 文本的管理等。按下列步骤,可以在类型库编辑器中定义接口。 1. 在类型库编辑器中,单击 New Property 工具栏按钮(或右键单击 IBuilder 接口,从接口的上下文 菜单中选择该菜单项,参考图 C.2)添加 DatabaseName 特性。 2. 在 Attributes 属性页上(在图 C.2 中也可以看到) ,将 Name 修改为 DatabaseName,将类型修改为 BSTR,对读写定义二者都进行同样的修改。 3. 对 TableName 特性重复步骤 2。这两个特性将用于修改选定的数据库名和表名。 4. 添加两个方法(单击 Methods 工具栏按钮),将其命名为 GetTableNames 和 GetDatabaseNames。 TStrings 对象可返回分隔好的文本,因此我们可以利用 TStrings 的 Text 特性将所有的列表项作为 一个字符串传递。 5. 在 Parameters 属性页上将 Type 设置为 BSTR*,这是指向 BSTR,即宽字符串的指针,并将 Modifier 修改为[out,retval]。BSTR*指针用于返回类型。现在我们只用一个方法调用就可以得到所有的数 据库名和方法名。 6. 重复步骤 5,定义 GetFieldsList 和 GetSQLText 方法。它们在自动化服务器中都存储为 TStrings, 因此声明步骤与 5 相同(看一下 SQLBuilder 工程,可以发现字段在 TValueListEditor 中,而 SQL 文本在 TMemo 对象中。两个控件都有 TStrings 特性)。 7. 将 SetFieldsList 在 Parameters 属性页上定义为[in] BSTR。该方法可用于更新对字段和值的列表的 修改。 附录 C 将程序转变为自动化服务器 541 8. 添加一个可用于读写的 QueryType 特性,它使用整数来修改查询类型,在服务器中用 TRadioGroup 来表示。 9. 最后,定义方法 TestQuery,它没有参数。我们将使用 TestQuery 对服务器进行查询。 10. 单击 Refresh Implementation 工具栏按钮。最后的结果是在包含 TBuilder 类的源代码单元中生成的 声明和定义。 如果一切正常,类声明如下: TBuilder = class(TAutoObject, IBuilder) protected function Get_DatabaseName: WideString; safecall; function Get_TableName: WideString; safecall; function GetDatabaseNames: WideString; safecall; function GetFieldsList: WideString; safecall; function GetSQLText: WideString; safecall; function GetTableNames: WideString; safecall; procedure Set_DatabaseName(const Value: WideString); safecall; procedure Set_TableName(const Value: WideString); safecall; procedure SetFieldsList(const FieldsList: WideString); safecall; procedure TestQuery; safecall; function Get_QueryType: SYSINT; safecall; procedure Set_QueryType(Value: SYSINT); safecall; { Protected declarations } end; 这里没有什么真正值得惊奇的东西。看一下数据类型,可以看到都已经转换成了 Delphi 中的别名。过 程传递常量参数而函数则返回适当的类型(还会生成.tlb 文件,但通常应该避免直接修改该文件)。下一步 是实现接口。因为工作已经在程序中完成,每个接口的实现都很简单。 C.1.3 实现接口 大多数方法都只对服务器程序主窗体的简单文本进行操作,或者是每个特定控件所包含的 TStrings 特 性。完整的实现代码如下,几乎不需要什么解释。 uses ComServ, UFormMain; function TBuilder.Get_DatabaseName: WideString; begin result := Form1.ComboBox1.Text; end; function TBuilder.Get_TableName: WideString; begin result := Form1.ComboBox2.Text; end; function TBuilder.GetDatabaseNames: WideString; begin result := Form1.ComboBox1.Items.Text; end; function TBuilder.GetFieldsList: WideString; 附录 C 将程序转变为自动化服务器 542 begin result := Form1.ValueListEditor1.Strings.Text; end; function TBuilder.GetSQLText: WideString; begin result := Form1.Memo1.Lines.Text; end; function TBuilder.GetTableNames: WideString; begin result := Form1.ComboBox2.Items.Text; end; procedure TBuilder.Set_DatabaseName(const Value: WideString); begin Form1.ComboBox1.Text := Value; Form1.ComboBox1Change(Self); end; procedure TBuilder.Set_TableName(const Value: WideString); begin Form1.ComboBox2.Text := Value; Form1.ComboBox2Change(Self); end; procedure TBuilder.SetFieldsList(const FieldsList: WideString); begin Form1.ValueListEditor1.Strings.Text := FieldsList; end; procedure TBuilder.TestQuery; begin Form1.RunSQL; end; function TBuilder.Get_QueryType: SYSINT; begin result := Form1.RadioGroup1.ItemIndex; end; procedure TBuilder.Set_QueryType(Value: SYSINT); begin Form1.RadioGroup1.ItemIndex := Value; end; 从代码显然可以看出,每个方法的实现都只有一两行代码。例如,稍复杂的是 Set_TableName 方法, 它是 TableName 特性的写方法。该方法直接把表名赋值给 TComboBox.Items.Text 特性,然后手工调用相关 联的 OnChange 事件处理程序;该处理程序确保用新表的字段定义列表来更新 TValueFieldList 控件。 在具有工业水准的程序中,需要进行一些检查。例如,当设置 TableName 特性时,需要验证要设置的 表名是有效的。虽然组合框已经提供了该功能并能够自动生成异常,但最好在客户端捕获异常并让用户重 新选择另一个表。 附录 C 将程序转变为自动化服务器 C.1.4 543 运行服务器来注册类型库 在使用服务器之前,需要注册类型库。最简单的注册方法是将服务器作为单独的程序运行。也可以使 用类型库编辑器中的 Register Type Library 工具栏按钮进行注册。 注意:Microsoft 正致力在.NET 框架中减少注册类型库的需要。这可是件好事情。 附录 C 将程序转变为自动化服务器 544 C.2 建立测试程序 在服务器程序中建立的自动化对象继承自 TOleServer,它是 TComponent 的子类。定义了一个 Register 方法,这样可以方便地注册服务器组件,使得该组件易于使用。要把该自动化服务器安装到 VCL 中,可 以选择 Project | Import Type Library 菜单项并单击 Add 按钮。找到服务器的.tlb 文件,然后单击 Open 按钮。 在 Import Type Library 对话框中,单击 Install 按钮。Install 按钮将提示您选择一个包。对于我们的目的来 说,dclusr60.bpl 包是很方便的。建立该包。现在您就可以建立测试程序了。 任何程序都可以用于测试该服务器,关键在于要创建服务器的实例,然后调用方法并访问特性以确保 一切都工作正确。下面的步骤提供了测试新的组件化服务器的一种可能的途径。 1. 缺省情况下,自动化服务器组件安装到组件面板的 Servers 属性页,除非修改自动生成的 Register 过程。 2. 在 Delphi 中,创建新的应用程序并将 TBuilder 组件拖放到主窗体上。 3. 在 FormCreate 事件方法中连接到服务器。例如,如果使用了缺省的名字 Builder1,那么可以使用 Builder1.Connect 来启动自动化服务器程序。 4. 最后,添加代码来测试服务器。测试代码如下,对 SQLBuilder 自动化服务器的各项功能进行了简 要测试。 Screen.Cursor := crHourglass; try try Form2.Enabled := False; Memo1.Lines.Text := Builder1.GetDatabaseNames; Sleep( 2000 ); Builder1.DatabaseName := 'DBDEMOS'; Sleep( 1000 ); Memo1.Lines.Text := Builder1.GetTableNames; Sleep(1000); Builder1.QueryType := 0; Builder1.TableName := 'customer.db'; Memo1.Lines.Text := Builder1.GetSQLText; Builder1.TestQuery; finally Form2.Enabled := True; end; finally Screen.Cursor := crDefault; end; 将上述代码添加到响应鼠标单击的事件方法中,从服务器获取的数据则显示在 TMemo 控件中。代码 中调用了 Sleep 暂停了一下,这样可以使用户跟上程序的响应。代码非常直观:上面列出的代码改变了 DatabaseName、TableName,并请求所生成的 SQL 文本。最后调用了 TestQuery 方法。如果运行示例程序 (定义在 TestAuto.dpr 中) ,可以先找到 SQLBuilder 程序的 Data 属性页,看一下从 DBDEMOS 数据库返 回的顾客数据。 代码的其余部分用处不大。有用的代码不见得冗长。在掌握用法之后,TStrings 对象是非常简单的; 从示例可以看出,文本就存储在 TStrings 对象中。在设计类和服务器时保持小而扼要的接口,就能达到这 种简单性。 附录 C 将程序转变为自动化服务器 545 C.3 小 结 如果您读到了这里,说明您已经顺利地创建并测试了服务器程序。Delphi 通过隐藏类型库代码和 IDL, 使得创建和使用 COM 对象,如自动化服务器,变得相对容易。创建自动化服务器的实例也很容易,因为 Delphi 可以使自动化服务器组件化。如果把服务器变成组件,那么无论自己或他人使用,都会非常方便。 要记住,自动化是一种手段,您可以通过它在开发工具之间共享应用程序。在不久的将来,自动化将 成为最大化的实现工具的重用的一条好途径。 附录 D 用 Delphi 实现无线程序 我从来都弄不清楚出血边是由什么构成的。也许我已经习惯了花费大量的时间同时运行 beta 版的办公 工具、beta 版的数据库服务器、beta 版的操作系统、编译器、以及硬件设备等。无线应用看上去已经满足 了出血边的条件。只要看一下 RFC 文档、白皮书、书籍和应用程序的版权日期,您就可以知道无线应用还 非常新。该技术与一些小型的设备和界面有关,这倒是与 20 年前很相似。 无线设备如蜂窝电话或 PDA(个人数字助手)通常提供的屏幕较小、带宽较低、网络连接状况较差(如 果您的蜂窝电话是是在密歇根买的,那么试着在西南部犹他州到维加斯之间的地方打个电话,您将会看到 那个区域的信号非常微弱)。由于这些原因,该技术有点冷门。摩托罗拉的蜂窝电话看起来像是星际旅行 的通讯器(如果现在我们有全息摄影技术的话)。更有趣的是,您可以从桌面上访问建立无线应用程序的 工具。确实是这样:Delphi 就能实现 WAP。WAP 是指无线应用协议(Wireless Application Protocol)。因此, 我说 Delphi 实现 WAP 时,指的是 Delphi 可以创建提供 WML(无线标记语言,Wireless Markup Language) 页面的服务器程序。WML 是由 XML(Extensible Markup Language,可扩展标记语言)衍生而来。 当创建无线应用时,有许多技术是必须的。标记语言,如 HTML、XML、WML,肯定是需要的。这 三种标记语言的标记之间有许多相似性。Web 服务器和无线服务器也是需要的。MIME(Multipurpose Internet Mail Extension,多用途 Internet 邮件控制)也是必须的。确定内容、定义接口、创建数据存储、编 写并测试应用服务器,这些对于创建 WAP 程序都是必须的。这些技术中的每一种可能都需要一本书来讲 述。因此,我们在本附录中相当于作一次垂直切片,对每种技术都稍稍涉猎一下(这与在水平层面上对某 一种特定技术继续讨论相对。您可以劝说出版商来出版这样一本书)。从正面来看,读完本附录后,可以 回忆起前面章节涉及到的各个独立的主题。 D.1 准 备 工 作 如果要试一下本附录中的练习,您需要把本节列出的工具组合一下。如果只是简单浏览一下,那么可 以跳过本节。 要建立本附录的 WAP 示例程序,您需要把下列工具组合起来(惟一需要出钱买的是 Delphi): · Delphi 企业版或 Delphi 专业版;Web Broker 必须单独购买,这些可以在 www.inprise.com 进行。 · 下 载 并 安 装 JRE 1.2.2 ( Java 运 行 时 环 境 ), 其 中 包 含 Java 虚 拟 机 。 JRE 1.2.2 可 以 在 http://java.sun.com/products/jdk/1.2/ 免费下载。 · 从 Nokia 网址 http://www.forum.nokia.com 下载 WAP 工具包。工具包的试用版是免费的。您可能 需要提供个人信息进行注册(而 Nokia 的网站有点令人迷惑) ,但 Nokia 的电话还不错。工具包中 的仿真器使得调试更为容易(本书的光盘上也有该工具包) 。 · 选择下载 Nokia 的服务器 Active Server 2.0。本附录中我们使用了 IIS 来提供 Web 页面,但 WAP 服务器既可以单独使用,也可以与 Web 服务器联合使用。 · 您需要安装 Personal Web Server 或 IIS(在 Windows 2000 系统中) ,或者需要访问您的网络中的 IIS 服务器,以便对 WAP 应用进行测试。如果您使用的是 Windows NT Workstation 版本,您可以安装 Personal Web Server。如果您使用 Windows 2000 Professional 版本,您可以在桌面上安装 IIS。或者 您可以在 Windows NT Server 版本的计算机上安装 IIS。为避免所有可能情形的组合,本书的例子 使用了 Windows 2000 Professional。 · 您还需要使用 ODBC 管理器和 Database Desktop,这些都用于数据存储的目的(如果忘了,可以 阅读第 13 章)。 显然,您也可以使用其他的仿真器或具有 WAP 功能的电话。您还可以使用其他的 WAP 或 Web 服务 附录 D 用 Delphi 实现无线程序 547 器,每种配置都可能有自身的问题,都需要一些研究和试验。例如在本书中,在具有 144M 内存和大约 2G 磁盘的 Pentium II 便携式电脑上运行 Windows 2000 Professional 系统和 Delphi 6 企业版。数据库是使用 Database Desktop 用 Paradox 表格式建立起来的。IIS 在便携式电脑上使用 localhost 或 IP 地址 127.0.0.1 独 立运行,它将作为 Web 服务器使用。当程序在 PC 上调试好之后,即可将其传送到企业内部网或 Internet 的 Web 服务器上。 D.2 无线标记语言基础 WML,或称无线标记语言,是 XML(可扩展标记语言)的近亲。如果您熟悉 HTML 或 XML,那么 WML 就不成问题。如果缺乏标记语言方面的经验,您可以先看一下 WML、WAP、WMLScript 方面的书 籍(参考书目中列出的《WAP Development with WML and WMLScript》一书中信息很丰富,作者是 Bert Forta)。 WML 文档的页面称为桌面(DECK),通常由一组相互链接的卡片(CARD)组成。一个单独的 HTML 文件就是一个文档。在 WML 中,一个文件包含一个桌面,桌面中至少包含一个卡片;而桌面还包含许多 卡片。单个的卡片大体上与单独的 HTML 文档等价。由于 WAP 设备屏幕的限制,在每个卡片上可以定义 的内容的种类和数量与 HTML 文档相比都非常少,因而必须更加简明。 D.2.1 定义桌面(或 WML 文档) 基本的 WML 文档包含一个序言,起始标记<wml>和终止标记</wml>,以及至少一对<card></card>标 记。下面是一个基本的文档,结果显示在 Nokia blueprint 仿真器中,如图 D.1 所示。 <?xml version="1.0"?> <!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.1//EN" "http://www.wapforum.org/DTD/wml_1.1.xml"> <wml> <card> <p> Hello World! </p> </card> </wml> 附录 D 用 Delphi 实现无线程序 图 D.1 548 Nokia blueprint 仿真器,表示通用的具有 WAP 功能 的电话,这里显示了上述 WML 文档的结果 在起始标记<wml>之前是序言。序言中包括了所用的标记语言版本,以及该版本定义的路径。最容易 的方法是将该信息保存为模板,并将其粘贴到每个文档中。与<html>和</html>类似,<wml>和</wml>标记 分别定义了 WML 文档的起始和结束。对于桌面(WML 文档)中的每个卡片而言,都有一对卡片标记。 所显示的文本必须放置在一对段落标记<p></p>中。 像 HTML 一样,有许多标记和标记属性。下面将涉及到其中的一些。请记住,WML 是大小写敏感的, 而您可能需要对几种不同的 WAP 设备测试 WML 文档,以确保内容的一般性。 D.2.2 定义卡片 卡片是与一页数据等价的。虽然数据未必适合于在无线设备的小屏幕上观看,但 WAP 设备确实提供 了垂直滚屏功能。保持卡片的简明,可以使您的程序更为友好。 卡片标记的两个属性很有用:id 和 title。id 属性对于在同一桌面的不同卡片之间定位很有用。它与 HTML 的书签标记工作方式类似。title 属性为卡片提供了标题,可供无线设备在可见区域的顶部显示,但 该属性不是必须的。在上一节的 Hello World!例子中,卡片的起始标记如下所示。 <card id="main" title="Hello"> 当要在桌面之内定位时,可以将 id 用于超文本引用属性中,如下所示: <a href="#main" >Main</a> <br/> 上面的语句将显示为超链接,通常由下划线字体表示(这里的 Main 文本)。当选择该超链接时,有 Main 所引用的卡片将称为当前卡片。 D.2.3 格式化标记 段落标记<p>和</p>是必须的。您也可以使用其他格式化标记,如行结束标记<br/>。可以注意到 WML 中 br 标记的结尾多出一条斜线。WML 有许多微小之处与 HTML 并不一致,例如上面的行结束标记;如果 您需要在协议之间来回切换,应该在手头准备语言的参考手册。 附录 D 用 Delphi 实现无线程序 549 WML 还支持字体格式化标记,如<b></b>,<i></i>,以及<u></u>,分别可用于黑体、斜体和下划线 字体。这些标记必须是对称的。 <b><i>Text</b></i> 虽然上面的代码在 HTML 中是可以接受的,但在 WML 中却无法正常工作。要使其能够在 WML 中正 确工作,可以进行修改,正确的写法如下: <b><i>Text</i></b> 通常情况下,WML 的语法比 HTML 更为严格。 D.2.4 导航按钮 在图 D.1 中,可以看到有些导航按钮放置在电话的关键之处。这些按钮,加上数字键盘,是用于用户 输入的。在可视区域之下,左右分别有两个按钮,这两个按钮是软件键。您可以利用 WML 对这两个键进 行编程,以辅助浏览。 <card id="main"> <do type="options" label="Back"> <prev/> </do> <p> Some more text! </p> </card> do 标记定义了与某个特定软件键相关联的事件响应。所表示的键在 type 选项中指定;当显示上面定 义的卡片时,该键的文本是 Back。例子中对该事件的响应是返回到前一个卡片。可以注意到,与 options 软件键相关联的事件是在包含显示文本的段落之前定义的。软件键的外观和位置如图 D.2 所示。 图 D.2 D.2.5 定义软件键以辅助浏览,示例代码中创建了向后浏览的功能 模板 因为 WAP 设备只涉及到有限的带宽,因此每个请求要尽可能多的发送信息。这就是需要在一个文档 中发送多个卡片的原因。但不要发送不必要的冗余数据。首先,重复的数据容易出错,其次,这会浪费有 限的带宽。 为补救可能的冗余和浪费,定义了模板标记。模板可以在桌面一级重新定义并应用到同一桌面中的所 有卡片。例如,如果要对每个卡片都定义一个返回按钮,那么可以在桌面的开头定义一次模板标记。下面 的片断示范了模板的位置。 <wml> <template> 附录 D 用 Delphi 实现无线程序 550 <do type="options" label="back"> <prev/> </do> </template> <card title="Hello"> 如果显示上面的代码中的桌面,那么每个卡片都会具有返回功能。 可以认为,桌面等价于全局作用域,而卡片等价于本地作用域。一次,如果在卡片一级定义了与桌面 一级相同的事件,那么卡片一级的事件是优先的。可以利用这一点对桌面模板进行选择性的重载。 <card id="main"> <do type="options" label="back"> <noop/> </do> 上面的片断重新将 options 软件键设置为 noop,即没有操作,这可能是由汇编语言程序员发明的。对 上面的代码中的 id 对应的卡片而言,对事件的重新设置将使桌面一级的相应事件失效。 D.2.6 输入域和变量 WML 并不像 HTML 那样支持复杂的显示布置,如表格和帧等。但如果不支持用户输入,WML 可能 不会太有用。输入标记用于为卡片定义输入域。该标记放置在段落标记对之内。下面给出了一个输入域的 例子,摘自 CGI 例子服务器 name_lookup.exe。 <input name="LastName" emptyok="false" format="*m" type="text" /> 输入语句定义了一个名为 LastName 的变量。输入域必须包含数据,例如 emptyok=“false”。格式化掩 码*m 表示可以输入任意数目、任意类型的字符,最后的属性表示输入域的数据类型是文本。 这只是 WML 中少量的标记和属性。WML 有语法和关键字。对于更为高级的 WML 用法,可以阅读 关于 WML、WAP、XML 和 WMLScript 的参考资料。可以从书后的参考书目开始,也可以去 Osborne.com 网站查找。 D.3 无线应用与 Delphi Delphi 如何支持无线应用?答案非常直接。WAP 应用可以通过 URL 请求数据。而使用 Web Broker 组 件建立的 Delphi 服务器可根据特定的 URL 返回动态页面。无论 WAP 应用运行在 WAP 服务器或 Web 服务 器上,这一点都是正确的。 可接受的 WAP URL 中可以包含脚本的名字和查询信息,这在第 17 章已经讨论过。惟一重要的不同点 在于,我们必须告知 Web 服务器可接受的 MIME 类型,包括 WAP 类型、以及要求 Web 服务器返回格式 化好的 WML 内容。简而言之,我们需要用于返回 WML 数据的 WAP 服务器(如 Nokia 的 Active Server) 或 Web 服务器,以及动态生成 WML 代码的 Web Broker 服务器。 在下一节中,我们将使用 IIS 来提供 WML 页面,因此首先需要将 WML 头类型添加到 IIS。要添加 WML MIME 头类型,可按照下列步骤进行,同时可以参考图 D.3(假定您已经安装了 IIS): 1. 2. 3. 4. 在 Windows 2000 Professional 系统中,启动控制面板。 在控制面板中,双击管理工具小应用程序。 在管理工具中,启动服务控制管理器。 前 3 步会打开 MMC(Microsoft Management Console),在 Windows NT 4.0 中的步骤是类似的。从 MMC 中选择 Web 站点,然后右击鼠标显示上下文菜单。单击 Properties 菜单项来显示 Web Site Properties 对话框。 5. 在 Web Site Properties 对话框中,选择 HTTP Headers 属性页并在 MIME Map 组中单击 File Types 按钮。 附录 D 用 Delphi 实现无线程序 551 6. 按照图 D.3 中前台的 File Types 对话框所示列表,添加所需的扩展和 MIME 类型(Web Site Properties 对话框显示在后台)。 图 D.3 File Types 对话框用于向 IIS 添加经过认可的 MIME 类型,这使得 IIS 能够提供例子所需要的 WML 页面 在完成上述步骤后,IIS 就可以向 WAP 浏览器提供 WML 页面了。在 Web Broker 服务器内部,我们也 需要指出相应的内容是 WML。我将在下一节中示范所需的步骤,那时就可以创建一个 WAP 服务器程序了。 D.4 使用 Delphi 创建无线服务器 用 Delphi 实现的无线应用可以使用 Web Broker 组件,如第 17 章示范的 TPageProducer。像其他程序 一样,我们首先需要对程序所提供的服务进行描述。 注意:举例来说,如果示例程序能够从 Outlook 读出联系信息,那么它就是有用的。无需向 电话输入数据,所有的记录表、联系信息、以及任务信息都能够读入到 WAP 电话上。 本节中的例子程序提供了一个电话目录。用户可以浏览站点,而且即使她连一个电话号码和联系方式 都记不住也能打电话,只要输入接电话的人的部分或全部姓氏即可。服务器将返回可能匹配的姓名列表, 用户可以从中进行选择。但找到一个特定的匹配时,将返回与该姓名相关的所有信息。 D.4.1 建立联系表 Contacts.db 表是使用 Database Desktop 定义的。本书光盘上的表中包含了姓名以及两个电话号码;它 可以表示一些更有用的信息,如客户联系信息列表等。 该表定义如下: ID Auto Increment Primary Key FIRST_NAME Alpha 10 LAST_NAME Alpha 15 HOME_PHONE Alpha 14 MOBILE_PHONE Alpha 14 在创建该表后,将其复制到 IIS 可访问的目录中。由于 CGI 脚本在 c:\inetpub\scripts 目录下运行,我将 表复制到该目录下。 现在表已经建好,可以用我们创建的脚本进行访问;向表添加几条记录并创建指向其位置的 ODBC 别 附录 D 用 Delphi 实现无线程序 552 名,这样创建示例数据库的步骤就结束了(第 13 章列出了使用 DataSource Administrator 创建 ODBC 别名 的步骤)。 D.4.2 使用 Web Broker 创建 CGI 服务器 根据对工作的描述,完成任务需要定义三个 WML 文档(如果多作一些工作,可以用更少的文档达到 目的,但这与我们的讨论并不相关)。第一个桌面包含了主要的输入卡片,用户可以在这里输入所需联系 信息对应的姓氏或姓氏掩码。第二个桌面返回一个卡片,其中包含了可能匹配的超链接列表,而第三个桌 面的卡片则包含所需联系信息的所有匹配数据。 注意:在实际的程序中,如果第二个桌面包含两个卡片,一个卡片中包含可能匹配的超链接 列表,而另一个卡片则包含匹配的细节信息,那么可以更好地利用带宽并使得响应更快。超 链接可以定位到同一桌面中的卡片,而不是再从服务器获取文档。 在例子程序中,服务器定义为单独的 CGI 可执行程序,由 Web Server Application 向导创建。服务器包 含了三个 TPageProducer 组件、一个 TDatabase 组件、两个 TQuery 组件。还定义了三个动作组件,以便向 用户提供三种可能的动作,这就是我们所需要的。 CGI 脚本名为 name_lookup.exe。将脚本放置到脚本子目录中。如果在 Windows 2000 工作站按照 IIS, 默认情况下该目录是 c:\inetpub\scripts。从第 17 章可知,服务器名是作为 URL 路径信息的一部分添加的。 每个服务器都有一个默认路径。对本例而言,服务器可通过下列 URL 调用: http://localhost/scripts/name_lookup.exe/ 由于没有在脚本之后给出特定的路径信息,将使用默认的路径进行调用。在本例中,将调用名为 root 的默认 TWebAction 组件。在页面生成器的内容返回之前,需要设置 Response.ContentType,以表明需要返 回 WML 文档。 procedure TWebModule1.WebModuleBeforeDispatch(Sender: TObject; Request: TWebRequest; Response: TWebResponse; var Handled: Boolean); begin Response.ContentType := 'text/vnd.wap.wml'; end; 页面生成器负责返回文档内容。root 动作组件的内容如下列出。 <?xml version="1.0"?> <!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.1//EN" "http://www.wapforum.org/DTD/wml_1.1.xml"> <wml> <!-- Request the user input --> <card title="Phone List"> <do type="accept" label="Find"> <go href= "http://localhost/scripts/name_lookup.exe/search?LastName= $(LastName)" /> </do> <p> Enter last name: <input name="LastName" emptyok="false" format="*m" type="text" /> </p> </card> </wml> root 动作组件包含了一个单独的卡片,其中定义了 Find 软件键,使得用户可以输入搜索信息。 附录 D 用 Delphi 实现无线程序 553 从超文本引用可知,Find 命令将把赋值给 LastName 变量的数据作为查询发送到 search 路径。服务器 端将运行 name_lookup.exe,而路径 search 和查询信息 LastName=value 将发送到脚本。 脚本将检查路径和查询信息,然后运行相应的 TWebAction 组件。所需的 TWebAction 组件是由路径信 息确定的。这里会运行具有/search 路径的 TWebAction 组件。浏览第 17 章,可以看到脚本使用了 TQuery 组件并动态建立了要显示的超链接列表以及其他特定的查询信息。代码与第 17 章中 TQueryTableProducer 部分的代码非常相似,因此这里不再解释。 第三个桌面及其卡片只需返回格式化的数据。从上面的文档和代码可知,HTML 和 WML 服务器之间 最重要的区别是所返回的内容、内容的类型和相应的客户。大部分情况下,客户端都可能是 WAP 浏览器。 其他方面的信息,您可以从前面的章节中了解。 D.4.3 测试程序 所有的程序都需要进行测试。找到尽可能多的工具来促进测试的进行是成功的关键因素。对本例来说, 可下载使用 Nokia 公司的 WAP Toolkit。仿真器可通过该工具包(如图 D.4 的背景所示)运行。WML 文档 的输出显示在仿真器中(如图 D.4 的前台所示) 。由于 IIS 在本地计算机上运行,Web 服务器的 URL 是 localhost。 图 D.4 Nokia WAP Toolkit 和仿真器,可用于测试 WAP 程序的工具之一 要使用 Nokia WAP 工具包测试 WAP 例子程序,需要运行 WAP 工具包和仿真器并键入脚本的完整路 径。完整的 URL 路径是 http://localhost/scripts/name_lookup.exe/。要测试程序的各项功能,对每一屏幕都进 行输入并搜索示例数据。 1. 2. 3. 4. 选择 Options 软件键。 从 Options 菜单选择 Edit Selection 菜单项,然后单击 Select 软件键。 使用数字小键盘将数据输入到输入域中,单击 OK 软件键。 单击 Options 软件键,找到 Find 菜单项,并单击 Select 软件键(请记住,软件键的值是与特定的 卡片相关的) 。 5. 定位到某个返回的超链接,单击 Options,然后单击 Follow Link。 注意:最后,使用 Delphi、IIS 和 WAP 工具包一起进行集成调试可能会有些困难。当我写本 书的时候,还未能解决一些细节问题。如果您能够在 Delphi 中步进调试 WAP 服务器,请给 附录 D 用 Delphi 实现无线程序 554 我发个电子邮件(pkimmel@softconcepts.com)。在此期间,我们只好编一些输出语句,用 较老的风格进行调试。我猜测,用于测试和调试 WAP 服务器的工具很快能变得更好用。 最后,如果存在与输入的值相匹配的数据,该数据将返回到用户。在实际的程序中,还需要检查输出 的有效性,并确保浏览功能正常工作。 D.5 小 结 附录 D 示范了 Delphi 可以方便地为无线设备建立动态的 Web 解决方案。Delphi 企业版携带了 Web Broker 工具。Delphi 专业版用户需要购买 Web Broker 工具。在 WAP 服务器和 Web 服务器之间的区别在于: 对于 WAP,客户浏览器运行在 WAP 电话上,而服务器需要返回 WML 内容。 您现在已经掌握了这些技术。您已经拥有了最好的工具之一。愿力量与你同在。