はじめに
今日は、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パターンの利点
- 単一責任原則: データアクセスロジックの分離
- 依存性の逆転: 上位層が抽象に依存
- テスタビリティ: モックしやすい設計
- 柔軟性: データソースの変更が容易
- キャッシュ戦略: 効率的なデータ管理
- エラーハンドリング: 統一された例外処理
実装のベストプラクティス
- インターフェース分離: Repository、DataSourceを適切に抽象化
- レイヤー分離: ドメインモデル、DTOモデル、Entityの使い分け
- エラーハンドリング: Resultパターンによる安全な戻り値
- キャッシュ戦略: 適切な有効期限とフォールバック機能
- 依存性注入: DIコンテナによる疎結合な設計
このRepositoryパターンを理解し実装することで、大規模で保守性の高いAndroidアプリケーションの基盤を構築できます。次回は、これらのアーキテクチャパターンを活用して、実際のLLMアプリケーションのUI/UXを具体的に設計・実装していきます。