ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Delphi多语言控件实战:老项目出海与运行时切换避坑指南

Delphi多语言控件实战:老项目出海与运行时切换避坑指南

简介:TsiLang V5.25 是面向 Delphi7 开发者的多语言组件包,用于解决跨语言应用本地化难题,适合需要将软件推向多地区市场的中级开发者。它提供统一接口管理多语言资源,支持菜单、对话框等界面文本自动翻译,并可在运行时动态切换语言而无需重启程序;配合 IDE 集成设计器、独立语言包管理、切换事件通知及日期时间与货币格式等区域适配能力,兼顾国际化与本地化需求。压缩包共 248 个文件,约 3.38MB,以 pas 源码、dcu 编译单元、dfm 窗体文件为主,辅以 cpp、h 头文件、res 资源、sil 语言包及示例工程与帮助文档,便于直接编译研究或集成到现有项目。已有 440 人学习下载,可帮助读者快速理解组件结构、复用示例工程并掌握多语言配置与维护思路。

1. Delphi 多语言控件:老项目要出海,先别急着改代码

接手一个跑了七八年的 Delphi 桌面项目,客户突然说要出东南亚版本,界面得跟着系统语言走。打开工程一看,窗体上几十个 Label、Button 的 Caption 全是硬编码中文,连个资源字符串都没抽。这时候最省事的做法不是逐个窗体改,而是引入一套多语言控件,把翻译从代码里剥出来。Delphi 多语言控件这类资源,核心价值就在这:它把界面文本、字体、布局方向这些和语言绑定的东西做成可切换的运行时组件,让一套 exe 跑多套语言,不用为每个语种单独编译。适合谁?手上维护着 VCL 或 FMX 老项目、要做出海交付、又不想推倒重来的团队。下面按我实际拆包和复现的顺序讲,从控件怎么接进工程,到翻译文件怎么组织,再到多语言切换时那些让人翻车的细节。

2. 控件接入工程:从安装包到第一个可切换窗体

2.1 先搞清楚这套控件管的是哪一层

Delphi 的多语言方案大致分三档:一是 IDE 自带的资源字符串表(ResourceString + resourcestring),编译期绑定,切换语言要重编译;二是外部语言文件加运行时替换,控件负责在窗体创建后遍历组件树、按名字回填文本;三是带设计期编辑器的控件包,装进 IDE 后能在窗体设计器里直接维护多语言条目。这份资源属于后两档的结合体,既有运行时切换的引擎,也带设计期维护入口。判断依据很简单:看包里有没有.dpk设计期包和运行时包分离的结构,有的话就是标准的三方控件交付形态。

我一般先看目录结构再动手。典型布局是运行期包(Runtime)、设计期包(Design)、语言文件样例(Lang 或 Res)、Demo 工程四块。运行期包只依赖 VCL/FMX 基础单元,设计期包才依赖 IDE 的 ToolsAPI。装的时候顺序不能反:先编译安装运行期包,再装设计期包,否则设计期包会因为找不到运行期单元而报“找不到单元 xxx”。这个顺序坑我见过不止一次,尤其是拿到别人二次打包的版本,包引用被改过。

2.2 安装与最小可运行示例

假设包文件已经解压到本地目录,用命令行编译安装是最可控的方式,比在 IDE 里点半天稳。下面这段是常见的批处理编译脚本,路径按实际改:

# 进入包所在目录,先编译运行期包 cd /path/to/package/runtime dcc32 -B MyLangRuntime.dpk # 再编译设计期包,注意 -LU 指向运行期包输出目录 cd ../design dcc32 -B -LU../runtime MyLangDesign.dpk

逻辑说明:-B是强制全量重编译,避免增量编译残留旧 DCU 导致版本错配;-LU指定单元搜索路径,让设计期包能找到刚编出来的运行期单元。参数上,如果项目用的是 64 位目标,把dcc32换成dcc64,并且运行期包和设计期包的位数必须一致,混用会出现在 IDE 里能装、一编译就链接失败的情况。

装完之后建一个最小窗体验证。放一个按钮、一个标签,加一个语言切换下拉框,核心代码就三行:

procedure TForm1.FormCreate(Sender: TObject); begin // 初始化语言管理器,指向语言文件目录 LangManager.LoadFromDir(ExtractFilePath(ParamStr(0)) + 'Lang'); // 绑定当前窗体,控件会遍历组件树回填文本 LangManager.AttachForm(Self); end; procedure TForm1.cbLangChange(Sender: TObject); begin // 按语言代码切换,内部触发重新回填 LangManager.CurrentLanguage := cbLang.Items[cbLang.ItemIndex]; end;

LoadFromDir负责扫描目录下的语言文件并建立索引,AttachForm是关键——它把窗体上所有带Caption、Text、Hint属性的组件登记进映射表,切换时按组件名回填。这里有个隐含前提:组件必须有稳定的Name,设计期随手拖出来的Label1、Button2一旦改名,翻译条目就对不上。所以接入前先做一轮命名规范化,比事后补映射省事得多。

2.3 语言文件长什么样,字段怎么定

语言文件一般是键值对结构,键用“窗体名.组件名.属性名”的路径式命名,值就是译文。下面是一个示意片段:

[MainForm] MainForm.Caption=Order Management btnSave.Caption=Save lblTotal.Caption=Total lblTotal.Hint=Amount including tax [MainForm.zh_CN] MainForm.Caption=订单管理 btnSave.Caption=保存 lblTotal.Caption=合计 lblTotal.Hint=含税金额

这种按语言分节的结构好处是新增语种只加一节,不用动已有内容;坏处是同一组件的多个属性散在不同节里,维护时容易漏。我一般要求翻译交付时按“组件”聚合,而不是按“语言”聚合,落地时再用脚本转成控件要的格式。字段命名上,属性名必须和 VCL 属性大小写一致,Caption写成caption在某些版本里能容错,在另一些版本里直接静默跳过,这种玄学问题排查起来最费时间,统一按原样写最保险。

3. 运行时切换的机制:组件树遍历与文本回填的边界

3.1 回填不是万能的,先分清哪些属性归控件管

控件遍历组件树时,能自动处理的是有明确文本语义的属性:Caption、Text、Hint、Items.Strings(列表类)、Columns[].Title.Caption(表格类)。但有些东西它管不了,也不该管:动态创建的组件、运行时拼出来的字符串、数据库里读出来的内容。这三类必须走业务层自己的翻译接口,不能指望控件兜底。

我见过一个典型翻车:某窗体在FormShow里动态 new 了十几个 TLabel 显示统计项,语言切换后这些标签纹丝不动。原因就是AttachForm只登记了设计期存在的组件,运行时 new 出来的不在映射表里。解决办法是在创建后手动注册:

var lbl: TLabel; begin lbl := TLabel.Create(Self); lbl.Parent := pnlStats; lbl.Name := 'lblStat' + IntToStr(i); // 必须有唯一稳定名 LangManager.RegisterComponent(lbl); // 手动登记进映射表 end;

RegisterComponent是控件提供的补充入口,参数就是组件本身,内部按Name去语言文件里找对应条目。注意Name必须在整个窗体范围内唯一,重名会导致后登记的覆盖先登记的,切换时只有一个能生效。

3.2 字体和布局:语言换了,界面不能塌

文本回填只是第一步,真正让界面“像那么回事”的是字体和布局适配。中文换英文,同样字号的文本宽度可能差三成;换阿拉伯语这类从右往左的语言,整个布局镜像都得翻。控件一般提供两个钩子:OnTranslateComponent事件和布局方向属性。

procedure TForm1.LangManagerTranslateComponent(Sender: TObject; AComponent: TComponent; const APropName: string; var AValue: string); begin // 针对特定组件做字体微调,避免长译文撑破按钮 if (AComponent is TButton) and (Length(AValue) > 12) then TButton(AComponent).Font.Size := 8 else if AComponent is TButton then TButton(AComponent).Font.Size := 9; end;

这个事件在每次回填单个属性时触发,AValue是即将写入的译文,可以在写之前改。上面这段是血泪经验:英文按钮文案普遍比中文长,固定宽度按钮放不下就换行或截断,调小字号是最不破坏布局的折中。布局方向则用BiDiMode配合控件的FlipLayout开关,从右往左语言下把pnlMain的Align从alLeft改成alRight,这类镜像逻辑控件能处理一部分,但自定义绘制的面板还是得自己写。

3.3 切换时机与刷新范围

语言切换放在哪个时机,直接影响体验。放在FormCreate里,只影响当前窗体;放在主窗体菜单里,要通知所有已打开的窗体。控件通常提供全局事件OnLanguageChanged,订阅它做统一刷新:

procedure TMainForm.LangManagerLanguageChanged(Sender: TObject); var i: Integer; begin for i := 0 to Screen.FormCount - 1 do if Screen.Forms[i] <> Self then LangManager.AttachForm(Screen.Forms[i]); // 重新绑定已打开窗体 end;

遍历Screen.Forms重新AttachForm是最省事的全量刷新,代价是每次切换都重走一遍组件树,窗体多的时候有短暂卡顿。优化做法是只对可见窗体刷新,隐藏窗体标记为“脏”,下次显示时再绑。参数上注意Screen.FormCount包含模态窗体,切换时如果有模态框开着,要单独处理,否则模态框里的文本不会更新。

4. 避坑与排查:多语言切换最常见的五类翻车

4.1 切换后部分文本没变

