1
1

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開発30日間マスターシリーズ - Day19: Repository パターン - データソースの抽象化と実践的な設計

Posted at

はじめに

今日は、Androidアプリ開発におけるデータ管理の要となるRepositoryパターンについて、実践的な観点から詳しく学びます。この設計パターンを適切に実装することで、アプリの保守性、テスト性、そしてスケーラビリティが大幅に向上します。

1. Repositoryパターンの本質

パターンの定義

Repositoryパターンは、データアクセスロジックを抽象化し、ビジネスロジックとデータソースを分離する設計パターンです。アプリケーションからは「単一のデータソース」のように見えながら、実際には複数のデータソース(API、ローカルDB、キャッシュなど)を統合管理します。

従来の問題点

// ❌ 悪い例:ViewModelが直接複数のデータソースに依存
class UserViewModel(
    private val apiService: GitHubService,
    private val database: UserDao,
    private val sharedPreferences: SharedPreferences
) : ViewModel() {
    
    fun loadUser(userName: String) {
        viewModelScope.launch {
            // ローカルDBを確認
            val localUser = database.getUser(userName)
            if (localUser != null && !isExpired(localUser.lastUpdated)) {
                _user.value = localUser
                return@launch
            }
            
            // APIから取得
            try {
                val apiUser = apiService.getUser(userName)
                database.insertUser(apiUser.toEntity())
                _user.value = apiUser.toModel()
            } catch (e: Exception) {
                // フォールバック処理
                if (localUser != null) {
                    _user.value = localUser
                }
            }
        }
    }
}

この実装の問題点:

  • 複雑な依存関係: ViewModelが複数のデータソースを直接管理
  • 責任の混在: データロジックとプレゼンテーションロジックが混在
  • テストの困難さ: 複数のモックが必要
  • 再利用困難: 他のViewModelで同じロジックを使用できない

2. Repositoryパターンの実装

データモデルの定義

まず、アプリケーション全体で使用するドメインモデルを定義します。

// ドメインモデル(アプリ内で使用する共通データ形式)
data class User(
    val id: String,
    val userName: String,
    val displayName: String,
    val email: String?,
    val avatarUrl: String,
    val followersCount: Int,
    val followingCount: Int,
    val bio: String?,
    val location: String?,
    val createdAt: LocalDateTime
)

// API レスポンス用のデータクラス
@JsonClass(generateAdapter = true)
data class GitHubUserResponse(
    val id: Int,
    val login: String,
    @Json(name = "avatar_url") val avatarUrl: String,
    val name: String?,
    val email: String?,
    val followers: Int,
    val following: Int,
    val bio: String?,
    val location: String?,
    @Json(name = "created_at") val createdAt: String
)

// ローカルDB用のエンティティ
@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey val id: String,
    val userName: String,
    val displayName: String,
    val email: String?,
    val avatarUrl: String,
    val followersCount: Int,
    val followingCount: Int,
    val bio: String?,
    val location: String?,
    val createdAt: Long,
    val lastUpdated: Long = System.currentTimeMillis()
)

データソースの定義

// リモートデータソース
interface UserRemoteDataSource {
    suspend fun getUser(userName: String): GitHubUserResponse
    suspend fun searchUsers(query: String): List<GitHubUserResponse>
}

class UserRemoteDataSourceImpl(
    private val apiService: GitHubService
) : UserRemoteDataSource {
    
    override suspend fun getUser(userName: String): GitHubUserResponse {
        val response = apiService.getUser(userName)
        if (response.isSuccessful) {
            return response.body() ?: throw Exception("Empty response body")
        } else {
            throw HttpException(response)
        }
    }
    
    override suspend fun searchUsers(query: String): List<GitHubUserResponse> {
        val response = apiService.searchUsers(query)
        if (response.isSuccessful) {
            return response.body()?.items ?: emptyList()
        } else {
            throw HttpException(response)
        }
    }
}

// ローカルデータソース
interface UserLocalDataSource {
    suspend fun getUser(userName: String): UserEntity?
    suspend fun getAllUsers(): List<UserEntity>
    suspend fun saveUser(user: UserEntity)
    suspend fun saveUsers(users: List<UserEntity>)
    suspend fun deleteUser(userName: String)
    suspend fun clearExpiredCache(expirationTime: Long)
}

@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE userName = :userName")
    suspend fun getUser(userName: String): UserEntity?
    
    @Query("SELECT * FROM users")
    suspend fun getAllUsers(): List<UserEntity>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: UserEntity)
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUsers(users: List<UserEntity>)
    
    @Query("DELETE FROM users WHERE userName = :userName")
    suspend fun deleteUser(userName: String)
    
    @Query("DELETE FROM users WHERE lastUpdated < :expirationTime")
    suspend fun clearExpiredCache(expirationTime: Long)
}

