Magnet DI, Developer Guide

July 8, 2018

Minimalist and fast dependency injection library for Android.

Table of contents

Disclaimer and support

Magnet is fully open source and available under Apache License, Version 2.0 at https://github.com/beworker/magnet.

It is provided for free, without any kind of support. If you consider using Magnet in your commercial product and you need additional support or training, feel free to contact me.

Motivation

Why create another dependency injection library? Here are the objectives Magnet tries to pursue better that the others:

Getting started

Step 1: Configure project

To use Magnet in your project, include it as a dependency into your build.gradle file.

Kotlin

dependencies {
    api 'de.halfbit:magnet-kotlin:2.3'
    kapt 'de.halfbit:magnet-processor:2.3'
}

Java

dependencies {
    api 'de.halfbit:magnet:2.3'
    annotationProcessor 'de.halfbit:magnet-processor:2.3'
}

Releases of Magnet are published to mavenCentral() repository.

Step 2: Initialize Magnet

Create an empty marker interface in your main application module and annotate it. If you have a single-module application, just add it to that module. This one-time initialization will trigger generation of all needed helper code.

@Magnetizer
interface AppMagnetizer
@Magnetizer
interface AppMagnetizer {}

Step 3: Write and annotate application classes

Start wring classes needed for implementing your application and let Magnet know how to instantiate them by applying @Instance annotation.

// Repository.kt

interface Repository {
    fun getHelloMessage(): String
}

@Instance(type = Repository::class)
internal class DefaultRepository(): Repository {
    override fun getHelloMessage() = "Hello Magnet!"
}

// Presenter.kt

@Instance(type = Presenter::class)
class Presenter(private val repository: Repository) {
    fun presentHelloMessage() {
        println(repository.getHelloMessage())
    }
}
// Repository.java

interface Repository {
    String getHelloMessage();
}

// DefaultRepository.java

@Instance(type = Repository.class)
class DefaultRepository implements Repository {
    @Override
    public String getHelloMessage() {
        return "Hello Magnet!";
    }
}

// Presenter.java

@Instance(type = Presenter.class)
class Presenter {
    private Repository repository;

    Presenter(Repository repository) {
        this.repository = repository;
    }

    public void presentHelloMessage() {
        System.out.println(repository.getHelloMessage());
    }
}

In the example above we define a Repository interface and its DefaultRepository implementation. Annotation @Instance(type = Repository::class) instructs Magnet to create an instance of DefaultRepository when a new instance of Repository type gets requested. Presenter class declares a dependency of Repository type. Its annotation @Instance(type = Presenter::class) says, that Magnet needs to create new instance of Presenter when a new instance of Presenter type gets requested.

Step 4: Create scope and inject objects

Now it’s time for glueing everything together and let Magnet creating and providing us with all instances needed. Magnet does it through a scope.

Create root scope and request instances of application classes.

val scope = Magnet.createRootScope()
val presenter: Presenter = root.getSingle()
presenter.presentHelloMessage()
Scope scope = Magnet.createRootScope();
Presenter presenter = scope.getSingle(Presenter.class);
presenter.presentHelloMessage();

Magnet will create instance of Presenter and Repository for you. Respecting the configuration above, Magnet will create an instance of DefaultRepository and inject it into Presenter’s constructor automatically.

Documentation

Scopes and Instances

Magnet retains created instances in scopes. On other words a scope is a container for instances. Instance’s lifespan corresponds to the lifespan of the scope it belongs to. Thus, for example, if you have a scope which lives forever, all instances inside this scope will also live forever.

Creation and initialization of scope

At first you need to create a scope. Newly created scope is empty and has no instances. Magnet adds instances to the scope as your application requests them. If you want to pre-fill the scope with some instances right away - those can be some system-provided instances like Context in Android - then you can bind those instances into the scope programmatically.

val scope = createRootScope() {
    bind<Context>(this@App)
    bind(LayoutInflater.from(this@App))
}
Scope scope = Magnet.createRootScope()
    .bind(Context.class, this)
    .bind(LayoutInflater.class, LayoutInflater.from(this));

