Як насправді працює StartCoroutine / модель повернення прибутку в Unity?


134

Я розумію принцип спільної роботи. Я знаю, як змусити стандарт StartCoroutine/ yield returnшаблон працювати в C # в Unity, наприклад, викликати метод, що повертається IEnumeratorчерез StartCoroutineі в цьому методі зробити щось, зробити yield return new WaitForSeconds(1);почекати секунду, а потім зробити щось інше.

Моє запитання: що насправді відбувається за лаштунками? Що StartCoroutineнасправді робить? Що IEnumeratorце WaitForSecondsповернення? Як здійснюється StartCoroutineуправління поверненням до "чогось іншого" частини названого методу? Як все це взаємодіє з моделлю сумісності Unity (де багато речей відбувається одночасно без використання супротивників)?


3
Компілятор C # перетворює методи, що повертають IEnumerator/ IEnumerable(або загальні еквіваленти) і містять yieldключове слово. Знайдіть ітераторів.
Damien_The_Unbeliever

4
Ітератор - це дуже зручна абстракція для "державної машини". Зрозумійте це спочатку, і ви також отримаєте супроводи Unity. en.wikipedia.org/wiki/State_machine
Ганс Пасант

2
Тег єдності зарезервований Microsoft Unity. Будь ласка, не зловживайте цим.
Лекс Лі

11
Я вважав цю статтю досить освітленою: детально розглядаються питання Unity3D
Kay

5
@Kay - Я б хотів, щоб я міг тобі купити пиво. Ця стаття - саме те, що мені було потрібно. Я починав ставити під сумнів свою розум, бо здавалося, що моє запитання навіть не має сенсу, але стаття безпосередньо відповідає на моє питання краще, ніж я міг собі уявити. Можливо, ви можете додати відповідь за цим посиланням, яке я можу прийняти, на користь майбутніх користувачів ЗП?
Ghopper21

Відповіді:


109

Часто посилання на підпрограми Unity3D докладно посилаються мертвими. Оскільки це згадується в коментарях та відповідях, я збираюся розмістити тут зміст статті. Цей вміст походить від цього дзеркала .


Детально розглядаються питання Unity3D

Багато процесів в іграх відбуваються протягом декількох кадрів. У вас є "щільні" процеси, як-от перенаправлення маршрутів, які наполегливо працюють кожен кадр, але розбиваються на кілька кадрів, щоб не впливати на кадр занадто сильно. У вас є "рідкісні" процеси, як ігрові тригери, які не роблять більшість кадрів, але періодично закликаються робити критичну роботу. І у вас є різні процеси між ними.

Кожного разу, коли ви створюєте процес, який відбуватиметься через декілька кадрів - без багатопотокової редакції, вам потрібно знайти спосіб розбиття роботи на шматки, які можна запускати один на кадр. Для будь-якого алгоритму з центральним циклом це досить очевидно: наприклад, A * pathfinder може бути структурований таким чином, що він підтримує свої списки вузлів напівперманентно, обробляючи лише декілька вузлів із відкритого списку кожен кадр, а не намагаючись виконати всю роботу за один рух. Для управління затримкою потрібно виконати балансування - адже якщо ви блокуєте частоту кадрів зі швидкістю 60 або 30 кадрів в секунду, то ваш процес займе лише 60 або 30 кроків в секунду, і це може призвести до простого процесу занадто довгий загальний. Акуратний дизайн може запропонувати найменшу можливу одиницю роботи на одному рівні - наприклад обробляти один вузол A * - і шар зверху способом групування спільної роботи в більші шматки - наприклад, тримати обробку A * вузлів протягом X мілісекунд. (Деякі люди називають це «часовим набором часу», хоча я цього не роблю).

