📄

【Jetpack Compose】記事取得アプリを作ってみる

2024/12/24に公開

初めに

今回は Jetpack Compose を用いて記事の一覧を取得するためのアプリケーションを作成してみたいと思います。記事の取得には Retrofit を用いるので、その使い方を中心に見ていきたいと思います。

Jetpack Compose のドキュメントでインターネットからデータを取得するの章で Retrofit を用いてデータの取得を行う実装があったので、それを別の形で使ってみたいと思います。

記事の対象者

  • Jetpack Compose 初学者
  • Retrofit に触れてみたい方

目的

今回の目的は、先述の通り Jetpack Compose で記事取得アプリを作ることです。
最終的には以下の動画のように Zenn の記事をトピックごとやユーザーごとに取得できるようなアプリを作っていきます。

https://youtube.com/shorts/uCLIlUBlFc0

また、今回実装するコートは以下で公開しているので、よろしければご参照ください。

https://github.com/Koichi5/jetpack-compose-zenn-app

実装

実装は以下の手順で進めていきたいと思います。

  1. Model の実装
  2. Repository の実装
  3. Network の実装
  4. View, ViewModel の実装
  5. Application, MainActivity の実装

1. Model の実装

まずは Model の実装を進めていきます。
コードは以下の通りです。

Article

com/example/zennapp/model/Article.kt
package com.example.zennapp.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class Article(
    @SerialName(value = "article_type")
    val articleType: String,
    @SerialName(value = "body_letters_count")
    val bodyLettersCount: Int,
    @SerialName(value = "body_updated_at")
    val bodyUpdatedAt: String,
    @SerialName(value = "comments_count")
    val commentsCount: Int,
    val emoji: String,
    val id: Int,
    @SerialName(value = "is_suspending_private")
    val isSuspendingPrivate: Boolean,
    @SerialName(value = "liked_count")
    val likedCount: Int,
    val path: String,
    val pinned: Boolean,
    @SerialName(value = "post_type")
    val postType: String,
    val publication: Publication?,
    @SerialName(value = "published_at")
    val publishedAt: String,
    val slug: String,
    @SerialName(value = "source_repo_updated_at")
    val sourceRepoUpdatedAt: String? = null,
    val title: String,
    val user: User
)

Publication

com/example/zennapp/model/Publication.kt
package com.example.zennapp.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class Publication(
    val id: Int,
    val name: String,
    @SerialName("display_name")
    val displayName: String,
    @SerialName("avatar_small_url")
    val avatarSmallUrl: String,
    val pro: Boolean,
    @SerialName("avatar_registered")
    val avatarRegistered: Boolean
)

User

com/example/zennapp/model/User.kt
package com.example.zennapp.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Suppress("PLUGIN_IS_NOT_ENABLED")
@Serializable
data class User(
    @SerialName(value = "avatar_small_url")
    val avatarSmallUrl: String,
    val id: Int,
    val name: String,
    val username: String
)

ZennArticle

com/example/zennapp/model/ZennArticle.kt
package com.example.zennapp.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ZennArticle(
    val articles: List<Article>,
    @SerialName(value = "next_page")
    val nextPage: Int?
)

これらのコードは以下のような Zenn のURLのレスポンスをまとめることで実装できます。

https://zenn.dev/api/articles

quicktype などのツールを使って JSON のデータから Kotlin のデータを生成できます。今回は kotlinx を使うので、以下のように言語を「Kotlin」に設定し、Serialization framework を「Kotlinx」に設定して生成してみます。

これで Model の実装は完了です。

2. Repository の実装

次に Repository の実装を行います。
まずは記事の取得を行う Repository の実装を行います。
コードは以下の通りです。

com/example/zennapp/data/ZennArticlesRepository.kt
package com.example.zennapp.data

import com.example.zennapp.model.ZennArticle
import com.example.zennapp.network.ZennApiService