In the example above we create a root scope (the scope without a parent) and bind this instance though Context type and an instance of layout inflater through LayoutInflater type. Now, when an instance of Context type gets requested in the scope, Magnet will return this instance back.

Getting scope instances

Once scope is created we can start requesting instances from it. Instances get requested from the scope by type. Type is a class or interface which requested instance must implement. This is the same type we used in @Instance annotation and when we bound instances into the scope.

In the example below we request an instance of Presenter type from the scope.

// main
val presenter: Presenter = scope.getSingle()

// Presenter.kt
interface Presenter {
    fun present()
}
// main
Presenter presenter = scope.getSingle(Presenter.class);

// Presenter.java
interface Presenter {
    void present();
}

When scope instance is requested, Magnet does the following:

  1. It checks the scope for requested instance by its type and, if the instance was found, Magnet returns it back.
  2. If instance wasn’t found, Magnet will use @Instance-annotated classes of requested type to create the instance and to bind it into the scope. Then the instance gets returned back.

Code above requests an instance of Presenter type, which has no implementation yet. If you execute this code, it will fail. Next sections describes how to let Magnet know which implementation of Presenter type should be used, which an instance of Presenter type is requested.

Instance annotation

Annotating instance implementations is basically the main development effort related to usage of Magnet. By applying @Instance annotation to implementation classes your instruct Magnet which class to use when instance of certain type is requested.

In the example below we implement Presenter interface and annotate the implementation.

@Instance(type = Presenter::class)
internal class DefaultPresenter(
    private val layoutInflater: LayoutInflater
) : Presenter {

    override fun present() {
        layoutInflater.inflate(...)
        ...
    }
}
@Instance(type = Presenter.class)
class DefaultPresenter implements Presenter {
    private final LayoutInflater layoutInflater;

    DefaultPresenter(LayoutInflater layoutInflater) {
        this.layoutInflater = layoutInflater;
    }

    @Override public void present() {
        layoutInflater.inflate(...);
        ...
    }
}

Once DefaultPresenter class gets processed by Magnet’s annotation processor, Magnet will be able to instantiate it when scope instance of Presenter type gets requested.

Instance factory methods

Sometimes you will need to instantiate third-party implementations which cannot be annotated by you. For this purposes Magnet supports annotation of static methods, which serve as instance factories. The example below demonstrates how you can provide an instance of SharedPreferences class using a static method.

@Instance(type = SharedPreferences::class)
fun provideGlobalSharedPreferences(context: Context)
    : SharedPreferences = PreferenceManager
        .getDefaultSharedPreferences(context)
class SharedPreferencesProvider() {

    @Instance(type = SharedPreferences.class)
    public static SharedPreferences provide(Context context) {
        return PreferenceManager
            .getDefaultSharedPreferences(context);
    }
}

Using Classifier

When type alone is not enough to differentiate between two implementations and you need a more fine-grained type classification, you can use classifier. Classifier is an additional differentiator inside the same type.

It can be used, for instance, to differentiate between Application and Activity contexts. Both implement the same Context type, but after applying a classifier Magnet can easily provide one or another instance upon request.

// Activity.kt

const val ACTIVITY = "activity"
const val APPLICATION = "application"

// bind context
val activity = this
val scope = createRootScope() {
    bind<Context>(activity, ACTIVITY)
    bind<Context>(activity.applicationContext, APPLICATION)
}

// Renderer.kt

@Instance(type = Renderer::class)
internal class Renderer(
    @Classifier(ACTIVITY) val activityContext: Context,
    @Classifier(APPLICATION) val applicationContext: Context
)

At this point you might have been used to Kotlin syntax already. Thus all the other code snippets will be written in Kotlin only ;)

Instance cardinalities

Magnet is capable of injecting instances in three different cardinalities: optional, single and many.

Optional

