云音乐桌面端 3.0 改版前端性能优化之旅

发布于:2024-05-10 ⋅ 阅读:(19) ⋅ 点赞:(0)

本文作者:吴敬昌 蒋涛

本文介绍了云音乐桌面端 3.0 改版前端在性能方面遇到的挑战和优化实践(卡顿、系统资源占用和具体业务场景等优化)。

背景

云音乐桌面版于 2014 年 5 月上线,从上线到本次 3.0 改版之前一直沿用的基于 + CEF() 的 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 代码的解析执行和渲染流水线同属于宏任务,在一次浏览器事件循环()中宏任务是按照进队顺序依次执行的。

因此,播放状态改变导致的渲染行为则会导致后续的请求播放歌曲信息和开始播放阶段等待前者渲染结束。如果,此时渲染行为所需要的耗时越长则会导致后续起播的阶段等待的时间越长,所以需要对这部分视图关联的组件做渲染调优处理(降低前者等待的时间)。

首先是歌单列表项的渲染调优。在列表组件中类似于表格的概念,每个列表项(表格列)都是由多个 Cell 组件构成,歌单列表项中和订阅播放状态相关的组件主要是播放按钮和歌曲名称:

  • 播放按钮由 TableIndex 组件和各类业务场景的 IndexCell 组件组成
  • 歌曲名称由 TrackTitleCell 组件和各类业务场景的 IndexCell 组件组成

其中,对于 IndexCell 组件来说,它仅仅是做业务场景到 TableCell 的参数透传,例如专辑的播放按钮的 IndexCell 组件:

const IndexCell: ICellRender<IBaseProps, IColumn, IAlbum> = (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 中,虽然很好地复用了组件,但是导致了 render 和 re-render 的成本上升,因为各个场景混杂着非本场景的代码。那么,这就需要合理地解耦各个业务场景的播放状态订阅和处理到各自的 IndeCell 组件中,然后 TableIndex 组件只接受 isPlaying 的 Props 透传,以及使用 memoTableIndex 组件进行新旧 Props 对比(避免冗余 re-render):

import { isEqual } from 'lodash-es';

export default memo(TableIndex, (oldProps, newProps) => {
    const isDataEqual = isEqual(newProps, oldProps);

    return isDataEqual;
});

其次,TableIndex 中使用了 CSS-in-JS 提供的 styled.div 来实现动态 CSS,其本质在编译的时候创建一个 React Component 来根据 Props 进行动态的渲染,这会导致组件树变得复杂,增加了渲染的成本,并且由于在列表场景 TableIndex 的数量是等于虚拟列表可视区域 + 缓冲区域的列表项总和:

所以,此时要降低 TableIndex 的 UI 实现的复杂度,通过原生的 HTML 标签 div、在行内 style 定义 CSS Variable 和在 CSS 中使用定义的 CSS Variable 来实现动态 CSS:

const styledIndexCellCls = css`
  ...
  .text {
      display: flex;
      min-width: 20px;
      justify-content: center;
      visibility: var(--text-default-visiblity);
  }
  ...
`

const TableIndex = <T extends {}, U extends []>(props: {
    className?: string
    isPlaying?: boolean
    enablePlay?: boolean
    playAction?: Action
    index: number
    data: ActionInfo<T, U>
}) => {
  ...
  return (
    <div
        style={{
            '--text-visibility': enablePlay ? 'hidden' : 'visible',
            '--text-default-visiblity': isPlaying ? 'hidden' : 'visible',
            '--play-visibility': isPlaying ? 'visible' : 'hidden',
        } as React.CSSProperties}
        className={classnames(className, styledIndexCellCls)}>
        ...
    </div>
  )
}

这样一来则可以降低使用 CSS-in-JS 创建的冗余的 React Component 带来的冗余渲染开销。最后,在综合上述 2 者的优化之下,仍然是歌单 1000 首的情况下,对比之前的数据播放起播耗时从 4410.67 ms 降至了 2133.67 ms(48.37%)

二、交互卡顿优化

站在浏览器渲染的角度,我们所制作的网页最后会经过浏览器渲染流水线绘制到屏幕上,然后通常情况下屏幕的刷新频率是 60 Hz,也就是每秒会刷新 60 次,所以当绘制的数度慢于屏幕的刷新时,则会产生卡顿的问题。

2.1 通用交互卡顿

UI 声明事件转 JavaScript 事件调用

在前面提及,针对全场的事件我们会通过 ActionProvider 来实现,在平常的业务开发中,仅需要通过配置 ActionProvider 的 Props 则可以完成,例如配置歌单的事件:

function PlaylistCard(props = {}) {
    const { data } = props

    return (
        <ActionProvider
            // 可右键打开歌单对应的菜单
            menu
            click
            data={{
                // 歌单数据
                resource: data,
                // 表示资源是歌单用来事件处理菜单映射
                resourceType: ResourceType.playlist,
                from: {
                    to: {
                        // 表示可支持点击跳转到 linkPath歌单详情页
                        linkPath: `${ROUTE_PATH.playlist}/${data?.id}`
                    }
                }
            }}
            >
            <div>
                歌单
            </div>
        </ActionProvider>
    )
}

这样就完成了歌单相关的点击路由跳转、右键菜单打开的功能,后续的操作也会携带上这里的 data,例如右键菜单收藏歌单会消费 data 的数据。其中,在 ActionProvider 的内部会根据 Props 的配置信息去给 div 绑定指定的事件,如 onContextMenuonClick

const ActionProvider = function(props) {
    const { children } = props
    const handleClick = useCallback(() => {
        // ...
    }, [])
    const handleDoubleClick = useCallback(() => {
        // ...
    }, [])
    const handleMenuClick = useCallback(() => {
        // ...
    }, [])
    const eventProps = useMemo(() => {
        onClick: handleClick,
        onDoubleClick: handleDoubleClick,
        onContextMenu: handleMenuClick
    }, [handleClick, handleDoubleClick, handleMenuClick])

    const finalChildren = useMemo(() => {
        // ...
        // 统一拷贝一份 children 保证旧的 Props 的不变和新的 Props 加入
        return React.cloneElement(children, eventProps)
    }, [children, eventProps])

    return (
        <>
            {finalChildren}
        </>
    )
}

通过示例可以得知使用 ActionProvider 可以通过 UI 声明式地配置化替代复杂的事件注册调用流程(简单,逻辑实现统一维护)。所以,这也在我们应用中大范围地得以使用,包含了播放、收藏、分享、跳转、创建歌单、删除歌曲、复制、举报、桌面歌词设置、下载、Mini 模式设置、云盘等 90+ 个功能相关的 Action 实现。

虽然, ActionProvider 的设计实现使得应用中的核心事件的注册、实现和维护变得简单,但是,其 UI 声明式的统一实现方案也带来了性能上的问题(卡顿):

  • 由于是一套统一方案,依赖或 Props 变化过于离散,存在大量的 re-render
  • 使用 React.cloneElement 对真实组件或组件树进行拷贝,产生运行时对 CPU 和内存的明显消耗

所以,ActionProvider 带来性能问题的严重程度会受到使用的数量和组件树的复杂度呈正相关的影响。并且,在当时整个应用中总共涉及 306 个文件和 674 处使用,也因此这类性能问题导致了应用全场景使用的卡顿,在当时应用功能的主观评测打分(满分 5 分),整体体验为 3.2 分(卡顿),旧版本为 4.2 分,较为不理想。

那么,要如何解决这个问题?是打破重来吗?

显然不可行,因为打破重来势必会导致上线后的功能稳定性问题,并且重新开始的成本是非常高的。回到 ActionProvider 的实现,其一是自动注册事件,其二是自动分发事件,对于第一点已不合理,因为各业务场景的 UI 是不可控的,无法通过统一的组件去合理控制组件的 re-render(离散不可枚举)。所以,需要实现可替代之前自行注册事件的方案,由需要绑定事件的组件去实现。其次,对于第二点,自动分发事件仍然可以保留,最终的方案也就是我们可以从 UI 声明式地配置化转位对应的 JavaScript 事件调用:

例如,原先的 ActionProvider 使用:

function Demo() {
    return (
        <ActionProvider
            click={Action.play}
            data={actionData}>
            <TrianglePlayButtonWrapper>
                // ...
            </TrianglePlayButtonWrapper>
        </ActionProvider>
    )
}

转为点击事件 JavaScript 事件调用式后:

import { doAction } from '@/components/ActionProvider/event';

function Demo() {
    const onClick = useCallback((e) => {
    doAction({
            click: currentAction,
            data: {
                resource,
                resourceType,
                from: from ?? {},
            },
            event: e
        })
    }, [currentAction, resource, resourceType, from])

    return (
        <TrianglePlayButtonWrapper
            onClick={onClick}
        >
        // ...
        </TrianglePlayButtonWrapper>
    )
}

那么,这样一来 ActionProvider 的实现的第二点得以很好的保留,且原先 UI 声明式的使用带来的性能问题也得以解决,应用整体功能的使用体验也得到了大幅提升,整体体验的主观评测分数也提升至了 4.2 分,基本对齐旧版本。

2.2 歌单列表卡顿

歌单作为云音乐十分重要的分发场景,其中较为复杂的场景则是自建歌单,如我喜欢的音乐、创建的歌单,由于它们可收藏本地歌曲、下载的歌曲、云盘歌曲等,所以在歌单中的列表项的数据来源场景会多种多样,与之而来列表项的实现也就相对复杂。

在我们应用中,所有类型的列表(歌曲、云盘歌曲、下载歌曲、本地歌曲、专辑歌曲、搜索歌曲等)都视为一种业务场景表格组件,而所有的业务场景表格则是由自定义的表格每行的列组件 Cell 和整体的 TableViewerTableViewerMain 组件构成,它们之间的渲染关系:

可以看到除了渲染展示列表,TableViewerTableViewerMain 组件还实现了以下的功能:

  • 表格排序,基于表格 Cell 给定的列字段进行升降序排序
  • 播放中歌曲滚动定位,基于滚动容器 scroller 实现的滚动列表到当前播放的歌曲
  • 拖放容器,基于 实现的可被拖放的容器,用于列表拖动排序或者其他歌曲拖动收藏
  • 虚拟列表,基于滚动容器 scroller 实现的动态计算列表项 position 位置
  • 分页加载和搜索,在虚拟列表实现的基础上自动管理分页加载和搜索

那么,导致列表滚动卡顿的问题是什么?相信有同学已经发现职责不单一,从 TableViewerTableViewerMain 的实现上可以发现各自的实现没有明显的边界,与之而来的产生了以下 3 个问题:

  • 拖放容器和拖拽,耦合 ActionProvider(会有明显运行时性能开销),其实现是基于在 ActionProvider 在 的封装基础上
function Demo() {
    return (
        <ActionProvider
            data={dropConfig?.data}
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-expect-error
            drop={dropConfig ? {
                ...dropConfig.drop,
            } : undefined}>
            // ...
         </ActionProvider>
    )
}
  • 虚拟列表,首先虚拟列表实现在 TableViewerMain 中 re-render 的范围太大,导致 re-render 的成本是非常昂贵的,其次虚拟列表的实现是从零实现没有经过很成熟的打磨会有很多生产模式下的问题,例如快速滚动白屏、不支持快速滚动骨架屏等
  • 播放中歌曲滚动定位,实现在 TableViewer 中 re-render(每次滚动)的范围太大,导致 re-render 的成本是非常昂贵的

在综合这 3 个问题的影响下,最初我们在歌单列表场景的滚动存在较为明显的卡顿问题,同样是功能体验主观评测打分,列表滚动的得分是 2.2 分(卡顿),旧版本的得分在 4.5 分

针对第一个问题拖放容器和拖拽耦合 ActionProvider,这个问题并不难处理,只需找到可替代的 JavaScript 事件调用的方式,以拖放为例会是这样:

const { drop } = dropConfig || {};

const [dropRef]= useDropAction({
    drop,
    data: data!,
});

return (
    <div ref={dropRef}>
     <!--....-->
    </div>
)

通过统一 useDropAction 来承接原先透传给 ActionProvider 的配置,而 useDropAction 则是基于 react-dnd 和列表所在的 Context 实现(由于拖放最终需要消费整个列表的顺序),同理拖拽的实现也是一致的。

虚拟列表重构:更好的 DX 和 UX

然后,针对虚拟列表 re-render 范围大和方案不成熟问题,我们重构了 TableViewer 组件:

  • 基于 封装 VirtualizedList 组件实现了如下的能力:
    • Window Scroller,通过将 document.scrollingElement 或者 document.documentElement 作为 Scoller,实现窗口滚动的效果,例如歌单页、播单页等
    • 滚动占位,用于在用户快速滚动情况下的渲染占位的骨架屏元素,其中骨架屏基于 实现,可自定义不同场景的样式,其中由于 react-content-loader 默认的扫光动画是有 CPU 开销,考虑到性能所以默认关闭扫光动画
    • 滚动定位,基于 Scroller 的 offsetHeightscrollTop 和列表项的高度 rowHeight 实现滚动至指定索引的列表项定位(在使用 WindowScrollerList 的情况下,List 提供的 scrollToIndex 无法正常工作)
  • 删除 TableViewerMain 组件,将其内部实现移至 TableViewer,非必要的组件层级,简化组件树
  • re-render 最小组件单位原则,从 TableViewer 组件中剥离歌曲播放中定位组件,减少 re-render 时的组件渲染成本

通过上述的优化手段的落地,主观评测也从最初的 2.2 分提升到了 4 分接近于旧版本,相关的舆情反馈也得到了对应的治理(相比优化前环比下降 68.22%):

在这里可能有同学会有疑问:”为什么不在原有手写的虚拟列表实现上继续优化修改?”。其实,不仅仅是今天本文中这个场景大家会有这种疑问,在平常的工作中相信也有可能遇到这种情况。对于前者手写实现,我们可以归为一类一般能力较强的同学,他们遇到这类场景会有从零开始实现的习惯,对于后者使用开源实现,我们可以归为一类关注团队维护成本、功能丰富程度的“拿来主义“的同学。

显然,我们选择的是后者,因为通过对比社区实现的各类虚拟列表,我们选择了其中更为稳定、功能更为强大的 ,一方面降低了维护成本(经过时间验证),另一方面提供了诸多开箱即用的功能,减轻了相关业务功能交付的开发成本。

三、系统资源占用优化

3.1 CPU:动画按需执行

说起 CPU 的资源占用,很多同学的第一反应可能是 JavaScript 代码实现的不合理产生的长任务(或耗时)导致的 CPU 的资源占用,这也是大部分应用 CPU 占用高的主要原因。但是,大家是否关注过在其他场景可能会导致 CPU 占用高的情况?例如 CSS 实现的动画产生的 CPU 占用。

在 3.0 中新增了很多动画,通过工具监控(系统任务管理器、Devtools 的性能监控器等)得出在开启动画的情况下,CPU 占用会增加 6% 左右,而这些动画大多都是基于 CSS keyframes 实现,例如底部播放条的黑胶转盘:

其对应的 CSS 代码实现:

@keyframes rotate {
    0% {
        transform: rotate(0);
    }

    100% {
        transform: rotate(360deg);
    }
}
animation: rotate 40s linear infinite;
animation-play-state: var(--animation-play-state);

此时,可能有同学会说使用 GPU 来加速,从而降低 CPU 的占用,这确实是一种解决方案,但是其实际只是转移了资源占用,并没有消除资源占用(导致 GPU 的占用上升)。

既然,使用 CSS 动画会产生 CPU 或者 GPU 的资源占用问题,那么需要将其产生的占用降低或者避免,这可以通过以下 2 种方式实现:

  • 通过原生组件渲染实现 CSS 动画,原生的动画实现会优于 CSS 动画,资源占用较小,例如通过实现混合渲染的架构,部分 UI 通过原生组件(Native UI)或者自绘引擎实现(如 Flutter),
  • 在应用切换到后台状态时,如最小化到任务栏、系统托盘、mini 播放器等情况下,自动暂停 CSS 动画的执行,避免相关的资源占用持续占用

相比较前者,后者的实现成本较低,我们也优先落地了相关的实现。首先,通过监听应用窗口的状态是前台还是后台来创建一个 windowStateChange$ 流,基于 windowStateChange$ 实现 useWindowShow hook:

const useWindowShow = (): [
    boolean,
    Dispatch<SetStateAction<boolean>>,
] => {
    const [isWindowShow, setIsWindowShow] = useState<boolean>(true);

    useEffect(() => {
        const sub = windowStateChange$.subscribe(({ isShow }) => {
            setIsWindowShow(isShow);
        });

        return () => {
            sub.unsubscribe();
        };
    }, []);

    return [
        isWindowShow,
        setIsWindowShow,
    ];
};

export default useWindowShow;

然后,在使用到 CSS 动画的地方,通过使用 useWindowShow hook 判断应用窗口状态是否在后台来决定暂停动画,其整体的工作流程:

最终,通过根据应用前后台的状态合理切换动画暂停和执行,我们应用在前台播放 CPU 的占用在 7% 左右,后台播放 CPU 占用在 0.74% 左右,避免了在后台情况下非必要的资源占用。

3.2 GPU:backdrop-filter 全局 CSS 和视口外 DOM 管理

除了上一小节提到的大量引入动画以及无节制地使用 GPU 加速会导致 GPU 占用高之外,在我们的排查实践中,发现错误地使用全局 CSS 属性和视口外 DOM 元素未及时清理是另外两个引起 GPU 高占用的主要因素

是一个十分强大的 CSS 属性,其可以通过不同的 filter 函数实现在层叠上下文中对层级在指定 DOM 元素之下的视觉内容进行高斯模糊、灰阶、对比度、饱和度等样式调整。而在 3.0 的云音乐中,全局应用了其提供的 2 个函数:grayscaleblur。其中,grayscale 应用在 React 挂载的根结点,用于在合适的时机(清明节等)对页面进行灰显展示,反之通过 backdrop-filter: grayscale(0) 来禁用;然后,blur 则应用在底部播放条,用于改善播放条在不同页面上的显示效果,提升用户体验。

虽然,全局范围应用 backdrop-filter 属性本身并不会引入特别大的资源占用问题,但是当页面中存在比较多的动画时,二者将产生并不美妙的“化学变化”:backdrop-filter 在绘制时会根据外部元素计算视觉效果,这在并不频繁的用户操作场景下无可厚非,但是自动且不断循化的动画(如底部播放条的黑胶转盘)不可避免地导致了 GPU 资源的持续消耗。

转动的黑胶唱片作为云音乐具有识别度的特征自然不能移除,那么针对该问题则需要从 backdrop-filter 本身以及 2 者之间的关联 2 方面着手考虑:

  • 针对 backdrop-filter 本身,在根结点通过 backdrop-filter: unset 彻底禁用灰显(grayscale(0) 仍然存在 GPU 占用);禁用底部播放条的高斯模糊,改用类似的静态颜色替代。
  • 针对 2 者之间的关联,调整底部播放条的 DOM 结构,通过合适的合成层优化,将转动的黑胶唱片从高斯模糊的计算范围中剔除。

考虑到调整 DOM 结构进行优化的时间成本以及额外的回归成本,我们优先落地了前者的优化方式。而后者在实现的可行性,以及兼顾了资源占用和视觉效果方面的优势,将是下一阶段的优化方向。

与 2.0 的云音乐相同的是,3.0 的云音乐除了常规的路由页面之外,可以通过点击底部播放条的黑胶转盘唤起独立的黑胶播放页面。不同之处在于,本次改版中对黑胶播放页的评论与歌词进行了分离。而为了保持用户在这 3 个页面之间切换的流畅程度以及切换后能够立即消费我们准备好的内容,如减少图片等资源的加载时间,我们对这些页面进行了常驻处理:即使用户在浏览常规的路由页面,应用在后台已经准备了黑胶播放页以及评论区域的布局框架以及大部分无需网络请求的内容:

此时,有同学可能会想到,3 个页面分别有各自的 DOM 元素,即使另外 2 个常驻页面没有在视口中参与页面展示,但是仍然会以层叠上下文的形式参与页面渲染。并且由于页面的复杂性,过多的 DOM 元素与层叠上下文极易引起 。同样的,在大量动画的参与下,层爆炸的影响进一步扩大。

针对该问题,我们对常驻页面的可展示内容进行了权衡。由于黑胶页面的 z-index 高于常规路由页面,应用展示常规路由页面时对黑胶页面通过 display: none 进行隐藏,避免黑胶页参与浏览器渲染过程的同时保留必要的 React 节点与逻辑;应用展示黑胶页面或评论页面时,对另外两个页面通过 visibility: hidden 进行隐藏,visibility 相较于 display 的优势在于浏览器缓存了页面的布局信息,可以更快地进行页面的还原。

最终,通过对上面两个问题的分析与优化,应用在用户常规操作时的 GPU 占用从 33.10% 降低到了 5.39%。

3.3 内存:清除非必要引用

3.0 的云音乐发布初期,有大量客诉反馈应用的内存占用持续增加且没有回落的趋势,在歌单浏览场景尤为明显,初步判断为发生了全局性的内存泄漏问题。

考虑到内存占用的增长在歌单、私信等场景下表现得尤为明显,最先想到的是 DOM 元素卸载后其 JavaScript 对象未能被垃圾回收这类内存泄漏问题。因为包含大量列表元素的滚动容器大都使用虚拟列表来优化滚动和渲染性能,但是虚拟列表涉及到频繁的 DOM 元素的增加和删除,如果在 DOM 元素删除时没有完全清理其对应的 JavaScript 引用,那么内存占用就会只增不减,最终影响用户体验。

在 React 框架中,为了能够方便地建立 DOM 元素与 FiberNode 之间的关联,由框架生成的 DOM 元素会持有其 FiberNode 对象的引用,FiberNode 中同样持有了相关 DOM 元素的引用。因此,无论是浏览器的 DOM 树还是 React 的 Fiber 树,只要有任意一个节点没有被正确释放引用,其自身以及所有子孙元素在两棵树上的对象都无法被垃圾回收。

通过浏览器的 Devtools 工具,我们可以按照下面的流程逐步排查和定位可能的内存泄漏问题:

3.3.1 定性

Performance Monitor 能够在较小的性能代价下展示出网站应用的若干个影响性能和体验的关键参数随着时间变化(用户操作)的趋势。针对内存泄漏问题,我们重点关注 JavaScript 堆大小和 DOM 节点数的变化趋势,并根据以下原则对内存泄漏进行初步的定性判断

  • 其中任何一个出现只增不减的趋势,则可以定性判断存在内存泄漏问题
  • 如果 JavaScript 堆大小只增不减,而 DOM 节点数趋势平稳,则可以定性只在 JavaScript 上下文中出现了内存泄漏
  • DOM 节点数只增不减往往会伴随着 JavaScript 堆大小的只增不减。此时需要关注二者增加的趋势是否同比(增长速度一致)同频(增长时机一致)
    • 如果同比同频,可以定性只有 DOM 元素卸载未清理引用引发的内存泄漏,JavaScript 堆大小的变化只是伴生现象
    • JavaScript 堆大小增长趋势更加陡峭,可以定性同时存在两个内存泄漏源头

而在我们的应用中,二者的变化趋势满足同比同频,所以可以确定是对 DOM 元素的引用没有清理导致的内存泄露问题。

3.3.2 定位

Detached Elements 的功能非常明确,即帮我们找到所有没有挂载在 DOM 树上,同时还没有被浏览器引擎垃圾回收的 DOM 元素。但是,因为浏览器的垃圾回收本身就是周期性的行为,所以在进行问题排查前,必须手动触发一次垃圾回收行为,保证剩下的就是要排查分析的目标元素。

3.3.3 分析

Memory 能够建立当前应用的 JavaScript 堆快照,用于进一步分析页面的 JavaScript 对象以及相互之间的引用关系。在我们已经定位了泄漏源的基础上,可以借助该工具查明目标 DOM 被什么 JavaScript 对象持有了引用导致无法被垃圾回收。

而读懂快照的重点在于 Distance 属性,在官方文档中,对 Distance 列的解释是 'displays the distance to the root using the shortest simple path of nodes'。

基于这里的快照,我们可以发现发生泄漏的 DOM 元素的 distance 是 7,点击之后可以反向追溯其到 Root(浏览器环境下为 window 对象)的完整路径。当然,持有该 DOM 元素的路径通常不止一条,我们只需要关注最短的那条即可。基于此,我们可以构建出其对象持有路径。

在分析了多个发生泄漏的 DOM 元素之后,我们最终定位到虚拟列表的父节点的 NE_DAWN_CHILDREN 属性持有了已经被卸载的 DOM 的引用,导致用户只要停留在歌单页面,那么滚动越多内存泄漏得越多。经过内部排查,发现 NE_DAWN_CHILDREN 属性是由埋点 SDK 管理的,其通过 监听 DOM 元素的挂载并进行记录保存,用于在 DOM 曝光时上报节点路径。但是在 DOM 元素卸载时没有及时地清除相关引用,引发了本次全局性的内存泄漏。

相应地,在处理了埋点 SDK 未及时清除引用的问题后,相比较 3.0 未优化的版本取得了较大的优化效果,对比旧版本在列表各种操作情况下的内存占用也基本对齐,同时,舆情平台上相关客诉也得以大幅减少。

五、Future:后续优化思考(计划)

诚然,通过对上述性能问题进行优化后取得到了显著的优化结果,但是,仍然需要进一步思考是否还有持续优化的空间,因此,下面汇总了 4 个后续我们在关于性能优化相关的思考:

  • 性能监控(防劣化),一方面对于核心业务页面增加 web-vitals 相关的指标监控,保证核心场景功能体验的稳定性。另一方面,对于播放过程增加监控,抽象播放过程关键指标(起播耗时、健康度),保证播放功能的稳定性。
  • 自绘 UI,Hybrid APP 架构虽然具备较高的研发效率,但是对比原生 UI 在体验上限方面是偏低的,所以需要通过自绘渲染引擎(如 Flutter) + 的方案来达到兼顾研发效率和体验上限高(提供和原生应用一致的交互体验)的结果
  • CEF 容器常态化更新,目前使用 CEF 的 Chromium(删减版)版本为 91,版本较为落后,通过保持 CEF 的常态化更新逐步对齐 Chromium 稳定版本来提升容器在渲染流水线、JavaScript 代码解析编译、内存分配等方面的性能
  • 播放流程重新编排,通过对播放流程的重新梳理和优化,如异步化耗时任务(播放列表构造)、延迟更新播放状态等,达成降低播放起播的耗时的结果

最后


网站公告

今日签到

点亮在社区的每一天
去签到