はじめに
2024年8月に株式会社タップルのAndroidチームにてインターン生として就業しました、名城大学情報工学部3年、村松侑です。
私は、高校2年生からオープンソースにコントリビュートしつつWeb開発に取り組んでおり、2023年に開催されたCA Tech Dojoへの参加をきっかけに、Android開発にも挑戦するようになりました。
CA Tech Dojoは、サイバーエージェントの育成型インターンシップで、Androidアプリ開発をほとんど知らない状態から学習していくインターンでした。それまでWeb開発に取り組んでいた私にとって、Androidアプリ開発はまったく新しい挑戦でした。
CA Tech Dojoへの参加後もAndroid開発を続けており、Androidを学ぶきっかけを作って下さったサイバーエージェントの就業型インターン「CA Tech JOB」で、今まで学習した成果を発揮したいと思い、今回のインターンに応募しました。
この1ヶ月間は、多くの知識を得ることができ、貴重な経験となりました。
タスクについて
タスクは、事前の面談で、数値として成果が見えるタスクに挑戦をしたいという希望を伝え、下記の施策やリファクタリング周りを行うことになりました。
施策について
タップルでは、学生同士がマッチングできる「タップルStudent」という機能を最近リリースしています。この機能を利用するには、メールアドレスまたは学生証を用いて学生認証を行う必要があります。ただ、ビジネスサイドの方には、学生証認証よりも、手軽なメールアドレスを用いた認証方法を利用して欲しいという考えがありました。
そこで、認証方法の選択画面で、「学校のメールアドレスで確認する」をデフォルトに設定し、「おすすめ」のラベルを追加する施策を入れました(下記画像参照)。この変更により、メールアドレス認証のクリック率向上を目指しています。
私自身、これまで、ビジネスサイドの視点を持ったことがなかったため、UIのちょっとした変更で、クリック率の改善を図るという考え方に感動しました。
他には、ABテストを活用したUI実装の施策も行いました。
詳細を記載することはできないのですが、Firebase機能の”Remote Config”と”A/B Test”を用いて、UIを切り替えられるようにしました。ABテストを採用した理由は、すべてのユーザーに影響を与えると、利用者数の大きな変動を招く可能性があるためです(ABテストではユーザー割合により影響範囲が限定可能)。ビジネスサイドの方は、メリットとデメリットを総合的に勘案した上で、施策の実施を決定していました。私にとって、ABテストの実装経験は初めてでした。規模の大きいプロダクトのABテスト導入には、メリットだけでなくデメリットも考慮されていることを理解できたのは貴重な経験でした。
リファクタリングについて
リファクタリング作業では、全部で7つの画面をAndroid ViewからComposeに移行しました。タップルでは、Groupieを使って無限スクロール画面の実装をしていました。Groupieで実装された箇所をCompose化する際には、
- 単体で表示されるitemをComposeで作成する
- 作成したComposeのitemを配置するために、既存のXMLをandroidx.compose.ui.platform.ComposeViewに変更し、setContentでCompose化したitemを配置する
- 繰り返し表示されるitemをCopmoseで作成する
- 同様に、作成したComposeのitemを配置するために、XMLをComposeViewに変更し、setContentで配置する
- 最後に、LazyColumnを使用して、これまで作成したitemで、元のadapter部分全体をComposeに移行する
このように、段階的に作業を進めることで、既存の実装をスムーズにCompose化することができました。
Android ViewをCompose化した際に苦労した点
Compose化の際に苦労した点が2つあります。
1つ目は、スクロール時にキーボードを非表示にする実装です。
Androir Viewの実装では、RecyclerViewにaddOnScrollListenerを追加し、スクロール時にキーボードを隠す処理をしていました。しかし、ComposeViewにはaddOnScrollListenerを直接追加できないため、以下のようにrememberLazyListStateでスクロール状態を監視し、リストの先頭アイテムが動くとhideKeyboard()が実行されるようにしました。
val listState = rememberLazyListState() LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex }.collect { hideKeyboard() } } LazyColumn( state = listState ) { // コンテンツの中身 }
2つ目の課題は、アニメーションの実装です。
タップルのタグ検索画面では、検索したタグをフォローすることができます。フォローボタンをクリックすると以下のアニメーションが発生します。
- アイコンが切り替わる(未フォローであればフォローボタンに変わる)
- 切り替わったアイコンが少し小さくなってから大きくなり、アイコンが大きくなるにつれてスピードが遅くなる
この挙動をComposeで再現するのは非常に難しかったです。
初期の実装ではAnimatedContetを使用しました。しかし、この実装方法ではフォローボタンをクリックしてUiStateの状態が変わると、表示されていたアイコンが小さくなると同時に変化後のアイコンが大きくなり、元のアニメーションとは異なってしまいました。
次に、アニメーションを示す状態をUiStateに増やし、その状態によってアニメーションのタイミングを制御する実装を試みましたが、この方法では切り替えのアニメーションが非常に重くなりました。
最終的に、ValueAnimatorを使用してFloat値を変化させ、その値をアイコンのサイズに乗算することでアニメーションを実装しました。さらに、interpolatorでDecelerateInterpolatorを使用することで、アイコンが大きくなるにつれてスピードが遅くなるという元の挙動も再現することができました。
var scaleValue by remember { mutableFloatStateOf(1f) } val animator = ValueAnimator.ofFloat(0.7f, 1f).apply { startDelay = 100L duration = 200L interpolator = DecelerateInterpolator() addUpdateListener { scaleValue = it.animatedValue as Float } Icon( painter = painterResource(RDrawable.ic_plus_circle_fill_24), contentDescription = null, tint = TappleTheme.colors.tertiaryLabel, modifier = Modifier .size((24 * scaleValue).dp) // ここでサイズの計算を行っている .clip(TappleRoundTokens.Full) .debounceClickable { onClickFollowIcon() animator.start() } )
また、フォローボタンをクリックして、すぐにアニメーションが開始されると、アイコンが切り替わる前にアニメーションが始まってしまうのでstartDelayを100msに設定してアニメーションの開始時間を遅延させています。
元の実装では、Android ViewのValueAnimatorを使用していたため、それをComposeに適応する形にしましたが、Composeに移行したので、本来であればAnimatableを使った実装にすべきでしたが、今回は時間が足りず実装までは至っていません。
拡張関数について
タップルでは多くの拡張関数が用意されていました。
よく利用していたのはModifier.debounceClickable()です。これは、Modifierのclickableを拡張したもので、300ms以内に再クリックを防ぐ処理が含まれています。
拡張関数を使用した実装経験がなかったのですが、このような実装があることで他の部分でも共通して利用することができ、非常に便利だと感じました。
他にも多くの拡張関数が存在していたため、最初は戸惑うことも多かったのですが、複数の画面をCompose化する中で徐々に慣れることができました。
バグ修正
いただいたタスクに加え、作業中に気付いた問題にも積極的に取り組みたいと考えていたため、リファクタリング中に気付いたバグも複数修正しました。
特に印象に残っているのは、アプリがクラッシュするバグに遭遇したときです。このバグを発見してすぐにトレーナーに報告し、一緒に原因を調査していただきながら修正に取り組み、Pull Requestを出すことができました。
バグの詳細は、2つのタブがある画面で、1つのタブを開いた状態でネットワークが切断され、その状態でもう一つのタブを開くとアプリがクラッシュするというものでした。原因は、ViewModelがRepositoryからAPIでデータを取得する際、runCatchingが使用されておらず、APIアクセスに失敗するためでした。今回はrunChatchigでRepositoryへのアクセス部分を囲み、onSuceessとonFailureで取得時の処理を適切に分岐させました。
バグの詳細の処理については下の画像の通りになります。このバグでエラーハンドリングの大切さがよくわかりました。
強制アップデートの判定にRemote Configを使用する
実装がある程度終わり、リファクタリングの作業が増えてきたため、新しいタスクを探すことになり、いくつかの施策の中から「強制アップデートの判定にRemote Configを使用する」タスクを選びました。このタスクを選んだ理由は、過去にハッカソンでRemote Configを使い、強制アップデートを推奨する画面表示の実装をしたことがあります。その経験を活かして、実際に多くのユーザーが利用するプロダクトでこの技術を試してみたいと思ったからです。
タップルでは、強制アップデートの判定をサーバーから行い、起動時のスプラッシュ表示とAPIを呼び出すたびに確認する方法を取っていました(下記画像参照)。
今回は、Remote Configを使用した強制アップデートの判定を行うために、サーバーでの判定方法と同様に、Splash表示時にRemote Configの値をチェックする仕組みを導入しました。しかし、API通信ごとにフラグを取得するのはRemote ConfigだとFirebase側のAPIなど様々な部分で望ましくありません。そこで、configUpdatesを使用して、強制アップデートのフラグが更新された際に端末に通知し、Flowで値の変化を監視する方法に変更しました。
このフローの詳細を示した図が下記画像です。
モノレポリポジトリでの開発
タップルではKMP(Kotlin Multi Platform)を用いて、AndroidとiOSの一部ロジックが共通化されています。初日にgithubのリポジトリに招待されたとき、リポジトリ名がtapple-iosとなっていたので、間違いかと思いましたが、その中にAndroidの実装も含まれているため間違いではありませんでした。
今回は、KMPに触れる機会は少なかったものの、Remote Configのキー設定部分がKMPで実装されていたため、少しだけKMPを扱いました。KMP部分の実装は、AndroidとiOS両方の変更が加わることが多く、他のファイルと比較してコンフリクトが発生しやすいと感じました。
しかし、1つのリポジトリでAndroidとiOS両方のPRやコードを見ることができるため、変更内容の把握やiOS側の実装の確認が容易でした。
最後に
CA Tech DojoをきっかけにAndroidアプリの開発を始め、多くの機会や経験に恵まれることができました。今回のCA Tech JOBもその一つです。CA Tech JOBに参加した1ヶ月は本当に短く、あっという間に過ぎました。社員の方々と一緒にお昼を食べる機会も多くあり、エンジニアだけでなく、さまざまな職種の社員さんと交流できました。そこで、皆さんの就活の経験やサイバーエージェントでの仕事について、色々な話を聞くことができました。こうした時間を通じて、インターンでの取り組み以外にも、サイバーエージェントの雰囲気や文化をより深く理解することができました。
また、今回のインターンを通して、自分に足りない部分も明確になりました。特に、私はAndroidの中で特に得意な領域がないと感じ、これからは開発を続けながら、自分の強みを見つけていきたいと思います。
最後に、メンターのむっきーさん、タップルの社員の皆さん、人事の方々、本当にありがとうございました!