モダンな技術と開発戦略で挑むABEMA Androidアプリのリニューアル

ABEMAのNativeチームでAndroidアプリの開発を担当している木永(@fuji_kinaga)と國師(@ronnnnn_jp)と高鼻(@go_takahana)です。
CyberAgent Developers Advent Calendar 2021 24日目のクリスマスイブは、ABEMAで行われたリニューアルプロジェクトについて、Androidアプリ開発の内容に特化してお話します。

目次

  1. リニューアルプロジェクトの概要
  2. リニューアルプロジェクトの開発項目
  3. 開発トピック1: ホーム画面のUI実装
  4. 開発トピック2: オンボーディングのアクセシビリティ対応
  5. 開発トピック3: リリースに至る開発戦略と開発Tipsのまとめ
  6. おわりに

リニューアルプロジェクトの概要

始めに、2021年、ABEMAで行われた大規模なUI/UX刷新のリニューアルプロジェクト(通称: Phoenix) について簡単にご紹介します。

複数プラットフォームでサービスを提供しているABEMA

このプロジェクトでは、サービスの打開を目指して大きく3つの柱を掲げ、開発・運用・クリエイティブの観点で大規模な改善が行われました。

  • Discovery
    • テレビとビデオを1つに / 他ジャンルの強みを尖らせる
  • Hospitality
    • パーソナライズ / 快適なUI/UX / 気の利いたレコメンデーション
  • Design
    • ABEMAのコンセプトを活かしたデザインの追求 / コンテンツファースト・脱ドメスティック

サービス打開の3つの柱はDiscovery・Hospitality・Design

モバイルアプリでリニューアルされた内容がわかりやすくまとめられている動画がありますので見てみましょう。

テレビとビデオが1つの画面に集約され、下タブ形式の画面遷移に対応したことがわかるかと思います。
こちらの動画では主要な画面しか見ることができませんが、今回、オンボーディング機能にも変更が加えられており、ユーザーへのコンテンツ訴求のレコメンデーション精度やホーム画面のパーソナライズ化に大きく寄与しています。

リニューアルプロジェクトの開発項目

リニューアルプロジェクトでは沢山の開発が行われました。
下の画像にあるような「テレビプレビュー」「ナビゲーション」をはじめ、チャンネル並べ替え機能、ABEMAプレミアム導線ページの改善、ジャンルアンケートなど数多くありました。
テレビとビデオが一つに
画像: ABEMA プロダクトアップデートのお知らせ

UI刷新や機能開発の項目が大半を占めますが、その裏で技術的負債の解消やリアーキテクチャなど基盤改修にも取り組みました。
それらの一部を以下に示します。
Phoenix開発項目 - 基板系の一覧

本記事で全ての開発項目について触れることはできませんが、ここでは大きく3つのトピックを紹介していきます。

  • テレビとビデオを1つにした、ホーム画面のUI実装 by 國師
  • すべての人にパーソナライズするための、オンボーディングのアクセシビティ対応 by 高鼻
  • リリースに至る開発戦略と開発Tipsのまとめ by 木永

ホーム画面のUI実装

今回のリニューアルプロジェクトの目玉機能でもあるホーム画面について、國師がお届けします。

まず、ホームの機能について説明させてください。ホームとそこからシームレスに切り替えられるテレビの主な機能は次の通りです。

  • ホーム
    • テレビプレビュー (音なしでテレビを視聴・ザッピングできる)
      • チャンネルのザッピング
      • チャンネル一覧
      • チャンネルの並べ替え
      • 新聞型番組表への導線
    • テレビへ遷移する (音ありでテレビを視聴・ザッピングできる)
    • 作品特集
  • テレビ
    • チャンネルの視聴
    • ホームへ戻る
    • チャンネルのザッピング
    • チャンネル一覧
    • チャンネルの並べ替え
    • チャンネル別の番組表
    • 新聞型番組表への導線
    • フルスクリーン
    • コメント
    • 応援

先でも説明した通り、ホームはテレビの機能と旧ビデオトップの機能を融合し構成されています。上記の機能一覧を見比べてみても、ホームでテレビ機能の一部を提供していることが分かるかと思います。このホームでのテレビ機能のことを、我々はテレビプレビューと呼んでいます。

ここからは、ぜひ手元でABEMAアプリを起動しながらご覧ください。
起動後のホーム面にはテレビプレビューと作品特集の一部が表示されます。では、ホームをスクロールしてみましょう。作品特集がずらりと表示されるかと思います。おや、テレビプレビューはどこにいったのでしょうか。ページの上までスクロールを戻してみると、テレビプレビューが表示されますね。
さて、このテレビプレビューで再生されている動画をタップするとどうなるでしょうか。そう、テレビ機能へとシームレスに遷移することができます。左上の戻るボタンか端末のバックキーで戻ってみましょう。アニメーションと共にたくさんの作品が並んだホームに戻ってきたかと思います。

前置きが長くなってしまいましたが、ホーム⇄テレビのシームレスな遷移を実現するための、状態管理やViewの構成、アニメーションに関するいくつかのポイントをご紹介します。

複雑な状態をどう管理するか

ホームのUIは

  • ホーム ⇄ テレビ ⇄ フルスクリーンの遷移
  • テレビプレビューのトリミング禁止
  • タブレット(sw600dp以上)対応
  • 端末の向き
  • フルスクリーンのコメント or 課金Viewの表示・非表示
  • WindowInsets

など、さまざまな要因によって変化します。
クライアントアプリケーションにおける状態管理の重要性は言うまでもありませんが、ユーザーのアクションやOSからのイベントなどによって刻一刻と変化する状態を、ABEMAのホームではどう管理しているでしょうか。

我々はまず、ホームで管理すべき最も大きなスコープの状態を、Preview、Tv、FullScreenの3つに分けて定義しました。

ポイント1: 遷移可能な状態を制限する

上図からも分かる通り、Preview、Tv、FullScreenの状態はそれぞれのルールに従って別の状態へ遷移することが可能です。これらの3つの状態と遷移のロジックを HomeMode として定義しています。

sealed class HomeMode {
  abstract fun forward(isPort: Boolean, isOrientationAllowed: Boolean): HomeMode?
  abstract fun backward(isPort: Boolean, isOrientationAllowed: Boolean): HomeMode?
  abstract fun orientation(isPort: Boolean, isOrientationAllowed: Boolean): HomeMode?
  