interface ZennArticlesRepository {
    suspend fun getArticles(): ZennArticle
    suspend fun getLatestArticles(): ZennArticle
    suspend fun getMyArticles(): ZennArticle
    suspend fun getMyLatestArticles(): ZennArticle
    suspend fun getTopicsArticle(topicName: String): ZennArticle
    suspend fun getTopicsLatestArticle(topicName: String): ZennArticle
}

class NetworkZennArticlesRepository(
    private val zennApiService: ZennApiService,
    private val username: String
) : ZennArticlesRepository {
    override suspend fun getArticles(): ZennArticle = zennApiService.getArticles()
    override suspend fun getLatestArticles(): ZennArticle =
        zennApiService.getLatestArticles()

    override suspend fun getMyArticles(): ZennArticle =
        zennApiService.getMyArticles(username)

    override suspend fun getMyLatestArticles(): ZennArticle =
        zennApiService.getMyLatestArticles(username)

    override suspend fun getTopicsArticle(topicName: String): ZennArticle =
        zennApiService.getTopicsArticles(topicName = topicName)

    override suspend fun getTopicsLatestArticle(topicName: String): ZennArticle =
        zennApiService.getTopicsLatestArticles(topicName = topicName)
}

それぞれ詳しくみていきます。

以下では interface として ZennArticlesRepository を定義しています。
それぞれのメソッドはコメントにある通りです。
今回は Zenn のAPIからのみデータを取得するため、 interface としての Repository は必要ないかもしれませんが、用意するべきメソッドがわかりやすいように設けておきます。

interface ZennArticlesRepository {
    suspend fun getArticles(): ZennArticle  // 通常の記事取得
    suspend fun getLatestArticles(): ZennArticle  // 最新の記事取得
    suspend fun getMyArticles(): ZennArticle  // 自分の記事の取得
    suspend fun getMyLatestArticles(): ZennArticle  // 自分の最新の記事取得
    suspend fun getTopicsArticle(topicName: String): ZennArticle  // トピックごとの記事取得
    suspend fun getTopicsLatestArticle(topicName: String): ZennArticle  // トピックごとの最新の記事取得
}

以下では NetworkZennArticlesRepository として、 ZennArticlesRepository を継承した Repository を作成しています。
引数としてAPIの処理を行う ZennApiService とユーザー名である username を受け取っています。
ZennArticlesRepository に用意されているメソッドを実装しており、基本的には ZennApiService にあるメソッドを実行しているだけになります。具体的な実装は ZennApiService に用意されています。

class NetworkZennArticlesRepository(
    private val zennApiService: ZennApiService,
    private val username: String
) : ZennArticlesRepository {
    override suspend fun getArticles(): ZennArticle = zennApiService.getArticles()
    override suspend fun getLatestArticles(): ZennArticle =
        zennApiService.getLatestArticles()

    override suspend fun getMyArticles(): ZennArticle =
        zennApiService.getMyArticles(username)

    override suspend fun getMyLatestArticles(): ZennArticle =
        zennApiService.getMyLatestArticles(username)

    override suspend fun getTopicsArticle(topicName: String): ZennArticle =
        zennApiService.getTopicsArticles(topicName = topicName)

    override suspend fun getTopicsLatestArticle(topicName: String): ZennArticle =
        zennApiService.getTopicsLatestArticles(topicName = topicName)
}

次に AppContainer の実装を行います。
コードは以下の通りです。

com/example/zennapp/data/AppContainer.kt
package com.example.zennapp.data

import com.example.zennapp.network.ZennApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit

interface AppContainer {
    val zennArticlesRepository: ZennArticlesRepository
}

class DefaultAppContainer : AppContainer {
    private val username = "koichi_51"
    private val baseUrl = "https://zenn.dev/api/"

    private val contentType = "application/json".toMediaType()
    private val json = Json {
        ignoreUnknownKeys = true
        coerceInputValues = true
    }

