☰
高级VB编程进阶:类、接口、异常处理与反射插件实战
2026/10/14 14:40:28 网站建设 项目流程

简介:面向已掌握VB基础语法、希望进阶的中高级开发者,这份资料以Matthew Curland《Advanced Visual Basic》知识体系为主线,覆盖OOP设计、事件处理、多线程、数据库访问、COM组件交互等高级主题,既能帮助读者厘清“高级VB到底学什么”,也提供大量可直接参考和复用的工程源码。压缩包为rar格式,共327个文件、10.33MB,类型以bas模块、cls类模块、frm窗体、vbp工程文件及ctl用户控件为主,辅以odl、ocx、dll等COM相关文件,另有pdf电子文档,兼顾理论讲解与实战代码。其中VBoost.bas、ROTHook.bas、CDECLFunctionDelegator.bas、ThreadOlePump.bas、StackAllocZero.bas等底层模块颇具价值,涉及内存分配、函数委托、线程消息泵等进阶技巧,读者可对照源码理解高级VB的实现精髓,并将相关思路直接移植到自己的项目中加速开发。目前已有355人学习下载。

1. 高级VB编程到底难在哪:从「能跑」到「扛得住」

高级VB编程,Advanced Visual Basic 这个说法,圈子里经常被误读成「多记几个冷门函数」。我在接手某公司的进销存系统改造时见过一个典型案例:同一个日志写入逻辑在三个窗体里复制了七份,改一次格式要改七处;一个查询条件用字符串拼接了两百行;数据库一超时就整个界面白屏。真正的高级不是函数记得多,而是类与接口、结构化异常处理、Win32 API 互操作、数据访问封装这四块地基打得稳。这篇笔记不聊语法手册,直接把这四条主线拆开,每段配可复现的代码,适合正在用 VB 写业务系统、又总觉得代码一复杂就改不动的人。

2. 用类模块和接口重构 VB 代码:从过程式思维切换到对象式思维

高级 VB 编程和入门 VB 最大的分水岭不是语法,而是代码组织方式。入门阶段一个窗体塞几十个事件函数,跑得通就算成功;等业务规则复杂到前后互相牵制,就发现函数级复用救不了你。类模块(Class)才是承载状态的容器,也是后面所有高级特性的地基。

2.1 为什么高级 VB 的第一课是类而不是函数

函数解决的是「重复代码」,类解决的是「分散的状态」。举个例子:某进销存系统早期版本里,三个窗体都要写日志。A同学的做法是每个窗体各自写一套文件写入代码,路径直接写死在窗体里。等部署到新机器发现日志目录不存在,三处都要改一遍。这个时候把日志逻辑抽成函数已经不够——因为每处还各自记录了自己的格式、编码、是否追加,抽函数要带一堆参数,调用方依然乱成一团。

类的思路是把这些参数变成属性,把日志写入变成方法,把「有日志发生」变成事件通知。调用方只需要 New 一次日志类,之后所有行为由这个对象自己负责,状态不再散落在各个窗体里。用 VB 写类这件事,VB6 时代就有类模块,但继承能力残缺,接口用起来也晦涩;VB.NET 才把类、接口、继承补完整。

我一般建议业务系统至少从「工具类」和「实体类」两类开始:工具类封装 IO、加解密、格式转换;实体类封装一条业务数据及其校验规则。这两类写顺手了,再谈接口多态。判断标准就一条:这段逻辑里有没有「状态」?有,就放进类;没有,才考虑函数。

2.2 一个可复用的日志类:属性、方法、事件一把抓

下面是一个文件日志类的完整实现。它同时展示了属性、方法、事件、资源释放这几件事,是很好的进阶范本:

Imports System.IO Imports System.Text Public Class FileLogger Private _logPath As String Private _append As Boolean = True ' 对外暴露日志文件路径,允许运行期调整 Public Property LogPath() As String Get Return _logPath End Get Set(ByVal value As String) _logPath = value End Set End Property ' 日志事件:UI 层可以订阅它做状态栏提示 Public Event MessageLogged(ByVal level As String, ByVal message As String) Public Sub New(ByVal path As String) _logPath = path End Sub Public Sub Log(ByVal level As String, ByVal message As String) ' 先保证目录存在,避免首次写入抛异常 Dim dir As String = Path.GetDirectoryName(_logPath) If Not String.IsNullOrEmpty(dir) AndAlso Not Directory.Exists(dir) Then Directory.CreateDirectory(dir) End If Dim line As String = String.Format("[{0}] [{1}] {2}", Now.ToString("yyyy-MM-dd HH:mm:ss"), level, message) ' Using 确保 StreamWriter 在异常时也能关闭 Using writer As New StreamWriter(_logPath, _append, Encoding.UTF8) writer.WriteLine(line) End Using RaiseEvent MessageLogged(level, message) End Sub End Class

调用端更简单:

Dim logger As New FileLogger("D:\logs\app.log") AddHandler logger.MessageLogged, Sub(l As String, m As String) Console.WriteLine("日志已写入:" & l & " - " & m) logger.Log("INFO", "系统启动")

代码里几个值得留意的点。一是 Log 方法内先创建目录,这是血泪经验——很多程序第一次跑在干净机器上就挂在日志写入,目录不存在是头号原因。二是 Using 语句,StreamWriter 实现了 IDisposable,用 Using 包起来,无论正常写完还是中途异常,文件句柄都会被释放;很多人手动写 Close,一旦前面抛异常,Close 根本执行不到。三是 _append 参数,设为 True 表示追加写,日志文件不会被覆盖。

事件的用法也说明一下:日志类不关心谁在听,只管把事件抛出去。UI 层用 AddHandler 订阅,就能在界面上看到实时日志,而日志类本身不依赖任何窗体。这是解耦最常见的落点,也是从「调用别人」到「被别人通知」的思维转变。

2.3 接口与多态:让新增日志类型不用改调用方

类解决了状态,接口解决的是「换实现不换调用」。假设同一个系统里,调试时要输出到控制台,生产要多文件记录,审计要求写数据库。如果业务代码各自调用具体类,未来每加一种记录方式,所有调用点都要跟着改。

先定义一个接口,约定「能做这件事」:

Public Interface ILogger ReadOnly Property Name() As String Sub WriteLog(ByVal level As String, ByVal message As String) End Interface

然后让 FileLogger 和 ConsoleLogger 分别实现这个接口:

Public Class FileLogger Implements ILogger Public ReadOnly Property Name() As String Implements ILogger.Name Get Return "FileLogger" End Get End Property Public Sub WriteLog(ByVal level As String, ByVal message As String) Implements ILogger.WriteLog ' 写入文本文件,实现同上一节,此处省略重复代码 End Sub End Class Public Class ConsoleLogger Implements ILogger Public ReadOnly Property Name() As String Implements ILogger.Name Get Return "ConsoleLogger" End Get End Property Public Sub WriteLog(ByVal level As String, ByVal message As String) Implements ILogger.WriteLog Console.WriteLine("[{0}] {1}", level, message) End Sub End Class

业务代码只需要面向接口声明:

Dim logs As New List(Of ILogger) logs.Add(New FileLogger()) logs.Add(New ConsoleLogger()) For Each lg As ILogger In logs lg.WriteLog("INFO", "订单已保存") Next

新增一个数据库日志类时,只要实现 ILogger,上面这段 For 循环一行都不用改。这就是接口多态的实用价值。VB6 里 Implements 写法与此类似,但 VB.NET 多了泛型 List(Of T),遍历更安全。接口相当于契约,把「做什么」和「怎么做」分开,这一条想通,代码质量能上一个台阶,后面讲反射插件框架时还会用到同一个套路。

3. Win32 API 与 COM 互操作:突破 VB 边界的两根支柱

VB 的类库再丰富,总有覆盖不到的系统底层能力。这时候要请出 Win32 API 和 COM。这两块是高级VB编程里最容易被神化、也最容易翻车的部分,需要讲清楚边界。

3.1 什么时候值得碰 Win32 API:三个典型场景

不是所有问题都要上 API。VB.NET 的 System.Environment、My.Computer 已经封装了大量系统信息,能用封装就用封装。API 调用是黑匣子,参数错一个就崩溃,调试成本比普通代码高一个量级。值得碰的场景通常是这三类:

  • 获取系统真实底层信息。比如临时目录、系统目录、屏幕物理分辨率,某些精简版系统上 .NET 封装返回的值不一定可靠,调用 GetTempPath、GetSystemMetrics 反而更准。
  • 控制窗口和进程行为。置顶窗口、鼠标穿透、任务栏隐藏、开机自启注册,这些往往要动窗口样式或注册表,标准控件没提供全部能力。
  • 和老系统做数据交换。某些行业老组件只暴露 C 接口,VB 侧没有现成封装,只能请 API 出面。

判断标准只有一个:类库做不到,或者用类库做出来的代码比 API 更绕,这时候才上。为了炫技去调 API,后面维护的人会骂人。

3.2 安全调用 API 的完整模板:StringBuilder 缓冲与句柄位宽

直接贴一个安全调用系统 API 的模板,取系统临时目录:

Imports System.Runtime.InteropServices Imports System.Text Public Class NativeMethods ' CharSet.Unicode 表示按 UTF-16 传递字符串,SetLastError 用于取错误码 <DllImport("kernel32.dll", CharSet:=CharSet.Unicode, SetLastError:=True)> Public Shared Function GetTempPath( ByVal nBufferLength As Integer, ByVal lpBuffer As StringBuilder) As Integer End Function End Class

调用:

Dim buffer As New StringBuilder(260) Dim result As Integer = NativeMethods.GetTempPath(buffer.Capacity, buffer) If result > 0 AndAlso result < buffer.Capacity Then Dim tempDir As String = buffer.ToString() MessageBox.Show(tempDir) Else Dim errCode As Integer = Marshal.GetLastWin32Error() MessageBox.Show("调用失败,错误码:" & errCode.ToString()) End If

这里有两个关键参数直接决定是否翻车。第一,输出字符串必须用 StringBuilder,不能直接用 String。String 是不可变类型,API 往缓冲区写内容时,String 内部没有预留可写空间,轻则返回空串,重则内存访问违规。StringBuilder 先按 260 长度预分配,API 往里写,再用 ToString 取回来。第二,GetTempPath 的返回值是实际写入的字符数,用它判断是否成功、是否截断,比盲目信任函数本身靠谱。声明里的 CharSet.Unicode 不能少,否则 ANSI 和 Unicode 版本错位,中文路径直接乱码。

句柄类型是另一个高频坑,给个对照表:

C++ 原始类型常见错误声明VB.NET 建议
HANDLELong / IntegerIntPtr
DWORDLongUInteger(计数类)
BOOLBooleanBoolean
LPTSTR 输出缓冲StringStringBuilder

HANDLE 在 64 位系统上是 64 位指针,VB6 时代用 Long 是因为当年系统是 32 位;现在直接照搬旧代码声明 Integer 或 Long,高位被截断,API 调用大概率立刻崩溃。这也是很多老项目迁移到 64 位时集中翻车的原因,抄网上旧代码之前先核对这一列。

3.3 COM 互操作:把老组件请进来,把新类送出去

COM 是 Windows 上老牌组件协议,VB6 写的组件天然就是 COM 组件。现在的业务系统里,COM 互操作主要出现在两个方向。

方向一:VB.NET 调用旧的 COM 组件。老系统里已经稳定的组件没必要重写,VB.NET 添加组件引用后可以直接 New,也可以用 CreateObject 做后期绑定。后期绑定的好处是不依赖注册版本,坏处是没有智能提示、运行期才暴露错误,我一般只在调用方是动态脚本时才用。

方向二:把 VB.NET 写的类暴露成 COM,给老 VB6 程序或其他脚本调用。常见做法是给类打上 ComVisible 和 Guid 特性,然后编译注册:

