かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Windows ストアアプリ開発の土台作り

Windows ストア アプリの開発で、今始めるうえで一番無難に便利にはじめれる個人的な考えを書いてみます。

選択するフレームワーク

MS公式のライブラリということで、やっぱり一番安定してますよね。安定というのは、別に機能が豊富だとか、イケてるだとかいうのではなく、プロジェクトで採用しやすいだろうとうそういう意図が込められていたりもします。

プロジェクトテンプレート

フレームワークを使った開発は、通常のテンプレートが吐き出すコードとは、必ずしもマッチするとは限らないので専用のプロジェクトテンプレートとアイテムテンプレートを入れましょう。最初からPrismの基本クラスを継承したAppクラスや、Pageクラスが作成されます。

選択するプロジェクトテンプレート

Prismのプロジェクトテンプレートには、以下の2種類があります。

  1. Prism App
  2. Prism App using Unity

特に理由が無い限りは、本気で作るならPrism App using Unityをお勧めします。View, ViewModel, Model, Prismの提供するクラス群を組み立てて適切にセットするという手間をUnityがかなり軽減してくれます。

土台作り

では、作成します。Prism App using Unityから新規作成します。とりあえず最終目標は仰々しい足し算アプリCalcAppという名前にします。

Models名前空間を作ろう

Models名前空間にアプリケーションのコア機能を実装します。ここらへんは、別にModelsじゃなくて、もっとしっくり来る名前があったり、単一名前空間に収まらないくらいの規模で整理整頓が必要だと感じたら、適時名前空間の分割を行ったりするといいと思います。今回は、単純にCalcAppModelという名前のクラスを作成します。プロパティの変更通知は、必須機能なので、Prismの基本クラスであるBindableBaseクラスを継承して殻だけ作っておきます。

using Microsoft.Practices.Prism.StoreApps;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace CalcApp.Models
{
    public class CalcAppModel : BindableBase
    {
    }
}

このCalcAppModelは、アプリケーション内では単一のインスタンスを使いまわすようにすることで、ページやViewModel間で同じデータを元にいろいろな表現ができるようにしたいと思います。これは、Unityの機能を使って行います。Prismでは、AppクラスのOnInitializeメソッドで行います。

protected override void OnInitialize(IActivatedEventArgs args)
{
    _container.RegisterInstance(NavigationService);

    // Modelの登録
    // コンテナ内で1つのインスタンスしか作られないようにContainerControlledLifetimeManagerを設定する。
    _container.RegisterType<CalcAppModel>(new ContainerControlledLifetimeManager());
}

このように、アプリケーションのコア機能のルートになるようなクラスは、インスタンスを乱発することは少ないと思うので、Unityに管理を委ねるのが楽だと思います。

ViewModelでModelのインスタンスを使おう

では、メインのページのViewModelであるMainPageViewModel(プロジェクトテンプレートで作成されています)で先ほど作ったCalcAppModelを使用できるように準備したいと思います。Unityでコンテナに組み立ててもらうときにオブジェクトを貰うには、コンストラクタで受け取るようにするか、[Dependency]属性をつけたプロパティを作るかの2通りになります。どちらでも構わないのですが、個人的には引数受け取りすぎるコンストラクタは好みではないので、今回は後者のプロパティで受け渡してもらうようにします。

public class MainPageViewModel : ViewModel
{
    private INavigationService _navigationService;

    /// <summary>
    /// Unityからインスタンスをもらうためのプロパティ
    /// </summary>
    [Dependency]
    public CalcAppModel Model { get; set; }

    /// <summary>
    /// デフォルトではコンストラクタ経由で画面遷移を行うためのINavigationServiceを貰うようになってる
    /// </summary>
    /// <param name="navigationService"></param>
    public MainPageViewModel(INavigationService navigationService)
    {
        _navigationService = navigationService;
    }
}

ViewとViewModelの紐づけ

デフォルトではViews名前空間のMainPageViewというクラスに対して、ViewModels名前空間のMainPageViewModelがDataContextにセットされるようになっています。ViewModelのインスタンスの生成はUnityで行われるため、先ほど行ったModelを受け取るという定義が効いて、Modelプロパティに値がセットされます。

