天天看點

雲音樂桌面端 3.0 改版前端性能優化之旅

作者:閃念基因
雲音樂桌面端 3.0 改版前端性能優化之旅

背景

雲音樂桌面版于 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 重構整個應用。

雲音樂桌面端 3.0 改版前端性能優化之旅

但是,要對一個有 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 改版前端性能優化之旅

一、播放起播耗時優化

作為一款音樂類目軟體,播放功能是我們最為重要的功能。相比較舊版本而言,在 3.0 中我們圍繞播放中的狀态做了更多産品互動上的改善和調整,例如播放條黑膠轉動、歌單清單項播放中的動圖和歌曲名高亮、歌單和播單等資源卡片播放中狀态按鈕:

雲音樂桌面端 3.0 改版前端性能優化之旅

通常情況下,使用者播放會進入到歌單頁面點選播放全部、單擊歌曲或者全部添加到播放清單來播放歌單清單的歌曲,其中播放流程的實作(簡化版):

雲音樂桌面端 3.0 改版前端性能優化之旅

播放是使用者使用應用所必定操作的功能,播放相關的體驗也是我們所重點關注的内容,其中較為重要的則是播放起播(開始播放)的耗時長短。但是,起初我們的播放起播速度并不理想,導緻起播耗時的原因主要是以下 2 點:

  • 歌曲清單接口分頁請求耗時,播放歌單或者歌曲清單需要擷取其所有的歌曲清單資料,但是因為對清單場景做了虛拟清單的優化,是以預設情況下隻請求了一次接口(接口分頁,長度 500),這就導緻如果當歌曲清單超出 500 條,在播放該清單的時候就需要等待拉取全部的歌曲清單(存在接口請求耗時)
  • 播放資訊(State)更新導緻視圖渲染阻塞起播流程,在播放一首新的歌曲時會先更新目前播放的基礎資訊,如歌曲名稱、歌手和封面等,然後再交給播放器去加載歌曲播放資源和起播,但是因為播放的基礎資訊更新會導緻訂閱者視圖(播放條、歌曲清單等)的重新渲染,産生阻塞播放器播放任務的執行的問題(等待前者執行,起播時間延後)

通過對比新舊版本的播放起播的耗時,以歌單歌曲 1000 首為例,起播耗時在 4410 ms 左右,舊版本在 1733ms 左右,2 者存在較為明顯的差距,同時線上也收集到大量相關的輿情回報。是以,優化播放起播耗時也成為當時所迫在眉急的事情。下面也将分别會針對上述 2 個導緻起播耗時的原因,介紹各自存在的問題和如何應對優化。

1.1 接口預加載

首先,是歌曲清單接口分頁請求耗時(擷取完整的歌曲清單)。在前面的小節中介紹到歌單頁的清單實作是基于虛拟清單實作的無限滾動清單,是以預設進入歌單頁隻會拉取第一頁(500 首)的歌曲清單資料。但是,站在播放的角度,在歌單場景播放預設情況下是播放該歌單下的全部歌曲,是以此時就需要按照歌單清單分頁總數來分批次請求接口,用于擷取歌單下的全部歌曲給到播放流程,而請求分頁接口會存在等待服務端接口響應耗時:

雲音樂桌面端 3.0 改版前端性能優化之旅

由于通常使用者進入歌單場景到開始播放歌單之間會存在一定空閑的時間,那麼,在這個空閑的是時間内,則可以陸續按照清單分頁總數來分批次預加載該接口,避免請求接口的耗時發生在使用者在播放的過程中:

雲音樂桌面端 3.0 改版前端性能優化之旅

1.2 渲染調優:re-render優化群組件複雜度降低

然後,是播放資訊(State)更新導緻視圖渲染阻塞起播流程。在初始化播放 State 時,訂閱播放 State 的元件則會開始渲染,如 Render 播放條(Minibar)、歌單清單項:

雲音樂桌面端 3.0 改版前端性能優化之旅

并且,到這個階段播放的起播流程還未結束,如請求播放歌曲資訊、開始播放階段。大家都知道的是在浏覽器中,JavaScript 代碼的解析執行和渲染流水線同屬于宏任務,在一次浏覽器事件循環(Event Loop)中宏任務是按照進隊順序依次執行的。

是以,播放狀态改變導緻的渲染行為則會導緻後續的請求播放歌曲資訊和開始播放階段等待前者渲染結束。如果,此時渲染行為所需要的耗時越長則會導緻後續起播的階段等待的時間越長,是以需要對這部分視圖關聯的元件做渲染調優處理(降低前者等待的時間)。

