開発者コンソール

Fire TV対応アプリのパフォーマンスを最適化

Giovanni Laquidara Dec 05, 2024
Share:
Fire TV App performance Best practices
Blog_Header_Post_Img

仕事を終え、ゆったりと座ってTV番組を視聴しているときに、読み込み中のアイコンをただ見つめながらアプリが起動するのをじっと待つというのは、開発者にとってもユーザーにとっても不本意な時間です。ユーザーにとってアプリのパフォーマンスはとても重要です。読み込み待ちや遅延が発生することなく、シームレスに動作する必要があります。スムーズで反応の良いストリーミング体験を提供するために、Fire TV対応アプリのパフォーマンスでよくある3つの問題と、その解決方法について考えていきましょう。

起動時間が長い

アプリの起動時間はユーザーとアプリとの最初のインタラクションであり、全体的な印象が決まります。起動が遅かったり、反応がなかったりすると、コンテンツを利用してもらう以前にユーザーが不満を感じ、アプリを離れる原因となってしまいます。アプリ起動時のパフォーマンスを把握して最適化することは、第一印象を良くし、ユーザーのリテンションを高める上で大事な要素です。

 

TTIDとTTFDについて

起動時のパフォーマンスを効果的に測定・改善するには、以下の2つの重要な指標を理解することが大切です。

  1. 初期表示までの時間(TTID): アプリが起動後に最初のフレームを表示するまでにかかる時間です。画面上でのユーザーへの表示速度を表し、アプリの反応の良さを示します。
  2. 完全表示までの時間(TTFD): 重要なUI要素と最初のコンテンツがすべて読み込まれ、アプリが完全に対話操作を受け付ける状態になるまでにかかる時間を測定します。

 

TTIDの取得

アプリのTTIDを確認するには、Logcatで「Displayed」の値を探します。

Retrieving TTID

TTFDの測定

TTFDを測定するには、reportFullyDrawn()メソッドを使用します。

Copied to clipboard
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            HelloWorldTVTheme {
                // Your app content
            }
        }
        // Call this when your app is fully loaded and interactive
        reportFullyDrawn()
    }
}
Measuring TTFD

Jetpack Composeを使用したTTIDとTTFDの測定

Fire TV対応アプリでJetpack Composeを使用している場合は、固有のAPIを使用して、前の指標を正確に測定できます。

  • ReportDrawn(): このAPIは、コンポーザブルですぐにインタラクションに対応できる準備が整っていることを示します。通常、TTIDを測定するために使用されます。

Copied to clipboard
@Composable
fun HomeScreen() {
    ReportDrawn()
    // Your UI content
}

  • ReportDrawnWhen(): このAPIは述語を受け取り、条件が満たされた場合に描画の完了状態を報告します。UIの準備状態が特定のデータの読み込みに依存する場合に役立ちます。

Copied to clipboard
@Composable
fun ContentList(items: List<Item>) {
    ReportDrawnWhen(items.size > 0)
    LazyColumn {
        items(items) { item ->
            ItemRow(item)
        }
    }
}

  • ReportDrawnAfter(): このAPIは、サスペンド関数を受け取り、関数の完了後に描画の完了状態を報告します。初期データの読み込みなど、時間がかかる可能性のある操作で特に役立ちます。

Copied to clipboard
@Composable
fun DataDependentScreen(viewModel: MyViewModel) {
    ReportDrawnAfter {
        viewModel.loadInitialData()
    }
    // UI that depends on the loaded data
}

上記やその他の測定値をさらに詳しく調べるには、オープンソースのFireOSパフォーマンステストツール(英文のみ)を活用して、コンピューターに接続された実際のデバイス上でアプリを分析し、最適化プロセスの包括的なログを生成することもできます。

TTIDとTTFDを短縮する方法

TTIDやTTFDの数値が大きすぎる場合(こちらに記載されている値が大きい場合など)は、アプリの起動パフォーマンスが、以下のよくある問題のいずれかの影響を受けている可能性があります。

  1. 複雑なレイアウト: 大きなレイアウトや深くネストされたレイアウトをインフレートしている。
  2. I/O操作: メインアクティビティの開始前またはApplicationクラス内のメインスレッドで、ディスクやネットワークの操作を実行している。
  3. 画像処理: 起動時に大きな画像の読み込みとデコードが非効率的に行われている。

