ForgetMeMot is built on a three-tier architecture with some specific features.
Kotlin coroutines are used for asynchronous work. View works on the regular Ui thread. Some screens use asynchronous inflation layout. A separate businessLogicThread
is allocated to execute the logic. Dispatchers.IO is used to write to the database. SpeakerImpl
encapsulates its own separate thread on which it runs.
The application has the following kinds of states:
kind | who can modify | term | example |
---|---|---|---|
GlobalState | All Interactor s |
Long (as long as the app is installed) | The state of cards, deck settings |
State of Interactor s |
Interactor that owns state |
Short (as long as Interactor is needed) |
Exercise.State consists ExerciseCard s' state, current position, text selections |
Display settings state | Controller s |
Long (as long as the app is installed) | Deck sorting, deck filter in HomeScreen |
Screen state | Controller of screen |
Short (as long as screen is not finished) | DeckSetup screen keeps reference to the deck that is being setuped |
State is the core of an application and how it works affects all 3 tiers of the application. I submitted the following claims to myself:
- modify the state as a regular class:
state.isSearching = false
- possibility of tracking state to update ui
- adequate state saving, maintaining data integrity and high performance.
To address these challenges I developed FlowMaker
and FlowMakerWithRegistry
classes.
To track state changes I utilize the power of Kotlin delegated properties. Entities that represent a state must either be immutable (Integer
, String
, List
) or inherit from FlowMaker
(or FlowMakerWithRegistry
). The requirement of this class is to delegate the assignment of all properties to its flowMaker()
function to track changes:
class HomeScreenState : FlowMaker<HomeScreenState>() {
var searchText: String by flowMaker("")
var selectedDeckIds: List<Long> by flowMaker(emptyList())
var exportedDeck: Deck? by flowMaker(null)
}
I chose the wonderful Kotlin Flow to represent the flow of changes. This is how you can get a flow and transform it in ViewModel
:
val hasSelectedDecks: Flow<Boolean> =
homeScreenState.flowOf(HomeScreenState::selectedDeckIds)
.map { it.isNotEmpty() }
And so you can track in Fragment
:
viewModel.hasSelectedDecks.observe(
fragmentCoroutineScope
) { hasSelectedDecks: Boolean ->
if (hasSelectedDecks) {
if (actionMode == null) {
actionMode = requireActivity().startActionMode(actionModeCallback)
}
} else {
actionMode?.finish()
}
}
inline fun <T> Flow<T>.observe(
coroutineScope: CoroutineScope,
crossinline onEach: (value: T) -> Unit
) {
coroutineScope.launch {
collect {
if (isActive) {
onEach(it)
}
}
}
}
View
immediately reacts to change of a state property, without waiting for the Interactor
to finish its work, thereby increasing the responsiveness of the application.
I use SQDelight for storing state. Depending on the lifetime of the state, I apply two strategy of saving:
-
Short-term state never survives app upgrade. So there is no need to create and maintain database schema. Also, it is usually small in size. To save such state I just serialize it to
String
and put it to database. -
Long-term state is saved by patches.
FlowMakerWithRegistry
is specifically designed to save state this way. Apart from possibility of being tracked, it logs each assignment by adding all change-related information (propertyOwnerClass
,propertyOwnerId
,property
,oldValue
,newValue
) toPropertyChangeRegistry
singleton. The problem is that the records of this registry will be used to write to disk on a different thread. Hence it is necessary to ensure the invariability of records. So I introducedCopyable
interface to take safe copies of mutable objects.FlowMakerWithRegistry
implementsCopyable
.
Separate attitude toward container classes. For example, althoughList
is immutable, the contents ofList
can be mutable. So instead of that "mutable"List
s, I useCopyableList
(List
wrapper that implementsCopyable
). Also, making copies of all objects of a collection can be costly. It happens during regular assigning a new value to the state property and is done onbusinessLogicThread
, that should do its work fast. Therefore, we first compute the diffs of the old and new collection, and then make safe copies of only the affected items.
Saving state is initiated by Controller
and usually occurs after UI events have been processed.
I don't use any dependency injection framework. I manage dependencies by myself. I define a DiScope
for each View
. DiScope
constructs all objects needed for View
and keeps them. DiScope
may be created in two ways: when navigating, and when restoring from the database after Android process death. DiScope
closes when Fragment
finishes. There is also AppDiScope
. It exists all the time and provides dependencies common to everyone.
I use Jetpack Navigation library for navigation. I don't use Safe Args. Instead, I apply an alternate approach that better matches my architecture.
Controller
initiates navigation, while Navigator
is responsible for the navigation logic. Navigator
is designed in such a way that in order to go to another screen, it requires a lambda of creating DiScope
of a new screen. To create a DiScope
instance, either a constructor or a factory method with arguments is used. These arguments are data passed between destinations.
The advantages of this approach:
- no restrictions on the type or size of data passed between screens (unlike Safe Args).
Fragment
s do not participate in the data transfer process. In my opinion, this is not what they should do.- we parallelize the navigation process. On the UI Thread, we immediately ask
NavController
to navigate to another screen while onbusinessLogicThread
we run the code for creatingDiScope
instance of the next screen. It speeds up the opening of a new screen, data and dependencies preparation.