专栏名称: 网易云音乐技术团队
网易云音乐技术团队公众号
目录
相关文章推荐
单向街书店  ·  【单向历】1 月 31 日,宜丢弃 ·  2 天前  
ONE文艺生活  ·  已造谣,莫辜负 ... ·  2 天前  
十点读书  ·  “一点破事就发朋友圈的人,没见过世面” ·  2 天前  
十点读书  ·  父母不要太尊重、太共情孩子 ·  3 天前  
51好读  ›  专栏  ›  网易云音乐技术团队

云音乐2023年报前端大揭秘

网易云音乐技术团队  · 公众号  ·  · 2024-07-01 17:34

正文

前言

每年的云音乐年度听歌报告,就像一个靠谱的老朋友,总会在忙碌一年的时光尽头里叩响记忆的大门。

  • 如果你曾错过了彼时的年报,不妨现在就拿起手机扫描上图的二维码,与往期的精彩视听来一场不期而遇的邂逅;
  • 如果你为年报中精巧而温馨的动画而深深着迷,请直接移步至本文的姊妹篇:「云音乐2023年报动效大揭秘」一探究竟;
  • 如果你恰好想了解年报中前端开发承担了什么样的角色、积累了哪些最佳实践 ——那么巧了,本文将从性能体验、质量管理、工程效率和一些笔者底层思考,帮助你逐步揭开年报的神秘面纱

性能体验

首次访问年报活动为例,好的用户体验主要包括以下几个方面:

  • 页面能秒级打开,页面到达率高、流失少;
  • 页面间转场展示流畅,不会出现卡顿;
  • 文本和图片内容在任何设备上都能完整展示,不出现缺失或者加载闪烁情况;
  • 音频或视频启播速度快,播放过程中不发生错乱或者卡顿情况;
  • 页面内的动效展示流畅,不会出现卡顿;

简而言之,体验优涉及以下几个方面:

1、页面导航:包括首屏秒开,页面转场等

2、资源管理:包括图片、视频、音频和字体包等

3、页面适配:包括文本适配、动效适配和机型适配等

首屏秒开

首屏秒开是指用户从点击链接开始到展示页面内容大约在1s左右完成。整个过程经历如图所示:

容器初始化 -> CDN -> TCP 建连 -> html/js/css 加载解析 -> DOM / CSSOM 解析 -> 渲染布局 -> 绘制


对于前端开发来说,优化难度是从右到左,越到左边就越需要跨团队合作来完成。可以简单的分析总结:

  • 在渲染/绘制阶段,可以隔离状态变化频繁的组件,减少无效状态引起的绘制。尽量选择由 GPU 渲染的 CSS3 来实现动效。逻辑实现的动效建议使用GSAP

  • 在 DOM / CSSDOM 解析阶段,可以减少 DOM 的嵌套深度,减少使用 JavaScript 直接修改元素样式,减少不必要的 CSS,减少使用 CSS 选择器等。

  • 在 HTML / JS / CSS 加载解析阶段,可以通过构件工具对文件进行压缩。利用离线包能力,将这些资源提前下载到本地。

  • 在 CDN、TCP 建连阶段,更多依赖 App 网络库底层的优化,如使用 HTPP/2 减少 TCP 连接数。

  • 在容器初始化阶段,为了达到极致的打开速度,可以将 H5 容器进行预初始化。也可以建立容器复用池,减少容器创建的耗时。

页面管理

页面路由

为了有效管控年报中多个视图的高效展示和切换,采用了 SPA[1] 的形式对H5进行组织,同时为页面路由提供了路由表的配置。

路由表简单理解是各个子页面路由对象的集合,这个集合可以是全局数组对象,本地文件,或者服务端下发的配置。集合内的顺序决定了用户看到报告页顺序。这样也可以根据产品的述求,灵活调整集合内的顺序,这样就能动态调整页面顺序了。

各个子页面路由对象的属性具体如下。建议不用传统的 path,因为子页面数量多,且名称很难记忆,可以使用 routerIndex,这是子页面在交互稿中的位置代号,方便开发和调试。其中 ignoreSwipe 是使用在下节手势处理中「子容器接管父容器手势」的场景。

