OSTRACISM CO.

テンプレート『F# Empty Windows App (WPF)』を使わずにWPFアプリケーションを作る

 F#は.NETにとって一級市民なのかもしれないが、Visual Studioでの扱いは三級というか奴隷というか困った状態なのはVisual Studio 2015でも変わらず。とはいえF#でもGUIプログラム(WPF)は書けるということで、テンプレート「F# Empty Windows App (WPF)」を使わずにWPFアプリケーションを作ってみた。正確に言えば「F# Empty Windows App (WPF)」で作ったアプリケーションからの移植。「F# Empty Windows App (WPF)」で作ってみると、「あれ、このテンプレート不要なんじゃね?」と気付いたからだ。余計なDLLがあるのが気に食わないってのが大きい。

 最初にすべきことは「Creating a WPF application in F#」(http://putridparrot.com/blog/creating-a-wpf-application-in-f/)にある。大雑把に言えば、

  1. 新規プロジェクトでF#の「コンソール アプリケーション」を作る。
  2. [プロパティ - アプリケーション - 出力の種類] を「Windows アプリケーション」にする。
  3. 参照設定にPresentationCore、PresentationFramework、System.Xaml、WindowsBase を加える。
  4. Program.fsとMainWindow.xamlの初期状態は上記サイトを参考に(どうせ全部書き換える)。
  5. MainWindow.xamlは[ソリューション エクスプローラー - プロパティ - ビルドアクション] を「Resource」にする。

 MpdDBReader2はLinux等で利用されるオーディオサーバのMPD(https://www.musicpd.org/)の作るデータベースファイルtag_cacheを読み込んで表示するプログラム。このプログラムを利用するには、

  1. Linux(など)が動いているマシンにMPDが稼働している。
  2. そのマシンにてSambaが稼働しており、tag_cacheとその対象のオーディオファイルがWindowsマシンからファイルアクセス可能なディレクトリにある。

という条件がある。はっきりいえばこれを利用できる環境を持つ人はきわめて少数とは思うが、サンプルプログラムとして応用そのものは無視して欲しい。

 私の場合、Linuxのオーディオ出力はAVコーナーに接続されており、ちょっと大袈裟なので、Windowsマシンに接続された手元のスピーカーからも出力させたいという需要があった。元々はdaapdを稼働させてiTunesでアクセスしていたが、daapdが終息し、そこから分岐したforked-daapdがマトモに動かなかったので、MpdDBReaderを書いた。Ubuntu 16.04になり、再度forked-daapdを試してみたが、iTunesでしばしば音が途切れる。MpdDBReaderに要求される機能はキーワードでの楽曲の検索とその音の確認のための再生である。鑑賞するならfoobar2000を直接使う。AVコーナーで鑑賞するならそこにあるパソコンからQMPDClientを使う。


MPDのtag_cacheをブラウズするプログラム

F# (Microsoft Visual Studio Community 2015) MpdDBReader_2.0.zip

MpdDBReader2はそのままで公開される試作品プログラムです。

利用・改変は自由に行えますが、改変後の公開はソースの公開が必要です。


プログラムの起動 Program.fs

 本筋とは関係ないコードが色々あってごちゃごちゃしてるが、その部分は後回しにして、大切なのは以下の通り。

let mutable mainWindow: Window = null
...
[<STAThread>]
[<EntryPoint>]
let main argv = 
    ...
    mainWindow <- MainWindow.Create ()
    (new Application()).Run(mainWindow)
    ...

 ウィンドウを生成、アプリケーションを生成、Run。このRunはメインのウィンドウが閉じられるまで返ってこない。MainWindowはMainWindow.fsで定義されたモジュール。

ウィンドウの制御 MainWindow.fs MainWindow.xaml

 MainWindow.fsはモジュールMainWindowの定義。F#(というかOCaml)はプログラムのモジュール分けをmoduleというキーワードで行う。一般的にはモジュール毎の型を定義して、その型を扱う関数の集合体をモジュールとする。このmodule MainWindowはウィンドウ生成のCreate関数を除き全てパラメタにWindowを指定する。独自の型を用意しても良いのだが、せっかくなのでプログラム固有の変数はビューモデルMainViewModelの方に振り分けた結果、このモジュールで扱うのはWindowだけになった。Windowをメンバーとして持つ型でなく、Window型を扱うモジュールということにした。

MainWindowモジュールの関数

let Create ()

 メインウィンドウの生成。

let w = Application.LoadComponent(new System.Uri("/MpdDBReader2;component/MainWindow.xaml", UriKind.Relative)) :?> Window;

 MainWindow.xamlをWindowとしてロードする。

 ビューモデル(後述)はXAMLロード時にインスタンスが作られWindowのDataContextに収められる。プログラムのUIの根幹をなすMainViewModelクラスをnewしてWindowに結び付けるのはMainWindow.xamlの

<Window.DataContext>
    <local:MainViewModel/>
</Window.DataContext>

という記述。プログラムでnew MainViewModel()してDataContextにセットしても良い。ただし、その場合はMenuItemのCommandがnullのままになっている(そうなっている適切な理由が思い当たらないのでバグなんだろう)。

 ハンドラやコマンドの実体を登録する。

(lvHits w).MouseDoubleClick.Add(lvHits_MouseDoubleClick w)

 (lvHits w)はWindowから"lvHits"という名前のListViewを得る。

let lvHits (w: Window) =
    w.FindName("lvHits") :?> ListView

 そのListViewのMouseDoubleClickイベントに(lvHits_MouseDoubleClick w)という関数をハンドラ登録する。

let lvHits_MouseDoubleClick w (arg: MouseButtonEventArgs) =
    ....

 MouseDoubleClickのハンドラのパラメタはMouseButtonEventArgs一つなので、パラメタを一つ確定して(カリー化して)渡している。lvHits_MouseDoubleClickはダブルクリックされたListViewのアイテムを検査し、ビューモデルのPlayCommandを実行する。

w.Closed.Add(mainWindow_Closed w)

 WindowのClosedイベントに(mainWindow_Closed w)という関数をハンドラ登録する。mainWindow_Closedは次回起動時に保持するウィンドウ位置などを保存する。

let _ = w.CommandBindings.Add(CommandBinding((menuItemCopy w).Command, ExecutedRoutedEventHandler(OnCopyCommand w), CanExecuteRoutedEventHandler (CanCopyCommand w)))

 メニューのコピーコマンド(menuItemCopy w).Commandを(OnCopyCommand w)関数と(CanCopyCommand w)関数に結び付けてWindowのCommandBindingsに登録する。(menuItemPlay w).Commandと(menuItemReload w).Commandも同様だが、Copyは.NETに用意されているコマンドなのでXAMLの

Command="ApplicationCommands.Copy"

という記述だけでインスタンス化されているが、PlayとReloadはMainViewModelでインスタンス化される(後述)。

 .NETにおけるコマンドとは、単に機能を呼び出す名前のような存在で、機能との結びつきは何らかの方法で記述(この場合はプログラムで表現)しないといけない。この中途半端に抽象度の高い謎概念に付き合うつもりがなければMenuItemのClickイベントにハンドラを登録する方法でも良い。

let OnPlayCommand w (obj: Object) (arg: ExecutedRoutedEventArgs) =
    ...
    let item = lv.SelectedItem :?> MpdDB.MusicItem
    item.Play()

 MenuItemのPlayに結び付けた機能。ListViewの選択アイテムMpdDB.MusicItemに対してPlay()を呼び出す。

 あとは、保存した状態の反映とDBの読み込み。Create関数はWindowを戻り値とする。

 MainWindowモジュールは概ねCreate関数とそこから使われたり登録されたりする関数の集合。

ビューモデル MainViewModel.fs MainWindow.xaml

 WPFではMVVM(Model-View-ViewModel)パターンが流行っているということになっている。UI(ビュー)とロジック(モデル)を仲介するのがビューモデルなんだそうな。

MainViewModelクラス

type MainViewModel()

 MainViewModelはMainWindow.xamlに記述されたコントロールのBindingを解決するためのビューモデル。

 ビューモデルはXAMLで下記の記述によりインスタンスが作られWindowのDataContextに収められる。

<Window.DataContext>
    <local:MainViewModel/>
</Window.DataContext>

 MainWindow.xamlに記述されたコントロールは以下の3つ。

  1. 検索文字列のTextBox - SearchTextにバインディング
  2. 検索ヒット数のTextBlock - CounterTextにバインディング
  3. DB内容表示のListView - Hitsにバインディング

 加えてMenuItemがある。

  1. Play - PlayCommandにバインディング
  2. Reload - ReloadCommandにバインディング

 よってMainViewModelでは上記バインディング対象のプロパティを用意する。

member this.SearchText

 MainWindow.xamlのText="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" により入力があったらここのセッターに来る。このプロパティ(SearchText)を含むクラス(MainViewModel)がINotifyPropertyChangedインターフェースの実装である必要はない。コントロールからプロパティ方向のイベント通知はXAMLの記述のみで可能。

 コントロールのTextChangedイベントに直接ハンドラを置いた場合、漢字変換前にもイベントが発生するが、PropertyChangedの方法だと漢字変換後に来る。直接ハンドラを置く方法で漢字変換後にするイベントがどれなのかあるいは存在するのかは不明。

 入力時ApplyHit()メソッドを呼び出して、DBのリストをフィルタリングする。

member this.CounterText

 MainWindow.xamlのText="{Binding CounterText}"のコントロールに値の変化を通知。通知の仕組みはプログラムで用意しなくてはいけない。それがINotifyPropertyChangedインターフェース。

let propertyChangedEvent = new Event<PropertyChangedEventHandler, PropertyChangedEventArgs>()
interface INotifyPropertyChanged with
    [<CLIEvent>]
    member this.PropertyChanged = propertyChangedEvent.Publish

 参考「F#: Using INotifyPropertyChanged for data binding」(http://stackoverflow.com/questions/1698147/f-using-inotifypropertychanged-for-data-binding)、「Events (F#)」(https://msdn.microsoft.com/visualfsharpdocs/conceptual/events-%5bfsharp%5d#)。

 これをC#で書くとINotifyPropertyChangedを継承(実装)したクラスにおける

public event PropertyChangedEventHandler PropertyChanged;

といった風にきわめて簡潔なのだが、F#にはeventというキーワードが無く、EventというF#専用のクラスを使う。そもそもeventはdelegateの一種(複数登録できるdelegateのようなもの)だそうだ。delegateはSystem.Delegateというクラスをキーワード化したもので最終的にクラス。F#のEventはSystem.Delegateを使って実装してるんだろうと予想。上記F#とC#の記述が同じことを目的としていることはわかるが、どんな風にとかまでは理解できてない。C#の場合、WindowのDataContextにビューモデルをセット(値の代入)したらPropertyChangedが有効になる(delegateとして呼べる状態になっている)。DataContextプロパティのセッターで何かしてるんだとは思う。

 eventは.NETの根幹を成す概念なんだからキーワードにしても良いのに、やはりGUI向けだから後回しにされたのであろうか。

 参考「Handling and Raising Events」(https://msdn.microsoft.com/en-us/library/edzehd2t(v=vs.110).aspx)。CLIEventについては「Core.CLIEventAttribute Class (F#)」(https://msdn.microsoft.com/en-us/visualfsharpdocs/conceptual/core.clieventattribute-class-%5Bfsharp%5D)。

 Eventをnewするとき、典型的には

let propertyChangedEvent = new Event<_, _>()

と書けるのだが、判りやすくするため敢えて型を書いている。あとnewも省略できるのだが、やはり判りやすくするためなるべく省略しないようにしている。

 さて、CounterTextである。

member this.CounterText
    with get () = counterTextInstance
    and set (value) = 
        counterTextInstance <- value
        propertyChangedEvent.Trigger(this, new PropertyChangedEventArgs("CounterText"))

 CounterTextプロパティへのセット(値の代入)時Eventクラスのインスタンスを使って通知。ってのを明示的に行う。PropertyChangedEventArgsクラスのコンストラクタのパラメタがプロパティ名の文字列で、結局のところ名前で対象のコントロールを探してPropertyChangedを通知するという仕組みのようだ。名前での接続(リフレクション)なので割と疎結合で望ましいことと言えるのだが、それは毎回辞書検索が入るということとのトレードで、プチ富豪な考え方だなぁと思う。いや、もう処理能力的にこの程度はいいのか。問題はどちらかというとCounterTextというプロパティで"CounterText"という文字列を持たなくちゃいけないことにある。これ、典型的な二重メンテ。Microsoftもその問題は認識しているようで、.NET 4.5でCallerMemberNameという姑息な属性を用意した。でもこれ、F#では使えない。使えるようにするつもりがあるのかも不明(https://msdn.microsoft.com/ja-jp/library/system.runtime.compilerservices.callermembernameattribute(v=vs.110).aspx)。

member val Hits

 MainWindow.xamlのItemsSource="{Binding Hits}"に対応。プロパティとしてはゲットのみ提供しているが、値が変更されたらそれがUIに反映されなくてはならない。それを提供するのがHitsの型のObservableCollectionクラス。これ、プロパティを持つクラスがINotifyPropertyChangedを実装している必要はなく、もう勝手に変更をコントロールに通知しまくる。そもそもListBox、ListView、TreeViewを扱いやすくするために用意されたそうだ。ここではObservableCollection<MpdDB.MusicItem>なので、コレクションのアイテムはMpdDB.MusicItemだが、アイテムのメンバーもmember val Artistなどプロパティであり、MainWindow.xamlでのDisplayMemberBinding="{Binding Artist}" に対応して表示される。

 この、自動的に通知するクラスは便利だということで、他の型でも同じように使えるようにとReactivePropertyというクラスが開発されている。Reactive Extensionsを使っているそうなので、.NET Frameworkに正式に取り込まれたら考えようか。そもそも「余計なDLLがあるのが気に食わない」わけですし。

member val PlayCommand

 MainWindow.xamlのCommand="{Binding PlayCommand}"に対応。Application.LoadComponentでXAMLをロードしたときにMenuItemのCommandにコマンドのインスタンスが作られる。本当にそれだけのためにここにこういうプロパティが必要。コマンドと機能はまた別箇に結び付けないといけない。

 CommandBindingsはWindowにもMenuItemにもあるが、PlayCommandはエンターキーでのショートカットを定義しており、WindowのCommandBindingsに登録しないとショートカットが効かない。メニューのPlayにReturnとショートカットが表示されるのはちゃんとコマンドを定義しているため。プログラム上Key.EnterがメニューでReturnになるのは謎。

member val ReloadCommand

 MainWindow.xamlのCommand="{Binding ReloadCommand}"に対応。

楽曲データベース MpdDB.fs

 MVVMパターンでのモデル。のはずだが、MpdDB.MusicItemはObservableCollectionクラスを経由してそのままビューモデルとして扱われる。

 このモジュールが唯一関数型言語っぽいプログラムが書けるところ。

 MPDのデータベースファイルtag_cacheはiCalやvCalに似た、「要素名: 値」を羅列したテキストをgzip圧縮したもの。

MusicItemクラス

type MusicItem()

 ArtistやTitleなどのプロパティに加えて、楽曲再生機能のPlay()と楽曲データのテキスト表現を得るGetTextForClip()を持つ。

member val Artist = "" with get, set

 MainViewModelでも使っているが、member valは自動実装プロパティで、with get, setはそれが可変だということを示している。ここでのArtist = ""はコンストラクタでのみ評価される。ようは初期値。

member this.Play()

 楽曲の持つファイルパスから実際にアクセス可能なファイルパスを合成し、Process.Startする。Windowsでは拡張子などファイルの種別に結び付けられたプログラムが起動して指定したファイルが再生される、ことが期待される。

member this.GetTextForClip()

 楽曲データを簡潔にテキストにする。音楽再生ソフトはこういった機能が本当に貧弱。iTunesは可能。

MpdDBモジュールの関数

let CommonComparer (item1: MusicItem) (item2: MusicItem)

 楽曲データをソートするための関数。もうちょっと上手い書き方がないかとは思うが、ベタな評価関数。

let rec MakeTrackValue (v: String)

 トラック番号はソートのためだけに使われる。トラック番号は文字列で保持してるので、4桁になるまで"0"を加える。再帰が関数型っぽい。

let ReadAllLinesWithGzipDecompress path

 どのバージョンからかは調べてないが、最新のMPDではDBファイルtag_cacheがテキストでなくgzip圧縮されたテキストに変わってしまった。なので、File.ReadAllLines()と同じように動くgzip版を用意。内部で再帰が関数型っぽい。.NETがgzipに対応してなかったらお手上げ。きっとライブラリを探すだろうけど。

let LoadDB ()

 tag_cacheファイルを読んでMusicItemのリストを得る。foldとかOptionが関数型っぽい。

設定の保存と読み込み Prefs.fs

 C#のSettings.settingsのような仕組みはF#にはないので、昔ながらに自力でPreferencesを保存する。

Preferencesクラス

type Preferences()

 XmlSerializerを使ってパブリックプロパティとパブリックフィールドが保存される。

member val Left = 50.0 with get, set

 member valは自動実装プロパティ。memberなのでパブリック。

let mutable data = new Preferences()

 プログラム中どこからでも参照できるようにモジュールでインスタンスを持つ。まぁ、グローバル変数は嫌いな人も多い。

let LoadPreferences ()

 XmlSerializerを使った読み込み。useはIDisposableを実装したクラスの変数に使うとスコープを抜けるときに自動的に閉じたり破棄したりしてくれる。

let SavePreferences ()

 XmlSerializerを使った保存。

多重起動の抑制 Program.fs

 必須の機能でもないのだが、Mutexを使ってプログラムの多重起動をチェック、既に起動していたらそれを前面にして終わる。

let APPL_NAME = "MpdDBReader2"
let appMutex = new Mutex(false, APPL_NAME)
...
let REMOTECALL_NAME = "RemoteCall"

 適当な文字列でMutexを作る。本来なら世界で唯一な文字列(例えばUUID)にすべきかもしれない。Mutexの所有権はこの段階では与えない。

if appMutex.WaitOne(0, false) then
    let serverChannel = new IpcServerChannel(APPL_NAME)
    ChannelServices.RegisterChannel(serverChannel, true)
    RemotingConfiguration.RegisterWellKnownServiceType(typeof<RemoteCall>, REMOTECALL_NAME, WellKnownObjectMode.Singleton)

 Mutex.WaitOneにて待ち時間0で所有権を得る。所有権が得られたなら最初の起動ということになる。RemoteCall型でプロセス間通信のサーバを立てる。

else
    let clientChannel = new IpcClientChannel()
    ChannelServices.RegisterChannel(clientChannel, true)
    RemotingConfiguration.RegisterWellKnownClientType(typeof<RemoteCall>, "ipc://" + APPL_NAME + "/" + REMOTECALL_NAME)
    let remote = new RemoteCall()
    remote.Apply(argv)

 Mutexの所有権が得られなかったなら既に起動しているプロセスがあるということになる。RemoteCall型でプロセス間通信クライアントを作り、RemoteCall.Applyメソッドを呼び出す。このRemoteCall.Applyはサーバ側のプロセスのRemoteCall.Applyを呼び出すことになる。

type RemoteCall()

 プロセス間通信でのやり取りに使う型。MarshalByRefObjectクラスを継承しているので、プロセスの境界をこえてメッセージのやりとり(メンバー変数への代入やメソッド呼び出し)が可能となる。

member this.Apply(argv) =
    if mainWindow <> null then
        mainWindow.Dispatcher.Invoke
            (fun () ->
                if mainWindow.WindowState = WindowState.Minimized then
                    mainWindow.WindowState <- WindowState.Normal
                let _ = mainWindow.Activate()
                ()
            )

 リモートで呼び出されるメソッド。

 メインウィンドウのインスタンスはここで参照するためにスコープをモジュールにしている。ApplyメソッドのスレッドはUIスレッド(ウィンドウのコントロールを制御可能なスレッド)ではないので、ウィンドウの状態を変更するにはWindow.Dispatcher.Invokeメソッドを通さなくてはいけない。InvokeのパラメタはActionデリゲートなので関数を直接書ける。


2016.08.18


「ホーム」へ戻る


OSTRACISM CO.

OSTRA / Takeshi Yoneki