Введение


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

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

Рассмотрим простой пример, с которым я думаю многие сталкивались при разработке игр. У нас есть некий персонаж, который должен выполнить серию действий: перейти в точку A, взять предмет, переместиться в точку Б, положить предмет. Как видно это обычная последовательность. Реализовать в коде ее можно по-разному, как самый примитивный вариант в одном Update с проверкой условий. Однако, все усложняется, если у нас много таких персонажей и действий у них тоже достаточно много. Хотелось бы, чтобы наш код умел сказать такому персонажу, соверши ряд действий последовательно и сообщи, когда закончишь, а я пока займусь другими вещами. В этом случае как раз и будет полезен асинхронный подход. На данный момент существует много различных систем (в то числе и для Unity), которые позволяют это делать, например, UniRx (реактивное асинхронное программирование). Но все подобные вещи для начинающих разработчиков достаточно сложны в понимании и освоении, поэтому попробуем воспользоваться тем, что предоставляет нам сам движок, а именно Coroutine.

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

Реализация


Перед тем как писать код и вдаваться в глубины C# остановимся на архитектуре и терминологии.

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

  • Ее можно запустить
  • Можно подписаться на событие завершения ее выполнения
  • Ее можно принудительно остановить

Опишем эти правила через интерфейс.
public interface ITask
{
       void Start();
       ITask Subscribe(Action completeCallback); 
       void Stop();
}


Почему Subscribe возвращает ITask? Просто это повышает удобство из-за возможности создания конструкции вида:

ITask myTask;
myTask.Subscribe(() => Debug.Log(“Task Complete”)).Start();

Интерфейс для задачи создан, однако в нем не хватает одной важной вещи – это приоритет выполнения. Для чего он нужен? Представим ситуацию, когда мы выставили задачи персонажу и по логике возникает ситуация, что он должен все свои задачи остановить и выполнить другую – важную для игрового процесса. В этом случае, нам надо полностью остановить текущую цепочку и выполнить новую задачу. Описанный пример лишь один из нескольких вариантов поведения, помимо этого приоритеты могут быть следующими:

  • Обычный приоритет, каждая новая задача помещается в конец очереди
  • Высший приоритет, новая задача помещается в начало очереди
  • Приоритет с принудительной остановкой текущих задач

С учетом приоритетов интерфейс задачи примет конечный вид.
public enum TaskPriorityEnum
{
        Default,
        High,
        Interrupt
}
public interface ITask
{
       TaskPriorityEnum Priority { get; }

       void Start();
       ITask Subscribe(Action feedback); 
       void Stop();
}


Итак, мы определились с общим пониманием, что такое задача, теперь нам необходима конкретная реализация. Как было описано выше, в данной системе будут использоваться Coroutine. Coroutine, в простом понимании, это сопрограмма (если переводить дословно), которая выполняется в основном поток, но без его блокирования. За счет использования итераторов (IEnumerator) возврат в эту сопрограмму происходит на каждом кадре, если внутри нее произошел вызов yield return.

Реализуем класс Task, который будет имплементировать интерфейс ITask
public class Task : ITask
{
    public TaskPriorityEnum Priority
    {
        get
        {
            return _taskPriority;
        }
    }

    private TaskPriorityEnum _taskPriority = TaskPriorityEnum.Default;

    private Action _feedback;
    private MonoBehaviour _coroutineHost;
    private Coroutine _coroutine;
    private IEnumerator _taskAction;

    public static Task Create(IEnumerator taskAction, TaskPriorityEnum priority = TaskPriorityEnum.Default)
    {
        return new Task(taskAction, priority);
    }

    public Task(IEnumerator taskAction, TaskPriorityEnum priority = TaskPriorityEnum.Default)
    {
        _coroutineHost = TaskManager.CoroutineHost;
        _taskPriority = priority;
        _taskAction = taskAction;
    }

    public void Start()
    {
        if (_coroutine == null)
        {
            _coroutine = _coroutineHost.StartCoroutine(RunTask());
        }
    }

    public void Stop()
    {
        if (_coroutine != null)
        {
            _coroutineHost.StopCoroutine(_coroutine);
            _coroutine = null;
        }
    }

