はじめに
みなさんこんにちは!
最近のAndroid開発って、Jetpack ComposeとKotlinが完全に主流になって、私が開発を始めた頃とは本当に様変わりしましたよね。特に状態管理の考え方が根本的に変わって、XMLベースの開発からほぼ完全に移行している感じがします。
私自身、この数年でいろいろなアーキテクチャパターンを試してきましたが、2025年時点で最も実用的だと感じている構成について、実際のプロジェクトで使っている実装例と一緒に整理してみたいと思います。
この記事では、MVVM + UseCase + Repository パターンを中心に書いていますが、なぜこの構成に落ち着いたのかという経緯も含めてお話しできればと思います。
対象読者
- Androidアプリ開発の基本はある程度分かる方
- Jetpack Composeを少し触ったことがある方
- アーキテクチャパターンで迷っている方
- 現場で使える実装が知りたい方
なぜアーキテクチャパターンが必要なのか
私も最初の頃は、「Activityにすべて書けば早いじゃん」って思ってました。実際、小さなアプリならそれでも十分だったりするんですよね。でも、アプリが複雑になってくると、こんな問題にぶつかるようになります:
- コードがぐちゃぐちゃに: 1つのクラスに何でもかんでも書いちゃって、後で読むのが辛い
- テストが書けない: UIとロジックがごちゃまぜで、どうやってテストすればいいか分からない
- 修正が怖い: ちょっと変更するだけで、どこに影響するか分からない
- 使い回しできない: 他の画面で同じロジックを使いたいのに、UI依存で切り出せない
私も過去に「なんでこんなコード書いたんだ過去の自分...」って頭を抱えたことが何度もありました。そんな経験を経て、やっぱり最初からちゃんと責任を分離しておくのが大事だなって実感しています。
MVVM + UseCase + Repository
各レイヤーの責任
レイヤー | 責任 |
---|---|
UI | ユーザーインターフェースの表示とユーザーイベントの処理 |
ViewModel | UI関連の状態管理とUseCaseの呼び出し |
UseCase | ビジネスロジックのカプセル化 |
Repository | データアクセスの抽象化と複数DataSourceの管理 |
DataSource | 特定のデータソース(API、DB等)への直接アクセス |
実装例で理解する各レイヤー
実際のユーザー情報取得機能を例に、各レイヤーがどんな感じになるか見てみましょう。
例では、他の画面からuserIdが渡されると、userIdに紐づくユーザー情報を画面に表示します。
データの流れは下記の通りです。
- UI 層でuserIdが他の画面から渡される
- ViewModel、UseCase、Repository 層にuserIdを渡していく
- DataSource 層でDBまたはAPIからuserIdに紐づくユーザー情報を取得する
- Repository、UseCase、ViewModel、UI層にユーザー情報を返していく
- UI層でユーザー情報と元にユーザ名やメールアドレスを表示する
1. データクラスの定義
まず最初に、アプリ全体で使うデータの「型」を定義します。ここでは2種類のクラスを定義しています。
Userクラス: アプリで扱うユーザー情報そのものを表現するデータクラスです。@Entity
アノテーションを付けることで、Roomデータベースのテーブルとして使えるようになります。データベースに保存するときも、API通信で受け取るときも、このクラスを使います。
UserUiState: UI(画面)の状態を表現するsealed classです。sealed classを使うことで、状態を「読み込み中」「成功」「エラー」の3つに限定できます。when式で状態を分岐するとき、すべてのパターンを網羅していることをコンパイラがチェックしてくれるので、状態の扱い漏れを防げます。
@Entity(tableName = "users")
data class User(
@PrimaryKey val id: String,
val name: String,
val email: String
)
sealed class UserUiState {
object Loading : UserUiState()
data class Success(val user: User) : UserUiState()
data class Error(val message: String) : UserUiState()
}
2. UI 層
UI層は、ユーザーに見える画面そのものを担当します。Jetpack Composeを使って、宣言的にUIを構築します。
この層の役割は、ViewModelの状態を監視して、それに応じたUIを表示することです。「状態がLoadingならローディング表示、Successならデータ表示、Errorならエラー表示」というように、状態に応じて画面を切り替えます。
実装のポイント:
- collectAsState(): StateFlowをComposeのStateに変換。ViewModelの状態が変わると自動的に再描画される
- hiltViewModel(): Hiltによって自動的にViewModelインスタンスを取得
- Composable関数の分割: UserContent、ErrorContentのように、画面を小さなコンポーネントに分割することで、再利用性とテストのしやすさが向上
UIはViewModelの状態を「見るだけ」で、直接データを取得したり加工したりしません。この分離のおかげで、UIのテストが書きやすく、デザイン変更の影響範囲も限定的になります。
@Composable
fun UserScreen(
userId: String,
viewModel: UserViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
when (uiState) {
is UserUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is UserUiState.Success -> {
UserContent(user = uiState.user)
}
is UserUiState.Error -> {
ErrorContent(
message = uiState.message,
onRetry = { viewModel.loadUser(userId) }
)
}
}
}
@Composable
private fun UserContent(user: User) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = user.name,
style = MaterialTheme.typography.headlineMedium
)
Text(
text = user.email,
style = MaterialTheme.typography.bodyLarge
)
}
}
3. ViewModel 層
ViewModel層は、UI(画面)の状態を管理する場所です。「今、画面は読み込み中なのか、データ表示中なのか、エラー表示中なのか」といった状態を保持し、UI側に公開します。
この層の最大の特徴は、画面回転などでActivityが再生成されても、データが保持されることです。ユーザーが画面を回転させたときに、また最初からローディングが始まる...みたいな悲しいことが起きません。
実装のポイント:
- StateFlowで状態を公開: UI側はこのFlowをcollectして、状態変化に応じて画面を再描画する
-
private/publicの使い分け:
_uiState
はprivate(ViewModel内でのみ更新可能)、uiState
はpublic(UI側は読み取り専用) - viewModelScope: ViewModelが破棄されると自動的にキャンセルされるCoroutineスコープ。これのおかげで、画面を閉じたときの処理忘れを防げる
-
Hiltでの依存性注入:
@HiltViewModel
と@Inject
により、UseCaseが自動的に注入される
ViewModelは「UIに関する状態管理」に集中し、ビジネスロジックはUseCaseに任せる、という責任の分離が重要です。
@HiltViewModel
class UserViewModel @Inject constructor(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
getUserUseCase(userId)
.onSuccess { user ->
_uiState.value = UserUiState.Success(user)
}
.onFailure { error ->
_uiState.value = UserUiState.Error(
error.message ?: "Unknown error occurred"
)
}
}
}
}
4. UseCase 層
UseCase層は、「アプリで実現したい1つの機能」を表現する場所です。「ユーザー情報を取得する」という1つの業務処理(ユースケース)が、1つのクラスになります。
この層の役割は、ビジネスロジックをカプセル化することです。例えば「userIdが空文字だったらエラーにする」といった、業務ルールに関わる判定をここに書きます。ViewModelがこういったロジックを直接持つと、同じロジックを別の画面でも使いたいときにコードが重複してしまいます。
実装のポイント:
-
operator fun invoke(): この記法を使うことで、
getUserUseCase(userId)
のように関数のように呼び出せる - 単一責任: 1つのUseCaseは1つのことだけをする。複雑な処理なら複数のUseCaseに分割
- ビジネスルールの集約: 入力値の検証、データの加工、複数Repositoryの組み合わせなど
小規模なアプリならUseCaseを省略してViewModelから直接Repositoryを呼んでも問題ありませんが、ビジネスロジックが複雑になってきたら、この層を導入すると整理しやすくなります。
class GetUserUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(userId: String): Result<User> {
// ここにビジネスロジックを記述
if (userId.isBlank()) {
return Result.failure(IllegalArgumentException("User ID cannot be blank"))
}
return userRepository.getUser(userId)
}
}
5. Repository 層
Repository層は、複数のDataSourceを束ねて、データ取得の戦略を決める場所です。「まずキャッシュを見て、なければAPIから取る」といったロジックをここに書きます。
この層の最大の役割は、データの取得元を上位層(UseCaseやViewModel)から隠蔽することです。UseCaseからは「getUserを呼べばデータが取れる」としか見えず、「それがDBから来たのか、APIから来たのか」を意識する必要がありません。
実装のポイント:
- 複数のDataSourceを組み合わせる: ローカルとリモートを使い分け、キャッシュ戦略を実装
- エラーハンドリング: try-catchでエラーを捕捉し、Result型で成功/失敗を表現
- Result型の使用: Kotlinの標準ライブラリのResultを使うことで、成功時のデータとエラーを型安全に扱える
この例では、「キャッシュが有効ならローカルから返す、そうでなければAPIから取得してキャッシュに保存」という一般的なキャッシュ戦略を実装しています。
interface UserRepository {
suspend fun getUser(userId: String): Result<User>
}
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(userId: String): Result<User> {
return try {
// まずローカルから取得を試行
val cachedUser = localDataSource.getUser(userId)
if (cachedUser != null && !isExpired(cachedUser)) { // isExpiredの実装は省略
Result.success(cachedUser)
} else {
// リモートから取得してローカルに保存
val user = remoteDataSource.getUser(userId)
localDataSource.saveUser(user)
Result.success(user)
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
6. DataSource 層
DataSource層は、実際にデータを取得・保存する場所です。「データベースから読む」「APIサーバーから取得する」といった、データソースごとの具体的な処理を担当します。
この層のポイントは、データソースごとにクラスを分けることです。リモート(API)とローカル(DB)で別々のクラスにすることで、後から「キャッシュのロジックを変えたい」とか「APIの仕様が変わった」といった場合でも、他の部分に影響を与えずに修正できます。
ここでは以下の4つを定義しています:
- UserRemoteDataSource: API通信でデータを取得するインターフェース
- UserLocalDataSource: ローカルDBでデータを読み書きするインターフェース
- UserDao: Roomデータベースへの実際のアクセス(SQL操作)
- 実装クラス: 各インターフェースの具体的な実装
interfaceで定義しておくことで、テスト時にモック(ダミー)に差し替えやすくなるのも大きなメリットです。
interface UserRemoteDataSource {
suspend fun getUser(userId: String): User
}
interface UserLocalDataSource {
suspend fun getUser(userId: String): User?
suspend fun saveUser(user: User)
}
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserById(userId: String): User?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User)
}
class UserRemoteDataSourceImpl(
private val userService: UserService
) : UserRemoteDataSource {
override suspend fun getUser(userId: String): User {
return userService.getUser(userId)
}
}
class UserLocalDataSourceImpl(
private val userDao: UserDao
) : UserLocalDataSource {
override suspend fun getUser(userId: String): User? {
return userDao.getUserById(userId)
}
override suspend fun saveUser(user: User) {
userDao.insertUser(user)
}
}
@Database(entities = [User::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
まとめ
いろいろ試してきた結果、2025年現在ではMVVM + UseCase + Repositoryパターンが一番使いやすいなって感じています。
この構成の良いところ:
- ✅ それぞれの役割がはっきりしてる
- ✅ テストが書きやすい
- ✅ Jetpack Composeとの相性が抜群
- ✅ 学習コストもそこまで高くない
- ✅ ライブラリのサポートも充実してる
大事なのは、プロジェクトの規模に合わせて柔軟に考えることだと思います。小さなアプリならUseCaseを省略しても全然問題ないし、複雑な状態管理が必要になったらMVIを検討するのもありですよね。