如何设计本地化组件

在我们项目开始初期,面临一个问题,就是本地化组件到底要不要重新造一次轮子。为了回答这个问题,首先我们需要仔细分析一下,本地化究竟需要哪些功能

  • 业务逻辑动态扩容语言支持
  • 运行时热切换语言,无需重启
    • 对于代码赋值的部分,无法做到实时变化,需要等下一次赋值
  • 任意多种资源类型的本地化
    • 文字
    • 图片等
  • 配表字段自动本地化
  • 代码动态赋值本地化
    • 避免手输 string,提供动态生成翻译 key 常量代码工具
  • 脚本挂载本地化
  • 静态 UI 文本自动本地化
    • 静态 UI 文本自动追加至待翻译文件中
  • 首包和热更包双段本地化

这里所有的内容,都需要和配表打交道,而 Luban 也提供了一套健全的本地化配置方式,所以方案要在 Luban 现有的设计上做高度的结合

虽然这里功能看上去很多,但是着手实现起来还是比较简单的,但本篇文章并不会涉及到所有的代码实现,比如生成 key 常量代码等功能,大部分仅介绍核心实现,提供解题思路,博客并不适合展示大段代码

Luban 适配

在 Luban 中,所有参与本地化的资源需要使用 text 这个特殊的字段进行标记,此处我们需要做两件事

  • 定义项目专属的 TranslateText 对象
  • 定制 Tpl 代码,解析 text 并指向 TranslateText

这里我们在每一个 TranslateText 构造函数期间,对当前语言发生变化时的回调做了监听,这样我们就完成了配表的动态更新

public class TranslateText
{
    [JsonProperty]
    public string key { get; private set; }

    [JsonProperty]
    public string text { get; private set; }

    public TranslateText() { LocalizeComponent.on_language_change += Translate; }

    public void Translate() { text = LocalizeComponent.Instance.GetText(this); }
}

Tpl 解析代码也很简单,在 bean.tpl 循环中增加如下内容即可

{{~if field.gen_text_key~}}
    [JsonProperty("{{field.name}}")]
    private TranslateText _{{field.name}} { get; set; }
    [JsonIgnore]
    public string {{field.name}} => _{{field.name}}.text;
...

从模板的代码可以看出,TranslateText 完全是一个壳子,外部完全没有任何感知

这里仔细阅读一下 _ 的区分,[JsonProperty] 的功能就是为了解决外部访问的变量名和实际序列化的变量名不一致时的细节处理

配表拆分

比如说,当前我们有 中文、英文、繁中,三种语言的适配,我们并不希望最终生成的翻译表文件全部存放在一个 json 中,但是!配表工作仍然需要在一张表中进行,所以需要解决如何将一张表,序列化为多个 json 文件的问题

这里会稍微有点绕,最好自己试一试,尝试理解一下为何要如此设计

首先,我们需要先定义 ALocalizeConfig 文件

[Serializable]
public abstract class ALocalizeConfig
{
    public string key;

    public abstract string value { get; }
}

然后声明继承关系,这里我个人习惯会将继承的结构完全放入 xml 进行定义

<module name="">
    <bean name="ALocalizeConfig">
        <var name="key" type="string"/>
        <bean name="LocalizeConfig_CN">
            <var name="text_cn" type="string"/>
        </bean>
        <bean name="LocalizeConfig_EN">
            <var name="text_en" type="string"/>
        </bean>
        <bean name="LocalizeConfig_TW">
            <var name="text_tw" type="string"/>
         </bean>
    </bean>
</module>

这样之后,会有一个新问题,ALocalizeConfig 我们希望定义在代码中,不依靠 Luban 进行生成,这个问题解决思路非常简单,直接在代码生成的脚本后面将 ALocalizeConfig.cs 文件删除即可

