【Android】jacocoでコードカバレッジを取る

LINEで送る
Pocket

最近UTを書くようになってきたのでカバレッジを取ってみました。Gradleにjacocoのプラグインがあるのでそれを使います。

build.gradleはDroidkaigi2017のbuild.gradleを参考にしました。

apply 'jacoco'
apply plugin: 'com.android.application'

android {
    // Settings for Android...
}

jacoco {
    toolVersion = "0.7.7.201606060606"
}

// A list of directories which should be included in coverage report
def coverageSourceDirs = ['src/main/java']
// A list of files which should be excluded from coverage report since they are generated and/or framework code
def coverageExcludeFiles = ['**/R.class', '**/R$*.class', '**/com/android/**/*.*',
                            '**/BuildConfig.class', '**/*Activity*.class',
                            '**/*Fragment*.class', '**/*Receiver.class',
                            '**/*Manifest*.class', '**/*Application*.class']
task jacocoTestReport(type: JacocoReport, dependsOn: ['testUiTestDebugUnitTest']) {
    group = "Reporting"
    description = "Generate Jacoco coverage reports after running tests."
    reports {
        xml.enabled true
        html.enabled true
        csv.enabled false
        xml.destination "${buildDir}/reports/jacoco/jacocoTestReport.xml"
        html.destination "${buildDir}/reports/jacoco/html"
        classDirectories = files(
                fileTree(
                        dir: "${buildDir}/intermediates/classes/uiTest/debug",
                        exclude: coverageExcludeFiles))
    }
    sourceDirectories = files(coverageSourceDirs)
    executionData = files "${buildDir}/jacoco/testUiTestDebugUnitTest.exec"

    doLast {
        println "jacoco xml report has been generated to file://${buildDir}/reports/jacoco/jacocoTestReport.xml"
        println "jacoco html report has been generated to file://${reports.html.destination}/index.html"
    }
}

dependencies {
    // For dependencies...
}

まずapply ‘jacoco’でプラグインを適用します。そしてjacocoプラグインのバージョンを指定します。

jacoco {
    toolVersion = "0.7.7.201606060606"
}

バージョンの一覧はここにあります。

次にコードカバレッジのレポートを作るタスクを作ります。
jacocoプラグインにはJacocoReportというレポートを作成するタスクがあります。jacocoの解析対象はバイトコードのため、UTのタスク(test(ProductFlavor)DebugUnitTest)と同時に実行させる必要があるので依存させます。私の場合はuiTestというProductFlavorでUTを実行しているので依存させるタスク名はtestUiTestDebugUnitTestになります。タスク名がわからなかったら./gradlew tasksで調べます。

task jacocoTestReport(type: JacocoReport, dependsOn: ['testUiTestDebugUnitTest']) {
}

あとはJacocoReportのページを参考に設定します。私の場合はActivityやFragment等のクラスはカバレッジに含めないようにしました。先ほど書いたようにjacocoの解析対象はバイトコードのため、reports.classDirectoriesにはビルド後のパスを設定します。executionDataのパスは”${buildDir}/jacoco/test(ProductFlavor)DebugUnitTest.exec”となるようです。

// A list of directories which should be included in coverage report
def coverageSourceDirs = ['src/main/java']
// A list of files which should be excluded from coverage report since they are generated and/or framework code
def coverageExcludeFiles = ['**/R.class', '**/R$*.class', '**/com/android/**/*.*',
                            '**/BuildConfig.class', '**/*Activity*.class',
                            '**/*Fragment*.class', '**/*Receiver.class',
                            '**/*Manifest*.class', '**/*Application*.class']
task jacocoTestReport(type: JacocoReport, dependsOn: ['testUiTestDebugUnitTest']) {
    group = "Reporting"
    description = "Generate Jacoco coverage reports after running tests."
    reports {
        xml.enabled true
        html.enabled true
        csv.enabled false
        xml.destination "${buildDir}/reports/jacoco/jacocoTestReport.xml"
        html.destination "${buildDir}/reports/jacoco/html"
        classDirectories = files(
                fileTree(
                        dir: "${buildDir}/intermediates/classes/uiTest/debug",
                        exclude: coverageExcludeFiles))
    }
    sourceDirectories = files(coverageSourceDirs)
    executionData = files "${buildDir}/jacoco/testUiTestDebugUnitTest.exec"
}

