ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

仿抖音/陌陌的uni-app微直播项目拆解:小视频+IM+直播室实现与避坑

仿抖音/陌陌的uni-app微直播项目拆解:小视频+IM+直播室实现与避坑

简介:一份基于Vue和uni-app的微直播项目实现详解,面向需要开发仿抖音/陌陌直播室、小视频及IM聊天功能的开发者,以图文实例拆解uni-liveShow项目。文档涵盖了从项目概览到效果预览的完整链路,并重点展开核心技术与实现细节:跨端编译到H5/小程序/App、vue/Nvue与vuex状态管理、iconfont图标库、自定义导航栏及底部Tabbar、uniPop弹窗组件、通过swiper组件实现上下滑动切换视频播放等;同时给出设备顶部状态栏高度计算、透明导航栏适配等关键代码片段,便于读者直接复用。全部内容收录于1个PDF文档(共1个文件),压缩包约80KB,内容精炼,适合开发者利用碎片时间系统学习。目前已有4347人学习,读者可对照示例代码与适配方案,快速搭建跨端直播互动应用,规避常见踩坑点。

1. 仿抖音/陌陌的uni-app微直播项目:三合一小视频+IM+直播室该拆哪些代码

第一次拿到这套基于vue+uni-app的微直播项目源码时,我原本只是想看看仿某短视频平台的上下滑视频是怎么用swiper做出来的,结果发现它把直播室、IM聊天、小视频三件事全装进了一个uni-app工程。更意外的是项目里写直播页用的是nvue而不是vue文件,这个选择恰好解决了做直播类应用最头疼的video标签层级盖不住view的问题。项目可以编译到H5、微信小程序、支付宝小程序和App端,界面风格仿的是某短视频平台和某直播软件。如果你正打算用uni-app做直播、短视频或社交类产品,想找现成的自定义导航栏、弹窗组件、礼物面板、滚动消息、购物车挂载位,这套代码值得拆一拆。

2. 短视频模块:swiper上下滑动、播放器实例与单击双击逻辑

2.1 为什么短视频选择swiper而不是scroll-view

仿某短视频App的核心交互是上下滑动切换视频,并保证切换过程中只有一个视频在播放。用scroll-view做纵向滚动也能实现类似效果,但每个滚动位置对应一个视频,需要自己计算当前处于哪个视频、滚动停止后还要处理惯性导致的索引抖动。项目里用的是swiper的vertical模式,每个swiper-item对应一个video标签,切换完成后通过change事件拿到的current索引,天然就是目标视频的下标。swiper还有一项优势是预加载和回收策略由组件内部处理,视频处于当前页时自动布局,滑走后保留在DOM中但不播放,不会在快速滑动时出现黑屏或卡顿。

小视频模板代码的关键结构如下:

<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" :ref="'myVideo' + index" class="player-video" :src="item.src" :controls="false" :loop="true" :show-center-play-btn="false" objectFit="fill"> </video> <!-- 中间播放按钮 --> <view class="vd-cover flexbox" @click="handleClicked(index)"> <text v-if="!isPlay" class="iconfont icon-bofang"></text> </view> </view> </swiper-item> </block> </swiper>

这段模板有几个细节值得注意。id和ref动态拼接了index,原因在于后面要用uni.createVideoContext去创建播放器上下文,必须保证每个video有唯一id。controls设为false是为了隐藏系统播放控件,否则视频下方会出现进度条和全屏按钮,和短视频交互不符。loop为true让视频结束后自动重播,这也是仿某短视频平台连刷体验的基本要求。show-center-play-btn设为false,防止点击视频时中间跳出系统的大播放按钮,因为点击暂停/播放的事件完全由自定义cover层接管。

2.2 播放器实例管理与滑动切换的核心逻辑

创建和销毁播放器实例是这类页面最容易写乱的部分。经典做法是在页面onReady之后把所有video的上下文一次性收集到一个数组里,切换时对旧视频执行pause加seek(0),对新视频执行play。onReady时机非常重要,不要在onLoad里执行,因为此时video标签还没完成渲染,createVideoContext会拿不到真实上下文,导致播放器实例是空的。项目里还加了一个200毫秒的setTimeout,目的是等播放器上下文真正可用再执行首次播放。

