Android多網絡機制淺析

Android從4.2版本開始,逐步支持了多網絡功能。相關的api能夠讓開發者選擇想要的網絡設備訪問,並且各個設備之間的切換和綁定也越來越方便。

判斷網絡連通性機制

從Android4.2.2開始,引入了一個叫“captive portal” detection的機制,用來判斷當前網絡是否連接上互聯網,是不是需要身份驗證的公共網絡。以5.0版本源碼中的代碼爲例:

https://android.googlesource.com/platform/frameworks/base/+/android-5.0.0_r7/services/core/java/com/android/server/connectivity/NetworkMonitor.java

在NetworkMonitor類中的isCaptivePortal方法:

/**
     * Do a URL fetch on a known server to see if we get the data we expect.
     * Returns HTTP response code.
     */
    private int isCaptivePortal() {
        if (!mIsCaptivePortalCheckEnabled) return 204;
        HttpURLConnection urlConnection = null;
        int httpResponseCode = 599;
        try {
            URL url = new URL("http", mServer, "/generate_204");
            if (DBG) {
                log("Checking " + url.toString() + " on " +
                        mNetworkAgentInfo.networkInfo.getExtraInfo());
            }
            urlConnection = (HttpURLConnection) mNetworkAgentInfo.network.openConnection(url);
            urlConnection.setInstanceFollowRedirects(false);
            urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS);
            urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS);
            urlConnection.setUseCaches(false);
            // Time how long it takes to get a response to our request
            long requestTimestamp = SystemClock.elapsedRealtime();
            urlConnection.getInputStream();
            // Time how long it takes to get a response to our request
            long responseTimestamp = SystemClock.elapsedRealtime();
            httpResponseCode = urlConnection.getResponseCode();
            if (DBG) {
                log("isCaptivePortal: ret=" + httpResponseCode +
                        " headers=" + urlConnection.getHeaderFields());
            }
            // NOTE: We may want to consider an "HTTP/1.0 204" response to be a captive
            // portal.  The only example of this seen so far was a captive portal.  For
            // the time being go with prior behavior of assuming it's not a captive
            // portal.  If it is considered a captive portal, a different sign-in URL
            // is needed (i.e. can't browse a 204).  This could be the result of an HTTP
            // proxy server.
            // Consider 200 response with "Content-length=0" to not be a captive portal.
            // There's no point in considering this a captive portal as the user cannot
            // sign-in to an empty page.  Probably the result of a broken transparent proxy.
            // See http://b/9972012.
            if (httpResponseCode == 200 && urlConnection.getContentLength() == 0) {
                if (DBG) log("Empty 200 response interpreted as 204 response.");
                httpResponseCode = 204;
            }
            sendNetworkConditionsBroadcast(true /* response received */, httpResponseCode == 204,
                    requestTimestamp, responseTimestamp);
        } catch (IOException e) {
            if (DBG) log("Probably not a portal: exception " + e);
            if (httpResponseCode == 599) {
                // TODO: Ping gateway and DNS server and log results.
            }
        } finally {
            if (urlConnection != null) {
                urlConnection.disconnect();
            }
        }
        return httpResponseCode;
    }

簡單的說,原理就是訪問google的clients3.google.com/generate_204地址,當返回204代碼,或者是200並且內容ContentLength是0時,判斷網絡已連通。否則就是未連上互聯網,或者需要身份驗證的公共網絡。爲什麼返回200並且ContentLength爲0時也認爲是連上互聯網了呢?因爲需要身份驗證的系統返回長度不可能是0,可能是因爲代理的緣故導致狀態碼錯誤。

使用Android 5.0版本以上原生系統的同學會發現,狀態欄上wifi或者cell的圖標上會有個歎號。這是因爲谷歌被牆,http的response code自然就不會是204了。

我們看到isCaptivePortal 方法是傳入一個參數InetAddress server的。要想在國內使用這個feature,可以將谷歌的地址替換爲國內網友架的服務或者自己建一個服務例如:https://github.com/HorseLuke/drafts/blob/master/sinaapp_generate_204/README.md

有網友已經搭好了服務,將地址替換到noisyfox.cn,具體可參考https://www.noisyfox.cn/45.html, 只要一個命令即可:

adb shell "settings put global captive_portal_server noisyfox.cn"

