Lazarus 项目支持多语言国际化(进阶篇)

在之前的一篇文章中简单介绍了如何在 Lazarus 项目中启用多语言国际化。不过使用 DefaultTranslatorSetDefaultLang() 无法实现运行时动态切换 GUI 的语言。更准确地说,在程序中首次调用 LCLTranslator.SetDefaultLang(),并将参数 ForceUpdate 设为 True,是能够在运行时修改 GUI 语言的,但若再次调用便无效了。

查看 SetDefaultLang() 源代码:

pascalfunction SetDefaultLang(Lang: string; Dir: string = ''; LocaleFileName: string = ''; ForceUpdate: boolean = true): string;
{ Arguments:
  Lang - language (e.g. 'ru', 'de', 'zh_CN'); empty argument is default language.
  Dir - custom translation files subdirectory (e.g. 'mylng'); empty argument means searching only in predefined subdirectories.
  LocaleFileName - custom translation file name; empty argument means that the name is the same as the one of executable.
  ForceUpdate - true means forcing immediate interface update. Only should be set to false when the procedure is
    called from unit Initialization section. User code normally should not specify it.
}
var
  lcfn: string;
  LocalTranslator: TUpdateTranslator;
  i: integer;

begin
  Result := '';
  LocalTranslator := nil;
  // search first po translation resources
  try
    lcfn := FindLocaleFileName('.po', Lang, Dir, LocaleFileName, Result);
    if lcfn <> '' then
    begin
      Translations.TranslateResourceStrings(lcfn);
      LocalTranslator := TPOTranslator.Create(lcfn);
    end
    else
    begin
      // try now with MO translation resources
      lcfn := FindLocaleFileName('.mo', Lang, Dir, LocaleFileName, Result);
      if lcfn <> '' then
      begin
        GetText.TranslateResourceStrings(UTF8ToSys(lcfn));
        LocalTranslator := TDefaultTranslator.Create(lcfn);
      end;
    end;
  except
    Result := '';
    lcfn := '';
  end;

  if lcfn<>'' then
    TranslateLCLResourceStrings(Lang, lcfn);

  if LocalTranslator<>nil then
  begin
    if Assigned(LRSTranslator) then
      LRSTranslator.Free;
    LRSTranslator := LocalTranslator;

    // Do not update the translations when this function is called from within
    // the unit initialization.
    if ForceUpdate=true then
    begin
      for i := 0 to Screen.CustomFormCount-1 do
        LocalTranslator.UpdateTranslation(Screen.CustomForms[i]);
      for i := 0 to Screen.DataModuleCount-1 do
        LocalTranslator.UpdateTranslation(Screen.DataModules[i]);
    end;
  end;
end;
lcltranslator.pas

该函数首先查找本地的 .po.mo 翻译文件;然后,将程序的资源字符串翻译成本地语言;接下来,创建一个新的 TUpdateTranslator 对象替代全局对象。

在调用过该函数后,新创建的 GUI 组件界面都将会使用本地语言。若要对之前已创建的组件界面进行本地化,则可以将函数参数 ForceUpdate 设为 True。这将遍历所有已创建的可视组件中所有类型为可视文本的属性,并逐一调用 TUpdateTranslator.TranslateStringProperty() 进行翻译。最终,会调用 TMOFile.Translate()TPOFile.Translate() 将原生语言(比如英文)翻译成本地语言(比如中文)。

PO文件翻译映射表
msgidmsgstr
“File”“文件”
“Edit”“编辑”
“Help”“帮助”
“About”“关于”

当第一次调用 SetDefaultLang() 时,可视组件是原生语言,可以正确翻译成本地语言;而第二次调用 SetDefaultLang() 时,可视组件界面已经被翻译成某个本地语言,TMOFile.Translate()TPOFile.Translate() 无法再将其翻译成另一种本地语言。

--- title: "第一次本地化翻译成功" --- flowchart LR Native["#quot;File#quot;"]-->|" Translate() "|Local["#quot;文件#quot;"]
--- title: "第二次本地化翻译失败(不存在值为\"文件\"的 msgid)" --- flowchart LR Local["#quot;文件#quot;"]-->|" Translate() "|AnotherLocal["?"]

通常的解决方案是,销毁已经创建的窗体,然后重新创建。窗体在创建时会加载经过二次本地化的资源字符串。不过这种方案的缺点也很明显,主窗体重建相当于程序重新启动,开发者需要自行保存和还原窗体的状态。对于程序的用户来说,体验也相当不好。

另一种解决方案是,先将已经本地化的 GUI 组件还原成原生语言,也就是初始状态,然后再次进行本地化翻译。

--- title: "先还原到原生语言,再翻译到另一种本地语言" --- flowchart LR Local["#quot;文件#quot;"]-->|还原|Native["#quot;File#quot;"]-->|" Translate() "|AnotherLocal["#quot;檔案#quot;"]

