結論
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 を保証