Imports System.Runtime.InteropServices <ComVisible(True)> <Guid("3F2504E0-4F89-41D3-9A0C-0305E82C3301")> Public Class CustomerHelper Public Function GetCustomerName(ByVal customerId As Integer) As String ' 这里实现真实业务查询,注意 COM 接口尽量只暴露简单类型 Return "客户-" & customerId.ToString() End Function End Class

编译后用 SDK 自带的 regasm 工具注册,加上 /codebase 参数表示允许从非全局程序集缓存位置加载。这里最容易被忽略的是位数匹配:32 位注册工具注册的组件,64 位进程调不到,反之亦然。老系统如果是 32 位,新模块就要用 32 位模式运行,两边必须一致。COM 互操作调试起来比普通代码难十倍,先把位数、注册状态这两个前提查清楚,能省掉大半折腾时间。

4. VB 的结构化异常处理与数据访问:把稳定性写进代码结构

业务系统里线上事故的根源,一半是数据写错了,一半是异常没接住。前者靠事务和参数化查询,后者靠结构化异常处理。这一章把两件事一起解决。

4.1 先别急着写业务:用 Try/Catch/Finally 框住不稳定操作

VB6 时代写异常处理是 On Error Resume Next 和 On Error GoTo,前者吞掉错误继续跑,后者让代码跳来跳去。On Error Resume Next 是很多老系统「看起来正常、一深用就崩」的根源——错误被静默吞掉,等数据写坏了才发现。VB.NET 提供了结构化异常处理,Try 块包住可能出错的操作,Catch 捕获,Finally 无论成败都执行清理:

Dim conn As New SqlConnection(_connectionString) Try conn.Open() ' 业务操作 Catch ex As Exception ' 至少写一条日志,不要空 Catch Console.WriteLine(ex.ToString()) Throw ' 无法恢复就原样抛出,让上层决定 Finally conn.Close() End Try

三条铁律,照着做能少踩一半坑。第一,不要用空 Catch 吞异常,至少写一条日志。日志都没有,线上问题全靠猜,猜错的代价是加倍的。第二,需要清理资源时用 Finally,或者直接用 Using。第三,捕获后如果无法恢复,用 Throw 原样抛出去,不要改成返回 False 假装成功——假装成功比崩溃可怕得多,崩溃至少能被人看见。

4.2 数据访问层最小封装:参数化查询与事务一起上

数据库操作是业务系统里最不能靠运气的部分。我一般会封装一个最小的数据访问类,把连接管理、参数化查询、事务三个能力锁在同一个类里:

Imports System.Data Imports System.Data.SqlClient Public Class DataAccess Private _connectionString As String Public Sub New(ByVal connectionString As String) _connectionString = connectionString End Sub ' 查询单个值:适合统计、判断存在性 Public Function ExecuteScalar( ByVal sql As String, ByVal ParamArray parameters() As SqlParameter) As Object Using conn As New SqlConnection(_connectionString) conn.Open() Using cmd As New SqlCommand(sql, conn) cmd.CommandTimeout = 30 If parameters IsNot Nothing Then cmd.Parameters.AddRange(parameters) End If Return cmd.ExecuteScalar() End Using End Using End Function ' 事务内执行写入:要么全部成功,要么全部回滚 Public Function ExecuteInTransaction( ByVal sql As String, ByVal parameters() As SqlParameter) As Boolean Using conn As New SqlConnection(_connectionString) conn.Open() Dim tx As SqlTransaction = conn.BeginTransaction(IsolationLevel.ReadCommitted) Try Using cmd As New SqlCommand(sql, conn, tx) If parameters IsNot Nothing Then cmd.Parameters.AddRange(parameters) End If cmd.ExecuteNonQuery() End Using tx.Commit() Return True Catch tx.Rollback() Throw End Try End Using End Function End Class

调用方式:

Dim db As New DataAccess("server=某服务器;database=某库;user=某用户;password=某密码;") Dim p As New SqlParameter("@orderId", SqlDbType.Int) p.Value = 1001 Dim count As Object = db.ExecuteScalar("SELECT COUNT(*) FROM orders WHERE order_id=@orderId", p)