init() { this.videoContextList = [] for(var i = 0; i < this.vlist.length; i++) { // 只能用uni.createVideoContext,不能直接用this.$refs this.videoContextList.push(uni.createVideoContext('myVideo' + i, this)) } setTimeout(() => { this.play(this.videoIndex) }, 200) }, handleSlider(e) { let curIndex = e.detail.current if(this.videoIndex >= 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 }

这里有个非常容易翻车的点,不要用this.$refs['myVideo' + i]来拿播放器实例,在App端某些版本下用ref拿到的不是组件实例而是DOM节点,直接调用play、pause方法会报错。常见做法是统一用uni.createVideoContext拼接video的id来创建。seek(0)这段代码的目的是把旧视频进度归零,这样滑动回来后又能从头开始播,而不是接着上次的进度。之所以判断curIndex是只能加一或减一,是在预处理逻辑中假设用户不会一次滑过多个视频,虽然swiper支持快速滑动跨多个index,但这里处理播放状态的判断是按相邻滑动假设的。

2.3 单击暂停与双击事件的300毫秒判定

点击视频需要区分单击和双击,项目里用了一个非常实用的小技巧:点击时先清掉上一次的timer,累加clickNum,300毫秒后统一处理。如果在300毫秒内又触发了一次点击,就判定为双击。这个手写方案避免了引入tap库,逻辑也清晰。单击时切换播/暂停状态,双击时预留一个埋点位置,实际直播产品里双击通常用来点赞或送爱心。

handleClicked(index) { if(timer){ clearTimeout(timer) } this.clickNum++ timer = setTimeout(() => { if(this.clickNum >= 2){ console.log('双击视频') } else { console.log('单击视频') if(this.isPlay) { this.pause(index) } else { this.play(index) } } this.clickNum = 0 }, 300) }

关于这段逻辑有两点要提醒。第一,双击后要不要顺带触发一次单击的播放切换,需要根据产品定义决定,现在是双击只输出日志,不影响播放状态,这是合理的,否则用户双击点赞时会连带切换播放暂停,体验很怪。第二,timer定义在data外面作为一个模块级变量,而不是放在data里,原因是不希望定时器引用参与响应式追踪,避免不必要的渲染。

3. IM聊天与顶部导航栏:消息列表、输入框和自定义导航栏的适配

3.1 聊天页面的布局与滚动消息列表

IM聊天页面涉及的内容比短视频复杂一些,包括会话列表、聊天消息列表、底部输入栏、表情面板、键盘弹出和输入框聚焦处理。项目里的live房间需要滚动消息列表模拟直播间弹幕效果,用的是scroll-view加scroll-y模式,每条消息样式由msg_bg和msg_name、msg_text组成。这里的实现方式虽然简单,但直播间滚动消息有个细节需要处理:如果带着滚动条并允许用户手动滑动,新消息进来后会打断用户的浏览位置。常见做法是监听scroll-view的滚动事件,如果用户正在向上翻阅,就暂停自动滚到底部,等用户停止操作后再恢复。

IM聊天输入框的消息发送逻辑(项目未包含完整服务端对接时)一般走以下流程:

handleSendMsg() { let inputMsg = this.msgInput.trim() if(!inputMsg) return let msgItem = { uname: this.userInfo.nickname, content: inputMsg, type: 'text', time: Date.now() } this.msgList.push(msgItem) this.msgInput = '' this.scrollToBottom() // 接入服务端时在这里调用socket.send // this.socketTask.send({ // type: 'chat', // data: msgItem // }) }

这里需要专门说一下scrollToBottom的实现,在uni-app里让scroll-view滑到底部,通常是把scroll-top设置为一个很大的数,或者给scroll-view设置一个scroll-into-view指向列表末尾的锚点。推荐用scroll-into-view配合id定位,因为scroll-top在部分安卓机型上存在延迟问题,尤其是聊天消息密集插入时,scroll-top更新不及时会导致列表看起来没滚动。锚点方案是给列表最后一条消息加一个id,比如id="lastMsg",然后设置scroll-into-view="lastMsg",uni-app会自动滚动到该元素位置。

3.2 自定义导航栏:透明背景、返回按钮和状态栏高度计算

项目里的导航栏不用原生导航,而是用header-bar自定义组件。这种设计的直接好处是页面可以做到沉浸式,比如个人主页和朋友圈动态顶部是透明的,背景图可以直接延伸到屏幕最上方。header-bar的使用方式能看出这个组件的设计思路:

<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>

导航栏高度在uni-app里不是写死的。状态栏高度在不同平台差异很大,尤其Android机型有挖孔屏、刘海屏,状态栏高度从20到50不等。项目在App.vue的onLaunch里通过uni.getSystemInfo获取设备信息,把这些高度存到Vue.prototype上,全局任何页面都能直接取用。

uni.getSystemInfo({ success: function(e) { Vue.prototype.statusBar = e.statusBarHeight // #ifndef MP if(e.platform == 'android') { Vue.prototype.customBar = e.statusBarHeight + 50 } else { Vue.prototype.customBar = e.statusBarHeight + 45 } // #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 } })

这段代码的核心是区分不同运行环境来算导航栏总高度。App端Android的导航栏比iOS高,多出50和45的这个差值基本是经验值;微信小程序端直接拿胶囊菜单的位置推断导航栏高度,这套算法是微信小程序自定义导航的通用解法;支付宝小程序则使用titleBarHeight字段。要特别注意条件编译的写法,// #ifndef MP表示非小程序平台,MP-WEIXIN和MP-ALIPAY分别代表对应小程序平台。这样写的好处是一套代码编译到不同端时,对应的高度计算代码会被完整保留或移除,不会影响其他平台。

3.3 一个坑:自定义导航栏后页面顶部内容被遮挡

自定义导航栏意味着页面内容区不再自动让出顶部状态栏和导航栏的高度,需要自己在每个页面手动处理占位。尤其是使用了vue页面的短视频列表,如果没有在页面容器顶部加上padding-top等于customBar的高度,视频画面的顶部就会被状态栏盖住。这个问题的排查很隐蔽,因为H5端无状态栏,看起来正常;但到了App端,视频画面上方大约50px被状态栏区域遮挡,滑动切换视频时画面跳动明显。常见做法是给根容器动态绑定style,paddingTop用statusBar加navigationBar总高度。

4. nvue直播室:video层级覆盖问题、weex css限制和iconfont引入

4.1 为什么直播室不用vue页面而用nvue

vue页面的video标签是一个原生组件,在小程序和App端有天然层级问题:video永远覆盖在最上层,view、text这类普通组件无论怎么设z-index都压不住它。小视频页面只是播放视频,这个问题不太致命,但直播室需要在视频画面上叠加排名信息、滚动消息、礼物面板、评论输入框、购物车入口等多个可交互控件,如果video盖在上面,整个直播间的操作区就全部失效了。项目里解决这个问题的方式是把直播页面写成了nvue文件。nvue是uni-app的weex模式页面,原生渲染机制下组件层级处理方式和vue页面不同,view叠加在video之上是可行的。

不过天下没有免费的午餐,nvue带来了两个显著限制。CSS不支持多级嵌套选择器,这一点在写下第一个样式时就能感觉到,所有样式要写成扁平的class名,不能有类似.nlv_container .nlv_main .nlv_playerbox这种后代选择器。许多css属性不支持或者行为不同,比如position fixed在部分安卓设备上会失效,阴影效果表现也不一致,所以在nvue里布局大量依赖flex。

4.2 直播室的页面骨架和flex布局约束

直播室的模板结构是一个典型的nvue全屏flex布局,最外层容器高度撑满全屏,内部优先级从上到下是顶部栏、排名栏、底部工具栏。

<template> <div class="nlv__container"> <view class="nlv_main"> <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" :ref="'myVideo' + index" class="player-video" :src="item.src" :autoplay="index == videoIndex" :controls="false" :loop="true" :show-center-play-btn="false" objectFit="fill"> </video> <!-- 顶部栏 --> <view class="nlv_topbar" :style="{ height: headerBarH, 'padding-top': statusBarH }"> </view> <!-- 下方滚动消息区 --> <scroll-view class="nlv-rollMsgPanel" scroll-y show-scrollbar="false"> <block v-for="(msgitem, msgidx) in item.rollmsg" :key="msgidx"> <view class="nlv-msglist"> <view class="msg_bg"> <text class="msg_name">{{msgitem.uname}}</text> <text class="msg_text">{{msgitem.content}}</text> </view> </view> </block> </scroll-view> <!-- 底部操作栏 --> <view class="nlv-infobox"> <view class="nlv_reply" @tap="handleRollMsg(index)"> <text class="nlv_reply_text">说点什么...</text> </view> <view class="nlv_btntool"> <view v-if="item.cart" class="btn-toolitem" @tap="handleLiveCart(index)"> <text class="iconfont i-btntool">礼</text> </view> <view class="btn-toolitem btn-toolitem-cart" @tap="handleLiveGift"> <text class="iconfont i-btntool">赏</text> </view> </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设置的autoplay属性,写法是:autoplay="index == videoIndex",只有当前展示的那个视频才自动播放,滑动到其他直播时自动暂停。第二是底部操作栏的几个按钮,分别对应购物车和礼物功能,这两个子组件通过ref注册,在页面里通过this.$refs.liveCart.show(index)这种方式弹出来。这种通过ref调用子组件方法的通信方式,在nvue里的表现比vuex的dispatch更直接,也更符合直播间内频繁弹出面板的场景。

4.3 nvue中引入阿里iconfont字体

在vue页面里引入字体图标,一般是在App.vue的style里用@font-face全局注册,或者直接在页面的style里引入。但nvue里这套方案不生效,weex环境需要用dom模块的addRule来注册字体规则。

beforeCreate() { // #ifdef APP-PLUS const domModule = weex.requireModule('dom') domModule.addRule('fontFace', { fontFamily: "nvueIcon", 'src': "url('../../../static/fonts/iconfont.ttf')" }) // #endif }

注意这个src要写相对路径,相对于当前nvue文件所在目录的层级,路径写错了字体图标就全变成方框。我踩过这个坑,项目里静态资源路径统一用的是绝对路径,从static目录直接引用,但weex的addRule要求src必须可被weex的bundle机制正确解析,改成相对路径后图标才正常显示。另外这里的条件编译是APP-PLUS,意味着这段代码只在App端执行,小程序端使用的是uParse或图片字体方案,不能混用。

4.4 商品广告、礼物面板和滚动消息的组件拆分

直播室页面中礼物、商品广告和滚动消息分别拆成了三个子组件live-cart、roll-msg、live-gift,在主页面里通过ref引用。这种拆分在大型直播间页面里是必要的,因为每次弹幕消息进来、礼物面板打开、购物车挂载变化都有独立的UI状态,写在同一个大vue实例里会让模板膨胀到上千行,很难维护。拆出来之后,主页面只负责视频播控和最外层的布局,三个子组件各自维护自己的显隐状态和数据。主页面和子组件的通信,礼物面板打开时调用this.$refs.liveGift.show(),直播间的商品挂载事件通过props的vlist透传给子组件,子组件内部通过watch监听变化。

5. 避坑清单:video层级、nvue css限制、状态栏高度与iconfont的五个典型问题

5.1 video一直盖在弹窗和评论框上方

现象:vue页面里的视频播放时,弹窗组件、评论输入框、底部工具栏全都显示在视频下面,怎么调z-index都没效果。

原因:vue页面的video是uni-app的原生组件,遵循原生组件层级规则,普通view和text组件无法覆盖。这个机制在不同端的表现不完全一致,App端尤其明显。

解决:对需要被覆盖的场景改用nvue页面实现,weex原生渲染模式下支持view覆盖video。如果不想整页改造成nvue,可以考虑把评论区、礼物面板做成单独的覆盖页面(半屏弹层放在上一级路由),绕开原生组件的层级冲突。

5.2 nvue里写多级css后代选择器导致样式不生效

现象:在nvue中写了.nlv_container .nlv_main .nlv-playerbox这样的嵌套选择器,编译通过但运行时样式没生效,控制台无报错。

原因:nvue基于weex,其css解析器不支持多级后代选择器,只支持单层class选择器,嵌套写法会被静默忽略。

解决:所有样式一律使用单层class,同时避免使用通配符和标签选择器,每个组件使用独立的class名。布局优先使用flex,减少对absolute和fixed的依赖。每次写完nvue样式后要在真机上验证一遍局部样式表现,因为H5端的Nvue模拟和App真机渲染存在差异。

5.3 状态栏高度在小程序端不符合预期

现象:小程序端自定导航栏后,状态栏高度和导航栏间距在小屏幕设备上明显偏大或偏小,胶囊按钮和自定义返回按钮不齐。

原因:部分小程序平台从uni.getSystemInfo获取的statusBarHeight不够准确,微信小程序右上角胶囊的实际高度和位置需要通过wx.getMenuButtonBoundingClientRect获取。

解决:按条件编译分别处理,微信端用胶囊按钮的boundingClientRect来计算customBar,公式是custom.bottom + custom.top - e.statusBarHeight,一套经验算法可以直接复用。支付宝端用titleBarHeight字段,App端靠platform判断Android和iOS分别加固定高度。需要在不同设备上各跑一遍验证,真机和模拟器的结果可能完全不一样。

5.4 直播页在App端iconfont图标显示为方框

现象:直播.nvue页面的字体图标在H5端正常,打包到App端后显示为方框或空白。

原因:vue页面通过全局@font-face注册的字体,weex渲染环境下没有自动生效,必须在nvue页面里通过weex的domModule.addRule手动注册。

解决:在nvue页面beforeCreate里执行addRule,fontFamily的值要唯一,src指到iconfont.ttf的相对路径。还有一个容易忽略的问题,字体文件最好放在static目录下,避免被打包器路径混淆。调试时如果图标还是方框,先检查src路径在编译后的bundle里是否指向了正确位置,确认无误后清缓存重启。

5.5 直播间滚动消息在频繁插入时列表不回到底部

现象:直播间弹幕消息插入很快时,scroll-view的scroll-top失效,消息列表停留在旧位置,新消息看不到,或者滚动闪烁。

原因:scoll-view在同一帧内收到多个scroll-top更新时会合并最后一次的值,或因为内部滚动容器的scrollTop计算时机晚于数据更新,导致定位方向错误。

解决:改用scroll-into-view定位到列表最后一条消息的id,每次push消息后更新scrollIntoView的值。滚动消息列表允许用户上滑阅读历史,当用户手动上滑时暂停自动滚动,等用户回到底部再恢复,减少滚动干扰。实测中每隔一批消息(比如5条)再触发一次滚动,比每条都滚动效果更流畅。

6. 把模板改造成自己的直播间:页面路由、状态管理和多端验证的小技巧

6.1 用vuex管理直播间的公共状态

短视频页面和直播页面都依赖videoIndex、vlist、播放状态等数据,但这些数据如果分散在各页面组件里,跨页面同步很麻烦。这个项目的vuex用法主要在全局状态上,比如用户信息、登录态、点赞数、礼物数。直播间场景里我一般会把直播流列表、当前主播信息、房间禁言状态放到store里,每个直播页面按roomId从store取数据,而不是每次进入直播间重新请求。

// store/modules/live.js const state = { liveList: [], currentRoomId: null, giftWall: [], socketConnected: false } const mutations = { SET_LIVE_LIST(state, list) { state.liveList = list }, SET_CURRENT_ROOM(state, roomId) { state.currentRoomId = roomId }, PUSH_GIFT(state, gift) { state.giftWall.push(gift) } }

注意vuex的mutation里不要放videoContextList这类实例引用,播放器上下文应该留在页面组件的data里,store只存纯数据。直播间Socket消息推送进来时,先commit更新giftWall,页面里通过mapGetters读取,再驱动礼物动画。数据流是单向的,不会出现多个页面同时操作同一个播放器实例的混乱。

6.2 多端差异自查清单

每次改完页面,不能只在H5端调试通过就收工,我习惯按固定顺序做多端验证。H5端看布局和交互是否完整,微信小程序端看自定义导航栏高度与胶囊按钮的对齐,支付宝小程序端看状态栏和titleBarHeight配合,App端重点验证video层级、nvue样式和iconfont字体三件事。尤其要注意在nvue页面里不要用只在H5端表现正常的css属性,比如box-shadow在weex环境会失效,需要改成border加背景渐变模拟。

6.3 直播流的接入与播放器切换

项目里直播流和短视频用的是同一个video标签接src的方式,实际接入直播流时,直播间用的通常是HLS或HTTP-FLV流,H5端和App端的支持情况有差异。如果产品方案确定只做App端,可以直接用原生播放器插件接HLS流;如果要兼容H5,考虑用video接HLS,或接入成熟的流媒体播放器方案。下滑切换直播间时,video的src切换会经历短暂的loading状态,我一般会在切换瞬间显示一个加载占位层,等play事件触发后再隐藏,避免切换时黑屏或白屏。

6.4 正式上线前的关键检查项

把模板改成自己的项目后,我每次都会强制走一遍这个清单:各端编译是否通过,小程序端有没有报原生组件警告;ios和安卓真机上video播放声音和自动播放策略是否一致;直播间滚动消息在200条消息连续注入时是否有卡顿;nvue页面删除掉所有注释和调试日志后重新编译,防止清屏指令将某些残留在页面上;体验版小程序在弱网环境下视频加载的状态提示是否友好。这个检查流程帮我提前规避过好几次线上事故,从那以后,每次交付直播类需求,我都坚持在三种运行环境各走一遍完整直播流程才收工。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进