結果は↓のような感じ

これでjacocoによるコードカバレッジが取れるようになりました。
次はCircleCIとCodecovを連携させてコードカバレッジをGitHubのREADMEに表示する方法について書きたいと思います。

LINEで送る
Pocket

[UI Automator 2.0] UiObject2#longClick()が効かない問題の対策

LINEで送る
Pocket

UI Automator 2.0を使ってListViewのContextMenuのテストを書きたかったのですが、UiObject2#longClick()ではどうもまくいきませんでした。

動きを見る感じロングクリックの時間が短いですね・・・
調べるとUiDevice#swipe()でロングクリックしたい場所の座標をスワイプすることで解決できるようです。

参考:http://stackoverflow.com/questions/21432561/how-to-achieve-long-click-in-uiautomator

UiDevice#swipe()の説明を見ると

Performs a swipe from one coordinate to another using the number of steps to determine smoothness and speed. Each step execution is throttled to 5ms per step. So for a 100 steps, the swipe will take about 1/2 second to complete.

とあるので最後のstepの値でロングクリックの時間を制御できそうです。
以下テストコードです。

@Test
public void deleteFilter() {
    UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

    // アプリ起動等
    // ...
    
    // ロングクリック
    UiObject2 filterList = device.wait(Until.findObject(
            By.clazz(ListView.class)), 5000);
    if (filterList == null) fail("Filter list was not found");
    List<UiObject2> filters = filterList.findObjects(
            By.clazz(LinearLayout.class).depth(2));
    if (filters == null) fail("Filter item was not found");
    assertThat(filters.size(), is(1));
    // 動かない
    // filters.get(0).longClick();
    Rect filterRect = filters.get(0).getVisibleBounds();
    device.swipe(filterRect.centerX(), filterRect.centerY(),
            filterRect.centerX(), filterRect.centerY(), 100);

    // 削除をクリック
    UiObject2 dialogContentList = device.wait(Until.findObject(
            By.res("android", "select_dialog_listview")), 5000);
    if (dialogContentList == null) fail("Dialog was not found");
    List<UiObject2> contents = dialogContentList.findObjects(
            By.clazz(RelativeLayout.class).depth(2));
    for (UiObject2 content : contents) {
        UiObject2 contentText = content.findObject(
                By.clazz(TextView.class));
        if (contentText != null && contentText.getText().equals("フィルター削除")) {
            content.click();
            break;
        }
    }

    // 結果確認
    UiObject2 emptyView = device.wait(Until.findObject(
            By.res(BuildConfig.APPLICATION_ID, "filter_emptyView")), 5000);
    assertNotNull(emptyView);
}

うまくいきました!

LINEで送る
Pocket

【Android】SQLiteのテーブルからカラムを削除する

LINEで送る
Pocket

ALTER TABLE mytable DROP COLOMN mycolomnを実行すれば完了・・・と思いきや、SQLiteはDROP COLOMNをサポートしていないらしい。

http://www.sqlite.org/faq.html#q11

そのため、以下の手順でカラムを削除した

  1. テーブルのデータを全て取得
  2. DROP TABLEでテーブル削除
  3. カラムを消したSQL文を使ってCREATE TABLEでテーブル作成
  4. テーブルのデータを復元

下のコードはfilterテーブルからfeedIdカラムを削除したコードになる。

public class DatabaseHelper extends SQLiteOpenHelper{
  
    public static final String DATABASE_NAME = "rss_manage";
    private static final int DATABASE_VERSION = 3;
    private static final int DATABASE_VERSION_ADD_FILTER_FEED_REGISTRATION = 3;

    private String createFiltersTableSQL =
            "create table " + Filter.TABLE_NAME + "(" +
                    Filter.ID + " integer primary key autoincrement,"+
                    Filter.KEYWORD + " text,"+
                    Filter.URL + " text," +
                    Filter.TITLE + " text,"+
                    Filter.ENABLED + " integer)";

