Mobile and Web Application Architecture with Decompose
In the previous post, I covered common HTTP clients for Android, iOS, and web applications. We were able to write a simple test and ensure that apps can communicate with the backend. We are slowly approaching the point, where we will be able to present the data to the users. To do so we should structure our code since we have got the repository layer done. Now it’s time to build up the presentation. For this, we will use Decompose a Kotlin multiplatform library that can build lifecycle-aware business logic components and provide routing. I choose to decompose because of its support for wasm.
libs.versions.toml
[versions]
decompose = "3.0.0-alpha04"
essenty = "2.0.0-alpha02"
[libraries]
decompose-core = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" }
decompose-extensions-compose = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose" }
essenty-lifecycle = { group = "com.arkivanov.essenty", name = "lifecycle", version.ref = "essenty" }
essenty-stateKeeper = { group = "com.arkivanov.essenty", name = "state-keeper", version.ref = "essenty" }
essenty-instanceKeeper = { group = "com.arkivanov.essenty", name = "instance-keeper", version.ref ="essenty" }
essenty-backHandler = { group = "com.arkivanov.essenty", name = "back-handler", version.ref = "essenty" }
shared/build.gradle.kts
commonMain.dependencies {
...
implementation(libs.decompose.core)
implementation(libs.essenty.lifecycle)
api(libs.essenty.stateKeeper)
api(libs.essenty.backHandler)
}
With the decompose we can create classes that will be our presenters – they are called Components and need to implement ComponentContext interface. This will be the place where all interactions happen: API calls, building UI models, updating the views, handling users’ interactions, and more. If you are an Android developer, you can think about them as ViewModels. Decompose will build a stack of components and provide a simple way to navigate around the stack. Components that aren’t currently visible are not destroyed but they can continue working in the background without attached UI. If you want to know more about decompose I encourage you to read the documentation. With this quick brief, we can create the RootComponent the entry point to our application, that will be hosting all the other components (sub-components/screens) and live as long as the whole application.
shared/features/RootComponent.kt
interface RootComponent {
val childStack: Value<ChildStack<*, Child>>
sealed class Child {
class LoginChild(val component: LoginComponent) : Child()
class RegisterChild(val component: RegisterComponent) : Child()
}
}
Every Component that will be able to host other components needs to provide a childStack – a holder value for current components. The childStack is visible for the platform and based on its values the UI will be determined. It can return actual (top child) and backStack (inactive children) or all of the items. The Child sealed class is the representation of components that can be hosted by the RootComponent. It is also a wrapper for the other Components.
shared/features/RealRootComponent.kt
internal class RealRootComponent(
componentContext: ComponentContext,
) : RootComponent, ComponentContext by componentContext {}
The context provides us with the whole lifecycle aware kinds of stuff and helps us implement behavior like stack navigation. The navigation requires a Configuration of the set of parameters, values, or any other things that are necessary to create sub-components. The configuration should be created by ourselves and needs to be serializable – the most common pattern is to use a sealed class again.
shared/commonMain/features/RealRootComponent.kt
private val navigation = StackNavigation<Config>()
@Serializable
sealed interface Config {
@Serializable
data object Login : Config
@Serializable
data object Register : Config
}
Following the same rules, we can create LoginComponent and RegisterComponent that will hold the login form. All the screens that can be reached from the RootComponent must be defined in child and config classes. Of course, different configurations may lead us to the same components.
shared/commonMain/features/login/LoginComponent.kt
interface LoginComponent {
fun onRegisterClick()
}
shared/commonMain/features/login/RealLoginComponent.kt
internal class RealLoginComponent(
componentContext: ComponentContext,
private val onRegister: () -> Unitt
) : LoginComponent, ComponentContext by componentContext {
override fun onRegisterClick() {
onRegister()
}
}
Now we can write a function that will use configuration to create a child, let’s call it childFactory.
shared/commonMain/features/RealRootComponent.kt
private fun childFactory(
config: Config,
componentContext: ComponentContext,
) = when (config) {
Config.Login ->
RootComponent.Child.LoginChild(
RealLoginComponent(componentContext = componentContext)
)
}
The last thing is to create a childStack that will manage the navigation. It will also be responsible for creating children and managing the components. It should have a unique key, navigation source, serializer that determines how to serialize the configuration, a flag that determines if the stack should handle the back button, an initial stack from which the component should start, and a child factory for creating new subcomponents.
private val stack = childStack(
key = "RootComponent",
source = navigation,
serializer = Config.serializer(),
handleBackButton = true,
initialStack = { listOf(Config.Login) },
childFactory = ::childFactory,
)
override val childStack: Value<ChildStack<*, RootComponent.Child>> = stack
The returning type of the stack is Value an internal Decompose way to provide an observable state (just like the state in Jetpack Compose). It’s a custom class that gives us the flexibility to do the library on any platform we want.
With the above configuration, we can now dandle the navigation events. With the onRegister lambda in LoginComponent, we can invoke changing the screen in RootComponent. The pushNew function will push a new configuration on the top of the current stack. With such an approach the whole navigation logic is separated from the platforms, and it’s simplified so it can be unit-tested in the shared code without running additional devices.
private fun childFactory(
config: Config,
componentContext: ComponentContext,
) = when (config) {
Config.Login -> {
RootComponent.Child.LoginChild(
RealLoginComponent(
componentContext = componentContext,
onRegister = {
navigation.pushNew(Config.Register)
},
),
)
}
Config.Register -> {
RootComponent.Child.RegisterChild(
RealRegisterComponent(
componentContext = componentContext,
),
)
}
}
The last thing that we need to do is to create RootComponent in the shared platform UI and start using the created stack. We can start from the entry point for all the platforms with the App function, which for now will only provide the RootScrren.
composeApp/commonMain/App.kt
@Composable
fun App(
component: RootComponent,
modifier: Modifier,
) {
RootScreen(component = component, modifier = modifier)
}
composeApp/commonMain/features/RootScreen.kt
@Composable
private fun RootScreen(
component: RootComponent,
modifier: Modifier = Modifier,
) {
Children(
stack = component.childStack,
modifier = modifier,
animation = stackAnimation(fade()),
) {
when (val child = it.instance) {
is RootComponent.Child.LoginChild ->
LoginScreen(child.component)
is RootComponent.Child.RegisterChild ->
RegisterScreen(child.component)
}
}
}
The Children function is part of the Decompose library responsible for handling the childStack values. The last parameter of this function is content @Composable lambda that returns the current child and allows us to handle UI changes depending on current child.
composeApp/commonMain/features/login/LoginScreen.kt
@Composable
internal fun LoginScreen(
component: LoginComponent,
modifier: Modifier = Modifier,
) {
Button(
onClick = {
component.onRegisterClick()
},
content = {
Text("Register")
},
)
}
composeApp/commonMain/features/register/RegisterScreen.kt
@Composable
internal fun RegisterScreen(
component: RegisterComponent,
modifier: Modifier = Modifier,
) {
Text("This is the Register screen")
}
Finally, it is time to use our App function on every platform. On Android, we need to use retainedComponent function that will handle orientation changes.
composeApp/androidMain/gameshop/MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = retainedComponent { RealRootComponent(componentContext = it) }
setContent { App(component = root, modifier = Modifier.fillMaxSize()) }
}
}
composeApp/iosMain/gameshop/main.kt
fun MainViewController() = ComposeUIViewController {
val root = remember { RealRootComponent(componentContext = DefaultComponentContext(LifecycleRegistry()),) }
App(component = root, modifier = Modifier.fillMaxSize())
}
composeApp/wasmJsMain/gameshop/main.kt
fun main() {
val root = RealRootComponent(componentContext = DefaultComponentContext(lifecycle = LifecycleRegistry()),)
CanvasBasedWindow(title = "GameShop", canvasElementId = "gameShopCanvas") {
App(component = root, modifier = Modifier.fillMaxSize())
}
}
This was the basic setup of the Decompose, which we can follow for other screens. Nevertheless, having direct access to components implementation and creating them on your own is not a great approach. Since we have created a simple DI and we are using it for the repository layer we should extend it with the methods for creating components.
composeApp/commonMain/features/factory/ComponentFactory.kt
interface ComponentFactory {
fun createRootComponent(
componentContext: ComponentContext,
): RootComponent
fun createRegisterComponent(
componentContext: ComponentContext
): RegisterComponent
fun createLoginComponent(
componentContext: ComponentContext,
onRegister: () -> Unit,
): LoginComponent
}
Now with the usage of the factory, we can inject it via the constructor to the RootCompoent and delegate the creation of sub-components to the factory.
ComposeApp/commonMain/features/RealRootComponent.kt
internal class RealRootComponent(
componentContext: ComponentContext,
private val componentFactory: ComponentFactory,
) : RootComponent, ComponentContext by componentContext {
...
Config.Register -> {
RootComponent.Child.RegisterChild(
componentFactory.createRegisterComponent(
componentContext = componentContext,
),
)
}
...
}
The implementation of ComponentFactory will have all the necessary dependencies to create any component, with such an approach we don’t have to take care of creating any additional classes. We can also extend the LoginComponent and the RegisterComponent with additional properties such as repository and lambda functions for user interactions. Lambdas will be passed from parent to child, but the repo is injected into the factory.
composeApp/commonMain/features/factory/RealComponentFactory.kt
internal class RealComponentFactory(
private val remoteRepository: RemoteRepository,
) : ComponentFactory {
override fun createRootComponent(
componentContext: ComponentContext,
): RootComponent {
return RealRootComponent(
componentContext = componentContext,
componentFactory = this,
)
}
override fun createRegisterComponent(componentContext: ComponentContext): RegisterComponent {
return RealRegisterComponent(
componentContext = componentContext,
loginRepository = remoteRepository.loginRepository(),
)
}
override fun createLoginComponent(
componentContext: ComponentContext,
onLogin: () -> Unit,
onRegister: () -> Unit,
): LoginComponent {
return RealLoginComponent(
loginRepository = remoteRepository.loginRepository(),
onLogin = onLogin,
onRegister = onRegister,
)
}
}
The last thing to do is to add the factory and it’s implementation to the DI class and use it on the platforms.
composeApp/commonMain/di/DI.kt
object DI {
private val tokenStorage: TokenStorage = RealTokenStorage()
private val httpClientFactory: HttpClientFactory = HttpClientFactory(tokenStorage)
private val remoteRepository: RemoteRepository = RealRemoteRepository(httpClientFactory.create(), tokenStorage)
fun rootComponent(
componentContext: ComponentContext,
): RootComponent {
return RealComponentFactory(remoteRepository = remoteRepository)
.createRootComponent(componentContext = componentContext)
}
}
composeApp/androidMain/gameshop/MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = retainedComponent { DI.rootComponent(componentContext = it) }
setContent { App(component = root, modifier = Modifier.fillMaxSize()) }
}
}
With the work made above where we introduced the presentation layer with components it’s time to call the API. Let’s assume that after clicking a login button on the login screen, the app will call the API with user credentials. With a successful response, the onLogin lambda should be invoked, and the user should be navigated to the home screen where the games list and orders will be presented.
The HomeComponent will be similar to the RootComponent as it will have its stack with GamesListComponent and OrdersComponent. The screen will have bottom navigation that will allow you to switch views.
The API calls are made with suspend functions for such reason we need to invoke them from a coroutine scope therefore every component should be able to create and provide its lifecycle-aware scope that can be used for making such requests.
We can introduce a BaseComponent abstract class that will be responsible for handling the coroutine scope creation. Every component implementation should use it to avoid code duplication.
composeApp/commonMain/features/BaseComponent.kt
internal abstract class BaseComponent(
componentContext: ComponentContext,
coroutineContext: CoroutineContext,
) : ComponentContext by componentContext {
protected val scope by lazy { coroutineScope(coroutineContext + Dispatchers.Default + SupervisorJob()) }
private fun CoroutineScope(
context: CoroutineContext,
lifecycle: Lifecycle,
): CoroutineScope {
val scope = CoroutineScope(context)
lifecycle.doOnDestroy { scope.coroutineContext.cancelChildren() }
return scope
}
private fun LifecycleOwner.coroutineScope(context: CoroutineContext): CoroutineScope =
CoroutineScope(context, lifecycle)
}
shared/features/login/RealLoginComponent.kt
internal class RealLoginComponent(
componentContext: ComponentContext,
coroutineContext: CoroutineContext,
private val loginRepository: LoginRepository,
private val onLogin: () -> Unit,
private val onRegister: () -> Unit,
) : BaseComponent(componentContext, coroutineContext), LoginComponent {
...
}
Let’s move forward and create a component factory, which will be used all over the app to help create components.
composeApp/commonMain/factory/ComponentFactory.kt
interface ComponentFactory {
fun createRootComponent(componentContext: ComponentContext): RootComponent
fun createRegisterComponent(componentContext: ComponentContext): RegisterComponent
fun createLoginComponent(componentContext: ComponentContext, onLogin: () -> Unit, onRegister: () -> Unit, ): LoginComponent
}
Through the implementation we can pass all the required dependencies
composeApp/commonMain/factory/RealComponentFactory.kt
internal class RealComponentFactory(
private val mainContext: CoroutineContext,
private val remoteRepository: RemoteRepository,
) : ComponentFactory {
override fun createRootComponent(componentContext: ComponentContext, ): RootComponent {
return RealRootComponent(
coroutineContext = mainContext,
componentContext = componentContext,
componentFactory = this,
)
}
override fun createRegisterComponent(componentContext: ComponentContext): RegisterComponent {
return RealRegisterComponent(
componentContext = componentContext,
coroutineContext = mainContext,
loginRepository = remoteRepository.loginRepository(),
)
}
override fun createLoginComponent(
componentContext: ComponentContext,
onLogin: () -> Unit,
onRegister: () -> Unit,
): LoginComponent {
return RealLoginComponent(
coroutineContext = mainContext,
componentContext = componentContext,
loginRepository = remoteRepository.loginRepository(),
onLogin = onLogin,
onRegister = onRegister,
)
}
}
Then in our ID object, we can replace the direct RootComponent call with the newly created factory. We also need to modify the platform call, and finally, the internals of the RealRootComponent to use the factory to create its sub-components.
composeApp/commonMain/di/DI.kt
object DI {
private val tokenStorage: TokenStorage = RealTokenStorage()
private val httpClientFactory: HttpClientFactory = HttpClientFactory(tokenStorage)
private val remoteRepository: RemoteRepository = RealRemoteRepository(httpClientFactory.create(), tokenStorage)
fun rootComponent(
componentContext: ComponentContext,
mainContext: CoroutineContext
): RootComponent {
return RealComponentFactory(
mainContext = mainContext,
remoteRepository = remoteRepository,
).createRootComponent(
componentContext = componentContext,
)
}
}
composeApp/androidMain/gameshop/MainActivity.kt
DI.rootComponent(componentContext = it, mainContext = MainScope().coroutineContext)
ComposeApp/commonMain/features/RealRootComponent.kt
internal class RealRootComponent(
componentContext: ComponentContext,
coroutineContext: CoroutineContext,
private val componentFactory: ComponentFactory,
) : BaseComponent(componentContext, coroutineContext), RootComponent {
...
private fun childFactory(
config: Config,
componentContext: ComponentContext,
) = when (config) {
Config.Login -> {
RootComponent.Child.LoginChild(
componentFactory.createLoginComponent(
componentContext = componentContext,
onLogin = {
navigation.pushNew(Config.Home)
},
onRegister = {
navigation.pushNew(Config.Register)
},
),
)
}
Config.Register -> {
RootComponent.Child.RegisterChild(
componentFactory.createRegisterComponent(
componentContext = componentContext,
),
)
}
}
Since the web application navigation is quite different than mobile – we can reach certain screens by passing a proper link – we need to ensure that this is handled. Thankfully the Decompose provides a tool to do it. The WebHistoryController is a connection between childStack and the Web History interface, it holds the web paths and can change the navigation accordingly to the current address. We can also introduce a sealed class called DeepLink which will be producing the current web path.
composeApp/commonMain/deepLink/DeepLink.kt
sealed interface DeepLink {
data object None : DeepLink
class Web(val path: String) : DeepLink
}
composeApp/commonMain/features/RealRootComponent.kt
@OptIn(ExperimentalDecomposeApi::class)
internal class RealRootComponent(
componentContext: ComponentContext,
coroutineContext: CoroutineContext,
private val deepLink: DeepLink = DeepLink.None,
private val webHistoryController: WebHistoryController? = null,
private val componentFactory: ComponentFactory,
) : BaseComponent(componentContext, coroutineContext), RootComponent { ... }
The webHistoryController needs to be attached to the stack and navigation. We need to pass the navigation, stack, and serializer. Then we need to find a way to change the web application path based on the current configuration. The getPath lambda provides the current configuration and requires a String in return. Similarly, the proper configuration should be returned for a given path and this is a role for the getConfiguration lambda, which takes a String and returns Configuration.
composeApp/commonMain/features/RealRootComponent.kt
init {
webHistoryController?.attach(
navigator = navigation,
stack = stack,
serializer = Config.serializer(),
getPath = ::getPathForConfig,
getConfiguration = ::getConfigForPath,
)
}
composeApp/commonMain/features/RealRootComponent.kt
private fun getPathForConfig(config: Config): String =
when (config) {
Config.Login -> "/login"
Config.Register -> "/register"
}
composeApp/commonMain/features/RealRootComponent.kt
private fun getConfigForPath(path: String): Config =
when (path.removePrefix("/")) {
“login” -> Config.Login
“register” -> Config.Register
else -> Config.Login
}
The childStack function also needs to be modified. The initialStack might be constructed differently, based on the passed path. We will take the webHistoryController paths, iterate through them, and try to find a proper config for a given address. If we can’t find anything we should initialize the default stack. For mobile apps (when here is no DeepLink) we are returning the Login configuration, but for the Web application with the provided address, we should try to resolve it.
composeApp/commonMain/features/RealRootComponent.kt
private val stack =
childStack(
key = "RootComponent",
source = navigation,
serializer = Config.serializer(),
handleBackButton = true,
initialStack = {
getInitialStack(
webHistoryPaths = webHistoryController?.historyPaths,
deepLink = deepLink,
)
},
childFactory = ::childFactory,
)
composeApp/commonMain/features/RealRootComponent.kt
private fun getInitialStack(
webHistoryPaths: List<String>?,
deepLink: DeepLink,
): List<Config> =
webHistoryPaths
?.takeUnless(List<*>::isEmpty)
?.map(::getConfigForPath)
?: getInitialStack(deepLink)
private fun getInitialStack(deepLink: DeepLink): List<Config> =
when (deepLink) {
is DeepLink.None -> listOf(Config.Login)
is DeepLink.Web -> listOf(getConfigForPath(deepLink.path))
}
Since we added two new parameters to the RootComponent constructor we need to update the factory and the platforms. We used default parameters in RootComponent so after adjusting the factory only the Web application entry point will be changed.
composeApp/iosMain/gameshop/main.kt
fun MainViewController() = ComposeUIViewController {
val root =
DI.rootComponent(
componentContext = DefaultComponentContext(lifecycle = LifecycleRegistry()),
deepLink = DeepLink.Web(path = window.location.pathname),
webHistoryController = DefaultWebHistoryController(),
mainContext = MainScope().coroutineContext,
)
App(component = root, modifier = Modifier.fillMaxSize())
}
That’s all in the when it comes to app architecture. We’ve got the presentation layer ready, with some abstraction on top of it, a component factory, and a configured DI. Adding new functionalities should be straightforward, every new screen needs its component and should be added to the navigation. We still missing one critical part – the UI layer. In the next blogpost, we will focus on providing UI models to the platforms and handling user interactions. We will create a simple login form and a home screen with a games list.