20190622のAndroidに関する記事は12件です。

[Android][Java] ListViewにCheckBoxを追加する。

2か月ほど詰まっていたので備忘録

実行環境

IDE:Android Studio 3.4
言語:Java

Xperia XZ3(実機デバッグに利用)
OSはAndroid 9 (API Level 28)

何がしたいか

ListView内のCheckBoxがスクロールによってそのチェック状態を変更されることがないようにする。

解決方法

ListViewで表示する要素を使いまわす処理をするgetViewメソッド内で、ListViewのCheckBoxのリスナーの貼り直しと、onCheckedChangedメソッド内でgetViewのpositionを利用して

ここには表記していませんが
ArrayAdapter< ListViewに表示するクラス型 >をextends(継承)、
ViewHolderパターンを使っています。

以下が成功例となります。

ListAdapter.java
// ListItemはListViewで表示する要素の自作クラス。
private ListItem item;
// 省略 ~~~~~~~~~~~~
public View getView (final int position, View convertView, ViewGroup parent) {
    ViewHolder holder;

    if (convertView == null) {
        // convertViewに何もなければ新しく生成する。
        convertView = mInflater.inflate(mResId, parent, false);

        // リスト内の各アイテムの要素を取得。
        holder = new ViewHolder();
        holder.isActive = convertView.findViewById(R.id.alarm_active);

        convertView.setTag(holder);

    } else {
        holder = (ViewHolder)convertView.getTag();
    }

    // リストビューに表示するアイテムのインスタンスを取得する。
    item = getItem(position);

    // リスナーを剥がす。
    holder.isActive.setOnCheckedChangeListener(null);

    // 要素にpositionから取得したitem(自作クラスのインスタンス)を要素へ代入。
    holder.isActive.setChecked(item.isActive());

    // リスナーを貼る。
    holder.isActive.setOnCheckedChangeListener(
        new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged (CompoundButton cb, boolean isChecked) {
                ListItem listItem = getItem(position);
                // 対warning用nullチェック
                if (listItem != null){
                    listItem.setActive(isChecked);
                }
            }
        });

    return convertView;
}

ここで重要なのは匿名クラス内のonCheckedChangedメソッド内の
ListItem listItem = getItem(position);
という処理です。

この処理はチェック状態をリストの要素(自作クラス)に反映するためのものですが、
すでにitem = getItem(position);のようにされているからといってこの部分を削除し、
item.setActive(isChecked);のようにしてしまうと正常に動作しません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Java(Kotlin/JVM)のラムダ式は必ず別インスタンスとなるわけではない

Javaの匿名インナークラスの代わりをラムダ式で書くとき、別インスタンスになると思い込んでいて、インスタンス単位での管理で問題となったメモです。

Android Architecture Components の LiveData を使って、1つの LiveData が管理しているデータソースを別の LiveData としても提供するということを実現するために、空の Observer を LiveData.observer に渡す以下のような実装でハマりました。

サンプルコード

kotlin
// observeされている間だけ1秒おきにインクリメントしていくカウンターLiveData
class Counter : LiveData<Int>() {
    private var timer: Timer? = null

    // アウタークラスのカウンターに依存しつつ、偶数だけを配信するLiveData
    var oddCounter: MutableLiveData<Int> = object : MutableLiveData<Int>() {
        override fun observe(owner: LifecycleOwner, observer: Observer<Int>) {
            super.observe(owner, observer)

            // アウタークラスにカウンターの実体があるため、
            // そちらも observe することで active にし、カウンターを開始する
            this@Counter.observe(owner, Observer<Int> { })
        }
    }

    override fun onActive() {
        val task = object : TimerTask() {
            override fun run() {
                var nextCount = (value ?: 0) + 1

                postValue(nextCount)
                if (nextCount % 2 != 0) {
                    oddCounter.postValue(nextCount)
                }
            }
        }
        timer = Timer()
        timer?.scheduleAtFixedRate(task, 0, 1000)
    }

    override fun onInactive() {
        timer?.cancel()
    }
}

class MainKotlinActivity : AppCompatActivity() {
    private val counter = Counter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 複数のownerがobserveする実装のサンプルで、アクティビティとプロセスがobserveするコード
        counter.oddCounter.observe(this, Observer<Int> { value ->
            value?.let { println("activity got $it") }
        })
        counter.oddCounter.observe(ProcessLifecycleOwner.get(), Observer<Int> { value ->
            value?.let { println("process got $it") }
        })
    }
}

これを実行すると、 this@Counter.observe(owner, Observer<Int> {}) というコードが、異なる owner インスタンスに対して2回呼び出されますが、2回目で java.lang.IllegalArgumentException: Cannot add the same observer with different lifecycles という例外が発生します。

原因は、次の通り。

  • Observer<Int> { } は、何度実行しても(同じクラス内では)同じインスタンスを返す
  • LiveData.observe仕様 で、1つのobserverを異なるownerでは使えない

おそらく、 Observer<Int> { } だとクロージャが呼び出し元のスコープに依存しておらず固定であるため、1つのインスタンスを流用しているんだろう。
実際、 Observer<Int> { }Observer<Int> { print(owner.toString()) } とする、つまり呼び出しのたびに変わる owner に依存する実装になっていれば、別インスタンスを返しました。

そもそもバイトコードとしては匿名インナークラスとラムダ式だと全く異なるんだろうと思い、 http://www.ne.jp/asahi/hishidama/home/tech/java/lambda.html#h_invokedynamic このへんを読んでフムフム。


今回、空実装のObserverインスタンスを作りたくて Observer<Int> { } と書いてしまっていたわけですが、IDE の出す warning に従ったらそうなってしまったという背景があります。実際、以下のようにいくつかの方法で書けるわけですが。。

もっともオーソドックスなやり方: Java の匿名インナークラス。

java
Observer<Integer> nullObserver = new Observer<Integer>() {
    @Override
    public void onChanged(@Nullable Integer integer) {
    }
};
observe(owner, nullObserver);

でもこれは、Android Studio に「can be replaced with lambda」と提案され、それに従うと以下のようにラムダ式を使うコードに変換されます。

java
Observer<Integer> nullObserver = value -> {};
observe(owner, nullObserver);

Kotlinでも同様。

kotlin
val nullObserver = object : Observer<Int> {
    override fun onChanged(t: Int?) {
    }
}
observe(owner, nullObserver);
kotlin
val nullObserver = Observer<Int> { }
observe(owner, nullObserver);

それぞれの違いを意識して使い分けないといかんなと、再認識しました。

単にもとの匿名インナークラスやオブジェクト式のままにしておい場合、別の人がIDEの提案に従って再度同じ変更をしてしまう可能性もあるため、以下のように専用のクラスをインスタンス化するようにしました。

kotlin
private class NullObserver<T> : Observer<T> {
    override fun onChanged(t: T?) {
    }
}

observe(owner, NullObserver<Int>())

いまqiita記事をまとめながら、ソースコードコメントでなにか補足しておいても良かったのかなとも思いました。

おわり。


ちなみに、ここに書いたサンプルコードだと Transformations を使えばこんなややこしい空observeみたいなことをしなくてすむ気もしますが、実際のコードはもう少し込み入った事情があったりはします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【内部用】android/javaでのアニメーション

はじめに

基本的にアプリは動くのでアニメーションは大事です。
特にゲームエディタ企画(ScriptEditor班以外)において今から記述するcanvasのアニメーションは習得必須です

仕組み

今回はviewのonDrawを繰り返し実行することでアニメーションを実現しています
もっといい方法があるのかもしれません

注意

パッケージ名をcom.test.gameeditorとしていますが適切なものに直して下さい

activity_main.xml

LinearLayout がrootにあってその中に独自viewがある一般的な編成です

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <com.test.gameeditor.MyCanvas
        android:id="@+id/canvas"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</LinearLayout>

MainActivity.java

レイアウトをactivity_main.xmlに設定する処理とMyCanvasのonDraw()を60msに一回呼ぶ処理が書かれています

MainActivity.java
package com.test.gameeditor;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.os.Handler;

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final MyCanvas canvas = (MyCanvas) findViewById(R.id.canvas);
        final Handler handler = new Handler();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                canvas.invalidate();
                handler.postDelayed(this, 60);
            }
        };
        handler.post(runnable);
    }
}

MyCanvas.java

アニメーションの内容をonDraw()の中に記述します。

MyCanvas.java
package com.test.gameeditor;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.view.View;
import android.util.AttributeSet;
public class MyCanvas extends View {
    private Paint mPaint = new Paint();
    Context context;
    int x=100;
    public MyCanvas(Context context) {
        super(context);
    }
    public MyCanvas(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.context = context;
    }
    @Override
    protected void onDraw(android.graphics.Canvas canvas) {
        super.onDraw(canvas);
        x+=5;
        canvas.drawRect(0,0,x,getHeight(),mPaint);
    }
}

あとがき

これをマスターすればアニメーションが描けるよ


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity (2018.3)でAndroidのapkをコマンドラインビルドする for mac

Unity で Android の apk ファイルを
コマンドラインからビルドする手続きです。

ビルドはコマンドラインから行いますが、
詳細なビルド手続きは Unityプロジェクト内で記述しておく必要があります。

環境

mac : 10.14.5 (Mojave)
unity : 2018.3.0f2

unity側でのビルド手続き

UnityでBuilder.csというファイルを作成します。
(一応 Asset/Editor 直下に作成したけど、場所はどこでも良いのかな??)

ここで定義したクラス名と関数名は Builder.Build という形で
シェルスクリプトの UNITY_BUILDE_NAME で参照されます。

outputPath で指定したパスにapkファイルが出力されます。
今回の状態では、Unityのプロジェクトフォルダ直下に生成されます。

Builder.cs
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System.Linq;

public static class Builder
{
    public static void Build()
    {
        var paths = GetBuildScenePaths();
        var fileName = "app.apk";
        var outputPath = $"./{fileName}";
        var buildTarget = BuildTarget.Android;
        var buildOptions = BuildOptions.Development;

        var buildReport = BuildPipeline.BuildPlayer(
            paths.ToArray(),
            outputPath,
            buildTarget,
            buildOptions
        );

        var summary = buildReport.summary;

        if (summary.result == UnityEditor.Build.Reporting.BuildResult.Succeeded) {
            Debug.Log("Success");
        } else {
            Debug.LogError("Error");
        }
    }

    private static IEnumerable<string> GetBuildScenePaths()
    {
        var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
        return scenes
            .Where((arg) => arg.enabled)
            .Select((arg) => arg.path);
    }
}

シェルスクリプト

コマンドラインからのビルド手続きをシェルスクリプトで書きます。

ビルドログは UNITY_LOG_PATH で指定したパスに出力されるので、
うまくビルドができない場合は、logファイルで原因を確認しましょう。

unityBuild.sh
#!/bin/bash

UNITY_APP_PATH=/Applications/Unity/Unity.app/Contents/MacOS/Unity
UNITY_PROJECT_PATH=Unityプロジェクトのパスを入力
UNITY_BUILDE_NAME=Builder.Build
UNITY_LOG_PATH=./build.log

$UNITY_APP_PATH -batchmode \
    -quit \
    -projectPath $UNITY_PROJECT_PATH \
    -executeMethod $UNITY_BUILDE_NAME \
    -logfile $UNITY_LOG_PATH \
    -platform Android \
    -isRelease false

if [ $? -eq 1 ]; then
    echo "error!! check logfile: ${UNITY_LOG_PATH}"
    exit 1
fi
echo "success!!"
exit 0

ターミナル

以下コマンドをターミナルから実行します。
Unityが起動した状態で行うとうまくビルドができないので、
Unityは終了させた状態で実行します。

ターミナルから実行
sh unitybuild.sh

実行後正常に完了したら、apkファイルがoutputPathに生成されますので、
実機にインストールして動作を確認しましょう。

github

プロジェクトはgithubにアップロードしてます
https://github.com/becky3/unity_command_build/tree/commandline_android

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityのAndroidビルドをコマンドラインから行う (Mac用)

Unity で Android の apk ファイルを
コマンドラインからビルドする手続きです。

ビルドはコマンドラインから行いますが、
詳細なビルド手続きは Unityプロジェクト内で記述しておく必要があります。

iOSビルドの手続きはこちらの記事に記載しています。

環境

Mac : 10.14.5 (Mojave)
Unity : 2018.3.0f2

unity側でのビルド手続き

UnityでBuilder.csというファイルを作成します。
(一応 Asset/Editor 直下に作成したけど、場所はどこでも良いのかな??)

ここで定義したクラス名と関数名は Builder.Build という形で
シェルスクリプトの UNITY_BUILDE_NAME で参照されます。

outputPath で指定したパスにapkファイルが出力されます。
今回の状態では、Unityのプロジェクトフォルダ直下に生成されます。

Builder.cs
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System.Linq;

public static class Builder
{
    public static void Build()
    {
        var paths = GetBuildScenePaths();
        var fileName = "app.apk";
        var outputPath = $"./{fileName}";
        var buildTarget = BuildTarget.Android;
        var buildOptions = BuildOptions.Development;

        var buildReport = BuildPipeline.BuildPlayer(
            paths.ToArray(),
            outputPath,
            buildTarget,
            buildOptions
        );

        var summary = buildReport.summary;

        if (summary.result == UnityEditor.Build.Reporting.BuildResult.Succeeded) {
            Debug.Log("Success");
        } else {
            Debug.LogError("Error");
        }
    }

    private static IEnumerable<string> GetBuildScenePaths()
    {
        var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
        return scenes
            .Where((arg) => arg.enabled)
            .Select((arg) => arg.path);
    }
}

シェルスクリプト

コマンドラインからのビルド手続きをシェルスクリプトで書きます。

ビルドログは UNITY_LOG_PATH で指定したパスに出力されるので、
うまくビルドができない場合は、logファイルで原因を確認しましょう。

unityBuild.sh
#!/bin/bash

UNITY_APP_PATH=/Applications/Unity/Unity.app/Contents/MacOS/Unity
UNITY_PROJECT_PATH=Unityプロジェクトのパスを入力
UNITY_BUILDE_NAME=Builder.Build
UNITY_LOG_PATH=./build.log

$UNITY_APP_PATH -batchmode \
    -quit \
    -projectPath $UNITY_PROJECT_PATH \
    -executeMethod $UNITY_BUILDE_NAME \
    -logfile $UNITY_LOG_PATH

if [ $? -eq 1 ]; then
    echo "error!! check logfile: ${UNITY_LOG_PATH}"
    exit 1
fi
echo "success!!"
exit 0

ターミナルから実行

以下コマンドをターミナルから実行します。
Unityが起動した状態で行うとうまくビルドができないので、
Unityは終了させた状態で実行します。

ターミナルから実行
sh unitybuild.sh

実行後正常に完了したら、apkファイルがoutputPathに生成されますので、
実機にインストールして動作を確認しましょう。

github

プロジェクトはgithubにアップロードしてます
https://github.com/becky3/unity_command_build/tree/commandline_android

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidのタップイベント透過メモ

問題

Androidアプリにおきまして、blueViewの中にあるorangeViewのタップイベントの有効・無効を切り替える仕様がありました。
無効にした場合、orangeViewをタップしたとき、タップイベントがblueViewまで届かなかったので、その対応方法をメモしておきます。

図1.png

解決

View#isClickableをfalseにすることで、blueViewのタップイベントが呼ばれるようになります。
しかし、orangeViewにリップルアニメーションなどつけていると、それが動作してしまいます。

orangeView.isClickable = false

View#isEnabledをfalseにすることで、orangeViewのタップイベントを無効にすることができます。
しかし、isClickableがtrueの場合、blueViewのタップイベントが呼ばれません。

orangeView.isEnabled = false

上記、2点を組み合わせて、以下のようにすることで解決することができます。

// orangeViewのタップイベント有効
orangeView.isClickable = true
orangeView.isEnabled = true

// orangeViewのタップイベント無効
orangeView.isClickable = false
orangeView.isEnabled = false

GitHubサンプル

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

