7月27・28日、サイバーエージェントの次世代技術者による技術カンファレンス「CA BASE NEXT 2022」を開催しました。本記事では「Jetpack Composeのイマ、プロダクション導入への道」についてご紹介します。
本セッションは、こちらから動画でも確認できます。
Jetpack Composeのイマ,プロダクション導入への道【CA BASE NEXT 2022】 – YouTube
こんにちは、株式会社CyberZの森です。
Android向けの新しいUIツールキットであるJetpack Composeは、宣言的UIモデルが採用されただけでなく、パフォーマンス面等の複数の課題が改善がされており、導入する大きなメリットがあると考えています。
私の担当しているOPENREC.tvでは、積極的にJetpack Composeを採用してきました。
これまでに10を超える画面がJetpack Composeで書き直され、またほとんどの新機能はJetpack Composeで実装されています。
この記事では、導入に至ったモチベーションや、既存プロダクトへの組み込み方法、また導入過程で発生した課題とその解決方法について紹介します。
目次
1. Jetpack Composeのメリット
Jetpack ComposeはAndroid登場から初めてとなる大規模フレームワーク刷新で、完全に1から再設計がなされました。
2019年のGoogle I/Oにて発表され、2021年7月にStableになっています。
命令的UIと宣言的UI
Jetpack Composeの最大の利点として、宣言的UIモデルであることが挙げられるでしょう。
宣言的UIモデルと対比して、従来のAndroid Viewのシステムのことを、命令的UIと呼ぶことがあります。
上のコードの例は、リスト表示と空表示を切り替えるコードのイメージです。
命令的UIで記述した場合、空表示の削除し忘れによって表示が残ってしまうような不具合が発生しやすい状態でした。
命令的UIでは、定義されたステートに対して反応的に記述するため、何度実行しても同じ結果が得られるようになり、以前のような不具合が起きにくくなっています。
より洗練された宣言的UI
この宣言的UIモデルはReactやFlutter、そしてSwiftUI等、様々なフレームワークで採用されています。
後発であるJetpack Composeはそれらの教訓を経て、より洗練されていると感じています。
1つ目の特徴は、コンポーネントを表現する関数(Composable関数)は返り値のないコンポーネントとして表現できる点です。
これにより、複雑な条件が書きやすいというメリットがあります。
また、hooksと呼ばれる副作用をコンポーネントや条件式内に記述でき、Reactと比較して柔軟になっています。
パフォーマンス面でも工夫がされており、Composable関数は引数に変更がないとみなされれば、内部の実行がデフォルトでスキップされます。
パフォーマンスの改善
Android Viewからの大きな変化として、レイアウト時のパフォーマンスが改善されています。
Android Viewではレイアウトサイズを確定していく際に、親と子を何度も計測します。
Viewのネストが深くなっていくと、かなりパフォーマンスが悪くなるというのは聞いたことがあると思います。
これを改善するため、Jetpack Composeでは各コンポーネントは必ず一1度ずつのみしか計測させないシステムを採用しました。
これにより、ネストが深くなっても比較的パフォーマンス良くレンダリングでき、今までは難しかったレイアウトサイズが変わるようなアニメーションも実現しやすくなっています。
ライブラリでの提供
最後に紹介するJetpack Composeの特徴は、ライブラリで提供されている点です。
Android Viewでは多くの機能をOSで提供していたため、OSを更新するとUIが崩れる、OSを更新しないと新しい機能が使えないといったことが良くありました。
Jetpack Composeでは、OSの低レベルのAPIを利用することで、OSの更新に影響されず、Android 5以降のデバイスで同じように動作させることができます。
2. プロダクション導入の3つの方法
プロダクション導入の前に
プロダクション導入の前に、調査と検証を行いました。
Jetpack ComposeはGoogleが力を入れて開発しているため、比較的安心して利用できるフレームワークだと考えていますが、Android Viewと比較すると大きな変化のため、導入は慎重に進めました。
まず最初に、デバッグ用のツールとして採用を開始しました。
デバッグ用のUI Catalogについては、以下の記事で紹介してます。
Android向けUI Catalog Library – Katalogを公開しました | CyberAgent Developers Blog
その後、簡単なリスト画面から移行を開始し、今では積極的に利用しています。
方法1. 画面単位で移行する
具体的にどのようにプロダクション導入しているのかを紹介します。
1つ目に紹介するのは、画面単位で移行していく方法です。
一見大変そうに聞こえますが、単純で効果的だと考えています。
ViewModelを使った推奨アーキテクチャのように、Android Viewへの依存をできる限り減らしていれば、UIのみの移行で済みます。
まず、ViewModelのLiveDataをStateFlowに変更します。
LiveDataのままでも動作しますが、StateFlowのほうが追加のライブラリが不要等、相性が良いと考えています。
次に、UIを記述していきます。
ここでは、コンポーネントに状態を持たないよう(Stateless)にし、引数でStateを受け取るように注意しています。
ページのコンポーネントは「○○Page」という名前で統一しています。
Coordinatorという名前のComposable関数で、StatelessなコンポーネントとViewModelを結合しています。
これにより、StatelessなコンポーネントとStatefullなコンポーネントを明確に分けることができます。
最後に、ActivityもしくはFragmentからCoordinatorを呼び出すことで画面の移行が完了します。
ActivityではsetContentを呼び出し、FragmentではComposeViewを利用します。
今回、ViewModel以下のアーキテクチャは変更しませんでした。
宣言的UIではMVIやFluxのようなアーキテクチャのほうが相性が良いという議論もありますが、同時に多くのことを変更しようとすると挫折する等のリスクがあると考えました。
また、既存のアーキテクチャに多くの不満がなかった点からも、既存のアーキテクチャをできるだけ変えずに作業を進めました。
また、ViewModelを差し替え可能にするかについても検討しました。
プレビューやテストのために、ViewModelをinterface等で差し替え可能にすることが考えられますが、いくつか試したところコストが大きいと感じました。
StatelessとStatefullなコンポーネントを分け、プレビューやテストではStatelessなコンポーネントを利用するようにしています。
方法2. 小さなコンポーネントを差し替える
2つ目に紹介するのは、コンポーネント単位でJetpack Composeを採用する方法です。ComposeViewを使うことで、実現することができます。
このままだと少し利用しにくいので、カスタムViewを作成してxmlから呼び出せるようにしています。
Composable関数をStateを持たせず、再利用可能な形にし、画面全体を移行する際にそのまま使えるように意識しています。
また、一部Android ViewとComposeの相性が悪いことがある点にも注意が必要です。
Compose 1.1まではAndroid ViewとComposeのnested scrollが動作しないという問題がありました。
この問題はCompose 1.2で解決していますが、その他にも相性が悪いケースはあり、事前の検証が重要です。
問題があった場合、Android Viewで実装するか、もう少し広域でComposeに移行することで解消できることがあります。
方法3. RecyclerViewのセルをCompose化する
最後に紹介するのは、RecyclerViewのセルにComposeを利用する方法についてです。
再利用がしやすく、個人的におすすめの方法になっています。
ViewHolderのViewにComposeViewを使うことで実現できます。
OPENREC.tvではEpoxyと組み合わせて利用しています。
デフォルトの状態だとセルが再利用されず、状況によってはスクロール時のパフォーマンスが落ちる可能性があります。
compose 1.2で導入されたpooling containerを使うことで回避できます。
3. 利用上の課題とその解決方法
課題1. 同じコンポーネントでも微妙に挙動が異なる
Android ViewとComposeやaccompanistで同じように提供されているコンポーネントであっても、微妙に挙動が異なることがあります。
特にアニメーションやジェスチャーの微妙な操作感はよく見てみると違いがあり、相互運用してると気になることもありました。
対応策としてComposeのレイアウトを自作して合わせていく、Android Viewを使う、といった方法が考えられますが、コストがかかるため、差分を認識してそのまま利用するのも選択肢の1つだと思います。
課題2. recomposeの抑制
ドラッグやスクロール、アニメーションなどの表現を実装していると、予期せずパフォーマンスが悪化するケースに遭遇しました。
それらのほとんどは、不要なrecompose(再構成)が広範囲で発生していることが原因でした。
パフォーマンス向上の前に、パフォーマンス低下の原因を調査することが重要だと考えています。
単にrecomposeの回数を知りたい場合、ログを仕込んだり、このModifierで可視化することができます。
また、Compose1.2以降とAndroid Studio Dolphinを使っている場合、レイアウトインスペクタからもrecomposeの回数を知ることができます。
不要にrecomposeが発生している箇所を見つけたら、recomposeを抑制していきます。
recomposeをさせない方法の1つとして、Composable関数をスキップ可能(skippable)にするものがあります。
Composable関数は、全ての引数に変更がないとみなされれば、内部の実行がスキップされます。
しかし、引数にListやSet等のmutableになりうるコレクション等は常に可変としてみなされ、これらが引数にある場合スキップされません。
スキップされないクラスの例
- List<*>, Set<*>
- Throwable, Date
- mutableなメンバを持つクラス
- composeを使っていないモジュールやライブラリのクラス
利用するクラスに、@Immutableアノテーションを付与することで、そのクラスが不変であることをcompose compilerに伝えることができ、変更がない場合はrecomposeがスキップされるようになります。
内部にState等を保持している場合は、@Stableを使う必要があります。
値の更新をcomposeに通知しながら、recomposeを減らしてUIの更新を行うことができます。
もう1つの解決方法として、Stateの監視場所を変更する方法があります。
左のように、親コンポーネントでアニメーションの値を取得し、子コンポーネントに値を渡している場合、毎フレーム親と子の両方がrecomposeされます。
左のように親から子へはStateのまま渡し、子コンポーネントで値を取得することで、recomposeの範囲を子コンポーネントに限定することができます。
他にもいくつかrecomposeを抑制する方法がありますが、いずれの手法においても早すぎる最適化は行わないことが重要だと考えています。
何度recomposeされてもUIの表示は変わらないはずなので、基本的にrecomposeの発生を意識する必要はありません。
最初から最適化を目指すのは、コードの見通しが悪化したり、無駄なコストになりやすいため注意が必要です。
パフォーマンスの問題が生じた際に、計測しながら解消を目指すのが良いでしょう。
まとめ
Jetpack Composeのメリットや導入方法について紹介しました。
ぜひ、みなさんもJetpack Composeで快適なAndroidアプリ開発を行って頂ければ幸いです。
■「CA BASE NEXT 2022」のアーカイブ動画・登壇資料は公式サイトにて公開しています。ぜひご覧ください。