vaguely

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

【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月ということで、また何か話すチャンスを狙っていきたいと思います✧

【Android】DataBindingとかRecyclerViewとか

はじめに

前回までは主にカメラの映像をプレビューとして表示したり、画像として保存したりしていました。

今回は、映像にかけるフィルター(Mono、Negativeなど)を選択するための画面を、DataBindingやRecyclerViewを使って作成した時(libwvm.soが開けない

ちょっと脇道に逸れますが、Nexus5Xでデバッグをする際気になっていたのですが、以下のようなエラーメッセージが表示されていました。

    E/WVMExtractor: Failed to open libwvm.so: dlopen failed: library "libwvm.so" not found

動作としては特に問題なさそうだったのですが、やはり内容的には気になる…。
ということでググッてみたところ、端末固有の問題のようです。

libwvm.so自体はオーディオのドライバ関連のファイルのようなので、今回はシャッター音を鳴らすためのMediaActionSoundに絡んで発生したものと考えられます。

DataBindingを使う

前から気にはなっていたDataBinding。せっかくなので取り入れてみることにしました。

準備

Beta版の頃と違って、現在はDataBindingを使うには プロジェクト > app にあるbuild.gradleに以下を加えて、DataBindingを有効にしてやるだけです。

build.gradle

~省略~
android {
~省略~
    dataBinding {
        enabled = true
    }
}
~省略~

今回はまず手始めに、「findViewById」を置き換えることにします。

  1. 対象のLayoutファイルのLayout全体を、「layout」タグの子に入れます。
  2. 「data」タグを追加して、「variable」の「type」として、対象のクラス(Activity、Fragment)を指定します。

layout-land-v17/activity_camera2.xml

< ?xml version="1.0" encoding="utf-8"? >
< < data >
        < variable name="camera2" type="jp.cameratest.Camera2Activity" / >
    < /data >
    < RelativeLayout
        android:layout_width='match_parent'
        android:layout_height='match_parent' >
        < jp.cameratest.PreviewTextureView
            android:id='@+id/texture_preview_camera2'
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:layout_alignParentStart="true"
            android:layout_alignParentTop="true"
            / >
        < android.support.v7.widget.Toolbar
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:id="@+id/toolbar_camera2"
            android:layout_height="wrap_content"
            android:layout_width="match_parent"
            android:minHeight="40dp"
            android:background="#0F000000" / >
        < Button
            android:layout_width='wrap_content'
            android:layout_height='wrap_content'
            android:text='@string/btn_taking_photo'
            android:id='@+id/btn_taking_photo_camera2'
            android:layout_centerVertical="true"
            android:layout_alignParentRight="true"
            android:layout_alignParentEnd="true" / >
    < /RelativeLayout >
< /~省略~
import jp.cameratest.databinding.ActivityCamera2Binding;

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public class Camera2Activity extends AppCompatActivity {
~省略~
    private ActivityCamera2Binding binding;
~省略~
    @Override
    protected void onCreate(Bundle savedInstanceState) {
~省略~
        binding = (ActivityCamera2Binding)DataBindingUtil.setContentView(this, R.layout.activity_camera2);
        binding.btnTakingPhotoCamera2 != null){
            binding.btnTakingPhotoCamera2.setOnClickListener(
                    (View v) ->{
                        takePicture();
                    }
            );
        }
~省略~

透明なToolbar

CameraのFilterなどを選択するボタンをToolbar上に設置しようと考えたのですが、 プレビュー用のTextureViewと重ねて表示するため透明にしたいと思います。

ということで調べてみたのですが、特別なことは何もなく、ただ背景色に透明度を与えるだけでしたw

toolbar_camera2.xml

< android.support.v7.widget.Toolbar
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/toolbar_camera2"
    android:layout_height="wrap_content"
    android:layout_width="match_parent"
    android:minHeight="50dp"
    android:title=""
    android:background="#0F000000" / >
  • あえてポイントがあるとすれば、色指定の並びがRGBAではなくARGBになっているため、頭の2桁が透明度の指定となります。

RecyclerViewを使う

Lollipopから追加されたRecyclerViewを使って、Filterが選択できるようにしてみました。
せっかくなので、↑で追加したDataBindingを使用します。

※以下などを読むと本当はListViewでできないかを検討すべきなのだとは思いますが、何よりRecyclerViewを使ってみたいという気持ちがあったのでこちらを選んでいます。

準備

RecyclerViewを使用するには、build.gradleに以下を追加します。

build.gradle

~省略~
dependencies {
~省略~
    compile 'com.android.support:recyclerview-v7:24.0.0-beta1'
~省略~
}
~省略~

Adapterの作成

表示する値のセットやUI部分を設定するAdapterを作成します。

recycler_item_filter.xml

< ?xml version="1.0" encoding="utf-8"? >
< layout xmlns:android="http://schemas.android.com/apk/res/android" >
    < data >
        < variable
            name="filterclass"
            type="jp.cameratest.FilterListAdapter.FilterClass" >
        < /variable >
    < /data >
    < LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical" >
        < TextView
            android:id="@+id/listitem_view_filter"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:minHeight="30dp"
            android:padding="20dp"
            android:layout_marginBottom="2dp"
            android:textColor="#FFFFFF"
            android:background="#3F000000"
            android:text="@{filterclass.filterName}"/ >
    < /LinearLayout >
< /layout >
  • Layout部分には表示するItemひとつ分(今回は一列分)のものを指定します。
  • 「data」タグでは、Itemが保持するFilter名、CaptureRequestのCONTROL_EFFECT_MODE_~を持つFilterClassを指定しています。表示する際はこのクラスのListを作成してViewにセットします。
  • android:text="@{filterclass.filterName}"」とすることで、Viewを表示するときにFilterClassのListが持つFilter名がそれぞれセットされます。

FilterListAdapter.java

import android.annotation.TargetApi;
import android.databinding.BaseObservable;
import android.databinding.Bindable;
import android.databinding.DataBindingUtil;
import android.databinding.ViewDataBinding;
import android.hardware.camera2.CaptureRequest;
import android.os.Build;
import android.support.annotation.NonNull;
import android.support.v7.widget.RecyclerView;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import java.util.ArrayList;

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public class FilterListAdapter extends RecyclerView.Adapter{
    private final static FilterListAdapter filterListAdapter = new FilterListAdapter();
    private ArrayList filterClassList;

    public class FilterClass extends BaseObservable {
        // 各Itemが保持するデータを格納するClass.
        private String filterName;
        private int filterNum;

        @Bindable
        public String getFilterName(){
            return filterName;
        }
        public void setFilterName(String newValue){
            filterName = newValue;
            // 変更されたことを通知
            notifyPropertyChanged(jp.cameratest.BR.filterName);
        }
        @Bindable
        public int getFilterNum(){
            return filterNum;
        }
        public void setFilterNum(int newValue){
            filterNum = newValue;
            // 変更されたことを通知
            notifyPropertyChanged(jp.cameratest.BR.filterNum);
        }
    }
    public static class DataBindingHolder extends RecyclerView.ViewHolder {
        private final ViewDataBinding dataBinding;

        public DataBindingHolder(View v) {
            super(v);
            dataBinding = DataBindingUtil.bind(v);
            // ItemがClickされたら呼ばれる.
            dataBinding.getRoot().setOnClickListener((view) -> {
                Log.d("FilterListAdapter", "ClickedPosition:" + getAdapterPosition());
            });
        }
        public ViewDataBinding getBinding() {
            return dataBinding;
        }
    }
    public FilterListAdapter() {
        // Itemの保持するFilter名、CaptureRequestのCONTROL_EFFECT_MODE番号をセット.
        filterClassList = new ArrayList<>();

        addFilterItem("DEFAULT", CaptureRequest.CONTROL_EFFECT_MODE_OFF);
        addFilterItem("MONO", CaptureRequest.CONTROL_EFFECT_MODE_MONO);
        addFilterItem("NEGATIVE", CaptureRequest.CONTROL_EFFECT_MODE_NEGATIVE);
        addFilterItem("SEPIA", CaptureRequest.CONTROL_EFFECT_MODE_SEPIA);
        addFilterItem("AQUA", CaptureRequest.CONTROL_EFFECT_MODE_AQUA);
        addFilterItem("BLACKBOARD", CaptureRequest.CONTROL_EFFECT_MODE_BLACKBOARD);
        addFilterItem("WHITEBOARD", CaptureRequest.CONTROL_EFFECT_MODE_WHITEBOARD);
        addFilterItem("POSTERIZE", CaptureRequest.CONTROL_EFFECT_MODE_POSTERIZE);
        addFilterItem("SOLARIZE", CaptureRequest.CONTROL_EFFECT_MODE_SOLARIZE);
    }
    @Override
    public DataBindingHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
        // ItemのViewを作成.
        View v = LayoutInflater.from(viewGroup.getContext())
                .inflate(R.layout.recycler_item_filter, viewGroup, false);

        return new DataBindingHolder(v);
    }
    @Override
    public void onBindViewHolder(DataBindingHolder viewHolder, final int position) {
        FilterClass filter = filterClassList.get(position);

        viewHolder.getBinding().setVariable(jp.cameratest.BR.filterclass, filter);
    }
    @Override
    public int getItemCount() {
        return filterClassList.size();
    }
    private void addFilterItem(@NonNull String filterName, int filterNum){
        // Filter名とCaptureRequestのCONTROL_EFFECT_MODEの番号からClassを作って配列に格納.
        FilterClass newClass = new FilterClass();
        newClass.filterName = filterName;
        newClass.filterNum = filterNum;
        filterClassList.add(newClass);
    }
}
  • RecyclerViewの各Itemの値を保持するクラス(FilterClass)には、BaseObservableを継承させます。
  • FilterClassにはget / setを持たせていますが、今回は特に使用していません。
  • Itemをクリックした時のイベントは、インナークラスとして作成しているViewHolderで取得できます。
  • Itemとしてセットする値は、ネット上のサンプルなどではRecyclerViewを表示するActivityやFragmentから渡していることが多かったのですが、今回はAdapter内で生成、セットしています。

Fragmentから呼び出して表示する

Adapterができれば、後は表示するだけです。

fragment_select_filter.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"
    android:id="@+id/fragment_select_filter_container" >
    < data >
        < variable name="selectfilter" type="jp.cameratest.SelectFilterFragment" / >
    < /data >
    < FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        < android.support.v7.widget.RecyclerView
            android:id="@+id/recycler_selectfilter"
            android:scrollbars="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_centerVertical="true"
            android:padding="30dp"
            app:layoutManager="LinearLayoutManager"
            / >
    < /FrameLayout >
< /layout >

SelectFilterFragment.java

import android.os.Bundle;
import android.app.Fragment;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import jp.cameratest.databinding.FragmentSelectFilterBinding;

public class SelectFilterFragment extends Fragment {
    protected FilterListAdapter adapter;

    public SelectFilterFragment() {
        // Required empty public constructor
    }
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View rootView = inflater.inflate(R.layout.fragment_select_filter, container, false);
        FragmentSelectFilterBinding binding = FragmentSelectFilterBinding.bind(rootView);

        int scrollPosition = 0;

        // スクロールした状態でActivityが破棄された場合はスクロール位置を直前の状態に戻す.
        if (binding.recyclerSelectfilter.getLayoutManager() != null) {
            scrollPosition = ((LinearLayoutManager) binding.recyclerSelectfilter.getLayoutManager())
                    .findFirstCompletelyVisibleItemPosition();
        }
        binding.recyclerSelectfilter.scrollToPosition(scrollPosition);

        adapter = new FilterListAdapter();

        // RecyclerViewにFilterListAdapterをセットする.
        binding.recyclerSelectfilter.setAdapter(adapter);

        return rootView;
    }
}

