Delphi 插件框架实战:Tangram 模块化架构与热插拔设计
简介Tangram Plugin Framework 是一套面向 Delphi 开发者的模块化插件框架源码包适合希望为 Object Pascal 项目引入可插拔架构的中高级开发者用于解决单一静态代码结构难以灵活扩展与定制的问题。压缩包共 404 个文件约 1.2MB以 140 个 pas 单元、50 个 dfm 窗体、30 个 dproj 工程文件为主并包含 dpr、dpk、groupproj、bdsproj 等工程与包定义文件以及 res、cfg、ini、xml 等配置资源另有少量 exe、dll、chm 与 mdb 示例数据覆盖框架核心、数据库支持、权限控制、配置工具等多个子模块。资源围绕插件注册与发现、插件间通信、版本兼容、动态加载与卸载、资源管理等关键机制展开读者可借此研究插件生命周期管理、接口规范设计与主程序解耦思路并参考其目录组织方式搭建自己的插件化应用。目前已有 744 人学习下载。1. 拆开一个 Delphi 插件框架压缩包先想清楚它到底解决什么问题拿到一个名为 Tangram Plugin FrameWork 的压缩包很多人的第一反应是解压、找 Demo、双击运行。但如果你做过几年 Delphi 桌面项目就会知道真正值钱的不是那几个窗体文件而是它背后那套「主程序不动、功能靠插件拼」的架构思路。Tangram 这个词本身就暗示了拼图式组合宿主程序提供画布和基础服务每个插件是一块独立拼图运行时动态加载、按需卸载。它解决的核心痛点是 Delphi 项目越做越大之后编译一次要几分钟、改一个模块影响全系统、交付时只能整包替换。适合谁适合手里有多个中小型 Delphi 桌面系统、需要按客户定制功能、又不想维护多套分支的团队。这一章先把「模块化插件框架」这件事讲透后面再动手。2. Tangram 的插件契约怎么定接口、生命周期与通信总线2.1 为什么先定接口而不是先写加载器很多 Delphi 插件方案翻车不是因为加载器写错了而是因为接口没定好。常见做法是让插件直接引用主程序的单元编译期耦合结果插件一多主程序单元成了公共厕所谁都能改。Tangram 这类框架的正确姿势是主程序和插件之间只通过一个「契约单元」通信这个单元只包含接口定义、常量、基础类型不包含任何窗体或业务逻辑。插件编译时只引用契约单元主程序运行时也只认接口不认具体类。我一般会把契约单元命名为Tangram.Contract.pas里面至少定义三样东西插件元信息接口、插件生命周期接口、宿主服务接口。元信息告诉宿主「我是谁、版本多少、依赖什么」生命周期负责Initialize和Finalize宿主服务则让插件能反向调用主程序提供的日志、配置、权限等能力。// Tangram.Contract.pas —— 只放接口不放实现 unit Tangram.Contract; interface type // 插件元信息宿主靠它做展示和依赖检查 IPluginInfo interface [{A1B2C3D4-0001-4000-9000-000000000001}] function GetName: string; function GetVersion: string; function GetAuthor: string; function GetDependencies: TArraystring; // 依赖的其他插件名 end; // 宿主服务插件反向调用主程序能力 IHostServices interface [{A1B2C3D4-0002-4000-9000-000000000002}] procedure Log(const Msg: string); function GetConfig(const Key: string): string; procedure RegisterMenuItem(const Caption: string; const Action: TProc); end; // 插件生命周期宿主只调这两个方法 IPlugin interface [{A1B2C3D4-0003-4000-9000-000000000003}] function GetInfo: IPluginInfo; procedure Initialize(const Host: IHostServices); procedure Finalize; end; implementation end.这段代码的关键在于接口全部带 GUID这是 Delphi 里跨模块传递接口而不依赖具体实现的基础。GetDependencies返回字符串数组宿主在加载前先做拓扑排序避免插件 A 依赖插件 B 但 B 还没加载的情况。IHostServices里的RegisterMenuItem用匿名方法TProc这样插件不需要引用主程序的菜单单元主程序也不用知道插件想加什么菜单。参数说明GUID 必须唯一可以用 Delphi 自带的CtrlShiftG生成TArraystring需要System.Generics.Collections或直接使用动态数组匿名方法在 Delphi 2009 之后才支持如果团队还在用更老的版本需要改成接口回调。2.2 插件 DLL 的导出函数与加载时机契约定好之后每个插件编译成一个 DLL。DLL 必须导出至少一个标准函数宿主通过GetProcAddress拿到它再拿到IPlugin接口。常见做法是导出两个函数CreatePlugin和DestroyPlugin或者只导出一个返回接口的函数由接口自身管理生命周期。// PluginDemo.Main.pas —— 一个最小插件的实现 unit PluginDemo.Main; interface uses Tangram.Contract; type TPluginInfo class(TInterfacedObject, IPluginInfo) public function GetName: string; function GetVersion: string; function GetAuthor: string; function GetDependencies: TArraystring; end; TPluginDemo class(TInterfacedObject, IPlugin) private FHost: IHostServices; public function GetInfo: IPluginInfo; procedure Initialize(const Host: IHostServices); procedure Finalize; end; // 唯一导出函数宿主用 GetProcAddress 获取 function CreatePlugin: IPlugin; stdcall; implementation function TPluginInfo.GetName: string; begin Result : DemoPlugin; end; function TPluginInfo.GetVersion: string; begin Result : 1.0.0; end; function TPluginInfo.GetAuthor: string; begin Result : 某开发者; end; function TPluginInfo.GetDependencies: TArraystring; begin Result : nil; // 无依赖 end; function TPluginDemo.GetInfo: IPluginInfo; begin Result : TPluginInfo.Create; end; procedure TPluginDemo.Initialize(const Host: IHostServices); begin FHost : Host; FHost.Log(DemoPlugin 初始化); FHost.RegisterMenuItem(演示功能, procedure begin FHost.Log(演示功能被点击); end); end; procedure TPluginDemo.Finalize; begin FHost : nil; end; function CreatePlugin: IPlugin; stdcall; begin Result : TPluginDemo.Create; end; exports CreatePlugin; end.逻辑说明TPluginDemo同时实现了IPlugin内部持有IHostServices引用。Initialize里通过宿主服务注册菜单项匿名方法捕获了FHost所以点击菜单时能继续调用宿主日志。exports段只导出CreatePlugin宿主加载 DLL 后调用它拿到接口后续全部走接口调用不需要再导出销毁函数因为接口引用计数归零时对象自动释放。参数说明stdcall是 Windows 下 DLL 导出的标准调用约定Delphi 默认是register跨模块必须显式写stdcall。TInterfacedObject提供了引用计数实现如果自己实现IInterface的三个方法要小心循环引用导致内存泄漏。2.3 宿主加载器从目录扫描到依赖排序宿主端的加载器负责三件事扫描插件目录、读取元信息、按依赖顺序加载。我一般会先加载所有 DLL 拿到IPluginInfo构建依赖图再做拓扑排序最后依次调用Initialize。这样即使插件 B 依赖插件 A也能保证 A 先初始化。// Tangram.Loader.pas —— 宿主加载器核心逻辑 unit Tangram.Loader; interface uses System.SysUtils, System.Classes, System.Generics.Collections, Tangram.Contract; type TPluginLoader class private FPlugins: TDictionarystring, IPlugin; FHost: IHostServices; function LoadDLL(const FileName: string): IPlugin; procedure SortByDependencies(List: TListIPlugin); public constructor Create(const Host: IHostServices); destructor Destroy; override; procedure LoadFromDir(const Dir: string); procedure UnloadAll; end; implementation constructor TPluginLoader.Create(const Host: IHostServices); begin inherited Create; FHost : Host; FPlugins : TDictionarystring, IPlugin.Create; end; destructor TPluginLoader.Destroy; begin UnloadAll; FPlugins.Free; inherited; end; function TPluginLoader.LoadDLL(const FileName: string): IPlugin; var Handle: THandle; Proc: function: IPlugin; stdcall; begin Handle : LoadLibrary(PChar(FileName)); if Handle 0 then raise Exception.CreateFmt(加载 %s 失败错误码 %d, [FileName, GetLastError]); try Proc : GetProcAddress(Handle, CreatePlugin); if not Assigned(Proc) then raise Exception.CreateFmt(%s 未导出 CreatePlugin, [FileName]); Result : Proc; except FreeLibrary(Handle); raise; end; end; procedure TPluginLoader.LoadFromDir(const Dir: string); var SR: TSearchRec; List: TListIPlugin; Plugin: IPlugin; begin List : TListIPlugin.Create; try if FindFirst(IncludeTrailingPathDelimiter(Dir) *.dll, faAnyFile, SR) 0 then begin repeat if (SR.Attr and faDirectory) 0 then begin Plugin : LoadDLL(IncludeTrailingPathDelimiter(Dir) SR.Name); List.Add(Plugin); end; until FindNext(SR) 0; FindClose(SR); end; SortByDependencies(List); for Plugin in List do begin FPlugins.Add(Plugin.GetInfo.GetName, Plugin); Plugin.Initialize(FHost); end; finally List.Free; end; end; procedure TPluginLoader.SortByDependencies(List: TListIPlugin); begin // 简化版按依赖名排序实际项目建议用拓扑排序 List.Sort(TComparerIPlugin.Construct( function(const L, R: IPlugin): Integer begin Result : Length(L.GetInfo.GetDependencies) - Length(R.GetInfo.GetDependencies); end)); end; procedure TPluginLoader.UnloadAll; var Pair: TPairstring, IPlugin; begin for Pair in FPlugins do Pair.Value.Finalize; FPlugins.Clear; end; end.逻辑说明LoadDLL用LoadLibrary加载 DLLGetProcAddress取CreatePlugin地址转成函数指针后调用。LoadFromDir遍历目录下所有 DLL先全部加载到列表再排序最后依次Initialize。SortByDependencies这里用了简化版按依赖数量排序真实项目里应该用拓扑排序否则依赖链一深就会乱序。参数说明LoadLibrary的路径建议用绝对路径避免当前工作目录变化导致找不到 DLL。GetLastError在LoadLibrary失败后立即调用否则可能被其他 API 覆盖。FPlugins用TDictionary按插件名索引方便后续按名查找或卸载。3. 把 Tangram 跑起来从零搭建宿主与第一个插件3.1 宿主工程的最小骨架宿主工程不需要复杂一个 VCL 窗体程序就够。窗体上放一个TMemo显示日志一个TButton触发加载。关键是宿主必须实现IHostServices并把自身实例传给加载器。// HostMain.pas —— 宿主主窗体 unit HostMain; interface uses Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, Tangram.Contract, Tangram.Loader; type TMainForm class(TForm, IHostServices) MemoLog: TMemo; BtnLoad: TButton; procedure BtnLoadClick(Sender: TObject); private FLoader: TPluginLoader; public procedure Log(const Msg: string); function GetConfig(const Key: string): string; procedure RegisterMenuItem(const Caption: string; const Action: TProc); end; var MainForm: TMainForm; implementation {$R *.dfm} procedure TMainForm.Log(const Msg: string); begin MemoLog.Lines.Add(Format([%s] %s, [TimeToStr(Now), Msg])); end; function TMainForm.GetConfig(const Key: string): string; begin Result : ; // 实际项目从 ini 或注册表读取 end; procedure TMainForm.RegisterMenuItem(const Caption: string; const Action: TProc); begin Log(插件注册菜单 Caption); // 实际项目动态创建 TMenuItem 并绑定 Action end; procedure TMainForm.BtnLoadClick(Sender: TObject); begin if not Assigned(FLoader) then FLoader : TPluginLoader.Create(Self); FLoader.LoadFromDir(ExtractFilePath(Application.ExeName) Plugins); Log(插件加载完成); end; end.逻辑说明TMainForm同时继承TForm并实现IHostServices这样Self可以直接传给TPluginLoader.Create。BtnLoadClick里从 exe 同级目录的Plugins文件夹加载所有 DLL。RegisterMenuItem目前只打日志实际项目里应该动态创建菜单项并绑定Action。参数说明ExtractFilePath(Application.ExeName)拿到 exe 所在目录拼接Plugins子目录。如果插件放在其他位置改成配置项读取。FLoader延迟创建避免窗体创建时就加载插件导致启动慢。3.2 编译插件 DLL 的目录与输出设置插件工程编译时输出路径要指向宿主的Plugins目录或者编译后手动拷贝。Delphi 项目里在Project Options的Delphi Compiler下设置Output directory为..\Host\Plugins。同时要确保插件工程引用了Tangram.Contract.pas但不要引用宿主工程的其他单元。# 命令行编译插件假设使用 dcc32 dcc32 -B PluginDemo.dpr -E..\Host\Plugins -NSSystem;Vcl;Tangram逻辑说明-B强制重新编译-E指定输出目录-NSSystem;Vcl;Tangram指定单元搜索路径。实际项目里更推荐用.dproj配合 MSBuild但命令行方式适合 CI 环境。参数说明-E后面的路径要存在否则编译失败。-NS里的Tangram是契约单元所在目录如果契约单元在独立文件夹要写全路径。3.3 验证插件是否真的被加载加载完成后在宿主日志里应该能看到插件Initialize里打的日志。如果没看到按以下顺序排查DLL 是否在Plugins目录、是否导出了CreatePlugin、LoadLibrary是否返回 0、GetProcAddress是否拿到地址。我一般会在LoadDLL里加详细日志把每个 DLL 的加载结果都打出来。// 在 LoadDLL 里加日志 FHost.Log(Format(尝试加载%s, [FileName])); Handle : LoadLibrary(PChar(FileName)); if Handle 0 then begin FHost.Log(Format(加载失败错误码%d, [GetLastError])); Exit(nil); end; FHost.Log(加载成功查找 CreatePlugin);逻辑说明日志要打在关键分支上这样即使插件没加载成功也能从日志看出卡在哪一步。GetLastError返回 0 表示成功但LoadLibrary失败时返回非零常见错误码 126 表示找不到模块193 表示不是有效的 Win32 程序。参数说明如果插件是 64 位而宿主是 32 位LoadLibrary会返回 193。Delphi 项目要确保宿主和插件的目标平台一致要么都是 32 位要么都是 64 位。4. 避坑与排查Delphi 插件框架最容易翻车的五个地方4.1 接口跨模块传递导致内存泄漏现象插件卸载后宿主内存不降反升反复加载卸载几次后内存持续增长。原因Delphi 的接口引用计数在跨 DLL 边界时如果 DLL 和 EXE 使用不同的内存管理器_Release调用可能不会正确释放对象。解决在插件 DLL 的initialization段调用IsMultiThread : True并确保使用ShareMem或者统一内存管理器。更稳妥的做法是插件对象由 DLL 内部管理宿主只持有接口DLL 卸载前先调用Finalize并释放所有接口引用。4.2 插件依赖顺序错乱导致初始化失败现象插件 B 在Initialize里调用插件 A 提供的服务但 A 还没初始化报空指针。原因加载器按文件名顺序加载没有做依赖排序。解决在IPluginInfo里返回依赖列表加载器先构建依赖图用拓扑排序确定加载顺序。如果依赖成环直接报错并拒绝加载不要试图自动解决。4.3 宿主关闭时插件未正确卸载现象关闭宿主时弹出异常提示某个 DLL 无法释放。原因插件在Finalize里没有释放自己创建的窗体或线程DLL 引用计数不为零。解决在Finalize里显式释放所有资源窗体用Free而不是Release线程要Terminate后WaitFor。宿主在FormClose里先调用FLoader.UnloadAll再释放加载器。4.4 调试插件时断点不生效现象在插件代码里下断点运行时断点变空心提示无法加载符号。原因宿主加载的是编译后的 DLL而 IDE 调试的是插件工程两者没有关联。解决在宿主工程的Run参数里设置Host Application为宿主 exe然后调试宿主或者把插件工程和宿主工程放在同一个项目组里用LoadLibrary的调试模式加载。我一般会在插件 DLL 的CreatePlugin里加OutputDebugString用调试工具查看输出。4.5 插件版本与宿主契约不匹配现象新插件在老宿主上加载后调用某个接口方法时崩溃。原因契约单元新增了方法但老宿主的接口 GUID 没变虚函数表偏移错位。解决每次修改契约单元必须重新生成接口 GUID并在IPluginInfo里返回宿主最低版本要求。宿主加载插件前先检查版本不匹配就拒绝加载并提示用户升级。5. 进阶让 Tangram 支持热插拔与插件间通信5.1 热插拔的实现边界热插拔不是真的「拔了再插」而是「卸载旧版本、加载新版本」。Delphi 的 DLL 一旦LoadLibrary在FreeLibrary之前无法替换文件。所以热插拔的正确流程是先调用插件的Finalize释放所有接口引用再FreeLibrary然后拷贝新 DLL 覆盖最后重新LoadLibrary。这里有个坑如果插件注册了菜单项或窗体必须先反注册否则宿主还持有插件的匿名方法引用FreeLibrary会失败。procedure TPluginLoader.ReloadPlugin(const Name, NewFile: string); var Old: IPlugin; begin if FPlugins.TryGetValue(Name, Old) then begin Old.Finalize; FPlugins.Remove(Name); Old : nil; // 引用计数归零DLL 可释放 end; // 此处可拷贝 NewFile 覆盖旧 DLL LoadDLL(NewFile); end;逻辑说明Old : nil是关键只有接口引用计数归零DLL 才会真正卸载。如果宿主其他地方还持有这个接口FreeLibrary不会立即生效需要确保所有引用都释放。参数说明NewFile应该是新 DLL 的完整路径覆盖前先备份旧文件防止加载失败后无法回滚。5.2 插件间通信事件总线还是直接引用插件 A 想调用插件 B 的功能有两种做法一是宿主提供事件总线插件通过总线发布订阅二是插件 B 把接口注册到宿主插件 A 从宿主查询。我一般推荐事件总线因为解耦更彻底。宿主实现一个简单的IEventBus插件通过IHostServices获取总线实例发布事件时带上事件类型和数据订阅者按类型过滤。type IEventBus interface procedure Publish(const Topic: string; const Data: TObject); procedure Subscribe(const Topic: string; const Handler: TProcTObject); end;逻辑说明Publish和Subscribe都用字符串主题避免插件之间引用具体类型。Data用TObject基类实际传递时可以用TJSONObject或自定义数据类。订阅者要记得在Finalize里取消订阅否则宿主还持有匿名方法引用导致插件无法卸载。参数说明主题命名建议用插件名.功能名格式避免冲突。TProcTObject是泛型匿名方法Delphi 2009 之后支持。5.3 用配置文件控制插件加载不是所有插件都需要每次加载。我一般会在宿主目录放一个plugins.ini列出启用和禁用的插件名加载器先读配置再决定加载哪些。[Plugins] EnabledDemoPlugin,ReportPlugin DisabledDebugPlugin// 读取配置 function TPluginLoader.IsEnabled(const Name: string): Boolean; var Ini: TIniFile; Enabled: string; begin Ini : TIniFile.Create(ExtractFilePath(ParamStr(0)) plugins.ini); try Enabled : Ini.ReadString(Plugins, Enabled, ); Result : Pos(Name, Enabled) 0; finally Ini.Free; end; end;逻辑说明TIniFile是 Delphi 自带的 ini 读写类ReadString读取启用列表Pos判断插件名是否在列表中。实际项目里可以用更严格的解析比如按逗号分割后精确匹配避免Demo匹配到DemoPlugin。参数说明ParamStr(0)是 exe 完整路径ExtractFilePath取目录。ini 文件编码建议用 ANSI 或 UTF-8 with BOM避免中文插件名乱码。5.4 一个验证插件框架是否合格的检查表检查项合格标准验证方法接口隔离插件不引用宿主业务单元查看插件 uses 列表依赖排序依赖插件先初始化构造 A 依赖 B 的场景卸载干净卸载后内存回落反复加载卸载 10 次版本校验不匹配拒绝加载改契约 GUID 后测试异常隔离单插件崩溃不影响宿主插件 Initialize 里抛异常这张表是我在多个 Delphi 项目里踩坑后总结的每次新做插件框架都拿它过一遍。尤其是异常隔离插件Initialize里抛异常时加载器必须捕获并记录不能让它冒泡到宿主主循环否则整个程序直接挂掉。5.5 我自己的习惯先写契约测试再写插件现在我做任何 Delphi 插件框架第一件事不是写加载器而是写一个契约测试工程模拟宿主实现IHostServices加载一个空插件验证接口调用、引用计数、卸载流程全部正常。这个测试工程跑通之后再写真实插件和宿主。这样做的代价是多花半天但省下的是后面几天排查内存泄漏和加载失败的时间。血泪经验是插件框架的 bug 往往不在插件本身而在契约边界上先把边界测透后面就是体力活。希望帮到你。本文还有配套的精品资源点击获取