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后,可以更好的拓展一些视频的能力,如画中画、投屏、视频下载等。

相关推荐
JeffongTan4 小时前
在LWC中镶嵌VF Page获取用户IP
前端·javascript·salesforce
91刘仁德6 小时前
MYSQL 事务原理及使用
android·mysql·adb
陆枫Larry7 小时前
Astro 是什么
前端
一笑的小酒馆7 小时前
AndroidKMP之瀑布流实现
android
RainyJiang7 小时前
AI时代下,Android的边界正在消失
android·openai·ai编程
是立不是利8 小时前
写给设计师的 CSS 博客美学指南
前端·css
Android-Flutter8 小时前
android 内存抖动详解
android
默_笙9 小时前
🏝 Docker 就是"房地产开发":从施工图纸到小区物业的容器化指南
前端·javascript
计算机魔术师9 小时前
Rohan Paul 谈用时间跨度衡量智能体能力,并引用 OpenAI 自动化研究实习生里程碑
前端
kyriewen9 小时前
我手写了个极简版 React Router——才搞懂 v6 为什么砍了这么多 API
前端·javascript·程序员