20200727のAndroidに関する記事は18件です。

kaptでjavaの予約語のプロパティを使わないほうがいい

TL;DR

kotlin -> javaのcode generationを行うときは変数名にjavaの予約語は使わないほうがいいよ

kaptとは

  • javaのAnnotation processorsをkotlinでサポートするpluginのこと
  • android開発ではおなじみのdagger2やDataBindingで使用することが多い
  • アノテーションを付けたクラスを付けてkaptを走らせるとアノテーションを読み取ってコードを自動生成してくれる

見つかった経緯

  • androidの公式ORMのRoomを使用していてぶつかりました
  • ある公開APIの返却のレスポンスjsonでキー名がcatchというものをその定義のままRoomを使用してローカルDBに保存しようとしてたらビルドがこけて調べました

Room

  • Roomはgoogleが作っているSQLiteを抽象化してくれるライブラリです
  • 公式HP
  • Roomは下記のように@Entityがついているclassをテーブル定義としてkaptでDAO等を自動生成します
@Entity
data class StringEntity(
    @PrimaryKey(autoGenerate = false)
    val id: Int,

    val catch: String
)

確認方法

  • サンプルプロジェクトを作って試しました
  • entityを用意しました
  • entityの変数名をcatchにしてビルド
  • 下記のようにビルド失敗になりました
エラー: Entities and POJOs must have a usable public constructor. You can have an empty constructor or a constructor whose parameters match the fields (by name and type).
public final class StringEntity {
             ^
  Tried the following constructors but they failed to match:
  StringEntity(int,java.lang.String) -> [param:id -> matched field:id, param:catch -> matched field:unmatched]\room-catch\app\build\tmp\kapt3\stubs\debug\com\ryunen344\room\column\db\entity\StringEntity.java:9: エラー: Cannot find setter for field.
    private final int id = 0;

原因

  • kaptでkotlinのコードから情報を読み取ってjavaのコードにする際にjavaの予約語とぶつかるため失敗するようです
  • 既に同様(catchではなくabstract)の問題でissueあげて、won't fix張られてるものがありました
  • void, catch, abstract, javaのprimitive型名にするとkaptが失敗することまでは確認しました

結論

kaptだけでなくkotlinを書くときはjavaを意識して書いたほうが良い(当たり前っちゃ当たり前)

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

Android 11でShortcutBadgerを使えるようにする

ShortcutBadgerが使えない!

Android 11対応を行っていたところ、バッジの更新が出来なくなっていることに気が付きました。
アプリは ShortcutBadger を使用してバッジの更新をしていました。

原因と対策

原因は Android 11 から追加された Package Visibility によって、他のパッケージへのアクセスが制限されたことによるものでした。

対応は、ShortcutBadgerのintentを許可するよう、マニフェストファイルに追加するだけです。

AndroidManifest.xml
<manifest ...>
    <application>
        ...
    </application>

    <queries>
        <intent>
            <action android:name="android.intent.action.BADGE_COUNT_UPDATE"/>
        </intent>
    </queries>
</manifest>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unit Test 探求記(その3)

はじめに

前回は、build.gradle から build.gradle.kts への書き換えを行いました。

今回は、LiveData のテストを行ってみようと思います。

テスト対象

今回は、Transformations#map(LiveData, Function) に対して複数の LiveData を受けられるようにしたメソッドを作成して、それをテスト対象とします。

◆ TransformationsUtils

src

TransformationsUtils
package com.objectfanatics.commons.androidx.lifecycle

import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData

object TransformationsUtils {
    /**
     * @see androidx.lifecycle.Transformations.map
     */
    @MainThread
    fun <T> map(vararg sources: LiveData<out Any?>, function: () -> T): LiveData<T> =
        MediatorLiveData<T>().apply {
            sources.forEach { source ->
                addSource(source) {
                    value = function()
                }
            }
        }
}

テスト構想

テストを作りこむ前に、テストの大まかな流れをコーディングしてみます。

◆ TransformationsUtilsTest(失敗)

src

TransformationsUtilsTest.kt
package com.objectfanatics.commons.androidx

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.objectfanatics.commons.androidx.lifecycle.TransformationsUtils
import org.junit.Test

class TransformationsUtilsTest {
    @Test
    fun map() {
        // 3 つの MutableLiveData を用意
        val input1: MutableLiveData<String?> = MutableLiveData()
        val input2: MutableLiveData<String?> = MutableLiveData()
        val input3: MutableLiveData<String?> = MutableLiveData()

        // 上記 3 つの MutableLiveData を受けて文字列を返す LiveData を用意
        val output: LiveData<String> = TransformationsUtils.map(input1, input2, input3) {
            "${input1.value}, ${input2.value}, ${input3.value}"
        }

        // 実行結果が標準出力に表示されるように準備
        output.observeForever { value ->
            println("value = $value")
        }

        // テストの実行
        input1.postValue("red")
        input2.postValue("blue")
        input3.postValue("green")
    }
}

これを実行してみると、以下のようなエラーになりました。

java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details.

◆ build.gradle.kts

android.os.Looper#getMainLooper() の値は Android の実行環境が Looper にセットするものなので、unit test 環境では利用できません。そこで、以下のように対応します。

src

build.gradle.kts差分
val archVersion = "2.1.0"
dependencies {
    // @see https://developer.android.com/jetpack/androidx/releases/lifecycle
    // optional - Test helpers for LiveData
    testImplementation("androidx.arch.core:core-testing:$archVersion")
}

◆ TransformationsUtilsTest(成功)

src

TransformationsUtilsTest.kt差分
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import org.junit.Rule
import org.junit.rules.TestRule

class TransformationsUtilsTest {
    @Rule
    @JvmField
    val rule: TestRule = InstantTaskExecutorRule()
}

@JvmField を忘れると org.junit.internal.runners.rules.ValidationError: The @Rule 'rule' must be public. と怒られます。

◆ 実行結果

テストの実行により、以下の文字列が表示されました。

value = red, null, null
value = red, blue, null
value = red, blue, green

テスト実装

red, blue, green の内容だとサンプルとして味気ないので、テストの内容を以下のように変更しました。1

  • 場面設定
    • 名前と苗字を入力するための入力フォームがある。
    • 送信ボタンは、名前と苗字の両方が空でない場合のみ押すことができる。
  • 仕様
    • 名前入力用の MutableLiveData がある。
    • 苗字入力用の MutableLiveData がある。
    • 送信ボタンの押下可否を表す LiveData を TransformationsUtils.map() によって作成する。
  • シナリオ
    • 最初は、名前も苗字も null.
    • 名前を入力する。苗字が null なので送信ボタンは押せない。
    • 苗字も入力する。名前も苗字も空でないので、送信ボタンは押せる。

◆ kotlin:build.gradle.kts

mockk への依存関係を追加します。

src

build.gradle.kts差分
dependencies {
    // for mockk
    // @see https://mockk.io/#installation
    testImplementation("io.mockk:mockk:1.10.0")
}

◆ TransformationsUtilsTest

結果が表示されるのではなく、結果を検証するようにコードを変更します。

src

TransformationsUtilsTest.kt抜粋
class TransformationsUtilsTest {
    @Rule
    @JvmField
    val rule: TestRule = InstantTaskExecutorRule()

    /**
     * フォーム(名前、苗字)の両方が空でない時にのみ送信ボタンが押せるという内容のテストケース。
     */
    @Test
    fun map() {
        // テストの実行経過を記録する Recorder の用意
        val recorder = mockk<Recorder<Boolean>>(relaxed = true)

        // 名前、苗字の MutableLiveData を用意
        val firstName: MutableLiveData<String?> = MutableLiveData()
        val lastName: MutableLiveData<String?> = MutableLiveData()
        val forms = listOf(firstName, lastName)

        // 送信ボタンが押せるかどうかの MutableLiveData を用意。
        // ※名前、苗字の両方が空でない場合のみ true になる
        val isSubmitButtonEnabled: LiveData<Boolean> =
            TransformationsUtils.map(firstName, lastName) {
                !forms.any { it.value.isNullOrEmpty() }
            }

        // 実行経過が Recorder に記録されるように準備
        isSubmitButtonEnabled.observeForever { value ->
            recorder.record(value)
        }

        // テストの実行
        firstName.postValue("シャミ子")
        lastName.postValue("吉田")

        // 実行内容の検証
        verify {
            // firstName が "シャミ子" になったが lastName が null のため false
            recorder.record(false)
            // firstName が "シャミ子"、lastName が "吉田" になったため true
            recorder.record(true)
        }

        // 実行内容の検証がすべて完了したことの確認
        confirmVerified(recorder)
    }

    interface Recorder<T> {
        fun record(value: T)
    }
}

テスト結果

下記のように、テストに成功しました。
test_resut.png

おわりに

今回は、mockk を用いて LiveData の unit test を行いました。


  1. 本来はこのようなシナリオにするのは良くないのですが、わかりやすさのためにあえてやりました。後悔はしてない。 

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

中華の闇 携帯電話

4月1日から使い始めた中華製のスマホUMIDIGI X ついに壊れました。(7月26日現在)

image.png

115日間で壊れたことになります。

壊れた場所は、type cのソケット毎日、PCに接続して充電したり、Visual studioのadb に接続していた。

原因は、ケーブル側のソケットの形状が悪いために、本体側のソケットが破損される。

アマゾンで購入すると1年間の保証付きである。

当然修理依頼のチャットを出した。らちが明かないので、電話した。

なんだ修理してくんない!,金返すから送れになった。

中国人女性は、電話の向こうで賢そうな応対してくれた。(ハッカーに対して高圧的なしゃべり)
日本語勉強しても、日本的な女性的なものの勉強がたりない。
要するに、通常の使い方していたかいなかを論破しようとした。
この電話を使い始めた理由は、ジャーナリストから中華スマホで個人情報抜かれているエビデンス作ることが目的である。

中華スマホは、個人情報を盗むか?

コロナアプリで 日本政府に個人情報を盗まれました。

googleに個人情報を盗まれました。

UmidigiにUpdateする際に位置情報と電話番号盗まれました。

Rakuten unlimitに位置情報 盗まれました。

家内に顔認証を破られました。

個人的感想は、そんなチップ入ってないし、OSもGoogleにおんぶにだっこみたいな携帯だった。

AmazonのUmudigi担当に勧められるままにS5Proを注文した。
image.png
Mediatek Helio G90T 2GHz 8 Core Arm めちゃくちゃはやい。
富岳と同じ系統のCPU RISC(単純命令セット)パケットモニターするのにUserLandを使っているんだけど相性抜群によい。
image.png
携帯でLinux/Ubuntuとかpython,C,Fortranまで使える。
結局、1万円高いデバイス買わされた。

まあいっか~! Rasberry pi4が買えなくなった!こうなったらUSBにGPIOつけたら、無停電付きのRaspiにしてやるぞ。

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

Unit Test 探求記(その2)

はじめに

前回は、Android Studio を用いて自動生成されたプロジェクトの unit test を確認してみました。

  • Android Studio から Android プロジェクトを自動生成
  • unit test と instrumented unit test を実行
  • 依存関係の確認

今回は、build.gradle を build.gradle.kts に書き換えてみようと思います。

root

◆ build.gradle

src

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
    ext.kotlin_version = "1.3.72"
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:4.0.1"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

◆ build.gradle.kts

src

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
    val kotlinVersion by extra { "1.3.72" }
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath("com.android.tools.build:gradle:4.0.1")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task("clean", Delete::class) {
    delete = setOf(rootProject.buildDir)
}

app

◆ build.gradle

src

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 29