mixiのAndroid Trainingを一通り読む(1-6~2-1)

mixiのAndroid Trainingを一通り読んでいきます。

https://github.com/mixi-inc/AndroidTraining

http://mixi-inc.github.io/AndroidTraining/

方針

  • 全体を読むことを優先する
  • 読みながらメモを作る。気になった部分は軽く調べてメモしておく

1-6 課題プロジェクトの開き方

https://github.com/mixi-inc/AndroidTraining をクローンしリポジトリ内のAndroidStudio/をAndroid Studioで開く

すると、最新版のAndroid Studio 3.4.1では、プロジェクトツリーで「Packages」を選ぶと全課題のプロジェクト一覧がプロジェクトツリーに表示される。

各プロジェクトを実行するには、ビルドボタンの左にあるプロジェクト名のプルダウンを選択し、実行したいプロジェクトを選択してビルドボタンを押す

これ以降の章では、このツリーに表示されるプロジェクト名に対応した課題・実習が用意されている。

2-1 アプリのレイアウト作成

この章では、Android アプリ画面のレイアウトの作り方を学びます

画面を構成する要素となる View には以下のようなものがあります。

TextView、EditText、ImageView、Button、CheckBox、RadioButton といったウィジェット(Widget)と、
LinearLayout、RelativeLayout、FrameLayout といったウィジェットを取りまとめるレイアウト(Layout)です。

これらウィジェットとレイアウトをまとめて、View と呼びます。

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent" <-- 親のViewと同じサイズに調整される fill_parentはmatch_parentと同じ機能だがAPI Level 7以下をターゲットにしない限りmatch_parentを使う
    android:layout_height="match_parent" 
    tools:context=".MainActivity" >
    <TextView
        android:layout_width="wrap_content" <-- Viewの横幅 wrap_contentはコンテンツを表示するのに十分なサイズに調整される。この場合は、"text"である"hello world"を表示するのに十分なサイズに調整される
        android:layout_height="wrap_content" <-- Viewの高さ
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:text="hello_world" />

</RelativeLayout>

padding(パディング)、margin(マージン)

padding(パディング)、margin(マージン)を使用することでViewの余白を設定することができます。

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="#f2f2f2"
    android:padding="12dp" <--上下左右のパディング
    android:text="Padding"
    android:textSize="30sp" />

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="12dp" <--上下左右のマージン
    android:background="#f2f2f2"
    android:text="Margin"
    android:textSize="30sp" />

dp

Androidには様々な画面サイズ、画面密度(解像度)の端末が存在します。
そのためpx(pixel)でサイズ指定をすると端末によって画像が小さくなったり大きくなったりしてしまいます。
Androidではこのような複数解像度の端末に対応するため以下の単位が用意されています。

dp(dip)

dp(dip) は density-independent pixels (密度非依存のピクセル) の略です。

dipは160dpi(dots per inch) の画面を基準とされており、1dipは160dpiの画面では1ピクセルと同等と定義されています。
この関係を式にすると以下のようになり、解像度に対する1dipが何pxに相当するかがわかります。

px = dp * (dpi / 160)

320dpiの画面の場合だと1dipは2pxに自動的に換算され、画面上に反映されることになります。
よって、サイズにdpを使用することで特に意識することなく複数のが解像度端末に対応することができます。

dipとdpは両方使えますがdpの記述の方がspと統一感があるのでわかりやすいです。

sp

spはscale-independent pixels(スケール非依存のピクセル)の略です。

指定したサイズは、解像度とユーザーが設定したフォントサイズにあわせて自動的にスケールされます。

位置

Gravityを使用することでViewを指定した位置に配置することができます。

android:gravity 内部の要素の位置を決めます

android:layout_gravity 自分の位置を決めます

RTL support

RTLとはright-to-left の略でアラビア語やヘブライ語などの右から左に記述する言語のことをRTL言語と呼ぶことがあります。
Androidでは古くからRTL言語の表示をすることは可能でしたが、4.2から機能が強化されました。ドキュメントではfull native support for RTLと記載されています。(実際には4.4で画像反転等の機能追加が行われるなどしています)

日本国内だけをターゲットにする場合は、対応が必須とは言えませんができるだけ対応したほうがいいといえるでしょう。

レイアウトを作成する

レイアウトはUIの構造を定義します。
LinearLayout、RelativeLayout、FrameLayout、GridLayout、ListView、ScrollViewなどがあり、用途によって使い分けます。

LinearLayout

Viewを縦横に並べることができます。
android:orientationで並べる方向を決めます。”horizontal” か “vertical”を指定します。
未設定の場合はhorizontalが適用されます。

Layout Weight
LinearLayoutは個々の内部View(子View)に対してandroid:layout_weightを割り当てることができます。
android:layout_weightはViewを配置したときに残っているスペースを埋めることができるとても重要な機能です。

例えば、2つボタンが横並びに配置されているとします。片方のボタンにのみandroid:layout_weight="1"を指定します。
指定していないボタンのandroid:layout_weightは0として扱われます。
android:layout_weightは指定された値を元にスペースを割り当てます。この例では割り当てられる比率は1:0となります。
よって、指定されたボタンは残っているスペースを全て使うことになり目一杯横方向に伸びます。

Relative Layout

相対的にVeiwを配置することができます。

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" >

    <Button
        android:id="@+id/a" <-- 識別子を付与
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true" <-- 横方向中心
        android:layout_centerVertical="true" <-- 縦方向中心
        android:text="A"
        android:textSize="16sp" />

    <Button
        android:id="@+id/b" <-- 識別子を付与
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignTop="@+id/a" <-- aの上端に合わせる
        android:layout_toRightOf="@+id/a" <-- aに対して右側
        android:text="B" />

    <Button
        android:id="@+id/c" <-- 識別子を付与
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignLeft="@+id/b" <-- bの左端に合わせる
        android:layout_below="@+id/b" <-- bの下に配置する
        android:text="C" />

    <Button
        android:id="@+id/d" <-- 識別子を付与
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignBottom="@+id/c" <-- cに下端を合わせる
        android:layout_alignTop="@+id/b" <-- bに上端を合わせる
        android:layout_toRightOf="@+id/b" <-- bの右
        android:text="D" />

</RelativeLayout>

Frame Layout

FrameLayoutはViewを重ねて配置するのに使用します。

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/FrameLayout1"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" >

    <!-- 赤 -->
    <ImageView
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:background="#FF0000" />

    <!-- 緑 -->
    <ImageView
        android:layout_width="190dp"
        android:layout_height="190dp"
        android:background="#00FF00" />

    <!-- 青 -->
    <ImageView
        android:layout_width="180dp"
        android:layout_height="180dp"
        android:background="#0000FF" />

</FrameLayout>

FrameLayout内の上から順にViewが配置されます。

ScrollView

ScrollViewは画面にレイアウトが収まらない場合、収まらない分をスクロールして表示するための使用します。

ScrollViewが持つルートとなるViewは1つでなければなりません。

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/ScrollView1"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" >

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical" >

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="hello_world"
            android:textSize="30dp" />

        ・・・

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="hello_world"
            android:textSize="30dp" />

    </LinearLayout>

</ScrollView>

課題は省略

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Android の Camera2 API を使って RAW モードで撮影する

Android の Camera2 API を使って カメラのプレビュー画面を表示する
の続きです。

RAW モード

一般的なデジカメやスマホは、撮影した画像をJPEG形式で保存する。
JPEG形式は、非可逆の圧縮方式なので、カメラ本来の画質より劣化する。
デジタル一眼などの専用機種では、
カメラ本来の画質で保存するRAW モードをサポートしている。

参考 : RAWデータとは

DNG フォーマット

RAW モードの画像保存は、カメラメーカーが独自のフォーマットを使用している。
カメラメーカーが提供する専用ソフトでないと、閲覧や編集することができない。
DNG 形式は、Adobeが推奨しているRAW モードの画像ファイルのフォーマットです。

参考 : PhotoshopとDNGの将来

Android端末の対応

Android OS では、API 21 から RAW モードで撮影保存するAPIを提供している。
ただし、機種依存なのですべての端末で利用出来るわけではない。

画像保存は、DNG フォーマットを採用している。

一方で、DNG フォーマットを閲覧編集するAPIは、提供されていない。
そのため、標準の画像閲覧アプリでは、閲覧編集もできない。

Adobeから 写真加工・編集アプリ Lightroom Mobile の Android 版が提供されているので、それを利用する。

Google Play : Adobe Lightroom

端末が RAW モードの撮影をサポートしているか

下記のように判定する。

contains(characteristics.get(
                                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES),
                        CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW)

reference : REQUEST_AVAILABLE_CAPABILITIES

Raw モードで撮影する

基本的な処理は、JPEG 形式の写真を撮影するときと同じです。
下記を参考のこと。

Camera2 API を使って 写真を撮る

ImageReader

JPEG 形式のときは、カメラからの画像をキャプチャーするために ImageReader を生成した。

同様に、Raw モード用の ImageReader を生成する

        CameraCharacteristics  characteristics;
        ImageReader.OnImageAvailableListener rawImageAvailableListener;

        StreamConfigurationMap map = characteristics.get(
                        CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);

// Raw モードの画像サイズを取得する
        Size largestRaw = Collections.max(
                        Arrays.asList(map.getOutputSizes(ImageFormat.RAW_SENSOR)),
                        new CompareSizesByArea());

// ImageReader を生成する
        ImageReader imageReaderRaw =
                                ImageReader.newInstance( 
                                        largestRaw.getWidth(),
                                        largestRaw.getHeight(), ImageFormat.RAW_SENSOR, 
                                        4);
// リスナーを設定する
        imageReaderRaw.setOnImageAvailableListener(
                            rawImageAvailableListener, backgroundHandler);

reference : ImageReader

DNG形式でファイルに保存する

JPEG形式のときは、ImageReader にキャプチャーされた Image から Bitmap を取得し保存した。

Raw モードでは、DngCreator を使って下記のように保存する。

    CameraCharacteristics characteristics;
    CaptureResult captureResult;
    Image image;
    File file;

    DngCreator dngCreator  = new DngCreator(characteristics, captureResult);

    try {
            FileOutputStream output = new FileOutputStream(file);
            dngCreator.writeImage(output, image);
    } catch (IOException e) {
            e.printStackTrace();
    }

reference : DngCreator

DngCreator の機能は、reference だけだとわかりにくい。
ソースコードも読んでおくといい。

ソースコード : DngCreator.java

Media Content Provider に登録する

Lightroom Mobile などの閲覧編集アプリから参照できるように、
画像ファイルを Media Content Provider に登録する。

        File file;
        MediaScannerConnection.MediaScannerConnectionClient mediaScannerConnectionClient;

        String[] paths = new String[]{ file.getPath() };
        MediaScannerConnection.scanFile(context, paths, null, mediaScannerConnectionClient );

reference : scanFile

JPEG画像との併用

公式サンプルでは、DNG 形式と同時に JPEG 形式でも撮影保存している。

DNG 形式 と JPEG 形式 は 3264x2448 と画像のサイズは同じでも、
ファイルサイズは DNG 形式 はJPEG 形式に比べ 5〜6倍になる

DNG : 3264x2448 15.44 MB
JPEG : 3264x2448 2.8MB

DNG 形式 という高画質の画像があるので、
JPEG 形式はサムネイル的な小さい画像のサイズでもいい。
そこで、JPEG 形式でファイル保存するときに、画像サイズを小さくする。

    Bitmap  srcBitmap;
    int desiredSize;

    int bitmap_width = srcBitmap.getWidth();
    int bitmap_height = srcBitmap.getHeight();
    int max = Math.max(bitmap_width, bitmap_height);
    double scale = (double)desiredSize/ (double)max;
    int scaled_width =  (int) ( (double)bitmap_width * scale );
    int scaled_height =  (int) ( (double)bitmap_height * scale );
    Bitmap resized_bitmap = Bitmap.createScaledBitmap(srcBitmap,
        scaled_width, scaled_height, true);

reference : createScaledBitmap

これにより、ファイルサイズは 12分の1に減少する。

JPEG 原寸: 3264x2448  2.8MB
JPEG サムネイル: 480x640 243KB 

Camera2Basic と Camera2Raw の違い

Rawモードの撮影は、上記のようにCamera2Basicをベースに、RAW用のImage Readerを追加すれば、いいのですが。
それ以外にも違いがあります。

Camera2Basic を読み解くだけで、お腹いっぱいです。
Camera2Raw と読み比べしたら、パンクしそう。

 自動ホワイトバランス

Camera2Raw は、自動ホワイトバランスを設定している。
これは画質に大きく左右するので、納得です。
一方で、Camera2Basicでは、なぜ設定していないのか、疑問になる。
推測するに、その頃は対応している実機がなかったのかな。

 RefCountedAutoCloseable

imgereaderのリソース管理のための AutoCloseable のラッパークラス。
imgereaderをそのまま使うのではなく、このクラスでラップしている。
連続して撮影するときに、imgereader を複数作成するので、
その管理のためと思われる。
今回は、ひとまずなしで。

PendingUserCaptures

PICTUREボタンをクリックした数

連続して撮影するときに、すぐに実行せず、カメラデバイスの状態に応じて、順次処理するための変数。
今回は、ひとまずなしで。

サンプルコードをgithub に公開した。
https://github.com/ohwada/Android_Samples/tree/master/Camera214

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(4)

前回の続きです。

今回の目標

  • データセットを扱う(Dataクラスの作成)
  • Data Bindingを使う
  • Fragmentを使う
  • Activity遷移を覚える

本当はRoomまでやりたかったのですが、Coroutineも絡んできてしまってボリュームが増えすぎるので、それは次回に回します。

今回は画面周りの実装がメインなので、楽しい、はず^^;

1. データセットを扱う

これまでは、記録しているデータはInt型カウント数値1つだけでした。
もう少し複雑なデータセットにしてみようと思います。とはいえ、「歩数計記録アプリ」という目標があるので、必要なのはあと日付くらいですね。

  • 歩行記録データクラス
    • 日付
    • 歩数

個人的には、「よく歩いた/全然歩かなかった/普通」みたいな感じでその日の感想を選択式で入れられるようにしようかな。あとはお天気とか?犬の散歩は雨だと行けないから・・・

  • 歩行記録データクラス
    • 日付(必須) : "yyyy/MM/dd"
    • 歩数(必須) : Int
    • 達成度(任意) : Enum(Default=NORMAL, GOOD, BAD)
    • 天気(任意) : Enum(Default=FINE, RAIN, CLOUD, HOT, COLD, SNOW...)

他に何か思いつく物があれば任意で追加して下さい。

(1) データクラスを作成する

Kotlinには、その名もズバリ、data classというキーワードがあります。通常のclass定義と何が違うかというと、

  • equals()/hashCode()を自動生成してくれる
  • toString()を自動生成してくれる
  • copy()を自動生成してくれる

など、自動で色々内部的に作ってくれて使うことができます。ただし制約もあって、「派生できない/継承できない」というのもあります。データ設計の際にはこの辺りは要注意ですね。

個人的には、toString()の自動生成が助かりますね。デバッガーでbreakポイントを貼ったときに、値が確認しやすくなります。Java時代は自前で書くの結構大変でしたから。

早速、上でざっくり設計した歩行記録データクラスをコードに起こしてみましょう。

1. パッケージを追加

まず、新しくdataというパッケージを追加します。

パッケージルートを右クリックして、[New]-[Package]と選択肢、dataと入力して下さい。

kotlin_04_001.png

2. データクラスを追加

新しくできたdataパッケージ下に、新規Kotlinクラスを追加します。
クラス名はStepCountLogとしましょうか。ファイルを新規作成して、以下のように記述します。

StepCountLog.kt
enum class LEVEL {
    NORMAL, GOOD, BAD,
}

enum class WEATHER {
    FINE, RAIN, CLOUD, SNOW, COLD, HOT,
}

