こんにちは、ピグディビジョンでフロントエンドのプログラムを書いています。maginemu (@maginemu) です。


今回は「どこでもピグライフ」を制作する上でハマった点を踏まえつつ、スマートフォンwebアプリでのメモリ管理とデバッグ方法について少しだけお話させていただきたいと思います。


どこでもピグライフとは

弊社の提供しておりますPC向けソーシャルゲーム「ピグライフ」をスマートフォン向けに移植したサービスです。『ピグライフのお庭をスマートフォンでも』を目標に移植を行いました。

(「どこでもピグライフ」はPCで「ピグライフ」を開始し、「いちごジュースを作ってみて!」というクエストをクリアするとお使い頂けるようになります)


どこでもピグライフ

PC版ピグライフはFlashでつくられていますが、どこでもピグライフはHTML5/CSS3/Javascriptで制作を行いました。画面遷移をせず、直接エリア上のテーブルや作物をタップして操作したり、メニューを下から引き出したり、ネイティブアプリのような操作を目標にしています。


方針

そもそものメモリ容量が少ないスマートフォン端末で遷移の無いアプリケーションを制作する上で、多少のパフォーマンスを犠牲にしてもこまめにデータを削除する必要がありました。


例えば普段隠れて表示されていない「ピグとも」のビュー部分を画面外に生成しておけば、表示のパフォーマンスは上がるのですが、その分メモリを消費してしまいます。どこでもピグライフでは、ほとんど常に表示されている「お庭」がリソースの大部分を消費してしまうので、表示されていない部分に関しては都度生成/削除するようにしています。


「お庭」に関しても、地面部分をタップするとその周辺の作物が表示され、表示されている領域が一定以上になると、古い領域から表示を消すようになっています。



小さく描いて大きく見せる

メモリ消費を抑えるために、そもそもの描画サイズを小さくするのは重要です。Imageもそうですが、Canvasに画像を描画することは、単純にそのサイズの分メモリを消費します。


どこでもピグライフでは床面の菱形を描画するのにcanvasを用いていますが、等倍のサイズで描いてしまうと、最も拡張されたお庭だと実に1024[幅]x768[高さ]x16[床グループ数]ピクセルものサイズになってしまいます。


そのため256x128ピクセル程度に描画したものをcssで4倍にscalingして表示しています。これで等倍で描画した場合に比べて実際のメモリ使用量がざっと70~80MBも低下します。
表示は荒くなってしまいますが、床面の描画ということもあり、軽量化を優先した部分です。


参照を削除する

画面遷移の無いアプリケーションでは、その要素をこまめに削除してその分のメモリを開放することが重要になります。


基本的なところとして、要らなくなったデータを開放するためには、ガベージコレクタに削除してもらう必要があり、つまり参照を確実に削除する必要があります。


参照が削除されて、データがヒープ領域から開放されたことを確認するためにWeb Inspectorでヒープスナップショットを撮ることができます。


下記のような簡単なスクリプトを考えてみます。起動時に幾つかのオブジェクトを生成して、キーボードで「a」をタイプするとviewにあたるオブジェクトをModelのメンバーから削除します。


(function() {
    var ns = {};

    // viewを保持
    ns.Model = function() {
        var d = new ns.Delegate(this);
        this.view = new ns.View(d);

    };

    // viewメンバーを削除する
    ns.Model.prototype.removeView = function() {
        delete this.view;
    };

    // modelを保持
    ns.Delegate = function(model) {
        this.m = model;
    };

    // delegateを保持
    ns.View = function(delegate) {
        this.d = delegate;
    };

    // 生成
    var model = new ns.Model();

    //
    // "a" がタイプされたら model.viewを削除する
    //
    window.addEventListener('keydown', function(e) {
        if (e.keyCode === 65) {
            model.removeView();
        }
    });
}());

