Quantcast
Channel: クックパッド開発者ブログ
Viewing all articles
Browse latest Browse all 726

Android クックパッドアプリの画面遷移実装

$
0
0

Androidエンジニアのこやまカニ大好きです。

10/19 に弊社で開催した After Party DroidKaigi 2022というイベントで、クックパッドアプリの画面遷移について発表しました。 当日のセッションでは時間が限られていたりスライドでのコード表示の制約から実装面の説明をかなり省略していたので、この記事で補足しつつ説明しようと思います。

クックパッドアプリの構成

実装の内容に入る前に、前提としてクックパッドアプリのプロジェクト構造を説明していきます。

モジュール構成

これまでの技術ブログでも何度か説明してきた通り、クックパッドアプリは2018年ごろからマルチモジュールプロジェクトになっています。

  • 画面実装は View(Compose)と関連ロジックを Scene という単位で構成
  • 関連のある複数 Scene で機能モジュール(feature module) を構成
  • 共通ロジックやドメイン層は役割ごとにモジュール分割(library module)

全体的なモジュール構造は以下のようになっています。 feature module から library module への参照はありますが、feature module 同士に参照はありません。 (この図は過去の技術記事から持ってきたため、 legacy モジュールが重要そうな感じで見えていますが今回の内容には関係ありません)

画面遷移実装

クックパッドアプリでは以下の理由から Navigation Component を利用していません。

  • Navigation Component が出る前からマルチモジュール化されていて登場当時導入が難しかった
  • クックパッドアプリの画面遷移が複雑で Navigation Component を利用できる箇所が限られていた
  • 特にボトムタブに Multiple Back Stacks 相当の実装を自前で入れていたので乗り換えが難しい状況だった

今回紹介するような実装の工夫によってクックパッドアプリの画面遷移処理が簡単になったこと、Navigation Component 自体が進歩してきていることから今後はクックパッドアプリでもNavigation Component の採用を検討できる状態になってきましたが、今回の発表時点では Navigation Component 関連の内容はありません。

クックパッドアプリの画面遷移実装

モジュール間画面遷移の基本

例として、 A module の画面A から B module の画面Bに遷移する場合を考えます。 この時、素直に 画面A から 画面B の Fragment を生成しようとすると A module から B module を参照する形になります。 もし、画面Aと画面Bが相互に行き来できる場合はどうでしょうか。 A module と B module は相互参照になってしまうため、モジュール間の依存関係を設定できなくなります。

クックパッドアプリでは、別モジュールの画面を生成するために navigation module に AppDestinationFactoryというインターフェイスを定義し、全ての画面は AppDestinationFactoryを経由して遷移先の画面インスタンス(Fragment,Intent)を生成するようにしています。 この構造だと各 feature module は navigation module への参照を持つだけで画面遷移を実装できます。 AppDestinationFactoryの実体は全てのモジュールへの参照を持つ、 application module で定義しています。

この構造はクックパッドアプリ特有というわけではなく、マルチモジュールプロジェクトで画面遷移を navigation component を使わずに実装しているアプリはほとんどが類似の方法で画面遷移を実装していると思います。

ボトムタブごとに画面遷移の履歴(Back Stacks) を切り替えられるようにする仕組み

クックパッドアプリはボトムタブで大まかな機能を切り替える設計になっています。 このボトムタブを切り替えた際、タブごとに画面遷移の履歴(Back Stacks)を保存して切り替える実装を入れています。 最近の FragmentManager、 Navigation Component では Multiple Back Stacksという名前で類似の実装が含まれていますが、クックパッドアプリでは PrimaryNavigationFragmentという仕組みを使い、数年前から独自に実装しています。

PrimaryNavigationFragmentの説明はかなり難しいのですが、大体以下のような仕組みだと考えてください。

  • Activity と画面 Fragment の間に存在する FragmentManager制御のための Fragment
    • NavHostFragmentのようなもの…というか NavHostFragmentPrimaryNavigationFragmentを使って実装されている
  • ActivitysupportFragmentManagerを使う代わりに PrimaryNavigationFragmentchildFragmentManagerを利用して画面遷移を実装することで back などの挙動はそのままに backstack の管理ができるレイヤーを作れる
  • タブ切り替え時に PrimaryNavigationFragmentを attach/detach して切り替えることで PrimaryNavigationFragmentchildFragmentManagerが持つ backstack ごとタブ表示を切り替えられる

全然わかりませんね。 ものすごく大雑把にいうと、 requireActivity().supportFragmentManagerの代わりに requireActivity().supportFragmentManager.let { it.primaryNavigationFragment?.childFragmentManager ?: it }を使って画面遷移するといい感じにできるということです。 この実装を作った時に個人ブログに説明記事を書いたので、もし興味がある人がいれば参考にしてみてください。

画面遷移の処理で毎回 requireActivity().supportFragmentManager.let { it.primaryNavigationFragment?.childFragmentManager ?: it }という記述を書いていられないので、 FragmentManagerを適切に選択してくれる NavigationControllerという仕組みを導入しました。

