laitimes

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

author:Flash Gene
Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

background

The desktop version of Cloud Music was launched in May 2014, and the Hybrid APP architecture based on NEJ + CEF (Chromium Embedded Framework) has been used since its launch until this 3.0 revision. From 2021 to 2022, we also tried to add the React technology stack (referred to as dual stack) to the NEJ technology stack. However, since 99% of the APP's code is in NEJ, the follow-up implementation based on the React technology stack has made the implementation of data communication and event calling around the dual stack to ensure that the new business implementation can be implemented using React (high development efficiency and low development cost).

Although our new business requirements can be implemented based on React, we are still limited by the maintenance of core data modules on the NEJ side, and often we can only modify the NEJ implementation to complete business delivery (React is expensive to rewrite and cannot deliver business on time). On the other hand, in the 3.0 revision, we ushered in a new adjustment of the interaction and visual of the entire application, and the implementation cost of modifying the original NEJ implementation is very high, and the subsequent development iteration will face the problems mentioned earlier, so after comprehensive consideration, we chose to refactor the entire application based on React.

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

But refactoring a project with 40+ pages (hundreds of thousands of lines of code) is a huge challenge. At the same time, during our 3.0 internal testing process, we also collected a lot of enthusiastic user feedback about the use of the new version, among which the performance problems are particularly prominent, mainly focusing on performance-related problems such as page switching lag, scrolling white screen, and large memory usage. Therefore, we have also carried out special performance optimization governance for these performance problems, and the challenges we face in the process of performance optimization governance are mainly the following four aspects:

  • In order to unify the visual standards and improve the maintainability and extensibility of the UI layer, we built 30+ basic components and 100+ business components from scratch, of which the business components provide highly customized and reusable components in business scenarios. However, the complexity of some business components is very high, taking the playlist list as an example, it supports sorting, dragging, virtual list and scrolling positioning, etc., which is developed under the React framework, and the render and re-render performance of the component will become particularly important, because the cost of a render of complex components is very expensive, and if the render and re-render are not properly controlled, it will bring obvious lag to the user
  • Large amount of data in the distribution scenario (playlist): As the most important song distribution scenario of cloud music, the playlist needs to be displayed in the UI of the playlist list in the form of a virtual list due to the fact that the number of songs corresponding to it is often in the order of thousands
  • There are many functions and event types in the global dimension: there are 90+ global functions that can be triggered, and the event types are right-click, left-click, double-click, menu and keyboard, etc., on the basis of maintaining a unified event distribution center, we also provide a very lightweight UI declarative (configure Props through the ActionProvider component) + runtime registration event implementation, although the UI declarative reduces the development cost of event registration, but its strong dependence on the runtime of real registered events will be implemented with React The increasing level of the Component Tree has a very serious stuttering effect
  • High complexity of view subscription state: There are 50+ global data models (maintenance states), including playback, download, local music, user-related, playlist, application-related, configuration center, and ABTest. Standing in the dimension of views, it is often necessary to subscribe to the status under different models to complete the display of normal pages, among them, taking a song resource as an example, its corresponding view usually needs to subscribe to the status of download, collection, heart, playback, cloud disk, etc., which is very large. Therefore, it is important to manage the scope of the view of the subscribed data reasonably, because if the view contains components with non-related states, or if the related components are also very complex, the cost of re-render will become very expensive when the state changes, and it will also cause serious stuttering problems

Based on the problems caused by these four challenges, our performance optimization also focuses on the corresponding analysis and governance of problems such as long playback start-up, obvious interaction lag, and large system resource occupation. Next, this article will also introduce the response and thinking of specific performance problems from the perspective of actual business scenarios:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

1. Optimized the playback and playback time

