
Modern architecture toolkit offering ViewModel foundations, coroutine-based UseCase/FlowUseCase framework, Compose-friendly State/StateFlow UI patterns, Decompose navigation support, and Hilt/Koin DI conventions.
Arkitekt is a set of architectural tools based on Android Architecture Components, which gives you a solid base to implement the concise, testable and solid application.
dependencies {
// Android / Compose
implementation("app.futured.arkitekt:core:LatestVersion")
implementation("app.futured.arkitekt:cr-usecases:LatestVersion")
implementation("app.futured.arkitekt:compose:LatestVersion")
// Decompose / KMP (optional)
implementation("app.futured.arkitekt:decompose:LatestVersion")
implementation("app.futured.arkitekt:decompose-annotation:LatestVersion")
ksp("app.futured.arkitekt:decompose-processor:LatestVersion")
// Testing
testImplementation("app.futured.arkitekt:core-test:LatestVersion")
testImplementation("app.futured.arkitekt:cr-usecases-test:LatestVersion")
}Add new maven repo to your top level gradle file.
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
Snapshots are grouped based on major version, so for version 6.x use:
implementation "app.futured.arkitekt:core:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:cr-usecases:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:compose:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:decompose:6.X.X-SNAPSHOT"Arkitekt is a modern Android & Kotlin Multiplatform architecture library focused on Jetpack Compose and Kotlin Coroutines. It combines built-in support for ViewModel, Coroutines use cases, Jetpack Compose, and Decompose (KMP). For dependency injection, the Android/Compose path uses Dagger-Hilt, while the Decompose/KMP path uses Koin.
Note: As of version 6.x, Arkitekt has removed legacy LiveData-based components, Fragment/Activity base classes, RxJava support, DataBinding, and the Dagger module. The library is now exclusively focused on Jetpack Compose with State/StateFlow for reactive UI and Kotlin Multiplatform via Decompose.
Version 6.x represents a major refactoring focused on modern Android development with Jetpack Compose. The following legacy components have been removed:
rx-usecases and rx-usecases-test - RxJava support has been completely removed. Use cr-usecases (Coroutines) insteaddagger - Dagger 2 injection module removed. Use Dagger-Hilt (@HiltViewModel, @AndroidEntryPoint) insteadbindingadapters - DataBinding adapters removed. Use Jetpack Compose insteadexample-minimal and example-hilt - Consolidated into single example moduleViewModel Base Classes:
BaseViewModel (from core) - Use BaseCoreViewModel (core) or BaseViewModel (compose) insteadBaseCrViewModel (from cr-usecases) - Use BaseViewModel (compose) insteadFragment/Activity Base Classes:
ViewModelActivity, BindingViewModelActivity - Use standard ComponentActivity with @AndroidEntryPoint
ViewModelFragment, BindingViewModelFragment - Use standard Compose navigationViewModelBottomSheetDialogFragment, BindingViewModelBottomSheetDialogFragment - Use Compose bottom sheetsViewModelDialogFragment, BindingViewModelDialogFragment - Use Compose dialogsDagger Classes:
BaseViewModelFactory, BaseSavedStateViewModelFactory - Use @HiltViewModel with hiltViewModel()
BaseDaggerActivity, BaseDaggerFragment and their Binding variants - Use @AndroidEntryPoint
ViewModelCreator, ViewModelFactory - No longer needed with HiltLiveData Components:
LiveEvent and LiveEventBus - Use Event with Channel-based eventsDefaultValueLiveData and DefaultValueMediatorLiveData - Use StateFlow or Compose State
NonNullLiveData - Use StateFlow or Compose State
UiData, UiDataExtensions, UiDataMediator - Use StateFlow or Compose State
LiveDataExtensions and LiveDataUtils - Use Kotlin Flow operatorsDataBinding:
ViewDataBinding base classes, binding adapters) - Use Jetpack Compose@AndroidEntryPoint
StateFlow (for ViewModels) or State (for Compose)Event system (see Events section)Minimal working project must contain files as presented in example
module. File hierarchy might look like this:
example
`-- src/main
|-- java/app/futured/arkitekt/sample
| |-- ui
| | |-- main
| | | `-- MainActivity.kt
| | `-- home
| | |-- HomeScreen.kt
| | |-- HomeViewModel.kt
| | `-- HomeViewState.kt
| `-- App.kt
`-- res/values/strings.xml
Let's describe individual files one by one:
Application class must be annotated with @HiltAndroidApp to trigger Hilt code generation. Optionally set UseCaseErrorHandler.globalOnErrorLogger for application-wide error logging in use cases.
@HiltAndroidApp
class App : Application() {
override fun onCreate() {
super.onCreate()
UseCaseErrorHandler.globalOnErrorLogger = { error ->
android.util.Log.d("UseCase error", "$error")
}
}
}Activity must be annotated with @AndroidEntryPoint. We use setContent to define the UI using Jetpack Compose. For a single screen, use HomeScreen() directly. For multiple screens, use a NavHost with your navigation graph (see example MainActivity).
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ArkitektTheme {
// Your navigation or single screen
HomeScreen()
}
}
}
}ViewModel annotated with @HiltViewModel. You can choose between extending
BaseCoreViewModel or BaseViewModel (for Coroutines support).
@HiltViewModel
class HomeViewModel @Inject constructor() : BaseCoreViewModel<HomeViewState>() {
override val viewState = HomeViewState()
}State representation of a screen. Should contain a set of State (Compose) or StateFlow fields observed by the UI.
class HomeViewState @Inject constructor() : ViewState {
val user = mutableStateOf(User.EMPTY)
}Composable function representing the UI. It obtains the ViewModel via Hilt injection.
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel()
) {
val user by viewModel.viewState.user
// UI implementation - User has firstName and lastName
Text(
text = "${user.firstName} ${user.lastName}".trim().ifEmpty { "Guest" },
modifier = modifier
)
}Module cr-usecases contains base classes useful for easy execution of
background tasks based on Coroutines. Two base types are available - UseCase (single result use case)
and FlowUseCase (multi result use case).
Following example describes how to make an API call and how to deal with result of this call.
class LoginUseCase @Inject constructor(
private val userStore: UserStore
) : UseCase<LoginData, Unit>() {
override suspend fun build(args: LoginData) {
userStore.setUser(User(args.firstName, args.lastName))
}
}
data class LoginData(val firstName: String, val lastName: String)class LoginViewState @Inject constructor() : ViewState {
// IN - values provided by UI
val name = mutableStateOf("")
val surname = mutableStateOf("")
// OUT - Values observed by UI
val fullName = mutableStateOf("")
val isLoading = mutableStateOf(false)
}class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase, // Inject UseCase
override val viewState: LoginViewState
) : BaseViewModel<LoginViewState>() {
fun logIn() = with(viewState) {
loginUseCase.execute(LoginData(name.value, surname.value)) {
onStart {
isLoading.value = true
}
onSuccess {
isLoading.value = false
fullName.value = "${viewState.name.value} ${viewState.surname.value}"
}
onError {
isLoading.value = false
// handle error
}
}
}
}Module cr-usecases allows you to execute use cases synchronously.
fun onButtonClicked() = launchWithHandler {
// ...
val data = useCase.execute().getOrDefault("Default")
// ...
}execute method returns a Result that can be either successful Success or failed Error.
launchWithHandler launches a new coroutine encapsulated with a try-catch block. By default exception thrown in launchWithHandler is rethrown but it is possible to override this behavior with defaultErrorHandler or just log these exceptions in logUnhandledException.
In order to set an application-wide error logger for all handled errors in all use-cases, it is possible to set the following method in the Application class:
UseCaseErrorHandler.globalOnErrorLogger = { error ->
CustomLogger.logError(error)
}The globalOnErrorLogger callback in the UseCaseErrorHandler will be called for every error thrown in all use-cases that have defined onError receiver in the execute method.
The following execute method will trigger globalOnErrorLogger:
useCase.execute {
...
onError {
isLoading = false
}
...
}The following execute method won't trigger globalOnErrorLogger because onError is not defined and execute method will throw an unhandled exception.
useCase.execute {}There are two main ways how to reflect data model changes in UI. Through ViewState observation
or one-shot Events.
You can observe state changes and reflect these changes in UI by observing State (Compose) or StateFlow
from your viewState in your Composable:
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val myText by viewModel.viewState.myTextState
Text(text = myText)
}Events are one-shot messages sent from ViewModel to a Composable. They
are based on Channel. Events are guaranteed to be delivered only once even when
there is screen rotation in progress. Basic event communication might look like this:
If you are using Jetpack Compose, you can collect these events via EventsEffect:
viewModel.EventsEffect {
onEvent<ShowDetailEvent> { /* handle event */ }
}sealed class HomeEvent : Event<HomeViewState>()
object ShowDetailEvent : HomeEvent()class HomeViewModel @Inject constructor(
override val viewState: HomeViewState
) : BaseCoreViewModel<HomeViewState>() {
fun onDetail() {
sendEvent(ShowDetailEvent)
}
}All our applications respect broadly known repository pattern. The main message this
pattern tells: Define Store (Repository) classes with single entity related business logic
eg. UserStore, OrderStore, DeviceStore etc. Let's see this principle on UserStore class
from sample app:
@Singleton
class UserStore @Inject constructor() {
private val userState = MutableStateFlow(User.EMPTY)
fun setUser(user: User) {
userState.value = user
// ... optionally persist user
}
fun getUser(): StateFlow<User> = userState
}With this approach only one class is responsible for User related data access. Besides
custom classes, Room library Daos or for example Retrofit API interfaces might be
perceived on the same domain level as stores. Thanks to use cases we can easily access,
manipulate and combine this kind of data on background threads.
class ObserveUserFullNameUseCase @Inject constructor(
private val userStore: UserStore
) : FlowUseCase<Unit, String>() {
override fun build(args: Unit): Flow<String> =
userStore.getUser().map { "${it.firstName} ${it.lastName}" }
}We strictly respect this injection hierarchy:
| Application Component | Injects |
|---|---|
Composable |
ViewModel |
ViewModel |
ViewState, UseCase
|
UseCase |
Store |
Store |
Dao, Persistence, ApiService
|
Arkitekt supports two modern navigation approaches:
You can use the standard Jetpack Navigation component with Compose. See the example module for a working implementation.
For KMP projects, decompose module provides integration with the Decompose library using Koin for dependency injection.
BaseComponent is the base class for all Decompose components. It provides coroutine scope tied to component lifecycle, state management via MutableStateFlow, and UiEvent support via Channel.
class HomeComponent(
componentContext: ComponentContext,
private val navigation: HomeNavigation,
) : BaseComponent<HomeState, HomeUiEvent>(componentContext, HomeState()) {
val state: StateFlow<HomeState> = componentState
fun onDetailClicked() {
navigation.toDetail()
}
}
data class HomeState(val title: String = "")
sealed interface HomeUiEvent : UiEvent {
data object ShowToast : HomeUiEvent
}NavigationActions and NavigationActionsProducer define navigation contracts for components:
interface HomeNavigation : NavigationActions {
fun toDetail()
fun toSettings()
}Annotate components with @GenerateFactory to auto-generate Koin-based factory objects.
Requires decompose-annotation and decompose-processor dependencies.
@GenerateFactory
class HomeComponent(
@InjectedParam componentContext: AppComponentContext,
@InjectedParam navigation: HomeNavigation,
private val someUseCase: SomeUseCase, // injected by Koin
) : BaseComponent<HomeState, HomeUiEvent>(componentContext, HomeState())This generates a HomeComponentFactory object with a createComponent(componentContext, navigation) method that resolves remaining dependencies from Koin.
In a Kotlin Multiplatform project, the processor must run in the common metadata compilation phase so generated code is available to all targets. Add the following to your module's build.gradle.kts:
plugins {
id("com.google.devtools.ksp")
}
dependencies {
add("kspCommonMainMetadata", "app.futured.arkitekt:decompose-processor:LatestVersion")
}
// Register generated sources in commonMain source set
kotlin.sourceSets.named("commonMain") {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
// Ensure KSP metadata task runs before compilation
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
if (name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}Important: Use
kspCommonMainMetadataconfiguration only. Do not add the processor to platform-specific configurations (kspAndroid,kspIosArm64, etc.) as this would cause duplicate generation.
ResultFlow enables passing results back between navigation destinations:
val resultFlow = ResultFlow<String>()
// Pass to child, collect results
resultFlow.collect { result -> /* handle */ }
// In child component
resultFlow.sendResult("some result")On Android, collect UiEvents from a component in Compose:
EventsEffect(component.events) {
onEvent<HomeUiEvent.ShowToast> { /* handle */ }
}Flow<T>.collectAsValue(initial, scope) - converts Flow to Decompose Value
Value<T>.asStateFlow() - converts Decompose Value to Kotlin StateFlow
StackNavigator.switchTab(config) - brings configuration to front without recreating if already on stackArkitekt supports SavedStateHandle in ViewModel via Hilt standard mechanism. Simply inject SavedStateHandle into your @HiltViewModel.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : BaseViewModel<HomeViewState>() {
// uses savedStateHandle
}In order to create successful applications, it is highly encouraged to write tests for your application.
See these tests in example module for more detailed sample.
core-test dependency contains utilities to help you with ViewModel testing.
ViewModelTest can be used as a base class for view model tests inside core-test module to help with Coroutines testing.
The spy object should be used for an easy way of testing that expected events were sent to the view.
viewModel = spyk(SampleViewModel(mockViewState, ...), recordPrivateCalls = true)
...
verify { viewModel.sendEvent(ExpectedEvent) }cr-usecase-test dependency contains utilities to help you with mocking use cases in a view model.
Since all 'execute' methods for use cases are implemented as extension functions, we created testing methods that will help you to easily mock them.
So if a method in the view model looks somehow like this:
fun onLoginClicked(name: String, password: String) {
loginUseCase.execute(LoginData(name, password)) {
onSuccess = { ... }
}
}then it can be mocked with the following method:
mockLoginUseCase.mockExecute(args = ...) { user } // For Coroutines Use CasesIn case that use case is using nullable arguments:
mockLoginUseCase.mockExecuteNullable(args = ...) { user } // For Coroutines Use CasesIf you want to test your UI, you can use standard Compose testing APIs (createComposeRule).
@HiltAndroidTest
class HomeScreenTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun testUI() {
composeTestRule.onNodeWithText("Hello").assertIsDisplayed()
}
}Arkitekt is available under the MIT license. See the LICENSE file for more information.
Created with ❤ at Futured. Inspired by Alfonz library.
Arkitekt is a set of architectural tools based on Android Architecture Components, which gives you a solid base to implement the concise, testable and solid application.
dependencies {
// Android / Compose
implementation("app.futured.arkitekt:core:LatestVersion")
implementation("app.futured.arkitekt:cr-usecases:LatestVersion")
implementation("app.futured.arkitekt:compose:LatestVersion")
// Decompose / KMP (optional)
implementation("app.futured.arkitekt:decompose:LatestVersion")
implementation("app.futured.arkitekt:decompose-annotation:LatestVersion")
ksp("app.futured.arkitekt:decompose-processor:LatestVersion")
// Testing
testImplementation("app.futured.arkitekt:core-test:LatestVersion")
testImplementation("app.futured.arkitekt:cr-usecases-test:LatestVersion")
}Add new maven repo to your top level gradle file.
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
Snapshots are grouped based on major version, so for version 6.x use:
implementation "app.futured.arkitekt:core:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:cr-usecases:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:compose:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:decompose:6.X.X-SNAPSHOT"Arkitekt is a modern Android & Kotlin Multiplatform architecture library focused on Jetpack Compose and Kotlin Coroutines. It combines built-in support for ViewModel, Coroutines use cases, Jetpack Compose, and Decompose (KMP). For dependency injection, the Android/Compose path uses Dagger-Hilt, while the Decompose/KMP path uses Koin.
Note: As of version 6.x, Arkitekt has removed legacy LiveData-based components, Fragment/Activity base classes, RxJava support, DataBinding, and the Dagger module. The library is now exclusively focused on Jetpack Compose with State/StateFlow for reactive UI and Kotlin Multiplatform via Decompose.
Version 6.x represents a major refactoring focused on modern Android development with Jetpack Compose. The following legacy components have been removed:
rx-usecases and rx-usecases-test - RxJava support has been completely removed. Use cr-usecases (Coroutines) insteaddagger - Dagger 2 injection module removed. Use Dagger-Hilt (@HiltViewModel, @AndroidEntryPoint) insteadbindingadapters - DataBinding adapters removed. Use Jetpack Compose insteadexample-minimal and example-hilt - Consolidated into single example moduleViewModel Base Classes:
BaseViewModel (from core) - Use BaseCoreViewModel (core) or BaseViewModel (compose) insteadBaseCrViewModel (from cr-usecases) - Use BaseViewModel (compose) insteadFragment/Activity Base Classes:
ViewModelActivity, BindingViewModelActivity - Use standard ComponentActivity with @AndroidEntryPoint
ViewModelFragment, BindingViewModelFragment - Use standard Compose navigationViewModelBottomSheetDialogFragment, BindingViewModelBottomSheetDialogFragment - Use Compose bottom sheetsViewModelDialogFragment, BindingViewModelDialogFragment - Use Compose dialogsDagger Classes:
BaseViewModelFactory, BaseSavedStateViewModelFactory - Use @HiltViewModel with hiltViewModel()
BaseDaggerActivity, BaseDaggerFragment and their Binding variants - Use @AndroidEntryPoint
ViewModelCreator, ViewModelFactory - No longer needed with HiltLiveData Components:
LiveEvent and LiveEventBus - Use Event with Channel-based eventsDefaultValueLiveData and DefaultValueMediatorLiveData - Use StateFlow or Compose State
NonNullLiveData - Use StateFlow or Compose State
UiData, UiDataExtensions, UiDataMediator - Use StateFlow or Compose State
LiveDataExtensions and LiveDataUtils - Use Kotlin Flow operatorsDataBinding:
ViewDataBinding base classes, binding adapters) - Use Jetpack Compose@AndroidEntryPoint
StateFlow (for ViewModels) or State (for Compose)Event system (see Events section)Minimal working project must contain files as presented in example
module. File hierarchy might look like this:
example
`-- src/main
|-- java/app/futured/arkitekt/sample
| |-- ui
| | |-- main
| | | `-- MainActivity.kt
| | `-- home
| | |-- HomeScreen.kt
| | |-- HomeViewModel.kt
| | `-- HomeViewState.kt
| `-- App.kt
`-- res/values/strings.xml
Let's describe individual files one by one:
Application class must be annotated with @HiltAndroidApp to trigger Hilt code generation. Optionally set UseCaseErrorHandler.globalOnErrorLogger for application-wide error logging in use cases.
@HiltAndroidApp
class App : Application() {
override fun onCreate() {
super.onCreate()
UseCaseErrorHandler.globalOnErrorLogger = { error ->
android.util.Log.d("UseCase error", "$error")
}
}
}Activity must be annotated with @AndroidEntryPoint. We use setContent to define the UI using Jetpack Compose. For a single screen, use HomeScreen() directly. For multiple screens, use a NavHost with your navigation graph (see example MainActivity).
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ArkitektTheme {
// Your navigation or single screen
HomeScreen()
}
}
}
}ViewModel annotated with @HiltViewModel. You can choose between extending
BaseCoreViewModel or BaseViewModel (for Coroutines support).
@HiltViewModel
class HomeViewModel @Inject constructor() : BaseCoreViewModel<HomeViewState>() {
override val viewState = HomeViewState()
}State representation of a screen. Should contain a set of State (Compose) or StateFlow fields observed by the UI.
class HomeViewState @Inject constructor() : ViewState {
val user = mutableStateOf(User.EMPTY)
}Composable function representing the UI. It obtains the ViewModel via Hilt injection.
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel()
) {
val user by viewModel.viewState.user
// UI implementation - User has firstName and lastName
Text(
text = "${user.firstName} ${user.lastName}".trim().ifEmpty { "Guest" },
modifier = modifier
)
}Module cr-usecases contains base classes useful for easy execution of
background tasks based on Coroutines. Two base types are available - UseCase (single result use case)
and FlowUseCase (multi result use case).
Following example describes how to make an API call and how to deal with result of this call.
class LoginUseCase @Inject constructor(
private val userStore: UserStore
) : UseCase<LoginData, Unit>() {
override suspend fun build(args: LoginData) {
userStore.setUser(User(args.firstName, args.lastName))
}
}
data class LoginData(val firstName: String, val lastName: String)class LoginViewState @Inject constructor() : ViewState {
// IN - values provided by UI
val name = mutableStateOf("")
val surname = mutableStateOf("")
// OUT - Values observed by UI
val fullName = mutableStateOf("")
val isLoading = mutableStateOf(false)
}class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase, // Inject UseCase
override val viewState: LoginViewState
) : BaseViewModel<LoginViewState>() {
fun logIn() = with(viewState) {
loginUseCase.execute(LoginData(name.value, surname.value)) {
onStart {
isLoading.value = true
}
onSuccess {
isLoading.value = false
fullName.value = "${viewState.name.value} ${viewState.surname.value}"
}
onError {
isLoading.value = false
// handle error
}
}
}
}Module cr-usecases allows you to execute use cases synchronously.
fun onButtonClicked() = launchWithHandler {
// ...
val data = useCase.execute().getOrDefault("Default")
// ...
}execute method returns a Result that can be either successful Success or failed Error.
launchWithHandler launches a new coroutine encapsulated with a try-catch block. By default exception thrown in launchWithHandler is rethrown but it is possible to override this behavior with defaultErrorHandler or just log these exceptions in logUnhandledException.
In order to set an application-wide error logger for all handled errors in all use-cases, it is possible to set the following method in the Application class:
UseCaseErrorHandler.globalOnErrorLogger = { error ->
CustomLogger.logError(error)
}The globalOnErrorLogger callback in the UseCaseErrorHandler will be called for every error thrown in all use-cases that have defined onError receiver in the execute method.
The following execute method will trigger globalOnErrorLogger:
useCase.execute {
...
onError {
isLoading = false
}
...
}The following execute method won't trigger globalOnErrorLogger because onError is not defined and execute method will throw an unhandled exception.
useCase.execute {}There are two main ways how to reflect data model changes in UI. Through ViewState observation
or one-shot Events.
You can observe state changes and reflect these changes in UI by observing State (Compose) or StateFlow
from your viewState in your Composable:
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val myText by viewModel.viewState.myTextState
Text(text = myText)
}Events are one-shot messages sent from ViewModel to a Composable. They
are based on Channel. Events are guaranteed to be delivered only once even when
there is screen rotation in progress. Basic event communication might look like this:
If you are using Jetpack Compose, you can collect these events via EventsEffect:
viewModel.EventsEffect {
onEvent<ShowDetailEvent> { /* handle event */ }
}sealed class HomeEvent : Event<HomeViewState>()
object ShowDetailEvent : HomeEvent()class HomeViewModel @Inject constructor(
override val viewState: HomeViewState
) : BaseCoreViewModel<HomeViewState>() {
fun onDetail() {
sendEvent(ShowDetailEvent)
}
}All our applications respect broadly known repository pattern. The main message this
pattern tells: Define Store (Repository) classes with single entity related business logic
eg. UserStore, OrderStore, DeviceStore etc. Let's see this principle on UserStore class
from sample app:
@Singleton
class UserStore @Inject constructor() {
private val userState = MutableStateFlow(User.EMPTY)
fun setUser(user: User) {
userState.value = user
// ... optionally persist user
}
fun getUser(): StateFlow<User> = userState
}With this approach only one class is responsible for User related data access. Besides
custom classes, Room library Daos or for example Retrofit API interfaces might be
perceived on the same domain level as stores. Thanks to use cases we can easily access,
manipulate and combine this kind of data on background threads.
class ObserveUserFullNameUseCase @Inject constructor(
private val userStore: UserStore
) : FlowUseCase<Unit, String>() {
override fun build(args: Unit): Flow<String> =
userStore.getUser().map { "${it.firstName} ${it.lastName}" }
}We strictly respect this injection hierarchy:
| Application Component | Injects |
|---|---|
Composable |
ViewModel |
ViewModel |
ViewState, UseCase
|
UseCase |
Store |
Store |
Dao, Persistence, ApiService
|
Arkitekt supports two modern navigation approaches:
You can use the standard Jetpack Navigation component with Compose. See the example module for a working implementation.
For KMP projects, decompose module provides integration with the Decompose library using Koin for dependency injection.
BaseComponent is the base class for all Decompose components. It provides coroutine scope tied to component lifecycle, state management via MutableStateFlow, and UiEvent support via Channel.
class HomeComponent(
componentContext: ComponentContext,
private val navigation: HomeNavigation,
) : BaseComponent<HomeState, HomeUiEvent>(componentContext, HomeState()) {
val state: StateFlow<HomeState> = componentState
fun onDetailClicked() {
navigation.toDetail()
}
}
data class HomeState(val title: String = "")
sealed interface HomeUiEvent : UiEvent {
data object ShowToast : HomeUiEvent
}NavigationActions and NavigationActionsProducer define navigation contracts for components:
interface HomeNavigation : NavigationActions {
fun toDetail()
fun toSettings()
}Annotate components with @GenerateFactory to auto-generate Koin-based factory objects.
Requires decompose-annotation and decompose-processor dependencies.
@GenerateFactory
class HomeComponent(
@InjectedParam componentContext: AppComponentContext,
@InjectedParam navigation: HomeNavigation,
private val someUseCase: SomeUseCase, // injected by Koin
) : BaseComponent<HomeState, HomeUiEvent>(componentContext, HomeState())This generates a HomeComponentFactory object with a createComponent(componentContext, navigation) method that resolves remaining dependencies from Koin.
In a Kotlin Multiplatform project, the processor must run in the common metadata compilation phase so generated code is available to all targets. Add the following to your module's build.gradle.kts:
plugins {
id("com.google.devtools.ksp")
}
dependencies {
add("kspCommonMainMetadata", "app.futured.arkitekt:decompose-processor:LatestVersion")
}
// Register generated sources in commonMain source set
kotlin.sourceSets.named("commonMain") {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
// Ensure KSP metadata task runs before compilation
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
if (name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}Important: Use
kspCommonMainMetadataconfiguration only. Do not add the processor to platform-specific configurations (kspAndroid,kspIosArm64, etc.) as this would cause duplicate generation.
ResultFlow enables passing results back between navigation destinations:
val resultFlow = ResultFlow<String>()
// Pass to child, collect results
resultFlow.collect { result -> /* handle */ }
// In child component
resultFlow.sendResult("some result")On Android, collect UiEvents from a component in Compose:
EventsEffect(component.events) {
onEvent<HomeUiEvent.ShowToast> { /* handle */ }
}Flow<T>.collectAsValue(initial, scope) - converts Flow to Decompose Value
Value<T>.asStateFlow() - converts Decompose Value to Kotlin StateFlow
StackNavigator.switchTab(config) - brings configuration to front without recreating if already on stackArkitekt supports SavedStateHandle in ViewModel via Hilt standard mechanism. Simply inject SavedStateHandle into your @HiltViewModel.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : BaseViewModel<HomeViewState>() {
// uses savedStateHandle
}In order to create successful applications, it is highly encouraged to write tests for your application.
See these tests in example module for more detailed sample.
core-test dependency contains utilities to help you with ViewModel testing.
ViewModelTest can be used as a base class for view model tests inside core-test module to help with Coroutines testing.
The spy object should be used for an easy way of testing that expected events were sent to the view.
viewModel = spyk(SampleViewModel(mockViewState, ...), recordPrivateCalls = true)
...
verify { viewModel.sendEvent(ExpectedEvent) }cr-usecase-test dependency contains utilities to help you with mocking use cases in a view model.
Since all 'execute' methods for use cases are implemented as extension functions, we created testing methods that will help you to easily mock them.
So if a method in the view model looks somehow like this:
fun onLoginClicked(name: String, password: String) {
loginUseCase.execute(LoginData(name, password)) {
onSuccess = { ... }
}
}then it can be mocked with the following method:
mockLoginUseCase.mockExecute(args = ...) { user } // For Coroutines Use CasesIn case that use case is using nullable arguments:
mockLoginUseCase.mockExecuteNullable(args = ...) { user } // For Coroutines Use CasesIf you want to test your UI, you can use standard Compose testing APIs (createComposeRule).
@HiltAndroidTest
class HomeScreenTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun testUI() {
composeTestRule.onNodeWithText("Hello").assertIsDisplayed()
}
}Arkitekt is available under the MIT license. See the LICENSE file for more information.
Created with ❤ at Futured. Inspired by Alfonz library.