20201017のAndroidに関する記事は6件です。

GoでローカルWebServerを立てた

初めに

GoでWebServerを立てて、Androidアプリからアクセスする

環境

PC Windows10
Android Studio 4.0
Kotlin 1.3.72
Android端末 Emulator(API Level 29)
Go 1.11.1

構成図

go_server.png
Androidアプリからファイルを送信して、サーバー側で保存、サーバーからアプリにステータスコードを返却する

実装

ローカルサーバー

Server.go
package controllers

import (
    "fmt"
    "net/http"
    "io/ioutil"
    "os"
)

func apiSampleHandler(w http.ResponseWriter, r *http.Request) {
    switch(r.Method) {
        case "POST":
            fmt.Println("POST")
            // アップロードしたファイルとヘッダーを取得 
            file, fileHeader, _ := r.FormFile ("upload_file") 
            fileName := fileHeader.Filename // ヘッダーからファイル名を取得
            defer file.Close()              // ファイルを最後にClose
            data, _ := ioutil.ReadAll(file) // ファイルを読み出し
            saveFile, _ := os.Create(fileName) // 保存用のファイルを生成
            defer saveFile.Close()          // 保存用のファイルを最後にClose
            _, err = saveFile.Write(data)   // ファイルに書き込み
            if err != nil {
                fmt.Println("can not write file")
                w.WriteHeader(http.StatusBadRequest)
                return
            }
        case "GET":
            fmt.Println("GET")
    }
    // 200:OKを返す 
    w.WriteHeader(http.StatusOK)
}

// main.goから呼び出してサーバーを起動
func StartWebServer() error {
    http.HandleFunc("/", apiSampleHandler)
    return http.ListenAndServe(":8080", nil)
}

main.goを実行し、サーバーを起動
http://localhost:8080 にアクセスして、サーバーが起動していることを確認

Android アプリ

1.Androidmanifest.xml

インターネットにアクセスするためのPermissionを定義

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />

Android 9.0からhttpの通信の設定がOFFになるのでusesCleartextTraficをtrueに設定

AndroidManifest.xml
<application
     省略
    android:usesCleartextTraffic="true">
    <activity android:name=".MainActivity">
        省略
    </activity>
</application>

2.ファイルを生成

Android端末のアプリ固有領域(/data/data/{package_name}/files配下)にテキストファイルを生成

File.kt
const val FILE_EXPAND = ".txt"

class File {
    fun makeTxtFile(context: Context, fileName: String, str: String) {
        try {
            context.openFileOutput(fileName + FILE_EXPAND, Context.MODE_PRIVATE).use {
                it.write(str.toByteArray())
            }
        } catch (e: IOException) {
            Log.e("File", "#makeTxtFile $e")
        }
    }
}

ファイルの生成を確認
Device File Explorer でデバイス上のファイルを表示する

3.サーバーアクセス

ファイルを読み込んで、http通信でファイルを送信する

Net.kt
 fun startConnection(context: Context, requestUrl: String, requestMethod: String, fileName: String): Pair<Int, String> {
        val url = URL(requestUrl)                      // URLオブジェクト生成
        // UrlConnectionオブジェクト生成
        val urlConnection = url.openConnection() as HttpURLConnection 
        var result = ""
        var responseCode = 0
        try {
            val boundary = "--------------------------" 
            urlConnection.requestMethod = requestMethod // POST, GETなど
            urlConnection.doOutput = true               // リクエストボディの送信
            urlConnection.doInput = true                // レスポンスボディの受信
            urlConnection.useCaches = false             // キャッシュの利用
            // multipart/form-data:複数のデータを送信、boundary:複数データ間の区切り
            urlConnection.setRequestProperty(
                "Content-Type",
                "multipart/form-data; boundary=$boundary" 
            )
            val filePath = context.filesDir             // アプリ固有領域のパス
            val file = File("$filePath/$fileName$FILE_EXPAND") // ファイルオブジェクトを生成
            FileInputStream(file).use { fileInputStream ->
                urlConnection.connect()                 // コネクションを確立
                DataOutputStream(urlConnection.outputStream).use {
                   it.writeBytes(                       // headerを設定
                       TWO_HYPHEN + boundary + LINE_END +
                               "Content-Disposition: form-data; name=\"upload_file\"; " +
                               "filename=\"$fileName$FILE_EXPAND\"$LINE_END" +
                               "Content-Type: application/octet-stream$LINE_END$LINE_END"
                   )
                   // ファイル書き込み
                   val buffer = ByteArray(BUFFER_SIZE)
                   var bytesRead: Int
                   do {
                       bytesRead = fileInputStream.read(buffer)
                       if (bytesRead == -1) break
                       it.write(buffer, 0, bytesRead)
                   } while (true)
                   it.writeBytes(                       // footerの設定
                        LINE_END + TWO_HYPHEN + boundary + TWO_HYPHEN + LINE_END
                   )
                   it.flush()
                }
            }
            responseCode = urlConnection.responseCode   // レスポンスコードを取得

            BufferedReader(InputStreamReader(urlConnection.inputStream)).use {
                val sb = StringBuffer()
                for (line in it.readLines()) {
                    line.let { sb.append(line) }
                }
                result = sb.toString()
            }
        } catch (e: Exception) {
            Log.e("Net", "#startConnection$e")
        } finally {
            urlConnection.disconnect()
        }
        return responseCode to result
    }
}

4.IPアドレスの確認

アクセスしたいサーバー(PC)のIPアドレスをcommand promptで以下を実行し確認

>ipconfig

5.PCとAndroid端末を同一のネットワーク環境に接続

6.Androidにて接続

Net#startConnectionのrequestUrlにhttp://{確認したIPアドレス}:8080を設定してアプリを実行

ファイルのアップロードを確認

PCにてmain.goと同じディレクトリを確認する
ファイルが作成されていれば完了

最後に

全体ソースはこちら
アクセス先やRequestMethod、ファイル名はアプリで動的に変更できるようにしています。

参考文献

Goサーバー側

Go言語でのファイルアップロード

Androidアプリ側

[Android] アプリ内にファイルを保存する FileOutputStream, FileInputStream
Upload File To Server - Android Example
[Android]kotlinでのwhile-FileInputStream.readエラー
【Kotlin】複数の値を返したい!

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

GoでローカルWebServerを立てた~ファイルアップロード~

初めに

GoでWebServerを立てて、Androidアプリからファイルのアップロードを行う

環境

PC Windows10
Android Studio 4.0
Kotlin 1.3.72
Android端末 Emulator(API Level 29)
Go 1.11.1

構成図

go_server.png
Androidアプリからファイルを送信して、サーバー側で保存、サーバーからアプリにステータスコードを返却する

実装

ローカルサーバー

Server.go
package controllers

import (
    "fmt"
    "net/http"
    "io/ioutil"
    "os"
)

func apiSampleHandler(w http.ResponseWriter, r *http.Request) {
    switch(r.Method) {
        case "POST":
            fmt.Println("POST")
            // アップロードしたファイルとヘッダーを取得 
            file, fileHeader, _ := r.FormFile ("upload_file") 
            fileName := fileHeader.Filename // ヘッダーからファイル名を取得
            defer file.Close()              // ファイルを最後にClose
            data, _ := ioutil.ReadAll(file) // ファイルを読み出し
            saveFile, _ := os.Create(fileName) // 保存用のファイルを生成
            defer saveFile.Close()          // 保存用のファイルを最後にClose
            _, err = saveFile.Write(data)   // ファイルに書き込み
            if err != nil {
                fmt.Println("can not write file")
                w.WriteHeader(http.StatusBadRequest)
                return
            }
        case "GET":
            fmt.Println("GET")
    }
    // 200:OKを返す 
    w.WriteHeader(http.StatusOK)
}

// main.goから呼び出してサーバーを起動
func StartWebServer() error {
    http.HandleFunc("/", apiSampleHandler)
    return http.ListenAndServe(":8080", nil)
}

main.goを実行し、サーバーを起動
http://localhost:8080 にアクセスして、サーバーが起動していることを確認

Android アプリ

1.Androidmanifest.xml

インターネットにアクセスするためのPermissionを定義

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />

Android 9.0からhttpの通信の設定がOFFになるのでusesCleartextTraficをtrueに設定

AndroidManifest.xml
<application
     省略
    android:usesCleartextTraffic="true">
    <activity android:name=".MainActivity">
        省略
    </activity>
</application>

2.ファイルを生成

Android端末のアプリ固有領域(/data/data/{package_name}/files配下)にテキストファイルを生成

File.kt
const val FILE_EXPAND = ".txt"

class File {
    fun makeTxtFile(context: Context, fileName: String, str: String) {
        try {
            context.openFileOutput(fileName + FILE_EXPAND, Context.MODE_PRIVATE).use {
                it.write(str.toByteArray())
            }
        } catch (e: IOException) {
            Log.e("File", "#makeTxtFile $e")
        }
    }
}