As a music category software, the playback function is our most important function. Compared with the previous version, in 3.0, we have made more improvements and adjustments to the playback status of the product, such as the vinyl rotation of the playbar, the GIF and song title highlighting in the playlist list item, and the playlist and playlist status buttons in the playback of resource cards:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Normally, the user will enter the playlist page and click Play All, click the song or add all to the playlist to play the song in the playlist list, where the playback process (simplified version):

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Playback is a function that users must operate when using the app, and the playback-related experience is also what we focus on, and the most important of these is the short time it takes to start playing. However, at first, the start speed of our playback was not ideal, and the main reasons for the start time were the following two points:

  • However, because the virtual list is optimized for the list scenario, the interface is only requested once by default (interface pagination, length 500), which causes that if the song list exceeds 500, you need to wait to pull all the song lists when playing the list (there is a time consuming interface request)
  • When playing a new song, the basic information of the current playback, such as the song name, artist, and cover, will be updated first, and then handed over to the player to load the song playback resources and start playing, but because the basic information of the playback will be updated, the subscriber view (playbar, song list, etc.) will be re-rendered, resulting in the problem of blocking the execution of the player's playback task (waiting for the former to be executed, and the start time will be delayed)

By comparing the playback time of the old and new versions, taking 1000 songs as an example, the playback time is about 4410 ms, and the old version is about 1733 ms. Therefore, optimizing the time taken to start broadcasting has also become an urgent matter at that time. The following will also introduce the problems and how to deal with the optimization of the above two reasons that cause the start time of broadcasting.

1.1 Interface preloading

First of all, it is time-consuming to request pagination requests from the song list interface (to get the full list of songs). As introduced in the previous section, the list implementation of the playlist page is based on the infinite scrolling list implemented by the virtual list, so by default, entering the playlist page will only pull the song list data on the first page (500 songs). However, from the perspective of playback, the playlist scene plays all the songs under the playlist by default, so you need to request the API in batches according to the total number of pagination in the playlist list, which is used to obtain all the songs under the playlist and give it to the playback process, and the request pagination API will have a time to wait for the response of the server-side interface:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Since there is usually a certain amount of idle time between the time the user enters the playlist scene and the playlist starts, the API can be preloaded in batches according to the total number of pagination in the list during this idle time, so as to avoid the time-consuming request of the interface from occurring during the playback process:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

1.2 渲染调优:re-render优化和组件复杂度降低

Then, it's the playback information (State) update that causes the view rendering to block the start flow. When the playback state is initialized, the components that subscribe to the playback state will start rendering, such as the Render playbar (Minibar) and playlist list items:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

In addition, the playback process has not yet ended at this stage, such as requesting song information and starting the playback stage. As we all know, in the browser, the parse execution of JavaScript code and the rendering pipeline are both macro tasks, and in a browser event loop, macro tasks are executed in the order in which they enter the queue.

Therefore, the rendering behavior caused by the change of playback state will cause subsequent requests to play the song information and start the playback phase and wait for the former to finish rendering. If the longer the rendering behavior takes at this time, the longer the waiting time will be in the subsequent stages of playback, so you need to optimize the rendering of the components associated with this part of the view (reduce the waiting time of the former).

The first is the rendering tuning of playlist items. Similar to the concept of a table in the list component, each list item (table column) is composed of multiple cell components, and the components related to the subscription playback status in the playlist list are mainly the play button and song name:

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

For the IndexCell component, it is only a transparent transmission of parameters from the business scenario to the TableCell, such as the IndexCell component of the album's playback button:

const IndexCell: ICellRender<IBaseProps, IColumn, IAlbum> = (props) => {
    const { row } = props;

    const { index, data } = row;

    return (
        <TableIndex
            index={index}
            data={{
                resource: data,
                resourceType: ResourceType.album,
            }} />
    );
};
           

In the same way, the playback button component is also used in scenarios such as playlists, searches, and playlists, and only the parameters of business scenarios are transparently transmitted to the TableIndex component, and then the TableIndex subscribes to the playback state. Then, there are two problems with TableIndex:

  • The playback state subscription and processing of all business scenarios are fully maintained in the TableIndex component, because the code that is not in this scenario is mixed together, resulting in the cost of render and re-render is very expensive
  • The implementation of the component is more complex, and there are redundant CSS-in-JS (Linaria) components, because every styled.div is rendered by the React Component behind the use (the complexity of the component tree increases)