ViewからViewModelの呼び出しの確認

ViewからViewModelを呼ぶには、一般的にICommandを経由しておこないます。DelegateCommandクラスがPrismでのICommandの実装なので、下記のようにMainPageViewModelにDelegateCommand型のプロパティを作成します。

private DelegateCommand _sampleCommand;

public DelegateCommand SampleCommand
{
    get
    {
        return _sampleCommand ?? (_sampleCommand = new DelegateCommand(() =>
        {
            // ここに何か処理を書く
            // 確認用にVSのデバッグの出力にModelを出力してみる
            Debug.WriteLine(this.Model);
        }));
    }
}

Commandは、ButtonなどのCommandプロパティのあるクラスや、InvokeCommandActionなどを使って呼び出すことが出来ます。動作確認には、画面に適当にボタンを貼り付けて、Commandプロパティにバインドするのが簡単です。

<Button 
  Content="Button" 
  HorizontalAlignment="Left" 
  Margin="120,23,0,0" 
  Grid.Row="1" 
  VerticalAlignment="Top" 
  Command="{Binding SampleCommand}"/>

実行して動作確認

ここまでで、デバッグ実行してボタンを押すと、出力ウィンドウに以下のようなメッセージが表示されます。

CalcApp.Models.CalcAppModel

ここまでのまとめ

  • Prism for Windows Runtimeを使う
  • プロジェクトテンプレートでUnityを使うものを使う
  • Modelを作ってUnityに管理は任せる
  • ViewModelとModelの関連付けはUnityに任せる
  • ViewからViewModelの呼び出しにはCommandを使う

足し算アプリに仕立て上げる

では、なんとなく土台が出来たので肉付けして足し算アプリにします。最初のページで2つのテキストボックスに値を入力して、計算ボタンを押すと画面遷移をして答えが出るというものです。答えのページから戻ると、前回の入力内容はそのまま残ってるという感じでいきましょう。

Modelを作る

足し算には仰々しいですが、足し算するクラスだけ別クラスに切り出して、残りの処理はCalcAppModelに持たせました。左辺値、右辺値、答えをプロパティで持って、計算を開始するメソッドを持ってる感じです。

using Microsoft.Practices.Prism.StoreApps;
using Microsoft.Practices.Unity;
using System;
using System.Threading.Tasks;

namespace CalcApp.Models
{
    public class CalcAppModel : BindableBase
    {
        private int _lhs;

        /// <summary>
        /// 左辺値
        /// </summary>
        public int Lhs
        {
            get { return this._lhs; }
            set { this.SetProperty(ref this._lhs, value); }
        }

        private int _rhs;

        /// <summary>
        /// 右辺値
        /// </summary>
        public int Rhs
        {
            get { return this._rhs; }
            set { this.SetProperty(ref this._rhs, value); }
        }

        private int _answer;

        /// <summary>
        /// 答え
        /// </summary>
        public int Answer
        {
            get { return this._answer; }
            set { this.SetProperty(ref this._answer, value); }
        }

        private bool _isProcessing;

        /// <summary>
        /// 処理中かどうかを表す
        /// </summary>
        public bool IsProcessing
        {
            get { return this._isProcessing; }
            set { this.SetProperty(ref this._isProcessing, value); }
        }



        /// <summary>
        /// Unityにインスタンスを入れてもらう
        /// </summary>
        [Dependency]
        public Calculator Calculator { get; set; }

        /// <summary>
        /// 時間がかかる計算をするということで…
        /// </summary>
        /// <returns></returns>
        public Task CalcAsync()
        {
            return DoTask<object>(async () =>
            {
                await Task.Delay(3000);
                this.Answer = this.Calculator.Add(this.Lhs, this.Rhs);
                return null;
            });
        }

        private async Task<T> DoTask<T>(Func<Task<T>> f)
        {
            this.IsProcessing = true;
            try
            {
                return await f();
            }
            finally
            {
                this.IsProcessing = false;
            }
        }
    }

    /// <summary>
    /// 計算をするクラス
    /// </summary>
    public class Calculator
    {
        public int Add(int x, int y)
        {
            return x + y;
        }
    }
}

