LoginSignup
2
3

More than 5 years have passed since last update.

ElmのMVU(MVC):自動履歴保存を付けてみる

Last updated at Posted at 2015-02-01

今回、文字を入力して、リストを作ったり消したりできるアプリに、
recode_1.gif

行動毎の自動履歴保存機能をつけてみる。(ちょっとレンジの挙動が怪しいが)

recode_3.gif

MVUとは?


      channel     -------------------→    channel
        ↑ send+merge                         ↓ subscribe      
main = View ---------- fopdp update ---------
             1 : 1          ↕          
                           Model 

 mainの前がmain = view <~ modelとなり、このmodelを作るのにmodel = foldp update initModel (subscribe appChannel)というふうに、全てのユーザーのアクションをまとめたChannelがあり、update内のcase式でアクションごとの処理を書く。みたいな感じに書くスタイル。
Elm使ってるとだいたいこうなる。

 この形はChannelとMessageがイベントとハンドラに当たり、それを使ってViewとModelが通信するのでMVCと言える。そこにElmだと、GUI用のFRPなので、foldpとupdate関数が現れるので、MVU(Model View Update)なんて呼んだりする。

公式のTODOアプリであるElm-tomvcでも現在このスタイルで書かれている。

まず機能を付ける前のコードのMVUを眺めてみる。

module App where

