有赞 Android 客户端网络架构演进

Android 客户端网络请求是每一个应用都不可或缺的模块,其设计的好坏直接影响应用的性能和代码稳定性、扩展性。Android 网络请求最开始官方只提供了最基础的方法,开发者必须在此基础上进行二次封装,这样就要求开发者对 Http 请求协议、缓存、JSON 转换、错误处理以及线程切换等都比较熟悉,稳定性、可扩展性和可维护性都是比较大的挑战。

目前 Android 主流的网络请求都是基于 Square 公司开发的 OkHttp,该框架也得到了 Google 官方的认可,OkHttp 对网络请求做了大量的封装和优化,极大降低了开发者的使用成本,同时兼备稳定性和可扩展性。目前有赞 Android 客户端也是采用 OkHttp 进行网络请求,在 OkHttp 框架的基础上做了大量的封装和优化,减小业务逻辑与框架的耦合性的同时,也极大降低了业务方的使用成本。

1. 现在的网络请求

先以 Get 请求为例,代码如下:

private static final String TRADE_CATEGORIES = "kdt/tradecategories/get"; 
/**
 * 获取订单类型
 *
 * @param context context
 * @param callback TradeCategoryEntity callback
 */
public void requestTradeCategories(Context context,Map<String, String> params, BaseTaskCallback<List<TradeCategoryEntity>> callback) {
    RequestApi api = createRequestTokenApi(TRADE_CATEGORIES);
    if (null != params) {
        api.addMultipleRequestParams(params);
    }
    api.setMethod(RequestApi.Method.GET);
    api.setResponseKeys(GoodsTradeApi.ResponseKey.RESPONSE, "categories");
    doRequest(context, api, true, callback, TaskErrorNoticeMode.NONE);
}

针对上述 Get 请求需要几点说明:

  1. 每个模块都需要一个单独的网络请求类,罗列该模块所有的业务请求方法
  2. 请求 url 需要在每个请求类中定义,如上文中的TRADE_CATEGORIES
  3. 每次请求都需要调用createRequestTokenApi方法,完成 token 参数的封装
  4. 请求参数可以直接通过addRequestParams添加,也可以以 Map 的形式统一添加addMultipleRequestParams
  5. 请求方法通过setMethod方法设置
  6. 返回值可以通过setResponseKeys指定需要的具体 JSON 节点
  7. 请求结果通过BaseTaskCallback回调,返回值类型也可通过泛型方式指定
  8. 回调方法包括请求成功、请求失败以及请求时机等方法

以下是请求结果回调方法代码:

// 请求开始的回调方法
public void onBefore(RequestApi api) {

}
// 请求结束的回调方法
public void onAfter() {

}
// 请求成功的回调方法,data表示返回值,statusCode表示请求状态值
public void onResponse(T data, int statusCode){
  
}
// 网络请求失败的回调方法
public void onError(ErrorResponse errorResponse) {

}
// 请求的业务错误回调方法
public void onRequestError(ErrorResponse errorResponse) {

}

业务方只需要在不同的回调方法中处理不同的业务即可,比如说在onBefore中显示进度条,在onAfter中隐藏进度条,在onResponse中获取返回值以及状态值,onRequestError中获取错误类型,以及显示错误提示等等。

针对上述请求过程,可以发现还是有一些不是很合理的地方。

  1. 每次新建一个网络接口方法都需要设置请求 url、封装 token、设置请求方法等,造成了大量的重复代码
  2. 请求 url 和具体的请求方法分开定义,不够直观
  3. 如果请求参数比较多的时候,使用 Map 方式容易写错,而且在编译过程和自测阶段不会有错误提示
  4. 请求成功回调方法的线程切换还是需要借助 Handler 等方式实现

2. 改进后的网络请求框架

针对以上问题,我们引入 Square 公司开发的 Retrofit 和 ReactiveX 出品的 RxJava,以下是结合有赞业务后网络请求用法。

首先定义网络请求方法,Retrofit 是通过接口的方式完成请求方法定义的,代码如下:

public interface TradesService {
    @GET("api.tradecategories/1.0.0/get")
    Observable<Response<CategoryResponse>> tradeCategories();

    @FormUrlEncoded
    @GET("api.trade/1.0.0/get")
    Observable<Response<TradeItemResponse>> tradeDetail(@Field("tid") String tid);
}

