Unityにおける通信APIを色々試して罠を踏んだ話
初めまして、2014年新卒入社でQualiArtsの技術基盤グループに所属する石黒です。グループ名の通り、特定のプロダクトを開発しているわけではなく、共通で使われる様々な基盤サービスの開発に携わっています。例えばユーザー認証、課金、リアルタイム通信、チャットなどなど。その中に、弊社のUnityアプリ共通で使われているAssetBundle配信基盤Octoというものがあります。
Octoは、Unityアプリを開発する上で欠かすことのできない要素の1つであるAssetBundleを、管理・配信するための基盤となります。開発・運用コストを下げるためや品質を担保するために、全サービス共通化を目指してOctoの開発が2015年秋頃にスタートしました。Octoを初めて組み込んだサービスがリリースされたのは2016年夏にリリースされたオルタナティブガールズで、それ以降ボーイフレンド(仮)きらめき✩ノートやエンドライド -X fragments-にも組み込んでリリースされています。
そんなOctoは、サーバーはもちろん開発が必要でしたが、ゲームの自由度を上げるためと標準のUnityのAPIよりパフォーマンスを出すため、クライアントSDKも同時に開発しました。そこで今回は、そのOctoのクライアントSDKを実装して得られた、Unityにおける各種通信APIを試行錯誤した話を紹介します。
通信の重要性
まずなぜ通信が重要視されるかと言えば、以下のようにスマホにおける通信について考慮 すべき課題が多数あるからです。
- 通信によって待たされる時間
- 通信するための費用
- 通信することによって消費するバッテリー
- 通信異常時の適切なハンドリング
- 通信内容の保護
そのため、通信がユーザー体験に与える影響は非常に大きいものになります。
エンドユーザーの通信環境は年々改善されていますが、通信が必要なくなることはないと容易に想像できるので、開発者はこれからもずっと向き合っていかなければいけません。これらの課題に対応するため、より一層通信APIを使いこなすことが求められていきます。
Octoでの通信パターン
以降具体的にUnityの通信APIについて説明していきますが、Octo的な事情も通信部分の設計に反映されています。そこで具体的な説明の前に、まずはOctoでの通信APIを設計する大前提を説明します。Octoで実際に扱う通信パターンは以下の2種類です。
- 1シーケンスあたり1本程度のAPIサーバーとの通信
- 1シーケンスあたり1〜1000本程度のAssetBundleダウンロード処理
※ 1シーケンス=一連の処理のかたまり
前者は通信頻度・量が少なく問題が表面化しにくいのですが、後者は通信頻度・量が多いため、ユーザー体験に与える影響はかなり大きいです。そこでOctoでは後者のパフォーマンス最適化のために様々な通信APIを試しましたが、ダウンロード処理=ファイル書き込みが発生することを前提に検証しています。通常の通信処理とは直接関係ない話もありますが、ファイル書き込みもストリーム処理として捉えれば一般化可能なので、他にも応用可能です。
Unityにおける4種類の通信API
Unityにおいて、主に以下の4種類の通信手段が存在します。
WWW
(古くからのUnityで提供されているAPI)UnityWebRequest
(Unity 5.4系より正式提供されているAPI)HttpWebRequest
(C#標準で提供されているAPI)- ネイティブプラグインを利用した通信処理の独自実装
アセットストアで配布されているものも、これらのいずれかに属していると考えられます。
(厳密には、独自にC#でゼロから実装するなども考えられますが、それは3に属すると考えます。)
WWW
WWWは古くからUnityで提供されているAPIで、コルーチンとの親和性や記述の少なさと言った点で他のAPIより優れています。例えば受信したbodyをそのままファイルに書き込む場合、以下のような実装になります。
IEnumerator Download(string url, string path) {
WWW www = new WWW(url);
yield return www;
File.WriteAllBytes(path, www.bytes); // need to handle error
}
エラー処理を省いているとは言え、僅か5行ととても記述が少なく済みます。またファイル書込を同期的にやっていてメインスレッドを止めてしまっていますが、実際には書き込み処理を非同期化することも可能です。
Unityの内部実装的には、WWWを通してネイティブAPIを操作していることがこの資料からわかります。ということは、事実上4番のネイティブプラグインの独自実装に等しいと言えます。ところがWWWには、以下のようなデメリットが考えられます。
www.bytes
が返すbyte[]
は、ヒープ領域にbodyのサイズに等しい大きいゴミを残して、GCやメモリ断片化を促進する- 受信が全て完了してから処理を始めるので、受信にかかる時間を有効活用できない
- ネイティブ側からC#へのメモリコピーコストが存在する(本来ネイティブ側で完結させられるのであれば)
ファイルサイズが大きいほど、これらの影響を深刻に受けます。特にガベージコレクト(GC)によるstop the worldを誘発しやすくするので何とかしたいところですが、これを回避するためにはWWWを使わないということに至ります。そのためOctoではWWWを採用しませんでした。
UnityWebRequest
UnityWebReuqestはUnity 5.2より実験的に導入され、5.4より正式機能となった、WWWに代わる次世代通信APIです。WWWは設計的に非常に柔軟性を欠いたAPIで、高度な制御ができませんでしたが、UnityWebRequestではAPIの自由度が格段に増え、様々なことが実現できるようになりました。
実装自体もWWWほど単純ではないものの、ミニマムなファイルダウンロード処理であれば以下のように簡単に記述できます。
IEnumerator Download(string url, string path) {
using (UnityWebRequest request = UnityWebRequest.Get(url)) {
yield return request.Send();
File.WriteAllBytes(path, request.downloadHandler.data); // need to handle error
}
}
こちらも内部実装的にはWWWと同じく、ネイティブプラグインの独自実装に等しいと考えられますので、コピーコストは回避できません。ただしUnityWebRequestの強みである、DownloadHandlerのカスタム化のためのDownloadHandlerScript
を併用することで、単一固定長バッファを用いた受信データの逐次処理が可能となるため、WWWで問題となったような大きいメモリのゴミや無駄な時間は回避することができます。
UnityWebRequestの 避けられないメインスレッド制約
そこでOctoでも初期段階からUnityWebRequest+DownloadHandlerScriptによる固定長バッファの非同期ストリーム処理を採用していますが、受信データの逐次処理が必ずメインスレッドから呼ばれるという制約というUnityWebRequest特有のパフォーマンス的な問題に直撃しました。
このメインスレッド制約はUnityWebRequestの仕様であるため、回避不可能です。全て非同期で処理したいのにメインスレッドにいちいち回していたら効率が悪いどころか、ゲームの進行にも影響を与えてしまいます。とは言えWWWと比較してパフォーマンス上の問題が少ないのも事実なので、この制約を飲まざるをえません。
WWW/UnityWebRequestを使うとiOSのキャッシュ領域が肥大化する?
メインスレッド制約以外にも、iOSの通信のキャッシュ機構に起因した問題も存在します。
もともとiOSには通信のレスポンスをキャッシュするNSURLCache
と呼ばれる仕組みがあり、これを活用すると通信量や時間を節約できます。更新頻度が低くて何回もアクセスされるAPIなどに使うと効果的なのですが、デメリットとしてこれを使うことによってストレージを少なからず圧迫します。
そして少なくともUnityWebRequest+DownloadHandlerScriptの場合、このNSURLCacheにレスポンスがキャッシュされてしまう場合があることを確認しました。Octoでは数百KB〜数MBぐらいの小さいファイルを大量にダウンロードするのですが、これの一部がキャッシュされてしまい、結果的にストレージ全体を数百MB程度圧迫するケースを確認しました。
詳しいキャッシュ条件は不明で、端末やOSバージョンの組み合わせや、他のDownloadHandlerやWWWの場合でも当てはまるかは未調査ですが、ユニティ・テクノロジーズ・ジャパンもキャッシュされないようにキャッシュコントロールヘッダを付けることを推奨しているので、 クライアントもしくはサーバー側にて何らかの対策が必要です。
HttpWebRequest
Octoでは次に、HttpWebRequestも使ってみることにしました。HttpWebRequestはUnityのAPIではなく、C#標準ライブラリに存在するため、Unityの妙なメインスレッド制約を受けることなく実装できるからです。
実際に実装してみると、UnityWebRequestを利用していた頃より遥かに効率良くダウンロードとファイル書込を実現できました。これはメインスレッド制約を受けず、またネイティブ側からの無駄なメモリコピーコストが発生しないためです。パフォーマンス的にこれなら行けると判断できましたが、ここにも幾つかの罠が潜んでいました・・・
SSL/TLSの証明書を検証できない罠
検証し始めてすぐに、Android実機で正常に通信できないことに気付きました。SSL/TLS通信で必須の証明書の検証に失敗するというエラーでした。よくよく調べてみると、monoの実装においてはOSの証明書ストアを用いた検証は実装されていな いため、本来はデフォルトで一切の検証ができないということでした。しかしながらMacやiPhoneにおいては、何故か検証に成功してしまいます。
これの理由を探ると、以下のソースに辿り着きました。
https://github.com/Unity-Technologies/mono/blob/unity-staging/mcs/class/System/System.Security.Cryptography.X509Certificates/OSX509Certificates.cs
(Unityがフォークしているmonoのソースです)
どうやら、OSX/iOS用にSecurity.framework
を用いた証明書の検証がきちんと実装されているようです。(ちなみにこのコードはAndroidとかでも動作してしまっているみたいで、プラットフォームが違うのにライブラリが読み込めないというエラーを実は吐いています。)
Androidでは動かないことはわかりましたが、それでもパフォーマンス的に有利だったため、iOSだけでもHttpWebRequestを採用することにし、リリースしました。
※巷ではServicePointManager.ServerCertificateValidationCallback
で常にtrueを返すようにすることで、証明書の検証をスキップできるように実装している例がありますが、大変危険なのでやめましょう。
HttpWebRequestはPOSIX系のAPIを利用したソケット通信で動く?
HttpWebRequestはリリースして暫くは問題ありませんでしたが、次第に以下のような問題がiOSであるという報告を頂きました。
-
端末のSIMによっては、通信エラー時に復帰後も通信リトライが無限に続く
-
3G/4Gで通信を始めた後、WiFiが接続されても3G/4Gで通信を継続してしまう場合がある
- Keep Aliveしているセッションが切れないため
- Xcode上でデバッガを繋いで、セッションのインターフェイスを監視して確認
- ユーザーにとって潜在的なパケ死リスクがある
これらの問題から、HttpWebRequestはPOSIX系のAPIを利用した純粋なTCPのソケット通信を行うような内部実装になっているのではないかという推測に至りました(推測であって実装を確認したわけではありません)。試しにOSの設定からプロキシを設定しても、プロキシを通らなかったためです。
純粋なソケット通信だけでは、OSのサポートを受けられずにモバイルのような複雑な通信環境に対応しきれないため、結果的にはHttpWebRequestを廃止することになりました。ちなみにAppleも公式にPOSIXは出来る限り回避しろと言及しています。
ネイティブプラグインを利用した通信処理の独自実装
最後に、通信処理をネイティブプラグインで独自実装すると、今回のように単純にファイルをダウンロードしたいような場合にパフォーマンスの最適化はもちろん、様々な恩恵に授かることができます。
-
OSのAPIを利用することで、OSの総合的なサポートを受けられる
- 例えば通信環境が変化した時の自動的なフォールバック処理など
-
受信からファイル書込までネイティブ側で完結できるので、C#側への無駄なメモリコピーコストが発生しない
-
メインスレッドに負荷をかけずに処理を完結できる
まさに夢 のようですが、以下のような問題と向き合っていく必要もあります。
-
実装コストが少なくない
- 汎用的な設計ではないものの、ミニマムで専用設計すればそこまで大きくもない
- プラットフォーム毎に記述が必要
- バグの原因になる
-
保守出来る人が必要
- OSのAPIの変更にある程度追従する必要がある
- C#だけでなく、ネイティブも書ける人が必須
これらデメリットを認識した上で、それでもなお独自実装するメリットは大きいです。
実際にUnityWebRequest実装を独自ネイティブ実装に置き換えてみた
実際にOctoでファイルダウンロード処理をUnityWebRequest+DownloadHandlerScriptから、ネイティブプラグイン化した場合のiOSのパフォーマンス変化を計測してみました@iPhone7+iOS10
UnityWebRequest+独自DownloadHandlerScriptを用いたファイルダウンロード
↓↓↓↓↓↓
NSURLSession+NSURLSessionDownloadTaskを用いたファイルダウンロード
CPU使用率・メモリ使用率はもちろん、受信と書き込みのスループットも改善され、同じデータをダウンロードするのに約3倍速くなりました。(具体的な値については環境に依存します)
CPU使用率が低く、メモリ使用量も全く増加しないのは、全ての処理がOS側で完結しているためユーザープロセス分としてカウントされていないと考えられます。そのため実際のOS全体のCPU使用率で見ると、また違う結果になると考えられます。
Androidについても、端末依存の部分やそもそも通信自体が端末全体に与える負荷が大きく、iOSほどとはならないもののパフォーマンスが改善されスループットが向上しました。
Octo流通信処理の最適解
以上を踏まえて、現状のOctoの通信処理は以下のようになっています。
- iOS, Android: 独自ネイティブプラグイン実装
- 各種エディタ、スタンドアローン: UnityWebRequest
スマホ向けでは、コストを払ってでもネイティブ実装を採用しています。これは上記実験の通り、ユーザー体験を最大限高めることが一番の目的です。基盤化することで一括で保守できるからこそ、こういった部分にコストを払うことも難しくなくなります。
一方でエディタ、スタンドアローンではハードウェアのスペックに余裕があるのと通信環境が安定しているため、UnityWebRequestでもただちに問題とはなりません。そのためワンソースで全環境対応できるメリットを最大限活かして、今のところネイティブ実装することは検討していません。
このようにOctoでは、環境によって実装方針を使い分けることで、パフォーマンスと保守性を両立させています。
最後に、この記事を通して、UnityWebRequestにファイル保存用のDownloadHandlerがいつの日かできることを切に願います。