ファイルの生成を確認
Device File Explorer でデバイス上のファイルを表示する

3.サーバーアクセス

ファイルを読み込んで、http通信でファイルを送信する

Net.kt
 fun startConnection(context: Context, requestUrl: String, requestMethod: String, fileName: String): Pair<Int, String> {
        val url = URL(requestUrl)                      // URLオブジェクト生成
        // UrlConnectionオブジェクト生成
        val urlConnection = url.openConnection() as HttpURLConnection 
        var result = ""
        var responseCode = 0
        try {
            val boundary = "--------------------------" 
            urlConnection.requestMethod = requestMethod // POST, GETなど
            urlConnection.doOutput = true               // リクエストボディの送信
            urlConnection.doInput = true                // レスポンスボディの受信
            urlConnection.useCaches = false             // キャッシュの利用
            // multipart/form-data:複数のデータを送信、boundary:複数データ間の区切り
            urlConnection.setRequestProperty(
                "Content-Type",
                "multipart/form-data; boundary=$boundary" 
            )
            val filePath = context.filesDir             // アプリ固有領域のパス
            val file = File("$filePath/$fileName$FILE_EXPAND") // ファイルオブジェクトを生成
            FileInputStream(file).use { fileInputStream ->
                urlConnection.connect()                 // コネクションを確立
                DataOutputStream(urlConnection.outputStream).use {
                   it.writeBytes(                       // headerを設定
                       TWO_HYPHEN + boundary + LINE_END +
                               "Content-Disposition: form-data; name=\"upload_file\"; " +
                               "filename=\"$fileName$FILE_EXPAND\"$LINE_END" +
                               "Content-Type: application/octet-stream$LINE_END$LINE_END"
                   )
                   // ファイル書き込み
                   val buffer = ByteArray(BUFFER_SIZE)
                   var bytesRead: Int
                   do {
                       bytesRead = fileInputStream.read(buffer)
                       if (bytesRead == -1) break
                       it.write(buffer, 0, bytesRead)
                   } while (true)
                   it.writeBytes(                       // footerの設定
                        LINE_END + TWO_HYPHEN + boundary + TWO_HYPHEN + LINE_END
                   )
                   it.flush()
                }
            }
            responseCode = urlConnection.responseCode   // レスポンスコードを取得

            BufferedReader(InputStreamReader(urlConnection.inputStream)).use {
                val sb = StringBuffer()
                for (line in it.readLines()) {
                    line.let { sb.append(line) }
                }
                result = sb.toString()
            }
        } catch (e: Exception) {
            Log.e("Net", "#startConnection$e")
        } finally {
            urlConnection.disconnect()
        }
        return responseCode to result
    }
}

4.IPアドレスの確認

アクセスしたいサーバー(PC)のIPアドレスをcommand promptで以下を実行し確認

>ipconfig

5.PCとAndroid端末を同一のネットワーク環境に接続

6.Androidにて接続

Net#startConnectionのrequestUrlにhttp://{確認したIPアドレス}:8080を設定してアプリを実行

ファイルのアップロードを確認

PCにてmain.goと同じディレクトリを確認する
ファイルが作成されていれば完了

最後に

全体ソースはこちら
アクセス先やRequestMethod、ファイル名はアプリで動的に変更できるようにしています。

参考文献

Goサーバー側

Go言語でのファイルアップロード

Androidアプリ側

[Android] アプリ内にファイルを保存する FileOutputStream, FileInputStream
Upload File To Server - Android Example
[Android]kotlinでのwhile-FileInputStream.readエラー
【Kotlin】複数の値を返したい!

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

Android Styling をイチからやり直す

この記事なに?

Android Styling とは Android アプリの見た目を決める一連の仕組みです。Android アプリを作ったことがある人なら必ず触れている基本ですが、ちゃんと理解できていますか? デフォルトで生成された themes.xml を雰囲気で書いているだけではないですか?
これは僕が Android Styling を使いこなすために今一度イチから勉強し直したまとめです。
間違っていたら教えてください?

ある特定の View の見た目を変える

Style や Theme の話に入る前に、View の見た目を変える方法を振り返ります。
Android の View の見た目を操作する最も基本の方法は View attribute です。View attribute は View に外部から設定する値です。xml で設定することが多いです。
例えば TextView の文字色は以下のように変えられます。

<TextView
    android:text="サンプル"
    android:textColor="#EC407A"
    ... />

android:textColor が1つの View attribute です。TextView は他にもたくさんの View attribute を定義しており、開発者は必要に応じてこれらの View attribute に値を設定します。xml で指定した View attribute は TextView のコンストラクタ引数に AttributeSet として渡され、textColor として設定されます。
もっと深く View attribute について知りたい場合は以下のドキュメントが参考になります。

Creating a View Class  |  Android Developers

Style と Theme

1つ1つの View に attribute を設定していてはリソースの適用が大変です。View attribute の設定値をUIコンポーネントとして意味のある単位でグループ化し、同じような View に再利用するほうが効率的です。
ここで登場するのが StyleTheme です。
Style と Theme の違いは Nick のこの記事がとてもわかり易いです。

Android styling: themes vs styles | by Nick Butcher | Android Developers | Medium

Style は特定の View の View attribute を一括設定するもの

Nick は StyleMap<View attribute, Resource> と表現しています。
つまり、設定したい View attribute と設定すべきリソースの値の組が集合したものです。
例えば以下の例は Material Button の Style を拡張して、カスタマイズしたい View attribute を設定しています。

<style name="Widget.MyApp.Button" parent="Widget.MaterialComponents.Button">
    <item name="android:textColor">#EF5350</item>
    <item name="android:padding">8dp</item>
</style>

Style は特定の View に適用する

Style は複数の View に対して同じ View attribute を設定する用途で利用します。
Style を View に適用するときは style attribute を使います。

<Button
    style="@style/Widget.MyApp.Button"
    android:text="ボタン"
    ... />

親 View や 子 View には Style は伝搬しません。
影響範囲は sytle attribute を設定した View のみです。

Theme はリソースの参照を提供するもの

他方、Nick は ThemeMap<Theme attribute, Resource> と表現しています。 Theme attribute とは、アプリ内で定義したリソース (Style, Color, TextAppearance, etc...) を別のリソースから参照できるように定義する値です。
Theme attribute はリソースの使い道を名前にしたセマンティック名で定義します。

Theme は以下のように定義します。

<style name="MyAppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <!-- アプリのメインカラー -->
    <item name="colorPrimary">@color/red</item>

    <!-- アプリで利用する Material Button の Style -->
    <item name="materialButtonStyle">@style/Widget.MyApp.Button</item> 
</style>

Theme attribute で設定したリソースを参照する場合は ?attr/[themeAttributeName] で参照できます。

<Button
    android:textAppearance="?attr/textAppearanceButton"
    ... />

利用する Theme attribute は <style> タグの中で好き勝手定義して良いものではなく、attrs.xml で定義しないといけません。
例えば、colorPrimary は AppCompat の attrs.xml で定義されています。自分のプロジェクトで attrs.xml を作ってオリジナルの Theme attribute を定義することもできます。

<attr name="colorPrimary" format="color"/>

Theme は Context に適用する

Theme は Context (Application, Activity, View, etc...) に適用します。
影響範囲は android:theme を設定したノードから下のノード全てです。
例えば、以下のような View のツリー構造があった場合、MyTheme の適用範囲は child1 の View と child1 を親に持つ全ての子 View です。

<LinearLayout
    android:id="+id/root" 
    ... >
    <LinearLayout
        android:id="+id/child1"
        android:theme="@style/MyTheme">
        <!-- child1 以下は MyTheme の attribute が使える -->
        <Button
            .../>
    </LinearLayout>

    <!-- child2 は MyTheme の attribute は使えない -->
    <Button
        android:id="+id/child2"
        ... />
</LinearLayout>

ここでいう Theme を適用する とは、Theme に設定された Theme attribute を利用できる状態にするという意味になります。

View attribute の設定はできるだけ Theme attribute を経由する

layout.xml に書いた View や自分で定義した Style で設定する View attribute の値は、できるだけ Theme attribute を経由したほうが良いです。なぜなら、ブランディング変更やダークテーマ対応など、アプリ全体の見た目を変更したい場合、Theme attribute を使ってリソースを間接参照していれば Theme を編集するだけで済みます。
View が Theme を介してリソースを参照する場合

View が直接リソースを参照していると、個々の layout.xml で設定している View attribute の設定値をすべて変更していかないといけなくなります。これは View の増加に伴って作業量が爆発的に増えていき、メンテが大変な layout が出来上がります。
View が直接リソースを参照する場合

