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ライブラリの学習

Last updated at Posted at 2025-07-06

234524352.PNG

Orbit MVIとともに始まった新たなスタート

最近、転職し開発に携わる機会を得ました。
現在のプロジェクトはすでにアーキテクチャ設計が完了しており、各機能の実装フェーズに入っています。

アーキテクチャは、ビジネスロジック層にClean Architectureを採用しUI層にはMVIパターンとOrbitライブラリをベースに開発が進められています。
Jetpack Composeは以前からオンライン講座などを通して間接的に触れてはいましたが実際の業務でMVIパターンやOrbitライブラリを扱うのは初めてで特に見慣れない構造や概念に戸惑うことも多く不安を感じた場面もありました。

気づけば1ヶ月が経ち少しずつ慣れてきたものの、今でも時折混乱する部分があり、理解を深めて整理するためにChatGPTを活用してサンプルを作成し各機能がどのように動作しているのか分析しながら学習を続けています。
忘れてしまったときにいつでも見返せるように、また同じように悩んでいる方の参考にもなればと思い、こうして記録として残すことにしました。

📌 なぜ Orbit MVI ?

  • シンプルな DSL:intent { reduce { ... } } だけで状態・副作用処理が完結
  • Orbit MVIは Intent → Reducer → State の明確な単方向の流れを基礎とし、状態と副作用の責任をしっかり分離
  • testContainer() を用いて、コルーチンや状態のテストが簡潔に行える
  • Jetpack Compose の宣言型UIアプローチと Orbit MVI のアーキテクチャ(Model–View–Intent)は非常に相性が良い
  • 状態(State)を中心に画面が構成されるため、Composeとの統合がスムーズ

とはいえ、現時点ではMVVM + Clean ArchitectureがAndroid開発の代表的な標準構成であることも事実です。
この構成に関する理解や学習もおろそかにせず、引き続き意識していくことが大切だと感じています。

🎯 Orbit MVIの主なメリット

1. 単方向のデータの流れ

ユーザーのアクション → Intent → Reducer → State → UI

  • データが一方向に流れるためデバッグしやすく動きも予測しやすいです。

2. 明確な状態管理

data class HomeState(
    val items: List<Message> = emptyList(),
    val isRefreshing: Boolean = false
)
  • 画面のすべての状態を1つの data class でまとめて管理します
  • UIはその state だけを見て表示すればOKです

3. テストしやすい

@Test
fun `リフレッシュ時にロディング状態が正しく変更されるかどうかをテスト`() = runTest {
    val viewModel = HomeViewModel(mockRepository)
    
    viewModel.test {
        // given
        expectInitialState()
        
        // when
        viewModel.onIntent(HomeIntent.Refresh)
        
        // then
        expectState { copy(isRefreshing = true) }
        expectState { copy(isRefreshing = false) }
    }
}

4. Composeとの完璧な調和

@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val state by viewModel.container.stateFlow.collectAsState()
    
    // state変更時に自動的にrecompositionが発生
    when {
        state.isRefreshing -> LoadingScreen()
        state.items.isEmpty() -> EmptyScreen()
        else -> MessageList(state.items)
    }
}

このような利点があるため、特にJetpack ComposeにおいてOrbit MVIがXML時代よりも多く使われるようになっているようです。

📌 プロジェクト設定

1. libs.versions


  • ライブラリバージョンカタログに必要なライブラリコードを作成

2. build.gradle.kts

  • Gradleにアプリモジュールを設定

なお、HiltおよびRoomを使用するためにkaptおよびkspを宣言しますが、kaptよりもkspを使用することが主流のトレンドです。kaptはJVMベースで、kspはKotlinベースであるため、パフォーマンスと安定性の観点からkspがkaptを徐々に置き換えており、Googleでもkspを推奨しています。

📘 “Migrate from kapt to KSP” (Android 公式)

📌 プロジェクト設定

🧱 依存性注入設定

1. Network モジュール

