期望结果
使用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
期望结果
使用Canvas 绘制了一个可以拖动的刻度尺,显示刻度尺上会精确到秒,在左右拖动时harmony 有明显的卡顿现象,但是在android上就比较流畅。请问是harmony上有性能瓶颈嘛?源码如下。有什么优化的方式嘛?
class TimeLineView : ComposeView<TimeLineViewAttr, TimeLineViewEvent>() {
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()
}
}
/** 录像时间段数据 /
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
}
fun ViewContainer<*, *>.TimeLineView(init: TimeLineView.() -> Unit) {
addChild(TimeLineView(), init)
}
实际结果
鸿蒙卡顿明显,android流畅很多
重现链接
www.
重现步骤
Nothing to preview
重现环境
Nothing to preview
补充说明
No response