data class StepCountLog(
    val date: String,
    val step: Int,
    val level: LEVEL = LEVEL.NORMAL,
    val weather: WEATHER = WEATHER.FINE
)
  • enum class LEVELが、達成度を表すenumクラスです。
  • enum class WEATHERが、天気を表すenumクラスです。
    • Javaにあったenumとそれほど大きな違いはありません。少なくとも、この連載で使っていく分には、複雑な使い方をしていませんので、特に難しいことは無いかと思います。
  • StepCountLogクラスは、プライマリコンストラクタのみ定義しています。
    • 基本的なメソッドを自動で作ってくれるので、データクラスはほとんどの場合、操作のない宣言だけのものになります。

コンストラクタの引数、val level: LEVEL = LEVEL.NORMALval weather: WEATHER = WEATHER.FINEの部分は、デフォルト値の設定です。Kotlinでは、引数を省略できます。省略した場合は、コンストラクタで指定されているデフォルト値が渡されることになります。
省略可能な引数は、省略不可能なすべての引数より、後ろに宣言されていなくてはなりません。
こういう形は出来ない、ということですね。

data class StepCountLog(
    val date: String,
    val level: LEVEL = LEVEL.NORMAL,
    val step: Int,
    val weather: WEATHER = WEATHER.FINE
)

上記のコードだと、クラス宣言の部分では特にエラーは出ないのですが、インスタンス化するコードのところでエラーが出ます。

StepCountLogクラスのインスタンス化(Javaでいうnewする)は次のように書けます。

val data1 = StepCountLog("2019/06/11", 123, LEVEL.GOOD, WEATHER.RAIN)

// LEVELはNORMAL, WEATHERはFINEが渡る
val data2 = StepCountLog("2019/06/11", 123) 

// 引数を1つだけ省略
val data3 = StepCountLog("2019/06/11", 123, LEVEL.GOOD) 

val data4 = StepCountLog("2019/06/11", 123, level = LEVEL.GOOD) 

data4の宣言を見てお気づきの通り、Kotlinでは、引数を渡すときにはエイリアスを指定することができます。エイリアスとは、まあ早い話、コンストラクタの宣言で書いた、引き数名を指定して、引数が渡せる、ということです。なので引数には分かりやすい名前を付けておく方が良いでしょう。
なお、エイリアス指定方式は、すべての関数で使用できます。

エイリアス指定の便利なところは、引数の順番を自由に書けるところでしょうか。
通常、関数の引数は、宣言の順番通りに渡さなければなりませんが、エイリアスを使うと、その順番を任意に出来るのです。

// 引数の順番を任意にする
val data = StepCountLog(step=123, level = LEVEL.GOOD, date = "2019/06/11")

これを利用して、こんな書き方も出来ます。

val data = StepCountLog("2019/06/11", 123, weather = WEATHER.RAIN)

省略可能な引数のうち、後に宣言されたweatherのみ、指定してます。levelはデフォルト値が渡されます。本当に必要なパラメータだけ渡せるので、便利ですね。

まあ、個人的には、行が長くなるので、エイリアスはあまり使いませんが(汗)
(※1行80文字でコードを長く書いてきた人間なので^^;)

3. ViewModelで扱うデータ型を変更する

さて、データクラスを作ったので、ViewModelで扱う型も変えていきましょう。

  • MainViewModelのLiveDataの型を変更する

    • Int型のリストにしていたのを、StepCountLogのリストにする
    MainViewModel.kt
      val stepCountList = MutableLiveData<MutableList<StepCountLog>>()
    
  • MainViewModeladdStepCountの引数の型も、StepCountLogにする

MainViewModel.kt
    @UiThread
    fun addStepCount(stepLog: StepCountLog) {
        val list = stepCountList.value ?: return
        list.add(stepLog)
        stepCountList.value = list
    }
  • InputDialogFramentaddStepCountを呼んでいる箇所を変更する

    • 今は入力値を選択出来ないので、levelとweatherはいったんデフォルト値が渡るようにします。日付も後で選べるようにしますが、今今は今日の日付、にしておきます。
    InputDialogFragment.kt
          val step = view.editStep.text.toString()
          val date = getDateStringYMD(Calendar.getInstance().time)
          viewModel.addStepCount(StepCountLog(date, step.toInt()))
    

getDateStringYMDは下記のような関数です。

InputDialogFragment.kt
    private fun getDateStringYMD(time:Date):String{
        val fmt = SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN)
        return fmt.format(time)
    }
  • LogRecyclerAdapterのリストの型も、StepCountLogに変更
class LogRecyclerAdapter(private var list: List<StepCountLog>) : RecyclerView.Adapter<LogRecyclerAdapter.LogViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
        val rowView = LayoutInflater.from(parent.context).inflate(R.layout.item_step_log, parent, false)
        return LogViewHolder(rowView)
    }

    fun setList(newList: List<StepCountLog>) {
        list = newList
        notifyDataSetChanged()
    }
// 後は同じ

ここまでで、いったんビルド&実行してみましょう。ビルドが通らないときは、一度Clean&Rebuildしてみてください。どちらも、[Run]メニューにあります。

動いたけど、なんか変だって?

kotlin_04_002.png

べ、別に、Adapterの表示を設定するところを直すのを忘れたわけじゃ無いんだからね!
わ、わざとに決まってるでしょ!!
「ホラ、レイアウト変更しなきゃね!」てやりたかったからだよ!!

まあ、原因は、お察しの通り、LogRecyclerAdapterの表示データをセットする以下のところですね。

    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        holder.textCount.text = if (position < list.size) list[position].toString() else ""
    }

TextViewに、list[position].toString()を渡してます。ここが、StepCountLog#toString()の呼び出しになり、自分ではこの関数は作っていませんが、前述の通り、data classですので、自動的に生成されています。
data classtoString()は、このようにメンバーの値をテキストで読みやすい形で出してくれるという代物なのです。
なぜデバッグで便利かというと、ブレークポイントを貼ってデバッガーで値を見るときに、自動的にこの形になっていると見やすいんですね。

例えば、こんな所にブレークポイントを貼っておき、アプリを実行して「登録」ボタンを押します。

kotlin_04_003.png

ブレークポイントで止まりますね。その時、[Valiables]のstepCountで[toString]-[View]とクリックすると、こんな風に値を確認することができます。

kotlin_04_004.png

[Variables]のところで、stepLogの左にある矢印をクリックして、要素を展開すれば中を見ることも出来ますが、ネストの深いデータ構造だと、矢印のクリック回数が増えて非常に面倒です。文字列で一瞥できた方が、便利なときもあるのです。

ということで、toString()の自動生成のありがたみを痛感したところで(?)、レイアウトをちゃんと対応していきましょう(汗)

(2) レイアウトを変更する

変えなければならないレイアウトは以下です。

  • リストの各行(アイテム)のレイアウト(item_step_log.xml)
  • 入力時のレイアウト(dialog_input.xml)

InputDialogFragmentについては、今回、新しく入力項目が増えたので、今回から、ダイアログ表示をやめて、画面遷移にしようと思います。

また、RecyclerViewは、databindingというのを使って行くと便利なのでそちらを使って行きます。

1. アイテムレイアウトを変更する

まずは、RecyclerViewのアイテムのレイアウトを変えましょう。日付、レベル、天気が表示出来るようにします。

  • 画像の準備

    レベル、天気は、こんな感じで、vector画像を用意しました。ほとんどは、AndroidStudioのクリップセットから作成可能ですが、一部は、フリー素材を使っています(参考ページ参照)

kotlin_04_005.png

サンプル画像はこんな感じです。

kotlin_05_006.png

  • item_step_log.xmlのレイアウトを、日付、レベル画像、天気画像を表示するように変更する
    私はこんなレイアウトにしました。

    kotlin_04_006.png

こちらはサンプルxmlです。
item_step_log.xml
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto" 
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".activity.logitem.LogInputFragment">

    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/dateTextView"
            tools:text="2019/06/11"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="8dp"/>

    <TextView
            android:text=""
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/stepTextView"
            android:textSize="24sp"
            android:textColor="#0B0A0A"
            tools:text="12345"
            app:layout_constraintStart_toEndOf="@+id/levelImageView"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/weatherImageView"/>
    <TextView
            android:text="@string/label_log_suffix"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toEndOf="@+id/stepTextView"
            app:layout_constraintBottom_toBottomOf="parent"
            android:id="@+id/suffixTextView"
            android:layout_marginStart="8dp"
            android:elevation="0dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toTopOf="@+id/stepTextView"/>
    <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:src="@drawable/ic_cloud_gley_24dp"
            android:id="@+id/weatherImageView"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="@+id/dateTextView"
            app:layout_constraintStart_toEndOf="@+id/dateTextView"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"/>
    <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:src="@drawable/ic_sentiment_neutral_green_24dp"
            android:id="@+id/levelImageView"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/dateTextView"/>
</androidx.constraintlayout.widget.ConstraintLayout>


  • LogRecyclerAdapterを修正する
    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        val stepCountLog = list[position]
        holder.textCount.text = stepCountLog.step.toString()
        holder.textDate.text = stepCountLog.date
        when (stepCountLog.level) {
            LEVEL.GOOD -> holder.level.setImageResource(R.drawable.ic_sentiment_very_satisfied_pink_24dp)
            LEVEL.BAD -> holder.level.setImageResource(R.drawable.ic_sentiment_dissatisfied_black_24dp)
            else -> holder.level.setImageResource(R.drawable.ic_sentiment_neutral_green_24dp)
        }
        when (stepCountLog.weather) {
            WEATHER.CLOUD -> holder.weather.setImageResource(R.drawable.ic_cloud_gley_24dp)
            WEATHER.RAIN -> holder.weather.setImageResource(R.drawable.ic_iconmonstr_umbrella_1)
            WEATHER.HOT -> holder.weather.setImageResource(R.drawable.ic_flare_red_24dp)
            WEATHER.COLD -> holder.weather.setImageResource(R.drawable.ic_iconmonstr_weather_64)
            WEATHER.SNOW -> holder.weather.setImageResource(R.drawable.ic_grain_gley_24dp)
            else -> holder.weather.setImageResource(R.drawable.ic_wb_sunny_yellow_24dp)
        }
    }

    class LogViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val textCount = itemView.stepTextView!!
        val textDate = itemView.dateTextView!!
        val level = itemView.levelImageView!!
        val weather = itemView.weatherImageView!!
    }

LogViewHolderクラスで保持するViewを増やし、LogRecyclerAdapter#onBindViewHolderでデータクラスの値に対応したそれぞれの値を入れたり、アイコン画像を引っ張ってきたりしています。

しかし・・・、when節でdrawableリソースのidを引っ張ってきているところが、なんか気になります。もっと綺麗に、短く書けないでしょうか?

enumクラスでなんとか返せないかな?というアプローチで考えると、実は、こんな書き方が出来ます。

enum class LEVEL(val drawableRes: Int) {
    NORMAL(R.drawable.ic_sentiment_neutral_green_24dp),
    GOOD(R.drawable.ic_sentiment_very_satisfied_pink_24dp),
    BAD(R.drawable.ic_sentiment_dissatisfied_black_24dp),
}
enum class WEATHER(val drawableRes: Int) {
    FINE(R.drawable.ic_wb_sunny_yellow_24dp),
    RAIN(R.drawable.ic_iconmonstr_umbrella_1),
    CLOUD(R.drawable.ic_cloud_gley_24dp),
    SNOW(R.drawable.ic_grain_gley_24dp),
    COLD(R.drawable.ic_iconmonstr_weather_64),
    HOT(R.drawable.ic_flare_red_24dp)
}

どちらも、Int型のdrawableResという「付加情報」を付けて、初期化しています。

これを使うと、LogRecyclerAdapter#onBindViewHolderはこんなにシンプルになります。

    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        val stepCountLog = list[position]
        holder.textCount.text = stepCountLog.step.toString()
        holder.textDate.text = stepCountLog.date
        holder.level.setImageResource(stepCountLog.level.drawableRes)
        holder.weather.setImageResource(stepCountLog.weather.drawableRes)
    }

すっきりしましたね。

もっとも、ただの列挙型、特にこの列挙型は、MVVMのModel部分に相当するStepCountLogというdataクラスで使われるものですので、そのクラスの情報に、R.drawableクラスという表示データに関わる情報を持たせることに、設計として「気持ち悪い」と感じる人もいらっしゃると思います。場合によってはそのせいで、UnitTestが書きづらくなったりすることもありますし。その辺りは、設計思想だったり、現場の文化だったりでも方針は変わってきますので、柔軟に対応していきましょう。

今回は勉強ということもあり、enumの付加情報について学べる良い機会ということで、取り上げてみました。

2. Data Binding

(1) data bindingとは

Androidには、Data Bindingという便利なライブラリがあります。何をしてくれるかというと、レイアウトのウィジェット(View)と、表示するデータを、xml上でバインドしておくと、ViewHolderクラスでやっていたような「値をビューにセットするだけ」のコードを、ごっそりコードから削除することが出来る、という代物です。

だいたい、ViewHolderに表示する物って、あるクラスの情報まるっとだったり、その一部だったり、要するに、まとめて渡せたら便利なことが多くない?それ実現できない?ってのをやってくれるのが、Data Bindingライブラリです。

公式ドキュメントはこちら

CodeLabsを日本語訳してみた拙記事もありますので、よければそちらも見てみて下さい。

とにかく使って行きましょう。
必要な初期化は、android{}ノードに、下記を追記するだけです。

app/build.gradle
android{
    ...
    dataBinding {
        enabled true
    }  
    ...
}

dependenciesに追加する記述はありません。

(2) アイテムレイアウトのdata binding

早速、アイテムレイアウトを、data bindingのものに変えましょう。

1. レイアウトファイル全体をdata binding向けにする

data binding向けのレイアウトファイルにするには、レイアウト全体を、<layout>タグで囲む必要があります。

ルート要素のタグ内にカーソルを合わせ、マウスをホバリングさせると表示される黄色い(オレンジ?)電球アイコンをクリックすると、[Convert to data binding layout]とやると、楽です。

kotlin_04_021.png

こんな感じになるはずです。

item_step_log.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools">
    <data>

    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
 ...
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

<data>タグに、レイアウト変数というのを定義します。挿入したいデータを外部から指定する場合の、渡される型、クラスなどを名前付きで指定します。

今回は、StepCountLogクラスを渡して使いたいので、こう書きます。

item_step_log.xml
    <data>
        <variable name="stepLog" 
                  type="jp.les.kasa.sample.mykotlinapp.data.StepCountLog"/>
    </data>

2. バインドするデータのセット

各ビューに、stepLogのどの値を使うかを設定していきます。
- dateTextViewのtextには、stepLog.date
- stepTextViewのtextには、stepLog.step(※ただしInt型なので文字列に変更して)
- weatherImageViewのsrcには、stepLog.weather.drawableRes
- levelImageViewのsrcには、stepLog.level.drawableRes

レイアウト式というのを使います。レイアウト式は、@{}で書きます。
各設定は次のようになります。

        <TextView
                android:id="@+id/dateTextView"
                android:text="@{stepLog.date}"
        <TextView
                android:id="@+id/stepTextView"
                android:text="@{Integer.toString(stepLog.step)}"
        <ImageView
                android:id="@+id/weatherImageView"
                android:src="@{stepLog.weather.drawableRes}"
        <ImageView
                android:id="@+id/levelImageView"
                android:src="@{stepLog.level.drawableRes}"

レイアウトxmlファイルへの設定はこれで終わりです。ここまでで、いったんBuildしておいてください。

3. LogRecyclerAdapterを書き換える

  • レイアウトのインフレートをdata bindingを使ったものに書き換える
    • LogRecyclerAdapter#onCreateViewHolderで、レイアウトをinflateしている部分を、Data Binding用に変更する
    • LogViewHolderのコンストラクタの引数の型を、Bindingオブジェクトに変更する
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
        val binding: ItemStepLogBinding = DataBindingUtil.inflate(
            LayoutInflater.from(parent.context), R.layout.item_step_log, parent, false
        )
        return LogViewHolder(binding)
    }
    class LogViewHolder(val binding: ItemStepLogBinding)
                   : RecyclerView.ViewHolder(binding.root)

