19.10.2026 14 мин чтения Просмотров:0

Строки без мусора в Unity: StringBuilder, SetText и ZString

Откуда берутся строковые GC-аллокации в UI Unity и пять приёмов, которые их убирают: проверка изменений, кэш строк, StringBuilder, запись чисел прямо в TextMeshPro и ZString.

Строки без мусора в Unity: StringBuilder, SetText и ZString

Счётчик очков, таймер, полоска здоровья с цифрами, отладочный оверлей с 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], чтобы релиз не платил за отладочные строки.

Чек-лист перед релизом

  1. Откройте Profiler в Development Build, отсортируйте по GC Alloc и найдите String.Concat, String.Format, ToString в кадрах без событий.
  2. Проверьте, что текст в Update пересобирается только при изменении значения.
  3. Замените tag == на CompareTag, строковые имена параметров аниматора и шейдеров — на кэшированные id.
  4. Уберите sb.ToString() там, где результат сразу уходит в TextMeshPro.
  5. Числа, которые меняются каждый кадр, выводите через SetText с шаблоном, SetCharArray или ZString.
  6. Оберните частые логи в метод с [Conditional].
  7. Для данных, а не для текста на экране, используйте CultureInfo.InvariantCulture.

Строковый мусор редко бывает единственной причиной фризов, но это одна из самых частых и самых дешёвых в исправлении. Начните с проверки изменения значения — она убирает большую часть аллокаций одной строкой кода, — а остальные приёмы применяйте там, где профайлер покажет, что их не хватило.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также