arkitekt

Project Url: futuredapp/arkitekt
More: Author   ReportBugs   
Tags:

Download Build Status

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.

Installation

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")
}

Snapshot installation

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"

Features

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.

MVVM architecture

Migration Guide (5.x → 6.x)

Version 6.x represents a major refactoring focused on modern Android development with Jetpack Compose. The following legacy components have been removed:

Removed Modules

  • rx-usecases and rx-usecases-test - RxJava support has been completely removed. Use cr-usecases (Coroutines) instead
  • dagger - Dagger 2 injection module removed. Use Dagger-Hilt (@HiltViewModel, @AndroidEntryPoint) instead
  • bindingadapters - DataBinding adapters removed. Use Jetpack Compose instead
  • example-minimal and example-hilt - Consolidated into single example module

Removed Classes

ViewModel Base Classes:

  • BaseViewModel (from core) - Use BaseCoreViewModel (core) or BaseViewModel (compose) instead
  • BaseCrViewModel (from cr-usecases) - Use BaseViewModel (compose) instead

Fragment/Activity Base Classes:

  • ViewModelActivity, BindingViewModelActivity - Use standard ComponentActivity with @AndroidEntryPoint
  • ViewModelFragment, BindingViewModelFragment - Use standard Compose navigation
  • ViewModelBottomSheetDialogFragment, BindingViewModelBottomSheetDialogFragment - Use Compose bottom sheets
  • ViewModelDialogFragment, BindingViewModelDialogFragment - Use Compose dialogs

Dagger Classes:

  • BaseViewModelFactory, BaseSavedStateViewModelFactory - Use @HiltViewModel with hiltViewModel()
  • BaseDaggerActivity, BaseDaggerFragment and their Binding variants - Use @AndroidEntryPoint
  • ViewModelCreator, ViewModelFactory - No longer needed with Hilt

LiveData Components:

  • LiveEvent and LiveEventBus - Use Event with Channel-based events
  • DefaultValueLiveData 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 operators

DataBinding:

  • All DataBinding support (ViewDataBinding base classes, binding adapters) - Use Jetpack Compose

Migration Path

  1. Replace Fragment/Activity base classes with standard Android components annotated with @AndroidEntryPoint
  2. Replace LiveData with StateFlow (for ViewModels) or State (for Compose)
  3. Replace LiveEvent with Channel-based Event system (see Events section)
  4. Migrate to Jetpack Compose for UI layer

Usage

Table of contents

  1. Getting started - Minimal project file hierarchy
  2. Use Cases
  3. Propagating data model changes into UI
  4. Stores (Repositories)

Getting Started - Minimal project file hierarchy

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:

App.kt

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")
        }
    }
}
MainActivity.kt

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()
            }
        }
    }
}
HomeViewModel.kt

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()
}
HomeViewState.kt

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)
}
HomeScreen.kt

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
    )
}

Use Cases

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.

LoginUseCase.kt
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)
LoginViewState.kt
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)
}
LoginViewModel.kt
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
            }
        }
    }
}

Synchronous execution of cr-usecase

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.

Global error logger for handled errors in use-cases

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 {}

Propagating data model changes into UI

There are two main ways how to reflect data model changes in UI. Through ViewState observation or one-shot Events.

ViewState observation

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

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 */ }
}
HomeEvents.kt
sealed class HomeEvent : Event<HomeViewState>()

object ShowDetailEvent : HomeEvent()
HomeViewModel.kt
class HomeViewModel @Inject constructor(
    override val viewState: HomeViewState
) : BaseCoreViewModel<HomeViewState>() {

    fun onDetail() {
        sendEvent(ShowDetailEvent)
    }
}

Stores (Repositories)

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:

UserStore.kt
@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

Navigation

Arkitekt supports two modern navigation approaches:

Native Android Navigation (Jetpack Compose)

You can use the standard Jetpack Navigation component with Compose. See the example module for a working implementation.

Decompose (Kotlin Multiplatform)

For KMP projects, decompose module provides integration with the Decompose library using Koin for dependency injection.

BaseComponent

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

NavigationActions and NavigationActionsProducer define navigation contracts for components:

interface HomeNavigation : NavigationActions {
    fun toDetail()
    fun toSettings()
}

@GenerateFactory (KSP)

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.

KSP Configuration for KMP

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 kspCommonMainMetadata configuration only. Do not add the processor to platform-specific configurations (kspAndroid, kspIosArm64, etc.) as this would cause duplicate generation.

ResultFlow

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")

EventsEffect (Decompose)

On Android, collect UiEvents from a component in Compose:

EventsEffect(component.events) {
    onEvent<HomeUiEvent.ShowToast> { /* handle */ }
}

Utility Extensions

  • 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 stack

SavedStateHandle

Arkitekt 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
}

Testing

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.

ViewModel testing

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.

Events 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) }

Mocking of Use Cases

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 Cases

In case that use case is using nullable arguments:

mockLoginUseCase.mockExecuteNullable(args = ...) { user } // For Coroutines Use Cases

Compose tests

If 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()
    }
}

License

Arkitekt is available under the MIT license. See the LICENSE file for more information.

Created with ❤ at Futured. Inspired by Alfonz library.

Apps
About Me
GitHub: Trinea
Facebook: Dev Tools