    defaultConfig {
        applicationId "com.objectfanatics.myapplication"
        minSdkVersion 23
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"

        // JUnit 4 のテストクラス群を利用するため、AndroidJUnitRunner をデフォルトの test instrumentation runner としてセットしている。
        // instrumented unit test とは実機(やエミュレータ)上で実行される unit test のこと。
        testInstrumentationRunner "androidx.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:$kotlin_version"
    implementation 'androidx.core:core-ktx:1.3.0'
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'

    // 通常の unit test から利用される JUnit 4 のクラス群への依存。※ org.junit.Test 等
    testImplementation 'junit:junit:4.12'

    // instrumented unit test から利用される JUnit 4 のクラス群への依存。※ AndroidJUnit4, ActivityScenarioRule 等
    androidTestImplementation 'androidx.test.ext:junit:1.1.1'

    // Espresso testing framework への依存。
    // Espresso testing framework は UI テストのためのフレームワーク。
    // @see https://developer.android.com/training/testing/ui-testing/espresso-testing
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

}

◆ build.gradle.kts

src

val kotlinVersion: String? by extra

plugins {
    id("com.android.application")
    kotlin("android")
    id("kotlin-android-extensions")
}

android {
    compileSdkVersion(29)

    defaultConfig {
        applicationId = "com.objectfanatics.myapplication"
        minSdkVersion(23)
        targetSdkVersion(29)
        versionCode = 1
        versionName = "1.0"

        // JUnit 4 のテストクラス群を利用するため、AndroidJUnitRunner をデフォルトの test instrumentation runner としてセットしている。
        // instrumented unit test とは実機(やエミュレータ)上で実行される unit test のこと。
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        getByName("release") {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}

dependencies {
    implementation(fileTree("dir" to "libs", "include" to listOf("*.jar")))
    implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
    implementation("androidx.core:core-ktx:1.3.0")
    implementation("androidx.appcompat:appcompat:1.1.0")
    implementation("androidx.constraintlayout:constraintlayout:1.1.3")

    // 通常の unit test から利用される JUnit 4 のクラス群への依存。※ org.junit.Test 等
    testImplementation("junit:junit:4.12")

    // instrumented unit test から利用される JUnit 4 のクラス群への依存。※ AndroidJUnit4, ActivityScenarioRule 等
    androidTestImplementation("androidx.test.ext:junit:1.1.1")

    // Espresso testing framework への依存。
    // Espresso testing framework は UI テストのためのフレームワーク。
    // @see https://developer.android.com/training/testing/ui-testing/espresso-testing
    androidTestImplementation("androidx.test.espresso:espresso-core:3.2.0")

}

おわりに

今回は、build.gradle から build.gradle.kts への書き換えを行ってみました。

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

Unit Test 探求記(その1)

はじめに

Unit test に関して、あまりトピックを絞らずに、まったりと実験しつつ、その過程を綴ってみようと思います。

新規プロジェクトの作成

まずは、Android Studio にて、新規のプロジェクトを作ってみました。1

Unit Test の実行

ここで言う unit test とは、src/test 以下のテストのことを指しています。

わざと間違えて、テストが実行されていることを確認してみます。2

class ExampleUnitTest {
    @Test
    fun addition_isCorrect() {
        // assertEquals(4, 2 + 2)

        // わざと間違えてみる。
        assertEquals(5, 2 + 2)
    }
}
$ ./gradlew clean test --info

com.objectfanatics.myapplication.ExampleUnitTest > addition_isCorrect FAILED
    java.lang.AssertionError: expected:<5> but was:<4>
        at org.junit.Assert.fail(Assert.java:88)
        at org.junit.Assert.failNotEquals(Assert.java:834)
        at org.junit.Assert.assertEquals(Assert.java:645)
        at org.junit.Assert.assertEquals(Assert.java:631)
        at com.objectfanatics.myapplication.ExampleUnitTest.addition_isCorrect(ExampleUnitTest.kt:18)

ちゃんとテストが失敗していることを確認できました。

Instrumented Unit Test の実行

ここで言う instrumented unit test とは、src/androidTest 以下のテストのことを指しています。

わざと間違えて、テストが実行されていることを確認してみます。3

@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
    @Test
    fun useAppContext() {
        // Context of the app under test.
        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
        // assertEquals("com.objectfanatics.myapplication", appContext.packageName)
        fail("I made a mistake on purpose.")
    }
}

実機を接続して、実行:

$ ./gradlew clean connectedAndroidTest

> Task :app:connectedDebugAndroidTest
Starting 1 tests on ASUS_X00PD - 8.0.0

com.objectfanatics.myapplication.ExampleInstrumentedTest > useAppContext[ASUS_X00PD - 8.0.0] FAILED
        java.lang.AssertionError: I made a mistake on purpose.
        at org.junit.Assert.fail(Assert.java:88)

> Task :app:connectedDebugAndroidTest FAILED

ちゃんとテストが失敗していることを確認できました。

依存関係を確認してみる

以下に、テスト関連の設定を抜粋し、コメントしてみます。4

android {
        // JUnit 4 のテストクラス群を利用するため、AndroidJUnitRunner をデフォルトの test instrumentation runner としてセットしている。
        // instrumented unit test とは実機(やエミュレータ)上で実行される unit test のこと。
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
}

dependencies {
    // 通常の Unit Test から利用される JUnit 4 のクラス群への依存。※ org.junit.Test 等
    testImplementation 'junit:junit:4.12'

    // instrumented unit test から利用される JUnit 4 のクラス群への依存。※ AndroidJUnit4, ActivityScenarioRule 等
    androidTestImplementation 'androidx.test.ext:junit:1.1.1'

    // Espresso testing framework への依存。
    // Espresso testing framework は UI テストのためのフレームワーク。
    // @see https://developer.android.com/training/testing/ui-testing/espresso-testing
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' を外しても IDE 上で警告は出ませんが、実行時に以下のようになり、instrumented unit test が失敗します:

$ ./gradlew clean connectedAndroidTest

> Task :app:connectedDebugAndroidTest
Starting 0 tests on ASUS_X00PD - 8.0.0
Tests on ASUS_X00PD - 8.0.0 failed: Instrumentation run failed due to 'Process crashed.'

com.android.build.gradle.internal.testing.ConnectedDevice > No tests found.[ASUS_X00PD - 8.0.0] FAILED
No tests found. This usually means that your test classes are not in the form that your test runner expects (e.g. don't inherit from TestCase or lack @Test annotations).

> Task :app:connectedDebugAndroidTest FAILED

FAILURE: Build failed with an exception.

エラーの表示から依存関係の指定漏れであることを推測するのは難しそうですね、、、。

おわりに

今回は、Android Studio がデフォルトで生成した Android プロジェクトの unit test 関連コードを確認してみました。

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

AWS CodeBuildでFlutterのAdd-to-app向けのMavenリポジトリを作成する。

先日のGDG Tokyo Flutter Meetup既存アプリにFlutterで新機能を追加しようというセッションに感銘を受けて、FlutterのAdd-to-appを始めました。そのセッションでは

モジュールからAARをビルドして既存アプリに追加
社内Mavenリポジトリを運用していてそこに相乗りするなどのケース

という説明があり、MavenリポジトリにAARを置くとネイティブアプリが簡単にビルドできるようになるのでやってみました。

該当セッションのスライドはこちらです。(Google Drive)
既存アプリにFlutterで新機能を追加しよう

今回のゴール

公式のAdd-to-appの解説ではbuild.gradleファイルの repositories 設定に相対パスを設定していますが、それを独自に作成したMevenリポジトリのURLにします。

app/build.gradle
repositories {
  maven {
    // 今回作るMavenリポジトリのURL
    url 'https://tfandkusu-maven.s3-ap-northeast-1.amazonaws.com/QuickEchoSoundMemo'
  }
  maven {
    url 'https://storage.googleapis.com/download.flutter.io'
  }
}

dependencies {
    def flutter_module_version = '0.0.1'
    debugImplementation "jp.bellware.echo.sound_memo:flutter_debug:$flutter_module_version"
    profileImplementation "jp.bellware.echo.sound_memo:flutter_profile:$flutter_module_version"
    releaseImplementation "jp.bellware.echo.sound_memo:flutter_release:$flutter_module_version"
}

前提

  • AndroidネイティブなアプリにFlutterで作った画面を追加します。
  • FlutterモジュールのソースコードはGithubにホストします。
  • AARのビルドにAWS CodeBuildを使用します。
  • MavenリポジトリにAWS S3を使います。今回は静的なウェブサイトとして誰からでも読み取れる設定にします。

手順

Mavenリポジトリを構成するためのAWS S3バケットを作成する

AWS S3のコンソール画面を開きます。

バケットを作成 ボタンを押します。
スクリーンショット 2020-07-26 13.17.54.png

バケット名とリージョンを設定します。バケット名は世界全体でユニークである必要があります。

スクリーンショット 2020-07-26 13.18.25.png

今回は誰でも読み取れるMavenリポジトリを作るので、パブリックアクセスを許可します。

スクリーンショット 2020-07-26 13.19.37.png

あとは特に設定を変更せずに バケットを作成 ボタンを押します。

スクリーンショット 2020-07-26 16.36.11.png

作成したバケットに対して バケットポリシー を設定します。設置したファイルはすべて誰からでも読めるようにします。

スクリーンショット 2020-07-26 16.38.10.png

バケットポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicRead",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::tfandkusu-maven/*"
        }
    ]
}

公式のこちらの情報を参考しました。

AWS CodeBuildのビルドプロジェクトを作成する

AWS CodeBuildのコンソール画面を開きます。

ビルドプロジェクトを作成する ボタンを押します。

スクリーンショット 2020-07-26 12.57.44.png

プロジェクト名、説明を入力して ビルドバッジを有効にする にチェックをつけます。ビルドバッジは補足で解説します。

スクリーンショット 2020-07-26 12.59.56.png

Flutterモジュールのソースコードがどこにあるか指定します。今回はソースプロバイダにGitHubを選択して、GitHubに接続してGitHubのリポジトリを指定しました。

スクリーンショット 2020-07-26 13.01.47.png

ビルド発動の条件を設定します。今回はmasterブランチへのPUSHで発動する設定にしました。

スクリーンショット 2020-07-26 13.02.57.png

ビルドに使うDockerイメージなどの環境を設定します。
CodeBuild に用意されている Docker イメージにAndroid用はありますがFlutter用は無いので、カスタムイメージその他のレジストリDockerHubにあるcirrusci/flutterイメージを指定しました。flutterで検索したら一番ダウンロード数が多いイメージでした。

スクリーンショット 2020-07-26 13.14.39.png

ビルドコマンドの設定についてはデフォルトのソース直下にあるbuildspec.ymlファイルを参照する設定にしました。

スクリーンショット 2020-07-27 1.32.09.png

ビルドした結果の格納先を設定します。タイプAmazon S3 にして前の手順で作成したバケット名を入力します。 名前もMavenリポジトリのURLに含まれることになるので、わかりやすい名前をつけます。今回は静的なウェブサイト公開になるので アーティファクト暗号化の削除 にチェックを入れます。

image.png

buildspec.ymlを作成する

AWS CodeBuildはbuildspec.ymlファイルの内容に従ってビルドを実行します。今回はこのような内容にしました。

buildspec.yml
version: 0.2
phases:
  install:
    # flutterモジュールのpubspec.yamlからバージョンを取得するために
    # yqコマンドをインストールする
    commands:
      - apt update
      - apt install -y gnupg2
      - apt-key adv --keyserver keyserver.ubuntu.com --recv-keys CC86BB64
      - add-apt-repository -y ppa:rmescandon/yq
      - apt install -y yq
  build:
    # AARを作成する
    commands:
      - flutter build aar --build-number `yq r pubspec.yaml version`
artifacts:
  # アーティファクトとしてS3にコピーするディレクトリを指定する
  files:
    - '**/*'
  base-directory: build/host/outputs/repo

単純にAARを作成するだけならば、 flutter build aar を実行するだけで良いですが、そうするとバージョンが 1.0 固定になってしまいます。flutterモジュールのpubspec.yamlファイルのversionフィールドは無視されます。Androidネイティブアプリのgradleによるビルドでは、Mavenリポジトリからダウンロードされたflutterモジュールは一度ダウンロードされると中身が変わっても再ダウンロードされません。よって変更の度にバージョンを上げる必要があります。
またAARのバージョンはコマンド引数で指定する必要があります。

flutter build aar --build-number 0.0.1

今回はコマンド引数に渡すバージョンをpubspec.yamlファイルのversionフィールドから流用するようにしました。

pubspec.yaml
name: sound_memo
description: Sound memo module for Quick Echo app.
# 略
version: 0.0.1 # これを流用
# 略

そのためにはyamlファイルをパースする必要があり、今回はyqコマンドを使うことにしました。このようにyqコマンドを使うとversionフィールドの値だけを標準出力できます。

yq r pubspec.yaml version

今回使用したDockerイメージcirrusci/flutterはUbuntuをベースに作られていたので、yqコマンドのreadme.mdを参考にaptコマンドなどでyqコマンドをインストールしています。
ビルドの度にyqコマンドをインストールしているところが気になる場合は、初めからyqコマンドが入っているDockerイメージを作成してDockerHubにpushすると良いと思います。

AWS CodeBuildでビルドを実行する

ここまで準備できたらflutterモジュールのソースコードをmasterブランチにPUSHします。ビルドの様子をコンソールから見ることが出来ます。

スクリーンショット 2020-07-26 23.20.06.png

完了するとS3にビルド結果が格納されます。アーティファクトで設定した名前(今回はQuickEchoSoundMemo)はS3でのパスに使われます。

スクリーンショット 2020-07-26 23.24.19.png

オブジェクトを公開する設定にしているので認証なしでWebアクセスが可能です。
https://tfandkusu-maven.s3-ap-northeast-1.amazonaws.com/QuickEchoSoundMemo/jp/bellware/echo/sound_memo/flutter_debug/maven-metadata.xml
ホスト名はバケット名とリージョンから作られパスはS3でのパスそのままになります。

AndroidネイティブプロジェクトからFlutterモジュールを使う

公式のAdd-to-appの解説ではapp/build.gradleファイルの repositories 設定に相対パスを設定していますが、それをS3にアップロードしたファイルのWebアクセスURLにします。

app/build.gradle
repositories {
  maven {
    // S3にアップロードしたファイルのWebアクセスURLにする。
    url 'https://tfandkusu-maven.s3-ap-northeast-1.amazonaws.com/QuickEchoSoundMemo'
  }
  maven {
    url 'https://storage.googleapis.com/download.flutter.io'
  }
}

dependencies {
    def flutter_module_version = '0.0.1'
    debugImplementation "jp.bellware.echo.sound_memo:flutter_debug:$flutter_module_version"
    profileImplementation "jp.bellware.echo.sound_memo:flutter_profile:$flutter_module_version"
    releaseImplementation "jp.bellware.echo.sound_memo:flutter_release:$flutter_module_version"
}

あとはAdd-to-appの公式解説に従いFlutterActivityを呼び出したり、FlutterFragmentを設置したりして使います。

補足

ビルドバッジ

AWS CodeBuildではビルドの結果を画像として配信する機能があります。バッジURLのコピー ボタンを押してクリップボードのURLをGithubのREADME.mdにimgタグで貼り付けます。

スクリーンショット 2020-07-27 0.32.10.png

スクリーンショット 2020-07-27 0.37.02.png

AWS CodeBuildを使っていることや、その成功失敗が分かりやすくなります。

スクリーンショット 2020-07-27 0.37.31.png

Slack通知

AWS CodeBuildの結果をSlackに投稿することが出来ます。設定方法はこちらの記事をご参照ください。
CodeBuild の結果をSlack に通知する (CodeBuild + Amazon SNS + AWS Chatbot)

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

Sunflowerリポジトリで学ぶJetPack〜WorkManager編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はWorkManager編です

尚、引用しているソースは明記しているところ以外は、基本的には全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用しました
  • JetPackAndroidXライブラリを利用するのでCompile SDK28以上にする必要があります

そもそもWorkManagerってなに?

WorkManager API を使用すると、アプリが終了したりデバイスが再起動したりしても
実行することが要求される延期可能な非同期タスクのスケジュールを簡単に設定できます。

です!

WorkManagerの主な機能

機能

  • API 14 までの下位互換性
    • API 23 以上が搭載されたデバイスでは JobScheduler を使用
    • API 14~22 が搭載されたデバイスでは BroadcastReceiverAlarmManager を組み合わせて使用
  • ネットワークの可用性や充電ステータスなどの処理の制約を追加する
  • 非同期の 1 回限りのタスクや定期的なタスクのスケジュールを設定する
  • スケジュール設定されたタスクの監視と管理を行う
  • タスクを連携させる
  • アプリやデバイスが再起動してもタスクを確実に実行する
  • Doze モードなどの省電力機能に準拠する (公式より)

この中だと、APIレベルが低いものでも同じように使えるというところと、Dozeモードに対応しているのがいいですね
Dozeモード中にバックグラウンド処理が色々動かない、というのはAndroidあるあるだと思いますので。。。)

注意点

公式によると

WorkManager は、アプリが終了したりデバイスが再起動したりしても確実に実行する必要がある遅延可能なタスク(つまり、直ちに実行する必要がないタスク)を対象としています。次に例を示します。

  • ログやアナリティクスをバックエンド サービスに送信する
  • アプリデータをサーバーと定期的に同期する

