Skip to content

View and its Scope

Viacheslav Ivanovichev edited this page Oct 24, 2023 · 11 revisions

View

One of the main objective of the Blackbox framework is to maximise reusability of UI elements and minimise amount of boilerplate code. To do this, the framework makes good use of Compose multiplatform that simplifies the process of creating adaptable UI elements that can adjust to various device form factors providing clear and declarative code.

Based on this, the Blackbox framework eliminates the need for terms like screens, screen sections, windows, and the like. Instead, everything is categorised as simply a View, and there are only two types of it:

  1. View: represents a portion of the UI, which may include child elements or not. The same view can be any size: a segment of a screen or take a whole. It can be an item of some list or a popup.
  2. NavigationFlow: represents series of Views organised into a navigation.

View as a black-box

Every View or NavigationFlow can be seen as black-box represented by @Composable function of the following interface:

💡 black-box : F(config, dependencies, io) → Composable

None of these parameters is required, but in general, most of the black-box arguments can be categorised as such:

  1. Config — a set of parameters or arguments that are used to configure the black-box during initialisation. For instance, navigation arguments and primitives.
  2. Dependencies - a set of external dependencies that are used by black-box business logic to operate.
  3. IO — Reactive Input/Output used for exchanging events between black-boxes during runtime.

To maximise reusability and customisation it is encouraged to define a @Modifier as an additional function parameter.


To be consistent with the Blackbox principles, each black-box function must conform to the following rules:

  1. The parent function is aware of all the children and their interface. The parent knows nothing about child implementation.
  2. The function should not be aware of outer context.
  3. The function's input/output should know nothing about navigation context.
  4. Pass only dependencies that are required the function to operate.

Here’s example TicketSummary view black-box interface taken from sample MovieApp
sealed interface TicketSummaryOutput {
    data object OnRequestUserData : TicketSummaryOutput
    data class OnPurchased(val order: Order) : TicketSummaryOutput
}

sealed interface TicketSummaryInput {
    data class OnUserData(val user: User) : TicketSummaryInput
}

class TicketSummaryIO : IO<TicketSummaryInput, TicketSummaryOutput>()

@Immutable
class TicketSummaryConfig(
    val movieName: String,
    val cinema: Cinema,
    val showTime: ShowTime,
    val seats: ImmutableList<Seat>
)

@Immutable
class TicketSummaryDependencies(
    val orderRepository: OrderRepository
)

@Composable
fun TicketSummary(
    modifier: Modifier,
    dependencies: TicketSummaryDependencies,
    io: TicketSummaryIO,
    config: TicketSummaryConfig
) {
	// ui definition here ...
}

Since Ticket Summary is @Composable function you must ensure the arguments are treated as stable by Compose Runtime to avoid performance issues. More about stability of the Compose functions.


Scope

Since black-box @Composable can be called multiple times during function recomposition, it is essential to have a mechanism for keeping business logic and state away from being recreated at every recomposition. To address this, each function has its own Scope tied to its lifecycle.

The lifecycle of Scope is following:

  1. Scope is created upon initialisation and lives as longer as @Composable is in composition.
  2. Scope is remembered across @Composable recompositions, that means no matter how many recompositions the function have the corresponding Scope is created only once.
  3. Scope is remembered during device configuration change in Android. Scope component stays in device memory as long as the application is active without being recreated during config changes.
  4. When @Composable leaves the composition Scope gets automatically cleared along with all the objects defined in it. In addition, Scope provides onDestroy callback where all the resources can be cleared manually before it is destroyed. For instance, this is appropriate place for cancelling coroutine scope.

Scope based DI

Scope decouples logic and state from the UI and serves as the appropriate place to define simple dependency injection. This approach offers several benefits:

  1. Easy-to-Read and Debug: as black-boxes divide the app into smaller and reusable components, they also segment the complex app's DI graph into easy manageable subgraphs scoped to that functions.
  2. Constructor based injection. Unlike popular solutions like Koin the DI graph is checked during compile time and freed from potential runtime crashes.
  3. No External Libraries: Scope based DI mechanism doesn't rely on external frameworks.

There are two types of Scope:

  1. ViewScope — a base scope that is tied to a black-box function. The class is used for defining view related dependencies.
  2. FlowScope — an extension of a ViewScope that is used for holding Coordinator and automatically manage its lifecycle.

You can simply define your own Scope by overriding either ViewScope or FlowScope as shown in the code example taken from the MovieApp. The code below defines AppScope for holding dependencies scoped to the App function lifecycle. The dependencies will be retained as long as application is alive since App function is application’s entry point.

@Immutable
class AppDependencies(val httpClient: HttpClient)

internal class AppScope(appDependencies: AppDependencies) : FlowScope() {
    val api by lazy { MoviesApi(appDependencies.httpClient) }

    val movieRepository by lazy { MovieRepositoryImpl(api) }
    val genreRepository by lazy { GenreRepositoryImpl(api) }

    val ticketingFlowIO by lazy { TicketingFlowIO() }
    val authIO by lazy { AuthIO() }
    val homeIO by lazy { HomeIO() }

    val movieDetailsDependencies by lazy {
        MovieDetailsDependencies(
            movieRepository,
            genreRepository
        )
    }

    val homeDependencies by lazy {
        HomeDependencies(
            ticketingFactory = ticketingFactory,
            movieRepository = movieRepository,
            genreRepository = genreRepository
        )
    }

    val ticketingFactory by lazy { TicketingFactory(TicketingDependencies(httpClient = appDependencies.httpClient)) }

    override val coordinator by lazy { AppCoordinator(homeIO, ticketingFlowIO, authIO, movieDetailsIO, config) }
}

To use the scope, simply call rememberScope {} method. The AppScope will be remembered with respect to the lifecycle.

@Composable
fun App(appDependencies: AppDependencies) {
    val appScope = rememberScope { AppScope(appDependencies) }
		// ... some ui logic
}

Scopes are shared within one Navigation route by default. To support multiple scopes tied to some function, the Scopes must be unique. Use unique objects or an unique key passed to the rememberScope to distinguish different scopes.


Custom architecture components

Scope was designed to be a universal DI holder upon which developers can build their own architectural components with respect to a black-box lifecycle. This means that it provides the foundation for creating various architectural patterns, such as MVVM or MVI, and allows for adaptation to specific application needs.

Out of the box the framework provides predefined ViewModel component (similar to Jetpack ViewModel) and corresponding ViewModelScope for managing ViewModel lifecycle:

@Composable
fun Featured(
    modifier: Modifier,
    dependencies: FeaturedDependencies,
    io: FeaturedIO
) {
    val viewModel = rememberScope {
        // Predefined Scope for holding ViewModelInstance
        ViewModelScope {

	// You can define local dependencies here

            FeaturedViewModel(
                repository = dependencies.movieRepository,
                genreRepository = dependencies.genreRepository,
                io = io
            )
        }
    }
 //...
}

Or just use rememberViewModel:

@Composable
fun Featured(
    modifier: Modifier,
    dependencies: FeaturedDependencies,
    io: FeaturedIO
) {
    val viewModel = rememberViewModel {
        FeaturedViewModel(
            repository = dependencies.movieRepository,
            genreRepository = dependencies.genreRepository,
            io = io
        )
    }

 //...
}