[ -f C#/Client/ALocalizeConfig.cs ] && rm C#/Client/ALocalizeConfig.cs
rm -rf C#/Client/LocalizeConfig_*

注意看这里 Tables.xlsx 文件中的定义,define_from_filefalse,我们希望可以从 xml 配表中读取,并使用 I18N/AfterLogin 文件夹中所有的文件作为数据源

这里使用一个文件夹是因为要隔离程序和策划用的本地化配表,具体还是要看自己项目的实际需求,不过推荐这里还是采用 csv 防止冲突问题

最后,在上述所有功能完成后,我们就可以将所有字段放在同一个文件中了

## key text_cn text_en text_tw
##type string string string string
##comment 中文 英文 繁中
key1 测试 Test 测试

业务逻辑动态扩容语言支持

这里我们要解决的核心问题如下:

  • 和配表中的设计保持一致,也就是 Category
  • 解决 SystemLanguage.EnglishLocalizeConfig_EN 的绑定关系

具体 Cateogry 参考,本质上和配表 ConfigComponent 一致,但是由于本地化组件特殊的地位需要自成一派

public abstract class ALocalizeCategory
{
    public abstract void Deserialize(TextAsset asset);

    public abstract bool TryGetValue(string key, out string value);

    public abstract bool TryGetValue(TranslateText text, out string value);

    public abstract void Clear();

    public abstract string Name();

    public abstract ALocalizeConfig GetData(string key);

    public abstract void TrimExcess();
}

public class ALocalizeCategory<T> : ALocalizeCategory where T : ALocalizeConfig
{
    private Dictionary<string, T> _dict;

    public override void Deserialize(TextAsset asset)
    {
        _dict = JsonHelper.FromJson<Dictionary<string, T>>(asset.text);
    }

    public override bool TryGetValue(string key, out string value)
    {
        value = string.Empty;
        _dict.TryGetValue(key, out var data);

        if(data is null)
        {
            return false;
        }

        value = data.value;

        return!string.IsNullOrEmpty(value);
    }

    public override bool TryGetValue(TranslateText text, out string value)
    {
        value = text.text;

        _dict.TryGetValue(text.key, out var data);

        if(data is null)
        {
            return false;
        }

        value = data.value;

        return!string.IsNullOrEmpty(value);
    }

    public override void Clear() { _dict?.Clear(); }

    public override string Name() { return typeof(T).Name; }

    public override ALocalizeConfig GetData(string key)
    {
        if(_dict is null)
        {
            return null;
        }

        _dict.TryGetValue(key, out var data);

        return data;
    }

    public override void TrimExcess() { _dict.TrimExcess(); }
}

此时想解决绑定问题,我们需要在具体类中挂载 LocalizeConfigAttribuite

public class LocalizeConfigAttribute : BaseAttribute
{
    public readonly SystemLanguage language;

    public LocalizeConfigAttribute(SystemLanguage language) => this.language = language;
}

English 语言的声明如下

[Serializable]
public class LocalizeConfig_EN : ALocalizeConfig
{
    [JsonProperty]
    public string text_en { get; private set; }

    public override string value => text_en;
}

[LocalizeConfig(SystemLanguage.English)]
public class LocalizeConfig_EN_Category : ALocalizeCategory<LocalizeConfig_EN>
{
}

这样我们就完成了 SystemLanguage.English 绑定到 LocalizeConfig_EN_Category 的功能,而具体的字段,我们借助了多态,将 LocalizeConfig_EN.text_en 绑定到了 ALocalizeConfig.value

这样做的好处是,我们不需要额外声明一个项目专用的语言 enum,可以和 Application.systemLanguage 这个 API 返回保持一致

任意多种资源类型的本地化

下面以加载一个 Sprite 为例

private async UniTask<T> _Load<T>(int hash, string path) where T : UnityEngine.Object
{
#if UNITY_EDITOR
// Editor 环境下, 且没有在运行
    if(!UnityEditor.EditorApplication.isPlaying)
    {
        return UnityEditor.AssetDatabase.LoadAssetAtPath<T>(path);
    }
#endif
    return await _config.loader.Load<T>(hash, path);
}

internal async UniTask<Sprite> GetSprite(int hash, string key)
{
    if(_current is null)
    {
        return null;
    }

    Sprite result = null;

    if(_current.TryGetValue(key, out var value))
    {
        result = await _Load<Sprite>(hash, value);
    }

    return result;
}

注意看这里的 GetSprite API 是 internal 权限的,这个函数完全是为了给挂载组件自动切换服务的,而代码动态翻译一张图片时,我们仍然可以使用 GetText 获取这张图片真实的加载路径,最后由业务逻辑进行加载

这里的设计必须要考虑资源的释放问题,而代码部分的卸载我们要利用好这篇文章中现有的机制 基于 ECS 设计下的加载管理

脚本挂载本地化

这里的细节处理稍微有点 Tricky,部分设计参考了 I2 的实现。首先挂载脚本的方式,很重要的一件事是要解决资源不会被错误打包的问题

比如:当前组件需要翻译一张图片,因为 EditorEnglish 语言下,导致 Prefab 上绑定了 English 的图片,这样会错误的增加其他环境下的 ab 大小,这个并不是我们希望看到的,而当我们在 Editor 打开这个 Prefab 时,又希望可以看到这张图片,而不是一张白图,此时就发生了矛盾

这个问题和一些动态加载的 UI 一样,比如某个 Transform 下,需要使用代码加载某个 UI,但是在编辑器中我们也希望能看到,但是在退出或者保存时,可以自动清除

Editor Load & Release

对资源的规范化我们能做什么 这篇文章中,我们介绍了如何阻止一些极端的操作,导致的资源问题的机制,在这里我们同样可以利用起来

Prefab 打开时,做一件事,关闭或保存时做一件事,那么与之对应的就是 AEditorPreview 对象,遍历当前 Prefab 中所有 AEditorPreview 在生命周期内执行下面两个 API 即可

    public abstract class AEditorPreview : MonoBehaviour
    {
#if UNITY_EDITOR
        public abstract void Editor_Load();

        public abstract void Editor_Release();
#endif
    }

LocalizeTarget_UGUI_Image 组件

在开始之前,我们还有一个问题需要解决,在未来可能参与本地化的组件中,可能远远不止一个 Image,还可能有视频、模型等等,那么每次挂载组件之前,我们首先需要记得这个组件叫什么名字,而这里的组件名,一般都很长,而这个并不是我希望看到的,所以需要一个 Localize.cs 脚本做中转

    internal class Localize : MonoBehaviour
    {
#if UNITY_EDITOR
        private void Reset()
        {
            var components = gameObject.GetComponents<Component>();

            foreach(Component component in components)
            {
                if(component is Transform)
                {
                    continue;
                }

                LocalizeEditorHelper.targets.TryGetValue(component.GetType(), out var type);

                if(type is null)
                {
                    continue;
                }

                gameObject.AddComponent(type);
                DestroyImmediate(this);
                return;
            }

            // 什么都没找到默认添加 prefab
            // gameObject.AddComponent<LocalizeTarget_UnityStandard_Prefab>();
            DestroyImmediate(this);
        }
#endif
    }
    
#if UNITY_EDITOR
internal static class LocalizeEditorHelper
{
    public static readonly Dictionary<Type, Type> targets = new();

    public static void RegisterTarget(Type component_type, Type target) 
    { 
        targets[component_type] = target; 
    }
}
#endif

这样我们实际在 Editor 添加组件时,就只需要记得 Localize 这个脚本即可,Reset 函数会根据当前 GameObject 挂载了那些脚本,动态添加

[AddComponentMenu("")]
#if UNITY_EDITOR
[UnityEditor.InitializeOnLoad]
#endif
public class LocalizeTarget_UGUI_Image : ALocalizeTarget<Image>
{
#if UNITY_EDITOR
   static LocalizeTarget_UGUI_Image() { AutoRegister(); }

   [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
   static void AutoRegister()
   {
       LocalizeEditorHelper.RegisterTarget(typeof(Image), typeof(LocalizeTarget_UGUI_Image));
   }

#endif

    protected override async UniTask _DoLocalize()
    {
        _target.sprite = await LocalizeComponent.Instance.GetSprite(GetHashCode(), _key);
    }

    protected override void _ReleaseAsset()
    {
        LocalizeComponent.Instance.Release(GetHashCode());
        _target.sprite = null;
    }
}

这里,我们禁止这个组件可以在 Editor AddComponent 中工作,并自动注册自己的类型,帮助 Localize.cs 组件重定向,整理思路还是比较简单的

静态 UI 文本自动本地化

这个功能,首先要解决静态文本绑定的问题,一般游戏框架中都有和 ET 类似的 ReferenceCollector 组件,在这类绑定组件中,增加绑定所有 Text 组件的功能即可,这里不对如何绑定进行介绍

或者修改 TextTextMeshPro 源码也可以

[SerializeField]
private List<TextMeshProUGUI> _all_tmps;

private Dictionary<TextMeshProUGUI, string> _origin_text = new();

private void Awake()
{
    if(Game.Scene.GetComponent<LocalizeComponent>() is null)
    {
        return;
    }

    foreach(TextMeshProUGUI tmp in _all_tmps)
    {
        _origin_text[tmp] = tmp.text;
    }

    LocalizeComponent.on_language_change += _AutoLocalize;

    _AutoLocalize();
}

private void OnDestroy() { LocalizeComponent.on_language_change -= _AutoLocalize; }

private void _AutoLocalize()
{
    if(_all_tmps is null)
    {
        return;
    }

    var localize = Game.Scene.GetComponent<LocalizeComponent>();

    if(localize is null)
    {
        return;
    }

    foreach(var (tmp, key) in _origin_text)
    {
        tmp.text = localize.GetText(key);
    }
}

这里具体实现思路非常简单,而大部分的工作都在绑定代码中,因为这里我们监听了语言变化的事件,也自动获得了动态切换语言的功能

首包和热更包双段本地化

这个问题还是比较烦的,在游戏没有进入下载 ab 之前,可能因为网络问题、版本问题等等,仍然需要给玩家提供一些必要的文字翻译,而这些翻译一般都是在首包里的,与后续翻译无关,但是我们希望上面所有功能都能复用,不需要再造一次轮子

我们先从配置开始看,注意这里多了一行 localizeConfig_BuildIndefine_from_file 列这次为 true,指向了 LocalizeConfig_Buildin.csv,而这个就是我们首包的翻译文件

这个 json 又比较特殊了,因为要放在首包里面,而不是和其他配置一样,放在一个 Config 文件夹中,解决这个问题的思路和上面一致,我们需要在配表生成的代码中做文章

# 注意之前的 rm,我们同样会删除这个 LocalizeConfig_BuildIn.cs 文件
rm -rf C#/Client/LocalizeConfig_*
...
mv Json/Client/LocalizeConfig_BuildIn.json ../Client/Assets/Resources

这里在复制 json 文件之前,先将这个首包的 json 丢到 Resources 文件夹中,这里具体放在哪里看个人喜好

那么问题就只剩如何复用机制了,这里我们需要思考,两者之间的不同,仅仅是时机和数据源不同,我们需要做的事情就是在热更前,先给本地化组件一个 LocalizeConfig_BuildIn.json,在热更后,再给一个 LocalizeConfig_English.json 文件,即可完成所有的复用

public class LocalizeComponentConfig
{
    public IAssetLoader   loader;
    public SystemLanguage current_language;
    public SystemLanguage default_language;

    /// <summary>
    /// 内置的翻译 json
    /// </summary>
    public readonly TextAsset build_in_json;

    public LocalizeComponentConfig(IAssetLoader   loader,
                                   SystemLanguage current_language,
                                   SystemLanguage default_language,
                                   TextAsset      build_in_json)
    {
        this.loader           = loader;
        this.current_language = current_language;
        this.default_language = default_language;
        this.build_in_json    = build_in_json;
    }
}

这个 Config 是传给 LocalizeComponent 组件作为 Awake 参数的配置,在这里我们可以直接将 LocalizeConfig_English.json 的引用传入,剩下的交给 LocalizeComponent 组件进行初始化

internal class LocalizeComponentAwakeSystem : AwakeSystem<LocalizeComponent, LocalizeComponentConfig>
{
    public override void Awake(LocalizeComponent self, LocalizeComponentConfig config) { self.Awake(config); }
}

/// <summary>
/// 本地化管理组件, 注意此处的所有加载脱离了框架中实现的 AddressableComponent/>
/// 所有加载和释放都由此组件自己维护
/// </summary>
public class LocalizeComponent : Entity
{
/// <summary>
/// 多语言组件比较特殊, Instance 初始化会在 Editor 和 Runtime
/// 同时使用, 所以此处不走 Awake 初始化, 直接 new
/// </summary>
[ClearOnReload]
public static LocalizeComponent Instance { get; private set; }

public LocalizeComponent()
{
    if(Application.isPlaying)
    {
        return;
    }

#if UNITY_EDITOR
            // TODO  Editor 环境初始化
#endif
}

/// <summary>
/// 语言发生变化时的回调
/// </summary>
[ClearOnReload]
public static Action on_language_change;

/// <summary>
/// 实际上使用的语言
/// </summary>
public SystemLanguage actual_language { get; private set; }

/// <summary>
/// 当前组件的配置
/// </summary>
private LocalizeComponentConfig _config;

/// <summary>
/// 当前语言环境下的所有数据
/// </summary>
private ALocalizeCategory _current;

/// <summary>
/// 本地化对应的类型
/// </summary>
private readonly Dictionary<SystemLanguage, ALocalizeCategory> _localize_type = new();

internal void Awake(LocalizeComponentConfig config)
{
    Instance = this;
    _config  = config;
    var types = EventSystem.Instance.GetTypes(typeof(LocalizeConfigAttribute));

    foreach(var type in types)
    {
        var attr = type.GetCustomAttribute<LocalizeConfigAttribute>();

        if(attr is null)
        {
            continue;
        }

        var handler = Activator.CreateInstance(type) as ALocalizeCategory;

        if(handler is null)
        {
            continue;
        }

        _localize_type.Add(attr.language, handler);
    }

    if(_localize_type.TryGetValue(_config.current_language, out _current))
    {
        actual_language = _config.current_language;
    }
    else
    {
        _localize_type.TryGetValue(_config.default_language, out _current);
        actual_language = _config.default_language;
    }

    if(_current is null)
    {
        throw new NullReferenceException($"[Localize] {actual_language} is not supported!");
    }

    _current.Deserialize(config.build_in_json);
}

这里使用同一个 json 文件,根据语言的不同选择不同的 ALocalizeConfig 来进行反序列化,即可完成对 LocalizeConfig_BuildIn.json 的解析

public async UniTask Load()
{
    actual_language = _config.current_language;

    _localize_type.TryGetValue(actual_language, out _current);

    if(_current is null)
    {
        _localize_type.TryGetValue(_config.default_language, out _current);
        actual_language = _config.default_language;
    }

    if(_current is null)
    {
        throw new InvalidParameterException(
            $"[Localize] language is invalid, current = {_config.current_language}"
        );
    }

    var text_asset = await _Load<TextAsset>(_current.Name());

    _current.Clear();

    _current.Deserialize(text_asset);
}

而这里是外部通知 LocalizeComponent 何时进行语言的加载,一般是通过了热更下载阶段后,这样我们就几乎解决了上面所有期望的目标

// 首先初始化本地化组件的配置
var localize_config = new LocalizeComponentConfig(
    new DefaultLoader(),
    last_language,
    SystemLanguage.English,
    // 传入首包的翻译 json
    Resources.Load<TextAsset>("LocalizeConfig_BuildIn")
);

Game.Scene.AddComponent<LocalizeComponent, LocalizeComponentConfig>(localize_config);

// 当热更结束后
await LocalizeComponent.Instance.Load();

这样对于业务逻辑来说,初始化变的非常简单,在 Load 调用之前,所有的组件使用的均是首包的翻译,在 Load 调用之后,所有的组件使用的都是热更后的翻译