Uniformly encapsulated into TableIndex, while reusing components well, leads to higher costs for render and re-render, as the scenes are mixed with code that is not the same as the scene. Then, it is necessary to reasonably decouple the playback state subscription and processing of each business scenario into their respective IndeCell components, and then the TableIndex component only accepts isPlaying's props transparently, and uses memo to compare the new and old Props of the TableIndex component (to avoid redundant re-render):

import { isEqual } from 'lodash-es';

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

    return isDataEqual;
});
           

Secondly, TableIndex uses the styled.div provided by CSS-in-JS to implement dynamic CSS, which essentially creates a React Component at compile time to perform dynamic rendering based on Props, which will cause the component tree to become complex, increase the cost of rendering, and since the number of TableIndex in the list scene is equal to the sum of the list items in the virtual list viewport + buffer area:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Therefore, at this time, it is necessary to reduce the complexity of the UI implementation of TableIndex, and implement dynamic CSS by using the native HTML tag div, defining the CSS Variable in the inline style, and using the defined CSS Variable in the 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>
  )
}
           

This reduces the redundant rendering overhead of redundant React Components created with CSS-in-JS. Finally, under the optimization of the above two songs, the playback time of the previous data was reduced from 4410.67 ms to 2133.67 ms (48.37%) when the playlist was still 1000 songs.

2. Interaction Caton optimization

From the perspective of browser rendering, the web page we make will finally be drawn to the screen through the browser rendering pipeline, and then the refresh rate of the screen is usually 60 Hz, that is, it will be refreshed 60 times per second, so when the drawing speed is slower than the refresh of the screen, there will be a lag problem.

2.1 General interaction lag

UI declaration events turn JavaScript event calls

As mentioned earlier, we will use the ActionProvider to implement the events for the whole site, and in the normal business development, we only need to configure the Props of the ActionProvider, such as configuring the events of the playlist:

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>
    )
}           

In this way, the functions related to the playlist are completed, and the right-click menu is opened, and the subsequent operations will also carry the data here, for example, the right-click menu will consume the data of the playlist. Internally, the ActionProvider will bind the specified events to the div according to the configuration information of the props, such as onContextMenu and 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}
        </>
    )
}
           

As you can see from the example, using ActionProvider can be used to declaratively configure the UI to replace the complex event registration call process (simple, logical and uniformly maintained). Therefore, this is also widely used in our application, including playback, favorites, sharing, jumping, creating playlists, deleting songs, copying, reporting, desktop lyrics settings, downloading, Mini mode settings, cloud disks and other 90+ function-related action implementations.

While the design and implementation of ActionProvider makes it easy to register, implement, and maintain core events in an application, its unified implementation of UI declarative also introduces performance issues (lag):

  • Because it's a unified scheme, dependencies or props changes are too discrete, and there are a lot of re-renders
  • Using React.cloneElement to make a copy of a real component or component tree results in a significant CPU and memory drain at runtime

As a result, the severity of the performance problems caused by the ActionProvider is positively correlated with the number of uses and the complexity of the component tree. In addition, at that time, a total of 306 files and 674 uses were involved in the entire application, so this kind of performance problem led to the lag of the application in all scenarios, and the subjective evaluation score of the application function at that time (out of 5), the overall experience was 3.2 points (stuttering), and the old version was 4.2 points, which was less than ideal.

So, how do you solve this problem? Is it a break and a new one?

Obviously, this is not feasible, because breaking and restarting will inevitably lead to post-go-live functional stability issues, and the cost of starting over is very high. Going back to the implementation of ActionProvider, one is to automatically register events, and the other is to automatically distribute events, which is no longer reasonable for the first point, because the UI of each business scenario is uncontrollable, and it is impossible to reasonably control the re-render of components through a unified component. Therefore, it is necessary to implement an alternative to the previous self-registration of events, and implement it by components that need to bind events. Second, for the second point, auto-dispatch events can still be preserved, and the final solution is that we can declaratively configure the corresponding JavaScript event call from the UI:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

For example, the original ActionProvider used:

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

After switching to a click event JavaScript event call:

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>
    )
}
           

Then, in this way, the second point of the implementation of ActionProvider has been well retained, and the performance problems caused by the original UI declarative use have been solved, the overall functional experience of the application has also been greatly improved, and the subjective evaluation score of the overall experience has also been increased to 4.2 points, which is basically in line with the old version.

