はじめに

2024年8/7から8/30までの約1ヶ月間、ABEMAのAndroid Based TVチームにCA Tech JOBのインターンシップ生として参加させていただきました、佐藤衡平(@kouheisatou)です。

普段はブロックチェーンに関する研究をしたり、個人でアプリ開発を進めたりしています。

この記事では、私がAndroid TVアプリ開発の現場でチャレンジしたタスクや、その中で学んだことについて具体的に紹介します。

 

 

インターンシップの目標

Android開発の正しい実装について学ぶ

私は今まで小規模なチーム開発の経験しかなく、その開発においてもAndroid開発におけるベストプラクティスを考慮せず独自のやり方で実装を進めてしまっていました。

そこで、本インターンを通して、正しいAndroidのアーキテクチャやその実装について実務を通して学びたいと考えました。

 

UIに関して提案を行う

私は個人開発で自らUIを設計する中で、ABEMAのような大規模なアプリではどのようにUIが設計されているのかに興味を持ちました。

そこで、実際にそのような設計段階に参加させていただけないかということをリクエストして、その中で何か自分なりに考えてUIに関する提案を行うことを目標に定めました。

 

 

検索画面ソフトウェアキーボード実装タスク

私が担当した本タスクは、元々Android TV標準のキーボードで検索キーワードを入力していた物を、左側に独自キーボード、右側に検索結果、のように分割で表示するように変更するタスクです。

UIに関するタスクに携わりたいとリクエストした結果、デザイナーさんと議論しながら進める段階の上流タスクにアサインしていただきました。

複数のタスクを担当させていただいたのですが、その中でも最も苦戦したこのタスクについて以下で詳しく説明します。

 

施策の理解

過去のドキュメントから本施策の理解を深めました。

施策は主に4つの要素から構成されています。

まず、この施策を打つ背景として、最終的な理想状態と現状のギャップを明確にし、このギャップを埋めることができればどのようなインパクトをもたらすことができるのかを明確にします。

次に、仮説です。なぜそのような理想状態と現状のギャップが発生しているかを明確にし、どの問題を解決することによりギャップを埋めることができるか仮説を立てます。この仮説に説得力を持たせるため、この仮説を裏付ける根拠を付随させることが重要です。

次に、具体的な評価基準として、何をもって成功とするかを決定します。

最後に、この仮説を検証するため、具体的に何を作るかを細かく設定します。

このような4つの要素を明確にすることにより、施策に携わるメンバーの中の認識と方向性を揃えることができ、認識違いによる混乱や差し戻しを防ぐことができます。また、実際にこれに従って作業するメンバーに関しても、明確に自分が何を目標にして作業しているのかがわかるため、ボトムアップの提案が発生しやすくなることも期待されます。

 

施策を実装する上での問題点の洗い出しと改善提案

本施策はIPTVではすでに実装済みで、Android TVはその実装に合わせる方針でした。

まず、Android TVに実装するにあたり、Android TV特有の挙動から発生する問題を洗い出しました。

また、デザイナーさんとのミーティングに使用するモックの作成も同時に行いました。

モック作成の目的は以下の2つが挙げられます。

  • デザイナーさんが実際の使用感をイメージしやすくする
  • 実装の難易度が高いようなデザインを事前に洗い出す

問題点 検索結果の表示が狭くなる

IPTVとAndroid TVの左側のナビゲーションが異なり、Android TVでは左側のグローバルナビゲーション(以降、グロナビ)が常に表示されています。このため、右の検索結果のスペースが狭くなってしまい、「カテゴリから探す」のグリッドが3列入らないという問題が発生しました。この問題に対して以下の4つのデザイン案を提案しました。

改善A案

パディングを減らして3列表示

デメリット:パディングが減って見づらくなる可能性

改善B案

2列にする

デメリット:スカスカでバランスが悪い

改善C案

文字サイズを小さくして3列

デメリット:遠くから見た時の視認性が悪くなる

改善D案

キーボードからフォーカスが外れた時キーボードを格納して4列表示する

デメリット:ユーザの導線や実装が複雑になる

デザイナーさんとミーティング

議論の中でA案とD案を検討することが決定し、まず実装難易度がそれほど高くなく最終的に採用されそうなA案から実装しました。

 

実装

基本的にIPTVの使用に従って、提案したA案の実装を開始しました。

問題点

実装中に、キーボードの外からキーボードにフォーカスが移動した時、1度フォーカスが消えてからキーボードの右下の「小」キーにフォーカスが飛んでしまうという問題に遭遇しました。

この問題は、本チームで以前から技術的課題の難易度が高いとされているものでした。

現状では、ComposeとAndroidViewを同じ画面に混在させると、フォーカスが飛んでしまうという問題がありました。

この問題を解決することができれば、従来のAndroidViewを用いている画面の一部分のみを新しいComposeで作成することができるようになる他、ComposeとAndroidViewの相互運用が可能になるため、Composeの再利用性が高まることが期待されます。

