
仿某租房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里定义了一个DeviceStatedata 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: StateFlowDeviceState _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.densitygraphicsLayer { 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还在慢慢跟。我试过alpha0.01完全像慢动作。后来调到0.08~0.1跟手度好了很多。如果你还是觉得延迟可以调大alpha到0.15或者在滤波后做一阶微分补偿。另一种常见原因是你在UI侧又套了一层动画双重平滑导致延迟叠加。去掉animateTo直接赋值的响应是最快的。5.2 卡片边缘露出底图怎么解决这是背景层位移方向或尺寸设置不对。我记得最开始把背景层也设成正方向位移边缘直接露白。正确做法是背景层反向位移且尺寸放大放大系数至少是最大位移比例加1。比如最大位移是40dp卡片宽度是300dp放大比例要大于2*40/3001≈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.shadowanimateDpAsState效果在低版本上也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)这里有个经验clipfalse才能让阴影投射在卡片外但也会导致圆角边缘之外的内容露出来所以卡片内容本身要做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的类似效果你都能准确判断它是怎么实现的。