In previous posts the arcitecture of this application was related: a Model-View-Presenter atop a stack of monads:
This setup felt clean-enough initially given the size of the application, but some of the implementaiton details belied its suitability as the top level of abstraction. The main issue, as providentially revealed, was that it was mixing different levels of architectural function into a single layer, muddying things.
In this post I'm going to go over what was wrong with this architecture and what the fix was.
Issues and a Potential Solution
The user-interface of this application currently consists of three different UI screens:
- Idle which instructs the user to drag-and-drop a WAV file into the program for it to load
- Loading to show the progress of reading the file from disk and other processing
- Editing to let the user manipulate the WAV file's contents
Each screen represents a particular stage of operation in the app -- prompting, loading, or interactivity -- with the processing associated with a stage being done by the Model in the background.
The Model and Presenter of the MVP triad were implemented as state-machines, with each machine consisting of states from all three of the different UI screens:
The fact that both machines had states for each of the three screens is where the problem lay; it meant that:
- the states for one screen had to be concerned with ignoring or handling Model-Presenter messages meant for the other screens 1
- the states had to be cognizant of some of the implementation details of the other screens' states for transitioning to them when the application switched screens
- the two state-machines had to be kept in sync so they were both functioning in states for the same screen
All of this added to the messiness and complexity of the code for these modules.
A way to improve this situation, removing these detractive qualities, seemeed to be to split the states out by screen and create an MVP triad for each to separate their concerns:
At first I was uncertain of how sound a decision this might be architecturally, but then something presented itself that quelled my doubts.
Architecture-Level Functions
To transition between the UI screens in the shared-state-machine architecture meant transitioning from one screen's state to another screen's in both the Model and the Presenter. If I were to separate the screens out into different MVP triads it would mean that such direct state-transitions would no-longer be possible when changing screens since their states would be in different Models and Presenters.
Furthermore, the data passed from state to state as part of these transitions would have to go through the new intermediary layer of the MVP triads whenever the app changed screens; whatever mechanism for screen-transitions I came up with would have to facilitate this data transfer.
It was while mulling over this detail w.r.t. the Loading screen that I had a moment of discernment -- one of many divine insights received during this proceess.
The Loading screen represents the stage of the application concerned with reading and processing a WAV file, so its triad would have to be given the path of a file to load, which it would do in the background, and it would have to return the loaded file and associated data for use by the next screen when it was done.
If you squint and look at this description in the abstract, it describes a function! It takes an input, does something, and returns an output! In fact, the application stages represented by every screen could be thought of in terms of being functions:
- the Idle screen would take no arguments and return the path of a file the user gave it -- equivalent to a
getLine
! - the Loading screen would accept a file path and return the loaded file and visualization data, or an error
- the Editing screen would accept the loaded file and visualization data for the user to edit and have no return value 2
With this insight I had my abstraction, along with the realization that the screen-transition logic belonged on its own architectural layer -- so far as the overarching application flow is concerned, displaying stuff to the user and accepting UI input in each screen are just implementation details; the high-level screen-to-screen flow is functional: after a function (screen) is run, the next function to be called depends on the particular output received from the previous one.
Amid other, ongoing refactoring, I moved each screen's state-logic into its own MVP triad and wrapped it as an effectful function, then setup some simple logic to perform the transitions between them:
mainLoop configFilePaths settingsFilePath files = do
(config, messages) <- readConfigFiles configFilePaths
graphics <- initializeGraphicsContext (getStyle config) programName
let context = GlobalContext programName config graphics
_ <- nextScreen context =<<
if null files
then idleScreen context messages
else loadingScreen context messages (head files)
destroyGraphicsContext graphics
nextScreen context = \case
GotFile path -> do
ret <- loadingScreen context [] path
nextScreen context ret
FileLoadErrored{} -> do
ret <- idleScreen context []
nextScreen context ret
FileLoaded audio segments waveRep -> do
ret <- editingScreen context [] audio segments waveRep
nextScreen context ret
Close ->
return ()
Conclusion
This solution segregated the screen-transition logic from the state-machines, extracting it to its own level of abstraction, making it cleaner and clearer. The rest of the code is now more cohesive, with better separation of concerns -- a definite win. (I did have to sacrifice the ability to dynamically reload the style at will with this change, but it was a hacky feature implementation-wise and needed to be rethought-out, which I'm still working on; I'll take the cleaner code for sure.)
I'm not sure if this solution is an already-established pattern for application architecture/flow which I've just rediscovered or if it's something new, but I thought I'd write about it since I could see it being handy for others; I think it might work well in the realm of videogames or mobile apps to represent the different menu screens a user can go through -- the menu item they select would be the return value of the screen-function they're on.
Acknowledgements
- God for the insights and everything else
- Ahmed Fasih for reviewing a draft of this post