EXPEDIA GROUP TECHNOLOGY — SOFTWARE
Migrating to WKWebView
With deprecation looming, it is time to move away from UIWebView, and fully embrace WebKit for embedding web experiences.
Deprecation alarm bells
In the Hotels.com™ iOS app, a web view is presented for some of the user journey. For example, when the user is signing in, or checking their loyalty status. UIWebView
from UIKit was used to handle this. We had briefly looked at using the newer web view class WKWebView
from WebKit, but couldn’t see a big enough gain from switching. However, alarm bells rang when, in 2017, Apple announced that UIWebView
would be deprecated. Initially, this was scheduled for iOS 11, but Apple delayed the deprecation to iOS 12. It was time to turn our backs on our old friend UIWebView
, and to fully embrace WKWebView
for all our embedded web views.
Benefits of migrating
UIWebView
is over ten years old, and has watched longingly as front-end web technologies have evolved rapidly out of reach of its capabilities. WKWebView
was first made available in iOS 8 and has had time for early issues to be ironed out. In addition to having the all-clear to drop iOS 12 when the time comes, there is a lot to gain from adopting this new web view class:
WKWebView
is equipped with the Nitro JavaScript engine, allowing in-app web experiences to finally be on par with Safari.- This web view runs out-of-process, which means if a page causes
WKWebView
to crash, it does not crash your app. - ‘Messages’ can be received from Javascript by implementing a message handler. For example, if the page runs this…
window.webkit.messageHandlers.notification.postMessage({body: "..."});
…a WKScriptMessageHandler
object can be informed of the message, provided it has been added to WKWebView
’s configuration, via an instance of WKUserContentController
.
class NotificationScriptMessageHandler: NSObject, WKScriptMessageHandler {func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage!) {
print(message.body)
}
}let userContentController = WKUserContentController()
let handler = NotificationScriptMessageHandler()
userContentController.addScriptMessageHandler(handler, name: "notification")
let configuration = WKWebViewConfiguration()
configuration.userContentController = userContentController
- The property
estimatedProgress
can be used to inform the user how much of the page is still to be loaded. WKWebView
also supports better handling of alerts. If a page runs this Javascript…
alert("Hello!");
…the UIDelegate
of the WKWebView
is informed of the alert in the runJavaScriptAlertPanelWithMessage:
method.
public func webView(webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: (() -> Void)) {
print(message)// or show a UIAlertController
completionHandler()
}
Implementation challenges
Delegates
WKWebView
takes two delegates, a WKNavigationDelegate
for handling decisions around navigation, and a WKUIDelegate
for window management and responding to Javascript executed on the page.
You can map your four existing UIWebViewDelegate
method implementations to these equivalent WKNavigationDelegate
methods:
UIWebViewDelegate: webView(_:shouldStartLoadWith:navigationType:)
===>
WKNavigationDelegate: webView(_:decidePolicyFor action:decisionHandler:)UIWebViewDelegate: webViewDidStartLoad(_:)
===>
WKNavigationDelegate: webView(_:didStartProvisionalNavigation:)UIWebViewDelegate: webViewDidFinishLoad(_:)
===>
WKNavigationDelegate: webView(_:didFinish:)UIWebViewDelegate: webView(_:didFailLoadWithError:)
===>
WKNavigationDelegate: webView(_:didFail:withError:)
In addition, watch out for:
webView(_:didFailProvisionalNavigation:withError:)
: this method can also be called when the loading of a page fails, so handle failure here too. It was found that this method can be called when a server is taking too long to respond (domainWebKitErrorDomain
, code 102), so handle this accordingly.webView(_:decidePolicyFor response:decisionHandler:)
: in addition to deciding whether to allow a navigation action (user tapping a link), we can also decide whether to accept the response of the navigation.
Link Preview
When you tap-and-hold on a link, the default behaviour is for the linked page to open in a small preview. If a web view is used to seamlessly meld with a native experience, it is wise to set the allowsLinkPreview
property on WKWebView
to false
.
target=_blank
By default, if a link is declared in HTML as opening in a new window (ie, with target=_blank
in the <a…>
tag), it will not be displayed in WKWebView
. This may be the desired behaviour. However to get around this, you can implement the WKUIDelegate
window-handling method, and pass the request on to the WKWebView
instance.
public func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if navigationAction.targetFrame == nil {
webView.load(navigationAction.request)
}
return nil
}
Cookies
Differences in cookie handling
If you had cookies stored in HTTPCookieStorage
, our old friend UIWebView
would kindly add relevant cookies from this store automatically into the request headers. Likewise, any cookies set by the response would land in the same store.
When WKWebView
was released into the wild in iOS 8, there was no way of handling cookies at all, other than getting / setting cookies by passing javascript to the page via the evaluateJavaScript
method on WKWebView
.
WKHTTPCookieStore
As of iOS 11, Apple introduced a new cookie store, WKHTTPCookieStore
. You do not instantiate one of these directly, or access a singleton like with HTTPCookieStorage
. Instead, you access WKWebView
’s dedicated cookie store via its WKWebsiteDataStore
, which is part of the WKWebViewConfiguration
object used to instantiate WKWebView
.
Unlike UIWebView
, a WKWebView
instance has its own data store WKWebsiteDataStore
, which houses cookies, caches, databases, and any other form of local storage required by the page. We can query the site’s data using WKWebsiteDataStore
’s API.
WKWebsiteDataStore types
It is possible to have two types of datastore; default
and nonPersistent
. Although the documentation at the time of writing is somewhat brief, we can assume that default
will store data to storage and be accessible when loading a web view again with the same request. In contrast, nonPersistent
will not persist any data from the page after the web view has been deallocated.
We had some success with using the default
store. To our surprise, without any configuration, cookies from HTTPCookieStorage
were included in a request made by WKWebView
! I spoke to the WebKit guys at WWDC 2019, and there is indeed a secret link between the system-wide persisted cookie store and WKHTTPCookieStorage
, however this link is complex (remember that WKHTTPCookieStorage
, like WKWebView
, runs out-of-process, hence the async get / set discussed later on). We found using a default
datastore to be unreliable, with unpredictable behaviour. Another solution was needed.
Getting and setting cookies
We had to somehow guarantee that relevant cookies would be made available to WKWebView
, and for cookies set in the response to be stored and made available for future network requests. Adding cookies into the request header manually works great for the first request, but subsequent requests will be missing those cookies, and no way was found to intercept the next request being made by WKWebView
. It’s also not possible to pick cookies out of the response headers, as the Set-cookie
header is never passed to the WKNavigationDelegate
. We also looked into adding a WKHTTPCookieStoreObserver
on WKHTTPCookieStorage
, but found this to be unreliable also, with sporadic chunks of repeating observations made even when the web view was not loading anything. This also adds another asynchronous problem: If we are waiting to hit a ‘success’ URL before dismissing the web view automatically (as in the case of signing in the user), can we be sure that the cookie observer will be called after the page load has completed, and that cookies in the response have been saved? This is not guaranteed…
Our solution
After a lot of testing, debugging, head-scratching, diagram drawing, and cookie-scrutinising, we adopted this approach:
- We create a
nonPersistent
datastore. - Cookies required in requests are copied into this datastore.
WKWebView
is instantiated with a configuration which uses this datastore, and the initial request is loaded.- When the web experience is complete, cookies from the
nonPersistent
store are copied to the sharedHTTPCookieStorage
, where they can be used by future network requests. The web view is deallocated, and its datastore disappears.
This approach allows us to implement something that is predictable and makes us less reliant on poorly documented shared cookie handling by WebKit classes.
Note: We tried to add cookies to the datastore after the WKWebView
is instantiated (via its configuration
property), but this approach was unreliable at the time of writing. This may have been fixed, so experiment!
Asynchronous cookies on a single thread
To get and set cookies on WKHTTPCookieStore
, you have to call an asynchronous method with a completion handler. You have to call it on the main thread, and the completion handler is called on the main thread, so waiting using a DispatchQueue
or some such forced synchronicity is out of the question. This now means that setting up your web view and making the initial request is delayed to some time later on the main thread. If setting up the webview in a view controller during viewDidLoad
, make sure there are no visual glitches whilst the view is being created.
Wrap Up
Moving to WKWebView
required us to shoe-horn this class into the hole left by UIWebView
, and for the most part, this was fairly straightforward.
The real issue for us was handling cookies, and whilst our solution is not great, at least it is predictable. As mentioned in a few places above, Apple has potentially improved the way they handle cookies in WebKit, and it would be great to see how others are solving the same issues as we faced.
Photo by Caspar Camille Rubin on Unsplash