Jetpack Composeへの移行で期待したこと
では、Jetpack Composeのどのような特徴が、これまで挙げてきたAndroid UI Frameworkの課題を解決するのでしょうか。
宣言的UI
宣言的UIとは、宣言型プログラミングを用いて構成されたGUIや、それを実現する手法のことです。
これと対比される命令的UIフレームワークでは、「ログインしているならこのボタンは非表示にしろ」や「文字数を超過しているなら文字色を赤色に変えろ」といったように、個々のUIパーツに対して、目標となる振る舞いをするための手続きを記述していきます。
それに対して宣言的UIでは、「ログインしているなら画面はこう表示される、していないならこの表示になる」というように、状態をもとにあるべきUIを宣言的に記述します。
どうやって(How)ではなく、何を(What)に注目するという言われ方もされます。
具体的に例をとって見てみましょう。フリマアプリにとって商品ページは、ユーザーが商品を購入するための画面であると同時に、出品者にとっては自分の商品を確認・編集するためのページでもあります。以下の2つのコードは、アプリの商品ページの下部に購入ボタンを置くか、編集ボタンを置くかを決定する、従来の手法とJetpack Composeでの少し誇張された例です。
override fun onCreate(savedInstanceState: Bundle?) { setContentView(R.layout.fragment_item) viewModel.item.observe(viewLifecycleOwner) { item -> // 出品者が自分であるかどうかに応じて購入ボタンの表示状態を変更する val bottomButton : Button = view.findViewById(R.id.button_bottom) if (item.seller.isSelf) { bottomButton.text = "商品編集" bottomButton.setBackGroundResource(R.drawable.button_secondary) bottomButton.setOnClickListener { navigateToItemEdit() } } else { bottomButton.text = "購入する" bottomButton.setBackGroundResource(R.drawable.button_primary) bottomButton.setOnClickListener { navigateToItemEdit() } } } }
setContentView()
でこの画面の全体のレイアウトを定義し、その後のコードで商品の出品者が自分であるかどうか応じて、画面下部にあるボタンを購入ボタンと商品編集ボタンで切り替えるために、表示状態を更新する操作を記述しています。このように、全体のレイアウトを定義した上で、個々のUIコンポーネントに対して更新命令によって表示状態を更新し、それによって目的とするUIの表示を達成する手法のことを、命令的UIといいます。
一方でJetpack Composeでは、次のように書かれるでしょう。
setContent { val state : ViewState by viewModel.viewState Scaffold( topBar = { MyAccountTopAppBar(...) }, content = { Column(modifier = Modifier.fillMaxWidth()) { ... // その他のUIコンポーネント if (state.item.seller.isSelf) { EditButton() } else { PurchaseButton() } } } ) }
これがsetContent()
メソッドであることに注目してください。ここでは、ボタンの表示状態を操作するような手続きは書かれていません。
そうではなく、画面全体のレイアウトを定義する箇所で、「自分の商品なら商品編集ボタンが表示される。そうでなければ購入ボタンが表示される」と、状態に対してどうあるべきかが書かれています。この箇所はstateの値が変更されるたびに呼ばれ、そうして表示が更新されます。
このように、状態に対してあるべき表示を記述し、状態が変化するたびに表示するUIすべてを再構成する手法のことを、宣言的UIといいます。
宣言的UIは、それほど新しい概念ではありません。JavaScriptのなかった時代の、文字とハイパーリンクで構成された古典的なHTMLも宣言的なUIであったと言えます。その後、JavaScriptでDOMを操作するようになり複雑化が進んでいきましたが、Reactの華々しい成功以降、Webアプリケーションだけでなく、モバイルネイティブアプリの分野においても宣言的UIの流れがきています。React Nativeはもちろんですが、LithoやFlutter、iOSでもSwift UIのように、立て続けに宣言的UIを採用したフレームワークが登場していることからも伺えるでしょう。
今後、モバイルアプリ分野でも宣言的プログラミングがデファクトになっていくと予想されます。近年のスマートフォンアプリは高い機能性が求められ、それにより様々な状態に応じて複雑に変化するUIを実装することが求められてきています。こうした要求を安全に、かつ生産性高く開発するためには、宣言的なUIフレームワークのほうが適しています。
先程の例に戻って、今度は次の仕様を追加してみましょう。
eコマースによっては、一般の会員資格とは別に「プレミアム会員」を用意して、有料で特別なサービスを提供していることがあります。そこで、プレミアム会員の場合には送料無料で購入できることを示すために、購入ボタンのテキストを「送料無料で購入する」と変更するようにしてみましょう(なお、PayPayフリマではプレミアム会員に限らず、すべてのユーザーが送料無料で購入することができます)。
素朴に作った場合、既存のUIフレームワークでは、次のようになるでしょう。
override fun onCreate(savedInstanceState: Bundle?) { setContentView(R.layout.fragment_item) viewModel.item.observe(viewLifecycleOwner) { item -> // 出品者が自分であるかどうかに応じて購入ボタンの表示状態を変更する val bottomButton : Button = view.findViewById(R.id.button_bottom) if (item.seller.isSelf) { bottomButton.text = "商品編集" bottomButton.setBackGroundResource(R.drawable.button_secondary) bottomButton.setOnClickListener { navigateToItemEdit() } } else { bottomButton.text = "購入する" bottomButton.setBackGroundResource(R.drawable.button_primary) bottomButton.setOnClickListener { navigateToItemEdit() } } } viewModel.user.observe(viewLifecycleOwner) { user -> if (user.isPremium) { bottomButton.text = "送料無料で購入する" } } }
しかしこれは、複数の問題を含んだコードです。もしユーザーがプレミアム会員で、ボタンを「送料無料で購入する」に変更したとして、その後に商品の情報が取得されれば、また「購入する」で上書きされてしまいます。ユーザーがプレミアム会員かどうか判定している箇所では、商品が自分の出品物かどうか判定していませんので、自分の商品に対して「送料無料で購入する」と表示されてしまううえ、そのボタンを押したら商品編集画面へと遷移してしまうなど、めちゃくちゃになってしまっています。このように、これまでの命令的UIフレームワークでは同一のUI要素を別々の箇所で操作できてしまうため、変更しようとしているUI要素が別の箇所で別の条件によって操作していないか注意深く確認する必要がありました
一方で、Composeであれば、次のようなコードになるでしょう。
setContent { val state = viewModel.viewState.observeAsState() Scaffold( topBar = { MyAccountTopAppBar(...) }, content = { Column(modifier = Modifier.fillMaxWidth()) { ... // その他のUIコンポーネント if (state.item.seller.isSelf) { EditButton() } else { PurchaseButton( label = if (state.user.isPremium) { "送料無料で購入する" } else { "購入する" } ) } } } ) }
これならば特に問題がありません。
実際のアプリでは、複数の異なるデータソースから得られる情報を組み合わせて表示内容を決定することは珍しくありません。UIの整合性を保つためには、できるだけ状態の管理は一元化されるべきであり、表示の決定ロジックも網羅的に記述されるべきです。もちろん命令的なUIフレームワークであったとしても、そのように実装することは可能です。しかし、フレームワークの思想として明確になっているほうが、実装も容易ですし、チームメンバー間での共通認識も作りやすいでしょう。
高速な描画
既存のViewフレームワークでは、あらゆるユースケースに対処するため、画面に描画されるすべてのViewに対して状態のトラッキングやさまざまなコールバックが実装されています。たとえばTextView
やImageView
などのすべてのウィジェットの基底クラスであるView
クラスには、そのレイアウトが変更されたことを検知するためのOnLayoutChangedListener
インターフェイスと、それをViewに登録するaddOnLayoutChangedListener()
メソッドが用意されており、それによってViewの内部でlayout()
メソッドが呼ばれたときの通知を受け取ることができます。
大部分の開発者にとっては、OnLayoutChangedListenerは使用するユースケースがほとんどなく、あってもアプリ内のごく一部に限られますが、しかしそうであっても、OnLayoutChangedListenerに対して変更通知を行うための種々の処理は、アプリに追加するすべてのView内で行われおり、これによって無駄なコストが生じています。
一方でJetpack ComposeではUI要素を生成後に操作することはありません。そのため状態のトラッキングやさまざまなコールバックは、それを必要としているComposable関数だけに削減されています。
UI要素の生成方法の違いによる差もあります。従来のアプリでは多くの場合、レイアウトをxmlに定義して、実行時にそれをもとに生成していました。厳密には、xmlはビルド時に解釈されており、そのときに作られた中間生成物をもとにレイアウトを作っていますが、それでも直接コードでViewを生成してレイアウトを行うよりは、速度面でわずかに不利となっています。これに対してJetpack Composeでは、すべてKotlinコードで書かれたCompose関数によってレイアウトされ、それを実行しています。このKotlinコードも他のコードと同じようにコンパイルされるため、xmlによるレイアウトと比較してコストが削減されます。
テスト可能な関数
現代のアプリケーション開発では、自動テストは欠かすことのできない大切な要素です。PayPayフリマでも、アプリを安全にユーザーに使っていただくためにテストを重視しています。自動テストの重要性は、アプリケーションが大規模化し、複雑化するにつれて高くなっていきます。
Jetpack Composeでは、個々のCompose関数単位でテストすることが可能です。
@Test fun `DebugMenu_valueがtrueのとき、onと表示されること`() { composeTestRule.setContent { MaterialTheme { DebugMenu(label = "testLabel", value = true) { } } } composeTestRule.onNodeWithText("on").assertIsDisplayed() }
これによって、細かくテストしながらレイアウトを実装していくことができます。また、Compose関数単位で細かくテストすることができるため、UI部品の再利用性も高まることになります。
既存のコードとの共存
アプリを開発・運営していくうえで最も大切なことは、ユーザーにとって使いやすく安定したサービスを提供し続けることです。そのため、これまでのコードベースの一切を捨て去って作り直しのために数カ月を費やし、その間は改修をストップする事態は避けなければなりません。Jetpack Composeは既存のコードベースと完全に共存することができます。既存のレイアウトの一部だけをComposeにすることもできますし、逆にComposeでつくられたレイアウトの一部にViewを追加することもできます。そのため、すべてのコードベースを捨てて一気に作り変えることなく、ゆっくりと移行していくことができます。
最終的に、既存のコードとの共存が可能であり、今後もこの方向での発展が見込めることから、技術的な知見の獲得をしておかないほうがリスクがあると判断して、試験的に導入してみることにしました。