Android
WPF
Xamarin
Xamarin.Forms
ReactiveProperty

Xamarin.FormsでAndroid・WPF・GTKアプリを開発した際のメモ

概要

 以前、パワーアップしたXamarin.Formsでクロスプラットフォームアプリ開発を体験してみた記事を書きました。
  Xamarin.FormsでWPF・GTKアプリも開発してみたかった
 しかし、ごく単純なサンプルですので、どうにもインパクトが弱いんじゃないかと考えました。そこで、より実用的なアプリ開発を例に、詳細な解説を行っていきます。

※今回使用したコードは、GiuHubで公開しています。
 https://github.com/YSRKEN/CalcExpPoint

※Linux向けのバイナリは、その性質上Monoを使ったものになります。ざっくり言えば、
 ・Windowsだとexeをクリックするだけで動作する
 ・LinuxだとMonoのランタイムをインストール後、exeをMono runtimeで開けば動作する
となります。Monoのインストール方法はこちら

※Xamarin.MacやXamarin.iOSをスルーしているのは、単にハードウェアを持っていないからです。Xamarin Live Playerを使えばWindows+iPhoneで表示をプレビューできますが、やはり両者をセットで購入しておくのが理想でしょう。いちいち専用ハードを要求するからAppleは嫌いですわ

作成手順

Step1. リポジトリを作成する

image.png

 別に必須ではありませんが、プログラミングする際にGit等でバージョン管理することは息をするように行いますよね?

Step2. プロジェクトを作成する

 上記記事などを参考にしながら、プロジェクトを構築します。この際、下の画像のようなフォルダ構成にしようと頑張りますと、

image.png

共有プロジェクトで指定できるソリューションが見つからず詰むというサイテーな結果になることがありますので注意しましょう。

image.png

Step3. お約束

 Mac持ち勢はこれにXamarin.MacとXamarin.iOSを並べられるんですよね? 羨ましい……

image.png

Step4. UI設計

 なまじ「様々なウィンドウサイズに対応」するのがスマホアプリですので、WPFなどととは色々と勝手が違います。基本的にXAMLなので知見が生かせないわけではないですが……。
 たぶんLive PlayerでXAMLをプレビューしながら組むのが一番楽なのですが、重要なのは「プロジェクト名.Android」ではなく「プロジェクト名.WPF」や「プロジェクト名.GTK」の方を選択して編集することです(Shared ProjectのXAMLを弄っている場合、どれを編集しても同じ結果になります)。なぜかAndroid側のソースではIntelliSenseが効きませんので……

image.png

image.png

image.png

 そんなこんなで組み上げたGUIがこちらです。1つのXAMLで3種類のプラットフォームに対するGUIが構築できていることが分かります。ただ、スマホ向けにUI設計すると他がかなり不自然になるのは否めませんので、現実的にはXAMLをそれぞれのプラットフォーム向けに書き分けるのがベストでしょう(やり方は知らない)

MainPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:CalcExpPoint"
    x:Class="CalcExpPoint.MainPage">

    <Grid Margin="10,10,10,10">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <Label Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
            Text="経験値計算機"
            VerticalOptions="Center"
            HorizontalOptions="Center"
            FontSize="Large"/>
        <Label Grid.Row="1" Grid.Column="0" Margin="5,5,5,5"
            Text="現在のレベル"
            VerticalOptions="Center"
            HorizontalOptions="End"/>
        <Entry Grid.Row="1" Grid.Column="1" Margin="5,5,5,5"
            Text="1" WidthRequest="50"
            VerticalOptions="Center"
            HorizontalOptions="Start"/>
        <Label Grid.Row="2" Grid.Column="0" Margin="5,5,5,5"
            Text="目標のレベル"
            VerticalOptions="Center"
            HorizontalOptions="End"/>
        <Entry Grid.Row="2" Grid.Column="1" Margin="5,5,5,5"
            Text="165" WidthRequest="50"
            VerticalOptions="Center"
            HorizontalOptions="Start"/>
        <Button Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2"
            Text="計算!"
            VerticalOptions="Center"
            HorizontalOptions="Center"/>
    </Grid>
</ContentPage>

image.png

Step5. ViewModelの構築

 Data Bindingのため、ReactivePropertyを活用します。どういったライブラリなのかについては、次のページなどを参考にしてください。
  【C#】ReactiveProperty全然分からねぇ!って人向けのFAQ集【修正済】 - Qiita

 まず、ViewModelを構築します。ReactiveProperty型およびReactiveCommand型はちゃんとpublicなPropertyにする必要がある必要があることと、とりあえずINotifyPropertyChangedを実装する必要があることに注意しましょう。

MainViewModel.cs
using Reactive.Bindings;
using System;
using System.ComponentModel;