2.2 Playlist list stuck

As a very important distribution scenario for cloud music, playlists are more complex scenarios such as self-built playlists, such as my favorite music and created playlists, because they can collect local songs, downloaded songs, cloud disk songs, etc., so the data source scenarios of the list items in the playlist will be diverse, and the implementation of the list items will be relatively complex.

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

In our application, all types of lists (songs, cloud disk songs, downloaded songs, local songs, album songs, search songs, etc.) are regarded as a business scenario table component, and all business scenario tables are composed of the column component Cell of each row of the custom table and the overall TableViewer and TableViewerMain components, and the rendering relationship between them:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

In addition to rendering the display list, the TableViewer and TableViewerMain components also implement the following functions:

  • Table sorting, which is sorted in ascending and descending order based on the column fields given by the table cell
  • In-play song scrolling positioning, based on scrolling the scrolling list implemented by the scrolling container scroller to the currently playing song
  • Drag-and-drop containers, drag-and-drop containers based on react-dnd, used for drag-and-drop sorting of lists or drag-and-drop collections of other songs
  • Virtual list, based on the dynamic calculation of the position position of a list item implemented by the scrolling container scroller
  • Pagination loading and search, which automatically manages pagination loading and search on top of the virtual list implementation

So, what's the problem that causes the list to scroll and stutter? I believe that some students have found that there is no single responsibility, and from the implementation of TableViewer and TableViewerMain, it can be found that there is no obvious boundary between their respective implementations, which leads to the following three problems:

  • Drag-and-drop containers and drag-and-drop, coupled to the ActionProvider (which has a significant runtime performance overhead), are implemented on top of the ActionProvider's wrapper in react-dnd
function Demo() {
    return (
        <ActionProvider
            data={dropConfig?.data}
            drop={dropConfig ? {
                ...dropConfig.drop,
            } : undefined}>
            // ...
         </ActionProvider>
    )
}
           
  • Virtual lists, first of all, the scope of re-render is too large in TableViewerMain, resulting in the cost of re-render is very expensive, and secondly, the implementation of virtual lists is implemented from scratch without very mature polishing, and there will be many problems in production mode, such as fast scrolling white screen, not supporting fast scrolling skeleton screen, etc
  • The re-render is very expensive due to the fact that the re-render is too large in the TableViewer

Under the influence of these three problems, we initially had a more obvious lag problem in the scrolling of the playlist list scene, which was also scored by the subjective evaluation of the functional experience, and the score of the list scrolling was 2.2 points (stuck), and the score of the old version was 4.5 points:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

For the first problem, drag-and-drop containers and drag-and-drop coupling ActionProviders, this problem is not difficult to deal with, just find an alternative way to call JavaScript events, using drag-and-drop as an example:

const { drop } = dropConfig || {};

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

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

By unifying useDropAction to undertake the original configuration that was transparently transmitted to the ActionProvider, useDropAction is based on react-dnd and the context in which the list is located (since drag-and-drop ultimately requires the order in which the entire list is consumed), the same drag-and-drop implementation is the same.

Virtual List Refactoring: Better DX and UX

Then, in response to the large scope of the virtual list re-render and the immaturity of the scheme, we refactored the TableViewer component:

  • Based on react-virtualized, the VirtualizedList component implements the following capabilities:
    • Window Scroller,通过将 document.scrollingElement 或者 document.documentElement 作为 Scoller,实现窗口滚动的效果,例如歌单页、播单页等
    • Scroll placeholder is used to render the skeleton screen element of the placeholder in the case of fast scrolling by the user, where the skeleton screen is based on react-content-loader implementation, which can customize the style of different scenes, in which because the default sweep animation of react-content-loader has CPU overhead, considering the performance, the sweep animation is turned off by default
    • Scroll positioning, based on Scroller's offsetHeight, scrollTop, and rowHeight of list items, to implement the positioning of list items scrolling to the specified index (in the case of using WindowScroller, List, the scrollToIndex provided by List does not work properly)
  • Remove the TableViewerMain component, move its internal implementation to TableViewer, the non-essential component level, and simplify the component tree
  • The principle of re-render minimum component unit, which strips the TableViewer component from the TableViewer component and positions the component in song playback, reducing the component rendering cost during re-render
Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Through the implementation of the above-mentioned optimization methods, the subjective evaluation has also increased from the initial 2.2 points to 4 points, which is close to the old version, and the relevant public opinion feedback has also been governed accordingly (a decrease of 68.22% compared with the previous optimization):

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Some students may have questions here: "Why don't you continue to optimize and modify the original handwritten virtual list implementation?" ”。 In fact, not only in this scene in today's article, everyone will have this kind of question, but I believe that this situation may also be encountered in ordinary work. For the former handwriting implementation, we can be classified as a class of students with strong general ability, who will have the habit of starting from scratch when encountering such scenarios, and for the latter using open source implementation, we can be classified as a class of students who are concerned about the team's maintenance cost and feature richness.

