TyranoScriptのWKWebViewメモリー最適化とクラッシュ対策

[CYBIRD Advent Calendar 2024](https://qiita.com/advent-calendar/2024/cybird) の16日目担当の [@alexk](https://qiita.com/alexk) と申します。
サーバサイドエンジニア、近頃はフロントエンドも担当しております。
15日目は [@cy-tatsuya-sakai](https://qiita.com/cy-tatsuya-sakai) さんの[「【Unity】シェイプマッチング弾性体の実装」](https://qiita.com/cy-tatsuya-sakai/private/93d77e0a8b114233ffc3)でした。

TyranoScriptは手軽にビジュアルノベルを制作できる便利なツールです。しかし、スマホでは最適化が不十分であるため、特にメモリー消費に関する課題が浮上します。本記事では、TyranoScriptをWKWebViewで使用する際のメモリー消費を最適化し、WebViewのクラッシュを防ぐための対策を提案します。なお、これはTyranoScript v5に関する内容ですが、v6でこの問題が解消されているかどうかは確認できていません。

## 1. TyranoScriptとは

TyranoScriptは、ビジュアルノベルやインタラクティブなストーリーを簡単に制作できるJavaScriptベースのエンジンです。豊富な機能と柔軟なカスタマイズ性により、PC向けのゲーム開発において広く利用されています。しかし、スマートフォンなどのモバイルデバイスでは、PCに比べてハードウェアリソースが限られているため、最適化が必要となります。


## 2. 問題定義

TyranoScriptをWKWebViewで起動すると、特にアニメーション時などに瞬間的に数百MBのメモリーが消費され、総メモリー使用量が1GBを超えることがあります。iPhone 6やiPhone 8のようなメモリーが少ないデバイスでは、WebViewが強制終了される現象が発生します。通常、1GBを超えるメモリー使用時でも自動で解放されるため問題ありませんが、急激なメモリー増加によりクラッシュが引き起こされます。TyranoScript自体は1GBを超えない場合でも、他の重いアプリと組み合わせることで容易にメモリーオーバーに陥ります。


## 3. メモリー検証

メモリー消費を検証する方法はいくつかありますが、今回はXcodeのActivity Monitorを使用しました。スマホをPCに接続し、Xcodeのメニューから Services > Activity Monitor を開きます。次に、左上に表示されているデバイス名をクリックし、スマホを選択します。その後、左上のレコーディングボタンをクリックし、下部に表示された一覧からwebviewのプロセスを探して、「Memory」欄を確認します。

![スクリーンショット 2024-12-12 19.04.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3951208/0454bef1-bdc3-1fe5-2103-452bee1d5af8.png)


検証の結果、[kanim]タグや[camera]タグ、ストーリーログを開く際にメモリーが異常に消費されることが確認されました。これらの共通点は、a3d（jquery.a3d.js）を使用している点です。a3dはCSS3のKeyframe Animationを用いてキャラクターや背景の動きをアニメーションさせていますが、どうやらCSS3のアニメーションが異常にメモリーを消費しているようです。WKWebView側に根本的な問題があると考えられますが、今回はTyranoScriptのソースコードを修正することでこの問題を解決する方法を紹介したいと思います。


## 4. 対策

以下の対策により、クラッシュ率を95%以上削減できることを確認しています。ただし、WKWebViewの仕様上、例えば同時に複数の重いアプリを起動した場合などには空きメモリ不足によりクラッシュが発生することがあるため、100%解決されるわけではないことに注意してください。

### 1. [kanim]と[camera]のクラッシュ対策

CSS3のKeyframe Animationによるメモリー消費を抑制するため、jQueryのanimate関数に置き換える方法を採用しました。具体的な実装方法については、以下のサンプルコードをご覧ください。

### 2. ストーリーログの表示時のクラッシュ対策

ストーリーログを開く際にクラッシュする原因を調査したところ、この部分ではa3dではなく、jQueryのfadeInおよびfadeOut（kag.menu.js）関数が使用されていることが判明しました。 
視覚的な滑らかさは減少しますが、fadeInやfadeOutをshowやhideに置き換えることで、メモリー消費を抑え、安定性を向上させることができます。

### 3. ストーリーログのスクロール時のクラッシュ対策

ストーリーログをスクロールする際にクラッシュが発生することが確認されました。原因は、.log_bodyに設定されたoverflowとtransformのCSS（tyrano.css）プロパティによるものでした。
この問題を解決するために、.log_bodyからoverflowとtransformを削除し、スクロール処理をJavaScriptで制御する方法を採用しました。具体的には、タップイベントを受け取り、jQueryのscrollTopを使用して手動でスクロールを実装することにより、スクロール操作を安定化させました。

## 5. コードサンプル

次に、CSS3のKeyframe AnimationをjQueryのanimate関数に置き換えるコードサンプルです。このコードはそのまま使用できるわけではありません。rotateなどに対応しておらず、バグを含んでいることもあるため、あくまで参考としてご利用ください。以下のコードでkag.tag_ext.jsをbeautifyしたものが編集されています。kag.tag_camera.jsのtyrano.plugin.kag.tag.cameraとreset_cameraも同じように修正する必要があります。

```js
/**
 * 元々横揺れがjQuery A3D（css animation）で動いてましたが、一部のiPhone端末でメモリを非常に消費していたので、
 * 以下の関数でjsで横揺れアニメーションを実装する。
 * 以下ksスクリプトの例
 *
 * [keyframe name='yokoyure']
 * [frame p=25% x='10']
 * [frame p=50% x='-10']
 * [frame p=75% x='10']
 * [frame p=100% x='0']
 * [endkeyframe]
 * [kanim name='Character1' keyframe='yokoyure' time='500']
 * [kanim name='base_fore' keyframe='yokoyure' time='500']
 *
 */
jQuery( function( $ ) {
  $.fn.kanim = function( config ) {
    //アニメーション対象html要素
    var obj = this;
    //デフォルト設定値
    var defaults = {
      'time' : 500,
      'count' : 1,
      'frames' : {
      }
    };
    //デフォルト設定値と引数で渡された設定値を合体
    var setting = $.extend( defaults, config );

    //アニメーション対象要素一つづつに対して
    return obj.each( function() {
      //一つのobjの移動は複数のkanimタグで行われる可能性があるので、初期位置をdataに保存しておく
      if (!obj.data('initial_position')) {
        obj.data('initial_position', {left: parseInt(obj.css('left')), top: parseInt(obj.css('top'))});
      }

      /**
       * 一つのframeのアニメーションを行う。
       * この関数はframeタグを指定した数だ実行される。
       * @param key css keyframeのパーセンテージ（例：'25%'）
       * @param frame アニメーションの詳細（jQuery A3D形式）、現在は{'trans': {'x': 10}}というようなxとy座標で移動する時のフォーマットだけに対応している。
       * @param initial_left アニメーション開始前のcss left position
       * @param initial_top アニメーション開始前のcss top position
       * @param count アニメーションの繰り返し回数（残り回数）
       */
      (function move(key, frame, initial_left, initial_top, count) {
        //このframeの長さ(duration)を計算する
        var keys = Object.keys(setting.frames), i = keys.indexOf(key);
        var prev_percentage = i !== -1 && keys[i - 1] && parseFloat(keys[i - 1]) || 0;
        var diff_percentage = parseFloat(key) - prev_percentage;
        var duration = setting.time * (diff_percentage / 100);
        //次のframeを取得
        var next_frame = i !== -1 && keys[i + 1] && setting.frames[keys[i + 1]];
        var next_key = Object.keys(setting.frames)[i + 1];
        //アニメーション実行
        $( obj ).animate(
          {
            left : initial_left + parseInt(frame.trans.x),
            top : initial_top + parseInt(frame.trans.y)
          },
          {
            duration : duration,
            queue: false,
            complete : function() {
              if (next_key && next_frame) {
                // 次のフレームが存在していたら、そのアニメーションを行う
                move(next_key, next_frame, initial_left, initial_top, count);
              } else if (count > 1) {
                // 次のフレームがなかったら、一回のアニメーションが終わったことになる。
                // countが1以上の場合、最初からアニメーションを繰り返す
                move(Object.keys(setting.frames)[0], Object.values(setting.frames)[0], parseInt(obj.css('left')), parseInt(obj.css('top')), count - 1);
              }
              // 全てのアニメーション完了
            }
          });
      })(Object.keys(setting.frames)[0], Object.values(setting.frames)[0], parseInt(obj.data('initial_position').left), parseInt(obj.data('initial_position').top), setting.count);
    } );
  };
} );

tyrano.plugin.kag.tag.kanim = {
  vital: ["keyframe"],
  pm: {
    name: "",
    layer: "",
    keyframe: ""
  },
  start: function(pm) {
    var that = this;
    const j_targets = $.findAnimTargets(pm);
    if (0 === j_targets.length) {
      this.kag.ftag.nextOrder();
      return
    }
    // 対応しているkeyframe名
    var replaceKeyframeNames = [
        'yokoyure',
        'down',
        'up',
        'shake'
    ];
    if(replaceKeyframeNames.includes(pm.keyframe)) {
      var selector = '';
      if (pm.name) {
        selector = '.' + pm.name;
      } else if (pm.layer) {
        selector = '.' + pm.layer + '_fore, .' + + pm.layer + '_back';
      } else {
        console.log('kanimタグのname属性かlayer属性を指定してください。');
      }
      $(selector).kanim( {
        'count' : pm.count,
        'time' : pm.time,
        'frames' : this.kag.stat.map_keyframe[pm.keyframe].frames,
      });
      this.kag.ftag.nextOrder();
      return;
    }
    // 以下変わってないので省略
```

## 6. まとめ

TyranoScriptをWKWebViewでスマートフォン向けに最適化する際、CSS3のKeyframe Animationがメモリ消費の増大とWebViewのクラッシュを引き起こす主な原因であることが明らかになりました。jQueryのanimate関数を用いることで、メモリ使用量を抑制し、クラッシュの発生率を大幅に低減することが可能です。場合によっては、この記事で提案した対策を講じても、消費メモリ量が減少しないこともありますが、それでもクラッシュ率は確実に低下します。そのため、根本的な原因は消費メモリだけでなく、WKWebViewの内部にある可能性があり、WKWebViewとCSSアニメーションの相性が悪いと考えられます。

## 7. 参考url
https://developer.mozilla.org/ja/docs/Web/CSS/animation
https://tyrano.jp/tag/#kanim
https://tyrano.jp/tag/#camera
https://developer.apple.com/documentation/webkit/wknavigationdelegate/webviewwebcontentprocessdidterminate(_:)

[CYBIRD Advent Calendar 2024](https://qiita.com/advent-calendar/2024/cybird) 17日目は [@kazurasaka](https://qiita.com/kazurasaka) さんの「RとRStudioで始める分析入門」です。
お楽しみに！！