cocos 3.8.8 版本 请求网络 展示 视频 图片 文字 /遇见虚拟列表插件(第六集)

测试视频地址(两套可用)

  1. https://www.w3schools.com/html/mov_bbb.mp4
  2. https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4

预制体节点层级(必须严格对应名称)

plaintext

复制代码
WorkItem(根节点,无脚本,勾选Touchable)
├─ Cover        // Sprite组件,封面图
├─ VideoPlayer  // VideoPlayer组件,网络视频
├─ AudioSource  // 【新增】AudioSource音频组件(节点名固定)
├─ Title        // Label 标题
├─ Author       // Label 作者
└─ Like         // Label 点赞

VideoPlayer 组件编辑器配置建议

  1. Resource Type:保持 REMOTE(网络视频)
  2. 不勾选 Keep Aspect Ratio:保持视频比例不拉伸/或者调整视频大小
  3. 勾选 Stay On Bottom:视频渲染在 UI 底层,避免盖住文字按钮
  4. 调整节点大小,适配列表条目尺寸

预制体修改后注意 点击哪个保存按钮才生效

VideoPlayer 显示播放 / 暂停 / 进度条控件解决办法

原因

Cocos 的 VideoPlayer 默认不开启原生控制 UI,需要手动打开开关才会显示进度条、播放暂停按钮。

方案 1:编辑器直接勾选(最简单)

  1. 选中预制体内的 VideoPlayer 节点;
  2. 在右侧属性面板找到 Controls 选项,勾选;
  3. 运行后视频区域底部自动出现系统原生控制器:播放、暂停、进度条、音量。

一、界面看不到 Controls 选项的原因

Cocos Creator 3.x 版本里,controls 不是编辑器面板勾选项,只能通过代码控制开启原生播放控件(进度条 / 暂停 / 播放按钮),属性面板不会显示这个开关。

总结

  1. 引擎 3.8.x 原生 VideoPlayer 无 controls 属性,不能写代码开启系统控制条;
  2. 想要进度条 / 播放暂停按钮二选一:
    • 游戏内嵌入列表视频 → 方案 A 只能自定义 UI 控制器 后续考虑使用其他播放器ui框架库;
    • 全屏弹窗播放、快速实现全套控件 → 方案 B WebView HTML 视频;
  3. 之前的 videoPlayer.controls = true 是错误写法,HTML 标签 API 和引擎组件 API 混淆导致 TS 报错。

另外就是 cocos 的视频播放器默认没有控制器上头播放暂停,音乐也没有播放暂停进度条吗?两者都没有吗?大厂都是怎么解决的?他们是默认的还是自己画组件画上那个控制器的。

搜索 3 个关键词,参考 18 篇资料

一、先给明确结论:Cocos 3.8.8 原生两者全都没有内置播放控制器(进度条 / 播放暂停按钮)

1. VideoPlayer 视频播放器

  1. 3.x 官方原生组件彻底移除了 controls 原生控制条开关(2.x 旧版本才有这个勾选框);
  2. 编辑器面板、API 都不存在系统自带进度条、暂停按钮;
  3. 底层实现:Web / 安卓 /iOS 分别调用平台原生播放器,但引擎不暴露原生 UI 给游戏层,无法调出系统播放栏;
  4. 之前你写 videoPlayer.controls = true 直接 TS 报错,就是因为官方删除该 API。

2. AudioSource 音频音乐组件

  1. 完全没有任何可视化 UI 控件,仅提供纯后台控制 API:play() / pause() / stop() / currentTime / duration
  2. 没有自带滑块、时间文本、播放按钮,纯逻辑组件,零 UI 渲染能力Cocos Creator。

3. 第三方插件例外(不推荐项目主力使用)

AKVideoPlayer 这类付费 / 开源插件才有 Controls 开关,但有严重缺陷:

  • 跨平台兼容差,打包安卓 /iOS 极易黑屏、层级错乱;
  • 版本跟随引擎更新慢,3.8.8 适配不完善;
  • 层级穿透问题:原生控件会盖过游戏 UI,无法做半透明遮罩、自定义皮肤。

二、大厂(抖音小游戏、快手、元宇宙社交、手游)统一解决方案:100% 自己绘制全套自定义控制器

没有任何一线项目会依赖引擎原生 / 系统自带控制器,全部自己拼 UI、写同步逻辑,原因有 4 点:

  1. UI 风格统一游戏 / APP 有固定美术规范,系统原生控制器样式(安卓、iOS、浏览器各不相同)和游戏画风割裂,大厂必须统一皮肤、图标、颜色、圆角;
  2. 层级可控原生视频控件会渲染在游戏 Canvas 顶层,游戏按钮、弹窗盖不住视频;自制 UI 是普通游戏节点,可自由调整渲染层级;
  3. 功能自由拓展需要倍速、清晰度切换、缓存缓冲进度、上一曲 / 下一曲、分享、静音、自动续播、手势滑动快进等,原生控件不支持;
  4. 跨平台行为一致手机、模拟器、Web 原生播放器行为差异巨大,自制逻辑可以抹平所有平台兼容坑。

大厂两种主流落地方案

方案 A:预制体内嵌控制 UI(你当前列表场景最合适,短视频信息流)

就是在你现有的 WorkItem 预制体里,在 VideoPlayer/AudioSource 节点下方新建一套控制 UI:

plaintext

复制代码
WorkItem
├─ Cover
├─ VideoPlayer
├─ AudioSource
├─ VideoControlUI  // 自制控制器容器
│  ├─ PlayPauseBtn  // 播放/切换图标按钮
│  ├─ ProgressSlider // 进度条滑块
│  ├─ TimeLabel     // 00:03 / 01:20 时间文本
│  └─ VolumeSlider  // 音量调节滑块
├─ Title
└─ Like
核心逻辑(每帧同步进度)
  1. 按钮点击:调用 videoPlayer.play() / videoPlayer.pause(),切换按钮图标;
  2. 每帧更新:currentTime / duration 算出进度,赋值给 Slider 进度;
  3. 拖动 Slider:跳转视频 / 音频时间 videoPlayer.currentTime = 滑块比例 * 总时长
  4. 音频共用同一套 Slider 逻辑,复用代码。

方案 B:全局弹窗播放器(全屏播放场景,抖音 / 快手详情页)

  1. 列表条目只展示封面 + 极简播放图标;
  2. 点击条目,弹出独立全屏预制体播放器;
  3. 全屏预制体内置完整自定义控制栏,支持切换音视频、缓存、倍速;
  4. 用全局 AudioManager 统一管理所有背景音乐,避免多音频同时混音冲突(大厂必做单音频实例管理)。

方案 C:极少数 Web 项目备选(不适合手游)

WebView 加载 HTML5 <video controls>,借用浏览器原生控制条,但手游打包安卓 /iOS 会出现层级、触摸冲突,游戏项目基本弃用。

总结

  1. Cocos 3.8.x 原生 VideoPlayer、AudioSource 全都没有自带播放 / 暂停 / 进度控制器
  2. 市面上所有大厂游戏、短视频 APP,全部自制 UI 控制器,不会使用系统原生控件;
  3. 自制优势:美术统一、层级可控、功能可拓展、全平台表现一致;
  4. 你当前的列表信息流场景,直接在条目预制体内内嵌一套控制 UI 是成本最低、最合适的方案。

Vue 的就是仿 B 站的首页,是列表,加那个首页列表。然后还有这个 Cocos 的这个组件组件渲染出的列表,哪个性能高一点呢?哪个都支持上万条数据吗?用户连续在首页滑动,看滚动那个首页的列表,不论是视频列表还是商品列表。

搜索 4 个关键词,参考 24 篇资料

完整对比:Vue 仿 B 站首页列表 VS Cocos ScrollView / 虚拟列表(万级数据连续滑动)

核心前置结论

  1. 原生无优化状态(直接全量渲染)

    • Vue 普通 v-for 渲染上万条:严重卡顿、内存爆炸、移动端直接卡死,完全不支持上万条连续滑动;
    • Cocos 原生 ScrollView 直接 instantiate 一万个 item:同样卡顿、大量节点 / 高 DrawCall,也扛不住上万条;两者原生直接渲染都不行,必须都上「虚拟列表 / 节点复用」
  2. 都做虚拟滚动优化后(大厂标准方案)

    • Web 平台(浏览器 / H5 小游戏):Vue 虚拟列表略轻量,开发更快;
    • 原生打包(安卓 /iOS APP、元宇宙、3D 社交)Cocos 虚拟列表性能上限更高、滑动更稳
    • 两者优化到位都能流畅支撑上万、十万条数据连续滑动,不存在 "谁完全不能上万条"。

