引用
作者:杜艳新,刘文军。58同城iOS高级研发工程师,专注于App Hybrid框架的架构研发,主导了58同城App的Hybird混合研发的系统架构以及研发。
责编:唐小引,欢迎技术投稿、约稿、给文章纠错,请发送邮件至tangxy@csdn.net。
本文为
《程序员》原创文章,未经允许不得转载,更多精彩文章请
订阅《程序员》
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 传统的通讯方式流程
按照上面的思路,在实现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的详细过程为:
- 接受网络数据;
- 将二进制码变成字符;
- 将字符变为Unicode code points;
- Tokenizer;
- Tree Constructor;
- DOM Ready;
- Window Ready。
Dom Ready事件就是DOM Tree创建完成后触发的。在业务开发过程中,有少量比较特殊的需求,需要在DOM Ready事件之前发起Native功能的调用,而动态添加iframe的方法并不能满足这种需求。为此,我们对其他几种发起请求的方法进行了调查,包括前文提到的AJAX请求、为window.location.href赋值、使用img标签的src属性、调用window.open()方法(各个方式的表现结果如表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框架设计
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页带来的加载慢和流量耗费大的问题。缓存策略的一般思路是:
- 内置通用的资源和关键页面;
- 按需缓存常用页面;
- 为缓存设置版本号,根据版本号进行使用和升级。
58 App中对一些通用资源和十分重要的Web页面进行了内置,防止App在首次启动时由于网络原因导致某些重要页面无法展示。在缓存使用和升级的策略上,58 App除了设置版本号以外,还针对那些已过期但还可用的缓存数据设置了缓存过期阈值。58 App的详细缓存策略如下:
- 将通用Hybrid资源(CSS、JS文件等)和关键页面(比如业务线大类页)附带版本号内置到App的特定Bundle中;
- 在NSURLCache单例中拦截到请求后,判断该请求是否带有缓存版本号信息,如果没有,说明该页面不使用缓存,走正常网络请求;
- 从缓存库中查找缓存数据,如果有则取出,否则到内置资源中取。如果两者都没有数据,走正常网络请求。并在请求完成后,将结果保存到缓存库中;
- 拿到缓存或内置数据后,将请求中带的版本号v1与取到数据的版本号v2进行对比。如果v1≤v2,返回取到的数据,不再请求网络;如果v1>v2且v1 – v2小于缓存过期阈值,则先返回缓存数据以供使用,然后后台请求新的数据并存入缓存;如果v1>v2且v1 – v2大于缓存过期阈值,走正常网络请求,并在请求完成后,将结果保存到缓存库中。
缓存框架设计
58 App中Hybrid的缓存框架设计如图3所示,其中:
图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框架设计图
这个框架在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界面的完整运行流程为:
- 程序开始运行,生成全局的Hybrid共享注册表(action名称到类名及方法名的映射),各个Hybrid功能组件向注册表中注册action名称;
- 需要使用Web页,应用程序生成Web载体页;
- Web载体页生成Web View实例和Hybrid处理引擎实例,并强持有这两个实例,将处理引擎实例设为Web view实例的代理对象,将自身设为处理引擎的代理对象;
- Web页发起Native调用请求;
- 处理引擎实例截获Native调用请求,并在共享注册表中查到可以处理本次请求的类名和方法名;
- 处理引擎生成查找到的Hybrid功能组件类的实例,强持有之,并将自身的代理对象设为功能组件的代理对象,调用该实例的处理方法;
- Hybrid功能组件解析全部的调用参数,处理请求,并通过代理对象将处理结果回调给Web页。
- 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。
参考资料
3 楼 khan 2017-07-18 09:00
2 楼 itshu 2017-07-17 09:53
1 楼 itshu 2017-07-17 09:47