Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Concept of separating update cycles. Again... #678

Closed
vshapenko opened this issue Feb 24, 2020 · 2 comments
Closed

Concept of separating update cycles. Again... #678

vshapenko opened this issue Feb 24, 2020 · 2 comments
Labels
t/discussion A subject is being debated

Comments

@vshapenko
Copy link

Hello! I am having a working concept of separating views update cycles without writing tons of boilerplate DUs. Code is here ,any appreciation is welcome. Concept is based on "pushing" of messages to main loop cycle through unit->unit function. @TimLariviere ,what do you think about such way of simplification?

@TimLariviere
Copy link
Member

@vshapenko That's interesting. Are you trying to have one "program" per page?

I kind of did a similar thing in #677
I wanted different samples in the AllControls project to be independent of one another but still have only one application root.

So I declared a typed record for representing a single Sample (with its own init, update, view).

type Sample<'Msg, 'CmdMsg, 'ExternalMsg, 'Model> =
    { Title: string
      Init: unit -> 'Model
      Update: 'Msg -> 'Model -> 'Model * 'CmdMsg list * 'ExternalMsg option
      View: 'Model -> ('Msg -> unit) -> ViewElement
      MapToCmd: 'CmdMsg -> Cmd<'Msg> }

(Note: I wanted to support the ExternalMsg and CmdMsg patterns also)

This allowed me to register my samples like that:

// Sample 1
{ Title = "RefreshView"
  Init = Controls.RefreshView.init
  Update = Controls.RefreshView.update
  View = Controls.RefreshView.view
  MapToCmd = Controls.RefreshView.mapToCmd }

// Sample 2
{ Title = "SkiaSharp"
  Init = Extensions.SkiaSharp.init
  Update = Extensions.SkiaSharp.update
  View = Extensions.SkiaSharp.view
  MapToCmd = Extensions.SkiaSharp.mapToCmd }

But since they have different types and I wanted to store them in a single list, I had to downcast them to obj. So I created another type for that - replacing types with obj - and a boxing function:

type SampleDefinition =
    { Title: string
      Init: unit -> obj
      Update: obj -> obj -> obj * obj list * obj option
      View: obj -> (obj -> unit) -> ViewElement
      MapToCmd: obj -> Cmd<obj> }

let boxSampleDefinition (sample: Sample<'Msg, 'CmdMsg, 'ExternalMsg, 'Model>): SampleDefinition =
    { Title = sample.Title
      Init = sample.Init >> box
      Update = fun msg model ->
          let newModel, cmdMsgs, externalMsg = sample.Update (unbox msg) (unbox model)
          (box newModel), (cmdMsgs |> List.map box), (externalMsg |> Option.map box)
      View = fun model dispatch ->
          sample.View (unbox model) (fun msg -> dispatch (unbox msg))
      MapToCmd = fun cmdMsg ->
          sample.MapToCmd (unbox cmdMsg) |> Cmd.map box }

Notice the usage of box/unbox, that's how it goes from obj to 'T and the other way around.

That way, I was able to register them all in a single list

let Samples = [
    // Sample 1
    ({ Title = "RefreshView"
       Init = Controls.RefreshView.init
       Update = Controls.RefreshView.update
       View = Controls.RefreshView.view
       MapToCmd = Controls.RefreshView.mapToCmd } |> boxSampleDefinition)

    // Sample 2
   ({ Title = "SkiaSharp"
      Init = Extensions.SkiaSharp.init
      Update = Extensions.SkiaSharp.update
      View = Extensions.SkiaSharp.view
      MapToCmd = Extensions.SkiaSharp.mapToCmd } |> boxSampleDefinition)
]

Then I had to implement the application root program to handle that list.

So first off, I needed a way to represent the current state of a single sample that could be stored in a list with the other samples states.

// Store the sample definition (init, update, view) with its current model
type SampleState =
    { Definition: SampleDefinition
      Model: obj }

Then the App implementation was rather straightforward. The samples are only a list of SampleState in a NavigationPage.