Тим не менш, дозволяючи розбити роботу таким чином, ви повинні перевести стан з одного кадру в інший. Якщо ви порушуєте ітераційний алгоритм, то вам доведеться зберегти весь стан, поділений в рамках ітерацій, а також засіб відстеження, яку ітерацію слід виконати далі. Зазвичай це не дуже погано - дизайн класу «A * pathfinder» досить очевидний - але є й інші випадки, які є менш приємними. Іноді ви будете стикатися з довгими обчисленнями, які виконують різні види роботи від кадру до кадру; об'єкт, що захоплює їх стан, може закінчитися великим безладом напівкорисних «місцевих жителів», що зберігаються для передачі даних з одного кадру в інший. І якщо ви маєте справу з розрідженим процесом, вам часто доводиться впроваджувати невелику державну машину просто для того, щоб відслідковувати, коли роботу взагалі потрібно робити.

Чи не було б охайним, якби замість того, щоб чітко відстежувати весь цей стан через декілька кадрів, а замість того, щоб багатопотоково читати та керувати синхронізацією та блокуванням тощо, ви могли просто написати свою функцію як єдиний фрагмент коду, і позначте конкретні місця, де функція повинна «призупинятись» і виконувати її згодом?

Єдність - разом з низкою інших середовищ та мов - надає це у формі Coroutines.

Як вони виглядають? У розділі "Unityscript" (Javascript):

function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield;
    }
}

В C #:

IEnumerator LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield return null;
    }
}

Як вони працюють? Дозвольте мені швидко сказати, що я не працюю в Unity Technologies. Я не бачив вихідного коду Unity. Я ніколи не бачив кишок механізму кореневих програм Unity. Однак, якщо вони реалізували це таким чином, що кардинально відрізняється від того, що я збираюся описати, я буду дуже здивований. Якщо хтось із УТ хоче прислухатися і поговорити про те, як це насправді працює, то це було б чудово.

Великі підказки є у версії C #. По-перше, зауважте, що типом повернення для функції є IEnumerator. По-друге, зауважте, що одне із тверджень - це прибутковість. Це означає, що вихід повинен бути ключовим словом, а оскільки підтримка C # Unity - це ваніль C # 3.5, це має бути ключове слово ванілі C # 3.5. Дійсно, ось мова йде про MSDN - про щось, що називається "блоки ітераторів". Отже, що відбувається?

По-перше, є такий тип IEnumerator. Тип IEnumerator діє як курсор над послідовністю, забезпечуючи два значущі члени: Current, що є властивістю, що дає вам елемент, над яким курсор зараз перебуває, і MoveNext (), функція, що переходить до наступного елемента в послідовності. Оскільки IEnumerator є інтерфейсом, він не вказує, як саме реалізуються ці члени; MoveNext () може просто додати один до потоку, або він може завантажити нове значення з файлу, або він може завантажити зображення з Інтернету та хеш-пам'ять і зберегти новий хеш у Current ... або він навіть може зробити одне для першого елемент у послідовності, а для другого щось зовсім інше. Ви можете навіть використовувати його для створення нескінченної послідовності, якщо цього хочете. MoveNext () обчислює наступне значення в послідовності (повертає помилкове, якщо більше немає значень),

Зазвичай, якщо ви хочете реалізувати інтерфейс, вам доведеться написати клас, реалізувати учасників тощо. Блоки ітератора - це зручний спосіб реалізації IEnumerator без усього цього клопоту - ви просто дотримуєтесь декількох правил, і реалізація IEnumerator автоматично генерується компілятором.

Блок ітератора - це звичайна функція, яка (a) повертає IEnumerator, і (b) використовує ключове слово прибутковість. Отже, що насправді робить ключове слово дохідності? Він оголошує, що таке наступне значення в послідовності - або що більше немає значень. Точка, в якій код стикається з поверненням виходу X або перервою врожайності, - це точка, в якій IEnumerator.MoveNext () повинен зупинитися; повернення прибутковості X призводить до того, що MoveNext () повертає істинне, а потоку присвоюється значення X, тоді як перерва врожайності приводить до того, що MoveNext () повертає помилкове.

