F# で Windows.UI.Xaml のクラスをリフレクションを使ってラップして Windows ストア アプリ作る試み

今、手元で作っている WinXamlProvider は、Windows Store 8.1 と Windows Phone 8.1 で使っている XAML を F# で動かそうという試みのひとつです。

WinXamlProvider
http://github.com/moonmile/WinXamlProvider

Windows Store アプリを作る F# プロジェクトが Microsoft から提供されればいいのですが、そんな雰囲気もない。正当な方法としては、自前で F# プロジェクトのテンプレートを作ればよいのですが、どうやら *.target のほうを大きく変えないと駄目っぽくて、頓挫中です。おそらく MSBuild あたりを詳細に調べないとうまくいかなそうなのと、XAML ファイルから C#/VB/C++ コードに落としている箇所に、うまく F# を追加しないといけません。MVVM パターンのみでやるか、ここでやっているように実行時にコードビハイドをバインディングする方法をとれば、ビルド時に XAML からコードビハイドを出力する必要はありません。あるいは、F# の Type Provider を作って、ビルド時に静的に作る方法でもよいでしょう。WPF の XAML 用の Type Provider は FsXaml
https://github.com/fsprojects/FsXaml を使えばよいんですが、WinStore 用の XAML がありません。なぜ、WinStore の XAML に私がこだわるのかといえば、Surface RT のような WinRT タブレットでの使い手を想定しているためです。ええ、ユニバーサルアプリにして Windows Phone 8.1 でもうまく動くようになったのは「おまけ」ですから。

■プロジェクトを分ける

現状では、WinStore と WinPhone ではメインプロジェクトに F# を据えることが難しいので、

  • フロントエンドを C# プロジェクトで作る
  • バックエンドを F# プロジェクトで作る

ということにします。この方式は、Xamarin.Forms を使って iOS/Android を作るときと同じで、バックエンドのロジック部分をライブラリ化して、可搬性を高めるという方式です。ただし、バックエンド部分が PCL(Portable Class Library) になってしまうので、使えるライブラリに制限がでてきます。

20140729_01

こんな風に、C# プロジェクトでは簡単に参照できる Windows.Xaml.UI.* も、PCL の中ではできません。
直接 F# プロジェクトのファイルを開いて、ターゲットの書き換えと、

    <TargetFrameworkVersion>v4.6</TargetFrameworkVersion>
    <TargetFrameworkProfile>Profile32</TargetFrameworkProfile>

ターゲットプラットフォームに Windows, Version=8.1 を無理やり追加すると Windows.UI.Xaml の namespace は参照できるようになるのですが、

  <ItemGroup>
    <TargetPlatform Include="Windows, Version=8.1" />
    <TargetPlatform Include="WindowsPhoneApp, Version=8.1" />
    <Compile Include="Class1.fs" />
  </ItemGroup>

実行時に

{"ファイルまたはアセンブリ 'Windows, Version=255.255.255.255, Culture=neutral, PublicKeyToken=null'、またはその依存関係の 1 つが読み込めませんでした。指定されたファイルが見つかりません。":"Windows, Version=255.255.255.255, Culture=neutral, PublicKeyToken=null"}

という謎なエラーを出力します。確かに、無理に追加しただけなので、ロード自体がうまくいっていないっぽいのです。ここは別途修正するとうまくいのかも。

実験したプロジェクトは https://github.com/moonmile/FsApp です。

■Windows.UI.Xaml をラッピングする

PCL から直接 Windows.UI.Xaml を扱うことはできませんが、リフレクションを使うとアクセスができます。

既にフロントのほうでは、Windwos.UI.Xaml に必要なアセンブリはロードされているハズですから、それめがけてちまちまとインターフェースを作っていく方法ですね。

この方法自体は、Xamarin.Forms の拡張レンダラーとか、MvvmCross のサービスの作り方だとかと似た感じで、Xamarin.Forms や MvvmCross の場合は、適当なインターフェースを使って呼び出すようにしていますが、面倒なので、直接リフレクション呼び出しをしています。インターフェースを使うと、F# の場合に型キャストでややこしいってのもあるので。

■プロジェクト構成

今、電卓アプリのサンプルを作っている途中ですが、構成はこんな感じです。

  1. WinStore/Phone のユニバーサルアプリを作成
  2. F# PCL を作成
  3. NuGet で WinXamlProvider をインストール(製作中)
  4. WinStore/Phone で XAML ファイルを動的にロードするように設定(調節中)
  5. Page クラスのコンストラクタで、BindInit を呼び出し。

のようにすると、特定の Xaml/Page が F# の Page クラスにバインドされます。4 の手順は、動的に XAML をロードする必要があって、こうなっています。元ネタの XAML はバイナリ形式の *.xbf ファイルになって手がだせないので、テキスト形式でコンテンツ埋め込みをさせています。

一応、動作させると、

な感じで動きます。

現在、Button, TextBlock, TextBox, Page クラスのプロパティしかバインドしていないので、かなり限定的ですが。

■リフレクションでバインド

詳細なコードは、WinXamlProvider にありますが、主要部分を抜き出すとこんな感じです。

