心遇APP站內玩法H5體驗最佳化實踐

發表於2024-02-12
本文作者:史志鵬

本文主要介紹心遇APP站內玩法H5的體驗最佳化實踐,主要包括離線包功能簡介、介面圖片預載入、榜單最佳化等具體場景內容。

1. 離線資源

在H5的開發過程中,儘管我們實踐了很多手段對H5進行效能提升,比如程式碼層面的 React 渲染最佳化,Web Vitals 體驗最佳化;打包構建層面的 Code Split & Bundle Analyze 載入最佳化;應用釋出層面的SSR、SSG、網路快取訪問最佳化等,我們不可否認這些最佳化手段的有效性和可行性,但是這些最佳化手段都無法以框架的形式沉澱下來,需要開發者根據已有的經驗和分析在程式碼編寫、構建打包、應用釋出等各個階段傾注額外的心力來進行效能最佳化提升工作,甚至有時候可能會弄巧成拙進行著“反向最佳化”。

常見的最佳化措施

對於心遇APP的社交活動玩法,一般來說和APP中的基礎性功能相比有明顯的不同:

  1. 它的玩法邏輯具有一定的系統性,是多個子功能系統的資料生產和消費過程,比如在我們《搶車位》的玩法中,涉及到的生產子功能有10多個:
    功能模組
  2. 玩法場景具有一定的複雜性,存在若干個遊戲場景,遊戲場景間存在關聯,在《搶車位》玩法中,有寶箱抽獎、停車、商城購車、碎片&皮膚合成等多個玩法場景,所有的玩法場景具有一定的資料關聯。
    車玩法
  3. 玩法的體驗也具有多樣性,比如混合常見的互動營銷互動、小遊戲場景等,例如在《搶車位》玩法中也有九宮格抽獎、停車位停車收車等遊戲化場景,在《打怪獸》玩法中,有怪獸擊打效果、對戰PK場景。影片不可見請點選 開寶箱效果打怪PK效果

<video controls autoplay="autoplay" src="https://d1.music.126.net/dmusic/575c/8717/741a/d1434dc6f1c069a7389ccf8608479810.mp4?infoId=1396584" width="375" height="600" ></video><video controls autoplay="autoplay" src="https://d1.music.126.net/dmusic/45a2/8fd0/6dcf/b050dcd011ca19920f5c5b4aa355d09b.mp4?infoId=1394586" width="375" height="600"></video>

因此這類業務的H5開發,不可避免的具有著靜態資源大、互動方式多的特點。此外,心遇APP的使用者量級與終端效能網路屬性,對H5的載入和互動體驗也有著一定的要求,這也決定了開發這類玩法H5需要提供很好的效能和互動體驗。

綜上,為了一勞永逸地解決前端資源載入的速度問題,我們和客戶端、部署平臺同事合作,共同推動了離線包功能的升級。

1.1 離線包拆包

上面說到,對於玩法類H5,靜態資源往往比較多,比如在我們的兩個玩法裡,圖片經過壓縮後,打包的總體積仍然會達到 10M 以上,由於離線包 diff 的版本可能有限,碰到客戶端快取的版本已經超過離線包 diff 版本限制時,則需要下載全量的離線包,這個全量包的流量不應該是使用者應該承擔的,所以我們選擇對離線包進行“拆包”,這個從功能上和小程式的分包一樣,在技術實現上“接近” Webpack 的 code split,即按照功能模組劃分,對重要的功能打包到主包進行優先載入,將不需要優先載入的子包(subpacks)按照一定的規則邏輯延後載入。

拆包實現的技術主要是:

  1. 首先需要對功能模組進行劃分,主要分為首屏、次屏。
  2. 按照首屏、次屏將檔案組織好,比如子包都在 subpacks 檔案目錄下。
  3. 使用 Webpack optimization 自定義分包能力,將 subpacks 下的檔案資源額外分包,形成獨立的 chunkFile,構建產物也放到 publicPath 的 subpacks 目錄下。
  4. 離線包釋出平臺提供主、子包打包、下發能力,同時提供後續釋出時的diff能力、下發能力。
  5. 客戶端根據一定策略,進行主、子包下載,同時提供 JSBridge 能力,交由前端進行子包下載。

1.2 Native開屏介面

有了離線包功能之後,儘管我們可以忽略網路載入的延遲,但是前端資源仍然需要具有客戶端攔截邏輯和磁碟載入帶來的延遲, Webview 容器首次載入仍然會有白屏或者 Loading UI 的可能。為了帶給使用者好的載入體驗,對於接入了離線包的 Web 應用都在客戶端 Webview 容器上新增了統一的開屏介面,開屏介面支援簡單的應用 UI 配置和顯示,開屏介面可以由前端在合適的時機控制其銷燬,比如在主介面的 DOM 渲染完成時,呼叫客戶端隱藏開屏介面能力,使用者即可以看到渲染好的 H5 介面,相比前端直接白屏和Loading,客戶端原生的開屏體驗更佳。

