4 Episodes · 2h01min
- View Models 33:58
- Deep Linking 27:12
- Playground-Driven Development 21:24
- Test-Driven Reactive Programming 38:47
Swift Talk # 47
with special guest Brandon Williams
Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber →
Brandon from Kickstarter shows us how they write highly testable code with view models. We integrate Apple Pay payments and look at their open-source codebase.
00:06 This is the first in a series of episodes with Brandon and Lisa, who are both iOS and Android engineers for Kickstarter. We're going to talk about the Kickstarter apps, covering topics like deep linking, Apple Pay, Playground-driven development, and test-driven development in a functional style.
01:52 The entire Kickstarter team works on both iOS and Android. At first glance, the two platforms may seem like different worlds, but Kickstarter's codebase is architected in a way that makes it simple to switch back and forth between the two.
02:23 Kickstarter has built both its native apps using the same Model-View-ViewModel pattern. Developers can find what they're looking for in both codebases, but they're just written in slightly different languages: Java, Swift, and Kotlin.
03:18 In looking at the view controllers, we saw they're each structured in the same way. Once you've grasped how that structure works, it's easy to see what's going on in each view controller.
03:44 Today we'll look at how Brandon implements Apple Pay. But first we'll set up a basic implementation before refactoring it to Kickstarter's way of programming.
04:09 We have a simple app with one Buy button, and we create and
present an Apple Pay view controller when the button is pressed. We need a
payment request for this, which we've prepared in a local product
property:
@IBAction func buy(_ sender: Any) {
let vc = PKPaymentAuthorizationViewController(paymentRequest: product.paymentRequest)
self.present(vc, animated: true, completion: nil)
}
04:54 Pressing the Buy button now opens the Apple Pay view controller. But the Cancel and Pay buttons don't work yet because we're missing a delegate.
05:15 We set self
as the delegate, so we have to conform to a protocol
with a very long name: PKPaymentAuthorizationViewControllerDelegate
. Two
delegate methods are relevant for now. The first one, with the label
didAuthorizePayment
, is called after the user has used Touch ID or their
passcode and an Apple Pay token has been generated, but before a payment
actually occurs. The second one, paymentAuthorizationViewControllerDidFinish
,
notifies us when the user either has authorized payment or wants to dismiss the
view controller:
class ViewController: UIViewController, PKPaymentAuthorizationViewControllerDelegate {
// ...
@IBAction func buy(_ sender: Any) {
let vc = PKPaymentAuthorizationViewController(paymentRequest: product.paymentRequest)
vc.delegate = self
self.present(vc, animated: true, completion: nil)
}
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment, completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
// ...
}
func paymentAuthorizationViewControllerDidFinish(_ controller: PKPaymentAuthorizationViewController) {
controller.dismiss(animated: true, completion: nil)
}
}
06:33 This makes the Cancel button work, but nothing happens after an
authorization. That's because we're not yet calling the completion handler in
didAuthorizePayment
. If we call it with a success status, the view controller
will be dismissed:
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment, completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
completion(.success)
}
07:14 The user is still not charged, because we're not doing anything
with the payment token. We use FakeStripe
— our placeholder for the payment
service provider — to create a token for this payment, which gives us an
optional token and an optional error to handle:
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment, completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
FakeStripe.shared.createToken(with: payment) { (token, error) in
if let token = token {
completion(.success)
} else if let error = error {
completion(.failure)
} else {
fatalError()
}
}
}
08:34 We're getting ahead of ourselves by calling the completion handler
with .success
, because no payment has actually been made yet. Instead, we
should send the Stripe token to a web service that will use it to charge the
user. Our web service has a method, processToken
, which takes the token, the
product, and a closure in which we receive the final status of the payment as a
boolean:
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment, completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
FakeStripe.shared.createToken(with: payment) { (token, error) in
if let token = token {
Webservice.shared.processToken(token: token, product: self.product, callback: { success in
if success {
completion(.success)
} else {
completion(.failure)
}
})
} else if let error = error {
completion(.failure)
} else {
fatalError()
}
}
}
09:54 It would be a good idea to tell the user about the different
statuses as we process the payment. We can set the first status text in the
buy
method and then update it when we get an error or a status back:
@IBAction func buy(_ sender: Any) {
let vc = PKPaymentAuthorizationViewController(paymentRequest: product.paymentRequest)
vc.delegate = self
statusLabel.text = "Authorizing..."
self.present(vc, animated: true, completion: nil)
}
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment, completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
FakeStripe.shared.createToken(with: payment) { (token, error) in
if let token = token {
Webservice.shared.processToken(token: token, product: self.product, callback: { success in
if success {
self.statusLabel.text = "Thank you"
completion(.success)
} else {
self.statusLabel.text = "Something went wrong."
completion(.failure)
}
})
} else if let error = error {
self.statusLabel.text = "Stripe error \(error)..."
completion(.failure)
} else {
fatalError()
}
}
}
11:36 When we cancel and dismiss the payment view controller, the status
"Authorizing..."
is still visible. We can fix that by clearing the status in
the delegate method paymentAuthorizationViewControllerDidFinish
. However, in
this method we have no information about the state of the payment process; all
we know is that the view controller should be dismissed. Additionally, we should
only clear the status if the payment isn't authorized. Otherwise, we might clear
an error status or a confirmation set by the authorization flow. We store a flag
in the view controller to keep track of the state:
var didAuthorize = false
12:36 In buy
, we reset the variable and then set it to true
in
didAuthorize
:
@IBAction func buy(_ sender: Any) {
didAuthorize = false
// ...
}
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment, completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
didAuthorize = true
// ...
}
12:46 Now when ...DidFinish
is called, we can check the didAuthorize
flag and only clear the status label if we didn't go through the authorization
flow:
func paymentAuthorizationViewControllerDidFinish(_ controller: PKPaymentAuthorizationViewController) {
controller.dismiss(animated: true, completion: nil)
if !didAuthorize {
self.statusLabel.text = nil
}
}
13:31 All of the above creates a lot of complex code, and we don't want to come back to it in a few months for changes, so it's time to refactor the code. To do so, we'll move a lot of logic into an object with a cleaner interface and better testability.
13:58 We first create a struct that holds all the data representing the current state. Right now that's just the status label, but we might want to add a boolean indicating whether the Buy button should be enabled (for example, to disable it during the payment process):
struct State {
var buttonIsEnabled: Bool
var statusLabelText: String?
}
14:35 Next we can create an object that describes everything the
PKPaymentAuthorizationController
wants to do. It uses a callback that lets the
view naively update itself to a given state, which corresponds to the current
point in the payment authorization flow. We want to move as much logic as we can
into this object so that it can be tested without interacting with a
PKPaymentAuthorization view controller. This object is meant to be the single
source of truth for our user interface state.
15:31 We name the new object ViewModel
. It holds a state value and a
callback that will be called with new states to update the UI. During setup, we
set the initial state value and call the callback once, in order to let the
interface update itself with the initial state:
class ViewModel {
var state: State = State(buttonIsEnabled: true, statusLabelText: nil) {
didSet {
callback(state)
}
}
var callback: ((State) -> Void)
init(callback: @escaping (State) -> Void) {
self.callback = callback
self.callback(state)
}
}
16:56 Now we can go through the view controller and strip away logic
that can be moved into actions on ViewModel
. We first add a viewModel
property to the view controller and initialize it in viewDidLoad
. We define
the callback that updates the UI as a trailing closure, marking self
as
unowned to avoid creating a reference cycle:
class ViewController: UIViewController, PKPaymentAuthorizationViewControllerDelegate {
// ...
var viewModel: ViewModel!
// ...
override func viewDidLoad() {
super.viewDidLoad()
viewModel = ViewModel { [unowned self] state in
self.statusLabel.text = state.statusLabelText
self.buyButton.isEnabled = state.buttonIsEnabled
}
}
}
18:51 In the buy
method, we need to update the status label text. We
don't want to directly mess with viewModel.state.statusLabelText
, but we
should design a very clear interface on ViewModel
so that its users know when
to call which actions. Therefore, a better solution is to add a method on
ViewModel
called buyButtonPressed
, which updates the status label text
internally:
class ViewModel {
// ...
func buyButtonPressed() {
didAuthorize = false
state.statusLabelText = "Authorizing..."
}
}
// ...
class ViewController: UIViewController, PKPaymentAuthorizationViewControllerDelegate {
// ...
@IBAction func buy(_ sender: Any) {
let vc = PKPaymentAuthorizationViewController(paymentRequest: product.paymentRequest)
vc.delegate = self
viewModel.buyButtonPressed()
self.present(vc, animated: true, completion: nil)
}
// ...
}
19:52 When we call viewModel.buyButtonPressed()
, the view model
updates its state, which automatically calls the callback, updating the label
text in our interface.
20:19 We need to decide which part of the didAuthorizePayment
logic
moves into ViewModel
. Because the Stripe interaction is a third-party SDK that
we don't have to test, we want to leave it out of the ViewModel
, so we just
move the web service bits into ViewModel
:
class ViewModel {
// ...
func stripeCreatedToken(token: STPToken?, error: Error? {
if let token = token {
// ...
}
}
}
// ...
class ViewController: UIViewController, PKPaymentAuthorizationViewControllerDelegate {
// ...
func paymentAuthorizationViewController(/* ... */) {
didAuthorize = true
FakeStripe.shared.createToken(with: payment) { (token, error) in
self.viewModel.stripeCreatedToken(token: token, error: error)
}
}
// ...
}
22:04 After we paste the didAuthorizePayment
code into ViewModel
,
we can tell from the compiler errors what needs to be fixed. For one,
ViewModel
needs the Product
to send to the web service, so we'll add it to
the initializer:
class ViewModel {
// ...
let product: Product
// ...
init(product: Product, callback: @escaping (State) -> Void) {
self.product = product
self.callback = callback
self.callback(state)
}
// ...
}
22:48 We also need to call the Apple Pay completion handler from the
ViewModel
, so we'll pass that in when we call stripeCreatedToken
. Then we
copy the closure's signature:
class ViewModel {
func stripeCreatedToken(token: STPToken?, error: Error?, completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
// ...
}
}
24:03 After fixing all compiler errors, everything works again. One
last bit: we should move the didAuthorize
flag into ViewModel
as well, in
order to make it testable. We won't put it inside the State
struct, because
that should only hold values that are directly visible in the user interface.
Instead, we treat the didAuthorize
flag as an implementation detail of the
ViewModel
itself:
class ViewModel {
// ...
private var didAuthorize: Bool = false
// ...
func buyButtonPressed() {
didAuthorize = false
state.statusLabelText = "Authorizing..."
}
// ...
func didAuthorizePayment() {
didAuthorize = true
}
func authorizationFinished() {
if !didAuthorize {
state.statusLabelText = nil
}
}
}
25:35 Back in the view controller, we should call methods on the
ViewModel
. In didAuthorizePayment
, we'll call the view model's corresponding
didAuthorizePayment()
.
26:25 Then in ...DidFinish
, we call
viewModel.authorizationFinished()
. The UIKit stuff — the dismissal of the
payment view controller — shouldn't move to the ViewModel
because that makes
it hard to test.
27:41 We ended up with a much simpler view controller. All the UI logic is in the view model now, and the public interface of the view model makes it clear what it wants from its users.
28:24 If we were to test the view model, we'd construct a value of
ViewModel
and attach a callback that, instead of applying changes to a user
interface, appends received states to an array. Then we could go through a user
script — open the app, press Buy, press Cancel — after which we could check that
we received the correct sequence of states. In other words, we call the methods
on ViewModel
and then check that in our states array, the status label text
goes from nil
to "Authorizing..."
and then back to nil
.
29:02 Our refactored implementation looks a lot like Kickstarter's app, except Kickstarter uses reactive programming with signals to more declaratively describe what's going on. In our code, we set the status label text in a few different places, but had we done it in a more reactive manner, we could've set it in one place by using a composition of signals.
30:06 As an example, Brandon shows the
RewardPledgeViewController
,
where Kickstarter users can change an amount they want to pledge to a project,
tweak the shipping, and ultimately pay. All actions the user can take are
described as inputs to the view model. Under the hood, those inputs are
transformed into signals, which are then combined into new signals that describe
the side effects to the view, such as the status label, the button's enabled
state, etc.
30:47 In the view controller code, we see a lot of code similar to what we implemented before, in that a lot of methods simply forward calls to the view model:
extension RewardPledgeViewController: PKPaymentAuthorizationViewControllerDelegate {
// ...
internal func paymentAuthorizationViewController(
_ controller: PKPaymentAuthorizationViewController,
didAuthorizePayment payment: PKPayment,
completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
self.viewModel.inputs.paymentAuthorization(didAuthorizePayment: .init(payment: payment))
// ...
}
internal func paymentAuthorizationViewControllerDidFinish(
_ controller: PKPaymentAuthorizationViewController) {
controller.dismiss(animated: true) {
self.viewModel.inputs.paymentAuthorizationDidFinish()
}
}
}
31:52 In the view model, we find an example of a state element that's
comparable to our status label text: the
applePaySuccessful
signal. This is the single source of truth for whether the authorization was
completed. Instead of scattering a didAuthorize
flag all over the code, three
different signals were merged in one place: a signal for whether or not the
authorization process started, a signal for if the error returned from the
Stripe token creation is nil
, and a signal for if the token isn't nil
. These
three signals together define a successful payment:
let applePaySuccessful = Signal.merge(
self.paymentAuthorizationWillAuthorizeProperty.signal.mapConst(false),
self.stripeTokenAndErrorProperty.signal.filter(isNotNil • second).mapConst(false),
self.stripeTokenAndErrorProperty.signal.filter(isNotNil • first).mapConst(true)
)
32:56 The merged signal can be used for other things too, e.g. analytics tracking. We can simply use the one signal to check whether the Apple Pay flow has started or finished successfully, or maybe to see if an error occurred in the Stripe stage or some time later, and all of this knowledge can be used to make granular business decisions.
33:39 The demo code is very close to what Kickstarter does in its app, so it was cool to see code that actually works in production.
Written in Swift 3.1
Become a subscriber to download episode videos.
4 Episodes · 2h01min
Episode 432 · Dec 06
Episode 431 · Nov 29
Episode 430 · Nov 22
Episode 429 · Nov 15
Episode 428 · Nov 08
Episode 427 · Nov 01
Episode 426 · Oct 25
Episode 425 · Oct 18
Unlock Full Access
A new episode every week
Take Swift Talk with you when you're offline
With your help we can keep producing new episodes