如何使用 Dagger 2 将服务注入 JavaFX 控制器

How to inject services into JavaFX controllers using Dagger 2

JavaFX 本身有一些 DI 方法允许在 XML 描述的 UI 和控制器之间进行绑定:

<Pane fx:controller="foo.bar.MyController">
  <children>
    <Label fx:id="myLabel" furtherAttribute="..." />
  </children>
</Pane>

Java 端看起来像这样:

public class MyController implements Initializable {

    @FXML private Label myLabel;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        // FXML-fields have been injected at this point of time:
        myLabel.setText("Hello world!");
    }

}

要实现这一点,我不能只创建 MyController 的实例。相反,我不得不请 JavaFX 为我做事:

FXMLLoader loader = new FXMLLoader(MyApp.class.getResource("/fxml/myFxmlFile.fxml"), rb);
loader.load();
MyController ctrl = (MyController) loader.getController();

到目前为止,还不错

但是,如果我想使用 Dagger 2 将一些非 FXML 依赖项注入到此控制器 class 的构造函数中,我会遇到问题,因为我无法控制实例化过程,如果我使用 JavaFX.

public class MyController implements Initializable {

    @FXML private Label myLabel;

    /*
    How do I make this work?

    private final SomeService myService;

    @Inject
    public MyController(SomeService myService) {
        this.myService = myService;
    }
    */

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        // FXML-fields have been injected at this point of time:
        myLabel.setText("Hello world!");
    }

}

有一个 API 看起来很有前途:loader.setControllerFactory(...); 也许这是一个很好的起点。但是我对这些库没有足够的经验,不知道如何解决这个问题。

自定义 ControllerFactory 将需要构建仅在运行时已知的某些类型的控制器。这可能如下所示:

T t = clazz.newInstance();
injector.inject(t);
return t;

这对于像 Guice 这样的大多数其他 DI 库来说完全没问题,因为它们只需要在依赖关系图中查找 t 类型的依赖关系。

Dagger 2 在编译时解析依赖关系。它最大的特点同时也是最大的问题:如果一个类型只在运行时已知,编译器就无法区分 inject(t) 的调用。可以是 inject(Foo foo)inject(Bar bar).

(这也不适用于 final 字段,因为 newInstance() 调用默认构造函数)。


好的,没有通用类型。让我们看看第二种方法:首先从 Dagger 获取控制器实例,然后将其传递给 FXMLLoader。

我使用了 Dagger 中的 CoffeeShop 示例并对其进行了修改以构建 JavaFX 控制器:

@Singleton
@Component(modules = DripCoffeeModule.class)
interface CoffeeShop {
    Provider<CoffeeMakerController> coffeeMakerController();
}

如果我得到一个 CoffeeMakerController,它的所有字段都已经注入,所以我可以轻松地在 setController(...):

中使用它
CoffeeShop coffeeShop = DaggerCoffeeShop.create();
CoffeeMakerController ctrl = coffeeShop.coffeeMakerController().get();

/* ... */

FXMLLoader loader = new FXMLLoader(fxmlUrl, rb);
loader.setController(ctrl);
Parent root = loader.load();
Stage stage = new Stage();
stage.setScene(new Scene(root));
stage.show();

我的 FXML 文件不能包含 fx:controller 属性,因为加载程序会尝试构建一个控制器,这当然会与我们的 Dagger 提供的控制器发生冲突。

完整示例可在 GitHub

上找到

或者你可以这样做:

...

  loader.setControllerFactory(new Callback<Class<?>, Object>() {
     @Override
     public Object call(Class<?> type) {

        switch (type.getSimpleName()) {
           case "LoginController":
              return loginController;
           case "MainController":
              return mainController;
           default:
              return null;
        }
     }
  });

...

如@Sebastian_S 所述,基于反射的控制器工厂是不可能的。然而,调用 setController 并不是唯一的方法,我实际上更喜欢这种 setControllerFactory 方法,因为它不会破坏工具(例如 IntelliJ 的 XML 检查),但必须明确列出所有 类 绝对是一个缺点.

这对很多人来说可能很久以前就解决了。我不喜欢此处描述的解决方案,因为它依赖于 class 名称或对简洁设计的反思。我写了一个有点不同的,在我看来更易于维护。

它的要点是使用 Dagger 创建注入到 Stage 中的场景。这是我的申请 class

CameraRemote context;

public static void main(String[] args) {
    launch(args);
}

public SimpleUI() {
    context = DaggerCameraRemote.builder().build();
}

@Override
public void start(Stage stage) throws IOException {
    stage.setTitle("Remote Control");
    stage.setScene(context.mainFrame());
    stage.show();
}

我的 Dagger 2 模块中有加载 fxml 和自定义控制器的逻辑,即注入 SsdpClient

@Provides
public static Scene provideMainScene(SsdpClient ssdpClient) {
    try {
        FXMLLoader loader = new FXMLLoader(CameraModule.class.getResource("/MainFrame.fxml"));
        Parent root;
        root = loader.load();
        MainController controller = (MainController) loader.getController();
        controller.setClient(ssdpClient);
        return new Scene(root, 800, 450);
    } catch (IOException e) {
        throw new RuntimeException("Cannot load MainFrame.fxml", e);
    }
}

我可以进一步拆分 Parent 实例的创建。它没有在其他任何地方使用,我妥协了。

感谢来自@Sebastian_S的Map multibinding mechanism提示,我已经设法使用Map<Class<?>, Provider<Object>>进行自动控制器绑定,将每个控制器映射到它的class。

在模块中将所有控制器收集到名为 "Controllers" 的地图中,并带有相应的 Class 键

@Module
public class MyModule {

    // ********************** CONTROLLERS **********************
    @Provides
    @IntoMap
    @Named("Controllers")
    @ClassKey(FirstController.class)
    static Object provideFirstController(DepA depA, DepB depB) {
        return new FirstController(depA, depB);
    }

    @Provides
    @IntoMap
    @Named("Controllers")
    @ClassKey(SecondController.class)
    static Object provideSecondController(DepA depA, DepC depC) {
        return new SecondController(depA, depC);
    }
}

然后在Component中,我们可以通过它的名字得到这个Map的一个实例。此映射的值类型应为 Provider<Object>,因为我们希望在每次 FXMLLoader 需要时获取控制器的新实例。

@Singleton
@Component(modules = MyModule.class)
public interface MyDiContainer {
    // ********************** CONTROLLERS **********************
    @Named("Controllers")
    Map<Class<?>, Provider<Object>> getControllers();
}

最后,在您的 FXML 加载代码中,您应该设置新的 ControllerFactory

MyDiContainer myDiContainer = DaggerMyDiContainer.create()
Map<Class<?>, Provider<Object>> controllers = myDiContainer.getControllers();

FXMLLoader loader = new FXMLLoader();
loader.setControllerFactory(type -> controllers.get(type).get());