    public DatabaseHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    //onCreate() is called when database is created
    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(createFiltersTableSQL);
    }
      
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        if (newVersion < oldVersion) return;
        if ((oldVersion < DATABASE_VERSION_ADD_FILTER_FEED_REGISTRATION)) {
            // Drop feed ID column in filter table, but Androd does not support drop column.
            // Copy and drop table and insert.
            ArrayList<Filter> filters = getAllFilters(db);
            String sql = "DROP TABLE " + Filter.TABLE_NAME;
            db.execSQL(sql);
            db.execSQL(createFiltersTableSQL);

            // Insert all of the filters
            insertFilters(db, filters);
        }
    }

    private void insertFilters(@NonNull SQLiteDatabase db, @NonNull ArrayList<Filter> filters) {
        try {
            db.beginTransaction();
            boolean result = true;
            for (Filter filter : filters) {
                ContentValues filterVal = new ContentValues();
                filterVal.put(Filter.TITLE, filter.getTitle());
                filterVal.put(Filter.KEYWORD, filter.getKeyword());
                filterVal.put(Filter.URL, filter.getUrl());
                filterVal.put(Filter.ENABLED, filter.isEnabled());
                long newFilterId = db.insert(Filter.TABLE_NAME, null, filterVal);
                if (newFilterId == -1) {
                    result = false;
                    break;
                }
            }
            if (result) db.setTransactionSuccessful();
        } catch (SQLiteException e) {
            e.printStackTrace();
        } finally {
            db.endTransaction();
        }
    }

    private ArrayList<Filter> getAllFilters(SQLiteDatabase db) {
        Cursor cursor = null;
        ArrayList<Filter> filters = new ArrayList<>();
        try {
            db.beginTransaction();
            String[] columns = {
                    Filter.ID,
                    Filter.TITLE,
                    Filter.KEYWORD,
                    Filter.URL,
                    Filter.FEED_ID,
                    Filter.ENABLED
            };
            cursor = db.query(Filter.TABLE_NAME, columns, "", null, null, null, null);
            if (cursor != null && cursor.getCount() > 0) {
                while (cursor.moveToNext()) {
                    int filterId = cursor.getInt(0);
                    String title = cursor.getString(1);
                    String keyword = cursor.getString(2);
                    String url = cursor.getString(3);
                    int feedId = cursor.getInt(4);
                    int enabled = cursor.getInt(5);
                    Filter filter = new Filter(filterId, title, keyword, url, feedId, enabled);
                    filters.add(filter);
                }
            }
            db.setTransactionSuccessful();
        } catch (SQLException e) {
            e.printStackTrace();
        } finally {
            if (cursor != null) {
                cursor.close();
            }
            db.endTransaction();
        }
        return filters;
    }
}
LINEで送る
Pocket

【Android】 UI Automator 2.0でUIテストをする

LINEで送る
Pocket

UI AutomatorはGoogleが開発しているAndroid向け自動UIテストフレームワークです。
2015年3月にバージョンが2.0になりGradleに対応しました。
有名なテストフレームワークのAppiumも中ではUI Automatorを実行しています。

Espressoとの違い

GoogleはテストフレームワークとしてEspressoも開発しています。EspressoとUI Automatorの違いとして最も大きいのは、UI Automatorは複数のアプリをテストできるということです。Espressoはアプリのソースコードに紐付いており、アプリのプロジェクトの中に入れる必要があります。それに対してUI Automatorは自分のアプリはもちろんのこと、設定アプリや自分で作ったツールアプリや他社のアプリ、通知バーなど自由に操作することができます。ソースコードと紐付いていないので、別プロジェクトで管理することもできますし、アプリと同じプロジェクトに含めることもできます。またUI Automatorは2.0からEspressoと併用できるようになったため、自分のアプリはEspresso、他のアプリを操作するときはUI Automatorといった書き方が可能です。

UI AutomatorはAndroid 4.3以上のみをサポートしています。まだまだAndroidでは最新のOSのみを対象とはできない状況なので、通常はAndroid 4.3以上のみを対象とていないことがほとんどかと思います。ここはProductFlavorを分けるかプロジェクトごと分けることで対応します。なおEspressoはAndroid 2.2からサポートしており、設定アプリなどを使わないテストのみであれば、Espressoのほうが幅広くテストを行えます。

設定

UI Automatorは2.0からAndroid Support Libraryの一部となり、build.gradleで簡単に使うことができるようになりました。
まずAndroid SDK ManagerからAndroid Support Repositoryをインストールします。

