vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

Gsonとか(和歌山トイレマップで遊んでみる 10)

はじめに

ちょこちょこいじっていたつもりではありましたが、ブログとしては結構間が空いていましたね。

以前Spreadsheetに入力していた値をJsonファイルとして出力できるようにしましたが、今回はこれをAndroidアプリ側で取り込んでみることにします。

github.com

Gsonを使う

Jsonファイルを出力する時と同じく、Gsonを使ってファイルからデータを読み込みます。

インストール

app/build.gradle

~省略~
dependencies {
~省略~
    compile "com.google.code.gson:gson:2.7"
~省略~
}
~省略~

何の事はない、dependenciesにgsonの項目を追加しただけですね。
簡単にできるのは素晴らしい。

データを格納するクラスを作る

Jsonファイルを作った時と同じく、Jsonから取得したデータを格納するクラスを作成します。

ToiletInfoClass.java

import com.google.gson.annotations.SerializedName;
import java.util.ArrayList;

public class ToiletInfoClass {
    @SerializedName("toiletInfo")
    private ArrayList toiletInfoList;

    public ArrayList getToiletInfoList(){
        return toiletInfoList;
    }
    public void setToiletInfoList(ArrayList newValue){
        toiletInfoList = newValue;
    }
    public int getInfoCount(){
        if(toiletInfoList == null){
            return 0;
        }
        return toiletInfoList.size();
    }
    public class ToiletInfo{
        @SerializedName("toiletName")
        public String toiletName;

        @SerializedName("district")
        public String district;

        @SerializedName("municipality")
        public String municipality;

        @SerializedName("address")
        public String address;

        @SerializedName("latitude")
        public double latitude;

        @SerializedName("longitude")
        public double longitude;

        @SerializedName("availableTime")
        public String availableTime;

        @SerializedName("hasMultiPurposeToilet")
        public boolean hasMultiPurposeToilet;
    }
}

ここで以前と異なっているのは、Jsonファイルのデータが以下のように配列で格納されている、ということです。

toiletdata.json

  "toiletInfo": [{
        "toiletName":"友ヶ島野奈浦公衆トイレ"
        ,"district":"和歌山県"
        ,"municipality":"和歌山市"
        ,"address":"和歌山市加太笘ヶ沖島2673-3"
        ,"latitude":34.282891
        ,"longitude":135.008677
        ,"availableTime":"終日"
        ,"hasMultiPurposeToilet":true
    }
    ,{
        "toiletName":"友ヶ島南垂水公衆トイレ"
        ,"district":"和歌山県"
        ,"municipality":"和歌山市"
        ,"address":"和歌山市加太苫ケ沖島2673-1"
        ,"latitude":34.28255
        ,"longitude":135.013597
        ,"availableTime":"終日"
        ,"hasMultiPurposeToilet":false
    }
    ,{
    ~省略~

そのためToiletInfoClass自体は配列(ArrayList)のみを持ち、その中身は内部クラスであるToiletInfoが入っている、という状態にしました。
なお、@SerializedNameを使うことで「@SerializedName("toiletInfo")」のようにJsonファイルの項目名を指定できます。

データの読み込み

Jsonデータの読み込み

それではJsonをロードして先ほどのクラスに値をセットします。
Jsonの置き場所は以前のCSVと同じく、app\src\main\assetsに置いています。

ToiletInfoAccesser.java

~省略~
    AssetManager assetManager = currentActivity.getResources().getAssets();
    if(assetManager == null){
        // TODO: Assetの取得に失敗した時の処理.
    }
    else{
        try {
            // Jsonの読み込み.
            InputStream inputStream = assetManager.open("toiletdata.json");
            JsonReader jsonReader = new JsonReader(new InputStreamReader(inputStream));

            // JsonデータをToiletInfoClassとして取得.
            ToiletInfoClass jsonToiletInfoClass = new Gson().fromJson(jsonReader, ToiletInfoClass.class);

            // 取得したデータをDBに挿入.
            toiletInfoModel.insertInfo(sqlite, jsonToiletInfoClass);

            jsonReader.close();
            inputStream.close();

            // DBへのデータ挿入後、データを検索してマーカー設置.
            ToiletInfoClass toiletInfoClass = toiletInfoModel.search(sqlite);
            subscriber.onNext(toiletInfoClass.getToiletInfoList());
            subscriber.onCompleted();
        } catch (IOException ex) {
            subscriber.onError(ex);
        }
    }
~省略~

かなりシンプルですね。
以前CSVを読み込んでいた時と比較するとえらい違いが...。

データの挿入

なお、DBへのデータ挿入はこのような感じに。
以前は登録済みのデータはそのまま無視していたのですが、今後データを修正する必要があった場合などを考えて上書き処理をすることとしました。

ToiletInfoModel.java

~省略~
    public void  insertInfo(SQLiteDatabase db, ToiletInfoClass toiletInfoClass) {
        Cursor cursorMaxId = db.query("toiletinfo"
                , new String[]{"MAX(id)"}
                , null
                , null
                , null, null, null);
        cursorMaxId.moveToFirst();
        int maxId = cursorMaxId.getInt(cursorMaxId.getColumnIndex("MAX(id)"));
        cursorMaxId.close();

        for(ToiletInfoClass.ToiletInfo toiletInfo: toiletInfoClass.getToiletInfoList()){
            Cursor cursorExistence = db.query("toiletinfo"
                    , new String[]{"id"}
                    , "toiletname = ? AND address = ?"
                    , new String[]{toiletInfo.toiletName, toiletInfo.address}
                    , null, null, null);
            cursorExistence.moveToFirst();

            if(cursorExistence.getCount() > 0){
                // 既存データをアップデート.
                update(db, toiletInfo, cursorExistence.getInt(cursorExistence.getColumnIndex("id")));
            }
            else{
                // 新規追加.
                maxId++;
                insert(db, toiletInfo, maxId);
            }
            cursorExistence.close();
        }
    }
~省略~
    private void insert(SQLiteDatabase db, ToiletInfoClass.ToiletInfo toiletInfo, int newId){
        // Transactionの開始.
        db.beginTransaction();

        // Transactionの開始・終了は呼び出し元で実行.
        ContentValues contentValues = new ContentValues();

        contentValues.put("id", newId);
        contentValues.put("toiletname", toiletInfo.toiletName);
        contentValues.put("district", toiletInfo.district);
        contentValues.put("municipality", toiletInfo.municipality);
        contentValues.put("address", toiletInfo.address);
        contentValues.put("latitude", toiletInfo.latitude);
        contentValues.put("longitude", toiletInfo.longitude);
        contentValues.put("availabletime", toiletInfo.availableTime);
        contentValues.put("hasMultiPurposeToilet", toiletInfo.hasMultiPurposeToilet);
        db.insert("toiletinfo", null, contentValues);

        // CommitしてTransactionを終了.

        db.setTransactionSuccessful();
        db.endTransaction();
    }
    private void update(SQLiteDatabase db, ToiletInfoClass.ToiletInfo toiletInfo, int targetId){
        // Transactionの開始.
        db.beginTransaction();

        // Transactionの開始・終了は呼び出し元で実行.
        ContentValues contentValues = new ContentValues();

        contentValues.put("id", targetId);
        contentValues.put("toiletname", toiletInfo.toiletName);
        contentValues.put("district", toiletInfo.district);
        contentValues.put("municipality", toiletInfo.municipality);
        contentValues.put("address", toiletInfo.address);
        contentValues.put("latitude", toiletInfo.latitude);
        contentValues.put("longitude", toiletInfo.longitude);
        contentValues.put("availabletime", toiletInfo.availableTime);
        contentValues.put("hasMultiPurposeToilet", toiletInfo.hasMultiPurposeToilet);

        db.update("toiletinfo", contentValues, "id = ?", new String[]{String.valueOf(targetId)});
        // CommitしてTransactionを終了.

        db.setTransactionSuccessful();
        db.endTransaction();
    }
}

以前に引き続きSQLiteを使用していますが、せっかく勉強会に参加したこともありRealmも試してみたいところ。

おわりに

コードがシンプルになるとスッキリ感も加わって気持ちが良いものですね。
ただ、取り組んでいる内容はずっとリファクタリングだけなので、そろそろ機能追加をしたいところ。

とりあえずサジェスト機能を付けるかなぁ。

今はアプリ内に入れているJsonも、外部に出してインターネット経由でデータを更新するようにもしたいのですが。

参考

Gson

【Unity】【LeapMotion】【Windows】ジェスチャー検出 その1

はじめに

諸事情により、Leap Motionを購入しました。
まぁ大した理由もないのですが、前からモーションセンサーは購入したいと思っていたので。

Leap Motionはハードウェアは据え置きでソフトウェアの進化のみでトラッキングの精度が上がっていくという稀有な存在だと思います(あまり自由にお金を使えない勢としてはありがたいことこの上ない)。

今回は、Orion BetaUnity Core Assets ver.4.1.2を使ってトラッキングの開始・停止や手を右から左に(またはその逆)素早く動かすSwipe操作などを検出してみたことについて書き残すことにします。

https://github.com/masanori840816/LeapMotionCtrl

ラッキングの開始・停止

まずは手のトラッキングの開始・停止を検出するところから。

MotionCtrl.cs

using Leap;
using UnityEngine;
using System.Collections.Generic;
using System.Linq;

public class MotionCtrl : MonoBehaviour {
    public List < GameObject > motionCtrlObjectList;

    private List < IMotionCallback > motionCtrlList;
    private int motionCtrlCount;

    private Controller leapCtrl;
    private Frame leapFrame;
    private List < Hand > handList;

    private bool isTracking;
    private bool isGrabbed;
    private bool lastTrackingStatus;
    private bool lastGrabbedStatus;
    private Vector3 convertedValue = new Vector3(0, 0, 0);

    private Vector3 handPosition;
    public Vector3 HandPosition
    {
        get { return handPosition; }
    }
    private void Start (){
        // Trackingと手の状態変化を返す対象のClass.
        motionCtrlList = motionCtrlObjectList.Select(ctrlObject => ctrlObject.GetComponent < IMotionCallback > ()).ToList();
        motionCtrlCount = motionCtrlList.Count - 1;

        // App起動時にLeapMotionに接続.
        leapCtrl = new Controller();
        // StartConnectionの実行は必要?
        leapCtrl.StartConnection();

        isTracking = false;
        isGrabbed = false;

        lastTrackingStatus = false;
        lastGrabbedStatus = false;
    }
    
    private void Update () {
        leapFrame = leapCtrl.Frame();
        handList = leapFrame.Hands;

        isTracking = (handList.Count > 0);

        if(isTracking != lastTrackingStatus)
        {
            // Trackingの開始・停止をCallbackで通知.
            if (isTracking)
            {
                for (var i = motionCtrlCount; i >= 0; i--)
                {
                    motionCtrlList[i].OnTrackingStarted();
                }
            }
            else {
                for (var i = motionCtrlCount; i >= 0; i--)
                {
                    motionCtrlList[i].OnTrackingStopped();
                }
                // 手の位置をリセット.
                handPosition.x = 0f;
                handPosition.y = 0f;
                handPosition.z = 0f;
            }
            lastTrackingStatus = isTracking;
        }

        if (! isTracking)
        {
            // Tracking中でなければ手の位置などは取得しない.
            return;
        }
        // Tracking中なら手(Hand0)の平の座標値を取得する.
        handPosition = ConvertToUnityVector3(handList[0].PalmPosition);
~省略~
    }
    private void OnApplicationQuit()
    {
        // Appの終了時は切断する.
        leapCtrl.StopConnection();
        leapCtrl.Dispose();
    }
    private Vector3 ConvertToUnityVector3(Vector originalValue)
    {
        // Leap.VectorからUnityのVector3に変換する.
        convertedValue.x = originalValue.x;
        convertedValue.y = originalValue.y;
        convertedValue.z = originalValue.z;
        return convertedValue;
    }
}
  • ラッキングなどの情報を得るためには、Leap.Controllerを使います。
  • アプリを閉じる前に、Leap.Controller.StopConnection()を実行して切断する必要があり、実行しないとアプリを閉じたタイミングでフリーズしてしまうことも。
  • 上記ではトラッキング中であれば0番目の手の座標値を取得しています。

ラッキングの開始・停止

Leap.Controller.Frame().Handsで取得した手の数が1以上、かつ前フレームと状態が変わった場合にコールバック関数を呼び出す、ということにしています。
が、もうちょっと簡単にできないかなぁとは思っています。

値の変化を検出するのなら、UniRxが使えるのでは?という気もしているのですが...。

コールバック関数

上記のMotionCtrl.csの情報を利用するClassでは、以下のInterfaceを継承して、トラッキングの開始・停止などの通知が受け取れるようにします。

IMotionCallback.cs

public interface IMotionCallback{
    void OnTrackingStarted();
    void OnTrackingStopped();
    void OnHandGrabbed();
    void OnHandReleased();
}

Grab(手を握る)の検出

手を握ったかどうかの検出は、Leap.Controller.Frame().Hands.Fingersが持つ「IsExtended」の値を見ることで判断できます。

MotionCtrl.cs

~省略~
    private void Update(){
    ~省略~
        // 手を握っているか(誤検知を考慮して開いている指の本数が1以下ならTrue).
        isGrabbed = (handList[0].Fingers.Where(finger => finger.IsExtended).Count() <= 1);
        if(isGrabbed != lastGrabbedStatus)
        {
            if (isGrabbed)
            {
                for (var i = motionCtrlCount; i >= 0; i--)
                {
                    motionCtrlList[i].OnHandGrabbed();
                }
            }
            else
            {
                for (var i = motionCtrlCount; i >= 0; i--)
                {
                    motionCtrlList[i].OnHandReleased();
                }
            }
            lastGrabbedStatus = isGrabbed;
        }
    }
~省略~

Swipeの検出

手を右から左、または上から下(+それぞれ逆方向)に素早く動かす、Swipe操作を検出します。

実はLeapMotionのVersion2.Xの時は、標準でジェスチャー検出が可能だったのですが、今回使用するVersion4.XではDeprecatedになっており、また今後実装される見込みも薄そうです。

ということで、自分でなんとかしてみることにします。

考え方

ある一定の速度で、ある一定の方向に手が動いているかを調べます。
Update関数で、前のFrameで取得した手の座標値と今の座標値とを比較して、その差分を取得します。

○                   ○                    ○                   ○
L前Frameの手の位置との差分 」L前Frameの手の位置との差分 」L前Frameの手の位置との差分 」

で、この値の合計値が一定以上ならSwipeとして処理を実行する、と。
ただし、斜めに動かした場合などは判別しづらいので、縦横逆方向に大きく動いている場合は無視することとします。

using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;

public class MotionSwipeCtrl : MonoBehaviour, IMotionCallback
{
    public GameObject motionCtrlObject;

    private readonly int MovedLengthCount = 3;
    // 
    private readonly float TargetDirectionPerFrameMoveLength = 25.0f;
    private readonly float OppositeDirectionPerFrameMoveLength = 5.0f;
    private readonly Vector2 DefaultMoveLength = new Vector2(0.0f, 0.0f);

    private MotionCtrl motionCtrl;
    private bool isCtrlDisabled;
    // X, Y方向のSwipeのみを取るためVector2を使う.
    private List movedLengthList;
    private float movedTotalLengthX;
    private float movedTotalLengthY;

    private Vector3 lastHandPosition;
    private Vector2 newMovedLength;

    private float validMinMoveLength;
    private float invalidMaxOppositeLength;

    private bool isGestureExecuted;

    public void OnTrackingStarted()
    {
        lastHandPosition = motionCtrl.HandPosition;
        isCtrlDisabled = false;
        isGestureExecuted = false;
    }
    public void OnTrackingStopped()
    {
        isCtrlDisabled = true;
        Reset();
    }
    public void OnHandGrabbed()
    {
        isCtrlDisabled = true;
    }
    public void OnHandReleased()
    {
        isCtrlDisabled = false;
    }
    private void Start(){
        motionCtrl = motionCtrlObject.GetComponent();
        movedLengthList = new List();
        validMinMoveLength = 0f;
        invalidMaxOppositeLength = 0f;
        // 初期化.
        for (var i = MovedLengthCount; i >= 0; i--)
        {
            movedLengthList.Add(DefaultMoveLength);

            validMinMoveLength += TargetDirectionPerFrameMoveLength;
            invalidMaxOppositeLength += OppositeDirectionPerFrameMoveLength;
        }
        isCtrlDisabled = true;
        isGestureExecuted = false;
    }
    private void Update () {
        if (isCtrlDisabled)
        {
            return;
        }
        // 前Frameからの手の座標値の差分を算出.
        newMovedLength.x = motionCtrl.HandPosition.x - lastHandPosition.x;
        newMovedLength.y = motionCtrl.HandPosition.y - lastHandPosition.y;

        if (! isGestureExecuted)
        {
            // 前数Frame分の移動距離を算出.
            movedTotalLengthX = Math.Abs(movedLengthList.Select(movedLength => movedLength.x).Sum() + newMovedLength.x);
            movedTotalLengthY = Math.Abs(movedLengthList.Select(movedLength => movedLength.y).Sum() + newMovedLength.y);

            if (movedTotalLengthX >= movedTotalLengthY)
            {
                if (movedTotalLengthX >= validMinMoveLength
                    && movedTotalLengthY <= invalidMaxOppositeLength)
                {
                    
                    // TODO: 横方向にSwipe操作した場合の処理.
                    
                    isGestureExecuted = true;
                    StopDetectingGesture();
                }
            }
            else
            {
                if (movedTotalLengthY >= validMinMoveLength
                    && movedTotalLengthX <= invalidMaxOppositeLength)
                {
                    
                    // TODO: 縦方向にSwipe操作した場合の処理.
                    
                    isGestureExecuted = true;
                    StopDetectingGesture();
                }
            }
        }
        
        // 値を更新して今Frameの座標値の差分を保存する.
        for (var i = MovedLengthCount; i >= 1; i--)
        {
            movedLengthList[i] = movedLengthList[i - 1];
        }
        movedLengthList[0] = newMovedLength;
        // 今Frameの座標値を保存する.
        lastHandPosition = motionCtrl.HandPosition;
    }
    private void Reset()
    {
        for (var i = MovedLengthCount; i >= 0; i--)
        {
            movedLengthList[i] = DefaultMoveLength;
        }
        isGestureExecuted = false;
    }
    private IEnumerator StopDetectingGesture()
    {
        // Swipe実行後は一定時間Swipe操作を無効にする.
        yield return new WaitForSeconds(0.5f);
        Reset();
    }
}
  • 手の座標値の差分を取得するFrame数やその合計値などは使用状況などによって調整してください。
  • 手を素早く動かし続けていると、Swipe処理が何度も実行されてしまうため、フラグで一定時間Swipe処理が実行されないようにしています。

おわりに

とりあえずそれっぽく動くようにはなりましたが、特にSwipeのしきい値などはまだ調整が必要そうです。

また、処理の内容の割にコードが長すぎる気がするので、ここももう少し工夫したいところです。

次回ももう少しLeapMotionであれこれやってみた話になる…予定です。

参考

Leap Motion

Linq

【Kotlin】【Windows】JavaFX + Apache POI + GsonでExcelからJsonファイルを作る2 (1の修正)

はじめに

前回で一応Excelからデータを取得してJsonファイルに書き出す、ということができるようになりました。
その時以下の内容で出力するようにしていました。

{
    data1:{
        "element1": "element1"
        ,"element2": "element2"
    }
    , data2{
        "element1": "element1"
        ,"element2": "element2"
    }
~省略~

しかし、Androidのアプリで同じくGsonを使ってこのJsonを読み込もうとしたときに、以下のような配列として出力されている方が良い、ということがわかってきました。

{
    data:[
        {
            "element1": "element1"
            ,"element2": "element2"
        }
        , {
            "element1": "element1"
            ,"element2": "element2"
        }
~省略~
    ]
}

ということで今回は、Excelから読み込んだデータを配列として出力する方法についてです。

データをセットするクラスを使用する

配列を作るには、専用のクラスを作成してその配列をJsonに変換 → 出力 という方法が考えられます。

ToiletInfo.kt

class ToiletInfo {
    var toiletName = ""
        get set
    var district = ""
        get set
    var municipality = ""
        get set
    var address = ""
        get set
    var latitude: Double = 0.0
        get set
    var longitude: Double = 0.0
        get set
    var availableTime = ""
        get set
    var hasMultiPurposeToilet: Boolean = false
        get set
}

このクラスを使って、Excelから読み込んだデータをセットした配列を作成します。

SpreadsheetAccesser.kt

class SpreadsheetAccesser {

    lateinit var ToiletInfoList: ArrayList< ToiletInfo >
        get

~省略~
    fun loadFile(targetFilePath: String, targetSheetName: String){
        val fileStream = FileInputStream(targetFilePath)
        val currentWorkbook = WorkbookFactory.create(fileStream)

        if(currentWorkbook == null){
            return
        }
        val targetSheet: Sheet? = currentWorkbook.getSheet(targetSheetName)
        if(targetSheet == null){
            return
        }
        val rowCount = targetSheet.physicalNumberOfRows - 1
        if(rowCount < 0){
            return
        }
        // 最初の行から列数を取得する.
        if(targetSheet.getRow(0).physicalNumberOfCells >= 9) {
            ToiletInfoList = ArrayList()

            // 最初の行は項目名なのでスキップ.
            for (i in 1..rowCount) {
                val toiletInfo = ToiletInfo()
                // 1. toiletName, 2. district, 3. municipality, 4. address,
                // 5. latitude, 6. longitude, 7.availableTime, 8.hasMultiPurposeToilet.
                toiletInfo.toiletName = targetSheet.getRow(i).getCell(1).stringCellValue
                toiletInfo.district = targetSheet.getRow(i).getCell(2).stringCellValue
                toiletInfo.municipality = targetSheet.getRow(i).getCell(3).stringCellValue
                toiletInfo.address = targetSheet.getRow(i).getCell(4).stringCellValue
                toiletInfo.latitude = targetSheet.getRow(i).getCell(5).numericCellValue
                toiletInfo.longitude = targetSheet.getRow(i).getCell(6).numericCellValue
                var availableTime: String? = targetSheet.getRow(i).getCell(7)?.stringCellValue
                availableTime = availableTime?: ""
                toiletInfo.availableTime = availableTime
                toiletInfo.hasMultiPurposeToilet = targetSheet.getRow(i).getCell(8).booleanCellValue

                ToiletInfoList.add(toiletInfo)
            }
        }
        currentWorkbook.close()
        fileStream.close()
    }
}

あとはJsonに変換し、出力します。

JsonFileCreater.kt

class JsonFileCreater {
    fun createFile(toiletInfoList: ArrayList< ToiletInfo >, fileTitle: String){
        val stringWriter = StringWriter()
        val jsonWriter = JsonWriter(BufferedWriter(stringWriter))
        // 出力したJsonファイルで適切にインデントが入る...はずだが今回は効いていないようです.
        jsonWriter.setIndent("  ")
        
        jsonWriter.beginObject()

        val gson = Gson()
        
        // 「jsonWriter.name("").value("")」のvalueにはArrayListを入れられないため、Jsonに変換した上でJsonデータとしてセット.
        jsonWriter.name("toiletInfo").jsonValue(gson.toJson(toiletInfoList))

        jsonWriter.endObject()
        jsonWriter.close()
        val createdJson = String(stringWriter.buffer)

        try{
            val splittedTitles = fileTitle.split('.')
            if(splittedTitles.size <= 0){
                return
            }
            val fileWriter = FileWriter(splittedTitles[0] + ".json")
            fileWriter.write(createdJson)
            fileWriter.close()
            
            // TODO: 出力が終わったことを知らせる.
            
        }catch(e: IOException){
            // TODO: 適切なエラー処理.
        }
    }
}

出力した結果は以下のようになります。

{

    "toiletInfo": [
        {
            "toiletName": "友ヶ島野奈浦公衆トイレ",
            "district": "和歌山県",
            "municipality": "和歌山市",
            "address": "和歌山市加太笘ヶ沖島2673-3",
            "latitude": 34.282891,
            "longitude": 135.008677,
            "availableTime": "終日",
            "hasMultiPurposeToilet": true
        },
        {
            "toiletName": "友ヶ島南垂水公衆トイレ",
            "district": "和歌山県",
            "municipality": "和歌山市",
            "address": "和歌山市加太苫ケ沖島2673-1",
            "latitude": 34.28255,
            "longitude": 135.013597,
            "availableTime": "終日",
            "hasMultiPurposeToilet": false
        },
~省略~

HashMapを使う(失敗)

上記の方法を取ると確かにやりたいことの実現はできたのですが、読み込むセルの列数やデータ型などが固定されてしまうため少々不便でもあります。

それを解決する方法を調べていたのですが、HashMap使えるんじゃね?と思い至り、試してみることにしました。

 SpreadsheetAccesser.kt

import javafx.collections.FXCollections
import javafx.collections.ObservableList
import org.apache.poi.ss.usermodel.Cell
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.WorkbookFactory
import java.io.FileInputStream
import java.util.*

class SpreadsheetAccesser {

    lateinit var LoadedSheetItemList: ArrayList < ArrayList < HashMap < String, Any > > >
        get

~省略~
    fun loadFile(targetFilePath: String, targetSheetName: String){
        val fileStream = FileInputStream(targetFilePath)
        val currentWorkbook = WorkbookFactory.create(fileStream)

        if(currentWorkbook == null){
            return
        }
        // シート名から対象のシートを取得する.
        val targetSheet: Sheet? = currentWorkbook.getSheet(targetSheetName)
        if(targetSheet == null){
            return
        }
        // セルに何らかの値が含まれる行数の取得.
        val rowCount = targetSheet.physicalNumberOfRows - 1
        if(rowCount < 0){
            return
        }
        // 最初の行から列数を取得する.
        val columnCount = targetSheet.getRow(0).physicalNumberOfCells - 1

        // 最初の行をタイトル行としてArrayListを作成する.
        val ColumnTitleList = ArrayList < String > ()
        for(cell in targetSheet.getRow(0)){
            ColumnTitleList.add(getCellValue(cell).toString())
        }
        // 実際の値が入ったセルの値をセットするArrayListの生成.
        LoadedSheetItemList = ArrayList < ArrayList < HashMap < String, Any > > > ()

        // タイトル行から取得した列数 ✕ (セルに値が含まれる行数 - 1)の値をセットする.
        for(i in 1..rowCount){
            val loadedRowItemList = ArrayList < HashMap < String, Any > > ()

            for(t in 1..columnCount){
                loadedRowItemList.add(hashMapOf < String, Any > (ColumnTitleList[t] to getCellValue(targetSheet.getRow(i).getCell(t))))
            }
            LoadedSheetItemList.add(loadedRowItemList)
        }
        currentWorkbook.close()
        fileStream.close()
    }
    fun getCellValue(targetCell: Cell?): Any{
        var result = ""
        if(targetCell == null){
            return result
        }
        // SpreadsheetにおけるCellの型に合わせて値を返す.
        when(targetCell.cellType){
            Cell.CELL_TYPE_BOOLEAN -> return targetCell.booleanCellValue
            Cell.CELL_TYPE_NUMERIC -> return targetCell.numericCellValue
            Cell.CELL_TYPE_STRING -> return targetCell.stringCellValue
            Cell.CELL_TYPE_FORMULA -> return targetCell.cellFormula
        }
        return result
    }
}

JsonFileCreater.kt

import com.google.gson.Gson
import com.google.gson.stream.JsonWriter
import java.io.BufferedWriter
import java.io.FileWriter
import java.io.IOException
import java.io.StringWriter
import java.util.*

class JsonFileCreater {
    fun createFile(toiletInfoList: ArrayList < ArrayList < HashMap < String, Any > > >, fileTitle: String){
        val stringWriter = StringWriter()
        val jsonWriter = JsonWriter(BufferedWriter(stringWriter))
        jsonWriter.setIndent("  ")
        jsonWriter.beginObject()

        val gson = Gson()
        jsonWriter.name("toiletInfo").jsonValue(gson.toJson(toiletInfoList))

        jsonWriter.endObject()
        jsonWriter.close()
        val createdJson = String(stringWriter.buffer)

        try{
            val splittedTitles = fileTitle.split('.')
            if(splittedTitles.size <= 0){
                return
            }
            val fileWriter = FileWriter(splittedTitles[0] + ".json")
            fileWriter.write(createdJson)
            fileWriter.close()
        }catch(e: IOException){

        }
    }
}

で、これを実行するとどうなるかというと、

{

    "toiletInfo": [
        {
            "toiletName": "友ヶ島野奈浦公衆トイレ"
        },
        {
            "district": "和歌山県"
        },
        {
            "municipality": "和歌山市"
        },
        {
            "address": "和歌山市加太笘ヶ沖島2673-3"
        },
        {
            "latitude": 34.282891
        },
        {
            "longitude": 135.008677
        },
        {        
            "availableTime": "終日"
        },
        {
            "hasMultiPurposeToilet": true
        },
        {
            "toiletName": "友ヶ島南垂水公衆トイレ"
        },
        {
            "district": "和歌山県"
        },
        {
            "municipality": "和歌山市"
        },
        {
            "address": "和歌山市加太苫ケ沖島2673-1"
        },
        {
            "latitude": 34.28255
        },
        {
            "longitude": 135.013597
        },
        {
            "availableTime": "終日"
        },
        {
            "hasMultiPurposeToilet": false
        },
~省略~

う~ん、なんか違う(´;ω;`)

何かしら解決方法はありそうですが、今回調べただけではよくわかりませんでしたorz

これについては、今後解決方法が見つかったらこのブログで報告したいと思います。

参考

Gson

Kotlin

【Kotlin】【Windows】JavaFX + Apache POI + GsonでExcelからJsonファイルを作る

はじめに

このところずっと取り組んでいる和歌山トイレマップを使ったアプリですが、トイレ情報はCSV形式で保存したファイルから取得しています。
これは、元データである和歌山トイレマップCSV形式であり、アプリを作り始めた当初はデータをそのまま使用する予定でいたためです。

ただ現在は別途取得した緯度・経度を加えたり、あれこれデータをいじっているため特にCSVである必要はなくなってきました。
加えてCSVに比べ、Jsonの方が解析などのためのツールが揃っている印象もあります。

で、Spreadsheet(Excel)のデータをJson形式で出力するツールを探してみたのですが、いまいちしっくりこず…。

というわけで、自作してみることにしました。

Kotlinを使って。

https://github.com/masanori840816/KtCreateJson

GUIを作る

今回は(今回も)チャラくいきたかったので、GUIで読み込むファイルを選択してシートを指定→ファイルを読み込んでJsonファイルを出力することにしました。

ということでGUIなのですが、JavaFXを使ってみることにしました。
Kotlin用のGUIフレームワークも探せばありそうな気はしたのですが、それほど豊富に情報がなさそうだったので(ヘタレ)。

あと、以前ある方がJavaFXを使ってプレゼンをしていたのを見て以来気になってはいた、ということもあります。

FXMLを使ってみる(失敗)

Gluon Scene Builderといったツールを使うことで、ドラッグ&ドロップGUIパーツを配置できます。

出来上がったファイル(FXML)を読み込むためには以下のようにします。

MainApplication.fxml

< ?xml version="1.0" encoding="UTF-8"? >

< ?import javafx.scene.control.Button? >
< ?import javafx.scene.control.ComboBox? >
< ?import javafx.scene.control.TextField? >
< ?import javafx.scene.layout.StackPane? >

< StackPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/8.0.60" xmlns:fx="http://javafx.com/fxml/1" >
    < Button fx:id="findFileButton" onAction="#onFindFileButtonClicked" mnemonicParsing="false" text="参照" translateX="250.0" translateY="-150.0" / >
    < Button fx:id="createButton" onAction="#onCreateButtonClicked" mnemonicParsing="false" scaleX="1.3" scaleY="1.3" text="作成" translateX="230.0" translateY="150.0" / >
    < ComboBox fx:id="sheetNameCombobox" prefWidth="150.0" translateX="-200.0" translateY="-70.0" / >
    < TextField fx:id="loadFilePathField" scaleX="0.8" translateX="-35.0" translateY="-150.0" / >
< /StackPane >

MainApplication.kt

import javafx.application.Application
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.fxml.FXML
import javafx.fxml.FXMLLoader
import javafx.scene.Parent
import javafx.scene.Scene
import javafx.scene.control.Button
import javafx.scene.control.ComboBox
import javafx.scene.control.TextField
import javafx.stage.Stage
import java.io.IOException

class MainApplication : Application(){
    // 変数名をキーにFXMLのGUIと関連付ける.
    @FXML
    lateinit private var loadFilePathField: TextField
    @FXML
    lateinit private var sheetNameCombobox: ComboBox< String >

    @Throws(IOException::class)
    override fun start(primaryStage: Stage) {
        // FXMLの読み込み
        val root = FXMLLoader.load< Parent >(javaClass.getResource("MainApplication.fxml"))
        val primaryScene = Scene(root)
        primaryStage.setScene(primaryScene)
        // ウインドウを表示する.
        primaryStage.show()
    }
    fun onFindFileButtonClicked(){
        // findFileButtonがクリックされた時に呼ばれる.
    }
    fun onCreateButtonClicked(){
        // createButtonがクリックされた時に呼ばれる.
    }
}
  • 「@FXML」を付けることで、FXML側の「fx:id」と、コード側の変数名とを関連付けます。そのため、これら2つの名前が異なっているとうまく関連付けされず、Nullになってしまいます。
  • 「@FXML」の関連付けが行われるのはstartの後のようで、それより前にGUIにアクセスしようとするとぬるぽになります。
  • クリックなどのイベントは、FXMLで「onAction」で指定することも、コードで「setOnAction」とすることもできます。

問題

ここまでは特に問題なく表示ができるのですが、Comboboxに値をセットしようとした辺りでハマってしまいました。

下記のようにstartで値を入れようとすると、何故か関連付けたGUIまでNullになってしまう、という問題が発生します。

MainApplication.kt

~省略~
    @FXML
    lateinit private var sheetNameCombobox: ComboBox< String >

    private var sheetNameList: ObservableList< String >? = null
    
    @Throws(IOException::class)
    override fun start(primaryStage: Stage) {
        // FXMLの読み込み
        val root = FXMLLoader.load< Parent >(javaClass.getResource("MainApplication.fxml"))
        val primaryScene = Scene(root)
        primaryStage.setScene(primaryScene)
        // ウインドウを表示する.
        primaryStage.show()
        
        sheetNameList = FXCollections.observableArrayList()
        sheetNameList.add("test")
        sheetNameCombobox.items = sheetNameList
    }
~省略~

結局解決方法がよくわからず、FXMLではなくコードでGUIを追加することにしました。

コード上でGUIを追加する

コード上でGUIを追加するため、以下のように記述しました。

MainApplication.kt

import javafx.application.Application
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.scene.Scene
import javafx.scene.control.Button
import javafx.scene.control.ComboBox
import javafx.scene.control.TextField
import javafx.scene.layout.StackPane
import javafx.stage.FileChooser
import javafx.stage.Stage
import java.io.File
import java.io.IOException

class MainForm : Application(){
~省略~

    lateinit private var loadFilePathField: TextField
    lateinit private var sheetNameCombobox: ComboBox< String >
    lateinit private var createButton: Button

    private var sheetNameList: ObservableList< String >? = null
    private var selectedFile: File? = null

    @Throws(IOException::class)
    override fun start(primaryStage: Stage) {

        spreadsheetAccesser = SpreadsheetAccesser()
        jsonFileCreator = JsonFileCreater()

        // ファイル選択用ダイアログを表示するボタン.
        val findFileButton = Button()
        findFileButton.text = "参照"
        findFileButton.translateX = 250.0
        findFileButton.translateY = -150.0
        findFileButton.setOnAction { event -> run {
                // ボタンクリック時の処理.
            }
        }
        // 読み込むSpreadsheetのパスを入力、表示するテキストフィールド.
        loadFilePathField = TextField()
        loadFilePathField.translateX = -35.0
        loadFilePathField.translateY = -150.0
        loadFilePathField.scaleX = 0.8
        loadFilePathField.setOnAction { event -> run{
            // テキストフィールドでEnterを押された時の処理.
        } }

        sheetNameList = sheetNameList?: FXCollections.observableArrayList("")

        // 読み込んだSpreadsheetのシート名を選択するコンボボックス.
        sheetNameCombobox = ComboBox()
        sheetNameCombobox.translateX = -200.0
        sheetNameCombobox.translateY = -70.0
        sheetNameCombobox.prefWidth = 160.0
        if(sheetNameList != null){
            sheetNameCombobox.items = sheetNameList
        }
        
        // Jsonのファイルを作成するボタン.
        createButton = Button()
        createButton.text = "作成"
        createButton.translateX = 230.0
        createButton.translateY = 150.0
        createButton.setOnAction { event -> run{
            // ボタンクリック時の処理.
        } }

        // StackPaneを作成してGUIを追加する.
        val stackPane = StackPane()
        stackPane.children.addAll(findFileButton
                , loadFilePathField
                , sheetNameCombobox
                , createButton)

        val primaryScene = Scene(stackPane, 600.0, 400.0)
        primaryStage.setScene(primaryScene)
        primaryStage.show()
    }
  • 今回各GUIはStackPaneに追加しています。このときaddAllを使うことで、一括で追加ができます。

FileChooser

読み込むSpreadsheetを選択するためのダイアログを表示します。

JavaFXではFileChooserを使用します。

MainApplication.kt

~省略~
    findFileButton.setOnAction { event -> run {
            // FileChooserでダイアログを表示する.
            val fileChooser = FileChooser()
            fileChooser.title = "ファイルを選択"
            // 選択対象のファイルの拡張子を指定する.
            fileChooser.extensionFilters.add(FileChooser.ExtensionFilter("Spreadsheet", "*.xlsx", "*.ods"))
            // ダイアログの表示.
            selectedFile = fileChooser.showOpenDialog(primaryStage)
            // 選択したファイルのパスをテキストフィールドに入力する.
            loadFilePathField.text = selectedFile.toString()
            // 選択したファイルからシート名を取得して、コンボボックスにセットする(※後述).
            setSheetNames()
        }
    }
~省略~

ApachePOIでシート名を取得する

Apache POIを使ってSpreadsheetからシート名を取得します。

インストール

まずApache POIを使うために、プロジェクト直下にあるbuild.gradleに以下を追加します。

build.gradle

~省略~
dependencies {
 ~省略~
    compile group: 'org.apache.poi', name : 'poi', version: '3.14'
    compile(group: 'org.apache.poi', name : 'poi-ooxml', version: '3.14') {
        exclude group: 'stax', module: 'stax-api'
    }
~省略~
}

シート名の取得

SpreadsheetAccesser.kt

import javafx.collections.FXCollections
import javafx.collections.ObservableList
import org.apache.poi.ss.usermodel.Cell
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.WorkbookFactory
import java.io.FileInputStream
import java.util.ArrayList

class SpreadsheetAccesser {
~省略~
    fun getSheetNames(targetFilePath: String): ObservableList< String >?{
        // SpreadsheetのパスからStreamを作成する.
        val fileStream = FileInputStream(targetFilePath)
        // StreamからWorkbookを取得.
        val currentWorkbook = WorkbookFactory.create(fileStream)

        if(currentWorkbook == null){
            return null
        }
        // ComboboxにセットするためのArrayListの生成.
        val sheetNameList: ObservableList = FXCollections.observableArrayList()

        // 取得したWorkbookからシート名を取得する.
        for (i in 0..currentWorkbook.numberOfSheets - 1){
            sheetNameList.add(currentWorkbook.getSheetName(i))
        }
        // Workbook、Streamを閉じる.
        currentWorkbook.close()
        fileStream.close()
        return sheetNameList
    }
~省略~
}
  • 本当は非同期で実行した方が良さそうですが、今回は特に何もせずそのまま実行しています。

ApachePOIでセルの値を取得する

読み込む対象のSpreadsheet、シート名が決まったら、セルの値を取得します。

今回は1列目の各セルの値を取得して、それが何列分あるかによって実際の値を読み込む時の列数を指定します。

SpreadsheetAccesser.kt

~省略~
    fun loadFile(targetFilePath: String, targetSheetName: String){
        // SpreadsheetのパスからStreamを作成する.
        val fileStream = FileInputStream(targetFilePath)
        // StreamからWorkbookを取得.
        val currentWorkbook = WorkbookFactory.create(fileStream)

        if(currentWorkbook == null){
            return
        }
        // シート名から対象のシートを取得する.
        val targetSheet: Sheet? = currentWorkbook.getSheet(targetSheetName)
        if(targetSheet == null){
            return
        }
        // セルに何らかの値が含まれる行数の取得.
        val rowCount = targetSheet.physicalNumberOfRows - 1
        if(rowCount < 0){
            return
        }
        // 最初の行から列数を取得する.
        val columnCount = targetSheet.getRow(0).physicalNumberOfCells - 1

        // 最初の行をタイトル行としてArrayListを作成する.
        ColumnTitleList = ArrayList()
        for(cell in targetSheet.getRow(0)){
            ColumnTitleList.add(getCellValue(cell))
        }
        // 実際の値が入ったセルの値をセットするArrayListの生成.
        LoadedSheetItemList = ArrayList< ArrayList < String > >()

        // タイトル行から取得した列数 ✕ (セルに値が含まれる行数 - 1)の値をセットする.
        for(i in 1..rowCount){
            val loadedRowItemList = ArrayList< String >()
            loadedRowItemList.add(i.toString())

            for(t in 1..columnCount){
                loadedRowItemList.add(getCellValue(targetSheet.getRow(i).getCell(t)))
            }
            LoadedSheetItemList.add(loadedRowItemList)
        }
        // Workbook、Streamを閉じる.
        currentWorkbook.close()
        fileStream.close()
    }
    fun getCellValue(targetCell: Cell?): String{
        var result = ""
        if(targetCell == null){
            return result
        }
        // SpreadsheetにおけるCellの型によらず、一律でStringとしてCellの値を取得.
        when(targetCell.cellType){
            Cell.CELL_TYPE_BOOLEAN -> return targetCell.booleanCellValue.toString()
            Cell.CELL_TYPE_NUMERIC -> return targetCell.numericCellValue.toString()
            Cell.CELL_TYPE_STRING -> return targetCell.stringCellValue.toString()
            Cell.CELL_TYPE_FORMULA -> return targetCell.cellFormula.toString()
        }
        return result
    }
}
  • Spreadsheetから値を取る際に、Cell.XXXXCellValueのデータ型とセルのデータ型とが合致しないと(例:targetCell.booleanCellValueで文字列型のセルから値を取得しようとすると)、エラーが発生します。
    そのためwhenを使ってセルのデータ型に合わせて値を取得するようにしています(getCellValue内)。

Jsonファイルを作成する

Spreadsheetから値が取得できたら、それをJsonとしてファイルに書き出します。

今回はGsonを使うことにしました。

インストール

プロジェクト直下にあるbuild.gradleに以下を追加します。

build.gradle

~省略~
dependencies {
~省略~
    compile "com.google.code.gson:gson:2.7"
}

Jsonファイルに書き出す

※2016.07.22 20:11更新
Jsonオブジェクトを作るところで誤りがあり、全データが一つの塊になっていたため、バラバラに分割するよう修正しました。

JsonFileCreater.kt

import com.google.gson.stream.JsonWriter
import java.io.BufferedWriter
import java.io.FileWriter
import java.io.IOException
import java.io.StringWriter
import java.util.ArrayList

class JsonFileCreater {
    fun createFile(titleList: ArrayList< String >, valueListCollection: ArrayList< ArrayList< String > >, fileTitle: String){
        val stringWriter = StringWriter()
        
        val jsonWriter = JsonWriter(BufferedWriter(stringWriter))
        // Jsonオブジェクトの作成開始.
    jsonWriter.beginObject()

    var i = 0
        valueListCollection.forEach { valueList -> run{
                jsonWriter.name("toilet_list_" + i.toString())
                jsonWriter.beginObject()
                for(t in 0..valueList.size - 1){
                    // Jsonの項目名(name)と値(value)をそれぞれ指定する.
                    jsonWriter.name(titleList[t]).value(valueList[t])
                }
                jsonWriter.endObject()
                i++
            }
        }

        // Jsonオブジェクトの作成を終了して文字列にする.
        jsonWriter.endObject()
        jsonWriter.close()
        val createdJson = String(stringWriter.buffer)

        try{
            // 読み込んだSpreadsheetのファイル名を使ってJsonファイルを書き出す.
            val splittedTitles = fileTitle.split('.')
            if(splittedTitles.size <= 0){
                return
            }
            val fileWriter = FileWriter(splittedTitles[0] + ".json")
            fileWriter.write(createdJson)
            fileWriter.close()
        }catch(e: IOException){
            // TODO: 適切なエラー処理
        }
    }
}

おわりに

とりあえずそれっぽいファイルを作成することはできました。

ただ、セルから取り出した値を全て文字列型にしてしまっているため、例えば緯度・経度の値も文字列として扱われてしまいます。
今回のように使いみちが決まっている場合は、値を格納するためのクラスを専用に作成して、それぞれのデータ型のままJsonに書き出す方が良いかもしれません。

またはフラグを立てるとか...?

あと途中でも書きましたが、今回別スレッドでの実行や非同期処理を行っていないため、本当はその部分についても考慮が必要になるかと思います。

あと今回はKotlinで挑戦してみましたが、Androidアプリでは無いのだし、どうせなのでJavaで書いてver.8系の仕様を堪能する、ということにしても良かったかもしれません。

次は今回作成したJsonファイルを、Androidアプリから読み込むお話になるはず。

参考

JavaFX

Apache POI

File Chooser

IntelliJ IDEA

Gson

Kansai.kt #1で発表してきました

はじめに

7/9に開催されたKansai.ktで、Null安全について発表してきました。

kansai-kt.connpass.com

speakerdeck.com

トーク

今回は自分を含めて3/4が初心者向け、ということでそれぞれDelegateや継承、コレクション、kaptとバラエティに富んでいながら、基礎的な話から紹介していただけたので、内容を元にこれまで書いたコードを見直したいなぁ、という気持ちが高まってきました。
またLTについてもAnko DSLやExposeなど、「こんなのもあるのか」と参考になりました。

特にSealed Classはトークで紹介された内容と合わせて積極的に取り込んでいきたいです。

しかしこういう勉強会は、たくさんの方のお話を聞いたり(今回はできませんでしたが)他の参加者の方とお話をしたりというのがメインの目的でありつつも、聞いた内容を使ってすぐ自分でコード書いたり、これまでのものを直したりしたくなるのが悩みどころですねw

自分のトークについて

実は今回、このような勉強会では初の20分トークであったのですが、緊張のためもあって14分足らずで終了してしまいました。
LTとは違って時間が少し長いため、内容をぎゅうぎゅう詰めにするとマズイ、とわかってはいたのですが、ほとんど実際には調整できず...。

とはいえLTだとさっと飛ばさざるを得ない、紹介する話の前提部分(今回だとKotlinにはNull許容型と非Null許容型の変数が存在すること、Kotlinにもぬるぽは存在することなど)にも時間を使ってお話できたことは良い経験だったと思います。
あとネタ部分もw

今から思えば、例えばElvis演算子 "?:" を使った場合と、 "if(x != null)" でNullチェックして値を入れる場合とでどっちが早いか?とか、"?"演算子の内部実装(どのように変数がNullかを確認しているのか)などを検証したり、より突っ込んで調べたりできると良かったのかな、とは感じます。

主催者でもあるたくじさんにフォローしていただけたり、参加者の皆さんに温かく見守っていただいたりと「ありがてぇ...ありがてぇ...」という気持ちが印象強く残っています。

あまり一人の方のお名前を出すのもアレですが、たくじさんが一番前で話を聞きながら大きく頷いてくれるのは、とても安心感があります。
自分も他の方のお話を聞きながら頷くってことをしたいなとは思うのですが、どうしてもメモやら内容のツイートやらに追われてしまって...。

資料は後で公開していただけることがほとんどですし、いっそメモを取るのも諦めてもっと話を聞く方に集中した方が良いのかもしれませんね。

Null安全について

今回のテーマであるNull安全ですが、実はKotlinに初めて触った時からモヤッとしているところではありました。
ぬるぽを撲滅できる、という話だったのに発生するし(Nullの変数に"!."をつけてアクセスしたため)、”?”演算子を本当に全てのNull許容型の変数に使うべきなのか?ということも気になっていました。

今回の発表のために調べたことで、Smart Cast(今回の場合はコードの処理からNullでないことが保証されれば、非Null許容型と同じように扱うことができる)やElvis演算子によってコードが安全かつシンプルに書ける、というKotlinの強みに触れられましたし、それを使ってアプリのコードをリファクタリングできるな、とも思いました。

あと発表内でも言いましたが、以下の2つのコードで、"if(x != null)" でNullチェックをする方が、"?."を使ったCode2 の方が(微々たる差ではあるものの)早い、というのは意外な感じがしました。

var nullableText:  String? = "Not null"
var nullableNum1: Int? = null
var nullableNum2: Int? = null
var nullableNum3: Int? = null
var nullableNum4: Int? = null
var nullableNum5: Int? = null
var nullableNum6: Int? = null
var nullableNum7: Int? = null
var nullableNum8: Int? = null
var nullableNum9: Int? = null
var nullableNum10: Int? = null

// どちらもnullableTextがNullならNullを、
// そうでなければnullableText.lengthの値をセットする.

// Code1
for(i in 0..100000){
    if(nullableText != null){
        nullableNum1 = nullableText.length
        nullableNum2 = nullableText.length
        nullableNum3 = nullableText.length
        nullableNum4 = nullableText.length
        nullableNum5 = nullableText.length
        nullableNum6 = nullableText.length
        nullableNum7 = nullableText.length
        nullableNum8: = nullableText.length
        nullableNum9 = nullableText.length
        nullableNum10 = nullableText.length
    }
}
// Code2
for(i in 0..100000){
    nullableNum1 = nullableText?.length
    nullableNum2 = nullableText?.length
    nullableNum3 = nullableText?.length
    nullableNum4 = nullableText?.length
    nullableNum5 = nullableText?.length
    nullableNum6 = nullableText?.length
    nullableNum7 = nullableText?.length
    nullableNum8 = nullableText?.length
    nullableNum9 = nullableText?.length
    nullableNum10 = nullableText?.length
}

この辺はKotlinの最適化処理の賜物なのかなぁ、と思います。

さいごに

次回は、今回の内容を踏まえてコードをリファクタリング → その時の知見をまとめられたら、と考えています。

会場をご提供いただいたはてな様、主催者の皆様、そして参加された方々、ありがとうございました。

【Android】【Kotlin】DataBindingとListView(和歌山トイレマップで遊んでみる 10)

はじめに

久々復活。最後の記事からふと思い立ってコードをKotlinに置き換えたりしていたのですが、しばらく放置したままでした。
7月にKansai.ktも開催されるということで、そろそろちゃんとやろうではないか、ということで色々トライしております。

今回は、「このアプリについて」画面をDataBindingとListViewを使って書きなおしてみた時のあれこれを書き留めていきます。

JavaからKotlinに置き換えたところは、基本的にはConverte機能を使っただけですので、特に引っかかったりした部分についてのみ取り上げていく予定です。

DataBindingを使う

KotlinでAndroidアプリを書く壁になっていたと思われるのがこのDataBinding。

前回と同じように書いても、クラス名 + Binding という名前のクラスが生成されず、 Javaで専用のクラスを作成して、Kotlinからはそれを呼び出して利用していたようです。

現在はというと、以下のように書くだけでJavaで書く場合と同じように使うことができました。

app / src / build.gradle

    dataBinding {
        enabled = true
    }
}
~省略~
dependencies {
~省略~
    kapt 'com.android.databinding:compiler:1.0-rc5'
~省略~
}
kapt {
    generateStubs = true
}

生成されたクラス

今回は後述するListViewの表示アイテムに使用しました(フラグに合わせて要素の表示・非表示をしたかったため)。

まず対象のLayoutファイルから。

about_app_listitem.xml

< ?xml version="1.0" encoding="utf-8"? >
< layout xmlns:android="http://schemas.android.com/apk/res/android" >
    < data >
        < import type="android.view.View"/ >
        < variable
            name="aboutappitemclass"
            type="jp.searchwakayamatoilet.AboutListItem"/ >
    < /data >
< LinearLayout
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" >
    < TextView
        android:id="@+id/about_list_area_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="@dimen/aboutapp_visible_item_margin"
        android:text="@{aboutappitemclass.areaTitle}"
        android:textAppearance="?android:attr/textAppearanceLarge"
        android:textStyle="bold"
        android:visibility="@{aboutappitemclass.hasAreaTitle? View.VISIBLE: View.GONE}"/ >
    < TextView
        android:id="@+id/about_list_item_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="@dimen/aboutapp_visible_item_margin"
        android:text="@{aboutappitemclass.itemTitle}"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:textColor="@android:color/black"
        android:textStyle="bold"
        android:visibility="@{aboutappitemclass.hasItemTitle? View.VISIBLE: View.GONE}" / >
    < TextView
        android:background="#E5F3FF"
        android:id="@+id/about_list_item_description"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:layout_marginBottom="0dp"
        android:padding="10dp"
        android:text="@{aboutappitemclass.description}" / >
    < TextView
        android:autoLink="web"
        android:background="#E5F3FF"
        android:id="@+id/about_list_item_link"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="0dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:layout_marginBottom="10dp"
        android:padding="10dp"
        android:text="@{aboutappitemclass.link}"/ >
< /LinearLayout >
< /layout >

で、ListViewのAdapterにてDataBindingクラスを生成します。

AboutAppDataAdapter.kt

import android.content.Context
import android.databinding.DataBindingUtil
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import jp.searchwakayamatoilet.databinding.AboutAppListitemBinding
import java.util.ArrayList

class AboutAppDataAdapter(context: Context, newListItemList: ArrayList) : BaseAdapter() {
    lateinit private var layoutInflater :LayoutInflater
    lateinit private var aboutDataList: ArrayList
    init{
        layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
        aboutDataList = newListItemList
    }
    override fun getCount(): Int{
        return aboutDataList.count()
    }
    override fun getItem(position: Int): AboutAppListItem {
        return aboutDataList.get(position)
    }
    override fun getItemId(position: Int): Long{
        return position.toLong()
    }
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View? {
        val binding: AboutAppListitemBinding? = DataBindingUtil.inflate(layoutInflater, R.layout.about_app_listitem, parent, false)

        // ※エラーは無視する.
        binding?.aboutappitemclass = aboutDataList.get(position)
        return binding?.root
    }
}

エラー

KotlinでDataBindingを使用すると、生成されるクラス(今回は「AboutAppListitemBinding」)で「cannot access class 'jp.searchwakayamatoilet.AboutAppListItem'. Check your module classpath for missing or conflicting dependencies」というエラーが表示されます。
またそれに関連してか、生成したクラスで「binding.root」としてRootViewを取得しようとすると、「Unresolved reference」とエラーが表示されます。

前者の方はIssue報告が上がっているようで、実はエラーが出たままでもビルド、実行が可能です。

https://youtrack.jetbrains.com/issue/KT-12402#u=1463619483291

なのでかなり気にはなりますが、この部分でのエラーは無視します。

ListView

これまでアプリについての紹介やクレジットを表示する画面では、一つの項目ごとにTextViewを作成して表示していました。
しかし流石にこれでは変更がしづらい…ということで、ListViewで置き換えることにしました。

RecyclerViewにしなかったのは、このアプリがAndroid 4.1以上を対象としているためです。

Item、Adapterを作成する

RecyclerViewと同じく、ListViewに表示するItem、Adapterを作成します。

Layoutはabout_app_listitem.xmlを参照してください。

AboutAppListItem.kt

import android.databinding.BaseObservable
import android.databinding.Bindable

class AboutAppListItem(newAreaTitle: String, newHasAreaTitle: Boolean
                       , newItemTitle: String, newHasItemTitle: Boolean, newDescription: String, newLink: String): BaseObservable(){
    private var areaTitle = ""
    private var hasAreaTitle = false
    private var itemTitle = ""
    private var hasItemTitle = false
    private var description = ""
    private var link = ""

    init{
        areaTitle = newAreaTitle
        hasAreaTitle = newHasAreaTitle
        itemTitle = newItemTitle
        hasItemTitle = newHasItemTitle
        description = newDescription
        link = newLink
    }
    @Bindable fun getAreaTitle(): String{
        return areaTitle
    }
    @Bindable fun getHasAreaTitle(): Boolean{
        return hasAreaTitle
    }
    @Bindable fun getItemTitle(): String{
        return itemTitle
    }
    @Bindable fun getHasItemTitle(): Boolean{
        return hasItemTitle
    }
    @Bindable fun getDescription(): String{
        return description
    }
    @Bindable fun getLink(): String{
        return link
    }
}

AdapterはAboutAppDataAdapter.ktを参照してください。

で、Activityからデータを突っ込みます。

activity_aboutapp.xml

< ?xml version="1.0" encoding="utf-8"? >
< layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context="jp.searchwakayamatoilet.AboutAppActivity" >
    < data >
        < variable
            name="aboutappclass"
            type="jp.searchwakayamatoilet.AboutAppActivity" / >
    < /data >
    < RelativeLayout
        android:id="@+id/about_activity"
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
        < ListView
            android:id="@+id/about_listview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="70dp"
            android:divider="@null"/ >
        < include layout="@layout/layout_toolbar" / >
    < /RelativeLayout >

  • ListViewのdeviderをNullにすることで、Item同士の枠線を非表示にできます。

AboutAppActivity.kt

import android.databinding.DataBindingUtil
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.support.v7.widget.Toolbar
import android.util.Log
import android.view.MenuItem
import android.widget.ListView
import jp.searchwakayamatoilet.databinding.ActivityAboutappBinding
import java.util.ArrayList

class AboutAppActivity : AppCompatActivity() {
    lateinit private var binding: ActivityAboutappBinding
    private var homeId = -1
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_about)
~省略~

        binding.aboutListview.scrollBarStyle = ListView.SCROLLBARS_OUTSIDE_OVERLAY;

        // ListViewにアイテムを追加する.
        val aboutDataList: ArrayList = ArrayList()
        // 第二引数: 大項目(このアプリについてorCredit)のタイトルがある場合はTrue.
        // 第四引数: 小項目(Creditの各項目名)のタイトルがある場合はTrue.
        aboutDataList.add(getItem(getString(R.string.about_title)
                , true
                , ""
                , false
                , getString(R.string.about_description)
                , getString(R.string.about_project_url)))

        aboutDataList.add(getItem(getString(R.string.about_credits_title)
                , true
                , getString(R.string.about_credits_title_toiletmap)
                , true
                , getString(R.string.about_credits_toiletmap)
                , getString(R.string.about_credits_toiletmap_url)))

        aboutDataList.add(getItem(""
                , false
                , getString(R.string.about_credits_title_kotlin)
                , true
                , getString(R.string.about_credits_kotlin)
                , getString(R.string.about_credits_kotlin_url)))

        aboutDataList.add(getItem(""
                , false
                , getString(R.string.about_credits_title_rxjava)
                , true
                , getString(R.string.about_credits_rxjava)
                , getString(R.string.about_credits_rxjava_url)))

        aboutDataList.add(getItem(""
                , false
                , getString(R.string.about_credits_title_rxandroid)
                , true
                , getString(R.string.about_credits_rxandroid)
                , getString(R.string.about_credits_rxandroid_url)))

        aboutDataList.add(getItem(""
                , false
                , getString(R.string.about_credits_title_lightweightstreamapi)
                , true
                , getString(R.string.about_credits_lightweightstreamapi)
                , getString(R.string.about_credits_lightweightstreamapi_url)))

        // 作成したデータをAdapterにセットする.
        binding.aboutListview.adapter = AboutAppDataAdapter(this, aboutDataList)
    }
~省略~
    private fun getItem(areaTitle: String, hasAreaTitle: Boolean, itemTitle: String, hasItemTitle: Boolean
                        , description: String, link: String): AboutAppListItem {
        val newItem = AboutAppListItem(areaTitle, hasAreaTitle, itemTitle, hasItemTitle
                , description, link)

        return newItem
    }
}

ListViewのItemクラスをArrayとしてデータを作成し、それを格納したAdapterをListViewにセットします。

ListViewのgetView

ListViewにAdapterが追加され、表示されるときにAboutAppDataAdapter.ktの「getView」が呼ばれます。

この時2つハマったことがありました。

1. 第二引数のViewをNull許容にしていなかった

最初はgetViewの「getView(position: Int, convertView: View?, parent: ViewGroup)」の2つ目の引数をNull許容にしておらず、Activityを切り替えてListViewが表示されるときにIllegalStateExceptionが発生しました。

2. データの更新を引数のViewがNullの時にしか行っていなかった

最初は以下のように、第二引数のViewがNullの場合のみDataBindingクラスを生成し、値をセットする、という処理を行っていました。

AboutAppDataAdapter.kt(失敗)

~省略~
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View? {
        if(convertView
        val binding: AboutAppListitemBinding? = DataBindingUtil.inflate(layoutInflater, R.layout.about_app_listitem, parent, false)

        // ※エラーは無視する.
        binding?.aboutappitemclass = aboutDataList.get(position)
        return binding?.root
    }
}

エラーは発生せず表示はされるのですが、ListViewがデフォルトの位置にある場合に表示されないItem(下の方に表示されていて、スライドすると見える)が、何故かセットしているArrayの最初のものに置き換わってしまう、という問題が発生しました。

ググッてみたところ、getViewが呼ばれた時は常に値を更新する必要がある、とのことでした。

で、Javaの場合は引数のViewに値を入れて、それを戻り値として返すことができるのですが、Kotlinではvalとして扱われていて値の代入ができません。

ということで、ViewがNullかどうかにかかわらず生成したDataBindingクラスのrootを返すことにしました。

…毎回DataBindingクラスを生成するのはどうよ?と思ったのですが、初期化時に一度だけ生成しようとすると、大きな空白が表示されたり、最後のアイテム以外が表示されなかったりと面白いことになってしまったため、断念しました。

AboutAppDataAdapter.kt

~省略~
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View? {
        val binding: AboutAppListitemBinding? = DataBindingUtil.inflate(layoutInflater, R.layout.about_app_listitem, parent, false)

        // ※エラーは無視する.
        binding?.aboutappitemclass = aboutDataList.get(position)
        return binding?.root
    }
}

参考

DataBinding

ListView

umeda.apk #1で発表してきました。

6/17に行われたumeda.apk #1で、MultiWindowについて喋ってきました。

shibuya-apk.connpass.com

speakerdeck.com

ここ数ヶ月参加させていただいている関モバに参加している方が多いのかな?と思いきや、初めてお見かけする方が多く、新鮮な顔ぶれでした。

というか、最初の関モバでのLTと同じぐらいには緊張しました(-_-;)。

今回のUmeda.apkのテーマはGoogleIOの報告ということで、特にFirebaseに関するお話が多かったのが印象的でした。

興味はあったもののなかなか手が出せていなかった自分としては、GoogleCloudMessagingよりシンプルに実装できるという事もあって、そろそろやってみようかな。という気持ちが強くなりました。

アーキテクチャのお話は、これまでに書いたコードもすぐゴチャついたBurritoになってしまうので、例えば複数のActivityからModelにアクセスする前にDomainを挟むなどお弁当🍱化を目指して、コードを見直したいと思いました。

Project TangoやAndroid autoのお話は、やっぱりわくわくしますね(๑•̀ㅂ•́)و✧

soilなどは秋か冬に開発者向けの端末が出るということで、プロジェクトが立ち消えになることなく実物の登場を待っておきます。

…自分が体験できるのはいつか分かりませんが。

あとIO関連で公開されている181セッション(!?)の中から、Android開発者がまずチェックすべきものをピックアップしていただけたのも助かります。

qiita.com

数が多いだけに、何から手を付けて良いかわからなくなりそうなので。

自分自身の発表については、とりあえず緊張しすぎでした(^_^;)

話したいことがどこまで伝えられたか怪しくはありますが、皆様に暖かく聴いていただけて、感謝感謝です。

緊張はするし準備に時間もかかりますが、やっぱりやると勉強にもなるし、楽しいですね。

唯一後悔していることといえば、ギュウギュウ詰めのカバンにNexus7を詰め込んでいたら、画面にヒビが入っちゃったことでしょうか-_-
ヒビが入っている部分以外は普通に使えるのでまだマシと言えますが...。

MultiWindowは、今作っているアプリではクラッシュはしないものの、UIに関する処理をonPauseで止めていたり、Windowサイズが変わったタイミングでUIサイズを変更していなかったりするため、順番に対応していきたいと思います。

最後に、会場提供のほかスポンサーとなって下さったサイバーエージェント様、主催者の方々、そして参加者の皆様ありがとうございましたm( )m

次回は8月ということで、また何か話すチャンスを狙っていきたいと思います✧