贝壳 APP 页面预加载实现思路
The following article is from 贝壳产品技术 Author 王芳
1.背景
一个页面从意图打开到用户可看,可以简单抽象成下面的流程:
图1 页面启动流程
整个流程可以分为三个步骤,页面的启动初始化、发送网络请求以及网络请求拿到数据后页面的刷新,整个过程是一个串行的流程,页面加载总的耗时为T=A+B+C。页面刷新指视图重新渲染的时间,其耗时跟页面视图层级的复杂度有关,优化空间一般不大,并且需要针对不同的页面做不同的优化。网络请求的耗时受网络状态的影响比较大,通常占整个耗时的很长一部分时间。在现有的流程中,网络请求需要等页面启动初始化后才能发送,我们期望将页面启动、初始化与网络请求并行进行,让网络请求提前发送,从而减少整个页面的加载时长,提高页面的响应速度。优化的整体思路如图2所示:
图2 优化思路
怎么将网络请求提前预加载呢?需要考虑以下问题:
1.对每个页面,在什么时间点去触发网络请求预加载;
2.怎么保证发送到服务端的网络请求只有一次,客户端的优化对服务端不会造成压力;
3.由于网络状况的差异,网络数据返回与页面刷新之间的同步、异步问题如何处理;
4.怎么设计一种通用的框架适用于所有的页面,并对原有的业务逻辑侵入小。
网络请求预加载框架能够通用于所有的页面,同时能够对原有的业务逻辑无侵入,是一件有挑战但是很必要的事情。目前业务代码的现状是,随着业务的迭代以及不同业务功能采用了不用的页面框架,在现有业务逻辑中,页面网络请求及数据返回后的处理往往与页面的各种逻辑(例如数据的解析、处理以及网络错误)绑定在一起。如果一种网络请求预加载方法需要重构原有的业务代码、修改原有的业务逻辑,这种方式一是工作量大,二是在重构代码中容易产生Bug,在实际的业务需求中会难以推广使用。
本文就向大家介绍我们设计实现的一种对业务代码无侵入的页面网络请求预加载框架,能够在不修改原业务逻辑代码的情况下,让页面具备网络请求预加载的功能并且具有通用性。本框架适用于使用了路由框架(例如阿里ARouter)的页面。
2.设计以及实现
2.1 整体设计
整体设计如下图,主要分成三部分功能:路由拦截器、网络请求拦截器和预加载数据管理中心。
图3 整体设计实现图
业务方如果想让某个页面具有网络请求预加载的功能,只需要使用该页面的路由URL注册该页面需要发送的网络请求。整个框架会在该页面启动之前,根据该URL注册的请求,提前触发网络请求,并将该网络请求返回的数据透明的返回该页面。接入的一个示例如下:
@Route(value =“homepage/”, desc = "预加载请求")
public static ListgetCallList(
@Param(value = “preload_bundle”, desc = "source_global") Bundle bundle) {
ArrayListlist =new ArrayList<>();
HttpCall firstDataCall =
APIService.createService(NetApiService.HostMode.class).getfirstDataCall();
list.add(firstDataCall);
return list;
}
下面我们详细介绍路由拦截器、网络请求拦截器和预加载数据管理中心各个部分的功能。
2.2 路由拦截器
路由拦截器的功能是,添加路由拦截器,触发网络请求的预加载。网络请求的预加载需要解决的第一个问题是在页面启动之前触发网络请求。对所有的页面,怎么找到一个统一的地方去提前触发网络请求呢?
我们选择在路由框架中添加一个路由拦截器,然后在该拦截器中提前发送网络请求。现在App内页面跳转一般由路由框架管理,启动一个新的页面之前统一会经过路由框架,发起方首先传递一个该页面的URL以及必须的参数到路由框架,路由框架然后根据URL跳转到相应的页面。路由框架通常支持在页面跳转之前添加拦截器做一些预处理。我们实现的拦截器整体逻辑如下:
@Override
public boolean intercept(Context context, RouteRequest request) {//RouteRequest封装了页面的Url,Bundle 参数//1.获取该需要预加载的网络请求
ListhttpCallList =
(List) Router.create(request.mUri)
.with(PRELOAD_BUNDLE, request.getBundle())
.call();
//如果没有预加载配置,则返回什么都不做
if(httpCallList == null || httpCallList.isEmpty()) {
return false;
}
//2.对每个请求在Header中添加Preload 字端,//3.将每个请求发送给服务端
for (HttpCall httpCall : httpCallList) {
PreloadManager.addPreloadHeader(httpCall);
httpCall.enqueue(new PreloadManager.HttpCallBackImpl());
}
return false;
}
路由拦截器的作用可以细分以下三点:
2.2.1 获取需要提前发送的网络请求
一个页面如果想支持预加载网络请求的功能,会通过该页面的URL注册需要发送的网络请求。路由拦截器首先会获取该页面需要提前加载的网络请求。
2.2.2 标识网络请求是预加载的请求
不是所有的页面都会支持预加载,另外还有一些请求(例如获取系统的配置信息)跟页面没有关系,此处添加一个字端标识该请求是预加载请求的目的是为了网络层的拦截器可以区分处理,后面网络拦截器部分会做详细介绍。
那怎么标识该请求是预加载的网络请求呢?我们的方法是扩展http协议的header字段,具体做法是每一个请求的header增加了"is_preload"字段,该字段的值为1表示是预加载请求,为0表示不是预加载请求。对于拦截器中触发的预加载请求,其header头中的"is_preload"字段值为1;其他没有做处理的网络请求,"is_preload"字段值默认为0。
public static void addPreloadHeader(HttpCall httpCall) {
Request httpRequest = httpCall.request();//1.构造新的Header
Headers headers = httpRequest.headers().newBuilder().add(“is_preload”,
1).build();
//2.通过反射的方式将构造的包含’is_preload’字段的Header赋值给httpRequest
Field headerField = null;
try {
hhttpeaderField = Request.class.getDeclaredField("headers");
Field modifiersField = Field.class.getDeclaredField("accessFlags");
modifiersField.setAccessible(true);
modifiersField.setInt(headerField, headerField.getModifiers() & ~Modifier.FINAL);
headerField.setAccessible(true);
headerField.set(httpRequest, headers);
modifiersField.setInt(headerField, headerField.getModifiers() & Modifier.FINAL);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
}
2.2.3 触发预加载请求
如果该页面获取到的网络请求不为空,就会将获取到的封装好的请求,在页面启动之前发送给服务端,起到网络请求预加载的目的。
网络请求预加载后,下面的处理就是怎么将预加载请求的数据返回给页面。
2.3 网络层拦截器
Android目前流行被广泛使用的网络框架是Retrofit+Okhttp。我们在Retrofit添加了网络层拦截器。该拦截器的作用是将预加载请求从网络返回的数据,返回给页面发出的网络请求。网络层不仅会收到支持预加载页面发起的预加载请求以及页面正常发起的请求,还有其他页面或者服务发起的正常请求,那网络层怎么区分处理这些不同的请求呢?具体做法是根据header中的is_preload 字段区分:该字段的值为1表示是预加载请求,为0表示不是预加载请求。
该拦截器的作用是将预加载的请求从服务端拿到的数据封装返回给页面发送的网络请求,同时从页面发送的网络请求不再向服务端请求新的数据,保证发送到服务端的网络请求只有一次避免给服务端造成压力,中间有一层转换的过程,具体流程见图3网络库部分。实现中一些需要注意的点:
我们通过一个请求的Url(包括请求的参数以及具体值), 将预加载请求与一个页面的正常请求关联起来,如果Url 相等,则认为是相同的业务请求。
从OKhttp中返回的Response 数据流只能被读取一次,为了后续页面请求读取数据流不发生异常,对于预加载的网络请求构造了一个Body 内容为空的response返回给上层
具体的逻辑如下:
public class PreloadInterceptor implements Interceptor {
public static final String PRELOAD_HEADER = "isPreload";
public static final String PRELOAD_ON = "1";
public static final String TAG = "PreloadInterceptor";
@Override
public Response intercept(Chain chain) throws IOException {
Response response = null;
final Request request = chain.request();
//1.预加载的请求,数据缓存到PreloadResultManager
if (PRELOAD_ON.equals(request.headers().get(PRELOAD_HEADER))) {
//预加载请求返回一个Body内容为空的response
response = getNewResponse(result);
} else {
//2.其他普通请求
//2.1 已经预加载过的请求
if (PreloadResultManager.getInstance().hasRequst(
request.url().url())) {
//从缓存数据管理中心PreloadResultManager获取数据,需要处理同步/异步的逻辑
response = PreloadResultManger.getInstantce().geResult();
} else {
//2.2 没有预加载的请求不做任何处理
response = chain.proceed(request);
}
return response;
}
}
}
2.4 预加载数据管理中心
该模块的主要作用是管理预加载网络请求返回数据与页面正常发送请求之间的时序同步关系,有以下两种可能的情况:
当网络请求返回数据早于页面正常发送的请求时,首先会缓存结果;然后在页面发出请求时,直接将数据返回
当网络请求返回数据晚于页面正常发送的请求时,页面请求首先会注册监听器, 等到预加载数据返回时,调用监听器将数据返回
预加载拦截器和预加载数据管理中心两个模块是整个功能的核心,通过这两个模块的协作, 将预加载的数据拦截并返回给页面的正常请求,使上层业务无感。整体流程如下图:
图4 数据请求返回流程
3.效果
优化之前整个页面的加载时长为T=A+B+C,优化之后页面加载时长为T=max(A, B) + C, 提升的时间为min(A,B)。当A和B的时间越接近时,预加载对整体的贡献就越大。
当A>B 时,提升的时间为B,为整个网络请求的耗时
例如首页的预加载在app初始化后开始,一般情况下后续页面启动和初始化耗时用的时间大于网络请求的耗时;
这种情况下可以提高的速度为网络请求的耗时。
例如从首页的球区第一次进入成交列表页面,由于加载插件耗时比较长,提高的速度为网络请求耗时的时间。
当A<=B 时,提升的速度为A,为页面的启动初始化时间
除了上面列举的两种特殊情况,路由框架下触发的网络请求节省的时间一般为页面启动初始化的时间。
本地测试和线上性能监控的数据显示,页面用预加载框架节省的时间平均是100ms左右。贝壳App的页面都是基于路由跳转的,加载时长较长的核心页面都接入了该框架。为了支持其他APP的接入,已经将该框架抽离成独立的SDK, 下一步会助力更多业务优化页面的加载时长,提升用户体验。
---END---
推荐阅读