Displaying PDF Files in Jetpack Compose with Performance Optimization
Introduction
Displaying PDF files in Android applications using Jetpack Compose presents unique challenges, particularly when dealing with large documents. This article explores a comprehensive approach to rendering PDFs efficiently while managing memory consumption and user interface responsiveness.
Basic PDF Rendering Implementation
The foundation of PDF display in Android involves using the PdfRenderer class. Here's a basic implementation:
@Composable
fun PDFViewer(file: File) {
val pdfDescriptor = ParcelFileDescriptor.open(
file,
ParcelFileDescriptor.MODE_READ_ONLY
)
val pdfRenderer = PdfRenderer(pdfDescriptor)
LazyColumn {
items(pdfRenderer.pageCount) { pageIndex ->
val pageBitmap = renderPage(pdfRenderer, pageIndex)
Image(
bitmap = pageBitmap.asImageBitmap(),
contentDescription = "PDF page ${pageIndex + 1}"
)
}
}
}
private fun renderPage(
renderer: PdfRenderer,
pageIndex: Int
): Bitmap {
return renderer.openPage(pageIndex).use { page ->
val bitmap = Bitmap.createBitmap(
page.width,
page.height,
Bitmap.Config.ARGB_8888
)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
bitmap
}
}
This straightforward approach loads all pages simultaneously, which works for small PDFs but becomes problematic with larger documents.
Performance Issues with Basic Implementation
When testing with a 200MB PDF file, significant performance issues emerge:
- Long loading times: All pages render at startup
- High memory consumption: All bitmaps remain in memory
- UI freezing: Interface remains unresponsive until all pages load

Memory consumption can reach nearly 6GB for a PDF viewer application, which is unacceptable for production use.
Asynchronous Rendering with Coroutines
To address UI freezing and improve loading times, we can implement asynchronous rendering using Kotlin coroutines:
class PDFRendererManager(
private val fileDescriptor: ParcelFileDescriptor
) {
private val pdfRenderer = PdfRenderer(fileDescriptor)
private val renderingMutex = Mutex()
private val renderingScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val totalPages get() = pdfRenderer.pageCount
private val pageCache = List(totalPages) { pageIndex ->
PDFPage(
pageIndex = pageIndex,
pdfRenderer = pdfRenderer,
coroutineScope = renderingScope,
renderingLock = renderingMutex
)
}
fun getPage(index: Int): PDFPage = pageCache[index]
fun cleanup() {
pageCache.forEach { it.releaseResources() }
pdfRenderer.close()
fileDescriptor.close()
}
}
class PDFPage(
private val pageIndex: Int,
private val pdfRenderer: PdfRenderer,
private val coroutineScope: CoroutineScope,
private val renderingLock: Mutex
) {
private var isRendered = false
private var renderingJob: Job? = null
val renderedContent = MutableStateFlow<Bitmap?>(null)
fun renderPage() {
if (!isRendered) {
renderingJob = coroutineScope.launch {
renderingLock.withLock {
val pageBitmap = pdfRenderer.openPage(pageIndex).use { page ->
val bitmap = Bitmap.createBitmap(
page.width,
page.height,
Bitmap.Config.ARGB_8888
)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
bitmap
}
isRendered = true
renderedContent.emit(pageBitmap)
}
}
}
}
fun releaseResources() {
isRendered = false
renderedContent.value?.recycle()
renderedContent.tryEmit(null)
}
}
Optimized Compose Implementation
Here's how to integrate the asynchronous rendering with Jetpack Compose:
@Composable
fun PDFDocumentViewer(documentFile: File) {
val pdfManager = remember {
PDFRendererManager(
ParcelFileDescriptor.open(
documentFile,
ParcelFileDescriptor.MODE_READ_ONLY
)
)
}
DisposableEffect(Unit) {
onDispose { pdfManager.cleanup() }
}
LazyColumn {
items(pdfManager.totalPages) { pageIndex ->
val currentPage = pdfManager.getPage(pageIndex)
LaunchedEffect(Unit) {
currentPage.renderPage()
}
DisposableEffect(Unit) {
onDispose { currentPage.releaseResources() }
}
val pageBitmap by currentPage.renderedContent.collectAsState()
pageBitmap?.asImageBitmap()?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = "PDF page ${pageIndex + 1}",
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
Memory Optimization with Page Dimensions
To further optimize memory usage, we can pre-calculate page dimentions and only render visible pages:
class OptimizedPDFPage(
private val pageIndex: Int,
private val pdfRenderer: PdfRenderer,
private val coroutineScope: CoroutineScope,
private val renderingLock: Mutex
) {
private var isRendered = false
private var renderingJob: Job? = null
val pageDimensions = pdfRenderer.openPage(pageIndex).use { page ->
PageSize(width = page.width, height = page.height)
}
val renderedContent = MutableStateFlow<Bitmap?>(null)
fun calculateHeightForWidth(targetWidth: Int): Int {
val aspectRatio = pageDimensions.width.toFloat() / pageDimensions.height
return (targetWidth / aspectRatio).toInt()
}
fun renderPage() {
if (!isRendered) {
renderingJob = coroutineScope.launch {
renderingLock.withLock {
val pageBitmap = pdfRenderer.openPage(pageIndex).use { page ->
val bitmap = Bitmap.createBitmap(
page.width,
page.height,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
bitmap
}
isRendered = true
renderedContent.emit(pageBitmap)
}
}
}
}
fun releaseResources() {
isRendered = false
renderedContent.value?.recycle()
renderedContent.tryEmit(null)
}
}
data class PageSize(val width: Int, val height: Int)
Final Optimized Implementation
Combining all optimizations, here's the complete solution:
@Composable
fun OptimizedPDFViewer(documentFile: File) {
val pdfManager = remember {
PDFRendererManager(
ParcelFileDescriptor.open(
documentFile,
ParcelFileDescriptor.MODE_READ_ONLY
)
)
}
DisposableEffect(Unit) {
onDispose { pdfManager.cleanup() }
}
LazyColumn {
items(pdfManager.totalPages) { pageIndex ->
val currentPage = pdfManager.getPage(pageIndex)
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val pageHeight = currentPage.calculateHeightForWidth(maxWidth.roundToPx())
LaunchedEffect(Unit) {
currentPage.renderPage()
}
DisposableEffect(Unit) {
onDispose { currentPage.releaseResources() }
}
val pageBitmap by currentPage.renderedContent.collectAsState()
pageBitmap?.asImageBitmap()?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = "PDF page ${pageIndex + 1}",
modifier = Modifier
.fillMaxWidth()
.height(pageHeight.dp)
)
} ?: Box(
modifier = Modifier
.fillMaxWidth()
.height(pageHeight.dp)
.background(Color.LightGray)
)
}
}
}
}
Performance Results
After implementing these optimizations:
- Significantly reduced loading time: Pages render only when needed
- Controlled memory usage: Only visible pages remain in memory
- Responsive UI: No freezing during PDF navigation

Memory consumption remains stable even with large PDF documents.

Conclusion
Displaying PDF files in Jetpack Compose requires careful consideration of performance and memory management. By implementing asynchronous rendering with coroutines, using StateFlow for reactive updates, and optimizing page loading based on visibility, you can create a smooth PDF viewing experience even with large documents. The key strategies include:
- Lazy loading: Render pages only when they become visible
- Memory management: Properly recycle bitmaps when pages leave the screen
- Asynchronous operations: Use coroutines to prevent UI freezing
- Dimension pre-calculation: Determine page sizes before rendering to optimize layout
This approach ensures that your PDF viewer remains responsive and memory-efficient regardless of document size.