高仿剪映视频多轨剪辑页实现

  剪映是当下比较火的一款手机视频剪辑工具,由抖音官方推出,可用于手机短视频的剪辑制作,拥有强大的多轨编辑能力。其中视频剪辑页用于剪辑的View拥有出色的交互性,很考验Android的基础能力,值得拿出来学习一下。
  观察剪映的视频剪辑页面,可见主要有时间轴视频轨道时间游标预览窗口四部分组成。时间轴用于展示当前的时间长度和时间刻度,通过缩放手势可以改变最小刻度值,拖动可以对音视频进行seek。视频轨道用于显示轨道在时间轴上的长度、以及轨道信息,同时视频轨道会显示对应时间的帧图像,而音频轨道则会显示波形图。时间游标会固定在整个View的中间位置,虽然叫它游标,但实际上并不会移动,只能通过移动时间轴和视频轨道来表示当前的时间位置。预览窗口用于显示视频帧,通常是SurfaceView或TextureView,比较简单,非本文的重点。

剪影剪辑页

实现

  本文并不会完全通过Canvas绘制每一个UI元素,而是尽可能利用Android现有的View进行组合实现,虽然性能较低,但实现起来简单。整个View结构分三层:

  1. AlTrackContainer作为整个View的根,继承自HorizontalScrollView以实现水平滚动,同时负责缩放手势处理以及时间游标的绘制。
  2. AlTrackView负责组织时间轴和各个视频轨道的布局,同时响应缩放手势,实时改变子View的长度。
  3. AlTimelineView作为时间轴,负责绘制时间刻度,同时响应缩放手势,实时改变时间刻度和长度。
  4. AlTrackItemView单纯继承自TextView,用于显示轨道名称以及音频的波形。
层级结构

时间轴

  AlTimelineView由时间刻度和圆点组成,时间刻度格式为##:##,值得注意的是刻度与圆点之间有一个最小和最大间距,这里把刻度与圆点距离、最小和最大间距分别定义为Space、MinSpace和MaxSpace,Space总是大于MinSpace,小于MaxSpace,其中MaxSpace=MinSpace*4+圆点直径+刻度文字宽度,以便于Space>MaxSpace时,正好能够增加显示一个时间刻度。

部分时间刻度
  1. 根据View的宽度、##:##宽度以及Space与MinSpace、MaxSpace的关系初始化刻度值,并把每个刻度值的String保存到一个数组。
  2. 当通过缩放手势放大时间轴,刻度间距由小到大变化,直到Space>MaxSpace时,根据View的宽度、刻度宽度以及Space与MinSpace、MaxSpace的关系重新生成新的刻度,并覆盖保存到数组,如果计算得当的话,新的刻度Space总是大于MinSpace,小于MaxSpace。
  3. 同理,当通过缩放手势放大时间轴,直到Space<MinSpace时,重新计算刻度数组。不同于上面的放大逻辑,这里直接把刻度数量除以2,然后根据新的刻度数量重新计算间距,这样就能实现刻度间距由大到小的效果。
      此时我们只需要在onDraw中根据Space把刻度数组里的文字、以及刻度之间的小圆点绘制出来即可。核心代码如下:
//放大的情况下保持最小刻度不变
private fun keepZoomLevel(visibleWidth: Int): Int {
    if (abs(mLastVisibleWidth - visibleWidth) < 5) {
        return textVec.size
    }
    mLastVisibleWidth = visibleWidth
    val tmp = (visibleWidth - textSize.x * textVec.size) / (textVec.size - 1).toFloat()
    if (tmp < textSize.x + cursorRect.width() * 2 && tmp > cursorRect.width()) {
        spaceSize = tmp
        return textVec.size
    }
    return Int.MIN_VALUE
}
​
private fun measureText(): Int {
    if (durationInUS <= 0) {
        textVec.clear()
        return 0
    }
    //textSize.x为##:##的宽度,加textSize.x是为了保证##:##的宽度中间为该刻度值。
    val visibleWidth = measuredWidth + textSize.x - paddingLeft - paddingRight
    var count = (visibleWidth / (textSize.x + cursorRect.width())).toInt()
    if (textVec.size == count) {
        return count
    }
    ​
    if (textVec.isNotEmpty()) {
        if (Int.MIN_VALUE != keepZoomLevel(visibleWidth)) {
            return textVec.size
        }
        count = if (count < textVec.size) {
            textVec.size / 2
        } else {
            textVec.size * 2
        }
    }
    ​
    textVec.clear()
    if (count > 1) {
        spaceSize = (visibleWidth - textSize.x * count) / (count - 1).toFloat()
        for (i in 0 until count) {
            textVec.add(fmt.format(Date(i * durationInUS / (count - 1) / 1000)))
        }
    } else {
        spaceSize = (visibleWidth - textSize.x).toFloat()
        textVec.add(fmt.format(Date(0)))
    }
    return count
}

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    val count = measureText()
    for (i in 0 until count) {
        val text = textVec[i]
        val x = paddingLeft - textSize.x / 2f + ((textSize.x + spaceSize) * i).toFloat()
        canvas?.drawText(text, x, (measuredHeight + textSize.y) / 2f, paint)
        if (i < count - 1) {
            canvas?.drawCircle(
                x + textSize.x + spaceSize / 2f,
                measuredHeight / 2f,
                cursorSize / 2f, paint
            )
        }
    }
}

