移动端竖屏短视频方案 — 设计与折腾记录
项目:mybilibili(bilibili 自建平台) 模块:
mybilibili-wap(Vue3 + Vite 移动端 SPA)+ 后端 Go 微服务 日期:2026-08-26 本文记录"竖屏视频"从数据层到移动端展示、再到全屏方案与安卓 WebView 打包的完整链路, 以及过程中踩过的坑(折腾)。
一、背景:为什么要做竖屏
一个移动端视频平台,竖屏(9:16 短视频)是刚需 —— 手机上竖着看视频是自然习惯, 对标抖音/快手。但平台最初所有视频都按横屏处理:
- 数据库
videos表没有任何方向/宽高字段,无从判断横竖屏。 - 转码管道用横屏思维(
scale=1280:-2),竖屏源会被拉成超高分辨率。 - 播放器固定 16:9,竖屏视频被压扁或留黑边,体验差。
实测存量数据:13 个视频里有 5 个是竖屏(360×640),说明竖屏内容真实存在、必须支持。
二、数据层:让"横竖屏"变成可存储、可查询的字段
2.1 数据库加字段
ALTER TABLE videos ADD COLUMN IF NOT EXISTS is_vertical SMALLINT NOT NULL DEFAULT 0;
-- 0 = 横屏 (width >= height)
-- 1 = 竖屏 (height > width)迁移文件:sql/026_video_orientation.sql,含存量回填。
2.2 转码时自动探测(work-service)
转码管道本来就用 ffprobe 取时长,顺带取宽高判断方向:
func (w *TranscodeWorker) GetVideoSize(ctx, srcFile) (width, height int, err error) {
// ffprobe -select_streams v:0 -show_entries stream=width,height
}转码开始时探测源文件,height > width 即竖屏,随 NATS 进度事件上报。
2.3 核心服务回写 + 出参(core-service)
进度订阅里把 is_vertical 写回 videos 表;ManuscriptInfo pb 加 IsVertical 字段(27), manuscriptToMap 输出,让所有列表接口(推荐/热门/频道/搜索/相关)都能拿到方向。 FirstVideoIsVertical() 查稿件第一个分P的方向,作为"稿件级"方向。
2.4 转码比例顺带修复
scale=1280:-2 → scale=min(1280,iw):-2:竖屏源不再被放大到 1280×2276, 保持竖屏比例(宽不超 1280、不放大原宽)。
三、移动端展示:抖音式全屏短视频页
新增页面 src/views/video/VerticalFeed.vue,路由 /m/vertical/:aId。
3.1 交互设计(对标抖音/快手)
- 全屏滑动流:每屏一条视频,
100dvh全屏黑底,上下滑动切换(scroll-snap) - 自动播放:当前视频自动播、其他暂停;切屏懒加载对应视频详情并换源,HLS 用 hls.js
- 右侧操作栏:UP主头像(+关注)、点赞❤️、评论💬、收藏⭐、分享↗
- 底部信息:@作者、标题(2行截断)、简介、旋转唱片音乐条
- 双击点赞心形动画:单击播放/暂停,300ms 内双击点赞 + 心形飘出
- 评论面板:底部抽屉拉取真实评论
- 加载动画 / 顶部进度条
3.2 视频铺满整个屏幕(关键认知)
手机屏幕就是视频本身,这是抖音式体验的核心:
- 竖屏视频:
object-fit: cover,裁切铺满整屏,不露黑边 - 横屏视频:
object-fit: contain居中 + 模糊封面铺满背景(抖音处理横屏的方式), 不再是大黑边
3.3 竖屏视频点击直达
列表项(首页推荐/热门、频道、搜索、相关推荐)携带 isVertical, VideoItem.vue 的 router-link :to 按方向分流:
video.isVertical ? '/m/vertical/' + aId : '/m/video/' + aId→ 点竖屏视频直接进短视频全屏页;横屏仍走普通详情页。 退出竖屏 → 回到普通详情页(16:9 + 左右补黑边,与横屏一致)。
四、全屏方案:浏览器 Fullscreen API 还是 CSS 应用全屏?(最大的折腾)
这是踩坑最多、最容易想当然的地方。"全屏"有两种,别混为一谈。
4.1 两种全屏的区别
| 浏览器原生全屏 (Fullscreen API) | 应用全屏 (CSS) | |
|---|---|---|
| 触发 | document.requestFullscreen() | position: fixed; inset:0; 100dvh |
| 手势 | 必须在用户点击手势内 | 不需要 |
| 提示 | 浏览器提示"按 ESC 退出" | 无 |
| 意外退出 | 用户按 ESC 直接退出 | 不会 |
| iOS Safari | 不支持普通元素全屏 | 支持 |
| Android WebView | 默认空操作 | 支持 |
4.2 最初想当然的错误
一开始以为"要全屏"= 调浏览器 Fullscreen API,于是:
- 在
openVertical(点击手势内)requestFullscreen()再跳转; - 在竖屏页
onMounted也requestFullscreen()(结果被拒,无手势); - 加了手动全屏切换按钮、
fullscreenchange监听。
结果一堆问题:手势限制、ESC 意外退出、WebView 里空操作、iOS 不生效。 纯属过度设计。
4.3 结论:只需要 CSS 应用全屏
竖屏页用 position: fixed; inset: 0; 100dvh 铺满视口即可:
- 无手势限制、无 ESC 提示、不会被意外退出;
- 浏览器、WebView 行为完全一致;
- 代码量大幅减少。
彻底删掉 Fullscreen API(requestFullscreen/exitFullscreen/toggleFullscreen/监听器/全屏按钮)。
五、安卓 WebView 打包:如何配合(重点)
目标是把 wap 塞进 mybilibili-webview-app(Android WebView)打包成 APK。
5.1 WebView 天然就是手机全屏
WebView 布局是 match_parent(铺满整个 Activity)→ wap 的 CSS 应用全屏 = 铺满整个手机屏幕。 所以 wap 端只用 CSS 应用全屏,塞进 WebView 后天然就是真·全屏,什么都不用额外做。
5.2 浏览器 Fullscreen API 在 WebView 里是空操作
requestFullscreen() 在 WebView 里默认不生效,除非你在 WebChromeClient 里实现 onShowCustomView(View, CustomViewCallback) 和 onHideCustomView()。 所以对 WebView 打包来说,Fullscreen API 调了也白调,删掉正好。
5.3 如果要隐藏 Android 系统栏(沉浸式)
想在竖屏时连状态栏/导航栏都隐藏,那是 Java 层的事,跟 wap 无关:
// MainActivity(沉浸式模式示意)
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(getWindow(), window.getDecorView());
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);也可以通过 JS Bridge(addJavascriptInterface)在进入竖屏页时切沉浸模式。
简单原则:web 只负责"铺满视口",隐藏系统栏交给安卓原生层。
六、折腾记录(踩坑清单)
6.1 竖屏按钮被顶部导航栏盖住,点了没反应
详情页 .top-nav-bar 是 z-index: 999 的固定头,竖屏按钮只有 z-index: 20, 被完全遮住。用无头浏览器 elementFromPoint 实测命中的是 top-nav-bar。 修复:按钮 z-index 提到 1000。
教训:
elementFromPoint是验证"元素是否真的可点"的好工具。
6.2 评论数一直拿不到(显示 0)
数据其实一直真实存在,是前端字段映射写错:
| 位置 | 错误 | 正确 |
|---|---|---|
详情 getVideoInfo | data.danmakuCount(弹幕数) | data.commentCount |
推荐 adaptRecommend | v.comment_count(snake) | v.commentCount(camel) |
教训:后端
manuscriptToMap会convertKeysToCamel(snake→camel), 而 search-service 返回 snake_case。两个后端字段命名风格不一致,前端要分别兼容。
6.3 点赞/评论/收藏数量"不真实"
- 推荐流项没映射
likeCount/collectCount→ 初始显示 0; - 收藏按钮只显示"收藏/已藏"文字、没有数字;
- 已赞/已藏/已关注状态没查后端 → 进入时状态永远是"未赞/未藏/未关注"。
修复:adaptRecommend 补 likeCount/collectCount;收藏按钮显示真实 collectCount; 每条视频加载后调 getInteractionStatus + checkFollow 拉真实状态。
6.4 分P(多P)选择不跳转
点分P只是高亮,播放器没换源。根因三叠加:
videoParts丢了每个分P的playUrl;selectPart没通知播放器;- 之前加的"同 aId 跳过重建"逻辑让分P切换(aId 相同)也被跳过。
修复:分P携带 playUrl;新增 switchPart() 用 art.switchUrl() 直接换源 + 重载对应分P弹幕。
6.5 进入视频页画面抖动
SWR 流程:缓存→网络两次 applyVideoData 都触发播放器 watch 销毁重建 → 视频重载闪一下。 修复:播放器 watch 里"同 aId 数据刷新不重建",只真正切换视频时才重建。
6.6 分P弹幕栏重复累积
artplayer-plugin-danmuku 把控件挂到播放器外的 #danmaku-emitter-mount,art.destroy() 不清理它,每次重建就多一份。修复:destroyPlayer 里手动清空 mount 节点。
七、关键结论
- 数据先行:方向这种元数据要入库,并在转码时自动探测,才能支撑前端判断。
- 移动端竖屏 = 铺满视口:抖音式的核心是"屏幕即视频",用
cover铺满 + 横屏模糊背景。 - 全屏别过度设计:浏览器 Fullscreen API 只适合"桌面/需要真全屏"的场景; 移动 app 用 CSS 应用全屏 就够了。
- WebView 打包友好:CSS 应用全屏天然填满手机;隐藏系统栏交给安卓原生层(沉浸模式 / JS Bridge)。
- 验证用无头浏览器:
elementFromPoint、真实鼠标/触屏事件、getAnimations()都是排查 "为什么没生效"的好工具。
(完)