Приветствую! Сегодня я хотел бы поделиться своей наработкой, которую я создал около двух лет назад и использую в проектах и сегодня.

Оговорюсь, что все что описано ниже - придумано мной, так что идею я нигде не украл. Также в результате поисков в интернете ничего подобного не нашел. Если для вас это актуально - пользуйтесь на здоровье =)

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

А в силу того, что на дворе 2023 год, мы в след за js-ом сделаем этот промис awaitable. 

Я не буду останавливаться на работе async/await, по этому поводу и так достаточно много написано, не только на официальном сайте, но и много где в интернете. Обозначим только основные пункты.

Для обеспечения работы этого механизма от нас требуется в типе:

  • Реализовать INotifyCompletion 

  • Добавить IsCompleted свойство (bool)

  • Добавить метод GetResult (можно вернуть из него войд)

  • Реализовать метод GetAwaiter без параметров - возвращающий этот же тип (можно extension`ом) 

Также наш промис будет уметь принимать в себя параметры. 

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

Итак, начнём. Сначала опишем механизм приема параметров в самом компоненте. В нашем случае будет вот так:

public abstract class AwaitableBehaviour : MonoBehaviour
{
    private static object[] _parameters;
    
    private void Awake()
    {
        try
        {
            OnAwake(_parameters);
        }
        catch (Exception e)
        {
            Debug.LogError("Initialization failed due to " + e);
        }
    }
    protected virtual void OnAwake(params object[] parameters)
    {
    }
    
    public static TAwaitable Run<TAwaitable>( GameObject container, params object[] parameters) 
        where TAwaitable:AwaitableBehaviour
    {
        _parameters = parameters;

        return container.AddComponent<TAwaitable>();
    }
}

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

Следующим шагом реализуем INotifyCompletion и все описанное выше:

public abstract class AwaitableBehaviour : MonoBehaviour,INotifyCompletion
{
    private static object[] _parameters;

    private bool _isCompleted;
    
    private Action _continuation;
    public virtual bool IsCompleted
    {
        get => _isCompleted;
        set
        {
            _isCompleted = value;

            if (!_isCompleted) return;

            Destroy(this);
            
            _continuation.Invoke();
        }
    }
    public void GetResult() { }
    public virtual void OnCompleted(Action continuation)
    {
        _continuation = continuation;
    }
    public AwaitableBehaviour GetAwaiter()
    {
        return this;
    }
    
    private void Awake()
    {
        try
        {
            OnAwake(_parameters);
        }
        catch (Exception e)
        {
            Debug.LogError("Initialization failed due to " + e);
        }
    }
    protected virtual void OnAwake(params object[] parameters)
    {
    }
    
    public static TAwaitable Run<TAwaitable>( GameObject container, params object[] parameters) 
        where TAwaitable:AwaitableBehaviour
    {
        _parameters = parameters;

        return container.AddComponent<TAwaitable>();
    }
}

Очень простой код. Немного его разберем - внесем ясность. Когда мы вызовем команду Run (с помощью await), мы добавим компонент, который вернется в методе GetAwaiter. Далее будет опрошено свойство IsCompleted, а за ним вызовется INotifyCompletion метод OnCompleted. Он вызовется до выполнения основного кода, и его параметр - это точка дальнейшего выполнения программы - мы должны будем запустить его самостоятельно, поэтому сохраним его. После того, как мы выполним все что хотели, нужно уничтожить компонент и продолжить выполнение программы. Сделаем это в сеттере свойства IsCompleted. 

Злые языки скажут: "А как же вернуть результат?" И мы вернем им результат:

public abstract class AwaitableBehaviour<TResult>:AwaitableBehaviour
{
    public TResult Result { get; protected set; }

    public new TResult GetResult()
    {
        return Result;
    }
    
    public new AwaitableBehaviour<TResult> GetAwaiter()
    {
        return this;
    }
}

Все, теперь у нас есть все необходимое для запуска компонента через ключевое слово await!(И все это занимает около 35 строк кода, не считая пробелов между строками)

Напишем наш первый awaitable компонент. Для удобства и простоты примера создадим таймер который отсчитает для нас 5 секунд.

public class Example : AwaitableBehaviour
{
    public float Duration = 5f;

    private void Update()
    {
        Duration -= Time.deltaTime;
        
        if (Duration > 0)
        {
            return;
        }

        IsCompleted = true;
    }
}
public class ExampleWithResult : AwaitableBehaviour<float>
{
    public float Duration = 5f;

    protected override void OnAwake(params object[] parameters)
    {
        Duration = (float)parameters[0];
    }

    private void Update()
    {
        Duration -= Time.deltaTime;
        
        if (Duration > 0)
        {
            return;
        }
        
        Result = Duration;
        
        IsCompleted = true;
    }
}

Сразу отмечу, что можно не только писать awaitable таймеры, можно  показывать попапы, открывать сцены и т.д. В такой записи все, что может быть выражено компонентом, может быть выполнено в await стиле.


У нас есть архитип, есть его конкретные дочерние объекты, осталось только запустить их. Сделаем это:

public class Test : MonoBehaviour
{
    async void Start()
    {
        await AwaitableBehaviour.Run<Example>(gameObject);
        
        Debug.Log("1");
        
        await AwaitableBehaviour.Run<Example>(gameObject);
        
        Debug.Log("2");
        
        var result = await AwaitableBehaviour.Run<ExampleWithResult>(gameObject,5f);
        
        Debug.Log(result);
    }
}

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

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


  1. rukhi7
    27.01.2023 08:18
    +1

    только таймер у вас получился на 5 сек + лапоть!

    А вот попробуйте сделать таймер пля бесконечной последовательности событий с периодом 5 сек.

    Если использовать ваш таймер то скажем уже 10 событие будет назначено не на 5*10=50 сек от первого, а на 10 * 5 + 10 * (сумарное время ошибки определения периода таймера) = 50 + (десятикратный лапоть),

    Помогут ваши конструкции решить задачу?

    Дальше можно рассмотреть более сложную схему событий, например одна последовательность фреймов со спутника приходит раз в 450 милли-секунд, и есть фреймы которые приходят раз в 350 мс, надо симулировать сумарную последовательность.

    Решается такая задача?


    1. dAnglerais Автор
      27.01.2023 11:32

      Этот таймер написан в качестве примера использования. Он ведь даже не останавливается в 0 (там отрицательное значение будет на момент остановки). Честно говоря, я не понял описываемую вами задачу, но добавлять и убирать таймеры на компонентах когда речь идет о миллисекундах точно не стоит =)


  1. Kerman
    27.01.2023 10:12
    +2

    Код скриншотами IDE - это ужас. Поэтому код я не смотрел.

    Вообще меня интересует, какую задачу решал автор и чем это отличается от тасков?


    1. dAnglerais Автор
      27.01.2023 17:03

      Согласен. Заменил на код. Задача - создать расширяемый каркас для асинхронного использование компонентов. Расширим наш код добавив 1 метод:

          public static TAwaitable Instantiate<TAwaitable>(string path, params object[] parameters) 
              where TAwaitable:AwaitableBehaviour
          {
              _parameters = parameters;
      
              var awaitable = Resources.Load<TAwaitable>(path);
      
              return GameObject.Instantiate(awaitable);
          }

      Допустим, мы показываем пользователю рекламу с несколькими кнопками, и хотим узнать на какую кнопку и спустя сколько времени он нажмет.

      Благодаря такой записи мы получим аккуратный код:

      var userPick = await AwaitableBehaviour.Instantiate<Advertising>(GO);

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

      Существующие библиотеки, мало того, что не поддаются нормальному расширению(попробуйте показать такую рекламу используя его ), так еще требуют изучения документации и знание тонкостей работы - а они есть, и представлены в товарном кол-ве. И это уже не говоря о том, что проекте появляется +1 огромная библиотека.
      Вопрос о тасках не совсем корректный, так как мне ничего не мешает использовать самые обычные таски в этой записи. Awaitable тип не есть task-like тип.


  1. trofian
    27.01.2023 11:32

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


    1. dAnglerais Автор
      27.01.2023 11:45

      Например, наше приложение это набор UI окон с различными формами. Мы можем написать сами окна таким образом, унаследовав их от обобщенного типа и показывать пользователю дожидаясь результата(например, мы хотим получить то, что юзер ввел в инпут). Можно, например, показать аддитивно сцену, и дождаться пока она отработает. Можно дождаться пока персонаж вашей игры появиться, сделает все необходимое, и вернет результаты своей работы. Как правило, в Unity всегда есть компонент- агрегатор через который происходит взаимодействие с контейнером(gameObject-ом). Делая такой компонент ожидаемым, можно получить все что захочет наша фантазия)


  1. Ageris
    27.01.2023 11:45
    +1

    Почему нельзя было код оформить нормально, а не скринить весь рабочий стол?


    1. dAnglerais Автор
      27.01.2023 12:16
      -1

      Хабр 3000 знаков только дает, пришлось больше половины выкинуть, а код скринить (


    1. dAnglerais Автор
      27.01.2023 17:15

      Виновен, исправился


  1. rukhi7
    28.01.2023 12:03

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

    class ExampleWithResult. private void Update()
    class Example. private void Update()

    они же приватные и кроме как изнутри класса их никто не может вызвать, как я понимаю. Но вызовов я не вижу! Зачем тогда они написаны???

    Это я такой тупой (косой, слепой) или это, действительно, фигня какая то написана?


    1. GiftedMamba
      28.01.2023 17:42

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


  1. rukhi7
    28.01.2023 23:07

    вон оно как, получается Unity это тестовый фреймворк, раз он имеет доступ к приватным членам?