開屏介面和離線包功能繫結,具有應用層面的配置能力。

{
  "moduleName": "xx", // 應用名,用於離線包關聯
  "url": ".+/xx", // publicPath 用於客戶端資源匹配
  "resID": "xx", // 離線包檔案資源ID
  "resVersion": "1700720234678", // 構建版本,timestamp
  "loadingInfo": { // 開屏介面配置
    "loadingBgUrl": "https://xxx.png", // 應用 icon
    "loadingTextInterval": 1500, // 多個文案切換間隔
    "loadingText": ["xxxx"] // 文案
  },
  "packages": [{ //  // 子包資訊
        "moduleName": "subpacks-xxx",
        "resID": "xx",
        "strategy": "open_block|preload",
        "resVersion": "1700720234678"
    }],
  "versionControl": [ // 版本控制配置,主要是過濾條件
    {
      "belowVersion": "xx", // 指定版本以下
      "specificVersionList": [], // 特地版本
      "minVersionName": "1.0.0", // 最小版本
      "userNos": "xx" // 過濾userId
    }
  ]
}

1.3 離線包拆包載入流程

下面是客戶端同學設計的離線包拆包載入流程,可以看到主要是基於子包拆包後新增了子包載入的邏輯,以及在原來離線包的功能上調整了主包的載入邏輯,同時增加 Native 開屏邏輯:

2. 資料狀態管理與預請求

玩法類 H5,業務場景一般比較多。這裡的業務場景,在技術層面可以理解為一個個的頁面,也可以實現為一個個的全頁面元件。業務場景之間存在比較多的資料狀態同步,比如當前使用者資產、全域性性的邏輯資料等;除了比較多的資料狀態同步之外,還存在多個業務模組資料的串並行讀寫,相同業務模組資料的不同表現形式等。基於這些業務情況,我們在資料管理上採用了以下兩個措施:

2.1 必要的資料狀態管理

透過全域性資料狀態管理,不僅可以提高開發效率,還可以“持久化”資料,做高效的資料傳遞和共享。在玩法類的 Web 應用,功能模組可以高達20多個,對於同一份業務資料,可能會被多個功能模組進行讀寫,為了高效地處理模組間資料的傳遞與同步,我們使用 zustand 來進行資料狀態管理,在資料層封裝好每個業務功能模組的資料讀寫,然後在業務邏輯層進行資料讀寫邏輯的引用和呼叫,UI 層直接取資料進行 UI 渲染,使業務邏輯的表達具有明顯的層次性,帶來業務模組編寫的高效。以下為脫敏程式碼:

// store.js
export default create<StoreType>((set, get) => ({
    data: {
        // xxx
    },
    getData: async () => {
        try {
            const res = awwait servivce.getData();
            set({ data: res });
        } catch (e) {
            //
        }
    },
    // 暴露給其他業務邏輯
    setData: payload => set({ data: payload });
    // ...
}));

// view.js
const data = useStore((state: StoreType) => state.data);
const getData = useStore((state: StoreType) => state.getData);
// getData();
// <View data={data} />

2.2 資料預載入

當然,為了減輕非同步資料載入對檢視展示的影響,使 H5 更具有小遊戲的體驗,我們還對各次級模組的資料進行預載入,具體的實現方式是在各次級模組的前一級模組的非阻塞邏輯裡完成對次級模組核心資料的預載入請求,在次級模組載入時,再重新發起資料請求更新資料來兜底,這樣在次級模組顯示時則可以減去 Loading UI,加快次級模組的展示和資料的準確同步。
非阻塞邏輯是指前一級模組元件 useEffect 模擬的元件 ComponentDidMount,比如上一級頁面或次級模組的入口元件 componentDidMount時機,儘管這些邏輯需要開發者關注更多的邏輯,但是當模組被處理成元件和頁面時,則可以結合 React-Router V6 的 loader 欄位和 React Suspense + use 的方案進行資料的規範預請求。以下為脫敏程式碼:

// A1, A2, A3...為不同的業務模組
// A1
useEffect(() => {
    fetchData(A1);
    prefetchData(A2);
    prefetchData(A3);
}, []);
return (
    <>
        <A1 />
        <Link to={A2} />
        <Link to={A3} />
    <>
);
// A2, A3
const data = useStore((state: StoreType) => state.dataA2);
return (
    <A2 data={data} />
);

3. 圖片載入最佳化

