四度目のブログになります、藤澤です。
今回は子要素の仮想化について書いてみたいと思います。
子要素の仮想化とは、簡単に言うと「見えてるとこだけ表示する」手法です。
弊社 くるるファンタズマ のようなゲームを作っていると、図鑑機能のように たくさんの画像をリスト表示したい場面が出てきます。そんなとき ListBox のようなコントロールを使って単純に実装するとリスト全件分の画像が一度に読み込まれ、メモリを圧迫することになりかねません。そこで、必要な部分のみ描画してリソースを節約しようというのが子要素の仮想化です。.NET Framework などでも使われていますので すでにお馴染みの方も多いかもしれません。
仮想化というと大仰な感じがしますが、やっていることは意外と単純です。
- 実際に見える範囲(Viewport)の矩形を算出する。
- リストの各アイテムの配置を算出する。
- Viewport の範囲内にあるアイテムを生成・描画する。
- すでに生成済みのアイテムのうち、Viewport の範囲外になったものを削除する。
上記の処理を描画処理のタイミングで行ないます。ただ、アイテムの生成・削除を毎フレーム行っていたのではレスポンスを損なう恐れがあるので、実際には Viewport に変更がなければ処理を間引くなどの対策が必要になります。
もう少し詳しく説明すると、
まずレイアウトを管理する Panel クラスを用意します。仮想化機能を担うのは この Panel クラスになります。
Panel クラスは元になるデータのコレクション(くるファンの場合 所持魔導書の配列とか)から画面に表示するアイテムを生成し、任意のレイアウトに配置します(※1)。
Panel は単体でも利用できますが、通常 Scroll を管理するコンテナの内部に配置して使用します。
Panel の描画処理では、まず Scroll コンテナの現在のスクロール位置とサイズから Viewport を算出します。
次にコレクション内のどの要素が Viewport 内にあるか判定するため、各アイテムの配置(x, y 座標とサイズ)を算出します。ここではレイアウトにしたがって配置を算出するだけでよく、実際にアイテムを生成する必要はありません(※2)。
Viewport とアイテムの配置が取得できたら、Viewport の範囲内にあるアイテムを実際に生成し描画します。同時に、生成済みのアイテムで Viewport の範囲外になったアイテムは削除します。
※1 表示する内容は場面ごとに異なるはずですので Panel の責務はレイアウトのみとし、アイテムの生成は別クラス(Panel を利用するクライアント)に任せるべきです。
※2 すべてのアイテムの配置を算出する必要はなく、Viewport の範囲外であることが明確であれば計算を省略することができます。ただし、そうすると Panel 全体の正しいサイズを計算できなくなるため、ある程度スクロールしたら急にスクロールバーのサイズが伸びる等、スクロールの挙動が若干おかしくなる可能性があります。
文章で説明してもわかりづらいかと思いますので、Cocos2d-x で実装した場合のサンプルは以下のようになります。ここでは最低限の機能のみ掲載しています。
void VirtualizingPanel::draw() { // (1) Viewport の範囲を算出 CCPoint origin = CCPointZero; CCSize size = CCDirector::sharedDirector()->getWinSize(); CCScrollView* scroll = dynamic_cast<CCScrollView*>(this->getParent()); if (scroll != NULL) { origin = scroll->getContentOffset() * -1; size = scroll->getViewSize(); } CCRect viewportRect = CCRect(origin.x, origin.y, size.width, size.height); float x = 0.0f; float y = 0.0f; float width = 0.0f; float height = 0.0f; for (int i = this->itemsSource->count() - 1; i >= 0; i--) { // (2) リストアイテムの配置を算出 CCSize itemSize = this->measure(i); CCRect itemRect = CCRect(x, y, itemSize.width, itemSize.height); if (viewportRect.intersectsRect(itemRect)) { // (3) アイテムが Viewport の範囲内であれば生成 if (this->getChildByTag(i) == NULL) { CCNode* node = CCNode::create(); node->setContentSize(itemSize); node->setPosition(ccp(x, y)); node->setTag(i); this->arrange(i, node); this->addChild(node); } } else { // (4) Viewport の範囲外のアイテムは削除 CCNode* item = this->getChildByTag(i); if (item != NULL) item->removeFromParent(); } // (5) アイテムを縦一列にレイアウト x = 0.0f; y += itemSize.height; width = MAX(width, size.width); height += itemSize.height; } this->setContentSize(CCSize(width, height)); }
(1)Panel が ScrollView の内部にある場合は ScrollView の表示領域を算出します。Layer に直接配置されている場合は画面全体を Viewport の範囲としています。
(2)アイテムの描画サイズを取得します。アイテムのサイズはクライアントで指定できるように別メソッドにしています。
(3)アイテムが描画範囲内であれば実際にアイテムを生成します。サイズの判定同様、アイテムの生成処理も別メソッドです。
(4)Viewport 範囲外で生成済みのアイテムは削除します。
(5)ここではアイテムを縦一列に配置しています。アイテムをどのように配置した以下によって この部分は異なってきます。
先に触れましたとおり、子要素の仮想化を行なった場合 描画処理に負担がかかりますので、子要素を仮想化するべきかどうかは その時々で判断する必要があります。
例えば、アイテム個々の消費リソースが大きく一度にすべてを読み込めない場合、リストの先頭のみ表示される可能性が高く後方は目に触れる機会が少ない場合(ランキングなど)は有用と思います。
逆に、頻繁にスクロールされる場合や個々のアイテムの生成に時間がかかる場合には向かないと思います。
VirtualizingPanel のサンプルソース全体はこちらになります。
ゲームにかぎらず、様々なアプリで使える手法ですので頭の隅にとどめていただければと思います。