
如果要评一个Android开发中被问得最多的高频需求RecyclerView做3D画廊效果绝对排得上号。产品经理一句“我想要一个卡片横着滑、两边带旋转缩放、像苹果CoverFlow那样的效果”就能让不少人从入门到放弃。这篇我不绕弯子直接围绕RecyclerView子视图叠加、3D画廊效果、高级动效和Android 3D坐标系这几个点展开把原理和踩坑一次性说清楚。文章适合三类人看一是刚接触RecyclerView自定义效果、想搞懂3D变换的小白二是已经会写基础列表但被“卡片叠在一起、旋转、吸附”这类需求卡住的中级开发者三是想把手上的画廊效果优化到不卡顿、不穿帮、不被产品挑刺的高级开发者。文中所有代码都基于实际项目中的常见写法你可以直接复制到工程里跑通再根据自己的设计去调参数。1. 坐标系这东西不搞清楚后面全是玄学标题里的“Android 3D坐标系介绍”不是凑数的后面所有旋转、缩放、视觉错位全都建立在坐标系理解上。很多人写3D画廊遇到奇怪的现象比如转着转着方向反了、旋转中心不在卡片中心、点击区域和视觉位置对不上根子都是坐标系没理顺。1.1 View坐标系和数学坐标系的差异原点在左上角先看一张最基础的关系图Android里每个View都有自己的一套坐标原点在View左上角。X轴正方向向右Y轴正方向向下Z轴正方向指向屏幕外也就是朝向观察者你注意这里和中学数学坐标系不一样Y轴是反的。我最早做3D切换时想把一个View“往上抬”下意识给translationY传了负值结果它反而往下跑就是这个原因。落到RecyclerView里每个item也是一个View它有自己的坐标。关键点在于item的getX()、getY()不是全局坐标而是相对于RecyclerView容器的坐标。举例child.getX() child.getWidth() / 2f得到的是这个item中心点相对于RecyclerView左上角的横坐标recyclerView.getWidth() / 2f是RecyclerView自身横向中心点的X坐标这两个值相减就是item中心与RecyclerView中心的水平偏移。写3D画廊时这个差值就是所有动效的数据源头后面第3节我会单独讲怎么把它归一化。1.2 Camera类与Matrix旋转中心与视点补偿如果只用View自带的setRotationY其实碰不到Camera类但了解它对你调试会有大帮助。android.graphics.Camera是一个可以操作3D变换的工具类注意它和我们理解的“拍照相机”不是一回事可以理解成它描述的是“以什么角度去看这个View”。常见用法Camera camera new Camera(); Matrix matrix new Matrix(); camera.save(); camera.rotateY(-30f); // 绕Y轴转30度 camera.getMatrix(matrix); camera.restore(); // 重点Camera默认旋转中心是View左上角必须平移到中心再移回来 matrix.preTranslate(-viewWidth / 2f, -viewHeight / 2f); matrix.postTranslate(viewWidth / 2f, viewHeight / 2f); canvas.concat(matrix);这段代码的作用是先把绘制坐标系平移到View中心旋转再平移回左上角。如果不做平移绕Y轴旋转时View会绕着左上角转效果直接崩坏。这就是为什么网上很多自定义3D效果看起来歪歪扭扭多半是少了pre/post translate。1.3 用setRotationY还是Canvas.concat这是个触摸命中问题实战中我强烈建议优先使用View属性方法setRotationX、setRotationY、setTranslationZ、setScaleX、setScaleY而不要自己重写onDraw用Canvas.concat。原因很实际View自带属性在改变绘制结果的同时会同步更新触摸命中区域、阴影、Z轴排序等逻辑。而如果自己用CameraMatrix去画你只是改了“画出来的样子”触摸事件还在原来的矩形区域里分发。旋转到一定角度后用户看到卡片边缘在这里点上去却完全没有反应——这个体感非常差。如果你看到有人吐槽“RecyclerView嵌套点击事件有时候无反应”一半以上是这个问题item里放Button你手写Canvas变换按钮的点击区域还留在未旋转的位置上。改用setRotationY这类属性后再配合Z轴调整层叠顺序点击基本就不会乱。2. 子视图叠加先分清“布局层叠加”和“视觉层叠加”“子视图叠加”这个需求其实有三种做法大多数文章混在一起讲很多人在实现时也把逻辑搅在一起。我建议先把目标拆成两类布局层的叠加和视觉层的叠加。3D画廊属于典型的视觉层叠加而卡片抽屉、抽卡展示属于布局层叠加。两者实现方式完全不同。2.1 视觉层叠加用LinearLayoutManager就够了所谓视觉层叠加就是RecyclerView每个item在布局上仍然是一个挨一个排开并没有真正重叠但我们通过绘制阶段的旋转、缩放、位移让视觉结果看起来像前后叠着。这种做法的最大优势是不需要自定义LayoutManager。LinearLayoutManager已经帮你处理好了测量、布局、回收复用、滑动你只需要在滚动回调里遍历所有可见子View按照他们相对RecyclerView中心的位置来调整3D属性就行。private void updateGalleryTransform(RecyclerView recyclerView) { float centerX recyclerView.getWidth() * 0.5f; for (int i 0; i recyclerView.getChildCount(); i) { View child recyclerView.getChildAt(i); float childCenter child.getX() child.getWidth() * 0.5f; float distance childCenter - centerX; float factor Math.max(-1f, Math.min(1f, distance / centerX)); child.setPivotX(child.getWidth() * 0.5f); child.setPivotY(child.getHeight() * 0.5f); child.setRotationY(factor * -35f); child.setScaleX(1f - Math.abs(factor) * 0.25f); child.setScaleY(1f - Math.abs(factor) * 0.25f); child.setTranslationZ((1f - Math.abs(factor)) * 200f); child.setAlpha(1f - Math.abs(factor) * 0.55f); } }这段代码就是整个3D画廊的核心。它做的是每滑一帧遍历当前所有可见的item计算它偏离RecyclerView中心的比例factor然后把旋转角度、缩放比例、透明度、Z轴高度都映射到这个比例上。注意我在onScrolled中调用它而不是在onBindViewHolder里设置。原因是onBindViewHolder只负责数据绑定在滚动过程中被复用的View会不断触发bind如果在bind里做3D变换会出现两个问题一是频繁bind导致性能浪费二是复用瞬间每张卡片都被重置到默认状态肉眼可见地“闪一下”。2.2 布局层叠加重写layoutChunk实现卡片抽屉如果需求是做“卡片抽屉”或“手风琴菜单”即每个卡片只露出一条边后面的卡片压在前面的卡片上面这种就属于布局层叠加。此时用ItemDecoration做负间距会有兼容性风险因为官方对负间距的行为没有做严格保证某些版本会计算异常。更稳的方式是自定义LayoutManager。核心思路是在onLayoutChildren阶段把所有子View放到同一个起点然后再按position偏移一小段距离。下面是一个简化版本public class StackLayoutManager extends RecyclerView.LayoutManager { private static final int VISIBLE_COUNT 4; private int overlayOffset; public StackLayoutManager(int overlayOffset) { this.overlayOffset overlayOffset; } Override public RecyclerView.LayoutParams generateDefaultLayoutParams() { return new RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); } Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { if (getItemCount() 0) { detachAndScrapAttachedViews(recycler); return; } detachAndScrapAttachedViews(recycler); int visibleCount Math.min(getItemCount(), VISIBLE_COUNT); for (int i 0; i visibleCount; i) { View child recycler.getViewForPosition(i); addView(child); measureChildWithMargins(child, 0, 0); int width getDecoratedMeasuredWidth(child); int height getDecoratedMeasuredHeight(child); int left (getWidth() - width) / 2; int top (getHeight() - height) / 2 - i * overlayOffset; layoutDecoratedWithMargins(child, left, top, left width, top height); } } Override public boolean canScrollVertically() { return false; } Override public boolean canScrollHorizontally() { return false; } }这个LayoutManager能让你所有卡片叠在同一个位置并通过top向上递增看起来像后面卡片的上边缘从前面卡片背后露出一条。真实项目里你要是想让它支持滑动还得重写scrollVerticallyBy并配合scrollBy做增量布局复杂度会上去不少。所以我在这里想强调一句如果你的目标只是3D画廊不要一上来就写自定义LayoutManager。先把LinearLayoutManager方案跑通绝大多数需求都能满足。2.3 一个能直接跑的最小实现简单列一下让上面updateGalleryTransform跑起来的最小代码组合。XML布局里RecyclerView配置androidx.recyclerview.widget.RecyclerView android:idid/galleryRecycler android:layout_widthmatch_parent android:layout_height240dp android:clipChildrenfalse android:clipToPaddingfalse android:overScrollModenever android:paddingStart48dp android:paddingEnd48dp /Activity里LinearLayoutManager lm new LinearLayoutManager(this, RecyclerView.HORIZONTAL, false); recyclerView.setLayoutManager(lm); recyclerView.setAdapter(adapter); recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(NonNull RecyclerView recyclerView, int dx, int dy) { updateGalleryTransform(recyclerView); } });clipChildrenfalse和clipToPaddingfalse很关键前者允许item绘制到RecyclerView边界以外后者让padding区域的item在滚动时也能被看到否则卡片滑到边缘时会出现“突然被裁断”的穿帮感。3. 3D画廊的关键算法从滚动偏移到3D参数映射很多教程把3D画廊的实现写得很玄乎其实核心就是一个归一化映射。理解了这一步后面不管改什么效果都是水磨工夫。3.1 归一到[-1,1]的factor怎么算前面代码里用了distance / centerX这个值本质上就是一个无量纲比例。当item中心恰好位于RecyclerView中心时distance为0factor为0卡片呈现最正的姿势当item滑到最左侧边缘时distance为负的RecyclerView一半宽度factor接近-1反过来接近1。需要注意一个边界问题distance / centerX的结果可能会超出[-1,1]因为item中心可能滑出RecyclerView范围。所以最好用Math.max(-1f, Math.min(1f, factor))把范围限制住避免旋转角度过大、缩放变成负值这种视觉崩溃。如果RecyclerView设置了左右padding计算中心X时需要把padding考虑进去float centerX recyclerView.getPaddingLeft() (recyclerView.getWidth() - recyclerView.getPaddingLeft() - recyclerView.getPaddingRight()) * 0.5f;这里padding会同时影响视觉中心的计算不处理的话吸附居中的位置会偏。3.2 缩放、旋转、透明度、景深的权重分配真正好看的3D画廊不是所有参数线性变化就完事需要根据视觉效果微调每个参数的系数。我给一组经过多次调优的起始值你可以照着改。参数偏移0时偏移最大时曲线类型备注rotationY0°35°~40°线性角度过大文字镜像中缝穿帮scaleX/scaleY1.00.75~0.8线性低于0.7观感变差alpha1.00.2~0.5线性太透明看不清卡片内容translationZ3000线性控制阴影和层叠顺序需要API21translationX00~40dp可用sin曲线让两边卡片再向外散开具体到代码里就是调整系数child.setRotationY(factor * -35f); child.setScaleX(1f - Math.abs(factor) * 0.25f); child.setScaleY(1f - Math.abs(factor) * 0.25f); child.setAlpha(1f - Math.abs(factor) * 0.55f); child.setTranslationZ((1f - Math.abs(factor)) * 200f);有人会问setTranslationZ是干什么的。在Android 5.0之后View有elevation和translationZ两个属性它们共同决定阴影深度和绘制顺序。在RecyclerView中让中心卡片获得更高的Z值系统绘制时就会天然把它放在上层省去手动调bringToFront()的麻烦。Z值越高它投下的阴影越明显视觉上也更“浮起来”。3.3 居中吸附用PagerSnapHelper还是自定义SnapHelper3D画廊通常要求松手后自动吸附到最中间那个item否则卡片停在半道很难看。如果item宽度等于屏宽直接用PagerSnapHelper没问题。但画廊里的卡片一般不是全屏宽PagerSnapHelper按“一页一屏”的语义去计算吸附距离行为会很奇怪。正确做法是自定义一个SnapHelper让它计算离中心最近的View并吸附。public class CenterSnapHelper extends SnapHelper { Override public int[] calculateDistanceToFinalSnap(NonNull RecyclerView.LayoutManager layoutManager, NonNull View targetView) { int[] out new int[2]; if (layoutManager instanceof LinearLayoutManager) { int childCenter (int) (targetView.getX() targetView.getWidth() / 2f); int containerCenter layoutManager.getWidth() / 2; out[0] childCenter - containerCenter; } return out; } Nullable Override public View findSnapView(LayoutManager layoutManager) { if (!(layoutManager instanceof LinearLayoutManager)) return null; int containerCenter layoutManager.getWidth() / 2; View closest null; int minDistance Integer.MAX_VALUE; for (int i 0; i layoutManager.getChildCount(); i) { View child layoutManager.getChildAt(i); int childCenter (int) (child.getX() child.getWidth() / 2f); int distance Math.abs(childCenter - containerCenter); if (distance minDistance) { minDistance distance; closest child; } } return closest; } }使用方法就是CenterSnapHelper snapHelper new CenterSnapHelper(); snapHelper.attachToRecyclerView(recyclerView);这个实现有几个细节值得注意。第一个是findSnapView返回的是当前最接近中心的ViewcalculateDistanceToFinalSnap返回的是它还需要滚动的距离。第二个是你的item宽度不能超过RecyclerView实际宽度否则吸附后已经滑到旁边的item仍然占据可见区域逻辑上没问题但视觉上会拥挤。第三个是如果卡片间距不是均匀的等宽建议在findSnapView里同时考虑item之间的间距按“中心最接近”而不是“第一个最接近”的逻辑去选。4. 动效落地中的几个大坑和对应解法这节内容是实打实从项目里踩出来的。很多时候功能写完了一上真机各种怪问题下面这几个问题最容易碰到。4.1 clipChildren和clipToPadding不设的话旋转就被截断3D画廊一个经典穿帮就是卡片转起来后边缘突然被切掉一截。原因是系统默认会裁剪子View超出父容器边界的部分。尤其是卡片在RecyclerView边缘时旋转后的投影区域比原矩形大超出边界就被砍。需要把RecyclerView本身以及它所有父级容器的clipChildren都设为false!-- Activity根布局 -- LinearLayout android:layout_widthmatch_parent android:layout_heightmatch_parent android:clipChildrenfalse !-- RecyclerView的外层容器 -- FrameLayout android:layout_widthmatch_parent android:layout_height240dp android:clipChildrenfalse androidx.recyclerview.widget.RecyclerView android:layout_widthmatch_parent android:layout_heightmatch_parent android:clipChildrenfalse android:clipToPaddingfalse / /FrameLayout /LinearLayout注意根布局、中间容器、RecyclerView三层都要设置。只要有一层裁剪卡片滑到边缘就会被截而且这种问题在布局里看不出来必须上机滑动才看得到。你可以在开发阶段给根布局加个临时背景色这样卡片穿帮时一眼就能识别是哪一层裁的。4.2 硬件加速和LayerType锯齿模糊的取舍3D旋转本质上是每帧做矩阵变换对RecyclerView的性能压力不小。在性能不足的机器上旋转时卡片边缘容易出现锯齿或者文字轻微模糊。一个常用做法是给当前可见的item设置硬件层if (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { child.setLayerType(View.LAYER_TYPE_HARDWARE, null); }硬件层把View先渲染到一个独立的GPU纹理上旋转时只是纹理变换省掉了每帧重新走一遍绘制流程。但要注意硬件层在旋转过程中占用的显存比较大如果列表item数量多超过7个建议只在滚动时开启硬件层滚动停止后恢复为LAYER_TYPE_NONE否则内存峰值会很高。recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrollStateChanged(NonNull RecyclerView recyclerView, int newState) { if (newState RecyclerView.SCROLL_STATE_IDLE) { for (int i 0; i recyclerView.getChildCount(); i) { recyclerView.getChildAt(i).setLayerType(View.LAYER_TYPE_NONE, null); } } else { for (int i 0; i recyclerView.getChildCount(); i) { recyclerView.getChildAt(i).setLayerType(View.LAYER_TYPE_HARDWARE, null); } } } });这里有一个取舍点硬件层在部分老机型上旋转大尺寸View时反而会出现撕裂感。如果你测试发现锯齿或模糊没法接受可以改用LAYER_TYPE_SOFTWARE重绘一帧虽然性能弱一些但有些绘制效果比如阴影渐变反而更细腻。具体用哪种真机多测几个型号再定。4.3 回收复用带来的状态残留问题RecyclerView的ViewHolder会被回收和复用这是它高性能的关键但也是3D状态残留的源头。一个item滑出屏幕又滑回来时它的rotationY、scale、alpha等属性可能还停留在“上一辈子”的值上结果画面就好像被谁偷偷改过。解决办法是在onBindViewHolder里重置这些绘制属性Override public void onBindViewHolder(NonNull GalleryHolder holder, int position) { holder.itemView.setRotationY(0f); holder.itemView.setScaleX(1f); holder.itemView.setScaleY(1f); holder.itemView.setAlpha(1f); holder.itemView.setTranslationZ(0f); // 再绑定数据 }同时保证滚动监听里的updateGalleryTransform在onScrolled和onScrollStateChangedIDLE状态时里都会被调用确保静止后所有item的视觉状态仍然和中心位置一致。实践中我遇到过一个问题滚动停止后中心卡片状态是对的但复用的卡片保留了偏移时的透明度就是因为在onBindViewHolder里只重置了数据没重置View属性。另外一个容易被忽略的坑是setElevation和translationZ对绘制顺序的影响。如果给每个item设置了不同的elevation而RecyclerView本身没有关闭Z排序后续滚动过程中item的绘制顺序可能不是你想要的看起来像“后面的卡片突然盖到前面卡片上面”。一般我会统一关闭item的elevation只依赖translationZ控制视觉层次child.setElevation(0f); child.setTranslationZ((1f - Math.abs(factor)) * 200f);5. 什么时候才需要重写LayoutManager叠加抽屉效果实战把3D画廊做完你可能会想既然每帧都要遍历子View做变换是不是自己写LayoutManager更高效答案是否定的。接下来我详细讲一下边界以及真正需要布局层叠加时该怎么写。5.1 为什么3D画廊不推荐自定义LayoutManager我看到很多开源项目为了做3D画廊上来就重写LayoutManager结果维护成本很高关键是没有解决任何实际问题。原因很简单3D画廊的item在布局层仍然是顺序排列你可以用LinearLayoutManager的测量、布局、回收机制视觉变换是在“已布局的item上做二次绘制”这部分完全可以通过View属性叠加。绕开系统LayoutManager等于你需要重新实现item测量与布局回收池的复用时机滚动时增量布局与detach/attachSnapHelper与LayoutManager的接口对接这些逻辑最少跑两周才能稳定而且收益只是省掉了每次遍历十几个可见View的那点点开销。这点开销在RecyclerView层面根本构不成性能瓶颈真正耗时的是绘制过程本身。我做项目时给自己定了一条规则能用系统LayoutManager解决的视觉需求坚决不自定义。只有当我需要item在布局层重叠、并且滚动偏移计算依赖自定义布局位置时才考虑重写LayoutManager。5.2 一个轻量级layoutChunk实现叠加布局如果确实遇到卡片抽屉、卡片夹子、观众席这类布局层叠加需求建议从layoutChunk下手。RecyclerView的LayoutManager核心布局入口是onLayoutChildren我前面给的StackLayoutManager就是一个起点。一个简化版layoutChunk思路是这样的每一帧只管一个位置上的子View让子View中心对齐父容器中心再根据position叠加偏移量。对于叠加效果关键是限制可见子View的数量不能被所有item都叠进来。我用的策略是取getItemCount()和VISIBLE_COUNT的较小值避免数量爆炸。比较常见的商业APP效果是“中间卡片在最上面前后卡片依次压进抽屉”这个效果其实是布局层和视觉层的结合体。布局层让item叠放视觉层再对非顶层item做缩小、压暗处理。这类需求的演进空间很大建议先把上面基础版本跑通再加缩放/透明度变化。5.3 扩展点仿CoverFlow、卡片轮播、观众席效果有了3D坐标系和factor归一化概念接下来很多效果都是同一个思路的变体。仿CoverFlow把rotationY的映射从线性改成sin曲线让中心附近角度变化平缓、两侧角度迅速加大视觉更柔和。同时给item加上镜像倒影这个就需要你在item布局里自己画一个翻转的副本。卡片轮播调整scale的衰减常数让非当前卡片缩小到0.85左右即可整体观感比大幅缩小更耐看透明度不要低于0.4否则卡片内容完全看不清用户会认为是bug。观众席效果观察者视角从斜上方俯视核心是rotationX配合translationYtranslationZ的组合让后排卡片看起来“低一层”。这时Z轴的映射方式会和画廊不同中心卡片不再是最高的而是离观察点更远的卡片Z值更小。这些扩展方向本质上都在问一个问题我的手势滑动和3D参数之间用什么函数关系。一旦你把factor这个中间量理解透所有效果都只是换映射规则而已。我最后分享一个调参的小技巧先把rotationY、alpha、translationZ全部去掉只保留scale和translationX看层次关系对不对然后单独调scale从1.0往下降到0.7每个档位停一帧找到视觉效果最舒服的位置最后再打开rotationY从15度加到40度观察中缝和文字镜像。这样分步调能快速判断是参数算错还是绘制层级出问题比一次性把所有特效全开再盲调要高效得多。