雪球 Android 客户端网络框架改造实践

作者:孙泉 前言 众所周知,所谓网络框架是一组网络通信能力的封装,为 APP 的数据传输提供技术基础,其重要性不言而喻。之前雪球使用的网络框架已经多年未更新,存在诸多问题,比如: 接入复杂:业务层需要

图片

作者:孙泉

前言

众所周知,所谓网络框架是一组网络通信能力的封装,为 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(outJsonWriter, 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

(0)
上一篇 2023年 4月 21日 下午10:20
下一篇 2023年 4月 21日 下午10:20

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

联系我们YX

mu99908888

在线咨询: 微信交谈

邮件:itzsgw@126.com

工作时间:时刻准备着!

关注微信