[Из песочницы] Как сделать корутины в Unity немного удобнее

habr.png

Каждый разработчик находит свои преимущества и недостатки использования корутин в Unity. И сам решает в каких сценариях их применить, а в каких отдать предпочтение альтернативам.

В повседневной практике я достаточно часто использую корутины для различных видов задач. Однажды, я понял, что именно меня в них раздражает и отторгает многих новичков.


Кошмарный интерфейс

Движок предоставляет всего пару методов и несколько их перегрузок для работы с корутинами:

Запуск (docs)


  • StartCoroutine(string name, object value = null)
  • StartCoroutine(IEnumerator routine)

Остановка (docs)


  • StopCoroutine(string methodName)
  • StopCoroutine(IEnumerator routine)
  • StopCoroutine(Coroutine routine)

Перегрузки со строковыми параметрами (не смотря на их обманчивое удобство) можно сразу отправить на помойку забыть как минимум по трем причинам.


  • Явное использование строковых имен методов усложнит будущий анализ кода, дебаг и прочее.
  • Согласно документации, строковые перегрузки выполняются дольше и позволяют передать только один дополнительный параметр.
  • На моей практике, достаточно часто получалось так, что при вызове строковой перегрузки StopCoroutine не происходило ровным счетом ничего. Корутина продолжала выполняться.

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


Ближе к сути

В этой статье я хочу описать небольшую обертку, которую я применяю уже давно. Благодаря ей, с мыслями о корутинах у меня в голове больше не возникают фрагменты шаблонного кода, с которым приходилось вокруг них плясать. Кроме этого, всей команде стало проще читать и понимать компоненты, где используются корутины.

Допустим, перед нами стоит следующая задача — написать компонент, который позволяет перемещать объект к заданной точке.

В данным момент не играет роли, каким именно методом будет выполняться передвижение и в каких координатах. Мной будет выбран лишь один из множества вариантов — это интерполяция и глобальные координаты.

Обратите внимание, крайне не рекомендуется перемещать объект путем изменения его координат «в лоб», то есть конструкцией transform.position = newPosition, если с ним используется компонент RigidBody (тем более в методе Update)(docs).


Стандартная реализация

Предлагаю следующий вариант реализации необходимого компонента:

using IEnumerator = System.Collections.IEnumerator;
using UnityEngine;

public sealed class MoveToPoint : MonoBehaviour
{
    public Vector3 target;

    [Space]

    public float speed;
    public float threshold;

    public void Move()
    {
        if (moveRoutine == null)
            StartCoroutine(MoveRoutine());
    }

    private IEnumerator MoveRoutine()
    {
        while (Vector3.Distance(transform.position, target) > threshold)
        {
            transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime);

            yield return null;
        }
    }
}


Немного о коде

В методе Move очень важно запускать корутину только в тому случае, когда она еще еще не запущена. Иначе их можно будет запустить сколько угодно и каждая из них будет перемещать объект.

threshold — допуск. Другими словами, расстояние к точке, приблизившись на которое мы будем считать, что достигли цели.

Для чего это нужно

Учитывая, что все компоненты (x, y, z) структуры Vector3 имеют тип float, использовать результат проверки на равенство расстояния к цели и допуска в качестве условия цикла — плохая затея.

Мы проверяем расстояние к цели на больше/меньше, что позволяет нам избежать данной проблемы.

Также, при желании можно применить метод Mathf.Approximately(docs) для примерной проверки на равенство. Стоит заметить, что при некоторых способах перемещения скорость может оказаться достаточно большой для того, чтобы объект за один кадр «перепрыгнул» цель. Тогда цикл никогда не завершится. Например, если вы используете метод Vector3.MoveTowards.

На сколько мне известно, в движке Unity для структуры Vector3 оператор == уже переопределен таким образом, что для покомпонентной проверки на равенство вызывается Mathf.Approximately.

Пока что это всё, наш компонент достаточно прост. И в текущий момент никаких проблем нет. Но, что это за компонент, позволяющий двигать объект к точке, но не предоставляющий возможность его остановить. Давайте исправим эту несправедливость.

Так как мы с вами решили не переходить на сторону зла, и не использовать перегрузки со строковыми параметрами, теперь нам нужно сохранить где-то ссылку на запущенную корутину. Иначе как потом остановить её?

Добавим поле:

private Coroutine moveRoutine;

Подправим Move:

public void Move()
{
    if (moveRoutine == null)
        moveRoutine = StartCoroutine(MoveRoutine());
}

Добавим метод остановки движения:

public void Stop()
{
    if (moveRoutine != null)
        StopCoroutine(moveRoutine);
}


Весь код целиком
using IEnumerator = System.Collections.IEnumerator;
using UnityEngine;

public sealed class MoveToPoint : MonoBehaviour
{
    public Vector3 target;

    [Space]

    public float speed;
    public float threshold;

    private Coroutine moveRoutine;

    public void Move()
    {
        if (moveRoutine == null)
            moveRoutine = StartCoroutine(MoveRoutine());
    }

public void Stop()
    {
        if (moveRoutine != null)
            StopCoroutine(moveRoutine);
    }

    private IEnumerator MoveRoutine()
    {
        while (Vector3.Distance(transform.position, target) > threshold)
        {
            transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime);

            yield return null;
        }
    }
}

Совсем другое дело! Хоть к ране прикладывай.

Итак. Мы имеем небольшой компонент, который выполняет поставленную задачу. В чем же моё негодование?


Проблемы и их решение

Со временем, проект растёт, а вместе с ним и количество компонентов, в том числе использующих корутины. И с каждым разом мне всё больше не дают покоя вот какие вещи:


  • Постоянные «бутербродные» вызовы
StartCoroutine(MoveRoutine());
StopCoroutine(moveRoutine);

Один их вид заставляет мой глаз дергаться, да и читать такой код сомнительное удовольствие (согласен, бывает и хуже). Но ведь куда приятнее и нагляднее было бы иметь что-нибудь в таком роде:

moveRoutine.Start();
moveRoutine.Stop();


  • При каждом вызове StartCoroutine нужно не забыть сохранить возвращаемое значение:
moveRoutine = StartCoroutine(MoveRoutine());

Иначе, по причине отсутствия ссылки на корутину, вы попросту не сможете ее остановить.


  • Постоянные проверки:
if (moveRoutine == null)
if (moveRoutine != null)


  • И еще одна злая вещь, о которой нужно всегда помнить (и которую я снова забыл специально упустил). В самом конце корутины и пред каждым выходом из нее (например, при помощи yield break) необходимо обнулить значение поля.
    moveRoutine = null;
    

Если забудете, то получите одноразовую корутину. После первого запуска в moveRoutine так и останется ссылка на корутину, и новую запустить уже не выйдет.

Точно так же нужно сделать и в случае принудительной остановки:

public void Stop()
{
    if (moveRoutine != null)
    {
        StopCoroutine(moveRoutine);
        moveRoutine = null;
    }
}


Код с учетом всех правок
public sealed class MoveToPoint : MonoBehaviour
{
    public Vector3 target;

    [Space]

    public float speed;
    public float threshold;

    private Coroutine moveRoutine;

    public void Move()
    {
        moveRoutine = StartCoroutine(MoveRoutine());
    }

    public void Stop()
    {
        if (moveRoutine != null)
        {
            StopCoroutine(moveRoutine);
            moveRoutine = null;
        }
    }

    private IEnumerator MoveRoutine()
    {
        while (Vector3.Distance(transform.position, target) > threshold)
        {
            transform.localPosition = Vector3.Lerp(transform.position, target, speed * Time.deltaTime);

            yield return null;
        }

        moveRoutine = null;
    }
}

В один прекрасный момент, очень хочется единожды куда-то вынести вот этот весь маскарад, а для себя оставить только нужные методы: Start, Stop и еще пару событий и свойств.

Давайте же наконец-то это сделаем!

using System.Collections;
using System;

using UnityEngine;

public sealed class CoroutineObject
{
    public MonoBehaviour Owner { get; private set; }
    public Coroutine Coroutine { get; private set; }
    public Func Routine { get; private set; }

    public bool IsProcessing => Coroutine != null;

    public CoroutineObject(MonoBehaviour owner, Func routine)
    {
        Owner = owner;
        Routine = routine;
    }

    private IEnumerator Process()
    {
        yield return Routine.Invoke();
        Coroutine = null;
    }

    public void Start()
    {
        Stop();

        Coroutine = Owner.StartCoroutine(Process());
    }

    public void Stop()
    {
        if (IsProcessing)
        {
            Owner.StopCoroutine(Coroutine);
            Coroutine = null;
        }
    }
}


Разбор полетов

Owner — ссылка на экземпляр MonoBehaviour, к которому будет привязана корутина. Как известно, она должна выполняться в контексте конкретного компонента, так как именно ему принадлежат методы StartCoroutine и StopCoroutine. Соответственно, нам нужна ссылка на компонент, который будет владельцем корутины.

Coroutine — аналог поля moveRoutine в компоненте MoveToPoint, содержит ссылку на текущую корутину.
Routine — делегат, с которым будет сообщен метод, выполняющий роль корутины.

Process() — небольшая обертка над основным методом Routine. Нужен для того, чтобы иметь возможность проследить, когда же завершится выполнение корутины, сбросить ссылку на неё и выполнить в этот момент другой код (если понадобится).

IsProcessing — позволяет узнать, выполняется ли корутина в текущий момент.

Таким образом мы избавляемся от большого количества головной боли, а наш компонент приобретает совсем другой вид:

using IEnumerator = System.Collections;
using UnityEngine;

public sealed class MoveToPoint : MonoBehaviour
{
    public Vector3 target;

    [Space]

    public float speed;
    public float threshold;

    private CoroutineObject moveRoutine;

