ViewModel Under the Hood: How it Works in Compose and View
What happens under the hood when we call viewModel() in Compose or View? How does ViewModelStoreOwner lookup work, what does LocalViewModelStoreOwner do, how does Compose connect to the View hierarchy, and how are DI delegates like hiltViewModel() and koinViewModel() structured? A deep technical look at the scoping and state mechanism.
Introduction
This is a continuation of two previous articles. If in the first one we figured out where ViewModelStore
is ultimately stored in the case of Activity
, and in the second one — how this is organized in Fragment
, today we’ll understand where ViewModel
s are stored when we use Compose (or even just View
).
Especially when we declare ViewModel
directly inside Composable
functions. But, as always, let’s start with the basics.
There’s such an approach — View-based ViewModel scoping. What does it mean?
We all know the standard practice when each fragment or activity has its own ViewModel
.
But there’s also a less popular story — when each View
can have its own ViewModel
.
How useful this is — it’s up to you to decide. You might ask: what does this have to do with Compose?
And I’ll answer: the thing is, Compose works roughly according to the same scheme. Let’s start with a simple example:
View-based ViewModel scoping — first look
Let’s create a custom View
. Let it be TranslatableTextView
.
For our example, it’s not so important what exactly this view does — the main thing is that we want to consider the View-based ViewModel scoping approach. Here’s what it might look like:
class TranslatableTextView(context: Context) : AppCompatTextView(context) {
private val viewModel: TranslatableTextViewViewModel by lazy {
val owner = findViewTreeViewModelStoreOwner() ?: error("ViewModelStoreOwner not found for TranslatableTextView")
ViewModelProvider.create(owner = owner).get(TranslatableTextViewViewModel::class.java)
}
fun translateTo(locale: Locale) {
text = viewModel.getTranslatedText(text.toString(), locale)
}
}
Let’s imagine that TranslatableTextView
can translate text, like in Telegram, for example.
If we used a regular ViewModel
, we would have to duplicate logic on all screens where this View
is used. But thanks to the View-based ViewModel scoping approach, TranslatableTextView
has its own ViewModel
.
What do we see here?
– Initialization of viewModel
directly through ViewModelProvider without delegates, with passing ViewModelStoreOwner.
– A simple translateTo
method that takes Locale
and updates the view’s text (AppCompatTextView
) to the translated one.
Let’s also look at the ViewModel
itself to make the example complete and illustrative:
class TranslatableTextViewViewModel : ViewModel() {
fun getTranslatedText(currentText: String, locale: Locale): String {
// Here could be real localization
return "Translated('$currentText') to ${locale.displayLanguage}"
}
}
Now let’s return to TranslatableTextView
to look at the ViewModel
initialization in more detail. It looks a bit unusual:
class TranslatableTextView(context: Context) : AppCompatTextView(context) {
private val viewModel: TranslatableTextViewViewModel by lazy {
val owner = findViewTreeViewModelStoreOwner() ?: error("ViewModelStoreOwner not found for TranslatableTextView")
ViewModelProvider.create(owner = owner).get(TranslatableTextViewViewModel::class.java)
}
...
}
The first thing that catches the eye is the call to the findViewTreeViewModelStoreOwner()
method.
It returns us a ViewModelStoreOwner
, and as we remember, only ComponentActivity
, Fragment
, or NavBackStackEntry
can be one.
Then we pass this owner
to ViewModelProvider
so that it creates (or returns) the needed ViewModel
and places it in the ViewModelStore
.
Let me remind you: ViewModelStore
is the place where our ViewModel
lives and is stored, and it’s available to every ViewModelStoreOwner
.
Let’s take a look at how the findViewTreeViewModelStoreOwner()
method itself is structured and how it manages to extract the ViewModelStoreOwner
:
ViewTreeViewModelStoreOwner.android.kt:
/**
* Retrieve the [ViewModelStoreOwner] associated with the given [View]. This may be used to retain
* state associated with this view across configuration changes.
*
* @return The [ViewModelStoreOwner] associated with this view and/or some subset of its ancestors
*/
@JvmName("get")
public fun View.findViewTreeViewModelStoreOwner(): ViewModelStoreOwner? {
var currentView: View? = this
while (currentView != null) {
val storeOwner =
currentView.getTag(R.id.view_tree_view_model_store_owner) as? ViewModelStoreOwner
if (storeOwner != null) {
return storeOwner
}
currentView = currentView.getParentOrViewTreeDisjointParent() as? View
}
return null
}
In short, the following happens in this method: on the current View
on which we called findViewTreeViewModelStoreOwner
,
we look for a tag with id R.id.view_tree_view_model_store_owner
. We cast the obtained value to ViewModelStoreOwner
,
and if it’s not null
— we return it. And if it’s null
, then we start going up the View
hierarchy.
This work is performed by the getParentOrViewTreeDisjointParent
method. We won’t dive into its sources — it just returns the parent of the current View
(direct parent or indirect parent).
Since this happens inside a loop, we go up the hierarchy until we find one of the parents that has the tag R.id.view_tree_view_model_store_owner
and already contains a ViewModelStoreOwner
.
At this point, in Christopher Nolan style, we temporarily forget about this method — and look at how we’ll use TranslatableTextView
:
class MainActivity : AppCompatActivity() {
private val frameRootLayout by lazy { findViewById<FrameLayout>(R.id.frameRootLayout) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Bind ViewModelStoreOwner to the View tree (frameRootLayout)
frameRootLayout.setViewTreeViewModelStoreOwner(this)
val translatableView = TranslatableTextView(this)
translatableView.text = "Hello, world!"
frameRootLayout.addView(translatableView)
// Example of using translation
translatableView.translateTo(Locale.ENGLISH)
}
}
Pretty simple, right?
We have some layout where the root is a FrameLayout
with id R.id.frameRootLayout
.
We find this FrameLayout
and add our custom View
: TranslatableTextView
to it. Everything is clear here.
But the most interesting thing is this line:
// Bind ViewModelStoreOwner to the View tree (frameRootLayout)
frameRootLayout.setViewTreeViewModelStoreOwner(this)
We call setViewTreeViewModelStoreOwner
and pass this
to it — that is, the Activity
itself.
As we know, Activity
implements the ViewModelStoreOwner
interface,
so we can safely pass it where ViewModelStoreOwner
is required.
Here’s what the inheritance chain looks like starting from the ViewModelStoreOwner interface:
[interface] ViewModelStoreOwner → ComponentActivity → FragmentActivity → AppCompatActivity
That is, when we pass this
from Activity
to setViewTreeViewModelStoreOwner
, we pass a completely valid ViewModelStoreOwner
, and everything works as expected.
But how exactly does this binding happen inside? What makes findViewTreeViewModelStoreOwner()
find this owner (ViewModelStoreOwner
) later?
To understand this, let’s look at the source code of the setViewTreeViewModelStoreOwner
method, which we’ve already encountered. ViewTreeViewModelStoreOwner.android.kt:
/**
* Set the [ViewModelStoreOwner] associated with the given [View]. Calls to [get] from this view or
* descendants will return `viewModelStoreOwner`.
*
* This should only be called by constructs such as activities or fragments that manage a view tree
* and retain state through a [ViewModelStoreOwner]. Callers should only set a [ViewModelStoreOwner]
* that will be *stable.* The associated [ViewModelStore] should be cleared if the view tree is
* removed and is not guaranteed to later become reattached to a window.
*
* @param viewModelStoreOwner ViewModelStoreOwner associated with the given view
*/
@JvmName("set")
public fun View.setViewTreeViewModelStoreOwner(viewModelStoreOwner: ViewModelStoreOwner?) {
setTag(R.id.view_tree_view_model_store_owner, viewModelStoreOwner)
}
The findViewTreeViewModelStoreOwner
method, which we’re already familiar with, is also nearby.
Right now we’re interested in setViewTreeViewModelStoreOwner
. As we can see, it simply puts the viewModelStoreOwner
as a tag in the specified View
with the key R.id.view_tree_view_model_store_owner
:
setTag(R.id.view_tree_view_model_store_owner, viewModelStoreOwner)
Everyone who has worked with View
knows the setTag(Object?)
method, but besides this, there’s also an overloaded method:
public void setTag(int key, final Object tag) {
...
}
This method allows storing different tags by keys, using SparseArray
under the hood. This is an important point, because it’s exactly through this mechanism that we’ll pass the ViewModelStoreOwner
.
Now let’s understand what happens in practice.
In the onCreate
method in Activity
, we call the setViewTreeViewModelStoreOwner
method for the root View
(R.id.frameRootLayout), passing this
as a parameter, that is, the Activity
itself. This is because Activity
implements the ViewModelStoreOwner
interface. We bind this activity to the view tree to have access to ViewModelStore
(since Activity is a ViewModelStoreOwner).
Next, we add our custom View
(TranslatableTextView) to this frameRootLayout
. Example:
class MainActivity : AppCompatActivity() {
private val frameRootLayout by lazy { findViewById<FrameLayout>(R.id.frameRootLayout) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Bind ViewModelStoreOwner to the View tree
frameRootLayout.setViewTreeViewModelStoreOwner(this)
val translatableView = TranslatableTextView(this)
translatableView.text = "Hello, world!"
frameRootLayout.addView(translatableView)
// Example of using translation
translatableView.translateTo(Locale.ENGLISH)
}
}
Now, what happens next?
When we’re in our custom View
, we call the findViewTreeViewModelStoreOwner
method. This method starts looking for a tag with ID R.id.view_tree_view_model_store_owner
in the view itself. If it doesn’t find the needed tag, it will go up the view hierarchy until it finds a parent element that contains this tag:
class TranslatableTextView(context: Context) : AppCompatTextView(context) {
private val viewModel: TranslatableTextViewViewModel by lazy {
val owner = findViewTreeViewModelStoreOwner() ?: error("ViewTreeViewModelStoreOwner not found for TranslatableTextView")
ViewModelProvider.create(owner = owner).get(TranslatableTextViewViewModel::class.java)
}
...
}
So, this mechanism allows finding the needed ViewModelStoreOwner
in the view tree, starting from the current view and moving up the hierarchy to the parent component that stores the ViewModelStore
.
In our case, findViewTreeViewModelStoreOwner
finds the ViewModelStoreOwner
in the parent view: FrameLayout(R.id.frameRootLayout)
, and we get the ViewModelStoreOwner
and by default create a ViewModel
by calling ViewModelProvider
. Ultimately, this way our ViewModel, which we created inside TranslatableTextView, will be stored in the ViewModelStore belonging to the Activity.
Now the question is, why did we consider this? And what does this have to do with Compose? The answer is in the next chapter of the article.
Where does Compose store ViewModel
s?
Let’s take a very simple ViewModel
and a very simple composable screen. Let’s start with the ViewModel
:
class MyViewModel : ViewModel() {
fun getName(): String = "Compose"
}
Our ViewModel
is very simple, and we need it only as an example to get to the essence. Next, our Composable Screen:
@Composable
fun Greeting(modifier: Modifier = Modifier) {
val viewModel = androidx.lifecycle.viewmodel.compose.viewModel<MyViewModel>()
Text(
text = "Hello ${viewModel.getName()}",
modifier = modifier
)
}
Now let’s continue:
viewModel()
is a function from the library: androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7. I specifically indicated the full path to the function in the example so that you’re not confused about where it’s stored and where it came from. Using Koin, for example, we could use koinViewModel()
from the io.insert-koin:koin-androidx-compose
library, or even hiltViewModel()
from androidx.hilt:hilt-navigation-compose
.
Regardless of which method we would use to get ViewModel
in Compose, they all work under the hood the same way, especially in the context of getting ViewModelStore
, since it can’t be taken from thin air. So let’s start studying with androidx.lifecycle.viewmodel.compose.viewModel()
, because it was the first, and libraries like Hilt and Koin for creating ViewModel
in Compose use a similar mechanism.
Next, the source code of the androidx.lifecycle.viewmodel.compose.viewModel
method in the file:
androidx.lifecycle.viewmodel.compose.ViewModel.kt:
@Suppress("MissingJvmstatic")
@Composable
public inline fun <reified VM : ViewModel> viewModel(
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
...
): VM = viewModel(VM::class, viewModelStoreOwner, key, factory, extras)
The other input parameters don’t interest us in this article, except for the viewModelStoreOwner
parameter:
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
Next, we’ll be interested in LocalViewModelStoreOwner.current - since it provides us with ViewModelStore, apparently. LocalViewModelStoreOwner.current from the name and syntax, it’s immediately clear that this is CompositionLocal:
CompositionLocal
is a mechanism inJetpack Compose
that allows passing values through the UI tree without explicit passing through parameters, with access to them via .current at any point in the composition. For use, it’s necessary to previously provide a value throughCompositionLocalProvider
or set it by default when creating.
Let’s look at the LocalViewModelStoreOwner source code:
/**
* The CompositionLocal containing the current [ViewModelStoreOwner].
*/
public object LocalViewModelStoreOwner {
private val LocalViewModelStoreOwner =
compositionLocalOf<ViewModelStoreOwner?> { null }
/**
* Returns current composition local value for the owner or `null` if one has not
* been provided nor is one available via [findViewTreeViewModelStoreOwner] on the
* current [androidx.compose.ui.platform.LocalView].
*/
public val current: ViewModelStoreOwner?
@Composable
get() = LocalViewModelStoreOwner.current ?: findViewTreeViewModelStoreOwner()
/**
* Associates a [LocalViewModelStoreOwner] key to a value in a call to
* [CompositionLocalProvider].
*/
public infix fun provides(viewModelStoreOwner: ViewModelStoreOwner):
ProvidedValue<ViewModelStoreOwner?> {
return LocalViewModelStoreOwner.provides(viewModelStoreOwner)
}
}
We see that LocalViewModelStoreOwner
is just a wrapper over the real CompositionLocal
. We access exactly its current field to read the current value. We either try to get the value from the current field of CompositionLocal
— this means that someone somewhere should have provide
d it. If it’s empty there, then in that case the findViewTreeViewModelStoreOwner
method is called. In the usual out-of-the-box usage scenario, we fall exactly under the second case when the findViewTreeViewModelStoreOwner
method is called. So let’s look at its source code next:
LocalViewModelStoreOwner.android.kt
@Composable
internal actual fun findViewTreeViewModelStoreOwner(): ViewModelStoreOwner? =
LocalView.current.findViewTreeViewModelStoreOwner()
And we see that another CompositionLocal
— LocalView
calls the View.findViewTreeViewModelStoreOwner() method — this is the same method we already looked at in the first part of the article. LocalView.current returns us the current View. Current View? Aren’t we working in compose now? Where did the current View come from? We’ll find out about this a bit later, what this View is and where it came from. For now, just know that under the hood LocalView.current returns us the current View, on which we can call the extension function findViewTreeViewModelStoreOwner
, which we already saw in the first part of the article, and will put the ViewModel in ViewModelStore:
ViewTreeLifecycleOwner.android.kt
/**
* Retrieve the [ViewModelStoreOwner] associated with the given [View]. This may be used to retain
* state associated with this view across configuration changes.
*
* @return The [ViewModelStoreOwner] associated with this view and/or some subset of its ancestors
*/
@JvmName("get")
public fun View.findViewTreeViewModelStoreOwner(): ViewModelStoreOwner? {
var currentView: View? = this
while (currentView != null) {
val storeOwner =
currentView.getTag(R.id.view_tree_view_model_store_owner) as? ViewModelStoreOwner
if (storeOwner != null) {
return storeOwner
}
currentView = currentView.getParentOrViewTreeDisjointParent() as? View
}
return null
}
Let’s go through the flow once more:
When we call any of the extension functions for creating viewmodel inside our Composable functions: whether it’s viewModel from the androidx.lifecycle:lifecycle-viewmodel-compose library, or even koinViewModel()
from the io.insert-koin:koin-androidx-compose
library, or even hiltViewModel()
from androidx.hilt:hilt-navigation-compose
, we ultimately access exactly the CompositionLocal named LocalViewModelStoreOwner
to its current field. And that, in turn, either gets the value stored inside it, or calls the Composable method findViewTreeViewModelStoreOwner
. And that, in turn, calls LocalView
— this is another CompositionLocal
that has the current View
, and for it the extension method View.findViewTreeViewModelStoreOwner
is launched, and a search through the View
tree occurs looking for ViewModelStoreOwner
. In the end, it finds it, but how? Two questions arise in mind:
- What do Views have to do with this? Why does Compose call LocalView, and where did LocalView itself come from?
- From the previous chapter in the article, we saw that before calling the View.findViewTreeViewModelStoreOwner() method, we put ViewModelStoreOwner into an internal tag inside FrameLayout, which was the root View in our layout, using the setViewTreeViewModelStoreOwner method. But in the Compose example, we didn’t put anything anywhere — how does all this work by itself?
Everything is quite simple, Google developers took care of this for us. Usually in Composable there are two approaches:
When the entire project is fully on Compose, or at least in each activity the UI tree starts with
setContent{}
, not withsetContentView
:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Greeting(modifier = Modifier.fillMaxWidth()) } } }
Hybrid UI, where part is on compose, and part is on View. Then they resort to using ComposeView:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/linearLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView"
android:layout_width="match_parent"
android:layout_height="200dp"/>
</LinearLayout>
class MainActivity : ComponentActivity(R.layout.activity_main) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val composeView = findViewById<ComposeView>(R.id.composeView)
composeView.setContent { Greeting() }
}
}
In both cases, if you run it as it is now, everything will work: our ViewModel
inside the Greeting
function will be created without problems and placed in the ViewModelStore
, which belongs to the Activity. Why does this happen?
In both cases, we call the setContent method, in the first case it’s ComponentActivity.setContent{}
, and in the second ComposeView.setContent {}
, which open the Composable area.
Let’s first consider the first case, starting with setContent for activity (ComponentActivity).
Using ComponentActivity.setContent:
public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
val existingComposeView =
window.decorView.findViewById<ViewGroup>(android.R.id.content).getChildAt(0) as? ComposeView
if (existingComposeView != null)
with(existingComposeView) {
setParentCompositionContext(parent)
setContent(content)
}
else
ComposeView(this).apply {
// Set content and parent **before** setContentView
// to have ComposeView create the composition on attach
setParentCompositionContext(parent)
setContent(content)
// Set the view tree owners before setting the content view so that the inflation
// process and attach listeners will see them already present
setOwners()
setContentView(this, DefaultActivityContentLayoutParams)
}
}
Note that the setContent function is an extension for ComponentActivity and has additional logic for initializing Owners and other components. Inside itself, it uses ComposeView and its setContent method.
What’s happening here? The window has a DecorView, inside this DecorView there’s another ViewGroup (FrameLayout). From this ViewGroup, a ComposeView is extracted at index 0, if it exists. If it doesn’t exist, then a new one is created and the setContentView method is called (which all activities have and is inherited from Activity itself). But what we need happens before calling the setContentView method — we’re talking about setOwners
. Let’s look at its source code too:
private fun ComponentActivity.setOwners() {
val decorView = window.decorView
...
if (decorView.findViewTreeViewModelStoreOwner() == null) {
decorView.setViewTreeViewModelStoreOwner(this)
}
...
}
And this is exactly where ViewModelStoreOwner is placed in DecorView by calling the setViewTreeViewModelStoreOwner method, where this is passed — that is, the activity itself. DecorView is the most (almost) root View in the entire View hierarchy, above it stands only the Window itself.
Overall Picture of ViewModelStoreOwner, ComposeView and LocalView Interaction
Now let’s summarize the entire process and draw conclusions: when we use ComponentActivity (or its descendants FragmentActivity and AppCompatActivity) in Compose and create a ViewModel using compose/hilt/koin delegates, then inside there’s a call to LocalViewModelStoreOwner. It returns ViewModelStoreOwner, if it exists. If not, then it calls the Composable method findViewTreeViewModelStoreOwner
. That, in turn, inside itself calls composition local — LocalView.current, gets View and calls another extension method View.findViewTreeViewModelStoreOwner on this View. This method recursively, starting from LocalView, searches for the saved ViewModelStoreOwner in View tags and thus climbs up the View hierarchy until it finds one. If it finds it, it returns it; if it doesn’t find it, it returns null, and an error is thrown: No ViewModelStoreOwner was provided via LocalViewModelStoreOwner
As we saw above, when calling ComponentActivity.setContent{}
, under the hood the method ComponentActivity.setOwners()
is called, which places ViewModelStoreOwner in the DecorView tag. It turns out that when calling the View.findViewTreeViewModelStoreOwner() method, climbing through the View hierarchy, ultimately ViewModelStoreOwner is found inside the topmost View (DecorView), but in Compose there’s no direct access to DecorView, instead there’s a call to LocalView.current:
LocalViewModelStoreOwner.android.kt
@Composable
internal actual fun findViewTreeViewModelStoreOwner(): ViewModelStoreOwner? =
LocalView.current.findViewTreeViewModelStoreOwner()
In this chain we haven’t considered only one moment — where does LocalView
come from. More precisely, it’s clear that this is CompositionLocal
, but where is the reference to the current View
in it? or who is the current View
?
If briefly and abstractly: ComposeView
inside itself calls LocalView
and provides itself to it. Therefore LocalView
by default refers to that ComposeView
in which the tree of Composable functions was launched. And the Compose tree in Android always starts exactly from ComposeView.
Below — the full path to the moment where LocalView
gets its value. Without detailed comments, just the chain:
class ComposeView @JvmOverloads constructor(...) : AbstractComposeView(context, attrs, defStyleAttr)
ComposeView
inherits from AbstractComposeView
. Let’s see what happens inside AbstractComposeView
:
abstract class AbstractComposeView(...) : ViewGroup(...) {
private fun ensureCompositionCreated() {
if (composition == null) {
composition = setContent(resolveParentCompositionContext()) {
Content()
}
}
}
}
In the ensureCompositionCreated
method, which is called, for example, on onMeasure
or onAttachedToWindow
, or when we call ComposeView.setContent, we’re interested in the call to the setContent
function:
internal fun AbstractComposeView.setContent(...): Composition {
val composeView = ... ?: AndroidComposeView(...).also {
addView(it.view, DefaultLayoutParams)
}
return doSetContent(composeView, parent, content)
}
Here the following happens: an object of the AndroidComposeView
class is created, this same object is placed inside ComposeView
by calling addView
. I remind you that AbstractComposeView
is an abstract class, and one of its descendants is ComposeView
. Although work here is done at the abstraction level, actually when addView
is called, it’s called for ComposeView
.
If there are too many new names that cause confusion, here’s a brief explanation:
AbstractComposeView
- abstract class that is a ViewGroup and already has many implementations insideComposeView
- one of the descendants ofAbstractComposeView
that allows us to run Composable functions inside itself. In Android everything ultimately comes down to working with it, since in Android there’s no way to run Composable directly at the Window level. Between Window and our Composable screens there are a bunch of View and ViewGroup, includingComposeView
itselfAndroidComposeView
- low-level class, inside which ultimately our Composable screens are drawn
Next — doSetContent
:
private fun doSetContent(
owner: AndroidComposeView,
parent: CompositionContext,
content: @Composable () -> Unit
): Composition {
...
val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
as? WrappedComposition
?: WrappedComposition(owner, original).also {
owner.view.setTag(R.id.wrapped_composition_tag, it)
}
wrapped.setContent(content)
}
We move to WrappedComposition.setContent
:
private class WrappedComposition(
val owner: AndroidComposeView,
val original: Composition
) : Composition, LifecycleEventObserver, CompositionServices {
override fun setContent(content: @Composable () -> Unit) {
...
ProvideAndroidCompositionLocals(owner, content)
...
}
}
And here’s — the key moment:
@Composable
internal fun ProvideAndroidCompositionLocals(
owner: AndroidComposeView,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
...
LocalView provides owner.view,
...
) {
content()
}
}
Here LocalView
gets the value owner.view
, where owner
is AndroidComposeView
, created inside ComposeView
.
Conclusion: LocalView
gets a reference to View
, inside which the composition is executed, due to the fact that ComposeView
itself initializes AndroidComposeView
, which is then passed to ProvideAndroidCompositionLocals
. AndroidComposeView
is created and stored inside ComposeView
, and LocalView
refers exactly to this AndroidComposeView
, and not to ComposeView
itself.
ComposeView
inherits from AbstractComposeView
, which in turn is ViewGroup
. That is, ComposeView
is not AndroidComposeView
itself, but just a container that creates AndroidComposeView
when calling setContent
and inserts it inside.
Therefore, when in ProvideAndroidCompositionLocals
this happens:
LocalView provides owner.view
owner.view
is AndroidComposeView
, not ComposeView
.
View hierarchy, if Activity
is AppCompatActivity
, will look like this:
ViewRootImpl
└── DecorView -> has weak reference to ViewModelStoreOwner (i.e. activity)
└── LinearLayout
└── FrameLayout
└── FitWindowsLinearLayout (action_bar_root)
└── ContentFrameLayout (android:id/content)
└── ComposeView
└── AndroidComposeView -> has weak reference to ViewModelStoreOwner (i.e. activity)
And if it’s ComponentActivity
or FragmentActivity
, then slightly shorter:
ViewRootImpl
└── DecorView -> has weak reference to ViewModelStoreOwner (i.e. activity)
└── LinearLayout
└── FrameLayout (android:id/content)
└── ComposeView
└── AndroidComposeView -> has weak reference to ViewModelStoreOwner (i.e. activity)
ViewRootImpl
is the root element of the entire View
hierarchy. In practice, every Android developer has encountered at least once the error:
“Only the original thread that created a view hierarchy can touch its views.”
This error occurs if you try to access View
from a non-UI thread. And it’s thrown exactly by ViewRootImpl
inside the checkThread()
method:
public final class ViewRootImpl implements ViewParent, ... {
void checkThread() {
Thread current = Thread.currentThread();
if (mThread != current) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views."
+ " Expected: " + mThread.getName()
+ " Calling: " + current.getName());
}
}
}
The key idea — LocalView
by default points to AndroidComposeView
, which is created inside ComposeView
dynamically. ComposeView
itself is just a wrapper that knows how to connect everything and embed the Composable
tree in the right place of the hierarchy.
Here we’ve considered the first case, when we use ComponentActivity.setContent with passing our composition and creating ViewModel. The second usage flow is inside the View hierarchy, for example, if we have all screens on Fragment/View, and we use Compose in some places. This is possible thanks to ComposeView. Let’s consider such a case:
Using ComposeView.setContent:
Here’s an example code from the examples above:
class MainActivity : ComponentActivity(R.layout.activity_main) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val composeView = findViewById<ComposeView>(R.id.composeView)
composeView.setContent { Greeting() }
}
}
@Composable
fun Greeting(modifier: Modifier = Modifier) {
val viewModel = androidx.lifecycle.viewmodel.compose.viewModel<MyViewModel>()
Text(
text = "Hello ${viewModel.getName()}",
modifier = modifier
)
}
We’ve already considered how setContent works in ComposeView. Inside itself ComposeView.setContent doesn’t put a reference to ViewModelStoreOwner and doesn’t have inside itself a call to the setViewTreeViewModelStoreOwner function, it only helps provide LocalView.
But if we run the code in its current form, everything will work as expected. What’s the matter? The situation is similar to before, when such logic was already provided for us. The thing is the following: when calling the setContentView(R.layout.activity_main) method or even when passing a layout reference to the constructor: ComponentActivity(R.layout.activity_main) the following chain occurs:
If we pass Layout Id to the constructor:
open class ComponentActivity() ... {
@ContentView
constructor(@LayoutRes contentLayoutId: Int) : this() {
this.contentLayoutId = contentLayoutId
}
override fun onCreate(savedInstanceState: Bundle?) {
...
if (contentLayoutId != 0) {
setContentView(contentLayoutId)
}
}
}
In the onCreate
method, setContentView is called if contentLayoutId was passed to the constructor. If setContentView was called directly, then the logic is as follows:
When we call the setContentView() method and pass our View or layout id, then under the hood the following happens (below are the sources of the setContentView method):
open class ComponentActivity() ... {
override fun setContentView(@LayoutRes layoutResID: Int) {
initializeViewTreeOwners()
reportFullyDrawnExecutor.viewCreated(window.decorView)
super.setContentView(layoutResID)
}
}
The name of the initializeViewTreeOwners method looks tempting, so let’s look at the sources:
@CallSuper
open class ComponentActivity() ... {
open fun initializeViewTreeOwners() {
...
window.decorView.setViewTreeViewModelStoreOwner(this)
...
}
}
And here we see that the window calls the getDecorView
method (in Kotlin all getters from Java have syntax like a variable), and then the setViewTreeViewModelStoreOwner function is called, which places this (ViewModelStoreOwner) in the tag inside DecorView.
Let’s draw conclusions: when we start our UI with the setContentView method or pass layout id to the activity constructor, then inside ComponentActivity itself (which is the parent for FragmentActivity and AppCompatActivity) logic triggers that places itself (activity implements the ViewModelStoreOwner interface) into the internal tag of DecorView (which is almost the highest in the hierarchy) by calling the setViewTreeViewModelStoreOwner method. Then, when we add our ComposeView to the View hierarchy to start writing in Compose, then inside ComposeView the value for LocalView.current is provided. Then when creating ViewModel inside Compose there’s a call to LocalViewModelStoreOwner, namely to its current field. There it’s checked if there’s a value, and if not, the findViewTreeViewModelStoreOwner
method is called on LocalView, which searches for ViewModelStoreOwner, climbing up the hierarchy until it finds one. Thus, ultimately ViewModelStoreOwner is found in DecorView. That’s how it all works. Below is a diagram of the View hierarchy:
ViewRootImpl
└── DecorView -> has weak reference to ViewModelStoreOwner (i.e. activity)
└── LinearLayout
└── FrameLayout (android:id/content)
└── FrameLayout (app:id/frameRootLayout)
└── ComposeView (app:id/composeView)
└── AndroidComposeView
The article is almost finished, it remains to shed light on one point. By this point all the information above suggests the thought: why did we manually call the setViewTreeViewModelStoreOwner
method at the beginning of the article, if all this is done for us?
(P.S. I’m returning to the example at the beginning of the article with View (TranslatableTextView))
Thanks to the fact that we set ViewModelStoreOwner for our root layout inside our layout, the tag inside FrameLayout (frameRootLayout) has a reference (weak) to ViewModelStoreOwner:
class MainActivity : AppCompatActivity() {
private val frameRootLayout by lazy { findViewById<FrameLayout>(R.id.frameRootLayout) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
...
// Bind ViewModelStoreOwner to View tree
frameRootLayout.setViewTreeViewModelStoreOwner(this)
...
}
}
And the findViewTreeViewModelStoreOwner
method, when it runs through the View hierarchy, will first look in TranslatableTextView, and then, if it doesn’t find it, will climb up through the parents. The parent is frameRootLayout (FrameLayout), there it will find ViewModelStoreOwner. But what if we remove the setting frameRootLayout.setViewTreeViewModelStoreOwner(this)
and run the code?
class TranslatableTextView(context: Context) : AppCompatTextView(context) {
private val viewModel: TranslatableTextViewViewModel by lazy {
val owner = findViewTreeViewModelStoreOwner() ?: error("ViewModelStoreOwner not found for TranslatableTextView")
ViewModelProvider.create(owner = owner).get(TranslatableTextViewViewModel::class.java)
}
...
}
Everything will still work the same. Why? The thing is that, as we’ve already considered in the hierarchy, there’s another parent — DecorView. Here’s how it looks:
ViewRootImpl
└── DecorView -> has weak reference to ViewModelStoreOwner (i.e. activity)
└── LinearLayout
└── FrameLayout (android:id/content)
└── FrameLayout (app:id/frameRootLayout)
└── TranslatableTextView
And when we call the AppCompatActivity.setContentView() method and pass our View or layout id, then under the hood the following happens (below are the sources of the setContentView method):
open class ComponentActivity() ... {
override fun setContentView(@LayoutRes layoutResID: Int) {
initializeViewTreeOwners()
...
}
}
The name of the initializeViewTreeOwners method looks tempting, so let’s look at the sources:
@CallSuper
open class ComponentActivity() ... {
open fun initializeViewTreeOwners() {
...
window.decorView.setViewTreeViewModelStoreOwner(this)
...
}
}
The conclusion is this: call setViewTreeViewModelStoreOwner
only if you want to specify yourself in which View
you want to place a specific ViewModelStoreOwner
. In Compose call LocalViewModelStoreOwner provides yourViewModelStoreOwner
only if you have a need for this, but in practice I haven’t encountered anyone doing this, since out-of-the-box solutions from Google solve everything, and manual work is usually not necessary — unless you’re really doing something very custom.
ViewModel Compose DI Delegates:
When we considered ViewModel
for Composable
functions, we only considered the composable
function viewModel()
— a function from the library: androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7 without DI. And the initialization was like this:
@Composable
fun Greeting(modifier: Modifier = Modifier) {
// here I specifically didn't import the function
val viewModel = androidx.lifecycle.viewmodel.compose.viewModel<MyViewModel>()
}
Earlier I said that:
When we call any of the extension functions for creating
viewModel
inside ourComposable
functions: whether it’s
viewModel
from the library androidx.lifecycle:lifecycle-viewmodel-compose,koinViewModel()
from the libraryio.insert-koin:koin-androidx-compose
,hiltViewModel()
fromandroidx.hilt:hilt-navigation-compose
,
Then ultimately we call exactly the CompositionLocal
named LocalViewModelStoreOwner
to its current
field. Therefore, the implementation is the same everywhere regardless of the library, the entire flow we considered regardless of the delegate and library will work the same way.
Let’s make sure of this, just look at the signature of all three:
We’ve already seen the first one, let’s look again:
androidx.lifecycle.viewmodel.compose.ViewModel.kt
@Suppress("MissingJvmstatic") @Composable public inline fun <reified VM : ViewModel> viewModel( viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, ... ): VM = viewModel(VM::class, viewModelStoreOwner, key, factory, extras)
Koin:
org.koin.androidx.compose.ViewModel.kt:
@OptIn(KoinInternalApi::class)
@Composable
inline fun <reified T : ViewModel> koinViewModel(
qualifier: Qualifier? = null,
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
...
): T {
return resolveViewModel(
T::class, viewModelStoreOwner.viewModelStore, key, extras, qualifier, scope, parameters
)
}
- Hilt:
androidx.hilt.navigation.compose.HiltViewModel.kt:
@Composable
inline fun <reified VM : ViewModel> hiltViewModel(
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
key: String? = null
): VM {
val factory = createHiltViewModelFactory(viewModelStoreOwner)
return viewModel(viewModelStoreOwner, key, factory = factory)
}
As you can notice, all three delegates — viewModel()
, koinViewModel()
and hiltViewModel()
— use the same mechanism for getting ViewModelStoreOwner
through LocalViewModelStoreOwner.current
. The differences are only in syntax and additional logic related to DI, but at the core everything comes down to one thing — getting ViewModelStoreOwner
from the View
tree.
The reason is simple: in Compose there’s no direct access to ComponentActivity
and its derivatives (FragmentActivity
, AppCompatActivity
), as well as to Fragment
or NavBackStackEntry
. Therefore, LocalViewModelStoreOwner
is used, which in the absence of a value in current
calls LocalView.current
and already for it calls the findViewTreeViewModelStoreOwner()
method — the standard way to get the nearest ViewModelStoreOwner
from the View
hierarchy.
That’s exactly why LocalViewModelStoreOwner
is a key element. It’s a universal mediator between Compose and the traditional ViewModel mechanism of Android. And regardless of whether you use Hilt, Koin, or nothing from DI, — everything works through it.
Discussion
No comments yet. Be the first to share your thoughts!