Knowlbo 開発者ブログ

株式会社Knowlboの開発者ブログです。

自社Webアプリを勝手にハックしてXamarinアプリ化してみた

開発部の醍醐です。
今日はちょっとした遊びハックな記事を・・・

こんな遊びをしてみます

自社Webアプリケーションである「勤怠管理システム タイムカードEX」の一部機能を勝手にXamarin Formsアプリとして実装してみようと思います。
Webアプリケーションに対するHTTP GET / POSTをXamarinアプリケーション内部でシミュレートするのですが、Xamarin Formsにおいて以下のような技術トピックを利用して実現します。

  • ログイン情報設定用のモーダルフォームを表示する
  • ログイン情報をアプリケーション領域に保存・読み込みする
  • WebアプリケーションのHTTP GET / POST 処理を、Xamarin Fromsアプリでバックエンドに実装する
  • レスポンスで得られたHTML内容は XDocument でスクレイピングして必要な情報を抜き出す
  • 出勤・退勤処理時に使用する位置情報をスマホ機能により取得する

タイムカードEXはこんなシステム

弊社が提供しているサービスとして「タイムカードEX」というものがあります。
これは出退勤管理を行うシステムになります。勤務シフトや勤務時間、有給休暇日数の管理等、多彩な機能があるのですが、その中で、各社員が「出勤・退勤」を行う処理の部分にのみ注目します。
また、出勤・退勤処理については「ICカードによるタッチ」や「PCブラウザからの出勤・退勤処理」などのインターフェイスが用意されているのですが、その中で「スマホから利用するWebアプリによる出勤・退勤」部分を Xamarin Forms で実装してみようと思います。

Xamarin化する予定のモバイル用Webアプリ画面

今回 Xamarin Forms化する「元のWebアプリの画面は以下のようなものになります。

【ログイン画面】
f:id:daigo-knowlbo:20161129210253p:plain

ユーザーIDとパスワードを入力する、ごく一般的なWebアプリケーションのログイン画面になります。

【出勤・退勤処理画面】
f:id:daigo-knowlbo:20161129210242p:plain

出勤時間ラベル:
 すでに出勤処理が行われている場合に、出勤時間が表示されます。
出勤チェックボックス:
 出勤時にチェックを行います。チェックを行うとHTML上で位置情報(緯度・経度)を取得して出勤用HTTP POSTが行われます。
退勤時間ラベル:
 すでに退勤処理が行われている場合に、退勤時間が表示されます。
退勤チェックボックス:
 退勤時にチェックを行います。チェックを行うとHTML上で位置情報(緯度・経度)を取得して退勤用HTTP POSTが行われます。

タイムカードEX モバイル用Webアプリ のHTTP通信を解析

正規の「Xamarin Forms + Web」の開発においては、Web側にXamarin Formsクライアントが欲する「Web API」を実装するのが通常です。
ただし、今回は「勝手にハック」での実装の為、現行のWebアプリの HTTP GET/POST をXamarin側のバックエンド処理としてシミュレートします。
そこで、まずはFiddlerを使用して現行の「タイムカードEX モバイル用Webアプリ」のHTTP通信を解析することにします。

ログイン画面表示~ログイン処理(/LoginPage.aspx)

ログイン画面の表示は「/LoginPage.aspx」への HTTP GET で行われます。

f:id:daigo-knowlbo:20161130001246p:plain

続いてユーザーID・パスワードを入力の上、「ログイン」ボタンをクリックすると HTTP POST が行われます。
応答は「302 found」で「TimeCard.aspx」ページへのリダイレクト要求となります。

f:id:daigo-knowlbo:20161130001308p:plain

出勤・退勤画面表示~出勤・退勤処理(/TimeCardPage.aspx)

ログイン完了後、TimeCardPage.aspxにリダイレクトします。この画面が、「本日の出勤時間・退勤時間の表示」および「出勤・退勤処理を行う」画面になります。
「出勤チェックボックス」にチェックを付けると、以下のような HTTP POST が行われます。

f:id:daigo-knowlbo:20161130001332p:plain

また、「退勤チェックボックス」にチェックを付けると、以下のような HTTP POST が行われます。

f:id:daigo-knowlbo:20161130001348p:plain

ということで・・・

既存Webアプリケーションにおける「ログインから出勤・退勤」までのHTTP通信の内容を把握することができました。
では、Xamarin側での具体的な実装に進みます。

ログインID・パスワードの設定画面

ログイン処理を行うための「ログインID・パスワード」を設定する画面を用意する事とします。
画面実装はシンプルな「ログインID」「パスワード」の2つの入力領域、「OK」「キャンセル」の各ボタンを配しただけのものとなります。

f:id:daigo-knowlbo:20161130022001p:plain

実装は以下の通りです。

// AccountSetting.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" 
    x:Class="TimeCardExApp.AccountSetting"
    Padding="0,20,0,0">
  <ContentPage.Content>
    <Grid>
      <Grid.RowDefinitions>
        <RowDefinition Height="40"/>
        <RowDefinition Height="40"/>
        <RowDefinition Height="40"/>
      </Grid.RowDefinitions>
      <Grid.ColumnDefinitions>
        <ColumnDefinition />
        <ColumnDefinition />
      </Grid.ColumnDefinitions>

      <Label Grid.Row="0" Grid.Column="0" Text="ログインID" />
      <Entry Grid.Row="0" Grid.Column="1" x:Name="LoginId" />

      <Label Grid.Row="1" Grid.Column="0" Text="パスワード" />
      <Entry Grid.Row="1" Grid.Column="1" IsPassword="true" x:Name="Password" />

      <Button Grid.Row="2" Grid.Column="0"  Text="OK" Clicked="OkButton_Clicked" />
      <Button Grid.Row="2" Grid.Column="1"  Text="Cancel" Clicked="CancelButton_Clicked" />
    </Grid>
  </ContentPage.Content>
</ContentPage>
// AccountSetting.xaml.cs
using System;
using System.Collections.Generic;

