本文作者:Gisercyw
背景
应用程序包含两个部分,代码和资源,资源通常包括配置文件、图标、图片、字体等,他们都直接影响到应用程序的包大小并且一定程度会影响应用程序的运行速度。在社交直播业务开发中不难发现,以下的两类场景对资源管理的诉求会相对强烈:
- 在游戏的开发过程中,一般需要使用到大量的图片、音频等资源来丰富整个游戏内容,而大量的资源就会带来管理上的困难, 一个好的资源管理也会为以后性能优化提供很大的帮助。
- 运营活动需要使用资源管理方式并配合浏览器缓存来完成活动资源的预加载/预请求, 以提升页面性能与用户体验。
基于上面两个场景的目的,我们需要一个通用的资源管理方案,让我们在游戏或者活动开发中,无需关心资源加载的细节,只需要指定加载的资源,并且在对应的逻辑位置中添加相应的执行加载代码即可完成对项目资源的管理。
调研
游戏框架通常具有较完备的资源管理方案,而这些资源管理方案具备下面共性和功能:
- 完善的资源加载基本机制,比如加载资源、查找资源、销毁资源、缓存资源
- 多资源配置文件管理与分组
- 支持资源进程状态监视
- 资源模块化
- 支持预加载/预请求
- 支持自定义资源处理,这样能够让加载更具有灵活性
这里我分为两类管理器,一类是资源预加载库,一类是游戏的资源管理库,并根据资源管理具备的功能点对比下目前已有的资源管理方案的特点。
在调研完这些方案后,我认为上述方案并不完全适合我们,我们需要的是能够覆盖我们游戏与活动业务开发更为通用的资源管理方案,不会与任何游戏引擎绑定,这是与其他方案最本质的不同。其次在设计上需要考虑更多的是性能问题,采用插拔式的代码组织方式,在保证主包体积稳定的基础上,通过插件扩展特定场景的功能需求,例如将预请求功能可以作为核心功能,而面向各个场景的资源转换、缓存功能可以作为独立的插件。具体的不同如下:
- 相对于游戏资源管理,大部分的游戏资源管理更多的目的是为其游戏引擎提供开箱即用的资源管理工作,跟游戏引擎耦合较深;另外虽然大型游戏引擎具有完备的资源管理体系,但是在预请求等场景下并不支持;
- 相对于这些预加载库,其作用主要是资源加载,而资源管理器的定位不只是资源的加载器,还包括资源管理,缓存,解析,转换等功能,并且在资源优先级等方面都做了完整的定义。另外在经过团队的测试后,发现 resource-loader 与 PxLoader 这类预加载库,在音视频资源的处理与加载上会存在一些兼容性问题,不支持 SSR/SSG 等服务端渲染等。
整体设计
针对业务开发中的核心场景,在保持资源管理核心模块基础上,通过插件化架构,设计出资源管理器的整个体系,如下图所示。
依赖能力
资源管理器主要依赖 Extension 模块的注册能力和 WebWorker 的多线程能力。
资源解析与转换是一项耗时的操作,特别是在需要大量资源的游戏场景中,如果只是并发的加载、解析大量的资源,由于 Javascript 单线程的原因,会容易产生卡顿现象,导致页面无法及时响应,而 Web Worker 使得网页中进行多线程编程成为可能。当主线程在处理界面事件时,Worker 可以在后台运行,帮你处理大量的资源加载、解析、转换、缓存工作,当完成这些操作后,将加载结果或者缓存数据返回给主线程,由主线程更新UI。资源管理器内置了 WebWorker 来解析、加载资源,每种类型资源的处理都可以通过开启 Worker 通道来完成,默认是开启的,同时也提供了开启参数能够覆盖默认配置,需要注意的是并非所有的环境都支持 Workers,在一些场景下设置不开启可能更合适。如下所示,以处理 Image 资源转换 Buffer 为例,通过指定资源转换脚本的 URI 来执行 Worker 线程。
import WorkController from 'music/WorkController';
const MAX_WORKER_NUM = navigator.hardwareConcurrency || 6;
const loadBufferImageCode = `
async function loadBufferImage(url) {
const result = await fetch(url);
if (!result.ok)
{
throw new Error('failed to load');
}
const imageBuffer = await result.arrayBuffer();
return imageBuffer;
}
onmessage = async (e) =>
{
const {
data: {
uuid,
id,
}
} = e
try
{
const bufferImage = await loadBufferImage(e.data.data[0]);
postMessage({
data: bufferImage,
uuid,
id,
}, [bufferImage]);
}
catch(error)
{
postMessage({
error,
uuid,
id,
});
}
};
`;
let worker = WorkController.workerPool.pop();
if (!worker && WorkController.WorkersNumber < MAX_WORKER_NUM) {
const workerURL = URL.createObjectURL(
new Blob([loadBufferImageCode], { type: 'application/javascript' })
);
WorkController.WorkersNumber++;
worker = new Worker(workerURL);
worker.addEventListener('message', (event: MessageEvent) => {
WorkController.complete(event.data);
WorkController.next();
});
}
插件注册能力是资源管理器的一个基本能力,方式是主功能通过主包引入,其他功能通过插件的形式按需引入,既能够保证主包的稳定,又能够减小整个包体积。资源管理器的核心功能是资源预加载,而针对特定类型资源的解析、缓存、转换则是通过对应插件来完成,插件模块的主要方法类型定义如下所示,提供了插件处理的基本功能。
declare const ExtensionModule: {
/**
* 移除插件
*/
remove(...extensions: Array<ExtensionOptionType>): any;
/**
* 注册插件
*/
add(...extensions: Array<ExtensionOptionType>): any;
/**
* 添加/删除扩展时的处理功能
*/
registerHandler(type: ExtensionType, onAdd: ExtensionHandler, onRemove: ExtensionHandler): any;
/**
* 处理插件列表
*/
handleExtensions(type: ExtensionType, list: any[]): any;
};
核心模块
对于特定类型的资源,在资源管理器底层会经过资源检测、 资源映射、加载解析、资源缓存的流程,每个环节都是独立的,其中部分环节并不是必需的,因此不是每个资源都会完全走完这几步,例如如果是预请求资源,则不需要缓存,因为预请求利用的是浏览器缓存,对于需要使用的功能,可以通过插件或者参数设置开启。
外部接口
外部接口主要提供了两类接口,一类单独的资源接口(Resource),一类是缓存接口(Cache)。而资源为了满足模块化的场景,我们又将其分为 Resource 与 Bundle ,Resource 提供全局资源的操作,Bundle 提供模块化资源的操作。在这些简洁易用的接口基础上,我们可以轻松完成资源的预请求、资源预加载、手动加载与自动加载,资源缓存处理等操作。
功能使用
下面选取几种业务中常见的的场景来介绍资源管理器的实际使用方式,可以满足小游戏或者活动开发中资源加载与转换的需求。
预加载
预加载是一种浏览器机制,使用浏览器空闲时间来预先下载/加载用户接下来很可能会浏览的页面/资源,当用户访问某个预加载的链接时,如果从缓存命中,页面就得以快速呈现。预加载一般会配合loading或者加载页来呈现,合理的有效加载交互设计可以减少用户焦虑,减轻用户等待的压力,而每个阶段预加载资源的分配能够有效降低页面访问速度,减少页面切换时的闪烁问题,进而达到提升用户体验的目的。
资源管理器的预加载功能可以通过简洁的api来实现, 如下所示:
const loadAssets = [
{
src: 'https://someurl.png',
type: 'IMAGE', // 图片
},
{
src: 'https://someurl.mp3',
type: 'AUDIO', // 音频资源
},
{
src: 'http://someurl.mp4',
type: 'VIDEO', // 视频资源
},
{
src: 'https://someurl.ttf',
type: 'FONT', // 字体资源
subType: 'ttf',
},
{
src: 'https://someurl.json',
type: 'JSON', // JSON资源
}
];
const LoadingPage = () => {
const [progress, setProgress] = React.useState(0);
React.useEffect(() => {
const load = async () => {
const res = await Resource.loadResource(loadAssets, (progress) => {
setProgress(Number(progress.toString().match(/^\d+(?:\.\d{0,2})?/)) * 100);
});
};
load();
}, []);
return (
<div>资源加载进度:{progress}%</div>
);
};
资源模块化
在游戏开发中,我们会需要将资源按照不同的功能和场景划分与使用,如下图所示,资源管理器中可以将图片,脚本,多媒体等资源指定为多个 Bundle,其中每种类型资源还可以根据页面划分成多个 Bundle,比如图片可以根据首屏图片、弹窗与浮层图片、非首屏图片分成多个 Bundle,然后在游戏运行过程中,按照需求去加载不同的 Bundle,以减少启动时需要加载的资源数量,从而减少首次下载和加载游戏时所需的时间。
// 添加
Resource.addBundle('first-scene', {
mainBg: 'backgroundA.png',
avatar: 'avatarA.png',
font: 'fontA.ttf',
});
// 添加
Resource.addBundle('next-scene', {
mainBg: 'backgroundB.png',
avatar: 'avatarB.png',
font: 'fontB.ttf',
});
// 加载
const firstSceneResource = await Resource.loadBundle('first-scene');
// 加载
const nextSceneResource = await Resource.loadBundle('next-screen');
资源转换
以图片类型资源转换为例,首先要启用图片转换插件,主要通过以下方式注册插件
import ResourceImagePlugin from 'resource-image-plugin';
Resource.addPlugin(ResourceImagePlugin)
然后通过 formatType 参数指定转换类型,resource-image-plugin可以支持以下类型转换: Buffer、Blob、BitMap、PixiTexture
png转Bitmap
const res = await Resource.loadResource(
{
src: 'https://p5.music.126.net/obj/wo3DlcOGw6DClTvDisK1/24086412116/de58/ecc0/3ef8/d0ce5485ed549eeb0e77b8a2e54bb4c4.png',
formatType: 'Bitmap',
}
);
png转Pixi Texture
const res = await Resource.loadResource(
{
src: 'https://p5.music.126.net/obj/wo3DlcOGw6DClTvDisK1/24086412116/de58/ecc0/3ef8/d0ce5485ed549eeb0e77b8a2e54bb4c4.png',
formatType: 'Texture',
}
);
总结
目前资源管理器已经社交直播多个业务中落地,其不仅为 Alice.js 底层提供开箱即用的资源管理能力,同时为社交直播运营活动提供了预加载的手段,未来还会针对内部其他场景适配与支持,例如支持3D资源/模型、智能化加载等。
本文主要分析了资源管理的现状与存在问题,在业务游戏化背景下,探索了符合社交直播业务发展的资源管理解决方案,并介绍了不同场景下的使用方式,如果您对此内容感兴趣,可以评论交流。
参考资料
本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。