Contents 非表示
はじめに
最近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.kt | Repositoryの抽象化(インターフェース) |
データ層 | WeatherRepositoryImpl.kt | Repositoryの実装(APIクライアント呼び出し) |
API層 | WeatherApiService.kt | Retrofitインターフェース。OpenWeather APIとの通信定義 |
DI層 | AppModule.kt | Hiltの依存注入設定(@ProvidesでUseCaseやRepoなどを提供) |
Application | WeatherApp.kt | @HiltAndroidApp でアプリ全体のDIエントリーポイントを設定 |
まとめ:Hiltを使うときのチェックポイント
1: @HiltAndroidApp
を Application クラスにつけること
- HiltのDIコンテナを初期化する“玄関口”
AndroidManifest.xml
にandroid: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
で差し替えがスマート