NavigationControllerはアプリ内の画面遷移における共通処理を吸収するレイヤーで、以下のような機能を持っています。

  • 処理対象の FragmentManagerの選択
  • replace 先の containerViewId 指定
  • backstack の管理
class NavigationController internalconstructor(  
    val context: Context,  
    val fragmentManager: FragmentManager,  
    val childFragmentManager: FragmentManager,  
    privateval activityResultCaller: ActivityResultCaller,  
) : ActivityResultCaller by activityResultCaller,  
    FragmentResultOwner by fragmentManager {
    ...

    @JvmOverloadsfun navigateFragment(  
        fragment: Fragment,  
        fragmentTransition: Int = FragmentTransaction.TRANSIT_FRAGMENT_OPEN
    ) {  
        fragmentManager.commit {  
            replace(DEFAULT_CONTAINER_ID, fragment, null)  
            setTransition(fragmentTransition)  
            addToBackStack(null)  
        }  
    }

    ...
}

privateval FragmentManager.primaryNavigationFragmentManager: FragmentManager  
    get() {  
        val fragment = primaryNavigationFragment  
        returnwhen (fragment?.isAdded) {  
            true-> fragment.childFragmentManager  
            else->this  
        }  
    }  
  
privateval FragmentActivity.primaryNavigationFragmentManager: FragmentManager  
    get() = supportFragmentManager.primaryNavigationFragmentManager  
  
val Fragment.navigationController: NavigationController  
    get() {
        return NavigationController(  
            context = requireContext(),  
            fragmentManager = requireActivity().primaryNavigationFragmentManager,  
            childFragmentManager = childFragmentManager,  
            activityResultCaller = this,
        )  
    }  
val FragmentActivity.navigationController: NavigationController  
    get() {  
        return NavigationController(  
            context = this,  
            fragmentManager = primaryNavigationFragmentManager,  
            childFragmentManager = primaryNavigationFragmentManager,  
            activityResultCaller = this,
        )  
    }

NavigationController を利用する前の画面処理は大体以下のような実装でした。

val destinationFragment = destinationFactory.createFragment()
val fragmentManager = fragment.requireActivity().supportFragmentManager.let { it.primaryNavigationFragment?.childFragmentManager ?: it }  
fragmentManager.commit {  
    replace(containerId, fragment, null)  
}

NavigationController を利用することで以下のようにシンプルな処理にできます。

val destinationFragment = fragmentFactory.createFragment()
fragment.navigationController.navigateFragment(destinationFragment)

NavigationController により、画面遷移がよりシンプルに実装できるようになったことがわかると思います。

条件によって遷移先が Fragment だったり Activity だったりする画面遷移の実装

クックパッドアプリにはユーザー状態などによって遷移先が変わるアクションが存在します。 極端な例ではログイン済みの場合は目的の画面(Fragment)、未ログインの場合はログイン画面(Activity)という複雑なパターンもあります。 この遷移先切り替え問題の解決のため、 Destinationという仕組みを導入しました。

Destinationは遷移先の実体を隠蔽するための仕組みで、以下のような特徴を持ちます。

  • sealed interface である DestinationFragment, Intentをラップする実体クラスという構成
  • NavigationControllerDestinationへの 画面遷移をサポートすることで各画面では遷移先が Fragmentなのか Activityなのか気にせず実装できる
    • Result APIなどで処理結果を受け取るケースでは考慮する必要がある

Destination の中身

sealedclass Destination {  
    class FragmentDestination internalconstructor(val fragment: Fragment) : Destination()  
    class DialogFragmentDestination internalconstructor(val dialogFragment: DialogFragment) : Destination()  
    class ActivityDestination internalconstructor(val intent: Intent) : Destination()  
}  
  
fun Fragment.toDestination(): Destination =  
    when (this) {  
        is DialogFragment -> Destination.DialogFragmentDestination(this)  
        else-> Destination.FragmentDestination(this)  
    }  
  
fun Intent.toDestination(): Destination = Destination.ActivityDestination(this)

NavigationController では Destination の実装クラスごとに navigation 処理の呼び分けをしているだけです。

class NavigationController internalconstructor(
    ...
    @JvmOverloadsfun navigate(destination: Destination) {  
        when (destination) {  
            is Destination.FragmentDestination -> navigateFragment(destination.fragment)  
            is Destination.DialogFragmentDestination -> navigateDialogFragment(destination.dialogFragment)  
            is Destination.ActivityDestination -> navigateActivity(destination.intent)  
        }  
    }
    ...
}

このように、 NavigationControllerDestinationの導入により各画面は遷移先の画面が Fragmentなのか Activityなのか気にせず画面遷移処理を書けるように成りました。

URL から画面遷移する処理を実装する

URL から画面遷移する処理というのは、いわゆるディープリンク処理と呼ばれているものです。 URLによって遷移先は Fragmentだったり Activityだったりする他、外部からのアプリ起動、WebView内での遷移、APIレスポンスからのアクションなど様々な箇所で同じ挙動を実現したいという要求があります。 この要求を解決するため、 DestinationParamsという仕組みを導入しました。

  • DestinaionParamsDestinationを生成するための情報をまとめた sealed interface
  • URLから遷移先(Destination)の種別と必要なパラメータを抽出し、パラメータとして保持する
  • Destination実装は sealed interface なため、when による分岐処理でパターン網羅しやすい

