Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

AAC Navigation で Bundleの受け渡しを SafeArgs で行なってハマった話

こんにちは、Studyplus Androidチームの中島です。

以前の話に引き続き、 AACのNavigationについて話したいと思います。

今回は 遷移元・遷移先のFragment間でのBundle受け渡しでちょっとハマったことについてです。

なお、執筆時に利用している Navigation のバージョンは2.2.0-rc02です。

(前回) tech.studyplus.co.jp

初めに、Fragment間受け渡し方法について

まず、Navigation でのFragment間のデータ受け渡し方法について軽く触れておきます。 もう知ってるよという方は、本節を読み飛ばして頂いても構いません。 (遷移方法などの説明は割愛します、公式ドキュメントなどを参照してください)

FactoryメソッドによるsetArgumentsを利用した方法

従来通りの、プリミティブ型やSerializable、ParcelableをBundleに詰めて渡す手法です。

companion object {
    private const val ARG_KEY_NAME = "arg_key_name"
    fun newInstance(arg: Int) = YyyFragment().apply {
        arguments = Bundle(1).apply {
            putInt(ARG_KEY_NAME, arg)
        }
    }
}

ただし、 Navigation ではライブラリ内部で Fragment の生成が行われるため、この従来の生成方法は使えません。

Navigation では navigate メソッドで遷移アクションを指定しますが、その際にBundle型をパラメータに入れることができます。 なので、Fragmentでは Instanceの生成 ではなく Bundleの生成 のメソッドを生やしておくといいと思います。

// 遷移先Fragment
companion object {
    private const val ARG_KEY_NAME = "arg_key_name"
    // Navigation遷移のためInstanceではなくBundleを作成して返している
    fun createBundle(myArg: Int) = Bundle(1).apply {
        putInt(ARG_KEY_NAME, myArg)
    }
}
// 遷移元Fragmentの遷移処理箇所
val args = YyyFragment.createBundle(myArg = 123)
findNavController().navigate(R.id.action_Xxx_to_Yyy, args)

受け取ったBundleの中身は従来通りに取り出せます。

// 遷移先Fragment
val myArg = requireArguments().getInt(ARG_KEY_NAME) ?: 0

SafeArgs

Navigation の便利機能として、SafeArgsがあります。 これはBundleの put~~ get~~ を書かずにArgumentを直接渡せる機能です。 最大の利点としてその名の通りSerializableやParcelableを型安全なかたちで キャスト処理を書かず受け渡し可能になります。 利用するには build.gradle に追加でプラグインを指定する必要があります。 以下公式ドキュメントから抜粋します。

To add Safe Args to your project, include the following classpath in your top level build.gradle file:

buildscript {
    repositories {
        google()
    }
    dependencies {
        def nav_version = "2.1.0"
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
    }
}

To generate Java language code suitable for Java or mixed Java and Kotlin modules, add this line to your app or module's build.gradle file:

apply plugin: "androidx.navigation.safeargs.kotlin"

これらを追記した後にビルドすることで、NavGraph内で設定した各Fragmentごとに~~Directionsというクラスが自動生成されます。 その中に、同じくNavGraphで指定した各actionと対応したメソッドが自動生成されます。 この action のメソッドに引数としてデータを入れるわけですが、その型はnavGraphの遷移先Fragment定義内に<argument />アトリビュートを追加することで指定できます。 以下公式ドキュメントから一部改変して抜粋します。

<fragment android:id="@+id/YyyFragment" >
    <argument
        android:name="myArg"
        app:argType="integer"
        android:defaultValue="0" />
</fragment>

ここまで指定して改めてビルドすることで、以下のようにBundleにput/getせずデータのやり取りをすることができます。

// 遷移元Fragmentの遷移処理箇所
findNavController().navigate(XxxFragmentDirections.actionToYyy(myArg = 123))
// 遷移先Fragmentではフィールドとしてby navArgs()で取得
private val args: YyyFragmentArgs by navArgs()

val myArg = args.myArg // Int型として型安全で受け取れる

例ではintegerで書きましたが、これを用いることでSerializableやParcelableを継承したdata classのやりとりがとても楽になります。

なおYyyFragmentArgsの内部コードを覗いてみますと、変数名をArgumentのkey名としてBundleにput/getしている処理を見ることができます。 put/getのラッパークラスを自動生成してくれる機能、と思っていいかと思います。

ActivityレベルのViewModel

ActivityのLifecycleに合わせたViewModelでデータを保持することで、そのViewModelを通して共有する手法です。 強力な保持期間があり楽にデータを受け渡しできますが、逆にデータがずっと保持されてしまうとも言えます。 そのため、あるFragmentからあるFragmentへ遷移する際に明示的なリセット処理が必要となることがあります。 本記事では主題の外なのでこれ以上触れません。

NavGraphViewModelはNavigation2.1.0-alpha02以上で利用できる機能です。 同一のNavGraphの中にあるFragment間でのみ生存するViewModelでデータを共有する手法です。 Activityレベルより細かくデータ保持期間を指定できますが、NavGraphの分け方に依存する面もあります。 詳しくは公式ドキュメントをご参照ください。 本記事では主題の外なのでこれ以上触れません。

データの受け渡しについてまとめ

Navigation におけるFragment間でのデータ受け渡しは、公式ドキュメントにもある通りデータの量を考慮して選別する必要がありそうです。 また、そのデータを利用する上で生き残るべき期間も考えて、これらの手法を的確に使い分けるべきなのかなという認識です。

