如何开发可安装插件(Plug-in)的应用程序?

时间:2021-07-27 22:51:20
开发可安装插件(Plug-in)的应用程序,应该在软件设计的时候注意什么?还是使用什么支持插件的工具啊?

5 个解决方案

#1


没有人知道吗?

#2


插件技术
Delphi  插件 (Plug-ins) 创建、调试与使用应用程序扩展 

关键词 :Delphi 控件杂项 

有没有使用过 Adobe Photoshop ?如果用过 ,你就会对插件的概念比较熟悉。 

对外行人来说 ,插件仅仅是从外部提供给应用程序的代码块而已 ( 举个例子来说 ,在 

一个 DLL 中 ) 。一个插件和一个普通 DLL 之间的差异在于插件具有扩展父应用程序功能 

的能力。例如 ,Photoshop 本身并不具备进行大量的图像处理功能。插件的加入使其获 

得了产生诸如模糊、斑点 ,以及其他所有风格的奇怪效果 ,而其中任何一项功能都不 
是父应用程序自身所具有的。对于图像处理程序来说这很不错 ,可是为什么要花偌大的力气去完成支持插件的商业应用程序呢?假设 ,我们举个例子 ,你的应用程序要产生一些报表。你的客户肯定会 
一直要求更新或者增加新的报表。你可以使用一个诸如 Report Smith 的外部报表生成器 ,这是个不怎么样的解决方案 ,需要发布附加的文件 ,要对用户进行额外的培训 ,等等。你也可以使用QuickReport,不过这会使你身处版本控制的噩梦之中 -- 如果每 
改变一次字体你就要 Rebuild 你的应用程序的话。 
然而 ,只要你把报表做到插件中 ,你就可以使用它。需要一个新的报表吗? 

没问题 ,只要安装一个 DLL,下次应用程序启动时就会看见它了。另外一个例子是处理 

来自外部设备 ( 比如条形码扫描器 ) 的数据的应用程序 ,为了给用户更多的选择 ,你 

不得不支持半打的各种设备。通过将每种设备接口处理例程写成插件 ,不用对父应用 

程序作任何变动就可以获得最大程度的可伸缩性。 

入门 

在开始写代码之前最重要的事情就是搞清楚你的应用程序到底需要扩展哪些功 

能。这是因为插件是通过一个特定的接口与父应用程序交互的 ,而这个接口将根据你的 

需要来定义。在本文中 ,我们将建立 3 个插件 ,以便展示插件与父应用程序相交互的几 

种方式。 

我们将把插件制作成 DLL 。不过 ,在做这项工作之前 ,我们得先制作一个外壳程 

序来载入和测试它们。图 1 显示的是加载了第一个插件以后的测试程序。第一个插件没有 

完成什么大不了的功能 ,实际上 ,它所做的只是返回一个描述自己的字符串。不过 ,它 

证明了很重要的一点 -- 不管有没有插件应用程序都可以正常运行。如果没有插件 ,它 

就不会出现在已安装的插件列表中 ,但是应用程序仍然可以正常的行使功能。 

我们的插件外壳程序与普通应用程序之间的唯一不同就在于工程源文件中出现 

在 uses 子句中的 Sharemem 单元和加载插件文件的代码。任何在自身与子 DLL 之间传递字符 

串参数的应用程序都需要 Sharemem 单元 ,它是 DelphiMM.dll(Delphi 提供该文件 ) 的接 

口。要测试这个外壳 ,需要将 DelphiMM.dll 文件从 Delphi\Bin 目录复制到 path 环境变量 

所包含的路径或者应用程序所在目录中。发布最终版本时也需要同时分发该文件。 

插件通过 LoadPlugins 过程载入到这个测试外壳中 ,这个过程在主窗口的 

FormCreate 事件中调用 ,见图 2 。该过程使用 FindFirst 和 FindNext 函数在应用程序所在 

目录中查找插件文件。找到一个文件以后 ,就使用图 3 所示的 LoadPlugins 过程将其载入。 

{  在应用程序目录下查找插件文件  }

procedure TfrmMain.LoadPlugins;

var

sr:     TSearchRec;

path:   string;

Found: Integer;

begin

path := ExtractFilePath(Application.Exename);

try

Found := FindFirst(path + cPLUGIN_MASK, 0, sr);

while Found = 0 do begin

LoadPlugin(sr);

Found := FindNext(sr);

end;

finally

FindClose(sr);

end;

end;

{  加载指定的插件  DLL. }

procedure TfrmMain.LoadPlugin(sr: TSearchRec);

var

Description:   string;

LibHandle:     Integer;

DescribeProc: TPluginDescribe;

begin

LibHandle := LoadLibrary(Pchar(sr.Name));

if LibHandle <> 0 then

begin

DescribeProc := GetProcAddress(LibHandle,

cPLUGIN_DESCRIBE);

if Assigned(DescribeProc) then

begin

DescribeProc(Description);

memPlugins.Lines.Add(Description);

end

else

begin

MessageDlg('File "' + sr.Name + '" is not a valid plug-in.',

mtInformation, [mbOK], 0);

end;

end

else

MessageDlg('An error occurred loading the plug-in "' +

sr.Name + '".', mtError, [mbOK], 0);

end;

LoadPlugin 方法展示了插件机制的核心。首先 ,插件被写成 DLL 。其次 ,通过 

LoadLibrary API 它被动态的加载。一旦 DLL 被加载 ,我们就需要一个访问它所包含的过 

程和函数的途径。 API 调用 GetProcAddress 提供这种机制 ,它返回一个指向所需例程的 

指针。在我们这个简单的演示中 ,插件仅仅包含一个名为 DescribePlugin 的过程 ,由常数 

cPLUGIN_DESCRIBE 指定 ( 过程名的大小写非常重要 ,传递到 GetProcAddress 的名称必须 

与包含在 DLL 中的例程名称完全一致 ) 。如果在 DLL 中没有找到请求的例程 ,

GetProcAddree 将返回 nil,这样就允许使用 Assigned 函数测定返回值。 

为了以一种易用的方式存储指向一个函数的指针 ,有必要为用到的变量创建一个 

特定的类型。注意 ,GetProcAddress 的返回值被存储在一个变量中 ,DescribeProc,属于 

TpluginDescribe 类型。下面是它的声明 :

type

TPluginDescribe = procedure(var Desc: string); stdcall;

由于过程存在于 DLL 内部 ,它通过标准调用转换编译所有导出例程 ,因此需要使 