Тепер ось ось хитрість. Не має значення, які фактичні значення повертаються послідовністю. Ви можете викликати MoveNext () повторно та ігнорувати Поточний; обчислення все одно будуть виконуватися. Кожен раз, коли MoveNext () викликається, ваш блок ітераторів переходить до наступного оператора 'yield', незалежно від того, який вираз він дає. Тож ви можете написати щось на кшталт:

IEnumerator TellMeASecret()
{
  PlayAnimation("LeanInConspiratorially");
  while(playingAnimation)
    yield return null;

  Say("I stole the cookie from the cookie jar!");
  while(speaking)
    yield return null;

  PlayAnimation("LeanOutRelieved");
  while(playingAnimation)
    yield return null;
}

і те, що ви насправді написали, - це блок ітераторів, який генерує довгу послідовність нульових значень, але важливими є побічні ефекти роботи, яку він виконує для їх обчислення. Ви можете запустити цю програму, використовуючи простий цикл, подібний до цього:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

Або, більш корисно, ви можете змішати його з іншою роботою:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // If they press 'Escape', skip the cutscene
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

Це все в термінах. Як ви бачили, кожен оператор повернення прибутковості повинен містити вираз (як null), щоб блок ітератора міг насправді присвоїти IEnumerator.Current. Довга послідовність нулів не зовсім корисна, але нас більше цікавлять побічні ефекти. Чи не ми?

Насправді ми можемо зробити щось із цим виразом. Що робити, якщо замість того, щоб просто отримати нуль і ігнорувати це, ми отримали щось, що вказувало, коли ми очікуємо, що потрібно зробити більше роботи? Часто нам потрібно перенести прямо на наступний кадр, звичайно, але не завжди: буде багато разів, коли ми хочемо продовжувати роботу після того, як анімація чи звук закінчиться відтворенням або після того, як пройде певна кількість часу. Ті, хто (грає в Анімацію), приносять нуль; конструкції трохи стомлюючі, ти не думаєш?

Unity оголошує базовий тип YieldInstruction і надає кілька конкретних похідних типів, які вказують на певні види очікування. У вас є WaitForSeconds, який відновлює процедуру після закінчення призначеного часу. У вас є WaitForEndOfFrame, який поновлює програму в певній точці пізніше в тому ж кадрі. Ви отримали сам тип Coroutine, який, коли програма A дає команду B, призупиняє програму A до тих пір, поки не закінчиться програма B.

Як це виглядає з точки зору виконання? Як я вже сказав, я не працюю для Unity, тому я ніколи не бачив їхнього коду; але я думаю, що це може виглядати трохи так:

List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;

foreach(IEnumerator coroutine in unblockedCoroutines)
{
    if(!coroutine.MoveNext())
        // This coroutine has finished
        continue;

    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }

    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
}

unblockedCoroutines = shouldRunNextFrame;

Не важко уявити, як можна додати більше підтипів YieldInstruction для обробки інших випадків - наприклад, може бути додана підтримка сигналів на рівні двигуна з підтримкою YieldInstruction WaitForSignal ("SignalName"). Додавши більше інструкцій YieldInstructions, самі підходи можуть стати більш виразними - повернення прибутків нового WaitForSignal ("GameOver") приємніше читати, ніж час (! Signals.HasFired ("GameOver")) прибутковість повернення дорівнює нулю, якщо ви запитаєте мене, зовсім крім той факт, що робити це в двигуні може бути швидше, ніж робити це за сценарієм.

Кілька неочевидних наслідків Є кілька корисних речей у всьому цьому, що люди іноді сумують, що я вважав, що слід зазначити.

По-перше, повернення урожайності - це лише вираз - будь-який вираз - і YieldInstruction - регулярний тип. Це означає, що ви можете робити такі речі, як:

YieldInstruction y;

if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);

yield return y;