import Text(asText)
import Html(Html,div,toElement,text,input,Attribute,li)
import Html.Attributes(class,type',value,style,id,max)
import Html.Attributes as Attri
import Html.Events(on, targetValue,onKeyDown,keyCode)
import Signal(..)
import List(tail)
import List
import String
import Array
import Array(Array)
import Time(..)
import Json.Decode as Json
import Result (..)
import Debug

---Model
type alias NameList = List String
type alias Model = { input :String 
                    ,nameList : NameList}

initState : Model
initState = {input = "", nameList = []}


type Actions 
    = None 
    | Input String 
    | Add String 
    | Delete () 


----channel

appChannel : Channel Actions
appChannel = channel None


---update

model : Signal Model
model = foldp update initState (subscribe appChannel)

update : Actions -> Model -> Model
update action state =

 case action of 

            Input str ->  {state| input <- str }

            Add str -> {state| input <- ""
                             , nameList <- state.nameList ++ [str]}

            Delete x ->if List.isEmpty state.nameList then state
                       else {state | nameList <- tail state.nameList}

            _ -> state

---view

main : Signal Html
main = view <~ model


view : Model -> Html
view state = 
    div []
        [ textbox state.input
        , text state.input
        , div [] (List.map listHtml state.nameList)
        ]

listHtml : String -> Html
listHtml str = li [] [text str]

textbox : String -> Html
textbox str = input [value str
                    ,id "nameInput"
                    ,on "input" targetValue (send appChannel << Input)
                    ,onBackEnter (send appChannel)]
                    []

onBackEnter : (Actions -> Message) -> Attribute
onBackEnter message = on "keydown" (eventObjDecoder) (message)

eventObjDecoder : Json.Decoder Actions
eventObjDecoder = 
  Json.customDecoder
    (Json.object2 (,) 
        keyCode 
        targetValue)
    (\(num, str)-> if| num == 8 && String.isEmpty str -> Ok (Delete ())
                     | num == 13 && (not <|String.isEmpty str) -> Ok (Add str)
                     | otherwise -> Err "")



Model

Modelはアプリケーション内で使う全てのデータ型を定義する。

入力されている時の文字や、それを保持するリストの型を定義している。
type Actions 型はユーザーの動作を型で表現している。


---Model
type alias NameList = List String
type alias Model = { input :String 
                    ,nameList : NameList}

initState : Model
initState = {input = "", nameList = []}


type Actions 
    = None 
    | Input String 
    | Add String 
    | Delete () 

形式的ぃ

Channel

Channelはひとつ。それぞれのInputからchannelにsendすると、自動でmergeされる。

----channel

appChannel : Channel Actions
appChannel = channel None

View

表示や画面を作るview関数のこと。
Modelを受け取ったら、Html(またはElement)を返す。



---view

main : Signal Html
main = view <~ model


view : Model -> Html
view state = 
    div []
        [ textbox state.input
        , text state.input
        , div [] (List.map listHtml state.nameList)
        ]

listHtml : String -> Html
listHtml str = li [] [text str]

textbox : String -> Html
textbox str = input [value str
                    ,id "nameInput"
                    ,on "input" targetValue (send appChannel << Input)
                    ,onBackEnter (send appChannel)]
                    []

onBackEnter : (Actions -> Message) -> Attribute
onBackEnter message = on "keydown" (eventObjDecoder) (message)

eventObjDecoder : Json.Decoder Actions
eventObjDecoder = 
  Json.customDecoder
    (Json.object2 (,) 
        keyCode 
        targetValue)
    (\(num, str)-> if| num == 8 && String.isEmpty str -> Ok (Delete ())
                     | num == 13 && (not <|String.isEmpty str) -> Ok (Add str)
                     | otherwise -> Err "")

Update

foldpでユーザーからのSignalがあると、Modelを更新する。
update関数ではcase式でそれぞれのSignalの処理をわける。アプリケーションの集合路。


---update

model : Signal Model
model = foldp update initState (subscribe appChannel)

update : Actions -> Model -> Model
update action state =

 case action of 

            Input str ->  {state| input <- str }

            Add str -> {state| input <- ""
                             , nameList <- state.nameList ++ [str]}

            Delete x ->if List.isEmpty state.nameList then state
                       else {state | nameList <- tail state.nameList}

            _ -> state

ここから、MVUに機能を付け足して自動履歴保存を付けてみる。

Modelを新しいデータ型にする

GUIの現在の状態をArrayに逐次、順番に保存することにする。
新しいModel型になった。

あと動作を表すActions型に、履歴からとりだす動作(ArchiveSelect Int)を付け足した。


type alias NameList = List String
type alias State = { input :String 
                    ,nameList : NameList}

type alias Model = {state:State
                   ,archive:Array State
                   ,current :Int}

initState : State
initState = {input = "", nameList = []}

initModel : Model
initModel = { state = initState
            , archive = Array.fromList [initState]
            , current = 0}

type Actions 
    = None 
    | Input String 
    | Add String 
    | Delete () 
    | ArchiveSelect Int

Channel

channelは変わらず1つ。

appChannel : Channel Actions
appChannel = channel None

Viewに、レンジタブを作る。

rangInputを作る。DOMの挙動で、値を取り出したら文字列型で、入れる時は数字でも文字列型でもいいのでちょっとその変換でわかりづらくなってる。


--レンジインプット、最大値と現在位置を引数に取る
rangeInput :Int -> Int -> Html 
rangeInput max current =
        div [] [ 
                div [] [
                        text "0"                  
                       ,input
                           [ type' "range"
                           , value (toString current)
                           , Attri.max (toString (max))
                           , on "input" (targetValue) ((send appChannel)<<ArchiveSelect<<toInt' )] []
                       ,text <|toString max ] 
                ,text (toString current)]

---inputはvalueを取り出すと、String型になるのでIntに変換
toInt' x = case String.toInt x of
                Ok x -> x
                Err str -> Debug.crash str

Updateに自動履歴処理を足す。

レンジの入力動作、自動履歴機能を、レコード表記で保存したい処理だけに付け足した。

レコード表記とは?

 レコード表記を使うと、型の部分を指定して新しい型を作る処理を書ける。構文
それによりデータ型に対する計算を構造のように出来る。参考→making pong

case式の前にletを使うとcase前に事前処理をはさめる。
そのlet式内で、modelからstateの取り出すのと、更新したstateを保存する関数(write)を定義して、それぞれ分岐に付け足した。(もっといい方法あるかも知れない)


---update

model : Signal Model
model = foldp update initModel (subscribe appChannel)

update : Actions -> Model -> Model
update action model =
    let state  = model.state 
        write state' = let target = model.current + 1
                       in {model | state <- state'
                                 , current <- target
                                 , archive <-if target > (Array.length model.archive) - 1  
                                            then Array.push state' model.archive
                                            else Array.set target state' model.archive
                                 }
    in case action of 

            Input str -> write <| {state| input <- str }

            Add str -> write <| {state| input <- ""
                                      , nameList <- state.nameList ++ [str]}

            Delete x ->if List.isEmpty state.nameList then model
                       else write <| {state | nameList <- tail state.nameList}

            ArchiveSelect v -> {model | current <- v 
                                      , state <- case Array.get v model.archive of
                                                        Just ste -> ste
                                                        Nothing -> Debug.crash "Unexpected access"}
            _ -> model

viewの引数部分

Modelが変わったので、view関数の引数部分を変える。レコード表記で受け取るように変えた。変更は最小限になるアピール。


view : Model -> Html
view {state, current , archive} = 
    div []
        [rangeInput ((Array.length archive)-1) current
        ,textbox state.input
        , text state.input
        , div [] (List.map listHtml state.nameList)
        ]

main : Signal Html
main = view <~ model

まとめ


      channel     -------------------→    channel
        ↑ send+merge                         ↓ subscribe      
main = View ---------- fopdp update ---------
             1 : 1          ↕          
                           Model 

 Elmは構文から何からシンプルで見やすい。FRPにより機能を拡張する時(何かイベントを増やす時)は付け足すだけでできる。

 履歴機能にしたのはviewと状態を一つづつの1:1にすると、保存した状態を取り出して過去の状態にすれば、即座にGUIの表示もその時その場面の状態になる、このリアクティブな挙動面白いなと。(タイムトラベルデバッガをイメージしてみた。)わりとトレンドでもある。

 Elm書いてみると、HtmlやSignalやイベントの発火の挙動を勘違いしてたり、Signalはこねくり回すと後から触れられなかったりした。けれどfoldp周りは、柔軟で、全て型付けされるので、イベントとチャンネルを越えたあとはElmは全体で一枚岩でテストもしやすい。じゃあMVUだけElmでほかjsとか出来るか、と聞かれたら(portはメインモジュールにしか書けないのと、nativeは参照透過に書かなきゃいけないことから一行くらいjsで書いてあと)なるべく1つの言語にした方が楽だなッて感じ。次のバージョンはどうなるかわからないが。

 今回MVUそれぞれに付け足すように拡張した。さらに、そのままMVUをワンセットでモジュールにする書き方がある(LocalChannel)。次やってみる。

2
3
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
2
3