‘Xamarin’のエントリ

ちょっと前からWeather TypingのWindowsデスクトップ版以外の更新をやっているのだが、テストが2300件くらいあって、さすがにテストが大変。てことでUIテストの自動化を本格的に調査。1週間くらいかかってようやく見えてきたのでメモ。ちなみにAndroid/UWP/WPFは実験成功して、Mac/iOSはまだ試せていない。

UIテストはいろいろな方法があると思うけど、マルチプラットフォーム対応だとAppiumがよさそう。Appiumは、WebのUIテスト自動化Seleniumと同様の方法でモバイルアプリのテストをするフレームワーク。サーバクライアントになっていて、クライアントでテストコードを書くと、サーバにJSON形式のコマンドを投げて、サーバからアプリのUIに向かってキーボードやマウスのイベントを投げてくれる。AppiumのサーバだとAndroidとiOSがテストできるが、Windows Application Driverを使えばWindowsのWPFやUWPもテストができる。クライアントについては、Pythonなどでやるのが普通かもしれないけど、Appium.WebDriverを使えばでC#で開発が可能。

まとめると、C#でUIをテストするコードを書いて、Appiumのサーバを起動すればAndroid/iOSのUIテスト、Windows Application Driverを起動すればWPF/UWPのテストが可能ということ。さらに一工夫いるが、Xamarin.Formsのアプリもテスト可能である。

Appiumのインストール
ここは普通にAppiumとAppium-doctorをインストール。Pythonとかnodeとかそのあたり。Appium-doctorを通すためにいろいろインストールする必要がある。だいたいはなんとかなるはずだが、Windowsで「bundletool.jar cannot be found」になるので環境変数「PATHEXT」に「.JAR」を追加する。これ1日はまったけどappium-doctorのソースまで見てようやく分かった。
Windows Application Driverのインストール
普通にインストール。特に困ることはない
Appium.WebDriverのインストール
とりあえずVisual StudioでUnitTestプロジェクトを作り、NuGetからインストールした。UnitTestなの? って思うけど・・・別にコンソールアプリでもいいのだが、テストケースの管理とかでUnitTestがいいかな、ってくらい
UI部品にIDを振る
テストの時にUI部品を特定するため、それぞれIDを振る。Appiumではいくつか特定の仕方があるが、これ実質Accessibility IDしか使えないよね。てことでXamarinのXAMLで、ButtonとかにAutomationId=”hoge”を入れる。だけでよければ話は簡単なんだけど、Android、UWPはうまくいったがWPFはダメだった。Xamarinのソースを見る限りWPFではViewRendererでSetAutomationIdをやっていないみたい。なので、Effectを使って特定の属性を設定すると、各プラットフォーム向けにAccessibility IDに対応する属性を設定するようにしてみた。これもEffectなの? って感じだけど、これ以外にうまい方法は思い浮かばない。

まずはEffectの属性。プラットフォーム共通。

    public static class TestEffect
    {
        public static readonly BindableProperty NameProperty =
          BindableProperty.CreateAttached("Name", typeof(string), typeof(TestEffect), "", propertyChanged: OnNameChanged);

        public static string GetName(BindableObject view)
        {
            return (string)view.GetValue(NameProperty);
        }

        public static void SetName(BindableObject view, string value)
        {
            view.SetValue(NameProperty, value);
        }

        private static void OnNameChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var view = bindable as View;
            if (view == null)
            {
                return;
            }

            var name = (string)newValue;
            view.Effects.Add(new UITestEffect());
        }
    }

    class UITestEffect : RoutingEffect
    {
        public UITestEffect() : base("com.denasu.UITestEffect")
        {
        }
    }

次に各プラットフォーム用のEffect。

[assembly: ResolutionGroupName("company name")]
[assembly: ExportEffect(typeof(App1.WPF.UITestEffect), "UITestEffect")]

namespace App1.WPF
{
    public class UITestEffect : PlatformEffect
    {
        protected override void OnAttached()
        {
            var name = TestEffect.GetName(Element);
            Control.SetValue(System.Windows.FrameworkElement.NameProperty, name);
        }

        protected override void OnDetached()
        {
            Control.SetValue(System.Windows.FrameworkElement.NameProperty, "");
        }
    }
}
[assembly: ResolutionGroupName("company name")]
[assembly: ExportEffect(typeof(App1.UWP.UITestEffect), "UITestEffect")]

namespace App1.UWP
{
    public class UITestEffect : PlatformEffect
    {
        protected override void OnAttached()
        {
            var name = TestEffect.GetName(Element);
            Control.SetValue(Windows.UI.Xaml.Automation.AutomationProperties.AutomationIdProperty, name);
        }

        protected override void OnDetached()
        {
            Control.SetValue(Windows.UI.Xaml.Automation.AutomationProperties.AutomationIdProperty, "");
        }
    }
}
[assembly: ResolutionGroupName("company name")]
[assembly: ExportEffect(typeof(App1.Droid.UITestEffect), "UITestEffect")]

namespace App1.Droid
{
    public class UITestEffect : PlatformEffect
    {
        protected override void OnAttached()
        {
            var name = TestEffect.GetName(Element);
            Control.ContentDescription = name;
        }