现象:点切换,大部分文本变了,个别 Label 还是旧语言。原因通常是组件Name在语言文件里缺失,或者大小写不一致导致匹配失败。排查办法是打开控件的调试日志(多数实现有LogMissing开关),把缺失的键打出来,对照窗体设计器逐个补。解决上,我习惯在接入初期先跑一遍“导出当前窗体所有文本键”的功能,生成一份完整键清单,再交给翻译,从源头避免漏项。

4.2 中文乱码或问号

现象:语言文件里中文显示正常,运行时变成乱码。原因是文件编码和读取时的编码声明不匹配,UTF-8 文件被按 ANSI 读。解决是确认语言文件存为 UTF-8 带 BOM,并在加载时显式指定编码;如果控件版本较老不支持编码参数,就在加载前用流转换一遍。这个坑在跨团队交付时特别常见,翻译方用记事本另存,编码就悄悄变了。

4.3 设计期能预览,运行期不生效

现象:IDE 里切换语言预览正常,编译出来的 exe 切换无效。原因多半是语言文件没随 exe 发布,或者LoadFromDir的路径用了相对路径,工作目录一变就找不到。解决是把语言目录固定到ExtractFilePath(ParamStr(0))下,并在加载失败时给出明确提示而不是静默返回。我一般还会在关于对话框里显示当前加载的语言文件路径,出问题时用户截图就能定位。

4.4 动态创建的组件切换后丢失文本

现象:运行时 new 出来的组件,首次显示有文本,切换语言后变空。原因是这些组件没登记进映射表,切换时被当成“无翻译条目”处理,某些实现会直接清空。解决是在创建后立即RegisterComponent,并在组件销毁前UnregisterComponent,避免映射表里留下野指针。这条和 3.1 是同一个根因,但表现形式不同,前者是不变,后者是变空,排查时容易误判成控件 bug。

4.5 多窗体共享组件名导致串译

现象:两个窗体都有btnOK,切换后其中一个显示成了另一个的译文。原因是语言文件的键如果只按组件名索引、不带窗体前缀,就会冲突。解决是强制键格式为“窗体名.组件名”,并在加载时校验重复键,发现冲突直接报错而不是后者覆盖前者。这条在多人协作的工程里几乎必踩,早加校验早省心。

5. 进阶:把翻译流程做成可校验的流水线

控件本身解决的是运行时切换,但真正决定多语言项目成败的是翻译资产的管理。我现在的习惯是把语言文件当代码管:进版本库、走 diff、加校验脚本。具体做法是写一个导出脚本,扫描所有.dfm文件,提取每个窗体的组件名和文本属性,生成一份“待翻译键清单”,和现有语言文件做差集,缺的标红、多的标废弃。这样每次发版前跑一遍,就不会出现“某个新加的按钮忘了翻译”这种低级问题。

import re, os, configparser # 从 dfm 文本里粗提组件名和 Caption/Text 属性 pattern = re.compile(r'object\s+(\w+):\s+\w+.*?\n(.*?)\nend', re.S) prop = re.compile(r'(Caption|Text|Hint)\s*=\s*[\'"](.*?)[\'"]') keys = set() for root, _, files in os.walk('forms'): for f in files: if f.endswith('.dfm'): form = os.path.splitext(f)[0] text = open(os.path.join(root, f), encoding='utf-8', errors='ignore').read() for m in pattern.finditer(text): comp = m.group(1) for p in prop.finditer(m.group(2)): keys.add(f'{form}.{comp}.{p.group(1)}') # 和语言文件比对,输出缺失键 cfg = configparser.ConfigParser() cfg.read('Lang/en_US.ini', encoding='utf-8') existing = {f'{s}.{k}' for s in cfg.sections() for k in cfg[s]} missing = sorted(keys - existing) print('\n'.join(missing) if missing else 'all keys covered')

这段脚本的定位是“粗筛”,正则匹配 dfm 文本会有漏网(比如多行字符串、继承窗体),但覆盖八成以上足够用来卡发版。参数上errors='ignore'是为了容忍个别非 UTF-8 的老 dfm,代价是可能漏掉含特殊字符的条目,所以输出结果还要人工扫一眼。跑通之后把它挂到构建脚本里,缺失键非空就让构建失败,比靠人记靠谱得多。

另一个值得做的验证是“伪翻译测试”:把所有译文替换成加长版的乱码串(比如在原文前后各加十个字符),跑一遍界面看有没有截断、重叠、按钮被挤出窗体。这个手法能在正式翻译交付前就暴露布局问题,比等客户截图反馈快得多。我一般会在每个大版本发布前强制走一遍伪翻译加键清单校验,这两步加起来不到十分钟,能挡掉后面几天的返工。从那以后我每次接多语言需求,都先把导出脚本和伪翻译流程搭好再动界面,顺序反了就是给自己挖坑。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进