class UserLocalDataSourceImpl(
    private val userDao: UserDao
) : UserLocalDataSource {
    
    override suspend fun getUser(userName: String): UserEntity? {
        return userDao.getUser(userName)
    }
    
    override suspend fun getAllUsers(): List<UserEntity> {
        return userDao.getAllUsers()
    }
    
    override suspend fun saveUser(user: UserEntity) {
        userDao.insertUser(user)
    }
    
    override suspend fun saveUsers(users: List<UserEntity>) {
        userDao.insertUsers(users)
    }
    
    override suspend fun deleteUser(userName: String) {
        userDao.deleteUser(userName)
    }
    
    override suspend fun clearExpiredCache(expirationTime: Long) {
        userDao.clearExpiredCache(expirationTime)
    }
}

Repositoryインターフェースの定義

interface UserRepository {
    suspend fun getUser(userName: String, forceRefresh: Boolean = false): Result<User>
    suspend fun searchUsers(query: String): Result<List<User>>
    suspend fun getFavoriteUsers(): Result<List<User>>
    suspend fun addToFavorites(userName: String): Result<Unit>
    suspend fun removeFromFavorites(userName: String): Result<Unit>
    suspend fun clearCache()
}

Repository実装クラス

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.concurrent.TimeUnit

class UserRepositoryImpl(
    private val remoteDataSource: UserRemoteDataSource,
    private val localDataSource: UserLocalDataSource,
    private val cacheExpirationTime: Long = TimeUnit.HOURS.toMillis(1) // 1時間
) : UserRepository {
    
    override suspend fun getUser(userName: String, forceRefresh: Boolean): Result<User> {
        return withContext(Dispatchers.IO) {
            try {
                // 強制更新でない場合、キャッシュをチェック
                if (!forceRefresh) {
                    val cachedUser = localDataSource.getUser(userName)
                    if (cachedUser != null && !isExpired(cachedUser.lastUpdated)) {
                        return@withContext Result.success(cachedUser.toDomainModel())
                    }
                }
                
                // リモートからデータを取得
                try {
                    val remoteUser = remoteDataSource.getUser(userName)
                    val userEntity = remoteUser.toEntity()
                    
                    // ローカルDBに保存
                    localDataSource.saveUser(userEntity)
                    
                    Result.success(userEntity.toDomainModel())
                } catch (e: HttpException) {
                    // ネットワークエラー時はキャッシュを返す
                    val cachedUser = localDataSource.getUser(userName)
                    if (cachedUser != null) {
                        Result.success(cachedUser.toDomainModel())
                    } else {
                        Result.failure(e)
                    }
                }
                
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }
    
    override suspend fun searchUsers(query: String): Result<List<User>> {
        return withContext(Dispatchers.IO) {
            try {
                val remoteUsers = remoteDataSource.searchUsers(query)
                val userEntities = remoteUsers.map { it.toEntity() }
                
                // 検索結果をキャッシュに保存
                localDataSource.saveUsers(userEntities)
                
                val domainUsers = userEntities.map { it.toDomainModel() }
                Result.success(domainUsers)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }
    
    override suspend fun getFavoriteUsers(): Result<List<User>> {
        return withContext(Dispatchers.IO) {
            try {
                val users = localDataSource.getAllUsers()
                val domainUsers = users.map { it.toDomainModel() }
                Result.success(domainUsers)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }
    
    override suspend fun addToFavorites(userName: String): Result<Unit> {
        return withContext(Dispatchers.IO) {
            try {
                // ユーザー情報を取得して保存(既存の場合は更新)
                getUser(userName, forceRefresh = true)
                Result.success(Unit)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }
    
    override suspend fun removeFromFavorites(userName: String): Result<Unit> {
        return withContext(Dispatchers.IO) {
            try {
                localDataSource.deleteUser(userName)
                Result.success(Unit)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }
    
    override suspend fun clearCache() {
        withContext(Dispatchers.IO) {
            val expirationTime = System.currentTimeMillis() - cacheExpirationTime
            localDataSource.clearExpiredCache(expirationTime)
        }
    }
    
    private fun isExpired(lastUpdated: Long): Boolean {
        return System.currentTimeMillis() - lastUpdated > cacheExpirationTime
    }
}

データ変換拡張関数

// GitHubUserResponse -> UserEntity
fun GitHubUserResponse.toEntity(): UserEntity {
    return UserEntity(
        id = id.toString(),
        userName = login,
        displayName = name ?: login,
        email = email,
        avatarUrl = avatarUrl,
        followersCount = followers,
        followingCount = following,
        bio = bio,
        location = location,
        createdAt = parseIsoDateTime(createdAt).toEpochSecond(ZoneOffset.UTC) * 1000
    )
}

// UserEntity -> User (ドメインモデル)
fun UserEntity.toDomainModel(): User {
    return User(
        id = id,
        userName = userName,
        displayName = displayName,
        email = email,
        avatarUrl = avatarUrl,
        followersCount = followersCount,
        followingCount = followingCount,
        bio = bio,
        location = location,
        createdAt = LocalDateTime.ofEpochSecond(createdAt / 1000, 0, ZoneOffset.UTC)
    )
}

private fun parseIsoDateTime(dateString: String): LocalDateTime {
    return LocalDateTime.parse(dateString.replace("Z", ""))
}

3. ViewModelでのRepository使用

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

data class UserUiState(
    val isLoading: Boolean = false,
    val user: User? = null,
    val errorMessage: String? = null,
    val isRefreshing: Boolean = false
)

class UserViewModel(
    private val userRepository: UserRepository
) : ViewModel() {
    
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUser(userName: String, forceRefresh: Boolean = false) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(
                isLoading = !forceRefresh,
                isRefreshing = forceRefresh,
                errorMessage = null
            )
            
            userRepository.getUser(userName, forceRefresh).fold(
                onSuccess = { user ->
                    _uiState.value = _uiState.value.copy(
                        isLoading = false,
                        isRefreshing = false,
                        user = user,
                        errorMessage = null
                    )
                },
                onFailure = { error ->
                    _uiState.value = _uiState.value.copy(
                        isLoading = false,
                        isRefreshing = false,
                        errorMessage = error.message
                    )
                }
            )
        }
    }
    
    fun refreshUser(userName: String) {
        loadUser(userName, forceRefresh = true)
    }
    
    fun clearError() {
        _uiState.value = _uiState.value.copy(errorMessage = null)
    }
}

4. 依存性注入(DI)の設定

Hiltを使用した場合

@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    
    @Provides
    @Singleton
    fun provideGitHubService(): GitHubService {
        return Retrofit.Builder()
            .baseUrl("https://api.github.com/")
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
            .create(GitHubService::class.java)
    }
    
    @Provides
    @Singleton
    fun provideUserDatabase(@ApplicationContext context: Context): UserDatabase {
        return Room.databaseBuilder(
            context,
            UserDatabase::class.java,
            "user_database"
        ).build()
    }
    
    @Provides
    fun provideUserDao(database: UserDatabase): UserDao = database.userDao()
    
    @Provides
    @Singleton
    fun provideUserRemoteDataSource(
        apiService: GitHubService
    ): UserRemoteDataSource = UserRemoteDataSourceImpl(apiService)
    
    @Provides
    @Singleton
    fun provideUserLocalDataSource(
        userDao: UserDao
    ): UserLocalDataSource = UserLocalDataSourceImpl(userDao)
    
    @Provides
    @Singleton
    fun provideUserRepository(
        remoteDataSource: UserRemoteDataSource,
        localDataSource: UserLocalDataSource
    ): UserRepository = UserRepositoryImpl(remoteDataSource, localDataSource)
}

@HiltViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {
    // 実装は上記と同じ
}

5. 包括的なテスト戦略

Repository のテスト

@RunWith(MockitoJUnitRunner::class)
class UserRepositoryImplTest {
    
    @Mock
    private lateinit var remoteDataSource: UserRemoteDataSource
    
    @Mock
    private lateinit var localDataSource: UserLocalDataSource
    
    private lateinit var repository: UserRepositoryImpl
    
    @Before
    fun setup() {
        repository = UserRepositoryImpl(
            remoteDataSource = remoteDataSource,
            localDataSource = localDataSource,
            cacheExpirationTime = TimeUnit.MINUTES.toMillis(30)
        )
    }
    
    @Test
    fun `getUser should return cached data when not expired`() = runTest {
        // Given
        val userName = "testuser"
        val cachedUser = createTestUserEntity().copy(
            userName = userName,
            lastUpdated = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(15)
        )
        `when`(localDataSource.getUser(userName)).thenReturn(cachedUser)
        
        // When
        val result = repository.getUser(userName)
        
        // Then
        assertTrue(result.isSuccess)
        assertEquals(userName, result.getOrNull()?.userName)
        verify(remoteDataSource, never()).getUser(any())
    }
    
    @Test
    fun `getUser should fetch from remote when cache expired`() = runTest {
        // Given
        val userName = "testuser"
        val expiredUser = createTestUserEntity().copy(
            userName = userName,
            lastUpdated = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
        )
        val remoteUser = createTestGitHubUserResponse().copy(login = userName)
        
        `when`(localDataSource.getUser(userName)).thenReturn(expiredUser)
        `when`(remoteDataSource.getUser(userName)).thenReturn(remoteUser)
        
        // When
        val result = repository.getUser(userName)
        
        // Then
        assertTrue(result.isSuccess)
        verify(remoteDataSource).getUser(userName)
        verify(localDataSource).saveUser(any())
    }
    
    @Test
    fun `getUser should return cached data on network error`() = runTest {
        // Given
        val userName = "testuser"
        val cachedUser = createTestUserEntity().copy(userName = userName)
        
        `when`(localDataSource.getUser(userName)).thenReturn(null, cachedUser)
        `when`(remoteDataSource.getUser(userName)).thenThrow(HttpException(Response.error<Any>(404, "".toResponseBody())))
        
        // When
        val result = repository.getUser(userName, forceRefresh = true)
        
        // Then
        assertTrue(result.isSuccess)
        assertEquals(userName, result.getOrNull()?.userName)
    }
    
    private fun createTestUserEntity(): UserEntity {
        return UserEntity(
            id = "1",
            userName = "testuser",
            displayName = "Test User",
            email = "test@example.com",
            avatarUrl = "https://example.com/avatar.jpg",
            followersCount = 10,
            followingCount = 5,
            bio = "Test bio",
            location = "Test Location",
            createdAt = System.currentTimeMillis()
        )
    }
    
    private fun createTestGitHubUserResponse(): GitHubUserResponse {
        return GitHubUserResponse(
            id = 1,
            login = "testuser",
            avatarUrl = "https://example.com/avatar.jpg",
            name = "Test User",
            email = "test@example.com",
            followers = 10,
            following = 5,
            bio = "Test bio",
            location = "Test Location",
            createdAt = "2023-01-01T00:00:00Z"
        )
    }
}

ViewModel のテスト

@RunWith(MockitoJUnitRunner::class)
class UserViewModelTest {
    
    @Mock
    private lateinit var userRepository: UserRepository
    
    private lateinit var viewModel: UserViewModel
    
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()
    
    @Before
    fun setup() {
        Dispatchers.setMain(UnconfinedTestDispatcher())
        viewModel = UserViewModel(userRepository)
    }
    
    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }
    
    @Test
    fun `loadUser should update UI state with success result`() = runTest {
        // Given
        val userName = "testuser"
        val expectedUser = createTestUser().copy(userName = userName)
        `when`(userRepository.getUser(userName, false)).thenReturn(Result.success(expectedUser))
        
        // When
        viewModel.loadUser(userName)
        
        // Then
        val uiState = viewModel.uiState.value
        assertFalse(uiState.isLoading)
        assertEquals(expectedUser, uiState.user)
        assertNull(uiState.errorMessage)
    }
    
    @Test
    fun `loadUser should update UI state with error result`() = runTest {
        // Given
        val userName = "testuser"
        val errorMessage = "Network error"
        `when`(userRepository.getUser(userName, false)).thenReturn(Result.failure(Exception(errorMessage)))
        
        // When
        viewModel.loadUser(userName)
        
        // Then
        val uiState = viewModel.uiState.value
        assertFalse(uiState.isLoading)
        assertNull(uiState.user)
        assertEquals(errorMessage, uiState.errorMessage)
    }
    
    private fun createTestUser(): User {
        return User(
            id = "1",
            userName = "testuser",
            displayName = "Test User",
            email = "test@example.com",
            avatarUrl = "https://example.com/avatar.jpg",
            followersCount = 10,
            followingCount = 5,
            bio = "Test bio",
            location = "Test Location",
            createdAt = LocalDateTime.now()
        )
    }
}

まとめ

Repositoryパターンの利点

  1. 単一責任原則: データアクセスロジックの分離
  2. 依存性の逆転: 上位層が抽象に依存
  3. テスタビリティ: モックしやすい設計
  4. 柔軟性: データソースの変更が容易
  5. キャッシュ戦略: 効率的なデータ管理
  6. エラーハンドリング: 統一された例外処理

実装のベストプラクティス

  • インターフェース分離: Repository、DataSourceを適切に抽象化
  • レイヤー分離: ドメインモデル、DTOモデル、Entityの使い分け
  • エラーハンドリング: Resultパターンによる安全な戻り値
  • キャッシュ戦略: 適切な有効期限とフォールバック機能
  • 依存性注入: DIコンテナによる疎結合な設計

このRepositoryパターンを理解し実装することで、大規模で保守性の高いAndroidアプリケーションの基盤を構築できます。次回は、これらのアーキテクチャパターンを活用して、実際のLLMアプリケーションのUI/UXを具体的に設計・実装していきます。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?