Конкретні рядки приносять прибутковість новій WaitForSeconds (), віддача повернення нова WaitForEndOfFrame () тощо, є загальними, але вони насправді не є спеціальними формами самі по собі.

По-друге, оскільки ці функції є просто блоками ітераторів, ви можете самі перебирати їх, якщо хочете - не потрібно, щоб двигун робив це за вас. Я використовував це для додавання умов переривання до підпрограми раніше:

IEnumerator DoSomething()
{
  /* ... */
}

IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

По-третє, той факт, що ви можете поступатися іншим процедурам, може допомогти вам реалізувати власні Інструкції Yield, хоч і не настільки ефективно, як якщо б вони були реалізовані двигуном. Наприклад:

IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

Coroutine UntilTrue(Func fn)
{
  return StartCoroutine(UntilTrueCoroutine(fn));
}

IEnumerator SomeTask()
{
  /* ... */
  yield return UntilTrue(() => _lives < 3);
  /* ... */
}

Однак я б не рекомендував цього - вартість запуску програми була дуже вагомою для мене.

Висновок Я сподіваюсь, що це трохи прояснить деякі речі, що насправді відбувається, коли ви використовуєте Coroutine в Unity. Блоки ітераторів C # 'є нелегкою конструкцією, і навіть якщо ви не використовуєте Unity, можливо, вам буде корисно таким же чином скористатися ними.


2
Дякую, що ви відтворили це тут. Це чудово, і мені це значно допомогло.
Найкровек

96

Перший заголовок нижче - це пряма відповідь на питання. Два заголовки після є більш корисними для повсякденного програміста.

Можливо, нудні відомості про впровадження Коротингів

Корунти пояснюються у Вікіпедії та інших місцях. Тут я лише наведу деякі деталі з практичної точки зору. IEnumerator, yieldтощо - це функції мови C # , які використовуються для дещо іншого призначення в Unity.

Простіше кажучи, IEnumeratorпретензії на колекцію значень, які ви можете запитувати одне за одним, на зразок а List. У C #, функція з підписом для повернення документа IEnumeratorне повинна насправді створювати та повертати її, але може дозволити C # надавати неявну інформацію IEnumerator. Функція тоді може надавати вміст повернутого IEnumeratorв майбутньому ледачого способу через yield returnзаяви. Кожен раз, коли абонент запитує інше значення з цього неявного IEnumerator, функція виконує до наступного yield returnоператора, який забезпечує наступне значення. Як побічний продукт цього, функція призупиняється, поки не буде запропоновано наступне значення.

У Unity ми не використовуємо їх для надання майбутніх значень, ми використовуємо той факт, що функція призупиняється. Через цю експлуатацію багато речей про супроводи в Єдності не мають сенсу (Що стосується IEnumeratorнічого? Що таке yield? Чому new WaitForSeconds(3)? Тощо). Що відбувається "під капотом", це те, що значення, які ви надаєте через IEnumerator, використовуються для того, StartCoroutine()щоб вирішити, коли запитувати наступне значення, яке визначає, коли ваша поправка знову відключиться.

Ваша гра Unity є однопоточною (*)

Супроводи не є нитками. Є одна основна петля Unity, і всі ті функції, які ви пишете, викликаються тією самою основною ниткою в порядку. Ви можете перевірити це, розмістивши while(true);в будь-якій із своїх функцій або підпрограм. Це заморозить усе, навіть редактор Unity. Це свідчення того, що все працює в одній головній нитці. Це посилання, про яке Кей згадував у своєму коментарі, також є чудовим ресурсом.

(*) Unity викликає ваші функції з одного потоку. Отже, якщо ви самі не створюєте потік, код, який ви написали, є єдиною ниткою. Звичайно, Unity використовує інші потоки, і ви можете створювати теми самостійно, якщо хочете.

Практичний опис процедур для ігрових програмістів

