LoginSignup
6
2

Slack Circuitの`@CircuitInject`周りのドキュメントとコードを読むメモ

Posted at

Hookの仕組みや、Jetpack Composeを使うと、宣言的にもっといい感じにアプリ全体をよくできるはずです。
moleculeでも同じようなことができるのですが、実装によっては画面が裏に行っても、ViewModelが生きている間は生き続けてしまうという問題があります。
そこで Retaining beyond ViewModels の記事があり、Circuitを使うと、Viewと同じライフサイクルでEventを受け取って値を返す仕組みを実現でき、かつ、ViewModelと同じスコープで値を持つことができるということで、Circuitを見てみようと思いました。

Overview

UDF(Unidirectional Data Flow)とかの原則に従いて、Cash App’s Broadway architecture に影響されていることなどが書いてあり、以下があります。

Circuit’s core components are its Presenter and Ui interfaces.

CircuitのコアコンポーネントはPresenterとUi interface。

  1. A Presenter and a Ui cannot directly access each other. They can only communicate through state and event emissions.

PresenterとUiは互いに直接やり取りしない。stateとeventの排出によってのみやり取りできる。
(このあたりがピンとこない人は https://qiita.com/takahirom/items/99cfa342a00ba9a99ef8#unidirectional-data-flowudf%E3%81%AB%E3%82%88%E3%82%8B%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E7%AE%A1%E7%90%86%E3%82%92%E3%81%99%E3%82%8B このあたりを読んでも良いかも )

2. UIs are compose-first.

UIはCompose

3. Presenters are also compose-first. They do not emit Compose UI, but they do use the Compose runtime to manage and emit state.

PresenterもComposeを利用する。ComposeのUIを排出はしないが、Compose runtimeを使ってstateを排出する。

4. Both Presenter and Ui each have a single composable function.

PresenterとUiはそれぞれ一つのComposable関数を持つ。

5. In most cases, Circuit automatically connects presenters and UIs.

ほとんどのケースでCircuitは自動的にpresenterとUIをつなげる。

6. Presenter and Ui are both generic types, with generics to define the UiState types they communicate with.

PresenterとUIは両方とも通信するUiStateの型でGeneric typeになっている。

7. They are keyed by Screens. One runs a new Presenter/Ui pairing by requesting them with a given Screen that they understand.

PresenterとUiは画面によって紐づけられる。画面でPresenterとUiが要求されることによって新しいPresenterとUiが動く

いい感じですね。

https://slackhq.github.io/circuit/ より引用

Counterの例

この例がなんともむずいんですよね。。 :sweat_smile:

CounterScreen

@Parcelize
data object CounterScreen : Screen {
  data class CounterState(
    val count: Int,
    val eventSink: (CounterEvent) -> Unit,
  ) : CircuitUiState
  sealed interface CounterEvent : CircuitUiEvent {
    data object Increment : CounterEvent
    data object Decrement : CounterEvent
  }
}

これは通信の型を決めるものっぽいですね。UIからPresenterのイベントがCounterEvent。PresenterからUIへ渡すStateがCounterStateですね。

CounterPresenter

@CircuitInject(CounterScreen::class, AppScope::class)
@Composable
fun CounterPresenter(): CounterState {
  var count by rememberSaveable { mutableStateOf(0) }

  return CounterState(count) { event ->
    when (event) {
      CounterEvent.Increment -> count++
      CounterEvent.Decrement -> count--
    }
  }
}

Counter UI

@CircuitInject(CounterScreen::class, AppScope::class)
@Composable
fun Counter(state: CounterState) {
  Box(Modifier.fillMaxSize()) {
    Column(Modifier.align(Alignment.Center)) {
      Text(
        modifier = Modifier.align(CenterHorizontally),
        text = "Count: ${state.count}",
        style = MaterialTheme.typography.displayLarge
      )
      Spacer(modifier = Modifier.height(16.dp))
      Button(
        modifier = Modifier.align(CenterHorizontally),
        onClick = { state.eventSink(CounterEvent.Increment) }
      ) { Icon(rememberVectorPainter(Icons.Filled.Add), "Increment") }
      Button(
        modifier = Modifier.align(CenterHorizontally),
        onClick = { state.eventSink(CounterEvent.Decrement) }
      ) { Icon(rememberVectorPainter(Icons.Filled.Remove), "Decrement") }
    }
  }
}

CircuitInject とは

あー 上のカウンターの例で、CircuitInject でてきましたね。。あー難しいですね。なんだこれってなりますね。

ここに説明があるみたいです。
https://slackhq.github.io/circuit/code-gen/

まずPresenterからですが、下の上のCircuitInjectがContributesMultibindingに置き換わるっぽいですね。これはanvilのもので、Dagger Hiltも選べるみたいですね。

// 生成元のコード
@CircuitInject(HomeScreen::class, AppScope::class)
@Composable
fun HomePresenter(): HomeState { ... }

// 生成後のコード
@ContributesMultibinding(AppScope::class)
class HomePresenterFactory @Inject constructor() : Presenter.Factory { ... }

https://slackhq.github.io/circuit/code-gen/ より

えっと、HomeScreenの情報生成後のコードから消えたけど大丈夫?ってなりますね。 ちょっとわからなすぎるので、コード読みにいきましょう。
こういうのはテストを見るとわかりやすいです。ちゃんとHomeScreenは生成後のコードにいますね。

  @Test
  fun simplePresenterFunction_withObjectScreen() {
    assertGeneratedFile(
      sourceFile =
        kotlin(
          "TestPresenter.kt",
          """
          package test

          import com.slack.circuit.codegen.annotations.CircuitInject
          import androidx.compose.runtime.Composable

          @CircuitInject(HomeScreen::class, AppScope::class)
          @Composable
          fun HomePresenter(): HomeScreen.State {

          }
        """
            .trimIndent(),
        ),
      generatedFilePath = "test/HomePresenterFactory.kt",
      expectedContent =
        """
          package test

          import com.slack.circuit.runtime.CircuitContext
          import com.slack.circuit.runtime.Navigator
          import com.slack.circuit.runtime.presenter.Presenter
          import com.slack.circuit.runtime.presenter.presenterOf
          import com.slack.circuit.runtime.screen.Screen
          import com.squareup.anvil.annotations.ContributesMultibinding
          import javax.inject.Inject

          @ContributesMultibinding(AppScope::class)
          public class HomePresenterFactory @Inject constructor() : Presenter.Factory {
            override fun create(
              screen: Screen,
              navigator: Navigator,
              context: CircuitContext,
            ): Presenter<*>? = when (screen) {
              HomeScreen -> presenterOf { HomePresenter() }
              else -> null
            }
          }
        """
          .trimIndent(),
    )
  }

https://github.com/slackhq/circuit/blob/33b5e286248d08b9e3f43cc8df358654caf6abd6/circuit-codegen/src/test/kotlin/com/slack/circuit/codegen/CircuitSymbolProcessorTest.kt#L499 より

この中身のwhen文は後で見ていきますが、
でも次はContributesMultibindingが分からんってなりますね。

          @ContributesMultibinding(AppScope::class)
          public class HomePresenterFactory @Inject constructor() : Presenter.Factory

生成されたコードより

で、ContributesMultibindingを使うと https://github.com/square/anvil?tab=readme-ov-file#contributed-multibindings によると、
さらに以下のようなコードを生成します。

@Binds @IntoSet
abstract fun bindHomePresenterFactory(listener: HomePresenterFactory): Presenter.Factory

これにはIntoSetが使われていて、つまり、HomePresenterFactoryを作ってSetにいれます。

ってことなので、ContributesMultibindingにより、AppScopeにHomePresenterFactoryを紐づけているということは分かると思います。どう使われるんでしょうか。

以下のように使われているようです。

@ContributesTo(AppScope::class)
@Module
interface CircuitModule {
  @Multibinds fun presenterFactories(): Set<Presenter.Factory>

  @Multibinds fun viewFactories(): Set<Ui.Factory>

  companion object {
    @Provides
    fun provideCircuit(
      presenterFactories: @JvmSuppressWildcards Set<Presenter.Factory>,
      uiFactories: @JvmSuppressWildcards Set<Ui.Factory>,
    ): Circuit {
      return Circuit.Builder()
        .addPresenterFactories(presenterFactories)
        .addUiFactories(uiFactories)
        .build()
    }
  }
}

https://github.com/slackhq/circuit/blob/3187f88c03920ceecfc367fcd69906da77853d0e/samples/star/src/jvmCommonMain/kotlin/com/slack/circuit/star/di/CircuitModule.kt#L15 より

先程のIntoSetで入れたインスタンスを配布する@Multibindsがいて、それを受け取って更にCircuitのインスタンスをProvideするprovideCircuit()がいます。

CircuitInject がDaggerの配布関連で何をしているのかをまとめると?

@CircuitInject(HomeScreen::class, AppScope::class) をつけると、 @ContributesMultibinding(AppScope::class) が作られて(Circuit genのkspにより)、これが、@Binds @IntoSet に変換される(anvilのkspにより)ことで、配布時にSetにインスタンスをいれるようになります。そして、そのインスタンスを @Multibinds によリ配布(Daggerのkspにより)して、Circuitの中で使われるFactoryとして機能するわけです。

配布周り、難しいんだけど一言でいうと??

以下から

          @CircuitInject(HomeScreen::class, AppScope::class)
          @Composable
          fun HomePresenter(): HomeScreen.State {

          }

以下のインスタンスを作って、配布する。

          @ContributesMultibinding(AppScope::class)
          public class HomePresenterFactory @Inject constructor() : Presenter.Factory {
            override fun create(
              screen: Screen,
              navigator: Navigator,
              context: CircuitContext,
            ): Presenter<*>? = when (screen) {
              HomeScreen -> presenterOf { HomePresenter() }
              else -> null
            }
          }

PresenterFactory

配布周りがなんとなくわかったところで、この中身のwhen文を見ていくんですが、まあスクリーンを受け取って、それに対応するPresenterを返す。ですね。なければnullを返すと。

            override fun create(
              screen: Screen,
              navigator: Navigator,
              context: CircuitContext,
            ): Presenter<*>? = when (screen) {
              HomeScreen -> presenterOf { HomePresenter() }
              else -> null
            }

で次はこれがどうやって利用されるのか。が気になってくるわけです。

MainActivityからPresenterFactory.create()までの流れを追う

以下のサンプルを見ていきます。このサンプルではDaggerによって作られるところを自分で作っているようです。これのcreate()はOrderTacosPresenter()を作っているところになります。create()までの流れを追いましょう。
https://github.com/slackhq/circuit/blob/72350bf9334b243d11450fb179c3250c35a19469/samples/tacos/src/main/kotlin/com/slack/circuit/tacos/MainActivity.kt#L22

class MainActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()

    val circuit: Circuit =
      Circuit.Builder()
        .addPresenterFactory(buildPresenterFactory())
        .addUiFactory(buildUiFactory())
        .build()

    setContent {
      TacoTheme { CircuitCompositionLocals(circuit) { CircuitContent(OrderTacosScreen) } }
    }
  }
}

