Pull to refresh

Unity: Что представляет из себя Coroutine и зачем там IEnumerator

Reading time5 min
Views21K

Название статьи - это вопрос, который мне задали на собеседовании на позицию Middle. В этой статье мы расмотри корутины в Unity, что они из себя представляют, и заодно захватим тему Enumerator\Enumerable в С# и небольшую тайну foreach. Статья должна быть очень полезной для начинающих и интересной для разработчиков с опытом.

И так, как всем известно, метод, представляющего из себя Coroutine в Unity, выглядит следующим образом:

IEnumerator Coroutine()
{
  yield return null;
}
Немного информации об корутинах в Unity и IEnumerator

В качестве возвращаемого объекта после yield return может быть:

  • new WaitForEndOfFrame() - останавливает выполнение до конца следующего кадра

  • new WaitForFixedUpdate() - останавливает выполнение до следующего кадра физического движка.

  • new WaitForSeconds(float x) - останавливает выполнение на x секунд игрового времени (оно может быть изменено через Time.timeScale)

  • new WaitForSecondsRealtime(float x) - останавливает выполнение на x секунд реального времени

  • new WaitUntil(Func<bool>) - останавливает выполнение до момента, когда Func не вернет true

  • new WaitWhile(Func<bool>) - обратное к WaitUntil, продолжает выполнение, когда Func возвращает false

  • null - то же, что и WaitForEndOfFrame(), но выполнение продолжается в начале след. кадра

  • break - завершает корутину

  • StartCoroutine() - выполнение останавливает до момента, когда новая начатая корутина не закончится.

Запускаются корутины через StartCoroutine(Coroutine()).

Корутины не являются асинхронными, они выполняются в основном потоке приложения, в том же, что и отрисовка кадров, инстансирование объектов и т.д., если заблокировать поток в корутине, то остановится все приложение, корутины с асинхронностью использовали бы "IAsyncEnumerator", который Unity не поддерживает. Корутина позволяет растянуть выполнение на несколько кадров, что бы не нагружать 1 кадр большими вычислениями. Unity предоставляет тип UnityWebRequest для Http запросов, которые можно выполнять "асинхронно" в несколько кадров, что может показаться "асинхронностью", на самом деле же это обертка над нативным асинхронным HttpClient, которая предоставляет некоторую информацию синхронно, по типу поля isDone, которое отображает - закончился ли запрос или еще ожидается ответ, но сам запрос идет асинхронно.

IEnumerator - это стандартная реализация паттерна "итератор" в C#, которая содержит синтаксический сахар для хранения состояния. (спасибо @SadOceanза дополнение)

Он возвращает IEnumerator и имеет необычный return с припиской yield.
yield return это составляющая IEnumerator, эта связка преобразуется, при компиляции, в машину состояний, которая сохраняет позицию в коде, ждет команды MoveNext от IEnumerator и продолжает выполнение до следующего yield return или конца метода, больше подробной информации можно найти на сайте майкрософта.

Интерфейс IEnumerator, в свою очередь, содержит следующие элементы:

public interface IEnumerator
{
  object Current { get; }

  bool MoveNext();
  void Reset();
}

Под капотом Unity это обрабатывается примерно так: Unity получает IEnumerator, который передается через StartCoroutine(IEnumerator), сразу вызывает MoveNext, для того, чтобы код дошел до первого yield return, здесь стоит уточнить, что при вызове такого метода выполнение кода внутри метода не начинается самостоятельно, и необходимо вызвать MoveNext, это можно проверить простым скриптом, который представлен под этим абзатцем, а затем если Unity получает в Current объект типа YieldInstruction, то выполняет инструкцию и снова вызывает MoveNext, то есть, метод может возвращать любой тип, и если это не YieldInstruction, то Unity его обработает как yield return null.

private IEnumerator _coroutine;

// Start is called before the first frame update
void Start()
{
  _coroutine = Coroutine();
}

// Update is called once per frame
void Update()
{
  if (Time.time > 5)
    _coroutine.MoveNext();
}

IEnumerator Coroutine()
{
  while (true)
  {
    Debug.Log(Time.time);
    yield return null;
  }
}
В логе видно, что впервые метод вызвался на 5-ой секунде, согласно условию в Update()
В логе видно, что впервые метод вызвался на 5-ой секунде, согласно условию в Update()

Отлично, мы разобрали основной момент, а именно, что такое IEnumerator и как он работает. Теперь разберем такой случай:

Опишем класс, который наследует интерфейс IEnumerator

