互動遊戲團隊如何將效能體驗最佳化做到TOP級別

發表於2024-02-29

一、背景

隨著互動遊戲業務 DAU 量級增加,效能和體驗重要性也越發重要,好的效能和體驗不僅可以增加使用者使用體感,也可以增加使用者對於互動遊戲的使用粘性。

對現狀分析,主要存在首屏渲染速度慢、開啟頁面存在白屏、頁面載入過多資源等問題,核心手段是增加骨架、介面優先順序調整、預渲染、減小包體積等。

最佳化後,互動遊戲簽到功能做到同類業務效能體驗 Top 級別,下面是最佳化後資料:

  • 首屏渲染速度:最佳化後提升首屏渲染速度 39%
  • 首屏骨架:骨架體積大小減少 44%(壓縮後減少 50%)。
  • 首次載入總資源:資源總體積最佳化後,大小減少 69%

二、骨架

骨架屏是指在頁面載入時,臨時顯示出頁面的主要結構,可以讓使用者在等待頁面載入完成時,得到視覺上的反饋,提升頁面的使用者體驗。
圖片
骨架示意圖vs資料渲染
圖片

圖片
可以看出在介面返回資料之前,可以先使用骨架得到一些介面反饋。

三、快取

雖然骨架屏可以讓使用者在視覺上得到反饋,畢竟不是真實的資料,總體還是有一些簡陋,使用者也可能並不知道這塊區域實際渲染的是什麼樣的內容,若是網路環境不好,很可能會長時間的停留在骨架屏階段,為了增強一些體感,使用快取進一步對頁面進行最佳化。
圖片
使用快取渲染具備以下優勢:

  • 與骨架屏相比,快取渲染十分接近使用者最終所見,因為每次介面返回資料都會更新快取,使用者再次進入時看到的都是自己上次進入時的資料。
  • 當使用者處在弱網或者斷網等不可抗力的環境中時,可以得到較為完整的頁面資料展示,可以很好減弱使用者環境帶來的網路營銷。

使用快取注意事項:

  • 一些快取渲染應遮蔽事件響應,避免造成不必要的報錯和客訴。比如商品的快取渲染,由於商品存在下架、優惠券調整等情況,快取的資料和實際資料會存在一定的偏差。
  • 快取渲染邏輯需要更加前置,不應該將快取渲染的邏輯放在原本的位置,這樣會拖慢渲染的時機。

四、介面後置

瀏覽器對同一時間內的請求數量是有限制的,既併發請求限制。當一個頁面首次渲染時需要瀏覽器發起很多介面請求,用於填充頁面渲染需要的資料,若是對於頁面渲染時的請求數量不加以控制,便可能導致一些問題出現。

現在有 home 和 info 兩個介面,home 介面返回的資料是首屏渲染需要依賴的,info 介面返回的資料則不是首屏必須依賴的。假設現在還有一些其他請求佔據了併發請求限制的數量,導致 home 介面請求變慢。
圖片
若是 info 介面響應慢,長時間佔據這瀏覽器的請求程式,會導致頁面首屏渲染速度更慢,那麼就需要有個一套方案可以根據介面的優先順序進行載入順序控制,可以將順序變為如下。
圖片
方案: 當頁面載入完成後一定時間後,進行低優先順序介面的請求,或者觸發頁面的滾動、點選等時立即進行介面請求。

此方案適用於:確定介面延遲載入並不會阻塞使用者的互動和操作。

將其封裝為一個 hooks,便於複用,直接先看程式碼再解釋:

import { useRM, createRM } from 'xxx'

const listen = (type: string, listener: () => void) => {
  const l = () => {
    listener()
    document.removeEventListener(type, l)
  }
  document.addEventListener(type, l)
}

const pageFlowModule = createRM(
  {
    assemble(state) {
      const reactionObserver = () => {
        state.isUserReactioned = true
      }

      ;['scroll', 'mousedown', 'touchstart'].forEach((type) => {
        listen(type, reactionObserver)
      })
      setTimeout(reactionObserver, 4000)
    },
  },
  { isUserReactioned: false },
)

pageFlowModule.actions.assemble()

export const usePageFlow = () => {
  const [state] = useRM(pageFlowModule)
  return state
}

使用:

import { usePageFlow } from 'xxx'

const Demo = () => {
    const { isUserReactioned } = usePageFlow()

    const fetchHanlder = useCallback(() => {
        // 介面請求資料
    }, [])

    useEffect(() => {
        if(isUserReactioned) {
            fetchHanlder()
        }
    }, [isUserReactioned, fetchHanlder])

    return <div>{/* 渲染介面返回的資料 */}</div>
}