        protected override void OnDetached()
        {
            Control.ContentDescription = "";
        }
    }
}

XAMLではEffectを付ける。

        <Entry local:TestEffect.Name="Text1" Text="{Binding TextA}" WidthRequest="100" />
        <Entry local:TestEffect.Name="Text2" Text="{Binding TextB}" WidthRequest="100"/>
        <Label local:TestEffect.Name="Result" Text="{Binding TextC}" WidthRequest="100"/>
        <Button local:TestEffect.Name="Execute" Text="Button" Clicked="Button_Click" WidthRequest="100"/>
テストケースの作成

C#ではこんな感じ。各プラットフォームのセッションを作って、共通のテストを実行するようにしてみた。

[TestClass]
public class UnitTest
{
    [TestMethod]
    public void TestMethod1()
    {
        var session = CreateSessionUWP();

        session.FindElementByAccessibilityId("Text1").Click();
        session.Keyboard.PressKey("111");
        session.FindElementByAccessibilityId("Text2").Click();
        session.Keyboard.PressKey("222");
        session.FindElementByAccessibilityId("Execute").Click();
        Assert.AreEqual(session.FindElementByAccessibilityId("Result").Text , "111222");
    }

    private WindowsDriver<WindowsElement> CreateSessionUWP()
    {
        DesiredCapabilities appCapabilities = new DesiredCapabilities();
        appCapabilities.SetCapability("app", "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXXXXXXXXXXXXXXXX!App");
        return new WindowsDriver<WindowsElement>(new Uri("http://127.0.0.1:4723"), appCapabilities);
    }

    private WindowsDriver<WindowsElement> CreateSessionWPF()
    {
        DesiredCapabilities appCapabilities = new DesiredCapabilities();
        appCapabilities.SetCapability("app", @"c:\xxx\App1.WPF.exe");
        return new WindowsDriver<WindowsElement>(new Uri("http://127.0.0.1:4723"), appCapabilities);
    }