Style や Theme は既存のものをカスタマイズしていく

Android には様々な View attribute, Theme attribute があるため、自分で Style や Theme をイチから作り上げるのは骨が折れます。そのため Android Styling システムは、ビルトインで提供されている Style や Theme を継承し、開発者が必要な attribute だけ変更できるように作られています。

以下の例だと、AppCompat で定義された Theme である Theme.AppCompat.Light.DarkActionBar を継承し、カスタマイズしたい Theme attribute だけ個別設定しています。

<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="colorPrimary"> ... </item>
    <item name="colorAccent"> ... </item>
</style>

colorPrimary, colorAccent 以外の Theme attribute は Theme.AppCompat.Light.DarkActionBar から引き継いでいます。
他にどんな Theme attribute があるのか気になる人はここで確認できます。めちゃくちゃあります。

View attribute に設定されている値を確認する方法

Style や Theme を継承して attribute を引き継いでいると、具体的にどの値が View attribute に設定されるかわからなくなります。
個人的には、特に xml で明示的に設定していないデフォルト値が一体どの Style/Theme 由来なのかが分かりにくいです。
そんなときは、Android Studio で View に設定されている全ての View attribute を確認できます。

気になる View を選んで、Attributes パネル を見ます。All Attributes にデフォルト値を含むすべての attribute の設定が表示されています。虫眼鏡アイコンを押せば attribute 名で検索もできます。
スクリーンショット 2020-10-15 1.36.59.png

Attributes パネルでは設定されているリソース名までしか確認できません。また、Style などで間接的に値を設定されている場合も確認できない気がします。(この辺はよく分かっていないので知ってる人教えて! )
とにかく、表示されないときの条件は不明ですが、Attribute パネルだけではわからないときがあります。
スクリーンショット 2020-10-15 1.49.29.png

Attribute パネルでわからないときは、実際にアプリを実行して調べる方法があります。
アプリを実行して Layout Inspector で気になる View を選択すると、実際に利用されているリソースのリテラル値が表示されます。
スクリーンショット 2020-10-15 1.48.50.png

まとめ

Android Styling の基本をまとめました。
この記事では Styling のコアな部分しか触れていません。View attribute が適用される優先順位や、Theme Overlay の細かい挙動など、Styling で知っておくべきことはまだまだあります。Android Developers のドキュメントやブログ記事でたくさん解説されているので、興味のある方はチェックしてみてください!

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

OpenJDKのDockerコンテナを使ってKotlinアプリのビルドを行う

経緯

  • KotlinでAndroid開発をやっているが、毎度AndroidStudioでビルドするのが面倒になってきた
  • gitlabを使ってるのでついでにgitlabciを使ってみよう(別プロジェクトでrunner設定済みだったので今回必要なさそう)
  • まずは手動でコンテナからビルドできるか試してみよう

Dockerインストール

最近手に入れた開発用Macなので、まだDockerが入っていませんでしたorz
ここは特に書くことないので省略します

コンテナ起動まで

今回はopenjdk8を使用します
理由はgitlabciのAndroid用テンプレートYAMLにデフォルトとして使われているのがこいつだったのと、JAVAのパッケージとかをインストールする手間が省けるからです(だから使われてるんだと思うけど)

まずはいつも通りpullします
バージョン指定は必須です

docker pull openjdk:8-jdk

そしてコンテナ起動します

docker run -it openjdk:8-jdk

必要なパッケージインストール

上記まで行うと自動的にコンテナの中に入れると思うので、パッケージをインストールしていきます

apt-get --quiet update --yes
apt-get --quiet install --yes wget tar unzip lib32stdc++6 lib32z1

export ANDROID_COMPILE_SDK=28
export ANDROID_BUILD_TOOLS=29.0.3
export ANDROID_SDK_TOOLS=6514223
export ANDROID_HOME="/android-home"

install -d $ANDROID_HOME
wget --output-document=$ANDROID_HOME/cmdline-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_TOOLS}_latest.zip

pushd $ANDROID_HOME
unzip -d cmdline-tools cmdline-tools.zip
popd
export PATH=$PATH:${ANDROID_HOME}/cmdline-tools/tools/bin/

sdkmanager --version

yes | sdkmanager --sdk_root=${ANDROID_HOME} --licenses || true
sdkmanager --sdk_root=${ANDROID_HOME} "platforms;android-${ANDROID_COMPILE_SDK}"
sdkmanager --sdk_root=${ANDROID_HOME} "platform-tools"
sdkmanager --sdk_root=${ANDROID_HOME} "build-tools;${ANDROID_BUILD_TOOLS}"

chmod +x ./gradlew

コンソールでエラーが出ていないかの確認と、念の為printenvで環境変数の確認くらいはやっても良いと思います

git clone

ビルドするソースコードをコンテナ内に持ってきます
ここはcloneするだけなので省略

ビルド実行

ビルド自体は簡単で、Gradleのコマンドを叩くだけです

./gradlew assemble

variantsとflavorを使いたい人はコマンドを変更してください
こちらを参考にさせていただきました

ただ、このままだとエラーになってうまく行かないです
私はここで躓いてしまったので、そのポイントを2点紹介いたします

local.properties作成

ビルドした際に下記のエラーが発生しました

「SDK location not found. Define location with an ANDROID_SDK_ROOT environment variable or by setting the sdk.dir path in your project's local properties file at」

「local propertiesにSDKの場所が書かれてない」的なエラーに見えます

言われたとおりlocal propertiesを探したのですが見つかりませんでした

AndroidStudioをのときは勝手に作られてた(多分)ので意識してなかったのですが、どうやらlocal propertiesを作り、SDKの場所を記載しないといけないようです

そもそもvimを入れてなかったのでそこからです^^;

apt-get install vim

アプリのトップフォルダに移動して

vi local.properties

中身はこんな感じです

## This file is automatically generated by Android Studio.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Wed Jan 07 15:34:05 JST 2015
sdk.dir=/android-home

これで再度ビルドコマンドを叩きましたが、今度は別のエラーが

メモリ上限解放

次のエラーは
「Could not connect to kotlin daemon. Using fallback strategy.」
という内容

調べてみると、メモリが上限まで行ってデーモンちゃんが死んじゃったみたいです

Macのステータスバーにあるクジラのマークをクリックして、dockerのアプリから設定を変更して上限メモリを増やしてあげます
やり方の詳細はこちらを参考にさせていただきました

ビルド成功!!

ようやく「BUILD SUCCESSFUL」の文字が!

ビルドの際に署名を聞かれなかったことが気になってるので、そこに関しては後日追記したいと思います
Debugだからかなーと思いましたが、AndroidStudioでビルドするときはReleaseでもDebugでも署名必要なのでなんとも言えませんね

今回はdockerfileを作りませんでした
理由はgitlabci.yamlに記載するからです

次回は

  • gitlabciからcommit時に自動ビルド
  • variantsとflavorを指定してビルド

あたりができたらなと思います

以上です
お付き合いいただきありがとうございました

※全体的にこちらも参考にさせていただきました

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

Dagger Hilt (DevFest 2020 資料)

DevFestの資料の記事版です。ステップごとにサンプルアプリの差分付きで説明します。
なぜDependency Injectionが必要なのか から始め、Dagger Hiltの説明、実践的なプラクティスまで説明していきます!

Dependency Injection(DI)とはなにか

なぜDIが必要なのか

DI、ちょっと難しいイメージありますが、そもそもなんで必要なんでしょうか?
作っているのが動画再生するアプリでVideoPlayerというクラスがあるとしましょう。
VideoPlayerのクラスの中にデータベースやcodecなどがハードコードされています。

コード: https://github.com/takahirom/hilt-sample-app/commit/8c36602aaa4e27d8f10c81e2808f0ff452f1c8a4#diff-bbc9d28d8bcbd080a704cacc92b8cf37R19

class VideoPlayer {
    // データベースにビデオの一覧が保存されている (Roomというライブラリを使っている)
    private val database = Room
        .databaseBuilder(
            App.instance,
            VideoDatabase::class.java,
            "database"
        )
        .createFromAsset("videos.db")
        .build()
    // 使えるコーデック一覧
    private val codecs = listOf(FMP4, WebM, MPEG_TS, AV1)

    private var isPlaying = false

    fun play() {
...
    }
}

使うときにはこれだけで、十分シンプルに見えます。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val videoPlayer = VideoPlayer()
        videoPlayer.play()
    }
}

しかし、これだけだと開発を進めていく中で以下のような問題が起こります。

  • VideoPlayerを再度使いたい場合、データベースやコーデックがハードコードされているので、データベースを変えて再生したりなどができない。
  • 実行時やデバッグで交換できない。
  • テストでも交換できない。例えばテストではメモリ内のDB使いたい場合などは困る。
  • さまざまな依存関係がコードの中に入ってくるのでコードが見にくくなり、リファクタリングを難しくする。

