RxMvi is a simple Redux/MVI-like Android library that helps to manage state using RxJava 3.
Add the Maven repository to your project root build.gradle
allprojects {
repositories {
maven { url 'https://jitpack.io' }
}
}
Add the dependency to module build.gradle
implementation 'com.github.merklol:RxMvi:<version>'
Note: All examples are using Hilt for dependency injection, and the 'by viewModels()' Kotlin property delegate from Android KTX.
A basic example how to use RxMvi in your project:
First of all, let's add a state class.
data class MainState(val text: String = "")
Then, let's add a sealed class to define the actions of our app.
Note: All actions should implement the Action interface.
sealed class MainAction: Action {
class ValidateText(val payload: String) : MainAction()
}
Now, let's add our reducer by implementing the Reducer<State> interface.
class MainReducer: Reducer<MainState> {
override fun reduce(state: MainState, action: Action): MainState {
return when(action) {
is MainAction.ValidateText -> state.copy(
text = action.payload
)
else -> state
}
}
}
Then, we add a ViewModel.
Note: All ViewModels should extend RxMviViewModel<State> to get RxMvi functionality.
class MainViewModel @ViewModelInject constructor(
private val store: Store<MainState>): RxMviViewModel<MainState>(store) {
override val disposables = CompositeDisposable()
fun validateText(uiEvent: Observable<CharSequence>) {
disposables += store.dispatch(uiEvent) { MainAction.ValidateText(it.toString()) }
}
}
Let's add our view(Activity/Fragment/etc).
Note: All Views should extend RxMviView<State, ViewModel: RxMviViewModel> to get RxMvi functionality.
@AndroidEntryPoint
class MainActivity: RxMviView<MainState, MainViewModel>() {
override val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewModel.validateText(editTextView.textChanges())
}
override fun render(state: MainState) {
if(state.text.length > 4) {
editTextView.error = "The text is too long"
}
}
}
Finally, let's provide a store to the app using Hilt.
@Module
@InstallIn(ApplicationComponent::class)
class MainModule {
@Provides
fun provideStore(): Store<MainState> {
return createStore(MainReducer(), MainState(), middlewares(RxMviLogger()))
}
}
A completed demo app here.
Using Middleware
Let's add a state class again.
data class PostsState(
val loading: Boolean = false,
val loaded: Boolean = false,
val error: Throwable? = null,
val posts: List<Post> = listOf()
)
Then, define the actions of the app.
sealed class Actions: Action {
object Load: Actions()
}
Now, we need to define side effects.
sealed class Effects: Effect {
object Loading: Effects()
class Loaded(val payload: List<Post>): Effects()
class Failed(val error: Throwable): Effects()
}
After that, it's time to add a middleware.
Middleware provides a way to interact with actions that have been dispatched to the store before they reach the store's reducer.
class LoadingPosts(
private val typicodeAPI: TypicodeAPI,
private val mapper: Mapper<PostEntity, Post>): Middleware<PostsState> {
override fun bind(state: Observable<PostsState>, actions: Observable<Action>): Observable<Action> {
return actions.ofType(Actions.Load::class.java)
.withLatestFrom(state) { action, currentState -> action to currentState }
.flatMap {
typicodeAPI.posts()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map<Effects> { result ->
Effects.Loaded(result.map { mapper.mapFromEntity(it) })
}
.onErrorReturn { Effects.Failed(it) }
.startWith(Observable.just(Effects.Loading))
}
}
}
Next, let's add a reducer again.
class PostsReducer: Reducer<PostsState> {
override fun reduce(state: PostsState, action: Action): PostsState {
return when(action) {
is Effects.Loaded -> state.copy(
loaded = true,
loading = false,
posts = action.payload
)
is Effects.Failed -> state.copy(
loaded = true,
loading = false,
error = action.error
)
is Effects.Loading -> state.copy(
loading = true,
)
else -> state
}
}
}
Then, let's add a ViewModel.
class PostsViewModel @ViewModelInject constructor(
private val store: Store<PostsState>): RxMviViewModel<PostsState>(store) {
override val disposables = CompositeDisposable()
fun loadPosts() {
val (_, loaded) = store.state.value
if(!loaded) {
disposables += store.dispatch { Actions.Load }
}
}
}
Then, add a view.
@AndroidEntryPoint
class PostsActivity : RxMviView<PostsState, PostsViewModel>() {
override val viewModel: PostsViewModel by viewModels()
private val adapter = RVAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_posts)
setRecyclerView()
viewModel.loadPosts()
}
override fun render(state: PostsState) {
when {
state.loading -> progressView.visibility = View.VISIBLE
state.loaded -> {
if(state.error != null) {
errorView.text = state.error.message
errorView.visibility = View.VISIBLE
}
progressView.visibility = View.GONE
adapter.addPosts(state.posts)
}
}
}
private fun setRecyclerView() {
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
recyclerView.addItemDecoration(RVMarginDecoration(this, 16, 16))
}
}
Lastly, let's provide a store to the app.
@Module
@InstallIn(ApplicationComponent::class)
class PostsModule {
/*...*/
@Provides
fun providesStore(typicodeAPI: TypicodeAPI): Store<PostsState> {
return createStore(
PostsReducer(), PostsState(), middlewares(LoadingPosts(typicodeAPI, PostMapper()))
)
}
}
A completed demo app here.
You can enable logging by passing an instance of RxMviLogger to the store at the initialization stage.
createStore(Reducer(), State(), middlewares(RxMviLogger()))
I/rxMvi-logger: ️action type = Calculating; current state = { CounterState(isCalculating=true, isHintDisplayed=false, result=0) }
I/rxMvi-logger: ️action type = Increment; current state = { CounterState(isCalculating=true, isHintDisplayed=false, result=0) }
Here is are a few examples of how to test the business logic in your app.
1. Unit tests
Let's first mock a middleware
@Before
private fun mockMiddleware() {
val action = MainEffect.IncrementSuccess(1)
every { inc.bind(any(), any()) } answers { Observable.just(action) }
}
Now let's test state changes out using the TestObserver from RxJava's testing APIs
@Test
fun `when increment counter triggered, should hide hint and change result to 1`() {
viewModel.incrementCounter(Observable.just(Unit))
val state = testObserver.values()[0] as CounterState
assertThat(state.isHintDisplayed, `is`(false))
assertThat(state.result, `is`(1))
}
To test the middleware, let's subscribe to the actions and check whether we receive the right effects
@Before
fun setup() {
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
val inc = IncrementMiddleware()
inc.bind(state, action).subscribe(testObserver)
}
@Test
fun `after delay, should return IncrementSuccess`() {
testObserver.awaitDone(4, TimeUnit.SECONDS)
val effect = testObserver.values()[1] as MainEffect.IncrementSuccess
assertThat(effect.payload, `is`(1))
}
2. UI Tests
For UI tests, we are going to use the Espresso Framework. First, let's add a test rule that is going to be responsible for taking screenshots during the tests.
class ScreenshotTestRule : TestWatcher() {
@Throws(IOException::class)
override fun finished(description: Description?) {
super.finished(description)
val className = description?.testClass?.simpleName ?: "NullClassname"
val methodName = description?.methodName ?: "NullMethodName"
val filename = "$className - $methodName"
val capture = Screenshot.capture()
capture.name = filename
capture.format = Bitmap.CompressFormat.PNG
val processors = HashSet<androidx.test.runner.screenshot.ScreenCaptureProcessor>()
processors.add(ScreenCaptureProcessor())
capture.process(processors)
}
}
Next, we add and set up a UI test
@RunWith(AndroidJUnit4::class)
class CounterActivityTest {
@get:Rule
var activityRule: ActivityScenarioRule<CounterActivity>
= ActivityScenarioRule(CounterActivity::class.java)
@get:Rule
val ruleChain: RuleChain = RuleChain
.outerRule(activityRule).around(ScreenshotTestRule())
/*...*/
}
Now, we can add test cases using the Espresso Framework
@Test
fun when_dec_button_clicked_should_display_minus1() {
onView(withId(R.id.decBtnView)).perform(click())
onView(withId(R.id.counterView)).check(matches(withText("-1")))
}
@Test
fun when_inc_button_clicked_should_display_progressBar() {
onView(withId(R.id.incBtnView)).perform(click())
onView(withId(R.id.progressView)).check(matches(isDisplayed()))
}
@Test
fun when_showHint_button_clicked_should_hide_hintView() {
onView(withId(R.id.showHintBtnView)).perform(click())
onView(withId(R.id.hintView)).check(matches(not(isDisplayed())))
}
You can find all demo apps over here.
If you like this project, or are using it in your app, consider starring the repository to show your support. Contributions from the community are very welcome.