これはチーム全体の改善につながると考え、難易度は高くてもチャレンジしたいと考えました。

以降は、レイアウトを作り切るよりも本課題を解決した方がチーム全体としてメリットがあると感じたので、ComposeとAndroidViewの相互運用に関する調査のタスクの優先度を上げて取り組みました。

調査

先輩方に教わりながら、変数が使われているところにコードジャンプして関係のありそうなところ全てにブレークポイントを張り、その時点のインスタンスの中身の状態を調べることにより、以下を調査しました。

  • フォーカスが飛ぶ時に、どの部分がフォーカスされているのか
  • フォーカスが変更した時に呼ばれる場所はどこか
  • フォーカスが移動してきた時にComposeView側でなんとかComposeにフォーカスを渡せないか

原因

左側のキーボード部分と右側の検索結果部分でレイアウトシステムが異なるため、本問題が発生していました。

キーボード部分は新しいレイアウトシステムのCompose for TVで、検索結果部分はAndroidの古いレイアウトシステムのLeanbackで構成されており、この間をフォーカスが跨いで移動する時フォーカスが飛んでいることに気がつきました。

実際にデバッグしたところ、ComposeViewではその中にsetContent()でComposeのレイアウトを定義して、Fragment上にComposeViewを配置していますが、これが原因で一度ComposeView全体にフォーカスが行ってから、Composeにフォーカスが行くので、フォーカスが一度飛ぶように見える現象が発生していたことがわかりました。

【用語解説】Leanback

https://developer.android.com/jetpack/androidx/releases/leanback

Android TVの基本的なUIを提供するライブラリ。

TV特有の横方向のリストが縦に並ぶようなUIを構築できる。

内部的にはJetpackのRecyclerViewとほぼ同じように、各リストアイテムをViewHolderとして扱っている。

【用語解説】Compose for TV

https://developer.android.com/jetpack/androidx/releases/tv

JetpackComposeをAndroid TVに最適化したライブラリ。

通常のAndroidのComposeと同じようにAndroid TVのUIを構築することができる。

改善案

ComposeViewにフォーカスが移動したことを検知して、ComposeViewの内部に定義されたComposeにフォーカスを強制的に移動させるような処理を挟むことができれば解決するのではないかと考えました。

全てのフォーカス移動時にはNavigationFragmentのonGlobalFocusChangeListener()が呼ばれているらしく、ここにComposeViewの内側にフォーカスを移動させる処理を挟むことによって問題の解決を試みました。

private val onGlobalFocusChangeListener: OnGlobalFocusChangeListener =
OnGlobalFocusChangeListener { v1, v2 ->
val navigationHasFocus = binding.navigationGridView.hasFocus() ||
(binding.navigationGridView.focusedChild != null)


displayUiLogic.updateNavigationHasFocus(navigationHasFocus)


viewModel.onFocusComposeFromLeanback(v1, v2)
}

ComposeView内部のどのComposeをフォーカスするかは、一つ前のフォーカスの座標から最も近いComposeを計算し、それによって決定しています。

最も近いComposeのUiModelをStateFlowに入れて、この変更をCompose側で購読することにより、最も近いComposeにフォーカスを移動しています。

fun focus(view: View?) {

 if (view != null) {

   val prevFocusedViewLocation = view.getLocationPointOnScreen()

   var minDistance: Float = Float.MAX_VALUE

   var minDistanceKey: SearchCustomKeyboardKeyUiModel? = null

   for (key in mutableSearchCustomKeyboardUiModelStateFlow.value.searchCustomKeyboardKeyUiModels) {

     if (key.positionX == null || key.positionY == null) continue

     val distance = (prevFocusedViewLocation.x - key.positionX!!).pow(2) + (prevFocusedViewLocation.y - key.positionY!!).pow(2)

     if (distance < minDistance) {

       minDistance = distance

       minDistanceKey = key

     }

   }

   mutableSearchCustomKeyboardUiModelStateFlow.value = mutableSearchCustomKeyboardUiModelStateFlow.value.copy(

     currentFocus = minDistanceKey

   )

 } else {

   mutableSearchCustomKeyboardUiModelStateFlow.value = mutableSearchCustomKeyboardUiModelStateFlow.value.copy(

     currentFocus = mutableSearchCustomKeyboardUiModelStateFlow.value.searchCustomKeyboardKeyUiModels.firstOrNull()

   )

 }

}

改善結果

ComposeとLeanbackが同じ画面に存在していても、ユーザーの意図したフォーカス移動ができるようになりました。

 

課題

今回は時間の都合上、キーボードに特化してフォーカス移動の実装をしましたが、将来的にLeanbackと相互運用可能な汎用的なComposeViewをライブラリとして実装したいです。

また、パフォーマンスについて、フォーカスがComposeViewに行くたびにComposeViewの内部のComposeの座標から最も近いComposeを計算しているので、スペックの低い端末で操作にラグが発生する可能性があります。

