在 Jetpack Compose 中做出 CRT 屏幕效果:从 GraphicsLayer 到 CRTBox 的完整拆解

Jasper
Jasper
发布时间:2026-08-24| 75人浏览过
原创

前言

CRT 屏幕的味道不只是一层扫描线,真正决定观感的是模糊外溢、亮度裁切和轻微抖动怎么叠在一起。本文按实现顺序拆解这套 Jetpack Compose 做法,从 GraphicsLayer 录制内容开始,到扫描线、模糊层和随机位移的组合,最后整理成一个能直接复用的 CRTBox,方便你判断这类效果适不适合放进自己的界面里。

CRT 风格在游戏界面、复古主题和氛围化动效里一直很常见,但真要在 Jetpack Compose 里做出“像样”的老电视质感,难点并不在单个 API,而在几层效果怎么叠得自然。下面按实现顺序拆开这套做法:先把内容录制到 GraphicsLayer,再分别加上扫描线、模糊发光和轻微抖动,最后整理成一个可复用的 CRTBox 组件,方便直接套在任意内容外层。

用 GraphicsLayer 作为效果底板

这套 CRT 效果的基础仍然是 GraphicsLayer。它的关键作用,是先把内容绘制到屏幕外缓冲区,再把这份结果重复拿出来做后续处理。这样不需要每加一层效果就把原内容重新绘制一遍,性能压力会小很多。

先看最基础的录制方式:

val graphicsLayer = rememberGraphicsLayer()

Box(Modifier.drawWithContent {
    graphicsLayer.record { 
        this@drawWithContent.drawContent() 
    }
}) {
    content()
}

这里的思路很直接:在 drawWithContent 中先把当前内容记录到 graphicsLayer,后面就可以通过 drawLayer(graphicsLayer) 随时把它重新绘制出来。对 CRT 这种需要同一份画面参与多次叠加的效果来说,这一步非常关键。

原文建议选择黑色背景搭配高饱和内容来做测试,这样扫描线、发光和颜色溢出的视觉特征会更明显,也更接近老式 CRT 显示器的观感。

扫描线怎么画出来

CRT 最容易被识别出来的特征之一,就是规则分布的横向扫描线。这里没有使用贴图,而是直接用重复渐变生成,这样更轻量,也更方便后续调参数。

展示扫描线渐变参数如何形成重复暗纹的结构信息图。
扫描线的渐变与混合方式扫描线的关键不是图片素材,而是颜色停止点、重复模式和混合方式的组合。

原实现把这部分封装成了一个扩展函数:

private fun DrawScope.drawScanLines(alpha: Float, blendMode: BlendMode) {
    val color = Colors.Black.copy(alpha = alpha)
    drawRect(
        brush = Brush.verticalGradient(
            0f to color,
            0.4f to color,
            0.4f to Colors.Transparent,
            1f to Colors.Transparent,
            tileMode = TileMode.Repeated,
            startY = 0f,
            endY = 10f,
        ),
        blendMode = blendMode
    )
    drawRect(
        brush = Brush.horizontalGradient(
            0f to color,
            0.1f to color,
            0.1f to Colors.Transparent,
            1f to Colors.Transparent,
            tileMode = TileMode.Repeated,
            startX = 0f,
            endX = 10f,
        ),
        blendMode = blendMode
    )
}

这里有两个值得注意的点。

为什么要手动写颜色停止点

因为扫描线需要“清晰的断层”,而不是柔和过渡。像 0f 到 0.4f 保持纯色、0.4f 之后立刻切到透明,能让线条边缘更硬,视觉上更像显示器像素行之间的亮度差。

展示 GraphicsLayer 录制内容后,如何被扫描线和多层模糊重复复用的流程信息图。
CRT 图层叠加流程先录制原始内容,再将同一份图层反复叠加,是这套 CRT 效果的核心结构。

为什么要用 Repeated

tileMode = TileMode.Repeated 配合很短的 startY/endY 或 startX/endX,就能让一小段渐变在整个区域不断重复,形成密集平行线。文中的纵向渐变负责主要的横向扫描线,横向渐变则补充一点细网格感,让画面不至于太平。

这个函数还暴露了两个参数:alpha 用来调强度,blendMode 用来决定它如何和底层内容混合。应用时写法如下:

.drawBehind {
    layer {
        drawLayer(graphicsLayer)
        drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)
    }
}

这里的重点是 BlendMode.DstOut。它不是简单把黑线盖在内容上,而是从已经绘制出来的图层里“扣掉”一部分亮度,因此结果更像扫描线在发光画面上留下的暗纹,而不是一层生硬蒙版。

多层模糊如何做出发光感

