Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use ViewModels #5070

Closed
westnordost opened this issue Jun 13, 2023 · 16 comments
Closed

Use ViewModels #5070

westnordost opened this issue Jun 13, 2023 · 16 comments
Assignees
Labels
code cleanup iOS necessary for iOS port

Comments

@westnordost
Copy link
Member

So far, the data in the view layer has been kept in the Fragments and Activitys. Since these can be destroyed and recreated when the Android system decides to do this, the serialization and deserialization into a savedInstanceState must be done manually. Moving to ViewModels has the following advantages:

  1. It's less error prone, possibly also less code. The data is not bound to the view lifecycle and hence does not have to be manually re-created

  2. It provides a better separation between view logic and view data. Thus, the latter is likely mostly platform independent and could be re-used on other platforms with less effort. (View model patterns exists for iOS too, there is at least one kotlin multiplatform library in development that one might switch to in the future).

  3. It will likely simplify some of the "talks to parent via listener" pattern, because some, or most of it(?) can probably be done with shared view models instead (i.e. a view model that is shared between a fragment and its parent fragment / activity).

  4. It's what the cool kids use! Joking. It is actually pretty standard to use since many years and the more standard things are used in StreetComplete, the easier it is for new contributors to join

@Doomsdayrs
Copy link
Contributor

While it is nearing the end of the summer for me.

I have experience with implementing view models into android apps.

Would you care for some help in regards to this?

@westnordost
Copy link
Member Author

westnordost commented Aug 9, 2023

Absolutely!

However, I am currently researching into Jetpack Compose / Compose Multiplatform and I didn't find out yet whether this'd use a different solution altogether. Do you know anything about this? Have you used Jetpack Compose before?

@pikzen
Copy link

pikzen commented Aug 9, 2023

@westnordost Jetpack Compose can absolutely be used with ViewModels! In the case of a pure Compose architecture, various navigation solutions allow you to have proper scoping.