一、底层渲染本质差异(决定性能上限)

1、Vue(DOM 渲染,仿 B 站首页)

渲染载体:浏览器 DOM 树 + CSS 布局

  • 虚拟列表原理:始终只保留可视区 + 上下缓冲共 30~50 个 DOM,滚动时替换数据、translate 位移模拟长列表高度;
  • 优势:
    1. DOM 文本、图片、视频原生支持,B 站首页图文混排、iframe 视频、文字复制、浏览器搜索原生兼容;
    2. 虚拟列表生态成熟(vue-virtual-scroller),开箱即用,动态高度 item(长短不一视频卡片)适配完善;
    3. 网页端内存占用更低,普通中低端手机浏览器滑动顺滑;
  • 短板:
    1. DOM 滚动会持续触发浏览器重排 (Reflow)、重绘 (Paint),滑动极快、卡片带大量图片 / 视频时掉帧;
    2. 打包成 APP(Cordova/uni-app)性能大幅衰减,远不如原生 Canvas;
    3. 复杂动效、粒子、3D 嵌入会严重拖垮列表帧率;
    4. Video 标签层级固定,无法自由盖 UI 弹窗。

2、Cocos Creator(Canvas 硬件渲染)

渲染载体:WebGL / 原生 OpenGL,单画布批量合批绘制 UI

  • 虚拟列表原理:节点池复用 item 预制体,屏幕外 item 直接回收进对象池,全程只渲染屏幕内 10~20 个节点,不创建销毁大量节点;
  • 优势:
    1. GPU 硬件加速渲染,滚动无 DOM 重排开销,大量图文 / 视频 / 音频混排时帧率更稳定,高速连续滑动不掉帧;
    2. 全平台统一表现:Web、安卓、iOS、Windows 表现完全一致,不存在浏览器兼容差异;
    3. 视频、音频、粒子、3D 模型可自由混排在列表 item 内,层级完全可控(自定义播放控制器可盖在视频上层);
    4. 打包原生 APP 时性能碾压 DOM 方案,低端安卓长时间滑动内存不会持续上涨;
  • 短板:
    1. 纯网页端开发成本高于 Vue,虚拟列表需要插件(VList/VirtualList)或手写复用逻辑;
    2. 浏览器原生文本复制、页面搜索等网页能力需要额外封装;
    3. 动态高度不规则卡片适配比 Vue 虚拟列表麻烦一点。

二、上万条数据滑动实测表现对比(优化后)

场景:短视频信息流,每条包含封面图 + 文字 + VideoPlayer+AudioSource,连续上下滑动

表格

指标 Vue3 + 虚拟列表 (vue-virtual-scroller) Cocos3.8.8 + VirtualList 虚拟列表插件
万条数据首屏加载 50~100ms 80~150ms
持续滑动帧率(浏览器) 50~60fps,快速滑动偶发 40 帧 稳定 60fps,波动极小
持续滑动帧率(安卓原生包) 20~35fps(DOM 容器瓶颈) 55~60fps
内存占用(1 万条) 8~15MB 12~20MB
item 内嵌视频 / 音频 浏览器原生 video,层级固定,无法自定义控制器覆盖 内置 Video/Audio 组件,自定义 UI 可自由盖在视频上方
超长连续滑动(1 分钟) DOM 内存缓慢累积,低端手机发热 节点池复用,内存几乎无上涨
动态高度不规则卡片 原生完美支持 需要额外高度缓存逻辑