DestinationParams の実装

DestinationParamsの定義は基本となる DestinationParams sealed interface と、それを実装する各 data class 、 object から成ります。

sealedinterface DestinationParams : Parcelable {
    @Parcelizedataclass Web(val url: String) : DestinationParams
    
    @Parcelizedataclass RecipeDetail(val recipeId: Long) : DestinationParams

    @Parcelizeobject MyKitchen : DestinationParams

    ...(似た実装がいっぱいある)
}

DestinationParams の利用方法

Uri.toDestinationParams()という拡張関数を定義し、その中でURLを解析して適切な DestinationParams (または null)に変換する処理を書いています。 この仕組みにより、ほとんどのケースで以下のような実装だけでディープリンクが動作するようになりました。

// URI から DestinationParams を生成val destinationParams = uri.toDestinationParams(serverSettings) ?:return// DestinationParams から Destination を生成val destination = appDestinationFactory.createDestination(context, destinationParams) ?:return// 画面遷移処理
fragment.navigationController.navigate(destination)

URI ->DestinationParamsの変換、 DestinationParams ->Destinationの変換が別処理になっているため、それぞれのレイヤーで必要なテストが書けるようになっているのが特徴です。 この仕組みの導入により、クックパッドアプリでディープリンクとして扱うURLは URL 〜 遷移対象の画面の生成まですべてテストが書かれている状態にできました。

DestinationParams の機能ごとのタグづけ

Kotlin ではひとつのクラス/オブジェクトが複数の sealed interface を継承することができます。 この仕組みを使い、各 DestinationParamsの実装クラスがサポートする機能に対応する sealed interface を作って継承するようにしています。

以下の例では、 RecipeDetailという DestinationParamsが外部からのディープリンクによるアプリ起動とアプリ内でのディープリンクによる画面遷移、WebView内でのディープリンクによる画面遷移をサポートしていることがわかります。

このサブ sealed interface によるタグ付の導入により、アプリの起動処理やURIから DestinationParamsの変換処理で何もしない when 分岐の数が減り、処理やテストが書きやすくなりました。

/**   * アプリ起動をサポートしている DestinationParams */sealedinterface AppLaunchSupportedDestinationParams : DestinationParams  
  
/**   * DeepLink からの変換をサポートしている DestinationParams */sealedinterface DeepLinkSupportedDestinationParams : DestinationParams  
 
/**   * WebView 内でのハンドリングをサポートしている DestinationParams */sealedinterface WebViewSupportedDestinationParams : DestinationParams  
  
sealedinterface DestinationParams : Parcelable {
    @Parcelizedataclass RecipeDetail(val recipeId: Long) :  
        DestinationParams,  
        AppLaunchSupportedDestinationParams,  
        DeepLinkSupportedDestinationParams,
        WebViewSupportedDestinationParams

...
}

DestinationParams による WebView の改善

2021年の DroidKaigi で発表した時点では DestinationParamsは存在していませんでした。 そのため、 各WebView画面ではディープリンクからネイティブ画面の遷移は遷移先画面ごとに navigateXXX()のようなメソッドを個別に実装していました。 今年に入って DestinationParamsが導入されたことにより、各 WebView 画面は WebViewSupportedDestinationParamsを処理するメソッドだけ実装すれば良いようになりました。 以下の画像は WebView のディープリンク処理に WebViewSupportedDestinationParamsを導入した時のPRの一部です。 大量のメソッドが消え、 WebViewSupportedDestinationParamsを処理するメソッドに集約されているのがわかると思います。 WebViewSupportedDestinationParamsも sealed interface なので、 when する処理を各 WebView 画面に書いておくだけで WebViewSupportedDestinationParamsのパターンが増えた場合でも自動的にビルドエラーになってくれます。賢いですね。

まとめ

クックパッドアプリでは画面遷移処理を自作しており、画面遷移の高度な要求に対応するために様々な工夫を凝らしてきました。 最近の変更で画面遷移処理の抽象化が進み、アプリ起動時の処理やWebViewが圧倒的に開発しやすくなりました。 一方、今後の課題としては以下のような項目があると考えています。

  • Jetpack Navigation Component を利用していないこと
  • Navigation Component 事態は必須ではないと感じているが、長期的なメンテナンスコストを考えると公式が推している仕組みに近づけた方が良さそう
  • Navigation Component のマルチモジュールサポートはまだ改善が必要だと考えているので、しばらくは自作した仕組みを Navigation Component に寄せやすくする改良をしていくかも

クックパッドではユーザー体験を最高にするため、モバイルアプリの画面遷移を最高の状態にしていくために引き続き改善を続けていく予定です。 最高の画面遷移処理や最高の WebView に興味がある人はぜひお気軽にご連絡ください。

info.cookpad.com


Viewing all articles
Browse latest Browse all 726

Trending Articles