はじめに
Singleton はとても分かりやすくて便利なデザインパターンです。ですが Android アプリの開発では他のプラットフォームにはない事情があるため、それを理解することなく Singleton を使うと痛い目を見ることがあります。その Android 特有の事情を書きたいと思います。
Android 固有の事情とは?
Singleton を利用する上で問題になると思われる Android 特有の事情は、私が把握している限り大きく二つあります。
- クラスのアンロードで静的変数が初期化される可能性がある
- プロセス終了後もアプリの状態が維持される可能性がある
前者は滅多に発生しない現象なのでほとんどの場合問題になりませんが、後者は比較的頻繁に起こります。
ですので以降では、現実的にはあまり問題にならない前者の現象について軽く説明し、その後でより影響の大きい後者の現象について掘り下げます。
クラスのアンロードで静的変数が初期化される可能性がある
Android の VM はクラスをアンロードすることがあり、その結果 static 変数に記憶している値が消失してしまう(次回読み出したときに初期化されている)ことがある、というものです。これは私自身がだいぶ以前に実機で経験したことのある現象です。また以下のページにもそれらしいことが記載されています。
クラス参照、フィールド ID、メソッド ID は、クラスがアンロードされるまで有効であることが保証されます。クラスがアンロードされるのは、クラスローダーに関連付けられているすべてのクラスに対してガベージ コレクションが可能な場合に限られます。この状況は Android ではまれにしか起こりませんが、あり得ないことではありません。
ただ、上記ページにもある通り、この現象は滅多に起こらないようです。実際私がこの現象に遭遇したのも上述の一回限りです(ただしこれは私が気を付けて実装するようになったからかも知れません)。
ですのでこの点に関して言えば「そんな滅多にしか起こらないことのために開発効率を犠牲にするべきでない」という判断もあり得ると思います。ですが先にも書いたように「プロセス終了後もアプリの状態が維持される可能性がある」という観点から、Android で Singleton を使うのは注意が必要だと思います。その理由を以下に書いていきます。
プロセス終了後もアプリの状態が維持される可能性がある
Android アプリの開発に馴染みがない方にとっては「なんじゃそりゃ」だと思うのですが、Android アプリは Android フレームワークによって管理されていて、そのため普通の Java アプリ(Java SE とか Java EE とか)とはだいぶ違ったライフサイクルを持ちます。
一般的な Java アプリの場合、アプリの終了とプロセスの終了はほぼ同義です。
ですが Android アプリの場合は、この両者が必ずしも一致しません。プロセスは終了していても(システム上は)アプリが生きていることがあります。たとえば以下のコードを見てみましょう。
class MainActivity : AppCompatActivity() {
private var state = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
state = savedInstanceState?.getInt("state", 0) ?: 0
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt("state", state)
}
}
このコードではアクティビティの状態変数として state
を用意しています。そしてこの状態変数を onSaveInstanceState()
メソッドで保存し、onCreate()
メソッドで復元しています。これは「アプリの状態を Android フレームワーク側に管理させている」ことを意味します。
なぜこんなことをするのでしょうか?こんなことをしなくても、MainActivity
のインスタンスが存在する間は state
の値は保持されるはずですし、その MainActivity
のインスタンスはアプリ動作中はずっと存在するはずですよね?
ここが重要な点だったりします。まず、アプリが終了していない状態でも Activity
クラスのインスタンスは破棄され得ます。これはほとんどの場合、アプリがバックグラウンド状態でメモリが足りなくなったときに発生します。クラスのインスタンスが破棄されるのですから、当然インスタンス変数である state
の内容も消えてしまいます。その場合、次回アプリがフォアグラウンドになったときに、Android フレームワークによって Activity
の新しいインスタンスが自動的に生成されます。そしてそのときに、onSaveInstanceState()
で保存されたデータが onCreate()
に渡されます。この仕組みを使って状態を維持するわけです。
そしてさらに重要なのは、Activity
クラスのインスタンスが破棄されるだけでなく、 アプリのプロセス自体がいったん終了して、新しいプロセスとして起動することもあるということです。 そしてその場合でも、 旧プロセスの onSaveInstanceState()
で保存したデータが新プロセスの onCreate()
に渡されます。 その結果、プロセスがいったん終了したにも関わらず、アプリの状態は維持されたままになります。ユーザには「このアプリは終了していなかった」ように見えるでしょう。これが「アプリの終了とプロセスの終了は必ずしも一致しない」とした理由です。
プロセスが終了したのに状態が維持されるなんて、そんなバカな!!!
…と思う方もきっといると思います。私も以前はそうでした。ですがこのことは公式ドキュメントにしっかり書かれています。
ユーザーは、構成変更(回転やマルチウィンドウ モードへの切り替えなど)全般にわたってアクティビティの UI の状態が変わらないことを想定します。(中略)ただし、システムはユーザーが離れてアクティビティが停止している間に、アプリのプロセスを破棄する場合があります。
システムの制約が原因でアクティビティが破棄される場合は、ViewModel、onSaveInstanceState()、およびローカル ストレージを組み合わせて使用して、ユーザーの一時的な UI の状態を保持する必要があります。ユーザーが想定する内容とシステムの動作の比較、およびシステムが開始したアクティビティとプロセスの終了時に複雑な UI の状態のデータを保持する最適な方法については、UI の状態を保存するをご覧ください。
以下のドキュメントには savedInstanceState
がプロセス終了後も保持されることが明示されています。
つまり Android はプロセスを跨いだ状態維持の仕組みをプラットフォームとして積極的に取り入れているわけで、アプリ開発者もそれに沿った実装をしないといけないということだと思います。
Android がこんな設計になっているのはアプリの生死とプロセスの生死を別物として捉えているからだと解釈できます。アクティビティだけでなくサービスにも同様の仕掛けがあります。Android のサービスはメモリ不足に陥ったりするとわりと簡単に終了してしまいますが、これも Android フレームワークによって自動的に再生成されます(Service#onStartCommand()
メソッドが START_STICKY
等を返した場合)。この点からも Android にはそういう思想があることが窺えます。(まぁこういうところが Android の取っつきにくさだとも思いますが…)
そしてこのこと(アプリの状態がプロセスを跨いで維持されること)は Singleton の利用にも影響を与えます。たとえば以下のようなクラスを考えてみます。
class MySingleton private constructor() {
companion object {
private var instance: MySingleton? = null
fun getInstance(): MySingleton = instance ?: MySingleton().also {
instance = it
}
}
var state = 0
}
よく見る Singleton の実装だと思います。静的変数 instance
にこのクラスのインスタンスを保持させ、アプリ内のどの場所からでも参照できるようにしています。ですが、もうお分かりかと思いますが、この Singleton に保持したデータはアプリのプロセスが終了した時点で消えてしまい、新しいプロセスには引き継がれません。
つまり、Android フレームワークはアプリの状態を「アプリのプロセスを跨いで維持する」設計になっているのに対して、Singleton はアプリの状態を「アプリの現在のプロセス内でだけ維持する」設計と言えるんじゃないかなと思います。その結果、Android フレームワーク側で管理している状態と Singleton で管理している状態に齟齬や矛盾が生じてしまう可能性があるというわけです。
と、ここまで読んだ方の中には「そんな Android 固有の機能なんて一切使わないように実装すれば良いのでは?」と考える人もいると思います。つまり Activity
の onSaveInstanceState()
メソッドをオーバーライドせず onCreate()
メソッドでの状態復元を行わないようにすれば上記で言う「Android フレームワーク側で管理している状態」はなくなるはずだからこの問題は発生しなくなるのではないか、と考える人がいてもおかしくないと思います。というかそれ昔の私です。
でもそのやり方はあまり現実的でないと思います。というのも Android のモダンなアーキテクチャはこの機能を使う前提で設計、実装されているからです。たとえばよく使われる Fragment
や Navigation コンポーネントはこの機能を使って状態を保持しているためアプリのプロセスがいったん終了した場合も画面や画面遷移の状態が再現されます。そのため、もしもアプリの状態(の一部)を Singleton に保持させていたりすると、その状態と画面の状態に不整合が生じる可能性があります。これは避けるべきでしょう。
サンプルアプリ
以上の問題を実際に確認できるサンプルアプリを用意しました。
Navigation コンポーネントを使った3画面構成の単純なアプリとなります。ユーザが入力したデータを Singleton なオブジェクトに保持させており、そのデータが消えてしまう様を確認できます。アプリの仕様や再現方法はリポジトリの README.md を読んでください。
DI コンテナを使う場合も同様の問題が起こる
DI コンテナから Singleton なオブジェクトを取得して利用することもあると思いますが、当たり前ですが同じ問題が起こります。
試しに上記のサンプルコードを改造して Hilt を使って Singleton なオブジェクトを注入するようにしてみます(同じリポジトリに use_hilt
というブランチを切って入れてあります)。
コードとしてはこんな感じです。
interface InputData {
var name: String
var email: String
}
class InputDataImpl @Inject constructor() : InputData {
override var name: String = ""
override var email: String = ""
}
@Module
@InstallIn(SingletonComponent::class)
abstract class InputDataModule {
@Singleton
@Binds
abstract fun bindInputData(
inputDataImpl: InputDataImpl
): InputData
}
// InputData の注入方法
@Inject
lateinit var inputData: InputData
結果は Hilt を使わない場合と変わらず、画面Cでバックグラウンド状態にしてメモリ枯渇後にフォアグラウンドに戻すと名前もメールアドレスも空文字になってしまいます。
ではどうするべきなのか?
長時間に渡って保持する必要のあるデータは savedInstanceState
に保存させるようにするのが無難だと思います。Singleton に保管しているデータも必要に応じて savedInstanceState
に保存しましょう。ただ本来的なことを言うと Singleton の内容が特定のコンポーネント(アクティビティ)によって「保存」および「復元」されるのはおかしなことですし、そんなの Singleton じゃない!という気もします。そんなことをするくらいなら ViewModel としてデータを保持するようにした方がモダンな設計になりそうです。また画面間でデータのやり取りが必要な場合は Safe Args 等、フレームワークで用意してくれている手法を活用しましょう。
あと、この記事の趣旨からは少し逸れてしまいますが、ViewModel を使う場合も少し注意が必要です。ViewModel が保持しているデータもプロセス再生成時に消えてしまうので必要に応じて savedInstanceState
に保存する必要があります。詳細は↓ここらへんを参照すると良いと思います。
では Singleton はどんなことに使うべきなのか?
なんだか Singleton を全否定しているように思えるかもしれませんがそういうわけではありません。要は使いどころの問題で、プロセスが終了したタイミングで状態がクリアされてしまっても問題ない用途に使えば良いだけです。すぐに思いつくのはロギングやリソース管理関係の処理ですね。それ以外の用途も色々あるかも知れません。
おわりに
大手企業のアプリでもバックグラウンドからフォアグラウンドに復帰したタイミングで不具合を起こすケースが散見されるため、もしかしたらここらへんが原因なんじゃないかと思い書いてみました。誰かの参考になれば幸いです。
またこの記事の内容は、公式ドキュメントを確認しながら書いたつもりではいますが、もし間違いがありましたらコメントをいただけると嬉しいです。