ItemStepLogBindingのimport候補が出ない場合は、xmlのgenerateに失敗しています。Buildをやり直してみて下さい。[generatedJava]に、ItemStepLogBindingというのが出来ているはずです。出来ていない場合は、xmlファイルの記述で何か間違えているか、どこかのKotlinコードが変になっていますので、よく確認して下さい。

  • 最後に、レイアウトで表示するのに必要な、バインドするデータを渡します。
    • LogRecyclerAdapter#onBindViewHolderを書き換える
    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        holder.binding.stepLog = list[position]
    }

stepLogがどこから来たかというと、xmlのここです。

    <data>
        <variable name="stepLog"
                  type="jp.les.kasa.sample.mykotlinapp.data.StepCountLog"/>
    </data>

<variable>タグのname属性に指定した名前の変数が、Data Bindingライブラリによって生成され、binding.stepLogのようにアクセスすることが出来るようになります。

だいぶコードがスッキリしましたね。実行してみて下さい。

(3) もっとdata binding(Binding Adapter)

CodeLabsを日本語訳してみたとき、便利な方法を学習したので紹介します。

BindingAdapterというのを自作する方法です。

値によって画像を変えるのには、こちらが適していそうだと感じました。enumクラスの付加情報を勉強のために使いましたが、こちらのほうが、モデルから表示情報を削除出来、data bindingも活用した良い方法なので、知っておくと役に立つと思います。

1. BindingAdapterを作成する

  • BindingAdapters.ktというファイルを作り、以下の関数を作る
BindingAdapters.kt
    @BindingAdapter("android:src")
    fun setImageLevel(view: ImageView, level: LEVEL) {
        val res =
            when (level) {
                LEVEL.GOOD -> R.drawable.ic_sentiment_very_satisfied_pink_24dp
                LEVEL.BAD -> R.drawable.ic_sentiment_dissatisfied_black_24dp
                else -> R.drawable.ic_sentiment_neutral_green_24dp
            }
        view.setImageResource(res)
    }

    @BindingAdapter("android:src")
    fun setImageWeather(view: ImageView, level: WEATHER) {
        val res =
            when (level) {
                WEATHER.RAIN -> R.drawable.ic_iconmonstr_umbrella_1
                WEATHER.CLOUD -> R.drawable.ic_cloud_gley_24dp
                WEATHER.SNOW -> R.drawable.ic_grain_gley_24dp
                WEATHER.COLD -> R.drawable.ic_iconmonstr_weather_64
                WEATHER.HOT -> R.drawable.ic_flare_red_24dp
                else -> R.drawable.ic_wb_sunny_yellow_24dp
            }
        view.setImageResource(res)
    }

やってることはだいたいわかるかと思いますが、次のような感じです。

  • "android:src"という属性に対し、特定の引数の型が指定されたら、その型に応じて、setImageLevelsetImageWeatherが呼び出される(@BindingAdapterアノテーションにより、その辺は適切にコードが自動生成されます)
  • 中身は、以前LogRecyclerAdapter#onBindViewHolderでやっていたのと一緒で、enumクラスの値に応じて、ImageViewにセットすべきdrawableのリソースidを振り分けて、それをsetImageResourceにセット。when節が値を返せるところが、Kotlin的なポイントでしょうか。ifもそうでしたね。

2. ImageViewのバインディングを変更する

レイアウトxmlのlevelImageView, weatherImageViewのバインドしている値の指定を変更します。

item_step_log.xml
        <ImageView
                android:id="@+id/weatherImageView"
                android:src="@{stepLog.weather}"
...
        />
        <ImageView
                android:id="@+id/levelImageView"
                android:src="@{stepLog.level}"
...

3. enumクラスを元に戻す

enum class LEVEL {
    NORMAL,
    GOOD,
    BAD,
}

enum class WEATHER {
    FINE,
    RAIN,
    CLOUD,
    SNOW,
    COLD,
    HOT,
}

BindingAdapter、便利そうなので覚えておくと、かなり開発効率がよくなりそうです。

(4) メインレイアウト(LiveData)のdata binding

さて、CodeLabをちらっと見て頂けた方なら、LiveDataをdata bindingする方法があることに気付いたと思います。

それを、RecyclerViewでもやってみようと思います。

1. RecyclerViewにもdata bindingを適用する

  • activity_main.xmlをdata binding用レイアウトに変更する
    • <data>タグに指定するのは、name=viewmodeltype=(フルパッケージ名).MainViewModel
  • app:itemsという属性でリストList<StepCountLog>を受け取る、BindingAdapter用の関数を作る
  • MainActivityでdata bindingの設定をする
    • レイアウト設定方法をDataBindingUtil#setContentViewにする
    • bindingオブジェクトのライフサイクルオーナーに自分を設定する
    • bindingオブジェクトのレイアウト変数viewmodelに、MainViewModelをセットする

全部やるとこんな感じになります。

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable name="viewmodel"
                  type="jp.les.kasa.sample.mykotlinapp.MainViewModel"/>
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".MainActivity">

        <androidx.recyclerview.widget.RecyclerView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:id="@+id/log_list"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                android:layout_marginTop="8dp"
                android:layout_marginStart="8dp"
                android:layout_marginEnd="8dp"
                android:layout_marginBottom="8dp"
                app:items="@{viewmodel.stepCountList}"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
BindingAdapters.kt
@BindingAdapter("app:items")
fun setLogItems(view: RecyclerView, logs: List<StepCountLog>?) {
    val adapter = view.adapter as LogRecyclerAdapter? ?: return

    logs?.let {
        adapter.setList(logs)
    }
}

nullチェックがしつこいですが、タイミングによってはnullがありうるので、きちんとチェックしておきます。

2. MainActivityでBindingオブジェクトにライフサイクルオーナーとViewModelをセットする

MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding: ActivityMainBinding
                = DataBindingUtil.setContentView(this, R.layout.activity_main)

        viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)

        binding.lifecycleOwner = this
        binding.viewmodel = viewModel

        // RecyclerViewの初期化
        log_list.layoutManager = LinearLayoutManager(this)
        adapter = LogRecyclerAdapter(viewModel.stepCountList.value!!)
        log_list.adapter = adapter
        // 区切り線を追加
        val decor = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        log_list.addItemDecoration(decor)

        InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
    }
  • val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)は、DataBindingでレイアウトを指定して、bindingオブジェクトを取得する定型的なコードです

  • binding.lifecycleOwner = thisで、ライフサイクルオーナーを指定しています

  • binding.viewmodel = viewModelで、レイアウトxmlの<data>タグのname属性に指定した名前のレイアウト変数viewmodelに、作成したViewModelのインスタンスを渡しています

ViewModelをobserveしているコードが無くなりました。Data Bindingの方でよしなにobserveしてくれているのですね。

実行すると、ちゃんと追加が反映されているかと思います。

3. ダイアログをやめて画面遷移にする(Activity遷移)

さて、お気づきでしょうが、せっかくログクラスにデータが増えているのに(天気、レベル)、それを入力する箇所がありません。
ということで、入力画面を変えていきます。

ダイアログでそのままやっても良いのですが、今後いろいろ肉付けしていく際に必要そうなので、別のActivityを作ってやっていこうと思います。

今回は、Fragmentの作成もやってみましょう。

おさらいですが、Fragmentとは、Activity(1画面)の上に何枚でも(多分上限はありそうですが)重ねられる、スクリーン(あるいはレイヤーと言った方が分かりやすいかな?)みたいなものです。

(1) 新しいActivityクラスを作成する

1. 新しいパッケージを作る

ついでなので、パッケージを増やしましょう。

[app]-[java]下にある、パッケージ名のトップで右クリックして、[New]-[Package]とし、
kotlin_04_030.png

activity.logitemと入力します。

kotlin_04_031.png

パッケージングにはいろいろ流儀があると思いますので、現場やチームでのルールに従いましょう。
私は最近は画面ごとに分けています。今回も、Activityが増えるごとに、activityパッケージ下に増やしていくイメージで作りました。

2. LogItemActivityを作成する

Activiyを新規作成します。

  • パッケージ[activity.logitem]で右クリックし、[New]-[Activity]-[Basic Activity]と選択

kotlin_04_032.png

  • ActivityNameにLogItemActivityと入力し、あとは図の通りにして[Finish]

kotlin_04_033.png

LogItemActivity.ktファイルと、activity_log_item.xml、そしてcontent_log_item.xmlが作成され、ファイルが開きます。また、[manifests]下にあるAndroidManifest.xmlを開くと、<activity>タグが追加されているのが分かります。
それと、app/build.gradleにもdependenciesが1行追加されています。

app/build.gradle
implementation 'com.google.android.material:material:1.0.0-beta01'

これは、AppBarLayoutというのに必要なので自動で追加されました。気になる人は、最新バージョンにしておきましょう。(2019/06/19現在、"1.1.0-alpha07"が最新版みたいです)

  • activity_log_item.xml

MainActivityにもActionBarは出ています(上部の緑色?の部分。この色はランダムに決まる可能性があるので、違うかも)が、このクラスは実は、もうあまり推奨されていません。多分、標準の(MainActivityについている)ActionBarは、マテリアルデザインに対応していないとかで、対応しているSupportActionBarを使えということだったと思います。それを使うためのレイアウトは、基本的にはこのような構成になります。

CoordinatorLayoutの中に、AppBarLayoutがあり、更にその中に、Toolbarがある、という階層構造になっています。
Acvitiyのメインコンテンツは、CoordinatorLayoutの中、AppBarLayoutの下に定義していきますが、

activity_log_item.xml
<include layout="@layout/content_log_item"/>

このように、別のレイアウトで定義した物をincludeする形が圧倒的に多いです。もちろん、直接その部分にレイアウトを書いていっても良いのですが、レイアウトファイルがゴチャゴチャするのを避ける為にも、なるべくこの形にした方が良いのではないかと思っています。

includeを使うと、細々としたレイアウトセットを別ファイルに作っておき、組み合わせて使えるようにもなったりするので、レイアウトファイルの再利用性が高まります。ただもちろん、これはデメリットもあって、1つの画面だけレイアウトを変えたくても、他の画面も影響を受けてしまう、ということもあるので、あまり分割/再利用しすぎるのも問題になりますので、要注意です。

3. レイアウトファイルを修正

FloatingActionButtonは不要なので、ごっそり削除して下さい。

activity_log_item.xml
<!-- <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="@dimen/fab_margin"
            app:srcCompat="@android:drawable/ic_dialog_email"/>
-->
  • content_log_item.xml

ルート要素に、app:layout_behavior="@string/appbar_scrolling_view_behavior"という属性が定義されています。これが無いと、ActionBarの下にViewが潜り込んでしまうので、必ず指定をして下さい。

そして、FrameLayoutConstraintLayout内に1つ作り、idをlogitem_containerとします。

content_log_item.xml
    <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent" 
            android:id="@+id/logitem_container" 
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent" 
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent">
    </FrameLayout>

Activityのレイアウトはここまでで完了です。値を入力する項目を作ってないって?それはFragmentのレイアウトでやるのです。
Activityに必要なのは、ActionBarに関わるレイアウトと、Fragmentをはめ込む「枠」としてのLayoutが1つあれば、ほとんどの場合、充分です。

  • LogItemActivity.kt

いまはonCreateがあるだけですね。

ひとまず、fabボタンのコードは不要なので削除します。
LogItemActivity.ktのコードはこうなります。

LogItemActivity.kt
class LogItemActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_log_item)
        setSupportActionBar(toolbar)
    }
}

setSupportActionBar(toolbar)が新しいですかね。
前述の通り、SupportActionBarに、toolbarとidを付けたToolbarオブジェクトを指定しています。
これにより、supportActionBarを介して、タイトルを表示したりアイコンを表示したりすることが出来るようになります。

4. Fragmentを追加するコードを実装

LogItemActivity#onCreateに下記のコードを追記します。

LogItemActivity.kt
    if(savedInstanceState==null){
        supportFragmentManager.beginTransaction()
            .replace(R.id.logitem_container, LogInputFragment.newInstance())
            .commitNow()
    }

このコードでやっているのは、supportFragmentManagerというActivityのFragmentを管理するマネージャーに(その名の通りですね)、LogInputFragmentのインスタンスを作って、R.id.logitem_containerというidのViewとreplaceする、という内容です。

以前、DialogFragmentを表示するときは、DialogFragment#showを使いましたが、Fragmentを表示するには、本来、supportFragmentManagerに対して設定を行っていきます。まずbeginTransaction()してから、replace/add等をして、最後にcommitNow()commit()をする、としなければなりません。DialogFragment#showは、内部でその流れをやってくれているということになります。

なお、LogInputFragmentクラスはまだ作っていないので、現時点ではコンパイルエラーになります。

(2) Fragmentを作成する

次はFragmentを作っていきます。

1. Fragmentを新規作成する

  • パッケージ[activity.logitem]を選んで、右クリックメニューから、[New]-[Fragment]-[Fragment (Blank)]を選択

kotlin_04_034.png

  • Fragment Nameに、InputLogFragmentとし、あとは図の通りの設定にして、[Finish]

kotlin_04_035.png

  • 不要なコードは削除しておく

    下記コードを削除

InputFragment.kt
    // TODO: Rename parameter arguments, choose names that match
    // the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
    private const val ARG_PARAM1 = "param1"
    private const val ARG_PARAM2 = "param2"

InputFragment.ktはいまはこれだけの状態のはずです。

InputFragment.kt
class LogInputFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_log_input, container, false)
    }
}

androidx.fragment.app.Fragmentを継承しています。これは必ずこのクラスにします。

onCreateViewは、LayoutInflatercontainer(nullable)と、savedInstanceState(nullable)を受け取ります。

LayoutInflaterはこれまでにも出てきたのでお分かりと思いますが、レイアウトxmlファイルを実際にViewオブジェクトにインスタンス化している、と思えば良いと思います。

containerは、親のViewGroupです。例えば、今回は、activity_log_item.xmlR.id.logitem_containerというidを指定してFragmentをセットしているので、ここに渡ってくるのは、FrameLayoutの実体ということになります。nullableになってるけど、nullが来ることは無い気がするんですがね・・・

さて、Bundle?savedInstanceStateは、Activityのコードでも出てきました。onCreateに追加したコードですね。

LogItemActivity.kt
        if(savedInstanceState==null){
            supportFragmentManager.beginTransaction()
                .replace(R.id.logitem_container, LogInputFragment.newInstance())
                .commitNow()
        }

Activityのライフサイクルを覚えているでしょうか?Activityが裏に回ったとき(onPaused以降)、Activityクラスのオブジェクトは、実はメモリから解放されてしまうことがあります。AndroidOSが、メモリが足りなくなると、バックグラウンドにあるオブジェクトのメモリを解放して賄おうとするからです。
その状態で、アプリをもう一度起動しようとすると、AndroidOSはなるべく前回の状態を復元しようとしてくれます。そんなとき、savedInstanceStateに、保存してあったデータが渡ってきます。

ということで、savedInstanceState==nullという条件は、実は、復元すべきデータが無いとき、つまり「最初にActivityが作成されたとき」を意味しています。

ちなみに、復元が必要なデータは、自動で保存してくれるわけでは無く、Activity#onSaveInstanceStateで自分で保存するコードを書いた物だけが渡ってきます。
このデータには容量制限があるので、やたらめったらデカいデータは、別の保存/復元方法を考えた方が良いでしょうね。

FragmentにもonSaveInstanceStateがあります。Activity同様、バックグラウンドなどから復帰したときに復元されて欲しいデータをセットしておくと、onCreateViewsavedInstanceStateはnullではなく、セットしておいたデータのセットが入ってきます。

バックグラウンドに行ったが、メモリが解放されることが無かった場合は、ActivityやFragmentの再作成は行われません(=onCreate等は呼ばれない)。その場合、画面が復帰したときはonResume等から呼ばれます。

