🚀 ニフティ’s Notion

Androidアプリ開発入門#7

第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通信となるため、アプリ側で許可するようにします。

  1. 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>
  2. 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からニュース一覧を取得するようにしたいです。

この場合のデータ層を考えてみましょう。

image block

DataSourceオブジェクトは特定のデータへの入出力を行うオブジェクトです。

例えば、ニュース記事一覧を取得するのであれば以下のようなインターフェースで表されます。

interface NewsDataSourceRemote {
	fun getArticles(): List<Article>
}

SET系操作(UPDATE/INSERT)やDELETEが必要であれば足します。

DataSourceを使う側は裏側がHTTPかどうかを気にせず、単にデータ操作用のメソッドを呼べば良いことになります。実際の処理はHTTPクライアントを利用して実装されます。

実際に記事一覧を取得するインターフェースを定義してみましょう。

  1. com.example.strongetnews.data.remote パッケージを作成し、その配下に NewsDataSourceRemote.kt を作成します
    image block
    app/src/main/java/com/example/strongestnews
    └── data // 新規作成
    		└── remote // 新規作成
    		    └── NewsDataSourceRemote.kt // 新規作成
  2. DataSource内にinterfaceを定義します
    • 記事一覧を取得
    • アプリ内で使うArticleModelがListでデータが返ってきて欲しい
    package com.example.strongestnews.data.remote
    
    import com.example.strongestnews.models.Article
    
    interface NewsDataSourceRemote {
        fun getArticles(): List<Article>
    }
    NewsDataSourceRemote.kt
7.2 非同期にデータ通信する

上は単純な例ですが、実際には通信は非同期で行うことになります。

引数を直接返り値として受け取ることができないため、コールバック経由で呼び出すことになります。やや複雑になりましたが、やりたいことは先ほどの例と変わっていません。

interface NewsDataSourceRemote {

    fun interface GetArticleCallback {
        fun onFinished(result: Article)
    }

    fun getArticles(callback: GetArticleCallback)
}

7.2.1 Kotlin Coroutine

コルーチンとは、中断可能な関数のことです。

image block

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>
    }
    NewsDataSourceRemote.kt

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種類のライブラリの力を借りて動作します。

  • HTTPクライアントライブラリ: Okhttp
    • 通信用
  • JSONパーサライブラリ: (もしくはMoshi等)
    • 文字列をJSONとして解釈したり、クラスへのマッピングを行ったりする

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"
    }
    
    /* ... */
    app/build.gradle.kts
  • 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")
    app/build.gradle

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")
    app/build.gradle

Sync Now します

retrofit2-kotlinx-serialization-converter

  • app/build.gradleのdependenciesに以下を追記
    // Retrofit
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    app/build.gradle

Sync Now します

Hilt

Hiltの導入にはDaggerとKaptが必要なため、一緒に入れます。

KaptはKSPへの移行が推奨されていますが、Hilt内部でKapt脱却が未完了なため、苦しみに耐えてKaptを使います。

  1. ルートの /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
    }
    /build.gradle.kts
  2. Sync Now します
  3. Hiltはkaptを使用するので、kaptを先に入れます
    1. app/build.gradle.kts のpluginに以下を追加
      plugins {
          id("com.android.application")
          id("org.jetbrains.kotlin.android")
      
          // serialization
          kotlin("plugin.serialization") version embeddedKotlinVersion
      
          // hilt
          kotlin("kapt")
      }
      /* ... */
      app/build.gradle.kts
    2. Sync Now します
  4. 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
    }
    app/build.gradle.kts
  5. Javaのバージョンが17になっていることを確認します
    /* ... */
    
    android {
    
        /* ... */
    
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
        }
    
    		kotlinOptions {
            jvmTarget = "17"
        }
    
    /* ... */
    app/build.gradle.kts
  6. Sync Now します

7.3.2 データ定義

まずはAPIで操作する対象のデータを定義します。

  1. 以下の様に、 com.example.strongestnews.data.remote 配下に model パッケージを作成し 、 Article.kt という名前でファイルを作成します。
    com.example.strongestnews
    └── data
    		└── remote
    		    ├── NewsDataSourceRemote.kt
    		    └── model // 新規作成
    		        └── Article.kt // 新規作成
  2. 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
    )
    data/remote/model/Article.kt
    シリアライザー

    @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と相互変換するためのクラスが自動生成されるようになります。

    自分でカスタムしたシリアライザーを作成することもできます。

  3. このままではURLとDateがパースできないため、カスタムシリアライザーを作成します。
    1. 同階層に Serializers.kt というファイルを作成します。
      com.example.strongestnews
      └── data
      		└── remote
      		    ├── NewsDataSourceRemote.kt
      		    └── model
      		        └── Article.kt
      						└── Serializers.kt // 新規作成
    2. 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))
          }
      }
      data/remote/model/Serializers.kt
    3. 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の定義を行います。

  1. com.example.strongestnews.data.remote パッケージ配下に NewsApi.kt ファイルを作成します。
    com.example.strongestnews
    └── data.remote
        ├── NewsApi.kt // 新規作成
        ├── NewsDataSourceRemote.kt
        └── model
            └── Article.kt
            └── Serializers.kt
  2. NewsApi.ktに記事一覧取得APIを定義します。
    1. APIはインターフェースの形で定義します。
      インターフェースなので、開発・テスト時には適当に作ったモックで差し替えることが可能です。
      package com.example.strongestnews.data.remote
      
      interface NewsApi {
      }
      data/remote/NewsApi.kt
    2. インターフェースに、アノテーションでHTTPメソッドとパスを定義します。

      引数としてパスを取り、中括弧によりパスパラメータを表現できます。

      package com.example.strongestnews.data.remote
      
      import retrofit2.http.GET
      
      interface NewsApi {
          @GET("articles") // 追加
      }
      data/remote/NewsApi.kt
    3. 記事一覧取得リクエストを呼び出す関数を作成します。

      返り値は @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>
      }
      data/remote/NewsApi.kt
      パラメータを設定したい時

      以下のように引数でパラメータを受け取ることができます。

      @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を関数につける必要がある

7.3.4 APIクライアントの取得

いよいよAPIからデータを取得して画面に流し込むようにしますが、一般的に、各層毎にテストができるように独立させ、依存関係を外部から渡すように実装します。

AndroidではHiltというライブラリを使って、簡単に依存関係の注入を行うことができます。

7.3.5 アプリをHiltに適合させる

まずは、アプリにHiltを導入していきます。

  1. com.example.strongestnews パッケージ配下にMainApplication.kt ファイルを新規作成します。
    com.example.strongestnews
    ├── MainActivity.kt
    ├── MainApplication.kt // 新規作成
    ├── data/
    ├── models/
    └── ui/
  2. 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()
        }
    }
    MainApplication.kt
  3. 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>
    AndroidManifest.xml

7.3.6 APIから記事一覧取得ができるようにする

APIから記事一覧データを取得し、画面に表示できるようにします。末端から作成します。

  1. Retrofitを使ってAPIクライアントを作成するモジュールを作成します。
    1. com.example.strongestnews 配下にdiパッケージを作成し、ApiModule.ktファイルを作成します。
      com.example.strongestnews
      ├── MainActivity.kt
      ├── MainApplication.kt
      ├── data/
      ├── di // 新規作成
      │   └── ApiModule.kt // 新規作成
      ├── models/
      └── ui/
      image block
    2. 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 {
      }
      ApiModule.kt
    3. @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()
          }
      }
      ApiModule.kt
    4. 同様に、 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 }
          }
      }
    5. HTTPクライアントとJSONコンバーターを引数に受け取ってAPIクライアントを生成するモジュールを作成します。

      APIはインターフェースしか定義していませんでしたが、 Retrofit.create() により自動的に実装が作成されます。

      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つだけ作成するようにする

    他にもアノテーションの種類が多数あります。詳しく知りたい場合は、以下のチートシートを参考にしてください。

  2. 記事一覧のDataSourceの実体を作成します。
    1. data/remote 配下にNewsDataSourceRemoteImpl.kt ファイルを作成します。
      com.example.strongestnews
      └── data
      		└── remote
      		    ├── NewsApi.kt
      		    ├── NewsDataSourceRemote.kt
      		    ├── NewsDataSourceRemoteImpl.kt // 新規作成
      		    └── model/
    2. @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 {
      }
    3. 🚫 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
                  )
              }
          }
      }
  3. 記事一覧のDataSourceのモジュールを作成します。
    1. di配下にDataSourceRemoteModule.kt ファイルを新規作成します
      com/example/strongestnews/di
      ├── ApiModule.kt
      └── DataSourceRemoteModule.kt // 新規作成
    2. 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 {
          
      }
    3. 実体とインターフェースを紐づけるモジュールを作成します

      これにより、 @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
      }
Repositoryパターン

ViewModelとDataSourceの間にRepositoryという層を設置しますが、現時点では非同期でDataSourceを呼び出す処理のみを行なっています。

