工具效果如图:
多语言是个非常简单且常用的功能。但是重复工作量大,程序手动把多语言Key配置到多语言表经常会出现错漏,或者几经改版,有些Key已经不用却没有剔除,久而久之造成冗余。这中简单且重复的工作必须让工具来完成。
功能设计:
多语言通过Key,Value的形式保存,通过多语言API GF.Localization.GetText(Key)获取当前语言对应的Value值。
1. 一键扫描多语言文本。扫描prefab资源、excel数据表以及代码里的多语言文本,这里扫描的就是多语言的Key。
2. 多语言列表(添加到此列表即为支持该语言)。点击"+"号弹出未添加的语言列表,点击对应语言添加到语言列表。多语言列表的第一项记为“母语”,其它语言以“母语”为基准翻译为对应语言。
3. 一键翻译。由于ChatGPT请求次数有限制,Google翻译需要魔法上网。最终为了体验选择了接入百度翻译。我们只需要把“母语”的Value填写好,其它语言直接通过百度翻译生成Value。
4. 由于机器翻译结果还需要人工审核修正。为了方便,工具先生成多语言Excel文件,方便交给其它部门翻译。项目真正使用的多语言文件是工具将多语言Excel导出的json文件。
5. 多语言工具以列表的形式显示“母语”,可以手动修改Key,Value值。
6. 细节体验优化。由于每次扫描结果会覆盖原多语言文件,可以通过勾选【锁定】强制保留该行。同时也在Excel的第一列生成了【锁定】勾选框方便策划操作。
7. 由于百度翻译免费翻译字节数有上限,为了节省翻译字节。一键翻译默认只翻译Value值为空白的行,如果想强制翻译所有行可以通过一键翻译的下拉按钮强制翻译全部行。
功能实现:
1. 一键扫描多语言文本:
①扫描Prefab资源上的多语言文本:
GameFramework框架提供了UIStringKey专门用来填写多语言文本Key, 所以只需要从所有Prefab上获取UIStringKey脚本上填写的Key即可。
扫描prefab上的多语言Key:
/// <summary>/// 扫描Prefab中的国际化语言/// </summary>public static List<string> ScanLocalizationTextFromPrefab(Action<string, int, int> onProgressUpdate = null){var assetGUIDs = AssetDatabase.FindAssets("t:Prefab", ConstEditor.PrefabsPath);List<string> keyList = new List<string>();int totalCount = assetGUIDs.Length;for (int i = 0; i < totalCount; i++){string path = AssetDatabase.GUIDToAssetPath(assetGUIDs[i]);var pfb = AssetDatabase.LoadAssetAtPath<GameObject>(path);onProgressUpdate?.Invoke(path, totalCount, i);var keyArr = pfb.GetComponentsInChildren<UnityGameFramework.Runtime.UIStringKey>(true);foreach (var newKey in keyArr){if (string.IsNullOrWhiteSpace(newKey.Key) || keyList.Contains(newKey.Key)) continue;keyList.Add(newKey.Key);}}return keyList;}
② 扫描数据表Excel中的多语言文本:
首先需要标记数据表多语言列,在数据表备注行用”i18n“标识,程序就自动扫描添加标识的列:
扫描excel中的多语言文本:
/// <summary>/// 从DataTable Excel文件扫描本地化文本/// </summary>/// <param name="onProgressUpdate"></param>/// <returns></returns>public static List<string> ScanLocalizationTextFromDataTables(Action<string, int, int> onProgressUpdate = null){List<string> keyList = new List<string>();var appConfig = AppConfigs.GetInstanceEditor();var mainTbFullFiles = GameDataGenerator.GameDataExcelRelative2FullPath(GameDataType.DataTable, appConfig.DataTables);var tbFullFiles = GameDataGenerator.GetGameDataExcelWithABFiles(GameDataType.DataTable, mainTbFullFiles);//同时扫描AB测试表for (int i = 0; i < tbFullFiles.Length; i++){var excelFile = tbFullFiles[i];var fileInfo = new FileInfo(excelFile);if (!fileInfo.Exists) continue;onProgressUpdate?.Invoke(excelFile, tbFullFiles.Length, i);string tmpExcelFile = UtilityBuiltin.ResPath.GetCombinePath(fileInfo.Directory.FullName, GameFramework.Utility.Text.Format("{0}.temp", fileInfo.Name));try{File.Copy(excelFile, tmpExcelFile, true);using (var excelPackage = new ExcelPackage(tmpExcelFile)){var excelSheet = excelPackage.Workbook.Worksheets.FirstOrDefault();if (excelSheet.Dimension.End.Row >= 4){for (int colIndex = excelSheet.Dimension.Start.Column; colIndex <= excelSheet.Dimension.End.Column; colIndex++){if (excelSheet.GetValue<string>(4, colIndex)?.ToLower() != EXCEL_I18N_TAG){continue;}for (int rowIndex = 5; rowIndex <= excelSheet.Dimension.End.Row; rowIndex++){string langKey = excelSheet.GetValue<string>(rowIndex, colIndex);if (string.IsNullOrWhiteSpace(langKey) || keyList.Contains(langKey)) continue;keyList.Add(langKey);}}}}}catch (Exception e){Debug.LogError($"扫描数据表本地化文本失败!文件:{excelFile}, Error:{e.Message}");}if (File.Exists(tmpExcelFile)){File.Delete(tmpExcelFile);}}return keyList;}
③ 扫描代码中的多语言文本:
原理:搜索代码中所有调用国际化函数GF.Localization.GetText(string key)的地方,然后把调用时传入参数key的字符串值扫描出来。
首先只能通过静态解析cs代码,获取函数调用时传入参数的值。这比想象中复杂得多,比如:
1. 如果传入的是字符串常量很容易获取,但如果传入的是变量,就需要找到该变量的初始值赋值,变量又涉及到局部变量和全局变量。
2. 如果key中包含特殊字符会影响正则表达式的匹配,所以不能使用正则表达式。
3. 注释的代码不应该扫描。
为了工具安全完善,最终选择了用"高射炮打蚊子", 使用微软Roslyn作为CSharp静态解析库。但是这个解析库依赖dll太多直接导入Unity会有各种冲突,为了Unity工程的兼容性索性写个C#命令行程序,由Unity代码调用命令行程序扫描代码,把扫描结果存入缓存文件供Unity读取使用。而且命令行程序可以发布跨平台包,不用担心跨平台问题。
用Visual Studio新建C#命令行程序,为工程添加CodeAnalysis.CSharp库:
命令行程序代码:
其中命令行args, 第一参数是cs代码文件名(完整路径),第二个参数是扫描结果输出到的文件(通过文本追加的方式把扫描结果列表追加到文本文件),剩余参数是目标函数名,因为获取国际化文本的函数可能有多个。
internal class Program{static int Main(string[] args){try{string csFile = args[0];string outputFile = args[1];List<string> funcNames = new List<string>();for (int i = 2; i < args.Length; i++){funcNames.Add(args[i]);}List<string> resultList = new List<string>();if ((File.GetAttributes(csFile) & FileAttributes.Directory) == FileAttributes.Directory){//如果传的是文件夹,扫描该文件夹下的所有cs文件var csFiles = Directory.GetFiles(csFile, "*.cs", SearchOption.AllDirectories);foreach (var item in csFiles){var codeText = File.ReadAllText(item);var strList = GetTextArgumentValues(codeText, funcNames);if (strList.Count > 0){resultList.AddRange(strList);}}}else{if (File.Exists(csFile)){var codeText = File.ReadAllText(csFile);var strList = GetTextArgumentValues(codeText, funcNames);if (strList.Count > 0){resultList.AddRange(strList);}}}resultList.Distinct();//去重resultList.RemoveAll(x => string.IsNullOrWhiteSpace(x));Console.WriteLine($"\n\n--------------Result List Count:{resultList.Count}--------------");for (int i = 0; i < resultList.Count; i++){var str = resultList[i];Console.WriteLine($"{i + 1}.\t[{str}]");}Console.WriteLine("--------------Result List End--------------");if (resultList.Count > 0){File.AppendAllLines(outputFile, resultList);}return 0;}catch (Exception err){Console.WriteLine($"Error:{err}");}return 1;}public static List<string> GetTextArgumentValues(string codeText, List<string> funcNames){List<string> argumentValues = new List<string>();SyntaxTree tree = CSharpSyntaxTree.ParseText(codeText);var root = (CompilationUnitSyntax)tree.GetRoot();var methodCalls = root.DescendantNodes().OfType<InvocationExpressionSyntax>().Where(i =>{return funcNames.Contains(i.Expression.ToString());});var compilation = CSharpCompilation.Create(typeof(object).Assembly.FullName, new SyntaxTree[] { tree }).WithOptions(new CSharpCompilationOptions(OutputKind.ConsoleApplication)).AddReferences(MetadataReference.CreateFromFile(typeof(object).Assembly.Location));var semanticModel = compilation.GetSemanticModel(tree);var methodCallsArr = methodCalls.ToArray();for (int i = 0; i < methodCallsArr.Length; i++){var call = methodCallsArr[i];var argumentList = call.ArgumentList;if (argumentList.Arguments.Count >= 1){var argExp = argumentList.Arguments[0].Expression;if (argExp is LiteralExpressionSyntax literal){Console.WriteLine($"{call} ------> {literal.Token.ValueText}");argumentValues.Add(literal.Token.ValueText);}else if (argExp is IdentifierNameSyntax variable){SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(variable);if (symbolInfo.Symbol is IFieldSymbol fieldSymbol){if (fieldSymbol.HasConstantValue){argumentValues.Add((string)fieldSymbol.ConstantValue);Console.WriteLine($"{call} ------> {fieldSymbol.ConstantValue}");}}else if (symbolInfo.Symbol is ILocalSymbol localSymbol){var localVar = localSymbol.DeclaringSyntaxReferences.Last()?.GetSyntax() as VariableDeclaratorSyntax;if (localVar != null && localVar.Initializer != null){var localVarValue = semanticModel.GetConstantValue(localVar.Initializer.Value);if (localVarValue.Value != null){argumentValues.Add((string)localVarValue.Value);Console.WriteLine($"{call} ------> {localVarValue.Value}");}}}}}}return argumentValues;}}
2.接入百度翻译开放API,实现一键翻译多语言
百度翻译官方接入文档:百度翻译开放平台
注册后在开发者后台可以看到App id和密钥,用于发送翻译WebRequest请求参数。
开发者实名认证后可以变更为高级版,高级版每月可享受免费翻译100万个字符,相当于50万个汉字。一次请求能翻译6000个字符(3000汉字),每秒请求上限10次。
以上限制就需要翻译时需要一次性塞入多条待翻译句子并且不能超过每次请求的上限字节。
比较坑的是百度翻译以换行符拆分句子,如果国际化文本中包含换行符翻译结果就不是我们想要的:
所以我使用一个特殊字符"↕"做为自己的多条句子之间的分割符,拿到翻译结果再用"↕"分割字符串得到句子数组。
百度翻译上行字段:
var randomCode = System.DateTime.Now.Ticks.ToString();var strBuilder = new StringBuilder();strBuilder.Append(BAIDU_TRANS_URL);strBuilder.AppendFormat("q={0}", UnityWebRequest.EscapeURL(srcText));strBuilder.AppendFormat("&from={0}", GetBaiduLanguage(srcLang) ?? "auto"); //自动识别源文字语言strBuilder.AppendFormat("&to={0}", GetBaiduLanguage(targetLang));//翻译到目标语言strBuilder.AppendFormat("&appid={0}", EditorToolSettings.Instance.BaiduTransAppId);strBuilder.AppendFormat("&salt={0}", randomCode);strBuilder.AppendFormat("&sign={0}", GenerateBaiduSign(srcText, randomCode));
生成签名:
/// <summary>/// 生成百度翻译请求签名/// </summary>/// <param name="srcText"></param>/// <returns></returns>private static string GenerateBaiduSign(string srcText, string randomCode){MD5 md5 = MD5.Create();var fullStr = GameFramework.Utility.Text.Format("{0}{1}{2}{3}", EditorToolSettings.Instance.BaiduTransAppId, srcText, randomCode, EditorToolSettings.Instance.BaiduTransSecretKey);byte[] byteOld = Encoding.UTF8.GetBytes(fullStr);byte[] byteNew = puteHash(byteOld);StringBuilder sb = new StringBuilder();foreach (byte b in byteNew){sb.Append(b.ToString("x2"));}return sb.ToString();}
百度翻译语言代号获取,用ChatGPT帮我生成函数,结果只有几种是对的,无奈只能人工找对照表修改代号:
无私献上获取百度翻译语言代码:
/// <summary>/// 根据语言类型返回对应的百度语言缩写/// </summary>/// <param name="lang"></param>/// <returns></returns>/// <exception cref="ArgumentException"></exception>public static string GetBaiduLanguage(Language lang){switch (lang){case Language.Afrikaans:return "afr";case Language.Albanian:return "alb";case Language.Arabic:return "ara";case Language.Basque:return "baq";case Language.Belarusian:return "bel";case Language.Bulgarian:return "bul";case Language.Catalan:return "cat";case Language.ChineseSimplified:return "zh";case Language.ChineseTraditional:return "cht";case Language.Croatian:return "hrv";case Language.Czech:return "cs";case Language.Danish:return "dan";case Language.Dutch:return "nl";case Language.English:return "en";case Language.Estonian:return "est";case Language.Faroese:return "fao";case Language.Finnish:return "fin";case Language.French:return "fra";case Language.Georgian:return "geo";case Language.German:return "de";case Language.Greek:return "el";case Language.Hebrew:return "heb";case Language.Hungarian:return "hu";case Language.Icelandic:return "ice";case Language.Indonesian:return "id";case Language.Italian:return "it";case Language.Japanese:return "jp";case Language.Korean:return "kor";case Language.Latvian:return "lav";case Language.Lithuanian:return "lit";case Language.Macedonian:return "mac";case Language.Malayalam:return "may";case Language.Norwegian:return "nor";case Language.Persian:return "per";case Language.Polish:return "pl";case Language.PortugueseBrazil:return "pt";case Language.PortuguesePortugal:return "pt";case Language.Romanian:return "rom";case Language.Russian:return "ru";case Language.SerboCroatian:return "sec";case Language.SerbianCyrillic:return "src";case Language.SerbianLatin:return "srp";case Language.Slovak:return "sk";case Language.Slovenian:return "slo";case Language.Spanish:return "spa";case Language.Swedish:return "swe";case Language.Thai:return "th";case Language.Turkish:return "tr";case Language.Ukrainian:return "ukr";case Language.Vietnamese:return "vie";default:throw new NotSupportedException($"暂不支持该语言:{lang}");}}
接入百度翻译示例代码:
private static void TranslateAndSave(List<LocalizationText> mainLangTexts, Language srcLang, List<LocalizationText> langTexts, Language targetLang, bool forceAll){int curTransIdx = 0;while (curTransIdx < langTexts.Count){string totalText = "";List<int> totalTextIdx = new List<int>();for (; curTransIdx < langTexts.Count; curTransIdx++){var text = langTexts[curTransIdx];string srcText = "";if (forceAll){var mainText = mainLangTexts.FirstOrDefault(tmpItm => pareTo(text.Key) == 0);if (mainText != null && !string.IsNullOrWhiteSpace(mainText.Value)){srcText = mainText.Value;}}else{if (string.IsNullOrWhiteSpace(text.Value)){var mainText = mainLangTexts.FirstOrDefault(tmpItm => pareTo(text.Key) == 0);if (mainText != null && !string.IsNullOrWhiteSpace(mainText.Value)){srcText = mainText.Value;}}}if (!string.IsNullOrWhiteSpace(srcText)){if ((totalText.Length + srcText.Length) > EditorToolSettings.Instance.BaiduTransMaxLength){curTransIdx -= 1; //如果长度超了下个请求接着这行break;}totalText += srcText + TRANS_SPLIT_TAG;totalTextIdx.Add(curTransIdx);}}if (string.IsNullOrWhiteSpace(totalText)){curTransIdx++;//如果一行字数就超过上限则跳过翻译这行continue;}totalText = totalText.Substring(0, totalText.Length - TRANS_SPLIT_TAG.Length);//去掉结分隔符TMP_EditorCoroutine.StartCoroutine(TranslateCoroutine(totalText, srcLang, targetLang, (success, trans, userDt) =>{if (success){ParseAndSaveTransResults(langTexts, targetLang, trans, userDt as int[]);}}, totalTextIdx.ToArray()));}}/// <summary>/// 解析翻译结果并保存到语言Excel/// </summary>/// <param name="targetTexts"></param>/// <param name="targetLang"></param>/// <param name="resultStr"></param>/// <param name="resultTextIdxArr"></param>private static void ParseAndSaveTransResults(List<LocalizationText> targetTexts, Language targetLang, TranslationResult trans, int[] resultTextIdxArr){if (string.IsNullOrWhiteSpace(trans.dst) || resultTextIdxArr == null) return;var srcTexts = trans.src.Split(TRANS_SPLIT_TAG);var resultTexts = trans.dst.Split(TRANS_SPLIT_TAG);if (resultTexts.Length != resultTextIdxArr.Length || resultTexts.Length != srcTexts.Length){Debug.LogError($"翻译失败, 翻译结果数量和索引数不一致.result count:{resultTexts.Length}, but index count:{resultTextIdxArr.Length}\n 翻译结果:{trans.dst}");return;}for (int i = 0; i < resultTextIdxArr.Length; i++){var idx = resultTextIdxArr[i];var srcStr = srcTexts[i];var dstStr = resultTexts[i].Trim();int leadingSpaces = srcStr.Length - srcStr.TrimStart().Length;int trailingSpaces = srcStr.Length - srcStr.TrimEnd().Length;dstStr = dstStr.PadLeft(dstStr.Length + leadingSpaces);dstStr = dstStr.PadRight(dstStr.Length + trailingSpaces);targetTexts[idx].Value = dstStr;}SaveLanguage(targetLang, targetTexts);}private static IEnumerator TranslateCoroutine(string srcText, Language srcLang, Language targetLang, Action<bool, TranslationResult, object> onComplete, object userData){var randomCode = System.DateTime.Now.Ticks.ToString();var strBuilder = new StringBuilder();strBuilder.Append(BAIDU_TRANS_URL);strBuilder.AppendFormat("q={0}", UnityWebRequest.EscapeURL(srcText));strBuilder.AppendFormat("&from={0}", GetBaiduLanguage(srcLang) ?? "auto"); //自动识别源文字语言strBuilder.AppendFormat("&to={0}", GetBaiduLanguage(targetLang));//翻译到目标语言strBuilder.AppendFormat("&appid={0}", EditorToolSettings.Instance.BaiduTransAppId);strBuilder.AppendFormat("&salt={0}", randomCode);strBuilder.AppendFormat("&sign={0}", GenerateBaiduSign(srcText, randomCode));//Debug.Log($"发送:{strBuilder}");// 发送请求using (var webRequest = working.UnityWebRequest.Get(strBuilder.ToString())){webRequest.SetRequestHeader("Content-Type", "text/html;charset=UTF-8");webRequest.certificateHandler = new WebRequestCertNoValidate();webRequest.SendWebRequest();while (!webRequest.isDone) yield return null;if (webRequest.result != working.UnityWebRequest.Result.Success){Debug.LogError($"---------翻译{targetLang}请求失败:{webRequest.error}---------");onComplete?.Invoke(false, null, userData);}else{var json = webRequest.downloadHandler.text;//Debug.Log($"接收:{json}");try{var responseJson = UtilityBuiltin.Json.ToObject<JObject>(json);if (responseJson.ContainsKey("trans_result")){var resultArray = responseJson["trans_result"].ToObject<TranslationResult[]>();if (resultArray != null && resultArray.Length > 0){var resultTrans = resultArray[0];onComplete?.Invoke(true, resultTrans, userData);}else{Debug.LogError($"---------翻译{targetLang}失败:{responseJson}---------");onComplete?.Invoke(false, null, userData);}}else{Debug.LogError($"---------翻译{targetLang}失败:{responseJson}---------");onComplete?.Invoke(false, null, userData);}}catch (System.Exception e){Debug.LogError($"---------翻译{targetLang}返回数据解析失败:{e.Message}---------");onComplete?.Invoke(false, null, userData);}}}}internal class TranslationResult{public string src;public string dst;}