用 stdcall 指示字。这个过程使用一个 var 参数 ,当过程返回的时候它包含插件的描述。 

要调用刚刚获得的过程 ,只需要使用保存地址的变量作为过程名 ,后面跟上任何 

参数。就我们的例子而言 ,声明 :

DescribeProc(Description)

将会调用在插件中获得的描述过程 ,并且用描述插件功能的字符串填充 Description 变量。 

构造插件 

我们已经创建好了父应用程序 ,现在该轮到创建我们希望加载的插件了。插件 

文件是一个标准的 Delphi DLL,所以我们从 Delphi IDE 中创建一个新 DLL 工程 ,保存它。 

由于导出的插件函数将用到字符串参数 ,所以要在工程的 uses 子句中把 Sharemen 单元放 

在最前面。图 4 列出的就是我们这个简单插件的工程源文件。 

uses

Sharemem, SysUtils, Classes,

main in 'main.pas';

{$E plg.}

exports

DescribePlugin;

begin

end.

虽然插件是一个 DLL 文件 ,但是没有必要一定要给它一个 .DLL 的扩展名。实际上 ,

一个原因就足以让我们有理由改变扩展名 : 当父应用程序寻找要加载的文件时 ,新的扩展 

名可以作为特定的文件掩模。通过使用别的扩展名 ( 我们的例子使用了 *.plg),你可以 

在一定程度上确信应用程序只会载入相应的文件。编译指示字 $X 可以实现这个改变 ,也可 

以通过 Project Options 对话框的 Application 页来设置扩展名。 

第一个例子插件的代码是很简单的。图 5 显示了包含在一个新单元中的代码。注 

意 ,DescribePlugin 原型与外壳应用程序中的 TpluginDescribe 类型相一致 ,使用附加的 

export 保留字指定该过程将被导出。被导出的过程名称也将会出现在主工程源代码的 

exports 段中 ( 在图 4 中列出 ) 。 

unit main;

interface

procedure DescribePlugin(var Desc: string);

export; stdcall;

implementation

procedure DescribePlugin(var Desc: string);

begin

Desc := 'Test plugin v1.00';

end;

end.

在测试这个插件之前 ,要先把它复制到主应用程序的路径下。最简单的办法就是 

在主目录的子目录下创建插件 ,然后把输出路径设置为主路径 (Project Options 对话框 

的 Directories/Conditionals 也可以作这个设置 ) 。 

#3


调试 

现在介绍一下 Delphi 3 中一个较好的功能 : 从 IDE 中调试 DLL 的能力。在 DLL 工程 

中可以通过 Run paramaters 对话框指定某程序为宿主应用程序 ,这就是指向将调用 DLL 的 

应用程序的路径 ( 在我们这个例子中 ,就是刚刚创建的测试外壳程序 ) 。然后你就可以 

在 DLL 代码中设置断点并且按 F9 运行它 -- 就像在一个普通应用程序中做的那样。 Delphi

会运行指定的宿主程序 ,并且 ,通过编译带有调试信息的 DLL,把你指引到 DLL 代码内的 

断点处。 

---

Delphi  插件 (Plug-ins) 创建、调试与使用应用程序扩展 ( 续 )

关键词 :Delphi 控件杂项 

延伸父应用 

这个简单的插件不错 ,不过它不能做什么有用的事情。第二个例子就是纠正这个问题。 

这个插件的目标就是在父应用程序的主菜单中加入一个项目。这个菜单项目 ,当被单击 

时 ,就会执行插件内的一些代码。图 6 显示外壳程序的改进版 ,两个插件都已经加载。在 

这个版本的外壳程序中 ,一个名为 Plug-in 的新菜单项目 ,被添加到主菜单中。插件会在 

运行时加入一个菜单项。 

为了实现这个目的 ,我们必须在插件 DLL 中定义第二个接口。现有的 DLL 只导出了一个过 

程 ,DescribePlugin 。第二个插件将声明一个叫做 InitPlugin 的过程。不过 ,在这个过程 

可以在主应用程序中看到以前 ,必须修改 LoadPlugin 来配合它。 

图 7 所示的代码展示了改进的过程。 

procedure TfrmMain.LoadPlugin(sr: TSearchRec);

var

Description:   string;

LibHandle:     Integer;

DescribeProc: TPluginDescribe;

InitProc:      TPluginInit;

begin

LibHandle := LoadLibrary(Pchar(sr.Name));

if LibHandle <> 0 then

begin

//  查找  DescribePlugin.

DescribeProc := GetProcAddress(LibHandle,

cPLUGIN_DESCRIBE);

if Assigned(DescribeProc) then

begin

//  调用  DescribePlugin.

DescribeProc(Description);

memPlugins.Lines.Add(Description);

//  查找  InitPlugin.

InitProc := GetProcAddress(LibHandle, cPLUGIN_INIT);

if Assigned(InitProc) then

begin

//  调用  InitPlugin.

InitProc(mnuMain);

end;

end

else

begin

MessageDlg('File "' + sr.Name + '" is not a valid plugin.',

mtInformation, [mbOK], 0);

end;

end

else

begin

MessageDlg('An error occurred loading the plugin "' +

sr.Name + '".', mtInformation, [mbOK], 0);

end;

end;

如你所见 ,当 GetProcAddress 第一次查找调用描述过程之后 ,又调用了一次 

GetProcAddress 。这一次 ,我们要寻找的是常量 cPLUGIN_INIT,定义如下 :

const

cPLUGIN_INIT = 'InitPlugin';

返回值存储在 TpluginInit 类型的变量中 ,定义如下 :

type

TPluginInit = procedure(ParentMenu: TMainMenu); stdcall;

当 InitPlugin 方法被执行时 ,父应用程序的主菜单被当作一个参数传递给它。这个过程 

可以按照自己的意愿修改菜单。由于所有 GetProcAddress 的返回值都用 assigned 测试 ,

新版本的 LoadPlugin 过程仍然会加载不包含 InitPlugin 过程的第一个插件。在这个过程 

中第一次调用寻找 DescribePlugin 方法会通过 ,第二次寻找 InitPlugin 会无响应失败。 

现在新的接口已经定义好了 ,可以为新的 InitPlugin 方法编写代码了。像原先一样 ,

新插件的实现代码存在于一个单独的单元中。图 8 显示了修改过的包含 InitPlugin 方法 

的 main.pas 。 