つまり、確実に定周期で何かをする、などということには対応していない、ということですね。

利用する際に必要なこと

必要なことは下記の4点です

  1. 依存関係の記載
  2. バックグラウンドタスクの作成
  3. タスクを実行する方法とタイミングを設定とタスクをシステムに引き渡す処理

依存関係の記載

build.gradleに記載する内容

build.gradleには下記の依存関係の記載を行います。
(公式ドキュメントより)

build.gradle
    dependencies {
      def work_version = "2.3.1"

        // (Java only)
        implementation "androidx.work:work-runtime:$work_version"

        // Kotlin + coroutines
        implementation "androidx.work:work-runtime-ktx:$work_version"

        // optional - RxJava2 support
        implementation "androidx.work:work-rxjava2:$work_version"

        // optional - GCMNetworkManager support
        implementation "androidx.work:work-gcm:$work_version"

        // optional - Test helpers
        androidTestImplementation "androidx.work:work-testing:$work_version"
      }
  • 必須設定
    • Java
    • Kotlin + Coroutines
      • Javaで使う場合は、Javaの設定が、Kotlinで使う場合はKotlinの設定が必須となります。
  • オプション設定
    • RxJava2
    • GCMNetworkManager
    • Test helpers
      • これらはオプションなので、利用する場合は記載します。

Sunflowerリポジトリの場合

Sunflowerリポジトリの場合はどのようになっているかを見てみましょう

build.gradle
buildscript {
    // Define versions in a single place
    ext {
                    :
        // App dependencies
                    :
        workVersion = '2.1.0'
    }
                    :
}
app/build.gradle
dependencies {
                        :
    implementation "androidx.work:work-runtime-ktx:$rootProject.workVersion"
                        :
    // Testing dependencies
                        :
    androidTestImplementation "androidx.work:work-testing:$rootProject.workVersion"
                        :
}

Sunflowerリポジトリの場合は、下記設定となっていました。

  • Kotlinで利用しています
  • オプションはTest helpersを利用しています

バックグラウンド タスクを作成する

それではSunflowerのバックグラウンドタスクを見てみましょう。

SeedDatabaseWorker.kt(part1)
class SeedDatabaseWorker(
    context: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
    override suspend fun doWork(): Result = coroutineScope {
                .
                .
                .
  • タスクは通常はWorkerクラスを使用して定義します
  • Workmanagerから提供されるバックグラウンドスレッドでdowork()メソッドが同期的に実行されます
  • ただし、SunflowerリポジトリではCoroutineWorkerを利用しています。
    • こちらはKotlinを使う際にオススメです
    • その場合doWork()メソッドはsuspend関数になり、サブスレッドで実行されます。
      • 実行するスレッドは、Workerクラスとは異なり、Configurationで指定されたExecutorではありません。
      • Dispatchers.Defaultで実行されます。
SeedDatabaseWorker.kt(part2)
        try {
            applicationContext.assets.open(PLANT_DATA_FILENAME).use { inputStream ->
                JsonReader(inputStream.reader()).use { jsonReader ->
                    val plantType = object : TypeToken<List<Plant>>() {}.type
                    val plantList: List<Plant> = Gson().fromJson(jsonReader, plantType)

                    val database = AppDatabase.getInstance(applicationContext)
                    database.plantDao().insertAll(plantList)

                    Result.success()
                }
            }
        } catch (ex: Exception) {
            Log.e(TAG, "Error seeding database", ex)
            Result.failure()
        }
    }

    companion object {
        private val TAG = SeedDatabaseWorker::class.java.simpleName
    }
}
  • 以降には、バックグラウンドタスクで実施する処理が記載されています。
    • ここではassets内のJsonファイルを読み込んだあと、パースしてデータベースに保存しています。
  • doWork()メソッドはResultの各メソッドを返します。
    • Result.success(): タスクが正常に終了したかどうか
    • Result.failure(): タスクが失敗したかどうか
    • Result.retry(): 後でタスクを再試行する必要があるかどうか
      • ここでは、データベースに保存ができれば正常終了、例外が発生した場合は失敗となっています。

タスク実行方法とタイミングを設定およびタスクをシステムに引き渡す処理

概要

  • Worker(CoroutineWorker)が作業単位を定義しますが、それに対し、WorkRequestは作業を実行する方法とタイミングを定義します。
  • タスクには1回実行するものと、定期的に実行されるものがあります
    • 1回のもの → OneTimeWorkRequest
    • 定期的なもの → PeriodicWorkRequest
  • WorkRequestを定義した後、WorkManagerenqueue()メソッドを使用してスケジュールを設定できます。

Sunflowerリポジトリ

AppDatabase.kt
        // Create and pre-populate the database. See this article for more details:
        // https://medium.com/google-developers/7-pro-tips-for-room-fbadea4bfbd1#4785
        private fun buildDatabase(context: Context): AppDatabase {
            return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
                    .addCallback(object : RoomDatabase.Callback() {
                        override fun onCreate(db: SupportSQLiteDatabase) {
                            super.onCreate(db)
                            val request = OneTimeWorkRequestBuilder<SeedDatabaseWorker>().build()
                            WorkManager.getInstance(context).enqueue(request)
                        }
                    })
                    .build()
        }

Sunflowerリポジトリでは下記の処理になっています。

  • データベース作成時に一度だけ実施する処理OneTimeWorkRequestBuilderSeedDatabaseWorkerを実施している。

まとめ

  • バックグラウンドタスクの作成はWorkerクラスを作成する。Kotlinの場合はCoroutineWorkerがおすすめ
  • Workerクラスのdowork()メソッド内に実行する処理を書く。
  • タスクの実行の際、1回しか実施しないことは、OneTimeWorkRequest、定期的に実施するものはPeriodicWorkRequestで実行する。

以上です!

参考サイト

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

【Room】Sunflowerリポジトリで学ぶJetPack〜Room編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はRoom編です

尚、引用しているソースは明記しているところ以外は、基本的には全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用しました
  • JetPackAndroidXライブラリを利用するのでCompile SDK28以上にする必要があります

そもそもRoomってなに?

公式の説明

Room 永続ライブラリは SQLite 全体に抽象化レイヤを提供することで、
データベースへのより安定したアクセスを可能にし、
SQLite を最大限に活用できるようにします。

です!

Roomを使う理由

  • アプリ内ではオブジェクトを利用して、SQLiteのデータベースにアクセスできる。
  • コンパイル時にチェックができる

(SunflowerのリポジトリのトップページのRoomの説明より)

利用する際に必要なこと

必要なことは下記の4点です

  1. 依存関係の記載
  2. データベース作成
  3. エンティティ作成
  4. DAO作成

依存関係の記載

build.gradleに記載する内容

build.gradleには下記の依存関係の記載を行います。
(公式ドキュメントより)

build.gradle
    dependencies {
      def room_version = "2.2.3"

      implementation "androidx.room:room-runtime:$room_version"
      annotationProcessor "androidx.room:room-compiler:$room_version" // For Kotlin use kapt instead of annotationProcessor

      // optional - Kotlin Extensions and Coroutines support for Room
      implementation "androidx.room:room-ktx:$room_version"

      // optional - RxJava support for Room
      implementation "androidx.room:room-rxjava2:$room_version"

      // optional - Guava support for Room, including Optional and ListenableFuture
      implementation "androidx.room:room-guava:$room_version"

      // Test helpers
      testImplementation "androidx.room:room-testing:$room_version"
    }

  • 注意点
    • room-compilerの部分については、Kotlinで使用する場合はannotationProcessorではなく、kaptにします
  • オプション設定
    • Kotlin
    • RxJava
    • Guava
    • Test helpers
      • これらはオプションなので、利用する場合は記載します。

Sunflowerリポジトリの場合

Sunflowerリポジトリの場合はどのようになっているかを見てみましょう

build.gradle
buildscript {
    // Define versions in a single place
    ext {
                    :
        // App dependencies
                    :
        roomVersion = '2.1.0'
                    :
    }
                    :
}
app/build.gradle
dependencies {
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
                        :
    implementation "androidx.room:room-runtime:$rootProject.roomVersion"
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
                        :
}

Sunflowerリポジトリの場合は、Kotlinのオプションのみ利用しているようです。

データベースのクラス作成

AppDatabase.kt(part1)
/**
 * The Room database for this app
 */
@Database(entities = [GardenPlanting::class, Plant::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun gardenPlantingDao(): GardenPlantingDao
    abstract fun plantDao(): PlantDao

データベースの設定を行うクラスをみてみましょう。
データベース利用の際の設定的なものは、ここの最初の部分に書かれています。

  • クラスの宣言に@Databaseアノテーションを記載します(必須)
    • データベースに関連付けられているエンティティのリストをアノテーション内に含む必要があり、entitiesにそれを記載します。(必須)
      • ここでは、GardenPlantingクラスとPlantクラスが記載されています。
    • versionはデータベースのバージョンを定義できます。マイグレーション処理を書く際はこちらのバージョンを参照できます(任意)
    • exportShemefalseに設定しています。(任意)
      • こちらはdefaulttrueに設定されます。
      • trueに設定された場合はroom.schemaLocationbuild.gradleに定義されている場合は、データベースのスキーマ情報をJSONファイルにエクスポートします。それを利用するとマイグレーションのテストが実施できます。
      • 詳しくはこちら
  • @TypeConverterアノテーションはentityに保存できない型がある場合(例えば、GardenPlantingクラスのplantDateCalender型))、変換処理を実装したクラスをつくり、そのクラス名を記載します。(任意)
    • ここでは、ConverterクラスにCalender ↔︎ long の相互変換処理が記載されており、そのConverterクラスを記載しています。
  • RoomDatabaseを拡張する抽象クラスとしてクラスを宣言します(必須)

    • abstract class AppDatabase : RoomDatabase() として宣言しています。
  • 引数が 0 で、@Dao アノテーション付きのクラスを返す抽象メソッドをクラス内に記載します(必須)

    • gardenPlantingDao()メソッドとplantDao()メソッドを記載してあります。
AppDatabase.kt(part2)
    companion object {

        // For Singleton instantiation
        @Volatile private var instance: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return instance ?: synchronized(this) {
                instance ?: buildDatabase(context).also { instance = it }
            }
        }

こちらは、シングルトンで利用するための内容が記載されています。
すでにインスタンスが作られている場合は、作られているものを使い、ない場合は作るという処理です
スレッドセーフにするためにsynchronized()で排他制御を行っています。

AppDatabase.kt(part3)
        // Create and pre-populate the database. See this article for more details:
        // https://medium.com/google-developers/7-pro-tips-for-room-fbadea4bfbd1#4785
        private fun buildDatabase(context: Context): AppDatabase {
            return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
                    .addCallback(object : RoomDatabase.Callback() {
                        override fun onCreate(db: SupportSQLiteDatabase) {
                            super.onCreate(db)
                            val request = OneTimeWorkRequestBuilder<SeedDatabaseWorker>().build()
                            WorkManager.getInstance(context).enqueue(request)
                        }
                    })
                    .build()
        }
    }
}

こちらでは、データベースを作成する処理です。
* Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME).buildでデータベースのインスタンスを作成します。
* その間にaddCallback()メソッドが入っています。
* こちらはデータベースが作成された時に実施されるコールバックメソッドです。
* 例えば、データベースの初期データなどを読み込む必要がある場合などで利用できます。
* ここではWorkManagerを利用し、バックグラウンド処理でjsonファイルから初期値の情報を読み込んで、データベースに保存しています。

エンティティの定義

それでは、SunflowerリポジトリのGardenPlantingクラスのエンティティ定義からみてみましょう

GardenPlanting.kt(part1)
@Entity(
    tableName = "garden_plantings",
    foreignKeys = [
        ForeignKey(entity = Plant::class, parentColumns = ["id"], childColumns = ["plant_id"])
    ],
    indices = [Index("plant_id")]
)
  • @Entityのアノテーションをつけてクラスを作成します。(必須)
    • tableNameに指定した名称が、SQLiteデータベース内のテーブル名になります。もし、なにも記載しなかった場合はクラス名がテーブル名になります。(任意)
    • foreignKeysは外部キー制約の定義を行います。ここでは、Plantクラスのidにある値のみ、plant_idに設定可能となります。(任意)
    • indicesで、このテーブルに設定するインデックスを指定します。この場合はplant_idをインデックスに設定しています。(任意)
GardenPlanting.kt(part2)
data class GardenPlanting(
    @ColumnInfo(name = "plant_id") val plantId: String,

    /**
     * Indicates when the [Plant] was planted. Used for showing notification when it's time
     * to harvest the plant.
     */
    @ColumnInfo(name = "plant_date") val plantDate: Calendar = Calendar.getInstance(),

    /**
     * Indicates when the [Plant] was last watered. Used for showing notification when it's
     * time to water the plant.
     */
    @ColumnInfo(name = "last_watering_date")
    val lastWateringDate: Calendar = Calendar.getInstance()
) {
        @PrimaryKey(autoGenerate = true)
        @ColumnInfo(name = "id")
        var gardenPlantingId: Long = 0
}
  • @ColumnInfoで各カラムの定義を行います。変数宣言などは通常の場合と同一です。(必須)
  • @ColumnInfoアノテーションの中のnameでカラム名を定義します。(任意)
    • テーブル名と同様に、指定しなかった場合は変数名がカラム名となります。
    • ここではplantId,plantDate,lastWateringDateの3つの引数を指定するコンストラクターを定義しています。
  • プライマリーキーには@PrimaryKeyのアノテーションをつけて変数を定義します。(任意)
    • カラム名id、変数名gardenPlantIdがプライマリーキーに指定されています。
    • ここではautoGenerate = trueを指定し、自動的に割り当てるようになっています。

DAOの定義

次にGardenPlantingDaoをみてみましょう。
DAOとはData Access Objectの略でデータベースの操作のインターフェースを提供するオブジェクトです。
使う側はデータベースの詳細を知ることなくデータの保存や取得が利用可能です。

GardenPlantingDao.kt(Part1)
/**
 * The Data Access Object for the [GardenPlanting] class.
 */
@Dao
interface GardenPlantingDao {
  • @Daoアノテーションをつけたインターフェースを定義します。(必須)
GardenPlantingDao.kt(Part2)
    @Query("SELECT * FROM garden_plantings")
    fun getGardenPlantings(): LiveData<List<GardenPlanting>>