using Xamarin.Forms;

namespace TimeCardExApp
{
  public partial class AccountSetting : ContentPage
  {
    public AccountSetting()
    {
      InitializeComponent();

      if (Application.Current.Properties.ContainsKey("LoginId"))
        this.LoginId.Text = Application.Current.Properties["LoginId"].ToString();
      if (Forms.Application.Current.Properties.ContainsKey("Password"))
        this.LoginId.Text = Forms.Application.Current.Properties["Password"].ToString();
    }

    public void OkButton_Clicked(object sender, EventArgs e)
    {
      Forms.Application.Current.Properties["LoginId"] = this.LoginId.Text;
      Forms.Application.Current.Properties["Password"] = this.Password.Text;

      this.Navigation.PopModalAsync();
    }

    public void CancelButton_Clicked(object sender, EventArgs e)
    {
      this.Navigation.PopModalAsync();
    }
  }
}

コンストラクタでは、Application.Current.Propertiesを通じて LoginId / Password を取得しています。また、OKボタンクリック時には、同様にApplication.Current.Propertiesを通じて LoginId / Password を設定しています。
ApplicationクラスはXamarin.Forms名前空間に実装されたクラスであり、「Current」が現在のアプリケーションを指します。PropertiesプロパティにKey/Value形式で値を設定することで、スマホ側に設定データをシリアライズしておくことが可能です。
このストレージの仕組みはXamarin Forms側で組み込みで用意されている機能となります。アプリケーション終了後も維持される保存領域となります。

ログイン処理(認証Cookieの取得)

タイムカードEX モバイルWebアプリでは、ユーザーIDとパスワードによる「ログイン認証」を行っています。
モバイルWebアプリの「ログイン」ボタンクリック時の HTTP POST をXamarin側でシミュレートする事とします。
注意点として、一般的なWebアプリケーションと同様にタイムカードEXでは「Cookieベースのセッション管理」を行っています。
つまり、Cookieを伴った HTTP GET/POST をシミュレートする必要があります。
もう1点、調査する中で分かった事として、タイムカードEXは ASP.NET WebFormの Event Validation が有効であることが分かりました。
つまり、ログイン HTTP POSTの際に、直前で取得したLoginPage.aspxレスポンスHTMLから「VIEWSTATE / EVENTVALIDATION ...」等の一連のhiddennフィールドの値を一式合わせて HTTP POST する必要があります。

