58 同城 iOS 客户端 Hybrid 框架探索

【CSDN 编者按】58 同城 iOS 客户端的 Hybrid 框架在最初设计和演进的过程中,遇到了许多问题。为此,整个 Hybrid 框架产生了很大的变化。本文作者将遇到的典型问题进行了总结,并重点介绍 58 iOS 采用的解决方案,希望能给读者搭建自己的 Hybrid 框架提供一些参考。

引言

Hybrid App 是指同时使用 Native 与 Web 的 App。Native 界面具有良好的用户体验,但是不易动态改变,且开发成本较高。对于变动较大的页面,使用 Web 来实现是一个比较好的选择,所以,目前很多主流 App 都采用 Native 与 Web 混合的方式搭建。58 同城客户端上线不久即采用了 Hybrid 方式,至今已有六七年。而 iOS 客户端的 Hybrid 框架在最初设计和演进的过程中,随着时间推移和业务需求的不断增加,遇到了许多问题。为了解决它们,整个 Hybrid 框架产生了很大的变化。本文将遇到的典型问题进行了总结,并重点介绍 58 iOS 采用的解决方案,希望能给读者搭建自己的 Hybrid 框架一些参考。主要包括以下四个方面:

1. 通讯方式以及通讯框架

58 App 最初采用的 Web 调用 Native 的通讯方式是 AJAX 请求,不仅存在内存泄露问题,且 Native 在回调给 Web 结果时无法确定回调给哪个 Web View。另外,如何搭建一个简单、实用、扩展性好的 Hybrid 框架是一个重点内容。这些内容将在通讯部分详细介绍。

2. 缓存原理及缓存框架

提升 Web 页面响应速度的一个有效手段就是使用缓存。58 iOS 客户端如何对 Web 资源进行缓存以及如何搭建 Hybrid 缓存框架将在缓存部分介绍。

3. 性能

iOS 8 推出了 WebKit 框架,核心是 WKWebView,其在性能上要远优于 UIWebView,并且提供了一些新的功能,但遗憾的是 WKWebView 不支持自定义缓存。我们经过调研和测试发现了一些从 UIWebView 升级到 WKWebView 的可行解决方案,将在性能部分重点介绍。

4. 耦合

58 iOS 客户端最初的 Hybrid 框架设计过于简单,导致 Web 载体页渐渐变得十分臃肿,继承关系十分复杂。耦合部分详细介绍了平稳解决载体页耦合问题的方案。

通讯

Hybrid 框架首先要考虑的问题就是 Web 与 Native 之间的通讯。苹果在 iOS 7 系统推出了 JavaScriptCore.framework 框架,通过该框架可以方便地实现 JavaScript 与 Native 的通讯工作。但是在 58 App 最早引入 Hybrid 时,需要支持 iOS 7 以下的系统版本,所以 58 App 并没有使用 JavaScriptCore.framework,而是采用了更原始的方式。

传统的通讯方式(如图 1 所示)中,Native 调用 JavaScript 代码比较简单,直接使用 UIWebView 提供的接口 stringByEvaluatingJavaScriptFromString:就可以实现。而 JavaScript 调用 Native 的功能需要通过拦截请求的方式来实现。即 JavaScript 发送一个特殊的 URL 请求,该请求并不是真正的网络访问请求,而是调用 Native 功能的请求,并传递相关的参数。Native 端收到请求后进行判断,如果是功能调 URL 请求则调用 Native 的相应功能,而不进行网络访问。

图 1  传统的通讯方式流程

图 1 传统的通讯方式流程

按照上面的思路,在实现 Hybrid 通讯时,我们需要考虑以下几个问题:

通讯方式