Obviously, we chose the latter, because by comparing the various virtual lists implemented by the community, we chose react-virtualized, which is more stable and powerful, which reduces maintenance costs (proven over time) and provides a lot of out-of-the-box functionality to reduce the development cost of related business function delivery.

3. Optimize system resource occupation

3.1 CPU: Animations are executed on demand

When it comes to CPU resource usage, many students' first reaction may be that the CPU resource usage is caused by unreasonable long tasks (or time-consuming) generated by JavaScript code implementation, which is also the main reason for the high CPU usage of most applications. But have you ever been concerned about situations where CPU usage can be high in other scenarios? For example, the CPU footprint generated by animations implemented by CSS.

In 3.0, a lot of animations have been added, and the CPU usage will increase by about 6% when the animations are turned on, and most of these animations are based on CSS keyframes, such as the vinyl turntable at the bottom of the playbar:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Its corresponding CSS code implementation:

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

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

At this point, some students may say that using GPU acceleration to reduce CPU usage is indeed a solution, but it actually only shifts the resource usage, but does not eliminate the resource usage (causing the GPU usage to rise).

Since the use of CSS animations can cause CPU or GPU resource usage problems, you need to reduce or avoid the usage they incur, which can be achieved in the following two ways:

  • CSS animation is implemented through native component rendering, and the native animation implementation will be better than CSS animation, and the resource consumption is smaller, for example, by implementing a hybrid rendering architecture, and some UIs are implemented through native components (Native UI) or self-drawing engines (such as Flutter).
  • When the app switches to a background state, such as minimizing to the taskbar, system tray, mini player, etc., the execution of CSS animations is automatically paused to avoid the continuous occupation of related resources

Compared with the former, the implementation cost of the latter is lower, and we have also prioritized the implementation of related implementations. First, create a windowStateChange$ stream by listening to whether the state of the application window is in the foreground or background, and implement the useWindowShow hook based on windowStateChange$:

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;
           

Then, where CSS animations are used, use the useWindowShow hook to determine whether the app window state is in the background to pause the animation, and the overall workflow is as follows:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Finally, by reasonably switching the animation pause and execution according to the state of the application in the front and back end, the CPU usage of our application in the foreground is about 7%, and the CPU usage of background playback is about 0.74%, avoiding unnecessary resource occupation in the background.

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

In addition to the high GPU usage caused by the heavy introduction of animations and the unrestrained use of GPU acceleration mentioned in the previous section, we found that the incorrect use of global CSS properties and the lack of timely cleanup of DOM elements outside the viewport were two other major contributors to high GPU usage.

backdrop-filter is a very powerful CSS property that can be used to adjust the visual content below the specified DOM elements in the context of the cascading with Gaussian blur, grayscale, contrast, saturation, etc. In Cloud Music 3.0, the two functions it provides are applied globally: grayscale and blur. Among them, the root node of the grayscale application mounted on React is used to gray-out the page at the right time (Qingming Festival, etc.), otherwise it can be disabled by backdrop-filter: grayscale(0); Blur is then applied to the bottom playbar to improve the display of the playbar on different pages and enhance the user experience.