视频轨道

  AlTrackItemViewAlTrackView进行布局,AlTrackView同时页负责时间轴的摆放,功能比较简单。只需要保证AlTimelineView和AlTrackItemView的垂直线性布局即可,同时需要保证AlTrackItemView在时间轴下的占比,并且在缩放的同时成比例改变AlTrackItemView和AlTrackView的宽度。
  首先AlTrackView需要有一个缩放接口,该接口输入一个缩放比例,比例改变的同时在onMeasure方法内部根据缩放系数改变自身宽度。

fun setScale(scale: AlRational) {
    this.scale.num = scale.num
    this.scale.den = scale.den
    requestLayout()
}
​
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    val width = MeasureSpec.getSize(widthMeasureSpec)
    val height = MeasureSpec.getSize(heightMeasureSpec)
    measureChildren(widthMeasureSpec, heightMeasureSpec)
    if (originWidth <= 0) {
        originWidth = width
    }
    setMeasuredDimension(
        originWidth * scale.num / scale.den + paddingLeft + paddingRight,
        height
    )
}

  而AlTimelineView则需要在AlTrackView初始化时进行添加。这里给AlTimelineView添加了一个上下的padding,让刻度与View的边缘保持一定间距。

constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int)
: super(context, attrs, defStyleAttr) {
    onResolveAttribute(context, attrs, defStyleAttr, 0)
    onInitialize(context)
}
​
private fun onInitialize(context: Context) {
    clipToPadding = false
    mTimeView = AlTimelineView(context)
    mTimeView.setPadding(
        0, applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f).toInt(),
        0, applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f).toInt()
    )
    addView(mTimeView, makeLayoutParams())
}

  同时AlTrackView需要有一个addTrack接口,支持外部添加不同的轨道。该接口会通过传入的轨道信息,生成对应的AlTrackItemView(TextView),同时把生成的View和轨道信息保存到不同的Map中,方便进行布局。updateAudioTrack用于根据音频轨道的文件路径生成音频波形的Bitmap,然后作为View的背景,音频波形图可以通过FFmpeg命令生成。

fun addTrack(track: AlMediaTrack) {
    if (tMap.containsKey(track.id)) {
        return
    }
    tMap[track.id] = track
    vMap[track.id] = TextView(context)
    vMap[track.id]?.textSize = 14f
    vMap[track.id]?.setTextColor(Color.WHITE)
    vMap[track.id]?.text = when (track.type) {
        AlMediaType.TYPE_VIDEO -> "Track ${track.id}"
        AlMediaType.TYPE_AUDIO -> "Track ${track.id}"
        else -> "Unknown Track"
    }
    vMap[track.id]?.setBackgroundColor(
        when (track.type) {
            AlMediaType.TYPE_VIDEO -> mVideoColor
            AlMediaType.TYPE_AUDIO -> mAudioColor
            else -> Color.RED
        }
    )
    val padding = applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f).toInt()
    vMap[track.id]?.setPadding(padding, padding, padding, padding)
    addView(vMap[track.id], makeLayoutParams())
    requestLayout()
    //显示音频轨道波形图
    updateAudioTrack(track)
}

  最后通过在onLayout方法中对AlTimelineView和AlTrackItemView进行布局,这里会根据轨道的时长占总时长的比例来设置AlTrackItemView自身的宽度。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    var height = 0

    var w = measuredWidth
    var h = mTimeView.measuredHeight
    mTimeView.measure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), h)
    mTimeView.layout(l, height, l + w, height + h)
    height += h

    vMap.forEach {
        val track = tMap[it.key]
        val view = it.value

        w = measuredWidth - paddingLeft - paddingRight
        h = view.measuredHeight
        var offset = 0
        if (null != track && mTimeView.getDuration() > 0 && track.duration > 0) {
            offset = (track.seqIn * w / mTimeView.getDuration()).toInt()
            w = (track.duration * w / mTimeView.getDuration()).toInt()
        }
        view.layout(paddingLeft + l + offset, height, paddingLeft + l + w + offset, height + h)
        view.measure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), h)

        height += h
    }
}

