promise应用于Unity异步操作

本文翻译自Using promises for Unity async operations,by Ashley Davis

你是否很难理解如何在Unity中使用promises? 本文将通过例子阐述,如何用promises表示Unity的通用异步操作

之前我写过使用promise进行游戏开发。那篇文章涉及到使用promise的背景、理论及优势,并笼统的介绍如何应用promise进行游戏开发。

本文中,我将降一些档次并提供简单而实用的unity使用promise的例子。

讲例子前,先快速复习下Unity的基本异步操作。然后将通过例子实现。最后再融入一些更复杂的例子来展示promise的强大威力——组合链式异步操作。

本文的Unity例程代码也可在github上找到。

开发环境

Unity版本 5.3.4f1, 例子也可用之前的版本运行。

代码编辑器使用 Visual Studio2015 社区版

为何使用异步操作

抛出第一个问题:为何使用异步操作呢?

在许多情形下,并不真正使用异步操作。Unity提供了同步和异步的方式来载入场景及资源,因此我们需要进行取舍。

通常,使用异步编程是为了防止阻塞主线程。举个例子来说,当你采用同步方式加载场景时,整个游戏将会停止刷新及渲染!这会将游戏挂起并不接受任何响应,并将一直持续到同步操作完成。由此可见,用户体验并不是很好,没准还会被认为游戏有BUG!

当你使用异步操作进行加载场景时(有一定延迟),游戏依旧可以进行操作。这意味着在载入的同时,你可以展示动态载入界面或其他游戏功能。

采用流内容的开放世界游戏依赖异步操作。它并不希望在玩家在游戏世界中移动时,因为载入地形数据而导致游戏挂起。

介绍Unity异步操作

Unity的异步操作通过Unity coroutines实现,尽管偶尔也通过update function推送载入完成状态进行操作

最简单的协程可以总结成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using RSG;
public class BasicCoroutine : MonoBehaviour
{
public void StartTheCoroutine(... parameters ...)
{
StartCoroutine(TheCoroutine(... parameters ...))
}
private IEnumerator TheCoroutine(... parameters ...)
{
var someAsyncOperation = ... creates the async operation ...
yield return someAsyncOperation;
// ... some time later this code will continue to
// execute after the async operation has completed ...
}
}

以上代码做了如下操作:

  1. 定义一个返回 IEnumerator 的函数,该函数实现协程的主体
  2. 调用该函数,同时传结果给 StartCoroutine
  3. 执行异步操作。通常使用 yield 返回一个 AsyncOperation异步操作对象。
  4. 协程将挂起,直到异步操作完成。然后恢复执行后续代码。

以上就是异步操作协程的基本模板。现在我们看一下实际点的例子。

该例子展示由Unity的WWW 类进行HTTP GET请求。

该例子简单实用,由REST API 推送 JSON 数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CoroutineWithHttpGet : MonoBehavior
{
private IEnumerator TheCoroutine(string url)
{
var www = new WWW(url);
yield return www; // Allow the async operation to complete.
if (! string.IsNullOrEmpty(www.error)){
// ... an error occurred, handle it ...
return;
}
// successfully got a response via HTTP GET.
var response = www.text;
// ... do something with the response ...
// if the data retrived is JSON, you can deserialize it here.
Debug.Log(response);
}
}

通过传入REST API的url来启动

1
StartCoroutine(TheCoroutine("http://some-host/some-rest-api"));

上例中,”http://some-host/some-rest-api“ 可以替换成 “http://www.google.com“ 等任何合法URL,
这样,将看到HTML源码输出到LOG。

目前我还不打算详解协程的工作流程,以后我将写一篇文章来讲解。这样说来,yield 之后的代码将是异步被执行的。

为何使用promise

本文的核心是如何用promise来表示异步操作和协程。来思考下为什么要这么做吧。

实际上,文章使用promise进行游戏开发的协程部分已经涉及到了。

可以说,我并不是协程的粉丝,我更倾向使用promise来组织异步操作。尽管协程固有其优势,但是我认为其弊大于利。当使用promise后,你会发现组合复杂的异步链式操作会更加灵活。

但是,协程在某些层次上是必须的! 因为Unity的异步操作是通过协程实现的(也有其他方式实现,在其他文章中我将讨论线程方式)。因此,这就是本文的由来:协程如何作为promise来使用?

这太容易实现了:

  • 简单创建一个promise对象,传入协程中
  • 如果协程成功,resolve promise
  • 否则,reject promise.

协程作为promise的例子

现在移步到协程作为promise的例子。 首先通过一些简单的模板开始,这些模板可以认为是入口。然后用实际的例子——载入场景或资源——来讲解。最后我将展示更复杂的多异步操作链式组合例子。

1. 协程作为promise的基本模板

所有例子的关键是如何用简单的协程表示promise. 你可以用此非特化的模板例子,它展示了所有后续例子的基本模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using RSG;
public class BasicCoroutineAsPromise : MonoBehavior
{
public IPromise Execute()
{
// Create Promise object
return new Promise((resolve, reject) =>
// Pass the promise to the coroutine
StartCoroutine(TheCoroutine(resolve, reject))
);
}
private IEnumerator TheCoroutine(
Action resolve,
Action<Exception>reject
)
{
// ... add your async operations here ...
yield return yourAsyncOperation;
// ... several yields later ...
var someErrorOccurred = ... did an error occur ...
if(someErrorOccurred){
// An error occured, reject the promise
reject(someErrorOccured);
} else{
// completed successfully, resolve the promise
resolve();
}
}
}

就是这么简单任性。

这个简单的例子执行了以下步骤:

  • Execute 方法调用异步操作
  • 创建了promise对象
  • Promise的构造函数传入一个匿名函数,它传递了resolvereject函数。promise通过这些函数同我们交互。
  • 启动协程
  • 依据协程成功与否来调用resolvereject

现在通过ThenCatch来实现链式回调:

1
2
3
4
5
6
7
8
9
var exampleCoroutine = someGameObject.AddComponment<BasicCoroutineAsPromise>();
exampleCoroutine.Execute()
.Then(()=>{
// Coroutine completed succesfully and yield a result.
})
.Catch(()=>{
// Coroutine failed with an error!
});

Then 当promise resolved后调用

Catch 当promise rejected后调用

2. 协程完成后获取结果

第一个例子假设我们并不需要协程返回结果。这在实际应用上更为常见。经常的,载入场景或后台处理很多时候并不需要明显的结果,你所要的仅是完成后的回调或错误处理。

另外一些情形,你需要取回一些结果。现在我们通过HTTP请求和载入资源来展示。以上例子处理的最后,你需要一些结果来进一步处理。因此你需要泛型promise来resolve一个特定值。

同样以一个非特化模板开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class CoroutineResult : MonoBehaviour
{
public IPromise<string> Execute()
{
return new Promise<string>((resolve, reject) =>
StartCoroutine(TheCoroutine(resolve, reject))
);
}
private IEnumerator TheCoroutine(
Action<string> resolve,
Action<Exception> reject
)
{
// ... add your async operations here ...
yield return yourAsyncOperation;
// ... several yields later ...
var someErrorOccurred = ... did an error occur ...
if (someErrorOccurred)
{
// An error occurred, reject the promise.
reject(new ApplicationException("My error"));
}
else
{
// Completed successfully,
// resolve the promise to return a particular value.
resolve("Hi from the coroutine!");
}
}
}

简明起见,该例子中promise硬编码resolve一个string值。我们稍后会用真是例子。

以下是调用代码。和之前的很像,只是Then 传递了一个value,它就是promise resolve的值。

1
2
3
4
5
6
7
8
9
10
var exampleCoroutine = gameObject.AddComponent<CoroutineResult>();
exampleCoroutine.Execute()
.Then(value =>
{
Debug.Log("Coroutine has completed with value: " + value);
})
.Catch(ex =>
{
Debug.LogException(ex, this);
});

3. 使用WWW进行Http GET请求作为promise

现在该是现实例子的时间了。该例子展示了如何用Unity的WWW类promise化HTTP GET请求。

