You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Canvas路径重叠异常求助:射线动画残留多余线条

问题排查:射线动画重叠残留线条

我正在开发一个从屏幕左上角发射三条射线的动画,射线需展开并擦除屏幕整个“上方”区域。目前已取得进展,但两条射线出现异常重叠,仿佛互相挤压,残留一条不必要的线条,恳请帮忙排查问题原因。

当前效果说明

动画运行时,两条射线的擦除区域出现异常重叠,中间残留一条未被正确擦除的线条,呈现出互相挤压的视觉效果。

自定义View代码

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {

    companion object {
        const val ADDITIONAL_NUM_VALUE_HOLDER = "additional_num"
        const val TEXT_TOP_PADDING_VALUE_HOLDER = "text_top_padding"
    }

    private var rayPaint: Paint
    private var textPaint: TextPaint
    private var rayPath: Path = Path()

    private var additionalNum = 1f
    private var shouldAnimateRays = false

    private var textX = 20f
    private var textY = 0f

    init {
        rayPaint = Paint().apply {
            style = Paint.Style.FILL
            strokeWidth = 4f
            color = -0x1
            xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
            isAntiAlias = true
        }

        textPaint = TextPaint().apply {
            style = Paint.Style.FILL
            color = Color.BLUE
            textSize = 60f
            isAntiAlias = true
        }
    }

    override fun dispatchDraw(canvas: Canvas) {
        val bitmap = Bitmap.createBitmap(canvas.width, canvas.height, Bitmap.Config.ARGB_8888)
        bitmap.eraseColor(Color.WHITE)

        val additionalCanvas = Canvas(bitmap)
        additionalCanvas.drawColor(Color.GREEN)

        val someText = "Hello world"

        val textLayout = StaticLayout.Builder
            .obtain(someText, 0, someText.length, textPaint, additionalCanvas.width)
            .setAlignment(Layout.Alignment.ALIGN_NORMAL)
            .setLineSpacing(0.0f, 1.0f)
            .setIncludePad(true)
            .build()

        additionalCanvas.save()

        additionalCanvas.translate(textX, textY)
        textLayout.draw(additionalCanvas)

        additionalCanvas.restore()

        if (shouldAnimateRays) {
            val x = canvas.width.toFloat()
            val y = canvas.height.toFloat()

            // first ray
            rayPath.moveTo(0f, 0f)
            rayPath.lineTo(x, (y * 0.5f) - additionalNum)
            rayPath.lineTo(x, (y * 0.5f) + additionalNum)
            rayPath.lineTo(0f, 0f)

            // second ray
            rayPath.moveTo(0f, 0f)
            rayPath.lineTo(x, (y * 0.7f) - additionalNum)
            rayPath.lineTo(x, (y * 0.7f) + additionalNum)
            rayPath.lineTo(0f, 0f)

            // third ray
            rayPath.moveTo(0f, 0f)
            rayPath.lineTo((x * 0.7f) - additionalNum, y)
            rayPath.lineTo((x * 0.7f) + additionalNum, y)
            rayPath.lineTo(0f, 0f)

            additionalCanvas.drawPath(rayPath, rayPaint)
        }

        canvas.drawBitmap(bitmap, 0f, 0f, null)

        super.dispatchDraw(canvas)
    }

    fun animateShapes() {
        val textValuesHolder = PropertyValuesHolder.ofFloat(TEXT_TOP_PADDING_VALUE_HOLDER, -100f, 20f)
        val valuesHolder = PropertyValuesHolder.ofFloat(ADDITIONAL_NUM_VALUE_HOLDER, 1f, 800f)

        val animator = ValueAnimator().apply {
            setValues(valuesHolder)
            duration = 2_000
            interpolator = AccelerateDecelerateInterpolator()

            addUpdateListener {
                val addNum = it.getAnimatedValue(ADDITIONAL_NUM_VALUE_HOLDER) as Float
                additionalNum = addNum

                invalidate()
            }
        }

        val textAnimator = ValueAnimator().apply {
            setValues(textValuesHolder)
            duration = 2_000
            interpolator = AccelerateDecelerateInterpolator()

            addUpdateListener {
                val paddingTop = it.getAnimatedValue(TEXT_TOP_PADDING_VALUE_HOLDER) as Float
                textY = paddingTop

                invalidate()
            }

            doOnEnd {
                shouldAnimateRays = true
                animator.start()
            }
        }

        textAnimator.start()
    }
}

