Search code examples
androidkotlinarrow-kt

How implement Arrow Kt with Android ViewModel?


In Android network operations are usually done within ViewModel. This ensures that even when the Activity or Fragment is recreate (for example when device is rotated), the network call keeps going and not cancelled.

Now to submit the result of the network request from the ViewModel to the view (Activity/Fragment). You have a reactive component such as a LiveData or Observable to set the value on it. Like:

val resultLiveData = MutableLiveData<Result>()

fun doNetworkRequest() {
    repository.requestSomeResult()  // Assume this returns Arrow's IO
        .unsafeRunAsync { eitherResult ->
            eitherResult.fold({ error ->
                // Handle Error
            }, { result ->
                resultLiveData.value = result
            })
        }
}

I was wondering whether there is a way to make val resultLiveData = MutableLiveData<Result>() to not be tied to a specific implementation such as LiveData something like returning a higher kind, Kind<F, Result> instead.

Is there a way I could do:

val result = Kind<F, Result>()

fun doNetworkRequest() {
    repository.requestSomeResult()  // Assume this returns Arrow's IO
        .unsafeRunAsync { eitherResult ->
            eitherResult.fold({ error ->
                // Handle Error
            }, { result ->
                resultLiveData.sendValue(result) // Or however it should be done
            })
        }
}

So I could define Kind<F, Result> later with an implementation I want?


Solution

  • Thanks for this question! This is something I have been working on lately. A few things to mention regarding this:

    In the following days we will publish a KotlinX integration module for Arrow. I will allow you to scope your IO tasks within a CoroutineScope. I.e:

    yourIOTask().unsafeRunScoped(scope) { cb -> }
    

    This will ensure your IO tasks are cancelled if the provided scope is cancelled. That means you could scope your "view model" operations like doNetworkRequest in this example using the view model scope, and you'll ensure those would survive a configuration change and are cancelled when the view model would be released.

    Said that, and if we observe how Android ViewModels work at the moment, you'd still need a "middle layer cache" to deliver results to, as you mention, to ensure those results are always delivered and as soon as the view starts observing i gets the most fresh possible data. By having this mechanism along with the scope mentioned in the previous paragraph, you can ensure that your long running tasks will always deliver results, no matter if they're completed before, during or after a configuration change.

    In this sense, and if you wanted to keep using Android ViewModel under the hood, you could encode something using arrow, like:

    interface ViewStateCache<ViewState> {
    
      val cacheScope: CoroutineScope
    
      fun observeViewState(observer: LifecycleOwner, renderingScope: CoroutineScope, render: (ViewState) -> IO<Unit>): IO<Unit>
    
      fun updateViewState(transform: (ViewState) -> ViewState): IO<ViewState>
    }
    

    We could use this contract to ensure ViewModels are used in a pure way. All ViewModels could implement this contract, like:

    class ViewModelViewStateCache<ViewState>(initialState: ViewState) : ViewModel(), ViewStateCache<ViewState> {
    
      override val cacheScope: CoroutineScope = viewModelScope
    
      private val _viewState = MutableLiveData<ViewState>(initialState)
      private val viewState: LiveData<ViewState> = _viewState
    
      override fun updateViewState(transform: (ViewState) -> ViewState) =
        IO {
          val transformedState = transform(viewState.value!!)
          _viewState.postValue(transformedState)
          transformedState
        }
    
      override fun observeViewState(observer: LifecycleOwner, renderingScope: CoroutineScope, render: (ViewState) -> IO<Unit>) =
        IO {
          viewState.observe(observer, Observer<ViewState> { viewState ->
            viewState?.let { render(it).unsafeRunScoped(renderingScope) {} }
          })
        }
    }
    

    With this, you effectively have a view state cache that is implemented using the Android ViewModel. It's an implementation detail, so you can inject it. Your program will work targeting the interface.

    Here, the ViewModel only works as a cache to deliver results to, and it's made pure by wrapping its operations to observe and update the view state into IO.

    With something like this, you could have pure functions that encode your presentation and thread coordination logics, and deliver results to the mentioned cache, like:

    fun doNetworkRequest(): IO<Unit> = IO.fx {
          !viewStateCache.updateViewState { Loading }
          !repository.requestSomeResult().redeemWith(
            ft = {
              viewStateCache.updateViewState { ErrorViewState(ServerError) }
            },
            fe = { error ->
              viewStateCache.updateViewState { ErrorViewState(error) }
            },
            fb = { data ->
              viewStateCache.updateViewState { SuccessfulViewState(data) }
            }
          )
        }
    

    Those functions would not need to live within a view model, but would use the cache as a delegate to deliver results to instead, so it can be injected as an implementation detail.

    You would also need to start observing the view state cache as soon as the view gets created, so that'd be similar to what you've been already doing with your view models. Note that we've intentionally exposed the scope as part of the cache contract so you can have access to it from the outside.

    This is an example of how you could wrap the current ViewModel apis to keep using them and ensuring their configuration changes support, mostly thought for gradual migrations to Arrow.

    This approach is more like a convenience approach and could require a bit of polishing, but should work. We are currently in the process of exploring Android usual problems like configuration changes to provide a seamless experience for those through extensions or similar into an integration library.

    Hope this was useful enough, if not, let me know 🙏