也可以完全禁止掉這個檢測:

adb shell "settings put global captive_portal_detection_enabled 0"

但這樣會有一個問題,就是如果連接上一個需要網頁驗證的wifi,就沒有辦法自動跳到登陸界面了。

多網絡連接機制

一直以來Android系統的訪問網絡的類型都是不可選的,連接WiFi走WiFi,否則走Cellular。但從5.0版本開始引入了多網絡連接機制,引用:

Android 5.0 provides new multi-networking APIs that let your app dynamically scan for available networks with specific capabilities, and establish a connection to them. This functionality is useful when your app requires a specialized network, such as an SUPL, MMS, or carrier-billing network, or if you want to send data using a particular type of transport protocol.

在api21中新加入了方法

ConnectivityManager.setProcessDefaultNetwork

可以將進程綁定到特定的網絡,這樣即使是wifi打開的情況下也可以使用Cellular訪問網絡了。但是當WiFi連接時,不管WiFi是否聯網,系統默認依然是會選擇走WiFi,只有手動綁定app進程才能切到Cellular網絡。

https://developer.android.com/about/versions/android-5.0.html#Wireless

自動切換網絡機制

到了Android6.0,在網絡方面又有如下變化。如change note中所說,在之前版本的系統中,當連接到WiFi時,其他類型的網絡就會斷開;而在6.0中,其他網絡不會斷開,雖然會優先從WiFi訪問,但當檢測到連接的WiFi沒有聯網而其他網絡(例如Cellular)是聯網的情況下,所有數據的訪問會走到Cellular網絡。引用:

This release introduces the following behavior changes to the Wi-Fi and networking APIs.

  • Your apps can now change the state of WifiConfiguration objects only if you created these objects. You are not permitted to modify or deleteWifiConfiguration objects created by the user or by other apps.
  • Previously, if an app forced the device to connect to a specific Wi-Fi network by using enableNetwork() with the disableAllOthers=true setting, the device disconnected from other networks such as cellular data. In This release, the device no longer disconnects from such other networks. If your app’s targetSdkVersion is “20” or lower, it is pinned to the selected Wi-Fi network. If your app’s targetSdkVersion is “21” or higher, use the multinetwork APIs (such as openConnection(), bindSocket(), and the new bindProcessToNetwork() method) to ensure that its network traffic is sent on the selected network.

https://developer.android.com/about/versions/marshmallow/android-6.0-changes.html#behavior-network

對大部分開發者來說,這個特性似乎沒什麼用。在大部分情況下,app不需要關心繫統網絡如何切換,只要能夠成功訪問並且得到當前訪問的類型就夠了。

但是對某些特殊的應用場景,有了這個特性就慘了。例如:你的app需要訪問到本地的網絡服務(例如一臺沒有接入互聯網的路由器),如果cellular是關閉的,那可以正常訪問;而如果cellular是打開的,所有的數據都默認走到cellular,就無法訪問本地的服務了。

從這個例子看6.0的系統是有些傻,明明可以做到兩個網絡同時連通,但卻很“智能”地選擇了一個連接到互聯網的網絡去訪問。當然解決這個問題並不難,使用系統提供的bindsocket和bindprocesstonetwork就夠了。

多網絡同時訪問

正如前一節所說,要同時訪問多個網絡設備,需要拿到網絡對應的Network,並且跟訪問的socket做綁定。以網絡框架Okhttp和主流圖片加載框架picasso與glide爲例,用以下幾個步驟便可實現網絡綁定:

獲取Network

參照如下的代碼片段,首先構造一個NetworkRequest.Builder,包含wifi但不包含網絡訪問;其次爲ConnectivityManager註冊監聽;當onAvailable回調獲取到Network時,記錄下當前連接的wifi。

final ConnectivityManager connManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkRequest.Builder request = new NetworkRequest.Builder()
                .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
                .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
connManager.registerNetworkCallback(request.build(), new ConnectivityManager.NetworkCallback() {
            @Override
            public void onAvailable(Network network) {
                NetworkUtil.setNetwork(BindNetActivity.this, network);
                L.d("bind network " + network.toString());
            }

            @Override
            public void onLost(Network network) {
                NetworkUtil.setNetwork(BindNetActivity.this, null);
                try {
                    connManager.unregisterNetworkCallback(this);
                } catch (SecurityException e) {
                    L.d("Failed to unregister network callback");
                }
            }
        });