Optional cardinality corresponds to zero or one instance. If your app can handle a case, when instance cannot be provided by Magnet (e.g. there is no implementation available), then you should use this cardinality.

Request optional from scope:

val context: Context? = scope.getOptional<Context>()

Request optional though dependency:

@Instance(type = Renderer::class)
internal class Renderer(
    val context: Context?
)

If Magnet cannot find an instance, it will inject null. If however there are more than one instance found, Magnet will fail injection.

When used with Java, Magnet respects @Nullable annotations if provided for enforced nullability.

Single

Single cardinality corresponds to exactly one instance. This is the most commonly used cardinality. If your app requires exactly one instance of a type to be provided by Magnet, you should use this cardinality.

Request single from scope:

val context = scope.getSingle<Context>()

Request single though dependency:

@Instance(type = Renderer::class)
internal class Renderer(
    val context: Context
)

If Magnet does not find requested instance or finds more than one instance, Magnet will fail injecting the value.

Many

Many cardinality corresponds to zero or more instances. If you expect zero or more instances of certain type to co-exist in your app, then this is the cardinality of choice.

Request many from scope:

interface MenuItem

val menuItems = scope.getMany<MenuItem>()

Request many though dependency:

@Instance(type = Renderer::class)
internal class Renderer(
    val menuItems: List<MenuItem>
)

The code above will inject all instances of MenuItem available in app. For instance, if you write a new application module and declare a new implementation of MenuItem for Magnet, new menu item will be available in the list right after the app gets recompiled and launched.

As you can see the cardinality uses List as container for instances. This imposes some restrictions on injection of List instances in Magnet. You cannot bind List type into scopes. Neither can you provide instances of List type. You should use custom classes instead. List type is reserved for many cardinality.

Scope hierarchies

The most power of Magnet can be gained by using hierarchical scopes. Each scope in Magnet can have an optional relation to a parent scope. If a parent relation is present, a scope hierarchy gets build up. The scope hierarchy can be any deep, but I suggest to keep it as flat as needed.

class App : Application() {

    override fun onCreate() {
        super.onCreate()
        scope.apply {
            bind(applicationContext, APP_CONTEXT)
            bind(contentResolver)
        }
    }

    companion object {
        val scope: Scope = Magnet.createRootScope()
    }
}

class PlayerActivity : Activity() {

    private lateinit var scope: Scope

    override fun onCreate(savedInstanceState: Bundle?) {
        val activity = this
        scope = App.scope.createSubscope {
            bind<Resources>(activity.resources)
            bind<Activity>(activity)
            bind<LifecycleOwner>(activity)
            bind(LayoutInflater.from(activity))
            bind(act.supportFragmentManager)
        }
    }
}

Parent scope does never know it is a parent scope, because it does not hold any references to its children. Parent relation allows instances kept in a child scope accessing instances of its parent scope or even parent’s parent scope up to the root.

This leads us to the following observations:

Such design fits the nature of Android applications very well. For instance, we can distinguish the following Android application scopes and put them into a hierarchy:

Application scope gets created on application start and lives as long as out app lives. Activity scope is a child of Application scope. It gets created and destroyed as user navigates from one activity to another through the application. The same happens to the other scopes.

Instances retained in Activity scope can access instances retained in Application scope. For example a Presenter from Activity scope can access NetworkManager from Application scope.

Scoping rules

Until now we learned how to bind instances into a scope. We also mentioned, that after Magnet creates a new instance it puts this instance into a scope. Scoping rules are exactly the rules Magnet applies when it puts instance into a scope.

After the instance is created, Magnet has to choose between the following options: retain the instance in scope or don’t retain. If first option is selected, there is another decision to make - in which exactly scope to retain it, because we have a scope hierarchy as you remember.

There is three scoping rules you can apply to instances for enforcing desired behavior.

Unscoped

Scoping.UNSCOPED is used when Magnet has to create new instances each time when instance is requested. The behavior is similar to usage of standard instance factory - each time instance is requested, each time Magnet create a new instance.