这个封装里有三个设计点值得讲。一是所有 SQL 一概用参数对象传值,绝不拼字符串。拼字符串的问题不只是慢,更是安全漏洞:用户输入里带一个单引号就能改变 SQL 语义,参数化查询让数据库把参数当数据而不当代码解析,这一条直接决定系统敢不敢上线。二是两个方法都用 Using 包连接对象,用完即还回连接池;很多性能问题不是数据库慢,是连接没释放,连接池被占满,后面的请求全部排队超时。三是事务方法里 Catch 之后 Rollback 再 Throw,错误没被吞掉,调用方能看到真实故障。

4.3 三个必调参数:超时、隔离级别、连接释放

这一小节专门说参数怎么设。CommandTimeout 默认 30 秒,报表型查询建议放宽到 60 或 120,但不要无脑放大——超时设成 0 表示无限等待,一条烂 SQL 能把整个系统拖死,我见过某公司把超时改成 0 之后系统集体卡死的事故。

连接字符串里 Pooling 默认开启,Max Pool Size 默认 100。如果系统并发不高却频繁报连接池错误,先查是不是哪里忘了关闭连接,而不是调大连接池。隔离级别选择看业务容忍度,给个简表:

隔离级别行为适合场景
ReadCommitted读不到未提交数据,但两次读可能不一致大多数业务系统默认选择
Serializable范围锁,并发最低库存、金额等强一致场景
ReadUncommitted能读到脏数据,性能最高报表、统计等容忍偏差的场景

最后强调连接释放:用 Using 是首选,手写 Close 也要放在 Finally 里。很多人写到一半才想起要 Close,异常路径上连接永远释放不了。宁可多用几层 Using 嵌套,也别省这个括号。

5. 高级 VB 编程避坑清单:五个踩过才记得住的教训

下面这几条全是真实项目里见过的翻车现场,写成「现象—原因—解决」三段,方便你遇到时直接对照。

5.1 现象:对象莫名变成 Nothing

现象:窗体关掉再打开,控件状态错乱,或者直接报 NullReferenceException;也有的是对象用着用着就空了,功能时好时坏。

原因:VB6 的窗体默认是全局单例,第二次打开同一个窗体还是原来那个对象;VB.NET 必须 New 才有新实例。老代码迁到 VB.NET 后,习惯性地直接访问窗体变量,拿到的是已经被释放的旧对象。另一个常见原因是事件订阅没退订,对象被某个全局事件引用着,Dispose 之后还在被调用。

解决:窗体访问统一走 New;关闭时在 FormClosed 事件里把全局引用置空;所有 AddHandler 在类 Dispose 时成对 RemoveHandler。访问任何可能已被释放的界面对象前,先检查 IsDisposed,这个习惯能救回大量调试时间。

5.2 现象:API 一调就崩溃或者返回乱码

现象:从网上抄的 API 声明,编译能过,一运行就崩;或者字符串返回一堆问号。这是 Win32 API 调用里最常见的翻车方式。

原因:输出缓冲区用了 String 而不是 StringBuilder;声明里没有 CharSet.Unicode;句柄类型声明成 Integer 或 Long,在 64 位系统上被截断。

解决:输出参数一律 StringBuilder 预分配;声明统一 CharSet.Unicode;句柄用 IntPtr。调用后检查返回值,失败就用 Marshal.GetLastWin32Error() 取真实错误码,别再凭感觉猜。网上抄声明一定要对照系统位数改类型。

5.3 现象:循环拼接字符串,程序越来越慢

现象:处理几万行数据时,内存暴涨、CPU 拉满,速度肉眼可见地慢下去,任务管理器里看内存曲线一路爬升。

原因:字符串是不可变对象,每次 & 拼接都生成一个全新的字符串,循环几万次就是几万次分配和复制,旧对象排队等垃圾回收。