unit main;

interface

uses Dialogs, Menus;

type

THolder = class

public

procedure ClickHandler(Sender: TObject);

end;

procedure DescribePlugin(var Desc: string);

export; stdcall;

procedure InitPlugin(ParentMenu: TMainMenu);

export; stdcall;

var

Holder: THolder;

implementation

procedure DescribePlugin(var Desc: string);

begin

Desc := 'Test plugin 2 - Menu test';

end;

procedure InitPlugin(ParentMenu: TMainMenu);

var

i: TMenuItem;

begin

//  创建新菜单项 .

i := NewItem('Plugin &Test', scNone, False, True,

Holder.ClickHandler, 0, 'mnuTest');

ParentMenu.Items[1].Add(i);

end;

procedure THolder.ClickHandler;

begin

ShowMessage('Clicked!');

end;

initialization

Holder := THolder.Create;

finalization

Holder.Free;

end.

很明显 ,对原始插件的第一个改变就是增加了 InitPlugin 过程。像原先一样 ,带有 

export 关键字的原型被加入到单元顶端的列表中 ,过程名也被加入到工程源代码的 

exports 子句列表中。这个过程使用 NewItem 函数创建一个新的菜单项 ,返回值是 TmenuItem

对象。新菜单项通过下列语句被加入到应用程序主菜单中 :

ParentMenu.Items[1].Add(I);

在测试外壳主菜单上的 Items[1] 是菜单项 Plug-in,所以这个语句在 Plugin 菜单条上添 

加一个叫 Plug-in Test 的菜单项。 

为了处理对新菜单项的响应 ,作为它的第五个参数 ,NewItem 可以接受一个 TNotifyEvent

类型的过程 ,这个过程将在菜单项被点击时调用。不幸的是 ,按照定义 ,这种类型的过程 

是一个对象方法 ,然而在我们的插件中并没有对象。如果我们想用通常的指针来指向函数 ,

那么得到的将只会是 Delphi 编译器的抱怨。所以 ,唯一的解决办法就是创建一个处理菜单 

点击的对象。这就是 Tholder 类的用处。它只有一个方法 ,是一个叫做 ClickHandler 的过程。 

一个叫做 Holder 的全局变量 ,在修改过的 main.pas 的 var 段中被声明为 Tholder 类型 ,并且 

在单元的 initialization 段中被创建。现在我们就有一个对象了 ,我们可以拿它的方法 

(Holder.ClickHandler) 当作 NewItem 函数的参数。 

搞了这一通 ,ClickHandler 除了显示一个 "Clicked!" 消息对话框以外什么以没干。也 

许这不怎么有趣 ,不过它仍然证明了一点 : 插件 DLL 成功的修改了父应用的主菜单 ,表现 

了它的新用途。并且如同第一个例子一样 ,不管这个插件在不在应用程序都能执行。 

由于我们创建了一个对象来处理菜单点击 ,那么在不再需要这个插件时 ,就要释放这个 

对象。修改后的单元中会在 finalization 段中处理这件事情。 Finalization 端时与 

initialization 段相对应的 ,如果前面有一个 initialization 段 ,那么在应用程序终止时 

finalization 段一定会得到执行。把下面的语句 

Holder.Free

加到 finalization 段中 ,以确保 Holder 对象会被正确的释放。 

显而易见 ,虽然这个插件只是修改了外壳应用的主菜单 ,但是它可以轻易地操纵传递 

到 InitPlugin 过程中的任何其他对象。如果有必要 ,插件也可以打开自己的对话框 ,向 

列表框 (List boxes) 和树状视图 (tree views) 中添加项目 ,或者在画布 (canvas)

中绘画。 

事件驱动的插件 

到现在为止我们所描述的技术可以产生一种通用的扩展应用程序的方法。通过增加新菜 

单、窗体和对话框 ,就可以实现全新的功能而不必对父应用做任何修改。不过仍然有一个 

限制 : 这只是一种单侧 (one-sided) 机制。正如所看到的 ,系统依赖用户的某些操作才 

能启动插件代码 ,比如点击菜单或者类似的动作。代码运行起来以后 ,又

#4


又要依靠另外一个 

用户动作来停止它 ,例如 ,关闭插件可能已经打开的窗体。克服这种缺陷的一种可行的方 

法就是使插件可以响应父应用中的动作 -- 模仿在 Delphi 中工作地很好的事件驱动编程模 

型的确有效。 

在最后一个例子插件中 ,我们将创建一种机制 ,插件可以藉此响应父应用中产生的事件。 

通常情况下 ,可以通过判定需要触发哪些事件、在父应用中为每个事件创建一个 Tlist 对象 

来实现。然后每个 Tlist 对象都被传递到插件的初始化过程中 ,如果插件想在某个事件中 

执行动作 ,它就把负责执行的函数地址加入到对应的 TList 中。父应用在适当的时刻循环 

这些函数指针的列表 ,按次序调用每个函数。通过这种方法 ,就为多个插件在同一事件中 

执行动作提供了可能。 

应用程序产生的事件完全依赖于程序已确定的功能。例如 ,一个 TCP/IP 网络应用程序可能 

希望通过 TclientSocket 的 onRead 事件通知插件数据抵达 ,而一个图形应用程序可能对调 

色板的变化更感兴趣。 

为了说明事件驱动的插件应答的概念 ,我们将创建一个用于限制主窗口最小尺寸的插件。 

这个例子有点儿造作 ,因为把这个功能做到应用程序里边会比这简单的多。不过这个例子 

的优点在语容易编码而且易于理解 ,而这正是本文想要做到的。 

很明显 ,我们要做的第一件事情就是决定到底要产生哪些事件。在本例中 ,答案很简单 :

要限制一个应用程序窗口的尺寸 ,有必要捕获并且修改 Windows 消息 WM_GETMINMAXSINFO 。 

因此 ,要创建一个完成这项功能的插件 ,我们必须捕获这个消息并且在这个消息处理器中 

调用插件例程。这就是我们要创建的事件。 

接下来我们要创建一个 TList 来处理这个事件。在主窗体的 initialization 段中将会创建 

lstMinMax 对象 ,然后 ,创建一个消息处理器来捕获 Windows 消息 WM_GETMINMAXINFO 。图 9 中 

的代码显示了这个消息处理器。 

{  捕获  WM_GETMINMAXINFO.  为每个消息调用插件例程 . }

procedure TfrmMain.MinMaxInfo(var msg: TMessage);