今回ハマったこと

手法選択

コードのリファクタリングを行なっていたところ、検索条件指定画面から検索結果表示画面への遷移を行なうコードにおいて Navigation を導入することとなりました。 このケースではFragment間での検索条件データの受け渡しが必要になります。 また、検索条件データは遷移時に生成して検索結果表示画面のFragmentだけで保持すればよく、検索条件指定画面のFragmentに戻る際は破棄したいデータでした。 そのため、ViewModelに依る共有ではなくBundleで渡す方法を選びました。

また、Serializable の data class であったため、型安全のため SafeArgs を利用したいと思いました。

ダメだったこと

以下の様に<argument />を指定したところ、debugビルドではうまくいっていました。

<argument
    android:name="data"
    app:argType="{data classのフルclasspass}" />

しかし、R8による難読化がなされたステージ環境向けのapkでテストしたところ、クラッシュが起きました。

Didn't find class {classpass}

つまり、難読化によって指定したクラス名が見つからないよと言われてしまったのです。 この解決法について調べている時、STAR-ZEROさんのブログ: Navigation SafeArgsで使える型から以下の記述を見つけました。

XMLで直接設定もできます。このとき . から開始することで、アプリのパッケージ名を省略することが可能です。

medium.com

この一文から、「もしかして相対パスで書けばいけるのでは?」と思い、以下の様にxmlを書き直しました。

<argument
    android:name="data"
    app:argType=".{data classの相対classpass}" />

結論から言うとこれでも同じく Didn't find class エラーでクラッシュし、しかも難読化のないdebugビルドですら同様のエラーが起きる様になってしまいました。

この時のエラーメッセージにあったクラスパスですが以下の様になっていました。

Didn't find class {アプリパッケージ名}.debug.{dataクラスの相対classpass}

Studyplus Androidアプリでは開発者環境、ステージング環境、本番環境で同端末内に共存できるよう、ビルドタイプに応じてsuffixをパッケージ名につけています。

アプリのパッケージ名を省略することが可能です。

そう、そのsuffixも当然パッケージ名の一部ですからここに挟まれてしまうわけです。 そのため、debugビルドでもクラスパスがズレてしまっていたわけでした。

加えて、Studyplus Androidアプリでは絶賛マルチモジュール化の促進中であることも思わぬ障壁になっていました。 マルチモジュールではアプリのパッケージ名各モジュールのパッケージ名が異なる形になります。 相対パスで書くと、SafeArgsによる自動生成クラス(~~FragmentArgsクラス)内ではモジュールのパッケージ名に相対パスを繋げてdata classをimportします。 しかし実行時だと、Navigationアプリのパッケージ名の直後に相対パスを繋げて指定したクラスを探しに行きます。 その結果、

  • 自動作成クラスに合わせて相対パスを指定すると実行時クラスが見つからずクラッシュする
  • 実行時の設定に合わせて相対パスを指定するとSafeArgsの自動生成クラスがdata classをimportできずビルドが通らない

となってしまっていました。 suffixがなかったとしても、マルチモジュールとこの相対クラスパスによる記法は相性が非常に悪いわけです。

解決法

まぁなんのことはなく、NavigationSafeArgs を利用せず、Bundle を生成して navigate メソッドに入れる手法で無事解決しました。

公式ドキュメントを改めて見たら…

SafeArgs と難読化の併用問題についての話には続きがあります。

コーディング当時(2019/11/11)は前節にまとめたように、FactoryメソッドによるsetArgumentsを利用した方法で解決しました。

その後(2019/11/22)、公式ドキュメントを改めて確認したところ、 ProGuard に関する考慮事項という記述があることに気付きました。 本節ではこれについて追記します。

keepnames ルールを使用する

公式ドキュメントから引用します。

keepnames ルールを proguard-rules.pro ファイルに追加することもできます。

これについては気づいていました。 確かに難読化によってフルパスが指定できなくなるのであれば、該当data classを難読化対象から外すことでももちろん解決できるはずです。 ですが、Studyplus Androidアプリは R8+Moshi Codegen によって、外部ライブラリ以外のファイルに関してproguard-rulesの表記が不要な状態となっています。 この手法を採用しなかったのは、今更 SafeArgs のためにこのdata classだけをproguard-rulesに追加したくないという判断でした。

@Keep アノテーション

公式ドキュメントから引用します。

    @Keep class ParcelableArg : Parcelable { ... }

    @Keep class SerializableArg : Serializable { ... }

    @Keep enum class EnumArg { ... }

androidx.annotation.Keep アノテーションを利用することで、難読化対象から外すというものです。 これについては恥ずかしながら知らなかったものでした。 試してみたところ、R8で難読化していても問題なく動作しました。 proguard-rulesファイルに影響を及ぼすことなく難読化対象から外せるのであれば、このアノテーションを付けて SafeArgs を利用していくのがベターかと思いました。

まとめ

今回は、NavigationSafeArgs を使おうとしてハマった話を紹介させていただきました。

SafeArgs は非常に便利ですが、現状では難読化と併用する場合もう一工夫必要になります。 まだ調査しきれていませんが、このArgumentの受け渡し周りについてはDeepLinkでの利用もある為、クラス名の取り扱いが内部で色々大変なのかもしれません。 今後のアップデートでこの辺りの利便性がさらに向上していくと嬉しいですね。

最後に反省点として、公式ドキュメントは今後も細かく確認していこうと思います…。