While applying the backdrop-filter property globally doesn't introduce a particularly large resource footprint, when there are a lot of animations on the page, the two can have a not very nice "chemistry": the backdrop-filter calculates the visual effects based on external elements as it is drawn, which is understandable in infrequent user scenarios, but automatic and constantly cyclical animations (such as the vinyl turntable at the bottom of the playbar) inevitably lead to the GPU Ongoing consumption of resources.

As a recognizable feature of cloud music, the rotating vinyl record naturally cannot be removed, so for this problem, we need to consider the backdrop-filter itself and the relationship between the two:

  • For the backdrop-filter itself, graying out is completely disabled at the root node via backdrop-filter: unset (grayscale(0) still has GPU usage); Disable Gaussian blur in the bottom playbar and replace it with a similar static color.
  • For the association between the two, the DOM structure of the bottom playback bar was adjusted, and the rotating vinyl record was removed from the Gaussian blur calculation range with a suitable synthesis layer optimization.

Considering the time cost of adjusting the DOM structure for optimization and the additional regression cost, we prioritized the former optimization method. The feasibility of the latter, as well as the advantages of taking into account both resource occupation and visual effects, will be the next stage of optimization.

Same as Cloud Music 2.0, Cloud Music 3.0 can evoke a separate vinyl playback page by clicking on the vinyl turntable at the bottom of the playbar, in addition to the regular routing page. The difference is that the comments on the vinyl play page are separated from the lyrics in this revision. In order to keep the user's transition between these three pages smooth and to be able to consume the content we have prepared immediately after the switch, such as reducing the loading time of resources such as images, we have made permanent processing on these pages: even if the user is browsing the regular routed page, the application has prepared a vinyl playback page and a comment area layout framework in the background, as well as most of the content that does not require network requests:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

At this point, some students may think that each of the 3 pages has its own DOM element, and even if the other 2 resident pages do not participate in the page display in the viewport, they will still participate in the page rendering in the form of cascading contexts. And due to the complexity of the page, too many DOM elements and cascading contexts can easily cause layer explosions. Similarly, with the participation of a large number of animations, the effects of the layer explosion are further amplified.

In response to this problem, we weighed the visible content of the resident page. Since the z-index of the vinyl page is higher than that of the regular routing page, the application hides the vinyl page by display: none when displaying the regular routing page, so as to prevent the vinyl page from participating in the browser rendering process while retaining the necessary React nodes and logic. When the application displays a vinyl page or a comment page, the other two pages are hidden by visibility: hidden, and the advantage of visibility over display is that the browser caches the layout information of the page, which can restore the page faster.

Finally, through the analysis and optimization of the above two problems, the GPU usage of the application during normal user operations was reduced from 33.10% to 5.39%.

3.3 Memory: Clear unnecessary references

At the beginning of the release of Cloud Music 3.0, there were a large number of customer complaints that the memory usage of the application continued to increase and did not decline, especially in the playlist browsing scene, and it was initially judged that a global memory leak problem occurred.

Considering that the increase in memory usage is especially obvious in scenarios such as playlists and private messages, the first thing that comes to mind is the memory leak problem of the JavaScript object not being garbage collected after the DOM element is unloaded. Because scrolling containers with a large number of list elements mostly use virtual lists to optimize scrolling and rendering performance, but virtual lists involve frequent additions and deletions of DOM elements, and if the corresponding JavaScript references are not completely cleaned up when DOM elements are deleted, the memory footprint will only increase, and ultimately affect the user experience.

In the React framework, in order to easily establish the association between DOM elements and FiberNodes, the DOM elements generated by the framework will hold references to their FiberNode objects, and the FiberNode also holds references to related DOM elements. As a result, whether it's the browser's DOM tree or React's Fiber tree, as long as any node is not properly released for reference, the objects on both trees and none of its descendants can be garbage collected.

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Using the browser's Devtools tool, we can troubleshoot and locate possible memory leaks step-by-step by following the following process:

3.3.1 Performance Monitor 定性