    private val retrofit = Retrofit.Builder()
        .baseUrl(baseUrl)
        .addConverterFactory(json.asConverterFactory(contentType))
        .build()

    private val retrofitService: ZennApiService by lazy {
        retrofit.create(ZennApiService::class.java)
    }

    override val zennArticlesRepository: ZennArticlesRepository by lazy {
        NetworkZennArticlesRepository(retrofitService, username)
    }
}

それぞれ詳しくみていきます。

以下では、 interface として、 AppContainer を定義しています。
AppContainer では ZennArticlesRepository を受け取るようにしています。

interface AppContainer {
    val zennArticlesRepository: ZennArticlesRepository
}

以下では AppContainer を継承した DefaultAppContainer を作成しています。
Zenn のユーザー名やベースのURL、JSONの形式などを指定しています。

class DefaultAppContainer : AppContainer {
    private val username = "koichi_51"
    private val baseUrl = "https://zenn.dev/api/"

    private val contentType = "application/json".toMediaType()
    private val json = Json {
        ignoreUnknownKeys = true
        coerceInputValues = true
    }

以下では、 Retrofit.Builder() で Zenn のAPIのURLを指定し、 addConverterFactory でJSONの形式を指定しています。

また、 retrofitService では ZennApiService を継承し、 retrofit.create メソッドでサービスに実装されたAPIのエンドポイントを定義しています。

DefaultAppContainerAppContainer を継承しており、 AppContainerZennArticlesRepository を受け取る必要があるため、 retrofitServiceusername を渡すようにしています。

private val retrofit = Retrofit.Builder()
    .baseUrl(baseUrl)
    .addConverterFactory(json.asConverterFactory(contentType))
    .build()

private val retrofitService: ZennApiService by lazy {
    retrofit.create(ZennApiService::class.java)
}

override val zennArticlesRepository: ZennArticlesRepository by lazy {
    NetworkZennArticlesRepository(retrofitService, username)
}

これで Repository と AppContainer の実装は完了です。

3. Network の実装

次に Network の実装に移ります。
コードは以下の通りです。

com/example/zennapp/network/ZennApiService.kt
package com.example.zennapp.network

import com.example.zennapp.model.ZennArticle
import retrofit2.http.GET
import retrofit2.http.Query

interface ZennApiService {
    @GET("articles")
    suspend fun getArticles(): ZennArticle

    @GET("articles")
    suspend fun getLatestArticles(
        @Query("order") order: String = "latest"
    ): ZennArticle

    @GET("articles")
    suspend fun getMyArticles(@Query("username") username: String): ZennArticle

    @GET("articles")
    suspend fun getMyLatestArticles(
        @Query("username") username: String, @Query("order") order: String = "latest"
    ): ZennArticle

    @GET("articles")
    suspend fun getTopicsArticles(@Query("topicname") topicName: String): ZennArticle

    @GET("articles")
    suspend fun getTopicsLatestArticles(
        @Query("topicname") topicName: String, @Query("order") order: String = "latest"
    ): ZennArticle
}

それぞれ詳しくみていきます。

以下では、ZennApiService として、 Zenn のAPIからデータを取得する実装を行なっています。
getArticles メソッドでは、 @GET メソッドで articles にアクセスしてデータを取得するようにしています。返り値としては ZennArticle を取るようにしています。

baseUrlhttps://zenn.dev/api/ であるため、 https://zenn.dev/api/articles に対して GET メソッドを実行していることになります。

interface ZennApiService {
    @GET("articles")
    suspend fun getArticles(): ZennArticle

以下では、getArticles メソッドと同様に articles に対して GET メソッドを実行しています。
@Query ではクエリパラメータを指定することができます。 order クエリに対して latest を指定することで、最新の記事を取得することができるようになります。

@GET("articles")
suspend fun getLatestArticles(
    @Query("order") order: String = "latest"
): ZennArticle

その他のメソッドでは usernametopicname などを指定して取得しています。

4. View, ViewModel の実装

次に、 View, ViewModel の実装を行います。
実装は以下の手順で進めていきたいと思います。