onSaveInstanceStateが呼ばれるもう1つ重要なタイミングとしては、「画面回転」が挙げられます。端末を縦にしたり横にしたりしたときですね。この場合は、画面の再作成は、必ず行われます。(行わせないようにする設定は一応ありますが、このアプリでは使いません)

面倒なので、スマホ向けのアプリだと、「縦画面固定」や、ゲーム等は「横画面固定」にしているものがほとんどだと思いますが、今回は、ViewModelにせっかく対応しているので、固定にはしないで実装を進めたいと思います。
ただし、縦画面向け、横画面向けに、レイアウトの変更が必要になる場合があるので、その際には、代替リソースを使って、レイアウトxmlファイルを分けて対応していきましょう。
(今回も最終的に横画面向きのレイアウトを作ってあります。Githubにpushしたプロジェクトで、res/layout-landを参照して下さい。)

2. LogInputFragmentをインスタンス化するメソッドを作成

Fragmentのクラスが出来たので、それをインスタンス化するメソッドを作ります。
LogItemActivityで、LogInputFragment.newInstance()と呼び出していたのを覚えていますか?このメソッドを作りましょう。

LogInputFragment.kt
    companion object {
        fun newInstance(): LogInputFragment {
            val f = LogInputFragment()
            return f
        }
    }

※inlineに出来るよ、という警告は無視して良いです。

companion object{}の説明はしたっけ?した気もするな・・・(汗)
Javaでいうクラスの静的メンバ、メソッドは、companion object{}の中に定義します。

今やっているのは、単にLogInputFragmentをnewしているだけですが、後々、ここに初期値などのデータを渡すこともあるかも知れない・・・ということでこの形で作っておく方が便利です。

さて、これでいったんビルドは通るようになりました。
実行・・・しても、変化はありません。当然です、LogItemActivityに遷移するコードを書いていません!

ということで、次はActvitiyからActivityへの遷移を実装します。

(3) MainActivityからの遷移を実装する

1. InputDialogFragmentは不要なので、削除する

  • クラスファイル、レイアウトファイル、MainActivityでshowしているコード、すべて削除する

    MainActivity.kt
        // このコードは削除
        // InputDialogFragment().show(supportFragmentManager, INPUT_TAG)
    

2. 追加メニューが押されたときの処理を、Activity遷移に変更する

MainActivity.kt
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        item?.let {
            return when (it.itemId) {
                R.id.add_record -> {
                    val intent = Intent(this, LogItemActivity::class.java)
                    startActivityForResult(intent, REQUEST_CODE_LOGITEM)
                    true
                }
                else -> false
            }
        }
        return false
    }

Activityを起動するときには、Intentを使い、startActivitystartActivityForResultというメソッドを呼びます。Intentオブジェクトの作り方はいくつか引数のパターンがありますが、よく必要なのは、「自分のアプリパッケージ内の特定のActivityクラスを開く」なので、上記のパターンを非常に多く使うことになります。引数の1つめはPackageContext、2つめは「リクエストコード」と言われるものです。startActivityForResultメソッドを通して起動したActivityが終了して自分に戻ってくると、そのActivityからの戻り値やデータを受け取ることが出来ます。startActivityは特にデータのやりとりが不要な場合に使います。今回は、後で値を受け取るので、startActivityForResultの方を使います。

REQUEST_CODE_LOGITEMは次のようにしました。

MainActivity.kt
companion object {
    const val REQUEST_CODE_LOGITEM = 100
}

そういえば、INPUT_TAGは不要ですのでこれも削除しておきます。

実行してみて下さい。"+"ボタンタップで、新しい画面に遷移しましたか?
端末の戻るボタンを押すと戻りましたか?

これで行き来できるようになりましたね。

3. ActionBar(Toolbar)に戻るボタンを表示して処理をする

遷移した画面からは、左上に戻るボタンを表示して、そこをタップしても戻れるようにしましょう。

  • 戻るボタン表示にする

    下記コードをLogItemActivity#onCreate内の、setSupportActionBar(toolbar)の後に追記します。

LogItemActivity.kt
    supportActionBar?.setDisplayHomeAsUpEnabled(true)
  • 戻るボタンがタップされたときの処理を実装する

    下記メソッドをLogItemActivity.ktに追加します。

LogItemActivity.kt
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            android.R.id.home -> {
                onBackPressed()
                return true
            }
        }
        return super.onOptionsItemSelected(item)
    }

面白いことに(?)、左上の「←」ボタンは、オプションの一部なんですね。そしてメニューアイテムIDは、android.R.id.homeが付いているようです。

onBackPressed()メソッドは、Activityクラスが元々持っているメソッドで、基本的には自分をfinish()するだけです。別の動作をさせたいときは、このメソッドをオーバーライドすると楽です。(少し古いサンプルだと、キーイベントでbackキーを拾って奪うようなコードを見かけますが、そんな必要はもう無いです。)

これで実行すると、ActionBarの左側に←アイコンが表示され、タップすると戻るようになりました。

kotlin_04_036.png


愚痴ここから===

ところで、よく、アプリを終了しようとして戻るボタン押すと、わざわざ「終了しますか?」って聞いていくるアプリがありますが、私、アレ嫌いなんですよね・・・もっと酷いのだと、戻るボタンの動作を無効にしていて、戻るボタンではアプリを終了できないのさえ、あります。そういうアプリは直ぐアンインストールしちゃいます。が、その仕様を企画から要求されたときには・・・ジレンマをご想像下さい^^; Googleさんも、「ユーザーの意図を妨げる、無効にするような処理」はやっちゃだめと言っているのにねえ・・・

==愚痴ここまで


4. 入力画面のレイアウトを作る

fragment_log_input.xmlを編集していきます。
とりあえず、既にあるTextViewは不要なので、削除します。

こんな見た目はどうでしょうか?(デザインセンスが無いのは目を瞑って下さい汗)

kotlin_04_040.png

階層はこんな感じになります。

kotlin_04_041.png

xmlは参考までにこちら。
fragment_log_input.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".activity.logitem.LogInputFragment">

    <TextView
            android:text="@string/label_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/label_date"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="16dp"/>
    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/text_date"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/label_date"
            android:layout_marginBottom="8dp"
            app:layout_constraintBottom_toBottomOf="@+id/button_date"
            android:textSize="18sp"
            tools:text="2999/99/99"/>
    <Button
            android:text="@string/label_select_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/button_date"
            app:layout_constraintStart_toEndOf="@+id/text_date"
            android:layout_marginStart="8dp"
            app:layout_constraintTop_toBottomOf="@+id/label_date"/>
    <TextView
            android:text="@string/label_step_count"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/label_step_count"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/button_date"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"/>
    <EditText
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:inputType="numberSigned"
            android:ems="10"
            android:id="@+id/edit_count"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/label_step_count"
            android:hint="@string/hint_edit_step"
            android:singleLine="true"
            android:textAlignment="textEnd"
            android:importantForAutofill="no" tools:targetApi="o"/>
    <TextView
            android:text="@string/label_level"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/label_level"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/edit_count"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"/>
    <RadioGroup
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:layout_marginTop="8dp"
            android:orientation="horizontal"
            app:layout_constraintTop_toBottomOf="@+id/label_level"
            android:id="@+id/radio_group"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginEnd="32dp">
        <RadioButton
                android:text="@string/level_normal"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/radio_normal"/>
        <ImageView
                android:src="@drawable/ic_sentiment_neutral_green_24dp"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:id="@+id/imageView"/>
        <RadioButton
                android:text="@string/level_good"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/radio_good"
                android:layout_marginLeft="8dp"/>
        <ImageView
                android:src="@drawable/ic_sentiment_very_satisfied_pink_24dp"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:id="@+id/imageView2"/>
        <RadioButton
                android:text="@string/level_bad"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/radio_bad"
                android:layout_marginLeft="8dp"/>
        <ImageView
                android:src="@drawable/ic_sentiment_dissatisfied_black_24dp"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:id="@+id/imageView3"/>
    </RadioGroup>
    <TextView
            android:text="@string/label_weather"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/label_weather"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/radio_group"/>

    <Spinner
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:minWidth="180dp"
            android:id="@+id/spinner_weather"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/label_weather"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="32dp"
            android:entries="@array/array_weathers"/>
    <Button
            android:text="@string/resist"
            android:layout_height="wrap_content"
            android:layout_width="match_parent"
            android:id="@+id/button_resist"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginEnd="8dp"
            app:layout_constraintTop_toBottomOf="@+id/spinner_weather"
            android:layout_marginTop="24dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

RadioGroupRadioButtonSpinnerが新しいですかね。

RadioGroupRadioButtonはその名の通り、ラジオボタンを管理するグループと、ラジオボタンのアイテムです。今回は、android:orientation="horizontal"を指定してるので、横に並びますが、デフォルトは縦に並びます。
また、RadioGroupは恐らくLinearLayoutの派生なので、RadioButtonでなくても他の子Viewを並べられます。
ただ、通常、RadioButtonを配置していくと、android:layout_weight="1"という属性がデフォルトで入ります。
これが本来は都合が良いのですが(並べた要素の数に応じて均等な幅にしてくれる)、今回はアイコン画像を差し込みたかったのもあり、その属性を消去しています。動作が気になる方は、入れてみてどうなるか見てみて下さい。

Spinnerは、いわゆるドロップダウンボックスです。固定の要素をポップアップでリスト表示して選べるあれです。
要素は、今回は完全に固定なので、android:entries="@array/array_weathers"と属性で指定しています。要素が変動する場合は、プログラム上から設定することになります。

array/array_weathersが新しいリソースの定義の仕方になりますかね。これは、arrays.xmlというファイルをres/values下に作って以下のよう記述してあります。

arrays.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="array_weathers">
        <item>晴れ</item>
        <item></item>
        <item>曇り</item>
        <item></item>
        <item>寒い</item>
        <item>暑い</item>
    </string-array>
</resources>

このarraysは、後でenumクラス(WEATHER)と付き合わせることになるので、enumクラスでの定義順と同じ並びになっている必要があります。


(4) 入力データの受け渡し

入力画面が出来たので、登録ボタンが押されたときに、その値を収集して、MainActivityに返すようにしてみましょう。
データの受け渡しには、ViewModel(LiveData)を使って行きます。data bindingは今回ちょっと使いませんが、使えないことはないはずなので興味ある方はチャレンジしてみて下さい。

1. ViewModelを作成する

クラス名は、LogItemViewModelとでもしましょうか。パッケージは、activity.logitemとしました。

LogItemViewModel.kt
import androidx.annotation.UiThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import jp.les.kasa.sample.mykotlinapp.data.StepCountLog

class LogItemViewModel : ViewModel() {

    private val _stepCountLog = MutableLiveData<StepCountLog>()

    val stepCountLog = _stepCountLog as LiveData<StepCountLog>

    @UiThread
    fun changeLog(data :StepCountLog){
        _stepCountLog.value = data
    }
}

さて、今回はちょっとLiveDataの取り扱いに小技を入れてみました。stepCountLogをそのままMutableLiveDataとしても良いのですが、それではどこで誰が変更しようとするか分かりづらくなります。changeLogという関数を介してしか、変更できないようにしておくと、[Find Usage]とか、[Grep]検索とかで、非常に探し出しやすくなります。・・・というのはかなり強引に考えた理由なのですが、Googleさんがこうしなさいと言っているみたいです。

ということで、Mutableな_stepCountLogをprivate変数とし、誰でも参照できるpublicなstepCountLogは、読み取り専用としました。

changeLogは新しいStepCountLog型のオブジェクトを受け取り、それをそのままLiveDataの値として発行します。setValueを直接使っていて、postValueとしていないため、この関数は、必ずUIメソッドから呼ばれる必要があります。そのため、@UiThreadアノテーションを付けています。

2. LogItemViewModelを使うようにする

LogItemActivityと、LogInputFragmentで、LogItemViewModelを使うようにします。

LogItemActivity.kt
    lateinit var viewModel: LogItemViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
...
        viewModel = ViewModelProviders.of(this).get(LogItemViewModel::class.java)
    }
LogInputFragment.kt
    lateinit var viewModel: LogItemViewModel

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)
    }

ほとんど同じですが、Fragmentの方は、少し初期化のタイミングが違います。onCreateViewでも良さそうな気がするけど、AndroidStudioが[Fragment + LiveData]でFragmentを新規作成したときに生成するコードが、ここで初期化していたのでそれを踏襲することにします。

やってることはもう分かりますね。ActivityとViewModelを共有したいので、ViewModelProviders.ofにはactivityを渡しています。(むしろ共有しないパターンがこのアプリでは出てこない気がしてきた)

3. データをMainActivityに戻す処理を実装する

  • ラジオボタンやスピナーの選択状態から、enum値を取り出す関数を作る

LogInputFragmentに、下記のようなprivate関数を作ります。

LogInputFragment.kt
    private fun levelFromRadioId(checkedRadioButtonId: Int): LEVEL {
        return when (checkedRadioButtonId) {
            R.id.radio_good -> LEVEL.GOOD
            R.id.radio_bad -> LEVEL.BAD
            else -> LEVEL.NORMAL
        }
    }

    private fun weatherFromSpinner(selectedItemPosition: Int): WEATHER {
        return WEATHER.values()[selectedItemPosition]
    }

weatherFromSpinnerが少し見慣れないコードでしょうか。といっても、Javaのenumと同様、values()で配列で取得できるので、それを利用して、「選択したスピナーの位置」をindexとして配列から値を取ってきて返していると言うだけのコードです。
ここで、配列の順番と、スピナーの各アイテムの位置が一致してないとこの手法は使えないので、arraysリソース定義のところで、「一致させる必要がある」と書きました。
当然、要素が途中に増えたりすると面倒なこと(挿入位置の間違いや片方の挿入漏れの発生等)になるので、頻繁に改修が入るようなときには要注意ですね。

  • 登録ボタンにクリックリスナーを登録する

onCreateViewで、登録ボタンに対してクリックリスナーを登録します。
onCreateViewの全体はこのようになります。

LogInputFragment.kt
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val contentView = inflater.inflate(R.layout.fragment_log_input, container, false)

        contentView.radio_group.check(R.id.radio_normal)
        contentView.text_date.text = getDateStringYMD(Calendar.getInstance().time)

        contentView.button_resist.setOnClickListener {
            val dateText = text_date.text.toString()
            val stepCount = edit_count.text.toString().toInt()
            val level = levelFromRadioId(radio_group.checkedRadioButtonId)
            val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
            val stepCountLog = StepCountLog(dateText, stepCount, level, weather)

            viewModel.changeLog(stepCountLog)
        }

        return contentView
    }

ビューの子要素にアクセスしたいので、ルートのViewをcontentView変数に入れ、最終的にそれをreturnしています。

contentView.radio_group.check(R.id.radio_normal)は、初期値のラジオボタンを選択状態にしています。未選択だと都合が悪いので。

contentView.text_date.text = getDateStringYMD(Calendar.getInstance().time)は、日付を今日の日付で初期化しています。
日付を選ぶ実装は少し後に入れます。getDateStringYMD関数は、以前InputDialogFramentにあったものを、Util.ktファイルに移して、Calendarクラス拡張関数にしてみました。

Util.kt
fun Calendar.getDateStringYMD():String{
    val fmt = SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN)
    return fmt.format(this.time)
}

fun クラス名.新しい関数宣言で、あるクラスに対して、追加の関数を定義することが出来ます。これが拡張関数です。
元のクラスを変更したり、わざわざ派生クラスを作る必要も無く、新しい関数を作れるので、とても便利です。
これにより、あたかも最初からCalendarクラスにgetDateStringYMDというメソッドが用意されていたかのように、利用できるわけです。

ちゃんとthisでそのオブジェクトのメンバーやメソッドにアクセスできます。thisは省略可能ですが、敢えて分かりやすく書いておきました。
あたかもクラスのメソッドのように使えると書きましたが、privateprotectなメンバーにはアクセスできないようです。

