シングルトンの活用方法(依存性の注入2)
今回もmusicLineの内部実装の話。(前回の続き)
Transform
を末端のクラスNote
へ共有する方法(依存性の注入)において、シングルトンを用いた方法について考えてみました。
はじめに
musicLineでは、画面上に音符を表示して、タップやスワイプにより音符を編集します。そのため、 音符位置をスクリーン座標系へ座標変換するTransform
が必要です。
前回の記事では、ルートModel
で保持しているTransform
を末端のNote
へ共有する際にはシングルトンを使用することでコードがスッキリするが、使用は最小限に抑えることが望ましいとまとめました。
詳細はこちら
シングルトンはどこからもでアクセスできて便利ですが、状態を複数持てないことや依存関係が分かりづらくなるデメリットがあります。
今回はシングルトンを利用することの問題について考え、改善案を提示していきたいと思います。
シングルトンの問題
シングルトンパターンは便利で簡単に使いがちですが、使い所が難しいデザインパターンとして知られています。
下手に使用すると不具合の温床になりかねないため、特徴を深く理解することが大切です。
シングルトンの問題として、
- 状態を複数持てない
- 依存関係が複雑になる
があります。
状態を複数持てない
Transform
は作曲画面で1つの状態しかないですが、musicLineでは編集曲を変更するときに前回の保存したTransform
を適用する仕様があります。曲を読み込む時にTransform
の状態が複数(編集中の曲、読み込む曲)存在することになり、シングルトンだと一時的にTransform
がどちらか一方の状態になります。
そのため、Transform
はシングルトンではなく、もう少し工夫する必要があります。
Transform
は状態が複数になる可能性があるため、シングルトンを解除し、代わりに編集中のインスタンスを管理するクラスEditManager
をシングルトンにします。Transform
の状態は複数になる可能性がありますが、編集中の画面に表示するTransform
は必ず1つの状態なるはずです。
インスタンスを共有する流れとしては、
- 曲変更時に、
EditManager
で管理しているtransform
を更新 Note
の作成時にEditManager
からtransform
を取得- 座標変換時に
Note
で保持しているtransform
を使用
となります。
作成時のコンストラクタでTransform
を保持することで編集状態に影響されず、インスタンス作成から破棄までのライフサイクルで必ず同じTransform
を共有することができます。
なお、関数内で直接シングルトンにアクセスするとTransform
のシングルトンを解除した意味がなくなり、複数の状態を表現できていないことになります。この状態だとTransform
が複数ある時(曲を読み込む場合)に参照しているインスタンスが代わり、予期せぬインスタンスへのアクセスで不具合が発生する可能性があります。
状態を複数持てないことでの不具合例
Android開発では編集中の拍子情報をEditManager
で管理しており、曲を読み込む時に拍子情報を参照してマイグレート(保存バージョンを新しいバージョンへ変換する作業)をしてました。
しかし、曲読み込みの際はまだ編集中の拍子情報は更新されていないため、マイグレートで異なる拍子情報を参照し、保存データが変わってしまう不具合がありました。
依存関係が複雑になる
本来ならば、Transform
はルートModel
から末端Note
へクラスを跨ぐ必要がありますが、シングルトンはその上層から下層に情報を橋渡しする冗長さをなくすことができます。
さらに、シングルトンEditManager
のプロパティでインスタンスTransform
を保持することで、シングルトンではないクラスも簡単に共有できます。
しかし、どこからでもアクセスできる便利さが(グローバル変数と同様)依存関係を複雑にします。ツリー構造(階層的なデータ構造)であれば、他ブランチのデータからの影響はなく、依存関係がわかりやすくなりますが、シングルトンに頼ると他ブランチからでもデータアクセスができるため、ツリー構造の利点が破壊されます。
データアクセスの自由度が高くなっても(シングルトンの利用)依存関係を最小限に留めるためには、共有するインスタンスTransform
の使い方を限定することが有効的だと思います。
使い方を限定するとは、Transform
を直接共有するのではなく、サービスクラスを作りTransform
をパッケージして共有することです。
Transform
を直接共有すると、意図しない使い方ができてしまい、クラス間の影響を把握することが難しくなります。
例えば、Transform
の共有は「頂点位置を算出する目的」を意図しているのに、Transform
の状態を変更したり、プロパティ(スクロールの位置等)を単体で取得したりとやりたい放題できてしまいます。
シングルトンで共有する目的を明確にするためにも、サービスクラスを作成してTransform
を利用する処理をメンバー関数で提供します。
役割を限定したサービスクラスを共有することでTransform
の使い方を制限し、インスタンスを共有する目的をコードで説明できていることが望ましいです。
依存関係が複雑になることでの不具合例
Android開発ではEditManager
で編集曲editSong
を共有したことが、依存関係を複雑にしました。
編集曲の情報を共有すれば、末端Note
からも編集曲の拍子やトラックの楽器等を簡単に取得できて便利になりました。しかしシングルトンに依存すると、上層から降ろすはずの依存関係について、不自然なところが気づかなくなり、実際は構造上の問題があるにも関わらず無理やり実装を完了したような状態になります。このような些細な歪みが積み重なることで、構造を破綻することになり、多くの不具合に繋がりました。
DIコンテナー
シングルトンの問題は共有インスタンスTransform
をサービスクラスにパッケージして、シングルトンServiceManager
で管理することで解消しました。
今回は具体例としてTransform
の場合を考えましたが、他にも同様のパターンがあると思われます。その際に、ServiceManager
のプロパティを増やすよりは、クラスとインスタンスを紐づけて管理する連想配列がいいでしょう。
ちなみに、このようなオブジェクトの依存関係を管理する役割をDIコンテナーと言います。また依存関係を解決することを依存性の注入 (DI)と言います。
あとDIコンテナーでサービスクラスを共有する利点として、末端Note
で同じサービスクラスを何度も作成しなくて済み、メモリを節約することができます。
シングルトンの代わりとしてDIコンテナーを利用する時に、なるべくシンプルな依存関係になるようにサービスクラスに限定して共有します。その際に、サービスクラスでもDIすると依存関係が複雑になり、依存解決する(インスタンス作成する)順番を考慮する必要性が出てきます。そのため、サービスクラスで他のサービスを使用する時はシンプルにコンストラクタ引数で渡すようにします。
musicLineでは編集曲が変わるタイミング(編集サイクル)でDIコンテナーを更新するようにしています。
SwiftでDIコンテナーを実装する記事はこちら
おわりに
今回の調査で、シングルトンパターンは正しく理解し、使いどきを見極めることが大切だとわかりました。
シングルトンの扱いに注意しないと、
- 状態を複数持てず、異なるインスタンスを参照してしまう
- 依存関係が複雑になり、思わぬ循環参照に陥ってしまう
と不具合に繋がります。
シングルトンパターンの性質を理解し、
- 状態が複数になり得ない状況に限定して管理する
- 使用用途を限定したサービスクラスで共有する
と対策を取ることで不具合を事前に防ぐことができます。
ちなみに、アプリ内で必ず状態を1つにしたい場合はシングルトンの使いどきです。musicLineでは、アカウント管理(ログイン状況等の管理)や音楽プレイヤー(再生曲等の管理)に対してシングルトンが向いていると思います。