apply plugin: 'com.android.application'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    defaultConfig {
        applicationId "com.phicdy.uiautomator2sample"
        minSdkVersion 14
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    productFlavors {
        production {
            minSdkVersion 14
        }
        uiTest {
            minSdkVersion 18
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    testCompile 'junit:junit:4.12'
    compile 'com.android.support:appcompat-v7:23.4.0'
    compile 'com.android.support:design:23.4.0'
    uiTestCompile 'com.android.support.test:runner:0.5'
    uiTestCompile 'com.android.support.test.uiautomator:uiautomator-v18:2.1.2'
}

testInstrumentationRunnerをandroid.support.test.runner.AndroidJUnitRunnerに指定します。これでJUnit4形式でテストが書けるようになります。productFlavorをアプリ用とUI Automator用で分け、UI Automator用のminSdkVersionを18(Android 4.3)に設定します。今回はuiTestとしました。

最後に実行に必要なライブラリであるcom.android.support.test:runner:0.5とcom.android.support.test.uiautomator:uiautomator-v18:2.1.2をuiTestCompileで読み込みます。

テストを書く時や実行時は、Android Studio上のBuild VariantsをuiTestDebugに変更します。

build_variant

これでUI Automatorの設定は終わりです。

テストを書く

テストはsrc/androidTest以下にJUnit4の書き方で追加していきます。
デフォルトでApplicationTestが入っていますが必要ないので消し、新たにテストを追加します。

package com.phicdy.uiautomator2sample;

import android.content.Context;
import android.content.Intent;
import android.support.test.InstrumentationRegistry;
import android.support.test.filters.SdkSuppress;
import android.support.test.runner.AndroidJUnit4;
import android.support.test.uiautomator.By;
import android.support.test.uiautomator.UiDevice;
import android.support.test.uiautomator.UiObject2;
import android.support.test.uiautomator.Until;

import org.junit.Test;
import org.junit.runner.RunWith;

import static junit.framework.Assert.assertNotNull;
import static junit.framework.Assert.fail;
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;

@RunWith(AndroidJUnit4.class)
@SdkSuppress(minSdkVersion = 18)
public class MainUiTest {

    @Test
    public void floatingButtonTest() {
        UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

        // Launch MainActivity
        Context context = InstrumentationRegistry.getContext();
        Intent intent = context.getPackageManager().getLaunchIntentForPackage("com.phicdy.uiautomator2sample");
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);

        // Click floating button
        UiObject2 btn = device.wait(Until.findObject(By.res("com.phicdy.uiautomator2sample:id/fab")), 3000);
        if (btn == null) fail("Floating button was not found");
        btn.click();

        UiObject2 snakeBar = device.wait(Until.findObject(By.res("com.phicdy.uiautomator2sample:id/snackbar_text")), 3000);
        assertNotNull(snakeBar);
        assertThat(snakeBar.getText(), is("Replace with your own action"));
    }
}

今回はプロジェクト作成するときにTabbed Activityで作成したデフォルトのアプリのテストを作ります。
テストステップは以下の通りです。

  1. MainActiivtyを起動
  2. FloatingButtonを押す
  3. SnakeBarが出るので文言が”Replace with your own action”であることを確認

テストの初めにUiDeviceのインスタンスを取得します。
UiDeviceは端末の操作をしたり、UiObject2を取得したり、様々な場面で使います。
UiDeviceを管理するクラスを用意してもいいと思います。

UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

MainActivity起動部分です。UI Automator 2.0ではContextが使えるようになったのでstartActivity()で起動します。
MainActivityはアプリ起動時に起動されるActivityなので、context.getPackageManager().getLaunchIntentForPackage()で対象のパッケージ名を指定して起動しています。

// Launch MainActivity
Context context = InstrumentationRegistry.getContext();
Intent intent = context.getPackageManager().getLaunchIntentForPackage("com.phicdy.uiautomator2sample");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);

FloatingButtonをクリックしてSnackBarのUiObject2を取得する部分です。

UiObject2 btn = device.wait(Until.findObject(By.res("com.phicdy.uiautomator2sample:id/fab")), 3000);

UI Automator 2.0では各ViewをUiObject2として取得します。主に取得には、UiDevice#findObject()かUiDevice#wait()を使います。
UiDevice#wait()を使うとViewが描画されるまで待ってくれるのでテストの成功率が上がります。