В основному, коли ви телефонуєте StartCoroutine(MyCoroutine()), це не так же , як звичайний виклик функції до MyCoroutine(), поки перший yield return X, де Xщо - щось на зразок null, new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), breakі т.д. Це коли він починає відрізняючись від функції. Єдність "призупиняє" функціонування прямо на цьому yield return Xрядку, продовжується з іншим бізнесом, і деякі кадри проходять, і коли знову настає час, Unity відновить цю функцію відразу після цього рядка. Він запам'ятовує значення для всіх локальних змінних у функції. Таким чином, ви можете мати forцикл, який замикається кожні дві секунди, наприклад.

Коли Unity відновить вашу програму, залежить від того, що Xбуло у вас yield return X. Наприклад, якщо ви користувались yield return new WaitForSeconds(3);, він відновиться після проходження 3 секунд. Якщо ви користувались yield return StartCoroutine(AnotherCoroutine()), він поновлюється після того, як AnotherCoroutine()буде повністю зроблено, що дозволяє вчасно вкладати поведінку. Якщо ви тільки що використовували a yield return null;, він відновиться прямо в наступному кадрі.


2
Це занадто погано, UnityGems, здається, на деякий час знижується. Деяким людям у Reddit вдалося отримати останню версію архіву: web.archive.org/web/20140702051454/http://unitygems.com/…
ForceMagic

3
Це дуже розпливчасто і ризикуєте бути неправильним. Ось як насправді компілюється код і чому він працює. Крім того, це також не відповідає на питання. stackoverflow.com/questions/3438670/…
Луї Гонг

Так, я думаю, я пояснив, "як працюють супроводи в Unity" з точки зору ігрового програміста. Фактична відмова запитувала, що відбувається під кришкою. Якщо ви можете вказати на неправильні частини моєї відповіді, я би радив це виправити.
Газіхан Аланкус

4
Я погоджуюся з приводу повернення прибутковості помилковим, я додав його, тому що хтось критикував мою відповідь за відсутність, і я поспішав переглянути, чи це навіть корисно, і просто додав посилання. Я її зараз зняв. Однак я вважаю, що Unity є єдиною ниткою і як сумісність підходить до цього, не очевидно для всіх. Чимало початківців програмістів Unity, з якими я говорив, мають дуже розпливчасте розуміння всього питання та отримують користь від такого пояснення. Я відредагував свою відповідь, щоб надати відповідь на питання. Пропозиції вітаються.
Газіхан Аланкус

2
Єдність - це не одиночна нитка. Він має основну нитку, в яку запускаються методи життєвого циклу MonoBehaviour-- але він також має інші потоки. Ви навіть вільні створити власні теми.
benthehutt

10

Не може бути простіше:

Unity (і всі ігрові двигуни) базуються на кадрах .

Вся суть, весь резонанс Єдності полягає в тому, що він базується на кадрах. Двигун робить для вас речі "кожен кадр". (Анімує, робить об’єкти, займається фізикою тощо).

Ви можете запитати .. "О, це чудово. Що робити, якщо я хочу, щоб двигун робив щось для мене кожен кадр? Як я можу сказати двигуну робити таке-і-таке в кадрі?"

Відповідь ...

Саме для цього і є "coroutut".

Це просто так просто.

І врахуйте це….

Ви знаєте функцію "Оновлення". Простіше кажучи, все, що ви там помістили, робиться кожен кадр . Це буквально абсолютно те саме, що зовсім не відрізняється від синтаксису coroutine-output.

void Update()
 {
 this happens every frame,
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 }

...in a coroutine...
 while(true)
 {
 this happens every frame.
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 yield return null;
 }

Різниці абсолютно немає.

Виноска: як усі зазначали, у Єдності просто немає ниток . "Кадри" в Unity або в будь-якому ігровому двигуні жодним чином не мають зв'язку з потоками.

Супроводи / вихід - це просто те, як ви отримуєте доступ до кадрів в Unity. Це воно. (І справді, це абсолютно те саме, що функція Update (), надана Unity.) Це все, що там є, це все просто.


