作者:孙泉
前言
众所周知,所谓网络框架是一组网络通信能力的封装,为 APP 的数据传输提供技术基础,其重要性不言而喻。之前雪球使用的网络框架已经多年未更新,存在诸多问题,比如:
-
接入复杂:业务层需要写很多冗余代码
-
性能表现不佳:在网络延迟和传输速度等方面不够高效
-
架构不合理:存在像公共参数扩展,通用错误处理,MVVM 页面架构等场景兼容问题
-
功能薄弱:比如上传下载等操作,均无法支持
-
开发社区不活跃:通过行业调研,目前已经不是业内主流方案
-
调用方式碎片化:雪球 APP 通过组件化方式集合了社区,基金,证券等多个子工程,调用方式各不一致,极其不利于代码规范统一,影响开发效率
因此,结合当下业务需求,封装一套调用简洁、架构合理、功能完善、高可用的网络框架,同时平滑迁移到当前项目中,变得迫在眉睫。
下面用一张对比图形象阐述这次改造的目标:
为了避免重复造轮子,我们使用 OkHttp 和 Retrofit 作为网络框架的底层支持。在每次接触一个技术栈的时候,首先会在脑海里过三个问题:
-
它是什么(what)?
-
为什么用它(why)?
-
如何用它(how)?
结合之前讲到的业务痛点,再把这三个问题连成线,就会知道为什么使用它以及如何通过它来改造我们的网络框架。
它是什么(what)?
OkHttp
它是一个专注于连接效率的 HTTP 客户端。OkHttp 提供了对 HTTP/2 和 SPDY 的支持,同时提供了连接池,GZIP 压缩和 HTTP 响应缓存等功能。
Retrofit
它是 squareup 公司的开源力作,因有着简易的接口配置、强大的扩展支持、优雅的代码结构等特点而受到广泛欢迎。Retrofit 是一个 RESTful 风格的 HTTP 网络请求框架的封装。这里并没有说它是网络请求框架,主要原因是网络请求的工作并不是 Retrofit 来完成的。Retrofit2.0 开始内置了 OkHttp,前者专注于接口的封装,后者专注于高效的网络请求,二者分工协作。就好像OkHttp 是一款的发动机,而 Retrofit 是汽车外壳和零部件,它不止使发动机的优秀性能完美发挥,还能方便我们改装这辆汽车,成为我们的心爱之物。
为什么用它(why)?
市面上的开源网络基础库有很多,我重点调研了 volley,xUtils,Android-async-http,Afinal,Retrofit 这几个网络框架。在做选择的时候,我主要从以下三个方面进行考虑:
-
架构合理性
-
高可用性
-
是否维护更新
架构合理性
正常满足我们开发需求的所谓的网络架构,其实包含两部分:一个是基于 HTTP 协议帮我们拼接请求报文、发起请求、收到服务器响应和预处理响应报文的部分;而另一个就是二次封装以便我们更灵活、更高效使用的部分。结合团队目前使用的基于 RxJava 的 MVVM 页面架构,OkHttp 和 Retrofit 就刚好扮演了这两个角色,这也是其他网络框架所不具备的。
高可用性
在高可用方面,由于 Retrofit 是基于 OkHttp 的,所以继承了 OkHttp 的 Okio,连接池复用,数据压缩等优秀特性。相比于其他网络库,速度非常快。下面是几个网络库的性能对比数据:
不难看出,在建立1次连接、7次连接、25次连接的速度表现上,OkHttp+Retrofit 远好于 Volley 和 Android-async-http。
是否维护更新
通过调研,volley、xUtils、Afinal 均已不再更新,Android-async-http 由于 Android6.0 以后不再使用 Apache-http 包,目前也处于废弃状态。而 Retrofit 和 OkHttp 日前还持续维护,社区也非常活跃,可以说是业内主流方案。
小结
对于 why,其实远不止上面说的几点。比如文件上传:volley 框架限制不适合做文件上传,客户端写了很多上传相关代码;再比如说,当多个请求需要组成链式请求的时候,代码也会写的很繁琐。所以 OkHttp 和 Retrofit 的出现,能够解决许多开发中遇到的实际问题,这就是为什么选择 OkHttp 和 Retrofit 的原因。
如何用它(how)?
当我们了解了它是什么,并且说服自己去使用它的时候,那么就要知道它是怎么用的?如何才能够基于它去搭建一套适合我们现有业务的网络框架?
对于 Retrofit 的基本使用,网上有很多相关例子,这里就不一一介绍了,我重点阐述以下几点:
-
改造过程中遇到哪些问题?是如何解决的?
-
项目中是如何基于 OkHttp 和 Retrofit 进行网络框架封装?
-
如何进行网络请求的接口迁移?
遇到哪些问题?
在基于 OkHttp 和 Retrofit 进行网络改造过程中,为了兼容一些特定业务场景,会遇到各种各样的坑,这里着重介绍以下几个问题的解决过程:
-
域名切换
-
自定义注解
-
数据校验
域名切换
众所周知,提升网络接入质量几乎是所有移动项目的需求。很多项目都会引入 HTTP DNS 作为网络接入最基础也是最重要的优化之一。HTTP DNS 的核心是后台下发某个域名对应的最优 IP,基础点可做到就近接入,甚至可以根据线上用户实际测速数据下发最优的 IP。而客户端需在 HTTP 接入时,将 URL 中的 HOST 从域名直接替换为后台下发的 IP。IP 直连相对于域名接入的好处包括:
-
省去 DNS 解析这一步,减少耗时
-
就近接入甚至就快接入,减少耗时
-
避免 DNS 劫持
-
当终端有多个 IP 接入选择时,具备一定的容灾能力
雪球的域名是动态 IP,IPManager 负责对域名表进行统一管理,完全独立于网络框架。相比于每个请求都创建一个 Retrofit 并设置 BaseUrl,使用 Interceptor 对 URL 进行拦截并修改 IP 的方式,在架构和性能上无疑是更优选择。部分实现代码片段如下:
fun getUrl(oldHttpUrl: HttpUrl): URL {
val path: String? = oldHttpUrl.toUrl().path
val host: String
val protocol: String
var newFullUrl: HttpUrl
try {
//通过IPManager获取到对应的协议(http or https?)
protocol = IPManager.getInstance().protocol
//通过IPManager获取到path对应的IP
host = IPManager.getInstance().matchedDomain(path)
//重新组装新的url
newFullUrl = oldHttpUrl.newBuilder()
.scheme(protocol) // 更换protocol
.host(host) //更换host
.encodedPath(path!!)
.build()
// 增加公共参数
val sign = generateSign(newFullUrl.toUrl().toString(), getParams(newFullUrl))
newFullUrl = newFullUrl.newBuilder()
.addQueryParameter(
URLEncoder.encode(PARAM_KEY_TRACE_ID, UTF_8),
URLEncoder.encode(mTraceIdGenerator.traceId(), UTF_8)
)
...
return newFullUrl.toUrl()
} catch (throwable: Throwable) {
}
return oldHttpUrl.toUrl()
}
另外需要注意的是,由于请求的 HOST 被替换成了 IP,导致底层在进行证书的 HOST 校验时会失败。解决起来比较简单,OkHttp 提供了接口,允许终端设置证书 HOST 校验策略。只需要在使用 IP 访问时设置不做证书验证即可。部分代码片段如下:
override fun verify(hostname: String, session: SSLSession): Boolean {
//使用ip访问不需做证书验证,此时不存在dns污染问题
if (Patterns.IP.matcher(hostname.trim { it <= ' ' }).find()) {
return true
}
return HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session)
}
自定义注解
在某些场景下,经常需要针对个别请求做特殊处理,比如对通用错误码执行个性化处理(当遇到服务器错误时,通用逻辑是弹框提示错误信息,但是在雪球交易模块,需要引导用户跳转到其他页面进行支付设置相关操作);还有像首页 timeline 这种支持频繁下拉刷新的接口,为了降低服务器压力,同时避免出现列表数据重复或错乱等情况,需要针对这一类接口进行防重保护。我们的接口都是在 Api 中声明的,因此为了降低侵入性,在定义接口的地方通过增加自定义注解,来确定哪些接口需要做特殊处理,是目前想到最合适的解决方案。
以屏蔽通用错误处理为例,首先我们需要自定义一个 DisableCommonError 注解:
/** * 自定义注解,判断是否屏蔽通用错误 */
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class DisableCommonError
这个注解如何使用?这是接下来需要考虑的问题。使用过 Retrofit 的都知道,我们之所以能够做到仅仅定义 Api 接口,却无需写实现类就能完成网络请求,是因为 Retrofit 运用了动态代理机制,在运行时通过 ClassLoader 加载了一个代理 Api 实现类,具体的实现细节这里不做讨论,有兴趣可以研究 Retrofit 源码。基于它的这个设计思路,我们在基础架构层修改 buildApi 方法,使用双重动态代理的思想来实现获取方法上的注解。部分代码片段如下:
override fun <T> buildApi(service: Class<T>): T {
val Api = mRetrofit!!.create(service)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
java.lang.reflect.Proxy.newProxyInstance(
NetManager::class.java.classLoader,
arrayOf<Class<*>>(
service
)
) { _, method, args ->
val disableCommonErrorAnnotation =
method.getDeclaredAnnotation(DisableCommonError::class.java)
val needDisableCommonError = disableCommonErrorAnnotation != null
var url: String? = null
for (annotation in method.declaredAnnotations) {
if (annotation is GET) {
url = annotation.value
} else if (annotation is POST) {
url = annotation.value
}
}
//把声明需要屏蔽通用错误的url放入set中
if (needDisableCommonError && url != null) {
DisableCommonErrorSets.add(url)
}
if (args != null) {
method.invoke(Api, *args)
} else {
method.invoke(Api)
}
} as T
} else {
Api
}
}
上面的代码就实现了对 Api 的双重代理,在调用请求方法的同时,可以保存需要屏蔽通用错误接口的 URL。
接下来在响应拦截器里面,去做下判断即可:
private fun modify(throwable: Throwable, path: String) {
if (DisableCommonErrorSets.contains(path)) {
return
}
Pools.MAIN_WORKER.schedule {
// 一些通用的错误,可以放到Application中统一处理
DJApiController.getTokenEventOutput().modify(throwable)
}
}
这里 TokenEventOutput 是定义的全局信号量,来处理通用错误,如果检测到该 URL 则需要屏蔽通用错误,不会使用该信号量发送异常信息。
总体来讲 OkHttp 拦截器和 Retrofit 的设计是非常巧妙的,可以添加各种额外操作。
数据校验
后端接口为客户端提供的数据,大部分场景下是返回 Json 格式字符串,客户端解析后展示,因此返回的 Json 格式的健壮性至关重要。在实际开发中经常出现如下问题:
-
传回的字符串或者数组为 null,使用时若不加空指针判断,容易出现空指针异常
-
测试用的数值为0,结果生成的对象默认为 int 类型,但可能该字段的真实类型为 float,所以之后收到类型为 float 的数据时,就可能导致解析错误
针对上述问题,我们使用了 Retrofit 提供的 addConverterFactory 函数,传入自定义 TypeAdapter 的 Gson 对象解决。
阅读过 Gson 的源码后发现,Gson 的数据解析都是委托到各个 TypeAdapter 内进行处理的。在 Gson 的构造函数内会预先加载一部分 TypeAdapter,包含 String、int、long、double等类型,并存放在 factories 中。我们可以自定义 TypeAdapter,将其放入 factories,让 Gson 在解析 Json 时使用对应的 TypeAdapter,而我们手动添加的 TypeAdapter 会优先于预设的 TypeAdapter 被使用。有兴趣的可以看看 Gson 相关源码,还是比较简单的。
这里以解决问题1为例,我们先定义一个 StringAdapter,代码如下:
private val StringTypeAdapter = object : TypeAdapter<String>() {
@Throws(IOException::class)
override fun write(out: JsonWriter, value: String) {
out.value(value)
}
@Throws(IOException::class)
override fun read(`in`: JsonReader): String? {
if (`in`.peek() == JsonToken.NULL) {
`in`.nextNull()
return transformer?.transformString(null)?: ""
}
return try {
val result = `in`.nextString()
transformer?.transformString(result)?:result
} catch (e: Exception) {
transformer?.transformString(null)?:""
}
}
}
这样在读取到 null 节点时,就自动变成返回空字符串了。数组部分稍微麻烦些,由于 Gson 以数组解析的 Adapter 是不可重写的,需要拷贝出来,重新写了个类,这里就不演示了。
通过 GsonBuilder 的 registerTypeAdapter 方法可以直接注册 TypeAdapter。如下:
private fun initGson(): Gson {
val builder = GsonBuilder()
builder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
...
builder.registerTypeAdapter(String::class.java, StringTypeAdapter)
...
builder.serializeSpecialFloatingPointValues()
return builder.create()
}
最后在初始化 Retrofit 的地方,将我们定义的 Gson 设置上:
Retrofit.Builder()
.baseUrl(mBuilder.baseUrl)
.addConverterFactory(GsonConverterFactory.create(GsonManager.getGson()))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
...
Retrofit 通过策略模式,为接入层提供自定义 Json 解析的入口,不得不说 Retrofit 的解耦方式还是非常巧妙的,很多套路没有多年的实际架构经验是很难设计出来的。
小结
除了上面这几个问题之外,在网络改造的过程中还解决了诸如超时重连,数据压缩,异常监控,文件下载等一系列问题。由于篇幅有限,就不逐个展开。在解决问题的过程中,最大感受就是对于任何一个技术栈,了解如何使用和实际应用到项目中,差别巨大。很多坑只有真正运用时才会遇到,而填坑本身就是一个成长与进化的过程。
如何封装框架?
考虑到扩展性和伸缩性,雪球网络框架采用分层的思想,下图展示了各个层级以及它们之间的关系:
如上所示,整体自下而上分为四层:
-
基础层:网络框架的底层支持
-
基础架构层:对底层库进行封装,并提供基础功能,同时向上提供可定制化接口
-
中间层:针对制定应用进行网络操作初始化和接口定义等,通过调用基础架构层提供的接口实现定制化操作
-
应用层:业务方实际网络请求的发送,结合现有页面架构,实现具体业务逻辑
需要说明的是,对于中间层,各个业务之间会有区别,比如雪球和基金业务差异性造成中间层的不一致,但是上层调用方式需要保持统一。这种通过中间层适配来屏蔽底层差异,从而统一上层调用的设计方式,能够大大提升框架的伸缩性。这种设计在其他场景也很常见,比如 WebView,在 Android4.4 之前内核使用的是 webkit,4.4及以后改为 chrome,但是对于上层开发毫无感知。
基础架构层
基础架构层封装了 OkHttp 和 Retrofit 初始化相关操作,并且提供基础的拦截器,方便客户端快速集成。同时通过 builder 像客户端对外提供一些可自定义的操作,将构建网络的过程和内部部件解耦。大体类结构如下:
其中,NetManager 作为整个网络框架的外观类,负责网络初始化,Api 创建等;NetBuilder 作为网络初始化配置创建类,包括超时时间,文件缓存,拦截器和白名单等等配置项设置;拦截器方面,除了请求相关的基础类之外,还包括响应拦截器分组,用于统一解析响应体并分发到各个响应拦截器处理,避免各个响应拦截器都重复进行解析和数据拷贝,从而造成性能损耗。除此之外,还包括域名,数据解析等基础设施管理类,以及一些工具类,接口,自定义注解等,这里就不一一赘述了。
应用中间层
其功能包括针对特定应用进行网络相关操作的初始化,以及接口定义等,通过调用基础架构层提供的接口实现定制化操作。这里以雪球业务为例,结构如下:
其中 ApiControler 负责网络 SDK 初始化相关工作,并结合雪球业务实现定制化操作;拦截器方面,通过设置公共参数拦截器,设置 cookie,header 等公共参数;通过 URL 拦截器,对域名进行动态替换;除此之外,对于登录状态校验,通用错误等,通过自定义响应拦截器实现,并封装了通用网络异常,通过定义全局的信号量实现统一处理;除此之外,还有一些网络请求的接口(Api),结合工程的页面框架相关技术栈,返回 RxJava 的 Observable 工作对象。
应用层
应用层是业务方实际网络请求的接口调用,同时结合现有的页面架构,实现具体业务逻辑。
这里介绍一下和雪球现有的基于 MVVM 的 onion 页面框架的关系:
上图所示的就是雪球 onion 页面框架,更多细节可以看公众号中另一篇文章- 雪球 Android 客户端页面架构最佳实践[1],这里简要概述该框架各层之间的关系:
-
View:和用户直接交互
-
ViewModel:针对最小业务需求进行开发
-
Model:根据业务类型分层,实现具体业务逻辑的地方
-
Repository:数据源提供层,包括网络数据,本地数据,系统服务等
网络框架在这里扮演着 Remote Api 的角色,我们可以直接为 model 层提供 Observable 工作对象,大大简化了业务层的开发,代码的可读性和逻辑分层上也会更加清晰。
如何迁移?
为了平稳迁移,同时尽快消除和旧网络框架并存所带来的风险隐患,采用了如下迁移方案:
-
通过抽象接口层对网络框架调用方式进行适配
-
在调用新Api请求时,结合 onion 框架,使用 Retrofit 标准调用方式
实现思路如图所示:
其中 Retrofit 可通过定义通用 Api,方便我们进行调用方式的适配,部分代码如下:
@GET("{path}")
fun get(
@Path("path", encoded = true) path: String,
@QueryMap params: Map<String, String>?,
@HeaderMap extraHeader: Map<String, String>?
): Observable<ResponseBody>
@FormUrlEncoded
@POST("{path}")
fun post(
@Path("path", encoded = true) path: String,
@FieldMap params: Map<String, String>,
@HeaderMap extraHeader: Map<String, String>?
): Observable<ResponseBody>
总结
内容回顾:
-
明确网络框架改造的意义和目标
-
关于使用 OkHttp 和 Retrofit 作为网络框架基础库的一些思考
-
通过讲解域名切换、自定义注解、数据校验几个典型问题的解决过程,帮助加深对 Retrofit 和 OkHttp 的理解,体会其中设计巧妙之处
-
介绍雪球如何进行网络框架封装、框架的分层方式、以及各个层级之间的关系等
-
简要介绍新网络框架的迁移过程
雪球客户端团队通过对网络框架进行改造,极大改善了旧网络框架所带来的性能不佳、架构不合理等一系列问题,并且功能足够强大以适应愈加庞大的工程和需求的不断变化。改造过程中通过对 Retrofit 和 OkHttp 的研究,一方面赞叹其精巧的设计实现,另一方面也提升了个人的抽象能力,极大程度避免在纷扰的业务层中迷失自我。
当然,网络框架的改造是一个持续演进的过程。“如何进一步提升网络成功率?”、“如何进行弱网优化?”… 这些问题都是需要持续思考的,未来我们继续不断的对其进行探索和优化。
还有一件事
雪球业务正在突飞猛进的发展,工程师团队期待牛人的加入。如果你对「做中国人首选的在线财富管理平台」感兴趣,希望你能一起来添砖加瓦,点击「阅读原文」查看热招职位,就等你了。
热招岗位:Android/iOS/FE 开发工程师、Java 开发工程师、测试工程师、运维工程师。
参考资料
[1]
雪球 Android 客户端页面架构最佳实践: mp.weixin.qq.com/s/FZ2CXIFtS…
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://itzsg.com/8822.html