contentView.button_resist.setOnClickListener {...}が、登録ボタンにクリックリスナーを登録するコードです。ラムダになっています。やっていることは単純なので分かりますね。各Viewから値を取得し、レベルと天気については、先ほど作った関数からenum値に変換し、最終的にStepCountLogの新しいインスタンスを作って、ViewModelに変更を掛けています。

(data bindingにしたらここもかなりスッキリしそうですね)

尚、contentView.text_dateとしている箇所と、ラムダの中ではtext_dateと直接アクセスしていたりする違いについてですが、onCreateViewを抜けるまでは、直接アクセスするtext_dateのほうは、まだnullなんですね。だってonCreateViewが返したViewオブジェクトを使って初めてFragmentが作られますから。この時点ではまだFragmentにViewはセットされていないんです。
なのでonCreateView内では、ルートのViewからアクセスしています。

import文をよく見ると、以下の2つがあることが分かるとかと思いますが、

import kotlinx.android.synthetic.main.fragment_log_input.*
import kotlinx.android.synthetic.main.fragment_log_input.view.*

下にあるのがcontentView.text_dateとアクセスするためのパッケージで、上にあるのが、直接アクセスするためのパッケージです。どちらも自動生成されたものです。

4. Activityでデータ変更を検知して呼び出し元に戻す

LogItemActivityで、LogItemViewModel.stepCountLogを監視しておき、変更がかかったら呼び出し元のActivityに値を返し、自分は終了するようにします。

  • LiveDataを監視するコード
LogItemActivity.kt
        viewModel.stepCountLog.observe(this, Observer {
            val dataIntent = Intent()
            dataIntent.putExtra("data", it)
            setResult(RESULT_OK, dataIntent)
            finish()
        })

Activityが呼び出し元にデータを返す常套手段は、setResultにリザルトコードを指定したり、付加情報を付けたIntentを返す方法です。

dataIntent.putExtra("data", it)で、dataIntentのExtraデータのキー名"data"として、it、すなわちStepLogCountのオブジェクトを入れています。
その後、setResult(RESULT_OK, dataIntent)で、RESULT_OKというリザルトコードと共に、dataIntentを結果として設定後、自分自身をfinish()しています。
これで、呼び出し元のActivityが、リザルトコードとdatIntentを受け取ることが出来ます。

・・・dataIntent.putExtra("data", it)でエラーになっているって?
今からそこを直します。

その前に、"data"のべた書きが気になるので、companion objectに定数で定義しておきます。

LogItemActivity.kt
class LogItemActivity : AppCompatActivity() {

    companion object {
        const val EXTRA_KEY_DATA = "data"
    }
}
LogItemActivity.kt
     dataIntent.putExtra(EXTRA_KEY_DATA, it)

5. StepLogCountをSerializableにする

Intent#putExtraは色んな型を受け取れるようオーバーロードされた関数がたくさんあります。
頑張ってStepLogCountの要素1つずつ、putExtra(String)とかでチマチマやっても良いですが、ちゃんとクラスで対応しておく方が後々楽です。

クラスごとIntentのExtraに設定できるようにするには、2つのアプローチがあります。

  1. Serializableにする
  2. Parcelableにする

Serializableは、Javaにもあるデータの直列化ですね。
Parcelableは、フルパッケージはandroid.os.Parcelableとなっていて、Androidならではの形式になります。

今回は、Serializableにします。というのも、その方が圧倒的に簡単だし(Parcelableにするとコーディング量が増える)、Parcelableのメリットは「アプリ同士で外部連携が可能」という部分くらいかなと思っているので、今回はSerializableで充分だと思います。

クラスをSerializableにするには、そのメンバーもすべてSerializableである必要があるので、Bitmap何かを渡したいときには注意が必要です。今回は、String, Int, そしてEnumが対象ですが、EnumクラスはSerializableに対応しているので、そこも問題ありません。

早速Serializableにしましょう。

Serializableをimplementするだけですね。

data class StepCountLog(
    val date: String,
    val step: Int,
    val level: LEVEL = LEVEL.NORMAL,
    val weather: WEATHER = WEATHER.FINE
) : Serializable

これで入力画面からデータを戻すところは出来ました。

6. 戻されたデータをMainActivityで処理する

入力画面からの結果を受け取って処理するコードを書いていきます。

  • Activityの終了結果を受け取る
MainActivity.kt
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {

        when (requestCode) {
            REQUEST_CODE_LOGITEM -> {
                onNewStepCountLog(resultCode, data)
                return
            }
        }

        super.onActivityResult(requestCode, resultCode, data)
    }

自分がstartActivityForResultで起動したActivityが終了したことは、onActivityResultで受け取ります。
上記のコードは、requestCode==REQUEST_CODE_LOGITEMだったとき、onNewStepCountLogメソッドを呼び出しています。
when{}節がネストするのは嫌いなので、private関数に分けました。

onNewStepCountLogは次のようになります。

MainActivity.kt
   private fun onNewStepCountLog(resultCode: Int, data: Intent?) {
        when (resultCode) {
            RESULT_OK -> {
                val log = data!!.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog
                viewModel.addStepCount(log)
            }
        }
    }

resultCode==RESULT_OKだったときのみ、dataから"data"キーのExtraデータをSerializableで取り出し、それをStepCountLogにキャストしています。?を付けていないので、この時変数lognon-nullです。万が一nullになる場合(data==nullか、Extraデータに該当キーのデータが無い、nullがセットされているなど)は、キャストに失敗するため、例外でクラッシュします。しかしnullは実装上あり得てはいけないので、ここでは!!で強行しています。
が、もし万全を期すなら、

                val log = data?.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog?
                log?.let{
                    viewModel.addStepCount(log)
                }

これくらいやれば、万全でしょう。

後は、これまで見てきたように、その値をViewModelに渡してリストに追加すれば、完了です。

実行してみましょう。
天気、レベルのアイコンが、ちゃんと入力画面で選んだ内容に変わっているはずです。

(5) 日付を選択出来るようにする

最後に、「日付を選ぶ」ボタンを押したときに、日付が選べるようにします。
CalendarViewというそのままズバリの物があるので、使いましょう。

1. DateSelectDialogFragmentクラスを作って表示する

InputDialogFragmentを作ったように、DateSelectDialogFragmentクラスを、AlertDialogで表示するように作ります。
レイアウトは特に不要で、CalendarViewオブジェクトをそのままsetViewします。

また、CalendarViewには、「今日」を初期値で選択状態にします。
CalendarView#setDateで初期値が設定できます。

button_dateが押されたときに、DateSelectDialogFragmentを表示します。

2. 選択した日付をセットするLiveDataを定義

LogItemViewModelに、選んだ日付を受け渡せるLiveDataを定義し、DateSelectDialogFragmentのポジティブボタンが押されたときに変更するようにします。

CalendarViewでの日付変更イベントリスナーは、CalendarView#setOnDateChangeListenerで設定します。

3. そのLiveDataをobserveし、変更をTextViewに反映する

そして、LogInputFragmentでそのLiveDataをobserveし、変更時にtext_dateに反映するようにします。せっかく作ったCalendarクラスの拡張関数getDateStringYMD()を使いましょう。

ここまでの復習です。まずは上記をヒントに作ってみましょう。

サンプルはこちら。
DateSelectDialogFragment.kt
import android.app.Dialog
import android.os.Bundle
import android.widget.CalendarView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProviders
import java.util.*

/**
 * 日付選択ダイアログ
 **/
class DateSelectDialogFragment : DialogFragment() {

    // CalendarViewで選択している日付の保存
    private val selectDate = Calendar.getInstance()

    // CalendarView
    lateinit var calendarView: CalendarView

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)

        // AlertDialogで作成する
        val builder = AlertDialog.Builder(requireContext())

        // CalendarViewのインスタンス生成
        calendarView = CalendarView(requireContext())
        // 初期値(今日)をセット
        calendarView.date = selectDate.timeInMillis

        // 選択している日付が変わったときのイベントリスナー
        calendarView.setOnDateChangeListener { _, year, month, dayOfMonth ->
            selectDate.set(year, month, dayOfMonth)
        }

        // AlertDialogのセットアップ
        builder.setView(calendarView)
            .setNegativeButton(android.R.string.cancel, null)
            .setPositiveButton(android.R.string.ok) { _, _ ->
                // ポジティブボタンでVieModelに最後に選択した日付をセット
                viewModel.dateSelected(selectDate)
            }
        return builder.create()
    }
}
LogItemViewModel.kt
class LogItemViewModel : ViewModel() {

    // StepCountLogデータ(Activityに戻す用)
    private val _stepCountLog = MutableLiveData<StepCountLog>()
    val stepCountLog = _stepCountLog as LiveData<StepCountLog>

    // 選択日付
    private val _selectDate = MutableLiveData<Calendar>()
    val selectDate = _selectDate as LiveData<Calendar>

    /**
     * StepCountLogデータのセット(すべてのデータの登録完了)
     */
    @UiThread
    fun changeLog(data: StepCountLog) {
        _stepCountLog.value = data
    }

    /**
     * 選択した日付のセット
     */
    @UiThread
    fun dateSelected(selectedDate: Calendar) {
        _selectDate.value = selectedDate
    }
}
LogInputFragment.kt
class LogInputFragment : Fragment() {

    companion object {
        const val DATE_SELECT_TAG = "date_select"

        fun newInstance(): LogInputFragment {
            val f = LogInputFragment()
            return f
        }
    }

    lateinit var viewModel: LogItemViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val contentView = inflater.inflate(R.layout.fragment_log_input, container, false)

        contentView.radio_group.check(R.id.radio_normal)
        contentView.text_date.text = Calendar.getInstance().getDateStringYMD()

        contentView.button_resist.setOnClickListener {
            val dateText = text_date.text.toString()
            val stepCount = edit_count.text.toString().toInt()
            val level = levelFromRadioId(radio_group.checkedRadioButtonId)
            val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
            val stepCountLog = StepCountLog(dateText, stepCount, level, weather)

            viewModel.changeLog(stepCountLog)
        }

        // 日付を選ぶボタンで日付選択ダイアログを表示
        contentView.button_date.setOnClickListener {
            val fgm = fragmentManager ?: return@setOnClickListener // nullチェック
            DateSelectDialogFragment().show(fgm, DATE_SELECT_TAG)
        }

        return contentView
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)

        // 日付の選択を監視
        viewModel.selectDate.observe(this, Observer {
            text_date.text = it.getDateStringYMD()
        })
    }

...


ビルド、実行してみてください。

4. 入力値の検証を行う

日付を変更して、登録ボタンを押すと、・・・あれ?クラッシュすることがある?
そんなときは、Logcatの出番です。どこで落ちているか、分かりましたか?

2019-06-20 03:24:11.471 29889-29889/jp.les.kasa.sample.mykotlinapp E/AndroidRuntime: FATAL EXCEPTION: main
    Process: jp.les.kasa.sample.mykotlinapp, PID: 29889
    java.lang.NumberFormatException: For input string: ""
        at java.lang.Integer.parseInt(Integer.java:533)
        at java.lang.Integer.parseInt(Integer.java:556)
        at jp.les.kasa.sample.mykotlinapp.activity.logitem.LogInputFragment$onCreateView$1.onClick(LogInputFragment.kt:46)
        at android.view.View.performClick(View.java:5657)
        at android.view.View$PerformClick.run(View.java:22314)
        at android.os.Handler.handleCallback(Handler.java:751)
        at android.os.Handler.dispatchMessage(Handler.java:95)
        at android.os.Looper.loop(Looper.java:241)
        at android.app.ActivityThread.main(ActivityThread.java:6223)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:865)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:755)


    --------- beginning of system

こんなスタックトレースが吐かれているはずです。
例外はjava.lang.NumberFormatException: For input string: ""。空文字""が渡ったのがダメだったようです。
原因箇所は、LogInputFragment.kt:46とのことですから、LogInputFragment.ktファイルの46行目です。クリックできるようになっていますので、クリックするとその行に飛んでくれます。便利。

val stepCount = edit_count.text.toString().toInt()で落ちています。どうやら、空文字""がtoInt()に渡ってはいけないようです。
どうしましょうか?強制的に0にするか・・・

値が未入力だったら、登録できないようにするのは、どうでしょう?

はいということで、入力データのバリデーション(varidation/検証)を行うようにしましょう。検証エラーだったら、メッセージを表示して、登録できないことをユーザーに知らせます。

今回の場合、空文字でなければ良いので、チェックはこうなります。

    if(edit_count.text.toString()==0){
        // エラーメッセージを表示
    }else{
        // 登録処理
    }

それと、未来日付もNGにしましょうか。
こんなチェック関数を用意しました。logInputValidationはグローバルな関数にしています。理由は後でJUnitでテストしやすいようにです^^;

class LogInputFragment : : Fragment() {
...
    private fun validation(): Int? {
        val selectDate = viewModel.selectDate.value?.clearTime()
        return logInputValidation(today, selectDate!!, edit_count.text.toString())
    }
}

fun logInputValidation(
    today: Calendar, selectDate: Calendar,
    stepCountText: String?
): Int? {
    if (today.before(selectDate)) {
        // 今日より未来はNG
        return R.string.error_validation_future_date
    }
    // ステップ数が1文字以上入力されていること
    if (stepCountText.isNullOrEmpty()) {
        return R.string.error_validation_empty_count
    }
    return null
}

clearTimeという関数は、Calendarクラスの拡張関数で次のように定義しました。

Util.kt
fun Calendar.clearTime() : Calendar{
    set(Calendar.HOUR, 0)
    set(Calendar.MINUTE, 0)
    set(Calendar.SECOND, 0)
    set(Calendar.MILLISECOND, 0)
    return this
}

LogInputFragmenttodayを今日で初期化し、同時にclearTime()しておきます

LogInputFragment.kt
    private val today = Calendar.getInstance().clearTime()

なぜ時間をクリアしているか分かりますか?

logInputValidationメソッドで、Calendar#beforeを使ってますが、この関数は当然ながら、ミリ秒まで比較します。
年月日までの比較で良いので(というかミリ秒まで比較すると都合が悪い)、不要なところはクリアしているというわけです。

最後に、登録部分の処理を書き換えます。

LogInputFragment.kt
        contentView.button_resist.setOnClickListener {
            validation()?.let {
                val fgm = fragmentManager ?: return@setOnClickListener
                ErrorDialog.Builder().message(it).create().show(fgm, null)
                return@setOnClickListener
            }

            val dateText = text_date.text.toString()
            val stepCount = edit_count.text.toString().toInt()
            val level = levelFromRadioId(radio_group.checkedRadioButtonId)
            val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
            val stepCountLog = StepCountLog(dateText, stepCount, level, weather)

            viewModel.changeLog(stepCountLog)
        }
  • ErrorDialog.Builder().message(it).create().show(fgm, null)

ErrorDialogクラスはこんな感じで作りました。最近、Builderかますのがマイブームです。

ErrorDialog.kt
class ErrorDialog : DialogFragment() {

    class Builder() {
        private var message: String? = null
        private var messageResId: Int = R.string.error

        fun message(message: String): Builder {
            this.message = message
            return this
        }

        fun message(resId: Int): Builder {
            this.messageResId = resId
            return this
        }

        fun create(): ErrorDialog {
            val d = ErrorDialog()
            d.arguments = Bundle().apply {
                if (message != null) {
                    putString(KEY_MESSAGE, message)
                } else {
                    putInt(KEY_RESOURCE_ID, messageResId)
                }
            }
            return d
        }
    }

    companion object {
        const val KEY_MESSAGE = "message"
        const val KEY_RESOURCE_ID = "res_id"
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        // AlertDialogで作成する
        val builder = AlertDialog.Builder(requireContext())

        // メッセージの決定
        val message =
            when {
                arguments!!.containsKey(KEY_MESSAGE) -> arguments!!.getString(KEY_MESSAGE)
                else -> requireContext().getString(
                    arguments!!.getInt(KEY_RESOURCE_ID)
                )
            }
        // AlertDialogのセットアップ
        builder.setMessage(message)
            .setNeutralButton(R.string.close, null)
        return builder.create()

    }
}

