ViewModel Factory (Class Instances)

Android Factory

In the previous post, ViewModel Factory (Old vs. Newer DSL), we talked about a more modern ViewModel factory. This practice works well, but there is something dangerous you may miss...

Previous Example

Take a quick look at this code below and think about where this might go wrong...

companion object {
private const val MODERN_UI_STATE_KEY = "modern_ui_state"

@VisibleForTesting
internal fun factory(name: String) = viewModelFactory {
initializer {
ModernDSLStyleFactoryViewModel(
name = name,
savedStateHandle = createSavedStateHandle()
)
}
}

@Composable
fun get(
name: String = "Modern DSL ViewModel Factory"
): ModernDSLStyleFactoryViewModel {
return viewModel(factory = factory(name))
}
}

The code above is safe, but that's because we're using literals...

...but if we pass in an object, things get more complicated.

New Example

We'll start with ExampleClass. It takes in a simple tag string but contains an internal counter. When the class is created, it takes the counter from the companion object. Every time the class is created, we bump that number after creation in the init block.

class ExampleClass(tag: String) {
private val _counter = MutableStateFlow(exampleClassCounter)
val counter = _counter.asStateFlow()

init {
Log.i(tag, "ExampleClass Created $exampleClassCounter")
exampleClassCounter++
}

companion object { var exampleClassCounter = 0 }
}

Here is our ViewModel:

class ClassInstanceViewModel private constructor(
exampleClass: ExampleClass = ExampleClass(TAG)
): ViewModel() {
val counter = exampleClass.counter

companion object {
private const val TAG = "CI_VM"
@VisibleForTesting
internal fun factory(exampleClass: ExampleClass) =
viewModelFactory {
Log.d(TAG, " viewModelFactory")
initializer {
Log.d(TAG, " initializer")
ClassInstanceViewModel(exampleClass)
}
}

@Composable
fun get(
exampleClass: ExampleClass = ExampleClass(TAG),
): ClassInstanceViewModel {
Log.d(TAG, " get()")
return viewModel(
// key = TAG,
factory = factory(exampleClass)
)
}
}
}

Again, I've added a few extra details so you can tell what is going on.

We have a link to the exampleClass counter so we know how many times the exampleClass has been created within the ViewModel.

We have added Log.d to get(), viewModelFactory, and initializer. This allows us to see when these are being called in the log.

Note1: I've removed the SavedStateHandle from this to keep things short.

Note2: I've commented out the key as we expect this ViewModel to only be used once in our app. If you need more than one copy of the ViewModel, you need to make sure they all get unique keys.

And then running this is straightforward:

@Composable
fun TestCi() {
val viewModelCi = ClassInstanceViewModel.get() // viewModel<ClassInstanceViewModel>()
val counter by viewModelCi.counter.collectAsStateWithLifecycle()

Text("Test CI_VM: Check Log")
Text(counter.toString())
}

Question

So what do you expect when you run this? Try to think about this before checking the answer....

Android Thinking

Answer Part 1:

The answer is kinda boring if you look at the UI. The ViewModel takes in the first instance of ExampleClass created. The ViewModel is never recreated, we just grab the existing version each time.

So what is the problem?

Ahh, now we need to look at the logs...

Answer Part 2:

Ok, so what do we see in the logs. We'll rotate the device a few times so our UI gets recreated.

Filtering our results to "CI_VM", we get the following:

LogCat results

You can see our ExampleClass is getting recreated every time we rotate even though it is never used. That's not a big deal for our current example as it only contains a counter, but if this class contained a lot of information this could hit the performance quite badly. Either way, we don't want to be creating objects for now reason.

A Chance to Learn

If you look at the logs, you can also see what exactly gets called. 

A) Our object is created before we log get is being called. The object is created before entering the function block of get as it is a default argument.

B) Next, we get the viewModelFactory and initializer. The initalizer is only called if the required ViewModel doesn't exist yet so it only gets called one time during the app lifecycle.

  • ExampleClass Created 0
  •  get()
  •  viewModelFactory
  •  initializer
  • ExampleClass Created 1
  •  get()
  •  viewModelFactory
  • ExampleClass Created 2
  • ... repeats as 1 for further examples
After that, it just repeats the same pattern without the initializer.

Question

So how do we fix this. We could use a DI framework (Dagger/Hilt etc.) but we don't want to add unnecessary overhead to our tiny project. Have a quick think...

Android Thinking 2

Fix

Let's see if you are right. The changes are actually very simple (but easy to miss):

companion object {
private const val TAG = "CIL_VM"
@VisibleForTesting
internal fun factory(exampleClass: () -> ExampleClass) =
viewModelFactory {
Log.d(TAG, " viewModelFactory")
initializer {
Log.d(TAG, " initializer")
ClassInstanceLambdaViewModel(exampleClass())
}
}

@Composable
fun get(
exampleClass: () -> ExampleClass = { ExampleClass(TAG) },
): ClassInstanceLambdaViewModel {
Log.d(TAG, " get()")
return viewModel(
// key = TAG,
factory = factory(exampleClass)
)
}
}

It may not immediately jump out as it is quite subtle, but we're using Lambdas to create the class instance instead of creating them directly. This means the lambda is only called when it is actually needed as can be seen below (filtering log by "CIL_VM"):

LogCat results
As you can see, the ExampleClass is only ever created once. As before, get and viewModelFactory get repeated but the initializer only gets called once.

  •  get()
  •  viewModelFactory
  •  initializer
  • ExampleClass Created 0
  •  get()
  •  viewModelFactory
  • ... get() repeats with viewModelFactory

Takeaways

So what did we learn from this?

As well as seeing how to properly use a custom ViewModelFactory with default class constructors, we also saw the potential issues related to passing objects with default constructors. It is not always obvious from the function that is may be doing some heavy lifting.

Be careful with any functions that take in a class instance with a default argument that may not be needed. You will be creating that object even if it is never used.

Links

Related Blog Posts

Comments

Popular Posts