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:

  1. Get the file URLs and create model objects.
  2. 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.
  3. Asynchronously load all URL data:
    • Update the main view’s progress indicator spinner
    • Create either NSImage or PDFDocument objects
  4. 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.
  5. 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

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,

Concat State Machine

The following code snippets are implemented in the view controller because it has access to both the model and view.

Empty

In the empty state Concat shows a empty placeholder image and make sure that all model objects are deleted (if we have any).

Inserting

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 insertItems(_, at:),

    func insertItems(items: [FileItem], at index:NSIndexSet?) {
		// 0. Create `FileItem` model objects for each URL
		...
        // 1. Transition to inserting
        machine.state = .Inserting(items, nil)
        // 2. Automatically transition to loading
        self.transitionToLoading()
    }

Once the state has changed we want to automatically transition to the Loading state, this is achieved in the last line by calling self.transitionToLoading().

In willTransition(from:,to:) we insert the new items into the model object,

    func willTransitionFrom(from: StateType, to: StateType) {
        switch (from, to) {
		//...
        case (.Empty, .Inserting(let items, _)):
            // Update model (ignore indexes)
            self.model.items.appendContentsOf(items)
		//...
		}
	}

and in didTransition(from:,to:) we update the user interface,

    func didTransitionFrom(from: StateType, to: StateType) {
        
        switch (from, to) {
        case (.Empty, .Inserting(_, _)):
            // State has changed and items have been inserted into the
            // model object. In here we update the user interface.
            self.tableView.reloadData()
		}
	}

Loading

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 to .Ready state.

    func transitionToLoading() {
	/* 2. This block is called when the transition to Loading is complete.
	    It simply performs an 'automatic' transition to Ready. */
        let completionHandler = { [weak self] () -> Void in
            if let weakSelf = self {
                // When loading to complete transitions to .Ready
                weakSelf.transitionToReady()
            }
        }
	// 1. Change state to Loading
        machine.state = .Loading(completionHandler)
    }

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,

    func willTransitionFrom(from: StateType, to: StateType) {
        switch (from, to) {
		//...
        case (.Inserting, .Loading):
            // Update UI
            self.progressIndicator.hidden = false
            self.progressIndicator.startAnimation(nil)
    	//...
        }
    }

In didTransition(from:,to:) we place the computationally expensive code and call the completion handler which transitions to the .Ready state,

    func didTransitionFrom(from: StateType, to: StateType) {
        
        switch (from, to) {
        //...
        case (.Inserting, .Loading(let completionHandler)):
            
            let qualityOfServiceClass = QOS_CLASS_USER_INITIATED
            let backgroundQueue = dispatch_get_global_queue(qualityOfServiceClass, 0)
            dispatch_async(backgroundQueue, { [weak self] () -> Void in
                
                if let weakSelf = self {
                    for item in weakSelf.model.items {
                        // Computationally intensive tasks go here
                        item.loadFileURL()
                    }
                }
                
                // Call completion handler always
                dispatch_async(dispatch_get_main_queue(), { () -> Void in
                    // Executed on main thread
                    completionHandler()
                })
            })
		//....
		}
	}

Ready

Transitioning to ready contains a single line which simply updates the state to .Ready,

    func transitionToReady() {
        machine.state = .Ready
    }

In the delegate method we stop and hide the progress indicator and enable the merge button,

    // State machine delegate
    func didTransitionFrom(from: StateType, to: StateType) {
        
        switch (from, to) {
		
		//...
        case (.Loading, .Ready):
            // Update UI
            self.progressIndicator.hidden = true
            self.progressIndicator.stopAnimation(nil)
            self.exportPDFButton.enabled = true
          
		//...
        }
    }

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:

  1. 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 .Ready to .Inserting.
  2. If the user imports a single file the workflow with dutifully enter the .Ready state 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 .Partial.

Concat State Machine with Partial state

The .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 .Partial to .Inserting.

The Removing state

We also need the ability to remove files, adding a .Removing state looks like this,

Concat State Machine with Removing state

It is possible to enter the .Removing state from either .Partial or .Ready because we must have some files to remove. Depending on how many items were removed resulting state can be either .Empty, .Partial or .Ready.

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 .Ready state.

Concat State Machine with Concatenation state

Example project

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.