Skip to content
This repository has been archived by the owner on Sep 3, 2023. It is now read-only.
/ ulfberht Public archive

🗡️ A small but powerful & opinionated DI library. Written in Kotlin, and powered by annotation processing.

License

Notifications You must be signed in to change notification settings

afollestad/ulfberht

Repository files navigation

Note: Dagger2 and Anvil should be used in production apps. This is now mostly just an experiment with kapt and Kotlinpoet, IR should be used rather than Kapt these days.

Ulfberht

Maven Central Android CI License

The Vikings were among the fiercest warriors of all time. Yet only a select few carried the ultimate weapon of their era: the feared Ulfberht sword. Fashioned using a process that would remain unknown to the Vikings' rivals for centuries, the Ulfberht was a revolutionary high-tech tool as well as a work of art.

A little more bad-ass than a Dagger. Dependency injection is a technique in which an application supplies dependencies of an object. "Dependencies" in this context are not dependencies like in a Gradle file. Dependencies are services (i.e. APIs, classes) that are needed in certain parts of your code.


Table of Contents

  1. Why Choose Ulfberht?
  2. Gradle Dependency
  3. Modules
    1. Binding
    2. Providing
    3. Singletons
    4. Qualifiers
  4. Components
    1. Basics
    2. Child Components
    3. Scoping
  5. Putting Modules and Components to Use - Injection
  6. Runtime Dependencies
  7. Android
    1. ScopeOwners
    2. ViewModels

Why Choose Ulfberht?

I wrote Ulfberht as an experiment to see if I could make Dagger style dependency injection a bit easier, and quicker to pickup for newbies. I wanted something lightweight, with less Boilerplate code and more automation. I wanted something annotation-processor based rather than reflection-based, while still being written in Kotlin. And I wanted better built-in scoping support, especially on Android. This is the result.

You may be wondering what makes this library different than KOIN or other Kotlin "DI" libraries? Libraries like KOIN are just service locators - you need to build the dependency graph manually, filling in constructors, etc. Annotation processor based DI libraries handle this for you with code generation, so you don't need to write boilerplate and you don't need to use reflection.


Gradle Dependency

dependencies {
  implementation "com.afollestad:ulfberht:0.6.0"
  kapt "com.afollestad:ulfbert-processor:0.6.0"
}

Modules

A module is a class that gives instructions for dependency instantiation.

Binding

One way a module can instruct object instantiation is through binding. If you've used Dagger, this should be straightforward.

In this example below, we bind an interface with a concrete implementation. Whenever you inject Demo, you inject the DemoImpl implementation of it.

Taking this interface and implementation...

interface Demo {
  fun myMethod()
}

class DemoImpl : Demo {
  override fun myMethod() {
    ...
  }
}

...they can be bound in a @Module interface:

@Module
interface DemoModule {
  @Binds 
  fun demoClass(impl: DemoImpl): Demo
}

If DemoImpl itself had dependencies in its constructor, those must be bound or provided as well so that they can be injected too...

interface SomethingElse
class SomethingElseImpl : SomethingElse

interface Demo {
  fun myMethod()
}
class DemoImpl(val somethingElse: SomethingElse) : Demo {
  override fun myMethod() {
    ...
  }
}

...with a module setup like this:

@Module
interface DemoModule {
  @Binds
  fun demoClass(impl: DemoImpl): Demo
  
  @Binds
  fun somethingElse(impl: SomethingElseImpl): SomethingElse
}

Providing

Another way a module can instruct object instantiation is through providing. This should also be a familiar concept for Dagger users.

Providing is more flexible than binding. You tell the library how instantiation should happen, and what you provide does not need all of its constructor parameters to be injectable. Notice that a module which can use @Provides must be a abstract class rather than an interface.

class SomethingElse
class Demo(val somethingElse: SomethingElse)

@Module
abstract class DemoModule {
  @Provides 
  fun demoClass(): Demo {
    val somethingElse = SomethingElse()
    return Demo(somethingElse)
  }
}

@Provides methods can have parameters, which the library will fill from the dependency graph as well. This could be a dependency provided in another module or even another component's module.

class SomethingElse
class Demo(val somethingElse: SomethingElse)
 
@Module
interface DemoModule {
  @Provides 
  fun demoClass(somethingElse: SomethingElse): Demo {
    return Demo(somethingElse)
  }
  
  @Provides 
  fun somethingElse(): SomethingElse {
    return SomethingElse()
  }
}

Singletons