この問題を簡単に回避するために簡単なDIを試してみましょう!

簡単にDIしてみよう

ここでDependency Injection(DI)です。日本語だと依存性の注入です。
最初は簡単にコンストラクタインジェクションというコンストラクタを使って依存性を注入する方法を紹介します。
簡単にVideoPlayerのコンストラクタに依存関係を渡してあげるだけです。
他にもsetterによって注入する方法をsetter injectionといいます。

コンストラクタインジェクションの例
差分: https://github.com/takahirom/hilt-sample-app/commit/a1fdef28515d158577313b90f7c2590bd5905366

VideoPlayerは依存関係が交換可能で、シンプルになった!

class VideoPlayer(
    private val database: VideoDatabase,
    private val codecs: List<Codec>
) {
    private var isPlaying = false

    fun play() {
...
    }
}

コンストラクタインジェクションを使うと使う側で、そのクラスの依存関係を先に作ってからでないとインスタンス化することができません。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val videoDatabase = Room
            .databaseBuilder(
                this,
                VideoDatabase::class.java,
                "database"
            )
            .createFromAsset("videos.db")
            .build()
        val codecs = listOf(FMP4, WebM, MPEG_TS, AV1)
        val videoPlayer = VideoPlayer(videoDatabase, codecs)
        videoPlayer.play()
    }
}

いろんな画面で、VideoPlayerを作るたびにこのようなコードを書く必要があるので、これは ボイラープレートとなりえます
この処理をよく見ると"VideoPlayerを作るための処理"と"VideoPlayer#playを呼び出す処理"があることが分かります。

VideoPlayerを作るための処理

"VideoPlayerを作るための処理"は、ただ、他の型やクラスを作るためのロジックで、これを"コンストラクションロジック"と呼びます。

val videoDatabase = Room
    .databaseBuilder(
        this,
        VideoDatabase::class.java,
        "database"
     )
     .createFromAsset("videos.db")
     .build()
val codecs = listOf(FMP4, WebM, MPEG_TS, AV1)
val videoPlayer = VideoPlayer(videoDatabase, codecs)

VideoPlayer#playを呼び出す処理

"VideoPlayer#playを呼び出す処理"は、アプリの価値を作るためのロジックとなり、"ビジネスロジック"と呼びます。ここではコンストラクションロジック以外をこう呼ばせてください。

videoPlayer.play()

コンストラクションロジックとビジネスロジックが一緒になっていると、コードを追ったり、読んだりするのを難しくします。
またそれはクラスを読む人にとってあまり意味のないものであることが多いです。
Dependency Injection(DI)を行うライブラリでは、このコンストラクションロジックとビジネスロジックを分離することができます。

DIについてまとめ

  • さまざまな良い影響があるのでDIを行おう!
  • インスタンス化のためのボイラープレートを避けるためにDIライブラリを使おう!

AndroidではDIが難しい

フレームワークがActivityなどのインスタンスを作ってしまいます。例えば、startActivityなどするとインスタンスが作られてしまいます。(コンストラクタをいじれません。)

// Androidのフレームワークによって勝手にActivityがインスタンス化される
startActivity(Intent(context, MainActivity::class))

AndroidのAPI Level 28からFactoryでActivityを作れるようになるなど、改善はされていますが、Android 9以上でないと動作しないので、現状では現実的ではないです。

Daggerがこれまでの解決策でした。

上位1万のアプリで74%がDagger使っており、今の解決策としてはこれがメインです。
しかし、アンケートによると49%のユーザーがより良いDIの解決策を必要としていたようです。

どんなDIの解決策が必要だったか?

  • Opinionated(意見を持った。)
    決めるのを楽にして、使うのを楽にする (後述)
  • セットアップが簡単
    (DaggerはAndroidではなく、Java用だったため、Daggerは設定が難しかった)
  • 重要な部分にフォーカスできる

これがDagger Hiltが生まれたメインの理由のようです。

Dagger Hilt

Dagger HiltはDaggerの上に構築されたライブラリです。
Daggerの良いところを使うことができます。
GoogleのAndroidXチームとDaggerチームで共同で作られています。

  • AndroidにおけるDIの標準化
    どのようにAndroidでDIするのかを標準化(standardize)します。
  • Dagger上に構築
    Daggerのためのコードを生成します。
  • アノテーションベース
    Hiltに対して、アノテーションで何をしたいのかを伝えます。
  • ツールサポート
    Android Studio 4.1以降では左側にガターアイコンが表示される。例えば依存関係がどこから来たのかがわかります。
  • AndroidX Extension
    ViewModelとWorkManagerで使える。他のライブラリも追加予定です。

Dagger HiltをVideoPlayerの例で使ってみよう

どうにかして、Dagger HiltにVideoPlayerの作り方を教えないといけないですが、どのように教えたら良いでしょうか?
一番基本的なDagger Hiltへのインスタンスの作り方の教え方は、コンストラクタに@Injectをつけることでです。
現状はVideoPlayerに依存関係をもたせていません。
Dagger Hiltはこのクラスをただインスタンス化するだけなので、作ることができます。

class VideoPlayer @Inject constructor() {
    private var isPlaying = false

    fun play() {
...
    }
}

次にこのアプリはHiltで動くということをHiltに教えないといけません。
AndroidのApplicationクラスで@HiltAndroidAppを使うと、このアプリがHiltで動くということを教えられます。また、@HiltAndroidAppを使うと内部的にはComponentが作られます。このComponentとは、コンストラクションロジックと、作ったインスタンスを保持する部分です。(Componentについては後でもう一度触れます。)

@HiltAndroidApp
class VideoApp : Application()

Activtyのコンストラクタがいじれないということでしたが、その対応としてActivityに@AndroidEntryPointをつけます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

@AndroidEntryPointをつけることで、3つのことをHiltに対して教えます。

  • "このActivityはInjectionを行う。このActivityはDependency Injectionを使う"
  • "ActivityのComponentをこのActivityに追加。"
  • "HiltからDependencyを持ってくる"

変数に@Injectアノテーションを付けています。
これはHiltからInjectされることを意味します。"Actiivtyが作られたときに、VideoPlayerをInjectして" とHiltに教えている形です。
そして、onCreateメソッドではVideoPlayerを呼ぶなど好きなことができます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    // ↓ Dagger HiltによってonCreateでInjectされる
    @Inject lateinit var videoPlayer: VideoPlayer
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        videoPlayer.play()
    }
}

差分: https://github.com/takahirom/hilt-sample-app/commit/6a8a3711808e806e5953712adeb19b11cb73c3a9#diff-bbc9d28d8bcbd080a704cacc92b8cf37R24

魔法?どこでフィールドに代入されるの?

少しだけ中身の仕組みを知っておいたほうが分かりやすいと思うので、説明しておきます。
@AndroidEntryPointがついているActivityはHiltによって変換されます。
Hiltによって変換後にMainActivityとAppCompatActivityの間に
生成されたHilt_MainActivityが入ります。
Hilt_MainActivityのonCreateの中でフィールドにInjectされます。

image.png

Hiltによって変換後のコード

@AndroidEntryPoint
class MainActivity : Hilt_MainActivity() { // Hilt_MainActivityになっている
    @Inject lateinit var videoPlayer: VideoPlayer
    override fun onCreate(savedInstanceState: Bundle?) {
        // この中でフィールドにInjectされる
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        videoPlayer.play()
    }
}


VideoPlayerにVideoDatabaseをInjectさせるには?

これで一応MainActivityでVideoPlayerが使えるようになりました。ただ、これではVideoPlayerがDatabaseなどにアクセスできません。どのようにしていけばいいでしょうか?

// ↓ Databaseなどを持っていない
class VideoPlayer @Inject constructor() {
    private var isPlaying = false

    fun play() {
...
    }
}

もし、VideoDatabaseのコンストラクタを変更することができるのであれば、同様にVideoDatabaseに @Injectをつけて、Hiltにインスタンスの作り方を教えられます。Daggerが勝手に依存関係のインスタンス、VideoDatabaseを先に作ってからVideoPlayerをインスタンス化してくれます。

class VideoPlayer @Inject constructor(
  private val database: VideoDatabase
) {
    private var isPlaying = false

    fun play() {
...
    }
}
class VideoDatabase @Inject constructor() {
...
}

ただ今回はRoomによって作られるインスタンスになるので、コンストラクタをいじって@Injectをつけることができません。そのためどうにかしてDagger Hiltに以下のVideoDatabaseの作り方を教えないといけません。

val videoDatabase = Room
    .databaseBuilder(
        this,
        VideoDatabase::class.java,
        "database"
     )
     .createFromAsset("videos.db")
     .build()

