深入 Android AGSL RuntimeShader 全链路:从 Skia 着色器编译到 Compose 自定义图形特效
去年做 Compose 迁移时,我需要在列表里实现毛玻璃模糊的 Header。Modifier.blur() 能用,但没法和半透明遮罩叠加——它是对整个图层的操作,粒度太粗。当时的思路:能不能自己写一个 Shader,精确控制每个像素的混合?
Android 13 的 AGSL(Android Graphics Shading Language) 和 RuntimeShader 就是干这个的。和传统的 GLSL 不同,AGSL 不依赖 GLSurfaceView,可以直接嵌入 Canvas 渲染管线,在 View 体系和 Compose 里都能用。
但深入用起来我发现,理解它的关键不在语法——AGSL 和 GLSL 八分像——而在于:这段代码经过了怎样的编译链路,最终跑在 GPU 上? 这个链路决定了你能做什么、性能瓶颈在哪。
AGSL → SkSL:编译链路的第一环
入口代码很简单:
val shader = RuntimeShader("""
uniform float2 resolution;
half4 main(float2 coord) {
float2 uv = coord / resolution;
return half4(uv.x, uv.y, 0.5, 1.0);
}
""")
shader.setFloatUniform("resolution", width.toFloat(), height.toFloat())
传入一段 AGSL 字符串,Android 运行时编译。但 AGSL 不是直接送给 GPU 的。
实际编译路径:AGSL → SkSL(Skia Shading Language)→ 平台后端代码。Skia 是 Android 2D 渲染引擎,内部用 SkSL 做中间表示。AGSL 本质是 SkSL 的超集,加上了 Android 特有的 uniform shader 类型和一些语义约束。
踩过的一个坑:texture() 采样函数在 AGSL 中坐标是 [0,1] 归一化的,和 GLSL 一致,但某些 Skia 版本对越界采样的 clamp 策略不同——纹理边缘会出黑线。排查方向不是改采样坐标,而是检查 Skia 的 tile mode 是否被正确传递到 Shader 的 sampler 对象上。
uniform shader:区别于普通 Shader 的核心
AGSL 最特别的设计是 uniform shader——你可以把 BitmapShader、LinearGradient、甚至另一个 RuntimeShader 作为 uniform 传入,然后在 Shader 里调用 .eval() 采样:
val imageShader = ImageShader(bitmap)
val blurShader = RuntimeShader("""
uniform shader content;
uniform float2 resolution;
float gauss(float x, float sigma) {
return exp(-(x*x)/(2.0*sigma*sigma)) / (2.506628*sigma);
}
half4 main(float2 coord) {
half4 color = half4(0.0);
for (int i = -3; i <= 3; i++) {
float w = gauss(float(i), 1.5);
color += content.eval(coord + float2(0, float(i)*4.0)) * w;
}
return color;
}
""")
blurShader.setInputShader("content", imageShader)
GLSL 只能采样纹理,不能嵌套调用 Shader 对象。content.eval() 让 AGSL 可以把已有的 Android 绘制对象直接当作着色器输入——这个能力在实际项目中非常实用。整条链路是:Compose 绘制 → RenderNode → Skia Picture → AGSL Shader 后处理 → GPU 渲染。
Compose 中的两种集成方式
Compose 里使用 RuntimeShader 分两个场景,执行路径不同。
场景一:作为 Paint 的 Shader 绘制内容
Canvas(modifier = Modifier.fillMaxSize()) {
shader.setFloatUniform("time", currentTime)
drawRect(brush = ShaderBrush(shader), size = size)
}
这替换掉了 Paint 的默认着色器,适合生成型效果:渐变、噪声、程序化图案。
场景二:作为 RenderEffect 做后处理
Image(
bitmap = imageBitmap,
modifier = Modifier.graphicsLayer {
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "content")
.asComposeRenderEffect()
}
)
这里 Shader 在 RenderNode 层级执行,对已绘制内容叠加一个额外的 Pass。有个细节容易被忽略:在 LazyColumn 里对 Item 套 graphicsLayer { renderEffect } 不会有额外开销——Compose 只为可见的 Item 维护 RenderNode,不可见的 Item 不会被提交到 GPU。
一个完整案例:呼吸光晕
把以上几点串起来,做一个用 uniform 驱动动画的动态效果:
@Composable
fun BreathingGlow(modifier: Modifier = Modifier) {
val shader = remember {
RuntimeShader("""
uniform float2 resolution;
uniform float time;
half4 main(float2 coord) {
float2 uv = coord / resolution;
float dist = distance(uv, float2(0.5));
float glow = 0.02 / (dist + 0.02);
float pulse = 1.0 + 0.3 * sin(time * 2.0);
return half4(0.2, 0.6, 1.0, glow * pulse);
}
""")
}
val time by rememberInfiniteTransition().animateFloat(
initialValue = 0f, targetValue = 6.28f,
animationSpec = infiniteRepeatable(tween(2000))
)
Canvas(modifier = modifier.fillMaxSize()) {
shader.setFloatUniform("resolution", size.width, size.height)
shader.setFloatUniform("time", time)
drawRect(brush = ShaderBrush(shader), size = size)
}
}
两个关键点:remember 确保重组时不重新编译 Shader;rememberInfiniteTransition 让动画循环不创建额外协程。
性能方面,每个像素在 GPU 上并行计算。Pixel 6 上实测,单 Shader 的 GPU 占用约 3-5%。但叠加 5 个以上类似复杂度的 Shader 时,GPU 时间从 8ms 升到 18ms,接近 60fps 的阈值。在实际业务里,我更倾向于把一个复杂效果合并到单个 Shader 里,而不是多层叠加——减少 Pass 数比优化单 Pass 的指令更见效。
边界与限制
用 AGSL 必须接受几个硬性限制:
没有 Geometry/Tessellation Shader。只有 Fragment Shader,你只能改变像素颜色,不能改几何形状。要做顶点变换,只能回到 OpenGL/Vulkan。
uniform 不支持 Struct 和数组。每个变量必须单独声明和设置。参数多的时候很啰嗦——变通方案是把多个值打包进 float4,但牺牲可读性。
编译时机问题。RuntimeShader 构造时不编译,首次绑定时才触发 SkSL 编译。语法错误会崩在 drawRect 那行,调用栈里看不到你的 AGSL 代码。调试技巧:adb logcat | grep -i "skia\|shader" 抓编译日志。
最后,一个实践建议:把 Shader 逻辑和 UI 状态拆开。封装一个独立的类管理 RuntimeShader 和 uniform 更新,而不是直接写在 Composable 里。在重组频繁的场景下,Shader 对象的生命周期就能和 UI 重组节奏解耦——这是我踩过重组导致 Shader 反复编译的坑之后形成的习惯。