  data class Preview(val requireCancelForceLandscape: Boolean) : HomeMode() {
    /* 遷移のロジックを抽象関数ごとに定義 */
  }
  data class Tv(val requireCancelForceLandscape: Boolean) : HomeMode() {
    /* 遷移のロジックを抽象関数ごとに定義 */
  }
  data class FullScreen(val requireForceLandscape: Boolean) : HomeMode() {
    /* 遷移のロジックを抽象関数ごとに定義 */
  }
}
  • HomeMode#forward : Previewを起点として順方向の状態遷移 (Preview → Tv、Tv → FullScreenなど)
  • HomeMode#backward : Previewを起点として逆方向の状態遷移 (FullScreen → Tv、Tv → Previewなど)
  • HomeMode#orientation : 画面回転による両方向の状態遷移

これらの関数の呼び出し側では、これらの関数から次の HomeMode を返してもらい、それを次の状態として保持します。状態変更が必要ない場合や遷移のルールを逸脱した状態の場合には、nullが返されます。

HomeMode の使用例

fun onTvPreviewClicked(
  currentHomeMode: HomeMode,
  isPort: Boolean,
  isOrientationAllowed: Boolean,
) {
  if (currentHomeMode !is HomeMode.Preview) return
  val nextHomeMode = currentHomeMode.forward(isPort, isOrientationAllowed) ?: return
  changeHomeMode(nextHomeMode) // リポジトリなどで保持している状態の更新
}

上記の例の changeHomeMode はprivateで定義されているため、このクラスからのみ HomeMode を変更できます。そのため、アプリケーション全体に HomeMode 変更のロジックが散らばってしまうこともありません。

このように、状態ごとにクラスを定義しつつ、関数で状態の遷移先を返すことで、

  • 遷移可能な状態を制限できる
  • 管理したい状態が分かりやすい
  • 状態管理をこのクラスに一任できる (ロジックの散らばりを防ぎ、再利用しやすい)
  • 状態遷移をテストしやすい

といったメリットを得ることができます。

ポイント2: Viewの設定も状態として定義する

先の図をもう一度みてみましょう。ホームの状態以外にも、16:9やfixed_heightといったものがプレイヤーの位置に書かれているかと思います。これは、プレイヤーのViewのアスペクト比とExoPlayerの AspectRatioFrameLayout.ResizeMode を表現しています。

テレビプレビューやテレビのプレイヤーは、

  • 端末の大きさ
  • トリミング可否
    • コンテンツプロバイダーによっては、テレビプレビューでプレイヤーを見切れさせることができない

に影響を受けます。つまり、これらの変数によって、プレイヤーのViewのアスペクト比や縦方向と横方向のどちらを基準にしてプレイヤーのViewのサイズを計算すべきかが変わってくるのです。

これらのロジックを統一し、後に説明するView階層間のレイアウトの同期を容易にするために、 TvScaleType というクラスを定義しました。

enum class TvScaleType(
  val playerDimensionRatio: String,
  @AspectRatioFrameLayout.ResizeMode val playerResizeMode: Int,
) {
  HORIZONTAL_TRIM("h,5:4", AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT),
  VERTICAL_TRIM("h,5:2", AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH),
  NO_TRIM("16:9", AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT),
  ;

  private enum class Rule(val preview: TvScaleType, val tv: TvScaleType) {
    MOBILE(HORIZONTAL_TRIM, NO_TRIM),
    TABLET(VERTICAL_TRIM, NO_TRIM),
    DISABLE_TRIM(NO_TRIM, NO_TRIM),
    ;
  }

  companion object {
    fun getScale(mode: HomeMode, isOrientationAllowed: Boolean, disableTrim: Boolean): TvScaleType {
      val rule = if (disableTrim) {
        Rule.DISABLE_TRIM
      } else {
        if (isOrientationAllowed) {
          Rule.TABLET
        } else {
          Rule.MOBILE
        }
      }

      return when (mode) {
        is HomeMode.Preview -> rule.preview
        is HomeMode.Tv,
        is HomeMode.FullScreen -> rule.tv
      }
    }
  }
}
  • TvScaleType.HORIZONTAL_TRIM : プレイヤーの左右を見切れさせる
  • TvScaleType.VERTICAL_TRIM : プレイヤーの上下を見切れさせる
  • TvScaleType.NO_TRIM : プレイヤーの見切れを許容しない

プレイヤーのトリミングの状態をこれら3つに定義しつつ、 TvScaleType.Rule として端末の大きさとテレビプレビュー、テレビごとのトリミングルールを定義しています。

また、静的関数として TvScaleType.getScale を定義することで、プレイヤーの状態変化をもたらす変数(インプット)に対して、プレイヤーのViewの状態が今どうあるべきか(アウトプット)を簡単に取得できるようにしています。

このように、Viewの設定も状態として定義してあげることで、複雑になりがちなUIのロジックが管理しやすくなり、Viewには状態を反映するだけでよくなります。

ホームのレイアウト構成

ここまで、状態管理の方法についてまとめましたが、ここからが本題です。ホームのレイアウト構成を深ぼっていきましょう。

レイアウト構成を紹介する前に、今一度ABEMAアプリを触ってみてください。あなたならこのホームをどう作りますか…?
スクロールで縦方向に動くテレビプレビュー…しかもその中身は横スクロールでザッピングできる…テレビプレビューをタップしたらプレイヤーの再生を途切れさせることなくシームレスにテレビに遷移…テレビにはテレビプレビューにない機能がたくさん…
イメージできましたか?では、答え合わせです。

レイアウトの階層全てを書ききれないので、一部省略して紹介します。

まず、アプリケーションの中心となるActivityとFragmentの中にBottomNavigationViewと各タブのFragmentがあります。ホームタブにはHomeFragmentが対応しており、HomeFragmentはCoordinatorLayoutが子にいます。このCoordinatorLayoutにAppBarLayoutとRecyclerViewを配置しています。AppBarLayoutはテレビ機能のViewを含むHomeTvFragmentを子に持っています。そして、RecyclerViewが作品特集に当たります。最後に、HomeTvFragmentの子にViewPagerが含まれ、ViewPagerの各ページのFragmentがテレビのチャンネルと1:1対応しています。

Viewが何層にも入れ子になっていますね。実際にはもっと多くのViewがホームには存在しています。

  • ホーム面での縦スクロールに合わせて、テレビプレビューも縦スクロールされる
    • テレビ面では画面全体を縦スクロールできないようにする
  • 横スクロールでテレビプレビューをザッピングできる
  • テレビ面へ遷移するときにテレビプレビューの領域が拡がっていくようなアニメーションを実現できる

これらの要件を満たすために、ホームの中にテレビのViewを丸ごと含める構成となりました。代替案として、RecyclerViewの一つのViewHolderとしてテレビ全体を保持する案もありましたが、RecyclerView内にFragmentを配置した際のライフサイクルの制御の難しさから断念しました。

皆さんの予想は当たったでしょうか?
ホームの機能要件を実現するのに大事なポイントはレイアウトの構成だけに留まりません。

