やる気駆動型エンジニアの備忘録

WPF(XAML+C#)の話題を中心に.NET/Android/CI やたまに趣味に関するブログです

(Jenkins)Android アプリのビルド・ユニットテスト・Lint を実行する

最近、Android アプリの開発をメインにやってます。 Android でもCI やりたいなーと思って色々調べて何とか最低限のジョブは実行できるようになったので、その内容についてメモ。

開発環境

今回は、Windows Server 2012 R2 上にいるJenkins がSlave 兼開発環境であるWindows10 に対してジョブを実行する構成となっています。

実行するジョブ

今回は以下のジョブをPipeline で実行してみます。

Lint については、以下のページを参照してください。

Lint によるコードの改善 | Android Studio

また、Jenkins でLint のレポートを集計する場合は以下のプラグインを予めインストールする必要があります。

Android Lint Plugin - Jenkins - Jenkins Wiki

Pipeline Script

node('<スレーブ名>') {

    stage('Get Repository') {
        // リポジトリからソースコードを取得する。
        git branch: 'develop', credentialsId: '<認証ID>', url: '<対象のリポジトリ クローンURL>'
    }
    
    stage('Build Debug') {
        // コマンドラインからGradle を実行する。
        // ./gradlew 以降の引数は、Android Studio でビルドした際にGradle が実行するタスクを列挙している。
        bat './gradlew :app:generateDebugSources :app:generateDebugAndroidTestSources :app:mockableAndroidJar :app:prepareDebugUnitTestDependencies build'
    }
    
    stage('Lint Report') {
        // Lint を実行・集計する。
        androidLint canComputeNew: false, defaultEncoding: 'UTF-8', healthy: '', pattern: '**/lint-results*.xml', shouldDetectModules: true, unHealthy: '', unstableTotalAll: '0'
    }

    stage('Unit Test') {
        // ユニットテストを実行
        bat './gradlew clean test'

        // HTML で実行結果がレポートされる。(こっちが見やすい)
        // レポートはデフォルトでは"app/build/reports/tests/testDebugUnitTest/"に出力される。
        publishHTML([allowMissing: true, alwaysLinkToLastBuild: true, keepAll: true, reportDir: 'app/build/reports/tests/testDebugUnitTest/', reportFiles: 'index.html', reportName: 'JUnit Results'])

        // JUnit 結果をカウントするためにレポート結果を集計する。
        // レポートはデフォルトでは"/app/build/test-results/testDebugUnitTest/"配下にあるXML ファイルが対象となる。
        junit allowEmptyResults: true, testResults: '**/app/build/test-results/testDebugUnitTest/*.xml'
    }

}

ハマった箇所

  1. コマンドラインが実行できない。
    Windows 上で"bat"コマンドを実行できない場合があります。
    その場合は、スレーブの設定から環境変数にパスを設定する必要があります。
    コマンドプロンプトは通常以下の場所にあるので環境変数から"Path"を追加して値を"C:\Windows\System32;"としましょう。
  2. Lint の検査に引っかかるとビルドが停止する。
    Lint はGradle のデフォルト設定では検査結果に異常があればビルド失敗として扱われます。
    その為、1件でも検査エラーがあれば、Jenkins の以降のタスクが実行されません。
    これでは検査エラーが有った場合でもレポートを出力したいときなどに困ります。
    そこで、Android プロジェクトの/app/build.gradle ファイルからビルド失敗時にAbort させないように設定する必要があります。
    変更箇所は以下の通り。
android {
    lintOptions {
        abortOnError false
    }
}

Lint オプションについては以下のページを参照してください。

LintOptions - Android Plugin 3.0.0-dev DSL Reference

(Jenkins)Pipeline を使う

Jenkins のPipeline の使い方と環境設定についてメモ。

Pipeline について

Jenkins のPipeline 機能が追加されてからもう2年近くが経ちますが、今の今までGroovy が分からなくて触ってなかったのですが、最近使って便利だったので使い方と環境設定についてメモします。
Jenkins Pipeline の概要については以下のページが参考になります。

jenkins.io

www.buildinsider.net

開発環境

Pipeline を使う

Pipeline の使い方、Groovy の書き方についての日本語情報は以下のページが非常に参考になりました。

kimulla.hatenablog.com こうやってスクリプトとその結果を画像で並べていると見やすくていいですね~

これだけだと参考ページの紹介で終わってしまうので、実際に使ってみてハマった箇所の解決方法を幾つか挙げてみます。

  • 環境設定でハマった箇所
    Slave を作成する際、ノード プロパティの環境変数にSlave の端末の環境変数を設定しておく必要があります。
    これをしていないと、例えばWindows batch を実行できなくなります。
    特にキー:PATH の環境変数は必須です。

  • Pipeline Syntax で使用したいPlugin が表示されない
    Pipeline のコード スニペットを使用する際に、"Sample Step" から実行したいJob を選択してPipeline Script を生成することが大半だと思います。
    この時、Sample Step に表示されるJob はPipeline に対応したPlugin のみ列挙されます。
    Plugin はバージョンによってはPipeline に対応していないものも存在するため、一度バージョンアップを試してみましょう。
    私の場合は、"Task Scanner Plugin" でこの問題にハマりました。

軽く使用しただけでもPipeline 機能は非常に便利なので、今後も使用していきたいと思います。

(Android)スクリーンのON/OFFを検出する

Android端末でスクリーンのON/OFFを検出する方法についてメモ。

開発環境

  • 開発環境 : Android Studio 3.0
  • Compile Sdk Version : API26 Android8.0 (O)
  • Min Sdk Version : API23 Android6.0 (Mashmallow)

スクリーンのON/OFFを検出する

通常、スクリーンのON/OFFを検出するにはIntentFilter を設定したBroadcastReceiver を使用します。
今回はサンプルとしてWakeLockBroadcastReceiver というスクリーンON/OFFを検出するためのBroadcastReceiver クラスを作成し、スクリーンの状態が変わるとWakeLockListener というリスナーに通知します。
Intent のAction 文字列は、スクリーンの表示が"Intent.ACTION_SCREEN_ON"、スクリーンの非表示が"Intent.ACTION_SCREEN_OFF"となっています。
以下、コード例です。

<WakeLockBroadcastReceiver.java

package com.example.iyemon.wakelocksample;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;

public final class WakeLockBroadcastReceiver extends BroadcastReceiver {

    private WakeLockListener listener;

    public WakeLockBroadcastReceiver(WakeLockListener listener) {
        this.listener = listener;
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (action.equals(Intent.ACTION_SCREEN_ON)) {
            this.listener.onScreenOn();
        } else if (action.equals(Intent.ACTION_SCREEN_OFF)) {
            this.listener.onScreenOff();
        }
    }
}

<WakeLockListener.java

package com.example.iyemon.wakelocksample;

public interface WakeLockListener {
    
    void onScreenOn();

    void onScreenOff();
}


<MainActivity.java

package com.example.iyemon.wakelocksample;

import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.text.format.DateFormat;
import android.widget.TextView;

import java.util.Calendar;


public class MainActivity extends AppCompatActivity implements WakeLockListener {

    private WakeLockBroadcastReceiver wakeLockBroadcastReceiver;
    private TextView loggingTextView;

    private final String NewLine =  System.getProperty("line.separator");

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        this.loggingTextView = (TextView)findViewById(R.id.loggingTextView);

        wakeLockBroadcastReceiver = new WakeLockBroadcastReceiver(this);
        registerReceiver(wakeLockBroadcastReceiver, new IntentFilter(Intent.ACTION_SCREEN_ON));
        registerReceiver(wakeLockBroadcastReceiver, new IntentFilter(Intent.ACTION_SCREEN_OFF));
    }

    @Override
    public void onScreenOn() {
        String text = loggingTextView.getText().toString();
        loggingTextView.setText(text + NewLine + getNow() + " Screen On.");
    }

    @Override
    public void onScreenOff() {
        String text = loggingTextView.getText().toString();
        loggingTextView.setText(text + NewLine + getNow() + " Screen Off.");
    }

    private String getNow(){
        return DateFormat.format("yyyy/MM/dd HH:mm:ss", Calendar.getInstance()).toString();
    }
}