var

m: PMinMaxInfo;   file:// 在  Windows.pas  中定义 .

i: Integer;

begin

m := pointer(msg.Lparam);

for i := 0 to lstMinMax.count -1 do begin

TResizeProc(lstMinMax[i])(m.ptMinTrackSize.x,

m.ptMinTrackSize.y);

end;

end;

外壳应用的 LoadPlugin 过程必须再次修改以便调用初始化例程。这个新初始化函数把 

我们的 TList 当作参数接受 ,在其中加入修改消息参数的函数地址。图 10 显示了 LoadPlugin

过程的最终版本 ,它可以执行到目前为止所讨论的全部几个插件的初始化工作。 

{  加载指定的插件 DLL. }

procedure TfrmMain.LoadPlugin(sr: TSearchRec);

var

Description:   string;

LibHandle:     Integer;

DescribeProc: TPluginDescribe;

InitProc:      TPluginInit;

InitEvents:TInitPluginEvents;

begin

LibHandle:=LoadLibrary(Pchar(sr.Name));

ifLibHandle<>0then

begin

// 查找 DescribePlugin.

DescribeProc:=GetProcAddress(LibHandle, cPLUGIN_DESCRIBE);

if Assigned(DescribeProc) then

begin

//  调用  DescribePlugin.

DescribeProc(Description);

memPlugins.Lines.Add(Description);

file:// 查找 InitPlugin.

InitProc := GetProcAddress(LibHandle, cPLUGIN_INIT);

if Assigned(InitProc) then

begin

file:// 调用 InitPlugin.

InitProc(mnuMain);

end;

//  为第三方插件查找  InitPluginEvents

InitEvents := GetProcAddress(LibHandle,

cPLUGIN_INITEVENTS);

if Assigned(InitEvents) then

begin

//  调用  InitPlugin.

InitEvents(lstMinMax);

end;

end

else

begin

MessageDlg('File "' + sr.Name +

'" is not a valid plugin.',

mtInformation, [mbOK], 0);

end;

end

else

begin

MessageDlg('An error occurred loading the plugin "' +

sr.Name + '".', mtInformation, [mbOK], 0);

end;

end;

图  10: LoadPlugin  的最终版本 

最后一步是创建插件自身。如同前面的几个例子 ,插件展示一个标志自身的描述过程。 

它也带有一个初始化例程 ,在本例中只是接受一个 TList 作为参数。最后 ,它还包含一个 

没有引出 (Export) 的历程 ,叫做 AlterMinTrackSize,它将修改传递给它的数值。图 11

显示了最终插件的完整代码。 

unit main;

interface

uses Dialogs, Menus, classes;

procedure DescribePlugin(var Desc: string);

export; stdcall;

procedure InitPluginEvents(lstResize: TList);

export; stdcall;

procedure AlterMinTrackSize(var x, y: Integer); stdcall;

implementation

procedure DescribePlugin(var Desc: string);

begin

Desc := 'Test plugin 3 - MinMax';

end;

procedure InitPluginEvents(lstResize: TList);

begin

lstResize.Add(@AlterMinTrackSize);

end;

procedure AlterMinTrackSize(var x, y: Integer);

begin

x := 270;

y := 220;

end;

end.

InitPluginEvents 过程是这个插件的初始化例程。它接受一个 TList 作为参数。这个 

TList 就是在父应用程序中创建的保存相应函数地址的列表。下面的语句 :

lstResize.Add(@AlterMinTrackSize);

把 AlterMinTrackSize 函数的地址加入到了这个列表中。它被声明为类型 stdcall 以便 

与其他过程相配 ,不过用不着 export 指示字。由于函数被直接通过它的地址调用 ,所以也 

就没有必要按照通常的方式把它从 DLL 中引出。 

所以 ,事件序列如下所列 :

1 、   在应用程序初始化时 ,创建一个 TList 对象。 

2 、   在启动时这个列表被传递到插件的初始化过程 InitPluginEvents 中。 

3 、   插件过程把一个过程的地址加入到列表中。 

4 、   每次窗口大小改变时所产生的 Windows 消息 WM_GETMINMAXINFO 被我们的应用程序所捕获。 

5 、   该消息被我们的消息处理器 TfrmMain.MainMaxInfo 所处理 ,见图 10 。 

6 、   消息处理器遍历列表并调用它所包含的函数 ,把当前的 X 和 Y 最小窗口尺寸作为参数传递 

。要注意 ,TList 类只是存储指针 ,所以如果想用保存的地址做些什么事情的话 ,我们必须 

把指针转换成所需要的类型 -- 在本例中 ,要转换成 TresizeProc 。 

TResizeProc = procedure (var x, y: Integer); stdcall;

7 、   插件过程 AlterMinTrackSize( 列表中的指针所指向的 ),接受 X 和 Y 值作为可变的 var

参数并且修改它们。 

8 、   控制权返回到父应用的消息处理器 ,按照最小窗口尺寸的新值继续运行下去。 

9 、   应用程序退出时 TList 会在主代码的 finalization 段被释放。 

结论 

使用该体系结构时 ,可能利用 Delphi 提供的 package 功能是个不错的主意。在通常情况 

下 ,我不是一个分割运行时模块的*爱好者 ,但是当你认为任一包含大量代码的 Delphi

DLL 超过 200KB 时 ,它就开始变得有意义了。 

这篇文章应该还是有些用处的 ,至少它可以让你思考一些程序设计方面的问题 ,比如 

如何让它变得更加灵活。我知道如果我在以前的应用程序中使用一些这种技术的话 ,我就 

可以省掉在修改程序方面的好多工作。我并不想把插件作为一种通用的解决方案。很明显 ,

有些情况下额外的复杂度无法验证其正确性 ,或者应用程序压根儿就不打算把自身搞成 

几块可扩展的单元。还有一些其它的方法也可以达成同样的效果。 Delphi 自身提供了一个 

接口来创作能集成到 IDE 中的模块 ,比起我所说明的技术这种方法更加面向对象 ( 或者说 

更 " 干净 "),而我也确信你可以在自己的应用中模仿这一技术。在运行时加载 Delphi 包 

也不是做不到的。探索一下这种可能性吧。 

[ 本文所介绍的技术在 Delphi 4 下工作的很好。实际上 ,Delphi 4 增加了工程选项 ,使 

这类应用程序加强 DLL(application-plus-DLL) 的开发变得更加容易了。 ]