ポイント1: 異なる層のViewの位置やサイズを同期させるためにSpaceを使用している

ホームでは次の要件を満たす必要がありました。

  1. ホーム面ではモード間の遷移時にViewを共有したまま滑らかに遷移させたい
  2. テレビプレビューで再生しているコンテンツのフレームが飛んだり、再生位置が変わったり、再生セッションが変わったりしない
  3. チャンネルタブのスクロール位置が保持されている

1の要件については、SharedElement Transitionを使うことで満たすことができそうです。しかし、2と3の要件はSharedElement Transiitonだけで実現することが難しく、今回は各層にあるViewを同期的にレイアウト変更、アニメーションさせることで、要件を実現しました。
また、サイズの大きさを同期させたいViewが同じFragment内に含まれない場合もあり、そういったケースでは軽量かつViewとして扱えるSpaceを仮想的なViewとして使用しています。

<com.google.android.material.appbar.AppBarLayout
  android:id="@+id/app_bar_layout"
  >

  <!-- ホームの時はwrap_content、テレビの時はmatch_parent -->
  <androidx.constraintlayout.widget.ConstraintLayout
    android:id="@+id/header"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <!-- テレビ関連のViewを含むFragmentの親View -->
    <androidx.fragment.app.FragmentContainerView
      android:id="@+id/home_tv_fragment_container"
      android:layout_width="0dp"
      android:layout_height="0dp"
      app:layout_constraintBottom_toBottomOf="parent"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="parent"
      />

    <!-- Player部分の高さ -->
    <Space android:id="@+id/home_player_space"
      android:layout_width="0dp"
      android:layout_height="0dp"
      app:layout_constraintDimensionRatio="h,5:4"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="parent"/>

    <!-- テレビプレビューの番組情報部分の高さ(テレビの時は0) -->
    <Space android:id="@+id/home_description_space"
      android:id="@+id/home_description_space"
      android:layout_width="0dp"
      android:layout_height="@dimen/home_tv_description_space"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@id/home_player_space"
      />

    <!-- チャンネルタブの高さ -->
    <Space android:id="@+id/home_channel_tab_space"
      android:layout_width="0dp"
      android:layout_height="@dimen/home_tv_channel_tab_height"
      app:layout_constraintBottom_toBottomOf="parent"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@id/home_description_space"
      />

  </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.AppBarLayout>

これは、HomeFragmentのレイアウトファイルの一部です。

@+id/home_tv_fragment_container は、テレビ関連のViewを含むFragmentの親Viewです。このViewはテレビに遷移した時に、画面いっぱいに広がる必要があるため、親のConstraintLayout( @+id/header )に一杯一杯表示されるようなConstraintに設定されています。

一方で @+id/header 自体は、ホーム状態の時は、wrap_contentになっています。

お気づきの方もいるかもしれませんが、これでは @+id/home_tv_fragment_container がうまく表示されません。 @+id/header のwrap_contentと @+id/home_tv_fragment_container のConstraintLayoutいっぱいに表示するというのがバッティングしてしまうためです。

そこで、 @+id/home_tv_fragment_container 内に含まれるホーム状態で表示され得るViewに対応したSpaceを配置しています( @+id/home_player_space + @+id/home_description_space + @+id/home_channel_tab_space )。そうすることで、ホーム状態の時に @+id/header がwrap_contentでもSpace分の高さを確保することができます。

また、これらのSpaceは、実際のViewのレイアウト変更に応じて、サイズを変えたり、Visibilityを変更したりなどViewの設定を同期しています。

ポイント2: ConstraintLayout配下のViewを動的に制御するConstraintApplier

ConstraintLayout配下のViewをコード上から制御する一般的な方法として次の方法があります。

fun updateConstraint() {
  val constraintSet = ConstraintSet()
  constraintSet.clone(rootConstraintLayout)
  // ConstraintSetに対して、変更したいViewの設定を行う
  constraintSet.setVisibility(R.id.targetView, ConstraintSet.VISIBLE)
  constraintSet.applyTo(rootConstraintLayout)
}

このやり方を各所で行うのは若干のボイラープレート感があるのと、ConstraintSetに用意されているインターフェースも少し冗長で使いづらいです。

この問題を解消するために、ABEMAでは ConstraintApplier というinterfaceを用意しています。

interface ConstraintApplier {

  val targetViewIds: IntArray

  fun applyTo(constraintSet: ConstraintSet)

  fun ConstraintSet.setVisible(isVisible: Boolean) {
    val visibility = if (isVisible) ConstraintSet.VISIBLE else ConstraintSet.GONE
    targetViewIds.forEach { setVisibility(it, visibility) }
  }

  fun ConstraintSet.setInvisible(isInvisible: Boolean) {
    val visibility = if (isInvisible) ConstraintSet.INVISIBLE else ConstraintSet.VISIBLE
    targetViewIds.forEach { setVisibility(it, visibility) }
  }

  /* 他にもConstraintSetを扱いやすくするための拡張関数を用意 */
}

fun ConstraintLayout.updateConstraint(
  block: ConstraintSet.(constraintLayout: ConstraintLayout) -> Unit
) {
  val constraintSet = ConstraintSet()
  constraintSet.clone(this)
  constraintSet.block(this)
  constraintSet.applyTo(this)
}

ConstaintApplier を実装したクラスを用意することで、ConstraintLayout配下の制御したいViewに対してまとめて変更を適用することができることに加えて、対象としたいViewごとに制御を分離することができます。これにより、個別のViewやまとめて扱いたいViewごとにどういう表示になるべきなのかをまとめられるため、イベント駆動ではなくState駆動でViewを制御できるのも利点です。

private fun animateRoot() {
  val applierList = listOf(
    TopGradientApplier(homeMode.isPreviewMode),
    ToolbarApplier(homeMode.isPreviewMode),
  )
  binding.rootLayout.animateConstraint(rootTransitionSet) {
    // 各Applierの変更を適用
    applierList.forEach { it.applyTo(this) }
  }
}

private class TopGradientApplier(private val isPreviewMode: Boolean) : ConstraintApplier {

  override val targetViewIds: IntArray = intArrayOf(R.id.home_top_gradient)

  override fun applyTo(constraintSet: ConstraintSet) {
    constraintSet.setVisible(isPreviewMode)
  }
}

private class ToolbarApplier(private val isPreviewMode: Boolean) : ConstraintApplier {

  override val targetViewIds: IntArray = intArrayOf(R.id.home_toolbar)

  override fun applyTo(constraintSet: ConstraintSet) {
    constraintSet.setVisible(isPreviewMode)
  }
}

ポイント3: 動的なWindwoInsetsの制御に対応する