Performance Monitor shows the trend over time (user actions) of several key parameters that affect performance and experience of a website application at a small performance cost. In response to the memory leak problem, we focus on the trend of JavaScript heap size and the number of DOM nodes, and make a preliminary qualitative judgment on memory leak based on the following principles:

  • If any of them only increases, you can qualitatively determine that there is a memory leak problem
  • If the JavaScript heap size is only increasing and the number of DOM nodes is trending flat, you can qualitatively assume that only memory leaks are occurring in the JavaScript context
  • The number of DOM nodes is often accompanied by an increase in the size of the JavaScript heap. At this time, it is necessary to pay attention to whether the increasing trend of the two is year-on-year (the growth rate is consistent) and the same frequency (the growth timing is the same)
    • If it is at the same frequency year-on-year, it can be characterized that only the memory leak caused by the unloaded uncleaned reference of the DOM element is only a companion phenomenon
    • The JavaScript heap size is growing more steeply, and it can be characterized that there are two sources of memory leaks at the same time

In our application, the trend of the two is consistent with the same frequency, so we can be sure that the reference to the DOM element is not cleaned up and the memory leak is the problem.

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

3.3.2 Detached Elements 定位

The purpose of Detached Elements is very clear, which is to help us find all the DOM elements that are not mounted on the DOM tree and have not been garbage collected by the browser engine. However, because the browser's garbage collection itself is a periodic behavior, you must manually trigger the garbage collection behavior before troubleshooting, and ensure that the rest is to troubleshoot the target element of the analysis.

3.3.3 Memory 分析

Memory creates a heap snapshot of the JavaScript heap of the current application, which can be used to further analyze the JavaScript objects of the page and the references to each other. Now that we've located the source of the leak, we can use the tool to find out what JavaScript object the target DOM has a reference to and can't be garbage collected.

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

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

Based on the snapshot here, we can see that the leaked DOM element has a distance of 7, which can be clicked back to the full path of the root (window object in the browser environment). Of course, there is usually more than one path to hold that DOM element, and we only need to focus on the shortest one. Based on this, we can construct its object holding path.

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

After analyzing several leaked DOM elements, we were able to locate the NE_DAWN_CHILDREN property of the parent node of the virtual list that held a reference to the DOM that had been unloaded, resulting in the user staying on the playlist page, and the more you scroll, the more memory leaks. After internal investigation, it was found that the NE_DAWN_CHILDREN attribute is managed by the tracking SDK, which listens to the mount of DOM elements through MutationObserver and saves records, which is used to report the node path when the DOM is exposed. However, the DOM element was unloaded and the relevant references were not cleared in time, which caused this global memory leak.

Correspondingly, after dealing with the problem that the tracking SDK did not clear the references in time, compared with the unoptimized version of 3.0, a greater optimization effect has been achieved, and the memory occupation of the old version in various operation cases of the list is also basically aligned, and at the same time, the relevant customer complaints on the public opinion platform have also been greatly reduced.

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey

4. Future: Follow-up Optimization Thinking (Plan)

It is true that significant optimization results have been achieved by optimizing the above performance problems, but there is still room for further reflection on whether there is still room for continuous optimization, so the following is a summary of 4 follow-up thoughts on performance optimization:

Cloud Music Desktop 3.0 Revision Front-end Performance Optimization Journey
  • Performance monitoring (anti-deterioration): On the one hand, web-vitals-related metrics are monitored for core business pages to ensure the stability of the functional experience in core scenarios. On the other hand, the monitoring of the playback process is added, and the key indicators of the playback process (start-up time and health) are abstracted to ensure the stability of the playback function.
  • Although the Hybrid APP architecture has high R&D efficiency, it is lower than the native UI in terms of experience ceiling, so it is necessary to use a self-drawn rendering engine (such as Flutter) + DSL (Domain-specific language) solution to achieve the result of both R&D efficiency and high experience ceiling (providing a consistent interactive experience with the native application).
  • By keeping the regular update of CEF and gradually aligning with the stable version of Chromium to improve the performance of the container in terms of rendering pipeline, JavaScript code parsing and compilation, memory allocation, etc
  • The playback process is re-orchestrated and optimized to reduce the time-consuming playback time by reorganizing and optimizing the playback process, such as asynchronous time-consuming tasks (playlist construction) and delaying the update of playback status

Author: Wu Jingchang Jiang Tao

Source-WeChat public account: NetEase Cloud Music Technical Team

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

Read on