    public ITask Subscribe(Action feedback)
    {
        _feedback += feedback;

        return this;
    }


    private IEnumerator RunTask()
    {
        yield return _taskAction;

        CallSubscribe();
    }

    private void CallSubscribe()
    {
        if (_feedback != null)
        {
            _feedback();
        }
    }
}


Немного пояснений по коду:

  • Статический метод Create необходим для удобства записи вида:
    Task.Create(..).Subscribe(..).Start()
  • _coroutineHost это ссылка на экземпляр любого MonoBehaviour объекта от лица которого будет запускаться задача (она же Coroutine). Передать ссылку можно, например, через статическую переменную
  • В методе Subscribe подписчики добавляются через +=, поскольку их может быть несколько (и это нам понадобиться позже)

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

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

Реализация класса менеджера задач.
public class TaskManager
{       
    public ITask CurrentTask
    {
        get
        {
            return _currentTask;
        }
    }

    private ITask _currentTask;
    private List<ITask> _tasks = new List<ITask>();
	

    public void AddTask(IEnumerator taskAction, Action callback, TaskPriorityEnum taskPriority = TaskPriorityEnum.Default)
    {
        var task = Task.Create(taskAction, taskPriority).Subscribe(callback);

        ProcessingAddedTask(task, taskPriority);
    }

    public void Break()
    {
        if(_currentTask != null)
        {
            _currentTask.Stop();
        }
    }

    public void Restore()
    {
        TaskQueueProcessing();
    }

    public void Clear()
    {
        Break();

        _tasks.Clear();
    }

    private void ProcessingAddedTask(ITask task, TaskPriorityEnum taskPriority)
    {
        switch(taskPriority)
        {
            case TaskPriorityEnum.Default:
                {
                    _tasks.Add(task);
                }
                break;
            case TaskPriorityEnum.High:
                {
                    _tasks.Insert(0, task);
                }
                break;
            
                return;
            case TaskPriorityEnum.Interrupt:
                {
                	if (_currentTask != null && _currentTask.Priority != TaskPriorityEnum.Interrupt))
                    {
                    		_currentTask.Stop();
                    }

_currentTask = task;

task.Subscribe(TaskQueueProcessing).Start();
                }
                break;
        }

        if(_currentTask == null)
        {
            _currentTask = GetNextTask();

            if (_currentTask != null)
            {
                _currentTask.Subscribe(TaskQueueProcessing).Start();
            }
        }
    }

    private void TaskQueueProcessing()
    {
        _currentTask = GetNextTask(); 

        if(_currentTask != null)
        {
            _currentTask.Subscribe(TaskQueueProcessing).Start();
        }
    }

    private ITask GetNextTask()
    {
        if (_tasks.Count > 0)
        {
            var returnValue = _tasks[0]; _tasks.RemoveAt(0);

            return returnValue;
        } else
        {
            return null;
        }
    }
}


Разберем приведенный код:

  • Свойство CurrentTask – необходимо, чтобы отслеживать активность цепочки задач и иметь возможно подписаться на событие завершения текущей задачи любому кто имеет доступ к менеджеру
  • AddTask – основной метод класса, который создает и добавляет в очередь новую задачу согласно заданному приоритету. После добавления, если список задач пустой, она автоматически запускается
  • В момент запуска задачи (в методе ProcessingAddedTask), происходит дополнительная подписка самим менеджером задач на событие ее завершения (именно поэтому использовался += в классе Task). Когда задача завершается, менеджер забирает из очереди следующую и так до тех пор, пока все задачи в списке не будут выполнены

В остальном, как и в случае с классом Task, код очень примитивный, но это и было целью данной статьи.

Использование


Рассмотрим на простом примере, как и где можно использовать описанную выше систему.
public class TaskManagerTest : MonoBehaviour
{
    public Button StartTaskQueue;
    public Button StopTaskQueue;

    public Image TargetImage;
    public Transform From;
    public Transform To;

    private TaskManager _taskManager = new TaskManager();