There's a @Singleton annotation that can be used to mark @Binds and @Provides methods. When it's used, a module in a specific component will hold the same instance of the provided object until the module is destroyed. If you were to use the same module in two different components, ComponentA and ComponentB, each component would have a separate singleton instance of what you're providing. They wouldn't share between each other.

@Module
interface DemoModule1 {
  @Binds @Singleton 
  fun demoClass1(impl: Demo1Impl): Demo1
}

@Module
abstract class DemoModule2 {
  @Provides @Singleton 
  fun demoClass2(): Demo2 {
    return Demo2Impl()
  }
}

Every time injection pulls Demo1 and Demo2, it will be the same cached instances.


Components

A component is a class that takes a set of modules, and knows how to inject what they collectively bind/provide into a target object.

Basics

A basic component looks like this:

@Component(modules = [DemoModule::class])
interface DemoComponent {
  fun inject(target: SomeClass)
}

You could include multiple items in the array of the @Component's modules parameter. You can also define a void (no return type) inject method for every class that the component can inject into.

Child Components

In a real application, you'd probably have a hierarchy of components. Components operate at a certain level - for an example you could have a component that's alive for the entire lifetime of the application, while you would have more short-lived components that are alive when specific screens of the application are. You build this hierarchy by assigning child components.

When you inject something at the bottom of the chain, you're able to inject things that are bound/provided throughout the chain all the way up to the top.


A component hierarchy is built by assigning children to components. The code below mimics the diagram above.

@Component(
  children = [Component2::class, Component3::class],
  modules = [Module1::class, Module2::class]
)
interface Component1

@Component(
  children = [Component4::class, Component5::class],
  modules = [Module3::class, Module4::class]
)
interface Component2

@Component(
  children = [Component6::class, Component7::class],
  modules = [Module5::class, Module6::class]
)
interface Component3

@Component(modules = [Module7::class, Module8::class])
interface Component4

@Component(modules = [Module9::class, Module10::class])
interface Component5

@Component(modules = [Module11::class, Module12::class])
interface Component6

@Component(modules = [Module13::class, Module14::class])
interface Component7

Scoping

In many applications, especially mobile applications, you generally do not want to keep things around for the entire lifecycle of the app.

Say you're on a login screen, and need to inject an authentication dependency -- you probably only need access to that object on the login screen. Once you leave, that authenticator should go away. Scoping allows you to achieve this easily.


Scope's are associated with components. When a scope is exited, components in that scope destroy themselves along with their modules, and anything that the modules may be storing. To scope a component, there's a simple parameter to add on the @Component annotation.

// Using constants is encouraged so you can share values throughout your app.
const val FIRST_SCOPE = "first scope"

@Component(
  scope = FIRST_SCOPE, 
  modules = [MyModule::class]
)
interface MyComponent

You can then retrieve an instance of this scope. There are a few things you can do with it:

val scope: Scope = getScope(FIRST_SCOPE)

// Hook into its lifecycle, which currently is just an exit event.
scope.addObserver(object : ScopeObserver {
  override fun onExit() {
    ...
  }
})

// You can tell the scope to exit, destroying its children.
scope.exit()

When you use the component<>() method to retrieve a component with a scope, that component is attached to its scope. There can be multiple components in a scope.

When you call exit() on that scope, every component attached to it is destroyed. All modules in those components go with them, along with any stored singletons amd child components. The next time you call component<>() for a destroyed component, a new instance is created.


If you use parenting and scoping together:

const val PARENT_SCOPE = "i'm a parent"

@Component(
  scope = PARENT_SCOPE, 
  children = [ChildComponent::class],
  modules = [Module1::class]
)
interface ParentComponent

@Component(modules = [Module2::class])
interface ChildComponent

// This would destroy both components
getScope(PARENT_SCOPE).exit()

Parent components will destroy all of their children (components and their modules) as well.


Putting Modules and Components to Use - Injection

To perform injection, you need to retrieve the component that's able to inject into your target.

@Component(...)
interface Component5 {
  fun inject(activity: MyActivity)
}

class MyActivity : AppCompatActivity() {
  @Inject lateinit var someDependency: NeededClass
  
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    // Perform injection, `SomeComponent` would have an inject() method defined for `MyActivity`
    component<Component5>().inject(this)
    
    // Use the injected dependency!
    someDependency.helloWorld()
  }
}

This code assumes that one of the modules going up the graph from Component5 can supply NeededClass with a @Binds or @Provides method.


Qualifiers

