乐于分享
好东西不私藏

uniApp 多端 video视频播放解决方案

uniApp 多端 video视频播放解决方案

uni-app 在 APP-PLUS(即 App 端)的视频播放,本质是原生组件方案——底层用 ijkplayer 库实现,而不是 H5 的 <video> 标签。所以"怎么播"只是入口,"怎么解决原生组件层级 / 全屏横屏 / 直播流"才是真正的工程难点。下面按三种典型场景给出可落地的方案。

🎯 方案一:常规点播 —— 直接用 <video> 组件

这是 90% 场景的首选。App 端 <video> 是原生组件,打包时必须勾选 manifest.json -> App 模块权限配置 -> VideoPlayer 模块(该模块体积较大,非默认内置)。

<template>  <video    id="myVideo"    :src="videoSrc"    controls    :autoplay="false"    @error="onVideoError"    @timeupdate="onTimeUpdate">  </video></template><script>export default {  data() {    return { videoSrc'https://example.com/video.mp4' }  },  onReady() {    this.videoCtx = uni.createVideoContext('myVideo'this)  },  methods: {    play() { this.videoCtx.play() },    pause() { this.videoCtx.pause() },    seek(t) { this.videoCtx.seek(t) },          // 跳转,单位秒    setRate(r) { this.videoCtx.playbackRate(r) } // 倍速:0.5/0.8/1.0/1.25/1.5  }}</script>

关键约束

App 端 <video> 使用 ijkplayer 库实现,比腾讯视频等商业 SDK 仍有差距,无法满足需求时可考虑插件市场的商业 SDK
如果想使用非原生的 video(即普通的 HTML5 自带 video),App 端有 2 种方案,具体参考官方文档
如果使用的视频路径为本地路径,需要配置资源为释放模式:在 manifest.json 文件内 app-plus 节点下新增 runmode 配置,设置值为 liberate

⚠️ 3.6.14 及以上 + iOS 16 以上全屏:必须在 manifest.json 的 app-plus 节点下新增 screenOrientation 配置:

"screenOrientation": ["portrait-primary","portrait-secondary","landscape-primary","landscape-secondary"]

📐 方案二:自定义 UI / 解决层级遮挡 —— cover-view 或 subNVue

非 H5 端 <video> 是原生组件,层级高于普通前端组件。想在上面盖自定义控件(倍速按钮、弹幕、礼物动画),普通 <view> 会被压住,必须用以下手段:

方案
适用场景
要点
cover-view
简单覆盖(按钮、文字)
只能嵌套 cover-view/cover-image,CSS 支持有限
subNVue
复杂覆盖(自定义控制栏、弹幕层)
原生渲染,可做任意 UI,通过 uni.$emit/on 通信
plus.nativeObj.view
5+ App 模式
更低层,性能最好但开发成本高

CSDN 上较成熟的实践是做一个统一接口层NativeVideoWrapper.vue,内部按平台分支:

业务层 → 统一接口层 → 平台适配层                        ├── H5 → HTML5 Video                        ├── 微信小程序 → createVideoContext                        └── App → plus.video.createVideoPlayer

这样业务代码不感知平台差异,扩展新平台只需加一个适配层。


🎬 方案三:直播流 / 强制横屏 / 系统级播放 —— plus.video 原生播放器

当 <video> 组件满足不了需求(直播流、需要完全接管全屏、要调系统播放器),直接调 5+ Engine 的 plus.video

// 仅 APP-PLUS 环境// #ifdef APP-PLUSconst player = plus.video.createVideoPlayer('videoPlayer', {  src: 'https://example.com/live.m3u8',  top: '0px',  left: '0px',  width: '100%',  height: '100%',  fullscreen: true})player.play()player.setFullscreen(true// 自动旋转屏幕// #endif

plus.video 原生播放器支持的协议比 <video> 组件宽:mp4、flv 格式;http/https、rtmp、hls、rtsp 直播流均支持。

强制横屏播放的完整配置:

// manifest.json -> app-plus{  "orientation": ["landscape-primary"],  "screenOrientation": ["landscape-primary"]}

并在 pages.json 中对视频页单独设置:

{  "path""pages/video-player",  "style": {    "app-plus": {      "hardwareAccelerated"true,      "orientation""landscape"    }  }}

💡 调用 videoContext.requestFullScreen() 失败的常见原因:iOS 要求禁用原生控件(controls="false")、视频源必须是 HTTPS、未开启横屏配置。


⚠️ 三个高频踩坑点

1. Android 小黑块,iOS 正常几乎都是编码问题。用 FFmpeg 转成 H.264 + MP4:

ffmpeg -i input.mp4 -c:v libx264 -c:a aac output.mp4

避免 H.265、AV1、WebM 等 Android 兼容性差的格式。若仍黑块,在 pages.json 该页面 app-plus 下设置 "hardwareAccelerated": false

2. 进度条拖动卡死 / timeupdate 不触发uni-app 的 <video> 在非 H5 端不支持 HLS(.m3u8)协议,也不支持 MSE。服务端务必开启 Accept-Ranges: bytes 响应头,否则拖动只能从头加载。

3. content:// 路径播不了Android 上 uni.chooseMedia 返回的是 content:// 协议,必须转换:

let path = res.tempFiles[0].tempFilePathif (path.startsWith('content://')) {  path = plus.io.convertLocalFileSystemURL(path) // → file://...}this.videoSrc = path

APP-PLUS 端做"高仿抖音竖滑",唯一能扛住流畅度要求的方案是 nvue 页面 + 垂直 swiper + 原生 video。官方文档明确:微信基础库 2.4.0+ 和 App 端 nvue 2.1.5+,才可以通过在垂直的 swiper 中内嵌 video 来实现;非 H5 端 <video> 是原生组件,普通 <view> 盖不住,必须用 cover-view 绘制 UI 层。

下面是一套可直接落地的实现。

一、架构选型:为什么必须用 nvue

方案
App 端表现
结论
vue 页面 + swiper + video
视频层级最高,swiper 滑动时画面卡在上一个视频不动,声音切了画面没切
❌ 不可用
nvue 页面 + swiper + video
真原生渲染,video 支持 gestureConfigdirection,touch 事件可被完整捕获
✅ 唯一解
插件市场商业 SDK
功能全但体积大、收费
💰 预算充足可选

💡 如果你的项目同时要兼容 H5 和小程序,建议路由级分平台:App 走 nvue 页面,H5/小程序走 vue 页面。同路由下同时放同名 .vue 和 .nvue,HBuilderX 会自动按平台选择。

二、核心页面结构(nvue)

<!-- pages/feed/feed.nvue --><template>  <swiper    class="swiper"    :vertical="true"    :current="currentIndex"    :duration="250"    @change="onSwiperChange"    @animationfinish="onAnimationFinish">    <swiper-item      v-for="(item, index) in visibleList"      :key="item.id"      class="swiper-item">      <!-- 只渲染当前项前后各1个 video,其余不挂 video -->      <video        v-if="isActive(index)"        :ref="'video_' + item.id"        class="video"        :src="item.url"        :autoplay="index === currentIndex"        :loop="true"        :muted="index === 0 ? true : false"        show-center-play-btn="false"        show-fullscreen-btn="false"        show-progress="false"        object-fit="cover"        @play="onPlay"        @pause="onPause"        @ended="onEnded(index)"        @loadedmetadata="onLoadedMeta(index)">      </video>      <!-- 封面兜底:video 未就绪时显示 -->      <image        v-if="!item.ready"        :src="item.cover"        class="cover"        mode="aspectFill">      </image>      <!-- UI 层:必须用 cover-view,普通 view 盖不住原生 video -->      <cover-view class="ui-layer">        <!-- 右侧操作栏 -->        <cover-view class="side-bar">          <cover-view class="side-btn" @click="onLike(item)">            <cover-image class="side-icon" :src="item.liked ? likeActive : likeNormal"></cover-image>            <cover-view class="side-text">{{ item.likes }}</cover-view>          </cover-view>          <cover-view class="side-btn" @click="onComment(item)">            <cover-image class="side-icon" :src="commentIcon"></cover-image>            <cover-view class="side-text">{{ item.comments }}</cover-view>          </cover-view>          <cover-view class="side-btn" @click="onShare(item)">            <cover-image class="side-icon" :src="shareIcon"></cover-image>            <cover-view class="side-text">分享</cover-view>          </cover-view>        </cover-view>        <!-- 底部信息 -->        <cover-view class="bottom-info">          <cover-view class="author">@{{ item.author }}</cover-view>          <cover-view class="title">{{ item.title }}</cover-view>        </cover-view>        <!-- 双击点赞动画锚点 -->        <cover-view          class="double-tap-area"          @click="onSingleTap(index)"          @doubleclick="onDoubleTap(item, $event)">        </cover-view>      </cover-view>    </swiper-item>  </swiper></template>

三、关键 JS 逻辑

<script>export default {  data() {    return {      currentIndex0,      videoList: [],       // 完整列表      visibleList: [],     // 当前渲染的窗口(current-1 ~ current+1)      likeNormal'/static/icon-like.png',      likeActive'/static/icon-like-active.png',      commentIcon'/static/icon-comment.png',      shareIcon'/static/icon-share.png'    }  },  onLoad(query) {    this.loadVideoList(query.feedId)  },  onReady() {    // 首屏:静音自动播放,规避 iOS 自动播放策略    this.playCurrent(true)  },  methods: {    // === 列表与窗口管理 ===    loadVideoList(feedId) {      // 从接口拉取,这里用假数据示意      this.videoList = [        { id: 1, url: 'https://xxx/video1.mp4', cover: '...', author: '张三', title: '第一条视频', likes: 1200, comments: 88, liked: false, ready: false },        { id: 2, url: 'https://xxx/video2.mp4', cover: '...', author: '李四', title: '第二条视频', likes: 3200, comments: 156, liked: false, ready: false },        // ...更多      ]      this.refreshVisible()    },    // 只保留 current-1, current, current+1 三个 video 实例    refreshVisible() {      const start = Math.max(0this.currentIndex - 1)      const end = Math.min(this.videoList.length - 1this.currentIndex + 1)      this.visibleList = this.videoList.slice(start, end + 1)    },    isActive(index) {      const realIndex = this.currentIndex - 1 + index      return Math.abs(realIndex - this.currentIndex) <= 1    },    // === 滑动切换 ===    onSwiperChange(e) {      const next = e.detail.current      if (next === this.currentIndex) return      // 1. 暂停旧的      this.pauseAt(this.currentIndex)      // 2. 更新索引      this.currentIndex = next      // 3. 刷新渲染窗口      this.refreshVisible()      // 4. 播放新的      this.$nextTick(() => {        this.playCurrent(this.currentIndex === 0)      })    },    onAnimationFinish(e) {      // 滑动动画结束后,清理非相邻 video 资源      this.releaseDistantVideos()    },    playCurrent(muted = false) {      const item = this.videoList[this.currentIndex]      if (!item) return      const ctx = uni.createVideoContext('video_' + item.id, this)      ctx.play()      if (!muted) {        // 首屏之后取消静音        setTimeout(() => ctx.mute(false), 100)      }    },    pauseAt(index) {      const item = this.videoList[index]      if (!item) return      const ctx = uni.createVideoContext('video_' + item.id, this)      ctx.pause()    },    releaseDistantVideos() {      // 滑出视口 500ms 后释放:pause + src置空 + load      setTimeout(() => {        this.videoList.forEach((itemidx=> {          if (Math.abs(idx - this.currentIndex) > 1) {            const ctx = uni.createVideoContext('video_' + item.id, this)            ctx.pause()            item._released = true          }        })      }, 500)    },    // === video 事件 ===    onLoadedMeta(index) {      this.videoList[index].ready = true    },    onEnded(index) {      // 抖音式循环播放,不自动跳下一个      const item = this.videoList[index]      const ctx = uni.createVideoContext('video_' + item.id, this)      ctx.seek(0)      ctx.play()    },    // === 交互 ===    onLike(item) {      item.liked = !item.liked      item.likes += item.liked ? 1 : -1    },    onDoubleTap(iteme) {      if (!item.liked) {        item.liked = true        item.likes++      }      // 这里可以加个爱心飘出的动画 cover-view    },    onSingleTap(index) {      // 单击:播放/暂停切换      const item = this.videoList[index]      const ctx = uni.createVideoContext('video_' + item.id, this)      // 用 playing 状态判断,这里简化      ctx.pause()    }  }}</script>

四、样式要点(nvue 用 flex 布局)

<style scoped>.swiper { flex1width750rpx; }.swiper-item { width750rpx; height1334rpx; positionrelative; }.video { width750rpx; height1334rpx; }.cover { positionabsolutetop0left0width750rpx; height1334rpx; }.ui-layer {  positionabsolutetop0left0right0bottom0;}.double-tap-area {  positionabsolutetop0left0right0bottom200rpx;}.side-bar {  positionabsoluteright24rpx; bottom240rpx;  flex-directioncolumnalign-itemscenter;}.side-btn { flex-directioncolumnalign-itemscentermargin-bottom40rpx; }.side-icon { width80rpx; height80rpx; }.side-text { color#ffffont-size24rpx; margin-top8rpx; }.bottom-info {  positionabsoluteleft24rpx; right160rpx; bottom60rpx;}.author { color#ffd700font-size32rpx; font-weightbold; }.title { color#ffffont-size28rpx; margin-top12rpx; }</style>

五、manifest.json 必配项

{  "app-plus": {    "modules": {      "VideoPlayer": {}    },    "nvue": {      "compiler""uni-app"    },    "screenOrientation": ["portrait-primary"],    "launchwebview": {      "renderType""always"    }  }}

nvue 页面必须配置沉浸式导航栏(在 pages.json 中):

{  "path""pages/feed/feed",  "style": {    "app-plus": {      "navigationBarTitleText""",      "navigationStyle""custom",      "backgroundColor""#000000"    }  }}

六、五个高频踩坑点

1. swiper 嵌套 video 后画面不跟随滑动这是 vue 页面的经典问题——视频是独立原生图层,swiper 切换时画面层不跟着动。必须用 nvue 页面,真原生渲染才没有这个问题。

2. iOS 首屏黑屏不自动播浏览器策略要求自动播放必须静音。首屏 video 必须 :muted="true" + :autoplay="true",等用户有过交互或其他视频再取消静音。

3. 内存爆炸一次性渲染 100 个 video,App 端内存直接飙到 500MB+。只保留 current-1, current, current+1 三个实例,滑出视口 500ms 后 pause() + 清空 src + load() 释放解码资源。

4. cover-view 的限制

App 端 nvue 的 cover-view无法嵌套无法内部滚动无法覆盖到视频全屏界面
所有 UI 元素必须是 cover-view / cover-image 的直接子节点
复杂动画(如飘心)建议用 subNVue 辅助

5. 双击/单击手势冲突cover-view 支持 @click 和 @doubleclick,但单击会有 300ms 延迟(等双击判定)。抖音式的"双击点赞 + 单击暂停"需要自己在 touchstart/touchend 里算时间间隔,或者用 @doubleclick + 延时单击方案。

七、性能优化清单

✅ object-fit="cover" 让视频填满屏幕
✅ preload="auto"(nvue 端),让系统自动管理缓冲
✅ 视频编码统一 H.264 + AAC,避免 Android 软解
✅ 封面图先加载,video loadedmetadata 后再隐藏封面
✅ 列表滚动用 v-if 控制 video 挂载,不要靠 display
✅ 双击点赞动画用 subNVue 承载,避免 cover-view 重排

总结 方案选型决策树

普通点播 + 默认控件 → 方案一,<video> + uni.createVideoContext
需要自定义控制栏 / 倍速 / 弹幕 → 方案一 + cover-view 或 subNVue
直播流(RTMP/HLS/RTSP)/ 强制横屏 / 系统播放器 → 方案三,plus.video.createVideoPlayer
商业级需求(DRM、清晰度切换、广告) → 插件市场商业 SDK