第7章 データ取得
7.0 事前準備
エンジニア定例フレームワーク回で作成したAPIを使ってニュースを取得します。
…が、執筆現在(2023/09/11時点)未公開となっているため、以下のレスポンスを返すmockを作成してください。
記事一覧取得
-
GET
/articles
-
レスポンス
[ { "id": 0, "title": "やばいニュース", "detail": "hoge", "type": "エンタメ", "img_url": "https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg", "created_at": "2022-12-05T09:46:20", "updated_at": "2022-12-05T09:46:20" } ]
記事詳細取得
-
GET
/articles/0(記事ID)
-
レスポンス
{ "id": 0, "title": "やばいニュース", "detail": "hoge", "type": "エンタメ", "img_url": "https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg", "created_at": "2022-12-05T09:46:20", "updated_at": "2022-12-05T09:46:20" }
コメント一覧取得
-
GET
/comments/0(記事ID)
-
レスポンス
[ { "detail": "感動しました!", "id": 1, "name": "田中", "updated_at": "2020-07-21T20:00:00", "created_at": "2020-07-21T20:00:00", "article_id": 0 } ]
コメント作成
-
POST
/comments
-
body
{ "name": "string", "article_id": 0, "detail": "string" }
-
レスポンス
- なし
ローカルでAPIを立てている場合、localhostはHTTP通信となるため、アプリ側で許可するようにします。
-
app/src/main/res/xml/network_security_config.xml
を作成します<?xml version="1.0" encoding="utf-8"?> <network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">10.0.2.2</domain> </domain-config> </network-security-config>
-
AndroidManifest.xml
に以下を追加します<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="android.permission.INTERNET" /> <application android:networkSecurityConfig="@xml/network_security_config" // 追加 android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.StrongestNews" tools:targetApi="31"> <activity android:name=".MainActivity" android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.StrongestNews"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
7.1 データ層
アプリの場合、データの取得はHTTP経由でAPIから取得することがほとんどです。
ニュースアプリも、ニュースを更新するために毎度アプリアップデートをするのは現実的でないため、APIからニュース一覧を取得するようにしたいです。
この場合のデータ層を考えてみましょう。
DataSourceオブジェクトは特定のデータへの入出力を行うオブジェクトです。
例えば、ニュース記事一覧を取得するのであれば以下のようなインターフェースで表されます。
interface NewsDataSourceRemote {
fun getArticles(): List<Article>
}
SET系操作(UPDATE/INSERT)やDELETEが必要であれば足します。
DataSourceを使う側は裏側がHTTPかどうかを気にせず、単にデータ操作用のメソッドを呼べば良いことになります。実際の処理はHTTPクライアントを利用して実装されます。
実際に記事一覧を取得するインターフェースを定義してみましょう。
-
com.example.strongetnews.data.remote
パッケージを作成し、その配下にNewsDataSourceRemote.kt
を作成しますapp/src/main/java/com/example/strongestnews └── data // 新規作成 └── remote // 新規作成 └── NewsDataSourceRemote.kt // 新規作成
-
DataSource内にinterfaceを定義します
- 記事一覧を取得
- アプリ内で使うArticleModelがListでデータが返ってきて欲しい
package com.example.strongestnews.data.remote import com.example.strongestnews.models.Article interface NewsDataSourceRemote { fun getArticles(): List<Article> }
7.2 非同期にデータ通信する
上は単純な例ですが、実際には通信は非同期で行うことになります。
引数を直接返り値として受け取ることができないため、コールバック経由で呼び出すことになります。やや複雑になりましたが、やりたいことは先ほどの例と変わっていません。
interface NewsDataSourceRemote {
fun interface GetArticleCallback {
fun onFinished(result: Article)
}
fun getArticles(callback: GetArticleCallback)
}
7.2.1 Kotlin Coroutine
コルーチンとは、中断可能な関数のことです。
HTTP通信のような時間のかかるタスクを実行するとき、Mainスレッドで実行してしまうと画面描画などの処理を止めてしまうことになるので、別スレッドで実行したいです。
しかし、別スレッドで通信処理をしてもI/Oスレッドではリクエストを投げた後レスポンスを受け取るまで何もせずに待っている時間があります。
そこで、リクエストを投げた時点で呼び出し元に処理を戻します。レスポンスが得られたイベントで再度処理をI/Oスレッドに戻し、結果を返すようにする事で待ち時間を無くします。
これをコルーチンで実現します。
コルーチンでは通常の関数に2つの振る舞いを追加します。
- suspend:現在のコルーチンの実行を一時停止し、すべてのローカル変数を保存
- resume:中断されたコルーチンを一時停止した場所から再開
コールバック経由で呼び出していた非同期なデータ通信を書き換えてみましょう。
Kotlin Coroutineのsuspend関数を使い非同期処理を含めた関数を定義することができます。
-
DataSourceのインターフェース
package com.example.strongestnews.data.remote import com.example.strongestnews.models.Article interface NewsDataSourceRemote { suspend fun getArticles(): List<Article> }
HTTP通信をライブラリが提供する非同期関数を用いて実装したとすると、suspend関数を用いて下記のように実装することができます。(ライブラリ導入後に実装できるようになります)
class ArticleDataSourceImpl: ArticleDataSource (
private val newsApi: NewsAPI
) {
suspend fun getArticle(id: Int): Article {
// NewsApi.getArticleはライブラリを利用して実装された非同期関数
val response = newsApi.getArticle(id)
// Modelに変換
return Article(
id = response.id
title = response.title
detail = response.detail
type = response.type
img_url = response.img_url
created_at = response.created_at
updated_at = response.updated_at
)
}
}
7.3 Retrofit
RESTful APIを使ってリソースを操作することを考えた場合、以下の2点を考える必要があります。
- HTTPによる通信
- プログラムで使っているクラス ↔ Bodyに載るJSON の相互変換
Java/Kotlinにはこの2つの処理を一括で行ってくれるライブラリが存在します。
それが Retrofit です。
Retrofitは単独では動作せず、以下の2種類のライブラリの力を借りて動作します。
Retrofitはこれらを連携させつつ、自動コード生成やリフレクションの仕組みを利用することによりAPIアクセスを簡単に扱うことを実現します。
7.3.1 必要なライブラリの導入
build.gradleに導入したいライブラリを記入し、File >
Sync Project with Gradle Files
でインストールします。
Serialization
-
pluginを追加
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") // serialization kotlin("plugin.serialization") version "1.9.10" } /* ... */
-
dependenciesを追加
/* ... */ dependencies { implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") implementation("androidx.activity:activity-compose:1.7.0") implementation(platform("androidx.compose:compose-bom:2023.03.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("io.coil-kt:coil-compose:2.4.0") implementation("androidx.navigation:navigation-compose:2.5.3") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") // serialization implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") }
Sync Now
します
Retrofit2
-
app/build.gradleのdependenciesに以下を追記
// Retrofit implementation("com.squareup.retrofit2:retrofit:2.9.0")
Sync Now
します
okhttp3
-
app/build.gradleのdependenciesに以下を追記
// OkHttp implementation("com.squareup.okhttp3:okhttp-bom:4.11.0") implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
Sync Now
します
retrofit2-kotlinx-serialization-converter
-
app/build.gradleのdependenciesに以下を追記
// Retrofit implementation("com.squareup.retrofit2:retrofit:2.9.0")
Sync Now
します
Hilt
Hiltの導入にはDaggerとKaptが必要なため、一緒に入れます。
KaptはKSPへの移行が推奨されていますが、Hilt内部でKapt脱却が未完了なため、苦しみに耐えてKaptを使います。
-
ルートの
/build.gradle.kts
(rootにあるGradleファイル) に以下を追加します// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id("com.android.application") version "8.1.0" apply false id("org.jetbrains.kotlin.android") version "1.8.10" apply false id("com.google.dagger.hilt.android") version "2.44" apply false }
-
Sync Now
します -
Hiltはkaptを使用するので、kaptを先に入れます
-
app/build.gradle.kts
のpluginに以下を追加plugins { id("com.android.application") id("org.jetbrains.kotlin.android") // serialization kotlin("plugin.serialization") version embeddedKotlinVersion // hilt kotlin("kapt") } /* ... */
-
Sync Now
します
-
-
app/build.gradle.kts
に以下を追加しますplugins { id("com.android.application") id("org.jetbrains.kotlin.android") // serialization kotlin("plugin.serialization") version embeddedKotlinVersion // hilt kotlin("kapt") id("com.google.dagger.hilt.android") } /* ... */ dependencies { /* ... */ // hilt implementation("com.google.dagger:hilt-android:2.44") implementation("androidx.hilt:hilt-navigation-compose:1.0.0") kapt("com.google.dagger:hilt-android-compiler:2.44") } kapt { correctErrorTypes = true }
-
Javaのバージョンが17になっていることを確認します
/* ... */ android { /* ... */ compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } /* ... */
-
Sync Now
します
7.3.2 データ定義
まずはAPIで操作する対象のデータを定義します。
-
以下の様に、
com.example.strongestnews.data.remote
配下にmodel
パッケージを作成し 、Article.kt
という名前でファイルを作成します。com.example.strongestnews └── data └── remote ├── NewsDataSourceRemote.kt └── model // 新規作成 └── Article.kt // 新規作成
-
Article.kt
にAPIで操作する対象のデータを定義します。APIのSwaggerを見ながら書いていきましょう。
ここで定義するモデルはAPIと通信する際に使用するもので、アプリ内で使用するモデルとは異なります。
package com.example.strongestnews.data.remote.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.net.URL import java.util.Date @Serializable data class Article( val id: Int, val title: String, val detail: String, val type: String, @SerialName("img_url") val img: URL?, @SerialName("created_at") val createdAt: Date, @SerialName("updated_at") val updatedAt: Date )
シリアライザー@Serializable
と、何やら謎のアノテーションがついていますが、これを指定することにより{ "id": 111, "title": "やばいニュース", "detail": "とってもやばいニュースの詳細hogehoge", "type": "variety", "img_url": "https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg", "created_at": "2022-03-29T10:00:00", "updated_at": "2022-03-29T10:00:00" }
のような形式のJSONと相互変換するためのクラスが自動生成されるようになります。
自分でカスタムしたシリアライザーを作成することもできます。
-
このままではURLとDateがパースできないため、カスタムシリアライザーを作成します。
-
同階層に
Serializers.kt
というファイルを作成します。com.example.strongestnews └── data └── remote ├── NewsDataSourceRemote.kt └── model └── Article.kt └── Serializers.kt // 新規作成
-
Serializer.kt
にURLとDateのシリアライザーを作成します。package com.example.strongestnews.data.remote.model import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.net.MalformedURLException import java.net.URL import java.text.SimpleDateFormat import java.util.Date import java.util.Locale object URLSerializer : KSerializer<URL?> { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("URL", PrimitiveKind.STRING) // デコード // StringをURLに変換。空文字など不正な文字列のときはnullを返す override fun deserialize(decoder: Decoder): URL? { try { return URL(decoder.decodeString()) } catch (e: MalformedURLException) { return null } } // エンコード // 値があれば文字列に変換し、nullなら空文字を返す override fun serialize(encoder: Encoder, value: URL?) { if (value != null) { encoder.encodeString(value.toString()) } else { encoder.encodeString("") } } } object DateSerializer : KSerializer<Date> { private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.JAPANESE) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING) // デコード // Date型に変換し、失敗したらとりあえず今日の日付渡しておく override fun deserialize(decoder: Decoder): Date { try { return dateFormat.parse(decoder.decodeString())!! } catch (e: MalformedURLException) { return Date() } } // エンコード // 文字列に変換 override fun serialize(encoder: Encoder, value: Date) { encoder.encodeString(dateFormat.format(value)) } }
-
date/remote/model/Article.kt
でURLとDate型を使用しているところに@Serializable
アノテーションを付けます。package com.example.strongestnews.data.remote.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.net.URL import java.util.Date @Serializable data class Article( val id: Int, val title: String, val detail: String, val type: String, @SerialName("img_url") @Serializable(with = URLSerializer::class) val img: URL?, @SerialName("created_at") @Serializable(with = DateSerializer::class) val createdAt: Date, @SerialName("updated_at") @Serializable(with = DateSerializer::class) val updatedAt: Date )
-
同階層に
7.3.3 API定義
Retrofitで扱うためのAPIの定義を行います。
-
com.example.strongestnews.data.remote
パッケージ配下に NewsApi.kt ファイルを作成します。com.example.strongestnews └── data.remote ├── NewsApi.kt // 新規作成 ├── NewsDataSourceRemote.kt └── model └── Article.kt └── Serializers.kt
-
NewsApi.ktに記事一覧取得APIを定義します。
-
APIはインターフェースの形で定義します。
インターフェースなので、開発・テスト時には適当に作ったモックで差し替えることが可能です。
package com.example.strongestnews.data.remote interface NewsApi { }
-
インターフェースに、アノテーションでHTTPメソッドとパスを定義します。
引数としてパスを取り、中括弧によりパスパラメータを表現できます。
package com.example.strongestnews.data.remote import retrofit2.http.GET interface NewsApi { @GET("articles") // 追加 }
-
記事一覧取得リクエストを呼び出す関数を作成します。
返り値は
@Serializable
を指定したAPIからのレスポンスのデータ型(※remote/model/Article.kt
に定義したModel )です。データがない場合にはUnit
(返り値なし)になります。package com.example.strongestnews.data.remote import com.example.strongestnews.data.remote.model.Article import retrofit2.http.GET interface NewsApi { @GET("articles") suspend fun getArticles(): List<Article> }
パラメータを設定したい時以下のように引数でパラメータを受け取ることができます。
@GET("hoge/{id}") suspend fun getHoge( @Path("id") Id: Int ): List<Hoge> @POST("hoge") suspend fun createHoge( @Body hoge: PostHoge )
引数にアノテーションをつけることで、パラメータをどこに埋め込むかを定義します。
アノテーション 用途 @Path(”パス変数名”) パスに指定したパス変数に埋め込む @Body BodyにJSONとして埋め込む @Query(”クエリ変数名”) 指定した名前のクエリ変数として埋め込む @Field(”変数名”) JSONではなく、フォーム形式でBodyに埋め込む
別途、@FormUrlEncodedを関数につける必要がある
-
APIはインターフェースの形で定義します。
7.3.4 APIクライアントの取得
いよいよAPIからデータを取得して画面に流し込むようにしますが、一般的に、各層毎にテストができるように独立させ、依存関係を外部から渡すように実装します。
AndroidではHiltというライブラリを使って、簡単に依存関係の注入を行うことができます。
依存関係の注入を楽に行うためのライブラリです。
以下のようにアノテーションを使って注入していきます。
7.3.5 アプリをHiltに適合させる
まずは、アプリにHiltを導入していきます。
-
com.example.strongestnews
パッケージ配下にMainApplication.kt ファイルを新規作成します。com.example.strongestnews ├── MainActivity.kt ├── MainApplication.kt // 新規作成 ├── data/ ├── models/ └── ui/
-
MainApplication.kt
にアプリケーションクラスを作成しますアプリケーションクラスに
@HiltAndroidApp
アノテーションをつけることで、アプリケーションレベルのコンポーネントがHiltで使えるようになります。このアプリではHiltを使うよ、ということを宣言しています。
package com.example.strongestnews import android.app.Application import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class MainApplication: Application() { override fun onCreate() { super.onCreate() } }
-
AndroidManifest.xml
にandroid:name=".MainApplication"
を追加します<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="android.permission.INTERNET" /> <application android:name=".MainApplication" // 追加 android:networkSecurityConfig="@xml/network_security_config" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.StrongestNews" tools:targetApi="31"> <activity android:name=".MainActivity" android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.StrongestNews"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
7.3.6 APIから記事一覧取得ができるようにする
APIから記事一覧データを取得し、画面に表示できるようにします。末端から作成します。
-
Retrofitを使ってAPIクライアントを作成するモジュールを作成します。
-
com.example.strongestnews
配下にdiパッケージを作成し、ApiModule.ktファイルを作成します。com.example.strongestnews ├── MainActivity.kt ├── MainApplication.kt ├── data/ ├── di // 新規作成 │ └── ApiModule.kt // 新規作成 ├── models/ └── ui/
-
Module
とInstallIn()
アノテーションを付けてApiModuleクラスを定義します。package com.example.strongestnews.di import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) object ApiModule { }
-
@Singleton
と@Provides
アノテーションを付けてHTTPクライアントを生成するモジュールを作成します。package com.example.strongestnews.di import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ApiModule { // 以下を追加 @Singleton @Provides fun provideHttpClient(): OkHttpClient { return OkHttpClient.Builder().build() } }
-
同様に、
kotlinx.serialization
のJSONコンバーターを生成するモジュールを作成します。package com.example.strongestnews.di import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ApiModule { @Singleton @Provides fun provideHttpClient(): OkHttpClient { return OkHttpClient.Builder().build() } // 以下を追加 @Singleton @Provides fun provideJsonConverter(): Json { return Json { ignoreUnknownKeys = true } } }
-
HTTPクライアントとJSONコンバーターを引数に受け取ってAPIクライアントを生成するモジュールを作成します。
APIはインターフェースしか定義していませんでしたが、
Retrofit.create()
により自動的に実装が作成されます。baseUrl
に指定しているhttp://10.0.2.2:3032/
はhttp://localhost:3032
のことです。package com.example.strongestnews.di import com.example.strongestnews.data.remote.NewsApi import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import retrofit2.Retrofit import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ApiModule { @Singleton @Provides fun provideHttpClient(): OkHttpClient { return OkHttpClient.Builder().build() } @Singleton @Provides fun provideJsonConverter(): Json { return Json { ignoreUnknownKeys = true } } // 以下を追加 @Singleton @Provides fun provideNewsApi(client: OkHttpClient, converter: Json): NewsApi { val contentType = "application/json".toMediaType() return Retrofit.Builder() .baseUrl("http://10.0.2.2:3032/") .addConverterFactory(converter.asConverterFactory(contentType)) .client(client) .build() .create(NewsApi::class.java) } }
各アノテーションの意味-
@Module
- Hiltモジュールにする
-
InstallIn()
- ModuleをどのHiltコンポーネントにインストールするか指定
-
@Provides
- Moduleで実体を返す
-
@Singleton
- アプリ内で共通の1つだけ作成するようにする
他にもアノテーションの種類が多数あります。詳しく知りたい場合は、以下のチートシートを参考にしてください。
-
-
記事一覧のDataSourceの実体を作成します。
-
data/remote 配下にNewsDataSourceRemoteImpl.kt ファイルを作成します。
com.example.strongestnews └── data └── remote ├── NewsApi.kt ├── NewsDataSourceRemote.kt ├── NewsDataSourceRemoteImpl.kt // 新規作成 └── model/
-
@Inject constructor
をつけ、コンストラクタ引数にNewsApiを持ったNewsDataSourceRemoteImplクラスを作成します。これにより、NewsDataSourceRemoteImplが実行されるとき、HiltがNewsApi型を返すモジュールを自動で探してきて呼び出してくれるようになります。
また、インターフェースのNewsDataSourceRemoteで返るように指定しています。
package com.example.strongestnews.data.remote import javax.inject.Inject class NewsDataSourceRemoteImpl @Inject constructor( private val api: NewsApi ): NewsDataSourceRemote { }
-
🚫
Post not found
の実装を書いていきます。
APIからデータを取得し、取得したデータ(
data/remote/model/Article
の型)をアプリ内で使うモデル(models/Article
の型)に変換する処理を入れています。package com.example.strongestnews.data.remote import com.example.strongestnews.models.Article import javax.inject.Inject class NewsDataSourceRemoteImpl @Inject constructor( private val api: NewsApi ): NewsDataSourceRemote { override suspend fun getArticles(): List<Article> { return api.getArticles().map { articleEntity -> // APIから取得したデータをアプリ内で使うModelに変換 Article( id = articleEntity.id.toString(), title = articleEntity.title, detail = articleEntity.detail, imageURL = articleEntity.img!!, createdAt = articleEntity.createdAt ) } } }
-
data/remote 配下にNewsDataSourceRemoteImpl.kt ファイルを作成します。
-
記事一覧のDataSourceのモジュールを作成します。
-
di配下にDataSourceRemoteModule.kt ファイルを新規作成します
com/example/strongestnews/di ├── ApiModule.kt └── DataSourceRemoteModule.kt // 新規作成
-
DataSourceRemote用のモジュールをまとめるクラスを作成します
package com.example.strongestnews.di import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) abstract class DataSourceRemoteModule { }
-
実体とインターフェースを紐づけるモジュールを作成します
これにより、
@Inject
がついたクラスのコンストラクタ引数にNewsDataSourceRemote
型が返る値をセットされたとき、bindNewsDataSourceRemote
が呼び出され、NewsDataSourceRemoteImpl
が実行されます。package com.example.strongestnews.di import com.example.strongestnews.data.remote.NewsDataSourceRemote import com.example.strongestnews.data.remote.NewsDataSourceRemoteImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) abstract class DataSourceRemoteModule { // 以下を追加 @Binds abstract fun bindNewsDataSourceRemote(impl: NewsDataSourceRemoteImpl): NewsDataSourceRemote }
-
di配下にDataSourceRemoteModule.kt ファイルを新規作成します
ViewModelとDataSourceの間にRepositoryという層を設置しますが、現時点では非同期でDataSourceを呼び出す処理のみを行なっています。
この層はキャッシュを実装した際に真価を発揮します。
-
記事一覧のRepositoryの実体を作成します。
-
コルーチンスコープを使いたいので、コルーチンスコープを作成するモジュールを作成します。
-
diパッケージ配下にDispatchers.ktファイルを作成します
com/example/strongestnews/di ├── ApiModule.kt ├── DataSourceRemoteModule.kt └── Dispatchers.kt // 新規作成
-
Dispatchers.Default
用のモジュールと、CouroutineScope
用のモジュールの2種類を作成します。package com.example.strongestnews.di import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object Dispatchers { @Provides fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default @Singleton @Provides fun provideApplicationCoroutineScope( dispatcher: CoroutineDispatcher ): CoroutineScope { return CoroutineScope(SupervisorJob() + dispatcher) } }
処理のスレッドRetrofit自体は別スレッドを使用しない形でも使えますが、Androidはメインスレッドでの通信処理を禁止しています。メインスレッドで処理をしている間は画面更新処理が行えないため、通信中ずっと画面が固まってしまうことがその理由です。
さらに、Androidは メインスレッド外での画面更新処理を禁止 してもいます。
つまり、APIアクセスの結果から画面を更新しようとすると以下のフローが必要です。
Retrofit + Kotlinで実装する際は、Kotlinコルーチンを用いて切り替えを実現します。
-
diパッケージ配下にDispatchers.ktファイルを作成します
-
dataパッケージの配下にrepositoryパッケージを作成し、NewsRepository.ktファイルを作成します。
com/example/strongestnews/data ├── remote/ └── repository // 新規作成 └── NewsRepository.kt // 新規作成
-
Repositoryのインターフェースを作成します。DataSourceと定義内容は同じになります。
package com.example.strongestnews.data.repository import com.example.strongestnews.models.Article interface NewsRepository { suspend fun getArticles(): List<Article> }
-
同階層にNewsRepositoryImpl.ktファイルを作成します。
data ├── remote/ └── repository ├── NewsRepository.kt └── NewsRepositoryImpl.kt // 新規作成
-
@Inject constructor
をつけ、コンストラクタ引数にNewsDataSourceRemote
とCoroutineScope
を持つクラスを定義します。package com.example.strongestnews.data.repository import com.example.strongestnews.data.remote.NewsDataSourceRemote import kotlinx.coroutines.CoroutineScope import javax.inject.Inject class NewsRepositoryImpl @Inject constructor( private val newsDataSourceRemote: NewsDataSourceRemote, private val scope: CoroutineScope ): NewsRepository { }
-
インターフェースに沿って実装を記述します。
非同期でDataSourceRemoteの記事一覧取得関数を呼び出します。
package com.example.strongestnews.data.repository import com.example.strongestnews.data.remote.NewsDataSourceRemote import com.example.strongestnews.models.Article import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import javax.inject.Inject class NewsRepositoryImpl @Inject constructor( private val newsDataSourceRemote: NewsDataSourceRemote, private val scope: CoroutineScope ): NewsRepository { // 以下を追加 override suspend fun getArticles(): List<Article> { return scope.async { return@async newsDataSourceRemote.getArticles() }.await() } }
-
コルーチンスコープを使いたいので、コルーチンスコープを作成するモジュールを作成します。
-
記事一覧のRepositoryのモジュールを作成します。
-
diパッケージ配下にRepositoryModule.ktファイルを作成します
com/example/strongestnews/di ├── ApiModule.kt ├── DataSourceRemoteModule.kt ├── Dispatchers.kt └── RepositoryModule.kt // 新規作成
-
Repository用のモジュールを格納するクラスを作成します。
package com.example.strongestnews.di import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { }
-
Repositoryのインターフェースと実体をbindするモジュールを作成します。
package com.example.strongestnews.di import com.example.strongestnews.data.repository.NewsRepository import com.example.strongestnews.data.repository.NewsRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { // 以下を追加 @Singleton @Binds abstract fun bindNewsRepository(impl: NewsRepositoryImpl): NewsRepository }
-
diパッケージ配下にRepositoryModule.ktファイルを作成します
-
ViewModelをhiltViewModelに適合するように修正します。
-
記事一覧のViewModelを修正します。
HiltViewModel
アノテーションを付与し、@Inject constructor
でコンストラクタ引数に作成したrepositoryを指定します。そしてViewModel()
を返すようにします。また、APIからデータを取得するように変更するため、変数宣言時にダミーデータを渡しているところを空の変数に変更します。
package com.example.strongestnews.ui.screens.articles import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.example.strongestnews.data.repository.NewsRepository import com.example.strongestnews.models.Article import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class ArticlesViewModel @Inject constructor( private val repository: NewsRepository ): ViewModel() { var viewState by mutableStateOf<ArticlesViewState>( ArticlesViewState( articles = listOf<Article>() ) ) }
-
記事一覧を取得する関数を作成します。
取得したデータはviewStateに代入するようにします。
viewModelScope.launch
は何をしている?viewModelScopeが生きている間にしてほしい非同期処理を括弧内に記述します。
package com.example.strongestnews.ui.screens.articles import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.strongestnews.data.repository.NewsRepository import com.example.strongestnews.models.Article import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ArticlesViewModel @Inject constructor( private val repository: NewsRepository ): ViewModel() { var viewState by mutableStateOf<ArticlesViewState>( ArticlesViewState( articles = listOf<Article>() ) ) private set fun getArticles() { viewModelScope.launch { val newData = repository.getArticles() viewState = viewState.copy(articles = newData) } } }
-
記事一覧のViewModelを修正します。
-
ArticleScreen.ktで、viewStateを取得した後、非同期にAPI側からデータを読み込むように変更します。
package com.example.strongestnews.ui.screens.articles import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.strongestnews.R import com.example.strongestnews.models.Article import com.example.strongestnews.ui.screens.articles.components.ArticleCard import com.example.strongestnews.ui.theme.StrongestNewsTheme import java.net.URL import java.util.Date @Composable fun ArticlesScreen( viewModel: ArticlesViewModel, onNavigateToArticleDetail: (String) -> Unit, ){ val viewState = viewModel.viewState LaunchedEffect(Unit) { viewModel.getArticles() } ArticlesContainer( articles = viewState.articles, onClickSeeArticleDetail = onNavigateToArticleDetail ) } /* ... */
-
MainApp.ktで作成している記事一覧取得のViewModelをhiltViewModelを使うように修正します。
/* ... */ import androidx.hilt.navigation.compose.hiltViewModel @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = MainRoute.Articles.route ) { composable(MainRoute.Articles.route) { // val viewModel = ArticlesViewModel() val viewModel = hiltViewModel<ArticlesViewModel>() ArticlesScreen( viewModel = viewModel, onNavigateToArticleDetail = { articleID -> navController.navigate("${MainRoute.ArticleDetail.route}/$articleID") } ) } /* ... */
-
MainActivityに
@AndroidEntryPoint
アノテーションを付与します。package com.example.strongestnews import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import com.example.strongestnews.ui.MainApp import com.example.strongestnews.ui.theme.StrongestNewsTheme import dagger.hilt.android.AndroidEntryPoint import java.net.URL import java.util.Date /* ... */ @AndroidEntryPoint // 追加 class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { StrongestNewsTheme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { MainApp() } } } } }
- ビルドして実行すると、記事一覧を取得できるようになりました!
7.4 APIから記事を取得できるようにする
記事一覧取得と同様に、記事詳細取得を実装してみましょう。
-
DataSourceに記事詳細取得のinterfaceを定義する
実装例
package com.example.strongestnews.data.remote import com.example.strongestnews.models.Article interface NewsDataSourceRemote { suspend fun getArticle( id: String ): Article }
-
記事一覧取得のAPIを定義します。
実装例
package com.example.strongestnews.data.remote import com.example.strongestnews.data.remote.model.Article import retrofit2.http.GET import retrofit2.http.Path interface NewsApi { @GET("articles") suspend fun getArticles(): List<Article> // 以下を追加 @GET("articles/{id}") suspend fun getArticle( @Path("id") ArticleId: Int ): Article }
-
DataSourceの実装を書いていきます。
実装例
package com.example.strongestnews.data.remote import com.example.strongestnews.models.Article import javax.inject.Inject class NewsDataSourceRemoteImpl @Inject constructor( private val api: NewsApi ): NewsDataSourceRemote { override suspend fun getArticles(): List<Article> { return api.getArticles().map { articleEntity -> Article( id = articleEntity.id.toString(), title = articleEntity.title, detail = articleEntity.detail, imageURL = articleEntity?.img, createdAt = articleEntity.createdAt ) } } override suspend fun getArticle(id: String): Article { val article = api.getArticle(id.toInt()) return Article( id = article.id.toString(), title = article.title, detail = article.detail, imageURL = article?.img, createdAt = article.createdAt ) } }
-
Repositoryのインターフェースを作成します。
実装例
package com.example.strongestnews.data.repository import com.example.strongestnews.models.Article interface NewsRepository { suspend fun getArticles(): List<Article> suspend fun getArticle(id: String): Article // 追加 }
-
インターフェースに沿ってRepositoryに実装を記述します。
実装例
package com.example.strongestnews.data.repository import com.example.strongestnews.data.remote.NewsDataSourceRemote import com.example.strongestnews.models.Article import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import javax.inject.Inject class NewsRepositoryImpl @Inject constructor( private val newsDataSourceRemote: NewsDataSourceRemote, private val scope: CoroutineScope ): NewsRepository { override suspend fun getArticles(): List<Article> { return scope.async { return@async newsDataSourceRemote.getArticles() }.await() } // 以下を追加 override suspend fun getArticle(id: String): Article { return scope.async { return@async newsDataSourceRemote.getArticle(id) }.await() } }
-
ViewModelからダミーデータを抜くように修正し、APIからデータを取得してviewStateにセットする関数を作成します。
実装例
package com.example.strongestnews.ui.screens.articleDetail import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.strongestnews.data.repository.NewsRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ArticleDetailViewModel @Inject constructor( private val repository: NewsRepository ) : ViewModel() { var viewState by mutableStateOf<ArticleDetailViewState>( ArticleDetailViewState( article = null ) ) private set fun getArticle(articleID: String) { viewModelScope.launch { val newData = repository.getArticle(id = articleID) viewState = viewState.copy(article = newData) } } }
-
ArticleDetailScreen.ktで、開いた時に記事一覧を取得してviewStateにセットする関数を呼び出すようにします。
実装例
@Composable fun ArticleDetailScreen( viewModel: ArticleDetailViewModel, onNavigateToComments: () -> Unit, onNavigateBack: () -> Unit, articleID: String ) { val viewState = viewModel.viewState // 以下を追加 LaunchedEffect(Unit) { viewModel.getArticle(articleID) } ArticleDetail( article = viewState.article, onNavigateToComments = onNavigateToComments, onNavigateBack = onNavigateBack ) } /* ... */
-
MainApp.ktで作成しているViewModelをhiltViewModelを使うように修正します。
実装例
package com.example.strongestnews.ui /* ... */ @Composable fun MainApp() { val navController = rememberNavController() NavHost(/* ... */) { composable(MainRoute.Articles.route) { val viewModel = hiltViewModel<ArticlesViewModel>() ArticlesScreen( viewModel = viewModel, onNavigateToArticleDetail = { articleID -> navController.navigate("${MainRoute.ArticleDetail.route}/$articleID") } ) } composable( "${MainRoute.ArticleDetail.route}/{articleID}", arguments = listOf( navArgument("articleID") { type = NavType.StringType } ) ) { backStackEntry -> val receivedArticleID = backStackEntry.arguments?.getString("articleID")!! val viewModel = hiltViewModel<ArticleDetailViewModel>() ArticleDetailScreen( articleID = receivedArticleID, viewModel = viewModel, onNavigateToBack = { navController.popBackStack() }, onNavigateToComment = { articleID -> navController.navigate("${MainRoute.Comments.route}/$articleID") } ) } /* ... */ } }
おめでとうございます!ニュースアプリができました!
コメント画面に関しては本資料では実装しませんが、興味のある方はチャレンジしてみてください。
7.5 キャッシュ
7.5.1 インメモリ
このままだとデータ取得の際に毎回HTTP通信が発生します。画面が変わったりしても同じデータを読み込むことは多いので、極力データは使いまわしたいところです。
そこで新たにキャッシュ層を導入します。
本資料では実装しませんが、一般的に多くのアプリではキャッシュが導入されています。
DataSourceの前に新たにRepositoryというものができました。
ここにデータをキャッシュし、存在する場合はHTTPアクセスをしないようにします。Repositoryの処理としては以下のような流れになります。
GETの時
- キャッシュ(変数)を参照し、あればその値を返す
- なければDataSourceから値を取得し、キャッシュに保存した後に返す
private var articles: MutableMap<String, Article> = mutableMapOf()
fun getArticle(id: Int) : Article {
if (id in articles) {
return articles[id]
}
else {
val remoteArticle = dataSource.getArticle(id)
users[id] = remoteArticle
return remoteAritcle
}
}
SETの時
- DataSourceでSET処理を行う
- 同じ処理をキャッシュに対しても行う
7.5.2 永続化
キャッシュを導入したものの、メモリ上にあるため、アプリが終了したりプロセスがKillされたりするとキャッシュは消滅します。多くのアプリは通信を最小限に抑えるため、アプリが終了してもデータを復元するようにしています。
これを実現するためには、ローカルで動作するDB(SQLite)を永続化キャッシュとして使用します。
通信用とDB用にDataSourceを2つ用意します。処理の流れとしては以下のようになります。
GETの時
- キャッシュ(変数)を参照し、あればその値を返す
- なければLocalDataSourceを参照し、あればキャッシュに保存し、値を返す
- そこにもなければRemoteDataSourceから取得し、キャッシュとLocalDataSourceへ保存し、値を返す
例えば下記のような実装が考えられます。
private var articles: MutableMap<String, Article> = mutableMapOf()
fun getArticle(id: Int) : Article {
if (id in articles) {
// キャッシュがあり、その値を返す
return aritcles[id]
}
else {
// キャッシュがない
val localArticle = localDataSource.getArticle(id)
if (localArticle != null) {
// LocalDataSourceを参照し、キャッシュに保存して値を返す
articles[id] = localArticle
return localArticle
}
else {
// RemoteDataSourceから取得し、キャッシュとLocalDataSourceに保存して値を返す
val remoteArticles = dataSource.getArticle(id)
localDataSource.setArticle(id, remoteArticle)
articles[id] = remoteArticle
return remoteArticle
}
}
}
SETの時
- RemoteDataSourceでSET処理を行う
- LocalDataSourceへもSET処理を行う
- キャッシュにも保存する
以上のように設計していくことで、上位層に意識させることなくキャッシュを実装していくことができます。このようなパターンを Repositoryパターン と言います。
第8章 おわりに
Androidの動く仕組みからはじまり、Jetpack Composeを使ったUI作成、MVVM構成のアプリ実装、Hilt・Retrofit・Coroutine などAndroid特有のパッケージを使った開発を学んできました。
Android開発についてもっと知りたいときは、以下の公式チュートリアルがおすすめです。
また、iOS開発をしてみたい方はAppleが提供している公式チュートリアルがおすすめです。
今回学んだことを活かしてアプリ開発を楽しんでください!
Have a happy mobile app develop!