前端能发起请求的方法有很多种,比如使用 window.open()方法、AJAX 请求、构造 iframe 等,甚至于使用 img 标签的 src 属性也可以发起请求。58 App 最早是使用 AJAX 请求来发起 Native 调用的,这种方式在最初支撑了 58 App 中 Hybrid 很长一段时间,不过却存在两个很严重的缺陷:

  • 一是内存问题:在 iOS 8 以前,iOS 中内嵌 Web 页都是通过系统提供的 UIWebView 来实现的。而在 UIWebView 中,JavaScript 在创建 XMLHttpRequest 对象发起 AJAX 请求后,会存在内存泄露问题。在实现的应用中,JavaScript 与 Native 的交互操作是很频繁的,使用 XMLHttpRequest 会引起比较严重的内存问题。

  • 二是拦截方法:UIWebView 中的正常 URL 请求会触发其代理方法,我们可以在其代理方法中进行拦截。但是 AJAX 请求是一个异步的数据请求,并不会触发 UIWebView 的代理方法。我们需要自定义 App 中的 NSURLCache 或 NSURLProcotol 对象,在其中可以拦截到 URL 请求。但是这种方式有两个问题,一个是当收到功能调用请求时,不易确定是哪个 Web View 对象发起的调用,回调时也无法确定调用哪个 Web View 的回调方法。为了解决这个问题,58 App 的 Hybrid 框架维护了一个 Web View 栈,记录所有视图层中的 Web View,前端在发起 Native 调用时,附加一个 Web View 的唯一标识信息。在 Native 需要回调 JavaScript 方法时,通过 Web View 的唯一标识信息在 Web View 栈中找到对应的 Web View。另一个是对 App 的框架结构有影响,Hybrid 中的一个简单的调用需要放在 App 的全局对象进行拦截处理,破坏 Hybrid 框架的内聚性,违反面向对象设计原则。

iframe 称作嵌入式框架,和框架网页类似,它可以把一个网页的框架和内容嵌入在现有的网页中。iframe 是在现有的网页中增加一个可以单独载入网页的窗口,通过在 HTML 页面中创建大小为 0 的 iframe,可以达到在用户完全无感知的情况下发起请求的目的。使用 iframe 发送请求的代码如下:

var iframe = document.createElement("iframe");
//设置 iframe 加载的页面链接
iframe.src = “ http://127.0.0.1/NativeFunction?parameters=values”;
//向 DOM tree 中添加 iframe 元素,以触发请求
document.body.AppendChild(iframe);
//请求触发后,移除 iframe
iframe.parentNode.removeChild(iframe);
iframe = null;

iframe 是加载一个新的页面,请求会走 UIWebView 的代理方法,不存在 AJAX 请求中无法确定 Web View 的问题。经过调研测试,多次创建和释放 iframe 不会存在内存泄露的问题。从这两个方面来说,使用 iframe 是远优于使用 AJAX 的,比较有名的 PhoneGap 和 WebViewJavascriptBridge 底层都是采用的 iframe 进行通讯的。

iframe 是前端调用 Native 方法的一个非常优秀的方案,但它也存在一些细微的局限性。58 App 前端为了提升代码的复用性和方便使用 Native 的功能,对 iframe 的通讯方式进行了统一封装,封装的具体实现是——在 JavaScript 代码中动态地向 DOM tree 上添加一个大小为 0 的 iframe,在请求发起后立刻将其移除。这个操作的前提是 DOM tree 已经形成,也就是说在 DOM Tree 进行之前,这个方案是行不通的。浏览器解析 HTML 的详细过程为:

  1. 接受网络数据;
  2. 将二进制码变成字符;
  3. 将字符变为 Unicode code points;
  4. Tokenizer;
  5. Tree Constructor;
  6. DOM Ready;
  7. Window Ready。

Dom Ready 事件就是 DOM Tree 创建完成后触发的。在业务开发过程中,有少量比较特殊的需求,需要在 DOM Ready 事件之前发起 Native 功能的调用,而动态添加 iframe 的方法并不能满足这种需求。为此,我们对其他几种发起请求的方法进行了调查,包括前文提到的 AJAX 请求、为 window.location.href 赋值、使用 img 标签的 src 属性、调用 window.open()方法(各个方式的表现结果如表 1 所示)。

表 1  五种方法效果对比

表 1 五种方法效果对比

结果显示,其他几种方式除 window.open()与 iframe 表现基本相同外,都有比较致命的缺陷。AJAX 有内存问题,并且无法使用 Web View 代理拦截请求,window.location.href 在连续赋值时只有一次生效,img 标签不需要添加到 DOM Tree 上也可发起请求,但是无法使用 Web View 代理拦截,并且相同的 URL 请求只发一次。

