【项目维护拓展】Unity3D利用子类组件替换项目中所有的父类组件
背景:在进行多语言功能的开发中,我遇到了这样的问题:项目开发时并没有考虑将来需要开发多语言版本,因此很多中文直接写在了代码或者prefab中。项目开发完成,但想在原来的基础上开发海外版本,就要实现多语言的拓展,因而必须将所有的中文字符替换成多语言字典的Key值,进而通过查询多语言字典得到当地文字。需要用自定义的Text组件的子类来替换项目中所有的Text组件,从而实现扩展功能。
(本文主要阐述如何解决prefab中控件的替换问题,仅以UnityEngine.UI.Text组件为例阐述,但类似方法可以运用在任何类型的组件上)
最为常见的带文本的基础控件为UnityEngine.UI.Text控件,其余的如Button、Dropdown、InputField等皆引用了Text组件作为子控件以显示文字。因此,我们只需要对原生的Text组件进行拓展即可。
有以下几个可选思路:
- 在所有带有Text组件的GameObject上都附加一个新组件,通过新组件中GetComponent<Text>().text="Localization Dictionary Key"的方式来进行更改。优点:简单暴力,开发容易;缺点:运行时开销很大,无法对众多Text组件进行统一管理。
- 继承,创建一个LocalizationText类继承自Text类,并在其中加入一些支持多语言的功能。优点:可以对所有的Text组件统一管理,不影响原有组件和代码之间的互相引用,一次性替换、运行时无额外开销;缺点:开发较麻烦。
第一种方式容易实现,缺点也很明显,这里就不讨论了。
按照第二种方式,先创建一个Text控件的子类:
示例:
按第二种方式写好子类之后,在替换父类组件时将会遇到以下问题:
- 手动替换不现实,项目中有成百上千个Text组件需要替换
- 采用Destroy组件+AddComponent的方式会导致prefab之间的引用断开
针对第一个问题,我们可以写一个Editor脚本来遍历所有的Text组件。
针对第二个问题,我进行了一番研究。
研究过程
(不想看的可以跳到最后的解决方案部分)
首先在菜单Edit->ProjectSetting->Editor中将AssetSerialization改为ForceText。
在一个空工程中创建一个Text对象,并做成prefab。
此外,再创建一个空的GameObject,附上一个脚本引用到Text。
找到Text.prefab的文件,用文本形式打开,可以看到其数据组织结构如下。
注:几个常见的Tag与类型的对应关系如下:
!u!1 →GameObject
!u!114 →脚本
!u!1001 →Prefab
补充知识点:GUID与fileID(本地ID)
Unity会为每个导入到Assets目录中的资源创建一个meta文件,文件中记录了GUID,GUID用来记录资源之间的引用关系。还有fileID(本地ID),用于标识资源内部的资源。资源间的依赖关系通过GUID来确定;资源内部的依赖关系使用fileID来确定。
从这个文件里可以看出,组件之间主要是通过fileID和guid来联系的。这个prefab结构较单一,字段很好辨认,我们先从简单的开始。
注:所有不同的GameObject、Script、Component、Prefab等都有自己唯一的fileID,但guid的值由其所在的文件确定,可在meta文件中查到。
例1:Text组件引用的m_Script、Image组件引用的m_Script是在同一个文件下,因此其guid是相同的,但其fileID不同;
例2:两个的Text组件自身的fileID不同,但其引用的m_Script的fileID和guid都相同。
首先,第一个显示的是自己的GameObject(在复杂控件如Button或Dropdown中,第一个显示的也有可能不是自己的GameObject,而是子对象的GameObject)。在GameObject中的m_Component字段下有三个组件,分别对应了脚本、CanvasRenderer、RectTransform(顺序随机不固定)。
很显然,Text组件为上图中以“--- !u!114 &11432036”为首行的一段数据。
再来链接到该Text组件的其他prefab的数据格式:
以下为GameObject.prefab文件,只截取脚本部分:
可以看到一个引用“link_Text: {fileID: 11453746, guid: 77bed882e6c4dd7438bb598ab0e2a342, type: 2}”
其中guid可以在Text的prefab的meta文件中查到:
如果我们手动将Text组件删除,发现prefab文本中有几处变化:
- Text.prefab中GameObject数据块中的m_Component中的“- 114: {fileID: 11453746}”记录被删除
- Text.prefab中以“--- !u!114 &11432036”为首行的一段数据被清除
- GameObject中的引用链接“link_Text: {fileID: 11453746, guid: 77bed882e6c4dd7438bb598ab0e2a342, type: 2}”无任何变化!依然指向原值,因此在Unity界面中会显示Missing
结论:
通过以上研究可以得出结论,如果将Text组件替换为LocalizationText组件,可以有两个方案
- 通过Editor代码Destory(Text)然后AddComponent<Localization>(),再在prefab中把丢失引用的fileID和guid都替换为新组件的fileID和guid。一开始我是用这个方法来实现的,但后面在InputField控件中遇到了特殊的问题(同一个prefab中一个组件引用另一个组件,如果另一个组件被destroy,原组件的引用fileID将置0,无法追踪替换位置),因此选用了第二种方案。
- 由于我们的新组件LocalizationText.cs没有像系统原生组件那样封装在dll文件中,因此我们可以轻松获得该组件的guid值和fileID值。因此,将原Text组件中的m_Script中的fileID和guid改为LocalizationText.cs的guid和fileID,即可实现全部替换。
至于说如何获得新、旧组件的fileID和guid,可以通过创建两个prefab分别获取(对组件来说,fileID和guid在同一个项目中是不会变化的)。
Tips:在重写prefab文件后,注意要执行一下:
EditorApplication.SaveAssets();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
否则无法生效。
最后感谢敦哥的指导!
附上代码:
using UnityEngine; using UnityEditor; using System.IO; using System.Text.RegularExpressions; using UnityEngine.UI; using System.Collections.Generic; using System.Reflection; using System; public class ReplaceTextComponent { private static Dictionary<string, string> replaceStringPairs = new Dictionary<string, string>();//替换string对_fileID和GUID同时替换 private static List<long> oldFileIDs = new List<long>(); private static Regex rg_Number = new Regex("-?[0-9]+"); private static Regex rg_FileID = new Regex(@"(?<=fileID:\s)-?[0-9]+"); private static Regex rg_GUID = new Regex(@"(?<=guid:\s)[a-z0-9]{32,}(?=,)"); #region 获取所有Text内容 [MenuItem("Tools /Localization/获取所有Text内容")] static void GetAllTextContent() { List<string> executePaths = getExecutePaths(); OnGetAllTextContent(executePaths); } static void OnGetAllTextContent(List<string> executePaths) { EditorApplication.SaveAssets(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); List<string> allPrefabPaths = getAllPrefabsFromPaths(executePaths); int count = 0; foreach (string file in allPrefabPaths) { string path = getAssetPath(file); var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path); Text[] comps_Old = prefab.GetComponentsInChildren<Text>(); foreach (Text com_Old in comps_Old) { count++; Stack<string> parentNames = new Stack<string>(); string debugString = "控件_" + count + ":"; Transform point = com_Old.transform; while (point.parent!=null) { parentNames.Push(point.parent.name); point = point.parent; } while (parentNames.Count != 0) { debugString += parentNames.Pop() + " > "; } debugString += "[" + com_Old.name + "] 内容: {" + com_Old.text + "}"; Debug.Log(debugString); } } } #endregion #region 替换Text组件为LocalText [MenuItem("Tools /Localization/替换Text组件为LocalText")] static void Replace() { List<string> executePaths = getExecutePaths(); OnExecute(executePaths); } private static List<string> getExecutePaths() { List<string> executePaths = new List<string>(); UnityEngine.Object[] arr = Selection.GetFiltered(typeof(UnityEngine.Object), SelectionMode.TopLevel); foreach (UnityEngine.Object dir in arr) { executePaths.Add(AssetDatabase.GetAssetPath(dir)); } return executePaths; } static string oldScriptGUID; static long oldScriptFileID; static string newScriptGUID; static long newScriptFileID; static List<Text> texts = new List<Text>(); static void OnExecute(List<string> executePaths) { EditorApplication.SaveAssets(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); //获取新脚本GUID var newScriptFile = Directory.GetFiles(Application.dataPath + "\\LocalizationPrefab", "LocalizationText.cs", SearchOption.TopDirectoryOnly); newScriptGUID = AssetDatabase.AssetPathToGUID(getAssetPath(newScriptFile[0])); //获取新脚本FileID string[] newComponentPrefabFile = Directory.GetFiles(Application.dataPath + "\\LocalizationPrefab", "LocalizationTextTempPrefab.prefab", SearchOption.TopDirectoryOnly); GameObject localizationTextPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(getAssetPath(newComponentPrefabFile[0])); long newComponentFileID = getFileID(localizationTextPrefab.GetComponent<LocalizationText>()); newScriptFileID = getScriptFileIDbyFileID(newComponentFileID, getAssetPath(newComponentPrefabFile[0]), newScriptGUID); //获取老脚本FileID,GUID string[] oldComponentPrefabFile = Directory.GetFiles(Application.dataPath + "\\LocalizationPrefab", "TextTempPrefab.prefab", SearchOption.TopDirectoryOnly); GameObject textPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(getAssetPath(oldComponentPrefabFile[0])); long oldComponentFileID = getFileID(textPrefab.GetComponent<Text>()); oldScriptFileID = getScriptFileIDbyFileID(oldComponentFileID, getAssetPath(oldComponentPrefabFile[0]), oldScriptGUID); oldScriptGUID = getScriptGUIDbyFilePath(getAssetPath(oldComponentPrefabFile[0])); replaceStringPairs.Clear(); List<string> allPrefabPaths = getAllPrefabsFromPaths(executePaths); Debug.Log("begin:replaceTextComponents"); foreach (string file in allPrefabPaths) { string path = getAssetPath(file); getAllTextComponents(path); } Debug.Log("Text Com count: " + texts.Count); Debug.Log("begin:getReplaceStringPairs"); foreach (string file in allPrefabPaths) { string path = getAssetPath(file); getReplaceStringPairs(path); } Debug.Log("replaceStringPairs: " + replaceStringPairs.Count); Debug.Log("begin:updatePrefab"); foreach (string file in allPrefabPaths) { string path = getAssetPath(file); updatePrefab(path); } printReplacePairs(); EditorApplication.SaveAssets(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log("Replace Complete"); } private static List<string> getAllPrefabsFromPaths(List<string> executePaths) { List<string> allPrefabPaths = new List<string>(); foreach (string dir in executePaths) { string absolute_dir = Application.dataPath.Substring(0, Application.dataPath.LastIndexOf('/')) + "/" + dir; if (Directory.Exists(dir)) { string[] files = Directory.GetFiles(absolute_dir, "*.prefab", SearchOption.AllDirectories); allPrefabPaths.AddRange(files); } if (Path.GetExtension(absolute_dir).Equals(".prefab")) { allPrefabPaths.Add(absolute_dir); } } return allPrefabPaths; } private static string getScriptGUIDbyFilePath(string prefabPath) { Regex rg = new Regex(@"(?<=m_Script:\s{fileID:\s-?[0-9]+, guid:\s)[a-z0-9]{32,}(?=,)"); using (StreamReader sr = new StreamReader(prefabPath)) { int beginLineNumber = 3; for (int i = 0; i < beginLineNumber - 1; i++) { sr.ReadLine(); } string line; while (!string.IsNullOrEmpty(line = sr.ReadLine())) { MatchCollection mc_Scripts = rg.Matches(line); if (mc_Scripts.Count != 0) { return mc_Scripts[0].ToString(); } } } return ""; } private static long getScriptFileIDbyFileID(long newComponentFileID, string prefabPath, string matchString) { using (StreamReader sr = new StreamReader(prefabPath)) { int beginLineNumber = 3; for (int i = 0; i < beginLineNumber - 1; i++) { sr.ReadLine(); } string line; while (!string.IsNullOrEmpty(line = sr.ReadLine())) { if (line.StartsWith("---")) { MatchCollection mc_ComponentFileID = rg_Number.Matches(line); if (newComponentFileID == long.Parse(mc_ComponentFileID[1].ToString())) { long fileID = 0; string guid = ""; while (!string.IsNullOrEmpty(line = sr.ReadLine())) { MatchCollection mc = rg_FileID.Matches(line); MatchCollection mc_guid = rg_GUID.Matches(line); if (mc.Count != 0 && int.Parse(mc[0].ToString()) != 0) { fileID = int.Parse(mc[0].ToString()); if (mc_guid.Count != 0 && !string.IsNullOrEmpty(mc_guid[0].ToString())) { guid = mc_guid[0].ToString(); if (guid == matchString) { return fileID; } } } } } } } } return 0; } private static void getAllTextComponents(string prefabPath) { var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath); Text[] comps_Old = prefab.GetComponentsInChildren<Text>(); foreach (Text com_Old in comps_Old) { texts.Add(com_Old); long old_fileID = getFileID(com_Old); if (!oldFileIDs.Contains(old_fileID)) { oldFileIDs.Add(old_fileID); } } } private static void getReplaceStringPairs(string prefabPath) { using (StreamReader sr = new StreamReader(prefabPath)) { int beginLineNumber = 3; for (int i = 0; i < beginLineNumber - 1; i++) { sr.ReadLine(); } string line; while (!string.IsNullOrEmpty(line = sr.ReadLine())) { if (line.StartsWith("---")) { MatchCollection mc_ComponentFileID = rg_Number.Matches(line); long thisComID = long.Parse(mc_ComponentFileID[1].ToString()); if (oldFileIDs.Contains(thisComID)) { long this_FileID = 0; string this_GUID = ""; while (!string.IsNullOrEmpty(line = sr.ReadLine())) { MatchCollection mc = rg_FileID.Matches(line); MatchCollection mc_guid = rg_GUID.Matches(line); if (mc.Count != 0 && int.Parse(mc[0].ToString()) != 0) { this_FileID = int.Parse(mc[0].ToString()); if (mc_guid.Count != 0 && !string.IsNullOrEmpty(mc_guid[0].ToString())) { this_GUID = mc_guid[0].ToString(); if (oldScriptGUID == this_GUID && oldScriptFileID == this_FileID) { string replace_old = "fileID: " + this_FileID + ", guid: " + this_GUID; string replace_new = "fileID: " + newScriptFileID + ", guid: " + newScriptGUID; if (!replaceStringPairs.ContainsKey(replace_old)) { replaceStringPairs.Add(replace_old, replace_new); } } } } } } } } } } private static void updatePrefab(string prefabPath) { string con; using (FileStream fs = new FileStream(prefabPath, FileMode.Open, FileAccess.Read)) { using (StreamReader sr = new StreamReader(fs)) { con = sr.ReadToEnd(); foreach (KeyValuePair<string, string> rsp in replaceStringPairs) { if (con.Contains(rsp.Key)) { Debug.Log("Contains: " + rsp.Value); } con = con.Replace(rsp.Key, rsp.Value); } } } using (FileStream fs2 = new FileStream(prefabPath, FileMode.Open, FileAccess.Write)) { using (StreamWriter sw = new StreamWriter(fs2)) { sw.WriteLine(con); } } } private static void printReplacePairs() { foreach (KeyValuePair<string, string> rsp in replaceStringPairs) { Debug.Log(rsp.Key + " -- " + rsp.Value); } } private static string getAssetPath(string str) { var path = str.Replace(@"\", "/"); path = path.Substring(path.IndexOf("Assets")); return path; } private static PropertyInfo inspectorMode = typeof(SerializedObject).GetProperty("inspectorMode", BindingFlags.NonPublic | BindingFlags.Instance); private static long getFileID(UnityEngine.Object target) { SerializedObject serializedObject = new SerializedObject(target); inspectorMode.SetValue(serializedObject, InspectorMode.Debug, null); SerializedProperty localIdProp = serializedObject.FindProperty("m_LocalIdentfierInFile"); return localIdProp.longValue; } #endregion }