  1. NavHost の実装
  2. ArticlesScreen, ErrorScreen, LoadingScreen の実装
  3. HomeScreen の実装
  4. MyPageScreen の実装
  5. TopicsScreen の実装

1. NavHost の実装

まずはそれぞれのページをタブでまとめる NavHost の実装を進めます。
コードは以下の通りです。

com/example/zennapp/ui/theme/screens/NavHost.kt
package com.example.zennapp.ui.theme.screens

import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.zennapp.ui.theme.ZennTopAppBar
import com.example.zennapp.ui.theme.screens.home.HomeScreen
import com.example.zennapp.ui.theme.screens.home.ZennHomeScreenViewModel
import com.example.zennapp.ui.theme.screens.mypage.MyPageScreen
import com.example.zennapp.ui.theme.screens.mypage.ZennMyPageScreenViewModel
import com.example.zennapp.ui.theme.screens.topics.TopicsScreen
import com.example.zennapp.ui.theme.screens.topics.ZennTopicsScreenViewModel

sealed class Screen(val route: String, val label: String, val icon: @Composable () -> Unit) {
    data object Home :
        Screen("home", "Home", { Icon(Icons.Default.Home, contentDescription = null) })

    data object Topics :
        Screen("topics", "Topics", { Icon(Icons.Default.List, contentDescription = null) })

