Transcript
This transcript was autogenerated. To make changes, submit a PR.
Hi, welcome to my talk leveraging the power of state machines in
Swift who am I? My name is Frank Corville and I'm based in trail,
Canada. I've done iOS mobile development consulting for almost a decade,
and now I'm an iOS corporate trainer for a company called School of Swift.
School of Swift is an online organization that keeps iOS teams
on the cutting edge. We deliver interactive workshops on all
sorts of iOS topics, and most workshops are only half a day.
This way you can ensure the continuing education of your team without
jeopardizing their development schedule. Some of our workshop topics include
getting started with voiceover, painless dynamic type Swift UI
for UI kit developers, and getting started with async
await. If that sounds interesting, check out schoolaswift.com
and of course, please reach out and tell me what you think of this talk.
You can find me on Twitter at frankacy, where my dms are always open.
You can also email me at hello, frank courville.com.
But keep in mind that being helpful on Twitter is
my superpower. So if you want a quick reply, you know where to find me.
All right, let's get to the good stuff.
In this presentation, we're going to talk about state machines in
Swift, what they are, how to build them, and why they're
useful. We'll learn how to draw state diagrams,
then we'll take those state diagrams and translate them into Swift,
and we'll also see some advanced applications of state machines in our
code. But perhaps most importantly,
why should you care? What's the point of talking about state machines
in the context of an iOS app?
The reality is that state machines are everywhere.
They're, of course, in our code, but also in our daily
lives. Things like the turnstile you
use to take public transit or the cruise control in your
car are classic examples of state machines. And perhaps
the best example, power of state machines is a traffic light,
which we'll look at soon.
But to drive the point home a bit further, in the context of iOS
specifically, I want to talk about something seemingly unrelated.
So just bear with me for a while.
Let's talk about the coordinator pattern in Uikit.
For those of you who are unfamiliar, coordinators are
objects that control the flow in an app. This was popularized
by Sarosh Kanlu, back when we were still all doing objective
c. In a way, they're like a programmatic
storyboard. They string together many view controllers
and define the flows that a user can take through a series of
screens. Now, as a freelance developer,
I've worked on many different projects and I've seen this pattern again and
again. It's my go to pattern for building
anything in Uikid. However, it has its shortcomings.
When you look at a coordinator class, there's always those questions
that come to mind. How can you tell the
order of the view controllers? There's nothing in
the code that communicates those to developer. You kind of have to piece this together
yourself. It's also unclear how
each of these view controllers interacts with a coordinator. You often need
to wade through multiple files in order to figure out what exactly
is going on. And perhaps worst
of all is, how are you supposed to test this? How do
you ensure that your flow is working correctly and isn't going to be broken by
future change? So with those questions in mind,
let's look at state machines in swift so
what is a state machine? Here's a
good definition. It's a pattern to represent a
finite number of states and enforce known transition
between those states. There's a lot going on
in this definition, so let's break it down. A finite number
of states. We aren't trying to model the
whole world, right? We have a constrained domain and
we want to focus on our specific problem.
Okay, now, known transitions.
This means that we can assign names to them. We know which states can transition
to which other states. Conversely, we also know
which states can transition to which other states.
We can make strong assumptions about our state machine and how it will
behave. And finally,
enforce. These transitions are
enforced. There's no dynamic way of bypassing
a certain transition. You can't, for example, reach into the
state machine and set an arbitrary state yourself. If you want
to move to a specific state, you must use the transitions that
already exist. So let's look at an example of what we
mean here. Let's look at the standard traffic light.
We can define its states as red, yellow, and green.
So here they are listed out in state diagrams.
It's common to list out states using their names in circles.
So that's what we did here. Now let's
think about how we move between each of these states. Green can become yellow,
yellow can become red, and red can become green.
Again, we can represent these possible
transitions by using arrows between the states.
Now let's assume that inside our traffic light, we have a set of timers
that determines when the light should change.
Now we can use that to name our transition.
So we have a state diagram from which we can glean the problem that
we're dealing with. We can easily see, for instance, that our
state machine transitions from green to yellow. When the green timer elapses.
Cool. Finite number of states. Known transitions between
those states. Exactly what we want.
Now, this is fine and dandy, but you're probably not here to
code traffic lights, right? You're here to build apps.
Let's go through this exercise again with something a little more concrete.
Loading remote content loading
remote content is a great example of an implicit state machine that we
have in our apps. In other words, we have
code that acts like a state machine, but isn't formally defined
that way. Let's see if we can break it down. When we
navigate to a screen, we start in the loading state.
We may encounter an error, which will push us to the error state.
We can then hit a button to retry, and that
moves us back to the loading state.
Eventually, we receive data from the back end, but maybe
it's empty. This brings us to an empty
state. At this point, we could press a button to
refresh our content and move back to the loading
state again. And then finally we
get a fresh set of data from the back end, moving us to the
data state. Many of us have built something
like this in one way or another in our apps without putting
much thought to it. But this is those perfect example of an implicit
state machine. Now, how do we take the state diagram and
translate it into swift?
In my experience, the most effective way to build a state machine
is in two parts. The first part is to create a
state definition enum. This will represent the state diagram
we just made. It will hold the different states, the different
transitions, and the rules that govern how your values change
from one state to the next. The second part
of the solution is to create a state machine wrapper class.
This class will wrap the current state of your state machine,
protecting it from the outside, and ensure that only predefined
transitions are called on it. This is
also the class that will communicate new states to the rest of the app
building. The first part, a state definition enum,
is pretty straightforward once you have your state diagram. So let's start
with that. Since we're dealing with mutually
exclusive, state enums are a natural fit.
Here I have my remote content state definition enum,
and we're going to start listing out the possible states.
Loading data, error and empty.
In addition to these cases, let's also define our different transitions.
These are the events that act on our state machine to cause the state to
change. In other words, they're the labels we added to
our arrows in the state diagram. Once again,
since they are known and mutually exclusive,
let's use another enum. Here's an enum called
event that defines four transitions, did receive
error, did trigger reload, did receive empty
data, and did receive data.
You'll also notice that our event enum is inside our
state definition enum. Since it should
only really be used here, it makes sense to scope it as
tightly as possible. This is also important if
you end up with many state machine implementations in your app.
Now those fun part we define our state transitions.
It looks something like those we
start with a mutating function called handle event. When it
receives an event, it's going to change the current state into the
next state. In the body of this method,
we're going to switch over two things, the current value of those state,
which is self, as well as the event we received.
Now those is a lot of code, so let's focus in on a single case.
Here you can see we're switching over a tuple of self, which is those
current state and event. And in
this case, when we're in the loading state, we receive the did receive
error event, we change self to error,
which means we are moved to the error state and
we simply repeat this logic for each of the transitions
until every transition on our state diagram
is handled. Now,
if you were to try to make this compile, you would get
an error switch must be exhausted.
This is a good thing. The swift compiler is on our side.
Great. Here are a few ways to fix this.
You could exhaustively list out every state and every transition.
This, although wordy, is a good solution
for a state machine that may grow over time. This approach
will force you to consider every other state or transition
in case you add a new one in the future.
Again, this approach is very wordy, but those composed has
your back, so that's a good thing.
Alternatively, you can use simply a default label
to handle all unused combinations, which is what we'll do here.
Now this brings up another question. What should
we do with an invalid state transition? Again,
you have a few options. One option, the one
that most often comes to mind, is to crash
to fatal error. This can be useful in
very strict applications power of state machines,
but watch out race conditions in your code,
especially revolving around UI, could cause
your app to unnecessarily crash. So generally
I don't recommend those approach. Another option is
to simply ignore the attempted transition and log
an error. You could, for example,
log it to crashalytics or some other remote logging database to
audit how your state machine is behaving in production.
But at the very least you should log an error message
to the console so that you can see it happening during development.
This will alert you that you have unexpected behavior
that requires your attention. So that wraps
up our state definition enum. Let's move on to the second power
of state machines wrapper class. Let's create
a class, and we're going to call it remote content state machine.
It's going to contain a private variable called state,
and we're going to initialize it to the initial state that we want our state
machine to be in. So in this case, that would be the
loading state. Next,
this class needs to communicate with the outside world, the rest
of our app. Now, again, there's many ways
that you could do this. You could, for example, use combine
rx, swift, or even a simple closure.
However, in our case, let's go with the approach that most of us
are familiar with, a delegate protocol.
Here I define a delegate called remote content state machine delegate,
and it has a single method did change state.
Next, I add a weak delegate property to my state machine,
and I'll use a did set to ensure that I never forget to notify
my delegate that the state machine has changed states.
Next, we need a way for our app to trigger these state transitions.
To do this, we can write public methods on our state machine class
to allow this to happen. For example,
here I have three methods on remote content state machine
receive error, reload, and receive data.
But what do these methods do?
Receive error and reload will simply pass on the
event to our state definition enum in order
for it to attempt to do its transition.
However, receive data is a bit different depending
on the data it receives, it decides which event to send to
our state definition enum. If it receives an empty array,
it sends the did receive empty data event.
Otherwise, it sends the did receive data
event. Your state machine class can be a convenient
place to put light business logic such as this.
You could also inject more complex validators into your state machine
class if necessary. And finally,
it's good practice to add a start method power of state machines class.
Those will allow you to control when the initial state is
communicated to the rest of the app through
the same mechanism that we defined earlier. In our
case, it's through the delegate,
and now we can integrate this solution into our app.
To do this, I've built a remote content container
controller. It's initialized with
both a network controller and our state machine.
As the state changes are communicated through the state machine,
the container controller switches between different view controllers.
A loading view controller, an error view controller,
an empty view controller and a data view controller.
This allows for each of these child view controllers to be completely
self contained and reusable throughout our app in other contexts.
In order to hook up our container controller to our remote
content state machine, we're going to make it conform to the remote content
state machine delegate that we
made earlier. Then using a few judicious helper
methods, we can simply switch over those state that
we receive and ensure that our container view controller is
displaying the correct child view controller. This is
also a good spot for you to trigger side effects. For instance,
when we receive the loading state, we send off that network request
and that's it, we're done.
So what have we accomplished?
We've taken something that was traditionally done in a single giant
view controller and split it up into a bunch of small parts that are easy
to reason about. And that's especially true for
our state machine. It is trivially easy to
test and ensure its correctness.
What's more, we've taken something implicit in loading
remote content, that is, the different view
controllers, and we've made them explicit.
This is a huge win for any project that wants to stand
the test of time. Great. So before we dive
a bit deeper into state machines, it's story time again.
So a few years ago I worked at a large multinational on
their Internet of things app. We had about a
dozen different devices to support, each with their own custom pairing
flows and UI. In this case, we did have
a state machine to handle, but it was like
5000 lines long and incredibly difficult to modify
without introducing bugs. The kicker is that we were adding
new devices practically every quarter, so it became
unmanageable quite quickly. This got
me thinking about two aspects that hadn't crossed my mind before.
First of all, in some use cases, state machines
tend to grow in complexity over time. You could imagine
that today you create a feature that only has three
different screens. But as your product evolves,
or as Apple adds new device functionalities, the number
of states increases to something that's not as manageable.
However, complex state machines also often have substate
machines inside them. That is to say, you can extract
part power of state machines and make it its own.
To me, this reveals that if we want our state machines to
remain easy to work with, they need to be composable.
We need a solution that allows us to bring to
build big state machines out of smaller state machines.
So let's take our remote content
example a little bit further. I find
that data and loaded are two sides of the same coin and I
want to be able to refresh data once it's loaded as well to either
see new data or to move back to the empty state if
everything has been consumed, how can
I model that as a child state machine?
Let's create a new state definition and list these
different states empty data and
refresh. We'll go through the same exercise creating
an event enum as well.
Once again, we implement the handle event method to mutate the
state of our child state machine.
So far, everything is how you'd expect, but here is where things get
interesting. Now, in our parent state
machine, we can remove the empty and data states and replace
it with a loaded state. It's going to have an associated
value, which is the current state of our child state machine.
Now, in the parent state definition, all we need to
do is clean up the handle event implementation in order to
forward events to the child state definition, and we're
good to go. If you're interested in seeing
the actual implementation of all of this, I encourage you to download the
sample talk at the end of the talk.
All right, so let's finish up this presentation with some examples
of state machines that you may have in your application. I find
that those task of discovering implicit state machines in
our code is one that benefits from lots of examples. So here we go.
Those first is an onboarding coordinator. Imagine you
start in a login state, and then you move to an app preview,
and then maybe you're prompted to enable location
and push notifications. When you hit the back
button in one of these last states, you don't necessarily
want to go back the screen before. Here in
our onboarding state machine, we would handle this.
Next up is form validation. Imagine a form field
in a form that would start off in an empty state. As you type,
it moves to an entering data state.
Once you're done entering data, it would move to a
validating state where it checks to see if what you've entered is correct.
Then you finally end up in either
a validated state or can error state, which can both transition
back to entering data.
And finally, let's look at accepting push notifications.
Those one is really great. Too often when we want
users to accept push notifications, we'll show some sort of priming
prompt to tell them the benefits of push notifications.
If they accept, then we can move to show them the system prompt,
but if they decline, we simply move to a declined state
and the app can move on. However,
if they accept the system prompt, then we move
to a push accepted state and
that's it for this presentation. If you want the sample code
and resources for this talk, go to bitly state machines
comp 42. The sample code is
really great, by the way. There are three different projects that pull straight from
the things we talked about during this talk.
Also, here are a few additional resources for learning more about state
machines. They don't all necessarily take the same
approach, but it's interesting to have a more global view
on what's possible and the different patterns that are available to us.
And finally, my contact information. Again,
if you enjoyed the talk, let me know on Twitter. And of course,
don't be shy. But if you are shy, you can send me an email as
well. Thank you so much and enjoy the rest of the conference.