Retrofit および Moshiを初期化して API 通信のためのオブジェクトを提供

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

    @Provides
    @Singleton
    fun provideMoshi(): Moshi =
        Moshi.Builder()
            .add(KotlinJsonAdapterFactory())  // Kotlin data classシリアライゼーションサポート
            .build()

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(
                HttpLoggingInterceptor().apply {
                    level = HttpLoggingInterceptor.Level.BODY
                }
            )
            .build()

    @Provides
    @Singleton
    fun provideRetrofit(
        moshi: Moshi,
        okHttpClient: OkHttpClient
    ): Retrofit = Retrofit.Builder()
        .baseUrl("https://qiita.com/") // 実際に使用するAPI URLを作成する必要があります
        .addConverterFactory(MoshiConverterFactory.create(moshi))
        .client(okHttpClient)
        .build()

    @Provides
    @Singleton
    fun provideMessageApi(retrofit: Retrofit): MessageApi =
        retrofit.create(MessageApi::class.java)
}
  • パーシング部分はMoshiを使用しましたが、以前はGsonを多く使用していましたが、現在はKotlin特化とより少ないメモリ使用量のためMoshiを多く使用するとのことです。
  • HttpLoggingInterceptor : リクエスト/レスポンスログ設定 = BODY: 全体のリクエスト/レスポンス内容

2. Repository モジュール

APIDAOを注入して実際の Repository オブジェクトを提供

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    
    @Provides
    @Singleton
    fun provideMessageRepository(
        api: MessageApi,
        dao: MessageDao
    ): MessageRepository = MessageRepository(api, dao)
}

3. Database モジュール

Roomデータベースインスタンスを提供し AppDatabase, MessageDaoがここで注入

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "message_database"
        )
        .build()

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

4. Application class 設定

@HiltAndroidApp 
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
    }
}

📌 データレイヤー実装

🎯 Model 定義

Domain Model

アプリで使用するメッセージドメインでありモデルUIに露出されるデータ形態

data class Message(
    val id: Int,
    val title: String,
    val body: String
)

💡 ドメインモデルの特徴

  • 外部依存性なし(Android/Room/Retrofit等)
  • ビジネスロジックの核心データ構造
  • 不変性維持(valのみ使用)

🌐 Remote Data Source

1. API インターフェース

interface MessageApi {
    @GET("posts")
    suspend fun getMessages(): List<MessageDto>
    
    @GET("posts/{id}")
    suspend fun getMessage(@Path("id") id: Int): MessageDto
}

💡 Retrofit アノテーション

  • @GET: HTTP GET リクエスト
  • suspend: コルーチン関数(非同期処理)
  • @Path: URL パラメータ

2. DTO (Data Transfer Object)

ネットワークレスポンス用データクラス

data class MessageDto(
    val id: Int,
    val title: String,
    val body: String
)

💡 DTOを別途作成する理由

  1. API仕様変更時のドメインモデル保護
  2. ネットワークレスポンス形態とアプリ内部構造の分離
  3. シリアライゼーション/デシリアライゼーション最適化

🔄 Data Mapper

// DTO → Entity 変換
// API結果(DTO)をDBに保存できるよう変換
fun MessageDto.toEntity(): MessageEntity = MessageEntity(
    id = this.id,
    title = this.title,
    body = this.body
)

// Entity → Domain Model 変換
// DBから取得したデータをUIに適した形態(Domain Model)に変換
fun MessageEntity.toDomain(): Message = Message(
    id = this.id,
    title = this.title,
    body = this.body
)

💡 Mapper関数を作成する理由

  1. 各レイヤー間の依存性分離
  2. データ構造変更に対する柔軟性
  3. 変換ロジックの中央化

📦 Repository 実装

class MessageRepository @Inject constructor(
    private val api: MessageApi,
    private val dao: MessageDao
) {
    // ローカルデータベースからメッセージリストを取得
    val messages: Flow<List<Message>> =
        dao.getAllFlow()
            .map { entities -> entities.map { it.toDomain() } }
            .distinctUntilChanged()  // 重複発出防止
    
    // 特定IDのメッセージを取得
    fun message(id: Int): Flow<Message?> =
        dao.findFlow(id).map { it?.toDomain() }
    
    // リモートサーバーからデータを取得してローカルDBに保存
    suspend fun refresh(): Result<Unit> = runCatching {
        val remoteMessages = api.getMessages()
        val entities = remoteMessages.map { it.toEntity() }
        dao.insertAll(entities)
    }
}