カレンダーで未来の日付を選択したり、ステップ数を入力しないで登録ボタンを押して下さい。
エラーメッセージは、strings.xmlにお好きに定義して下さい。

kotlin_04_050.png

kotlin_04_051.png

4. テスト

今回テストはこんな内容でしょうか。

  • 表示テストを修正する

    • 追加メニューボタンでダイアログでは無くActivityが起動する
    • RecyclerViewのレイアウト変更に合わせたテストの修正
  • 新しい表示テスト

    • 登録画面の表示テスト
    • 登録画面で日付選択ボタンでダイアログが表示される
    • ダイアログのテスト
    • 登録画面でステップ数を入力したときのテスト
    • 登録画面でラジオボタンを押したときのテスト
    • 登録画面でスピナーを押したときのテスト
    • 登録画面で登録ボタンを押したときのテスト
  • validationのテスト

結構ボリュームがありますね。頑張りましょう。

(1) MainActivityのテストの変更

androidTest向けに書きます。Robolectric向けは、Githubにはpushしてあります。
下記をapp/build.gradleのdependenciesに追加しておく必要があるかも知れません。

app/build.gradle
    androidTestImplementation 'org.assertj:assertj-core:3.2.0'

また、テストをビルド時に

   > Error while dexing.
     The dependency contains Java 8 bytecode. Please enable desugaring by adding the following to build.gradle

というようなエラーが出た場合は、これは"dependenciesがJava8を使ってるから有効にしなさい"というメッセージなので、その下にある指示の通り、app/build.gradleandroid{}内に、下記を追記して下さい。

app/build.gradle
android{
...
    compileOptions {
        sourceCompatibility 1.8
        targetCompatibility 1.8
    }
}

1. 不要なテストを削除

以下のテストは不要なので削除します。

  • inputDialogFragmentShown
  • inputStep

また、addRecordMenuIconは、最初のEspresso.pressBack()が不要になったので削除しておきます。

2. 画面遷移のテスト

addRecordMenuを変更して、画面遷移のテストにします。

MainActivityTestI.kt
    @Test
    fun addRecordMenu() {
        // ResultActivityの起動を監視
        val monitor = Instrumentation.ActivityMonitor(
            LogItemActivity::class.java.canonicalName, null, false)
        getInstrumentation().addMonitor(monitor)

        // 追加メニューをクリック
        onView(
            Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
        ).perform(click())

        // ResultActivityが起動したか確認
        val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L)
        assertThat(monitor.hits).isEqualTo(1)
        assertThat(resultActivity).isNotNull()

        // 端末戻るボタンで終了を確認
        Espresso.pressBack()
        assertThat(resultActivity.isFinishing).isTrue()
    }

Activityが起動したかどうかのテストは、ActivityMonitorを使ってカウントが増えたことと、その結果のresultActivityがnullでないことで確認するのが王道・・・かな?
取り敢えず起動したことだけを確認したい場合は、これで十分かと思います。

尚、上記テストコードをそのままRobolectric向けで実行すると、resultActivityがnullになってしまいテストが失敗します。
hit==1にはなっているのに・・・
原因・回避方法を探っていますがまだ分かっていないので、分かったら追記します。
今は取り敢えず、androidTestで進めていきます。

3. 新しいRecyclerViewレイアウト

addRecordListも、レイアウトが変わったのでビルドエラーになっていると思います。直していきましょう。

MainActivityTestI.kt
    @Test
    fun addRecordList() {
        // ViewModelのリストに直接追加
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // リストの表示確認
        var index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("12345"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/13"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_wb_sunny_yellow_24dp),R.id.weatherImageView)))
            // @formatter:on
        index = 1
        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp),R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_iconmonstr_umbrella_1),R.id.weatherImageView)))
        // @formatter:on
    }

mainActivity.runOnUiThread {}の部分は、ViewModelのaddStepCount@UiThreadを付けた上でLiveDataのpostValueではなくsetValueを使っているので、UIスレッドから更新する必要があるためにこうしています。
(※ViewModelを更新できるルールがあるはずなのですが、どうにもimportが出来ず、今回は使うのを断念しました。また解決次第、追記します)

InstrumentationRegistry.getInstrumentation().waitForIdleSync()で、UIの更新が終わるのを待っています。

尚、Robolectric版の場合は、このUIスレッドからviewModelを更新しなければならない、という部分はなぜか見逃されるので、以下のように書いてもテストは通りますが、もしこれがRobolectricのバグで、将来直されたら通過しなくなる可能性は有ります。

        val mainActivity = activityRule.activity
        mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
        mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))

        // リストの表示確認
   ...

// @formatter:off// @formatter:onは、フォーマッターで改行されて縦に長くなって見づらかったので解除してみました。この二つで囲むと、formatterを部分的に無効にすることが出来ますが、設定で有効にしておく必要があります。

[Preferences]-[Editor]-[Code Style]とし、下記図の部分にチェックを入れ、[OK]すると有効になります。

kotlin_04_052.png

withDrawableというのも自作関数です。
やっているのは、ImageViewに設定されているカレントのDrawableと、指定のリソースidのDrawableが、内部的に同じBitmapかをチェックしています。

class DrawableMatcher(private val expectedId: Int) : TypeSafeMatcher<View>(View::class.java) {
    private var resourceName: String? = null

    override fun matchesSafely(target: View): Boolean {
        if (target is ImageView) {
            if (expectedId < 0) {
                return target.drawable == null
            }
            val resources = target.getContext().resources
            val expectedDrawable = resources.getDrawable(expectedId, null)
            resourceName = resources.getResourceEntryName(expectedId)

            if (expectedDrawable == null) {
                return false
            }

            var drawable = target.drawable
            if (drawable is StateListDrawable) {
                drawable = drawable.getCurrent()
            }

            val bitmap = drawable.toBitmap()
            val otherBitmap = expectedDrawable.toBitmap()
            return bitmap.sameAs(otherBitmap)
        }
        return false
    }


    override fun describeTo(description: Description) {
        description.appendText("with drawable from resource id: ")
        description.appendValue(expectedId)
        if (resourceName != null) {
            description.appendText("[")
            description.appendText(resourceName)
            description.appendText("]")
        }
    }
}

fun withDrawable(resourceId: Int): Matcher<View> {
    return DrawableMatcher(resourceId)
}


どこに書いてもいいですが、atPositionOnViewと併せて、EspressoUtils.ktとかにまとめておいてもいいかも知れませんね。

(2) 登録画面のテスト

新しく追加した画面のテストをしましょう。Fragmentごとにテストするという手もあるようなのですが、今はまだ1つしかないので、いったんActivityのテストとして作成します。

なお、LogItemActivityのActivityRuleは次のようにします。

LogItemActivityTest.kt
    @get:Rule
    val activityRule = ActivityTestRule(LogItemActivity::class.java, false, false)

ActivityTestRuleコンストラクタの2つめの引数は、initialTouchModeに対するBoolean値です。デフォルトはfalseです。
3つめの引数は、launchActivityに対するBoolean値です。デフォルトはtrueで、trueだと、自動的にActivityが起動されます。でも、自動で起動されたら困る場合もあります。例えば、先に設定ファイルやデータベースの値を、テストケースごとに設定したい場合などです。LogItemActivityはいずれ他のFragmentを起動する場合を想定しているため、あらかじめ自動で起動せず、こちらで設定を終えた後で手動で起動するようにしておきます。

Activityを起動するコードは次のようになります。

    activity = activityRule.launchActivity(null)

launchActivityの引数はIntent型です。Activityを起動するIntentを指定できますが、ExtraDataが特にない場合は、nullでOKです。

上記を踏まえ、テストを書いていってみましょう。

1. 起動直後の表示のテスト

各Viewの初期値のセットを確認しましょう。

2. 日付選択ボタンでダイアログが表示されるテスト

以前あったInputDialogFragmentに対してやっていたのと同じ感じで、該当のDialogが表示されていることを確認したいですが、CalendarViewが中々に特殊なので、ちょっと別のアプローチが必要です。
SupportFragmentManagerで、今追加されているFragmentが全部取れるので、そのクラスのオブジェクトからCalendarViewを直接参照して確認します。

3. ステップ数を入力したときのテスト

値がちゃんと置き換わることを確認すれば良いでしょう。

4. ラジオボタンを押したときのテスト

Aを押せばB,Cは非選択になること、Bを押せばA,Cは非選択になること、Cを押せば・・・というのを確認しましょう。
ラジオボタンがチェック状態かどうかは、isChecked()でチェック出来ます。

5. スピナーを押したときのテスト

初期値とは別の任意の要素を選択したとき、表示が変わるかどうかをチェックしましょう。
可能であれば、必要な要素が全部ポップアップされるかも確認しましょう。スクロールすると厄介ですが、取り敢えずそんなに数はないのでまだ大丈夫かと。

6. 登録ボタンを押したときのテスト

値をそれぞれセットして、戻りのIntentにExtraDataとして正しく設定されているか確認しましょう。

7. エラーメッセージの表示テスト

いわゆる"異常系"のテストです。
「日付が未来」「ステップ数が未入力」のとき、エラーメッセージが表示されていることも確認しましょう。
validation関数のUnitテスト自体も書きましたが、その「戻り」を正確に判定して、正しいエラーメッセージを出しているか、のUIのテストとしては、やはり必要です。

8. 実装結果

すべてを実装したandroidTest用のサンプルはこちら。
LogItemActivityTestI.kt
import android.app.Activity
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import jp.les.kasa.sample.mykotlinapp.*
import jp.les.kasa.sample.mykotlinapp.activity.logitem.LogItemActivity.Companion.EXTRA_KEY_DATA
import jp.les.kasa.sample.mykotlinapp.data.LEVEL
import jp.les.kasa.sample.mykotlinapp.data.StepCountLog
import jp.les.kasa.sample.mykotlinapp.data.WEATHER
import jp.les.kasa.sample.mykotlinapp.espresso.withDrawable
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.data.Offset
import org.hamcrest.Matchers.not
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.*

@RunWith(AndroidJUnit4::class)
class LogItemActivityTestI {
    @get:Rule
    val activityRule = ActivityTestRule(LogItemActivity::class.java, false, false)

    lateinit var activity: LogItemActivity

    /**
     *   起動直後の表示のテスト<br>
     *   LogInputFragmentの初期表示をチェックする
     */
    @Test
    fun logInputFragment() {
        activity = activityRule.launchActivity(null)

        // 日時ラベル
        onView(withText(R.string.label_date)).check(matches(isDisplayed()))
        // 日付
        val today = Calendar.getInstance().getDateStringYMD()
        onView(withText(today)).check(matches(isDisplayed()))
        // 日付選択ボタン
        onView(withText(R.string.label_select_date)).check(matches(isDisplayed()))
        // 歩数ラベル
        onView(withText(R.string.label_step_count)).check(matches(isDisplayed()))
        // 歩数ヒント
        onView(withHint(R.string.hint_edit_step)).check(matches(isDisplayed()))
        // 気分ラベル
        onView(withText(R.string.label_level)).check(matches(isDisplayed()))
        // 気分ラジオボタン
        onView(withText(R.string.level_normal)).check(matches(isDisplayed()))
        onView(withText(R.string.level_good)).check(matches(isDisplayed()))
        onView(withText(R.string.level_bad)).check(matches(isDisplayed()))
        onView(withDrawable(R.drawable.ic_sentiment_neutral_green_24dp)).check(matches(isDisplayed()))
        onView(withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp)).check(matches(isDisplayed()))
        onView(withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp)).check(matches(isDisplayed()))
        // 天気ラベル
        onView(withText(R.string.label_step_count)).check(matches(isDisplayed()))
        // 天気スピナー
        onView(withId(R.id.spinner_weather)).check(matches(isDisplayed()))
        // 登録ボタン
        onView(withText(R.string.resist)).check(matches(isDisplayed()))
    }

    /**
     * 日付選択ボタンでダイアログが表示されるテスト
     */
    @Test
    fun selectDate() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance()

        // 日付選択ボタン
        onView(withText(R.string.label_select_date)).perform(click())

        // CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
        // 内容の確認は難しい(表示されているはずの文字列で見つけられない。もしかしたら文字列じゃ無く画像なのかも)
        // なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
        // そこからCalendarViewのインスタンスを得ている
        val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
                as DateSelectDialogFragment
        // 初期選択時間が、起動前に取得した時間と僅差であることの確認
        assertThat(fragment.calendarView.date).isCloseTo(today.timeInMillis, Offset.offset(1000L))
    }

    /**
     * 選択を変更してキャンセルしたときに表示が変わっていないこと
     */
    @Test
    fun selectDate_cancel() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance()

        // 日付選択ボタン
        onView(withText(R.string.label_select_date)).perform(click())

        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
        // 内容の確認は難しい
        // なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
        // そこからCalendarViewのインスタンスを得ている
        val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
                as DateSelectDialogFragment
        val newDate = today.clone() as Calendar
        newDate.add(Calendar.DAY_OF_MONTH, -1) // 未来はNGなので一つ前に
        // 日付を選んだ動作も書けないので、クリックされるときに変わるはずのselectDateを無理矢理上書き。
        // そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してある
        fragment.selectDate.set(newDate.getYear(), newDate.getMonth(), newDate.getDay())

        // ボタンのクリックはEspressoで書ける
        onView(withText(android.R.string.cancel)).perform(click())

        // 新しい日付は表示されていない
        onView(withText(newDate.getDateStringYMD())).check(doesNotExist())
        // 当日のまま
        onView(withText(today.getDateStringYMD())).check(matches(isDisplayed()))
    }

    /**
     * 選択を変更してOKしたときに表示が変わっていること
     */
    @Test
    fun selectDate_ok() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance()

        // 日付選択ボタン
        onView(withText(R.string.label_select_date)).perform(click())

        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // CalendarViewは特殊で、OSバージョンで表示される物が異なるため、
        // 内容の確認は難しい
        // なので、直接SupportFragmentManagerから今持っているFragmentでTAGを条件にDialogFragmentを探しだし、
        // そこからCalendarViewのインスタンスを得ている
        val fragment = activity.supportFragmentManager.findFragmentByTag(LogInputFragment.DATE_SELECT_TAG)
                as DateSelectDialogFragment
        val newDate = today.clone() as Calendar
        newDate.add(Calendar.DAY_OF_MONTH, -1) // 未来はNGなので一つ前に
        // 日付を選んだ動作も書けないので、クリックされるときに変わるはずのselectDateを無理矢理上書き。
        // そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してある
        fragment.selectDate.set(newDate.getYear(), newDate.getMonth(), newDate.getDay())

        // ボタンのクリックはEspressoで書ける
        onView(withText(android.R.string.ok)).perform(click())

        // 新しい日付になっていること
        onView(withId(R.id.text_date)).check(matches(withText(newDate.getDateStringYMD())))
    }

    /**
     * ステップ数を入力したときのテスト
     */
    @Test
    fun editCount() {
        activity = activityRule.launchActivity(null)

        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("12345"))

        onView(withId(R.id.edit_count)).check(matches(withText("12345")))

        // 取り敢えず再入力も
        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("4444"))

        onView(withId(R.id.edit_count)).check(matches(withText("4444")))
    }

    /**
     * ラジオグループの初期状態
     */
    @Test
    fun levelRadioGroup() {
        activity = activityRule.launchActivity(null)

        // 初期選択状態
        onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
        onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
    }

    /**
     * ラジオボタン[GOOD]を押したときのテスト
     */
    @Test
    fun levelRadioButtonGood() {
        activity = activityRule.launchActivity(null)

        onView(withId(R.id.radio_good)).perform(click())

        // 選択状態
        onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
        onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
    }

    /**
     * ラジオボタン[BAD]を押したときのテスト
     */
    @Test
    fun levelRadioButtonBad() {
        activity = activityRule.launchActivity(null)

        onView(withId(R.id.radio_bad)).perform(click())

        // 選択状態
        onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
    }

    /**
     * スピナーを押したときのテスト
     */
    @Test
    fun weatherSpinner() {
        activity = activityRule.launchActivity(null)

        // 初期表示
        onView(withText("晴れ")).check(matches(isDisplayed()))

        onView(withId(R.id.spinner_weather)).perform(click())

        // リスト表示を確認
        onView(withText("晴れ")).check(matches(isDisplayed()))
        onView(withText("雨")).check(matches(isDisplayed()))
        onView(withText("曇り")).check(matches(isDisplayed()))
        onView(withText("雪")).check(matches(isDisplayed()))
        onView(withText("寒い")).check(matches(isDisplayed()))
        onView(withText("暑い")).check(matches(isDisplayed()))

        // 初期値以外を選択
        onView(withText("雨")).perform(click())

        onView(withText("晴れ")).check(doesNotExist())
        onView(withText("雨")).check(matches(isDisplayed()))
    }

    /**
     * 登録ボタン押下のテスト:正常
     */
    @Test
    fun resistButton_success() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance().apply {
            set(Calendar.YEAR, 2019)
            set(Calendar.MONTH, 5)
            set(Calendar.DAY_OF_MONTH, 20)
        }
        activity.runOnUiThread {
            activity.viewModel.dateSelected(today)
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("12345"))
        onView(withId(R.id.radio_good)).perform(click())

        onView(withId(R.id.spinner_weather)).perform(click())
        onView(withText("曇り")).perform(click())

        onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
            .perform(click())

        assertThat(activityRule.activityResult.resultCode).isEqualTo(Activity.RESULT_OK)
        assertThat(activityRule.activityResult.resultData).isNotNull()
        val data = activityRule.activityResult.resultData.getSerializableExtra(EXTRA_KEY_DATA)
        assertThat(data).isNotNull()
        assertThat(data is StepCountLog).isTrue()
        val expectItem = StepCountLog("2019/06/20", 12345, LEVEL.GOOD, WEATHER.CLOUD)
        assertThat(data).isEqualToComparingFieldByField(expectItem)
    }


    /**
     * 登録ボタン押下のテスト:未来日付エラー
     */
    @Test
    fun resistButton_error_futureDate() {
        activity = activityRule.launchActivity(null)

        val next = Calendar.getInstance().addDay(1)
        activity.runOnUiThread {
            activity.viewModel.dateSelected(next)
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("12345"))

        onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
            .perform(click())

        onView(withText(R.string.error_validation_future_date)).check(matches(isDisplayed()))
    }

    /**
     * 登録ボタン押下のテスト:カウント未入力エラー
     */
    @Test
    fun resistButton_error_emptyCount() {
        activity = activityRule.launchActivity(null)

        val today = Calendar.getInstance()
        activity.runOnUiThread {
            activity.viewModel.dateSelected(today)
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        onView(withId(R.id.button_resist)).check(matches(isDisplayed()))
            .perform(click())

        onView(withText(R.string.error_validation_empty_count)).check(matches(isDisplayed()))
    }
}