ホームでは、状態によってWindowInsetsを考慮してレイアウトを変更するべき時と考慮すべきでない場合が存在します。

例えば、ホームではテレビプレビューがステータスバーの後ろに配置される必要がありますが、テレビではステータスバーの高さを考慮してテレビのViewを配置する必要があります。
WindowInsetsを簡単に制御するために、ABEMAでは insetter を導入していますが、下記のような使い方では特定のViewに対して動的にWindowInsetsを考慮する・しないといったケースに対応できません。

// よくある使い方
view.applyInsetter {
  type(statusBars = true) { padding(top = true) }
}

上記のようなWindowInsetsに動的に対応しなければならないケースでは、状態変化時にinsetterを作り直しつつ、 OnApplyInsetsListener を用いるといいです。

private fun applyRootLayoutPadding() {
  Insetter.builder()
    .setOnApplyInsetsListener { _, insets, initialState ->
      val paddingTop = if (homeMode.isTv) {
        // テレビの時はstatus bar分のpaddingをとる
        insets.getInsets(WindowInsetsCompat.Type.statusBars()).top + initialState.paddings.top
      } else {
        // そのほかの時はstatus barの高さを考慮しない
        initialState.paddings.top
      }
      binding.homeTvConstraintLayout.updatePadding(top = paddingTop)
    }
    .applyToView(binding.homeTvConstraintLayout)
}

ホームのアニメーション

ホーム ⇄ テレビの遷移にはリッチなアニメーションが実装されています。それらのほとんどは、 androidx.transition を使用して実装しています。

MotionLayout でも検証を行いましたが、画面回転時にtransitionの状態を保持してくれない点や、ホーム内はアニメーションが多いためxmlで管理するConstraintSetの状態爆発が懸念される点から、今回は導入を見送りました。

androidx.transition の使い方については今回は割愛しますが、 androidx.transition を使用する上での大事なポイントとUIデザイナーとのやりとりのポイントについて紹介します。

ポイント1: androidx.transitionの対象を明確にする

androidx.transition では、アニメーションの対象となるViewの設定が大事です。

Transition#addTarget によってアニメーションの対象を設定してあげないと、デフォルトでは TransitionManager.beginDelayedTransition の引数で渡すViewGroupの子View全てがアニメーションの対象となってしまいます。

意図しない挙動を防ぐために、アニメーションさせたいViewのみが対象になるようにしましょう。

Sets the target view instances that this Transition is interested in animating. By default, there are no targets, and a Transition will listen for changes on every view in the hierarchy below the sceneRoot of the Scene being transitioned into. Setting targets constrains the Transition to only listen for, and act on, these views. All other views will be ignored.

https://developer.android.com/reference/androidx/transition/Transition#addTarget

また、アニメーション対象のViewがViewGroupで、そのViewGroup内で別途アニメーションが定義されている場合は、 Transition#excludeChildren によって子Viewがアニメーションの対象にならないようにしておきましょう。

ポイント2: ProtoPieのrecipe機能を活用する

UIデザイナーの要望を実現するのは、クライアントエンジニアの腕の見せ所です。しかし、アニメーション要素の多い箇所では、アニメーションの1つ1つをUIデザイナーに確認をとりながら実装を進めるのは骨が折れる作業になるかと思います。
今回のリニューアルプロジェクトでは、モック作成の段階からProtoPieを使用していたという経緯もあり、レシピ機能を活用しました。

上の例では、テレビのプレイヤー部分のアニメーションを確認しています。右上に表示されているビューから、アニメーションの間隔やイージングといった各プロパティを確認することができます。
この機能によって、UIデザイナーとのやりとりを大幅に削減することができました。皆さんもぜひ試してみてください。

現状の課題と今後の展望

これらのポイントを踏まえて、ホームを実現することができました。しかし、まだまだ完璧ではありません。

State駆動でViewの変更やアニメーションを行う

ConstraintApplierの箇所でも少し触れましたが、Viewの変更やアニメーションをイベント駆動で行っている箇所が多く存在しています。これらをState駆動に置き換えることで、

  • Viewの状態変化を特定のクラスやメソッドに閉じ込められ管理しやすくなる
  • TransitionManager.beginDelayedTransition が多重発火される可能性を低くできる
    • TransitionManager.beginDelayedTransition が次のフレームまでの間に何度も呼び出された場合は、初めの呼び出しによる値の変更が優先されます。
    • Calling this method several times before the next frame (for example, if unrelated code also wants to make dynamic changes and run a transition on the same scene root), only the first call will trigger capturing values and exiting the current scene. Subsequent calls to the method with the same scene root during the same frame will be ignored.</blockquote >

    • https://developer.android.com/reference/android/transition/TransitionManager#beginDelayedTransition
  • Visual Regression Test、Jetpack Composeなどとの親和性が高い
  • View側のロジックを少なくし、薄く保つことができる

といったメリットを得られることができます。

コードで示すと、下記のようなイメージです。前者がイベント駆動の例で、後者のようにできると理想です。

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  viewLifecycleOwner.lifecycleScope.launch {
    fooEventStateFlow
      .flowWithLifecycle(viewLifecycleOwner.lifecycle)
      .collect { updateBarView(it) }
  }
}

fun updateBarView(fooEvent: FooEvent) {
  // BarViewの状態を更新する
  barView.isVisible = fooEvent.isHoge
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  viewLifecycleOwner.lifecycleScope.launch {
    viewStateStateFlow
      .flowWithLifecycle(viewLifecycleOwner.lifecycle)
      .collect { updateLayout(it) }
  }
}

fun updateLayout(viewStates: ViewStates) {
  // 全てのViewの状態を更新する
  fooView.isVisible = viewStates.foo.isVisible
  barView.isVisible = viewStates.bar.isVisible
}

また、Androidのアプリアーキテクチャガイドでも、UIをモデルで操作することが推奨されています。

ここまで、複雑なUIやアニメーションを実現する上でのポイントを、今回のリニューアルプロジェクトを例に紹介しました。
クライアントアプリケーションにとってUIは非常に重要な要素です。我々のノウハウが少しでも皆さんの開発に役に立つと嬉しいです。

オンボーディングのアクセシビリティ対応

この章は2021新卒の高鼻郷が担当します。
この章ではオンボーディングのアクセシビリティ対応について、実装例も合わせて紹介します。

ABEMAのオンボーディング

ABEMAのオンボーディングはスプラッシュ〜ホームまでに表示するページのことを指しており、以下の画像のように4つのページで構成されています。

ABEMAのオンボーディングはWelcomeページ・年齢性別アンケートページ・ジャンルアンケートページ・レコメンド最適化ページの4つで構成