版权声明:
以上引用Kingron大虾的资料

#5


厉害,受教了,一定给分,还有没有兄弟发言啊?

#1


没有人知道吗?

#2


插件技术
Delphi  插件 (Plug-ins) 创建、调试与使用应用程序扩展 

关键词 :Delphi 控件杂项 

有没有使用过 Adobe Photoshop ?如果用过 ,你就会对插件的概念比较熟悉。 

对外行人来说 ,插件仅仅是从外部提供给应用程序的代码块而已 ( 举个例子来说 ,在 

一个 DLL 中 ) 。一个插件和一个普通 DLL 之间的差异在于插件具有扩展父应用程序功能 

的能力。例如 ,Photoshop 本身并不具备进行大量的图像处理功能。插件的加入使其获 

得了产生诸如模糊、斑点 ,以及其他所有风格的奇怪效果 ,而其中任何一项功能都不 
是父应用程序自身所具有的。对于图像处理程序来说这很不错 ,可是为什么要花偌大的力气去完成支持插件的商业应用程序呢?假设 ,我们举个例子 ,你的应用程序要产生一些报表。你的客户肯定会 
一直要求更新或者增加新的报表。你可以使用一个诸如 Report Smith 的外部报表生成器 ,这是个不怎么样的解决方案 ,需要发布附加的文件 ,要对用户进行额外的培训 ,等等。你也可以使用QuickReport,不过这会使你身处版本控制的噩梦之中 -- 如果每 
改变一次字体你就要 Rebuild 你的应用程序的话。 
然而 ,只要你把报表做到插件中 ,你就可以使用它。需要一个新的报表吗? 

没问题 ,只要安装一个 DLL,下次应用程序启动时就会看见它了。另外一个例子是处理 

来自外部设备 ( 比如条形码扫描器 ) 的数据的应用程序 ,为了给用户更多的选择 ,你 

不得不支持半打的各种设备。通过将每种设备接口处理例程写成插件 ,不用对父应用 

程序作任何变动就可以获得最大程度的可伸缩性。 

入门 

在开始写代码之前最重要的事情就是搞清楚你的应用程序到底需要扩展哪些功 

能。这是因为插件是通过一个特定的接口与父应用程序交互的 ,而这个接口将根据你的 

需要来定义。在本文中 ,我们将建立 3 个插件 ,以便展示插件与父应用程序相交互的几 

种方式。 

我们将把插件制作成 DLL 。不过 ,在做这项工作之前 ,我们得先制作一个外壳程 

序来载入和测试它们。图 1 显示的是加载了第一个插件以后的测试程序。第一个插件没有 

完成什么大不了的功能 ,实际上 ,它所做的只是返回一个描述自己的字符串。不过 ,它 

证明了很重要的一点 -- 不管有没有插件应用程序都可以正常运行。如果没有插件 ,它 

就不会出现在已安装的插件列表中 ,但是应用程序仍然可以正常的行使功能。 

我们的插件外壳程序与普通应用程序之间的唯一不同就在于工程源文件中出现 

在 uses 子句中的 Sharemem 单元和加载插件文件的代码。任何在自身与子 DLL 之间传递字符 

串参数的应用程序都需要 Sharemem 单元 ,它是 DelphiMM.dll(Delphi 提供该文件 ) 的接 

口。要测试这个外壳 ,需要将 DelphiMM.dll 文件从 Delphi\Bin 目录复制到 path 环境变量 

所包含的路径或者应用程序所在目录中。发布最终版本时也需要同时分发该文件。 

插件通过 LoadPlugins 过程载入到这个测试外壳中 ,这个过程在主窗口的 

FormCreate 事件中调用 ,见图 2 。该过程使用 FindFirst 和 FindNext 函数在应用程序所在 

目录中查找插件文件。找到一个文件以后 ,就使用图 3 所示的 LoadPlugins 过程将其载入。 

{  在应用程序目录下查找插件文件  }

procedure TfrmMain.LoadPlugins;

var

sr:     TSearchRec;

path:   string;

Found: Integer;

begin

path := ExtractFilePath(Application.Exename);

try

Found := FindFirst(path + cPLUGIN_MASK, 0, sr);

while Found = 0 do begin

LoadPlugin(sr);

Found := FindNext(sr);

end;

finally

FindClose(sr);

end;

end;

{  加载指定的插件  DLL. }

procedure TfrmMain.LoadPlugin(sr: TSearchRec);

var

Description:   string;

LibHandle:     Integer;

DescribeProc: TPluginDescribe;

begin

LibHandle := LoadLibrary(Pchar(sr.Name));

if LibHandle <> 0 then

begin

DescribeProc := GetProcAddress(LibHandle,

cPLUGIN_DESCRIBE);

if Assigned(DescribeProc) then

begin

DescribeProc(Description);

memPlugins.Lines.Add(Description);

end

else

begin

MessageDlg('File "' + sr.Name + '" is not a valid plug-in.',

mtInformation, [mbOK], 0);

end;

end

else

MessageDlg('An error occurred loading the plug-in "' +

sr.Name + '".', mtError, [mbOK], 0);

end;

LoadPlugin 方法展示了插件机制的核心。首先 ,插件被写成 DLL 。其次 ,通过 

LoadLibrary API 它被动态的加载。一旦 DLL 被加载 ,我们就需要一个访问它所包含的过 

程和函数的途径。 API 调用 GetProcAddress 提供这种机制 ,它返回一个指向所需例程的 

指针。在我们这个简单的演示中 ,插件仅仅包含一个名为 DescribePlugin 的过程 ,由常数 

cPLUGIN_DESCRIBE 指定 ( 过程名的大小写非常重要 ,传递到 GetProcAddress 的名称必须 

与包含在 DLL 中的例程名称完全一致 ) 。如果在 DLL 中没有找到请求的例程 ,

GetProcAddree 将返回 nil,这样就允许使用 Assigned 函数测定返回值。 

为了以一种易用的方式存储指向一个函数的指针 ,有必要为用到的变量创建一个 

特定的类型。注意 ,GetProcAddress 的返回值被存储在一个变量中 ,DescribeProc,属于 

TpluginDescribe 类型。下面是它的声明 :

type

TPluginDescribe = procedure(var Desc: string); stdcall;

由于过程存在于 DLL 内部 ,它通过标准调用转换编译所有导出例程 ,因此需要使 

用 stdcall 指示字。这个过程使用一个 var 参数 ,当过程返回的时候它包含插件的描述。 