    @Query("SELECT EXISTS(SELECT 1 FROM garden_plantings WHERE plant_id = :plantId LIMIT 1)")
    fun isPlanted(plantId: String): LiveData<Boolean>

    /**
     * This query will tell Room to query both the [Plant] and [GardenPlanting] tables and handle
     * the object mapping.
     */
    @Transaction
    @Query("SELECT * FROM plants WHERE id IN (SELECT DISTINCT(plant_id) FROM garden_plantings)")
    fun getPlantedGardens(): LiveData<List<PlantAndGardenPlantings>>
  • @Queryアノテーションは、データベースに対して読み書き処理を実行できます。
    • コンパイル時に検証されるので、クエリに問題があると、コンパイルエラーになります
    • 戻り値についても検証を行い、誤りがある場合は警告またはエラーを表示します
    • クエリにパラメータを渡す場合は、:[メソッドの引数名]としてクエリ内に記載します
      • ここでは2つめのクエリの中の:plantIdがそのように使われています
  • @Transactionアノテーションで記載したメソッドは、全て同一トランザクションで実行します
  • 各メソッドの戻り値にLiveDataを指定することができます。LiveDataRoomの組み合わせで、例えば、データベースのテーブルが更新された際にViewの更新などができます。詳しくはLiveData編をご覧ください
GardenPlantingDao.kt(Part3)
    @Insert
    suspend fun insertGardenPlanting(gardenPlanting: GardenPlanting): Long

    @Delete
    suspend fun deleteGardenPlanting(gardenPlanting: GardenPlanting)
}
  • @Insertアノテーションをつけたメソッドは、データベースに挿入する実装がRoomによって作成されます。
    • 複数のパラメータを指定することができます。
    • 全てのパラメータを同一のトランザクションで実行します
    • 戻り値を指定すると、挿入されるアイテムの新しいrowId になるlongの値を返すことができます。
  • @Deleteアノテーションをつけたメソッドは、こちらも、@Insertと同様に、データベースないの指定したエンティティのセットを削除する処理を作成します。
  • ここでは使用されていませんが、@Updateも同様です

  • メソッド名の前にsuspendがついているものは、コルーチン機能を使用した際に非同期かすることができ、メインスレッド上で実行される可能性がなくなります。(サブスレッドで実行して下さい、の意)

参考サイト

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

Sunflowerリポジトリで学ぶJetPack〜Room編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はRoom編です

尚、引用しているソースは明記しているところ以外は、基本的には全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用しました
  • JetPackAndroidXライブラリを利用するのでCompile SDK28以上にする必要があります

そもそもRoomってなに?

公式の説明

Room 永続ライブラリは SQLite 全体に抽象化レイヤを提供することで、
データベースへのより安定したアクセスを可能にし、
SQLite を最大限に活用できるようにします。

です!

Roomを使う理由

  • アプリ内ではオブジェクトを利用して、SQLiteのデータベースにアクセスできる。
  • コンパイル時にチェックができる

(SunflowerのリポジトリのトップページのRoomの説明より)

利用する際に必要なこと

必要なことは下記の4点です

  1. 依存関係の記載
  2. データベース作成
  3. エンティティ作成
  4. DAO作成

依存関係の記載

build.gradleに記載する内容

build.gradleには下記の依存関係の記載を行います。
(公式ドキュメントより)

build.gradle
    dependencies {
      def room_version = "2.2.3"

      implementation "androidx.room:room-runtime:$room_version"
      annotationProcessor "androidx.room:room-compiler:$room_version" // For Kotlin use kapt instead of annotationProcessor

      // optional - Kotlin Extensions and Coroutines support for Room
      implementation "androidx.room:room-ktx:$room_version"

      // optional - RxJava support for Room
      implementation "androidx.room:room-rxjava2:$room_version"

      // optional - Guava support for Room, including Optional and ListenableFuture
      implementation "androidx.room:room-guava:$room_version"

      // Test helpers
      testImplementation "androidx.room:room-testing:$room_version"
    }

  • 注意点
    • room-compilerの部分については、Kotlinで使用する場合はannotationProcessorではなく、kaptにします
  • オプション設定
    • Kotlin
    • RxJava
    • Guava
    • Test helpers
      • これらはオプションなので、利用する場合は記載します。

Sunflowerリポジトリの場合

Sunflowerリポジトリの場合はどのようになっているかを見てみましょう

build.gradle
buildscript {
    // Define versions in a single place
    ext {
                    :
        // App dependencies
                    :
        roomVersion = '2.1.0'
                    :
    }
                    :
}
app/build.gradle
dependencies {
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
                        :
    implementation "androidx.room:room-runtime:$rootProject.roomVersion"
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
                        :
}

Sunflowerリポジトリの場合は、Kotlinのオプションのみ利用しているようです。

データベースのクラス作成

AppDatabase.kt(part1)
/**
 * The Room database for this app
 */
@Database(entities = [GardenPlanting::class, Plant::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun gardenPlantingDao(): GardenPlantingDao
    abstract fun plantDao(): PlantDao

データベースの設定を行うクラスをみてみましょう。
データベース利用の際の設定的なものは、ここの最初の部分に書かれています。

  • クラスの宣言に@Databaseアノテーションを記載します(必須)
    • データベースに関連付けられているエンティティのリストをアノテーション内に含む必要があり、entitiesにそれを記載します。(必須)
      • ここでは、GardenPlantingクラスとPlantクラスが記載されています。
    • versionはデータベースのバージョンを定義できます。マイグレーション処理を書く際はこちらのバージョンを参照できます(任意)
    • exportShemefalseに設定しています。(任意)
      • こちらはdefaulttrueに設定されます。
      • trueに設定された場合はroom.schemaLocationbuild.gradleに定義されている場合は、データベースのスキーマ情報をJSONファイルにエクスポートします。それを利用するとマイグレーションのテストが実施できます。
      • 詳しくはこちら
  • @TypeConverterアノテーションはentityに保存できない型がある場合(例えば、GardenPlantingクラスのplantDateCalender型))、変換処理を実装したクラスをつくり、そのクラス名を記載します。(任意)
    • ここでは、ConverterクラスにCalender ↔︎ long の相互変換処理が記載されており、そのConverterクラスを記載しています。
  • RoomDatabaseを拡張する抽象クラスとしてクラスを宣言します(必須)

    • abstract class AppDatabase : RoomDatabase() として宣言しています。
  • 引数が 0 で、@Dao アノテーション付きのクラスを返す抽象メソッドをクラス内に記載します(必須)

    • gardenPlantingDao()メソッドとplantDao()メソッドを記載してあります。
AppDatabase.kt(part2)
    companion object {

        // For Singleton instantiation
        @Volatile private var instance: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return instance ?: synchronized(this) {
                instance ?: buildDatabase(context).also { instance = it }
            }
        }

こちらは、シングルトンで利用するための内容が記載されています。
すでにインスタンスが作られている場合は、作られているものを使い、ない場合は作るという処理です
スレッドセーフにするためにsynchronized()で排他制御を行っています。

AppDatabase.kt(part3)
        // Create and pre-populate the database. See this article for more details:
        // https://medium.com/google-developers/7-pro-tips-for-room-fbadea4bfbd1#4785
        private fun buildDatabase(context: Context): AppDatabase {
            return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
                    .addCallback(object : RoomDatabase.Callback() {
                        override fun onCreate(db: SupportSQLiteDatabase) {
                            super.onCreate(db)
                            val request = OneTimeWorkRequestBuilder<SeedDatabaseWorker>().build()
                            WorkManager.getInstance(context).enqueue(request)
                        }
                    })
                    .build()
        }
    }
}

こちらでは、データベースを作成する処理です。
* Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME).buildでデータベースのインスタンスを作成します。
* その間にaddCallback()メソッドが入っています。
* こちらはデータベースが作成された時に実施されるコールバックメソッドです。
* 例えば、データベースの初期データなどを読み込む必要がある場合などで利用できます。
* ここではWorkManagerを利用し、バックグラウンド処理でjsonファイルから初期値の情報を読み込んで、データベースに保存しています。

エンティティの定義

それでは、SunflowerリポジトリのGardenPlantingクラスのエンティティ定義からみてみましょう

GardenPlanting.kt(part1)
@Entity(
    tableName = "garden_plantings",
    foreignKeys = [
        ForeignKey(entity = Plant::class, parentColumns = ["id"], childColumns = ["plant_id"])
    ],
    indices = [Index("plant_id")]
)
  • @Entityのアノテーションをつけてクラスを作成します。(必須)
    • tableNameに指定した名称が、SQLiteデータベース内のテーブル名になります。もし、なにも記載しなかった場合はクラス名がテーブル名になります。(任意)
    • foreignKeysは外部キー制約の定義を行います。ここでは、Plantクラスのidにある値のみ、plant_idに設定可能となります。(任意)
    • indicesで、このテーブルに設定するインデックスを指定します。この場合はplant_idをインデックスに設定しています。(任意)
GardenPlanting.kt(part2)
data class GardenPlanting(
    @ColumnInfo(name = "plant_id") val plantId: String,

    /**
     * Indicates when the [Plant] was planted. Used for showing notification when it's time
     * to harvest the plant.
     */
    @ColumnInfo(name = "plant_date") val plantDate: Calendar = Calendar.getInstance(),

    /**
     * Indicates when the [Plant] was last watered. Used for showing notification when it's
     * time to water the plant.
     */
    @ColumnInfo(name = "last_watering_date")
    val lastWateringDate: Calendar = Calendar.getInstance()
) {
        @PrimaryKey(autoGenerate = true)
        @ColumnInfo(name = "id")
        var gardenPlantingId: Long = 0
}
  • @ColumnInfoで各カラムの定義を行います。変数宣言などは通常の場合と同一です。(必須)
  • @ColumnInfoアノテーションの中のnameでカラム名を定義します。(任意)
    • テーブル名と同様に、指定しなかった場合は変数名がカラム名となります。
    • ここではplantId,plantDate,lastWateringDateの3つの引数を指定するコンストラクターを定義しています。
  • プライマリーキーには@PrimaryKeyのアノテーションをつけて変数を定義します。(任意)
    • カラム名id、変数名gardenPlantIdがプライマリーキーに指定されています。
    • ここではautoGenerate = trueを指定し、自動的に割り当てるようになっています。

DAOの定義

次にGardenPlantingDaoをみてみましょう。
DAOとはData Access Objectの略でデータベースの操作のインターフェースを提供するオブジェクトです。
使う側はデータベースの詳細を知ることなくデータの保存や取得が利用可能です。

GardenPlantingDao.kt(Part1)
/**
 * The Data Access Object for the [GardenPlanting] class.
 */
@Dao
interface GardenPlantingDao {
  • @Daoアノテーションをつけたインターフェースを定義します。(必須)
GardenPlantingDao.kt(Part2)
    @Query("SELECT * FROM garden_plantings")
    fun getGardenPlantings(): LiveData<List<GardenPlanting>>

    @Query("SELECT EXISTS(SELECT 1 FROM garden_plantings WHERE plant_id = :plantId LIMIT 1)")
    fun isPlanted(plantId: String): LiveData<Boolean>

    /**
     * This query will tell Room to query both the [Plant] and [GardenPlanting] tables and handle
     * the object mapping.
     */
    @Transaction
    @Query("SELECT * FROM plants WHERE id IN (SELECT DISTINCT(plant_id) FROM garden_plantings)")
    fun getPlantedGardens(): LiveData<List<PlantAndGardenPlantings>>
  • @Queryアノテーションは、データベースに対して読み書き処理を実行できます。
    • コンパイル時に検証されるので、クエリに問題があると、コンパイルエラーになります
    • 戻り値についても検証を行い、誤りがある場合は警告またはエラーを表示します
    • クエリにパラメータを渡す場合は、:[メソッドの引数名]としてクエリ内に記載します
      • ここでは2つめのクエリの中の:plantIdがそのように使われています
  • @Transactionアノテーションで記載したメソッドは、全て同一トランザクションで実行します
  • 各メソッドの戻り値にLiveDataを指定することができます。LiveDataRoomの組み合わせで、例えば、データベースのテーブルが更新された際にViewの更新などができます。詳しくはLiveData編をご覧ください
GardenPlantingDao.kt(Part3)
    @Insert
    suspend fun insertGardenPlanting(gardenPlanting: GardenPlanting): Long

    @Delete
    suspend fun deleteGardenPlanting(gardenPlanting: GardenPlanting)
}
  • @Insertアノテーションをつけたメソッドは、データベースに挿入する実装がRoomによって作成されます。
    • 複数のパラメータを指定することができます。
    • 全てのパラメータを同一のトランザクションで実行します
    • 戻り値を指定すると、挿入されるアイテムの新しいrowId になるlongの値を返すことができます。
  • @Deleteアノテーションをつけたメソッドは、こちらも、@Insertと同様に、データベースないの指定したエンティティのセットを削除する処理を作成します。
  • ここでは使用されていませんが、@Updateも同様です

  • メソッド名の前にsuspendがついているものは、コルーチン機能を使用した際に非同期かすることができ、メインスレッド上で実行される可能性がなくなります。(サブスレッドで実行して下さい、の意)

参考サイト

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

【ViewModel】Sunflowerリポジトリで学ぶJetPack〜ViewModel編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はViewModel編です
DataBinding編LiveData編などでも、
チラチラ出てきてはいましたが、見てみぬふりをしていました笑

尚、引用しているソースは明記しているところ以外は、基本的には全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用しました
  • JetPackAndroidXライブラリを利用するのでCompile SDK28以上にする必要があります

そもそもViewModelってなに?

公式の説明によると

  • ViewModel は、ライフサイクルを意識した方法で UI 関連のデータを保存および管理するためのクラスです
  • ViewModel クラスを使用すると、画面の回転などの設定の変更後にデータを引き継ぐことができます。

です!

ViewModelってなに?(詳細)

公式にはこんな感じのことが書いてありました。

  • UI コントローラで利用するデータをバンドルから復元して利用しないで済む
    • アクティビティがonSaveInstanceState() メソッドを使用して onCreate() のバンドルからデータを復元しなくてもよくなります。
    • そもそもこの方法(onSaveInstanceState()利用)が適しているのは少量のデータの場合だけです。
    • ユーザーやビットマップのリストのようにデータの量が多くなる可能性がある場合には適していません。

画面をぐるぐるする度に、バンドルからデータをとって。。。とするのはめんどくさかったので、
それをしないで済むのは良さそうですね!

  • メモリリークの心配がない
    • UI コントローラでは実行に時間がかかる非同期呼び出しを頻繁に行う必要があります。それらを管理し、破棄された後にシステムが呼び出しのクリーンアップを行ってメモリリークが発生しないようにする必要がありますが、ViewModelを利用した場合それをする必要がありません。