module App =
    type Msg =
        | SampleMsg of obj // One sample sent a Msg
        | NavigateToRequested of Node // One sample requested to navigate to another sample
        | NavigationPopped // The user tapped the back button

    // The App model stores the stack of currently running samples (in my example, I had intermediate pages)
    type Model =
        { SampleStates: SampleState list }

    let init () =
        // Get the first sample of the registered list. It will be our home
        let definition = Samples.head

        // Create a state for it
        let sampleState =
            { Definition = definition
              Model = definition.Init() }

        // Store that state in the app's model
        { SampleStates = [sampleState] }, []

    // The ExternalMsg pattern is supported so there could be inter-samples interactions. Like navigation for instance
    let mapExternalMsg (externalMsg: obj option) =
        match externalMsg with
        | Some (:? RefreshView.ExternalMsg as msg) -> // Match on the ExternalMsg type to determine from which sample it comes
            match msg with
            | RefreshView.ExternalMsg.NavigateToSkiaSharp ->
                let skiaSharpDefinition = Samples |> List.... // Do a lookup to find the SkiaSharp registration
                Cmd.ofMsg (NavigateToRequested skiaSharpDefinition)
        | _ ->
            Cmd.none

    let update msg model =
        match msg with
        | SampleMsg sampleMsg ->

            // Received a Msg from a Sample, so we need to call the appropriate `update` function. Here I go with the assumption only the most recent sample sends Msgs so I take the head (list reversed).
            match model.SampleStates with
            | [] -> model, []
            | sampleState::rest ->
                let newSampleModel, newCmdMsgs, externalMsg = sampleState.Definition.Update sampleMsg sampleState.Model
                let newSampleState = { sampleState with Model = newSampleModel }
                let newCmd = newCmdMsgs |> List.map (sampleState.Definition.MapToCmd >> (Cmd.map SampleMsg)) |> Cmd.batch
                let newExternalCmd = mapExternalMsg externalMsg

                // Replace the head with a new SampleState with the updated sample's model
                { model with SampleStates = newSampleState :: rest }, Cmd.batch [newCmd; newExternalCmd]

        | NavigateToRequested definition ->
            let sampleState =
                { Definition = definition
                  Model = definition.Init() }
            { model with SampleStates = sampleState :: model.SampleStates }, []

        | NavigationPopped ->
            { model with SampleStates = model.SampleStates.Tail }, []

    let view model dispatch =
        View.NavigationPage(
            popped = (fun _ -> dispatch NavigationPopped),
            pages = [
                for sampleState in List.rev model.SampleStates ->
                    sampleState.Definition.View sampleState.Model (SampleMsg >> dispatch)
            ]
        )

type App () as app = 
    inherit Application ()

    let runner = 
        Program.mkProgram App.init App.update App.view
        |> Program.withConsoleTrace
        |> XamarinFormsProgram.run app

So, of course it takes a lot of assumptions on how the app should behave, but it's a pretty generic way of handling multiple independent "programs" in a single app.

Also, the pattern exhibited in FabulousContacts, despite being verbose, has the advantage to prevent exceptions like InvalidCastException when going from obj to 'T when the type was in fact 'U...

@TimLariviere TimLariviere added the t/discussion A subject is being debated label Feb 27, 2020
@vshapenko
Copy link
Author

@TimLariviere , in this case i am trying to avoid all these complex DU's of messages or boxing (which is akka way). The main idea is that i have separate update cycles, one for each model, and each cycle has inner model state:

let create<'T, 'TMessage>
    (defaultValue: 'T,
     init: ('TMessage -> unit) -> IDisposable list,
     update: 'TMessage -> 'T -> 'T * Cmd<'TMessage>,
     view: 'T -> ('TMessage -> unit) -> ViewElement)
    (onBackButtonPressed: ('T -> ('TMessage -> unit) -> bool) option) =

    let mutable parentDispatch = fun (x: AppMessage )-> printfn "fake dispatch"
    let mutable subs = List.empty<IDisposable>

    let mutable state = defaultValue

    let inbox = MailboxProcessor.Start(fun b->
        let rec loop ()= async {
            let! msg = b.Receive()
            match msg with
            | Msg msg ->
               let dispatch msg =
                    b.Post (Msg msg)
                    
               let f()=
                     let newState,cmd = update msg state
                     state<-newState
                     for c in cmd do
                        b.Post (Process (c,dispatch))
                        
               parentDispatch (AppMessage.Dummy f)
               return! loop ()

            | Process (f,arg) ->                 
                f arg
                return! loop ()
                
            return! loop ()
        }
        loop ()
        )
    let disp msg = inbox.Post(Msg msg)

    let backPressed: (unit -> bool) option =
      
      match onBackButtonPressed with
      | Some x -> Some <| fun _ -> x state disp
      | None -> None

    { View = fun () -> view state disp
      Init = fun () ->
       subs |> List.iter (fun x -> x.Dispose())
       subs <- init disp
      OnBackButtonPressed = backPressed
      Dispose = fun ()->
         subs|> Seq.iter (fun x-> x.Dispose())
              
      SetDispatch  = fun d->parentDispatch<-d
    }

As we see, we have a create function, which is a 'glue' for our app. There are three important components in there :

  1. mailbox processor, which schedules operations order on main loop (this is needed for correct command execution order and message processing)

  2. mutable state of model, which keeps the page state up to date

  3. link to main app dispatch. This is needed to 'wrap' a model update cycle into main loop.

Also we have subs, which are for subscribing for internal events.

init function invokes each time we show the page. It should have all needed logic (i.e. update state by getting data from db) before showing the page.

I think, such(or similar) implementation could be built in into Program.

Pros are:
child pages do not care about main loop cycle and messages and rely on their own message types
no need to write dispatch composition etc, we just add new page with init, update, view and it works like a charm

Cons are:
We cannot track a type of child message in main loop, also we are losing possibility to push messages for other pages (cause we do not have SuperMessage type).

I am using such code in production (messenger app), and it really speeds up the process of app development / refactoring. So i hope it also can be used to improve development experience when using Fabulous

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
t/discussion A subject is being debated
Projects
None yet
Development

No branches or pull requests

2 participants