このスクリプトを含むページを開き、Web Inspectorを起動します。View > Develop > Developer Tool とメニューを辿って開きます。Macであれば Command + Option + i がショートカットになっています。


Profiles > Take Heap Snapshot を選択し、Startをクリックしてスナップショットを撮影します。



このとき自動的にGCが走るので、GCについては気にしなくても大丈夫です。


取得されたSnapshot1を見てみると、ns.Model, ns.View, ns.Delegateが存在しているのがわかります。
さらに例えばns.Viewを選択すると、ns.Modelによって参照されているということもわかります。


Snapshot1

「a」をタイプしてmodel.viewを削除する処理を走らせてから、もう一度スナップショットを撮ります。こうして取得されたSnapshot2ではns.View, ns.Delegateが消えているはずです。


Snapshot2

目視で確認しても良いのですが、下のメニューから Comparisonを選択することで、オブジェクトがどのように変化したのかを調べることができます。



今回の場合はns.View, ns.Delegateについて、# Deltaが–1になっていることが分かると思います。


今回は簡単な例だったので迷わずに削除されたと思いますが、実際のプロジェクトでは意外な参照が残っていてオブジェクトが開放されないといったことが起こる可能性があります。ヒープスナップショットを確認することで、何が原因でオブジェクトが開放されないのかを調べることができます。


DOM要素の増減に注意する

DOM Nodeの存在による消費メモリは javascriptのヒープには現れませんが、ネイティブのメモリを消費しています。Image要素やCanvas要素がネイティブのメモリを消費することはわかりますが、DIV要素などの存在も数が多くなってくると馬鹿にできません。また要素数の増加はCPU使用率の上昇も引き起こします。


Google ChromeのWeb Inspector には現在DOM Node Countを表示してくれる機能があります。Timeline > Memory を開き、監視を開始すると現在のDOM要素数がグラフで表示されます。



その状態で要素の生成→削除が発生する動作を行い、GCボタンを押下してGCを発生させます。このとき要素数が想定通りに上昇→下降するかを確かめます。想定どおりに要素数が変化しなければ、なんらかの原因でDOM Nodeが開放されていないことになります。


生成処理をするとグラフ上でもDOM要素が生成されたのが分かります。


GCボタンを押すとガベージコレクションが行われます。この例ではDOM Node Countが初期の状態に戻りました。


HtimImageElementをpoolする

大量に要素を生成/削除するようなプログラムだと、GCによる開放が間に合わなくなる(若しくは、ブラウザが意図的にキャッシュしているのかもしれません)ケースがあるようです。


少し長くなってしまいましたが、下記のコードは、画像を100件表示し、削除することを繰り返すプログラムです。


(function() {

    var generate_count = 0;

    //
    // url を src に設定した新しいImageを返す
    //
    var loadImage = function(url) {
        var img = new Image();
        img.src = url;
        return img;
    };

    //
    // 読み込むimage (なんとなく3種類)
    //
    var urls = [
        'img/ameba_sq_g.png',
        'img/ameba_sq_r.png',
        'img/ameba_sq_b.png'
    ];

    var urlIndex = 0;
    //
    // 呼ばれる度に異なるurlを返す
    //
    var nextUrl = function() {
        urlIndex++;
        if (urlIndex >= urls.length) {
            urlIndex = 0;
        }
        return urls[urlIndex] + '?cnt=' + (generate_count++);
    };

    //
    // 画像を表示する領域
    //
    var image_area = document.getElementById('image-area');

    //
    // 100件の画像を表示します。
    // 表示した画像はそれぞれ500ms後に削除されます。
    //
    var addremove = function() {
        for (var i=0; i<100; i++) {
            // imageの取得
            var img = loadImage(nextUrl());
            // image-areaに追加する
            image_area.appendChild(img);
            // 500ms後に削除する
            setTimeout(removeImage, 500, img);
        }
    };
    //
    // img を image-area から削除する
    //
    var removeImage = function(img) {
        console.log('removing src:[' + img.src + ']');
        image_area.removeChild(img);
    };
    //
    // 1s 毎に繰り返す
    //
    setInterval(addremove, 1000);
}());

