液态玻璃与性能降级:toCode 的视觉系统
用 AGSL 手写 SDF 边缘光着色器、RenderEffect 渐进模糊顶栏、二阶阻尼液态底栏,并以 Full/Degraded/Fallback 三级降级覆盖低端机,减动效模式下自动退场。

「码达 toCode」系列第三篇:在 Compose 里手写"液态玻璃"——SDF 边缘光着色器、渐进模糊顶栏、阻尼拖拽,以及一套面向低端机的三级视觉降级策略。

为什么自研特效层
取件码工具的用户什么设备都有——从旗舰机到三年前的千元机。想要"液态玻璃"这种高感知的视觉品质,又不想在低端机上掉帧,就不能只依赖现成组件库。码达在 Miuix(MIUI 风格 Compose 组件库)之上自建了一层特效系统:ui/effects + ui/glass 两个包,负责玻璃卡片、边缘光、渐进模糊、液态底栏四种效果,并且每一种都内置降级路径。
先看依赖组合:
miuix-ui = "top.yukonga.miuix.kmp" # MIUI 风格组件
backdrop = "io.github.kyant0:backdrop" # 玻璃折射的"背景采样"
shapes = "io.github.kyant0:shapes" # 超椭圆连续圆角
backdrop 库的思路是把"背景内容"建模为可采样的图层(layerBackdrop),玻璃组件再对该图层做模糊与折射——比传统 BlurMaskFilter 方案多出真实的"折射边缘"。码达在此基础上叠加了两层自研能力。
边缘光:用 AGSL 写 SDF
EdgeLight 是最核心的自研特效:沿圆角卡片边缘渲染一圈可调的光,模拟玻璃边缘对环境光的反射。实现在 EdgeLightShaders.kt,用 Android 的 AGSL(RuntimeShader,API 33+)手写了几何着色:
// 圆角矩形的 SDF(有向距离场)
float sdRoundedRect(float2 coord, float2 halfSize, float radius) {
float2 cornerCoord = abs(coord) - (halfSize - float2(radius));
float outside = length(max(cornerCoord, 0.0)) - radius;
float inside = min(max(cornerCoord.x, cornerCoord.y), 0.0);
return outside + inside;
}
// 距离场梯度:光沿边缘法线方向衰减
float2 gradSdRoundedRect(float2 coord, float2 halfSize, float radius) { ... }
基于 SDF 与其梯度,三种光样式被统一进一个着色器参数集:
Uniform:均匀描边,默认 0.28dp 超细亮线;Directional:定向光,按角度与衰减系数模拟单侧受光;Glow:泛光,光晕沿边缘向外扩散。
使用侧是一个标准 Modifier 扩展(EdgeLightModifier),参数全部 @Immutable + @Stable 预置实例(EdgeLight.Subtle / Prominent),避免重组时反复分配。宽度以半像素为单位调(0.28dp),在暗色背景下"似有若无"——这正是玻璃质感的关键:边缘光不该被看见,应该被感觉到。
渐进模糊顶栏与液态底栏
ProgressiveBlurTopBar 用 RenderEffect 对顶栏背景做渐进模糊——从上到下模糊半径递减,内容滚动时上边缘"融化"进模糊里。为了避免整帧模糊的性能开销,实现按行分段采样背景图层(rememberLayerBackdrop),只模糊顶栏矩形区域。
LiquidBottomTabs 则是交互重头:底部标签以"液滴"形态在选中项之间流动,配合 DampedDragAnimation 实现二阶阻尼——手指拖动时液滴带惯性跟随,松手后以临界阻尼回弹,DragGestureInspector 负责把原始手势解析成速度向量。全部动画走 withFrameNanos 帧驱动,滚动列表时与页面滑动共享同一时间轴。
三级降级策略:Full / Degraded / Fallback
重头戏是 VisualEffectsPolicy。它把设备能力收敛成一个 GlassCapability 值对象,所有特效组件只看 tier,不直接探测硬件:
enum class GlassTier { Full, Degraded, Fallback }
data class GlassCapability(
val runtimeShaderSupported: Boolean, // AGSL (API 33+)
val renderEffectSupported: Boolean, // RenderEffect (API 31+)
val topGradientEnabled: Boolean,
val reduceMotion: Boolean, // 系统减动效
val lowRamDevice: Boolean, // ActivityManager.isLowRamDevice
) {
val fullLiquidGlass: Boolean get() = runtimeShaderSupported
val blurGlass: Boolean get() = renderEffectSupported && !lowRamDevice
val progressiveBlur: Boolean get() = fullLiquidGlass && !lowRamDevice
val tier: GlassTier
get() = when {
progressiveBlur -> GlassTier.Full // SDF 边缘光 + 渐进模糊
blurGlass -> GlassTier.Degraded // 保留模糊,去着色器
else -> GlassTier.Fallback // 纯色渐变近似
}
}
决策树一目了然:
flowchart TD
A["设备能力探测"] --> B{"AGSL 可用且非低内存?"}
B -- 是 --> C["Tier Full<br/>SDF 边缘光 + 渐进模糊 + 液态底栏"]
B -- 否 --> D{"RenderEffect 可用?"}
D -- 是 --> E["Tier Degraded<br/>保留模糊,特效退化"]
D -- 否 --> F["Tier Fallback<br/>顶栏渐变 + 实色底栏"]
两个值得强调的细节:
- 减动效无障碍:
rememberReduceMotionEnabled()监听系统ANIMATOR_DURATION_SCALE(开发者开启"动画时长为 0"即减动效模式),并注册ContentObserver实时响应变化。减动效开启时所有弹性动画退化为直接切换——这是很多特效实现会漏掉的无障碍义务; - 能力值全局缓存:
rememberGlassCapability()以 Application Context 为 key 记忆结果,探测(反射 + ActivityManager 查询)只发生一次,不会在每次重组中重复执行。
小结
这套特效层的经验可以浓缩成三句话:用 SDF 而不是贴图做边缘光(任意圆角、任意尺寸都精确);把"能不能开特效"收敛成一个纯函数(组件只看 tier,探测只做一次);每一种高级效果都准备一个体面的退路(模糊 → 渐变 → 实色)。视觉的华丽程度应该由设备决定,而视觉的一致性由架构保证——低端机用户看到的依然是同一个设计语言,只是少了几层光。
