作者丨曾庆隆
责编丨唐小引
“
架构的演进是为业务不断发展服务的,架构不能脱离业务,这是最基本的出发点。58 同城 iOS 客户端随着业务量和用户量的持续增长,架构也是不断受到挑战,采用什么样的架构去适应这些变化,对技术人员来说也是一大考验。58 App 的架构先后经历了纯 Native、引入 Hybrid 框架、底层服务组件化、业务线组件化,即整个 App 组件化的四个阶段。
”
早在 2010 年 58 同城诞生第一版 iOS 客户端,按照传统的
MVC 模式去设计,纯 Native 页面,这时的功能较为简单,架构也是如此,从上至下分为 UI 展现、业务逻辑、数据访问三层,如图 1
所示。和同期其他公司一样,App 的出发点是为了快速抢占市场,采取“短平快”的方式开发。纯 Native 的 App
在早期业务量不是太大的情况下,能满足业务的需求。
图 1 App 早期架构
Hybrid 框架需求
由于苹果审核周期较长,业务需求不断增大,有些业务如果用
Native 进行开发,工作量大投入人员较多,也不能动态更新,如 58 App 的大类、列表、详情页面。这种情况下,用 HTML5
是比较流行的解决方式,由此产生了第二版架构,如图 2 所示,在 UI 层添加了 HTML5 页面及 Hybrid 交互框架。
图 2 带 Hybrid 的架构
当时 58 App 设计时用于加载 HTML5 的组件是 UIWebView,也只能使用这个(彼时还没有 WKWebView),但实现起来有几个问题是需要解决的:
-
怎么解决 Hybrid 中 Web 和 Native 交互问题,如用户点击一个类别,能调起 Native 的一些方法去执行相关页面跳转或写日志。
-
如何提高 HTML5 页面的加载速度,HTML5 页面加载时要下载一些 JavaScript、CSS 及图片资源,是比较耗时的。
设置缓存
为了方便描述,本文先介绍如何提高 HTML5 页面加载速度的问题。
对于一些访问比较频繁的页面,如大类列表详情,我们早期采用的都是 HTML5 页面。要加速这些页面的渲染,就要想办法提升资源的加载。那么如何实现呢?首先想到的是使用缓存,我们可以把这些页面的资源内置到 App 中随版本发布。
由于 UIWebView 在发请求的时候都会走 NSURLCache 的这个方法:
- (nullable NSCachedURLResponse*)cachedResponseForRequest:(NSURLRequest *)request;
我们可以从 NSURLCache 派生出子类 WBHybrid
Component,复写 cachedResponseForRequest:方法,在这之中加载 App 的内置资源,具体加载策略可见图 3。
图 3 缓存处理流程
其中,H5ViewController
为 HTML5 载体页面,WBCacheHandler 为专门处理内置资源类,用于加载、查找、下载、保存内置资源。URL 的 query
中设置版本号参数 cachevers 作为资源缓存的标识,其值为数字类型,假设 cachev1,其与内置资源中的版本号如为 cachev2
进行对比,若 cachev2>= cachev1,表示内置资源中是最新数据,直接给请求返回数据;否则下载新的内置资源,同时根据
cachev1- cachev2 的差值进行判断,如设置一个临界值 x,若差值大于 x,则说明内置资源为旧,给请求返回
nil,否则返回内置数据,让请求先用缓存数据,下次启动时再用新数据。
内置数据采用的是一个 bundle 包,如图 4 所示,CacheResources.bundle 为内置包名,里面包含了一个索引文件和若干个内置数据文件,其中索引文件中每项 item 格式为 key、版本号和文件名。
图 4 缓存包结构
想要使用自定义的
NSURLCache,必须在 App 启动时初始化 WBHybridComponent,并进行设置,替换默认的
Cache,注意:这个设置必须在所有请求之前进行,否则设置失效,而是采用默认的 NSURLCache 实例,我们曾经踩过这个坑。
// URLCache初始化WBHybridComponent *hybridComp = [[WBHybridComponent alloc] initWithMemoryCapacity:MEM_CAPACITY diskCapacity:DISK_CAPACITY diskPatch:nil];
[NSURLCache setSharedURLCache:hybridComp]
基于 AJAX 的 Hybrid 框架
对于前面所列的第一个问题,我们是要设计一个
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 所示。
图 5 Hybrid 框架处理流程图
其中,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 传过来的协议。
图 6 Web 调 Native 传输协议
改进的 Hybrid 框架
前面我们设计的
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);//请求触发后,移除iFrameiFrame.parentNode.removeChild(iFrame);
iFrame = null;
由于 iframe 方式是整个页面刷新,所以能执行
UIWebViewDelegate
的回调方法 - (BOOL)webView:(UIWebView
)webView shouldStartLoadWithRequest:(NSURLRequest
)request navigationType:(UIWebViewNavigationType)navigationType。我们可以直接在这个方法中拦截 Web 的调起,iframe 方式处理流程如图 7 所示。
图 7 iframe 的 Hybrid 交互方式
通过 iframe 的方式,我们 App 极大地简化了 Hybrid 框架的交互流程,同时也解决了内存泄露、与缓存功能耦合、消耗不必要的内存空间等问题。
随着业务的进行,一些新的技术需求来了,比如有些基础模块可以从 App 中独立出来进行多应用间的复用;需要为转转 App 提供一个日志 SDK;为违章查询等 App 提供登录的 Passport SDK;为其他 App 提供一个可定制化的分享组件等等。
App 拆分组件
这时我们迫切地需要在工程代码层面对原来的 App 进行拆分、组件化开发,如图 8 所示。
图 8 第三版架构
我们将 App 拆分成三层,从下至上依次是基础服务层、基础业务层、主业务层:
-
基础服务层里的组件是与业务无关的,供上层调用,每个组件为一个工程,如网络、数据库、日志等。这里面有些组件是整个公司的其他 App
也在使用,如乐高日志,我们对外提供一个 SDK,与文档一起放在代码服务器上供其他团队使用。并将 58 App
中用到的所有第三方库都集中起来存放到一个专门的工程中,也便于更新维护。
-
基础业务层里的组件是与业务相关的,供主业务层使用,每个组件是一个工程,如登录、分享、推送、IM 等,我们把 Hybrid 框架也归在业务层。其中登录组件我们做成 Passport SDK,供公司其他 App 集成调用。
-
主业务包括 App 首页、个人中心、各业务线业务和第三方接入业务,业务线业务主要包括发布、大类、列表、详情。
集成管理组件
工程拆分完后,就是工程集成了,我们用 Cocoapods 将各工程集成到一起编译运行和打包,对于每一个工程配置好.podspec 文件。在配置 podfile 文件时,当用于本地开发时,我们通过 path 的方式进行集成,不用临时下载工程代码,如下所示。
pod proj, :path => '~/58_ios_libs/proj’
在进行 Jenkins 打包时,我们通过 Git 方式将代码实时下载:
pod proj, :git => '[email protected]:58_ios_team/proj.git',:branch => '1.0.0'。
GitLab 服务进行代码管理
我们在局域网搭建一个 GitLab 服务,用于管理所有工程代码,并设置好开发组及相应的权限。通过 GitLab 还可以实现提交代码审核、代码合并请求及工程分支保护。
随着
58 App 用户量的剧增,各业务线业务迅速增长,对 58 App 又提出了新需求,如为加快大类列表详情页面的渲染速度,需要将原来这些
HTML5 页面 Native
化;再如各业务线要定制列表详情和筛选样式。面对如此众多需求,显然原来的架构已经满足不了,那就需要我们进一步改进客户端架构,将主业务层进一步拆分。
主业务层拆分
我们对主业务层进行一个拆分,拆分后的整体架构如图 9 所示,其中每一个模块为一个工程,也是一个组件。
图 9 第四版架构
我们将首页、发布、发现、消息中心、个人中心及第三方业务等都从主业务层拆分出来成为独立工程。同样将房产、二手、二手车、黄页、招聘等业务线的代码从原工程里面剥离出来,每个业务线独立一工程,将列表和详情分别剥离出来并进行
Native 化,为上层业务线定制功能提供接口。
业务线拆分的时候我们遵循以下几个原则:
-
各业务线之间不能有依赖关系,因为我们的业务线在开发的整个过程中都是独立运行的,不会含有其他业务线代码。
-
非业务线工程不能对各业务线有依赖关系,即所有业务线都不集成进 App 也要能正常编译。
-
各业务线对非业务线工程可以保留必要的依赖,如业务线对列表组件的依赖。
在拆分过程中我们也采取了一些策略,如在拆分招聘业务线时,先把招聘业务线从集成后的工程中删除,进行编译,会出现各种编译错误,说明是有工程对招聘业务线代码进行依赖。如何解决这些依赖关系呢?我们主要是解决相互依赖关系,招聘业务线对非业务线工程肯定是有一定的依赖关系,这个先保留,我们要解决的是其他组件甚至可能是其他业务线对招聘的依赖。我们总结了下,主要用了以下几种方式:
-
将依赖的文件或方法下沉,如有些文件并不是招聘业务线专用的,可以从招聘中下沉到其他工程,同样有些方法也可以下沉。
-
Runtime,这种方式比较普遍,但也不需要所有地方都用,毕竟其维护成本还是比较高的。
-
Category 方式,如个人中心组件中方法 funA 要调用招聘组件中的方法 funB,但 funB
的实现是要依赖招聘内部代码,这种情况下个人中心是依赖招聘业务线的,理论上招聘可以依赖个人中心,而不应该反过来依赖。解决办法是可以在个人中心添加一个类,如
ClassA,里面添加方法 funB,但实现为空,如果带返回值可以返回一个默认值,再在招聘中添加一个 ClassA 的类别
ClassA+XX,将原来招聘中的方法 funB 放入 ClassA+XX,这样如果招聘集成进来,就会执行 ClassA+XX 中的 funB
方法,否则执行个人中心自己的 funB 方法。