# 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ế.

- URL: https://dantech.academy/blog/android/graphicslayer-chong-recomposition-jetpack-compose
- Ngày đăng: 2026-04-12
- Cập nhật: 2026-06-23
- Thời gian đọc: 7 phút
- Tags: android, kotlin, jetpack-compose, performance

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](https://dantech.academy/static/images/compose-3-phases.svg)

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](https://dantech.academy/blog/android/android-mastery-app-memory-kien-thuc) 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:

| Property                              | Mô tả          |
| ------------------------------------- | -------------- |
| `scaleX`, `scaleY`                    | Thu phóng      |
| `alpha`                               | Độ mờ          |
| `translationX`, `translationY`        | Dịch chuyển    |
| `rotationX`, `rotationY`, `rotationZ` | Xoay 3D        |
| `shadowElevation`                     | Bóng đổ        |
| `clip`, `shape`                       | Crop 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:

```kotlin
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

```kotlin
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

|                       | Before       | After |
| --------------------- | ------------ | ----- |
| Recompositions/frame  | ~25+         | 0     |
| Animation states/item | 3 Animatable | 0     |
| Phase chạy effects    | Composition  | Draw  |
| Offscreen buffer      | Yes          | No    |

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à.

## Kỹ thuật bổ trợ

### 1. Callback chỉ khi scroll dừng

```kotlin
// 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](https://dantech.academy/blog/android/android-thread-kien-thuc-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

```kotlin
// 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

```kotlin
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 `graphicsLayer`                       | Dù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 paths                 | Prototype / 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
