[Jetpack Compose] 簡単なお天気アプリで、Hiltを理解してみる

はじめに

最近Android学習サボってたので、復習がてらHiltの概念を理解する。

今回の構成

Hilt & Jetpack Compose & Clean Architecture

名前の通り、一番クリーンで初心者にも理解しやすそうなので今回の練習用アーキテクチャに採用。(iOS開発でも最近取り入れてるし)


実装

ファイル構成

今回のプロジェクト名: weather_clean_architecture

weather_clean_architecture/
├── build.gradle
├── settings.gradle
├── app/
│   ├── build.gradle
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           ├── java/com/example/weatherapp/
│           │   ├── WeatherApp.kt                  // @HiltAndroidApp
│           │   ├── MainActivity.kt                  // @HiltAndroidApp (エントリーポイント)
│           │
│           │   ├── di/
│           │   │   └── AppModule.kt               // Hilt Module
│           │
│           │   ├── data/
│           │   │   ├── remote/
│           │   │   │   ├── WeatherApiService.kt   // Retrofit API定義
│           │   │   │   └── WeatherResponseDto.kt          // APIレスポンスDTO
│           │   │   │   └── WeatherDtoMapper.kt    // APIレスポンスDTOマッパー (整形)
│           │   │   └── repository/
│           │   │       └── WeatherRepositoryImpl.kt // Repository実装
│           │
│           │   ├── domain/
│           │   │   ├── model/
│           │   │   │   └── Weather.kt             // ドメインモデル
│           │   │   ├── repository/
│           │   │   │   └── WeatherRepository.kt   // Repositoryインターフェース
│           │   │   └── usecase/
│           │   │       └── WeatherUseCase.kt   // UseCase
│           │
│           │   ├── presentation/
│           │   │   ├── viewmodel/
│           │   │   │   └── WeatherViewModel.kt    // ViewModel
│           │   │   ├── ui/
│           │   │   │   ├── WeatherScreen.kt       // メイン画面
│           │   │   │   └── WeatherCard.kt         // 天気カード表示UI
│           │   │   └── navigation/
│           │   │       └── AppNavigation.kt       // Composeナビゲーション
│           │

※ OpenWeatherのAPIKeyを使用するので、事前に取得してください。


必要な依存系インストール

libs.versions.toml:

[versions]
agp = "8.5.2"
kotlin = "1.9.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.04.01"
coil = "2.6.0"
hilt = "2.51"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

build.gradle.kts (モジュール):

plugins {
	alias(libs.plugins.android.application)
	alias(libs.plugins.jetbrains.kotlin.android)
	alias(libs.plugins.kotlin.kapt)
	alias(libs.plugins.dagger.hilt)
}

android {
	namespace = "com.example.weather_clean_architecture"
	compileSdk = 35

	defaultConfig {
		applicationId = "com.example.weather_clean_architecture"
		minSdk = 24
		targetSdk = 35
		versionCode = 1
		versionName = "1.0"

		testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
		vectorDrawables {
			useSupportLibrary = true
		}
	}

	buildTypes {
		release {
			isMinifyEnabled = false
			proguardFiles(
				getDefaultProguardFile("proguard-android-optimize.txt"),
				"proguard-rules.pro"
			)
		}
	}
	compileOptions {
		sourceCompatibility = JavaVersion.VERSION_1_8
		targetCompatibility = JavaVersion.VERSION_1_8
	}
	kotlinOptions {
		jvmTarget = "1.8"
	}
	buildFeatures {
		compose = true
	}
	composeOptions {
		kotlinCompilerExtensionVersion = "1.5.1"
	}
	packaging {
		resources {
			excludes += "/META-INF/{AL2.0,LGPL2.1}"
		}
	}
	hilt {
		enableAggregatingTask = false
	}
}