通过注解的方式指定请求类型、请求 url、请求参数,返回值是 RxJava 的Observable对象,就可以进行一系列的链式操作,具体用法等一下会详细讲述。通过对比两种请求方法的写法,很显然,通过 Retrofit 定义请求方法更简洁清晰,下面我们来看一下如何通过 RxJava 实现错误信息的统一处理。

2.1 请求错误处理

首先定义一个BaseResponse,所有的Response都要继承自它。

public class BaseResponse {
    public static final int CODE_SUCCESS = 0;

    public String msg;
    public int code;
    @SerializedName("error_response")
    public ErrorResponse errorResponse;

    public static final class ErrorResponse {
        public String msg;
        public int code;
    }
}

BaseResponse定义了错误信息,后续所有的Response都会继承BaseResponse,而服务端返回的错误信息都会进行集中处理,处理逻辑代码如下:

 // 下文的T:T extends Response<R>, R: R extends BaseResponse
 public Observable<R> call(Observable<T> observable) {
    return observable.map(new Func1<T, R>() {
        @Override
        public R call(T t) {
            String msg = null;
            if (!t.isSuccessful() || t.body() == null) {
                msg = mDefaultErrorMsg;
            } else if (t.body().code != BaseResponse.CODE_SUCCESS) {
                msg = t.body().msg;
                if (msg == null) {
                    msg = mDefaultErrorMsg;
                }
                tryLogin(t.body().code);
            } else if (t.body().errorResponse != null) {
                msg = t.body().errorResponse.msg;
                if (msg == null) {
                    msg = mDefaultErrorMsg;
                }
                tryLogin(t.body().errorResponse.code);
            }
            if (msg != null) {
                try {
                    throw new ErrorResponseException(msg);
                } catch (ErrorResponseException e) {
                    throw Exceptions.propagate(e);
                }
            }
            return t.body();
        }
    });     

关于上面的代码有几点需要说明:

  1. T 继承了 Retrofit 提供的Response类,该类包含了 Http 返回值、协议版本号等等通用信息,R 是具体的业务数据结构,如同上文所说,R 继承了BaseResponse,方便统一处理错误信息。
  2. 业务的错误返回值都是事先定义好的,只需要根据返回的错误码和错误类型进行处理即可
  3. 这里的错误类型本质上是利用 RxJava 的 map 转换方法,即将一种Observable转换成另一种Observable,转换的过程中对不同的错误类型进行处理,同时将处理后的结果通过Observable传递出去

2.2 线程切换

正如上文所言,之前的请求方法在请求回调方法中无法方便的切换线程,必须要借助 Android 原生的Handler方式,代码就会比较分散,可读性也比较差,而 RxJava 针对线程切换提供了很友好的处理方法,只需要显式的设置即可完成不同线程的切换。

public class SchedulerTransformer<T> implements Observable.Transformer<T, T> {
    @Override
    public Observable<T> call(Observable<T> observable) {
        return observable
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread());
    }
}

上面的代码主要完成了两部分的工作,subscribeOn声明生产者Observable所在的线程,由于网络请求比较耗时,一般会放到 IO 线程或者单独的子线程;而observeOn声明了处理请求返回值所在的线程为 Android 系统的主线程,也即 UI 线程,这样就可以在处理返回值时更新 UI。

2.3 请求日志

网络请求经常会出现各种各样的异常情况,这个时候就需要通过查看请求日志来跟踪定位问题,OkHttp 提供了打印完整日志的方法,方便开发者调试网络请求。OkHttp 官方提供了一个很方便查看日志的Interceptor,你可以控制你需要的打印信息类型,使用方法也很简单。

首先需要在 build.gradle 文件中引入 logging-interceptor

compile 'com.squareup.okhttp3:logging-interceptor:3.4.1'

在 OkHttpClient 创建处添加创建好的Interceptor即可,完整的示例代码如下:

private static OkHttpClient getNewClient(){
    HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
    logging.setLevel(HttpLoggingInterceptor.Level.BODY);
    return new OkHttpClient.Builder()
           .addInterceptor(logging)
           .build();
}

HttpLoggingInterceptor提供了 4 中控制打印信息类型的等级,分别是 NONE,BASIC,HEADERS,BODY,接下来分别来说一下相应的打印信息类型。

  • NONE