次回

だいぶサンプルコードを丸写しにしているところが多いですが、とりあえず表示できるようになりました。

f:id:mslGt:20160608005814p:plain

ただ、このままだとどのItemがClickされたかがわかるだけで、CameraへのFilterのセットができません。

次回はその辺りを扱う予定です。

参考

libwvm.so

DataBinding

Toolbar

RecyclerView

Android NでもCamera2を使いたい(MultiWindowとか)

はじめに

今回はGoogle IOに合わせて?リリースされたAndroid NのPreview3をインストールしてみたので、ここまで作成しているCameraアプリの動作を確認してみました。

Camera2のAPI自体は変更がなさそうですが、大きな話題となっているMultiWindowに対応するためにいくつか変更や設定が必要となったため、これをまとめることとします。

MultiWindowについて

  • 画面を2分割して、2つのアプリを表示するMultiWindow。
    とはいっても両方のアプリが同時に動くわけではなく、片側はBackgroundへと移行します。
  • MultiWindowモードでは、Windowのサイズを画面の半分、約2/3などサイズを変えることができます(Nexus5XではPortraitモードでのみ変更可能)。
  • MultiWindowのOn/Off時、MultiWindowでWindowサイズを変更すると端末を回転させた時と同じく、Activityが破棄されます(端末を回転させたときと同じ)。

