0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Androidアプリに認証機能を実装する(詳細設計&実装・改訂版)

Posted at

HiltとRetrofitの基盤の上に、アプリの入口となる**「ユーザー認証機能」**を設計・実装します。

この記事は、単なる設計書ではなく、具体的なコードと**「なぜそう設計したのか」という思考プロセス**を詳述した、実践的な実装ガイドです。


1. 今回のゴール (要件定義)

  • 目的: ユーザー認証機能を提供し、ログイン状態を永続化する。
  • スコープ: メール/パスワードでの新規登録・ログイン、ログイン状態の維持、画面分岐。

2. 詳細設計 & 実装ステップ

Step 1: 依存関係の追加とDIの準備 (DataStore)

🧠 思考のポイント:なぜSharedPreferencesではなくDataStoreなのか?

ログイン状態の保存には、Jetpack DataStoreが公式に推奨されています。DataStoreは、KotlinコルーチンとFlowをベースにした完全な非同期APIであるため、UIスレッドをブロックする危険性がなく、より安全でモダンな実装が可能です。

👨‍💻 具体的な実装

  1. app/build.gradle.ktsDataStoreのライブラリを追加し、「Sync Now」をクリックします。
// app/build.gradle.kts

dependencies {
    // ... (Retrofitの依存関係など) ...
    implementation("androidx.datastore:datastore-preferences:1.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の責任は「ユーザーとセッションの管理」**です。

👨‍💻 具体的な実装

  1. network/dtoUser関連の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)
  1. 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
}
  1. data/repositoryUserRepository.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に分割します。

  1. AuthViewModel: ログイン/登録フォームの入力処理と認証APIコールのみを担当。
  2. 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層とナビゲーションの実装 (改訂版)

🧠 思考のポイント

SplashScreenSplashViewModelの状態を監視することに専念し、状態が変化した瞬間に画面遷移を実行します。

👨‍💻 具体的な実装

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) //
        }

        // ...
    }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?