type ParseXaml() =

    let findName( page, propName ) =
        let mi = page.GetType().GetRuntimeMethod("FindName", [|typeof<string>|])
        let res = mi.Invoke( page, [|propName|])
        res

    let bindMethod( page:obj, bind:obj, target:obj, eventName:string, methodName:string) =
        let ei = target.GetType().GetRuntimeEvent(eventName)
        let dt = ei.AddMethod.GetParameters().[0].ParameterType
        let mi = bind.GetType().GetRuntimeMethod(methodName,[|typeof<obj>; typeof<RoutedEventArgs>|])

        let handler =
            new Action<obj,obj>(
                fun sender eventArgs ->
                    let e = new RoutedEventArgs(eventArgs)
                    mi.Invoke( bind, [|sender; e|]) |> ignore )

        let handlerInvoke = handler.GetType().GetRuntimeMethod("Invoke",[|typeof<obj>; typeof<Type[]> |]);
        let dele = handlerInvoke.CreateDelegate(dt, handler)

        let add = new Func<Delegate, EventRegistrationToken> ( fun (t) ->
                        let ret = ei.AddMethod.Invoke(target, [|t|] )
                        ret :?> EventRegistrationToken
                    )
        let remove = new Action<EventRegistrationToken>( fun(t) ->
                ei.RemoveMethod.Invoke( target, [|t|]) |> ignore )
        WindowsRuntimeMarshal.AddEventHandler<Delegate>( add, remove, dele)

    let bindProperty( page:obj, bind:obj, propName:string, t:Type ) =
        let pprop = findName( page, propName )
        if pprop <> null then
            let bi = bind.GetType().GetRuntimeProperty(propName)

            match t.Name with
            | "TextBlock" -> bi.SetValue( bind, new TextBlock(target = pprop))
            | "TextBox" -> bi.SetValue( bind, new TextBox(target = pprop))
            | "Button"    -> bi.SetValue( bind, new Button(target=pprop))
            | _ -> bi.SetValue( bind, new UIElement( target=pprop ))

Page.FindName が必要なので、findName 関数でリフレクションを使います。
bindMethod 関数は、XAML で記述してある Click=”OnClickButton” のイベントを、F# のクラスメソッドに倍度する仕組みですね。イベントハンドラの登録は、WindowsRuntimeMarshal.AddEventHandler を使います。残念ながら、元ネタのイベントを削除していないので(頑張ればできる目途は立ちそうなのですが)、元のイベントと F# のイベントメソッドの両方を呼び出してしまいます。まあ C# のイベントハンドラは空なので、そのままでいいでしょう。

このあたりは、頭がちぎれそうになるぐらいややこしいのですが、まあなんとか。いくつか C# のコードも出てくるので、それを参照に組み立てます。こまめに小さなメソッドにしたほうがわかりやすいですね。

プロパティ呼び出しもいちいちリフレクションします。

namespace Moonmile.WinXamlProvider.UI
open System
open System.Reflection

[<AllowNullLiteral>]
type BaseElement() =
    member val target:obj  = null with get, set
    member this.getProp<'T>( propName:string ) =
        let pi = this.target.GetType().GetRuntimeProperty( propName )
        pi.GetValue(this.target) :?> 'T
    member this.setProp( propName:string, value:obj ) =
        let pi = this.target.GetType().GetRuntimeProperty( propName )
        pi.SetValue( this.target, value)

これを使って、Windows.UI.Xaml を再構築します。
Button クラスを、こんな風にリフレクションでバインドします。

type Button() =
    inherit ButtonBase()
    member this.Flyout
        with get() = base.getProp<FlyoutBase>("Flyout")
        and set(value:FlyoutBase) = base.setProp("Flyout", value )
    member this.CommandParameter
        with get() = base.getProp<Object>("CommandParameter")
        and set(value:Object) = base.setProp("CommandParameter", value )
    member this.Command
        with get() = base.getProp<Windows.Input.ICommand>("Command")
        and set(value:Windows.Input.ICommand) = base.setProp("Command", value )
    member this.ClickMode
        with get() = base.getProp<ClickMode>("ClickMode")
        and set(value:ClickMode) = base.setProp("ClickMode", value )
...

このあたりは手作業で書くと大変なので、適当なツールを作って、コード出力しています。
そのうち自動生成させてしまうつもり。

■F# で MainPage クラスを書いてみる

namespace WinXamlProvider.Lib
open System
open Moonmile.WinXamlProvider
open Moonmile.WinXamlProvider.UI

type MainPage() =
    member val textMessage:TextBlock = null with get, set
    member this.Button_Click(sender:obj, e:RoutedEventArgs) =
        this.textMessage.Text <- "New F# message."

Button をクリックしたときに Button_Click イベントが呼び出されて、textMessage の内容を書き換えるコードです。まだ MVVM タイプの SetBinding を実装していないので、WinForm っぽい書き方になりますが、これで Widows Store アプリと Windows Phone アプリを F# で書くことが可能になります。

■今後は

Button.Click イベントあたりをバインドしてしまえば、直接 this.btn.Click.Add( … ) が使えるようになるので、コードビハイドのイベントコードを書かなく手済むようになります。これを早急に実装。
あとは、自動生成コードを修正して、Windows.UI.Xaml 以下の全てのクラスをバインドさせてしまう。

カテゴリー: F#, WinRT, XAML パーマリンク