Skip to content

移动端竖屏短视频方案 — 设计与折腾记录

🕒 Published at:

移动端竖屏短视频方案 — 设计与折腾记录 ​

项目:mybilibili(bilibili 自建平台) 模块:mybilibili-wap(Vue3 + Vite 移动端 SPA)+ 后端 Go 微服务 日期:2026-08-26 本文记录"竖屏视频"从数据层到移动端展示、再到全屏方案与安卓 WebView 打包的完整链路, 以及过程中踩过的坑(折腾)。


一、背景:为什么要做竖屏 ​

一个移动端视频平台,竖屏(9:16 短视频)是刚需 —— 手机上竖着看视频是自然习惯, 对标抖音/快手。但平台最初所有视频都按横屏处理:

  1. 数据库 videos 表没有任何方向/宽高字段,无从判断横竖屏。
  2. 转码管道用横屏思维(scale=1280:-2),竖屏源会被拉成超高分辨率。
  3. 播放器固定 16:9,竖屏视频被压扁或留黑边,体验差。

实测存量数据:13 个视频里有 5 个是竖屏(360×640),说明竖屏内容真实存在、必须支持。


二、数据层:让"横竖屏"变成可存储、可查询的字段 ​

2.1 数据库加字段 ​

sql
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 取时长,顺带取宽高判断方向:

go
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,于是:

  1. 在 openVertical(点击手势内)requestFullscreen() 再跳转;
  2. 在竖屏页 onMounted 也 requestFullscreen()(结果被拒,无手势);
  3. 加了手动全屏切换按钮、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 无关:

java
// 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) ​

数据其实一直真实存在,是前端字段映射写错:

位置错误正确
详情 getVideoInfodata.danmakuCount(弹幕数)data.commentCount
推荐 adaptRecommendv.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只是高亮,播放器没换源。根因三叠加:

  1. videoParts 丢了每个分P的 playUrl;
  2. selectPart 没通知播放器;
  3. 之前加的"同 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 节点。


七、关键结论 ​

  1. 数据先行:方向这种元数据要入库,并在转码时自动探测,才能支撑前端判断。
  2. 移动端竖屏 = 铺满视口:抖音式的核心是"屏幕即视频",用 cover 铺满 + 横屏模糊背景。
  3. 全屏别过度设计:浏览器 Fullscreen API 只适合"桌面/需要真全屏"的场景; 移动 app 用 CSS 应用全屏 就够了。
  4. WebView 打包友好:CSS 应用全屏天然填满手机;隐藏系统栏交给安卓原生层(沉浸模式 / JS Bridge)。
  5. 验证用无头浏览器:elementFromPoint、真实鼠标/触屏事件、getAnimations() 都是排查 "为什么没生效"的好工具。

(完)