dependencies {

	implementation(libs.androidx.core.ktx)
	implementation(libs.androidx.lifecycle.runtime.ktx)
	implementation(libs.androidx.activity.compose)
	implementation(platform(libs.androidx.compose.bom))
	implementation(libs.androidx.ui)
	implementation(libs.androidx.ui.graphics)
	implementation(libs.androidx.ui.tooling.preview)
	implementation(libs.androidx.material3)

	// --- Hilt ---
	implementation("com.google.dagger:hilt-android:2.51")
	kapt("com.google.dagger:hilt-compiler:2.51")
	implementation("androidx.hilt:hilt-navigation-compose:1.1.0")

    // --- Retrofit & Gson ---
	implementation("com.squareup.retrofit2:retrofit:2.9.0")
	implementation("com.squareup.retrofit2:converter-gson:2.9.0")

    // --- OkHttp logging ---
	implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

    // --- Coroutines ---
	implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")

    // --- ViewModel + Lifecycle ---
	implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
	implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")

	// Coil (Async系)
	implementation(libs.coil.compose)

	testImplementation(libs.junit)
	androidTestImplementation(libs.androidx.junit)
	androidTestImplementation(libs.androidx.espresso.core)
	androidTestImplementation(platform(libs.androidx.compose.bom))
	androidTestImplementation(libs.androidx.ui.test.junit4)
	debugImplementation(libs.androidx.ui.tooling)
	debugImplementation(libs.androidx.ui.test.manifest)
}

build.gradle.kts (アプリ全体):

plugins {
	alias(libs.plugins.android.application) apply false
	alias(libs.plugins.jetbrains.kotlin.android) apply false
	alias(libs.plugins.kotlin.kapt)
}

全部入れ終わったらsync、Rebuild Projectをしてエラーにならないことを確認。


WeatherApp & AppModule

WeatherApp (アプリの入り口となる部分):

@HiltAndroidApp // Hiltにここがエントリーポイントだと知らせる
class WeatherApp : Application()

Manifest.xmlには以下を追加しておく👇

<uses-permission android:name="android.permission.INTERNET" /> // 通信許可

<application
        android:name=".WeatherApp" // 入り口

AppModule (DIの核となる部分):

package com.example.weather_clean_architecture

import com.example.weather_clean_architecture.data.remote.api.WeatherApiService
import com.example.weather_clean_architecture.data.repository.WeatherRepositoryImpl
import com.example.weather_clean_architecture.domain.repository.WeatherRepository
import com.example.weather_clean_architecture.domain.usecase.GetWeatherUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

	private const val BASE_URL = "https://api.openweathermap.org/data/2.5/"

    // OkHttpClientの作成(Singleton = 1個の使い回し)
	@Provides
	@Singleton
	fun provideHttpClient(): OkHttpClient =
		OkHttpClient.Builder()
			.addInterceptor(HttpLoggingInterceptor().apply {
				level = HttpLoggingInterceptor.Level.BODY
			})
			.build()

    // Retrofitの作成
	@Provides
	@Singleton
	fun provideRetrofit(client: OkHttpClient): Retrofit =
		Retrofit.Builder()
			.baseUrl(BASE_URL)
			.addConverterFactory(GsonConverterFactory.create())
			.client(client)
			.build()

    // WeatherApiServiceの作成
	@Provides
	@Singleton
	fun provideWeatherApiService(retrofit: Retrofit): WeatherApiService =
		retrofit.create(WeatherApiService::class.java)

    // WeatherRepositoryImplのDI作成 (ApiServiceを注入)
	@Provides
	@Singleton
	fun provideWeatherRepository(api: WeatherApiService): WeatherRepository =
		WeatherRepositoryImpl(api)

    // GetWeatherUseCaseのDI作成 (Repositoryを注入)
	@Provides
	@Singleton
	fun provideGetWeatherUseCase(repository: WeatherRepository): GetWeatherUseCase =
		GetWeatherUseCase(repository)
}

各ファイルはまだ作ってないのでエラーになりますがこれから作っていくので一旦無視でOKです。


WeatherApiService、レスポンスデータ

WeatherApiService:

import com.example.weather_clean_architecture.data.remote.dto.WeatherResponseDto
import retrofit2.http.GET
import retrofit2.http.Query

interface WeatherApiService {
	@GET("weather")
	suspend fun getWeatherByCity(
		@Query("q") cityName: String,
		@Query("appid") apiKey: String,
		@Query("units") units: String = "metric",
		@Query("lang") lang: String = "ja"
	): WeatherResponseDto
}

WeatherResponseDto (DTO = Data Transfer Object、つまり生のレスポンスデータ):