要调用刚刚获得的过程 ,只需要使用保存地址的变量作为过程名 ,后面跟上任何 

参数。就我们的例子而言 ,声明 :

DescribeProc(Description)

将会调用在插件中获得的描述过程 ,并且用描述插件功能的字符串填充 Description 变量。 

构造插件 

我们已经创建好了父应用程序 ,现在该轮到创建我们希望加载的插件了。插件 

文件是一个标准的 Delphi DLL,所以我们从 Delphi IDE 中创建一个新 DLL 工程 ,保存它。 

由于导出的插件函数将用到字符串参数 ,所以要在工程的 uses 子句中把 Sharemen 单元放 

在最前面。图 4 列出的就是我们这个简单插件的工程源文件。 

uses

Sharemem, SysUtils, Classes,

main in 'main.pas';

{$E plg.}

exports

DescribePlugin;

begin

end.

虽然插件是一个 DLL 文件 ,但是没有必要一定要给它一个 .DLL 的扩展名。实际上 ,

一个原因就足以让我们有理由改变扩展名 : 当父应用程序寻找要加载的文件时 ,新的扩展 

名可以作为特定的文件掩模。通过使用别的扩展名 ( 我们的例子使用了 *.plg),你可以 

在一定程度上确信应用程序只会载入相应的文件。编译指示字 $X 可以实现这个改变 ,也可 

以通过 Project Options 对话框的 Application 页来设置扩展名。 

第一个例子插件的代码是很简单的。图 5 显示了包含在一个新单元中的代码。注 

意 ,DescribePlugin 原型与外壳应用程序中的 TpluginDescribe 类型相一致 ,使用附加的 

export 保留字指定该过程将被导出。被导出的过程名称也将会出现在主工程源代码的 

exports 段中 ( 在图 4 中列出 ) 。 

unit main;

interface

procedure DescribePlugin(var Desc: string);

export; stdcall;

implementation

procedure DescribePlugin(var Desc: string);

begin

Desc := 'Test plugin v1.00';

end;

end.

在测试这个插件之前 ,要先把它复制到主应用程序的路径下。最简单的办法就是 

在主目录的子目录下创建插件 ,然后把输出路径设置为主路径 (Project Options 对话框 

的 Directories/Conditionals 也可以作这个设置 ) 。 

#3


调试 

现在介绍一下 Delphi 3 中一个较好的功能 : 从 IDE 中调试 DLL 的能力。在 DLL 工程 

中可以通过 Run paramaters 对话框指定某程序为宿主应用程序 ,这就是指向将调用 DLL 的 

应用程序的路径 ( 在我们这个例子中 ,就是刚刚创建的测试外壳程序 ) 。然后你就可以 

在 DLL 代码中设置断点并且按 F9 运行它 -- 就像在一个普通应用程序中做的那样。 Delphi

会运行指定的宿主程序 ,并且 ,通过编译带有调试信息的 DLL,把你指引到 DLL 代码内的 

断点处。 

---

Delphi  插件 (Plug-ins) 创建、调试与使用应用程序扩展 ( 续 )

关键词 :Delphi 控件杂项 

延伸父应用 

这个简单的插件不错 ,不过它不能做什么有用的事情。第二个例子就是纠正这个问题。 

这个插件的目标就是在父应用程序的主菜单中加入一个项目。这个菜单项目 ,当被单击 

时 ,就会执行插件内的一些代码。图 6 显示外壳程序的改进版 ,两个插件都已经加载。在 

这个版本的外壳程序中 ,一个名为 Plug-in 的新菜单项目 ,被添加到主菜单中。插件会在 

运行时加入一个菜单项。 

为了实现这个目的 ,我们必须在插件 DLL 中定义第二个接口。现有的 DLL 只导出了一个过 

程 ,DescribePlugin 。第二个插件将声明一个叫做 InitPlugin 的过程。不过 ,在这个过程 

可以在主应用程序中看到以前 ,必须修改 LoadPlugin 来配合它。 

图 7 所示的代码展示了改进的过程。 

procedure TfrmMain.LoadPlugin(sr: TSearchRec);

var

Description:   string;

LibHandle:     Integer;

DescribeProc: TPluginDescribe;

InitProc:      TPluginInit;

begin

LibHandle := LoadLibrary(Pchar(sr.Name));

if LibHandle <> 0 then

begin

//  查找  DescribePlugin.

DescribeProc := GetProcAddress(LibHandle,

cPLUGIN_DESCRIBE);

if Assigned(DescribeProc) then

begin

//  调用  DescribePlugin.

DescribeProc(Description);

memPlugins.Lines.Add(Description);

//  查找  InitPlugin.

InitProc := GetProcAddress(LibHandle, cPLUGIN_INIT);

if Assigned(InitProc) then

begin

//  调用  InitPlugin.

InitProc(mnuMain);

end;

end

else

begin

MessageDlg('File "' + sr.Name + '" is not a valid plugin.',

mtInformation, [mbOK], 0);

end;

end

else

begin

MessageDlg('An error occurred loading the plugin "' +

sr.Name + '".', mtInformation, [mbOK], 0);

end;

end;

如你所见 ,当 GetProcAddress 第一次查找调用描述过程之后 ,又调用了一次 

GetProcAddress 。这一次 ,我们要寻找的是常量 cPLUGIN_INIT,定义如下 :

const

cPLUGIN_INIT = 'InitPlugin';

返回值存储在 TpluginInit 类型的变量中 ,定义如下 :

type

TPluginInit = procedure(ParentMenu: TMainMenu); stdcall;

当 InitPlugin 方法被执行时 ,父应用程序的主菜单被当作一个参数传递给它。这个过程 

可以按照自己的意愿修改菜单。由于所有 GetProcAddress 的返回值都用 assigned 测试 ,

新版本的 LoadPlugin 过程仍然会加载不包含 InitPlugin 过程的第一个插件。在这个过程 

中第一次调用寻找 DescribePlugin 方法会通过 ,第二次寻找 InitPlugin 会无响应失败。 

现在新的接口已经定义好了 ,可以为新的 InitPlugin 方法编写代码了。像原先一样 ,

新插件的实现代码存在于一个单独的单元中。图 8 显示了修改过的包含 InitPlugin 方法 

的 main.pas 。 

unit main;

interface

uses Dialogs, Menus;

type

THolder = class

public

procedure ClickHandler(Sender: TObject);