class TestEnumerator : IEnumerator
{
    public object Current => new WaitForSeconds(1);
		
    public bool MoveNext()
    {
        Debug.Log(Time.time);
        return true;
    }

    public void Reset()
    {
    }
    
    /// Этот класс равносилен следующей корутине:
    /// IEnumerator Coroutine()
    /// {
    /// 	while(true){
    ///			Debug.Log(Time.time);
    ///			yield return new WaitForSeconds(1);
    /// 	}
    ///	}
}

И мы теперь его можем использовать следующим способом:

void Start()
{
  StartCoroutine(new TestEnumerator());
}
Выполняет так же, как и корутина-метод
Выполняет так же, как и корутина-метод

И так, мы рассмотрели IEnumerator и корутины, здесь можно еще долго рассматривать разные варианты использования, но в корне остается передача IEnumerator в каком либо виде в метод StartCoroutine.

Теперь предлагаю рассмотреть IEnumerable, этот интерфейс наследуют нативный массив C#, List из System.Generic и прочие подобные типы, вся его суть заключается в том, что он содержит метод GetEnumerator, который возвращает IEnumerator:

public interface IEnumerable
{
  [DispId(-4)]
  IEnumerator GetEnumerator();
}

Реализуем простенький пример:

class TestEnumerable : IEnumerable
{
    public IEnumerator GetEnumerator()
    {
        return new TestEnumerator();
    }
}

И теперь, мы можем сделать следующее:

IEnumerator Coroutine()
{
  foreach (var delay in new TestEnumerable())
  {
    Debug.Log($"{Time.time}");
    yield return delay;
  }
}
Можно видеть, что там дважды выводится время в лог, это из-за того, что у нас в TestEnumerator остался Debug в методе MoveNext.
Можно видеть, что там дважды выводится время в лог, это из-за того, что у нас в TestEnumerator остался Debug в методе MoveNext.

Применений для этого множество, например можно добавить в TestEnumerator случайное время задержки:

class TestEnumerator : IEnumerator
{
    public object Current => new WaitForSeconds(_currDelay);
    private float _currDelay;

    public bool MoveNext()
    {
        _currDelay = Random.Range(1.0f, 3.0f);
        return true;
    }

    public void Reset()
    {
    }
}
Время между логами не одинаковое
Время между логами не одинаковое

И немного магии для начинающих: foreach не требует чтобы объект возвращаемый GetEnumerator реализовывал IEnumerable, самое главное, что бы тип после "in" имел метод GetEnumerator(), и возвращал тип с свойством Current и методом MoveNext(), то есть, мы можем сделать так:

class TestEnumerator // Здесь было наследование от IEnumerator
{
    public object Current => new WaitForSeconds(_currDelay);
    private float _currDelay;

    public bool MoveNext()
    {
        _currDelay = Random.Range(1.0f, 3.0f);
        return true;
    }
  
  	// Здесь был Reset из IEnumerator, он теперь не нужен :)
}

class TestEnumerable // Здесь было наследование от IEnumerable
{
  	// Возвращаемый тип был IEnumerator
    public TestEnumerator GetEnumerator()
    {
        return new TestEnumerator();
    }
}

Как видно, нигде нет наследования и любого упоминания IEnumerable и IEnumerator, но при этом мы так же можем использовать следующий код:

IEnumerator Coroutine()
{
  foreach (var delay in new TestEnumerable())
  {
    Debug.Log($"{Time.time}");
    yield return delay;
  }
}
Все работает так же и без ошибок
Все работает так же и без ошибок

И так, разобрав корутины, IEnumerator, IEnumerable и foreach нужно бы увидеть пример использования этих знаний на практике:

Более удобные в использовании Coroutine

Здесь я хотел описать реализацию корутины с токеном отмены (CancelationToken) и событиями старта\завершения с возможность ставить выполнение на паузу, но я опоздал, и на github есть готовое решение, советую к изучение, хоть я и не полностью согласен с реализацией:

unity-task-manager/TaskManager.cs at master · AdamRamberg/unity-task-manager (github.com)

Буду благодарен критике и замечаниям, так же советую посмотреть другие мои статьи.

Only registered users can participate in poll. Log in, please.
Знали ли Вы, что foreach не требует реализации интерфейсов IEnumerator и IEnumerable?
51.16% Нет.44
48.84% Да, знал!42
86 users voted. 5 users abstained.
Tags:
Hubs:
Total votes 4: ↑3 and ↓1+3
Comments7

Articles