首先是歌單清單項的渲染調優。在清單元件中類似于表格的概念,每個清單項(表格列)都是由多個 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 透傳,以及使用 memo 對 TableIndex 元件進行新舊 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 的數量是等于虛拟清單可視區域 + 緩沖區域的清單項總和:

雲音樂桌面端 3.0 改版前端性能優化之旅

是以,此時要降低 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 綁定指定的事件,如 onContextMenu、onClick:

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 事件調用:

雲音樂桌面端 3.0 改版前端性能優化之旅

例如,原先的 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 歌單清單卡頓

歌單作為雲音樂十分重要的分發場景,其中較為複雜的場景則是自建歌單,如我喜歡的音樂、建立的歌單,由于它們可收藏本地歌曲、下載下傳的歌曲、雲盤歌曲等,是以在歌單中的清單項的資料來源場景會多種多樣,與之而來清單項的實作也就相對複雜。

雲音樂桌面端 3.0 改版前端性能優化之旅

在我們應用中,所有類型的清單(歌曲、雲盤歌曲、下載下傳歌曲、本地歌曲、專輯歌曲、搜尋歌曲等)都視為一種業務場景表格元件,而所有的業務場景表格則是由自定義的表格每行的列元件 Cell 和整體的 TableViewer、TableViewerMain 元件構成,它們之間的渲染關系:

雲音樂桌面端 3.0 改版前端性能優化之旅

可以看到除了渲染展示清單,TableViewer 和 TableViewerMain 元件還實作了以下的功能:

  • 表格排序,基于表格 Cell 給定的列字段進行升降序排序
  • 播放中歌曲滾動定位,基于滾動容器 scroller 實作的滾動清單到目前播放的歌曲
  • 拖放容器,基于 react-dnd 實作的可被拖放的容器,用于清單拖動排序或者其他歌曲拖動收藏
  • 虛拟清單,基于滾動容器 scroller 實作的動态計算清單項 position 位置
  • 分頁加載和搜尋,在虛拟清單實作的基礎上自動管理分頁加載和搜尋

那麼,導緻清單滾動卡頓的問題是什麼?相信有同學已經發現職責不單一,從 TableViewer 和 TableViewerMain 的實作上可以發現各自的實作沒有明顯的邊界,與之而來的産生了以下 3 個問題:

  • 拖放容器和拖拽,耦合 ActionProvider(會有明顯運作時性能開銷),其實作是基于在 ActionProvider 在 react-dnd 的封裝基礎上
function Demo() {
    return (
        <ActionProvider
            data={dropConfig?.data}
            drop={dropConfig ? {
                ...dropConfig.drop,
            } : undefined}>
            // ...
         </ActionProvider>
    )
}
           
  • 虛拟清單,首先虛拟清單實作在 TableViewerMain 中 re-render 的範圍太大,導緻 re-render 的成本是非常昂貴的,其次虛拟清單的實作是從零實作沒有經過很成熟的打磨會有很多生産模式下的問題,例如快速滾動白屏、不支援快速滾動骨架屏等
  • 播放中歌曲滾動定位,實作在 TableViewer 中 re-render(每次滾動)的範圍太大,導緻 re-render 的成本是非常昂貴的

在綜合這 3 個問題的影響下,最初我們在歌單清單場景的滾動存在較為明顯的卡頓問題,同樣是功能體驗主觀評測打分,清單滾動的得分是 2.2 分(卡頓),舊版本的得分在 4.5 分:

雲音樂桌面端 3.0 改版前端性能優化之旅

針對第一個問題拖放容器和拖拽耦合 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 元件:

  • 基于 react-virtualized 封裝 VirtualizedList 元件實作了如下的能力:
    • Window Scroller,通過将 document.scrollingElement 或者 document.documentElement 作為 Scoller,實作視窗滾動的效果,例如歌單頁、播單頁等
    • 滾動占位,用于在使用者快速滾動情況下的渲染占位的骨架屏元素,其中骨架屏基于 react-content-loader 實作,可自定義不同場景的樣式,其中由于 react-content-loader 預設的掃光動畫是有 CPU 開銷,考慮到性能是以預設關閉掃光動畫
    • 滾動定位,基于 Scroller 的 offsetHeight、scrollTop 和清單項的高度 rowHeight 實作滾動至指定索引的清單項定位(在使用 WindowScroller、List 的情況下,List 提供的 scrollToIndex 無法正常工作)
  • 删除 TableViewerMain 元件,将其内部實作移至 TableViewer,非必要的元件層級,簡化元件樹
  • re-render 最小元件機關原則,從 TableViewer 元件中剝離歌曲播放中定位元件,減少 re-render 時的元件渲染成本