💡 Repositoryパターンの利点

  1. データソース抽象化(API/DB区別なしに使用)
  2. キャッシング戦略実装(オフライン優先、オンライン優先など)
  3. テスト容易性(Mock Repositoryを簡単に生成)
  4. 単一責任原則(データアクセスロジックのみ担当)

💡 Result 使用理由

  1. 成功/失敗を明確に表現
  2. 例外処理を呼び出し側で決定
  3. 関数型プログラミングスタイル

📌 MVIパターン実装

🎯 MVI Contract

// 画面のすべての状態を一つのオブジェクトで管理
data class HomeState(
    val items: List<Message> = emptyList(),  // メッセージリスト
    val isRefreshing: Boolean = false        // リフレッシュ状態
)

// ユーザーが実行できるすべてのアクション定義
sealed interface HomeIntent {
    data object Load : HomeIntent      // 初期ロード
    data object Refresh : HomeIntent   // リフレッシュ
}

// 一度だけ実行される外部効果
sealed interface HomeSideEffect {
    data class Error(val message: String) : HomeSideEffect
    data object NavigateToDetail : HomeSideEffect
}

💡 State 設計原則

  1. 不変性維持 (data class + val)
  2. UIレンダリングに必要なすべての情報を含む
  3. デフォルト値設定で初期状態を明確化
  4. 単一責任原則(一つの画面の状態のみ管理)

💡 Intent 設計原則

  1. sealed interfaceで型安全性確保
  2. ユーザーアクションを明確に表現
  3. パラメータが必要な場合はdata classを使用
  4. 単純なアクションはobjectを使用

💡 SideEffect 使用事例

  • スナックバー表示
  • ナビゲーション
  • トーストメッセージ
  • ダイアログ表示
  • 外部アプリ実行など

🎯 ViewModel実装

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val repository: MessageRepository
) : ContainerHost<HomeState, HomeSideEffect>, ViewModel() {

    // Orbit Container初期化
    override val container = container<HomeState, HomeSideEffect>(
        initialState = HomeState()
    )

    init {
        // アプリ開始時にローカルデータの監視開始
        observeLocalData()
    }

    // ローカルデータベース変更事項の監視
    private fun observeLocalData() = intent {
        repository.messages.collect { messageList ->
            reduce { state.copy(items = messageList) }
        }
    }

    // Intent処理
    fun onIntent(intent: HomeIntent) = when (intent) {
        HomeIntent.Load -> refresh()
        HomeIntent.Refresh -> refresh()
    }

    // リフレッシュ処理
    private fun refresh() = intent {
        // ローディング状態開始
        reduce { state.copy(isRefreshing = true) }
        
        // リモートデータ取得
        val result = repository.refresh()
        
        // 失敗時エラーSideEffect発生
        result.onFailure { throwable ->
            postSideEffect(
                HomeSideEffect.Error(
                    throwable.localizedMessage ?: "Unknown error occurred"
                )
            )
        }
        
        // ローディング状態終了
        reduce { state.copy(isRefreshing = false) }
    }
}

💡 ViewModelの役割

  1. UI状態管理(State)
  2. ユーザーアクション処理(Intent)
  3. ビジネスロジック実行
  4. 外部効果発生(SideEffect)

💡 intent { } ブロックの意味

  • 状態変更とサイドエフェクトを安全に処理
  • コルーチンスコープ提供
  • 例外処理自動化

💡 reduce { } ブロックの意味

  • 現在の状態を新しい状態に変更
  • 不変性を維持して状態アップデート
  • UI自動アップデートトリガー

🔄 MVIパターンのデータフロー

┌─────────────────┐     ┌──────────────┐     ┌─────────────────┐
│ ユーザーアクション│ ──▶│  Intent      │───▶│   ViewModel     │
│ (ボタンクリック等)│    │ (Load/Refresh)│    │ (ビジネスロジック)│
└─────────────────┘     └──────────────┘     └─────────────────┘
                                                       │
                                                       ▼