该例子是之前HTTP GET例子的扩展。它resolved了取回的网页文本内容。内容可能是简单字符串,或是CSV文件,或是JSON数据等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CoroutineWithHttpGet : MonoBehavior
{
public IPromise<string> Get(string url)
{
return new Promise<string>((resolve, reject) =>
StartCoroutine(TheCoroutine(url, resolve, reject))
);
}
private IEnumerator TheCoroutine(
string url,
Action<string> resolve,
Action<Exception> reject)
{
var www = new WWW(url);
yield return www;
if(!string.IsNullOrEmpty(www.error)){
reject(new ApplicationException(www.error));
} else{
resolve(www.text);
}
}
}

上例中,我们假设取回的数据是文本数据。他可以当做简单的二进制数据。这样,我们可以用byte数组或某种buffer对象来表示promise resolve的值。

以下就是简单例子:

1
2
3
4
5
6
7
8
var exampleCoroutine = gameObject.AddComponment<CoroutineWithHttpGet>();
exampleCoroutine.Get("http://www.google.com")
.Then(value => {
Debug.Log("Html: " + value);
})
.Catch(ex =>{
Debug.LogException(ex, this);
})

现在,我们增加JSON反序列化。

假设REST API返回了JSON数据。同时让例子更加实际,在游戏云服务器取回的数据是leaderboard。

1
2
3
4
5
6
7
8
9
public class Leaderboard
{
// Class defined for deserialization.
}
exampleCoroutine.Get("http://the-game-server.com/leaderboard-rest-api")
.Then(jsonData => JsonConvert.DeserializeObject<Leaderboard>(jsonData))
.Then(leaderboard => ... do something with the leaderboard ...)
.Catch(ex => Debug.Exception(ex, this));

本例中,我使用JSON.NET。你可以使用其他可用于Unity的版本。JSON.NET非常棒,但是在Unity下不好用。若是你用Unity5.3以上版本,我更推荐用原生JSON API

你可能没见过promise的链式调用,我希望这将是你喜爱它的契机。如果链式调用过程中发生任何错误,Catch将捕获并处理。这异步异常捕获和常规异常处理非常类似

4. GameObject/MonoBehavior的单例形式

现在用单例让通用模本更便捷实用。与之前HTTP例子类似,但是惰性初始化单例GameObject对象,直到第一次调用。这样,能够在后续的使用中复用该单例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SingletonInstance : MonoBehavior
{
private static SingletonInstance singletonInstance = null;
public static IPromise<string> Get(string url)
{
if(singletonInstance == null){
var gameObject = new GameObject("_HTTP_Helper_");
GameObject.DontDestroyOnLoad(gameObject);
singletonInstance = gameObject.AddComponent<SingletonInstance>();
}
return singletonInstance.PrivateGet(url);
}
private IPromise<string> PrivateGet(string url)
{
return new Promise<string>((resolve, reject) =>
StartCortoutine(TheCoroutine(url, resolve, reject))
);
}
... same before ...
}

注意,在Get方程中调用游戏对象和组件的生成。同时调用DontDestroyOnLoad,他保证单例在切换场景后依旧存留在内存中。

可以注意到我们把GameObject的名字以下划线开头,这样在Unity的面板中就能直观看到创建的过程。这意味着我们用肉眼快速分辨哪些对象是手工创建的。

那么,如何使用这个单例呢?通过单例的自动创建,将更简化使用:

1
2
3
SingletonInstance.Get("http://www.google.com")
.Then(value => Debug.Log(value))
.Catch(ex => Debug.ExceptionLog(ex, this));

你可能会说单例模式是一个糟糕的设计模式。正常情况下我同意你的观点。但是基于Unity奇怪的框架架构,单例会简单实用。当APP规模变得更大更复杂后,困难成倍增长。这时,我们需要学习新的技术,例如依赖注入来简化管理。这点我将在未来的文章中讨论。