Дякую! Але ваша відповідь пояснює, як користуватися супротинами, а не як вони працюють за лаштунками.
Ghopper21

1
Мені приємно, дякую. Я розумію, що ви маєте на увазі - це може бути хорошою відповіддю для початківців, які завжди запитують, що таке чортові супроводи. Ура!
Fattie

1
Власне - жодна з відповідей, навіть трохи не пояснює, що відбувається "за кадром". (Це означає, що це IEnumerator, який потрапляє до планувальника.)
Fattie

Ви сказали: "Різниці абсолютно немає". Тоді чому Unity створив Coroutines, коли вони вже мають таку практичну реалізацію, як Update()? Я маю на увазі, що між цими двома реалізаціями та їх використанням слід мати хоча б невелику різницю, що досить очевидно.
Леандро Гекозо

Ей @LeandroGecozo - я б сказав більше, що "Оновлення" - це лише якесь ("дурне") спрощення, яке вони додали. (Багато людей ніколи не користуються ним, просто використовують супроводи!) Я не думаю, що на ваше запитання немає жодної хорошої відповіді, це просто те, як єдність.
Fattie

5

Нещодавно зайнявшись цим питанням, написав публікацію тут - http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/ -, що проливає світло на внутрішні пристрої (з щільними прикладами коду), базовий IEnumeratorінтерфейс, і як його використовують для корутин.

Використання для цієї мети колекторів колекцій все ще здається мені трохи дивним. Це обернено те, для чого переглядачі призначені. Точка обчислювачів - це повернене значення при кожному доступі, але точка Coroutines - це код, що знаходиться між значеннями, що повертається. Фактичне повернене значення в цьому контексті безглуздо.


0

Основними функціями в Unity, які ви отримуєте автоматично, є функція Start () та Update (), тому функції Coroutine по суті є такими ж функціями, як функція Start () та Update (). Будь-яку стару функцію func () можна назвати так само, як можна викликати посібник. Єдність, очевидно, встановила певні межі для Коротингів, які роблять їх іншими, ніж звичайні функції. Одна різниця - замість

  void func()

Ви пишете

  IEnumerator func()

для корутин. І таким же чином ви можете контролювати час у звичайних функціях за допомогою кодових рядків

  Time.deltaTime

Програма має специфічну ручку про спосіб контролю часу.

  yield return new WaitForSeconds();

Хоча це не єдине, що можна зробити всередині IEnumerator / Coroutine, це одна з корисних речей, для якої використовуються Coroutines. Вам доведеться досліджувати сценарій API API, щоб дізнатися про інші конкретні способи використання.


0

StartCoroutine - це метод виклику функції IEnumerator. Це схоже на виклик простої функції пустоти, лише різниця полягає в тому, що ви використовуєте її у функціях IEnumerator. Цей тип функції є унікальним, оскільки дозволяє дозволити використовувати спеціальну функцію прибутковості , зауважте, що вам потрібно щось повернути. То наскільки я знаю. Тут я написав просту мерехтливу гру Над методом тексту в єдності

    public IEnumerator GameOver()
{
    while (true)
    {
        _gameOver.text = "GAME OVER";
        yield return new WaitForSeconds(Random.Range(1.0f, 3.5f));
        _gameOver.text = "";
        yield return new WaitForSeconds(Random.Range(0.1f, 0.8f));
    }
}

Потім я викликав це з самого IEnumerator

    public void UpdateLives(int currentlives)
{
    if (currentlives < 1)
    {
        _gameOver.gameObject.SetActive(true);
        StartCoroutine(GameOver());
    }
}

Як ви бачите, як я використовував метод StartCoroutine (). Сподіваюся, я якось допоміг. Я сам перемагаю, тому, якщо ви мене виправите чи оціните, будь-який тип відгуку був би чудовим.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.