そこでModuleを使います。Moduleを使うことで、Hiltにインスタンスの作り方を教えることができます。
@Module@InstallInというアノテーションを付けるただのクラスです。
そのModuleにはメソッドを追加します。
Moduleにあるメソッドは料理のレシピだと考えると分かりやすいです。VideoDatabaseの型を作るレシピになり、このレシピをHiltに教えています。
このレシピを@InstallInSingletonComponentに置きます。SingletonComponent は ApplicationのComponentに追加するということです。

メソッドをよく見ると@Providesと書いてあることが分かります。HiltにどのようにVideoDatabaseを作るのかを教えるメソッドということを教えています。
HiltがVideoDatabaseを作る必要があるときに、このメソッドを実行してインスタンスを返します。
ちなみに、contextクラスのインスタンスが@ApplicationContextで提供されていますが、Hiltで事前定義されたクラスもいくつかあり、Hiltがいくつかのインスタンスを提供してくれます。
差分: https://github.com/takahirom/hilt-sample-app/commit/c85a6f668a0bf447c0a4b119f4f6d8cc8c2cff80

@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
    fun provideVideoDB(@ApplicationContext context: Context): VideoDatabase {
        return Room
            .databaseBuilder(
                context,
                VideoDatabase::class.java,
                "database"
            )
            .createFromAsset("videos.db")
            .build()
    }
}

さて、この SingletonComponentが出てきましたが、 Componentについてもう少し触れておきましょう。

Component

Componentは以下のことができます。

  • どのようにオブジェクトをビルドするのかのロジックを持っています。
    VideoDatabaseはこうやって作る。。など
    VideDatabase : Room.databaseBuilder() … .build()
  • インスタンス生成の順序のロジックを持っています。

    "VideoDatabaseの後にVideoPlayerを生成する。"など

  • スコープによってインスタンスを使い回します。 (後述)

Dagger Hilt標準のComponent

HiltはOpnionedであると言いましたが、Dagger Hiltには標準のComponentが存在し、Componentの構造について迷わなくて良くなっています。
この図はComponentの階層を表しており、アプリケーション全体のSingletonComponent、画面回転でも生き残るActivityRetainedCompoent、Activityと紐づくActivityComponentなどのComponentがある構造になっています。
上についているアノテーションはスコープアノテーションです。これについては後ほど説明します
例えば、SingletonComponentやConfigration Changeでも生き残るActivityRetainedComponent、ActivityComponent、そしてFragmentComponentなどが付随します。

image.png
https://dagger.dev/hilt/components より

今回の例ではSingletonComponentにVideoPlayerの作り方とVideoDatabaseの作り方が入っており、その作成順序も入っています。
image.png

インスタンスを共有したいときはどうするのか

例えば現状ではVideoDatabaseは現在使われるたびにインスタンス化されてしまいます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject
    lateinit var videoPlayer: VideoPlayer
    @Inject
    lateinit var videoPlayer2: VideoPlayer
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        println(videoPlayer.database)
        // VideoDatabase_Impl@764b474 ← ハッシュコードが違う
        println(videoPlayer2.database)
        // VideoDatabase_Impl@a945d9d ← ハッシュコードが違う
    }
}

class VideoPlayer @Inject constructor(
    val database: VideoDatabase
)
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
    fun provideVideoDB(@ApplicationContext context: Context): VideoDatabase {
        return ...
    }
}

コネクションを使いまわしたいなど様々な理由で、インスタンスを使いまわしたいことがあります。また今回の例とは関係ないですが、Androidの通信で一般的に使われるOkHttpはインスタンスをアプリ全体で使いまわすとパフォーマンスが良くなります。 (https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/ より)

Activity内で共有したい場合は、@ActivityScopedを使うことでActivityComponentで保持されるため、
Activity内で同じインスタンスを使い回せます。この@ActivityScopedをScope Annotationといいます

これでActivity内でインスタンスを共有できます。
差分: https://github.com/takahirom/hilt-sample-app/commit/f895dfac123a0317b9e0e247af3a48b57388ad5d

@Module
@InstallIn(ActivityComponent::class)
object DataModule {
    @ActivityScoped
    @Provides
    fun provideVideoDB(@ApplicationContext context: Context): VideoDatabase {
...
    }
}

@ActivityScopedではActivityが違えば別のインスタンスになってしまいます。アプリ全体で使いまわしたいときは@SingletonのScope Annotationを使うことでSingletonComponentで保持されるため、アプリ全体で使えます。
差分: https://github.com/takahirom/hilt-sample-app/commit/64cc1b50388cf9c79fba26a774ae86efb1f093bc

@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Singleton
    @Provides
    fun provideVideoDB(@ApplicationContext context: Context): VideoDatabase {
...
    }
}

VideoDtabaseはSingletonComponent内でインスタンスも保持されるようになりました。

image.png

Hiltが管理していないクラスから、Hiltの依存関係を使いたい時

Hiltが管理しているMainActivityやVideoPlayerクラスではHiltから依存関係を取得できますが、Hiltが管理していないクラスでは取得が難しい場合があります。
例えば、ContentProviderクラスや、他のライブラリが生成するクラス、Dagger Hiltにマイグレーションしているときの既存のクラスなどです。
ここで、EntryPointという仕組みが使えます。EntryPointを使うことで、HiltのもつComponentが持つ依存関係にアクセスすることができます。

これはHiltで管理できていないActivityでHiltがコンストラクションロジックをもつVideoPlayerを使う例となります。
差分: https://github.com/takahirom/hilt-sample-app/commit/d66fb46b395b0c9b6a98ff91bd55f3c4f12c99c9

class NonHiltActivity : AppCompatActivity() {
    @EntryPoint // @EntryPointをつける。
    @InstallIn(SingletonComponent::class)
    interface NonHiltActivityEntryPoint {
        fun videoPlayer(): VideoPlayer
    }

    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        val entryPoint = EntryPointAccessors.fromApplication(
            applicationContext,
            NonHiltActivityEntryPoint::class.java
        )
        val videoPlayer = entryPoint.videoPlayer()
        videoPlayer.play()
    }
}

テスト

一般的なテストの書き方と同じように自分で対象のオブジェクトnewしてテストを書くことができます。
この場合、自分で依存関係を先に作らなくてはいけません。
差分: https://github.com/takahirom/hilt-sample-app/commit/068082bf7bcb20ecbb1258ac6a3027988d624303

    @Test
    fun normalTest() {
        // メモリ内にDBを作る
        val database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            VideoDatabase::class.java
        ).build()
        val videoPlayer = VideoPlayer(database)

        videoPlayer.play()

        assertThat(videoPlayer.isPlaying, `is`(true))
    }

Hiltを使うと、このように自分で依存関係を作らずにHiltにインスタンスを作らせることができます。
しかし、今回は実際のDatabaseを使わずにメモリ内にDatabaseを使ってそれを使いたいです。どのようにするでしょうか?

@HiltAndroidTest
class VideoPlayerTest {
    @get:Rule
    var hiltAndroidRule = HiltAndroidRule(this)

    @Inject
    lateinit var videoPlayer: VideoPlayer