package com.example.weather_clean_architecture.data.remote.dto

data class WeatherResponseDto(
	val name: String,
	val main: MainDto,
	val weather: List<WeatherDto>
)

data class MainDto(
	val temp: Double,
	val humidity: Int
)

data class WeatherDto(
	val main: String,
	val description: String,
	val icon: String
)

WeatherDtoMapper(DTOを整形する場所、WeatherResponseDtoにまとめてもOK):

fun WeatherResponseDto.toDomain(): Weather {
	return Weather(
		city = name,
		temperature = main.temp,
		humidity = main.humidity,
		description = weather.firstOrNull()?.description ?: "不明",
		iconUrl = "https://openweathermap.org/img/wn/${weather.firstOrNull()?.icon}@2x.png"
	)
}

Weather(VMとかに返す、整形したデータ):

data class Weather(
	val city: String,
	val temperature: Double,
	val humidity: Int,
	val description: String,
	val iconUrl: String
)

WeatherRepository, WeatherRepositoryImpl

WeatherRepository (大元のルール決めを記載する場所):

interface WeatherRepository {
	suspend fun getWeather(city: String): Weather // suspend = 非同期でいい感じにやってくれるやつ
}

WeatherRepositoryImpl (実際に具体的な処理を書く場所):

class WeatherRepositoryImpl(private val api: WeatherApiService) : WeatherRepository {
	override suspend fun getWeather(city: String): Weather {
		// 本番ではAPIキーはここで直書きしない
		val apiKey = "APIKeyをここにペースト"
		val response = api.getWeatherByCity(city, apiKey)
		return response.toDomain()
	}
}

WeatherUseCase

ViewModelとRepositoryの仲介

class GetWeatherUseCase(
	private val repository: WeatherRepository
) {
	suspend operator fun invoke(city: String): Weather {
		return repository.getWeather(city)
	}
}

WeatherViewModel

@HiltViewModel // 重要、Hiltに俺がVMやぞって知らせる
class WeatherViewModel @Inject constructor(
	private val getWeatherUseCase: GetWeatherUseCase
) : ViewModel() {

	private val _state = MutableStateFlow<WeatherUiState>(WeatherUiState.Loading)
	val state: StateFlow<WeatherUiState> = _state

	fun fetchWeather(city: String) {
		viewModelScope.launch {
			_state.value = WeatherUiState.Loading
			try {
				val result = getWeatherUseCase(city)
				_state.value = WeatherUiState.Success(result)
			} catch (e: Exception) {
				_state.value = WeatherUiState.Error(e.message ?: "エラー発生")
			}
		}
	}
}

sealed class WeatherUiState {
	object Loading : WeatherUiState()
	data class Success(val data: Weather) : WeatherUiState()
	data class Error(val message: String) : WeatherUiState()
}

WeatherScreen, WeatherCard

WeatherScreen (メイン画面):

package com.example.weather_clean_architecture.presentation.screen.weather

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel

@Composable
fun WeatherScreen(
	viewModel: WeatherViewModel = hiltViewModel()
) {
	val state by viewModel.state.collectAsState()
	var city by remember { mutableStateOf("Tokyo") }

	LaunchedEffect(Unit) {
		viewModel.fetchWeather(city)
	}

	Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
		OutlinedTextField(
			value = city,
			onValueChange = { city = it },
			label = { Text("都市名を入力") },
			modifier = Modifier.fillMaxWidth()
		)
		Spacer(modifier = Modifier.height(8.dp))
		Button(
			onClick = { viewModel.fetchWeather(city) },
			modifier = Modifier.align(Alignment.End)
		) {
			Text("検索")
		}

		Spacer(modifier = Modifier.height(16.dp))

		when (val result = state) {
			is WeatherUiState.Loading -> CircularProgressIndicator()
			is WeatherUiState.Success -> WeatherCard(result.data)
			is WeatherUiState.Error -> Text("エラー: ${result.message}")
		}
	}
}

WeatherCard (カードUI):

package com.example.weather_clean_architecture.presentation.screen.weather

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.example.weather_clean_architecture.domain.model.Weather