    data object MyPage :
        Screen("my_page", "My Page", { Icon(Icons.Default.Person, contentDescription = null) })
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZennAppScaffold() {
    val navController = rememberNavController()
    val items = listOf(Screen.Home, Screen.Topics, Screen.MyPage)
    val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()

    Scaffold(
        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = { ZennTopAppBar(scrollBehavior = scrollBehavior) },
        bottomBar = {
            NavigationBar {
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentDestination = navBackStackEntry?.destination
                items.forEach { screen ->
                    NavigationBarItem(
                        icon = { screen.icon() },
                        label = { Text(screen.label) },
                        selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
                        onClick = {
                            navController.navigate(screen.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { innerPadding ->
        MyAppNavHost(navController = navController, modifier = Modifier.padding(innerPadding))
    }
}

@Composable
fun MyAppNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier,
    startDestination: String = Screen.Home.route
) {
    val zennHomeScreenViewModel: ZennHomeScreenViewModel =
        viewModel(factory = ZennHomeScreenViewModel.Factory)
    val zennMyPageScreenViewModel: ZennMyPageScreenViewModel =
        viewModel(factory = ZennMyPageScreenViewModel.Factory)

    NavHost(
        navController = navController,
        startDestination = startDestination,
        modifier = modifier
    ) {
        composable(Screen.Home.route) {
            HomeScreen(
                zennUiState = zennHomeScreenViewModel.zennUiState,
                retryAction = zennHomeScreenViewModel::getArticles,
                getArticles = zennHomeScreenViewModel::getArticles,
                getLatestArticles = zennHomeScreenViewModel::getLatestArticles
            )
        }
        composable(Screen.MyPage.route) {
            MyPageScreen(
                zennUiState = zennMyPageScreenViewModel.zennUiState,
                retryAction = zennMyPageScreenViewModel::getMyArticles,
                getArticles = zennMyPageScreenViewModel::getMyArticles,
                getLatestArticles = zennMyPageScreenViewModel::getLatestArticles
            )
        }
        composable(Screen.Topics.route) {
            TopicsScreen(
                viewModel = viewModel(factory = ZennTopicsScreenViewModel.Factory)
            )
        }
    }
}

タブのナビゲーションは rememberNavController で管理します。
タブの実装は NavHost を用いて実装しています。

2. ArticlesScreen, ErrorScreen, LoadingScreen の実装

次に記事一覧画面、エラー時の画面、ローディング時の画面を実装していきます。

ArticlesScreen

com/example/zennapp/ui/theme/screens/ArticlesScreen.kt
package com.example.zennapp.ui.theme.screens

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.zennapp.model.ZennArticle
import com.example.zennapp.ui.theme.PaleLightBlue
import com.example.zennapp.ui.theme.components.ArticleCard

data class DropdownMenuItemData(
    val label: String,
    val onClick: () -> Unit
)

@Composable
fun ArticlesScreen(
    articles: ZennArticle,
    modifier: Modifier = Modifier,
    menuItems: List<DropdownMenuItemData>
) {
    var isMenuExpanded by remember { mutableStateOf(false) }
    Surface(
        color = PaleLightBlue,
        modifier = Modifier.fillMaxSize()
    ) {
        Column(modifier = Modifier.fillMaxSize()) {
            Row(
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    "Tech",
                    style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
                    modifier = Modifier.padding(16.dp)
                )
                Spacer(modifier = Modifier.weight(1f))
                Box {
                    IconButton(
                        onClick = {
                            isMenuExpanded = true
                        },
                        modifier = Modifier.padding(16.dp)
                    ) {
                        Icon(Icons.Default.Menu, contentDescription = "Menu")
                    }
                    DropdownMenu(
                        modifier = Modifier
                            .clip(RoundedCornerShape(16.dp)),
                        expanded = isMenuExpanded,
                        onDismissRequest = { isMenuExpanded = false }
                    ) {
                        menuItems.forEach { item ->
                            DropdownMenuItem(
                                text = { Text(item.label) },
                                onClick = {
                                    isMenuExpanded = false
                                    item.onClick()
                                }
                            )
                        }
                    }
                }
            }
            LazyColumn(
                modifier = Modifier.padding(horizontal = 16.dp)
            ) {
                items(items = articles.articles, key = { article -> article.id }) { article ->
                    ArticleCard(
                        article,
                        modifier = modifier
                            .padding(4.dp)
                            .fillMaxWidth()
                    )
                }
            }
        }
    }
}

ErrorScreen

com/example/zennapp/ui/theme/screens/ErrorScreen.kt
package com.example.zennapp.ui.theme.screens

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.example.zennapp.R

@Composable
fun ErrorScreen(
    retryAction: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_connection_error),
            contentDescription = "Error"
        )
        Text("Failed to load articles", modifier = Modifier.padding(16.dp))
        Button(onClick = retryAction) {
            Text("Retry")
        }
    }
}

LoadingScreen

com/example/zennapp/ui/theme/screens/LoadingScreen.kt
package com.example.zennapp.ui.theme.screens

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.example.zennapp.R

@Composable
fun LoadingScreen(
    modifier: Modifier = Modifier
) {
    Image(
        painter = painterResource(id = R.drawable.loading_img),
        contentDescription = "Loading",
        modifier = modifier.size(200.dp)
    )
}

3. HomeScreen の実装

次に HomeScreen の View と ViewModel を作成していきます。
コードは以下の通りです。

HomeScreen

com/example/zennapp/ui/theme/screens/home/HomeScreen.kt
package com.example.zennapp.ui.theme.screens.home

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.zennapp.ui.theme.screens.ArticlesScreen
import com.example.zennapp.ui.theme.screens.DropdownMenuItemData
import com.example.zennapp.ui.theme.screens.ErrorScreen
import com.example.zennapp.ui.theme.screens.LoadingScreen

@Composable
fun HomeScreen(
    zennUiState: ZennUiState,
    retryAction: () -> Unit,
    getArticles: () -> Unit,
    getLatestArticles: () -> Unit,
    modifier: Modifier = Modifier
) {
    when (zennUiState) {
        is ZennUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
        is ZennUiState.Success -> ArticlesScreen(
            zennUiState.articles,
            modifier = modifier.fillMaxWidth(),
            menuItems = listOf(
                DropdownMenuItemData(
                    label = "指定なし",
                    onClick = getArticles
                ),
                DropdownMenuItemData(
                    label = "新しい投稿順",
                    onClick = getLatestArticles
                ),
            )
        )

        is ZennUiState.Error -> ErrorScreen(retryAction, modifier = modifier.fillMaxSize())
    }
}

HomeScreen では ZennUiState に応じて表示させるUIを切り替えています。
データの取得に成功した場合は ArticlesScreen、ローディング中の場合は LoadingScreen、エラーが発生した場合は ErrorScreen を表示させています。

ZennHomeScreenViewModel

com/example/zennapp/ui/theme/screens/home/ZennHomeScreenViewModel.kt
package com.example.zennapp.ui.theme.screens.home

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil.network.HttpException
import com.example.zennapp.ZennArticlesApplication
import com.example.zennapp.data.ZennArticlesRepository
import com.example.zennapp.model.ZennArticle
import kotlinx.coroutines.launch
import java.io.IOException

sealed interface ZennUiState {
    data class Success(val articles: ZennArticle) : ZennUiState
    data object Error: ZennUiState
    data object Loading: ZennUiState
}

class ZennHomeScreenViewModel(private val zennArticlesRepository: ZennArticlesRepository): ViewModel() {
    var zennUiState: ZennUiState by mutableStateOf(ZennUiState.Loading)
        private set

    init {
        getLatestArticles()
    }

    fun getArticles() {
        viewModelScope.launch {
            zennUiState = ZennUiState.Loading
            zennUiState = try {
                ZennUiState.Success(zennArticlesRepository.getArticles())
            } catch (e: IOException) {
                ZennUiState.Error
            } catch (e: HttpException) {
                ZennUiState.Error
            }
        }
    }

    fun getLatestArticles() {
        viewModelScope.launch {
            zennUiState = ZennUiState.Loading
            zennUiState = try {
                ZennUiState.Success(zennArticlesRepository.getLatestArticles())
            } catch (e: IOException) {
                ZennUiState.Error
            } catch (e: HttpException) {
                ZennUiState.Error
            }
        }
    }

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = (this[APPLICATION_KEY] as ZennArticlesApplication)
                val zennArticlesRepository = application.container.zennArticlesRepository
                ZennHomeScreenViewModel(zennArticlesRepository = zennArticlesRepository)
            }
        }
    }
}

ZennHomeScreenViewModel について詳しくみていきます。

ZennHomeScreenViewModel では、 ZennArticlesRepository を受け取っています。
zennUiState は State として定義して、初期値は ZennUiState.Loading としています。
また、 init では getLatestArticles を実行することで、初期化の段階で最新の記事を取得するようにしています。

class ZennHomeScreenViewModel(private val zennArticlesRepository: ZennArticlesRepository): ViewModel() {
    var zennUiState: ZennUiState by mutableStateOf(ZennUiState.Loading)
        private set

