Hello, World!

基于UIWebView的混合编程

字数统计: 3.3k阅读时长: 12 min
2018/03/16 Share

基于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
2
3
4
5
6
7
8
9
10
11
12
13
- (NSString *)demoFormatWithName:(NSString *)name value:(NSString *)value {
NSString *html =
@"<HTML>"
"<HEAD>"
"</HEAD>"
"<BODY>"
"<H1>%@</H1>"
"<P>%@</P>"
"</BODY>"
"</HTML>";
NSString *content = [NSString stringWithFormat:html, name, value];
return content;
}

但其实我们可以看出,这样写并不舒服,因为:
1.模板内容和代码混在一起,既不方便阅读,也不方便更改。
2.模板的渲染逻辑使用简单的[NSString stringWithFormat]来完成,功能单一。在实际开发中,我们很可能需要将原始数据进行二次处理,而这些如果模板渲染模块不能支持,我们就只能自己手工写这部分数据二次处理,费时费力。例如:微博的详情页面, 如果微博的发送时间小于1天,则需要显示成“xx小时前”,如果小于1分钟,则需要显示成“刚刚”。这些界面渲染方面的逻辑如果能够抽取到专门的排版代码中,则会清晰很多。
所以我们需要一个模板引擎,专门负责这类渲染的工作。

我个人使用过的模板引擎是 MGTemplateEngine,它的模板语言比较像Smarty、FreeMarker和Django。另外它可以自定义Filter,以便实现上面提到的自定义渲染逻辑。它需要依赖RegexKit, RegexKit是一个正则表达式工具类,提供强大的正则表达式匹配和替换功能。
不喜欢模板引擎功能太过于复杂的朋友,也可以尝试GRMustache,它比MGTemplateEngine、GRMustache的功能更简单。另外GRMustache 在开源社区更加活跃,更新更频繁。

对于上面的示例代码,在使用GRMustache模板后,我们首先需要调整模板的内容:
1.将模板内容放在另一个单独的文件中,方便日后更改。
2.将原来的%@,替换成的形式。
模板调整后变成了如下内容(文件名为template.html):

1
2
3
4
5
6
7
8
<HTML>
<HEAD>
</HEAD>
<BODY>
<H1> {{ name }} </H1>
<P> {{ content }}</P>
</BODY>
</HTML>

然后我们在代码中将该文件读取到内存中,再使用GRMustache的renderobject方法生成渲染后的HTML内容,示例代码如下:

1
2
3
4
5
6
7
8
- (NSString *)demoFormatWithName:(NSString *)name value:(NSString *)value { 
NSString *fileName = @"template.html";
NSString *path = [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:fileName];
NSString *template = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
NSDictionary *renderObject = @{@"name": name, @"content": value };
NSString *content = [GRMustacheTemplate renderObject:renderobject fromString: template error:nil];
return content;
)

这样,我们使用GRMustache模板引擎成功完成了HTML内容渲染工作,之后就可以通过如下代码来让UIWebView加载HTML的内容了:

1
2
3
4
5
NSString *path = [[NSBundle mainBundle] bundlePath];
NSURL *baseURL = [NSURL fileURLWithPath:path];
//通过模板渲染得到内容
NSString *htmlString = [self htmlContent];
[self.webView loadHTMLString:htmlString baseURL:baseURL];

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
2
3
4
5
6
7
8
9
10
11
12
// Objective-C语言
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
NSURL *url = [request URL];
if ([[url scheme] isEqualToString:@"gap"]) {
//在这里做JavaScript调Objective-C的事情
// ....
//做完,之后用如下方法调回JavaScript
[webView stringByEvaluatingJavaScriptFromString:@"alert('done')"];
return NO;
}
return YES;
}

在UIWebView中发起一次特殊的网络请求也有很多种办法,最合适的办法是创建一个临时的隐藏的iFrame,在iFrame中加载这个特殊的网络请求。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// JavaScript语言
// 通知iPhone UIWebView加载url对应的资源
// url的格式为:gap://something
function loadURL(url) (
var iFrame;
iFrame = document.createElement("iFrame");
iFrame.setAttribute('src", url);
iFrame.setAttribute("style", "display:none;");
iFrame.setAttribute("height", "Opx");
iFrame.setAttribute("width", "Opx");
iFrame.setAttribute("frameborder", "0");
document.body.appendChild(iFrame);
//发起请求后这个iFrame就没用了,所以把它从dom上移除掉
iFrame.parentNode.removeChild(iFrame);
iFrame = null;
}

需要特别注意的是,通过修改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”选项,如下图所示。

Safari调试

同时需要在iPhone模拟器或真机的设置上把调试模式打开,在iPhone模拟器或真机中打开应用设置界面,选择”Safari” —> “高级” —> “Web检査器”即可,如下图所示。

Safari调试

之后启动模拟器或者真机,通过USB连上电脑时,Safari的”Develop”菜单下就会多出相应的菜单项,如下图所示。

Safari调试

Safari连接上UIWebView后,我们可以在Safari中直接修改HTML的代码、css效果,以及调试JavaScript所有的效果都可以立即在UIWebView上看到。Safari也提供了”审査元素”功能,可以通过下图中的小手图标完成,单击小手图标,然后在Web上想査看的元素上单击一下,就可以跳到该元素对应的DOM节点上。

Safari调试

参考资料:《iOS开发进阶》

CATALOG
  1. 1. 混合编程简介
  2. 2. 使用模板引擎渲染HTML界面
  3. 3. Objective-C语言和JavaScript语言相互调用
  4. 4. 如何传递参数
  5. 5. 同步和异步
  6. 6. 注意事项
    1. 6.1. 线程阻塞问题
    2. 6.2. 主线程的问题
    3. 6.3. 键盘控制
    4. 6.4. CommonJS 规范
  7. 7. 使用Safari进行调试