做了这么多年Unity项目的本地化,我越来越确定一件事:多语言这事,麻烦从来不在翻译本身,而在于把翻译结果准确、无损、可维护地塞回游戏里。尤其是项目一多、文本一杂,光靠手动复制粘贴配置肯定要出事故。所以我自己搓了一个"Unity多语言转换工具",核心思路就一句话:把散落在场景、预制体里的中文文本自动抽成表格,批量翻译完再自动导回成运行时资源,整个流程能不用手就别用手。
这套东西做下来,解决的痛点很具体:文本漏抽、Key命名混乱、翻译完不知道填哪、运行时切语言要重启、Excel打开CSV乱码……每一个都是实际开发中踩过的坑。这篇文章我会把整套工具的架构、核心代码、踩坑记录和排查思路完整拆开,给同样被本地化折腾过的开发者一个可以直接参考的落地样板。
1. 先说说为什么非要自己造这个轮子
1.1 官方方案和第三方插件的尴尬
Unity生态里做本地化,绕不开两条路:官方UCL(Unity Localization)和第三方插件如I2 Localization。这两套方案功能都挺全,但都有一个共性门槛——编辑器学习曲线陡,数据模型重。
官方Localization包走的是Asset + Table + Entry的体系,配置界面层级深,还要理解Locale、StringTableCollection、Smart String这些概念。如果你只做了三五种语言、几十个UI界面,花一两天去啃这套体系其实是划算的;但如果你手头是个小团队、项目节奏紧、临时要加一个阿拉伯语版本,方案选型的成本就已经吃掉一半预算了。
I2那套则相反,上手快,但自动化能力偏弱。它擅长的是"你已经知道要翻译什么、手动填进去",而不是"我帮你从场景里自动挖出所有文本再丢给翻译API"。
说白了,市面上现成的工具解决的是"翻译内容怎么存、怎么运行时读取",但很少解决上游的"文本怎么抽出来、怎么批量翻译、怎么保证翻译结果是干净可用的"。我的工具选择自己实现,就是为了把这条链路串起来:场景扫描 → 表格生成 → 机器翻译 → 资源导入 → 运行时切换,每一步都可控、可查、可自动化。
1.2 工具要解决的四个核心痛点
开发过程中我最烦的几件事,是这套工具设计的第一驱动力:
- 漏文本。策划改了个新手引导文本,程序只改了场景里的Text,本地化导出的时候没人记得重新扫一遍,结果海外面板玩家看到一堆空心方块或者干脆是中文。
- Key混乱。手动维护字典的人一多,命名风格就百花齐放,有的是
text_1001,有的是guide/confirm,还有直接拿中文当Key的。到了后期,同一个意思的文本在表里出现五遍。 - 翻译流程断档。传统的做法是:程序导出Excel → 发给翻译公司 → 翻译公司回传 → 程序手动粘贴到代码里。这个周期以天计算,而且中间任何一次表格格式微调,都可能导致整列错位。
- 运行时切语言体验差。不少项目是切换语言后要求玩家重启应用,这其实是技术偷懒。真正做本地化的产品,切换语言应该像切设置一样即时生效。
这套工具不算什么惊天动地的创新,但就是把上面四个痛点全部用自动化手段堵死了。核心原则是:人只负责审,不负责搬运。
1.3 整体设计思路与数据流
整个工具分为编辑器和运行时两大部分,总的数据流是这样的:
Unity场景/预制体中的Text组件 ↓ 编辑器扫描 翻译Key清单(自动去重、命名) ↓ 导出 中间格式CSV(含原始中文文本) ↓ 翻译管线(调用API / 人工校对) 多语言CSV(中文+英文+日语+韩语...) ↓ 导入 生成语言Asset(ScriptableObject) ↓ 运行时 LocalizationManager 加载并响应切换为什么中间格式选CSV而不直接存Unity的ScriptableObject?因为CSV是翻译行业事实上的通用交换格式,翻译公司、在线翻译平台、Excel都能无缝处理。直接生成一个巨大的SO资产当然也行,但如果你需要发出去给外部团队处理,或者想用脚本批量diff两份翻译结果,CSV比二进制资产方便太多了。
运行时资源选ScriptableObject而不是JSON,理由也很直接:SO能直接在Inspector里查看和编辑,项目中文案在做完导入后,任何人打开工程都能一眼看到当前语言的文本内容,不用等运行时Log再排查问题。加上SO天然支持Unity的序列化体系,资源打包、热更都很友好。
2. 编辑器端核心设计:从场景文本到CSV
2.1 文本收集:怎么把场景里的"你好"挖出来
这个工具的第一个关键环节,是把场景和预制体里所有可能的文本源遍历出来。实现不复杂,核心是UnityEditor的预制体工作流。
我的做法是写一个编辑器扫描器,遍历指定目录下的所有Prefab以及当前打开的场景,找到Text和TextMeshProUGUI组件,读取它们的text属性。如果是TMP组件,还要额外处理它的text可能来自TMP_Text的富文本标签情况。
扫描结果会先进入一个去重阶段。同一个文本内容,比如"确定"、"取消"这种高频词,在全项目里可能出现几十次。如果每处都生成一个独立Key,翻译表会膨胀得很厉害。所以我按内容文本做了聚合:同一个原文,只生成一个Key,源位置列表记下来,导入阶段再回写到所有位置。
遍历本身要注意的点:
- 必须跳过编辑器专用目录(如
Editor文件夹、Gizmos),否则会把工具的编辑器文本也收集进去。 - 要忽略动态生成的文本。比如代码里用
String.Format拼出来的句子,扫描器在静态场景里是看不到的,这部分需要单独给一个"动态Key"注册入口,让开发者手工声明。 - 资源路径用
AssetDatabase.GetAssetPath取,不要用字符串拼,避免路径大小写问题在不同平台上的差异。
核心逻辑大致是这样:
public static List<TextEntry> CollectAllTexts(string[] targetDirectories) { var result = new Dictionary<string, TextEntry>(); var prefabGuids = AssetDatabase.FindAssets("t:Prefab", targetDirectories); foreach (var guid in prefabGuids) { var path = AssetDatabase.GUIDToAssetPath(guid); var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path); var texts = prefab.GetComponentsInChildren<Text>(true); var tmpTexts = prefab.GetComponentsInChildren<TMP_Text>(true); foreach (var text in texts) { if (string.IsNullOrWhiteSpace(text.text)) continue; if (text.text.StartsWith("[LOC]")) continue; // 跳过已标记忽略的 AddOrMerge(result, text.text, path); } // TMP_TEXT 同样处理 } return result.Values.ToList(); }这段代码有个细节值得提:GetComponentsInChildren的第二个参数传true,表示包括隐藏的GameObject。很多新手容易漏掉这点——尤其是默认隐藏的弹窗、活动页面,如果不扫隐藏物体,等上线之后某个界面突然冒出一句中文就很尴尬了。
2.2 Key的自动生成策略
Key的命名是最容易被低估的坑。早期我用的是"内容哈希":对原文做Hash生成一个定长ID,比如txt_8f2a1c。优点是唯一性极好、永远不重复,缺点是可读性差。策划拿到这张表,完全看不出txt_8f2a1c是哪个界面的什么文案,核对翻译的时候非常痛苦。
后来我改用基于语义的Key生成,规则是:界面名/组件用途 + 内容摘要。例如扫描到"新手引导"面板里的"点击继续",自动生成Key为guide_start_click_continue。这套规则能让所有看到表格的人,都大致知道文本出自哪里,翻译核对效率翻倍。
具体实现上,我维护了一个字典:旧的Key和内容映射到新的自动规则时,如果内容没变,Key保持不变。只有内容变化、新增文本时,才重新生成新Key。这保证了已完成的翻译不会因为Key改名而全部作废。
Key唯一性检查也是必须的。扫描完成后,工具会跑一遍冲突检测,两个不同文本内容映射到同一个Key时直接标红报错。这种bug一旦漏到线上,就会出现A界面文案显示成B界面文案的诡异问题。
生成规则要支持自定义。每个团队的命名习惯不同,我在工具里开放了一个简单的配置面板,可以设置前缀、分隔符、是否包含界面名。实际项目里我建议团队把这个配置提交到版本库,所有人统一用一套生成标准,避免命名风格碎片化。
2.3 CSV中间格式的设计细节
CSV看起来简单,实际上这里坑非常多,我踩过的都写在这一节。
第一,编码问题。Windows平台下,Excel默认用系统ANSI编码打开CSV,而Unity的File.WriteAllText默认是UTF-8无BOM。这样生成的CSV用Excel打开时,中文全是乱码。解决方案是手动写入带BOM的UTF-8:
var utf8WithBom = new UTF8Encoding(true); File.WriteAllText(path, csvContent, utf8WithBom);这是整个工具里最不起眼但最关键的一行代码。没有BOM,翻译公司返回的文件大概率也是乱码,一步错步步错。
第二,字段转义。游戏文案经常带逗号、双引号甚至换行。如果直接拼字符串,下一行就串位了。比如一句对话原文是"他说:'我很好,真的。'"其中的逗号和中文引号都是JSON格式的风险点。CSV标准规则是:字段内包含逗号、引号、换行时,整个字段用双引号包裹;字段内的双引号用两个双引号转义。具体实现可以用一个静态方法做转义,或者直接用TextFieldParser,但为了不引入额外的依赖,我倾向于自己写一个轻量的CSV读写器:
public static string EscapeField(string field) { if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')) { return "\"" + field.Replace("\"", "\"\"") + "\""; } return field; }解析的时候,同样要处理带引号字段内的换行。因为文案里含有换行是很正常的,比如对话文本通常就有分段。
第三,列结构。我采用的是固定列结构而不是原文本即Key。每行结构:
| key | source(zh-CN) | en-US | ja-JP | ko-KR | 备注 |
|---|
其中source永远是项目主语言,导出时填充;其他语言列由翻译管线填充。把主语言独立一列,方便程序定位对照,而不是把Key当文本源。
第四,注释和忽略标记。扫描器在收集文本时,允许开发者通过给文本添加特定前缀来控制是否导出该文本到CSV。我用的前缀是[SKIP],挂在Text组件文本的开头,工具扫描时就自动跳过这个字段。这样做非常方便,因为有些文本就是运行时生成的占位符,或者临时测试文案,不需要进入翻译流程。
3. 翻译管线与导入实现
3.1 翻译API接入与限流处理
CSV生成之后,就要进入翻译环节。工具支持两种翻译方式:调用机器翻译API和人工翻译。
机器翻译API我用过有道的和百度的,整体接入流程类似:注册应用、申请密钥、按字符计费、有并发限制。通常的流程是:
- 初始化一个
TranslationProvider接口,方便后面替换供应商。 - 批量读取CSV源文本列,组装请求。
- 调用翻译API,把目标语言翻译结果回填到对应列。
- 保存CSV。
调用时有几个非常关键的工程细节:
限流和重试。翻译API通常有QPS限制,比如每秒最多请求5次。免费额度或者低配套餐可能更低。直接循环调用肯定会被封,即使不封,也可能因为触发限流返回错误码。我的做法是加一个简单的请求队列:
private async Task<string> TranslateWithRetry(string text, string targetLang, int maxRetry = 5) { int retry = 0; while (retry < maxRetry) { try { var result = await provider.TranslateAsync(text, targetLang); return result; } catch (RateLimitException) { retry++; await Task.Delay(1000 * retry); // 指数退避 } } throw new Exception("翻译失败,超过最大重试次数"); }RateLimitException是自定义异常,在HTTP返回限流状态码时抛出。注意这里Task.Delay(1000 * retry)是指数退避,避免一直撞墙。
批量并发控制。如果逐条翻译几千条文本,速度太慢了。我会用SemaphoreSlim控制最大并发数,比如同时最多5个请求。这样既不会触发API限流,也能明显缩短总耗时。
翻译结果的校验。机器翻译不是百分百可靠,尤其是游戏术语、品牌名、专有名词。所以翻译完成之后,不能直接导入,必须先经过人工校对。工具里内置一个"待校对列表"视图,把所有机器翻译结果标记为"未确认"状态,人工在编辑器面板上逐条确认或者修改。只有确认过的条目才能导入。
3.2 保护占位符与富文本标签
翻译管线里最容易被坑的,就是文本里的动态内容和格式标签。
游戏文案几乎都带着占位符,比如:
- "欢迎回来,{playerName}!"
- "今日剩余次数:{0}"
- "获得道具 {itemName} x{count}"
直接把这个原文丢给翻译API,机器翻译很可能把{playerName}翻译成一些乱七八糟的内容,或者干脆把占位符吃掉。后者尤其致命——翻译回来的文本里找不到{playerName},运行时会直接显示"欢迎回来,!"这样的漏字符。
我的处理方案是两步保护:翻译前把所有占位符提取出来,替换为特殊令牌;翻译完成后再把令牌还原为占位符。
具体实现:
private static string ProtectTokens(string text, out Dictionary<string, string> tokenMap) { tokenMap = new Dictionary<string, string>(); var regex = new Regex(@"\{[^{}]*\}"); var matchIndex = 0; return regex.Replace(text, match => { var token = $"__TOKEN_{matchIndex++}__"; tokenMap[token] = match.Value; return token; }); }还原时再扫一遍令牌,把__TOKEN_0__还原成{playerName}。注意TOKEN本身要选一个在正常文本中几乎不可能出现的格式,否则会和用户文本撞车。
富文本标签也是一样的道理。Unity的TMP文本经常带<color=#FF0000>、<size=30>这种标签,如果翻译API把它们拆散或者插入了不匹配的标签,运行时TMP解析就会抛异常或者显示乱码。保护方式和占位符完全一致,正则换成匹配<[^>]+>即可。
还有一类是换行符:大部分翻译服务会把\n当成普通字符,翻译后变成空格或者被直接删掉。我的做法是在保护阶段把\n替换成__NEWLINE__标记,翻译完成再还原为\n。
3.3 从CSV到运行时资源
CSV搞定之后,剩下的是导入。导入阶段要做的事:读取CSV,对每一行解析出Key和各语言文本,然后生成(或更新)一个ScriptableObject格式的语言资产。
资产结构我设计得比较轻:
[CreateAssetMenu(fileName = "LangTable.asset", menuName = "Localization/Lang Table")] public class LangTable : ScriptableObject { public string languageCode; // "zh-CN" / "en-US" public List<LangEntry> entries; [Serializable] public class LangEntry { public string key; public string text; } }每个语言生成一个独立的LangTable资产,放在Assets/Localization/Generated/目录下,文件名为LangTable_zh-CN.asset这样。
导入工具要特别注意增量更新:不能每次导入都新建资产把所有条目重新写入,因为开发过程中可能有其他人手工改过这些资产,粗暴覆盖会丢改动。我的做法是,导入前先检查已有的资产,如果Key已存在,用CSV中的最新值覆盖;如果Key不存在,追加新条目;如果CSV已删除该Key,则从资产中删除——但要弹窗确认,防止误删。
资产生成时,还会自动跑一次漏翻译检查:对所有非主语言列,如果为空则标记为[MISSING],并在日志里输出一行警告,包括Key和原文。这样导入完就能马上看到哪些文本还没有翻译,不用等运行时才发现。
3.4 运行时切换语言的实现
运行时这块是整个工具能不能落地的关键。如果切语言需要重启应用,那前面做得再好体验也是碎的。
核心是一个LocalizationManager单例:
public class LocalizationManager : MonoBehaviour { public static LocalizationManager Instance { get; private set; } private Dictionary<string, LangTable> _tables = new(); private string _currentLanguage = "zh-CN"; public event Action OnLanguageChanged; private void Awake() { Instance = this; DontDestroyOnLoad(gameObject); } public void LoadLanguage(string code) { var table = Resources.Load<LangTable>($"Localization/LangTable_{code}"); if (table == null) return; _tables[code] = table; } public string GetText(string key, params object[] args) { var table = _tables[_currentLanguage]; var entry = table.entries.Find(e => e.key == key); var template = entry?.text ?? key; return args.Length > 0 ? string.Format(template, args) : template; } }所有需要响应语言切换的UI组件,在OnLanguageChanged里重新拉取文本。但每个Text组件手动写监听太累了,我写了一个LocalizedText组件挂到对应的Text/TMP组件上:
public class LocalizedText : MonoBehaviour { public string key; private TMP_Text _tmp; private void OnEnable() { _tmp = GetComponent<TMP_Text>(); LocalizationManager.Instance.OnLanguageChanged += Refresh; Refresh(); } private void OnDisable() { if (LocalizationManager.Instance != null) LocalizationManager.Instance.OnLanguageChanged -= Refresh; } public void Refresh() { if (_tmp != null) _tmp.text = LocalizationManager.Instance.GetText(key); } }这样切换语言的时候,所有挂载了LocalizedText的组件会自动刷新,不需要重启应用。场景里动态生成的对象,只要在生成后调用一次Refresh即可。
需要说明的是,这里用到Resources.Load是为了讲解方便。生产环境最好用Addressables或者直接引用Asset,把表格打进Bundle,避免Resources目录过度膨胀。不过原理完全一样,无非是加载方式换成异步而已。
4. 常见问题与排查技巧实录
4.1 Excel打开CSV乱码:编码错位
这个前面提到过一次,但实际项目中它引发的连锁反应值得单独说。具体场景是:开发用Mac导出CSV,用Windows的Excel打开,中文全是Æ–°æ‰‹这种乱码。查下来原因就是UTF-8无BOM,Excel默认按GBK解码。
解决方案已经说过:写文件时强制用UTF8Encoding(true)带BOM输出。
还有一个延伸问题:如果CSV已经是无BOM的UTF-8文件,不要手工去改编码,直接用脚本批量转一趟即可。我用的是一个简单的控制台脚本遍历CSV目录,重新读入再保存成带BOM格式。注意读入时一定要显式指定UTF-8编码,不要用系统默认编码。
4.2 CSV字段解析错乱:引号和换行
有次从翻译公司拿回来的CSV,导入工具后大量文本错位,有的Key下面对应的翻译是别的条目。排查后发现是翻译公司用Excel编辑时,把含逗号的字段处理成了引号字段,而我的CSV解析器没有正确处理"字段内双引号转义",导致后续所有列全部错位。
从那以后,我对CSV解析做了三层防御:
- 编写了完整的CSV解析器,遵守RFC 4180规则。
- 导入时做列数校验,每行分隔后列数必须一致,否则立刻报错并指出行号。
- 导入前先跑一个"烟雾测试":读取前20行,确认Key列和主语言列都存在非空值,否则直接终止导入。
这也是为什么我一直强调:中间格式能不用手写解析器就不要手写,但如果必须手写,一定要把所有转义规则测一遍。实测中很多CSV库都能处理标准格式,但一旦遇到带引号换行就考验实现质量了。
4.3 机器翻译把占位符翻译没了
这个前面提过保护机制,但真实场景比想象中更邪门。有一次翻译"恭喜你获得{0}个金币",英文翻译回来是"Congratulations on getting {0} coins",占位符没丢,但位置从中间挪到了后面。这在语法上没问题,运行时也不会报错,因为string.Format只认位置。但很多项目的真名占位符是{playerName}这种,一些翻译引擎会把大括号当成一般标点处理,可能给换成{player name}、(playerName)之类,结果运行时匹配不上。
我的排查方法是写一个巡检脚本:导入完成后,对每个条目的文本做个占位符匹配,检查原文本里的占位符集合和目标文本里的占位符集合是否完全一致(类型、数量、顺序可以不同,但数量和类型必须一致)。不一致的直接标红。这样就能在导入阶段把所有翻译丢占位符的问题一次性揪出来。
4.4 字体缺失导致方框乱码
这是多语言项目最典型的翻车现场。中文正常的UI切到英文没问题,但切到日语或韩语,一大片方框。原因是TMP的字体资产只包含中文字形,不支持日韩字符。
处理方案大致有两条:
- 使用大字库字体。找一款覆盖中日韩统一表意文字的TTF(常见的有思源黑体),导入Unity后生成TMP字体资产,勾选动态字库模式。
- 按语言切换字体。每个语言维护一个FontSettings资产,
LocalizedText刷新文本时同步切换字体。
更现实的方案是两者的组合:默认用大字库字体,但欧系语言(法语、德语、西班牙语)的拉丁字符本身都在中文字体里,切换字体时要注意保留粗体、斜体的样式映射。TMP的字体资产做成动态字库后,首次出现某个字符会动态生成字形,运行时有轻微卡顿,通常可以接受,但如果项目对性能敏感,建议做一次"预生成":启动时遍历本语言所有Key,把文本传给TMP让字形提前生成好。
我在项目中还专门写了一个快捷键操作:扫描当前语言所有文本,调内部接口强制生成字形,确保上线前把这个语言用到的字符全部固化在字体资产里。
4.5 UI被长文本撑爆
英文通常比中文长30%左右,德语甚至可能翻倍。按钮和标签在中文状态下整整齐齐,切到英文直接溢出边框。
我们项目一开始的解决方案是"人工注意留空间",但后来发现完全不可控。后来在LocalizedText.Refresh里加了一个自适应策略:刷新文本后,检测TMP的preferredWidth是否超过RectTransform的当前宽度,如果超了就尝试自动换行、缩放字号,甚至调整对齐方式。
public void Refresh() { _tmp.text = LocalizationManager.Instance.GetText(key); LayoutRebuilder.ForceRebuildLayoutImmediate(rectTransform); if (_tmp.preferredWidth > rectTransform.rect.width) { _tmp.fontSize = Mathf.Max(minFontSize, _tmp.fontSize * 0.9f); } }这个方案只能应急,真正的根子还是在UI设计阶段就为每种语言预留伸缩空间。工具层面能做的,是把这些超长文本以警告的形式全部收集出来,形成一个"本地化UI风险清单",让开发者在界面上直观看到哪些控件在哪种语言下会溢出。这个报告生成器我用得非常频繁,每次新增语言都会跑一遍。
5. 几个非常值得做的扩展功能
5.1 增量更新与人工校对闭环
一个成熟的工具,不能每次全量跑完就算完了。项目迭代过程中,每周都有几百条新文案,如果每次都要求翻译公司把整张表翻一遍,成本太高了。增量更新的逻辑其实非常简单:
- 导出时,把新扫描到的文本与旧CSV的Key对比。
- 如果Key已存在且原文没变,旧翻译保留。
- 如果原文变了,该Key的旧翻译标记为过时,需要重新翻译。
- 新增的Key则进入"待翻译"队列。
这样翻译公司只需要处理增量部分,量小、快、也便宜。人工校对流程也在这个闭环里:开发者可以从工具中导出"待校对.xlsx",校对完成再导入,工具自动更新并覆盖旧的翻译结果。
5.2 动态文本与复数规则
游戏里的"你还有3次机会"在英语里要考虑复数:You have 3 chances left,但You have 1 chance left单数形式不同。很多团队直接忽略这个问题,英文玩家看到也会觉得别扭,但不算致命。要想做好,需要引入复数规则引擎,在运行时根据数量选择正确的文案模板。
我在工具里做的方案是:给Key增加后缀变体,例如key.zero、key.one、key.other,按照CLDR复数规则加载对应的文本。CSV里每个Key可以有多行变体,导入时解析成同一个Key的多个候选文本。运行时:
var text = LocalizationManager.Instance.GetTextPlural(key, count);实现不复杂,但效果是英文、俄语这些复数体系复杂的语言穿上了一层"本地化尊严"。如果你的游戏未来要上欧美和东欧市场,这个扩展建议早早考虑进去。
5.3 自动化集成到CI流程
最后一层,是把这套工具集成到自动化构建流程里。我们项目用的是命令行批处理模式,在构建出包前跑一个静态检查:
- 检查是否存在未翻译的Key。
- 检查是否存在重复的Key映射。
- 检查所有本地化资源是否与CSV同步。
- 有任何问题,构建直接失败并输出错误清单。
这样可以保证出包前每一批文案都是完整的。实际操作中我见过太多次"急着发版,结果某语言文本大面积缺失"的惨案。自动化检查可以在流程上杜绝这类问题。
最后再分享一点个人体会
这套工具从第一个版本到现在,前前后后迭代了将近半年。最大的体会就是:本地化工具的本质不是翻译,而是流程的确定性和可追溯性。翻译质量有高有低,那是人和模型的事;但框架保证每条文本都有明确Key、每个Key都有完整翻译、每次翻译都不丢占位符、每次切换语言都即时生效——这些是工程能兜住的事。
如果你也想做类似的东西,我建议不要一开始就搞得特别复杂。先做最核心的"扫描→导出→导入"三步,跑通之后再加翻译API、增量更新、CI检查。工具是给团队用的,不顺手的话没人会用,再好的架构也是摆设。把每一步的反馈做清楚、报错信息写明白,比加一堆花哨功能有意义得多。真正好用的工具,往往是那种"不显眼但每天都在替你挡坑"的存在。