從上面程式碼可以看到,會將一些非首屏需要的請求後置,後置的介面可以在頁面載入完成 4s 後自動觸發呼叫,也會在使用者有觸屏、滾動頁面等行為的時觸發介面的呼叫。

五、骨架最佳化

簽到和許願樹目前主文件中除了骨架部分還包含了一些公共的 JS 和 CSS,對不同資源型別進行拆分、彙總後發現,不管是簽到還是許願樹,實際包含 HTML + JS 部分僅佔極小比例,大量的流量消耗在了 CSS 上。

對 HTML 中 CSS 部分再進行梳理發現,檔案中包含的除了骨架的 CSS 部分和公共元件庫的 CSS 部分之外,還包含了大量彈框的 CSS。這三類中,骨架的 CSS 要保留,公共元件庫的 CSS 可以拆分但是難度較大,剩下的就是彈框或者非骨架部分的 CSS。

  • 需要把彈框部分元件做非同步載入,保證預渲染的時候這部分 CSS 檔案不會被載入到。
  • 拆分骨架元件,把骨架元件從業務元件中剝離,預渲染的時候只渲染和載入骨架部分,不載入其餘主檔案部分 CSS,進一步縮小骨架。
    圖片

六、localStorage效能問題

在做最佳化之前,並未意識到 localStorage 所隱藏的效能問題,業務中使用了大量的本地儲存,使用 Performance 記錄一下儲存消耗的時間。

記錄核心程式碼:

export const setMallFlowStoreData = (data: any) => {
  performance.mark('start_localstorage_operation')
  // localStorage 操作.....
  performance.mark('end_localstorage_operation')

  performance.measure('localstorage_operation_duration', 'start_localstorage_operation', 'end_localstorage_operation')
}

輸出記錄的時間:

const entries = performance.getEntriesByName('localstorage_operation_duration')
const TOTAL_TIME = entries.reduce((current, next) => {
return current + next?.duration
}, 0)

console.log('全部記錄:', entries, '共耗時:', TOTAL_TIME)

輸出結果:

可以看到透過 localStorage 進行一次儲存操作,大致需要耗時 0.2-0.5ms之間,若是當頁面存在大量的前端的儲存操作時,低端機型在儲存操作上消耗甚至達到 10-20ms,若是程式碼寫的不合理,導致頁面 reload、反覆觸發獲取操作等情況,這個時間又將會成倍的增加。

接下來先一起看看為何會存在效能方面的問題和解決方案。

儲存資料

問題:

localStorage 的儲存是同步的操作,因此在儲存大量資料時,可能會導致阻塞 UI 執行緒,影響使用者體驗。

方案:

核心思路便是將同步操作轉換為非同步操作,這樣就不會阻塞 UI 執行緒。

使用 Web Worker ,會增加一些專案維護的複雜度,且其是 HTML5 標準中新增的技術,存在一定的相容性(ChatGPT 給的,應該是錯誤答案,並未在 MDN 中看到)。
圖片

使用 setTimeout、setInterval,相容性絕對的好,但是並未從根本解決問題。

不用 localStorage,直接上 IndexDB,但是由於程式碼專案原因,不能改動原有的太多邏輯。

綜合解決方案和歷史原因,只能退而求其次選擇 setTimeout 的方式解決這個問題。

讀取資料

問題:

每次讀取 localStorage 資料時,都需要從磁碟中讀取資料,因此在處理大量資料時,可能會出現效能問題。

方案:

可以將資料進行放到記憶體中快取處理,在使用者的整個操作週期內只從 localStorage 獲取一次資料,需要注意的是每次對資料進行操作時,需要將 localStorage 和記憶體快取的資料同步更新。

資料型別轉換

問題:

在儲存和讀取資料時,需要將資料進行序列化和反序列化操作。這些操作可能會導致效能問題。

方案:

使用 JSON.stringify() 和 JSON.parse() 函式來處理資料的序列化和反序列化。

經過對 localStorage 儲存最佳化以後,在紅米 note 11 上面進行了簡單測試,首屏開啟速度提升,對於整體提升首屏提升約 2%

七、動效執行時機

頁面存在漸入漸現的動效,在頁面首次載入時,由於漸現動效的存在,會延遲使用者感知該模組,從而導致感覺頁面存在更多時間的白屏,動效如下:
 
圖片