private fun buildPresenterFactory(): Presenter.Factory =
  Presenter.Factory { _, _, _ ->
    OrderTacosPresenter(
      fillingsProducer = FillingsProducerImpl(IngredientsRepositoryImpl),
      toppingsProducer = ToppingsProducerImpl(IngredientsRepositoryImpl),
      confirmationProducer = { details, _ -> confirmationProducer(details) },
      summaryProducer = { _, sink -> summaryProducer(sink) },
    )
  }

private fun buildUiFactory(): Ui.Factory =
  Ui.Factory { _, _ ->
    ui<OrderTacosScreen.State> { state, modifier -> OrderTacosUi(state, modifier) }
  }

MainActivity: CircuitContentを呼び出す

呼び出してます。

    setContent {
      TacoTheme { CircuitCompositionLocals(circuit) { CircuitContent(OrderTacosScreen) } }
    }

MainActivity: CircuitContentを呼び出す

呼び出してます。ただ、 CircuitCompositionLocalsにcircuitを渡していることで、PresenterFactoryとかをComposable関数内で使える余地を残しています。
また OrderTacosScreenを渡していることもポイントです

    val circuit: Circuit =
      Circuit.Builder()
        .addPresenterFactory(buildPresenterFactory())
        .addUiFactory(buildUiFactory())
        .build()

    setContent {
      TacoTheme { CircuitCompositionLocals(circuit) { CircuitContent(OrderTacosScreen) } }
    }

