業務に近いかたちでアプリ開発を行いながら、 Androidアプリ開発の基礎復習・実務スキルを身に付けるための研修です.
このAndroidプロジェクトはandroid-training-templateからセットアップされました.
課題の詳細や進め方は元のリポジトリのREADMEを参照してください.
License: Apache License 2.0
業務に近いかたちでアプリ開発を行いながら、 Androidアプリ開発の基礎復習・実務スキルを身に付けるための研修です.
このAndroidプロジェクトはandroid-training-templateからセットアップされました.
課題の詳細や進め方は元のリポジトリのREADMEを参照してください.
📄 JSON形式で天気を取得しましょう
YumemiWeather
suspend fun fetchJsonWeatherAsync(json: String) : String
Json文字列
Key | 型 | フォーマット | 例 |
---|---|---|---|
area | String | 任意 | 東京 |
date | String | ISO8601拡張形式 "yyyy-MM-dd'T'HH:mm" | 2020-04-01T12:00 |
Json文字列
Key | 型 | フォーマット | 例 |
---|---|---|---|
weather | String | sunny, cloudy, rainy, snow | sunny |
maxTemp | Int | -- | 20 |
minTemp | Int | -- | -20 |
date | String | ISO8601拡張形式 "yyyy-MM-dd'T'HH:mm" | 2020-04-01T12:00 |
area | String | requestと同じ | 東京 |
OkHttpのInterceptorを活用しましょう
Retrofitで実装したAPI呼び出しでは、内部的にOkHttpライブラリを利用してHTTP通信を実装しています(Square Open Sourceという同じ開発元のライブラリです)。OkHttpのInterceptorを利用すると通信リクエストの発生・レスポンスを受け取り・エラーの発生など様々なタイミングに自由な処理を挟むことができます。
Tip
Application / Network Interceptorの使い分けを意識してみましょう
HttpLoggingInterceptor
を利用して通信ログをLogcatで見てみます🔍
APIリクエストにはAPI key appid
, 言語指定lang
, 単位指定units
と共通のクエリを追加しています。Interceptorでリクエストに一括でクエリを付与すれば各エンドポイントで個別に指定せず済みます 😎
🖥️ Composeでメイン画面を追加しましょう
画像やテキストは空もしくは適当なダミー画像・文字列で大丈夫です
ComposeではColumn, Row, Boxといった標準コンポーネントを組み合わせて所望のレイアウトを組んでいきます。Viewとは異なり、Composeは何重にもネストしても計算コストの高騰を気に掛ける必要がありません!Viewでよく利用されるConstraintLayoutに相当するコンポーネントもComposeで用意されているので、好みの方法でUIを作成しましょう
以下の条件のような画面をComposeで作成しましょう
(説明の画像はViewのものを利用しています)
🔗 ViewBindingを利用してXMLレイアウトをKotlinコードから参照しましょう
findViewById
の撤廃XMLレイアウトをからViewを参照する場合はfindViewById
を使用していましたが、いくつか問題があります
加えてこれらの例外は実行時例外としてthrowされるため、コンパイル段階で気づくことが難しいです。ViewBindingの利用で問題を解決しましょう 🚀
💾 Repositoryを追加しましょう
Androidアプリ開発におけるアーキテクチャ設計はいくつかパターンがありますが、UI層とデータ層を分離する考え方には共通点があります。データ層に位置するRepositoryはUI層へデータを公開すると同時に、具体的なデータソース(APIやローカルDB)を隠蔽します。
これまでの課題でもActivityやFragmentからデータの保持と処理をViewModelへ分離してきましたが、さらにRepositoryへ分離することでUI層とデータ層を明確に区別します。
スコープを適切に設定することで同一のインスタンスを注入することができます。この課題ではアプリの状態を保持&更新する役割がViewModelからRepositoryに移動していますので、RepositoryをシングルトンとしてDIすればActivityやViewModelが破棄・再生成されても状態を保持できます。
Tip
ViewModelでSavedStateHandleを利用しなくても「Don't keep activities」オプションONで状態を保持できます
🚀 Previewを利用して開発速度を上げましょう
AndroidStudioにはCompose開発を助ける様々な機能がありますが、今回はPreview機能を活用していきます。Previewにより毎回アプリをEmulatorで起動しなくてもUIの外見を即座に確認できて便利です。
💬 APIのエラーを補足してダイアログを表示しましょう。
YumemiWeather
fun fetchThrowsWeather() : String
UnknownException
をthrowします📦 UseCaseを追加します
Repositoryの追加ではUI層とデータ層の分離を明確化しましたが、場合によっては中間にドメイン層を設けます。ドメイン層に置かれるUseCaseは、複雑なビジネスロジックをカプセル化してViewModel(UI層)から分離したり、複数のViewModelで再利用されたりします。今回はRepositoryの関数を呼び出すだけの簡単な処理ですが、UseCaseの利用を簡単に体験してみましょう。
Tip
UseCaseは通常、ひとつの関数のみ外部に公開します(invoke()
をoverrideする場合が多いです)
🖥️ Fragmentでメイン画面を追加しましょう
app/build.gradle
に追加main
ブランチのapp
を実行すると"Hello World!"と表示されますが、文字を表示するViewはMainActivity
のレイアウトファイルactivity_main.xml
に直接書かれています。しかし単一Activityのアプリでは往々にしてActivityが肥大化しがちです😰
そこでUIの機能単位ごとにViewをまとめてFragmentとして扱うと、Activityのコードやレイアウトファイルが簡潔になるだけでなく、画面の切り替えやUIの再利用が容易になります👍
レイアウトを構成するViewGroupは様々ありますが、何重にもネストしたViewGroupは計算コストが高くなる傾向にあります。一方でConstraintLayoutは自由度が高く、1層で多くのViewGroupを重ねたようにレイアウトすることが可能です。ただし、ConstraintLayoutはそれ自身が既に計算コストが高くなる傾向にあります 🤔
適宜、どのViewGroupを選択するか十分に検討するのが良いでしょう。
以下の条件のレイアウトファイルをConstraintLayoutで作ってみましょう(画像やテキストは空もしくは適当なダミー画像・文字列で大丈夫です)
🔍 JSONのデコード処理をテストします
ソースコードの品質を保つためにはテストが重要です!この課題ではJSONのデコードを例に簡単なテストを書きます。
Tip
テスト対象の入力となるJSON形式の文字列を何らかの方法で用意しましょう
🌤️ 天気を取得してメイン画面に表示しましょう
Reloadボタンをタップしたら画面を更新する実装を行います
api
モジュールのYumemiWeather
を利用します
fun fetchSimpleWeather() : String
天気を表す文字列 "sunny" or "cloudy" or "rainy" or "snow"を返します
(main
ブランチの段階ではネットワーク上での通信はせずランダムな値を返します)
こちらのSVGを利用してください
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M4.069 13h-4.069v-2h4.069c-.041.328-.069.661-.069 1s.028.672.069 1zm3.034-7.312l-2.881-2.881-1.414 1.414 2.881 2.881c.411-.529.885-1.003 1.414-1.414zm11.209 1.414l2.881-2.881-1.414-1.414-2.881 2.881c.528.411 1.002.886 1.414 1.414zm-6.312-3.102c.339 0 .672.028 1 .069v-4.069h-2v4.069c.328-.041.661-.069 1-.069zm0 16c-.339 0-.672-.028-1-.069v4.069h2v-4.069c-.328.041-.661.069-1 .069zm7.931-9c.041.328.069.661.069 1s-.028.672-.069 1h4.069v-2h-4.069zm-3.033 7.312l2.88 2.88 1.415-1.414-2.88-2.88c-.412.528-.886 1.002-1.415 1.414zm-11.21-1.415l-2.88 2.88 1.414 1.414 2.88-2.88c-.528-.411-1.003-.885-1.414-1.414zm6.312-10.897c-3.314 0-6 2.686-6 6s2.686 6 6 6 6-2.686 6-6-2.686-6-6-6z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 3c-4.006 0-7.267 3.141-7.479 7.092-2.57.463-4.521 2.706-4.521 5.408 0 3.037 2.463 5.5 5.5 5.5h13c3.037 0 5.5-2.463 5.5-5.5 0-2.702-1.951-4.945-4.521-5.408-.212-3.951-3.473-7.092-7.479-7.092z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M13 2.056v-1.056c0-.552-.448-1-1-1s-1 .448-1 1v1.052c-6.916.522-10.372 5.594-11 9.906 1.864-2.677 6.136-2.677 8 0 1.839-2.641 6.047-2.685 7.917 0 1.864-2.677 6.219-2.677 8.083 0-.625-4.291-4.125-9.333-11-9.902zm0 10.101v8.843c0 1.657-1.343 3-3 3s-3-1.343-3-3v-1h2v1c0 .551.449 1 1 1s1-.449 1-1v-8.866c.68-.226 1.27-.242 2 .023z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path d="M420.313,248.953c-9.625-19.672-22.656-37.328-38.344-52.234c8.156-17.656,12.719-37.359,12.719-58.031c0-19.094-3.875-37.391-10.906-53.984c-10.531-24.922-28.094-46.047-50.219-61.016C311.438,8.75,284.656,0,256,0c-19.094,0-37.375,3.875-54,10.906C177.094,21.453,155.969,39,141,61.141c-14.938,22.109-23.688,48.891-23.688,77.547c0,20.672,4.563,40.375,12.719,58.031c-15.688,14.906-28.719,32.563-38.344,52.234c-11.844,24.219-18.5,51.516-18.5,80.234c0,25.188,5.125,49.281,14.375,71.156c13.875,32.844,37.031,60.719,66.219,80.422C182.938,500.469,218.188,512,256,512c25.188,0,49.281-5.125,71.156-14.375c32.844-13.891,60.719-37.047,80.438-66.219c19.688-29.156,31.219-64.422,31.219-102.219C438.813,300.469,432.156,273.172,420.313,248.953zM391,386.203c-11.094,26.266-29.688,48.672-53.094,64.484c-23.406,15.797-51.5,25-81.906,25.016c-20.281-0.016-39.5-4.109-57.031-11.516c-26.281-11.094-48.656-29.703-64.469-53.094c-15.813-23.406-25-51.5-25.031-81.906c0.031-23.125,5.344-44.875,14.844-64.281c9.469-19.391,23.156-36.422,39.813-49.859c7.094-5.703,8.875-15.734,4.156-23.516c-9.313-15.438-14.656-33.438-14.656-52.844c0-14.188,2.844-27.609,8.031-39.844c7.75-18.359,20.75-34.031,37.125-45.063C215.125,42.734,234.719,36.313,256,36.297c14.188,0.016,27.594,2.875,39.844,8.047c18.344,7.75,34.031,20.766,45.063,37.109c11.047,16.359,17.469,35.969,17.469,57.234c0,19.406-5.344,37.406-14.656,52.844c-4.719,7.781-2.938,17.813,4.156,23.516c16.656,13.438,30.328,30.469,39.813,49.859c9.5,19.406,14.813,41.156,14.813,64.281C402.5,349.469,398.406,368.688,391,386.203z" /><path d="M230.781,132.391c0-8.906-7.219-16.141-16.125-16.141s-16.125,7.234-16.125,16.141c0,8.922,7.219,16.141,16.125,16.141S230.781,141.313,230.781,132.391z" /><path d="M297.344,116.25c-8.906,0-16.125,7.234-16.125,16.141c0,8.922,7.219,16.141,16.125,16.141s16.125-7.219,16.125-16.141C313.469,123.484,306.25,116.25,297.344,116.25z" /></svg>
各天気アイコンの色は以下のとおり
#FF0000
#888888
#0000FF
#44EEFF
画面の構成を踏まえてComposable関数を分割します。個々のComposable関数を小さくすることで、「何を表示すればいいか」という注目の範囲や責任も小さくなり設計し易くなります。複数画面で同じようなUI要素を表示したい場合などは、Composable関数を再利用することもできます。
💡 例えば図のような分割方法が考えられます
ところでWeatherInfoが表示する天気アイコン・気温を直書きしたら再利用できません。状態を引数として外部から受け取り、動的に表示内容を変更できるようにします。またActionButtonsでボタンがクリックされたときの処理に関しても、ActionButtons内側に直書きしては再利用できません。そこでイベントのコールバック関数を外部から引数に渡すようにします。
stateDiagram-v2
WeatherApp --> ChildComposable: state
ChildComposable --> WeatherApp: event
このように状態は呼び出し元の親から子へ、逆にイベントは子から親の呼び出し元へ流れるような設計パターンを状態ホイスティングと呼びます。
利点
宣言的UIであるComposeで画面を更新するには新しい引数でComposableを呼び出します(コンポジション)。状態を更新したときコンポジションを自動でトリガーさせるため、MutableState
で状態を保持しましょう。
Tip
MutableState
が初期化されるのを防ぐためにはremember
を利用しますMutableState
の定義&APIの呼び出し)はActivity直下のComposableにのみ現れるはずです🛠️ GradleスクリプをKotlinで書き換えます
app/build.gradle
をKotlin DSLで書き換えるbuild.gradle
をKotlin DSLで書き換える依存関係の解決などAndroid開発では決して無視できない Gradle. 古いプロジェクトでは Groovyで記述される場合もありますが Kotlinでも書けます!
Kotlinで書けると型情報があるのでエディターの型推論や自動補完の恩恵を受けられます 👍
ただしGroovyとは書き方がだいぶ異なるので注意しましょう
**/build.gradle
ファイルの拡張子を.gradle.kts
に変更YumemiWeather
があるapi
モジュールのスクリプトapi/build.gradle.kts
も参考にしてください
🚰 アプリの状態をFlowで保持しましょう
app/build.gradle
に追加ViewModelが保持している状態をFlowに置き換えます。すると外部で状態の変更を検知できるため、画面に新しい状態を反映する処理(UIロジック)が容易になります。
Tip
Flowには書き込み可能な型と不可能な型がありますので、ViewModel内部で保持するFlowの型と外部に公開する型に気をつけましょう
Flowでラップされた状態の更新を検知して画面に反映します
Tip
Flowをcollect
する時はActivityやFragmentのライフサイクルを意識しましょう。ComposeでUIを作成した場合はcollectAsState*
APIでFlowからStateに変換します。
🌤️ 天気を取得してメイン画面に表示しましょう
Reloadボタンをタップしたら画面を更新する実装を行います
api
モジュールのYumemiWeather
を利用します
fun fetchSimpleWeather() : String
天気を表す文字列 "sunny" or "cloudy" or "rainy" or "snow"を返します
🚧 main
ブランチの段階ではネットワーク上での通信はせずランダムな値を返します
こちらのSVGを利用してください
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M4.069 13h-4.069v-2h4.069c-.041.328-.069.661-.069 1s.028.672.069 1zm3.034-7.312l-2.881-2.881-1.414 1.414 2.881 2.881c.411-.529.885-1.003 1.414-1.414zm11.209 1.414l2.881-2.881-1.414-1.414-2.881 2.881c.528.411 1.002.886 1.414 1.414zm-6.312-3.102c.339 0 .672.028 1 .069v-4.069h-2v4.069c.328-.041.661-.069 1-.069zm0 16c-.339 0-.672-.028-1-.069v4.069h2v-4.069c-.328.041-.661.069-1 .069zm7.931-9c.041.328.069.661.069 1s-.028.672-.069 1h4.069v-2h-4.069zm-3.033 7.312l2.88 2.88 1.415-1.414-2.88-2.88c-.412.528-.886 1.002-1.415 1.414zm-11.21-1.415l-2.88 2.88 1.414 1.414 2.88-2.88c-.528-.411-1.003-.885-1.414-1.414zm6.312-10.897c-3.314 0-6 2.686-6 6s2.686 6 6 6 6-2.686 6-6-2.686-6-6-6z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 3c-4.006 0-7.267 3.141-7.479 7.092-2.57.463-4.521 2.706-4.521 5.408 0 3.037 2.463 5.5 5.5 5.5h13c3.037 0 5.5-2.463 5.5-5.5 0-2.702-1.951-4.945-4.521-5.408-.212-3.951-3.473-7.092-7.479-7.092z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M13 2.056v-1.056c0-.552-.448-1-1-1s-1 .448-1 1v1.052c-6.916.522-10.372 5.594-11 9.906 1.864-2.677 6.136-2.677 8 0 1.839-2.641 6.047-2.685 7.917 0 1.864-2.677 6.219-2.677 8.083 0-.625-4.291-4.125-9.333-11-9.902zm0 10.101v8.843c0 1.657-1.343 3-3 3s-3-1.343-3-3v-1h2v1c0 .551.449 1 1 1s1-.449 1-1v-8.866c.68-.226 1.27-.242 2 .023z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path d="M420.313,248.953c-9.625-19.672-22.656-37.328-38.344-52.234c8.156-17.656,12.719-37.359,12.719-58.031c0-19.094-3.875-37.391-10.906-53.984c-10.531-24.922-28.094-46.047-50.219-61.016C311.438,8.75,284.656,0,256,0c-19.094,0-37.375,3.875-54,10.906C177.094,21.453,155.969,39,141,61.141c-14.938,22.109-23.688,48.891-23.688,77.547c0,20.672,4.563,40.375,12.719,58.031c-15.688,14.906-28.719,32.563-38.344,52.234c-11.844,24.219-18.5,51.516-18.5,80.234c0,25.188,5.125,49.281,14.375,71.156c13.875,32.844,37.031,60.719,66.219,80.422C182.938,500.469,218.188,512,256,512c25.188,0,49.281-5.125,71.156-14.375c32.844-13.891,60.719-37.047,80.438-66.219c19.688-29.156,31.219-64.422,31.219-102.219C438.813,300.469,432.156,273.172,420.313,248.953zM391,386.203c-11.094,26.266-29.688,48.672-53.094,64.484c-23.406,15.797-51.5,25-81.906,25.016c-20.281-0.016-39.5-4.109-57.031-11.516c-26.281-11.094-48.656-29.703-64.469-53.094c-15.813-23.406-25-51.5-25.031-81.906c0.031-23.125,5.344-44.875,14.844-64.281c9.469-19.391,23.156-36.422,39.813-49.859c7.094-5.703,8.875-15.734,4.156-23.516c-9.313-15.438-14.656-33.438-14.656-52.844c0-14.188,2.844-27.609,8.031-39.844c7.75-18.359,20.75-34.031,37.125-45.063C215.125,42.734,234.719,36.313,256,36.297c14.188,0.016,27.594,2.875,39.844,8.047c18.344,7.75,34.031,20.766,45.063,37.109c11.047,16.359,17.469,35.969,17.469,57.234c0,19.406-5.344,37.406-14.656,52.844c-4.719,7.781-2.938,17.813,4.156,23.516c16.656,13.438,30.328,30.469,39.813,49.859c9.5,19.406,14.813,41.156,14.813,64.281C402.5,349.469,398.406,368.688,391,386.203z" /><path d="M230.781,132.391c0-8.906-7.219-16.141-16.125-16.141s-16.125,7.234-16.125,16.141c0,8.922,7.219,16.141,16.125,16.141S230.781,141.313,230.781,132.391z" /><path d="M297.344,116.25c-8.906,0-16.125,7.234-16.125,16.141c0,8.922,7.219,16.141,16.125,16.141s16.125-7.219,16.125-16.141C313.469,123.484,306.25,116.25,297.344,116.25z" /></svg>
各天気アイコンの色は以下のとおり
#FF0000
#888888
#0000FF
#44EEFF
🔍 ViewModelのユニットテストを追加します
Tip
Repositoryを導入している場合、ViewModelはRepositoryの関数を呼び出すだけ&プロパティを公開するだけの実装になっているかもしれません。代わりにRepositoryをテストします。
テストを書く前提でプログラムを設計していないと、テストはなかなか書きづらいものです。もしテストが書けなかった場合は次のようなリファクタリングをしてみましょう。
YumemiWeather
をInterfaceで抽象化して、ViewModelもしくはRepositoryのAPI呼び出しはInterfaceに依存させるYumemiWeather
などの依存を渡すテスト対象のViewModelもしくはRepositoryを動かすためにはAPI呼び出しの実装(YumemiWeather
など)が必要です。しかしテスト中にネットワーク通信が発生してしまうと様々な外的要因が入ってしまい、テスト対象自体に問題が無くてもテストが失敗する可能性があります 😇
ネットワーク通信に限らず、テストでは対象以外の依存をモック(代わりのインスタンス)に差し替えることでテスト対象に関心を集中させます 😎
ComposeのUIテストを追加します
アプリの画面が実際に想定通り表示されているかを自動テストします。一般にUIテストはユニットテストと比較して、影響される要素が多くテストが煩雑・不安定と敬遠されがちです。しかしComposeではシンプルなAPIを利用してUIテストを簡単に構築することができます。
APIが返す天気情報がテスト対象への入力となるので、APIレスポンスをテスト側で制御する必要があります。ただしComposableからは直接APIを呼び出さず、ViewModelから呼び出すようこれまでの課題で設計してきたので工夫が必要です。
YumemiWeather
などAPI呼び出しをモックするTip
テストを意識したComposableの設計が大切です。以下のようにViewModelを外部から受け取れるようにします。ただしデフォルト引数を指定して、テスト以外ではHiltでDIされるViewModelを参照します
@Composable
fun MyComposable(
modifier: Modifier = Modifier, // デフォルト引数ありの引数はModifierを最初に書くのが通例です
viewModel: MyViewModel = hiltViewModels(),
) {}
🎯 ViewModelを追加しましょう
app/build.gradle
に追加AAC (Android Architecture Components)のViewModelをアプリに追加しましょう。ViewModelで天気情報を取得・保持する実装を行うと様々な利点があります 🚀
アプリの状態をUIにどう反映させるかの処理をUIロジックと呼び、ActivityやFragmentとレイアウトファイル(Jetpack Composeの場合はComposable関数)が該当します。一方でデータを管理してアプリの状態を更新する処理をビジネスロジックと呼びます。これまではActivityやFragmentに直接ビジネスロジックを記述していましたが、ViewModelに分離することでActivityやFragmentはUIロジックに専念できます。
Tip
by viewModels()
イディオムが便利ですviewModel()
を指定します画面の回転など構成が変更されるとActivityは再生成されるため、アプリの状態(表示されている天気アイコン)が初期状態に戻ってしまい維持されません。しかしViewModelで状態を保持すると画面の回転でも状態を維持できます👍
ViewModelはActivityよりもライフサイクルが長く、画面が回転しても生存し続けるためです。
Android developersより引用
プロジェクトをマルチモジュール構成にします
app
モジュールの一部機能を別のモジュールに分割するapp
モジュールから新しいモジュールを利用してビルドできるアプリが複雑で肥大化する場合、app
モジュールに全部のコードを記述すると見通しが悪くなります。ここではマルチモジュール開発を簡単に体験してみます。
Tip
YumemiWeather
を定義するためapi
という別モジュールがあらかじめ用意されているので参考にしてください
🌤️ 非同期に天気を取得します
api
モジュールのYumemiWeather
を利用します
suspend fun fetchWeatherAsync() : String
suspend
な関数は同じsuspend
関数から、もしくはコルーチンから呼び出せます。ViewModelでコルーチンを起動してfetchWeatherAsync
を呼び出しましょう。
Tip
ViewModelのライフサイクルに対応したコルーチンスコープviewModelScope
を利用します。ViewModelが破棄されると起動したコルーチンも自動でキャンセルされます。
🗡️ Dagger Hiltを利用してDIします
YumemiWeather
をViewModelにDIするアプリでは天気を取得するのにYumemiWeather
を依存として利用しています。しかしアプリが複雑になるとより多くの依存が必要となり、手動で用意するのは大変です。DI (Dependency Injection)を利用すると自動で依存を必要な場所に用意してくれるため、大規模なアプリ開発では必須のツールです。
Tip
ビジネスロジックが集約されているViewModelに依存を注入します。
@HiltViewModel
class YourViewModel @Inject constructor(
private val weather YumemiWeather,
) : ViewModel() { }
ViewModel導入の課題で画面を回転させても状態(天気アイコン)を保持できるように修正しました。しかしまだ対応できない場合もあります。
通常ではバックグラウンドに移行したアプリも再度表示すれば、直前の状態から引き続きアプリを利用できます。しかしAndroidシステムはメモリ解放など状況に応じてバックグラウンドのActivityを破棄する場合があり、ViewModelも同時に破棄されて状態を保持できません。開発者オプションの「Don't keep activities」はこの状況を意図的に再現するのに利用します。
この課題では「Don't keep activities」オプションがONでもアプリの状態を保持できるように改修しましょう。
Tip
ViewModelでSavedStateHandleを利用する方法があります
🖥️ 詳細画面を追加してメイン画面から遷移できるようにします
以下の条件を満たす範囲で自由にレイアウトを組んでください
Tip
リスト表示にはRecyclerViewもしくはListViewを利用します
リストの各要素に表示する
Fragmentを追加したり、移動するにはFragmentManagerを利用します。戻るボタンで元の画面に遷移できよう、BackStackにトランザクションを積んでおきましょう。
Warning
Fragmentのコンストラクタに引数を渡す方法は正しく動作しない場合があります。Activity同様にFragmentもAndroidシステムによって破棄&再生成される場合がありますが、再生成時は引数なしコンストラクタが呼ばれるためデータが失われてしまいます😰
代わりにBundleを利用します
val fragment = YourFragment().apply {
arguments = bundleOf(
"key" to "value",
)
}
天気予報のリスト表示は空もしくはダミーデータで大丈夫です
マルチモジュール化を今後するので、version catalog
を導入しておいたほうが良さそう
Navigation Componentを利用します
これまで画面遷移(Fragmentのトランザクション)はFragmentManagerを直接利用していました。Naviigationを利用するとより効率的に安全にFragmenの画面遷移を実装できます。
Navigationにおいてデスティネーション(遷移先のFragment)には引数を設定することができます。この引数を型安全に扱うためのGradleプラグインがSafeArgsです。プラグインのセットアップ方法は公式ドキュメントを参照してください。
例えば、次のようなデスティネーションにString型の引数を定義すると、
<fragment
android:id="@+id/myFragment"
android:name="jp.co.yumemi.droidtraining.ui.MyFragment"
android:label="Forecast">
<argument
android:name="key"
app:argType="string" />
</fragment>
MyFragment
をデスティネーションとするactionにはString型の引数が必要になります
val action = SomeFragmentDirections.someAction("value")
受け取るFragment側ではnavArgs
で引数を取得できます。
// MyFragmentArgsはプラグインが自動生成した引数の型です
val args: MyFragmentArgs by navArgs()
val key: String = args.key
val viewModel: MyViewModel by viewModels()
ViewModelで引数を受け取ることもできます。
@HiltViewModel
class MyViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val args by lazy {
MyFragmentArgs.fromSavedStateHandle(savedStateHandle)
}
}
🌤️ APIから天気予報を取得して画面に表示します
Important
天気予報をAPIから取得する実装は作成済みコードを利用できます
ご自身で実装する余裕がない場合は活用してください
これまで学んできた知識を活用して天気予報を表示しましょう
OpenWeatherMapの5 day weather forecastを利用します。指定した地点の向こう5日間の天気情報を3時間ごとに取得できます。API keyの取得、地点の指定、レスポンスの表記方法の指定などは以前の課題 #22 を参照してください。
APIから天気予報を取得する実装は作成済みコードを利用できます
template/api-weather-forecast
ブランチをmainまたは作業ブランチにmergeしてください
OpenWeatherMapから取得したAPI keyを記載したapi/apikey.properties
ファイルを追加します
(ファイルは.gitignoreに指定されているのでGitHub上に公開されません)
api_key="your_api_key"
特にパラメータを指定しなければapi/apikey.properties
で指定したAPI keyを利用します
val weather = YumemiWeather()
YumemiWeather
suspend fun fetchJsonForecastAsync(json: String) : String
都市名 | id | country |
---|---|---|
札幌 | 2128295 | JP |
釧路 | 2129376 | JP |
仙台 | 2111149 | JP |
新潟 | 1855431 | JP |
東京 | 1850144 | JP |
名古屋 | 1856057 | JP |
金沢 | 1860243 | JP |
大阪 | 1853909 | JP |
広島 | 1862415 | JP |
高知 | 1859146 | JP |
福岡 | 1863967 | JP |
鹿児島 | 1860827 | JP |
那覇 | 1856035 | JP |
New York | 5128581 | US |
London | 2643743 | GB |
WeatherRequest
Key | 型 | フォーマット | 例 |
---|---|---|---|
area | String | 都市名 | 東京 |
date | String | ISO8601拡張形式 "yyyy-MM-dd'T'HH:mm" | 2020-04-01T12:00 |
ForecastResponse
Key | 型 | フォーマット | 例 |
---|---|---|---|
list | List<ForecastPoint> | ||
area | String | requestと同じ | 東京 |
ForecastPoint
Key | 型 | フォーマット | 例 |
---|---|---|---|
weather | String | sunny, cloudy, rainy, snow | sunny |
temperature | Int | -- | 20 |
date | String | ISO8601拡張形式 "yyyy-MM-dd'T'HH:mm" | 2020-04-01T12:00 |
🔗 DataBindingを利用してUIにデータを反映しましょう
これまでレイアウトの定義はXMLファイルで、データを画面に反映するUIロジックはKotlinファイルに別々に書いていました。DataBindingを利用するとUIロジックもXMLファイル側にシンプルに記述できます 🚀
するとUIに関する記述はXMLファイル側に、データの操作に関する記述はKotlinファイル側にそれぞれ集約され見通しも良くなります 👍
今回は天気を表すデータをレイアウトファイルに追加します
(String型以外にもEnum Classなども考えられます)
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="weather"
type="String" />
</data>
<ConstraintLayout... /> <!-- UI layout's root element -->
</layout>
🖥️ 詳細画面を追加してメイン画面から遷移できるようにします
以下の条件を満たす範囲で自由にレイアウトを組んでください
Tip
リスト表示(縦方向)にはLazyColumnを利用します
リストの各要素に表示する
天気予報のリスト表示は空もしくはダミーデータで大丈夫です
📩 実際にネットワーク経由でAPIを利用しましょう。今回は無料でも利用できるOpenWeatherMapを使います
Important
この課題は選択必須です
ご自身で直接実装する代わりに作成済みのコードを利用する選択もできます
API keyなど認証情報は自分以外に知られたくはありません。センシティブな情報はソースコードにハードコーディングせず、別ファイルに保存してソースコードから読み出して使いましょう。ただ認証情報を書いたファイルをそのままGitHubに公開しては意味がないので、**/.gitignore
ファイルを適宜編集してGitHub上にpushされないよう注意しましょう 🚨
Retrofitライブラリを利用するとinterfaceによる抽象的な定義だけで通信を簡単に実装できます 🚀
加えてConverterを指定すればAPIレスポンスの文字列からデータモデルのデコードまで処理しくれます!以前の課題で kotlin-serializationによるJSONデコードを実装したので、同じく kotlin-serializationを利用するKotlin Serialization Converterを使ってください。
OpenWeatherMapでは多種のAPIが提供されていますが、今回は無料枠でも使用できる current weather dataを呼び出します
APIのパラメータ指定やレスポンスの詳細などはAPI docsを参照しましょう
Tip
地点の緯度経度lon, lat
でも指定できますが、意図しない都市がヒットする場合があります。代わりに都市IDid
をクエリパラメータに指定してください。有効な都市IDの例を下の表に示しましたので、ランダムな都市を選んで天気情報を取得してみましょう。
都市名 | id | country |
---|---|---|
札幌 | 2128295 | JP |
釧路 | 2129376 | JP |
仙台 | 2111149 | JP |
新潟 | 1855431 | JP |
東京 | 1850144 | JP |
名古屋 | 1856057 | JP |
金沢 | 1860243 | JP |
大阪 | 1853909 | JP |
広島 | 1862415 | JP |
高知 | 1859146 | JP |
福岡 | 1863967 | JP |
鹿児島 | 1860827 | JP |
那覇 | 1856035 | JP |
New York | 5128581 | US |
London | 2643743 | GB |
都市名は日本語で、数値はメートル法でJSONを返してもらうには、クエリパラメータにlang=ja, units=metric
を付与します。
前回の課題で利用したYumemiWeather
のレスポンスに対応するフィールドは、
.weather[0].id
(idと天気状態の対応はWeather ConditionのAPI docsを参照).main.temp_max
.main.temp_min
.dt
(秒単位のUnix Timestamp).name
(場合によってはローマ字表記)template/api-current-weather
ブランチをmainまたは作業ブランチにmergeしてください
OpenWeatherMapから取得したAPI keyを記載したapi/apikey.properties
ファイルを追加します
(ファイルは.gitignoreに指定されているのでGitHub上に公開されません)
api_key="your_api_key"
特にパラメータを指定しなければapi/apikey.properties
で指定したAPI keyを利用します
val weather = YumemiWeather()
YumemiWeather
suspend fun fetchJsonWeatherAsync(json: String) : String
Json文字列
Key | 型 | フォーマット | 例 |
---|---|---|---|
area | String | 都市名 | 東京 |
date | String | ISO8601拡張形式 "yyyy-MM-dd'T'HH:mm" | 2020-04-01T12:00 |
Json文字列
Key | 型 | フォーマット | 例 |
---|---|---|---|
weather | String | sunny, cloudy, rainy, snow | sunny |
maxTemp | Int | -- | 20 |
minTemp | Int | -- | -20 |
date | String | ISO8601拡張形式 "yyyy-MM-dd'T'HH:mm" | 2020-04-01T12:00 |
area | String | requestと同じ | 東京 |
Warning
LiveDataはFlowに比べてレガシーなAPIです。
内定者研修として課題に取り組むなど特別な場合以外はスキップしてください。
LiveDataはFlow同様に外部から状態の更新を監視可能にするAPIのひとつです。昨今のアプリ開発では最新のFlowを積極的に採用する場合が多いですが、依然としてLiveDataも使われ続けています。
🛠️ Jetpack Composeを利用する環境を準備しましょう
Important
この課題は選択必須です
ご自身で直接実装する代わりに作成済みのコードを利用できます
Jetpack ComposeはAndroidの新しいUIツールキットです。XMLファイルを利用するViewとは対照的に、宣言的にUIを定義できるためより直感的に・少ないコード量でレイアウトを組めます。
最新の Android Studio で新規プロジェクトを作成すると、デフォルトで Compose を利用した雛形が作成されます。既存のViewで実装されたプロジェクトを Compose に移行する場合を除けば、多くの場合でこの課題の作業は不要となります。
template/compose
ブランチに Android Studio が自動作成する雛形と同様のコードがあるので、mainまたは作業ブランチにmergeしてください
コードの品質を担保するためにLint(静的コード解析ツール)や自動テストの実行を習慣づけましょう 😎
今回はGitHub Actionsを利用してテストのワークフローを実行させます.
Important
この課題は選択必須です
ご自身で直接実装する代わりに作成済みのコードを利用する選択もできます
ワークフローの起動タイミングは色々指定できるので、PRを出した時に自動で起動するよう設定します.
もしLintやテストが失敗すればワークフローも失敗するので、PRをマージする前に異常に気付けます👍
Android Lintや単体テストの実行はAndroidStudioと同様にGradleタスクとして実行させますが、まずはワークフローでJavaを使える環境をセットアップします ☕️
GithHub Actionsのマーケケットプレイスで提供されている様々なアクションを利用すると、複雑な処理を簡単な呼び出しで実現できます!JDKのセットアップにはこちらのアクションを利用しましょう
Gradleタスク:lint
もしくはlint${build_variant}
Androidアプリ開発に関する様々なバグの元を解析して教えてくれます
$ ./gradlew lintRelease
Gradleタスク:test${build_variant}UnitTest
**/src/test/
以下に定義したすべての単体テストを実行します
$ ./gradlew testReleaseUnitTest
デフォルトではレビュワーの承認が無くても、GitHub Actionsで実行したLintやテストが失敗してもPRはマージできてしまいますが、こうした事故を未然に防ぐ仕組みがあります.
template/ci
ブランチをmainまたは作業ブランチにmergeしてください.github/actions/check-pull-request/
以下のファイルすべてを.github/workflows/
以下に移動してくださいこちらの作成済みブランチでは課題内容に加えて以下の機能が追加されています
💬 APIのエラーを補足してダイアログを表示しましょう。
YumemiWeather
fun fetchThrowsWeather() : String
UnknownException
をthrowしますエラーなどのイベントをUIでどう処理するのか?ではなく、イベントによってUIが表示すべき状態をどう変化させるか、という観点でモデル化します。今回ではエラーが発生すると、エラーダイアログを表示するか・非表示かの状態に影響しますので、例えば次のようにUI状態を定義できます
data class WeatherState(
val weather: String?, // もっと適切な表現方法があります!
val showErrorDialog: Boolean,
)
💡 ComposeではUI状態をひとつのDataClassにまとめて扱う場合が多いです
🌤️ APIから天気予報を取得して画面に表示します
Important
天気予報をAPIから取得する実装は作成済みコードを利用できます
ご自身で実装する余裕がない場合は活用してください
これまで学んできた知識を活用して天気予報を表示しましょう
OpenWeatherMapの5 day weather forecastを利用します。指定した地点の向こう5日間の天気情報を3時間ごとに取得できます。API keyの取得、地点の指定、レスポンスの表記方法の指定などは以前の課題 #20 を参照してください。
APIから天気予報を取得する実装は作成済みコードを利用できます
template/api-weather-forecast
ブランチをmainまたは作業ブランチにmergeしてください
OpenWeatherMapから取得したAPI keyを記載したapi/apikey.properties
ファイルを追加します
(ファイルは.gitignoreに指定されているのでGitHub上に公開されません)
api_key="your_api_key"
特にパラメータを指定しなければapi/apikey.properties
で指定したAPI keyを利用します
val weather = YumemiWeather()
YumemiWeather
suspend fun fetchJsonForecastAsync(json: String) : String
都市名 | id | country |
---|---|---|
札幌 | 2128295 | JP |
釧路 | 2129376 | JP |
仙台 | 2111149 | JP |
新潟 | 1855431 | JP |
東京 | 1850144 | JP |
名古屋 | 1856057 | JP |
金沢 | 1860243 | JP |
大阪 | 1853909 | JP |
広島 | 1862415 | JP |
高知 | 1859146 | JP |
福岡 | 1863967 | JP |
鹿児島 | 1860827 | JP |
那覇 | 1856035 | JP |
New York | 5128581 | US |
London | 2643743 | GB |
WeatherRequest
Key | 型 | フォーマット | 例 |
---|---|---|---|
area | String | 都市名 | 東京 |
date | String | ISO8601拡張形式 "yyyy-MM-dd'T'HH:mm" | 2020-04-01T12:00 |
ForecastResponse
Key | 型 | フォーマット | 例 |
---|---|---|---|
list | List<ForecastPoint> | -- | -- |
area | String | requestと同じ | 東京 |
ForecastPoint
Key | 型 | フォーマット | 例 |
---|---|---|---|
weather | String | sunny, cloudy, rainy, snow | sunny |
temperature | Int | -- | 20 |
date | String | ISO8601拡張形式 "yyyy-MM-dd'T'HH:mm" | 2020-04-01T12:00 |
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.