核心問題是首次渲染直出 DOM 結構,不走漸現動效便可,這個比較偏向於邏輯處理,屬於體驗最佳化的範疇,主打的就是在後續有相關首屏動效時,有意識對其做一下處理,保證首屏首次渲染的完整度。

八、渲染模組的取捨

首先看一下兩種狀態各自的樣式:未簽到 VS 已簽到。
圖片
簽到業務的日曆會根據使用者當天簽到狀態進行渲染,存在已簽到和未簽到兩種渲染邏輯,由於當前的架構限制,並不能在預渲染時感使用者的簽到狀態,導致日曆部分的渲染會滯後,嚴重影響頁面的首屏渲染速度。

第一版本最佳化

將簽到狀態進行快取,當使用者進入簽到時的大致流程如下:
圖片
當使用者進入頁面時,會優先獲取快取中的資料進行渲染,確保使用者可以第一時間看到日曆部分的渲染,這裡需要注意:1. 快取需要結合使用者 token 一起判斷,避免造成切換賬號時造成資料汙染。2. 若是使用者第一次進入或者當天未簽到,會使用系統時間作為小日曆上的數字展示,當使用者修改了系統時間設定時,日期判斷會存在誤差。

快取資料必然會先於介面響應資料,因此頁面第一時間看到的肯定是快取資料(沒有快取資料,會預設使用未簽到資料)所渲染的頁面,那麼當介面響應完成時,需要使用真實的資料觸發頁面的 rerender,需要注意處理,避免造成頁面閃爍。

雖然這樣做可以提高頁面的渲染體感,當進入頁面時,頂部區域還是會存在一定時間的空白,畢竟還是需要執行 JS 後才能執行骨架渲染邏輯,本質提升速度為:介面響應時間 - JS 執行時間,在低端機表現會較為好一些,高階機體感並非太明顯。

第二版最佳化

日曆部分由於已簽到和未簽到的樣式存在著較大的出入,不能像某些競品一樣:已籤、未籤的整體頁面佈局並未有區分,使用一套公用的渲染邏輯,這樣也導致簽到業務需要將渲染日曆部分的動作滯後,那麼核心就是怎麼解決這個問題。

綜合考慮後,決定將未簽到樣式作為預渲染時直接生成 DOM,這樣可以保證使用者未簽到的狀態下進入到頁面可以第一時間對的狀態,也可以更快的完成首屏的渲染。

若是使用者已簽到,便在此基礎之上覆用今日簽到的邏輯,就是會在簽到完成後展示一個小的動效,將小日曆變成大日曆的樣式。這樣做的好處可以是獲取到使用者真實狀態後,自動切換到大日曆狀態,效果如下。
圖片

結合使用者行為分析:多數使用者一天不會多次訪問,也就是在即不怎麼犧牲高頻率訪問使用者的體驗之下,提高了絕大多數使用者的體驗。

九、首屏資料優先請求

前置小知識:最大併發請求數

為了避免瀏覽器過度佔用系統資源,瀏覽器對於同一域名下的請求數量是有一定限制的,也就是常見的瀏覽器最大請求數量。

以 Chrome 瀏覽器舉例:同一域名下,HTTP 協議最多允許同時存在 6 個 TCP 連線進行,HTTPS 協議最多為 4 個。

業務現狀

簽到進入頁面共計載入許多介面。

其中首屏渲染需要的幾個核心介面如圖紅色標記所示,核心的介面滯後會導致頁面資料渲染的更慢,嚴重影響體驗,那麼到底影響多少呢?可以在瀏覽器 Network 中檢視 Waterfall。
圖片
核心介面是在其他完成後開始,是因為其沒有趕上瀏覽器第一批次介面請求佇列中,需要等待前面某些介面結束後,才會將其放到請求佇列中。

動作

有了問題,接下來便是如何做:

  • 首先是制定方案,如何確保介面的請求可以搭上瀏覽器請求佇列的第一班車,本質是將之前散落在各個元件內的 useEffect 中的初始化邏輯進行提取,統一觸發。
  • 梳理介面和首屏渲染的關聯度,確定哪些介面的優先順序權重更高。

核心程式碼如下:

export const StartModule = createRM(
  {
    init() {
      SigninTopModule?.actions?.getHomeData()
      AdModule?.actions?.reqAdInfoList()
      HomeModule?.actions?.getBubbleList()
    },
  }
)

在頁面初始化時執行 StartModule?.actions?.init(),將核心介面最佳化執行,透過控制介面請求順序,簽到業務在此提升了大致 6-8% 的首屏渲染速度。

