C# TaskよりUnity Coroutineをお勧めする理由。中断処理がとても面倒なTask。
関連ページ
Unityを使ってる人でコルーチンを知らない人はいないと思う。非同期処理を扱うのにとても便利な機能。
一方で2010年に公開された.NET Framework 4から、C#そのものに非同期処理を扱うTaskというコンポーネントが追加された。
Taskが公開された直後は、もうCoroutineは古いと言ってTaskを盛んに持て囃すのが流行った。
たしかにTaskにはCoroutineには無い良い部分があるものの、両方使ってみた感想としてはCoroutineの方が使いやすいというのが自分の結論。
理由は中断処理にある。
シンプルなコードで比較
最初に、全く同じ実装をCoroutineとTaskで行い、両者の比較をしていく。
実装する内容は、右のボタンを押すと星が5秒かけて右端に移動し、右端に到達すると星が点滅する。
左のボタンを押すと星が3秒かけて左端に移動し、左端に到達すると星が点滅する。
星の移動中は、上のテキストに経過時間が表示される。
補足として今回は、星の移動処理に関してのみTaskでもCoroutineでもなく
Tween
を使っていく。(便利なので)
using
DG.Tweening;
using
UnityEngine;
using
UnityEngine.UI;
using
System.Collections;
/// <summary>
/// Coroutineを使ったアニメーション実装
/// </summary>
public
class
TestCoroutine
:
MonoBehaviour
{
[SerializeField]
private
Button
rightButton;
[SerializeField]
private
Button
leftButton;
[SerializeField]
private
Text
infoText;
[SerializeField]
private
Transform
star;
private
float
rightSlideSec
=
5f;
private
float
leftSlideSec
=
3f;
private
float
rightEndPosX
=
500;
private
float
leftEndPosX
=
-500;
private
int
flashCount
=
3;
private
float
flashInterval
=
0.2f;
private
void
Start()
{
//ボタンイベントをスクリプトから登録
rightButton.onClick.AddListener(OnTapRightButton);
leftButton.onClick.AddListener(OnTapLeftButton);
}
/// <summary>
/// 右のボタンを押した時の処理
/// </summary>
private
void
OnTapRightButton()
{
StartCoroutine(CommonSlideAction(rightEndPosX,
rightSlideSec));
}
/// <summary>
/// 左のボタンを押した時の処理
/// </summary>
private
void
OnTapLeftButton()
{
StartCoroutine(CommonSlideAction(leftEndPosX,
leftSlideSec));
}
/// <summary>
/// アニメーションの共通処理
/// </summary>
private
IEnumerator
CommonSlideAction(float
endPosX,
float
duration)
{
infoText.text
=
"経過時間0秒";
//星をTweenを使って移動させ、1秒置きにテキストを更新する
star.DOLocalMoveX(endPosX,
duration).SetEase(Ease.Linear);
var
remainTime
=
duration;
while
(remainTime
>
0)
{
yield
return
new
WaitForSeconds(1);
remainTime--;
infoText.text
=
"経過時間"
+
(duration
-
remainTime)
+
"秒";
}
//星が目的地に着いたら点滅させる
for(int
i
=
0;
i
<
flashCount
*
2;
i++)
{
//偶数かどうか判定
var
isEven
=
i
%
2
==
0;
//偶数なら星を非表示、奇数なら表示
star.gameObject.SetActive(isEven
?
false
:
true);
yield
return
new
WaitForSeconds(flashInterval);
}
infoText.text
=
"やりましたね!!";
}
}
返り値
IEnumerator
で定義された関数を、
StartCoroutine
関数で読んで非同期処理を行っている。
実際の待機処理のコードは
yield return new WaitForSeconds
の部分。
using
DG.Tweening;
using
UnityEngine;
using
UnityEngine.UI;
using
System.Threading.Tasks;
/// <summary>
/// Taskを使ったアニメーション実装
/// </summary>
public
class
TestTask
:
MonoBehaviour
{
[SerializeField]
private
Button
rightButton;
[SerializeField]
private
Button
leftButton;
[SerializeField]
private
Text
infoText;
[SerializeField]
private
Transform
star;
private
float
rightSlideSec
=
5f;
private
float
leftSlideSec
=
3f;
private
float
rightEndPosX
=
500;
private
float
leftEndPosX
=
-500;
private
int
flashCount
=
3;
private
float
flashInterval
=
0.2f;
private
void
Start()
{
rightButton.onClick.AddListener(OnTapRightButton);
leftButton.onClick.AddListener(OnTapLeftButton);
}
/// <summary>
/// 右のボタンを押した時の処理
/// </summary>
private
void
OnTapRightButton()
{
CommonSlideAction(rightEndPosX,
rightSlideSec);
}
/// <summary>
/// 左のボタンを押した時の処理
/// </summary>
private
void
OnTapLeftButton()
{
CommonSlideAction(leftEndPosX,
leftSlideSec);
}
/// <summary>
/// アニメーションの共通処理
/// </summary>
private
async
Task
CommonSlideAction(float
endPosX,
float
duration)
{
infoText.text
=
"経過時間0秒";
//星をTweenを使って移動させ、1秒置きにテキストを更新する
star.DOLocalMoveX(endPosX,
duration).SetEase(Ease.Linear);
var
remainTime
=
duration;
while
(remainTime
>
0)
{
//Task.Delayはミリ秒で管理するので、1000を渡すと1秒待機になる
await
Task.Delay(1000);
remainTime--;
infoText.text
=
"経過時間"
+
(duration
-
remainTime)
+
"秒";
}
for(int
i
=
0;
i
<
flashCount
*
2;
i++)
{
var
isEven
=
i
%
2
==
0;
star.gameObject.SetActive(isEven
?
false
:
true);
//Task.Delayにはintしか渡せない。0.2*1000で200ミリ秒、つまり0.2秒待機
await
Task.Delay(Mathf.FloorToInt(flashInterval
*
1000));
}
infoText.text
=
"やりましたね!!";
}
}
Coroutineの
yeild return new WaitForSeconds
に相当するのが
await Task.Delay
。
ただしTaskはミリ秒が基本なので、1秒待ちたい場合はawait Task.Delay(1000)と記述する。
関数を宣言するとき、Coroutineは返り値に
IEnumerator
を指定するが、Taskは
async Task
と入力する。
またその関数を呼ぶ時にStartCoroutineと入力する必要がなく、通常の関数と呼び出し方法が変わらない。
IEnumeratorの関数内ではyield return ~の待機処理コードが無いとエラーが出るが、aync Taskでは待機処理コードが無くても別に問題ない。
以上の細かい違いがあるが、このレベルの実装では正直どっちでもいい。
中断処理から比較
さて、もし右のボタンを押した後、その処理が完了する前に左のボタンを押すと下の動画のようになる。
右にスライドする処理と左にスライドする処理が同時に走っている。
しかも右スライドの方がアニメーション時間が長いので、左のボタンを右ボタンの後に押しても最終的な星の座標は右端になっている。
こうした不自然で予期できない挙動を阻止するため、ゲーム実装では頻繁に中断処理を差し込むことになる。
修正した結果は次の通り。
using
DG.Tweening;
using
UnityEngine;
using
UnityEngine.UI;
using
System.Collections;
/// <summary>
/// Coroutineを使ったアニメーション実装
/// </summary>
public
class
TestCoroutine
:
MonoBehaviour
{
[SerializeField]
private
Button
rightButton;
[SerializeField]
private
Button
leftButton;
[SerializeField]
private
Text
infoText;
[SerializeField]
private
Transform
star;
private
float
rightSlideSec
=
5f;
private
float
leftSlideSec
=
3f;
private
float
rightEndPosX
=
500;
private
float
leftEndPosX
=
-500;
private
int
flashCount
=
3;
private
float
flashInterval
=
0.2f;
private
Coroutine
slideCoroutine;
private
Tweener
slideTweener;
private
void
Start()
{
rightButton.onClick.AddListener(OnTapRightButton);
leftButton.onClick.AddListener(OnTapLeftButton);
}
/// <summary>
/// 右のボタンを押した時の処理
/// </summary>
private
void
OnTapRightButton()
{
if
(slideCoroutine
!=
null)
{
StopCoroutine(slideCoroutine);
slideTweener.Kill();
}
slideCoroutine
=
StartCoroutine(CommonSlideAction(rightEndPosX,
rightSlideSec));
}
/// <summary>
/// 左のボタンを押した時の処理
/// </summary>
private
void
OnTapLeftButton()
{
if
(slideCoroutine
!=
null)
{
StopCoroutine(slideCoroutine);
slideTweener.Kill();
}
slideCoroutine
=
StartCoroutine(CommonSlideAction(leftEndPosX,
leftSlideSec));
}
/// <summary>
/// アニメーションの共通処理
/// </summary>
private
IEnumerator
CommonSlideAction(float
endPosX,
float
duration)
{
//中断を実行したタイミングによっては後半の点滅で
//star.gameOjbectが消えてる可能性があるのでtrueにする
star.gameObject.SetActive(true);
infoText.text
=
"経過時間0秒";
//星をTweenを使って移動させ、1秒置きにテキストを更新する
slideTweener
=
star.DOLocalMoveX(endPosX,
duration).SetEase(Ease.Linear);
var
remainTime
=
duration;
while
(remainTime
>
0)
{
yield
return
new
WaitForSeconds(1);
remainTime--;
infoText.text
=
"経過時間"
+
(duration
-
remainTime)
+
"秒";
}
for(int
i
=
0;
i
<
flashCount
*
2;
i++)
{
var
isEven
=
i
%
2
==
0;
star.gameObject.SetActive(isEven
?
false
:
true);
yield
return
new
WaitForSeconds(flashInterval);
}
infoText.text
=
"やりましたね!!";
}
}
private Coroutine slideCoroutine;
、この変数に実行するCoroutineをあらかじめ保存する。
次にボタンを押す際にはまず実行中のコルーチンを停止させる。
また星の移動にはTweenを使っているが、TweenはCoroutineとは関係ない独立したスレッドで動いている。
なので
private Tweener slideTweener;
で実行するTweenを保存させている。Coroutineの停止と同じタイミングで停止を呼ぶ。
using
DG.Tweening;
using
UnityEngine;
using
UnityEngine.UI;
using
System.Threading.Tasks;
using
System.Threading;
/// <summary>
/// Taskを使ったアニメーション実装
/// </summary>
public
class
TestTask
:
MonoBehaviour
{
[SerializeField]
private
Button
rightButton;
[SerializeField]
private
Button
leftButton;
[SerializeField]
private
Text
infoText;
[SerializeField]
private
Transform
star;
private
float
rightSlideSec
=
5f;
private
float
leftSlideSec
=
3f;
private
float
rightEndPosX
=
500;
private
float
leftEndPosX
=
-500;
private
int
flashCount
=
3;
private
float
flashInterval
=
0.2f;
private
CancellationTokenSource
slideTokenSource;
private
Tweener
slideTweener;
private
void
Start()
{
rightButton.onClick.AddListener(OnTapRightButton);
leftButton.onClick.AddListener(OnTapLeftButton);
}
/// <summary>
/// 右のボタンを押した時の処理
/// </summary>
private
void
OnTapRightButton()
{
if
(slideTokenSource
!=
null)
{
slideTokenSource.Cancel();
slideTweener.Kill();
}
slideTokenSource
=
new
CancellationTokenSource();
CommonSlideAction(rightEndPosX,
rightSlideSec,
slideTokenSource.Token);
}
/// <summary>
/// 左のボタンを押した時の処理
/// </summary>
private
void
OnTapLeftButton()
{
if
(slideTokenSource
!=
null)
{
slideTokenSource.Cancel();
slideTweener.Kill();
}
slideTokenSource
=
new
CancellationTokenSource();
CommonSlideAction(leftEndPosX,
leftSlideSec,
slideTokenSource.Token);
}
/// <summary>
/// アニメーションの共通処理
/// </summary>
private
async
Task
CommonSlideAction(float
endPosX,
float
duration,
CancellationToken
token)
{
//中断を実行したタイミングによっては後半の点滅で
//star.gameOjbectが消えてる可能性があるのでtrueにする
star.gameObject.SetActive(true);
infoText.text
=
"経過時間0秒";
//星をTweenを使って移動させ、1秒置きにテキストを更新する
slideTweener
=
star.DOLocalMoveX(endPosX,
duration).SetEase(Ease.Linear);
var
remainTime
=
duration;
while
(remainTime
>
0)
{
//Task.Delayはミリ秒で管理するので、1000を渡すと1秒待機になる
await
Task.Delay(1000);
if(token.IsCancellationRequested)
{
return;
}
remainTime--;
infoText.text
=
"経過時間"
+
(duration
-
remainTime)
+
"秒";
}
for(int
i
=
0;
i
<
flashCount
*
2;
i++)
{
var
isEven
=
i
%
2
==
0;
star.gameObject.SetActive(isEven
?
false
:
true);
//Task.Delayにはintしか渡せない。0.2*1000で200ミリ秒、つまり0.2秒待機
await
Task.Delay(Mathf.FloorToInt(flashInterval
*
1000));
if(token.IsCancellationRequested)
{
return;
}
}
infoText.text
=
"やりましたね!!";
}
}
Taskの中断処理では
StopCoroutine
ではなく
CancellationTokenSource
と
CancellationToken
を使う。
これが直感的でなく非常に分かりずらい。
中断命令はCancellationTokenSourceで行うが、中断命令が出されたかどうかのフラグはCancellationTokenを見る必要がある。
Taskの中断処理は、StopCoroutineのようにCoroutine関数の外部で行うのではなく、Task関数の内部で行う必要がある。
下が実際の中断処理のコード。
if(token.IsCancellationRequested)
{
return;
}
しかもTask関数の内部の全てのawait Task.Delayの後ろに中断処理を書き込む必要がある。
これが厄介で、長いアニメーションでは頻繁にTask.Delayを行うので、無意味に冗長で汚いコードになる。抜けも起きやすい。
Taskに関する不満はこれ一点で、他は素晴らしいところもある。
例えば返り値を受け取ることができたり、最近のTweenではAsyncWaitForCompletionというTaskに変換する機能があって便利。
ただこの中断処理一点の不満があまりに大きすぎる。
0
0