Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Optimizing PDF Rendering Performance and Memory Usage in Jetpack Compose

Tech 1

Rendering large PDF files in Android applications often leads to performance bottlenecks and excessive memory consumption. For instance, a 200MB PDF can cause rendering delays and memory usage spikes up to 6GB. The root cause is that all pages are rendered upon opening, with Bitmaps retained in memory even when not displayed.

A key limitation is that Android's PdfRenderer class does not support concurrent page rendering, preventing parallelization to reduce load times. Additionally, UI freezes occur until all pages are loaded. To address these issues, asynchronous rendering with coroutines and state management can be implemented.

Using StateFlow allows for asynchronous updates in Compose. Transform the pageContent field from a Bitmap to a MutableStateFlow<Bitmap?>, enabling the UI to update only when a Bitmap is available.

@Composable
fun PageImage(page: PdfPage) {
    val bitmapState = page.pageContent.collectAsState()
    bitmapState.value?.asImageBitmap()?.let { imageBitmap ->
        Image(
            bitmap = imageBitmap,
            contentDescription = "PDF page"
        )
    }
}

To manage rendering order, use a Mutex to ensure only one page renders at a time within a coroutine.

class PdfPage(
    private val pageIndex: Int,
    private val renderer: PdfRenderer,
    private val scope: CoroutineScope,
    private val lock: Mutex
) {
    private var rendered = false
    private var renderJob: Job? = null
    val pageContent = MutableStateFlow<Bitmap?>(null)

    fun renderPage() {
        if (!rendered) {
            renderJob = scope.launch {
                lock.withLock {
                    val pageBitmap: Bitmap
                    renderer.openPage(pageIndex).use { page ->
                        pageBitmap = Bitmap.createBitmap(
                            page.width,
                            page.height,
                            Bitmap.Config.ARGB_8888
                        )
                        page.render(
                            pageBitmap,
                            null,
                            null,
                            PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
                        )
                    }
                    rendered = true
                    pageContent.emit(pageBitmap)
                }
            }
        }
    }

    fun clear() {
        rendered = false
        val existingBitmap = pageContent.value
        pageContent.tryEmit(null)
        existingBitmap?.recycle()
    }
}

In the Compose UI, trigger rendering only for visible pages using LaunchedEffect or DisposableEffect to avoid loading all pages simultaneously.

@Composable
fun PdfViewer(pdfFile: File) {
    val pdfHandler = remember {
        PdfHandler(
            ParcelFileDescriptor.open(
                pdfFile,
                ParcelFileDescriptor.MODE_READ_ONLY
            )
        )
    }
    DisposableEffect(Unit) {
        onDispose { pdfHandler.cleanup() }
    }
    LazyColumn {
        items(pdfHandler.totalPages) { idx ->
            val currentPage = pdfHandler.pages[idx]
            DisposableEffect(Unit) {
                currentPage.renderPage()
                onDispose { currentPage.clear() }
            }
            PageImage(currentPage)
        }
    }
}

To further optimize memory, pre-calculate page dimensions during initialization, allowing LazyColumn to allocate proper height without triggering renders for off-screen items.

class PdfPage(
    private val pageIndex: Int,
    private val renderer: PdfRenderer,
    private val scope: CoroutineScope,
    private val lock: Mutex
) {
    val dimensions = renderer.openPage(pageIndex).use { page ->
        PageSize(page.width, page.height)
    }
    fun computeHeight(targetWidth: Int): Int {
        val aspectRatio = dimensions.width.toFloat() / dimensions.height
        return (targetWidth / aspectRatio).toInt()
    }
    // Other fields and methods as above
}

In the UI, use BoxWithConstraints to set placeholder sizes based on computed dimensions, preventing unnecessary randers.

@Composable
fun PdfViewer(pdfFile: File) {
    val pdfHandler = remember { PdfHandler(/* file descriptor */) }
    DisposableEffect(Unit) { onDispose { pdfHandler.cleanup() } }
    LazyColumn {
        items(pdfHandler.totalPages) { idx ->
            val currentPage = pdfHandler.pages[idx]
            BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
                DisposableEffect(Unit) {
                    currentPage.renderPage()
                    onDispose { currentPage.clear() }
                }
                val bitmapState = currentPage.pageContent.collectAsState()
                if (bitmapState.value != null) {
                    Image(
                        bitmap = bitmapState.value!!.asImageBitmap(),
                        contentDescription = "Page ${idx + 1}",
                        modifier = Modifier.fillMaxWidth(),
                        contentScale = ContentScale.FillWidth
                    )
                } else {
                    Box(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(currentPage.computeHeight(constraints.maxWidth).pxToDp())
                    )
                }
            }
        }
    }
}

This approach reduces memory usage from over 6GB to around 200MB for large PDFs, with minimal impact on load times. Memory peaks during scrolling are mitigated by recycling Bitmaps when pages exit the composition.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.