export interface PageProps {
    model: unknown;
    position?: number// 页面埋点使用
}

export interface PageRouteProps {
    c: ComponentType;
    cId: string// 当前页面唯一id
    routerIndex: number | string// 路由索引
    ignoreSwipe?: boolean// 是否忽略滑动,默认false
}

手势处理

年报项目中用户可以通过点击、左右滑动、上下滑动来切换页面。为了防止手势冲突,全局只有一个父容器,各个报告页面是子容器;一些手势频控、页面状态变化等通用逻辑统一在父容器中实现。只在父容器中使用 hammerjs 做手势监听,子容器不再负责页面切换的手势监听,关键代码如下。

 hammer = new Hammer(reportRef.current);
 hammer.get('swipe').set({ direction: Hammer.DIRECTION_ALL });
 hammer.on('swipeleft', onNextPageWrap);
 hammer.on('swiperight', onPrePageWrap);
 hammer.on('swipeup', onNextPage);
 hammer.on('swipedown', onPrePage);

对于子容器,可能会有以下几种特殊情况:

情况一:如果子容器需要感知用户手势事件,可以监听父容器发出的自定义事件。

// 【翻页】动效:下一页
export const ON_PAGE_NEXT = 'ON_PAGE_NEXT';

// 【翻页】动效:上一页
export const ON_PAGE_PREV = 'ON_PAGE_PREV';

情况二:如果子容器需要完全接管父容器的手势监听事件,例如年度歌手相关的所有页面。首先需要在路由表中将 ignoreSwipe=ture 设置忽略手势,然后在监听父容器发出的通知,进行自定义的事件处理。最后当子容器接管结束后,需要根据需要再次触发父容器的切换事件。关键代码如下:

const onNextPage = useCallback(
    (nextAction) => {
        if (currentPage >= len - 1) {
            nextAction(); // 结束接管,再次触发父容器的切换事件
            return;
        }
        
        setCurrentPage(currentPage + 1);
    },
    [currentPage, len]
);

useEffect(() => {
        bus.on(ON_PAGE_NEXT, onNextPage);

        return () => {
            bus.off(ON_PAGE_NEXT, onNextPage);
        };
    }, [onNextPage, onPrePage]);
    

情况三:如果子容器存在特定区域需要响应特点手势,例如歌手来信页面左右切换是查看歌手来信。这时候需要子容器调用stopPropagation主动阻止手势向父容器传递。

转场实现

在路由表和手势处理准备就绪后,最后来看看页面之间转场的实现。年报页面的转场效果使用React官方实现的 react-transition-group 组件。由于篇幅限制,这里不详细介绍react-transition-group 组件的底层原理。结合路由表顺序和当前页面位置,通过 z-index 和 match 来控制子页面的层级和显示隐藏。使用 CSSTransition 实现页面间的进场和退场CSS动画。关键代码如下:

(pages || []).map((item, index) => {
    // zIndex 和 match是关键代码
    const zIndex = (pages.length - index) * 100;
    const match = index === currentIdx;
    return (
        
            key={item.cId}
            style={{
                zIndex,
                pointerEvents: match ? 'auto' : 'none',
                overflow: 'hidden',
            }}>
                                    in={match}
                    timeout={100}
                    ...
                    appear
                    unmountOnExit>
                                            model={item.model}
                        position={index} />
                </CSSTransition>
          
div>        
    );
})

但是以上的页面转场存在一个问题,即页面之间只能存在一种转场方式。如何自定义页面之间的转场效果?基本思路是各自页面维护自己的转场效果;大部分页面只需将转场信息配置到路由表中;小部分页面可以通过页面上下文获得上一个或者下一个页面的信息,动态决定如何进场或者退场。基于该思路,改造路由表对象,新增一个 TransitionParams 协议,并且提供渐隐渐显的默认转场实现。关键代码如下:

export type TransitionParams = {
    timeout: number | { appear?: number | undefined; enter?: number | undefined; exit?: number | undefined };
    classNames: CSSTransitionClassNames;
};