对于在 DOM Ready 之前需要发起 Native 调用的问题,最终采取的解决方案是尽量避免这种需求。无法避免的进行特殊处理,通过在 HTML 中添加静态的 iframe 来解决。

通讯协议

通讯协议是整个 Hybrid 通讯框架的灵魂,直接影响着 Hybrid 框架结构和整个 Hybrid 的扩展性。为了保证尽量高的扩展性,58 App 中采用了字典的格式来传递参数。一个完整的 Native 功能调用的 URL 如下:

Hybrid://iframe?parameter={“action”:”changetitle”,”title”:”标题”}

其中“Hybrid”是 Native 调用的标识,Native 端在拦截到请求后判断请求 URL 的前缀是否为“Hybrid”,如果是则调起 Native 功能,同时阻止该请求继续进行。Native 功能调用的相应参数在 parameter 后面的 JSON 数据里,其中“action”字段指明调用哪个 Native 功能,其余字段是调用该功能需要的参数。因为“action”字段名称的原因,后来把为 Web 提供的 Native 功能的处理逻辑称为 action 处理。

这样制定通讯协议有很强的可扩展性,Native 端任意增加新的 Hybrid 接口,只要为 action 字段定一个新值,就可以实现,新接口需要的参数完全自定义。但是这种灵活的协议格式存在一个问题,就是开发者很难记住每种调用协议的参数字段,开发过程中需要查看文档来调用 Native 功能,需要更长的开发时间。为此 58 App 首先建立了健全的协议文档,将每种调用协议都一一列举,并给出调用示例,方便前端开发者查阅。另外,Native 端开发了一套协议数据校验系统,该系统将每种调用协议的参数要求用 XML 文档表示出来,在收到 Native 调用协议数据时,动态地解析数据内部是否符合 XML 文档中的要求,如果不符合则禁止调用 Native 功能,并提示哪里不符合要求。

框架设计

依照上面的通讯协议,58 App 中目前的 Hybrid 的框架设计如图 2 所示。其中:

图 2  Hybrid 框架设计

图 2 Hybrid 框架设计

Native 基础服务是 Native 端已有的一些通用的组件或接口,在 Native 端各处都在调用,比如埋点系统、统一跳转及全局 alert 提示框等。这些功能在某些 Web 页面也会需要使用到。

Native Hybrid 框架是整个 Hybrid 的核心部分,其内部封装了除缓存以外的所有 Hybrid 相关的功能。Native Hybrid 框架可大致分为 Web 载体、Hybrid 处理引擎、Hybrid 功能接口三部分。校验系统是前文提到的在开发过程中校验协议数据格式的模块,方便前端开发者在开发过程中快速定位问题。

Web 载体包含 Web 载体页和 Web View 组件,所有的 Hybrid 页面使用统一的 Web 载体页。Web 载体页提供了所有 Web 页面都可能会使用到的功能,而 Web View 组件为了实现 Web View 的一些定制需求,对系统的 Web View 进行了继承,并重写了某些父类方法。

Hybrid 处理引擎负责处理 Web 页面发起事件,是 Web View 组件的代理对象,也是 Web 调用 Native 功能的通讯桥梁。前面提到的判断 Web 请求是页面载入请求还是 Native 功能调用请求的逻辑在 Hybrid 处理引擎中实现。在判定请求为 Native 功能调用请求后,Hybrid 处理引擎根据请求参数中的“action”字段的值对该 Native 调用请求进行分发,找到对应的 Hybrid 功能组件,并将参数传递给该组件,由组件进行真正的处理。

Hybrid 功能组件部分包含了所有开放给前端调用的功能。这些功能可以分成两类,一类是需要 Native 基础服务支撑的,另一类是 Hybrid 框架内部可以处理的。需要 Native 基础服务支撑的功能,如埋点、统一跳转、Native 模块化组件(图片选择、登录等),本身在 Native 端已经有可用的成熟的组件。这些 Hybrid 功能组件所做的事是解析 Web 页传递过来的参数,将参数转换为 Native 组件可用的数据,并调用相应的 Native 基础服务,将基础服务返回的数据转换格式回调给 Web。另一类 Hybrid 功能组件通常是比较简单的操作,比如改变 Web 载体页的标题和导航栏按钮、刷新或者返回等。这些组件通过代理的方式获取载体页和 Web View 对象,对其进行相应的操作。