┌─────────────────┐     ┌──────────────┐     ┌─────────────────┐
│   UI アップデート│◀── │    State     │◀───│     Reducer     │
│ (Recomposition) │     │   (UI状態)   │     │    (状態変更)    │
└─────────────────┘     └──────────────┘     └─────────────────┘
        ▲                                            │
        │               ┌──────────────┐             │
        └────────────── │  SideEffect  │◀───────────┘
                        │  (外部効果)   │
                        └──────────────┘

📌 Compose UIとOrbitの連携

📄 UI構成 (Compose)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
    onItemClick: (Int) -> Unit,
    showTopBar: Boolean = false,
    viewModel: HomeViewModel = hiltViewModel()
) {
    // State監視 - 状態変更時に自動recomposition
    val state by viewModel.container.stateFlow.collectAsState()
    val snackbarHostState = remember { SnackbarHostState() }

    // SideEffect処理 - 一度だけ実行される外部効果
    LaunchedEffect(Unit) {
        viewModel.container.sideEffectFlow.collectLatest { sideEffect ->
            when (sideEffect) {
                is HomeSideEffect.Error -> {
                    snackbarHostState.showSnackbar(sideEffect.message)
                }
            }
        }
    }

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) },
        topBar = {
            if (showTopBar) {
                CenterAlignedTopAppBar(
                    title = { Text(stringResource(R.string.title_messages)) },
                    actions = {
                        IconButton(
                            onClick = { 
                                viewModel.onIntent(HomeIntent.Refresh) 
                            }
                        ) {
                            Icon(
                                Icons.Outlined.Refresh,
                                contentDescription = "リフレッシュ"
                            )
                        }
                    }
                )
            }
        }
    ) { paddingValues ->

        // State基盤のUIレンダリング
        when {
            state.isRefreshing -> {
                LoadingScreen(paddingValues)
            }
            
            state.items.isEmpty() -> {
                EmptyScreen(paddingValues)
            }
            
            else -> {
                MessageList(
                    messages = state.items,
                    onItemClick = onItemClick,
                    paddingValues = paddingValues
                )
            }
        }
    }
}

💡 Compose UI設計原則

  1. State基盤のレンダリング(when文で状態別UI分岐)
  2. 小さなComposable関数に分離
  3. パラメータを通じた依存性注入
  4. 不変データ使用

📌 実際の動作フロー

[ユーザー] リフレッシュボタンクリック
    ↓
[UI] onClick = { viewModel.onIntent(HomeIntent.Refresh) }
    ↓
[ViewModel] intent {
    reduce { state.copy(isRefreshing = true) }  // ローディング開始
    ↓
[Repository] refresh() 呼び出し
    ↓
[API] リモートサーバーからデータ取得
    ↓
[Repository] 受信データをローカルDBに保存
    ↓
[DAO] insertAll() → Room データベースアップデート
    ↓
[Repository] messages Flowから新データ発出
    ↓
[ViewModel] observeLocalData()でcollect
    ↓
[ViewModel] reduce { state.copy(items = messageList) }
    ↓
[UI] collectAsState()が新状態を受けてrecomposition
    ↓
[ViewModel] reduce { state.copy(isRefreshing = false) }  // ローディング終了
    ↓
[UI] ローディングインジケーターが消えて新データ表示

大体このような形でデータフローが動作することになります。

📌 まとめ

明確な状態管理리: 単一State オブジェクトでUI状態を一箇所で管理
予測可能なデータフロー: IntentReducerStateの単方向フロー
効果的な副作用処理: SideEffectを通じた一回性外部効果管理
Composeとの調和: 宣言型UIMVIパターンの自然な結合

🚀 さらに進む

追加学習方向

  1. Orbit 状態関連 DSL - reduce, postSideEffect など
  2. 複雑な状態管理 - 複数画面間状態共有
  3. ナビゲーション: ディープリンク、バックスタック管理
  4. テストパターン など
  5. ビジネスロジック Clean Architectureに変更`

このような形で追加的に継続学習していこうと思う。


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?