## 58同城 iOS 客户端组件化演变历程 文/曾庆隆 >架构的演进是为业务不断发展服务的,架构不能脱离业务,这是最基本的出发点。58同城 iOS 客户端随着业务量和用户量的持续增长,架构也是不断受到挑战,采用什么样的架构去适应这些变化,对技术人员来说也是一大考验。58 App的架构先后经历了纯 Native、引入 Hybrid 框架、底层服务组件化、业务线组件化,即整个 App 组件化的四个阶段。 ### 第一版 App 架构 早在2010年58同城诞生第一版 iOS 客户端,按照传统的 MVC 模式去设计,纯 Native 页面,这时的功能较为简单,架构也是如此,从上至下分为 UI 展现、业务逻辑、数据访问三层,如图1所示。和同期其他公司一样,App 的出发点是为了快速抢占市场,采取“短平快”的方式开发。纯 Native 的 App 在早期业务量不是太大的情况下,能满足业务的需求。 图1  App早期架构 图1 App早期架构 ### 第二版架构 #### Hybrid 框架需求 由于苹果审核周期较长,业务需求不断增大,有些业务如果用 Native 进行开发,工作量大投入人员较多,也不能动态更新,如 58 App 的大类、列表、详情页面。这种情况下,用 HTML5 是比较流行的解决方式,由此产生了第二版架构,如图2所示,在 UI 层添加了 HTML5 页面及 Hybrid 交互框架。当时 58 App 设计时用于加载 HTML5 的组件是 UIWebView,也只能使用这个(彼时还没有 WKWebView),但实现起来有几个问题是需要解决的: 图2  带 Hybrid 的架构 图2 带 Hybrid 的架构 1. 怎么解决 Hybrid 中 Web 和 Native 交互问题,如用户点击一个类别,能调起 Native 的一些方法去执行相关页面跳转或写日志。 2. 如何提高 HTML5 页面的加载速度,HTML5 页面加载时要下载一些 JavaScript、CSS 及图片资源,是比较耗时的。 #### 设置缓存 为了方便描述,本文先介绍如何提高 HTML5 页面加载速度的问题。 对于一些访问比较频繁的页面,如大类列表详情,我们早期采用的都是 HTML5 页面。要加速这些页面的渲染,就要想办法提升资源的加载。那么如何实现呢?首先想到的是使用缓存,我们可以把这些页面的资源内置到 App 中随版本发布。 由于 UIWebView 在发请求的时候都会走 NSURLCache 的这个方法: ``` - (nullable NSCachedURLResponse*)cachedResponseForRequest:(NSURLRequest *)request; ``` 我们可以从 NSURLCache 派生出子类 WB Hybrid Component,复写 cachedResponseForRequest:方法,在这之中加载 App 的内置资源,具体加载策略可见图3。 图3  缓存处理流程 图3 缓存处理流程 其中,H5ViewController为HTML5 载体页面,WBCacheHandler 为专门处理内置资源类,用于加载、查找、下载、保存内置资源。URL 的 query 中设置版本号参数 cachevers 作为资源缓存的标识,其值为数字类型,假设 cachev1,其与内置资源中的版本号如为 cachev2 进行对比,若 cachev2>= cachev1,表示内置资源中是最新数据,直接给请求返回数据;否则下载新的内置资源,同时根据 cachev1- cachev2 的差值进行判断,如设置一个临界值 x,若差值大于 x,则说明内置资源为旧,给请求返回 nil,否则返回内置数据,让请求先用缓存数据,下次启动时再用新数据。 内置数据采用的是一个 bundle 包,如图4所示,CacheResources.bundle 为内置包名,里面包含了一个索引文件和若干个内置数据文件,其中索引文件中每项 item 格式为 key、版本号和文件名。 图4   缓存包结构 图4 缓存包结构 想要使用自定义的 NSURLCache,必须在 App 启动时初始化 WB Hybrid Component,并进行设置,替换默认的 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。因此,我们决定采用复用缓存中的 WB Hybrid Component 拦截 AJAX 请求,具体 Web 调 Native 的交互设计如图5所示。 图5   Hybrid 框架处理流程图 图5 Hybrid 框架处理流程图 其中,H5ViewController 为 HTML5 的载体页,WBWebView 是 UIWebView 派生类。WBWebView 中通过 AJAX 发出的异步请求,在 WB Hybrid Component 中被拦截,再通过 WB Hybrid JSHandler 中的 dic 表找到对应的 WBActionAnalysis 对象,然后在 WBActionAnalysis 中分析异步请求传过来的协议,取出 action 字段,再根据 action 值找到 delegate 即 H5ViewController 中对应的方法。 AJAX 发出的请求我们约定为: Nativechannel://?paras=``,WB Hybrid Component 在拦截时判断 URL 中是否为 Nativechannel 的协议头,如果是则为 Web 调起 Native 操作,需要进行后续 Native 处理;否则放过进行其他处理。``的简化格式如图6所示,这是二手车大类页点击二手车类目 Web 调 Native 时 AJAX 传过来的协议。 图6  Web调 Native传输协议 图6 Web 调 Native 传输协议 #### 改进的 Hybrid 框架 前面我们设计的 Hybrid 框架,通过创建 XMLHttpRequest 对象发送 AJAX 请求的方式能达到 Web 调 Native 的目的,也可以满足业务上的需求,在一段内发挥了重要作用。但随着时间的推移,这个 Hybrid 框架暴露出了一些问题,如下所示。 1. 我们发现 App 中存在大量的内存泄露,经查罪魁祸首竟是 UIWebView。调研发现 UIWebView 中执行 XMLHttpRequest 异步请求时会有内存泄露,网上也有人探讨过这个问题,参考博客:http://blog.techno-barje.fr//post/2010/10/04/UIWebView-secrets-part1-memory-leaks-on-xmlhttprequest/。 2. Hybrid 交互方式与缓存都使用 NSURLCache 的派生类 WB Hybrid Component 执行拦截,其初衷也是用于缓存。我们的 Hybrid 框架将两者耦合在一起,这对于后期的开发和性能优化工作会带来不少隐患。 3. 我们在 Hybrid 交互的时候维护了一个``列表,需要消耗额外空间,同时也存在一个隐患——如果两个 HTML5 页面的 URL 相同,这时列表中只有一份,在回调时会存在问题,当然,其机率特别小。 对于这些问题,我们调研了一些解决方案,最终决定采用 iframe 方式,向 DOM 中添加一个空的 iframe,设置其 src 属性,实现代码如下所示。 ``` //创建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所示。 图7  iframe的 Hybrid 交互方式 图7 iframe 的 Hybrid 交互方式 通过 iframe 的方式,我们 App 极大地简化了 Hybrid 框架的交互流程,同时也解决了内存泄露、与缓存功能耦合、消耗不必要的内存空间等问题。 ### 第三个版本架构 随着业务的进行,一些新的技术需求来了,比如有些基础模块可以从 App 中独立出来进行多应用间的复用;需要为转转 App 提供一个日志 SDK;为违章查询等 App 提供登录的 Passport SDK;为其他 App 提供一个可定制化的分享组件等等。 #### App 拆分组件 这时我们迫切地需要在工程代码层面对原来的 App 进行拆分、组件化开发,如图8所示。 图8  第三版架构 图8 第三版架构 我们将 App 拆分成三层,从下至上依次是基础服务层、基础业务层、主业务层: 1. 基础服务层里的组件是与业务无关的,供上层调用,每个组件为一个工程,如网络、数据库、日志等。这里面有些组件是整个公司的其他 App 也在使用,如乐高日志,我们对外提供一个 SDK,与文档一起放在代码服务器上供其他团队使用。并将 58 App 中用到的所有第三方库都集中起来存放到一个专门的工程中,也便于更新维护。 2. 基础业务层里的组件是与业务相关的,供主业务层使用,每个组件是一个工程,如登录、分享、推送、IM 等,我们把 Hybrid 框架也归在业务层。其中登录组件我们做成Passport SDK,供公司其他 App 集成调用。 3. 主业务包括 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 服务,用于管理所有工程代码,并设置好开发组及相应的权限。通过 GitLab 还可以实现提交代码审核、代码合并请求及工程分支保护。 ### 第四版架构 随着 58 App 用户量的剧增,各业务线业务迅速增长,对 58 App 又提出了新需求,如为加快大类列表详情页面的渲染速度,需要将原来这些 HTML5 页面 Native 化;再如各业务线要定制列表详情和筛选样式。面对如此众多需求,显然原来的架构已经满足不了,那就需要我们进一步改进客户端架构,将主业务层进一步拆分。 #### 主业务层拆分 我们对主业务层进行一个拆分,拆分后的整体架构如图9所示,其中每一个模块为一个工程,也是一个组件。 图9  第四版架构 图9 第四版架构 我们将首页、发布、发现、消息中心、个人中心及第三方业务等都从主业务层拆分出来成为独立工程。同样将房产、二手、二手车、黄页、招聘等业务线的代码从原工程里面剥离出来,每个业务线独立一工程,将列表和详情分别剥离出来并进行 Native 化,为上层业务线定制功能提供接口。 业务线拆分的时候我们遵循以下几个原则: 1. 各业务线之间不能有依赖关系,因为我们的业务线在开发的整个过程中都是独立运行的,不会含有其他业务线代码。 2. 非业务线工程不能对各业务线有依赖关系,即所有业务线都不集成进 App 也要能正常编译。 3. 各业务线对非业务线工程可以保留必要的依赖,如业务线对列表组件的依赖。 在拆分过程中我们也采取了一些策略,如在拆分招聘业务线时,先把招聘业务线从集成后的工程中删除,进行编译,会出现各种编译错误,说明是有工程对招聘业务线代码进行依赖。如何解决这些依赖关系呢?我们主要是解决相互依赖关系,招聘业务线对非业务线工程肯定是有一定的依赖关系,这个先保留,我们要解决的是其他组件甚至可能是其他业务线对招聘的依赖。我们总结了下,主要用了以下几种方式: 1. 将依赖的文件或方法下沉,如有些文件并不是招聘业务线专用的,可以从招聘中下沉到其他工程,同样有些方法也可以下沉。 2. Runtime,这种方式比较普遍,但也不需要所有地方都用,毕竟其维护成本还是比较高的。 3. Category 方式,如个人中心组件中方法 funA 要调用招聘组件中的方法 funB,但 funB 的实现是要依赖招聘内部代码,这种情况下个人中心是依赖招聘业务线的,理论上招聘可以依赖个人中心,而不应该反过来依赖。解决办法是可以在个人中心添加一个类,如 ClassA,里面添加方法 funB,但实现为空,如果带返回值可以返回一个默认值,再在招聘中添加一个 ClassA 的类别 ClassA+XX,将原来招聘中的方法 funB 放入 ClassA+XX,这样如果招聘集成进来,就会执行 ClassA+XX 中的 funB 方法,否则执行个人中心自己的 funB 方法。 #### 跳转总线 总线包括 UI 总线和服务总线,前者主要处理组件间页面间的跳转,尤其是在主业务层,UI 总线用得比较频繁。服务总线主要处理组件间的服务调用,这里主要讲跳转总线。在主业务层,被封装成的各个组件需要通过 UI 总线进行页面跳转,我们设计了一个总分发中心和子分发中心的模式进行处理,如图10所示。 图10  UI跳转总线 图10 UI 跳转总线 主业务层每个组件内都有一个子分发中心,它的处理逻辑由各组件内来进行,但必须实现一些共同的接口,且这个子分发中心需要进行注册。当组件内需要进行 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 中。 #### AB 测跳转 对于指定跳转 URL,有时跳转的目标页面是不固定的,如我们的发布页面,有 HTML5 和 React Native 两套页面,如果 React Native 页面出了问题,可以将 URL 做修改跳到 HTML5 页面。具体方案是服务器下发一个路由表,每个表项有一个 ID 和对应新的跳转 URL,每个表项设置有过期时间。跳转的 URL 可以带有 AB 测跳转用的标识 ID,即 markID。如果有这个标识,跳转时就去与路由表中的表项匹配,如果命中就改用路由表中的 URL 跳转,否则还用原来的 URL 执行跳转,大概流程如图11所示。 图11  AB测跳转流程图 图11 AB 测跳转流程图 #### 静态库方案 为了提高整个 App 的编译速度,我们为每个工程配置一个对应的库工程,里面预先由源码工程编译出来一个对应的静态库,如图12所示。 开发人员可以将权限内的源码和静态下载到本地,按需进行源码和库混合集成,如对于招聘业务线 RD,我们只需关心招聘业务线源码工程,不需要其他业务线的源码或静态库,剩下的工程可以选择全部用静态库进行集成。 对于 Jenkins 打包平台,我们也可以根据需求适当在源码和静态库之间做选择。对于一些特殊的工程,如第三方库工程 ThirdComponent,一般也不会变,可以直接接入对应的静态库工程 ThirdComponentLib。 ### 总结 业务在不断变化,需求持续增多,技术也在不断地更新,我们的架构也需要不断进行调整和升级,架构的演进是一项长期的任务。