WebKit框架
对于某些应用,Safari视图控制器中包含的自定义选项还不够。为此Apple又提供WebKit框架这一选项。借助于这个框架,我们可以在视图内展示网页内容。该视图通过UIView
类的子类WKWebView
定义。这个类提供了如下管理内容的属性和方法。
- title:该属性返回文档标题字符串。
- url:该属性返回带文档URL的URL结构体。
- isLoading:该属性返回决定视图是否处于加载URL状态的布尔值。
- canGoBack:该属性返回决定视图是否可导航至前一页的布尔值。
- canGoForward:该属性返回决定视图是否可导航至下一页的布尔值。
- estimatedProgress:该属性返回0.0到1.0之间Double类型的值,决定内容加载的占比。
- load(URLRequest):该方法加载URL的内容。参数是希望打开URL的请求对象。
- goBack():该方法导航到导航历史记录中的上一页。
- goForward():该方法导航到导航历史记录中的下一页。
- go(to: WKBackForwardListItem):该方法导航至参数指定的网页。to参数为表示导航列表中网页的对象。
- reload():该方法重新加载当前页(刷新网页)。
- stopLoading():该方法要求视图停止加载内容。
要加载网站,我们必须创建一个请求。为此UIKit框架提供了URLRequest
结构体。该结构体包含如下初始化方法。
- URLRequest(url: URL, cachePolicy: CachePolicy, timeoutInterval: TimeInterval):这个初始化方法创建一个加载由
url
参数指定URL的请求。cachePolicy
参数是一个枚举,指定请求如何操作缓存。可以使用的值有:useProtocolCachePolicy
(默认值)、reloadIgnoringLocalCacheData
、reloadIgnoringLocalAndRemoteCacheData
、returnCacheDataElseLoad
、returnCacheDataDontLoad
和reloadRevalidatingCacheData
。timeoutInterval
参数是允许系统处理请求的最大时间(默认为60.0)。只有第一个参数必填,其余的参数都有默认值。
WebKit视图可以通过代理上报内容的状态。为此框架定义了WKNavigationDelegate
协议。以下是此协议中包含的部分方法。
- webView(WKWebView, decidePolicyFor: WKNavigationAction, decisionHandler: Closure):该方法对代理调用,指定视图是否应处理请求。
decidePolicyFor
参数是带有请求信息的对象,decisionHandler
参数是一个闭包,必须执行它来上报我们的决策。闭包接收WKNavigationActionPolicy
类型的值,这是一个属性为cancel
和allow
的枚举。 - webView(WKWebView, didStartProvisionalNavigation: WKNavigation!):该方法在视图开始加载新内容时对代理调用。
- webView(WKWebView, didFinish: WKNavigation!):该方法在视图完成内容加载时对代理调用。
- webView(WKWebView, didFailProvisionalNavigation: WKNavigation!, withError: Error):该方法在内容加载发生错误时对代理调用。
- webView(WKWebView, didReceiveServerRedirectForProvisionalNavigation: WKNavigation!):该方法在服务端将导航器重定向到其它目标时对代理调用。
WebKit视图是一个UIKit视图,因此我们必须使用UIViewRepresentable
进行创建。定义好representable视图后,在WebKit视图中加载网站的流程非常简单,创建请求、要求视图加载它。
示例17-10:通过WebKit视图加载网站
import SwiftUI import WebKit struct WebView: UIViewRepresentable { let searchURL: URL func makeUIView(context: Context) -> WKWebView { let view = WKWebView() let request = URLRequest(url: searchURL) view.load(request) return view } func updateUIView(_ uiView: UIViewType, context: Context) { } }
本例中,我们通过从SwiftUI界面接收到的URL准备请求,然后使用load()
方法加载网站。因为我们加载的是同一个网站,视图只需要定义好URL、传递给WebView
实例。
示例17-11:显示WebKit视图
struct ContentView: View { var body: some View { WebView(searchURL: URL(string: "https://www.google.com")!) } }
✍️跟我一起做:创建一个多平台项目。使用示例17-10中的代码创建Swift文件WebView.swift
。用示例17-11中的代码更新ContentView
视图。在iPhone模拟器上运行应用。会看到Google的首页显示在屏幕上。
注意:示例17-11的示例中,我们打开的是安全的URL(以
https://
开头的URL),因为这是默认允许的URL。我们在第9章中学到,Apple实现了一个名为的应用传输安全(ATS)的系统来屏幕不安全的URL。如果希望允许用户在WKWebView
视图中加载不安全的URL,必须将ATS系统配置为Allow Arbitrary Loads选项(见图9-3)。
通过WKWebView
视图,我们可以加载包含用户指定在内的所有网站。我们只需要和前面例子一样为用户提供一种输入URL的方式,然后执行load()
方法加载它。为此,在下面视图中包含有一个TextField
视图和一个按钮。在点击按钮后,我们调用WebView
结构体中的方法,通过用户输入的URL更新视图。
示例17-12:允许用户插入URL
struct ContentView: View { @State private var webView: WebView! @State private var inputURL: String = "" var body: some View { VStack { HStack { TextField("Insert URL", text: $inputURL) .autocapitalization(.none) .autocorrectionDisabled(true) Button("Load") { let text = inputURL.trimmingCharacters(in: .whitespaces) if !text.isEmpty { webView.loadWeb(web: text) } } }.padding(5) webView }.onAppear { webView = WebView(inputURL: $inputURL) } } }
本例中,我们添加了一个webView
属性,用于存储WebView
结构体。该属性在视图出现时进行初始化,然后它用于调用结构体的方法并在屏幕上显示视图。
用户插入的URL存储在inputURL
属性中,它被传递给WebView
结构体。这是为了在每次用户浏览新的页面时可以更新文本框中的值。
本例中的WebView
结构体需要创建WKWebView
视图、实现方法加载新URL并保持视图更新。
示例17-13:通过用户插入的URL更新WKWebView
import SwiftUI import WebKit struct WebView: UIViewRepresentable { @Binding var inputURL: String let view: WKWebView = WKWebView() func makeUIView(context: Context) -> WKWebView { view.navigationDelegate = context.coordinator let request = URLRequest(url: URL(string: "https://www.google.com")!) view.load(request) return view } func updateUIView(_ uiView: UIViewType, context: Context) { } func loadWeb(web: String) { var components = URLComponents(string: web) components?.scheme = "https" if let newURL = components?.string { if let url = newURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { if let loadURL = URL(string: url) { let request = URLRequest(url: loadURL) view.load(request) } } } } func makeCoordinator() -> CoordinatorWebView { return CoordinatorWebView(input: $inputURL) } } class CoordinatorWebView: NSObject, WKNavigationDelegate { @Binding var inputURL: String init(input: Binding<String>) { self._inputURL = input } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { if let webURL = webView.url { inputURL = webURL.absoluteString } } }
我们对WebView
结构体做了几处修改,使其可以加载多个URL。首先,我们在makeUIView()
方法外实例化了WKWebView
视图,因此可以在我肯定义方法中访问。在makeUIView()
方法内,我们通过将coordinator
的指针赋值给视图的navigationDelegate
属性来声明coordinator
为视图的代理,然后创建并加载请求。视图完成初始化并会调用coordinator
上报改变。但在实现coordinator
前,我们定义了loadWeb()
方法加载用户所输入的URL。这一方法在用户点击文本框旁的Load按钮时执行。该方法接收一个字符串、准备好URL并通过load()
方法加载它。用户输入的URL加载后内容会显示到屏幕上。下面做反向操作,我们需要在视图内容发生变化时更新文本框中的URL。这在用户点击页面上的链接导航至其它页面时发生。为此,我们让coordinator
实现WKNavigationDelegate
协议并实现webView(WKWebView, didCommit)
方法。这个方法在加载新内容时由WKWebView
调用。这里当前的URL通过视图的url
属性获取、赋值给inputURL
属性,接着修改TextField
视图中的值,这样文本框中的URL会与屏幕上显示的网站相一致。
✍️跟我一起做:使用示例17-12中的代码更新ContentView.swift
,用示例17-13中的代码更新WebView.swift
文件。在iPhone模拟器上运行应用。插入一个URL、点击Load按钮。视图中会加载这一URL并显示网站。点击页面上的链接导航至另一个页面。文本框中的URL会与屏幕上的页面地址保持一致。
我们到目前所构建的应用中,用户可以访问任意URL并通过点击链接导航至其它页面,但界面中并没有提供访问导航历史前一页和后一页的方式。WKWebView
类提供了一些控制内容的方法。比如,有一个goBack()
方法可以回到前一页,goForward()
方法可以回到上一页,而reload()
方法可以刷新页面。要执行这些方法,我们在导航栏下添加三个按钮。
示例17-14:提供导航按钮
struct ContentView: View { @State private var webView: WebView! @State private var inputURL: String = "" @State private var backDisabled: Bool = true @State private var forwardDisabled: Bool = true var body: some View { VStack { HStack { TextField("Insert URL", text: $inputURL) Button("Load") { let text = inputURL.trimmingCharacters(in: .whitespaces) if !text.isEmpty { webView.loadWeb(web: text) } } }.padding(5) HStack { Button(action: { webView.goBack() }, label: { Image(systemName: "arrow.left.circle") .font(.title) }).disabled(backDisabled) Button(action: { webView.goForward() }, label: { Image(systemName: "arrow.right.circle") .font(.title) }).disabled(forwardDisabled) Spacer() Button(action: { webView.refresh() }, label: { Image(systemName: "arrow.clockwise.circle") .font(.title) }) }.padding(5) webView }.onAppear { webView = WebView(inputURL: $inputURL, backDisabled: $backDisabled, forwardDisabled: $forwardDisabled) } } }
以上视图增加了两个@State
属性,指定前一页和后一页按钮是否可点击。在初次显示视图时,按钮应处于禁用,因为视图中只加载了一个文档,但在加载了新文档后,我们需要启用按钮让用户可在导航历史中向前或向后访问。为此我们必须将这些属性传给WebView
结构体,每次加载文档时在coordinator
中修改这些值。
示例17-15:在访问历史中向前或向后导航
struct WebView: UIViewRepresentable { @Binding var inputURL: String @Binding var backDisabled: Bool @Binding var forwardDisabled: Bool let view: WKWebView = WKWebView() func makeUIView(context: Context) -> WKWebView { view.navigationDelegate = context.coordinator let request = URLRequest(url: URL(string: "https://www.google.com")!) self.view.load(request) return view } func updateUIView(_ uiView: UIViewType, context: Context) { } func loadWeb(web: String) { var components = URLComponents(string: web) components?.scheme = "https" if let newURL = components?.string { if let url = newURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { if let loadURL = URL(string: url) { let request = URLRequest(url: loadURL) view.load(request) } } } } func goBack() { view.goBack() } func goForward() { view.goForward() } func refresh() { view.reload() } func makeCoordinator() -> CoordinatorWebView { return CoordinatorWebView(input: $inputURL, back: $backDisabled, forward: $forwardDisabled) } } class CoordinatorWebView: NSObject, WKNavigationDelegate { @Binding var inputURL: String @Binding var backDisabled: Bool @Binding var forwardDisabled: Bool init(input: Binding<String>, back: Binding<Bool>, forward: Binding<Bool>) { self._inputURL = input self._backDisabled = back self._forwardDisabled = forward } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { if let webURL = webView.url { inputURL = webURL.absoluteString backDisabled = !webView.canGoBack forwardDisabled = !webView.canGoForward } } }
这段代码对WebView
结构体添加了三个方法,来执行用户选择的操作(向前、向后或刷新页面)。在webView(WKWebView, didFinish:)
方法中,我们和之前一样更新文本框中的URL,但同时使用了canGoBack
和canGoForward
的值变更按钮的状态,这样只在有页面可供打开时才启用按钮。
图17-5:导航按钮
✍️跟我一起做:使用示例17-14中的代码更新ContentView.swift
文件,用示例17-15中的代码更新WebView.swift
文件。在iPhone模拟器中运行应用。在Google中搜索一个词,点击链接再点击返回按钮,这时视图会回到上一个页面。
注意:WebKit框架还提供了处理cookie和JavaScript代码的工具,让我们可以和文档的内容进行交互。这里暂不讨论。更多内容请参见苹果官方文档。
Web内容
Safari视图控制器和WKWebView
用于向用户展示内容,但内容与应用之间的集成却十分有限。有时会只需要从文档中提取部分信息,或是处理数据而不是将内容原封不动展示出来。这时,我们可以在后台加载、解析文档,只提取出我们需要的内容。
Foundation
包含了一组获取URL指向内容的类。其中最重要的类是URLSession
。这个类创建一个管理HTTP连接的会话,用于提取数据、下载或上传文件。下面是该类为创建会话所提供的部分属性和初始化方法。
- shared:这一类型属性返回一些默认配置的标准会话,适于执行基本请求。
- URLSession(configuraiton: URLSessionConfiguration):这个初始化方法按照参数配置新建会话。
configuration
参数是一个指定会话行为的对象。 - URLSession(configuration: URLSessionConfiguration, delegate: URLSessionDelegate?, delegateQueue: OperationQueue?):这个初始化方法通过参数指定配置新建会话。
configuration
参数是一个指定会话行为的对象,delegate
参数是我们希望赋给会话的代理对象指针,delegateQueue
参数是代理方法中所要执行的队列。
会话配置连接,但并不执行任何任务。下载或上传数据,我们必须实现URLSession
类中定义的如下方法。
- data(from: URL, delegate: URLSessionTaskDelegate?):这一异步方法对会话添加任务,下载
from
参数指定URL的数据。delegate
参数是任务在处理过程上报更新所使用的代理对象。该方法返回一个带两个值的元组:一个是包含服务端返回数据的Data
结构体,一个是包含请求状态的URLResponse
对象。 - download(from: URL, delegate: URLSessionTaskDelegate?):这个异步方法向会话添加任务,下载
from
参数指定的URL对应的文件。delegate
参数是任务在处理过程上报更新所使用的代理对象。该方法返回一个带两个值的元组:一个是包含表示所下载文件位置的的URL
结构体,一个是包含请求状态的URLResponse
对象。
以下由该类定义的用于上传数据和文件的方法。
- upload(for: URLRequest, from: Data, delegate: URLSessionTaskDelegate?):这一异步方法向会话添加一个任务,上传
from
参数所指定的数据。delegate
参数是任务在处理过程上报更新所使用的代理对象。该方法返回一个包含两个值的元组:一个是包含服务端返回数据的Data
结构体,一个是包含请求状态的URLResponse
对象。 - upload(for: URLRequest, fromFile: URL, delegate: URLSessionTaskDelegate?):这个异步方法向会话添加任务,上传
fromFile
参数指定的URL对应的文件。delegate
参数是任务在处理过程上报更新所使用的代理对象。该方法返回一个带两个值的元组:一个是服务端返回数据的Data
结构体,一个是包含请求状态的URLResponse
对象。
这些方法都是异步的,也就是说它们在数据下载或上传完成后舞台结果。例如,我们使用data()
方法获取 网站数据,返回值包含数据值及一个类型为URLResponse
的带请求状态的对象。在通过HTTP协议访问URL时,响应由一个HTTPURLResponse
类型(URLResponse
的子类)的对象表示。这个类包含表示请求状态码的statusCode
属性。有很多的状态,比如200,表示请求成功,或时301,表示网站跳转到另一个地址。如果要确保正确下载数据,可以在做处理前检查statusCode
属性的值是否为200。下例展示了如何执行一个简单的请求。
示例17-16:加载远程文档
import SwiftUI import Observation @Observable class ApplicationData { var webContent: String = "" var buttonDisabled: Bool = false func loadWeb() async { buttonDisabled = true let session = URLSession.shared let webURL = URL(string: "https://www.yahoo.com") do { let (data, response) = try await session.data(from: webURL!) if let resp = response as? HTTPURLResponse { let status = resp.statusCode if status == 200 { if let content = String(data: data, encoding: String.Encoding.ascii) { await MainActor.run { webContent = content buttonDisabled = false } print(content) } } else { print("Error: \(status)") } } } catch { print("Error: \(error)") } } }
这个模型加载www.yahoo.com
网站的内容,将其赋值给webContent
属性。这一操作由loadWeb()
方法执行。该方法使用https://www.yahoo.com
这一URL定义请求,然后调用会话的data()
方法下载页面。这个方法下载指定地址的内容,检测操作是否成功(200),从数据接收字符串,用这个值更新webContent
属性让其可在视图中使用。下面是处理这个数据的简单视图。
示例17-17:显示文档内容
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { VStack { Button("Load Web") { Task(priority: .high) { await appData.loadWeb() } }.disabled(appData.buttonDisabled) Text("Total Characters: \(appData.webContent.count)") .padding() Spacer() }.padding() } }
www.yahoo.com
返回的内容非常多。这里方便演示,在控制台中打印内容,按字符串进行字符计数,显示在屏幕上,但专业的应用通常会处理其值提取信息。
✍️跟我一起做:创建一个多平台项目。使用示例17-16的代码创建一个名为ApplicationData.swift
的Swift文件。使用示例17-17的代码更新ContentView
视图。记住要在应用和预览中将ApplicationData
对象注入环境(第7章示例7-4)。在iPhone模拟器上运行应用。点击Load Web按钮。等待数秒,会看到从www.yahoo.com
下载的文档在控制台中打印,并且在屏幕上显示了字符数。
我们在本例中使用的这种默认配置的标准会话适用于大多数场景,而自定义会话需要自行配置。为进行会话配置,Foundation
提供了一个名为URLSessionConfiguration
的类。下面的类型属性可用于获取带默认值的配置对象。
- default:该属性返回一个带默认设置的
URLSessionConfiguration
。
通过标准配置获得对象后,我们可以对其自定义满足自己应用的要求。下面来自URLSessionConfiguration
类的属性可用于配置会话。
- allowsCellularAccess:此属性设置或返回一个布尔值,指定在设备通过蜂窝网络连接时是否进行连接。
- timeoutIntervalForRequest:该属性返回一个
TimeInterval
值(Double
的别名),指定会话等待请求回复的秒数。默认值是60. - waitsForConnectivity:该属性设备或返回一个布尔值,指定会话是否等待设备连接到网络后再执行请求。默认值是
false
。
处理自定义会员只需要改变会话的初始化,其它代码保持不变。
示例17-18:初始化自定义会话
@Observable class ApplicationData { var webContent: String = "" var buttonDisabled: Bool = false func loadWeb() async { buttonDisabled = true let config = URLSessionConfiguration.default config.waitsForConnectivity = true let session = URLSession(configuration: config) let webURL = URL(string: "https://www.yahoo.com") do { let (data, response) = try await session.data(from: webURL!) if let resp = response as? HTTPURLResponse { let status = resp.statusCode if status == 200 { if let content = String(data: data, encoding: String.Encoding.ascii) { await MainActor.run { webContent = content buttonDisabled = false } print(content) } } else { print("Error: \(status)") } } } catch { print("Error: \(error)") } } }
上例中,我们没有实现data()
方法的delegate
参数。这是一个可选参数,但可以在需要响应及处理更新时进行声明。框架定义了一个URLSessionTaskDelegate
协议来创建这个代理 。下面是协议中的一些方法。
- urlSession(URLSession, task: URLSessionTask, didReceive: URLAuthenticationChallenge, completionHandler: Closure):该方法在服务端请求需要进行校验时对代理调用。我们的实现必须使用定义设置和认证信息的两个参数调用方法接收的完结处理器。
- urlSession(URLSession, task: URLSessionTask, willPerformHTTPRedirection: HTTPURLResponse, newRequest: URLRequest, completionHandler: Block):该方法在服务端将请求重定向到另一个URL时对代理调用。我们的实现必须通过新请求(
newRequest
参数的值)定义的参数或在不希望重定向时用nil
调用方法接收的完结处理器。
一些网站,比如www.yahoo.com
,自动将用户重定向到另一个适配用户地理位置和偏好网站版本的地址。这意味着我们所提供的URL不是最终地址。服务端不返回任何数据,而是将用户重定向到另一个文档。这时,我们可以定义一个带代理的自定义会话,然后实现URLSessionTaskDelegate
协议方法指定服务端重定向应用时我们希望做的操作。
示例17-19:重定向用户
@Observable class ApplicationData: NSObject, URLSessionTaskDelegate { var webContent: String = "" var buttonDisabled: Bool = false func loadWeb() async { buttonDisabled = true let session = URLSession.shared let webURL = URL(string: "https://www.yahoo.com") do { let (data, response) = try await session.data(from: webURL!, delegate: self) if let resp = response as? HTTPURLResponse { let status = resp.statusCode if status == 200 { if let content = String(data: data, encoding: String.Encoding.ascii) { await MainActor.run { webContent = content buttonDisabled = false } print(content) } } else { print("Error: \(status)") } } } catch { print("Error: \(error)") } } func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest) async -> URLRequest? { print(request.url ?? "No URL") return request } }
✍️跟我一起做:使用示例17-19的代码更新ApplicationData
类。在iPhone模拟器中运行应用。点击Load Web按钮。会在控制台中打印出用户重定向的URL。
www.yahoo.com
等所返回的网页文档,是用HTML写的。这是一种简单的编程语言,由网站用于组织信息。从这些文档中提取数据比较枯燥、易出错。因此,网站通常会提供以JSON格式分享数据的服务。JSON文档动态生成,仅包含应用所请求的信息。例如,www.openweathermap.org
提供了生成包含天气信息JSON文档的服务(https://openweathermap.org/api
)。
为演示如何访问和处理这些服务生成的文档,我会从一个生成假文档的www.openweathermap.org
(jsonplaceholder.typicode.com
)网站上读取文章。这一处理不需要新知识。我们必须通过URLSession
加载文档并使用JSONDecoder
对象进行解码。
示例17-20:加载JSON文档
struct Post: Codable, Identifiable { var id: Int var userId: Int var title: String var body: String } @Observable class ApplicationData { var listOfPosts: [Post] = [] init() { Task(priority: .high) { await loadJSON() } } func loadJSON() async { let session = URLSession.shared let webURL = URL(string: "https://jsonplaceholder.typicode.com/posts") do { let (data, response) = try await session.data(from: webURL!) if let resp = response as? HTTPURLResponse { let status = resp.statusCode if status == 200 { let decoder = JSONDecoder() if let posts = try? decoder.decode([Post].self, from: data) { await MainActor.run { listOfPosts = posts } } } else { print("Error: \(status)") } } } catch { print("Error: \(error)") } } }
我们在第10章中学过,解码JSON文档,我们需要定义一个与JSON值相匹配的结构体。URL https://jsonplaceholder.typicode.com/posts
返回一个文章列表,每条包含四个值:表示用户ID的整数、表示文章ID的整数、表示文章标题的字符串和表示内容的字符串。为存储这些值,我们在示例17-20中定义了一个Post
结构体。为能够解码其中的值这个结构体实现了Codable
协议,为能使用List
视图列出实例实现了Identifiable
协议。
文档下载的处理与之前相同。我们获取会话,调用data()
方法,使用JSONDecoder
将数据解码至Post
结构体数组中,将值存储到listOfPosts
属性中更新视图。因文档在模型初始化时下载,我们只需要在视图中列出这些值即可。
示例17-21:列出文档中的值
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { VStack { List { ForEach(appData.listOfPosts) { post in VStack(alignment: .leading) { Text(post.title).bold() Text(post.body) }.padding(5) } }.listStyle(.plain) }.padding() } }
✍️跟我一起做:使用示例17-20中的代码更新ApplicationData.swift
文件,用示例17-21的代码更新ContentView
视图。运行应用。应该会在屏幕上看到100条信息。要查看https://jsonplaceholder.typicode.com/posts
所返回JSON文件的结构,可在浏览器中直接打开该链接。
代码请见:GitHub仓库