これらのページは初回のみユーザに表示され、かつ、動画再生や課金処理をするようなページではないため、一見するとあまり重要なページに思えないかもしれません。
しかしオンボーディングが突破できないユーザがいた場合、ホームに辿り着けず、ABEMAの視聴体験をせずに離脱してしまいます。
そのためオンボーディングの実装をする際には、アクセシビリティの観点を踏まえて、TalkBackを使った操作においてもオンボーディングを突破できるように実装をしました。

TalkBackとは

TalkBackとはAndroidのフレームワークが標準で提供しているユーザ補助機能です。TalkBackを有効にすると、画面に表示された情報が読み上げられるようになり、ユーザは画面を見ずに Android デバイスを操作できます。
より詳しい情報はこちらのサイトから確認できます。
https://developer.android.com/guide/topics/ui/accessibility/testing

TalkBackを考慮する前の音声読み上げ

Androidでは開発者が特別なアクセシビリティ対応をしなくても、ある程度はOS側が最適な読み上げをしてくれます。
例えばWelcomeページとレコメンド最適化ページでは特別なアクセシビリティ対応をしておらず、TextViewやButtonを配置しているだけです。それでもTalkBackのフォーカスが当たれば、その内容を読み上げてくれます。Welcomeページは画面下の「ジャンルを選択してはじめる」ボタンをTalkBackのフォーカスを移動させて見つけてるのは容易ですし、レコメンド最適化ページは自動的にホーム画面に遷移するので、この2つのページでは特別なアクセシビリティ対応はしていません。

一方で、年齢性別アンケートとジャンルアンケートのページでは特別な対応が必要でした。
まず年齢性別アンケートは、「00歳」のように年齢を表示しているビューの読み上げに対応が必要でした。
下の画像では、「00」と「歳」が1つのグループになってTalkBackのフォーカスが当たっていることがわかります。実は「00」と「歳」は別のTextViewになっていて、親のレイアウトでその2つを囲んでいます。そして親レイアウトをfocusableにしているため、TalkBackのフォーカスはその親レイアウトに当たっています。
一見すると「ゼロサイ」と普通に年齢を読み上げてくれそうですが、「ダブルオー とし」と意味がおかしな読み上げになってしまいます。これでは画面を見ずに入力した年齢を確認することができません。
次にジャンルアンケートは、ジャンルの選択状態の読み上げに対応する必要がありました。
下の画像では「ジャンル1」が選択されているのがわかりますが、ジャンルの名前しか読み上げられないため、そのジャンルを選択したかどうかわかりません。

TalkBackを考慮した実装の例

前節で述べたように、オンボーディングにはTalkBackの読み上げについて問題が2点ありました。

  • 年齢性別アンケートで意味がおかしな年齢の読み上げがされてしまう
  • ジャンルアンケートでジャンルの選択状態が読み上げられない

この節では、これらを解決するために実装した例を紹介します。

年齢性別アンケートで年齢を正しく読み上げる

まず年齢を表示している箇所のレイアウトは次のようになっています。

まず親のレイアウトが ConstraintLayout で、その中央に年齢の数字を表示するTextView(@+id/demographic_survey_age)があります(以下、年齢TextView)。そして、年齢TextViewのBaseLineに合わせて、「歳」を固定表示するTextView(@+id/demographic_survey_age_unit)があります(以下、歳TextView)。

<ConstraintLayout
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:focusable="true"
  ...
  >
  <TextView
    android:id="@+id/demographic_survey_age"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:accessibilityLiveRegion="polite"
    android:hint="@string/demographic_survey_age_hint"
    ...
    />

  <TextView
    android:id="@+id/demographic_survey_age_unit"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="12dp"
    android:text="@string/demographic_survey_age_unit"
    app:layout_constraintBaseline_toBaselineOf="@id/demographic_survey_age"
    ...
    />
</ConstraintLayout>

親ViewGroupをfocusableにする

親の ConstraintLayoutandroid:focusable="true" になっています。
これにより、親のレイアウトにフォーカスがあたり、子Viewごとにフォーカスが当たらなくなります。

子Viewの内容がグループ化される

このままでは親のレイアウトにTalkBackのフォーカスが当たった時に「ダブルオー とし」とおかしなテキストが読み上げられます。
そもそも「ダブルオー とし」と読み上げれてしまう理由は、以下のように年齢TextViewと歳TextViewが別々に読み上げれているためです。

  • 年齢TextViewの「00」→「ダブルオー」
  • 歳TextViewの「歳」→「とし」

TalkBackのフォーカスがViewGroupに当たった場合は、子のViewの内容が順次読み上げられます。これにより、TalkBackのユーザは関連したViewの内容を一度の読み上げで聞くことができます。もしViewGroupがfocusableになっていなければ、子Viewにそれぞれフォーカスを移動させないと読み上げられないため、関連がわかりにくくなり、手間もかかります。

(参考:関連コンテンツのグループ)

しかしViewの内容が一度に読み上げられるからと言って、Viewの内容同士の関係を理解してくれるわけではありません。目で見た情報では「0歳である」と理解できても、TalkBackの動作を考えると、2つのTextViewを順番に読み上げているだけなので「ダブルオー」「とし」と読み上げるしかありません。

ViewCompat.setAccessibilityDelegate(...) を使って読み上げテキストを上書きする

AndroidにはAccessibility APIがあり、ユーザ補助機能を強化するために使うことができます。ここでは読み上げテキストを上書きするというユースケースで利用します。
そして、Accessibility APIを使って実現したいことは「親のViewGroupにフォーカスがあった時に〇〇歳と読み上げる」ことです。

ViewCompat.setAccessibilityDelegate(...) を利用することで、ユーザ補助機能を強化するメソッドを実装することができます。

第1引数には対象のView、第2引数には AccessibilityDelegateCompat を継承した無名オブジェクトを渡しています。 AccessibilityDelegateCompatonInitializeAccessibilityNodeInfo(...) などのユーザ補助機能を強化するメソッドを持っていて、オーバライドすることがあります。各種メソッドの説明は以下のドキュメントに記載されています。

ここではViewに関する情報を追加したいので、 onInitializeAccessibilityNodeInfo(...) をオーバライドします。

(参考:Accessibility API のメソッドを実装する)

private val binding: LayoutDemographicSurveyAgeViewBinding = ...
private var age: Int = 0

ViewCompat.setAccessibilityDelegate(
  binding.demographicSurveyAge,  // 年齢TextView
  object : AccessibilityDelegateCompat() {
    override fun onInitializeAccessibilityNodeInfo(
      host: View,
      info: AccessibilityNodeInfoCompat
    ) {
      super.onInitializeAccessibilityNodeInfo(host, info)
      info.hintText = null
      info.text = resources.getString(R.string.demographic_survey_age_readable, age)
      // %d歳
    }
  }
)

