When you sit down to watch some TV after work, the last thing you want to stare at a spinning circle as you wait for the app to load. The same goes for your customers. The last thing your customers want to think about is how well your app is performing. They just want it to work seamlessly, without any spinning circles or delays. To provide a smooth, responsive streaming experience, let's explore three common Fire TV app performance issues and how to fix them.
Your app startup time is your user's first interaction with your app, and it sets the tone for their entire experience. A slow or unresponsive startup can lead to frustration and even app abandonment before the user has a chance to engage with your content. Understanding and optimizing your app's startup performance is crucial for making a strong first impression and retaining users.
Understanding TTID and TTFD
To effectively measure and improve startup performance, it's important to understand two key metrics:
Retrieving TTID
To find your app's TTID, look for the "Displayed" value in Logcat:
Measuring TTFD
To measure TTFD, use the reportFullyDrawn()
method:
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 TTID and TTFD with Jetpack Compose
If you are using Jetpack Compose for your TV app, you can use specific APIs to help you accurately measure the previous metrics.
@Composable
fun HomeScreen() {
ReportDrawn()
// Your UI content
}
@Composable
fun ContentList(items: List<Item>) {
ReportDrawnWhen(items.size > 0)
LazyColumn {
items(items) { item ->
ItemRow(item)
}
}
}
@Composable
fun DataDependentScreen(viewModel: MyViewModel) {
ReportDrawnAfter {
viewModel.loadInitialData()
}
// UI that depends on the loaded data
}
To explore more of these and other measurements you can also leverage the open-source FireOS Performance Testing tool, to analyze your app on a real device connected to your computer, producing comprehensive logs for your optimization process.
How to speed up your TTID and TTFD
If you discover that your TTID and TTFD numbers are too big (for example more of the values stated here), your app’s startup performance may be impacted by one of these common issues:
Video playback is at the heart of any streaming app. Performance issues and poor user experience are often caused by improper management of the player instance.
This code example contains some common video playback management mistakes. Take a look to see if you can find them.
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
}
})
}
Did you found them? Here are the issues:
onCreate
, potentially delaying other UI initialization.
How to fix the issues
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()
)
}
We used a ViewModel and Compose's LaunchedEffect
and DisposableEffect
to handle proper initialization and cleanup of the video player;
LaunchedEffect(Unit) {
viewModel.initializePlayer(context)
}
DisposableEffect(Unit) {
onDispose {
viewModel.releasePlayer()
}
}
Plus, we added a loading indicator and error messages to provide a better feedback to the user and covered playback errors preventing crashes.
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()
}
Your Fire TV app likely showcases many images in the same screens such as movie posters, show thumbnails, actor headshots, and more. The way these images are loaded can significantly impact your app's performance. You may be tempted to use something like this to draw one of these images composable on screen:
@Composable
fun UnoptimizedThumbnail(url: String) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.size(200.dp)
)
}
This kind of component can lead to three main issues:
This is because the code is hiding several performance pitfalls:
Fix:
To address these issues you can use an image loading library ( like Coil )
@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)
)
}
Let's break down the improvements:
.size(width = 200, height = 200)
tells Coil to resize the image to 200x200 pixels, ideally on the server or during download. This significantly reduces the amount of data transferred and the memory used to store the image..crossfade(true)
adds a smooth transition effect when loading images. This improves the perceived performance, making image loading feel more seamless.contentScale = ContentScale.Crop
ensures the image fills the entire space without distortion. This provides a consistent look across different image aspect ratios.Wrapping up
These optimizations form the foundation of a high-performance Fire TV apps. By implementing these techniques, you're well on your way to creating a streaming experience that stands out in the competitive world of TV apps and makes your user happy.
However, the journey to optimal performance doesn't end here. In future articles, we'll explore additional optimization!
Stay tuned and keep optimizing!