Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dagger2 - How to conditionally choose modules at runtime

I have a BIG Android app that needs to run different code for depending on the OS version, the manufacturer, and many other things. This app however needs to be a single APK. It needs to be smart enough at runtime to determine which code to use. Until now we have been using Guice but performance issues are causing us to consider migrating to Dagger. However, I've been unable to determine if we can achieve the same use case.

The main goal is for us have some code that runs at startup to provide a list of compatible Modules. Then pass that this list to Dagger to wire everything up.

Here is some pseudocode of the current implementation in Guice we want to migrate

import com.google.inject.AbstractModule;

@Feature("Wifi")
public class WifiDefaultModule extends AbstractModule {
  @Override
  protected void configure() {
    bind(WifiManager.class).to(WifiDefaultManager.class);
    bind(WifiProcessor.class).to(WifiDefaultProcessor.class);
  }
}

@Feature("Wifi")
@CompatibleWithMinOS(OS > 4.4)
class Wifi44Module extends WifiDefaultModule {
  @Override
  protected void configure() {
    bind(WifiManager.class).to(Wifi44Manager.class);
    bindProcessor();
  }

  @Override
  protected void bindProcessor() {
    (WifiProcessor.class).to(Wifi44Processor.class);
  }
}  

@Feature("Wifi")
@CompatibleWithMinOS(OS > 4.4)
@CompatibleWithManufacturer("samsung")
class WifiSamsung44Module extends Wifi44Module {
  @Override
  protected void bindProcessor() {
    bind(WifiProcessor.class).to(SamsungWifiProcessor.class);
}

@Feature("NFC")
public class NfcDefaultModule extends AbstractModule {
  @Override
  protected void configure() {
    bind(NfcManager.class).to(NfcDefaultManager.class);
  }
}

@Feature("NFC")
@CompatibleWithMinOS(OS > 6.0)
class Nfc60Module extends NfcDefaultModule {
  @Override
  protected void configure() {
    bind(NfcManager.class).to(Nfc60Manager.class);
  }
}

public interface WifiManager {
  //bunch of methods to implement
}

public interface WifiProcessor {
  //bunch of methods to implement
}

public interface NfcManager {
  //bunch of methods to implement
}

public class SuperModule extends AbstractModule {
  private final List<Module> chosenModules = new ArrayList<Module>();

  public void addModules(List<Module> features) {
    chosenModules.addAll(features);
  }

  @Override
  protected void configure() {
    for (Module feature: chosenModules) {
      feature.configure(binder())
    }
  }  
}

so at startup the app does this:

SuperModule superModule = new SuperModule();
superModule.addModules(crazyBusinessLogic());
Injector injector = Guice.createInjector(Stage.PRODUCTION, superModule);

where crazyBusinessLogic() reads the annotations of all the modules and determines a single one to use for each feature based on device properties. For example:

  • a Samsung device with OS = 5.0 will have crazyBusinessLogic() return the list { new WifiSamsung44Module(), new NfcDefaultModule() }
  • a Samsung device with OS = 7.0 will have crazyBusinessLogic() return the list { new WifiSamsung44Module(), new Nfc60Module() }
  • a Nexus device with OS = 7.0 will have crazyBusinessLogic() return the list { new Wifi44Module(), new Nfc60Module() }
  • and so on....

Is there any way to do the same with Dagger? Dagger seems to require you to pass the list of modules in the Component annotation.

I read a blog that seems to work on a small demo, but it seems clunky and the extra if statement and extra interfaces for components might cause my code to balloon.

https://blog.davidmedenjak.com/android/2017/04/28/dagger-providing-different-implementations.html

Is there any way to just use a list of modules returned from a function like we are doing in Guice? If not, what would be the closest way that would minimize rewriting the annotations and the crazyBusinessLogic() method?

like image 878
Paul Nogas Avatar asked Feb 19 '18 21:02

Paul Nogas


1 Answers

Dagger generates code at compile-time, so you are not going to have as much module flexibility as you did in Guice; instead of Guice being able to reflectively discover @Provides methods and run a reflective configure() method, Dagger is going to need to know how to create every implementation it may need at runtime, and it's going to need to know that at compile time. Consequently, there's no way to pass an arbitrary array of Modules and have Dagger correctly wire your graph; it defeats the compile-time checking and performance that Dagger was written to provide.

That said, you seem to be okay with a single APK containing all possible implementations, so the only matter is selecting between them at runtime. This is very possible in Dagger, and will probably fall into one of four solutions: David's component-dependencies-based solution, Module subclasses, stateful module instances, or @BindsInstance-based redirection.

Component dependencies

As in David's blog you linked, you can define an interface with a set of bindings that you need to pass in, and then supply those bindings through an implementation of that interface passed into the builder. Though the structure of the interface makes this well-designed to pass Dagger @Component implementations into other Dagger @Component implementations, the interface may be implemented by anything.

However, I'm not sure this solution suits you well: This structure is also best for inheriting freestanding implementations, rather than in your case where your various WifiManager implementations all have dependencies that your graph needs to satisfy. You might be drawn to this type of solution if you need to support a "plugin" architecture, or if your Dagger graph is so huge that a single graph shouldn't contain all of the classes in your app, but unless you have those constraints you may find this solution verbose and restrictive.

Module subclasses

Dagger allows for non-final modules, and allows for the passing of instances into modules, so you can simulate the approach you have by passing subclasses of your modules into the Builder of your Component. Because the ability to substitute/override implementations is frequently associated with testing, this is described on the Dagger 2 Testing page under the heading "Option 1: Override bindings by subclassing modules (don’t do this!)"—it clearly describes the caveats of this approach, notably that the virtual method call will be slower than a static @Provides method, and that any overridden @Provides methods will necessarily need to take all parameters that any implementation uses.

// Your base Module
@Module public class WifiModule {
  @Provides WifiManager provideWifiManager(Dep1 dep1, Dep2 dep2) {
    /* abstract would be better, but abstract methods usually power
     * @Binds, @BindsOptionalOf, and other declarative methods, so
     * Dagger doesn't allow abstract @Provides methods. */
    throw new UnsupportedOperationException();
  }
}

// Your Samsung Wifi module
@Module public class SamsungWifiModule {
  @Override WifiManager provideWifiManager(Dep1 dep1, Dep2 dep2) {
    return new SamsungWifiManager(dep1);  // Dep2 unused
  }
}

// Your Huawei Wifi module
@Module public class HuaweiWifiModule {
  @Override WifiManager provideWifiManager(Dep1 dep1, Dep2 dep2) {
    return new HuaweiWifiManager(dep1, dep2);
  }
}

// To create your Component
YourAppComponent component = YourAppComponent.builder()
    .baseWifiModule(new SamsungWifiModule())   // or name it anything
                                               // via @Component.Builder
    .build();

This works, as you can supply a single Module instance and treat it as an abstract factory pattern, but by calling new unnecessarily, you're not using Dagger to its full potential. Furthermore, the need to maintain a full list of all possible dependencies may make this more trouble than it's worth, especially given that you want all dependencies to ship in the same APK. (This might be a lighter-weight alternative if you need certain kinds of plugin architecture, or you want to avoid shipping an implementation entirely based on compile-time flags or conditions.)

Module instances

The ability to supply a possibly-virtual Module was really meant more for passing module instances with constructor arguments, which you could then use for choosing between implementations.

// Your NFC module
@Module public class NfcModule {
  private final boolean useNfc60;

  public NfcModule(boolean useNfc60) { this.useNfc60 = useNfc60; }

  @Override NfcManager provideNfcManager() {
    if (useNfc60) {
      return new Nfc60Manager();
    }
    return new NfcDefaultManager();
  }
}

// To create your Component
YourAppComponent component = YourAppComponent.builder()
    .nfcModule(new NfcModule(true))  // again, customize with @Component.Builder
    .build();

Again, this doesn't use Dagger to its fullest potential; you can do that by manually delegating to the right Provider you want.

// Your NFC module
@Module public class NfcModule {
  private final boolean useNfc60;

  public NfcModule(boolean useNfc60) { this.useNfc60 = useNfc60; }

  @Override NfcManager provideNfcManager(
      Provider<Nfc60Manager> nfc60Provider,
      Provider<NfcDefaultManager> nfcDefaultProvider) {
    if (useNfc60) {
      return nfc60Provider.get();
    }
    return nfcDefaultProvider.get();
  }
}

Better! Now you don't create any instances unless you need them, and Nfc60Manager and NfcDefaultManager can take arbitrary parameters that Dagger supplies. This leads to the fourth solution:

Inject the configuration

// Your NFC module
@Module public abstract class NfcModule {
  @Provides static NfcManager provideNfcManager(
      YourConfiguration yourConfiguration,
      Provider<Nfc60Manager> nfc60Provider,
      Provider<NfcDefaultManager> nfcDefaultProvider) {
    if (yourConfiguration.useNfc60()) {
      return nfc60Provider.get();
    }
    return nfcDefaultProvider.get();
  }
}

// To create your Component
YourAppComponent component = YourAppComponent.builder()
    // Use @Component.Builder and @BindsInstance to make this easy
    .yourConfiguration(getConfigFromBusinessLogic())
    .build();

This way you can encapsulate your business logic in your own configuration object, let Dagger provide your required methods, and go back to abstract modules with static @Provides for the best performance. Furthermore, you don't need to use Dagger @Module instances for your API, which hides implementation details and makes it easier to move away from Dagger later if your needs change. For your case, I recommend this solution; it'll take some restructuring, but I think you'll wind up with a clearer structure.

Side note about Guice Module#configure(Binder)

It's not idiomatic to call feature.configure(binder()); please use install(feature); instead. This allows Guice to better describe where errors occur in your code, discover @Provides methods in your Modules, and to de-duplicate your module instances in case a module is installed more than once.

like image 155
Jeff Bowman Avatar answered Nov 16 '22 01:11

Jeff Bowman