- 投稿日:2020-10-17T21:31:29+09:00
GoでローカルWebServerを立てた
初めに
GoでWebServerを立てて、Androidアプリからアクセスする
環境
PC Windows10
Android Studio 4.0
Kotlin 1.3.72
Android端末 Emulator(API Level 29)
Go 1.11.1構成図
Androidアプリからファイルを送信して、サーバー側で保存、サーバーからアプリにステータスコードを返却する実装
ローカルサーバー
Server.gopackage 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.ktconst 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.ktfun 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で以下を実行し確認
>ipconfig5.PCとAndroid端末を同一のネットワーク環境に接続
6.Androidにて接続
Net#startConnectionのrequestUrlにhttp://{確認したIPアドレス}:8080を設定してアプリを実行
ファイルのアップロードを確認
PCにてmain.goと同じディレクトリを確認する
ファイルが作成されていれば完了最後に
全体ソースはこちら
アクセス先やRequestMethod、ファイル名はアプリで動的に変更できるようにしています。参考文献
Goサーバー側
Androidアプリ側
[Android] アプリ内にファイルを保存する FileOutputStream, FileInputStream
Upload File To Server - Android Example
[Android]kotlinでのwhile-FileInputStream.readエラー
【Kotlin】複数の値を返したい!
- 投稿日:2020-10-17T21:31:29+09:00
GoでローカルWebServerを立てた~ファイルアップロード~
初めに
GoでWebServerを立てて、Androidアプリからファイルのアップロードを行う
環境
PC Windows10
Android Studio 4.0
Kotlin 1.3.72
Android端末 Emulator(API Level 29)
Go 1.11.1構成図
Androidアプリからファイルを送信して、サーバー側で保存、サーバーからアプリにステータスコードを返却する実装
ローカルサーバー
Server.gopackage 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.ktconst 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.ktfun 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で以下を実行し確認
>ipconfig5.PCとAndroid端末を同一のネットワーク環境に接続
6.Androidにて接続
Net#startConnectionのrequestUrlにhttp://{確認したIPアドレス}:8080を設定してアプリを実行
ファイルのアップロードを確認
PCにてmain.goと同じディレクトリを確認する
ファイルが作成されていれば完了最後に
全体ソースはこちら
アクセス先やRequestMethod、ファイル名はアプリで動的に変更できるようにしています。参考文献
Goサーバー側
Androidアプリ側
[Android] アプリ内にファイルを保存する FileOutputStream, FileInputStream
Upload File To Server - Android Example
[Android]kotlinでのwhile-FileInputStream.readエラー
【Kotlin】複数の値を返したい!
- 投稿日:2020-10-17T20:10:05+09:00
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 に再利用するほうが効率的です。
ここで登場するのがStyle
とTheme
です。
Style と Theme の違いは Nick のこの記事がとてもわかり易いです。Android styling: themes vs styles | by Nick Butcher | Android Developers | Medium
Style は特定の View の View attribute を一括設定するもの
Nick は
Style
をMap<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 は
Theme
をMap<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 が直接リソースを参照していると、個々の layout.xml で設定している View attribute の設定値をすべて変更していかないといけなくなります。これは View の増加に伴って作業量が爆発的に増えていき、メンテが大変な layout が出来上がります。
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 名で検索もできます。
Attributes パネルでは設定されているリソース名までしか確認できません。また、Style などで間接的に値を設定されている場合も確認できない気がします。(この辺はよく分かっていないので知ってる人教えて! )
とにかく、表示されないときの条件は不明ですが、Attribute パネルだけではわからないときがあります。
Attribute パネルでわからないときは、実際にアプリを実行して調べる方法があります。
アプリを実行して Layout Inspector で気になる View を選択すると、実際に利用されているリソースのリテラル値が表示されます。
まとめ
Android Styling の基本をまとめました。
この記事では Styling のコアな部分しか触れていません。View attribute が適用される優先順位や、Theme Overlay の細かい挙動など、Styling で知っておくべきことはまだまだあります。Android Developers のドキュメントやブログ記事でたくさん解説されているので、興味のある方はチェックしてみてください!
- Styles and Themes | Android Developers
- Android styling: themes vs styles | by Nick Butcher | Android Developers | Medium
- Android styling: common theme attributes | by Nick Butcher | Android Developers | Medium
- Android Styling: prefer theme attributes | by Nick Butcher | Android Developers | Medium
- Android Styling: themes overlay. In previous articles in this series on… | by Nick Butcher | Android Developers | Medium
- 投稿日:2020-10-17T17:14:05+09:00
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 assemblevariantsと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を指定してビルド
あたりができたらなと思います
以上です
お付き合いいただきありがとうございました※全体的にこちらも参考にさせていただきました
- 投稿日:2020-10-17T12:23:10+09:00
Dagger Hilt (DevFest 2020 資料)
DevFestの資料の記事版です。ステップごとにサンプルアプリの差分付きで説明します。
なぜDependency Injectionが必要なのか から始め、Dagger Hiltの説明、実践的なプラクティスまで説明していきます!Dependency Injection(DI)とはなにか
なぜDIが必要なのか
DI、ちょっと難しいイメージありますが、そもそもなんで必要なんでしょうか?
作っているのが動画再生するアプリでVideoPlayerというクラスがあるとしましょう。
VideoPlayerのクラスの中にデータベースやcodecなどがハードコードされています。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/a1fdef28515d158577313b90f7c2590bd5905366VideoPlayerは依存関係が交換可能で、シンプルになった!
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() } }魔法?どこでフィールドに代入されるの?
少しだけ中身の仕組みを知っておいたほうが分かりやすいと思うので、説明しておきます。
@AndroidEntryPoint
がついているActivityはHiltによって変換されます。
Hiltによって変換後にMainActivityとAppCompatActivityの間に
生成されたHilt_MainActivityが入ります。
Hilt_MainActivityのonCreateの中でフィールドにInjectされます。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に教えています。
このレシピを@InstallIn
でSingletonComponent
に置きます。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などが付随します。
https://dagger.dev/hilt/components より今回の例ではSingletonComponentにVideoPlayerの作り方とVideoDatabaseの作り方が入っており、その作成順序も入っています。
インスタンスを共有したいときはどうするのか
例えば現状では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内でインスタンスも保持されるようになりました。
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/d66fb46b395b0c9b6a98ff91bd55f3c4f12c99c9class 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を使った図の例
今回の状況の前提
例とする状況は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/tivifastInitモードが有効になるので、影響を確認する
Dagger HiltはComponentの形が標準化されたことによって、たくさんのSingletonComponentなどのコンポーネントに、この型はこう作るなどのバインディングが入るようになります。
通常、Daggerではこのバインディングの数が増えると増えるだけインスタンス化に時間がかかります。Dagger Hiltを入れたタイミングで通常のモードではなくfastInitモードが有効になることで、これが時間がかからなくなります。しかし、この処理にはトレードオフもあるようなので、リリース後にFirebase PerformanceやAndroid Vitalsなどで確認してみましょう。val PROCESSOR_OPTIONS = listOf( "dagger.fastInit" to "enabled",
生成されるコードの比較
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#L60ViewModelにコンストラクタで値を渡す
“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/f28ceb7a6d4e69e4dafeEntryPointの定義場所
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とマルチモジュールについて考えておきます。以下のようなモジュール構成があったとします。
Applicationクラスをコンパイルする Gradleモジュールは、
すべての Hilt モジュールおよびコンストラクタ インジェクションで注入するクラスを
推移的依存関係に含める必要があります。https://developer.android.com/training/dependency-injection/hilt-multi-module?hl=ja より
ということなので、以下のようにルートのモジュールからDaggerのModuleを持つGradleモジュールへの参照が必要になります。
この部分に関して、真ん中の形が色んなパターンで無駄に依存関係を増やさずに動くので、いいのかなとは思うのですが、まだベストプラクティスと言えるものはないです。
ただ、モジュールを作ったときにクラスパスに含めるだけで@InstallIn
されたModuleがコンポーネントにインストールされて使えるので、すごく楽で、本当に使いやすいです。参考
Googleの方のchrisbanes/tiviのアプリのappモジュールからの参照
ルートのGradleモジュールから各モジュールに参照していることが分かる。
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
変換されたコードのAndroid Studioでの実行が現状、サポートされていないため、Gradleでテストを実行する必要がある。(Android StudioでGradleの設定を行ってAndroid Studioで実行させることもできる。)
詳しくは: https://dagger.dev/hilt/gradle-setup.html#running-with-android-studio
Android Studio 4.1からRun/Debug Configrationsが保存できるようになったので、活用できます。
差分: https://github.com/takahirom/hilt-sample-app/commit/2274ff3b5712e6b266cf022ff91f4581532bf45bテストを楽にするルールを作れる
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=jaAndroid Dependency Injection
https://www.youtube.com/watch?v=B56oV3IHMxg
Dagger Hilt Deep Dive
https://www.youtube.com/watch?v=4di2TTqeCrEArchitecture 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
- 投稿日:2020-10-17T00:41:15+09:00
termux×AWS IoT でスマホ位置情報をAWSに送信
はじめに
妻に、「職場を出たらラインしろ」と言われているのですが、正直面倒です。
自分が職場を出たことを自動で通知できないかなと思い、とりあえず位置情報をAWSに送ってみました。環境
スマホ:京セラ(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」を選択することで、バックグラウンド処理が行われるようになる。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」という名前のトピックを追加しました。
編集が終わったら、「新しいバージョンとして保存」します。
これをしないと接続エラーが発生します。テスト
AWS IoT >テストにアクセスし、トピック名(location)を入力したのち、「トピックへのサブスクライブ」をクリックします。
ターミナルで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トピックをサブスクライブしていることを確認します。
おつかれさまでした。
おわりに
スマホの位置情報をAWSに送信する環境を構築しました。
次は、lamda等を利用して退社自動通知や、帰りの電車の発車時刻を調べてくれたらいいですね。
ちなみに、本ページで表示している位置情報は私の家ではありませんので、ご安心ください。今回作成したファイルは、githubにあげました。
https://github.com/zakuzakuzaki/zaki-aws/tree/main/iot参考URL
ご協力ありがとうございました。
Android 端末上で開発環境を整えてみた
https://qiita.com/leosuke/items/b83425d5a6730aa4cf94Termux で持ち運べるモバイルリポジトリを作る
https://wlog.flatlib.jp/item/1859PythonでJSON 読み込み
https://qiita.com/kikuchiTakuya/items/53990fca06fb9ba1d8a7