Xamarin.Forms の XAML を F# から使うために XamlTypeProvider を使う

XFormsPreviewer では、動的にXAMLファイルをロードしてレンダリングする(内部的にはXamarin.Formsのコントロールを生成する)してプレビューを実現していますが、このコアライブラリに XamlTypeProvider があります。最初は Xamarin.Forms のプレビューだけを作るつもりだったけど、作っているうちに F# の TypeProvider と似ていることがわかり、じゃあ TypeProvider 的にしようとしたのが発端です。

■Xamrin.Forms と XAML の関係

要は、WPF や Windows Store の XAML と同じように、フロントエンドを記述する XAML と、ビルド時に自動生成される partial class とで成り立っています。この partial class のほうで、コントロールの名前による参照や、ボタンコントロールのクリックイベントなどを記述するわけで、事実上のコードビハイドですね。Silverlight の XAML の場合には、XAML 自体に C# のコードを埋め込めるので(今でもそうだと思う)XAML 単体で動かすことも可能なわけですが…Windows Store/Windows Phone 8.1 の XAML はできたかどうかわかりません。たぶん、Xamarin.Forms の XAML もできません。

で、コードビハイドを使うと、XAML で記述した View に内部動作のコードが埋め込まれるわけで、(それがコードビハイドだとしても)View と Logic が分離していないのではないか。という話なのですが、まあ、実装的には MVVM の INotifyPropertyChanged/ICommand にしてしまうか、別な方法をにしてしまうか、という具合です。ワタクシ的にはコードビハイドでもいいと思うんですけどね。うまく分離されていれば。

「うまく分離する」という点で、MVVM パターンは、View と Modelが分離できていますが、複数の View を扱うときにちょっと困ります。これは Xamarin.Forms の問題かもしれないけど、とあるビューから別のビューに移るときに、PushAsync を使いますが、この Navigation プロパティは Page にくっついて引数もページなんですよね。

page.Navigation.PushAsync(nextPage)

当たり前といえば当たり前だけど、画面遷移自体は、ページからページに移る。ViewModel の場合は Page に依存したくないので、View から本格的に ViewModel を分離させたときに Command パターン内では他のページに遷移させることができない、ってことになります。まあ、現実的な解としては ViewModel に対応する View を持たせてしまえばいいのですが。

そんな依存関係から、Xamarin.Forms も Windows Store の XAML も View=XAML に対応するコードを出力して、各種イベントやデータを保持する方法をとっています。
実は、F# の TypeProvider も同じことが言えて、実行時にビルドして Type を決めるので FsXaml のように WPF の XAML をコンバートできるけど、これは動作時にはできないのです。というのも、未知の XAML に対して、内部プロパティやイベントの判定を、先にコードビハイドに記述することはできませんよね。。まあ、方法としては、

  • XAML ダウンロード時に TypeProvider でビルドする。
  • Page に付属するプロパティは、dynamic で受けて参照させる。

ということで、実行にデータの整合性のチェックをすればよいのですが、完全にダイナミックに、というわけにはいきません。

また、Xamrin.Forms の XAML から出力するコードは C# のために、F# から使おうとするビルドエラーになります。これは Windows Store の XAML の場合も同じで、ここのコードビハイドのコードが言語依存になってしまって邪魔です。XAML 自体はコード依存していないのに。
逆に言えば、コードビハイドに全く依存しない形で、書けばバックエンドが C# であろうと F# であろうと、場合によっては Visual Basic であろうと関係なく書ける可能性があります。

■View と ViewModel を動的ににリンクさせる

通常の MVVM モデルの場合には、先の画面遷移の関係もあって、View と ViewModel は1対1の関係になっています。View(XAML)に対して、ひとつひとつ ViewModel を作ることが多いのですが、ViewModel から View への依存を完全に切り離してしまえば、ひとつの ViewModel を複数の View に対応させることができます。

コンパイル時にこれを実現するのは非常に簡単で、同じ ViewModel を使っているけど状況に応じて複数の View を切り替えることができます。ただし、「状況に応じて」切り替えられるのは、ViewModel の中ではなく、先の Page のようなコードビハイドの中になります。

ここで、View と ViewModel を完全に分離させようとすると矛盾がでます。

– XAML は、コードビハイドを出力する
– コードビハイドでのみ、画面遷移/Viewの切り替えが可能
– View は ViewModel と分離させたい
– ViewModel から画面遷移くを直接アクセスできない。

この矛盾は、XAML の仕組みがコードビハイドに依存している(partial class)ためであって、XAML 自体が徹底して View に徹していれば、うまく矛盾が解消できるような気がします。もちろん、ビヘイビアのような View 内部に存在するコード(アニメーションのようなコード)は、XAML にあるほうがよいのですが、ViewModel でもまかなえるものは、切り離してしまったほうがよいでしょう。