    private void Start()
    {
        StartTaskQueue.onClick.AddListener(StartTaskQueueClick);
        StopTaskQueue.onClick.AddListener(StopTaskQueueClick);
    }

    private void StartTaskQueueClick()
    {
        _taskManager.AddTask(MoveFromTo(TargetImage.gameObject.transform, From.position, To.position, 2f));
        _taskManager.AddTask(AlphaFromTo(TargetImage, 1f, 0f, 0.5f));
        _taskManager.AddTask(Wait(1f));
        _taskManager.AddTask(AlphaFromTo(TargetImage, 0f, 1f, 0.5f));
        _taskManager.AddTask(MoveFromTo(TargetImage.gameObject.transform, To.position, From.position, 2f));        
    }

    private void StopTaskQueueClick()
    {
        if (_taskManager.CurrentTask != null)
        {
            _taskManager.Break();
        }else
        {
            _taskManager.Restore();
        }
    }

    private IEnumerator Wait(float time)
    {
        yield return new WaitForSeconds(time);
    }

    private IEnumerator MoveFromTo(Transform target, Vector3 from, Vector3 to, float time)
    {
        var t = 0f;
        do
        {
            t = Mathf.Clamp(t + Time.deltaTime, 0f, time);

            target.position = Vector3.Lerp(from, to, t / time);

            yield return null;
        } while (t < time);
    }

    private IEnumerator AlphaFromTo(Image target, float from, float to, float time)
    {
        var imageColor = target.color;
        var t = 0f;
        do
        {
            t = Mathf.Clamp(t + Time.deltaTime, 0f, time);

            imageColor.a = Mathf.Lerp(from, to, t / time);
            target.color = imageColor;

            yield return null;
        } while (t < time);
    }
}


Итак, что делает данный код. По нажатию на кнопку StartTaskQueue происходит запуск цепочки задач по оперированию объектом Image (TargetImage):

  • перемещает объект из позиции From в позицию To
  • скрывает объект через альфу
  • ждет одну секунду
  • показывает объект через альфу
  • перемещает объект из позици To в позицию From

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

Заключение


Несмотря на относительную простоту кода, данная подсистема позволяет решить множество задач при разработке, которые при решении в лоб могут вызвать определенные трудности. При использовании таких менеджеров и других подобных (более сложных) вы получаете гибкость и гарантию, что применяемые действия к объекту будут завершены в нужной последовательности и в случае, если этот процесс будет нужно прервать, это не вызовет “танцев с бубном”. В своих проектах я использую более сложный вариант описанной системы, которая позволяет работать и с Action и c YieldInstruction и с CustomYieldInstruction. Помимо прочего я использую больше вариантов приоритетов выполнения задач, а также режим запуска задачи вне менеджера и вне очередей с использованием Func(позволяет возвращать результат выполнения задачи). Реализация этих вещей не представляет собой сложности, и вы сами можете легко понять, как это сделать, используя код представленный выше.

