博客 / 詳情

返回

遊戲中心弱網優化實踐

作者:vivo 互聯網客户端團隊- Ke Jie
本實踐圍繞遊戲中心在弱網環境下的性能優化展開,針對複雜網絡場景下的頁面加載慢、資源加載失敗等問題,提出了優化方案:接入支持 QUIC 協議的 Cronet 網絡庫,通過更快的連接建立與傳輸特性提升請求響應速度。配合弱網狀態精細化判定與限速測試,線上灰度實驗顯示頁面加載失敗率下降 40%,請求耗時降低 7%,圖片加載速度在正常至極差網絡環境均有顯著提升。

1分鐘看圖掌握核心觀點👇

動圖封面
 

圖 1 VS 圖 2,您更傾向於哪張圖來輔助理解全文呢?歡迎在評論區留言。

一、弱網優化背景

遊戲中心 APP 的核心功能依賴網絡連接,如遊戲下載、更新、啓動、禮包領取及活動參與等。而在電梯、地下車庫等弱網環境中,用户常遇到進入頁面慢、圖片資源加載不出來等問題,嚴重影響體驗,導致活躍下降和用户流失。

隨着移動遊戲用户規模擴大,確保在複雜網絡條件下的穩定訪問和核心功能可用性,成為提升留存和轉化的關鍵。通過優化傳輸協議、傳輸數據優化等,可顯著改善弱網下的使用體驗,保障用户使用流暢性,提升整體用户滿意度。

二、如何去定義網絡狀態

在移動應用中,網絡狀態的定義通常是指當前設備所處的網絡連接類型與質量。它不僅僅是“有網”或“沒網”,還包括網絡速度、延遲、丟包率等關鍵指標,特別在進行弱網優化時,需要更精細地感知和分類網絡狀態。如果要對優化效果進行衡量,首頁要定義各種情形下歸屬哪種網絡狀態。

由於網絡狀態並沒有一個統一的定義,遊戲中心基於以下維度構建立了App內部的弱網判定標準。

弱網與疑似弱網對比

大概的現象可以總結為:

  • 弱網環境:網絡質量嚴重下降,已對用户體驗造成明顯影響。
  • 疑似弱網環境:網絡出現不穩定或退化跡象,但尚未達到嚴重弱網程度。

遊戲中心通過判斷網絡狀態、WIFI信號、手機信號強弱、Ping百度/Vivo域名、最近接口請求失敗率、上下行帶寬、最近請求平均耗時等維度,賦予不同的網絡狀態值,將網絡狀態值作為作為埋點的公參上報,作為優化前後提取數據的維度。

三、遊戲中心接入QUIC協議

3.1 QUIC協議簡介

QUIC 是 Google 在 2013 年推出的一種新型網絡協議,全稱是“快速 UDP 網絡連接”(Quick UDP Internet Connections)。它和我們常用的 TCP 協議不一樣,是基於 UDP 打造的。QUIC 的目標是讓網站和應用加載得更快,同時也更加安全。

它能一次建立多個數據連接,而且建立連接的速度比傳統方式更快,這意味着打開網頁、看視頻或傳輸數據時,等待的時間會更短。此外,QUIC 還具備自動控制網絡帶寬的功能,可以根據網絡情況進行調節,避免網絡堵塞。

Google 希望用 QUIC 來替代現有的 TCP 協議,並推動它成為互聯網新的標準協議。

3.2 QUIC協議應用場景

輕量資源傳輸優化:對於圖片、圖標等體積較小的文件,能夠快速完成傳輸,縮短加載時間,提升整體響應效率。

視頻播放體驗增強:在進行視頻點播時,可以實現更快的內容呈現,提升首幀加載速度,減少播放中斷,提高觀影流暢度。

高頻交互請求加速:針對如登錄驗證、支付流程等頻繁交互的請求場景,可有效提升數據響應速度,改善用户的操作體驗。

複雜網絡下保持穩定:在網絡條件較差,如高延遲或頻繁丟包的情況下,依然能維持穩定的數據傳輸,減少失敗和卡頓,保障服務可用性。

應對大規模併發訪問:在面對大量用户同時訪問、多資源並行加載等高併發情境時,具備更強的連接能力,提升整體訪問速度與穩定性。

實現方式

Cronet和Okhttp一樣都是網絡庫,Cronet 原生支持 QUIC,而 OkHttp 默認不支持 QUIC。

由於原來業務中對Okhttp網絡庫是有一定改造的,所以這裏在Okhttp網絡庫中去接入Cronet庫,做好兼容。

網絡庫實現的思路是自定義 Cronet 攔截器,一個完整的 Cronet 攔截器主要包含三個步驟:

  • OkHttp Request 轉換為 Cronet Request
  • 發起 Cronet 請求並處理生命週期
  • Cronet Response 轉 OkHttp Response

將自定義的 Cronet 攔截器添加到 OkHttp 攔截鏈的末尾,保證其他攔截器(如緩存、日誌、認證)正常工作後,才使用 Cronet 處理請求。

OkHttpClient輔助類中兼容Cronet:

// 1. 創建緩存路徑
val cachePath = File(AppContext.getContext().cacheDir, CRONET_CACHE_PATH)
if (!cachePath.isDirectory) {
    cachePath.mkdirs()
    VLog.d(TAG, "no cronet cache dir, mkdirs")
}

// 2. 構建 CronetEngine
var builder = CronetEngine.Builder(AppContext.getContext())
try {
    builder = builder
        .setStoragePath(cachePath.absolutePath)   // 設置緩存路徑
        .enableBrotli(false)                      // 是否開啓 Br 壓縮,暫不開啓
        .enableQuic(true)                         // 開啓 QUIC
        .enableHttp2(true)                        // 開啓 HTTP/2
        .enableHttpCache(
            CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP,
            SIZE_1_MB.toLong()
        ) // 1MB 磁盤緩存,需先設置 setStoragePath()
    
    // 配置 QUIC Hint
    NetworkManager.getInstance().quicHintHosts?.forEach {
        builder = builder.addQuicHint(it, 443, 443)
    }

    // 構建 CronetEngine
    cronetEngine = builder.build()
} catch (e: Throwable) {
    VLog.e(
        TAG,
        "init cronet engine fail",
        e
    ) // 初始化 CronetEngine 失敗,則返回 null,不走 QUIC 請求
    cronetEngine = null
}

// 3. 構建 OkHttpClient 並集成 Cronet
val netClientBuilder = defOkhttpClient.newBuilder()
    .connectTimeout(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS)
    .writeTimeout(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS)
    .addInterceptor(CronetInterceptor.Builder(cronetEngine).build())

CronetInterceptor攔截器,對需要使用QUIC協議的域名進行QUIC請求,相關域名可以做成配置項,具備線上隨時切換的能力。

CronetInterceptor攔截器作用主要職責是:OKHttp 的Request 轉換成Cronet Request,並能接收響應。

public final class CronetInterceptor implementsInterceptor {
    private static final String TAG = "CronetInterceptor";

    private final RequestResponseConverter mConverter;

    private CronetInterceptor(RequestResponseConverter converter){
        this.mConverter = checkNotNull(converter);
    }

    @Override
    public Response intercept(Chain chain) throws IOException {
        if (chain.call().isCanceled()) {
            thrownew IOException("Request call canceled");
        }
        Request request = chain.request();
        if (OkHttpClientHelper.INSTANCE.isNeedUseCronet(request.url())) {
            VLog.d(TAG, "use Cronet request:" + request.url());
            return proceedWithCronet(chain); // 使用 Cronet 發起 Quic 請求
        } else {
            VLog.d(TAG, "don't use Cronet request:" + request.url());
            return proceedDefault(chain); // 不使用 Cronet 請求
        }
    }

    private Response proceedWithCronet(Chain chain) throws IOException {
        RequestResponseConverter.CronetRequestAndOkHttpResponse requestAndOkHttpResponse =
                mConverter.convert(chain.request(), chain.readTimeoutMillis(), chain.writeTimeoutMillis());
        try {
            requestAndOkHttpResponse.getRequest().start();
            return toInterceptorResponse(requestAndOkHttpResponse.getResponse(), chain.call());
        } catch (Throwable e) {
            VLog.e(TAG, "proceedWithCronet exception:", e);
            throw e;
        }
    }

    private Response proceedDefault(Chain chain) throws IOException {
        try {
            Request request = chain.request();
            VLog.d(TAG, "intercept " + request.method() + ", " + request.tag());
            request = RequestHelper.handleRequest(request);

            Response response = chain.proceed(request);
            int retryNum = 0;
            while ((response == null || !response.isSuccessful()) && retryNum < DEFAULT_RETRY_COUNT) {
                retryNum++;
                if (response != null && response.body() != null) {
                    response.body().close();
                }
                response = chain.proceed(request);
            }
            return response;
        } catch (Throwable e) {
            if (e instanceof IOException) {
                throw e;
            } else {
                thrownew IOException(e);
            }
        }
    }

    private Response toInterceptorResponse(Response response, Call call){
        checkNotNull(response.body());
        return response
                .newBuilder()
                .body(new CronetInterceptorResponseBody(response.body(), call))
                .build();
    }
}

接收到響應後,需要將Croent Response 轉成 OKHttp Response,核心的實現:

Response toResponse(Request request, OkHttpBridgeRequestCallback callback) throws IOException {
    Response.Builder responseBuilder = new Response.Builder();

    UrlResponseInfo urlResponseInfo = getFutureValue(callback.getUrlResponseInfo());

    @Nullable String contentType = getLastHeaderValue(CONTENT_TYPE_HEADER_NAME, urlResponseInfo);

    @Nullable String contentLengthString = null;

    List<String> contentEncodingItems = new ArrayList<>();

    for (String contentEncodingHeaderValue : getOrDefault(
            urlResponseInfo.getAllHeaders(),
            CONTENT_ENCODING_HEADER_NAME,
            Collections.emptyList())) {
        Iterables.addAll(contentEncodingItems, COMMA_SPLITTER.split(contentEncodingHeaderValue));
    }

    boolean keepEncodingAffectedHeaders =
            contentEncodingItems.isEmpty() || !ENCODINGS_HANDLED_BY_CRONET.containsAll(contentEncodingItems);

    if (keepEncodingAffectedHeaders) {
        contentLengthString = getLastHeaderValue(CONTENT_LENGTH_HEADER_NAME, urlResponseInfo);
    }

    ResponseBody responseBody =
            createResponseBody(
                    request,
                    urlResponseInfo.getHttpStatusCode(),
                    contentType,
                    contentLengthString,
                    getFutureValue(callback.getBodySource()));

    responseBuilder
            .request(request)
            .code(urlResponseInfo.getHttpStatusCode())
            .message(urlResponseInfo.getHttpStatusText())
            .protocol(convertProtocol(urlResponseInfo.getNegotiatedProtocol()))
            .body(responseBody);

    for (Map.Entry<String, String> header : urlResponseInfo.getAllHeadersAsList()) {
        boolean copyHeader = true;
        if (!keepEncodingAffectedHeaders) {
            if (Ascii.equalsIgnoreCase(header.getKey(), CONTENT_LENGTH_HEADER_NAME)
                    || Ascii.equalsIgnoreCase(header.getKey(), CONTENT_ENCODING_HEADER_NAME)) {
                copyHeader = false;
            }
        }
        if (copyHeader) {
            responseBuilder.addHeader(header.getKey(), header.getValue());
        }
    }

    return responseBuilder.build();
}

這樣整體在OkHttp網絡庫中,能夠兼容使用Cronet網絡庫,整體的流程就通了。

測試方式及配置

① 域名支持

需要將支持的域名配置成支持QUIC,這裏注意需要和運營商確認是否支持GQUIC/IQUIC。

② 限制網速參數

各個網絡狀態的參數可以參考這樣設置:

參數參考:[稀土掘金 · Fiddler 抓包(下載安裝及使用)]

③ 測試工具

由於QUIC抓包比較複雜,這裏自定義了腳本,通過限制延遲時間、帶寬、丟包率來限制網速,參數可以參考上一小節。

#!/bin/bash

# 延遲時間,以毫秒為單位進行指定。
# 帶寬,以千比特或兆比特為單位進行指定。
# 丟包率,以百分比進行指定。
# 比如設置 300 毫秒的延遲時間、100 千比特的帶寬和 50% 的丟包率,請運行以下命令:
# bash NetworkSimulation.sh 300ms 100kbit 50%

# 如需設置 100 毫秒的延遲時間、1 兆比特的帶寬和 0% 的丟包率,請運行以下命令:
# bash NetworkSimulation.sh 100ms 1mbit 0%

# root device and set it to permissive mode
adb root
adb shell setenforce 0

# Clear the current tc control
adb shell tc qdisc del dev ifb0 root
adb shell ip link set dev ifb0 down
adb shell tc qdisc del dev wlan0 ingress
adb shell tc qdisc del dev wlan0 root

if [ $# -eq 1 ]; then
    echo "setup cleared"
elif [ $# -eq 3 ]; then
    latency=$1
    bandwidth=$2
    packetloss=$3
    # Create a virtual device for ingress
    adb shell ip link set dev wlan0 up
    adb shell ip link set dev ifb0 up
    adb shell tc qdisc del dev wlan0 clsact
    adb shell tc qdisc add dev wlan0 handle ffff: ingress
    adb shell tc filter add dev wlan0 parent ffff: protocol all u32 match u32 00 action mirred egress redirect dev ifb0

    # Throttle upload bandwidth / latency / packet loss
    adb shell tc qdisc add dev wlan0 root handle 1: htb default11
    adb shell tc class add dev wlan0 parent 1: classid 1:1 htb rate "$bandwidth"
    adb shell tc class add dev wlan0 parent 1:1 classid 1:11 htb rate "$bandwidth"
    adb shell tc qdisc add dev wlan0 parent 1:11 handle 10: netem delay "$latency" loss "$packetloss"

    # Throttle download bandwidth
    adb shell tc qdisc add dev ifb0 root handle 1: htb default10
    adb shell tc class add dev ifb0 parent 1: classid 1:1 htb rate "$bandwidth"
    adb shell tc class add dev ifb0 parent 1:1 classid 1:10 htb rate "$bandwidth"
else
    echo "Invalid parameters"
fi

通過命令行執行類似於bash NetworkSimulation.sh 100ms 1mbit 0%命令,即可以限制手機的網絡狀態。

四、優化效果

在本次面向核心接口與圖片域名的線上 A/B 灰度實驗中,經過一段時間的觀測與數據對比,灰度策略取得了顯著優化效果,主要體現在以下幾個方面:

  • 頁面加載失敗率顯著下降:整體失敗率下降 40%,顯著提升頁面可用性;
  • 頁面請求響應性能優化:平均頁面請求耗時下降 7%,加載更流暢;
  • 正常網絡環境圖片加載速度提升:加載速度提升 38%,提升用户體驗;
  • 弱網絡環境圖片加載速度提升:加載速度提升 30%,弱網下表現更優;
  • 極差網絡環境圖片加載速度提升:加載速度提升達58%,保障極端場景下的可用性與體驗。
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.