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

Android中Timer与TimerTask引发内存泄漏的问题求助

解决TimerTask导致的Android内存泄漏问题

问题背景

在自定义View(LinearDotsLoader)中使用Timer()和scheduleAtFixedRate()实现圆点加载动画,已在视图不可见时调用Timer.cancel(),但LeakCanary检测到TimerTask存在内存泄漏,导致View无法被GC回收。

相关代码片段

定时器调度代码

private fun scheduleTimer() {
    timer = Timer()
    timer?.scheduleAtFixedRate(object : TimerTask() {
        override fun run() {
            if (isSingleDir) {
                selectedDotPos++
                if (selectedDotPos > noOfDots) {
                    selectedDotPos = 1
                }
            } else {
                if (isFwdDir) {
                    selectedDotPos++
                    if (selectedDotPos == noOfDots) {
                        isFwdDir = !isFwdDir
                    }
                } else {
                    selectedDotPos--
                    if (selectedDotPos == 1) {
                        isFwdDir = !isFwdDir
                    }
                }
            }

            (scanForActivity(context))?.runOnUiThread {
                invalidate()
            }
        }
    }, 0, animDur.toLong())
}

视图可见性变化处理

override fun onVisibilityChanged(changedView: View, visibility: Int) {
    super.onVisibilityChanged(changedView, visibility)

    if (visibility != VISIBLE) {
        timer?.cancel()
    } else if (shouldAnimate) {
        scheduleTimer()
    }
}

完整自定义View代码

class LinearDotsLoader : DotsLoaderBaseView
{
    private var timer: Timer? = null

    var isSingleDir = true

    private var diffRadius: Int = 0
    private var isFwdDir = true

    constructor(context: Context) : super(context) {
        initCordinates()
        initPaints()
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        initAttributes(attrs)
        initCordinates()
        initPaints()
        initShadowPaints()
    }

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initAttributes(attrs)
        initCordinates()
        initPaints()
        initShadowPaints()
    }

    override fun initAttributes(attrs: AttributeSet) {

        super.initAttributes(attrs)

        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LinearDotsLoader, 0, 0)

        this.noOfDots = typedArray.getInt(R.styleable.LinearDotsLoader_loader_noOfDots, 3)

        this.selRadius = typedArray.getDimensionPixelSize(R.styleable.LinearDotsLoader_loader_selectedRadius, radius + 10)

        this.dotsDistance = typedArray.getDimensionPixelSize(R.styleable.LinearDotsLoader_loader_dotsDist, 15)

        this.isSingleDir = typedArray.getBoolean(R.styleable.LinearDotsLoader_loader_isSingleDir, false)
        this.expandOnSelect = typedArray.getBoolean(R.styleable.LinearDotsLoader_loader_expandOnSelect, false)

        typedArray.recycle()
    }

    override fun initCordinates() {
        diffRadius = this.selRadius - radius

        dotsXCorArr = FloatArray(this.noOfDots)

        //init X cordinates for all dots
        for (i in 0 until noOfDots) {
            dotsXCorArr[i] = (i * dotsDistance + (i * 2 + 1) * radius).toFloat()
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        val calWidth: Int
        val calHeight: Int

        if (expandOnSelect) {
            calWidth = (2 * this.noOfDots * radius + (this.noOfDots - 1) * dotsDistance + 2 * diffRadius)
            calHeight = 2 * this.selRadius
        } else {
            calHeight = 2 * radius
            calWidth = (2 * this.noOfDots * radius + (this.noOfDots - 1) * dotsDistance)
        }
        setMeasuredDimension(calWidth, calHeight)
    }

    override fun onVisibilityChanged(changedView: View, visibility: Int) {
        super.onVisibilityChanged(changedView, visibility)

        if (visibility != VISIBLE) {
            timer?.cancel()
            timer?.purge()
        } else if (shouldAnimate) {
            scheduleTimer()
        }
    }

    private fun scheduleTimer() {
        timer = Timer()
        val dotsTimerTask = DotsTimerTask()
        timer?.scheduleAtFixedRate(dotsTimerTask, 0, animDur.toLong())
    }


    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawCircle(canvas)
    }

    private fun drawCircle(canvas: Canvas) {
        for (i in 0 until noOfDots) {

            var xCor = dotsXCorArr[i]
            if (expandOnSelect) {
                if (i + 1 == selectedDotPos) {
                    xCor += diffRadius.toFloat()
                } else if (i + 1 > selectedDotPos) {
                    xCor += (2 * diffRadius).toFloat()
                }
            }

            var firstShadowPos: Int
            var secondShadowPos: Int

            if ((isFwdDir && selectedDotPos > 1) || selectedDotPos == noOfDots) {
                firstShadowPos = selectedDotPos - 1
                secondShadowPos = firstShadowPos - 1
            } else {
                firstShadowPos = selectedDotPos + 1
                secondShadowPos = firstShadowPos + 1
            }

            if (i + 1 == selectedDotPos) {
                selectedCirclePaint?.let {
                    canvas.drawCircle(
                        xCor,
                        (if (expandOnSelect) this.selRadius else radius).toFloat(),
                        (if (expandOnSelect) this.selRadius else radius).toFloat(),
                        it
                    )
                }
            } else if (showRunningShadow && i + 1 == firstShadowPos) {
                canvas.drawCircle(
                    xCor,
                    (if (expandOnSelect) this.selRadius else radius).toFloat(),
                    radius.toFloat(),
                    firstShadowPaint)
            } else if (showRunningShadow && i + 1 == secondShadowPos) {
                canvas.drawCircle(
                    xCor,
                    (if (expandOnSelect) this.selRadius else radius).toFloat(),
                    radius.toFloat(),
                    secondShadowPaint)
            } else {
                defaultCirclePaint?.let {
                    canvas.drawCircle(
                        xCor,
                        (if (expandOnSelect) this.selRadius else radius).toFloat(),
                        radius.toFloat(),
                        it
                    )
                }
            }

        }
    }

    var dotsDistance: Int = 15
        set(value) {
            field = value
            initCordinates()
        }

    var noOfDots: Int = 3
        set(noOfDots) {
            field = noOfDots
            initCordinates()
        }

    var selRadius: Int = 38
        set(selRadius) {
            field = selRadius
            initCordinates()
        }

    var expandOnSelect: Boolean = false
        set(expandOnSelect) {
            field = expandOnSelect
            initCordinates()
        }

    private fun scanForActivity(context: Context?): Activity? {
        return when (context) {
            null -> null
            is Activity -> context
            is ContextWrapper -> scanForActivity(context.baseContext)
            else -> null
        }
    }

    private fun updateSelectedDot()
    {
        if (isSingleDir) {
            selectedDotPos++
            if (selectedDotPos > noOfDots) {
                selectedDotPos = 1
            }
        } else {
            if (isFwdDir) {
                selectedDotPos++
                if (selectedDotPos == noOfDots) {
                    isFwdDir = !isFwdDir
                }
            } else {
                selectedDotPos--
                if (selectedDotPos == 1) {
                    isFwdDir = !isFwdDir
                }
            }
        }

        (scanForActivity(context))?.runOnUiThread {
            invalidate()
        }
    }

    private inner class DotsTimerTask: TimerTask(){
        override fun run() {
            updateSelectedDot()
        }
    }

}