ViewModelを作る

計算の入力値を受け取りバリデーションをするクラス

using Microsoft.Practices.Prism.StoreApps;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace CalcApp.ViewModels
{
    public class CalcInputViewModel : ValidatableBindableBase
    {
        private string _lhs;

        [Required(ErrorMessage = "左辺値を入力してください")]
        [CustomValidation(typeof(CalcInputViewModel), "ValidateIntValue", ErrorMessage = "左辺値は整数値で入力してください")]
        public string Lhs
        {
            get { return this._lhs; }
            set { this.SetProperty(ref this._lhs, value); }
        }

        private string _rhs;

        [Required(ErrorMessage = "右辺値を入力してください")]
        [CustomValidation(typeof(CalcInputViewModel), "ValidateIntValue", ErrorMessage = "右辺値は整数値で入力してください")]
        public string Rhs
        {
            get { return this._rhs; }
            set { this.SetProperty(ref this._rhs, value); }
        }

        public static ValidationResult ValidateIntValue(string value, ValidationContext ctx)
        {
            int dummy;
            return int.TryParse(value, out dummy) ?
                ValidationResult.Success :
                new ValidationResult(null);
        }
    }
}

そして、それを保持して、コマンドの活性非活性を制御しつつ、コマンドが実行されたらモデルに値を渡して呼び出しを行って画面遷移してます。

using CalcApp.Models;
using Microsoft.Practices.Prism.StoreApps;
using Microsoft.Practices.Prism.StoreApps.Interfaces;
using Microsoft.Practices.Unity;
using System.Collections.Generic;
using System.Diagnostics;
using Windows.UI.Xaml.Navigation;
using System.Linq;

namespace CalcApp.ViewModels
{
    public class MainPageViewModel : ViewModel
    {
        private INavigationService _navigationService;

        /// <summary>
        /// Unityからインスタンスをもらうためのプロパティ
        /// </summary>
        [Dependency]
        public CalcAppModel Model { get; set; }

        private CalcInputViewModel _input;

        public CalcInputViewModel Input
        {
            get { return this._input; }
            set { this.SetProperty(ref this._input, value); }
        }

        public DelegateCommand CalcCommand { get; private set; }

        /// <summary>
        /// デフォルトではコンストラクタ経由で画面遷移を行うためのINavigationServiceを貰うようになってる
        /// </summary>
        /// <param name="navigationService"></param>
        public MainPageViewModel(INavigationService navigationService)
        {
            _navigationService = navigationService;

            this.CalcCommand = new DelegateCommand(() =>
            {
                this.Model.Lhs = int.Parse(this.Input.Lhs);
                this.Model.Rhs = int.Parse(this.Input.Rhs);
                var nowait = this.Model.CalcAsync();
                this._navigationService.Navigate("Answer", null);
            },
            () => !this.Input.Errors.Errors.Any());
        }

        public override void OnNavigatedTo(object navigationParameter, NavigationMode navigationMode, Dictionary<string, object> viewModelState)
        {
            base.OnNavigatedTo(navigationParameter, navigationMode, viewModelState);
            this.Input = new CalcInputViewModel
            {
                Lhs = this.Model.Lhs.ToString(),
                Rhs = this.Model.Rhs.ToString()
            };

            // エラーに変更があったらコマンドの活性非活性を切り替える
            this.Input.ErrorsChanged += (_, __) => this.CalcCommand.RaiseCanExecuteChanged();
        }

    }
}

Answerのほうは、ViewModelこんな感じで、ModelのプロパティをProxyするのがだるくなったのでModelをそのまま持つだけにしました。

using CalcApp.Models;
using Microsoft.Practices.Prism.StoreApps;
using Microsoft.Practices.Unity;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Windows.UI.Xaml.Navigation;

namespace CalcApp.ViewModels
{
    public class AnswerPageViewModel : ViewModel
    {
        [Dependency]
        public CalcAppModel Model { get; set; }
    }
}

あとは適当にViewつくっとけばいいです。

画面

力尽きた。コードだけ

