Starting a project
I always tell people who ask me how they should go about learning to code: "Think of something you want to make and try to build it." You'll find yourself forced to do research every step of the way, having a project as a goal will motivate you to work on it, and even if you never complete the project you'll learn a lot from it.
In the spirit of that, I've decided that I'd like to build a relatively simple MIDI Sequencer for Android. In addition to acting as a portfolio piece, this will allow me to:
Build complex UI components with Jetpack Compose, which I would like to know well as an Android developer.
Learn more about the domain of music authoring software, which is relevant to my interests as a musician.
Specifically, while I'm familiar with MIDI as a user, I've never programmed software for interacting with it, so that will be a good skill to add to my repertoire.
I started by building a simple proof-of-concept app in order to be sure I could work with MIDI on Android. Along the way, I learned to use the core features of Compose. I’ll cover those two topics more thoroughly in my next two posts, but this post is an overview of how the application itself is set up in order to run and integrate those things.
This won't be required reading for experienced Android developers, but if you're new to the platform or to application development in general it should hopefully serve as a primer on how to set up a new Jetpack Compose project, as well as using Hilt for dependency injection.
I also like simply documenting the process I'm following and attempting to explain how everything fits together. Even for these basics, I find myself wondering aloud if I could improve the code in some ways, or if I really understand what some piece of boilerplate is actually doing "under the hood" within the Android OS and SDK. It helps my own understanding to write those thoughts down.
Here’s a video of the app playing a composition:
The source code for the project can be found here: https://github.com/kaelambda/note-grid
Just the basics
NoteGrid is not a complex app, so its "architecture" as such is achieved simply by dividing the code into three categories: Android integration, the user interface, and audio output.
These are all the essential classes and composable functions in the app:
Android integration:
MainActivity
NoteGridViewModel
NoteGridViewModelModule
Compose UI:
NoteGridTheme
NoteGridScreen
NoteGrid
Note
InstrumentSelector
Audio and MIDI:
MidiPlaybackController
SoundPoolPlaybackController
MidiFileWriter
I haven't actually put these classes in separate modules, just packages, which in Kotlin is more about organizing your code than actually implementing encapsulation. Kotlin has an 'internal' access modifier but not 'package private', so classes in different packages can see the public members of other packages within the same module. For a simple proof-of-concept, this is fine, but it's something that would change in a production app, which I would divide into modules.
That said, there is a second module in NoteGrid called sherlockmidi
. I've taken this directly from the MidiDriver project, which I’ll discuss in more detail in a future post. So there is still good separation between the application code I've worked on and code that I only touch in order to apply updates from an upstream repository.
Android integration
A pretty small amount of code is needed for the glue that provides an entry-point from the OS and connects our classes together. We just need an Activity and a ViewModel.
MainActivity is the initial entry-point of our app, as defined in the AndroidManifest just as one would for any typical Android app:
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.NoteGrid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
The entire MainActivity class is small enough to include as a code snippet:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NoteGridTheme {
NoteGridScreen(viewModel())
}
}
}
}
This has only been modified slightly from the MainActivity included automatically by Android Studio when creating a new project. The @AndroidEntryPoint
annotation tells Hilt to generate dependencies for classes used by this Activity -- but we'll cover Hilt setup later in this post. What's really important is the usage of setContent{}
in onCreate()
. setContent{}
is what tells this Activity that it will be built with Compose and passes along the composable function used to generate the contents of the screen.
NoteGridTheme defines themed values for things like colours that our UI will use, and was also included automatically by Android Studio.
NoteGridScreen is the root of our layout. A somewhat magical viewModel()
SDK function creates an instance of the appropriate ViewModel class (in this case, our NoteGridViewModel) that we pass to NoteGridScreen.
NoteGridViewModel extends ViewModel, a core Android SDK class that is lifecycle-aware like Activity, but which survives events like device rotation that cause an Activity to be destroyed and recreated. This makes our ViewModel the ideal place to store state and dependencies that we want to be long-lived, like our audio playback controllers, and to route events from the UI to the appropriate logic. NoteGridScreen is passed the NoteGridViewModel so that it can tell it about these events.
Composable UI
NoteGridScreen is the root of our view. The screen declares and remembers a fair bit of its own state. This includes a matrix of booleans representing which notes are enabled and a 'time' Animatable for progressing through the grid when playing.
Once it's done that, NoteGridScreen populates a Surface with UI controls, including the grid of notes. Some UI controls have been refactored into their own components, namely NoteGrid and InstrumentSelector, and it would probably be good to refactor others into their own composable functions as well.
In addition to managing their own state, Compose components can perform logic and calculations to determine how to present themselves and when to trigger events (for example by calling functions in the ViewModel). It makes sense to allow a certain amount of UI logic in the views, but when calculations are more complex it's best practice to trigger an event and let a class or module dedicated to business logic take things from there.
I’ll go into more detail about building this screen in my next post.
Audio and file I/O
There are actually two playback controllers in NoteGrid: One backed by SoundPool, a straightforward Android SDK class for playing short audio clips from a collection; and one backed by MidiDriver, an open-source library that includes an Android port of javax.sound.midi along with other packages, and a SoftSynthesizer used to load soundfonts and play MIDI data in real-time.
Getting MIDI playback working took a bit of digging and experimentation, which I'll document in its own post.
Thankfully, however, with that research complete, the actual implementations of both MidiPlaybackController and MidiFileWriter are pretty straightforward. Library code does the heavy lifting.
Hilt setup
Our collection of classes forms a "object graph" where some classes depend on others in order to do their jobs. For example, MidiPlaybackController uses a SoftSynthesizer to play audio and a Context to load a soundfont from the filesystem. When we have such a dependency, our preference should be to pass it into the class as a constructor parameter rather than construct it within the class. This principle of "inversion of control" allows code to be tested in isolation by passing in mock versions of dependencies.
If we don't use a Dependency Injection tool such as Hilt, then we need to write code that constructs all the objects in our dependency graph ourselves. This is a reasonable enough approach, but DI frameworks do simplify that work, especially for larger projects. For example, in order for classes such as NoteGridViewModel to be connected to the Android lifecycle they shouldn't be constructed directly. Instead, we'd need to implement a ViewModelProvider.Factory to pass to the androidx.lifecycle.viewmodel.compose.viewModel utility function. With Hilt, that code is replaced with an annotation on the ViewModel.
The official documentation for Hilt can be found here: https://developer.android.com/training/dependency-injection/hilt-android
Hilt provides simple Android integration and my experience with it so far is that it's much easier to set up than Dagger, which Hilt extends. A handful of annotations get us most of the way there. For starters:
@HiltAndroidApp
class NoteGridApplication : Application()
This is our entire NoteGridApplication class. It only exists at all so that we can annotate it with @HiltAndroidApp
, giving Hilt an entrypoint where it can generate components -- essentially the root of the object graph.
The @AndroidEntryPoint
annotation above MainActivity performs a similar function of marking an Android component class to be setup for injection, as does @HiltViewModel
above NoteGridViewModel.
Hilt injection and modules
With that initial setup taken care of, we can use constructor injection on our classes that don't inherit from the Android SDK.
class MidiPlaybackController @Inject constructor(
@ApplicationContext private val appContext: Context,
private val synth: SoftSynthesizer
) { … }
@Inject
tells Hilt to provide dependencies and construct an instance of this class when it's required by other objects. @ApplicationContext
tells Hilt to provide our Application as a Context rather than something else, like an Activity -- which would cause issues when the Activity was destroyed and recreated. It might be better still not to depend on Context at all in MidiPlaybackController, but this is a convenient way to access the app's assets in order to load a soundbank.
SoftSynthesizer isn't one of our classes -- it comes from a library. So rather than annotate it with @Inject
, which we can't do for code we don't control, we need to tell Hilt how to construct one by creating a Hilt module (not to be confused with Kotlin modules):
@Module
@InstallIn(ViewModelComponent::class)
object NoteGridViewModelModule {
@Providespl
fun providesSynth(): SoftSynthesizer {
return SoftSynthesizer()
}
}
With this in place, Hilt will create a SoftSynthesizer object whenever a class it's injecting asks for one.
ViewModel injection
There's just one more subtlety to injecting Android components like the ViewModel, which is that we can't use constructor injection for these classes. Instead we have to annotate our Hilt-injected properties like this:
@HiltViewModel
class NoteGridViewModel @Inject constructor(): ViewModel() {
@Inject lateinit var soundPoolController: SoundPoolPlaybackController
@Inject lateinit var midiController: MidiPlaybackController
@Inject lateinit var midiFileWriter: MidiFileWriter
@Inject lateinit var mediaPlayer: MediaPlayer
…
}
One of the only minor gripes I have with Hilt is that these dependencies need to be declared as lateinit var
properties rather than in the constructor. I'd prefer dependencies to be constant once initialized (that is, using val
as shown above in MidiPlaybackController), while using lateinit var
means that they could be changed at any time, even from outside the class. Of course, this is how Hilt reaches in to initialize them, so it's a bit of a necessary evil. In practice, this is not a big deal as long as we remember not to reassign variables declared with an @Inject
annotation.
It's also a point in favour of considering ViewModel implementations to be part of the "Interface Adapters" layer of our application (as defined by Robert C. Martin in describing Clean Architecture) -- that is, as part of the "glue" connecting our application to the operating system, rather than a place to put business logic. This is some special boilerplate that we need for that, which we can isolate here. The ViewModel is put in charge of responding to UI and lifecycle events and dispatching them to the appropriate classes implementing those use cases, and it shouldn't do more than that. Those other classes can use constructor injection and not worry about their dependencies being reassigned.
Now we just need to build the rest of the app
Now that I've described the overall organization of the app, I hope it will be easy to see where the code discussed in my upcoming posts fits into the project. I find it very satisfying to be able to follow the flow of a program's logic from its initial entrypoint all the way down into the low-level details of animating UI components and writing to files, and I hope these posts make that thread easy to follow for this simple sample app.
Once again, in case you’d like to take a closer look, the source code for the project can be found here: https://github.com/kaelambda/note-grid