除了便捷,单例还有什么好处呢?你可能注意到,我们轻微的实现了Unity API的解耦,松耦合几乎等同于更好的代码设计(虽然用到极致也会产生破坏)。我们调用MonoBehavior的静态函数,返回一个promise用于管理异步操作。我们可以将此放在一个非MonoBehavior的类中,实现更好的解耦。未来将使用允许依赖注入的接口。这样,遮蔽了Unity并实现了Unity API的完全解耦。这怎么实现?它为什么这么好用?简单而言,这样更便于移植代码到其他游戏引擎。例如,若是我们完全解耦,代码可能移植到MonoGame。这里我YY了,我将在以后的文章中讨论这点。

5. 异步加载场景

本例中,我将展示作为promise,如何异步载入场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class SceneLoader : MonoBehaviour
{
private static SceneLoader singletonInstance = null;
/// <summary>
/// Load a named scene, returns a promise that is resolved
/// when the scene has loaded.
/// </summary>
public static IPromise LoadScene(string sceneName)
{
if (singletonInstance == null)
{
var gameObject = new GameObject("_SceneLoader");
GameObject.DontDestroyOnLoad(gameObject);
singletonInstance = gameObject.AddComponent<SceneLoader>();
}
return singletonInstance.PrivateLoadScene(sceneName);
}
private IPromise PrivateLoadScene(string sceneName)
{
return new Promise((resolve, reject) =>
StartCoroutine(TheCoroutine(sceneName, resolve, reject))
);
}
private IEnumerator TheCoroutine(
string sceneName,
Action resolve,
Action<Exception> reject
)
{
yield return Application.LoadLevelAsync(sceneName);
resolve();
}
}

以下是调用代码

1
2
3
SceneLoader.LoadScene("Some-Scene")
.Then(() => Debug.Log("Loaded scene."))
.Catch(ex => Debug.LogException(ex, this));

确认你的场景已经添加到 build settings中,否则将会报错。
该例中用Application.LoadLevelAsync。同样你也可用例8的LoadLevelAdditiveAsync

若是使用Unity 5.3,上述函数可能已经被废弃了。你可以使用SceneManager function来实现相同的功能。

6. 异步加载bundle

本例展示如何异步载入asset bundle,然后实例化bundle并放入场景中。本例很有意思,因为可以使用promise的链式调用实现实例化后的操作。

首先创建asset bundle。可以依据Unity的文档实现。这里我简化了。

使用Unity Editor标记prefabs包含于bundles中。

在github例子工程中,我已经预制了一些bundle。依据Unity手册,需使用如下代码创建asset bundle。

1
2
3
4
5
6
7
8
9
10
11
12
using UnityEditor
public class AssetBundleCreator
{
[MenuItem("Custom/Build Asset Bundles")]
static void BuildAllAssetBundles()
{
BuildPipeline.BuildAssetBundles(
Path.Combine(Application.dataPath, "<sub-directory>")
);
}
}

老实说,难以理解为什么Unity要我们添加额外琐碎的代码来创建asset bundle这么一个重要的功能。他们为什么就不在Unity Editor中默认包含该选项呢。

AssetBundleCreator必须保存于 Editor目录下。它只能在编辑模式下使用。

现在看看如何用promise表达加载bundle。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class AssetBundleLoader : MonoBehaviour
{
private static AssetBundleLoader singletonInstance = null;
/// <summary>
/// Initiatiates an asynchronous load of an asset bundle.
/// Returns a promise that is resolved to the handle of the bundle.
/// </summary>
public static IPromise<AssetBundle> LoadAssetBundle(
string assetBundleFilePath
)
{
if (singletonInstance == null)
{
var gameObject = new GameObject("_AssetBundleLoader");
GameObject.DontDestroyOnLoad(gameObject);
singletonInstance =
gameObject.AddComponent<AssetBundleLoader>();
}
return singletonInstance
.PrivateLoadAssetBundle(assetBundleFilePath);
}
private IPromise<AssetBundle> PrivateLoadAssetBundle(
string assetBundleFilePath
)
{
return new Promise<AssetBundle>((resolve, reject) =>
StartCoroutine(TheCoroutine(
assetBundleFilePath,
resolve,
reject
))
);
}
private IEnumerator TheCoroutine(
string assetBundleFilePath,
Action<AssetBundle> resolve,
Action<Exception> reject
)
{
var www = new WWW("file://" + assetBundleFilePath);
yield return www;
if (!string.IsNullOrEmpty(www.error))
{
var msg = "Failed to load asset bundle " +
assetBundleFilePath + "\r\n" + www.error;
reject(new ApplicationException(msg));
}
else
{
resolve(www.assetBundle);
}
}
}