@Instance(
    type = PlayerViewModel::class,
    scoping = Scoping.UNSCOPED
)
internal class PlayerViewModel : ViewModel()

Direct

Scoping.DIRECT is used when Magnet has to bind created instance directly into the scope, from which this instance has been requested.

@Instance(
    type = ViewHolderProvider::class,
    scoping = Scoping.DIRECT
)
internal class SectionViewHolderProvider(
    private val layoutInflater: LayoutInflater,
    private val resources: Resources
) : ViewHolderProvider

Given the following scope hierarchy Fragment ➜ Activity ➜ App let’s see the behavoir of this scoping rule depending on initial and follow up requests.

  1. Initially requesting from App scope. Magnet retains instance in App scope.

    • Follow up request to Activity scope will return the instance retained in App scope, because Magnet will search scope hierarchy up and find the instance.
    • Follow up request to Fragment scope will return the instance retained in App scope, because Magnet will search scope hierarchy up and find the instance.
  2. Initially requesting from Activity scope. Magnet retains instance in Activity scope.

    • Follow up request to App scope will create a new instance and retain it in App, because Magnet was not able to find the instance in scope hierarchy.
    • Follow up request to Fragment scope will return the instance retained in Activity scope, because Magnet will search scope hierarchy up and find the instance.
  3. Initially requesting from Fragment scope. Magnet retains instance in Fragment scope.

    • Follow up request to App scope will create a new instance and retain it in App, because Magnet was not able to find the instance in scope hierarchy.
    • Follow up request to Activity scope will return the instance retained in App scope, because Magnet will search scope hierarchy up and find the instance.

You should use Scoping.DIRECT when you want to keep instances in scope, but you don’t want to ‘inject’ them to the parent scope, which is possible when the next scoping rule is used.

Top Most

Scoping.TOPMOST is the default scoping rule instructing Magnet to find the top most scope for the requested instance, where all dependencies of the instance can still be satisfied.

We have same Fragment ➜ Activity[Resources] ➜ App scope hierarchy, but this time Activity scope has an instance of Resources bound into it.

Given the following classes, Magnet will inject UserInteractor and Presenter as following.

interface UserInteractor

@Instance(
    type = UserInteractor::class,
    scoping = Scoping.TOPMOST
)
internal class DefaultUserInteractor() : UserInteractor

@Instance(
    type = Presenter::class,
    scoping = Scoping.TOPMOST
)
class Presenter(
    private val resources: Resources,
    private val userInteractor: UserInteractor
)
  1. Request Presenter from Fragment scope. Resulting scope hierarchy looks like Fragment ➜ Activity[Resources, Presenter] ➜ App[DefaultUserInteractor]

    • Magnet retains instance of DefaultUserInteractor in App scope because DefaultUserInteractor has no dependencies and its top most scope is the root scope of scope hierarchy.
    • Magnet retains instance of Presenter in Activity scope, because is has two dependencies: Resources and UserInteractor. The top most scope where both dependencies are reachable is the Activity scope.

Note: If we destroy Fragment scope and create a new Fragment2 scope then the hierarchy will look like this.

Fragment2 ➜ Activity[Resources, Presenter] ➜ App[DefaultUserInteractor].

Magnet keeps Activity and App scopes pre-filled with instances and another request of Presenter instance from Fragment2 scope will return same instance Magnet has already created when initial Fragment scope existed.

  1. Request Presenter from Activity scope. The resulting scope hierarchy looks exactly like in case 1.

  2. Request Presenter from Application scope. Magnet will fail injecting, because Presenter's dependency on Resources cannot be satisfied.

Best practices

@Instance(
    type = InstanceHolder::class,
    scoping = Scoping.UNSCOPED
)
internal class InstanceHolder(
    val imageLoader: ImageLoader,
    val networkInteractor: NetworkInteractor
)

// MyActivity.kt

private lateinit var activityScope: Scope
private lateinit var holder: InstanceHolder

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    activityScope = appScope.createSubscope()
    holder = activityScope.getSingle()
}