    init {
        getLatestArticles()
    }

以下では、記事の一覧を取得するメソッドを実装しています。
zennArticlesRepository.getArticles メソッドで記事の取得を行い、成功した場合は ZennUiState.Success に渡すようにしています。

fun getArticles() {
    viewModelScope.launch {
        zennUiState = ZennUiState.Loading
        zennUiState = try {
            ZennUiState.Success(zennArticlesRepository.getArticles())
        } catch (e: IOException) {
            ZennUiState.Error
        } catch (e: HttpException) {
            ZennUiState.Error
        }
    }
}

4. MyPageScreen の実装

次に MyPageScreen の実装を行います。
なお、基本的には HomeScreen と同様の実装であるため、コードの詳細は割愛したいと思います。

MyPageScreen の実装
com/example/zennapp/ui/theme/screens/mypage/MyPageScreen.kt
package com.example.zennapp.ui.theme.screens.mypage

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.zennapp.ui.theme.screens.ArticlesScreen
import com.example.zennapp.ui.theme.screens.DropdownMenuItemData
import com.example.zennapp.ui.theme.screens.ErrorScreen
import com.example.zennapp.ui.theme.screens.LoadingScreen
import com.example.zennapp.ui.theme.screens.home.ZennUiState

@Composable
fun MyPageScreen(
    zennUiState: ZennUiState,
    retryAction: () -> Unit,
    getArticles: () -> Unit,
    getLatestArticles: () -> Unit,
    modifier: Modifier = Modifier
) {
    when (zennUiState) {
        is ZennUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
        is ZennUiState.Success -> ArticlesScreen(
            zennUiState.articles,
            modifier = modifier.fillMaxWidth(),
            menuItems = listOf(
                DropdownMenuItemData(
                    label = "指定なし",
                    onClick = getArticles
                ),
                DropdownMenuItemData(
                    label = "新しい投稿順",
                    onClick = getLatestArticles
                )
            ),
        )

        is ZennUiState.Error -> ErrorScreen(retryAction, modifier = modifier.fillMaxSize())
    }
}
ZennMyPageScreenViewModel の実装
com/example/zennapp/ui/theme/screens/mypage/ZennMyPageScreenViewModel.kt
package com.example.zennapp.ui.theme.screens.mypage

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil.network.HttpException
import com.example.zennapp.ZennArticlesApplication
import com.example.zennapp.data.ZennArticlesRepository
import com.example.zennapp.ui.theme.screens.home.ZennUiState
import kotlinx.coroutines.launch
import java.io.IOException

class ZennMyPageScreenViewModel(private val zennArticlesRepository: ZennArticlesRepository): ViewModel() {
    var zennUiState: ZennUiState by mutableStateOf(ZennUiState.Loading)