準備

MultiWindowを有効にするには、AndroidManifest.xmlの「application」または「activity」で「android:resizeableActivity="true"」を追加します。

AndroidManifest.xml

~省略~
    < application
            android:allowBackup="true"
            android:icon="@mipmap/ic_launcher"
            android:label="@string/app_name"
            android:resizeableActivity="true"
            android:supportsRtl="true"
            android:theme="@style/AppTheme" >
~省略~
  • 追加しなくてもMultiWindowで開くことは可能なのですが、その時に「App may not work with split-screen.」というToastが表示されます。

MultiWindowのOn/Off

MultiWindowをOn/OffするとActivityが破棄されるのは上記の通りですが、再生成後、「onCreate」などの後に「onMultiWindowChanged(boolean inMultiWindow)」が呼ばれます。
(ただし今回は使用していません)

    @Override
    public void onMultiWindowChanged(boolean isInMultiWindowMode) {
        super.onMultiWindowModeChanged(isInMultiWindowMode);
    }

また、Activityでは「isInMultiWindowMode()」を使って今MultiWindowであるかを確認することができます。

画面の向き

MultiWindow対応で引っかかったのが画面の向きです。

Windowのサイズが縦長か横長かによって、表示上の画面の向きが切り替わります。

f:id:mslGt:20160527010257j:plain

f:id:mslGt:20160527010310j:plain

このスクリーンショットはどちらも端末自体はPortraitモードで表示しているのですが、Windowが縦長の場合はPortraitモードのLayout(撮影ボタンを下に表示)が、横長の場合はLandscapeモードのLayout(撮影ボタンを右側に表示)が適用されています。

データ的には、端末がPortraitモードの状態でWindowを横長にすると、以下の値が異なります。

// 例: 端末が縦向きならWindowサイズによらず0°(Portraitモード)となる.
    int displayOrientation = getWindowManager().getDefaultDisplay().getRotation();

    // 例: 端末が縦向きでもWindowサイズによってLandscape、Portraitの値が変わる.
    Configuration config = getResources().getConfiguration();
    int configOrientation = config.orientation;

話の流れ的には下のConfiguration.orientationを使う話になりそうですが、実はそうしてしまうと、カメラの映像を映すTextureViewのサイズ自体は正しいのに横または縦に押しつぶされた映像が表示されます。

原因は、たとえ画面表示がLandscapeモードであったとしても、カメラの映像自体は縦長のPortraitモードのものが送られてくるからです。

