spotify/mobius

Modelling dilemmas

kaciula opened this issue · 7 comments

While using Mobius in more complex features I come upon scenarios where I am not sure how to optimally define my model. For instance:

  1. I keep a boolean flag in my model in order to know what side effect to trigger (Effect1 vs Effect2) and this boolean flag is updated based on a particular event. However, whenever I get the event I need to update the flag (and thus the model) and this triggers a re-rendering of the view, although it's not needed. Can I avoid this?

  2. I need to update only parts of the view that really changed because some views are costly to re-render (markers on a map). What's the best approach here? If I create a boolean flag indicating what has changed, I will have then to update the flag back to false everywhere I am modifying the model as a result of events coming. This doesn't seem right especially if I have several boolean flags for various things. Alternatively, I thought of triggering a side effect which clears the flag immediately after setting it to true. Thoughts?

To sum up, I need some best practices on how to selectively render only parts of the view, how to deal with one-time boolean flags and how to update some model fields without re-rendering of the view.

@kaciula this is actually something we've been talking about as well. I think this isn't really related to how you model things but rather how you render new models.

For both questions, I believe you have two alternatives that can help:

  1. Create a new type ViewData, example, that represents the state of your view and that can be derived from your Model and function that maps model instances to that new type, here's an example. The idea would be to map each new model to a view data object, then render that view data object. Because your boolean flag and other parts of the model aren't really necessarily for rendering, they won't exist on the view data type. You can then apply a distinctUntilChanged() on the Observable<ViewData> that you get, and that will guarantee that you only ever render ViewData instances that have changed. So when you change things on your model that don't have to do with UI, no re-rendering will occur. It is important to note that this step will make your view a Connectable<ViewData, Event> or ObservableTransformer<ViewData, Event> instead of Connectable<Model, Event>. So to connect that to the Mobius controller appropriately, you can use the contramap function. Here's an example of that. The way contravariant map works is basically, given Connectable<B, C> and a function A -> B, it'll create a Connectable<A, C>.

  2. Break down the model instance and determine if subsets of that instance have changed from previous values, then render them only if they have. We're in the process of building a tiny library that makes it easier to do that declaratively. However, here's how you could do something similar using RxJava in the meantime.

data class Header(val imageUri: String, val title: String)
data class Model(val header: Header, val body: List<String>)

// assuming your view is an ObservableTransformer<Model, Event>
// You can use RxConnectables to create a normal Connectable from an ObservableTransformer
// to be used with your MobiusController
fun connectViews(model: Observable<Model>): Observable<Event> {

    val subs = CompositeDisposable()
    subs.addAll(
            model.map(Model::header).distinctUntilChanged().subscribe { header ->
                headerImage.setImageUri(header.imageUri)
                headerTextView.setText(header.title)
            },
            model.map(Model::body).distinctUntilChanged().subscribe { items ->
                itemsAdapter.setData(items)
            }
    )

    return RxView.clicks(headerImage)
            .map { click -> Event.headerImagePressed() }
            .doOnDispose { subs.dispose() }
}

If you're not using RxJava for Views you can do the same thing by caching the old value and comparing it to the new values. This is really what this solution is doing.

  1. Combine both options. For more complex UIs, it's probably better to do the first step to make sure the UI gets a type that makes sense for rendering. This is presentation logic implemented as a pure function. I would then apply the partial rendering solution from option 2 to make sure I'm only rendering things that have truly changed. If I have a list of items, I'd use DiffUtils in my adapter to ensure I don't have to re-render the full list.

Hope this helps. Let us know if you have any follow up questions!

@anawara Thanks for the details. It makes a lot of sense.

One option that I was thinking of is for the library to have a method Next.noRender(newModel) so when the model changes without impacting the view, you would call this. It's a bit similar to Next.noChange() but it changes the model without the model reaching the view for rendering. This approach would work well when the model contains mostly things impacting the view and you want to avoid duplicating most of the fields in an extra ViewData. Not sure if this can be achieved or if there are philosophical problems with adding this to the library.

I'll implement the suggestions above and see if any issues come up. Thank you for the prompt reply.

