In this article, we will learn about WorkManager
best practices including performant component initialization with the App Startup
library.
Hilt
is an opinionated DI library that abstracts away the task of creating ‘factories’ by generating the boilerplate code itself.
But Hilt has its perks for example we don’t have to write the dependencies and their containers (Service Locators, factories, etc.) ourselves.
Thumb rule of dependency injection:
Classes don’t need to construct dependencies on their own, these dependencies should be provided to them.
So how do we use Hilt with WorkManager
?
Steps for providing WorkManager
as a dependency and on-demand initialization using App Startup
:
- Define a Hilt module that implements
Initializer<WorkManager>
- Override the
create()
function as a@Provides
method - Remove the default initializer from the
AppManifest.kt
file.
ViewModels shouldn’t be responsible for creating/initializing
WorkManager
instance themselves.
class BlurViewModel(application: Application) : ViewModel(){ private val workManager = WorkManager.getInstance(application) ... }
We can use Hilt to provide the WorkManager
instance here.
The
WorkManager
instance should be initialized on-demand and not immediately when the app starts.
This article uses the starter code provided in WorkManager codelab as a reference.
Set up your environment
Project level build.gradle
dependencies { | |
... | |
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.42' | |
} |
App level build.gradle
apply plugin: 'kotlin-kapt' | |
apply plugin: 'dagger.hilt.android.plugin' | |
... | |
dependencies { | |
.... | |
implementation "androidx.work:work-runtime-ktx:2.7.1" | |
implementation "com.google.dagger:hilt-android:2.42" | |
kapt "com.google.dagger:hilt-compiler:2.42" | |
implementation 'androidx.hilt:hilt-work:1.0.0' | |
// When using Kotlin. | |
kapt 'androidx.hilt:hilt-compiler:1.0.0' | |
implementation "androidx.startup:startup-runtime:1.1.1" | |
} |
Custom configuration with Hilt
@HiltAndroidApp | |
class BlurApplication : Application(), Configuration.Provider{ | |
@Inject | |
lateinit var workerFactory: HiltWorkerFactory | |
override fun getWorkManagerConfiguration(): Configuration { | |
return Configuration.Builder().setWorkerFactory(workerFactory).build() | |
} | |
} |
According to the documentation:
When you need to use WorkManager, make sure to call the method
WorkManager.getInstance(Context).
This was previously done in the ViewModel
code we pointed out above
private val workManager = WorkManager.getInstance(context)
WorkManager calls your app’s custom
getWorkManagerConfiguration()
method to discover itsConfiguration
. (You do not need to callWorkManager.initialize()
yourself.)
This feature is called a custom configuration that we’ll implement using Hilt. If we wouldn’t be using Hilt then we would probably write something like this:
class MyApplication() : Application(), Configuration.Provider { | |
override fun getWorkManagerConfiguration() = | |
Configuration.Builder() | |
.setMinimumLoggingLevel(android.util.Log.INFO) | |
.build() | |
} |
Disable the default initializer
<provider | |
android:name="androidx.startup.InitializationProvider" | |
android:authorities="${applicationId}.androidx-startup" | |
android:exported="false" | |
tools:node="merge"> | |
<meta-data | |
android:name="androidx.work.WorkManagerInitializer" | |
android:value="androidx.startup" | |
tools:node="remove" /> | |
</provider> |
Note that we used a single provider node for this task.
Hilt Module setup:
We define an object
and make it implement the Initializer<WorkManager>
interface by overriding its methods and annotating the relevant ones with @Provides
. WorkManager instance should be a singleton (scoped to the application container) and hence the method is annotated with @Singleton
.
@Module | |
@InstallIn(SingletonComponent::class) | |
object WorkManagerInitializer : Initializer<WorkManager> { | |
@Provides | |
@Singleton | |
override fun create(@ApplicationContext context: Context): WorkManager { | |
val configuration = Configuration.Builder().build() | |
WorkManager.initialize(context, configuration) | |
Log.d("Hilt Init", "WorkManager initialized by Hilt this time") | |
return WorkManager.getInstance(context) | |
} | |
override fun dependencies(): List<Class<out Initializer<*>>> { | |
// No dependencies on other libraries. | |
return emptyList() | |
} | |
} |
Job Offers
Our module is responsible for creating
WorkManager
dependency. The@Provides
method overrides thecreate()
method and initializesWorkManager
to provide its instance with the desired configuration.
In this example, WorkManager
didn’t have any dependencies so we returned an empty list.
Consuming the WorkManager dependency in our ViewModel
What we did in the previous steps:
- Remove the default initializer
- Wrote a custom initializer inside a Hilt module using App startup.
We’ll use constructor injection to consume the
WorkManager
instance as a dependency rather than initializing it in the class itself.
@HiltViewModel | |
class BlurViewModel @Inject constructor( | |
application: Application, | |
private val workManager: WorkManager | |
) : ViewModel() { | |
//private val workManager = WorkManager.getInstance(application) | |
internal var imageUri: Uri? = null | |
internal var outputUri: Uri? = null | |
init { | |
imageUri = getImageUri(application.applicationContext) | |
} | |
private fun createInputDataForUri(): Data { | |
val builder = Data.Builder() | |
imageUri?.let { | |
builder.putString(KEY_IMAGE_URI, imageUri.toString()) | |
} | |
return builder.build() | |
} | |
/** | |
* Create the WorkRequest to apply the blur and save the resulting image | |
* @param blurLevel The amount to blur the image | |
*/ | |
internal fun applyBlur(blurLevel: Int) { | |
val workRequest = | |
OneTimeWorkRequestBuilder<BlurWorker>().setInputData(createInputDataForUri()).build() | |
workManager.enqueue(workRequest) | |
} | |
private fun uriOrNull(uriString: String?): Uri? { | |
return if (!uriString.isNullOrEmpty()) { | |
Uri.parse(uriString) | |
} else { | |
null | |
} | |
} | |
private fun getImageUri(context: Context): Uri { | |
val resources = context.resources | |
val imageUri = Uri.Builder() | |
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) | |
.authority(resources.getResourcePackageName(R.drawable.android_cupcake)) | |
.appendPath(resources.getResourceTypeName(R.drawable.android_cupcake)) | |
.appendPath(resources.getResourceEntryName(R.drawable.android_cupcake)) | |
.build() | |
return imageUri | |
} | |
internal fun setOutputUri(outputImageUri: String?) { | |
outputUri = uriOrNull(outputImageUri) | |
} | |
} |
Note that when we use Hilt, the constructor parameter application: Application
is also provided by Hilt.
Worker classes
As the last step, we’ll use Hilt annotations on the BlurWorker
class to complete the dependency-injection process and to get the app running.
This class uses AssistedInjection
According to the Dagger documentation:
The assisted injection is a dependency injection (DI) pattern that is used to construct an object where some parameters may be provided by the DI framework and others must be passed in at creation time (a.k.a “assisted”) by the user.
A factory is typically responsible for combining all of the parameters and creating the object.
That factory is the
HiltWorkerFactory
that we field-injected in ourBlurApplication.kt
class.
@HiltWorker | |
class BlurWorker @AssistedInject constructor( | |
@Assisted ctx: Context, | |
@Assisted params: WorkerParameters | |
) : CoroutineWorker( | |
ctx, | |
params | |
) { | |
override suspend fun doWork(): Result { | |
val applicationContext = applicationContext | |
val resourceUri = inputData.getString(KEY_IMAGE_URI) | |
makeStatusNotification("Sit tight, Image getting blurred", applicationContext) | |
return try { | |
if (TextUtils.isEmpty(resourceUri)) { | |
Log.e("Debug for Uri", "Invalid input uri") | |
throw IllegalArgumentException("Invalid input uri") | |
} | |
val picture = | |
BitmapFactory.decodeStream( | |
applicationContext.contentResolver.openInputStream( | |
Uri.parse( | |
resourceUri | |
) | |
) | |
) | |
val output = blurBitmap(picture, applicationContext) | |
val blurredImageUri = writeBitmapToFile(applicationContext, output) | |
val outputData = workDataOf(KEY_IMAGE_URI to blurredImageUri.toString()) | |
makeStatusNotification("$blurredImageUri", applicationContext) | |
Result.success(outputData) | |
} catch (e: Throwable) { | |
Log.e("BlurWorker", "Error blurring the image") | |
Result.failure() | |
} | |
} | |
} |
The rest of the implementation for doWork()
doesn’t change.
Conclusion
This article gave an insight into the custom initialization of a component called WorkManager. We discussed an idea about how an instance of an architecture component can be provided as a dependency.
In a production environment, we would probably use a simpler approach, because
Worker
classes and their methods can be tested using some testing utilities.
We can use WorkManagerTestInitHelper
to initialize WorkManager for testing.
Further Learning
This article was originally published on proandroiddev.com on July 18, 2022