Skip to content

Canvas harmony卡顿明显 #1193

@walker199105

Description

@walker199105

期望结果

使用Canvas 绘制了一个可以拖动的刻度尺,显示刻度尺上会精确到秒,在左右拖动时harmony 有明显的卡顿现象,但是在android上就比较流畅。请问是harmony上有性能瓶颈嘛?源码如下。有什么优化的方式嘛?

class TimeLineView : ComposeView<TimeLineViewAttr, TimeLineViewEvent>() {

// observable 驱动重绘
/** frameOffset:时间轴左端相对于 View 左端的偏移量(px),负值表示向左平移 */
private var frameOffset by observable(0f)
/** scaleFactor:缩放倍数,1.0 = 全天24h铺满,>1 放大 */
private var scaleFactor by observable(1.0f)

// 布局参数(Canvas 回调中初始化)
private var viewW = 0f
private var viewH = 0f
private var isLayoutReady = false
// 触摸状态
private var lastTouchX = 0f
// 防止 centerOnTime 在 onTimeChanged 回调中被递归调用
private var isUpdatingTime = false
private var isTouching = false
// 节流:记录上次触发 notifyCurrentTime 的 frameOffset,变化超过阈值才触发
private var lastNotifiedOffset = Float.MIN_VALUE
private val NOTIFY_THRESHOLD = 3f  // px,低于此变化量不触发回调

companion object {
    const val SECONDS_PER_DAY = 86400L
    // 刻度高度比例(相对 viewH),缩短为原来一半
    const val TICK_L_RATIO = 0.275f
    const val TICK_M_RATIO = 0.19f
    const val TICK_S_RATIO = 0.125f
    // 游标宽度(px)
    const val SLIDER_W = 1f
    // 时间标签距顶部距离比例
    const val LABEL_Y_RATIO = 0.82f
    // 录像色块高度比例
    const val REC_H_RATIO = 0.35f
}

override fun createAttr(): TimeLineViewAttr = TimeLineViewAttr()
override fun createEvent(): TimeLineViewEvent = TimeLineViewEvent()

override fun body(): ViewBuilder {
    val ctx = this
    return {
        val w = ctx.attr.width
        val h = ctx.attr.height

        // 外层 View 接收 touch 事件
        View {
            attr {
                width(w)
                height(h)
            }
            event {
                touchDown { e ->
                    ctx.lastTouchX = e.x
                    ctx.isTouching = true
                    ctx.event.touchDownHandler?.invoke()
                }
                touchMove { e ->
                    if (ctx.isTouching) {
                        val dx = e.x - ctx.lastTouchX
                        ctx.lastTouchX = e.x
                        ctx.onSwipe(dx)
                    }
                }
                touchUp {
                    ctx.isTouching = false
                    ctx.lastNotifiedOffset = Float.MIN_VALUE  // 重置节流,确保 touchUp 时触发一次
                    ctx.notifyCurrentTime()
                    ctx.notifyTimeSelected()
                    ctx.event.touchUpHandler?.invoke()
                }
                touchCancel {
                    ctx.isTouching = false
                }
            }

            // Canvas 绘制
            Canvas({
                attr {
                    absolutePosition(top = 0f, left = 0f, bottom = 0f, right = 0f)
                }
            }) { context, width, height ->
                // 初始化布局
                if (!ctx.isLayoutReady) {
                    ctx.viewW = width
                    ctx.viewH = height
                    ctx.isLayoutReady = true
                    // 默认将当前时间居中
                    ctx.centerOnTime(ctx.attr.currentTimeSec)
                }

                val offset = ctx.frameOffset
                val scale = ctx.scaleFactor
                // 整个时间轴的像素宽度 = viewW * scale
                val totalW = width * scale

                // 1. 背景
                context.beginPath()
                context.moveTo(0f, 0f)
                context.lineTo(width, 0f)
                context.lineTo(width, height)
                context.lineTo(0f, height)
                context.closePath()
                context.fillStyle(ctx.attr.bgColor)
                context.fill()

                // 2. 录像色块(按颜色分组批量绘制,减少 fillStyle 切换)
                val recsByColor = ctx.attr.records.groupBy { it.color }
                for ((color, recs) in recsByColor) {
                    context.beginPath()
                    context.fillStyle(color)
                    for (rec in recs) {
                        val rx = offset + (rec.startSec.toFloat() / SECONDS_PER_DAY) * totalW
                        val rw = (rec.durationSec.toFloat() / SECONDS_PER_DAY) * totalW
                        if (rx + rw < 0 || rx > width) continue
                        context.moveTo(rx, 0f)
                        context.lineTo(rx + rw, 0f)
                        context.lineTo(rx + rw, height)
                        context.lineTo(rx, height)
                        context.closePath()
                    }
                    context.fill()
                }

                // 3. 刻度线 + 时间标签(只绘制可见区域,大幅减少鸿蒙 Canvas 桥接调用)
                val params = ctx.calcScaleParams(scale)
                val tickLH = height * TICK_L_RATIO
                val tickMH = height * TICK_M_RATIO
                val tickSH = height * TICK_S_RATIO
                val labelY = height * LABEL_Y_RATIO
                val totalTicks = params.totalTicks
                val stepPx = totalW / totalTicks.toFloat()

                // 计算可见区域的刻度索引范围,避免遍历全部 totalTicks
                val iStart = ((0f - offset) / stepPx - 1).toInt().coerceIn(0, totalTicks)
                val iEnd = ((width - offset) / stepPx + 1).toInt().coerceIn(0, totalTicks)

                context.lineWidth(0.5f)
                context.font(size = ctx.attr.labelFontSize)

                // 第一遍:只绘制可见范围内的刻度线(极密集时跳过不可辨识的刻度)
                context.beginPath()
                context.strokeStyle(ctx.attr.tickColor)
                // stepPx < 1.5px:刻度完全重叠,只画长刻度;< 3px:短刻度挤成一团,跳过
                val skipShort = stepPx < 3f
                val skipMedium = stepPx < 1.5f
                var i = iStart
                if (skipMedium) {
                    val alignedStart = if (iStart % params.sL == 0) iStart else (iStart / params.sL + 1) * params.sL
                    i = alignedStart
                    while (i <= iEnd) {
                        val x = offset + i * stepPx
                        context.moveTo(x, 0f)
                        context.lineTo(x, tickLH)
                        i += params.sL
                    }
                } else if (skipShort) {
                    val step = if (params.sM > 0) params.sM else params.sL
                    val alignedStart = if (iStart % step == 0) iStart else (iStart / step + 1) * step
                    i = alignedStart
                    while (i <= iEnd) {
                        val x = offset + i * stepPx
                        val tickH = if (i % params.sL == 0) tickLH else tickMH
                        context.moveTo(x, 0f)
                        context.lineTo(x, tickH)
                        i += step
                    }
                } else {
                    i = iStart
                    while (i <= iEnd) {
                        val x = offset + i * stepPx
                        val tickH = when {
                            i % params.sL == 0 -> tickLH
                            params.sM > 0 && i % params.sM == 0 -> tickMH
                            else -> tickSH
                        }
                        context.moveTo(x, 0f)
                        context.lineTo(x, tickH)
                        i++
                    }
                }
                context.stroke()

                // 第二遍:只绘制可见范围内的时间标签
                context.fillStyle(ctx.attr.labelColor)
                // 将 iStart 对齐到最近的标签刻度索引
                val labelStart = if (iStart % params.sT == 0) iStart else (iStart / params.sT + 1) * params.sT
                i = labelStart
                while (i <= iEnd) {
                    val x = offset + i * stepPx
                    // 根据索引直接计算分钟数,无需累加
                    val labelMinutes = (i / params.sT) * params.tInterval
                    val hh = labelMinutes / 60
                    val mm = labelMinutes % 60
                    val label = if (i == totalTicks) "24:00"
                    else "${hh.toString().padStart(2, '0')}:${mm.toString().padStart(2, '0')}"
                    val tw = ctx.attr.labelFontSize * label.length * 0.6f
                    val lx = x - tw / 2f
                    if (lx >= 0 && lx + tw <= width) {
                        context.fillText(label, lx, labelY)
                    }
                    i += params.sT
                }

                // 4. 中心红色游标(固定在 View 中央,1px 细线)
                val cx = width / 2f
                context.beginPath()
                context.moveTo(cx, 0f)
                context.lineTo(cx, height)
                context.strokeStyle(ctx.attr.sliderColor)
                context.lineWidth(SLIDER_W)
                context.stroke()
            }
        }
    }
}

/** 左右拖动时间轴 */
private fun onSwipe(dx: Float) {
    val totalW = viewW * scaleFactor
    val newOffset = frameOffset + dx
    // 边界限制:游标不能超出 00:00 和 24:00
    val minOffset = viewW / 2f - totalW   // 24:00 在游标左侧
    val maxOffset = viewW / 2f            // 00:00 在游标右侧
    frameOffset = newOffset.coerceIn(minOffset, maxOffset)
    notifyCurrentTime()
}

/** 将指定时间戳(秒,相对当天 00:00)居中显示,并触发 onTimeChanged 回调 */
fun centerOnTime(timeSec: Long) {
    if (viewW == 0f || isUpdatingTime) return
    val clamped = timeSec.coerceIn(0L, SECONDS_PER_DAY)
    val totalW = viewW * scaleFactor
    val ratio = clamped.toFloat() / SECONDS_PER_DAY
    frameOffset = viewW / 2f - ratio * totalW
    val minOffset = viewW / 2f - totalW
    val maxOffset = viewW / 2f
    frameOffset = frameOffset.coerceIn(minOffset, maxOffset)
    attr.currentTimeSec = clamped
    isUpdatingTime = true
    event.timeChangedHandler?.invoke(clamped)
    isUpdatingTime = false
}

/**
 * 对应 Android setDisplayedDate(long date)
 * 传入绝对时间戳(ms),时间轴跳转到该时刻居中显示,并重置缩放。
 * dayStartMs:当天 00:00 的毫秒时间戳,用于计算当天内偏移。
 *
 * 使用示例:
 *   timeLine.setDisplayedDate(timestampMs = 1710000000000L, dayStartMs = 1709913600000L)
 */
fun setDisplayedDate(timestampMs: Long, dayStartMs: Long) {
    // 重置缩放(对应 Android reset())
    scaleFactor = 1f
    // 计算当天内秒偏移
    val secInDay = ((timestampMs - dayStartMs) / 1000L).coerceIn(0L, SECONDS_PER_DAY)
    // centerOnTime 内部已更新 attr.currentTimeSec 并触发回调,无需重复调用
    centerOnTime(secInDay)
}

/** 缩放(scale > 1 放大,< 1 缩小),以游标为中心缩放 */
fun applyScale(newScale: Float) {
    val clampedScale = newScale.coerceIn(1f, 240f)
    val cx = viewW / 2f
    // 保持游标对应时间不变
    val ratio = (cx - frameOffset) / (viewW * scaleFactor)
    scaleFactor = clampedScale
    frameOffset = cx - ratio * (viewW * scaleFactor)
    val minOffset = viewW / 2f - viewW * scaleFactor
    val maxOffset = viewW / 2f
    frameOffset = frameOffset.coerceIn(minOffset, maxOffset)
}

/**
 * 按步进缩放,对应 Android TimeLine.applyZoom(boolean in)
 * in=true 放大(时间轴拉长,看到更少时间范围),in=false 缩小
 */

public fun applyZoom(zoomIn: Boolean) {
if (viewW == 0f) return
val spanDefault = viewW / 8f
val dx = if (zoomIn) spanDefault else -spanDefault
// 对应 Android calculateScale 逻辑
val acc = (4.0 * (scaleFactor - 1f)) / 239.0
val k = dx / viewW
val dScale = (1.0 + (1.0 + acc) * k).toFloat()
val newScale = (scaleFactor * dScale).coerceIn(1f, 240f)
applyScale(newScale)
// 缩放后保持当前游标时间居中
notifyCurrentTime()
}

/** 获取当前游标对应的时间(秒,相对当天 00:00) */
fun getCurrentTimeSec(): Long = attr.currentTimeSec

/** 获取当前游标对应的时间字符串,格式 "hh:mm:ss" */
fun getCurrentTimeString(): String {
    val sec = getCurrentTimeSec()
    val hh = (sec / 3600).toString().padStart(2, '0')
    val mm = ((sec % 3600) / 60).toString().padStart(2, '0')
    val ss = (sec % 60).toString().padStart(2, '0')
    return "$hh:$mm:$ss"
}

fun changeTime2String(timeSec: Long) : String{
    val sec = timeSec
    val hh = (sec / 3600).toString().padStart(2, '0')
    val mm = ((sec % 3600) / 60).toString().padStart(2, '0')
    val ss = (sec % 60).toString().padStart(2, '0')
    return "$hh:$mm:$ss"
}

/** 通知当前游标时间(拖动过程中),加节流避免鸿蒙频繁触发外部回调 */
private fun notifyCurrentTime() {
    if (viewW == 0f) return
    // 偏移量变化不足阈值时跳过回调,减少鸿蒙 JS 桥接开销
    if (abs(frameOffset - lastNotifiedOffset) < NOTIFY_THRESHOLD) return
    lastNotifiedOffset = frameOffset
    val totalW = viewW * scaleFactor
    val ratio = (viewW / 2f - frameOffset) / totalW
    val timeSec = (ratio * SECONDS_PER_DAY).toLong().coerceIn(0L, SECONDS_PER_DAY)
    attr.currentTimeSec = timeSec
    event.timeChangedHandler?.invoke(timeSec)
}

/** 通知手势抬起时的游标时间(拖动结束) */
private fun notifyTimeSelected() {
    if (viewW == 0f) return
    val totalW = viewW * scaleFactor
    val ratio = (viewW / 2f - frameOffset) / totalW
    val timeSec = (ratio * SECONDS_PER_DAY).toLong().coerceIn(0L, SECONDS_PER_DAY)
    event.timeSelectedHandler?.invoke(timeSec)
}

/**
 * 根据缩放倍数计算刻度参数,对齐 Android TimeLine.java calcScaleParams 逻辑
 *
 * Android 参数含义:
 *   nScratch  = 每小时的刻度数,totalTicks = nScratch * 24
 *   sL        = 每 sL 个刻度画长刻度线
 *   sM        = 每 sM 个刻度画中刻度线(0 = 不画)
 *   sT        = 每 sT 个刻度显示时间标签(= sL,即大刻度才显示)
 *   tInterval = 相邻两个标签之间的分钟数
 */
private fun calcScaleParams(scale: Float): TickParams {
    val q = ((scale - 1f) / 239f) * 100.0
    return when {
        q < 0.5  -> TickParams(nScratch = 1,   sL = 2,  sM = 0, sT = 4,   tInterval = 240) // 每4h标签
        q < 1.5  -> TickParams(nScratch = 2,   sL = 4,  sM = 2, sT = 4,   tInterval = 120) // 每2h标签
        q < 4.0  -> TickParams(nScratch = 6,   sL = 6,  sM = 3, sT = 6,   tInterval = 60)  // 每1h标签
        q < 8.0  -> TickParams(nScratch = 12,  sL = 6,  sM = 3, sT = 6,   tInterval = 30)  // 每30min标签
        q < 30.0 -> TickParams(nScratch = 60,  sL = 10, sM = 5, sT = 10,  tInterval = 10)  // 每10min标签
        q < 75.0 -> TickParams(nScratch = 120, sL = 10, sM = 2, sT = 10,  tInterval = 5)   // 每5min标签
        else     -> TickParams(nScratch = 360, sL = 6,  sM = 3, sT = 6,   tInterval = 1)   // 每1min标签
    }
}

private data class TickParams(
    val nScratch: Int,    // 每小时刻度数,totalTicks = nScratch * 24
    val sL: Int,          // 每 sL 个刻度画长刻度线
    val sM: Int,          // 每 sM 个刻度画中刻度线(0 = 不画)
    val sT: Int,          // 每 sT 个刻度显示时间标签
    val tInterval: Int    // 相邻标签间隔分钟数
) {
    val totalTicks: Int get() = nScratch * 24
}

}