Комментарии (17)


  1. 2morrowMan
    30.03.2018 10:20

    Поверхностно пробежал по коду.

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

    Pull Request Rejected :D


    1. Ichimitsu Автор
      30.03.2018 10:49

      Не будет вызова одновременно всех методов) при добавлении задач, стартует только первая, последующие стартуют, когда завершиться предыдущая. Либо я вас не понял. А насчет выключения, ну это же просто менеджер, поэтому для этих целей заводишь отдельный MonoBehaviour, который только для Coroutine (его можно даже создать в TaskManager, чтобы в сцене случайно никто не убил).


      1. 2morrowMan
        30.03.2018 11:10

        Например:

        _taskManager.AddTask(MoveFromTo(TargetImage.gameObject.transform, From.position, To.position, 2f));
        _taskManager.AddTask(AlphaFromTo(TargetImage, 1f, 0f, 0.5f));

        Не учитывая геттеров в параметрах, сначала вызывается MoveFromTo до первого «yield return ...» и возвращает IEnumerator, с которым вызывается и выполняется AddTask.
        Сразу после этого вызывается AlphaFromTo, потом снова AddTask.
        И уж где-то потом работают корутины.


        1. Ichimitsu Автор
          30.03.2018 11:28

          Вы по опыту говорите или теоретически. Вот я сейчас запустил и проверил, вызывается все так как надо, не запускаются задачи, если не вызвано StartCorouine (даже до первого yield return)


          1. 2morrowMan
            30.03.2018 12:06
            +1

            Теоретически, после беглого просмотра, как написал выше.
            Да, вы правы, если при добавлении таска корутины не стартуют, то методы не выполнятся. Не было времени углубится где там старт вызывается )
            Ответ получил, вас это не смущает и ситуация разрулена правильно :)


            1. Ichimitsu Автор
              30.03.2018 12:10

              Все ок и спасибо, я на самом деле когда писал, на автомате, вы сомнения зародили резонные и пришлось проверить)


    1. fstleo
      30.03.2018 11:12

      Дисейбл компонента не влияет на выполнение корутин


      1. 2morrowMan
        30.03.2018 12:10

        ок, дисейбл объекта с компонентом :)


  1. mmortall
    30.03.2018 15:37

    Хорошая статья, спасибо.
    Я похожее делаю с использованием UniRx.
    github.com/neuecc/UniRx

    Но ваша обертка мне нравится больше. Она проще и понятнее.


    1. Ichimitsu Автор
      30.03.2018 18:09

      Спасибо. UniRx это настоящая многопоточность, там не будет эффекта фризов основного потока Unity, которые возникают при наличии в Coroutine сильно прожорливых задач, но при этом всплывут другие неудобства. В общем инструмент под задачи)


  1. Goldseeker
    30.03.2018 20:34

    Честно говоря, не очень.
    Кроме общей неэффективности кода(каждый Таск — это две отдельные корутины, серьезно?), есть чисто архитектурные пробелы:

    • Таски не могут сообщить об ошибке
    • Таски не могут возвращать результат
    • Если бы Таски умели бы в вышеперечисленное, то неплохо было бы, чтобы внутри Таска можно было бы создавать другие таски дожидаться их выполнения и реагировать на результат/ошибку
    • Система приоритетов здесь вообще не к месту.


    В общем и целом, следовало посмотреть на Task с async/await в современном c# и повторить их функционал на энумераторах — это вполне возможно, просто будет чуть менее аккуратно, чем с Task, так как он всё-таки имеет поддержку от компилятора.


    1. Ichimitsu Автор
      30.03.2018 21:12

      async/await нет в Unity3d пока что, как минимум их нет для мобильных платформ. Две отдельные Coroutine только для IEnumerator, пишите CustomYieldInstruction обертку и будет одна. А в остальном, ну как бы да, но потому и называется простой менеджера задач, а не UniRx или что-то подобное.


      1. Goldseeker
        30.03.2018 21:19

        async/await нет (точнее есть в 2017 в статусе experimental, а в 2018.1b12 .Net 4.6 уже дефолтный), но можно повторить его функционал на итераторах, немного криво-косо, но сносно (особенно если не добавлять возможность таскам возвращать значение)

        Корутиной же можно обойтись одной на весь TaskManager.

        К сожалению, в текущем виде статья — сборник вредных советов.


        1. Ichimitsu Автор
          30.03.2018 21:35

          Ну напишите здесь пример, как можно это улучшить) а то как-то голословно все


          1. Goldseeker
            30.03.2018 21:57

            К сожалению, я не могу приводить код по этой теме, потому что она слишком пересекается с тем, что я делаю для своего работодателя. Основная идея в том, чтобы перечислять IEnumerator/IEnumerable вручную и не создавать на каждый чих корутину. Обработку ошибок можно реализовать добавив в ITask свойство exception и заполнять его, если очередной MoveNext() выкинул исключение.

            Так же стоит обратить внимание, что в Unity до сих пор используется очень плохой GC, так что следует быть осторожным с использованием функций-итераторов, так как они аллоцируют при вызове.


            1. Ichimitsu Автор
              30.03.2018 22:07

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


    1. Ichimitsu Автор
      30.03.2018 22:19

      Про 2 Coroutine спасибо (поправил в тексте). Излишки когда режешь код свой для статьи.