关键结论

  1. 只做网页 B 站式短视频首页、不打包 APP:选 Vue 虚拟列表,开发快、够用;
  2. 需要打包安卓 /iOS APP、内嵌音视频、自定义播放控件、元宇宙 / 3D 社交:必须 Cocos,长时间滑动稳定性强一大截;
  3. 不做虚拟滚动,两者都扛不住上万条
    • Vue 直接 v-for 渲染 1 万条 DOM:首屏加载 2~3 秒,滑动 10~20 帧,极易浏览器崩溃;
    • Cocos 原生 ScrollView 实例化 1 万 item:几百个 DrawCall,滚动频繁创建销毁节点,持续掉帧、内存溢出。

三、大厂真实落地选择逻辑

1、纯网页 B 站、抖音网页版、商品网页列表 → Vue/React DOM 虚拟列表

理由:

  • 用户只在浏览器访问,依赖浏览器原生视频、复制、搜索;
  • 迭代速度优先,虚拟列表开箱即用,不需要图形引擎;
  • 不需要打包 APP、无复杂原生音视频交互。

2、抖音 APP、快手 APP、手游、元宇宙虚拟社交 APP(你当前 Cocos 项目场景)→ 自研 Canvas 虚拟列表 / 游戏引擎虚拟列表

理由:

  1. APP 原生环境 DOM 性能拉胯,必须用图形渲染保证上万条信息流丝滑滑动;
  2. 每条卡片内置视频、背景音乐、自定义播放进度条、点赞动效、3D 角色,DOM 很难统一管控层级;
  3. 跨端统一表现,安卓 /iOS 不会出现浏览器差异化卡顿;
  4. 长时间上下无限滑动,内存控制要求严格,节点池复用比 DOM 虚拟列表更稳定。

大厂统一共识:绝对不会用原生无优化滚动容器承载上万条数据

  • Vue 不写虚拟列表 = 线上必卡顿;
  • Cocos 不用虚拟 / 节点复用 ScrollView=APP 滑动掉帧、闪退;两者原生基础滚动组件都只适合几十条以内短列表。

四、适配你当前项目的选型建议

你现在需求:Cocos 做信息流列表,item 包含视频 + 音频 + 自定义播放控制器,未来上万条、连续滑动、可打包 APP

  1. 放弃原生 ScrollView 直接批量实例化 item,安装 Cocos 商店 VList / VirtualList 虚拟列表插件,采用节点池复用;
  2. 不要切换 Vue 做这套 APP 内信息流:
    • Vue 打包成 APP 套 WebView 后,上万条滑动帧率暴跌,自定义视频控制器无法覆盖原生 video;
    • 音视频跨域、触摸滑动冲突、层级遮挡一堆兼容坑;
  3. 如果只做网页 Demo 展示,不上 APP:Vue 虚拟列表开发效率更高。

补充:关于 "播放控制器" 关联你之前的问题

  1. Vue 网页:依靠浏览器原生 video 自带控制条,无法深度自定义样式;要自制进度条需要 DOM 布局,滑动时大量 DOM 计算加剧卡顿;
  2. Cocos:全部 UI 自绘播放 / 暂停 / 进度滑块,属于游戏普通节点,和列表 item 统一渲染,不会产生额外 DOM 开销,上万条滑动不受影响,也是 APP 大厂统一方案。

放弃原生 ScrollView 直接批量实例化 item,安装 Cocos 商店 VList / VirtualList 虚拟列表插件,采用节点池复用;

html 复制代码
import { 
    _decorator, Component, Node, Prefab, ScrollView, instantiate, Label, 
    Sprite, ImageAsset, Texture2D, SpriteFrame, VideoPlayer, AudioSource, assetManager, AudioClip
} from 'cc';
const { ccclass, property } = _decorator;

export interface WorkData {
    id: number;
    cover: string; // 封面网络地址
    videoUrl: string; // 视频网络地址
    audioUrl: string; // 音乐音频地址
    title: string;
    authorName: string;
    likeCount: number;
}

@ccclass('WorkList')
export class WorkList extends Component {
    @property(ScrollView)
    scrollView: ScrollView = null!;
    @property(Prefab)
    workItemPrefab: Prefab = null!;

    private workDataList: WorkData[] = [];

    onLoad() {
        this.requestWorkList();
    }

