今回、文字を入力して、リストを作ったり消したりできるアプリに、
行動毎の自動履歴保存機能をつけてみる。(ちょっとレンジの挙動が怪しいが)
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)。次やってみる。