Qualifiers are simple identifiers that associate a type with a very specific bound or provided instance. A qualifier is a special type of annotation, which is defined like this:

@Qualifier
annotation class DemoQualifier1

@Qualifier
annotation class DemoQualifier2

You use it to mark @Binds and @Provides functions:

@Module
interface DemoModule1 {
  @Binds @Singleton @DemoQualifier1
  fun demoClass(impl: Demo1Impl): Demo1
}

@Module
abstract class DemoModule2 {
  @Provides @Singleton @DemoQualifier2
  fun demoClass(): Demo1 {
    return Demo1Impl()
  }
}

Then, you can mark constructor parameters with it...

class SomeInjectedClass(
  @DemoQualifier1 val someDependency: Demo1,
  @DemoQualifier2 val anotherDependency: Demo1
) {
  ...
}

...along with @Inject targets (the field: prefix on the annotation name is important in Kotlin):

class SomeClass {
  @Inject @field:DemoQualifier1
  lateinit var someDependency1: Demo1
  @Inject @field:DemoQualifier2 
  lateinit var someDependency2: Demo1

  init {
    component<SomeComponent>().inject(this)
  }
}

You will get two completely separate instances of Demo1, since two different qualifiers are being used. @Singleton was applied for demo purposes to show that it'll store two different instances. But even without that, you're providing two different things. This could be useful if you were providing primitives, like strings, or an interface for something like preferences. There's a lot of possibilities.


Runtime Dependencies

Sometimes your app may need to be able to inject something that is defined at runtime, something that cannot be constructed in a module. A good example of when this would be necessary is in an Android application, like if you needed to inject the Application context.

First, you tag constructor parameters or fields that need to be provided at runtime with a qualifier annotation, which is discussed in Qualifiers above.

@Qualifier
annotation class AppContext

@Qualifier
annotation class ApiKey

class StringRetriever(
  @AppContext val appContext: Context,
  @ApiKey val apiKey: String
) {
  fun getString(@IdRes res: Int): String {
    return appContext.resources.getString(res)
  }
}

At injection time, you pass mapped runtime dependencies into the component<>() method. They are available for injection until the component is destroyed, or its parents destroy it.

class LoginActivity : AppCompatActivity() {
  @Inject 
  lateinit var stringRetriever: StringRetriever  
  
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    component<LoginComponent>(
      AppContext::class to applicationContext,
      ApiKey::class to "hello, world!"
    ).inject(this)
  }
}

Runtime dependencies in a component are made available to all of the component's children too. In an Android application, providing the application context at the Application level will make it available to all Activities and Fragments that use child components.


Android

ScopeOwners

On Android, you can automatically attach scopes to LifecycleOwner's, such as:

  • Fragment (from androidx.app)
  • AppCompatActivity/FragmentActivity
  • androidx.lifecycle.ViewModel

(these all implement the LifecycleOwner interface)


First, setup your components and modules as you would normally:

object ScopeNames {
  const val LOGIN_SCOPE = "scope_login"
  const val MAIN_SCOPE = "scope_main"
}

@Component(
  children = [LoginComponent::class, MainComponent::class],
  modules = [AppModule::class]
)
interface AppComponent {
  fun inject(app: Application)
}

@Component(
  scope = ScopeNames.LOGIN_SCOPE,
  modules = [LoginModule::class]
)
interface LoginComponent {
  fun inject(activity: LoginActivity)
}

@Component(
  scope = ScopeNames.MAIN_SCOPE,
  modules = [MainModule::class]
)
interface MainComponent {
  fun inject(activity: MainActivity)
}

Then, you annotate your LifecycleOwner's with the ScopeOwner annotation:

@ScopeOwner(LOGIN_SCOPE)
class LoginActivity : AppCompatActivity() {
  
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    component<LoginComponent>().inject(this)
  }
}

Since LoginComponent is being injected, and because it's marked as being in LOGIN_SCOPE, it will automatically destroy itself when LoginActivity is destroyed.


ViewModels

On Android, injecting AndroidX ViewModel's is supported. You don't have to do anything special, just inject the ViewModel as you would inject anything else.

However, you can only inject a ViewModel into an androidx.fragment.app.Fragment or androidx.fragment.app.FragmentActivity (includes AppCompatActivity and descendants). Why? Internally, Ulfberht's generated code delegates through ViewModelProviders which must attach to an Activity or Fragment.

About

🗡️ A small but powerful & opinionated DI library. Written in Kotlin, and powered by annotation processing.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages