Kusakabe Pages

KMPアプリのアーキテクチャとDIについての考察 (2026-07-18)

1. JiuJitsuScoreBoard KMP の現在のアーキテクチャ

現在のアーキテクチャは、「Google公式のAndroidアーキテクチャガイドライン」と「クリーンアーキテクチャ(軽量版)」を組み合わせた MVVM + UDF(単方向データフロー) を採用している。

graph TB
    subgraph "composeApp (Presentation)"
        App["App.kt"]
        HS["HomeScreen"]
        MS["MatchScreen(STANDALONE)"]
        subgraph "UI Components"
            SBD["ScoreBoardDisplay\n(表示部)"]
            SC["ScoreController\n(操作部)"]
            TC["TimerControls"]
            EM["EndMatchDialog"]
        end
        VM["MatchViewModel"]
    end

    subgraph "shared (Domain)"
        MM["MatchManager"]
        MT["MatchTimer"]
        IR["IbjjfRule"]
    end

    App --> HS
    App --> MS
    MS --> SBD
    MS --> SC
    SC --> TC
    SC --> EM
    MS --> VM
    VM --> MM
    MM --> MT
    MM --> IR

    style SBD fill:#FF9800,color:#fff
    style SC fill:#2196F3,color:#fff
    style MM fill:#607D8B,color:#fff

3つのレイヤー

UDF(単方向データフロー)のイメージ

ユーザーの操作が下へ流れ、状態が上へ流れる構造。

// ドメインレイヤー (MatchManager.kt)
class MatchManager {
    // 状態のストリーム(川)
    private val _state = MutableStateFlow(MatchState())
    val state: StateFlow<MatchState> = _state.asStateFlow()

    // イベントを受け取って状態を更新
    fun addPoint() {
        _state.value = _state.value.copy(points = _state.value.points + 2)
    }
}

// UIレイヤー (MatchScreen.kt)
@Composable
fun MatchScreen(viewModel: MatchViewModel) {
    // 状態を購読して画面を自動更新
    val state by viewModel.state.collectAsState()
    
    Button(onClick = { viewModel.onAddPoint() }) {
        Text("Points: ${state.points}")
    }
}

2. アプリ内DBとデータレイヤー

KMPでのDB技術選定(Room vs SQLDelight の判断基準)

1. Room Multiplatform (コードファースト) Kotlinのコードにアノテーションを付けてSQLを生成させる。Android開発者にお馴染み。

// Roomのコード例
@Entity(tableName = "match_history")
data class MatchHistory(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val winnerName: String
)

@Dao
interface MatchHistoryDao {
    @Query("SELECT * FROM match_history")
    fun getAll(): Flow<List<MatchHistory>>
}

2. SQLDelight (SQLファースト) .sq ファイルに生のSQLを書き、そこからKotlinコードを自動生成させる。KMPでの圧倒的な実績がある。

-- SQLDelightのコード例 (MatchHistory.sq)
CREATE TABLE match_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    winnerName TEXT NOT NULL
);

selectAll:
SELECT * FROM match_history;

補足:Realm からの移行との違い(SQLiteの互換性)

Room も SQLDelight も、裏側で動いているのは全く同じ SQLite.db ファイル)である。 RealmのようなNoSQLからRoom(SQL)への移行は「地獄の移行作業」になるが、Room ⇔ SQLDelight 間の移行はデータベースファイルの引っ越しが不要なため、比較的低コストで乗り換え可能。


3. クリーンアーキテクチャとDI(依存性の注入)

現状のDI実装 (手動DI)

現在はライブラリを使わず、コンストラクタで依存を注入している。

// 手動DIの例
class MatchViewModel(
    private val rule: MatchRule = IbjjfRule() // デフォルト引数で注入
) : ViewModel() { ... }

DIライブラリの導入について

初期フェーズは手動DIで十分だが、将来的に「別団体ルールの追加」などで依存関係が増えたタイミングでライブラリを導入すべき。 KMPでは Koin がデファクトスタンダードとなっている(HiltはKMPでは動かせないため)。

// Koinを使ったDIの例
val appModule = module {
    // IbjjfRuleをシングルトンとして登録
    single<MatchRule> { IbjjfRule() }
    factory { MatchViewModel(get()) }
}

// 使う側
val viewModel: MatchViewModel by inject()

4. KMPにおけるHTTP通信

KMPの commonMain ではRetrofitは利用できないため、JetBrains公式の Ktor Client を利用する。エンジンを各OSで切り替えることができる。

// Ktor Clientのコード例
val client = HttpClient {
    install(ContentNegotiation) {
        json() // JSONパースを自動化
    }
}

// 実際の通信(suspend関数内)
suspend fun fetchTournamentInfo(): TournamentInfo {
    return client.get("https://api.example.com/tournament").body()
}

5. KMPにおける非同期処理とリアルタイム通信

FireStoreのようなリアクティブな仕組みや非同期処理は、KMPでは Kotlin Coroutines / Flow が標準。

単発の非同期処理 (Coroutines)

コールバックを使わず、上から下へ直感的に書ける。

// Coroutinesの例
viewModelScope.launch {
    // ネットワーク通信などの重い処理
    val data = fetchTournamentInfo() 
    updateUi(data) // 通信完了後に実行される
}

リアルタイムな連続通信 (Flow)

時間経過とともに流れてくるデータのストリーム。本アプリのスコア管理 (MatchManagerStateFlow) にも使われている仕組み。

// Flowの例
fun observeTimer(): Flow<Int> = flow {
    var time = 0
    while (true) {
        emit(time) // 1秒ごとに時間を流す
        delay(1000)
        time++
    }
}

// 受け取る側
observeTimer().collect { currentTime ->
    println("現在の時間: $currentTime")
}

参考資料(一次情報)

本記事で解説した各技術・アーキテクチャの公式ドキュメント(一次情報)は以下の通りです。