namespace CalcExpPoint
{
    class MainViewModel : INotifyPropertyChanged
    {
        public ReactiveProperty<int> NowLevel { get; } = new ReactiveProperty<int>(1);
        public ReactiveProperty<int> EndLevel { get; } = new ReactiveProperty<int>(165);
        public ReactiveProperty<string> OutputMessage { get; } = new ReactiveProperty<string>("");
        public ReactiveCommand CalcCommand { get; } = new ReactiveCommand();
        //
        public MainViewModel()
        {
            CalcCommand.Subscribe(_ => {
                // コマンドを定義する
            });
        }

        public event PropertyChangedEventHandler PropertyChanged;
    }
}

 次に、そのViewModelをBindingContextに代入します。

MainPage.xaml.cs
using Xamarin.Forms;

namespace CalcExpPoint
{
    public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();
            this.BindingContext = new MainViewModel();
        }
    }
}

 最後に、それらをXAMLに関連付けます。ReactiveProperty HogeHoge.ValueのようにBindingすることと、<ContentPage.BindingContext>で紐づけすることが重要です。

MainPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:CalcExpPoint"
    x:Class="CalcExpPoint.MainPage">

    <ContentPage.BindingContext>
        <local:MainViewModel/>
    </ContentPage.BindingContext>

    <Grid Margin="10,10,10,10">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <Label Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
            Text="経験値計算機"
            VerticalOptions="Center"
            HorizontalOptions="Center"
            FontSize="Large"/>
        <Label Grid.Row="1" Grid.Column="0" Margin="5,5,5,5"
            Text="現在のレベル"
            VerticalOptions="Center"
            HorizontalOptions="End"/>
        <Entry Grid.Row="1" Grid.Column="1" Margin="5,5,5,5"
            Text="{Binding NowLevel.Value, Mode=TwoWay}"
            WidthRequest="50"
            VerticalOptions="Center"
            HorizontalOptions="Start"/>
        <Label Grid.Row="2" Grid.Column="0" Margin="5,5,5,5"
            Text="目標のレベル"
            VerticalOptions="Center"
            HorizontalOptions="End"/>
        <Entry Grid.Row="2" Grid.Column="1" Margin="5,5,5,5"
            Text="{Binding EndLevel.Value, Mode=TwoWay}"
            WidthRequest="50"
            VerticalOptions="Center"
            HorizontalOptions="Start"/>
        <Button Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2"
            Text="計算!"
            VerticalOptions="Center"
            HorizontalOptions="Center"
            Command="{Binding CalcCommand}"/>
        <Label Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2"
            Text="{Binding OutputMessage.Value, Mode=OneWay}"
            HorizontalOptions="Center"
            VerticalOptions="Start"/>
    </Grid>
</ContentPage>

2018/03/19追記:
 現在、Xamarin Live Playerで上記コードを実行すると、次のようなエラーが出て実行できません

Visualization Error
Failed to resolve type {Name = "T";
GenericArgs = [||];
ArrayRank = 0;} (NInterpretException)

 ググっても原因は見つからず、私としても困り果てています。Androidエミュレーターでは正常に起動したので、ReactivePropertyとXamarin Live Playerの相性が悪いのだと思わr……エミュも正常に動くんだからXamarin Live Playerのバグに決まってるだろ常考
 確かにXamarin Live Playerは一部Reflectionが動作しないですが、ReactiveProperty無しでコードを書くのは今では考えられないので辛いorz

Step6. ロジックを実装

 後は思い思いにロジックを実装します。今回は必要経験値を計算したいので、エラー対策を含めこんなコードでいいんじゃないでしょうか。

Library.cs
using System.Collections.Generic;

namespace CalcExpPoint
{
    static class Library
    {
        // 達成に必要な経験値(Lv.1~Lv.165)
        private static List<int> levelExpList = new List<int> {
            0,
            0,
            100,
            300,
            //(中略)
            7320000,
            7820000,
        };
        // あるレベルにおける総経験値を返す
        private static int LevelExp(int level) => levelExpList[level];
        // レベルA→レベルBになるために必要な経験値を返す
        public static int WantExpByLevelDifference(int levelA, int levelB)
            => LevelExp(levelB) - LevelExp(levelA);
    }
}
MainViewModel.cs
public MainViewModel()
{
    CalcCommand.Subscribe(_ => {
        if(NowLevel.Value <= 0 || NowLevel.Value > 165)
        {
            OutputMessage.Value = "エラー:現在のレベルは1以上165以下の整数で入力してください";
        }else if (EndLevel.Value <= 0 || EndLevel.Value > 165)
        {
            OutputMessage.Value = "エラー:目標のレベルは1以上165以下の整数で入力してください";
        }
        else {
            OutputMessage.Value = $"必要経験値:{Library.WantExpByLevelDifference(NowLevel.Value, EndLevel.Value)}";
        }
    });
}

Step7. 本番ビルド後にそれぞれの環境で動かす

 WPF版はこんな感じ(Windows 10 64bit版の上で動作)。
image.png

 GTK版はこんな感じ(同上・Monoで動作)。
image.png

 Android版はこんな感じ(Nox Player・Android 4.2の上で動作)。
image.png