简介:TsiLang V5.25 是面向 Delphi 7 开发者的多语言组件包,用于解决跨语言应用本地化难题,适合需要将软件推向多地区市场的中级开发者。它提供统一接口管理多语言资源,支持菜单、对话框等界面文本自动翻译,并可在运行时动态切换语言而无需重启程序;配合 IDE 集成设计器、独立语言包管理、切换事件通知及日期时间与货币格式适配,兼顾国际化与本地化需求。压缩包共 248 个文件,约 3.38MB,以 pas 与 dcu 源码单元、dfm 窗体文件、cpp 与 h 头文件、res 资源及 sil 语言包为主,另含 dpr、bpr 工程文件与 exe 示例、hlp 与 chm 帮助文档,便于直接编译研究。已有 440 人学习下载,可帮助读者快速理解多语言控件的接入方式、语言包组织与运行时切换机制,为实际项目本地化提供可参考的完整实现。
1. Delphi 多语言控件:老项目要出海,界面翻译这关怎么过
接手一个跑了七八年的 Delphi 桌面项目,业务逻辑稳得像老狗,突然产品说要出东南亚版本,界面得同时挂中文、英文、泰文。第一反应是找现成的多语言控件库,结果发现市面上的方案要么绑死某个第三方组件包,要么只支持设计期静态切换,运行时改语言得重启。Delphi 多语言控件这个话题,本质上解决的是三件事:字符串资源怎么从窗体里剥离、运行时怎么无痛切换、以及布局在德语长词和泰文断行下怎么不崩。适合的人群很明确——手里有 VCL 或 FMX 存量项目、不想重写 UI、又必须交付多语版本的团队。下面按我实际趟过的路径拆,从资源组织到运行时切换,再到那些只有真跑起来才会暴露的坑。
2. 先把字符串从窗体里剥出来:资源组织的三种落地方式
2.1 为什么不能直接改 Caption 硬编码
很多老项目的窗体里,Label1.Caption := '用户名'这种写法散落在几十个单元里。直接做多语言的第一反应是写个函数按语言返回字符串,但这样会把翻译逻辑和业务逻辑搅在一起,后期加一门语言要翻遍所有.pas文件。更麻烦的是设计期属性——.dfm里存的 Caption 是二进制或文本资源,运行时改起来要么遍历组件树,要么在FormCreate里逐个赋值,组件一多就是灾难。
常见做法是把所有可见字符串收敛到一处。Delphi 原生给了两条路:一是.dfm的字符串表机制,配合ResStringPool或第三方翻译工具导出;二是自己维护一份外部资源文件,启动时加载。前者改动小但灵活性差,后者可控性强但要自己写加载器。我一般倾向后者,因为翻译交付物通常是 Excel 或 CSV,让译员直接改.dfm不现实。
2.2 用外部 JSON 做语言包的最小结构
先定资源格式。JSON 的好处是层级清晰、Delphi 有现成解析库、译员用文本编辑器也能改。结构上按「窗体名 → 控件名 → 属性」三级组织,避免不同窗体里同名控件冲突。
{ "lang": "zh-CN", "forms": { "frmLogin": { "Caption": "用户登录", "lblUser": { "Caption": "用户名" }, "lblPwd": { "Caption": "密码" }, "btnOk": { "Caption": "确定" }, "btnCancel": { "Caption": "取消" } }, "frmMain": { "Caption": "主界面", "mnuFile": { "Caption": "文件" } } } }这段结构的关键在于forms下按窗体类名索引,每个控件再按组件Name索引。属性名直接对应 VCL 的 published 属性,这样加载器可以用 RTTI 统一赋值,不用为每个控件写特判。lang字段用于校验加载的语言包和当前语言是否一致,防止误加载。
2.3 加载器:用 RTTI 批量赋值的核心代码
加载器的职责是读 JSON、找到对应窗体实例、遍历控件树、按 Name 匹配后写属性。核心是用TypInfo单元的SetStrProp,它对字符串类属性通用,不用关心具体是 Caption 还是 Text。
uses System.JSON, System.TypInfo, System.Classes; procedure ApplyLangPack(AForm: TForm; const AJson: TJSONObject); var FormsObj, FormObj, CtrlObj: TJSONObject; Pair: TJSONPair; Comp: TComponent; PropName, PropVal: string; begin FormsObj := AJson.GetValue('forms') as TJSONObject; FormObj := FormsObj.GetValue(AForm.ClassName) as TJSONObject; if FormObj = nil then Exit; // 先处理窗体自身属性 for Pair in FormObj do begin if Pair.JsonValue is TJSONString then begin PropName := Pair.JsonString.Value; PropVal := Pair.JsonValue.Value; SetStrProp(AForm, PropName, PropVal); end; end; // 再遍历控件树,按 Name 匹配子对象 for Pair in FormObj do begin if not (Pair.JsonValue is TJSONObject) then Continue; Comp := AForm.FindComponent(Pair.JsonString.Value); if Comp = nil then Continue; CtrlObj := Pair.JsonValue as TJSONObject; for var SubPair in CtrlObj do SetStrProp(Comp, SubPair.JsonString.Value, SubPair.JsonValue.Value); end; end;逻辑说明:第一轮循环处理窗体级字符串属性(如 Caption),第二轮按控件 Name 用FindComponent定位实例,再对控件属性赋值。SetStrProp内部走 RTTI 的SetStrProp,对非字符串属性会抛异常,所以 JSON 里只放字符串属性。参数上,AForm.ClassName必须和 JSON 里的键完全一致,大小写敏感,这点后面避坑章会展开。
提示:
FindComponent只找直接子组件,嵌套容器(如 PageControl 里的 TabSheet 上的控件)需要递归遍历,或者把 JSON 结构改成扁平的全限定名。
3. 运行时切换语言:不重启刷新界面的实现路径
3.1 切换时机与刷新策略的取舍
运行时切换的核心矛盾是:已经创建的窗体实例,属性改了要不要立即重绘?VCL 的SetStrProp改 Caption 会触发CM_TEXTCHANGED,标签和按钮会自动重绘,但有些控件(比如 TListView 的列头、TDBGrid 的标题)不会自动响应,需要手动刷新。另一个问题是已经打开的模态窗体,切换语言时要不要同步更新——我的做法是只刷新当前活动窗体和主窗体,其他已创建但隐藏的窗体标记为「脏」,下次 Show 时再应用。
这个策略的代价是内存里可能同时存在新旧两种语言的字符串,但换来的是切换瞬间不卡顿。对于窗体数量在 50 个以内的项目,这个取舍是划算的。
3.2 语言管理器:单例 + 观察者模式
用一个全局的语言管理器持有当前语言包,窗体在OnShow时向管理器注册,切换语言时管理器遍历注册列表逐个应用。这样避免了在每个窗体里写重复的加载代码。
type TLangManager = class private FCurrent: TJSONObject; FRegistry: TList<TForm>; public procedure LoadLang(const AFile: string); procedure SwitchTo(const ALangCode: string); procedure RegisterForm(AForm: TForm); procedure UnregisterForm(AForm: TForm); end; procedure TLangManager.SwitchTo(const ALangCode: string); var F: TForm; begin LoadLang(ExtractFilePath(ParamStr(0)) + 'lang\' + ALangCode + '.json'); for F in FRegistry do if F.Visible then ApplyLangPack(F, FCurrent); end;LoadLang负责读文件并缓存,SwitchTo先换包再遍历可见窗体。注册时机放在FormShow,注销放在FormDestroy,避免悬空指针。参数上,语言包路径用ParamStr(0)推导而不是写死,方便绿色部署。
3.3 处理非字符串资源:图片、日期格式与字体
多语言不只是文字。日期格式要用FormatSettings按语言切换,ShortDateFormat和LongDateFormat在德语和英语下差异明显。图片资源里如果嵌了文字(比如带「新建」字样的图标),要么准备多套图,要么改成纯图标加 Tooltip。字体是最容易被忽略的——泰文需要Leelawadee UI之类的字体,中文在部分英文系统上默认字体渲染发虚,得在语言包里带一个font字段,切换时同步改Font.Name。
procedure ApplyLocaleSettings(const ALang: string); begin if ALang = 'th-TH' then begin FormatSettings.ShortDateFormat := 'dd/mm/yyyy'; Application.DefaultFont.Name := 'Leelawadee UI'; end else if ALang = 'de-DE' then FormatSettings.ShortDateFormat := 'dd.mm.yyyy'; end;这段要在SwitchTo里、应用窗体字符串之前调用,因为字体变了之后控件宽度会变,布局调整逻辑要基于新字体计算。
4. 布局适配:德语长词和泰文断行下的控件重排
4.1 为什么固定像素布局必然翻车
设计期按中文「确定」定的按钮宽度,换成德语「Bestätigen」直接溢出,泰文虽然字符短但断行规则不同,TLabel的AutoSize在泰文下可能把一行拆成三行。血泪经验是:凡是AutoSize := False且宽度写死的控件,多语言上线后必然有一批要手动调。解决方向有两个——要么全部改AutoSize := True配合Align和Margins做流式布局,要么在语言包里给每个控件带一个建议宽度,加载时按比例缩放。
前者改动大但一劳永逸,后者改动小但维护成本高。我的建议是核心窗体(登录、主界面、高频操作页)走流式布局,低频设置页用建议宽度兜底。
4.2 用 Align 和 Margins 做最小改动的流式布局
VCL 的Align配合Margins和AlignWithMargins能覆盖大部分场景。把按钮放进TPanel并设Align := alBottom,按钮自身Align := alRight,左边距用Margins.Left控制,这样按钮宽度随文字自适应,面板高度固定。标签和输入框用alTop纵向堆叠,Margins.Bottom控制间距。
// 运行时把一组控件改成流式布局 procedure MakeFlowLayout(AContainer: TWinControl); var I: Integer; C: TControl; begin for I := 0 to AContainer.ControlCount - 1 do begin C := AContainer.Controls[I]; if C is TLabel then begin TLabel(C).AutoSize := True; C.Align := alTop; C.AlignWithMargins := True; C.Margins.Bottom := 6; end; end; end;这段适合在窗体创建后统一处理,但要注意alTop的堆叠顺序由Top值决定,动态改 Align 后需要按原Top排序重新赋值,否则顺序会乱。参数上Margins.Bottom := 6是经验值,对应 96 DPI 下的视觉间距,高 DPI 下要按Screen.PixelsPerInch缩放。
4.3 泰文断行的特殊处理
泰文没有空格分词,TLabel的WordWrap在泰文下依赖系统的断行算法,部分 Windows 版本会把整句当一词不换行。稳妥做法是对泰文语言包里的长文本手动插入零宽空格(U+200B)作为断点提示,或者在加载时用TLabel的Caption预处理,按字符数每 40 个字符插一个零宽空格。这个阈值不是拍脑袋——泰文平均字符宽度比拉丁字母窄,40 字符大约对应一行 300 像素,适配常见窗体宽度。
5. 避坑与排查:多语言控件上线前必须过的五道坎
5.1 切换语言后部分控件文字没变
现象:点切换按钮,大部分标签变了,但某个TDBGrid的列标题还是旧语言。原因:TDBGrid的列标题存在Columns[i].Title.Caption,不是组件自身的 published 字符串属性,SetStrProp遍历不到。解决:在语言包里为这类复合控件单独开一个columns数组,加载器里判断Comp is TDBGrid后按索引赋值。同类问题还有TListView.Columns、TPageControl的页签 Caption(页签是TTabSheet子组件,FindComponent能找到但属性路径不同)。
5.2 语言包键名大小写不一致导致静默失败
现象:某个窗体的翻译完全没生效,日志里也没报错。原因:JSON 里写的是frmlogin,而AForm.ClassName返回TfrmLogin,GetValue返回 nil,代码里if FormObj = nil then Exit直接跳过。解决:加载器里对窗体名做统一处理,去掉T前缀并转小写比较,或者在构建语言包时用脚本从.dfm自动提取类名,杜绝手写。这个坑的隐蔽性在于它不抛异常,只是安静地不干活。
5.3 高 DPI 下切换语言后布局错位
现象:在 150% 缩放的机器上,切换语言后按钮重叠。原因:AutoSize触发的重排基于当前Font计算,而语言包切换字体后没有触发父容器的Realign。解决:在ApplyLangPack末尾对窗体调AForm.Perform(CM_RECREATEWND, 0, 0)或直接AForm.Realign,强制重新计算布局。更彻底的做法是在SwitchTo里先Screen.Cursor := crHourGlass,改完字体和字符串后统一Realign再恢复光标,避免用户看到中间态。
5.4 模态窗体切换语言后按钮默认焦点丢失
现象:登录框切到英文后,回车不再触发「OK」按钮。原因:SetStrProp改 Caption 时如果控件正处于焦点,某些 Windows 主题下会重建窗口句柄,Default属性失效。解决:切换后重新设置ActiveControl和Default,或者在语言包里不翻译按钮的Caption而是翻译Hint,按钮用图标加 Tooltip 规避。后者适合按钮密集的工具栏,对话框还是老老实实重设焦点。
5.5 语言包文件编码导致中文变问号
现象:英文系统上加载中文语言包,界面全是???。原因:JSON 文件存成了 ANSI 或 GBK,TFile.ReadAllText默认按系统编码读,非 Unicode 系统上解析出错。解决:语言包统一存 UTF-8 无 BOM,读取时显式指定TEncoding.UTF8,写入时同理。顺带一提,TJSONObject.ParseJSONValue对 BOM 敏感,带 BOM 的文件会解析失败返回 nil,这个黑匣子行为坑过不少人。
6. 进阶:把翻译流程自动化,以及一套可复用的验证脚本
走到这一步,运行时切换已经能跑,但真正的成本在翻译交付的往返。译员改完 Excel,你要手动转 JSON、对键名、查漏翻,一个版本来回两三天。我的习惯是写一个提取脚本,从源码和.dfm里扫出所有待翻译字符串,生成带上下文的 CSV,译员填完再转回 JSON。提取用正则扫.pas里的字符串字面量和.dfm里的Caption =行,上下文列带上窗体名和控件名,译员能判断「确定」是按钮还是提示。
验证环节我一般跑一个对照脚本,加载每个语言包后遍历窗体,检查三件事:JSON 里的键在窗体里是否都能找到对应组件(防拼写错误)、窗体里所有可见字符串属性是否都在 JSON 里有条目(防漏翻)、翻译后的字符串长度是否超过原长度的 1.8 倍(防布局溢出预警)。第三项的阈值 1.8 是经验值,德语和俄语常见 1.5 到 1.7 倍膨胀,超过 1.8 基本要调布局。
import json, os def check_lang_pack(pack_path, form_names): with open(pack_path, encoding='utf-8') as f: data = json.load(f) missing = [] for form in form_names: if form not in data['forms']: missing.append(f'form:{form}') # 长度膨胀检查 warnings = [] for form, ctrls in data['forms'].items(): for name, val in ctrls.items(): if isinstance(val, dict): for prop, text in val.items(): if len(text) > 40: warnings.append(f'{form}.{name}.{prop} len={len(text)}') return missing, warnings这段脚本不依赖 Delphi 环境,CI 里跑一遍就能拦住大部分低级错误。form_names从编译产物或.dproj里提取,保证和实际窗体一致。长度阈值 40 是给单行标签用的,多行文本要单独放行。
最后说个教训:多语言这活儿最怕「先上线再补」。我见过一个项目中文版跑了两年才加英文,结果发现几十处字符串拼在 SQL 和日志里,界面翻译完了,导出的报表还是中文。所以控件层的多语言只是第一层,数据层和导出层要同步规划。希望帮到你。
本文还有配套的精品资源,点击获取