Android高性能编码 - 第五篇 网络请求和数据解析

尽管现在项目中已经普遍采用okhttp等框架进行网络请求,但出于开发的高性能要求,我们仍有必要对一些网络请求的性能要点进行讨论,以便对网络请求中的整体编码和第三方框架的配置使用有更深入的认识。

5.1 网络请求编码的基本要素

当我们开始讨论网络请求时,从客户端的角度来说,主要涉及三个方面:客户端、请求、响应。从开发编码的角度来说,我们主要会关注请求和响应的几个具体要素,如下图所示。

为了处理这些请求和响应要素,Android原生为我们提供了两个主要的Api家族:

l  HttpClient : The DefaultHttpClient and AndroidHttpClient classes are the main classes to use for this HTTPimplementation.

l  URLConnection : This is a more flexible and performant API toconnect to a URL. It can use different protocols.

但是,Android官方不再推荐HttpClient并从API Level 23开始将其移除SDK,尽管有一些第三方库还可以提供支持,但考虑到SDK的后续更新方向,强烈要求不再使用。

因此,如果使用原生网络请求接口的话,要求只能使用URLConnection家族,而这也是众多网络开源框架的趋势。

5.2 关于HttpURLConnection

URLConnection是所有表示应用程序与URL之间通信连接的类的父类(superclass),该类的实例可以用来对由URL引用的资源进行读取和写入操作。

HttpURLConnection 是支持HTTP特定功能的URLConnection,还有JarConnection是URLConnection的直接子类。

众多开源框架基于URLConnection提供了良好的编码接口,也不排除在某些场景,我们采用该API自定义轻量级的网络请求封装,本小节将简介URLConnection本身的一些基本用法。

5.2.1 Protocols

当我们讨论网络通信的时候,我们比较感兴趣的是使用HTTP协议来执行网络数据通信,但是使用URLConnection及其子类可以支持数据协议包括:

l  HTTP and HTTPS:HttpUrlConnection是本家族的主要类,用于执行http请求;

l  FTP:默认的URLConnection类提供了FTP通信接口,使用其即可执行FTP通信;

l  File:本地文件系统也可以使用URLConnection基于URI进行访问;

l  JAR:这种协议用来处理JAR文件,可以使用JarUrlConnection扩展类来执行。

5.2.2 Methods

HttpURLConnection类提供的主要请求方式为:

l  GET:这是默认的请求方式,不需要做特别的设置即可使用;

l  POST:通过调用URLConnection.setDoInput()接口,即可实现Post请求;

其它请求方式,可以通过调用URLConnection.setRequestMethod()方法来实现

5.2.3 Headers

当准备发送请求时,我们可能有必要增加一些metadata数据,或者增加一些用户和session信息等等,来让服务器指导客户端的的相关状态。头信息Headers将以key/value的格式承载这些信息,并且也可以在响应的时候,读取服务端修改的一些信息,比如响应数据的格式、启用压缩等。

头信息的使用主要涉及两个接口:

l  URLConnection.setRequestProperty()

l  URLConnection.getHeaderFields()

5.2.4 Timeout

URLConnection支持两种类型的超时:

l 连接超时Connect timeout:通过调用URLConnection.setConnectTimeout()方法设置超时时间,在期间内,客户端将等待连接成功;如果连接超时,将抛出SocketTimeoutException异常;

l 读取超时Read timeout:通过调用URLConnection.setReadTimeout()方法设置,这是Inputstream读完的时间限制,如果读取超时,也将抛出SocketTimeoutException异常。

两者的默认值都是0,即没有超时设置。因此,默认的超时机制将由TCP传输层自行进行控制,这个时间通常较长,超出我们的可控范围。

5.2.5 Content

当我们和服务端启动一个新的connection后,即开始等待响应,通过调用URLConnection.getContent()方法,将以InputStream的方式获取到响应内容;同时还有几个响应参数以及三个主要的头信息:

l  Content length:响应内容的长度,通过调用URLConnection.getContentLength()方法进行读取;

l  Content type:响应内容的MIME-type通过调用URLConnection.getContentType()读取;

l  Content encoding:压缩编码方式,通过调用URLConnection.getContentEncoding()方法读取。

5.2.6 Compression

通过上述Content encoding读取的值,将知道响应内容的压缩方式,当然客户端也可以通过设置Accept-Encoding头字段来告知服务端其期望的方式。

服务端默认不启用压缩,但是客户端应该通过URLConnection.getContentEncoding()方法检查返回的结果;客户端不会自动解压,如果数据流是压缩的话,需要使用GZIPInputStream方式代替默认的InputStream流来读取数据。