画面のライフサイクルに合わせて、データの破棄や再取得を考えて設計・実装するのは大変ですね。。。
それをしないで済むのはこちらも良さそうです!

  • リソースの無駄がない
    • この管理ではメンテナンスを何度も実施する必要があります
    • 設定の変更によってオブジェクトが再作成された場合にはすでに行った呼び出しを再度行わなければならないこともあるため、リソースが無駄になります。

メモリリークしない作りにするために、冗長な処理になってしまいがちだったかもしれません。
それをしないで済むのはこちらもありがたいです!

  • UIコントローラクラスの肥大化防止とテスト効率の向上
    • UI コントローラに対してデータベースやネットワークからのデータ読み込みも行うよう要求すると、クラスが肥大化することになります
    • UI コントローラに過度の役割を割り当てると、アプリの作業を他のクラスに任せずに 1 つのクラスですべて処理しようとすることになり、テストも困難になります。
    • ビューデータの所有権を UI コントローラのロジックから切り離すことで、複雑さが軽減され、効率性が高まります。

処理が増えてくると、ActivityFragmentが巨大化してしまい、本当のところ実際にUIをコントロールしているところが
見えにくくなってしまう、という状況になってしまいがちだと思います。
また、処理とUIコントロールの部分が分割できるよう、設計を工夫してなんとかしていたと思いますが、
公式でサポートするViewModelで出来るのは頼もしいですね!!!

ViewModelクラス

その名の通り、上記のようなUIコントローラー向けにUIデータを準備するためのViewModelヘルパークラスが用意されています!!!
それを使えば上記の問題が解決できるはずです!

使用箇所

Sunflowerリポジトリの中ではどのようにViewModelが使われているか、実際に見てみましょう

ViewModelの定義

GardenPlantingListViewModel.kt
class GardenPlantingListViewModel internal constructor(
    gardenPlantingRepository: GardenPlantingRepository
) : ViewModel() {
    val plantAndGardenPlantings: LiveData<List<PlantAndGardenPlantings>> =
            gardenPlantingRepository.getPlantedGardens()
}
  • plantAndGardenPlantingsを取得する処理は、アクティビティやフラグメントではなく、GardenPlantingListViewModel内に割り当てられてられています。
  • ViewModel内で定義しているデータはLiveDataとなっています

    • こうすると更新を監視できて、非アクティブになった時に破棄できるなどメリットがあるため、一緒に使う場合が多いと思います。
    • ※詳しくはLiveData編も併せて確認してください。
  • ちなみに、ここではplantAndGardenPlantingsの値自体の取得処理は、GardenPlantingRepositoryを利用し抽象化されており、さらに外部でインスタンス化されたものをコンストラクターで渡されています。(依存性注入)

    • こうすることで、取得先がローカルDBからサーバーに変わったとしても、このクラスとの依存関係がないため、変更は不要であり、変更に強い設計になっています。
    • 尚、依存性注入についてはDagger2を利用して実施する場合が多いですが、Sunflowerリポジトリでは、Dagger2の依存性注入については、対応しない!と明言しています笑

ViewModel利用箇所

UI コントローラー(ここではFragment)ではこのようにして、ViewModelを利用していました。

GardenFragment.kt
class GardenFragment : Fragment() {
          :
          :
    private val viewModel: GardenPlantingListViewModel by viewModels {
        InjectorUtils.provideGardenPlantingListViewModelFactory(requireContext())
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
          :
          :
        subscribeUi(adapter, binding)
        return binding.root
    }

    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
    viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
        binding.hasPlantings = !result.isNullOrEmpty()
        adapter.submitList(result)
    }
  • viewModelを定義している部分ですが、by viewModels {ラムダ式}となっています。
    • これはAndroid KTXの機能のうち、Properties Delegeteというもので、この場合は下記のパターンです。
    • →ラムダ式の中に自作のFactory Methodを書くことができ、そこで作られたインスタンスをViewModelに与えることができます
class MyFragment : Fragment() {
    val viewmodel: MYViewModel by viewmodels { myFactory }
}

ここでは、InjectorUtils#provideGardenPlantingListViewModelFactory()メソッドを実施し、このViewModelのインスタンスを取得しています

  • viewModel.plantAndGardenPlantingsLiveDataのため、observe()して変更を監視します。
  • この処理はFragmentが作成された時にコールされる処理、onCreateView()メソッド内で実施しています。
  • 値がセットされる度にonChange()メソッドがコールされ、ラムダ式内の処理が実施されます。そこで、UIの更新を実施しています。

ViewModelFactoryメソッドについて

ViewModelFactoryメソッドにはandroidx.lifecycle.ViewModelProviderを使用します。

引数無しの場合

val viewmodel: MYViewModel by viewmodels{ 
    ViewModelProvider.NewInstanceFactory().create(MYViewModel::class.java)
}

これでOKです。(新たなクラスの定義は不要です。)

引数ありの場合(今回のパターン)

ただし、今回は引数がある場合なので、
引数があるViewModelFactoryクラスを作成しています。

こちらはViewModelFactoryクラスのFactoryメソッド(Factoryクラスのコンストラクターメソッド)を呼び出している処理です。
Sunflowerリポジトリでは、kotlin:InjectorUtilsクラスに各ViewModelFactoryメソッドをまとめて記載してあります。

InjectorUtils.kt
object InjectorUtils {
                :
                :
    fun provideGardenPlantingListViewModelFactory(
        context: Context
    ): GardenPlantingListViewModelFactory {
        val repository = getGardenPlantingRepository(context)
        return GardenPlantingListViewModelFactory(repository)
    }
                :
                :
}

続いて、GardenPlantingListViewModelFactoryクラスであるkotlin:GardenPlantingListViewModelFactoryを見てみましょう

GardenPlantingListViewModelFactory.kt
class GardenPlantingListViewModelFactory(
    private val repository: GardenPlantingRepository
) : ViewModelProvider.NewInstanceFactory() {

    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GardenPlantingListViewModel(repository) as T
    }
}

GardenPlantingRepositoryを引数として渡す形で定義されています。

Sunflowerリポジトリの他のViewModelクラスも同じ形式となっていたので、別途作成する場合もこの形式を踏襲し、以下の部分の変更のみすればよさそうです

  • クラス名(◯◯◯Factory)
  • Constructorの引数
  • オーバーライドするcreateメソッドが返す引数(新たに作成したViewModelのクラス名)

ViewModelとテストについて

  • ViewModelのライフサイクルは長めに設計されています。そのため、ViewModelのテストをしやすい設計となっています。

viewmodel-lifecycle.png

  • 画面が無くなった(Activity#onDestroy())「後」に、なにかやる、などもViewmodelScope.onCleared()とかでできます。
  • この図はActivityについて書かれてますが、Fragmentなどでも基本的には同じのようです

まとめ

  • ViewModel は、ライフサイクルを意識した方法で UI 関連のデータを保存および管理するためのクラスで、画面の回転などの設定の変更後にデータを引き継ぐことができる。
  • UIコントローラー(Activity,Fragmentなど)とライフサイクルを共にするため、メモリリークの心配やリソースの無駄がない
  • UIコントローラーからViewModelを切り出すことにより、UIコントローラーの肥大化を防止し、テストをしやすくすることができる
  • Android KTXProperties DelegeteFactoryメソッドでインスンタンスを作ることができる。
  • ViewModelクラスのコンストラクタが、引数なしの場合は自前のFactoryクラスを作らないでもよいが、引数ありの場合はFactoryクラスを作る必要がある。

以上です!

参考サイト

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

Sunflowerリポジトリで学ぶJetPack〜ViewModel編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はViewModel編です
DataBinding編LiveData編などでも、
チラチラ出てきてはいましたが、見てみぬふりをしていました笑

尚、引用しているソースは明記しているところ以外は、基本的には全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用しました
  • JetPackAndroidXライブラリを利用するのでCompile SDK28以上にする必要があります

そもそもViewModelってなに?

公式の説明によると

  • ViewModel は、ライフサイクルを意識した方法で UI 関連のデータを保存および管理するためのクラスです
  • ViewModel クラスを使用すると、画面の回転などの設定の変更後にデータを引き継ぐことができます。

です!

ViewModelってなに?(詳細)

公式にはこんな感じのことが書いてありました。

  • UI コントローラで利用するデータをバンドルから復元して利用しないで済む
    • アクティビティがonSaveInstanceState() メソッドを使用して onCreate() のバンドルからデータを復元しなくてもよくなります。
    • そもそもこの方法(onSaveInstanceState()利用)が適しているのは少量のデータの場合だけです。
    • ユーザーやビットマップのリストのようにデータの量が多くなる可能性がある場合には適していません。

画面をぐるぐるする度に、バンドルからデータをとって。。。とするのはめんどくさかったので、
それをしないで済むのは良さそうですね!

  • メモリリークの心配がない
    • UI コントローラでは実行に時間がかかる非同期呼び出しを頻繁に行う必要があります。それらを管理し、破棄された後にシステムが呼び出しのクリーンアップを行ってメモリリークが発生しないようにする必要がありますが、ViewModelを利用した場合それをする必要がありません。

画面のライフサイクルに合わせて、データの破棄や再取得を考えて設計・実装するのは大変ですね。。。
それをしないで済むのはこちらも良さそうです!

  • リソースの無駄がない
    • この管理ではメンテナンスを何度も実施する必要があります
    • 設定の変更によってオブジェクトが再作成された場合にはすでに行った呼び出しを再度行わなければならないこともあるため、リソースが無駄になります。

メモリリークしない作りにするために、冗長な処理になってしまいがちだったかもしれません。
それをしないで済むのはこちらもありがたいです!

  • UIコントローラクラスの肥大化防止とテスト効率の向上
    • UI コントローラに対してデータベースやネットワークからのデータ読み込みも行うよう要求すると、クラスが肥大化することになります
    • UI コントローラに過度の役割を割り当てると、アプリの作業を他のクラスに任せずに 1 つのクラスですべて処理しようとすることになり、テストも困難になります。
    • ビューデータの所有権を UI コントローラのロジックから切り離すことで、複雑さが軽減され、効率性が高まります。

処理が増えてくると、ActivityFragmentが巨大化してしまい、本当のところ実際にUIをコントロールしているところが
見えにくくなってしまう、という状況になってしまいがちだと思います。
また、処理とUIコントロールの部分が分割できるよう、設計を工夫してなんとかしていたと思いますが、
公式でサポートするViewModelで出来るのは頼もしいですね!!!

ViewModelクラス

その名の通り、上記のようなUIコントローラー向けにUIデータを準備するためのViewModelヘルパークラスが用意されています!!!
それを使えば上記の問題が解決できるはずです!

使用箇所

Sunflowerリポジトリの中ではどのようにViewModelが使われているか、実際に見てみましょう

ViewModelの定義

GardenPlantingListViewModel.kt
class GardenPlantingListViewModel internal constructor(
    gardenPlantingRepository: GardenPlantingRepository
) : ViewModel() {
    val plantAndGardenPlantings: LiveData<List<PlantAndGardenPlantings>> =
            gardenPlantingRepository.getPlantedGardens()
}
  • plantAndGardenPlantingsを取得する処理は、アクティビティやフラグメントではなく、GardenPlantingListViewModel内に割り当てられてられています。
  • ViewModel内で定義しているデータはLiveDataとなっています

    • こうすると更新を監視できて、非アクティブになった時に破棄できるなどメリットがあるため、一緒に使う場合が多いと思います。
    • ※詳しくはLiveData編も併せて確認してください。
  • ちなみに、ここではplantAndGardenPlantingsの値自体の取得処理は、GardenPlantingRepositoryを利用し抽象化されており、さらに外部でインスタンス化されたものをコンストラクターで渡されています。(依存性注入)

    • こうすることで、取得先がローカルDBからサーバーに変わったとしても、このクラスとの依存関係がないため、変更は不要であり、変更に強い設計になっています。
    • 尚、依存性注入についてはDagger2を利用して実施する場合が多いですが、Sunflowerリポジトリでは、Dagger2の依存性注入については、対応しない!と明言しています笑

ViewModel利用箇所

UI コントローラー(ここではFragment)ではこのようにして、ViewModelを利用していました。

GardenFragment.kt
class GardenFragment : Fragment() {
          :
          :
    private val viewModel: GardenPlantingListViewModel by viewModels {
        InjectorUtils.provideGardenPlantingListViewModelFactory(requireContext())
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
          :
          :
        subscribeUi(adapter, binding)
        return binding.root
    }

    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
    viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
        binding.hasPlantings = !result.isNullOrEmpty()
        adapter.submitList(result)
    }
  • viewModelを定義している部分ですが、by viewModels {ラムダ式}となっています。
    • これはAndroid KTXの機能のうち、Properties Delegeteというもので、この場合は下記のパターンです。
    • →ラムダ式の中に自作のFactory Methodを書くことができ、そこで作られたインスタンスをViewModelに与えることができます
class MyFragment : Fragment() {
    val viewmodel: MYViewModel by viewmodels { myFactory }
}

ここでは、InjectorUtils#provideGardenPlantingListViewModelFactory()メソッドを実施し、このViewModelのインスタンスを取得しています

  • viewModel.plantAndGardenPlantingsLiveDataのため、observe()して変更を監視します。
  • この処理はFragmentが作成された時にコールされる処理、onCreateView()メソッド内で実施しています。
  • 値がセットされる度にonChange()メソッドがコールされ、ラムダ式内の処理が実施されます。そこで、UIの更新を実施しています。

ViewModelFactoryメソッドについて

ViewModelFactoryメソッドにはandroidx.lifecycle.ViewModelProviderを使用します。

引数無しの場合

val viewmodel: MYViewModel by viewmodels{ 
    ViewModelProvider.NewInstanceFactory().create(MYViewModel::class.java)
}

これでOKです。(新たなクラスの定義は不要です。)

引数ありの場合(今回のパターン)

ただし、今回は引数がある場合なので、
引数があるViewModelFactoryクラスを作成しています。

こちらはViewModelFactoryクラスのFactoryメソッド(Factoryクラスのコンストラクターメソッド)を呼び出している処理です。
Sunflowerリポジトリでは、kotlin:InjectorUtilsクラスに各ViewModelFactoryメソッドをまとめて記載してあります。

InjectorUtils.kt
object InjectorUtils {
                :
                :
    fun provideGardenPlantingListViewModelFactory(
        context: Context
    ): GardenPlantingListViewModelFactory {
        val repository = getGardenPlantingRepository(context)
        return GardenPlantingListViewModelFactory(repository)
    }
                :
                :
}

続いて、GardenPlantingListViewModelFactoryクラスであるkotlin:GardenPlantingListViewModelFactoryを見てみましょう

GardenPlantingListViewModelFactory.kt
class GardenPlantingListViewModelFactory(
    private val repository: GardenPlantingRepository
) : ViewModelProvider.NewInstanceFactory() {

    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GardenPlantingListViewModel(repository) as T
    }
}