これを実行すると以下のようになります。
f:id:iyemon018:20171228011742p:plain

ハマったところ

BroadcastReceiver にIntentFilter を設定する場合、manifest ファイルにフィルター名を設定すればいいのですが、ACTION_SCREEN_ON/ACTION_SCREEN_OFF はContext#registerReceiver を使用する必要があります。
その理由については以下のページが参考になります。

qiita.com


今回使用したプロジェクトはこちら↓↓↓↓↓

github.com

(WPF)要素のスクリーンキャプチャーを保存するTriggerActionを作る

WPFで画面上の要素をキャプチャーして画像ファイルとして保存するような要件がワリとありますが、どうせならTriggerActionで作られたプロダクトは無いかなーと思って探してみたところ、すぐには見つからなかったので自作してみました。

以下、今回の開発環境です。

WPFでスクリーンキャプチャーを保存する方法は色々ありますが、今回は以下のページを参考にさせていただきました。

mseeeen.msen.jp

これをTriggerActionで実装してみた結果がこちら

<CaptureAciton.cs>

namespace CaptureTriggerSample.TriggerActions
{
    using System;
    using System.IO;
    using System.Windows;
    using System.Windows.Interactivity;
    using System.Windows.Media;
    using System.Windows.Media.Imaging;

    public class CaptureAction : TriggerAction<FrameworkElement>
    {
        public static readonly DependencyProperty FileNameProperty =
                DependencyProperty.Register("FileName"
                                            , typeof(string)
                                            , typeof(CaptureAction)
                                            , new FrameworkPropertyMetadata(null));

