Dan Tech Academy

graphicsLayer - Vũ khí chống Recomposition trong Jetpack Compose

Cách dùng graphicsLayer để đưa visual effects xuống Draw phase, loại bỏ hoàn toàn recomposition trong scroll-driven animations. Case study WheelPicker thực tế.

Dan TechDan TechSoftware Engineer, Weight Lifter7 phút đọcCập nhật 23/06/2026

Mobile Action Pro Course

Bài viết này dùng production code từ khóa Mobile Action Pro. Học Compose performance, Clean Architecture, và ship ứng dụng AI quiz app thực tế.

108 Bài học9 ModulesTrọn đời Truy cập
Xem khóa học

Compose chạy qua 3 phase: Composition → Layout → Draw. Phần lớn animation code mặc định chạy ở Composition phase - mỗi frame thay đổi đều trigger recomposition. graphicsLayer cho phép bạn đẩy visual effects xuống Draw phase, nơi thay đổi chỉ redraw mà không recompose hay relayout.

Bài viết này giải thích khi nào và cách dùng graphicsLayer để tối ưu performance, minh họa qua case study WheelPicker thực tế.

Compose 3 Phases và vấn đề Performance

Compose 3 Phases — animateXAsState vs graphicsLayer

Khi bạn dùng animateFloatAsState hay animateColorAsState, giá trị mới tạo ra State change - Compose phải chạy lại Composition + Layout + Draw. Với graphicsLayer, bạn chỉ tốn Draw.

Sự khác biệt này trở nên quan trọng trong scroll-driven animations, nơi state change xảy ra mỗi frame. Một LazyColumn với 5 visible items, mỗi item giữ 3 animation states, nghĩa là 15+ recomposition triggers mỗi frame - nhiều hơn rất nhiều so với những gì GPU thực sự cần.

graphicsLayer làm được gì?

Trong lambda graphicsLayer, bạn truy cập trực tiếp các transform properties:

PropertyMô tả
scaleX, scaleYThu phóng
alphaĐộ mờ
translationX, translationYDịch chuyển
rotationX, rotationY, rotationZXoay 3D
shadowElevationBóng đổ
clip, shapeCrop theo hình

Thay đổi bất kỳ property nào ở đây - chỉ redraw, không recompose.

Điểm mấu chốt: lambda version của graphicsLayer capture state reads vào draw time, không phải composition time. Đây chính là cách Compose biết nó có thể skip hoàn toàn hai phases đầu.

Case Study: WheelPicker - Từ 25+ xuống 0 Recompositions/frame

Vấn đề

Một time picker dạng scroll wheel - items ở center lớn và rõ, items xa center nhỏ và mờ dần. Code ban đầu:

var centerIndex by remember { mutableIntStateOf(initialIndex) }

// State change mỗi scroll frame → recompose toàn bộ LazyColumn
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .collectLatest { centerIndex = it }
}

items(items.size) { index ->
    val isCenter = centerIndex == index
    // 3 animation states per item × 5 visible items = 15 recomposition triggers/frame
    val fontSize by animateFloatAsState(if (isCenter) 32f else 15f)
    val fontWeight by animateFloatAsState(if (isCenter) 700f else 350f)
    val color by animateColorAsState(if (isCenter) Purple600 else Grey400)
}

Chi phí mỗi scroll frame: ~25+ recompositions - centerIndex state change cascade + 15 Animatable triggers + offscreen compositing buffer.

Giải pháp: Chuyển hết vào graphicsLayer

items(items.size, key = { it }, contentType = { 0 }) { index ->
    Box(
        Modifier.height(itemHeightDp).graphicsLayer {
            // Tính khoảng cách item so với center viewport
            val info = listState.layoutInfo
            val viewportCenter =
                info.viewportStartOffset + info.viewportSize.height / 2f
            val itemCenter = info.visibleItemsInfo
                .firstOrNull { it.index == index + halfVisible }
                ?.let { it.offset + it.size / 2f } ?: viewportCenter

            val distance =
                (abs(itemCenter - viewportCenter) / itemHeightPx).coerceIn(0f, 2f)
            val eased = (distance / 2f).let { it * it }  // quadratic easing

            // Tất cả visual effects - zero recomposition
            scaleX = lerp(1f, 0.3f, eased)
            scaleY = scaleX
            alpha = scaleX
            rotationX =
                ((itemCenter - viewportCenter) / itemHeightPx)
                    .coerceIn(-2f, 2f) * -18f
            translationY =
                ((itemCenter - viewportCenter) / itemHeightPx)
                    .coerceIn(-2f, 2f) * 2f
        }
    ) { Text(items[index], style = baseTextStyle) }
}