    init {
        getMyArticles()
    }

    fun getMyArticles() {
        viewModelScope.launch {
            zennUiState = ZennUiState.Loading
            zennUiState = try {
                ZennUiState.Success(zennArticlesRepository.getMyArticles())
            } catch (e: IOException) {
                ZennUiState.Error
            } catch (e: HttpException) {
                ZennUiState.Error
            }
        }
    }

    fun getLatestArticles() {
        viewModelScope.launch {
            zennUiState = ZennUiState.Loading
            zennUiState = try {
                ZennUiState.Success(zennArticlesRepository.getMyLatestArticles())
            } catch (e: IOException) {
                ZennUiState.Error
            } catch (e: HttpException) {
                ZennUiState.Error
            }
        }
    }

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = (this[APPLICATION_KEY] as ZennArticlesApplication)
                val zennArticlesRepository = application.container.zennArticlesRepository
                ZennMyPageScreenViewModel(zennArticlesRepository = zennArticlesRepository)
            }
        }
    }
}

5. TopicsScreen の実装

次に TopicsScreen の実装を行います。
こちらも基本的には HomeScreen と同様の実装であるため、コードの詳細は割愛したいと思います。

TopicsScreen の実装
com/example/zennapp/ui/theme/screens/topics/TopicsScreen.kt
package com.example.zennapp.ui.theme.screens.topics

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.zennapp.ui.theme.screens.ArticlesScreen
import com.example.zennapp.ui.theme.screens.DropdownMenuItemData
import com.example.zennapp.ui.theme.screens.ErrorScreen
import com.example.zennapp.ui.theme.screens.LoadingScreen
import com.example.zennapp.ui.theme.screens.home.ZennUiState
import java.util.Locale

