モバイルアプリ上の WebAssembly 製ライブゲームで発生した例外を捕捉して計測する - Mirrativ Tech Blog

Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

モバイルアプリ上の WebAssembly 製ライブゲームで発生した例外を捕捉して計測する

こんにちは、エンジニアのちぎら(@_naru_jpn)です。ミラティブでは、配信中のゲームに視聴者が介入できるゲームとライブ配信が融合した次世代のゲーム体験を提供しており、この体験を ライブゲーミング と呼んでいます。

ライブゲーミングは、Unity から WebGL 向けにビルドされた WebAssembly 製のゲームを、アプリに配置したウェブブラウザ上で動作させることによって実現しています。*1

今回は UnityでモバイルWebGLゲーム開発を頑張る話 の「メモリリークによって発生するクラッシュ」にも書かれているような、捕捉は難しいがユーザー影響があるような例外の発生を捕捉して、計測をするための仕組み作りについて解説をします。

ライブゲームが動作する仕組み

例外を捕捉することを考える前に、ライブゲームがどのように動作しているのかを知る必要があります。

Unity の WebGL 向けビルドで生成される成果物

Unity の Build Settings で Platform を WebGL に向けてビルドをすると、いくつかファイルが生成されます。主なファイルは以下のようなものです。

Unity WebGL向けビルドの主な成果物

  • index.html
    • ブラウザで読み込まれるWebページ
  • framework.js
    • WebAssembly バイナリのインスタンス化、WebAssembly から呼び出される JavaScript 上の関数の指定などを行う
  • loader.js
    • ブラウザが対応しているかなどの前処理を行う
  • wasm
    • WebAssembly バイナリ
  • data
    • アセットなどのデータを含むファイル

ウェブビュー上で WebAssembly 製ライブゲームが起動する流れ

ライブゲームの起動に必要なファイルと概要は上で述べました。以下にそれぞれのファイルの関係性を図示します。

ブラウザの index.html から wasm のインスタンス化まで

index.html に書かれている WebAssembly 製アプリケーションの起動に関わるコードを抜粋します(実際のファイル名にはプロジェクト名が含まれていたりしますが、見やすくするために省略しています)。

var buildUrl = "build";
var loaderUrl = buildUrl + "/loader.js";
var config = {
  dataUrl: buildUrl + "/data",
  frameworkUrl: buildUrl + "/framework.js",
  codeUrl: buildUrl + "/wasm",
  // ...
};

// ...

var script = document.createElement("script");
script.src = loaderUrl;
script.onload = () => {
  createUnityInstance(canvas, config, (progress) => {
    // ...
  });
};
document.body.appendChild(script);

loaderUrl には loader.js ファイルのパスが指定されていて、 createUnityInstanceloader.js 上に定義された関数です。loader.js から更に読み込まれる framework.js には、WebAssembly のインスタンス化を行うためのコードが書かれています。以下に framework.js にある WebAssembly のインスタンス化のためのコードを抜粋して記載します。 *2