@Composable
fun WeatherCard(weather: Weather) {
	Card(
		modifier = Modifier
			.fillMaxWidth()
			.padding(16.dp),
		elevation = CardDefaults.cardElevation(4.dp)
	) {
		Column(
			modifier = Modifier.padding(16.dp),
			horizontalAlignment = Alignment.CenterHorizontally
		) {
			Text(weather.city, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
			Spacer(modifier = Modifier.height(8.dp))
			Text("気温: ${weather.temperature}°C")
			Text("湿度: ${weather.humidity}%")
			Text("天気: ${weather.description}")
			AsyncImage(
				model = weather.iconUrl,
				contentDescription = "weather icon",
				contentScale = ContentScale.Fit,
				modifier = Modifier.size(64.dp)
			)
		}
	}
}

Navigation系

今回は不要ですが、天気詳細画面など作りたい時の土台として。

AppNavigation(ナビゲーション管理):

@Composable
fun AppNavigation(navController: NavHostController = rememberNavController()) {
	NavHost(
		navController = navController,
		startDestination = "weather"
	) {
		composable("weather") {
			WeatherScreen()
		}
		
		// 今後ここに遷移先追加とかすればOK
	}
}

MainActivity (エントリーポイント):

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContent {
			MaterialTheme {
				Surface(color = MaterialTheme.colorScheme.background) {
					AppNavigation()
				}
			}
		}
	}
}

エミューレータ or 実機で実行、検索クエリの地域の天気情報がカードで表示されたらOK。


Hilt & Clean Architecture データフロー

ざっくりとこんな感じ:

WeatherScreen (UI)
     ↓ (ユーザー操作)
WeatherViewModel
     ↓ (UseCase呼び出し)
WeatherUseCase
     ↓ (Interface呼び出し)
WeatherRepository(interface)
     ↓ (実装クラス)
WeatherRepositoryImpl(data層)
     ↓ (API呼び出し)
WeatherApiService(Retrofit)
     ↓
OpenWeather API

// Hiltお嬢様 (GPTに考えてもらった)
WeatherScreen → hiltViewModel()
    → WeatherViewModel を所望ですの?
        → Hilt「まあ!それなら WeatherUseCase をお持ちしなくては」
            → WeatherUseCase には Repository が必要ですわね?
                → RepositoryImpl を用意するには APIクライアントが不可欠ですの
                    → すべてご用意いたしましたわ!どうぞ、ご笑納くださいませ✨

各クラス・レイヤーの役割:

ファイル名役割・責務
UI層WeatherScreen.ktユーザー操作・状態表示(Composeで描画)
ViewModel層WeatherViewModel.kt状態管理・UseCase呼び出し・状態のFlow化
UseCase層WeatherUseCase.ktビジネスロジック(データ取得のトリガー)
ドメイン層WeatherRepository.ktRepositoryの抽象化(インターフェース)
データ層WeatherRepositoryImpl.ktRepositoryの実装(APIクライアント呼び出し)
API層WeatherApiService.ktRetrofitインターフェース。OpenWeather APIとの通信定義
DI層AppModule.ktHiltの依存注入設定(@ProvidesでUseCaseやRepoなどを提供)
ApplicationWeatherApp.kt@HiltAndroidApp でアプリ全体のDIエントリーポイントを設定

まとめ:Hiltを使うときのチェックポイント

1: @HiltAndroidApp を Application クラスにつけること

  • HiltのDIコンテナを初期化する“玄関口”
  • AndroidManifest.xmlandroid:name=".YourApp" を忘れずに

2: 依存性は @Module + @Provides or @Inject で定義

  • どこで何を注入するかは明示的に書く
  • Hiltはコンパイル時に依存関係を解析&コード生成してくれる

3: @InstallIn でスコープ(範囲)を指定

  • どこで何を注入するかは明示的に書く
  • 他にも ViewModelComponent, ActivityComponent など目的に応じて使い分け

4: ViewModelは @HiltViewModel + @Inject constructor(...)

  • hiltViewModel() で簡単に呼び出せる、便利
  • 内部のUseCaseもHiltが自動で注入してくれるからスッキリする

5: デバッグ・テスト時は Fake を差し替えられる構成にしておく

  • interface + @Provides 構成にしておくと、MockやTestModuleに切り替えやすい
  • テストでは @TestInstallIn + replaces で差し替えがスマート