Regarding 1, the view is a Connectable<ViewData, Event> so I cannot apply distinctUntilChanged() . Is there an equivalent approach for this? Or do I actually have to make my view an ObservableTransformer ? (I cannot find the ObservableTransformer approach in the samples)

@kaciula I don't think a mechanism like Next.noRender() is desirable, the Consumer in this case is what is worried about receiving "duplicate" updates so it should be responsible for handling such cases. Trying to push that concern into the update function would complicate the design of your Loop as a whole.

Your Connectable can return a Connection that implements something like this:

abstract class DistinctConnection<M> : Connection<M> {
  private var latestModel: M? = null

  override fun accept(model: M) {
    if (latestModel != model) {
      latestModel = model
      acceptDistinct(model)
    }
  }

  abstract fun acceptDistinct(model: M)
}

I agree with @DrewCarlson, this is a presentation layer concern. Not all state changes necessarily should lead to rendering, and that is up for the presentation layer to determine whether or not it should re-render the UI due to the state change.

If you want, you can probably create a distinct until changed connection class that wraps the connection you return like Drew just mentioned or perhaps using interface delegation and composition like so:

class DistinctUntilChangedConnection<I>(val connection: Connection<I>) : Connection<I> by connection {
   private var latestInput: I? = null

   override fun accept(input: I) {
      if (latestInput != input) {
         latestInput = input
         connection.accept(input)
      }
   }
}

This will make sure the entire input that your connection accepts is distinct until it changes. That means that if anything changes on your ViewData, the entire UI will be re-rendered, if you aren't doing the splitting. The library I mentioned in the works will actually allow you to describe this kind of thing declaratively and allow you to define partial bindings that only run when that part of the model changes. So it's kind of a way to compose the above mechanism so you can use it in the connection you return. I'll let you know as soon as we have something ready! I think you'll like it a lot :)

Another thing you can do is keep your view as a Connectable<ViewData, Event>, and then use an PublishRelay in there to relay the value like so

data class Header(val imageUri: String, val title: String)
data class Model(val header: Header, val body: List<String>)

// assuming your view is an ObservableTransformer<Model, Event>
// You can use RxConnectables to create a normal Connectable from an ObservableTransformer
// to be used with your MobiusController
private val relay = PublishRelay() //class member
fun connect(consumer: Consumer<Event>): Connection<ViewData> {
    val subs = CompositeDisposable()
    subs.addAll(
            relay.map(Model::header).distinctUntilChanged().subscribe { header ->
                headerImage.setImageUri(header.imageUri)
                headerTextView.setText(header.title)
            },
            relay.map(Model::body).distinctUntilChanged().subscribe { items ->
                itemsAdapter.setData(items)
            }
    )

    return object: Connection<ViewData>() {
         override fun accept(input: ViewData) = relay.onNext(input)
         override fun dispose() = subs.dispose() // don't forget to remove your listeners too
    }
}

For complex models, I prefer the ViewData pattern because it eliminates any presentation logic from my view. Furthermore, creating a simpler type that's easier to bind to the UI would reduce the complexity of the reasoning you'd have to do in the view. Questions like "which attribute of my model should I bind to my UI?" should be super simple to answer. Since your model will usually be used for representing the state of your feature, not your UI, it might have additional information that isn't required for rendering. I personally like to not have access to such information when it isn't needed, because it helps reduce the things I need to understand and look at in the context of UI rendering.

Hope this helps!

@DrewCarlson @anawara I completely agree with not adding Next.noRender() function. Indeed it is the presentation layer's concern. And I like that Mobius encourages you to separate concerns and keep the code clean.

@anawara I will be using the DistinctUntilChangedConnection for now and I really like the relay approach of splitting the ViewData. I will probably use this also until you release the library you mentioned. Thanks for everything.

@kaciula

However, whenever I get the event I need to update the flag (and thus the model) and this triggers a re-rendering of the view, although it's not needed. Can I avoid this?

We just open sourced our small library, which does exactly this - re-rendering parts of views, only which were updated
https://github.com/FactoryMarketRetailGmbH/Memo

We using it in the same way in our Unidirectional Dataflow library, and it works very well! Feedback is very welcome!