HiltとRetrofitの基盤の上に、アプリの入口となる**「ユーザー認証機能」**を設計・実装します。
この記事は、単なる設計書ではなく、具体的なコードと**「なぜそう設計したのか」という思考プロセス**を詳述した、実践的な実装ガイドです。
1. 今回のゴール (要件定義)
- 目的: ユーザー認証機能を提供し、ログイン状態を永続化する。
- スコープ: メール/パスワードでの新規登録・ログイン、ログイン状態の維持、画面分岐。
2. 詳細設計 & 実装ステップ
Step 1: 依存関係の追加とDIの準備 (DataStore
)
🧠 思考のポイント:なぜSharedPreferences
ではなくDataStore
なのか?
ログイン状態の保存には、Jetpack DataStoreが公式に推奨されています。DataStore
は、KotlinコルーチンとFlowをベースにした完全な非同期APIであるため、UIスレッドをブロックする危険性がなく、より安全でモダンな実装が可能です。
👨💻 具体的な実装
-
app/build.gradle.kts
にDataStore
のライブラリを追加し、「Sync Now」をクリックします。
// app/build.gradle.kts
dependencies {
// ... (Retrofitの依存関係など) ...
implementation("androidx.datastore:datastore-preferences:1.1.1") // ★追加
}
-
di/AppModule.kt
に、DataStore
のインスタンスをHiltで提供するためのコードを追記します。
// di/AppModule.kt
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import android.app.Application // Applicationクラスのインポートを想定
// Contextの拡張関数としてDataStoreのインスタンスを定義
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "session")
@Module
@InstallIn(SingletonComponent::class) //
object AppModule {
// ... (既存のProvides関数) ...
@Provides
@Singleton
fun provideDataStore(app: Application): DataStore<Preferences> {
return app.dataStore
}
}
Step 2: データ層の実装 (UserRepository
とAPI仕様)
🧠 思考のポイント:なぜUserRepository
を新設するのか?(単一責任の原則)
認証ロジックを既存のMemoRepository
に追加することは、**「単一責任の原則」に反します。責務を明確に分離することで、コードはクリーンで、変更に強く、テストしやすくなります。MemoRepository
の責任は「メモの管理」、UserRepository
の責任は「ユーザーとセッションの管理」**です。
👨💻 具体的な実装
-
network/dto
にUser
関連のDTO(Data Transfer Object)を作成します。
// network/dto/UserRequest.kt (新規作成)
import kotlinx.serialization.Serializable
@Serializable
data class UserRequest(val email: String, val password: String)
// network/dto/UserResponse.kt (新規作成)
@Serializable
data class UserResponse(val id: String, val email: String, val createdAt: String)
-
ApiService.kt
に、ユーザー登録とメールアドレスでのユーザー検索のエンドポイントを追加します。
// network/ApiService.kt (追記)
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Body
import com.example.simplememoapp_android.network.dto.UserResponse
import com.example.simplememoapp_android.network.dto.UserRequest
interface ApiService {
// ... getMemosForUser ...
@GET("user") //
suspend fun getUserByEmail(@Query("email") email: String): List<UserResponse>
@POST("user")
suspend fun registerUser(@Body user: UserRequest): UserResponse
}
-
data/repository
にUserRepository.kt
を新規作成します。
// data/repository/UserRepository.kt (新規作成)
package com.example.simplememoapp_android.data.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.example.simplememoapp_android.network.ApiService //
import com.example.simplememoapp_android.network.dto.UserRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserRepository @Inject constructor(
private val apiService: ApiService,
private val dataStore: DataStore<Preferences> // DataStoreを注入
) {
private object PreferencesKeys {
val LOGGED_IN_USER_ID = stringPreferencesKey("logged_in_user_id")
}
val loggedInUserIdFlow: Flow<String?> = dataStore.data.map { preferences ->
preferences[PreferencesKeys.LOGGED_IN_USER_ID] //
}
suspend fun saveUserId(userId: String) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.LOGGED_IN_USER_ID] = userId
}
}
suspend fun login(email: String, password: String): Result<String> {
return try {
val users = apiService.getUserByEmail(email)
val user = users.firstOrNull() // Emailが一致する最初のユーザーを取得
if (user != null) {
// 本来はパスワードも検証するが、今回はEmailの存在確認だけで成功とみなす
saveUserId(user.id)
Result.success(user.id)
} else {
Result.failure(Exception("ユーザーが見つかりません"))
}
} catch (e: Exception) { //
Result.failure(e)
}
}
suspend fun register(email: String, password: String): Result<String> {
return try {
val request = UserRequest(email = email, password = password)
val user = apiService.registerUser(request)
saveUserId(user.id)
Result.success(user.id)
} catch (e: Exception) {
Result.failure(e)
}
}
}
Step 3: ViewModel層の実装 (改訂版: SplashViewModel
の導入)
🧠 思考のポイント:ViewModelの関心分離 (単一責任の原則の徹底)
今回は、責務の分離を徹底するため、以下の2つのViewModelに分割します。
-
AuthViewModel
: ログイン/登録フォームの入力処理と認証APIコールのみを担当。 -
SplashViewModel
: アプリ起動時に現在のログイン状態をチェックし、画面遷移の判断結果をUIに伝えることのみを担当。
1. AuthViewModel.kt
(認証処理担当)
// ui/viewmodel/AuthViewModel.kt
package com.example.simplememoapp_android.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.simplememoapp_android.data.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow //
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AuthUiState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
)
sealed interface AuthUiEvent {
data class NavigateTo(val route: String) : AuthUiEvent
data class ShowSnackbar(val message: String) : AuthUiEvent
}
@HiltViewModel
class AuthViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState()) //
val uiState = _uiState.asStateFlow()
private val _eventFlow = MutableSharedFlow<AuthUiEvent>()
val eventFlow = _eventFlow.asSharedFlow()
fun onEmailChange(email: String) {
_uiState.update { it.copy(email = email) }
}
fun onPasswordChange(password: String) {
_uiState.update { it.copy(password = password) }
}
fun login() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
userRepository.login(_uiState.value.email, _uiState.value.password)
.onSuccess { //
_eventFlow.emit(AuthUiEvent.NavigateTo("memo_list"))
}
.onFailure { e ->
_eventFlow.emit(AuthUiEvent.ShowSnackbar(e.message ?: "ログインに失敗しました"))
}
_uiState.update { it.copy(isLoading = false) }
}
}
fun register() {
// registerメソッドも同様に実装
}
}
2. SplashViewModel.kt
(起動時状態チェック担当)
// ui/viewmodel/SplashViewModel.kt (新規作成)
package com.example.simplememoapp_android.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.simplememoapp_android.data.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn //
import javax.inject.Inject
// 画面分岐のための状態を定義
sealed interface SplashUiState {
object Loading : SplashUiState // チェック中
data class Authenticated(val userId: String) : SplashUiState // ログイン済み
object Unauthenticated : SplashUiState // 未ログイン
}
@HiltViewModel
class SplashViewModel @Inject constructor(
userRepository: UserRepository
) : ViewModel() {
// UserRepositoryから流れてくるログイン状態を、UIが見るべき状態に変換する
val uiState: StateFlow<SplashUiState> = userRepository.loggedInUserIdFlow
.map { userId ->
if (userId.isNullOrBlank()) {
SplashUiState.Unauthenticated //
} else {
SplashUiState.Authenticated(userId)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = SplashUiState.Loading
)
}
Step 4: UI層とナビゲーションの実装 (改訂版)
🧠 思考のポイント
SplashScreen
はSplashViewModel
の状態を監視することに専念し、状態が変化した瞬間に画面遷移を実行します。
👨💻 具体的な実装
1. ui/screen/SplashScreen.kt
(修正後)
SplashViewModel
を使用するように変更しました。
// ui/screen/SplashScreen.kt (修正後)
package com.example.simplememoapp_android.ui.screen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue //
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.example.simplememoapp_android.ui.viewmodel.SplashUiState
import com.example.simplememoapp_android.ui.viewmodel.SplashViewModel
@Composable
fun SplashScreen(navController: NavController) {
val viewModel: SplashViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsState()
// uiStateが変化するたびにLaunchedEffectが再評価される
LaunchedEffect(uiState) { //
when (val state = uiState) {
is SplashUiState.Authenticated -> {
// ログイン済みならメモ一覧へ
navController.navigate("memo_list") {
popUpTo("splash") { inclusive = true } // Splash画面を履歴から消す
}
}
is SplashUiState.Unauthenticated -> {
// 未ログインならログイン画面へ
navController.navigate("login") {
popUpTo("splash") { inclusive = true } // Splash画面を履歴から消す
}
}
is SplashUiState.Loading -> {
// ローディング中は何もしない(UIが表示されるのを待つ)
}
}
}
// ローディング中のUI
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center //
) {
CircularProgressIndicator()
}
}
2. ui/screen/LoginScreen.kt
(骨子)
// ui/screen/LoginScreen.kt (新規作成・骨子)
import androidx.compose.runtime.collectLatest
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.navigation.NavController
import com.example.simplememoapp_android.ui.viewmodel.AuthUiEvent
import com.example.simplememoapp_android.ui.viewmodel.AuthViewModel
@Composable
fun LoginScreen(navController: NavController) {
val viewModel: AuthViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.eventFlow.collectLatest { event ->
when(event) {
is AuthUiEvent.NavigateTo -> {
navController.navigate(event.route) {
popUpTo("login") { inclusive = true } //
}
}
is AuthUiEvent.ShowSnackbar -> { /* Snackbar表示ロジックを実装 */ }
}
}
}
// ここにTextFieldやButtonを配置し、uiStateと連携させる
// 例: Button(onClick = viewModel::login)
}
3. navigation/AppNavHost.kt
(修正後)
スタート地点を"splash"
に変更します。
// navigation/AppNavHost.kt (修正後)
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = "splash") { // ★スタート地点を変更
composable("splash") {
SplashScreen(navController = navController)
}
composable("login") {
LoginScreen(navController = navController)
}
composable("register") {
RegisterScreen(navController = navController) //
}
composable("memo_list") {
MemoListScreen(navController = navController) //
}
// ...
}
}