以上の事から、ログイン処理の為に、以下の処理を行う事としました。

  • LoginPage.aspxに対してCookieを有効にしたHTTP通信を行う
    → HttpClientHandler / HttpClient / CookieContainer(を利用する事で実装
  • LoginPage.aspxを HTTP GET した結果に対して、特定要素をスクレイピングする
    →XDocumentクラスを用いてHTMLをスクレイピングする。
  • 上記処理で得られた情報及びユーザー設定情報を元に LoginPage.asp への HTTP POSTを行う

結果として、以下のような実装となります(本実装を含め HTTP通信や位置情報関連の各種実装は TimeCardExOperatorクラス にまとめて実装する前提とします)。

// TimeCardExOperator.csの一部
public class TimeCardExOperator
{
  /// <summary>
  /// 認証クッキー
  /// </summary>
  private CookieContainer _authCookie = null;

  // ...省略

  public void Login(string loginId, string password)
  {
    // TimeCardExOperator.siteUrlは定数でサイトURLルートを定義しています。
    var uri = new Uri(TimeCardExOperator.siteUrl + "/LoginPage.aspx");
    this._authCookie = new CookieContainer();
   
    using (var handler = new HttpClientHandler()
    {
      CookieContainer = this._authCookie
    })
    using (var client = new HttpClient(handler)
    {
      BaseAddress = uri
    })
    {
      // LoginPage.aspxへGET
      var getResult = client.GetAsync("").Result;
      var getResHtml = getResult.Content.ReadAsStringAsync().Result;
  
      // XDocumentでスクレイピング
      var xDoc = System.Xml.Linq.XDocument.Parse(getResHtml);
      XNamespace ns = "http://www.w3.org/1999/xhtml";
      //  inputタグ要素のうち name属性値が「__VIEWSTATE / __VIEWSTATEGENERATOR / __EVENTVALIDATION」の要素のvalue値を取得
      string viewState = xDoc.Descendants(ns + "input").Where(d => d.Attribute("name").Value == "__VIEWSTATE").FirstOrDefault().Attribute("value").Value;
      string viewStateGenerator = xDoc.Descendants(ns + "input").Where(d => d.Attribute("name").Value == "__VIEWSTATEGENERATOR").FirstOrDefault().Attribute("value").Value;
      string eventValidation = xDoc.Descendants(ns + "input").Where(d => d.Attribute("name").Value == "__EVENTVALIDATION").FirstOrDefault().Attribute("value").Value;

      // LoginPage.aspxへPOST
      var content = new FormUrlEncodedContent(
        new Dictionary<string, string>
        {
          { "__LASTFOCUS", "" },
          { "__EVENTTARGET", "" },
          { "__EVENTARGUMENT", "" },
          { "__VIEWSTATE", viewState },
          { "__VIEWSTATEGENERATOR",  viewStateGenerator },
          { "__SCROLLPOSITIONX", "0" },
          { "__SCROLLPOSITIONY", "0" },
          { "__EVENTVALIDATION", eventValidation },
          { "textBox_UserName", loginId },
          { "textBox_Password", password },
          { "imageButton_Login.x", "0" },
          { "imageButton_Login.y", "0" },
          { "textBox_Lat", "" },
          { "textBox_Lng", "" },
        });

      var postResult = client.PostAsync("", content).Result;
      var postResHtml = postResult.Content.ReadAsStringAsync().Result;
    }
  }
}

cookieに関して「this._authCookie = new CookieContainer();」としていますが、クラスプロパティに値を保持し、後で出勤・退勤処理の際にも使いまわすことを想定しています。

現在の出勤・退勤状況の取得

ログイン後「TimeCardPage.aspx」ページを HTTP GET することで、本日の出勤時間・退勤時間情報を取得することができます。
TimeCardPage.aspxページは、仕様として以下のHTMLを出力します。

  • 既に出勤している場合
    その出勤時間を spanタグで id属性値が label_StartTime_toWork という要素として表示
  • 既に退勤している場合
    その退勤時間を spanタグで id属性値が label_EndTime_toWork という要素として表示

上記に従いXDocumentでスクレイピングを行い「現在の出勤時間・退勤時間状況」を取得します。

public class TimeCardExOperator
{

  // ...省略

  // TimeCardExOperator.csの一部
  public void GetCurrentStatus(out string startTimeToWork, out string endTimeToWork)
  {
    // TimeCardExOperator.siteUrlは定数でサイトURLルートを定義しています。
    var uri = new Uri(TimeCardExOperator.siteUrl + "/TimeCardPage.aspx");

    using (var handler = new HttpClientHandler()
    {
      CookieContainer = this._authCookie
    })
    using (var client = new HttpClient(handler)
    {
      BaseAddress = uri
    })
    {
      // TimeCardPage.aspxへGET
      var getResult = client.GetAsync("").Result;
      var getResHtml = getResult.Content.ReadAsStringAsync().Result;

      // XDocumentでスクレイピング
      var xDoc = System.Xml.Linq.XDocument.Parse(getResHtml);
      XNamespace ns = "http://www.w3.org/1999/xhtml";
      //  spanタグ要素のうち name属性値が「label_StartTime_toWork / label_EndTime_toWork」の要素の値を取得
      startTimeToWork = xDoc.Descendants(ns + "span").Where(d => d.Attribute("id") != null && d.Attribute("id").Value == "label_StartTime_toWork").FirstOrDefault().Value;
      endTimeToWork = xDoc.Descendants(ns + "span").Where(d => d.Attribute("id") != null && d.Attribute("id").Value == "label_EndTime_toWork").FirstOrDefault().Value;
    }
  }
}

位置情報の取得

出勤・退勤処理の際には、「何処で出勤・退勤処理を行ったか」を保持する為、緯度経度の位置情報をクライアントからサーバーに対して通知(HTTP POST)します。
スマホネイティブ機能による位置情報取得を行うため、「Geolocator Plugin for Xamarin and Windows」を利用します。
これにより、iOS / android共通の実装コードを利用することが可能になります。
Visual Studio for Macなどを使用している場合、NuGetで「Xam.Plugin.xxxx」を参照することになります。

具体的な実装は以下になります。

// TimeCardExOperator.csの一部

// ...省略
using Plugin.Geolocator;
using Plugin.Geolocator.Abstractions;

public class TimeCardExOperator
{
  // ...省略
  
  /// <summary>
  /// 現在位置(経度)
  /// </summary>
  private double currentLongitude = -1;

  /// <summary>
  /// 現在位置(緯度)
  /// </summary>
  private double currentLatitude = -1;

  // ...省略

  /// <summary>
  /// 位置情報取得を開始します
  /// </summary>
  public void StartGps()
  {
    IGeolocator locator = CrossGeolocator.Current;

    if (!locator.IsListening)
    {
      locator.PositionChanged += (object sender, PositionEventArgs posArgs) =>
      {
        this.currentLongitude = posArgs.Position.Longitude;
        this.currentLatitude = posArgs.Position.Latitude;
      };
      locator.StartListeningAsync(60, 5, false).Wait();
    }
  }

  /// <summary>
  /// 位置情報取得を停止します
  /// </summary>
  public void StopGps()
  {
    IGeolocator locator = CrossGeolocator.Current;

    locator.StopListeningAsync();
  }
}

StartGps()メソッドでGPSの補足を開始します。位置情報変更イベント時(PositionChanged)には、クラスプロパティ「currentLongitude / currentLatitude」にその値を反映させます。
StopGps()メソッドでGPSの補足を終了します。
本記事内説明では触れませんが、アプリケーションライフサイクル(「アプリの起動時(Appコンストラクタ)」「スリープ時(App.OnSleep())」「レジューム時(App.OnResume())」)の中で、Start() / Stop()の制御を行います。

出勤・退勤処理の実装

出勤ボタン・退勤ボタンがクリックされた際の実装を行います。
事前のHTTP解析により、以下の HTTP POST 処理を行う事が分かっています。

【出勤時のHTTP POST】 f:id:daigo-knowlbo:20161130013607p:plain

【退勤時のHTTP POST】 f:id:daigo-knowlbo:20161130013736p:plain

出勤・退勤処理時の HTTP POST は、一部を除いて重複しますので、共通メソッド「StartEndToWork(WorkOperationType workOperationType)」というものを用意します。
また、ログイン処理と同様に、HTTP POST前に「TimeCardPage.aspx」に対する HTTP GET & 応答HTMLのスクレイピングを行い、__VIEWSTATE等の必要hiddenフィールド値を取得します。スクレイピングで得られたこれらの値は、続いて行う HTTP POST で利用します。
実装は以下の通りです。

// TimeCardExOperator.csの一部
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Xml.Linq;
using System.Linq;
// ...省略

public class TimeCardExOperator
{
  // ...省略
  
  /// <summary>
  /// 出勤をキックします
  /// </summary>
  public void StartToWork()
  {
    this.StartEndToWork(WorkOperationType.Start);
  }
  
  /// <summary>
  /// 退勤をキックします
  /// </summary>
  public void EndToWork()
  {
    this.StartEndToWork(WorkOperationType.End);
  }
  
  /// <summary>
  /// 出勤もしくは退勤のHTTP POSTを実行します
  /// </summary>
  /// <param name="workOperationType">
  /// 出勤もしくは退勤の操作種別
  /// </param>
  private void StartEndToWork(WorkOperationType workOperationType)
  {
    // TimeCardExOperator.siteUrlは定数でサイトURLルートを定義しています。
    var uri = new Uri(TimeCardExOperator.siteUrl + "/TimeCardPage.aspx");
  
    using (var handler = new HttpClientHandler()
    { // this._authCookieにはログイン認証を経たCookieが保持されています。
      CookieContainer = this._authCookie
    })
    using (var client = new HttpClient(handler)
    {
      BaseAddress = uri
    })
    {
      // TimeCardPage.aspxへGET
      var getResult = client.GetAsync("").Result;
      var getResHtml = getResult.Content.ReadAsStringAsync().Result;
  
      // XDocumentでスクレイピング
      //  inputタグ要素のうち name属性値が「__VIEWSTATE / __VIEWSTATEGENERATOR / __EVENTVALIDATION」の要素のvalue値を取得
      var xDoc = System.Xml.Linq.XDocument.Parse(getResHtml);
      XNamespace ns = "http://www.w3.org/1999/xhtml";
      string viewState = xDoc.Descendants(ns + "input").Where(d => d.Attribute("name").Value == "__VIEWSTATE").FirstOrDefault().Attribute("value").Value;
      string viewStateGenerator = xDoc.Descendants(ns + "input").Where(d => d.Attribute("name").Value == "__VIEWSTATEGENERATOR").FirstOrDefault().Attribute("value").Value;
      string eventValidation = xDoc.Descendants(ns + "input").Where(d => d.Attribute("name").Value == "__EVENTVALIDATION").FirstOrDefault().Attribute("value").Value;
  
      // TimeCardPage.aspxへPOST
      var postParams = new Dictionary<string, string>
        {
        { "textBox_Lat", this.currentLatitude.ToString() },
        { "textBox_Lng", this.currentLongitude.ToString() },
        { "__EVENTARGUMENT", ""},
        {"__VIEWSTATE", viewState },
        { "__VIEWSTATEGENERATOR",  viewStateGenerator },
        { "__EVENTVALIDATION", eventValidation },
        {"__SCROLLPOSITIONX", "0" },
        {"__SCROLLPOSITIONY", "0" },
        {"__LASTFOCUS", "" },
      };
      if (workOperationType == WorkOperationType.Start)
      {
        postParams.Add("__EVENTTARGET", "checkBox_IsStart_toWork");
        postParams.Add("checkBox_IsStart_toWork", "on");
      }
      else
      {
        postParams.Add("__EVENTTARGET", "checkBox_IsEnd_toWork");
        postParams.Add("checkBox_IsEnd_toWork", "on");
      }
      var content = new FormUrlEncodedContent(postParams);
  
      var result = client.PostAsync("", content).Result;
      var text = result.Content.ReadAsStringAsync().Result;
    }
  }
}

メインフォームの実装

上述で、主なロジックである「TimeCardExOperatorクラス」の実装説明を行なってきました。
これを使用する画面部分の実装は以下のようになります。

// TimeCardExAppPage.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:TimeCardExApp" 
    x:Class="TimeCardExApp.TimeCardExAppPage"
    Padding="0,20,0,0">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="90" />
      <RowDefinition Height="40" />
      <RowDefinition Height="40" />
      <RowDefinition Height="40" />
      <RowDefinition Height="40" />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
      <ColumnDefinition />
      <ColumnDefinition />
    </Grid.ColumnDefinitions>

    <Image Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" x:Name="logo" />
    <Button Grid.Row="1" Grid.Column="0" Text="出勤" Clicked="StartToWorkButton_Clicked"/>
    <Label Grid.Row="1" Grid.Column="1" x:Name="StartTimeToWorkLabel" VerticalTextAlignment="Center" Text="" />
    <Button Grid.Row="2" Grid.Column="0" Text="退勤" Clicked="EndToWorkButton_Clicked"/>
    <Label Grid.Row="2" Grid.Column="1" x:Name="EndTimeToWorkLabel" VerticalTextAlignment="Center" Text="" />
    <Button Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2" Text="refresh" Clicked="RefreshButton_Clicked"/>
    <Button Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2" Text="ログイン設定" Clicked="SettingsButton_Clicked"/>
  </Grid>
</ContentPage>
// TimeCardExAppPage.xaml.cs
using System;
using Xamarin.Forms;

using TimeCardExLib;

namespace TimeCardExApp
{
  public partial class TimeCardExAppPage : ContentPage
  {
    private TimeCardExOperator _timeCardExOperator;

    public string LoginId
    {
      get
      {
        return (string)Xamarin.Forms.Application.Current.Properties["LoginId"];
      }
    }

    public string Password
    {
      get
      {
        return (string)Xamarin.Forms.Application.Current.Properties["Password"];
      }
    }

    public TimeCardExAppPage()
    {
      InitializeComponent();
      InitializeLogo();
    }

    public void SetTimeCardExOperator(TimeCardExOperator timeCardExOperator)
    {
      this._timeCardExOperator = timeCardExOperator;

      if (!Xamarin.Forms.Application.Current.Properties.ContainsKey("LoginId"))
      {
        this.Navigation.PushModalAsync(new AccountSetting()).Wait();
      }
      else
      {
        InitializeCurrentStatus();
      }
}

    public async void SettingsButton_Clicked(object sender, EventArgs e)
    {
      await this.Navigation.PushModalAsync(new AccountSetting());
      this._timeCardExOperator.Login(this.LoginId, this.Password);
      this.RefreshButton_Clicked(this, null);
    }

    public void RefreshButton_Clicked(object sender, EventArgs e)
    {
      string startTimeToWork, endTimeToWork;
      this._timeCardExOperator.Login(this.LoginId, this.Password);
      this._timeCardExOperator.GetCurrentStatus(out startTimeToWork, out endTimeToWork);
      this.StartTimeToWorkLabel.Text = startTimeToWork;
      this.EndTimeToWorkLabel.Text = endTimeToWork;
    }

    public async void StartToWorkButton_Clicked(object sender, EventArgs e)
    {
      this._timeCardExOperator.Login(this.LoginId, this.Password);
      this._timeCardExOperator.StartToWork();
      this.InitializeCurrentStatus();

      await DisplayAlert("タイムカードEX", "出勤記録を行いました。", "閉じる" );
    }
 
    public async void EndToWorkButton_Clicked(object sender, EventArgs e)
    {
      this._timeCardExOperator.Login(this.LoginId, this.Password);
      this._timeCardExOperator.EndToWork();
      this.InitializeCurrentStatus();

      await DisplayAlert("タイムカードEX", "退勤記録を行いました。", "閉じる");
    }

    private void InitializeCurrentStatus()
    {
      string startTimeToWork, endTimeToWork;

      this._timeCardExOperator.Login(this.LoginId, this.Password);
      this._timeCardExOperator.GetCurrentStatus(out startTimeToWork, out endTimeToWork);

      this.StartTimeToWorkLabel.Text = startTimeToWork;
      this.EndTimeToWorkLabel.Text = endTimeToWork;
    }

    private void InitializeLogo()
    {
      this.logo.WidthRequest = 322;
      this.logo.HeightRequest = 90;
      this.logo.Source = ImageSource.FromResource("TimeCardExApp.Resources.logo.png");
    }
  }
}

最終的なメイン画面は以下のような感じです。

f:id:daigo-knowlbo:20161130020743p:plain

また、App.xaml.csで初期化処理及びスリープ・レジューム時の処理を記述しています。

//App.xaml.cs
using Xamarin.Forms;

using TimeCardExLib;

namespace TimeCardExApp
{
  public partial class App : Application
  {
    private TimeCardExOperator _timeCardExOperator;

    public App()
    {
      InitializeComponent();

      TimeCardExAppPage timeCardExAppPage = new TimeCardExAppPage();

      this._timeCardExOperator = new TimeCardExOperator();
      this._timeCardExOperator.StartGps();
      timeCardExAppPage.SetTimeCardExOperator(_timeCardExOperator);

      MainPage = timeCardExAppPage;
    }

    protected override void OnStart()
    {
      // Handle when your app starts
    }

    protected override void OnSleep()
    {
      this._timeCardExOperator.StopGps();
    }

    protected override void OnResume()
    {
      this._timeCardExOperator.StartGps();
    }

    // iOS対策
    public void EnterBackground() => OnSleep();
    public void EnterForeground() => OnResume();
  }
}

それから、iOS版で上手いこと Sleep / Resume のイベントが上がるように AppDelegate.cs にも手を入れています。

// AppDelegate.cs
using System;
using System.Collections.Generic;
using System.Linq;

using Foundation;
using UIKit;

namespace TimeCardExApp.iOS
{
  [Register("AppDelegate")]
  public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
  {
    private App application;

    public override bool FinishedLaunching(UIApplication app, NSDictionary options)
    {
      global::Xamarin.Forms.Forms.Init();

      application = new App();
      LoadApplication(application);

      return base.FinishedLaunching(app, options);
    }

    public override void OnActivated(UIApplication uiApplication)
    {
    }
    public override void DidEnterBackground(UIApplication uiApplication)
    {
      base.DidEnterBackground(uiApplication);
      application.EnterBackground();
    }

    public override void WillEnterForeground(UIApplication uiApplication)
    {
      base.WillEnterForeground(uiApplication);
      application.EnterForeground();

    }
  }
}

ライフサイクルイベント辺りの処理は、以下の ゆ〜か さんの記事を参考にさせていただきました。

tamafuyou.hatenablog.com

まとめ

全体的に煩雑な記事になってしまいましたが、Webアプリケーションを無理やりハックしてXamarin Formsアプリ化して遊んでみたお話をつらつらと書かせていただきました。
プロジェクト一式のソースを見ないと、説明的には省略している部分が多々ありますので、追ってGithubにソース一式をあげようかな・・・検討中・・・
ということで、冒頭にもあげさせていただいた以下の技術トピックについては参考になるのではないかと思います。

  • ログイン情報設定用のモーダルフォームを表示する
    → Navigation.PushModalAsync()を使用

  • ログイン情報をアプリケーション領域に保存・読み込みする
    → Xamarin.Forms.Application.Current.Properties を使用

  • WebアプリケーションのHTTP GET / POST 処理を、Xamarin Fromsアプリでバックエンドに実装する
    → HttpClient / HttpClientHandler / CookieContainerを使用

  • レスポンスで得られたHTML内容は XDocument でスクレイピングして必要な情報を抜き出す
    → XDocumentを使用

  • 出勤・退勤処理時に使用する位置情報をスマホ機能により取得する
    → Geolocator Plugin for Xamarin and Windows を使用

PowerShell スクリプトで気をつけたい事

開発部の本橋です。 Windows でバッチ処理といえば cmd.exe ですが、cmd.exe で物足りない時に PowerShell を使う機会も増えてきました。PowerShell スクリプトを書く時に気をつけたい事をいくつか書いてみます。

推奨される Verb

PowerShell 標準のコマンドレットには 動詞-名詞 という命名規則があります。自分で function を作る時にもこの命名規則に従っておくと他の人が使う時にもわかりやすくていいです。

さらに、動詞(Verb)の部分には推奨される単語があります。Get-Verb コマンドレットで推奨される Verb を確認できます。

> Get-Verb

Verb Group
---- -----
Add Common
Clear Common
Close Common
Copy Common
Enter Common
Exit Common
...(略)

# 使いたい Verb を引数に取ることもできる。
> Get-Verb Save

Verb Group
---- -----
Save Data

動詞-名詞 の命名規則に従わなくても、さらには推奨される Verb を使わなくても別に良いのですが、ルールに則っておくと後々変な名前に悩まされることが少なくなります。

カレントディレクトリが2つある(ように見える)

pwd(Get-Locationコマンドレットのエイリアス)で取得できるカレントディレクトリと、 .NET 側の API である[System.IO.Directory]::GetCurrentDirectory()で取得できるカレントディレクトリが違います。

# カレントディレクトリは C:\Users\motohashi
> pwd

Path
----
C:\Users\motohashi

# .NET で用意されている API を使っても同じ
> [System.IO.Directory]::GetCurrentDirectory()
C:\Users\motohashi

# Cドライブの直下に移動するとカレントディレクトリは変わる
> cd C:\
> pwd

Path
----
C:\

# .NET で用意されている API を使うと変わっていない
> [System.IO.Directory]::GetCurrentDirectory()
C:\Users\motohashi

どうやらpwdは現在のセッションのカレントディレクトリを返し、[System.IO.Directory]::GetCurrentDirectory()は PowerShell プロセスのカレントディレクトリを返す、ということのようです。*1*2

これで何が困るかというと、.NET 側のクラスを使って相対パスでファイルを保存するようなケースです。

# 相対パスを指定してXMLファイルを読み込み
# pwd で取得できるディレクトリからの相対パスとなる。
$xmldoc = [xml](cat 'path/to/xml')

# ... xmlを変更 ...

# 相対パスを指定してXMLファイルを書き込み
# [System.IO.Directory]::GetCurrentDirectory() で取得できるディレクトリからの相対パスとなる。
$xmldoc.save('path/to/xml')

相対パスを使わずに絶対パスを使うようにするか、[System.IO.Directory]::SetCurrentDirectory()で PowerShell プロセスのカレントディレクトリを変更するようにするといいですね。

「Visual Studio for Mac」でXamarin Forms + ASP.NET Coreシステムを作る

Knowlbo開発部の醍醐と申します。

弊社開発部にて技術ブログを始める事になりましたので、個人的に気になっている技術、業務上得た知識や経験等々、ご紹介させていただければと思います。

Connect(); //2016が開催されました

先日、日本時間 11/16深夜 に行われた開発者向けオンラインイベント「 Connect(); // 2016」にて数々の新技術の発表が行われました。Connect()というイベントは昨年も含め、開発者にとって大きな、そして大変興味深い発表がなされる場となっています。

connectevent.microsoft.com

その中での発表の1つとして「Visual Studio for Mac Preview Release」というものがありました。
ここ数年、マイクロソフトはLinuxやMac、iOSやandroidなど、プラットフォームを超えた世界、そしてOSSとの協調に力を注いできています。
Connecti()イベント内でも「any developer / any app / any platform」というキーワードが出されておりました。

Mobile First(スマートフォン)

最近のマイクロソフトのイベントで常に謳われているキーワードとして「cloud first / mobile first」というものがあります。
今や当たり前であり、あえて声高々宣言するのが恥ずかしいかと思われるほど当たり前の事柄となりつつあります。
B to Cでは特に当たり前、B to Bでも1歩遅れつつ mobile first が具現化してきているのではないかと思います。
そんな中、弊社はワークフローシステム(ワークフローEX)という自社サービスをお客様に展開しておりますが、比較的お固めのプロダクトで有ることもあり、mobile first化は十分に行われていませんでした。しかし、弊社社長からも mobile にもっと力を入れるというような発言もあり、モバイル・スマホへの注力も行われていくものと思われます。

Visual Studio for Macで「クライアント(スマホアプリ)とサーバーサイドWebAPI」を実装する

ということで、前置きが長かったのですが、「Visual Studio for Mac」を利用してスマホアプリ+サーバーWebAPIのアプリ開発を行ってみたいと思います。
スマホアプリは「Xamarin Forms」で開発を行い、サーバーWebAPIは「ASP.NET Core」で実装します。

Visual Studio for Mac以前の世界では、MacではXamarin Studioを利用することでXamarinスマホアプリを実装する事ができました。 そして、サーバーWebAPIが必要であれば、Visual Studio Codeを利用して(もしくは別技術を利用して)実装を行う必要がありました。
つまり、Visual Studio for Macの登場により、同一の開発環境によりスマホアプリ側もサーバーWebAPI側も実装する事が可能になったのです(そしてWindowsを使わずにMacだけで)。

こんなアプリを作る

以下がスマホアプリ(Xamarinアプリ)のスクリーンショットです。

f:id:daigo-knowlbo:20161119134935p:plain

上部に「Get PRODUCTS!」ボタンが配置されており、これをクリックするとWebAPI呼び出しが行われます。
サーバーサイドのWebAPI(これはASP.NET Coreアプリ)は製品一覧を取得し、この情報をJSON形式でXamarinアプリ側に返却します。
Xamarinアプリは受け取ったJSONデータの内容をリスト形式で画面に表示します。

プロジェクトの構成

ソリューションは「Xamarinアプリ側」「ASP.NET Coreアプリ側」の2つをそれぞれ別々に用意する事とします。
以下が完成したプロジェクトの形です。

f:id:daigo-knowlbo:20161119151211p:plain

ASP.NET Core で WebAPIを実装する

1.Visual Studio for Macでプロジェクトを新規作成
New Project を選択します。

f:id:daigo-knowlbo:20161119151426p:plain

プロジェクトテンプレートから「.NET Core→App→ASP.NET Core→ASP.NET Core Empty Web Application」を選択します。

f:id:daigo-knowlbo:20161119151530p:plain

プロジェクト名・ソリューション名はここでは「FirstStepWeb」としました。場所は「/Users/daigo/Projects/vsMac/」とします。

f:id:daigo-knowlbo:20161119151837p:plain

2.NuGetパッケージを追加
「プロジェクトを選択→マウス右ボタンメニュー→追加→NuGetパッケージの追加」から「Microsoft.AspNetCore.Mvc」を追加します。

f:id:daigo-knowlbo:20161119152041p:plain

f:id:daigo-knowlbo:20161119152119p:plain

これはASP.NET Core MVCの機能によりWebAPIインターフェイスの実装を行う為です。

3.アプリケーション初期化処理の追記
.NET CoreのエントリーポイントはMain()ファンクションです。
Main()ファンクション内で「.UseStartup()」として構成初期化クラスとして明示されたのがStartup.csでの実装です。
Startupクラス内にて、ASP.NET Core MVCを有効にします。

// Startup.cs(ウィザードによって出力された余分なコードは削除しています)
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace FirstStepWeb
{
  public class Startup
  {
    public void ConfigureServices(IServiceCollection services)
    {
      services.AddMvc();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
      app.UseMvc();
    }
  }
}

4.Productモデルを作成
製品モデルを表すProductクラスを実装するため、Product.csファイルをプロジェクトに追加します。

「プロジェクトを選択→マウス右ボタンメニュー→追加→新しいフォルダー」を選択し「Models」という名称にします。
Modelsフォルダ配下にProduct.csファイルを追加します。

// Models/Product.cs
namespace FirstStepWeb.Models
{
  public class Product
  {
    public int Id { get; set; }
    public string Name { get; set; }
    public string PicUrl { get; set; }
  }
}

5.WebAPIを実装
WebAPIのI/Fを実装する為、ProductController.csを追加します。
先程のModelsと同じ要領で、プロジェクトフォルダー直下に「Controllers」フォルダーを追加し、更にControllersフォルダー配下に「ProductController.cs」ファイルを追加します。

// Contorllers/ProductController.cs
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using FirstStepWeb.Models;

namespace FirstStepWeb.Controllers.Api
{
  [Route("api/product")]
  public class ProductController : Controller
  {
    [HttpGet("list")]
    public IActionResult List()
    {
      return Ok(this.GetProducts());
    }

    // テスト用データを作成するサンプルメソッド
    private List<Product> GetProducts()
    {
      List<Product> result = new List<Product>();

      result.Add(new Product() { Id = 0, Name = "ワークフローEX", PicUrl = "./WorkflowEX.jpg" });
      result.Add(new Product() { Id = 1, Name = "タイムカードEX", PicUrl = "./TimeCardEx.jpg" });
      result.Add(new Product() { Id = 2, Name = "Office View", PicUrl = "./OfficeView.jpg" });
      result.Add(new Product() { Id = 3, Name = "VisitView", PicUrl = "./VisitView.jpg" });

      return result;
    }
  }
}

本実装はサンプルである為、GetProducts()メソッドに固定のサンプルデータを作成するロジックを実装しています。実プロジェクトでは、データベースに対するクエリーであり、サービスクラス・ドメインクラス・DAOクラスもしくはRepositoryクラス等の呼び出しに置き換わります。

また「Rout属性」を付与する事で WebAPI のルーティング定義を行なっています。つまり「api/product/list」というリクエストが行われると、ProductController.GetProducts()メソッドの呼び出しにルーティングが行われます。

「OK()メソッド」に取得したProductリストを渡しています。これによりProductオブジェクトリストが自動的にJSON形式に変換され呼び出し元クライアントにレスポンスとして送信されます。

6.外部IPからの受け口の用意
ここで実装したWebAPIに対して、外部IPからアクセス可能にするように実装を調整します。
自動生成された本プロジェクトは、デフォルトでは「http://localhost:5000」にてHTTPリクエストをホストする構成となっています。
スマホアプリ(ローカルPC外)から、本WebAPIへのHTTPリクエストを受け入れる為に、Program.csに「.UseUrls("http://0.0.0.0:5000")」の1文を追記します。

// Program.cs
using System;
using System.IO;
using Microsoft.AspNetCore.Hosting;

namespace FirstStepWeb
{
  public class Program
  {
    public static void Main(string[] args)
    {
      var host = new WebHostBuilder()
        .UseKestrel()
        .UseContentRoot(Directory.GetCurrentDirectory())
        .UseIISIntegration()
        .UseStartup<Startup>()
        .UseUrls("http://0.0.0.0:5000")
        .Build();

      host.Run();
    }
  }
}

以上でWebAPI側の実装は完了しました。左上の▶︎の実行ボタンをクリックしてみましょう。

f:id:daigo-knowlbo:20161119154016p:plain

Buildが行われ、エラーがなければ実行されます。
ブラウザが自動的に起動しますが、「http://localhost:5000/api/product/list」とURLを入れ直してEnterを押すと、以下のように、製品一覧を表すJSONデータが返却されてきます。

f:id:daigo-knowlbo:20161119161431p:plain

FirstStepWebは実行したまま、次の作業に入りましょう。

Xamarin Formsスマホアプリを実装する

では次に上記で作成したWebAPI(FirstStepWebプロジェクトにより実装された htp://xxx/api/product/list )を呼び出して、得られたJSONデータを画面にリスト表示するXamarin Fromsアプリケーションを実装します。
実装イメージは本投稿の最初の方で紹介した画面になります。

〜〜〜 Tips 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
1点、Tipsとして、MacのランチャーやDockからVisual Studio for Macを選択すると、アプリケーションが同時に1つしか起動する事ができません。今回のようにサーバーサイドとクライアントサイドを実装する場合、実行・デバッグの都合上、同時に2つのVisual Studio for Macを起動させたいと思うでしょう。
こんな時。ターミナルから以下のようにコマンドを実行する事で、同一アプリケーションを複数起動する事が可能です。

 open -n "/applications/Visual Studio.app"

〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

では、新規に2つ目のVisual Studio for Macを起動します。

1.プロジェクトの作成
先程と同じく New Project を選択します。
プロジェクトテンプレートとして「MultiPlatform→App→Xamarin.Forms→Forms App」を選択します。

f:id:daigo-knowlbo:20161119161808p:plain

App Nameは「FirstStepXamarin」とします。

f:id:daigo-knowlbo:20161119161819p:plain

f:id:daigo-knowlbo:20161119162052p:plain

2.NuGetパッケージを追加
必要な機能参照を追加する為、NuGetの追加を行います。

f:id:daigo-knowlbo:20161119162209p:plain

Xamarinアプリ側では以下の2つの処理を行う為に、2つのNuGetパッケージの参照追加を行います。

  • 「HTTPによるWebAPI呼び出し」を行う為に「Microsoft.net.http」への参照を追加
  • 「WebAPI呼び出しの結果として得られたJSONデータ」解析する為に「Newtonsoft.json」への参照を追加

f:id:daigo-knowlbo:20161119162257p:plain

f:id:daigo-knowlbo:20161119162304p:plain

上記はいずれもXamarin FormsプロジェクトへのNuGet参照追加です。

3.モデルクラスへのリンクの追加
モデルクラス(Productクラス)へのリンクをプロジェクトに追加します。
サーバーサイドの処理では Productクラス をデータとして扱い、そのJSONイメージをWebAPIの返却値として扱いました。
Xamarinクライアント側でも、得られたJSONデータをProduct型にデシリアライズして扱おうと思います。
その為に、サーバーサイド実装で用意した Product.cs を、Xamarinプロジェクト上でもソースリンクを行う事で共有する事とします。
「プロジェクトを選択→マウス→ボタンメニュー→追加→既存のフォルダーを追加」を選択します。
Modelsフォルダを選択します。

f:id:daigo-knowlbo:20161119170520p:plain

プロジェクトに含めるファイルをチェックしてOKをクリックします。

f:id:daigo-knowlbo:20161119170533p:plain

「ファイルに対するリンクを追加する」を選択してOKをクリックします。

f:id:daigo-knowlbo:20161120125528p:plain

4.UI実装
FirstStepXamarin.xamlを開き以下のように Button と ListView を配置します。

<?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:FirstStepXamarin" x:Class="FirstStepXamarin.FirstStepXamarinPage">
  <StackLayout>
    <Button Text="get products!" Clicked="getProductsClicked" />
    <ListView x:Name="productList" >
      <ListView.ItemTemplate>
        <DataTemplate>
          <TextCell Text="{Binding Name}"
                     Detail="{Binding PicUrl}" />
        </DataTemplate>
      </ListView.ItemTemplate>
    </ListView>
  </StackLayout>
</ContentPage>

Xamarin Formsは、WPF→Silverlight→UWPと続く XAML によるUI定義を受け継いでいます。各技術でXAMLのサポート機能の相違はありますが、過去にWindows開発を行ってきた開発者にとっては、感覚的にスマホアプリのUI定義が可能になっています。

5.イベントハンドラメソッドの実装追加
UI上でのボタンクリックイベントハンドラメソッドをコードビハインドファイル(FirstStepXamarinPage.xaml.cs)に実装します。
(サンプルアプリなのでイベントハンドラメソッドにベタ書きします)

// FirstStepXamarinPage.xaml.cs
using Xamarin.Forms;

using System;
using System.Collections.Generic;
using System.Net.Http;

using HelloVsWeb.Models;

using Newtonsoft.Json;

namespace FirstStepXamarin
{
  public partial class FirstStepXamarinPage : ContentPage
  {
    public HelloVsXamarinPage()
    {
      InitializeComponent();
    }

    // get prodcuts!ボタンラメソッド。
    public async void getProductsClicked(object sender, System.EventArgs e)
    {
      // Httpクライアントを作成
      HttpClient client;
      client = new HttpClient();
      client.MaxResponseContentBufferSize = 256000;

      // URLは私の環境に固定化してあります。適時IPを修正してください。
      var uri = new Uri("http://192.168.11.15:5000/api/product/list");

      // HTTPリクエスト
      var response = await client.GetAsync(uri);
      if (response.IsSuccessStatusCode)
      {
        var content = await response.Content.ReadAsStringAsync();
        // 得られたJSONデータを Product 配列にデシリアライズ
        var products = JsonConvert.DeserializeObject<List<Product>>(content);

        // リストビューにデータバインド
        this.productList.ItemsSource = products;
      }
    }
  }
}

6.実行する
では完成したXamarin Fromsアプリケーションを実行してみます。
実行方法は、iOS / Android、それぞれエミュレータ / 実機の選択がありますが、ここではandroidの実機及びiOSエミュレータで実行してみましょう。

まずandroid実機での実行です。
Visual Studio for Macウィンドウの左上から「FirstStepXamarin.droid 」を選択します。つまり、android版の実行を選択するという意味。
続いて、「Asus_Z017DA(API 23)」を選択しています。これは私がUSB接続している実機android端末Zenfone3になります。

f:id:daigo-knowlbo:20161119163710p:plain

実行の三角マーク(▶︎)をクリックすると、ビルドから実機へのランタイム及びアプリケーションイメージのデプロイが行われ、実行が行われます。
以下のような画面が実機android端末に表示されるはずです。

f:id:daigo-knowlbo:20161119134935p:plain

次にiOSエミュレータで実行します。
先程と同様に左上のUIから「FirstStepXamarin.iOS 」を選択します。適当なエミュレータ端末を選択し、実行の三角マーク(▶︎)をクリックします。

まとめ

数年前、異なる言語やライブラリが氾濫するスマホアプリ開発の世界に、Xamarinは一つの選択肢を提案しました。
マイクロソフトによるXamarin社の買収、フレームワークのOSS化や開発環境のオープンな解放により、Xamarinはより身近なものになりました。
しかしiOSアプリの開発は、Xamarinを利用しても、アップルの方針により必ずMac(Xcodeバックグラウンド)によるビルドを行う必要があります。

今回のVisual Studio for Macの登場により .NET & C# による開発がクライアント(スマホ)アプリに留まらず、サーバーサイドの処理も同様に記述・実装できることは非常に大きなことだったのではないかと考えています。
勿論、従来通り、Window & Visual Studio 2015/2017という選択肢も、いわば「鉄板」の選択肢として残っています。

私たち開発者は、ソフトウェア開発において自由な選択肢をまた1つ得たと言えるのではないかと思います。