Recently I released Concat.app to the Mac App Store. The entire app is modelled as a state machine. In this post I will cover how state machines saved me from spaghetti code and resulted in clean and extensible model, views and controllers.
Concat looks deceptively simple: a table view and an export button. As is usually the case with software there is a lot more going on behind the scenes.
Values are not State
Let’s take the act of importing files and displaying a representation in a table view. The required steps are:
- Get the file URLs and create model objects.
- Immediately update the table view with model objects:
- Infer the document type from the file extension and load an image representation.
- Use the URL last path component as the name for the row.
- Asynchronously load all URL data:
- Update the main view’s progress indicator spinner
- Create either
- When asynchronous loading finishes update the table view again:
- Remove any items that failed to load in the previous step. For example, the image or PDF data on disk might be corrupted.
- Update the image associated with each item if it has changed from the initial guess made purely on the file URL extension.
- Update the main view
- Stop the progress indicator
- If more than one valid file remains, enable the export PDF button (more than one document is needed to perform concatenation).
- If we only have one or fewer files, disable the export PDF button.
I tackled the above workflow using a mix of notifications and delegate methods to perform different actions on completion of asynchronous tasks. However, I found that this distributed application logic in different places throughout the code. This made it difficult to change the workflow while prototyping and also hard to catch edge cases. In hindsight the main error was due to inferring the application state by checking the values of different variables: values are not state.
State machines allow the centralisation of application state and formalise transitions between different states as discrete steps. In other words, the states themselves become first class citizens of you application and you build actions around them, not the other way around.
Concat uses the Swift state machine written by @jemmons, please read his excellent series of blog posts on this for more information. The state machine calls the delegate method
didTransition(from:,to:) on a state change. I found it useful to also include a
willTransition(from:,to:) method. In certain circumstances it is useful to do some preparation before officially changing state. For example, to start a progress indicator animation before a long computation is triggered by the official state change.
Let’s recast the above workflow as a state machine,
The following code snippets are implemented in the view controller because it has access to both the model and view.
In the empty state Concat shows a empty placeholder image and make sure that all model objects are deleted (if we have any).
On file drop we transition to the inserting phase and process the file URLs to create
FileItem model objects for the table view to display. The state transition occurs by calling the transitional method
Once the state has changed we want to automatically transition to the Loading state, this is achieved in the last line by calling
willTransition(from:,to:) we insert the new items into the model object,
didTransition(from:,to:) we update the user interface,
The Loading state is represented by a Swift enum
case Loading( () -> () ). Note the associated value. It is a completion handler block that is called when the state transition is completed. This allows an ‘automatic’ transition from the
Loading file URL data and creating images and PDFs can be computationally intensive thus before loading we using the
willTransition(from:,to:) to start a progress indicator on the view,
didTransition(from:,to:) we place the computationally expensive code and call the completion handler which transitions to the
Transitioning to ready contains a single line which simply updates the state to
In the delegate method we stop and hide the progress indicator and enable the merge button,
Extending State Machines
State machines are easy to extend and modify. Transitions compartmentalise application logic making it easy to insert new states, remove old ones, or re-route transitions in different ways. This is particularly useful when prototyping an application as it allows for rapid experimentation whilst minimising code complexity.
The Partial state
In the above workflow there are two bugs:
- Once we enter the ready state we cannot insert any more files. We definitely want a user to be able to drag and drop additional files into the table view or choose import from the File menu. This is easily fixed by adding an
insertItems(_, at:)state transition from
- If the user imports a single file the workflow with dutifully enter the
.Readystate and enable the export button. However two or more files are needed for concatenation so the export button should remain disabled! To fix this let’s introduce a new state
.Loading state can now branch to either
.Partial, in the case where only one file is imported, or ‘.Ready’, in the case where two or more files are imported. In the
.Partial state the export button is disabled. To allow additional files to be imported we link
The Removing state
We also need the ability to remove files, adding a
.Removing state looks like this,
It is possible to enter the
.Removing state from either
.Ready because we must have some files to remove. Depending on how many items were removed resulting state can be either
The Concatenating state
The final piece of the puzzle is the
.Concatenating state. Concatenation occurs when the user clicks the export button. We use Grand Central Dispatch to asynchronously concatenate the PDF pages on a background thread. When this completes we automatically transition to the
.Done state in which we show a save box. The user clicks either cancel or save; we save the concatenated file if needed and then return to the
Take a look at the example code on GitHub, it demonstrates these ideas my implementing the import workflow described above using a
NSTableView. I found state machines a very useful way of organising complexity and triggering and handling completion of asynchronous tasks. State machines made it easy to explore different ideas while developing the application. This was demonstrated above by adding new states that did not interfere with the existing logic of the application.