没有任何日志信息
* Basic

打印请求类型,URL,请求体大小,返回值状态以及返回值的大小

  D/HttpLoggingInterceptor$Logger: --> POST /upload HTTP/1.1 (277-byte body)  
  D/HttpLoggingInterceptor$Logger: <-- HTTP/1.1 200 OK (543ms, -1-byte body)  
  • Headers

打印返回请求和返回值的头部信息,请求类型,URL 以及返回值状态码

  <-- 200 OK https://your_url (3787ms)
  D/OkHttp: Date: Sat, 06 Aug 2016 14:26:03 GMT
  D/OkHttp: Content-Type: application/json; charset=utf-8
  D/OkHttp: Transfer-Encoding: chunked
  D/OkHttp: Connection: keep-alive
  D/OkHttp: Keep-Alive: timeout=30
  D/OkHttp: Vary: Accept-Encoding
  D/OkHttp: Expires: Sun, 1 Jan 2006 01:00:00 GMT
  D/OkHttp: Pragma: no-cache
  D/OkHttp: Cache-Control: must-revalidate, no-cache, private
  D/OkHttp: Set-Cookie: bid=D6UtQR5N9I4; Expires=Sun, 06-Aug-17 14:26:03 GMT; Domain=.douban.com; Path=/
  D/OkHttp: X-DOUBAN-NEWBID: D6UtQR5N9I4
  D/OkHttp: X-DAE-Node: dis17
  D/OkHttp: X-DAE-App: book
  D/OkHttp: Server: dae
  D/OkHttp: <-- END HTTP
  • Body

