58 同城 iOS 客户端组件化演变历程

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