Refactoring for the win
Refactoring for the win 관련
Our app has a fatal flaw, and there are two ways to fix it: double up on code, or refactor. Cunningly, the first option is nearly always the easiest, and yet counter-intuitively also the hardest.
The flaw is this: we let users select from a list of websites, but once they are on that website they can get pretty much anywhere else they want just by following links. Wouldn't it be nice if we could check every link that was followed so that we can make sure it's on our safe list?
One solution – doubling up on code – would have us writing the list of accessible websites twice: once in the UIAlertController
and once when we're checking the link. This is extremely easy to write, but it can be a trap: you now have two lists of websites, and it's down to you to keep them both up to date. And if you find a bug in your duplicated code, will you remember to fix it in the other place too?
The second solution is called refactoring, and it's effectively a rewrite of the code. The end result should do the same thing, though. The purpose of the rewrite is to make it more efficient, make it easier to read, reduce its complexity, and to make it more flexible. This last use is what we'll be shooting for: we want to refactor our code so there's a shared array of allowed websites.
Up where we declared our two properties webView
and progressView
, add this:
var websites = ["apple.com", "hackingwithswift.com"]
That's an array containing the websites we want the user to be able to visit.
With that array, we can modify the web view's initial web page so that it's not hard-coded. In viewDidLoad()
, change the initial web page to this:
let url = URL(string: "https://" + websites[0])!
webView.load(URLRequest(url: url))
So far, so easy. The next change is to make our UIAlertController
use the websites for its list of UIAlertAction
s. Go down to the openTapped()
method and replace these two lines:
ac.addAction(UIAlertAction(title: "apple.com", style: .default, handler: openPage))
ac.addAction(UIAlertAction(title: "hackingwithswift.com", style: .default, handler: openPage))
…with this loop:
for website in websites {
ac.addAction(UIAlertAction(title: website, style: .default, handler: openPage))
}
That will add one UIAlertAction
object for each item in our array. Again, not too complicated.
The final change is something new, and it belongs to the WKNavigationDelegate
protocol. If you find space for a new method and start typing "web" you'll see the list of WKWebView
-related code completion options. Look for the one called decidePolicyFor
and let Xcode fill in the method for you.
This delegate callback allows us to decide whether we want to allow navigation to happen or not every time something happens. We can check which part of the page started the navigation, we can see whether it was triggered by a link being clicked or a form being submitted, or, in our case, we can check the URL to see whether we like it.
Now that we've implemented this method, it expects a response: should we load the page or should we not? When this method is called, you get passed in a parameter called decisionHandler
. This actually holds a function, which means if you "call" the parameter, you're actually calling the function.
In project 2 I talked about closures: chunks of code that you can pass into a function like a variable and have executed at a later date. This decisionHandler
is also a closure, except it's the other way around – rather than giving someone else a chunk of code to execute, you're being given it and are required to execute it.
And make no mistake: you are required to do something with that decisionHandler
closure. That might make sound an extremely complicated way of returning a value from a method, and that's true – but it's also underestimating the power a little! Having this decisionHandler
variable/function means you can show some user interface to the user "Do you really want to load this page?" and call the closure when you have an answer.
You might think that already sounds complicated, but I’m afraid there’s one more thing that might hurt your head. Because you might call the decisionHandler
closure straight away, or you might call it later on (perhaps after asking the user what they want to do), Swift considers it to be an escaping closure. That is, the closure has the potential to escape the current method, and be used at a later date. We won’t be using it that way, but it has the potential and that’s what matters.
Because of this, Swift wants us to add the special keyword @escaping
when specifying this method, so we’re acknowledging that the closure might be used later. You don’t need to do anything else – just add that one keyword, as you’ll see in the code below.
So, we need to evaluate the URL to see whether it's in our safe list, then call the decisionHandler
with a negative or positive answer. Here's the code for the method:
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.url
if let host = url?.host {
for website in websites {
if host.contains(website) {
decisionHandler(.allow)
return
}
}
}
decisionHandler(.cancel)
}
There are some easy bits, but they are outweighed by the hard bits so let's go through every line in detail to make sure:
- First, we set the constant
url
to be equal to theURL
of the navigation. This is just to make the code clearer. - Second, we use
if let
syntax to unwrap the value of the optionalurl.host
. Remember I said thatURL
does a lot of work for you in parsing URLs properly? Well, here's a good example: this line says, "if there is a host for this URL, pull it out" – and by "host" it means "website domain" like apple.com. Note: we need to unwrap this carefully because not all URLs have hosts. - Third, we loop through all sites in our safe list, placing the name of the site in the
website
variable. - Fourth, we use the
contains()
String method to see whether each safe website exists somewhere in the host name. - Fifth, if the website was found then we call the decision handler with a positive response - we want to allow loading.
- Sixth, if the website was found, after calling the
decisionHandler
we use thereturn
statement. This means "exit the method now." - Last, if there is no host set, or if we've gone through all the loop and found nothing, we call the decision handler with a negative response: cancel loading.
You give the contains()
method a string to check, and it will return true if it is found inside whichever string you used with contains()
. You've already met the hasPrefix()
method in project 1, but hasPrefix()
isn't suitable here because our safe site name could appear anywhere in the URL. For example, slashdot.org redirects to m.slashdot.org for mobile devices, and hasPrefix()
would fail that test.
The return
statement is new, but it's one you'll be using a lot from now on. It exits the method immediately, executing no further code. If you said your method returns a value, you'll use the return
statement to return that value.
Your project is complete: press Cmd+R to run the finished app, and enjoy!