    @Test
    fun play() {
        hiltAndroidRule.inject()

        videoPlayer.play()

        assertThat(videoPlayer.isPlaying, `is`(true))
    }

ここで@UninstallModules(DataModule::class)することで、Dagger HiltからDataModuleが持つVideoDatabaseの作り方を忘れさせることができます
そして、テストの中で、新たなModuleを定義することで、Databaseを提供することができます。 テストの外でModuleを宣言するとテスト全体でInstallされます。
差分: https://github.com/takahirom/hilt-sample-app/commit/4c862ee62e8dfc133ea6e7e3ff0735c0497cfb6a

@HiltAndroidTest
@UninstallModules(DataModule::class)
@RunWith(RobolectricTestRunner::class)
class VideoPlayerTest {
    @InstallIn(SingletonComponent::class)
    @Module
    class TestDataModule {
        @Provides
        fun provideVideoDatabase(): VideoDatabase {
            return Room.inMemoryDatabaseBuilder(
                ApplicationProvider.getApplicationContext(),
                VideoDatabase::class.java
            ).build()
        }
    }

Daggerからの Dagger Hiltマイグレーションについて

Daggerを知っている人向けの話になりますので、Dagger分からんという方は、へーっていうぐらいで見てください。

DaggerからDagger Hiltに一歩ずつマイグレーションしていく方法について触れておきます。

Hiltへの導入準備

Daggerのライブラリをバージョンアップしておく

→ 普通にマイグレーションするだけです。

Daggerのコンポーネントの状況を見てみる

Daggerのコンポーネントの図を出すツールは昔からいろんなプラグインがあります。
Dagger SPI(Service provider interface)というDagger内部の
情報を取り出せるDaggerのAPIのようなものがあるので、それを使っているツールで確認することをおすすめします。

https://github.com/arunkumar9t2/scabbard
https://github.com/Snapchat/dagger-browser
など

そしてある程度、どのコンポーネントが、どのコンポーネントに対応付けられそうかを見てみます。
scabbardを使った図の例
image.png

今回の状況の前提

例とする状況はApplicationレベルのコンポーネントのAppComponentがあり、その下にActivityごとのComponentがたくさんあるような形になっているとします。
(Daggerだとコンポーネントの形が標準化されていないので、そもそものComponentの形もさまざまになります。)

AppComponentの作り方でModuleを引数を渡す形で作っている場合はそれをやめる

Dagger Hiltはこのモジュールを渡し方して作る作り方をサポートしていないためやめる必要があります。Moduleの引数をなくして参照してあげることで可能です。
Daggerのドキュメント的にもそれは良くないらしいです。(don't do thisって書いてあります https://dagger.dev/dev-guide/testing )

NG

    DaggerAppComponent
      .builder()
      .networkModule(networkModule)
    .build()

OK

    DaggerAppComponent.factory()
      .create(application)

Hiltを導入してAppComponentを置き換える

ApplicationレベルのComponentをDagger HiltのSingletonComponentに置き換える

Dagger Hiltのライブラリを導入します。これは基本的なドキュメントをご確認ください。
https://dagger.dev/hilt/migration-guide

少しずつマイグレーションしていく場合はdisableModulesHaveInstallInCheckを入れる必要があります。

Dagger Hiltで標準だと@InstallInが入っていない既存のモジュールがあるときにエラーになってしまいます。このオプションを入れるとそのエラーを出さなくして、ただライブラリを入れるだけということができます。

 javaCompileOptions {
      annotationProcessorOptions {
           // ↓ **ここが+=になっていないとDagger Hiltのプラグインがargumentを追加するのでハマるので注意**
           arguments += [
               "dagger.hilt.disableModulesHaveInstallInCheck": "true"
           ]
      }
  }

Dagger HiltのEntryPointはComponentから依存関係をとってくるだけでなく、サブコンポーネントを作ることもできるので、その機能を使って置き換えます。 (ここは説明飛ばします。)
差分: https://github.com/takahirom/hilt-sample-app/commit/8e542f191bb50ce50db30cb2a72a569f7d17b178

@Subcomponent
interface JustDaggerComponent {
    @Subcomponent.Factory
    interface Factory {
        fun create(): JustDaggerComponent
    }

    fun inject(justDaggerActivity: JustDaggerActivity)
}

@InstallIn(SingletonComponent::class)
@EntryPoint
interface JustDaggerEntryPoint {
    fun activityComponentFactory(): JustDaggerComponent.Factory
}

class JustDaggerActivity : AppCompatActivity() {
    @Inject lateinit var videoPlayer: VideoPlayer
    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        // old: appComponent.justDaggerComponent().inject(this)
        val entryPoint = EntryPointAccessors.fromApplication(
            applicationContext,
            JustDaggerEntryPoint::class.java
        )
        entryPoint.activityComponentFactory().create().inject(this)

        videoPlayer.play()
    }
}

既存のActivityのComponentをHiltに置き換えていく

基本的に @AndroidEntryPointをつけて、既存のDaggerの処理を外していくということになります。
JustDaggerActivityの形をMainActivityの形に変えていきます。

マイグレーションについては、他にもいろいろありますが、コードラボが詳しいので考えている方はCodelabをやってみてください。

Dagger HiltとJetpackの連携

よく開発で利用されるViewModelやWorkManagerといったJetpackのComponentと連携するライブラリが提供されていて、それを利用することができます。
差分: https://github.com/takahirom/hilt-sample-app/commit/1bec3370fec0fd5b4233db1884e8427bcf91a540
Androidアプリの開発ではViewModelをよく使います。まずはViewModelについて見ていきましょう。
ViewModelではコンストラクタに変更が加えられますが、通常、ProviderやFactoryなどを経由して作成するため難しいですが、この部分をDagger Hiltはうまく隠蔽して簡単にViewModelを作ってくれます。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val videoPlayerViewModel: VideoPlayerViewModel by viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        videoPlayerViewModel.play()
    }
}

class VideoPlayerViewModel @ViewModelInject constructor(
    private val videoPlayer: VideoPlayer
) : ViewModel() {
    fun play() {
        videoPlayer.play()
    }
}

Dagger Hilt 実践プラクティス

Google(or Googler)のサンプルを参照する

迷ったら、サンプルアプリ達を見てみましょう。

Architecture Samples
https://github.com/android/architecture-samples/tree/dev-hilt
Goole I/Oアプリ
https://github.com/google/iosched
Sunflower
https://github.com/android/sunflower
chrisbanes/tivi
https://github.com/chrisbanes/tivi

fastInitモードが有効になるので、影響を確認する