雲音樂桌面端 3.0 改版前端性能優化之旅

通過上述的優化手段的落地,主觀評測也從最初的 2.2 分提升到了 4 分接近于舊版本,相關的輿情回報也得到了對應的治理(相比優化前環比下降 68.22%):

雲音樂桌面端 3.0 改版前端性能優化之旅

在這裡可能有同學會有疑問:”為什麼不在原有手寫的虛拟清單實作上繼續優化修改?”。其實,不僅僅是今天本文中這個場景大家會有這種疑問,在平常的工作中相信也有可能遇到這種情況。對于前者手寫實作,我們可以歸為一類一般能力較強的同學,他們遇到這類場景會有從零開始實作的習慣,對于後者使用開源實作,我們可以歸為一類關注團隊維護成本、功能豐富程度的“拿來主義“的同學。

顯然,我們選擇的是後者,因為通過對比社群實作的各類虛拟清單,我們選擇了其中更為穩定、功能更為強大的 react-virtualized,一方面降低了維護成本(經過時間驗證),另一方面提供了諸多開箱即用的功能,減輕了相關業務功能傳遞的開發成本。

三、系統資源占用優化

3.1 CPU:動畫按需執行

說起 CPU 的資源占用,很多同學的第一反應可能是 JavaScript 代碼實作的不合理産生的長任務(或耗時)導緻的 CPU 的資源占用,這也是大部分應用 CPU 占用高的主要原因。但是,大家是否關注過在其他場景可能會導緻 CPU 占用高的情況?例如 CSS 實作的動畫産生的 CPU 占用。

在 3.0 中新增了很多動畫,通過工具監控(系統任務管理器、Devtools 的性能監控器等)得出在開啟動畫的情況下,CPU 占用會增加 6% 左右,而這些動畫大多都是基于 CSS keyframes 實作,例如底部播放條的黑膠轉盤:

雲音樂桌面端 3.0 改版前端性能優化之旅

其對應的 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 判斷應用視窗狀态是否在背景來決定暫停動畫,其整體的工作流程:

雲音樂桌面端 3.0 改版前端性能優化之旅

最終,通過根據應用前背景的狀态合理切換動畫暫停和執行,我們應用在前台播放 CPU 的占用在 7% 左右,背景播放 CPU 占用在 0.74% 左右,避免了在背景情況下非必要的資源占用。

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

除了上一小節提到的大量引入動畫以及無節制地使用 GPU 加速會導緻 GPU 占用高之外,在我們的排查實踐中,發現錯誤地使用全局 CSS 屬性和視口外 DOM 元素未及時清理是另外兩個引起 GPU 高占用的主要因素。

backdrop-filter 是一個十分強大的 CSS 屬性,其可以通過不同的 filter 函數實作在層疊上下文中對層級在指定 DOM 元素之下的視覺内容進行高斯模糊、灰階、對比度、飽和度等樣式調整。而在 3.0 的雲音樂中,全局應用了其提供的 2 個函數:grayscale 和 blur。其中,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.0 改版前端性能優化之旅

此時,有同學可能會想到,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 樹,隻要有任意一個節點沒有被正确釋放引用,其自身以及所有子孫元素在兩棵樹上的對象都無法被垃圾回收。

雲音樂桌面端 3.0 改版前端性能優化之旅

通過浏覽器的 Devtools 工具,我們可以按照下面的流程逐漸排查和定位可能的記憶體洩漏問題:

3.3.1 Performance Monitor 定性

Performance Monitor 能夠在較小的性能代價下展示出網站應用的若幹個影響性能和體驗的關鍵參數随着時間變化(使用者操作)的趨勢。針對記憶體洩漏問題,我們重點關注 JavaScript 堆大小和 DOM 節點數的變化趨勢,并根據以下原則對記憶體洩漏進行初步的定性判斷:

  • 其中任何一個出現隻增不減的趨勢,則可以定性判斷存在記憶體洩漏問題
  • 如果 JavaScript 堆大小隻增不減,而 DOM 節點數趨勢平穩,則可以定性隻在 JavaScript 上下文中出現了記憶體洩漏
  • DOM 節點數隻增不減往往會伴随着 JavaScript 堆大小的隻增不減。此時需要關注二者增加的趨勢是否同比(增長速度一緻)同頻(增長時機一緻)
    • 如果同比同頻,可以定性隻有 DOM 元素解除安裝未清理引用引發的記憶體洩漏,JavaScript 堆大小的變化隻是伴生現象
    • JavaScript 堆大小增長趨勢更加陡峭,可以定性同時存在兩個記憶體洩漏源頭

