UnityEditor と実機間で通信がしたい

UnityEditorとビルド後の実機との間で通信がしたい

表題の件を、色々と今まで試行錯誤しつつやっていたんですが、公式で PlayerConnection が実装され、晴れて自前実装がお役御免になりそうです。
なんか勿体ないので記録として残しておこうかと思って書きました。決してContribute稼ぎではな(ry

昔のお話

昔々、CD-ROMというメディアでゲームが提供されていた時代のことですが、当然開発中にテストをする際もCD-ROMにゲームを焼く(書き込む)必要がありました。

しかし、CD-ROMに焼くのも時間がかかる。下手したら現在のビルド〜転送よりも時間がかかる。更に言えば、当時のライターは焼きに失敗することもままあり、ブランクのCD-ROMもタダじゃない。

では、昔の人は実機で確認する際にどうしたのかというと、開発に使っているパソコン(プログラムを書いたりコンパイルしたりする)のHDDを実機からCD-ROMドライブに見えるようにエミュレートしてました。そうすると、実行されているプログラムからCD-ROMのファイルを読もうとした際に、自動的にHDDから読み込むようになっていました。

現代でよくある話

  • パターン1

    1. UnityEditor上で開発、良い感じになったのでビルド、実機(iOS/Android等)へ転送
    2. 実機で動いたけど、動いたけど……ちょっと操作感覚が気にくわないから調整するか
    3. UnityEditor上で調整、再度ビルド、実機へ転送
    4. 実機で確認。うーんなにか違うな……
    5. 再度ビルド……
  • パターン2

    1. UnityEditor上で開発、今度はマスターデータを外部から読み込むようにしたのでいちいちビルドしなくて平気だぜ
    2. マスターデータが一カ所にしかない、あっても本番と検証の二カ所程度なので、複数人でいじると死ぬ

どちらも、ビルド〜転送が秒で完了すれば解決する話なんですが、それは現実的ではない。

もしくは、UnityRemoteでなんとかするというのもありますが、公式的にもあまり活用されていないので、微妙感。

調整したいものが、数値のような単純なものであれば実機用のデバッグメニューを用意して調整することも可能ですが、限界もあります。

UnityEditorはよくできてますし、たいていの場合UnityEditor上で動くものは実機でも同じように動きます。また、外部のゲームコントローラを使うようなゲーム機の場合はコントローラが同じなら、UnityEditorでもそこそこ操作感覚は変わりません。

現代で解決するには

実機とホストで通信が簡単に行えて、ファイルの転送ができれば良いんで、構造的に採用したのは、

  • ホスト側でHTTPサーバを立てる
  • 実機側からホストにHTTPリクエストを投げる

という方法を採用しました。

初期はホスト側のHTTPサーバをRubyで実行してたりしたのですが、Editorと別に起動しなくてはいけない点が面倒くさくて、
最終的にはEditorWindow内で HttpListener を使ってHTTPサーバを実行しました。

具体的な実装方法

ここから、明日から役に立たないUnity情報です。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
public class HostIOWindow : EditorWindow {
[MenuItem("Window/HostIO")]
static void Open() {
var win = (HostIOWindow)EditorWindow.GetWindow(typeof(HostIOWindow));
win.Show();
}
static HostIOWindow _instance = null;
HttpListener _listener;
Task<HttpListenerContext> _task;
IPAddress _local;
byte[] _ip;
//
void Awake() {
if (!HttpListener.IsSupported) {
throw new NotSupportedException("not supported");
}
}
void Update() {
if (_task == null) {
_task = _listener.GetContextAsync();
} else {
if (_task.IsCompleted) {
var ctxt = _task.Result;
try {
RequestHandler(ctxt);
}
catch (WebException e) {
ErrorHandler(ctxt, e);
}
finally {
_task.Dispose();
_task = null;
}
} else
if (_task.IsCanceled) {
_task.Dispose();
_task = null;
} else
if (_task.IsFaulted) {
_task.Dispose();
_task = null;
}
}
Repaint();
}
// サーバ起動
void StartWebServer() {
StopWebServer();
_task = null;
_ip = HostIOUtil.DetectLocalAddress();
_listener = new HttpListener();
_listener.Prefixes.Add("http://localhost:4649/");
var url = string.Format("http://{0}.{1}.{2}.{3}:4649/", _ip[0], _ip[1], _ip[2], _ip[3]);
_listener.Prefixes.Add(url);
_listener.Start();
Debug.Log("START WEB SERVER => " + url);
}
// リクエストハンドラ
void RequestHandler(HttpListenerContext ctxt) {
var req = ctxt.Request;
var res = ctxt.Response;
var pathelem = req.RawUrl.Split('/').ToList();
pathelem.RemoveAll((e) => string.IsNullOrEmpty(e));
if (pathelem.Count > 0) {
switch (pathelem[0]) {
case "assets":
if (pathelem.Count == 1) {
RequestHandler_file(ctxt, "/");
} else {
var pathname = "/" + Path.Combine(pathelem.GetRange(1, pathelem.Count - 1).ToArray());
pathname = pathname.Replace('\\', '/');
RequestHandler_file(ctxt, pathname);
}
break;
case "ping":
RequestHandler_ping(ctxt);
break;
default:
throw new WebException("unknown API");
}
} else {
throw new WebException("unknown API");
}
}
}

色々端折ってますが、だいたいこんな感じでUnityEditor側のサーバを起動します。

次は実機側ですが、以下のような感じになります。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
public class HostIO : MonoBehaviour {
static HostIO _instance = null;
static byte _pinCode = 0;
static StringBuilder _addressBase = new StringBuilder(1024);
static int _addressBaseRewindLength;
static bool _connect;
static byte[] _ip;
static readonly string _saveKey = "HostIO";
static readonly string _savePINCodeKey = _saveKey + "_PINCode";
static float _wait = 1.0f;


public static HostIO Instance {
get { return _instance; }
}
public static byte PINCode {
get { return _pinCode; }
set {
_pinCode = value;
_addressBase.Clear();
_addressBase.Append(string.Format("http://{0}.{1}.{2}.{3}", _ip[0], _ip[1], _ip[2], value));
_addressBase.Append(":3751");
_addressBaseRewindLength = _addressBase.Length;
Debug.Log("SERVER: " + _addressBase.ToString());
_wait = 0.0f;
}
}
public static bool IsConnected {
get { return _connect; }
}
void Awake() {
#if UNITY_EDITOR
// UnityEditor上で起動した場合は不要なので死ぬ
_ip = null;
Destroy(this);
#else
// みんな大好きシングルトン
if (_instance != null) {
Destroy(this);
} else {
_connect = false;
_instance = this;
DontDestroyOnLoad(gameObject);
_ip = HostIOUtil.DetectLocalAddress();
PINCode = (byte)PlayerPrefs.GetInt(_savePINCodeKey, 0);
}
#endif
}
void OnDestroy() {
if (_instance == this) {
PlayerPrefs.SetInt(_savePINCodeKey, (int)PINCode);
PlayerPrefs.Save();
_connect = false;
_instance = null;
}
}
void Start() {
StartCoroutine(ConnectServer());
}
// 定期的にHTTPサーバにPingしてサーバが生きているかどうかチェックする
IEnumerator ConnectServer() {
while (true) {
if (PINCode != 0) {
using (var webreq = UnityWebRequest.Get(MakeAPIUrl("/ping"))) {
yield return webreq.SendWebRequest();
if (webreq.isNetworkError || webreq.isHttpError) {
_connect = false;
} else {
_connect = true;
}
}
} else {
_connect = false;
}
yield return new WaitForSecondsRealtime(1.0f);
}
}
// 同期的にファイルを読む
public static byte[] LoadFile(string path) {
using (var webreq = UnityWebRequest.Get(MakeAPIUrl("/assets" + path))) {
var async = webreq.SendWebRequest();
while (!async.isDone) {
Thread.Sleep(1);
}
var req = async.webRequest;
if (req.isNetworkError || req.isHttpError) {
return null;
}
return req.downloadHandler.data;
}
}
// 非同期的にファイルを読む
public static UnityWebRequestAsyncOperation LoadFileAsync(string path) {
using (var webreq = UnityWebRequest.Get(MakeAPIUrl("/assets" + path))) {
return webreq.SendWebRequest();
}
}
}

これまた色々端折ってますが、だいたいこんな感じです。

注意点としては、 Resources などのように UnityObject として返ってくることがない点です。あくまで byte[] として読めるだけです。その後の処置に関しては、そこそこ苦労しています。

いきなりソースをぶちまけてみましたが、簡単に流れを説明すると、

  • UnityEditor側
    1. EditorWindowを作る
    2. その中でHttpListenerを作り、待ち受けを開始する
    3. ホストのIPアドレスを取得しておき表示する
    4. HTTPリクエストがあったらそれに応じて処理する
  • 実機側
    1. 指定されたIPアドレスと通信を開始する
    2. 定期的にPingを発して通信状況を確認する
    3. 何かしらファイルが読みたくなったらHTTPサーバに問い合わせる

実機側でのIPアドレス指定ですが、SRDebuggerを利用し、デバッグメニューから指定できるようにしてあります。また、一度指定したアドレスは PlayerPrefs に保存しておき、次回以降は使い回します(アドレス振り直しは頻繁に起こらないだろうという予測)。

具体的な用例

  • xLuaのようなスクリプトで処理を記述するゲームの場合、スクリプトを修正する度にビルドしてたら日が暮れるので、ホストPC上のスクリプトを更新したら実機側でそのままリロードできるようにしました。
  • StreamingAssets に埋め込むようなAssetBundleを更新する度にビルドしてたら……なので、リロードできるようにしました。

最後に

長々と書いてきましたが、今まで書いた内容はほぼ無効です。 PlayerConnection を使いましょう。もっと簡単に安定して通信が出来るはずです。