1. 项目概述:一个被忽视的UI细节痛点
在Unity UI开发中, Text 或 TextMeshPro 组件是展示文字信息最基础、最频繁的控件。我们常常花费大量精力在字体、颜色、动画效果上,却很容易忽略一个看似微小、实则严重影响用户体验和产品专业度的细节: 标点符号的排版 。具体来说,就是“句首标点”和“标点导致的意外换行”这两个问题。
想象一下,你精心设计了一个对话气泡、一段物品描述或者一个任务提示。当动态文本被填入时,一个句号“。”或者一个逗号“,”孤零零地跑到了下一行的开头,或者一个右括号“)”被尴尬地留在了上一行的末尾,而它的左括号“(”却在新行。更常见的是,因为一个标点符号的“顽固”,导致本可以紧凑排列的一行文字被强制换行,破坏了整体的布局美感。这些问题在中文排版中尤为突出,因为我们的标点通常占一个全角字符宽度,且排版规则(如避头尾)与英文不同。
Unity自带的 Text 组件和功能更强大的 TextMeshPro 组件,其换行逻辑主要基于字符宽度和文本框的 RectTransform 尺寸进行“硬计算”。它们并不理解中文排版中“标点符号不应位于行首”等规则。这个痛点几乎在每个需要显示动态、不定长文本的Unity项目中都会出现,无论是手游、PC游戏还是应用软件。
手动在字符串里加 \n 或者调整文本框宽度是低效且不可维护的,特别是当文本内容来自策划配置表、网络接口或多语言系统时。因此,我们需要一个 自动化、高性能、可配置 的解决方案,在文本渲染前对其进行预处理,智能地调整标点位置,实现更优雅、更专业的排版效果。这就是本次实战要解决的问题。
2. 核心思路与方案设计
解决这个问题的核心思路很直接: 在Unity将文本字符串交给 Text 组件渲染之前,对字符串本身进行预处理,重新排列字符顺序,以避免标点符号出现在行首或导致不美观的换行。
2.1 问题拆解与目标定义
首先,我们需要明确要处理的具体场景:
- 禁止行首标点 :确保任何一行的第一个字符不是特定的标点符号(如中文的句号、逗号、感叹号、问号、右括号、右引号等)。
- 处理标点换行 :当一行末尾是一个标点,且该标点导致文本宽度刚好超出容器边界时,尝试调整以避免难看的“标点悬挂”或因此产生的额外空行。
- 保持高性能 :预处理算法不能成为性能瓶颈,尤其是在UI频繁更新或文本量较大时(如长篇公告)。
- 兼容性与可配置性 :方案应能同时适用于Unity原生的
Text组件和TextMeshPro,并且允许开发者自定义需要处理的标点集合。
2.2 方案选型:预处理 vs 运行时计算
有两种主流实现路径:
- 路径A:基于字符宽度的精确计算 。在预处理时,模拟
Text组件的渲染引擎,使用Font.GetCharacterInfo或TMP_Text.GetTextInfo逐个计算字符宽度,精确判断换行点,然后进行字符调整。这种方法最准确,但实现复杂,且严重依赖字体信息,性能开销相对较大。 - 路径B:基于规则与字符串操作的启发式处理 。不进行精确的像素级宽度计算,而是定义一套规则(如“某些标点不能出现在行首”),在字符串中插入零宽空格
\u200B或调整字符顺序来“暗示”或“强制”换行引擎做出我们期望的决策。这种方法实现简单,性能极高,在绝大多数情况下效果足够好。
对于大多数项目而言, 路径B的启发式处理是性价比最高的选择 。Unity的换行算法在遇到零宽空格时,会将其视为一个潜在的换行点。我们可以利用这一点,在“禁止行首”的标点前插入一个零宽空格,这样当排版引擎计算到此处时,就会优先在前一个“正常”字符后换行,从而将标点“推”到下一行的行中位置。
本实战将采用 路径B ,并在此基础上增强,处理更复杂的“成对标点分离”问题(如括号、引号)。
2.3 整体架构设计
我们将创建一个名为 PunctuationOptimizer 的静态工具类。它的核心工作流程如下:
- 输入 :原始字符串、用户自定义的“禁止行首标点”集合。
- 处理 :
- 遍历字符串,识别出所有属于“禁止行首标点”集合的字符。
- 对于每一个这样的标点,检查它是否有可能成为行首(即它前面是换行符
\n或字符串开头)。 - 更进阶地,处理成对标点:当左括号
(在行尾而右括号)在行首时,调整它们的位置使其尽可能处于同一行。
- 输出 :处理后的新字符串。这个新字符串可以直接赋值给
Text或TextMeshPro组件的text属性。
为了便于使用,我们还会创建一个 MonoBehaviour 组件 TextPunctuationHelper ,可以挂载在任何有 Text 或 TextMeshPro 组件的UI对象上,自动在 Start 或 OnEnable 时进行优化,并提供一个编辑器按钮方便测试。
3. 核心算法实现与代码解析
接下来,我们深入代码层面,看看如何实现上述的启发式算法。我们将分步构建 PunctuationOptimizer 类。
3.1 基础定义与配置
首先,定义一些常量和配置。我们将默认的中文避头尾标点集合初始化好,同时也允许用户传入自定义集合。
using System.Collections.Generic;
using System.Text;
using UnityEngine;
public static class PunctuationOptimizer
{
// 默认的禁止出现在行首的标点符号集合(基于中文排版规则)
private static readonly HashSet<char> DefaultForbiddenLineStartChars = new HashSet<char>
{
'。', ',', '、', ';', ':', '!', '?', // 句末和句中点号
'”', '’', ')', '】', '』', '》', '〉', '〕', '〉', '》', // 右引号、右括号类
'…', '—', // 省略号、破折号(后半部分)
'.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '\'', '\"' // 英文标点(部分场景需要)
};
/// <summary>
/// 优化文本,避免标点出现在行首。
/// </summary>
/// <param name="originalText">原始文本</param>
/// <param name="customForbiddenChars">自定义的禁止行首字符集合,为null时使用默认集合</param>
/// <returns>优化后的文本</returns>
public static string Optimize(string originalText, HashSet<char> customForbiddenChars = null)
{
if (string.IsNullOrEmpty(originalText))
return originalText;
HashSet<char> forbiddenChars = customForbiddenChars ?? DefaultForbiddenLineStartChars;
// 核心处理逻辑将在下一步实现
return ProcessLineStart(originalText, forbiddenChars);
}
}
注意 :这里使用
HashSet<char>来存储禁止字符,是因为它的查找时间复杂度是O(1),在遍历字符串时效率极高。千万不要用List<char>并在其中使用Contains方法,那会大幅降低性能。
3.2 核心处理逻辑:防止标点出现在行首
这是算法的核心。我们遍历字符串,寻找那些属于“禁止集合”且前面是换行符或字符串开头的标点。
private static string ProcessLineStart(string text, HashSet<char> forbiddenChars)
{
StringBuilder sb = new StringBuilder(text.Length + 10); // 预分配稍大空间
for (int i = 0; i < text.Length; i++)
{
char currentChar = text[i];
sb.Append(currentChar);
// 检查下一个字符是否存在且是否为禁止行首的标点
if (i + 1 < text.Length)


336

被折叠的 条评论
为什么被折叠?