ViewCompat.setAccessibilityDelegate(
  binding.demographicSurveyAgeUnit,  // 歳TextView
  object : AccessibilityDelegateCompat() {
    override fun onInitializeAccessibilityNodeInfo(
      host: View,
      info: AccessibilityNodeInfoCompat
    ) {
      super.onInitializeAccessibilityNodeInfo(host, info)
      info.text = null
    }
  }
)

AccessibilityNodeInfoCompatのプロパティを設定する

AccessibilityNodeInfoCompatはユーザ補助機能にViewの情報を伝えるための情報を持っています。プロパティは以下の画像のように数多くあります。

読み上げテキストの上書きをするためには、 text プロパティを設定します。

年齢TextViewはStringリソースを用いて "0歳"という文字列を設定しています。

また hintText プロパティをnullに設定して、ヒントテキストが読み上げられないようにします。これにより「ダブルオー」と読み上げられなくなります。

なお、歳TextViewは読み上げる必要が無くなったので、 text プロパティをnullに設定します。

ここまで設定すると、親のViewGroupにフォーカスがあった時に〇〇歳と読み上げることができるようになります。

ジャンルアンケートのジャンルの選択状態を読み上げる

ジャンルはRecyclerView(GridLayout)に表示されています。

ジャンルそれぞれは CardView で作られています。

<androidx.cardview.widget.CardView
  ...
  >
  <androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    ...
    >
    <ImageView
      android:id="@+id/genre_selection_thumbnail"
      ...
      />

    <TextView
      android:id="@+id/genre_selection_title"
      ...
      />

    ...

  </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

ViewCompat.setAccessibilityDelegate(...) を使ってチェック済みかどうかを上書きする

年齢アンケートと同様に ViewCompat.setAccessibilityDelegate(...) を利用して、Viewに関する情報を追加します。ただし、年齢アンケートとは異なり onInitializeAccessibilityEvent(...) もオーバーライドしています。

onInitializeAccessibilityEvent(...) はチェックボックスにチェックが入った時のようなフィードバックを設定するのに使用でき、ジャンルアンケートではジャンルにチェックが入ったかどうかを「オン/オフ」という読み上げでフィードバックします。

「オン/オフ」というフィードバックを与えるには AccessibilityEventisChecked プロパティを設定します。

onInitializeAccessibilityNodeInfo も同様にして、 isCheckable プロパティと isChecked プロパティを設定して、TalkBackの読み上げ時に「オン/オフ」という情報を読み上げられるようにします。

なお、RecyclewViewのアイテムのbind時に ViewCompat.setAccessibilityDelegate(...) を実行しています。

val binding: LayoutGenreSurveyGenreItemBinding = ...
var isGenreSelected: Booelan = ...

binding.root.isSelected = isGenreSelected

ViewCompat.setAccessibilityDelegate(
  binding.root, // 親レイアウト
  object : AccessibilityDelegateCompat() {
    override fun onInitializeAccessibilityEvent(host: View, event: AccessibilityEvent) {
      super.onInitializeAccessibilityEvent(host, event)
      event.isChecked = isGenreSelected
    }

    override fun onInitializeAccessibilityNodeInfo(
      host: View,
      info: AccessibilityNodeInfoCompat
    ) {
      super.onInitializeAccessibilityNodeInfo(host, info)
      info.isCheckable = true
      info.isChecked = isGenreSelected
    }
  }
)

View.setSelected(...) を設定して「選択済み」と読み上げる

ViewCompat.setAccessibilityDelegate(...) の設定だけでも十分な情報が追加できていますが、 binding.root.isSelected = isGenreSelected のように View.setSelected() をセットすることで、TalkBackのフォーカスが当たった時に「選択済み」と読み上げられるようになります。

最終的には「選択済み オン 1 ジャンル1」という読み上げになり、視覚情報と同等の情報を付与することができました。

年齢性別アンケートページとジャンルアンケートページ

アクセシビリティ対応をするきっかけ

アクセシビリティ対応は、オンボーディングの開発要件ではありませんでした。しかし、TalkBackを有効にしてアプリを触ってみるというエンジニア主体の取り組みの中で「オンボーディングが突破できないかもしれない」という懸念点が見つかり、対応に至りました。
なお、サイバーエージェントのアクセシビリティの取り組みについては、CyberAgent Developers Advent Calendar 2021の 12/22の記事 にて詳しく紹介されていますので、ぜひそちらもご覧ください。

まとめ – ABEMAのオンボーディング

ABEMAのオンボーディングで行ったアクセシビリティ対応について紹介しました。
AndroidにはAccessibility APIがあり、ユーザ補助機能を強化するために使うことができます。このAPIを利用することで、読み上げテキストやチェックの情報を追加することができます。
また、ABEMAではアクセシビリティを推進する取り組みがあるため、これからさらにアクセシビリティの対応は広がっていくと思います。

リリースに至る開発戦略と開発Tipsのまとめ

ここからはリニューアルプロジェクトの成功に向けて実践した開発戦略や開発Tipsについて木永が書いていきます。

プロジェクト開始当初、このリニューアルプロジェクトのチームリーダーを私が任された時は、仕様書もなければ明文化された要件もなく、N1分析というマーケティング手法とプロトタイピングを組み合わせたユーザーヒアリングが行われ、仮説と検証を繰り返しながら、プロダクトのリニューアルに向けた方向性を模索している状態でした。

そのような状態から実際にリリースに至るまでにはさまざまな取り組みや改善が必要でした。

こちらはそのリニューアルプロジェクト期間で取り組んだAndroidのタスクとチームメンバーの変遷をまとめたものです。

Androidチームのタスクとメンバーの変遷

要件定義や仕様策定のフェーズからリリース後のブラッシュアップまでを含めると約1年もの時間をかけたプロジェクトになっています。
さらに、プロジェクトを通じてチーム内で管理していたロードマップ/ガントチャートの最終的なアウトプットを見てみましょう。

1年規模のリニューアルプロジェクトの全体ロードマップ

このシートからも本プロジェクトの規模感が伝わるかなと思います😇
前半は基盤改修をメインで行い、後半でリニューアルのための機能改修を行なっていきました。

なぜリニューアルに伴い、基盤改修を行なったのかについてはこちらの記事では詳しくは触れませんが、内容をまとめたセッション動画がありますので、合わせてご覧いただければと思います。

それではAndroidチームのリーダーとしてこのプロジェクトを実際にリリースするまでに行なったトピックを一部抜粋してご紹介したいと思います。

  • プロジェクト終了時の理想状態の言語化をプロジェクト開始時に行っておく
  • 複数人で対応が必要な改修はプロセスを可視化し、ブロッキング構造を明確にする
  • 正式リリース時のリスクを定常リリースや段階リリースで分散させる
  • 中だるみが発生しないようにチームビルディングで開発モチベーションを高め続ける
  • 開発以外にかかる工数も見積もりに組み込んでスケジュールを調整する