遗憾的是,LCLTranslator 并没有提供翻译还原的功能,这需要开发者自己实现。其原理就是,在本地化翻译的同时,记录一个 msgstrmsgid 逆映射表,再根据这个映射表进行文本还原。

需要注意的是,不同的原生语言,可能被翻译成同一个本地语言条目。这样在还原时就需要对文本条目的来源加以区分,而不能简单地根据翻译条目的文本映射关系进行还原。比如:英文 PropertyAttribute 都会被翻译成「属性」。如果只用简单的文本映射,就无法确定「属性」是该还原成 Property 还是 Attribute 了。

逆映射表键名加组件来源前缀
msgstrmsgid
“Form1.Label1.Caption#属性”“Property”
“Form2.Edit1.Hint#属性”“Attribute”

这里实现了一个 TRestorableTranslator 类来替代默认的 TDefaultTranslator,具体实现代码如下:

pascalunit MyTranslator;

{$mode ObjFPC}{$H+}

interface

uses
  Classes, SysUtils, Generics.Collections, LCLTranslator, TypInfo;
  
type

  { TRestorableTranslator }

  TRestorableTranslator = class(TDefaultTranslator)
  type

    { TRestoreTranslator }

    TRestoreTranslator = class(TUpdateTranslator)
    private
      FTranslatedTerms: specialize TDictionary<string, string>;
    public
      constructor Create;
      destructor Destroy; override;
      property TranslatedTerms: specialize TDictionary<string, string> read FTranslatedTerms;
      procedure TranslateStringProperty(Sender: TObject; const Instance: TPersistent;
        PropInfo: PPropInfo; var Content: string); override;
    end;
  private
    FRestoreTranslator: TRestoreTranslator;
  public
    constructor Create(MOFileName: string);
    destructor Destroy; override;
    procedure TranslateStringProperty(Sender: TObject; const Instance: TPersistent;
      PropInfo: PPropInfo; var Content: string); override;
    procedure Restore;
  end; 
  
implementation

function GetPersistentPath(APersistent: TPersistent): string;
var
  TempPersistent: TPersistent;
  NamePath: string;
begin
  Result := '';
  TempPersistent := APersistent;
  while Assigned(TempPersistent) do
  begin
    NamePath := TempPersistent.GetNamePath;
    if NamePath <> '' then Result := NamePath + '.' + Result;
    {$IFDEF DEBUG}{$objectChecks-}{$ENDIF}
    TempPersistent := TPersistentAccess(TempPersistent).GetOwner;
    {$IFDEF DEBUG}{$objectChecks+}{$ENDIF}
  end;
end;

{ TRestorableTranslator }

constructor TRestorableTranslator.Create(MOFileName: string);
begin
  inherited Create(MOFileName);
  FRestoreTranslator := TRestoreTranslator.Create;
end;

destructor TRestorableTranslator.Destroy;
begin
  FreeAndNil(FRestoreTranslator);
  inherited Destroy;
end;

procedure TRestorableTranslator.TranslateStringProperty(Sender: TObject; const Instance: TPersistent;
  PropInfo: PPropInfo; var Content: string);
var
  OriginalContent, NamePath: string;
begin
  OriginalContent := Content;
  inherited;
  if Content <> OriginalContent then
  begin
    NamePath := GetPersistentPath(Instance) + PropInfo^.Name;
    FRestoreTranslator.TranslatedTerms.TryAdd(NamePath + #4 + Content, OriginalContent);
  end;
end;

procedure TRestorableTranslator.Restore;
var
  i: integer;
  LocalTranslator: TAbstractTranslator;
begin
  LocalTranslator := LRSTranslator;
  LRSTranslator := FRestoreTranslator;
  for i := 0 to Screen.CustomFormCount-1 do
    FRestoreTranslator.UpdateTranslation(Screen.CustomForms[i]);
  for i := 0 to Screen.DataModuleCount-1 do
    FRestoreTranslator.UpdateTranslation(Screen.DataModules[i]);
  LRSTranslator := LocalTranslator;
end;

{ TRestorableTranslator.TRestoreTranslator }

constructor TRestorableTranslator.TRestoreTranslator.Create;
begin
  FTranslatedTerms := specialize TDictionary<string, string>.Create;
end;

destructor TRestorableTranslator.TRestoreTranslator.Destroy;
begin
  FreeAndNil(FTranslatedTerms);
  inherited Destroy;
end;

procedure TRestorableTranslator.TRestoreTranslator.TranslateStringProperty(Sender: TObject; const Instance: TPersistent;
  PropInfo: PPropInfo; var Content: string);
var
  Value, NamePath: string;
begin
  NamePath := GetPersistentPath(Instance) + PropInfo^.Name;
  if FTranslatedTerms.TryGetValue(NamePath + #4 + Content, Value) then
    Content := Value;
end;            

end.

只要在二次本地化翻译前,调用 TRestorableTranslator.Restore 将已翻译的组件还原成最初的状态即可。