解决方案

原因分析

  1. 内部类强引用外部View:DotsTimerTask是非静态内部类,默认持有LinearDotsLoader的强引用,即使调用Timer.cancel(),未执行的任务仍会保留该引用,导致View无法被GC回收。
  2. 定时器销毁不彻底:仅在onVisibilityChanged中取消定时器,未处理View从Window移除的场景(如Activity销毁),存在遗漏。

修复步骤

  1. 将内部类改为静态内部类+WeakReference
    静态内部类不持有外部类强引用,通过WeakReference弱引用持有View,确保View可被正常回收:
private class DotsTimerTask(private val loaderRef: WeakReference<LinearDotsLoader>) : TimerTask() {
    override fun run() {
        val loader = loaderRef.get() ?: return // View已回收则直接终止任务
        loader.updateSelectedDot()
    }
}
  1. 修改定时器初始化逻辑
private fun scheduleTimer() {
    timer = Timer()
    val dotsTimerTask = DotsTimerTask(WeakReference(this))
    timer?.scheduleAtFixedRate(dotsTimerTask, 0, animDur.toLong())
}
  1. 完善View销毁时的定时器清理
    添加onDetachedFromWindow方法,确保View从Window移除时彻底停止定时器:
override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    timer?.cancel()
    timer?.purge()
    timer = null // 置空引用,辅助GC回收
}
  1. 优化UI线程切换逻辑
    无需递归查找Activity,直接使用View的post方法切换到UI线程:
private fun updateSelectedDot() {
    // ... 原有逻辑不变 ...

    post { invalidate() }
}

额外建议

  • 优先使用Handler+Runnable或Kotlin协程替代Timer,前者更适配Android UI场景,后者自带生命周期管理,可避免手动处理引用泄漏问题。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.14 19:05:15