Android圆角闪光遮罩效果:自定义View实现与性能优化

发布时间:2026/10/15 4:11:22
Android圆角闪光遮罩效果:自定义View实现与性能优化 做榜单卡片的时候产品提了个需求卡片上要盖一层圆角遮罩一束高光从左上角扫到右下角扫完之后再回来整个过程不能挡住任何点击。这个需求落到安卓端就是标题里说的“Android 圆角闪光遮照效果”。当时团队里有人提议直接用现成的库但这类库往往参数写死换个圆角、换个扫描方向就要改源码我最后选择了自己写一个自定义View把效果稳定沉淀下来。文章会完整拆解这套实现包括圆角遮罩的边界怎么处理、闪光带用什么方案画、动画频率和卡顿怎么平衡以及我在项目里踩过的几个真坑。适合正在做卡片高光、礼包入口、会员权益位的Android工程师参考。1. 效果拆解与方案选型1.1 先把效果拆成三层看“圆角闪光遮照”这个名字其实挺直白但很多人会漏掉一层的含义。它不是一个普普通通的白条在卡片上左右移动而是由三层内容叠在一起产生的结果。第一层是底层素材可能是一张图片、一个卡片背景或者一个用其他控件画出来的复杂布局。第二层是半透明遮罩层一个覆盖整个卡片、但边缘同样带圆角的深色蒙版作用是压低底层内容的对比度让高光扫过来的时候更明显。第三层是高光带一条宽度有限、边缘渐变的白色亮条它会按一定角度扫过遮罩表面形成“光在遮罩上滑动”的视觉感受。这个效果最常用的地方是会员卡、活动入口、榜单排行这类需要引导视线的场景。它的价值不是把界面做得花哨而是用一次轻量级的动态反馈让用户知道这一块内容值得点击。1.2 三种实现方案与我的取舍我最初评估过三个实现方向最后选定的是自定义View方案下面把对比过程和选择理由一次说清楚。第一个方向是图层叠加。简单来说用一个 FrameLayout 包住卡片上面放两个子View一个半透明遮罩View一个带动画平移的闪光View。闪光View通过属性动画修改 translationX看起来像是光线扫过。这个方案的优点是代码非常少而且不需要自己处理Shader。但问题也很明显两层View的圆角很难对齐尤其是闪光条移动时亮条会超出圆角边界漏出直角或者压到隔壁内容上。如果卡片圆角还会随屏幕宽度变化处理起来就更痛苦。第二个方向是自定义View拿到Canvas直接绘制。所有遮罩和高光都通过Canvas完成圆角边界用 drawRoundRect 天然约束住动画则通过ValueAnimator驱动Shader矩阵。这样做的好处是一个View就能完成所有绘制不会出现多个View层级错位的问题也方便整体复用和参数化。第三个方向是利用 Android 12 以后的 RenderEffect 配合 BlurShader 或者 LinearGradient可以直接在效果层做圆角裁切。但问题很现实最低版本要求高如果项目要兼容低版本就得再写一套降级逻辑维护成本上不划算。我把三个方向放在一起对比过方案实现成本圆角匹配兼容性性能我的结论图层叠加低差好一般只适合临时Demo自定义View绘制中好好好最终采用RenderEffect低好差好高版本可用暂不采用自定义View最终胜出不是因为技术难度高而是因为它的边界最清晰一个View只负责一件事圆角、遮罩、闪光、动画都在同一个坐标系里完成后续维护的人不需要在两个View之间来回找逻辑。2. 从零开始搭一个自定义View2.1 先想清楚这个View要暴露哪些参数写自定义View之前先别急着敲代码把“哪些东西以后可能要调”列出来。这一步看起来不起眼却能决定这个控件是只能自己用还是能被团队其他人顺手接到自己的页面里。我最后给这个View留了这样几个参数圆角半径 cornerRadius遮罩和闪光共用同一个圆角单位是像素。遮罩颜色 maskColor如果是深色遮罩建议用带透明度的颜色比如半透明黑。高光颜色 highlightColor一般用半透明白也可以根据业务改成金色、蓝色。闪光带宽度 flashWidth相对卡片宽度的比例推荐在 0.5 到 1.0 之间。扫描角度 angle高光带和水平方向的夹角推荐 20 到 30 度。动画时长 duration从一端扫到另一端的时间。是否自动播放 autoPlay有些页面希望一直循环闪光有些只希望出现一次。这些参数最好在构造方法里暴露让调用方可以直接通过代码设置。如果项目里习惯用 XML 自定义属性也可以把每个字段都接到 attrs 里。我自己的项目两种方式都支持默认参数放在代码里XML 属性用于覆盖默认值。2.2 View骨架代码新建一个类比如叫 RoundFlashOverlayView继承自 View。因为遮照效果需要叠加在卡片上面所以这个View本身不需要复杂布局整个绘制都在 onDraw 里完成。class RoundFlashOverlayView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private var viewWidth 0f private var viewHeight 0f private val boundsRect RectF() var cornerRadius: Float 24f set(value) { field value invalidate() } var maskColor: Int Color.parseColor(#80000000) set(value) { field value invalidate() } var highlightColor: Int Color.parseColor(#88FFFFFF) set(value) { field value invalidate() } var flashWidthRatio: Float 0.7f set(value) { field value invalidate() } var scanAngle: Float 25f set(value) { field value invalidate() } private val maskPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { style Paint.Style.FILL } private val flashPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { style Paint.Style.FILL } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) viewWidth w.toFloat() viewHeight h.toFloat() boundsRect.set(0f, 0f, viewWidth, viewHeight) } }这段代码里比较重要的一个点是 onSizeChanged。View 的宽高在 onDraw 里虽然也能拿到但 onDraw 每帧都会执行如果每次绘制都重新计算 boundsRect 这类常量会造成无意义的开销。onSizeChanged 在尺寸变化时才触发一次用来初始化矩形区域最合适。2.3 画笔初始化的几个细节画笔用 Paint.ANTI_ALIAS_FLAG 打开抗锯齿这个一定要加。圆角遮罩最怕边缘锯齿特别是深色遮罩压在浅色卡片上时锯齿会非常明显。还有一个容易被忽略的点我没有关闭硬件加速也没有调用 setLayerType(LAYER_TYPE_SOFTWARE)。因为后面选的绘制方案不依赖 clipPath而 clipPath 在硬件加速下的行为很不稳定。这一点我放到第五章详细说这里只记住结论能用 Canvas 直接画圆角矩形就尽量别做裁剪性能好锯齿也少。3. 核心绘制圆角遮罩与闪光带3.1 遮罩层只有一行但这行不要写错遮罩层在 onDraw 里其实就是一行代码override fun onDraw(canvas: Canvas) { maskPaint.color maskColor canvas.drawRoundRect(boundsRect, cornerRadius, cornerRadius, maskPaint) }这行代码有几个地方容易踩坑。第一maskColor 一定要带透明度比如 #80000000不然卡片内容就完全看不清了。我见过有人直接把 maskColor 写成纯黑色结果整个卡片黑成一片高光也看不出来。第二圆角半径用的是同一个值但实际效果中如果底层卡片背景有边框阴影遮罩的圆角可能会比卡片本身的圆角小一点点这样边缘更自然。具体差值可以自己微调通常在 2 到 4 像素之间。3.2 闪光带的核心是Shader矩阵闪光带不能用普通 Paint 直接画一个白色矩形因为矩形边缘是硬的扫到圆角边缘时会突然截断看起来像贴纸。正确做法是使用 LinearGradient让亮条两侧都做透明渐变扫过来时才会有“光”的感觉。先创建一个横向的渐变颜色从透明到高光色再到透明然后通过 Matrix 旋转和平移让这条渐变带变成斜向并且能随动画移动。private fun updateFlashShader(offsetX: Float) { val flashWidth viewWidth * flashWidthRatio val shader LinearGradient( 0f, 0f, flashWidth, 0f, intArrayOf(Color.TRANSPARENT, highlightColor, Color.TRANSPARENT), floatArrayOf(0f, 0.5f, 1f), Shader.TileMode.CLAMP ) val matrix Matrix() matrix.setRotate(scanAngle, viewWidth / 2f, viewHeight / 2f) matrix.postTranslate(offsetX, 0f) shader.setLocalMatrix(matrix) flashPaint.shader shader }这里解释一下 Matrix 的作用。我创建的是水平方向的渐变如果不做任何处理闪光带就是水平扫过缺乏冲击力。通过 setRotate 把渐变坐标系旋转 25 度整条亮带就变成了斜向。postTranslate 再沿旋转后的坐标轴平移就能实现高光从左上到右下的扫动。这个过程可以在动画里反复调用每帧更新 offsetX 就行。3.3 完整绘制其实就是两次 drawRoundRect有了遮罩和闪光 ShaderonDraw 里的绘制顺序变得非常清晰override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 1. 绘制遮罩层 maskPaint.color maskColor canvas.drawRoundRect(boundsRect, cornerRadius, cornerRadius, maskPaint) // 2. 绘制闪光带 if (flashPaint.shader ! null) { canvas.drawRoundRect(boundsRect, cornerRadius, cornerRadius, flashPaint) } }看到这里你会发现整个效果没有用到一次 clipPath也没有做复杂的区域裁剪。原因很简单Canvas 的 drawRoundRect 本身就只会在圆角矩形内部填充内容Shader 虽然是整个 LinearGradient但在绘制时只会应用到圆角矩形覆盖的像素范围不会被绘制到矩形外面。所以只要把遮罩和高光都通过 drawRoundRect 画上去圆角边界天然就被约束住了。3.4 为什么不需要clipPath很多人在做类似效果时第一反应是先画一个 Path然后 canvas.clipPath再绘制高光。这个思路没有错但在安卓的硬件加速环境下clipPath 对非矩形路径的支持并不稳定某些机型上会直接失效甚至会强制关闭硬件加速导致掉帧。用 drawRoundRect 加 Shader 的方案等于把“裁剪”变成了“填充边界”既保留了圆角约束又不需要额外裁剪操作。逻辑上更简单性能上也更友好。这也是我在选择方案时最看重的一点。4. 动画控制与参数调优4.1 用ValueAnimator驱动offset闪光扫动其实只需要一个变量offsetX。它表示闪光带在旋转坐标系里的横向偏移量。动画开始时 offsetX 在负值区域此时闪光带在卡片左上方之外用户看不到随着动画推进offsetX 增大闪光带进入卡片区域扫过之后又从右下方离开。private var animator: ValueAnimator? null fun startFlash() { animator?.cancel() val flashWidth viewWidth * flashWidthRatio val animator ValueAnimator.ofFloat(-flashWidth, viewWidth flashWidth).apply { duration 1600L interpolator DecelerateInterpolator() repeatMode ValueAnimator.RESTART repeatCount ValueAnimator.INFINITE addUpdateListener { animation - val offsetX animation.animatedValue as Float updateFlashShader(offsetX) invalidate() } } this.animator animator animator.start() }注意动画的起始值和结束值。起始值不能是 0为了让光带从卡片外进入应该是 -flashWidth结束值也不能是 viewWidth因为光带宽度不是 0要让它完全移出卡片右下方应该是 viewWidth flashWidth。4.2 角度、宽度、时长怎么配这几个参数直接决定了效果好不好看我把自己调过几轮的经验列在这里。角度建议设置在 20 到 30 度之间。角度太小闪光接近水平看起来像是脏污横条角度太大闪光几乎垂直扫过范围变窄视觉存在感会下降。闪光带宽度建议是卡片宽度的 0.5 到 1.0 倍。太窄会变成一条线没有“光”的感觉太宽会把整个卡片扫成一片白失去层次。我常用 0.7 倍。时长建议在 1200 到 1800 毫秒之间。太短闪光扫得太快用户还没看清就结束了太长扫动拖沓阅读路径会被打断。另外如果卡片上文字比较多遮罩 alpha 不要超过 40%。不然文字会显得很脏。4.3 加一点人类喜欢的非线性闪光动画如果全程匀速视觉上会很机械。我习惯在进入时用加速插值器扫出后立刻减速或者直接用正弦插值让整体节奏先快后慢。上面的代码里用了 DecelerateInterpolator光带在进入用户视线时速度比较快尾部慢慢变缓符合人眼对“扫光”的预期。如果业务场景要求高光先停顿一下再扫可以通过插值器做分段延迟。更简单的方法是设置 startDelay 或者 repeatDelay让两轮动画之间留白。这个可以根据实际效果随手调。5. 性能优化与常见坑5.1 圆角边缘出现黑边或白边这是做圆角遮罩最常遇到的问题。深色遮罩在圆角边缘会出现一条黑色细线高光在圆角边缘会出现白色细线。出现黑边的原因是遮罩层的绘制区域和底层卡片圆角没有完全重合。比如底层卡片的圆角是 24 像素遮罩的圆角也是 24 像素理论上会重合但如果底层卡片有任何 padding 或者阴影边缘就会漏出来。解决办法很简单遮罩的 cornerRadius 比卡片背景的 cornerRadius 小 2 到 4 像素让遮罩稍微“缩”进卡片内部就不会漏边了。如果还是有白边检查是不是高光色太亮可以在高光渐变的边缘多加一层透明过渡。5.2 在RecyclerView里动画闪烁这个效果一旦用在列表里很容易遇到一个典型问题卡片快速滑动时动画反复重启闪光层闪烁得让人头疼。根因是 RecyclerView 的 ViewHolder 复用时View 会被重新 bind如果每次都调用 startFlash就会取消上一次动画再重新开始造成闪烁。我建议在 ViewHolder 的 onViewDetachedFromWindow 里取消动画在 onViewAttachedToWindow 里重新启动。同时配合 setVisible 或者 isShown 判断只有真正可见时才开始扫光。如果列表卡顿严重可以考虑把动画次数改成有限次而不是无限循环。毕竟无限循环绘制在列表里的成本会随可见数量成倍增加。5.3 多View同时动画的帧率优化整页如果有多个卡片都在闪光每一帧会有多个 View invalidate很容易出现掉帧。一个有效的优化方向是控制 invalidate 区域。如果闪光带只影响卡片的一部分理论上只需要局部刷新那个区域。但写起来比较复杂收益不一定明显。我实际测试下来更稳的做法是控制同时动画的 View 数量页面里最多让两到三个卡片同时闪光其他卡片挂起动画等可见时再启动。另外如果 View 尺寸不变闪光带 Shader 里的 Matrix 更新其实是轻量操作不会触发重新创建大对象。唯一需要注意的是不要直接在 onDraw 里 new LinearGradient要把 Shader 创建放到动画更新方法里并且尽量复用同一套颜色数组。5.4 常见问题速查表问题现象可能原因解决办法遮罩圆角边缘有黑线遮罩圆角与卡片圆角不一致把遮罩圆角调小2到4像素闪光带太粗像贴纸渐变宽度不合理调整flashWidthRatio到0.7左右闪光扫动生硬缺少渐变或者使用普通矩形Paint改用LinearGradient三色渐变列表滑动时动画闪烁ViewHolder复用未取消动画在detach/attach生命周期里管理动画低端机掉帧同时动画的View太多控制可见数量和动画频率圆角直角残留用了clipPath且硬件加速不兼容改成drawRoundRect Shader方案6. 落地经验与最后的建议6.1 让事件穿过遮罩层这个效果覆盖在卡片上以后默认情况下会影响点击事件。为了让手指仍然点到卡片内容需要把这个 View 设置为不可点击、不可聚焦并且 onTouchEvent 直接返回 false。否则卡片上原有的点击、水波纹反馈都会失效。isClickable false isFocusable false isFocusableInTouchMode false如果是用 FrameLayout 叠加记得这个 View 要放在卡片的上面一层但不要拦截触摸事件。这样卡片内容点击正常只有视觉上被蒙上一层闪光遮罩。6.2 什么时候不要用这个效果不是所有卡片都适合闪光扫动。如果页面信息密度比较高用户需要快速浏览多条内容这种效果会制造额外注意力负担。还有如果卡片是列表里的小尺寸元素闪光带也容易过宽看起来像一张白纸在晃动。我自己定了一条经验只有当卡片是页面里的“主角”时才值得上圆角闪光遮照效果。比如会员权益卡、付费课程入口、限时活动头图。普通列表项尽量不用。6.3 最后分享一套我调好的参数如果你不想从零开始试可以直接用这套初始值cornerRadius 对应卡片背景圆角maskColor 用 #66000000 到 #99000000highlightColor 用 #66FFFFFFflashWidthRatio 用 0.7scanAngle 用 25duration 用 1500 毫秒。拿到这套参数后先截图对比不同 alpha 下的卡片可读性再扫一次看闪光宽度是否刺眼。实际调参时我通常先调角度再调宽度最后才动 alpha。因为角度和宽度决定了“光”的形状alpha 决定最终观感颜色一旦定下来就不太需要反复动。这个效果从思路到完整实现并不复杂核心就一句话用 drawRoundRect 画边界用 LinearGradient 画光带用 ValueAnimator 驱动偏移。把这三点处理好任何圆角卡片都能获得同样顺滑的闪光遮照体验。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询