UiDevice#wait()にはSearchConditionとtimeoutを指定します。
SearchConditionはどの条件で待つかを指定します。Untilという便利なクラスがあるので、これを使ってSearchConditionを作ります。今回は特定のUiObject2が出るまで待つので、Unitl.findObject()を使います。Unitl.findObject()にはBySelectorを指定します。BySelectorはByクラスから生成してUiObject2を特定する条件を指定します。条件はresoruse IDやテキスト、クラス名などで指定します。resource IDが確実なので、アプリ側できるだけresource IDを設定します。Android SDKのtoolsにuiautomatorviewerというツールがあるのでこれで簡単にIDやViewの階層構造を確認できます。Android Studio 2.2からはこういった機能がAndroid Studio自体に追加されるようです。

uiautomatorviewer

UiObject2を取得したらnullチェックをしてエラーハンドリングをした後、クリックします。

if (btn == null) fail("Floating button was not found");
btn.click();

クリックするとSnackBarが出るので、テキスト部分を同様に取得します。
最後にテキスト部分のnullチェックと文言を確認します。

UiObject2 snakeBarText = device.wait(Until.findObject(By.res("com.phicdy.uiautomator2sample:id/snackbar_text")), 3000);
assertNotNull(snakeBarText);
assertThat(snakeBarText.getText(), is("Replace with your own action"));

最後に

UI Automator 2.0を使うことで、UIテストが書けました。
今回は1つのアプリだけを対象としたテストでしたが、例えば設定アプリを起動して機内モードをオンにし、エラーダイアログが出るか確認するといったテストもUI Autoamtor 2.0では可能です。
UIテストを書くことでCIでのリグレッションテストや手動テストの削減ができるので、少しずつ書いていきたいです。

今回のサンプルプロジェクトはこちらです。
以上です。

LINEで送る
Pocket

【Android】MaterialShowcaseViewライブラリでチュートリアルを実装する

LINEで送る
Pocket

MyCurationの1.2.1でチュートリアルを追加しました。というのもインストールされてもすぐアンインストールされているようで、説明が足りなかったかな・・・と思い始めたためです。
実装を自分でやってもよかったのですが、デザイン的なセンスがないのと、ある程度テンプレート化されたものがあるのではということでライブラリを探しました。
今回はMaterialShowcaseViewというライブラリを使ってチュートリアルを実装してみました。

tutorial

設定

GitHubのページ通りにbuild.gradleを編集

プロジェクトのbuild.gradle

allprojects {
    repositories {
        jcenter()
        maven { url "https://jitpack.io" }
    }
}

アプリのbuild.gradle

dependencies {
  compile 'com.github.deano2390:MaterialShowcaseView:1.1.0@aar'
}

使い方

MaterialShowcaseViewでは特定のViewにハイライトを当ててチュートリアルを行います。
実装できるパターンとしては、(1)単純に追加ボタンなど1つだけを説明するパターンと、(2)ボタン1を押して次にボタン2を押して・・・といったように連続して説明をするパターンがあります。

MyCurationでは最初のRSS購読のチュートリアルを入れました。
RSS購読の流れとしては

  1. メイン画面からToolbarの「+」ボタンを押し、検索画面に行く(メイン画面)
  2. RSSを購読するサイト検索する(検索画面)
  3. サイトを開いたら追加ボタンを押す(検索画面)

です。

まずメイン画面で実装した単純に追加ボタンなど1つだけを説明するパターンです。

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    // Menu settings...

    // Start tutorial at first time
    new Handler().post(new Runnable() {
        @Override
        public void run() {
            View view = findViewById(R.id.add);
            new MaterialShowcaseView.Builder(TopActivity.this)
                    .setTarget(view)
                    .setContentText(R.string.tutorial_go_to_search_rss_description)
                    .setDismissText(R.string.tutorial_next)
                    .singleUse(SHOWCASE_ID)
                    .setListener(new IShowcaseListener() {
                        @Override
                        public void onShowcaseDisplayed(MaterialShowcaseView materialShowcaseView) {

                        }

                        @Override
                        public void onShowcaseDismissed(MaterialShowcaseView materialShowcaseView) {
                            goToFeedSearch();
                        }
                    })
                    .show();
        }
    });
    return true;
}