この層はキャッシュを実装した際に真価を発揮します。

  1. 記事一覧のRepositoryの実体を作成します。
    1. コルーチンスコープを使いたいので、コルーチンスコープを作成するモジュールを作成します。
      1. diパッケージ配下にDispatchers.ktファイルを作成します
        com/example/strongestnews/di
        ├── ApiModule.kt
        ├── DataSourceRemoteModule.kt
        └── Dispatchers.kt // 新規作成
      2. 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アクセスの結果から画面を更新しようとすると以下のフローが必要です。

      image block

      Retrofit + Kotlinで実装する際は、Kotlinコルーチンを用いて切り替えを実現します。

    2. dataパッケージの配下にrepositoryパッケージを作成し、NewsRepository.ktファイルを作成します。
      com/example/strongestnews/data
      ├── remote/
      └── repository // 新規作成
          └── NewsRepository.kt // 新規作成
    3. Repositoryのインターフェースを作成します。DataSourceと定義内容は同じになります。
      package com.example.strongestnews.data.repository
      
      import com.example.strongestnews.models.Article
      
      interface NewsRepository {
          suspend fun getArticles(): List<Article>
      }
      NewsRepository.kt
    4. 同階層にNewsRepositoryImpl.ktファイルを作成します。
      data
      ├── remote/
      └── repository
          ├── NewsRepository.kt
          └── NewsRepositoryImpl.kt // 新規作成
    5. @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 {
      }
    6. インターフェースに沿って実装を記述します。

      非同期で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()
          }
      }
  2. 記事一覧のRepositoryのモジュールを作成します。
    1. diパッケージ配下にRepositoryModule.ktファイルを作成します
      com/example/strongestnews/di
      ├── ApiModule.kt
      ├── DataSourceRemoteModule.kt
      ├── Dispatchers.kt
      └── RepositoryModule.kt // 新規作成
    2. 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 {
      }
    3. 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
      }
  3. ViewModelをhiltViewModelに適合するように修正します。
    1. 記事一覧の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>()
              )
          )
      }
    2. 記事一覧を取得する関数を作成します。

      取得したデータは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)
              }
          }
      }
  4. 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
        )
    }
    
    /* ... */
  5. 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")
                    }
                )
            }
    
    /* ... */
  6. 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. ビルドして実行すると、記事一覧を取得できるようになりました!

7.4 APIから記事を取得できるようにする

記事一覧取得と同様に、記事詳細取得を実装してみましょう。

  1. DataSourceに記事詳細取得のinterfaceを定義する
    実装例
    package com.example.strongestnews.data.remote
    
    import com.example.strongestnews.models.Article
    
    interface NewsDataSourceRemote {
        suspend fun getArticle(
            id: String
        ): Article
    }
    data/remote/NewsDataSourceRemote.kt
  2. 記事一覧取得の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
    }
    data/remote/NewsApi.kt
  3. 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
            )
        }
    }
    data/remote/NewsDataSourceRemoteImpl.kt
  4. 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 // 追加
    }
    data/repository/NewsRepository.kt
  5. インターフェースに沿って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()
        }
    }
    data/repository/NewsRepositoryImpl.kt
  6. 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)
            }
        }
    }
    ui/screens/articleDetail/ArticleDetailViewModel.kt
  7. 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
        )
    }
    
    /* ... */
  8. 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")
                    }
                )
            }
    
    				/* ... */
        }
    
    }
    ui/MainApp.kt

おめでとうございます!ニュースアプリができました!

コメント画面に関しては本資料では実装しませんが、興味のある方はチャレンジしてみてください。

7.5 キャッシュ
7.5.1 インメモリ

このままだとデータ取得の際に毎回HTTP通信が発生します。画面が変わったりしても同じデータを読み込むことは多いので、極力データは使いまわしたいところです。

そこで新たにキャッシュ層を導入します。

本資料では実装しませんが、一般的に多くのアプリではキャッシュが導入されています。

image block

DataSourceの前に新たにRepositoryというものができました。

ここにデータをキャッシュし、存在する場合はHTTPアクセスをしないようにします。Repositoryの処理としては以下のような流れになります。

GETの時

  1. キャッシュ(変数)を参照し、あればその値を返す
  2. なければ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の時

  1. DataSourceでSET処理を行う
  2. 同じ処理をキャッシュに対しても行う

7.5.2 永続化

キャッシュを導入したものの、メモリ上にあるため、アプリが終了したりプロセスがKillされたりするとキャッシュは消滅します。多くのアプリは通信を最小限に抑えるため、アプリが終了してもデータを復元するようにしています。

これを実現するためには、ローカルで動作するDB(SQLite)を永続化キャッシュとして使用します。

image block

通信用とDB用にDataSourceを2つ用意します。処理の流れとしては以下のようになります。

GETの時

  1. キャッシュ(変数)を参照し、あればその値を返す
  2. なければLocalDataSourceを参照し、あればキャッシュに保存し、値を返す
  3. そこにもなければ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の時

  1. RemoteDataSourceでSET処理を行う
  2. LocalDataSourceへもSET処理を行う
  3. キャッシュにも保存する

以上のように設計していくことで、上位層に意識させることなくキャッシュを実装していくことができます。このようなパターンを Repositoryパターン と言います。

第8章 おわりに

Androidの動く仕組みからはじまり、Jetpack Composeを使ったUI作成、MVVM構成のアプリ実装、Hilt・Retrofit・Coroutine などAndroid特有のパッケージを使った開発を学んできました。

Android開発についてもっと知りたいときは、以下の公式チュートリアルがおすすめです。

また、iOS開発をしてみたい方はAppleが提供している公式チュートリアルがおすすめです。

今回学んだことを活かしてアプリ開発を楽しんでください!

Have a happy mobile app develop!

image block