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]Jetpack Compose MVI Orbit + Clean Architecture

Posted at

image.png

はじめに

前回はOrbitのコアDSLについてまとめましたが、今回は該当プロジェクトのアーキテクチャをクリーンアーキテクチャにリファクタリングした過程をまとめました。

🎯 核心目標

  • Domain Layerの完全な独立性を確保
  • MVIパターンで単方向データフロー

🛠 技術スタック

• Clean Architecture (Multi-Module)
• MVI Pattern with Orbit
• Hilt (Dependency Injection)
• Room + Flow (Local Database)
• Retrofit + Moshi (Network)
• Kotlin Coroutines

1. アーキテクチャ構造設計

📁 モジュール構造

project/
├── app/                   # Applicationモジュール
│   └── di/                # グローバルDI設定
├── domain/                # ビジネスロジック (純粋Kotlin)
│   ├── model/             # ドメインモデル
│   ├── repository/        # Repositoryインターフェース
│   └── usecase/           # UseCaseインターフェース
├── data/                  # データレイヤー
│   ├── datasource/        # DataSourceインターフェース & 実装
│   ├── local/             # Room関連
│   ├── remote/            # Retrofit関連
│   ├── repository/        # Repository実装体
│   ├── usecase/           # UseCase実装体 
│   ├── mapper/            # データ変換
│   └── di/                # DIモジュール
└── presentation/          # UIレイヤー
    ├── home/              # ホーム画面
    └── detail/            # 詳細画面

🎨 依存関係

核心原則:

  • Domainは何にも依存しない(純粋Kotlin)
  • DataPresentationはDomainにのみ依存
  • Appですべてを接続

クリーンアーキテクチャとGoogleアーキテクチャは同じようで違う感じ

1.クリーンアーキテクチャは関心事の分離依存性の規則を強調する汎用的なソフトウェア設計原則
2.Google推奨アーキテクチャはこの原則をAndroid環境に合わせて実装する実践ガイド
つまり、Googleアーキテクチャはクリーンアーキテクチャ原則 + ViewModel、RepositoryのようなJetpack(AAC)ライブラリを活用

2. Domain Layer

🎯 Domain Layerはビジネスロジックの核心
Android Frameworkや外部ライブラリに依存しない純粋なKotlinコードのみで構成される。

2.1 Domain Model

package com.example.domain.model

// アプリ全体で使用する純粋なデータ構造
data class Message(
    val id: Int,
    val title: String,
    val body: String
)

💡 なぜ別途Domain Modelが必要なのか?

  • API応答(DTO)やDBエンティティ(Entity)と分離
  • ビジネスロジックに最適化された構造
  • 外部依存性がなく不変性(val)を維持する

2.2 Repositoryインターフェース

package com.example.domain.repository

interface MessageRepository {
    fun observeMessages(): Flow<List<Message>>
    fun observeMessage(id: Int): Flow<Message?>
    suspend fun refreshMessages(): Result<Unit>
    suspend fun deleteMessage(id: Int): Result<Unit>
    suspend fun clearAllMessages(): Result<Unit>
}

💡 設計ポイント:

  • Flowでリアルタイムデータ監視
  • Result<T>で成功/失敗を明確に表現
  • suspend関数で非同期処理

2.3 UseCaseインターフェース

核心: UseCaseはDomainにインターフェースのみ定義し、実装体はDataモジュールに位置

package com.example.domain.usecase

// インターフェースのみDomainに定義
interface ObserveMessagesUseCase {
    operator fun invoke(): Flow<List<Message>>
}

interface DeleteMessageUseCase {
    suspend operator fun invoke(id: Int): Result<Unit>
}

interface ClearAndReloadMessagesUseCase {
    suspend operator fun invoke(): Result<Unit>
}

💡 このようにする理由:

  1. Domainの純粋性維持(Hilt依存性除去)
  2. DI機能は依然として活用可能
  3. 責任を分離しながら各チームが自分の領域のみ管理することも容易になる

3. Data Layer

🎯 Data LayerはDomainのインターフェースを実際に実装する階層

3.1 DataSourceパターン

Local DataSource (Room)

interface MessageLocalDataSource {
    fun observeAll(): Flow<List<MessageEntity>>
    fun observeById(id: Int): Flow<MessageEntity?>
    suspend fun insertAll(messages: List<MessageEntity>)
    suspend fun deleteById(id: Int)
    suspend fun deleteAll()
}

class MessageLocalDataSourceImpl @Inject constructor(
    private val messageDao: MessageDao
) : MessageLocalDataSource {
    override fun observeAll() = messageDao.getAllFlow()
    override fun observeById(id: Int) = messageDao.findFlow(id)
    override suspend fun insertAll(messages: List<MessageEntity>) = 
        messageDao.insertAll(messages)
    override suspend fun deleteById(id: Int) = messageDao.deleteById(id)
    override suspend fun deleteAll() = messageDao.deleteAll()
}

