uni-app短视频直播:nvue破解video原生层级与竖滑适配
简介:这份资料面向具备一定 Vue 与 uni-app 基础的移动端开发者,聚焦跨平台直播类应用的实战复刻,帮助读者理清从界面仿制到多端兼容的完整实现路径。资源包为单个 PDF 文件,共 1 个文件,体积约 80KB,以图文形式集中呈现项目架构与关键代码片段,便于快速浏览与对照查阅。内容围绕 uni-liveShow 微直播项目展开,涵盖小视频、IM 聊天、直播室三大模块:借助 swiper 组件实现仿抖音的上下滑动切换与循环播放、仿陌陌直播室的实时视频流观看与互动、直播间内文字表情图片的即时通讯,并涉及自定义透明导航栏与底部 Tabbar、uniPop 弹窗组件、iconfont 图标库以及 vuex 状态管理等技术点。文中还给出用 uni.getSystemInfo 适配状态栏与导航栏高度的多端方案,并讨论了直播流性能优化、交互反馈设计与内容安全等落地难点。目前已有 4346 人学习,适合需要搭建直播类跨端应用参考骨架的开发者。
1. 竖滑短视频加直播间,难点从来不在页面,而在 video 的原生层级
做过 uni-app 直播类需求的人基本都撞过同一个墙:弹幕、礼物面板、商品卡片全写好了,H5 上一切正常,到了 App 真机就被视频压得死死的,按钮点不动、浮层看不见。uni-liveShow 这个基于 vue + uni-app 的微直播项目,把抖音式竖滑小视频、陌陌式直播室和 IM 聊天塞进一套代码,拉开差距的地方恰好就在这——小视频走 vue 页面,直播页改成 nvue 页面,两种渲染方式对应两套 video 层级规则。这套代码适合三类人:正在做 uni-app 短视频或直播模块的前端、想把单端项目迁到多端的人、以及在 nvue 里被 flex 布局反复折磨的人。下面按多端适配、竖滑播放控制、nvue 直播页、真机排错的顺序拆一遍,能直接抄的结构和参数都标出来了。
2. 状态栏高度、自定义导航栏与多端取值差异
自定义导航栏是这套项目第一个绕不开的技术点。只要页面上要放透明导航、沉浸式直播顶栏,就必须自己算状态栏高度和标题栏高度,而 uni-app 在各端返回的字段完全不一样,写死一个 44px 是最常见的翻车方式。
2.1 statusBarHeight 与 customBar 的计算逻辑
项目把计算放在 App.vue 的 onLaunch 里,全局只跑一次,结果挂在 Vue.prototype 上供所有页面读取:
// App.vue —— 全局算一次,挂到原型上给所有页面复用
<script>
import Vue from 'vue'
export default {
onLaunch: function () {
uni.getSystemInfo({
success: function (e) {
// 状态栏高度:App 和小程序有值,H5 端通常为 0
Vue.prototype.statusBar = e.statusBarHeight
// #ifndef MP
if (e.platform == 'android') {
Vue.prototype.customBar = e.statusBarHeight + 50 // Android 标题栏按 50px
} else {
Vue.prototype.customBar = e.statusBarHeight + 45 // iOS 标题栏按 45px
}
// #endif
// #ifdef MP-WEIXIN
// 微信小程序用胶囊按钮位置反推,比写死数值稳
let custom = wx.getMenuButtonBoundingClientRect()
Vue.prototype.customBar = custom.bottom + custom.top - e.statusBarHeight
// #endif
// #ifdef MP-ALIPAY
Vue.prototype.customBar = e.statusBarHeight + e.titleBarHeight
// #endif
}
})
}
}
</script>
逻辑上分三步走:先拿系统给的状态栏高度,再按平台补标题栏高度,最后按端做条件编译分支覆盖。几个参数值得单独说清楚—— statusBarHeight 是刘海屏顶部那块区域的高度,直播页顶部工具栏的 padding-top 直接用它; platform 在 App 端区分 android/ios,标题栏高度差 5px,肉眼看不出来但图标居中对齐时很要命;微信小程序不提供 titleBarHeight,所以用 getMenuButtonBoundingClientRect() 拿胶囊的 top 和 bottom 反推,这样即使官方调整胶囊尺寸也不会错位。
有个容易被忽略的坑: uni.getSystemInfo 的回调是异步的,而页面 onLoad 可能先执行。稳妥写法是在模板里做兜底,比如 :style="{ paddingTop: (statusBar || 0) + 'px' }" ,别让首屏出现一次跳动。
| 端 | 状态栏来源 | 标题栏算法 | 常见坑 |
|---|---|---|---|
| App-Android | e.statusBarHeight | statusBarHeight + 50 | 部分机型状态栏高度返回 0,需兜底 |
| App-iOS | e.statusBarHeight | statusBarHeight + 45 | 刘海屏与非刘海屏差值明显 |
| 微信小程序 | e.statusBarHeight | 胶囊 rect 反推 | 别用 getSystemInfo 里的 windowHeight 直接减 |
| 支付宝小程序 | e.statusBarHeight | + e.titleBarHeight | 字段直接给,别再做反推 |
| H5 | 通常为 0 | 按 45 处理即可 | 浏览器地址栏会挤占高度,别依赖 100vh |
2.2 header-bar 的 props 设计与透明导航实现
顶部导航被封装成 header-bar 组件,透明效果靠一个开关式 prop 加背景色对象实现:
<!-- 个人主页:透明导航栏 + 左右两个 slot 图标 -->
<header-bar :isBack="true" title=" " :bgColor="{background: 'transparent'}" transparent>
<text slot="back" class="uni_btnIco iconfont icon-guanbi" style="font-size: 25px;"></text>
<text slot="iconfont" class="uni_btnIco iconfont icon-dots mr_5" style="font-size: 25px;"></text>
</header-bar>
isBack 决定左侧是返回箭头还是关闭叉号,直播页、个人主页这类需要一键退出的场景用关闭图标更顺手; title 传一个空格是故意的,透明导航下文字会和背景图打架; bgColor 接收对象而不是字符串,是为了能直接塞渐变色,比如 {background: 'linear-gradient(...)'} ; transparent 这个 prop 除了去掉默认边框和阴影,还要保证组件仍然占位——因为页面主体需要靠 padding-top 顶下去,组件塌陷会让内容整体上移。
配套的页面配置不能漏,pages.json 里必须关掉原生导航栏:
{
"path": "pages/user/index",
"style": {
"navigationStyle": "custom",
"app-plus": { "titleNView": false }
}
}
只写 navigationStyle 在 H5 和小程序生效,App 端还要靠 titleNView: false 把原生标题栏彻底去掉,否则会出现两条导航栏叠在一起。
2.3 各端渲染差异与打包后布局异常的排查
vue 页面在 H5、小程序、App 三端都能跑,但尺寸单位的表现不一样。vue 页面用 rpx 基本没问题,到了 nvue 里就要谨慎——nvue 的默认设计宽度虽然也是 750,但部分 CSS 属性不支持,我一般改成 uni.getSystemInfoSync() 拿到 windowWidth 、 windowHeight 后直接算 px,配合 :style 绑定,可控性高很多。
至于打包后布局异常,踩得最多的两类:一是图标全部变成方块,检查 iconfont 的字体文件有没有进 static 目录、 @font-face 的 url 是不是相对路径,H5 打包后路径基准会变;二是顶部内容被状态栏挡住,多半是上面说的异步取值没兜底。排查顺序建议固定下来:先在真机打印 uni.getSystemInfoSync() 的完整对象,再对照样式里的数值逐个核,比反复调 rpx 快得多。
3. swiper 驱动竖滑短视频:结构、播放控制与手势判定
仿抖音的竖滑交互,本质就是一个 vertical 的 swiper 加上一组 video 节点。看似简单,真写起来真正费时间的是播放时机和手势冲突。
3.1 竖滑骨架与 video 上下文初始化
<!-- 竖滑容器:垂直 swiper,一屏一个视频 -->
<swiper :indicator-dots="false" :duration="200" :vertical="true"
:current="videoIndex" @change="handleSlider" style="height: 100%;">
<block v-for="(item, index) in vlist" :key="index">
<swiper-item>
<view class="uni_vdplayer">
<video :id="'myVideo' + index" class="player-video" :src="item.src"
:controls="false" :loop="true" :show-center-play-btn="false"
objectFit="fill"></video>
<!-- 中间播放按钮,由 isPlay 控制显隐 -->
<view class="vd-cover" @click="handleClicked(index)">
<text v-if="!isPlay" class="iconfont icon-bofang"></text>
</view>
<!-- 右侧互动栏:点赞 / 评论 / 分享 -->
</view>
</swiper-item>
</block>
</swiper>
indicator-dots 关掉是因为抖音式界面不用圆点; duration: 200 是切换动画时长,调大到 400 会明显发飘; vertical 打开竖滑; current 双向绑定当前索引,从外部跳转进来带 option.index 时能直接定位到指定视频; controls: false 和 show-center-play-btn: false 是为了藏掉原生控件,自己画一套 UI。
video 节点的 id 必须全局唯一,所以用 'myVideo' + index 拼。拿到句柄不能靠 $refs ,App 端和 nvue 下 ref 对 video 支持不稳,正确姿势是 uni.createVideoContext(id, this) :
onReady() {
this.init()
},
methods: {
init() {
this.videoContextList = []
for (let i = 0; i < this.vlist.length; i++) {
// 每个 video 单独建上下文,后续按索引控制
this.videoContextList.push(uni.createVideoContext('myVideo' + i, this))
}
setTimeout(() => { this.play(this.videoIndex) }, 200)
}
}
放在 onReady 而不是 onLoad ,是因为节点还没渲染完时创建上下文会失败。外面包一层 200ms 的 setTimeout 是为了等首个视频可播,不加的话首屏容易黑着不动。
3.2 handleSlider 里的暂停、归零与播放时机
handleSlider(e) {
let curIndex = e.detail.current
if (this.videoIndex >= 0) {
// 上一个视频:暂停并回到 0 秒,避免下次进来从中段续播
this.videoContextList[this.videoIndex].pause()
this.videoContextList[this.videoIndex].seek(0)
this.isPlay = false
}
// 只在相邻滑动时播放,防止快速连续滑动导致多个视频同时出声
if (curIndex === this.videoIndex + 1) {
this.videoContextList[this.videoIndex + 1].play()
this.isPlay = true
} else if (curIndex === this.videoIndex - 1) {
this.videoContextList[this.videoIndex - 1].play()
this.isPlay = true
}
this.videoIndex = curIndex
}
pause() 和 seek(0) 的顺序不能反,先暂停再归零,否则会出现短暂的一帧残留画面。判断 curIndex === videoIndex ± 1 而不是直接 play(curIndex) ,是为了处理用户从列表跳转或快速甩动的情况——只有相邻切换才播,跳着切就不播,避免几个视频抢音频通道。 pause 之后同步把 this.isPlay 置 false,中间那个播放按钮的显隐状态才会跟着更新。
| 方法 | 作用 | 注意点 |
|---|---|---|
| play(index) | 播放指定视频并置 isPlay=true | 索引必须和 video 上下文一一对应 |
| pause(index) | 暂停并置 isPlay=false | 不 seek(0) 会续播 |
| seek(0) | 回到起始帧 | 在 pause 之后调用 |
| handleSlider | 滑动结束统一收口 | 只处理相邻切换 |
3.3 300ms 单击双击判定与播放暂停联动
竖滑视频里单击暂停、双击点赞是标配,用一次性定时器实现最省事:
handleClicked(index) {
if (timer) clearTimeout(timer) // 双击时清掉上一次待执行的单击
this.clickNum++
timer = setTimeout(() => {
if (this.clickNum >= 2) {
console.log('双击视频') // 这里接点赞逻辑
} else {
console.log('单击视频')
this.isPlay ? this.pause(index) : this.play(index)
}
this.clickNum = 0 // 无论哪种都重置计数
}, 300)
}
timer 要定义在组件外部或 data 之外,避免被响应式系统反复劫持。300ms 是业界常用的双击判定窗口,小于 250ms 很多人点不出来,大于 400ms 单击会有明显延迟感,播放暂停响应就变得迟钝。同时 clickNum 在回调里归零,不做这一步会出现第三次点击被误判成双击的情况。
注意:这个判定和 swiper 的滑动是并存的,滑动结束后 event 仍可能触发 tap,所以别在 handleClicked 里做状态切换之外的重操作,点赞接口调用放在双击分支里做防抖。
3.4 m3u8 视频源在多端的处理差异
短视频的 src 直接填 mp4 是最省心的,但直播回放、切片源常常给 m3u8。这类源在 App 端由原生播放器接管,直接填地址就能播;H5 端浏览器原生 video 标签基本不支持,常见做法是交给 hls.js 或 video.js 挂载到同一个 video 容器上,通过 #ifdef H5 分支加载;小程序端原生 video 不支持 m3u8,需要走 live-player 或后端转封装成 mp4/hls 兼容格式。选型上别硬扛,接口层做一次格式判断,返回不同地址给不同端,比在小程序里折腾播放器靠谱得多。
4. nvue 直播页:video 原生层级覆盖与 flex 布局约束
直播页是整套代码里技术含量最高的部分。原因很简单:vue 页面在 App 端是 webview 渲染,video 是原生控件,层级天生最高,任何 view 都盖不住它;而 nvue 页面走原生渲染,所有节点在同一棵原生视图树里,覆盖问题自然消失。
4.1 vue 页面与 nvue 页面的层级模型对比
| 对比项 | vue 页面 | nvue 页面 |
|---|---|---|
| App 端渲染方式 | webview 渲染 | 原生渲染(weex 体系) |
| video 层级 | 原生控件,层级最高 | 普通节点,可被覆盖 |
| 弹窗/浮层覆盖 video | 不行 | 可以 |
| CSS 支持 | 完整 | 仅 flex 布局,部分属性不支持 |
| 字体图标 | @font-face | 需 weex dom.addRule |
| 适用场景 | 复杂图文页面 | 直播、长列表、需要覆盖原生控件 |
实测下来,直播间用 nvue 是刚需,不是优化选项。
4.2 live.nvue 的结构与样式约束
<template>
<div class="nlv__container">
<view class="nlv_main">
<!-- 横向 swiper 切直播间,和竖滑短视频方向相反 -->
<swiper class="nlv-swiper" :indicator-dots="false" :vertical="false"
:current="videoIndex" @change="handleSlider">
<swiper-item v-for="(item, index) in vlist" :key="index">
<view class="nlv-playerbox">
<video :id="'myVideo' + index" :src="item.src"
:autoplay="index == videoIndex" :controls="false" :loop="true"
:show-center-play-btn="false" objectFit="fill"
:style="{height: winHeight, width: winWidth}"></video>
<!-- 顶部工具栏:用 padding-top 顶开状态栏 -->
<view class="nlv_topbar" :style="{ height: headerBarH, 'padding-top': statusBarH }"></view>
<!-- 排名条,top 值跟着工具栏高度走 -->
<view class="nlv-rankbox" :style="{ top: headerBarH }">
<view class="nlv-rkls">
<text class="rkitem">总排名{{ item.allRank }}</text>
<text v-if="item.hourRank" class="rkitem">小时榜第{{ item.hourRank }}名</text>
</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
<live-cart ref="liveCart" :vlist="vlist" />
<roll-msg ref="rollMsg" :vlist="vlist" />
<live-gift ref="liveGift" />
</div>
</template>
节点结构上有个关键点:video 的宽高没有写死在 CSS 里,而是用 winHeight 、 winWidth 绑定。nvue 里 100% 在视频上不稳定,尤其在横竖屏切换或分屏状态下会算错,直接用系统尺寸算 px 最稳。 autoplay 用一个 index == videoIndex 的布尔值控制,只有当前这一屏自动播放,其他屏静默。
nvue 的 CSS 有几条硬规则要记住:布局全部走 flex,默认 flex-direction: column ,所以横向排布必须显式声明;selector 支持度有限,多级嵌套和复杂后代选择器会失效,类名尽量平铺; box-shadow 、 transform 的部分写法、 position: fixed 的表现都和 web 不一致。项目里顶部栏和排名条的位置计算全部靠 top 、 padding-top 这类最基础的属性,反而是最不容易出问题的写法。
4.3 weex dom.addRule 注入 iconfont 字体
nvue 页面不支持 @font-face ,字体图标必须用 weex 的模块接口注入,而且必须在 beforeCreate 里做,早于任何渲染:
beforeCreate() {
// #ifdef APP-PLUS
const domModule = weex.requireModule('dom')
domModule.addRule('fontFace', {
fontFamily: "nvueIcon", // 名字要和 CSS 里 font-family 完全一致
'src': "url('../../../static/fonts/iconfont.ttf')" // 相对本文件的路径,不是根路径
})
// #endif
}
三处细节决定成败: fontFamily 的字符串必须和样式里写的 font-family: nvueIcon 一模一样,大小写敏感; src 的路径是相对于当前 nvue 文件算的,写成 /static/... 在 App 端加载不到;整段要用 #ifdef APP-PLUS 包住,因为 H5 和小程序没有 weex 对象,不加条件编译会在编译期直接报错。
| 引入方式 | 适用页面 | 是否支持变量路径 | 备注 |
|---|---|---|---|
| @font-face | vue 页面 | 支持 | 各端表现一致,最省事 |
| weex dom.addRule | nvue 页面 | 否,需相对路径 | 必须放 beforeCreate |
| base64 内联 | 全端 | 支持 | 字体大时体积膨胀明显 |
4.4 直播间弹窗组件的引用与通信
商品广告、滚动消息、礼物面板三个组件都挂在 swiper 外层,通过 ref 暴露 show 方法给页面调用:
// 打开礼物面板
handleLiveGift() {
this.$refs.liveGift.show()
},
// 打开商品卡片,把当前直播间索引传进去
handleLiveCart(index) {
this.$refs.liveCart.show(index)
},
// 打开滚动消息输入
handleRollMsg(index) {
this.$refs.rollMsg.show(index)
}
放在 swiper 外面而不是 swiper-item 内部,是为了避免切换直播间时弹窗被一起销毁重建。 vlist 作为 prop 透传给子组件,保证滚动消息和商品数据跟当前直播间一致。这些弹窗本身用 view 绝对定位实现,在 nvue 页面里能正常盖住 video,这也是直播页非得用 nvue 写的直接原因。
5. 滚动消息渲染与真机排错的具体手法
直播间最容易拖垮帧率的地方是滚动消息,一条一条往上刷,消息多了之后 scroll-view 的渲染压力很直观。
5.1 滚动消息的自动滚底与节流
<!-- 滚动消息区:scroll-y 纵向滚动,隐藏滚动条 -->
<scroll-view class="nlv-rollMsgPanel" :scroll-top="msgScrollTop"
:scroll-with-animation="true" scroll-y show-scrollbar="false">
<block v-for="(msgitem, msgidx) in item.rollmsg" :key="msgidx">
<view class="nlv-msglist">
<text class="msg_name">{{ msgitem.uname }}</text>
<text class="msg_text">{{ msgitem.content }}</text>
</view>
</block>
</scroll-view>
// 新消息进来后把 scroll-top 设成一个极大值,滚到底部
appendMsg(msg) {
this.vlist[this.videoIndex].rollmsg.push(msg)
this.$nextTick(() => {
// 每次比上一次大 1,保证值变化能触发滚动
this.msgScrollTop += 1000
})
}
节流上我一般做两件事:把消息数组长度卡在 50~80 条,超出就 shift() 掉最早的,DOM 数量不上去帧率就稳;高频礼物消息合并成一条「xxx 送出 x 个礼物」再 push,而不是一条一条进列表。
5.2 真机验证清单
| 现象 | 先查什么 | 常见原因 |
|---|---|---|
| 浮层点不动 | video 所在页面类型 | vue 页面写浮层,必须换 nvue |
| 图标全变方块 | 字体引入方式 | nvue 漏了 dom.addRule 或 fontFamily 不一致 |
| 顶部被状态栏压住 | statusBarH 取值时机 | onLaunch 异步,模板没做兜底 |
| 首屏黑屏不播 | createVideoContext 时机 | 没放 onReady 或缺少延时 |
| 切换后多个视频出声 | handleSlider 判断 | 未限制只在相邻切换时 play |
| 样式在真机错位 | 尺寸单位 | nvue 里用了 rpx 或百分比 |
另外两个只有真机才暴露的细节:一是 Android 上 seek(0) 偶发不生效,需要在 pause 之后延迟 50ms 再 seek;二是 nvue 页面返回时 video 不会自动释放,页面 onUnload 里要显式对每个上下文调一次 stop() ,否则连着进出几个直播间之后声音会串。
调这类问题我习惯先在页面顶部固定打印一次 uni.getSystemInfoSync() 的 statusBarHeight 、 windowHeight 、 platform 三个值,把数值对上了再动样式,比盲改 CSS 快出一个量级。
更多推荐
所有评论(0)