Builderパターンを使ってMaterialShowcaseView.Builderを作成していきます。今回はToolbar上の「+」ボタン(R.id.add)にハイライトを当てたかったのでonCreateOptionsMenu()の中でチュートリアルを作成します。
まずsetTarget()でハイライトするViewを指定します。次にチュートリアルの説明文と、チュートリアルを消す部分のテキストを設定します。singleUse()でIDを指定することで初回のみチュートリアルを表示し、同じIDのチュートリアルは今後表示しないように設定できます。今回はチュートリアルが閉じたときに次の画面(検索画面)に移りたかったので、setListener()でIShowcaseListenerをセットし、チュートリアルが閉じられたときに呼ばれるonShowcaseDismissed()で次の画面に飛んでいます。設定が終わったら最後にshow()でチュートリアルを表示します。

使ったメソッドのまとめです。

メソッド 説明
setTarget(View view) ハイライトするViewを指定
setContentText(int resource) 説明文を指定
setDismissText(int resource) タップして閉じる部分の文を指定
singleUse(String showCaseId) 1度だけの表示を管理するためのID。これを設定するとアンインストールしない限りは同じIDのチュートリアルは表示されない
setListener(IShowcaseListener listener) リスナの設定。表示時、消えた時のハンドリング
show() 表示する

次に検索画面で実装した連続して説明をするパターンです。

@Override
public boolean onCreateOptionsMenu(Menu menu) { 
    // Menu settings...

    // Start tutorial at first time
    new Handler().post(new Runnable() {
        @Override
        public void run() {
            View view = findViewById(R.id.search_rss);
            ShowcaseConfig config = new ShowcaseConfig();
            config.setDelay(500); // half second between each showcase view

            MaterialShowcaseSequence sequence = new MaterialShowcaseSequence(FeedSearchActivity.this, SHOWCASE_ID);
            sequence.setConfig(config);

            // Search tutorial
            sequence.addSequenceItem(
                    new MaterialShowcaseView.Builder(FeedSearchActivity.this)
                            .setTarget(view)
                            .setContentText(R.string.tutorial_search_rss_description)
                            .setDismissText(R.string.tutorial_next)
                            .build()
            );

            // Add button tutorial
            sequence.addSequenceItem(
                    new MaterialShowcaseView.Builder(FeedSearchActivity.this)
                            .setTarget(fab)
                            .setContentText(R.string.tutorial_add_rss_description)
                            .setDismissText(R.string.tutorial_close)
                            .setDismissOnTouch(true)
                            .build()
            );

            // Open software keyboard if tutorial already finished
            if (sequence.hasFired()) {
                searchView.setIconified(false);
            }

            sequence.start();
        }
    });
    return true;
}

今度はMaterialShowcaseSequenceというクラスのインスタンスを作り、MaterialShowcaseViewのインスタンスを追加していってチュートリアルの流れを作ります。

さきほどと同様にBuilderパターンでMaterialShowcaseView.Builderを作っていき、今回は最後にbuild()を呼ぶことでMaterialShowcaseViewを作り、MaterialShowcaseSequenceに追加します。これを繰り返すことでチュートリアルを表示→閉じる→チュートリアルを表示→閉じる→・・・と流れを作れます。作り終わったらstart()でチュートリアルを始めます。

注意した点

Toolbar上のViewがNullになる

onCreateOptionsMenu()の中でViewをfindViewById()で取るとNullが返ってきて落ちるという現象がありました。Handler#post()を使うことで遅延を発生させてこれを回避しました。

参考

How To Get Action View Of Menu Item?

チュートリアル中にキーボードが開かないようにする

検索画面では開いたときにすぐキーボードが開くようにsearchView.setIconified(false)を呼んでいたのですが、チュートリアル中は邪魔です。MaterialShowcaseSequence#hasFired()でチュートリアルが既に終わったかを判定できるので、チュートリアルでないときのみキーボードを開くようにしました。

// Open software keyboard if tutorial already finished
if (sequence.hasFired()) {
    searchView.setIconified(false);
}

終わりに

MaterialShowcaseViewを使うことでかなり簡単にチュートリアルを作成しました。さくっと作れるので、凝ったチュートリアルが必要でなければこれで十分かなと思います。以上です。

LINEで送る
Pocket