AlTrackContainer

  AlTrackContainer作为AlTrackView的直接父级,承载着横向滚动的功能,我们可以继承HorizontalScrollView实现。同时实现了缩放手势的监听,通过缩放手势计算缩放系数,层层传递到AlTrackViewAlTimelineView进行缩放响应。缩放手势的监听很简单,只需要使用Android提供的ScaleGestureDetector即可。

private val mScaleDetector = ScaleGestureDetector(context, mScaleListener)
private val mScaleListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
    private var previousScaleFactor = 1f
    override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
        previousScaleFactor = 1f
        return super.onScaleBegin(detector)
    }
    ​
    override fun onScaleEnd(detector: ScaleGestureDetector?) {
        previousScaleFactor = 1f
        super.onScaleEnd(detector)
    }
    ​
    override fun onScale(detector: ScaleGestureDetector): Boolean {
        val anchor = PointF(
            detector.focusX * 2 / measuredWidth.toFloat() - 1f,
            -(detector.focusY * 2 / measuredHeight.toFloat() - 1f)
        )
        scale = scale * detector.scaleFactor / previousScaleFactor
        previousScaleFactor = detector.scaleFactor
        //限制最大最小缩放系数
        if (scale < 0.5f) {
            scale = 0.5f
        }
        if (scale > 3) {
            scale = 3f
        }
        //把缩放系数传给AlTrackView
        getChildView().setScale(AlRational((scale * 10000).toInt(), 10000))
        return super.onScale(detector)
    }
}
override fun onTouchEvent(event: MotionEvent): Boolean {
    mScaleDetector.onTouchEvent(event)
    return super.onTouchEvent(event)
}

  同时AlTrackContainer还需要绘制中心的游标,用来标示当前的时间点,这里游标使用一个圆角矩形来表示。由于游标需要显示在所有元素的上方,如果在onDraw中绘制会被其它元素遮挡,所以需要在dispatchDraw中绘制。至此,高仿剪映多轨编辑View实现完成。

override fun dispatchDraw(canvas: Canvas?) {
    super.dispatchDraw(canvas)
    canvas?.drawRoundRect(
        scrollX + (measuredWidth - cursorSize) / 2,
        0f,
        scrollX + (measuredWidth + cursorSize) / 2,
        measuredHeight.toFloat(),
        cursorSize / 2f,
        cursorSize / 2f,
        paint
    )
}

实际效果对比

高仿效果
剪映放大效果

总结

  以上只是对剪映主要逻辑的实现,实际还缺失很多比较细微的功能,比如显示视频截图、删除移动轨道等,并且实际效果与剪映还有一些差异。希望通过本文能给读者学习Android自定义View带来一些帮助。最后附上源码:

AlTrackContainer
AlTrackView
AlTimelineView

Special

  如果只是实现一个UI的交互功能,有点太缺乏挑战了。实际上本文不仅实现了用于编辑的交互UI,而且还实现了音视频多轨预览剪辑的逻辑。

  1. 支持同时添加多个音视频轨道进行播放预览!
  2. 支持剪映没有的多视频轨道图层移动和缩放,可以任意摆放各个视频轨道的位置!
  3. 支持常规的音视频Seek、暂停与播放等。
      以上源码都开源在hwvc项目,感兴趣的读者可以自取。
多轨录屏

欢迎关注微信公众,第一时间获取一手多媒体技术资讯