@Composable
fun TopicsScreen(
    viewModel: ZennTopicsScreenViewModel,
    modifier: Modifier = Modifier
) {
    when (val zennUiState = viewModel.zennUiState) {
        is ZennUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
        is ZennUiState.Success -> ArticlesScreen(
            zennUiState.articles,
            modifier = modifier.fillMaxWidth(),
            menuItems = viewModel.topics.map { topic ->
                DropdownMenuItemData(
                    label = topic.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() },
                    onClick = { viewModel.getTopicsLatestArticle(topic) }
                )
            }
        )

        is ZennUiState.Error -> ErrorScreen(
            retryAction = { viewModel.getTopicsLatestArticle(viewModel.topics.first()) },
            modifier = modifier.fillMaxSize()
        )
    }
}
ZennTopicsScreenViewModel の実装
com/example/zennapp/ui/theme/screens/topics/ZennTopicsScreenViewModel.kt
package com.example.zennapp.ui.theme.screens.topics

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil.network.HttpException
import com.example.zennapp.ZennArticlesApplication
import com.example.zennapp.data.ZennArticlesRepository
import com.example.zennapp.ui.theme.screens.home.ZennUiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import java.io.IOException

class ZennTopicsScreenViewModel(private val zennArticlesRepository: ZennArticlesRepository) : ViewModel() {
    var zennUiState: ZennUiState by mutableStateOf(ZennUiState.Loading)
        private set

    val topics = listOf(
        "jetpack", "flutter", "swift", "kotlin", "python", "javascript"
    )

    private var currentTopic = MutableStateFlow(topics.first())

    init {
        getTopicsLatestArticle(currentTopic.value)
    }

    fun getTopicsLatestArticle(topicName: String) {
        viewModelScope.launch {
            zennUiState = ZennUiState.Loading
            currentTopic.value = topicName
            zennUiState = try {
                ZennUiState.Success(zennArticlesRepository.getTopicsLatestArticle(topicName))
            } catch (e: IOException) {
                ZennUiState.Error
            } catch (e: HttpException) {
                ZennUiState.Error
            }
        }
    }

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = (this[APPLICATION_KEY] as ZennArticlesApplication)
                val zennArticlesRepository = application.container.zennArticlesRepository
                ZennTopicsScreenViewModel(zennArticlesRepository = zennArticlesRepository)
            }
        }
    }
}

これで View, ViewModel の実装は完了です。

5. Application, MainActivity の実装

最後に Application, MainActivity の実装を行います。

コードは以下の通りです。
AppContainer を遅延初期化して、 onCreate の中で DefaultAppContainer を割り当てています。DefaultAppContainer には Zenn API や Retrofit の設定が含まれるため、これで ZennArticlesApplication でデータの取得が行えるようになります。
ZennArticlesApplication

com/example/zennapp/ZennArticlesApplication.kt
package com.example.zennapp

import android.app.Application
import com.example.zennapp.data.AppContainer
import com.example.zennapp.data.DefaultAppContainer
import kotlinx.serialization.Serializable

@Suppress("PLUGIN_IS_NOT_ENABLED")
@Serializable
class ZennArticlesApplication: Application() {
    lateinit var container: AppContainer
    override fun onCreate() {
        super.onCreate()
        container = DefaultAppContainer()
    }
}

最後に MainActivity で以下のように ZennAppScaffold を指定することで、記事の一覧を取得するアプリを表示できるようになるかと思います。
MainActivity

com/example/zennapp/MainActivity.kt
package com.example.zennapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.zennapp.ui.theme.ZennAppTheme
import com.example.zennapp.ui.theme.screens.ZennAppScaffold

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ZennAppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize()
                ) {
                    ZennAppScaffold()
                }
            }
        }
    }
}

これで以下の動画のように記事を取得、表示するアプリが実装できるかと思います。

https://youtube.com/shorts/uCLIlUBlFc0

まとめ

最後まで読んでいただいてありがとうございました。

今回は公式ドキュメントで実装した内容を定着させるために、記事取得アプリを実装してみました。
今回は GET メソッドでデータを取得するのみでしたが、別の実装をする機会があればその都度まとめられたらと思います。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://developer.android.com/codelabs/basic-android-kotlin-compose-getting-data-internet?hl=ja&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fandroid-basics-compose-unit-5-pathway-1%3Findex%3D..%252F..android-kotlin-fundamentals%26hl%3Dja%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fbasic-android-kotlin-compose-getting-data-internet#7

Discussion