圖片資源的載入最佳化也是應用體驗最佳化重要的一環,對應用的 LCP、FCP 資料有著明顯提升。在 Web 應用中,圖片分為應用本地的靜態圖片和介面返回的動態圖片,在圖片的載入和展示最佳化上我們也有一些實踐。

3.1 靜態圖片

類似於介面預載入的思路,我們使用 web worker 技術,將核心次級模組中的大圖進行提前載入,由於 web worker 的非阻塞性和瀏覽器本身的資源快取能力,這些次級模組的背景圖會被提前載入並快取在瀏覽器的記憶體中,而由於圖片模組引用路徑的一致性,且這類靜態圖片都被離線快取到客戶端本地,所以提前和實時的渲染請求也不會造成消耗流量的問題,同時即使提前請求失敗,也會有實時渲染請求來保底。

// preloadAssets.js
import { RESOURCE_TYPE } from '@music/tl-resource';
import BoxBg from '@/subpacks/assets/TreasureBox/tbg.png';
import PackageBg from '@/subpacks/assets/PackStore/bg.png';
// 需要預載入的圖片
export default [{
    src: BoxBg,
    type: RESOURCE_TYPE.IMAGE,
},
{
    src: PackageBg,
    type: RESOURCE_TYPE.IMAGE,
},
{
    src: StoreBg,
    type: RESOURCE_TYPE.IMAGE,
}];
// view.js
// 預載入圖片
await Resource.loadResource(loadAssets, (progress: number) => {
    setLoadProgress(progress);
});

3.2 動態圖片

在 Web 應用中,介面返回動態圖片,一般分為使用者上傳的 UGC 圖片和平臺在後臺上傳的 PGC 圖片。我們對於這兩類圖片,從圖片的生產、轉換、消費流程上都進行了合理的最佳化:對於介面下發的 PGC 圖片,在後臺配置的時候就根據 UI 稿顯示的大小限制好圖片的寬高、大小、格式,比如 UI 稿上圖片展示的是 100x100 畫素,則取三倍圖示準 300x300 進行限制,這樣可以合理控制資源的大小,避免不必要的渲染。

同時對於在業務迭代過程中一些改動較少的 PGC 圖片,我們會在工程內進行圖片的本地化,然後基於圖片上傳得到的儲存 key 建立和介面返回圖片地址對映,當遠端圖片載入時,替換成了本地圖片地址進行載入,這樣可以做到遠端圖片的載入速度顯著提升。
對於 UGC 圖片,則使用 CDN 裁剪,減少不必要的畫素渲染,同時對裁剪引數進行收斂,避免 CDN 由於引數差異性導致不必要的回源。

程式碼層面對比較大的圖片減少使用 CSS background-image,增多使用 img 標籤來提高瀏覽器對圖片的載入優先順序。

// 本地圖片Map,key是儲存 key,value 是對應圖片的本地地址,資料的來源是基於介面解析獲得
const LocalImgMap = {
    obj_w57DlMOIw6PCnj7DjMOi_31820368447_d791_9c66_d7e1_a0b39b42967e725d72c1a701d6bbe3ec: require('./locals/obj_w57DlMOIw6PCnj7DjMOi_31820368447_d791_9c66_d7e1_a0b39b42967e725d72c1a701d6bbe3ec.png'),
    obj_w57DlMOIw6PCnj7DjMOi_31820383635_fe96_8304_f720_474678d79820f05a5af723f710ecb54a: require('./locals/obj_w57DlMOIw6PCnj7DjMOi_31820383635_fe96_8304_f720_474678d79820f05a5af723f710ecb54a.png'),
    obj_w57DlMOIw6PCnj7DjMOi_31820418766_05dd_fe2d_1313_5b80b1108b2bfbbbe084585a3cb57f1f: require('./locals/obj_w57DlMOIw6PCnj7DjMOi_31820418766_05dd_fe2d_1313_5b80b1108b2bfbbbe084585a3cb57f1f.png')
    // ...
};
// 本地圖片對映元件
const LocalImg = ({ src, ...rest }) => {
    const localNosKeyStr = Object.keys(LocalImgMap).find(nosKeyStr => src.indexOf(nosKeyStr.replaceAll('_', '/')) > -1)
    const nSrc = LocalImgMap?.[localNosKeyStr] || src;
    return (
        <Image src={nSrc} {...rest} />;
    );
}

4. 過渡動畫效果