ということで、今回は上の「getWindowManager().getDefaultDisplay().getRotation()」を使いました。

MultiWindowには関係がないですが、写真を撮った時に音が鳴らないのはなんとも寂しい気がするため、シャッター音を鳴らすことにしました。

~省略~
    private MediaActionSound mediaActionSound;
~省略~
    @Override
    public void onDestroy(){
        super.onDestroy();
        if(previewImageReader != null){
            previewImageReader.close();
            previewImageReader = null;
        }
        getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        // Soundオブジェクトの開放.
        mediaActionSound.release();

        savedOrientationNum = -1;
    }
~省略~
    private void initCameraView(){
        // シャッター音の準備.
        mediaActionSound = new MediaActionSound();
        mediaActionSound.load(MediaActionSound.SHUTTER_CLICK);
~省略~
    }
~省略~
    private void takePicture() {
        if(cameraDevice == null || previewSession == null) {
            return;
        }
        try {
            final CaptureRequest.Builder captureBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE);
            captureBuilder.addTarget(previewImageReader.getSurface());
            setCameraMode(captureBuilder);

            // 画像の回転を調整する.
            captureBuilder.set(CaptureRequest.JPEG_ORIENTATION
                    , (ORIENTATIONS.get(getWindowManager().getDefaultDisplay().getRotation()) + sensorOrientationNum + 270) % 360);
            // プレビュー画面の更新を一旦ストップ.
            previewSession.stopRepeating();

            // シャッター音を鳴らす.
            mediaActionSound.play(MediaActionSound.SHUTTER_CLICK);

            // 画像の保存.
            previewSession.capture(captureBuilder.build(), null, null);
        } catch (CameraAccessException e) {
        }
    }
  • MediaActionSoundを使って音を鳴らします。
  • MediaActionSound.loadで音声ファイルを読み込むのですが、準備が完了するまでには少し時間がかかるのか、MediaActionSound.playで音を鳴らす直前(今回は「takePicture()」の中)に実行しても音が再生されません。
  • Activityが破棄されるときにMediaActionSound.releaseで開放してあげればOKです。

ソースコード

記録がてら現状のコードを載せておきます。