/** 录像时间段数据 /
data class TimeLineRecord(
/
* 开始时间(秒,相对当天 00:00) /
val startSec: Long,
/
* 持续时长(秒) /
val durationSec: Long,
/
* 色块颜色,默认绿色 */
val color: Color = Color(0xFF4CAF50)
)

class TimeLineViewAttr : ComposeAttr() {
/** View 宽度 /
var width: Float = 375f
/
* View 高度 /
var height: Float = 60f
/
* 录像时间段列表 /
var records: List = emptyList()
/
* 当前时间(秒,相对当天 00:00),用于初始居中 /
var currentTimeSec: Long = 43200L // 默认 12:00
/
* 背景色 /
var bgColor: Color = Color(0xFFFFFFFF)
/
* 刻度线颜色 /
var tickColor: Color = Color(0xFF000000)
/
* 游标颜色 /
var sliderColor: Color = Color(0xFFFF4444)
/
* 时间标签颜色 /
var labelColor: Color = Color(0xFF000000)
/
* 时间标签字号 */
var labelFontSize: Float = 10f
}

class TimeLineViewEvent : ComposeEvent() {
var timeChangedHandler: ((Long) -> Unit)? = null
var timeSelectedHandler: ((Long) -> Unit)? = null
var touchDownHandler: (() -> Unit)? = null
var touchUpHandler: (() -> Unit)? = null

/** 游标时间变化回调(拖动过程中持续触发),参数为秒(相对当天 00:00) */
fun onTimeChanged(handler: (Long) -> Unit) {
    timeChangedHandler = handler
}

/** 手势抬起时回调(拖动结束后触发一次),参数为秒(相对当天 00:00) */
fun onTimeSelected(handler: (Long) -> Unit) {
    timeSelectedHandler = handler
}

/** 手指按下回调,可用于外部停止定时器 */
fun onTouchDown(handler: () -> Unit) {
    touchDownHandler = handler
}

/** 手指抬起回调,可用于外部重启定时器 */
fun onTouchUp(handler: () -> Unit) {
    touchUpHandler = handler
}

}

fun ViewContainer<*, *>.TimeLineView(init: TimeLineView.() -> Unit) {
addChild(TimeLineView(), init)
}

实际结果

鸿蒙卡顿明显,android流畅很多

重现链接

www.

重现步骤

Nothing to preview

重现环境

Nothing to preview

补充说明

No response

Metadata

Metadata

Assignees

Labels

s: need triageRequires review and categorization.

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions