Skip to content

Latest commit

 

History

History
109 lines (78 loc) · 7.13 KB

ARCHITECTURE.md

File metadata and controls

109 lines (78 loc) · 7.13 KB

ForgetMeNot Architecture

ForgetMeMot is built on a three-tier architecture with some specific features.

Basic components & Data flow

Architecture scheme

Multithreading

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.

State

The application has the following kinds of states:

kind who can modify term example
GlobalState All Interactors Long (as long as the app is installed) The state of cards, deck settings
State of Interactors Interactor that owns state Short (as long as Interactor is needed) Exercise.State consists ExerciseCards' state, current position, text selections
Display settings state Controllers 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.

Tracking state

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.

Saving state

I use SQDelight for storing state. Depending on the lifetime of the state, I apply two strategy of saving:

  1. 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.

  2. 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) to PropertyChangeRegistry 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 introduced Copyable interface to take safe copies of mutable objects. FlowMakerWithRegistry implements Copyable.

    Separate attitude toward container classes. For example, although List is immutable, the contents of List can be mutable. So instead of that "mutable" Lists, I use CopyableList (List wrapper that implements Copyable). 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 on businessLogicThread, 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.

Dependency Injection

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.

Navigation

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).
  • Fragments 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 on businessLogicThread we run the code for creating DiScope instance of the next screen. It speeds up the opening of a new screen, data and dependencies preparation.