十、字型使用和最佳化

字型載入和最佳化是前端開發中的一個重要問題,特別是在移動端和低網路狀況下。下面是一些字型載入和最佳化的技巧。

FOUT問題

透過設定 Font-Display 屬性可以控制字型載入時的顯示效果,包括 Auto、Swap、Block、FallBack 和 Optional 幾種模式,可以減少字型載入時間和防止文字閃爍。設定屬性為FallBack時效果: 
圖片

可以看到日期存在明顯的 FOUT(無樣式文字閃現)問題,設定 Swap 也是類似效果,並不符合預期。設定屬性為 Block 時效果: 
圖片

可以看到第一時間並沒有渲染日期,而是有點的短暫空白,因為其可以避免 FOUT,字型檔案必須在後臺下載完全後,文字才能顯示。

最終選擇了 font-display: block;效果會更好一些。

注意,並不是整個頁面都使用 Block 屬性,對於一些非首屏關鍵渲染的樣式,使用 fallback 更為合適一些,因為其會使用瀏覽器預設字型,所以還是需要結合業務、場景合理使用。

字型庫大小,你得懂

先看一個 GPT 對於簽到業務常用字型庫打下的統計:

DIN Condensed 字型庫的大小在幾百KB 到幾MB之間 Helvetica Neue 字型庫的大小在幾MB到十幾MB之間

也就是這兩種字型的大小,如果不加以處理,全部載入的大小在幾 MB 到十幾 MB 之間,對於前端專案而言,這是挺誇張的一件事。

可以和設計人員溝通,將字型庫中常用的字型匯出,前端專案僅僅引入需要的字型就好,比如 DIN Condensed 字型都是使用在阿拉伯數字上,並不會在其他字上使用,那麼只需要將阿拉伯數字匯出即可。比如漢字,根據《現代漢語通用字表》(GB/T 13000-2018),常用漢字(包括簡體字和繁體字)共計 3500 個,其中常用的一般是指前 1000 個左右的漢字,那麼在使用字型庫的時候,是不是可以預設只需要匯出部分即可。

經過處理後的字型庫大小如下圖:
圖片

字型庫數量,你得控制

上面說了一個字型庫的大小是多大,就算是經過處理,最少也會有 30KB 大小,所以專案引入的字型種類是需要控制的,不能設計同學使用了多少種類字型設計,我們就要照單全收。

當設計同學新增字型庫時,如果字型使用在 3 次以內,是不是可以使用圖片來代替文字,或者使用現有的字型庫來平替。

十一、慎用三方庫

業務中存在一些簡單的校驗、轉換和動效並不需要引入三方庫,尤其是因為一個較為簡單的功能引入了一個較為大且冷門的庫時,不僅會增加專案的打包體積,還會增加專案後續維護的溝通、學習成本。例如下面一個簡單切換動效: 
圖片

是一個比較常規的切換動效,卻在專案中引入了一個第三方庫來實現,該庫的使用也是有一些學習成本,因為其具備實現比較複雜的動效能力,在業務動效具備一定複雜度且非首屏的場景下,是可以考慮引入使用的,否則類似這種首屏便需要載入的動效,還是慎重。

上述的切換動效 CSS 實現程式碼如下:

@keyframes bigScale {
  0% {
    opacity: 0;
    transform: scale(0.95);
  }

  to {
    transform: scale(1);
    opacity: 1;
  }
}

@keyframes smallScale {
  0% {
    transform: scale(1);
    opacity: 1;
  }

  to {
    transform: scale(0.95);
    opacity: 0;
  }
}

.squareInCenter {
  animation: 0.3s linear 0s 1 normal forwards running bigScale;
}

.squareOutCenter {
  animation: 0.3s linear 0s 1 normal forwards running smallScale;
}

在業務開發的過程中,尤其是 C 端的頁面,在實現功能時對於引入額外的庫是一件需要十分謹慎的事情,在內部就看到不少專案在引入關於日期處理方面的庫時,DayJS、MomentJS 同時都會引用到專案中,B 端專案都不能忍,更何況 C 端專案。

十二、總結

本文僅僅介紹得物前端增長團隊在互動遊戲側一些體驗最佳化實踐心得,後續還在不斷迭代和最佳化,將實踐經驗應用擴大至多個業務中,將整個互動遊戲效能體驗最佳化至 TOP 級別。

*文/來駿

本文屬得物技術原創,更多精彩文章請看:得物技術官網

未經得物技術許可嚴禁轉載,否則依法追究法律責任!

相關文章