export interface PageRouteProps {
    c: ComponentType;
    cId: string// 当前页面唯一id
    routerIndex: number// 路由索引
    transition: TransitionParams; //默认是渐隐渐显的转场效果
    ignoreSwipe?: boolean// 是否忽略滑动,默认false
}

CSSTransition 配合改造后的关键代码如下:

    in={match}
    timeout={item.transition.timeout}
    // 关键代码
    classNames={item.transition.classNames}
    appear
    unmountOnExit>
        
</CSSTransition>

资源管理

资源管理主要任务是将网络资源下载到本地,最终将本地资源加载到内存,以便程序可以使用这些资源。优化资源管理可以有效提高年报用户体验。通常开发者会通过压缩资源的大小,并通过内容分发网络(CDN)加快资源的下载速度。但是除了这些还有其他通用的方法呢?

在介绍具体优化手段前,先来看以下关键字,这些是性能优化的通用方法。

  • 提前 preload:提前准备必要的资源,提升加载速度

  • 同步 sync:串行执行当前任务,确保执行任务的优先级

  • 异步 async:工作线程异步执行,处理比较耗时的操作,不阻塞主线程

  • 懒加载 lazy:又称按需加载,不浪费请求

  • 缓存 cache:将资源缓存到内存或者磁盘本地,减少不必要的网络请求

  • 延迟 defer:不立即执行任务,延迟执行

总结如下图:


接下来,将从图片、视频、字体包等各个资源,进一步解析如何结合上述关键词进行优化。

图片

关键字:提前 preload、懒加载 lazy、缓存 cache

图片资源占整个年报项目资源中的比例是最高的,大概 70% 左右。所以图片展示速度是否足够快和内容是否完整都会直接影响用户体验。

优先将图片资源使用 tinypng 进行手动压缩,在不失真的情况下,保证图片大小足够小。其次正确选择图片格式能有效减少图片大小。其中图片格式很多,主流的有:

  • SVG 是基于XML的矢量图片格式,不失真无限放大。支持动画。
  • JPEG 是有损压缩,不支持透明度或者动画。
  • PNG 是无损压缩,支持透明度。APNG 是 PNG 的扩展,支持动胡奥。
  • WebP 是无损和有损压缩,支持动画或者透明度。
  • GIF 是位图图片格式,支持动画。

不同图片格式在不同场景上使用。格式没选准确,会导致资源浪的费。如在「年度总览」一页中,海浪🌊背景是一张PNG格式,大小为 1.7MB。但是该场景不需要透明,选择JPEG后大小为 96kb。总结如下是选择图片格式的流程图。


小图标或 logo 可以使用 SVG。其它能用 WebP 尽量使用 WebP。对于 WebP 的兼容性问题,可以通过业务封装的图片组件进行处理,不能使用 WebP 则兜底变成 PNG ,因为 PNG 兼容性最好。GIF 尽量不适用。对于超过特定大小的动图,建议使用CSS动效或者视频替代。

在已经压缩图片和选择正确的图片格式的前提下,会在当前报告页面提前 preload 预加载下一页面的图片资源,并将图片缓存cache在本地。

预下载的方式有多种,可以自动全量下载,也可以手动按需下载。

自动全量下载,可以基于上文 CSSTransition 的 in 参数。不只是匹配当前页面进行渲染,也提前渲染下一页。自动下载的方案存在缺点比较明显,如:不能按需下载;页面的生命周期和用户感知不一致,导致一些逻辑提前执行如页面曝光埋点。

let matchIndex = -1;

(pages || []).map((item, index) => {
    const zIndex = (pages.length - index) * 100;
    const match = index === currentIdx;
    if (match) {
        matchIndex = index;
    }

    return (
        
            key={item.cId}
            style={{
                zIndex,
                pointerEvents: match ? 'auto' : 'none',
                overflow: 'hidden',
            }}>
                                    // in 这里是关键代码
                    in={match || (index ===  matchIndex + 1)}
                    timeout={item.transition.timeout}
                    classNames={item.transition.classNames}
                    appear
                    unmountOnExit>