20190521のKotlinに関する記事は5件です。

Android開発者のためのOCR入門(tess-two編)

はじめに

AndroidアプリでOCRを実装する際、TessaractのAndroid用ラッパーライブラリtess-twoを利用するか、またはML Kit for Firebaseを利用する方法があります。
今回は、前者のtess-twoを使ってギャラリーから画像を選択してその画像にある文字を読み取るアプリを作ってみたいと思います。

サンプルコードはここに置いてあります。

インストール

まずはtess-twoを入れます。また、画像をギャラリーから読み込む際にExifを読み取って画像の向きを正しい方向に直したいのでexifinterfaceも入れておきます。

app/build.gradle.kts
// 略
dependencies {
    implementation("com.rmtheis:tess-two:9.0.0")
    implementation("androidx.exifinterface:exifinterface:1.0.0")
}

続いてappディレクトリ以下に/assets/tessdata/ディレクトリを作成して言語データを配置します。
今回はこんな感じにしました。

app - assets - tessdata
                  |-- jpn.traineddata
                  `-- eng.traineddata

言語データは下記からダウンロードしてください。tess-twoはtesseract3.0.5以下で作成された言語データが必要です。
https://github.com/tesseract-ocr/tessdata/tree/3.04.00

実装

tess-twoの使い方自体は非常に簡単です。わずか5行程度です。

OCRUtil.kt
        val baseApi = TessBaseAPI()
        // initで言語データを読み込む
        baseApi.init(context.getFilesDir().toString(), "jpn")
        // ギャラリーから読み込んだ画像をFile or Bitmap or byte[] or Pix形式に変換して渡してあげる
        baseApi.setImage(bitmap)
        // これだけで読み取ったテキストを取得できる
        val recognizedText = baseApi.utF8Text
        baseApi.end()

ですが注意点が2つあります。

1つ目はbaseApi.initはの中では、言語データをFileでopenしているためFileで読み込める場所にコピーしてあげる必要があります。

OCRUtil.kt
    private val TESS_DATA_DIR = "tessdata" + File.separator
    private val TESS_TRAINED_DATA = arrayListOf("eng.traineddata", "jpn.traineddata")
    private fun copyFiles(context: Context) {
        try {
            TESS_TRAINED_DATA.forEach {
                val filePath = context.filesDir.toString() + File.separator + TESS_DATA_DIR + it

                // assets以下をinputStreamでopenしてbaseApi.initで読み込める領域にコピー
                context.assets.open(TESS_DATA_DIR + it).use {inputStream ->
                    FileOutputStream(filePath).use {outStream ->
                        val buffer = ByteArray(1024)
                        var read = inputStream.read(buffer)
                        while (read != -1) {
                            outStream.write(buffer, 0, read)
                            read = inputStream.read(buffer)
                        }
                        outStream.flush()
                    }
                }

                val file = File(filePath)
                if (!file.exists()) throw FileNotFoundException()
            }
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

2つ目はtess-twoで画像を読み込む場合、画像の向きが正しくないと当然読み込めません。ギャラリーから読み込む場合、画像が回転してしまっている場合があるのでExifを見て正しい方向に直す必要があります。

MainActivity.kt
    // 画像のuri
    uri?.let {
        contentResolver.openFileDescriptor(it, "r").use {parcelFileDescriptorNullable ->
            parcelFileDescriptorNullable?.let {parcelFileDescriptor ->
                val fileDescriptor = parcelFileDescriptor.fileDescriptor
                bitmapOrigin = BitmapFactory.decodeFileDescriptor(fileDescriptor)
                contentResolver.openInputStream(it).use {
                    it?.let {
                        // ExifInterfaceを初期化
                        val exifInterface = ExifInterface(it)
                        val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
                        val degrees = when (orientation) {
                            // 正しい方向の場合は回転させない
                            1 -> { 0f }
                            // 逆向きなので180度回転させる
                            3-> { 180f }
                            // 左向きの画像になってるので90度回転させる
                            6 -> { 90f }
                            // 右向きの画像になってるので270度回転させる
                            8 -> { 270f }
                            else -> { 0f }
                        }
                        val matrix = Matrix()

                        val imageWidth = bitmapOrigin?.getWidth() ?: 0
                        val imageHeight = bitmapOrigin?.getHeight() ?: 0
                        matrix.setRotate(degrees, imageWidth.toFloat() / 2, imageHeight.toFloat() / 2)
                        bitmap = Bitmap.createBitmap(bitmapOrigin!!, 0, 0, imageWidth, imageHeight, matrix, true)
                    }
                }
            }
        }
    }

ここまで実装したら早速動かしてみましょう。今回はこちらの画像から文字を読み取ってみます。

早速アプリを起動して画像を読み込んでみると…

無事、文字を読み取ることが出来るようになりました!が、、精度がいまいちです…

日本語言語データをカスタマイズ

先程の画像を見てみると数字が全然読み取れていないことに気がつくと思います。今回はこの数字の精度を改善してみたいと思います。

数字の誤変換の原因ですが、こちらのサイトを見てみると認識結果の変換マッピングが原因のようです。なのでjpn.unicharambigsを修正してみます。

今回、tesseract-ocrの環境をDockerで用意しました。こちらの環境を使ってカスタマイズします。
https://github.com/tarumzu/OCRSampleAndroid/tree/master/containers

まずは下記の手順でDockerコンテナにログイン

cd containers
docker-compose build
docker-compose up

docker-compose exec tesseract bash

ログインしたら、jpn.traineddataをダウンロード、次にcombine_tessdataコマンドのeオプションでjpn.traineddataからjpn.unicharambigsを取り出します。

wget https://github.com/tesseract-ocr/tessdata/raw/3.04.00/jpn.traineddata
combine_tessdata -e jpn.traineddata jpn.unicharambigs

取り出したらjpn.unicharambigsをvimで開いて下記の行を削除します。これは左側のキャラクタを右側のものに変換するというルールなのですが正直必要ない変換だと思います。

1   l   1   ー 1
1   | 1   ー 1
1   I   1   ー 1
1   1   1   ー 1
1   |   1   ー 1
1   O   1   。 1
1   °  1   。 1

ここまでできたらjpn.traineddataを再作成します。

combine_tessdata -o jpn.traineddata jpn.unicharambigs

では、先程作成した言語データはcontainersに保存されます。この言語データを使って再度画像を読み込んでみましょう。

たったこれだけで数字の精度が劇的に改善できました!

終わりに

簡単にOCRが実装できましたが言語データのカスタマイズは必須ですね…
次はML Kit for FirebaseでOCRを実装してみて違いなどを記事にしてみたいと思います。

参考

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

Jetpack Composeについて調査 文法編

はじめに

 Google I/O 2019で発表されたJetpack Composeについて調査しました。本記事ではその中でも特徴的な宣言的UIの構築を行うための文法についてまとめてみます。

注意点

本記事は5/21時点でpre-alpha段階のJetpack Composeについて調査を行なっており、今後の変更で大きくインターフェースが変わる可能性があります。

Jetpack Composeの文法

Hello Worldを書く

定番のHello Worldは以下のように書けます。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CraneWrapper {
                MaterialTheme {
                    val textColor = Color(0xFFFF0000.toInt())
                    Text(
                        text = "Hello World!",
                        style = +themeTextStyle { h2.copy(color = textColor) }
                    )
                }
            }
        }
    }
}

これは以下のように表示されます。

文字列を縦方向に二つ表示してみましょう。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CraneWrapper {
                MaterialTheme {
                    val textColor = Color(0xFFFF0000.toInt())
                    Column {
                        Text(
                            text = "Hello",
                            style = +themeTextStyle { h2.copy(color = textColor) }
                        )
                        Text(
                            text = "World!",
                            style = +themeTextStyle { h2.copy(color = textColor) }
                        )
                    }
                }
            }
        }
    }
}

これは以下のように表示されます。

宣言的にUIが構築されているので、結構読みやすいです。

Hello Worldの解説

上記Hello Worldに登場したJetpack Compose固有のワードについて解説します。

setContent

以下のようにActivityの拡張関数として実装されています。

fun Activity.setContent(composable: @Composable() () -> Unit) =
    setContentView(FrameLayout(this).apply { compose(composable) })

受け取ったcomposableな関数をFrameLayoutに描画して自身にsetしています。
基本的にはこの関数を使うことでJetpack Composeで書かれたUIを描画することになります。

CraneWrapper

Context, FocusManager, TextInputServiceを作り、Jetpack Composeの世界でこれらを使えるようにするためのwrapperです。上記Android上でのUI構築に必須なものを作る部分なので、setContent直下にこれを記述し、これに関数を渡すようにUIを設計しないと例外が発生してしまいます。

MaterialTheme

TextButtonなど、各種コンポーネントに適用できるマテリアルデザインなスタイルを提供します。

+themeTextStyle

上記MaterialThemeで提供されている文字に関するスタイルです。これに文字のスタイルを定義する関数を渡すことができます。
この辺りはKotlinDSLの利点が活かされており、適切なスタイル定義をIDEやコンパイラの支援を受けつつ簡単に書くことができるようになっています。
上記コードでは、h2スタイルの色だけを0xFFFF0000(赤色)に変更するようにスタイルを定義しています。

Text

文字列を表現する関数です。Androidで言うTextViewに相当します。

text

この名前で渡した引数がTextで表示される文字列になります。

style

この名前で渡した引数がTextで表示される文字列のスタイルになります。

Column

複数のコンポーネントを縦に並べて表示することができます。Androidのorientation=”vertical”なLinearLayoutに相当します。

コンポーネントを自作する

Jetpack Composeではコンポーネントの自作も簡単に行うことができます。試しに上記Hello Worldを以下のような仕様でコンポーネントにしてみます。

  • Hello World!と表示する
  • styleをパラメータとして適用できる

これは以下のように実装することができます。

@Composable
fun HelloWorld(style: TextStyle? = null) {
    Text(
        text = "Hello World!",
        style = style
    )
}

このようにして作られたコンポーネントは、以下のようにして使うことができます。

class MainActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CraneWrapper {
                MaterialTheme {
                    val textColor = Color(0xFFFF0000.toInt())
                    HelloWorld(
                        style = +themeTextStyle { h2.copy(color = textColor) }
                    )
                }
            }
        }
    }
}

コンポーネントの自作は非常に簡単で、@Composableを付与した関数を作るだけです。ただし、いくつか気をつけなければならない点もあり、

  • 副作用を起こさないようにする
  • 関数の引数として渡されたパラメータ以外は使わないようにする(例えばグローバル変数にアクセスしたりしない)

と行った注意点も存在します。

ステートを持つUIを作る

上記Hello Worldはステートを持たないシンプルなUIでした。
今度は「ボタンをタップする度にカウントアップする」と言う機能を持ったUIを作ってみます。
タップされた回数を保持するのでステートを必要とします。

class MainActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CraneWrapper {
                MaterialTheme {
                    CountableButton()
                }
            }
        }
    }
}

@Composable
fun CountableButton() {
    val count = +state { 0 }
    Column {
        Button(
            onClick = {
                count.value++
            },
            text = "Tap Me!!"
        )
        Text(
            text = "Tapped count ${count.value}",
            style = +themeTextStyle { h3 }
        )
    }
}

これは以下のように動作します

タップされた回数をカウントしていきます。上の例ではIntを使いましたが、@Modelを使うことで独自に定義した型を使うこともできます。独自の型を利用する形でCountableButtonコンポーネントを書き換えると以下のようになります。これは上記GIF画像と同じ動きをします。

@Composable
fun CountableButton() {
    val count = +state { CountModel() }
    Column {
        Button(
            onClick = {
                count.value.increment()
            },
            text = "Tap Me!!"
        )
        Text(
            text = "Tapped count ${count.value.num}",
            style = +themeTextStyle { h3 }
        )
    }
}

@Model
class CountModel {
    var num = 0
    fun increment() {
        num++
    }
}

ステートを持つUIの解説

上記ステートを持つUIに登場したJetpack Compose固有のワードについて解説します。

+state

この関数はStateインスタンスを作るトップレベル関数です。
この関数にはStateインスタンスが保持するパラメータを初期化する関数を与えることができます。
上記初期化式で初期化されたパラメータを保持したStateインスタンスを返します。

State

stateを管理するクラスです。上記+state関数で作ることができます。
valueプロパティを経由することで、内部で保持しているパラメータを参照、更新することができます。
このようにしてパラメータを更新すると、UIが再描画され、更新後のパラメータが適用されます。

@Model

stateを表現するクラスに付与しなければならないアノテーションです。
+state関数で作成したStateインスタンスで独自のクラスを保持させたい場合には、これを対象のクラスに付与しなければなりません。
これを付与し忘れた場合、コンパイルは通りますが、パラメータに変更があった際にUIが再描画されません。

KTX

以下のようなコードは

Text(text = "Hello World!")

このようにも書けます。

<Text text="Hello World!" />

このJSXのタグにも似た書き方はKTXと呼ばれているそうです。この書き方はKotlinのコンパイラを拡張する方法で実現されているそうです。しかし、KTXは関数呼び出しで記述する方法を開発する以前の古い記法らしく、現在はKTXからそちらに移行している最中だそうです。今後のことを考えるとこの書き方はしない方が良さそうです。

おわりに

Jetpack Composeについて調べてみました。
Androidの既存の書き方(いわゆる宣言的ではない方法)だと色々と辛いところもありましたが、Jetpack Composeが実用化されると、非常に簡単かつ苦しみを少なくしてUIを構築できそうです。
stableになるまでまだまだ時間はかかりそうですが、非常に期待できそうです。

参考

https://developer.android.com/jetpack/compose
https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/compose/README.md
https://medium.com/q42-engineering/android-jetpack-compose-895b7fd04bf4

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

[Kotlin] AlertDialogをカスタマイズする

AlertBuilderでAlertDialogをカスタマイズします。(カスタムレイアウト作成した方がおすすめですが)

・標準のAlertDialog
Screen Shot 2019-05-17 at 17.51.55.png

・成果物
Screen Shot 2019-05-17 at 17.51.41.png

ソースコード

AlertDialogFragment.kt
// 省略

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

    // ダイアログのタイトルのカスタマイズ
    val text = TextView(context)
    text.text = "Error"
    text.setPadding(0, 25, 0, 25)
    text.textSize = 20F
    text.textAlignment = View.TEXT_ALIGNMENT_CENTER
    text.setTextColor(resources.getColor(R.color.white, null))
    text.background = resources.getDrawable(R.color.green, null)

    val builder = activity?.let { AlertDialog.Builder(it) }

    // setTitleではなくsetCustomTitleを使う
    builder!!.setCustomTitle(text)
            .setMessage(message)
            .setPositiveButton(okButtonName, onOkClickListener)

    val dialog = builder.show()

    // OKボタンを中央揃えに
    val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
    button.gravity = Gravity.CENTER
    button.setTextColor(resources.getColor(R.color.colorWhite, null))
    button.setPadding(0, -30, 0, -30)
    button.background = resources.getDrawable(R.drawable.green_button, null)
    button.textSize = 15F

    val layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 55)
    layoutParams.gravity = Gravity.CENTER
    layoutParams.marginStart = 250
    layoutParams.marginEnd = 250
    layoutParams.topMargin = 20
    layoutParams.bottomMargin = 15
    button.layoutParams = layoutParams

    this.isCancelable = false

    return dialog
}

二つ以上のボタンがある時

AlertDialogFragment.kt
// 省略

        builder!!.setCustomTitle(text)
                .setMessage(message)
                .setPositiveButton("OK",onOkClickListener)
                .setNegativeButton("CANCEL",onCancelClickListener)
                .setOnDismissListener(onDismissListener)

        val dialog = builder.show()

        val okButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
        okButton.gravity = Gravity.CENTER
        okButton.setTextColor(resources.getColor(R.color.white, null))
        okButton.setPadding(70, -30, 70, -30)
        okButton.background = resources.getDrawable(R.drawable.green_button, null)
        okButton.textSize = 15F

        val layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, 55)
        layoutParams.gravity = Gravity.CENTER
        layoutParams.marginStart = 30
        layoutParams.marginEnd = 160
        layoutParams.topMargin = 20
        layoutParams.bottomMargin = 10
        okButton.layoutParams = layoutParams

        val cancelButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE)
        cancelButton.gravity = Gravity.CENTER
        cancelButton.setTextColor(resources.getColor(R.color.green, null))
        cancelButton.setPadding(50, -30, 50, -30)
        cancelButton.textSize = 15F

        val layoutParams2 = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, 55)
        layoutParams2.gravity = Gravity.CENTER
        layoutParams2.marginStart = 60
        layoutParams2.marginEnd = 30
        layoutParams2.topMargin = 20
        layoutParams2.bottomMargin = 10
        cancelButton.layoutParams = layoutParams2

// 省略

成果物

Screen Shot 2019-05-17 at 17.51.49.png

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

Android GoogleMapの使い方メモ

はじめに

最近プロジェクトでGoogleMapAPIを利用したので、細かな使い方を記事として残しておきます。

プロジェクト作成〜地図の表示

AndroidStudioのプロジェクトウィザードで簡単に作れます。
1.png

生成された、google_maps_api.xml を開き、YOUR_KEY_HERE を更新します。

2.png

キーは https://console.developers.google.com から取得します。

3.png

ビルドして実行すると、このように表示されます。
4.png

UIの設定

移動

スワイプで地図を平行移動できます。

googleMap.uiSettings.isScrollGesturesEnabled = true

ズーム

ピンチイン・アウトで縮尺を変更できます。

googleMap.uiSettings.isZoomGesturesEnabled = true

回転

ピンチからの回転で地図が回転できます。

googleMap.uiSettings.isRotateGesturesEnabled = true

ズームボタン

画面右下にズーム変更ボタンが表示されます。

10.png

googleMap.uiSettings.isZoomGesturesEnabled = true

ツールバー

マーカーをタップした時に、
画面の右下に表示されるGoogleMapのユーザインターフェイスです。

googleMap.uiSettings.isMapToolbarEnabled = true

5.png

ズームボタンと同時に表示するとこのように表示されます。

11.png

ティルト

2本指でスワイプで視点を傾けることができます。

googleMap.uiSettings.isTiltGesturesEnabled = true

6.png

コンパス

画面左上の表示されます。

googleMap.uiSettings.isCompassEnabled = true

7.png

現在地表示

ACCESS_COARSE_LOCATION または ACCESS_FINE_LOCATION
の許可を取得しておく必要があります。

googleMap.isMyLocationEnabled = true

9.png

現在地ボタン

画面の右上に表示されます。
タップすると現在地にカメラと縮尺が調整されます。

googleMap.isMyLocationButtonEnabled = true

8.png

カメラの移動

// アニメーションなし
googleMap.moveCamera(CameraUpdateFactory.newLatLng(latLng))

// アニメーションあり
googleMap.animateCamera(CameraUpdateFactory.newLatLng(sydney))

カメラの縮尺変更

val zoomValue = 14.0f // 1.0f 〜 21.0f を指定
googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, zoomValue))

おおよその詳細レベル

1: World
5: Landmass/continent
10: City
15: Streets
20: Buildings

ズームレベルの詳細はこちらを参考に
https://developers.google.com/maps/documentation/android-sdk/views
https://qiita.com/SnowMonkey/items/795779913be692c12a0b

マーカー表示

val sydney = LatLng(-34.0, 151.0)
googleMap.addMarker(
    MarkerOptions()
        .position(sydney)             // 地図上のマーカーの位置
        .title("Marker in Sydney")    // マーカーをタップ時に表示するテキスト文字列
        .snippet("Australian cities") // タイトルの下に表示される追加のテキスト
        .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE)) // アイコン
)

12.png

また、マーカーをタップしなくても吹き出しを表示することも出来る。

val marker = googleMap.addMarker(
    MarkerOptions()
        .position(latLng)
        .title("test")
        .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED))
)
marker.showInfoWindow() // タップした時と同じ挙動

円の描画

val latLng = LatLng(35.681236, 139.767125) // 東京駅
val radius = 1000 * 10.0 // 10km
googleMap.addCircle(
    CircleOptions()
        .center(latLng)          // 円の中心位置
        .radius(radius)          // 半径 (メートル単位)
        .strokeColor(Color.BLUE) // 線の色
        .strokeWidth(2f)         // 線の太さ
        .fillColor(0x400080ff)   // 円の塗りつぶし色
)

13.png

線の描画

googleMap.addPolyline(
    PolylineOptions()
        .add(LatLng(35.681236, 139.767125)) // 東京駅
        .add(LatLng(34.7331, 135.5002))     // 新大阪駅
        .color(Color.RED)                   // 線の色
        .width(8f)                          // 線の太さ
)

14.png

2点間に距離を計算(SphericalUtil)

距離を簡単に計算するのに必要なユーティリティライブラリを追加します。
https://developers.google.com/maps/documentation/android-sdk/utility/

/app/build.gradle
dependencies {
  ...
  // add
  implementation 'com.google.maps.android:android-maps-utils:0.5'
}

検証として、東京駅新大阪駅 の緯度経度を使います。

val latLngA = LatLng(35.681236, 139.767125)
val latLngB = LatLng(34.7331, 135.5002)

// 距離をメートル単位で返す
val distance = SphericalUtil.computeDistanceBetween(latLngA, latLngB)

println("AB: $distance m")
println("AB: ${distance / 1000} km")

出力

AB: 401709.92548952583 m
AB: 401.70992548952586 km

以下は、PCのブラウザでGoogleMapで距離を測った結果です。

ab.png

2点間に距離を計算(Location)

ユーティリティライブラリを入れなくても、Locationクラスで2点間の距離を計算できます。
簡単に呼び出せるように以下のExtensionを定義しておきます。

fun LatLng.distanceBetween(toLatLng: LatLng): Float {
    val results = FloatArray(1)
    try {
        Location.distanceBetween(
            this.latitude, this.longitude,
            toLatLng.latitude, toLatLng.longitude,
            results
        )
    } catch (e: IllegalArgumentException) {
        return -1.0f
    }
    return results[0]
}

呼び出し元

val latLngA = LatLng(35.681236, 139.767125)
val latLngB = LatLng(34.7331, 135.5002)

// 距離をメートル単位で返す
val distance = latLngA.distanceBetween(latLngB)

println("AB: ${latLngA.distanceBetween(latLngB)} m")
println("AB: ${latLngA.distanceBetween(latLngB) / 1000} km")

SphericalUtilと比較した結果、こちらは少し誤差が出るようです。

 AB: 402483.6 m
 AB: 402.48358 km

まとめ

本記事で記述しているのはGoogleMapAPIの中でもごく一部です。
忘れた頃にいちいちググりにいくのが面倒なので、ひとまずまとめておきました。

公式資料

https://developers.google.com/android/reference/com/google/android/gms/maps/GoogleMap
http://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/SphericalUtil.html

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

いまさらWorkManager〜基本編③(繰り返し処理を実行する)〜

はじめに

この記事で説明した通り、様々な機能を活用していきましょう。
今回は、繰り返し処理を設定する方法を学習していきます。

動作環境

この記事の動作環境は以下のとおりです。

  • Android Studio:3.3
  • Kotln:1.3.11
  • Open JDK:1.8
  • compileSdkVersion:28
  • targetSdkVersion:28
  • minSdkVersion:19

目標

繰り返して処理を実行出来るようになる!

概要

繰り返し処理を実行したい場合は、PeriodicWorkRequestクラスを利用します。
連続して実行は出来ませんが、ある一定時間(インターバル)を置いて実行が可能です。
イメージとしてはこんな感じでしょうか。

PeriodicWorkRequest.png

実際に一定のインターバルでノーティフィケーションを表示する処理をすると下図のような実行結果になります。

実装方法

基本的な実装方法はOnetimeWorkRequestと同じですが、最後のRequestオブジェクトを作成する時に、インターバルなどの設定を行います。

PeriodicWorkRequestオブジェクトを生成するには、Builderクラスを利用します。
そのコンストラクタで、インターバルを設定します。

共通の条件

インターバルの最小時間は決まっています。 PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS 以上でなければなりません。
定義を確認してみると。。。

public final class PeriodicWorkRequest extends WorkRequest {

    /**
     * The minimum interval duration for {@link PeriodicWorkRequest} (in milliseconds).
     */
    public static final long MIN_PERIODIC_INTERVAL_MILLIS = 15 * 60 * 1000L; // 15 minutes.
 〜〜〜省略〜〜〜〜
}

となっていました。
なので、最低でも 15分 以上は開ける必要があります。

PeriodicWorkRequest.Builder(Class<? extends ListenableWorker> workerClass, long repeatInterval, TimeUnit repeatIntervalTimeUnit)

コンストラクタの引数

引数 値と説明
第1引数 Workerクラスのクラス情報
第2引数 インターバルの時間
第3引数 時間の単位

説明

APIの説明を読むと以下のように記述されています。

引数で指定したインターバル期間に実行するPeriodicWorkRequeクラスのオブジェクトを生成します。
PeriodicWorkRequestはインターバルの期間中に1度だけ実行されることが保証されています。
バッテリーや端末の状況によっては、その影響を受ける可能性はあります。

実行する処理は15以上間隔を開ける必要があります。それ以外の条件が満たされていれば、その期間中に実行可能状態になります。また、実行する処理の時間はフレックス期間を設定し、制限することも可能です。

ということは、インターバル期間中に実行が可能なれば実行できるみたいです。
きっちり15分間とかではなく、その15分間の中で実行されるイメージみたいです。
分かりづらいですが、絵にすると下図のような感じになります。

PeriodicWorkRequest_実行タイミング.png

また、フレックスについては、後述します。

実装例

val periodicWork = PeriodicWorkRequest.Builder(
    MyWorker::class.java,
    15, TimeUnit.MINUTES
).build()

public PeriodicWorkRequest.Builder (Class<? extends ListenableWorker> workerClass, Duration repeatInterval)

コンストラクタの引数

引数 値と説明
第1引数 Workerクラスのクラス情報
第2引数 インターバルの時間と単位

説明

基本的には、上記に記載した「PeriodicWorkRequest.Builder(Class<? extends ListenableWorker> workerClass, long repeatInterval, TimeUnit repeatIntervalTimeUnit)」と同じです。
違いとしては、インターバルの時間をDurationクラスのメソッドを使用して、時間と単位を一度に設定できることです。

実装例

val periodicWork = PeriodicWorkRequest.Builder(
    MyWorker::class.java,
    Duration.ofMinutes(15)
).build()

PeriodicWorkRequest.Builder(Class<? extends ListenableWorker> workerClass, long repeatInterval, TimeUnit repeatIntervalTimeUnit, long flexInterval, TimeUnit flexIntervalTimeUnit)

コンストラクタの引数

引数 値と説明
第1引数 Workerクラスのクラス情報
第2引数 インターバルの時間
第3引数 インターバルの時間の単位
第4引数 フレックスの時間
第5引数 フレックスの時間の単位

説明

インターバルの時間内にフレックス時間を設け、そのフレックス時間内に処理が実行されます。
フレックス時間は下記で計算された時間からインターバル終了までとします。

フレックス開始時間 = インターバル時間 ー フレックス時間

また、インターバルの時間は最低でもPeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS(15分以上) 以上、フレックスの時間は、最低でもPeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS(15以上)に設定しなければなりません。

これだけだといまいちピンとこなかったので、図にしてみました。

PeriodicWorkRequest_フレックス.png

要するに、インターバルの中にフレックスがありそのフレックスの時間の間で処理を実行します。
フレックス時間の終了はインターバルの終了時間と同じであるため、インターバルの最初の方で処理が実行されることはありません。

実装例

val periodicWork = PeriodicWorkRequest.Builder(
    MyWorker::class.java,
    20, TimeUnit.MINUTES, // インターバルの時間
    15, TimeUnit.MINUTES  // フレックスの時間
).build()

PeriodicWorkRequest.Builder(Class<? extends ListenableWorker> workerClass, Duration repeatInterval, Duration flexInterval)

コンストラクタの引数

引数 値と説明
第1引数 Workerクラスのクラス情報
第2引数 インターバルの時間と単位
第3引数 フレックスの時間と単位

説明

基本的な内容はPeriodicWorkRequest.Builder(Class<? extends ListenableWorker> workerClass, long repeatInterval, TimeUnit repeatIntervalTimeUnit, long flexInterval, TimeUnit flexIntervalTimeUnit)と同じです。
違いとしては、Durationクラスを利用して、時間と単位を一気に設定します。

実装例

val periodicWork = PeriodicWorkRequest.Builder(
    MyWorker::class.java,
    Duration.ofMinutes(20), // インターバルの時間
    Duration.ofMinutes(15) // フレックスの時間
).build()

動作確認をした際のコード

最後に、動作確認した際のコードをすべて掲載します。
AndroidManifest.xmlはプロジェクトのテンプレートから変更していないため、掲載はしません。

build.gradle
apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "jp.co.casareal.workmanagerperiodicwork"
        minSdkVersion 19
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
    def work_version = "1.0.0"

    implementation "android.arch.work:work-runtime-ktx:$work_version"

}
MyWork.kt
package jp.co.casareal.workmanagerperiodicwork

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.support.v4.app.NotificationCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.text.SimpleDateFormat

class MyWorker(cxt: Context, params: WorkerParameters) : Worker(cxt, params) {
    val notificationManager =
        applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    // カテゴリー名(通知設定画面に表示される情報)
    val name = "通知のタイトル的情報を設定"
    // システムに登録するChannelのID
    val id = "casareal_chanel"
    // 通知の詳細情報(通知設定画面に表示される情報)
    val notifyDescription = "この通知の詳細情報を設定します"

    private val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")

    companion object {
        var nid = 1;
    }


    init {
        // Channelの取得と生成
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            notificationManager.getNotificationChannel(id) == null
            val mChannel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH)
            mChannel.apply {
                description = notifyDescription
            }
            notificationManager.createNotificationChannel(mChannel)
        }
    }

    override fun doWork(): Result {


        val notification = NotificationCompat.Builder(applicationContext, id).apply {

            setContentText("${nid}回目のメッセージ:${simpleDateFormat.format(System.currentTimeMillis())}")
            setSmallIcon(R.drawable.ic_launcher_background)
        }

        notificationManager.notify(MyWorker.nid, notification.build())

        MyWorker.nid++

        return Result.success()
    }
}
MainActivity.kt
package jp.co.casareal.workmanagerperiodicwork

import android.os.Build
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import kotlinx.android.synthetic.main.activity_main.*
import java.time.Duration

class MainActivity : AppCompatActivity() {


    private val manager = WorkManager.getInstance()

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



        buttonStart.setOnClickListener {

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                val periodicWork = PeriodicWorkRequest.Builder(
                    MyWorker::class.java,
                    Duration.ofMinutes(15)
                ).build()

                val operation = manager.enqueue(periodicWork)

            }


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

    <Button
            android:id="@+id/buttonStart"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Hello World!"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

</android.support.constraint.ConstraintLayout>

まとめ

繰り返し実行する場合は、インターバルの時間を設定することが大事なようです。
そこで、インターバルの時間を開始してから実行を遅らせるためにフレックスの時間を利用することによって適切なタイミングでの実行が可能になっていることがわかりました。

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