简介:VBto Converter V2.89 面向需要迁移旧版 VB6 项目的开发者,可将 Microsoft Visual Basic 6.0 完整工程及源代码转换为 VC++ MFC、VC++.NET、VB.NET、C#、J#、C++ Builder 与 Delphi 工程,适合跨语言重构、平台升级和代码对照学习。压缩包约 21.57MB,共 2000 个文件,含 h、cpp、cs、vb、pas、dfm、frm 等源码与窗体文件,resx、gif、bmp、ico 等界面资源,以及 vbproj、csproj、sln、vbp 等工程配置和 exe、dll、chm 辅助文件。已有 326 人学习下载。借助包内多语言工程样例,读者可对照查看 VB6 到 C#、VB.NET、C++ 等目标代码的转换结果。读者可进一步理解窗体、资源、数据访问与工程配置的迁移方式,并用于验证转换流程、排查兼容性问题。
1. 接手一套 VB6 老系统之后:我先装的为什么是转换器而不是 IDE
接手那套系统时,硬盘里只剩一个.vbp、四十多个.frm,和一堆没人敢碰的.frx。开发早走了,编译环境重装一次能折腾半天,改一个查询条件要在三处硬编码里同步。这种局面下,我先摆上桌的不是 VB6 的安装盘,而是 VBto Converter V2.89 —— 它干的活很明确:把 VB6 的工程文件连同窗体、标准模块、类模块一起解析,按你选的目标语言吐出可编译的 .NET 工程。核心价值在于先把控件声明、事件绑定、布局坐标、API 声明这些体力活解决掉七八成,语法和语义差异留给人来收尾。适合手上压着 VB6 存量代码、要往 .NET 走、又没预算重写的人;不适合指望点一下按钮就交付的项目。后面几章按「能转什么 → 怎么点 → 哪里必翻车 → 怎么排查」的顺序展开。
2. 转换边界:.vbp、.frm、.frx 里哪些东西能被吃下
动手之前先搞清楚转换器的输入输出模型,比急着点「转换」有用得多。VB6 的工程不是一个文件,而是一组文本文件和二进制资源文件的集合,转换器能读到什么粒度、读不到什么,直接决定了你后面要补多少手工活。这一章把输入侧和输出侧都拆开看,最后给一份不可转清单。
2.1 .vbp 只是一张清单,真正的活在 .frm 里
.vbp是纯文本,一行一条记录,作用相当于今天的项目文件。它不包含任何代码逻辑,只声明「这个工程由哪些文件组成、引用了哪些类型库、启动对象是谁」。下面是一个典型片段:
Type=Exe Reference=*\G{00020430-0000-0000-C000-000000000046}#2.0#0#..\..\Windows\System32\stdole2.tlb#OLE Automation Form=Form_Main.frm Form=Form_Query.frm Module=Mod_DB; Mod_DB.bas Class=Cls_User; Cls_User.cls UserControl=UC_Grid.ctl Object={XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}#1.0#0; MSCOMCTL.OCX Startup="Form_Main"Form=行列出窗体文件,Module=和Class=列出标准模块与类模块(分号后面是磁盘上的文件名),Object=行是第三方 OCX 引用,Startup是启动对象。转换器读这张清单决定要处理哪些文件,所以清单里缺失的文件,转换时会被静默跳过——这是第一个容易吃亏的地方,移动过目录、丢过文件的工程尤其要核对。
真正的工作量在.frm里。.frm是「属性块 + 代码块」的混合体:前半段用Begin ... End的嵌套结构描述控件树和每个控件的初始属性,中间是Attribute VB_Name之类的元信息,后半段才是 VB6 代码。转换器靠Begin/End的缩进层级还原父子关系,靠属性行还原控件的位置、尺寸、字体、初始值。一旦某个第三方控件的属性块格式特殊,解析就会在这里断掉,表现是「窗体生成了,但控件是空的」。
.frx是配套的二进制资源文件,存图片、图标,以及某些控件(比如带图片的按钮、Grid)的属性块。转换器会把这些内容导出到 .NET 工程的资源里,图片一般进.resx。.frx缺失或损坏时,窗体通常还能生成,但图片全丢,需要自己补。
2.2 目标语言选型:VB.NET、C# 与其余选项的实际代价
转换器提供多个目标语言,选择不只看团队喜好,更要看后续维护成本。下面这张表是我实际用下来的判断依据,具体下拉里有哪几项,以你手上这份 V2.89 的界面为准:
| 目标 | 典型产物 | 适合场景 | 手工收尾成本 |
|---|---|---|---|
| VB.NET | .sln+.vbproj | 团队还熟 VB 语法,想先跑通再逐步重构 | 低,语法差异小,On Error、默认属性都能对上 |
| C# | .sln+.csproj | 要并进已有的 C# 解决方案 | 中,默认属性、Set/Let、数组下标的差异要逐处改 |
| C++ | .vcxproj等 | 有原生依赖或性能诉求 | 高,字符串、异常、内存模型全变 |
| 其他语言 | 对应工程文件 | 特定存量生态 | 高,且社区资料少 |
如果目标是「尽快让这套系统能编译、能跑、能继续维护」,我一般选 VB.NET 作为第一站。原因很实际:VB6 到 VB.NET 的语法断层最小,On Error GoTo有对应的Try/Catch、默认属性两者都支持、字符串函数名基本一致,转换器产出的代码可读性最高,人工过一遍就能编译。等系统在 .NET 上稳定跑起来,再考虑要不要二次迁到 C#——那时候是一次可控的重构,而不是在转换器的输出上硬改。
选 C# 的代价主要体现在三处:一是 VB6 大量依赖控件的默认属性(Text1 = "abc"这种写法),C# 不支持默认属性,生成代码会变成显式属性名,虽然能编译,但如果你在转换后还要手工补代码,读起来会别扭;二是Set和Let的区别消失,对象赋值和值赋值在 C# 里长得一样,转换器偶尔会漏掉Set语义导致编译错误;三是数组下标、字符串比较、日期处理这些细节,两边习惯不同。
2.3 不可转清单:先给工程分个级再动手
转换器不是万能的,有些东西在 .NET 里根本没有等价物,转换器只能生成空壳或者干脆跳过。动手前拿这份清单把工程扫一遍,能省掉后面大量返工:
- 第三方 OCX / ActiveX 控件:
MSCOMCTL.OCX、MSFLXGRD.OCX这类,转换器无法生成对应的 .NET 控件,通常是留一个未声明的类型引用,编译时报「类型未定义」。 - ActiveX EXE / DLL 调用:进程外组件,.NET 里要用 COM 互操作或者干脆重写成服务。
- DDE、OLE 容器、拖放:VB6 时代的老机制,.NET 里要么换实现,要么删功能。
- Data 控件、ADO Data Control、DataReport:这些是有 UI 的数据绑定组件,.NET 里对应的是 ADO.NET 代码,得重写数据访问层。
- 资源文件里的自定义格式:
.frx里的图片能导,但自定义二进制块不行。 - 硬编码路径和注册表分支:转换器不判断这些,会把字符串原样搬过去,运行时才炸。
我一般按这三条给工程分级:A 类,纯 VB6 + 标准控件 + 少量 Win32 API,转换器能搞定八成,人工补一两天;B 类,夹带少量第三方控件和 API 调用,转换后要替换控件、重写声明,按窗体数量估工期;C 类,重度依赖 ActiveX 组件和报表控件,建议先把这些组件换成 .NET 等价物,再整体转换,否则转出来的工程是空的。
分级的意义在于,A 类工程可以整包转,B 类要拆批转,C 类转之前先做组件替换。这一步判断错了,后面所有工作都建立在流沙上。
3. 第一次跑通:从 .vbp 到能 build 的 .NET 工程
这一章是把转换器真正跑起来的完整链路。工具是图形界面的,操作不复杂,但顺序和准备工作比点按钮重要得多——准备工作没做够,第一次转换的结果基本不可用,还会让你误以为是工具不行。
3.1 复制工程与依赖盘点:转换前必须做的三件事
第一件事,把整个工程目录复制一份到新路径,绝不在原目录上直接转。转换器会在源目录旁边生成输出目录,混在一起以后做文件比对会非常痛苦,而且转换失败时容易污染原件。我的习惯是建一个migrate/工作目录,下面分src/(原工程副本)、out/(转换输出)、baseline/(VB6 编译的原版 exe 和截图,用于后续比对)。
第二件事,把.vbp里声明的所有文件核对一遍。打开工程文件,逐行对Form=、Module=、Class=后面的文件名,确认磁盘上都存在。VB6 的工程引用是相对路径,改过目录结构的老工程经常出现「清单里有、磁盘上没有」的情况。缺失的文件转换器不会报错,只会跳过,你会在编译时发现少了一整个模块。
第三件事,把第三方依赖的.ocx、.dll找齐,确认它们的版本。这一步不是为了转换,是为了后面手工替换控件时知道原来用的是什么。第三方控件的属性、方法、事件在 .NET 里没有直接对应,替换时只能靠查原控件的文档,或者反编译看调用点。
顺带确认一下环境:生成的结果要编译,机器上得有对应版本的 .NET SDK;只做转换不编译的话,转换器本身是独立运行的,不依赖 VB6 IDE。这一点很多人误解,以为要先装 VB6 才能转,其实不需要——转换器读的是文本文件。
3.2 向导里的四处选择:工程、类别、语言、输出
转换器的操作流程大体是「选工程 → 选要转的条目 → 选目标语言 → 选输出目录」。不同版本的向导页面名称略有出入,按界面实际文字为准,但四个决策点是固定的:
第一处,工程文件。选src/下那份.vbp。如果转换器提示解析失败,先回去看 3.1 的第二件事,八成是清单里有文件缺失,或者.frx损坏。
第二处,转换类别。界面一般会把工程里的条目列成树:窗体、标准模块、类模块、用户控件、资源。这一步的关键是分批。我的做法是第一次只勾标准模块和类模块,不勾窗体。原因是非 UI 代码没有控件依赖,转换成功率最高,先把数据访问、业务逻辑、工具函数这些转过去并编译通过,等于给整个迁移打了个地基。窗体留到第二轮。
第三处,目标语言。按第 2 章的选择来。这里有一个容易被忽略的细节:同一批文件不要用不同语言分别转,混语言工程后期的引用管理会很麻烦。
第四处,输出目录。指到out/下,别指到src/里。输出目录里一般会生成一个解决方案文件加一个项目目录,加上资源目录。转换器通常会为每个源文件生成一个同名目标文件,命名规则稳定,方便你写脚本做批量比对。
3.3 生成选项与目录结构:命名空间和资源文件怎么落
生成之前会有一些选项,值得逐项确认:
- 命名空间 / 根命名空间:默认一般跟项目名走。如果后面要并进已有的解决方案,这里就改掉,省得生成后再全局替换。
- 项目名与程序集名:跟命名空间保持一致最省事。
- 是否生成解决方案文件:勾上,否则后面要手工
dotnet new sln。 - 资源处理:
.frx里的图片是否导出到.resx。默认开启,除非你确认工程里没有图片资源。 - 代码风格:是否有
Option Strict、是否保留原注释、是否生成My命名空间相关代码。Option Strict On会让一批隐式类型转换报错,但对迁移来说这是好事——早报错早发现,比运行期才炸强。我一般建议打开。
生成完成后目录大致是这样:
out/ ├── LegacyApp.sln └── LegacyApp/ ├── LegacyApp.vbproj ├── Form_Main.vb ├── Form_Query.vb ├── Mod_DB.vb ├── Cls_User.vb └── Resources/ └── Form_Main.resx每个生成文件顶部通常带一段注释,标明源文件路径和转换时间。这段注释别删,后面排查问题时靠它快速定位原件。
3.4 第一次编译:把错误按类型归堆再看
生成完直接编译,别急着看代码。命令行方式最省事:
cd out dotnet new sln -n LegacyMigrated 2>/dev/null || true dotnet sln LegacyMigrated.sln add ./LegacyApp/LegacyApp.vbproj dotnet build LegacyMigrated.sln -v m 2>&1 | tee build.log第一行建解决方案容器,第二行把生成的项目挂进去,第三行编译并把完整日志同时打到屏幕和build.log。-v m是 minimal 详细级别,够看错误但不会被无关信息刷屏;如果你要定位具体的类型解析问题,改成-v d看详细日志。2>&1 | tee是为了留档——第一次编译的错误数量通常几十到上百条,翻屏幕会漏。
拿到build.log之后不要从上往下一条条改,先按错误码归堆。常见几类:
| 错误码(VB.NET / C#) | 含义 | 大概率来源 |
|---|---|---|
| BC30002 / CS0246 | 类型未定义 | 第三方控件未转换、引用缺失 |
| BC30451 / CS0103 | 名称未声明 | 变量未初始化、模块级变量丢失 |
| BC30456 / CS1061 | 成员不存在 | 控件属性改名、方法签名差异 |
| BC30518 / CS1501 | 参数个数不匹配 | API 声明、事件签名 |
| BC42024 / CS0168 | 变量声明但未使用 | 无害,可批量清理 |
归堆之后你会发现,同一类错误往往集中在几个文件里,改一个模式化的写法就能消掉几十条。这个顺序比逐条修改快得多,也不会在中途失去耐心。
4. 控件与事件:转换质量在这三处见分晓
模块层编译通过只说明逻辑代码转得动,真正的迁移难点全在窗体上。控件映射、控件数组、事件签名这三处处理得好不好,直接决定转换结果是「能跑的原型」还是「要重写的半成品」。
4.1 控件对照:从 CommandButton 到 Button 的属性改名
VB6 的标准控件在 .NET 里基本都有对应,但属性名、事件名、行为细节有差异。转换器会做映射,但不会帮你处理语义差异。常用的对照关系如下:
| VB6 控件 | .NET 对应 | 需要注意的属性差异 |
|---|---|---|
| CommandButton | Button | Caption→Text |
| Label | Label | Caption→Text,BackStyle行为不同 |
| TextBox | TextBox | MaxLength、PasswordChar→UseSystemPasswordChar |
| Frame | GroupBox | 子控件父子关系要重挂 |
| OptionButton | RadioButton | 分组靠容器,不再靠TabIndex |
| CheckBox | CheckBox | Value的三种状态映射到CheckState |
| ComboBox | ComboBox | ListIndex从 0 开始,-1表示未选中,.NET 用SelectedIndex |
| ListBox | ListBox | ListIndex同上,ItemData在 .NET 里没有直接对应 |
| Timer | Timer | Interval单位同为毫秒,但 .NET 的 Timer 属于组件不是控件 |
| DriveListBox / DirListBox / FileListBox | 无直接对应 | 要用OpenFileDialog或自己实现 |
| CommonDialog | OpenFileDialog / SaveFileDialog / ColorDialog / FontDialog | 一个拆成四个 |
| MSFlexGrid | DataGridView | 属性模型完全不同,基本要重写绑定 |
| SSTab | TabControl | 页签索引和子控件归属要重排 |
| Menu(菜单编辑器) | MenuStrip | 层级结构重建 |
| MDIForm | Form +IsMdiContainer | 子窗体管理方式变化 |
Caption到Text这一条看着简单,实际是错误高发区:VB6 里 Label 和 CommandButton 都用Caption,转换后属性名变了,如果你在代码里大量用默认属性(Label1 = "..."),生成代码会变成Label1.Text = "...",能编译;但如果拼接了字符串或者用了With块,就容易漏。
ItemData是另一个坑。VB6 的 ListBox 和 ComboBox 有一个ItemData数组,用来给每个条目挂一个整数 ID,是当年很常见的做法。.NET 的 ListBox 没有这个属性,转换器一般会忽略掉,导致运行期找不到数据。遇到这种,正确做法是换成List<Item>绑定,或者用Tag加字典。别指望转换器能猜出你的意图。
4.2 控件数组:Index 没了之后靠什么区分
VB6 的控件数组是「同名控件 + Index 属性」,共享一套事件处理程序,靠Index参数区分是哪一个。这是 VB6 里最常用的技巧之一,也是转换后最容易出问题的地方。
转换器一般把控件数组转成 .NET 的数组或集合,事件处理变成每个控件单独订阅、共享一个处理方法。生成代码大致长这样:
' 转换器生成的形态(示意) Private CommandButtons(0 To 3) As Button Private Sub Form_Load(sender As Object, e As EventArgs) Handles MyBase.Load CommandButtons = New Button() {Button0, Button1, Button2, Button3} For i As Integer = 0 To CommandButtons.Length - 1 AddHandler CommandButtons(i).Click, AddressOf CommandButtons_Click Next End Sub Private Sub CommandButtons_Click(sender As Object, e As EventArgs) Dim btn As Button = DirectCast(sender, Button) ' 原来靠 Index 分支的逻辑,现在要靠 Tag 或 Name 反查 Select Case btn.Name Case "Button0" ' 分支一 Case "Button1" ' 分支二 End Select End Sub这段代码的关键点在AddHandler那一行和Select Case的替代逻辑。VB6 里Sub Command1_Click(Index As Integer)的Index参数在 .NET 里不存在了,sender只告诉你「哪个对象触发的」,不告诉你「数组下标是多少」。所以转换器会保留数组结构,但事件分发必须靠别的标识——Name、Tag、或者数组下标反查。
这里有两个实操建议。第一,转换前在 VB6 里把控件数组的Index值写进每个控件的Tag属性,这样转换后可以直接CInt(btn.Tag)拿回原下标,逻辑改动最小。第二,如果控件数组规模很大(比如动态生成的按钮墙),不要用生成代码里的数组方案,改成Dictionary(Of Integer, Button),增删都方便,也避免了数组越界。
Load Command1(i)这种动态加载控件的写法是另一个雷。VB6 里控件数组可以运行时Load出新实例,.NET 里必须New一个控件、设好属性、Controls.Add、再AddHandler。转换器通常只能生成一段注释提示你手工补,不会自动展开。
4.3 事件签名:Event、RaiseEvent 与 WithEvents 的三种写法
VB6 的自定义事件和 .NET 的事件模型看起来像,实际差别不小。VB6 的写法是:
' VB6 原貌 Public Event ProgressChanged(ByVal percent As Long, ByVal msg As String) Private Sub DoWork() RaiseEvent ProgressChanged(50, "half done") End Sub转换到 C# 之后一般会变成这样:
// 转换器生成的形态(示意,手工整理过) public class Worker { public event EventHandler<ProgressChangedEventArgs> ProgressChanged; protected virtual void OnProgressChanged(int percent, string msg) { ProgressChanged?.Invoke(this, new ProgressChangedEventArgs(percent, msg)); } private void DoWork() { OnProgressChanged(50, "half done"); } } public class ProgressChangedEventArgs : EventArgs { public int Percent { get; } public string Msg { get; } public ProgressChangedEventArgs(int percent, string msg) { Percent = percent; Msg = msg; } }三个变化点要看清。第一,VB6 的Event带参数列表,.NET 的EventHandler<T>要求参数打包进一个EventArgs子类,转换器会顺手生成这个类。第二,RaiseEvent变成了?.Invoke,那个?.是空值判断——VB6 里没有订阅者时RaiseEvent是安全的空操作,.NET 里如果不判空会直接抛空引用,这是转换后运行期崩溃的高频原因。第三,WithEvents变量在 .NET 里变成字段加AddHandler/+=订阅,声明处的WithEvents关键字消失,订阅关系挪到了构造函数或Load里。
WithEvents这一条尤其容易漏。VB6 里Private WithEvents rs As ADODB.Recordset一行声明就完成了订阅,转换器生成 .NET 代码时,如果订阅语句没跟上,事件永远不会触发,而且不报错。转换完拿到代码,搜一遍原工程里的WithEvents,逐个确认生成代码里有对应的订阅语句。
4.4 布局换算:twip、ScaleMode 与字体三件套
界面错位是转换后最直观的问题。根因在坐标单位:VB6 默认ScaleMode = 1,单位是 twip,1 英寸等于 1440 twips;.NET 的 WinForms 用像素,96 DPI 下 1 英寸约等于 96 像素。换算比例是 1440 / 96 = 15,也就是1 像素约等于 15 twips。转换器一般按这个比例把Left、Top、Width、Height换算成像素并写死。
问题出在ScaleMode不是 1 的窗体上。如果原窗体把ScaleMode设成了 3(像素)或者 5(英寸),转换器仍然按 twip 比例算,结果就是控件被放大 15 倍或者缩小到看不见。所以转换前要做一件事:把所有窗体的ScaleMode统一改成 1,在 VB6 里批量改一遍再转。这一步花十分钟,能省掉后面几个小时的重排。
字体是第二个错位源。VB6 里字体是三个独立属性:FontName、FontSize、FontBold,.NET里是一个Font对象。转换器会把它们拼成New Font("宋体", 9.0F, FontStyle.Bold)。字号单位两边都是 pt,数值上没问题,但中文字体名可能被替换成英文名或者直接丢失,导致回退到默认字体,字宽变化进而撑破布局。转完之后统一检查一遍New Font(的调用,确认字体名和字重。
第三个错位源是 DPI 缩放。写死的像素坐标在 100% 缩放下正常,用户开到 125% 或 150% 就可能重叠。短期方案是给窗体设AutoScaleMode = AutoScaleMode.Font,让框架按字体度量缩放;长期方案是把关键布局换成TableLayoutPanel。这一步不用全做,先把主界面和查询界面这两个高频使用的窗体处理掉,剩下的慢慢来。
5. 排查顺序:编译不过的 5 个高频原因与定位手法
转换之后的头两天基本都在跟错误打交道。有经验的做法的不是见一个改一个,而是先归类、再定位、最后批量处理。这一章是前面几章踩过的坑的集中整理,按「现象 → 原因 → 解决」的结构写,方便你对着日志找。
5.1 先分类再动手:四类错误的定位顺序
拿到build.log,先看错误总数和分布。我的处理顺序是:
第一类,类型未定义(BC30002 / CS0246)。这类错误通常一次性出现几十条,集中指向几个第三方控件。先解决它们,因为一个类型缺失会引发下游一串「成员不存在」。处理方式是把引用的第三方控件从工程里摘掉,换成内置控件或者临时桩类,让编译先过去。
第二类,名称未声明(BC30451 / CS0103)。指向变量、常量、模块级成员丢失。常见原因是原工程用了Public模块级变量,转换后作用域变了;或者Option Explicit关闭时留下的隐式变量。这类错误要逐个看上下文,不能批量替换。
第三类,成员不存在(BC30456 / CS1061)。控件属性改名、方法签名差异。这类错误往往有规律,比如所有Caption都要变Text,搜一批改一批。
第四类,参数不匹配(BC30518 / CS1501)。API 声明和事件签名。数量少但改起来最费劲,放到最后。
按这个顺序,每一轮编译的错误数应该显著下降,而不是原地打转。
5.2 五条踩坑记录:从现象倒推原因
第一条:编译报几十个「类型 XXX 未定义」,全都指向同一个控件。
现象:错误列表里MSComCtlLib、MSFlexGridLib相关的类型找不到,涉及十几个窗体。
原因:原工程引用的第三方 OCX 在 .NET 里没有等价物,转换器把引用原样写进了项目文件,但程序集根本不存在。
解决:打开项目文件,把这些Import/using删掉;然后在每个受影响的窗体里,把对应控件的声明替换成 .NET 内置控件(Grid 换DataGridView,工具栏换ToolStrip),再逐个补属性。这一步不要试图找「兼容包」,找到的往往是另一个需要注册的 COM 组件,问题只会延后。
第二条:编译全过,运行到某个按钮就空引用。
现象:点了查询按钮,抛NullReferenceException,堆栈指向控件数组的事件处理。
原因:VB6 的控件数组在Form_Load里就能访问,因为控件在窗体加载时就全存在了;转换后控件数组在构造函数里New出来,但如果Load事件里先访问了数组元素,此时数组还是Nothing。
解决:把控件数组的初始化挪到InitializeComponent()之后、其他逻辑之前,或者在访问前加空判断。更稳的做法是把初始化和事件订阅集中写在一个InitControls()方法里,在构造函数末尾调用一次,避免散落在各处。
第三条:调用 Win32 API 返回值乱码,或者程序直接崩。
现象:GetWindowText、GetSystemMetrics之类的调用,字符串变成方块,或者整数返回一个巨大的负数。
原因:VB6 的Long是 32 位有符号整数,Integer是 16 位;.NET 的Long是 64 位,Integer是 32 位。转换器按名字映射,Long变成了 64 位,但DllImport的封送如果没跟着改,参数宽度不对,栈就乱了。
解决:不要相信自动生成的Declare转换结果。把每个 API 声明对照 Win32 原型重写一遍,Long一律用Int32,指针用IntPtr,字符串显式指定CharSet。数量不多的话,一个个手写比调试快。
第四条:循环少跑一次,或者下标越界。
现象:原来遍历 10 个元素的循环,转换后只处理了 9 个;或者运行时报IndexOutOfRangeException。
原因:VB6 的数组默认下界可以是 0 也可以是 1,取决于Option Base,还可以用Dim a(1 To 10)显式指定。.NET 的数组一律 0 基,上界是Length - 1。转换器处理Dim a(1 To 10)时,有的版本会保留原下界但改写成 0 基,导致边界差一。
解决:全局搜Option Base和To形式的数组声明,逐个核对。转换后统一改成 0 基,循环条件从For i = 1 To n改成For i = 0 To n - 1。这类错误编译器不报,只能靠测试用例兜。
第五条:窗体打开,控件全挤在左上角或者互相重叠。
现象:界面能用,但布局完全乱掉,按钮叠在一起。
原因:原窗体的ScaleMode不是 1(twip),转换器仍按 twip 比例换算,坐标被放大了十几倍;或者窗体用了Move方法在运行期动态定位,转换后单位不一致。
解决:转换前把所有窗体的ScaleMode改成 1,重新转一遍。已经转完的,按实际比例反算回去,或者干脆在 .NET 里用TableLayoutPanel重排主界面。动态Move的调用点要单独检查,把 twip 常量除以 15。
5.3 编译过了也别高兴太早:运行期才暴露的三处
编译通过只是第一关,下面三处问题只在运行期出现,而且不会给明确报错。
默认属性的隐式转换。VB6 里If Text1 = "" Then直接拿控件的默认属性Text比较,.NET 里TextBox和字符串比较会编译失败,转换器一般会补成Text1.Text。但如果原代码是If Text1 = Text2 Then,两个控件比较,生成的代码可能变成比较对象引用,永远不相等。搜一遍所有控件直接参与比较的地方。
DoEvents的语义变化。VB6 里DoEvents让出控制权处理消息队列,是当年做进度条的标准手法。.NET 里Application.DoEvents()还在,但重入问题更严重——用户在DoEvents期间又点了一次按钮,会嵌套执行。转换后如果发现某个操作偶尔执行两次,查这里。正确做法是改用async/await或者禁用按钮。
窗体显示顺序和模态。VB6 的Form.Show vbModal在 .NET 里是ShowDialog(),但Hide之后再Show的行为两边不同:VB6 里Hide只是不可见,控件状态保留;.NET 里Hide之后再Show会触发Load之外的一系列事件,如果代码依赖「只加载一次」的假设,会重复初始化。查一遍所有Hide/Show配对。
6. 从一次性转换到可重复迁移:比对、分批与机械替换
转完第一个工程之后,真正有价值的是把这套流程变成可重复的。我后来接手第二批、第三批 VB6 工程时,靠的全是第一次留下的检查清单和脚本,人工判断的部分越来越少。
先做基线。转换之前,用 VB6 编译一份原版 exe 放进baseline/,把核心业务流程跑一遍,记录关键输出:查询结果条数、导出文件的格式、报表数字、几个边界输入的返回值。转换完成后,用同样输入跑 .NET 版本,逐条比对。这一步听起来笨,但它能抓到编译器和单元测试都抓不到的问题——比如日期格式化、浮点精度、字符串截断。我吃过一次亏:一个金额字段 VB6 用Currency,转换后变成Decimal,精度没问题,但舍入规则从「银行家舍入」变成了「四舍五入」,对账差了三分钱,查了两天才定位到。
然后是机械替换。转换器会在处理不了的代码位置留标记,常见的是TODO、FIXME或者特定前缀的注释。用一条命令把它们全收集起来:
grep -rn "TODO\|FIXME\|UPGRADE_" ./out \ --include=*.vb --include=*.cs \ > todo_list.txt wc -l todo_list.txt第一条命令递归搜索三类标记并输出到文件,--include限定只扫源码文件,避免把资源文件里的字符串也算进去。第二条统计总数,这个数字就是你手工工作量的下限,也是判断「这个工程该不该继续转」的依据——如果 TODO 有三百条,说明转换器基本没帮上忙,不如重写。
分批的顺序也有讲究。我现在的固定套路是:第一轮只转标准模块和类模块,编译通过后跑一遍业务层的最小测试;第二轮转不依赖第三方控件的窗体,主界面、查询界面优先;第三轮处理带第三方控件和 API 调用的窗体,这部分逐个手工过。每一轮结束都提交一次代码,出问题能回退到上一个可用状态。整包一次性转然后面对两百个错误,是最容易半途而废的做法。
最后一个习惯:每个生成文件顶部的转换注释不要删,同时在项目根目录维护一份对照表,记录「源文件 → 目标文件 → 遗留问题 → 负责人」。这份表在多人接手时价值极高,因为转换后的代码和原工程长得太像,半年后没人分得清哪段是机器生成的、哪段是手改的。我现在每次转换新工程,第一步就是建这张表,转换过程中随手填。有次一个窗体转换后行为诡异,靠对照表十分钟就定位到是原工程里一个隐藏的UserControl没被列进.vbp,转换器直接跳过了它,而那个控件负责了界面上一半的交互。从那以后我每次转换前都强制走一遍「.vbp 清单核对 + ScaleMode 统一 + 基线输出记录」这三步,一步不省。希望帮到你。
这套 V2.89 的安装包、上面那份不可转清单和转换检查表我整理在一起了,拿到之后先按第 2 章给工程分级,再从标准模块开始转,别一上来就整包点转换。
本文还有配套的精品资源,点击获取