再看 Web 端,前端对 Hybrid 通讯进行了一层封装,将发送 Native 调用请求的逻辑统一封装为一个方法,业务层需要调用 Native 功能时调用这个方法,传入 action 名称、参数,即可完成调用。当需要回调时,需要先定义一个回调方法,然后在参数中将方法名带上即可。

缓存

Web 页面具有实时更新的特点,它为 App 提供了不依赖发版就能更新的能力。但是每次都请求完整的页面,增加了流量的消耗,并且界面展示依赖网络,需要更长的时间来加载,给用户比较差的体验。所以对一些常用的不需要每次都更新的内容进行缓存是很重要的。另外,Web 页面需要用到的某些 CSS 和 JavaScript 资源是固定不变的,可以直接内置到 App 包中。所以,在 Hybrid 中,缓存是必不可少的功能。要实现 Hybrid 缓存,需要考虑三个方面的问题,即 Hybrid 缓存实现原理、缓存策略和 Hybrid 缓存框架设计。

缓存实现原理

NSURLCache 是 iOS 系统提供的一个类,每个 App 都存在一个 NSURLCache 的单例对象,即使开发者没有添加任何有关 NSURLCache 的代码,系统也会为 App 创建一个默认的 NSURLCache 单例对象。几乎 App 中的所有网络请求都会调用这个单例对象的 cachedResponseForRequest:方法。该方法是系统从缓存中获取数据的方法,如果缓存中有数据,通过这个方法将缓存数据返回给请求者即可,不必发送网络请求。通过使用 NSURLCache 的自定义子类替换默认的全局 NSURLCache 单例,并重写 cachedResponseForRequest:方法,可以截获 App 内几乎所有的网络请求,并决定是否使用缓存数据。

当没有缓存可用时,我们在 cachedResponseForRequest:方法中返回 null。这时系统会发起网络请求,拿到请求数据后,系统会调用 NSURLCache 实例的 storeCachedResponse:forRequest:方法,将请求信息和请求得到的数据传入这个方法。App 通过重写这个方法就可以达到更新缓存的目的。

58 App 目前就是通过替换全局的 NSURLCache 对象,来实现拦截 App 内的 URL 请求。在自定义 NSURLCache 对象的 cachedResponse ForRequest:方法中判断请求的 URL 是否有对应的缓存,如果有缓存则返回缓存数据,没有则再正常走网络请求。请求完成后在 store CachedResponse:forRequest:方法中将请求到的数据按需加入缓存中。

使用替换 NSURLCache 的方法时需要注意替换 NSURLCache 单例对象的时机,一定要在整个 App 发起任何网络请求之前替换。一旦 App 有了网络请求行为,NSURLCache 单例对象就确定了,再去改变是无效的。

缓存策略

Web 的大部分内容是多变的,开发者需要根据具体的业务需求制定缓存策略。好的缓存策略可以在很大程度上弥补 Web 页带来的加载慢和流量耗费大的问题。缓存策略的一般思路是:

  1. 内置通用的资源和关键页面;
  2. 按需缓存常用页面;
  3. 为缓存设置版本号,根据版本号进行使用和升级。

58 App 中对一些通用资源和十分重要的 Web 页面进行了内置,防止 App 在首次启动时由于网络原因导致某些重要页面无法展示。在缓存使用和升级的策略上,58 App 除了设置版本号以外,还针对那些已过期但还可用的缓存数据设置了缓存过期阈值。58 App 的详细缓存策略如下:

  1. 将通用 Hybrid 资源(CSS、JS 文件等)和关键页面(比如业务线大类页)附带版本号内置到 App 的特定 Bundle 中;
  2. 在 NSURLCache 单例中拦截到请求后,判断该请求是否带有缓存版本号信息,如果没有,说明该页面不使用缓存,走正常网络请求;
  3. 从缓存库中查找缓存数据,如果有则取出,否则到内置资源中取。如果两者都没有数据,走正常网络请求。并在请求完成后,将结果保存到缓存库中;
  4. 拿到缓存或内置数据后,将请求中带的版本号 v1 与取到数据的版本号 v2 进行对比。如果 v1≤v2,返回取到的数据,不再请求网络;如果 v1>v2 且 v1 – v2 小于缓存过期阈值,则先返回缓存数据以供使用,然后后台请求新的数据并存入缓存;如果 v1>v2 且 v1 – v2 大于缓存过期阈值,走正常网络请求,并在请求完成后,将结果保存到缓存库中。

