Android -- 前端<video>标签原生化

目录

[1. 方案介绍](#1. 方案介绍 "#block_1")

[2. 实现](#2. 实现 "#block_2")

[3. 总结](#3. 总结 "#block_3")

1. 方案介绍

  • 1.1 什么是"前端<video>标签原生化"

    上图这个页面里,我开启了"开发者模式-显示布局边界"选项。可以看到一开始视频是前端<video>,点击播放之后,视频变成了Android视频控件。其核心做法就是在前端<video>上面添加一个一模一样的Android视频控件,同时支持同步滚动。我把这个称为"前端<video>标签原生化"。

  • 1.2 需求背景

    众所周知,Android系统五花八门,不同系统对前端<video>的实现也是比较简陋且不统一,无法统一支持倍速播放、投屏、画中画等功能;前端<video>在全屏时,也容易出现奇怪的问题,比如:WebView播放视频的方式和坑点记录。总之,功能简陋不统一、容易出现问题、响应速度不及原生等,都是前端播放视频亟需解决的问题。

    市面上很多浏览器APP都是采用的上面的方案,比如夸克(下边左图)、QQ浏览器(下边右图)等。所以"前端<video>标签原生化"应该是业界比较认同的处理方式。

2. 实现

  • 2.1 相关View组成

    java 复制代码
    - FrameLayout
      - WebView(底层)
      - WebVideo2NativeVideo(顶层)
        - FrameLayout
          - FrameLayout
            - VideoView
            - VideoView
            - ...   

    上面是页面的结构树。从大的关系来看,页面主要由如下两部分组成:

    • 底层的WebView

      底层的WebView很好理解,就是用于展示前端页面的容器。

    • 顶层的WebVideo2NativeVideo

      一个继承ScrollView的自定义类,核心逻辑基本在这个类。因为前端页面可能可以滚动,所以顶层基于ScrollView做定制。ScrollView内嵌了两层FrameLayout(两层FrameLayout的原因在下一个小节解释),FrameLayout里面是VideoView,这些VideoView在位置上与前端<video>一一对应。

  • 2.2 管理原生VideoView

    首先,我们需要在Android端为前端<video>添加对应的VideoViewVideoView的位置、视频信息需要与前端<video>保持一致。主要涉及下面两部分:

    • 2.2.1 添加VideoView

      当前端<video>标签被点击时,获取<video>标签的相关信息(下面称:VideoInfo),然后在Android端添加对应的VideoView

      实现上述步骤的方式可能有很多种,比如:利用WebView.evaluateJavascript全部由Android端实现、利用WebView.addJavascriptAndroid端和前端配合实现......

      下面介绍Android端和前端配合的方案。首先在前端给每个<video>添加play监听,在视频开始播放时,把VideoInfo传递给Android端:

      js 复制代码
      const postVideoPlay = () => {
        function getVideoInfo() {
          let video = videoRef.value;
          var rect = video.getBoundingClientRect();
          return {
            'scrollHeight': document.body.scrollHeight,
            'width': rect.width,
            'height': rect.height,
            'left': rect.left,
            'top': rect.top + window.scrollY,
            'url': video.src,
            'poster': video.poster
          };
        }
        // 调用约定好的ydk方法
        window.ydk.onVideoPlay(getVideoInfo())
      }
      
      videoRef.value.addEventListener('play', postVideoPlay);

      至此前端<video>完成使命,但由于前端页可能被其他端使用,所以把<video>的暂停操作放在Android端去做。

      ini 复制代码
      val pauseWebVideoJS = """  
      try {  
          var url = "${videoInfo.url}";  
          let selector = 'video[src="' + url + '"]';  
          let video = document.querySelector(selector)  
          video.pause();  
      } catch (e) {  
          console.log(e);  
      }  
      """.trimIndent()  
      getWebView().evaluateJavascript(pauseWebVideoJS, null)

      把前端<video>暂停后,我们需要利用VideoInfoAndroid端添加一个VideoView

      kotlin 复制代码
      // WebVideo2NativeVideo
      fun addVideoView(...) {  
          if (mIsFirstAddVideoView) {  
              mIsFirstAddVideoView = false  
              // PS:1
              val scrollViewChild = FrameLayout(context)  
              addView(scrollViewChild)  
              scrollViewChild.addView(mVideoViewContainer)  
          }  
          setContainerLayoutParams(webView, videoInfo)
          ...
          mVideoViewContainer.addView(videoView)  
      }
      
      private fun setContainerLayoutParams(webView: WebView, videoInfo: VideoInfo) {  
          // 设置整个大容器的高度  
          mVideoViewContainer.updateLayoutParams {  
               height = SizeUtils.dp2px(videoInfo.scrollHeight.toFloat())  
          }
          ...
      }

      上面有两个需要注意的地方:

      PS:1 前面提到WebVideo2NativeVideo是自定义的ScrollViewScrollView比较特殊,measure时会强制将子ViewHeight设置成UNSPECIFIED,导致我们无法设置子View的具体高度,即我们无法设置承载VideoViewFrameLayout的高度,会影响VideoView的定位和滚动。所以这里我们额外增加一层FrameLayout来解决这个问题。

      这个也就是前面提到"两层FrameLayout"的原因

      PS:2 前端返回的VideoInfo是以dp为单位的,这里需要转一下。

    • 2.2.2 更新VideoView

      实际开发时,可能遇到屏幕旋转等情况,会导致先前设置的尺寸信息不适用,我们需要在Activity.onConfigurationChanged方法中判断方向是否发生变化。

      kotlin 复制代码
      override fun onConfigurationChanged(newConfig: Configuration) {  
          super.onConfigurationChanged(newConfig)  
          val newScreenOrientation = newConfig.orientation    
          if (newScreenOrientation != mScreenOrientation) {
              mScreenOrientation = newScreenOrientation  
      
              val refreshVideoInfoTask = Runnable {
                  val getVideoInfoJS = """  
                      function getVideoInfo() {
                          // PS:1
                          let videos = document.getElementsByTagName('video');  
                          const resultList = [];  
                          Array.from(videos).forEach(video => {  
                              ...  
                          });  
                          return resultList;  
                      }  
                      getVideoInfo();  
                      """.trimIndent()  
                  getWebView().evaluateJavascript(getVideoInfoJS) { value ->  
                      if (value != null) {  
                          ...  
                          mWebVideo2NativeVideo.updateLayoutParams(getWebView(), videoInfoList)  
                      }  
                  }  
              }  
              if (mWebVideo2NativeVideo.haveAddedVideoView) {
                  // PS:2
                  mWebVideo2NativeVideo.postDelayed(refreshVideoInfoTask, 100)  
              }
          }
      }

      上面有两个需要注意的地方:

      PS:1 屏幕旋转时,可能已经添加了多个VideoView,所以需要一次性获取所有VideoInfo

      PS:2 实践证明,onConfigurationChanged回调时,获取到的VideoInfo还是旋转前的,所以这里通过postDelayed处理。

      拿到所有VideoInfo后,我们通过VideoInfo.url匹配更新已有的VideoView

      java 复制代码
      // WebVideo2NativeVideo
      fun updateLayoutParams(webView: WebView, newVideoInfoList: List<VideoInfo>) {  
          if (newVideoInfoList.isNotEmpty()) {  
              setContainerLayoutParams(webView, newVideoInfoList[0])  
              mVideoViewContainer.children.forEach { child ->  
                  val videoView = child as EduVideoView  
                  val newVideoInfo = newVideoInfoList.find { it.url == videoView.mediaInfo.videoUrl }  
                  if (newVideoInfo != null) {  
                      setVideoViewLayoutParams(videoView, newVideoInfo)  
                  }  
              }  
          }  
      }
  • 2.3 处理触摸事件

    经过上一步,我们已经可以在前端<video>上面盖一层VideoView,如上图所示。但是当用户滑动时,页面会露馅。所以这一步,我们要让VideoView与前端<video>同步滚动,那样才不会露馅。 (对Android触摸事件不熟悉的,建议先看下这个文章:Android触摸事件分发机制详解

    • 2.3.1 分析需要处理的问题

      java 复制代码
      - FrameLayout(父容器)
        - WebView(底层)
        - WebVideo2NativeVideo(顶层)
          - FrameLayout
            - FrameLayout
              - VideoView

      先回顾下页面的结构树,默认情况下,触摸事件的传递是这样的(省略与触摸事件传递关系不大的两层FrameLayout):

      由于WebViewWebVideo2NativeVideo同属于FrameLayoutWebView位于底层,所以触摸事件是不会传递给它的。但WebView是需要响应触摸事件的,所以问题1:如何把触摸事件传递给WebView?

      WebView的上层还有VideoViewVideoView同样需要响应触摸事件且优先级更高。所以当触摸事件落在VideoView上面时,我们需要选择性的消费一些事件,所以问题2:VideoView如何选择性的消费事件?

      假设我们把前面两个问题解决了,触摸事件可以给到WebView,这个时候WebView能响应点击事件,滑动事件等。也就是说前端<video>可以滚动了,那为了让VideoView也可以滚动,我们需要让WebVideo2NativeVideo这个容器也可以滚动,所以问题3:如何让WebVideo2NativeVideoWebView同步滚动?

    • 2.3.2 解决问题

      下面按照事件响应的优先级,逐步分析解决上面的问题。

      问题2:VideoView如何选择性的消费事件? 当触摸事件落在VideoView时,我们才需要考虑这个问题。解决这个问题,需要从功能需求出发。目前的需求,VideoView需要响应左右滑动、点击等,但不响应上下滑动,所以统一把上下滑动事件过滤。

      做法也比较简单粗暴,就是在VideoViewVideoView本身是个FrameLayout容器)的onInterceptTouchEvent方法进行判断拦截。

      kotlin 复制代码
      // VideoView
      private var mDownX = 0F  
      private var mDownY = 0F  
      override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {  
          when (ev?.action) {  
              MotionEvent.ACTION_DOWN -> {  
                  mDownX = ev.rawX  
                  mDownY = ev.rawY  
              }  
              MotionEvent.ACTION_MOVE -> {  
                  val moveX = ev.rawX  
                  val moveY = ev.rawY  
                  val diffX: Float = Math.abs(moveX - mDownX)  
                  val diffY: Float = Math.abs(moveY - mDownY)  
                  if (diffY > diffX) {
                      return true  
                  }  
              }  
          }  
          return false  
      }

      问题1:如何把触摸事件传递给WebView? 上一步中,VideoView消费了点击、左右滑动等事件。那么剩下的事件就是:没落在VideoView上的事件、落在VideoView上的上下滑动事件。这些事件最终会来到WebVideo2NativeVideo,我们需要把这些事件传给WebView

      于是我们需要对WebVideo2NativeVideoonTouchEvent做特殊处理。

      java 复制代码
      // WebVideo2NativeVideo
      private var mWebView: WebView? = null  
      private var isTouching = false  
      override fun onTouchEvent(ev: MotionEvent?): Boolean {  
          if (ev?.action == MotionEvent.ACTION_DOWN) {  
              isTouching = true  
          } else if (ev?.action == MotionEvent.ACTION_MOVE) {  
              if (!isTouching) {  
                  // PS: 1
                  val mockDown = MotionEvent.obtain(ev).also { it.action = MotionEvent.ACTION_DOWN }  
                  mWebView?.dispatchTouchEvent(mockDown)  
              }  
              isTouching = true  
          } else if (ev?.action == MotionEvent.ACTION_UP || ev?.action == MotionEvent.ACTION_CANCEL) {  
              isTouching = false  
          }  
          return mWebView?.dispatchTouchEvent(ev) ?: false  
      }

      做法同样比较简单,直接把触摸事件传给WebView,但有个地方需要注意下:

      PS: 1 isTouching为false,代表"没被VideoView消费的上下滑动事件"。这些滑动事件直接传递给WebView是没用的,因为一个完整的事件流还需要ACTION_DOWN事件,所以我们要模拟一个ACTION_DOWN事件,那样WebView才会响应。

      问题3:如何让WebVideo2NativeVideoWebView同步滚动? 经过上一步,WebView已经可以滚动了,为了WebVideo2NativeVideo可以同步滚动,我们利用WebViewsetOnScrollChangeListener简单处理。

      java 复制代码
      // WebVideo2NativeVideo
      fun syncScroll(webView: WebView) {  
          this.scrollY = webView.scrollY  
          webView.setOnScrollChangeListener { _, _, scrollY, _, _ ->  
              this.scrollY = scrollY  
          }  
          mWebView = webView  
      }
  • 2.4 处理页面细节

    经过上面的处理,VideoView与前端<video>就可以同步滚动了,但有些细节还需要处理下。

    • 2.4.1 页面上下padding

      如上图所示,由于VideoViewAndroid端添加的,不受前端ToolBar遮挡,所以在向上滑动过程中,Android端的VideoView没有很好的隐藏,导致露馅。

      所以我们需要给WebVideo2NativeVideo添加topPadding,同时不分发落在topPadding内的触摸事件(原因见下面),topPadding的大小可以来自前端。如果底部也有类似前端ToolBar的前端BottomBar,那么需要类似的处理WebVideo2NativeVideobottomPadding

      如果分发topPadding内的触摸事件,如果刚好VideoViewtopPadding区域内,会导致触摸事件被VideoView消费,传递不到前端,从而导致前端的控件无法响应(如:返回键)。

      java 复制代码
      // WebVideo2NativeVideo
      fun setExtraPadding(topPadding: Int = paddingTop, bottomPadding: Int = paddingBottom) {  
          setPadding(0, topPadding, 0, bottomPadding)  
      }
      
      override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {  
          if (ev != null) {  
              if (ev.y <= paddingTop || ev.y >= (height - paddingBottom)) {  
                  return false  
              }  
          }  
          return super.dispatchTouchEvent(ev)  
      }
    • 2.4.2 前端弹窗处理

      如上图所示,前端的弹窗会被VideoView遮挡,所以需要前端在弹窗的时候,告诉Android端,Android端做隐藏。

3. 总结

经过一番操作,就可以达到上面的效果。把前端<video>转化为AndroidVideoView后,可以更好的拓展一些视频的能力,如画中画、投屏、视频下载等。

相关推荐
Mercury Random14 分钟前
Qwen 个人笔记
android·笔记
苏苏码不动了21 分钟前
Android 如何使用jdk命令给应用/APK重新签名。
android
一只欢喜28 分钟前
uniapp使用uview2上传图片功能
前端·uni-app
aqi0037 分钟前
FFmpeg开发笔记(五十三)移动端的国产直播录制工具EasyPusher
android·ffmpeg·音视频·直播·流媒体
尸僵打怪兽41 分钟前
后台数据管理系统 - 项目架构设计-Vue3+axios+Element-plus(0920)
前端·javascript·vue.js·elementui·axios·博客·后台管理系统
ggome1 小时前
Uniapp低版本的安卓不能用解决办法
前端·javascript·uni-app
Ylucius1 小时前
JavaScript 与 Java 的继承有何区别?-----原型继承,单继承有何联系?
java·开发语言·前端·javascript·后端·学习
前端初见1 小时前
双token无感刷新
前端·javascript
、昔年1 小时前
前端univer创建、编辑excel
前端·excel·univer
emmm4591 小时前
前端中常见的三种存储方式Cookie、localStorage 和 sessionStorage。
前端