A media-heavy feed (photos, videos, GIFs) requires careful preloading, memory management, and cache tuning. Without these, users see blank placeholders while scrolling and the app consumes excessive memory.
Image Preloading in RecyclerView
class FeedAdapter(
private val imageLoader: ImageLoader // Coil
) : ListAdapter<Post, PostViewHolder>(PostDiffCallback()) {
// RecyclerViewPreloader from Glide or Coil's preload support
private val preloadSizeProvider = FixedPreloadSizeProvider<Post>(400, 300)
private val preloadModelProvider = object : PreloadModelProvider<Post> {
override fun getPreloadItems(position: Int): List<Post> =
listOf(getItem(position))
override fun getPreloadRequestBuilder(item: Post): RequestBuilder<*>? =
Glide.with(context).load(item.imageUrl).override(400, 300)
}
fun getPreloader(): RecyclerViewPreloader<Post> = RecyclerViewPreloader(
Glide.with(context),
preloadModelProvider,
preloadSizeProvider,
3 // preload 3 items ahead
)
}
// Attach to RecyclerView:
recyclerView.addOnScrollListener(adapter.getPreloader())
Coil: Preloading in Compose
@Composable
fun FeedScreen(posts: List<Post>, imageLoader: ImageLoader) {
val listState = rememberLazyListState()
// Preload images for upcoming items
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { firstVisible ->
val preloadRange = firstVisible + 5..firstVisible + 15
preloadRange.forEach { index ->
posts.getOrNull(index)?.imageUrl?.let { url ->
val request = ImageRequest.Builder(LocalContext.current)
.data(url)
.size(400, 300)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
imageLoader.enqueue(request) // fire-and-forget preload
}
}
}
}
LazyColumn(state = listState) {
items(posts, key = { it.id }) { post ->
PostCard(post = post)
}
}
}
Video: ExoPlayer Pool for RecyclerView
Creating a new ExoPlayer per item is too expensive. Use a pool:
class ExoPlayerPool(context: Context, size: Int = 3) {
private val pool = ArrayDeque<ExoPlayer>(size)
init {
repeat(size) {
pool.add(ExoPlayer.Builder(context).build())
}
}
fun acquire(): ExoPlayer? = pool.removeFirstOrNull()
fun release(player: ExoPlayer) {
player.stop()
player.clearMediaItems()
pool.addLast(player)
}
}
// In ViewHolder:
class VideoViewHolder(
private val binding: ItemVideoBinding,
private val playerPool: ExoPlayerPool
) : RecyclerView.ViewHolder(binding.root) {
private var player: ExoPlayer? = null
fun bind(post: Post) {
player = playerPool.acquire()?.also { exoPlayer ->
binding.playerView.player = exoPlayer
exoPlayer.setMediaItem(MediaItem.fromUri(post.videoUrl))
exoPlayer.prepare()
exoPlayer.playWhenReady = false // autoplay only when visible
}
}
fun recycle() {
player?.let { playerPool.release(it) }
binding.playerView.player = null
player = null
}
}
Autoplay on Visibility
class FeedLayoutManager(context: Context) : LinearLayoutManager(context) {
var onItemVisibilityChanged: ((Int, Boolean) -> Unit)? = null
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
if (state == RecyclerView.SCROLL_STATE_IDLE) {
val firstVisible = findFirstCompletelyVisibleItemPosition()
val lastVisible = findLastCompletelyVisibleItemPosition()
// Notify which items are fully visible
(firstVisible..lastVisible).forEach { pos ->
onItemVisibilityChanged?.invoke(pos, true)
}
}
}
}
// When item is fully visible, start autoplay
layoutManager.onItemVisibilityChanged = { position, visible ->
val holder = recyclerView.findViewHolderForAdapterPosition(position) as? VideoViewHolder
holder?.setAutoplay(visible)
}
Memory: Bitmap Pool and Sampling
// Configure Glide's bitmap pool (avoids allocation on every decode)
Glide.with(context)
.load(imageUrl)
.format(DecodeFormat.PREFER_RGB_565) // 2 bytes/px vs 4 bytes for ARGB_8888
.override(Target.SIZE_ORIGINAL)
.diskCacheStrategy(DiskCacheStrategy.DATA) // cache compressed; decode on demand
.into(imageView)
// For large images: downsample to display size
fun decodeSampledBitmap(res: Resources, resId: Int, reqWidth: Int, reqHeight: Int): Bitmap {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeResource(res, resId, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeResource(res, resId, options)
}
Key Takeaways
| Technique | Impact |
|---|---|
| Preload 5–15 items ahead | Near-zero placeholder time on normal scroll speed |
| ExoPlayer pool (size 2–3) | Avoids per-item player creation; recycled on scroll |
| Autoplay on full visibility | Battery-friendly; only plays when user actually sees it |
RGB_565 format | Halves memory vs ARGB_8888; OK for photos without transparency |
diskCacheStrategy(DATA) | Cache compressed; decode at display size; trades CPU for memory |
| Stable keys | Prevents image flicker when list updates |