Remote DataSource (Retrofit)

interface MessageRemoteDataSource {
    suspend fun getMessages(): List<MessageDto>
    suspend fun getMessage(id: Int): MessageDto
    suspend fun deleteMessage(id: Int)
}

class MessageRemoteDataSourceImpl @Inject constructor(
    private val api: MessageApi
) : MessageRemoteDataSource {
    override suspend fun getMessages() = api.getMessages()
    override suspend fun getMessage(id: Int) = api.getMessage(id)
    override suspend fun deleteMessage(id: Int) = api.deleteMessage(id)
}

3.2 Mapper - データ変換

package com.example.data.mapper

// DTO → Entity (API応答をDBに保存)
fun MessageDto.toEntity() = MessageEntity(
    id = this.id,
    title = this.title,
    body = this.body
)

// Entity → Domain (DBからUIへ)
fun MessageEntity.toDomain() = Message(
    id = this.id,
    title = this.title,
    body = this.body
)

💡 Mapperを別途作成する理由:

  1. 各レイヤー間の依存性分離
  2. データ構造変更に柔軟に対応
  3. 変換ロジックを一箇所で管理

3.3 Repository実装

class MessageRepositoryImpl @Inject constructor(
    private val localDataSource: MessageLocalDataSource,
    private val remoteDataSource: MessageRemoteDataSource
) : MessageRepository {

    // ローカルDBを監視してリアルタイム更新
    override fun observeMessages(): Flow<List<Message>> {
        return localDataSource.observeAll()
            .map { entities -> entities.map { it.toDomain() } }
    }

    // サーバーから最新データを取得してローカルに保存
    override suspend fun refreshMessages(): Result<Unit> {
        return runCatching {
            val remoteMessages = remoteDataSource.getMessages()
            val entities = remoteMessages.map { it.toEntity() }
            localDataSource.insertAll(entities)
        }
    }

    // Optimistic Update: ローカル先削除後サーバーリクエスト
    override suspend fun deleteMessage(id: Int): Result<Unit> {
        return runCatching {
            localDataSource.deleteById(id)
            remoteDataSource.deleteMessage(id)
        }
    }
}

💡 パターン:

  • Single Source of Truth: ローカルDBが唯一のデータソース
  • Optimistic Update: 高速UXのためUIを先に更新
  • Resultタイプ: 成功/失敗を明確に表現

3.4 UseCase実装体

🎯 重要: UseCase実装体はDataモジュールに位置

package com.example.data.usecase

class ObserveMessagesUseCaseImpl @Inject constructor(
    private val repository: MessageRepository
) : ObserveMessagesUseCase {
    override fun invoke(): Flow<List<Message>> {
        return repository.observeMessages()
            .distinctUntilChanged()  // 重複イベント除去
    }
}

class DeleteMessageUseCaseImpl @Inject constructor(
    private val repository: MessageRepository
) : DeleteMessageUseCase {
    override suspend fun invoke(id: Int): Result<Unit> {
        return repository.deleteMessage(id)
    }
}

// 複雑なビジネスロジック例
class ClearAndReloadMessagesUseCaseImpl @Inject constructor(
    private val repository: MessageRepository
) : ClearAndReloadMessagesUseCase {
    override suspend fun invoke(): Result<Unit> {
        return runCatching {
            repository.clearAllMessages().getOrThrow()
            delay(1000)  // UXのための遅延
            repository.refreshMessages().getOrThrow()
        }
    }
}

4. Presentation Layer

Presentation Layerは**MVIパターン(Orbit)**を使用して単方向データフローを実装

4.1 MVI構成要素

// State: UI状態
data class HomeState(
    val items: List<Message> = emptyList(),
    val isRefreshing: Boolean = false,
    val isLoading: Boolean = false,
    val isEmpty: Boolean = true
)

// Intent: ユーザーアクション
sealed interface HomeIntent {
    data object Load : HomeIntent
    data object Refresh : HomeIntent
    data class Delete(val id: Int) : HomeIntent
    data object ClearAndReload : HomeIntent
}

// SideEffect: 一回性イベント
sealed interface HomeSideEffect {
    data class ShowSnackBar(val message: String) : HomeSideEffect
    data class ShowError(val message: String) : HomeSideEffect
    data class NavigateToDetail(val messageId: Int) : HomeSideEffect
}

💡 設計原則:

  • State: 不変オブジェクト、UIレンダリングに必要なすべての情報を含む
  • Intent: sealed interfaceで型安全性を確保し、単純なアクションはobject使用
  • SideEffect: スナックバー、ナビゲーションなど一回性イベント