綁定網絡

在setNetwork方法裏,做了一下幾件事。首先記錄下Network,然後更新相關httpclient並綁定網絡,然後更新圖片加載並添加綁定網絡的註冊。

public static void setNetwork(Context context, Network network) {
    L.d("init network" + network);
    NetworkUtil.network = network;
    BindedHttpClient.getInstance().updateClient(network);
    ImageLoader.initGlide(context);
}            

BindHttpClient 裏的關鍵代碼如下,可以看到,在updateClient方法中,使用OkHttpClient.Builder的socketFactory方法將創建連接的socketFactory 指定爲Network的socketFactory。這樣所有通過OkHttpClient的訪問都會走到指定的Network了。

public OkHttpClient client;
public void updateClient(Network network) {
    OkHttpClient.Builder builder = new OkHttpClient().newBuilder();
    builder.connectTimeout(10, TimeUnit.SECONDS)
            .writeTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .connectionPool(new ConnectionPool(0, 5, TimeUnit.MINUTES));
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && network != null) {
        builder.socketFactory(network.getSocketFactory());
    }
    client = builder.build();
}    

ImageLoader裏的關鍵代碼如下,glide支持圖片加載的自定義註冊。簡單的說,就是可以將加載的地址包裝成modelClass,指定加載後的數據爲resourceClass,以及加載工廠factory。

public static void initGlide(Context context) {
    Glide.get(context).register(GlideCameraUrl.class, InputStream.class, new LocalLoaderFactory());
}    

GlideCustomUrl裏面沒什麼,就是繼承了GlideUrl,方便區分是普通的圖片請求,還是特定網絡的圖片請求。

public class GlideCustomUrl extends GlideUrl {

    public GlideCustomUrl(URL url) {
        super(url);
    }

    public GlideCustomUrl(String url) {
        super(url);
    }

    public GlideCustomUrl(URL url, Headers headers) {
        super(url, headers);
    }

    public GlideCustomUrl(String url, Headers headers) {
        super(url, headers);
    }
}

LocalLoaderFactory裏的build方法返回了自定義的ModelLoader OkHttpUrlLoader,傳入之前綁定過網絡的httpclient作爲網絡請求,這樣就可以綁定到特定網絡了。

public class LocalLoaderFactory implements ModelLoaderFactory<GlideCustomUrl, InputStream> {

    @Override
    public ModelLoader<GlideCustomUrl, InputStream> build(Context context, GenericLoaderFactory factories) {
        return new OkHttpUrlLoader(BindedHttpClient.getInstance().client);
    }

    @Override
    public void teardown() {
    }
}

在OkHttpUrlLoader裏又需要自定義一個DataFetcher,在OkHttpStreamFetcher這裏面纔是真正的請求網絡數據的部分。

@Override
public DataFetcher<InputStream> getResourceFetcher(GlideCustomUrl model, int width, int height) {
    return new OkHttpStreamFetcher(client, model);
}

對c層socket的綁定

以上寫的都是在java層創建http的綁定,對於有些應用場景需要在C層訪問網絡,又如何綁定呢?

參照如下代碼,linux系統中創建socket會分配對應的fileDescriptor即文件描述符,這個是一個IO的唯一標識。只要創建完socket之後通過jni調用此java方法,將fileDescriptor綁定到相關network中就可以了。

@TargetApi(Build.VERSION_CODES.M)
public static void bindSocketToNetwork(int socketfd) {
    L.d("start bindSocketToNetwork");
    if (network != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        FileDescriptor fileDescriptor = new FileDescriptor();
        try {
            Field field = FileDescriptor.class.getDeclaredField("descriptor");
            field.setAccessible(true);
            field.setInt(fileDescriptor, socketfd);
            // fileDescriptor.sync();

            network.bindSocket(fileDescriptor);
//                bindSocket(socketfd, netId);
            L.d("bindSocketToNetwork success: network" + network + "+socketfd" + socketfd);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

相關源碼

本文涉及到的相關代碼可以參考BindSocketDemo



作者:mqstack
鏈接:https://www.jianshu.com/p/0042c0e3a15b
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯繫作者獲得授權並註明出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章