However, a complete Compose rewrite of StreetComplete feels like a pipe dream, a branch that'll just live on the side and hopefully, when it's complete, it releases and more bugs have to be resolved. Not to mention some fun things, like map integrations. Having access to a MapView in a Compose-friendly manner requires _lots_of work (see https://github.com/googlemaps/android-maps-compose, where you need quite a bit of Compse-specific knowledge to make an implementation of it. It could be accessed with an AndroidView, but even then, that's quite a bit of work.

A happy middle ground that can slowly be released and tested out would be keeping Fragments, moving all the state into ViewModels, and using Compose for the UI (returning a ComposeView in onCreateView).

Funnily enough, I saw this issue as I checked the repo wondering if some help with Compose or code cleanups would be needed, perfect timing :D

As for Compose Multiplatform, I don't believe it'd be of much use for now. Ignoring the fact that I don't believe there is StreetComplete for iOS, the implementation is a bit dodgy at the moment. Also, many composables would be missing for other platforms like desktop, etc.

EDIT: if you'd be interested, I could do a quick rewrite of a fragment using both a ViewModel and Compose, to see the difference.

@westnordost
Copy link
Member Author

westnordost commented Aug 9, 2023

That's good to hear! I have neither worked with view models in depth yet nor with compose and wasn't sure whether compose would already take care of reinstantiating the state after the view is recreated in some way or another (mutableStateOf/ remember).

I am only contemplating about a compose rewrite in context of compose multiplatform for a possible iOS version (see my last comment). In what way do you think the implementation is dodgy? In the sense that it is still alpha, or something else? (I thought the API would be mostly 1:1 the same as Jetpack compose, as it is mostly just an extension to it.)

I agree that using compose just because it might be more modern or convenient is absolutely not worth it, as the undertaking would be huge while there is pretty much no gain, the current UI implementation already works, after all. As with any huge undertaking, there is also the high risk of it never seeing the light of day anyway.
This is why I also envisioned that if a move to compose for a possible iOS version is done, it should be done step-by-step. As far as I understand, it is possible to put normal android views into a composable and the other way round. So, the UI can be replaced bit by bit. Also, the tangram-es map view (or then maybe the MapLibre map) would and should just remain a normal view, I don't think it would be worth it to somehow make it a composable. (But to be honest, I did not read deep enough into the topic to have a well founded opinion on that.)

Anyway, yes, I'd be interested to see how a fragment would look like with a view model and compose! If not for anything else, for learning purposes.

However, back to the topic at hand, it looks like view models could be implemented now!

First, to replace the awkward saveInstanceState stuff, but then moving on, it can be used to move some more logic out of the view. Then maybe even more. (I think it is best to do this step-by-step and merge each step to master, so that this results in reasonably small chunks to work on)

@pikzen
Copy link

pikzen commented Sep 23, 2023

As expected, the moment I get to work again on an open source project, work comes back with a vengeance and I end up disappearing. Sorry :D

Yes, I meant dodgy as in, the implementation, especially of default material components isn't quite there yet (but getting better!). In addition, KMP can only do interop with Objective-C API, which are notably much less pleasant than the Swift ones to work with. It's serviceable, but not the best experience. (and of course, you still need a mac anyways to work on it and test it, but that's to be expected out of the iOS ecosystem)

As far as I understand, it is possible to put normal android views into a composable and the other way round.

Yep! If you're looking to instantiate an android view in a Composable, @composable AndroidView() exists, and you can render a composable into a normal view with ComposeView. To give you an idea, I rewrote the Note creation view in Compose, making the fragment simply return a ComposeView, in a matter of few hours, along with removing the use of some 9 patches. So, it's relatively easy to get something that works everywhere while making things a little bit easier for yourself.

Also, the tangram-es map view (or then maybe the MapLibre map) would and should just remain a normal view, I don't think it would be worth it to somehow make it a composable. (But to be honest, I did not read deep enough into the topic to have a well founded opinion on that.)

Yep! I haven't looked too deep into the code yet, but I believe the map is just a fragment, on which other fragments get overlaid ? In which case, it can absolutely stay this way. Actually, unless you have a very, very good reason to go full compose, keeping the interop with Fragments/UIViewControllers is a very good option.

Anyway, yes, I'd be interested to see how a fragment would look like with a view model and compose! If not for anything else, for learning purposes.

I'll try to clean up what I have and make a pull request (either here, or on another repo) in a week or so!

@westnordost
Copy link
Member Author

No worries.

I haven't looked too deep into the code yet, but I believe the map is just a fragment, on which other fragments get overlaid ?

It's a view, in tangram-es at least.

Actually, unless you have a very, very good reason to go full compose

Well I figure a very good reason would be to make the UI fully multiplatform(?). Anything that is a fragment would still need to be reimplemented (and maintained) on the iOS side with UIViewControllers.

I'll try to clean up what I have and make a pull request (either here, or on another repo) in a week or so!

Cool, great! However, for now, this wouldn't be merged and would be for educational purposes. A PR that just uses a viewmodel on the other hand could be merged.

@westnordost
Copy link
Member Author

Cool, great! However, for now, this wouldn't be merged and would be for educational purposes. A PR that just uses a viewmodel on the other hand could be merged.

Actually, it could very likely be merged even when using view models + jetpack compose. More about that later, in any case I would be very interested in reading the code of your port (of a fragment) to viewmodel (+jetpack compose), as I am new to both.

@westnordost westnordost added the iOS necessary for iOS port label Jan 17, 2024
@westnordost westnordost moved this to Todo in iOS Port Jan 17, 2024
@westnordost
Copy link
Member Author

(By the way, a decision has been made to convert the UI to Compose and use ViewModels)

Anyway, regarding this ticket, consider this a master ticket: PRs can be made to add ViewModels to single Fragments to advance this ticket, it is neither necessary nor wise to do this all in one big PR, covering the whole app at once.

@westnordost westnordost moved this from Todo to In Progress in iOS Port Feb 11, 2024
@westnordost westnordost self-assigned this Feb 11, 2024
@westnordost
Copy link
Member Author

westnordost commented Feb 11, 2024

I started experimenting with it. There are a few choices one has to make in regards to how the ViewModels should be structured, which patterns to use. One of these is whether view models should generally look like this:

class MyViewModel : ViewModel() {

    private val _a = MutableStateFlow<String?>(null)
    val a: StateFlow<String?> get() = _a

    private val _b = MutableStateFlow<String?>(null)
    val b: StateFlow<String?> get() = _b

    // and then updates e.g.
    fun updateA() {
      _a.value = "foo"
    }
}

or like this

data class MyUiState(
    val a: String? = null,
    val b: String? = null
)

class MyViewModel : ViewModel() {

    private val _state = MutableStateFlow<MyUiState>(MyUiState())
    val state: StateFlow<MyUiState> get() = _state 

    // and then updates e.g.
    fun updateA() {
      _state.update { it.copy(a = "foo") }
    }
}

The difference is that in the first, properties are updated and observed for changes each separately.

The second keeps all state in one immutable data class, on each update of a single property, the state is copied to include the change of the single property. Changes are (thus) observed in a non-granular way, i.e. all the UI is updated even when a single property is changed.

The second method sound hugely wasteful in terms of performance, however:

  • copying is cheap
  • this pattern is used in the official documentation and examples (mostly), this must have a reason. (The reason may be:)
  • when using Android Views, updating all views when just one part of the UI state changes seems wasteful, however, in Compose, and we want to migrate to that after all, only the part of the view will be re-composed whose data actually changed (explained also here)
  • actually using the StateFlow in the UI (in the Fragment) is hugely cumbersome. If there is only one of those, it would result in less code of course. For every single StateFlow you want to observe, you have to write e.g.
      // update aTextView when a field from the viewmodel changed
      viewLifecycleOwner.lifecycleScope.launch {
          repeatOnLifecycle(Lifecycle.State.STARTED) {
              viewModel.a.collect {
                  binding.aTextView.text = it
              }
          }
      }
    this can be mitigated of course, with some convenience extension functions. Plus, it is basically a one-liner when using Compose (or in other words, convenience extension functions are already built-in I suppose).

So, I tend towards using the second method, but it will be less performant (until we have switched to Compose, or always).

What do you think? Will post some actual code in a draft PR soon.

@Doomsdayrs
Copy link
Contributor

Abstract with an interace so you don't need to do the private vs public mess

interface VM{
val flow: StateFlow<T>
}

class ImplVM : VM {
// mutable state flow for states
// errors should be pushed to UI using a mutable shared flow
override val flow : MutableStateFlow<T> = MutableStateFlow<T>(emptyValue)
}

I recently converted my entire application (Shosetsu) (Pending new Release) into compose, because I had already established flowful VMs my migration was smooth as butter.

Do note that flowful VMs also provide the benefit of being able to quickly stream and manipulate data.

For example the UI is a list with a state flow backing it holding the list. There are filters (another stateflow) dependent on what's in the list as well.
Whenever the model list updates, the UI updates the ui list, but the filter updates in sequence asynchronously.

Example in my application.
https://gitlab.com/shosetsuorg/shosetsu/-/blob/development/android/src/main/java/app/shosetsu/android/viewmodel/impl/DownloadsViewModel.kt?ref_type=heads

@westnordost
Copy link
Member Author

Hmm, the answer is orthogonal to my question, but nonetheless useful, thank you!

Right, good hint with the interface. Although, of course, this doesn't save any lines of code. Also, you didn't seem to have done that in the ViewModel you linked to as an example, though?

You mean with flowful VMs that they use StateFlow (instead of LiveData)? For us, it doesn't make sense to use LiveData anyway because this is Android-specific, while StateFlow isn't.

@Doomsdayrs
Copy link
Contributor

Hmm, the answer is orthogonal to my question, but nonetheless useful, thank you!

Apologies, I saw your message and had to get it out of my mind as soon as possible (while on transit). I should be able to make a better response when I am more settled down, which should be now.

Right, good hint with the interface. Although, of course, this doesn't save any lines of code. Also, you didn't seem to have done that in the ViewModel you linked to as an example, though?

D'oh, I had thought that would have been the best example (For using overrides), since I saw it had my mutable shared flow mention in it.

This is my SearchViewModel and it's abstraction ASearchViewModel.

It does utilize the interface method I referred to prior.

You mean with flowful VMs that they use StateFlow (instead of LiveData)? For us, it doesn't make sense to use LiveData anyway because this is Android-specific, while StateFlow isn't.

Apologies, it seems I had totally misread what you sent (due to email shenanigans), and had assumed you were putting compose state holders in the VM ( that I have seen done before and was traumatized by) .

A better response is as follows.

So, I tend towards using the second method, but it will be less performant (until we have switched to Compose, or always).

While copying is cheap, one must thing about how the UI will look, essentially "stuttering" in between states if we assume that there would be a loading frame.

Let's do an example in practice with mastodon

Read the following instructions completely before performing them.

  1. Go to mastodon.social on a desktop browser such as FireFox (Suggest using dev console to slow loading)
  2. Notice the order of the UI loading in
  3. Notice that side bars load in (least data) first, then the main content loads in.
  4. Select "live feeds", notice only main content changes.

let's view this in a Kotlin manner.

class VM {

val serverInfo : StateFlow<A>
val content: StateFlow<List<B>> // maybe a paging data but that is another topic
val rightColumn : StateFlow<C>

val tabs : StateFlow<List<D>>
val title: StateFlow<String>
}

Each of the state flows is loading separately, serverInfo, rightColumn, content, tabs, title etc.

Now how long each takes varies yes, but the user already sees enough of the page and would be satisfied, knowing the content is just a moment away.

Imagine this if we followed this "pattern" that android uses, in which the ENTIRE UI is a single data class. Instead, the user would be stuck waiting while all needed data collected.

Android uses it likely as a means of simplifying what is going on. But in writing performant UIs, you want to minimize the time it takes for the general shape of the UI to load in, DiceUiState is just a few kbs of data, but imagine if you were loading in hundreds of entries of a list that were updated every time the user clicked a checkbox (quests screen) and you can see how the slow downs start occurring?

...

In Shosetsu, the most clunky UIs are NovelInfo, LibraryView, & ChapteReader.

Let's take the NovelInfo for example, with its NovelViewModel and its abstraction ANovelViewModel

Some Novels have upwards of 3 thousand chapters, which can lead to seconds of wait even on fast devices.
Thus, the two datas are loaded in separately. The user gets to see the novel 'info' such as title desc and image first, then the chapters load in afterwards.

	abstract val novelLive: StateFlow<NovelUI?>
	abstract val chaptersLive: StateFlow<ImmutableList<ChapterUI>>

...

One also needs to apply a semi case by case basis in how data classes that hold numerous states are used.

	abstract val selectedChaptersState: StateFlow<SelectedChaptersState>

Take example this from NovelViewModel, in which the selection state is in a data object instead of individual fields.

	/**
	 * @param showRemoveBookmark If any chapters are bookmarked, show the remove bookmark logo
	 * @param showBookmark If any chapters are not bookmarked, show bookmark
	 * @param showDelete  If any are downloaded, show delete
	 * @param showDownload  If any are not downloaded, show download option
	 * @param showMarkAsRead If any are unread, show read option
	 * @param showMarkAsUnread If any are read, show unread option
	 */
	@Immutable
	data class SelectedChaptersState(
		val showRemoveBookmark: Boolean = false,
		val showBookmark: Boolean = false,
		val showDelete: Boolean = false,
		val showDownload: Boolean = false,
		val showMarkAsRead: Boolean = false,
		val showMarkAsUnread: Boolean = false
	)

The reason for this, is that the "SelectedChaptersState" is a ton of related data (all appearing on the same bar in app) that wouldn't make sense to have in 6 different flows.

So Coalescing data together into a data class is not off the table, but having the entire UI determined by a single data class would be counter productive.

@westnordost
Copy link
Member Author

westnordost commented Feb 11, 2024

Right, I followed your hint regarding the interfaces (though they have to be abstract classes for now, hoping that can be changed when switching from Android ViewModels to a multiplatform implementation) and also made all those observable state flows quite granular. (It's much less of a pain when there is not that mess with the MutableStateFlow/StateFlow, looks quite clean.)

I notice you follow a lot of best practices otherwise with the app. I need to wire the ViewModels to the data layer of StreetComplete, which is structured differently that what one can read in modern guides.

(Very short crash course: *Controller are pretty alike *Repository, *Source are like *Controller but have only getters, the whole data layer is blocking, knows nothing about coroutines, there are also no *UseCase classes, but some *Controllers that in turn use other controllers. This architecture will of course not be touched now, because one step after another.)

I've posted the draft PR for the first ViewModels in #5482 in case you want to have a look. (I'd like that.)

@Doomsdayrs
Copy link
Contributor

Right, I followed your hint regarding the interfaces (though they have to be abstract classes for now, hoping that can be changed when switching from Android ViewModels to a multiplatform implementation)

// commom
expect class Foo {
 fun bar()
}

// android
actual class Foo : ViewModel {
 Fun bar()
}

// ios
actual class Foo : IOSViewModel {
 Fun bar()
}

@westnordost
Copy link
Member Author

We plan to use some framework that supplies multiplatform view models, see #5421 (comment) . In one of the choices, view models were actually just interfaces.

@westnordost
Copy link
Member Author

I'll close this issue and create a new master ticket for using viewmodels with summarized information: #5530

@github-project-automation github-project-automation bot moved this from In Progress to Done in iOS Port Mar 9, 2024
@westnordost westnordost moved this from Done to Released in iOS Port Apr 29, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
code cleanup iOS necessary for iOS port
Projects
Status: Released
Development

No branches or pull requests

3 participants