http://geek.csdn.net/news/detail/193435
导语: 架构的演进是为业务不断发展服务的,架构不能脱离业务,这是最基本的出发点。58 同城 iOS 客户端随着业务量和用户量的持续增长,架构也是不断受到挑战,采用什么样的架构去适应这些变化,对技术人员来说也是一大考验。58 App 的架构先后经历了纯 Native、引入 Hybrid 框架、底层服务组件化、业务线组件化,即整个 App 组件化的四个阶段。
早在 2010 年 58 同城诞生第一版 iOS 客户端,按照传统的 MVC 模式去设计,纯 Native 页面,这时的功能较为简单,架构也是如此,从上至下分为 UI 展现、业务逻辑、数据访问三层,如图 1 所示。和同期其他公司一样,App 的出发点是为了快速抢占市场,采取“短平快”的方式开发。纯 Native 的 App 在早期业务量不是太大的情况下,能满足业务的需求。
由于苹果审核周期较长,业务需求不断增大,有些业务如果用 Native 进行开发,工作量大投入人员较多,也不能动态更新,如 58 App 的大类、列表、详情页面。这种情况下,用 HTML5 是比较流行的解决方式,由此产生了第二版架构,如图 2 所示,在 UI 层添加了 HTML5 页面及 Hybrid 交互框架。
当时 58 App 设计时用于加载 HTML5 的组件是 UIWebView,也只能使用这个(彼时还没有 WKWebView),但实现起来有几个问题是需要解决的:
为了方便描述,本文先介绍如何提高 HTML5 页面加载速度的问题。
对于一些访问比较频繁的页面,如大类列表详情,我们早期采用的都是 HTML5 页面。要加速这些页面的渲染,就要想办法提升资源的加载。那么如何实现呢?首先想到的是使用缓存,我们可以把这些页面的资源内置到 App 中随版本发布。
由于 UIWebView 在发请求的时候都会走 NSURLCache 的这个方法:
- (nullable NSCachedURLResponse*)cachedResponseForRequest:(NSURLRequest *)request;
我们可以从 NSURLCache 派生出子类 WBHybrid
Component,复写 cachedResponseForRequest:方法,在这之中加载 App 的内置资源,具体加载策略可见图 3。
其中,H5ViewController 为 HTML5 载体页面,WBCacheHandler 为专门处理内置资源类,用于加载、查找、下载、保存内置资源。URL 的 query 中设置版本号参数 cachevers 作为资源缓存的标识,其值为数字类型,假设 cachev1,其与内置资源中的版本号如为 cachev2 进行对比,若 cachev2>= cachev1,表示内置资源中是最新数据,直接给请求返回数据;否则下载新的内置资源,同时根据 cachev1- cachev2 的差值进行判断,如设置一个临界值 x,若差值大于 x,则说明内置资源为旧,给请求返回 nil,否则返回内置数据,让请求先用缓存数据,下次启动时再用新数据。
内置数据采用的是一个 bundle 包,如图 4 所示,CacheResources.bundle 为内置包名,里面包含了一个索引文件和若干个内置数据文件,其中索引文件中每项 item 格式为 key、版本号和文件名。
想要使用自定义的 NSURLCache,必须在 App 启动时初始化 WBHybridComponent,并进行设置,替换默认的 Cache,注意:这个设置必须在所有请求之前进行,否则设置失效,而是采用默认的 NSURLCache 实例,我们曾经踩过这个坑。
// URLCache初始化
WBHybridComponent *hybridComp = [[WBHybridComponent alloc] initWithMemoryCapacity:MEM_CAPACITY diskCapacity:DISK_CAPACITY diskPatch:nil];
[NSURLCache setSharedURLCache:hybridComp]
对于前面所列的第一个问题,我们是要设计一个 Web/Native 的 Hybrid 框架。交互主要包括两部分内容,一是 Native 调用 Web,这个比较简单,直接通过 UIWebView 的 stringByEvaluatingJavascriptFromString:执行一段 JS 脚本,并返回执行结果,本文主要分享 Web 调 Native 的方法。
对于 Web 调 Native 交互的方式,我们采用异步 AJAX 进行,创建一个 XMLHttpRequest 对象,执行 send()进行异步请求,Native 拦截。
xmlhttp.Onreadystatechange= function() {
if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
// 处理返回数据
}
};
xmlhttp.open("GET", "nativechannel://?paras=...”, true);
xmlhttp.send();
由于 XMLHttpRequest 的方式是进行页面局部刷新,并不能被 UIWebViewDelegate
代理的 - (BOOL)webView:(UIWebView )webView shouldStartLoadWithRequest:(NSURLRequest )request navigationType:(UIWebViewNavigationType)navigationType 方法拦截到,设计到这里又出现了新问题,如何让 Native 能拦截到 AJAX 请求呢?
经过一番调研,我们找到了用于缓存的 NSURLCache,对于 UIWebView 中的所有请求(包括 AJAX 请求)都会走 NSURLCache。因此,我们决定采用复用缓存中的 WBHybridComponent 拦截 AJAX 请求,具体 Web 调 Native 的交互设计如图 5 所示。
其中,H5ViewController 为 HTML5 的载体页,WBWebView 是 UIWebView 派生类。WBWebView 中通过 AJAX 发出的异步请求,在 WBHybridComponent 中被拦截,再通过 WBHybridJSHandler 中的 dic 表找到对应的 WBActionAnalysis 对象,然后在 WBActionAnalysis 中分析异步请求传过来的协议,取出 action 字段,再根据 action 值找到 delegate 即 H5ViewController 中对应的方法。
AJAX 发出的请求我们约定为:nativechannel://?paras=
,WBHybridComponent 在拦截时判断 URL 中是否为 nativechannel 的协议头,如果是则为 Web 调起 Native 操作,需要进行后续 Native 处理;否则放过进行其他处理。
的简化格式如图 6 所示,这是二手车大类页点击二手车类目 Web 调 Native 时 AJAX 传过来的协议。
前面我们设计的 Hybrid 框架,通过创建 XMLHttpRequest 对象发送 AJAX 请求的方式能达到 Web 调 Native 的目的,也可以满足业务上的需求,在一段内发挥了重要作用。但随着时间的推移,这个 Hybrid 框架暴露出了一些问题,如下所示。
我们发现 App 中存在大量的内存泄露,经查罪魁祸首竟是 UIWebView。调研发现 UIWebView 中执行 XMLHttpRequest 异步请求时会有内存泄露,网上也有人探讨过这个问题,参考博文:http://blog.techno-barje.fr//post/2010/10/04/UIWebView-secrets-part1-memory-leaks-on-xmlhttprequest/。
Hybrid 交互方式与缓存都使用 NSURLCache 的派生类 WBHybridComponent 执行拦截,其初衷也是用于缓存。我们的 Hybrid 框架将两者耦合在一起,这对于后期的开发和性能优化工作会带来不少隐患。
我们在 Hybrid 交互的时候维护了一个
//创建iFrame元素
variFrame= document.createElement("iframe");
//设置iFrame加载的页面链接
iFrame.src= "nativechannel://?paras=" ;
//向dom tree中添加iFrame元素,以触发请求
document.body.AppendChild(iFrame);
//请求触发后,移除iFrame
iFrame.parentNode.removeChild(iFrame);
iFrame = null;
由于 iframe 方式是整个页面刷新,所以能执行 UIWebViewDelegate
的回调方法 - (BOOL)webView:(UIWebView )webView shouldStartLoadWithRequest:(NSURLRequest )request navigationType:(UIWebViewNavigationType)navigationType。我们可以直接在这个方法中拦截 Web 的调起,iframe 方式处理流程如图 7 所示。
通过 iframe 的方式,我们 App 极大地简化了 Hybrid 框架的交互流程,同时也解决了内存泄露、与缓存功能耦合、消耗不必要的内存空间等问题。
随着业务的进行,一些新的技术需求来了,比如有些基础模块可以从 App 中独立出来进行多应用间的复用;需要为转转 App 提供一个日志 SDK;为违章查询等 App 提供登录的 Passport SDK;为其他 App 提供一个可定制化的分享组件等等。
这时我们迫切地需要在工程代码层面对原来的 App 进行拆分、组件化开发,如图 8 所示。
我们将 App 拆分成三层,从下至上依次是基础服务层、基础业务层、主业务层:
工程拆分完后,就是工程集成了,我们用 Cocoapods 将各工程集成到一起编译运行和打包,对于每一个工程配置好.podspec 文件。在配置 podfile 文件时,当用于本地开发时,我们通过 path 的方式进行集成,不用临时下载工程代码,如下所示。
pod proj, :path => '~/58_ios_libs/proj’
在进行 Jenkins 打包时,我们通过 Git 方式将代码实时下载:
pod proj, :git => 'git@gitlab.58corp.com:58_ios_team/proj.git',:branch => '1.0.0'。
我们在局域网搭建一个 GitLab 服务,用于管理所有工程代码,并设置好开发组及相应的权限。通过 GitLab 还可以实现提交代码审核、代码合并请求及工程分支保护。
随着 58 App 用户量的剧增,各业务线业务迅速增长,对 58 App 又提出了新需求,如为加快大类列表详情页面的渲染速度,需要将原来这些 HTML5 页面 Native 化;再如各业务线要定制列表详情和筛选样式。面对如此众多需求,显然原来的架构已经满足不了,那就需要我们进一步改进客户端架构,将主业务层进一步拆分。
我们对主业务层进行一个拆分,拆分后的整体架构如图 9 所示,其中每一个模块为一个工程,也是一个组件。
我们将首页、发布、发现、消息中心、个人中心及第三方业务等都从主业务层拆分出来成为独立工程。同样将房产、二手、二手车、黄页、招聘等业务线的代码从原工程里面剥离出来,每个业务线独立一工程,将列表和详情分别剥离出来并进行 Native 化,为上层业务线定制功能提供接口。
业务线拆分的时候我们遵循以下几个原则:
在拆分过程中我们也采取了一些策略,如在拆分招聘业务线时,先把招聘业务线从集成后的工程中删除,进行编译,会出现各种编译错误,说明是有工程对招聘业务线代码进行依赖。如何解决这些依赖关系呢?我们主要是解决相互依赖关系,招聘业务线对非业务线工程肯定是有一定的依赖关系,这个先保留,我们要解决的是其他组件甚至可能是其他业务线对招聘的依赖。我们总结了下,主要用了以下几种方式:
总线包括 UI 总线和服务总线,前者主要处理组件间页面间的跳转,尤其是在主业务层,UI 总线用得比较频繁。服务总线主要处理组件间的服务调用,这里主要讲跳转总线。在主业务层,被封装成的各个组件需要通过 UI 总线进行页面跳转,我们设计了一个总分发中心和子分发中心的模式进行处理,如图 10 所示。
主业务层每个组件内都有一个子分发中心,它的处理逻辑由各组件内来进行,但必须实现一些共同的接口,且这个子分发中心需要进行注册。当组件内需要进行 UI 跳转时,调用总分发中心,将跳转协议传入总分发中心,总分发中心根据协议中组件标识(如业务线标识)找到对应的目标组件子分发中心,将跳转协议透传到对应的子分发中心。接下来的跳转由子分发中心去完成。这样的方式极大降低了组件间的耦合度。
UI 总线中的跳转协议我们原来用 JSON 形式,后来统一调整为 URL 的方式,将 m 调起、浏览器调起、push 调起、外部 App 调起和 App 内跳转统一处理。
新统跳协议 URL 格式如下:
wbmain://jump/job/list? ABMark=markID¶ms=
其中,wbmain 为 58 App 的 scheme,job 为招聘业务线标识,list 为到列表页,ABMark 为 AB 测跳转用的标识 ID,后面会细讲,params 为传过来的一些参数,如是否需要动画,push 还 present 方式入栈等。为了兼容老协议,我们将原来协议中的一部分内容直接透传到 params 中。
对于指定跳转 URL,有时跳转的目标页面是不固定的,如我们的发布页面,有 HTML5 和 React Native 两套页面,如果 React Native 页面出了问题,可以将 URL 做修改跳到 HTML5 页面。具体方案是服务器下发一个路由表,每个表项有一个 ID 和对应新的跳转 URL,每个表项设置有过期时间。跳转的 URL 可以带有 AB 测跳转用的标识 ID,即 markID。如果有这个标识,跳转时就去与路由表中的表项匹配,如果命中就改用路由表中的 URL 跳转,否则还用原来的 URL 执行跳转,大概流程如图 11 所示。
为了提高整个 App 的编译速度,我们为每个工程配置一个对应的库工程,里面预先由源码工程编译出来一个对应的静态库,如图 12 所示。
开发人员可以将权限内的源码和静态下载到本地,按需进行源码和库混合集成,如对于招聘业务线 RD,我们只需关心招聘业务线源码工程,不需要其他业务线的源码或静态库,剩下的工程可以选择全部用静态库进行集成。
对于 Jenkins 打包平台,我们也可以根据需求适当在源码和静态库之间做选择。对于一些特殊的工程,如第三方库工程 ThirdComponent,一般也不会变,可以直接接入对应的静态库工程 ThirdComponentLib。
业务在不断变化,需求持续增多,技术也在不断地更新,我们的架构也需要不断进行调整和升级,架构的演进是一项长期的任务。