Camera2Activity.java

    import android.Manifest;
    import android.annotation.TargetApi;
    import android.content.Context;
    import android.content.pm.PackageManager;
    import android.graphics.ImageFormat;
    import android.graphics.Matrix;
    import android.graphics.Point;
    import android.graphics.RectF;
    import android.graphics.SurfaceTexture;
    import android.hardware.camera2.CameraAccessException;
    import android.hardware.camera2.CameraCaptureSession;
    import android.hardware.camera2.CameraCharacteristics;
    import android.hardware.camera2.CameraDevice;
    import android.hardware.camera2.CameraManager;
    import android.hardware.camera2.CaptureRequest;
    import android.hardware.camera2.params.StreamConfigurationMap;
    import android.hardware.display.DisplayManager;
    import android.media.Image;
    import android.media.ImageReader;
    import android.media.MediaActionSound;
    import android.media.MediaScannerConnection;
    import android.net.Uri;
    import android.os.Build;
    import android.os.Environment;
    import android.os.Handler;
    import android.os.HandlerThread;
    import android.support.annotation.NonNull;
    import android.support.v7.app.AppCompatActivity;
    import android.os.Bundle;
    import android.util.Log;
    import android.util.Size;
    import android.util.SparseIntArray;
    import android.view.Surface;
    import android.view.TextureView;
    import android.view.View;
    import android.view.WindowManager;
    import android.widget.Button;
    import android.widget.Toast;

    import com.annimon.stream.Optional;
    import com.annimon.stream.Stream;

    import java.io.File;
    import java.io.FileNotFoundException;
    import java.io.FileOutputStream;
    import java.io.IOException;
    import java.io.OutputStream;
    import java.nio.ByteBuffer;
    import java.util.Arrays;

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public class Camera2Activity extends AppCompatActivity {
        private final static int RequestNumPermissionCamera = 1;
        private final static int RequestNumPermissionStorage = 2;
        private final static int TextureViewMaxWidth = 1920;
        private final static int TextureViewMaxHeight = 1080;
        private Size previewSize;
        private PreviewTextureView previewTextureView;
        private CameraDevice cameraDevice;
        private CaptureRequest.Builder previewBuilder;
        private CameraCaptureSession previewSession;
        private int sensorOrientationNum;
        private ImageReader previewImageReader;
        private DisplayManager displayManager;
        private DisplayManager.DisplayListener displayListener;
        private HandlerThread backgroundThread;
        private Handler backgroundHandler;
        private boolean isFlashlightSupported;
        // 画像保存時Storageの権限を要求した場合に、BackgroundThread再開後に画像保存処理を行うためのフラグ.
        private boolean isPictureTaken = false;
        private Image capturedImage;
        // 端末を180度回転させた場合に、2回目のconfigureTransformを呼ぶのに使う.
        private int lastOrientationNum = -1;
        private int savedOrientationNum = -1;
        private MediaActionSound mediaActionSound;

        private static final SparseIntArray ORIENTATIONS = new SparseIntArray();
        static {
            ORIENTATIONS.append(Surface.ROTATION_0, 90);
            ORIENTATIONS.append(Surface.ROTATION_90, 0);
            ORIENTATIONS.append(Surface.ROTATION_180, 270);
            ORIENTATIONS.append(Surface.ROTATION_270, 180);
        }
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            // フルスクリーン表示.
            getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);

            setContentView(R.layout.activity_camera2);
            
            initCameraView();

            if(isInMultiWindowMode()){
                savedOrientationNum = -1;
            }
            else if(savedInstanceState != null){
                savedOrientationNum = savedInstanceState.getInt(getString(R.string.saved_orientation_num));
            }
        }
        @Override
        public void onResume(){
            super.onResume();

            startBackgroundThread();

            // Storageの権限要求後は画像の保存処理を行う.
            if(isPictureTaken){
                prepareSavingImage();
                isPictureTaken = false;
            }
            lastOrientationNum = getWindowManager().getDefaultDisplay().getRotation();

            if(savedOrientationNum == lastOrientationNum
                    && previewSize != null){
                openCamera(previewSize.getWidth(), previewSize.getHeight());
                savedOrientationNum = -1;
            }
        }
        @Override
        public void onPause(){
            super.onPause();
            if(previewSession != null) {
                previewSession.close();
                previewSession = null;
            }
            if(cameraDevice != null){
                cameraDevice.close();
                cameraDevice = null;
            }
            if(displayManager != null
                    && displayListener != null){
                displayManager.unregisterDisplayListener(displayListener);
            }
            displayManager = null;
            displayListener = null;

            if(isInMultiWindowMode()){
                savedOrientationNum = getWindowManager().getDefaultDisplay().getRotation();
            }
            stopBackgroundThread();
        }
        @Override
        public void onDestroy(){
            super.onDestroy();
            if(previewImageReader != null){
                previewImageReader.close();
                previewImageReader = null;
            }
            getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
            mediaActionSound.release();

            savedOrientationNum = -1;
        }
        @Override
        protected void onSaveInstanceState(Bundle outState) {
            super.onSaveInstanceState(outState);
            outState.putInt(getString(R.string.saved_orientation_num), lastOrientationNum);
        }
        @Override
        public void onRequestPermissionsResult(int intRequestCode
                , @NonNull String[] strPermissions
                , @NonNull int[] intGrantResults) {
            super.onRequestPermissionsResult(intRequestCode, strPermissions, intGrantResults);
            if(intGrantResults.length <= 0){
                return;
            }
            switch (intRequestCode){
                case RequestNumPermissionCamera:
                    if (intGrantResults[0] == PackageManager.PERMISSION_GRANTED) {
                        runOnUiThread(
                            () ->{
                                // 権限が許可されたらプレビュー画面の使用準備.
                                openCamera(previewTextureView.getWidth(), previewTextureView.getHeight());
                            }
                        );
                    }
                    else{
                        // 権限付与を拒否されたらMainActivityに戻る.
                        finish();
                    }
                    break;
                case RequestNumPermissionStorage:
                    if (intGrantResults[0] == PackageManager.PERMISSION_GRANTED) {
                        // Storageへのアクセスが許可されたら、OnResumeで画像の保存処理実行.
                        isPictureTaken = true;
                    }
                    else{
                        // 権限付与を拒否されたらMainActivityに戻る.
                        finish();
                    }
                    break;
            }
        }
        @TargetApi(Build.VERSION_CODES.M)
        private void requestCameraPermission(){
            if(checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){
                // 権限が許可されていたらプレビュー画面の使用準備.
                openCamera(previewTextureView.getWidth(), previewTextureView.getHeight());
            }
            else{
                requestPermissions(new String[]{Manifest.permission.CAMERA}, RequestNumPermissionCamera);
            }
        }
        @TargetApi(Build.VERSION_CODES.M)
        private void requestStoragePermission(){
            if(checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
                    && checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED){
                // 権限付与済みであれば画像を保存する.
                prepareSavingImage();
            }
            else{
                requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}
                        , RequestNumPermissionStorage);
            }
        }
        private void initCameraView(){
            // シャッター音の準備.
            mediaActionSound = new MediaActionSound();
            mediaActionSound.load(MediaActionSound.SHUTTER_CLICK);

            // プレビュー用のViewを追加.
            previewTextureView = (PreviewTextureView) findViewById(R.id.texture_preview_camera2);

            previewTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
                @Override
                public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
                    // Textureが有効化されたらプレビューを表示.
                    // OS6.0以上ならCameraへのアクセス権確認.
                    if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
                        requestCameraPermission();
                    }
                    else{
                        openCamera(width, height);
                    }
                }
                @Override
                public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
                    configureTransform(width, height);
                }
                @Override
                public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
                    return true;
                }
                @Override
                public void onSurfaceTextureUpdated(SurfaceTexture surface) {
                }
            });
            Button btnTakingPhoto = (Button) findViewById(R.id.btn_taking_photo_camera2);
            if(btnTakingPhoto != null){
                btnTakingPhoto.setOnClickListener(
                    (View v) ->{
                        takePicture();
                    }
                );
            }
        }
        private void openCamera(int width, int height) {
            if(width <= 0
                    || height <= 0){
                return;
            }
            // 画面回転を検出.
            displayListener = new DisplayManager.DisplayListener() {
                @Override
                public void onDisplayAdded(int displayId) {
                }
                @Override
                public void onDisplayChanged(int displayId) {
                    // Displayサイズの取得.
                    Point displaySize = new Point();
                    getWindowManager().getDefaultDisplay().getSize(displaySize);

                    configureTransform(displaySize.x, displaySize.y);

                    int newOrientationNum = getWindowManager().getDefaultDisplay().getRotation();
                    // 端末を180度回転させると2回目のconfigureTransformが呼ばれないのでここで実行.
                    if(Math.abs(newOrientationNum - lastOrientationNum) == 2){
                        configureTransform(previewTextureView.getWidth(), previewTextureView.getHeight());
                        // 180度回転の場合はonResumeが呼ばれないのでここで角度情報を保持.
                        lastOrientationNum = newOrientationNum;
                    }
                }
                @Override
                public void onDisplayRemoved(int displayId) {
                }
            };
            displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE);
            displayManager.registerDisplayListener(displayListener, backgroundHandler);

            // Camera機能にアクセスするためのCameraManagerの取得.
            CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);
            try {
                // Back Cameraを取得してOpen.
                for (String strCameraId : manager.getCameraIdList()) {
                    // Cameraから情報を取得するためのCharacteristics.
                    CameraCharacteristics characteristics = manager.getCameraCharacteristics(strCameraId);
                    Integer facing = characteristics.get(CameraCharacteristics.LENS_FACING);
                    if (facing == null
                            || facing == CameraCharacteristics.LENS_FACING_FRONT) {
                        // Front Cameraならスキップ.
                        continue;
                    }
                    Integer cameraOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION);
                    sensorOrientationNum = (cameraOrientation != null)? cameraOrientation: 0;

                    // 端末がFlashlightに対応しているか確認.
                    Boolean isAvailable = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE);
                    isFlashlightSupported = (isAvailable != null && isAvailable);

                    // ストリームの設定を取得(出力サイズを取得する).
                    StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
                    if(map == null) {
                        continue;
                    }
                    Size[] sizes = map.getOutputSizes(ImageFormat.JPEG);

                    // 配列から最大の組み合わせを取得する.
                    Size maxImageSize = new Size(width, height);
                    Optional maxSize = Stream.of(sizes)
                            .max((a, b) -> Integer.compare(a.getWidth(), b.getWidth()));

                    if(maxSize != null){
                        maxImageSize = maxSize.get();
                    }

                    // 画像を取得するためのImageReaderの作成.
                    previewImageReader = ImageReader.newInstance(maxImageSize.getWidth(), maxImageSize.getHeight(), ImageFormat.JPEG, 2);

                    previewImageReader.setOnImageAvailableListener(
                        (ImageReader reader)-> {
                        capturedImage = reader.acquireLatestImage();

                        // OS6.0以上ならStorageへのアクセス権を確認.
                        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                            requestStoragePermission();
                        }
                        else{
                                prepareSavingImage();
                        }
                        }
                        , backgroundHandler);

                    int deviceRotation = getWindowManager().getDefaultDisplay().getRotation();

                    if(deviceRotation == Surface.ROTATION_90
                            || deviceRotation == Surface.ROTATION_270){
                        previewTextureView.setAspectRatio(maxImageSize.getWidth(), maxImageSize.getHeight());
                    }
                    else{
                        previewTextureView.setAspectRatio(maxImageSize.getHeight(), maxImageSize.getWidth());
                    }

                    // 取得したSizeのうち、画面のアスペクト比に合致していてTextureViewMaxWidth・TextureViewMaxHeight以下の最大値をセット.
                    final float aspectRatio = ((float)maxImageSize.getHeight() / (float)maxImageSize.getWidth());

                    int maxWidth;
                    int maxHeight;

                    if(isInMultiWindowMode()){
                        maxWidth = width;
                        maxHeight = height;
                    }
                    else{
                        maxWidth = TextureViewMaxWidth;
                        maxHeight = TextureViewMaxHeight;
                    }

                    Optional setSize = Stream.of(sizes)
                            .filter(size ->
                                    size.getWidth() <= maxWidth
                                            && size.getHeight() <= maxHeight
                                            && size.getHeight() == (size.getWidth() * aspectRatio))
                            .max((a, b) -> Integer.compare(a.getWidth(), b.getWidth()));

                    if(setSize == null){
                        previewSize = new Size(640, 480);
                    }
                    else{
                        previewSize = setSize.get();
                    }

                    try {
                        manager.openCamera(strCameraId, new CameraDevice.StateCallback() {
                            @Override
                            public void onOpened(@NonNull CameraDevice camera) {
                                if(cameraDevice == null){
                                    cameraDevice = camera;
                                }
                                runOnUiThread(
                                    () -> {
                                        // カメラ画面表示中はScreenをOffにしない.
                                        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
                                    });
                                createCameraPreviewSession();
                            }
                            @Override
                            public void onDisconnected(@NonNull CameraDevice cmdCamera) {
                                cameraDevice.close();
                                cameraDevice = null;
                            }
                            @Override
                            public void onError(@NonNull CameraDevice cmdCamera, int error) {
                                cameraDevice.close();
                                cameraDevice = null;
                            }
                        }, backgroundHandler);
                    }catch (SecurityException e){
                    }
                }
            } catch (CameraAccessException e) {
            }
        }
        private void prepareSavingImage(){
            backgroundHandler.post(
                ()->{
                    try {
                        try {
                            ByteBuffer buffer = capturedImage.getPlanes()[0].getBuffer();
                            byte[] bytes = new byte[buffer.capacity()];
                            buffer.get(bytes);
                            saveImage(bytes);
                        }catch (FileNotFoundException e) {
                        }
                    } catch (IOException e) {
                    } finally {
                        if (capturedImage != null) {
                            capturedImage.close();
                        }
                    }
                }
            );
        }
        private void saveImage(byte[] bytes) throws IOException {
            OutputStream output = null;
            try {
                // ファイルの保存先のディレクトリとファイル名.
                String strSaveDir = Environment.getExternalStorageDirectory().toString() + "/DCIM";
                String strSaveFileName = "pic_" + System.currentTimeMillis() +".jpg";

                final File file = new File(strSaveDir, strSaveFileName);

                // 生成した画像を出力する.
                output = new FileOutputStream(file);
                output.write(bytes);

                // 保存した画像を反映させる.
                MediaScannerConnection.scanFile(
                        getApplicationContext()
                        , new String[]{strSaveDir + "/" + strSaveFileName}
                        , new String[]{"image/jpeg"}
                        , (String path, Uri uri) ->{
                            runOnUiThread(
                                ()->{
                                    Toast.makeText(getApplicationContext(), "Saved: " + path, Toast.LENGTH_SHORT).show();
                                    // もう一度カメラのプレビュー表示を開始する.
                                    if(cameraDevice == null){
                                        // 権限確認でPause状態から復帰したらCameraDeviceの取得も行う.
                                        openCamera(previewTextureView.getWidth(), previewTextureView.getHeight());
                                    }
                                    else{
                                        createCameraPreviewSession();
                                    }
                                }
                            );
                        });
            } finally {
                if (output != null) {
                    output.close();
                }
            }
        }
        private void createCameraPreviewSession(){
            if(cameraDevice == null || ! previewTextureView.isAvailable() || previewSize == null) {
                return;
            }
            SurfaceTexture texture = previewTextureView.getSurfaceTexture();
            if(texture == null) {
                return;
            }
            texture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight());
            Surface surface = new Surface(texture);

            try {
                // プレビューウインドウのリクエスト.
                previewBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
            } catch (CameraAccessException e) {
            }
            previewBuilder.addTarget(surface);

            try {
                cameraDevice.createCaptureSession(Arrays.asList(surface, previewImageReader.getSurface())
                        , new CameraCaptureSession.StateCallback() {
                            @Override
                            public void onConfigured(@NonNull CameraCaptureSession session) {
                                previewSession = session;
                                if(cameraDevice == null) {
                                    return;
                                }
                                setCameraMode(previewBuilder);
                                try {
                                    previewSession.setRepeatingRequest(previewBuilder.build(), null, backgroundHandler);
                                } catch (CameraAccessException e) {
                                }
                            }
                            @Override
                            public void onConfigureFailed(@NonNull CameraCaptureSession session) {
                            }
                }, null);
            } catch (CameraAccessException e) {
            }
        }
        private void takePicture() {
            if(cameraDevice == null || previewSession == null) {
                return;
            }
            try {
                final CaptureRequest.Builder captureBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE);
                captureBuilder.addTarget(previewImageReader.getSurface());
                setCameraMode(captureBuilder);

                // 画像の回転を調整する.
                captureBuilder.set(CaptureRequest.JPEG_ORIENTATION
                        , (ORIENTATIONS.get(getWindowManager().getDefaultDisplay().getRotation()) + sensorOrientationNum + 270) % 360);
                // プレビュー画面の更新を一旦ストップ.
                previewSession.stopRepeating();

                // シャッター音を鳴らす.
                mediaActionSound.play(MediaActionSound.SHUTTER_CLICK);

                // 画像の保存.
                previewSession.capture(captureBuilder.build(), null, null);
            } catch (CameraAccessException e) {
            }
        }
        private void configureTransform(int viewWidth, int viewHeight){
            // 画面の回転に合わせてTextureViewの向き、サイズを変更する.
            if (previewTextureView == null || previewSize == null){
                return;
            }
            runOnUiThread(
                () ->{
                    RectF rctView = new RectF(0, 0, viewWidth, viewHeight);
                    RectF rctPreview = new RectF(0, 0, previewSize.getHeight(), previewSize.getWidth());
                    float centerX = rctView.centerX();
                    float centerY = rctView.centerY();

                    Matrix matrix = new Matrix();

                    int displayRotationNum = getWindowManager().getDefaultDisplay().getRotation();

                    if(displayRotationNum == Surface.ROTATION_90
                            || displayRotationNum == Surface.ROTATION_270){

                        rctPreview.offset(centerX - rctPreview.centerX(), centerY - rctPreview.centerY());

                        matrix.setRectToRect(rctView, rctPreview, Matrix.ScaleToFit.FILL);

                        // 縦または横の画面一杯に表示するためのScale値を取得.
                        float scale = Math.max(
                                (float) viewHeight / previewSize.getHeight()
                                , (float) viewWidth / previewSize.getWidth()
                        );
                        matrix.postScale(scale, scale, centerX, centerY);
                        // ROTATION_90: 270度回転、ROTATION_270: 90度回転.
                        matrix.postRotate((90 * (displayRotationNum + 2)) % 360, centerX, centerY);
                    }
                    else{
                        // ROTATION_0: 0度回転、ROTATION_180: 180度回転.
                        matrix.postRotate(90 * displayRotationNum, centerX, centerY);
                    }
                    previewTextureView.setTransform(matrix);
                }
            );
        }
        private void setCameraMode(CaptureRequest.Builder requestBuilder){
            // AutoFocus
            requestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
            // Filterを使う場合はコメントを外す.
    //        requestBuilder.set(CaptureRequest.CONTROL_EFFECT_MODE, CaptureRequest.CONTROL_EFFECT_MODE_MONO);

            // 端末がFlashlightに対応していたら自動で使用されるように設定.
            if(isFlashlightSupported){
                requestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);
            }
        }
        private void startBackgroundThread() {
            backgroundThread = new HandlerThread("CameraPreview");
            backgroundThread.start();
            backgroundHandler = new Handler(backgroundThread.getLooper());
        }
        private void stopBackgroundThread(){
            backgroundThread.quitSafely();
            try{
                backgroundThread.join();
                backgroundThread = null;
                backgroundHandler = null;
            }catch (InterruptedException e){
            }
        }
    }