プロジェクト終了時の理想状態の言語化をプロジェクト開始時に行っておく

プロジェクト初期にまずやってこととして、このプロジェクトを達成したときにどういう状態になっていたいかを言語化するところから始めました。

以下の5つの観点で今よりも良い状態にする、ということを掲げました。
それぞれ具体例があり、理想状態を言語化しています。

プロダクト(ユーザー体験)
  • ユーザーの満足度が高い状態になっている
  • サービス構造が整理され、仕様や体験もシンプルになる
  • 不要となった実装がしっかり削除されている
  • アクセシビリティが向上している
  • 致命的なバグやクラッシュがない
組織
  • ドキュメントや仕様書が充実し、誰でも既存の仕組みを理解できる状態になる
  • 若手が活躍している
  • Phoenixを通した成果や実績が評価につながっている
  • 余分なMTGが少なくなり、作業やコーディングに集中できる
チーム
  • ネイティブチームとして動けている(Androidチームではなく)
  • 仲良く気さくにランチに行けている
  • メンバーが仕様やプロジェクトの目指すところを平等に理解できている
技術
  • 技術的負債が返却される
  • モダンな技術を採用できている
  • Kotlin化が今よりも進んでいる
  • リアーキテクチャに関する実績やプラクティスが貯まっている
  • iOSとAndroidの共通化がMPPで実現できている
採用
  • ABEMAに来たいというメンバーが増えている
  • 採用情報の技術スタックにモダンな技術を書いていける

プロジェクト達成時の理想状態の言語化

この理想状態は、定期的に見返すことで、その時の状態とのギャップを認識するために活用していました。
振り返ってみると、これらの達成度は総合で約80%といった感じで、リニューアルのための機能開発だけでなく、技術的なチャレンジや開発フローへの改善などにも広く取り組めたかなと思っています。

また、この理想状態の言語化は、自身の意思決定に対する行動指針としても活用できたなと感じていて、例えば、「なぜそうしたいのか」を自分の中で腑に落とせたり、メンバーに対してWhyを説明するときにも自分の考えを伝える中で参考にできました。

ABEMAに来たいというメンバーが増えている
ちなみに今回の記事については、ここに刺さるといいなぁという気持ちで書いているところです😇

複数人で対応が必要な改修はプロセスを可視化し、ブロッキング構造を明確にする

影響範囲も多く、関わるメンバーが多い開発では、スケジュールの解像度を上げるために事前に開発リスクやゴールまでのプロセスを可視化しておくことで開発が進めやすくなります。
特に今回のリニューアルプロジェクトでは前半で対応していたSingleActivity化やDaggerHiltの導入といった基盤改修の対応内容が膨大かつ複雑であったため、アプリ全体のクラス一覧、対応方針、作業ストーリーなどの資料を事前に作成し、チームでレビューや懸念出しを行なって進めていきました。

アプリ全体のクラス一覧 対応方針 作業ストーリー

これらの資料をもとに議論を進めることで、完璧な工数までを見極めることはできませんが、達成までのプロセスを想像でき、例えば以下のような戦略を立てることができます。

  • それぞれのタスクに誰を、どれくらいの期間・比重でアサインさせるか
  • ブロッキングを防ぐために、それぞれのタスクをどの順番でやっていくか
  • QAチームや他の部署やチームをどのフェーズで巻き込むか、すり合わせしていくべきか

振り返ると、チームメンバーが作業の全体感を平等に理解できていることは、メンバー間のサポートや連携を行いやすくする恩恵もあると思うので、可視化を率先して行なって議論を進めたのはとても意義があったと思っています。

また、この可視化は開発以外の面でも有効です。
実際にリリースを行うには、機能を開発すればいいだけではありません。PMなどと一緒に様々なチームと連携していく必要があります。

  • QAチーム: 品質・動作保証のためのテストを連携して行う
  • 運用チーム: コンテンツ訴求やレコメンドなどを開始(不要となる機能については随時停止する必要もある)させるため、日程やスケジュールをすり合わせる
  • 宣伝・広報チーム: ASOやプレスリリース/お知らせ情報の更新、各所スクショなどの差し替えなどを連携して行う
  • データチーム: リリース後の効果検証を行っていくため、BIツールなどを整備しておく

これらも事前に全体感を把握し、リリースまでのあらゆる作業を一通り洗い出せていることで安心して作業を進められます。
まとまった一枚絵があることで、作業や観点の抜け漏れも各所のメンバーからもらいやすくなると思います。

正式リリース時のリスクを定常リリースや段階リリースで分散させる

ある程度大きな開発になってくると、開発用のブランチを切ってそちらにすべての変更を入れていくと、リリース時のmasterマージがとても大変なものになってしまいます。その結果、機能のデグレードや品質低下のリスクが上がってしまいます。
今回のリニューアルプロジェクトでは、基盤改修など比較的影響範囲が大きいものや、FeatureFlagなどで機能の切り替えが可能な部分については、先んじてmasterにマージすることで市場リリースし、デザインや機能において破壊的な変更があるもののみ、リニューアルプロジェクト用のブランチで開発していく方針にしました。

リニューアルプロジェクト期間におけるブランチ戦略

このようにすると、デメリットとして、改修単位でどのブランチで開発を行っていくかを考える必要がありますが、メリットとしては、正式リリース時の変更差分が少なくなり、市場リリースしている変更については不具合に気付けるタイミングが早くなります。
また、リニューアル自体がとてもリスキーなものなので、リリース後のKPI・KGIの推移次第ではロールバックの可能性もありますし、破壊的な仕様変更が開発途中で発生するリスクもあります。そのような場合に技術的な改善までを切り戻す必要はないので、リニューアルプロジェクト用のブランチの差分はできる限り薄くしておくのが得策だと考えています。

中だるみが発生しないようにチームビルディングで開発モチベーションを高め続ける

KPTとチームラーニング

Androidチームでは、リニューアルプロジェクトを通じて、過去の目線ではKPTを行い、未来の目線でチームラーニングを実施することでチームビルディングを行っていきました。
KPTは基本的に月1のペースで実施し、チームラーニングは開発内容が大きく変化するタイミングや、チームメンバーに変動があったタイミングで実施しました(結果的には1年間で3回実施しました)。
チームラーニングの取り組みについては、チームメンバーの1人が過去に別のチームで行なった経験があったこと、またこちらの記事を参考にさせていただき、Miroを使った方法で実践してみました。

- (自分に対して自分が書く)
    - 得意なこと・強み
    - チームメンバーは自分にどんな成果を期待しているか