接着,载入bundle并链式实例化游戏对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var assetBundleFilePath =
Path.Combine(Application.dataPath, "<sub-directory>");
AssetBundleLoader.LoadAssetBundle(assetBundleFilePath)
.Then(assetBundle =>
{
Debug.Log("Loaded bundle.");
var allBundleObjects = assetBundle
// Load all game objects from the bundle.
.LoadAllAssets(typeof(GameObject))
// Cast all objects to the expected type.
.Cast<GameObject>();
var x = 0f;
// For simplicity let's just load all objects from the
// bundle and place them in a row.
foreach (var bundleObject in allBundleObjects)
{
Instantiate(
bundleObject,
new Vector3(x, 0f, 0f),
Quaternion.identity
);
x += 3f;
}
// Unload the bundle, keep loads objects in tact.
assetBundle.Unload(false);
})
.Catch(ex => Debug.LogException(ex, this));

本例中,我们简单的实例化bundle并放入场景中,这仅是一个人为的例子用于展示该技术。你可以寻到更好的方式来实现。

本例中,bundle在游戏对象实例化后被unloaded,你也可能保留bundle的缓存,用于后续的使用。

7. 组合例子

是时候把例子放在一起了,这样你就能更好感受到promise组合异步逻辑的链式威力。

本例中,我重构提取了一些帮助函数方便自由使用。这样能够更直观的看到链式promise,逻辑同时也很清晰,代码易懂同时bug难以隐藏。

1
2
3
4
5
LoadScene()
.Then(() => LoadBundle())
.Then(assetBundle => InstantiateGameObjects(assetBundle))
.Then(assetBundle => assetBundle.Unload(false))
.Catch(ex => Debug.ExceptionLog(ex, this));

具体代码详见github

8. 硬编码实现合并多场景

本例使用附加场景加载合并到多场景,以下是代码最重要的代码

1
2
3
4
AdditiveSceneLoader.LoadScene("MergeScene1")
.Then(() => AdditiveSceneLoader.LoadScene("MergeScene2"))
.Then(() => AdditiveSceneLoader.LoadScene("MergeScene3"))
.Catch(ex => Debug.LogException(ex, this));

同样的,Unity5.3 api和之前的不同,请相应更换。

9. 动态实现合并多场景

上例是硬编码实现。若是动态实现将更有用。有许多方式可以实现。

这里,我生成一个列表,然后使用LINQ的Aggregate折叠列表成promise序列。它实现场景一个接一个的载入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var scenesToLoad = new string[]
{
"MergeScene1",
"MergeScene2",
"MergeScene3",
};
scenesToLoad.Aggregate(
Promise.Resolved(),
(prevPromise, sceneName) =>
prevPromise.Then(() =>
AdditiveSceneLoader.LoadScene(sceneName))
)
.Then(() => Debug.Log("Loaded all scenes."))
.Catch(ex => Debug.LogException(ex, this));

上述代码看上去很吓人哩,这里有个简单易读的方式,它使用了Promise.All

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var scenesToLoad = new string[]
{
"MergeScene1",
"MergeScene2",
"MergeScene3",
};
Promise.All(
scenesToLoad.Select(sceneName =>
AdditiveSceneLoader.LoadScene(sceneName)
)
)
.Then(() => Debug.Log("Loaded all scenes."))
.Catch(ex => Debug.LogException(ex, this));

这里,Promise.All中所有的场景载入是同时的,不分先后。如果想按顺序载入它们,还是用LINQ的Aggregate吧。

结论

这仅是一篇短文介绍如何表示Unity的异步操作为promise的,代码在github上。

图标这么闪,你不点一下吗