// そのため、selectDate関数のアクセス修飾子を、テスト向けにはpublicになるように変更してあるのコメント部分についてですが、具体的に以下のような記述になっています。

DateSelectDialogFragment.kt
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    val selectDate = Calendar.getInstance()!!

本来はprivateでいいのだけど、テストではアクセスしたいのでpublicにしたい・・・という時に疲れる技です。


Robolectric用は、Githubに上がっているブランチqiita_04にpushしてあります。ご興味ある方はチェックしてみて下さい。

※両方のテストを書いているので、工数が半端なくなってきた・・・
※取り敢えず分かったことは、結構アプローチを変えないと行けないテストが多く、まだまだテストコードの完全共有にはほど遠いな、と感じています。

(3) validationのテスト

1. Calendarの拡張関数のテスト

Util.ktに追加したCalendarクラスの拡張関数2本のテストを追加しましょう。

UtilTest.kt
    @Test
    fun calendar_getStringYMD() {
        val cal = Calendar.getInstance()
        cal.set(2020, 9 - 1, 11) // 月だけはindex扱いなので、実際の月-1のセットとしなければならない
        assertThat(cal.getDateStringYMD()).isEqualTo("2020/09/11")
    }

    @Test
    fun calendar_clearTime() {
        val cal = Calendar.getInstance()
        // 時間関連が0にならないようにセット
        cal.set(Calendar.HOUR, 1)
        cal.set(Calendar.MINUTE, 10)
        cal.set(Calendar.SECOND, 20)
        cal.set(Calendar.MILLISECOND, 300)
        // 0でないことの確認
        assertThat(cal.get(Calendar.HOUR)).isNotEqualTo(0)
        assertThat(cal.get(Calendar.MINUTE)).isNotEqualTo(0)
        assertThat(cal.get(Calendar.SECOND)).isNotEqualTo(0)
        assertThat(cal.get(Calendar.MILLISECOND)).isNotEqualTo(0)

        cal.clearTime()
        // 0になっていることの確認
        assertThat(cal.get(Calendar.HOUR)).isEqualTo(0)
        assertThat(cal.get(Calendar.MINUTE)).isEqualTo(0)
        assertThat(cal.get(Calendar.SECOND)).isEqualTo(0)
        assertThat(cal.get(Calendar.MILLISECOND)).isEqualTo(0)
    }

Githubのプロジェクトには、Util.ktに他にも便宜上追加した関数があり、それらのテストもUtilTestに追加してあります。

2. validation関数のテスト

logInputValidationはpublicなグローバル関数でなので、そのままJUnitのテストとして書けば良いでしょう。
特に難しいことは無いと思いますが、正常系だけで無く、異常系も閾値をしっかりテストしましょう。

3. MainActivityに登録画面からの戻りが反映されるかのテスト

データが登録画面から返ってきたときに表示に反映されるかの確認をします。
このテストはMainActivityTestでonActivityResultのテストとして実装しますが、onActivityResultprotectedで、Javaのprotectedと違い、同じパッケージでも呼び出せないので、androidTestではかなり強引にやります。

他のテスト同様に、登録画面を起動する手順を踏むと、resultActivityが取れますから、resultActivity#setResult()してresultActiviy.finish()してしまいます。
そうすれば後は表示内容を確認すれば良いです。

MainActivityTest.kt
    @Test
    fun onActivityResult() {
        val resultData = Intent().apply {
            putExtra(EXTRA_KEY_DATA, StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.SNOW))
        }

        val monitor = Instrumentation.ActivityMonitor(
            LogItemActivity::class.java.canonicalName, null, false
        )
        getInstrumentation().addMonitor(monitor)

        // 登録画面を起動
        onView(
            Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
        ).perform(click())

        val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 500L)
        resultActivity.setResult(Activity.RESULT_OK, resultData)
        resultActivity.finish()

        // 反映を確認
        val index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_grain_gley_24dp),R.id.weatherImageView)))
            // @formatter:on

    }

Robolectricでは、resultActivityが取れないので、shadowクラスを使った書き方しか分かりませんでした。Githubにpushしてあるので、サンプルを参照してみて下さい。

espresso-intentsライブラリを使って、書けないかなと頑張ったのですが、上手くいきませんでした・・・

(4) ViewModelのテスト

1. MainViewModelのテストの修正

MainViewModel.ktのテストを修正します。
LiveDataの型が変わったのでその対応ですね。

MainViewModel.kt
    @Test
    fun addStepCount() {
        viewModel.addStepCount(StepCountLog("2019/06/21", 123))
        viewModel.addStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))

        assertThat(viewModel.stepCountList.value)
            .isNotEmpty()

        val list = viewModel.stepCountList.value as List<StepCountLog>
        assertThat(list.size).isEqualTo(2)
        assertThat(list[0]).isEqualToComparingFieldByField(StepCountLog("2019/06/21", 123))
        assertThat(list[1]).isEqualToComparingFieldByField(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
    }

2. LogItemViewModelのテスト

MainViewModelと同等なテストを作れば良いでしょう。

LogItemViewModelTest.kt
class LogItemViewModelTest {
    @get:Rule
    val rule: TestRule = InstantTaskExecutorRule()

    lateinit var viewModel: LogItemViewModel

    @Before
    fun setUp() {
        viewModel = LogItemViewModel()
    }

    @Test
    fun init() {
        Assertions.assertThat(viewModel.stepCountLog.value)
            .isNull() // 初期化したときはnull
    }

    @Test
    fun changeLog() {
        viewModel.changeLog(StepCountLog("2019/06/21", 12345, LEVEL.BAD, WEATHER.COLD))

        assertThat(viewModel.stepCountLog.value)
            .isEqualToComparingFieldByField(StepCountLog("2019/06/21", 12345, LEVEL.BAD, WEATHER.COLD))
    }

    @Test
    fun dateSelected() {
        var date = Calendar.getInstance()
        date.set(Calendar.YEAR, 2019)
        date.set(Calendar.MONDAY, 5)
        date.set(Calendar.DAY_OF_MONTH, 15)
        date = date.clearTime()
        viewModel.dateSelected(date)

        assertThat(viewModel.selectDate.value!!.getYear())
            .isEqualTo(2019)
        assertThat(viewModel.selectDate.value!!.getMonth())
            .isEqualTo(5)
        assertThat(viewModel.selectDate.value!!.getDay())
            .isEqualTo(15)
    }

まとめ

data classの使い方を覚えたり、data binding、そしてFragmentの使い方、Activity同士の遷移の仕方を学びました。
結構なボリュームでしたね。

ここまでの状態のプロジェクトをGithubにpushしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_04

次回予告

今は起動するとデータが消えてしまいます。再起動したら、前回入力した値は表示されていたいですよね。
つまりデータを永続化しようというお話になります。
ま、早い話、Room使ってデータベースに保存していきましょ。

あ、でもその前に一つ、番外編を挟むかも知れません。

参考ページなど

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidのリモート接続をスクリプトで自動化する

はじめに

Androidは adb tcpip [port]adb connect [ipaddr:port] でリモート接続できます。
ですが何度も実行するのは面倒なのでスクリプトで自動化しました。

使い方

https://gist.github.com/shiena/e0bfbd465fefae424e42b909f24e5434

  1. ここからOSに合わせたファイルを以下のように保存します。
    • Windowsはadb-connect.batを改行コードCRLFで保存
    • macOSとLinuxはadb-connect.shを改行コードLFで保存してchmod +x adb-connect.shで実行権限を付ける
  2. 変数 ADB をadbコマンドのフルパスに書き換えます。

あとはそのまま実行するだけです。2台以上のAndroidがUSB接続されているとそれぞれリモート接続するか選択できます。

image.png

所感

Windowsのバッチファイルでも意外と文字列処理できるもんですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

OculusQuest / OculusGo / Androidのリモート接続をスクリプトで自動化する

はじめに

Androidは adb tcpip [port]adb connect [ipaddr:port] でリモート接続できます。
ですが何度も実行するのは面倒なのでスクリプトで自動化しました。

使い方

https://gist.github.com/shiena/e0bfbd465fefae424e42b909f24e5434

  1. ここからOSに合わせたファイルを以下のように保存します。
    • Windowsはadb-connect.batを改行コードCRLFで保存
    • macOSとLinuxはadb-connect.shを改行コードLFで保存してchmod +x adb-connect.shで実行権限を付ける
  2. 変数 ADB をadbコマンドのフルパスに書き換えます。

あとはそのまま実行するだけです。2台以上のAndroidがUSB接続されているとそれぞれリモート接続するか選択できます。

image.png

所感

Windowsのバッチファイルでも意外と文字列処理できるもんですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidでMQTTのトピックをサブスクライブし、スマホ画面上にメッセージを表示

やりたいこと

AndroidアプリでMQTTのトピックをサブスクライブし、スマホ画面上にメッセージを表示したい。

絵にすると

[外部サーバー(MQTT Broker)] -> [Android(subscriber)]

なぜこんなことをやるのか

外部のMQTTブローカーからのメッセージをサブスクライブし内容確認を行う部分をアプリ化することで、非エンジニアの方にもアプリをインストールさえすれば利用可能にする。
Webアプリ上でサブスクライブも考えたが、認証部分を考えるのが面倒だったのと、Webサーバーを作りたくなかった(手離れを良くしたかった)

補足

参考記事

https://qiita.com/KazuyukiEguchi/items/c67524e8b3c9c6459b2d

注意

忘備録ですので、基本的なAndroidアプリの開発方法は書きません。

実装

Android Studio 3.3.2 (OSX版)で実装。

最初に、Empty Activityを作成する。

この Eclipse Paho Android Service ライブラリが数年前からデファクトのようなので、利用する。
https://github.com/eclipse/paho.mqtt.android

gradle

app のGradleに以下を追加

repositories {
    maven {
        url "https://repo.eclipse.org/content/repositories/paho-snapshots/"
    }
}

dependencies {
    implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.0'
    implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1'

manifest

    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
     ...
        <service android:name="org.eclipse.paho.android.service.MqttService">
        </service>
    </application>

MainActivity.kt

package me.oxoxo.mqttsubscriber

import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.util.Log
import kotlinx.android.synthetic.main.activity_main.*
import org.eclipse.paho.android.service.MqttAndroidClient
import org.eclipse.paho.client.mqttv3.*

class MainActivity : AppCompatActivity() {

    private lateinit var mqttAndroidClient: MqttAndroidClient

    private val clientId = System.currentTimeMillis()
    private val serverUri = "tcp://<your-endpoint>:1883"
    private val subscriptionTopic = "<your-topic>"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mqttAndroidClient = MqttAndroidClient(applicationContext, serverUri, clientId.toString())
        mqttAndroidClient.setCallback(object : MqttCallbackExtended {
            override fun connectComplete(reconnect: Boolean, serverURI: String) {

                if (reconnect) {
                    addToHistory("Reconnected to : $serverURI")
                    subscribeToTopic()
                } else {
                    addToHistory("Connected to: $serverURI")
                }
            }

            override fun connectionLost(cause: Throwable) {
                addToHistory("The Connection was lost.")
            }

            @Throws(Exception::class)
            override fun messageArrived(topic: String, message: MqttMessage) {
                addToHistory("Incoming message: " + String(message.payload))

                hello.text = String(message.payload)
            }

            override fun deliveryComplete(token: IMqttDeliveryToken) {}
        })

        val mqttConnectOptions = MqttConnectOptions()
        mqttConnectOptions.isAutomaticReconnect = true
        mqttConnectOptions.isCleanSession = false
        mqttConnectOptions.userName = "<user_name>"
        mqttConnectOptions.password = "<password>".toCharArray()

        try {
            mqttAndroidClient.connect(mqttConnectOptions, null, object : IMqttActionListener {
                override fun onSuccess(asyncActionToken: IMqttToken) {
                    val disconnectedBufferOptions = DisconnectedBufferOptions()
                    disconnectedBufferOptions.isBufferEnabled = true
                    disconnectedBufferOptions.bufferSize = 100
                    disconnectedBufferOptions.isPersistBuffer = false
                    disconnectedBufferOptions.isDeleteOldestMessages = false
                    mqttAndroidClient.setBufferOpts(disconnectedBufferOptions)
                    subscribeToTopic()
                }

                override fun onFailure(asyncActionToken: IMqttToken, exception: Throwable) {
                    addToHistory("Failed to connect to: $serverUri")
                    exception.printStackTrace()
                }
            })

        } catch (ex: MqttException) {
            ex.printStackTrace()
        }
    }

    fun subscribeToTopic() {
        try {
            mqttAndroidClient.subscribe(subscriptionTopic, 0, null, object : IMqttActionListener {
                override fun onSuccess(asyncActionToken: IMqttToken) {
                    addToHistory("Subscribed!")
                }

                override fun onFailure(asyncActionToken: IMqttToken, exception: Throwable) {
                    addToHistory("Failed to subscribe")
                }
            })

        } catch (ex: MqttException) {
            System.err.println("Exception whilst subscribing")
            ex.printStackTrace()
        }
    }

    private fun addToHistory(mainText: String) {
        Log.d("tag","LOG: $mainText")
    }
}

endpoint, topic, user name, password は、環境に合わせて入力する。
activity_main.xmlのTextViewに、 android:id="@+id/hello"
を入れて完成。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む