绘本阅读是一个高仿真实书本阅读 h5 项目,每一页都有场景动画、人物动画、人物语音、背景音乐、字幕,所以整个项目资源上会比一般交互应用更为庞大,那么如何做到整个项目的流畅运行?
为了使整个绘本阅读不必等待资源的加载,我们预加载了后端接口数据、多媒体资源,主要资源有音频、动画 svga 文件、图片。
提前请求接口,缓存数据到内存中,下次请求直接使用缓存
1const cache = {};
2// 获取绘本基础信息
3export async function getBaseDate(data) {
4 const strkey = JSON.stringify(data);
5 if (cache[strkey]) {
6 return cache[strkey];
7 }
8 const { data } = await request.post('url');
9 // eslint-disable-next-line no-return-assign
10 return (cache[strkey] = data
11}
预加载的实现比较简单,比如:
1. 通过创建 Image 对象来加载图片
1const img = document.createElement('img'); // 等价于 new Image()
2img.src = '';
3img.onload= ()=>{};
4img.onerror = ()=>{};
2. 通过创建 audio 对象来加载音频
1const audio = document.createElement('audio'); // 等价于 new Audio()
2audio.src = '';
3audio.ended = ()=>{};
4audio.onerror = ()=>{}
5audio.muted = true;
6audio.playbackRate = 4;
7audio.play();
TIP
这里涉及音频自动播放问题,我们采用4倍速静音播放来让音频自动播放,从而快速预加载音频,当然这里可能会有兼容问题,所以需要 native 端设置 webview 允许自动播放音频。
3. 使用 svga 开源模块加载 svga 文件
1import SVGA from 'svgaplayerweb'
2const parser = new SVGA.Parser();
3parser.load('url')
上面的音频预加载看起来没问题,很OK。但是在真机测试时,发现 iOS 手机音频首次播放总是会慢8s左右(不同的机型会有点区别)。猜想:因为使用静音播放预加载,从而导致音频资源占用,只有等待前面音频播放完毕,资源释放,才等到现有音频播放。验证:使用 ajax 异步加载文件,避免音频资源占用
TIP
ajax 异步加载文件需要允许跨域
1const xhr = new XMLHttpRequest();
2xhr.onload = () => {};
3xhr.onerror = () => {};
4xhr.open('GET', 'url', false);
5xhr.send(null);
TIP
同样,我们也想到了其他资源同样也可以使用 ajax 异步加载,尤其是 svga 文件的加载,其开源模块的加载还包括了文件资源的预处理,这并不是我们需要的。
经过测试,发现 iOS 手机首次音频播放延迟问题确实解决。看起来很完美,但是,打开 chrome performance monitor 性能时时监听工具,发现内存占用暴增 20M。
猜想:为什么 svga 开源库加载没有内存暴增问题。
验证:通过阅读 svga parse.load 方法,发现,同样是使用 ajax 异步加载,但是多了这么一行代码:
1xhr.responseType = 'arraybuffer';
加上这么一行之后,暴增的 20M 内存瞬间就没有了,问题解决。
TIP
猜测 ajax 请求默认返回 text 文本,从而需要浏览器缓存到内存中,等待程序使用,而 arraybuffer 是 缓冲区内存,占用的是系统内存,返回的是内存块引用
上面通过 ajax 预加载文件看起来完美。但是,忽略了一个问题--网络问题,因为浏览器限制,一个 TCP 链接最多只允许 5 个 http 请求同时进行,那么当前面的请求文件资源比较庞大,或者网络状态差的情况下,后面的 ajax 请求将会超时,那么如何解决。使用调度器,进行网络调度。一个简单的调度器如下:
1class Scheduler {
2 list = [];
3
4 finishCount = 0;
5
6 tasks = 0
7
8 constructor({
9 num, autoProcess = false, errorCount = 3, onPerTaskFinish, onError
10 } = {}) {
11 this.num = num;
12 this.autoProcess = autoProcess;
13 this.errorCount = errorCount;
14 this.onPerTaskFinish = onPerTaskFinish;
15 this.onError = onError;
16 }
17
18 async add(fn) {
19 this.tasks += 1;
20 await new Promise((resolve) => {
21 this.list.push(resolve);
22 });
23 let result;
24 try {
25 result = await fn();
26 } catch (error) {
27 // eslint-disable-next-line no-param-reassign
28 fn.error = fn.error || 0 + 1;
29 if (fn.error <= this.errorCount) {
30 this.add(fn);
31 } else {
32 this.onError && this.onError();
33 }
34 }
35
36 if (this.list.length > 0) {
37 this.list.shift()();
38 }
39 this.finishCount += 1;
40 this.onPerTaskFinish && this.onPerTaskFinish();
41
42 return result;
43 }
44
45 process() {
46 if (this.list.length > 0) {
47 const tasks = this.list.slice(0, this.num);
48 this.list = this.list.slice(this.num);
49 tasks.forEach((task) => {
50 task();
51 });
52 }
53 }
54}
55
56export default Scheduler;
1const schedulerPreload = new Scheduler({ num: 5});
2
3schedulerPreload.add(() => axios.get('url', { responseType: 'arraybuffer', loading: false }));
4
5schedulerPreload.process();
TIP
设置调度器每次最多同时有 5 个网络请求,没完成一个请求,从队列中取出一个新的任务执行。如果资源可以配置多个域名,那么就可以绕过一个 TCP 链接只能同时 5 个 http 请求,但是没有如果。
web 服务基本上都是一些静态资源,我们是不是可以考虑,把资源先下载到客户端,等待用户使用。所有,本地化出现了。资源本地化之后,如何利用:
1. native 通过网络拦截,检测请求路径是否命中本地文件,命中则返回本地文件,不命中则代理网络请求。
2. native 使用已下载 web 资源,本地起 web 服务,接口预先加载。
TIP
本地化主要是 native 端实现
同样为了页面流畅,我们对数据进行了预处理,主要有首页场景动画、首页人物动画、字幕数据。
1. 对首页 svga 动画文件解析
1function preloadPromise(src) {
2 return new Promise((resolve) => {
3 parser.load(src, (videoItem) => {
4 resolve(videoItem);
5 }, reject);
6 });
7}
2. 对字幕数据进行解析
TIP
这里对字幕进行解析是为了实现字幕跑马灯、重点字词标记功能,以及返回统一数据结构,防止接口变动改动业务代码
1const str = item.subtitle.replace(new RegExp(zhmMark.source, 'g'), '');
2
3let subtitle = item.subtitle || '';
4
5const importWordIndexs = item.wordsInputVOList?.map((li) => {
6 const startIndex = str.indexOf(li.words);
7 return {
8 startIndex,
9 endIndex: startIndex + li.words.length - 1
10 };
11}) || [];
12
13let analyzedStrList = JSON.parse(item.analyzedStr || '[]');
14
15const destArr = [];
16
17analyzedStrList.forEach((wordLi, index) => {
18 let temp = '';
19 temp += str[index];
20 let charIndex = subtitle.indexOf(temp);
21 temp = subtitle.slice(0, charIndex) + temp;
22
23 let nextChar = subtitle[++charIndex];
24 while (zhmMark.test(nextChar)) {
25 temp += nextChar;
26 nextChar = subtitle[++charIndex];
27 }
28 subtitle = subtitle.slice(charIndex);
29
30 const importantIndex = importWordIndexs.findIndex(
31 (offset) => index >= offset.startIndex && index <= offset.endIndex
32 );
33 const { startIndex = -1, endIndex = -1 } = importWordIndexs[importantIndex] || {};
34 destArr.push({
35 ...wordLi,
36 word: temp,
37 read: false,
38 importantIndex,
39 startFlag: index === startIndex,
40 endFlag: index === endIndex
41 });
42});
43
44return destArr;
3. 替换正则,使用哈希表
字幕跑马灯中英文标点符号不高亮展示,上面采用正则进行匹配,m 个符号,最差时间复杂度 O(m),使用哈希表,时间复杂度 O(1)
1const charMap = ',。!:‘“’”?、…;~﹏¥()【】『』『』「」﹃﹄〔〕—'.split('').reudce((obj, key)=>{
2 obj[key] = true;
3 return obj;
4},{})
TIP
这个正则替换为哈希表没有实现
上面的 svga 动画文件解析是全量解析,这样会导致一个问题:cpu 密集计算,阻塞渲染线程。为了绕过这个限制,只解析首页 svga 动画文件。
1. 只预先解析首页 svga 动画
1let firstPromise = null;
2
3firstPromise = [
4 preloadPromise('url'),
5 preloadPromise('url')
6]
7
8export function getFirstPromise() {
9 return firstPromise;
10}
11
12export function clearFirstPromise() {
13 firstPromise = null;
14}
TIP
因为首页预解析只在首页需要,提供 clear 方法清除内存占用。
TIP
svga 动画解析,也考虑过使用 webWorker 多线程解析,利用当下手机的多内核,但是实际测试发现,webWorker 全量解析时,中低端手机直接黑屏,如果加上调度算法,又会导致 后面的 svga 解析缓慢
2. 利用本地化下载时 cpu 空闲时间
在本地化下载文件时,native 可以对下载的 svga 文件,进行预解析,同时保存解析结果为 json 文件,减轻后续播放 cpu 密集计算
TIP
当前未实现,只是一个想法
如果全量渲染所有页面,页面只有 10 页的情况下内存占用基本上就到到达 150M,所以采用虚拟渲染的概念,每次播放只预渲染前中后 3 页动画、前中后 3 幕字幕,减少 dom 数量,减少内存占用。
上面只渲染 3 个页面虽然内存占用少了,但是会出现 cpu 频繁计算,在每次切换页面时,都需要解析下一个页面组员,解析 svga、渲染字幕,这里就会出现一个 cpu 峰值时期,在此刻再次切换页面,在低端机页面容易出现卡死、白屏等问题。
TIP
如果在本地化 下载阶段就进行解析 svga 文件,也许就可以解决 cpu 频繁计算问题。
TIP
svga 开源播放库,在播放时,在每一帧会重复进行屏幕适配,本项目中的播放库经过修正,只在播放器初始化时进行屏幕适配,已发公司 npm 管理库。
当前项目是 2D 平面翻页,3D 翻页也已经实现,但是只能在电脑端模拟,手机端性能较差,也许当实现本地化资源下载时期进行解析动画文件,可以解决 3D 翻页效果差的问题。
---------- END ----------
加入掌门
掌门在招职位有研发总监( Java
/音视频方向)、研发工程师/架构师( Web
前端/ Java
/ iOS
/ 安卓 )、测试工程师(功能/自动化/性能)、DBA
、大数据工程师、算法工程师( NLP
/用户画像)、K8S
架构师、运维工程师、产品经理。欢迎加入掌门教育大家庭,一起畅谈技术,分享交流。
投递信箱:zeying.shi@zhangmen.com 施老师。
往期好文
微信扫一扫
关注该公众号