- (他のメンバーに対して自分が書く)
    - 他のメンバーに期待することは何か
- (他のメンバーから自分に届く)
    - 他のメンバーから期待されていることは何か

チームラーニングの様子

このような内容で対話をし、周囲と自身との期待値をすり合わせたり、気付いていなかったことなど周囲とのギャップに気付けるような機会になるようにしています。

技術的なチャレンジを開発項目に加える

UI/UXの刷新を目指す中で、開発基盤も今後の開発を見据えてリニューアルプロジェクトの改善の対象として工数やスケジュールの中に組み込みました。
その中で、今このタイミングでやっておかないと実現が難しそうなものであったり、Android開発者としてチャレンジしたい改修やライブラリ導入などをチームメンバーで洗い出し、優先度を議論しながら対応するものを決めていきました。

リニューアルプロジェクトに取り込みたい技術的なチャレンジ一覧

結果的には、この議論の中で決まったものを冒頭での説明でも触れていた基盤系の開発項目として実現できたり、分掌単位の開発の中で率先して取り組んでいく方針を立て付けることができました。
事業の案件のみを実装しているだけだと、開発のモチベーションを個人で維持し続けることが難しい場合もあるので、チームとしてこのような仕組みを形成し、アサインなども工夫していくことで、タスクのミスマッチを防ぎ、士気を高めていきました。

開発以外にかかる工数も見積もりに組み込んでスケジュールを調整する

今回のリニューアルプロジェクトは、ABEMA全体で取り組んでいるプロジェクトであったため、リリーススケジュールを他のプラットフォームなどとも足並みを揃えて開発を進めていくことが求められます。
そのため、リリースまでにかかる見積もりが例え序盤のフェーズであっても精度の高いものを算出し、共有していくことが望ましい状況でした。
そこで、Androidチームでは、見積もりに関して大きく2つの工夫を行いました。

人日単位の個人間の見積もりズレを矯正するためにストーリーポイントの粒度を明文化する

スプリントプランニングなどで用いられるストーリーポイントを活用して、序盤は見積もりを出していきました。
というのも、プロジェクトの序盤はまだまだ仕様が明確に固まっていないことも多く、曖昧な要件や仕様に関しても考慮漏れなどが多数あり、そのタイミングでタスクの単位にまで細分化することは現実的に難しいため、ストーリーポイントを使うことである程度の工数ギャップを埋め合わせながら全体感を見定めていきます。
この際にチームでSP(ストーリーポイント)の大きさを揃えるために以下のように定義しました。

1SP...
 - 調査:1.5h
 - 設計:1h
 - 実装:3.5h
 - 単体テスト:1.5h
 - プルリクエスト(レビューアの工数もここに計上):2.5h
 - QAテスト(インプット&テスト内容レビュー):2h
 - QA不具合対応(調査・修正・PR・テスト依頼):2.5h
 - リファクタリング:2h
 - コンテキストスイッチ:2h
 - 必要に応じて
   - 仕様調整(MTG・issue・GA):4.75h
合計23.25h

タスクによってはこの内容の一部が不要となるケースも多いと思うので、あくまでタスクのサイズ感の認識を合わせるために定義したものです。
不確実性の高いプロジェクト初期のフェーズでは見積もりはSP単位で行い、そのSPを時間に換算して、スケジュールを引いていきます。

開発外工数を見積もりに含める

手前で解説したストーリーポイントは開発に必要な工数でしたが、業務の1日は全てこの開発工数の消化に当てられるわけではありません。
業務上必要なMTGやトレーナー・マネージャー業務などメンバーによって関わっているプロジェクトや仕事も異なり、開発に割ける時間も異なります。
これらを含めずにスケジュールを組んだ場合、MTGや開発外の業務が増えれば増えるほど実際の開発スケジュールが厳しくなっていってしまいます。
Androidチームでは、この開発外の工数を定量的に算出し、ロードマップに組み込んでスケジューリングを行いました。
細かい算出の内容については、チームの状況やプロジェクト単位で大きく変化するため割愛しますが、より正確な工数出しに寄与してくれたと感じています。

取り組みの成果

いくつか抜粋して紹介してきましたが、これらの取り組みはプロジェクトの成果にも繋がりました。

クラッシュフリー率が99.7% → 99.9%に

リリース戦略やQAチームとの連携が作用し、リニューアル後の方がクラッシュフリー率が高い状態でリリースを実現することができました。

直近1週間のクラッシュフリーユーザー率の推移。99.9%を維持

スケジュールは前倒しの成果

開発外工数の導入でより現実的なロードマップの策定を行いつつ、ブロッキング状況の可視化を適宜行うことで手戻りのない開発を継続的に遂行することができました。結果的には開発終盤でFixさせたリリース日からはさらに2週間ほど前倒しの形でリリースすることができました。

モダンなコード基盤を実現

リニューアルプロジェクトの開発要件を実現させるだけでなく、Androidのコード基盤においてはモダンな技術の導入や大規模な刷新も行われ、今後の開発を加速させる改善も合わせて行うことができました。

まとめ – リリースに至る開発戦略と開発Tipsのまとめ

リニューアルプロジェクトの中で取り組んだ取り組みについてご紹介しました。
良い面を特にピックアップして書いてきましたが、iOSとの機能差異の問題や開発フローに潜む問題はまだまだ依然として存在しています。
今回のリニューアルプロジェクトは終わりではなく、ABEMAとしてテレビの再発明に向けた始まりなので、ここからさらにプロダクトを成長させていければと思っています。

おわりに

ここまでお読みいただき、ありがとうございました。
今回のリニューアルプロジェクトでは、この記事で紹介しきれないほどのさまざまな取り組みを行っています。関連する記事や動画を掲載しておきますので、ぜひご覧ください。

また、Androidに関してはJetpack Composeの導入やKotlin Multiplatform Mobileによるコードの共通化など、技術的にチャレンジングな試みを引き続き行っていきます。
もしABEMAやサイバーエージェントに興味を持っていただけた方は、下記の採用情報もご覧ください。

採用情報

サイバーエージェントに少しでも興味を持っていただきましたら、お気軽にマイページ登録やエントリーをお願いします!

2020年に株式会社サイバーエージェント中途入社。株式会社AbemaTV Native Team所属。 Androidチームのテックリードとしてサービス開発を行なっている。
2018年に株式会社サイバーエージェントに新卒入社。 株式会社AbemaTV Native Team所属。 モバイルネイティブアプリケーションエンジニアとして、ABEMAモバイルアプリやテレビアプリ、マルチプラットフォーム開発に従事。
2021年新卒入社のAndroidエンジニアです。ABEMAでAndroidアプリ開発をしています。