        public string FileName
        {
            get => (string)GetValue(FileNameProperty);
            set => SetValue(FileNameProperty, value);
        }

        protected override void Invoke(object parameter)
        {
            Rect bounds = VisualTreeHelper.GetDescendantBounds(AssociatedObject);
            DrawingVisual drawingVisual = new DrawingVisual();
            using (DrawingContext drawingContext = drawingVisual.RenderOpen())
            {
                VisualBrush visualBrush = new VisualBrush(AssociatedObject);
                drawingContext.DrawRectangle(visualBrush, null, bounds);
            }

            RenderTargetBitmap renderTargetBitmap =
                    new RenderTargetBitmap((int)bounds.Width, (int)bounds.Height, 96, 96, PixelFormats.Pbgra32);
            renderTargetBitmap.Render(drawingVisual);
            renderTargetBitmap.Freeze();

            PngBitmapEncoder png = new PngBitmapEncoder();
            BitmapFrame bitmapFrame = BitmapFrame.Create(renderTargetBitmap);
            bitmapFrame.Freeze();
            png.Frames.Add(bitmapFrame);
            using (FileStream fileStream = File.Create(FileName))
            {
                png.Save(fileStream);
            }

            MessageBox.Show($"キャプチャーを保存しました{Environment.NewLine}"
                            + $"[ファイル名]{Environment.NewLine}"
                            + $" {FileName}"
                            , "キャプチャー"
                            , MessageBoxButton.OK
                            , MessageBoxImage.Information);
        }
    }
}

※ブログ用に可読性を考慮してエラー処理などはしていません。

FileName プロパティに出力するファイル名を指定できるようにしています。

これを使うにはこのようにします。

<MainWindow.xaml

<Window
    x:Class="CaptureTriggerSample.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
    xmlns:local="clr-namespace:CaptureTriggerSample"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:triggerActions="clr-namespace:CaptureTriggerSample.TriggerActions"
    Title="MainWindow"
    Width="525"
    Height="350"
    mc:Ignorable="d">
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Click" SourceObject="{Binding ElementName=CaptureButton}">
            <triggerActions:CaptureAction FileName="Resources\Capture.png" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
    <Grid x:Name="LayoutRoot">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <Button
            x:Name="CaptureButton"
            Grid.Row="0"
            Margin="8"
            Padding="16,8"
            HorizontalAlignment="Left"
            Content="Capture" />
        <Grid Grid.Row="1" Margin="8">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>
            <TextBox x:Name="TextBox" />
            <TextBlock
                Grid.Row="1"
                Margin="0,8"
                Text="{Binding Text, ElementName=TextBox, Mode=OneWay}" />
        </Grid>
    </Grid>
</Window>