4.2 ViewModel with Orbit

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val observeMessagesUseCase: ObserveMessagesUseCase,
    private val refreshMessagesUseCase: RefreshMessagesUseCase,
    private val deleteMessageUseCase: DeleteMessageUseCase,
    private val clearAndReloadMessagesUseCase: ClearAndReloadMessagesUseCase
) : ContainerHost<HomeState, HomeSideEffect>, ViewModel() {

    override val container = container<HomeState, HomeSideEffect>(
        initialState = HomeState()
    ) {
        /**
         * repeatOnSubscription: UIが購読するたびに自動実行
         * リソース効率性: 画面が表示されない時はデータベース監視を中断してバッテリーとメモリ節約
         * 再購読処理: 画面が再表示される時に自動的に最新データを再購読
         */
        repeatOnSubscription {
            observeMessagesUseCase().collect { messages ->
                reduce {
                    state.copy(
                        items = messages,
                        isEmpty = messages.isEmpty()
                    )
                }
            }
        }
    }

    init {
        loadInitialData()
    }

    // Intent処理エントリーポイント
    fun onIntent(intent: HomeIntent) = when (intent) {
        HomeIntent.Load -> loadInitialData()
        HomeIntent.Refresh -> onRefresh()
        is HomeIntent.Delete -> onDeleteMessage(intent.id)
        HomeIntent.ClearAndReload -> onClearAndReload()
    }

    // 初期データロード
    private fun loadInitialData() = intent {
        reduce { state.copy(isLoading = true) }
        
        refreshMessagesUseCase()
            .onSuccess {
                postSideEffect(HomeSideEffect.ShowSnackBar("Data loaded"))
            }
            .onFailure { error ->
                postSideEffect(HomeSideEffect.ShowError(error.message ?: "Error"))
            }
        
        reduce { state.copy(isLoading = false) }
    }

    // Optimistic Update
    fun onDeleteMessage(id: Int) = intent {
        val previousItems = state.items
        
        // 即座にUI更新
        reduce {
            state.copy(items = state.items.filter { it.id != id })
        }
        
        deleteMessageUseCase(id)
            .onSuccess {
                postSideEffect(HomeSideEffect.ShowSnackBar("Deleted"))
            }
            .onFailure {
                // 失敗時ロールバック
                reduce { state.copy(items = previousItems) }
                postSideEffect(HomeSideEffect.ShowError("Failed"))
            }
    }
}

💡 Orbitの核心概念:

  • intent { }: 状態変更とサイドエフェクトを安全に処理
  • reduce { }: 現在の状態を新しい状態に変更
  • postSideEffect(): 一回性イベント発生

4.3 Compose UI

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
    onItemClick: (Int) -> Unit,
    viewModel: HomeViewModel = hiltViewModel()
) {
    val state by viewModel.container.stateFlow.collectAsState()
    val snackBarHostState = remember { SnackbarHostState() }

    // SideEffect処理
    LaunchedEffect(Unit) {
        viewModel.container.sideEffectFlow.collectLatest { sideEffect ->
            when (sideEffect) {
                is HomeSideEffect.ShowSnackBar -> {
                    snackBarHostState.showSnackbar(sideEffect.message)
                }
                is HomeSideEffect.ShowError -> {
                    snackBarHostState.showSnackbar(
                        message = sideEffect.message,
                        actionLabel = "Retry"
                    )
                }
                is HomeSideEffect.NavigateToDetail -> {
                    onItemClick(sideEffect.messageId)
                }
            }
        }
    }

    Scaffold(
        snackbarHost = { SnackbarHost(snackBarHostState) },
        topBar = {
            TopAppBar(
                title = { Text("Messages") },
                actions = {
                    IconButton(onClick = { viewModel.onRefresh() }) {
                        Icon(Icons.Default.Refresh, contentDescription = "Refresh")
                    }
                }
            )
        }
    ) { padding ->
        PullToRefreshBox(
            isRefreshing = state.isRefreshing,
            onRefresh = { viewModel.onRefresh() },
            modifier = Modifier.fillMaxSize().padding(padding)
        ) {
            when {
                state.isLoading -> LoadingContent()
                state.isEmpty -> EmptyContent(
                    onLoadClick = { viewModel.onIntent(HomeIntent.Load) }
                )
                else -> MessageList(
                    messages = state.items,
                    onItemClick = onItemClick,
                    onDelete = { id -> 
                        viewModel.onIntent(HomeIntent.Delete(id)) 
                    }
                )
            }
        }
    }
}

@Composable
private fun MessageList(
    messages: List<Message>,
    onItemClick: (Int) -> Unit,
    onDelete: (Int) -> Unit
) {
    LazyColumn {
        items(
            items = messages,
            key = { it.id }  // パフォーマンス最適化
        ) { message ->
            MessageItem(
                message = message,
                onClick = { onItemClick(message.id) },
                onDelete = { onDelete(message.id) }
            )
        }
    }
}

