何が辛いのかとそのツラミを減らす方法について説明してみます(今すぐ導入したら解決!みたいなのはまだちょっと出せてないです)。
TL;DR→ライフサイクル理解してるぜ!という人はベストプラクティスからどうぞ・・!
前提1: Androidのコンポーネントとプロセス
Androidの主なコンポーネントはActivity, Service, Content Provider, Broadcast Receiverです。これらはアプリをまたがると原則別のプロセスになります。
また、あまり知られていないことですが、AndroidManifest.xmlに記述することで1つのアプリ内でも別のプロセスとして起動することができます。逆に、特別な設定をすれば同じプロセス内で2つのアプリを実行することもできます(普通しない)。
https://developer.android.com/guide/components/processes-and-threads.html#Processes
Activityライフサイクル
Activityは次の場合にインスタンスが一度捨てられて、後から再生成されます。
- 画面から見えなくなっていて、メモリが不足してきた時
- 端末の画面方向を回転した時(即座に再生成)
特に前者はいかなる方法でも回避できず、onSaveInstanceState()でデータを保存し、onCreate()のsavedInstanceStateで復元することは必須です。
プロセスライフサイクル
プロセス内(アプリ内)にフォアグラウンド表示されているActivityやstartForeground()しているサービスがなくなったら、プロセス全体が(Serviceがいても)いつでもkillされ、staticなものも含めて全ての状態がメモリから消える可能性があります。
https://developer.android.com/reference/android/app/Service.html#ProcessLifecycle
プロセスのkillは、あらゆるstatic変数を含むメモリ上の状態が揮発することを指します。
これらを踏まえると、ActivityもServiceもいつkill/destroyされてもおかしくない(特にActivityは1つずつ消える)という前提で設計する必要があります。
理想的なコンポーネント間のデータのやり取り
↑多分Googleが理想だと思ったアーキテクチャ(予想w)
Content ProviderとかBroadcastReceiverは省略しました。
各自、共通のDBを参照する
ライフサイクルやプロセス境界を考えると、起動されたActivity間で直接データをやりとりするのは難しいです。Activityのインスタンスがいなくなってるかもしれないし、メモリ空間が一緒とは限らないからです。
アプリ内共通のSQLiteファイル、SharedPreferencesを各Activity、Serviceで参照すると、直接のやりとりをなくし、かつSingle-Source-of-Truthを実現することができます。Androidにはサーバ・クライアント同期用のSync Adapterという仕組みがあることからも、これはGoogleの目指した世界観のように感じます。
(このやり方を突き詰めると送信途中の投稿みたいな情報もディスクに永続化する必要があるかもしれません。)
Intent、onSavedInstanceState()
DBやファイルに書き出すには不適な情報は、IntentやBundleを使うことで下記のような形で受け渡しできます。
- Activity起動時のパラメータ・返り値: Intent
- UIの一時的な状態: onSavedInstanceState()のBundle
- Serviceの進捗状態などのイベント: Broadcast Intent, LocalBroadcastManager
IntentもBundleもParcelableで、プロセス間通信やプロセスの再生成に備えた状態保存のために設計された仕組みです。そのためか、含められるデータの容量には**1MBの制限があります**。この容量を超える情報をやりとりor保存するのは不具合("!!! FAILED BINDER TRANSACTION !!!"のログが出てデータが空になるなど)が出るので禁止です。
いやいやそんなことは知っているけど
正直、表示のたびにリクエストを投げたり、次のActivityにどでかい全表示データをIntentで投げつけることに比べて圧倒的に開発が大変になり、大人の事情(ry
そうなるとActivityライフサイクルの中でリクエストを管理したり、1MB制限回避のためにあの手この手を使わねばならず、Androidの闇or沼にどっぷり浸かってしまいかねません。
前提2: Fragmentとそのライフサイクル
Fragmentはタブレット向けのマルチペイン(縦分割など)表示に対応するため、またUIの再利用のために追加されました。
https://developer.android.com/guide/components/fragments.html
Activityとライフサイクルが同期されていたり、イベントの直接のやりとりが推奨されていたり、画面回転時にインスタンスを維持する(setRetainInstanse(true))機能があったりして、Activityよりライフサイクル対応もやりとりも簡単にできる感じがあります。
しかし、Jake神からFragmentへの反対表明が出されるくらいに、よくクラッシュするし複雑になってしまっているのです。
非同期で操作するとクラッシュする問題
FragmentManagerへの操作はActivity側のonSaveInstanceState()と同タイミングで保存されるため、状態保存以降に変更が加わってActivityの再生成時にロストしないように、下記のパターンで意図的に(Googleによって)クラッシュさせられます。
- onSaveInstanceState()以降、概ねonPause()がreturnしたより後の操作(commitAllowingStateLoss()で回避は可能だけど状態が消えるかも)
- LoaderCallbacksのonLoadFinished()からのあらゆる操作(意図的に禁止されていて回避不能)
- タスクスイッチャを起動した瞬間など際どいタイミングで、戻るボタンを押したなどでFragmentが切り替わる場合(回避不能)
最後のが最悪ですが、非同期処理のcallbackでFragmentManagerを操作するのはやめろということのようです。
ライフサイクル多すぎて把握できない問題、getView()やgetActivity()またはgetContext()がnullでのクラッシュがすぐ起きる問題
Fragmentのライフサイクルも含めるともう1ページじゃ到底収まらない数のライフサイクルコールバックが定義されています・・。
https://github.com/xxv/android-lifecycle
また、下記のメソッドがnullを返すことがクラッシュの原因になることがよくあるようです。
- getView(): onCreateView()→onDestroyView()の外側
- getActivity()/getContext(): onAttach()→onDetach()の外側
ライフサイクルに応じて非同期処理を上手にキャンセルしないと簡単にNPEの原因になってしまいます・・。
いやいやそんなことは知っているけど
Fragmentはタブレット対応のときには結局必要になりそうな感じがあるし、クラッシュする以外は便利な仕組みなのでなんとかしてほしいという感じがあります。
前提まとめ
- Activityとプロセスにはそれぞれライフサイクルがある
- Activityはメモリが足りなくなると見えてないものから捨てられる
- フォアグラウンドのActivityやServiceがなくなるといつでもプロセス全体がkillされメモリから全ての状態がなくなる可能性がある
- →Activity間の通信に制約がある、onSaveInstanceState()の状態保存を実装する必要がある
- IntentやParcelable、onSaveInstanceState()はプロセス間通信を前提とした作りになっている
- ActivityやServiceはアプリを跨ぐ、跨らないに関わらず別プロセスになる可能性がある
- IntentやonSaveInstanceState()に含められるデータのサイズには1MBの制限がある
- →DBからロードしたデータなどでかいリストを渡すことはできない
- FragmentはUI再利用とタブレット対応のために用意されていて、使っていく必要がある
- でもFragmentは状態保存の信頼性のために、onSaveInstanceState()後にFragmentManagerを使うとクラッシュする
- でもライフサイクルが複雑すぎる
- でもViewがすぐにnullになる
ベストプラクティス
この活動を通して、我々は以下の価値に至った。
同じ情報の表示方法を変える場合や、複数画面で一時的な状態を共有する場合は、Activity内のFragment遷移でやる
そのこころは?
例えば、写真系アプリでサムネイルをタップしたときに、拡大表示され横スワイプで移動できるパターンはよくあると思います。
このとき、新しいActivityを開く方法でも実装できますが、下記の理由からFragmentやViewによる遷移で実装するのが望ましいです。
- Activityをまたがる場合のデータ受け渡しや状態保存(選択状態など)の面倒を回避できる
- Activityはナビゲーションの仕組みでもあるので、同じ情報の表示や、編集状態などは1つのActivityの範囲内に留めておくのが望ましい(iOSでいうModal Context)
どうやって?
2枚のActivityの代わりに2枚のFragmentを使い、fragmentManager.replace(...).addToBackStack(...)
で遷移します。
画面遷移のアニメーションが必要な場合はFragmentのTransitionを使えばActivityに近いことが実現可能なはずです。
または、Activity/Fragment上のViewをsetVisibility()などで制御したり、自前でanimationをかけます。
選択状態を2つのFragmentで共有する際は、Activityの方に状態を持たせておき、それぞれのFragmentからそれを取りに行くとうまく作れるはずです。後述のようにViewModelを導入するとうまくいくかもしれません。
罠はないの?
遷移元のFragmentに設置されたViewPagerでFragment**PagerAdapterを使うなど、Child/Nested Fragmentを使っているとFragmentのTransitionの際に画面が真っ白になってしまう問題があるそうです。
また、画面の役割が大きく異なる場合や、Google Play Storeのように、アプリ一覧→タップすると詳細というようなナビゲーションをする場合、詳細画面に直接ジャンプする場合は、Activity遷移で実装しておいたほうが良いと思います。詳しくはUp Navigationに載ってる例を参照してください。
Fragmentを使いつつ、その切り替えはユーザの操作によってのみ発生するようにする
そのこころは?
Jake氏みたいにFragment全面的にやめることもできますが、クラッシュさえなければFragmentとおつきあいしたほうが下記の理由から楽です。
非同期処理でFragmentを切り替えるケースはロードした内容をFragmentに渡して表示するケースだと思いますが、ここはGoogleに従ってやめます。
- タブレット対応するにはFragment使っておいたほうが楽
- 上記のActivity内の画面遷移を実装するのもFragmentのほうが楽
- 画面回転対応もFragmentのsetRetainInstance(true)が楽
どうやって?
ロード完了後にsetArguments()などするなどしてFragmentを切り替える代わりに、ボタンがタップされたその場でFragmentを切り替えてからFragmentまたはActivity上でロードを始めます。ロードに失敗した場合もFragmentを閉じる代わりに「失敗」をView上に表示します。
罠はないの?
心を鬼にして(or Material Designに準拠して)作れば非同期のFragment操作はなくせるはずです。ただし、ロード完了後にFragmentにsetArguments()で渡していた情報を、Fragment間で直接やり取りしたくなる場合があると思います。その場合は、複数のFragmentに対して同じデータを供給するための仕組みを用意する必要があるかもしれません。後述のようにViewModelを導入するとうまくいくかもしれません。
非同期処理を含むロジックが、直接ViewやActivity Context、Fragmentに依存しないような設計を導入する
そのこころは
非同期処理の結果を直接Viewに反映するようなところが1箇所でもあると、ActivityやFragment(getView())のライフサイクル、特に画面回転と戦うことになってしまいます。このAndroidのツラミを回避する方法の1つとして、非同期処理を含む表示ロジックと、実際にViewに反映する処理に完全に分離するという手があります。
Viewへの直接の依存を完全に排除すると、1つのモデルクラスの状態を複数のFragmentやViewに表示することができるようになり、タブレット対応や(写真の拡大表示機能のように)表示方法が変化するViewへの対応、setRetainInstance(true)を使った画面回転への対応が捗ります。
どうやって
ViewやActivity Context(context.getString()などApplication ContextへのアクセスはOK)、Fragmentに一切触れないViewModelを用意し、そのロジック・状態を、Data Binding LibraryやRxJavaを使うなどして、ObserverパターンでViewに紐づけます。画面遷移やDialogなど、Activity、Fragment、Activity Contextを必要とする処理はViewModelが発行するイベントを通して実装します。
複数Fragmentで使うモデルにする場合は、DialogやActionBarなどのための共通処理をActivityに書いておくとやりやすいかと思います。
罠はないの?
画面回転の問題は100%解決されるわけではないです。
- 実行中の非同期処理を維持する必要がある場合(例:コメント投稿中に画面回転された)、モデルをFragmentに載せてsetRetainInstanceState(true)をうまく活用する必要がある
- (ただの)Dialogは画面回転すると消えるので、DialogFragmentに載せるか表示状態を保存する必要がある
また、ViewModelを複数のFragmentで使い回すような実装をするとき、Activityを経由するなどしてViewModelを配る方法を用意する必要があります。
追記: モデルの画面回転対応、Googleが作ってくれました!
上記のモデルを画面回転後も維持する仕組み、ずっと作りたいと思ってたんですが、なんとついにGoogleが作ってくれました・・!
Google I/O 2017で発表された、Android Architecture Componentsの中のViewModel(本来の意味と違うけどw)がそれです。
HogeViewModel viewModel = ViewModelProviders.of(activityOrFragment).get(HogeViewModel.class);
仕組みはやはり、setRetainInstanceState(true)
したFragmentの上にモデルのインスタンスを載せる形になっているそうです。多分Activityを経由して複数のFragmentにモデルを配るような使い方も、このViewModelでできると思います。
http://yslibrary.net/2017/05/19/android-how-viewmodel-retain-istself/
https://willowtreeapps.com/ideas/google-i-o-2017-the-viewmodel-is-nice-from-up-here
これで画面回転対応も一瞬ですね・・!(ただしModelからViewへの依存を完全に排除済みな場合に限る)(ただし、onSaveInstanceState()はメモリ不足でkillされる時用にちゃんと実装しなくちゃならないので注意してください)
しかも、最初の「理想的なコンポーネント間のデータのやり取り」で書いてた、SQLiteを中心としたコミュニケーションの壁となっていたあの面倒なSQLiteインタフェースに対して、ついに公式のwrapperライブラリ(Room)が用意されたようです。
http://shaunkawano.hatenablog.com/entry/2017/05/29/202111
https://developer.android.com/topic/libraries/architecture/room.html
ベストプラクティスまとめ
- 同じ情報の表示方法を変える場合や、複数の画面が一時的な状態を持つ場合は、Activity内のFragment遷移として実装する
- ナビゲーションの表現としてきれい、一時的なView状態を直接渡せる(IntentやDBじゃなくてよい)
- Activityの側に状態を持たせておき、それを各Fragmentで表示する
- Fragmentを使うが、ユーザの操作によってのみFragmentManagerの操作=Fragmentの切り替えを行う
- Googleの方針に従えばクラッシュはあまり起きないはずで、画面回転もタブレットも便利
- ロード結果をFragmentのsetArguments()に渡している場合は、Fragment切り替えてからロードを始めるように変更する
- 非同期処理を含むロジックが、直接ViewやActivity Context、Fragmentに依存しないような設計を導入する
- 非同期処理の結果でViewを触るのをやめればライフサイクル系クラッシュは避けられる
- Viewのための状態とロジックを持ったViewModelと、ViewModelの状態をViewに反映したりActivity Contextを操作したりするbinding処理に完全に分離する
続編:ViewModel方式もうちょっと詳しく書きたい気持ちがあります。フィードバックもお待ちしています・・!
→Googleが仕組みを用意してくれたので、そのうちやってみます・・!