π Hi, this is P.Y., I work as an Android Distinguished Engineer at Block. This blog shares a bit of hackery to be able to access an activity instance within a Hilt ViewModel. If you come up with other interesting ways to do this, let me know on Twitter! If you're mad because you think I'm encouraging bad practices, try yoga.
I need that god object
I've been playing with Hilt's support for view models in a small app, and needed my view model to start a sharing activity:
@HiltViewModel
class MyCuteLittleViewModel @Inject constructor(
) : ViewModel() {
// ... some code that invokes share()
private fun share(content: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, content)
}
val chooserIntent = Intent.createChooser(intent, "Share withβ¦")
val activity = TODO("Need an activity here!")
activity.startActivity(chooserIntent)
}
}
View models are retained across activity config changes, so the activity isn't injectable, which makes total sense: injecting the activity in a view model would lead to leaks on config changes.
Unfortunately, the Activity
class provides a lot of utility so it's fairly common to need access to it (see God object).
Most online resources recommend moving the code to the activity or a collaborator that has access to it, have that listen to events that indicate the action to perform, then send the events from the ViewModel.
You're not the boss of me.
I don't care for these "best" practices. I want that code right there where it's used, and I don't want unnecessary decoupling (also I pinky swear I'll write unit tests tomorrow).
Anyway, here's a little bit of Hilt hackery to support this without changing any Activity
code.
First, let's create a CurrentActivityProvider
scoped to @ActivityRetainedScoped
, which will be in charge of holding the current activity instance:
@ActivityRetainedScoped
class CurrentActivityProvider @Inject constructor() {
// TODO Set and clear currentActivity
private var currentActivity: Activity? = null
fun <T> withActivity(block: Activity.() -> T) : T {
checkMainThread()
val activity = currentActivity
check(activity != null) {
"Don't call this after the activity is finished!"
}
return activity.block()
}
}
Then we can use it as needed. Notice that withActivity()
makes it slightly harder to store the activity instance in the wrong place accidentally:
@HiltViewModel
class MyCuteLittleViewModel @Inject constructor(
private val activityProvider: CurrentActivityProvider
) : ViewModel() {
private fun share(content: String) {
// ...
activityProvider.withActivity {
startActivity(chooserIntent)
}
}
}
Now we need to set up CurrentActivityProvider.currentActivity
for each ActivityRetainedComponent
scope. For that, we create an entry point scoped to the activity (ActivityComponent
) which will provide access to the CurrentActivityProvider
(which lives in a parent ActivityRetainedComponent
scope). The entry point:
@EntryPoint
@InstallIn(ActivityComponent::class)
interface ActivityProviderEntryPoint {
val activityProvider: CurrentActivityProvider
}
Now we can retrieve the scoped activity provider from an activity instance with:
val entryPoint: ActivityProviderEntryPoint =
EntryPointAccessors.fromActivity(this)
val activityProvider = entryPoint.activityProvider
This only works if the activity is Hilt-aware, so let's check that it implements GeneratedComponentManagerHolder
(π€« it's in Hilt's internal package but it's also public so π€·ββοΈ) and let's make a small Activity.withProvider()
utility for that:
activity.withProvider { activityProvider ->
// TODO
}
private fun Activity.withProvider(
block: CurrentActivityProvider.() -> Unit
) {
if (this is GeneratedComponentManagerHolder) {
val entryPoint: ActivityProviderEntryPoint =
EntryPointAccessors.fromActivity(this)
val provider = entryPoint.activityProvider
provider.block()
}
}
Note: Android apps can have multiple activities in created state at the same time. The code here supports that by relying on the
ActivityRetainedComponent
scope which will give us a new component for each activity in the stack, but still return the same logical component when an activity is recreated through a config change.
Now let's add methods to update the activity reference on lifecycle changes:
@ActivityRetainedScoped
class CurrentActivityProvider @Inject constructor() {
private var currentActivity: Activity? = null
fun <T> withActivity(block: Activity.() -> T) : T { /* ... */ }
companion object {
private fun Activity.withProvider(
block: CurrentActivityProvider.() -> Unit
) { /* ... */ }
fun onActivityCreated(activity: Activity) {
activity.withProvider {
currentActivity = activity
}
}
fun onActivityDestroyed(activity: Activity) {
activity.withProvider {
if (currentActivity === activity) {
currentActivity = null
}
}
}
}
}
And finally let's hook the lifecycle callbacks from my Application
class:
@HiltAndroidApp
class MyCuteLittleApp : Application() {
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
CurrentActivityProvider.onActivityCreated(activity)
}
override fun onActivityDestroyed(activity: Activity) {
CurrentActivityProvider.onActivityDestroyed(activity)
}
})
}
}
With this, we can now inject CurrentActivityProvider
in any ActivityRetainedComponent
scope (as well as lower scopes) and easily access the activity with activityProvider.withActivity()
.
Testing testing 1 2 3 π€
To make MyCuteLittleViewModel
easier to test we can move the sharing responsibility to an injected collaborator, e.g. Sharer
:
interface Sharer {
fun share(content: String)
}
class ActivitySharer @Inject constructor(
private val activityProvider: CurrentActivityProvider
) : Sharer {
override fun share(content: String) {
// ...
activityProvider.withActivity {
startActivity(chooserIntent)
}
}
}
@Module
@InstallIn(ActivityRetainedComponent::class)
interface SharerModule {
@Binds fun bindSharer(sharer: ActivitySharer): Sharer
}
Header image generated by DALL-E, prompt: "sword held by an Android with glasses, 3d render"