5.2.7 Response code

响应返回码将决定客户端不同的处理方式,通过调用HttpURLConnection.getResponseCode()方法获取code之后,执行不同的响应。响应码通常包含如下几组:

2xx:成功,服务端接收到请求并发送返回数据;

3xx:重定向,大多数情况下由http层自动完成,不需要额外处理;其中304属于服务端无更新;

4xx:客户端请求出错,可能是请求的参数或语法有误,资源无法找到等;

5xx:服务端响应失败,服务器内部出错或者负载过重。

5.3 区分网络条件

客户端可以根据网络连接状态决定如何执行请求,以及请求什么样的数据,无论使用上述HttpURLConnect接口还是第三方网络请求库,都应当进行不同的处理,至少包括不同的请求超时和不同的数据响应。

5.3.1 Connection types

通过调用ConnectionManager.getActiveNetworkInfo()来获得NetworkInfo,通过NetworkInfo.getType()获取具体的网络连接类型,通常有以下几种:

l  TYPE_MOBILE

n  TelephonyManager.NETWORK_TYPE_LTE

n  TelephonyManager.NETWORK_TYPE_GPRS

l  TYPE_WIFI

l  TYPE_WIMAX 即全球微波互联接入,美国Sprint4G技术。是一项新兴的宽带无线接入技术,能提供面向互联网的高速连接,数据传输距离最远可达50km。

l  TYPE_ETHERNET 以太网

l  TYPE_BLUETOOTH

比如,在手机应用中,当需要下载大文件是,我们应该尽可能避免在移动网络状态下执行,或者提醒用户连接wifi;在电视应用中,也至少要通过NetworkInfo.isConnected()判断是否有网络可用。

更进一步,我们应该在服务层通过BroadcastReceiver and registering监听ConnectivityManager.CONNECTIVITY_ACTION事件,在全局提醒用户网络不良或关闭的信息。

5.3.2 根据不同的网络使用不同的请求策略

如上所示,我们可以得到的网络实时状况,那么应该根据当前的网络类型来设置不同的请求策略,具体的策略由app根据自身的业务而定,至少包括请求超时和重度资源的调整设置。

使用ConnectivityManager和TelephonyManager对网络进行综合判断的示例如下:

/** 没有网络 */
public staticfinal int NETWORKTYPE_INVALID = 0;
/** wap网络 */
public staticfinal int NETWORKTYPE_WAP = 1;
/** 2G网络 */
public staticfinal int NETWORKTYPE_2G = 2;
/** 3G和3G以上网络,或统称为快速网络 */
public staticfinal int NETWORKTYPE_3G = 3;
/** wifi网络 */
public staticfinal int NETWORKTYPE_WIFI = 4;

/**
 * 获取网络状态,wifi,wap,2g,3g.
 *
 * @param context 上下文
 * @return int 网络状态 {@link #NETWORKTYPE_2G},{@link#NETWORKTYPE_3G},          *{@link#NETWORKTYPE_INVALID},{@link #NETWORKTYPE_WAP}*<p>{@link #NETWORKTYPE_WIFI}
 */
