基于UIWebView的混合编程是指同时使用原生的控件和UIWebView来展现应用界面。合理地使用该方案可以保证应用既有原生界面的流畅交互效果,又有Web界面的良好的动态修改和多平台复用的优势。
混合编程简介
基于UIWebView的混合编程本来是一个挺普通和常见的技术框架,但是自从国外开始用Hybird来称呼它时,这个技术突然间就变得“高大上”起来。国内的许多应用都采用了基于UIWebView的混合编程技术,这些页面都具有以下共同的特点:
1.排版复杂。通常包括图片和文字的混排,还有可能有链接需要支持点击。如果不用 UIWebView,自己用原生控件通过拼装来实现,由于界面元素太多,做起来会很困难,而如果用CoreText来实现,就需要自己实现相当多的复杂排版逻辑。
2.界面的变化需求频繁。例如淘宝的彩票页面,可能常常需要更新界面以推出不同的活动。采用UIWebView实现后,这类页面就可以动态地更新而不用向AppStore提交新的版本,而原生实现的界面很难达到如此的灵活性。
3.界面对用户的交互需求不复杂。因为UIWebView实现的交互效果与原生效果相比还是会大打折扣,所以这类界面通常都没有复杂的交互效果。这也是主流应用大多采用混合UIWebView来实现应用界面而不是使用纯UIWebView来实现界面的原因。
如果你的应用界面也具有以上属性,那么你也可以考虑使用UIWebView来实现该界面。下面我们来看看使用该技术方案需要考虑哪些问题。
使用模板引擎渲染HTML界面
在实际开发中,UIWebView控件接受一个HTML内容,用于呈现相应的界面,下面是该API的接口:
1 | - (void)loadHTMLString:(NSString *)string baseURL:(NSURL *)baseURL; |
由于HTML内容通常是变化的,所以我们需要在内存中生成该HTML内容。比较简单粗暴的做法是将该HTML的基本内容定义在一个NSString中,然后用[NSString stringWithFormat:]方法将内容进行格式化,下面是一个示例:
1 | - (NSString *)demoFormatWithName:(NSString *)name value:(NSString *)value { |
但其实我们可以看出,这样写并不舒服,因为:
1.模板内容和代码混在一起,既不方便阅读,也不方便更改。
2.模板的渲染逻辑使用简单的[NSString stringWithFormat]来完成,功能单一。在实际开发中,我们很可能需要将原始数据进行二次处理,而这些如果模板渲染模块不能支持,我们就只能自己手工写这部分数据二次处理,费时费力。例如:微博的详情页面, 如果微博的发送时间小于1天,则需要显示成“xx小时前”,如果小于1分钟,则需要显示成“刚刚”。这些界面渲染方面的逻辑如果能够抽取到专门的排版代码中,则会清晰很多。
所以我们需要一个模板引擎,专门负责这类渲染的工作。
我个人使用过的模板引擎是 MGTemplateEngine,它的模板语言比较像Smarty、FreeMarker和Django。另外它可以自定义Filter,以便实现上面提到的自定义渲染逻辑。它需要依赖RegexKit, RegexKit是一个正则表达式工具类,提供强大的正则表达式匹配和替换功能。
不喜欢模板引擎功能太过于复杂的朋友,也可以尝试GRMustache,它比MGTemplateEngine、GRMustache的功能更简单。另外GRMustache 在开源社区更加活跃,更新更频繁。
对于上面的示例代码,在使用GRMustache模板后,我们首先需要调整模板的内容:
1.将模板内容放在另一个单独的文件中,方便日后更改。
2.将原来的%@,替换成的形式。
模板调整后变成了如下内容(文件名为template.html):
1 | <HTML> |
然后我们在代码中将该文件读取到内存中,再使用GRMustache的renderobject方法生成渲染后的HTML内容,示例代码如下:
1 | - (NSString *)demoFormatWithName:(NSString *)name value:(NSString *)value { |
这样,我们使用GRMustache模板引擎成功完成了HTML内容渲染工作,之后就可以通过如下代码来让UIWebView加载HTML的内容了:
1 | NSString *path = [[NSBundle mainBundle] bundlePath]; |
Objective-C语言和JavaScript语言相互调用
原生界面与UIWebView相互调用示意图如下所示。
在UIWebView通过一次性加载HTML内容获得初始界面后,对于复杂的应用,我们还需要在原生界面和UIWebView界面相互调用传递数据。但是,iOS的UIWebView控件在与原生界面交互数据这方面功能较弱,所以我们需要详细看一下原生界面和UIWebView界面是如何做到相互调用的。因为原生界面是用Objective-C语言写的,而UIWebView界面是用JavaScript写的,所以我们讨论的主要就是如何做Objective-C语言和JavaScript语言之间的跨语言相互调用。
Objective-C语言调用JavaScript语言,是通过UIWebView的-(NSString *)stringByEvaluatingJavaScriptFromString: (NSString *)script;方法实现的。该方法向UIWebView传递一段需要执行的JavaScript文件,最后获得执行结果。
JavaScript语言调用Objective-C语言,并没有现成的API,但是业界有一种“曲线救国”的方法,间接达到了调用的效果。该方法是利UIWebView的特性:在UIWebView内发起的所有网络请求,都可以通过delegate函数在原生界面得到通知。这样我们在UIWebView内发起一个特殊的网络请求,请求加载的网址内容通常不是真实的地址,地址常常类似这样: gap://methodname?argument0
于是在UIWebView的delegate函数中,我们只要发现是gap://开头的地址,就不进行内容的加载,转而执行相应的调用逻辑。这也是著名的Cordova (PhoneGap的核心代码,贡献给了Apache基金会)框架调用原生逻辑的机制。以下是原生端截获特殊网络请求进行处理的示例代码:
1 | // Objective-C语言 |
在UIWebView中发起一次特殊的网络请求也有很多种办法,最合适的办法是创建一个临时的隐藏的iFrame,在iFrame中加载这个特殊的网络请求。代码如下所示:
1 | // JavaScript语言 |
需要特别注意的是,通过修改document.location也可以达到发起网络请求的效果。但是经过我们试验,修改document.location有一个很严重的问题,就是如果我们连续两次修改document.location的话,在原生界面的delegate方法中,只能截获后面那次请求,前一次请求由于很快被替换掉,所以被忽略掉了。所以该方法无法稳定地多次向原生界面发起调用。
如果你不想自己实现具体的相互调用,你也可以使用开源的WebViewJavaScriptBridge,它能帮你实现上面我们提到的 相互调用功能。
如何传递参数
以上的示例代码为了讲清楚机制,所以只是示例了最简单的相互调用。但实际上Objective-C语言和JavaScript语言相互调用时,常常需要传递参数。
参数传递最简单的方式是将参数作为URL的一部分,放到iFrame的src里面。这样UIWebView通过截取分析URL后面的内容即可获得参数。但是这样的问题是,该方法只能传递简单的参数信息,如果参数是一个很复杂的对象,那么这个URL的编码将会很复杂。
Cordova的技术方案,是用JSON传递参数,将JSON放在UIWebView中的一个全局数组中,当UIWebView需要读取参数时,通过读取这个全局数组来获得相应的参数。
同步和异步
因为iOS SDK并非“天生”就支持UIWebView和原生界面相互调用,所以这里面还有同步异步的问题。细心的读者就能发现,UIWebView调用原生界面是通过插入一个iFrame,这个iFrame插入后就完了,执行的结果需要原生界面另外用stringByEvaluatingJavaScriptFromString方法通知UIWebView,所以这是一个异步的调用。
而stringByEvaluatingJavaScriptFromString方法本身会直接返回一个NSString类型的执行结果,所以这显然是一个同步调用。
所以UIWebView调用原生界面是异步,原生界面调用UIWebView是同步。在处理一些逻辑的时候,不可避免地需要考虑这个特点。
注意事项
线程阻塞问题
我们在开发中发现,当在Objective-C语言中调用stringByEvaluatingJavaScriptFromString方法时,可能由于JavaScript是单线程的原因,会阻塞原有JavaScript代码的执行。这里我们的解决办法是在JavaScript端用defer将iFrame的插入延后执行。
主线程的问题
UIWebView的stringByEvaluatingJavaScriptFromString方法必须在主线程中执行,而主线程的执行时间过长就会阻塞UI的更新。所以我们应该尽量让stringByEvaluatingJavaScriptFromString方法执行时间短。
键盘控制
做iOS开发的都知道,当我们需要键盘显示在某个控件上时,可以调用[obj becomeFirstResponder]方法来让键盘出来,并且光标输入焦点出现在该控件上。
但是这个方法对于UIWebView并不适用。也就是说,我们无法通过程序控制让光标输入焦点出现在UIWebView上。关于这个问题,我在Stack Overflow专门问了一下,还是没有得到很好的解决办法。
CommonJS 规范
CommonJS是一个模块加载的规范,而AMD是该规范的一个草案。CommonJS AMD规范描述了模块化的定义、依赖关系、引用关系及加载机制,其规范原文在这里: http://wiki.commonjs.org/wiki/Modules/AsynchronousDefinition 它被requireJS,Nodejs,Dojo,jQuery等开源框架广泛使用。
AMD规范需要用目录层级当作包层次,这一点就象Java—样。所以我们需要iOS打包后的ipa资源文件中具有资源目录层级关系。具体做法非常简单:只需要将该目录拖入工程中, 然后选择”Create groups for any added folders”,这样目录层级就能够打包到ipa 文件中。
使用Safari进行调试
由于UIWebView的使用实在广泛,所以从2012年开始,苹果支持用Safari浏览器直接连接到模拟器或真机中的UIWebView来进行相关HTML页面,以及JavaScript逻辑的调试。
在使用之前,首先需要打开Safari的调试模式,在Safari的菜单中,选择”Safari” —> “Preferences” —> “Advanced”,勾选上Show Develop menu in menu bar”选项,如下图所示。
同时需要在iPhone模拟器或真机的设置上把调试模式打开,在iPhone模拟器或真机中打开应用设置界面,选择”Safari” —> “高级” —> “Web检査器”即可,如下图所示。
之后启动模拟器或者真机,通过USB连上电脑时,Safari的”Develop”菜单下就会多出相应的菜单项,如下图所示。
Safari连接上UIWebView后,我们可以在Safari中直接修改HTML的代码、css效果,以及调试JavaScript所有的效果都可以立即在UIWebView上看到。Safari也提供了”审査元素”功能,可以通过下图中的小手图标完成,单击小手图标,然后在Web上想査看的元素上单击一下,就可以跳到该元素对应的DOM节点上。
参考资料:《iOS开发进阶》