CircuitContent: Presenter.createの呼び出し

@Composable
internal fun CircuitContent(
  screen: Screen,
  modifier: Modifier,
  navigator: Navigator,
  circuit: Circuit,
  unavailableContent: (@Composable (screen: Screen, modifier: Modifier) -> Unit),
  context: CircuitContext,
  key: Any? = screen,
) {
  val eventListener = rememberEventListener(screen, context, factory = circuit.eventListenerFactory)
  DisposableEffect(eventListener, screen, context) { onDispose { eventListener.dispose() } }

  // ↓ ここで Presenterの取得
  val presenter = rememberPresenter(screen, navigator, context, eventListener, circuit::presenter)

  // ↓ ここで UIの取得
  val ui = rememberUi(screen, context, eventListener, circuit::ui)
...
}

https://github.com/slackhq/circuit/blob/44713592c494301e0a0b2fbf533687000325cfed/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt#L96 より

で、nextPresenterを呼び出して、

  @OptIn(InternalCircuitApi::class)
  public fun presenter(
    screen: Screen,
    navigator: Navigator,
    context: CircuitContext = CircuitContext(null).also { it.circuit = this },
  ): Presenter<*>? {
    return nextPresenter(null, screen, navigator, context)
  }

一個ずつ、渡されたpresenterFactoriesのcreateを呼んでみて、nullじゃなければ返します!

  public fun nextPresenter(
    skipPast: Presenter.Factory?,
    screen: Screen,
    navigator: Navigator,
    context: CircuitContext,
  ): Presenter<*>? {
...
    for (i in start until presenterFactories.size) {
      val presenter = presenterFactories[i].create(screen, navigator, context)
      if (presenter != null) {
        return presenter
      }
    }

    return null
  }

そういえば以下で思ったのですが、CashApp/Moleculeとの大きな違いとして、Circuitを使うと同じCompositionの中にPresenterがいることになりますね。これはかなり大きな違いになると思います。 (Chrisさんの記事でも触れられていましたが。)

  // ↓ ここで Presenterの取得
  val presenter = rememberPresenter(screen, navigator, context, eventListener, circuit::presenter)

  // ↓ ここで UIの取得
  val ui = rememberUi(screen, context, eventListener, circuit::ui)
...

メモまとめ

  • CircuitInject自体はPresenterやUIをScreenに紐づけて、Screenをキーにして、取り出せるようにするためのもの。であんまり怖くない。
  • CircuitのPresenterはComposeのライフサイクルで動く。
6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2