Kết quả: 0 recompositions per scroll frame. Scale, alpha, rotation, translation - tất cả tính từ scroll offset trong draw phase.

So sánh

BeforeAfter
Recompositions/frame~25+0
Animation states/item3 Animatable0
Phase chạy effectsCompositionDraw
Offscreen bufferYesNo

Trên thiết bị mid-range, đây là sự khác biệt giữa jank thấy rõ và scroll mượt mà.

Mobile Action Pro Course

WheelPicker này nằm trong project AI quiz app thực tế của khóa Mobile Action Pro - nơi bạn học Compose performance patterns bằng cách ship production code.

108 Bài học9 ModulesTrọn đời Truy cập
Xem nội dung khóa học

Kỹ thuật bổ trợ

1. Callback chỉ khi scroll dừng

// TRƯỚC: fire mỗi frame
snapshotFlow { listState.firstVisibleItemIndex }
    .collectLatest { centerIndex = it }

// SAU: fire 1 lần khi settle
snapshotFlow { listState.isScrollInProgress }
    .distinctUntilChanged()
    .collectLatest {
        if (!it) onSelectedChanged(listState.firstVisibleItemIndex)
    }

Pattern này kết hợp tốt với Kotlin Coroutines - snapshotFlow + distinctUntilChanged rẻ hơn rất nhiều so với state updates mỗi frame.

2. Gradient overlay thay offscreen compositing

// TRƯỚC: tốn buffer allocation + blend mode
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent { drawRect(blendMode = BlendMode.DstIn) }

// SAU: gradient vẽ on-top, không cần buffer
Box(Modifier.matchParentSize().drawWithContent {
    drawContent()
    drawRect(
        Brush.verticalGradient(listOf(White, Transparent)),
        endY = fadeRatio
    )
    drawRect(
        Brush.verticalGradient(listOf(Transparent, White)),
        startY = size.height - fadeRatio,
        endY = size.height
    )
})

Offscreen compositing phải allocate một bitmap buffer riêng - tốn kém trên thiết bị hạn chế memory. Gradient overlay tạo visual effect tương tự mà không cần allocation đó.

3. LazyColumn recycling hints

items(count, key = { it }, contentType = { 0 })

contentType giúp LazyColumn reuse composition slots hiệu quả hơn khi tất cả items cùng type. Kết hợp với stable key, giảm thiểu composition work mà LazyColumn phải làm khi scroll.

Khi nào dùng graphicsLayer vs animateXAsState?

Dùng graphicsLayerDùng animateXAsState
Scroll-driven effects (LazyColumn, Pager)One-shot transitions (click, toggle)
Nhiều items cùng animate mỗi frameÍt items, ít tần suất
Chỉ cần transform (scale, alpha, rotation)Cần thay đổi content (fontSize, color, text)
Performance-critical pathsPrototype / low-item-count UI

graphicsLayer chỉ hỗ trợ transform properties - không thay đổi được text size hay color thực tế. Nhưng với scroll animations, scale + alpha thường tạo visual effect tương đương mà zero cost.

Quyết định khá rõ ràng: nếu animation của bạn chạy từ giá trị liên tục thay đổi (scroll offset, drag position, sensor data), graphicsLayer gần như luôn là lựa chọn đúng. Với discrete state transitions, animateXAsState cho ergonomics tốt hơn với performance chấp nhận được.

Tóm lại

  1. graphicsLayer chạy ở Draw phase - skip Composition + Layout hoàn toàn
  2. Tính visual effects từ scroll offset trong lambda, không qua State
  3. 3D effects miễn phí - rotationX, translationY cùng phase, thêm không tốn gì
  4. Kết hợp gradient overlay để tránh offscreen compositing overhead
  5. Callback settle-only - không fire state change mỗi scroll frame

Course

Thực hành Jetpack Compose Performance ngay

Khóa Mobile Action Pro dạy graphicsLayer, tối ưu LazyColumn, và các Compose patterns thực chiến - qua project AI quiz app nơi các kỹ thuật này được áp dụng.

  • Jetpack Compose với performance best practices
  • Clean Architecture, MVVM, Dependency Injection
  • 9 modules, 108 bài thực hành
  • Truy cập trọn đời & hỗ trợ cộng đồng
Explore Mobile Action Pro

Đọc blog thì vui. Đi theo lộ trình thì đến đích.

Kotlin Android Roadmap sắp xếp các bài Android Mastery, OOP và Design Patterns thành 5 cấp độ - từ dòng Kotlin đầu tiên đến app lên store.

Kèm bài viết mới mỗi tuần. Huỷ bất cứ lúc nào.