…という発想のもとに、XAML コード自体を完全に動的にロードさせます。動的にロードさせるわけですから、未知の XAML ファイルでも大丈夫です。いままで、リソースとして持つか、文字列としてプロジェクト内に埋め込むかしかなかったものが、インターネット上に XAML ファイルを置いて、それをダウンロードして使うことができます。まあ、作り始めて分かったのですが、ちょうど HTML がブラウザ上で動くのと同じ仕組みですね。

HTML の場合が

  • HTML でデザインコードを書く。
  • Javascript で動きのあるコードを書く。
  • Javascript はブラウザのインタープリタで動く

というように、コード部分がインタープリタにしかならないのがスピードではネックだったりします。

Xamarin.Forms 用の XAML ファイルを直接ダウンロードできるようにすることで、

  • XAML でデザインコードを書く。
  • 動作は、C# で ViewModel 内に書く。あるいは、動的リンクされるコードビハイドもどきに記述する。
  • ViewModel内 のコードは事前コンパイルで、Xamarin によりネイティブコードで動く。

ViewModel のアセンブリを動的ダウンロードできるかどうかは分からないのですが(ActiveXみたいな感じ?)、ひとまず、この方式をとることで Javascript などによるインタープリタではないネイティブの動作が可能になります(もちろん、Javascript等であっても、実行時ビルドされていればスピードは同じなんですけど)。

Xaml のパース自体が車輪の再発明っぽいので、あまりやりたくないのですが、ざっと F# で書いてみたところ 400 行程度なのでなんとか許容範囲でしょう。

■実際どう使うのか?

moonmile/XamarinXamlPreview
https://github.com/moonmile/XamarinXamlPreview

Xamarin.Forms for F# のコンテスト用に作ったので F# になっていますが、当然ながら C# でも使えます。page.Navigation.PushAsync あたりが、変なことになっているのは F# だからであって、C# だと結構素直に書けます。

type MainPage(path:string) =
    inherit ContentPage()
    let mutable page:Page = null
    let mutable buttonNextPage:Button = null
    let mutable buttonStopWatch:Button = null
    let mutable buttonStopWatchDownload:Button = null
    let mutable label:Label = null
    
    let pushPageVM(rn:string, vm:ViewModelBase) =
        let xaml = ResourceLoader.GetString(rn)
        let next = ParseXaml.LoadXaml(xaml) 
        next.BindingContext <- vm
        let t = new Task( fun() -> 
            let task = page.Navigation.PushAsync(next)
            task.Start()
        )
        t.RunSynchronously()
    
    let pushPage(rn:string) =
        let xaml = ResourceLoader.GetString(rn)
        let next = ParseXaml.LoadXaml(xaml) 
        let t = new Task( fun() -> 
            let task = page.Navigation.PushAsync(next)
            task.Start()
        )
        t.RunSynchronously()

    let pushPageDL(url:string) =
        
        try 
            let hc = new HttpClient()
            let xaml = hc.GetStringAsync(url).Result
            let next = ParseXaml.LoadXaml(xaml) 
            let t = new Task( fun() -> 
                let task = page.Navigation.PushAsync(next)
                task.Start()
            )
            t.RunSynchronously()
        with
        | _ -> 
            label.Text <- String.Format("Error: cannot open {0}", url )

    do 
        let xaml = ResourceLoader.GetString(path)

        page <- ParseXaml.LoadXaml(xaml)

        buttonNextPage <- page.FindByName<Button>("buttonNextPage")
        buttonStopWatch <- page.FindByName<Button>("buttonStopWatch")
        buttonStopWatchDownload <- page.FindByName<Button>("buttonStopWatchDownload")
        label <- page.FindByName<Label>("label")
        
        buttonNextPage.Clicked.Add( fun(e) -> 
                pushPageVM("NextPage.xml", new ViewModelNextPage())
            )
        buttonStopWatch.Clicked.Add( 
            fun(e) -> pushPage("StopWatchPage.xml"))

        buttonStopWatchDownload.Clicked.Add( 
            fun(e) -> pushPageDL("http://moonmile.net/up/DownLoadPage.xml"))

    member this.Page 
        with get() = page

Xaml ファイルをそのままプロジェクトに埋め込むと、C# のコードビハイドを吐き出すので、拡張子を xml にしています。ViewModel 自体は、XAML を動的に生成して結びつけます。内部的にはリフレクションの嵐になっていますが気にしてはいけません。外側から使う時、ViewModel から使う時はリフレクションなどは意識しない作りになっています。普通の ViewModel を使うときと同じ要領ですね。