只有扫描线还不够,CRT 画面的另一层辨识度来自轻微外溢和发光。Compose 里比较实用的办法,是把同一个 graphicsLayer 重复绘制多次,每一层设置不同的模糊半径、透明度和缩放比例。

原文先用一个列表描述这些层:

val blurLayers = remember {
    listOf(
        Triple(1.dp, 0.2f, 1.02f to 1.03f),
        Triple(0.dp, .2f, 1f to 1f),
        Triple(1.dp, 0.9f, 1f to 1f),
        Triple(10.dp, 1f, 1f to 1f),
        Triple(40.dp, 1f, 1f to 1f),
    )
}

每个 Triple 分别表示:

  • 模糊半径
  • 图层透明度
  • scaleX / scaleY 缩放值

这个列表除了存参数,也顺便定义了绘制顺序。前后顺序不同,最终光晕的层次也会明显变化,所以这部分非常值得自己试参数。

接着,按列表依次渲染各层:

blurLayers.forEach { (blur, alpha, scale) ->  
    Box(  
        Modifier  
            .matchParentSize()  
            .blur(blur, BlurredEdgeTreatment.Unbounded)  
            .graphicsLayer {  
                scaleX = scale.first  
                scaleY = scale.second  
                this.alpha = alpha  
            }  
            .drawBehind {  
                layer {  
                    drawLayer(graphicsLayer)  
                    drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)  
                }  
            }    
    )  
}

这段代码里有几处实现细节决定了最终效果是否自然。

为什么先 matchParentSize

因为每一层都要完整覆盖原内容区域,否则发光边缘会不齐,特别是在文本或高对比图形周围更容易露馅。

为什么 blur 要用 Unbounded

blur(blur, BlurredEdgeTreatment.Unbounded) 很关键。默认情况下,模糊可能被当前可组合项边界裁切,发光只能停留在内容内部,看起来会很“假”。改成 Unbounded 后,模糊才允许溢出边界,接近真实屏幕的外扩亮斑。

缩放和透明度分别解决什么问题

轻微放大某些模糊层,可以制造边缘偏移和颜色外扩的观感;透明度则用来控制不同层对最终亮度的贡献。比如小半径、较高透明度的图层更接近主体轮廓,大半径、较低存在感的图层则负责补足外围光晕。

原作者也明确提到,这组值并不是固定答案,而是当前使用的一套经验配置。换不同字体、不同底色、不同 UI 密度时,都可能需要重调。

最后一层:加入轻微屏幕抖动

老式 CRT 屏幕除了静态纹理,还会带一点不稳定感。这里用一个很轻的随机位移来模拟抖动,不追求夸张动画,而是让画面有一种细小的“颤动”。

实现方式如下:

var shake by remember { mutableStateOf(Offset.Zero) }

LaunchedEffect(Unit) {
    while (true) {
        shake = Offset(
            Random.nextInt(-1, 1) * Random.nextFloat(),
            Random.nextInt(-1, 1) * Random.nextFloat(),
        )
        delay(32)
    }
}

思路并不复杂:维护一个 Offset 状态,在协程里不断刷新,X 和 Y 都从 -1 到 1 的范围内取随机方向,再乘以一个随机浮点数,得到很小的位移量。

delay(32) 决定了闪烁或抖动的更新频率。这个值越小,刷新越频繁,抖动感越明显;调大之后则更接近偶发闪动。

把这个偏移量挂到容器上即可:

modifier = modifier  
    .graphicsLayer {  
        translationX = shake.x  
        translationY = shake.y  
    }

这种做法的好处是,它作用在整个 CRT 容器最外层,里面的扫描线和模糊层会一起移动,不会出现效果层和内容本体不同步的问题。

整合成可复用的 CRTBox 组件

前面几步拆开看都不复杂,但实际项目里更适合把它们包进一个可复用组件。这样以后不管是文本、图标,还是一整块游戏 UI,都可以直接套上 CRT 风格。

展示 CRTBox 组件中抖动循环、参数入口和最终调用方式的结构信息图。
CRTBox 组件结构整合后的 CRTBox 把抖动、图层录制和效果叠加收拢到一个统一入口。

下面是整合后的实现:

@Composable  
fun CRTBox(  
    modifier: Modifier = Modifier,  
    flickerDelay: Int = 32,  
    content: @Composable () -> Unit,  
) {  
    var shake by remember { mutableStateOf(Offset.Zero) }  
  
    LaunchedEffect(Unit) {  
        while (flickerDelay > 0) {  
            shake = Offset(  
                Random.nextInt(-1, 1) * Random.nextFloat(),  
                Random.nextInt(-1, 1) * Random.nextFloat(),  
            )  
            delay(flickerDelay.toLong())  
        }  
    }  
  
    val graphicsLayer = rememberGraphicsLayer()  
  
    Box(  
        modifier = modifier  
            .graphicsLayer {  
                translationX = shake.x  
                translationY = shake.y  
            }  
    ) {  
        Box(Modifier.drawWithContent {  
            graphicsLayer.record { this@drawWithContent.drawContent() }  
        }) {  
            content()  
        }  
  
        val blurLayers = remember {  
            listOf(  
                Triple(5.dp, .3f, 1.02f to 1.03f),  
                Triple(0.dp, .8f, 1f to 1f),  
                Triple(1.dp, .9f, 1f to 1f),  
                Triple(10.dp, .6f, 1.001f to 1f),  
                Triple(40.dp, .7f, 1f to 1f),  
            )  
        }  
  
        blurLayers.forEach { (blur, alpha, scale) ->  
            Box(  
                Modifier  
                    .matchParentSize()  
                    .blur(blur, BlurredEdgeTreatment.Unbounded)  
                    .graphicsLayer {  
                        scaleX = scale.first  
                        scaleY = scale.second  
                        this.alpha = alpha  
                    }  
                    .drawBehind {  
                        layer {  
                            drawLayer(graphicsLayer)  
                            drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)  
                        }  
                    }            
            )  
        }  
    }}  
  
private fun DrawScope.layer(  
    bounds: Rect = size.toRect(),  
    block: DrawScope.() -> Unit  
) =  
    drawIntoCanvas { canvas ->  
        canvas.withSa veLayer(  
            bounds = bounds,  
            paint = Paint(),  
        ) { block() }  
    }  
  
private fun DrawScope.drawScanLines(alpha: Float, blendMode: BlendMode) {  
    val color = Colors.Black.copy(alpha = alpha)  
    drawRect(  
        brush = Brush.verticalGradient(  
            0f to color,  
            0.4f to color,  
            0.4f to Colors.Transparent,  
            1f to Colors.Transparent,  
            tileMode = TileMode.Repeated,  
            startY = 0f,  
            endY = 10f,  
        ),  
        blendMode = blendMode  
    )  
    drawRect(  
        brush = Brush.horizontalGradient(  
            0f to color,  
            0.1f to color,  
            0.1f to Colors.Transparent,  
            1f to Colors.Transparent,  
            tileMode = TileMode.Repeated,  
            startX = 0f,  
            endX = 10f,  
        ),  
        blendMode = blendMode  
    )  
}

这个版本对外暴露了两个主要入口:

  • content:要应用 CRT 效果的实际内容
  • flickerDelay:控制抖动或闪烁的更新频率,默认值是 32

和前面的分步示例相比,整合版本里的模糊层参数也做了调整,比如第一层改成了 Triple(5.dp, .3f, 1.02f to 1.03f),其余层的透明度和缩放值也有细化。这说明作者在最终组件里更关注整体观感,而不是逐段示例时的单点演示。

需要留意的一点是,LaunchedEffect 中使用的是:

while (flickerDelay > 0) {
    ...
    delay(flickerDelay.toLong())
}

也就是说,当 flickerDelay 小于等于 0 时,抖动逻辑就不会继续运行,这样也等于给组件保留了一个“关闭闪烁”的入口。

使用方式则和普通可组合项一样简单:

CRTBox {
    Text("GAME OVER")
}

如果你的内容本身就是高对比、亮色、像素风或游戏风格,这个组件会很容易出效果;如果用于日常表单、浅色背景信息流界面,视觉收益通常就没那么高。

适合用在哪些场景

从文章给出的示例来看,这类效果更适合复古游戏界面、节日主题页面、恐怖风格 HUD、像素风弹窗或特殊状态提示,而不是通用业务 UI。原因很简单:扫描线、光晕和抖动都会降低文字锐度,如果整个应用大面积使用,阅读负担会明显上升。

作者也提到,实际效果已经被用在 Sweeper 的最新更新中,做成了偏万圣节气氛的 CRT 风格界面。对应应用页面如下:

如果你准备把这套方案放进自己的项目,比较实际的调参顺序通常是:

  1. 先确认原始内容在黑底或深色底上是否足够亮、足够有对比。
  2. 再调扫描线密度和透明度,避免画面一上来就过暗。
  3. 然后逐层修改 blurLayers 的半径与透明度,找到最适合当前内容的发光范围。
  4. 最后再决定是否需要 flickerDelay,以及闪烁频率应该轻到什么程度。

如果目标是“复古味道”,那么适度通常比堆满效果更重要。Compose 给了足够灵活的图层能力,但最终是否像 CRT,往往取决于你愿意为具体内容反复试多少组参数。

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。