実はこのプログラムはメモリを大量に消費してアプリを不安定にしてしまう可能性があります。というのも


//
// url を src に設定した新しいImageを返す
//
var loadImage = function(url) {
    var img = new Image();
    img.src = url;
    return img;
};

// imageの取得
var img = loadImage(nextUrl());

// image-areaに追加する
image_area.appendChild(img);        

image_area.removeChild(img);

このようにnew Image()すると、そのimageをremoveChildしたとしても、MobileSafariは確保したメモリをなかなか開放してくれないらしいのです。場合によってはクラッシュしてしまうことになります。



これを回避するためには、Imageを使いまわすようにします。既存のImageのsrcに新しいurlを指定すると、Image内部の画像データが開放され、新しいデータが生成されるようです。


そこで下記のようなreuseImageを実装してみます。


var imagePool = [];

//
// url を src に設定したImageを返す
// もしimagePoolにimageがあればそれを使う。
// なければ新しいImageを作成する。
//
var reuseImage = function(url) {
    var img;
    if (imagePool.length < 1) {
        img = new Image();
    } else {
        img = imagePool.pop();
    }

    //
    // release メソッドを追加する
    //
    img.release = function() {

        //
        // releaseされたらimagePoolに追加
        // releaseメソッドを削除
        //
        imagePool.push(this);
        delete this.release;
    };

    img.src = url;
    return img;
};

reuseImageを使うと、imagePoolにImageが存在すればそれを再利用するようになっています。imageのreleaseメソッドを呼ぶとそのImageはimagePoolに入ります。


Imageの取得部分をreuseImageに変更し、


// imageの取得
//var img = loadImage(nextUrl());
var img = reuseImage(nextUrl());

削除部分にreleaseのコールを追加します


//
// img を image-area から削除する
//
var removeImage = function(img) {
    console.log('removing src:[' + img.src + ']');
    image_area.removeChild(img);

    // release
    img && img.release && img.release();
};

このようにすることで、imageの生成が最小限に抑えられ、メモリ使用量を抑えることができます。例えば今回の例だと最初に100だけNodeが増えてそれ以降増えなくなります。



最終的なコードはjsdo.itに書いておきました。(だいぶ整理してしまいました)。



webkit-canvasも同様に

webkitにはbackground-imageに -webkit-canvasを指定することが可能です。これは


background: -webkit-canvas(canvas_id);

のように任意のcanvas_idを指定しておくと、javascriptから


var ctx = document.getCSSCanvasContext(‘2d’, ‘canvas_id’, width, height);

のようにCanvasContextを取得できる仕組みです。


非常に便利な仕組みですが、このとき用いるcanvas_idについても、ユニークなidを次々と生成して使用していると、前述のImage要素と同様にCanvasContextがなかなか開放されなくなってしまいます。


Imageの場合と同様に、使わなくなったcanvas_idを再利用するような実装をすると、以前そのidに紐付いて使われていたデータが開放されて新しいcanavsが生成されるようです。


Canvasについてもソースコードをjsdo.itにアップしました



まとめ

ピンポイントなお話になってしまいましたが、いかがでしたでしょうか。ChromeのWeb Inspectorが高機能で非常に便利です。 DOM Node Countの表示機能などはどこでもピグライフ開発中に追加され、歓喜したのを覚えています。


偉そうに記事を書いたものの、解釈の間違いがあるかもしれません。何を言っているんだ、という部分があればご指摘頂ければと思います。


どこでもピグライフは今後もまだまだパフォーマンス改善、機能追加を行なって参りますのでどうぞよろしくお願いします。



written by
maginemu
twitter: @maginemu
t-blog: http://magichilli.blogspot.jp/