缓存框架设计

58 App 中 Hybrid 的缓存框架设计如图 3 所示,其中:

图 3  Hybrid 缓存框架设计

图 3 Hybrid 缓存框架设计

1. Hybrid 内置资源管理

Hybrid 内置资源管理模块是单独为 Hybrid 的内置资源而创建的。Hybrid 内置资源单独存放在一个 Bundle 下,这些内置资源主要包括 HTML 文件、JavaScript 文件、CSS 文件和图片。Hybrid 内置资源管理模块负责解读这个 Bundle,并向上提供读取内置资源的接口,该接口以资源的 URL 和版本号为参数,按照固定的规则进行转换,查找可用的内置资源。

内置资源中除了这些 Web 资源外,还单独内置了一份文件,用于保存 URL 到内置资源文件名和内置资源版本号的映射表。管理模块在收到内置资源请求后,先用 URL 到这个映射表中查找内置资源版本号,比对版本号,然后再通过映射表中查到的文件名读取相应的内置资源并返回。

2. App 缓存库

58 App 内有一个独立的缓存库组件,App 中需要用到的缓存性质的数据都存放在这个库中,便于缓存的统一管理。缓存库内的缓存数据也有版本号的概念,完全可以满足 Hybrid 缓存的需求,且使用十分方便。Hybrid 的缓存数据都使用 App 的缓存库来保存。

3. Hybrid 缓存管理器

Hybrid 缓存管理器是 Hybrid 缓存相关功能的总入口,负责提供 Hybrid 缓存数据和升级缓存数据,所有的 Hybrid 缓存相关的策略都封装在这个模块中。全局的 NSURLCache 实例在收到 Hybrid 请求时会调起 Hybrid 缓存管理器,索取缓存数据。Hybrid 缓存管理器先到 App 的缓存库中查找可用的缓存,如果没有再到内置资源管理模块查找,如果可以查到数据,则返回查到的数据,如果查不到,则返回空。在 NSURLCache 的 storeCachedResponse:forRequest:方法中,会调用 Hybrid 缓存管理器的缓存升级接口,将请求到的数据传入该接口。新请求到的数据会带有最新的版本号信息。缓存升级接口将新的数据和版本号信息一同存入缓存库中,以便下次使用。

性能

前面分享了 58 App 中 Hybrid 的通讯框架和缓存框架,接下来介绍一下遇到的性能方面的问题及解决方案。

AJAX 通讯方式的内存泄露问题

前面介绍过在 UIWebView 中使用 AJAX 的方式进行 Native 功能调用,会产生内存泄露问题,《UIWebView Secrets - Part1 - Memory Leaks on Xmlhttprequest》(参考资料 1)中给出了一个解决方案,是在 UIWebView 的代理方法 WebViewDidFinishLoad:中添加如下代码:

[[NSUserDefaults standardUserDefaults] setInteger:0 forKey:@"WebKitCacheModelPreferenceKey"];

测试结果显示,这种方法并没有使用 iframe 的效果好。加上拦截方式的局限性,58 App 最终选择的解决方案是使用 iframe 代替 AJAX。

UIWebView 内存问题

使用过 UIWebView 的开发者应该都知道,UIWebView 有比较严重的内存问题。苹果在 iOS8 推出了 WebKit 框架,其核心是 WKWebView,志在取代 UIWebView。WKWebView 不仅解决了 UIWebView 的内存问题,且具有更高的稳定性和响应速度,还支持一些新的功能。使用 WKWebView 代替 UIWebView 对提升整个 Hybrid 框架的性能会有很重大的意义。

但是,WKWebView 一直存在一个问题,就是 WKWebView 发出的请求并不走 NSURLCache 的方法。这就导致我们自定义的缓存系统会整个失效,也无法再用内置资源。经过一段时间的摸索和调研,终于找到了可以实现自定义缓存的方法。主要思想是 WKWebView 发起的请求可以通过 NSURLProtocol 来拦截——将自定义的 NSURLProtocol 子类注册到 NSURLProtocol 的方式,可以像之前用 NSURLCache 一样使用缓存或内置数据代替请求结果返回。注册自定义 NSURLProtocol 的关键代码如下:

[NSURLProtocol registerClass:WBCustomProtocol.class];
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
    [(id)cls performSelector:sel withObject:@"http"];

代码中从第二行开始,是为了让 WKWebView 发起的请求可以被自定义的 NSURLProtocol 对象拦截而添加的。添加了上面的代码后,就可以在自定义的 NSURLProtocol 子类的方法中截获到 WKWebView 的请求和数据下载完成的事件。

以上方案解决了 WKWebView 无法使用自定义缓存的问题,但是这种方案还存在一些问题,且使用了苹果系统的私有 API,不符合官方规定,在 App 中直接使用有被拒的风险。另外 WKWebView 还有一些其他问题(详情可参见参考资源 6)。

目前,58 App 正在准备接入 WKWebView,但是没有决定使用这种方案来解决自定义缓存问题。我们正在逐步减少对自定义缓存的依赖程度,在前面几个版本迭代中,已经逐步去除了内置的 HTML 页面。

页面加载完成事件优化

正常的 Web 页面加载是比较耗时的,尤其是在网络环境较差的情况下。而 Web 的页面文件与样式表、JavaScript 文件以及图片是分别加载的,很有可能界面元素已经渲染完成,但样式表或 JavaScript 文件还没有加载完,这时会出现布局混乱和事件不响应的情况,影响用户体验。为了不让用户看到这种情况,一般 Native 会在加载 Web 资源的过程中隐藏掉 Web View,或用 Loading 视图遮挡住 Web View。等到 Web 资源加载完成再将 Web View 展示给用户。系统通过 UIWebViewDelegate 的 WebViewDidFinishLoad:方法告知 Native 资源加载完成的事件。这个方法在页面用到的所有资源文件全部加载完成后触发。

在实用中发现,一般情况下样式表资源和 JavaScript 资源的加载速度很快,比较耗时的是图片资源(事实是 Native 界面也存在图片加载比较慢的情况,一般 Native 会采用异步加载图片的策略,即先将界面展示给用户,后台下载图片,下载完成后再刷新图片控件)。实际上当 HTML、样式表和 JavaScript 文件加载完成后,整个界面就完全可以展示给用户并允许用户交互了。图片资源加载完成与否并不影响交互。

且这样的逻辑也与 Native 异步加载图片的体验一致。在 WebViewDidFinishLoad:方法中才展示界面的策略会延长加载时间,尤其在图片很大或网络环境较差的情况下,用户可能需要多等待几倍的时间。

基于以上的考虑,58 App 的 Hybrid 框架专门为 Web 提供了一功能接口,允许 Web 提前通知 Native 展示界面。该功能实现起来很简单,只需单独定义一个 Hybrid 通讯协议,并在 Native 端相应的处理逻辑即可。前端在开发一些图片资源比较多的页面时,提前调用该接口,可以在很大程度上提升用户体验。

耦合

58 App 最初引入 Hybrid 的时候,业务要简单许多,Native 没有现在这么多功能可供 Web 调用,所以最开始设计的 Hybrid 通讯框架也比较简单。由于使用 AJAX 的方式进行通讯,通讯请求的拦截也要在 NSURLCache 中。当时也没有公用的缓存库组件,Hybrid 的缓存功能与内置资源一起写在单独的模块中(最初的 Hybrid 框架如图 4 所示)。

图 4  旧版 Hybrid 框架设计图

图 4 旧版 Hybrid 框架设计图

这个框架在 58 App 中存在了很长一段时间,运行比较稳定。但是随着业务的不断增加,这个框架暴露出了一些比较严重的问题。

自定义的 NSURLCache 类中耦合了 Hybrid 的业务逻辑