    private AndroidDriver<AndroidElement> CreateSessionAndroid()
    {
        DesiredCapabilities appCapabilities = new DesiredCapabilities();
        appCapabilities.SetCapability(MobileCapabilityType.DeviceName, "emulator-5554");
        appCapabilities.SetCapability(MobileCapabilityType.PlatformVersion, "8.1");
        appCapabilities.SetCapability(AndroidMobileCapabilityType.AppPackage, "com.companyname.App1");
        appCapabilities.SetCapability(MobileCapabilityType.PlatformName, "Android");
        appCapabilities.SetCapability(AndroidMobileCapabilityType.AppActivity, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.MainActivity");
        return new AndroidDriver<AndroidElement>(new Uri("http://127.0.0.1:4723/wd/hub"), appCapabilities);
    }

実行はこんな感じ。静止画じゃ分からないけど、勝手に文字が入力されてボタンが押される。

てことで、単純なUIのテストはこれで自動化できるかな。まあ、UIが崩れるとか、なんか変、っていうバグの方が多いんだけど、画面遷移とかはこれで自動化して、人が見ないと分からないようなテストにリソースを集中できれば成功かな。

そして、まだ試してないけど問題文章も自動で打てるのだろうか。そうすると不正ツールを作れてしまうかも。とりあえず打ち込み速度が遅いからよさそうだけど、設定ができたりすると厄介。

Xamarin Forms 3.0が正式リリースになったのでアップデート。今更だが,Xamarin FormsはC#でWindowsストア,Android,iOS,Macの開発ができる共通フレームワークで,共通フレームワークで足りないところはネイティブの機能を使えるのが特徴。ウェザタイではこれを使ってマルチプラットフォーム版を作っている。

Microsoftはいつもバージョン3.0で一気によくなるジンクス通り,3.0でようやく実用的になった感じがある。詳しい変更点はMicrosoft Buildの動画参照だが,ウェザタイにとって嬉しい箇所を紹介。

パフォーマンス

すごく早くなったわけじゃないんだけど,リストビューとかボタンとか,表示の途中経過が見えていたのが目立たなくなった。途中経過が見えるとパフォーマンスが悪く見えるので,これは良い感じ。

Visual State Manager

WPFにあってXamarinになかったものとしてVisual State Managerがある。例えば横画面と縦画面でUIを動的に切り替えたりするときに使う。どうしても必要だったので自作していたのだが,デフォルトで用意された。

WPF

一番大きいのがこれ。これまでXamarinのWindows版はUWP,つまりWindows 10のストアのみに対応。なので,マルチプラットフォーム版ウェザタイはWindows 7では動かない,はずだった。でもWPFに対応したことでWindows 7と同じものが動くようになる。

とはいえリリースされたばかりなのでいろいろおかしなところがある。一番困ったのがList Viewのアイテムを選択したままアイテムを削除するとNotImplementedExceptionが発生する。どうしようもないので,アイテムを削除する前に,CustomRendererからWPFネイティブの「ListView.SelectedIndex = -1」を実行することに。まあこんな感じでやろうと思えばネイティブ機能を使えるところが良い点ではある。

Weather TypingマルチOS対応も大詰め。最後にMac対応をしているのだが、なかなか難しい。どうしてもダメだったのがListViewの列の高さを動的に変える方法。ListViewの項目のうち、下半分を普段は隠しておいて、選択したときだけ全部表示する、というのを実現したい。要はランキングのアップロードボタンを隠したいってことなんだけど。

で、WindowsとAndroidではボタン等の表示非表示を切り替えるだけで問題なく動くのだが、iOSとMacはうまくいかない。いろいろ調べて、ListViewItemがクリックされたときにViewCellのForceUpdateSizeを呼ぶとiOSではうまくいった。でも同じ事をMacでやってもうまくいかない。Xamarinのソースを見てみたが、ForceUpdateSizeの実装はTODOになっていて何もやってなかった。その後、Mac側のネイティブコントロールまでいって、NSTableView.NoteHeightOfRowsWithIndexesChangedを呼び出すとか、NSTableView.ReloadDataを呼び出すとかしてもダメ。で、どうもNSTableViewDelegateのGetRowHeightで値を返せばよさそうというところまではいったのだが、XamarinがDelegateを使っているようで、ごっそり置き換えるのは困難。

というところで、Mac版はボタンを表示しっぱなしにすることに。Xamarin側で実装してくれるといいな。

あと、Mac/iOSではListViewに大量のデータを入れると表示が遅い。Windows/Androidはちょっとずつ表示してくれるけど、Mac/iOSはやってくれないっぽい。これも実装してくれるといいな。

しばらくXamarin.Forms使ってみて分かってきたこと。

1. F5を押してもビルドされない問題

F5を押すとすぐに実行されてしまうので、手動でビルド、デプロイ、実行していたが、Configuration ManagerでUWPプロジェクトのビルドとデプロイにチェックが入っていないためだった。デフォルトでオフになっているのかな。

2. Release版だと例外が発生する問題

Shared ProjectのMainPageを起動するときにSystem.PlatformNotSupportedException: ‘Arg_PlatformNotSupported’ 例外などが発生。Releaseで動かした場合だけなんだけど、そのまま例外をスルーすると動くっぽい。なんだろう。

3. Xamlのインテリセンスが死ぬ

一番困ってるのがこれ。たまに動くんだけど、何かしているといつのまにかインテリセンスが死んでいる。UWPプロジェクトを選択しているときはインテリセンスが正常に動いたことがない。”ContentPage was not found”みたいなのが出てインテリセンスの選択肢がほとんどない。Android/iOSプロジェクトを選択していると動くことも多いけど、いつの間にかインテリセンスのウィンドウが出なくなる。一応そんなときはAndroidやiOSのプロジェクトから共有プロジェクトの参照を消してビルド、もう一回共有プロジェクトの参照を追加してビルドすると出るようになったりする。かも?

とりあえずウェザタイをラズパイで動かすのが当面の目標だが、せっかくやるならモバイル対応も同時にできるとよい。ってことで前からやりたかったXamarin.Formsを試してみる。

Visual Studio 2017でクロスプラットフォームプロジェクトを作るとShared ProjectとUWP/Android/iOS用のプロジェクトができる。とりあえずWindows 10 IoTで動かすためにUWPを中心に。PCで実験してからラズパイに持って行けるのがすごく便利。

クロスプラットフォームのやり方はいろいろあって、まずはWPFでも使っていたPortable Class Library(PCL)はそのまま使える。で、WPF用の共通プロジェクトはShared Projectにして、ifdefでなんとかするかな。PCLでインターフェースを定義、プラットフォーム依存プロジェクトで実装を定義、でDependency Injectionでやるのが綺麗らしいけど、クラス数が多すぎてめんどい。

Shared Projectはそれ自体はDLLでもスタティックライブラリでもなくて、各プロジェクトに勝手にマージされるイメージっぽい。それはいいとして、ifdefのdefine値は各プラットフォームで定義されていると思ってたんだけどそういうわけではない、んですかね。とりあえず自分で各プロジェクトに_UWP__とか定義した。

XAMLの方のクロスプラットフォームはどうかというと、本を読んでいるとXamarin Formsで共通にするかプラットフォーム依存でそれぞれ作るかの2択で、やれることはそんなに変わらないように見えた。なので当然Xamarin Formsでやるんでしょ? って感じだったが、実際やってみると・・・これはプラットフォーム依存の方が楽かも、って思える。コーディング中に画面イメージが出ないのはまあ仕方ないとして、何かちょっとでも変なコードを書くとインテリセンスが動かなくなったり、とにかく不安定。まあ、何をするとおかしくなるのかを学習しながら作っていけばなんとかなる、のか? あと、コード変更した後いちいちBuild&Deployを手動でやらないと新しいバイナリでデバッグできないのは何か設定とかないのかな。

少し心配だったGPIO周り。XamarinのUWPで動くかな、と思っていたが普通にUWPのプロジェクトからGPIOのライブラリは参照できた。最終的に動くのはXamarinがエミュレーションしたものではなくてUWPそのものなのかな?

ひとまずUWP on Xamarin.Formsで作った最初の画面。