解决:循环内改用 StringBuilder.Append;只需要拼一次的用 String.Format 或 String.Join。改完一般快一个数量级,这是性价比最高的性能优化,没有之一。

5.4 现象:数据库记录被莫名覆盖

现象:两个人同时改同一条订单,后保存的人把先保存的修改覆盖了,没有任何提示,业务对不上账。

原因:UPDATE 语句无条件覆盖全部字段,事务只能保证单条语句原子性,管不住「先读后写」之间的并发窗口。

解决:表上加 Version 列(时间戳或自增序号),UPDATE 时在 WHERE 里带上次读到的版本号,影响行数为 0 就说明数据已被别人改过,这时候给用户提示而不是硬写:

UPDATE orders SET order_status = @newStatus, version = version + 1 WHERE order_id = @orderId AND version = @oldVersion

如果影响行数为 0,重新读取最新数据,让用户决定是否覆盖。这是乐观并发的标准做法,比锁表温和得多。

5.5 现象:换一台机器启动就崩溃

现象:开发机上一切正常,部署到客户机器上一启动就报错,或者某个功能点点了没反应。

原因:引用的第三方 COM 组件没注册;目标机器上运行时版本不对;程序是 32 位编译,依赖的组件却是 64 位注册的,或者反过来。

解决:发布清单里列清每个依赖组件;启动时做环境自检,检查关键组件是否存在,缺什么提示什么;打包前先确认目标机器的位数,COM 组件按对应位数重新注册。这一条在部署现场能救回一整个下午的时间。

6. 用反射写一个 VB 插件框架:把高级技巧串成体系

反射是多数 VB 开发者最后才碰的东西,但它能把前面所有技巧串起来。场景:某订单处理系统需要不断接入新的处理逻辑——归档、通知、对账,如果每加一种就改主程序重新发布,维护成本太高。常见做法是做一个插件目录,主程序启动时扫描该目录下的 DLL,用反射找到实现了约定接口的类型,实例化后登记使用:

Public Interface IPlugin ReadOnly Property Name() As String Sub Execute() End Interface Public Class PluginHost Private _plugins As New List(Of IPlugin) Public Sub LoadPlugins(ByVal pluginFolder As String) For Each dllPath As String In IO.Directory.GetFiles(pluginFolder, "*.dll") Try Dim asm As Reflection.Assembly = Reflection.Assembly.LoadFrom(dllPath) For Each t As Type In asm.GetTypes() If t.IsClass AndAlso Not t.IsAbstract AndAlso GetType(IPlugin).IsAssignableFrom(t) Then Dim plugin As IPlugin = DirectCast(Activator.CreateInstance(t), IPlugin) _plugins.Add(plugin) Console.WriteLine("已加载插件:" & plugin.Name) End If Next Catch ex As Exception ' 单个插件出错不能影响主程序启动 Console.WriteLine("跳过插件 " & dllPath & ":" & ex.Message) End Try Next End Sub Public Sub RunAll() For Each p As IPlugin In _plugins p.Execute() Next End Sub End Class

调用:

Dim host As New PluginHost() host.LoadPlugins("C:\plugins") host.RunAll()

几个参数和边界条件值得说明。LoadPlugins 的文件夹参数建议用独立目录,别把主程序的 DLL 混进去,否则反射会尝试加载主程序自身,徒增启动开销。IsAssignableFrom 判断类型是否实现 IPlugin,IsAbstract 排除抽象类。Activator.CreateInstance 反射创建实例,等价于 New,但类型在运行期才确定。每个 DLL 用 Try/Catch 单独包住,一个坏插件只会跳过自己,不影响主程序和其他插件启动。插件接口本身要定得稳,接口一变,所有已发布的插件都得跟着重新编译,这是插件系统里最该提前想清楚的事。

我后来养成的习惯是:新写任何一个功能模块,先问自己一句——这段逻辑以后会不会有第二种实现?如果会,就拆成接口;如果不确定,宁可先拆了再说,回头合并比回头重构容易得多。这个习惯谈不上玄学,就是被上面这些坑打磨出来的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询