Merpay Tech Fest 2022 は、事業との関わりから技術への興味を深め、プロダクトやサービスを支えるエンジニアリングを知ることができるお祭りで、2022年8月23日(火)からの3日間、開催しました。セッションでは、事業を支える組織・技術・課題などへの試行錯誤やアプローチを紹介していきました。
この記事は、「段階的Jetpack Compose導入〜メルペイの場合〜」の書き起こしです。
「段階的Jetpack Compose導入~メルペイの場合~」という題で発表させていただきます。まず初めに自己紹介をさせていただきます。Matsuyama Junyaといいます。現在メルペイで Android Engineer 兼 Engineering Managerをしています。
メルペイへの入社は今から1年と少し前の昨年の7月。入社後は新機能の開発等に従事しています。
メルペイに入社する前は、メーカーで携帯電話…いわゆるガラケーとスマホ両方のソフトウェア開発をしていました。その後は別の会社で、ソーシャルゲーム、SNS、ライブ配信サービス、タクシーの配車サービスなど、様々なモバイル向けのアプリ・サービスを開発していました。
では、本題に入っていきたいと思います。こちらが本日のAgendaです。
Jetpack Composeの導入のお話の前に、まず最初にメルカリアプリの構造についてお話をさせてください。次に、Jetpack Composeをどのように導入したのか。最後に、その導入をしてどうだったか、それから今後のお話をさせていただきたいと思います。
メルカリアプリの構造
ではまず最初は「メルカリアプリの構造」の話です。
メルカリアプリはすごく大雑把に書きますと、現在上記のスライドのような構造になっています。
一つのアプリケーションの中に、メルカリの部分とメルペイの部分、そしてそれらで共有しているDesign Systemがあります。実際には株式会社ソウゾウが作っているメルカリShopsなども入っているのですが、今回は図の簡単化のために、メルカリShopsなどはメルカリ部分に含ませていただきました。
Design Systemが横いっぱいに広がっていないのは、途中で生まれたために全画面に対して適用がされていないことを表現しています。
このメルカリ部分、初期リリースは2013年とおよそ9年前となります。そのため、一定程度のリファクタリングを行ってはいても、技術的負債がかなり蓄積してきている状態。それによって引き起こされる開発効率の低下が大きな問題となっていました。
対してメルペイ部分のリリースはおよそ3年半前、技術的負債がゼロということはもちろんありませんが、まだ開発効率を大きく低下させる状況ではありません。
このように、一つのアプリの中ではあるのですが、メルカリ部分とメルペイ部分、技術的状況としてはかなり異なっています。また、それぞれ事業フェーズが異なっていることから、異なる意思決定が行われることになりました。
メルカリ部分については、新機能開発を可能な限り止めて全てを作り直すことに。
対してメルペイ部分については、現時点では作り直しなどはせず、事業成長のための機能開発・改善を継続することになりました。つまり、メルペイ部分は共通の状態で、新旧両方のメルカリ部分と共に動く状態での開発を行うことになります。
じゃあどうする?
こちらが現在のリポジトリ構成です。
アプリケーションの根っこの部分から完全に作り直すために、新メルカリおよび新Design Systemは、新規のリポジトリで作成されました。そのうえで、新しいリポジトリが現行のリポジトリをsubmoduleとして持つ状態になっています。
この新メルカリ部分については、Jetpack Composeで作成されています。
そのうえで、現行アプリをビルドする場合は、特に新しいリポジトリは使いません。今までどおりビルドしてリリースをします。
新しいメルカリアプリはというと、モジュールの参照状況としてはこのような状況です。
GitのモジュールとGradleのモジュールがあって少しややこしいのですが、今回はGradleのモジュールのお話です。メルカリ部分については新しい方を使います。submodule内のメルカリ関連モジュールは使用せずに、新メルカリ、新Design System、そして現行のメルペイと、Design Systemでビルドして、aab/apkを作成します。
これでメルペイ部分の新規開発を止めることなく、並行してメルカリ部分のフルリファクタリングを行う基盤が整いました。なお、このsubmodule構造をずっと続けたいわけではありません。
今後どうしていくつもりかと言いますと、新メルカリへの移行が成功したら、まずはスライドのようにする想定です。
submoduleで特定のハッシュの中身を引き込むのではなく、subtreeを使って必要なコードだけを履歴ごと新しいリポジトリに取り込みたいと思っています。
そして、最終的にsubtreeを使うのをやめることで、再度一つのリポジトリに履歴を持ったまま合流することを目指しています。
Jetpack Composeの導入
次は「Jetpack Composeの導入」についてです。
メルペイ部分については、すぐに全てを作り直すことは“しない”となりました。とはいえ、昨今の情勢を見ると、Jetpack Composeを使っていく検討は必要です。
まず、Jetpack Composeは基本的にはUI部分の仕組みです。そのUI部分だけの置き換えは可能か、という点から確認をしていきました。
ここでメルペイの基本アーキテクチャを見てみます。
こちらの図は、tech blogにも以前掲載した図です。メルペイはReduxの考えとMVVMを組み合わせたような構成となっています。
参考:https://engineering.mercari.com/blog/entry/merpay-android-architecture-and-life-cycle/
Presentationの部分には、ConductorというライブラリのControllerというものが使われています。このPresentationのところからViewModelにInputが流れ、そのInputに応じてActionが生成されます。そのActionがそのままStoreに流れることもあれば、ActionによってはAPIコールを伴ったうえで、そのResponseから新しいActionが生成されることもあります。そしてそれらActionをReducerが受け、新しいStateが生成され、Presentation層にそのStateが反映される、というような流れになっています。つまり、単一方向のData Flowになっているのです。
「あれ、Jetpack Composeって…」
はい。こちらはAndroid Developersから引用した図です。
先ほどの図とは上下が逆転している状況ではありますが、Jetpack ComposeではUnidirectional data flow、単一方向のData Flowが推奨されています。
参考:https://developer.android.com/jetpack/compose/architecture
つまり、基本アーキテクチャとしては、UI部分を置き換える事で導入できそうだとわかりました。これで、マイグレーションが比較的簡単にできそうというだけではなく、Jetpack Composeとの思想的な親和性がある、アーキテクチャの大きな変更は必須ではない、ということがわかります。
では、試します。
チーム内で実装の方向性の議論をするためにも、PoCを行いました。
Jetpack Compose化前の模式的なコードがこのようになっています。
onCreateViewのメソッド内でinflator.inflateによって、layout xmlからViewが生成されています。そして、コードとしてはかなり端折ってしまっているんですが、UIからのeventの発行先としてinputsという要素があって、event発行のためのメソッドが入っています。そしてUI更新のために、現状の最新のstateをcollectしているというような状況になっています。
それがJetpack Compose化するとこのようになります。
onCreateViewの中でComposeViewを配置していまして、そのsetContent内でComposableのUIを記述するというようなスタイルにできます。
inputsとstateについてはScreenのComposableメソッドに渡してあります。
そのうえで、stateに応じて表示の出し分けをしたり、UIからのeventの発火などを記述したりすることで、移行ができます。
ということで、UI部分だけのJetpack Compose化ができました。しかし、ほとんどのアプリケーションは一つの画面だけでは構成されません。画面から画面への遷移が必須になります。
次は画面遷移をどう整理するかを考えます。
対応前のAction群が上記のようになっていたとします。ReduxのActionですね。
これをナビゲーション系のActionのみ、別のsealed interfaceも実装させます。
そうすると…。
該当のinterfaceでfilterIsInstanceを用いてfilterすることでナビゲーション系のActionのみを取り出すことができるようになります。
その取り出したナビゲーション系のActionのみに対して、それぞれ遷移を記述してあげることで、遷移が実現できました。
遷移のPoCもできました。ただ、まだやることはあります。
次はDeep Linkです。リポジトリ構造の図では明確には記載していませんでしたが、アプリケーションクラスやランチャー属性の付くActivityなどは、あの図の中のメルカリ部分、新メルカリ部分に属しています。つまり、今回全て書き換えられています。
こちらが従来のメルカリアプリで行われていたスタイルです。
Deep Linkを受ける専用のActivityがいて、そこでDeep Linkを処理するというスタイルです。
そのDeep LinkのintentからDeep Linkを処理するもの…Resolverを探し出してresolveしています。
そのResolverとしてメルペイ専用のものがある、という形になっていました。
このResolverは、その先でタブをメルペイに切り替えたり、メルペイ系機能群をホストするような別のActivityを立ち上げたりということを行っていました。
ここで大事なのは、メルペイ系のDeep Linkが情報としてまとめられていたということと、メルペイ系の機能群は基本的に別のActivityだったということです。
では、次に新しいアプリへのDeep Linkハンドリングを見てみます。
Navigation ComponentのDeep Linkを使う形となりました。記法はxmlではなくてJetpack Composeの記法です。そのためdeepLinksのところに、navDeepLinkのリストを渡す形式となります。
新規のDeep Linkに関しては、普通に作ればいいだけです。しかし、この中で既存のメルペイのDeep Linkを使うにはどうすればいいか。メルペイ内をJetpack Compose化しないにしても必要な検討課題が出てきました。
先ほどもお話しました通り、メルペイ系のDeep Linkについてはまずタブをメルペイタブに変えたうえで、Deep Linkに応じた方法でメルペイ用の別Activityを起動する必要があります。
ではどうするか。次に出すコードの都合上、先ほどのコードを少し下に下げました。中身は変わっていません。
メルペイのDeep Linkは1ヶ所にまとめてあるため、そこからnavDeepLinksのリストを作って、deepLinksに渡します。このcomposableは中にメルペイタブを抱えています。こうすることで、メルペイタブ経由のDeep Linkまで実現できました。
またスペースの都合上、上の方を少し縮めています。
元々のメルペイ用のActivity起動は、Navigation Componentでは扱えないような形だったので、直接処理を書きました。composableの中でまずDeep LinkのUriを取り出します。そして、そのUriに応じてLaunchedEffectの中で、Activity起動等を行っています。
既存のDeep Linkはこれでハンドリングできるようになりました。これにて、一通りの周辺懸念事項について、PoCが完了しました。
いよいよ実際にやっていきます。
では、どの画面からやっていくのがベストなのかを次に検討いたします。
再度メルカリアプリのリポジトリ構造を思い出してみます。
現時点ではこの中にDesign Systemが二つあります。
新しい方はComposable、そうでない方はAndroid Viewとなっています。
つまり、メルペイ機能をComposableで新Design System準拠で記述すると、必然的に上記のスライドのようになります。新リポジトリ側での実装、ということになります。
参照関係としては、新メルペイの方からメルペイは見えます。しかし、メルペイの方から新メルペイは基本的には見えない、という状況になります。もちろん依存性の逆転等の手法はあるのですが、単純には見えない状況です。
そのため、画面遷移は根っこの方がやりやすそう。
先ほども出てきたDesign System、実は新旧で見た目が同一ではありません。ほぼ同じ見た目の部分もあれば、それなりに変わっている部分もあり、単一の画面フローの中で新旧の行き来は避けた方がUXとしては良さそうでした。
やはり画面の根っこの方がよさそうだ。
ということで、「メルペイタブが適していそう」という結論になりました。
「メルペイタブがいい」となったのはそれらだけではなく、他にも理由がいくつかあります。
一つは他のタブの状況です。メルカリアプリが起動後、最初に表示する画面は下に5つのタブがあります。メルペイタブ以外の4つのタブは全てJetpack Composeおよび新Design Systemに置き換わっています。そのため、ここをまず書き換えることがUX観点では良いだろう、となっています。
また、実はこのメルペイタブの特にUI部分に対しては、ちょうど大きく作り直す予定がありました。
これらの理由もあって、他の既存画面や新機能の画面ではなく、メルペイタブの画面を書き換えることに決定し、実行に移すことになりました。
導入の結果&今後
では最後に、まだ開発途中ではありますが新メルペイタブの「導入の結果&今後」についてのお話をさせていただければと思います。
まずは大変だったところからお話していきたいなと思います。
パフォーマンスまわりが難しい。メルペイには、Android領域には経験の長いエンジニアが多く在籍しています。そのためAndroid Viewについては、過去の経験がかなり蓄積されているのです。以前作った機能について「このUIコンポーネントをこう使うと負荷問題が出た」「この使い方なら問題なかった」という勘所を持っていたりしますし、そういった知見は広く共有されています。例えばRecyclerViewなどのライブラリ自身に問題があれば、それらはissueが立てられ、致命的なものから順次修正されています。
しかし、Jetpack Composeは状況が違う。Jetpack Compose自身がまだ生まれてから日が浅いため、そもそも10年レベルの経験を持っているような人がおらず、小慣れてはいない状況です。そのため、問題があった場合に使い方の問題なのか、そもそも使い方ではなくてJetpack Compose側にperfomance issueがあるのか、という切り分けが都度必要になります。
次は慣れるのが大変だったという声です。
rememberなどのComposable特有の作法や考え方は、既存の方法と比べると差が大きく慣れるのが大変でした。こちらについては、特にメルペイタブの開発のチームに関してはペアプロや技術的な雑談などを頻繁に行うことで、慣れるまでの時間を短縮できたのではないかなと思っています。
一方で、つらいところよりも良いところがたくさんありました。
一つ目は、xmlより作りやすいこと。
.xmlと.ktファイルの行ったり来たりが不要になったので、一箇所で全てが見れるようになり、見通しが良くなりました。
また、xmlで作る場合…例えばStateによってUIを出し分けるような画面を思い描いてください。メルペイタブの開発中の新UIを一部持ってきました。
ここの部分、四角で囲んだ部分の上のカードの切り替えによって、この四角で囲んだ部分の表示が切り替わるスタイルです。
こういったケースだと、各パターンのUIをxml上は記載してvisibilityをvisibleとgoneで切り替える、というような作り方は一つのパターンとしてありました。先ほどの話とも近いのですが、その分岐ロジックとUI Designがファイルがわかれている状態だったり、xmlでlayoutを書いていると、Previewの表示を切り替えるためtoolsのvisibilityをvisibleとgoneで都度書き換えたりすることになっていたかと思います。
それがJetpack Composeだとstateによる分岐の中でそれぞれ記述ができますし、Previewも自由度高く構築ができます。両方のPreviewを作れば、両方とも同時に見ることができます。また、Jetpack Composeで記述している場合、余計なUIをlayout計算したり、メモリ上に乗せるというようなこともありません。
また今回の画面ではありませんが、長いリストの中で状態によって個別の要素の有無がわかれるようなケースも、宣言的UIが得意とする画面だと思います。
次に、UIコンポーネントに可視性を設定できるという声も挙がっています。layout xmlはファイルなので、internalやprotectedなどのKotlinで定義されているような可視性を付与することはできませんでした。そのため、それぞれがモジュール内専用なのか、モジュール外からもアクセスして良いのかという部分が管理できません。
それに対して、Jetpack ComposeはUIを関数で作ります。言語機能による可視性指定、public、protected、internalといった可視性指定が可能になり、より厳格な運用ができるようになります。
次はデータ監視のコードを書かなくていい、です。
Jetpack ComposeではStateの変化は勝手に監視され、変更時にRecomposeされます。そのため、手動で各Stateごとにcollectするなどの対応が不要になりました。また、Recomposeはあくまで該当UIが生きている間しか適用されないため、その周辺でlifecycleを気にする必要もほぼなくなりました。
次は、Jetpack Composeそのものの利点ではないのですが、ライブラリが良かったという話です。
今回メルペイタブのUIリニューアルではカルーセル型UIが採用されたのですが、こちらの要件がほぼ、AccompanistのPager Layoutで実現可能でした。
真ん中でスナップもデフォルトで可能です。また、カードの間隔を空けて、中央に表示されていないカードは少し半透明になるという要件は、Content PadidngとItem Scroll Effect。Indicatorは、その名の通りIndicators。カード切り替えの進捗に応じてカードの下の領域がクロスフェードする、という要件もあったのですが、そこについてはPagerState内のページ切り替えの進捗情報を使うことで実現できました。
Android Viewの世界は、そもそもスマホのタッチUIが10年以上かけて変遷しており、その過程で様々なライブラリが勃興しています。目的のUIを構築するにあたっても、どれも一部機能が足りていないというようなことや、本来想定していた使い方じゃないけれどなんとか実現できる、ということはよくありました。
ただ、今回新しいライブラリであるが故に、10年以上の間のUI Designパターンや、その変遷に応えられるような仕様・定義・実装になっていたおかげで、非常にスムーズに開発に適用できました。しかし、こちらはあと5年10年経つとまた同じ状況になるかもしれません。
最後に今後の話をさせていただきたいなと思います。
ある程度当たり前のお話ではあるのですが、今後はメルペイタブ以外についてもJetpack Composeで徐々に書いていきたいなと思っています。
リポジトリの単一化後の話となりますが、大別すると大きな変更のある画面から進めていくのか、あるいはどこかの段階でメルカリ部分と同じように、一定期間を取って一気に書き換えていくのか、方向性決めが必要になると思っています。
Design SystemがAndroid Viewの世界とJetpack Composeの世界が別であるので、Android エンジニアだけではなく、デザイナーやiOSチームとも話をしながら進めていきたいですね。ただ、おそらく前者かとは思っています。また、実はずっとスルーしてきた話題ですが、メルペイ画面群の遷移をConductorからNaviation Componentに変更もしていきたいです。
どういうことか具体的に話します。こちらが以前お見せしていた各画面のコードです。
ここにあるのがConductorのControllerです。
Conductorは、単一のActivityの上にRouterというものを配置しています。そのRouterにてControllerを切り替えるということで、画面遷移を実現しています。
各Controllerはそれぞれ、Viewのヒエラルキーを内部に持っている、というような状況です。
これはNavigation Componentでいうところの、NavControllerとFragment、あるいはComposableに相当する動きとなっています。
直近はそのままでも一応は問題なく動きます。しかし、Jetpack Compose化がController内としてしか行えないこと、それから類似の機能がJetpackで提供されている状況を考えると、今後Jetpackに他に機能が追加されたり変更されたりするような場合に、そこで提供される新しいメリットを100%享受できない可能性が高いと思っています。
そのため、こちらについては可能であれば移行をしていきたいなと思っているところです。しかし、ご想像通り大変だとは思うので、できるかどうかは難しいところだなと思っています。
メルペイにおける現時点でのJetpack Compose化についてお話をさせていただきました。かなり状況が特殊ですが、何か参考になるところがあれば幸いです。ご清聴ありがとうございました。