Androidの多機能Fragmentを再利用可能にした話

# はじめに
この記事は、[eureka Advent Calendar](https://qiita.com/advent-calendar/2021/eureka)の17日目の記事です。
本日は今年の4月にeurekaに新卒のAndroidエンジニアとして入社した私が担当させていただきます。

この記事では、複数の画面で共通する多機能なFragmentを再利用可能な形に書き換えた際に自分にとって学びがあったことを紹介します。

## 概要
![communitychat_KV.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146329/256fbed6-e8ce-0fbf-cd2a-d769d1c2eeed.jpeg)

Pairsでは、2021年6月に新機能としてコミュニティチャットが実装されました。
これは、マッチング前のお相手とカジュアルにテキストベースでお話が出来る機能です。
これに加えて、コミュニティチャットにスタンプを投稿する機能が実装されることとなりました。

ところが、途中でマッチング中のお相手との1:1メッセージにもコミュニティチャットと同じスタンプを使えるようにすることとなりました。
そこでスタンプのFragmentを複数画面で利用可能な形にリファクタしたのですが、Fragmentの再生成や親Fragmentからの独立性を考慮した実装が必要だったのでその際に得た知見をまとめます。

## 前提：スタンプ関連のクラス構造

最終的に実装されたチャットやスタンプの画面構成は以下のようになりました。

![スクリーンショット 2021-12-03 19.17.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146329/fda08204-f8f5-edcb-e3be-ca18fdd74401.png)

同様に、状態管理を構造を図にすると以下のようになります。

![c90be17a-d485-4c85-837a-dde8439d131f.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146329/1c524619-d7d1-4b59-6056-f3b3378046c4.png)


コミュニティチャットや1:1メッセージのFragmentにスタンプFragmentが含まれる形となっていますが、スタンプの状態やサーバーからのスタンプ情報の取得はチャットと分離して管理することが出来ます。
これは共通したスタンプFragmentを複数の画面で利用するための第一歩であり、チャットとスタンプの状態管理を独立させることでそれぞれをシンプルに実装することが出来ます。
なお、スタンプを別モジュールに切り出すことが出来たのでコミュニティチャットのモジュールと1:1メッセージのモジュールがそれぞれスタンプモジュールを参照する構造となっています。

ところがチャットとスタンプは機能面で密接に関係しているため、状態管理を分離したことで連携方法を工夫させる必要が出てきました。

# 機能要件
スタンプFragmentの分離と共通化にあたって課題となった以下の3つの機能要件をピックアップして紹介します。

- スタンプをタップしたらチャットに投稿する
- チャットに投稿されたスタンプをタップすると、そのスタンプが含まれるパッケージを表示する
- スタンプパッケージ[^1]の選択やダイアログの表示・操作のログを送信する

## スタンプをタップしたらチャットに投稿する

![画面収録 2021-12-03 10.40.28.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146329/e4584cb8-bef3-338c-a5ab-63a56178da75.gif)

※画面は開発中のものです。実際のユーザーのデータ等は使用されていません。

スタンプ一覧画面を開き、送信したいスタンプをタップするとチャットに投稿する機能です。
これを実装都合で見ていくと、以下のフローで実現されます。

1. スタンプFragmentがタップイベントを受け取る
1. スタンプFragmentからチャットFragmentにスタンプがタップされた旨を伝える
1. チャットFragmentからチャットにスタンプを投稿するAPIを叩く

今回問題となるのは、2番の**スタンプFragmentからチャットFragmentにスタンプがタップされた旨を伝える**部分です。
子Fragmentから親Fragmentへのイベント通知はどのように実装すればよいでしょうか？

### 採用した解決策

- スタンプFragmentで実装したListener interfaceを親が実装し、スタンプFragmentのonAttachメソッドでローカル変数に代入する

具体的には以下のような実装になります。

```StampFragment.kt
  interface OnStampClickListener {
    fun onStampClicked(stampId: Stamp.ID, stampPackage: StampPackage)
  }
```

```StampFragment.kt
  override fun onAttach(context: Context) {
    super.onAttach(context)
    val parentFragment = parentFragment
    if (parentFragment is OnStampClickListener) {
      onStampClickListener = parentFragment
    }
    val parentActivity = activity ?: return
    if (parentActivity is OnStampClickListener) {
      onStampClickListener = parentActivity
    }
  }
```

こうすることで、Fragmentが再生成された場合でもonAttachでListenerを登録出来る実装になりました。

### ボツ案

- スタンプFragmentの生成時にコンストラクタでcallback関数を渡す

この方法では、Fragmentの再生成に対応することが出来ません。
Fragmentの再生成時、AndroidはそのFragmentの無引数コンストラクタを呼び出してインスタンスを生成するためFragmentのコンストラクタは無引数でなければいけません。
理由については、[stackOverFlowで詳しく書かれている回答](https://stackoverflow.com/a/10450535)がありますのでそちらを参照してください。

同じ理由で、コンストラクタ以外の関数でFragmentに渡そうとしても再生成に対応できなくなってしまいます。

## チャットに投稿されたスタンプをタップすると、そのスタンプが含まれるパッケージを表示する

![画面収録 2021-12-03 10.41.30.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146329/0bdc5bbb-0ae7-b207-9ee8-9df78f93c5ce.gif)
※画面は開発中のものです。実際のユーザーのデータ等は使用されていません。

チャットに投稿されたスタンプが気になったとき、それをタップするとそのスタンプが含まれるパッケージを表示することが出来る機能です。
これを実装都合で見ていくと、以下のフローで実現されます。

1. チャットFragmentがタップイベントを受け取る
1. チャットFragmentからスタンプFragmentにスタンプがタップされた旨を伝える
1. スタンプFragmentが対応するスタンプパッケージを表示する

今回問題となるのは、2番の**チャットFragmentからスタンプFragmentにスタンプがタップされた旨を伝える**部分です。
先ほどとは逆に、親Fragmentから子Fragmentへのイベント通知はどのように実装すればよいでしょうか？

### 採用された解決策

- findFragmentByIdで見つけてくる

これはFragmentManagerのメソッドで、指定したidを持つViewが表示しているFragmentを取得できます。公式解説は[こちら](https://developer.android.com/guide/fragments/fragmentmanager#finding)。

こうすることで、Fragmentの再生成に対応しつつ子Fragmentのインスタンスを取得することが出来ます。

### ボツ案

- 親であるチャットFragmentがスタンプFragmentのインスタンスを持つ

例えば以下のような実装です。

```DummyParentFragment.kt
  var stampFragment: StampFragment? = null

  fun setupStampFragment() {
    stampFragment = StampFragment.newInstance(
      parentInfo = StampFragmentParentInfo.Talk(partnerId)
    )
    supportFragmentManager.beginTransaction()
      .replace(binding.stickerFragmentContainer.id, stampFragment)
      .commitNow()
  }
```

この状態でスタンプFragmentが再生成されてしまうと、親が変数で保持しているスタンプFragmentと実際に表示に利用されているスタンプFragmentが別のインスタンスになってしまいます。
そのため、チャットに投稿されたスタンプをタップしてもスタンプFragmentが適切に表示されないという問題が起こってしまいます。

## スタンプパッケージの選択やダイアログの表示・操作のログを送信する

Pairsではサービスの質の向上のため、ユーザーの操作やアプリの回遊のログを収集し分析しています。
スタンプについても、スタンプパッケージの選択やスタンプの購入動作等に対してログを収集しています。

ログにはいくつかのパラメータが含まれますが、今回問題になったのは**コミュニティチャットと1:1メッセージのどちらから操作を行ったか**です。

先述の通り、スタンプFragmentは親となるチャットFragmentがコミュニティチャットのものであるか1:1メッセージのものであるかを知りません。
この独立性を保ったまま、親Fragmentによって変化するログを送るにはどうすればよいでしょうか？

### 解決策

- 親Fragmentに関するログに必要なパラメータを持つdata classをsealed classでまとめる

具体的にはsealed classは以下のような実装になります。

```StampFragmentParentInfo.kt
sealed class StampFragmentParentInfo : Parcelable {
  @Parcelize
  data class CommunityChat(
    val communityId: Community.ID,
    val topicId: Topic.ID
  ) : StampFragmentParentInfo()

  @Parcelize
  data class Talk(
    val partnerId: String
  ) : StampFragmentParentInfo()
}
```

スタンプFragmentはこのsealed classのインスタンスを持ちますが、それがどちらのdata classなのかは知りません。
このインスタンスをログ送信用のhandlerに渡し、その先で実際に送信するログの形に変換します。

こうすることで、親Fragmentが何であるかを知らないまま親Fragmentに関するログを送信することが出来るようになりました。

この実装については事前に経験豊富な先輩からログの抽象化をしたほうがいいのではないか？と助言を頂いていたので、特にボツ案となる実装をせずに済みました。

# おわりに
今回はAndroid開発において機能の多いFragmentを使い回す際の実装の工夫について紹介しました。
ここで紹介した機能は私一人で実装したわけではなく、設計段階からAndroidチーム内で何度も相談しながら進めていきました。
最近は定期的にAndroidチーム内でペアプロを実施しており、設計に関して議論したり新規参入メンバーに対するドメイン知識の共有をしたりする機会が増えてきています。

もし、そんなエウレカのAndroidチームや私の普段やっていることに興味をお持ちいただけたらぜひカジュアル面談しましょう！
今回のAdCに合わせてMeetyを作成してみましたので、こちらからご連絡ください。Twitterでのご連絡もお待ちしております。

https://meety.net/matches/TyubLtzAHral

https://twitter.com/blonde_namazu

[^1]: スタンプのまとまりの単位です。スタンプ一覧の表示やスタンプの購入はパッケージ単位で行われます。