玩法 H5 開發和普通展示型的H5開發還有很大的不同,就是在互動體驗上需要更接近一些小遊戲,比如需要在一些場景轉換和狀態變更時,做一些合理的視覺效果,在按鈕點選時需要有明顯的互動反饋。總的來說就是要從互動最佳化的角度做的一系列的業務開發工作。這裡我們舉幾個簡單的例子:

  1. 一般在 React 應用開發中,資料狀態的變更,不可避免的會出現檢視閃爍的情況,比如資料變更引起的區域性UI結構變化,元素的清除、元素的更新等,對於這類小元素狀態變更的處理,就是要在資料發生變化時進行過渡,但是檢視時受資料響應的,這裡需要結合資料發生變化時對元素做一些動畫效果。比如列表項資料發生變化時,需要使用緩動消失,這裡可以結合一些動畫庫進行處理。再比如為了資料項不生硬展示時,可以書寫一些 CSS 動畫讓資料緩動入場等,再比如文字發生變化時,可以新增一個切換狀態toogle,將資料變化和切換狀態結合,切換狀態又和動畫繫結,則可以表達資料變化的過渡效果。
  2. 對於 UI 變動較大的情況,則可以參考行業內的做法,新增比較大的過場動畫,來緩解使用者的視覺衝擊。比如玩法中場景的變化,可以在每一個場景元件中內建一個提前展示的全場動畫,透過下一個場景的資料、UI的到達等合理去控制過場動畫展示。
  3. 普通的互動最好都設計好一套標準的互動,比如按鈕點選效果、彈窗展示和消失動畫、模態彈窗的使用等,總之玩法H5的開發要逐步向遊戲開發的標準靠近。

5. 榜單最佳化

  1. 直播社交類應用往往不乏排名榜單的功能,而且隨著業務功能的擴大,榜單展示的邏輯也會變得複雜,比如從單層Tab榜單發展為多層 Tab 巢狀榜單,在我們的玩法中,榜單巢狀可以達到 2x3x2 = 12 個資料榜單,如何在滿足較高體驗目標的情況下設計這12個榜單的組織結構和資料載入,是一個值得考慮和實踐的問題。

榜單

  1. 在最初的版本中,實現方式是多層 Tab 組合和一個資料列表 List,使用者點選任一 Tab,觸發新的資料請求,重新渲染 List,List 是一個最大長度為300的列表。這種實現方式相對比較簡單,實際的效果就是頻繁切換Tab的時候,同時一次性重新渲染300條資料的結構,造成明顯的 UI 閃爍。
<Fragment>
    <Tabs tabs={[A1, A2]} />
    <Tabs tabs={[B1, B2, B3]} />
    <Tabs tabs={[C1, C2]} />
    <List data={calc(A1, B1, C1)} />
</Fragment>
  1. 為了解決重新渲染引起的閃爍問題,我們將榜單的 List 改成了 KeepAliveList,即維護了3個 List 節點,只有1個 List 處於可見區域,其他 List 則被 KeepAlive 元件快取在記憶體當中,當使用者在切換 Tab 時,就會將快取住的 List 移入可見檢視,這個過程不會再有大量的節點重建,只有已渲染快取的節點移動,所以變消除了閃爍的情況。
<KeepAlive cacheKey={`${biz}_pre`} saveScrollPosition={false}>
    <div className="item hide" key={pre}>
        {childs[pre]}
    </div>
</KeepAlive>
<div className="item show cur" key={index}>
    {childs[index]}
</div>
<KeepAlive cacheKey={`${biz}_next`} saveScrollPosition={false}>
    <div className="item hide" key={next}>
        {childs[next]}
    </div>
</KeepAlive>
  1. 同時,為了保證首次載入建立的閃爍問題,我們在遊戲進入場景時即提前請求了全量榜單的前10條資料,這樣可以既保證榜單首次建立時可以不會出現Loading的樣式,也緩解了首次建立的資料載入消耗。當然,對於後續的資料載入,我們也採用了常見的上拉載入的方式,儘量避免單次大量資料的渲染。

  1. 在多榜單處理的中,還有一個比較常見的問題,就是滾動問題。使用了多個 List 來表單榜單後,由於不同榜單的高度可能不一致,如果使用全域性滾動,則在 Tab 切換的時候,就會出現滾動重置的情況,所以在這種情況下有必要使用區域性滾

總結規劃

以上,我們透過離線快取、介面預載入、圖片載入最佳化、過渡動畫、KeepAliveList 榜單最佳化等實踐方式最佳化了玩法H5的使用者體驗,雖然最後達成的效果從感官上相比普通的H5有明顯的不一樣,但是大部分最佳化都是需要耗費一定的開發成本。未來會將其中一些可以框架化的方案沉澱下來,減少一定的開發成本,比如資料預載入、圖片預載入、KeepAliveList、動畫元件等,為後續的小遊戲H5開發提供較好的開發經驗。

參考

  1. react-activation
  2. Web Worker在專案中的妙用

最後


更多崗位,可進入網易招聘官網檢視 https://hr.163.com/