    private void Awake()
    {
        moveRoutine = new CoroutineObject(this, MoveRoutine);
    }

    public void Move() => moveRoutine.Start();

    public void Stop() => moveRoutine.Stop();

    private IEnumerator MoveRoutine()
    {
        while (Vector3.Distance(transform.position, target) > threshold)
        {
            transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime);

            yield return null;
        }
    }
}

Осталась лишь сама корутина и несколько строчек кода для работы с ней. Значительно лучше.

Допустим, пришла новая задача — нужно добавить возможность выполнить любой код после того, как объект достиг цели.

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

private IEnumerator MoveRoutine(System.Action callback)
    {
        while (Vector3.Distance(transform.position, target) > threshold)
        {
            transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime);

            yield return null;
        }

        moveRoutine = null

        callback?.Invoke();
    }

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

moveRoutine = StartCoroutine(moveRoutine(CallbackHandler));

private void CallbackHandler()
{
    // do something
}

А если еще в качестве обработчика будет какая-нибудь лямбда, то смотрится еще страшнее.

С нашей же оберткой достаточно лишь один раз добавить в нее это событие.

public Action Finish;
private IEnumerator Process()
{
    yield return Routine.Invoke();

    Coroutine = null;

    Finish?.Invoke();
}

А затем, при необходимости, подписаться.

moveRoutine.Finished += OnFinish;

private void OnFinish()
{
    // do something
}

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

Но, по хорошему, было бы неплохо сначала вынести код, который будет одинаковым для всех оберток, в какой-то базовый класс, чтобы не писать одно и то же. Мы ведь с этим и боремся.

В него уберем:


  • Свойства Owner, Coroutine, IsProcessing
  • Событие Finished
using Action = System.Action;

using UnityEngine;

public abstract class CoroutineObjectBase
{
    public MonoBehaviour Owner { get; protected set; }
    public Coroutine Coroutine { get; protected set; }

    public bool IsProcessing => Coroutine != null;

    public abstract event Action Finished;
}


Обёртка без параметров после рефакторинга
using System;
using System.Collections;

using UnityEngine;

public sealed class CoroutineObject : CoroutineObjectBase
{
    public Func Routine { get; private set; }

    public override event Action Finished;

    public CoroutineObject(MonoBehaviour owner, Func routine)
    {
        Owner = owner;
        Routine = routine;
    }

    private IEnumerator Process()
    {
        yield return Routine.Invoke();

        Coroutine = null;

        Finished?.Invoke();
    }

    public void Start()
    {
        Stop();

        Coroutine = Owner.StartCoroutine(Process());
    }

    public void Stop()
    {
        if(IsProcessing)
        {
            Owner.StopCoroutine(Coroutine);

            Coroutine = null;
        }
    }
}

И теперь, собственно говоря, обертка для корутин с одним параметром:

using System;
using System.Collections;

using UnityEngine;

public sealed class CoroutineObject : CoroutineObjectBase
{
    public Func Routine { get; private set; }

    public override event Action Finished;

    public CoroutineObject(MonoBehaviour owner, Func routine)
    {
        Owner = owner;
        Routine = routine;
    }

    private IEnumerator Process(T arg)
    {
        yield return Routine.Invoke(arg);

        Coroutine = null;

        Finished?.Invoke();
    }

    public void Start(T arg)
    {
        Stop();

        Coroutine = Owner.StartCoroutine(Process(arg));
    }

    public void Stop()
    {
        if(IsProcessing)
        {
            Owner.StopCoroutine(Coroutine);

            Coroutine = null;
        }
    }
}

Как видим, код практически такой же. Лишь в некоторых местах добавились фрагменты, зависящие от количества аргументов.

Допустим, нас попросили обновить компонент MoveToPoint таким образом, чтобы точку можно было задавать не через окно Inspector в редакторе, а кодом при вызове метода Move.

using IEnumerator = System.Collections.IEnumerator;
using UnityEngine;

public sealed class MoveToPoint : MonoBehaviour
{
    public float speed;
    public float threshold;

    private CoroutineObject moveRoutine;

    public bool IsMoving => moveRoutine.IsProcessing;

    private void Awake()
    {
        moveRoutine = new CoroutineObject(this, MoveRoutine);
    }

    public void Move(Vector3 target) => moveRoutine.Start(target);
    public void Stop() => moveRoutine.Stop();

    private IEnumerator MoveRoutine(Vector3 target)
    {
        while (Vector3.Distance(transform.position, target) > threshold)
        {
            transform.localPosition = Vector3.Lerp(transform.position, target, speed);

            yield return null;
        }
    }
}

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

Цель данной статьи — желание поделиться насущными проблемами, с которыми я столкнулся и предложить вариант их решения, а не покрыть возможные потребности всех разработчиков.

Надеюсь, как новички, так и опытные товарищи почерпнут пользу из моего опыта. Возможно, поделятся своим в комментариях или укажут на ошибки, которые я мог допустить.


© Habrahabr.ru