<prism:VisualStateAwarePage
    x:Name="pageRoot"
    x:Class="CalcApp.Views.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CalcApp.Views"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:prism="using:Microsoft.Practices.Prism.StoreApps"
    mc:Ignorable="d"
    prism:ViewModelLocator.AutoWireViewModel="True" 
    d:DataContext="{d:DesignData /SampleData/MainPageViewModelSampleData.xaml}">

    <prism:VisualStateAwarePage.Resources>
        <!-- TODO: Delete this line if the key AppName is declared in App.xaml -->
        <x:String x:Key="AppName">MainPage</x:String>
    </prism:VisualStateAwarePage.Resources>

    <!--
        This grid acts as a root panel for the page that defines two rows:
        * Row 0 contains the back button and page title
        * Row 1 contains the rest of the page layout
    -->
    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="120"/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <Grid.ChildrenTransitions>
            <TransitionCollection>
                <EntranceThemeTransition/>
            </TransitionCollection>
        </Grid.ChildrenTransitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="140"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <!-- Back button and page title -->
        <Grid Grid.ColumnSpan="2">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="120"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Button x:Name="backButton" 
                    AutomationProperties.Name="Back"
                    AutomationProperties.AutomationId="BackButton"
                    AutomationProperties.ItemType="Navigation Button"
                    Command="{Binding GoBackCommand, ElementName=pageRoot}"
                    Margin="39,59,39,0" 
                    Style="{StaticResource NavigationBackButtonNormalStyle}"
                    VerticalAlignment="Top" />
            <TextBlock x:Name="pageTitle" 
                       Grid.Column="1" 
                       IsHitTestVisible="false" 
                       Margin="0,0,30,40"
                       Style="{StaticResource HeaderTextBlockStyle}" 
                       Text="{StaticResource AppName}" 
                       TextWrapping="NoWrap" 
                       VerticalAlignment="Bottom" />
        </Grid>
        <TextBox HorizontalAlignment="Left" Margin="67,10,0,0" Grid.Row="1" TextWrapping="Wrap" Text="{Binding Input.Lhs, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Top" Grid.Column="1" Width="315"/>
        <TextBox HorizontalAlignment="Left" Margin="67,63,0,0" Grid.Row="1" TextWrapping="Wrap" Text="{Binding Input.Rhs, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Top" Grid.Column="1" Width="315"/>
        <TextBlock Grid.Column="1" HorizontalAlignment="Left" Margin="11,10,0,0" Grid.Row="1" TextWrapping="Wrap" Text="左辺値" VerticalAlignment="Top" Style="{StaticResource CaptionTextBlockStyle}"/>
        <TextBlock Grid.Column="1" HorizontalAlignment="Left" Margin="11,63,0,0" Grid.Row="1" TextWrapping="Wrap" Text="右辺値" VerticalAlignment="Top" Style="{StaticResource CaptionTextBlockStyle}" />
        <TextBlock Grid.Column="1" HorizontalAlignment="Left" Margin="398,10,0,0" Grid.Row="1" TextWrapping="Wrap" Text="{Binding Input.Errors[Lhs][0], Mode=OneWay}" VerticalAlignment="Top" Style="{StaticResource BaseTextBlockStyle}" Foreground="Red"/>
        <TextBlock Grid.Column="1" HorizontalAlignment="Left" Margin="398,63,0,0" Grid.Row="1" TextWrapping="Wrap" Text="{Binding Input.Errors[Rhs][0], Mode=OneWay}" VerticalAlignment="Top" Style="{StaticResource BaseTextBlockStyle}" Foreground="Red"/>
        <Button Content="計算" Grid.Column="1" HorizontalAlignment="Left" Margin="305,129,0,0" Grid.Row="1" VerticalAlignment="Top" Width="80" Command="{Binding Path=CalcCommand}"/>
    </Grid>