GardenPlantingRepositoryを引数として渡す形で定義されています。

Sunflowerリポジトリの他のViewModelクラスも同じ形式となっていたので、別途作成する場合もこの形式を踏襲し、以下の部分の変更のみすればよさそうです

  • クラス名(◯◯◯Factory)
  • Constructorの引数
  • オーバーライドするcreateメソッドが返す引数(新たに作成したViewModelのクラス名)

ViewModelとテストについて

  • ViewModelのライフサイクルは長めに設計されています。そのため、ViewModelのテストをしやすい設計となっています。

viewmodel-lifecycle.png

  • 画面が無くなった(Activity#onDestroy())「後」に、なにかやる、などもViewmodelScope.onCleared()とかでできます。
  • この図はActivityについて書かれてますが、Fragmentなどでも基本的には同じのようです

まとめ

  • ViewModel は、ライフサイクルを意識した方法で UI 関連のデータを保存および管理するためのクラスで、画面の回転などの設定の変更後にデータを引き継ぐことができる。
  • UIコントローラー(Activity,Fragmentなど)とライフサイクルを共にするため、メモリリークの心配やリソースの無駄がない
  • UIコントローラーからViewModelを切り出すことにより、UIコントローラーの肥大化を防止し、テストをしやすくすることができる
  • Android KTXProperties DelegeteFactoryメソッドでインスンタンスを作ることができる。
  • ViewModelクラスのコンストラクタが、引数なしの場合は自前のFactoryクラスを作らないでもよいが、引数ありの場合はFactoryクラスを作る必要がある。

以上です!

参考サイト

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

【LiveData】Sunflowerリポジトリで学ぶJetPack〜LiveData編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを勧めてもらいました。

JetPackのライブラリのうち、今回はLiveData編です
DataBinding編からの続きです。

尚、引用しているソースは明記しているところ以外は、基本的には全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用しました
  • JetPackAndroidXライブラリを利用するのでCompile SDK28以上にする必要があります

そもそもLiveDataってなに?

公式の説明によると

  • LiveDataは監視可能なデータホルダー クラス
  • 通常の監視と異なるのはライフサイクルに応じた監視が可能
  • ライフサイクルがアクティブなオブザーバーのみを更新する
    • ライフサイクルの状態がSTARTED または RESUMEDの場合 = アクティブ
    • LiveDataは更新に関する情報をアクティブなオブザーバーにのみ通知
    • 非アクティブなオブザーバーには、変更に関する通知は行われない
  • オブザーバーLifecycleOwnerインターフェースを実装するオブジェクトとペアで登録できる
    • ペアリング → 対応するLifecycleオブジェクトの状態がDESTROYED → オブザーバーを削除可能
  • アクティビティフラグメントで利用すると、LiveDataオブジェクトを安全に監視可能
    • ライフサイクルが破棄されるとすぐに登録が解除される

だそうで、ざっくりいうと

LifecycleOwnerが破棄されれば登録が解除される通知機能を持つクラス
という感じでしょうか??(^^;

メリット

公式にはこんなに書いてありました!

  • UI をデータの状態と一致させることができる
  • メモリリークが発生しない
  • 停止されたアクティビティに起因するクラッシュが発生しない
  • 手動によるライフサイクル処理が行われない
  • データが常に最新
  • 適切な設定の変更
  • リソースの共有

たくさんあります!!

購読処理の実施(LiveDataとの連携)

DataBinding編で見ていた処理を確認してみましょう

使用箇所

GardenFragment.kt
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
          :
          :
        subscribeUi(adapter, binding)
        return binding.root
    }

    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
    viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
        binding.hasPlantings = !result.isNullOrEmpty()
        adapter.submitList(result)
    }
  • 名前の通りsubscribeUi()メソッドで購読処理を実施しており、plantAndGardenPlantingsobserveしています。 こうすることで、plantAndGardenPlantingsの値が変更された時にresultが更新されて、そこから算出された hasPlantingsRecyclerViewを更新します。

購読を実施するタイミングについて

この購読処理はGardenFragment#onCreateViewで実施されています。
その理由は下記によるものです。

  • 殆どの場合画面が作られた時にのみ実施されるのが適しているためです。
    • 例えば、ActivityonResume()などの場合は、繰り返し呼ばれる場合があるので不適切
  • アクティビティやフラグメントがアクティブになり次第、そのデータを表示できるため(その前に準備しておく必要がある!)

LiveDataクラスを見てみる

次にplantAndGardenPlantingsがどうなっているかをみてみましょう!

LiveDataクラスの定義

GardenPlantingListViewModel.kt
class GardenPlantingListViewModel internal constructor(
    gardenPlantingRepository: GardenPlantingRepository
) : ViewModel() {
    val plantAndGardenPlantings: LiveData<List<PlantAndGardenPlantings>> =
            gardenPlantingRepository.getPlantedGardens()
}

なにやらカッコが多くて見にくいですが
plantAndGardenPlantingsの型は LiveData<List<PlantAndGardenPlantings>> となっています
これはLiveDataクラスで、PlantAndGardenPlantingsクラスのリストの型をしているよ! という定義です。

GardenPlantingRepository.kt
class GardenPlantingRepository private constructor(
    private val gardenPlantingDao: GardenPlantingDao
) {
            :
            :
    fun getPlantedGardens() = gardenPlantingDao.getPlantedGardens()
            :
            :
}
GardenPlantingDao.kt
@Dao
interface GardenPlantingDao {
            :
            :
    @Transaction
    @Query("SELECT * FROM plants WHERE id IN (SELECT DISTINCT(plant_id) FROM garden_plantings)")
    fun getPlantedGardens(): LiveData<List<PlantAndGardenPlantings>>
            :
            :
}

GardenPlantingRepository#getPlantedGardens()GardenPlantingDao#getPlantedGardens() と順に呼び出していき、
最終的にはDBからデータを取得していますね。

Observeの定義とonChanged()

LiveData.kt
@MainThread inline fun <T> LiveData<T>.observe(
    owner: LifecycleOwner,
    crossinline onChanged: (T) -> Unit
): Observer<T> {
    val wrappedObserver = Observer<T> { t -> onChanged.invoke(t) }
    observe(owner, wrappedObserver)
    return wrappedObserver
}

そして、LiveDataの定義をみてみます。。。
とても複雑ですが、ポイントは

  • observe()はメインスレッドで実施する
  • 第二引数のラムダ式onChange()も、メインスレッドで実施する

改めて最初のobserveメソッドを実施している部分をみてみると・・・

GardenFragment.kt
    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
    viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
        binding.hasPlantings = !result.isNullOrEmpty()
        adapter.submitList(result)
    }
  • observe()メソッドのラムダ式で書いてある第二引数のonChange()メインスレッドで実行する。
  • resultに値がセットされた場合、通知がくる
  • result には list<PlantAndGardenPlantings>が返ってくる

注意点

  • 実際に動作確認を実施したところ、値が変更した時のみに動作するのかと思っていたのですが、
    そうではなく LiveDataに値がセットされる度に、onChanged()が実行されていました。
    onChanged()じゃない気がしますが。。。)

まとめ

  • LiveDataとは、LifecycleOwnerが破棄されれば登録が解除される通知機能を持つクラスです *LiveData<データ型>で定義します
  • LiveDataの購読処理はonCreate() (Fragmentの場合はonCreateView)で実施するのが殆どの場合適している。
  • 購読されたLiveDataのデータが更新されると、その度に通知が来る。
    • ただし、更新とは言っても変更する度ではなく、値がセットされる度に通知がくる。

以上です!

参考サイト

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

Sunflowerリポジトリで学ぶJetPack〜LiveData編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを勧めてもらいました。

JetPackのライブラリのうち、今回はLiveData編です
DataBinding編からの続きです。

尚、引用しているソースは明記しているところ以外は、基本的には全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用しました
  • JetPackAndroidXライブラリを利用するのでCompile SDK28以上にする必要があります

そもそもLiveDataってなに?

公式の説明によると

  • LiveDataは監視可能なデータホルダー クラス
  • 通常の監視と異なるのはライフサイクルに応じた監視が可能
  • ライフサイクルがアクティブなオブザーバーのみを更新する
    • ライフサイクルの状態がSTARTED または RESUMEDの場合 = アクティブ
    • LiveDataは更新に関する情報をアクティブなオブザーバーにのみ通知
    • 非アクティブなオブザーバーには、変更に関する通知は行われない
  • オブザーバーLifecycleOwnerインターフェースを実装するオブジェクトとペアで登録できる
    • ペアリング → 対応するLifecycleオブジェクトの状態がDESTROYED → オブザーバーを削除可能
  • アクティビティフラグメントで利用すると、LiveDataオブジェクトを安全に監視可能
    • ライフサイクルが破棄されるとすぐに登録が解除される

だそうで、ざっくりいうと

LifecycleOwnerが破棄されれば登録が解除される通知機能を持つクラス
という感じでしょうか??(^^;

メリット

公式にはこんなに書いてありました!

  • UI をデータの状態と一致させることができる
  • メモリリークが発生しない
  • 停止されたアクティビティに起因するクラッシュが発生しない
  • 手動によるライフサイクル処理が行われない
  • データが常に最新
  • 適切な設定の変更
  • リソースの共有

たくさんあります!!

購読処理の実施(LiveDataとの連携)

DataBinding編で見ていた処理を確認してみましょう

使用箇所

GardenFragment.kt
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
          :
          :
        subscribeUi(adapter, binding)
        return binding.root
    }

    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
    viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
        binding.hasPlantings = !result.isNullOrEmpty()
        adapter.submitList(result)
    }
  • 名前の通りsubscribeUi()メソッドで購読処理を実施しており、plantAndGardenPlantingsobserveしています。 こうすることで、plantAndGardenPlantingsの値が変更された時にresultが更新されて、そこから算出された hasPlantingsRecyclerViewを更新します。

購読を実施するタイミングについて

この購読処理はGardenFragment#onCreateViewで実施されています。
その理由は下記によるものです。

  • 殆どの場合画面が作られた時にのみ実施されるのが適しているためです。
    • 例えば、ActivityonResume()などの場合は、繰り返し呼ばれる場合があるので不適切
  • アクティビティやフラグメントがアクティブになり次第、そのデータを表示できるため(その前に準備しておく必要がある!)

LiveDataクラスを見てみる

次にplantAndGardenPlantingsがどうなっているかをみてみましょう!

LiveDataクラスの定義

GardenPlantingListViewModel.kt
class GardenPlantingListViewModel internal constructor(
    gardenPlantingRepository: GardenPlantingRepository
) : ViewModel() {
    val plantAndGardenPlantings: LiveData<List<PlantAndGardenPlantings>> =
            gardenPlantingRepository.getPlantedGardens()
}

なにやらカッコが多くて見にくいですが
plantAndGardenPlantingsの型は LiveData<List<PlantAndGardenPlantings>> となっています
これはLiveDataクラスで、PlantAndGardenPlantingsクラスのリストの型をしているよ! という定義です。

GardenPlantingRepository.kt
class GardenPlantingRepository private constructor(
    private val gardenPlantingDao: GardenPlantingDao
) {
            :
            :
    fun getPlantedGardens() = gardenPlantingDao.getPlantedGardens()
            :
            :
}
GardenPlantingDao.kt
@Dao
interface GardenPlantingDao {
            :
            :
    @Transaction
    @Query("SELECT * FROM plants WHERE id IN (SELECT DISTINCT(plant_id) FROM garden_plantings)")
    fun getPlantedGardens(): LiveData<List<PlantAndGardenPlantings>>
            :
            :
}

GardenPlantingRepository#getPlantedGardens()GardenPlantingDao#getPlantedGardens() と順に呼び出していき、
最終的にはDBからデータを取得していますね。

Observeの定義とonChanged()

LiveData.kt
@MainThread inline fun <T> LiveData<T>.observe(
    owner: LifecycleOwner,
    crossinline onChanged: (T) -> Unit
): Observer<T> {
    val wrappedObserver = Observer<T> { t -> onChanged.invoke(t) }
    observe(owner, wrappedObserver)
    return wrappedObserver
}

そして、LiveDataの定義をみてみます。。。
とても複雑ですが、ポイントは

  • observe()はメインスレッドで実施する
  • 第二引数のラムダ式onChange()も、メインスレッドで実施する

改めて最初のobserveメソッドを実施している部分をみてみると・・・

GardenFragment.kt
    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
    viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
        binding.hasPlantings = !result.isNullOrEmpty()
        adapter.submitList(result)
    }
  • observe()メソッドのラムダ式で書いてある第二引数のonChange()メインスレッドで実行する。
  • resultに値がセットされた場合、通知がくる
  • result には list<PlantAndGardenPlantings>が返ってくる

注意点

  • 実際に動作確認を実施したところ、値が変更した時のみに動作するのかと思っていたのですが、
    そうではなく LiveDataに値がセットされる度に、onChanged()が実行されていました。
    onChanged()じゃない気がしますが。。。)

まとめ

  • LiveDataとは、LifecycleOwnerが破棄されれば登録が解除される通知機能を持つクラスです *LiveData<データ型>で定義します
  • LiveDataの購読処理はonCreate() (Fragmentの場合はonCreateView)で実施するのが殆どの場合適している。
  • 購読されたLiveDataのデータが更新されると、その度に通知が来る。
    • ただし、更新とは言っても変更する度ではなく、値がセットされる度に通知がくる。

以上です!

参考サイト

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

【DataBinding】Sunflowerリポジトリで学ぶJetPack〜DataBinding編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はDataBinding編です

尚、引用しているソースは明記しているところ以外は、全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用
  • Data bindingはサポートライブラリなので、Android 4.0(API レベル 14)以降で利用可能
  • Android Plugin for Gradle1.5.0以降でサポートされているが、なるべく最新を使用したほうがいい(らしい)