public staticint getNetWorkType(Context context) {
    int mNetWorkType = 0;
    ConnectivityManager manager =(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo networkInfo =manager.getActiveNetworkInfo();
    if (networkInfo != null && networkInfo.isConnected()) {
        String type =networkInfo.getTypeName();
        if (type.equalsIgnoreCase("WIFI")) {
            mNetWorkType = NETWORKTYPE_WIFI;
        } else if (type.equalsIgnoreCase("MOBILE")) {
            String proxyHost =android.net.Proxy.getDefaultHost();
            mNetWorkType = TextUtils.isEmpty(proxyHost)
                    ? (isFastMobileNetwork(context)? NETWORKTYPE_3G : NETWORKTYPE_2G)
                    : NETWORKTYPE_WAP;
        }
    } else {
        mNetWorkType = NETWORKTYPE_INVALID;
    }
    return mNetWorkType;
}

private staticboolean isFastMobileNetwork(Context context) {
    TelephonyManager telephonyManager =(TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);
    switch (telephonyManager.getNetworkType()) {
        case TelephonyManager.NETWORK_TYPE_1xRTT:
            return false; // ~ 50-100 kbps
        case TelephonyManager.NETWORK_TYPE_CDMA:
            return false; // ~ 14-64 kbps
        case TelephonyManager.NETWORK_TYPE_EDGE:
            return false; // ~ 50-100 kbps
        case TelephonyManager.NETWORK_TYPE_EVDO_0:
            return true; // ~ 400-1000 kbps
        case TelephonyManager.NETWORK_TYPE_EVDO_A:
            return true; // ~ 600-1400 kbps
        case TelephonyManager.NETWORK_TYPE_GPRS:
            return false; // ~ 100 kbps
        case TelephonyManager.NETWORK_TYPE_HSDPA:
            return true; // ~ 2-14 Mbps
        case TelephonyManager.NETWORK_TYPE_HSPA:
            return true; // ~ 700-1700 kbps
        case TelephonyManager.NETWORK_TYPE_HSUPA:
            return true; // ~ 1-23 Mbps
        case TelephonyManager.NETWORK_TYPE_UMTS:
            return true; // ~ 400-7000 kbps
        case TelephonyManager.NETWORK_TYPE_EHRPD:
            return true; // ~ 1-2 Mbps
        case TelephonyManager.NETWORK_TYPE_EVDO_B:
            return true; // ~ 5 Mbps
        case TelephonyManager.NETWORK_TYPE_HSPAP:
            return true; // ~ 10-20 Mbps
        case TelephonyManager.NETWORK_TYPE_IDEN:
            return false; // ~25 kbps
        case TelephonyManager.NETWORK_TYPE_LTE:
            return true; // ~ 10+ Mbps
        case TelephonyManager.NETWORK_TYPE_UNKNOWN:
            return false;
        default:
            return false;
    }
}

5.3.3 弱网队列

在手机app的开发中,在2G弱网条件下,除了使用不同的请求设置外,还需要控制请求数量。此时,一方面,避免大量的请求互相抢占资源导致响应慢;另一方面,可以更进一步,在网络波动的环境中,争取在网络改善时发起请求,获得更好的请求效果。

一个简单的自定义队列管理示例如下:

public class TransferQueue {
    private Queue<Request> queue;
    public void addRequest(Request request) {
        queue.add(request);
    }
    public void execute() {
        //Iteration over the queue for executions
    }
}

5.4 减少DNS解析

DNS域名系统的主要功能是根据应用请求所用的域名URL去网络映射表中查找对于IP地址,这个过程可能会需要数十上百ms不等的实际,也存在DNS解析拦截甚至劫持攻击的风险。

根据具体业务需求,可以考虑采用IP直连的方式来代替域名访问的方式,从而达到更快的网络请求。但使用IP直连的方式不够灵活,当对应的服务因为某些原因改变IP地址后,客户端就访问不了了。

因此,减少DNS解析的策略为:

一方面,对于比较稳定的URI资源,可以使用IP直连方式;

另一方面,对于可能存在潜在动态更新的URI资源,采用IP方式方式的同时,在IP方式访问失败时,增加切换到域名访问的方式;

此外,许多APP中为防止local dns劫持而采用httpDNS域名解析方案,同时也是一个高性能访问方案;有条件自建解析服务的,也可以进一步扩展,将自家网关接口全部上HTTPDNS方式;

5.5 请求合并

一次完整的http请求,首先需要进行DNS解析查找,接着通过握手建立连接;如果是https,还需要进过TLS的握手才建立连接。

频繁的请求不仅会极大的消耗设备CPU、电量等性能,而且也对app的线程资源和内存等造成负担,因此,对于网络请求应该尽量减少,能够合并的请求就尽量合并。当然,这也涉及到client/server的架构设计,需要在服务端网络接口设计和优化时双向协商。

对于客户端开发来说,我们可以从两个方面考虑:

一方面,一次请求适当拉取更多的数据,一次性向服务器请求多个紧密联系模块的数据;

另一方面,如果服务器没有足够弹性的接口,那么尽可能紧密的连续执行几个紧密联系模块的数据请求。

5.6 离线数据缓存

对于类似图片、文件等重度数据,可以使用内存缓存、磁盘缓存的方式实现多级缓存策略,不仅在手机应用中可以节省用户流量,重要的是避免网络延迟,提升用户体验。

具体的图片、数据缓存实现已有成熟的开源框架可供选择,在此不作细表,开发人员根据具体条件进行设置即可。

对于使用HttpURLConnection的情况,android从Api Level 14开始提供了HttpResponseCache类相关缓存接口;

5.6.1 HttpURLConnection缓存示例

首先设置缓存:

protected void onCreate(Bundle savedInstanceState) {
    try {
        File httpCacheDir = new File(getCacheDir(), "http");
        long httpCacheSize = 0;
        HttpResponseCache.install(httpCacheDir, httpCacheSize);
    } catch (IOException e) {
        Log.i(getClass().getName(), "HTTP response cache installationfailed:" + e);
    }
}

在退出时刷新缓存:

protected void onStop() {
    
    HttpResponseCache cache =HttpResponseCache.getInstalled();
    if (cache != null) {
        cache.flush();
    }
}

在下次获取数据时,添加需要应用的缓存策略:

connection.addRequestProperty("Cache-Control",POLICY);

POLICY的值包括:

l  no-cache:完全使用网络下载;

l  max-age=SECONDS:client将接收age小于该值的数据

l  max-stale=SECONDS:client将接收expiration小于该值的数据

l  only-if-cached:仅使用缓存,如果缓存文件没有的话,抛出FileNotFoundException异常。

5.6.2 Okhttp缓存示例


public class RetrofitManager {
    private static OkHttpClient mOkHttpClient;
    private static final String TAG = "OkHttp";

    //短缓存1分钟
    public static final int CACHE_AGE_SHORT = 60;
    //长缓存有效期为1天
    public static final int CACHE_STALE_LONG = 60 * 60 * 24;
    //自己的base_url
    public static final String BASE_URL = "http://www.baidu.com";

    private ApiServer apiServer;
    private static RetrofitManager instance = new RetrofitManager();

    public static RetrofitManager getInstance() {
        return instance;
    }

    private RetrofitManager() {
        initOkHttpClient();
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(BASE_URL)
                .client(mOkHttpClient)
               .addConverterFactory(GsonConverterFactory.create())
                .build();
        apiServer = retrofit.create(ApiServer.class);
    }

    private void initOkHttpClient() {
        //日志拦截,使用okhttputils的HttpLoggingInterceptor,设置请求级别
        HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() {
            @Override
            public void log(String message) {
                Log.e(TAG, message);
            }
        });
        interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
        //缓存的cookieJar负责管理cookies
        com.zhy.http.okhttp.cookie.CookieJarImplcookieJar = new com.zhy.http.okhttp.cookie.CookieJarImpl(new PersistentCookieStore(App.getInstance()));
        //单例
        if (mOkHttpClient == null) {
            synchronized (OkHttpClient.class) {
                if (mOkHttpClient == null) {
                    // 指定缓存路径,缓存大小10Mb
                    Cache cache = new Cache(new File(App.getInstance().getCacheDir(), "HttpCache"),
                            1024 * 1024 * 10);
                    mOkHttpClient = new OkHttpClient.Builder()
                            .cache(cache)
                           .addInterceptor(interceptor)
                           .addNetworkInterceptor(mCacheControlInterceptor)
                           .cookieJar(cookieJar)
                           .retryOnConnectionFailure(true)
                            .connectTimeout(60, TimeUnit.SECONDS)
                            .readTimeout(60, TimeUnit.SECONDS)
                           .writeTimeout(60, TimeUnit.SECONDS)
                            .build();
                }
            }
        }
    }

    public static ApiServer build() {
        return instance.apiServer;
    }

    // 响应头拦截器,用来配置缓存策略
    private Interceptor mCacheControlInterceptor = new Interceptor() {
        @Override
        public Response intercept(Chain chain) throws IOException {
            Request request =chain.request();
            if (!NetUtils.isConnected(App.getInstance())){
                //没网时只使用缓存
                //自定义请求头,可以在响应头对请求头的header进行拦截,配置不同的缓存策略
                request = request.newBuilder()
                        .header("head-request", request.toString())
                       .cacheControl(CacheControl.FORCE_CACHE)
                        .build();
            }
            Response response =chain.proceed(request);
            if (NetUtils.isConnected(App.getInstance())){
                //有网的时候读接口上的@Headers里的配置,你可以在这里进行统一的设置
                Log.e("Interceptor", "response: " + response.toString());
                //添加头信息,配置Cache-Control
               //removeHeader("Pragma") 使缓存生效
                return response.newBuilder()
                        .header("Cache-Control", "public, max-age=" + CACHE_AGE_SHORT)
                        .removeHeader("Pragma")
                        .build();
            } else {
                Log.e("Interceptor", "net not connect");
                return response.newBuilder()
                        .header("Cache-Control", "public,only-if-cached,max-stale=" + CACHE_STALE_LONG)
                        .removeHeader("Pragma")
                        .build();
            }
        }
    };

}

5.7 优化重连机制

网络请求失败特别是超时时,我们通常会启动重连机制,进行若干重试。在重试时容易产生的问题是持续轮询,并且简单在线程中采用sleep的方式进行唤醒。

出于性能的考虑,不应该在应用中过度的轮询,如果有必要,则至少从以下两点做好优化:

一方面,轮询不能简单的重复间隔执行,对轮询周期时间,要采用逐步扩大的方式进行设计,比如阶梯倍增,同时控制好重试次数。如下所示:

public class Backoff {
    private static final int BASE_DURATION = 1000;
    private static final int[] BACK_OFF = new int[]{1, 2, 4, 8,
            16, 32, 64};
    public static InputStream execute(String urlString) {
        for (int attempt = 0; attempt < BACK_OFF.length; attempt++) {
            try {
                URL url = new URL(urlString);
                HttpURLConnection connection =
                       (HttpURLConnection) url.openConnection();
                connection.connect();
                return connection.getInputStream();
            } catch (SocketTimeoutException |
                    SSLHandshakeExceptione) {
                try {
                    Thread.sleep(BACK_OFF[attempt] *
                            BASE_DURATION);
                } catch (InterruptedException ex) {
                    throw new RuntimeException(ex);
                }
            } catch (Exception e) {
                return null;
            }
        }
        return null;
    }
}

另一方面,如上示例,在重试等待的时候,采用Thread.Sleep()简单的在线程中等待,消耗线程资源,同时占用CPU,如果轮询间隔较大时,则耗费比较严重,比较好的做法是使用系统的AlarmManager来实现定时,可以保证在系统休眠时,CPU也可以得到休眠,在下一次需要发起网络请求的时候才唤醒。

5.8 压缩数据

从节省移动数据流量和提高应用响应速度等方面考虑,我们需要减少网络传输数据,对于客户端来说,主要从两方面考虑数据的压缩:

一方面,可以对发送给服务端的数据进行压缩,通常是GZIP格式;

另一方面,采用更优的数据传输格式,要求使用json或者更优的格式来承载data,考虑WebPage格式代替JPEG/PNG来承载图片。

5.9 数据序列化解析

网络请求通常伴随着数据的序列化和响应数据的解析工作,目前比较通行的是json格式。尽管Android提供了原生的Json解析Api,但是速度较慢,且接口不够简洁。所以一般选用其他优秀的json解析库。

5.9.1 Json库的选择

考虑到性能和持续更新能力,我们推荐的是官方Gson和阿里Fastjson。其中Fastjson是针对java平台的库,如果纯粹android平台使用,可以采用其Android剪裁版本。

另外,LoganSquare是近两年崛起的库,针对android进行了特别优化,根据其作者在MotoX机型的测试数据,是完胜Gson的,特别在解析大JSON文件的时候性能比GSON好太多。

备注:

1 还在使用Eclipse的同学,无法使用LoganSquare,其只支持Gradle配置,GITHUB地址:https://github.com/bluelinelabs/LoganSquare

2 LoganSquareEntity类采用注解方式,并提供了回调,增加一定的工作量的同时,提高了灵活性。

5.9.2 优化Json对象结构

优化的主要目的是减少不必要的数据结构冗余。如下所示的Json文件:

[
        {
        "level": 23,
        "name": "Marshmallow",
        "version": "6.0"
        }, {
        "level": 22,
        "name": "Lollipop",
        "version": "5.1"
        }, {
        "level": 21,
        "name": "Lollipop",
        "version": "5.0"
        }, {
        "level": 19,
        "name": "KitKat",
        "version": "4.4"
        }
]

其采用了若干个Json对象数组的结构方式,其实可以优化为普通Json数组的结构方式,如下所示:

{
    "level": [23, 22, 21, 19],
    "name": ["Marshmallow", "Lollipop", "Lollipop", "Kitkat"],
    "version": ["6.0", "5.1", "5.0", "4.4"]
}

简化的Json格式不久减小了数据大小,而且整体为一个Json对象,解析速度也必将大大提高。

PS: 如果是用过Python、JS的同学,可以直观的感受到zip函数在此处的魅力了,Java同学表示有小情绪了。

5.9.3 避免本地存储序列化

序列化工作使得数据以一种轻量化的方式在不同的环境中传输,对于网络通信来说是非常有益的。但是,应该避免在本地存储中使用序列化,因为序列化和反序列化工作是比较耗时的。

一个典型例子是,将JSON文件缓存到本地,如此每次使用的时候,必须进行反序列化;更进一步,如果需要修改内容,则通常需要整体序列化后再覆盖。这和使用sqlite数据库等方式相比,开销较大。

猜你喜欢

转载自blog.csdn.net/qq_16206535/article/details/79935148