情報や状態の期間と保持について
AndroidやiOS向けのアプリケーションを考えていると、情報や状態の持ち方に悩むことがよくあります。
ここでは、AndroidやiOSのアプリケーションを設計したり、より良いコードを考えるためのコードレビューにおいて重要な、情報や状態の期間と保持について整理します。
情報の期間
アプリケーションの中では、多くの情報を扱います。また、アプリケーションごとに必要となる情報が変わるため、網羅的な議論は難しくなります。
このため、筆者が関わったことのあるいくつかのアプリケーションをベースに、5つほどにまとめました。大半のものは分類できるのではと思いますが、複数の要素と見做せるものも含めて、完全に分類できているとは言い切れません。あくまで、話の整理のために分割したものです。
- サービスを成立させるための情報
- ユーザーアカウントを成立させるための情報
- ユーザーのための画面を成立させるための情報
- ユーザーの操作を管理するための情報
- ユーザー操作の結果を表示するための情報
この分割は情報の期間、つまり情報がいつからいつまで存在されることが期待されるか、に着目しています。というのも、あるサービスを利用するためのアプリケーションの中では、さまざまな期間を持つ情報が存在するためです。
例えば、サービスの1機能として記事を配信している場合、その記事はサービスが記事機能を提供している間存在することが期待されます。また、ユーザーが記事にコメントを投稿できる機能がある場合には、コメント投稿用テキスト入力エリアに入力されている文字文字列はユーザーが文字入力を開始し、投稿するか取りやめるか決めるまでの間存在することが期待されます。
個別具体的に、これらの情報が存在する期間、つまり日数などを述べることはできません。
その代わりに、その期間がどれほど長いと思われるか、概念的なものを整理します。
サービスの期間
アプリケーションをあるサービスを提供する仕組みと考えると、アプリケーションはサービスを提供するための1つの手段です。
ここでは、アプリケーションが提供しているサービスにおいて、サービスを構築するための情報をサービスの期間と呼びます。
サービスを提供し始めた時からサービスをクローズするまで、サービスの期間は存在することになります。厳密に考えれば、リリース前からサービス終了後まで、存在します。リリース前はユーザーから見えないこと、サービス終了後は終了したことをお知らせする等に限定されることから、ここでは扱いません。
なお、リリースしているサービスがAndroidやiOSの中で完結する場合、つまりバックエンド的な存在を持たないこともあります。この時は、ストアにアプリケーションをリリースしている間がサービスの期間に該当すると思います。とはいえ、ある規模感以上のサービスであれば、端末の中だけで完結しにくくなります。このため、練習用のアプリケーションでは該当しやすいかと思いますが、日々の業務で取り組むアプリケーションではレアケースかなと思っています。
アプリケーションの期間
アプリケーションがインストールされてからアンインストールされるまでの期間を、アプリケーションの期間と呼びます。少し厄介なのは、AndroidやiOSに備わっている自動バックアップの仕組みです。自動バックアップを適切に利用すると、端末からアプリケーションを削除しても再インストール時に参照できるようになります。
このため、アプリケーションが起動してから終了するまでを、アプリケーションの期間と捉えた方が、日常的な開発では扱いやすいと思われます。期間の概念を分割することもできそうなのですが、分割したところで、適応する情報はほぼ同じになるはずです。
代表的な情報としては、サービス内でユーザーを特定するためのIDがあげられます。
この期間は、ユーザーがアカウントを作成した時からアカウントを削除した時まで、保持しなければならない情報です。AndroidやiOSアプリケーションにおいては、ほぼアプリケーションをインストールして初回の利用をした時から、退会しアンインストールするまでの期間に一致します。アプリケーションにサインインの機能がある場合には、初回のインストールからサインアップ処理を済ませるまでの間になったり、サインインが必要なタイミングまでずれ込むことはあります。
サインアップしアカウントをせずとも機能が利用できる場合、サインインしていないという情報を扱うこととなります。サインインがされているかどうかなどを、アプリケーションの起動時から終了時までUIに反映する必要があるためです。
画面の期間
AndroidやiOSアプリケーションはさまざまな画面で構成されます。この画面の期間を画面の期間と呼びます。フレームワークが提供する画面遷移処理において、遷移が管理される画面を想定しています。このため、AndroidにおけるActivity
や(特にSingleActivityにおける)Fragment
、iOSにおけるUIViewController
が該当します。
なお、近年では、タブレットやデスクトップ向けの開発も想定されるようになりつつあります。このケースでは、スマートフォンにおける複数の画面が、タブレットやデスクトップで1つの画面にまとめられることがあります。議論をシンプルにするために、ここではスマートフォンにおける1画面を主に想定します。
画面の期間は、フレームワークによるところもありますが、おおよそ画面が表示されてから、される前の画面に戻るまでの間となります。一例を挙げると、Aという画面を開いたのち、Bの画面を重ねる形で開いた場合、Aの画面は(前面に表示されていなくとも)保持されています。Bの画面からAの画面に戻り、Aの画面からそれ以前の画面に戻った際、Aの画面は破棄されることとなります。
この設計は、画面ごとに機能がまとまっているケースで役立ちます。タイムラインを眺める画面であれば、画面を開いた時にタイムラインのリストを取得すると、開いた時に最新のタイムラインを表示できます。もしもユーザーがタイムラインの更新をしたい場合には、画面の中に更新ボタンを追加し、タップされた際にリストの再取得が可能です。画面を戻る、つまりタイムラインのリスト画面を閉じた場合には、タイムラインのリストをメモリ上から破棄します。
ViewやWidgetの期間
画面の中に配置されている、ViewやWidgetなどの期間をViewやWidgetの期間と呼びます。AndroidやiOSのアプリケーションにおいては、UIに依存しないバックグラウンドタスクを除くと、最も期間が短いものになります。
Widgetは、AndroidにおけるRecycleViewなどのように画面で表示されている間だけ存在するものと、表示される要素としてレイアウトされている間存在するものがあります。この違いは大きいのですが、Widgetの呼び出し方の問題であるので、それぞれのWidgetでは呼び出されてから破棄されるまでの処理、ライフサイクルに合わせた実装が必要となります。
俯瞰してみると、画面もViewやWidgetの一形態と見なすことができます。Flutterのように、アプリケーションに関わる大半のクラスがWidget
であるフレームワークにおいては、アプリケーションとして振る舞うWidget
とそれ以外のWidget
ということもできます。このため、単純に継承元のクラスで分割しようとすると、なかなか話がまとまりません。
ここでは画面の期間とViewとWidgetの期間を分けることで、アプリケーションを構成する最小の期間と、それ以上の意味を持つ期間を分けることを試みます。
状態を保存する領域
AndroidやiOSでは、file systemへの書き込みや、OSが提供するSQLiteが利用できます。またCやC++のライブラリを持ち込むことで、その他のDBなどを持ち込むこともできます。
メモリー(揮発性メモリ)とディスク(不揮発性メモリ)が利用できることは、AndroidやiOSアプリケーション開発において重要な要素です。
メモリー
最も基本的な保持は、メモリー上での保持です。
ここではAndroidとiOS、Flutterのdebugモードとreleaseモードの違い。そして端末内にある複数のメモリーについて、一旦無視してしまいます。というのも、KotlinやSwift、Dartでコードを書いている際には、あまり気にしない項目なためです。
また、デスクトップ向けと違い、いわゆる主記憶装置をSwap領域として利用することも想定しません。AndroidやiOS的には、Swapが発生するような状況では、そもそもアプリケーションが立ち上がらない状況になっているためです。
メモリー上に置いた情報は、アプリケーションにおいては、その読み書きの時間を無視して実装をできます。このため、メモリー上に展開した情報を読み取るロジックが複雑でない場合、同期的な処理で実装できます。だいたいList
やMap
、常識的なサイズのクラスであればこの範疇に入るでしょう。
他方、メモリー上でDBを構築した場合、例えばAndroidのRoomをin memoryで構築する場合やiOSのRealmをin memoryで構築する場合には、非同期処理となることもあります。
コードがリークしていない場合、メモリー上のデータはアプリケーションの破棄時に失われます。このため、アプリケーションを立ち上げ直した時に再取得できる情報であったり、アプリケーション内の特定の画面のみで利用したいデータは、メモリー上に展開できます。一方で、アプリケーションの状態によらずに保持されているべき情報は、メモリー上だけで保持するべきではありません。
また、利用できるメモリーの量に注意する必要があります。例えば超微細な画像を数十枚メモリー上で保持してしまうと、利用できる領域を食い潰してしまうでしょう。一般的にスマートフォンに搭載されているメモリーは、デスクトップPCのものと比べて少なくなります。Android向けの開発では、ユーザーが利用している端末が多種多様なため、思っているよりも少ないメモリーしか利用できない場合があります。
ディスク
AndroidやiOSでは、file systemへのアクセスをできます。
AndroidとiOSで差異はあるのですが、ざっくり整理すると、アプリケーション用のファイルを保存する領域とアプリケーション用のキャッシュを保存する領域が存在します。後者はキャッシュ用のため、任意のタイミングで再駆除されても良いものを置くこととなります。
なおアプリケーション用のファイルは、OSが提供するバックアップ機能の対象になることがあります。どの箇所がどのように保存されるかについては、それぞれのOSのドキュメントをご確認ください。
キャッシュ用の領域で良いものを、アプリケーション用の領域においてしまうと、バックアップ用の領域を使い切ってしまう問題を引き起こすことがあります。
この領域には、さまざまなファイルを保存できます。アプリケーションでpathを管理すればよいので、保存する形式は問われません。例えばJSONやXML、JPG、Textといったメジャーなものが利用されるでしょう。
実装さえしてしまえば、大抵の情報はスマートフォンの中に保存できます。このため、3Dモデルなども保存可能です。
メモリー + ディスク
AndroidやiOSでは、メモリーとディスクを適切に組み合わせたライブラリが提供されています。
典型的な例としては、AndroidのSharedPreferencesです。これは、ディスク上にxmlでファイルを保存しつつ、アプリケーションの動作中は(基本的には)メモリー上に展開されたキャッシュへの読み書きを行います。
この動きについてはSharedPreferences.Editor.apply()に記述があるので、気になる方は確認してみてください。
また、高速な画像の表示のためにもよく利用されます。AndroidではPicassoやGlide、Coil。iOSではSDWebImageやKingfisher、Nuke。そして、Flutterではcached_network_imageが典型例でしょう。
これらのライブラリは、サーバーから取得した画像をメモリー上にキャッシュし、必要になったタイミングで高速に展開します。もしも大量の画像を保持することになった場合には、優先度の低い画像をメモリー上から破棄し、必要になったタイミングでディスク上に保存したキャッシュファイルから再取得します。
Cloud FirestoreやRealm Syncなどは、この仕組みを上手に利用しているサービスです。
これらのサービスは、クライアントから見た場合、DBへのアクセスをアプリケーション内部で生成したインスタンスの操作で賄うことができるようになります。サービス側がディスクやサーバーへのリクエストなどを行っているのですが、ライブラリの作り上、メモリー上の操作のように感じられる、というわけです。
情報や状態の管理と整合性
状態管理には、2つの側面があります。1つはサーバーからクライアントに情報を引っ張ってくること、そしてもう1つはユーザーの操作に応じて情報を更新することです。
サービスが提供する情報の量が少ない場合、すべての情報をアプリケーションに組み込んで提供することがあります。しかし、手元のスマートフォンを眺めてもらえればわかる通り、大半のアプリケーションはそのような実装をしません。
これは情報の量が組み込むのには大きすぎる、最新の情報が常に更新されている、Webサービスとの連携が行われるなど、さまざまな理由があります。これはサービスの期間の情報を、クライアント側で取得して利用する、といえます。最も長い期間をクライアントで保持することになるため、アプリケーションの期間でもViewやWidgetの期間でも、任意の期間で保持できます。
ユーザーの操作に応じた情報はどうでしょうか?
極端な例になりますが、アプリケーションの内部で扱う情報をサーバーやディスク上で管理する実装は可能です。この場合、ユーザーがアプリケーションを間違って閉じたとしても、最後に開いていた画面を復元できます。ただし、操作のたびに保存の通信や書き込みが発生してしまいます。ゲームなどでは見る気もしますが、大半のアプリケーションでは、この設計を採用することはないでしょう。
情報や状態は、その特性や期間に合った方法で保持する必要があります。「より良い」状態管理を考えるひとつの補助線が、情報や状態の期間であり、保持される場所です。
アプリケーションの特性との合致
アプリケーションがサービスを利用するためのものである以上、サービスの違いの数だけアプリケーションには違いがあります。
当然ではありますが、サービスの大きな違いがアプリケーションのあり方に違いを及ぼさないこともありますし、小さな差が大きな違いを生み出すこともあります。
以下では、筆者が特に意識している、3つのケースを紹介します。
サーバーとの通信回数を最小限にするケース
サーバーへのリクエストを抑えることで、通信データ量やバッテリーの消費量を抑えるケースです。
多くの場合、AndroidやiOSのアプリケーションはこのケースに該当すると思われます。
Androidのドキュメントにおいて、オフラインファーストと表現されているコンセプトも含まれます。日常生活の中でも、例えば奥まった場所にあるエレベーターの中でスマートフォンを開いた時や、初めて入ったカフェでWi-Fi機能のみのタブレットを開いた時などなど、端末がオフラインになることはあります。特にAndroid向けのアプリケーションを海外展開した場合には、利用できるインターネット回線が安定していない状況のユーザーがいるかもしれず、重要なコンセプトとなります。
このケースでは、HTTPのキャッシュを利用するものと、そうではなく開発者が独自にキャッシュを作成するものがあります。
HTTPのキャッシュでは、iOSにおいては、URLSessionがETagを標準的に利用され、AndroidではOkHttpのcacheが利用されることが多い印象です。オプションの設定によって振る舞いが変わる点も多いのですが、基本的には、HTTP Cacaheの仕様を読んだ上で、プラットフォームごとの実装を確認することとなります。
Flutterの場合、標準的なhttpにHTTP Cacheの仕組みが備わっていません。このため、flutter_cache_managerを自前で追加するか、dioをdio_http_cacheと組み合わせて使うなどの一手間を加える必要があります。
開発者が独自にキャッシュを実装する場合、ディスク上に保存する仕組みを構築する必要があります。
もっとも典型的なものは、画像のキャッシュです。画像のリクエストにおいては、HTTP Cacheではなく、典型的にはURLをキーとした永続的なキャッシュを作成します。他にはApolloのキャッシュなども、独自の仕組みを利用しています。
これらの他に、GoogleMapのオフラインマップのような、オフラインでも機能が利用できるようにするキャッシュ。ゲームにおける、音楽などのリソースを事前にダウンロードする仕組みなどがあります。
キャッシュを利用して通信回数を最小限にしようとすると、細かなキャッシュの管理が必要になります。
このため、サーバーとディスクの間で情報を適切に管理する、Repository層を実装するといった議論が進みます。
サーバーからのレスポンスがクライアントの状態と一致するケース
サーバーから得られるレスポンスが、クライアントにおける状態と一致する場合、サーバーからのレスポンスを中心に設計します。
このケースでは、クライアント側で状態をどのように管理することよりも、サーバーからのレスポンスをどれだけ適切に管理できるかが重要になります。つまりいつまでキャッシュを利用して良いのか、そしてキャッシュをどのタイミングで更新するべきかに主眼が置かれます。
この実装はWebの開発において、議論が先行している印象です。例えばreact-queryやswrなどです。サーバーからのレスポンスを簡単にキャッシュし、それを元にViewを構築する設計が議論されています。
GraphQLを利用する場合には、画面の構築に必要なプロパティをqueryとして表現できるため、表示したい状態とレスポンスのキャッシュが一致しやすくなります。
レスポンスがWidgetの表示と一致するということは、Widgetの内部でサーバーへのリクエストを行っても問題がないことになります。このため、不必要にあるWidgetが別のWidgetの子要素となるケースを避けることができ、レイアウトの自由度が高まります。タブレットやデスクトップ向けのレイアウトを作成する際、スマートフォン向けでは表示しないWidgetであっても、特に考慮する必要なくWidgetを追加できるようになるのは強みです。このため、ViewやWidgetに処理を閉じた方が、実装がしやすくなります。
一方で、どのViewをどのようにレイアウトするかまで、レスポンスでコントロールできるようになります。このため、レスポンスを取得するWidgetが、そのWidgetの子要素をレスポンスに応じて組み替えることも可能です。
この見方をすると、最終的にレイアウトにクライアントとサーバーのどちらが責任を持つのかが議論の中心になります。というのも、サーバーサイドからHTMLの代わりにJSONを配布することで、アプリケーションのUIを決定する仕組みであるからです。アプリケーション側のアップデートに依存しないレイアウトのアップデート、A/Bテストの実行などが、サーバーサイドで処理を切り替えるだけで実現できるようになります。
多くの処理がクライアント側で行われるケース
カメラによる写真や動画の撮影、録音などの端末上で処理がある程度完結する場合、通信処理はあまり重要な処理になりません。
このようなアプリケーションでは、アプリケーションの目的を達成するために、サービスに応じた設計をすることとなります。
Android端末に搭載されている時計アプリのコードを見ると、一般的に議論されるような設計になっていません。しかし、これは時計アプリが複雑であるためであり、必要に応じた実装になっているように感じます。
このように、スマートフォンという端末にガッチリと取り組んだアプリケーションの場合は、処理の大半がクライアント側で行われることになります。
アプリケーション(=サービス)の主体が、クライアント側にほとんどあるケースだといえます。
この場合、AndroidやiOSのさまざまな都合に合わせて、設計をすることになります。例えば、アプリケーションの実行中に他のアプリケーションを立ち上げたり、端末が再起動した時にも動作するようにしたり、などです。一般化して議論することが難しいため、ここではほとんど扱わないこととします。
おわりに
普段アプリケーションのことを話す時、頭に浮かべていることをざっくりとまめました。
ここからは、筆者の意見を簡単に述べておきます。
状態の管理について
AndroidとiOS、Flutterにおける、状態管理の考え方の差はほぼなくなってきたように思います。
かつては、フレームワークがViewやWidgetごとの状態管理に関心をあまり持っていないiOS、ViewやWidgetではなくActivity
やFragment
毎の管理を行っていたAndroidという印象がありました。しかし、宣言的UIが取り入れられてからは、SwiftのAsyncStreamやKotlinのFlowといったプログラミング言語の力を活用して、ViewやWidgetごとの管理がしやすくなっています。Flutterにおいては、ProviderやRiverpodを利用することで、コード量を抑えつつ実装ができる状態になっています。
ディスク上で情報を保存する手法については、AndroidがiOSやFlutterよりも整備されています。公式にリリースされているRoomやPaging、そしてOSSで開発されているStoreのようなライブラリが整っており、メモリーとディスクの利点を掛け合わせた実装がしやすくなっています。iOSではRealm、FlutterではIsarを利用することはできますが、Androidに比べると劣っている印象です。
とはいえ、この差が大きく出るのは多くの処理がクライアント側で行われるケースだと思われます。ほとんどのアプリケーションでは、宣言的UIフレームワークの使い勝手が、最も違いを感じる箇所となるでしょう。
宣言的UIを利用できるフレームワークであれば、最小単位のViewやWidgetは可能な限りどこからでも呼び出せる設計が望ましいと感じています。というのもWidgetのテストは該当のWidgetだけを呼び出せば良くなり、UIを変更する場合にも修正範囲が最小限になるためです。MVx
のVが交換可能であることは、GUIアプリケーションでは重要だと思っています。
とはいえ、従来のMVVMを否定するものではありません。むしろ、従来のMVVMの議論の延長上にあるものだと考えています。
従来のAndroidでは、ライフサイクルの基準となるのがActivity
やFragment
であったために、AACのViewModel
が適していました。同様に、宣言的UIのAndroidやFlutterであれば、ライフサイクルの基準を個別のViewやWidgetに寄せれるだけ寄せたい、という意見です。とはいえ、Button
やText
ごとに状態の管理をする必要性はなさそうなので、寄せるのもの程度問題です。現状では、再利用したくなるようなWidgetとしてまとめると言う方針が、最も柔軟性が高い単語選びになると考えています。結果として、それがViewModelに帰ってくることも、あり得なくはないなと思っています。
どういった状態の管理をしていくか
状態管理ライブラリの議論は、大抵の場合、ViewやWidgetの期間でどれだけ情報を扱わないかを考えるものだと思っています。
上記の公式ドキュメントでは、非常にシンプルなアプリケーションが例に挙げられています。ここで述べられている状態をWidget Treeの上部で管理するという方針は、そのまま受け入れられるものです。問題は、大きなアプリケーションにおいて、すべての情報・状態をMyApp
に持たせるべきかどうかです。これまでに定義してきた単語を使えば、末端のWidgetの状態を、すべてアプリケーションの期間で管理するべきか、という話になります。
Providerが優れていたのは、この期間の管理を、Providerで括ったWidgetの子要素
とできたことだと考えています。公式サンプルの例で言えば、MyCatalog
とMyCart
を束ねたWidgetを置くことで、MyLoginScreen
では考慮しなくて良い実装をしやすくしました。全てをMyApp
で管理することのほうがシンプルだとは思いますが、大きなアプリケーションでは、このようにどの範囲で状態が管理されるかをコントロールできるようになると、設計の幅が広がると思います。
2023年2月現在では、この議論がRiverpodの登場により、もう一歩進んでいる印象です。Riverpodは(、自分の理解では)、基本的にはアプリケーションの期間で情報や状態を管理します。しかし、.autoDispose
修飾子を使うことで、RiverpodのProviderを参照しているWidgetが破棄された際に、disposeできる仕組みを備えています。この仕組みにより、Providerを呼び出したWidgetの期間、つまり任意のViewやWidgetの期間で情報や状態を管理しやすくしています。
これらはサーバーとの通信回数を最小限にするケースのアプリケーションにおいて、よりFlutterらしい設計にできる力があると感じています。
サーバーからのレスポンスがクライアントの状態と一致するケースは、Webの知見をAndroidやiOSに持ち込もう、という流れにあると思います。
JSON色付け係の議論ではありませんが、もとより、クライアントの責務がレスポンスを解析してレイアウトを組み上げるだけ、という側面はありました。「Web上で設定するだけで、アプリケーションのレイアウトが自由に組み替えられる」サービスも存在していましたし、クックパッドのReclerViewとGroupieを組み合わせる試みなども行われていました。これらが、宣言的UIが利用できるようになったことで、よりメジャーになったものだと感じています。
レスポンスを保持する期間としても、アプリケーションの期間から画面の期間、そしてViewやWidgetの期間まで、任意の期間を選択できる必要があります。これらの期間を調整しやすくなってきたこともあり、サービスによっては、十二分に魅力的な考え方になってくるのではと思っています。
Discussion