これを実行すると以下のような画面が表示されます。
f:id:iyemon018:20171215142227p:plain

CaptureボタンをクリックするとWindow要素のキャプチャーが実行されます。
(実行フォルダに"Resources"フォルダを作成する必要があります。)
f:id:iyemon018:20171215142328p:plain

実際にキャプチャーされた画像ファイルはこちら。
f:id:iyemon018:20171215142417p:plain

このように簡単に指定した領域をキャプチャーすることが可能です。
TriggerActionにしたことにより、例えばKeyTrigger を使ってCtrl + Cキー入力時にキャプチャーを保存したりすることも可能です。

ただし、1点注意することがあります。
レアケースだとは思いますが、以下の様にキャプチャー対象の要素の外側にも子要素が配置されている場合、子要素を含む領域がキャプチャーされてしまいます。
以下はVisual StudioXAMLデザイナープレビューです。
右上にEllipseを配置していますが、Grid の領域外に配置しています。
f:id:iyemon018:20171215143722p:plain

これをキャプチャーすると以下の様になります。
f:id:iyemon018:20171215144027p:plain

原因はここ

Rect bounds = VisualTreeHelper.GetDescendantBounds(AssociatedObject);

VisualTreeHelper.GetDescendantBounds は指定したビジュアル要素の全ての子要素の和集合から領域を算出します。
つまり、AssociatedObject の子要素が配置された領域全てがその対象となります。(ただし原点はあくまでAssociatedObject を基準とする。)

これを回避するには、AssociatedObject もしくはその子要素にClipToBounds="True" を設定します。
こうすることで要素の範囲外にあるものはキャプチャーされなくなります。

画面領域外にコントロールを配置することってあんまりないと思うのですが、移動アニメーション中などに予期せずキャプチャー範囲が期待したもので無くなることはあり得るので注意が必要です。


今回使用したサンプルはこちら↓↓↓↓
github.com

Visual Studioのリモートデバッグ方法メモ

Visual Studioのリモートデバッグ機能を初めて使ったのでメモ

リモートデバッグ機能とは?

リモートデバッグとは、例えば客先から受領している端末上で実行しているアセンブリに対してブレークポイントやステップ実行するための機能です。
詳細は以下のページを参照してください。

リモート デバッグ

使い方

適当にググれば出てくるのですが、一番わかりやすかったのは以下のページでした。

ネットワーク経由でのリモート ユーザーモード デバッグ – Japan WDK Support Blog


と、ここまでですんなり実行できるのかと思いきやいくつかハマりました。
ちなみに実行環境は"Visual Studio2015"のWPF アプリケーションです。

Visual Studio2015 で開発していたのですが、リモートデバッグ ツールのページは404になっています…
以下のページからダウンロードしましょう。

qiita.com

  • 実行フォルダがホストとリモートで同じでなければならない

これは上記MSDNページにも記述されていますが、開発用環境とリモート環境でアセンブリの配置フォルダ パスが同一である必要があります。
私の環境はDドライブ上で実行していたので最初は実行に失敗しました。
仕方なくプロジェクトの[プロパティ] - [ビルド] - [出力パス] をCドライブに変更しました。

  • 毎回実行ファイルをコピーする必要がある

仕方ないことなのですが、実行環境にビルドしたアセンブリを毎回配置する必要があります。
最初はUSBメモリを使用してコピーしていたのですが、面倒なのでリモート環境に共有フォルダを作成しました。
そして、上記の出力パスを共有フォルダに設定することでアセンブリ コピーの手順が不要になりました。
つまり、以下のようなイメージ

Before
f:id:iyemon018:20171205185628p:plain

After
f:id:iyemon018:20171205185630p:plain

これで毎回、開発用PCと同じ感覚でデバッグすることが可能になりました。
リモートの設定は環境に依存するため、リモート デバッグ用の構成を追加しておくのがベターだと思います。

実行環境にリモートツールをインストールしたりネットワークに接続したりといくつか条件はありますが、実行環境でしか発生しない不具合もあるため条件さえ合致すれば有用なデバッグ方法だと思いました。
ただ、この仕組み上、複数人で同時にリモートデバッグを行うといった方法を取ることはできないでしょう。