end;

procedure DescribePlugin(var Desc: string);

export; stdcall;

procedure InitPlugin(ParentMenu: TMainMenu);

export; stdcall;

var

Holder: THolder;

implementation

procedure DescribePlugin(var Desc: string);

begin

Desc := 'Test plugin 2 - Menu test';

end;

procedure InitPlugin(ParentMenu: TMainMenu);

var

i: TMenuItem;

begin

//  创建新菜单项 .

i := NewItem('Plugin &Test', scNone, False, True,

Holder.ClickHandler, 0, 'mnuTest');

ParentMenu.Items[1].Add(i);

end;

procedure THolder.ClickHandler;

begin

ShowMessage('Clicked!');

end;

initialization

Holder := THolder.Create;

finalization

Holder.Free;

end.

很明显 ,对原始插件的第一个改变就是增加了 InitPlugin 过程。像原先一样 ,带有 

export 关键字的原型被加入到单元顶端的列表中 ,过程名也被加入到工程源代码的 

exports 子句列表中。这个过程使用 NewItem 函数创建一个新的菜单项 ,返回值是 TmenuItem

对象。新菜单项通过下列语句被加入到应用程序主菜单中 :

ParentMenu.Items[1].Add(I);

在测试外壳主菜单上的 Items[1] 是菜单项 Plug-in,所以这个语句在 Plugin 菜单条上添 

加一个叫 Plug-in Test 的菜单项。 

为了处理对新菜单项的响应 ,作为它的第五个参数 ,NewItem 可以接受一个 TNotifyEvent

类型的过程 ,这个过程将在菜单项被点击时调用。不幸的是 ,按照定义 ,这种类型的过程 

是一个对象方法 ,然而在我们的插件中并没有对象。如果我们想用通常的指针来指向函数 ,

那么得到的将只会是 Delphi 编译器的抱怨。所以 ,唯一的解决办法就是创建一个处理菜单 

点击的对象。这就是 Tholder 类的用处。它只有一个方法 ,是一个叫做 ClickHandler 的过程。 

一个叫做 Holder 的全局变量 ,在修改过的 main.pas 的 var 段中被声明为 Tholder 类型 ,并且 

在单元的 initialization 段中被创建。现在我们就有一个对象了 ,我们可以拿它的方法 

(Holder.ClickHandler) 当作 NewItem 函数的参数。 

搞了这一通 ,ClickHandler 除了显示一个 "Clicked!" 消息对话框以外什么以没干。也 

许这不怎么有趣 ,不过它仍然证明了一点 : 插件 DLL 成功的修改了父应用的主菜单 ,表现 

了它的新用途。并且如同第一个例子一样 ,不管这个插件在不在应用程序都能执行。 

由于我们创建了一个对象来处理菜单点击 ,那么在不再需要这个插件时 ,就要释放这个 

对象。修改后的单元中会在 finalization 段中处理这件事情。 Finalization 端时与 

initialization 段相对应的 ,如果前面有一个 initialization 段 ,那么在应用程序终止时 

finalization 段一定会得到执行。把下面的语句 

Holder.Free

加到 finalization 段中 ,以确保 Holder 对象会被正确的释放。 

显而易见 ,虽然这个插件只是修改了外壳应用的主菜单 ,但是它可以轻易地操纵传递 

到 InitPlugin 过程中的任何其他对象。如果有必要 ,插件也可以打开自己的对话框 ,向 

列表框 (List boxes) 和树状视图 (tree views) 中添加项目 ,或者在画布 (canvas)

中绘画。 

事件驱动的插件 

到现在为止我们所描述的技术可以产生一种通用的扩展应用程序的方法。通过增加新菜 

单、窗体和对话框 ,就可以实现全新的功能而不必对父应用做任何修改。不过仍然有一个 

限制 : 这只是一种单侧 (one-sided) 机制。正如所看到的 ,系统依赖用户的某些操作才 

能启动插件代码 ,比如点击菜单或者类似的动作。代码运行起来以后 ,又

#4


又要依靠另外一个 

用户动作来停止它 ,例如 ,关闭插件可能已经打开的窗体。克服这种缺陷的一种可行的方 

法就是使插件可以响应父应用中的动作 -- 模仿在 Delphi 中工作地很好的事件驱动编程模 

型的确有效。 

在最后一个例子插件中 ,我们将创建一种机制 ,插件可以藉此响应父应用中产生的事件。 

通常情况下 ,可以通过判定需要触发哪些事件、在父应用中为每个事件创建一个 Tlist 对象 

来实现。然后每个 Tlist 对象都被传递到插件的初始化过程中 ,如果插件想在某个事件中 

执行动作 ,它就把负责执行的函数地址加入到对应的 TList 中。父应用在适当的时刻循环 

这些函数指针的列表 ,按次序调用每个函数。通过这种方法 ,就为多个插件在同一事件中 

执行动作提供了可能。 

应用程序产生的事件完全依赖于程序已确定的功能。例如 ,一个 TCP/IP 网络应用程序可能 

希望通过 TclientSocket 的 onRead 事件通知插件数据抵达 ,而一个图形应用程序可能对调 

色板的变化更感兴趣。 

为了说明事件驱动的插件应答的概念 ,我们将创建一个用于限制主窗口最小尺寸的插件。 

这个例子有点儿造作 ,因为把这个功能做到应用程序里边会比这简单的多。不过这个例子 

的优点在语容易编码而且易于理解 ,而这正是本文想要做到的。 

很明显 ,我们要做的第一件事情就是决定到底要产生哪些事件。在本例中 ,答案很简单 :

要限制一个应用程序窗口的尺寸 ,有必要捕获并且修改 Windows 消息 WM_GETMINMAXSINFO 。 

因此 ,要创建一个完成这项功能的插件 ,我们必须捕获这个消息并且在这个消息处理器中 

调用插件例程。这就是我们要创建的事件。 

接下来我们要创建一个 TList 来处理这个事件。在主窗体的 initialization 段中将会创建 

lstMinMax 对象 ,然后 ,创建一个消息处理器来捕获 Windows 消息 WM_GETMINMAXINFO 。图 9 中 

的代码显示了这个消息处理器。 

{  捕获  WM_GETMINMAXINFO.  为每个消息调用插件例程 . }

procedure TfrmMain.MinMaxInfo(var msg: TMessage);

var

m: PMinMaxInfo;   file:// 在  Windows.pas  中定义 .

i: Integer;

