仿某租房App那种“裸眼3D”卡片,第一次看到时觉得只是简单的倾斜位移,自己动手在Compose里复刻一遍才发现,从传感器选型到视差层级、动画协调,每一环都有不少细节。这篇文章就把我的完整实现过程和踩坑记录分享出来,给想在Compose里做类似效果的朋友一条直接能走通的路。1. 效果拆解与实现思路1.1 裸眼3D效果的本质是“视差”,不是真的3D我们常说的裸眼3D卡片,本质上不是渲染了三维模型,而是利用人眼对远近物体运动速度不一致的感知,制造出“层次感”。当手机旋转时,屏幕上的前景层和背景层以不同幅度、不同方向移动,大脑就会脑补出纵深。这和小时候看立体画、长大看2.5D游戏是同一个原理。具体到租房App首页的卡片,画面里通常有房间图、文字信息、价格标签、背景色块。要让它们“立起来”,核心就一句话:离观察者越近的层,移动幅度越大、方向越明显;离观察者越远的层(比如背景),移动幅度小,甚至反向缓动。只要把这个规则执行到位,不需要任何三维引擎,也能骗过眼睛。另外还有一个重要因素:光照和阴影。裸眼3D的“真实感”有一部分来自卡片边缘的微弱阴影变化。手机倾斜时,卡片边缘会随着光线角度出现深浅不一的阴影,这能强化体积感。我在实现里给最外层加了动态阴影偏移,效果提升很明显。1.2 为什么选Jetpack Compose来实现选Compose而不是传统View体系,原因很直接:声明式状态驱动。传感器数据是不断变化的流,Compose可以直接把传感器数值映射为UI状态,UI自动重组,不用手动写setOnTouchListener或setX/setY。图形变换能力强。graphicsLayer支持旋转、平移、缩放、透视(cameraDistance)等多种变换,组合起来非常灵活。动效衔接自然。从传感器数据到最终画面,中间可能需要过滤、平滑、回弹,Compose的animate*AsState系列和Animatable能很好地配合。如果是在传统View里做,通常要用ViewPager+自定义Transformation或者RotationX配合setCameraDistance,代码量大,还容易跟触摸事件冲突。Compose把数据层和渲染层分开,思路清晰得多。有一点要注意:Compose的graphicsLayer变换是作用在整块内容上的,如果卡片内部有多个子布局,需要把它们拆成多个Box层级,分别施加不同幅度的变换,才能做出层次差异。这也是做裸眼3D效果的核心操作:分层渲染。2. 前置基础:传感器数据获取与坐标系映射2.1 用加速度计还是陀螺仪?我的选型过程做这类效果,可选传感器有加速度计、陀螺仪、旋转矢量传感器。我最初的版本用的是陀螺仪,因为响应最灵敏,数据是角速度,积分出角度后很平滑。但实际问题很多:陀螺仪有漂移,长时间使用后角度会慢慢偏掉,需要定期校准。积分累计误差会让画面倾斜回不到原位。后来换成旋转矢量传感器,它融合了加速度计、陀螺仪和磁力计,直接输出设备姿态四元数或欧拉角,使用简单,稳定性好。缺陷是它在快速转动时有轻微延迟,但对卡片视差效果来说影响不大。如果你没有旋转矢量传感器(极少数设备),可以用加速度计的x和y值近似替代。不过加速度计在静止时会有微小抖动,且无法区分重力加速度和运动加速度,效果会差一些,需要滤波。从实际体验来说,我最终采用的是旋转矢量传感器 + 自定义低通滤波。没有选择SensorManager自带的lowPass过滤器,因为它的系数是写死的,调节不灵活。2.2 传感器数据到UI状态的桥接Compose里接传感器,最顺手的方案是LocalContext.current.getSystemService(SensorManager::class.java),然后注册监听器,把onSensorChanged里的数据用回调抛给ViewModel或直接用StateFlow。我这里在ViewModel里定义了一个DeviceState:data class DeviceState( val azimuth: Float = 0f, val pitch: Float = 0f, val roll: Float = 0f )其中azimuth是方位角,我们这里用不到(除非你想让卡片随手机水平旋转而旋转,但那会有方向上的问题);pitch是手机绕横轴倾斜角度;roll是绕纵轴倾斜角度。实际想要的效果是:手机左右倾斜时,卡片产生绕Y轴的旋转并位移;手机上下倾斜时,卡片产生绕X轴的旋转并位移,所以只需要pitch和roll。关键代码:class DeviceOrientationViewModel : ViewModel() { private val _deviceState = MutableStateFlow(DeviceState()) val deviceState: StateFlow<DeviceState> = _deviceState.asStateFlow() private val filter = LowPassFilter() fun onSensorChanged(event: SensorEvent?) { event ?: return // 旋转矢量传感器的values里有三个分量,这里直接转为欧拉角 val rotationMatrix = FloatArray(9) SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) val orientation = FloatArray(3) SensorManager.getOrientation(rotationMatrix, orientation) val filteredPitch = filter.apply(orientation[1]) val filteredRoll = filter.apply(orientation[2]) _deviceState.update { it.copy(pitch = filteredPitch, roll = filteredRoll) } } }LowPassFilter是我自己写的一阶惯性滤波,简单实用:class LowPassFilter(private val alpha: Float = 0.08f) { private var hasPrevious = false private var previousValue = 0f fun apply(value: Float): Float { if (!hasPrevious) { hasPrevious = true previousValue = value return value } previousValue = previousValue + alpha * (value - previousValue) return previousValue } }alpha值越小,曲线越平滑,但延迟越大。我用的是0.08左右,既保留了手感,又不会抖。实际你可以自己调,0.05更丝滑,0.15更跟手。这里有个容易踩的坑:传感器数据不能直接用原始值去驱动UI角度。因为手机握持姿势各异,初始角度不一定是0,直接计算会让卡片一开始就是歪的。所以需要在页面onResume时记录一组“初始基准角度”,之后的每个值都减去这个基准值。private var initialPitch = 0f private var initialRoll = 0f fun resetBaseAngles() { // 在需要校准时调用 val current = _deviceState.value initialPitch = current.pitch initialRoll = current.roll }使用时做差分:相对角度 = 当前角度 - 初始角度。3. Compose实现核心:从数据到画面3.1 用graphicsLayer做透视与旋转拿到相对角度后,最直接的做法是给卡片整体设置rotationX和rotationY。但如果你只是单纯旋转整张卡片,效果会和一块木板在转一样,没有立体感。裸眼3D的精髓在于内容分层——卡片里的元素各自移动。Compose中graphicsLayer可以接受多个变换参数:Modifier.graphicsLayer { rotationX = pitchDegrees rotationY = rollDegrees cameraDistance = 8 * density }cameraDistance是相机到平面的距离,值越大,透视畸变越小;值越小,透视越夸张。默认值是8 * density,我试过更小值,边缘变形太严重,文字会看起来扭曲;更大值又没了“立体”感觉。对于卡片效果,建议保持在8 * density到12 * density之间。如果要单独让某个子层(比如价格标签)做更大幅度的平移,可以用translationX和translationY。由于graphicsLayer内部变换顺序是固定的,你可以分别设置rotationY和translationX,两者的合成顺序恰好是我们想要的:先旋转,后偏移。这样元素会像贴在曲面上的贴纸一样,随着卡片转动而顺带移动。3.2 多层卡片的位移与景深效果具体到卡片内容,我设计了三层:背景层(模糊色块)、图片层(房源图)、文字信息层。三层位移系数分别为:背景层:位移幅度为0.2倍,方向与倾斜方向相反,模拟远距离运动迟缓。图片层:位移幅度为0.6倍,方向与倾斜方向相同,视觉上居中。文字层:位移幅度为1.0倍,方向与倾斜方向相同,看起来离用户最近。你可能会问:为什么不把图片层设为1.0,文字层设0.6?因为文字是用户重点关注的信息,稍微突出一点才能有“浮起来”的感觉。图片如果是大图,过度移动容易露出卡片边缘,所以它的移动幅度应该更克制。数字并非绝对,我调了两天之后得出这组系数,适合大多数卡片。这里的位移量计算公式为:val maxOffsetX = 24.dp val maxOffsetY = 18.dp graphicsLayer { translationX = lerp(-maxOffsetX.value, maxOffsetX.value, rollRatio) * layerDepth translationY = lerp(-maxOffsetY.value, maxOffsetY.value, pitchRatio) * layerDepth }rollRatio和pitchRatio是把传感器角度映射到-1..1区间的归一化值。layerDepth就是上文的0.2/0.6/1.0。这里需要说明lerp的精度问题。lerp(Float, Float, Float)是Compose的线性插值,参数类型是Float(也就是px)。如果直接用dp值参与计算,需要先px转dp,否则会跟密度挂钩,不同设备效果差异大。更稳妥的做法是:val maxOffsetX = 24.dp val maxOffsetXPx = with(LocalDensity.current) { maxOffsetX.toPx() }或者在graphicsLayer作用域内使用this.density:graphicsLayer { val px = 24.dp.toPx() translationX = lerp(-px, px, rollRatio) * layerDepth }graphicsLayer的GraphicsLayerScope实现了Density接口,可以直接调用24.dp.toPx(),很方便。3.3 让背景产生视差错位背景层不只是位移,我还在它上面叠了一个比卡片本身大一圈的渐变图片,利用wrapContentBounds控制范围。背景图片的尺寸要略大于卡片表面,比如宽高放大20%。这样,当背景层做反向位移时,边缘不会露出卡片底下的空白。如果背景只是一个单色色块,那视差效果不明显。建议给背景加一点模糊的灯光光斑,或者用从图片中取出的主色做径向渐变。我实现时从房源图里动态提取了平均色,然后做一个Brush.radialGradient,这样每次展示不同房源,卡片底色都会跟着变,背景视差移动起来就非常明显。Box( Modifier .matchParentSize() .graphicsLayer { translationX = -rollRatio * 40.dp.toPx() translationY = -pitchRatio * 30.dp.toPx() scaleX = 1.2f scaleY = 1.2f } .background( Brush.radialGradient( colors = listOf(dominantColor.copy(alpha = 0.6f), Color.Transparent), center = Offset(0.5f, 0.5f), radius = 1.2f ) ) )这里背景层取负方向位移,会让画面产生凝视深度。和前景层相反的运动方向是视差的关键,别把方向搞混了。4. 完整代码实现与调参心得4.1 基础状态架构我先定义一个CardDepth数据类,把各层深度参数集中管理:data class CardDepth( val background: Float = 0.2f, val image: Float = 0.6f, val text: Float = 1.0f, val shadow: Float = 1.2f )然后写一个ParallaxCardcomposable,接收deviceState和depth参数。@Composable fun ParallaxCard( modifier: Modifier = Modifier, deviceState: DeviceState, depth: CardDepth = CardDepth() ) { val density = LocalDensity.current val rollRatio = remember(deviceState.roll) { (deviceState.roll / maxRoll).coerceIn(-1f, 1f) } val pitchRatio = remember(deviceState.pitch) { (deviceState.pitch / maxPitch).coerceIn(-1f, 1f) } val maxOffsetXPx = with(density) { 24.dp.toPx() } val maxOffsetYPx = with(density) { 18.dp.toPx() } Box( modifier = modifier .aspectRatio(1.4f) .graphicsLayer { rotationX = pitchRatio * 12f rotationY = rollRatio * 12f cameraDistance = 8 * density } ) { // 背景层 Box( Modifier .matchParentSize() .graphicsLayer { translationX = -rollRatio * maxOffsetXPx * depth.background translationY = -pitchRatio * maxOffsetYPx * depth.background } .background(Color(0xFFF3EFEA)) ) // 图片层 Image( painter = painterResource(...), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() .padding(12.dp) .graphicsLayer { translationX = rollRatio * maxOffsetXPx * depth.image translationY = pitchRatio * maxOffsetYPx * depth.image } ) // 文字层 Column( Modifier .align(Alignment.BottomStart) .padding(16.dp) .graphicsLayer { translationX = rollRatio * maxOffsetXPx * depth.text translationY = pitchRatio * maxOffsetYPx * depth.text } ) { Text("房源标题", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("地址信息 · 租金", style = MaterialTheme.typography.bodyMedium) } // 阴影层 Box( Modifier .matchParentSize() .graphicsLayer { translationX = rollRatio * maxOffsetXPx * depth.shadow translationY = pitchRatio * maxOffsetYPx * depth.shadow shadowElevation = 4.dp.toPx() * (0.5f + abs(rollRatio) * 0.5f) shape = RoundedCornerShape(16.dp) } ) } }上面代码里的shadowElevation在graphicsLayer里使用需要配合shape,如果你用Modifier.shadow并依赖外部元素,可以动态设置elevation,但Compose的Modifier.shadow不支持动态计算(重组时会卡顿),所以更建议用graphicsLayer的shadowElevation。不过严格说,graphicsLayer里的shadowElevation需要配合shape和clip,而且它在Android 10+才有效果,低版本上是扁平阴影。如果要兼容低版本,还是要用Modifier.shadow配合graphicsLayer的alpha变化来模拟阴影深度。4.2 处理零角度抖动当手机平放或接近水平时,传感器值会在0附近抖动,导致卡片抖来抖去。我加了一个死区范围:当相对角度的绝对值小于某个阈值(比如2度)时,强制把rollRatio和pitchRatio置为0。这样看起来像“停住”了,体验会稳很多。val rawRollRatio = (deviceState.roll - initialRoll) / maxRoll val filteredRollRatio = if (abs(rawRollRatio) < 0.1f) 0f else rawRollRatio.coerceIn(-1f, 1f)这里的0.1是归一化后的死区阈值,等价于maxRoll * 10%的角度。具体阈值需要配合maxRoll使用,maxRoll我设为30度,所以10%就是3度。4.3 让动效更有弹性:回正动画用户把手机倾斜后又放平,卡片应该平滑地回到原始位置。由于传感器数据已经过滤波,其实已经很平滑了,但在极端值到0的过渡中,如果滤波过强,会有“粘滞感”,不干脆。我在测量之后,决定使用Animatable对rollRatio和pitchRatio再做一次动画。每次传感器数据到达时,不是立即更新UI,而是通过snapTo或animateTo修正。val rollRatioAnim = remember { Animatable(0f) } LaunchedEffect(deviceState.roll) { rollRatioAnim.animateTo( targetValue = filteredRollRatio, animationSpec = tween(durationMillis = 80, easing = LinearOutSlowInEasing) ) }注意:传感器频率通常高于UI刷新率,如果每个事件都用animateTo,会累积大量协程任务。经验做法是先做传感器值的时间差分,当变化量超过某个阈值时才触发动画。或者是改用snapTo,因为传感器本身已经滤波,不需要二次补间。我最后采用的方案是:传感器值 -> 低通滤波 -> 死区过滤 -> 直接设置StateFlow。UI侧不做动画,而是用一个derivedStateOf做映射。这样延迟最低,也最流畅。如果希望有回弹,可以在卡片内容里加一个animateFloatAsState来监听比值变化,但当传感器频率很高时反而会卡。建议先跑起来,再决定加不加二次动画。5. 常见问题与排查技巧实录5.1 为什么卡片倾斜时不跟手,有明显延迟?这是滤波系数过大导致的。低通滤波的alpha值太小,数据变化被压得非常平缓,手已经转过去了,UI还在慢慢跟。我试过alpha=0.01,完全像慢动作。后来调到0.08~0.1,跟手度好了很多。如果你还是觉得延迟,可以调大alpha到0.15,或者在滤波后做一阶微分补偿。另一种常见原因是你在UI侧又套了一层动画,双重平滑导致延迟叠加。去掉animateTo,直接赋值的响应是最快的。5.2 卡片边缘露出底图,怎么解决?这是背景层位移方向或尺寸设置不对。我记得最开始把背景层也设成正方向位移,边缘直接露白。正确做法是:背景层反向位移且尺寸放大,放大系数至少是最大位移比例加1。比如最大位移是40dp,卡片宽度是300dp,放大比例要大于2*40/300+1≈1.27,所以取1.2倍可能不够。我后来改成背景scaleX/scaleY = 1.3,同时matchParentSize改为wrapContent并居中,才算稳妥。或者更省事:把背景层设置为填充整个屏幕而不是卡片本身,这样无论怎么移都不会露边。但这样会破坏卡片圆角遮罩,所以还是推荐放大背景层。5.3 在RecyclerView/列表页中,卡片移出屏幕外还有动画吗?如果传感器是全局注册的,所有卡片都会收到数据。但如果每张卡都注册传感器监听的,那就浪费资源且数据冲突。正确做法是:传感器监听放在基座(页面级)ViewModel或Activity级,再把deviceState下发给列表中的每张卡片。列表项通过derivedStateOf观察同一个状态,不会增加额外的传感器。我实际项目里是一个Pager列表,每张卡都共用同一个deviceState。然后我发现一个问题:列表滚动时,卡片会先滚动再倾斜,因为graphicsLayer变换只作用在卡片内容,不随滚动位置变化。这反而效果不错。但如果你希望卡片只在页面可见时才有倾斜,需要判断LazyListState的visibleItemInfo再决定是否应用动画,不然离屏卡片仍然消耗GPU。这个优化做好了,帧率能提升不少。5.4 低版本设备上阴影卡顿,怎么处理?graphicsLayer的shadowElevation在Android 8上不生效,且如果卡片内容频繁变化,会导致图层重绘。我后来改成用Modifier.shadow+animateDpAsState,效果在低版本上也ok。val shadowElevation by animateDpAsState( targetValue = if (abs(rollRatio) > 0.3f) 12.dp else 4.dp, animationSpec = tween(200) ) Modifier.shadow(shadowElevation, RoundedCornerShape(16.dp), clip = false)这里有个经验:clip=false才能让阴影投射在卡片外,但也会导致圆角边缘之外的内容露出来,所以卡片内容本身要做clip(RoundedCornerShape(16.dp))。两层配合好,阴影才有立体感。5.5 传感器权限和生命周期管理注意点传感器不需要普通权限,但必须在onResume注册、onPause注销,否则会一直耗电。我用LifecycleEventEffect(Lifecycle.Event.ON_RESUME)处理注册。有一个细节是:如果页面从后台回来,传感器注册后需要重新校准初始角度。我是在ON_RESUME里获取当前角度并设为基准,这样用户不会看到卡片突然旋转一个很大的角度。LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI) viewModel.resetBaseAngles() }SENSOR_DELAY_UI的采样频率大约16Hz,足够用了。用SENSOR_FASTEST会增加电量和压力,但人眼不会觉得更流畅。我的实测中,UI档位下裸眼3D已经很顺滑。5.6 为何卡片画面会有一层“残影”?这可能是因为背景层和图片层都有translation,但两层之间的zIndex关系在重组时发生了改变。Compose中,graphicsLayer的translationZ可以控制绘制顺序。如果背景层和图片层没有显式设置zIndex,可能会在倾斜时出现图层间闪烁。解决办法是给每一层设置不同的graphicsLayerlayer类型?其实不是,直接给Modifier.zIndex()即可:Modifier.zIndex(0f) // 背景 Modifier.zIndex(1f) // 图片 Modifier.zIndex(2f) // 文字 Modifier.zIndex(3f) // 阴影不过要注意,zIndex只影响同一父布局内的绘制顺序。如果你用matchParentSize多次,多次zIndex是有效的。5.7 如何让卡片在滚动列表结束时自动回正?这个需求常见:用户滑动列表,停止后卡片应该有个回正动画。另一种更高级的交互是卡片跟随手指拖动偏移,但我们这是传感器方向,所以要判断列表滚动状态。可以用listState.isScrollInProgress扩展,让卡片在滚动时保持当前角度,在停止后回正。我为此写了一个额外状态:val isScrollInProgress by remember { derivedStateOf { listState.isScrollInProgress } } LaunchedEffect(isScrollInProgress) { if (!isScrollInProgress) { viewModel.resetBaseAngles() } }注意不要直接清零角度,而是重置初始基准。这样回正是平滑的。6. 性能调优与显示效果细节6.1 避免过度重组我最初把传感器数据直接写进State,每帧都重组整个卡片。后来又调整:State是DeviceState数据类型,StateFlow更新频率就是传感器频率,但Compose的重组范围只会覆盖读取了这些state的lambda。我们的ParallaxCard在graphicsLayer中读取了deviceState.roll,所以每次更新都会重组到graphicsLayer块,这个成本很低,但如果卡片内容很大,仍然会有性能压力。更优的方法是把rollRatio和pitchRatio作为两个单独的Floatstate,不要包装成一个大对象,这样任意一个变化不会触发另一个产生额外重组。实际看如果传感器的两个方向频率差不多,差别不大,但习惯上分开更好。6.2 关闭不必要的背景模糊如果在背景层用Modifier.blur()或者RenderEffect做模糊,会显著增加GPU开销。我后面改用Brush.radialGradient模拟模糊光斑,几乎零成本,而且效果不比真模糊差。只有当你确实需要背景图片实时模糊时,才去用Modifier.blur,但记得用graphicsLayer中的renderEffect不要在每帧更新时频繁设置新实例,应复用。Compose中的RenderEffect底层是RenderNode效果,频繁创建会导致高昂的分配开销。如果必须模糊,请对renderEffect实例做remember。6.3 用阴影和高光模拟“环境光”除了位移,我还在文字层上叠加了一个半透明的高光层,方向跟倾斜方向相反:Brush.linearGradient( colors = listOf(Color.White.copy(alpha = 0.15f), Color.Transparent), start = Offset.Zero, end = Offset(rollRatio * 300f, pitchRatio * 300f) )这样当手机倾斜到一侧时,高光从另一侧照过来,卡片表面像真的在转动时捕捉到环境光,立体感直接拉满。这个高光层使用drawWithContent或者drawBehind都可以,我选择了drawWithCache来记住Brush实例,减少重复创建。我把这次实现反复打磨之后,最大的体会有两个。第一,裸眼3D效果的“质感”很大程度来源于图层间的错位方向和比例,而这需要根据自己的UI重新调节,不能照搬参数。不同的配色、字体大小、图片占比都会影响最终观感,我在自己项目里调整了两三轮,才找到平衡点。第二,传感器数据的平滑处理和死区设置比旋转本身更重要,直接决定用户是否会头晕、会不会觉得“假”。如果你也想上手试一下,建议从最简单的三层错位开始,先跑通方向映射和位移,再逐步加入旋转、阴影和高光。这个套路走通了,以后看到任何App的类似效果,你都能准确判断它是怎么实现的。
仿某租房App那种“裸眼3D”卡片,第一次看到时觉得只是简单的倾斜位移,自己动手在Compose里复刻一遍才发现,从传感器选型到视差层级、动画协调,每一环都有不少细节。这篇文章就把我的完整实现过程和踩坑记录分享出来,给想在Compose里做类似效果的朋友一条直接能走通的路。1. 效果拆解与实现思路1.1 裸眼3D效果的本质是“视差”,不是真的3D我们常说的裸眼3D卡片,本质上不是渲染了三维模型,而是利用人眼对远近物体运动速度不一致的感知,制造出“层次感”。当手机旋转时,屏幕上的前景层和背景层以不同幅度、不同方向移动,大脑就会脑补出纵深。这和小时候看立体画、长大看2.5D游戏是同一个原理。具体到租房App首页的卡片,画面里通常有房间图、文字信息、价格标签、背景色块。要让它们“立起来”,核心就一句话:离观察者越近的层,移动幅度越大、方向越明显;离观察者越远的层(比如背景),移动幅度小,甚至反向缓动。只要把这个规则执行到位,不需要任何三维引擎,也能骗过眼睛。另外还有一个重要因素:光照和阴影。裸眼3D的“真实感”有一部分来自卡片边缘的微弱阴影变化。手机倾斜时,卡片边缘会随着光线角度出现深浅不一的阴影,这能强化体积感。我在实现里给最外层加了动态阴影偏移,效果提升很明显。1.2 为什么选Jetpack Compose来实现选Compose而不是传统View体系,原因很直接:声明式状态驱动。传感器数据是不断变化的流,Compose可以直接把传感器数值映射为UI状态,UI自动重组,不用手动写setOnTouchListener或setX/setY。图形变换能力强。graphicsLayer支持旋转、平移、缩放、透视(cameraDistance)等多种变换,组合起来非常灵活。动效衔接自然。从传感器数据到最终画面,中间可能需要过滤、平滑、回弹,Compose的animate*AsState系列和Animatable能很好地配合。如果是在传统View里做,通常要用ViewPager+自定义Transformation或者RotationX配合setCameraDistance,代码量大,还容易跟触摸事件冲突。Compose把数据层和渲染层分开,思路清晰得多。有一点要注意:Compose的graphicsLayer变换是作用在整块内容上的,如果卡片内部有多个子布局,需要把它们拆成多个Box层级,分别施加不同幅度的变换,才能做出层次差异。这也是做裸眼3D效果的核心操作:分层渲染。2. 前置基础:传感器数据获取与坐标系映射2.1 用加速度计还是陀螺仪?我的选型过程做这类效果,可选传感器有加速度计、陀螺仪、旋转矢量传感器。我最初的版本用的是陀螺仪,因为响应最灵敏,数据是角速度,积分出角度后很平滑。但实际问题很多:陀螺仪有漂移,长时间使用后角度会慢慢偏掉,需要定期校准。积分累计误差会让画面倾斜回不到原位。后来换成旋转矢量传感器,它融合了加速度计、陀螺仪和磁力计,直接输出设备姿态四元数或欧拉角,使用简单,稳定性好。缺陷是它在快速转动时有轻微延迟,但对卡片视差效果来说影响不大。如果你没有旋转矢量传感器(极少数设备),可以用加速度计的x和y值近似替代。不过加速度计在静止时会有微小抖动,且无法区分重力加速度和运动加速度,效果会差一些,需要滤波。从实际体验来说,我最终采用的是旋转矢量传感器 + 自定义低通滤波。没有选择SensorManager自带的lowPass过滤器,因为它的系数是写死的,调节不灵活。2.2 传感器数据到UI状态的桥接Compose里接传感器,最顺手的方案是LocalContext.current.getSystemService(SensorManager::class.java),然后注册监听器,把onSensorChanged里的数据用回调抛给ViewModel或直接用StateFlow。我这里在ViewModel里定义了一个DeviceState:data class DeviceState( val azimuth: Float = 0f, val pitch: Float = 0f, val roll: Float = 0f )其中azimuth是方位角,我们这里用不到(除非你想让卡片随手机水平旋转而旋转,但那会有方向上的问题);pitch是手机绕横轴倾斜角度;roll是绕纵轴倾斜角度。实际想要的效果是:手机左右倾斜时,卡片产生绕Y轴的旋转并位移;手机上下倾斜时,卡片产生绕X轴的旋转并位移,所以只需要pitch和roll。关键代码:class DeviceOrientationViewModel : ViewModel() { private val _deviceState = MutableStateFlow(DeviceState()) val deviceState: StateFlow<DeviceState> = _deviceState.asStateFlow() private val filter = LowPassFilter() fun onSensorChanged(event: SensorEvent?) { event ?: return // 旋转矢量传感器的values里有三个分量,这里直接转为欧拉角 val rotationMatrix = FloatArray(9) SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) val orientation = FloatArray(3) SensorManager.getOrientation(rotationMatrix, orientation) val filteredPitch = filter.apply(orientation[1]) val filteredRoll = filter.apply(orientation[2]) _deviceState.update { it.copy(pitch = filteredPitch, roll = filteredRoll) } } }LowPassFilter是我自己写的一阶惯性滤波,简单实用:class LowPassFilter(private val alpha: Float = 0.08f) { private var hasPrevious = false private var previousValue = 0f fun apply(value: Float): Float { if (!hasPrevious) { hasPrevious = true previousValue = value return value } previousValue = previousValue + alpha * (value - previousValue) return previousValue } }alpha值越小,曲线越平滑,但延迟越大。我用的是0.08左右,既保留了手感,又不会抖。实际你可以自己调,0.05更丝滑,0.15更跟手。这里有个容易踩的坑:传感器数据不能直接用原始值去驱动UI角度。因为手机握持姿势各异,初始角度不一定是0,直接计算会让卡片一开始就是歪的。所以需要在页面onResume时记录一组“初始基准角度”,之后的每个值都减去这个基准值。private var initialPitch = 0f private var initialRoll = 0f fun resetBaseAngles() { // 在需要校准时调用 val current = _deviceState.value initialPitch = current.pitch initialRoll = current.roll }使用时做差分:相对角度 = 当前角度 - 初始角度。3. Compose实现核心:从数据到画面3.1 用graphicsLayer做透视与旋转拿到相对角度后,最直接的做法是给卡片整体设置rotationX和rotationY。但如果你只是单纯旋转整张卡片,效果会和一块木板在转一样,没有立体感。裸眼3D的精髓在于内容分层——卡片里的元素各自移动。Compose中graphicsLayer可以接受多个变换参数:Modifier.graphicsLayer { rotationX = pitchDegrees rotationY = rollDegrees cameraDistance = 8 * density }cameraDistance是相机到平面的距离,值越大,透视畸变越小;值越小,透视越夸张。默认值是8 * density,我试过更小值,边缘变形太严重,文字会看起来扭曲;更大值又没了“立体”感觉。对于卡片效果,建议保持在8 * density到12 * density之间。如果要单独让某个子层(比如价格标签)做更大幅度的平移,可以用translationX和translationY。由于graphicsLayer内部变换顺序是固定的,你可以分别设置rotationY和translationX,两者的合成顺序恰好是我们想要的:先旋转,后偏移。这样元素会像贴在曲面上的贴纸一样,随着卡片转动而顺带移动。3.2 多层卡片的位移与景深效果具体到卡片内容,我设计了三层:背景层(模糊色块)、图片层(房源图)、文字信息层。三层位移系数分别为:背景层:位移幅度为0.2倍,方向与倾斜方向相反,模拟远距离运动迟缓。图片层:位移幅度为0.6倍,方向与倾斜方向相同,视觉上居中。文字层:位移幅度为1.0倍,方向与倾斜方向相同,看起来离用户最近。你可能会问:为什么不把图片层设为1.0,文字层设0.6?因为文字是用户重点关注的信息,稍微突出一点才能有“浮起来”的感觉。图片如果是大图,过度移动容易露出卡片边缘,所以它的移动幅度应该更克制。数字并非绝对,我调了两天之后得出这组系数,适合大多数卡片。这里的位移量计算公式为:val maxOffsetX = 24.dp val maxOffsetY = 18.dp graphicsLayer { translationX = lerp(-maxOffsetX.value, maxOffsetX.value, rollRatio) * layerDepth translationY = lerp(-maxOffsetY.value, maxOffsetY.value, pitchRatio) * layerDepth }rollRatio和pitchRatio是把传感器角度映射到-1..1区间的归一化值。layerDepth就是上文的0.2/0.6/1.0。这里需要说明lerp的精度问题。lerp(Float, Float, Float)是Compose的线性插值,参数类型是Float(也就是px)。如果直接用dp值参与计算,需要先px转dp,否则会跟密度挂钩,不同设备效果差异大。更稳妥的做法是:val maxOffsetX = 24.dp val maxOffsetXPx = with(LocalDensity.current) { maxOffsetX.toPx() }或者在graphicsLayer作用域内使用this.density:graphicsLayer { val px = 24.dp.toPx() translationX = lerp(-px, px, rollRatio) * layerDepth }graphicsLayer的GraphicsLayerScope实现了Density接口,可以直接调用24.dp.toPx(),很方便。3.3 让背景产生视差错位背景层不只是位移,我还在它上面叠了一个比卡片本身大一圈的渐变图片,利用wrapContentBounds控制范围。背景图片的尺寸要略大于卡片表面,比如宽高放大20%。这样,当背景层做反向位移时,边缘不会露出卡片底下的空白。如果背景只是一个单色色块,那视差效果不明显。建议给背景加一点模糊的灯光光斑,或者用从图片中取出的主色做径向渐变。我实现时从房源图里动态提取了平均色,然后做一个Brush.radialGradient,这样每次展示不同房源,卡片底色都会跟着变,背景视差移动起来就非常明显。Box( Modifier .matchParentSize() .graphicsLayer { translationX = -rollRatio * 40.dp.toPx() translationY = -pitchRatio * 30.dp.toPx() scaleX = 1.2f scaleY = 1.2f } .background( Brush.radialGradient( colors = listOf(dominantColor.copy(alpha = 0.6f), Color.Transparent), center = Offset(0.5f, 0.5f), radius = 1.2f ) ) )这里背景层取负方向位移,会让画面产生凝视深度。和前景层相反的运动方向是视差的关键,别把方向搞混了。4. 完整代码实现与调参心得4.1 基础状态架构我先定义一个CardDepth数据类,把各层深度参数集中管理:data class CardDepth( val background: Float = 0.2f, val image: Float = 0.6f, val text: Float = 1.0f, val shadow: Float = 1.2f )然后写一个ParallaxCardcomposable,接收deviceState和depth参数。@Composable fun ParallaxCard( modifier: Modifier = Modifier, deviceState: DeviceState, depth: CardDepth = CardDepth() ) { val density = LocalDensity.current val rollRatio = remember(deviceState.roll) { (deviceState.roll / maxRoll).coerceIn(-1f, 1f) } val pitchRatio = remember(deviceState.pitch) { (deviceState.pitch / maxPitch).coerceIn(-1f, 1f) } val maxOffsetXPx = with(density) { 24.dp.toPx() } val maxOffsetYPx = with(density) { 18.dp.toPx() } Box( modifier = modifier .aspectRatio(1.4f) .graphicsLayer { rotationX = pitchRatio * 12f rotationY = rollRatio * 12f cameraDistance = 8 * density } ) { // 背景层 Box( Modifier .matchParentSize() .graphicsLayer { translationX = -rollRatio * maxOffsetXPx * depth.background translationY = -pitchRatio * maxOffsetYPx * depth.background } .background(Color(0xFFF3EFEA)) ) // 图片层 Image( painter = painterResource(...), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() .padding(12.dp) .graphicsLayer { translationX = rollRatio * maxOffsetXPx * depth.image translationY = pitchRatio * maxOffsetYPx * depth.image } ) // 文字层 Column( Modifier .align(Alignment.BottomStart) .padding(16.dp) .graphicsLayer { translationX = rollRatio * maxOffsetXPx * depth.text translationY = pitchRatio * maxOffsetYPx * depth.text } ) { Text("房源标题", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("地址信息 · 租金", style = MaterialTheme.typography.bodyMedium) } // 阴影层 Box( Modifier .matchParentSize() .graphicsLayer { translationX = rollRatio * maxOffsetXPx * depth.shadow translationY = pitchRatio * maxOffsetYPx * depth.shadow shadowElevation = 4.dp.toPx() * (0.5f + abs(rollRatio) * 0.5f) shape = RoundedCornerShape(16.dp) } ) } }上面代码里的shadowElevation在graphicsLayer里使用需要配合shape,如果你用Modifier.shadow并依赖外部元素,可以动态设置elevation,但Compose的Modifier.shadow不支持动态计算(重组时会卡顿),所以更建议用graphicsLayer的shadowElevation。不过严格说,graphicsLayer里的shadowElevation需要配合shape和clip,而且它在Android 10+才有效果,低版本上是扁平阴影。如果要兼容低版本,还是要用Modifier.shadow配合graphicsLayer的alpha变化来模拟阴影深度。4.2 处理零角度抖动当手机平放或接近水平时,传感器值会在0附近抖动,导致卡片抖来抖去。我加了一个死区范围:当相对角度的绝对值小于某个阈值(比如2度)时,强制把rollRatio和pitchRatio置为0。这样看起来像“停住”了,体验会稳很多。val rawRollRatio = (deviceState.roll - initialRoll) / maxRoll val filteredRollRatio = if (abs(rawRollRatio) < 0.1f) 0f else rawRollRatio.coerceIn(-1f, 1f)这里的0.1是归一化后的死区阈值,等价于maxRoll * 10%的角度。具体阈值需要配合maxRoll使用,maxRoll我设为30度,所以10%就是3度。4.3 让动效更有弹性:回正动画用户把手机倾斜后又放平,卡片应该平滑地回到原始位置。由于传感器数据已经过滤波,其实已经很平滑了,但在极端值到0的过渡中,如果滤波过强,会有“粘滞感”,不干脆。我在测量之后,决定使用Animatable对rollRatio和pitchRatio再做一次动画。每次传感器数据到达时,不是立即更新UI,而是通过snapTo或animateTo修正。val rollRatioAnim = remember { Animatable(0f) } LaunchedEffect(deviceState.roll) { rollRatioAnim.animateTo( targetValue = filteredRollRatio, animationSpec = tween(durationMillis = 80, easing = LinearOutSlowInEasing) ) }注意:传感器频率通常高于UI刷新率,如果每个事件都用animateTo,会累积大量协程任务。经验做法是先做传感器值的时间差分,当变化量超过某个阈值时才触发动画。或者是改用snapTo,因为传感器本身已经滤波,不需要二次补间。我最后采用的方案是:传感器值 -> 低通滤波 -> 死区过滤 -> 直接设置StateFlow。UI侧不做动画,而是用一个derivedStateOf做映射。这样延迟最低,也最流畅。如果希望有回弹,可以在卡片内容里加一个animateFloatAsState来监听比值变化,但当传感器频率很高时反而会卡。建议先跑起来,再决定加不加二次动画。5. 常见问题与排查技巧实录5.1 为什么卡片倾斜时不跟手,有明显延迟?这是滤波系数过大导致的。低通滤波的alpha值太小,数据变化被压得非常平缓,手已经转过去了,UI还在慢慢跟。我试过alpha=0.01,完全像慢动作。后来调到0.08~0.1,跟手度好了很多。如果你还是觉得延迟,可以调大alpha到0.15,或者在滤波后做一阶微分补偿。另一种常见原因是你在UI侧又套了一层动画,双重平滑导致延迟叠加。去掉animateTo,直接赋值的响应是最快的。5.2 卡片边缘露出底图,怎么解决?这是背景层位移方向或尺寸设置不对。我记得最开始把背景层也设成正方向位移,边缘直接露白。正确做法是:背景层反向位移且尺寸放大,放大系数至少是最大位移比例加1。比如最大位移是40dp,卡片宽度是300dp,放大比例要大于2*40/300+1≈1.27,所以取1.2倍可能不够。我后来改成背景scaleX/scaleY = 1.3,同时matchParentSize改为wrapContent并居中,才算稳妥。或者更省事:把背景层设置为填充整个屏幕而不是卡片本身,这样无论怎么移都不会露边。但这样会破坏卡片圆角遮罩,所以还是推荐放大背景层。5.3 在RecyclerView/列表页中,卡片移出屏幕外还有动画吗?如果传感器是全局注册的,所有卡片都会收到数据。但如果每张卡都注册传感器监听的,那就浪费资源且数据冲突。正确做法是:传感器监听放在基座(页面级)ViewModel或Activity级,再把deviceState下发给列表中的每张卡片。列表项通过derivedStateOf观察同一个状态,不会增加额外的传感器。我实际项目里是一个Pager列表,每张卡都共用同一个deviceState。然后我发现一个问题:列表滚动时,卡片会先滚动再倾斜,因为graphicsLayer变换只作用在卡片内容,不随滚动位置变化。这反而效果不错。但如果你希望卡片只在页面可见时才有倾斜,需要判断LazyListState的visibleItemInfo再决定是否应用动画,不然离屏卡片仍然消耗GPU。这个优化做好了,帧率能提升不少。5.4 低版本设备上阴影卡顿,怎么处理?graphicsLayer的shadowElevation在Android 8上不生效,且如果卡片内容频繁变化,会导致图层重绘。我后来改成用Modifier.shadow+animateDpAsState,效果在低版本上也ok。val shadowElevation by animateDpAsState( targetValue = if (abs(rollRatio) > 0.3f) 12.dp else 4.dp, animationSpec = tween(200) ) Modifier.shadow(shadowElevation, RoundedCornerShape(16.dp), clip = false)这里有个经验:clip=false才能让阴影投射在卡片外,但也会导致圆角边缘之外的内容露出来,所以卡片内容本身要做clip(RoundedCornerShape(16.dp))。两层配合好,阴影才有立体感。5.5 传感器权限和生命周期管理注意点传感器不需要普通权限,但必须在onResume注册、onPause注销,否则会一直耗电。我用LifecycleEventEffect(Lifecycle.Event.ON_RESUME)处理注册。有一个细节是:如果页面从后台回来,传感器注册后需要重新校准初始角度。我是在ON_RESUME里获取当前角度并设为基准,这样用户不会看到卡片突然旋转一个很大的角度。LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI) viewModel.resetBaseAngles() }SENSOR_DELAY_UI的采样频率大约16Hz,足够用了。用SENSOR_FASTEST会增加电量和压力,但人眼不会觉得更流畅。我的实测中,UI档位下裸眼3D已经很顺滑。5.6 为何卡片画面会有一层“残影”?这可能是因为背景层和图片层都有translation,但两层之间的zIndex关系在重组时发生了改变。Compose中,graphicsLayer的translationZ可以控制绘制顺序。如果背景层和图片层没有显式设置zIndex,可能会在倾斜时出现图层间闪烁。解决办法是给每一层设置不同的graphicsLayerlayer类型?其实不是,直接给Modifier.zIndex()即可:Modifier.zIndex(0f) // 背景 Modifier.zIndex(1f) // 图片 Modifier.zIndex(2f) // 文字 Modifier.zIndex(3f) // 阴影不过要注意,zIndex只影响同一父布局内的绘制顺序。如果你用matchParentSize多次,多次zIndex是有效的。5.7 如何让卡片在滚动列表结束时自动回正?这个需求常见:用户滑动列表,停止后卡片应该有个回正动画。另一种更高级的交互是卡片跟随手指拖动偏移,但我们这是传感器方向,所以要判断列表滚动状态。可以用listState.isScrollInProgress扩展,让卡片在滚动时保持当前角度,在停止后回正。我为此写了一个额外状态:val isScrollInProgress by remember { derivedStateOf { listState.isScrollInProgress } } LaunchedEffect(isScrollInProgress) { if (!isScrollInProgress) { viewModel.resetBaseAngles() } }注意不要直接清零角度,而是重置初始基准。这样回正是平滑的。6. 性能调优与显示效果细节6.1 避免过度重组我最初把传感器数据直接写进State,每帧都重组整个卡片。后来又调整:State是DeviceState数据类型,StateFlow更新频率就是传感器频率,但Compose的重组范围只会覆盖读取了这些state的lambda。我们的ParallaxCard在graphicsLayer中读取了deviceState.roll,所以每次更新都会重组到graphicsLayer块,这个成本很低,但如果卡片内容很大,仍然会有性能压力。更优的方法是把rollRatio和pitchRatio作为两个单独的Floatstate,不要包装成一个大对象,这样任意一个变化不会触发另一个产生额外重组。实际看如果传感器的两个方向频率差不多,差别不大,但习惯上分开更好。6.2 关闭不必要的背景模糊如果在背景层用Modifier.blur()或者RenderEffect做模糊,会显著增加GPU开销。我后面改用Brush.radialGradient模拟模糊光斑,几乎零成本,而且效果不比真模糊差。只有当你确实需要背景图片实时模糊时,才去用Modifier.blur,但记得用graphicsLayer中的renderEffect不要在每帧更新时频繁设置新实例,应复用。Compose中的RenderEffect底层是RenderNode效果,频繁创建会导致高昂的分配开销。如果必须模糊,请对renderEffect实例做remember。6.3 用阴影和高光模拟“环境光”除了位移,我还在文字层上叠加了一个半透明的高光层,方向跟倾斜方向相反:Brush.linearGradient( colors = listOf(Color.White.copy(alpha = 0.15f), Color.Transparent), start = Offset.Zero, end = Offset(rollRatio * 300f, pitchRatio * 300f) )这样当手机倾斜到一侧时,高光从另一侧照过来,卡片表面像真的在转动时捕捉到环境光,立体感直接拉满。这个高光层使用drawWithContent或者drawBehind都可以,我选择了drawWithCache来记住Brush实例,减少重复创建。我把这次实现反复打磨之后,最大的体会有两个。第一,裸眼3D效果的“质感”很大程度来源于图层间的错位方向和比例,而这需要根据自己的UI重新调节,不能照搬参数。不同的配色、字体大小、图片占比都会影响最终观感,我在自己项目里调整了两三轮,才找到平衡点。第二,传感器数据的平滑处理和死区设置比旋转本身更重要,直接决定用户是否会头晕、会不会觉得“假”。如果你也想上手试一下,建议从最简单的三层错位开始,先跑通方向映射和位移,再逐步加入旋转、阴影和高光。这个套路走通了,以后看到任何App的类似效果,你都能准确判断它是怎么实现的。