2
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】I/O境界はRepositoryに閉じ込めよ

Last updated at Posted at 2025-09-03

結論

I/O境界はRepositoryに閉じ込め、
UI(や任意の上位層)はI/OやDispatcherを知らないようにする。

I/Oについて / 前提

ここでは「計算ではなく、外部とのやりとりで遅延が発生する処理」とする。

特別な対応が必要な理由

1. 時間が読めない

  • CPU内の計算(足し算・掛け算など)はナノ秒レベルで予測できる
  • I/Oは外部要因(ネットワーク遅延・ディスク速度)でミリ秒〜秒単位に跳ね上がる

2. スレッドを塞ぐ危険

  • Thread.sleep()のように、待ち時間がそのままスレッド占有になる
  • AndroidではUIスレッドでやると「ANR(アプリ応答なし)」を招く

3. キャンセルや並行性の制御が必須

  • 「待たされてる間に画面が閉じる」なんてよくある
  • Coroutineのsuspendは、この「途中で中断してキャンセルできる」ための道具

具体的にどうするか / 原則

1. Dispatcherの直書き禁止

Dispatchers.IO等をコードにベタ書きしない。なぜならテストで差し替えできず、並行性の制御が不能になるから。Google公式も「Dispatchersはハードコードするな。注入(置換可能)にせよ」と明言している。さらに「suspend関数はmain-safe(メインスレッドから安全に呼べる)であるべき」とも。つまり、メインから呼ばれても内部で適切にwithContext(...)で移動する責任は“呼ばれ側”にある。

2. I/Oのスレッド切替はRepositoryの中で行う

上位(UIなど)でwithContext(IO)を書くのではなく、Repository側がwithContext(io)でスレッドを切り替える。コルーチンのスコープ所有はライフサイクル層(例:ViewModel)に任せ、Repositoryは“中断しうる処理”をsuspendでmain-safeに提供する。

3. Repositoryはsuspendを公開するべき

「呼び出し側がスレッドを決めるべき」という反論もあるが、内部事情(IO/CPU負荷の配分)を知らない呼び出し側が最適なディスパッチを選ぶのは困難。Repository側でI/O→CPUへの切り替えを適切に制御し、main-safeを保証するほうが安全。

4. テスト容易性は置換可能なDispatcherから

テストではTest/StandardTestDispatcherを渡すだけで、仮想時間・進行制御・同期を思い通りに扱えるようになる。

コードサンプル

Repository

// Domain境界
interface ArticleRepository {
    suspend fun fetch(
        id: String, 
        io: CoroutineDispatcher, 
        cpu: CoroutineDispatcher,
    ): Article
}

// Data実装(I/O境界はここだけ)
class ArticleRepositoryImpl(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val mapper: ArticleMapper
) : ArticleRepository {

    override suspend fun fetch(
        id: String,
        io: CoroutineDispatcher,
        cpu: CoroutineDispatcher,
    ): Article = withContext(io) {
        // 1) ネットワークI/O
        val dto = api.getArticle(id)
        // 2) 重いパースやマッピングはCPUバウンド
        val entity = withContext(cpu) { mapper.dtoToEntity(dto) }
        // 3) DB I/O
        dao.upsert(entity)
        // 4) Domain変換
        mapper.entityToDomain(entity)
    }
}

UI側

Dispatcherを渡すだけでwithContext()を書かない。

class ArticleViewModel(
    private val repo: ArticleRepository
) : ViewModel() {
    private val _state = MutableStateFlow<Article?>(null)
    val state: StateFlow<Article?> = _state

    fun load(id: String) = viewModelScope.launch {
        // UIはI/OもDispatcherも知らない
        val article = repo.fetch(
            id = id,
            io = Dispatchers.IO, // ← 呼び出し側で“渡す”だけ(直書きはここでのみ)
            cpu = Dispatchers.Default,
        )
        _state.value = article
    }
}

※フレームワークでのDI注入に慣れているなら、引数ではなく“コンストラクタ注入”でも同じ効果が得られる。要はハードコードしなければ良い。

テスト

Dispatcherの差し替えが簡単で、テストがしやすい。

@OptIn(ExperimentalCoroutinesApi::class)
class ArticleRepositoryImplTest {

    private val testIo = StandardTestDispatcher()
    private val testCpu = StandardTestDispatcher()

    @Test
    fun fetch_maps_and_persists() = runTest {
        val repo = ArticleRepositoryImpl(fakeApi, fakeDao, mapper)

        val article = repo.fetch("1", io = testIo, cpu = testCpu)

        // verify: 呼び出し順や変換の正しさを検証
        // fakeDao.assertUpsertCalled()
        // assertThat(article.title).isEqualTo("...")
    }
}

まとめ

  • I/O境界 = Repository
  • UI は I/O・Dispatcherを一切知らない
  • テストのためにDispatcherは引数で渡す

関連する記事

  • Android 公式:Coroutines Best Practices — Dispatchers をハードコードせず注入/“suspend は main-safe に”

  • ProAndroidDev:Repository 層と Coroutines — スコープはライフサイクル層で管理、IO 切替はデータ層で withContext

  • Craig Russell:Repository は suspend を公開すべきか — 呼び出し側に決めさせるより、内部事情を踏まえたスレッド選択で main-safe を保証

2
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
2
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?