Project4:用WKWebView做一个简易浏览器
概述
摘要:嵌入Web Kit,学习委托,KVO,类和UIToolbar。
概念:loadView(),WKWebView,委托,类和结构体,NSURLRequest,UIToolbar,UIProgressView,键-值观察。
1.设置
2.用WKWebView创建一个简易浏览器
3.选择一个网站:UIAlertController
4.监控页面载入:UIToolbar和UIProgressView
5.最后的重构
6.总结
设置
这个项目要通过写一个浏览器app来告诉你关于UIBarButtonItem,UIAlertController和NSURL的知识。这又是个简单的项目,但学习就是一边挑战新事物一边回顾旧知识的过程。
为了让这个过程更加美好,我会用这个机会来告诉你很多新东西:WKWebView(Apple的超级网络插件),UIToolbar(放UIBarButtonItem的工具栏组件),UIProgressView,委托,类和结构体,关键值观察,还有怎么在代码中创建你自己的视图。最后,这是最后一个简单项目。
开始吧。用Single View Application template创建一个新Xcode项目,命名为project4,选择iPhone和swift,然后保存到你的桌面。打开Main.storyboard,选择视图控制器,然后选择Editor>Embed In>Navigation Controller——这样我们的故事板就完成了。
用WKWebView创建一个简易浏览器
在项目1和2中,我们用IB完成了大量的布局工作,但这里我们的布局十分简单,所以我们差不多可以用代码完成所有的任务。之前我们都是在视图中添加按钮和图像,但这里网络视图将要占据视图控制器的整个页面空间,自然而然它就是主视图。
到目前为止,布局载入之后,我们都是用方法viewDidLoad()来设定我们的视图。这次我们需要重写视图的实际载入过程——我们不要故事板上的空白,我们要自己的代码。它依旧会在导航控制器中,但其它的由我们来决定。
打开ViewController.swift,在viewDidLoad()之前加上以下代码:
override func loadView() {
webView = WKWebView()
webView.navigationDelegate = self
view = webView
}
并不是非得把loadView()放在viewDidView()前面——你可以把它放在class ViewController: UIViewController { .. }两个大括号之间的任何位置。但我建议你按一定顺序来放置你的方法,因为loadView()在viewDidLoad()之前被调用,所以它应该被写在前面。
不管怎么说,我们只关心三件事,因为现在你需要理解为什么我们要用关键字override。(提示:因为有从故事板载入布局的默认方法的存在!)第一步,我们创建了一个Apple的WKWebView网络浏览器组件的新实例而且把它赋值给一个变量webView。第三步,我们让网络视图变成我们的视图。
是的,我漏掉了第二步,这是因为这里有一个新的概念:委托。委托是一种编程模式——在iOS中被大面积使用,因为一些很好的理由:很好懂,很好用,而且特别灵活。
委托是指A事物在B事物的方式行动,以A自己的名义来高效地回答问题和回应事件的发生。在我们的例子中,我们使用的是WKWebView:Apple的强大、灵活和高效的网络渲染。但即便聪明如WKWebView,它也不知道我们的app想要如何表现,因为它现在是我们自定义的代码。
委托办法很巧妙:我们可以告诉WKWebView当有趣的事情发生时通知我们。在这里,我们把网络视图的navigationDelegate属性设置成self,表示“当任何网页导航发生时,请告诉我。”
当你做这件事时,有两件事会发生:
1.你必须遵从协议。这是在用一种神奇的方式表达“如果你告诉我你可以接受我的委托,这里是你得实现的一些方法。”在navigationDelegate的情况中,所有的方法都是可选项,表示我们并不需要实现所有的方法。
2.任何你实现的方法都可以控制WKWebView的行为,任何你没有实现的方法都会使用WKWebView的默认行为。
在我们深入之前,你可能注意到你的代码实际并不能编译。有三个原因,可以秒修复。
理由1:Swift不知道什么是WKWebView。你也发现,它不是以UI开头的,所以不是UIKit的部分。所以我们需要引进一个新的架构,这样我们就可以用了。“WKWebView”中的“WK”代表WebKit,所以回到文件顶部把它调整为:
import UIKit
import WebKit
理由2:我们没有定义一个webView的属性,但我们已经给它赋值了。可以在loadView()方法前面加上它的定义来修复:
var webView: WKWebView!
这定义了一个隐式解析可选的WKWebView实例叫webView。我会再解释一遍这样我们就会清楚:在WKWebView的结尾处的“!”是必要的因为这个属性在被设置之前是nil。
理由3:当你设置任何委托,你需要遵从协议。是的,所有的navigationDelegate协议方法都是可选的,但是Swift并不清楚。它所知道的是我们承诺这个委托对于网络视图来说是合适的,但还没有履行协议。
修复方法很简单,但我想插入点其他东西,因为这个时间很合适。首先是修复:找到这行:
class ViewController: UIViewController {
把它改写成:
class ViewController: UIViewController, WKNavigationDelegate {
就好了。我想聊的是class。因为我已经在使用一些词比如“数据类型”、“组件”和“实例”等等,但却不是非常的清楚——而且我感保证很多开发者完全就只是把它们看成是一种结果。你好,讨厌鬼!
Swift中有两种复杂的数据类型:结构体和类。它们很像,实际上就只有两个要紧的区别。
第一个是类可以被继承。在项目1里面我们就聊过了。在子类中你可以使用任何一种父类中定义好了的东西,而且你还可以在顶部加上你自己的定制内容。
第二个不同是当你把一个结构体传入方法时,你传入的是一个副本。也就是说,方法对结构体的影响仅限于方法内部。另一方面,当你把一个类的实例传入到方法中时,传入的是引用,即方法中操作的对象就是方法外的对象,所有的修改都会被保存下来。
就谁是谁而言:Int,Double,Float,String和Array都是结构体,UIViewController和任何UIView都是类。实际操作中,这意味着不论何时你把一个数组传入到一个方法中,它就会被复制一份。粗看十分低效,特别是数组的数据量十分巨大的时候,但不用担心:Swift会利用写时拷贝(copy on write)尽可能避免任何表现打折。
回到代码:所有的这些都很重要,因为我希望你理解这行代码到底在说什么:
class ViewController: UIViewController, WKNavigationDelegate {
如你所见,代码以class开头,表明我们是在定义一个新类。接下来的大括号内的所有内容都属于这个新类。ViewController是类的名字,不是什么特别牛叉的名字。
接下来的东西很有趣:是个冒号,然后是UIViewController和一个逗号,还有WKNavigationDelegate。这一部分叫做类型继承从句,它真正的意思是告诉我们新类ViewController是由什么构成的:它继承自UIViewController,履行WKNavigationDelegate协议。
顺序很重要:第一个是父类,然后是所有需要履行的协议,都用逗号隔开。我们是说这里我们只遵循一个协议,但你可以添加你所需要的任何协议,无论多少。
所以,这行代码的意思是“创建一个叫ViewController的UIViewController的子类,然后告诉编译器我们可以安全使用WKNavigationDelegate协议。”
程序差不多可以用了,运行之前让我们再加三行。请把下面的代码加入到viewDidLoad()中,就在super调用后面:
let url = NSURL(string: "https://www.hackingwithswift.com")!
webView.loadRequest(NSURLRequest(URL: url))
webView.allowsBackForwardNavigationGestures = true
第一行创建了一个新NSURL,跟前一个项目一样。这里我用了hackingwithswift.com作为示例,你可以改成其他你喜欢的。警告:你得确保你用https://来上你的网,因为iOS9不喜欢app不加安全措施地发送和接收数据。如果你想改写什么,看下iOS9的App数据传输安全。
第二行做了两件事:根据NSURL创建了一个新NSURLRequest对象,然后把它交给我们的网络视图去载入。
现在Apple系统可能会觉得它有点无厘头,但WKWebView不会从像www.hackingwithswift.com这样的字符串载入网站,甚至不会从一个NSURL中载入。你得先把字符串变成NSURL,然后再把它放进NSURLRequest中,这样WKWebView才会载入。幸好不是太困难!
警告:你的URL必须是完整而且有效的,为了让这个进程可以工作。也就是说,https://也是必要的。
第三行激活了网络视图中的一个属性,这样用户就可以通过从网络浏览器屏幕中的左边沿扫到右边沿来返回之前的页面。这是个Safari中常用的功能,所以最好保留。
按下Cmd+R来运行下你的app,你应该可以浏览你的网站了。第一步完成!
选择一个网站:UIAlertController
我们要锁定这个app这样它就只能打开用户指定的网站。第一步是提供一些我们选好了的网站,这样用户可以自己选择想要登陆的网址,也就是说,我们需要在导航栏加上一个按钮。
在viewDidLoad()中找个地方(通常是在super.viewDidLoad()下面)添加一下代码:
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Open", style: .Plain, target: self, action: "openTapped")
之前的项目已经做过同样的事情了,除了这里我们用的是一个自定义标题而不是系统图标。它调用了openTapped()方法,目前还不存在。所以现在我们来创建它。把这个方法放到viewDidLoad()下面:
func openTapped() {
let ac = UIAlertController(title: "Open page...", message: nil, preferredStyle: .ActionSheet)
ac.addAction(UIAlertAction(title: "apple.com", style: .Default, handler: openPage))
ac.addAction(UIAlertAction(title: "hackingwithswift.com", style: .Default, handler: openPage))
ac.addAction(UIAlertAction(title: "Cancel", style: .Cancel, handler: nil))
presentViewController(ac, animated: true, completion: nil)
}
警告:如果本章一开始你没有把目标设备设置成iPhone,上面的代码很可能无法正常工作。我跟你说过要设置成iPhone,但很多人可能直接略过了。如果你选择的是iPad或Universal,你需要在openTapped()里,在显示警告控制器的前面加上ac.popoverPresentationController?.barButtonItem = self.navigationItem.rightBarButtonItem。
我们已经在项目2里用过UIAlertController类了,但这里有些许不一样,因为三个原因:
1.我们在message中使用nil,因为这个警告不需要调用方法。
2.我们用.ActionSheet是因为我们要向用户推送更多的信息。
3.我们添加了专用取消按钮风格.Cancel,它调用的也是nil,就是隐藏警告控制器。
我们的网站按钮都是指向方法openPage(),虽然它还没写好。跟我们之前载入网页的方式很像,但现在你至少会看到为什么UIAlertAction的操作方法要用一个参数来告诉你哪个动作被选中了。
把下面的方法直接加到方法openTapped()后面:
func openPage(action: UIAlertAction!) {
let url = NSURL(string: "https://" + action.title!)!
webView.loadRequest(NSURLRequest(URL: url))
}
方法只有一个参数,即用户选中的UIAlertAction类对象。很明显如果Cancel被触碰它就不会被调用,因为它的操作方法是nil,而不是openPage。
方法完成的是,用动作的属性title(apple.com等)和前面加上的“https://”来确保传输安全,然后以此构建一个NSURL。然后用NSURLRequest将其打包再交给网络视图去载入。你需要做的就是确保UIAlertController中的网址都是正确的,其他的交给方法去做。
现在你可以测试下app了,但这里还有个小改动可以让整个体验水平上升:把标题放到导航条中去。现在我们被网络视图的导航委托,意思是任何有趣的导航发生,比如网页载入完成时,我们都会被通知到。我们要用这个来设置导航栏标题。
一旦我们告诉Swift我们的ViewController类遵从WKNavigationDelegate协议,Xcode就会及时升级它的系统来支持所有的WKNavigationDelegate方法。之后如果你在openPage()方法下面开始输入“web”,你就会看到一系列可以用的WKNavigationDelegate方法。
滚动可选列表直到你找到didFinishNavigation后按下回车让Xcode为你完成方法。现在把它修改成下面的样子:
func webView(webView: WKWebView, didFinishNavigation navigation: WKNavigation!) {
title = webView.title
}
这个方法就是为了让视图控制器的title属性值变成网页视图的标题,也就是最近载入的网页标题。
按下Cmd+R来运行app,你会看到网站首页,还有在载入完成时导航栏中的标题。
监控页面载入:UIToolbar和UIProgressView
现在是个介绍两个新的UIView子类UIToolbar和UIProgressView的好时机。UIToolbar有一系列用户可以触碰的UIBarButtonItem对象。我们已经见过每个视图控制器是怎么拥有一个rightBarButton的,UIToolbar就像一个工具栏。UIProgressView是一个彩色条状物,可以表示任务的进展程度,所以有时会被称为“进度条”。
我们将要使用UIToolbar的方法非常简单:当在UINavigationController中被激活时,视图控制器自带一个toolbarItems数组。
这跟视图控制器被激活时rightBarButtonItem的出现方式很像。我们只需要设置好数组,然后告诉导航控制器要显示它的工具栏就好了。
首先要创建两个UIBarButtonItem,其中有一个有点特别是因为它是一个可变空间。这是种特别的UIBarButtonItem类型,它表现得像个喷泉,一旦它所有的空间都被用上了它就会把其他按钮挤到一边。
在viewDidLoad()中的rightBarButtonItem下面我们加上以下代码:
let spacer = UIBarButtonItem(barButtonSystemItem: .FlexibleSpace, target: nil, action: nil)
let refresh = UIBarButtonItem(barButtonSystemItem: .Refresh, target: self, action: "refreshTapped")
toolbarItems = [spacer, refresh]
navigationController?.toolbarHidden = false
第一行是新内容,至少一部分是:我们用系统自带类型.FlexibleSpace来创建一个代表可变空间的按钮。它不需要目标或是动作,因为不会被按。第二行我们已经见过了,只不过现在调用的是方法refreshTapped()。
最后两行是新的:第一行把可变空间和刷新按钮放到一个数组中,然后把它赋值给视图控制器的toolbarItems数组。第二行把导航控制器的toolbarHidden属性设置成假,即工具栏和它的子项都会在当前视图中显示。
编译,运行。然后你会看到刷新按钮整齐地对齐到右侧——这就是可变空间的效果,它会尽可能占据左边开始的所有空间。但如果你点击它app就会崩溃。
回到Xcode中,你甚至可能注意到屏幕下方的窗格中出现一个很长的崩溃记录。你要滚动到顶部然后从那里开始阅读,因为剩下的很多现在都没用。你会看到下面的内容:
[project4.ViewController refreshTapped]: unrecognized selector sent to instance
"selector"是方法的另一种称呼。好吧,不是很准确的说法——它要比方法聪明点儿,但你只需要把它当做方法就可以了。所以,错误信息的意思是“我试着调用了refreshTapped(),但我找不到它”——这就对了!让我们在类的最后加上它:
func refreshTapped() {
webView.reload()
}
还没完,你可以看到:WKWebView有个方法reload(),就是用来重新载入网页的。这很简单,所以我们可以把它直接放到UIBarButtonItem的语句里面。所以把refresh按钮的定义改成这样:
let refresh = UIBarButtonItem(barButtonSystemItem: .Refresh, target: webView, action: "reload")
把webView作为目标,把“reload”作为动作意思是按钮指向webView.reload()。很简单!
下一步是把UIProgressView加入到工具栏中,它可以显示完成载入还要多久。它需要两个信息:
你不能直接把随机的UIView子类交给UIToolbar或rightBarButtonItem属性。相反,你得先把它们打包成UIBarButtonItem,这样就可以了。
虽然WKWebView会用它的estimatedProgress属性告诉我们页面载入多少了,但WKNavigationDelegate系统不会告诉我们什么时候这个值被更改了。所以,我们要通过一个叫关键值观察,即KVO的强大技术来通知我们。
首先,我们创建一个进程视图然后把它放到导航条按钮里面。从顶部的定义开始,就放在WKWebView属性下面:
var progressView: UIProgressView!
现在把下面的代码直接放到viewDidLoad()中“let spacer = ”行前面:
progressView = UIProgressView(progressViewStyle: .Default)
progressView.sizeToFit()
let progressButton = UIBarButtonItem(customView: progressView)
三行都是新的:
1. 第一行创建了一个UIProgressView的实例,给了默认样式。还有另外一种叫.Bar的样式,不是用填充线来表示进度的,这里用默认的最好。
2.第二行让进程视图根据它的内容来设置布局尺寸。
3.最后一行用customView参数来创建一个新的UIBarButtonItem,也就是我们在UIBarButtonItem里面打包UIProgressView的地方,这样它就可以直接进入我们的工具栏了。
进程控件创建好了之后,我们可以把他放进toolbar里面的任意位置。现有的spacer会自动缩小自己的尺寸来放置进程按钮,所以我要把toolbarItems数组调整如下:
toolbarItems = [progressButton, spacer, refresh]
即进程视图在最前面,中间留空,最后是刷新按钮。
现在运行的话,你会看到一条代表进程的灰线——颜色值默认为0。理想情况下我们想把它设置成跟webView的estimatedProgress值相同,也就是0~1之间的一个数值,但WKNavigationDelegate不会告诉我们什么时候它的值变化了。
Apple用来解决这个问题的办法有很多,很强大,而且无处不在,学一次就可以次次用。它叫做KVO,可以代表你跟Swift说:“请告诉我什么时候谁改变了对象Y的X属性。”
我们要用KVO来观察estimatedProgress属性,真的很简单。首先在viewDidLoad()中,我们通过添加下面的代码来把自己作为网络视图中的属性观察者:
webView.addObserver(self, forKeyPath: "estimatedProgress", options: .New, context: nil)
方法addObserver()有四个参数:谁在观察,观察什么属性,想要什么值,还有个环境值。
forKeyPath并非forProperty是因为它并不仅仅只是输入一个属性名。你可以指定一个路径:一个属性中的属性中的属性……更深入的关键路径还可以添加功能,比如一个数组中所有元素的平均值!
context简单点儿:就是用于定位,也就是你指定的值的变化和变化发生的环境被一并返回到你这里。
警告:在更复杂一点的应用中,每个addObserver()都得跟一个removeObserver()配对,在观察结束时——比如你在视图控制器里的观察结束时。
一旦你作为观察者使用了KVO,你必须执行observeValueForKeyPath()方法。它会告诉你什么时候观察值发生了变化,所以,现在让我们加上这个方法:
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer) {
if keyPath == "estimatedProgress" {
progressView.progress = Float(webView.estimatedProgress)
}
}
代码表示哪个对象被改过了,同时还有之前登记过的环境,这样你就能知道它是不是你要的那个对象。
在这个项目中,我们关心的是参数keyPath是否被赋值给estimatedProgress——即estimatedProgress的值是否被变更过。如果是,那我们就把新的estimatedProgress值赋给进程视图的progress属性。
小提示:estimatedProgress是Double类型的,而UIProgressView的progress是Float。Swift不允许把Double放进Float中去,所以我们得从这个Double中创建一个新的Float。
最后的重构
我们的app有个致命的漏洞,想修复的话要么让代码量翻倍,要么重构。一般而言,第一个选项都是最简单的,然而反直觉的是它也是最难的。
漏洞是介个样子的:我们让用户从网站列表中选择,但一旦他们登陆所选网站之后,他们可以跟着链接随便去哪儿都行。如果能确保之后跳转的链接地址都在我们的安全列表上,这不是很赞的一件事嘛?
第一种办法——复制一份代码——即写两份可登陆的网址名单:一次是在UIAlertController中,另外一次是我们检查链接的时候。很简单,但也可能成为陷阱:你有两份网址清单,而你得让它们同时保持更新。而且如果你在复制过了的代码中发现了bug,你会记得要在第二份里面也去修复嘛?
第二种办法叫重构(refactoring),这是一种非常高效的重写代码的办法。虽然结果也得相同。重写的目的是为了让它更高效,更容易读取,降低它的复杂性,让它更灵活。最后一项才是我们的真正目的:我们重构代码就是为了一个共享的允许网址数组。
在定义两个属性webView和progressView的后面加上:
var websites = ["apple.com", "hackingwithswift.com"]
这是个包含我们允许用户访问的网址的数组。
利用这个数组,我们可以简化网络视图的初始网址这样就会易于编程。在viewDidLoad()中,把网页初始化部分改成:
let url = NSURL(string: "http://" + websites[0])!
webView.loadRequest(NSURLRequest(URL: url))
目前为止还挺简单的。下一个改动就是让UIAlertController为它的一系列UIAlertAction来使用网址。找到openTapped()方法,然后把下面的两行:
ac.addAction(UIAlertAction(title: "apple.com", style: .Default, handler: openPage))
ac.addAction(UIAlertAction(title: "hackingwithswift.com", style: .Default, handler: openPage))
改成下面的循环:
for website in websites {
ac.addAction(UIAlertAction(title: website, style: .Default, handler: openPage))
}
这会为我们数组中个每个网址都添加一个UIAlertAction对象,也不太复杂。
最后一个改动是新的,属于WKNavigationDelegate协议。如果你找个地方输入以web开头的新方法,你会看到一个WKWebView相关代码自动完成选项表。找到一个叫decidePolicyForNavigationAction的,新建一个方法。
这个委托返回允许我们决定每当什么事情发生时我们是否想让跳转发生。我们可以检查跳转在页面的什么位置开始发生,我们可以看到它是被点击的链接还是递交的表格触发的,或者在我们的例子中,我们可以查看是否为我们喜欢的链接。
现在我们执行了这一方法,它期望得到回应:是否载入页面?这个方法被调用时,你传入了一个decisionHandler参数。它实际包含的是一个函数,也就是说,你“调用”这个参数,实际上调用的是方法。
如果把你弄糊涂了,请让我试着解释下。项目2中我们聊过了闭包:一堆你可以像传变量一样传入到方法中的代码,会在稍晚一点的时间被执行。decisionHandler同样也是闭包,只是有些不同——不是给别人一块代码来执行,你被要求来执行给定的代码块。
没错:你被要求用decisionHandler闭包来做点什么。听起来就像用一种很复杂的方式来表达从方法返回一个值,但这还是低估了它的能耐!有了它你可以给用户展示一些用户接口“你真的想要载入这个页面吗?”,然后当你有了答案时调用闭包。【最后一句略别扭】
所以,我们得评估URL,看看它是否在我们的安全列表中,然后根据是/否回答来调用decisionHandler。方法的代码如下:
func webView(webView: WKWebView, decidePolicyForNavigationAction navigationAction: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.URL
if let host = url!.host {
for website in websites {
if host.rangeOfString(website) != nil {
decisionHandler(.Allow)
return
}
}
}
decisionHandler(.Cancel)
}
让我们详细过一遍每一行:
1.让常量url等于要跳转的NSURL,这让代码更清楚。
2.我们用if/let句法来解包可选项url.host的值。还记得我说过NSURL可以帮你正确地分析URL吗?这就是个好例子:这行在说,“如果这个URL有主机,把它拉出来”——“主机”表示网站域名,就像apple.com一样。PS:我们得非常小心地解包,因为不是所有的连接都有主机。
3.我们循环了安全列表中的所有网站,把站名放在website变量中。
4.我们用rangeOfString()方法来检查是不是每个安全地址都出现在主机名的某个位置上。
5.如果网址被发现了(即rangeOfString()返回的不是nil),那我们就调用decision handler:允许载入。
6.找到网址,调用了decisionHandler之后我们使用return语句。就是说“现在离开方法了。”
7.如果没有主机,或者循环结束我们也没找到要访问的网址,我们调用decisionhandler来取消载入。
rangeOfString()方法可以有好几个参数,只有第一个是可选参数,所以上面的用法是可以的。在一个字符串上调用它,然后把另外一个字符串作为参数,然后它就会告诉你它是在哪儿被发现的,或者它没被发现(即nil)。
你已经在项目1中碰到过hasPrefix()了,但它在这里不合适是因为我们的安全网址名可以在URL的任意位置出现。比如,slashdot.org指向的是移动网址m.slashdot.org,而hasPrefix()就会在这个测试中失败。
返回语句是新的,但接下来它会很常用。它直接跳出方法,不再执行任何代码。如果你说你的代码返回了一个值,你就会用return来做这件事。
你的项目完成了:按下Cmd+R来体验下!
总结
又一个程序完成了,又学了很多东西。这一节你已经学到了loadView(),WKWebView,委托,类和结构体,NSURLRequest,UIToolbar,UIProgressView,KVO等,所以你应该对你的成就感到自豪!
这个项目还有很大的提升空间,你从哪里开始由你自己决定。我会建议你调查,如果初始视图控制器改成表视图,这样用户就可以从列表中自由选择而不是只有数组中的前两个。
一旦你完成了项目5,你可能会想要返回这里来增加从文件里载入列表网站的选项,而不是数组里的硬编码。