DataBinding を利用するのに必要なこと

  • ビルド環境設定
  • レイアウトファイルを変更
  • 購読処理の実施(LiveDataとの連携)

準備

Build.gradleファイルでData Bindingの機能を有効にします
Sunflowerではapp/build.gradleに記載してありますねー!(^^)

app/build.gradle
android {
    compileSdkVersion rootProject.compileSdkVersion
    dataBinding {
        enabled = true
    }

レイアウトファイル

ここではData Bindingを利用しているfragment_garden.xmlを見てみましょう

fragment_garden.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
                name="hasPlantings"
                type="boolean" />

    </data>
    <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/garden_list"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:clipToPadding="false"
                android:layout_marginStart="@dimen/card_side_margin"
                android:layout_marginEnd="@dimen/card_side_margin"
                android:layout_marginTop="@dimen/margin_normal"
                app:isGone="@{!hasPlantings}"
                app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
                app:spanCount="@integer/grid_columns"
                tools:listitem="@layout/list_item_garden_planting"/>

        <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:gravity="center"
                android:orientation="vertical"
                app:isGone="@{hasPlantings}">

                :
                :

        </LinearLayout>

    </FrameLayout>

</layout>

ポイント

  1. レイアウトファイルが<Layout>タグで開始されている。(<LinearLayout>とかじゃない)
  2. その中に<data>タグがある。
  3. さらにその中に<variable>タグでバインディング変数を定義している
  4. バインディング変数はnameで名前を、typeで型を定義している
  5. バインディング変数の利用箇所は@{変数名}と記載する ここでは、"@{hasPlantings}"と記載している *ちなみに"@{viewModel.imageUrl}"のようなプロパティ参照もできる(list_item_garden_planting.xml参照)

ここではhasPlantingsの値によってRecyclerViewとなにもない時に表示するLinearLayoutを出し分けてますね

データのバインド

ではレイアウトファイルで記載した変数をどう関連付けるのか、Kotlinファイルを確認してみましょう

GardenFragment.kt
class GardenFragment : Fragment() {

 private lateinit var binding: FragmentGardenBinding
          :
          :
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
        val adapter = GardenPlantingAdapter()
        binding.gardenList.adapter = adapter

        binding.addPlant.setOnClickListener {
            navigateToPlantListPage()
        }

        subscribeUi(adapter, binding)
        return binding.root
    }
          :
          :
}

ポイント

1. データバインディングを実施すると、バインディングクラスがレイアウトファイルごとに作られる。 作られるクラスの名称は下記のルールで決定されます

バインディング クラスはレイアウト ファイルごとに生成されます。
デフォルトのクラス名は、レイアウト ファイルの名前がパスカルケースに変換され、Binding サフィックスが付加されたものになります。

ここの場合はfragment_garden.xmlのレイアウトファイルからFragmentGardenBindingクラスが作成されます

2. データバインディングの変数を利用するために、1.で自動生成されたバインディングクラスでinflate()します。
3. FragmentListViewRecyclerViewなどいずれかのアダプター内でデータバインディングの変数を利用する場合は、DataBindingUtil#inflate()メソッドも利用可能です

DataBindingUtil#inflate()利用箇所

GardenPlantingAdapterでは、DataBindingUtil#inflate()メソッドを利用してバインディングしていました。

GardenPlantingAdapter.kt
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(
            DataBindingUtil.inflate(
                LayoutInflater.from(parent.context),
                R.layout.list_item_garden_planting, parent, false
            )
        )
    }

購読処理の実施(LiveDataとの連携)

それでは、購読する処理を見てみましょう

GardenFragment.kt
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
          :
          :
        subscribeUi(adapter, binding)
        return binding.root
    }

    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
    viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
        binding.hasPlantings = !result.isNullOrEmpty()
        adapter.submitList(result)
    }

名前の通りsubscribeUi()メソッドで購読処理を実施しています。
なるほどー。 ここでは、実際にレイアウトにバインディングしたhasPlantingsを直接購読しているのではなく、その情報を算出するためのplantAndGardenPlantingsobserveしているんですね!
こうすることで、plantAndGardenPlantingsの値が変更された時にresultが更新されて、そこから算出された
hasPlantingsRecyclerViewを更新すると。

ここから先はLiveData編に記載します!

参考サイト

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

Sunflowerリポジトリで学ぶJetPack〜DataBinding編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はDataBinding編です

尚、引用しているソースは明記しているところ以外は、全てSunflowerのリポジトリのものです。 

環境

  • 確認時はAndroid Studioのバージョンは 3.6.2を使用
  • Data bindingはサポートライブラリなので、Android 4.0(API レベル 14)以降で利用可能
  • Android Plugin for Gradle1.5.0以降でサポートされているが、なるべく最新を使用したほうがいい(らしい)

DataBinding を利用するのに必要なこと

  • ビルド環境設定
  • レイアウトファイルを変更
  • 購読処理の実施(LiveDataとの連携)

準備

Build.gradleファイルでData Bindingの機能を有効にします
Sunflowerではapp/build.gradleに記載してありますねー!(^^)

app/build.gradle
android {
    compileSdkVersion rootProject.compileSdkVersion
    dataBinding {
        enabled = true
    }

レイアウトファイル

ここではData Bindingを利用しているfragment_garden.xmlを見てみましょう

fragment_garden.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
                name="hasPlantings"
                type="boolean" />

    </data>
    <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/garden_list"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:clipToPadding="false"
                android:layout_marginStart="@dimen/card_side_margin"
                android:layout_marginEnd="@dimen/card_side_margin"
                android:layout_marginTop="@dimen/margin_normal"
                app:isGone="@{!hasPlantings}"
                app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
                app:spanCount="@integer/grid_columns"
                tools:listitem="@layout/list_item_garden_planting"/>

        <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:gravity="center"
                android:orientation="vertical"
                app:isGone="@{hasPlantings}">

                :
                :

        </LinearLayout>

    </FrameLayout>

</layout>

ポイント

  1. レイアウトファイルが<Layout>タグで開始されている。(<LinearLayout>とかじゃない)
  2. その中に<data>タグがある。
  3. さらにその中に<variable>タグでバインディング変数を定義している
  4. バインディング変数はnameで名前を、typeで型を定義している
  5. バインディング変数の利用箇所は@{変数名}と記載する ここでは、"@{hasPlantings}"と記載している *ちなみに"@{viewModel.imageUrl}"のようなプロパティ参照もできる(list_item_garden_planting.xml参照)

ここではhasPlantingsの値によってRecyclerViewとなにもない時に表示するLinearLayoutを出し分けてますね

データのバインド

ではレイアウトファイルで記載した変数をどう関連付けるのか、Kotlinファイルを確認してみましょう

GardenFragment.kt
class GardenFragment : Fragment() {

 private lateinit var binding: FragmentGardenBinding
          :
          :
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
        val adapter = GardenPlantingAdapter()
        binding.gardenList.adapter = adapter

        binding.addPlant.setOnClickListener {
            navigateToPlantListPage()
        }

        subscribeUi(adapter, binding)
        return binding.root
    }
          :
          :
}

ポイント

1. データバインディングを実施すると、バインディングクラスがレイアウトファイルごとに作られる。 作られるクラスの名称は下記のルールで決定されます

バインディング クラスはレイアウト ファイルごとに生成されます。
デフォルトのクラス名は、レイアウト ファイルの名前がパスカルケースに変換され、Binding サフィックスが付加されたものになります。

ここの場合はfragment_garden.xmlのレイアウトファイルからFragmentGardenBindingクラスが作成されます

2. データバインディングの変数を利用するために、1.で自動生成されたバインディングクラスでinflate()します。
3. FragmentListViewRecyclerViewなどいずれかのアダプター内でデータバインディングの変数を利用する場合は、DataBindingUtil#inflate()メソッドも利用可能です

DataBindingUtil#inflate()利用箇所

GardenPlantingAdapterでは、DataBindingUtil#inflate()メソッドを利用してバインディングしていました。

GardenPlantingAdapter.kt
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(
            DataBindingUtil.inflate(
                LayoutInflater.from(parent.context),
                R.layout.list_item_garden_planting, parent, false
            )
        )
    }

購読処理の実施(LiveDataとの連携)

それでは、購読する処理を見てみましょう

GardenFragment.kt
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
          :
          :
        subscribeUi(adapter, binding)
        return binding.root
    }

    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
    viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
        binding.hasPlantings = !result.isNullOrEmpty()
        adapter.submitList(result)
    }

名前の通りsubscribeUi()メソッドで購読処理を実施しています。
なるほどー。 ここでは、実際にレイアウトにバインディングしたhasPlantingsを直接購読しているのではなく、その情報を算出するためのplantAndGardenPlantingsobserveしているんですね!
こうすることで、plantAndGardenPlantingsの値が変更された時にresultが更新されて、そこから算出された
hasPlantingsRecyclerViewを更新すると。

ここから先はLiveData編に記載します!

参考サイト

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

【Navigation】Sunflowerリポジトリで学ぶJetPack〜Navigation編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はNavigation編です

尚、引用しているソースは明記しているところ以外は、全てSunflowerのリポジトリのものです。 

環境

  • Android Studioは3.3以上が必要
  • 確認時は3.6.2を使用

Navgation を利用するのに必要なこと

大きく分けて3つ必要です
1. 準備(依存関係の記載)
1. レイアウトファイルにナビゲーションを使うことを記載する
3. ナビゲーション グラフのファイルを作る

準備

依存関係の記載

  • 公式によると、build.gradleに以下の4つの依存関係を記載する必要があります。
    • Java language implementation
    • Kotlin
    • Dynamic Feature Module Support
    • Testing Navigation

公式のbuild.gradleのサンプル

build.gradle
    dependencies {
      def nav_version = "2.3.0-alpha01"

      // Java language implementation
      implementation "androidx.navigation:navigation-fragment:$nav_version"
      implementation "androidx.navigation:navigation-ui:$nav_version"

      // Kotlin
      implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
      implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

      // Dynamic Feature Module Support
      implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"

      // Testing Navigation
      androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
    }

引用元:Navigation コンポーネント スタートガイド | 環境をセットアップする

gradleファイル

それでは、Sunflowerのbuild.gradleを見てみましょう。
Navigationと関係ないところは省略

build.gradle(sunflower)

build.gradle
buildscript {
    // Define versions in a single place
    ext {
               :
        navigationVersion = '2.2.0'
               :
        }
    dependencies {
               :
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion"
    }
}

公式のチュートリアルにはないものが含まれています!
こちらは safe argsの機能を利用する際に記載の必要があるものです。
Navgation利用時に安全にパラメータを渡す機能です。
こちらはrootbuild.gradleに記載する必要があるとのこと。
https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args

build.gradle(:app)

app/build.gradle
dependencies {
              :
    implementation "androidx.navigation:navigation-fragment-ktx:$rootProject.navigationVersion"
    implementation "androidx.navigation:navigation-ui-ktx:$rootProject.navigationVersion"
              :

$rootProject.navigationVersionbuild.gradle(sunflower)で定義しているnavigationVersion = '2.2.0'ですね

ここからわかること

  • Sunflowerリポジトリでは、下記のものは利用していないので、依存関係の記載がない。
    • Java
    • Dynamic Feature Module Support
    • Navigationのテスト
  • kotlinは使っているので、依存関係の記載がある。

layout

次にレイアウトファイルを見てみましょう。
最初に表示される画面から詳細画面に遷移するところで使っているはずなので、
最初に表示する画面を見てみます。
起動時のActivityGardenActivityでそこでsetContentViewしているxmlファイルはactivity_garden.xmlです
レイアウトはこんな感じ

activity_garden.xml

activity_garden.xml
<fragment
    android:id="@+id/nav_host"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_garden"/>
</FrameLayout>

お、やっぱりここにNavgation関連の設定をしています。

必要なこと

  1. android:name には androidx.navigation.fragment.NavHostFragmentを設定する
    • このfragmentNavigationを使うよ!
  2. app:navGraph には 利用するNavigationのXMLファイルを記載します。ここでNavGraphと関連付けます。
    • ナビゲーショングラフはこのファイルを使うよ!

その他

  • app:defaultNavHost="true"を設定すると、戻るボタンで戻るようになります。
    • 1つの画面に複数のFragmentがあり、両方でNavgationを利用している時などに、戻るボタンを効かなくする、 とかの時にはfalseにする模様

ナビゲーショングラフ

レイアウトで指定されていたnav_gardenのファイルを見てみましょう

Navigation Editor

NavEditor.png

こんな感じで、画面が複数と、その間を結ぶ矢印が表示されています。

XMLファイル

nav_garden.xml
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    app:startDestination="@id/view_pager_fragment">

    <fragment
        android:id="@+id/view_pager_fragment"
        android:name="com.google.samples.apps.sunflower.HomeViewPagerFragment"
        tools:layout="@layout/fragment_view_pager">

        <action
                android:id="@+id/action_view_pager_fragment_to_plant_detail_fragment"
                app:destination="@id/plant_detail_fragment"
                app:enterAnim="@anim/slide_in_right"
                app:exitAnim="@anim/slide_out_left"
                app:popEnterAnim="@anim/slide_in_left"
                app:popExitAnim="@anim/slide_out_right" />

    </fragment>

    <fragment
        android:id="@+id/plant_detail_fragment"
        android:name="com.google.samples.apps.sunflower.PlantDetailFragment"
        android:label="@string/plant_details_title"
        tools:layout="@layout/fragment_plant_detail">
        <argument
            android:name="plantId"
            app:argType="string" />
    </fragment>

</navigation>

ポイント

  1. 最初に表示する画面が<navigation>タグのapp:startDestinationに記載されている。
  2. 画面遷移に関連する画面が<fragment>タグで記載されている。
  3. 画面遷移の処理詳細が、遷移元の画面に<action>タグで記載されている。
  4. 遷移先画面に渡されるパラメータが遷移先画面に<argment>タグで記載されている。

※この辺りは、Navigation Editorで画面をクリックしてグイーンとやって・・・みたいな感じで設定できる部分もあるようです!
iOSでいうStoryboardみたいな感じでしょうか?)
https://developer.android.com/guide/navigation/navigation-getting-started?hl=ja#Designate-start

actionタグ設定値の記載方法について

特有の設定値については下記となっています。
※アニメーション関連の設定値が分かりづらかったので
こちらを参照しました
https://stackoverflow.com/questions/56285197/what-is-the-difference-between-enteranim-popenteranim-exitanim-popexitanim
https://developer.android.com/guide/navigation/navigation-animate-transitions