Dagger HiltはComponentの形が標準化されたことによって、たくさんのSingletonComponentなどのコンポーネントに、この型はこう作るなどのバインディングが入るようになります。
通常、Daggerではこのバインディングの数が増えると増えるだけインスタンス化に時間がかかります。Dagger Hiltを入れたタイミングで通常のモードではなくfastInitモードが有効になることで、これが時間がかからなくなります。しかし、この処理にはトレードオフもあるようなので、リリース後にFirebase PerformanceやAndroid Vitalsなどで確認してみましょう。

    val PROCESSOR_OPTIONS = listOf(
      "dagger.fastInit" to "enabled",

https://github.com/google/dagger/blob/d3c1d2025a87201497aacb0a294f41b322767a09/java/dagger/hilt/android/plugin/src/main/kotlin/dagger/hilt/android/plugin/HiltGradlePlugin.kt#L108


生成されるコードの比較

fastInitがdisabledになっている場合
public final class DaggerApp_HiltComponents_SingletonC extends App_HiltComponents.SingletonC {
  private Provider<Context> provideContextProvider;

  private Provider<VideoDatabase> provideVideoDBProvider;

  private DaggerApp_HiltComponents_SingletonC(
      ApplicationContextModule applicationContextModuleParam) {

    initialize(applicationContextModuleParam);
  }
...

  @SuppressWarnings("unchecked")
  private void initialize(final ApplicationContextModule applicationContextModuleParam) {
    this.provideContextProvider = ApplicationContextModule_ProvideContextFactory.create(applicationContextModuleParam);
    this.provideVideoDBProvider = DoubleCheck.provider(DataModule_ProvideVideoDBFactory.create(provideContextProvider));
  }


  @Override
  public VideoPlayer videoPlayer() {
    return new VideoPlayer(provideVideoDBProvider.get());
  }

fastInitがenabled
Providerが値を保持する代わりにComponentが値を保持するようになる。

public final class DaggerApp_HiltComponents_SingletonC extends App_HiltComponents.SingletonC {
  private final ApplicationContextModule applicationContextModule;

  private volatile Object videoDatabase = new MemoizedSentinel();

  private DaggerApp_HiltComponents_SingletonC(
      ApplicationContextModule applicationContextModuleParam) {
    this.applicationContextModule = applicationContextModuleParam;
  }

  private VideoDatabase videoDatabase() {
    Object local = videoDatabase;
    if (local instanceof MemoizedSentinel) {
      synchronized (local) {
        local = videoDatabase;
        if (local instanceof MemoizedSentinel) {
          local = DataModule_ProvideVideoDBFactory.provideVideoDB(ApplicationContextModule_ProvideContextFactory.provideContext(applicationContextModule));
          videoDatabase = DoubleCheck.reentrantCheck(videoDatabase, local);
        }
      }
    }
    return (VideoDatabase) local;
  }

  @Override
  public VideoPlayer videoPlayer() {
    return new VideoPlayer(videoDatabase());
  }


詳細画面などでIDを渡していきたい場合はどうするのか?

Dagger HiltではComponentの構造が標準化されているため、例えばEpisodeDetailComponentを作って、そこで画面詳細IDを配布というようなことは難しいです。
これに対してさまざまなやり方が考えられますが、Googleのサンプルでのやり方は一つのようです。
Daggerを使って配布せず、直接渡す方法です。
HiltのComponentを作るための公式のページに、バックグラウンドタスクの話で少し文脈は違うのですが、"だいたいは自分で渡したほうがシンプルで十分"という話が出てきます。
一番渡しがちなAssisted InjectについてはただViewModelに渡すときは少しだけ工夫することができます。

https://dagger.dev/hilt/custom-components

for most background tasks, a component really isn’t necessary and only adds complexity where simply passing a couple objects on the call stack is simpler and sufficient.

コールスタックで引数を渡すだけのほうがシンプルで十分で、Componentは複雑さを増すだけ。ということを言っています。

Architecture Samples
https://github.com/android/architecture-samples/blob/dev-hilt/app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailFragment.kt#L90
Iosched
https://github.com/google/iosched/blob/b428d2be4bb96bd423e47cb709c906ce5d02150f/mobile/src/main/java/com/google/samples/apps/iosched/ui/speaker/SpeakerViewModel.kt#L101
Sunflower
https://github.com/android/sunflower/blob/2bbe628f3eb697091567c3be8f756cfb7eb7258a/app/src/main/java/com/google/samples/apps/sunflower/PlantDetailFragment.kt#L55
chrisbanes/tivi
https://github.com/chrisbanes/tivi/blob/27348c6e4705c707ceaa1edc1a3080efa06109ae/ui-showdetails/src/main/java/app/tivi/showdetails/details/ShowDetailsFragment.kt#L60

ViewModelにコンストラクタで値を渡す

“IDなどを直接渡していく”ということでしたが、ViewModelのコンストラクタで渡せないと、lateinitにしたり、nullableにして少し安全じゃない形になっちゃいますよね?
AssistedInjectというライブラリで、コンストラクタ引数を渡す実装ができる。(今後Daggerにも組み込まれるそうです)

差分: https://github.com/takahirom/hilt-sample-app/commit/6584808f8fe13cc92317df50d413f828d1dfdf00

DaggerはAssistedInjectと呼ばれるものを対応しようとしています。これはInjectするときに、プログラムから引数で値を渡せるというものです。
https://github.com/google/dagger/issues/1825

これを先に使えるライブラリがあります。
https://github.com/square/AssistedInject

具体的にはGooglerの以下のgistの内容を使ってViewModelにコンストラクタで値を渡すことができます。
https://gist.github.com/manuelvicnt/437668cda3a891d347e134b1de29aee1

本質的に理解しようとすると大変なので、仕組みが気になる方は以下を読んでみてください。
https://qiita.com/takahirom/items/f28ceb7a6d4e69e4dafe

EntryPointの定義場所

EntryPointは、基本的にはGoogleのサンプルでは使われていないようです。
大きいアプリのマイグレーションなどでは使われると思われるので、紹介しておきます。
EntryPointはどこにでも書くことができますが、どこに書くのがいいでしょうか?
依存関係を取得するときに使うEntryPointは、使う場合には必要な依存関係のみを取得したほうが依存するオブジェクトを少なくできるので、基本的には取得する場所に定義して利用していきましょう

class NonHiltActivity : AppCompatActivity() {
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface NonHiltActivityEntryPoint {
        fun videoPlayer(): VideoPlayer
    }

    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        val entryPoint = EntryPointAccessors.fromApplication(
            applicationContext,
            NonHiltActivityEntryPoint::class.java
        )
        val videoPlayer = entryPoint.videoPlayer()
        videoPlayer.play()
    }
}

マルチモジュール

まだちゃんとしたベストプラクティスがあるわけではないと思いますが、Dagger Hiltとマルチモジュールについて考えておきます。以下のようなモジュール構成があったとします。
image.png

Applicationクラスをコンパイルする Gradleモジュールは、
すべての Hilt モジュールおよびコンストラクタ インジェクションで注入するクラスを
推移的依存関係に含める必要があります

https://developer.android.com/training/dependency-injection/hilt-multi-module?hl=ja より

ということなので、以下のようにルートのモジュールからDaggerのModuleを持つGradleモジュールへの参照が必要になります
この部分に関して、真ん中の形が色んなパターンで無駄に依存関係を増やさずに動くので、いいのかなとは思うのですが、まだベストプラクティスと言えるものはないです。
ただ、モジュールを作ったときにクラスパスに含めるだけで@InstallInされたModuleがコンポーネントにインストールされて使えるので、すごく楽で、本当に使いやすいです。

image.png

参考
Googleの方のchrisbanes/tiviのアプリのappモジュールからの参照
ルートのGradleモジュールから各モジュールに参照していることが分かる。
image.png

Hiltでビルドできる?できない?みたいな実験する環境作るのめんどうですよね?Hiltが導入済みのアプリで実験したくなったらこのサンプルプロジェクトを用意しているので、実験してみてください。
https://github.com/takahirom/dagger-transitive-playground/tree/hilt

テストのプラクティス

実際の依存関係を使おう

Hilt Testing Philosophyというページが有り、Dagger Hiltを使ってテストをする際のプラクティスが書いてあります。これがかなり主張を含んだもので面白いのでぜひ読んでみてください。
https://dagger.dev/hilt/testing-philosophy
こちらに自分のメモがあります。
https://qiita.com/takahirom/items/a3e406b067ad645605da

これによると2つ言いたいことがあるようです。

  • ユーザーからの観点でテストする
    ユーザーとは実際のユーザーもクラスのユーザーも、APIのユーザーも含む。internalなメソッド名や実装になど依存せず、テストが壊れることがユーザーの観点から変更があったことを意味する
  • 実際の依存関係を利用する

なぜ実際の依存関係を使うか?

  • 実際の依存関係は本当の問題を捕捉しやすい。モックのように古いまま残されたりしない。
  • "ユーザーからの観点でテスト"と組み合わせることで、同じカバレッジでもっと少ないテストの量で書くことができる
  • テストが壊れることが、FakeやMockの設定ミスによる問題による問題の代わりに、実際の問題を指し示す (そして、逆に言えばテストがパスすることはコードがちゃんと動くことを意味する)
  • "ユーザーからの観点でテスト"と"実際の依存関係を使う"は相性が良い。依存関係を入れ替えないため。

どうやってDagger Hiltで実際の依存関係を使うか?

普通にテストして実際の依存関係を使おうとすると依存関係を作るためのボイラープレートが発生します
PlayerFragmentを作るためにViewModelのFactoryが必要で、ViewModelを作るにはVideoPlayerが必要で、VideoPlayerを作るにはVideoDatabaseが必要になるなどを書いていくと。。大変になります。

launchFragment {
    // たいへん!
    PlayerFragment().apply {
        videoPlayerViewModelAssistedFactory =
            object : VideoPlayerViewModel.AssistedFactory {
                override fun create(videoId: String): VideoPlayerViewModel {
                    return VideoPlayerViewModel(
                        videoPlayer = VideoPlayer(
                            database = Room.inMemoryDatabaseBuilder(
                                ApplicationProvider.getApplicationContext(),
                                VideoDatabase::class.java
                            ).build()
                        ),
                        videoId = "video_id"
                    )
                }
            }
    }
}
onView(withText("playing")).check(matches(isDisplayed()))

紹介したように、Dagger Hiltを使ってテストでもInjectを行うことで実際の依存関係を使ってテストをすることが可能になります。

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
@UninstallModules(DataModule::class)
class AndroidPlayerFragmentTest {
    @InstallIn(SingletonComponent::class)
    @Module
    class TestDataModule {
        @Provides
        fun provideVideoDatabase(): VideoDatabase {
            return Room.inMemoryDatabaseBuilder(
                ApplicationProvider.getApplicationContext(),
                VideoDatabase::class.java
            ).build()
        }
    }

    @get:Rule
    var hiltAndroidRule = HiltAndroidRule(this)

    @Test
    fun play() {
        hiltAndroidRule.inject()
        launchFragmentInHiltContainer<PlayerFragment> {
        }
        onView(withText("playing")).check(matches(isDisplayed()))
    }

さまざまなデメリットがあるのでTestでのCustom Applicationクラスはやめよう

一応@CustomTestApplicationでカスタムアプリケーションを使ってテストできます。
テスト用のTestAppクラスなどを作っているのであればHiltと組み合わせると
さまざまなデメリットが発生します。また一般的にやめておいたほうが良さそうです。

  • テストでは以下の問題があるため、:カスタムアプリケーションでは@Injectのフィールドを使えない*。(コンパイルエラー)
  • Applicationはテストをまたいで生存してしまうので、テストをまたいだ状態のリークを起こす
  • 子が親に依存するテストになってしまう ので、テストの独立性を高めるためにやめておくべき。など

Dagger Hiltの細かいTips

HiltAndroidAutoInjectRuleなどを用意しておくと自分でInjectを呼ばなくても動くようになります。

@get:Rule val hiltAndroidAutoInjectRule = HiltAndroidAutoInjectRule(this)

class HiltAndroidAutoInjectRule(testInstance: Any) : TestRule {
  private val hiltAndroidRule = HiltAndroidRule(testInstance)
  private val delegate = RuleChain
    .outerRule(hiltAndroidRule)
    .around(HiltInjectRule(hiltAndroidRule))

  override fun apply(base: Statement?, description: Description?): Statement {
    return delegate.apply(base, description)
  }
}
class HiltInjectRule(val rule: HiltAndroidRule) : TestWatcher() {
  override fun starting(description: Description?) {
    super.starting(description)
    rule.inject()
  }
}

まとめ

以下について話してきました。

  • なぜDIを使うのか
  • なぜDagger Hiltなのか
  • 基本的な使い方や概念
  • テスト
  • マイグレーション
  • 実践プラクティス
  • Tips

Dagger Hiltを使うことでさまざまないい効果が見込めるので、Dagger Hiltを使ってアプリを作ってみましょう!

参考

公式ウェブページ
https://dagger.dev/hilt/
https://developer.android.com/training/dependency-injection/hilt-android?hl=ja

Android Dependency Injection
https://www.youtube.com/watch?v=B56oV3IHMxg
Dagger Hilt Deep Dive
https://www.youtube.com/watch?v=4di2TTqeCrE

Architecture Samples
https://github.com/android/architecture-samples/tree/dev-hilt
Goole I/Oアプリ
https://github.com/google/iosched
Sunflower
https://github.com/android/sunflower
chrisbanes/tivi
https://github.com/chrisbanes/tivi

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

termux×AWS IoT でスマホ位置情報をAWSに送信

はじめに

妻に、「職場を出たらラインしろ」と言われているのですが、正直面倒です。
自分が職場を出たことを自動で通知できないかなと思い、とりあえず位置情報をAWSに送ってみました。

図1.png

環境

スマホ:京セラ(KTV44)
OS:Android 9

スマホ(termux)側

AndroidにGoogle Playを利用して、termuxをインストールします。
https://play.google.com/store/apps/details?id=com.termux&hl=ja

スマホだとキーボード入力しづらいため、
インストール後に下記コマンドを入力し、ssh用の環境を構築します。

$ termux-setup-storage #termuxにandroidストレージの使用を許可する。
$ pkg install openssh #ssh(サ-バ、クライアント両方)をインストール
$ sshd #sshサーバを起動
$ passwd #パスワードの設定
$ whoami #ユーザ名の確認(メモしてください。)
$ ifconfig #スマホのIPアドレスの確認(メモしてください。)

ちなみに、termuxで表示されるIPアドレスはスマホ自体のIPアドレスと一緒なので、あらかじめ分かっていればifconfigしなくても大丈夫です。

次に作業しやすいようにPCからsshでスマホに接続します。
ここで注意です.
sshのデフォルトのTCPポート番号は22ですが、termuxの場合は8022が指定されています。
そこで-pオプションでポート番号を指定します。
PC(Ubuntu)側

$ ssh -p 8022  (ユーザ名)@(スマホのIPアドレス)

最初にsshをした場合、
Are you sure you want to continue connecting (yes/no)?
と出るので、yesと回答。
その後、スマホで設定したパスワードを入力します。

初期状態ではバックグラウンド処理が禁止されているため、スマホがスリープ状態になるとsshの接続がその都度中断されててしまいます。
スマホで「RELEASE WAKELOCK」を選択することで、バックグラウンド処理が行われるようになる。

Screenshot_20201015-214307.png

sshで入れるようになったら、引き続き環境を構築していきます。

$ pkg install git #後に使用するAWS IoT Core用にgitをインストールしておく。
$ pkg install python #Pythonもインストールしておく。
$ pkg install vim-python #ファイル編集用にvimもインストールしておく。
$ pkg install termux-api #termux-apiもインストールしておく。GPS情報を取得するために必要。

インストール系が終了したら、次はAWS Iot Coreを設定します。
この↓記事を参考に「android01」なるモノを登録し、スマホからAWSへの接続テストまで行います。
https://qiita.com/zakuzakuzaki/items/e30d63598ca1d6c0f2a9

意外とそのままできたのでびっくりです。
termuxすごい。

「termux-api」の中の、termux-locationコマンドで、位置情報を取得します。
https://wiki.termux.com/wiki/Termux-location

$ termux-location -p network #スマホが観測している現在位置を出力
{
  "latitude":   35.6359322,
  "longitude": 139.8786311,
  "altitude": 43.5,
  "accuracy": 23.26799964904785,
  "vertical_accuracy": 3.299999237060547,
  "bearing": 0.0,
  "speed": 0.0,
  "elapsedMs": 97,
  "provider": "network"
}

-pは、位置情報の取得手段を選択するオプションで[gps/network/passive]の中から選択します。
デフォルトだとgpsですが、なぜか自分のスマホではできませんでしたので、networkを選択しました。
(エラーも出ず、ずっと待ち状態になる。)
ちなみにスマホをWi-Fi接続から3G接続に変えても、networkを選択しないと位置情報を取得できません。

おそらくtermuxアプリの権限の問題だと思いますが、解決できなかったので放置しました。
通常、androidはアプリの設定画面でアプリ毎にフォルダ、カメラ、GPSなどの利用権限が付与できるのですが、
termuxはそもそも権限の選択肢が出てこないので、付与のしようがありません。

位置情報が取得できることが確認できたら、処理しやすいようにしてjson形式で保存します。

$ termux-location -p network |sed -e "4,10d"|tr -d '\n'|tr -d ' '| sed -s s/,}/}/g > location.json
$ cat location.json 
{"latitude":35.6359322,"longitude":139.8786311}#いい感じに整形されたことを確認

やっていることは、
・位置情報を取得
・4~10行目(緯度経度以外)を削除
・改行を削除
・スペースを削除
・最後の” , ”(カンマ)を削除
・カレントディレクトリにlocation.jsonを保存
です。
全部の情報をそのまま送ってもいいと思うのですが、少しでも通信量を下げるために、余計なデータをカットしました。

次に、位置情報をパブリッシュするためのプログラムを作成します。
aws-iot-device-sdk-python内のサンプルファイル、「basicPubSub.py」の最後の部分を改造した、「jsonPub.py」を作成します。
位置情報が入っている、location.jsonを読み込みパブリッシュするようにします。

編集前

basicPubSub.py(112行目以降)
# Publish to the same topic in a loop forever
loopCount = 0
while True:
    if args.mode == 'both' or args.mode == 'publish':
        message = {}
        message['message'] = args.message
        message['sequence'] = loopCount
        messageJson = json.dumps(message)
        myAWSIoTMQTTClient.publish(topic, messageJson, 1)
        if args.mode == 'publish':
            print('Published topic %s: %s\n' % (topic, messageJson))
        loopCount += 1
    time.sleep(1)

編集後

jsonPub.py(112行目以降)
# Publish to the same topic in a loop forever
with open("location.json", "rb") as load_file:#位置情報ファイルの読み込み
    location = bytearray(load_file.read())#MQTT送信用にbytearray型に変換
loopCount = 0
while True:
    if args.mode == 'both' or args.mode == 'publish':
        myAWSIoTMQTTClient.publish(topic, location, 1)#位置情報をパブリッシュ
        if args.mode == 'publish':
            print('Published topic %s: %s\n' % (topic, location))#確認用
        loopCount += 1
    time.sleep(1)

これでスマホ側の構築は終わりです。

AWS側の設定

次にクラウド側の設定を行います。
といってもポリシーを変えるだけです。

AWS IoT>ポリシーから、パブリッシュ、サブスクライブしてもいいトピックを追加します。
今回は「location」という名前のトピックを追加しました。
編集が終わったら、「新しいバージョンとして保存」します。
これをしないと接続エラーが発生します。

0.PNG

テスト

AWS IoT >テストにアクセスし、トピック名(location)を入力したのち、「トピックへのサブスクライブ」をクリックします。

1.PNG

ターミナルでjsonPub.pyを実行します。
コマンドの最後に-tオプションでトピック名を指定します。

python jsonPub.py  -e (自分のARNに合わせてください)-ats.iot.ap-northeast-1.amazonaws.com -r root-CA.crt -c android01.cert.pem -k android01.private.key -t location

ターミナルに下記メッセージが出力されることを確認します。
スマホがAWSにパブリッシュした位置情報をAWSからサブスクライブできました。

Received a new message: 
b'{"latitude":35.6359322,"longitude":139.8786311}'
from topic: 
location
--------------

さらにAWS側で、locationトピックをサブスクライブしていることを確認します。
2.PNG

おつかれさまでした。

おわりに

スマホの位置情報をAWSに送信する環境を構築しました。
次は、lamda等を利用して退社自動通知や、帰りの電車の発車時刻を調べてくれたらいいですね。
ちなみに、本ページで表示している位置情報は私の家ではありませんので、ご安心ください。

今回作成したファイルは、githubにあげました。
https://github.com/zakuzakuzaki/zaki-aws/tree/main/iot

参考URL

ご協力ありがとうございました。
  
Android 端末上で開発環境を整えてみた
https://qiita.com/leosuke/items/b83425d5a6730aa4cf94

Termux で持ち運べるモバイルリポジトリを作る
https://wlog.flatlib.jp/item/1859

PythonでJSON 読み込み
https://qiita.com/kikuchiTakuya/items/53990fca06fb9ba1d8a7

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