而在我們的應用中,二者的變化趨勢滿足同比同頻,是以可以确定是對 DOM 元素的引用沒有清理導緻的記憶體洩露問題。

雲音樂桌面端 3.0 改版前端性能優化之旅

3.3.2 Detached Elements 定位

Detached Elements 的功能非常明确,即幫我們找到所有沒有挂載在 DOM 樹上,同時還沒有被浏覽器引擎垃圾回收的 DOM 元素。但是,因為浏覽器的垃圾回收本身就是周期性的行為,是以在進行問題排查前,必須手動觸發一次垃圾回收行為,保證剩下的就是要排查分析的目标元素。

3.3.3 Memory 分析

Memory 能夠建立目前應用的 JavaScript 堆快照,用于進一步分析頁面的 JavaScript 對象以及互相之間的引用關系。在我們已經定位了洩漏源的基礎上,可以借助該工具查明目标 DOM 被什麼 JavaScript 對象持有了引用導緻無法被垃圾回收。

而讀懂快照的重點在于 Distance 屬性,在官方文檔中,對 Distance 列的解釋是 'displays the distance to the root using the shortest simple path of nodes'。

雲音樂桌面端 3.0 改版前端性能優化之旅

基于這裡的快照,我們可以發現發生洩漏的 DOM 元素的 distance 是 7,點選之後可以反向追溯其到 Root(浏覽器環境下為 window 對象)的完整路徑。當然,持有該 DOM 元素的路徑通常不止一條,我們隻需要關注最短的那條即可。基于此,我們可以建構出其對象持有路徑。

雲音樂桌面端 3.0 改版前端性能優化之旅

在分析了多個發生洩漏的 DOM 元素之後,我們最終定位到虛拟清單的父節點的 NE_DAWN_CHILDREN 屬性持有了已經被解除安裝的 DOM 的引用,導緻使用者隻要停留在歌單頁面,那麼滾動越多記憶體洩漏得越多。經過内部排查,發現 NE_DAWN_CHILDREN 屬性是由埋點 SDK 管理的,其通過 MutationObserver 監聽 DOM 元素的挂載并進行記錄儲存,用于在 DOM 曝光時上報節點路徑。但是在 DOM 元素解除安裝時沒有及時地清除相關引用,引發了本次全局性的記憶體洩漏。

相應地,在處理了埋點 SDK 未及時清除引用的問題後,相比較 3.0 未優化的版本取得了較大的優化效果,對比舊版本在清單各種操作情況下的記憶體占用也基本對齊,同時,輿情平台上相關客訴也得以大幅減少。

雲音樂桌面端 3.0 改版前端性能優化之旅

四、Future:後續優化思考(計劃)

誠然,通過對上述性能問題進行優化後取得到了顯著的優化結果,但是,仍然需要進一步思考是否還有持續優化的空間,是以,下面彙總了 4 個後續我們在關于性能優化相關的思考:

雲音樂桌面端 3.0 改版前端性能優化之旅
  • 性能監控(防劣化),一方面對于核心業務頁面增加 web-vitals 相關的名額監控,保證核心場景功能體驗的穩定性。另一方面,對于播放過程增加監控,抽象播放過程關鍵名額(起播耗時、健康度),保證播放功能的穩定性。
  • 自繪 UI,Hybrid APP 架構雖然具備較高的研發效率,但是對比原生 UI 在體驗上限方面是偏低的,是以需要通過自繪渲染引擎(如 Flutter) + DSL(Domain-specific language) 的方案來達到兼顧研發效率和體驗上限高(提供和原生應用一緻的互動體驗)的結果
  • CEF 容器常态化更新,目前使用 CEF 的 Chromium(删減版)版本為 91,版本較為落後,通過保持 CEF 的常态化更新逐漸對齊 Chromium 穩定版本來提升容器在渲染流水線、JavaScript 代碼解析編譯、記憶體配置設定等方面的性能
  • 播放流程重新編排,通過對播放流程的重新梳理和優化,如異步化耗時任務(播放清單構造)、延遲更新播放狀态等,達成降低播放起播的耗時的結果

作者:吳敬昌 蔣濤

來源-微信公衆号:網易雲音樂技術團隊

出處:https://mp.weixin.qq.com/s/WAkPVXI-Q0GgoZokMPz6jA

繼續閱讀