简介VBto Converter V2.89 面向仍维护 Visual Basic 6.0 遗留项目、又希望迁移到现代开发栈的开发者与团队可将 VB6 完整工程及源代码转换为 VC MFC、VC.NET、VB.NET、C#、J#、Borland C Builder 与 Delphi 等工程文件覆盖界面、工程配置和常见组件调用适合旧系统评估、迁移预研或跨语言学习。压缩包约 21.57MB共 2000 个文件。其中 .h、.cpp、.cs、.vb、.pas 等为源码与头文件.frm、.bas、.cls、.vbp 保留 VB6 工程结构.resx、.dfm、.rc 对应界面与资源定义.vbproj、.csproj、.vcproj、.sln、.bpr、.dpr 等工程文件便于按目标语言对照另有图像素材及少量说明文档。已有 326 人学习下载。包内多语言工程样本、表单与模块文件可用于比对转换结果、梳理目录结构、排查引用差异也能作为工具使用参考降低手工迁移的试错成本。1. VBto Converter 2.89把一个 .vbp 变成能编译的 .NET 工程中间隔着什么一个跑了十几年的 VB6 老系统三十多个窗体模块里全是全局变量和On Error GoTo这时候谈重写不现实。能落地的路子是先用工具做一轮 VBto Converter 代码转换把.vbp整体翻成 .NET 工程再人工收口。VBto Converter 2.89 干的就是这件事喂它一个 VB6 工程文件按选定的目标语言输出带工程文件、窗体代码和资源目录的一整套代码控件按对应关系重建Declare声明转成 P/InvokeOn Error跳转改写成 try/catch。它适合手里有可编译源码、要保住界面和业务逻辑、又不想从零重画窗体的人只有 exe 没源码或者重度依赖第三方 OCX 的项目别指望它一步到位。2. 转换前的工程体检先让老代码能被工具读懂转换器不是编译器它做的事更接近「按规则做文本与资源的结构化重写」。这意味着它在读你代码的时候不会帮你校验语义原文有多少含糊输出就有多少含糊。所以真正的第一步不是打开工具而是把待转工程整理成一份「转换器友好」的输入。2.1 以 .vbp 为入口把引用和部件先盘清VB6 的工程文件.vbp是纯文本本身就等于一份依赖清单。里面Form、Module、Class、UserControl这几类行列出源码单元Reference行是类型库引用带 GUID 和版本号Object行是控件部件*.ocx同样带 GUID 和版本。转换器读的就是这份清单它按行去磁盘上找对应文件找不到就跳过而且多数情况下只会给一行警告。判断这个项目值不值得转我一般先数Object的行数。0 到 2 个部件纯标准控件的项目转换后编译通过率很高3 到 5 个还能靠人工替换控件扛过去超过 5 个第三方 OCX尤其是那种带授权文件、带设计期属性的表格/图表控件转换器只能生成占位声明编译一定报错这时候要先算一笔账是替换控件的工作量大还是重画窗体的工作量大。还有一个容易忽略的点.vbp里可能挂着Reference*\G{...}#2.0#0#...\stdole2.tlb#OLE Automation这类系统类型库也可能挂着已经卸载的组件。把工程拷到一台干净的转换机上只装 VB6 运行库和 .NET SDK打开.vbp时如果弹出「找不到部件」那就说明清单和现实不一致先补齐再谈转换。2.2 在 VB6 里编译一遍把语法错误挡在转换前这一步经常被跳过但它决定了后面所有工作的起点质量。转换器解析的是源文件文本如果原工程本身就编译不过输出必然带病而且病根会被翻译成另一种语言的样子排查成本翻倍。老工程里最常见的是「运行时不报错、编译其实有隐患」Option Explicit没开变量拼错了也能跑Dim出来的 Variant 到处传某个函数少一个End If但恰好被Exit Sub挡住没暴露。这些在 VB6 里靠容错活着转换到强类型语言就全变成硬错误。常见做法是打开 IDE 先加Option Explicit逐个模块编译把未声明变量、重复定义、参数类型不匹配一次性清掉。批量验证可以用命令行编译# 在装有 VB6 IDE 的机器上做一次全量编译把错误挡在转换之前 C:\Program Files (x86)\VB6\VB6.EXE /make D:\src\Legacy\Main.vbp \ /out D:\src\Legacy\build.log /rebuild/make让 IDE 以无交互方式执行「编译为 exe」/out把编译输出重定向到日志文件/rebuild强制全量重编而不是增量。命令的重点不在自动化而在那份build.log里面会列出每个编译错误所在的文件、行号和错误码可以直接当成分批修复的工单。注意这条命令会短暂拉起 IDE 进程机器上必须真的装了 VB6 开发环境只有运行时库是不行的。编译通过之后再把工程里那些「只在某个分支用到」的引用删掉。多一个无用引用转换后就多一组解析不了的符号。2.3 建一份转换清单窗体、模块、类、第三方控件分四类转换不是一次性动作中途一定会返工。返工的前提是知道有哪些单元、各自多大、各自依赖什么。手工数不现实写个小脚本把.vbp拍平成表格后面每转一批就在表上打个勾。# scan_vbp.py把 .vbp 解析成单元清单并统计每个单元的代码行数 import pathlib import collections KEYS (Form, Module, Class, UserControl, Reference, Object) def parse_vbp(vbp_path): root pathlib.Path(vbp_path).parent bucket collections.OrderedDict((k, []) for k in KEYS) # VB6 源文件默认是 ANSI简体中文环境即 CP936必须按 gbk 读否则中文注释会变成乱码 text pathlib.Path(vbp_path).read_text(encodinggbk, errorsreplace) for raw in text.splitlines(): line raw.strip() if not line or line.startswith(;) or not in line: continue key, val line.split(, 1) key key.strip() if key in bucket: bucket[key].append(val.strip()) return root, bucket def count_loc(path): # .frm/.cls 顶部有一段 Attribute 头从 VB_Name 之后开始算正文 lines path.read_text(encodinggbk, errorsreplace).splitlines() start 0 for i, l in enumerate(lines): if l.strip().startswith(Attribute VB_Name): start i 1 break return sum(1 for l in lines[start:] if l.strip() and not l.strip().startswith()) root, bucket parse_vbp(rD:\src\Legacy\Main.vbp) total 0 for key in (Form, Module, Class, UserControl): for entry in bucket[key]: fname entry.split(;)[0].split(:)[0].strip() f root / fname loc count_loc(f) if f.exists() else -1 total max(loc, 0) print(f{key:12s} {fname:24s} {loc:6d} 行) print(f{合计:12s} {:24s} {total:6d} 行) print(f部件Object数量: {len(bucket[Object])}) for o in bucket[Object]: print( OCX -, o.split(;)[-1])脚本的逻辑很直白按行切keyvalue把五类条目分别装桶count_loc跳过Attribute VB_Name之前的头部只统计非空非注释行。参数上唯一需要小心的是编码encodinggbk不能省用utf-8读 VB6 源文件会在第一个中文注释处炸掉errorsreplace是兜底真出现替换字符说明这台机器上文件本身已经不是原始编码了那是另一个问题。跑完之后我会得到三样东西总行数决定转换分几批、每个窗体的行数挑样板间的依据、OCX 清单决定要不要提前做控件替换。这份清单还有一个隐性价值——转换后如果某个窗体的输出明显「薄」了比如原来 800 行出来只有 200 行那基本可以判定转换器在某个语法结构上放弃了直接对着行数差去查最快。清单维度用途判断阈值经验值总代码行数决定分几批转单批 1 万行以内可控窗体数决定界面验收工作量每 10 个窗体约需 1 人日冒烟OCX 数量决定是否先做控件替换超过 5 个建议先替换再转Declare数量决定 P/Invoke 返工量每条声明平均 10 分钟核对无Option Explicit的模块数决定类型收敛工作量逐模块补齐再转3. 目标语言与转换参数选 VB.NET 还是 C#开关怎么拨同一个 VB6 工程转成 VB.NET 和转成 C#返工量的分布完全不同。选错目标语言后面每改一个语法糖都要多花十分钟几十个窗体累起来就是几天。这一章讲的就是选型依据和那几个真正影响成败的开关。3.1 三条判断线决定目标语言第一条线是「这套代码后面谁维护」。如果转换完还要在这套代码上持续加功能目标语言必须是团队日常写的那个否则你会得到一套没人敢动的中间产物——比原来的 VB6 更尴尬因为它既不是原生老代码也不是团队熟悉的技术栈。这种情况下即便 C# 更主流团队只会 VB.NET 也应当选 VB.NET。第二条线是「界面要不要重画」。如果最终形态是 Web 或者跨平台客户端那 WinForms 只是过渡转换的目标语言选什么其实无所谓因为窗体迟早要扔。此时应当把转换当成「业务逻辑提取」而不是「整体迁移」重点是把算法、数据访问、校验规则翻出来界面部分能跑就行不要在控件属性对齐上耗时间。第三条线是「有没有原生互操作需求」。老系统里如果有一大块是做设备通信、硬件采集、或者依赖某个只能用 C 接口调用的组件转 C# 或 C 会更顺因为 P/Invoke 和结构体封送在 C# 里生态最全示例最多。转 VB.NET 也能做但遇到复杂封送时参考资料少一半。综合下来我的默认选择是一次性交付、团队不打算长期维护、界面保留 → VB.NET因为语法距离 VB6 最近转换器在 VB.NET 上的输出最贴近原文人工比对快长期维护、有互操作需求、后续要接现代生态 → C#接受多一轮语法校正只提取算法模块塞进原生进程 → C但要有心理准备窗体部分基本等于重写。3.2 转换选项里真正影响成败的几个开关工具面板上的选项不少但真正会在后期咬人的就那么几个其余多数是命名风格问题改起来不费劲。第一是目标框架版本。选高了老机器上的运行库跑不起来选低了一些语法糖和异步 API 用不了。老系统迁移我一般选一个还带长期支持的 .NET Framework 版本先保证能跑再谈升级。注意这里有个连锁反应框架版本决定了项目文件的格式如果输出的是旧格式项目文件用新版 IDE 打开会提示升级升级过程会顺手改掉一些属性最好在转换前就把目标框架定死别中途换。第二是命名空间与项目名。转换器通常会用工程名做默认命名空间而老工程名往往带空格、带中文、带版本后缀这三种在 .NET 命名空间里都不合法工具会做替换替换规则未必合你意。转换前把.vbp里的工程名改成合法的英文标识符比转换后再全局重命名省事得多。第三是资源输出位置。VB6 的图片、图标都存在.frx二进制附属文件里转换器要把它们切出来存成独立文件并在代码里生成引用。如果资源目录配错了编译能过但运行时一片空白或者干脆抛「资源未找到」。这一项没有通用最优值但有一条原则资源目录必须跟着项目文件走相对路径绝对路径在换机器后必炸。第四是控件命名保留。保留原始名字Command1、Text2的好处是能和老代码逐行对照坏处是新代码可读性差。我倾向保留理由是迁移期的第一优先级是「能对上」可读性可以后面统一重构反过来就难了。3.3 输出目录与工程文件转换完先别急着打开转换器输出的是一个目录树一个解决方案文件、每个窗体一个代码文件、一个资源目录、若干项目级配置文件。很多人转换一结束就双击打开解决方案然后被几十个编译错误糊一脸直接判断「这工具不行」。实际上这些错误里有一半是环境问题不是转换问题。我的做法是先做三件事再打开 IDE。第一确认项目文件里写的目标框架和本机装的开发工具链一致不一致就先改项目文件。第二确认资源目录里的文件数和老工程里.frx的数量大致对得上差太多说明资源抽取失败。第三用脚本扫一遍输出文件的编码和换行符。# check_out.py扫描转换输出目录找出编码异常和疑似丢中文的文件 import pathlib import sys SUSPECT (\ufffd,) # 替换字符出现即表示解码失败 root pathlib.Path(sys.argv[1]) bad_enc, bad_q, total [], [], 0 for p in root.rglob(*): if not p.is_file() or p.suffix.lower() not in (.cs, .vb, .resx, .csproj, .vbproj): continue total 1 data p.read_bytes() has_bom data.startswith(b\xef\xbb\xbf) try: text data.decode(utf-8-sig) except UnicodeDecodeError: bad_enc.append(str(p.relative_to(root))) continue if any(s in text for s in SUSPECT): bad_enc.append(str(p.relative_to(root))) # 连续 3 个以上问号通常是中文在转换链上被降级成了 ? if ??? in text: bad_q.append(str(p.relative_to(root))) crlf data.count(b\r\n) lf data.count(b\n) - crlf if lf crlf: print(f[LF 混入] {p.relative_to(root)} CRLF{crlf} LF{lf}) print(f扫描文件数: {total}) print(f编码异常: {len(bad_enc)}) for f in bad_enc[:20]: print( , f) print(f疑似中文丢失: {len(bad_q)}) for f in bad_q[:20]: print( , f)utf-8-sig这个解码器的意思是「允许并吞掉 BOM」因为转换器输出带 BOM 和不带 BOM 都有可能用严格utf-8会把带 BOM 的文件误判成异常。???检测是针对一种很典型的现象中文在某个环节被按单字节编码处理每个汉字降级成一个问号。换行符检查看着多余实际上混用换行会让后续的 diff 工具把整个文件判成「全部改动」代码评审时根本看不出改了什么。这一步跑完输出的目录树才算「干净可打开」。先修环境问题再修代码问题顺序反了会白干。4. 核心映射逐个过窗体资源、控件、错误处理与 API转换器输出质量的高低基本由四块决定窗体资源还原得准不准、控件映射对不对得上、错误处理语义有没有丢、API 声明转得对不对。这四块里任何一块塌了编译能过也跑不对。4.1 窗体与 .frx控件数组、默认属性、Tab 顺序VB6 的窗体是两个文件.frm是文本描述控件树和属性.frx是二进制装图片、图标这类资源。.frm里你会看到Picture Form1.frx:0000这种写法冒号后面是资源在.frx里的字节偏移。转换器要做的就是把.frx按偏移切开每段存成独立文件再把引用改成资源路径。这里最典型的翻车是偏移解析。老工程的.frx里如果混着几种资源图片、图标、自定义的二进制属性切错一个偏移后面的全错位表现是图片显示成另一张、或者尺寸对不上但不报错。验收方法很简单把新旧窗体同时打开截图叠在一起看错位肉眼可见。控件数组是第二个坑。VB6 里Command1(0)、Command1(1)共享同一个事件过程事件签名带一个Index As Integer。转成 .NET 之后没有控件数组这个概念工具通常生成一组字段加一个带索引参数的处理器或者干脆展开成 N 个独立处理器。无论哪种都要检查三件事每个成员的Location、Size、Parent是不是逐个赋值了索引是否连续VB6 允许Command1(1)、Command1(5)这样跳号事件里的Index分支逻辑有没有被正确保留。第三个是默认属性。VB6 里Text1单独出现在表达式里取的是它的默认属性TextLabel1取的是Caption。转换器要能识别这种隐式取值并展开成显式属性访问。如果某处没展开代码可能编译不过类型不匹配也可能编译过了但语义变了——比如把控件对象本身当参数传了出去。第四个是 Tab 顺序和 Z 序。这两个属性在.frm里是以控件定义顺序和TabIndex属性体现的转换后如果控件创建顺序变了Z 序遮挡关系会跟着变界面上会出现「按钮被面板盖住一半」这种诡异现象。检查方法是把新窗体跑起来看有没有控件被遮挡尤其是放在Frame里的那些。4.2 控件替换表与替换顺序标准控件的映射多数是直译但属性名和默认行为有差异这些差异不会报错只会让界面「看起来不太对」。VB6 控件.NET 对应需要手改的点LabelLabelCaption→TextAutoSize默认值相反TextBoxTextBoxVB6 的MaxLength0表示不限.NET 里 0 表示一个字符都输不进CommandButtonButtonDefault/Cancel在 VB6 是控件属性.NET 提为窗体级FrameGroupBox内部控件坐标基准从窗体变成容器转换后常整体偏移OptionButtonRadioButton互斥范围由 Frame 决定容器关系错则分组全乱CheckBoxCheckBoxValue的三态语义与CheckState不完全对应ListBox / ComboBox同名ItemData无对应物要另建并行集合DriveListBox / DirListBox / FileListBox无换成目录选择对话框或自己实现CommonDialog四个独立对话框类一个控件拆成打开、保存、颜色、字体四个TimerTimerInterval单位一致但 .NET 里需要显式Start()菜单编辑器MenuStrip菜单控件数组的索引要重建StatusBar / ToolBarStatusStrip / ToolStrip面板与按钮集合改成 Items 模型SSTabTabControl每个页签的容器语义不同控件归属要重新挂Line / Shape无用 Panel 或自绘代替第三方表格 / 图表 OCX无只能找替代控件或重写替换顺序有讲究先把「无对应物」的那几类处理掉再跑转换。原因是带这类控件的窗体转换后一定编译不过而编译不过的窗体没法做界面验收会阻塞整批进度。反过来如果先转再改你会在一堆编译错误里分不清哪些是控件问题、哪些是语法问题。4.3 On Error GoTo 到 try/catch语义不是一一对应VB6 的错误处理是过程级的跳转On Error GoTo 标签之后整个过程内任何运行时错误都会跳到标签处Resume回到出错行Resume Next跳到下一行On Error GoTo 0关闭当前过程的处理。try/catch 是块级的两者粒度不同直接映射一定丢语义。最典型的丢失发生在On Error Resume Next。它在 VB6 里是「从这个点到过程结束所有错误都忽略」转换器为了实现这个效果往往把方法体整个包进 trycatch 块留空。结果就是原本设计上「只忽略某一行特定错误」的代码变成了「整个过程静默吞掉所有异常」包括空引用、数组越界这种本该暴露的问题。另一种丢失是清理逻辑。老代码流行这么写 VB6 时代的标准写法用错误处理做统一清理出口 Public Sub SaveRecord(ByVal id As Long) On Error GoTo Cleanup Dim conn As Object Set conn OpenConn() 可能失败 conn.Execute update t set flag1 where id id Cleanup: If Not conn Is Nothing Then conn.Close End Sub这段代码的意图是「无论成功失败都关闭连接」但它在 VB6 里其实有隐患没有Exit Sub挡在Cleanup前面正常流程也会掉进清理段。转换器把它翻成 C# 时最常见的输出是整个方法包 try、清理放 finally看着对但如果原文里Cleanup标签前面还有业务语句那些语句就被错误地挪进了 finally。正确的目标是这样的结构// 目标形态把统一出口识别成 try/finally而不是 try/catch 吞掉 public void SaveRecord(int id) { OleDbConnection conn null; try { conn OpenConn(); using (var cmd conn.CreateCommand()) { cmd.CommandText update t set flag1 where id id; cmd.ExecuteNonQuery(); } } finally { conn?.Close(); } }finally对应原文的「清理出口」try里的业务代码保持原样没有 catch 就意味着异常照常向上抛——这正是原文On Error GoTo Cleanup后面没有Resume Next时该有的行为。参数上要注意VB6 的Long是 32 位对应 C# 的int而不是long这个映射错了不会报错但会悄悄改变接口契约调用方传进来的大数被截断。还有两个细节值得单独核对。Err.Number与Exception.HResult不是一回事自定义错误号vbObjectError 1001转换后如果继续用数字比较永远匹配不上正确做法是定义自己的异常类型。以及 VB6 的整除运算符\会先把操作数四舍五入再相除而 C# 的/直接截断负数情况下结果不同VB6 的/返回浮点数C# 里两个 int 相除得到的是整除结果转换器漏加(double)就是精度事故。4.4 Declare 到 P/InvokeAs Any 和结构体对齐老工程里几乎都有几段Declare用来调系统 API 或者自己的 C 接口 DLL。转换器会把它们翻成 P/Invoke但翻得对不对取决于参数类型和字符集。 原文读 INI 文件注意返回缓冲区是用 ByVal String 传的 Private Declare Function GetPrivateProfileString Lib kernel32 _ Alias GetPrivateProfileStringA (ByVal lpAppName As String, _ ByVal lpKeyName As String, ByVal lpDefault As String, _ ByVal lpReturnedString As String, ByVal nSize As Long, _ ByVal lpFileName As String) As Long// 转换目标缓冲区参数必须改成 StringBuilderCharSet 要跟着 Alias 走 [DllImport(kernel32.dll, CharSet CharSet.Ansi, SetLastError true)] private static extern int GetPrivateProfileString( string lpAppName, string lpKeyName, string lpDefault, StringBuilder lpReturnedString, int nSize, string lpFileName); public static string ReadIni(string section, string key, string path) { var sb new StringBuilder(1024); // 容量即 nSize 的上限 int n GetPrivateProfileString(section, key, , sb, sb.Capacity, path); return n 0 ? sb.ToString() : string.Empty; // 返回值为拷贝长度0 表示没找到 }这里有三处必须核对。第一VB6 的ByVal String传的是字符串缓冲区的指针函数会往里写数据.NET 里只有StringBuilder能承担这个角色用string会直接抛访问冲突。第二CharSet.Ansi要和原声明的Alias ...A后缀保持一致如果原函数是W版本就要改成CharSet.Unicode写成CharSet.Auto在某些系统上会拿到错误的入口点。第三Long在 VB6 是 32 位对应int把返回值写成long64 位在 x64 进程里不影响结果但在结构体封送时会改变整个布局。As Any是最麻烦的一种。VB6 允许一个参数接受任意类型转换器没法自动生成对应签名通常会退化成IntPtr。这时候要么按实际调用点拆成多个重载要么手工封送。结构体参数则要注意 VB6 默认 4 字节对齐转成 C# 结构体必须标[StructLayout(LayoutKind.Sequential, Pack 4)]不写Pack时默认按 8 对齐字段之间会多出填充字节函数读到的数据整体偏移。回调函数还有一刀VB6 的AddressOf转成委托之后必须把这个委托存成静态字段或实例字段。临时创建委托传进去GC 一跑就回收下次回调直接崩在不可预测的位置而且崩溃点离真正的原因很远属于典型的黑匣子故障。5. 避坑与排查五类转换后必炸的现场这一章列的五个问题都是转换完成后「看起来没问题、跑起来出事」的类型按出现频率排序。5.1 中文注释变问号源文件编码在转换链上丢了现象转换出来的代码里中文注释和字符串常量全变成???或者一堆方块但代码结构完好编译也能过。原因VB6 源文件是 ANSI 编码在简体中文环境下就是 GBK。转换器读文件时按系统当前的 ANSI 代码页解释字节如果转换机的区域设置不是中文GBK 的双字节会被按单字节拆开每个汉字降级成问号。这个过程不可逆输出之后再改回来是不可能的。解决在中文区域设置的系统上做转换具体是控制面板里「非 Unicode 程序的语言」设为简体中文并重启或者更稳妥转换前先把所有中文字符串抽到独立的资源文件里源码里只留 ASCII 标识符这样编码问题就从根上消失了。检查手段是前面那个扫???的脚本转换完立刻跑一遍别等到排查业务逻辑时才发现注释全没了。5.2 控件数组索引错位事件签名对上了Index 没对上现象界面上有几个按钮共用一个点击处理器点第一个按钮执行的是第二个按钮的逻辑或者数组越界异常。原因VB6 的控件数组允许索引跳号也允许在设计期动态增删成员。转换器如果按「成员个数」重建数组索引就会从跳号变成连续Select Case Index里的分支自然错位。另一种情况是Load Command1(i)这种运行时动态加载成员转换器往往不处理生成的代码里根本没有对应成员。解决转换后搜索控件数组的原始索引值逐个核对生成的成员是否按原索引赋值。动态加载的成员要手工补上新建实例、设置Parent和位置、挂接同一处理器、手动维护索引字典。验收方法是把每个成员都点一遍观察行为是否与老版本一致——这个测试没法自动化只能人工过。5.3 On Error Resume Next 变成空 catch异常被静默吞掉现象新版本跑起来「什么都不报错」但数据没写进去、文件没生成日志里一片安静。原因前面说过On Error Resume Next被映射成空 catch 块过程内所有异常都被吃掉。老代码里那些「本来就会失败、失败也无所谓」的语句和「关键路径」混在同一个过程里一旦全吞关键路径出问题也没人知道。解决逐个 catch 块过一遍凡是空实现或只有注释的问一句「这里原本期望忽略哪种错误」。能用具体的异常类型替代的就收窄收窄不了的在 catch 里至少写一行日志带堆栈。我在迁移时习惯在 catch 里加一个「异常计数器」跑完回归测试看哪个过程的计数不为零那基本就是语义被放大的地方。5.4 第三方 OCX 没有等价物占位控件编译不过现象编译报「类型或命名空间不存在」定位到某个控件声明或者更隐蔽控件声明生成成了object编译过了运行时一访问属性就抛异常。原因转换器对不认识的部件只能生成占位代码或者直接跳过。老工程里那种带授权、带设计期属性、带自定义事件的表格控件没有任何自动映射的可能。解决转换前就把这类控件列出来决定是替换成原生控件、还是找商业替代品、还是干脆重写这一块。判断标准看使用深度只用来展示只读数据换成标准表格控件加数据绑定即可如果有编辑、合并单元格、自定义绘制那基本是重写。别指望转换完再回头处理因为这类控件往往是被最多窗体引用的越晚处理阻塞面越大。5.5 数组下界与 Currency/Date不报错但结果偏了现象报表金额差几分钱或者按日期统计的条数少了几条。编译通过界面正常只有对账时才发现。原因有两个。一是Option Base 1。VB6 允许把数组默认下界设成 1转换器如果忽略这行生成的数组下界变成 0For i 1 To UBound(a)的循环就少遍历一个元素反过来如果转换器保留了 1-based 语义但分配空间时按 0-based 算会多出一个空位聚合时把空值算进去。二是Currency类型。它是四位小数的定点数转成double之后浮点误差在累加中放大转成decimal才等价。Date在 VB6 内部是 Double如果老代码里把日期当数值做加减d d 1转换后要确认类型没被当成字符串。解决转换后全局搜索Option Base、ReDim、UBound三组关键字逐个确认下界金额字段的类型声明逐个核对凡是Currency必须是decimal不能是double也不能是float。验收靠数据比对不靠肉眼——下一章讲怎么比。6. 验收与回归从「能编译」推到「算得对」编译通过只是入场券。我见过太多迁移项目卡在这一步解决方案能打开程序能启动界面看着一模一样一上生产数据就出问题。原因是没有把「能跑」和「算得对」分开验收。6.1 三层验收编译、冒烟、数据比对第一层是编译零错误、零警告里那些「使用了未赋值变量」的警告也要清零这类警告在 VB6 时代是合法的在 .NET 里往往意味着逻辑分支漏了。第二层是冒烟每个窗体都要打开一次每个菜单项点一遍每个按钮点一遍重点看控件遮挡、布局偏移、下拉框内容是否为空。这一步纯人工但可以用清单管理——前面那份.vbp清单打上勾窗体多的项目分批做别指望一次点完。冒烟阶段发现的问题九成是控件层修起来快。第三层是数据比对也是最容易被跳过的一层。方法是准备一批固定输入让老系统和新系统各跑一遍把输出文件做归一化后逐行比对。6.2 影子运行新旧两版同输入比输出老系统跑在旧机器上新系统跑在开发机上两者读同一份输入数据各自吐一份结果文件然后比。# diff_run.py归一化后比对两版输出抹掉时间戳等易变字段 import pathlib import re import sys import difflib TS re.compile(r\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}) def norm(path): text path.read_text(encodingutf-8, errorsreplace) text TS.sub(TS, text) # 时间戳不参与比对 lines [l.rstrip() for l in text.splitlines()] return [l for l in lines if l.strip()] # 空行不参与比对 old_root, new_root pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) bad 0 for old in sorted(p for p in old_root.rglob(*) if p.is_file()): new new_root / old.relative_to(old_root) if not new.exists(): print(f[缺失] {old.relative_to(old_root)}) bad 1 continue a, b norm(old), norm(new) if a ! b: p a hrefhttps://download.csdn.net/download/zyyujq/89937910 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p