简介:这份资料面向具备一定 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 快出一个量级。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