由于 AJAX 方式的通讯请求要在 NSURLCache 中进行拦截,NSURLCache 在收到请求后,不得不先判断是否是 Hybrid 通讯请求——如果是,则需要将请求转发给 Hybrid 通讯框架处理。另外,为了解决 Native 回调 Web 时无法确定 Web View 的问题,需要维护一个 Web View 的 Web View 栈,App 内所有的 Web View 对象都需要存入到这个栈中。这个栈需要全局存放,但是 Web 载体页和 Hybrid 事件分发器都是局部对象,无法保存这个栈。考虑到 NSURLCache 对象与 Hybrid 有关联且是单例,最终将这个栈保存在了 NSURLCache 的属性中,更加重了 NSURLCache 与 Hybrid 的耦合。

NSURLCache 耦合 Hybrid 业务逻辑的问题随着 iframe 的引入迎刃而解,通讯请求的拦截直接转移到了 Hybrid 事件分发器中。

NSURLCache 的职责重新恢复单一,只负责缓存相关的内容。使用 iframe 的通讯方式,Web 在调用 Native 功能的请求是在 UIWebView 的代理方法中截获,系统会将相应的 Web view 通过参数传递过来,不再有无法确定 Web view 的问题,之前的 Web view 栈也没有必要再维护了。iframe 的引入使得 Hybrid 的通讯框架和缓存框架完全分离开来,互不干涉。

Web 载体页臃肿

最初的 Hybrid 框架中,action 处理的具体实现写在了 Web 载体页中,这导致 Web 载体页随着业务的增加变得十分臃肿,内部包含大量的 action 处理代码。另外,由于一些为 Web 提供的功能是针对某些特定业务场景的,写在公用载体页中并不合适,所以开始了使用继承的方式派生出各种各样的 Web 载体页,最终导致 App 内的 View Controller 的继承关系十分混乱,继承层次最多时高达九层。

Web 载体页耦合 action 处理的问题是业务逐步累积的结果,当决定要重构的时候,这里的逻辑已经变得十分庞杂。强行将这两部分剥离困难很大,一方面代码太多,工作量大,另一方面逻辑过于复杂,稍有不慎就会引起 Bug。解决 Web 载体页的问题采取的方案分成两部分。

搭建新 Hybrid 框架,逐步淘汰老的框架。

为了解决 Web 载体页臃肿的问题,更为了提供对 iOS 8 WebKit 框架的支持,提升 Hybrid 性能,58 iOS 客户端重新搭建了一套新的 Hybrid 框架。新 Hybrid 框架严格按照图 2 所示的结构进行实现。新增的业务使用新的 Hybrid 框架,并逐步将老的业务切换到新的框架上来。

在图 2 的框架中,为了在增加新的 Hybrid 功能组件时整体框架满足开闭原则,需要解除 Hybrid 处理引擎对 Hybrid 功能组件的依赖。这里采用的设计是,处理引擎不主动添加组件,而是提供全局的注册接口,内部保存一份共享的注册表。各个功能组件在 load 方法中主动向处理引擎中注册 action 名称、功能组件的类名及方法。处理引擎在运行时动态地查阅注册表,找到 action 对应的类名和方法,生成功能组件的实例,并调用相应的处理方法。

按照上面的设计,一个 Web 界面的完整运行流程为:

  1. 程序开始运行,生成全局的 Hybrid 共享注册表(action 名称到类名及方法名的映射),各个 Hybrid 功能组件向注册表中注册 action 名称;
  2. 需要使用 Web 页,应用程序生成 Web 载体页;
  3. Web 载体页生成 Web View 实例和 Hybrid 处理引擎实例,并强持有这两个实例,将处理引擎实例设为 Web view 实例的代理对象,将自身设为处理引擎的代理对象;
  4. Web 页发起 Native 调用请求;
  5. 处理引擎实例截获 Native 调用请求,并在共享注册表中查到可以处理本次请求的类名和方法名;
  6. 处理引擎生成查找到的 Hybrid 功能组件类的实例,强持有之,并将自身的代理对象设为功能组件的代理对象,调用该实例的处理方法;
  7. Hybrid 功能组件解析全部的调用参数,处理请求,并通过代理对象将处理结果回调给 Web 页。
  8. Web 页生命周期完成,释放 Web View 实例、Hybrid 处理引擎实例、Hybrid 引擎实例释放所有的 Hybrid 功能组件实例。

通过使用组件主动注册和运行时动态查找的方式,固化了新增组件的流程,保证已有代码的完备性,使 Hybrid 框架在增加新的功能上严格遵守开闭原则。