最後に

とりあえずこれでMultiWindowがやってきてもそれなりに動くようにはなったので、FilterやFlashlightなどCamera2の持つ機能を使って遊んでみたいところ。

ただその前に、とりあえずコードを見なおして余計な処理を削ったり、処理の流れを追いやすい形に直したいところ(まぁTextureViewの定義以外全部一纏めにしているせいでもあるのでしょうが)。

参考

MultiWindow

シャッター音

関モバ #14で発表してきました。

はじめに

性懲りもなく関モバ #14で発表してきました。

kanmoba.connpass.com

speakerdeck.com

今回はCamera2(とMultiWindow)についてです。

自分の発表内容について

今回は直前までバグが修正しきれず、本当にギリギリとなってしまいました。
話す内容の時間調整にさけた時間が少なく、いつも以上に余裕の無い発表になってしまったのは反省点です。

ただ、実際にPreview3をインストールして、MultiWindowの対応をギリギリまで粘ったことで最新の情報を盛り込めた気がしますし、 何よりMultiWindowがどう動くのかに触れられて楽しかった、というところは良かったところだと思います。

MultiWindowについて、ネガティブな情報ばかりを共有してしまったのでは?という懸念点はありますが、Drag&Drop機能などがアプリ間でできるようになれば、特にタブレット端末で便利に使えそうで、楽しみです。