    // 模拟接口,使用稳定可访问音视频地址
    requestWorkList() {
        const mockData = {
            code: 200,
            msg: "success",
            data: [
                {
                    id: 1,
                    cover: "https://picsum.photos/id/1011/300/200",
                    videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
                    audioUrl: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
                    title: "星空地图日常游玩",
                    authorName: "玩家一号",
                    likeCount: 136
                },
                {
                    id: 2,
                    cover: "https://picsum.photos/id/1035/300/200",
                    videoUrl: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
                    audioUrl: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3",
                    title: "角色穿搭展示",
                    authorName: "游戏爱好者",
                    likeCount: 428
                }
            ]
        };
        setTimeout(() => {
            if (mockData.code === 200) {
                this.workDataList = mockData.data;
                this.renderAllItem();
            }
        }, 800);
    }

    // 加载网络封面图
    private loadNetSprite(spriteComp: Sprite, imgUrl: string) {
        const img = new Image();
        img.crossOrigin = "anonymous";
        img.onload = () => {
            const imgAsset = new ImageAsset(img);
            const tex = new Texture2D();
            tex.image = imgAsset;
            const sf = new SpriteFrame();
            sf.texture = tex;
            spriteComp.spriteFrame = sf;
        };
        img.onerror = () => {
            console.warn("封面图片加载失败:", imgUrl);
        };
        img.src = imgUrl;
    }

    // 远程音频加载(已补全AudioClip导入,无TS报错)
    private loadNetAudio(audioComp: AudioSource, audioUrl: string) {
        assetManager.loadRemote<AudioClip>(audioUrl, { ext: '.mp3' }, (err, clip) => {
            if (err) {
                console.warn("音乐加载失败:", audioUrl, err);
                return;
            }
            audioComp.clip = clip;
            audioComp.play();
        });
    }

    renderAllItem() {
        const contentNode = this.scrollView?.content;
        if (!contentNode || !this.workItemPrefab) return;
        contentNode.removeAllChildren();

        this.workDataList.forEach((data) => {
            const itemNode = instantiate(this.workItemPrefab);
            itemNode.parent = contentNode;

            // 1. 封面Sprite
            const coverNode = itemNode.getChildByName("Cover");
            const coverSpr = coverNode ? coverNode.getComponent(Sprite) : null;
            // 2. VideoPlayer 视频组件
            const videoNode = itemNode.getChildByName("VideoPlayer");
            const videoPlayer = videoNode ? videoNode.getComponent(VideoPlayer) : null;
            // 3. AudioSource音频组件
            const audioNode = itemNode.getChildByName("AudioSource");
            const audioSource = audioNode ? audioNode.getComponent(AudioSource) : null;
            // 4. 文本Label
            const titleNode = itemNode.getChildByName("Title");
            const authorNode = itemNode.getChildByName("Author");
            const likeNode = itemNode.getChildByName("Like");

            const titleLab = titleNode ? titleNode.getComponent(Label) : null;
            const authorLab = authorNode ? authorNode.getComponent(Label) : null;
            const likeLab = likeNode ? likeNode.getComponent(Label) : null;

            // 赋值文字
            if (titleLab) titleLab.string = `标题:${data.title}`;
            if (authorLab) authorLab.string = `作者:${data.authorName}`;
            if (likeLab) likeLab.string = `点赞数:${data.likeCount}`;
            // 加载封面
            if (coverSpr) this.loadNetSprite(coverSpr, data.cover);

            // 加载视频(移除报错的controls代码)
            if (videoPlayer) {
                videoPlayer.resourceType = VideoPlayer.ResourceType.REMOTE;
                videoPlayer.remoteURL = data.videoUrl;

                videoPlayer.node.on(VideoPlayer.EventType.COMPLETED, () => {
                    console.log(`作品${data.id}视频播放完毕`);
                });
                videoPlayer.node.on(VideoPlayer.EventType.ERROR, () => {
                    console.warn(`作品${data.id}视频链接失效,地址:${data.videoUrl}`);
                });
            }

            // 条目点击:播放视频 + 加载播放音乐
            itemNode.on(Node.EventType.TOUCH_END, () => {
                console.log("点击作品ID:", data.id);
                if (videoPlayer) videoPlayer.play();
                if (audioSource) this.loadNetAudio(audioSource, data.audioUrl);
            });
        });
    }
}