ビデオ再生管理の効率が悪い

An animated GIF showing a spinning loading indicator, indicating that the video player is initializing

ビデオ再生は、あらゆるストリーミングアプリの核となる機能です。パフォーマンスの問題やユーザーエクスペリエンスの低下は、多くの場合、プレーヤーインスタンスの不適切な管理が原因で発生します。

こちらのコード例には、ビデオ再生管理に関するよくある間違いがいくつか含まれています。どこに問題があるか、探してみてください。

Copied to clipboard
class VideoPlayerActivity : ComponentActivity() {
    private lateinit var player: ExoPlayer

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        player = ExoPlayer.Builder(this).build()
        val mediaItem = MediaItem.fromUri("https://example.com/video.mp4")
        player.setMediaItem(mediaItem)
        player.prepare()

        setContent {
            VideoPlayerComposable(player)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        player.release()
    }
}

@Composable
fun VideoPlayerComposable(player: ExoPlayer) {
    AndroidView({ context ->
        PlayerView(context).apply {
            this.player = player
        }
    })
}

問題は見つかりましたか? 問題となる点は以下のとおりです。

  1. プレーヤーの作成と準備がonCreateで実行されているため、ほかのUIの初期化が遅延する可能性がある。
  2. 構成変更の処理がないため、プレーヤーの不要な再初期化が行われる可能性がある。
  3. プレーヤーの準備中にユーザーへの視覚的なフィードバックがない。
  4. エラー処理がないため、クラッシュしたり、UIが応答しなくなったりする可能性がある。

 

問題の解決方法

Copied to clipboard
class VideoPlayerViewModel : ViewModel() {
    private val _playerState = MutableStateFlow<PlayerState>(PlayerState.Initializing)
    val playerState: StateFlow<PlayerState> = _playerState

    private var player: ExoPlayer? = null

    fun initializePlayer(context: Context) {
        // Ensure this is called on the main thread
        player = ExoPlayer.Builder(context).build().apply {
            val mediaItem = MediaItem.fromUri("https://example.com/video.mp4")
            setMediaItem(mediaItem)
            // Prepare asynchronously
            prepare()
            addListener(object : Player.Listener {
                override fun onPlaybackStateChanged(state: Int) {
                    when (state) {
                        Player.STATE_READY -> _playerState.value = PlayerState.Ready(this@apply)
                        Player.STATE_BUFFERING -> _playerState.value = PlayerState.Buffering
                        Player.STATE_ENDED -> _playerState.value = PlayerState.Ended
                        Player.STATE_IDLE -> _playerState.value = PlayerState.Idle
                    }
                }

                override fun onPlayerError(error: PlaybackException) {
                    _playerState.value = PlayerState.Error(error.message ?: "Unknown error")
                }
            })
        }
    }

    fun releasePlayer() {
        player?.release()
        player = null
        _playerState.value = PlayerState.Initializing
    }

    override fun onCleared() {
        releasePlayer()
        super.onCleared()
    }
}

sealed class PlayerState {
    object Initializing : PlayerState()
    object Buffering : PlayerState()
    class Ready(val player: ExoPlayer) : PlayerState()
    object Ended : PlayerState()
    object Idle : PlayerState()
    class Error(val message: String) : PlayerState()
}

@Composable
fun VideoPlayerScreen(viewModel: VideoPlayerViewModel = viewModel()) {
    val playerState by viewModel.playerState.collectAsState()
    val context = LocalContext.current

    LaunchedEffect(Unit) {
        viewModel.initializePlayer(context)
    }

    DisposableEffect(Unit) {
        onDispose {
            viewModel.releasePlayer()
        }
    }

    when (val state = playerState) {
        is PlayerState.Initializing, PlayerState.Buffering -> LoadingIndicator()
        is PlayerState.Ready -> VideoPlayer(state.player)
        is PlayerState.Error -> ErrorMessage(state.message)
        PlayerState.Ended -> PlaybackEndedUI()
        PlayerState.Idle -> IdleStateUI()
    }
}

@Composable
fun VideoPlayer(player: ExoPlayer) {
    AndroidView(
        factory = { context ->
            PlayerView(context).apply {
                this.player = player
            }
        },
        modifier = Modifier.fillMaxSize()
    )
}

ViewModelおよびComposeのLaunchedEffectDisposableEffectを使用して、ビデオプレーヤーの初期化とクリーンアップが適切に処理されるようにしました。