F# なので Command パターンのところがややこしくなっていますが、C# でも同じように書けます。データバインディングを使うので、View に依存しないように書けます。ここは MVVM と同じです。

type ViewModelStopWatch() =
    inherit ViewModelBase()

    let mutable startTime = DateTime.Now
    let mutable endTime  = DateTime.Now
    let mutable time:TimeSpan = new TimeSpan()
    let mutable task:Task = null
    let mutable taskflag = false


    /// リフレクションで iOS/Android の System.Timers.Timer を持ってくる
    /// 現在調節中
    let makeTimer (interval:int, hdr:ElapsedEventHandler) =
        let asm = Assembly.Load(AssemblyName("System"))
        let t = asm.GetType( "System.Timers.Timer" )
        let item = System.Activator.CreateInstance(t)

        t.GetRuntimeProperty("Enabled").SetValue( item, true )
        t.GetRuntimeProperty("AutoReset").SetValue( item, true )
        t.GetRuntimeProperty("Interval").SetValue( item, interval )
        t.GetRuntimeEvent("Elapsed").AddEventHandler( item, hdr )
        t.GetRuntimeMethod("Start", null).Invoke(item, null) |> ignore
        item

    member this.Time 
        with get() =  
            time <- endTime - startTime
            String.Format("{0:00}min {1:00}sec {2:000}", time.Minutes, time.Seconds, time.Milliseconds )
    member this.StartTime
        with get() = startTime.ToString()

    member this.StartCommand 
        with get() =
            let cmd = 
                new DelegateCommand(
                    (fun (o) -> true ),
                    (fun (o) -> 
                        startTime <- DateTime.Now 
                        endTime <- startTime
                        this.OnPropertyChanged("StartTime")
                        this.OnPropertyChanged("Time")
                        // makeTimer(2000, new ElapsedEventHandler(fun(s,e)-> 
                        //     System.Diagnostics.Debug.WriteLine("time {0}", endTime ))) |> ignore
                        // )))
                        ))
            cmd

    member this.StopCommand 
        with get() =
            let cmd = 
                new DelegateCommand(
                    (fun (o) -> true ),
                    (fun (o) -> 
                        taskflag <- false
                        endTime <- DateTime.Now 
                        this.OnPropertyChanged("Time")
                    ))    
            cmd

    member this.ResetCommand 
        with get() =
            let cmd = 
                new DelegateCommand(
                    (fun (o) -> true ),
                    (fun (o) -> 
                        taskflag <- false
                        startTime <- DateTime.Now
                        endTime <- DateTime.Now 
                        this.OnPropertyChanged("StartTime")
                        this.OnPropertyChanged("Time")
                    ))    
            cmd

ここでストップウォッチを作ろうと思ったのですが、Xamarin.Android の場合、Task.Delay の扱いが Windows Forms と異なるので苦戦中です。実は WinRT も同じ現象になるので、定期的なメソッド起動はタイマーを使わないと駄目なんですよね。このあたりは、別なサンプルアプリとして実装します。

Xamarin.Forms や MvvmCross 的には Service を使うのでしょうが、今回はプラットフォーム依存のアセンブリを動的ロードして、タイマークラスをリフレクションを使って操作する、ということを試しています。結構乱暴ですが、こうすると PCL からプラットフォームへのインターフェースがいらなくなって(タイマーを起動するインターフェースが必要なくなる)依存関係が「疎」になります。実は、タイマーの扱いも、Android/iOS/WinStore とそれぞれ異なるので、工夫が必要なのですが、Device を使って回避する予定です。

ライブラリとしての XamlTypeProvider は NuGet を使って取得できます。
https://www.nuget.org/packages/Xamarin.Forms.XamlProvider/

■実行する

「Download View from Internet」ボタンをクリックすると、インターネット上から XAML コードをダウンロードしています。こうすることで、View だけを定期的に更新することが可能です。

XFormsPerviwer もそうなのですが、Xamarin.Forms の XAML を外だしにしていつでも更新できるようにしておくと、ちょこちょこと XAML を手作業で修正して、エミュレータ上で動作を確認できます。たぶん、開発効率が上がるかな、ってのと、ちょっとしたパズルゲームとか設定画面をダイナミックにロードさせると、画面の雰囲気ががらりと変わってよいかと。アプリ自体はそのままなので、手軽に変えらえるのがミソです。そう、ややこしい「審査」がいらなくなりますからね。

この仕組み自体が「審査」が通るからどうかは、近々試してみる予定です。しばらくは、プレビュー的に使うのと、XamlTypeProvider の整備ってところです。

カテゴリー: 開発 パーマリンク