つまるところ、アニメーションはPUSH時とPOP時で分けて記述が可能ということのようです。

名称 内容    設定されている値      
android:id     遷移アクションの名称(これをソース内に記載する) @id/action_view_pager_fragment_to_plant_detail_fragment
app:destination 遷移先の画面(宛先) @id/plant_detail_fragment
app:enterAnim 宛先に遷移する際のアニメーション @anim/slide_in_right
app:exitAnim 宛先からに出て行く時のアニメーション @anim/slide_out_left
app:popEnterAnim 宛先にPOPで遷移する時のアニメーション @anim/slide_in_left
app:popExitAnim 宛先からPOPで出て行く時のアニメーション @anim/slide_out_right

使用箇所について

  • RecyclerViewの項目をタップした時に、navigatioToPlantで画面遷移を実施している。
  • navigatioToPlantactionViewPagerFragmentToPlantDetailFragment(plantId)で遷移先を決定するNavDirectionsを取得している。
  • NavDirectionsの取得にはNavDirectionsインターフェースを継承したクラスActionViewPagerFragmentToPlantDetailFragmentのコンストラクタActionViewPagerFragmentToPlantDetailFragment(plantId)使用している。
  • ActionViewPagerFragmentToPlantDetailFragmentクラスでは、NavDirectionsインターフェースのgetActionId()メソッドをオーバーライドしている。
  • getActionId()メソッドは action_view_pager_fragment_to_plant_detail_fragmentから取得している。
    ⬆︎先ほどナビゲーショングラフのXMLファイルで定義していたactionのIDですね!(やっと出てきた!(^^;;)
GardenPlantingAdapter.kt
    class ViewHolder(
        private val binding: ListItemGardenPlantingBinding
    ) : RecyclerView.ViewHolder(binding.root) {
        init {
            binding.setClickListener { view ->
                binding.viewModel?.plantId?.let { plantId ->
                    navigateToPlant(plantId, view)
                }
            }
        }

        private fun navigateToPlant(plantId: String, view: View) {
            val direction = HomeViewPagerFragmentDirections
                .actionViewPagerFragmentToPlantDetailFragment(plantId)
            view.findNavController().navigate(direction)
        }
HomeViewPagerFragmentDirections.kt
class HomeViewPagerFragmentDirections private constructor() {
  private data class ActionViewPagerFragmentToPlantDetailFragment(
    val plantId: String
  ) : NavDirections {
    override fun getActionId(): Int = R.id.action_view_pager_fragment_to_plant_detail_fragment

    override fun getArguments(): Bundle {
      val result = Bundle()
      result.putString("plantId", this.plantId)
      return result
    }
  }

  companion object {
    fun actionViewPagerFragmentToPlantDetailFragment(plantId: String): NavDirections =
        ActionViewPagerFragmentToPlantDetailFragment(plantId)
  }
}

引数の設定

遷移先の画面のタグ内の<argument>タグ内に、
遷移先画面で受け取る引数を設定可能

名称 内容    設定されている値      
android:name 引数の名前 plantId
app:argType 引数の型 string

遷移先での引数の取得

遷移先のコードは下記です。

PlantDetailFragment.kt
private val args: PlantDetailFragmentArgs by navArgs()

private val plantDetailViewModel: PlantDetailViewModel by viewModels {
    InjectorUtils.providePlantDetailViewModelFactory(requireActivity(), args.plantId)
}

公式のドキュメント
https://developer.android.com/guide/navigation/navigation-pass-data?hl=ja#Safe-args
によると、

受信側デスティネーションのコード内で、getArguments() メソッドを使用して、バンドルを取得し、そのコンテンツを使用します。-ktx 依存関係を使用している場合、Kotlin ユーザーは、by navArgs() プロパティ デリゲートを使用して引数にアクセスすることもできます。

なるほど-
ここではsafe argsという機能を利用して、遷移元から渡されたデータを遷移先でargsという変数で取得し、
それをViewModelに入れてますねー。
で、画面ではそのViewModelを介してデータを使用するようになっています。

まとめ

Sunflowerのリポジトリでは

  • KotlinNavigationを利用している
  • Navigationを利用する時ために、以下のところを追加・変更している
    • レイアウト
    • ナビゲーショングラフ
    • 呼び出し元
    • 呼び出し先で引数の取得
  • Safe Argsという機能で遷移元から遷移先へデータを渡している

以上です!

参考サイト

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

Sunflowerリポジトリで学ぶJetPack〜Navigation編

新型コロナの影響で自宅待機になってしまい、その間勉強するものとしてSunflowerリポジトリを
勧めてもらいました。

JetPackのライブラリのうち、今回はNavigation編です

尚、引用しているソースは明記しているところ以外は、全てSunflowerのリポジトリのものです。 

環境

  • Android Studioは3.3以上が必要
  • 確認時は3.6.2を使用

Navgation を利用するのに必要なこと

大きく分けて3つ必要です
1. 準備(依存関係の記載)
1. レイアウトファイルにナビゲーションを使うことを記載する
3. ナビゲーション グラフのファイルを作る

準備

依存関係の記載

  • 公式によると、build.gradleに以下の4つの依存関係を記載する必要があります。
    • Java language implementation
    • Kotlin
    • Dynamic Feature Module Support
    • Testing Navigation

公式のbuild.gradleのサンプル

build.gradle
    dependencies {
      def nav_version = "2.3.0-alpha01"

      // Java language implementation
      implementation "androidx.navigation:navigation-fragment:$nav_version"
      implementation "androidx.navigation:navigation-ui:$nav_version"

      // Kotlin
      implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
      implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

      // Dynamic Feature Module Support
      implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"

      // Testing Navigation
      androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
    }

引用元:Navigation コンポーネント スタートガイド | 環境をセットアップする

gradleファイル

それでは、Sunflowerのbuild.gradleを見てみましょう。
Navigationと関係ないところは省略

build.gradle(sunflower)

build.gradle
buildscript {
    // Define versions in a single place
    ext {
               :
        navigationVersion = '2.2.0'
               :
        }
    dependencies {
               :
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion"
    }
}

公式のチュートリアルにはないものが含まれています!
こちらは safe argsの機能を利用する際に記載の必要があるものです。
Navgation利用時に安全にパラメータを渡す機能です。
こちらはrootbuild.gradleに記載する必要があるとのこと。
https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args

build.gradle(:app)

app/build.gradle
dependencies {
              :
    implementation "androidx.navigation:navigation-fragment-ktx:$rootProject.navigationVersion"
    implementation "androidx.navigation:navigation-ui-ktx:$rootProject.navigationVersion"
              :

$rootProject.navigationVersionbuild.gradle(sunflower)で定義しているnavigationVersion = '2.2.0'ですね

ここからわかること

  • Sunflowerリポジトリでは、下記のものは利用していないので、依存関係の記載がない。
    • Java
    • Dynamic Feature Module Support
    • Navigationのテスト
  • kotlinは使っているので、依存関係の記載がある。

layout

次にレイアウトファイルを見てみましょう。
最初に表示される画面から詳細画面に遷移するところで使っているはずなので、
最初に表示する画面を見てみます。
起動時のActivityGardenActivityでそこでsetContentViewしているxmlファイルはactivity_garden.xmlです
レイアウトはこんな感じ

activity_garden.xml

activity_garden.xml
<fragment
    android:id="@+id/nav_host"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_garden"/>
</FrameLayout>

お、やっぱりここにNavgation関連の設定をしています。

必要なこと

  1. android:name には androidx.navigation.fragment.NavHostFragmentを設定する
    • このfragmentNavigationを使うよ!
  2. app:navGraph には 利用するNavigationのXMLファイルを記載します。ここでNavGraphと関連付けます。
    • ナビゲーショングラフはこのファイルを使うよ!

その他

  • app:defaultNavHost="true"を設定すると、戻るボタンで戻るようになります。
    • 1つの画面に複数のFragmentがあり、両方でNavgationを利用している時などに、戻るボタンを効かなくする、 とかの時にはfalseにする模様

ナビゲーショングラフ

レイアウトで指定されていたnav_gardenのファイルを見てみましょう

Navigation Editor

NavEditor.png

こんな感じで、画面が複数と、その間を結ぶ矢印が表示されています。

XMLファイル

nav_garden.xml
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    app:startDestination="@id/view_pager_fragment">

    <fragment
        android:id="@+id/view_pager_fragment"
        android:name="com.google.samples.apps.sunflower.HomeViewPagerFragment"
        tools:layout="@layout/fragment_view_pager">

        <action
                android:id="@+id/action_view_pager_fragment_to_plant_detail_fragment"
                app:destination="@id/plant_detail_fragment"
                app:enterAnim="@anim/slide_in_right"
                app:exitAnim="@anim/slide_out_left"
                app:popEnterAnim="@anim/slide_in_left"
                app:popExitAnim="@anim/slide_out_right" />

    </fragment>

    <fragment
        android:id="@+id/plant_detail_fragment"
        android:name="com.google.samples.apps.sunflower.PlantDetailFragment"
        android:label="@string/plant_details_title"
        tools:layout="@layout/fragment_plant_detail">
        <argument
            android:name="plantId"
            app:argType="string" />
    </fragment>

</navigation>

ポイント

  1. 最初に表示する画面が<navigation>タグのapp:startDestinationに記載されている。
  2. 画面遷移に関連する画面が<fragment>タグで記載されている。
  3. 画面遷移の処理詳細が、遷移元の画面に<action>タグで記載されている。
  4. 遷移先画面に渡されるパラメータが遷移先画面に<argment>タグで記載されている。

※この辺りは、Navigation Editorで画面をクリックしてグイーンとやって・・・みたいな感じで設定できる部分もあるようです!
iOSでいうStoryboardみたいな感じでしょうか?)
https://developer.android.com/guide/navigation/navigation-getting-started?hl=ja#Designate-start

actionタグ設定値の記載方法について

特有の設定値については下記となっています。
※アニメーション関連の設定値が分かりづらかったので
こちらを参照しました
https://stackoverflow.com/questions/56285197/what-is-the-difference-between-enteranim-popenteranim-exitanim-popexitanim
https://developer.android.com/guide/navigation/navigation-animate-transitions

つまるところ、アニメーションはPUSH時とPOP時で分けて記述が可能ということのようです。

名称 内容    設定されている値      
android:id     遷移アクションの名称(これをソース内に記載する) @id/action_view_pager_fragment_to_plant_detail_fragment
app:destination 遷移先の画面(宛先) @id/plant_detail_fragment
app:enterAnim 宛先に遷移する際のアニメーション @anim/slide_in_right
app:exitAnim 宛先からに出て行く時のアニメーション @anim/slide_out_left
app:popEnterAnim 宛先にPOPで遷移する時のアニメーション @anim/slide_in_left
app:popExitAnim 宛先からPOPで出て行く時のアニメーション @anim/slide_out_right

使用箇所について

  • RecyclerViewの項目をタップした時に、navigatioToPlantで画面遷移を実施している。
  • navigatioToPlantactionViewPagerFragmentToPlantDetailFragment(plantId)で遷移先を決定するNavDirectionsを取得している。
  • NavDirectionsの取得にはNavDirectionsインターフェースを継承したクラスActionViewPagerFragmentToPlantDetailFragmentのコンストラクタActionViewPagerFragmentToPlantDetailFragment(plantId)使用している。
  • ActionViewPagerFragmentToPlantDetailFragmentクラスでは、NavDirectionsインターフェースのgetActionId()メソッドをオーバーライドしている。
  • getActionId()メソッドは action_view_pager_fragment_to_plant_detail_fragmentから取得している。
    ⬆︎先ほどナビゲーショングラフのXMLファイルで定義していたactionのIDですね!(やっと出てきた!(^^;;)
GardenPlantingAdapter.kt
    class ViewHolder(
        private val binding: ListItemGardenPlantingBinding
    ) : RecyclerView.ViewHolder(binding.root) {
        init {
            binding.setClickListener { view ->
                binding.viewModel?.plantId?.let { plantId ->
                    navigateToPlant(plantId, view)
                }
            }
        }

        private fun navigateToPlant(plantId: String, view: View) {
            val direction = HomeViewPagerFragmentDirections
                .actionViewPagerFragmentToPlantDetailFragment(plantId)
            view.findNavController().navigate(direction)
        }
HomeViewPagerFragmentDirections.kt
class HomeViewPagerFragmentDirections private constructor() {
  private data class ActionViewPagerFragmentToPlantDetailFragment(
    val plantId: String
  ) : NavDirections {
    override fun getActionId(): Int = R.id.action_view_pager_fragment_to_plant_detail_fragment

    override fun getArguments(): Bundle {
      val result = Bundle()
      result.putString("plantId", this.plantId)
      return result
    }
  }

  companion object {
    fun actionViewPagerFragmentToPlantDetailFragment(plantId: String): NavDirections =
        ActionViewPagerFragmentToPlantDetailFragment(plantId)
  }
}

引数の設定

遷移先の画面のタグ内の<argument>タグ内に、
遷移先画面で受け取る引数を設定可能

名称 内容    設定されている値      
android:name 引数の名前 plantId
app:argType 引数の型 string

遷移先での引数の取得

遷移先のコードは下記です。

PlantDetailFragment.kt
private val args: PlantDetailFragmentArgs by navArgs()

private val plantDetailViewModel: PlantDetailViewModel by viewModels {
    InjectorUtils.providePlantDetailViewModelFactory(requireActivity(), args.plantId)
}

公式のドキュメント
https://developer.android.com/guide/navigation/navigation-pass-data?hl=ja#Safe-args
によると、

受信側デスティネーションのコード内で、getArguments() メソッドを使用して、バンドルを取得し、そのコンテンツを使用します。-ktx 依存関係を使用している場合、Kotlin ユーザーは、by navArgs() プロパティ デリゲートを使用して引数にアクセスすることもできます。

なるほど-
ここではsafe argsという機能を利用して、遷移元から渡されたデータを遷移先でargsという変数で取得し、
それをViewModelに入れてますねー。
で、画面ではそのViewModelを介してデータを使用するようになっています。

まとめ

Sunflowerのリポジトリでは

  • KotlinNavigationを利用している
  • Navigationを利用する時ために、以下のところを追加・変更している
    • レイアウト
    • ナビゲーショングラフ
    • 呼び出し元
    • 呼び出し先で引数の取得
  • Safe Argsという機能で遷移元から遷移先へデータを渡している

以上です!

参考サイト

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