KStateMachine / kstatemachine

KStateMachine is a powerful Kotlin Multiplatform library with clean DSL syntax for creating complex state machines and statecharts driven by Kotlin Coroutines.
https://kstatemachine.github.io/kstatemachine/
Boost Software License 1.0
358 stars 21 forks source link

Missing listener which reports state changes in an "atomic" way #54

Closed dimsuz closed 1 year ago

dimsuz commented 1 year ago

I have the following sample state machine:

  val machine = createStateMachine(name = "app") {
    initialState("init")
    state("main", childMode = ChildMode.PARALLEL) {
      state("main_screen") {
        initialState("idle")
      }
      state("bottom_bar") {
        initialState("bar_visible")
        state("bar_hidden")
      }
      state("bottom_sheet") {
        initialState("sheet_hidden")
        state("search")
        state("routing")
        state("bookmarks")
        state("more")
      }
    }

and the following listener:

  machine.addListener(object : StateMachine.Listener {
    override fun onStateChanged(newState: IState) {
      println("active states:\n${machine.activeStates().joinToString(",")}")
    }
  })

There are few problems with it. Before I list them I want to say that I think they are caused by the fact that this listener should simply have a different name, something like onStateEntered, because a "state change" usually means the machine arriving at the new state (or set of states) and is usually called after full transition has happened.

From the current name I expected it to behave as "active state change listener" and had the following problems:

  1. When entering the main state this listener reports 3 separate state changes:
    • change to main_screen.idle
    • change to bottom_bar.bar_visible
    • change to bottom_sheet.sheet.hidden But the parallel state change is one change, i.e machine goes from init state to all 3 parallel states as one transaction. I mean internally it may do several onEntry in order, but to the user this should be reported as a single state change (it just so happens that the next state consists of 3 sub-states, but it's still one state);
  2. Also when used in non-parallel states, but with child states, this listener reports changes to "compound states", but it should only report "leaf" states. That is if some event changes state from parent1.childA to parent1.parent2.parent3.childB, listener will be called with arguments parent2, parent3, childB while the actual "finalized" state changed only once, because single event moves machine from one state to another (=1 state change) and this listener should be called only once with argument childB. At least this is how all libs that I've used behave...
  3. It has only one argument which doesn't "feel" right in presence of parallel state feature, I'd expect active states set to be passed there...

Perhaps one solution to this is to rename the existing listener somehow, but I'd also like to be able to use the actual state change listener implemented sometime.

dimsuz commented 1 year ago

Oh, I have also found Listener.onTransition but it seems it is called before transition is finished and machine arrives to a new state. I miss a listener method which is called after maсhine is fully transitioned into a new set of active states...

nsk90 commented 1 year ago

Yes I agree those callbacks are looking outdated, I added them when there was no HSM support in mind. I will fix it soon.

dimsuz commented 1 year ago

Awesome, thank you!

nsk90 commented 1 year ago

renamed listener onStateChanged to onStateEntry, added new onTransitionComplete machine listener method that is triggered after transition is complete, with a set of currently active states. will be released in v0.16.0. Please reopen if this is not enough.

dimsuz commented 1 year ago

This is great, thank you! Will test it once release comes out!

dimsuz commented 1 year ago

I see that jetpack repo has 0.16.0, but maven central is still on 0.15.0 (reporting this in case it got out of sync somehow).

dimsuz commented 1 year ago

I played with the new release (thanks!) and I found one feature I still miss :) Let me describe a usecase:

When we have sub-states and they have a finalDataState, when it is reached, I want to be somehow able to extract the data from FinishedEvent and depending on this data move to some other state in parent.

Currently FinishedEvent is does not have a data field. In xstate.js IIRC all events have "payload" field. In KStateMachine this is explicit: Eventand DataEvent. Maybe FinishedEvent can be data event? Or would this be not the right solution... I'm not sure, wanted to describe the usecase, maybe you'll be able to think of a nice solution.

Here is the sample of what I have tried to do:

data class Ev(override val data: Int) : DataEvent<Int>

fun main() {
  val m = createStateMachine {
    val s2 = state("s2")
    val s3 = state("s3")

    initialState("s1") {
      val childFinal = finalDataState<Int>("child_final")

      initialState("child_first") {
        dataTransition<Ev, Int> {
          targetState = childFinal
        }
      }

      transitionOn<FinishedEvent> {
        targetState = {
          // val data = event.data <-- cannot be done, FinishedEvent doesn't have 'data'

          // Then for some reason I thought that maybe event.state will be `childFinal`
          // and I can extract data from there.
          // this compiles, but crashes at runtime, 
          // because event.state is "s1" actually (and this is correct)
          val data = (event.state as DataState<Int>).data
          if (data == 3) s2 else s3
        }
      }
    }
  }

  m.processEvent(Ev(3)) // expecting to go to s2
  // OR
  m.processEvent(Ev(5)) // expecting to go to s3
}

If this is something which can be improved, I can create a separate issue with the description above, if you approve.

nsk90 commented 1 year ago

I see that jetpack repo has 0.16.0, but maven central is still on 0.15.0 (reporting this in case it got out of sync somehow).

I will publish to maven central on next week

dimsuz commented 1 year ago

In xstate.js IIRC all events have "payload" field

Checked their manual and actually for done events they have .data field which contain the data which was contained in .data property of the final state.

SCMXL has something they call donedata which is similar to this (I guess).

EDIT Oops, I keep calling "final" states "finished" states, sorry.

nsk90 commented 1 year ago

Try this workaround for your sample:

                targetState = {
                    val activeChild = event.state.activeStates().single()
                    val data = (activeChild as DataState<Int>).data
                    if (data == 3) s2 else s3
                }

It is easy to get data from DataState and put it into FinishedState at the moment of generation on library side. But I am not sure that it will work without casts on client side. (at least with current design)

Yes, let's create new issue. I think it can be done some way.