打印请求和返回值的头部和body信息

  <-- 200 OK https://your_url (3583ms)
  D/OkHttp: Connection: keep-alive
  D/OkHttp: Date: Sat, 06 Aug 2016 14:29:11 GMT
  D/OkHttp: Keep-Alive: timeout=30
  D/OkHttp: Content-Type: application/json; charset=utf-8
  D/OkHttp: Vary: Accept-Encoding
  D/OkHttp: Expires: Sun, 1 Jan 2006 01:00:00 GMT
  D/OkHttp: Transfer-Encoding: chunked
  D/OkHttp: Pragma: no-cache
  D/OkHttp: Connection: keep-alive
  D/OkHttp: Cache-Control: must-revalidate, no-cache, private
  D/OkHttp: Keep-Alive: timeout=30
  D/OkHttp: Set-Cookie: bid=ESnahto1_Os; Expires=Sun, 06-Aug-17 14:29:11 GMT; Domain=.douban.com; Path=/
  D/OkHttp: Vary: Accept-Encoding
  D/OkHttp: X-DOUBAN-NEWBID: ESnahto1_Os
  D/OkHttp: Expires: Sun, 1 Jan 2006 01:00:00 GMT
  D/OkHttp: X-DAE-Node: dis5
  D/OkHttp: Pragma: no-cache
  D/OkHttp: X-DAE-App: book
  D/OkHttp: Cache-Control: must-revalidate, no-cache, private
  D/OkHttp: Server: dae
  D/OkHttp: Set-Cookie: bid=5qefVyUZ3KU; Expires=Sun, 06-Aug-17 14:29:11 GMT; Domain=.douban.com; Path=/
  D/OkHttp: X-DOUBAN-NEWBID: 5qefVyUZ3KU
  D/OkHttp: X-DAE-Node: dis17
  D/OkHttp: X-DAE-App: book
  D/OkHttp: Server: dae
  D/OkHttp: {"count":3,"start":0,"total":778,"books":[{"rating":{"max":10,"numRaters":202900,"average":"9.0","min":0},"subtitle":"","author":["[法] 圣埃克苏佩里"],"pubdate":"2003-8","tags":[{"count":49322,"name":"小王子","title":"小王子"},{"count":41381,"name":"童话","title":"童话"},{"count":19773,"name":"圣埃克苏佩里","title":"圣埃克苏佩里"}
  D/OkHttp: <-- END HTTP (13758-byte body)

2.3 统一添加 token 和 User-Agent

现在基本上所有的应用都会通过token来鉴定用户权限,User-Agent参数方便服务端获取更多手机本地的信息,类似这样的每一个请求都需要的参数,也可以通过Interceptor方式实现。

public class LoginInterceptor implements Interceptor {
    public static final String ACCESS_TOKEN = "access_token";
    public static final String USER_AGENT = "User-Agent";

    @Override
    public Response intercept(Chain chain) throws IOException {
        Request original = chain.request();
        Request request = original.newBuilder()
                .header(USER_AGENT, AppUtils.getUserAgent() + " app_name/" + AppUtils.getVersionName())
                .header(ACCESS_TOKEN, AppUtils.getToken())
                .method(original.method(), original.body())
                .build();
        return chain.proceed(request);
    }
}

2.4 通过对象封装请求体

有的时候post请求的参数会有很多个,如果一个个写,那么方法的参数就显得很多,杂乱且不好维护,当然可以用Map的方式集中管理,但是Map有一个问题就是一旦key值写错了,前期是无法及时发现的,必须等到真正的网络请求的时候才能发现。Retrofit提供了一个很好的解决方法,通过对象封装这些请求参数。

@FormUrlEncoded
@POST("book/reviews")
Observable<Response<String>> addReviews(@Body Reviews reviews);

public class Reviews {
    public String book;
    public String title;
    public String content;
    public String rating;
}

需要注意的是类中的属性名必须要和请求参数的 key 保持一致,否则服务端无法正常识别请求参数

关于 Retrofit 详细的用法可以参考文章Retrofit 详解

3. 如何使用

我们以获取Category为例来说明如何利用RetrofitRxAndroid来改写现有模块。

3.1 定义 CategoryResponse

CategoryResponse必须继承自BaseResponse,里面包含了错误信息的数据结构。

@Keep
public class CategoryResponse extends BaseResponse {
    public Response response;

    @Keep
    public static final class Response {
        public List<Category> categories;
    }
}

其中Category是具体的实体类型。

3.2 定义 Service Method

public interface TradesService {
    @GET("api/tradecategories/get")
    Observable<Response<CategoryResponse>> tradeCategories();
}

注意点

  • TradesService必须是一个interface,而且不能继承其他interface
  • tradeCategories的返回值必须是Observable类型。

3.3 利用 ServiceFactory 创建一个 TradeService 实例

在适当的时机(Activity#onCreateFragment#onViewCreated等)根据网关类型通过ServiceFactory创建一个TradeService实例。

mTradesService = ServiceFactory.createNewService(TradesService.class)

3.4 TradeService 获取数据

mTradesService.tradeCategories()
        .compose(new DefaultTransformer<CategoryResponse>(getActivity()))
        .map(new Func1<CategoryResponse, List<Category>>() {
            @Override
            public List<Category> call(CategoryResponse response) {
                return response.response.categories;
            }
        })
        .flatMap(new Func1<List<Category>, Observable<Category>>() {
            @Override
            public Observable<Category> call(List<Category> categories) {
                return Observable.from(categories);
            }
        })
        .subscribe(new ToastSubscriber<Category>(getActivity) {
            @Override
            public void onCompleted() {
                hideProgressBar();
                // business related code
            }

            @Override
            public void onError(Throwable e) {
                super.onError(e);
                hideProgressBar();
                // business related code
            }

            @Override
            public void onNext(Category category) {
                // business related code
            }
        });

注意:DefaultTransformer包含了线程分配错误处理两部分功能,所以调用方只需要关心正确的数据就可以了。

DefaultTransformer包含了上文提到的线程切换转换器SchedulerTransformer和错误处理转换器ErrorCheckerTransformer,具体代码如下:

public class DefaultTransformer<R extends BaseResponse>
        implements Observable.Transformer<Response<R>, R> {

    private Context context;

    public DefaultTransformer(final Context context) {
        this.context = context;
    }

    @Override
    public Observable<R> call(Observable<Response<R>> observable) {
        return observable
                .compose(new SchedulerTransformer<Response<R>>())
                .compose(new ErrorCheckerTransformer<Response<R>, R>(context));
    }
}

4. 写在最后

网络请求对于提升 Android 应用的体验和性能有很大的影响,结合 Retrofit 使用提高了代码可读性和编码的灵活性,RxJava 提供了链式调用方式,融合了线程切换、数据过滤、数据转换等优点。有赞在此基础上进行了少量的封装,便可适应大部分复杂的业务场景。