Compose設計ポイント:

  • LaunchedEffect(Unit): 画面進入時に一度だけ実行
  • collectAsState(): FlowをStateに変換
  • keyパラメータ: リストパフォーマンス最適化

5. DI設定

5.1 DataSourceバインディング

@Module
@InstallIn(SingletonComponent::class)
abstract class DataSourceModule {

    @Binds
    @Singleton
    abstract fun bindMessageLocalDataSource(
        impl: MessageLocalDataSourceImpl
    ): MessageLocalDataSource

    @Binds
    @Singleton
    abstract fun bindMessageRemoteDataSource(
        impl: MessageRemoteDataSourceImpl
    ): MessageRemoteDataSource
}

5.2 UseCaseバインディング

⭐ 核心: DataモジュールでUseCaseをバインディングします。

@Module
@InstallIn(ViewModelComponent::class)  // ViewModelライフサイクル
abstract class UseCaseModule {

    @Binds
    abstract fun bindObserveMessagesUseCase(
        impl: ObserveMessagesUseCaseImpl
    ): ObserveMessagesUseCase

    @Binds
    abstract fun bindDeleteMessageUseCase(
        impl: DeleteMessageUseCaseImpl
    ): DeleteMessageUseCase

    @Binds
    abstract fun bindClearAndReloadMessagesUseCase(
        impl: ClearAndReloadMessagesUseCaseImpl
    ): ClearAndReloadMessagesUseCase
}

なぜViewModelComponentなのか?

  • ViewModelが生成されるたびに新しいUseCaseインスタンス
  • メモリ効率的
  • ViewModelとライフサイクル一致

5.3 Database & Network設定

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideAppDatabase(
        @ApplicationContext context: Context
    ): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "message_database"
        )
        .fallbackToDestructiveMigration()
        .build()
    }

    @Provides
    fun provideMessageDao(database: AppDatabase) = database.messageDao()
}

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideMoshi() = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()

    @Provides
    @Singleton
    fun provideOkHttpClient() = OkHttpClient.Builder()
        .addInterceptor(HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        })
        .connectTimeout(30, TimeUnit.SECONDS)
        .build()

    @Provides
    @Singleton
    fun provideRetrofit(moshi: Moshi, okHttpClient: OkHttpClient) = 
        Retrofit.Builder()
            .baseUrl("https://your-api.com/")
            .addConverterFactory(MoshiConverterFactory.create(moshi))
            .client(okHttpClient)
            .build()

    @Provides
    @Singleton
    fun provideMessageApi(retrofit: Retrofit) = 
        retrofit.create(MessageApi::class.java)
}

6. 核心ポイント

✅ アーキテクチャ設計原則

1. Domainの独立性

✅ 純粋Kotlinのみ使用
✅ インターフェースのみ定義
✅ Android/Hilt依存性除去

2. UseCase実装体の位置

📁 domain/usecase/          ← インターフェース
📁 data/usecase/            ← 実装体 ⭐

🎯UseCaseインターフェースと実装体を分離する理由:

  1. Domainの純粋性: Android Frameworkに依存しない
  2. DI活用: Hiltのすべての機能を使用しながらも純粋性維持
  3. DIP準守: 上位モジュールが下位モジュールに依存しない
  4. テスト容易性: 各レイヤーを独立的にテスト可能
  5. 拡張性: 複数の実装体を状況に応じて交換可能
  6. チーム協業: モジュール別独立開発可能

核心原則:
Domainは「何をするのか」(What) - インターフェース
Dataは「どのようにするのか」(How) - 実装体

3. 単方向データフロー (MVI)

User Action → Intent → ViewModel → State → UI
                          ↓
                     SideEffect

📊 パフォーマンス最適化

  1. distinctUntilChanged(): 不要なリコンポジション防止
  2. ViewModelScoped: メモリ効率性
  3. LazyColumn key: リストパフォーマンス向上
  4. Single Source of Truth: データ同期問題解決

🚀 拡張可能性

  • ✅ 機能別モジュール分離可能
  • ✅ 各レイヤー独立テスト
  • ✅ チーム並列開発可能
  • ✅ 外部変更に強い

📌 まとめ

この構造は

明確な責任分離: 各レイヤーが自分の役割にのみ集中
テスト容易性: すべての階層を独立的にテスト可能
保守性: 変更事項が他のレイヤーに影響を与えない
拡張性: 新機能追加が容易
チーム協業: レイヤー別並列開発可能

🚀 さらに進む

追加学習方向

現在の業務でもクリーンアーキテクチャとOrbitを使用していますが、該当プロジェクトをマルチモジュールに分けながら再度勉強になりました。次はArrowライブラリについてまとめてみようと思います。


📚 参考資料

💻 全体コード

GitHub : https://github.com/GEUN-TAE-KIM/Mvi_Orbit_Study

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?