背景
云音乐桌面版于 2014 年 5 月上线,从上线到本次 3.0 改版之前一直沿用的基于
NEJ
+ CEF(
Chromium Embedded Framework
) 的 Hybrid APP 架构。其中,前端基于 NEJ 实现的架构,存在开发理念落后、没有社区生态和上手成本高的问题,在 2021 年到 2022 年期间,我们也尝试了在 NEJ 技术栈中加入 React 技术栈(简称双栈)。但是,由于 APP 的 99% 的代码都在 NEJ,所以后续基于 React 技术栈的实现,围绕双栈做了数据通信、事件调用的实现,确保新增业务实现都是能使用 React 实现(开发效率高、开发成本低)。
虽然,我们新的业务需求可以基于 React 实现,但是,仍然受限于核心数据模块维护在 NEJ 侧、时常只能修改 NEJ 实现来完成业务交付(React 重写成本高,无法按时交付业务)。另一方面,在 3.0 改版,我们迎来了
整个应用的交互、视觉上的全新调整
,用原先 NEJ 的实现去修改实现成本很高、以及后续开发迭代会面临之前提及的问题,所以综合考量之下,我们选择了基于 React 重构整个应用。
但是,要对一个有 40+ 页面(几十万行代码)的项目进行重构,所要面临的挑战肯定是巨大的。同时,在我们 3.0 内测过程,也收集到很多热心的用户有关新版本的使用反馈,其中性能问题尤为凸显,主要集中在页面切换卡顿、滚动白屏、内存占用大等性能相关的问题。因此,针对这些性能问题我们也进行了专门的性能优化治理,在性能优化治理的过程我们面临的挑战主要是以下 4 个方面:
-
产品交互形态多种多样
:包含了 40+ 的页面和多个窗口(登录、音效和客服等),我们为了统一视觉标准和提高 UI 层的可维护、可扩展性,从 0 开始建设了 30+ 基础组件和 100+ 的业务组件,其中业务组件提供了业务场景下高度定制且可复用的组件。但是,与之而来的部分业务组件的复杂度是非常高的,以歌单列表为例,其支持了排序、拖拽、虚拟列表和滚动定位等,这在 React 框架下开发,组件的 render 和 re-render 性能则会变得尤其重要,因为复杂组件的一次 render 成本非常昂贵,如果没有加以合理控制 render 和 re-render 则会给用户带来使用时的明显卡顿感
-
分发场景(歌单)的数据量大
:歌单作为云音乐最重要的歌曲分发的场景,由于其对应的歌曲数量常常是成千上万的量级的特点,则需要以虚拟列表的方式进行歌单列表的 UI 展示,但是,由于其多数为大列表,这就对快速滚动、内存管理、组件复杂度(渲染性能)和播放起播耗时等有较高的要求,因为在大数据量的影响下这些问题都会演变成非常严重的问题
-
全局维度的功能和事件类型多
:可触发的全局功能有 90+,事件类型有右键、左键、双击、菜单和键盘等,我们在维护统一的事件分发中心的基础上,也提供了非常轻便的 UI 声明式(通过
ActionProvider
组件配置 Props) + 运行时的注册事件实现,虽然,UI 声明式降低了事件注册的开发成本,但是,其强依赖运行时的真实注册事件会随着 React Component Tree 的层级增加产生非常严重的卡顿影响
-
视图订阅状态(State)复杂度高
:全局的数据模型(维护 State)有 50+,包含播放、下载、本地音乐、用户相关、播放列表、应用相关、配置中心和 ABTest 等。站在视图的维度,常常需要订阅不同模型下的状态才能完成正常页面的展示,其中,以一个歌曲资源为例,它对应的视图通常需要订阅下载、收藏、红心、播放、云盘等状态,数量非常之多。所以,如何合理管理订阅数据的视图范围则变得非常重要,因为如果视图中包含了状态非相关的组件,或者相关组件复杂度也很高,那么在状态变化时 re-render 的成本也会变得非常昂贵,同时也会造成严重的卡顿问题
基于这 4 个方面的挑战所带来的问题,我们的性能优化也着重对
播放起播耗时长
、
交互卡顿明显
和
系统资源占用大
等问题进行对应的分析和治理。接下来本文也将会从实际的业务场景角度出发,围绕以下 4 点展开介绍在具体性能问题下的应对和思考:
一、播放起播耗时优化
作为一款音乐类目软件,播放功能是我们最为重要的功能。相比较旧版本而言,在 3.0 中我们围绕
播放中的状态
做了更多产品交互上的改善和调整,例如播放条黑胶转动、歌单列表项播放中的动图和歌曲名高亮、歌单和播单等资源卡片播放中状态按钮:
通常情况下,用户播放会进入到歌单页面点击播放全部、单击歌曲或者全部添加到播放列表来播放歌单列表的歌曲,其中播放流程的实现(简化版):
播放是用户使用应用所必定操作的功能,播放相关的体验也是我们所重点关注的内容,其中较为重要的则是播放起播(开始播放)的耗时长短。但是,起初我们的播放起播速度并不理想,导致起播耗时的原因主要是以下 2 点:
-
歌曲列表接口分页请求耗时,播放歌单或者歌曲列表需要获取其所有的歌曲列表数据,但是因为对列表场景做了虚拟列表的优化,所以默认情况下只请求了一次接口(接口分页,长度 500),这就导致如果当歌曲列表超出 500 条,在播放该列表的时候就需要等待拉取全部的歌曲列表(存在接口请求耗时)
-
播放信息(State)更新导致视图渲染阻塞起播流程,在播放一首新的歌曲时会先更新当前播放的基础信息,如歌曲名称、歌手和封面等,然后再交给播放器去加载歌曲播放资源和起播,但是因为播放的基础信息更新会导致订阅者视图(播放条、歌曲列表等)的重新渲染,产生阻塞播放器播放任务的执行的问题(等待前者执行,起播时间延后)
通过对比新旧版本的播放起播的耗时,
以歌单歌曲 1000 首为例,起播耗时在 4410 ms 左右,旧版本在 1733ms 左右,2 者存在较为明显的差距
,同时线上也收集到大量相关的舆情反馈。因此,优化播放起播耗时也成为当时所迫在眉急的事情。下面也将分别会针对上述 2 个导致起播耗时的原因,介绍各自存在的问题和如何应对优化。
1.1 接口预加载
首先,是歌曲列表接口分页请求耗时(获取完整的歌曲列表)。在前面的小节中介绍到歌单页的列表实现是基于虚拟列表实现的无限滚动列表,所以默认进入歌单页只会拉取第一页(500 首)的歌曲列表数据。但是,站在播放的角度,在歌单场景播放默认情况下是播放该歌单下的全部歌曲,所以此时就需要按照歌单列表分页总数来分批次请求接口,用于获取歌单下的全部歌曲给到播放流程,而请求分页接口会存在等待服务端接口响应耗时:
由于通常用户进入歌单场景到开始播放歌单之间会存在一定
空闲的时间
,那么,在这个空闲的是时间内,则可以陆续按照列表分页总数来分批次
预加载该接口
,避免请求接口的耗时发生在用户在播放的过程中:
1.2 渲染调优:re-render优化和组件复杂度降低
然后,是播放信息(State)更新导致视图渲染阻塞起播流程。在初始化播放 State 时,订阅播放 State 的组件则会开始渲染,如 Render 播放条(Minibar)、歌单列表项:
并且,到这个阶段播放的起播流程还未结束,如请求播放歌曲信息、开始播放阶段。大家都知道的是在浏览器中,JavaScript 代码的解析执行和渲染流水线同属于宏任务,在一次浏览器事件循环(
Event Loop
)中宏任务是按照进队顺序依次执行的。
因此,播放状态改变导致的渲染行为则会导致后续的请求播放歌曲信息和开始播放阶段等待前者渲染结束。如果,此时渲染行为所需要的耗时越长则会导致后续起播的阶段等待的时间越长,所以需要对这部分视图关联的组件做渲染调优处理(降低前者等待的时间)。
首先是歌单列表项的渲染调优。在列表组件中类似于表格的概念,每个列表项(表格列)都是由多个
Cell
组件构成,歌单列表项中和订阅播放状态相关的组件主要是播放按钮和歌曲名称:
-
播放按钮由
TableIndex
组件和各类业务场景的
IndexCell
组件组成
-
歌曲名称由
TrackTitleCell
组件和各类业务场景的
IndexCell
组件组成
其中,对于
IndexCell
组件来说,它仅仅是做业务场景到
TableCell
的参数透传,例如专辑的播放按钮的
IndexCell
组件:
const IndexCell: ICellRender = (props) => {
const { row } = props;
const { index, data } = row;
return (
<TableIndex
index={index}
data={{
resource: data,
resourceType: ResourceType.album,
}} />
);
};
同理,对于歌单、搜索、播单等场景的播放按钮组件也是一样的使用,都只做业务场景的参数透传给
TableIndex
组件,然后再由
TableIndex
去订阅播放 State。那么,与之而来
TableIndex
则会存在 2 个问题:
-
所有业务场景的播放状态订阅和处理全维护在
TableIndex
组件,因为非本场景的代码混杂一起,导致 render 和 re-render 成本非常昂贵
-
在组件的实现较为复杂,存在冗余的 CSS-in-JS(Linaria)组件,因为每个
styled.div
使用的背后都是由 React Component 进行渲染(组件树的复杂度上升)
统一封装到
TableIndex