フォーカスに関して不具合はまだ残ってしまっていて、グロナビから右にフォーカス移動すると、キーボードにフォーカスが行かずに、直接検索結果の方に行ってしまうので、これについては追加で調査が必要です。

 

 

その他学んだこと

実装を進める中で、指摘され学んだことをまとめます。

 

コードリーディングの方法

アーキテクチャを意識して読む

ABEMAのAndroid TVアプリのコードリーディングと同時に、アーキテクチャの勉強をしました。

アーキテクチャの勉強にはGuide to app architectureが各レイヤの定義、分け方、使い方が図と実際のコードを用いてわかりやすく解説されていたので、非常に参考になりました。

Androidのアーキテクチャを勉強する際のサンプルコードとして、nowinandroidが参考になりました。このリポジトリはGuide to app architectureで解説されているアーキテクチャが実際のアプリではどのように利用されているかをみることができます。特にこのリポジトリのArchitecture Learning Journeyには本リポジトリのコードの実際のクラスとアーキテクチャ上の層が対応づけられておりわかりやすくまとまっています。

とにかくコードジャンプ

まず、変数名の使われているところと定義をコードジャンプして流れを掴みます。

わからないことがあったら、ライブラリ側のコードであってもコードジャンプして、関数の引数やそのコメント、その関数やクラスで何をしているかを見ることから始めます。関数や変数名とその内容、他に使われている場所を参考にしつつ、どのような処理なのかを読み進めていきます。

シーケンス図を書いて整理する

大体の構造が掴めたら、一旦シーケンス図を書いて整理します。

少しわからない状態でも、シーケンス図を書いているうちに理解ができるため、とりあえず登場人物だけ書いてみる方法を勧められ、実際にやってみたところかなり理解が明確になりました。

関心の分離

大規模アプリでは、アーキテクチャ的に上の層に依存しない構造を徹底することが非常に大切です。

これを守らないとデータフローが双方向になってしまい、コードの可読性の低下や、シーケンス数の増加によるパフォーマンスの悪化が引き起こされる可能性があります。

個人的に、SSOTの場所がどこにあるかを基準に考えると、プロジェクトのコードとアーキテクチャの対応が理解しやすかったです。

階層を意識する

アーキテクチャの層を理解しないと、全体の中でどの部分を読んでいるのかを把握できないため、コードを読む難易度が跳ね上がります。

私は以下のことを意識しながら読み進めました。

  • どの名前のパッケージに入っているか
  • アーキテクチャ的にはどの層にいるのか
  • それぞれの層の責務を明確にする

他の似た実装を見て真似てみる

他にも今読んでわからない部分が使われていないか見て、それをヒントに読むと理解が深まります。

 

プルリクエスト

プルリクエストは相手の立場に立って、What, Why, Howを意識して書きました。

特に、プルリクエストの粒度を分けることはレビュワーにとって非常に重要で、1つのプルリクエストに1つの変更、という点を徹底するべきだということを学びました。

同じゴールの変更でも、コードの挙動を変える変更と、コードの構造を変える変更は別のプルリクエストで出すとわかりやすくなります。

 

 

質問の仕方

質問は基本テキストベースで行うということを学びました。Slackで質問をすると、それが記録として残るため、自分や同じ問題に直面したチームメンバーにとっての資産となります。

質問をする時は、以下の情報を含めることを意識しました。

  • 現状
  • 理想状態
  • 詰まっているところ
  • 試したこと

このようにある程度フォーマットを固めることにより、質問の心理的ハードルが下がり、質問回数を増やすことができます。また、質問を受ける人にとっても、1つの質問に割くリソースを削減できるため、プロジェクト全体の生産性が向上します。

 

エンジニアの勉強法

概念をきちんととらえ、「なんとなく使える」を脱却しなければならないということを教えていただきました。

わからないことが出てきた時、一旦は解決法を調べて実装し、その後完全に理解するフェーズが必要です。

 

メモを取る、情報を残す

Slackの自分の呟き専用のチャンネルを作成して、そこで今やっていることを常に公開して残しておくことは非常に重要です。

私はこれを実践して以下のメリットを享受できました。

  • 後から自分の考えを辿れる
  • 他の人がこれをみて助け舟を出してくれることがある

 

おわりに

私はこの1ヶ月間で、主に3つのタスクにチャレンジすることによって、大規模アプリ開発の現場の様々な側面を体験し、その中で多くの学びを得ることができました。

Android TV開発チームは本当にレベルの高い方ばかりで、刺激のある良い環境だったと思います。

特に、ComposeとLeanback間のフォーカス移動問題で悩んでいた時に、先輩からデバッグのやり方をかなりの時間親身になって教えていただいたことが印象に残っています。また、トレーナーさんに質問したとき、質問に対して答えるのではなく、その考え方を教えて下さったので、非常に勉強になりました。

1ヶ月間お世話になりました!