今回挙げていた課題についても、順次試していきたいと思います(FreedomWindowモードは普通に使えるのかは分かりませんが)。

ちなみにAndroid Nの話題を出したときに、こっそりNeruneruneruneと入れておきましたが、特にツッコミはありませんでしたw

名前は果たして何になるのでしょうね。順当にヌガーとかでしょうか。

Swiftについて

正直0.X時点の知識では全く話についていけなくなっているところがあるので、Swiftも少しは触っていきたいなというところです。

iOSの開発はもはや大部分がSwiftに切り替わっている印象ですし、次期バージョンの3.0の話も登場したりとわからないなりにも話を聞いているとワクワクしてきますね。

ConstraintLayoutについて

Google IOで発表されて以降、私も気になっていたので良いタイミングで(しかも2件)聞けて良かったです。
実際に使ってみるとまた違った印象になるかもしれませんが、プライオリティがつけられないなど気になるところがありつつも、 以前から作っているトイレアプリなど多少GUIパーツがいくつかあるアプリで試してみたいところです。

AndroidにせよiOSにせよ、便利な機能はお互いどんどん取り入れていって貰えると、ユーザーとしてもアプリ開発者としても嬉しいですね :)

WindowsPhoneについて

ReactNativeのお話にて登場したWindowsPhone。

気にはなりつつも未だ手は出せていませんorz 中国だとGoogleのサービスが片っ端から切られているものの、Microsoftのサービス(bingとか)はほぼそのまま使えるので、もう少し流行っても良さそうですが、 小米など中国内のメーカーの方がやっぱり強そうでした。

開発チーム縮小みたいな話も聞こえたりしますが、できれば第3のスマートフォンとしてもう少し頑張って欲しいところ。

最後に

話についていけたいけない、次回に向けて挑戦したい課題など色々ありましたが、やはり参加できて発表できたことで学べたことは多く、刺激も受けられました。

スポンサーであり会場提供をしていただいたはてな様、主催者の方々、また参加者の皆様、ありがとうございました。

最後に、今回の開発のためにあちこちうろついて、ぼっちで開発していた時の写真を載せておきますw

時間オーバーしそうだったのでスライドからは外しましたが、ここに載せることで供養としたいと思います。

f:id:mslGt:20160526000127j:plain