ソフトウェア設計

Swift オブジェクトプールパターン完全ガイド(Flyweightとの違い・再利用方法)

ゲームエフェクトを大量に表示する画面を作っていると、フレームが急にカクつくことがあります。

読了 5 分
Swift オブジェクトプールパターン完全ガイド(Flyweightとの違い・再利用方法)のカバー画像

ゲームエフェクトを大量に表示する画面を作っていると、フレームが急にカクつくことがあります。

弾丸やパーティクルのように短命なオブジェクトを毎回init生成していると、Instrumentsでメモリグラフがノコギリ状に跳ねるのがわかります。

そんなときに登場するのがSwiftオブジェクトプールパターンです。

先に結論を言うと、オブジェクトプールは「オブジェクトを毎回新しく作らず、あらかじめ作ったものを借りて返す」再利用手法です。混同されやすいFlyweightとは、そもそも目的が異なります。

この記事では、オブジェクトプールとは何か、Flyweightとどう違うのか、そしてSwiftでの実装方法まで順に解説します。


オブジェクトプールパターンとは?

オブジェクトプールは、生成コストの高いオブジェクトをあらかじめ複数作り、「プール」に入れておく方式です。

必要なときに新しく作るのではなく、プールから取り出して使い、使い終わったら返却します。

核心は再利用です。initdeinitを繰り返すことで発生するメモリの確保・解放コストを減らします。

オブジェクトプールを一言でまとめると、「作らずに借りて返す」です。

主に次のような場面で効果を発揮します。

  • 弾丸やパーティクルのように短時間で大量に生成・破棄されるオブジェクト
  • ネットワーク接続やスレッドのように、生成そのものが重いリソース
  • UITableViewCellの再利用キューのように、画面上で何度も入れ替えるビュー

実はiOS開発者なら、すでにオブジェクトプールを使っています。dequeueReusableCellはAppleが用意したオブジェクトプールだからです。


Flyweightとは何が違う?

どちらも「オブジェクトを節約して使う」印象があるため、混同しやすいものです。

しかし、目的はまったく異なります。

オブジェクトプールは、弾丸のようなオブジェクトを時間をずらして使い回す方式です。今の弾丸を使って返却すると、次の弾丸がその場所を引き継ぎます。

Flyweightは、複数の場所から同時に1つの共有オブジェクトを参照する方式です。たとえば森に1万本の木を描く場合、色やテクスチャなどの共通データは1つだけ持ち、位置だけを別に渡します。

表にまとめると、次のように分かれます。

区分 オブジェクトプール Flyweight
目的 生成・破棄コストの削減 メモリ使用量の削減
再利用方式 借りて返す(時間分割) 同時に共有する(空間分割)
状態 オブジェクトごとに固有の状態を保持 共有状態と外部状態を分離
代表例 セルの再利用、パーティクル フォントグリフ、地図アイコン

一言でいえば、オブジェクトプールは「順番に使い回す」、Flyweightは「みんなで共有する」です。

借りて使い、返す構造なので、コードは思ったよりシンプルです
借りて使い、返す構造なので、コードは思ったよりシンプルです

Swiftでオブジェクトプールを作る方法

構造は思ったよりシンプルです。利用可能なオブジェクトを格納する配列1つと、貸し出し・返却用のメソッド2つがあれば十分です。

以下はジェネリックで実装したシンプルなプールです。

final class ObjectPool<T> {
    private var available: [T] = []
    private let factory: () -> T

    init(factory: @escaping () -> T) { self.factory = factory }

    func acquire() -> T { available.popLast() ?? factory() }  // なければ新しく生成
    func release(_ item: T) { available.append(item) }        // 使い終わったら返却
}

acquire()は、プールに残っていれば取り出し、空の場合だけ新しく生成します。

release()で返却すると、次のリクエストでそのオブジェクトが再利用されます。

借りて、使って、返す。この循環がすべてです
借りて、使って、返す。この循環がすべてです

実際にパーティクルへ適用すると、次のような流れになります。

let pool = ObjectPool<Particle> { Particle() }

let p = pool.acquire()  // プールから借りる
p.reset(at: point)      // 状態の初期化が重要!
// ...画面で使い終わったら
pool.release(p)         // 返却

ここで必ず覚えておきたいことがあります。返却されたオブジェクトには以前の状態がそのまま残っています。

そのため、再び貸し出すときはreset()のような初期化を必ず行い、ゴーストデータを防ぐ必要があります。


オブジェクトプールを使うべきとき、避けるべきとき

便利だからとどこでも使うと、かえって逆効果になります。

実際に使って整理した基準を紹介します。

次のような場合におすすめです。

  1. 1秒あたり数十個以上の生成・破棄が繰り返されるとき
  2. オブジェクト1個の生成コストが明らかに大きいとき
  3. メモリグラフがノコギリ状に跳ね、GC(ガベージコレクション)/ARC(Automatic Reference Counting、自動参照カウント)の負荷が見えるとき

次のような場合は考え直してください。

  • たまに1〜2個作るだけの軽量なオブジェクトなら、プール管理のコストのほうが大きくなります
  • 返却を忘れるとプールが空になり、かえって毎回新しく作ることになります
  • マルチスレッド環境では、プールへのアクセスにロックやキューの同期が必要です

Swiftは値型(struct)が多く、ARCもかなり効率的なので、必ずしも最初からプールを導入する必要はありません。

まずInstrumentsで測定し、ボトルネックを確認してから導入するのがおすすめです。

測定してみると、まずメモリグラフがはっきり穏やかになりました
測定してみると、まずメモリグラフがはっきり穏やかになりました

オブジェクトプールは生成コストを抑える再利用パターンで、Flyweightはメモリを抑える共有パターンです。

違いをしっかり押さえておけば、状況に合った手法を選べます。

まず測定し、必要なときにぴったり合う形で導入してみてください。フレームが一段となめらかになる瞬間を、ぜひ体験してください。

あわせて読みたい