关于注册表,目前是采用全局共享的方式保存。在最初设计时,还有另一种动态组合注册的方案。该方案不使用共享的注册表,而是每一个 Hybrid 处理引擎保存一份独立的注册表,在 Web 载体页生成 Hybrid 处理引擎的时候,根据业务场景选择部分 Hybrid 功能组件注册到处理引擎中。这种动态组合的方案对功能组件的组合进行了细化,每个 Web 载体页对象根据各自的业务场景按需注册组件。动态组合注册的方案考虑的主要问题是:在 Hybrid 框架中,有许多专用 Hybrid 功能组件,大部分 Web 页并不需要使用这些组件,另外 58 App 被拆分为主 App 和多个业务线共同维护和开发,有一些 Hybrid 功能组件是业务线独有的,其他业务线并不需要使用。动态组合注册的方案可以达到隔离业务线的目的,同时不使用全局注册表,在不使用 Web 页时不占用内存资源,也减小了单张注册表的大小。

现在的 Hybrid 框架采用全局注册方案,而没有采用动态组合注册的方案,原因是动态组合注册方案需要在生成 Web 载体页时区分业务场景,Web 页的使用方必须提供需要注册的组件信息,而这是比较困难的,也加大了调用方调用 Web 页的复杂程度。另外,大部分组件是否会被使用都是处于模糊状态,并不能保证使用或者不使用,这种模糊性越大,使用动态组合注册方案的意义也就越小。

最终 58 App 采用了全局注册的方案,虽然注册表体积较大,但是由于使用散列算法,并不会增加查找的复杂度而影响性能,同时避免了调用方需要区分业务场景的不便,简化了后续的开发成本。

改造原 Hybrid 框架,防止 Web 载体页进一步扩大

为了保证业务逻辑的稳定,不能直接淘汰老的 Hybrid 框架,老业务中会有一部分新的需求需要在老的框架上继续扩展。为了防止老的 Web 载体页因为这些新需求进一步扩大,决定将原 Hybrid 通讯框架改装为双向支持的结构。在保持原 Web 功能接口处理逻辑不变的情况下,支持以组件的方式新增 Web 功能接口。具体的实现是在 Hybrid 事件分发器中也添加了与新 Hybrid 框架的处理引擎相似的逻辑,增加了全局共享注册表,支持组件向其中注册。在分发处理中添加了查找和调用注册组件的逻辑。改造后的 Hybrid 事件分发器在收到 action 请求后,先按老的逻辑进行分发,如果分发成功则调用载体页的处理逻辑,如果分发失败,则查找共享注册表,找到可以处理该 action 的组件进行实例化,并调用相应的处理逻辑。

虽然 Web 载体页由于继承的关系变得很分散,但是事件分发器一直只有一份,逻辑比较集中。进了这样的改造后,有效扼制了 Web 载体的进一步扩大,也不再需要使用继承来复用 action 处理逻辑了。

总结

本文重点介绍了 58 App 中 Hybrid 框架在设计和发展过程中遇到的问题及采用的解决方案。目前的 Hybrid 框架是一个比较简单实用的框架,前端没有对 Native 提供的功能进行一一封装,这样可以在扩展新 action 协议时尽量少地改动代码。且封装层次少,执行效率比较高。目前的 Hybrid 框架依然很友好地支撑着 58 业务的发展,所以暂时还没引入 JavaScriptCore.framework。在未来的发展中,会逐步引入新技术,搭建更好的 Hybrid。

参考资料

  1. UIWebView Secrets - Part1 - Memory Leaks on Xmlhttpreques
  2. IFrame (Inline Frame)
  3. 《网页加载历程详解》
  4. NSURLCach
  5. 《WKWebView 不支持 NSURLProtocol》
  6. 《WKWebView 那些坑》

作者:杜艳新,刘文军。58 同城 iOS 高级研发工程师,专注于 App Hybrid 框架的架构研发,主导了 58 同城 App 的 Hybird 混合研发的系统架构以及研发。
责编:唐小引,欢迎技术投稿、约稿、给文章纠错,请发送邮件至 tangxy@csdn.net。
本文为《程序员》原创文章,未经允许不得转载,更多精彩文章请订阅《程序员》


评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值