Счётчик очков, таймер, полоска здоровья с цифрами, отладочный оверлей с FPS — почти в каждой игре есть текст, который меняется каждый кадр. И почти в каждой игре этот текст тихо производит мусор для сборщика: по 50–200 байт на кадр, а через минуту — заметный GC-спайк на слабом Android. В этой статье разберём, откуда именно берутся строковые аллокации в Unity, как их найти, и пять рабочих приёмов, которые убирают их полностью: от простой проверки «изменилось ли значение» до записи цифр прямо в буфер TextMeshPro и библиотеки ZString. Весь код рассчитан на Unity 2021.3 и новее, включая Unity 6.
Почему строки — главный источник мусора в UI
Строка в C# неизменяема. Любая операция, которая «меняет» строку, на самом деле создаёт новую в управляемой куче, а старая становится мусором. Вот типичный код, который пишут все:
void Update()
{
scoreLabel.text = "Очки: " + score;
timerLabel.text = $"{minutes:00}:{seconds:00}";
}
Что здесь происходит за кадр:
score(int) превращается в строку черезToString()— первая аллокация;- конкатенация
"Очки: " + ...создаёт вторую строку; - интерполяция
$"{minutes:00}:{seconds:00}"в C# 9, который использует Unity, компилируется вstring.Format: аргументы упаковываются вobject(боксинг — ещё две аллокации), создаётся массивobject[]для params и итоговая строка; - присваивание в
textу TextMeshPro заставляет компонент пересобрать меш, даже если текст не изменился.
Четыре-пять мелких объектов на каждую надпись, 60 раз в секунду. Mono-сборщик в Unity не поколенческий: даже с инкрементальным режимом он должен рано или поздно пройти по всей куче, и чем чаще вы её засоряете, тем чаще это случается.
Важная деталь: в Unity-версии Mono методыStringBuilder.Append(int)иAppend(float)внутри вызываютToString()у числа. То есть «просто перейти на StringBuilder» не делает код безаллокационным — об этом ниже.
Как найти строковые аллокации
Прежде чем оптимизировать, убедитесь, что проблема есть. Самый быстрый способ — Profiler в режиме Deep Profile или CPU Usage с колонкой GC Alloc: отсортируйте по ней и ищите String.Concat, String.Format, Int32.ToString, Single.ToString, Box.
Для проверки конкретного места удобно измерять аллокации прямо из кода через ProfilerRecorder. Счётчик "GC Allocated In Frame" показывает, сколько байт выделено в управляемой куче за кадр:
using Unity.Profiling;
using UnityEngine;
public class GcAllocMeter : MonoBehaviour
{
ProfilerRecorder gcAlloc;
void OnEnable() => gcAlloc = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame");
void OnDisable() => gcAlloc.Dispose();
void OnGUI()
{
// OnGUI сам аллоцирует, поэтому смотрите на разницу до и после правок
GUI.Label(new Rect(10, 10, 300, 20), "GC/кадр: " + gcAlloc.LastValue);
}
}
Учтите, что в редакторе цифры завышены: инспектор, окна и проверки безопасности сами создают мусор. Финальную проверку делайте в Development Build с подключённым профайлером.
Приём 1. Не обновляйте текст, если значение не изменилось
Самая дешёвая оптимизация — не делать работу вообще. Очки меняются несколько раз в минуту, а код выше пересобирает строку 60 раз в секунду. Храните последнее показанное значение:
using TMPro;
using UnityEngine;
public class ScoreView : MonoBehaviour
{
[SerializeField] TMP_Text label;
int shownScore = int.MinValue;
public void Show(int score)
{
if (score == shownScore) return;
shownScore = score;
label.text = "Очки: " + score;
}
}
Аллокация осталась, но теперь она происходит только при реальном изменении. Для 90% UI этого достаточно. Ещё лучше — вообще уйти от опроса в Update и обновлять надпись по событию изменения значения.
Для таймера сравнивайте не float, а то, что реально видит игрок. Если вы показываете секунды, сравнивайте целые секунды:
int shownSeconds = -1;
void Update()
{
int seconds = Mathf.CeilToInt(timeLeft);
if (seconds == shownSeconds) return;
shownSeconds = seconds;
UpdateLabel(seconds);
}
Таймер с точностью до секунды теперь пересобирает строку раз в секунду, а не каждый кадр — в 60 раз меньше мусора без единой хитрости.
Приём 2. Кэш готовых строк
Если множество значений небольшое и известное заранее, создайте все строки один раз. Классический пример — цифры от 0 до 99 для минут и секунд, номера уровней, проценты:
public static class NumberStrings
{
static readonly string[] twoDigits = Build();
static string[] Build()
{
var result = new string[100];
for (int i = 0; i < 100; i++)
result[i] = i.ToString("00");
return result;
}
public static string TwoDigits(int value) => twoDigits[Mathf.Clamp(value, 0, 99)];
}
То же самое касается enum. Вызов ToString() у перечисления в Mono медленный и аллоцирует каждый раз. Если вы выводите названия состояний или типов предметов, кэшируйте их:
public static class EnumNames<T> where T : struct, System.Enum
{
static readonly System.Collections.Generic.Dictionary<T, string> cache = new();
public static string Get(T value)
{
if (!cache.TryGetValue(value, out var name))
{
name = value.ToString();
cache[value] = name;
}
return name;
}
}
// Использование
stateLabel.text = EnumNames<EnemyState>.Get(state);
Словарь здесь заполняется один раз на каждое значение, дальше вызов Get только читает готовую строку. Если перечисление небольшое и без «дыр» в значениях, вместо словаря можно взять обычный массив строк, индексированный значением enum.
Кэш не подходит для произвольных чисел (очки до миллиона — это миллион строк), но для «маленьких» диапазонов он проще и быстрее любой другой техники.
Приём 3. Переиспользуемый StringBuilder — и его подвохи
StringBuilder — изменяемый буфер символов. Если создать его один раз и переиспользовать через Clear(), сам буфер перестанет аллоцироваться:
using System.Text;
using TMPro;
using UnityEngine;
public class StatsView : MonoBehaviour
{
[SerializeField] TMP_Text label;
readonly StringBuilder sb = new StringBuilder(64);
public void Show(string playerName, int level, int gold)
{
sb.Clear();
sb.Append(playerName);
sb.Append(" — ур. ");
sb.Append(level); // внимание: в Mono это level.ToString()
sb.Append(", золото: ");
sb.Append(gold); // и здесь тоже
label.SetText(sb); // без sb.ToString()
}
}
Здесь три важных момента.
Ёмкость. Задайте начальную ёмкость с запасом (new StringBuilder(64)). Если буфер переполнится, StringBuilder выделит новый кусок, и при следующем использовании он останется — это разовая аллокация, но лучше её избежать.
ToString() аллоцирует. Каждый вызов sb.ToString() создаёт новую строку. Если результат идёт в TextMeshPro, используйте перегрузку TMP_Text.SetText(StringBuilder): она копирует символы из билдера во внутренний буфер компонента, не создавая промежуточную строку.
Append(int) не бесплатен. Как уже сказано, в Mono-реализации числа превращаются в строку через ToString(). Если надпись обновляется редко, это не страшно. Если каждый кадр — нужен приём 4 или 5.
Совет по стилю: не делайте один «глобальный» StringBuilder на всю игру. Вложенный вызов, который тоже использует его, затрёт ваш текст посередине. Один билдер на компонент или на метод — надёжнее.
Приём 4. Числа прямо в TextMeshPro
У TMP_Text есть набор перегрузок SetText, которые принимают строку-шаблон и до трёх чисел типа float. Компонент сам форматирует числа в свой символьный буфер, без создания строки:
// Шаблон константный — строка-литерал не аллоцируется повторно
label.SetText("Очки: {0}", score);
hpLabel.SetText("{0} / {1}", hp, maxHp);
speedLabel.SetText("Скорость: {0:1} м/с", speed); // один знак после точки
Формат здесь не тот, что у string.Format. После двоеточия TMP понимает число знаков после десятичной точки ({0:2}) и шаблоны с нулями вроде {1:00} или {2:000.0} для ведущих нулей. Сложные спецификаторы N, P, C и пользовательские форматы не поддерживаются — для них нужен другой путь.
Есть и ограничение типов: аргументы приводятся к float. Для целых до 16 777 216 это безопасно, а большие значения (очки за миллиард, деньги в копейках) начнут терять младшие разряды. Для таких чисел пишите цифры сами.
Про аллокации честно: на форуме Unity встречаются замеры, где SetText с форматом в редакторе показывает GC Alloc, а в билде — ноль. Причина в редакторных проверках вокруг компонента. Поэтому проверять нужно в Development Build, а не в Play Mode.
Свой форматтер в char[]
Когда возможностей SetText не хватает, остаётся самый низкий уровень: собрать символы в собственный массив и отдать его через SetCharArray. Метод принимает массив, начальный индекс и длину, и не требует строки вообще.
using TMPro;
public sealed class CharBuffer
{
readonly char[] buffer;
int length;
public CharBuffer(int capacity) => buffer = new char[capacity];
public CharBuffer Clear() { length = 0; return this; }
public CharBuffer Append(string s)
{
for (int i = 0; i < s.Length && length < buffer.Length; i++)
buffer[length++] = s[i];
return this;
}
public CharBuffer Append(char c)
{
if (length < buffer.Length) buffer[length++] = c;
return this;
}
public CharBuffer Append(long value, int minDigits = 1)
{
if (value < 0)
{
Append('-');
value = -value; // long.MinValue здесь не поддерживаем
}
// Считаем количество цифр
int digits = 1;
for (long v = value; v >= 10; v /= 10) digits++;
if (digits < minDigits) digits = minDigits;
if (length + digits > buffer.Length) return this;
// Пишем цифры с конца
int end = length + digits;
for (int i = end - 1; i >= length; i--)
{
buffer[i] = (char)('0' + value % 10);
value /= 10;
}
length = end;
return this;
}
public void ApplyTo(TMP_Text label) => label.SetCharArray(buffer, 0, length);
}
Использование для таймера в формате мм:сс и счёта с разделителем тысяч:
readonly CharBuffer timerText = new CharBuffer(16);
void ShowTimer(int totalSeconds)
{
timerText.Clear()
.Append(totalSeconds / 60, 2)
.Append(':')
.Append(totalSeconds % 60, 2)
.ApplyTo(timerLabel);
}
Разделитель тысяч добавляется тем же способом: при записи цифр с конца вставляйте пробел после каждой третьей цифры. Такой форматтер — 60 строк кода, которые покрывают 95% игрового UI: целые числа, ведущие нули, время, «x / y». Дробные числа можно выводить как целую часть, точку и целую часть от frac * 10^n — для HUD этого достаточно.
Приём 5. ZString от Cysharp
Если писать свой форматтер не хочется, есть готовая библиотека ZString от Cysharp (авторы UniTask). Это StringBuilder на структурах с пулом буферов, который умеет форматировать числа без боксинга и без промежуточных строк. API повторяет привычные методы:
using Cysharp.Text;
// Аналог x + y + z, но без боксинга чисел
string a = ZString.Concat("HP: ", hp, "/", maxHp);
// Аналог string.Format с теми же спецификаторами
string b = ZString.Format("x:{0}, y:{1:000}, z:{2:P}", x, y, z);
// Билдер обязательно в using — он возвращает буфер в пул
using (var sb = ZString.CreateStringBuilder())
{
sb.Append("Уровень ");
sb.Append(level);
sb.Append(", опыт ");
sb.Append(xp);
label.SetText(sb); // расширение ZString для TextMeshPro
}
Для TextMeshPro библиотека добавляет методы-расширения SetText(builder) и SetTextFormat(...), которые пишут результат прямо в компонент, вообще без строки:
label.SetTextFormat("Позиция: {0:F1}, {1:F1}, {2:F1}", pos.x, pos.y, pos.z);
В отличие от встроенного SetText, здесь работают обычные .NET-спецификаторы формата и нет приведения к float.
Установка
- Через Package Manager по git URL:
https://github.com/Cysharp/ZString.git?path=src/ZString.Unity/Assets/Scripts/ZString, либо через OpenUPM (com.cysharp.zstring), либо unitypackage со страницы релизов. Минимальная версия Unity — 2021.3. - Библиотеке нужна сборка
System.Runtime.CompilerServices.Unsafe. В unitypackage она уже есть, при установке по git URL её нужно добавить самостоятельно (взять из unitypackage или из NuGet). - Расширения для TextMeshPro включаются автоматически, если TMP установлен как пакет. В Unity 6 TextMeshPro входит в пакет uGUI, и если методов
SetTextFormatне видно, добавьте в Scripting Define Symbols символZSTRING_TEXTMESHPRO_SUPPORT.
Главное правило ZString: билдер из CreateStringBuilder() — это структура с арендованным буфером, её обязательно освобождать через using. Забытый Dispose не приведёт к ошибке, но буфер не вернётся в пул.
Скрытые строковые аллокации, о которых забывают
Помимо UI, строки создают мусор во многих местах, где этого не ожидаешь.
gameObject.name и tag
Свойства name и tag каждый раз копируют строку из нативной части движка в управляемую. Код if (other.tag == "Enemy") в OnTriggerStay аллоцирует на каждый вызов. Используйте CompareTag, который сравнивает без создания строки:
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Enemy"))
TakeHit();
}
Имена объектов не используйте как идентификаторы в горячем коде. Если нужно различать объекты, храните ссылку, компонент-маркер или числовой id.
Строковые параметры аниматора и шейдеров
Вызовы animator.SetFloat("Speed", v) и material.SetColor("_BaseColor", c) каждый раз хэшируют строку. Аллокации тут нет, но это лишняя работа. Посчитайте идентификаторы один раз:
static readonly int SpeedId = Animator.StringToHash("Speed");
static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
void Update()
{
animator.SetFloat(SpeedId, velocity.magnitude);
}
Заодно это защищает от опечаток: имя параметра записано в одном месте.
Debug.Log в релизе
Строка в Debug.Log("Hit " + target.name + " for " + damage) собирается до вызова метода, даже если логирование выключено через Debug.unityLogger.logEnabled = false. В релизной сборке такой лог в часто вызываемом коде продолжает производить мусор. Решение — обёртка с атрибутом [Conditional]: компилятор удаляет и сам вызов, и вычисление аргументов, если символ не определён.
using System.Diagnostics;
public static class Log
{
[Conditional("UNITY_EDITOR"), Conditional("DEVELOPMENT_BUILD")]
public static void Info(string message) => UnityEngine.Debug.Log(message);
}
// В релизе эта строка исчезнет целиком вместе с конкатенацией
Log.Info("Hit " + target.name + " for " + damage);
Культура и запятая вместо точки
Это не про память, но регулярно всплывает рядом. float.ToString() и float.Parse() используют текущую культуру системы. На компьютере с русской локалью 1.5f.ToString() вернёт "1,5", а float.Parse("1.5") бросит исключение. Для сохранений, сетевых сообщений и любых данных, которые читает программа, а не человек, всегда указывайте CultureInfo.InvariantCulture:
using System.Globalization;
string data = speed.ToString(CultureInfo.InvariantCulture);
float parsed = float.Parse(data, CultureInfo.InvariantCulture);
Сравнение строк
string.Equals(a, b) и == сравнивают посимвольно и не аллоцируют. А вот a.ToLower() == b.ToLower() создаёт две новые строки. Для сравнения без учёта регистра используйте string.Equals(a, b, StringComparison.OrdinalIgnoreCase). Порядковое сравнение (Ordinal) к тому же быстрее культурного, а для ключей, идентификаторов и команд консоли оно и есть правильное.
Split, Substring и разбор текста
Разбор строк через Split создаёт массив и по строке на каждую часть. Если вы парсите что-то каждый кадр (команды, сетевые сообщения), работайте со ReadOnlySpan<char>: срезы AsSpan(start, length) не копируют данные. В Unity 2021.2 и новее доступны перегрузки int.Parse(ReadOnlySpan<char>) и float.Parse(ReadOnlySpan<char>, ...), поэтому число можно разобрать прямо из среза без Substring:
using System;
// "move 12 -4"
ReadOnlySpan<char> line = input.AsSpan();
int first = line.IndexOf(' ');
int second = line.Slice(first + 1).IndexOf(' ') + first + 1;
int x = int.Parse(line.Slice(first + 1, second - first - 1));
int y = int.Parse(line.Slice(second + 1));
Какой приём выбрать
Не нужно переписывать весь UI на char[]. Подход зависит от того, как часто меняется текст.
- Меняется редко (имя игрока, название уровня, очки в казуальной игре) — обычная строка плюс проверка изменения. Аллокация раз в несколько секунд никому не мешает.
- Небольшой известный набор значений (минуты и секунды, номера волн, проценты от 0 до 100) — кэш строк.
- Числа меняются каждый кадр (FPS, скорость, таймер с миллисекундами, координаты) —
TMP_Text.SetTextс шаблоном, если хватает его форматирования, иначе свой форматтер вchar[]или ZString. - Сложные строки собираются часто (лог боя, всплывающие подсказки, чат) — ZString или переиспользуемый StringBuilder с
SetText(sb). - Логирование — обёртка с
[Conditional], чтобы релиз не платил за отладочные строки.
Чек-лист перед релизом
- Откройте Profiler в Development Build, отсортируйте по GC Alloc и найдите
String.Concat,String.Format,ToStringв кадрах без событий. - Проверьте, что текст в
Updateпересобирается только при изменении значения. - Замените
tag ==наCompareTag, строковые имена параметров аниматора и шейдеров — на кэшированные id. - Уберите
sb.ToString()там, где результат сразу уходит в TextMeshPro. - Числа, которые меняются каждый кадр, выводите через
SetTextс шаблоном,SetCharArrayили ZString. - Оберните частые логи в метод с
[Conditional]. - Для данных, а не для текста на экране, используйте
CultureInfo.InvariantCulture.
Строковый мусор редко бывает единственной причиной фризов, но это одна из самых частых и самых дешёвых в исправлении. Начните с проверки изменения значения — она убирает большую часть аллокаций одной строкой кода, — а остальные приёмы применяйте там, где профайлер покажет, что их не хватило.