begin

m := pointer(msg.Lparam);

for i := 0 to lstMinMax.count -1 do begin

TResizeProc(lstMinMax[i])(m.ptMinTrackSize.x,

m.ptMinTrackSize.y);

end;

end;

外壳应用的 LoadPlugin 过程必须再次修改以便调用初始化例程。这个新初始化函数把 

我们的 TList 当作参数接受 ,在其中加入修改消息参数的函数地址。图 10 显示了 LoadPlugin

过程的最终版本 ,它可以执行到目前为止所讨论的全部几个插件的初始化工作。 

{  加载指定的插件 DLL. }

procedure TfrmMain.LoadPlugin(sr: TSearchRec);

var

Description:   string;

LibHandle:     Integer;

DescribeProc: TPluginDescribe;

InitProc:      TPluginInit;

InitEvents:TInitPluginEvents;

begin

LibHandle:=LoadLibrary(Pchar(sr.Name));

ifLibHandle<>0then

begin

// 查找 DescribePlugin.

DescribeProc:=GetProcAddress(LibHandle, cPLUGIN_DESCRIBE);

if Assigned(DescribeProc) then

begin

//  调用  DescribePlugin.

DescribeProc(Description);

memPlugins.Lines.Add(Description);

file:// 查找 InitPlugin.

InitProc := GetProcAddress(LibHandle, cPLUGIN_INIT);

if Assigned(InitProc) then

begin

file:// 调用 InitPlugin.

InitProc(mnuMain);

end;

//  为第三方插件查找  InitPluginEvents

InitEvents := GetProcAddress(LibHandle,

cPLUGIN_INITEVENTS);

if Assigned(InitEvents) then

begin

//  调用  InitPlugin.

InitEvents(lstMinMax);

end;

end

else

begin

MessageDlg('File "' + sr.Name +

'" is not a valid plugin.',

mtInformation, [mbOK], 0);

end;

end

else

begin

MessageDlg('An error occurred loading the plugin "' +

sr.Name + '".', mtInformation, [mbOK], 0);

end;

end;

图  10: LoadPlugin  的最终版本 

最后一步是创建插件自身。如同前面的几个例子 ,插件展示一个标志自身的描述过程。 

它也带有一个初始化例程 ,在本例中只是接受一个 TList 作为参数。最后 ,它还包含一个 

没有引出 (Export) 的历程 ,叫做 AlterMinTrackSize,它将修改传递给它的数值。图 11

显示了最终插件的完整代码。 

unit main;

interface

uses Dialogs, Menus, classes;

procedure DescribePlugin(var Desc: string);

export; stdcall;

procedure InitPluginEvents(lstResize: TList);

export; stdcall;

procedure AlterMinTrackSize(var x, y: Integer); stdcall;

implementation

procedure DescribePlugin(var Desc: string);

begin

Desc := 'Test plugin 3 - MinMax';

end;

procedure InitPluginEvents(lstResize: TList);

begin

lstResize.Add(@AlterMinTrackSize);

end;

procedure AlterMinTrackSize(var x, y: Integer);

begin

x := 270;

y := 220;

end;

end.

InitPluginEvents 过程是这个插件的初始化例程。它接受一个 TList 作为参数。这个 

TList 就是在父应用程序中创建的保存相应函数地址的列表。下面的语句 :

lstResize.Add(@AlterMinTrackSize);

把 AlterMinTrackSize 函数的地址加入到了这个列表中。它被声明为类型 stdcall 以便 

与其他过程相配 ,不过用不着 export 指示字。由于函数被直接通过它的地址调用 ,所以也 

就没有必要按照通常的方式把它从 DLL 中引出。 

所以 ,事件序列如下所列 :

1 、   在应用程序初始化时 ,创建一个 TList 对象。 

2 、   在启动时这个列表被传递到插件的初始化过程 InitPluginEvents 中。 

3 、   插件过程把一个过程的地址加入到列表中。 

4 、   每次窗口大小改变时所产生的 Windows 消息 WM_GETMINMAXINFO 被我们的应用程序所捕获。 

5 、   该消息被我们的消息处理器 TfrmMain.MainMaxInfo 所处理 ,见图 10 。 

6 、   消息处理器遍历列表并调用它所包含的函数 ,把当前的 X 和 Y 最小窗口尺寸作为参数传递 

。要注意 ,TList 类只是存储指针 ,所以如果想用保存的地址做些什么事情的话 ,我们必须 

把指针转换成所需要的类型 -- 在本例中 ,要转换成 TresizeProc 。 

TResizeProc = procedure (var x, y: Integer); stdcall;

7 、   插件过程 AlterMinTrackSize( 列表中的指针所指向的 ),接受 X 和 Y 值作为可变的 var

参数并且修改它们。 

8 、   控制权返回到父应用的消息处理器 ,按照最小窗口尺寸的新值继续运行下去。 

9 、   应用程序退出时 TList 会在主代码的 finalization 段被释放。 

结论 

使用该体系结构时 ,可能利用 Delphi 提供的 package 功能是个不错的主意。在通常情况 

下 ,我不是一个分割运行时模块的*爱好者 ,但是当你认为任一包含大量代码的 Delphi

DLL 超过 200KB 时 ,它就开始变得有意义了。 

这篇文章应该还是有些用处的 ,至少它可以让你思考一些程序设计方面的问题 ,比如 

如何让它变得更加灵活。我知道如果我在以前的应用程序中使用一些这种技术的话 ,我就 

可以省掉在修改程序方面的好多工作。我并不想把插件作为一种通用的解决方案。很明显 ,

有些情况下额外的复杂度无法验证其正确性 ,或者应用程序压根儿就不打算把自身搞成 

几块可扩展的单元。还有一些其它的方法也可以达成同样的效果。 Delphi 自身提供了一个 

接口来创作能集成到 IDE 中的模块 ,比起我所说明的技术这种方法更加面向对象 ( 或者说 

更 " 干净 "),而我也确信你可以在自己的应用中模仿这一技术。在运行时加载 Delphi 包 

也不是做不到的。探索一下这种可能性吧。 

[ 本文所介绍的技术在 Delphi 4 下工作的很好。实际上 ,Delphi 4 增加了工程选项 ,使 

这类应用程序加强 DLL(application-plus-DLL) 的开发变得更加容易了。 ]

版权声明:
以上引用Kingron大虾的资料

#5


厉害,受教了,一定给分,还有没有兄弟发言啊?