androidengineers.Book a session

Designing a News Feed/Social App

Media Feed Perf & Caching

article20 minHard

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

TechniqueImpact
Preload 5–15 items aheadNear-zero placeholder time on normal scroll speed
ExoPlayer pool (size 2–3)Avoids per-item player creation; recycled on scroll
Autoplay on full visibilityBattery-friendly; only plays when user actually sees it
RGB_565 formatHalves memory vs ARGB_8888; OK for photos without transparency
diskCacheStrategy(DATA)Cache compressed; decode at display size; trades CPU for memory
Stable keysPrevents image flicker when list updates

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Media Feed Perf & Caching | Android System Design | Android Engineers