// ...
function createWasm() {
  var info = {
    "env": asmLibraryArg,
    "wasi_snapshot_preview1": asmLibraryArg
  };

  // ...

  function instantiateArrayBuffer(receiver) {
    return getBinaryPromise().then(function(binary) {
      var result = WebAssembly.instantiate(binary, info);  

WebAssembly.instantiate は、WebAssembly のインスタンス化のためのコードです。 *3 第一引数は WebAssembly のバイナリコード、第二引数には WebAssembly から呼び出したい JavaScript 側の関数などの情報を指定します。

info に含めている asmLibraryArg の内容も確認してみましょう。

function _abort() {
 abort();
}

// ...

var asmLibraryArg = {
 "JS_Accelerometer_IsRunning": _JS_Accelerometer_IsRunning,
 "JS_Accelerometer_Start": _JS_Accelerometer_Start,
 "JS_Accelerometer_Stop": _JS_Accelerometer_Stop,
  // ...
 "abort": _abort,
  // ...

_JS_Accelerometer_IsRunning_abort は JavaScript 側に定義されている関数です。これらの情報を WebAssembly に渡すことで、WebAssembly から JavaScript の関数を呼び出すことができるようになります。試しに wasm バイナリを WebAssembly テキスト形式に変換し*4、WebAssembly バイナリ内で _abort 関数がどのように扱われているかを確認してみます。

...
(import "env" "_munmap_js" (func $fimport$7 (param i32 i32 i32 i32 i32 i32) (result i32)))
(import "env" "abort" (func $fimport$8))
(import "env" "invoke_ii" (func $fimport$9 (param i32 i32) (result i32)))
...

上記は「外部から渡された env オブジェクトに含まれる abort を、WebAssembly 側では $fimport$8 という関数とみなす」ということを宣言しています。

ここまでで WebAssembly がブラウザ上で読み込まれ、JavaScript との連携が設定される大体の流れが分かっていただけたのではないかと思います。

例外が発生した際に起こることを調べる

例外が発生した場合の挙動は iOS と Android で異なります。Android の方が比較的シンプルな挙動になっているので、まずは Android の例外発生時の挙動について記載します。

例外発生時の挙動(Android)

Android で例外が発生した場合、ブラウザ上に例外発生時のスタックフローの情報を含んだアラートが表示されます。

Android の例外発生時にブラウザ上に表示されるアラート

エラーメッセージなどからこのアラートの発生源を探ると、loader.js でアラートの表示処理が行われていることがわかります。errorHandler 関数の引数 message には、例外発生時のスタックトレースを含む情報が含まれています。

function errorHandler(message, filename, lineno) {
  // ...
  var message = "An error occurred running the Unity content on this page. See your browser JavaScript console for more info. The error was:\n" + message;
  if (message.indexOf("DISABLE_EXCEPTION_CATCHING") != -1) {
    message = "An exception has occurred, but exception handling has been disabled in this build. If you are the developer of this content, enable exceptions in your project WebGL player settings to be able to catch the exception or see the stack trace.";
  } else if (message.indexOf("Cannot enlarge memory arrays") != -1) {
    message = "Out of memory. If you are the developer of this content, try allocating more memory to your WebGL build in the WebGL player settings.";
  } else if (message.indexOf("Invalid array buffer length") != -1  || message.indexOf("Invalid typed array length") != -1 || message.indexOf("out of memory") != -1 || message.indexOf("could not allocate memory") != -1) {
    message = "The browser could not allocate enough memory for the WebGL content. If you are the developer of this content, try allocating less memory to your WebGL build in the WebGL player settings.";
  }
  alert(message);
  errorHandler.didShowErrorMessage = true;
}

Module.abortHandler = function(message) {
  errorHandler(message, "", 0);
  return true;
};

上記の errorHandlerModule.abortHandler を介して呼び出されていることが分かります。更に Module.abortHandlerframework.jsabort 関数の内部で呼び出されています。

var abort = function(what) {
  // ...
  var message = "abort(" + what + ") at " + stackTrace();
  if (Module.abortHandler && Module.abortHandler(message)) return;

ここまでの追跡から、WebAssembly の内部で例外が発生した場合の処理のフローは以下のようなものであると想像できます。

例外に付随して発生する処理(Android)

例外発生時の挙動(iOS)

iOS では、例外が発生した際に ブラウザのコンテンツが自動的にリロードされる 挙動があります( index.html が再読み込みされます )。

更に、iOS で例外が発生してブラウザのコンテンツが再読み込みされた際には、ブラウザを管理するインスタンス上で WKNavigationDelegate に定義された webViewWebContentProcessDidTerminate(_:) が必ず呼ばれます*5

この再読み込みの挙動も加わり、例外の発生時に Android と同じ処理フローを想定すればいいのかどうかが不明瞭になります。ログなどで観測したところによると、Android と同様の処理フローに到達する場合もあるし、到達しない場合もあるように見えました。

例外に付随して発生する処理(iOS)

例外を計測する

ここまで WebAssembly で例外が発生した際に付随して発生する処理を確認してきました。各 OS ごとに例外を計測する方法について記載をします。

例外の計測(Android)

Android についてはアラートを表示する処理が例外の発生毎に実行されることが分かっていますので、アラートを表示している処理付近の JavaScript のプログラムに手を加え、JavaScript とアプリのブラウザ間で例外に関する情報を共有できるようにします。

function errorHandler(message, filename, lineno) {
  // ...

  var message = "An error occurred running the Unity content on this page. See your browser JavaScript console for more info. The error was:\n" + message;
  if (message.indexOf("DISABLE_EXCEPTION_CATCHING") != -1) {
    message = "An exception has occurred, but exception handling has been disabled in this build. If you are the developer of this content, enable exceptions in your project WebGL player settings to be able to catch the exception or see the stack trace.";
  } else if (message.indexOf("Cannot enlarge memory arrays") != -1) {
    message = "Out of memory. If you are the developer of this content, try allocating more memory to your WebGL build in the WebGL player settings.";
  } else if (message.indexOf("Invalid array buffer length") != -1  || message.indexOf("Invalid typed array length") != -1 || message.indexOf("out of memory") != -1 || message.indexOf("could not allocate memory") != -1) {
    message = "The browser could not allocate enough memory for the WebGL content. If you are the developer of this content, try allocating less memory to your WebGL build in the WebGL player settings.";
  }
  alert(message);
  errorHandler.didShowErrorMessage = true;
  /*
    この辺りにクラッシュ情報をアプリに共有するための処理を加える
  */
}

例外に関する情報を受け取ったアプリは、例外を計測するための情報としてサーバーにログを保存します。これにより、例外の発生回数や例外発生時のスタックトレースが確認できるようになりました。

例外の情報をログとして送信する流れ(Android)

例外の計測(iOS)

iOS ではAndroid と同様の処理フローに到達しない場合があることを上で見ました。従って、iOS で例外の発生回数を把握するためには Android で考えた処理フローだけでは足りません。代わりに、例外発生時に必ず実行される webViewWebContentProcessDidTerminate(_:) の実行回数を例外の発生回数とみなすなどの対応が必要になります。

func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  /* この辺りでログを発行し、iOS ではこのログの発生回数を例外の発生回数とみなす */
}

また、稀にではあるものの iOS でも Android で想定した処理フローが実行されることもあり、これを介して iOS も同様に例外発生時のスタックトレースなどを記録するようにしています。

例外の発生と例外の情報をログとして送信する流れ(iOS)

まとめ

上のような取り組みを通じて、定性的にしか把握できていなかった安定性についての情報が定量的に計測できるようになりました。

  • ゲームプレイに対する例外の発生割合
  • 例外ごとの発生頻度
  • 例外発生時のスタックトレース
  • 例外発生数の内で各端末が占める割合

例えば、iPhone8 のような比較的古くスペックの低い端末ではゲームが不安定になることが体感的には知られていましたが、定量的には測れていませんでした。実際に例外を計測することによって、やはり iPhone8 で発生する例外が多いことが分かりました。

iOS端末全体の例外発生数の内、約46%を iPhone8 が占めている日もある

こういった数値を、感覚ではなく定量的に議論できるようにすることは正しい判断をするために大切だと感じています。動作環境の差やメモリの制約の多い WebAssembly 製のゲームではありますが、状況を正確に把握できるようにして、今後も少しでもユーザーのプレイ環境の改善に繋がる仕組みを作っていけたらと思っています。

We are hiring!

ミラティブではライブゲーミングやライブ配信をはじめ、技術的にもおもしろいサービス開発に取り組んでいます!話を聞いてみたいなど興味を持っていただける方がいらっしゃったら、ぜひ気軽にお声がけください!

mirrativ.co.jp

mirrativ.notion.site

speakerdeck.com

*1:仕組みについては過去の記事 MirrativにおけるUnityのWebGLを用いたライブゲームの仕組み でも紹介されています。

*2:ここで記載しているコードは、Unity 側で Build Settings の Development Build を有効にした場合のコードです。Development Build を無効にすると、最適化が行われて人間にとっては非常に処理が追いづらいコードになります。

*3:https://developer.mozilla.org/ja/docs/WebAssembly/JavaScript_interface/instantiate_static

*4:例えば wabt をインストールして wasm2wat を実行することによって wasm バイナリを WebAssembly テキスト形式に変換できます。

*5:https://developer.apple.com/documentation/webkit/wknavigationdelegate/1455639-webviewwebcontentprocessdidtermi