Работа с Корутинами в Unity


Корутины (Coroutines, сопрограммы) в Unity — простой и удобный способ запускать функции, которые должны работать параллельно в течение некоторого времени. В работе с корутинами ничего принципиально сложного нет и интернет полон статей с поверхностным описанием их работы. Тем не менее, мне так и не удалось найти ни одной статьи, где описывалась бы возможность запуска группы корутинов с продолжением работы после их завершения.
Хочу предложить вам небольшой паттерн, реализующий такую возможность, а также подбор информации о корутинах.



Корутины представляют собой простые C# итераторы, возвращающие IEnumerator и использующие ключевое слово yield. В Unity корутины регистрируются и выполняются до первого yield с помощью метода StartCoroutine. Дальше Unity опрашивает зарегистрированные корутины после каждого вызова Update и перед вызовом LateUpdate, определяя по возвращаемому в yield значению, когда нужно переходить к следующему блоку кода.

Существует несколько вариантов для возвращаемых в yield значений:

Продолжить после следующего FixedUpdate:
yield return new WaitForFixedUpdate();


Продолжить после следующего LateUpdate и рендеринга сцены:
yield return new WaitForEndOfFrame();


Продолжить через некоторое время:
yield return new WaitForSeconds(0.1f); // продолжить примерно через 100ms


Продолжить по завершению другого корутина:
yield return StartCoroutine(AnotherCoroutine());


Продолжить после загрузки удаленного ресурса:
yield return new WWW(someLink);


Все прочие возвращаемые значения указывают, что нужно продолжить после прохода текущей итерации цикла Update:
yield return null;


Выйти из корутина можно так:
yield return break;


При использовании WaitForSeconds создается долгосуществующий объект в памяти (управляемой куче), поэтому его использование в быстрых циклах может быть плохой идеей.

Я уже написал, что корутины работают параллельно, следует уточнить, что они работают не асинхронно, то есть выполняются в том же потоке.

Простой пример корутина:

void Start()
{
	StartCoroutine(TestCoroutine());
}

IEnumerator TestCoroutine()
{
	while(true)
	{
		yield return null;
		Debug.Log(Time.deltaTime);
	}
}


Этот код запускает корутин с циклом, который будет писать в консоль время, прошедшее с последнего фрейма.
Следует обратить внимание на то, что в корутине сначала вызывается yield return null, и только потом идет запись в лог. В нашем случае это имеет значение, потому что выполнение корутина начинается в момент вызова StartCoroutine(TestCoroutine()), а переход к следующему блоку кода после yield return null будет осуществлён после метода Update, так что и до и после первого yield return null Time.deltaTime будет указывать на одно и то же значение.

Также нужно заметить, что корутин с бесконечным циклом всё еще можно прервать, вызвав StopAllCoroutines(), StopCoroutine(«TestCoroutine»), или уничтожив родительский GameObject.

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

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

Класс, который нужно двигать:

public bool IsMoving = false;

IEnumerator MoveCoroutine(Vector3 moveTo)
{
	IsMoving = true;
	
	// делаем переход от текущей позиции к новой
	var iniPosition = transform.position;
	while (transform.position != moveTo)
	{
		// тут меняем текущую позицию с учетом скорости и прошедшего с последнего фрейма времени
		// и ждем следующего фрейма
		yield return null;
	}
	
	IsMoving = false;
}


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

IEnumetaror PerformMovingCoroutine()
{
	// делаем дела

	foreach(MovableObjectScript s in objectsToMove)
	{
		// определяем позицию
		StartCoroutine(s.MoveCoroutine(moveTo));
	}

	bool isMoving = true;
	while (isMoving) 
	{
		isMoving = false;
		Array.ForEach(objectsToMove, s => { if (s.IsMoving) isMoving = true; });
		if (isMoving) yield return null;
	}

	// делаем еще дела
}


Блок «делаем еще дела» начнет выполнятся после завершения корутина MoveCoroutine у каждого объекта в массиве objectsToMove.

Что ж, уже интересней.
А что, если мы хотим создать группу корутинов, с возможностью в любом месте и в любое время проверить, завершила ли группа работу?
Сделаем!

Для удобства сделаем всё в виде методов расширения:

public static class CoroutineExtension
{
	// для отслеживания используем словарь <название группы, количество работающих корутинов>
	static private readonly Dictionary<string, int> Runners = new Dictionary<string, int>();

	// MonoBehaviour нам нужен для запуска корутина в контексте вызывающего класса
	public static void ParallelCoroutinesGroup(this IEnumerator coroutine, MonoBehaviour parent, string groupName)
	{
		if (!Runners.ContainsKey(groupName))
			Runners.Add(groupName, 0);

		Runners[groupName]++;
		parent.StartCoroutine(DoParallel(coroutine, parent, groupName));
	}
	

