[Jetpack Compose] MVVMでAPI Requestを学ぶ

ユーザー情報をリクエストして、受け取った情報でカードViewを作る

Dependencies系セットアップ

AndroidManifest.xml

以下を追加

<uses-permission android:name="android.permission.INTERNET" />

bundle.gradle.kts (Module)

pluginsに以下を追加

id("kotlin-kapt")
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.10"

dependenciesに以下を追加、syncする

implementation(libs.retrofit2.kotlinx.serialization.converter)
implementation(libs.kotlinx.serialization.json)
implementation(libs.converter.moshi)
implementation(libs.moshi.kotlin)
implementation("io.coil-kt:coil-compose:2.5.0")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
implementation(libs.converter.gson)
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit)
implementation(libs.retrofit2.kotlinx.serialization.converter.v080)
implementation(libs.okhttp)

実装

今回はこちらからユーザ情報を取得します。

Model (User.kt)

@Serializable
data class User(
    val results: List<ResultsItem>
)

@Serializable
data class ResultsItem(
    val phone: String,
    val name: Name,
    val location: Location,
    val email: String,
    val picture: Picture
)

@Serializable
data class Picture(
    val thumbnail: String,
    val large: String,
    val medium: String
)

@Serializable
data class Name(
    val last: String,
    val title: String,
    val first: String
)

@Serializable
data class Location(
    val country: String,
    val city: String,
)

// Preview用のダミーデータ、なくてもOK
val dummyUser = ResultsItem(
	phone = "+123456789",
	name = Name(title = "Mr", first = "John", last = "Doe"),
	location = Location(
		country = "USA",
		city = "New York"
	),
	email = "john.doe@example.com",
	picture = Picture(
		thumbnail = "https://randomuser.me/api/portraits/thumb/men/1.jpg",
		large = "https://randomuser.me/api/portraits/men/1.jpg",
		medium = "https://randomuser.me/api/portraits/med/men/1.jpg"
	)
)

API Request基盤

interface ApiService {
	@GET("api/") // APIのエンドポイント(パス)を指定
	suspend fun getUsers(): User // 非同期でユーザー情報を取得する
}

object RetrofitClient {
	private const val BASE_URL = "https://randomuser.me/"

	private val json = Json {
		// JSONレスポンスに定義されていないキーが含まれていても無視
		ignoreUnknownKeys = true
	}

	private val contentType = "application/json".toMediaType()

	val apiService: ApiService by lazy {
		Retrofit.Builder()
			.baseUrl(BASE_URL)
			.addConverterFactory(json.asConverterFactory(contentType)) // Kotlin Serializationを使用してJSONを変換
			.build()
			.create(ApiService::class.java)
	}
}

ViewModel (UserViewModel.kt)

class UserViewModel : ViewModel() {
	var users = mutableStateOf<List<ResultsItem>>(emptyList())
	var isLoading = mutableStateOf(false)
	var errorMessage = mutableStateOf<String?>(null)

	init {
		fetchUsers()
	}

	fun fetchUsers() {
		isLoading.value = true
		// ViewModelのライフサイクル内でコルーチンを開始
		viewModelScope.launch {
			try {
				// RetrofitClientを使用してAPIからユーザーデータを取得
				val response = RetrofitClient.apiService.getUsers()
				users.value = response.results
				isLoading.value = false
			} catch (e: Exception) {
				// エラーが発生した場合、エラーメッセージをMutableStateに設定
				errorMessage.value = e.message
				isLoading.value = false
			}
		}
	}
}

View (MainActivity.kt)

class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContent {
			MyJSONUserTheme {
				Surface(
					modifier = Modifier.fillMaxSize(),
					color = MaterialTheme.colorScheme.background
				) {
					val viewModel = UserViewModel()
					UserListScreen(viewModel = viewModel)
				}
			}
		}
	}
}

@Composable
private fun UserListScreen(viewModel: UserViewModel) {
	val users by viewModel.users
	val isLoading by viewModel.isLoading
	val errorMessage by viewModel.errorMessage

	Box(
		modifier = Modifier.fillMaxSize(),
		contentAlignment = Alignment.Center
	) {
		Card(
			modifier = Modifier
				.fillMaxWidth(0.9f)
				.height(480.dp)
				.shadow(12.dp),
			shape = RoundedCornerShape(16.dp)
		) {
			Column(
				modifier = Modifier.fillMaxWidth(),
				horizontalAlignment = Alignment.CenterHorizontally
			) {
				if (isLoading) {
					CircularProgressIndicator(modifier = Modifier.padding(16.dp))
				} else if (errorMessage != null) {
					ErrorView(viewModel, errorMessage)
				} else {
					UserItem(user = users[0], viewModel = viewModel)
				}
			}
		}
	}
}

@Composable
private fun ErrorView(
	viewModel: UserViewModel,
	errorMessage: String?
) {
	Column(
		modifier = Modifier.padding(16.dp)
	) {
		Button(
			modifier = Modifier
				.padding(top = 12.dp)
				.align(Alignment.CenterHorizontally),
			onClick = {
				viewModel.fetchUsers()
			}
		) {
			Text("Try Again")
		}
		Text(
			"Error: ${errorMessage}",
			modifier = Modifier.padding(16.dp)
		)
	}
}

@Composable
private fun UserItem(user: ResultsItem, viewModel: UserViewModel) {
	Column(
		modifier = Modifier.padding(12.dp)
	) {
		AsyncImage(
			model = user.picture.large,
			contentDescription = "User Picture",
			alignment = Alignment.Center,
			contentScale = ContentScale.Crop,
			modifier = Modifier
				.padding(8.dp)
				.size(200.dp)
				.clip(CircleShape)
				.align(Alignment.CenterHorizontally)
		)
		Text("Name: ${user.name.first} ${user.name.last}")
		Text("Email: ${user.email}")
		Text("Phone: ${user.phone}")
		Text("Location: ${user.location.city}, ${user.location.country}")
		Button(
			modifier = Modifier
				.padding(top = 12.dp)
				.align(Alignment.CenterHorizontally),
			onClick = {
				viewModel.fetchUsers()
			}
		) {
			Text("Fetch Another User Info")
		}
	}
}

@Preview(showBackground = true)
@Composable
fun UserItemPreview() {
	val dummyUser = dummyUser
	val viewModel = UserViewModel()

	MyJSONUserTheme {
		UserItem(user = dummyUser, viewModel = viewModel)
	}
}