简介:面向 Delphi 7 开发者的多语言本地化解决方案,这份资源提供 TsiLang V5.25 组件控件,可实现在应用程序中便捷管理多语言界面、运行时动态切换语言,并处理日期、货币等本地化细节。包内共 248 个文件,约 3.38MB,包含 dcu、pas、dfm 等主要源码与窗体文件,以及演示工程、帮助文档、语言包等,便于直接安装试用或参照集成。已有 434 人学习下载,适合有基础 Delphi 开发经验、正在为跨语言应用做国际化改造的开发者。通过该控件可快速建立语言包管理机制,减少界面翻译和资源维护成本,同时借助自带示例工程快速掌握配置与事件处理方式,提升应用面向全球用户的适应能力。 说起 Delphi 多语言控件,我第一个反应不是方案有多全,而是坑有多深。上个月我把一套跑了七八年的老系统从 Delphi 2007 升级到 10.4,顺便要支持中英双语切换。一开始以为无非是把按钮和标签翻译一遍,真动手才发现,窗口上的 Caption、Hint、网格列头、Action 列表、Frame 内的子控件,甚至弹出菜单里的字符串,全都不是同一个地方管的。于是我在 TsiLang、dxGetText、自研轮子之间来回折腾,最终才整理出一套能落地的方案。这篇就把过程、选型和踩过的坑一次性说清楚,如果你正准备给 Delphi 项目做国际化,大部分经验都能直接用。
1. 先想清楚:多语言控件到底要管哪些事
1.1 界面字符串的三种藏身之处
在 VCL 里,一个窗体的界面文本并不只存在于一个地方。设计期放的 TLabel.Caption、TButton.Caption 会被序列化进 DFM;代码里通过Form1.Label1.Caption := 'hello'改掉的值,运行期才会出现;第三类则是 TAction.Caption、TPopupMenu.Items 的文本,以及数据库里查出来的业务字符串。绝大多数多语言控件真正能处理的,只是第一类和部分第二类,后面两类需要额外桥接。理解这一点,就不会对控件抱有不切实际的期待。
我再举个例子。你创建一个窗体,在 Object Inspector 里改掉按钮的 Caption,这个改动会写进.dfm文件;程序跑起来后,如果代码里又执行了Button1.Caption := '取消',那么 DFM 里的原始值根本没机会参与运行。多语言控件如果只是简单替换 DFM 里的字符串,根本拦不住运行期的赋值。所以真正可靠的多语言方案,必须同时覆盖控件创建完成后的初始化和运行期被代码改写后的再刷新两条链路。
1.2 为什么不能靠全局查找替换
很多人想到的第一个办法,是全文搜索把'保存'替换成'Save'。在小工具里也许能应付,但到了正式项目立刻崩盘:一个词在不同窗口可能需要不同翻译,一句话中间有变量需要格式化,弹窗消息里的换行和参数位置会变。更致命的是,替换完后源文件里的中文就没了,以后想切回中文还需要做反向替换,两套语言互相覆盖,版本管理里一片混乱。
所以多语言控件的本质不是文本替换工具,而是一套 Key-Value 映射加运行期遍历组件树加属性赋值的机制。控件替你完成的是最机械的部分:把组件的某个字符串属性读出来,拿组件名加属性名当 Key,去字典里查目标语言,再写回去。只要想清楚这一层,后面不管是买商业控件还是自己写,方向都不会偏。
写一个最简单的遍历并不难,难的是怎么覆盖各种特殊情况。比如TPopupMenu.Items是一个嵌套列表,它挂在 MainMenu 组件内部,不是 Form 的直接子组件;TDBGrid.Columns是一组 TColumn 对象,而 TColumn 并不是 TComponent 的子类,所以通用遍历根本不会碰到它们。这些都是需要一个控件而不只是一个函数的原因。
2. 主流方案横评:TsiLang、dxGetText和自研框架
2.1 三条技术路线对比
我实际纠结过的方案主要是三个,先列个表方便对比。
| 方案 | 授权方式 | 字符串提取 | 运行期切换 | 动态控件 | 主要痛点 |
|---|---|---|---|---|---|
| TsiLang | 商业 | 设计期集成,一键收集 | 强 | 支持良好 | 私有格式,授权成本高 |
| dxGetText | 开源 | 脚本提取到 PO 文件 | 一般 | 一般 | VCL 集成需要自己封装 |
| 自研字典 | 无 | 正则脚本加人工 | 完全可控 | 自己写逻辑 | 前期开发成本大 |
TsiLang 属于商业控件里老牌的多语言方案,设计期集成度很高,装完以后每个窗体可以单独管理一份翻译文件,也能一键把整个项目的字符串收集出来。它处理动态控件和第三方控件的兼容性在商业组件里算第一梯队,但缺点也很明显:你需要按席位购买授权,版本升级还会绑定 IDE 版本。
dxGetText 则是把 GNU gettext 的体系搬到 Delphi 里,用 PO 文件作为翻译载体。它的优势是开源、命令行友好,很多现成工具链可以配合;劣势是提取字符串需要脚本扫描源文件,对 VCL 的动态属性覆盖比较弱,运行期切换语言经常要你自己写递归刷新逻辑。
自研字典则是把字符串收拢到一张表,控件属性用 RTTI 遍历。对于控件体系很标准、没有多少特殊需求的团队,反而是一种干净的方案。这三种不是非此即彼,实际项目里经常混用。
2.2 我选型的判断逻辑
我的判断逻辑有三条:第一是接入老项目的成本要低,不能逼我把每个 Form 的 DFM 都重构一遍;第二是运行期切换要可靠,不能只有 Form.Caption 变英文,弹窗消息还是中文;第三是团队维护要简单,翻译文件不能放在二进制资源里,否则每次发版都要重新编译。
最后我选了 TsiLang 做前期的字符串提取,但运行时切换没有完全依赖它,主要用的是自研的 LanguageManager。原因有两个:TsiLang 的运行时切换会强制把翻译文件保存在它自己的数据结构里,一旦项目里的控件的属性名超出了它内置的白名单,就要额外写插件;而自研字典用标准 RTTI 遍历,遇到新控件只需要在属性名白名单里加一个字符串,改动更直接。
这里不是说自研一定更好,而是你把控件当成提取工具还是运行引擎,定位要清晰。TsiLang 的提取能力帮我把 2600 多处散落字符串汇总成清单,这个体力活节省了非常多时间;而运行时的自动切换我用 300 行代码就能控制,反而不想再被一套私有格式绑住。
3. 实际接入一个旧项目的完整步骤
3.1 扫描存量字符串,先摸清家底
接入前第一件事,是把全工程里能被控件识别的字符串找出来。不要指望控件自动完成这一步,至少先用文本搜索跑一遍,搜Caption :=、Hint :=、Text :=、MessageDlg(这几类模式。我当时的项目里,硬编码字符串分布在 87 个窗口和 32 个 Frame 里,一共有 2600 多处。如果漏掉一块,后面测界面时会这里英文那里中文,非常难受。
我建议在动代码之前,先用脚本把扫描结果导成 CSV,每行记录所在文件、窗体名、组件名、属性名、原文。这一步不光为了提取,更重要的是让翻译人员可以对着清单工作,而不是带 IDE 跑进代码里找字符串。扫描脚本可以用 Delphi 写,也可以用正则脚本,关键是输出格式稳定,之后校验也方便。
3.2 用字典类接管所有文本映射
扫描完的字符串,需要落到一个统一的字典类里。我的 LanguageManager 核心结构很简单:一个TDictionary<string, string>存当前语言的 Key-Value,一个 Translate 方法负责查字典,查不到就返回默认文本。
type TLanguageManager = class private FDict: TDictionary<string, string>; public function Translate(const AKey, ADefault: string): string; end; function TLanguageManager.Translate(const AKey, ADefault: string): string; begin if not FDict.TryGetValue(AKey, Result) then Result := ADefault; end;要注意TDictionary<string, string>在 Delphi 里默认区分大小写,所以 Key 的规范必须统一。我使用窗体名.组件名.属性名作为 Key,比如MainForm.btnSave.Caption。组件名并不是窗体实例名,而是设计期的 Name。这样即使运行时窗体被创建了两次,只要设计期 Name 一致,Key 就不会变。这里也说明一下字符串能不能作字典 Key:能,但你得给 Key 加上足够上下文,否则不同窗体的相同文本会互相覆盖。
3.3 递归应用语言到组件树
字典准备好之后,核心工作就是递归遍历组件树。用 RTTI 的 GetPropInfo 和 SetStrProp,可以不用写一堆if TLabel、if TButton的分支,你只需要定义一份属性名白名单:Caption、Hint、Text、Title。遇到属性类型是字符串,就调用 Translate 得到目标语言并写回。
function IsStrPropKind(AKind: TTypeKind): Boolean; begin Result := AKind in [tkString, tkLString, tkWString, tkUString]; end; procedure TLanguageManager.ApplyToComponent(AParent: TComponent); const PropNames: array[0..3] of string = ('Caption', 'Hint', 'Text', 'Title'); var I, J: Integer; PropInfo: PPropInfo; Comp: TComponent; begin for I := 0 to AParent.ComponentCount - 1 do begin Comp := AParent.Components[I]; for J := 0 to High(PropNames) do begin PropInfo := GetPropInfo(Comp, PropNames[J]); if (PropInfo <> nil) and IsStrPropKind(PropInfo^.PropType^.Kind) then SetStrProp(Comp, PropInfo, Translate(AParent.Name + '.' + Comp.Name + '.' + PropNames[J], GetStrProp(Comp, PropInfo))); end; if Comp is TCustomActionList then ApplyToActions(TCustomActionList(Comp)); if Comp is TCustomDBGrid then ApplyToDBGridColumns(TCustomDBGrid(Comp)); ApplyToComponent(Comp); end; end;这个递归方法会在 Form 的 OnCreate 末尾调用一次,也会在切换语言时对整个 Form 再调用一次。由于递归会深入 TFrame 和 TPanel 等容器,所以大部分嵌套控件都能被覆盖。这里再补一句,遍历层级应该用 TComponent 而不是 TWinControl,因为像 TImage 这类没有句柄但有 Hint 的组件,用 WinControl 遍历会漏掉。
3.4 让Action和第三方表格列头跟手
通用遍历最大的缺口有两个。第一个是 TActionList 里的 TAction,它虽然也是组件,但文本并不像普通控件那样直接参与控件的子组件关系,需要单独遍历 ActionCount。第二个是 TDBGrid 的列标题,它藏在Columns[i].Title.Caption,TColumn 本身不是 TComponent,通用 RTTI 根本碰不到。这两个不处理,界面会出现明明组件都被改过了,但按钮上的字没变、表格列头还是老语言的情况。
procedure TLanguageManager.ApplyToActions(AActionList: TCustomActionList); var I: Integer; begin for I := 0 to AActionList.ActionCount - 1 do AActionList.Actions[I].Caption := Translate(AActionList.Owner.Name + '.' + AActionList.Actions[I].Name + '.Caption', AActionList.Actions[I].Caption); end;第三方控件同样要具体处理。比如 TDBGridEh、TcxGrid 都有类似的列集合,我的做法是维护一份特殊容器处理器列表,每个处理器负责一种控件类型。这套东西说完有点复杂,但无非是注册、执行两步。不要试图用一个万能 RTTI 搞定所有控件,那才是真正的无底洞。
4. 切换语言时的踩坑排查记录
4.1 乱码不是编码问题,是Charset没跟着走
第一次完整切换后,英文版正常,切回中文发现部分按钮的汉字变成了问号。我第一反应是语言文件编码没统一,把 JSON 改成 UTF-8 with BOM,重新生成,问题依旧。后来挨个控件看,发现出问题的都是 TButton,而 TLabel 没问题。对比后发现,这些按钮在运行期通过代码动态修改过 Font,Font.Charset 停留在了RUSSIAN_CHARSET,因为我上一次测试时用的语言包是俄文。
解决方式是在 ApplyLanguage 最后统一走一遍 SetControlCharset,把所有可视控件的 Font.Charset 设成DEFAULT_CHARSET,并调用Font.Changed让 VCL 重建字体句柄。中文和大部分拉丁语言用 DEFAULT_CHARSET 都能正常显示,少数阿拉伯语、希伯来语需要单独指定。这个坑很容易被误判成编码问题,排查时最好直接看控件的 Font.Charset。
4.2 动态创建的组件漏翻译
另一个常见问题是,动态创建的 Form 或 Frame 没有被翻译。因为它们在主窗体的 ApplyToComponent 调用之后才被创建,字典虽然加载了,但没人去遍历它的子组件。我的解决方法是让每个 Form、Frame 都重写一个 ApplyLanguage 虚方法,在 OnCreate 里调用 LanguageManager.ApplyToComponent(Self)。这样不管是静态窗口还是动态弹出的窗口,创建完成的那一刻就会应用当前语言。
还有更隐蔽的情况:一个 Frame 嵌在 Panel 里,Panel 又嵌在另一个 Frame 里。递归遍历能进入组件容器,但如果你把遍历限定成只处理 WinControl,就可能漏掉没有句柄的 TImage 这类组件。所以遍历层级一定要用 TComponent 递归,并且每次在窗体创建完成后强制应用一次,动态创建的子窗体也要在 OnCreate 里补上这一步。
4.3 数据库里的内容到底归谁管
多语言控件管不到数据库里查出来的字段值。比如产品名称,英文界面希望显示English Name,中文界面显示中文名。这种内容只能在数据库层加语言字段,或者建一张翻译表,由查询时传入语言代码来决定取哪个字段;控件最多帮你把表头翻译掉。很多初级团队把这个责任甩给多语言控件,结果发现不管怎么切换,表格里的业务数据纹丝不动。
这里给一个实用建议:在项目里定义一个全局函数L(const AEnglish, AChinese: string): string,返回当前语言对应的文案。业务模块里不用费心维护字典 Key,直接在参数里给中英文,简单直接。控件管界面静态文本,L 函数管动态拼接文本,两个入口分开,逻辑清晰。
4.4 一个难以复现的悬案:字典Key冲突
客户说主界面保存按钮切到英文后变成了 Delete。我打开语言包,MainForm.btnSave.Caption=Save明明是对的。于是我在 Translate 函数里打断点,发现传进来的 Key 不是MainForm.btnSave.Caption,而是MainForm.btnDelete.Caption。也就是说,按钮的 Name 在运行时已经变了。
追根溯源,原来这个窗口在启动流程里被创建了两次,第二次实例的 Name 被 VCL 自动改成了MainForm_1,而我在扫描阶段用正则提取字符串时,Key 用的却是运行时的窗体 Name,扫描到MainForm_1.btnDelete.Caption后,翻译文件里也有一份。等到用户打开第二次实例,Translate 优先命中了MainForm_1.btnDelete.Caption,自然就错位了。
这个坑的教训是:字典 Key 必须基于设计期 Name 和类名,不能用运行时实例名。后来我在扫描脚本里统一用ClassName + '.' + CompName做 Key,并且扫描时做了去重。前面 3.2 里说的 Key 规范,就是从这次事故里总结出来的。
5. 让多语言维护不再痛苦的辅助工具
5.1 用正则表达式从源文件里捞字符串
扫描硬编码字符串这件事,我最后是用脚本完成的。核心正则其实很短,像\.(Caption|Hint|Text|Title)\s*:=\s*'([^']+)'能抓到一大半,但实战里要注意两个坑:一是 Delphi 字符串里的单引号转义是'',所以[^']+会在转义处断掉;二是字符串可能跨多行。我的做法是按行读取,遇到单引号没有成对就继续读取下一行,直到闭合为止。
抓完以后,脚本会把每个条目的文件路径、窗体名、组件名、属性名、原文输出成 CSV。人工翻一遍再导回 JSON 语言包。这一步不用做得太重,但一定要让翻译人员和开发人员能对同一份清单工作,否则后面谁改了文案,没人记得住。
5.2 语言文件格式选型:JSON 好过 INI
语言包我推荐用 JSON,而不是 INI 或 DFM 资源。JSON 对 Unicode 友好,可以嵌在 MSIX 或独立目录里,还方便翻译平台对接。Delphi 的 System.JSON 单元解析几百个 Key 完全够用。记得保存成 UTF-8 with BOM,避免某些 Windows API 读文件时默认按 ANSI 处理。
加载顺序也有讲究:程序启动时先读默认语言包,再读目标语言包覆盖。这样目标语言包缺了某个 Key,Translate 函数还能从默认语言取到原文,界面不会出现空白按钮。这个回退机制在多人维护翻译文件时非常重要,因为总有人漏翻。
5.3 从人工到半自动的协作流程
我把语言包放到版本控制里单独一个目录,翻译人员拿到 CSV 后修改,每周合一次。合包前跑一个校验脚本,主要检查三件事:Key 有没有重复、是否还有 Key 没有对应目标语言、是否出现了源文件里已经不存在的孤儿 Key。脚本本身也是 Delphi 写的,几百行,里面对 CSV 的去重逻辑和 4.4 的坑直接相关。
整个过程跑顺以后,发版前只需要花半小时更新语言包,界面上的可见文本不会再变成硬编码的一部分。后续新功能只要开发阶段坚持用_()函数包裹新字符串,多语言成本基本就不会再涨。
最后再分享一个小技巧:无论你最终选哪个控件,从第一天起就在所有代码里用_('key', '默认文本')包裹界面字符串,而不是直接写Label1.Caption := '保存'。这样后期接入多语言控件时,只需要把_函数内部从返回默认文本改成查字典,所有已经写完的界面会自动走翻译链路。这个习惯帮我省掉了后来大概两千处的手工替换,也让多语言控件真正变成一个可以随时切换、无侵入的底层组件。
本文还有配套的精品资源,点击获取