Copied to clipboard
    LaunchedEffect(Unit) {
        viewModel.initializePlayer(context)
    }
    DisposableEffect(Unit) {
        onDispose {
            viewModel.releasePlayer()
        }
    }

さらに、読み込み中を表すインジケーターとエラーメッセージを追加してユーザーへのフィードバックを改善し、再生エラーをカバーしてクラッシュを回避できるようにしました。

Copied to clipboard
 when (val state = playerState) {
        is PlayerState.Initializing, PlayerState.Buffering -> LoadingIndicator()
        is PlayerState.Ready -> VideoPlayer(state.player)
        is PlayerState.Error -> ErrorMessage(state.message)
        PlayerState.Ended -> PlaybackEndedUI()
        PlayerState.Idle -> IdleStateUI()
    }

画像の読み込みが最適化されていない

A screenshot of a mobile app UI displaying a gallery of movie poster thumbnails. The thumbnails are arranged in a grid layout, showcasing various movie titles and cover art

 

Fire TV対応アプリでは、映画のポスター、番組のサムネイル、俳優の顔写真など、同じ画面上に多くの画像が表示されることがあります。画像の読み込み方法によっては、アプリのパフォーマンスに多大な影響を及ぼす可能性もあります。画面上に構成可能な画像を描画する場合は、以下のような対策を講じることをお勧めします。

Copied to clipboard
@Composable
fun UnoptimizedThumbnail(url: String) {
    AsyncImage(
        model = url,
        contentDescription = null,
        modifier = Modifier.size(200.dp)
    )
}

こういったコンポーネントは、主に以下の3つの問題を引き起こす可能性があります。

  1. メモリ使用量が過剰になる: サムネイル表示でフルサイズの画像を読み込むと、メモリが過剰に消費されます。
  2. UIの読み込みが遅くなる: 大きな画像はダウンロードや処理に時間がかかるため、インターフェイスに遅延が発生します。
  3. スクロールが途切れる: コンテンツリストのスクロール時に画像の読み込みが不十分だと、スクロールが不安定になる可能性があります。

これは、コード内に以下のようなパフォーマンス上の落とし穴がいくつか隠されているためです。

  • 表示サイズに関係なく、フルサイズの画像がURLから読み込まれている。
  • ネットワークレベルでサイズ変更が行われていないため、必要以上に多くのデータがダウンロードされる可能性がある。
  • 画像をサイズ変更して表示するためにデバイスでより多くのCPUパワーが消費されるため、UIに問題が発生する可能性がある。

 

解決策:

これらの問題は、画像読み込みライブラリ(Coilなど)を使用して対処できます。

Copied to clipboard
@Composable
fun OptimizedThumbnail(url: String) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(url)
            .size(width = 200, height = 200)
            .crossfade(true)
            .build(),
        contentDescription = null,
        contentScale = ContentScale.Crop,
        modifier = Modifier.size(200.dp)
    )
}

改善内容について詳しく見ていきましょう。

1. サイズの指定

  • .size(width = 200, height = 200)で、(理想的にはサーバー上またはダウンロード中に)画像のサイズを200x200ピクセルに変更するようCoilに指示しています。これにより、転送されるデータ量と画像の保存に使用されるメモリが大幅に削減されます。

2. クロスフェード効果

  • .crossfade(true)で、画像の読み込み時にスムーズな遷移効果を追加しています。これにより、体感的なパフォーマンスが向上し、画像の読み込みがよりシームレスに感じられるようになります。

3. コンテンツのスケーリング

  • contentScale = ContentScale.Cropで、画像がゆがみなく領域全体に表示されるようにしています。これにより、画像の縦横比が異なっていても一貫した外観を提供できるようになります。

まとめ

パフォーマンスが高いFire TV対応アプリの基盤は、こうした最適化によって形成されます。上記のような手法を実装することで、競争の激しいTV向けアプリの世界で差別化されたストリーミング体験を提供し、ユーザーの満足度を高められるようになります。

ただし、最適なパフォーマンスの追求に終わりはありません。さらなる最適化を目指して、今後の記事でも引き続き情報をお届けしてまいります。

どうぞご期待ください。

関連記事

ニュースレターを購読してみませんか?

最新のAmazon開発者向けニュース、業界の動向、ブログの記事をお届けします。