Activity代码

@AndroidEntryPoint
class CustomActivity : AppCompatActivity() {

    private lateinit var binding: ActivityCustomBinding

    protected var rootView: ViewGroup? = null
        private set

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityCustomBinding.inflate(layoutInflater)
        setContentView(binding.root)
        rootView = findViewById(android.R.id.content)

        binding.customView.animateShapes()
    }
}

布局文件

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/colorWhite">

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleX="0.8"
        android:scaleY="0.8"
        android:src="@drawable/common_google_signin_btn_icon_dark_normal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <com.app.android.view.CustomView
        android:id="@+id/customView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

问题原因及解决方法

核心问题

  1. 路径未重置:rayPath作为成员变量,每次dispatchDraw绘制射线时未清空旧路径,导致动画更新时新路径叠加在旧路径之上,最终出现重叠挤压效果。
  2. Xfermode兼容问题:PorterDuff.Mode.CLEAR在硬件加速模式下可能出现渲染异常,导致擦除效果不符合预期。

修复步骤

1. 重置路径避免叠加

在绘制射线前调用rayPath.reset()清空旧路径,同时用close()替代手动lineTo(0f,0f),路径闭合更规范:

if (shouldAnimateRays) {
    val x = canvas.width.toFloat()
    val y = canvas.height.toFloat()
    
    // 重置路径,清除之前的绘制内容
    rayPath.reset()

    // first ray
    rayPath.moveTo(0f, 0f)
    rayPath.lineTo(x, (y * 0.5f) - additionalNum)
    rayPath.lineTo(x, (y * 0.5f) + additionalNum)
    rayPath.close()

    // second ray
    rayPath.moveTo(0f, 0f)
    rayPath.lineTo(x, (y * 0.7f) - additionalNum)
    rayPath.lineTo(x, (y * 0.7f) + additionalNum)
    rayPath.close()

    // third ray
    rayPath.moveTo(0f, 0f)
    rayPath.lineTo((x * 0.7f) - additionalNum, y)
    rayPath.lineTo((x * 0.7f) + additionalNum, y)
    rayPath.close()

    additionalCanvas.drawPath(rayPath, rayPaint)
}

2. 关闭硬件加速解决Xfermode异常

在CustomView的初始化代码中添加硬件加速关闭逻辑:

init {
    // 关闭硬件加速,解决CLEAR模式渲染异常
    setLayerType(LAYER_TYPE_SOFTWARE, null)
    
    rayPaint = Paint().apply {
        // 原有画笔配置
    }

    textPaint = TextPaint().apply {
        // 原有画笔配置
    }
}

3. 可选:使用局部Path对象

每次绘制时创建新的局部Path变量,彻底避免成员变量路径累加问题:

if (shouldAnimateRays) {
    val x = canvas.width.toFloat()
    val y = canvas.height.toFloat()
    val rayPath = Path()

    // 绘制第一条射线
    rayPath.moveTo(0f, 0f)
    rayPath.lineTo(x, (y * 0.5f) - additionalNum)
    rayPath.lineTo(x, (y * 0.5f) + additionalNum)
    rayPath.close()
    additionalCanvas.drawPath(rayPath, rayPaint)

    // 重置局部路径绘制第二条射线
    rayPath.reset()
    rayPath.moveTo(0f, 0f)
    rayPath.lineTo(x, (y * 0.7f) - additionalNum)
    rayPath.lineTo(x, (y * 0.7f) + additionalNum)
    rayPath.close()
    additionalCanvas.drawPath(rayPath, rayPaint)

    // 重置局部路径绘制第三条射线
    rayPath.reset()
    rayPath.moveTo(0f, 0f)
    rayPath.lineTo((x * 0.7f) - additionalNum, y)
    rayPath.lineTo((x * 0.7f) + additionalNum, y)
    rayPath.close()
    additionalCanvas.drawPath(rayPath, rayPaint)
}

内容的提问来源于stack exchange,提问作者memeprincess

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.05 00:04:55