	static IEnumerator DoParallel(IEnumerator coroutine, MonoBehaviour parent, string groupName)
	{
		yield return parent.StartCoroutine(coroutine);
		Runners[groupName]--;
	}
	
	// эту функцию используем, что бы узнать, есть ли в группе незавершенные корутины
	public static bool GroupProcessing(string groupName)
	{
		return (Runners.ContainsKey(groupName) && Runners[groupName] > 0);
	}
}


Теперь достаточно вызывать на корутинах метод ParallelCoroutinesGroup и ждать, пока метод CoroutineExtension.GroupProcessing возвращает true:

public class CoroutinesTest : MonoBehaviour
{

	// Use this for initialization
	void Start()
	{
		StartCoroutine(GlobalCoroutine());
	}

	IEnumerator GlobalCoroutine()
	{
		for (int i = 0; i < 5; i++)
			RegularCoroutine(i).ParallelCoroutinesGroup(this, "test");

		while (CoroutineExtension.GroupProcessing("test"))
			yield return null;

		Debug.Log("Group 1 finished");

		for (int i = 10; i < 15; i++)
			RegularCoroutine(i).ParallelCoroutinesGroup(this, "anotherTest");

		while (CoroutineExtension.GroupProcessing("anotherTest"))
			yield return null;

		Debug.Log("Group 2 finished");
	}

	IEnumerator RegularCoroutine(int id)
	{
		int iterationsCount = Random.Range(1, 5);

		for (int i = 1; i <= iterationsCount; i++)
		{
			yield return new WaitForSeconds(1);
		}

		Debug.Log(string.Format("{0}: Coroutine {1} finished", Time.realtimeSinceStartup, id));
	}
}



Готово!

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 13

    0
    Спасибо за статью. Как раз искал нечто подобное.
      +1
      Стоило бы рассказать в чем плюсы и минусы корутин в сравнении с тредами.
        +2
        Уместнее было бы сравнивать корутины с выполнением ваших задач в методе Update (тут всё просто, корутины проще читать, легко понимать и не обязательно выполнять в каждом фрейме). Юнити не без причины работает с вашим кодом в одном потоке, а Unity api не работает асинхронно. Конечно, вы можете использовать свои потоки для некоторых вычислений не связанных непосредственно с юнити, но это уже совсем другая история.
          0
          Я сам с этим столкнулся, и пока разбирался в теме, то видел один и тот же вопрос — люди не понимают, что корутины однопоточны. И что для асинхронности и повышения скорости нужны как раз треды, а не корутины. И у тредов свои минусы. Потому и предложил отдельно отметить, новичку это будет неочевидно.
            0
            А вы правы, по корутинам и потокам можно набрать приличную кучу текста с примерами и тестами, это отличная тема для отдельной статьи! *ушел собирать информацию по теме*
            0
            О, как я намаялся, выковыривая апи юнити из кода генерации ландшафта, когда до меня дошло, что волшебное StartCoroutine работает в том же потоке =)
              0
              Все-таки как-то запутанно все с корутинами и потоками. Я тот самый классический новичек. В статье я вижу:
              Корутины (Coroutines, сопрограммы) в Unity — простой и удобный способ запускать функции, которые должны работать параллельно в течение некоторого времени.

              В комментарии:
              Юнити не без причины работает с вашим кодом в одном потоке, а Unity api не работает асинхронно.

              Так где же истина?
                0
                Чуть ниже в статье я уточнил, что параллельно != асинхнонно. Мне тоже слово «параллельно» показалось довольно шатким в контексте С#, но в контексте Юнити, видимый процесс работы группы корутин вполне можно назвать параллельным.

                Если коротко, истина в том, что корутина к потокам отношения не имеет, вызов Unity api в ваших, вручную созданных потоках, невозможен, а свои потоки вы можете использовать для работы, не связанной напрямую с изменением состояния игровых объектов. Конечно, вы можете работать со статическими функциями Юнити в своих потоках (например, использовать класс Mathf для своих вычислений).
                Этой информации должно быть достаточно для работы с корутинами, а для тех, кому хочется копнуть поглубже, готовлю отдельную статью. Спасибо :)
                  0
                  Тогда с нетерпением ждемс ))
            +1
            Давайте только все таки «корутина», а не «корутин», это все же она.
              +1
              Забыли скобку закрыть и точку с запятой поставить в методе:
              IEnumetaror PerformMovingCoroutine() {
              // в Array.ForEach(…
              }
                0
                Спасибо, поправил :)
                0
                Вместо yield return break полагаю должен быть yield break.

                Only users with full accounts can post comments. Log in, please.