</prism:VisualStateAwarePage>
<prism:VisualStateAwarePage
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CalcApp.Views"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:prism="using:Microsoft.Practices.Prism.StoreApps"
    xmlns:Interactivity="using:Microsoft.Xaml.Interactivity" xmlns:Core="using:Microsoft.Xaml.Interactions.Core" 
    x:Name="pageRoot"
    x:Class="CalcApp.Views.AnswerPage"
    mc:Ignorable="d"
    prism:ViewModelLocator.AutoWireViewModel="True" 
    d:DataContext="{d:DesignData /SampleData/AnswerPageViewModelSampleData.xaml}">

    <prism:VisualStateAwarePage.Resources>
        <!-- TODO: Delete this line if the key AppName is declared in App.xaml -->
        <x:String x:Key="AppName">AnswerPage</x:String>
    </prism:VisualStateAwarePage.Resources>

    <!--
        This grid acts as a root panel for the page that defines two rows:
        * Row 0 contains the back button and page title
        * Row 1 contains the rest of the page layout
    -->
    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
        <Grid.ChildrenTransitions>
            <TransitionCollection>
                <EntranceThemeTransition/>
            </TransitionCollection>
        </Grid.ChildrenTransitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="140"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <!-- Back button and page title -->
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="120"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Button x:Name="backButton" 
                AutomationProperties.Name="Back"
                AutomationProperties.AutomationId="BackButton"
                AutomationProperties.ItemType="Navigation Button"
                Command="{Binding GoBackCommand, ElementName=pageRoot}"
                IsEnabled="{Binding CanGoBack, ElementName=pageRoot}"
                Margin="39,59,39,0" 
                Style="{StaticResource NavigationBackButtonNormalStyle}"
                VerticalAlignment="Top" />
            <TextBlock x:Name="pageTitle" 
                Grid.Column="1" 
                IsHitTestVisible="false" 
                Margin="0,0,30,40"
                Style="{StaticResource HeaderTextBlockStyle}" 
                Text="{StaticResource AppName}" 
                TextWrapping="NoWrap" 
                VerticalAlignment="Bottom" />
        </Grid>
        <TextBlock Text="{Binding Model.Answer}" Margin="119,46,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" Grid.Row="1" Style="{StaticResource BodyTextBlockStyle}">
            <Interactivity:Interaction.Behaviors>
                <Core:DataTriggerBehavior x:Name="True1" Binding="{Binding Model.IsProcessing}" Value="True">
                    <Core:ChangePropertyAction PropertyName="Visibility">
                        <Core:ChangePropertyAction.Value>
                            <Visibility>Collapsed</Visibility>
                        </Core:ChangePropertyAction.Value>
                    </Core:ChangePropertyAction>
                </Core:DataTriggerBehavior>
                <Core:DataTriggerBehavior x:Name="False1" Binding="{Binding Model.IsProcessing}" Value="False">
                    <Core:ChangePropertyAction PropertyName="Visibility"/>
                </Core:DataTriggerBehavior>
            </Interactivity:Interaction.Behaviors>
        </TextBlock>
        <ProgressRing HorizontalAlignment="Center" VerticalAlignment="Center" Grid.RowSpan="2" Width="50" Height="50" IsActive="True">
            <Interactivity:Interaction.Behaviors>
                <Core:DataTriggerBehavior x:Name="True" Binding="{Binding Model.IsProcessing}" Value="True">
                    <Core:ChangePropertyAction PropertyName="Visibility">
                        <Core:ChangePropertyAction.Value>
                            <Visibility>Visible</Visibility>
                        </Core:ChangePropertyAction.Value>
                    </Core:ChangePropertyAction>
                </Core:DataTriggerBehavior>
                <Core:DataTriggerBehavior x:Name="False" Binding="{Binding Model.IsProcessing}" Value="False">
                    <Core:ChangePropertyAction PropertyName="Visibility">
                        <Core:ChangePropertyAction.Value>
                            <Visibility>Collapsed</Visibility>
                        </Core:ChangePropertyAction.Value>
                    </Core:ChangePropertyAction>
                </Core:DataTriggerBehavior>
            </Interactivity:Interaction.Behaviors>
        </ProgressRing>
    </Grid>
</prism:VisualStateAwarePage>

Modelが処理中の時はAnswerPageではプログレスリング出してます。それくらい?

Microsoft SkyDrive - Access files anywhere. Create docs with free Office Web Apps.